Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6c533e7
fix(upgrade): compare prerelease versions per SemVer
riglar Jun 24, 2026
d543981
fix: suppress refresh countdown in quiet mode
riglar Jun 24, 2026
f77841f
Merge pull request #34 from devicecloud-dev/fix/upgrade-prerelease-co…
riglar Jun 24, 2026
10eade4
Merge pull request #35 from devicecloud-dev/fix/quiet-suppress-refres…
riglar Jun 24, 2026
ea62f72
feat(cloud): warn on deprecated iOS 16 (removal 2026-08-23)
finalerock44 Jun 24, 2026
d794695
Merge pull request #37 from devicecloud-dev/chore/deprecate-ios-16
finalerock44 Jun 24, 2026
62c7672
feat(cloud): drop legacy Maestro removed-versions block; soft-warn on…
finalerock44 Jun 24, 2026
ee995e7
Merge pull request #38 from devicecloud-dev/chore/maestro-deprecation
finalerock44 Jun 24, 2026
ec16bcc
fix(installer): make beta opt-in, add stable/beta channels
riglar Jun 24, 2026
77cf138
chore: add open-source contribution governance
finalerock44 Jun 24, 2026
88c3532
Merge pull request #39 from devicecloud-dev/fix/installer-beta-opt-in
riglar Jun 24, 2026
129f802
chore: drop CODEOWNERS
finalerock44 Jun 24, 2026
ebac7e2
Merge pull request #40 from devicecloud-dev/chore/oss-governance
finalerock44 Jun 24, 2026
dc87257
fix(ci): keep dependabot and fork PRs green (#46)
finalerock44 Jun 24, 2026
07074d3
ci: power CLA via the shared automation GitHub App (#49)
finalerock44 Jun 24, 2026
d3b0acc
docs: set legal entity to Moropo Ltd t/a DeviceCloud (#50)
finalerock44 Jun 24, 2026
3fe1f21
ci: bump the actions group across 1 directory with 6 updates (#47)
dependabot[bot] Jun 24, 2026
d780e55
fix: v5 release blockers — installer, binary version, repeated flags,…
finalerock44 Jun 24, 2026
7fd253a
chore(dev): release 5.0.0-beta.2 (#36)
github-actions[bot] Jun 24, 2026
bd60298
fix: stop CLA locking release PRs (breaks release pipeline) (#52)
finalerock44 Jun 24, 2026
a02584f
feat(live): add a beta warning to `dcd live start` (#54)
finalerock44 Jun 25, 2026
3eedde3
chore(dev): release 5.0.0-beta.3 (#53)
dcd-cli-release-please[bot] Jun 25, 2026
b72b83a
chore: remove dead Maestro 1.39.5/1.41.0 deprecation warning (#57)
finalerock44 Jun 26, 2026
10dfdbf
feat: render DB-driven notices and forward CLI/CI identity (#58)
finalerock44 Jun 26, 2026
58c3b1b
chore(dev): release 5.0.0-beta.4 (#59)
dcd-cli-release-please[bot] Jun 26, 2026
c99f040
fix: notices render polish (#60)
finalerock44 Jun 26, 2026
5adec18
chore: release 5.0.1-beta.1 (#62)
finalerock44 Jun 26, 2026
ee23356
chore(dev): release 5.0.1-beta.1 (#61)
dcd-cli-release-please[bot] Jun 26, 2026
dd93bdd
Merge remote-tracking branch 'origin/dev' into release/promote-5.1.0
finalerock44 Jun 29, 2026
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
146 changes: 146 additions & 0 deletions src/services/notices.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { ui } from '../utils/ui.js';
import { colors, symbols } 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':
// Red ⚠ so a deprecation reads as more serious than a plain (yellow) warn.
opts.out(`${colors.error('⚠')} ${colors.bold(notice.title)}`);
opts.out(ui.branch(rows));
break;
case 'warn':
opts.out(`${symbols.warning} ${colors.bold(notice.title)}`);
opts.out(ui.branch(rows));
break;
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