diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 15d69da..8e8a6dc 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -76,12 +76,26 @@ jobs: # The plugin also stops early on PRs it judges trivial. The prompt above # tells Claude to post a one-line note in that case, so a deliberate skip # leaves a comment and only a real failure trips this check. + # + # Nor is a PR that edits THIS file: the action refuses to run unless the + # workflow is identical to the default branch's ("Workflow validation + # failed"), a deliberate guard against a PR rewriting its own reviewer. It + # exits green having posted nothing, so pass with a notice instead. - name: Check a review comment was posted env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR: ${{ github.event.pull_request.number }} + SELF: ${{ github.workflow_ref }} run: | + # workflow_ref is "owner/repo/.github/workflows/@". + self_path="${SELF#"$REPO"/}" + self_path="${self_path%@*}" + if gh api "repos/$REPO/pulls/$PR/files" --paginate --jq '.[].filename' | grep -qxF "$self_path"; then + echo "::notice::This PR edits $self_path, so the review action skipped it by design (it only runs the default branch's version of its own workflow). No review on this PR." + exit 0 + fi + # Prints the time of every claude[bot] comment, inline comment and review. claude_posts() { gh api "repos/$REPO/issues/$PR/comments" --paginate \ diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index 3ec9ff9..fddf327 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -113,8 +113,11 @@ jobs: # # It has to be on a NORMAL commit, not the merge commit — release-please's # commit splitting is unreliable on merges. + # + # Release-please's own release PR also targets `production` but is not a + # promotion: it never carries a pin, so the check would always fail on it. promotion-pin: - if: github.event_name == 'pull_request' && github.base_ref == 'production' + if: github.event_name == 'pull_request' && github.base_ref == 'production' && !startsWith(github.head_ref, 'release-please--') runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 diff --git a/.gitleaks.toml b/.gitleaks.toml index 555ff26..2bff356 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -1,20 +1,22 @@ # Gitleaks configuration — extends the default ruleset. # # The only allowlisted secrets are PUBLIC key material committed in -# src/config/environments.ts: -# - the two Supabase anon keys — anon-role JWTs, designed to be embedded in -# client code and gated by RLS (see the doc comment in that file). -# - the two KEK public keys (prod + dev) for client-side binary envelope -# encryption — base64 of the raw 32-byte X25519 PUBLIC half. They can only -# *encrypt*; the private halves live solely on the API, never in this repo. -# Both are intentionally not secret; gitleaks flags them on entropy alone +# src/config/environments.ts: the two KEK public keys (prod + dev) for +# client-side binary envelope encryption — base64 of the raw 32-byte X25519 +# PUBLIC half. They can only *encrypt*; the private halves live solely on the +# API, never in this repo. gitleaks flags them on entropy alone # (generic-api-key), not because it recognizes them as credentials. # +# The two legacy Supabase anon keys (prod + dev) are also listed, but only +# because they remain in git history and CI scans the full history. The source +# no longer contains them: the CLI fetches its publishable key from the API's +# GET /cli-config at runtime (dcd#1112). Do not add a Supabase key back. +# # They are allowlisted by EXACT VALUE, deliberately not by file path or by the -# whole `jwt` / `generic-api-key` rule: a Supabase service_role key is also a -# JWT, and a KEK *private* key is the same shape as its public half, so a -# path/rule allowlist would let a genuinely sensitive key pasted into the same -# file slip through. Matching exact values keeps that detection intact. +# whole `generic-api-key` rule: a KEK *private* key is the same shape as its +# public half, so a path/rule allowlist would let a genuinely sensitive key +# pasted into the same file slip through. Matching exact values keeps that +# detection intact. # # When a KEK is rotated, replace the corresponding value below — do not simply # append, or the retired key stops being distinguishable from a live secret. @@ -22,8 +24,9 @@ useDefault = true [allowlist] -description = "Public Supabase anon keys and KEK public keys (safe to commit)" +description = "KEK public keys, and retired Supabase anon keys left in history (safe)" regexes = [ + # retired legacy anon keys (prod, dev), history only '''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBneWRucGhiaW1ldGluc2dma2JvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDc1OTQzNDYsImV4cCI6MjAyMzE3MDM0Nn0\.hAYOMFxxwX1exkQkY9xyQJGC_GhGnyogkj2N-kBkMI8''', '''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxibXNvd2VodGp3bnFsdXJwZW1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMTg0ODcsImV4cCI6MjAyNDc5NDQ4N30\.zeLTMAuZ_WwYvGdeP0kdvL_Zrs-RQee5APPyxmWq7qQ''', # prod kekPublicKey v1 diff --git a/.husky/pre-commit b/.husky/pre-commit index aa604fd..0386007 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,7 +1,7 @@ #!/usr/bin/env sh # Scan staged changes for secrets before they ever enter git history. # Mirrors the gitleaks CI gate (.github/workflows/cli-ci.yml) and shares the -# same allowlist (.gitleaks.toml), so the public Supabase anon keys won't trip. +# same allowlist (.gitleaks.toml), so the public KEK keys won't trip. # # gitleaks is NOT a dependency of this package — contributors install the binary # themselves. If it's missing we skip (exit 0) rather than block: CI is the diff --git a/.release-please-manifest.json b/.release-please-manifest.json index cabdafc..028d2d6 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "5.7.1" + ".": "5.10.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 02c2a19..17f94af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,62 @@ # Changelog +## [5.10.0](https://github.com/devicecloud-dev/dcd-cli/compare/v5.9.0...v5.10.0) (2026-10-22) + + +### Features + +* **encrypt:** list --encrypt in --help now encryption is on by defau… ([#250](https://github.com/devicecloud-dev/dcd-cli/issues/250)) ([55a0609](https://github.com/devicecloud-dev/dcd-cli/commit/55a0609abbc686021fe969c523bcaa1efa994658)) +* **encrypt:** list --encrypt in --help now encryption is on by default (dcd[#1138](https://github.com/devicecloud-dev/dcd-cli/issues/1138)) ([55a0609](https://github.com/devicecloud-dev/dcd-cli/commit/55a0609abbc686021fe969c523bcaa1efa994658)) +* fetch the KEK public key from the API instead of pinning it ([#251](https://github.com/devicecloud-dev/dcd-cli/issues/251)) ([452324b](https://github.com/devicecloud-dev/dcd-cli/commit/452324ba6f6313fe9e4d418c3db655e858f79540)) + + +### Miscellaneous + +* pin the 5.10.0 promotion ([d85115c](https://github.com/devicecloud-dev/dcd-cli/commit/d85115c1efc1da80b7eaeb9dacca49dd0d37257b)) + +## [5.9.0](https://github.com/devicecloud-dev/dcd-cli/compare/v5.8.0...v5.9.0) (2026-10-22) + + +### Features + +* **auth:** fetch the Supabase key from the API instead of compiling it in ([#245](https://github.com/devicecloud-dev/dcd-cli/issues/245)) ([8cedb4e](https://github.com/devicecloud-dev/dcd-cli/commit/8cedb4e2f133f4300b669f877896e65c61c38820)) +* **login:** reuse the last org and add --org ([#243](https://github.com/devicecloud-dev/dcd-cli/issues/243)) ([5f710d5](https://github.com/devicecloud-dev/dcd-cli/commit/5f710d55d024c6869d529c606f261530ac9013ad)) + + +### Dependencies + +* bump the minor-and-patch group across 1 directory with 5 updates ([#238](https://github.com/devicecloud-dev/dcd-cli/issues/238)) ([5fffd7a](https://github.com/devicecloud-dev/dcd-cli/commit/5fffd7ae00e2e6749271ae859c6cf6c47d98678f)) + + +### Miscellaneous + +* pin the 5.9.0 promotion ([57cf6b1](https://github.com/devicecloud-dev/dcd-cli/commit/57cf6b1fc0cb74ac4fe9f30b6659095d4321fbfc)) + +## [5.8.0](https://github.com/devicecloud-dev/dcd-cli/compare/v5.7.1...v5.8.0) (2026-10-16) + + +### Features + +* **cloud:** add --priority flag for within-org run ordering ([#222](https://github.com/devicecloud-dev/dcd-cli/issues/222)) ([510d64b](https://github.com/devicecloud-dev/dcd-cli/commit/510d64baa34e3f7f19c5483f693aced20c0f0758)) +* **cloud:** add --priority flag for within-org run ordering (dcd[#1581](https://github.com/devicecloud-dev/dcd-cli/issues/1581)) ([510d64b](https://github.com/devicecloud-dev/dcd-cli/commit/510d64baa34e3f7f19c5483f693aced20c0f0758)) + + +### Bug Fixes + +* **cloud:** re-send a flow submit whose response was lost ([#226](https://github.com/devicecloud-dev/dcd-cli/issues/226)) ([f4db8f9](https://github.com/devicecloud-dev/dcd-cli/commit/f4db8f9d0b51d358b62153f40c2164abc67cb078)) +* **device:** drop Android API 29-31 from the --android-api-level help ([#227](https://github.com/devicecloud-dev/dcd-cli/issues/227)) ([05ea2dc](https://github.com/devicecloud-dev/dcd-cli/commit/05ea2dc292b1c8d2d453a4ec05b3c34577cf2c61)) +* **validation:** mirror the API's Pixel 7 / API 36 Android default ([#225](https://github.com/devicecloud-dev/dcd-cli/issues/225)) ([3daeb94](https://github.com/devicecloud-dev/dcd-cli/commit/3daeb94e077a3819f4568c74cc5059bd8ac9ba9b)) + + +### Performance + +* **cloud:** poll results with ?view=summary and If-None-Match ([#219](https://github.com/devicecloud-dev/dcd-cli/issues/219)) ([e916c59](https://github.com/devicecloud-dev/dcd-cli/commit/e916c59631ee10aee0c315af016582747afa0b81)) + + +### Dependencies + +* bump the minor-and-patch group with 6 updates ([#220](https://github.com/devicecloud-dev/dcd-cli/issues/220)) ([2b88b64](https://github.com/devicecloud-dev/dcd-cli/commit/2b88b6489d77591f3f47b00c980e11d5e6cc2e27)) + ## [5.7.1](https://github.com/devicecloud-dev/dcd-cli/compare/v5.7.0...v5.7.1) (2026-10-07) diff --git a/CLAUDE.md b/CLAUDE.md index 0e91224..dce73de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ Top-level `defineCommand` in `src/index.ts` wires eleven subcommands (`cloud`, ` **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`. +**Config store.** `dcd login` writes `$XDG_CONFIG_HOME/dcd/config.json` (fallback `~/.dcd/config.json`, 0600). Shape: `{ version, env, api_url, supabase_url, session: { access_token, refresh_token, expires_at, user_email, user_id }, current_org_id, current_org_name }`. `DCD_CONFIG_DIR` overrides the directory (used by tests). The login command itself (`src/commands/login.ts`) uses PKCE (S256) with a server rendezvous — no loopback server: it mints `state`, `code_verifier`, and `code_challenge`, opens `/cli-login?state=...&code_challenge=...`, then polls the dcd API's `POST /cli-login/claim` with `{state, code_verifier}` while the frontend POSTs proof of identity (the browser's access token) to `POST /cli-login/handoff`; the API verifies the token, **mints a dedicated Supabase session for the CLI** (its own refresh-token family — sharing the browser's tokens caused "Invalid Refresh Token: Already Used" whenever either client rotated them), stores it keyed by state, then on claim verifies `sha256(verifier) === challenge` and returns it. After claiming, the CLI fetches `/me/orgs` and picks the org with `selectOrg` (`src/utils/orgs.ts`): `--org ` if given, else the only org, else the picker `dcd switch-org` uses, starting on the last login's org. That org only counts when the stored `api_url` matches, because org ids are per environment. Without a TTY (`canPrompt` in `src/utils/ci.ts`) the prompts can't be answered: the "already logged in" confirm takes its default and signs in again, the last login's org is used, and with none the command fails pointing at `--org`. `dcd logout` deletes the whole config, so nothing is remembered after it. The frontend lives in `../dcd/frontend/app/features/cli-login/CliLoginScreen.tsx`. **Cross-repo auth surface.** The dcd API's `ApiKeyGuard` accepts either `x-app-api-key` (existing) or `Authorization: Bearer ` + `x-dcd-org: `. For Bearer it verifies the JWT, checks `user_org_profile` membership, and injects the org's api_key back into the request headers so existing `@Headers(APP_API_KEY_HEADER)` controller code keeps working unchanged. `dcd switch-org` calls `GET /me/orgs`, a JWT-only endpoint at `../dcd/api/src/apps/me/me.controller.ts`. diff --git a/package.json b/package.json index 462a3a2..375f785 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "chai": "^6.2.2", "eslint": "^10.5.0", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-unicorn": "^77.0.0", + "eslint-plugin-unicorn": "^78.0.0", "husky": "^9.1.7", "mocha": "^12.0.0", "prettier": "^3.8.4", @@ -68,7 +68,7 @@ "test:unit": "node scripts/test-runner.mjs --unit", "typecheck": "tsc --noEmit -p tsconfig.test.json" }, - "version": "5.7.1", + "version": "5.10.0", "bugs": { "url": "https://discord.gg/gm3mJwcNw8" }, @@ -110,7 +110,7 @@ "esbuild@<0.28.1": ">=0.28.1", "qs@<6.16.0": "6.16.0", "fast-uri@>=3.0.0 <3.1.8": "3.1.8", - "hono@>=4.0.0 <4.13.7": "4.13.7", + "hono@>=4.0.0 <4.13.11": "4.13.11", "source-map-js@<1.2.2": "1.2.2", "micromatch>picomatch": "^2.3.2", "tinyglobby>picomatch": "^4.0.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84e357c..c0d2354 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,7 +31,7 @@ overrides: esbuild@<0.28.1: '>=0.28.1' qs@<6.16.0: 6.16.0 fast-uri@>=3.0.0 <3.1.8: 3.1.8 - hono@>=4.0.0 <4.13.7: 4.13.7 + hono@>=4.0.0 <4.13.11: 4.13.11 source-map-js@<1.2.2: 1.2.2 micromatch>picomatch: ^2.3.2 tinyglobby>picomatch: ^4.0.4 @@ -45,10 +45,10 @@ importers: version: 1.8.1 '@modelcontextprotocol/sdk': specifier: ^1.31.0 - version: 1.32.1(zod@4.6.5) + version: 1.32.1(zod@4.6.7) '@supabase/supabase-js': specifier: ^2.108.2 - version: 2.117.3 + version: 2.118.0 bplist-parser: specifier: ^0.5.0 version: 0.5.0 @@ -60,7 +60,7 @@ importers: version: 0.2.2 js-yaml: specifier: ^5.2.2 - version: 5.4.3 + version: 5.5.0 node-stream-zip: specifier: ^1.15.0 version: 1.16.0 @@ -78,11 +78,11 @@ importers: version: 3.3.1 zod: specifier: ^4.4.3 - version: 4.6.5 + version: 4.6.7 devDependencies: '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.12.0) + version: 10.0.1(eslint@10.13.0) '@types/chai': specifier: ^5.2.3 version: 5.2.3 @@ -103,13 +103,13 @@ importers: version: 6.3.0 eslint: specifier: ^10.5.0 - version: 10.12.0 + version: 10.13.0 eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.12.0) + version: 10.1.8(eslint@10.13.0) eslint-plugin-unicorn: - specifier: ^77.0.0 - version: 77.0.0(eslint@10.12.0) + specifier: ^78.0.0 + version: 78.0.0(eslint@10.13.0)(typescript@6.0.3) husky: specifier: ^9.1.7 version: 9.1.7 @@ -130,7 +130,7 @@ importers: version: 6.0.3 typescript-eslint: specifier: ^8.61.1 - version: 8.71.1(eslint@10.12.0)(typescript@6.0.3) + version: 8.71.2(eslint@10.13.0)(typescript@6.0.3) packages: @@ -326,8 +326,8 @@ packages: resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/css-tree@4.1.1': - resolution: {integrity: sha512-A+s89eP+yDAPkKjnIOfjlkGifg5J7GPn01nWx14UjBVWktfkxOU4975F9ZZ13xNxkGiblNcBaAqgKhnQrW6SWA==} + '@eslint/css-tree@4.1.2': + resolution: {integrity: sha512-rVcxLmwSkEL3aWS3vAgsTRrQHoYE1b0XcgDtKHDJ92XOkyUpcbS/g0tUJKhvCtlEhYzgKBWsgDd+Z/aPhd166w==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/js@10.0.1': @@ -351,7 +351,7 @@ packages: resolution: {integrity: sha512-2g1qeHl4BduwDn6L8nkAvDrPUWPMlkQ//a5NhP/zIyaXKMjZNRV+cG9Csu+BiJFf6+68pqzjf4Hi5sJGKHuS1g==} engines: {node: '>=20'} peerDependencies: - hono: 4.13.7 + hono: 4.13.11 '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} @@ -408,31 +408,31 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@supabase/auth-js@2.117.3': - resolution: {integrity: sha512-XOZV+jllcU2Hzij8SwgM78zl0lOEr6BTjzi2MYkBb39nOAQzb5AelGpMUvVj7lwGSB11JmpAs/FEQroAyFzWEA==} + '@supabase/auth-js@2.118.0': + resolution: {integrity: sha512-xWfPXyhS2KDSfAneMtI6p/tBnbdiz/5vaRKXEqUfzfC9WCniKr2Ds5o5G7RIGig0bom4z6wUY9zaV07WDVTfnQ==} engines: {node: '>=22.0.0'} - '@supabase/functions-js@2.117.3': - resolution: {integrity: sha512-6GuizbP4N5xWaxEVUsI+UQpJdmMmn0t3sYNpObo9gCAldvM22gFjnYzRVmkHxp5ylU21azEYmiWVwk9iDgCMkQ==} + '@supabase/functions-js@2.118.0': + resolution: {integrity: sha512-4ofletPa/noKAU6KHeqsd3FztGzRPUEJRey1MKY0o+PIHaVePqRPShIXtl37gcVyIWaFM+zNg6r9zh0uUSFOSA==} engines: {node: '>=22.0.0'} '@supabase/phoenix@0.4.5': resolution: {integrity: sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==} - '@supabase/postgrest-js@2.117.3': - resolution: {integrity: sha512-NpyTgullu6tw11ojYKTdZg9HannZ4mu4FUlhc9V5zDXOVllUJzEmuyLM+WWoYF0BurfrRvarimpm3SR8P0Gzfw==} + '@supabase/postgrest-js@2.118.0': + resolution: {integrity: sha512-JXy+37KPApHsyotaQACovv/Jy9E22rfiWLRuT+2UjoEAcAIelDFIrsF/FY47Dy19mO7lI0Tq+zDCX8oJ2Ozfiw==} engines: {node: '>=22.0.0'} - '@supabase/realtime-js@2.117.3': - resolution: {integrity: sha512-4W4ISbreRygNXbhwqxeqyuRul8RPx+SF+Hp2IT8wctebQEHSVCULfKatiRiEhmasfoXvYm2AHV4XFsOxhoousA==} + '@supabase/realtime-js@2.118.0': + resolution: {integrity: sha512-pzCIEWjqD5/xqEZQerpt+q9rsgYkcrkgCJgf0W2ULqC4fYUeB/p9CFa3t9vn+IVh6dMN9r4ZCE262lZvUC+Bfw==} engines: {node: '>=22.0.0'} - '@supabase/storage-js@2.117.3': - resolution: {integrity: sha512-QhUxFC33Crlw6WDeA4hLADdi1PzjI3c4UJs7ivWHHEnU7ulmCWEZSDaRCuIfmsjUwbHYaIfRNFQNsm+IsGYS6w==} + '@supabase/storage-js@2.118.0': + resolution: {integrity: sha512-5MNkKt2Moy7Jud/sPX7/28BwdDxZMCVHGJT00b8op3KRa9LKSjOAYU5IcfGWIstaasAqY8GkbA6oI3IRDgB1YQ==} engines: {node: '>=22.0.0'} - '@supabase/supabase-js@2.117.3': - resolution: {integrity: sha512-K+f8PimXDunPWQ63CKRXaF5pbVPJxmfw5ryAnh0s9S9PkwcuqBjiSpIaqpF7KLDljeYwXIL85ST8QxaO0+XuvA==} + '@supabase/supabase-js@2.118.0': + resolution: {integrity: sha512-FxI8UXMVC2UG7IkeiI8fuQZqq2oI3JeSuN8MPIC2RLVaoEAxKS0nqE6kii9Ewpq8f9gNPM6git1Y8f0vN/QLFw==} engines: {node: '>=22.0.0'} peerDependencies: '@opentelemetry/api': '>=1.0.0' @@ -443,6 +443,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -461,69 +464,72 @@ packages: '@types/mocha@10.0.10': resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@26.6.5': resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==} '@types/yazl@3.3.1': resolution: {integrity: sha512-DIWfCKpsTp6hE5BDBHV3+fIL/bLUF9Bv13iDrWnMlmhQpH67buNvI291ZauQ1xcccxK3FqQ9honnXpq4R8NMuQ==} - '@typescript-eslint/eslint-plugin@8.71.1': - resolution: {integrity: sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==} + '@typescript-eslint/eslint-plugin@8.71.2': + resolution: {integrity: sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.71.1 + '@typescript-eslint/parser': ^8.71.2 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.71.1': - resolution: {integrity: sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==} + '@typescript-eslint/parser@8.71.2': + resolution: {integrity: sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.71.1': - resolution: {integrity: sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==} + '@typescript-eslint/project-service@8.71.2': + resolution: {integrity: sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.71.1': - resolution: {integrity: sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==} + '@typescript-eslint/scope-manager@8.71.2': + resolution: {integrity: sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.71.1': - resolution: {integrity: sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==} + '@typescript-eslint/tsconfig-utils@8.71.2': + resolution: {integrity: sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.71.1': - resolution: {integrity: sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==} + '@typescript-eslint/type-utils@8.71.2': + resolution: {integrity: sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.71.1': - resolution: {integrity: sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==} + '@typescript-eslint/types@8.71.2': + resolution: {integrity: sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.71.1': - resolution: {integrity: sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==} + '@typescript-eslint/typescript-estree@8.71.2': + resolution: {integrity: sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.71.1': - resolution: {integrity: sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==} + '@typescript-eslint/utils@8.71.2': + resolution: {integrity: sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.71.1': - resolution: {integrity: sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==} + '@typescript-eslint/visitor-keys@8.71.2': + resolution: {integrity: sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@xmldom/xmldom@0.9.12': @@ -539,8 +545,8 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.18.0: - resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + acorn@8.19.0: + resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} engines: {node: '>=0.4.0'} hasBin: true @@ -558,8 +564,8 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + argparse@3.0.3: + resolution: {integrity: sha512-Sn98ftV3KfsO8xfLPHG6oT8y+b0yvbrLgZjgPDXsharNY15tTAjqIsIju4S32nl6/iEUNNE4v+yL2Zhm8ooP3w==} assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} @@ -569,8 +575,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.11.28: - resolution: {integrity: sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==} + baseline-browser-mapping@2.11.29: + resolution: {integrity: sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==} engines: {node: '>=6.0.0'} hasBin: true @@ -624,8 +630,8 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - caniuse-lite@1.0.30001815: - resolution: {integrity: sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==} + caniuse-lite@1.0.30001816: + resolution: {integrity: sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==} chai@6.3.0: resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} @@ -638,6 +644,9 @@ packages: change-case@5.4.4: resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -704,6 +713,9 @@ packages: supports-color: optional: true + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -711,10 +723,17 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-indent@7.0.2: resolution: {integrity: sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==} engines: {node: '>=12.20'} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff@8.0.3: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} @@ -726,8 +745,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.452: - resolution: {integrity: sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==} + electron-to-chromium@1.5.458: + resolution: {integrity: sha512-/hIH2gRSmxrf4/X4Nw/t/Wc8PVS22l8/3HnWjWTWsTOM5dsnMUPZF/+u+yr3LqweNuTBJE1rq6qAQzKkZVwMfA==} encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} @@ -774,11 +793,15 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-plugin-unicorn@77.0.0: - resolution: {integrity: sha512-aayznMCKTXlPAsoyUjRok862sz3BJAfxpQX/t79AT/uVQTmWsCx3FXfXo+9hfhL8aR2mMZQYyXoDZo3/ly6lJw==} + eslint-plugin-unicorn@78.0.0: + resolution: {integrity: sha512-XTDqxcJeAmh2lhgU75TpuMDdFtPDnAU+DCW56nR5UzXTG+uOyvOGjV80O11my7kNb2bYSCPlWa8c4K8/rwlYog==} engines: {node: '>=22'} peerDependencies: eslint: '>=10.4' + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true eslint-scope@9.1.2: resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} @@ -792,8 +815,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.12.0: - resolution: {integrity: sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==} + eslint@10.13.0: + resolution: {integrity: sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -977,8 +1000,8 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} - hono@4.13.7: - resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} + hono@4.13.11: + resolution: {integrity: sha512-/SMX/RQNJn7oNmFwH6DtwDqcrzZU2otm1FD6OJe2cWF6cfy/F8hq0XVBv7xW3FTJshRQwuBnXHDq2kNsdZnshg==} engines: {node: '>=16.9.0'} hookified@1.15.1: @@ -1031,8 +1054,8 @@ packages: resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} engines: {node: '>= 0.10'} - ip-address@10.7.3: - resolution: {integrity: sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==} + ip-address@10.7.4: + resolution: {integrity: sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -1087,8 +1110,8 @@ packages: js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} - js-yaml@5.4.3: - resolution: {integrity: sha512-yzzNtgczwgoVHNigCyKTXcQJU4Y8t/ikU4UxXW0BvrLfHTGWZhsNcOT2s/a7vn81/4KqVnNmHjaUEPlvWnNwVw==} + js-yaml@5.5.0: + resolution: {integrity: sha512-paNr4LFIFPjeeSmwNYdrb8FafLLG/Cr2Af3GjbsHJ5CxIjH4r0c2emlmRdAtpPAieP1gRVzk6buBbVP5gBMGAg==} hasBin: true jsesc@3.1.0: @@ -1151,8 +1174,8 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - mdn-data@2.35.0: - resolution: {integrity: sha512-k+1+dEIm2z/BEfdvYzT/fIyAGeDBo/1AJNJI4ilFXJRYoxP/3Ds+GP8shAOIq8jYXY9N5nGB3Ag104NsagmLAA==} + mdn-data@2.37.2: + resolution: {integrity: sha512-rz9YOBGItdSbe7HcSArUbhiAv4SB/4DKRYg96CSFS2CcIYMbUvBtZlzA1NCbk2AdgbmYwMqwNblLpR441lLeiQ==} media-typer@1.1.1: resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} @@ -1166,6 +1189,69 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + micromark-core-commonmark@2.0.4: + resolution: {integrity: sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.1.0: + resolution: {integrity: sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-edit-map@1.0.0: + resolution: {integrity: sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.3: + resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} + + micromark@4.0.3: + resolution: {integrity: sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -1215,8 +1301,8 @@ packages: nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} - node-releases@2.0.58: - resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==} + node-releases@2.0.59: + resolution: {integrity: sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==} engines: {node: '>=18'} node-stream-zip@1.16.0: @@ -1258,6 +1344,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -1544,8 +1633,8 @@ packages: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} - typescript-eslint@8.71.1: - resolution: {integrity: sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==} + typescript-eslint@8.71.2: + resolution: {integrity: sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -1623,8 +1712,8 @@ packages: peerDependencies: zod: ^3.25.28 || ^4 - zod@4.6.5: - resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} snapshots: @@ -1730,9 +1819,9 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@10.12.0)': + '@eslint-community/eslint-utils@4.10.1(eslint@10.13.0)': dependencies: - eslint: 10.12.0 + eslint: 10.13.0 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -1753,14 +1842,14 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/css-tree@4.1.1': + '@eslint/css-tree@4.1.2': dependencies: - mdn-data: 2.35.0 + mdn-data: 2.37.2 source-map-js: 1.2.2 - '@eslint/js@10.0.1(eslint@10.12.0)': + '@eslint/js@10.0.1(eslint@10.13.0)': optionalDependencies: - eslint: 10.12.0 + eslint: 10.13.0 '@eslint/object-schema@3.0.5': {} @@ -1769,9 +1858,9 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@hono/node-server@2.1.4(hono@4.13.7)': + '@hono/node-server@2.1.4(hono@4.13.11)': dependencies: - hono: 4.13.7 + hono: 4.13.11 '@humanfs/core@0.19.2': dependencies: @@ -1801,9 +1890,9 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@modelcontextprotocol/sdk@1.32.1(zod@4.6.5)': + '@modelcontextprotocol/sdk@1.32.1(zod@4.6.7)': dependencies: - '@hono/node-server': 2.1.4(hono@4.13.7) + '@hono/node-server': 2.1.4(hono@4.13.11) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -1813,13 +1902,13 @@ snapshots: eventsource-parser: 3.1.1 express: 5.3.0 express-rate-limit: 8.7.1(express@5.3.0) - hono: 4.13.7 + hono: 4.13.11 jose: 6.2.12 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 4.6.5 - zod-to-json-schema: 3.25.2(zod@4.6.5) + zod: 4.6.7 + zod-to-json-schema: 3.25.2(zod@4.6.7) transitivePeerDependencies: - supports-color @@ -1835,43 +1924,47 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@supabase/auth-js@2.117.3': + '@supabase/auth-js@2.118.0': dependencies: tslib: 2.8.1 - '@supabase/functions-js@2.117.3': + '@supabase/functions-js@2.118.0': dependencies: tslib: 2.8.1 '@supabase/phoenix@0.4.5': {} - '@supabase/postgrest-js@2.117.3': + '@supabase/postgrest-js@2.118.0': dependencies: tslib: 2.8.1 - '@supabase/realtime-js@2.117.3': + '@supabase/realtime-js@2.118.0': dependencies: '@supabase/phoenix': 0.4.5 tslib: 2.8.1 - '@supabase/storage-js@2.117.3': + '@supabase/storage-js@2.118.0': dependencies: iceberg-js: 0.8.1 tslib: 2.8.1 - '@supabase/supabase-js@2.117.3': + '@supabase/supabase-js@2.118.0': dependencies: - '@supabase/auth-js': 2.117.3 - '@supabase/functions-js': 2.117.3 - '@supabase/postgrest-js': 2.117.3 - '@supabase/realtime-js': 2.117.3 - '@supabase/storage-js': 2.117.3 + '@supabase/auth-js': 2.118.0 + '@supabase/functions-js': 2.118.0 + '@supabase/postgrest-js': 2.118.0 + '@supabase/realtime-js': 2.118.0 + '@supabase/storage-js': 2.118.0 '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} '@types/esrecurse@4.3.1': {} @@ -1884,6 +1977,8 @@ snapshots: '@types/mocha@10.0.10': {} + '@types/ms@2.1.0': {} + '@types/node@26.6.5': dependencies: undici-types: 8.9.0 @@ -1892,15 +1987,15 @@ snapshots: dependencies: '@types/node': 26.6.5 - '@typescript-eslint/eslint-plugin@8.71.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0)(typescript@6.0.3))(eslint@10.12.0)(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.71.2(@typescript-eslint/parser@8.71.2(eslint@10.13.0)(typescript@6.0.3))(eslint@10.13.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.71.1(eslint@10.12.0)(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.71.1 - '@typescript-eslint/type-utils': 8.71.1(eslint@10.12.0)(typescript@6.0.3) - '@typescript-eslint/utils': 8.71.1(eslint@10.12.0)(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.71.1 - eslint: 10.12.0 + '@typescript-eslint/parser': 8.71.2(eslint@10.13.0)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.71.2 + '@typescript-eslint/type-utils': 8.71.2(eslint@10.13.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.2(eslint@10.13.0)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.71.2 + eslint: 10.13.0 ignore: 7.0.12 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -1908,56 +2003,56 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.71.1(eslint@10.12.0)(typescript@6.0.3)': + '@typescript-eslint/parser@8.71.2(eslint@10.13.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.71.1 - '@typescript-eslint/types': 8.71.1 - '@typescript-eslint/typescript-estree': 8.71.1(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.71.1 + '@typescript-eslint/scope-manager': 8.71.2 + '@typescript-eslint/types': 8.71.2 + '@typescript-eslint/typescript-estree': 8.71.2(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.71.2 debug: 4.4.3(supports-color@8.1.1) - eslint: 10.12.0 + eslint: 10.13.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.71.1(typescript@6.0.3)': + '@typescript-eslint/project-service@8.71.2(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.71.1(typescript@6.0.3) - '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/tsconfig-utils': 8.71.2(typescript@6.0.3) + '@typescript-eslint/types': 8.71.2 debug: 4.4.3(supports-color@8.1.1) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.71.1': + '@typescript-eslint/scope-manager@8.71.2': dependencies: - '@typescript-eslint/types': 8.71.1 - '@typescript-eslint/visitor-keys': 8.71.1 + '@typescript-eslint/types': 8.71.2 + '@typescript-eslint/visitor-keys': 8.71.2 - '@typescript-eslint/tsconfig-utils@8.71.1(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.71.2(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.71.1(eslint@10.12.0)(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.71.2(eslint@10.13.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.71.1 - '@typescript-eslint/typescript-estree': 8.71.1(typescript@6.0.3) - '@typescript-eslint/utils': 8.71.1(eslint@10.12.0)(typescript@6.0.3) + '@typescript-eslint/types': 8.71.2 + '@typescript-eslint/typescript-estree': 8.71.2(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.2(eslint@10.13.0)(typescript@6.0.3) debug: 4.4.3(supports-color@8.1.1) - eslint: 10.12.0 + eslint: 10.13.0 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.71.1': {} + '@typescript-eslint/types@8.71.2': {} - '@typescript-eslint/typescript-estree@8.71.1(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.71.2(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.71.1(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.71.1(typescript@6.0.3) - '@typescript-eslint/types': 8.71.1 - '@typescript-eslint/visitor-keys': 8.71.1 + '@typescript-eslint/project-service': 8.71.2(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.71.2(typescript@6.0.3) + '@typescript-eslint/types': 8.71.2 + '@typescript-eslint/visitor-keys': 8.71.2 debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.3 semver: 7.8.5 @@ -1967,20 +2062,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.71.1(eslint@10.12.0)(typescript@6.0.3)': + '@typescript-eslint/utils@8.71.2(eslint@10.13.0)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0) - '@typescript-eslint/scope-manager': 8.71.1 - '@typescript-eslint/types': 8.71.1 - '@typescript-eslint/typescript-estree': 8.71.1(typescript@6.0.3) - eslint: 10.12.0 + '@eslint-community/eslint-utils': 4.10.1(eslint@10.13.0) + '@typescript-eslint/scope-manager': 8.71.2 + '@typescript-eslint/types': 8.71.2 + '@typescript-eslint/typescript-estree': 8.71.2(typescript@6.0.3) + eslint: 10.13.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.71.1': + '@typescript-eslint/visitor-keys@8.71.2': dependencies: - '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/types': 8.71.2 eslint-visitor-keys: 5.0.1 '@xmldom/xmldom@0.9.12': {} @@ -1990,11 +2085,11 @@ snapshots: mime-types: 3.0.2 negotiator: 1.1.0 - acorn-jsx@5.3.2(acorn@8.18.0): + acorn-jsx@5.3.2(acorn@8.19.0): dependencies: - acorn: 8.18.0 + acorn: 8.19.0 - acorn@8.18.0: {} + acorn@8.19.0: {} ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: @@ -2014,13 +2109,13 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - argparse@2.0.1: {} + argparse@3.0.3: {} assertion-error@2.0.1: {} balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.28: {} + baseline-browser-mapping@2.11.29: {} body-parser@2.3.0: dependencies: @@ -2050,10 +2145,10 @@ snapshots: browserslist@4.29.3: dependencies: - baseline-browser-mapping: 2.11.28 - caniuse-lite: 1.0.30001815 - electron-to-chromium: 1.5.452 - node-releases: 2.0.58 + baseline-browser-mapping: 2.11.29 + caniuse-lite: 1.0.30001816 + electron-to-chromium: 1.5.458 + node-releases: 2.0.59 update-browserslist-db: 1.3.4(browserslist@4.29.3) buffer-crc32@1.0.0: {} @@ -2082,7 +2177,7 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - caniuse-lite@1.0.30001815: {} + caniuse-lite@1.0.30001816: {} chai@6.3.0: {} @@ -2090,6 +2185,8 @@ snapshots: change-case@5.4.4: {} + character-entities@2.0.2: {} + chokidar@5.0.0: dependencies: readdirp: 5.1.1 @@ -2146,12 +2243,22 @@ snapshots: optionalDependencies: supports-color: 8.1.1 + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + deep-is@0.1.4: {} depd@2.0.0: {} + dequal@2.0.3: {} + detect-indent@7.0.2: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + diff@8.0.3: {} dunder-proto@1.0.1: @@ -2162,7 +2269,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.452: {} + electron-to-chromium@1.5.458: {} encodeurl@2.0.0: {} @@ -2215,26 +2322,28 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.12.0): + eslint-config-prettier@10.1.8(eslint@10.13.0): dependencies: - eslint: 10.12.0 + eslint: 10.13.0 - eslint-plugin-unicorn@77.0.0(eslint@10.12.0): + eslint-plugin-unicorn@78.0.0(eslint@10.13.0)(typescript@6.0.3): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0) - '@eslint/css-tree': 4.1.1 + '@eslint-community/eslint-utils': 4.10.1(eslint@10.13.0) + '@eslint/css-tree': 4.1.2 browserslist: 4.29.3 change-case: 5.4.4 ci-info: 4.4.0 core-js-compat: 3.50.0 detect-indent: 7.0.2 entities: 8.1.0 - eslint: 10.12.0 + eslint: 10.13.0 find-up-simple: 1.0.1 globals: 17.13.0 identifier-regex: 1.1.0 indent-string: 5.0.0 is-builtin-module: 5.0.0 + micromark: 4.0.3 + parse5: 8.0.1 pluralize: 8.0.0 quote-js-string: 0.1.0 regjsparser: 0.13.4 @@ -2242,6 +2351,10 @@ snapshots: semver: 7.8.5 strip-indent: 4.1.1 yaml: 2.9.1 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color eslint-scope@9.1.2: dependencies: @@ -2254,9 +2367,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.12.0: + eslint@10.13.0: dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.13.0) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5 '@eslint/config-helpers': 0.7.0 @@ -2291,8 +2404,8 @@ snapshots: espree@11.2.0: dependencies: - acorn: 8.18.0 - acorn-jsx: 5.3.2(acorn@8.18.0) + acorn: 8.19.0 + acorn-jsx: 5.3.2(acorn@8.19.0) eslint-visitor-keys: 5.0.1 esquery@1.7.0: @@ -2329,7 +2442,7 @@ snapshots: dependencies: debug: 4.4.3(supports-color@8.1.1) express: 5.3.0 - ip-address: 10.7.3 + ip-address: 10.7.4 transitivePeerDependencies: - supports-color @@ -2497,7 +2610,7 @@ snapshots: dependencies: function-bind: 1.1.2 - hono@4.13.7: {} + hono@4.13.11: {} hookified@1.15.1: {} @@ -2535,7 +2648,7 @@ snapshots: interpret@1.4.0: {} - ip-address@10.7.3: {} + ip-address@10.7.4: {} ipaddr.js@1.9.1: {} @@ -2571,9 +2684,9 @@ snapshots: js-base64@3.7.8: {} - js-yaml@5.4.3: + js-yaml@5.5.0: dependencies: - argparse: 2.0.1 + argparse: 3.0.3 jsesc@3.1.0: {} @@ -2628,7 +2741,7 @@ snapshots: math-intrinsics@1.1.0: {} - mdn-data@2.35.0: {} + mdn-data@2.37.2: {} media-typer@1.1.1: {} @@ -2636,6 +2749,138 @@ snapshots: merge2@1.4.1: {} + micromark-core-commonmark@2.0.4: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.1.0 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-edit-map: 1.0.0 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-space@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.3 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-edit-map@1.0.0: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.3: {} + + micromark@4.0.3: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3(supports-color@8.1.1) + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-edit-map: 1.0.0 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + transitivePeerDependencies: + - supports-color + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -2673,7 +2918,7 @@ snapshots: glob: 13.0.6 is-path-inside: 4.0.0 is-unicode-supported: 0.1.0 - js-yaml: 5.4.3 + js-yaml: 5.5.0 minimatch: 10.2.3 ms: 2.1.3 picocolors: 1.1.1 @@ -2692,7 +2937,7 @@ snapshots: nice-try@1.0.5: {} - node-releases@2.0.58: {} + node-releases@2.0.59: {} node-stream-zip@1.16.0: {} @@ -2731,6 +2976,10 @@ snapshots: dependencies: p-limit: 3.1.0 + parse5@8.0.1: + dependencies: + entities: 8.1.0 + parseurl@1.3.3: {} path-exists@4.0.0: {} @@ -3008,13 +3257,13 @@ snapshots: media-typer: 1.1.1 mime-types: 3.0.2 - typescript-eslint@8.71.1(eslint@10.12.0)(typescript@6.0.3): + typescript-eslint@8.71.2(eslint@10.13.0)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.71.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0)(typescript@6.0.3))(eslint@10.12.0)(typescript@6.0.3) - '@typescript-eslint/parser': 8.71.1(eslint@10.12.0)(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.71.1(typescript@6.0.3) - '@typescript-eslint/utils': 8.71.1(eslint@10.12.0)(typescript@6.0.3) - eslint: 10.12.0 + '@typescript-eslint/eslint-plugin': 8.71.2(@typescript-eslint/parser@8.71.2(eslint@10.13.0)(typescript@6.0.3))(eslint@10.13.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.71.2(eslint@10.13.0)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.71.2(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.2(eslint@10.13.0)(typescript@6.0.3) + eslint: 10.13.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -3068,8 +3317,8 @@ snapshots: yocto-queue@0.1.0: {} - zod-to-json-schema@3.25.2(zod@4.6.5): + zod-to-json-schema@3.25.2(zod@4.6.7): dependencies: - zod: 4.6.5 + zod: 4.6.7 - zod@4.6.5: {} + zod@4.6.7: {} diff --git a/server.json b/server.json index 1eee672..314da65 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "dev.devicecloud/dcd", "description": "Run Maestro mobile app tests on devicecloud.dev: submit flows, check status, download artifacts.", - "version": "5.7.1", + "version": "5.10.0", "repository": { "url": "https://github.com/devicecloud-dev/dcd-cli", "source": "github" @@ -11,7 +11,7 @@ { "registryType": "npm", "identifier": "@devicecloud.dev/dcd", - "version": "5.7.1", + "version": "5.10.0", "runtimeHint": "npx", "runtimeArguments": [ { @@ -48,6 +48,11 @@ "name": "DCD_API_URL", "description": "Override the API base URL (e.g. for a non-production environment).", "isRequired": false + }, + { + "name": "DCD_ENCRYPT", + "description": "Uploads (app binary, flows, env vars) are encrypted on your machine by default. Set to 0 to upload them unencrypted; a run's encrypt input overrides this.", + "isRequired": false } ] } diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index 1420b78..fc3f04f 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -199,7 +199,7 @@ export const cloudCommand = defineCommand({ // Client-side envelope encryption of every sensitive artifact: the // binary (#1138), the flow zip (#1151), and env vars (#1152). Kept as // given (--no-encrypt is an explicit false) and resolved once the - // compatibility data, which carries the org's default, is in. + // compatibility data, which carries the server default, is in. const encryptFlag = args.encrypt as boolean | undefined; const includeTags = coerceArray( collectRepeatedFlag(rawArgs, ['--include-tags']), @@ -390,7 +390,7 @@ export const cloudCommand = defineCommand({ out(`[DEBUG] API URL: ${apiUrl}`); } - const encryption = checkKekForEncryption( + const encryption = await checkKekForEncryption( resolveEncryption({ flag: encryptFlag, serverDefault: compatibilityData.encryption?.defaultOn, @@ -797,7 +797,7 @@ export const cloudCommand = defineCommand({ } if (encrypt && encryption.source === 'server') { - out(ui.note('Encrypting uploads (beta); opt out with --no-encrypt')); + out(ui.note('Encrypting uploads; opt out with --no-encrypt')); } if (!finalBinaryId) { diff --git a/src/commands/device.ts b/src/commands/device.ts index 556ecfd..669bef9 100644 --- a/src/commands/device.ts +++ b/src/commands/device.ts @@ -10,6 +10,7 @@ import type { AuthContext } from '../types/domain/auth.types.js'; import { type DeviceCatalogue, type DeviceCommandResult, + type DeviceHumanStatus, type DeviceInstalledApp, type DeviceRunResponse, type DeviceScreenElement, @@ -25,16 +26,20 @@ import { type DeviceArtifactFile, downloadArtifactFiles, fetchAllArtifacts, + pendingArtifacts, + pendingArtifactsWarning, planArtifactFiles, } from '../utils/device-artifacts.js'; import { CliError, logger, validateEnum } from '../utils/cli.js'; import { resolveApiUrl } from '../utils/config-store.js'; import { deviceProgress, + humanFields, printSession, resolveSessionId, runDeviceCommand, } from '../utils/device-output.js'; +import { parseRunReport, printRunResult, runFailureMessage } from '../utils/device-run.js'; import { isUrl } from '../utils/expo.js'; import { colors, formatUrl } from '../utils/styling.js'; import { ui } from '../utils/ui.js'; @@ -209,13 +214,33 @@ function parsePositiveInt(value: unknown, flag: string): number | undefined { return n; } +/** + * The platform a `--model` belongs to, from GET /device/models. Matches the + * model id (iphone-16) or its name (iPhone 16), ignoring case. Unknown, or on + * both platforms: a usage error that says to pass --platform. + */ +export function platformOfModel(catalogue: DeviceCatalogue, model: string): 'android' | 'ios' { + const wanted = model.trim().toLowerCase(); + const found = PLATFORMS.filter((p) => + (catalogue[p] ?? []).some((m) => m.model.toLowerCase() === wanted || m.name.toLowerCase() === wanted), + ); + if (found.length === 1) return found[0]; + if (found.length === 0) { + throw new CliError( + `Unknown model "${model}". See \`dcd device models\` for the list, or pass --platform.`, + ); + } + + throw new CliError(`"${model}" is a model on both iOS and Android. Pass --platform ios or --platform android.`); +} + const startSub = defineCommand({ meta: { name: 'start', description: 'Start a device session' }, args: { ...commonArgs, platform: { type: 'string', - description: 'ios or android. Optional with --app, which implies it', + description: 'ios or android. Optional with --model or --app, which imply it', }, model: { type: 'string', @@ -267,14 +292,19 @@ const startSub = defineCommand({ const binaryId = args['app-binary-id'] as string | undefined; if (app && binaryId) throw new CliError('Pass --app or --app-binary-id, not both.'); - const platform = - validateEnum(args.platform as string | undefined, PLATFORMS, 'platform') ?? - (app && !isUrl(app) ? platformOfAppFile(app) : undefined); + const model = args.model as string | undefined; + const appPlatform = app && !isUrl(app) ? platformOfAppFile(app) : undefined; + let platform = + validateEnum(args.platform as string | undefined, PLATFORMS, 'platform') ?? appPlatform; + if (!platform && !model) { + throw new CliError('Pass --platform ios or android, or a --model or --app that says which.'); + } + + const ctx = await context(args); if (!platform) { - throw new CliError('--platform is required (ios or android).'); + platform = platformOfModel(await DeviceGateway.models(ctx.apiUrl, ctx.auth), model!); } - const appPlatform = app && !isUrl(app) ? platformOfAppFile(app) : undefined; if (appPlatform && appPlatform !== platform) { throw new CliError(`${app} is a build for ${appPlatform}, not ${platform}.`); } @@ -283,7 +313,6 @@ const startSub = defineCommand({ throw new CliError('--google-play is only for Android.'); } - const ctx = await context(args); const resolved = app ? await resolveApp(ctx, app) : undefined; const uploadedId = binaryId ?? (resolved && 'binaryUploadId' in resolved ? resolved.binaryUploadId : undefined); @@ -292,7 +321,7 @@ const startSub = defineCommand({ platform, clientLabel: (args.name as string | undefined) || DEFAULT_CLIENT_LABEL, ...(uploadedId && { binaryUploadId: uploadedId }), - ...(args.model ? { model: args.model as string } : {}), + ...(model ? { model } : {}), ...(args.os ? { os: String(args.os) } : {}), ...(args['google-play'] ? { googlePlay: true } : {}), ...(args.locale ? { deviceLocale: args.locale as string } : {}), @@ -375,7 +404,7 @@ const listSub = defineCommand({ const ctx = await context(args); const { sessions } = await DeviceGateway.list(ctx.apiUrl, ctx.auth, Boolean(args.all)); return { - session: null, + session: undefined, data: { sessions }, print() { if (sessions.length === 0) { @@ -408,13 +437,28 @@ const statusSub = defineCommand({ await runDeviceCommand(json, async () => { const ctx = await context(args); const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id as string | undefined); - const { session } = await DeviceGateway.status(ctx.apiUrl, ctx.auth, id); + // The summary does not say whether a person handed the session back, + // so ask. An API without the route (or a failure) just leaves it out. + const [{ session }, human] = await Promise.all([ + DeviceGateway.status(ctx.apiUrl, ctx.auth, id), + DeviceGateway.human(ctx.apiUrl, ctx.auth, id).then( + (r) => r.human ?? null, + () => null, + ), + ]); return { session, - data: null, + data: human ? { human } : null, print() { logger.log(ui.section('Device session')); printSession(session); + if (human?.handed_back) { + logger.log(ui.info(`A person handed it back${human.note ? `: ${human.note}` : ' (no note).'}`)); + } + + if (human?.human_in_control) { + logger.log(ui.warn(`A person has control. Wait with \`dcd device human ${session.id} --wait\`.`)); + } }, }; }); @@ -441,7 +485,7 @@ const stopSub = defineCommand({ } return { - session: null, + session: undefined, data: { stopped }, print() { logger.log( @@ -616,7 +660,7 @@ const modelsSub = defineCommand({ const catalogue = await DeviceGateway.models(ctx.apiUrl, ctx.auth); const data: Partial = platform ? { [platform]: catalogue[platform] } : catalogue; return { - session: null, + session: undefined, data, print() { for (const p of PLATFORMS) { @@ -832,19 +876,10 @@ const runSub = defineCommand({ session: response.session, data: { command_id: response.command_id, status: response.status, result }, ...(!passed && { - failure: { code: null, message: result.error || 'The flow failed.' }, + failure: { code: null, message: runFailureMessage(result, parseRunReport(result)) }, }), print() { - logger.log(passed ? ui.success('Flow passed') : `${ui.statusSymbol('FAILED')} Flow failed`); - if (result.output) { - logger.log(ui.section('Output')); - logger.log(result.output); - } - - if (result.error) { - logger.log(ui.section('Error')); - logger.log(colors.error(result.error)); - } + printRunResult(logger.log, passed, result, entry ?? (flow ? path.basename(flow) : 'flow')); }, }; }); @@ -997,11 +1032,125 @@ const openSub = defineCommand({ }, }); +const HUMAN_POLL_MS = 5000; + +/** The session is with a person: they were asked for help, or took control. */ +function withPerson(human: DeviceHumanStatus): boolean { + return human.waiting || human.human_in_control; +} + +type HumanResponse = { human: DeviceHumanStatus; session: DeviceSession }; + +/** + * Poll GET .../human until the person hands the session back (or gives up + * control). A session that ends meanwhile is exit 4. Ctrl-C stops waiting + * and leaves the session as it is. + */ +async function waitForHandBack(ctx: Ctx, id: string, first: HumanResponse): Promise { + let current = first; + if (!withPerson(current.human)) return current; + ctx.progress(ui.running(`Waiting for a person to hand ${id} back. Ctrl-C stops waiting; the session stays open.`)); + for (;;) { + if (ENDED_STATES.includes(current.session.state)) { + throw new DeviceApiError( + `Device session ${id} ended while waiting for a person (${current.session.release_reason ?? current.session.state}).`, + 410, + 'LIVE_SESSION_ENDED', + current.session, + ); + } + + if (!withPerson(current.human)) return current; + await sleep(HUMAN_POLL_MS); + current = await DeviceGateway.human(ctx.apiUrl, await freshAuth(ctx), id); + } +} + +/** A person's part in a session, for `human` and `request-human`. */ +function printHuman(id: string, human: DeviceHumanStatus, waited: boolean): void { + if (waited && human.handed_back) logger.log(ui.success(`${id} was handed back`)); + logger.log(ui.section('Human help')); + logger.log(ui.branch(ui.fields(humanFields(human)))); + if (!waited && withPerson(human)) { + logger.log(ui.note(`Wait for the hand-back with \`dcd device human ${id} --wait\`.`)); + } +} + +const waitForPersonArg = { + type: 'boolean', + default: false, + description: `Wait until the person hands the session back (checks every ${HUMAN_POLL_MS / 1000}s; Ctrl-C stops waiting)`, +} as const; + +const requestHumanSub = defineCommand({ + meta: { + name: 'request-human', + description: 'Ask a person for help (a 2FA code, a CAPTCHA): the session waits for them to hand it back', + }, + args: { + ...commonArgs, + reason: { + type: 'positional', + required: true, + description: 'What the person should do, e.g. "Enter the 2FA code sent to the test phone"', + }, + id: idArg, + wait: waitForPersonArg, + }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + const reason = String(args.reason ?? '').trim(); + if (!reason) throw new CliError('Say what the person should do, e.g. dcd device request-human "Enter the 2FA code".'); + if (reason.length > 500) throw new CliError('The reason is too long: keep it to 500 characters.'); + const ctx = await context(args); + const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id as string | undefined); + let response = await DeviceGateway.requestHuman(ctx.apiUrl, ctx.auth, id, reason); + ctx.progress(ui.success(`Asked for a person on ${id}`)); + if (args.wait) response = await waitForHandBack(ctx, id, response); + const { human, session } = response; + return { + session, + data: { human }, + print() { + printHuman(session.id, human, Boolean(args.wait)); + }, + }; + }); + }, +}); + +const humanSub = defineCommand({ + meta: { name: 'human', description: 'Whether a session waits for a person, and their note when they hand it back' }, + args: { ...commonArgs, id: idArg, wait: waitForPersonArg }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + const ctx = await context(args); + const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id as string | undefined); + let response = await DeviceGateway.human(ctx.apiUrl, ctx.auth, id); + if (args.wait) response = await waitForHandBack(ctx, id, response); + const { human, session } = response; + return { + session, + data: { human }, + print() { + printHuman(session.id, human, Boolean(args.wait)); + }, + }; + }); + }, +}); + interface ArtifactsData { /** Where they were saved; null with --links. */ dir: null | string; files: DeviceArtifactFile[]; failed: { file: string; reason: string }[]; + /** What is still uploading after the session ended; see pendingArtifacts. */ + pending: ('logs' | 'recording')[]; + /** Says so in words, or null. */ + warning: null | string; } const artifactsSub = defineCommand({ @@ -1030,19 +1179,26 @@ const artifactsSub = defineCommand({ const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id as string | undefined); const { pages, session } = await fetchAllArtifacts(ctx.apiUrl, ctx.auth, id); const files = planArtifactFiles(pages); + const pending = pendingArtifacts(pages); + const warning = pendingArtifactsWarning(pending); + const printWarning = () => { + if (warning) logger.log(ui.warn(warning)); + }; if (args.links) { return { session, - data: { dir: null, files, failed: [] }, + data: { dir: null, files, failed: [], pending, warning }, print() { if (files.length === 0) { logger.log(ui.info('This session has no artifacts yet.')); + printWarning(); return; } logger.log(ui.section(`Artifacts (${files.length})`)); logger.log(ui.branch(ui.fields(files.map((f) => [f.file, f.url ? formatUrl(f.url) : colors.dim('no link')])))); + printWarning(); }, }; } @@ -1053,7 +1209,7 @@ const artifactsSub = defineCommand({ const saved = files.length - failed.length; return { session, - data: { dir, files, failed }, + data: { dir, files, failed, pending, warning }, ...(failed.length > 0 && { failure: { code: null, @@ -1063,11 +1219,13 @@ const artifactsSub = defineCommand({ print() { if (files.length === 0) { logger.log(ui.info('This session has no artifacts yet.')); + printWarning(); return; } logger.log(ui.success(`Saved ${saved} of ${files.length} files to ${dir}`)); for (const f of failed) logger.log(ui.warn(`${f.file}: ${f.reason}`)); + printWarning(); if (!ENDED_STATES.includes(session.state)) { logger.log(ui.note('The session is still open: its video and log are saved when it ends.')); } @@ -1099,6 +1257,8 @@ export const deviceCommand = defineCommand({ logs: logsSub, open: openSub, artifacts: artifactsSub, + 'request-human': requestHumanSub, + human: humanSub, }, }); diff --git a/src/commands/login.ts b/src/commands/login.ts index 18ba5fc..611eef6 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -18,9 +18,12 @@ * 5. Meanwhile, the CLI polls POST /cli-login/claim with {state, code_verifier}. * Once the api has the row, it verifies sha256(verifier) === challenge, * deletes the row, and returns the session. - * 6. CLI fetches /me/orgs with the fresh Bearer token and prompts the user to - * pick an org. Org selection lives here, not in the web UI — that way the - * CLI login and `dcd switch-org` share one picker. + * 6. CLI fetches /me/orgs with the fresh Bearer token and picks the org: + * `--org` if given, else the only one, else a picker that starts on the + * last login's org. Without a TTY nobody can answer the picker, so the + * last login's org is used, or the command fails pointing at `--org`. + * Org selection lives here, not in the web UI — that way the CLI login + * and `dcd switch-org` share one picker. * * Why PKCE + server rendezvous (instead of a localhost loopback): Safari * blocks HTTPS → HTTP fetches to 127.0.0.1 as mixed content, and a loopback @@ -32,12 +35,19 @@ import * as p from '@clack/prompts'; import { defineCommand } from 'citty'; import { createHash, randomBytes } from 'node:crypto'; -import { ENVIRONMENTS, inferEnvFromApiUrl, resolveFrontendUrl } from '../config/environments.js'; +import { + type DcdEnvName, + ENVIRONMENTS, + inferEnvFromApiUrl, + resolveFrontendUrl, +} from '../config/environments.js'; +import { getSupabaseKey } from '../config/supabase-key.js'; import { jsonFlag } from '../config/flags/output.flags.js'; import { openBrowser } from '../utils/browser.js'; +import { canPrompt } from '../utils/ci.js'; import { CliError, logger } from '../utils/cli.js'; import { readConfig, writeConfig } from '../utils/config-store.js'; -import { fetchOrgs, pickOrg } from '../utils/orgs.js'; +import { fetchOrgs, selectOrg } from '../utils/orgs.js'; import { colors } from '../utils/styling.js'; import { ui } from '../utils/ui.js'; @@ -77,6 +87,10 @@ export const loginCommand = defineCommand({ default: true, description: 'Open the login URL in a browser (pass --no-browser to just print it)', }, + org: { + type: 'string', + description: 'Organization to use, by name or id (skips the picker)', + }, json: jsonFlag, }, async run({ args }) { @@ -84,6 +98,12 @@ export const loginCommand = defineCommand({ const frontendOverride = args['frontend-url'] as string | undefined; const noBrowser = args.browser === false; const json = Boolean(args.json); + // citty gives '' for a bare `--org`. Refuse it, and an empty `--org "$ORG"`, + // rather than fall back to an org the caller didn't ask for. + const requestedOrg = (args.org as string | undefined)?.trim(); + if (requestedOrg === '') { + throw new CliError('--org needs an organization name or id.'); + } // Login is interactive, so under --json the human rows and prompts move to // stderr (the user still needs the URL) and stdout gets only the result. const say = (m: string) => { @@ -91,6 +111,9 @@ export const loginCommand = defineCommand({ else logger.log(m); }; const promptOutput = json ? process.stderr : undefined; + // Without a TTY (a script, CI, a shell an agent runs) nobody can answer a + // prompt, so each one below falls back to the answer Enter would give. + const interactive = canPrompt(promptOutput); // If there's an existing stored session, make the user confirm before we // overwrite it. Silent clobber is fine for power users but surprising if @@ -107,6 +130,10 @@ export const loginCommand = defineCommand({ `Your session for ${existing.session.user_email} has expired — signing in again.`, ), ); + } else if (!interactive) { + // The confirm below defaults to yes. The old session stays until the + // new login succeeds, so this replaces nothing if it never completes. + say(ui.info(`Already logged in as ${existing.session.user_email} — signing in again.`)); } else { const currentOrg = existing.current_org_name ?? existing.current_org_id; const ok = await p.confirm({ @@ -179,18 +206,28 @@ export const loginCommand = defineCommand({ // rejects it, the token itself is bad; if Supabase accepts, the // api's JwtService is misconfigured. const iss = decodeJwtIssuer(payload.access_token); - const supabaseUser = await probeSupabaseUser( - iss, - ENVIRONMENTS[env].supabase.anonKey, - payload.access_token, - ); + const supabaseUser = await probeSupabaseUser(iss, env, payload.access_token); const parts = [ iss ? `token iss: ${iss}` : null, `supabase /auth/v1/user: ${supabaseUser}`, ].filter(Boolean); throw new CliError(`${(err as Error).message} [${parts.join(' | ')}]`); } - const chosen = await pickOrg(orgs, undefined, promptOutput); + const { org: chosen, fromLastLogin } = await selectOrg(orgs, { + requested: requestedOrg, + // Org ids are per environment (dev org 1 isn't prod org 1), so the + // last login's org only counts if that login was to this same API. + lastOrgId: existing?.api_url === apiUrl ? existing.current_org_id : undefined, + interactive, + output: promptOutput, + }); + if (fromLastLogin) { + say( + ui.info( + `No terminal to pick an organization in, so using ${chosen.name} from your last login.`, + ), + ); + } writeConfig({ version: 1, @@ -284,7 +321,7 @@ function base64url(buf: Buffer): string { async function probeSupabaseUser( iss: string | null, - anonKey: string, + env: DcdEnvName, token: string, ): Promise { if (!iss) return 'skipped (no iss)'; @@ -292,7 +329,7 @@ async function probeSupabaseUser( const res = await fetch(`${iss}/user`, { headers: { authorization: `Bearer ${token}`, - apikey: anonKey, + apikey: await getSupabaseKey(env), }, }); const bodyText = await res.text().catch(() => ''); diff --git a/src/commands/logout.ts b/src/commands/logout.ts index 8072b31..be2a163 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -4,7 +4,6 @@ */ import { defineCommand } from 'citty'; -import { ENVIRONMENTS } from '../config/environments.js'; import { jsonFlag } from '../config/flags/output.flags.js'; import { CliAuthGateway } from '../gateways/cli-auth-gateway.js'; import { logger } from '../utils/cli.js'; @@ -33,8 +32,7 @@ export const logoutCommand = defineCommand({ return; } - const { anonKey } = ENVIRONMENTS[config.env].supabase; - await CliAuthGateway.signOut(config.supabase_url, anonKey, config.session); + await CliAuthGateway.signOut(config.supabase_url, config.env, config.session); clearConfig(); if (json) { logger.json({ status: 'SUCCESS', loggedOut: true, user: config.session.user_email }); diff --git a/src/commands/switch-org.ts b/src/commands/switch-org.ts index 87de101..60a86e5 100644 --- a/src/commands/switch-org.ts +++ b/src/commands/switch-org.ts @@ -3,15 +3,17 @@ * * With an argument (`dcd switch-org `): update config immediately after * confirming the user is a member via GET /me/orgs. - * Without an argument: fetch orgs and prompt the user to pick one. + * Without an argument: fetch orgs and prompt the user to pick one (an error + * instead when there's no TTY to answer the prompt). */ import { defineCommand } from 'citty'; import { jsonFlag } from '../config/flags/output.flags.js'; import { apiKeyOverride, resolveAuth } from '../utils/auth.js'; +import { canPrompt } from '../utils/ci.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'; +import { fetchOrgs, matchOrg, pickOrg, OrgListItem } from '../utils/orgs.js'; import { colors } from '../utils/styling.js'; import { ui } from '../utils/ui.js'; @@ -52,10 +54,17 @@ export const switchOrgCommand = defineCommand({ let chosen: OrgListItem; if (target) { - chosen = matchOrg(orgs, target); + chosen = matchOrg(orgs, target, '`dcd switch-org` without arguments'); } else { // Under --json the picker draws on stderr so stdout stays pure JSON. - chosen = await pickOrg(orgs, undefined, json ? process.stderr : undefined); + const output = json ? process.stderr : undefined; + // Without a TTY the picker would wait forever for a keypress. + if (orgs.length > 1 && !canPrompt(output)) { + throw new CliError( + `There's no terminal to pick an organization in. Run \`dcd switch-org \` with one of: ${orgs.map((o) => o.name).join(', ')}`, + ); + } + chosen = await pickOrg(orgs, { output }); } writeConfig({ @@ -88,27 +97,4 @@ export const switchOrgCommand = defineCommand({ }, }); -/** - * Match a user-supplied string to an org. Prefers case-insensitive name match. - * Falls back to slug / id so existing scripts keep working, but error output - * only surfaces names. - */ -function matchOrg(orgs: OrgListItem[], target: string): OrgListItem { - const needle = target.toLowerCase(); - const nameMatches = orgs.filter((o) => o.name.toLowerCase() === needle); - if (nameMatches.length > 1) { - throw new CliError( - `Multiple orgs named "${target}". Run \`dcd switch-org\` without arguments to pick interactively.`, - ); - } - const chosen = - nameMatches[0] ?? orgs.find((o) => o.slug === target || o.id === target); - if (!chosen) { - throw new CliError( - `No org named "${target}". Available: ${orgs.map((o) => o.name).join(', ')}`, - ); - } - return chosen; -} - export default switchOrgCommand; diff --git a/src/commands/upload.ts b/src/commands/upload.ts index c8c4955..ffa1359 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -48,7 +48,7 @@ export const uploadCommand = defineCommand({ const appUrl = args['app-url'] as string | undefined; const ignoreShaCheck = Boolean(args['ignore-sha-check']); // Kept as given, so --no-encrypt is an explicit false and a missing flag - // leaves DCD_ENCRYPT and then the org's default to decide. + // leaves DCD_ENCRYPT and then the server default to decide. const encryptFlag = args.encrypt as boolean | undefined; const debug = Boolean(args.debug); const positional = args.appFile as string | undefined; @@ -75,7 +75,7 @@ export const uploadCommand = defineCommand({ } } - const encryption = checkKekForEncryption( + const encryption = await checkKekForEncryption( resolveEncryption({ flag: encryptFlag, serverDefault }), apiUrl, ); @@ -121,7 +121,7 @@ export const uploadCommand = defineCommand({ ui.branch([ ...ui.fields([['file', colors.highlight(resolvedFile)]]), ...(encryption.enabled && encryption.source === 'server' - ? [ui.note('Encrypting uploads (beta); opt out with --no-encrypt')] + ? [ui.note('Encrypting uploads; opt out with --no-encrypt')] : []), ]), ); diff --git a/src/config/cli-config.ts b/src/config/cli-config.ts new file mode 100644 index 0000000..2937adb --- /dev/null +++ b/src/config/cli-config.ts @@ -0,0 +1,61 @@ +/** + * The dcd API's public `GET /cli-config`: runtime configuration the CLI used to + * compile in, fetched from the API it talks to so that rotating any of it is an + * env change on the API rather than a CLI release. + * + * - `supabase_publishable_key` (dcd#1112): see ./supabase-key.ts. + * - `binary_kek_public_key` (dcd#1138): the newest KEK public key the API + * publishes for client-side envelope encryption, with the version the API + * unwraps it with. Absent when the API publishes none. + * + * Fetched once per process per API URL and kept in memory only. It is + * deliberately not cached on disk: a stale Supabase key makes GoTrue answer a + * refresh with a 4xx, which `resolveAuth` treats as a dead session and deletes + * the user's login, and a stale KEK would wrap uploads the API can no longer + * unwrap. A failed fetch is not cached, so the next call retries. + */ + +const FETCH_TIMEOUT_MS = 10_000; + +export interface CliConfig { + supabase_url?: string; + supabase_publishable_key?: string; + binary_kek_public_key?: { version: number; key: string }; +} + +const cache = new Map>(); + +/** `apiUrl` without trailing slashes, so `…/` and `…` share one cache entry. */ +export function cliConfigUrl(apiUrl: string): string { + return `${apiUrl.replace(/\/+$/, '')}/cli-config`; +} + +async function fetchCliConfig(apiUrl: string): Promise { + const url = cliConfigUrl(apiUrl); + const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!res.ok) { + throw new Error(`Failed to fetch CLI config from ${url}: HTTP ${res.status}`); + } + const body: unknown = await res.json(); + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new Error(`CLI config from ${url} is not a JSON object`); + } + return body as CliConfig; +} + +/** The API's CLI config. A failed fetch is not cached, so the next call retries. */ +export function getCliConfig(apiUrl: string): Promise { + const key = cliConfigUrl(apiUrl); + let pending = cache.get(key); + if (!pending) { + pending = fetchCliConfig(apiUrl); + cache.set(key, pending); + pending.catch(() => cache.delete(key)); + } + return pending; +} + +/** Test seam: forget fetched configs. */ +export function resetCliConfigCache(): void { + cache.clear(); +} diff --git a/src/config/environments.ts b/src/config/environments.ts index 22a16c8..1d79cd7 100644 --- a/src/config/environments.ts +++ b/src/config/environments.ts @@ -1,12 +1,12 @@ /** * Per-env devicecloud.dev coordinates — single source of truth for the URLs - * and Supabase credentials the CLI uses. + * the CLI uses. * - * **These `anonKey` values are public.** Supabase anon keys are JWTs designed - * to be embedded in client code (web apps, mobile apps, CLIs). They only - * grant whatever the project's RLS policies expose. Don't worry about seeing - * them here — every Supabase-based product ships its anon key the same way. - * https://supabase.com/docs/guides/api/api-keys + * There is deliberately no Supabase key and no KEK public key here. A + * compiled-in key can never be rotated without breaking every pinned CLI + * version, so both are fetched from the API at runtime instead: see + * ./cli-config.ts (`getSupabaseKey` in ./supabase-key.ts for dcd#1112, and + * `resolveKekPublicKey` in ../utils/envelope.ts for dcd#1138). */ export type DcdEnvName = 'dev' | 'prod'; @@ -17,22 +17,7 @@ export interface DcdEnvironment { supabase: { url: string; projectRef: string; - anonKey: string; }; - /** - * Pinned KEK public key for client-side binary envelope encryption (dcd#1138). - * `key` is base64 of the raw 32-byte X25519 public key; `version` selects which - * KEK the platform API unwraps with. **Public — safe to embed** (like the anon - * key above): it can only *encrypt*; the private half lives solely on the API. - * - * Left `null` until the KEK is generated and provisioned. Generate a keypair - * with, e.g.: - * node -e 'const c=require("crypto");const{publicKey,privateKey}=c.generateKeyPairSync("x25519");const pub=publicKey.export({type:"spki",format:"der"}).subarray(12);const priv=privateKey.export({type:"pkcs8",format:"der"}).subarray(16);console.log("public :",pub.toString("base64"));console.log("private:",priv.toString("base64"))' - * Pin `public` here; set `private` as `BINARY_KEK_PRIVATE_KEYS={"":""}` - * on the API service and keep one offline escrow copy. Until then, encryption - * can be exercised via the `DCD_BINARY_KEK_PUBLIC` env override (see envelope.ts). - */ - kekPublicKey: { version: number; key: string } | null; } export const ENVIRONMENTS: Record = { @@ -42,10 +27,7 @@ export const ENVIRONMENTS: Record = { supabase: { url: 'https://cloud.devicecloud.dev', projectRef: 'pgydnphbimetinsgfkbo', - anonKey: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBneWRucGhiaW1ldGluc2dma2JvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDc1OTQzNDYsImV4cCI6MjAyMzE3MDM0Nn0.hAYOMFxxwX1exkQkY9xyQJGC_GhGnyogkj2N-kBkMI8', }, - kekPublicKey: { version: 1, key: 'wtfyWEwK7nJzwI4PD+9RAW8jxIR1u8kMQq2IhsrVnH4=' }, }, dev: { apiUrl: 'https://api.dev.devicecloud.dev', @@ -53,10 +35,7 @@ export const ENVIRONMENTS: Record = { supabase: { url: 'https://lbmsowehtjwnqlurpemb.supabase.co', projectRef: 'lbmsowehtjwnqlurpemb', - anonKey: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxibXNvd2VodGp3bnFsdXJwZW1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMTg0ODcsImV4cCI6MjAyNDc5NDQ4N30.zeLTMAuZ_WwYvGdeP0kdvL_Zrs-RQee5APPyxmWq7qQ', }, - kekPublicKey: { version: 1, key: 'RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=' }, }, }; @@ -75,27 +54,6 @@ export function inferEnvFromApiUrl(apiUrl: string): DcdEnvName { return 'prod'; } -/** - * The environment whose pinned KEK may encrypt uploads to `apiUrl` (dcd#1138), - * or undefined when the URL isn't a known one. Stricter than - * {@link inferEnvFromApiUrl}, which falls back to prod for any URL: that is - * harmless for picking a console link, but an upload encrypted for a KEK its - * API can't unwrap is unusable. Known means the host of a pinned `apiUrl`, or - * `localhost`, which runs against dev. - */ -export function kekEnvForApiUrl(apiUrl: string): DcdEnvName | undefined { - let host: string; - try { - host = new URL(apiUrl).hostname; - } catch { - return undefined; - } - - if (host === 'localhost') return 'dev'; - const names = Object.keys(ENVIRONMENTS) as DcdEnvName[]; - return names.find((name) => new URL(ENVIRONMENTS[name].apiUrl).hostname === host); -} - /** * Frontend URL to open for a given API URL. Same as * `ENVIRONMENTS[inferEnvFromApiUrl(apiUrl)].frontendUrl`, except that when the diff --git a/src/config/flags/binary.flags.ts b/src/config/flags/binary.flags.ts index cbda3f8..96111f3 100644 --- a/src/config/flags/binary.flags.ts +++ b/src/config/flags/binary.flags.ts @@ -1,4 +1,4 @@ -import type { CliArgsDef } from '../../utils/help.js'; +import type { ArgsDef } from 'citty'; /** * Binary upload and management flags @@ -26,10 +26,7 @@ export const binaryFlags = { }, encrypt: { type: 'boolean', - // Left out of --help while encryption is a per-org beta (dcd#1138); the - // flag still parses, --no-encrypt included. - hidden: true, description: - 'Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). The binary is still deduplicated across runs, so an unchanged app is not re-uploaded. Also turned on by DCD_ENCRYPT=1, and by default for orgs in the encryption beta; --no-encrypt or DCD_ENCRYPT=0 turns it off.', + 'Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). On by default; --no-encrypt or DCD_ENCRYPT=0 turns it off. The binary is still deduplicated across runs, so an unchanged app is not re-uploaded.', }, -} as const satisfies CliArgsDef; +} as const satisfies ArgsDef; diff --git a/src/config/supabase-key.ts b/src/config/supabase-key.ts new file mode 100644 index 0000000..d09ecff --- /dev/null +++ b/src/config/supabase-key.ts @@ -0,0 +1,38 @@ +/** + * The Supabase publishable key, fetched from the dcd API at runtime (dcd#1112). + * + * Releases up to this one compiled the anon key in, so the key could never be + * rotated without breaking every CLI version pinned in customer CI. The API now + * serves it from the public `GET /cli-config` (see ./cli-config.ts for the + * fetch and its in-memory cache), and rotating it is an env change on the API. + * + * Only the key comes from the API. The Supabase URLs stay pinned in + * `ENVIRONMENTS`: prod goes through the `cloud.devicecloud.dev` custom domain, + * which customer firewalls may allow-list, not the API's raw `supabase.co` URL. + */ +import { cliConfigUrl, getCliConfig, resetCliConfigCache } from './cli-config.js'; +import { ENVIRONMENTS, type DcdEnvName } from './environments.js'; + +/** The publishable key for `env`. A failed fetch is not cached, so the next call retries. */ +export async function getSupabaseKey(env: DcdEnvName): Promise { + const apiUrl = ENVIRONMENTS[env].apiUrl; + const key = (await getCliConfig(apiUrl)).supabase_publishable_key; + if (typeof key !== 'string' || key.length === 0) { + throw new Error(`CLI config from ${cliConfigUrl(apiUrl)} has no supabase_publishable_key`); + } + return key; +} + +/** + * Headers that authenticate a raw Storage/Auth request with `key` alone. A + * legacy anon key is a JWT and also goes in `Authorization`; an + * `sb_publishable_` key is not a JWT and must only be sent as `apikey`. + */ +export function supabaseKeyHeaders(key: string): Record { + return key.startsWith('sb_') ? { apikey: key } : { apikey: key, authorization: `Bearer ${key}` }; +} + +/** Test seam: forget fetched keys. */ +export function resetSupabaseKeyCache(): void { + resetCliConfigCache(); +} diff --git a/src/constants.ts b/src/constants.ts index 0030737..7cdf1e1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,6 +3,8 @@ * Flags are organized by domain in src/config/flags/ */ +import type { ArgsDef } from 'citty'; + import { apiFlags } from './config/flags/api.flags.js'; import { binaryFlags } from './config/flags/binary.flags.js'; import { deviceFlags } from './config/flags/device.flags.js'; @@ -10,7 +12,6 @@ import { environmentFlags } from './config/flags/environment.flags.js'; import { executionFlags } from './config/flags/execution.flags.js'; import { githubFlags } from './config/flags/github.flags.js'; import { outputFlags } from './config/flags/output.flags.js'; -import type { CliArgsDef } from './utils/help.js'; /** * All flag definitions consolidated from domain-specific flag modules. @@ -24,6 +25,6 @@ export const flags = { ...executionFlags, ...githubFlags, ...outputFlags, -} as const satisfies CliArgsDef; +} as const satisfies ArgsDef; export type FlagKey = keyof typeof flags; diff --git a/src/gateways/cli-auth-gateway.ts b/src/gateways/cli-auth-gateway.ts index 35b10cf..18caf9a 100644 --- a/src/gateways/cli-auth-gateway.ts +++ b/src/gateways/cli-auth-gateway.ts @@ -7,6 +7,8 @@ */ import { createClient, isAuthApiError } from '@supabase/supabase-js'; +import type { DcdEnvName } from '../config/environments.js'; +import { getSupabaseKey } from '../config/supabase-key.js'; import type { StoredSession } from '../utils/config-store.js'; /** @@ -34,8 +36,8 @@ export interface RefreshedSession { user_id: string; } -function client(supabaseUrl: string, supabaseAnonKey: string) { - return createClient(supabaseUrl, supabaseAnonKey, { +function client(supabaseUrl: string, supabaseKey: string) { + return createClient(supabaseUrl, supabaseKey, { auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false }, }); } @@ -43,10 +45,21 @@ function client(supabaseUrl: string, supabaseAnonKey: string) { export const CliAuthGateway = { async refresh( supabaseUrl: string, - supabaseAnonKey: string, + env: DcdEnvName, session: StoredSession, ): Promise { - const sb = client(supabaseUrl, supabaseAnonKey); + let supabaseKey: string; + try { + supabaseKey = await getSupabaseKey(env); + } catch (error) { + // Never definitive: the refresh token was not even tried, so the stored + // session must survive for the next attempt. + throw new SessionRefreshError( + `Failed to refresh session: ${(error as Error).message}`, + false, + ); + } + const sb = client(supabaseUrl, supabaseKey); const { data, error } = await sb.auth.refreshSession({ refresh_token: session.refresh_token, }); @@ -78,7 +91,7 @@ export const CliAuthGateway = { async signOut( supabaseUrl: string, - supabaseAnonKey: string, + env: DcdEnvName, session: StoredSession, ): Promise { // `scope: 'local'` clears this client's SDK state only — no server call, @@ -86,8 +99,8 @@ export const CliAuthGateway = { // EVERY session for the user (browser, mobile, other CLIs), so // `dcd logout` would kick the user out of the web app. We don't want // that — logging out locally should be local. - const sb = client(supabaseUrl, supabaseAnonKey); try { + const sb = client(supabaseUrl, await getSupabaseKey(env)); await sb.auth.setSession({ access_token: session.access_token, refresh_token: session.refresh_token, diff --git a/src/gateways/device-gateway.ts b/src/gateways/device-gateway.ts index 276db8a..25ce7fc 100644 --- a/src/gateways/device-gateway.ts +++ b/src/gateways/device-gateway.ts @@ -5,6 +5,7 @@ import type { DeviceFlowBundle, DeviceCatalogue, DeviceCommandResult, + DeviceHumanStatus, DeviceInstalledApp, DeviceLogs, DeviceRunResponse, @@ -45,7 +46,7 @@ export class DeviceApiError extends Error { * is either Nest's status text or a machine code such as LIVE_NOT_FOUND. * Only an upper-case code is kept, so "Not Found" never reads as one. */ -async function toDeviceApiError(res: Response): Promise { +async function toDeviceApiError(res: Response, path: string): Promise { const text = await res.text(); let body: { error?: unknown; message?: unknown; session?: unknown } = {}; try { @@ -64,6 +65,15 @@ async function toDeviceApiError(res: Response): Promise { const code = typeof body.error === 'string' && /^[A-Z][A-Z0-9_]+$/.test(body.error) ? body.error : null; + if (code === 'LIVE_HUMAN_IN_CONTROL') { + // The API's text points at MCP and raw HTTP too; a CLI user needs the command. + const id = /^\/device\/sessions\/([^/?]+)/.exec(path)?.[1]; + const name = id ? decodeURIComponent(id) : ''; + message = + `A person has taken control of ${name} in the console, so it takes no commands until they hand it back. ` + + `Wait for that with \`dcd device human ${name} --wait\`.`; + } + const session = body.session && typeof body.session === 'object' ? (body.session as DeviceSession) : null; return new DeviceApiError(message, res.status, code, session); @@ -97,7 +107,7 @@ async function request( throw error; } - if (!res.ok) throw await toDeviceApiError(res); + if (!res.ok) throw await toDeviceApiError(res, path); try { return (await res.json()) as T; } catch (error) { @@ -144,6 +154,15 @@ export const DeviceGateway = { ); }, + /** Whether the session waits for a person, or was handed back. */ + human( + baseUrl: string, + auth: AuthContext, + id: string, + ): Promise<{ human: DeviceHumanStatus; session: DeviceSession }> { + return request(baseUrl, auth, 'GET', `${sessionPath(id)}/human`); + }, + inspect( baseUrl: string, auth: AuthContext, @@ -206,6 +225,16 @@ export const DeviceGateway = { return request(baseUrl, auth, 'POST', '/device/bundles', body); }, + /** Ask a person for help: the session moves to needs_human until they hand it back. */ + requestHuman( + baseUrl: string, + auth: AuthContext, + id: string, + reason: string, + ): Promise<{ human: DeviceHumanStatus; session: DeviceSession }> { + return request(baseUrl, auth, 'POST', `${sessionPath(id)}/request-human`, { reason }); + }, + run( baseUrl: string, auth: AuthContext, diff --git a/src/gateways/realtime-gateway.ts b/src/gateways/realtime-gateway.ts index 49035c5..2ede962 100644 --- a/src/gateways/realtime-gateway.ts +++ b/src/gateways/realtime-gateway.ts @@ -16,10 +16,12 @@ import { createClient, REALTIME_SUBSCRIBE_STATES, + type RealtimeChannel, type SupabaseClient, } from '@supabase/supabase-js'; import { ENVIRONMENTS, type DcdEnvName } from '../config/environments.js'; +import { getSupabaseKey } from '../config/supabase-key.js'; export interface RealtimeResultsSubscription { /** @@ -83,87 +85,99 @@ export class RealtimeResultsGateway { }; let client: SupabaseClient | undefined; - try { - const { url, anonKey } = ENVIRONMENTS[env].supabase; - client = createClient(url, anonKey, { - // Match SupabaseClientGateway / the frontend; no session persistence — - // we set the token explicitly below. - auth: { autoRefreshToken: false, persistSession: false }, - realtime: { params: { eventsPerSecond: 10 } }, - }); - - // Attach the user's JWT to the socket so RLS is enforced on the channel. - client.realtime.setAuth(accessToken); + let channel: RealtimeChannel | undefined; + let closed = false; + // The latest JWT, so a refresh that lands while the key is still being + // fetched is the one the socket joins with. + let token = accessToken; - const channel = client - .channel(`results-cli-${uploadId}`) - .on( - // The typings don't cover the postgres_changes overload cleanly. - 'postgres_changes' as never, - { - event: '*', - schema: 'public', - table: 'results', - filter: `org_id=eq.${orgId}`, - } as never, - (payload: ResultChangePayload) => { - if (payload.new?.test_upload_id === uploadId) { - dbg(`change for upload ${uploadId}`); - onChange(); - } - }, - ) - .subscribe((status) => { - if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) { - dbg('subscribed'); - setConnected(true); - } else if ( - status === REALTIME_SUBSCRIBE_STATES.CHANNEL_ERROR || - status === REALTIME_SUBSCRIBE_STATES.TIMED_OUT || - status === REALTIME_SUBSCRIBE_STATES.CLOSED - ) { - // Don't try to recover — the backstop poll covers us. Surface in - // debug so a network-blocked websocket is diagnosable. - dbg(`channel ${status}; relying on backstop poll`); - setConnected(false); - } + // The publishable key comes from the API (dcd#1112), so the socket opens + // asynchronously. `subscribe` stays synchronous for the caller: until it + // connects, `isConnected` is false and the backstop poll covers the gap. + const ready = getSupabaseKey(env) + .then((supabaseKey) => { + if (closed) return; + const sb = createClient(ENVIRONMENTS[env].supabase.url, supabaseKey, { + // Match SupabaseClientGateway / the frontend; no session persistence — + // we set the token explicitly below. + auth: { autoRefreshToken: false, persistSession: false }, + realtime: { params: { eventsPerSecond: 10 } }, }); + client = sb; - const activeClient = client; - return { - isConnected: () => connected, - async unsubscribe() { - setConnected(false); - try { - await activeClient.removeChannel(channel); - await activeClient.realtime.disconnect(); - } catch { - /* 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)}`, - ); + // Attach the user's JWT to the socket so RLS is enforced on the channel. + sb.realtime.setAuth(token); + + channel = sb + .channel(`results-cli-${uploadId}`) + .on( + // The typings don't cover the postgres_changes overload cleanly. + 'postgres_changes' as never, + { + event: '*', + schema: 'public', + table: 'results', + filter: `org_id=eq.${orgId}`, + } as never, + (payload: ResultChangePayload) => { + if (payload.new?.test_upload_id === uploadId) { + dbg(`change for upload ${uploadId}`); + onChange(); + } + }, + ) + .subscribe((status) => { + if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) { + dbg('subscribed'); + setConnected(true); + } else if ( + status === REALTIME_SUBSCRIBE_STATES.CHANNEL_ERROR || + status === REALTIME_SUBSCRIBE_STATES.TIMED_OUT || + status === REALTIME_SUBSCRIBE_STATES.CLOSED + ) { + // Don't try to recover — the backstop poll covers us. Surface in + // debug so a network-blocked websocket is diagnosable. + dbg(`channel ${status}; relying on backstop poll`); + setConnected(false); + } }); - }, - }; - } catch (error) { - dbg(`failed to subscribe: ${error instanceof Error ? error.message : String(error)}`); - // Best-effort cleanup of a half-built client, then degrade to polling. - try { - client?.realtime.disconnect(); - } catch { - /* ignore */ - } - return { - isConnected: () => false, - async unsubscribe() {}, - updateAccessToken() {}, - }; - } + }) + .catch((error: unknown) => { + dbg(`failed to subscribe: ${error instanceof Error ? error.message : String(error)}`); + // Best-effort cleanup of a half-built client, then degrade to polling. + try { + client?.realtime.disconnect(); + } catch { + /* ignore */ + } + client = undefined; + channel = undefined; + }); + + return { + isConnected: () => connected, + async unsubscribe() { + closed = true; + setConnected(false); + await ready; + if (!client) return; + try { + if (channel) await client.removeChannel(channel); + await client.realtime.disconnect(); + } catch { + /* best effort — process is exiting anyway */ + } + }, + updateAccessToken(nextToken: string) { + token = nextToken; + // setAuth updates the join payload and pushes the token to joined + // channels. A failure only costs realtime; the backstop poll stays. + client?.realtime.setAuth(nextToken).catch((error: unknown) => { + dbg( + `failed to update the socket's token: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + }, + }; } } diff --git a/src/gateways/supabase-gateway.ts b/src/gateways/supabase-gateway.ts index 67d0478..c37efa7 100644 --- a/src/gateways/supabase-gateway.ts +++ b/src/gateways/supabase-gateway.ts @@ -4,6 +4,7 @@ import { createClient } from '@supabase/supabase-js'; import * as tus from 'tus-js-client'; import { ENVIRONMENTS, type DcdEnvName } from '../config/environments.js'; +import { getSupabaseKey, supabaseKeyHeaders } from '../config/supabase-key.js'; /** Disk-backed upload descriptor — see UploadSource in src/methods.ts. */ export interface ResumableUploadSource { @@ -16,7 +17,7 @@ export interface ResumableUploadSource { export class SupabaseGateway { /** * Upload to Supabase using resumable uploads (TUS protocol) - * Uploads to staging location (uploads/{id}/) using anon key + * Uploads to staging location (uploads/{id}/) using the publishable key * File is later moved to final location by API after finalization * @param env - Environment (dev or prod) * @param path - Staging storage path (uploads/{id}/file.ext) @@ -32,8 +33,8 @@ export class SupabaseGateway { debug = false, onProgress?: (bytesUploaded: number, bytesTotal: number) => void, ): Promise { - const { anonKey: SUPABASE_PUBLIC_KEY, projectRef } = - ENVIRONMENTS[env].supabase; + const { projectRef } = ENVIRONMENTS[env].supabase; + const supabaseKey = await getSupabaseKey(env); const storageUrl = `https://${projectRef}.storage.supabase.co`; if (debug) { @@ -58,9 +59,9 @@ export class SupabaseGateway { retryDelays: [0, 3000, 5000, 10_000, 20_000], // Authentication and headers - // Use anon key for staging uploads to uploads/* folder + // Publishable key for staging uploads to uploads/* folder headers: { - authorization: `Bearer ${SUPABASE_PUBLIC_KEY}`, + ...supabaseKeyHeaders(supabaseKey), 'x-upsert': 'true', // Allow overwriting existing files }, @@ -137,7 +138,7 @@ export class SupabaseGateway { file: File, debug = false, ) { - const { url: SUPABASE_URL, anonKey: SUPABASE_PUBLIC_KEY } = ENVIRONMENTS[env].supabase; + const { url: SUPABASE_URL } = ENVIRONMENTS[env].supabase; if (debug) { console.log(`[DEBUG] Supabase upload starting...`); @@ -148,7 +149,7 @@ export class SupabaseGateway { } try { - const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLIC_KEY); + const supabase = createClient(SUPABASE_URL, await getSupabaseKey(env)); const uploadToUrl = await supabase.storage .from('organizations') .uploadToSignedUrl(path, token, file); diff --git a/src/index.ts b/src/index.ts index 69fbc08..d6a0b79 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,7 +18,6 @@ import { uploadCommand } from './commands/upload.js'; import { whoamiCommand } from './commands/whoami.js'; import { telemetry } from './services/telemetry.service.js'; import { CliError, getCliVersion, logger, wantsJson } from './utils/cli.js'; -import { withoutHiddenArgs } from './utils/help.js'; // @clack/prompts ships the US spelling ("Canceled") for its built-in // spinner/prompt cancellation message; align it with the British spelling @@ -87,18 +86,12 @@ async function resolveSubCommand( return [cmd, parent]; } -// Usage for the (sub)command named in rawArgs, without the args marked hidden. -async function showHelp(rawArgs: string[]): Promise { - const [cmd, parent] = await resolveSubCommand(main, rawArgs); - await showUsage(await withoutHiddenArgs(cmd), parent); -} - async function run(): Promise { const rawArgs = process.argv.slice(2); telemetry.recordCommandStart(); try { if (rawArgs.includes('--help') || rawArgs.includes('-h')) { - await showHelp(rawArgs); + await showUsage(...(await resolveSubCommand(main, rawArgs))); } else if (rawArgs.length === 1 && rawArgs[0] === '--version') { logger.log(getCliVersion()); } else { @@ -114,7 +107,7 @@ async function run(): Promise { // citty throws CLIError (by name — the class isn't exported) for usage // problems like unknown commands or missing required args. if (!json && error instanceof Error && error.name === 'CLIError') { - await showHelp(rawArgs); + await showUsage(...(await resolveSubCommand(main, rawArgs))); } const exitCode = error instanceof CliError ? error.exitCode : 1; diff --git a/src/mcp/tools/run-cloud-test.ts b/src/mcp/tools/run-cloud-test.ts index c57137f..1d32845 100644 --- a/src/mcp/tools/run-cloud-test.ts +++ b/src/mcp/tools/run-cloud-test.ts @@ -103,7 +103,7 @@ export function registerRunCloudTest(server: McpServer): void { .boolean() .optional() .describe( - 'true encrypts the binary, flow zip and env vars client-side before upload; false never does. Omit to follow DCD_ENCRYPT, then the organization default.', + 'true encrypts the binary, flow zip and env vars client-side before upload; false never does. Omit to follow DCD_ENCRYPT, then the default, which is on.', ), wait: z .boolean() @@ -206,8 +206,8 @@ export function registerRunCloudTest(server: McpServer): void { } // Client-side envelope encryption (binary/flow/env), in the same order - // as `dcd cloud`: the encrypt input, DCD_ENCRYPT, then the org default. - const encryption = checkKekForEncryption( + // as `dcd cloud`: the encrypt input, DCD_ENCRYPT, then the server default. + const encryption = await checkKekForEncryption( resolveEncryption({ flag: args.encrypt, serverDefault: compatibilityData.encryption?.defaultOn, @@ -225,7 +225,7 @@ export function registerRunCloudTest(server: McpServer): void { }; telemetryExtra.encrypt = encryptTelemetry; if (encrypt && encryption.source === 'server') { - logStderr('Encrypting uploads (beta); opt out with encrypt: false'); + logStderr('Encrypting uploads; opt out with encrypt: false'); } // Resolve the binary: existing id, or upload the local file. diff --git a/src/methods.ts b/src/methods.ts index d4eb213..3634c06 100644 --- a/src/methods.ts +++ b/src/methods.ts @@ -268,7 +268,7 @@ export const uploadBinary = async (config: UploadBinaryConfig) => { /** * Encrypt the prepared upload source in place (dcd#1138): generate a per-upload * DEK, stream-encrypt `source.diskPath` into a temp ciphertext file, wrap the - * DEK with the environment's pinned KEK public key, and repoint `source` at the + * DEK with the KEK public key the API publishes, and repoint `source` at the * ciphertext so the SHA, dedup check, and both uploaders operate on ciphertext. * Returns the envelope metadata (for `binaries.metadata.enc`) plus the temp dir * to clean up. Throws if no KEK is available for the environment. @@ -278,10 +278,10 @@ async function encryptUploadSource( apiUrl: string, debug: boolean, ): Promise<{ enc: BinaryEnvelope; cleanupDir: string }> { - const kek = resolveKekPublicKey(apiUrl); + const kek = await resolveKekPublicKey(apiUrl); if (!kek) { throw new Error( - 'Encryption is on (from --encrypt, DCD_ENCRYPT or the organization default) but no KEK public key is configured for this API. ' + + 'Encryption is on (from --encrypt, DCD_ENCRYPT or the default) but no KEK public key is configured for this API. ' + 'Set DCD_BINARY_KEK_PUBLIC=:, or pass --no-encrypt to upload without encryption.', ); } diff --git a/src/services/test-submission.service.ts b/src/services/test-submission.service.ts index 367208b..004d23d 100644 --- a/src/services/test-submission.service.ts +++ b/src/services/test-submission.service.ts @@ -238,10 +238,10 @@ export class TestSubmissionService { if (!apiUrl) { throw new Error('Encryption requires apiUrl to resolve the KEK public key'); } - const kek = resolveKekPublicKey(apiUrl); + const kek = await resolveKekPublicKey(apiUrl); if (!kek) { throw new Error( - 'Encryption is on (from --encrypt, DCD_ENCRYPT or the organization default) but no KEK public key is configured for this API. ' + + 'Encryption is on (from --encrypt, DCD_ENCRYPT or the default) but no KEK public key is configured for this API. ' + 'Set DCD_BINARY_KEK_PUBLIC=:, or pass --no-encrypt to upload without encryption.', ); } diff --git a/src/types/domain/device-session.types.ts b/src/types/domain/device-session.types.ts index 5956ab4..4c3c7a4 100644 --- a/src/types/domain/device-session.types.ts +++ b/src/types/domain/device-session.types.ts @@ -51,6 +51,29 @@ export interface DeviceSession { monitor_url: string; } +/** + * GET .../human and POST .../request-human (ILiveHumanStatus in the API): + * whether the session waits for a person, and what they said on handing back. + */ +export interface DeviceHumanStatus { + /** The session is in needs_human: wait, polling this. */ + waiting: boolean; + /** Why help was asked for, while waiting. */ + reason: null | string; + /** When help was asked for, while waiting. */ + since: null | string; + /** When the session ends unless a person hands it back, while waiting. */ + expires_at: null | string; + /** A person handed the session back since help was last asked for. */ + handed_back: boolean; + /** Their note, if they left one. */ + note: null | string; + handed_back_at: null | string; + /** A person has taken control in the console; commands answer 409 meanwhile. */ + human_in_control: boolean; + controller_user_id: null | string; +} + /** POST /device/sessions. */ export interface StartDeviceSessionRequest { platform: 'android' | 'ios'; @@ -86,9 +109,19 @@ export interface DeviceInstalledApp { reused: boolean; } -/** The result of a typed action or a launch. */ +/** One flow of a run, as newer APIs report it in `result.flows`. */ +export interface DeviceFlowResult { + error: null | string; + /** The flow's path inside the bundle; null for a single flow. */ + file: null | string; + success: boolean; +} + +/** The result of a typed action, a launch or a run. */ export interface DeviceCommandResult { error?: string; + /** Each flow of a run. Absent from APIs older than this field. */ + flows?: DeviceFlowResult[]; output?: string; success: boolean; } @@ -158,6 +191,12 @@ export interface DeviceArtifactsPage { logs: DeviceArtifactLink | null; /** Pass as `before` for the next (older) page; null on the last page. */ next_before: null | string; + /** + * Files the runner is still uploading after the session ended: the video + * and the device log land up to a minute or so later. Absent from APIs + * older than this field. + */ + pending?: ('logs' | 'recording')[]; recording: DeviceArtifactLink | null; session: DeviceSession; } diff --git a/src/utils/app-upload.ts b/src/utils/app-upload.ts index 9a7449c..4aafed7 100644 --- a/src/utils/app-upload.ts +++ b/src/utils/app-upload.ts @@ -58,7 +58,7 @@ export async function uploadLocalApp(opts: UploadLocalAppOptions): Promise { const deadline = Date.now() + LOCK_WAIT_MS; for (;;) { diff --git a/src/utils/ci.ts b/src/utils/ci.ts index c5a8153..5a2f8ed 100644 --- a/src/utils/ci.ts +++ b/src/utils/ci.ts @@ -41,6 +41,16 @@ export function isCI(): boolean { return !process.stdout.isTTY; } +/** + * Whether a prompt drawn on `output` can be answered: stdin and `output` must + * both be terminals. Without that (a script, CI, a shell an agent runs) a clack + * prompt waits for a keypress that never comes. Unlike isCI(), CI env vars + * don't count: a terminal with `CI=true` exported can still answer a prompt. + */ +export function canPrompt(output: { isTTY?: boolean } = process.stdout): boolean { + return process.stdin.isTTY === true && output.isTTY === true; +} + /** Which DCD CI integration (if any) is wrapping this CLI invocation, and its version. */ export interface CiContext { /** e.g. 'github' | 'bitrise' | 'bitbucket' | 'eas' | 'gitlab' | 'circleci'. */ diff --git a/src/utils/compatibility.ts b/src/utils/compatibility.ts index 9df0285..0440087 100644 --- a/src/utils/compatibility.ts +++ b/src/utils/compatibility.ts @@ -13,9 +13,8 @@ export interface CompatibilityData { /** Active CLI notices, piggybacked onto the compatibility response by the API. */ notices?: Notice[]; /** - * The org's client-side encryption default (dcd#1138): on for orgs in the - * `client_encryption` beta unless the API's kill switch is set. An older API - * omits it, which means off. + * The client-side encryption default (dcd#1138): on for every org unless the + * API's kill switch is set. An older API omits it, which means off. */ encryption?: { defaultOn: boolean }; } diff --git a/src/utils/device-artifacts.ts b/src/utils/device-artifacts.ts index f53a02b..27ca314 100644 --- a/src/utils/device-artifacts.ts +++ b/src/utils/device-artifacts.ts @@ -6,10 +6,11 @@ import { pipeline } from 'node:stream/promises'; import { DeviceGateway } from '../gateways/device-gateway.js'; import type { AuthContext } from '../types/domain/auth.types.js'; -import type { - DeviceArtifactLink, - DeviceArtifactsPage, - DeviceSession, +import { + type DeviceArtifactLink, + type DeviceArtifactsPage, + type DeviceSession, + ENDED_STATES, } from '../types/domain/device-session.types.js'; /** One file a session left behind, and where it goes on disk. */ @@ -53,6 +54,46 @@ export async function fetchAllArtifacts( return { pages, session: pages.at(-1)!.session }; } +/** How long after a session ends its video and log may still be uploading. */ +const PENDING_WINDOW_MS = 3 * 60 * 1000; + +/** + * What a session's runner is still uploading: the video (`recording`) and + * the device log (`logs`) land up to a minute or so after the session ends. + * The API says so in `pending`. An API older than that field gets a guess: + * an ended session missing either file whose last activity (its newest + * action, else its start) was under three minutes ago. The session summary + * carries no end time, so the last activity stands in for it. + */ +export function pendingArtifacts( + pages: DeviceArtifactsPage[], + now: number = Date.now(), +): ('logs' | 'recording')[] { + const first = pages[0]; + if (!first) return []; + if (Array.isArray(first.pending)) return first.pending; + if (!ENDED_STATES.includes(first.session.state)) return []; + + const lastActivity = Date.parse(first.actions[0]?.created_at ?? first.session.created_at); + if (!Number.isFinite(lastActivity) || now - lastActivity > PENDING_WINDOW_MS) return []; + const pending: ('logs' | 'recording')[] = []; + if (!first.recording) pending.push('recording'); + if (!first.logs) pending.push('logs'); + return pending; +} + +/** The warning for {@link pendingArtifacts}, or null when nothing is pending. */ +export function pendingArtifactsWarning(pending: ('logs' | 'recording')[]): null | string { + if (pending.length === 0) return null; + const what = + pending.length === 2 + ? 'The video and device log are' + : pending[0] === 'recording' + ? 'The video is' + : 'The device log is'; + return `${what} still uploading. Run this again in a minute.`; +} + /** * Lay the artifacts out as files: `recording.mp4`, `device.log`, and per * action `actions/001-screenshot.jpg` and `actions/001-screen.json`, diff --git a/src/utils/device-bundle.ts b/src/utils/device-bundle.ts index b6dd26d..f652d4f 100644 --- a/src/utils/device-bundle.ts +++ b/src/utils/device-bundle.ts @@ -95,9 +95,9 @@ export async function uploadFlowBundle( serverDefault = undefined; } - const encryption = checkKekForEncryption(resolveEncryption({ serverDefault }), apiUrl); + const encryption = await checkKekForEncryption(resolveEncryption({ serverDefault }), apiUrl); if (encryption.notice) log(encryption.notice); - const kek = encryption.enabled ? resolveKekPublicKey(apiUrl) : null; + const kek = encryption.enabled ? await resolveKekPublicKey(apiUrl) : null; const sealed = kek ? encryptFlowBuffer(zip, kek) : null; const body = sealed ? sealed.ciphertext : zip; diff --git a/src/utils/device-output.ts b/src/utils/device-output.ts index 1dd0d34..0ee106b 100644 --- a/src/utils/device-output.ts +++ b/src/utils/device-output.ts @@ -1,6 +1,6 @@ import { DeviceApiError, DeviceGateway } from '../gateways/device-gateway.js'; import type { AuthContext } from '../types/domain/auth.types.js'; -import type { DeviceSession } from '../types/domain/device-session.types.js'; +import type { DeviceHumanStatus, DeviceSession } from '../types/domain/device-session.types.js'; import { CliError, logger } from './cli.js'; import { colors, formatUrl } from './styling.js'; import { type Field, ui } from './ui.js'; @@ -44,16 +44,21 @@ export function deviceExitCode(error: unknown): number { return DEVICE_EXIT.failed; } -/** The one object `--json` prints, on success and on failure alike. */ +/** + * The one object `--json` prints, on success and on failure alike. `session` + * is left out by the commands that are not about one session (`list`, + * `models`, `stop --all`), so it never reads as "no session". + */ export interface DeviceEnvelope { ok: boolean; - session: DeviceSession | null; + session?: DeviceSession | null; data: T | null; error: { code: null | string; message: string } | null; } export interface DeviceOutcome { - session: DeviceSession | null; + /** The session the command was about; undefined when it is not about one. */ + session: DeviceSession | null | undefined; data: T; /** Prints the result for a person. Not called under --json. */ print(): void; @@ -94,7 +99,7 @@ export async function runDeviceCommand( if (json) { const envelope: DeviceEnvelope = { ok: !outcome.failure, - session: outcome.session, + ...(outcome.session !== undefined && { session: outcome.session }), data: outcome.data, error: outcome.failure ?? null, }; @@ -148,7 +153,7 @@ export async function resolveSessionId( ); } -function formatSeconds(seconds: number): string { +export function formatSeconds(seconds: number): string { if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const rest = seconds % 60; @@ -195,3 +200,43 @@ export function printSession(session: DeviceSession): void { logger.log(ui.branch(ui.fields(sessionFields(session)))); for (const warning of session.warnings) logger.log(ui.warn(warning)); } + +/** + * An API timestamp for a person: how far off it is and the local time, e.g. + * "in 12m (14:32)" or "3m ago (14:17)". The text as given when unreadable. + */ +export function formatWhen(iso: string, now: number = Date.now()): string { + const at = Date.parse(iso); + if (!Number.isFinite(at)) return iso; + const clock = new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const seconds = Math.round((at - now) / 1000); + if (Math.abs(seconds) < 5) return `now (${clock})`; + return seconds > 0 + ? `in ${formatSeconds(seconds)} (${clock})` + : `${formatSeconds(-seconds)} ago (${clock})`; +} + +/** Where the hand-over stands, in a few words. */ +export function humanState(human: DeviceHumanStatus): string { + if (human.human_in_control) return 'a person has control'; + if (human.waiting) return 'waiting for a person'; + if (human.handed_back) return 'handed back'; + return 'no help asked for'; +} + +/** The rows that describe a request for a person, for {@link ui.fields}. */ +export function humanFields(human: DeviceHumanStatus, now: number = Date.now()): Field[] { + const fields: Field[] = [['state', humanState(human)]]; + if (human.reason) fields.push(['reason', human.reason]); + if (human.since) fields.push(['asked', formatWhen(human.since, now)]); + if (human.waiting && human.expires_at) { + fields.push(['ends', `${formatWhen(human.expires_at, now)} unless they hand it back`]); + } + + if (human.handed_back && human.handed_back_at) { + fields.push(['handed back', formatWhen(human.handed_back_at, now)]); + } + + if (human.handed_back) fields.push(['note', human.note ?? colors.dim('none')]); + return fields; +} diff --git a/src/utils/device-run.ts b/src/utils/device-run.ts new file mode 100644 index 0000000..b35b838 --- /dev/null +++ b/src/utils/device-run.ts @@ -0,0 +1,133 @@ +import type { DeviceCommandResult, DeviceFlowResult } from '../types/domain/device-session.types.js'; +import { colors } from './styling.js'; +import { type Field, ui } from './ui.js'; + +/** + * What `dcd device run` shows a person: one row per flow and a short summary. + * + * The API passes on the runner's report as text in `result.output` (a pass) + * or `result.error` (a failure). For a directory that report is Maestro's + * JSON: `{ success, device_id, total_flows, total_commands_executed, + * results: [{ file, success, error, message }], env_vars, message }`. For one + * flow it is the same without `results`. Newer APIs also send + * `result.flows`, which is used when present. None of the runner's own + * fields (device_id, command counts, env_vars) are shown. + */ +export interface RunReport { + flows: DeviceFlowResult[]; + /** The runner's one-line summary, e.g. "Some flows failed to execute". */ + message: null | string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function text(value: unknown): null | string { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function toFlow(raw: Record, file: null | string): DeviceFlowResult { + const success = raw.success === true; + return { + file: text(raw.file) ?? file, + success, + // A failed flow's reason is in `error`; some reports only have `message`. + error: text(raw.error) ?? (success ? null : text(raw.message)), + }; +} + +/** The runner's JSON report in `body`, or null when it is not one. */ +function parseRunnerJson(body: string | undefined): null | RunReport { + if (!body || !body.trimStart().startsWith('{')) return null; + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return null; + } + + if (!isRecord(parsed)) return null; + if (Array.isArray(parsed.results)) { + return { + flows: parsed.results.filter((r) => isRecord(r)).map((r) => toFlow(r, null)), + message: text(parsed.message), + }; + } + + if (typeof parsed.success !== 'boolean') return null; + // One flow: the report is that flow's result. + return { flows: [toFlow(parsed, null)], message: text(parsed.message) }; +} + +/** + * The flows a run reported, or null when the result carries no report the + * CLI can read (plain text): then the text is shown as it came. + */ +export function parseRunReport(result: DeviceCommandResult): null | RunReport { + if (Array.isArray(result.flows)) { + return { flows: result.flows.map((f) => toFlow({ ...f }, null)), message: null }; + } + + return parseRunnerJson(result.error) ?? parseRunnerJson(result.output); +} + +/** The failure message for a failed run: each failing flow and why. */ +export function runFailureMessage(result: DeviceCommandResult, report: null | RunReport): string { + if (!report) return result.error || 'The flow failed.'; + const failed = report.flows.filter((f) => !f.success); + if (failed.length === 0) return report.message ?? 'The flow failed.'; + return failed + .map((f) => `${f.file ?? 'flow'}: ${f.error ?? 'failed'}`) + .join('; '); +} + +/** + * Print a run's result for a person. `label` names a flow the report does not + * (a single flow's report has no file name). + */ +export function printRunResult( + log: (line: string) => void, + passed: boolean, + result: DeviceCommandResult, + label: string, +): void { + const report = parseRunReport(result); + if (!report || report.flows.length === 0) { + log(passed ? ui.success('Flow passed') : `${ui.statusSymbol('FAILED')} Flow failed`); + if (report) { + if (report.message) log(ui.note(report.message)); + return; + } + + // Plain text, not a report the CLI can read: show it as it came. + if (result.output) { + log(ui.section('Output')); + log(result.output); + } + + if (result.error) { + log(ui.section('Error')); + log(colors.error(result.error)); + } + + return; + } + + const { flows } = report; + const failed = flows.filter((f) => !f.success).length; + const total = flows.length; + let summary: string; + if (total === 1) summary = passed ? 'Flow passed' : 'Flow failed'; + else if (failed === 0) summary = `${total} flows passed`; + else summary = `${failed} of ${total} flows failed`; + log(passed && failed === 0 ? ui.success(summary) : `${ui.statusSymbol('FAILED')} ${summary}`); + + const rows: Field[] = flows.map((f) => [ + f.file ?? label, + f.success + ? ui.statusWord('PASSED') + : `${ui.statusWord('FAILED')}${f.error ? ` ${colors.error(f.error)}` : ''}`, + ]); + log(ui.branch(ui.fields(rows))); +} diff --git a/src/utils/envelope.ts b/src/utils/envelope.ts index 751243b..d611d8e 100644 --- a/src/utils/envelope.ts +++ b/src/utils/envelope.ts @@ -7,7 +7,7 @@ import { randomBytes, } from 'node:crypto'; import { open, stat } from 'node:fs/promises'; -import { ENVIRONMENTS, kekEnvForApiUrl } from '../config/environments.js'; +import { cliConfigUrl, getCliConfig } from '../config/cli-config.js'; /** * Client-side envelope encryption of app binaries before upload (dcd#1138). @@ -18,9 +18,9 @@ import { ENVIRONMENTS, kekEnvForApiUrl } from '../config/environments.js'; * specified in `dcd/docs/binary-envelope-encryption.md`. * * Scheme: a per-upload random 256-bit DEK encrypts the binary (chunked - * AES-256-GCM); the DEK is wrapped with the environment's pinned X25519 KEK - * public key (sealed box). Only the platform API holds the KEK private half, so - * every storage/transport tier sees ciphertext only. + * AES-256-GCM); the DEK is wrapped with the X25519 KEK public key the API + * publishes on `GET /cli-config` (sealed box). Only the platform API holds the + * KEK private half, so every storage/transport tier sees ciphertext only. */ const MAGIC = Buffer.from('DCDE', 'ascii'); @@ -87,7 +87,7 @@ function parseEncryptEnv(value: string | undefined): boolean | undefined { * 1. `flag`: `--encrypt` / `--no-encrypt`, or the MCP tool's `encrypt` input. * 2. `DCD_ENCRYPT`, then its legacy alias `DCD_ENCRYPT_BINARIES`. * 3. `serverDefault`: `encryption.defaultOn` from the compatibility data, - * which the API turns on for orgs in the `client_encryption` beta. + * which the API turns on for every org unless its kill switch is set. * 4. Off. An API too old to send `encryption` ends up here. * When on, the binary, the flow zip, and the env map are each encrypted with * their **own** per-upload DEK (all wrapped under the same per-env KEK). @@ -110,32 +110,35 @@ export function resolveEncryption(opts: { /** * Check that `apiUrl` has a KEK before a run encrypts against it: either - * `DCD_BINARY_KEK_PUBLIC` is set or the URL is a known devicecloud.dev API - * (see kekEnvForApiUrl). Without one, an explicit `--encrypt` is an error, - * while encryption from DCD_ENCRYPT or the org default is skipped with a - * notice, so a run against another API URL uploads in plaintext as before. + * `DCD_BINARY_KEK_PUBLIC` is set or the API publishes one on `GET /cli-config` + * (see resolveKekPublicKey). Without one, an explicit `--encrypt` is an error, + * while encryption from DCD_ENCRYPT or the server default is skipped with a + * notice, so a run against an API that publishes no KEK uploads in plaintext + * as before. An API that cannot be asked at all is an error whatever decided + * encryption: silently falling back to plaintext would be the one outcome the + * org asked not to have. */ -export function checkKekForEncryption( +export async function checkKekForEncryption( decision: EncryptionDecision, apiUrl: string, -): EncryptionDecision { - if (!decision.enabled || resolveKekPublicKey(apiUrl)) return decision; +): Promise { + if (!decision.enabled || (await resolveKekPublicKey(apiUrl))) return decision; if (decision.source === 'flag') { throw new Error( - `--encrypt needs the KEK public key of the API it uploads to, and ${apiUrl} is not a devicecloud.dev API this CLI has one for. ` + + `--encrypt needs the KEK public key of the API it uploads to, and ${apiUrl} does not publish one. ` + 'Set DCD_BINARY_KEK_PUBLIC=: to encrypt against it, or drop --encrypt.', ); } return { enabled: false, - notice: `Not encrypting uploads: ${apiUrl} is not a devicecloud.dev API this CLI has a key for. Set DCD_BINARY_KEK_PUBLIC to encrypt against it.`, + notice: `Not encrypting uploads: ${apiUrl} does not publish a KEK public key. Set DCD_BINARY_KEK_PUBLIC to encrypt against it.`, source: decision.source, }; } -/** Pinned KEK public key (base64 raw 32-byte X25519) + version, per env. */ +/** KEK public key (raw 32-byte X25519) + the version the API unwraps it with. */ interface KekPublicKey { version: number; keyRaw: Buffer; @@ -150,14 +153,17 @@ function x25519PublicFromRaw(raw: Buffer) { } /** - * Resolve the KEK public key for the environment behind `apiUrl`. Order: + * Resolve the KEK public key for the API behind `apiUrl`. Order: * 1. `DCD_BINARY_KEK_PUBLIC` env override (`:`, e.g. `1:AAAA…`) - * — lets the feature be exercised before keys are pinned in the release, - * or against an API URL that isn't a known one. - * 2. the pinned `ENVIRONMENTS[env].kekPublicKey`, only for a known API URL. - * Returns null when no key is available (encryption cannot proceed). + * — lets the feature be exercised against an API that publishes nothing. + * 2. `binary_kek_public_key` from the API's own `GET /cli-config` (dcd#1138): + * the newest KEK the API holds the private half of. Fetched once per + * process (see ../config/cli-config.ts), so a KEK rotation is an env + * change on the API and never a CLI release. + * Returns null when the API publishes no key (encryption cannot proceed). + * Throws when the API cannot be asked, or publishes something malformed. */ -export function resolveKekPublicKey(apiUrl: string): KekPublicKey | null { +export async function resolveKekPublicKey(apiUrl: string): Promise { const override = process.env.DCD_BINARY_KEK_PUBLIC; if (override) { const [versionPart, b64] = override.split(':'); @@ -171,15 +177,16 @@ export function resolveKekPublicKey(apiUrl: string): KekPublicKey | null { ); } - const env = kekEnvForApiUrl(apiUrl); - if (!env) return null; - const pinned = ENVIRONMENTS[env].kekPublicKey; - if (!pinned) return null; - const keyRaw = Buffer.from(pinned.key, 'base64'); - if (keyRaw.length !== 32) { - throw new Error(`Pinned KEK public key for ${env} is not a 32-byte key`); + const published = (await getCliConfig(apiUrl)).binary_kek_public_key; + if (published === undefined || published === null) return null; + const { version, key } = published as { version?: unknown; key?: unknown }; + const keyRaw = typeof key === 'string' ? Buffer.from(key, 'base64') : Buffer.alloc(0); + if (!Number.isInteger(version) || keyRaw.length !== 32) { + throw new Error( + `CLI config from ${cliConfigUrl(apiUrl)} has a malformed binary_kek_public_key; expected { version: integer, key: base64 32-byte X25519 public key }`, + ); } - return { version: pinned.version, keyRaw }; + return { version: version as number, keyRaw }; } /** diff --git a/src/utils/help.ts b/src/utils/help.ts deleted file mode 100644 index 3560332..0000000 --- a/src/utils/help.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { ArgDef, ArgsDef, CommandDef } from 'citty'; - -/** - * A citty arg definition plus a local `hidden` marker. citty (0.2) can hide a - * command from help (`meta.hidden`) but not an arg, so {@link withoutHiddenArgs} - * drops hidden args before usage is rendered. They still parse as normal. - */ -export type CliArgDef = ArgDef & { hidden?: boolean }; -export type CliArgsDef = Record; - -/** `cmd` with its hidden args left out, for rendering `--help` only. */ -export async function withoutHiddenArgs( - cmd: CommandDef, -): Promise> { - const args = typeof cmd.args === 'function' ? await cmd.args() : await cmd.args; - if (!args) return cmd; - - const visible = Object.fromEntries( - Object.entries(args).filter(([, def]) => !(def as CliArgDef).hidden), - ) as T; - return { ...cmd, args: visible }; -} diff --git a/src/utils/orgs.ts b/src/utils/orgs.ts index 97a2c60..078ee48 100644 --- a/src/utils/orgs.ts +++ b/src/utils/orgs.ts @@ -1,9 +1,9 @@ /** - * Shared helpers for fetching and picking from /me/orgs. Used by both - * `dcd login` (after the PKCE claim returns a session) and `dcd switch-org`. + * Shared helpers for fetching, matching and picking from /me/orgs. Used by + * both `dcd login` (after the PKCE claim returns a session) and `dcd switch-org`. */ import * as p from '@clack/prompts'; -import type { Writable } from 'node:stream'; +import type { Readable, Writable } from 'node:stream'; import { CliError } from './cli.js'; @@ -28,14 +28,54 @@ export async function fetchOrgs( return body.orgs ?? []; } +/** + * Match a user-supplied string to an org. Prefers case-insensitive name match. + * Falls back to slug / id so existing scripts keep working, but error output + * only surfaces names. `pickCommand` is how to pick interactively instead, + * for when two orgs share a name. + */ +export function matchOrg( + orgs: OrgListItem[], + target: string, + pickCommand: string, +): OrgListItem { + const needle = target.toLowerCase(); + const nameMatches = orgs.filter((o) => o.name.toLowerCase() === needle); + if (nameMatches.length > 1) { + throw new CliError( + `Multiple orgs named "${target}". Run ${pickCommand} to pick interactively.`, + ); + } + const chosen = + nameMatches[0] ?? orgs.find((o) => o.slug === target || o.id === target); + if (!chosen) { + throw new CliError( + `No org named "${target}". Available: ${orgs.map((o) => o.name).join(', ')}`, + ); + } + return chosen; +} + +export interface PickOrgOptions { + message?: string; + /** Org to start the picker on, so Enter confirms it. Ignored if not listed. */ + initialId?: string; + input?: Readable; + output?: Writable; +} + /** * Interactive org picker. Auto-selects when there's only one org (returns it * without prompting). Throws on zero orgs or user cancellation. */ export async function pickOrg( orgs: OrgListItem[], - message = 'Pick an organization', - output?: Writable, + { + message = 'Pick an organization', + initialId, + input, + output, + }: PickOrgOptions = {}, ): Promise { if (orgs.length === 0) { throw new CliError('No organizations found for this user.'); @@ -44,8 +84,11 @@ export async function pickOrg( const picked = await p.select({ message, + input, output, options: orgs.map((o) => ({ value: o.id, label: o.name })), + // clack starts on the first option when this isn't one of them. + initialValue: initialId, }); if (p.isCancel(picked)) throw new CliError('Cancelled.'); @@ -53,3 +96,55 @@ export async function pickOrg( if (!chosen) throw new CliError('No organization selected.'); return chosen; } + +export interface SelectOrgOptions { + /** `dcd login --org`: a name, slug or id. Skips the picker. */ + requested?: string; + /** The org the last login chose. */ + lastOrgId?: string; + /** Whether anyone can answer the picker (see canPrompt). */ + interactive: boolean; + input?: Readable; + output?: Writable; +} + +/** + * Choose the org for `dcd login`: `--org` if given, else the only org, else + * the picker starting on the last login's org. Without a TTY nobody can + * answer the picker, so the last login's org is used without asking, and + * when there isn't one the error names `--org` instead of hanging. + * `fromLastLogin` is set when that fallback chose the org, so the caller + * can say so. + */ +export async function selectOrg( + orgs: OrgListItem[], + { requested, lastOrgId, interactive, input, output }: SelectOrgOptions, +): Promise<{ org: OrgListItem; fromLastLogin: boolean }> { + if (orgs.length === 0) { + throw new CliError('No organizations found for this user.'); + } + if (requested !== undefined) { + return { + org: matchOrg(orgs, requested, '`dcd login` without --org'), + fromLastLogin: false, + }; + } + if (orgs.length === 1) return { org: orgs[0], fromLastLogin: false }; + + if (!interactive) { + const last = orgs.find((o) => o.id === lastOrgId); + if (last) return { org: last, fromLastLogin: true }; + + const reason = lastOrgId + ? "The organization from your last login is no longer available, and there's no terminal to pick another in." + : "You belong to more than one organization, and there's no terminal to pick one in."; + throw new CliError( + `${reason} Run \`dcd login --org \` with one of: ${orgs.map((o) => o.name).join(', ')}`, + ); + } + + return { + org: await pickOrg(orgs, { initialId: lastOrgId, input, output }), + fromLastLogin: false, + }; +} diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts index 89ed4dd..0a605ae 100644 --- a/test/unit/auth.test.ts +++ b/test/unit/auth.test.ts @@ -3,8 +3,11 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { SessionRefreshError } from '../../src/gateways/cli-auth-gateway.js'; -import { dropStoredSession, resolveAuth } from '../../src/utils/auth.js'; +import { + CliAuthGateway, + SessionRefreshError, +} from '../../src/gateways/cli-auth-gateway.js'; +import { resolveAuth } from '../../src/utils/auth.js'; import { clearConfig, configFileMode, @@ -190,39 +193,48 @@ describe('resolveAuth precedence', () => { expect(dead).to.be.instanceOf(Error); }); - it('dropStoredSession removes only the session, keeping env/org fields', async () => { - await withTempConfigDir(() => { - writeConfig({ - version: 1, - env: 'dev', - api_url: 'https://api.dev.devicecloud.dev', - supabase_url: 'https://lbmsowehtjwnqlurpemb.supabase.co', - session: { - access_token: 'a', - refresh_token: 'consumed-by-another-client', - expires_at: Math.floor(Date.now() / 1000) - 60, - user_email: 'u@example.com', - user_id: 'u1', - }, - current_org_id: '42', - current_org_name: 'Acme', - }); - - dropStoredSession(); + for (const definitive of [true, false]) { + it(`keeps the stored session when a refresh fails (definitive: ${definitive})`, async () => { + const original = CliAuthGateway.refresh; + CliAuthGateway.refresh = () => + Promise.reject(new SessionRefreshError('Legacy API keys are disabled', definitive)); + try { + await withTempConfigDir(async () => { + delete process.env.DEVICE_CLOUD_API_KEY; + const session = { + access_token: 'a', + refresh_token: 'r', + expires_at: Math.floor(Date.now() / 1000) - 60, + user_email: 'u@example.com', + user_id: 'u1', + }; + writeConfig({ + version: 1, + env: 'dev', + api_url: 'https://api.dev.devicecloud.dev', + supabase_url: 'https://lbmsowehtjwnqlurpemb.supabase.co', + session, + current_org_id: '42', + current_org_name: 'Acme', + }); - const after = readConfig(); - expect(after).to.not.equal(null); - expect(after!.session).to.equal(undefined); - expect(after!.env).to.equal('dev'); - expect(after!.api_url).to.equal('https://api.dev.devicecloud.dev'); - expect(after!.current_org_id).to.equal('42'); - expect(after!.current_org_name).to.equal('Acme'); + let caught: unknown = null; + try { + await resolveAuth({ apiKeyFlag: undefined }); + } catch (error) { + caught = error; + } + expect(caught).to.be.instanceOf(SessionRefreshError); - // Idempotent when there's no session to drop. - dropStoredSession(); - expect(readConfig()!.session).to.equal(undefined); + const after = readConfig(); + expect(after!.session).to.deep.equal(session); + expect(after!.current_org_id).to.equal('42'); + }); + } finally { + CliAuthGateway.refresh = original; + } }); - }); + } it('uses a non-expired stored session as a last resort', async () => { await withTempConfigDir(async () => { diff --git a/test/unit/device-artifacts.test.ts b/test/unit/device-artifacts.test.ts index 6a1304e..b743c33 100644 --- a/test/unit/device-artifacts.test.ts +++ b/test/unit/device-artifacts.test.ts @@ -7,6 +7,8 @@ import type { DeviceArtifactsPage, DeviceSession } from '../../src/types/domain/ import { downloadArtifactFiles, fetchAllArtifacts, + pendingArtifacts, + pendingArtifactsWarning, planArtifactFiles, } from '../../src/utils/device-artifacts.js'; @@ -59,6 +61,39 @@ describe('dcd device artifacts', () => { ]); }); + describe('pending uploads', () => { + const now = Date.parse('2026-10-19T10:01:00Z'); + const ended = { ...session, created_at: '2026-10-19T09:50:00Z' } as DeviceSession; + const action = (at: string) => ({ command_id: 'c', created_at: at, hierarchy: null, screenshot: null }); + + it('uses what the API says when it says it', () => { + expect(pendingArtifacts([page({ pending: ['recording', 'logs'] })], now)).to.deep.equal(['recording', 'logs']); + expect(pendingArtifacts([page({ pending: [], session: ended })], now)).to.deep.equal([]); + }); + + it('without the field, guesses from a recent end and the missing files', () => { + const recent = page({ session: ended, actions: [action('2026-10-19T10:00:00Z')] }); + expect(pendingArtifacts([recent], now)).to.deep.equal(['recording', 'logs']); + const withVideo = { ...recent, recording: link('https://cdn/v', 'video/mp4') }; + expect(pendingArtifacts([withVideo], now)).to.deep.equal(['logs']); + }); + + it('without the field, says nothing for an old or open session', () => { + const old = page({ session: ended, actions: [action('2026-10-19T09:40:00Z')] }); + expect(pendingArtifacts([old], now)).to.deep.equal([]); + const open = page({ session: { ...ended, state: 'active' }, actions: [action('2026-10-19T10:00:00Z')] }); + expect(pendingArtifacts([open], now)).to.deep.equal([]); + }); + + it('words the warning for what is missing', () => { + expect(pendingArtifactsWarning([])).to.equal(null); + expect(pendingArtifactsWarning(['recording', 'logs'])).to.equal( + 'The video and device log are still uploading. Run this again in a minute.', + ); + expect(pendingArtifactsWarning(['logs'])).to.match(/^The device log is still uploading/); + }); + }); + it('names the files, numbering actions oldest first', () => { const files = planArtifactFiles([ page({ diff --git a/test/unit/device-command.test.ts b/test/unit/device-command.test.ts index 3380216..856efd0 100644 --- a/test/unit/device-command.test.ts +++ b/test/unit/device-command.test.ts @@ -14,7 +14,13 @@ import { DeviceApiError } from '../../src/gateways/device-gateway.js'; import type { DeviceSession } from '../../src/types/domain/device-session.types.js'; import { platformOfAppFile } from '../../src/utils/app-upload.js'; import { CliError } from '../../src/utils/cli.js'; -import { DEVICE_EXIT, deviceExitCode, resolveSessionId } from '../../src/utils/device-output.js'; +import { + DEVICE_EXIT, + deviceExitCode, + formatWhen, + humanFields, + resolveSessionId, +} from '../../src/utils/device-output.js'; const API = 'https://api.devicecloud.dev'; const auth = { headers: { 'x-app-api-key': 'k' }, mode: 'apiKey' as const }; @@ -201,6 +207,13 @@ describe('dcd device', () => { expect(out.error).to.equal(null); }); + it('leaves session out for commands that are not about one session', async () => { + stubFetch(() => ({ body: { sessions: [] } })); + const { out } = await runJson('list', {}); + expect(out).to.have.keys('ok', 'data', 'error'); + expect(out.data).to.deep.equal({ sessions: [] }); + }); + it('carries the API code, the session and exit 4 for an ended session', async () => { const ended = session('brave-teal-otter', { state: 'released', release_reason: 'user_stop' }); stubFetch(() => ({ @@ -317,6 +330,36 @@ describe('dcd device', () => { }); }); + describe('start --model without --platform', () => { + const catalogue = { + ios: [{ model: 'iphone-16', name: 'iPhone 16', os: ['18'], default_os: '18', google_play: false, default: true }], + android: [ + { model: 'pixel-10', name: 'Pixel 10', os: ['36'], default_os: '36', google_play: true, default: true }, + { model: 'iphone-16', name: 'Clash', os: ['36'], default_os: '36', google_play: false, default: false }, + ], + }; + + it('reads the platform from the model list', async () => { + const calls = stubFetch((call) => + call.url.endsWith('/device/models') ? { body: catalogue } : { body: { session: session('a-b-c') } }, + ); + const { exit } = await runJson('start', { model: 'Pixel 10' }); + expect(exit).to.equal(0); + const start = calls.find((c) => c.method === 'POST')!; + expect(start.body).to.include({ platform: 'android', model: 'Pixel 10' }); + }); + + it('is a usage error for an unknown model, or one on both platforms', async () => { + stubFetch(() => ({ body: catalogue })); + const unknown = await runJson('start', { model: 'nokia-3310' }); + expect(unknown.exit).to.equal(DEVICE_EXIT.usage); + expect((unknown.out.error as { message: string }).message).to.include('dcd device models'); + const both = await runJson('start', { model: 'iphone-16' }); + expect(both.exit).to.equal(DEVICE_EXIT.usage); + expect((both.out.error as { message: string }).message).to.include('--platform'); + }); + }); + it('start --app with a URL starts, then installs from the URL', async () => { const calls = stubFetch((call) => call.url.endsWith('/install') @@ -465,6 +508,23 @@ describe('dcd device', () => { expect(out.error).to.deep.equal({ code: null, message: 'Element not found' }); }); + it('run reports the failing flows, not the runner JSON, and keeps the raw result in data', async () => { + const report = JSON.stringify({ + success: false, + device_id: 'emulator-5554', + results: [{ file: 'run-test.yml', success: false, error: 'Assertion is false', message: 'Flow execution failed' }], + env_vars: { MAESTRO_CLI_NO_ANALYTICS: 'false' }, + message: 'Some flows failed to execute', + }); + stubFetch(() => ({ + body: { command_id: 'c1', status: 'failed', result: { success: false, error: report }, session: session('a-b-c') }, + })); + const { exit, out } = await runJson('run', { id: 'a-b-c', yaml: '- tapOn: Go' }); + expect(exit).to.equal(DEVICE_EXIT.failed); + expect(out.error).to.deep.equal({ code: null, message: 'run-test.yml: Assertion is false' }); + expect((out.data as { result: { error: string } }).result.error).to.equal(report); + }); + it('run needs a file, a directory or --yaml', async () => { const { exit } = await runJson('run', { id: 'a-b-c' }); expect(exit).to.equal(DEVICE_EXIT.usage); @@ -491,7 +551,133 @@ describe('dcd device', () => { }); }); + describe('a person in the loop', () => { + const human = (extra: Record = {}) => ({ + waiting: false, + reason: null, + since: null, + expires_at: null, + handed_back: false, + note: null, + handed_back_at: null, + human_in_control: false, + controller_user_id: null, + ...extra, + }); + const waiting = human({ waiting: true, reason: 'Enter the 2FA code', since: '2026-10-19T10:00:00Z' }); + const handedBack = human({ handed_back: true, note: 'Code entered', handed_back_at: '2026-10-19T10:02:00Z' }); + + it('request-human sends the reason and returns where it stands', async () => { + const calls = stubFetch(() => ({ body: { human: waiting, session: session('a-b-c', { state: 'needs_human' }) } })); + const { exit, out } = await runJson('request-human', { reason: ' Enter the 2FA code ', id: 'a-b-c' }); + expect(exit).to.equal(0); + expect(calls).to.have.length(1); + expect(calls[0].method).to.equal('POST'); + expect(calls[0].url).to.equal(`${API}/device/sessions/a-b-c/request-human`); + expect(calls[0].body).to.deep.equal({ reason: 'Enter the 2FA code' }); + expect(out.data).to.deep.equal({ human: waiting }); + }); + + it('request-human needs a reason', async () => { + const calls = stubFetch(() => ({ body: {} })); + const { exit } = await runJson('request-human', { reason: ' ', id: 'a-b-c' }); + expect(exit).to.equal(DEVICE_EXIT.usage); + expect(calls).to.have.length(0); + }); + + it('request-human --wait polls until the session is handed back', async () => { + let polls = 0; + const calls = stubFetch((call) => { + if (call.method === 'POST') return { body: { human: waiting, session: session('a-b-c', { state: 'needs_human' }) } }; + polls += 1; + return { body: { human: handedBack, session: session('a-b-c') } }; + }); + const { exit, out } = await runJson('request-human', { reason: 'Enter the 2FA code', id: 'a-b-c', wait: true }); + expect(exit).to.equal(0); + expect(polls).to.equal(1); + expect(calls.at(-1)!.url).to.equal(`${API}/device/sessions/a-b-c/human`); + expect((out.data as { human: { note: string } }).human.note).to.equal('Code entered'); + }).timeout(15_000); + + it('human --wait returns at once when nobody is waiting', async () => { + const calls = stubFetch(() => ({ body: { human: handedBack, session: session('a-b-c') } })); + const { exit, out } = await runJson('human', { id: 'a-b-c', wait: true }); + expect(exit).to.equal(0); + expect(calls).to.have.length(1); + expect(out.data).to.deep.equal({ human: handedBack }); + }); + + it('human --wait exits 4 when the session ends while waiting', async () => { + stubFetch(() => ({ + body: { human: waiting, session: session('a-b-c', { state: 'expired', release_reason: 'needs_human_timeout' }) }, + })); + const { exit, out } = await runJson('human', { id: 'a-b-c', wait: true }); + expect(exit).to.equal(DEVICE_EXIT.gone); + expect((out.error as { message: string }).message).to.include('ended while waiting for a person'); + }); + + it('status carries the hand-back', async () => { + stubFetch((call) => + call.url.endsWith('/human') + ? { body: { human: handedBack, session: session('a-b-c') } } + : { body: { session: session('a-b-c') } }, + ); + const { out } = await runJson('status', { id: 'a-b-c' }); + expect(out.data).to.deep.equal({ human: handedBack }); + }); + + it('status still works on an API without the human route', async () => { + stubFetch((call) => + call.url.endsWith('/human') ? { status: 404, body: { statusCode: 404, message: 'Not Found' } } : { body: { session: session('a-b-c') } }, + ); + const { exit, out } = await runJson('status', { id: 'a-b-c' }); + expect(exit).to.equal(0); + expect(out.data).to.equal(null); + }); + + it('a driving command blocked by a person says how to wait for them', async () => { + stubFetch(() => ({ + status: 409, + body: { statusCode: 409, message: 'A person has taken control of this device.', error: 'LIVE_HUMAN_IN_CONTROL' }, + })); + const { exit, out } = await runJson('launch', { id: 'a-b-c' }); + expect(exit).to.equal(DEVICE_EXIT.failed); + const error = out.error as { code: string; message: string }; + expect(error.code).to.equal('LIVE_HUMAN_IN_CONTROL'); + expect(error.message).to.include('A person has taken control of a-b-c'); + expect(error.message).to.include('dcd device human a-b-c --wait'); + }); + }); + describe('helpers', () => { + it('formatWhen says how far off a time is', () => { + const now = Date.parse('2026-10-19T10:00:00Z'); + expect(formatWhen('2026-10-19T10:12:00Z', now)).to.match(/^in 12m \(.+\)$/); + expect(formatWhen('2026-10-19T09:57:30Z', now)).to.match(/^2m 30s ago \(.+\)$/); + expect(formatWhen('not a time', now)).to.equal('not a time'); + }); + + it('humanFields shows the reason while waiting and the note once handed back', () => { + const base = { + waiting: true, + reason: 'Enter the 2FA code', + since: '2026-10-19T10:00:00Z', + expires_at: '2026-10-19T10:30:00Z', + handed_back: false, + note: null, + handed_back_at: null, + human_in_control: false, + controller_user_id: null, + }; + const now = Date.parse('2026-10-19T10:00:00Z'); + const labels = (h: typeof base | Record) => + humanFields(h as typeof base, now).map(([label]) => label); + expect(labels(base)).to.deep.equal(['state', 'reason', 'asked', 'ends']); + const back = { ...base, waiting: false, reason: null, since: null, handed_back: true, note: 'Done', handed_back_at: '2026-10-19T10:00:00Z' }; + expect(humanFields(back, now)).to.deep.include(['note', 'Done']); + expect(humanFields(back, now)[0]).to.deep.equal(['state', 'handed back']); + }); + it('newLogLines prints only what the last fetch did not', () => { expect(newLogLines(['a', 'b', 'c'], ['b', 'c', 'd', 'e'])).to.deep.equal(['d', 'e']); expect(newLogLines(['a', 'b'], ['a', 'b'])).to.deep.equal([]); diff --git a/test/unit/device-run.test.ts b/test/unit/device-run.test.ts new file mode 100644 index 0000000..3c24e4e --- /dev/null +++ b/test/unit/device-run.test.ts @@ -0,0 +1,123 @@ +import { expect } from 'chai'; +import { stripVTControlCharacters } from 'node:util'; + +import { parseRunReport, printRunResult, runFailureMessage } from '../../src/utils/device-run.js'; + +/** A directory run that failed, as the runner reports it (from QA). */ +const FAILED_DIR = JSON.stringify({ + success: false, + device_id: 'emulator-5554', + total_flows: 1, + total_commands_executed: 0, + results: [ + { + file: 'run-test.yml', + success: false, + error: 'Assertion is false: "Welcome" is visible', + message: 'Flow execution failed', + }, + ], + env_vars: { MAESTRO_CLI_NO_ANALYTICS: 'false' }, + message: 'Some flows failed to execute', +}); + +function printed(passed: boolean, result: Parameters[2], label = 'flow.yaml'): string { + const lines: string[] = []; + printRunResult((line) => lines.push(stripVTControlCharacters(line)), passed, result, label); + return lines.join('\n'); +} + +describe('dcd device run output', () => { + describe('parseRunReport', () => { + it('reads a directory report from error, one row per flow', () => { + expect(parseRunReport({ success: false, error: FAILED_DIR })).to.deep.equal({ + flows: [{ file: 'run-test.yml', success: false, error: 'Assertion is false: "Welcome" is visible' }], + message: 'Some flows failed to execute', + }); + }); + + it('reads a single-flow report from output', () => { + const output = JSON.stringify({ + success: true, + device_id: 'emulator-5554', + commands_executed: 4, + message: 'Flow executed successfully', + env_vars: { USER: 'me' }, + }); + expect(parseRunReport({ success: true, output })).to.deep.equal({ + flows: [{ file: null, success: true, error: null }], + message: 'Flow executed successfully', + }); + }); + + it('prefers result.flows when the API sends it', () => { + const flows = [ + { file: 'a.yaml', success: true, error: null }, + { file: 'b.yaml', success: false, error: 'Element not found' }, + ]; + expect(parseRunReport({ success: false, flows, error: FAILED_DIR })!.flows).to.deep.equal(flows); + }); + + it('leaves plain text alone', () => { + expect(parseRunReport({ success: false, error: 'Element not found: Login' })).to.equal(null); + expect(parseRunReport({ success: false, error: '{not json' })).to.equal(null); + expect(parseRunReport({ success: true, output: '{"other":1}' })).to.equal(null); + expect(parseRunReport({ success: true })).to.equal(null); + }); + }); + + describe('runFailureMessage', () => { + it('names each failing flow and why, not the raw report', () => { + const result = { success: false, error: FAILED_DIR }; + expect(runFailureMessage(result, parseRunReport(result))).to.equal( + 'run-test.yml: Assertion is false: "Welcome" is visible', + ); + }); + + it('falls back to the error text, then a default', () => { + expect(runFailureMessage({ success: false, error: 'boom' }, null)).to.equal('boom'); + expect(runFailureMessage({ success: false }, null)).to.equal('The flow failed.'); + }); + }); + + describe('printRunResult', () => { + it('prints one line per flow and a summary, without the runner fields', () => { + const out = printed(false, { success: false, error: FAILED_DIR }); + expect(out).to.include('Flow failed'); + expect(out).to.include('run-test.yml'); + expect(out).to.include('Assertion is false'); + expect(out).not.to.include('env_vars'); + expect(out).not.to.include('MAESTRO_CLI_NO_ANALYTICS'); + expect(out).not.to.include('emulator-5554'); + expect(out).not.to.include('total_commands_executed'); + }); + + it('counts the failures in a run of several flows', () => { + const out = printed(false, { + success: false, + flows: [ + { file: 'a.yaml', success: true, error: null }, + { file: 'b.yaml', success: false, error: 'Element not found' }, + ], + }); + expect(out).to.include('1 of 2 flows failed'); + expect(out).to.match(/a\.yaml\s+passed/); + expect(out).to.match(/b\.yaml\s+failed\s+Element not found/); + }); + + it('names a single flow by its label and drops the raw JSON on a pass', () => { + const output = JSON.stringify({ success: true, device_id: 'x', commands_executed: 3, message: 'ok' }); + const out = printed(true, { success: true, output }, 'login.yaml'); + expect(out).to.include('Flow passed'); + expect(out).to.match(/login\.yaml\s+passed/); + expect(out).not.to.include('device_id'); + expect(out).not.to.include('Output'); + }); + + it('shows plain text as it came', () => { + const out = printed(false, { success: false, error: 'Element not found: Login' }); + expect(out).to.include('Error'); + expect(out).to.include('Element not found: Login'); + }); + }); +}); diff --git a/test/unit/hidden-help.test.ts b/test/unit/encrypt-help.test.ts similarity index 63% rename from test/unit/hidden-help.test.ts rename to test/unit/encrypt-help.test.ts index 89029ea..871ce2a 100644 --- a/test/unit/hidden-help.test.ts +++ b/test/unit/encrypt-help.test.ts @@ -7,11 +7,10 @@ import * as path from 'node:path'; import { cloudCommand } from '../../src/commands/cloud.js'; import { uploadCommand } from '../../src/commands/upload.js'; -import { withoutHiddenArgs } from '../../src/utils/help.js'; /** - * While client-side encryption is a per-org beta (dcd#1138), --encrypt and - * --no-encrypt are left out of --help but must still parse. + * Client-side encryption is on by default (dcd#1138), so --encrypt is listed + * in --help, and --no-encrypt, the per-run opt-out, must parse. */ const commands: Array<[string, CommandDef]> = [ ['cloud', cloudCommand as CommandDef], @@ -22,21 +21,16 @@ async function argsOf(cmd: CommandDef): Promise { return (typeof cmd.args === 'function' ? await cmd.args() : await cmd.args) ?? {}; } -describe('hidden --encrypt flag', () => { +describe('--encrypt flag', () => { for (const [name, cmd] of commands) { describe(`dcd ${name}`, () => { - it('leaves encrypt out of the usage', async () => { - const usage = await renderUsage(await withoutHiddenArgs(cmd)); - expect(usage).to.not.match(/encrypt/i); - // Everything else still renders. - expect(usage).to.contain('--api-key'); + it('lists encrypt in the usage, with the opt-out', async () => { + const usage = await renderUsage(cmd); + expect(usage).to.contain('--encrypt'); + expect(usage).to.contain('--no-encrypt or DCD_ENCRYPT=0 turns it off'); }); - it('would list it without the filter, so the test above means something', async () => { - expect(await renderUsage(cmd)).to.contain('--encrypt'); - }); - - it('still parses --encrypt and --no-encrypt', async () => { + it('parses --encrypt and --no-encrypt', async () => { const args = await argsOf(cmd); expect(parseArgs(['--encrypt'], args).encrypt).to.equal(true); expect(parseArgs(['--no-encrypt'], args).encrypt).to.equal(false); @@ -45,11 +39,6 @@ describe('hidden --encrypt flag', () => { }); } - it('does not change the command it filters', async () => { - await withoutHiddenArgs(cloudCommand as CommandDef); - expect(await argsOf(cloudCommand as CommandDef)).to.have.property('encrypt'); - }); - describe('through the real entry point', () => { let configDir: string; @@ -62,7 +51,7 @@ describe('hidden --encrypt flag', () => { }); for (const name of ['cloud', 'upload']) { - it(`dcd ${name} --help does not mention encrypt`, () => { + it(`dcd ${name} --help lists --encrypt`, () => { const out = execFileSync( process.execPath, ['--import', 'tsx/esm', path.resolve('src/index.ts'), name, '--help'], @@ -77,7 +66,7 @@ describe('hidden --encrypt flag', () => { }, ); expect(out).to.contain(`dcd ${name}`); - expect(out).to.not.match(/encrypt/i); + expect(out).to.contain('--encrypt'); }); } }); diff --git a/test/unit/encrypted-dedup.test.ts b/test/unit/encrypted-dedup.test.ts index 47e4c5c..65ba6ee 100644 --- a/test/unit/encrypted-dedup.test.ts +++ b/test/unit/encrypted-dedup.test.ts @@ -66,7 +66,7 @@ function mockFetch( return calls; } -/** A throwaway X25519 public key so encryption can run without a pinned KEK. */ +/** A throwaway X25519 public key so encryption can run without asking an API for one. */ function setTestKek() { const { publicKey } = generateKeyPairSync('x25519'); const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(12); diff --git a/test/unit/encryption-decision.test.ts b/test/unit/encryption-decision.test.ts index cda1aa7..2621498 100644 --- a/test/unit/encryption-decision.test.ts +++ b/test/unit/encryption-decision.test.ts @@ -7,7 +7,7 @@ import { /** * The order that decides whether a run encrypts (dcd#1138): an explicit flag, - * then DCD_ENCRYPT (or its legacy alias), then the org's default from the + * then DCD_ENCRYPT (or its legacy alias), then the server default from the * compatibility data, then off. Every case passes `env` explicitly so the * developer's own shell can't leak in. */ diff --git a/test/unit/envelope.test.ts b/test/unit/envelope.test.ts index 2c8f1a0..36cfce1 100644 --- a/test/unit/envelope.test.ts +++ b/test/unit/envelope.test.ts @@ -1,4 +1,6 @@ import { expect } from 'chai'; + +import { resetCliConfigCache } from '../../src/config/cli-config.js'; import { createDecipheriv, createPublicKey, @@ -160,10 +162,10 @@ describe('binary envelope encryption (#1138)', () => { expect(() => refDecryptContainer(ciphertext, dek)).to.throw(); }); - it('wraps a DEK that the matching KEK private key unwraps', () => { + it('wraps a DEK that the matching KEK private key unwraps', async () => { const { privRaw, pubRaw } = rawX25519(); process.env.DCD_BINARY_KEK_PUBLIC = `3:${pubRaw.toString('base64')}`; - const kek = resolveKekPublicKey('https://api.devicecloud.dev'); + const kek = await resolveKekPublicKey('https://api.devicecloud.dev'); expect(kek).to.not.equal(null); const dek = generateDek(); @@ -175,20 +177,38 @@ describe('binary envelope encryption (#1138)', () => { ); }); - it('resolves the pinned KEK public key for each environment', () => { - const prod = resolveKekPublicKey('https://api.devicecloud.dev'); - expect(prod).to.not.equal(null); - expect(prod!.version).to.equal(1); - expect(prod!.keyRaw.toString('base64')).to.equal( - 'wtfyWEwK7nJzwI4PD+9RAW8jxIR1u8kMQq2IhsrVnH4=', - ); + it('resolves the KEK public key the API publishes on /cli-config', async () => { + const { privRaw, pubRaw } = rawX25519(); + const realFetch = globalThis.fetch; + const requested: string[] = []; + globalThis.fetch = (async (input: string | URL | Request) => { + requested.push(String(input)); + return new Response( + JSON.stringify({ + supabase_publishable_key: 'sb_publishable_x', + binary_kek_public_key: { version: 4, key: pubRaw.toString('base64') }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }) as typeof fetch; + try { + resetCliConfigCache(); + const kek = await resolveKekPublicKey('https://api.example.test/'); + expect(kek).to.not.equal(null); + expect(kek!.version).to.equal(4); + expect(kek!.keyRaw.equals(pubRaw)).to.equal(true); + expect(requested).to.deep.equal(['https://api.example.test/cli-config']); - const dev = resolveKekPublicKey('https://api.dev.devicecloud.dev'); - expect(dev).to.not.equal(null); - expect(dev!.version).to.equal(1); - expect(dev!.keyRaw.toString('base64')).to.equal( - 'RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=', - ); + const dek = generateDek(); + const envelope = wrapDek(dek, kek!); + expect(envelope.kek).to.equal(4); + expect(refUnwrapDek(envelope.wrapped_key, privRaw).equals(dek)).to.equal( + true, + ); + } finally { + globalThis.fetch = realFetch; + resetCliConfigCache(); + } }); }); @@ -197,10 +217,10 @@ describe('flow + env envelope encryption (#1151, #1152)', () => { delete process.env.DCD_BINARY_KEK_PUBLIC; }); - it('encryptFlowBuffer produces a container the platform decrypts to the original zip', () => { + it('encryptFlowBuffer produces a container the platform decrypts to the original zip', async () => { const { privRaw, pubRaw } = rawX25519(); process.env.DCD_BINARY_KEK_PUBLIC = `2:${pubRaw.toString('base64')}`; - const kek = resolveKekPublicKey('https://api.dev.devicecloud.dev')!; + const kek = (await resolveKekPublicKey('https://api.dev.devicecloud.dev'))!; const zip = randomBytes(5000); const { ciphertext, enc } = encryptFlowBuffer(zip, kek); @@ -212,10 +232,10 @@ describe('flow + env envelope encryption (#1151, #1152)', () => { expect(refDecryptContainer(ciphertext, dek).equals(zip)).to.equal(true); }); - it('encryptEnv produces an inline envelope the platform decrypts back to the map', () => { + it('encryptEnv produces an inline envelope the platform decrypts back to the map', async () => { const { privRaw, pubRaw } = rawX25519(); process.env.DCD_BINARY_KEK_PUBLIC = `1:${pubRaw.toString('base64')}`; - const kek = resolveKekPublicKey('https://api.dev.devicecloud.dev')!; + const kek = (await resolveKekPublicKey('https://api.dev.devicecloud.dev'))!; const env = { API_TOKEN: 'secret', PASSWORD: 'p@ss word=1', EMPTY: '' }; const enc = encryptEnv(env, kek); @@ -228,10 +248,10 @@ describe('flow + env envelope encryption (#1151, #1152)', () => { expect(JSON.parse(plain.toString('utf8'))).to.deep.equal(env); }); - it('gives the flow zip and env their own distinct DEKs', () => { + it('gives the flow zip and env their own distinct DEKs', async () => { const { pubRaw } = rawX25519(); process.env.DCD_BINARY_KEK_PUBLIC = `1:${pubRaw.toString('base64')}`; - const kek = resolveKekPublicKey('https://api.dev.devicecloud.dev')!; + const kek = (await resolveKekPublicKey('https://api.dev.devicecloud.dev'))!; const flow = encryptFlowBuffer(randomBytes(100), kek); const env = encryptEnv({ A: 'b' }, kek); diff --git a/test/unit/kek-from-api.test.ts b/test/unit/kek-from-api.test.ts new file mode 100644 index 0000000..a57a5b1 --- /dev/null +++ b/test/unit/kek-from-api.test.ts @@ -0,0 +1,194 @@ +import { expect } from 'chai'; +import { generateKeyPairSync } from 'node:crypto'; + +import { getCliConfig, resetCliConfigCache } from '../../src/config/cli-config.js'; +import { + checkKekForEncryption, + resolveKekPublicKey, +} from '../../src/utils/envelope.js'; + +/** + * The KEK public key comes from the API the upload goes to (dcd#1138): its + * `GET /cli-config` publishes the newest KEK it holds the private half of, so + * a rotation is an env change on the API and never a CLI release. Encrypting + * for a key the receiving API cannot unwrap produces uploads it can't decrypt, + * which is why there is no fallback to a compiled-in key any more. + */ +describe('KEK public key from /cli-config', () => { + const ORIGINAL_OVERRIDE = process.env.DCD_BINARY_KEK_PUBLIC; + const realFetch = globalThis.fetch; + let requested: string[]; + + function rawPublicKey(): string { + const { publicKey } = generateKeyPairSync('x25519'); + return publicKey.export({ format: 'der', type: 'spki' }).subarray(12).toString('base64'); + } + + function stubFetch(respond: (url: string) => Response | Promise): void { + requested = []; + globalThis.fetch = (async (input: string | URL | Request) => { + requested.push(String(input)); + return respond(String(input)); + }) as typeof fetch; + } + + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + + beforeEach(() => { + delete process.env.DCD_BINARY_KEK_PUBLIC; + resetCliConfigCache(); + }); + + afterEach(() => { + globalThis.fetch = realFetch; + resetCliConfigCache(); + if (ORIGINAL_OVERRIDE === undefined) delete process.env.DCD_BINARY_KEK_PUBLIC; + else process.env.DCD_BINARY_KEK_PUBLIC = ORIGINAL_OVERRIDE; + }); + + describe('resolveKekPublicKey', () => { + it('uses the key the API publishes, for any API URL', async () => { + const key = rawPublicKey(); + stubFetch(() => json({ binary_kek_public_key: { version: 2, key } })); + + const kek = await resolveKekPublicKey('https://my-proxy.corp.test/devicecloud'); + expect(kek?.version).to.equal(2); + expect(kek?.keyRaw.toString('base64')).to.equal(key); + expect(requested).to.deep.equal(['https://my-proxy.corp.test/devicecloud/cli-config']); + }); + + it('fetches the config once per process and shares it with the Supabase key', async () => { + const key = rawPublicKey(); + stubFetch(() => + json({ supabase_publishable_key: 'sb_publishable_x', binary_kek_public_key: { version: 1, key } }), + ); + + await resolveKekPublicKey('https://api.devicecloud.dev/'); + await resolveKekPublicKey('https://api.devicecloud.dev'); + expect((await getCliConfig('https://api.devicecloud.dev')).supabase_publishable_key).to.equal( + 'sb_publishable_x', + ); + expect(requested).to.deep.equal(['https://api.devicecloud.dev/cli-config']); + }); + + it('has no key when the API publishes none', async () => { + stubFetch(() => json({ supabase_publishable_key: 'sb_publishable_x' })); + expect(await resolveKekPublicKey('https://api.example.com')).to.equal(null); + }); + + it('rejects a malformed published key rather than wrapping with it', async () => { + stubFetch(() => json({ binary_kek_public_key: { version: 1, key: 'not-32-bytes' } })); + let message = ''; + try { + await resolveKekPublicKey('https://api.example.com'); + } catch (error) { + message = (error as Error).message; + } + expect(message).to.contain('malformed binary_kek_public_key'); + }); + + it('fails, and does not cache the failure, when the API cannot be asked', async () => { + stubFetch(() => json({}, 503)); + let failed = false; + try { + await resolveKekPublicKey('https://api.example.com'); + } catch { + failed = true; + } + expect(failed).to.equal(true); + + const key = rawPublicKey(); + stubFetch(() => json({ binary_kek_public_key: { version: 1, key } })); + expect((await resolveKekPublicKey('https://api.example.com'))?.version).to.equal(1); + }); + + it('prefers DCD_BINARY_KEK_PUBLIC without asking the API', async () => { + process.env.DCD_BINARY_KEK_PUBLIC = `7:${rawPublicKey()}`; + stubFetch(() => json({ binary_kek_public_key: { version: 1, key: rawPublicKey() } })); + + expect((await resolveKekPublicKey('https://api.example.com'))?.version).to.equal(7); + expect(requested).to.deep.equal([]); + }); + + it('rejects a malformed DCD_BINARY_KEK_PUBLIC', async () => { + process.env.DCD_BINARY_KEK_PUBLIC = 'nope'; + let message = ''; + try { + await resolveKekPublicKey('https://api.example.com'); + } catch (error) { + message = (error as Error).message; + } + expect(message).to.contain('DCD_BINARY_KEK_PUBLIC must be'); + }); + }); + + describe('checkKekForEncryption', () => { + const NO_KEK = 'https://api.example.com'; + + it('passes a decision through when the API publishes a key', async () => { + stubFetch(() => json({ binary_kek_public_key: { version: 1, key: rawPublicKey() } })); + for (const source of ['flag', 'env', 'server'] as const) { + expect( + await checkKekForEncryption({ enabled: true, source }, 'https://api.dev.devicecloud.dev'), + ).to.deep.equal({ enabled: true, source }); + } + }); + + it('passes a decision to stay off through without asking the API', async () => { + stubFetch(() => json({}, 500)); + expect(await checkKekForEncryption({ enabled: false, source: 'off' }, NO_KEK)).to.deep.equal({ + enabled: false, + source: 'off', + }); + expect(requested).to.deep.equal([]); + }); + + for (const source of ['env', 'server'] as const) { + it(`skips ${source} encryption when the API publishes no key, with a notice`, async () => { + stubFetch(() => json({ supabase_publishable_key: 'sb_publishable_x' })); + const decision = await checkKekForEncryption({ enabled: true, source }, NO_KEK); + expect(decision).to.include({ enabled: false, source }); + expect(decision.notice).to.contain(NO_KEK); + expect(decision.notice).to.contain('DCD_BINARY_KEK_PUBLIC'); + expect(decision.notice).to.not.contain('\n'); + }); + } + + it('refuses an explicit --encrypt when the API publishes no key', async () => { + stubFetch(() => json({ supabase_publishable_key: 'sb_publishable_x' })); + let message = ''; + try { + await checkKekForEncryption({ enabled: true, source: 'flag' }, NO_KEK); + } catch (error) { + message = (error as Error).message; + } + expect(message).to.match(/--encrypt needs the KEK public key.*DCD_BINARY_KEK_PUBLIC/); + }); + + it('fails closed rather than uploading plaintext when the API cannot be asked', async () => { + stubFetch(() => json({}, 503)); + for (const source of ['flag', 'env', 'server'] as const) { + let failed = false; + try { + await checkKekForEncryption({ enabled: true, source }, NO_KEK); + } catch { + failed = true; + } + expect(failed, source).to.equal(true); + } + }); + + it('lets DCD_BINARY_KEK_PUBLIC unlock any API, whatever the source', async () => { + process.env.DCD_BINARY_KEK_PUBLIC = `7:${rawPublicKey()}`; + stubFetch(() => json({}, 500)); + for (const source of ['flag', 'env', 'server'] as const) { + expect(await checkKekForEncryption({ enabled: true, source }, NO_KEK)).to.deep.equal({ + enabled: true, + source, + }); + } + expect(requested).to.deep.equal([]); + }); + }); +}); diff --git a/test/unit/kek-pinning.test.ts b/test/unit/kek-pinning.test.ts deleted file mode 100644 index 517cf5f..0000000 --- a/test/unit/kek-pinning.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { expect } from 'chai'; -import { generateKeyPairSync } from 'node:crypto'; - -import { - inferEnvFromApiUrl, - kekEnvForApiUrl, -} from '../../src/config/environments.js'; -import { - checkKekForEncryption, - resolveKekPublicKey, -} from '../../src/utils/envelope.js'; - -/** - * A pinned KEK only applies to the API it belongs to (dcd#1138). Encrypting - * for the prod KEK against any other URL, which inferEnvFromApiUrl's prod - * fallback used to do, produces uploads that API can't decrypt. - */ -describe('KEK pinning by API URL', () => { - const ORIGINAL_OVERRIDE = process.env.DCD_BINARY_KEK_PUBLIC; - - beforeEach(() => { - delete process.env.DCD_BINARY_KEK_PUBLIC; - }); - - afterEach(() => { - if (ORIGINAL_OVERRIDE === undefined) delete process.env.DCD_BINARY_KEK_PUBLIC; - else process.env.DCD_BINARY_KEK_PUBLIC = ORIGINAL_OVERRIDE; - }); - - function setTestKek() { - const { publicKey } = generateKeyPairSync('x25519'); - const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(12); - process.env.DCD_BINARY_KEK_PUBLIC = `7:${raw.toString('base64')}`; - } - - describe('kekEnvForApiUrl', () => { - const known: Array<[string, 'dev' | 'prod']> = [ - ['https://api.devicecloud.dev', 'prod'], - ['https://api.devicecloud.dev/', 'prod'], - ['https://api.dev.devicecloud.dev', 'dev'], - ['https://api.dev.devicecloud.dev/', 'dev'], - ['http://localhost:3000', 'dev'], - ['http://localhost', 'dev'], - ]; - for (const [url, env] of known) { - it(`maps ${url} to ${env}`, () => { - expect(kekEnvForApiUrl(url)).to.equal(env); - }); - } - - const unknown = [ - 'https://api.example.com', - // inferEnvFromApiUrl reads any `api.dev.` host as dev. - 'https://api.dev.example.com', - 'https://devicecloud.dev.attacker.test', - 'http://127.0.0.1:3000', - 'https://my-proxy.corp.test/devicecloud', - 'not a url', - ]; - for (const url of unknown) { - it(`does not know ${url}`, () => { - expect(kekEnvForApiUrl(url)).to.equal(undefined); - }); - } - - it('leaves inferEnvFromApiUrl falling back to prod for its other callers', () => { - expect(inferEnvFromApiUrl('https://api.example.com')).to.equal('prod'); - expect(inferEnvFromApiUrl('https://api.dev.example.com')).to.equal('dev'); - }); - }); - - describe('resolveKekPublicKey', () => { - it('has no key for an unknown API URL', () => { - expect(resolveKekPublicKey('https://api.example.com')).to.equal(null); - }); - - it('uses DCD_BINARY_KEK_PUBLIC for any URL, known or not', () => { - setTestKek(); - expect(resolveKekPublicKey('https://api.example.com')?.version).to.equal(7); - expect(resolveKekPublicKey('https://api.devicecloud.dev')?.version).to.equal(7); - }); - }); - - describe('checkKekForEncryption', () => { - const UNKNOWN = 'https://api.example.com'; - - it('passes a decision through for a known API URL', () => { - for (const source of ['flag', 'env', 'server'] as const) { - expect( - checkKekForEncryption({ enabled: true, source }, 'https://api.dev.devicecloud.dev'), - ).to.deep.equal({ enabled: true, source }); - } - }); - - it('passes a decision to stay off through, whatever the URL', () => { - expect(checkKekForEncryption({ enabled: false, source: 'off' }, UNKNOWN)).to.deep.equal({ - enabled: false, - source: 'off', - }); - }); - - for (const source of ['env', 'server'] as const) { - it(`skips ${source} encryption on an unknown URL, with a notice`, () => { - const decision = checkKekForEncryption({ enabled: true, source }, UNKNOWN); - expect(decision).to.include({ enabled: false, source }); - expect(decision.notice).to.contain(UNKNOWN); - expect(decision.notice).to.contain('DCD_BINARY_KEK_PUBLIC'); - expect(decision.notice).to.not.contain('\n'); - }); - } - - it('refuses an explicit --encrypt on an unknown URL', () => { - expect(() => - checkKekForEncryption({ enabled: true, source: 'flag' }, UNKNOWN), - ).to.throw(/--encrypt needs the KEK public key.*DCD_BINARY_KEK_PUBLIC/); - }); - - it('lets DCD_BINARY_KEK_PUBLIC unlock an unknown URL, whatever the source', () => { - setTestKek(); - for (const source of ['flag', 'env', 'server'] as const) { - expect(checkKekForEncryption({ enabled: true, source }, UNKNOWN)).to.deep.equal({ - enabled: true, - source, - }); - } - }); - }); -}); diff --git a/test/unit/org-selection.test.ts b/test/unit/org-selection.test.ts new file mode 100644 index 0000000..3554f82 --- /dev/null +++ b/test/unit/org-selection.test.ts @@ -0,0 +1,311 @@ +import { expect } from 'chai'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { PassThrough, Writable } from 'node:stream'; +import { stripVTControlCharacters } from 'node:util'; + +import { loginCommand } from '../../src/commands/login.js'; +import { telemetry } from '../../src/services/telemetry.service.js'; +import { canPrompt } from '../../src/utils/ci.js'; +import { CliError } from '../../src/utils/cli.js'; +import { readConfig, writeConfig } from '../../src/utils/config-store.js'; +import { + matchOrg, + OrgListItem, + selectOrg, + SelectOrgOptions, +} from '../../src/utils/orgs.js'; + +/** + * How `dcd login` picks an org (dcd-cli#241): `--org`, else the only org, else + * the picker starting on the last login's org. Without a TTY nobody can answer + * the picker, so the last login's org is used, and a missing one is an error + * rather than a hang. + */ +describe('login org selection', () => { + const ACME: OrgListItem = { id: '1', name: 'Acme Corp', slug: 'acme' }; + const BETA: OrgListItem = { id: '2', name: 'Beta Labs', slug: 'beta' }; + const ORGS = [ACME, BETA]; + + /** The message of the CliError selectOrg rejects with. */ + async function selectError(orgs: OrgListItem[], opts: SelectOrgOptions): Promise { + try { + await selectOrg(orgs, opts); + } catch (error) { + expect(error).to.be.instanceOf(CliError); + return (error as Error).message; + } + + throw new Error('expected selectOrg to throw'); + } + + describe('selectOrg', () => { + it('uses --org by name, ignoring case, over the last org', async () => { + for (const interactive of [true, false]) { + expect( + await selectOrg(ORGS, { requested: 'BETA LABS', lastOrgId: ACME.id, interactive }), + ).to.deep.equal({ org: BETA, fromLastLogin: false }); + } + }); + + it('matches --org by id or slug too', async () => { + expect((await selectOrg(ORGS, { requested: '2', interactive: false })).org).to.equal(BETA); + expect((await selectOrg(ORGS, { requested: 'acme', interactive: false })).org).to.equal(ACME); + }); + + it('rejects an --org the user is not a member of, naming the ones they are', async () => { + expect(await selectError(ORGS, { requested: 'Nope', interactive: false })).to.equal( + 'No org named "Nope". Available: Acme Corp, Beta Labs', + ); + }); + + it('checks --org even when there is only one org', async () => { + expect( + await selectError([ACME], { requested: 'Beta Labs', interactive: false }), + ).to.match(/^No org named "Beta Labs"/); + }); + + it('points an ambiguous --org name at the picker', async () => { + const twin = { id: '3', name: 'Acme Corp' }; + expect( + await selectError([ACME, twin], { requested: 'acme corp', interactive: true }), + ).to.equal( + 'Multiple orgs named "acme corp". Run `dcd login` without --org to pick interactively.', + ); + }); + + it('fails when the user has no orgs', async () => { + expect(await selectError([], { interactive: true })).to.equal( + 'No organizations found for this user.', + ); + }); + + it('uses the only org without prompting or needing a last org', async () => { + expect(await selectOrg([ACME], { interactive: false })).to.deep.equal({ + org: ACME, + fromLastLogin: false, + }); + }); + + it('without a TTY, uses the last login org and says it did', async () => { + expect(await selectOrg(ORGS, { lastOrgId: BETA.id, interactive: false })).to.deep.equal({ + org: BETA, + fromLastLogin: true, + }); + }); + + it('without a TTY or a last org, fails pointing at --org instead of hanging', async () => { + expect(await selectError(ORGS, { interactive: false })).to.equal( + "You belong to more than one organization, and there's no terminal to pick one in. Run `dcd login --org ` with one of: Acme Corp, Beta Labs", + ); + }); + + it('without a TTY, fails when the last org is no longer available', async () => { + expect(await selectError(ORGS, { lastOrgId: '9', interactive: false })).to.equal( + "The organization from your last login is no longer available, and there's no terminal to pick another in. Run `dcd login --org ` with one of: Acme Corp, Beta Labs", + ); + }); + }); + + describe('the picker', () => { + /** Open the picker, press Enter, and return the org it chose. */ + async function pressEnter(lastOrgId: string | undefined): Promise { + const input = new PassThrough(); + const output = new Writable({ write: (_chunk, _encoding, done) => done() }); + const selection = selectOrg(ORGS, { lastOrgId, interactive: true, input, output }); + setImmediate(() => input.write('\r')); + return (await selection).org; + } + + it('starts on the last login org, so Enter keeps it', async () => { + expect(await pressEnter(BETA.id)).to.equal(BETA); + }); + + it('starts on the first org when the last one is gone', async () => { + expect(await pressEnter('9')).to.equal(ACME); + }); + }); + + describe('matchOrg', () => { + it('keeps the switch-org hint for an ambiguous name', () => { + const twin = { id: '3', name: 'Acme Corp' }; + expect(() => + matchOrg([ACME, twin], 'Acme Corp', '`dcd switch-org` without arguments'), + ).to.throw( + CliError, + 'Multiple orgs named "Acme Corp". Run `dcd switch-org` without arguments to pick interactively.', + ); + }); + }); + + describe('canPrompt', () => { + const realIsTTY = process.stdin.isTTY; + const setStdinTTY = (value: boolean | undefined) => { + // isTTY is a plain (writable) property on the stream in Node. + (process.stdin as { isTTY?: boolean }).isTTY = value; + }; + + afterEach(() => setStdinTTY(realIsTTY)); + + it('needs stdin and the output the prompt draws on to both be terminals', () => { + setStdinTTY(true); + expect(canPrompt({ isTTY: true })).to.equal(true); + expect(canPrompt({ isTTY: false })).to.equal(false); + expect(canPrompt({})).to.equal(false); + + setStdinTTY(false); + expect(canPrompt({ isTTY: true })).to.equal(false); + }); + }); + + describe('dcd login without a TTY', () => { + const API_URL = 'https://api.devicecloud.dev'; + const ORIGINAL_ENV = { ...process.env }; + const realFetch = globalThis.fetch; + const realExit = process.exit; + const realFlushSync = telemetry.flushSync; + const realIsTTY = process.stdin.isTTY; + let tempDir: string; + + const claimed = { + access_token: 'new-access', + refresh_token: 'new-refresh', + expires_at: Math.floor(Date.now() / 1000) + 3600, + user_email: 'u@example.com', + user_id: 'u1', + }; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-login-org-test-')); + process.env.DCD_CONFIG_DIR = tempDir; + (process.stdin as { isTTY?: boolean }).isTTY = false; + + const json = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + globalThis.fetch = (async (input: unknown) => { + const url = String(input); + if (url === `${API_URL}/cli-login/claim`) return json(claimed); + if (url === `${API_URL}/me/orgs`) return json({ orgs: ORGS }); + throw new Error(`unexpected fetch: ${url}`); + }) as typeof fetch; + + // A failure must fail the test, not end the mocha process, and must not + // ship failure telemetry to the real API on its way out. + process.exit = ((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as typeof process.exit; + telemetry.flushSync = () => {}; + }); + + afterEach(() => { + globalThis.fetch = realFetch; + process.exit = realExit; + telemetry.flushSync = realFlushSync; + (process.stdin as { isTTY?: boolean }).isTTY = realIsTTY; + process.env = { ...ORIGINAL_ENV }; + fs.rmSync(tempDir, { force: true, recursive: true }); + }); + + /** A still-valid session from an earlier login to `apiUrl` that chose Beta Labs. */ + const loggedInBefore = (apiUrl: string) => + writeConfig({ + version: 1, + env: apiUrl === API_URL ? 'prod' : 'dev', + api_url: apiUrl, + supabase_url: 'https://cloud.devicecloud.dev', + session: { + access_token: 'old-access', + refresh_token: 'old-refresh', + expires_at: Math.floor(Date.now() / 1000) + 3600, + user_email: 'u@example.com', + user_id: 'u1', + }, + current_org_id: BETA.id, + current_org_name: BETA.name, + }); + + /** Run `dcd login --json --no-browser`, capturing its output and the error it ends with. */ + async function login(args: Record = {}) { + let stdout = ''; + let stderr = ''; + let error: unknown; + const realStdout = process.stdout.write.bind(process.stdout); + const realStderr = process.stderr.write.bind(process.stderr); + process.stdout.write = ((chunk: string) => { + stdout += chunk; + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: string) => { + stderr += chunk; + return true; + }) as typeof process.stderr.write; + try { + await (loginCommand.run as (ctx: { args: Record }) => Promise)({ + args: { 'api-url': API_URL, browser: false, json: true, ...args }, + }); + } catch (caught) { + error = caught; + } finally { + process.stdout.write = realStdout; + process.stderr.write = realStderr; + } + + return { stdout, stderr: stripVTControlCharacters(stderr), error }; + } + + it('signs in again over a live session and keeps the last org, without prompting', async () => { + loggedInBefore(API_URL); + const { stdout, stderr, error } = await login(); + + expect(error).to.equal(undefined); + expect(JSON.parse(stdout)).to.deep.equal({ + status: 'SUCCESS', + user: 'u@example.com', + org: { id: BETA.id, name: BETA.name }, + env: 'prod', + }); + expect(stderr).to.include('Already logged in as u@example.com — signing in again.'); + expect(stderr).to.include( + 'No terminal to pick an organization in, so using Beta Labs from your last login.', + ); + expect(readConfig()).to.include({ current_org_id: BETA.id, current_org_name: BETA.name }); + expect(readConfig()!.session!.access_token).to.equal('new-access'); + }); + + it('uses --org over the last org', async () => { + loggedInBefore(API_URL); + const { stdout, stderr, error } = await login({ org: 'acme' }); + + expect(error).to.equal(undefined); + expect(JSON.parse(stdout).org).to.deep.equal({ id: ACME.id, name: ACME.name }); + expect(stderr).to.not.include('from your last login'); + expect(readConfig()!.current_org_id).to.equal(ACME.id); + }); + + it('ignores an org saved by a login to another API, as org ids differ per environment', async () => { + loggedInBefore('https://api.dev.devicecloud.dev'); + const { stdout, error } = await login(); + + expect((error as Error).message).to.equal('process.exit(1)'); + expect(JSON.parse(stdout)).to.deep.equal({ + status: 'FAILED', + error: + "You belong to more than one organization, and there's no terminal to pick one in. Run `dcd login --org ` with one of: Acme Corp, Beta Labs", + }); + // Nothing was saved: the old dev session is still there. + expect(readConfig()!.session!.access_token).to.equal('old-access'); + }); + + it('rejects a blank --org before signing in', async () => { + const { stdout, error } = await login({ org: ' ' }); + + expect(error).to.be.instanceOf(CliError); + expect((error as Error).message).to.equal('--org needs an organization name or id.'); + expect(stdout).to.equal(''); + }); + }); +}); diff --git a/test/unit/session-refresh.test.ts b/test/unit/session-refresh.test.ts index c2cf354..919545b 100644 --- a/test/unit/session-refresh.test.ts +++ b/test/unit/session-refresh.test.ts @@ -22,7 +22,6 @@ import { refreshAuth, resolveAuth, } from '../../src/utils/auth.js'; -import { CliError } from '../../src/utils/cli.js'; import { readConfig, writeConfig } from '../../src/utils/config-store.js'; /** @@ -157,7 +156,7 @@ describe('session refresh for long-running processes', () => { expect(readConfig()!.session!.refresh_token).to.equal('refresh-2'); }); - it('classifies a refused refresh token as final and drops the dead session', async () => { + it('classifies a refused refresh token as final but keeps the stored session', async () => { storeSession('stale', 10); CliAuthGateway.refresh = async () => { throw new SessionRefreshError('Invalid Refresh Token: Already Used', true); @@ -171,17 +170,9 @@ describe('session refresh for long-running processes', () => { } 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); + // Only `dcd logout` / `dcd login` replace the session; a retry or an + // upgrade may still get past the refusal. + expect(readConfig()!.session!.access_token).to.equal('stale'); }); it('treats a network failure during refresh as retryable', () => { diff --git a/test/unit/supabase-key.test.ts b/test/unit/supabase-key.test.ts new file mode 100644 index 0000000..20bd390 --- /dev/null +++ b/test/unit/supabase-key.test.ts @@ -0,0 +1,96 @@ +import { expect } from 'chai'; + +import { + getSupabaseKey, + resetSupabaseKeyCache, + supabaseKeyHeaders, +} from '../../src/config/supabase-key.js'; +import { CliAuthGateway, SessionRefreshError } from '../../src/gateways/cli-auth-gateway.js'; + +/** + * The Supabase key is fetched from the API's `GET /cli-config` (dcd#1112) + * rather than compiled in, so it can be rotated without a CLI release. + */ +describe('Supabase key from /cli-config', () => { + const realFetch = globalThis.fetch; + let requested: string[]; + + function stubFetch(respond: () => Response | Promise): void { + requested = []; + globalThis.fetch = (async (input: string | URL | Request) => { + requested.push(String(input)); + return respond(); + }) as typeof fetch; + } + + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + + beforeEach(() => resetSupabaseKeyCache()); + afterEach(() => { + globalThis.fetch = realFetch; + resetSupabaseKeyCache(); + }); + + it('fetches the key from the env API once per process', async () => { + stubFetch(() => json({ supabase_publishable_key: 'sb_publishable_abc' })); + + expect(await getSupabaseKey('prod')).to.equal('sb_publishable_abc'); + expect(await getSupabaseKey('prod')).to.equal('sb_publishable_abc'); + expect(requested).to.deep.equal(['https://api.devicecloud.dev/cli-config']); + }); + + it('does not cache a failure, so the next call retries', async () => { + stubFetch(() => json({}, 503)); + let failed = false; + try { + await getSupabaseKey('dev'); + } catch { + failed = true; + } + expect(failed).to.equal(true); + + stubFetch(() => json({ supabase_publishable_key: 'sb_publishable_dev' })); + expect(await getSupabaseKey('dev')).to.equal('sb_publishable_dev'); + }); + + it('rejects a response with no key', async () => { + stubFetch(() => json({ supabase_url: 'https://x.supabase.co' })); + let message = ''; + try { + await getSupabaseKey('prod'); + } catch (error) { + message = (error as Error).message; + } + expect(message).to.contain('no supabase_publishable_key'); + }); + + it('sends a publishable key as apikey only, and a legacy JWT key as a Bearer too', () => { + expect(supabaseKeyHeaders('sb_publishable_abc')).to.deep.equal({ apikey: 'sb_publishable_abc' }); + expect(supabaseKeyHeaders('eyJhbGciOi.x.y')).to.deep.equal({ + apikey: 'eyJhbGciOi.x.y', + authorization: 'Bearer eyJhbGciOi.x.y', + }); + }); + + it('keeps the stored session when the key cannot be fetched during a refresh', async () => { + stubFetch(() => { + throw new TypeError('fetch failed'); + }); + let caught: unknown; + try { + await CliAuthGateway.refresh('https://cloud.devicecloud.dev', 'prod', { + access_token: 'a', + refresh_token: 'r', + expires_at: 0, + user_email: 'u@example.com', + user_id: 'u1', + }); + } catch (error) { + caught = error; + } + expect(caught).to.be.instanceOf(SessionRefreshError); + // Not definitive, so resolveAuth will not delete the user's login. + expect((caught as SessionRefreshError).definitive).to.equal(false); + }); +}); diff --git a/test/unit/upload-encrypt.test.ts b/test/unit/upload-encrypt.test.ts index a5a1c26..4443d8a 100644 --- a/test/unit/upload-encrypt.test.ts +++ b/test/unit/upload-encrypt.test.ts @@ -6,11 +6,12 @@ import * as path from 'node:path'; import { uploadCommand } from '../../src/commands/upload.js'; import { telemetry } from '../../src/services/telemetry.service.js'; +import { resetCliConfigCache } from '../../src/config/cli-config.js'; import { clearCompatibilityCache } from '../../src/utils/compatibility.js'; /** * `dcd upload` decides encryption in the same order as `dcd cloud`: the flag - * (--encrypt / --no-encrypt), then DCD_ENCRYPT, then the org's default from + * (--encrypt / --no-encrypt), then DCD_ENCRYPT, then the server default from * the compatibility data (dcd#1138), then off. * * Observed through the dedup lookup, which is the first upload request either @@ -18,9 +19,11 @@ import { clearCompatibilityCache } from '../../src/utils/compatibility.js'; * (`{ shaPlain, encrypted: true }`), a plaintext one sends `{ sha }`. */ const API = 'http://localhost:9999'; -/** Not a devicecloud.dev API, so no pinned KEK applies. */ +/** An API that publishes no KEK on /cli-config (see the fetch stub). */ const UNKNOWN_API = 'https://api.example.com'; const APK = path.join(process.cwd(), 'test/fixtures/wikipedia.apk'); +/** Printed when the server default, not a flag or env var, turned encryption on. */ +const DEFAULT_ON_NOTICE = 'Encrypting uploads; opt out with --no-encrypt'; describe('dcd upload encryption', () => { const ORIGINAL_ENV = { ...process.env }; @@ -40,10 +43,11 @@ describe('dcd upload encryption', () => { delete process.env.DCD_ENCRYPT; delete process.env.DCD_ENCRYPT_BINARIES; clearCompatibilityCache(); + resetCliConfigCache(); compatEncryption = undefined; compatFails = false; - // A throwaway KEK so encryption can run without a pinned key. + // A throwaway KEK so encryption can run against an API that publishes none. const { publicKey } = generateKeyPairSync('x25519'); const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(12); process.env.DCD_BINARY_KEK_PUBLIC = `1:${raw.toString('base64')}`; @@ -58,6 +62,12 @@ describe('dcd upload encryption', () => { status, }); + // The API publishes no KEK here; the tests that encrypt set the + // DCD_BINARY_KEK_PUBLIC override instead. + if (String(input).endsWith('/cli-config')) { + return json({ supabase_publishable_key: 'sb_publishable_test' }); + } + if (String(input).includes('/results/compatibility/data')) { if (compatFails) return json({ message: 'boom' }, 500); return json({ @@ -95,6 +105,7 @@ describe('dcd upload encryption', () => { telemetry.flushSync = realFlushSync; process.env = { ...ORIGINAL_ENV }; clearCompatibilityCache(); + resetCliConfigCache(); fs.rmSync(tempDir, { force: true, recursive: true }); }); @@ -141,17 +152,17 @@ describe('dcd upload encryption', () => { expect(lookups[0]).to.not.have.property('encrypted'); }); - it('uploads in plaintext when the org default is off', async () => { + it('uploads in plaintext when the server default is off', async () => { compatEncryption = { defaultOn: false }; expect(await upload()).to.equal('plain-binary'); }); - it('encrypts when the org default is on', async () => { + it('encrypts when the server default is on', async () => { compatEncryption = { defaultOn: true }; expect(await upload()).to.equal('encrypted-binary'); }); - it('lets --no-encrypt beat the org default', async () => { + it('lets --no-encrypt beat the server default', async () => { compatEncryption = { defaultOn: true }; expect(await upload({ encrypt: false })).to.equal('plain-binary'); }); @@ -161,7 +172,7 @@ describe('dcd upload encryption', () => { expect(await upload({ encrypt: false })).to.equal('plain-binary'); }); - it('lets DCD_ENCRYPT=0 beat the org default', async () => { + it('lets DCD_ENCRYPT=0 beat the server default', async () => { process.env.DCD_ENCRYPT = '0'; compatEncryption = { defaultOn: true }; expect(await upload()).to.equal('plain-binary'); @@ -172,31 +183,29 @@ describe('dcd upload encryption', () => { expect(await upload()).to.equal('plain-binary'); }); - it('says the beta is on, and how to opt out, when the org default encrypts', async () => { + it('says it is encrypting, and how to opt out, when the server default encrypts', async () => { compatEncryption = { defaultOn: true }; - expect(await run({})).to.contain( - 'Encrypting uploads (beta); opt out with --no-encrypt', - ); + expect(await run({})).to.contain(DEFAULT_ON_NOTICE); }); - it('says nothing about the beta when the flag or env var turned encryption on', async () => { + it('prints no notice when the flag or env var turned encryption on', async () => { compatEncryption = { defaultOn: true }; - expect(await run({ encrypt: true })).to.not.contain('(beta)'); + expect(await run({ encrypt: true })).to.not.contain(DEFAULT_ON_NOTICE); process.env.DCD_ENCRYPT = '1'; - expect(await run({})).to.not.contain('(beta)'); + expect(await run({})).to.not.contain(DEFAULT_ON_NOTICE); }); - describe('against an API URL with no pinned KEK', () => { + describe('against an API that publishes no KEK', () => { beforeEach(() => { delete process.env.DCD_BINARY_KEK_PUBLIC; }); - it('skips the org default, with a notice, and uploads in plaintext', async () => { + it('skips the server default, with a notice, and uploads in plaintext', async () => { compatEncryption = { defaultOn: true }; expect(await upload({ 'api-url': UNKNOWN_API })).to.equal('plain-binary'); const out = await run({ 'api-url': UNKNOWN_API }); expect(out).to.contain(`Not encrypting uploads: ${UNKNOWN_API}`); - expect(out).to.not.contain('(beta)'); + expect(out).to.not.contain(DEFAULT_ON_NOTICE); }); it('skips DCD_ENCRYPT=1 the same way', async () => {