diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 86b0445..3ffe6e3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,12 @@ version: 2 # No `target-branch`: Dependabot targets the default branch (`dev`), which is # what we want. Setting it explicitly also stops security-update PRs from -# using the groups and commit prefixes below. +# using the commit prefixes below. +# +# A group covers scheduled version updates only, unless it says +# `applies-to: security-updates`. Security PRs open the day an advisory +# lands, not on the schedule, so each npm entry carries a `security` group +# too: a burst of advisories arrives as one PR instead of one per package. updates: # npm / pnpm dependencies. - package-ecosystem: npm @@ -20,6 +25,10 @@ updates: update-types: - minor - patch + security: + applies-to: security-updates + patterns: + - "*" # GitHub Actions used by our workflows. - package-ecosystem: github-actions diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 15d69da..8e8a6dc 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -76,12 +76,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/CLAUDE.md b/CLAUDE.md index d2fe424..2e2a5a9 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.** @@ -41,7 +43,7 @@ Top-level `defineCommand` in `src/index.ts` wires eleven subcommands (`cloud`, ` **Cloud command workflow** (the canonical shape other commands mirror): upload binary with SHA dedup → extract metadata / validate devices → build execution plan → submit tests → poll results every 10s → download artifacts (reports/videos/logs). `RunFailedError` from the polling service is the signal for a failed test run (distinct from infra errors). -**Auth.** Every command calls `resolveAuth({ apiKeyFlag })` (`src/utils/auth.ts`) once and threads the returned `AuthContext` into gateways/services. `ApiGateway` and `fetchCompatibilityData` spread `auth.headers` into fetch headers — they no longer accept a raw api key. Precedence: `--api-key` flag > `DEVICE_CLOUD_API_KEY` env > stored session from `dcd login`. `resolveAuth` refreshes expiring Supabase sessions via `CliAuthGateway.refresh` and rewrites the config atomically. A session's access token lasts about an hour, so code that can outlive it re-checks with `isAuthExpiring` / `refreshAuth`: the `dcd cloud` poll loop and the report downloads after it, the MCP context, and `dcd_run_cloud_test`'s wait loop. `refreshAuth` keeps the org the command started with and leaves API-key contexts untouched. +**Auth.** Every command calls `resolveAuth({ apiKeyFlag })` (`src/utils/auth.ts`) once and threads the returned `AuthContext` into gateways/services. `ApiGateway` and `fetchCompatibilityData` spread `auth.headers` into fetch headers — they no longer accept a raw api key. The same headers carry the CLI's identity on every request (`x-dcd-cli-version`, plus `x-dcd-ci-provider` / `x-dcd-ci-wrapper-version` from `detectCiContext`), which the API uses for notice targeting and records on each upload and binary (dcd#1622). Precedence: `--api-key` flag > `DEVICE_CLOUD_API_KEY` env > stored session from `dcd login`. `resolveAuth` refreshes expiring Supabase sessions via `CliAuthGateway.refresh` and rewrites the config atomically. A session's access token lasts about an hour, so code that can outlive it re-checks with `isAuthExpiring` / `refreshAuth`: the `dcd cloud` poll loop and the report downloads after it, the MCP context, and `dcd_run_cloud_test`'s wait loop. `refreshAuth` keeps the org the command started with and leaves API-key contexts untouched. **Config store.** `dcd login` writes `$XDG_CONFIG_HOME/dcd/config.json` (fallback `~/.dcd/config.json`, 0600). Shape: `{ version, env, api_url, supabase_url, session: { access_token, refresh_token, expires_at, user_email, user_id }, current_org_id, current_org_name }`. `DCD_CONFIG_DIR` overrides the directory (used by tests). The login command itself (`src/commands/login.ts`) uses PKCE (S256) with a server rendezvous — no loopback server: it mints `state`, `code_verifier`, and `code_challenge`, opens `/cli-login?state=...&code_challenge=...`, then polls the dcd API's `POST /cli-login/claim` with `{state, code_verifier}` while the frontend POSTs proof of identity (the browser's access token) to `POST /cli-login/handoff`; the API verifies the token, **mints a dedicated Supabase session for the CLI** (its own refresh-token family — sharing the browser's tokens caused "Invalid Refresh Token: Already Used" whenever either client rotated them), stores it keyed by state, then on claim verifies `sha256(verifier) === challenge` and returns it. After claiming, the CLI fetches `/me/orgs` and picks the org with `selectOrg` (`src/utils/orgs.ts`): `--org ` if given, else the only org, else the picker `dcd switch-org` uses, starting on the last login's org. That org only counts when the stored `api_url` matches, because org ids are per environment. Without a TTY (`canPrompt` in `src/utils/ci.ts`) the prompts can't be answered: the "already logged in" confirm takes its default and signs in again, the last login's org is used, and with none the command fails pointing at `--org`. `dcd logout` deletes the whole config, so nothing is remembered after it. The frontend lives in `../dcd/frontend/app/features/cli-login/CliLoginScreen.tsx`. @@ -51,10 +53,16 @@ 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`. One connection is shared, and its `fetch` re-reads the auth headers from `getContext()` on every request, so a renewed session is picked up without remaking the connection (which would close it under a `run` still going). If the hosted endpoint can't be reached, it serves the `dcd_*` tools only and says why on stderr. Read-only mode keeps only device tools annotated `readOnlyHint`. `dcd-mcp` itself is unchanged. + +**`dcd mcp install [client]`** (`src/utils/mcp-install.ts`) adds the server to Claude Code (`claude mcp add --scope user`), VS Code (`code --add-mcp`), or Claude Desktop / Cursor (merges an entry into their JSON config, and refuses a file that isn't plain JSON rather than rewrite it). Entries always start dcd by **full path**, since desktop apps opened from the Dock don't read the shell rc that puts `~/.dcd/bin` on PATH: the standalone binary is `process.execPath`; an npm install is node (`stableNodePath`, the PATH entry rather than Homebrew's versioned Cellar path) plus `argv[1]` as started, not resolved. It refuses to run from source or from the npx cache. Note citty runs a parent's `run` **after** its subcommand, so `mcpCommand.run` returns early when a positional is present — without that, `dcd mcp install` would go on to start a server. + ## Contributing Full guide in `CONTRIBUTING.md`; the operationally important parts (the ones that gate a merge or affect a release): +- ⚠️ **This repo is public — write PR titles and bodies, commit messages, code comments, test names and issues for a stranger.** Work here is often prompted by a support ticket or incident in the private `dcd` repo; that context stays there. Keep out: customer or org names and ids, ticket refs (`SUP-…`), private-repo issue/PR numbers, customer usage numbers, internal hostnames, Supabase project refs and dashboard links, and anything about our own security posture — key rotations or revocations, when they happened, which CLI versions they broke. Describe the fault generically ("a dropped B2 request failed the upload") and link the private issue from the private side instead. Edits don't undo a leak: GitHub keeps every revision of a PR or issue body, and a pushed commit stays readable from the PR after a later commit fixes it, so get it right before the first push. + - **Branch off `dev`** (the default branch) and open PRs **against `dev`**. `production` is the maintainer-only stable track — never target it directly. - PRs are **squash-merged**, so the **PR title becomes the commit** and must be a [Conventional Commit](https://www.conventionalcommits.org). The title — not the branch commits — is what release-please reads to compute the next version, so it matters even though individual commits are squashed away. A `PR Title` CI check enforces it. - Type → bump: `feat` **minor**; `fix`/`perf`/`deps`/`revert`/`refactor` **patch**; `docs`/`chore`/`test`/`ci`/`build`/`style` are hidden and bump nothing. Allowed scopes are free-form. diff --git a/README.md b/README.md index 04b861f..ddb1fcb 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,81 @@ See full documentation: [Docs](https://docs.devicecloud.dev) ## MCP server -The same npm package ships an [MCP](https://modelcontextprotocol.io) server (`dcd-mcp` bin) so AI agents — Claude, Cursor, VS Code — can drive devicecloud.dev directly. +devicecloud.dev has an [MCP](https://modelcontextprotocol.io) server, so AI agents (Claude, Cursor, VS Code) can run cloud tests and drive cloud devices. You don't run it yourself. You add it to your MCP client's config, and the client starts it. -Add it to your MCP client config: +There are three ways to connect: + +| | Install | Tools | Use it when | +| --- | --- | --- | --- | +| **`dcd mcp`** (recommended) | the CLI | device tools + `dcd_*` cloud-test tools | you want everything, including installing a local build or running local flow files | +| **Hosted** `https://api.devicecloud.dev/mcp` | nothing | device tools | the agent only drives devices, with apps and flows that are already uploaded or at a URL | +| **`dcd-mcp`** | the CLI | `dcd_*` cloud-test tools | you only run cloud tests (the older server; `dcd mcp` covers it) | + +### `dcd mcp` + +`dcd mcp` connects to the hosted server and passes its device tools through. It adds what only a local process can do: `install_app` takes `app_path` (a build on your machine, uploaded first) and `run` takes `path` (a flow file, or a directory uploaded as a bundle). It also serves the `dcd_*` tools below. + +**Set it up with `dcd mcp install`.** Install the CLI (see the top of this page), sign in, then add the server to your AI tool: + +```sh-session +$ dcd login +$ dcd mcp install cursor # or claude-code, claude-desktop, vscode +``` + +Leave out the client name to pick from a list. Add `--read-only` for a server that only offers tools that change nothing, or `--print` to see what would be added without changing anything. For Claude Code and VS Code it runs `claude mcp add` and `code --add-mcp`. For Claude Desktop and Cursor it adds an entry to their JSON config file and keeps everything else in it. Restart the app afterwards. + +**Setting it up by hand.** Start dcd by its **full path**. Desktop apps such as Claude Desktop and Cursor are usually opened from the Dock or Start menu, so they don't see the PATH your shell sets up, and a bare `dcd` gives "command not found". The installer prints the path at the end; it's `~/.dcd/bin/dcd` by default, written out in full because config files don't expand `~`. + +Claude Desktop (`claude_desktop_config.json`) and Cursor (`~/.cursor/mcp.json`): + +```jsonc +{ + "mcpServers": { + "devicecloud": { + "command": "/Users/you/.dcd/bin/dcd", + "args": ["mcp"] + } + } +} +``` + +VS Code (`.vscode/mcp.json`, or the user `mcp.json`) uses `servers` and a `type`: + +```jsonc +{ + "servers": { + "devicecloud": { + "type": "stdio", + "command": "/Users/you/.dcd/bin/dcd", + "args": ["mcp"] + } + } +} +``` + +Claude Code, run from a terminal, does see your PATH: + +```sh-session +$ claude mcp add --scope user devicecloud -- dcd mcp +``` + +If you use the npm package instead of the standalone binary, the command is `npx` with `"args": ["-y", "@devicecloud.dev/dcd", "mcp"]`. That needs Node 22 or newer on the app's PATH, and it checks npm each time it starts. + +It signs in the same way as the CLI: run `dcd login` once, or set `DEVICE_CLOUD_API_KEY` in `env`. Add `--read-only` to the args (or set `DCD_MCP_READONLY=1`) to serve only the tools that change nothing. If the hosted server can't be reached, it serves the `dcd_*` tools only and says why on stderr. + +**Device tools:** `list_devices`, `start_device`, `get_device_status`, `install_app`, `launch_app`, `inspect_screen`, `take_screenshot`, `run`, `get_command_status`, `get_logs`, `request_human`, `get_human_status`, `stop_device`, and `cheat_sheet` (Maestro syntax for the agent). + +### Hosted + +Point an HTTP-capable client at the URL. It signs you in through the browser (OAuth), and you can disconnect it later under Settings → Connected apps. + +```sh-session +$ claude mcp add --transport http devicecloud https://api.devicecloud.dev/mcp +``` + +### `dcd-mcp` + +The older server, with the `dcd_*` tools only. Add it to your MCP client config: ```jsonc { @@ -56,7 +128,7 @@ Add it to your MCP client config: Auth is inherited from the CLI: set `DEVICE_CLOUD_API_KEY` as above, or run `dcd login` once and the server picks up the stored session. Point it at a non-prod environment with `DCD_API_URL`. -**Tools** +**`dcd_*` tools** (served by both `dcd mcp` and `dcd-mcp`) | Tool | What it does | | --- | --- | diff --git a/install.ps1 b/install.ps1 index dab6b1f..c0c181e 100644 --- a/install.ps1 +++ b/install.ps1 @@ -126,6 +126,9 @@ if (-not $alreadyOnPath) { Write-Host "Installed: $(& "$InstallDir\dcd.exe" --version)" Write-Host ' Try: dcd --help' } +Write-Host '' +Write-Host ' Connect an AI agent (Claude, Cursor, VS Code): dcd mcp install' +Write-Host " In an MCP config file, start dcd by its full path: $InstallDir\dcd.exe" # --- warn about a conflicting (shadowing) install --- # A leftover `npm install -g @devicecloud.dev/dcd` resolves earlier on PATH than diff --git a/install.sh b/install.sh index 4d25dc8..195663f 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,56 @@ 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 mcp install${RESET} ${DIM}connect an AI agent (Claude, Cursor, VS Code)${RESET}" + info " ${CYAN}dcd --help${RESET} ${DIM}see every command${RESET}" + info "" + # Desktop apps don't read the shell rc that puts dcd on PATH, so an MCP + # config written by hand needs the full path (dcd mcp install uses it). + info " ${DIM}In an MCP config file, start dcd by its full path: $INSTALL_DIR/dcd${RESET}" + info "" } main "$@" 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 39e0d07..9e4e3fe 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 @@ -130,7 +130,7 @@ importers: version: 6.0.3 typescript-eslint: specifier: ^8.61.1 - version: 8.71.2(eslint@10.13.0)(typescript@6.0.3) + version: 8.72.0(eslint@10.13.0)(typescript@6.0.3) packages: @@ -473,63 +473,63 @@ packages: '@types/yazl@3.3.1': resolution: {integrity: sha512-DIWfCKpsTp6hE5BDBHV3+fIL/bLUF9Bv13iDrWnMlmhQpH67buNvI291ZauQ1xcccxK3FqQ9honnXpq4R8NMuQ==} - '@typescript-eslint/eslint-plugin@8.71.2': - resolution: {integrity: sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==} + '@typescript-eslint/eslint-plugin@8.72.0': + resolution: {integrity: sha512-aysKgysPX9XOv/dk95UkvUDe++Q3n3dJPG9CEWT3RUccxfkPJ+Qn6nhKPDhZXU6Ofo9kgwGWwKyQCnZIXTgngg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.71.2 + '@typescript-eslint/parser': ^8.72.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.71.2': - resolution: {integrity: sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==} + '@typescript-eslint/parser@8.72.0': + resolution: {integrity: sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.71.2': - resolution: {integrity: sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==} + '@typescript-eslint/project-service@8.72.0': + resolution: {integrity: sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.71.2': - resolution: {integrity: sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==} + '@typescript-eslint/scope-manager@8.72.0': + resolution: {integrity: sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.71.2': - resolution: {integrity: sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==} + '@typescript-eslint/tsconfig-utils@8.72.0': + resolution: {integrity: sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.71.2': - resolution: {integrity: sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==} + '@typescript-eslint/type-utils@8.72.0': + resolution: {integrity: sha512-gEXxmmbSTSOVTC37IYYvremOM31v+UN2W772bJxmVYPhAOPSUxtacyb7wkm1EhkYKU6KzFJsdU44DCV4to0LWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.71.2': - resolution: {integrity: sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==} + '@typescript-eslint/types@8.72.0': + resolution: {integrity: sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.71.2': - resolution: {integrity: sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==} + '@typescript-eslint/typescript-estree@8.72.0': + resolution: {integrity: sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.71.2': - resolution: {integrity: sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==} + '@typescript-eslint/utils@8.72.0': + resolution: {integrity: sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.71.2': - resolution: {integrity: sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==} + '@typescript-eslint/visitor-keys@8.72.0': + resolution: {integrity: sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@xmldom/xmldom@0.9.12': @@ -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==} @@ -1035,8 +1035,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.12: - resolution: {integrity: sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==} + ignore@7.0.13: + resolution: {integrity: sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q==} engines: {node: '>= 4'} imurmurhash@0.1.4: @@ -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: @@ -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==} @@ -1633,8 +1637,8 @@ packages: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} - typescript-eslint@8.71.2: - resolution: {integrity: sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==} + typescript-eslint@8.72.0: + resolution: {integrity: sha512-r26Fkc8As+mZvqp7SLsMmNFyYUup/6CjK7/eHBHmhOw6GH6n6YFxHVFrG0jN1c5CMpDzelLPxvgeCOzx7BjN+A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -1987,57 +1991,57 @@ snapshots: dependencies: '@types/node': 26.6.5 - '@typescript-eslint/eslint-plugin@8.71.2(@typescript-eslint/parser@8.71.2(eslint@10.13.0)(typescript@6.0.3))(eslint@10.13.0)(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.72.0(@typescript-eslint/parser@8.72.0(eslint@10.13.0)(typescript@6.0.3))(eslint@10.13.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.71.2(eslint@10.13.0)(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.71.2 - '@typescript-eslint/type-utils': 8.71.2(eslint@10.13.0)(typescript@6.0.3) - '@typescript-eslint/utils': 8.71.2(eslint@10.13.0)(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.71.2 + '@typescript-eslint/parser': 8.72.0(eslint@10.13.0)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.72.0 + '@typescript-eslint/type-utils': 8.72.0(eslint@10.13.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.72.0(eslint@10.13.0)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.72.0 eslint: 10.13.0 - ignore: 7.0.12 + ignore: 7.0.13 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.71.2(eslint@10.13.0)(typescript@6.0.3)': + '@typescript-eslint/parser@8.72.0(eslint@10.13.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.71.2 - '@typescript-eslint/types': 8.71.2 - '@typescript-eslint/typescript-estree': 8.71.2(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.71.2 + '@typescript-eslint/scope-manager': 8.72.0 + '@typescript-eslint/types': 8.72.0 + '@typescript-eslint/typescript-estree': 8.72.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.72.0 debug: 4.4.3(supports-color@8.1.1) eslint: 10.13.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.71.2(typescript@6.0.3)': + '@typescript-eslint/project-service@8.72.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.71.2(typescript@6.0.3) - '@typescript-eslint/types': 8.71.2 + '@typescript-eslint/tsconfig-utils': 8.72.0(typescript@6.0.3) + '@typescript-eslint/types': 8.72.0 debug: 4.4.3(supports-color@8.1.1) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.71.2': + '@typescript-eslint/scope-manager@8.72.0': dependencies: - '@typescript-eslint/types': 8.71.2 - '@typescript-eslint/visitor-keys': 8.71.2 + '@typescript-eslint/types': 8.72.0 + '@typescript-eslint/visitor-keys': 8.72.0 - '@typescript-eslint/tsconfig-utils@8.71.2(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.72.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.71.2(eslint@10.13.0)(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.72.0(eslint@10.13.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.71.2 - '@typescript-eslint/typescript-estree': 8.71.2(typescript@6.0.3) - '@typescript-eslint/utils': 8.71.2(eslint@10.13.0)(typescript@6.0.3) + '@typescript-eslint/types': 8.72.0 + '@typescript-eslint/typescript-estree': 8.72.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.72.0(eslint@10.13.0)(typescript@6.0.3) debug: 4.4.3(supports-color@8.1.1) eslint: 10.13.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -2045,14 +2049,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.71.2': {} + '@typescript-eslint/types@8.72.0': {} - '@typescript-eslint/typescript-estree@8.71.2(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.72.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.71.2(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.71.2(typescript@6.0.3) - '@typescript-eslint/types': 8.71.2 - '@typescript-eslint/visitor-keys': 8.71.2 + '@typescript-eslint/project-service': 8.72.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.72.0(typescript@6.0.3) + '@typescript-eslint/types': 8.72.0 + '@typescript-eslint/visitor-keys': 8.72.0 debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.3 semver: 7.8.5 @@ -2062,20 +2066,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.71.2(eslint@10.13.0)(typescript@6.0.3)': + '@typescript-eslint/utils@8.72.0(eslint@10.13.0)(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.13.0) - '@typescript-eslint/scope-manager': 8.71.2 - '@typescript-eslint/types': 8.71.2 - '@typescript-eslint/typescript-estree': 8.71.2(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.72.0 + '@typescript-eslint/types': 8.72.0 + '@typescript-eslint/typescript-estree': 8.72.0(typescript@6.0.3) eslint: 10.13.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.71.2': + '@typescript-eslint/visitor-keys@8.72.0': dependencies: - '@typescript-eslint/types': 8.71.2 + '@typescript-eslint/types': 8.72.0 eslint-visitor-keys: 5.0.1 '@xmldom/xmldom@0.9.12': {} @@ -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: @@ -2442,14 +2446,14 @@ 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 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: @@ -2638,7 +2642,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.12: {} + ignore@7.0.13: {} imurmurhash@0.1.4: {} @@ -2648,7 +2652,7 @@ snapshots: interpret@1.4.0: {} - ip-address@10.7.3: {} + ip-address@10.7.4: {} ipaddr.js@1.9.1: {} @@ -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: {} @@ -3257,12 +3266,12 @@ snapshots: media-typer: 1.1.1 mime-types: 3.0.2 - typescript-eslint@8.71.2(eslint@10.13.0)(typescript@6.0.3): + typescript-eslint@8.72.0(eslint@10.13.0)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.71.2(@typescript-eslint/parser@8.71.2(eslint@10.13.0)(typescript@6.0.3))(eslint@10.13.0)(typescript@6.0.3) - '@typescript-eslint/parser': 8.71.2(eslint@10.13.0)(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.71.2(typescript@6.0.3) - '@typescript-eslint/utils': 8.71.2(eslint@10.13.0)(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.72.0(@typescript-eslint/parser@8.72.0(eslint@10.13.0)(typescript@6.0.3))(eslint@10.13.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.72.0(eslint@10.13.0)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.72.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.72.0(eslint@10.13.0)(typescript@6.0.3) eslint: 10.13.0 typescript: 6.0.3 transitivePeerDependencies: diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index fc3f04f..969c633 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -12,7 +12,7 @@ import { writeJSONFile, } from '../methods.js'; import { DeviceValidationService } from '../services/device-validation.service.js'; -import { plan } from '../services/execution-plan.service.js'; +import { countExtendedRuntimeFlows, plan } from '../services/execution-plan.service.js'; import { isSubmitEndpointMissing, submitWithRetry, @@ -365,11 +365,7 @@ export const cloudCommand = defineCommand({ const ciContext = detectCiContext(); let compatibilityData: CompatibilityData; try { - compatibilityData = await fetchCompatibilityData(apiUrl, auth, { - cliVersion, - ciProvider: ciContext.provider, - ciWrapperVersion: ciContext.wrapperVersion, - }); + compatibilityData = await fetchCompatibilityData(apiUrl, auth); if (debug) { out('[DEBUG] Successfully fetched compatibility data from API'); } @@ -774,6 +770,17 @@ export const cloudCommand = defineCommand({ ), ); } + + // The 2x is charged per flow, so say it before the run, not on the bill. + const extendedFlows = countExtendedRuntimeFlows(flowOverrides); + if (extendedFlows > 0) { + submitRows.push( + '', + ui.note( + `${extendedFlows} flow${extendedFlows === 1 ? '' : 's'} with extended runtime: up to 20 min each, billed at 2×`, + ), + ); + } } out(ui.section('Submitting new job')); diff --git a/src/commands/device.ts b/src/commands/device.ts new file mode 100644 index 0000000..099099e --- /dev/null +++ b/src/commands/device.ts @@ -0,0 +1,1312 @@ +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.`); +} + +/** + * The "Next steps" `dcd device start` prints. They follow the session: a + * device that isn't ready yet can't be inspected, so that comes first, and + * a session with no app gets the install step. + */ +export function startNextSteps(session: DeviceSession, hasApp: boolean): [string, string][] { + const { id } = session; + const steps: [string, string][] = []; + if (!USABLE_STATES.has(session.state)) { + steps.push(['check it is ready', `dcd device status ${id}`]); + } + + if (!hasApp) steps.push(['install an app', `dcd device install ${id} `]); + steps.push( + ['inspect the screen', `dcd device inspect ${id}`], + ['run a flow', `dcd device run ${id} flow.yaml`], + ['stop', `dcd device stop ${id}`], + ['all commands', 'dcd device --help'], + ); + return steps; +} + +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')); + const hasApp = Boolean(installedId ?? uploadedId); + const steps = startNextSteps(session, hasApp); + logger.log(ui.branch(ui.fields(steps.map(([label, cmd]) => [label, colors.highlight(cmd)])))); + if (!USABLE_STATES.has(session.state)) { + logger.log(ui.info('Next time, `dcd device start --wait` waits until the device is ready.')); + } + }, + }; + }); + }, +}); + +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, + all: { + type: 'boolean', + default: false, + description: 'Also list the keyboard keys and the System UI (status and navigation bars)', + }, + }, + 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, Boolean(args.all)); + 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)))); + const notes: string[] = []; + if (screen.keyboard_open) notes.push('A keyboard is open.'); + if (screen.hidden && (screen.hidden.keyboard > 0 || screen.hidden.system > 0)) { + notes.push( + `Left out ${screen.hidden.keyboard} keyboard and ${screen.hidden.system} System UI elements (--all shows them).`, + ); + } + + if (notes.length > 0) logger.log(ui.note(notes.join(' '))); + }, + }; + }); + }, +}); + +/** 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}"`); + for (const state of ['checked', 'selected', 'focused'] as const) { + if (el[state]) parts.push(state); + } + + if (el.enabled === false) parts.push('disabled'); + 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: ./-