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
6 changes: 3 additions & 3 deletions .github/workflows/cli-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
lint-and-test:
runs-on: ubuntu-latest

# The mock-api lives in the private moropo-com/dcd repo, checked out via an
# The mock-api lives in the private devicecloud-dev/dcd repo, checked out via an
# SSH deploy key. GitHub does NOT expose secrets to pull_request workflows
# triggered from forks, so that checkout (and the integration tests that need
# it) can only run for same-repo events. Fork PRs still run lint/typecheck/build.
Expand All @@ -57,7 +57,7 @@ jobs:
if: env.HAS_PRIVATE_ACCESS == 'true'
uses: actions/checkout@v7
with:
repository: moropo-com/dcd
repository: devicecloud-dev/dcd
path: dcd
ssh-key: ${{ secrets.DCD_SSH_DEPLOY_KEY }}
# api/swagger.json is a file, which cone-mode sparse checkout rejects
Expand Down Expand Up @@ -106,7 +106,7 @@ jobs:

- name: Skip integration tests (fork PR — no mock-api access)
if: env.HAS_PRIVATE_ACCESS != 'true'
run: echo "::notice::Integration tests skipped — the mock-api (private moropo-com/dcd) is not accessible from fork PRs. Lint, typecheck, and build still ran."
run: echo "::notice::Integration tests skipped — the mock-api (private devicecloud-dev/dcd) is not accessible from fork PRs. Lint, typecheck, and build still ran."

- name: Build CLI
working-directory: ./cli
Expand Down
37 changes: 32 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Commands

