diff --git a/docs/cli/overview.mdx b/docs/cli/overview.mdx index 6576ff7..1ff9754 100644 --- a/docs/cli/overview.mdx +++ b/docs/cli/overview.mdx @@ -11,6 +11,29 @@ npm install -g timberlogs-cli **Requirements:** Node.js 20 or later. +## REPL + +Running `timberlogs` with no arguments launches an interactive REPL session — a persistent prompt where you can run any command without reinvoking the binary. + +```bash +timberlogs +``` + +``` +▲ Timberlogs v1.0.0 ● Acme Corp +logs stats flows flows show whoami login logout config list help exit +──────────────────────────────────────────────────────────── +❯ logs --level error --from 24h +❯ stats --group-by source +❯ flows show checkout-abc-123 +``` + +Commands work exactly like the CLI — same flags, same output. Commands that produce a table (`logs`, `stats`, `flows show`) take over the full screen with keyboard navigation; press `q` to return to the prompt. + +**Builtins:** `help`, `clear`, `exit` + +**History:** Use `↑` / `↓` to navigate previous commands. History is saved to `~/.config/timberlogs/history.json`. + ## Authentication The CLI uses OAuth device flow — no API keys needed. @@ -106,8 +129,11 @@ timberlogs logs --level error | jq '.logs[].message' ## Config ```bash -timberlogs config list # Show current config -timberlogs config reset # Delete config file +timberlogs config list # Show current config +timberlogs config reset # Delete config file (prompts for confirmation) +timberlogs config reset --force # Delete without confirmation ``` +When running inside the REPL, `config reset` requires `--force` — interactive confirmation prompts are not supported in the REPL session. + Session data is stored at `~/.config/timberlogs/config.json` with `600` permissions. Override the directory with the `TIMBERLOGS_CONFIG_DIR` environment variable. diff --git a/src/commands/index.tsx b/src/commands/index.tsx index a966f8a..b5522ab 100644 --- a/src/commands/index.tsx +++ b/src/commands/index.tsx @@ -1,28 +1,5 @@ -import {Text, Box} from 'ink'; +import ReplView from '../components/ReplView.js'; export default function Index() { - return ( - - Timberlogs CLI v{CLI_VERSION} - - Usage: timberlogs {''} [options] - - Commands: - login Authenticate via browser - logout Remove stored session - whoami Show current auth status - logs Query and search logs - flows List all flows - flows show {''} View a flow timeline - stats Show log volume and distribution - config list Show current config - config reset Delete config file - - Global Flags: - --json Force JSON output - --verbose Show debug info - --version, -v Show version - --help, -h Show help - - ); + return ; } diff --git a/src/commands/login.tsx b/src/commands/login.tsx index 9e41d77..c87904e 100644 --- a/src/commands/login.tsx +++ b/src/commands/login.tsx @@ -1,7 +1,7 @@ import {Text, Box} from 'ink'; import Spinner from 'ink-spinner'; import {useState, useEffect} from 'react'; -import {exec} from 'node:child_process'; +import {spawn} from 'node:child_process'; import {z} from 'zod'; import {readConfig, writeConfig} from '../lib/config.js'; import {handleError} from '../lib/errors.js'; @@ -34,16 +34,13 @@ type TokenErrorResponse = { const POLL_INTERVAL_MS = 5000; function openBrowser(url: string): void { - const command = - process.platform === 'darwin' - ? `open "${url}"` - : process.platform === 'win32' - ? `start "${url}"` - : `xdg-open "${url}"`; - - exec(command, () => { - // Ignore errors — user can open URL manually - }); + if (process.platform === 'win32') { + spawn('cmd', ['/c', 'start', '', url], {windowsHide: true, detached: true, stdio: 'ignore'}); + } else if (process.platform === 'darwin') { + spawn('open', [url], {detached: true, stdio: 'ignore'}); + } else { + spawn('xdg-open', [url], {detached: true, stdio: 'ignore'}); + } } export default function Login({options}: Props) { diff --git a/src/commands/repl.tsx b/src/commands/repl.tsx new file mode 100644 index 0000000..e8bae09 --- /dev/null +++ b/src/commands/repl.tsx @@ -0,0 +1,5 @@ +import ReplView from '../components/ReplView.js'; + +export default function Repl() { + return ; +} diff --git a/src/components/FlowTimeline.tsx b/src/components/FlowTimeline.tsx index 43063f9..3b2d8a4 100644 --- a/src/components/FlowTimeline.tsx +++ b/src/components/FlowTimeline.tsx @@ -8,6 +8,7 @@ type Props = { stepCount: number; durationMs: number; hasErrors: boolean; + onBack?: () => void; }; function formatDuration(ms: number): string { @@ -26,10 +27,11 @@ function formatDataInline(data: Record | undefined, maxWidth: n return result.length > maxWidth ? result.slice(0, maxWidth - 1) + '…' : result; } -export default function FlowTimeline({flowId, logs, stepCount, durationMs, hasErrors}: Props) { +export default function FlowTimeline({flowId, logs, stepCount, durationMs, hasErrors, onBack}: Props) { useInput((input) => { if (input === 'q') { - process.exit(0); + if (onBack) onBack(); + else process.exit(0); } }); diff --git a/src/components/LogTable.tsx b/src/components/LogTable.tsx index 7501b9a..e3aea65 100644 --- a/src/components/LogTable.tsx +++ b/src/components/LogTable.tsx @@ -8,9 +8,10 @@ type Props = { logs: LogEntry[]; pagination: LogsResponse['pagination']; filterSummary?: string; + onBack?: () => void; }; -export default function LogTable({logs, pagination, filterSummary}: Props) { +export default function LogTable({logs, pagination, filterSummary, onBack}: Props) { const [cursor, setCursor] = useState(0); const [expanded, setExpanded] = useState(null); @@ -33,7 +34,8 @@ export default function LogTable({logs, pagination, filterSummary}: Props) { } else if (key.return) { setExpanded(cursor); } else if (input === 'q') { - process.exit(0); + if (onBack) onBack(); + else process.exit(0); } }); diff --git a/src/components/ReplOutput.tsx b/src/components/ReplOutput.tsx new file mode 100644 index 0000000..96b8dcc --- /dev/null +++ b/src/components/ReplOutput.tsx @@ -0,0 +1,19 @@ +import {Box, Text} from 'ink'; +import type {ReactNode} from 'react'; + +export type ReplEntry = + | {kind: 'command'; input: string} + | {kind: 'output'; content: ReactNode} + | {kind: 'error'; message: string}; + +export default function ReplOutput({entry}: {entry: ReplEntry}) { + if (entry.kind === 'command') { + return ❯ {entry.input}; + } + + if (entry.kind === 'error') { + return ✗ {entry.message}; + } + + return {entry.content as ReactNode}; +} diff --git a/src/components/ReplPrompt.tsx b/src/components/ReplPrompt.tsx new file mode 100644 index 0000000..b51ebf0 --- /dev/null +++ b/src/components/ReplPrompt.tsx @@ -0,0 +1,67 @@ +import {Box, Text, useInput} from 'ink'; +import TextInput from 'ink-text-input'; +import {useState} from 'react'; + +type Props = { + history: string[]; + onSubmit: (input: string) => void; + disabled: boolean; +}; + +export default function ReplPrompt({history, onSubmit, disabled}: Props) { + const [value, setValue] = useState(''); + const [historyIndex, setHistoryIndex] = useState(-1); + const [draft, setDraft] = useState(''); + + useInput((_, key) => { + if (disabled) return; + if (key.upArrow) { + if (history.length === 0) return; + if (historyIndex === -1) { + setDraft(value); + const idx = history.length - 1; + setHistoryIndex(idx); + setValue(history[idx]!); + } else if (historyIndex > 0) { + const idx = historyIndex - 1; + setHistoryIndex(idx); + setValue(history[idx]!); + } + } else if (key.downArrow) { + if (historyIndex === -1) return; + if (historyIndex < history.length - 1) { + const idx = historyIndex + 1; + setHistoryIndex(idx); + setValue(history[idx]!); + } else { + setHistoryIndex(-1); + setValue(draft); + } + } + }); + + function handleChange(val: string) { + if (disabled) return; + setValue(val); + if (historyIndex !== -1) setHistoryIndex(-1); + } + + function handleSubmit(val: string) { + if (disabled) return; + setValue(''); + setHistoryIndex(-1); + setDraft(''); + onSubmit(val); + } + + return ( + + + {disabled ? ( + ... + ) : ( + + )} + + ); +} diff --git a/src/components/ReplView.tsx b/src/components/ReplView.tsx new file mode 100644 index 0000000..2b4ef2a --- /dev/null +++ b/src/components/ReplView.tsx @@ -0,0 +1,287 @@ +import {Box, Text} from 'ink'; +import {useState, useEffect, useCallback} from 'react'; +import type {ReactNode} from 'react'; +import {loadHistory, saveHistory} from '../lib/history.js'; +import {parseReplInput} from '../lib/replParser.js'; +import {parseFlags} from '../lib/flagParser.js'; +import {resolveToken} from '../lib/auth.js'; +import {createApiClient} from '../lib/api.js'; +import {readConfig, writeConfig, deleteConfig} from '../lib/config.js'; +import {options as logsOptions} from '../commands/logs.js'; +import {options as statsOptions} from '../commands/stats.js'; +import {options as flowsOptions} from '../commands/flows/index.js'; +import {options as flowShowOptions} from '../commands/flows/show.js'; +import ReplOutput from './ReplOutput.js'; +import ReplPrompt from './ReplPrompt.js'; +import WhoamiView from './commandViews/WhoamiView.js'; +import FlowsView from './commandViews/FlowsView.js'; +import StatsViewCmd from './commandViews/StatsViewCmd.js'; +import LogsView from './commandViews/LogsView.js'; +import FlowShowView from './commandViews/FlowShowView.js'; +import LoginView from './commandViews/LoginView.js'; +import type {ReplEntry} from './ReplOutput.js'; + +const COMMANDS_HINT = 'logs stats flows flows show whoami login logout config list help exit'; + +function ReplHeader({token, orgName}: {token: string | null; orgName: string | null}) { + const cols = process.stdout.columns || 80; + return ( + + + + + Timberlogs + v{CLI_VERSION} + + {token ? ( + {orgName ?? 'Authenticated'} + ) : ( + Not logged in + )} + + {COMMANDS_HINT} + {'─'.repeat(Math.min(cols, 60))} + + ); +} + +type Phase = + | {tag: 'idle'} + | {tag: 'running'; node: ReactNode} + | {tag: 'interactive'; node: ReactNode}; + +const MAX_ENTRIES = 200; + +const HELP_LINES = [ + 'Commands:', + ' logs [flags] Query and search logs', + ' stats [flags] Show log volume and stats', + ' flows [flags] List flows', + ' flows show View a flow timeline', + ' whoami Show auth status', + ' login Authenticate via browser', + ' logout Remove stored session', + ' config list Show config', + ' config reset [--force] Reset config', + '', + 'Builtins: help, clear, exit', + 'History: ↑↓ navigate', +]; + +export default function ReplView() { + const [entries, setEntries] = useState([]); + const [phase, setPhase] = useState({tag: 'idle'}); + const [history, setHistory] = useState([]); + const [token, setToken] = useState(null); + const [orgName, setOrgName] = useState(null); + + const fetchOrgName = useCallback(async (t: string) => { + try { + const client = createApiClient({token: t}); + const whoami = await client.get<{organizationName?: string}>('/v1/whoami'); + if (whoami.organizationName) setOrgName(whoami.organizationName); + } catch { + // silently fail — header shows "Authenticated" fallback + } + }, []); + + useEffect(() => { + setHistory(loadHistory()); + setToken(resolveToken()); + }, []); + + useEffect(() => { + if (!token) { setOrgName(null); return; } + void fetchOrgName(token); + }, [token]); + + function addEntry(entry: ReplEntry) { + setEntries(prev => [...prev, entry].slice(-MAX_ENTRIES)); + } + + function handleSubmit(input: string) { + const trimmed = input.trim(); + if (!trimmed) return; + + const newHistory = history[history.length - 1] === trimmed + ? history + : [...history, trimmed]; + setHistory(newHistory); + saveHistory(newHistory); + + const parsed = parseReplInput(trimmed); + + if (parsed.type === 'empty') return; + + addEntry({kind: 'command', input: trimmed}); + + if (parsed.type === 'builtin') { + handleBuiltin(parsed.name); + return; + } + + if (parsed.type === 'unknown') { + addEntry({kind: 'error', message: `Unknown command: ${parsed.input}. Type "help" for available commands.`}); + return; + } + + dispatch(parsed.name, parsed.tokens); + } + + function handleBuiltin(name: 'help' | 'exit' | 'clear') { + if (name === 'clear') { + setEntries([]); + return; + } + + if (name === 'exit') { + process.exit(0); + } + + addEntry({ + kind: 'output', + content: ( + + {HELP_LINES.map((line, i) => ( + {line} + ))} + + ), + }); + } + + function dispatch(name: string, tokens: string[]) { + const onDone = (output: ReactNode, interactive: boolean) => { + const newToken = resolveToken(); + setToken(newToken); + if (interactive) { + setPhase({tag: 'interactive', node: output}); + } else { + if (output !== null) { + addEntry({kind: 'output', content: output}); + } + setPhase({tag: 'idle'}); + } + }; + + const onError = (message: string) => { + setToken(resolveToken()); + addEntry({kind: 'error', message}); + setPhase({tag: 'idle'}); + }; + + const onBack = () => setPhase({tag: 'idle'}); + + if (name === 'login') { + setPhase({tag: 'running', node: }); + return; + } + + if (name === 'logout') { + const config = readConfig(); + if (config.sessionToken) { + delete config.sessionToken; + writeConfig(config); + setToken(null); + addEntry({kind: 'output', content: ✓ Logged out}); + } else { + addEntry({kind: 'output', content: Not logged in}); + } + + return; + } + + if (name === 'config list') { + const config = readConfig(); + addEntry({ + kind: 'output', + content: ( + + {'Authenticated: '} + + {config.sessionToken ? 'Yes' : 'No'} + + + ), + }); + return; + } + + if (name === 'config reset') { + if (!tokens.includes('--force')) { + addEntry({kind: 'error', message: 'Use `config reset --force` to confirm.'}); + return; + } + + deleteConfig(); + setToken(null); + addEntry({kind: 'output', content: ✓ Config reset successfully}); + return; + } + + if (!token) { + addEntry({kind: 'error', message: 'Not authenticated. Run `login` to get started.'}); + return; + } + + if (name === 'whoami') { + setPhase({tag: 'running', node: }); + return; + } + + if (name === 'logs') { + const result = parseFlags(tokens, logsOptions); + if (result.error) { addEntry({kind: 'error', message: result.error}); return; } + const flags = result.flags as Parameters[0]['flags']; + setPhase({tag: 'running', node: }); + return; + } + + if (name === 'stats') { + const result = parseFlags(tokens, statsOptions); + if (result.error) { addEntry({kind: 'error', message: result.error}); return; } + const flags = result.flags as Parameters[0]['flags']; + setPhase({tag: 'running', node: }); + return; + } + + if (name === 'flows') { + const result = parseFlags(tokens, flowsOptions); + if (result.error) { addEntry({kind: 'error', message: result.error}); return; } + const flags = result.flags as Parameters[0]['flags']; + setPhase({tag: 'running', node: }); + return; + } + + if (name === 'flows show') { + const result = parseFlags(tokens, flowShowOptions); + if (result.error) { addEntry({kind: 'error', message: result.error}); return; } + const flowId = result.positional[0]; + if (!flowId) { + addEntry({kind: 'error', message: 'Usage: flows show '}); + return; + } + + setPhase({tag: 'running', node: }); + return; + } + } + + if (phase.tag === 'interactive') { + return <>{phase.node}; + } + + const rows = process.stdout.rows || 24; + const visibleEntries = entries.slice(-(rows - 3)); + + return ( + + + {visibleEntries.map((entry, i) => ( + + ))} + {phase.tag === 'running' && phase.node} + + + ); +} diff --git a/src/components/StatsView.tsx b/src/components/StatsView.tsx index 385babc..1a0f19d 100644 --- a/src/components/StatsView.tsx +++ b/src/components/StatsView.tsx @@ -25,6 +25,7 @@ type Props = { warn: number; error: number; }>; + onBack?: () => void; }; function formatNumber(n: number): string { @@ -37,10 +38,11 @@ function renderBar(value: number, total: number, maxWidth: number): string { return '█'.repeat(filled) + '░'.repeat(maxWidth - filled); } -export default function StatsView({totals, period, comparison, groupBySource}: Props) { +export default function StatsView({totals, period, comparison, groupBySource, onBack}: Props) { useInput((input) => { if (input === 'q') { - process.exit(0); + if (onBack) onBack(); + else process.exit(0); } }); diff --git a/src/components/commandViews/FlowShowView.tsx b/src/components/commandViews/FlowShowView.tsx new file mode 100644 index 0000000..5590d6d --- /dev/null +++ b/src/components/commandViews/FlowShowView.tsx @@ -0,0 +1,55 @@ +import {useEffect} from 'react'; +import type {ReactNode} from 'react'; +import {createApiClient} from '../../lib/api.js'; +import {LogsResponseSchema} from '../../types/log.js'; +import FlowTimeline from '../FlowTimeline.js'; + +type Props = { + flowId: string; + token: string; + onBack: () => void; + onDone: (output: ReactNode, interactive: boolean) => void; + onError: (message: string) => void; +}; + +export default function FlowShowView({flowId, token, onBack, onDone, onError}: Props) { + useEffect(() => { + void run(); + }, []); + + async function run() { + try { + const client = createApiClient({token}); + const raw = await client.get('/v1/logs', {flowId, limit: 1000}); + const response = LogsResponseSchema.parse(raw); + const logs = response.logs; + + if (logs.length === 0) { + onError(`No logs found for flow ${flowId}`); + return; + } + + const stepCount = logs.length; + const durationMs = logs.length > 1 + ? new Date(logs[logs.length - 1]!.timestamp).getTime() - new Date(logs[0]!.timestamp).getTime() + : 0; + const hasErrors = logs.some(l => l.level === 'error'); + + onDone( + , + true, + ); + } catch (err) { + onError(err instanceof Error ? err.message : String(err)); + } + } + + return null; +} diff --git a/src/components/commandViews/FlowsView.tsx b/src/components/commandViews/FlowsView.tsx new file mode 100644 index 0000000..e5bf18b --- /dev/null +++ b/src/components/commandViews/FlowsView.tsx @@ -0,0 +1,111 @@ +import {useEffect} from 'react'; +import type {ReactNode} from 'react'; +import {Text, Box} from 'ink'; +import {z} from 'zod'; +import {createApiClient} from '../../lib/api.js'; +import {parseRelativeTime} from '../../lib/time.js'; + +const FlowSummarySchema = z.object({ + flowId: z.string(), + source: z.string().nullish(), + logCount: z.number(), + firstSeen: z.number(), + lastSeen: z.number(), +}); + +const FlowsResponseSchema = z.object({ + flows: z.array(FlowSummarySchema), + pagination: z.object({ + total: z.number().optional(), + offset: z.number(), + limit: z.number(), + hasMore: z.boolean(), + }), +}); + +type FlowsFlags = { + from: string; + to?: string; + source?: string; + limit: number; + offset: number; +}; + +type Props = { + flags: FlowsFlags; + token: string; + onDone: (output: ReactNode, interactive: boolean) => void; + onError: (message: string) => void; +}; + +function formatRelativeTime(timestamp: number): string { + const diff = Date.now() - timestamp; + const seconds = Math.floor(diff / 1000); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function getFlowName(flowId: string): string { + const lastDash = flowId.lastIndexOf('-'); + return lastDash > 0 ? flowId.slice(0, lastDash) : flowId; +} + +export default function FlowsView({flags, token, onDone, onError}: Props) { + useEffect(() => { + void run(); + }, []); + + async function run() { + try { + const client = createApiClient({token}); + const from = parseRelativeTime(flags.from); + const to = flags.to ? parseRelativeTime(flags.to) : undefined; + const raw = await client.get('/v1/flows', { + from, + to, + source: flags.source, + limit: flags.limit, + offset: flags.offset > 0 ? flags.offset : undefined, + }); + const data = FlowsResponseSchema.parse(raw); + + if (data.flows.length === 0) { + onDone(No flows found, false); + return; + } + + const nameW = 24; + const logsW = 6; + const sourceW = 16; + const timeW = 10; + + onDone( + + Flows{data.pagination.total != null ? ` (${data.pagination.total} total)` : ''} + {'NAME'.padEnd(nameW)}{'LOGS'.padEnd(logsW)}{'SOURCE'.padEnd(sourceW)}{'LAST SEEN'} + {data.flows.map(flow => ( + + {getFlowName(flow.flowId).slice(0, nameW - 1).padEnd(nameW)} + {String(flow.logCount).padEnd(logsW)} + {(flow.source ?? '-').slice(0, sourceW - 1).padEnd(sourceW)} + {formatRelativeTime(flow.lastSeen).padEnd(timeW)} + + ))} + {data.pagination.hasMore ? ( + Use --offset {flags.offset + flags.limit} to see more + ) : null} + , + false, + ); + } catch (err) { + onError(err instanceof Error ? err.message : String(err)); + } + } + + return null; +} diff --git a/src/components/commandViews/LoginView.tsx b/src/components/commandViews/LoginView.tsx new file mode 100644 index 0000000..5a31ee7 --- /dev/null +++ b/src/components/commandViews/LoginView.tsx @@ -0,0 +1,144 @@ +import {Text, Box} from 'ink'; +import Spinner from 'ink-spinner'; +import {useState, useEffect} from 'react'; +import type {ReactNode} from 'react'; +import {spawn} from 'node:child_process'; +import {readConfig, writeConfig} from '../../lib/config.js'; +import {API_URL} from '../../types/config.js'; + +type DeviceResponse = { + device_code: string; + user_code: string; + verification_uri: string; +}; + +type TokenResponse = { + access_token: string; + organization_name?: string; +}; + +type TokenErrorResponse = { + error: string; + error_description?: string; +}; + +const POLL_INTERVAL_MS = 5000; + +function openBrowser(url: string): void { + if (process.platform === 'win32') { + spawn('cmd', ['/c', 'start', '', url], {windowsHide: true, detached: true, stdio: 'ignore'}); + } else if (process.platform === 'darwin') { + spawn('open', [url], {detached: true, stdio: 'ignore'}); + } else { + spawn('xdg-open', [url], {detached: true, stdio: 'ignore'}); + } +} + +type Props = { + onDone: (output: ReactNode, interactive: boolean) => void; + onError: (message: string) => void; +}; + +export default function LoginView({onDone, onError}: Props) { + const [status, setStatus] = useState<'init' | 'polling' | 'done'>('init'); + const [userCode, setUserCode] = useState(''); + + useEffect(() => { + void startDeviceFlow(); + }, []); + + async function startDeviceFlow() { + let device: DeviceResponse; + try { + const response = await fetch(`${API_URL}/v1/auth/device`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + }); + if (!response.ok) { + const body = await response.text(); + throw new Error(`Failed to start device flow: ${response.status} ${body}`); + } + + device = (await response.json()) as DeviceResponse; + } catch (err) { + onError(err instanceof Error ? err.message : String(err)); + return; + } + + setUserCode(device.user_code); + setStatus('polling'); + + const verificationUrl = `${device.verification_uri}?code=${device.user_code}`; + openBrowser(verificationUrl); + + void pollForToken(device.device_code); + } + + async function pollForToken(deviceCode: string) { + while (true) { + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); + + let response: Response; + try { + response = await fetch(`${API_URL}/v1/auth/device/token`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({device_code: deviceCode}), + }); + } catch (err) { + onError(err instanceof Error ? err.message : String(err)); + return; + } + + if (response.ok) { + const token = (await response.json()) as TokenResponse; + const config = readConfig(); + config.sessionToken = token.access_token; + writeConfig(config); + setStatus('done'); + onDone( + + ✓ Authenticated successfully + {token.organization_name ? ( + ✓ Organization: {token.organization_name} + ) : null} + , + false, + ); + return; + } + + let errorBody: TokenErrorResponse; + try { + errorBody = (await response.json()) as TokenErrorResponse; + } catch { + onError(`Unexpected response: ${response.status}`); + return; + } + + if (errorBody.error === 'authorization_pending') continue; + + if (errorBody.error === 'expired_token') { + onError('Device code expired. Run `login` again.'); + return; + } + + onError(errorBody.error_description ?? errorBody.error); + return; + } + } + + if (status === 'init') return null; + if (status === 'done') return null; + + return ( + + + + {' '}Waiting for browser authentication... + + Your code: {userCode} + If the browser didn't open, visit the URL and enter the code above. + + ); +} diff --git a/src/components/commandViews/LogsView.tsx b/src/components/commandViews/LogsView.tsx new file mode 100644 index 0000000..2c3be1a --- /dev/null +++ b/src/components/commandViews/LogsView.tsx @@ -0,0 +1,88 @@ +import {useEffect} from 'react'; +import type {ReactNode} from 'react'; +import {createApiClient} from '../../lib/api.js'; +import {parseRelativeTime} from '../../lib/time.js'; +import {LogsResponseSchema} from '../../types/log.js'; +import LogTable from '../LogTable.js'; + +type LogsFlags = { + level?: 'debug' | 'info' | 'warn' | 'error'; + source?: string; + env?: string; + search?: string; + from: string; + to?: string; + limit: number; + offset: number; + 'user-id'?: string; + 'session-id'?: string; + 'flow-id'?: string; + dataset?: string; + verbose?: boolean; +}; + +type Props = { + flags: LogsFlags; + token: string; + onBack: () => void; + onDone: (output: ReactNode, interactive: boolean) => void; + onError: (message: string) => void; +}; + +export default function LogsView({flags, token, onBack, onDone, onError}: Props) { + useEffect(() => { + void run(); + }, []); + + async function run() { + try { + const client = createApiClient({token, verbose: flags.verbose}); + const from = parseRelativeTime(flags.from); + const to = flags.to ? parseRelativeTime(flags.to) : undefined; + + const params: Record = { + level: flags.level, + source: flags.source, + environment: flags.env, + from, + to, + limit: flags.limit, + offset: flags.offset > 0 ? flags.offset : undefined, + userId: flags['user-id'], + sessionId: flags['session-id'], + flowId: flags['flow-id'], + dataset: flags.dataset, + }; + + let raw: unknown; + if (flags.search) { + raw = await client.get('/v1/logs/search', {q: flags.search, ...params}); + } else { + raw = await client.get('/v1/logs', params); + } + + const response = LogsResponseSchema.parse(raw); + + const filterParts: string[] = []; + if (flags.level) filterParts.push(`level=${flags.level}`); + if (flags.source) filterParts.push(`source=${flags.source}`); + if (flags.env) filterParts.push(`env=${flags.env}`); + if (flags.search) filterParts.push(`search="${flags.search}"`); + const filterSummary = filterParts.length > 0 ? `(${filterParts.join(', ')})` : undefined; + + onDone( + , + true, + ); + } catch (err) { + onError(err instanceof Error ? err.message : String(err)); + } + } + + return null; +} diff --git a/src/components/commandViews/StatsViewCmd.tsx b/src/components/commandViews/StatsViewCmd.tsx new file mode 100644 index 0000000..1edb28c --- /dev/null +++ b/src/components/commandViews/StatsViewCmd.tsx @@ -0,0 +1,96 @@ +import {useEffect} from 'react'; +import type {ReactNode} from 'react'; +import {createApiClient} from '../../lib/api.js'; +import {parseRelativeTime} from '../../lib/time.js'; +import {StatsResponseSchema, StatsSummaryResponseSchema} from '../../types/log.js'; +import StatsView from '../StatsView.js'; + +type StatsFlags = { + from: string; + to?: string; + 'group-by': 'hour' | 'day' | 'source'; + source?: string; + env?: string; + dataset?: string; +}; + +type Props = { + flags: StatsFlags; + token: string; + onBack: () => void; + onDone: (output: ReactNode, interactive: boolean) => void; + onError: (message: string) => void; +}; + +function toDateString(ms: number): string { + return new Date(ms).toISOString().slice(0, 10); +} + +export default function StatsViewCmd({flags, token, onBack, onDone, onError}: Props) { + useEffect(() => { + void run(); + }, []); + + async function run() { + try { + const client = createApiClient({token}); + const fromMs = parseRelativeTime(flags.from); + const toMs = flags.to ? parseRelativeTime(flags.to) : Date.now(); + const fromDate = toDateString(fromMs); + const toDate = toDateString(toMs); + + const [statsResponse, summaryResponse] = await Promise.all([ + client.get('/v1/stats', { + from: fromDate, + to: toDate, + groupBy: flags['group-by'], + source: flags.source, + environment: flags.env, + dataset: flags.dataset, + }).then(raw => StatsResponseSchema.parse(raw)), + client.get('/v1/stats/summary') + .then(raw => StatsSummaryResponseSchema.parse(raw)) + .catch(() => null), + ]); + + const totals = { + debug: (statsResponse.totals?.['debug'] as number) ?? 0, + info: (statsResponse.totals?.['info'] as number) ?? 0, + warn: (statsResponse.totals?.['warn'] as number) ?? 0, + error: (statsResponse.totals?.['error'] as number) ?? 0, + total: (statsResponse.totals?.['total'] as number) ?? 0, + }; + + let comparison: {yesterdayTotal: number; changePercent: number; trend: 'up' | 'down' | 'stable'} | undefined; + if (summaryResponse?.today !== undefined && summaryResponse?.yesterday !== undefined) { + const yesterdayTotal = summaryResponse.yesterday; + const todayTotal = summaryResponse.today; + const changePercent = yesterdayTotal > 0 + ? ((todayTotal - yesterdayTotal) / yesterdayTotal) * 100 + : 0; + const trend = changePercent > 1 ? 'up' as const : changePercent < -1 ? 'down' as const : 'stable' as const; + comparison = {yesterdayTotal, changePercent, trend}; + } + + let groupBySource: Array<{source: string; total: number; debug: number; info: number; warn: number; error: number}> | undefined; + if (flags['group-by'] === 'source' && Array.isArray(statsResponse.stats)) { + groupBySource = statsResponse.stats as typeof groupBySource; + } + + onDone( + , + true, + ); + } catch (err) { + onError(err instanceof Error ? err.message : String(err)); + } + } + + return null; +} diff --git a/src/components/commandViews/WhoamiView.tsx b/src/components/commandViews/WhoamiView.tsx new file mode 100644 index 0000000..569e5e5 --- /dev/null +++ b/src/components/commandViews/WhoamiView.tsx @@ -0,0 +1,36 @@ +import {useEffect} from 'react'; +import type {ReactNode} from 'react'; +import {Text, Box} from 'ink'; +import {createApiClient} from '../../lib/api.js'; + +type Props = { + token: string; + onDone: (output: ReactNode, interactive: boolean) => void; + onError: (message: string) => void; +}; + +export default function WhoamiView({token, onDone, onError}: Props) { + useEffect(() => { + void run(); + }, []); + + async function run() { + try { + const client = createApiClient({token}); + const whoami = await client.get<{organizationName?: string}>('/v1/whoami'); + onDone( + + {'Status: '}Authenticated + {whoami.organizationName ? ( + {'Organization: '}{whoami.organizationName} + ) : null} + , + false, + ); + } catch (err) { + onError(err instanceof Error ? err.message : String(err)); + } + } + + return null; +} diff --git a/src/lib/flagParser.ts b/src/lib/flagParser.ts new file mode 100644 index 0000000..e59d2a5 --- /dev/null +++ b/src/lib/flagParser.ts @@ -0,0 +1,81 @@ +import {z} from 'zod'; + +function unwrapType(t: z.ZodTypeAny): z.ZodTypeAny { + const def = t._def as Record; + if (def['innerType']) return unwrapType(def['innerType'] as z.ZodTypeAny); + if (def['schema']) return unwrapType(def['schema'] as z.ZodTypeAny); + return t; +} + +function getFieldKind(t: z.ZodTypeAny): 'boolean' | 'number' | 'string' { + const inner = unwrapType(t); + const typeName = (inner._def as {typeName?: string}).typeName; + if (typeName === 'ZodBoolean') return 'boolean'; + if (typeName === 'ZodNumber') return 'number'; + return 'string'; +} + +export type ParseFlagsResult = { + flags: Record; + positional: string[]; + error: string | null; +}; + +export function parseFlags( + tokens: string[], + schema: z.ZodObject, +): ParseFlagsResult { + const shape = schema.shape; + const raw: Record = {}; + const positional: string[] = []; + let i = 0; + + while (i < tokens.length) { + const token = tokens[i]!; + if (token.startsWith('--')) { + const eqIdx = token.indexOf('='); + if (eqIdx !== -1) { + const key = token.slice(2, eqIdx); + const val = token.slice(eqIdx + 1); + const kind = shape[key] ? getFieldKind(shape[key]!) : 'string'; + raw[key] = kind === 'number' ? Number(val) : val; + i++; + } else { + const key = token.slice(2); + const kind = shape[key] ? getFieldKind(shape[key]!) : 'string'; + if (kind === 'boolean') { + raw[key] = true; + i++; + } else { + const next = tokens[i + 1]; + if (next !== undefined && !next.startsWith('-')) { + raw[key] = kind === 'number' ? Number(next) : next; + i += 2; + } else { + raw[key] = true; + i++; + } + } + } + } else { + positional.push(token); + i++; + } + } + + const unknownKeys = Object.keys(raw).filter(k => !shape[k]); + if (unknownKeys.length > 0) { + return {flags: schema.parse({}) as Record, positional, error: `Unknown flag(s): ${unknownKeys.map(k => `--${k}`).join(', ')}`}; + } + + try { + return {flags: schema.parse(raw) as Record, positional, error: null}; + } catch (err) { + if (err instanceof z.ZodError) { + const msg = err.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', '); + return {flags: schema.parse({}) as Record, positional, error: msg}; + } + + return {flags: schema.parse({}) as Record, positional, error: String(err)}; + } +} diff --git a/src/lib/history.ts b/src/lib/history.ts new file mode 100644 index 0000000..3498650 --- /dev/null +++ b/src/lib/history.ts @@ -0,0 +1,23 @@ +import {readFileSync, writeFileSync, mkdirSync} from 'node:fs'; +import {join} from 'node:path'; +import {getConfigDir} from './config.js'; + +const MAX_HISTORY = 500; + +export function getHistoryPath(): string { + return join(getConfigDir(), 'history.json'); +} + +export function loadHistory(): string[] { + try { + return JSON.parse(readFileSync(getHistoryPath(), 'utf-8')) as string[]; + } catch { + return []; + } +} + +export function saveHistory(entries: string[]): void { + const trimmed = entries.slice(-MAX_HISTORY); + mkdirSync(getConfigDir(), {recursive: true, mode: 0o700}); + writeFileSync(getHistoryPath(), JSON.stringify(trimmed) + '\n', {mode: 0o600}); +} diff --git a/src/lib/replParser.ts b/src/lib/replParser.ts new file mode 100644 index 0000000..f12b74b --- /dev/null +++ b/src/lib/replParser.ts @@ -0,0 +1,59 @@ +export type ParsedCommand = + | {type: 'builtin'; name: 'help' | 'exit' | 'clear'} + | {type: 'command'; name: string; tokens: string[]} + | {type: 'empty'} + | {type: 'unknown'; input: string}; + +const TWO_WORD_COMMANDS = new Set(['flows show', 'config list', 'config reset']); +const ONE_WORD_COMMANDS = new Set(['logs', 'stats', 'flows', 'whoami', 'login', 'logout']); +const BUILTINS = new Set(['help', 'exit', 'clear']); + +function shellSplit(input: string): string[] { + const tokens: string[] = []; + let current = ''; + let inSingle = false; + let inDouble = false; + + for (const ch of input) { + if (ch === "'" && !inDouble) { + inSingle = !inSingle; + } else if (ch === '"' && !inSingle) { + inDouble = !inDouble; + } else if (ch === ' ' && !inSingle && !inDouble) { + if (current.length > 0) { + tokens.push(current); + current = ''; + } + } else { + current += ch; + } + } + + if (current.length > 0) tokens.push(current); + return tokens; +} + +export function parseReplInput(input: string): ParsedCommand { + const trimmed = input.trim(); + if (!trimmed) return {type: 'empty'}; + + const tokens = shellSplit(trimmed); + const first = tokens[0]!.toLowerCase(); + + if (BUILTINS.has(first)) { + return {type: 'builtin', name: first as 'help' | 'exit' | 'clear'}; + } + + if (tokens.length >= 2) { + const twoWord = `${first} ${tokens[1]!.toLowerCase()}`; + if (TWO_WORD_COMMANDS.has(twoWord)) { + return {type: 'command', name: twoWord, tokens: tokens.slice(2)}; + } + } + + if (ONE_WORD_COMMANDS.has(first)) { + return {type: 'command', name: first, tokens: tokens.slice(1)}; + } + + return {type: 'unknown', input: trimmed}; +}