From 43cdf5fe437a00bad2d278a3ca6ccaae21df3d47 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Fri, 23 Oct 2026 10:02:59 +0100 Subject: [PATCH 01/23] deps: patch ip-address advisory (#256) Bump the transitive ip-address from 10.7.3 to 10.7.4 for GHSA-rxp7-6hxx-x8q5. express-rate-limit's ^10.2.0 range already allows it, so only the lockfile changes. Co-authored-by: Claude Opus 5.5 --- pnpm-lock.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39e0d07..c0d2354 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1054,8 +1054,8 @@ packages: resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} engines: {node: '>= 0.10'} - ip-address@10.7.3: - resolution: {integrity: sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==} + ip-address@10.7.4: + resolution: {integrity: sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -2442,7 +2442,7 @@ snapshots: dependencies: debug: 4.4.3(supports-color@8.1.1) express: 5.3.0 - ip-address: 10.7.3 + ip-address: 10.7.4 transitivePeerDependencies: - supports-color @@ -2648,7 +2648,7 @@ snapshots: interpret@1.4.0: {} - ip-address@10.7.3: {} + ip-address@10.7.4: {} ipaddr.js@1.9.1: {} From 93bef013b3fcb48623fdb2c2b14378740647a641 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Fri, 23 Oct 2026 10:04:48 +0100 Subject: [PATCH 02/23] ci: authenticate the Claude action by workload identity federation (#255) Swap the CLAUDE_CODE_OAUTH_TOKEN secret for the action's built-in OIDC exchange, so the workflows need no stored secret. Co-authored-by: Claude Opus 5.5 --- .github/workflows/claude-code-review.yml | 24 ++++++++++++++++++++++-- .github/workflows/claude.yml | 8 +++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 15d69da..2a2e1d5 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -13,7 +13,7 @@ on: jobs: claude-review: # Skip PRs we shouldn't (or can't) review: - # - Dependabot / forks: no CLAUDE_CODE_OAUTH_TOKEN, so the action would fail. + # - Dependabot / forks: no OIDC token to exchange, so the action would fail. # - release-please release PRs: just version bumps + changelog — nothing to # review, and it must never block a release. # - Back-merge PRs (production -> dev, opened by release-please.yml): only @@ -44,7 +44,13 @@ jobs: id: claude-review uses: anthropics/claude-code-action@v1 with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # Workload identity federation: the action swaps the job's GitHub OIDC + # token (hence `id-token: write`) for a short-lived Anthropic token. + # No stored secret. These IDs aren't secret either. + anthropic_federation_rule_id: fdrl_01TQKFjxagS7QzsRp6royjYN + anthropic_organization_id: 43f1fdcd-16a2-46c9-902d-f939b82bb4f4 + anthropic_service_account_id: svac_01TcDsbHiTCxQ5m7aNHrBgLg + anthropic_workspace_id: wrkspc_01SadkG1A6MDwLr3FVD1VmyN plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' # --comment is required for the review to be posted to the PR: the @@ -76,12 +82,26 @@ jobs: # The plugin also stops early on PRs it judges trivial. The prompt above # tells Claude to post a one-line note in that case, so a deliberate skip # leaves a comment and only a real failure trips this check. + # + # Nor is a PR that edits THIS file: the action refuses to run unless the + # workflow is identical to the default branch's ("Workflow validation + # failed"), a deliberate guard against a PR rewriting its own reviewer. It + # exits green having posted nothing, so pass with a notice instead. - name: Check a review comment was posted env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR: ${{ github.event.pull_request.number }} + SELF: ${{ github.workflow_ref }} run: | + # workflow_ref is "owner/repo/.github/workflows/@". + self_path="${SELF#"$REPO"/}" + self_path="${self_path%@*}" + if gh api "repos/$REPO/pulls/$PR/files" --paginate --jq '.[].filename' | grep -qxF "$self_path"; then + echo "::notice::This PR edits $self_path, so the review action skipped it by design (it only runs the default branch's version of its own workflow). No review on this PR." + exit 0 + fi + # Prints the time of every claude[bot] comment, inline comment and review. claude_posts() { gh api "repos/$REPO/issues/$PR/comments" --paginate \ diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index f1fc658..7749816 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -34,7 +34,13 @@ jobs: id: claude uses: anthropics/claude-code-action@v1 with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # Workload identity federation: the action swaps the job's GitHub OIDC + # token (hence `id-token: write`) for a short-lived Anthropic token. + # No stored secret. These IDs aren't secret either. + anthropic_federation_rule_id: fdrl_01TQKFjxagS7QzsRp6royjYN + anthropic_organization_id: 43f1fdcd-16a2-46c9-902d-f939b82bb4f4 + anthropic_service_account_id: svac_01TcDsbHiTCxQ5m7aNHrBgLg + anthropic_workspace_id: wrkspc_01SadkG1A6MDwLr3FVD1VmyN # This is an optional setting that allows Claude to read CI results on PRs additional_permissions: | From d6aea840d68ffc8a305f6be6f4654572251db0a2 Mon Sep 17 00:00:00 2001 From: "dcd-release-please[bot]" <296541543+dcd-release-please[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:05:40 +0100 Subject: [PATCH 03/23] chore: merge production into dev after 5.10.0 (#254) * ReleaseAs: 5.8.0 * Release-As: 5.8.0 * Release-As: 5.8.0 * chore(production): release 5.8.0 * chore: pin the 5.9.0 promotion Release-As: 5.9.0 * chore(production): release 5.9.0 * chore: pin the 5.10.0 promotion Release-As: 5.10.0 * chore(production): release 5.10.0 --------- Co-authored-by: finalerock44 Co-authored-by: finalerock44 <77282157+finalerock44@users.noreply.github.com> Co-authored-by: dcd-release-please[bot] <296541543+dcd-release-please[bot]@users.noreply.github.com> Co-authored-by: Tom Riglar Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 14 ++++++++++++++ package.json | 2 +- server.json | 4 ++-- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 09242e0..028d2d6 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "5.9.0" + ".": "5.10.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1377fbe..17f94af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [5.10.0](https://github.com/devicecloud-dev/dcd-cli/compare/v5.9.0...v5.10.0) (2026-10-22) + + +### Features + +* **encrypt:** list --encrypt in --help now encryption is on by defau… ([#250](https://github.com/devicecloud-dev/dcd-cli/issues/250)) ([55a0609](https://github.com/devicecloud-dev/dcd-cli/commit/55a0609abbc686021fe969c523bcaa1efa994658)) +* **encrypt:** list --encrypt in --help now encryption is on by default (dcd[#1138](https://github.com/devicecloud-dev/dcd-cli/issues/1138)) ([55a0609](https://github.com/devicecloud-dev/dcd-cli/commit/55a0609abbc686021fe969c523bcaa1efa994658)) +* fetch the KEK public key from the API instead of pinning it ([#251](https://github.com/devicecloud-dev/dcd-cli/issues/251)) ([452324b](https://github.com/devicecloud-dev/dcd-cli/commit/452324ba6f6313fe9e4d418c3db655e858f79540)) + + +### Miscellaneous + +* pin the 5.10.0 promotion ([d85115c](https://github.com/devicecloud-dev/dcd-cli/commit/d85115c1efc1da80b7eaeb9dacca49dd0d37257b)) + ## [5.9.0](https://github.com/devicecloud-dev/dcd-cli/compare/v5.8.0...v5.9.0) (2026-10-22) diff --git a/package.json b/package.json index 227fcaf..375f785 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "test:unit": "node scripts/test-runner.mjs --unit", "typecheck": "tsc --noEmit -p tsconfig.test.json" }, - "version": "5.9.0", + "version": "5.10.0", "bugs": { "url": "https://discord.gg/gm3mJwcNw8" }, diff --git a/server.json b/server.json index 1856ebb..314da65 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "dev.devicecloud/dcd", "description": "Run Maestro mobile app tests on devicecloud.dev: submit flows, check status, download artifacts.", - "version": "5.9.0", + "version": "5.10.0", "repository": { "url": "https://github.com/devicecloud-dev/dcd-cli", "source": "github" @@ -11,7 +11,7 @@ { "registryType": "npm", "identifier": "@devicecloud.dev/dcd", - "version": "5.9.0", + "version": "5.10.0", "runtimeHint": "npx", "runtimeArguments": [ { From 69249e31c98a460f6847ce82e823cbbab88e5245 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Fri, 23 Oct 2026 13:08:11 +0100 Subject: [PATCH 04/23] ci: authenticate the Claude action with the org CLAUDE_CODE_OAUTH_TOKEN (#257) Back from workload identity federation to the OAuth token, now an org-level secret. Federation bills the Anthropic API org's purchased credit, which promotional credit doesn't cover for Claude Code, so reviews were failing with "Credit balance is too low". Claude-Session: https://claude.ai/code/session_016xsPHeqEEpmiNy9YvGFDwB Co-authored-by: Claude Opus 5.5 --- .github/workflows/claude-code-review.yml | 10 ++-------- .github/workflows/claude.yml | 8 +------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 2a2e1d5..8e8a6dc 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -13,7 +13,7 @@ on: jobs: claude-review: # Skip PRs we shouldn't (or can't) review: - # - Dependabot / forks: no OIDC token to exchange, so the action would fail. + # - Dependabot / forks: no CLAUDE_CODE_OAUTH_TOKEN, so the action would fail. # - release-please release PRs: just version bumps + changelog — nothing to # review, and it must never block a release. # - Back-merge PRs (production -> dev, opened by release-please.yml): only @@ -44,13 +44,7 @@ jobs: id: claude-review uses: anthropics/claude-code-action@v1 with: - # Workload identity federation: the action swaps the job's GitHub OIDC - # token (hence `id-token: write`) for a short-lived Anthropic token. - # No stored secret. These IDs aren't secret either. - anthropic_federation_rule_id: fdrl_01TQKFjxagS7QzsRp6royjYN - anthropic_organization_id: 43f1fdcd-16a2-46c9-902d-f939b82bb4f4 - anthropic_service_account_id: svac_01TcDsbHiTCxQ5m7aNHrBgLg - anthropic_workspace_id: wrkspc_01SadkG1A6MDwLr3FVD1VmyN + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' # --comment is required for the review to be posted to the PR: the diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 7749816..f1fc658 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -34,13 +34,7 @@ jobs: id: claude uses: anthropics/claude-code-action@v1 with: - # Workload identity federation: the action swaps the job's GitHub OIDC - # token (hence `id-token: write`) for a short-lived Anthropic token. - # No stored secret. These IDs aren't secret either. - anthropic_federation_rule_id: fdrl_01TQKFjxagS7QzsRp6royjYN - anthropic_organization_id: 43f1fdcd-16a2-46c9-902d-f939b82bb4f4 - anthropic_service_account_id: svac_01TcDsbHiTCxQ5m7aNHrBgLg - anthropic_workspace_id: wrkspc_01SadkG1A6MDwLr3FVD1VmyN + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # This is an optional setting that allows Claude to read CI results on PRs additional_permissions: | From 8fe73cd8d5c35a0d2919c2e6940ab377ab258a03 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Mon, 26 Oct 2026 08:34:07 +0000 Subject: [PATCH 05/23] deps: patch body-parser and fast-uri advisories (#262) body-parser 2.3.0 -> 2.4.0 (GHSA-27r3-jxmh-p828, high) and fast-uri 3.1.8 -> 3.1.9 (GHSA-w74x-v6f9-j3xg, GHSA-wjr9-j795-jxcr, moderate), both under @modelcontextprotocol/sdk. fast-uri is pinned by an override, so the override moves to 3.1.9. Co-authored-by: Claude Opus 5.5 --- package.json | 2 +- pnpm-lock.yaml | 29 +++++++++++++++++++---------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 375f785..7460522 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ "ws@>=8.0.0 <8.21.0": "8.21.0", "esbuild@<0.28.1": ">=0.28.1", "qs@<6.16.0": "6.16.0", - "fast-uri@>=3.0.0 <3.1.8": "3.1.8", + "fast-uri@>=3.0.0 <3.1.9": "3.1.9", "hono@>=4.0.0 <4.13.11": "4.13.11", "source-map-js@<1.2.2": "1.2.2", "micromatch>picomatch": "^2.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0d2354..5cd373b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,7 +30,7 @@ overrides: ws@>=8.0.0 <8.21.0: 8.21.0 esbuild@<0.28.1: '>=0.28.1' qs@<6.16.0: 6.16.0 - fast-uri@>=3.0.0 <3.1.8: 3.1.8 + fast-uri@>=3.0.0 <3.1.9: 3.1.9 hono@>=4.0.0 <4.13.11: 4.13.11 source-map-js@<1.2.2: 1.2.2 micromatch>picomatch: ^2.3.2 @@ -580,8 +580,8 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - body-parser@2.3.0: - resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + body-parser@2.4.0: + resolution: {integrity: sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==} engines: {node: '>=18'} bplist-parser@0.5.0: @@ -890,8 +890,8 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.8: - resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + fast-uri@3.1.9: + resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==} fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -1427,6 +1427,10 @@ packages: resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} + qs@6.16.1: + resolution: {integrity: sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==} + engines: {node: '>=0.6'} + querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} @@ -2105,7 +2109,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.8 + fast-uri: 3.1.9 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -2117,7 +2121,7 @@ snapshots: baseline-browser-mapping@2.11.29: {} - body-parser@2.3.0: + body-parser@2.4.0: dependencies: bytes: 3.1.2 content-type: 2.1.0 @@ -2125,7 +2129,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 - qs: 6.16.0 + qs: 6.16.1 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: @@ -2449,7 +2453,7 @@ snapshots: express@5.3.0: dependencies: accepts: 2.0.0 - body-parser: 2.3.0 + body-parser: 2.4.0 content-disposition: 2.0.1 content-type: 2.1.0 cookie: 0.7.2 @@ -2499,7 +2503,7 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.8: {} + fast-uri@3.1.9: {} fast-wrap-ansi@0.2.2: dependencies: @@ -3043,6 +3047,11 @@ snapshots: es-define-property: 1.0.1 side-channel: 1.1.1 + qs@6.16.1: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + querystringify@2.2.0: {} queue-microtask@1.2.3: {} From eb7881fe397ade839616fdef1fdec6f57268fd0c Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Mon, 26 Oct 2026 08:50:21 +0000 Subject: [PATCH 06/23] feat: polish the install script and the progress spinner (#264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install.sh now prints a short banner and groups its output into stages (resolve, download, verify, install) in the same ⏺/⎿ tree the CLI uses, with a separate "next steps" block at the end. Colours are only used on a real terminal, and are off with NO_COLOR or TERM=dumb. It stays POSIX sh and wrapped in main(). The spinner now uses cyan braille frames instead of clack's magenta ones, drops the │ guide bar, and ends with the CLI's own ✓ / ✗ symbols. A new ux.action.fail() marks failures. The CI fallback is unchanged apart from those symbols. Closes #90 Co-authored-by: Claude Opus 5.5 --- install.sh | 114 +++++++++++++++++++----- src/methods.ts | 6 +- src/services/moropo.service.ts | 2 +- src/services/results-polling.service.ts | 2 +- src/utils/progress.ts | 67 +++++++++----- test/unit/progress.test.ts | 52 +++++++++++ 6 files changed, 197 insertions(+), 46 deletions(-) create mode 100644 test/unit/progress.test.ts diff --git a/install.sh b/install.sh index 4d25dc8..3649cad 100755 --- a/install.sh +++ b/install.sh @@ -15,8 +15,32 @@ set -eu +# Colours are on only for a real terminal: not when output is piped or logged +# (CI), not when NO_COLOR is set (https://no-color.org), and not on a dumb +# terminal. stdout and stderr are checked separately, because either can be +# redirected on its own. With colours off every variable is empty, so the +# output is the same text with no escape codes. +setup_colors() { + BOLD='' DIM='' GREEN='' YELLOW='' CYAN='' RESET='' + ERR_RED='' ERR_RESET='' + if [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != dumb ]; then + if [ -t 1 ]; then + BOLD=$(printf '\033[1m') + DIM=$(printf '\033[2m') + GREEN=$(printf '\033[32m') + YELLOW=$(printf '\033[33m') + CYAN=$(printf '\033[36m') + RESET=$(printf '\033[0m') + fi + if [ -t 2 ]; then + ERR_RED=$(printf '\033[31m') + ERR_RESET=$(printf '\033[0m') + fi + fi +} + err() { - printf 'error: %s\n' "$1" >&2 + printf '%s✗ error:%s %s\n' "$ERR_RED" "$ERR_RESET" "$1" >&2 exit 1 } @@ -24,12 +48,39 @@ info() { printf '%s\n' "$1" } +# The same tree shape the CLI itself prints (see STYLE_GUIDE.md): a `⏺` heading +# for each stage, with `⎿` detail rows beneath it. +step() { + printf '\n%s⏺%s %s%s%s\n' "$DIM" "$RESET" "$BOLD" "$1" "$RESET" +} + +detail() { + printf ' %s⎿%s %s\n' "$DIM" "$RESET" "$1" +} + +# A second row under detail(), aligned with its text. +detail_more() { + printf ' %s\n' "$1" +} + +success() { + printf ' %s✓%s %s\n' "$GREEN" "$RESET" "$1" +} + +warn() { + printf '%s!%s %s\n' "$YELLOW" "$RESET" "$1" +} + +banner() { + printf '\n %sdcd%s %s·%s DeviceCloud installer\n' "$BOLD$CYAN" "$RESET" "$DIM" "$RESET" +} + # Stable is the default channel and beta is strictly opt-in, so when no stable # release exists yet (only prereleases published) we refuse to silently install a # beta and instead point the user at the two explicit opt-ins. $DOWNLOAD_BASE is # echoed so a custom host shows the right command. no_stable_release_err() { - printf 'error: No stable dcd release is available yet.\n' >&2 + printf '%s✗ error:%s No stable dcd release is available yet.\n' "$ERR_RED" "$ERR_RESET" >&2 printf ' Install the latest beta: curl -fsSL %s/install.sh | DCD_BETA=1 sh\n' "$DOWNLOAD_BASE" >&2 printf ' Or pin a version: curl -fsSL %s/install.sh | DCD_VERSION=5.0.0-beta.1 sh\n' "$DOWNLOAD_BASE" >&2 exit 1 @@ -112,9 +163,12 @@ persist_path() { } main() { + setup_colors DOWNLOAD_BASE="${DCD_DOWNLOAD_BASE:-https://get.devicecloud.dev}" INSTALL_DIR="${DCD_INSTALL_DIR:-$HOME/.dcd/bin}" + banner + # --- detect platform --- os=$(uname -s) case "$os" in @@ -134,17 +188,17 @@ main() { # --- resolve version --- # Precedence: explicit DCD_VERSION pin > DCD_BETA opt-in > latest stable. + step "Resolving version" if [ -n "${DCD_VERSION:-}" ]; then version="$DCD_VERSION" + detail "${CYAN}${version}${RESET} ${DIM}(pinned by DCD_VERSION)${RESET}" else if [ -n "${DCD_BETA:-}" ]; then channel=beta manifest_url="$DOWNLOAD_BASE/latest.json?channel=beta" - info "Resolving latest beta version..." else channel=stable manifest_url="$DOWNLOAD_BASE/latest.json" - info "Resolving latest version..." fi # Fetch the manifest separately from parsing so we can tell a transient @@ -167,14 +221,15 @@ main() { err "No beta release is available yet from $manifest_url" fi fi + detail "${CYAN}${version}${RESET} ${DIM}(latest ${channel})${RESET}" fi url="$DOWNLOAD_BASE/download/${version}/${asset}" sums_url="$DOWNLOAD_BASE/download/${version}/SHA256SUMS" - info "Installing dcd ${version} (${os_id}-${arch_id})" - info " from: $url" - info " to: $INSTALL_DIR/dcd" + step "Downloading dcd ${version} ${DIM}(${os_id}-${arch_id})${RESET}" + detail "${DIM}from${RESET} $url" + detail_more "${DIM}to${RESET} $INSTALL_DIR/dcd" # --- download --- mkdir -p "$INSTALL_DIR" @@ -182,6 +237,7 @@ main() { trap 'rm -f "$tmp" "$tmp.sums"' EXIT curl -fSL --progress-bar "$url" -o "$tmp" \ || err "Download failed: $url" + success "Downloaded" # --- verify checksum --- curl -fsSL "$sums_url" -o "$tmp.sums" \ @@ -200,6 +256,7 @@ main() { if [ "$expected" != "$actual" ]; then err "Checksum mismatch for $asset: expected $expected, got $actual" fi + success "Checksum verified ${DIM}(SHA-256)${RESET}" # --- install --- chmod +x "$tmp" @@ -213,36 +270,51 @@ main() { *) on_path=0 ;; esac - info "" - info "✓ Installed dcd $version to $INSTALL_DIR/dcd" + success "Installed to $INSTALL_DIR/dcd" + # Work out the PATH outcome now, but print it in the "next steps" block + # below, so the install log above stays a plain record of what happened. + path_state=on_path if [ "$on_path" -eq 0 ]; then rc=$(rc_file) if persist_path "$rc"; then - info "" - info " Added $INSTALL_DIR to your PATH in $rc." - info " Restart your shell, or run: $path_line" + path_state=persisted else - info "" - info " $INSTALL_DIR is not on your PATH. Add this to your shell rc:" - info " $path_line" + path_state=manual fi fi # --- warn about a conflicting (shadowing) install --- if conflict=$(find_conflicting_dcd); then info "" - info "! Another dcd is already on your PATH:" + warn "Another dcd is already on your PATH:" info " $conflict" info " This is usually a previous 'npm install -g @devicecloud.dev/dcd', which" info " can shadow this binary depending on PATH order. Remove it with:" - info " npm uninstall -g @devicecloud.dev/dcd" + info " ${CYAN}npm uninstall -g @devicecloud.dev/dcd${RESET}" fi - if [ "$on_path" -eq 1 ]; then - info "" - info " Try: dcd --help" - fi + # --- next steps --- + info "" + info " ${GREEN}${BOLD}dcd ${version} is installed.${RESET}" + info "" + case "$path_state" in + persisted) + info " Added $INSTALL_DIR to your PATH in ${BOLD}$rc${RESET}." + info " Restart your shell, or run:" + info " ${CYAN}$path_line${RESET}" + info "" + ;; + manual) + info " $INSTALL_DIR is not on your PATH. Add this to your shell rc:" + info " ${CYAN}$path_line${RESET}" + info "" + ;; + esac + info " Get started:" + info " ${CYAN}dcd login${RESET} ${DIM}sign in to DeviceCloud${RESET}" + info " ${CYAN}dcd --help${RESET} ${DIM}see every command${RESET}" + info "" } main "$@" diff --git a/src/methods.ts b/src/methods.ts index 3634c06..c1d6839 100644 --- a/src/methods.ts +++ b/src/methods.ts @@ -195,7 +195,7 @@ export const uploadBinary = async (config: UploadBinaryConfig) => { ux.info( colors.dim('SHA hash matches existing binary with ID: ') + formatId(binaryId) + colors.dim(', skipping upload. Force upload with --ignore-sha-check'), ); - ux.action.stop(colors.info('Skipping upload')); + ux.action.stop('Skipping upload'); } return binaryId; @@ -233,13 +233,13 @@ export const uploadBinary = async (config: UploadBinaryConfig) => { }); if (log) { - ux.action.stop(colors.success('\n✓ Binary uploaded with ID: ') + formatId(uploadId)); + ux.action.stop(colors.success('Binary uploaded with ID: ') + formatId(uploadId)); } return uploadId; } catch (error) { if (log) { - ux.action.stop(colors.error('✗ Failed')); + ux.action.fail(colors.error('Failed')); } if (debug) { diff --git a/src/services/moropo.service.ts b/src/services/moropo.service.ts index 57873d3..89e877b 100644 --- a/src/services/moropo.service.ts +++ b/src/services/moropo.service.ts @@ -92,7 +92,7 @@ export class MoropoService { return moropoDir; } catch (error) { if (!quiet && !json) { - ux.action.stop('failed'); + ux.action.fail('failed'); } // Remove the temp directory (and any partially-written zip inside it) diff --git a/src/services/results-polling.service.ts b/src/services/results-polling.service.ts index d411451..04589ea 100644 --- a/src/services/results-polling.service.ts +++ b/src/services/results-polling.service.ts @@ -196,7 +196,7 @@ export class ResultsPollingService { // displayFinalResults. Anything else aborts mid-poll with the spinner // still live, which would corrupt the terminal under the error output. if (!options.json && !(error instanceof RunFailedError)) { - ux.action.stop(colors.error('failed')); + ux.action.fail(colors.error('failed')); } throw error; } diff --git a/src/utils/progress.ts b/src/utils/progress.ts index fcf8d45..099b6b8 100644 --- a/src/utils/progress.ts +++ b/src/utils/progress.ts @@ -10,13 +10,25 @@ * non-interactive environments we skip the spinner entirely and instead print a * plain line once per *distinct* status, so CI logs show real progress without * the flood. + * + * Styling: the spinner uses the CLI's own palette (cyan braille frames) rather + * than clack's magenta default, and no `│` guide bar, since the rest of the CLI + * is a `⏺`/`⎿` tree rather than a clack session. The final line is written by + * us, not clack, so it carries the same `✓` / `✗` symbols as every other + * command (see STYLE_GUIDE.md). */ import * as p from '@clack/prompts'; import { isCI } from './ci.js'; +import { colors, symbols } from './styling.js'; type ClackSpinner = ReturnType; +// Braille dots where the terminal can draw them, plain ASCII otherwise. +const FRAMES = p.unicode + ? ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + : ['-', '\\', '|', '/']; + class Action { private current: ClackSpinner | null = null; private _status = ''; @@ -29,37 +41,32 @@ class Action { start(title: string, initialStatus?: string, _opts?: unknown): void { this._status = initialStatus ?? ''; - const line = initialStatus ? `${title} — ${initialStatus}` : title; + const line = initialStatus ? `${title} ${colors.dim('·')} ${initialStatus}` : title; if (!this.interactive()) { this.current = null; this.print(line); return; } if (this.current) { - this.current.stop(); + this.current.clear(); } - this.current = p.spinner(); + this.current = p.spinner({ + delay: 80, + frames: FRAMES, + styleFrame: colors.highlight, + withGuide: false, + }); this.current.start(line); } + /** End the action as a success: `✓ message`. */ stop(message?: string): void { - if (!this.interactive()) { - if (message) this.print(message); - this.current = null; - this._status = ''; - this._lastPrinted = ''; - return; - } - if (!this.current) { - if (message) { - // eslint-disable-next-line no-console - console.log(message); - } - return; - } - this.current.stop(message ?? ''); - this.current = null; - this._status = ''; + this.finish(symbols.success, message); + } + + /** End the action as a failure: `✗ message`. */ + fail(message?: string): void { + this.finish(symbols.error, message); } set status(value: string) { @@ -78,6 +85,26 @@ class Action { return this._status; } + // Erase the spinner (if one is live) and print the final line ourselves, so + // the symbol matches the rest of the CLI instead of clack's `◇`. + private finish(symbol: string, message?: string): void { + const line = message ? `${symbol} ${message}` : ''; + if (!this.interactive()) { + if (line) this.print(line); + this.current = null; + this._status = ''; + this._lastPrinted = ''; + return; + } + this.current?.clear(); + this.current = null; + this._status = ''; + if (line) { + // eslint-disable-next-line no-console + console.log(line); + } + } + // Emit a line only when it differs from the previous one, so repeated polls // with no state change stay quiet. private print(line: string): void { diff --git a/test/unit/progress.test.ts b/test/unit/progress.test.ts new file mode 100644 index 0000000..686ae99 --- /dev/null +++ b/test/unit/progress.test.ts @@ -0,0 +1,52 @@ +import { expect } from 'chai'; + +import { stripAnsi } from '../../src/utils/styling.js'; +import { ux } from '../../src/utils/progress.js'; + +// The non-interactive path (CI, pipes) prints plain lines instead of animating, +// so it can be asserted on directly by capturing console.log. +describe('progress (non-interactive)', () => { + const originalLog = console.log; + const originalCI = process.env.CI; + let lines: string[]; + + beforeEach(() => { + process.env.CI = 'true'; + lines = []; + console.log = (line: string) => { + lines.push(stripAnsi(line)); + }; + }); + + afterEach(() => { + console.log = originalLog; + if (originalCI === undefined) { + delete process.env.CI; + } else { + process.env.CI = originalCI; + } + }); + + it('prints the title and initial status on one line', () => { + ux.action.start('Waiting for results', 'Initializing'); + ux.action.stop(); + expect(lines).to.deep.equal(['Waiting for results · Initializing']); + }); + + it('prints each distinct status once', () => { + ux.action.start('Uploading'); + ux.action.status = '10%'; + ux.action.status = '10%'; + ux.action.status = '50%'; + ux.action.stop(); + expect(lines).to.deep.equal(['Uploading', '10%', '50%']); + }); + + it('marks a stop with ✓ and a failure with ✗', () => { + ux.action.start('Uploading'); + ux.action.stop('completed'); + ux.action.start('Uploading'); + ux.action.fail('failed'); + expect(lines).to.deep.equal(['Uploading', '✓ completed', 'Uploading', '✗ failed']); + }); +}); From 8d87e672d2d5bfd83d4538824ebe690ce0390a37 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Mon, 26 Oct 2026 09:05:45 +0000 Subject: [PATCH 07/23] feat(device): add dcd device and dcd mcp for Agent Devices (#260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(device): add dcd device start, list, status, stop, install, launch and models (#231) The first stages of the Agent Devices CLI (dcd plan 3.1 and 3.2). - --json prints one envelope, {ok, session, data, error}, on success and on failure. - Exit codes: 0 ok, 1 usage, 2 failed, 3 limit, 4 session ended or not found. - The session id may be left out when exactly one session is open. - start --app uploads a local build first; an https URL goes to the API's install-from-URL. - Progress goes to stderr, with no spinner unless at a terminal. Hidden from dcd --help until the API reaches production. Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA Co-authored-by: Claude Opus 5.5 * feat(device): add dcd device inspect, screenshot, run, logs and open (#232) * feat(device): add dcd device start, list, status, stop, install, launch and models The first stages of the Agent Devices CLI (dcd plan 3.1 and 3.2). - --json prints one envelope, {ok, session, data, error}, on success and on failure. - Exit codes: 0 ok, 1 usage, 2 failed, 3 limit, 4 session ended or not found. - The session id may be left out when exactly one session is open. - start --app uploads a local build first; an https URL goes to the API's install-from-URL. - Progress goes to stderr, with no spinner unless at a terminal. Hidden from dcd --help until the API reaches production. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA * feat(device): add dcd device inspect, screenshot, run, logs and open Stage 3.3 of the Agent Devices CLI. - inspect prints the compact screen; screenshot saves the frame to a file and reports its path. - run sends one flow (a file or --yaml) with optional --env KEY=VALUE, and polls a flow that outlasts the API's wait. A failed flow exits 2. - logs prints the app's recent log lines; --follow keeps printing new ones until the session ends. - open opens the monitor at a terminal, and prints its URL otherwise. Running a directory of flows needs an API change first and is refused for now. openBrowser moves out of login.ts so device can share it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA * fix(device): renew a dcd login token while polling logs --follow, a long run and start --wait reused the token resolved at the start. A dcd login access token lasts about an hour, so a poll past that failed with an auth error. Each poll now calls refreshAuth first; an API key is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA --------- Co-authored-by: Claude Opus 5.5 * feat(device): add dcd device artifacts (#234) * feat(device): add dcd device start, list, status, stop, install, launch and models The first stages of the Agent Devices CLI (dcd plan 3.1 and 3.2). - --json prints one envelope, {ok, session, data, error}, on success and on failure. - Exit codes: 0 ok, 1 usage, 2 failed, 3 limit, 4 session ended or not found. - The session id may be left out when exactly one session is open. - start --app uploads a local build first; an https URL goes to the API's install-from-URL. - Progress goes to stderr, with no spinner unless at a terminal. Hidden from dcd --help until the API reaches production. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA * feat(device): add dcd device inspect, screenshot, run, logs and open Stage 3.3 of the Agent Devices CLI. - inspect prints the compact screen; screenshot saves the frame to a file and reports its path. - run sends one flow (a file or --yaml) with optional --env KEY=VALUE, and polls a flow that outlasts the API's wait. A failed flow exits 2. - logs prints the app's recent log lines; --follow keeps printing new ones until the session ends. - open opens the monitor at a terminal, and prints its URL otherwise. Running a directory of flows needs an API change first and is refused for now. openBrowser moves out of login.ts so device can share it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA * feat(device): add dcd device artifacts Stage 5.3 of Agent Devices. Downloads what a session left behind into ./-artifacts (or -o dir): recording.mp4, device.log, and per action actions/NNN-screenshot.jpg and NNN-screen.json, numbered oldest first. Follows every page. --links lists the signed links instead. Files that cannot be fetched are reported, and the command exits 2. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA * fix(device): renew a dcd login token while polling logs --follow, a long run and start --wait reused the token resolved at the start. A dcd login access token lasts about an hour, so a poll past that failed with an auth error. Each poll now calls refreshAuth first; an API key is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA --------- Co-authored-by: Claude Opus 5.5 * feat(device): run a directory of flows as a bundle (#235) * feat(device): add dcd device start, list, status, stop, install, launch and models The first stages of the Agent Devices CLI (dcd plan 3.1 and 3.2). - --json prints one envelope, {ok, session, data, error}, on success and on failure. - Exit codes: 0 ok, 1 usage, 2 failed, 3 limit, 4 session ended or not found. - The session id may be left out when exactly one session is open. - start --app uploads a local build first; an https URL goes to the API's install-from-URL. - Progress goes to stderr, with no spinner unless at a terminal. Hidden from dcd --help until the API reaches production. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA * feat(device): add dcd device inspect, screenshot, run, logs and open Stage 3.3 of the Agent Devices CLI. - inspect prints the compact screen; screenshot saves the frame to a file and reports its path. - run sends one flow (a file or --yaml) with optional --env KEY=VALUE, and polls a flow that outlasts the API's wait. A failed flow exits 2. - logs prints the app's recent log lines; --follow keeps printing new ones until the session ends. - open opens the monitor at a terminal, and prints its URL otherwise. Running a directory of flows needs an API change first and is refused for now. openBrowser moves out of login.ts so device can share it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA * feat(device): add dcd device artifacts Stage 5.3 of Agent Devices. Downloads what a session left behind into ./-artifacts (or -o dir): recording.mp4, device.log, and per action actions/NNN-screenshot.jpg and NNN-screen.json, numbered oldest first. Follows every page. --links lists the signed links instead. Files that cannot be fetched are reported, and the command exits 2. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA * fix(device): renew a dcd login token while polling logs --follow, a long run and start --wait reused the token resolved at the start. A dcd login access token lasts about an hour, so a poll past that failed with an auth error. Each poll now calls refreshAuth first; an API key is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA * feat(device): run a directory of flows as a bundle dcd device run zips the directory (without dot-folders and node_modules), uploads it to both copies through POST /device/bundles/upload-url, registers it with its SHA-256, and runs it by id. --entry picks one flow inside it; without it every flow runs. The zip is encrypted when the org's default or DCD_ENCRYPT says so, as dcd cloud's flow zips are. Needs dcd#1718. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA --------- Co-authored-by: Claude Opus 5.5 * feat(mcp): add dcd mcp, one stdio server for cloud tests and cloud devices (#237) * feat(device): add dcd device start, list, status, stop, install, launch and models The first stages of the Agent Devices CLI (dcd plan 3.1 and 3.2). - --json prints one envelope, {ok, session, data, error}, on success and on failure. - Exit codes: 0 ok, 1 usage, 2 failed, 3 limit, 4 session ended or not found. - The session id may be left out when exactly one session is open. - start --app uploads a local build first; an https URL goes to the API's install-from-URL. - Progress goes to stderr, with no spinner unless at a terminal. Hidden from dcd --help until the API reaches production. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA * feat(device): add dcd device inspect, screenshot, run, logs and open Stage 3.3 of the Agent Devices CLI. - inspect prints the compact screen; screenshot saves the frame to a file and reports its path. - run sends one flow (a file or --yaml) with optional --env KEY=VALUE, and polls a flow that outlasts the API's wait. A failed flow exits 2. - logs prints the app's recent log lines; --follow keeps printing new ones until the session ends. - open opens the monitor at a terminal, and prints its URL otherwise. Running a directory of flows needs an API change first and is refused for now. openBrowser moves out of login.ts so device can share it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA * feat(device): add dcd device artifacts Stage 5.3 of Agent Devices. Downloads what a session left behind into ./-artifacts (or -o dir): recording.mp4, device.log, and per action actions/NNN-screenshot.jpg and NNN-screen.json, numbered oldest first. Follows every page. --links lists the signed links instead. Files that cannot be fetched are reported, and the command exits 2. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA * fix(device): renew a dcd login token while polling logs --follow, a long run and start --wait reused the token resolved at the start. A dcd login access token lasts about an hour, so a poll past that failed with an auth error. Each poll now calls refreshAuth first; an API key is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA * feat(device): run a directory of flows as a bundle dcd device run zips the directory (without dot-folders and node_modules), uploads it to both copies through POST /device/bundles/upload-url, registers it with its SHA-256, and runs it by id. --entry picks one flow inside it; without it every flow runs. The zip is encrypted when the org's default or DCD_ENCRYPT says so, as dcd cloud's flow zips are. Needs dcd#1718. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA * feat(mcp): add dcd mcp, one stdio server for cloud tests and cloud devices Agent Devices 4.4. dcd mcp serves the dcd_* tools (the same in-process server dcd-mcp runs) and the device tools, proxied from the hosted /mcp so they are defined once. It adds what only a local process can do: install_app takes app_path (uploaded first) and run takes path (a flow file sent as yaml, or a directory uploaded as a bundle). Uses the CLI's credentials; the hosted /mcp accepts a dcd login session with dcd#1719. Without the hosted endpoint it serves the dcd_* tools only. Read-only mode keeps only read-only device tools. Hidden until the device API is live; dcd-mcp is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA * fix(mcp): refuse state-changing device tools in read-only mode Read-only mode hid them from the tool list, but a call made anyway was still forwarded to the hosted /mcp, which knows nothing of the mode. The bridge now refuses any device tool not annotated readOnlyHint, and the server instructions in read-only mode name only the reading tools. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA * fix(mcp): don't cut forwarded calls off at the SDK's 60s default dcd mcp forwarded every call through an MCP Client with no request options, so the SDK's 60s default applied: dcd_run_cloud_test with wait (up to an hour), a large upload, or a long device run failed with 'Request timed out' while the work carried on. Both forwarded calls now pass the caller's cancel signal and a 2h timeout that resets on progress, so the calling client's own timeout is what limits them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA * test(mcp): check the bridge's call timeout is the one applied createBridgeServer takes callTimeoutMs (default 2h), so a test can show a forwarded call outlasting it times out and a faster one doesn't. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA --------- Co-authored-by: Claude Opus 5.5 * fix(device): time out stalled artifact downloads (#240) * fix(device): install --wait waits for the install itself (#242) install --wait returned as soon as the session was usable, but a session stays ready while a new build installs, so it came back before the app was there. It now also polls the session block's install.status (dcd #1748) until the build is installed or failed. A failed install exits 2 with the reason and says the session is still open; start --app --wait does the same. An API without install status returns as before. Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA Co-authored-by: Claude Opus 5.5 * fix(device): QA fixes, dev sync, and request-human/human commands (#258) * chore: merge production into dev after 5.8.0 (#236) * ReleaseAs: 5.8.0 * Release-As: 5.8.0 * Release-As: 5.8.0 * chore(production): release 5.8.0 --------- Co-authored-by: finalerock44 Co-authored-by: finalerock44 <77282157+finalerock44@users.noreply.github.com> Co-authored-by: dcd-release-please[bot] <296541543+dcd-release-please[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * chore: bump eslint-plugin-unicorn from 77.0.0 to 78.0.0 (#239) Bumps [eslint-plugin-unicorn](https://github.com/sindresorhus/eslint-plugin-unicorn) from 77.0.0 to 78.0.0. - [Release notes](https://github.com/sindresorhus/eslint-plugin-unicorn/releases) - [Commits](https://github.com/sindresorhus/eslint-plugin-unicorn/compare/v77.0.0...v78.0.0) --- updated-dependencies: - dependency-name: eslint-plugin-unicorn dependency-version: 78.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * deps: bump the minor-and-patch group across 1 directory with 5 updates (#238) Bumps the minor-and-patch group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@supabase/supabase-js](https://github.com/supabase/supabase-js/tree/HEAD/packages/core/supabase-js) | `2.117.3` | `2.118.0` | | [js-yaml](https://github.com/nodeca/js-yaml) | `5.4.3` | `5.5.0` | | [zod](https://github.com/colinhacks/zod) | `4.6.5` | `4.6.7` | | [eslint](https://github.com/eslint/eslint) | `10.12.0` | `10.13.0` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.71.1` | `8.71.2` | Updates `@supabase/supabase-js` from 2.117.3 to 2.118.0 - [Release notes](https://github.com/supabase/supabase-js/releases) - [Changelog](https://github.com/supabase/supabase-js/blob/master/packages/core/supabase-js/CHANGELOG.md) - [Commits](https://github.com/supabase/supabase-js/commits/v2.118.0/packages/core/supabase-js) Updates `js-yaml` from 5.4.3 to 5.5.0 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/5.4.3...5.5.0) Updates `zod` from 4.6.5 to 4.6.7 - [Release notes](https://github.com/colinhacks/zod/releases) - [Commits](https://github.com/colinhacks/zod/compare/v4.6.5...v4.6.7) Updates `eslint` from 10.12.0 to 10.13.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v10.12.0...v10.13.0) Updates `typescript-eslint` from 8.71.1 to 8.71.2 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.2/packages/typescript-eslint) --- updated-dependencies: - dependency-name: "@supabase/supabase-js" dependency-version: 2.118.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-and-patch - dependency-name: eslint dependency-version: 10.13.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: minor-and-patch - dependency-name: js-yaml dependency-version: 5.5.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-and-patch - dependency-name: typescript-eslint dependency-version: 8.71.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: zod dependency-version: 4.6.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat(login): reuse the last org and add --org (#243) * feat(login): reuse the last org and add --org dcd login no longer hangs where nobody can answer a prompt, such as a script, CI, or a shell an agent runs. - The org picker starts on the org from the last login, so Enter keeps it. - Without a TTY, the last login's org is used and one line says so. With no last org, or one that is gone, login fails with an error that names --org and lists the available orgs. - --org picks the org without the picker, and fails if the user isn't a member. - Without a TTY, the "already logged in" confirm takes its default and signs in again. The old session stays until the new login succeeds. - The last org only counts when the last login was to the same API, because org ids differ between environments. - A bare dcd switch-org without a TTY now fails with the org names instead of hanging the same way. Closes #241 * chore(deps): bump hono override to 4.13.11 to clear audit pnpm audit flagged GHSA-5r4p-p66f-jhc7 (moderate) on hono >=4.12.4 <4.13.11, which comes in through @modelcontextprotocol/sdk. In those versions serveStatic decodes the request path twice. The advisory was published on 2026-10-20, after dev's last green run, so the lint-and-test audit step now fails on every branch. Dependabot opened an alert but no fix PR, because the exact-pin override holds hono at 4.13.7. 4.13.11 is the lowest patched version, as with the earlier pins. The audit passes apart from the advisory that was already ignored. Lint, typecheck, unit tests and build pass, and dcd-mcp answers initialize and tools/list over stdio with all five tools. * feat(auth): fetch the Supabase key from the API instead of compiling it in (#245) Every release so far compiled the Supabase anon key in, so the key could not be rotated without breaking pinned CLI versions (dcd#1112). The CLI now fetches its publishable key from the API's public GET /cli-config, once per process, so a rotation is an env change on the API. - The key is kept in memory only. A stale on-disk copy would make a session refresh fail with a 4xx, which drops the user's login. - A failed key fetch during refresh is a retryable error, never a definitive one, so the stored session survives. - Supabase URLs stay pinned (prod uses the cloud.devicecloud.dev custom domain); only the key comes from the API. - Raw Storage/Auth requests send an sb_publishable_ key as `apikey` only, and a legacy JWT key as a Bearer too. - Realtime keeps a synchronous subscribe() and connects once the key arrives; until then the backstop poll covers it. - The anon keys stay in the gitleaks allowlist, relabelled: they remain in history, which CI scans in full. Co-authored-by: Claude Opus 5.5 * ci: skip promotion-pin on release-please PRs (#249) Release-please's release PR targets production but is not a promotion, so the Release-As pin check always failed on it (#230, #247). Co-authored-by: Claude Opus 5.5 * feat(encrypt): list --encrypt in --help now encryption is on by defau… (#250) feat(encrypt): list --encrypt in --help now encryption is on by default (dcd#1138) The API now sends encryption.defaultOn = true for every org, not only orgs in the client_encryption beta, so the beta-only pieces come out: - --encrypt is listed in `dcd cloud --help` and `dcd upload --help` again, and its description says it is on by default and that --no-encrypt or DCD_ENCRYPT=0 turns it off. The hidden-arg filter (src/utils/help.ts), which only ever hid this flag, is removed; flags go back to citty's ArgsDef. - The notice drops "(beta)": "Encrypting uploads; opt out with --no-encrypt" (and "encrypt: false" from the MCP tool). It still prints only when the server default turned encryption on, and --json still suppresses it. - The KEK error messages, the MCP encrypt input and comments say "the default" or "the server default" instead of "the organization default". - server.json lists DCD_ENCRYPT, so MCP users can find the opt-out. - environments.ts no longer says the KEK is "left null until generated"; it describes how to rotate. resolveEncryption is unchanged: flag, then DCD_ENCRYPT, then the server default, then off. The default stays server-side so the API's CLIENT_ENCRYPTION_DEFAULT=off kill switch keeps working for every CLI. src/types/generated/schema.types.ts still carries the beta wording in one JSDoc description; a full regeneration would pull in unrelated API surface, so it waits for the next one. * feat: fetch the KEK public key from the API instead of pinning it (#251) The CLI pinned one KEK public key per environment, so every KEK rotation needed a CLI release that customers then had to pick up. GET /cli-config now publishes the newest KEK the API holds the private half of (binary_kek_public_key), and the CLI wraps uploads with whatever the API it talks to serves, fetched once per process alongside the Supabase key. DCD_BINARY_KEK_PUBLIC still overrides for an API that publishes nothing. An API that publishes no key keeps the old behaviour (explicit --encrypt fails, env/org-default encryption skips with a notice); an API that cannot be asked at all fails the run rather than silently uploading plaintext. * chore: merge production into dev after 5.9.0 (#248) * ReleaseAs: 5.8.0 * Release-As: 5.8.0 * Release-As: 5.8.0 * chore(production): release 5.8.0 * chore: pin the 5.9.0 promotion Release-As: 5.9.0 * chore(production): release 5.9.0 --------- Co-authored-by: finalerock44 Co-authored-by: finalerock44 <77282157+finalerock44@users.noreply.github.com> Co-authored-by: dcd-release-please[bot] <296541543+dcd-release-please[bot]@users.noreply.github.com> Co-authored-by: Tom Riglar Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * deps: patch ip-address advisory (#256) Bump the transitive ip-address from 10.7.3 to 10.7.4 for GHSA-rxp7-6hxx-x8q5. express-rate-limit's ^10.2.0 range already allows it, so only the lockfile changes. Co-authored-by: Claude Opus 5.5 * ci: authenticate the Claude action by workload identity federation (#255) Swap the CLAUDE_CODE_OAUTH_TOKEN secret for the action's built-in OIDC exchange, so the workflows need no stored secret. Co-authored-by: Claude Opus 5.5 * chore: merge production into dev after 5.10.0 (#254) * ReleaseAs: 5.8.0 * Release-As: 5.8.0 * Release-As: 5.8.0 * chore(production): release 5.8.0 * chore: pin the 5.9.0 promotion Release-As: 5.9.0 * chore(production): release 5.9.0 * chore: pin the 5.10.0 promotion Release-As: 5.10.0 * chore(production): release 5.10.0 --------- Co-authored-by: finalerock44 Co-authored-by: finalerock44 <77282157+finalerock44@users.noreply.github.com> Co-authored-by: dcd-release-please[bot] <296541543+dcd-release-please[bot]@users.noreply.github.com> Co-authored-by: Tom Riglar Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * ci: authenticate the Claude action with the org CLAUDE_CODE_OAUTH_TOKEN (#257) Back from workload identity federation to the OAuth token, now an org-level secret. Federation bills the Anthropic API org's purchased credit, which promotional credit doesn't cover for Claude Code, so reviews were failing with "Credit balance is too low". Claude-Session: https://claude.ai/code/session_016xsPHeqEEpmiNy9YvGFDwB Co-authored-by: Claude Opus 5.5 * fix(auth): keep the stored session when a refresh fails A refused refresh used to delete the session from config.json. Old builds hit that on every refresh once Supabase turned off legacy keys ("Legacy API keys are disabled"), so the session was wiped even though an upgrade alone would have fixed it. Now only `dcd logout` (or the next `dcd login`) replaces the stored session. The error still tells the user to log in again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xsPHeqEEpmiNy9YvGFDwB * fix(device): show run results as one line per flow `dcd device run` printed the runner's raw JSON under "Output" on a pass and under "Error" on a failed directory run, env_vars included. It now reads that report (or `result.flows`, when the API sends it) and prints each flow with passed or failed and the reason, plus a short summary. The failure message names the failing flows. --json still carries the API's full result. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xsPHeqEEpmiNy9YvGFDwB * fix(device): leave session out of --json for list, models and stop --all `dcd device list --json` printed a stray "session": null, as if the command were about one session. Commands that are not about one session now leave the key out. Every other command's envelope is unchanged, and errors still carry session (null when the API sent none). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xsPHeqEEpmiNy9YvGFDwB * fix(device): infer the platform from --model `dcd device start --model iphone-16` failed with "--platform is required". Without --platform, start now looks the model up in GET /device/models (by id or name) and uses its platform. It is still a usage error when the model is unknown or exists on both platforms. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xsPHeqEEpmiNy9YvGFDwB * fix(device): warn when the video and log are still uploading Right after `dcd device stop`, `dcd device artifacts` downloaded only the per-action screenshots; the video and device log landed about 45s later, with no word that they were coming. It now warns "The video and device log are still uploading. Run this again in a minute." and puts `pending` and `warning` in --json. It reads the API's new `pending` field. An older API gets a guess instead: an ended session missing either file whose last activity was under three minutes ago. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xsPHeqEEpmiNy9YvGFDwB * feat(device): add request-human and human, like the MCP tools The MCP server has request_human and get_human_status; the CLI had nothing, though session warnings told CLI users to wait for a person. - `dcd device request-human "" [id] [--wait]` asks a person for help (POST .../request-human). - `dcd device human [id] [--wait]` shows whether the session waits for a person, who has control, when it ends, and the hand-back note. - --wait checks every 5s until the person hands back or the session ends (exit 4). Ctrl-C stops waiting and leaves the session open. - `dcd device status` shows the hand-back note, and --json carries it. - A command refused with 409 LIVE_HUMAN_IN_CONTROL now says a person has control and to run `dcd device human --wait`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xsPHeqEEpmiNy9YvGFDwB --------- Signed-off-by: dependabot[bot] Co-authored-by: dcd-release-please[bot] <296541543+dcd-release-please[bot]@users.noreply.github.com> Co-authored-by: finalerock44 Co-authored-by: finalerock44 <77282157+finalerock44@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 * fix(device): say a person has control once in dcd device status (#259) The API's session warnings already say a person has control and how to wait, so status printed the same thing twice. Print our own line only when an older API leaves it out. Claude-Session: https://claude.ai/code/session_016xsPHeqEEpmiNy9YvGFDwB Co-authored-by: Claude Opus 5.5 * feat(device): list dcd device and dcd mcp in help, and deprecate dcd live (#233) * feat(device): add dcd device start, list, status, stop, install, launch and models The first stages of the Agent Devices CLI (dcd plan 3.1 and 3.2). - --json prints one envelope, {ok, session, data, error}, on success and on failure. - Exit codes: 0 ok, 1 usage, 2 failed, 3 limit, 4 session ended or not found. - The session id may be left out when exactly one session is open. - start --app uploads a local build first; an https URL goes to the API's install-from-URL. - Progress goes to stderr, with no spinner unless at a terminal. Hidden from dcd --help until the API reaches production. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA * feat(device): add dcd device inspect, screenshot, run, logs and open Stage 3.3 of the Agent Devices CLI. - inspect prints the compact screen; screenshot saves the frame to a file and reports its path. - run sends one flow (a file or --yaml) with optional --env KEY=VALUE, and polls a flow that outlasts the API's wait. A failed flow exits 2. - logs prints the app's recent log lines; --follow keeps printing new ones until the session ends. - open opens the monitor at a terminal, and prints its URL otherwise. Running a directory of flows needs an API change first and is refused for now. openBrowser moves out of login.ts so device can share it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA * feat(device): list dcd device in help and deprecate dcd live Stage 3.4 of the Agent Devices CLI. dcd device appears in dcd --help. dcd live is hidden and prints a deprecation line on stderr pointing at dcd device; it keeps working for existing scripts. Merge only when the /device/sessions API is in production. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA * fix(device): renew a dcd login token while polling logs --follow, a long run and start --wait reused the token resolved at the start. A dcd login access token lasts about an hour, so a poll past that failed with an auth error. Each poll now calls refreshAuth first; an API key is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA * feat(mcp): list dcd mcp in help at launch Unhide dcd mcp alongside dcd device, and update CLAUDE.md to match. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA --------- Co-authored-by: Claude Opus 5.5 * fix(device): open URLs without a shell, and keep device calls across a session renewal (#261) openBrowser: Windows used `cmd /c start`, which parses the URL again, so the login URL's `&` split it and ran the rest as a command (CodeQL: uncontrolled command line). It now uses rundll32, and opens only http(s) URLs. dcd mcp: renewing a `dcd login` session closed the hosted connection under calls still running, so a long `run` failed with "Connection closed" while the flow carried on. The bridge now keeps one connection and puts the current auth on each request. Calls that arrive together share one connect. Co-authored-by: Claude Opus 5.5 * fix(device): keep the --json envelope when request-human has no reason (#263) The reason positional was required, so citty refused a missing one before run() and the top-level handler printed { status: 'FAILED', error } instead of { ok, session, data, error }. It is now optional to citty, like every other dcd device positional, and run() rejects it with the usual CliError. Co-authored-by: Claude Opus 5.5 --------- Signed-off-by: dependabot[bot] Co-authored-by: Claude Opus 5.5 Co-authored-by: dcd-release-please[bot] <296541543+dcd-release-please[bot]@users.noreply.github.com> Co-authored-by: finalerock44 Co-authored-by: finalerock44 <77282157+finalerock44@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- CLAUDE.md | 6 +- src/commands/device.ts | 1265 ++++++++++++++++++++++ src/commands/live.ts | 12 + src/commands/login.ts | 18 +- src/commands/mcp.ts | 32 + src/gateways/device-gateway.ts | 272 +++++ src/index.ts | 4 + src/mcp/bridge.ts | 251 +++++ src/types/domain/device-session.types.ts | 218 ++++ src/utils/app-upload.ts | 84 ++ src/utils/auth.ts | 39 +- src/utils/browser.ts | 35 + src/utils/device-artifacts.ts | 269 +++++ src/utils/device-bundle.ts | 129 +++ src/utils/device-output.ts | 242 +++++ src/utils/device-run.ts | 133 +++ test/unit/auth.test.ts | 76 +- test/unit/browser.test.ts | 11 + test/unit/device-artifacts.test.ts | 211 ++++ test/unit/device-bundle.test.ts | 143 +++ test/unit/device-command.test.ts | 801 ++++++++++++++ test/unit/device-run.test.ts | 123 +++ test/unit/live-command.test.ts | 39 + test/unit/mcp-bridge.test.ts | 234 ++++ test/unit/session-refresh.test.ts | 17 +- 25 files changed, 4574 insertions(+), 90 deletions(-) create mode 100644 src/commands/device.ts create mode 100644 src/commands/mcp.ts create mode 100644 src/gateways/device-gateway.ts create mode 100644 src/mcp/bridge.ts create mode 100644 src/types/domain/device-session.types.ts create mode 100644 src/utils/app-upload.ts create mode 100644 src/utils/browser.ts create mode 100644 src/utils/device-artifacts.ts create mode 100644 src/utils/device-bundle.ts create mode 100644 src/utils/device-output.ts create mode 100644 src/utils/device-run.ts create mode 100644 test/unit/browser.test.ts create mode 100644 test/unit/device-artifacts.test.ts create mode 100644 test/unit/device-bundle.test.ts create mode 100644 test/unit/device-command.test.ts create mode 100644 test/unit/device-run.test.ts create mode 100644 test/unit/mcp-bridge.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index d2fe424..4ea6ff6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,10 +24,12 @@ The package ships a **second bin, `dcd-mcp`** (`bin.dcd-mcp` → `dist/mcp/index ## Architecture -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. +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.** @@ -51,6 +53,8 @@ Top-level `defineCommand` in `src/index.ts` wires eleven subcommands (`cloud`, ` **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 `/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`, and the remote connection is remade when a session is renewed. 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. + ## Contributing Full guide in `CONTRIBUTING.md`; the operationally important parts (the ones that gate a merge or affect a release): diff --git a/src/commands/device.ts b/src/commands/device.ts new file mode 100644 index 0000000..3f63694 --- /dev/null +++ b/src/commands/device.ts @@ -0,0 +1,1265 @@ +import { defineCommand } from 'citty'; +import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import * as path from 'node:path'; +import { resolve as resolvePath } from 'node:path'; + +import { apiFlags } from '../config/flags/api.flags.js'; +import { jsonFlag } from '../config/flags/output.flags.js'; +import { DeviceApiError, DeviceGateway } from '../gateways/device-gateway.js'; +import type { AuthContext } from '../types/domain/auth.types.js'; +import { + type DeviceCatalogue, + type DeviceCommandResult, + type DeviceHumanStatus, + type DeviceInstalledApp, + type DeviceRunResponse, + type DeviceScreenElement, + type DeviceSession, + ENDED_STATES, + type StartDeviceSessionRequest, +} from '../types/domain/device-session.types.js'; +import { platformOfAppFile, uploadLocalApp } from '../utils/app-upload.js'; +import { refreshAuth, resolveAuth } from '../utils/auth.js'; +import { openBrowser } from '../utils/browser.js'; +import { uploadFlowBundle } from '../utils/device-bundle.js'; +import { + type DeviceArtifactFile, + downloadArtifactFiles, + fetchAllArtifacts, + pendingArtifacts, + pendingArtifactsWarning, + planArtifactFiles, +} from '../utils/device-artifacts.js'; +import { CliError, logger, validateEnum } from '../utils/cli.js'; +import { resolveApiUrl } from '../utils/config-store.js'; +import { + deviceProgress, + humanFields, + printSession, + resolveSessionId, + runDeviceCommand, +} from '../utils/device-output.js'; +import { parseRunReport, printRunResult, runFailureMessage } from '../utils/device-run.js'; +import { isUrl } from '../utils/expo.js'; +import { colors, formatUrl } from '../utils/styling.js'; +import { ui } from '../utils/ui.js'; + +const PLATFORMS = ['android', 'ios'] as const; +/** What `--name` defaults to: shown in the console and the session timeline. */ +const DEFAULT_CLIENT_LABEL = 'dcd-cli'; +const WAIT_POLL_MS = 2000; +/** Longer than the API's own queue timeout, so that one is what ends a wait. */ +const WAIT_TIMEOUT_MS = 20 * 60 * 1000; +/** States in which a session takes commands. */ +const USABLE_STATES = new Set(['active', 'needs_human', 'ready']); + +/** Every `dcd device` subcommand takes these. */ +const commonArgs = { ...apiFlags, json: jsonFlag } as const; + +const idArg = { + type: 'positional', + required: false, + description: 'Session id, e.g. brave-teal-otter. Optional when exactly one session is open.', +} as const; + +interface Ctx { + apiUrl: string; + auth: AuthContext; + json: boolean; + progress: (message: string) => void; +} + +async function context(args: Record): Promise { + const json = Boolean(args.json); + return { + apiUrl: resolveApiUrl(args['api-url'] as string | undefined), + auth: await resolveAuth({ apiKeyFlag: args['api-key'] as string | undefined }), + json, + progress: deviceProgress(json), + }; +} + +/** A spinner only for a person at a terminal (plan 4.2: none when not a TTY). */ +function interactive(json: boolean): boolean { + return !json && process.stdout.isTTY === true && process.stdin.isTTY === true; +} + +/** + * Polling loops can outlast a `dcd login` access token (about an hour), so + * they renew it before each call. An API key is returned unchanged. + */ +async function freshAuth(ctx: Ctx): Promise { + ctx.auth = await refreshAuth(ctx.auth); + return ctx.auth; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Poll until the session takes commands. Reports each change of state (and + * queue position) as progress. A session that ends while waiting is exit 4. + */ +async function waitUntilUsable(ctx: Ctx, id: string): Promise { + const deadline = Date.now() + WAIT_TIMEOUT_MS; + let last = ''; + for (;;) { + const { session } = await DeviceGateway.status(ctx.apiUrl, await freshAuth(ctx), id); + if (USABLE_STATES.has(session.state)) return session; + if (ENDED_STATES.includes(session.state)) { + throw new DeviceApiError( + `Device session ${id} ended while starting (${session.release_reason ?? session.state}).`, + 410, + 'LIVE_SESSION_ENDED', + session, + ); + } + + const position = session.queue?.position; + const line = position ? `${session.state} (position ${position} in the queue)` : session.state; + if (line !== last) { + last = line; + ctx.progress(` ${colors.dim(line)}`); + } + + if (Date.now() >= deadline) { + throw new DeviceApiError( + `Timed out after ${WAIT_TIMEOUT_MS / 60_000} minutes waiting for ${id} (state: ${session.state}). It is still open: check it with \`dcd device status ${id}\`.`, + 408, + null, + session, + ); + } + + await sleep(WAIT_POLL_MS); + } +} + +/** + * Poll until the device has finished installing `binaryId`: the session + * block's `install.status` leaves `installing`. The session is usable + * throughout (it stays ready while a new build installs), so waiting on the + * state alone returned too early. An API without `install` returns at once. + */ +async function waitForInstall(ctx: Ctx, id: string, binaryId: string): Promise { + const deadline = Date.now() + WAIT_TIMEOUT_MS; + for (;;) { + const { session } = await DeviceGateway.status(ctx.apiUrl, await freshAuth(ctx), id); + const install = session.install; + if (ENDED_STATES.includes(session.state)) { + throw new DeviceApiError( + `Device session ${id} ended while installing (${session.release_reason ?? session.state}).`, + 410, + 'LIVE_SESSION_ENDED', + session, + ); + } + + // Done, failed, superseded by another install, or an API too old to say. + if (!install || install.binary_upload_id !== binaryId || install.status !== 'installing') { + return session; + } + + if (Date.now() >= deadline) { + throw new DeviceApiError( + `Timed out after ${WAIT_TIMEOUT_MS / 60_000} minutes waiting for the install on ${id}. It may still finish: check it with \`dcd device status ${id}\`.`, + 408, + null, + session, + ); + } + + await sleep(WAIT_POLL_MS); + } +} + +/** A failed install as a command failure (exit 2); the session stays open. */ +function installFailure(session: DeviceSession): { code: null; message: string } | undefined { + if (session.install?.status !== 'failed') return undefined; + return { + code: null, + message: `The app could not be installed: ${session.install.error ?? 'unknown error'} Session ${session.id} is still open.`, + }; +} + +/** + * Turn `` into what /install takes: a URL goes to the API's + * install-from-URL, a local file is uploaded first. + */ +async function resolveApp( + ctx: Ctx, + app: string, +): Promise<{ binaryUploadId: string } | { url: string }> { + if (isUrl(app)) return { url: app }; + if (!existsSync(app)) throw new CliError(`No such file: ${app}`); + ctx.progress(ui.running(`Uploading ${colors.highlight(app)}…`)); + const binaryUploadId = await uploadLocalApp({ + apiUrl: ctx.apiUrl, + auth: ctx.auth, + filePath: app, + log: ctx.progress, + showProgress: interactive(ctx.json), + }); + return { binaryUploadId }; +} + +function parsePositiveInt(value: unknown, flag: string): number | undefined { + if (value === undefined || value === '') return undefined; + const n = Number(value); + if (!Number.isInteger(n) || n <= 0) { + throw new CliError(`--${flag} must be a whole number of minutes, got "${String(value)}".`); + } + + return n; +} + +/** + * The platform a `--model` belongs to, from GET /device/models. Matches the + * model id (iphone-16) or its name (iPhone 16), ignoring case. Unknown, or on + * both platforms: a usage error that says to pass --platform. + */ +export function platformOfModel(catalogue: DeviceCatalogue, model: string): 'android' | 'ios' { + const wanted = model.trim().toLowerCase(); + const found = PLATFORMS.filter((p) => + (catalogue[p] ?? []).some((m) => m.model.toLowerCase() === wanted || m.name.toLowerCase() === wanted), + ); + if (found.length === 1) return found[0]; + if (found.length === 0) { + throw new CliError( + `Unknown model "${model}". See \`dcd device models\` for the list, or pass --platform.`, + ); + } + + throw new CliError(`"${model}" is a model on both iOS and Android. Pass --platform ios or --platform android.`); +} + +const startSub = defineCommand({ + meta: { name: 'start', description: 'Start a device session' }, + args: { + ...commonArgs, + platform: { + type: 'string', + description: 'ios or android. Optional with --model or --app, which imply it', + }, + model: { + type: 'string', + description: 'Device model, e.g. iphone-16 or pixel-10 (see `dcd device models`)', + }, + os: { + type: 'string', + description: 'iOS major version or Android API level, e.g. 18 or 35', + }, + 'google-play': { + type: 'boolean', + description: '[Android] An image with Google Play services', + }, + app: { + type: 'string', + description: 'A build to install: a local .apk/.app/.zip/.tar.gz (uploaded first) or an https URL', + }, + 'app-binary-id': { + type: 'string', + description: 'An already uploaded build to install', + }, + locale: { + type: 'string', + description: 'Device locale, e.g. de_DE', + }, + name: { + type: 'string', + description: `Who is driving the session, shown in the console (default: ${DEFAULT_CLIENT_LABEL})`, + }, + ttl: { + type: 'string', + description: "Longest the session may run, in minutes (default: your org's limit)", + }, + record: { + type: 'boolean', + default: true, + description: 'Record a video of the session (--no-record to turn off)', + }, + wait: { + type: 'boolean', + default: false, + description: 'Wait until the device is ready for commands', + }, + }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + const app = args.app as string | undefined; + const binaryId = args['app-binary-id'] as string | undefined; + if (app && binaryId) throw new CliError('Pass --app or --app-binary-id, not both.'); + + const model = args.model as string | undefined; + const appPlatform = app && !isUrl(app) ? platformOfAppFile(app) : undefined; + let platform = + validateEnum(args.platform as string | undefined, PLATFORMS, 'platform') ?? appPlatform; + if (!platform && !model) { + throw new CliError('Pass --platform ios or android, or a --model or --app that says which.'); + } + + const ctx = await context(args); + if (!platform) { + platform = platformOfModel(await DeviceGateway.models(ctx.apiUrl, ctx.auth), model!); + } + + if (appPlatform && appPlatform !== platform) { + throw new CliError(`${app} is a build for ${appPlatform}, not ${platform}.`); + } + + if (args['google-play'] && platform !== 'android') { + throw new CliError('--google-play is only for Android.'); + } + + const resolved = app ? await resolveApp(ctx, app) : undefined; + const uploadedId = + binaryId ?? (resolved && 'binaryUploadId' in resolved ? resolved.binaryUploadId : undefined); + + const body: StartDeviceSessionRequest = { + platform, + clientLabel: (args.name as string | undefined) || DEFAULT_CLIENT_LABEL, + ...(uploadedId && { binaryUploadId: uploadedId }), + ...(model ? { model } : {}), + ...(args.os ? { os: String(args.os) } : {}), + ...(args['google-play'] ? { googlePlay: true } : {}), + ...(args.locale ? { deviceLocale: args.locale as string } : {}), + ...(args.record === false ? { record: false } : {}), + }; + const ttl = parsePositiveInt(args.ttl, 'ttl'); + if (ttl) body.maxDurationMin = ttl; + + ctx.progress(ui.running(`Starting ${platform} device session…`)); + let { session } = await DeviceGateway.start(ctx.apiUrl, ctx.auth, body); + ctx.progress(ui.success(`Session ${session.id} started`)); + + // The session exists and bills from here on: every later failure must + // still name it, so the caller can stop it. + let installedId: string | undefined; + try { + if (resolved && 'url' in resolved) { + ctx.progress(ui.running('Installing the app from its URL…')); + const fromUrl = await DeviceGateway.install(ctx.apiUrl, ctx.auth, session.id, resolved); + session = fromUrl.session; + installedId = fromUrl.app.binary_upload_id; + } + + if (args.wait) { + ctx.progress(ui.running('Waiting for the device…')); + session = await waitUntilUsable(ctx, session.id); + const expected = installedId ?? uploadedId; + if (expected) { + ctx.progress(ui.running('Waiting for the app to install…')); + session = await waitForInstall(ctx, session.id, expected); + } + } + } catch (error) { + if (error instanceof DeviceApiError && error.code === 'LIVE_SESSION_ENDED') throw error; + const message = `${error instanceof Error ? error.message : String(error)} Session ${session.id} is still open: stop it with \`dcd device stop ${session.id}\`.`; + if (error instanceof DeviceApiError) { + throw new DeviceApiError(message, error.status, error.code, error.session ?? session); + } + + throw new DeviceApiError(message, 500, null, session); + } + + const startFailure = args.wait ? installFailure(session) : undefined; + return { + session, + data: { binary_upload_id: installedId ?? uploadedId ?? null }, + ...(startFailure && { failure: startFailure }), + print() { + logger.log(ui.section('Device session')); + printSession(session); + logger.log(ui.section('Next steps')); + logger.log( + ui.branch( + ui.fields([ + ['inspect the screen', colors.highlight(`dcd device inspect ${session.id}`)], + ['run a flow', colors.highlight(`dcd device run ${session.id} flow.yaml`)], + ['stop', colors.highlight(`dcd device stop ${session.id}`)], + ]), + ), + ); + }, + }; + }); + }, +}); + +const listSub = defineCommand({ + meta: { name: 'list', description: "List your org's open device sessions" }, + args: { + ...commonArgs, + all: { + type: 'boolean', + default: false, + description: 'Include sessions that ended in the last 24 hours', + }, + }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + const ctx = await context(args); + const { sessions } = await DeviceGateway.list(ctx.apiUrl, ctx.auth, Boolean(args.all)); + return { + session: undefined, + data: { sessions }, + print() { + if (sessions.length === 0) { + logger.log(ui.info(args.all ? 'No device sessions in the last 24 hours.' : 'No open device sessions.')); + return; + } + + logger.log(ui.section(`Device sessions (${sessions.length})`)); + logger.log( + ui.branch( + ui.fields( + sessions.map((s) => [ + colors.highlight(s.id), + [s.state, s.platform, s.model, s.client_label].filter(Boolean).join(' '), + ]), + ), + ), + ); + }, + }; + }); + }, +}); + +const statusSub = defineCommand({ + meta: { name: 'status', description: "A session's state, time left and cost" }, + args: { ...commonArgs, id: idArg }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + const ctx = await context(args); + const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id as string | undefined); + // The summary does not say whether a person handed the session back, + // so ask. An API without the route (or a failure) just leaves it out. + const [{ session }, human] = await Promise.all([ + DeviceGateway.status(ctx.apiUrl, ctx.auth, id), + DeviceGateway.human(ctx.apiUrl, ctx.auth, id).then( + (r) => r.human ?? null, + () => null, + ), + ]); + return { + session, + data: human ? { human } : null, + print() { + logger.log(ui.section('Device session')); + printSession(session); + if (human?.handed_back) { + logger.log(ui.info(`A person handed it back${human.note ? `: ${human.note}` : ' (no note).'}`)); + } + + // The API's own warning already says a person has control and how + // to wait; only add ours when an older API leaves it out. + const apiSaysWaiting = (session.warnings ?? []).some((w) => w.startsWith('Waiting for a human')); + if (human?.human_in_control && !apiSaysWaiting) { + logger.log(ui.warn(`A person has control. Wait with \`dcd device human ${session.id} --wait\`.`)); + } + }, + }; + }); + }, +}); + +const stopSub = defineCommand({ + meta: { name: 'stop', description: 'Stop a session, or every open one with --all' }, + args: { + ...commonArgs, + id: idArg, + all: { type: 'boolean', default: false, description: "Stop every open session in your org" }, + }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + const ctx = await context(args); + if (args.all) { + if (args.id) throw new CliError('Pass a session id or --all, not both.'); + const { sessions } = await DeviceGateway.list(ctx.apiUrl, ctx.auth); + const stopped: DeviceSession[] = []; + for (const s of sessions) { + stopped.push((await DeviceGateway.stop(ctx.apiUrl, ctx.auth, s.id)).session); + } + + return { + session: undefined, + data: { stopped }, + print() { + logger.log( + stopped.length === 0 + ? ui.info('No open device sessions to stop.') + : ui.success(`Stopped ${stopped.map((s) => s.id).join(', ')}`), + ); + }, + }; + } + + const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id as string | undefined); + const { session } = await DeviceGateway.stop(ctx.apiUrl, ctx.auth, id); + return { + session, + data: null, + print() { + logger.log(ui.success(`Stopped ${session.id}`)); + printSession(session); + }, + }; + }); + }, +}); + +/** + * `install [id] `: with one positional it is the app when it looks + * like one (a URL, a file that exists, or a build's extension). + */ +function looksLikeApp(value: string): boolean { + return isUrl(value) || existsSync(value) || platformOfAppFile(value) !== undefined; +} + +const installSub = defineCommand({ + meta: { name: 'install', description: 'Install a build: a local file (uploaded first) or an https URL' }, + args: { + ...commonArgs, + id: idArg, + app: { + type: 'positional', + required: false, + description: 'A local .apk/.app/.zip/.tar.gz, or an https URL to an .apk or a zipped .app', + }, + 'app-binary-id': { type: 'string', description: 'An already uploaded build' }, + wait: { + type: 'boolean', + default: false, + description: 'Wait until the device is ready again after installing', + }, + }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + let id = args.id as string | undefined; + let app = args.app as string | undefined; + if (id && !app && looksLikeApp(id)) [id, app] = [undefined, id]; + const binaryId = args['app-binary-id'] as string | undefined; + if (Boolean(app) === Boolean(binaryId)) { + throw new CliError('Give the build: a file path, an https URL, or --app-binary-id.'); + } + + const ctx = await context(args); + id = await resolveSessionId(ctx.apiUrl, ctx.auth, id); + const body = binaryId ? { binaryUploadId: binaryId } : await resolveApp(ctx, app!); + ctx.progress(ui.running(`Installing on ${colors.highlight(id)}…`)); + const installed = await DeviceGateway.install(ctx.apiUrl, ctx.auth, id, body); + let { session } = installed; + if (args.wait) { + ctx.progress(ui.running('Waiting for the device…')); + session = await waitUntilUsable(ctx, id); + ctx.progress(ui.running('Waiting for the app to install…')); + session = await waitForInstall(ctx, id, installed.app.binary_upload_id); + } + + const appInfo: DeviceInstalledApp = installed.app; + const failure = args.wait ? installFailure(session) : undefined; + return { + session, + data: { app: appInfo }, + ...(failure && { failure }), + print() { + if (failure) { + logger.log(`${ui.statusSymbol('FAILED')} ${failure.message}`); + return; + } + + logger.log( + ui.success(`${args.wait ? 'Installed' : 'Installing'} ${appInfo.app_id ?? appInfo.binary_upload_id}`), + ); + logger.log( + ui.branch( + ui.fields([ + ['binary id', appInfo.binary_upload_id], + ...(appInfo.app_id ? [['app id', appInfo.app_id] as [string, string]] : []), + ['session', colors.highlight(session.id)], + ]), + ), + ); + if (!args.wait) { + logger.log(ui.note('The device installs it next. Commands answer "not ready" until it is done.')); + } + }, + }; + }); + }, +}); + +/** Bundle ids and package names have dots; session ids never do. */ +function looksLikeAppId(value: string): boolean { + return value.includes('.'); +} + +const launchSub = defineCommand({ + meta: { name: 'launch', description: 'Launch an app: the installed one, or a bundle id / package name' }, + args: { + ...commonArgs, + id: idArg, + appId: { + type: 'positional', + required: false, + description: 'Bundle id or package name (default: the installed app)', + }, + 'clear-state': { + type: 'boolean', + default: false, + description: "Clear the app's data before launching", + }, + }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + let id = args.id as string | undefined; + let appId = args.appId as string | undefined; + if (id && !appId && looksLikeAppId(id)) [id, appId] = [undefined, id]; + + const ctx = await context(args); + id = await resolveSessionId(ctx.apiUrl, ctx.auth, id); + const { result, session } = await DeviceGateway.launch(ctx.apiUrl, ctx.auth, id, { + ...(appId && { appId }), + ...(args['clear-state'] ? { clearState: true } : {}), + }); + return { + session, + data: { result } as { result: DeviceCommandResult }, + ...(!result.success && { + failure: { code: null, message: result.error || 'The app did not launch.' }, + }), + print() { + logger.log( + result.success + ? ui.success(`Launched ${appId ?? 'the app'}`) + : `${ui.statusSymbol('FAILED')} Launch failed`, + ); + if (result.error) logger.log(colors.error(result.error)); + }, + }; + }); + }, +}); + +const modelsSub = defineCommand({ + meta: { name: 'models', description: 'The devices a session can ask for' }, + args: { + ...commonArgs, + platform: { type: 'string', description: 'Only this platform: ios or android' }, + }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + const platform = validateEnum(args.platform as string | undefined, PLATFORMS, 'platform'); + const ctx = await context(args); + const catalogue = await DeviceGateway.models(ctx.apiUrl, ctx.auth); + const data: Partial = platform ? { [platform]: catalogue[platform] } : catalogue; + return { + session: undefined, + data, + print() { + for (const p of PLATFORMS) { + const models = data[p]; + if (!models) continue; + logger.log(ui.section(p === 'ios' ? 'iOS' : 'Android')); + logger.log( + ui.branch( + ui.fields( + models.map((m) => [ + m.default ? colors.bold(m.model) : m.model, + `${m.name} ${colors.dim(`os ${m.os.join(', ')} (default ${m.default_os})`)}` + + (m.google_play ? colors.dim(' google play') : '') + + (m.default ? colors.dim(' default') : ''), + ]), + ), + ), + ); + } + }, + }; + }); + }, +}); + +const inspectSub = defineCommand({ + meta: { name: 'inspect', description: 'The screen as compact JSON: each element with text, an id or a label' }, + args: { ...commonArgs, id: idArg }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + const ctx = await context(args); + const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id as string | undefined); + const { screen, session } = await DeviceGateway.inspect(ctx.apiUrl, ctx.auth, id); + return { + session, + data: { screen }, + print() { + logger.log( + ui.section( + `Screen ${screen.width}x${screen.height}, ${screen.elements.length} elements` + + (screen.fresh ? '' : colors.dim(' (no new frame; this is the latest)')), + ), + ); + logger.log(ui.branch(screen.elements.map((el) => formatElement(el)))); + }, + }; + }); + }, +}); + +/** One element on one line: what to target it by, then its bounds. */ +export function formatElement(el: DeviceScreenElement): string { + const parts: string[] = []; + if (el.txt) parts.push(`"${el.txt}"`); + if (el.a11y && el.a11y !== el.txt) parts.push(`a11y:"${el.a11y}"`); + if (el.rid) parts.push(`id:${el.rid}`); + if (el.hint) parts.push(`hint:"${el.hint}"`); + return `${parts.join(' ')} ${colors.dim(el.b)}`; +} + +/** `data:image/jpeg;base64,...` to bytes and a file extension. */ +export function decodeDataUrl(dataUrl: string): { bytes: Buffer; ext: string } { + const match = /^data:image\/([a-z]+);base64,(.*)$/s.exec(dataUrl); + if (!match) throw new Error('The API returned a screenshot in an unexpected format.'); + return { bytes: Buffer.from(match[2], 'base64'), ext: match[1] === 'jpeg' ? 'jpg' : match[1] }; +} + +const screenshotSub = defineCommand({ + meta: { name: 'screenshot', description: 'Save a screenshot of the device' }, + args: { + ...commonArgs, + id: idArg, + output: { + type: 'string', + alias: ['o'], + description: 'Where to save it (default: ./-