- `pnpm dcd <args>` — run the CLI from source via `tsx`.
- `pnpm build` — clean, compile TypeScript to `dist/`, and `chmod +x dist/index.js` so the published binary is directly executable.
- `pnpm build` — clean, compile TypeScript to `dist/`, and `chmod +x dist/index.js dist/mcp/index.js` so both published binaries are directly executable.
- `pnpm build:binaries` — `scripts/build-binaries.mjs` produces the bun-compiled, self-contained `dcd-<platform>-<arch>` binaries published to GitHub Releases (the install `dcd upgrade` self-updates). The platform/arch keys must stay in sync with `ASSET_BY_PLATFORM` in `src/commands/upgrade.ts`.
- `pnpm lint` — ESLint over `src/` and `test/`.
- `pnpm typecheck` — `tsc --noEmit` over `src/` and `test/` (strict mode; `pnpm build` only compiles `src/`).
- `pnpm test` — runs `scripts/test-runner.mjs`: builds the CLI, boots a mock API, then runs all `test/**/*.test.ts` via mocha + ts-node. The mock API lives in the **sibling `dcd/` repo** (`../dcd/mock-api`). Override its location with `MOCK_API_DIR=/path/to/mock-api`.
- Run a single test: `pnpm mocha test/integration/cloud.integration.test.ts --timeout 60000` (picks up `.mocharc.json` which wires tsx; requires the mock API already running on its expected port).
- `pnpm typecheck` — `tsc --noEmit -p tsconfig.test.json` over `src/` and `test/` (strict mode; `pnpm build` only compiles `src/`). Requires Node `>=22`.
- `pnpm test` — runs `scripts/test-runner.mjs`: builds the CLI, boots the mock API, then runs all `test/**/*.test.ts` via mocha. TypeScript is loaded by **tsx** (`.mocharc.json`'s `node-option: ["import=tsx"]`), *not* ts-node — Mocha 11 imports specs as ESM, which bypasses the `require: ts-node/register` hook. The mock API lives in the **sibling `dcd/` repo** (`../dcd/mock-api`, started via `npm run start:auth` on port 3001). Override its location with `MOCK_API_DIR=/path/to/mock-api`. The runner isolates `DCD_CONFIG_DIR` to a temp dir so tests never touch your real `dcd login` session.
- Tests split into `test/unit/*` (pure, no backend) and `test/integration/*` (drive the built CLI against the mock API). Run a single test: `pnpm mocha test/integration/cloud.integration.test.ts --timeout 60000` (picks up `.mocharc.json` which wires tsx; integration specs require the mock API already running on port 3001).

## Entry point

Expand All @@ -19,7 +20,7 @@ The package ships a **second bin, `dcd-mcp`** (`bin.dcd-mcp` → `dist/mcp/index

## Architecture

Top-level `defineCommand` in `src/index.ts` wires ten subcommands (`cloud`, `upload`, `list`, `status`, `artifacts`, `live`, plus the auth-related `login`, `logout`, `whoami`, `switch-org`). `cloud` is the primary command and replicates `maestro cloud`.
Top-level `defineCommand` in `src/index.ts` wires eleven subcommands (`cloud`, `upload`, `list`, `status`, `artifacts`, `live`, `upgrade`, plus the auth-related `login`, `logout`, `whoami`, `switch-org`). `cloud` is the primary command and replicates `maestro cloud`; `upgrade` self-updates the standalone bun binary (no-op for npm installs, which it redirects to `npm install -g`). Note `src/index.ts` deliberately **reimplements** citty's `runMain` rather than calling it — see the Telemetry section for why.

**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.

Expand All @@ -45,3 +46,29 @@ Top-level `defineCommand` in `src/index.ts` wires ten subcommands (`cloud`, `upl
**Telemetry.** `src/services/telemetry.service.ts` ships lifecycle (`command started` / `command completed` / `command failed`) and error events to the dcd API's `/cli/logs` proxy → Axiom `cli-dev` / `cli-prod`. Wired in at three points: `src/index.ts` replicates citty's `runMain` (which would otherwise swallow errors and exit 1) to record start/success/failure and honor `CliError.exitCode`; `src/utils/auth.ts` calls `telemetry.configure({ auth })` from `resolveAuth` so the token never has to be re-derived; `src/utils/cli.ts` `logger.error` calls `telemetry.flushSync()` (which shells out to `curl` because `process.exit` bypasses `beforeExit`) before exiting. Unauthenticated invocations (`--help`, `--version`, `dcd login` pre-success) buffer in memory and drop on exit — by design, since there's no identity to attach. Opt out per-invocation with `DCD_TELEMETRY_DISABLED=1`.

**MCP server.** `src/mcp/` is a third front-end onto the same service layer (a sibling to `src/commands/`), shipped as the `dcd-mcp` bin over stdio transport (`@modelcontextprotocol/sdk`, `zod` schemas). `index.ts` boots the server; `server.ts` registers tools; `context.ts` resolves auth + API URL **lazily and once** (so `tools/list` works unauthenticated and auth errors surface as tool errors, not a boot crash) via the same `resolveAuth`/`resolveApiUrl` as the CLI — `DEVICE_CLOUD_API_KEY` env or stored `dcd login` session, with `DCD_API_URL` to override. **Critical invariant: stdout is the JSON-RPC channel** — tools must never call the `src/commands/*` layer or `utils/cli` `logger` (both write to stdout / can `process.exit`); they call services/gateways directly with `logStderr` and return data via `helpers.ts` `jsonResult`/`errorResult`. The `runTool` wrapper records `mcp tool …` telemetry and converts thrown errors to `isError` results. Tools: `dcd_list_devices`, `dcd_list_runs`, `dcd_get_status`, `dcd_download_artifacts` (read-only), and `dcd_run_cloud_test` (billable — gated out by `--read-only` / `DCD_MCP_READONLY=1`, annotated destructive, async-by-default). `dcd_run_cloud_test` reuses `computeCommonRoot`/`buildTestMetadataMap` from `src/services/flow-paths.ts` (extracted from `cloud.ts` so both build identical server-side paths). Registry manifest: `server.json` at repo root.

## Contributing

Full guide in `CONTRIBUTING.md`; the operationally important parts (the ones that gate a merge or affect a release):

- **Branch off `dev`** (the default branch) and open PRs **against `dev`**. `production` is the maintainer-only stable track — never target it directly.
- PRs are **squash-merged**, so the **PR title becomes the commit** and must be a [Conventional Commit](https://www.conventionalcommits.org). The title — not the branch commits — is what release-please reads to compute the next version, so it matters even though individual commits are squashed away. A `PR Title` CI check enforces it.
- Type → bump (pre-1.0, so `feat` and breaking `!` both bump **minor**): `feat` minor; `fix`/`perf`/`deps`/`revert`/`refactor` patch; `docs`/`chore`/`test`/`ci`/`build`/`style` are hidden and bump nothing. Allowed scopes are free-form.
- **Never hand-edit `package.json` version, `CHANGELOG.md`, or the `.release-please-manifest*.json` files** — release-please owns all of them. `src/types/generated/schema.types.ts` is likewise generated (openapi-typescript).
- A first-time contributor must sign the CLA (the CLA Assistant bot comments on the first PR); the CLA check must be green to merge.
- **CI (`.github/workflows/cli-ci.yml`) runs on every PR** including forks: gitleaks secret scan, `pnpm lint`, `pnpm typecheck`, `pnpm build`, `pnpm audit --audit-level moderate`. The **integration tests need the private `devicecloud-dev/dcd` mock-api** (cloned via the `DCD_SSH_DEPLOY_KEY` secret), and GitHub withholds secrets from fork and Dependabot PRs — so `pnpm test` is **skipped there** and a maintainer runs the full suite before merge. gitleaks also runs as a pre-commit hook (allowlist in `.gitleaks.toml`); without the binary installed the hook self-skips and CI is the backstop.

## Releases

Fully automated by [release-please](https://github.com/googleapis/release-please) — no manual version bumping. `.github/workflows/release-please.yml` drives **two parallel tracks off two separate config+manifest pairs**:

| Push to | Track | Config / manifest | Version | npm tag |
| --- | --- | --- | --- | --- |
| `dev` | **beta** (prerelease) | `release-please-config-beta.json` / `.release-please-manifest-beta.json` | `X.Y.Z-beta.N` | `beta` |
| `production` | **stable** | `release-please-config.json` / `.release-please-manifest.json` | `X.Y.Z` | `latest` |

The two manifests track their versions **independently** (e.g. beta `5.0.0-beta.3` while stable is `5.0.0`). On each qualifying push release-please opens/updates a **Release PR** on that branch; merging the Release PR creates the git tag + GitHub Release, and the same workflow run **chains** (as `needs:` jobs, because a `GITHUB_TOKEN`-created release won't fire `release: published`) into:
1. `npm-publish.yml` — publishes to npm. Guards: a prod publish may only run from `production` and its version must **not** carry `-beta`; a beta version **must** carry `-beta`.
2. `release-binaries.yml` — bun-compiles the standalone binaries (`node scripts/build-binaries.mjs`) and uploads them to the GitHub Release. `get.devicecloud.dev` serves them by reading the GitHub Releases API at runtime, so there's no separate manifest to deploy.

**Promoting beta → stable** is a maintainer merging `dev` into `production` via a PR (also gated by `cli-ci.yml`) — that push to `production` is what triggers the stable Release PR. The current `release/promote-v5` branch is exactly such a promotion. Releases prefer an automation GitHub App token (`BOT_APP_ID`) so the Release PR triggers the CI / PR-title / CLA checks that branch protection requires, falling back to `GITHUB_TOKEN` until the App secrets are configured.
53 changes: 23 additions & 30 deletions src/commands/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
EiOSVersions,
} from '../types/domain/device.types.js';
import { resolveAuth } from '../utils/auth.js';
import { isCI } from '../utils/ci.js';
import { detectCiContext, isCI } from '../utils/ci.js';
import {
CliError,
coerceArray,
Expand All @@ -48,6 +48,7 @@ import {
CompatibilityData,
fetchCompatibilityData,
} from '../utils/compatibility.js';
import { renderNotices } from '../services/notices.service.js';
import { resolveApiUrl } from '../utils/config-store.js';
import { downloadExpoUrl, extractTarGz, findAppBundle, isUrl } from '../utils/expo.js';
import {
Expand Down Expand Up @@ -346,9 +347,14 @@ export const cloudCommand = defineCommand({
);
}

const ciContext = detectCiContext();
let compatibilityData: CompatibilityData;
try {
compatibilityData = await fetchCompatibilityData(apiUrl, auth);
compatibilityData = await fetchCompatibilityData(apiUrl, auth, {
cliVersion,
ciProvider: ciContext.provider,
ciWrapperVersion: ciContext.wrapperVersion,
});
if (debug) {
out('[DEBUG] Successfully fetched compatibility data from API');
}
Expand Down Expand Up @@ -378,20 +384,6 @@ export const cloudCommand = defineCommand({
},
);

// Soft deprecation notice for Maestro versions slated for removal on
// 26 June 2026. Non-fatal — these still run during the grace period.
const DEPRECATED_MAESTRO_VERSIONS = ['1.39.5', '1.41.0'];
if (DEPRECATED_MAESTRO_VERSIONS.includes(resolvedMaestroVersion)) {
warnOut(ui.warn(colors.bold(`Maestro ${resolvedMaestroVersion} is deprecated`)));
warnOut(
ui.branch([
`Maestro ${resolvedMaestroVersion} will be removed on 26 June 2026; after that, tests pinned to it will fail.`,
'Upgrade to Maestro 2.6.0 or above.',
`${colors.dim('See:')} ${colors.url('https://docs.devicecloud.dev/configuration/maestro-versions')}`,
]),
);
}

if (retry !== undefined && retry > 2) {
out(
ui.warn(
Expand Down Expand Up @@ -486,20 +478,21 @@ export const cloudCommand = defineCommand({
logger: (m: string) => out(m),
});

// iOS 16 deprecation notice (soft warning during the grace period;
// removed on 23 August 2026). Only fires on an explicit --ios-version 16 —
// when omitted the API defaults to iOS 17, so no false warning.
const DEPRECATED_IOS_VERSIONS = ['16'];
if (iOSVersion && DEPRECATED_IOS_VERSIONS.includes(iOSVersion)) {
warnOut(ui.warn(colors.bold('iOS 16 is deprecated')));
warnOut(
ui.branch([
'iOS 16 will be removed on 23 August 2026; after that, tests targeting it will fail.',
'Switch to iOS 17 or newer — iPhone 14 also supports 17 and 18.',
`${colors.dim('See:')} ${colors.url('https://docs.devicecloud.dev/getting-started/devices-configuration')}`,
]),
);
}
// Render DB-driven notices (deprecation/warn/info/marketing) the API
// returned with the compatibility data. Replaces the previously hardcoded
// iOS-16 deprecation warning — that is now a seeded notice gated on the
// selected iOS version below. Honours --json via out/warnOut.
renderNotices(
compatibilityData.notices,
{
ios_version: iOSVersion,
android_api_level: androidApiLevel,
cli_version: cliVersion,
ci_provider: ciContext.provider,
ci_wrapper_version: ciContext.wrapperVersion,
},
{ out },
);

deviceValidationService.validateAndroidDevice(
androidApiLevel,
Expand Down
145 changes: 145 additions & 0 deletions src/services/notices.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { ui } from '../utils/ui.js';
import { colors } from '../utils/styling.js';
import { compareSemver } from './version.service.js';

export type NoticeLevel = 'deprecation' | 'warn' | 'info' | 'marketing';

export interface NoticeMatchRule {
field: string;
op: 'present' | 'absent' | 'equals' | 'in' | 'not_in' | 'lt' | 'gt';
value?: unknown;
}

export interface NoticeMatch {
rules: NoticeMatchRule[];
}

/**
* The client-facing notice shape returned by the API (embedded in the
* compatibility response and from GET /notices). `match` is an optional display
* gate the CLI evaluates locally against its own context (e.g. the selected iOS
* version) — the API can't know those at fetch time.
*/
export interface Notice {
id: string;
slug: string | null;
level: NoticeLevel;
title: string;
body: string;
learnMoreUrl: string | null;
dismissible: boolean;
match: NoticeMatch | null;
}

/** Flat key/value context the `match` DSL is evaluated against. */
export type NoticeContext = Record<string, unknown>;

function isPresent(raw: unknown): boolean {
return raw !== null && raw !== undefined && raw !== false && raw !== '';
}

function ruleMatches(rule: NoticeMatchRule, ctx: NoticeContext): boolean {
const raw = ctx[rule.field];
switch (rule.op) {
case 'present':
return isPresent(raw);
case 'absent':
return !isPresent(raw);
case 'equals':
return isPresent(raw) && String(raw) === String(rule.value);
case 'in':
return (
isPresent(raw) &&
Array.isArray(rule.value) &&
rule.value.map(String).includes(String(raw))
);
case 'not_in':
return (
isPresent(raw) &&
Array.isArray(rule.value) &&
!rule.value.map(String).includes(String(raw))
);
case 'lt':
return (
isPresent(raw) &&
rule.value != null &&
compareSemver(String(raw), String(rule.value)) < 0
);
case 'gt':
return (
isPresent(raw) &&
rule.value != null &&
compareSemver(String(raw), String(rule.value)) > 0
);
default:
return false;
}
}

/** A null / empty match imposes no constraint; otherwise every rule must match (AND). */
export function matchesRules(
match: NoticeMatch | null | undefined,
ctx: NoticeContext,
): boolean {
if (!match || !Array.isArray(match.rules) || match.rules.length === 0) {
return true;
}
return match.rules.every((rule) => ruleMatches(rule, ctx));
}

export interface RenderNoticesOptions {
/**
* Emit a line of human output. Notices route through the same gated `out` as
* the rest of the CLI (suppressed under `--json`); we don't use the `warn`
* channel because its logger prepends its own `⚠`, which would double up with
* the symbol the `ui.*` helpers already add.
*/
out: (message: string) => void;
}

/** Render a single notice with styling appropriate to its level. */
function renderNotice(notice: Notice, opts: RenderNoticesOptions): void {
const rows = [notice.body];
if (notice.learnMoreUrl) {
rows.push(`${colors.dim('See:')} ${colors.url(notice.learnMoreUrl)}`);
}

switch (notice.level) {
case 'deprecation':
opts.out(ui.deprecation(colors.bold(notice.title)));
opts.out(ui.branch(rows));
break;
case 'warn':
opts.out(ui.warn(colors.bold(notice.title)));
opts.out(ui.branch(rows));
break;
Comment thread
finalerock44 marked this conversation as resolved.
case 'marketing':
opts.out(ui.section(notice.title));
opts.out(ui.branch(rows));
break;
case 'info':
default:
opts.out(ui.info(colors.bold(notice.title)));
opts.out(ui.branch(rows));
break;
}
}

/**
* Filter notices by their local-context `match` gate and render those that pass.
* Returns the visible notices so a `--json` caller can include them in its
* payload instead of printing. `opts.out` is the caller's `--json`-gated
* emitter, so under `--json` nothing prints but the list is still returned.
*/
export function renderNotices(
notices: Notice[] | undefined,
ctx: NoticeContext,
opts: RenderNoticesOptions,
): Notice[] {
if (!notices || notices.length === 0) return [];
const visible = notices.filter((n) => matchesRules(n.match, ctx));
for (const notice of visible) {
renderNotice(notice, opts);
}
return visible;
}
2 changes: 1 addition & 1 deletion src/services/version.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export type LatestVersionResult =
* lexically (ASCII), and numeric always sorts below alphanumeric. A longer
* set of identifiers wins when all preceding ones are equal.
*/
function compareSemver(a: string, b: string): number {
export function compareSemver(a: string, b: string): number {
const split = (v: string): { release: number[]; pre: string[] } => {
const [core, ...preParts] = v.trim().replace(/^v/, '').split('-');
const nums = core.split('.').map((n) => Number(n) || 0);
Expand Down
Loading
Loading