Skip to content

Latest commit

 

History

History
103 lines (65 loc) · 21.3 KB

CLAUDE.md

File metadata and controls

103 lines (65 loc) · 21.3 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Commands

  • pnpm dcd <args> — run the CLI from source via tsx.
  • 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 -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 if one is available, then runs 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 runner isolates DCD_CONFIG_DIR to a temp dir so tests never touch your real dcd login session.
  • pnpm test:unit — the same runner with --unit: unit specs only, no mock API. This is what CI runs.
  • Tests split into test/unit/* (pure, no backend) and test/integration/* (drive the built CLI against a Prism mock of the dcd API on port 3001).
  • There is no default mock API any more. It used to live in the sibling private dcd/ repo; dcd#1036 deleted it, and this repo — which is public — deliberately no longer reaches into that one (no deploy key, no swagger.json pull). So pnpm test with no MOCK_API_DIR set silently degrades to the unit suite and prints a notice. To run test/integration/*, stand up a Prism mock over the API's swagger.json and point MOCK_API_DIR=/path/to/mock-api at it (it needs a start:auth npm script serving port 3001).
  • Consequence worth knowing: CI no longer catches CLI↔swagger contract drift, which used to surface as a Prism 422 from the integration specs. Nothing replaces that check yet.
  • 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

The CLI is citty-native: src/index.ts has a #!/usr/bin/env node shebang (preserved by tsc), and package.json points bin.dcd directly at dist/index.js. There is no bin/ wrapper directory — don't add one.

The package ships a second bin, dcd-mcp (bin.dcd-mcp → dist/mcp/index.js, also shebanged + chmod'd by pnpm build). It's the MCP server — see the MCP section under Architecture.

Architecture

Top-level defineCommand in src/index.ts wires thirteen subcommands (cloud, upload, list, status, artifacts, device, mcp, live, upgrade, plus the auth-related login, logout, whoami, switch-org). live is hidden and deprecated: it still works for old scripts, and prints a deprecation line on stderr pointing at device. 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.

dcd device (Agent Devices). Cloud device sessions that a person or an AI agent leases, drives and releases, over the API's /device/sessions routes (src/gateways/device-gateway.ts). Its rules differ from the other commands, and scripts and agents depend on them: --json prints one envelope, { ok, session, data, error }, even on failure (runDeviceCommand in src/utils/device-output.ts prints it, so the top-level { status, error } never appears); exit codes are 0 ok, 1 usage, 2 failed, 3 limit, 4 session ended or not found (DEVICE_EXIT); the session id may be left out when exactly one session is open (resolveSessionId); progress goes to stderr and there is no spinner unless stdin and stdout are TTYs. DeviceApiError keeps the API's machine code (LIVE_NOT_FOUND, …) and the session block an error carries, which ApiError drops.

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; the --json result goes through logger.json. Every command takes --json (jsonFlag), and src/index.ts turns thrown errors into { status: 'FAILED', error } whenever --json is in argv. 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.
  • src/gateways/api-gateway.ts, src/gateways/supabase-gateway.ts — HTTP/Supabase boundaries. ApiGateway also owns network-error enhancement and standardized response handling.
  • src/methods.ts — shared binary/archive helpers (zip verification, SHA-based upload dedup, JSON file writes).
  • src/utils/* — cli (logger, CliError, version, enum/int parsing), styling (colors, section headers), progress (ux/spinners), compatibility (device/OS matrix), expo (Expo URL download + tarball extraction), connectivity.

Types. src/types/generated/schema.types.ts is auto-generated by openapi-typescript — do not hand-edit. Domain types live in src/types/domain/.

Cloud command workflow (the canonical shape other commands mirror): upload binary with SHA dedup → extract metadata / validate devices → build execution plan → submit tests → poll results every 10s → download artifacts (reports/videos/logs). RunFailedError from the polling service is the signal for a failed test run (distinct from infra errors).

Auth. Every command calls resolveAuth({ apiKeyFlag }) (src/utils/auth.ts) once and threads the returned AuthContext into gateways/services. ApiGateway and fetchCompatibilityData spread auth.headers into fetch headers — they no longer accept a raw api key. The same headers carry the CLI's identity on every request (x-dcd-cli-version, plus x-dcd-ci-provider / x-dcd-ci-wrapper-version from detectCiContext), which the API uses for notice targeting and records on each upload and binary (dcd#1622). Precedence: --api-key flag > DEVICE_CLOUD_API_KEY env > stored session from dcd login. resolveAuth refreshes expiring Supabase sessions via CliAuthGateway.refresh and rewrites the config atomically. A session's access token lasts about an hour, so code that can outlive it re-checks with isAuthExpiring / refreshAuth: the dcd cloud poll loop and the report downloads after it, the MCP context, and dcd_run_cloud_test's wait loop. refreshAuth keeps the org the command started with and leaves API-key contexts untouched.

Config store. dcd login writes $XDG_CONFIG_HOME/dcd/config.json (fallback ~/.dcd/config.json, 0600). Shape: { version, env, api_url, supabase_url, session: { access_token, refresh_token, expires_at, user_email, user_id }, current_org_id, current_org_name }. DCD_CONFIG_DIR overrides the directory (used by tests). The login command itself (src/commands/login.ts) uses PKCE (S256) with a server rendezvous — no loopback server: it mints state, code_verifier, and code_challenge, opens <frontend>/cli-login?state=...&code_challenge=..., then polls the dcd API's POST /cli-login/claim with {state, code_verifier} while the frontend POSTs proof of identity (the browser's access token) to POST /cli-login/handoff; the API verifies the token, mints a dedicated Supabase session for the CLI (its own refresh-token family — sharing the browser's tokens caused "Invalid Refresh Token: Already Used" whenever either client rotated them), stores it keyed by state, then on claim verifies sha256(verifier) === challenge and returns it. After claiming, the CLI fetches /me/orgs and picks the org with selectOrg (src/utils/orgs.ts): --org <name|id> if given, else the only org, else the picker dcd switch-org uses, starting on the last login's org. That org only counts when the stored api_url matches, because org ids are per environment. Without a TTY (canPrompt in src/utils/ci.ts) the prompts can't be answered: the "already logged in" confirm takes its default and signs in again, the last login's org is used, and with none the command fails pointing at --org. dcd logout deletes the whole config, so nothing is remembered after it. The frontend lives in ../dcd/frontend/app/features/cli-login/CliLoginScreen.tsx.

Cross-repo auth surface. The dcd API's ApiKeyGuard accepts either x-app-api-key (existing) or Authorization: Bearer <jwt> + x-dcd-org: <id>. For Bearer it verifies the JWT, checks user_org_profile membership, and injects the org's api_key back into the request headers so existing @Headers(APP_API_KEY_HEADER) controller code keeps working unchanged. dcd switch-org calls GET /me/orgs, a JWT-only endpoint at ../dcd/api/src/apps/me/me.controller.ts.

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 again when a dcd login session nears expiry (so tools/list works unauthenticated, auth errors surface as tool errors rather than a boot crash, and a long-lived server outlasts the one-hour access token) 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.

dcd mcp (the bridge). A second stdio entry point, src/mcp/bridge.ts, run by the dcd mcp command. It is a low-level Server that routes between two MCP clients: the dcd_* tools from the same in-process createServer() (over an in-memory transport), and the device tools proxied from the hosted <api>/mcp, so those are defined and contract-tested once, in the dcd API. It adds the inputs only a local process can serve: install_app takes app_path (uploaded with uploadLocalApp) and run takes path (a file is sent as yaml, a directory is uploaded as a flow bundle). Credentials are the CLI's (getContext): the hosted /mcp accepts an API key or a dcd login session with x-dcd-org. One connection is shared, and its fetch re-reads the auth headers from getContext() on every request, so a renewed session is picked up without remaking the connection (which would close it under a run still going). If the hosted endpoint can't be reached, it serves the dcd_* tools only and says why on stderr. Read-only mode keeps only device tools annotated readOnlyHint. dcd-mcp itself is unchanged.

dcd mcp install [client] (src/utils/mcp-install.ts) adds the server to Claude Code (claude mcp add --scope user), VS Code (code --add-mcp), or Claude Desktop / Cursor (merges an entry into their JSON config, and refuses a file that isn't plain JSON rather than rewrite it). Entries always start dcd by full path, since desktop apps opened from the Dock don't read the shell rc that puts ~/.dcd/bin on PATH: the standalone binary is process.execPath; an npm install is node (stableNodePath, the PATH entry rather than Homebrew's versioned Cellar path) plus argv[1] as started, not resolved. It refuses to run from source or from the npx cache. Note citty runs a parent's run after its subcommand, so mcpCommand.run returns early when a positional is present — without that, dcd mcp install would go on to start a server.

Contributing

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

  • ⚠️ This repo is public — write PR titles and bodies, commit messages, code comments, test names and issues for a stranger. Work here is often prompted by a support ticket or incident in the private dcd repo; that context stays there. Keep out: customer or org names and ids, ticket refs (SUP-…), private-repo issue/PR numbers, customer usage numbers, internal hostnames, Supabase project refs and dashboard links, and anything about our own security posture — key rotations or revocations, when they happened, which CLI versions they broke. Describe the fault generically ("a dropped B2 request failed the upload") and link the private issue from the private side instead. Edits don't undo a leak: GitHub keeps every revision of a PR or issue body, and a pushed commit stays readable from the PR after a later commit fixes it, so get it right before the first push.

  • 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. 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: feat minor; fix/perf/deps/revert/refactor patch; docs/chore/test/ci/build/style are hidden and bump nothing. Allowed scopes are free-form.

  • ⚠️ A ! (or BREAKING CHANGE: footer) bumps the MAJOR — do not use it casually. The configs set bump-minor-pre-major: true, but that only applies below 1.0.0; we are on 5.x, so it is inert and a breaking marker means exactly what semver says. A refactor(cloud)!: PR title once produced a 6.0.0-beta.1 release PR for what was only a flag rename in an unconsumed beta. Because PRs are squash-merged, the PR title IS the commit — the ! lands even if no branch commit carried it.

  • Never hand-edit package.json version, CHANGELOG.md, or .release-please-manifest.json — release-please owns all three, and hand-editing them is what left the 5.6.0 release PR internally inconsistent. (CHANGELOG-beta.md is frozen; nothing writes it any more.) 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 the same steps on every PR — fork, Dependabot and same-repo alike, with no privileged path: gitleaks secret scan, pnpm lint, pnpm typecheck, pnpm test:unit, pnpm build, pnpm audit --audit-level moderate. test/integration/* is not run by CI at all (see the Commands section: this public repo no longer reaches into the private devicecloud-dev/dcd repo for a mock API), so a green PR says nothing about the integration suite — run it locally with MOCK_API_DIR set if a change touches the API surface. 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

Stable releases are automatic; betas are cut by hand.

Line Trigger Version npm tag
stable push to production -> release-please opens a Release PR; merging it tags, publishes and uploads binaries X.Y.Z latest
beta run the Release beta workflow (release-beta.yml) by hand from dev X.Y.Z-beta.N beta

Stable is driven by release-please.yml off release-please-config.json / .release-please-manifest.json, writing CHANGELOG.md. Merging the Release PR creates the tag + GitHub Release and the same run chains (as needs: jobs, because a GITHUB_TOKEN-created release won't fire release: published) into npm-publish.yml and release-binaries.yml. get.devicecloud.dev serves the binaries by reading the GitHub Releases API at runtime, so there's no separate manifest to deploy. After a stable release the workflow also opens a back-merge PR into dev, so the two branches don't drift apart the way they did up to 5.5.0.

Betas have no release-please track and no manifest. release-beta.yml derives the version from the npm registry (scripts/next-beta-version.mjs): it bumps the published latest and appends the next -beta.N, so a beta is always above the current stable. There was a second release-please track until 5.6.0, and because its manifest numbered itself with no reference to what stable had shipped, @beta ended up older than @latest three times (5.0.0, 5.2.0, 5.5.0) — each needing a hand-written Release-As to escape. CHANGELOG-beta.md is frozen at 5.6.0-beta.2; beta notes now live on the GitHub prerelease.

Promoting beta -> stable is a maintainer opening a PR from dev into production and merging it with a merge commit — that push to production is what triggers the stable Release PR. Use a merge commit, never squash or rebase: the merge is what makes production a descendant of dev, so the two branches stay reconcilable and the next promotion's diff is only the commits since the last one. A squash or rebase promotion rewrites the commits, leaves the histories permanently divergent, and forces the next promotion to be reconstructed by hand — that is exactly what the pre-5.5.0 promotions did.

Every promotion must carry a Release-As: pin

Put the stable version on its own commit in the promotion branch:

git commit --allow-empty -m "chore: pin the X.Y.Z promotion" -m "Release-As: X.Y.Z"

It must be an ordinary commit, not the merge commit — release-please's commit splitting is unreliable on merges. The promotion-pin check in cli-ci.yml enforces this.

Do not skip it on the grounds that the conventional commits since the last stable already imply the right bump. They don't. A promotion is a merge, so every beta tag becomes reachable from production, and release-please takes the newest reachable tag as its base — which is a -beta one. The 5.6.0 promotion reasoned exactly that way, skipped the pin, and produced a chore(production): release 5.6.0-beta.1 release PR while leaving production carrying 5.6.0-beta.1 in package.json against a stable manifest still reading 5.5.0. npm-publish.yml's version guard then refuses to publish, which is the intended backstop, not the fix.

Also keep production's version line when resolving the promotion's package.json conflict, not dev's. Taking dev's puts a prerelease on the stable branch for as long as the Release PR is open; release-please rewrites it from the manifest when that PR lands, but if the PR stalls — as 5.6.0's did — the stable branch sits on a -beta.

Releases prefer an automation GitHub App token (BOT_APP_ID) so the Release PR and the back-merge PR trigger the CI / PR-title / CLA checks that branch protection requires, falling back to GITHUB_TOKEN until the App secrets are configured.