From cef3bd2bf90cfd33bba2907fb7175dc3168b0264 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 10 Mar 2026 14:35:53 +0000 Subject: [PATCH 1/7] feat: REPL interactive mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace bare `timberlogs` invocation with a persistent REPL session - Add `timberlogs repl` as an alias entry point - Implement shell-style input parsing (quoted strings, two-word commands) - Flag parser uses Zod v3 shape introspection to type-coerce CLI args - Command history persisted to ~/.config/timberlogs/history.json (max 500) - Up/down arrow history navigation with draft preservation in ReplPrompt - Three render phases: idle (prompt), running (fetching), interactive (full-screen) - Interactive commands (logs, stats, flows show) take over screen; q returns to REPL - Static commands (whoami, flows list, config list) render inline in scroll buffer - login runs the device flow inline; logout/config reset handled synchronously - Add onBack? prop to LogTable, FlowTimeline, StatsView so q returns to REPL instead of process.exit Closes #72 ๐Ÿค– Auto-generated --- src/commands/index.tsx | 27 +- src/commands/repl.tsx | 5 + src/components/FlowTimeline.tsx | 6 +- src/components/LogTable.tsx | 6 +- src/components/ReplOutput.tsx | 19 ++ src/components/ReplPrompt.tsx | 67 +++++ src/components/ReplView.tsx | 245 +++++++++++++++++++ src/components/StatsView.tsx | 6 +- src/components/commandViews/FlowShowView.tsx | 55 +++++ src/components/commandViews/FlowsView.tsx | 111 +++++++++ src/components/commandViews/LoginView.tsx | 144 +++++++++++ src/components/commandViews/LogsView.tsx | 88 +++++++ src/components/commandViews/StatsViewCmd.tsx | 96 ++++++++ src/components/commandViews/WhoamiView.tsx | 36 +++ src/lib/flagParser.ts | 76 ++++++ src/lib/history.ts | 23 ++ src/lib/replParser.ts | 59 +++++ 17 files changed, 1038 insertions(+), 31 deletions(-) create mode 100644 src/commands/repl.tsx create mode 100644 src/components/ReplOutput.tsx create mode 100644 src/components/ReplPrompt.tsx create mode 100644 src/components/ReplView.tsx create mode 100644 src/components/commandViews/FlowShowView.tsx create mode 100644 src/components/commandViews/FlowsView.tsx create mode 100644 src/components/commandViews/LoginView.tsx create mode 100644 src/components/commandViews/LogsView.tsx create mode 100644 src/components/commandViews/StatsViewCmd.tsx create mode 100644 src/components/commandViews/WhoamiView.tsx create mode 100644 src/lib/flagParser.ts create mode 100644 src/lib/history.ts create mode 100644 src/lib/replParser.ts 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/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..ad1b466 --- /dev/null +++ b/src/components/ReplView.tsx @@ -0,0 +1,245 @@ +import {Box, Text} from 'ink'; +import {useState, useEffect} 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 {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'; + +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); + + useEffect(() => { + setHistory(loadHistory()); + setToken(resolveToken()); + }, []); + + 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..0a38235 --- /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) { + onDone(null, false); + 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..1915da1 --- /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 {exec} 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 { + const command = + process.platform === 'darwin' + ? `open "${url}"` + : process.platform === 'win32' + ? `start "${url}"` + : `xdg-open "${url}"`; + exec(command, () => {}); +} + +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..4d94cf3 --- /dev/null +++ b/src/lib/flagParser.ts @@ -0,0 +1,76 @@ +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++; + } + } + + 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}; +} From 3729e1f94270b4608d3a0367d6ef2bd259cdbf76 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 10 Mar 2026 14:45:41 +0000 Subject: [PATCH 2/7] feat: show startup banner with auth status in REPL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Display version and auth status on launch - Prompt to run `login` if not authenticated - Hint to type `help` for available commands ๐Ÿค– Auto-generated --- src/components/ReplView.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/components/ReplView.tsx b/src/components/ReplView.tsx index ad1b466..09e147b 100644 --- a/src/components/ReplView.tsx +++ b/src/components/ReplView.tsx @@ -51,7 +51,20 @@ export default function ReplView() { useEffect(() => { setHistory(loadHistory()); - setToken(resolveToken()); + const t = resolveToken(); + setToken(t); + setEntries([{ + kind: 'output', + content: ( + + Timberlogs v{CLI_VERSION} + + {t ? 'โœ“ Authenticated' : 'โœ— Not authenticated โ€” run `login` to get started'} + {' ยท Type `help` for commands'} + + + ), + }]); }, []); function addEntry(entry: ReplEntry) { From 1e5e75618f143fbfed0ccf1a23d807877b496bba Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 10 Mar 2026 14:55:33 +0000 Subject: [PATCH 3/7] feat: redesign REPL header with persistent banner and async org name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract persistent ReplHeader component shown above scroll buffer - Async fetch org name on startup; updates after login/logout - Shows โ–ฒ Timberlogs vX.X.X ยท โ— Org Name when authenticated - Shows โ— Not logged in hint when unauthenticated - Separator line below header for visual separation - Remove startup banner from entries[] scroll buffer ๐Ÿค– Auto-generated --- src/components/ReplView.tsx | 60 +++++++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/src/components/ReplView.tsx b/src/components/ReplView.tsx index 09e147b..0112494 100644 --- a/src/components/ReplView.tsx +++ b/src/components/ReplView.tsx @@ -1,10 +1,11 @@ import {Box, Text} from 'ink'; -import {useState, useEffect} from 'react'; +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'; @@ -20,6 +21,33 @@ import FlowShowView from './commandViews/FlowShowView.js'; import LoginView from './commandViews/LoginView.js'; import type {ReplEntry} from './ReplOutput.js'; +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 ยท run login to authenticate + + )} + + {'โ”€'.repeat(Math.min(cols, 60))} + + ); +} + type Phase = | {tag: 'idle'} | {tag: 'running'; node: ReactNode} @@ -48,25 +76,30 @@ export default function ReplView() { 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()); const t = resolveToken(); setToken(t); - setEntries([{ - kind: 'output', - content: ( - - Timberlogs v{CLI_VERSION} - - {t ? 'โœ“ Authenticated' : 'โœ— Not authenticated โ€” run `login` to get started'} - {' ยท Type `help` for commands'} - - - ), - }]); + if (t) void fetchOrgName(t); }, []); + useEffect(() => { + if (!token) { setOrgName(null); return; } + void fetchOrgName(token); + }, [token]); + function addEntry(entry: ReplEntry) { setEntries(prev => [...prev, entry].slice(-MAX_ENTRIES)); } @@ -248,6 +281,7 @@ export default function ReplView() { return ( + {visibleEntries.map((entry, i) => ( ))} From dff13bbb1b3c03eb36a5f595b8658d6219415f29 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 10 Mar 2026 14:58:04 +0000 Subject: [PATCH 4/7] fix: suppress CMD window flash when opening browser on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use spawn with windowsHide: true instead of exec('start ...') to prevent a visible CMD window appearing when launching the browser for device flow auth on Windows. ๐Ÿค– Auto-generated --- src/commands/login.tsx | 19 ++++++++----------- src/components/commandViews/LoginView.tsx | 16 ++++++++-------- 2 files changed, 16 insertions(+), 19 deletions(-) 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/components/commandViews/LoginView.tsx b/src/components/commandViews/LoginView.tsx index 1915da1..5a31ee7 100644 --- a/src/components/commandViews/LoginView.tsx +++ b/src/components/commandViews/LoginView.tsx @@ -2,7 +2,7 @@ import {Text, Box} from 'ink'; import Spinner from 'ink-spinner'; import {useState, useEffect} from 'react'; import type {ReactNode} from 'react'; -import {exec} from 'node:child_process'; +import {spawn} from 'node:child_process'; import {readConfig, writeConfig} from '../../lib/config.js'; import {API_URL} from '../../types/config.js'; @@ -25,13 +25,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, () => {}); + 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 = { From dab25ac330c19aa4d640a1f186e214d4a50045eb Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 10 Mar 2026 15:05:55 +0000 Subject: [PATCH 5/7] feat: show command list in REPL header for discoverability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display available commands inline below the auth status so users don't need to type `help` to discover what's available. ๐Ÿค– Auto-generated --- src/components/ReplView.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/components/ReplView.tsx b/src/components/ReplView.tsx index 0112494..a00debe 100644 --- a/src/components/ReplView.tsx +++ b/src/components/ReplView.tsx @@ -21,6 +21,8 @@ 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 ( @@ -32,17 +34,12 @@ function ReplHeader({token, orgName}: {token: string | null; orgName: string | n v{CLI_VERSION} {token ? ( - - โ— - {orgName ?? 'Authenticated'} - + โ— {orgName ?? 'Authenticated'} ) : ( - - โ— - Not logged in ยท run login to authenticate - + โ— Not logged in )} + {COMMANDS_HINT} {'โ”€'.repeat(Math.min(cols, 60))} ); From 5f768b3c1616f01652d2b676f40da000157c445e Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 10 Mar 2026 15:07:43 +0000 Subject: [PATCH 6/7] docs: document REPL interactive mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add REPL section with usage example and annotated output - Document builtins (help, clear, exit) and history navigation - Note that config reset requires --force inside the REPL ๐Ÿค– Auto-generated --- docs/cli/overview.mdx | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) 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. From 8df2b66957b5636adc9c94211d4117f1ff71ec4a Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 10 Mar 2026 15:13:35 +0000 Subject: [PATCH 7/7] fix: address code review issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove double org name fetch on startup (was firing twice via direct call + token watcher effect) - FlowShowView: show error message instead of silent no-op when no logs found for a flow - flagParser: surface unknown flags as an error instead of silently ignoring them ๐Ÿค– Auto-generated --- src/components/ReplView.tsx | 4 +--- src/components/commandViews/FlowShowView.tsx | 2 +- src/lib/flagParser.ts | 5 +++++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/ReplView.tsx b/src/components/ReplView.tsx index a00debe..2b4ef2a 100644 --- a/src/components/ReplView.tsx +++ b/src/components/ReplView.tsx @@ -87,9 +87,7 @@ export default function ReplView() { useEffect(() => { setHistory(loadHistory()); - const t = resolveToken(); - setToken(t); - if (t) void fetchOrgName(t); + setToken(resolveToken()); }, []); useEffect(() => { diff --git a/src/components/commandViews/FlowShowView.tsx b/src/components/commandViews/FlowShowView.tsx index 0a38235..5590d6d 100644 --- a/src/components/commandViews/FlowShowView.tsx +++ b/src/components/commandViews/FlowShowView.tsx @@ -25,7 +25,7 @@ export default function FlowShowView({flowId, token, onBack, onDone, onError}: P const logs = response.logs; if (logs.length === 0) { - onDone(null, false); + onError(`No logs found for flow ${flowId}`); return; } diff --git a/src/lib/flagParser.ts b/src/lib/flagParser.ts index 4d94cf3..e59d2a5 100644 --- a/src/lib/flagParser.ts +++ b/src/lib/flagParser.ts @@ -63,6 +63,11 @@ export function parseFlags( } } + 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) {