Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
90 changes: 90 additions & 0 deletions STYLE_GUIDE.md
Original file line number Diff line number Diff line change
@@ -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'));
```
160 changes: 70 additions & 90 deletions src/commands/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())}`,
]),
);
}
};

Expand Down Expand Up @@ -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.',
),
);
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}

Expand Down
Loading
Loading