diff --git a/CLAUDE.md b/CLAUDE.md index fd9e362..426a23a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,8 @@ Top-level `defineCommand` in `src/index.ts` wires ten subcommands (`cloud`, `upl **Flag composition.** Flag definitions are split by domain in `src/config/flags/*.flags.ts` (api, binary, device, environment, execution, github, output) and re-exported as a single `flags` object from `src/constants.ts`. Commands that need the full cloud surface spread `...flags` into their citty `args`; subset commands import individual flag groups. +**Output rendering.** All human-facing output goes through `src/utils/ui.ts` (the canonical layer: `section`/`branch`/`fields`/`status`/`success`/`info`/`warn`) on top of `src/utils/styling.ts` (`colors`, `symbols`, `statusPalette`). The visual language is a Claude Code-style tree (`⏺` section headings, `⎿` branch groups). Commands must not hand-roll layouts from `colors`/`symbols` or call `console.log` (except the single `JSON.stringify` line under `--json`). Full rules and a cookbook live in `STYLE_GUIDE.md`. + **Layered call stack.** - `src/commands/*` — thin citty command definitions; orchestrate services, no I/O logic. - `src/services/*.service.ts` — domain workflows (execution planning, device validation, test submission, results polling, report download, version check, metadata extraction). Services call gateways and each other. diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md new file mode 100644 index 0000000..243bd8d --- /dev/null +++ b/STYLE_GUIDE.md @@ -0,0 +1,90 @@ +# CLI output style guide + +All human-facing terminal output is rendered through a single layer so every +command looks the same. The visual language is a **tree**, inspired by Claude +Code: top-level `⏺` section headings, with `⎿` branch groups of indented detail +rows beneath them. + +``` +⏺ Upload Status + ⎿ ✓ passed + name Checkout flow + upload id a1b2c3d4 + created Jun 22 2026, 10:30 + console https://console.devicecloud.dev/… + +⏺ Test Results + ⎿ ✓ login.yaml 12s + ✗ checkout.yaml 8s · assertion failed +``` + +## The rules + +1. **Never build output by hand.** Commands compose from `src/utils/ui.ts` and + the helpers in `src/utils/styling.ts`. Do not concatenate `colors.*`, + `symbols.*`, or raw strings into bespoke layouts inside a command — if you + need a shape that isn't there, add it to `ui` so every command gets it. + +2. **Print through the logger.** Use `logger` from `src/utils/cli.ts` + (`logger.log` / `logger.warn` / `logger.error`). Never `console.log` directly + except for the single `JSON.stringify` line in `--json` mode. + +3. **Sections then branches.** A screen is one or more `ui.section(title)` + headings, each followed by a `ui.branch([...])` group of detail rows. Key/ + value detail uses `ui.fields([[label, value], …])` (dim, aligned labels) fed + into `ui.branch`. + +4. **Status is centralised.** Anything with a run/test status + (passed/failed/running/pending/queued/cancelled) renders via `ui.status`, + `ui.statusSymbol`, or `ui.statusWord`, which all draw from + `statusPalette` in `styling.ts`. Never re-derive the status→colour→symbol + mapping in a command or service. + +5. **Standalone messages have a glyph.** Use `ui.success` (`✓`, emphasised), + `ui.info` (`ℹ`), `ui.warn` (`⚠`), `ui.running` (`▶`), and `ui.note` (dim + secondary text). Errors go through `logger.error`, which prepends `✗ Error:`. + +6. **Colour carries meaning, structure stays muted.** The `⏺`/`⎿` glyphs and + field labels are dim; colour is reserved for status and for emphasising a + value (IDs via `formatId`, URLs via `formatUrl`). Don't colour a label. + +7. **`--json` suppresses everything else.** When `--json` is set, emit only the + single serialized object and gate all other output behind the flag (the + `out()` wrapper pattern in `cloud.ts`/`upload.ts`). + +8. **No full-width rulers.** The 80-char `dividers` and `box` are deprecated; + the tree provides the structure. Status words are lowercase. + +## Cookbook + +```ts +import { logger } from '../utils/cli'; +import { ui } from '../utils/ui'; +import { formatId, formatUrl } from '../utils/styling'; + +// A section with a key/value detail block: +logger.log(ui.section('Upload Status')); +logger.log( + ui.branch([ + ui.status(status), + ...ui.fields([ + ['name', name], + ['upload id', formatId(uploadId)], + ['console', formatUrl(consoleUrl)], + ]), + ]), +); + +// A list of status rows: +logger.log(ui.section('Test Results')); +logger.log( + ui.branch( + tests.map((t) => `${ui.statusSymbol(t.status)} ${t.name}`), + ), +); + +// Standalone messages: +logger.log(ui.success('Upload complete')); +logger.log(ui.info('Keeping existing session')); +logger.log(ui.warn('No devices matched; using defaults')); +``` diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index c3f452c..3d6786b 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -44,16 +44,12 @@ import { import { resolveApiUrl } from '../utils/config-store'; import { downloadExpoUrl, extractTarGz, findAppBundle, isUrl } from '../utils/expo'; import { - box, colors, - dividers, formatId, formatUrl, getConsoleUrl, - listItem, - sectionHeader, - symbols, } from '../utils/styling'; +import { type Field, ui } from '../utils/ui'; // Suppress punycode deprecation warning (caused by whatwg, supabase dependency). // Every other warning must still reach the user — removeAllListeners drops @@ -120,14 +116,13 @@ export const cloudCommand = defineCommand({ const versionCheck = async () => { const latestVersion = await versionService.checkLatestCliVersion(); if (latestVersion && versionService.isOutdated(cliVersion, latestVersion)) { - const body = - `${symbols.warning} ${colors.bold('Update Available')}\n` + - colors.dim('A new version of the DeviceCloud CLI is available: ') + - colors.highlight(latestVersion) + - '\n' + - colors.dim('Run: ') + - colors.info(getUpgradeCommand()); - out(`\n${box(body)}\n`); + out(ui.warn(colors.bold('Update available'))); + out( + ui.branch([ + `A new version of the DeviceCloud CLI is available: ${colors.highlight(latestVersion)}`, + `${colors.dim('Run:')} ${colors.info(getUpgradeCommand())}`, + ]), + ); } }; @@ -377,38 +372,34 @@ export const cloudCommand = defineCommand({ if (retry !== undefined && retry > 2) { out( - `${symbols.warning} ` + - colors.dim( - 'Retries are now free of charge but limited to 2. If your test is still failing after 2 retries, please ask for help on Discord.', - ), + ui.warn( + 'Retries are now free of charge but limited to 2. If your test is still failing after 2 retries, please ask for help on Discord.', + ), ); retry = 2; } if (runnerType === 'm4') { out( - `${symbols.info} ` + - colors.dim( - 'Note: runnerType m4 is experimental and currently supports iOS only, Android will revert to default.', - ), + ui.info( + 'runnerType m4 is experimental and currently supports iOS only, Android will revert to default.', + ), ); } if (runnerType === 'm1') { out( - `${symbols.info} ` + - colors.dim( - 'Note: runnerType m1 is experimental and currently supports Android (Pixel 7, API Level 34) only.', - ), + ui.info( + 'runnerType m1 is experimental and currently supports Android (Pixel 7, API Level 34) only.', + ), ); } if (runnerType === 'gpu1') { out( - `${symbols.info} ` + - colors.dim( - 'Note: runnerType gpu1 is Android-only (all devices, API Level 34 or 35), available to all users.', - ), + ui.info( + 'runnerType gpu1 is Android-only (all devices, API Level 34 or 35), available to all users.', + ), ); } @@ -615,63 +606,58 @@ export const cloudCommand = defineCommand({ const hasOverrides = overridesEntries.some( ([, overrides]) => Object.keys(overrides).length > 0, ); - let overridesLog = ''; - - if (hasOverrides) { - overridesLog = '\n\n ' + colors.bold('With overrides'); - for (const [flowPath, overrides] of overridesEntries) { - if (Object.keys(overrides).length > 0) { - const relativePath = flowPath.replace(process.cwd(), '.'); - overridesLog += `\n ${colors.dim('→')} ${relativePath}:`; - for (const [key, value] of Object.entries(overrides)) { - overridesLog += `\n ${colors.dim(key + ':')} ${colors.highlight(String(value))}`; - } - } - } - } - out(sectionHeader('Submitting new job')); - out(` ${colors.dim('→ Flow(s):')} ${colors.highlight(flowFile)}`); - out( - ` ${colors.dim('→ App:')} ${colors.highlight(appBinaryId || finalAppFile || '')}`, - ); + const submitRows: string[] = ui.fields([ + ['flow(s)', colors.highlight(flowFile)], + ['app', colors.highlight(appBinaryId || finalAppFile || '')], + ]); if (flagLogs.length > 0) { - out(`\n ${colors.bold('With options')}`); - for (const flagLog of flagLogs) { - const [key, ...valueParts] = flagLog.split(': '); - const value = valueParts.join(': '); - out( - ` ${colors.dim('→ ' + key + ':')} ${colors.highlight(value)}`, - ); - } + submitRows.push('', colors.bold('Options')); + submitRows.push( + ...ui.fields( + flagLogs.map((flagLog) => { + const [key, ...valueParts] = flagLog.split(': '); + return [key, colors.highlight(valueParts.join(': '))] as Field; + }), + ), + ); } if (hasOverrides) { - out(overridesLog); + submitRows.push('', colors.bold('Overrides')); + for (const [flowPath, overrides] of overridesEntries) { + if (Object.keys(overrides).length === 0) { + continue; + } + submitRows.push(colors.dim(`${flowPath.replace(process.cwd(), '.')}:`)); + submitRows.push( + ...ui.fields( + Object.entries(overrides).map( + ([key, value]) => [key, colors.highlight(String(value))] as Field, + ), + ), + ); + } } - out(''); + out(ui.section('Submitting new job')); + out(ui.branch(submitRows)); if (dryRun) { out( - `\n${symbols.warning} ${colors.bold('Dry run mode')} ${colors.dim('- no tests were actually triggered')}\n`, + ui.warn( + `${colors.bold('Dry run mode')} ${colors.dim('— no tests were actually triggered')}`, + ), ); - out(colors.bold('The following tests would have been run:')); - out(dividers.light); - for (const test of testFileNames) { - out(listItem(test)); - } + out(ui.section('The following tests would have been run')); + out(ui.branch(testFileNames)); if (sequentialFlows.length > 0) { - out(`\n${colors.bold('Sequential flows:')}`); - out(dividers.short); - for (const flow of sequentialFlows) { - out(listItem(flow)); - } + out(ui.section('Sequential flows')); + out(ui.branch(sequentialFlows)); } - out('\n'); return; } @@ -766,31 +752,27 @@ export const cloudCommand = defineCommand({ if (!results?.length) { throw new CliError('No tests created: ' + message); } - out(`${symbols.success} ${colors.bold('Submitted')} ${colors.dim(message)}`); + out(`${ui.success('Submitted')} ${colors.dim(message)}`); const testNames = results .map((r) => r.test_file_name) .sort((a, b) => a.localeCompare(b)) .join(colors.dim(', ')); - out( - `\n${colors.bold(`Created ${results.length} test${results.length === 1 ? '' : 's'}:`)} ${testNames}\n`, - ); - const url = getConsoleUrl(apiUrl, results[0].test_upload_id, results[0].id); - out( - colors.bold('Run triggered') + colors.dim(', you can access the results at:'), - ); - out(formatUrl(url)); - out(''); + out(ui.section(`Created ${results.length} test${results.length === 1 ? '' : 's'}`)); out( - colors.dim('Your upload ID is: ') + formatId(results[0].test_upload_id), - ); - out( - colors.dim('Poll upload status using: ') + - colors.info( - `dcd status --api-key ... --upload-id ${results[0].test_upload_id}`, - ), + ui.branch([ + testNames, + ...ui.fields([ + ['results', formatUrl(url)], + ['upload id', formatId(results[0].test_upload_id)], + [ + 'poll status', + colors.info(`dcd status --upload-id ${results[0].test_upload_id}`), + ], + ]), + ]), ); if (async) { @@ -827,9 +809,7 @@ export const cloudCommand = defineCommand({ return; } - out( - `\n${symbols.info} ${colors.dim('Not waiting for results as async flag is set to true')}\n`, - ); + out(ui.info('Not waiting for results as async flag is set to true')); return; } diff --git a/src/commands/list.ts b/src/commands/list.ts index 688bcb5..c6a0ee0 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -5,7 +5,8 @@ import { ApiGateway } from '../gateways/api-gateway'; import { resolveAuth } from '../utils/auth'; import { CliError, logger, parseIntFlag } from '../utils/cli'; import { resolveApiUrl } from '../utils/config-store'; -import { colors, formatId, formatUrl, sectionHeader, symbols } from '../utils/styling'; +import { colors, formatId, formatUrl } from '../utils/styling'; +import { ui } from '../utils/ui'; type UploadListItem = { consoleUrl: string; @@ -44,19 +45,13 @@ function displayResults(response: ListResponse): void { const { uploads, total, limit, offset } = response; if (uploads.length === 0) { - logger.log(`\n${symbols.info} No uploads found matching your criteria.\n`); + logger.log(ui.info('No uploads found matching your criteria.')); return; } - logger.log(sectionHeader('Recent Uploads')); - logger.log( - ` ${colors.dim('Showing')} ${uploads.length} ${colors.dim('of')} ${total} ${colors.dim('uploads')}`, - ); - if (offset > 0) { - logger.log(` ${colors.dim('(offset:')} ${offset}${colors.dim(')')}`); - } - - logger.log(''); + const offsetNote = offset > 0 ? colors.dim(`, offset ${offset}`) : ''; + logger.log(ui.section('Recent Uploads')); + logger.log(ui.note(` showing ${uploads.length} of ${total}${offsetNote}`)); for (const upload of uploads) { const date = new Date(upload.created_at); @@ -68,24 +63,29 @@ function displayResults(response: ListResponse): void { year: 'numeric', }); - const displayName = upload.name || colors.dim('(unnamed)'); - logger.log(` ${colors.bold(displayName)}`); - logger.log(` ${colors.dim('ID:')} ${formatId(upload.id)}`); - logger.log(` ${colors.dim('Created:')} ${formattedDate}`); - logger.log(` ${colors.dim('Console:')} ${formatUrl(upload.consoleUrl)}`); - logger.log(''); + const displayName = upload.name ? colors.bold(upload.name) : colors.dim('(unnamed)'); + logger.log( + ui.branch([ + displayName, + ...ui.fields([ + ['id', formatId(upload.id)], + ['created', formattedDate], + ['console', formatUrl(upload.consoleUrl)], + ]), + ]), + ); } if (total > offset + uploads.length) { const remaining = total - (offset + uploads.length); logger.log( - ` ${colors.dim('Use')} --offset ${offset + uploads.length} ${colors.dim('to see the next')} ${Math.min(remaining, limit)} ${colors.dim('uploads')}\n`, + ui.note( + `\nUse --offset ${offset + uploads.length} to see the next ${Math.min(remaining, limit)} uploads`, + ), ); } - logger.log( - ` ${symbols.info} ${colors.dim('Use')} dcd status --upload-id ${colors.dim('for detailed test results')}\n`, - ); + logger.log(ui.info(colors.dim('Use dcd status --upload-id for detailed test results'))); } export const listCommand = defineCommand({ diff --git a/src/commands/live.ts b/src/commands/live.ts index 4e3c382..e1c1de2 100644 --- a/src/commands/live.ts +++ b/src/commands/live.ts @@ -9,7 +9,8 @@ import type { LiveSession } from '../types/domain/live.types'; import { resolveAuth } from '../utils/auth'; import { CliError, logger, validateEnum } from '../utils/cli'; import { resolveApiUrl } from '../utils/config-store'; -import { colors, sectionHeader, symbols } from '../utils/styling'; +import { colors, formatUrl } from '../utils/styling'; +import { type Field, ui } from '../utils/ui'; const PLATFORM_OPTIONS = ['android', 'ios'] as const; type Platform = (typeof PLATFORM_OPTIONS)[number]; @@ -105,15 +106,15 @@ function readFlowFile(filePath: string): string { function printExecResult(result: { error?: string; output?: string; success: boolean }): void { logger.log( result.success - ? `${symbols.success} Command executed successfully` - : `${symbols.error} Command failed`, + ? ui.success('Command executed successfully') + : `${ui.statusSymbol('FAILED')} Command failed`, ); if (result.output) { - logger.log(sectionHeader('Output')); + logger.log(ui.section('Output')); logger.log(result.output); } if (result.error) { - logger.log(sectionHeader('Error')); + logger.log(ui.section('Error')); logger.log(colors.error(result.error)); } } @@ -170,7 +171,7 @@ const startSub = defineCommand({ throw new CliError('--android-device and --android-api-level must be provided together.'); } - logger.log(`${symbols.running} Starting ${platform} live session...`); + logger.log(ui.running(`Starting ${platform} live session…`)); const session = await ApiGateway.startLiveSession(apiUrl, auth, { binaryUploadId: binaryId, @@ -182,33 +183,36 @@ const startSub = defineCommand({ const frontendUrl = resolveFrontendUrl(apiUrl); - logger.log(`${symbols.success} Live session started`); - logger.log(` ${colors.dim('Session:')} ${colors.highlight(session.session_name)}`); - logger.log(` ${colors.dim('Platform:')} ${session.platform}`); - logger.log(` ${colors.dim('Status:')} ${session.status}`); + logger.log(ui.success('Live session started')); logger.log( - ` ${colors.dim('Console:')} ${colors.highlight(`${frontendUrl}/live?session=${session.session_name}`)}`, + ui.branch( + ui.fields([ + ['session', colors.highlight(session.session_name)], + ['platform', session.platform], + ['status', session.status], + ['console', formatUrl(`${frontendUrl}/live?session=${session.session_name}`)], + ]), + ), ); if (args.wait) { - logger.log(''); - logger.log(`${symbols.running} Waiting for the device to be ready...`); + logger.log(ui.running('Waiting for the device to be ready…')); const ready = await waitForReady(apiUrl, auth, session.session_name); - logger.log(`${symbols.success} Device ready${ready.device_model ? ` (${ready.device_model})` : ''}`); + logger.log( + ui.success(`Device ready${ready.device_model ? ` (${ready.device_model})` : ''}`), + ); } - logger.log(''); + logger.log(ui.section('Next steps')); logger.log( - ` ${colors.dim('Install a binary:')} ${colors.highlight(`dcd live install --session ${session.session_name} --app-binary-id `)}`, - ); - logger.log( - ` ${colors.dim('Run a flow:')} ${colors.highlight(`dcd live run --session ${session.session_name} path/to/flow.yaml`)}`, - ); - logger.log( - ` ${colors.dim('Inspect screen:')} ${colors.highlight(`dcd live hierarchy --session ${session.session_name}`)}`, - ); - logger.log( - ` ${colors.dim('Stop session:')} ${colors.highlight(`dcd live stop --session ${session.session_name}`)}`, + ui.branch( + ui.fields([ + ['install a binary', colors.highlight(`dcd live install --session ${session.session_name} --app-binary-id `)], + ['run a flow', colors.highlight(`dcd live run --session ${session.session_name} path/to/flow.yaml`)], + ['inspect screen', colors.highlight(`dcd live hierarchy --session ${session.session_name}`)], + ['stop session', colors.highlight(`dcd live stop --session ${session.session_name}`)], + ]), + ), ); }, }); @@ -236,17 +240,19 @@ const installSub = defineCommand({ const binaryId = args['app-binary-id'] as string; logger.log( - `${symbols.running} Installing binary ${colors.highlight(binaryId)} on session ${colors.highlight(sessionName)}...`, + ui.running( + `Installing binary ${colors.highlight(binaryId)} on session ${colors.highlight(sessionName)}…`, + ), ); await ApiGateway.installLiveBinary(apiUrl, auth, sessionName, binaryId); - logger.log(`${symbols.success} Binary installed successfully`); + logger.log(ui.success('Binary installed successfully')); if (args.wait) { - logger.log(`${symbols.running} Waiting for the device to be ready...`); + logger.log(ui.running('Waiting for the device to be ready…')); await waitForReady(apiUrl, auth, sessionName); - logger.log(`${symbols.success} Device ready`); + logger.log(ui.success('Device ready')); } }, }); @@ -287,7 +293,7 @@ const execSub = defineCommand({ } logger.log( - `${symbols.running} Executing commands on session ${colors.highlight(sessionName)}...`, + ui.running(`Executing commands on session ${colors.highlight(sessionName)}…`), ); const result = await ApiGateway.execLiveYaml(apiUrl, auth, sessionName, yaml); @@ -336,7 +342,9 @@ const runSub = defineCommand({ } logger.log( - `${symbols.running} Running ${colors.highlight(flowFile)} on session ${colors.highlight(sessionName)}...`, + ui.running( + `Running ${colors.highlight(flowFile)} on session ${colors.highlight(sessionName)}…`, + ), ); // Submit asynchronously and poll, so a long flow isn't capped by the @@ -416,10 +424,14 @@ const screenshotSub = defineCommand({ writeFileSync(output, Buffer.from(base64, 'base64')); - logger.log(`${symbols.success} Screenshot saved to ${colors.highlight(output)}`); + logger.log(ui.success(`Screenshot saved to ${colors.highlight(output)}`)); if (session.hierarchy) { logger.log( - ` ${colors.dim('Resolution:')} ${session.hierarchy.width}x${session.hierarchy.height}`, + ui.branch( + ui.fields([ + ['resolution', `${session.hierarchy.width}x${session.hierarchy.height}`], + ]), + ), ); } }, @@ -460,7 +472,7 @@ const hierarchySub = defineCommand({ const out = JSON.stringify(hierarchy, null, 2); if (output) { writeFileSync(output, out); - logger.log(`${symbols.success} Hierarchy written to ${colors.highlight(output)}`); + logger.log(ui.success(`Hierarchy written to ${colors.highlight(output)}`)); } else { // eslint-disable-next-line no-console console.log(out); @@ -488,7 +500,7 @@ const hierarchySub = defineCommand({ if (output) { writeFileSync(output, `${out}\n`); - logger.log(`${symbols.success} Hierarchy written to ${colors.highlight(output)}`); + logger.log(ui.success(`Hierarchy written to ${colors.highlight(output)}`)); } else { logger.log(out); } @@ -506,11 +518,11 @@ const stopSub = defineCommand({ const apiUrl = resolveApiUrl(args['api-url'] as string | undefined); const sessionName = args.session as string; - logger.log(`${symbols.running} Stopping session ${colors.highlight(sessionName)}...`); + logger.log(ui.running(`Stopping session ${colors.highlight(sessionName)}…`)); await ApiGateway.stopLiveSession(apiUrl, auth, sessionName); - logger.log(`${symbols.success} Session stopped`); + logger.log(ui.success('Session stopped')); }, }); @@ -527,34 +539,32 @@ const statusSub = defineCommand({ const session = await ApiGateway.getLiveSession(apiUrl, auth, sessionName); - logger.log(sectionHeader('Live Session')); - logger.log(` ${colors.dim('Session:')} ${colors.highlight(session.session_name)}`); - logger.log(` ${colors.dim('Platform:')} ${session.platform}`); - logger.log(` ${colors.dim('Status:')} ${session.status}`); - logger.log( - ` ${colors.dim('Ready:')} ${session.ready ? colors.success('yes') : colors.warning('no')}`, - ); + const fields: Field[] = [ + ['session', colors.highlight(session.session_name)], + ['platform', session.platform], + ['status', session.status], + ['ready', session.ready ? colors.success('yes') : colors.warning('no')], + ]; const phase = session.device_state?.phase_label ?? session.device_state?.phase; if (phase) { - logger.log(` ${colors.dim('Phase:')} ${phase}`); + fields.push(['phase', phase]); } if (session.device_model) { - logger.log(` ${colors.dim('Device:')} ${session.device_model}`); + fields.push(['device', session.device_model]); } if (session.device_locale) { - logger.log(` ${colors.dim('Locale:')} ${session.device_locale}`); + fields.push(['locale', session.device_locale]); } if (session.binary_upload_id) { - logger.log(` ${colors.dim('Binary:')} ${session.binary_upload_id}`); + fields.push(['binary', session.binary_upload_id]); } if (typeof session.seconds_until_auto_cancel === 'number') { - logger.log( - ` ${colors.dim('Auto-cancel in:')} ${session.seconds_until_auto_cancel}s`, - ); + fields.push(['auto-cancel in', `${session.seconds_until_auto_cancel}s`]); } - logger.log( - ` ${colors.dim('Created:')} ${new Date(session.created_at).toLocaleString()}`, - ); + fields.push(['created', new Date(session.created_at).toLocaleString()]); + + logger.log(ui.section('Live Session')); + logger.log(ui.branch(ui.fields(fields))); }, }); @@ -591,17 +601,22 @@ export const liveCommand = defineCommand({ const firstPositional = rawArgs.find((arg) => !arg.startsWith('-')); if (firstPositional && subNames.has(firstPositional)) return; - logger.log(sectionHeader('Live Session Commands')); - logger.log(` ${colors.bold('start')} Start a new live device session`); - logger.log(` ${colors.bold('install')} Install a binary on the device`); - logger.log(` ${colors.bold('exec')} Execute Maestro YAML commands`); - logger.log(` ${colors.bold('run')} Run a whole Maestro flow file`); - logger.log(` ${colors.bold('screenshot')} Save the current device screen to an image file`); - logger.log(` ${colors.bold('hierarchy')} Dump the current view hierarchy (selectors)`); - logger.log(` ${colors.bold('stop')} Stop a live session`); - logger.log(` ${colors.bold('status')} Get session status`); - logger.log(''); - logger.log(` Run ${colors.highlight('dcd live --help')} for details`); + logger.log(ui.section('Live Session Commands')); + logger.log( + ui.branch( + ui.fields([ + [colors.bold('start'), 'Start a new live device session'], + [colors.bold('install'), 'Install a binary on the device'], + [colors.bold('exec'), 'Execute Maestro YAML commands'], + [colors.bold('run'), 'Run a whole Maestro flow file'], + [colors.bold('screenshot'), 'Save the current device screen to an image file'], + [colors.bold('hierarchy'), 'Dump the current view hierarchy (selectors)'], + [colors.bold('stop'), 'Stop a live session'], + [colors.bold('status'), 'Get session status'], + ]), + ), + ); + logger.log(ui.note(`\nRun ${colors.highlight('dcd live --help')} for details`)); }, }); diff --git a/src/commands/login.ts b/src/commands/login.ts index cd341a7..d33716e 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -34,7 +34,8 @@ import { ENVIRONMENTS, inferEnvFromApiUrl, resolveFrontendUrl } from '../config/ import { CliError, logger } from '../utils/cli'; import { readConfig, writeConfig } from '../utils/config-store'; import { fetchOrgs, pickOrg } from '../utils/orgs'; -import { colors, sectionHeader, symbols } from '../utils/styling'; +import { colors } from '../utils/styling'; +import { ui } from '../utils/ui'; interface ClaimedSession { access_token: string; @@ -92,7 +93,7 @@ export const loginCommand = defineCommand({ initialValue: false, }); if (p.isCancel(ok) || !ok) { - logger.log(`${symbols.info} Keeping existing session.`); + logger.log(ui.info('Keeping existing session.')); return; } } @@ -107,23 +108,26 @@ export const loginCommand = defineCommand({ const loginUrl = `${frontendUrl}/cli-login?state=${state}&code_challenge=${codeChallenge}`; - logger.log(sectionHeader('Signing in to devicecloud.dev')); + logger.log(ui.section('Signing in to devicecloud.dev')); + const rows: string[] = []; if (noBrowser) { - logger.log(` ${colors.dim('Open this URL in a browser to finish login:')}`); - logger.log(` ${colors.highlight(loginUrl)}`); + rows.push(ui.note('Open this URL in a browser to finish login:'), colors.highlight(loginUrl)); } else { - logger.log(` ${colors.dim('Opening your browser...')}`); const opened = openBrowser(loginUrl); + rows.push( + ui.note( + opened + ? 'Opening your browser…' + : 'Could not launch a browser. Open this URL manually:', + ), + ); if (!opened) { - logger.log( - ` ${colors.dim('Could not launch a browser. Open this URL manually:')}`, - ); - logger.log(` ${colors.highlight(loginUrl)}`); + rows.push(colors.highlight(loginUrl)); } } - - logger.log(` ${colors.dim('Waiting for login to complete...')}\n`); + rows.push(ui.note('Waiting for login to complete…')); + logger.log(ui.branch(rows)); try { const payload = await pollForClaim(apiUrl, state, codeVerifier); @@ -174,15 +178,15 @@ export const loginCommand = defineCommand({ current_org_name: chosen.name, }); + logger.log(ui.success(`Logged in as ${colors.highlight(payload.user_email)}`)); logger.log( - `${symbols.success} ${colors.bold('Logged in')} as ${colors.highlight(payload.user_email)}`, + ui.branch([ + ...ui.fields([['organization', colors.highlight(chosen.name)]]), + ...(orgs.length > 1 + ? [ui.note(`Switch orgs later with ${colors.highlight('dcd switch-org')}`)] + : []), + ]), ); - logger.log(` ${colors.dim('Organization:')} ${colors.highlight(chosen.name)}`); - if (orgs.length > 1) { - logger.log( - ` ${colors.dim('Switch orgs later with')} ${colors.highlight('dcd switch-org')}`, - ); - } } catch (error) { logger.error(error as Error, { exit: 1 }); } diff --git a/src/commands/logout.ts b/src/commands/logout.ts index c1dfc47..bb265dd 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -8,7 +8,8 @@ import { ENVIRONMENTS } from '../config/environments'; import { CliAuthGateway } from '../gateways/cli-auth-gateway'; import { logger } from '../utils/cli'; import { clearConfig, getConfigPath, readConfig } from '../utils/config-store'; -import { colors, symbols } from '../utils/styling'; +import { colors } from '../utils/styling'; +import { ui } from '../utils/ui'; export const logoutCommand = defineCommand({ meta: { @@ -18,7 +19,7 @@ export const logoutCommand = defineCommand({ async run() { const config = readConfig(); if (!config?.session) { - logger.log(`${symbols.info} No active session found (${colors.dim(getConfigPath())}).`); + logger.log(ui.info(`No active session found (${colors.dim(getConfigPath())}).`)); clearConfig(); return; } @@ -26,7 +27,7 @@ export const logoutCommand = defineCommand({ const { anonKey } = ENVIRONMENTS[config.env].supabase; await CliAuthGateway.signOut(config.supabase_url, anonKey, config.session); clearConfig(); - logger.log(`${symbols.success} Logged out ${colors.dim(`(${config.session.user_email})`)}.`); + logger.log(ui.success(`Logged out ${colors.dim(`(${config.session.user_email})`)}`)); }, }); diff --git a/src/commands/status.ts b/src/commands/status.ts index 6dc2b1f..d19601f 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -10,7 +10,8 @@ import { ConnectivityCheckResult, checkInternetConnectivity, } from '../utils/connectivity'; -import { colors, formatId, formatStatus, formatUrl, sectionHeader } from '../utils/styling'; +import { colors, formatId, formatUrl } from '../utils/styling'; +import { type Field, ui } from '../utils/ui'; type StatusKind = 'CANCELLED' | 'FAILED' | 'PASSED' | 'PENDING' | 'QUEUED' | 'RUNNING'; @@ -234,51 +235,48 @@ async function statusMain({ return; } - logger.log(sectionHeader('Upload Status')); - logger.log(` ${formatStatus(status.status)}`); - + const fields: Field[] = []; if (status.name) { - logger.log(` ${colors.dim('Name:')} ${colors.bold(status.name)}`); + fields.push(['name', colors.bold(status.name)]); } - if (status.uploadId) { - logger.log(` ${colors.dim('Upload ID:')} ${formatId(status.uploadId)}`); + fields.push(['upload id', formatId(status.uploadId)]); } - if (status.appBinaryId) { - logger.log(` ${colors.dim('Binary ID:')} ${formatId(status.appBinaryId)}`); + fields.push(['binary id', formatId(status.appBinaryId)]); } - if (status.createdAt) { - logger.log(` ${colors.dim('Created:')} ${formatDateTime(status.createdAt)}`); + fields.push(['created', formatDateTime(status.createdAt)]); } - if (status.consoleUrl) { - logger.log(` ${colors.dim('Console:')} ${formatUrl(status.consoleUrl)}`); + fields.push(['console', formatUrl(status.consoleUrl)]); } - if (status.tests.length > 0) { - logger.log(sectionHeader('Test Results')); - - for (const item of status.tests) { - logger.log(` ${formatStatus(item.status)} ${colors.bold(item.name)}`); + logger.log(ui.section('Upload Status')); + logger.log(ui.branch([ui.status(status.status), ...ui.fields(fields)])); - if (item.status === 'FAILED' && item.failReason) { - logger.log(` ${colors.error('Fail reason:')} ${item.failReason}`); - } - - if (item.durationSeconds) { - logger.log( - ` ${colors.dim('Duration:')} ${formatDurationSeconds(item.durationSeconds)}`, - ); - } - - if (item.createdAt) { - logger.log(` ${colors.dim('Created:')} ${formatDateTime(item.createdAt)}`); - } - - logger.log(''); - } + if (status.tests.length > 0) { + logger.log(ui.section('Test Results')); + logger.log( + ui.branch( + status.tests.map((item) => { + const head = `${ui.statusSymbol(item.status)} ${item.name}`; + const meta: string[] = []; + if (item.durationSeconds) { + meta.push(formatDurationSeconds(item.durationSeconds)); + } + if (item.createdAt) { + meta.push(colors.dim(formatDateTime(item.createdAt))); + } + if (item.status === 'FAILED' && item.failReason) { + meta.push(colors.error(item.failReason)); + } + return meta.length > 0 + ? `${head} ${colors.dim('·')} ${meta.join(colors.dim(' · '))}` + : head; + }), + ), + ); } } catch (error) { throw new CliError(`Failed to get status: ${(error as Error).message}`); diff --git a/src/commands/switch-org.ts b/src/commands/switch-org.ts index ba51987..9da3f3e 100644 --- a/src/commands/switch-org.ts +++ b/src/commands/switch-org.ts @@ -11,7 +11,8 @@ import { resolveAuth } from '../utils/auth'; import { CliError, logger } from '../utils/cli'; import { readConfig, resolveApiUrl, writeConfig } from '../utils/config-store'; import { fetchOrgs, pickOrg, OrgListItem } from '../utils/orgs'; -import { colors, symbols } from '../utils/styling'; +import { colors } from '../utils/styling'; +import { ui } from '../utils/ui'; export const switchOrgCommand = defineCommand({ meta: { @@ -59,7 +60,7 @@ export const switchOrgCommand = defineCommand({ current_org_name: chosen.name, }); - logger.log(`${symbols.success} ${colors.bold('Switched')} to ${colors.highlight(chosen.name)}`); + logger.log(ui.success(`Switched to ${colors.highlight(chosen.name)}`)); }, }); diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index a499710..5efe736 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -27,7 +27,8 @@ import { getInstallMethod, logger, } from '../utils/cli'; -import { colors, symbols } from '../utils/styling'; +import { colors } from '../utils/styling'; +import { ui } from '../utils/ui'; const DEFAULT_DOWNLOAD_BASE = 'https://get.devicecloud.dev'; @@ -66,7 +67,7 @@ export const upgradeCommand = defineCommand({ if (!versionService.isOutdated(current, latest)) { logger.log( - `${symbols.success} Already on the latest version (${colors.highlight(current)}).`, + ui.success(`Already on the latest version (${colors.highlight(current)})`), ); return; } @@ -94,9 +95,9 @@ export const upgradeCommand = defineCommand({ const sumsUrl = `${base}/download/${latest}/SHA256SUMS`; logger.log( - `${symbols.info} Upgrading ${colors.highlight(current)} → ${colors.highlight(latest)}`, + ui.info(`Upgrading ${colors.highlight(current)} → ${colors.highlight(latest)}`), ); - logger.log(colors.dim(` ${binaryUrl}`)); + logger.log(ui.note(` ${binaryUrl}`)); const execPath = process.execPath; const tmpPath = `${execPath}.new`; @@ -121,7 +122,7 @@ export const upgradeCommand = defineCommand({ ); } - logger.log(`${symbols.success} Upgraded to ${colors.highlight(latest)}`); + logger.log(ui.success(`Upgraded to ${colors.highlight(latest)}`)); }, }); diff --git a/src/commands/upload.ts b/src/commands/upload.ts index 5b66f65..18f342f 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -9,7 +9,8 @@ import { resolveAuth } from '../utils/auth'; import { CliError, logger } from '../utils/cli'; import { resolveApiUrl } from '../utils/config-store'; import { downloadExpoUrl, extractTarGz, findAppBundle, isUrl } from '../utils/expo'; -import { colors, formatId, sectionHeader, symbols } from '../utils/styling'; +import { colors, formatId } from '../utils/styling'; +import { ui } from '../utils/ui'; export const uploadCommand = defineCommand({ meta: { @@ -54,14 +55,14 @@ export const uploadCommand = defineCommand({ } if (isUrl(resolvedFile)) { - out(` ${colors.dim('→ Downloading Expo build from URL...')}`); + out(ui.running('Downloading Expo build from URL…')); const tarPath = await downloadExpoUrl(resolvedFile, debug); tempFiles.push(tarPath); resolvedFile = tarPath; } if (resolvedFile.endsWith('.tar.gz')) { - out(` ${colors.dim('→ Extracting Expo archive...')}`); + out(ui.running('Extracting Expo archive…')); const extractDir = await extractTarGz(resolvedFile, debug); tempFiles.push(extractDir); resolvedFile = await findAppBundle(extractDir); @@ -80,9 +81,8 @@ export const uploadCommand = defineCommand({ await verifyAppZip(resolvedFile); } - out(sectionHeader('Uploading app binary')); - out(` ${colors.dim('→ File:')} ${colors.highlight(resolvedFile)}`); - out(''); + out(ui.section('Uploading app binary')); + out(ui.branch(ui.fields([['file', colors.highlight(resolvedFile)]]))); const appBinaryId = await uploadBinary({ auth, @@ -99,10 +99,14 @@ export const uploadCommand = defineCommand({ return; } - logger.log(`\n${symbols.success} ${colors.bold('Upload complete')}`); - logger.log(` ${colors.dim('Binary ID:')} ${formatId(appBinaryId)}\n`); - logger.log(colors.dim('You can use this Binary ID in subsequent test runs with:')); - logger.log(colors.info(`dcd cloud --app-binary-id ${appBinaryId} path/to/flow.yaml\n`)); + logger.log(`\n${ui.success('Upload complete')}`); + logger.log( + ui.branch([ + ...ui.fields([['binary id', formatId(appBinaryId)]]), + ui.note('Use this Binary ID in subsequent test runs with:'), + colors.info(`dcd cloud --app-binary-id ${appBinaryId} path/to/flow.yaml`), + ]), + ); } catch (error) { logger.error(error as Error, { exit: 1, json }); } finally { diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index 306fc18..922010f 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -5,7 +5,8 @@ import { defineCommand } from 'citty'; import { logger } from '../utils/cli'; import { readConfig } from '../utils/config-store'; -import { colors, sectionHeader, symbols } from '../utils/styling'; +import { colors, formatId } from '../utils/styling'; +import { type Field, ui } from '../utils/ui'; export const whoamiCommand = defineCommand({ meta: { @@ -16,19 +17,24 @@ export const whoamiCommand = defineCommand({ const config = readConfig(); if (!config?.session) { logger.log( - `${symbols.info} Not logged in. Run ${colors.highlight('dcd login')} or set ${colors.highlight('DEVICE_CLOUD_API_KEY')}.`, + ui.info( + `Not logged in. Run ${colors.highlight('dcd login')} or set ${colors.highlight('DEVICE_CLOUD_API_KEY')}.`, + ), ); return; } - logger.log(sectionHeader('devicecloud.dev')); - logger.log(` ${colors.dim('User:')} ${colors.highlight(config.session.user_email)}`); - if (config.current_org_name) { - logger.log(` ${colors.dim('Org:')} ${colors.highlight(config.current_org_name)}`); - } else if (config.current_org_id) { - // Fallback for sessions stored before we persisted org name alongside id. - logger.log(` ${colors.dim('Org:')} ${colors.highlight(config.current_org_id)}`); + + // Fallback for sessions stored before we persisted org name alongside id. + const org = config.current_org_name ?? config.current_org_id; + + const fields: Field[] = [['user', formatId(config.session.user_email)]]; + if (org) { + fields.push(['org', formatId(org)]); } - logger.log(` ${colors.dim('Env:')} ${config.env}`); + fields.push(['env', config.env]); + + logger.log(ui.section('devicecloud.dev')); + logger.log(ui.branch(ui.fields(fields))); }, }); diff --git a/src/gateways/realtime-gateway.ts b/src/gateways/realtime-gateway.ts index ddc82df..4d42a82 100644 --- a/src/gateways/realtime-gateway.ts +++ b/src/gateways/realtime-gateway.ts @@ -22,6 +22,12 @@ import { import { ENVIRONMENTS, type DcdEnvName } from '../config/environments'; export interface RealtimeResultsSubscription { + /** + * Whether the channel is currently subscribed and receiving pushes. False + * until the socket subscribes, and again if it errors/closes — the backstop + * poll always covers the gap. + */ + isConnected(): boolean; /** Tear down the channel and close the socket. Best-effort, never throws. */ unsubscribe(): Promise; } @@ -35,6 +41,8 @@ export interface RealtimeSubscribeOptions { log?: (message: string) => void; /** Fired when a result row for this upload changes. */ onChange: () => void; + /** Fired whenever the connection state flips (subscribed ↔ disconnected). */ + onConnectionChange?: (connected: boolean) => void; orgId: string; uploadId: string; } @@ -55,11 +63,18 @@ export class RealtimeResultsGateway { static subscribe( options: RealtimeSubscribeOptions, ): RealtimeResultsSubscription { - const { accessToken, debug, env, log, onChange, orgId, uploadId } = options; + const { accessToken, debug, env, log, onChange, onConnectionChange, orgId, uploadId } = options; const dbg = (message: string) => { if (debug && log) log(`[DEBUG] [realtime] ${message}`); }; + let connected = false; + const setConnected = (next: boolean) => { + if (next === connected) return; + connected = next; + onConnectionChange?.(next); + }; + let client: SupabaseClient | undefined; try { const { url, anonKey } = ENVIRONMENTS[env].supabase; @@ -94,6 +109,7 @@ export class RealtimeResultsGateway { .subscribe((status) => { if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) { dbg('subscribed'); + setConnected(true); } else if ( status === REALTIME_SUBSCRIBE_STATES.CHANNEL_ERROR || status === REALTIME_SUBSCRIBE_STATES.TIMED_OUT || @@ -102,12 +118,15 @@ export class RealtimeResultsGateway { // Don't try to recover — the backstop poll covers us. Surface in // debug so a network-blocked websocket is diagnosable. dbg(`channel ${status}; relying on backstop poll`); + setConnected(false); } }); const activeClient = client; return { + isConnected: () => connected, async unsubscribe() { + setConnected(false); try { await activeClient.removeChannel(channel); await activeClient.realtime.disconnect(); @@ -124,7 +143,7 @@ export class RealtimeResultsGateway { } catch { /* ignore */ } - return { async unsubscribe() {} }; + return { isConnected: () => false, async unsubscribe() {} }; } } } diff --git a/src/services/results-polling.service.ts b/src/services/results-polling.service.ts index e54d3d1..304fe94 100644 --- a/src/services/results-polling.service.ts +++ b/src/services/results-polling.service.ts @@ -10,7 +10,8 @@ import type { AuthContext } from '../types/domain/auth.types'; import { paths } from '../types/generated/schema.types'; import { checkInternetConnectivity } from '../utils/connectivity'; import { ux } from '../utils/progress'; -import { colors, formatTestSummary, table } from '../utils/styling'; +import { colors, formatTestSummary, statusPalette, table } from '../utils/styling'; +import { type Field, ui } from '../utils/ui'; type TestResult = NonNullable< paths['/results/{uploadId}']['get']['responses']['200']['content']['application/json']['results'] @@ -114,7 +115,6 @@ export class ResultsPollingService { this.initializePollingDisplay(json, logger); let sequentialPollFailures = 0; - let previousSummary = ''; const pollIntervalMs = auth.mode === 'bearer' @@ -152,17 +152,38 @@ export class ResultsPollingService { }); }; + // Live display state, recomposed by renderStatus() so the footer (countdown + // to the next backstop poll + realtime connection state) stays current + // between polls without re-fetching. `nextPollAt` is an epoch-ms deadline + // while waiting, or null while a fetch is in flight. + let subscription: RealtimeResultsSubscription | undefined; + let realtimeEnabled = false; + let statusBody = ''; + let nextPollAt: null | number = null; + const renderStatus = () => { + if (json) return; + const footer = this.buildStatusFooter( + realtimeEnabled, + subscription?.isConnected() ?? false, + nextPollAt, + ); + ux.action.status = footer ? `${statusBody}\n${footer}` : statusBody; + }; + // Realtime is a latency optimisation over the backstop poll; only logged-in // (bearer) users can authenticate the socket under RLS. Any failure inside // the gateway degrades silently to pure polling. - let subscription: RealtimeResultsSubscription | undefined; if (auth.mode === 'bearer' && auth.accessToken && auth.orgId && auth.env) { + realtimeEnabled = true; subscription = RealtimeResultsGateway.subscribe({ accessToken: auth.accessToken, debug, env: auth.env, log: logger, onChange: poke, + // Reflect connect/disconnect in the live footer immediately rather than + // waiting for the next ticker frame. + onConnectionChange: renderStatus, orgId: auth.orgId, uploadId, }); @@ -177,21 +198,26 @@ export class ResultsPollingService { logger(`[DEBUG] Starting polling loop for results`); } + // Tick the live footer once a second so the countdown actually counts down + // (the spinner's own frames don't recompute our message). Unref'd so it + // never keeps the process alive on its own. + const ticker: NodeJS.Timeout | null = json ? null : setInterval(renderStatus, 1000); + ticker?.unref?.(); + try { // Poll in a loop until all tests complete // eslint-disable-next-line no-constant-condition while (true) { try { + nextPollAt = null; + renderStatus(); + const updatedResults = await this.fetchAndLogResults(apiUrl, auth, uploadId, debug, logger); const { summary } = this.calculateStatusSummary(updatedResults); - previousSummary = this.updateDisplayStatus( - updatedResults, - quiet, - json, - summary, - previousSummary, - ); + if (!json) { + statusBody = this.buildStatusBody(updatedResults, quiet, summary); + } const allComplete = updatedResults.every( (result) => !['PENDING', 'QUEUED', 'RUNNING'].includes(result.status), @@ -212,7 +238,9 @@ export class ResultsPollingService { sequentialPollFailures = 0; // Wait for the next backstop poll, or a realtime poke, whichever comes - // first. + // first, while the footer counts down to the deadline. + nextPollAt = Date.now() + pollIntervalMs; + renderStatus(); await waitForNextPoll(pollIntervalMs); } catch (error) { // Re-throw RunFailedError immediately (test failures, not polling errors) @@ -242,6 +270,9 @@ export class ResultsPollingService { } } } finally { + if (ticker) { + clearInterval(ticker); + } if (subscription) { if (debug && logger) { logger('[DEBUG] Closing realtime subscription'); @@ -344,32 +375,8 @@ export class ResultsPollingService { }, status: { get(row) { - const statusUpper = row.status.toUpperCase(); - switch (statusUpper) { - case 'PASSED': { - return colors.success(row.status); - } - - case 'FAILED': { - return colors.error(row.status); - } - - case 'RUNNING': { - return colors.info(row.status); - } - - case 'PENDING': { - return colors.warning(row.status); - } - - case 'QUEUED': { - return colors.dim(row.status); - } - - default: { - return colors.dim(row.status); - } - } + const { color } = statusPalette(row.status); + return color(row.status.toLowerCase()); }, }, test: { @@ -395,8 +402,8 @@ export class ResultsPollingService { if (logger) { logger('\n'); - logger(colors.bold('Run completed') + colors.dim(', you can access the results at:')); - logger(colors.url(consoleUrl)); + logger(ui.success('Run completed')); + logger(ui.branch(ui.fields([['results', colors.url(consoleUrl)]]))); logger('\n'); } } @@ -607,43 +614,60 @@ export class ResultsPollingService { }); } - private updateDisplayStatus( + /** + * Build the body of the live status display (the per-test table, or just the + * one-line summary in quiet mode). The footer (countdown + realtime state) is + * appended separately by {@link buildStatusFooter} so it can re-render on a + * timer without re-fetching. + */ + private buildStatusBody( results: TestResult[], quiet: boolean, - json: boolean, summary: string, - previousSummary: string, ): string { - if (json) { - return previousSummary; + if (quiet) { + return summary; } - if (quiet) { - if (summary !== previousSummary) { - ux.action.status = summary; - return summary; - } - } else { - ux.action.status = colors.dim('\nStatus Test\n─────────── ───────────────'); - for (const { - retry_of: isRetry, - status, - test_file_name: test, - } of results) { - const statusFormatted = status.toUpperCase() === 'PASSED' - ? colors.success(status.padEnd(10, ' ')) - : status.toUpperCase() === 'FAILED' - ? colors.error(status.padEnd(10, ' ')) - : status.toUpperCase() === 'RUNNING' - ? colors.info(status.padEnd(10, ' ')) - : status.toUpperCase() === 'QUEUED' - ? colors.dim(status.padEnd(10, ' ')) - : colors.warning(status.padEnd(10, ' ')); + const rows: Field[] = results.map( + ({ retry_of: isRetry, status, test_file_name: test }) => { + const label = `${ui.statusSymbol(status)} ${ui.statusWord(status)}`; const retryText = isRetry ? colors.dim(' (retry)') : ''; - ux.action.status += `\n${statusFormatted} ${test}${retryText}`; - } + return [label, `${test}${retryText}`]; + }, + ); + + return `\n${ui.branch(ui.fields(rows))}`; + } + + /** + * Build the live footer shown under the status display: whether realtime + * updates are connected (for logged-in users) and how long until the next + * backstop poll. While a fetch is in flight (`nextPollAt` is null) the + * countdown reads "refreshing…". + */ + private buildStatusFooter( + realtimeEnabled: boolean, + realtimeConnected: boolean, + nextPollAt: null | number, + ): string { + const parts: string[] = []; + + if (realtimeEnabled) { + parts.push( + realtimeConnected + ? colors.success('● realtime connected') + : colors.warning('○ realtime connecting…'), + ); + } + + if (nextPollAt === null) { + parts.push(colors.dim('refreshing…')); + } else { + const secondsLeft = Math.max(0, Math.ceil((nextPollAt - Date.now()) / 1000)); + parts.push(colors.dim(`next refresh in ${secondsLeft}s`)); } - return previousSummary; + return parts.join(colors.dim(' · ')); } } diff --git a/src/utils/styling.ts b/src/utils/styling.ts index 65fb869..f4549be 100644 --- a/src/utils/styling.ts +++ b/src/utils/styling.ts @@ -9,7 +9,7 @@ import { findEnvByApiUrl } from '../config/environments'; /** Strip ANSI color escape sequences for visible-width calculations. */ // eslint-disable-next-line no-control-regex -- matches ANSI escape sequences -const stripAnsi = (s: string): string => s.replace(/\u001B\[[0-9;]*m/g, ''); +export const stripAnsi = (s: string): string => s.replace(/\u001B\[[0-9;]*m/g, ''); /** * Status symbols with associated colors @@ -41,81 +41,77 @@ export const colors = { } as const; /** - * Dividers for visual separation + * Structural glyphs that give the CLI its tree-shaped layout. `section` marks a + * top-level heading; `branch` opens the group of detail rows beneath it. + * See STYLE_GUIDE.md. */ -export const dividers = { - heavy: chalk.gray('═'.repeat(80)), - light: chalk.gray('─'.repeat(80)), - short: chalk.gray('─'.repeat(40)), +export const glyphs = { + branch: '⎿', + section: '⏺', } as const; /** - * Format a status with appropriate symbol and color - * @param status - The status string to format - * @returns Formatted status string with color and symbol + * The single source of truth mapping a run/test status to its colour and + * (already-coloured) symbol. Both {@link formatStatus} and the `ui` status + * helpers build on this, so every status reads identically everywhere. + * @param status - The status string (case-insensitive) */ -export function formatStatus(status: string): string { - const statusUpper = status.toUpperCase(); - - switch (statusUpper) { +export function statusPalette(status: string): { + color: (s: string) => string; + symbol: string; +} { + switch (status.toUpperCase()) { case 'PASSED': { - return `${symbols.success} ${colors.success(status)}`; + return { color: colors.success, symbol: symbols.success }; } + case 'ERROR': case 'FAILED': { - return `${symbols.error} ${colors.error(status)}`; + return { color: colors.error, symbol: symbols.error }; } case 'RUNNING': { - return `${symbols.running} ${colors.info(status)}`; + return { color: colors.info, symbol: symbols.running }; } case 'PENDING': { - return `${symbols.pending} ${colors.warning(status)}`; + return { color: colors.warning, symbol: symbols.pending }; } case 'QUEUED': { - return `${symbols.queued} ${colors.dim(status)}`; + return { color: colors.dim, symbol: symbols.queued }; } case 'CANCELLED': { - return `${symbols.cancelled} ${colors.dim(status)}`; + return { color: colors.dim, symbol: symbols.cancelled }; } default: { - return `${symbols.unknown} ${colors.dim(status)}`; + return { color: colors.dim, symbol: symbols.unknown }; } } } /** - * Format a section header - * @param title - The title of the section - * @returns Formatted section header - */ -export function sectionHeader(title: string): string { - return `\n${colors.bold(title)}\n${dividers.light}`; -} - -/** - * Format a key-value pair with optional icon - * @param icon - Icon to display before the key - * @param key - The key name - * @param value - The value to display - * @returns Formatted key-value string + * Format a status as a coloured symbol followed by the lowercased status word, + * e.g. `✓ passed`. + * @param status - The status string to format + * @returns Formatted status string with color and symbol */ -export function keyValue(icon: string, key: string, value: string): string { - return `${icon} ${colors.dim(key + ':')} ${colors.highlight(value)}`; +export function formatStatus(status: string): string { + const { color, symbol } = statusPalette(status); + return `${symbol} ${color(status.toLowerCase())}`; } /** - * Format a list item - * @param text - The text of the list item - * @param prefix - The prefix character (default: '•') - * @returns Formatted list item + * Format a top-level section header in the tree style: a `⏺` marker followed by + * the bold title, preceded by a blank line for separation. Detail rows belong + * underneath in a branch group (see the `ui` helpers). + * @param title - The title of the section + * @returns Formatted section header */ -export function listItem(text: string, prefix: string = '•'): string { - return `${colors.dim(prefix)} ${text}`; +export function sectionHeader(title: string): string { + return `\n${colors.dim(glyphs.section)} ${colors.bold(title)}`; } /** @@ -162,24 +158,6 @@ export function formatTestSummary(summary: { return parts.join(' │ '); } -/** - * Format a box with content - * @param content - The content to display in the box - * @returns Formatted box with borders - */ -export function box(content: string): string { - const lines = content.split('\n'); - const visibleLen = (s: string): number => stripAnsi(s).length; - const maxLength = Math.max(...lines.map((l) => visibleLen(l))); - const top = chalk.gray('┌' + '─'.repeat(maxLength + 2) + '┐'); - const bottom = chalk.gray('└' + '─'.repeat(maxLength + 2) + '┘'); - const middle = lines - .map((line) => chalk.gray('│ ') + line + ' '.repeat(Math.max(0, maxLength - visibleLen(line))) + chalk.gray(' │')) - .join('\n'); - - return `${top}\n${middle}\n${bottom}`; -} - /** * Minimal column table renderer. * Columns are defined with a `get(row) => string` accessor and optional header label. diff --git a/src/utils/ui.ts b/src/utils/ui.ts new file mode 100644 index 0000000..54431f0 --- /dev/null +++ b/src/utils/ui.ts @@ -0,0 +1,121 @@ +/** + * Unified rendering layer for all CLI command output. + * + * Every command composes its output from these helpers so the look stays + * consistent — a tree of `⏺` section headings with `⎿` branch groups of detail + * rows beneath them, inspired by Claude Code. See STYLE_GUIDE.md for the rules + * and worked examples. + * + * Functions RETURN strings (never write to stdout themselves) so callers route + * them through their own logger / `--json` gate and they stay unit-testable. + */ +import { + colors, + formatStatus, + glyphs, + sectionHeader, + statusPalette, + stripAnsi, + symbols, +} from './styling'; + +/** + * Indentation under a section. The branch glyph sits two columns in and its + * content begins at column four; continuation rows align to the same column. + * + * ⏺ Title + * ⎿ first detail row + * continuation row + */ +const BRANCH_PREFIX = ` ${colors.dim(glyphs.branch)} `; +const CONTINUATION_PREFIX = ' '; + +/** A `[label, value]` detail row; the value is passed through already styled. */ +export type Field = [label: string, value: string]; + +export const ui = { + /** + * Render detail rows as a branch group beneath the most recent section: + * + * ⎿ row one + * row two + * + * Embedded newlines in a row are kept aligned. Returns `''` for no rows. + */ + branch(rows: string[]): string { + const lines = rows.flatMap((row) => row.split('\n')); + if (lines.length === 0) { + return ''; + } + + return lines + .map((line, index) => + index === 0 ? `${BRANCH_PREFIX}${line}` : `${CONTINUATION_PREFIX}${line}`, + ) + .join('\n'); + }, + + /** + * Align `[label, value]` pairs into `label value` rows for use inside a + * {@link branch} group. Plain labels are dimmed; labels the caller already + * styled (e.g. `colors.bold(name)`) are left as-is. Padding is measured on + * visible width so ANSI colours never throw the columns off. + */ + fields(pairs: Field[]): string[] { + const width = Math.max(0, ...pairs.map(([label]) => stripAnsi(label).length)); + return pairs.map(([label, value]) => { + const styled = stripAnsi(label) === label ? colors.dim(label) : label; + const padding = ' '.repeat(Math.max(0, width - stripAnsi(label).length)); + return `${styled}${padding} ${value}`; + }); + }, + + /** `ℹ message` — neutral, standalone information. */ + info(message: string): string { + return `${symbols.info} ${message}`; + }, + + /** Dimmed secondary text — hints, tips, "you can close this terminal", etc. */ + note(message: string): string { + return colors.dim(message); + }, + + /** `▶ message` — an action currently in progress. */ + running(message: string): string { + return `${symbols.running} ${message}`; + }, + + /** + * A top-level section header — `⏺ Title`, preceded by a blank line. Detail + * rows belong underneath via {@link branch}. + */ + section(title: string): string { + return sectionHeader(title); + }, + + /** A coloured status word + symbol, e.g. `✓ passed`. Delegates to the shared palette. */ + status(status: string): string { + return formatStatus(status); + }, + + /** The coloured status symbol alone, e.g. green `✓`. */ + statusSymbol(status: string): string { + return statusPalette(status).symbol; + }, + + /** The lowercased status word in its status colour, e.g. green `passed`. */ + statusWord(status: string): string { + const { color } = statusPalette(status); + return color(status.toLowerCase()); + }, + + /** `✓ message` — a completed action; the message is emphasised. */ + success(message: string): string { + return `${symbols.success} ${colors.bold(message)}`; + }, + + /** `⚠ message` — a non-fatal warning. */ + warn(message: string): string { + return `${symbols.warning} ${message}`; + }, +} as const; diff --git a/test/integration/list.integration.test.ts b/test/integration/list.integration.test.ts index b1ce934..565cdb9 100644 --- a/test/integration/list.integration.test.ts +++ b/test/integration/list.integration.test.ts @@ -141,7 +141,7 @@ describe('List Command Integration Tests', () => { const { stdout } = await exec(command, { timeout: 15_000 }); expect(stdout).to.include('Recent Uploads'); - expect(stdout).to.match(/Showing \d+ of \d+ uploads/); + expect(stdout).to.match(/showing \d+ of \d+/); }); }); diff --git a/test/integration/upload.integration.test.ts b/test/integration/upload.integration.test.ts index 8e970ca..4f3ab19 100644 --- a/test/integration/upload.integration.test.ts +++ b/test/integration/upload.integration.test.ts @@ -122,7 +122,7 @@ describe('Upload Command Integration Tests', () => { const { stdout } = await exec(command, { timeout: 30_000 }); expect(stdout).to.include('Upload complete'); - expect(stdout).to.include('Binary ID'); + expect(stdout).to.include('binary id'); expect(stdout).to.include('dcd cloud --app-binary-id'); // Should not be JSON format expect(() => JSON.parse(stdout)).to.throw();