diff --git a/CLAUDE.md b/CLAUDE.md index 0bf72db..2917e1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,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. +**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. **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 prompts for an org (the same picker `dcd switch-org` uses). The frontend lives in `../dcd/frontend/app/features/cli-login/CliLoginScreen.tsx`. @@ -49,7 +49,7 @@ Top-level `defineCommand` in `src/index.ts` wires eleven subcommands (`cloud`, ` **Telemetry.** `src/services/telemetry.service.ts` ships lifecycle (`command started` / `command completed` / `command failed`) and error events to the dcd API's `/cli/logs` proxy → Axiom `cli-dev` / `cli-prod`. Wired in at three points: `src/index.ts` replicates citty's `runMain` (which would otherwise swallow errors and exit 1) to record start/success/failure and honor `CliError.exitCode`; `src/utils/auth.ts` calls `telemetry.configure({ auth })` from `resolveAuth` so the token never has to be re-derived; `src/utils/cli.ts` `logger.error` calls `telemetry.flushSync()` (which shells out to `curl` because `process.exit` bypasses `beforeExit`) before exiting. Unauthenticated invocations (`--help`, `--version`, `dcd login` pre-success) buffer in memory and drop on exit — by design, since there's no identity to attach. Opt out per-invocation with `DCD_TELEMETRY_DISABLED=1`. -**MCP server.** `src/mcp/` is a third front-end onto the same service layer (a sibling to `src/commands/`), shipped as the `dcd-mcp` bin over stdio transport (`@modelcontextprotocol/sdk`, `zod` schemas). `index.ts` boots the server; `server.ts` registers tools; `context.ts` resolves auth + API URL **lazily and once** (so `tools/list` works unauthenticated and auth errors surface as tool errors, not a boot crash) via the same `resolveAuth`/`resolveApiUrl` as the CLI — `DEVICE_CLOUD_API_KEY` env or stored `dcd login` session, with `DCD_API_URL` to override. **Critical invariant: stdout is the JSON-RPC channel** — tools must never call the `src/commands/*` layer or `utils/cli` `logger` (both write to stdout / can `process.exit`); they call services/gateways directly with `logStderr` and return data via `helpers.ts` `jsonResult`/`errorResult`. The `runTool` wrapper records `mcp tool …` telemetry and converts thrown errors to `isError` results. Tools: `dcd_list_devices`, `dcd_list_runs`, `dcd_get_status`, `dcd_download_artifacts` (read-only), and `dcd_run_cloud_test` (billable — gated out by `--read-only` / `DCD_MCP_READONLY=1`, annotated destructive, async-by-default). `dcd_run_cloud_test` reuses `computeCommonRoot`/`buildTestMetadataMap` from `src/services/flow-paths.ts` (extracted from `cloud.ts` so both build identical server-side paths). Registry manifest: `server.json` at repo root. +**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. ## Contributing diff --git a/README.md b/README.md index fdb4804..04b861f 100644 --- a/README.md +++ b/README.md @@ -45,13 +45,15 @@ Add it to your MCP client config: "mcpServers": { "devicecloud": { "command": "npx", - "args": ["-y", "@devicecloud.dev/dcd", "dcd-mcp"], + "args": ["-y", "--package=@devicecloud.dev/dcd", "dcd-mcp"], "env": { "DEVICE_CLOUD_API_KEY": "" } } } } ``` +`--package` matters: the package's default bin is `dcd`, so `npx @devicecloud.dev/dcd dcd-mcp` would run `dcd dcd-mcp` (an unknown command) instead of the server. + 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** @@ -64,7 +66,7 @@ Auth is inherited from the CLI: set `DEVICE_CLOUD_API_KEY` as above, or run `dcd | `dcd_download_artifacts` | Download a run's artifacts/report to disk | | `dcd_run_cloud_test` | Submit a flow to run on the cloud (**billable**) | -**Read-only mode.** `dcd_run_cloud_test` consumes test minutes, so it is annotated as non-read-only/destructive (clients can prompt before calling it). To hide it entirely — recommended for autonomous or untrusted agents — pass `--read-only` in `args`, or set `DCD_MCP_READONLY=1` in `env`. +**Read-only mode.** `dcd_run_cloud_test` consumes test minutes, so it is annotated as non-read-only/destructive (clients can prompt before calling it). To hide it entirely — recommended for autonomous or untrusted agents — pass `--read-only` at the end of `args` (`["-y", "--package=@devicecloud.dev/dcd", "dcd-mcp", "--read-only"]`), or set `DCD_MCP_READONLY=1` in `env`. By default `dcd_run_cloud_test` is async: it returns an `uploadId` immediately, which you poll with `dcd_get_status`. Pass `wait: true` (bounded by `waitTimeoutSeconds`) to block until completion, or `dryRun: true` to preview the flows without submitting. diff --git a/package.json b/package.json index ea0c840..83f801e 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "chai": "^6.2.2", "eslint": "^10.5.0", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-unicorn": "^74.0.0", + "eslint-plugin-unicorn": "^76.0.0", "husky": "^9.1.7", "mocha": "^12.0.0", "prettier": "^3.8.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02679d2..46ec460 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,10 +41,10 @@ importers: dependencies: '@clack/prompts': specifier: ^1.6.0 - version: 1.8.0 + version: 1.8.1 '@modelcontextprotocol/sdk': specifier: ^1.29.0 - version: 1.30.0(zod@4.6.2) + version: 1.30.0(zod@4.6.5) '@supabase/supabase-js': specifier: ^2.108.2 version: 2.116.0 @@ -59,7 +59,7 @@ importers: version: 0.2.2 js-yaml: specifier: ^5.2.2 - version: 5.4.1 + version: 5.4.2 node-apk: specifier: ^1.2.1 version: 1.2.1 @@ -80,7 +80,7 @@ importers: version: 3.3.1 zod: specifier: ^4.4.3 - version: 4.6.2 + version: 4.6.5 devDependencies: '@eslint/js': specifier: ^10.0.1 @@ -96,7 +96,7 @@ importers: version: 10.0.10 '@types/node': specifier: ^26.0.0 - version: 26.5.1 + version: 26.6.1 '@types/yazl': specifier: ^3.3.1 version: 3.3.1 @@ -110,17 +110,17 @@ importers: specifier: ^10.1.8 version: 10.1.8(eslint@10.10.0) eslint-plugin-unicorn: - specifier: ^74.0.0 - version: 74.0.0(eslint@10.10.0) + specifier: ^76.0.0 + version: 76.0.0(eslint@10.10.0) husky: specifier: ^9.1.7 version: 9.1.7 mocha: specifier: ^12.0.0 - version: 12.0.1 + version: 12.0.2 prettier: specifier: ^3.8.4 - version: 3.9.6 + version: 3.9.8 shx: specifier: ^0.4.0 version: 0.4.0 @@ -142,12 +142,12 @@ packages: '@cacheable/utils@2.5.0': resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} - '@clack/core@1.5.0': - resolution: {integrity: sha512-zNikCcd8BbcEvzzG1sbXFrRHFk5kHPrpwZwksPvf9qyQO1Teb7JaXaOAxXZei9nZLDW0gaZawiuTCji88bTBhw==} + '@clack/core@1.5.1': + resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} engines: {node: '>= 20.12.0'} - '@clack/prompts@1.8.0': - resolution: {integrity: sha512-PXzLZ8N34rxmuo4dJg3xtOXhcBse94qGjDqsteoEYrFrrZ5FSjIGwMAuOcv64ln8rHVBBD06XeVGr+/JX+plcA==} + '@clack/prompts@1.8.1': + resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==} engines: {node: '>= 20.12.0'} '@esbuild/aix-ppc64@0.28.2': @@ -328,8 +328,8 @@ packages: resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/css-tree@4.1.0': - resolution: {integrity: sha512-cg0ohyrAG3swyGqt8t1K/OK97DqBw/ftDvlvyY1fmEst5B40UOmsimwLENq74z2dyw5CDM+3zJIW+CV2nFNDdA==} + '@eslint/css-tree@4.1.1': + resolution: {integrity: sha512-A+s89eP+yDAPkKjnIOfjlkGifg5J7GPn01nWx14UjBVWktfkxOU4975F9ZZ13xNxkGiblNcBaAqgKhnQrW6SWA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/js@10.0.1': @@ -463,8 +463,8 @@ packages: '@types/mocha@10.0.10': resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} - '@types/node@26.5.1': - resolution: {integrity: sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==} + '@types/node@26.6.1': + resolution: {integrity: sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA==} '@types/yazl@3.3.1': resolution: {integrity: sha512-DIWfCKpsTp6hE5BDBHV3+fIL/bLUF9Bv13iDrWnMlmhQpH67buNvI291ZauQ1xcccxK3FqQ9honnXpq4R8NMuQ==} @@ -571,8 +571,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.11.21: - resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} + baseline-browser-mapping@2.11.25: + resolution: {integrity: sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==} engines: {node: '>=6.0.0'} hasBin: true @@ -595,8 +595,8 @@ packages: browser-stdout@1.3.1: resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} - browserslist@4.28.9: - resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} + browserslist@4.29.0: + resolution: {integrity: sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -607,8 +607,8 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - builtin-modules@5.3.0: - resolution: {integrity: sha512-hMQUl2bUFG339QygPM97E+mc8OY1IAchORZxm4a/frcYwKzozMzRVDBwHW0NjOqGElLm2O37AVQE8ikxlZHrMQ==} + builtin-modules@5.4.0: + resolution: {integrity: sha512-JCWSCdun+4Ovd9BhM/Vtlmwb7oD6Wl4YiPRXXwhAuwlPhW1un/MKgXn7GZoFm53ginnoNzus69V8JWx0K393jg==} engines: {node: '>=18.20'} bytes@3.1.2: @@ -670,10 +670,6 @@ packages: resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} - convert-hrtime@5.0.0: - resolution: {integrity: sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==} - engines: {node: '>=12'} - cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -732,8 +728,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.422: - resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==} + electron-to-chromium@1.5.433: + resolution: {integrity: sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==} encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} @@ -742,8 +738,8 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - entities@8.0.0: - resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} engines: {node: '>=20.19.0'} es-define-property@1.0.1: @@ -780,8 +776,8 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-plugin-unicorn@74.0.0: - resolution: {integrity: sha512-AGnsGi2SxHg1HEAXxn9nSnZfyjvTWkxm8E8hpd/9tD6dLjBUdcD7+D6ZN64HmmCXTSXlrwVyUqe20Uyb2CaurA==} + eslint-plugin-unicorn@76.0.0: + resolution: {integrity: sha512-3ywJrCMHKYhcBP5yFzGaOOX3dY7+iR3hvpuoS/0EnhRFgVWLejly+t4fOdJvWQxJdHZl1C+QBW7hybXPDld4Pw==} engines: {node: '>=22'} peerDependencies: eslint: '>=10.4' @@ -910,10 +906,6 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} - find-up@8.0.0: - resolution: {integrity: sha512-JGG8pvDi2C+JxidYdIwQDyS/CgcrIdh18cvgxcBge3wSHRQOrooMD3GlFBcmMJAN9M42SAZjDp5zv1dglJjwww==} - engines: {node: '>=20'} - flat-cache@6.1.23: resolution: {integrity: sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==} @@ -936,10 +928,6 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - function-timeout@1.0.2: - resolution: {integrity: sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==} - engines: {node: '>=18'} - get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -1069,10 +1057,6 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-identifier@1.1.0: - resolution: {integrity: sha512-NhOds0mDx9lJu+1lBRO0xbwFo5nobA7GCk/0e5xjr6+6XugX985+0OyGX35BNrTkPAsdLcIKg02HUQJOK8D8kw==} - engines: {node: '>=18'} - is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -1105,8 +1089,8 @@ packages: js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} - js-yaml@5.4.1: - resolution: {integrity: sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==} + js-yaml@5.4.2: + resolution: {integrity: sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==} hasBin: true jsesc@3.1.0: @@ -1137,10 +1121,6 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - locate-path@8.0.0: - resolution: {integrity: sha512-XT9ewWAC43tiAV7xDAPflMkG0qOPn2QjHqlgX8FOqmWa/rxnyYDulF9T0F7tRy1u+TVTmK/M//6VIOye+2zDXg==} - engines: {node: '>=20'} - lodash._baseiteratee@4.7.0: resolution: {integrity: sha512-nqB9M+wITz0BX/Q2xg6fQ8mLkyfF7MU7eE+MNBNjTHFKeKaZAPEzEg+E8LWxKWf1DQVflNEn9N49yAuqKh2mWQ==} @@ -1169,16 +1149,12 @@ packages: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} - make-asynchronous@1.1.0: - resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==} - engines: {node: '>=18'} - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - mdn-data@2.34.0: - resolution: {integrity: sha512-OgIlLv0NxJKVW4GTSAoEgpRGd4F2XCqGinK0MsMlBCCS/Zcm2/LsbercNWNA7PeMMcjl75NnI97eqyo7zkdxWA==} + mdn-data@2.35.0: + resolution: {integrity: sha512-k+1+dEIm2z/BEfdvYzT/fIyAGeDBo/1AJNJI4ilFXJRYoxP/3Ds+GP8shAOIq8jYXY9N5nGB3Ag104NsagmLAA==} media-typer@1.1.1: resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} @@ -1223,8 +1199,8 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} - mocha@12.0.1: - resolution: {integrity: sha512-/ILgtFHV+R7EJLwN0OkrPPOu6wbJyrtuRtTJ5szdFHv2A3mPmkbu3r1H2q6FhaAfywDJCulcQQ2S9qDGfZW6dw==} + mocha@12.0.2: + resolution: {integrity: sha512-SjAulGHxlMJLCD1bhvJBAiYwYxglydRzA9PDQ5MALq1oLRMMtIhxSQJ0olCFgA7c9YbTdxC0KOV0wl8JEDYVhA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -1248,8 +1224,8 @@ packages: resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} engines: {node: '>= 6.13.0'} - node-releases@2.0.54: - resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + node-releases@2.0.56: + resolution: {integrity: sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==} engines: {node: '>=18'} node-stream-zip@1.16.0: @@ -1279,10 +1255,6 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - p-event@6.0.1: - resolution: {integrity: sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==} - engines: {node: '>=16.17'} - p-finally@1.0.0: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} engines: {node: '>=4'} @@ -1291,22 +1263,10 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} - p-limit@4.0.0: - resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - p-locate@6.0.0: - resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - p-timeout@6.1.4: - resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} - engines: {node: '>=14.16'} - parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -1360,8 +1320,8 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier@3.9.6: - resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + prettier@3.9.8: + resolution: {integrity: sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==} engines: {node: '>=14'} hasBin: true @@ -1413,8 +1373,8 @@ packages: resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} engines: {node: '>= 0.10'} - regjsparser@0.13.2: - resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==} + regjsparser@0.13.3: + resolution: {integrity: sha512-ycwFAS14Jw4mppvmK4GR/J6u3WpWpjkEApehuHtLc/8VpPNpDMbQ4WjqwplXifGeyKOzHSFLmSPqzksDQE2Sfg==} hasBin: true require-from-string@2.0.2: @@ -1543,10 +1503,6 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} - super-regex@1.1.0: - resolution: {integrity: sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==} - engines: {node: '>=18'} - supports-color@8.1.1: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} @@ -1559,10 +1515,6 @@ packages: resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} engines: {node: '>=18'} - time-span@5.1.0: - resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} - engines: {node: '>=12'} - tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -1597,10 +1549,6 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} - type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} @@ -1620,16 +1568,12 @@ packages: undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} - unicorn-magic@0.3.0: - resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} - engines: {node: '>=18'} - unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - update-browserslist-db@1.3.2: - resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} + update-browserslist-db@1.3.3: + resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -1644,9 +1588,6 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - web-worker@1.5.0: - resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} - which@1.3.1: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} hasBin: true @@ -1674,8 +1615,8 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} - yaml@2.9.0: - resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} engines: {node: '>= 14.6'} hasBin: true @@ -1686,17 +1627,13 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yocto-queue@1.2.2: - resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} - engines: {node: '>=12.20'} - zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: zod: ^3.25.28 || ^4 - zod@4.6.2: - resolution: {integrity: sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ==} + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} snapshots: @@ -1712,14 +1649,14 @@ snapshots: hashery: 1.5.1 keyv: 5.6.0 - '@clack/core@1.5.0': + '@clack/core@1.5.1': dependencies: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@clack/prompts@1.8.0': + '@clack/prompts@1.8.1': dependencies: - '@clack/core': 1.5.0 + '@clack/core': 1.5.1 fast-string-width: 3.0.2 fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 @@ -1825,9 +1762,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/css-tree@4.1.0': + '@eslint/css-tree@4.1.1': dependencies: - mdn-data: 2.34.0 + mdn-data: 2.35.0 source-map-js: 1.2.1 '@eslint/js@10.0.1(eslint@10.10.0)': @@ -1873,7 +1810,7 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@modelcontextprotocol/sdk@1.30.0(zod@4.6.2)': + '@modelcontextprotocol/sdk@1.30.0(zod@4.6.5)': dependencies: '@hono/node-server': 2.0.12(hono@4.13.5) ajv: 8.20.0 @@ -1890,8 +1827,8 @@ snapshots: json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 4.6.2 - zod-to-json-schema: 3.25.2(zod@4.6.2) + zod: 4.6.5 + zod-to-json-schema: 3.25.2(zod@4.6.5) transitivePeerDependencies: - supports-color @@ -1956,13 +1893,13 @@ snapshots: '@types/mocha@10.0.10': {} - '@types/node@26.5.1': + '@types/node@26.6.1': dependencies: undici-types: 8.9.0 '@types/yazl@3.3.1': dependencies: - '@types/node': 26.5.1 + '@types/node': 26.6.1 '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.10.0)(typescript@6.0.3))(eslint@10.10.0)(typescript@6.0.3)': dependencies: @@ -2092,7 +2029,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.21: {} + baseline-browser-mapping@2.11.25: {} body-parser@2.3.0: dependencies: @@ -2120,19 +2057,19 @@ snapshots: browser-stdout@1.3.1: {} - browserslist@4.28.9: + browserslist@4.29.0: dependencies: - baseline-browser-mapping: 2.11.21 + baseline-browser-mapping: 2.11.25 caniuse-lite: 1.0.30001810 - electron-to-chromium: 1.5.422 - node-releases: 2.0.54 - update-browserslist-db: 1.3.2(browserslist@4.28.9) + electron-to-chromium: 1.5.433 + node-releases: 2.0.56 + update-browserslist-db: 1.3.3(browserslist@4.29.0) buffer-crc32@1.0.0: {} buffer-from@1.1.2: {} - builtin-modules@5.3.0: {} + builtin-modules@5.4.0: {} bytes@3.1.2: {} @@ -2183,15 +2120,13 @@ snapshots: content-type@2.0.0: {} - convert-hrtime@5.0.0: {} - cookie-signature@1.2.2: {} cookie@0.7.2: {} core-js-compat@3.50.0: dependencies: - browserslist: 4.28.9 + browserslist: 4.29.0 cors@2.8.6: dependencies: @@ -2236,7 +2171,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.422: {} + electron-to-chromium@1.5.433: {} encodeurl@2.0.0: {} @@ -2244,7 +2179,7 @@ snapshots: dependencies: once: 1.4.0 - entities@8.0.0: {} + entities@8.1.0: {} es-define-property@1.0.1: {} @@ -2293,29 +2228,29 @@ snapshots: dependencies: eslint: 10.10.0 - eslint-plugin-unicorn@74.0.0(eslint@10.10.0): + eslint-plugin-unicorn@76.0.0(eslint@10.10.0): dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0) - '@eslint/css-tree': 4.1.0 - browserslist: 4.28.9 + '@eslint/css-tree': 4.1.1 + browserslist: 4.29.0 change-case: 5.4.4 ci-info: 4.4.0 core-js-compat: 3.50.0 detect-indent: 7.0.2 - entities: 8.0.0 + entities: 8.1.0 eslint: 10.10.0 find-up-simple: 1.0.1 globals: 17.12.0 + identifier-regex: 1.1.0 indent-string: 5.0.0 is-builtin-module: 5.0.0 - is-identifier: 1.1.0 pluralize: 8.0.0 quote-js-string: 0.1.0 - regjsparser: 0.13.2 + regjsparser: 0.13.3 reserved-identifiers: 1.2.0 semver: 7.8.5 strip-indent: 4.1.1 - yaml: 2.9.0 + yaml: 2.9.1 eslint-scope@9.1.2: dependencies: @@ -2500,11 +2435,6 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 - find-up@8.0.0: - dependencies: - locate-path: 8.0.0 - unicorn-magic: 0.3.0 - flat-cache@6.1.23: dependencies: cacheable: 2.5.0 @@ -2522,8 +2452,6 @@ snapshots: function-bind@1.1.2: {} - function-timeout@1.0.2: {} - get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2622,7 +2550,7 @@ snapshots: is-builtin-module@5.0.0: dependencies: - builtin-modules: 5.3.0 + builtin-modules: 5.4.0 is-core-module@2.16.2: dependencies: @@ -2634,11 +2562,6 @@ snapshots: dependencies: is-extglob: 2.1.1 - is-identifier@1.1.0: - dependencies: - identifier-regex: 1.1.0 - super-regex: 1.1.0 - is-number@7.0.0: {} is-path-inside@4.0.0: {} @@ -2657,7 +2580,7 @@ snapshots: js-base64@3.7.8: {} - js-yaml@5.4.1: + js-yaml@5.4.2: dependencies: argparse: 2.0.1 @@ -2684,10 +2607,6 @@ snapshots: dependencies: p-locate: 5.0.0 - locate-path@8.0.0: - dependencies: - p-locate: 6.0.0 - lodash._baseiteratee@4.7.0: dependencies: lodash._stringtopath: 4.8.0 @@ -2716,15 +2635,9 @@ snapshots: lru-cache@11.5.2: {} - make-asynchronous@1.1.0: - dependencies: - p-event: 6.0.1 - type-fest: 4.41.0 - web-worker: 1.5.0 - math-intrinsics@1.1.0: {} - mdn-data@2.34.0: {} + mdn-data@2.35.0: {} media-typer@1.1.1: {} @@ -2759,17 +2672,17 @@ snapshots: dependencies: minipass: 7.1.3 - mocha@12.0.1: + mocha@12.0.2: dependencies: browser-stdout: 1.3.1 chokidar: 5.0.0 debug: 4.4.3(supports-color@8.1.1) diff: 8.0.3 - find-up: 8.0.0 + find-up-simple: 1.0.1 glob: 13.0.6 is-path-inside: 4.0.0 is-unicode-supported: 0.1.0 - js-yaml: 5.4.1 + js-yaml: 5.4.2 minimatch: 10.2.3 ms: 2.1.3 picocolors: 1.1.1 @@ -2792,7 +2705,7 @@ snapshots: node-forge@1.4.0: {} - node-releases@2.0.54: {} + node-releases@2.0.56: {} node-stream-zip@1.16.0: {} @@ -2821,30 +2734,16 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - p-event@6.0.1: - dependencies: - p-timeout: 6.1.4 - p-finally@1.0.0: {} p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 - p-limit@4.0.0: - dependencies: - yocto-queue: 1.2.2 - p-locate@5.0.0: dependencies: p-limit: 3.1.0 - p-locate@6.0.0: - dependencies: - p-limit: 4.0.0 - - p-timeout@6.1.4: {} - parseurl@1.3.3: {} path-exists@4.0.0: {} @@ -2879,7 +2778,7 @@ snapshots: prelude-ls@1.2.1: {} - prettier@3.9.6: {} + prettier@3.9.8: {} proper-lockfile@4.1.2: dependencies: @@ -2929,7 +2828,7 @@ snapshots: dependencies: resolve: 1.22.12 - regjsparser@0.13.2: + regjsparser@0.13.3: dependencies: jsesc: 3.1.0 @@ -3065,12 +2964,6 @@ snapshots: strip-json-comments@5.0.3: {} - super-regex@1.1.0: - dependencies: - function-timeout: 1.0.2 - make-asynchronous: 1.1.0 - time-span: 5.1.0 - supports-color@8.1.1: dependencies: has-flag: 4.0.0 @@ -3085,10 +2978,6 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - time-span@5.1.0: - dependencies: - convert-hrtime: 5.0.0 - tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.7) @@ -3126,8 +3015,6 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-fest@4.41.0: {} - type-is@2.1.0: dependencies: content-type: 2.0.0 @@ -3149,13 +3036,11 @@ snapshots: undici-types@8.9.0: {} - unicorn-magic@0.3.0: {} - unpipe@1.0.0: {} - update-browserslist-db@1.3.2(browserslist@4.28.9): + update-browserslist-db@1.3.3(browserslist@4.29.0): dependencies: - browserslist: 4.28.9 + browserslist: 4.29.0 escalade: 3.2.0 picocolors: 1.1.1 @@ -3170,8 +3055,6 @@ snapshots: vary@1.1.2: {} - web-worker@1.5.0: {} - which@1.3.1: dependencies: isexe: 2.0.0 @@ -3190,7 +3073,7 @@ snapshots: yallist@5.0.0: {} - yaml@2.9.0: {} + yaml@2.9.1: {} yazl@3.3.1: dependencies: @@ -3198,10 +3081,8 @@ snapshots: yocto-queue@0.1.0: {} - yocto-queue@1.2.2: {} - - zod-to-json-schema@3.25.2(zod@4.6.2): + zod-to-json-schema@3.25.2(zod@4.6.5): dependencies: - zod: 4.6.2 + zod: 4.6.5 - zod@4.6.2: {} + zod@4.6.5: {} diff --git a/release-please-config.json b/release-please-config.json index acbb339..4869ae1 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -24,7 +24,11 @@ "packages": { ".": { "package-name": "@devicecloud.dev/dcd", - "changelog-path": "CHANGELOG.md" + "changelog-path": "CHANGELOG.md", + "extra-files": [ + { "type": "json", "path": "server.json", "jsonpath": "$.version" }, + { "type": "json", "path": "server.json", "jsonpath": "$.packages[*].version" } + ] } } } diff --git a/server.json b/server.json index a2e1b92..7b6df2e 100644 --- a/server.json +++ b/server.json @@ -1,8 +1,8 @@ { - "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.json", + "$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, and download artifacts.", - "version": "5.0.0-beta.0", + "description": "Run Maestro mobile app tests on devicecloud.dev: submit flows, check status, download artifacts.", + "version": "5.5.0", "repository": { "url": "https://github.com/devicecloud-dev/dcd-cli", "source": "github" @@ -11,12 +11,27 @@ { "registryType": "npm", "identifier": "@devicecloud.dev/dcd", - "version": "5.0.0-beta.0", + "version": "5.5.0", "runtimeHint": "npx", - "transport": { "type": "stdio" }, + "runtimeArguments": [ + { + "type": "positional", + "value": "-y" + }, + { + "type": "named", + "name": "--package" + } + ], "packageArguments": [ - { "type": "positional", "value": "dcd-mcp", "valueHint": "dcd-mcp" } + { + "type": "positional", + "value": "dcd-mcp" + } ], + "transport": { + "type": "stdio" + }, "environmentVariables": [ { "name": "DEVICE_CLOUD_API_KEY", diff --git a/src/commands/artifacts.ts b/src/commands/artifacts.ts index aa5227a..ee6f75b 100644 --- a/src/commands/artifacts.ts +++ b/src/commands/artifacts.ts @@ -49,7 +49,7 @@ export const artifactsCommand = defineCommand({ 'html-path': { type: 'string', description: - 'Custom file path for downloaded HTML report (default: ./report.html)', + 'Custom file path for the downloaded HTML report, a ZIP of report.html and its assets (default: ./report.zip)', }, 'junit-path': { type: 'string', diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index deac9d9..f1edef0 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -27,13 +27,7 @@ import { import { telemetry } from '../services/telemetry.service.js'; import { TestSubmissionService } from '../services/test-submission.service.js'; import { VersionService } from '../services/version.service.js'; -import { - EAndroidApiLevels, - EAndroidDevices, - EiOSDevices, - EiOSVersions, - isIosMatrixConfig, -} from '../types/domain/device.types.js'; +import { isIosMatrixConfig } from '../types/domain/device.types.js'; import { resolveAuth } from '../utils/auth.js'; import { assertMatrixSupported, @@ -206,26 +200,16 @@ export const cloudCommand = defineCommand({ const includeTags = coerceArray( collectRepeatedFlag(rawArgs, ['--include-tags']), ); - const iOSDevice = validateEnum( - args['ios-device'] as string | undefined, - Object.values(EiOSDevices), - 'ios-device', - ); - const iOSVersion = validateEnum( - args['ios-version'] as string | undefined, - Object.values(EiOSVersions), - 'ios-version', - ); - const androidApiLevel = validateEnum( - args['android-api-level'] as string | undefined, - Object.values(EAndroidApiLevels), - 'android-api-level', - ); - const androidDevice = validateEnum( - args['android-device'] as string | undefined, - Object.values(EAndroidDevices), - 'android-device', - ); + // Devices and OS versions are checked against the API's live + // compatibility data further down (DeviceValidationService), not the + // CLI's enums, which only feed --help: a device the API adds must not + // need a CLI release, and one it drops must fail with the API's list. + const iOSDevice = (args['ios-device'] as string | undefined) || undefined; + const iOSVersion = (args['ios-version'] as string | undefined) || undefined; + const androidApiLevel = + (args['android-api-level'] as string | undefined) || undefined; + const androidDevice = + (args['android-device'] as string | undefined) || undefined; const androidNoSnapshot = Boolean(args['android-no-snapshot']); // Repeatable device-matrix flags: one validated cell each, no cross-product. const iosMatrixFlags = collectRepeatedFlag(rawArgs, ['--ios-device-matrix']); @@ -266,7 +250,9 @@ export const cloudCommand = defineCommand({ ); const showCrosshairs = Boolean(args['show-crosshairs']); const maestroChromeOnboarding = Boolean(args['maestro-chrome-onboarding']); - const disableAnimations = Boolean(args['disable-animations']); + // Left undefined when not passed, so config.yaml's per-platform value + // applies; --no-disable-animations is an explicit false that beats it. + const disableAnimations = args['disable-animations'] as boolean | undefined; const ghBranch = args.branch as string | undefined; const ghCommitSha = args['commit-sha'] as string | undefined; const ghRepoName = args['repo-name'] as string | undefined; @@ -838,6 +824,8 @@ export const cloudCommand = defineCommand({ androidNoSnapshot, apiUrl, appBinaryId: finalBinaryId, + // With --app-binary-id the binary's platform is unknown here. + appPlatform: appBinaryId ? undefined : platformFromAppFile(finalAppFile), cancelPrevious, cliVersion, commonRoot, diff --git a/src/commands/switch-org.ts b/src/commands/switch-org.ts index 83d155f..969e97f 100644 --- a/src/commands/switch-org.ts +++ b/src/commands/switch-org.ts @@ -7,7 +7,7 @@ */ import { defineCommand } from 'citty'; -import { resolveAuth } from '../utils/auth.js'; +import { apiKeyOverride, resolveAuth } from '../utils/auth.js'; import { CliError, logger } from '../utils/cli.js'; import { readConfig, resolveApiUrl, writeConfig } from '../utils/config-store.js'; import { fetchOrgs, pickOrg, OrgListItem } from '../utils/orgs.js'; @@ -61,6 +61,15 @@ export const switchOrgCommand = defineCommand({ }); logger.log(ui.success(`Switched to ${colors.highlight(chosen.name)}`)); + + // The switch only changes the stored session; an exported key still wins. + if (apiKeyOverride() === 'DEVICE_CLOUD_API_KEY') { + logger.log( + ui.warn( + `${colors.highlight('DEVICE_CLOUD_API_KEY')} is set, so other dcd commands still authenticate with that API key and its org, not ${chosen.name}. Unset it to use the session.`, + ), + ); + } }, }); diff --git a/src/commands/upload.ts b/src/commands/upload.ts index f56cb83..5fd6f34 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -8,6 +8,7 @@ import { uploadBinary, verifyAppZip } from '../methods.js'; import { resolveAuth } from '../utils/auth.js'; import { CliError, logger } from '../utils/cli.js'; import { resolveApiUrl } from '../utils/config-store.js'; +import { isEncryptionEnabled } from '../utils/envelope.js'; import { downloadExpoUrl, extractTarGz, findAppBundle, isUrl } from '../utils/expo.js'; import { colors, formatId } from '../utils/styling.js'; import { ui } from '../utils/ui.js'; @@ -43,7 +44,10 @@ export const uploadCommand = defineCommand({ const apiUrl = resolveApiUrl(args['api-url'] as string | undefined); const appUrl = args['app-url'] as string | undefined; const ignoreShaCheck = Boolean(args['ignore-sha-check']); - const encryptBinary = Boolean(args['encrypt']); + // Same rule as `dcd cloud`: the flag turns encryption on, and without it + // DCD_ENCRYPT decides. Passing `false` here used to mean "explicitly + // off", which silently beat DCD_ENCRYPT=1 for every `dcd upload`. + const encryptBinary = isEncryptionEnabled(args['encrypt'] ? true : undefined); const debug = Boolean(args.debug); const positional = args.appFile as string | undefined; diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index e6139a1..3dab8c9 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -1,8 +1,14 @@ /** * `dcd whoami` — prints the logged-in user + active org from stored config. + * + * It reads only the stored `dcd login` session, while every other command + * prefers an API key from --api-key or DEVICE_CLOUD_API_KEY — so when one is + * present it says so, rather than implying the session is what they use. */ import { defineCommand } from 'citty'; +import { apiFlags } from '../config/flags/api.flags.js'; +import { apiKeyOverride } from '../utils/auth.js'; import { logger } from '../utils/cli.js'; import { readConfig } from '../utils/config-store.js'; import { colors, formatId } from '../utils/styling.js'; @@ -13,12 +19,26 @@ export const whoamiCommand = defineCommand({ name: 'whoami', description: 'Show the logged-in user and active organization', }, - run() { + args: { + 'api-key': { + ...apiFlags['api-key'], + description: + 'An API key as you would pass it to other commands; whoami says that it takes precedence over the stored session', + }, + }, + run({ args }) { const config = readConfig(); + const keySource = apiKeyOverride(args['api-key'] as string | undefined); + const keyName = colors.highlight(keySource ?? ''); + if (!config?.session) { logger.log( ui.info( - `Not logged in. Run ${colors.highlight('dcd login')} or set ${colors.highlight('DEVICE_CLOUD_API_KEY')}.`, + keySource === 'DEVICE_CLOUD_API_KEY' + ? `Not logged in. ${keyName} is set, so dcd commands authenticate with that API key.` + : keySource === '--api-key' + ? `Not logged in. Commands given ${keyName} authenticate with that API key.` + : `Not logged in. Run ${colors.highlight('dcd login')} or set ${colors.highlight('DEVICE_CLOUD_API_KEY')}.`, ), ); return; @@ -35,6 +55,20 @@ export const whoamiCommand = defineCommand({ logger.log(ui.section('devicecloud.dev')); logger.log(ui.branch(ui.fields(fields))); + + if (keySource === 'DEVICE_CLOUD_API_KEY') { + logger.log( + ui.warn( + `${keyName} is set, so other dcd commands authenticate with that API key and its org, not this session. Unset it to use the session.`, + ), + ); + } else if (keySource === '--api-key') { + logger.log( + ui.warn( + `Commands given ${keyName} authenticate with that API key and its org, not this session.`, + ), + ); + } }, }); diff --git a/src/config/flags/device.flags.ts b/src/config/flags/device.flags.ts index 4b9e6d9..eb0c49c 100644 --- a/src/config/flags/device.flags.ts +++ b/src/config/flags/device.flags.ts @@ -76,12 +76,15 @@ export const deviceFlags = { type: 'boolean', default: false, description: - '[Android only] Force cold boot instead of using snapshot boot. This is automatically enabled for API 35+ but can be used to force cold boot on older API levels.', + '[Android only] Force cold boot instead of using snapshot boot. This is automatically enabled for API 34+ but can be used to force cold boot on older API levels.', }, + // No default: an unset flag must be distinguishable from --no-disable-animations + // so the platform's disableAnimations in config.yaml applies only when unset. 'disable-animations': { type: 'boolean', - default: false, description: - 'Disable device animations during test execution. On Android, disables system animation scales. On iOS, enables Reduce Motion. Reduces CPU load and may improve test reliability.', + 'Disable device animations during test execution. On Android, disables system animation scales. On iOS, enables Reduce Motion. Reduces CPU load and may improve test reliability. Overrides platform.ios/android.disableAnimations in config.yaml.', + negativeDescription: + 'Keep device animations on, even where config.yaml sets platform.ios/android.disableAnimations', }, } as const satisfies ArgsDef; diff --git a/src/config/flags/execution.flags.ts b/src/config/flags/execution.flags.ts index 3e4988b..bc969af 100644 --- a/src/config/flags/execution.flags.ts +++ b/src/config/flags/execution.flags.ts @@ -8,7 +8,7 @@ export const executionFlags = { type: 'boolean', default: false, description: - 'Cancel the still-queued tests of the previous run from the same CI context (repo + branch/PR + check name, read from your CI metadata). Tests already running are left to finish; cancelled tests are refunded at 75%. Does nothing outside CI.', + 'Cancel the still-queued tests of the previous run from the same CI context: the same repo and branch or PR (and check name, if one is set), read from the run metadata. Needs repo plus branch/PR metadata (--repo-name with --branch or --pr-number, or --metadata gh_repo=… with gh_branch=… or gh_pr_number=…); without it nothing is superseded. Tests already running are left to finish; cancelled tests are refunded at 75%.', }, config: { type: 'string', diff --git a/src/config/flags/output.flags.ts b/src/config/flags/output.flags.ts index a5fcffc..304f5b4 100644 --- a/src/config/flags/output.flags.ts +++ b/src/config/flags/output.flags.ts @@ -22,7 +22,7 @@ export const outputFlags = { 'html-path': { type: 'string', description: - 'Custom file path for downloaded HTML report (requires --report html, default: ./report.html)', + 'Custom file path for the downloaded HTML report, a ZIP of report.html and its assets (requires --report html or html-detailed, default: ./report.zip)', }, async: { type: 'boolean', diff --git a/src/gateways/api-gateway.ts b/src/gateways/api-gateway.ts index 2c3dfd9..37fd6da 100644 --- a/src/gateways/api-gateway.ts +++ b/src/gateways/api-gateway.ts @@ -785,7 +785,8 @@ export const ApiGateway = { }, html: { endpoint: `/results/${uploadId}/html-report`, - defaultFilename: `report-${uploadId}.html`, + // A ZIP of report.html plus its assets, not a bare HTML file. + defaultFilename: `report-${uploadId}.zip`, notFoundMessage: `Upload ID '${uploadId}' not found or no HTML report available for this upload`, errorPrefix: 'Failed to download HTML report', }, diff --git a/src/gateways/realtime-gateway.ts b/src/gateways/realtime-gateway.ts index 832c913..49035c5 100644 --- a/src/gateways/realtime-gateway.ts +++ b/src/gateways/realtime-gateway.ts @@ -30,6 +30,13 @@ export interface RealtimeResultsSubscription { isConnected(): boolean; /** Tear down the channel and close the socket. Best-effort, never throws. */ unsubscribe(): Promise; + /** + * Hand the socket a refreshed JWT. RLS authorises the channel with the token + * it joined with, and the server drops the channel once that expires — so a + * poll that outlives the token passes the new one on. Best-effort, never + * throws. + */ + updateAccessToken(accessToken: string): void; } export interface RealtimeSubscribeOptions { @@ -134,6 +141,15 @@ export class RealtimeResultsGateway { /* best effort — process is exiting anyway */ } }, + updateAccessToken(nextToken: string) { + // setAuth updates the join payload and pushes the token to joined + // channels. A failure only costs realtime; the backstop poll stays. + activeClient.realtime.setAuth(nextToken).catch((error: unknown) => { + dbg( + `failed to update the socket's token: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + }, }; } catch (error) { dbg(`failed to subscribe: ${error instanceof Error ? error.message : String(error)}`); @@ -143,7 +159,11 @@ export class RealtimeResultsGateway { } catch { /* ignore */ } - return { isConnected: () => false, async unsubscribe() {} }; + return { + isConnected: () => false, + async unsubscribe() {}, + updateAccessToken() {}, + }; } } } diff --git a/src/mcp/context.ts b/src/mcp/context.ts index 735a2cf..175cbc9 100644 --- a/src/mcp/context.ts +++ b/src/mcp/context.ts @@ -1,6 +1,7 @@ /** - * Per-process MCP context: a single resolved AuthContext + API URL, plus the - * stderr logger every tool must use. + * Per-process MCP context: the resolved AuthContext + API URL (re-resolved + * when a `dcd login` session nears expiry), plus the stderr logger every tool + * must use. * * stdio transport reserves **stdout** for the JSON-RPC frame stream — anything * a tool prints there corrupts the protocol. Tools therefore call the services @@ -8,7 +9,7 @@ * can `process.exit`). */ import type { AuthContext } from '../types/domain/auth.types.js'; -import { resolveAuth } from '../utils/auth.js'; +import { isAuthExpiring, resolveAuth } from '../utils/auth.js'; import { resolveApiUrl } from '../utils/config-store.js'; /** Write a line to stderr. Safe under stdio transport; stdout is reserved. */ @@ -24,24 +25,37 @@ export interface McpContext { let cached: McpContext | null = null; /** - * Resolve auth + API URL once per process, lazily on first tool invocation. + * Resolve auth + API URL lazily on first tool invocation, and again whenever a + * `dcd login` session is near expiry. * * Lazy so `initialize` / `tools/list` succeed before the user has supplied a * credential (clients enumerate tools on connect), and so an auth failure * surfaces as a tool error rather than crashing the server at boot. * + * Re-resolved near expiry because a session's access token lasts about an + * hour while an MCP server can run for days: resolving once left every tool + * call failing after the first hour. resolveAuth refreshes the stored session + * (or picks up one another `dcd` process already refreshed). A failed + * refresh leaves the cache alone, so the next call retries — including after + * the user runs `dcd login` again. API keys never expire and are resolved once. + * * Precedence matches the CLI: `DEVICE_CLOUD_API_KEY` env > stored `dcd login` * session. The API URL honors `DCD_API_URL` (handy for pointing the server at * dev/staging), then the logged-in env, then the prod default. */ export async function getContext(): Promise { - if (cached) return cached; + if (cached && !isAuthExpiring(cached.auth)) return cached; const auth = await resolveAuth({ apiKeyFlag: undefined }); const apiUrl = resolveApiUrl(process.env.DCD_API_URL); cached = { apiUrl, auth }; return cached; } +/** Forget the resolved context. For tests. */ +export function clearContextCache(): void { + cached = null; +} + /** * Read-only mode hides the only state-changing / billable tool * (`dcd_run_cloud_test`). Recommended for autonomous or untrusted agents. diff --git a/src/mcp/tools/download-artifacts.ts b/src/mcp/tools/download-artifacts.ts index 9e28175..5af9b97 100644 --- a/src/mcp/tools/download-artifacts.ts +++ b/src/mcp/tools/download-artifacts.ts @@ -41,7 +41,9 @@ export function registerDownloadArtifacts(server: McpServer): void { reportPath: z .string() .optional() - .describe('Local path for the report file (defaults depend on report type)'), + .describe( + 'Local path for the report file (default ./report.xml for junit, ./report.html for allure, ./report.zip for html, which is a ZIP of report.html and its assets)', + ), }, annotations: { readOnlyHint: true, openWorldHint: true }, }, @@ -68,13 +70,14 @@ export function registerDownloadArtifacts(server: McpServer): void { let reportPath: string | undefined; if (args.report) { - reportPath = args.reportPath - ? path.resolve(args.reportPath) - : path.resolve( - args.report === 'junit' - ? 'report.xml' - : 'report.html', - ); + // The HTML report is a ZIP of report.html plus assets; Allure's is + // a single HTML file. + const defaultReportPath = { + allure: 'report.html', + html: 'report.zip', + junit: 'report.xml', + }[args.report]; + reportPath = path.resolve(args.reportPath || defaultReportPath); await service.downloadReports({ apiUrl, auth, diff --git a/src/mcp/tools/list-devices.ts b/src/mcp/tools/list-devices.ts index dc746d1..1b8047c 100644 --- a/src/mcp/tools/list-devices.ts +++ b/src/mcp/tools/list-devices.ts @@ -17,7 +17,7 @@ export function registerListDevices(server: McpServer): void { description: 'List the iOS and Android devices, OS versions, and Maestro versions available on devicecloud.dev. ' + 'Use this to discover valid device/version values before submitting a test run. ' + - 'Returns: { ios, android, androidPlay, maestro }, where each platform maps OS version → supported device list.', + 'Returns: { ios, android, androidPlay, maestro }, where each platform maps a device slug to the OS versions it runs.', inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: true }, }, diff --git a/src/mcp/tools/run-cloud-test.ts b/src/mcp/tools/run-cloud-test.ts index a94d07e..b54213c 100644 --- a/src/mcp/tools/run-cloud-test.ts +++ b/src/mcp/tools/run-cloud-test.ts @@ -7,9 +7,11 @@ import { ApiError, ApiGateway } from '../../gateways/api-gateway.js'; import { plan } from '../../services/execution-plan.service.js'; import { computeCommonRoot, buildTestMetadataMap } from '../../services/flow-paths.js'; import { DeviceValidationService } from '../../services/device-validation.service.js'; +import { platformFromAppFile } from '../../services/notices.service.js'; import { TestSubmissionService } from '../../services/test-submission.service.js'; import { VersionService } from '../../services/version.service.js'; import { uploadBinary, uploadFlowZip, verifyAppZip } from '../../methods.js'; +import { refreshAuth } from '../../utils/auth.js'; import { getCliVersion } from '../../utils/cli.js'; import { fetchCompatibilityData } from '../../utils/compatibility.js'; import { isEncryptionEnabled } from '../../utils/envelope.js'; @@ -114,18 +116,25 @@ export function registerRunCloudTest(server: McpServer): void { const compatibilityData = await fetchCompatibilityData(apiUrl, auth); const deviceValidation = new DeviceValidationService(); + // Hints in validation errors name this tool's parameters, not CLI flags. + const argNames = { + androidApiLevel: 'androidApiLevel', + androidDevice: 'androidDevice', + iOSDevice: 'iosDevice', + iOSVersion: 'iosVersion', + }; deviceValidation.validateiOSDevice( args.iosVersion, args.iosDevice, compatibilityData, - { logger: logStderr }, + { argNames, logger: logStderr }, ); deviceValidation.validateAndroidDevice( args.androidApiLevel, args.androidDevice, Boolean(args.googlePlay), compatibilityData, - { logger: logStderr }, + { argNames, logger: logStderr }, ); const resolvedMaestroVersion = new VersionService().resolveMaestroVersion( @@ -210,6 +219,8 @@ export function registerRunCloudTest(server: McpServer): void { const { buffer, fields } = await testSubmissionService.buildTestPayload({ apiUrl, appBinaryId, + // Picks the workspace config's per-platform disableAnimations. + appPlatform: args.appBinaryId ? undefined : platformFromAppFile(args.appFile), cliVersion, commonRoot, continueOnFailure, @@ -279,8 +290,13 @@ export function registerRunCloudTest(server: McpServer): void { // the caller can resume with dcd_get_status using the returned uploadId. const deadline = Date.now() + (args.waitTimeoutSeconds ?? 600) * 1000; + let pollAuth = auth; for (;;) { - const status = await ApiGateway.getUploadStatus(apiUrl, auth, { uploadId }); + // A wait of up to an hour can outlast a `dcd login` access token. + pollAuth = await refreshAuth(pollAuth); + const status = await ApiGateway.getUploadStatus(apiUrl, pollAuth, { + uploadId, + }); if (TERMINAL_STATUSES.has(status.status)) { return jsonResult({ uploadId, consoleUrl, status: status.status, tests: status.tests }); } diff --git a/src/services/device-validation.service.ts b/src/services/device-validation.service.ts index 0716d80..b3f4a98 100644 --- a/src/services/device-validation.service.ts +++ b/src/services/device-validation.service.ts @@ -1,13 +1,61 @@ -import { EAndroidDevices, EiOSDevices } from '../types/domain/device.types.js'; -import { CompatibilityData } from '../utils/compatibility.js'; +import type { CompatibilityData } from '../utils/compatibility.js'; + +const DEVICES_DOCS_URL = + 'https://docs.devicecloud.dev/getting-started/devices-configuration'; + +/** The device / OS inputs a caller exposes, named for "pass it explicitly" hints. */ +export interface DeviceArgNames { + androidApiLevel: string; + androidDevice: string; + iOSDevice: string; + iOSVersion: string; +} + +/** `dcd cloud`'s spelling; the MCP tool passes its own parameter names. */ +const CLI_ARG_NAMES: DeviceArgNames = { + androidApiLevel: '--android-api-level', + androidDevice: '--android-device', + iOSDevice: '--ios-device', + iOSVersion: '--ios-version', +}; export interface DeviceValidationOptions { + /** How the caller names its inputs in error hints. Defaults to the CLI flags. */ + argNames?: DeviceArgNames; debug?: boolean; logger?: (message: string) => void; } +/** Device slug → the OS versions the API will run it on. */ +type CompatibilityLookup = Record | undefined; + +/** The versions a lookup offers for one device; empty when it offers none. */ +function versionsFor(lookup: CompatibilityLookup, device: string): string[] { + // hasOwn, so a slug like "constructor" can't resolve to a prototype member. + if (!lookup || !Object.hasOwn(lookup, device)) return []; + const versions = lookup[device]; + return Array.isArray(versions) ? versions : []; +} + +/** + * Devices the lookup can actually run, in the API's order. A rollout gate can + * leave a device with no versions (every non-Play device has an empty Google + * Play list), and those must not be offered as alternatives. + */ +function offeredDevices(lookup: CompatibilityLookup): string[] { + return Object.keys(lookup ?? {}).filter( + (device) => versionsFor(lookup, device).length > 0, + ); +} + /** - * Service for validating device configurations against compatibility data + * Validates requested devices against the compatibility matrix the API serves + * from `GET /results/compatibility/data`, which is the only authority on what + * can run. Nothing here consults the CLI's device and OS enums + * (src/types/domain/device.types.ts) — those only feed `--help` — so a device + * or OS version the API adds is accepted without a CLI release, and one it + * withdraws is refused, before anything is uploaded, with the list the API + * offers today. */ export class DeviceValidationService { /** @@ -27,6 +75,11 @@ export class DeviceValidationService { compatibilityData: CompatibilityData, options: DeviceValidationOptions = {}, ): void { + const lookup = googlePlay + ? compatibilityData.androidPlay + : compatibilityData.android; + const argNames = options.argNames ?? CLI_ARG_NAMES; + this.validateDevice({ debugLines: (deviceID, version, supportedVersions) => [ `[DEBUG] Android device: ${deviceID}`, @@ -34,20 +87,31 @@ export class DeviceValidationService { `[DEBUG] Google Play enabled: ${googlePlay}`, `[DEBUG] Supported Android versions: ${supportedVersions.join(', ')}`, ], + // Mirrors the API, which applies these same global defaults to a + // request that omits either half (test-request-validator.service.ts) + // and then rejects an incompatible pair. defaultDevice: 'pixel-7', defaultVersion: '34', - device: androidDevice as EAndroidDevices | undefined, - lookup: googlePlay - ? compatibilityData.androidPlay - : compatibilityData.android, - noSupportMessage: () => - `We don't support that device configuration - please check the docs for supported devices: https://docs.devicecloud.dev/getting-started/devices-configuration`, + device: androidDevice, + deviceArg: argNames.androidDevice, + deviceKind: googlePlay ? 'Google Play' : 'Android', + formatVersion: (version) => `API level ${version}`, + lookup, options, + platform: 'Android', + // A real device that merely has no Google Play image deserves a sharper + // answer than "not supported". + unknownDeviceMessage: (deviceID) => + googlePlay && versionsFor(compatibilityData.android, deviceID).length > 0 + ? `Android device "${deviceID}" is not available with Google Play. Google Play devices: ${offeredDevices(lookup).join(', ')}. See ${DEVICES_DOCS_URL}` + : undefined, unsupportedVersionMessage: (deviceID, supportedVersions) => `${deviceID} ${ googlePlay ? '(Play Store) ' : '' }only supports these Android API levels: ${supportedVersions.join(', ')}`, version: androidApiLevel, + versionArg: argNames.androidApiLevel, + versionName: 'Android API level', }); } @@ -66,22 +130,29 @@ export class DeviceValidationService { compatibilityData: CompatibilityData, options: DeviceValidationOptions = {}, ): void { + const argNames = options.argNames ?? CLI_ARG_NAMES; + this.validateDevice({ debugLines: (deviceID, version, supportedVersions) => [ `[DEBUG] iOS device: ${deviceID}`, `[DEBUG] iOS version: ${version}`, `[DEBUG] Supported iOS versions: ${supportedVersions.join(', ')}`, ], + // Mirrors the API's global defaults, as for Android above. defaultDevice: 'iphone-14', defaultVersion: '17', - device: iOSDevice as EiOSDevices | undefined, + device: iOSDevice, + deviceArg: argNames.iOSDevice, + deviceKind: 'iOS', + formatVersion: (version) => `iOS ${version}`, lookup: compatibilityData?.ios, - noSupportMessage: (deviceID) => - `Device ${deviceID} is not supported. Please check the docs for supported devices: https://docs.devicecloud.dev/getting-started/devices-configuration`, options, + platform: 'iOS', unsupportedVersionMessage: (deviceID, supportedVersions) => `${deviceID} only supports these iOS versions: ${supportedVersions.join(', ')}`, version: iOSVersion, + versionArg: argNames.iOSVersion, + versionName: 'iOS version', }); } @@ -101,25 +172,43 @@ export class DeviceValidationService { defaultDevice: string; defaultVersion: string; device: string | undefined; - lookup: Record | undefined; - noSupportMessage: (deviceID: string) => string; + /** How the caller names the device input, e.g. "--ios-device". */ + deviceArg: string; + /** What the offered-device list is called: "iOS", "Android", "Google Play". */ + deviceKind: string; + /** One version in prose, e.g. "iOS 27" or "API level 37". */ + formatVersion: (version: string) => string; + lookup: CompatibilityLookup; options: DeviceValidationOptions; + platform: 'Android' | 'iOS'; + /** Replaces the generic unknown-device message when it returns a string. */ + unknownDeviceMessage?: (deviceID: string) => string | undefined; unsupportedVersionMessage: ( deviceID: string, supportedVersions: string[], ) => string; version: string | undefined; + /** How the caller names the version input, e.g. "--ios-version". */ + versionArg: string; + /** e.g. "iOS version" / "Android API level". */ + versionName: string; }): void { const { debugLines, defaultDevice, defaultVersion, device, + deviceArg, + deviceKind, + formatVersion, lookup, - noSupportMessage, options, + platform, + unknownDeviceMessage, unsupportedVersionMessage, version, + versionArg, + versionName, } = config; const { debug = false, logger } = options; @@ -128,18 +217,60 @@ export class DeviceValidationService { } const deviceID = device || defaultDevice; - const supportedVersions: string[] = lookup?.[deviceID] || []; + const supportedVersions = versionsFor(lookup, deviceID); const requestedVersion = version || defaultVersion; if (supportedVersions.length === 0) { - throw new Error(noSupportMessage(deviceID)); + if (!device) { + // Only a version was requested, and the API no longer offers the + // device the CLI assumes it defaults to. That assumption is what's + // stale, not the request: the API resolves and validates its own + // default device when the run is submitted. + if (debug && logger) { + logger( + `[DEBUG] Default ${platform} device ${deviceID} is not in the compatibility data; leaving the ${versionName} check to the API`, + ); + } + + return; + } + + const offered = offeredDevices(lookup); + throw new Error( + unknownDeviceMessage?.(deviceID) ?? + (offered.length > 0 + ? `${platform} device "${deviceID}" is not supported. Supported ${deviceKind} devices: ${offered.join(', ')}. See ${DEVICES_DOCS_URL}` + : `${platform} device "${deviceID}" is not supported. See ${DEVICES_DOCS_URL}`), + ); } - if ( - Array.isArray(supportedVersions) && - !supportedVersions.includes(requestedVersion) - ) { - throw new Error(unsupportedVersionMessage(deviceID, supportedVersions)); + if (!supportedVersions.includes(requestedVersion)) { + const unsupported = unsupportedVersionMessage(deviceID, supportedVersions); + + if (!version) { + // Device-only request. The API fills in the same global default + // version rather than one this device runs, so the submission would + // be refused there too — say so, and how to avoid it. + throw new Error( + `${unsupported}. No ${versionName} was given, so the default (${defaultVersion}) was checked: pass one of those explicitly with ${versionArg}`, + ); + } + + if (!device) { + // Version-only request: name the device that was assumed, and the + // devices that do offer the requested version. + const devicesWithVersion = offeredDevices(lookup).filter((d) => + versionsFor(lookup, d).includes(requestedVersion), + ); + throw new Error( + `${unsupported}. No ${platform} device was given, so the default (${deviceID}) was checked: ` + + (devicesWithVersion.length > 0 + ? `pass one that offers ${formatVersion(requestedVersion)} with ${deviceArg}: ${devicesWithVersion.join(', ')}` + : `no ${deviceKind} device offers ${formatVersion(requestedVersion)}`), + ); + } + + throw new Error(unsupported); } if (debug && logger) { diff --git a/src/services/notices.service.ts b/src/services/notices.service.ts index ff5022f..4319c50 100644 --- a/src/services/notices.service.ts +++ b/src/services/notices.service.ts @@ -140,6 +140,8 @@ export function platformFromAppFile( appFile: string | undefined, ): 'android' | 'ios' | undefined { if (!appFile) return undefined; + // Expo iOS simulator builds (`dcd cloud` extracts these to a .app first). + if (appFile.split('?')[0].toLowerCase().endsWith('.tar.gz')) return 'ios'; const ext = appFile.split('?')[0].toLowerCase().match(/.([a-z0-9]+)$/)?.[1]; if (ext === 'apk' || ext === 'aab') return 'android'; if (ext === 'zip' || ext === 'app' || ext === 'ipa') return 'ios'; diff --git a/src/services/report-download.service.ts b/src/services/report-download.service.ts index 80437e5..d1bfc96 100644 --- a/src/services/report-download.service.ts +++ b/src/services/report-download.service.ts @@ -2,6 +2,7 @@ import * as path from 'node:path'; import { ApiGateway } from '../gateways/api-gateway.js'; import type { AuthContext } from '../types/domain/auth.types.js'; +import { refreshAuth } from '../utils/auth.js'; export interface DownloadOptions { auth: AuthContext; @@ -52,9 +53,11 @@ export class ReportDownloadService { logger(`[DEBUG] Downloading artifacts: ${downloadType}`); } + // `dcd cloud` downloads after polling, which can outlast a `dcd login` + // token; a failed refresh lands in the warning below like any failure. await ApiGateway.downloadArtifactsZip( apiUrl, - auth, + await refreshAuth(auth), uploadId, downloadType, artifactsPath, @@ -118,7 +121,9 @@ export class ReportDownloadService { case 'html': case 'html-detailed': { - const htmlReportPath = path.resolve(process.cwd(), htmlPath || 'report.html'); + // The HTML report is a ZIP (report.html plus its screenshots and + // assets), so the default name says so. An explicit path is kept as-is. + const htmlReportPath = path.resolve(process.cwd(), htmlPath || 'report.zip'); await this.downloadReport('html', htmlReportPath, { ...downloadOptions, warnLogger, @@ -153,9 +158,10 @@ export class ReportDownloadService { logger(`[DEBUG] Downloading ${type.toUpperCase()} report`); } + // As for artifacts: this can run after a poll that outlasted the token. await ApiGateway.downloadReportGeneric( apiUrl, - auth, + await refreshAuth(auth), uploadId, type, filePath, diff --git a/src/services/results-polling.service.ts b/src/services/results-polling.service.ts index 5b26839..494b1a8 100644 --- a/src/services/results-polling.service.ts +++ b/src/services/results-polling.service.ts @@ -8,6 +8,7 @@ import { import { formatDurationSeconds } from '../methods.js'; import type { AuthContext } from '../types/domain/auth.types.js'; import { paths } from '../types/generated/schema.types.js'; +import { isDefinitiveAuthFailure, refreshAuth } from '../utils/auth.js'; import { checkInternetConnectivity } from '../utils/connectivity.js'; import { isCI } from '../utils/ci.js'; import { ux } from '../utils/progress.js'; @@ -28,6 +29,18 @@ export class RunFailedError extends Error { } } +/** + * The `dcd login` session behind a poll expired and only a new login can fix + * it, so the poll stops at once instead of spending its retry budget — meant + * for network blips — on a failure that can't clear. + */ +export class PollingAuthError extends Error { + constructor(message: string) { + super(message); + this.name = 'PollingAuthError'; + } +} + export interface PollingOptions { auth: AuthContext; apiUrl: string; @@ -193,7 +206,10 @@ export class ResultsPollingService { options: PollingOptions, testMetadata?: Record, ): Promise { - const { apiUrl, auth, uploadId, consoleUrl, quiet = false, json = false, debug = false, logger } = options; + const { apiUrl, uploadId, consoleUrl, quiet = false, json = false, debug = false, logger } = options; + // Re-checked before every poll: a run can outlast a `dcd login` access + // token (about an hour). API-key auth passes through unchanged. + let { auth } = options; this.initializePollingDisplay(json, logger); @@ -307,6 +323,7 @@ export class ResultsPollingService { nextPollAt = null; renderStatus(); + auth = await this.refreshPollingAuth(auth, uploadId, subscription, debug, logger); const updatedResults = await this.fetchAndLogResults(apiUrl, auth, uploadId, debug, logger); const { summary } = this.calculateStatusSummary(updatedResults); @@ -338,8 +355,9 @@ export class ResultsPollingService { renderStatus(); await waitForNextPoll(pollIntervalMs); } catch (error) { - // Re-throw RunFailedError immediately (test failures, not polling errors) - if (error instanceof RunFailedError) { + // Re-throw RunFailedError immediately (test failures, not polling + // errors), and an expired login no retry can fix. + if (error instanceof RunFailedError || error instanceof PollingAuthError) { throw error; } @@ -564,6 +582,53 @@ export class ResultsPollingService { return updatedResults; } + /** + * Swap in a fresh `dcd login` token when the current one is about to + * expire, and hand it to the realtime socket too; API-key auth comes back + * as-is. A transient refresh failure is thrown as-is, to be retried like + * any failed poll; one only `dcd login` can fix becomes a PollingAuthError. + * @param auth The credential the previous poll used + * @param uploadId Upload being polled, for the reconnect hint + * @param subscription Realtime subscription to pass a new token to + * @param debug Whether debug logging is enabled + * @param logger Optional logger function + * @returns The credential for the next poll + */ + private async refreshPollingAuth( + auth: AuthContext, + uploadId: string, + subscription: RealtimeResultsSubscription | undefined, + debug: boolean, + logger?: (message: string) => void, + ): Promise { + let next: AuthContext; + try { + next = await refreshAuth(auth); + } catch (error) { + if (isDefinitiveAuthFailure(error)) { + const reason = error instanceof Error ? error.message : String(error); + throw new PollingAuthError( + `Your dcd login session expired and could not be refreshed: ${reason}\n\n` + + `The test is still running in the cloud. Run \`dcd login\`, then reconnect with:\n dcd status --upload-id ${uploadId}`, + ); + } + + throw error; + } + + if (next !== auth) { + if (debug && logger) { + logger('[DEBUG] Refreshed the dcd login session for polling'); + } + + if (next.accessToken && next.accessToken !== auth.accessToken) { + subscription?.updateAccessToken(next.accessToken); + } + } + + return next; + } + private filterLatestResults(results: TestResult[]): TestResult[] { // Resolve each row to the root of its retry chain (a retry's `retry_of` // may point at the previous retry rather than the original attempt), then diff --git a/src/services/test-submission.service.ts b/src/services/test-submission.service.ts index 5e79036..ede96e9 100644 --- a/src/services/test-submission.service.ts +++ b/src/services/test-submission.service.ts @@ -18,6 +18,12 @@ export interface TestSubmissionConfig { androidNoSnapshot?: boolean; apiUrl?: string; appBinaryId: string; + /** + * Platform of the app binary, when the CLI can tell from a local file + * (.apk → android; .app / .zip / Expo .tar.gz → ios). Unknown for + * --app-binary-id, which falls back to the device flags. + */ + appPlatform?: 'android' | 'ios'; /** * Ask the API to cancel the still-queued tests of the previous run from * the same CI context. Sent as its own field rather than inside `config`, @@ -31,6 +37,11 @@ export interface TestSubmissionConfig { debug?: boolean; deviceLocale?: string; deviceMatrix?: DeviceMatrixConfig[]; + /** + * --disable-animations as given on the command line: true / false when the + * flag (or --no-disable-animations) was passed, undefined when it wasn't — + * only then does the workspace config's per-platform value apply. + */ disableAnimations?: boolean; /** * Encrypt the flow zip and env vars before upload (#1151/#1152), each with its @@ -86,6 +97,7 @@ export class TestSubmissionService { const { apiUrl, appBinaryId, + appPlatform, encrypt = false, flowFile, executionPlan, @@ -278,8 +290,10 @@ export class TestSubmissionService { } // Platform used only to pick which workspace-config disableAnimations flag - // applies. A device matrix is single-platform; its first cell decides. Fall - // back to the scalar iOS flags for single-device submissions. + // applies. The binary decides when the CLI knows it — otherwise an iOS run + // without --ios-device/--ios-version read the android block. For an + // --app-binary-id, a device matrix (single-platform; its first cell + // decides) or the scalar iOS flags are the best evidence left. const matrixPlatform = deviceMatrix && deviceMatrix.length > 0 ? 'iOSDevice' in deviceMatrix[0] @@ -287,12 +301,18 @@ export class TestSubmissionService { : 'android' : undefined; const targetPlatform = - matrixPlatform ?? (iOSDevice || iOSVersion ? 'ios' : 'android'); + appPlatform ?? + matrixPlatform ?? + (iOSDevice || iOSVersion ? 'ios' : 'android'); const configYamlDisableAnimations = targetPlatform === 'ios' ? Boolean(workspaceConfig?.platform?.ios?.disableAnimations) : Boolean(workspaceConfig?.platform?.android?.disableAnimations); - const effectiveDisableAnimations = disableAnimations || configYamlDisableAnimations; + // An explicit --disable-animations / --no-disable-animations wins over + // config.yaml; previously `flag || config` meant the flag could only ever + // turn animations off, never keep them on against the config. + const effectiveDisableAnimations = + disableAnimations ?? configYamlDisableAnimations; const configPayload: Record< string, diff --git a/src/types/domain/auth.types.ts b/src/types/domain/auth.types.ts index 922d72c..716029e 100644 --- a/src/types/domain/auth.types.ts +++ b/src/types/domain/auth.types.ts @@ -14,6 +14,12 @@ export interface AuthContext { accessToken?: string; /** Environment the session belongs to — present when mode === 'bearer'. */ env?: DcdEnvName; + /** + * Unix epoch seconds at which `accessToken` expires — present when + * mode === 'bearer'. Long-lived callers check it (utils/auth.ts + * isAuthExpiring / refreshAuth) because the headers below stop working then. + */ + expiresAt?: number; /** Present when mode === 'bearer'. */ orgId?: string; /** Present when mode === 'bearer'. */ diff --git a/src/types/domain/device.types.ts b/src/types/domain/device.types.ts index ba2f0fa..8715385 100644 --- a/src/types/domain/device.types.ts +++ b/src/types/domain/device.types.ts @@ -1,18 +1,32 @@ /** * Device type definitions - should be kept in sync with API - * @see /Users/riglar/repos/dcd/api/src/common/types/device.types.ts + * @see dcd/api/src/common/types/device.types.ts + * + * These drive the `--ios-device` / `--ios-version` / `--android-device` / + * `--android-api-level` help text and nothing else: validation runs against + * the live compatibility payload (device-validation.service.ts). A value the + * API has dropped gets a "not supported" error listing what it offers now, and + * one it has added is accepted even if it is missing here — so keeping these + * lists current only improves the help text; it never gates a run. + * + * `iphone-14-pro` and `iphone-15-pro` were listed here but have never existed + * in the API enum; removed 2026-09-21 rather than carried forward. */ export enum EiOSDevices { 'ipad-pro-6th-gen' = 'ipad-pro-6th-gen', + 'ipad-pro-m5-11' = 'ipad-pro-m5-11', + 'ipad-pro-m5-13' = 'ipad-pro-m5-13', 'iphone-14' = 'iphone-14', - 'iphone-14-pro' = 'iphone-14-pro', 'iphone-15' = 'iphone-15', - 'iphone-15-pro' = 'iphone-15-pro', 'iphone-16' = 'iphone-16', 'iphone-16-plus' = 'iphone-16-plus', 'iphone-16-pro' = 'iphone-16-pro', 'iphone-16-pro-max' = 'iphone-16-pro-max', + 'iphone-17' = 'iphone-17', + 'iphone-18-pro' = 'iphone-18-pro', + 'iphone-18-pro-max' = 'iphone-18-pro-max', + 'iphone-air' = 'iphone-air', } export enum EAndroidDevices { diff --git a/src/types/generated/schema.types.ts b/src/types/generated/schema.types.ts index 29897ea..c59d2a6 100644 --- a/src/types/generated/schema.types.ts +++ b/src/types/generated/schema.types.ts @@ -1652,9 +1652,11 @@ export interface components { /** @enum {string} */ iOSVersion?: "17" | "18" | "26" | "27"; /** @enum {string} */ - iOSDevice?: "iphone-14" | "iphone-15" | "iphone-16" | "iphone-16-plus" | "iphone-16-pro" | "iphone-16-pro-max" | "ipad-pro-6th-gen"; + iOSDevice?: "iphone-14" | "iphone-15" | "iphone-16" | "iphone-16-plus" | "iphone-16-pro" | "iphone-16-pro-max" | "iphone-17" | "iphone-air" | "iphone-18-pro" | "iphone-18-pro-max" | "ipad-pro-6th-gen" | "ipad-pro-m5-11" | "ipad-pro-m5-13"; platform?: string; googlePlay?: boolean; + /** @description Cancel the still-queued tests of the previous run from the same CI context. The context is derived from the run metadata (gh_repo/bb_repo, gh_branch/gh_pr_number or their bb_ twins, and gh_check_name); without it nothing is cancelled. Tests already running are left to finish. */ + cancelPrevious?: boolean; config: string; name?: string; /** @enum {string} */ @@ -1692,9 +1694,11 @@ export interface components { /** @enum {string} */ iOSVersion?: "17" | "18" | "26" | "27"; /** @enum {string} */ - iOSDevice?: "iphone-14" | "iphone-15" | "iphone-16" | "iphone-16-plus" | "iphone-16-pro" | "iphone-16-pro-max" | "ipad-pro-6th-gen"; + iOSDevice?: "iphone-14" | "iphone-15" | "iphone-16" | "iphone-16-plus" | "iphone-16-pro" | "iphone-16-pro-max" | "iphone-17" | "iphone-air" | "iphone-18-pro" | "iphone-18-pro-max" | "ipad-pro-6th-gen" | "ipad-pro-m5-11" | "ipad-pro-m5-13"; platform?: string; googlePlay?: boolean; + /** @description Cancel the still-queued tests of the previous run from the same CI context. The context is derived from the run metadata (gh_repo/bb_repo, gh_branch/gh_pr_number or their bb_ twins, and gh_check_name); without it nothing is cancelled. Tests already running are left to finish. */ + cancelPrevious?: boolean; config: string; name?: string; /** @enum {string} */ @@ -1743,9 +1747,11 @@ export interface components { /** @enum {string} */ iOSVersion?: "17" | "18" | "26" | "27"; /** @enum {string} */ - iOSDevice?: "iphone-14" | "iphone-15" | "iphone-16" | "iphone-16-plus" | "iphone-16-pro" | "iphone-16-pro-max" | "ipad-pro-6th-gen"; + iOSDevice?: "iphone-14" | "iphone-15" | "iphone-16" | "iphone-16-plus" | "iphone-16-pro" | "iphone-16-pro-max" | "iphone-17" | "iphone-air" | "iphone-18-pro" | "iphone-18-pro-max" | "ipad-pro-6th-gen" | "ipad-pro-m5-11" | "ipad-pro-m5-13"; platform?: string; googlePlay?: boolean; + /** @description Cancel the still-queued tests of the previous run from the same CI context. The context is derived from the run metadata (gh_repo/bb_repo, gh_branch/gh_pr_number or their bb_ twins, and gh_check_name); without it nothing is cancelled. Tests already running are left to finish. */ + cancelPrevious?: boolean; config: string; name?: string; /** @enum {string} */ @@ -1881,6 +1887,7 @@ export interface components { retry_of?: number; fail_reason?: string; duration_seconds?: number; + cancellation_reason?: string; simulator_name?: string; config?: Record; }; @@ -2191,6 +2198,8 @@ export interface operations { message?: string; success?: boolean; cancelledCount?: number; + /** @description Credits returned for tests that had not started yet. 0 when nothing was refundable. See the billing docs for the rate. */ + refundedAmount?: number; }; }; }; @@ -2952,174 +2961,125 @@ export interface operations { * "statusCode": 200, * "data": { * "ios": { - * "iphone-14": { - * "name": "iPhone 14", - * "versions": [ - * "17", - * "18" - * ], - * "deprecated": false - * }, - * "iphone-15": { - * "name": "iPhone 15", - * "versions": [ - * "17" - * ], - * "deprecated": false - * }, - * "iphone-16": { - * "name": "iPhone 16", - * "versions": [ - * "18", - * "26" - * ], - * "deprecated": false - * }, - * "iphone-16-plus": { - * "name": "iPhone 16 Plus", - * "versions": [ - * "26" - * ], - * "deprecated": false - * }, - * "iphone-16-pro": { - * "name": "iPhone 16 Pro", - * "versions": [ - * "18", - * "26" - * ], - * "deprecated": false - * }, - * "iphone-16-pro-max": { - * "name": "iPhone 16 Pro Max", - * "versions": [ - * "18", - * "26" - * ], - * "deprecated": false - * }, - * "ipad-pro-6th-gen": { - * "name": "iPad Pro (6th gen)", - * "versions": [ - * "18", - * "26" - * ], - * "deprecated": false - * } + * "iphone-14": [ + * "17", + * "18" + * ], + * "iphone-15": [ + * "17" + * ], + * "iphone-16": [ + * "18", + * "26", + * "27" + * ], + * "iphone-16-plus": [ + * "26", + * "27" + * ], + * "iphone-16-pro": [ + * "18", + * "26", + * "27" + * ], + * "iphone-16-pro-max": [ + * "18", + * "26", + * "27" + * ], + * "iphone-17": [ + * "26", + * "27" + * ], + * "iphone-air": [ + * "26", + * "27" + * ], + * "iphone-18-pro": [ + * "27" + * ], + * "iphone-18-pro-max": [ + * "27" + * ], + * "ipad-pro-6th-gen": [ + * "18", + * "26", + * "27" + * ], + * "ipad-pro-m5-11": [ + * "26", + * "27" + * ], + * "ipad-pro-m5-13": [ + * "26", + * "27" + * ] * }, * "android": { - * "pixel-6": { - * "name": "Pixel 6", - * "apiLevels": [ - * "29", - * "30", - * "31", - * "32", - * "33", - * "34", - * "35", - * "36", - * "37" - * ], - * "deprecated": false - * }, - * "pixel-6-pro": { - * "name": "Pixel 6 Pro", - * "apiLevels": [ - * "33", - * "35" - * ], - * "deprecated": false - * }, - * "pixel-7": { - * "name": "Pixel 7", - * "apiLevels": [ - * "33", - * "34", - * "35", - * "36", - * "37" - * ], - * "deprecated": false - * }, - * "pixel-7-pro": { - * "name": "Pixel 7 Pro", - * "apiLevels": [ - * "33", - * "34", - * "35", - * "36", - * "37" - * ], - * "deprecated": false - * }, - * "pixel-8": { - * "name": "Pixel 8", - * "apiLevels": [ - * "34", - * "35", - * "36", - * "37" - * ], - * "deprecated": false - * }, - * "pixel-10": { - * "name": "Pixel 10", - * "apiLevels": [ - * "36", - * "37" - * ], - * "deprecated": false - * }, - * "pixel-10-pro": { - * "name": "Pixel 10 Pro", - * "apiLevels": [ - * "36", - * "37" - * ], - * "deprecated": false - * }, - * "pixel-10-pro-xl": { - * "name": "Pixel 10 Pro XL", - * "apiLevels": [ - * "36", - * "37" - * ], - * "deprecated": false - * }, - * "pixel-10-pro-fold": { - * "name": "Pixel 10 Pro Fold", - * "apiLevels": [ - * "36", - * "37" - * ], - * "deprecated": false - * }, - * "pixel-11": { - * "name": "Pixel 11", - * "apiLevels": [ - * "37" - * ], - * "deprecated": false - * }, - * "generic-tablet": { - * "name": "Generic Tablet", - * "apiLevels": [ - * "33", - * "36", - * "37" - * ], - * "deprecated": false - * } + * "pixel-6": [ + * "29", + * "30", + * "31", + * "32", + * "33", + * "34", + * "35", + * "36", + * "37" + * ], + * "pixel-6-pro": [ + * "33", + * "35" + * ], + * "pixel-7": [ + * "33", + * "34", + * "35", + * "36", + * "37" + * ], + * "pixel-7-pro": [ + * "33", + * "34", + * "35", + * "36", + * "37" + * ], + * "pixel-8": [ + * "34", + * "35", + * "36", + * "37" + * ], + * "pixel-10": [ + * "36", + * "37" + * ], + * "pixel-10-pro": [ + * "36", + * "37" + * ], + * "pixel-10-pro-xl": [ + * "36", + * "37" + * ], + * "pixel-10-pro-fold": [ + * "36", + * "37" + * ], + * "pixel-11": [ + * "37" + * ], + * "generic-tablet": [ + * "33", + * "36", + * "37" + * ] * }, * "androidPlay": { - * "pixel-7": { - * "name": "Pixel 7 (Google Play)", - * "apiLevels": [ - * "34" - * ], - * "deprecated": false - * } + * "pixel-7": [ + * "34" + * ] * }, * "maestro": { * "supportedVersions": [ diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 21aecac..2b4c146 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -4,8 +4,9 @@ * Supabase session (refreshed if near expiry). Throws CliError if none found. * * The refresh path writes the rotated tokens back to disk atomically. Callers - * use the returned AuthContext for the duration of the command — one resolve - * per invocation, not per request. + * resolve once per invocation, not per request — except where a process can + * outlive the session's access token (the MCP server, a long `dcd cloud` + * poll), which re-checks with isAuthExpiring / refreshAuth below. */ import { closeSync, openSync, rmSync, statSync } from 'node:fs'; @@ -45,6 +46,19 @@ export interface ResolveAuthOptions { sessionOnly?: boolean; } +/** + * Which API key, if any, outranks the stored session — the precedence + * resolveAuth applies. For the session-only commands (`whoami`, `switch-org`) + * to say that what they show is not what other commands will authenticate as. + */ +export function apiKeyOverride( + apiKeyFlag?: string, +): '--api-key' | 'DEVICE_CLOUD_API_KEY' | undefined { + if (apiKeyFlag?.trim()) return '--api-key'; + if (process.env.DEVICE_CLOUD_API_KEY?.trim()) return 'DEVICE_CLOUD_API_KEY'; + return undefined; +} + export async function resolveAuth( opts: ResolveAuthOptions, ): Promise { @@ -96,6 +110,7 @@ export async function resolveAuth( mode: 'bearer', accessToken: session.access_token, env: config.env, + expiresAt: session.expires_at, orgId: config.current_org_id, userEmail: session.user_email, headers: { @@ -107,6 +122,61 @@ export async function resolveAuth( return auth; } +/** + * True when a `dcd login` (bearer) context's access token has expired or will + * within the refresh window. API-key contexts never expire. + */ +export function isAuthExpiring( + auth: AuthContext, + nowSeconds = Math.floor(Date.now() / 1000), +): boolean { + return ( + auth.mode === 'bearer' && + auth.expiresAt !== undefined && + auth.expiresAt <= nowSeconds + REFRESH_SKEW_SECONDS + ); +} + +/** + * Keep a long-running command's credential usable. An API-key context, or a + * session that isn't near expiry, comes back unchanged. Otherwise the stored + * session is re-read and, if it is expiring too, refreshed under the same lock + * resolveAuth uses — so a session another `dcd` process has already rotated + * is picked up rather than raced for. + * + * Only the token changes: the org the command started with is kept, so an + * org switched in another terminal mid-run can't redirect an in-flight poll + * at an upload that org doesn't own. + * + * @throws SessionRefreshError when Supabase refuses the refresh (`definitive` + * when only `dcd login` can fix it), or CliError when there is no stored + * session any more (e.g. after `dcd logout`). + */ +export async function refreshAuth(auth: AuthContext): Promise { + if (!isAuthExpiring(auth)) return auth; + + const fresh = await resolveAuth({ apiKeyFlag: undefined, sessionOnly: true }); + if (!auth.orgId || fresh.orgId === auth.orgId) return fresh; + + return { + ...fresh, + orgId: auth.orgId, + headers: { ...fresh.headers, 'x-dcd-org': auth.orgId }, + }; +} + +/** + * Whether a refreshAuth failure is final for this process — the refresh token + * was refused, or the session is gone — as opposed to a network blip that the + * next attempt may get past. + */ +export function isDefinitiveAuthFailure(error: unknown): boolean { + return ( + (error instanceof SessionRefreshError && error.definitive) || + error instanceof CliError + ); +} + /** * Refresh the stored session under a config-adjacent lockfile so two * concurrent `dcd` invocations (CI matrices) can't both consume the same diff --git a/test/integration/cloud.integration.test.ts b/test/integration/cloud.integration.test.ts index 39d77d5..08a7806 100644 --- a/test/integration/cloud.integration.test.ts +++ b/test/integration/cloud.integration.test.ts @@ -158,8 +158,28 @@ appId: com.example.app const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --ios-device unsupported-device --ios-version 99`; const { output } = await runExpectingFailure(command); - expect(output).to.include('Invalid value for --ios-device'); - expect(output).to.include('unsupported-device'); + expect(output).to.include( + 'iOS device "unsupported-device" is not supported', + ); + // The alternatives come from the API's compatibility data, not the + // CLI's help-text enum. + expect(output).to.include('Supported iOS devices: '); + expect(output).to.include('iphone-17'); + }); + + it('accepts a device from the API compatibility data (dry run)', async () => { + const command = `${CLI} cloud ${iosAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --ios-device iphone-17 --ios-version 26 --dry-run`; + + const { stdout } = await exec(command, { timeout: 30_000 }); + expect(stdout).to.include('Dry run mode'); + }); + + it('asks for the version when a lone device needs a non-default one', async () => { + const command = `${CLI} cloud ${iosAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --ios-device iphone-17 --dry-run`; + + const { output } = await runExpectingFailure(command); + expect(output).to.include('iphone-17 only supports these iOS versions'); + expect(output).to.include('--ios-version'); }); }); diff --git a/test/integration/mcp.integration.test.ts b/test/integration/mcp.integration.test.ts index 9ef4b96..5553cda 100644 --- a/test/integration/mcp.integration.test.ts +++ b/test/integration/mcp.integration.test.ts @@ -72,6 +72,22 @@ describe('MCP Server Integration Tests', () => { } }); + // server.json has registry clients pass `@` as the + // value of --package, but a client that assembles argv differently could + // still hand it to dcd-mcp, so the server must shrug off a stray positional. + it('ignores the package spec a registry client appends to argv', async () => { + const { client, ready } = connect(['@devicecloud.dev/dcd@5.5.0', '--read-only']); + await ready; + try { + const { tools } = await client.listTools(); + const names = tools.map((t) => t.name); + expect(names).to.include('dcd_list_devices'); + expect(names).to.not.include('dcd_run_cloud_test'); + } finally { + await client.close(); + } + }); + it('marks the run tool as non-read-only / destructive', async () => { const { client, ready } = connect(); await ready; diff --git a/test/unit/device-validation.service.test.ts b/test/unit/device-validation.service.test.ts new file mode 100644 index 0000000..db5c104 --- /dev/null +++ b/test/unit/device-validation.service.test.ts @@ -0,0 +1,191 @@ +import { expect } from 'chai'; + +import { DeviceValidationService } from '../../src/services/device-validation.service.js'; +import { EiOSDevices } from '../../src/types/domain/device.types.js'; +import type { CompatibilityData } from '../../src/utils/compatibility.js'; + +/** + * Device names are validated against the API's live compatibility data, never + * the CLI's help-text enums. `iphone-19` and `pixel-12` stand in for devices + * the API has added since this CLI was released. + */ +const compat: CompatibilityData = { + ios: { + 'iphone-14': ['17', '18'], + 'iphone-17': ['26', '27'], + 'iphone-19': ['28'], + // A rollout gate can leave a device with nothing bookable. + 'iphone-gated': [], + }, + android: { + 'pixel-7': ['33', '34', '35', '36', '37'], + 'pixel-10': ['36', '37'], + 'pixel-12': ['38'], + }, + androidPlay: { + 'pixel-7': ['34'], + 'pixel-10': [], + 'pixel-12': [], + }, + maestro: { + defaultVersion: '2.2.0', + latestVersion: '2.10.0', + supportedVersions: ['2.2.0', '2.10.0'], + }, +}; + +const MCP_ARG_NAMES = { + androidApiLevel: 'androidApiLevel', + androidDevice: 'androidDevice', + iOSDevice: 'iosDevice', + iOSVersion: 'iosVersion', +}; + +describe('DeviceValidationService', () => { + const service = new DeviceValidationService(); + const ios = (version?: string, device?: string, data = compat) => + service.validateiOSDevice(version, device, data); + const android = ( + apiLevel?: string, + device?: string, + googlePlay = false, + data = compat, + ) => service.validateAndroidDevice(apiLevel, device, googlePlay, data); + + describe('devices the CLI does not know about', () => { + it('accepts an iOS device and version the API offers but the CLI enum lacks', () => { + expect(Object.values(EiOSDevices)).to.not.include('iphone-19'); + expect(() => ios('28', 'iphone-19')).to.not.throw(); + }); + + it('accepts an Android device and API level the API offers but the CLI enum lacks', () => { + expect(() => android('38', 'pixel-12')).to.not.throw(); + }); + }); + + describe('unknown devices', () => { + it('rejects an unknown iOS device, listing what the API offers in its order', () => { + expect(() => ios('17', 'iphone-99')).to.throw( + 'iOS device "iphone-99" is not supported. Supported iOS devices: iphone-14, iphone-17, iphone-19. See https://docs.devicecloud.dev/getting-started/devices-configuration', + ); + }); + + it('never offers a gated device, and treats it as unsupported', () => { + expect(() => ios('17', 'iphone-gated')).to.throw( + /iOS device "iphone-gated" is not supported\. Supported iOS devices: iphone-14, iphone-17, iphone-19\./, + ); + }); + + it('rejects an unknown Android device, listing the Android devices', () => { + expect(() => android('34', 'pixel-99')).to.throw( + /Android device "pixel-99" is not supported\. Supported Android devices: pixel-7, pixel-10, pixel-12\./, + ); + }); + + it('says a real device has no Google Play image, listing the Play devices', () => { + expect(() => android('36', 'pixel-10', true)).to.throw( + /Android device "pixel-10" is not available with Google Play\. Google Play devices: pixel-7\./, + ); + }); + + it('lists only Google Play devices for an unknown device with --google-play', () => { + expect(() => android('34', 'pixel-99', true)).to.throw( + /Android device "pixel-99" is not supported\. Supported Google Play devices: pixel-7\./, + ); + }); + + it('does not resolve a prototype member as a device', () => { + expect(() => ios('17', 'constructor')).to.throw( + /iOS device "constructor" is not supported/, + ); + }); + + it('still fails when the API sent no list at all, without an empty list', () => { + const noIos = { ...compat, ios: {} }; + expect(() => ios('17', 'iphone-14', noIos)).to.throw( + 'iOS device "iphone-14" is not supported. See https://docs.devicecloud.dev/getting-started/devices-configuration', + ); + }); + }); + + describe('versions', () => { + it('accepts a supported pair', () => { + expect(() => ios('26', 'iphone-17')).to.not.throw(); + expect(() => android('34', 'pixel-7', true)).to.not.throw(); + }); + + it('rejects an unsupported explicit pair with the device\'s versions', () => { + expect(() => ios('27', 'iphone-14')).to.throw( + /^iphone-14 only supports these iOS versions: 17, 18$/, + ); + expect(() => android('34', 'pixel-10')).to.throw( + /^pixel-10 only supports these Android API levels: 36, 37$/, + ); + }); + + it('does nothing when neither a device nor a version was requested', () => { + expect(() => ios()).to.not.throw(); + expect(() => android(undefined, undefined, true)).to.not.throw(); + }); + }); + + // The API fills an omitted half from the same global defaults (iphone-14 / + // iOS 17, pixel-7 / API 34) and rejects an incompatible pair, so the CLI + // mirrors that — but must tell the user to pass the other half. + describe('requests that rely on a default', () => { + it('tells a device-only iOS request to pass the version explicitly', () => { + expect(() => ios(undefined, 'iphone-17')).to.throw( + 'iphone-17 only supports these iOS versions: 26, 27. No iOS version was given, so the default (17) was checked: pass one of those explicitly with --ios-version', + ); + }); + + it('tells a device-only Android request to pass the API level explicitly', () => { + expect(() => android(undefined, 'pixel-10')).to.throw( + 'pixel-10 only supports these Android API levels: 36, 37. No Android API level was given, so the default (34) was checked: pass one of those explicitly with --android-api-level', + ); + }); + + it('accepts a device-only request when the default version fits', () => { + expect(() => ios(undefined, 'iphone-14')).to.not.throw(); + expect(() => android(undefined, 'pixel-7')).to.not.throw(); + }); + + it('names the MCP tool parameters when asked to', () => { + expect(() => + service.validateiOSDevice(undefined, 'iphone-17', compat, { + argNames: MCP_ARG_NAMES, + }), + ).to.throw(/pass one of those explicitly with iosVersion$/); + expect(() => + service.validateAndroidDevice('38', undefined, false, compat, { + argNames: MCP_ARG_NAMES, + }), + ).to.throw(/with androidDevice: pixel-12$/); + }); + + it('points a version-only request at the devices that offer that version', () => { + expect(() => ios('27')).to.throw( + 'iphone-14 only supports these iOS versions: 17, 18. No iOS device was given, so the default (iphone-14) was checked: pass one that offers iOS 27 with --ios-device: iphone-17', + ); + }); + + it('says when no device offers the requested version at all', () => { + expect(() => ios('99')).to.throw( + /No iOS device was given, so the default \(iphone-14\) was checked: no iOS device offers iOS 99$/, + ); + expect(() => android('36', undefined, true)).to.throw( + /no Google Play device offers API level 36$/, + ); + }); + + it('leaves a version-only request to the API once the API no longer offers the assumed default device', () => { + // The API has moved its default on; checking against the CLI's stale + // assumption would refuse a request the API accepts. + const withoutPixel7 = { + ...compat, + android: { 'pixel-10': ['36', '37'] }, + }; + expect(() => android('36', undefined, false, withoutPixel7)).to.not.throw(); + }); + }); +}); diff --git a/test/unit/disable-animations.test.ts b/test/unit/disable-animations.test.ts new file mode 100644 index 0000000..7aa07e3 --- /dev/null +++ b/test/unit/disable-animations.test.ts @@ -0,0 +1,130 @@ +import { expect } from 'chai'; +import { parseArgs } from 'citty'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { deviceFlags } from '../../src/config/flags/device.flags.js'; +import type { IExecutionPlan } from '../../src/services/execution-plan.service.js'; +import { platformFromAppFile } from '../../src/services/notices.service.js'; +import { + type TestSubmissionConfig, + TestSubmissionService, +} from '../../src/services/test-submission.service.js'; + +/** + * config.yaml can set `platform.ios.disableAnimations` and + * `platform.android.disableAnimations` separately. Which one applies must + * follow the app actually being tested, and an explicit + * --disable-animations / --no-disable-animations must beat either. + */ +describe('disableAnimations', () => { + let workspace: string; + let flowFile: string; + + before(() => { + workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-animations-')); + flowFile = path.join(workspace, 'flow.yaml'); + fs.writeFileSync(flowFile, 'appId: com.example\n---\n- launchApp\n'); + }); + + after(() => { + fs.rmSync(workspace, { force: true, recursive: true }); + }); + + /** The disableAnimations value the payload's config carries. */ + async function sent( + overrides: Partial, + platform = { android: { disableAnimations: false }, ios: { disableAnimations: true } }, + ): Promise { + const executionPlan: IExecutionPlan = { + flowMetadata: {}, + flowOverrides: {}, + flowsToRun: [flowFile], + includedFiles: [], + referencedFiles: [], + totalFlowFiles: 1, + workspaceConfig: { platform }, + }; + const { fields } = await new TestSubmissionService().buildTestPayload({ + appBinaryId: 'binary-1', + cliVersion: '0.0.0-test', + commonRoot: workspace, + executionPlan, + flowFile, + maestroVersion: '2.10.0', + ...overrides, + }); + return (JSON.parse(fields.config) as { disableAnimations?: unknown }) + .disableAnimations; + } + + describe('which platform block of config.yaml applies', () => { + it('follows an iOS binary even without --ios-device / --ios-version', async () => { + // Used to read the android block here, because only the iOS flags + // marked a run as iOS. + expect(await sent({ appPlatform: 'ios' })).to.equal(true); + }); + + it('follows an Android binary even when an iOS flag is present', async () => { + expect(await sent({ appPlatform: 'android', iOSVersion: '18' })).to.equal(false); + }); + + it('falls back to the iOS flags when the binary is unknown (--app-binary-id)', async () => { + expect(await sent({ iOSDevice: 'iphone-16' })).to.equal(true); + expect(await sent({})).to.equal(false); + }); + + it('falls back to a device matrix when the binary is unknown', async () => { + expect( + await sent({ deviceMatrix: [{ iOSDevice: 'iphone-16', iOSVersion: '18' }] }), + ).to.equal(true); + }); + }); + + describe('an explicit flag', () => { + it('keeps animations on against the config (--no-disable-animations)', async () => { + expect(await sent({ appPlatform: 'ios', disableAnimations: false })).to.equal(false); + }); + + it('turns animations off against the config (--disable-animations)', async () => { + expect(await sent({ appPlatform: 'android', disableAnimations: true })).to.equal(true); + }); + + it('leaves the config in charge when not passed', async () => { + const androidOff = { + android: { disableAnimations: true }, + ios: { disableAnimations: false }, + }; + expect(await sent({ appPlatform: 'android' }, androidOff)).to.equal(true); + }); + }); + + describe('the --disable-animations flag', () => { + const parse = (argv: string[]) => parseArgs(argv, deviceFlags)['disable-animations']; + + it('is undefined when not passed, so the config can apply', () => { + expect(parse([])).to.equal(undefined); + }); + + it('is true or false when passed either way', () => { + expect(parse(['--disable-animations'])).to.equal(true); + expect(parse(['--no-disable-animations'])).to.equal(false); + }); + }); + + describe('platformFromAppFile', () => { + it('reads the platform from the app file', () => { + expect(platformFromAppFile('build/app.apk')).to.equal('android'); + expect(platformFromAppFile('build/App.app')).to.equal('ios'); + expect(platformFromAppFile('build/app.zip')).to.equal('ios'); + expect(platformFromAppFile('build/expo-build.tar.gz')).to.equal('ios'); + expect(platformFromAppFile('https://expo.dev/build.tar.gz?token=x')).to.equal('ios'); + }); + + it('does not guess from a flow path or nothing', () => { + expect(platformFromAppFile('flows/login.yaml')).to.equal(undefined); + expect(platformFromAppFile(undefined)).to.equal(undefined); + }); + }); +}); diff --git a/test/unit/report-download.service.test.ts b/test/unit/report-download.service.test.ts index 8c75f13..9feda29 100644 --- a/test/unit/report-download.service.test.ts +++ b/test/unit/report-download.service.test.ts @@ -3,6 +3,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { ApiGateway } from '../../src/gateways/api-gateway.js'; import { ReportDownloadService } from '../../src/services/report-download.service.js'; import type { AuthContext } from '../../src/types/domain/auth.types.js'; @@ -457,3 +458,66 @@ describe('ReportDownloadService', () => { }); }); }); + +// The HTML report (html and html-detailed alike) is a ZIP of report.html and +// its assets, so saving it as ./report.html by default produced a file no +// browser could open. +describe('ReportDownloadService default report paths', () => { + const realDownload = ApiGateway.downloadReportGeneric; + let saved: Array<{ filePath?: string; type: string }>; + + beforeEach(() => { + saved = []; + ApiGateway.downloadReportGeneric = (async ( + _baseUrl: string, + _auth: AuthContext, + _uploadId: string, + type: 'allure' | 'html' | 'junit', + filePath?: string, + ) => { + saved.push({ filePath, type }); + }) as typeof ApiGateway.downloadReportGeneric; + }); + + afterEach(() => { + ApiGateway.downloadReportGeneric = realDownload; + }); + + const download = ( + reportType: 'allure' | 'html' | 'html-detailed' | 'junit', + paths: { allurePath?: string; htmlPath?: string; junitPath?: string } = {}, + ) => + new ReportDownloadService().downloadReports({ + apiUrl: 'https://api.example.com', + auth: TEST_AUTH, + reportType, + uploadId: 'u1', + ...paths, + }); + + it('saves html and html-detailed reports to ./report.zip', async () => { + await download('html'); + await download('html-detailed'); + + const zip = path.resolve(process.cwd(), 'report.zip'); + expect(saved).to.deep.equal([ + { filePath: zip, type: 'html' }, + { filePath: zip, type: 'html' }, + ]); + }); + + it('keeps an explicit --html-path exactly as given', async () => { + await download('html', { htmlPath: 'out/report.html' }); + expect(saved[0].filePath).to.equal(path.resolve(process.cwd(), 'out/report.html')); + }); + + it('leaves the junit and allure defaults alone', async () => { + await download('junit'); + await download('allure'); + + expect(saved.map((s) => s.filePath)).to.deep.equal([ + path.resolve(process.cwd(), 'report.xml'), + path.resolve(process.cwd(), 'report.html'), + ]); + }); +}); diff --git a/test/unit/server-json.test.ts b/test/unit/server-json.test.ts new file mode 100644 index 0000000..d3b711e --- /dev/null +++ b/test/unit/server-json.test.ts @@ -0,0 +1,98 @@ +import { expect } from 'chai'; +import { readFileSync } from 'node:fs'; + +/** + * server.json is the MCP registry manifest. Registry clients turn an npm entry + * into `npx @ ` (VS + * Code's McpManagementService does exactly this), and the server is this + * package's *second* bin — so the manifest has to name it through + * `npx --package= dcd-mcp`, or npx runs the default `dcd` bin instead. + * + * The runtime arguments end with a bare `--package`, so the + * `@` the client appends becomes its value, and + * `dcd-mcp` follows as the package argument. The pin therefore lives only in + * `packages[].version`, a bare version string release-please can rewrite. + */ +interface Argument { + name?: string; + type: 'named' | 'positional'; + value?: string; +} + +interface ServerJson { + packages: Array<{ + identifier: string; + packageArguments?: Argument[]; + runtimeArguments?: Argument[]; + runtimeHint?: string; + version: string; + }>; + version: string; +} + +interface ReleasePleaseConfig { + packages: Record< + string, + { 'extra-files'?: Array<{ jsonpath?: string; path: string; type: string }> } + >; +} + +const readJson = (file: string): T => + JSON.parse(readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8')) as T; + +const server = readJson('server.json'); +const npmPackage = server.packages[0]; + +/** A client's rendering of registry arguments into argv (named → flag, value). */ +const render = (args: Argument[] = []): string[] => + args.flatMap((a) => + a.type === 'named' ? [a.name!, ...(a.value ? [a.value] : [])] : [a.value!], + ); + +describe('server.json (MCP registry manifest)', () => { + it('assembles into an npx command that runs the dcd-mcp bin', () => { + const argv = [ + ...render(npmPackage.runtimeArguments), + `${npmPackage.identifier}@${npmPackage.version}`, + ...render(npmPackage.packageArguments), + ]; + + expect(npmPackage.runtimeHint).to.equal('npx'); + expect(argv).to.deep.equal([ + '-y', + '--package', + `@devicecloud.dev/dcd@${npmPackage.version}`, + 'dcd-mcp', + ]); + + const { bin } = readJson<{ bin: Record }>('package.json'); + expect(bin).to.have.property('dcd-mcp'); + }); + + it('pins the last stable release, and only in the bare version fields', () => { + const { '.': stable } = readJson>( + '.release-please-manifest.json', + ); + + expect(server.version).to.equal(stable); + expect(npmPackage.version).to.equal(stable); + + // A version embedded in an argument can't be kept current: release-please's + // json updater replaces the whole matched value with the bare version. + const argv = [ + ...render(npmPackage.runtimeArguments), + ...render(npmPackage.packageArguments), + ]; + expect(argv.filter((a) => /@\d/.test(a))).to.deep.equal([]); + }); + + it('only lets release-please rewrite bare version fields in server.json', () => { + const config = readJson('release-please-config.json'); + const serverJsonPaths = Object.values(config.packages) + .flatMap((pkg) => pkg['extra-files'] ?? []) + .filter((file) => file.path === 'server.json') + .map((file) => file.jsonpath); + + expect(serverJsonPaths).to.have.members(['$.version', '$.packages[*].version']); + }); +}); diff --git a/test/unit/session-refresh.test.ts b/test/unit/session-refresh.test.ts new file mode 100644 index 0000000..c2cf354 --- /dev/null +++ b/test/unit/session-refresh.test.ts @@ -0,0 +1,378 @@ +import { expect } from 'chai'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { ApiGateway } from '../../src/gateways/api-gateway.js'; +import { + CliAuthGateway, + SessionRefreshError, +} from '../../src/gateways/cli-auth-gateway.js'; +import { RealtimeResultsGateway } from '../../src/gateways/realtime-gateway.js'; +import { clearContextCache, getContext } from '../../src/mcp/context.js'; +import { ReportDownloadService } from '../../src/services/report-download.service.js'; +import { + PollingAuthError, + ResultsPollingService, +} from '../../src/services/results-polling.service.js'; +import type { AuthContext } from '../../src/types/domain/auth.types.js'; +import { + isAuthExpiring, + isDefinitiveAuthFailure, + refreshAuth, + resolveAuth, +} from '../../src/utils/auth.js'; +import { CliError } from '../../src/utils/cli.js'; +import { readConfig, writeConfig } from '../../src/utils/config-store.js'; + +/** + * A `dcd login` session's access token lasts about an hour. Anything that can + * run longer — the MCP server, a `dcd cloud` poll and the downloads after it — + * must refresh it rather than keep sending the token it started with. + */ + +const ORIGINAL_ENV = { ...process.env }; +const realNow = Date.now; +const realRefresh = CliAuthGateway.refresh; +const realGetResults = ApiGateway.getResultsForUpload; +const realSubscribe = RealtimeResultsGateway.subscribe; +const realFetch = (global as any).fetch; + +const nowSeconds = () => Math.floor(Date.now() / 1000); + +let tempDir: string; + +/** Write a stored `dcd login` config, as `dcd login` / a refresh would. */ +function storeSession( + accessToken: string, + expiresInSeconds: number, + orgId = '42', +): void { + writeConfig({ + version: 1, + env: 'prod', + api_url: 'https://api.devicecloud.dev', + supabase_url: 'https://cloud.devicecloud.dev', + session: { + access_token: accessToken, + refresh_token: `refresh-for-${accessToken}`, + expires_at: nowSeconds() + expiresInSeconds, + user_email: 'u@example.com', + user_id: 'u1', + }, + current_org_id: orgId, + }); +} + +/** A bearer context whose token expired a moment ago. */ +function expiredBearer(accessToken = 'stale-token', orgId = '42'): AuthContext { + return { + mode: 'bearer', + accessToken, + env: 'prod', + expiresAt: nowSeconds() - 5, + orgId, + userEmail: 'u@example.com', + headers: { authorization: `Bearer ${accessToken}`, 'x-dcd-org': orgId }, + }; +} + +/** Fail loudly if a test that shouldn't reach Supabase does. */ +function forbidNetworkRefresh(): void { + CliAuthGateway.refresh = async () => { + throw new Error('unexpected network refresh'); + }; +} + +describe('session refresh for long-running processes', () => { + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-refresh-test-')); + process.env.DCD_CONFIG_DIR = tempDir; + delete process.env.DEVICE_CLOUD_API_KEY; + delete process.env.DCD_API_URL; + forbidNetworkRefresh(); + clearContextCache(); + }); + + afterEach(() => { + Date.now = realNow; + CliAuthGateway.refresh = realRefresh; + ApiGateway.getResultsForUpload = realGetResults; + RealtimeResultsGateway.subscribe = realSubscribe; + (global as any).fetch = realFetch; + clearContextCache(); + process.env = { ...ORIGINAL_ENV }; + fs.rmSync(tempDir, { force: true, recursive: true }); + }); + + describe('refreshAuth', () => { + it('reports when a resolved session expires', async () => { + storeSession('token-a', 3600); + const auth = await resolveAuth({ apiKeyFlag: undefined }); + expect(auth.expiresAt).to.equal(readConfig()!.session!.expires_at); + expect(isAuthExpiring(auth)).to.equal(false); + expect(isAuthExpiring({ ...auth, expiresAt: nowSeconds() + 30 })).to.equal(true); + }); + + it('never touches API-key auth', async () => { + const auth: AuthContext = { mode: 'apiKey', headers: { 'x-app-api-key': 'k' } }; + expect(isAuthExpiring(auth)).to.equal(false); + expect(await refreshAuth(auth)).to.equal(auth); + }); + + it('returns a session that is not near expiry unchanged', async () => { + storeSession('token-a', 3600); + const auth = await resolveAuth({ apiKeyFlag: undefined }); + expect(await refreshAuth(auth)).to.equal(auth); + }); + + it('picks up a session another dcd process already refreshed, without a refresh call', async () => { + storeSession('rotated-elsewhere', 3600); + const next = await refreshAuth(expiredBearer()); + expect(next.headers.authorization).to.equal('Bearer rotated-elsewhere'); + expect(next.accessToken).to.equal('rotated-elsewhere'); + expect(isAuthExpiring(next)).to.equal(false); + }); + + it('keeps the org the command started with, even if another terminal switched org', async () => { + storeSession('fresh', 3600, '99'); + const next = await refreshAuth(expiredBearer('stale', '42')); + expect(next.orgId).to.equal('42'); + expect(next.headers['x-dcd-org']).to.equal('42'); + expect(next.headers.authorization).to.equal('Bearer fresh'); + }); + + it('refreshes the stored session when it is expiring too, and saves the rotation', async () => { + storeSession('stale', 10); + CliAuthGateway.refresh = async () => ({ + access_token: 'refreshed', + refresh_token: 'refresh-2', + expires_at: nowSeconds() + 3600, + user_email: 'u@example.com', + user_id: 'u1', + }); + + const next = await refreshAuth(expiredBearer('stale')); + expect(next.headers.authorization).to.equal('Bearer refreshed'); + expect(readConfig()!.session!.refresh_token).to.equal('refresh-2'); + }); + + it('classifies a refused refresh token as final and drops the dead session', async () => { + storeSession('stale', 10); + CliAuthGateway.refresh = async () => { + throw new SessionRefreshError('Invalid Refresh Token: Already Used', true); + }; + + let caught: unknown; + try { + await refreshAuth(expiredBearer('stale')); + } catch (error) { + caught = error; + } + + expect(isDefinitiveAuthFailure(caught)).to.equal(true); + expect(readConfig()!.session).to.equal(undefined); + // With the session gone, the next attempt is "not authenticated": final too. + let next: unknown; + try { + await refreshAuth(expiredBearer('stale')); + } catch (error) { + next = error; + } + + expect(next).to.be.instanceOf(CliError); + expect(isDefinitiveAuthFailure(next)).to.equal(true); + }); + + it('treats a network failure during refresh as retryable', () => { + expect(isDefinitiveAuthFailure(new SessionRefreshError('fetch failed', false))).to.equal( + false, + ); + expect(isDefinitiveAuthFailure(new Error('socket hang up'))).to.equal(false); + }); + }); + + describe('MCP context', () => { + it('re-resolves a dcd login session once it nears expiry', async () => { + storeSession('token-a', 3600); + const first = await getContext(); + expect(first.auth.headers.authorization).to.equal('Bearer token-a'); + + // Another process (or a re-login) stores a newer session... + storeSession('token-b', 3 * 3600); + // ...which the server doesn't need while its own token is still good. + expect((await getContext()).auth.headers.authorization).to.equal('Bearer token-a'); + + // Two hours on, token-a has expired and the server picks up token-b. + Date.now = () => realNow() + 2 * 3600 * 1000; + const later = await getContext(); + expect(later.auth.headers.authorization).to.equal('Bearer token-b'); + expect(later.apiUrl).to.equal('https://api.devicecloud.dev'); + }); + + it('resolves an API key once, whatever the time', async () => { + process.env.DEVICE_CLOUD_API_KEY = 'mcp-key'; + const first = await getContext(); + Date.now = () => realNow() + 48 * 3600 * 1000; + expect(await getContext()).to.equal(first); + expect(first.auth.headers['x-app-api-key']).to.equal('mcp-key'); + }); + + it('keeps failing, rather than caching, until the user logs in again', async () => { + storeSession('token-a', 3600); + await getContext(); + fs.rmSync(path.join(tempDir, 'config.json'), { force: true }); + Date.now = () => realNow() + 2 * 3600 * 1000; + + let caught: unknown; + try { + await getContext(); + } catch (error) { + caught = error; + } + + expect((caught as Error)?.message).to.match(/Not authenticated/); + storeSession('after-relogin', 3 * 3600); + expect((await getContext()).auth.headers.authorization).to.equal( + 'Bearer after-relogin', + ); + }); + }); + + describe('dcd cloud polling', () => { + const passedRow = { + id: 1, + test_file_name: 'flow.yaml', + status: 'PASSED', + retry_of: null, + duration_seconds: 1, + fail_reason: null, + }; + + /** Record the headers of every poll and answer with a finished run. */ + function stubResults(): Array> { + const seen: Array> = []; + ApiGateway.getResultsForUpload = (async (_url: string, auth: AuthContext) => { + seen.push({ ...auth.headers }); + return { results: [passedRow] }; + }) as unknown as typeof ApiGateway.getResultsForUpload; + return seen; + } + + /** Stand in for the Supabase socket and record the tokens it is handed. */ + function stubRealtime(): { joinedWith: string[]; updatedTo: string[] } { + const calls = { joinedWith: [] as string[], updatedTo: [] as string[] }; + RealtimeResultsGateway.subscribe = (options) => { + calls.joinedWith.push(options.accessToken); + return { + isConnected: () => false, + async unsubscribe() {}, + updateAccessToken(token: string) { + calls.updatedTo.push(token); + }, + }; + }; + return calls; + } + + const poll = (auth: AuthContext, service = new ResultsPollingService()) => + service.pollUntilComplete({ + auth, + apiUrl: 'https://api.example.com', + consoleUrl: 'https://console.example.com/results?upload=u1', + json: true, + uploadId: 'u1', + }); + + it('polls with a refreshed token, and hands it to the realtime socket', async () => { + storeSession('fresh', 3600, '99'); + const seen = stubResults(); + const realtime = stubRealtime(); + + const result = await poll(expiredBearer('stale', '42')); + + expect(result.status).to.equal('PASSED'); + expect(seen).to.deep.equal([{ authorization: 'Bearer fresh', 'x-dcd-org': '42' }]); + expect(realtime.joinedWith).to.deep.equal(['stale']); + expect(realtime.updatedTo).to.deep.equal(['fresh']); + }); + + it('leaves API-key polling exactly as it was', async () => { + const seen = stubResults(); + await poll({ mode: 'apiKey', headers: { 'x-app-api-key': 'k' } }); + expect(seen).to.deep.equal([{ 'x-app-api-key': 'k' }]); + }); + + it('retries a refresh that failed on a network blip, like any failed poll', async () => { + storeSession('stale', 10); + stubRealtime(); + const seen = stubResults(); + let attempts = 0; + CliAuthGateway.refresh = async () => { + attempts++; + if (attempts === 1) throw new SessionRefreshError('fetch failed', false); + return { + access_token: 'second-try', + refresh_token: 'r2', + expires_at: nowSeconds() + 3600, + user_email: 'u@example.com', + user_id: 'u1', + }; + }; + const service = new ResultsPollingService(); + // Skip the real backoff between failed polls. + (service as unknown as { sleep: () => Promise }).sleep = async () => {}; + + const result = await poll(expiredBearer('stale'), service); + + expect(result.status).to.equal('PASSED'); + expect(attempts).to.equal(2); + expect(seen.map((h) => h.authorization)).to.deep.equal(['Bearer second-try']); + }); + + it('stops at once, with a reconnect hint, when only `dcd login` can help', async () => { + storeSession('stale', 10); + stubRealtime(); + const seen = stubResults(); + CliAuthGateway.refresh = async () => { + throw new SessionRefreshError('Invalid Refresh Token: Already Used', true); + }; + + const started = realNow(); + let caught: unknown; + try { + await poll(expiredBearer('stale')); + } catch (error) { + caught = error; + } + + expect(caught).to.be.instanceOf(PollingAuthError); + expect((caught as Error).message).to.include('Invalid Refresh Token'); + expect((caught as Error).message).to.include('dcd login'); + expect((caught as Error).message).to.include('dcd status --upload-id u1'); + expect(seen).to.deep.equal([]); + // No trip through the 30-attempt retry budget and its backoff. + expect(realNow() - started).to.be.below(2000); + }); + + it('downloads the report after a long poll with a refreshed token', async () => { + storeSession('fresh', 3600); + const headers: Array> = []; + (global as any).fetch = async (_input: unknown, init?: RequestInit) => { + headers.push({ ...(init?.headers as Record) }); + return new Response('', { status: 200 }); + }; + + await new ReportDownloadService().downloadReports({ + apiUrl: 'https://api.example.com', + auth: expiredBearer('stale'), + junitPath: path.join(tempDir, 'report.xml'), + reportType: 'junit', + uploadId: 'u1', + }); + + expect(headers[0].authorization).to.equal('Bearer fresh'); + expect(fs.readFileSync(path.join(tempDir, 'report.xml'), 'utf8')).to.equal(''); + }); + }); +}); diff --git a/test/unit/upload-encrypt.test.ts b/test/unit/upload-encrypt.test.ts new file mode 100644 index 0000000..ce558ac --- /dev/null +++ b/test/unit/upload-encrypt.test.ts @@ -0,0 +1,106 @@ +import { expect } from 'chai'; +import { generateKeyPairSync } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { uploadCommand } from '../../src/commands/upload.js'; + +/** + * `dcd upload` must honour DCD_ENCRYPT=1 exactly as `dcd cloud` does. It used + * to pass `Boolean(args.encrypt)`, and an explicit `false` beats the env var, + * so an exported DCD_ENCRYPT=1 was silently ignored and the binary went up in + * plaintext. + * + * Observed through the dedup lookup, which is the first request either way: + * an encrypted upload deduplicates on the plaintext hash and says so + * (`{ shaPlain, encrypted: true }`), a plaintext one sends `{ sha }`. + */ +const API = 'http://localhost:9999'; +const APK = path.join(process.cwd(), 'test/fixtures/wikipedia.apk'); + +describe('dcd upload encryption', () => { + const ORIGINAL_ENV = { ...process.env }; + const realFetch = globalThis.fetch; + const realExit = process.exit; + let lookups: Array>; + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-upload-test-')); + process.env.DCD_CONFIG_DIR = tempDir; + process.env.DEVICE_CLOUD_API_KEY = 'test-key'; + delete process.env.DCD_ENCRYPT; + delete process.env.DCD_ENCRYPT_BINARIES; + + // A throwaway KEK so encryption can run without a pinned key. + const { publicKey } = generateKeyPairSync('x25519'); + const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(12); + process.env.DCD_BINARY_KEK_PUBLIC = `1:${raw.toString('base64')}`; + + // Answer the dedup lookup with a hit of the matching kind, so the command + // finishes without uploading anything. + lookups = []; + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as Record; + lookups.push(body); + return new Response( + JSON.stringify({ + appBinaryId: body.encrypted ? 'encrypted-binary' : 'plain-binary', + encrypted: Boolean(body.encrypted), + exists: true, + }), + { headers: { 'content-type': 'application/json' }, status: 200 }, + ); + }) as typeof fetch; + + // A failure must fail the test, not end the mocha process. + process.exit = ((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as typeof process.exit; + }); + + afterEach(() => { + globalThis.fetch = realFetch; + process.exit = realExit; + process.env = { ...ORIGINAL_ENV }; + fs.rmSync(tempDir, { force: true, recursive: true }); + }); + + /** Run `dcd upload --json ` and return the binary id it printed. */ + async function upload(args: Record = {}): Promise { + let out = ''; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string) => { + out += chunk; + return true; + }) as typeof process.stdout.write; + try { + await ( + uploadCommand.run as (ctx: { args: Record }) => Promise + )({ args: { appFile: APK, 'api-url': API, json: true, ...args } }); + } finally { + process.stdout.write = original; + } + + return (JSON.parse(out) as { appBinaryId: string }).appBinaryId; + } + + it('encrypts when DCD_ENCRYPT=1 is exported and --encrypt is not passed', async () => { + process.env.DCD_ENCRYPT = '1'; + + expect(await upload()).to.equal('encrypted-binary'); + expect(lookups[0]).to.include({ encrypted: true }); + expect(lookups[0]).to.have.property('shaPlain'); + }); + + it('still encrypts with --encrypt alone', async () => { + expect(await upload({ encrypt: true })).to.equal('encrypted-binary'); + }); + + it('uploads in plaintext when neither is set', async () => { + expect(await upload()).to.equal('plain-binary'); + expect(lookups[0]).to.have.property('sha'); + expect(lookups[0]).to.not.have.property('encrypted'); + }); +}); diff --git a/test/unit/whoami.test.ts b/test/unit/whoami.test.ts new file mode 100644 index 0000000..5967ba7 --- /dev/null +++ b/test/unit/whoami.test.ts @@ -0,0 +1,114 @@ +import { expect } from 'chai'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { stripVTControlCharacters } from 'node:util'; + +import { whoamiCommand } from '../../src/commands/whoami.js'; +import { apiKeyOverride } from '../../src/utils/auth.js'; +import { writeConfig } from '../../src/utils/config-store.js'; + +/** + * `whoami` shows the stored `dcd login` session, but every other command + * authenticates with --api-key or DEVICE_CLOUD_API_KEY first — so it must say + * when one of those is in play instead of implying the session is used. + */ +describe('dcd whoami', () => { + const ORIGINAL_ENV = { ...process.env }; + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-whoami-test-')); + process.env.DCD_CONFIG_DIR = tempDir; + delete process.env.DEVICE_CLOUD_API_KEY; + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + fs.rmSync(tempDir, { force: true, recursive: true }); + }); + + const logIn = () => + writeConfig({ + version: 1, + env: 'prod', + api_url: 'https://api.devicecloud.dev', + supabase_url: 'https://cloud.devicecloud.dev', + session: { + access_token: 'a', + refresh_token: 'r', + expires_at: Math.floor(Date.now() / 1000) + 3600, + user_email: 'u@example.com', + user_id: 'u1', + }, + current_org_id: '42', + current_org_name: 'Acme', + }); + + /** Run whoami and return what it printed, without colour codes. */ + function whoami(args: Record = {}): string { + let out = ''; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string) => { + out += chunk; + return true; + }) as typeof process.stdout.write; + try { + (whoamiCommand.run as (ctx: { args: Record }) => void)({ args }); + } finally { + process.stdout.write = original; + } + + return stripVTControlCharacters(out); + } + + it('shows the session and nothing else when no API key is set', () => { + logIn(); + const out = whoami(); + expect(out).to.include('u@example.com'); + expect(out).to.include('Acme'); + expect(out).to.not.match(/API key/); + }); + + it('warns that an exported DEVICE_CLOUD_API_KEY is what other commands use', () => { + logIn(); + process.env.DEVICE_CLOUD_API_KEY = 'exported-key'; + const out = whoami(); + expect(out).to.include('u@example.com'); + expect(out).to.include( + 'DEVICE_CLOUD_API_KEY is set, so other dcd commands authenticate with that API key and its org, not this session.', + ); + }); + + it('warns about --api-key the same way', () => { + logIn(); + const out = whoami({ 'api-key': 'flag-key' }); + expect(out).to.include( + 'Commands given --api-key authenticate with that API key and its org, not this session.', + ); + }); + + it('says a key will be used when nobody is logged in', () => { + process.env.DEVICE_CLOUD_API_KEY = 'exported-key'; + expect(whoami()).to.include( + 'Not logged in. DEVICE_CLOUD_API_KEY is set, so dcd commands authenticate with that API key.', + ); + }); + + it('still points a logged-out user without a key at dcd login', () => { + expect(whoami()).to.include('Not logged in. Run dcd login or set DEVICE_CLOUD_API_KEY.'); + }); + + describe('apiKeyOverride', () => { + it('follows resolveAuth: the flag, then the env var', () => { + process.env.DEVICE_CLOUD_API_KEY = 'exported-key'; + expect(apiKeyOverride('flag-key')).to.equal('--api-key'); + expect(apiKeyOverride()).to.equal('DEVICE_CLOUD_API_KEY'); + }); + + it('ignores blank values, as resolveAuth does', () => { + process.env.DEVICE_CLOUD_API_KEY = ' '; + expect(apiKeyOverride(' ')).to.equal(undefined); + }); + }); +});