Skip to content

Latest commit

 

History

History
99 lines (63 loc) · 23.5 KB

CLAUDE.md

File metadata and controls

99 lines (63 loc) · 23.5 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 the release reads to compute the next version and the changelog entry (scripts/stable-release.mjs), 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) means a MAJOR — do not use it casually. 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. The release never derives a major from one: it stops and lists the breaking commits, and a major has to be given as the version.

  • Never hand-edit the package.json or server.json version, or CHANGELOG.md — the release commit that promote.yml writes 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

A stable release is merging the release PR that a workflow keeps open; betas are cut by hand.

Line How Version npm tag
stable every push to dev runs Promote to stable (promote.yml), which keeps one release PR into production current; merging it tags, publishes and uploads binaries X.Y.Z latest
beta run Release beta (release-beta.yml) by hand from dev X.Y.Z-beta.N beta

Cutting a stable release. You don't start one: you merge the open chore: release X.Y.Z PR from promote/cli-next into production. Use a merge commit, the only method production allows. That push runs release-please.yml. It finds an untagged version on production, tags it, and creates the GitHub Release from the changelog section. It then chains into npm-publish.yml and release-binaries.yml as needs: jobs, because a GITHUB_TOKEN-created release won't fire release: published. Last, it opens a back-merge PR into dev. get.devicecloud.dev serves the binaries by reading the GitHub Releases API at runtime, so there's no separate manifest to deploy. To hold a release, just don't merge the PR. Closing it doesn't pause releases, because the next push to dev opens a new one.

How the release PR is kept current. promote.yml runs on every push to dev. It rebuilds promote/cli-next from origin/dev, with origin/production merged in, plus one chore: release X.Y.Z commit that sets the version in package.json and server.json and adds the CHANGELOG.md section. Then it force-pushes the branch and opens the PR, or updates the open one's title and body. So the PR always carries all of dev, and its version moves up as feats land. A push with nothing releasable since the last release, such as a docs change or the back-merge, does nothing. If production moved during the run because a release just merged, the run pushes nothing, and the back-merge's push into dev rebuilds the PR. If an open PR into production came from another branch, push runs warn and leave it alone. The workflow can also be run by hand (gh workflow run promote.yml --ref dev, or the Actions tab) to set the version. dry_run: true shows the version and changelog in the run summary and pushes nothing. The CHANGELOG.md date is the day the PR was last rebuilt.

scripts/stable-release.mjs does the version and changelog work (plan, apply, verify, notes). test/unit/stable-release.test.ts drives it against throwaway git repos.

  • Version. It is derived from the PR titles of the non-merge commits in origin/production..origin/dev, using the type → bump table above. When nothing is releasable, a push run does nothing, and a run started by hand fails unless a version is given. A breaking marker never derives a major: every push run fails and lists the breaking commits. To proceed, run the workflow by hand with the major as version. Later runs keep the open PR's version (--keep) whenever it is above the derived one, so a version set by hand is never lowered, and a major set once stays. The version must be above every stable tag.
  • Changelog. It keeps release-please's layout, with one entry per PR. A title cut short with … is replaced by the full line from the squash body. release-please listed that line as a second entry.
  • Checks. release-check in cli-ci.yml runs verify on every PR into production. It checks that package.json, server.json and the newest CHANGELOG.md section agree on a version that is untagged and above every stable tag. The Release workflow runs the same check before it tags. A push to production whose version is already tagged releases nothing.

Why production is merged in every time. dev's rulesets allow only squash merges with linear history. So the back-merge lands as one commit with production's content but not its history, and production never becomes an ancestor of dev. The back-merge keeps dev's version files and changelog current. The merge in promote.yml is what keeps the release PR mergeable. If that merge conflicts, the workflow stops. That only happens when both branches changed the same lines, for example a hand edit on production. Then build the branch by hand the same way: from origin/dev, merge origin/production and resolve the conflict. Run node scripts/stable-release.mjs plan --notes-out notes.md, then apply --version X.Y.Z --notes notes.md, and commit chore: release X.Y.Z. Push it as promote/cli-next and open the PR. Later push runs fail on the same conflict until it is merged.

release-please.yml keeps its name, although release-please is gone. npm's trusted publisher is registered against that filename, and npm checks the name of the workflow that started the run. So a rename needs that registration updated on npmjs.com first. release-please needed a promotion PR, then a Release-As: pin on it, then its own Release PR. The pin was needed because a promotion makes every beta tag reachable from production, and release-please took the newest one as its base. That is how 5.6.0 got a 5.6.0-beta.1 release PR.

Betas have 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. Until 5.6.0 there was a release-please beta track. Its manifest numbered itself without reference to what stable had shipped, so @beta ended up older than @latest three times (5.0.0, 5.2.0, 5.5.0). CHANGELOG-beta.md is frozen at 5.6.0-beta.2, and beta notes now live on the GitHub prerelease.

The release PR and the back-merge PR are opened with the automation GitHub App token (BOT_APP_ID). That way they trigger the CI, PR-title and CLA checks the rulesets require, which a GITHUB_TOKEN PR does not. promote.yml requires the App token; the back-merge falls back to GITHUB_TOKEN. Both PRs skip claude-review, which rejects bot actors.