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/CLAUDE.md b/CLAUDE.md index 2917e1e..d2fe424 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ Top-level `defineCommand` in `src/index.ts` wires eleven subcommands (`cloud`, ` **Flag composition.** Flag definitions are split by domain in `src/config/flags/*.flags.ts` (api, binary, device, environment, execution, github, output) and re-exported as a single `flags` object from `src/constants.ts`. Commands that need the full cloud surface spread `...flags` into their citty `args`; subset commands import individual flag groups. -**Output rendering.** All human-facing output goes through `src/utils/ui.ts` (the canonical layer: `section`/`branch`/`fields`/`status`/`success`/`info`/`warn`) on top of `src/utils/styling.ts` (`colors`, `symbols`, `statusPalette`). The visual language is a Claude Code-style tree (`⏺` section headings, `⎿` branch groups). Commands must not hand-roll layouts from `colors`/`symbols` or call `console.log` (except the single `JSON.stringify` line under `--json`). Full rules and a cookbook live in `STYLE_GUIDE.md`. +**Output rendering.** All human-facing output goes through `src/utils/ui.ts` (the canonical layer: `section`/`branch`/`fields`/`status`/`success`/`info`/`warn`) on top of `src/utils/styling.ts` (`colors`, `symbols`, `statusPalette`). The visual language is a Claude Code-style tree (`⏺` section headings, `⎿` branch groups). Commands must not hand-roll layouts from `colors`/`symbols` or call `console.log`; the `--json` result goes through `logger.json`. Every command takes `--json` (`jsonFlag`), and `src/index.ts` turns thrown errors into `{ status: 'FAILED', error }` whenever `--json` is in argv. Full rules and a cookbook live in `STYLE_GUIDE.md`. **Layered call stack.** - `src/commands/*` — thin citty command definitions; orchestrate services, no I/O logic. @@ -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/STYLE_GUIDE.md b/STYLE_GUIDE.md index 243bd8d..6ce99d9 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -26,8 +26,8 @@ rows beneath them. need a shape that isn't there, add it to `ui` so every command gets it. 2. **Print through the logger.** Use `logger` from `src/utils/cli.ts` - (`logger.log` / `logger.warn` / `logger.error`). Never `console.log` directly - except for the single `JSON.stringify` line in `--json` mode. + (`logger.log` / `logger.warn` / `logger.error`, and `logger.json` for the + `--json` result). Never `console.log` directly. 3. **Sections then branches.** A screen is one or more `ui.section(title)` headings, each followed by a `ui.branch([...])` group of detail rows. Key/ @@ -48,9 +48,16 @@ rows beneath them. field labels are dim; colour is reserved for status and for emphasising a value (IDs via `formatId`, URLs via `formatUrl`). Don't colour a label. -7. **`--json` suppresses everything else.** When `--json` is set, emit only the - single serialized object and gate all other output behind the flag (the - `out()` wrapper pattern in `cloud.ts`/`upload.ts`). +7. **`--json` suppresses everything else.** Every command takes `--json` + (`jsonFlag` from `output.flags.ts`). When it is set, stdout carries exactly + one object, printed with `logger.json`. Gate all other output behind the + flag (the `out()` wrapper pattern in `cloud.ts`/`upload.ts`). Warnings stay + on stderr. Interactive commands (`login`, `switch-org`) draw their prompts + and the login URL on stderr instead. Thrown errors need no handling: the + top-level handler in `index.ts` prints `{ "status": "FAILED", "error" }` + whenever `--json` is in argv. If a command has already printed its result + and still has to exit non-zero, use `logger.fail` so a second object isn't + printed. 8. **No full-width rulers.** The 80-char `dividers` and `box` are deprecated; the tree provides the structure. Status words are lowercase. diff --git a/package.json b/package.json index 8d733ff..4db0b30 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", @@ -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..39e0d07 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: @@ -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: @@ -2497,7 +2610,7 @@ snapshots: dependencies: function-bind: 1.1.2 - hono@4.13.7: {} + hono@4.13.11: {} hookified@1.15.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/src/commands/artifacts.ts b/src/commands/artifacts.ts index ee6f75b..4abbac5 100644 --- a/src/commands/artifacts.ts +++ b/src/commands/artifacts.ts @@ -1,7 +1,11 @@ import { defineCommand } from 'citty'; import { apiFlags } from '../config/flags/api.flags.js'; -import { ReportDownloadService } from '../services/report-download.service.js'; +import { jsonFlag } from '../config/flags/output.flags.js'; +import { + type DownloadResult, + ReportDownloadService, +} from '../services/report-download.service.js'; import { resolveAuth } from '../utils/auth.js'; import { CliError, logger, validateEnum } from '../utils/cli.js'; import { resolveApiUrl } from '../utils/config-store.js'; @@ -56,6 +60,7 @@ export const artifactsCommand = defineCommand({ description: 'Custom file path for downloaded JUnit report (default: ./report.xml)', }, + json: jsonFlag, }, async run({ args }) { const apiKeyFlag = args['api-key'] as string | undefined; @@ -76,6 +81,9 @@ export const artifactsCommand = defineCommand({ const allurePath = args['allure-path'] as string | undefined; const htmlPath = args['html-path'] as string | undefined; const junitPath = args['junit-path'] as string | undefined; + const json = Boolean(args.json); + // Success lines are dropped under --json; warnings stay on stderr. + const log = json ? undefined : (m: string) => logger.log(m); const auth = await resolveAuth({ apiKeyFlag }); @@ -92,33 +100,42 @@ export const artifactsCommand = defineCommand({ } const service = new ReportDownloadService(); + const downloads: DownloadResult[] = []; if (downloadArtifacts) { - await service.downloadArtifacts({ + downloads.push(await service.downloadArtifacts({ auth, apiUrl, artifactsPath: artifactsPath ?? './artifacts.zip', debug, downloadType: downloadArtifacts, - logger: (m: string) => logger.log(m), + logger: log, uploadId, warnLogger: (m: string) => logger.warn(m), - }); + })); } if (report) { - await service.downloadReports({ + const result = await service.downloadReports({ allurePath, auth, apiUrl, debug, htmlPath, junitPath, - logger: (m: string) => logger.log(m), + logger: log, reportType: report, uploadId, warnLogger: (m: string) => logger.warn(m), }); + if (result) downloads.push(result); + } + + if (json) { + const ok = downloads.some((d) => d.ok); + logger.json({ status: ok ? 'SUCCESS' : 'FAILED', uploadId, downloads }); + // Same exit code a thrown error would give, without a second JSON object. + if (!ok) logger.fail('Every download failed', 1); } }, }); diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index 8dabab3..1420b78 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -1051,8 +1051,7 @@ export const cloudCommand = defineCommand({ } if (json) { - // eslint-disable-next-line no-console - console.log(JSON.stringify(jsonOutput, null, 2)); + logger.json(jsonOutput); return; } @@ -1192,8 +1191,7 @@ export const cloudCommand = defineCommand({ } if (output) { - // eslint-disable-next-line no-console - console.log(JSON.stringify(output, null, 2)); + logger.json(output); } } diff --git a/src/commands/list.ts b/src/commands/list.ts index c057b38..4ed1fbf 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -169,8 +169,7 @@ export const listCommand = defineCommand({ })) as ListResponse; if (json) { - // eslint-disable-next-line no-console - console.log(JSON.stringify(response, null, 2)); + logger.json(response); return; } diff --git a/src/commands/live.ts b/src/commands/live.ts index 23a8e57..a5f82d0 100644 --- a/src/commands/live.ts +++ b/src/commands/live.ts @@ -1,13 +1,14 @@ -import { defineCommand } from 'citty'; +import { type CommandMeta, defineCommand } from 'citty'; import { readFileSync, writeFileSync } from 'node:fs'; import { resolveFrontendUrl } from '../config/environments.js'; import { apiFlags } from '../config/flags/api.flags.js'; +import { jsonFlag } from '../config/flags/output.flags.js'; import { ApiGateway } from '../gateways/api-gateway.js'; import type { AuthContext } from '../types/domain/auth.types.js'; import type { LiveSession } from '../types/domain/live.types.js'; import { resolveAuth } from '../utils/auth.js'; -import { CliError, logger, validateEnum } from '../utils/cli.js'; +import { CliError, logger, validateEnum, wantsJson } from '../utils/cli.js'; import { resolveApiUrl } from '../utils/config-store.js'; import { colors, formatUrl } from '../utils/styling.js'; import { type Field, ui } from '../utils/ui.js'; @@ -22,6 +23,13 @@ async function requireAuth(keyFlag: string | undefined): Promise { return resolveAuth({ apiKeyFlag: keyFlag }); } +/** Human output, dropped under --json so stdout carries only the result. */ +function humanLog(json: boolean): (m: string) => void { + return (m) => { + if (!json) logger.log(m); + }; +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -36,6 +44,7 @@ async function waitForReady( apiUrl: string, auth: AuthContext, sessionName: string, + log: (m: string) => void = (m) => logger.log(m), timeoutMs = READY_TIMEOUT_MS, ): Promise { const deadline = Date.now() + timeoutMs; @@ -51,7 +60,7 @@ async function waitForReady( session.device_state?.phase_label ?? session.device_state?.phase ?? session.status; if (phase && phase !== lastPhase) { lastPhase = phase; - logger.log(` ${colors.dim(phase)}`); + log(` ${colors.dim(phase)}`); } if (Date.now() >= deadline) { @@ -103,7 +112,20 @@ function readFlowFile(filePath: string): string { } } -function printExecResult(result: { error?: string; output?: string; success: boolean }): void { +function printExecResult( + result: { error?: string; output?: string; success: boolean }, + json: boolean, + extra: Record = {}, +): void { + if (json) { + logger.json({ + success: result.success, + ...(result.output && { output: result.output }), + ...(result.error && { error: result.error }), + ...extra, + }); + return; + } logger.log( result.success ? ui.success('Command executed successfully') @@ -150,6 +172,7 @@ const startSub = defineCommand({ default: false, description: 'Block until the device is ready to accept commands', }, + json: jsonFlag, }, async run({ args }) { const auth = await requireAuth(args['api-key'] as string | undefined); @@ -171,15 +194,18 @@ const startSub = defineCommand({ throw new CliError('--android-device and --android-api-level must be provided together.'); } - logger.log(ui.warn(colors.bold('Live is in beta'))); - logger.log( + const json = Boolean(args.json); + const out = humanLog(json); + + out(ui.warn(colors.bold('Live is in beta'))); + out( ui.branch([ 'Live device sessions are a beta feature, billed at $0.03/min.', `${colors.dim('Not enrolled?')} Contact support to request access.`, ]), ); - logger.log(ui.running(`Starting ${platform} live session…`)); + out(ui.running(`Starting ${platform} live session…`)); const session = await ApiGateway.startLiveSession(apiUrl, auth, { binaryUploadId: binaryId, @@ -190,25 +216,57 @@ const startSub = defineCommand({ }); const frontendUrl = resolveFrontendUrl(apiUrl); + const consoleUrl = `${frontendUrl}/live?session=${session.session_name}`; - logger.log(ui.success('Live session started')); - logger.log( + out(ui.success('Live session started')); + out( ui.branch( ui.fields([ ['session', colors.highlight(session.session_name)], ['platform', session.platform], ['status', session.status], - ['console', formatUrl(`${frontendUrl}/live?session=${session.session_name}`)], + ['console', formatUrl(consoleUrl)], ]), ), ); + let ready: LiveSession | undefined; if (args.wait) { - logger.log(ui.running('Waiting for the device to be ready…')); - const ready = await waitForReady(apiUrl, auth, session.session_name); - logger.log( - ui.success(`Device ready${ready.device_model ? ` (${ready.device_model})` : ''}`), - ); + out(ui.running('Waiting for the device to be ready…')); + try { + ready = await waitForReady(apiUrl, auth, session.session_name, out); + } catch (error) { + // The session exists and is billing. Under --json the name was never + // printed, so the top-level `{ status, error }` would leave a script + // with no way to `dcd live stop` it. Report the name with the error. + if (!json) throw error; + logger.json({ + status: 'FAILED', + error: error instanceof Error ? error.message : String(error), + session_name: session.session_name, + platform: session.platform, + ready: false, + consoleUrl, + }); + logger.fail( + error instanceof Error ? error : String(error), + error instanceof CliError ? error.exitCode : 1, + ); + } + + out(ui.success(`Device ready${ready.device_model ? ` (${ready.device_model})` : ''}`)); + } + + if (json) { + logger.json({ + session_name: session.session_name, + platform: session.platform, + status: ready?.status ?? session.status, + ready: Boolean(ready?.ready), + consoleUrl, + ...(ready?.device_model && { device_model: ready.device_model }), + }); + return; } logger.log(ui.section('Next steps')); @@ -230,6 +288,7 @@ const installSub = defineCommand({ args: { ...apiFlags, session: { type: 'string', required: true, description: 'Live session name' }, + json: jsonFlag, 'app-binary-id': { type: 'string', required: true, @@ -246,8 +305,10 @@ const installSub = defineCommand({ const apiUrl = resolveApiUrl(args['api-url'] as string | undefined); const sessionName = args.session as string; const binaryId = args['app-binary-id'] as string; + const json = Boolean(args.json); + const out = humanLog(json); - logger.log( + out( ui.running( `Installing binary ${colors.highlight(binaryId)} on session ${colors.highlight(sessionName)}…`, ), @@ -255,12 +316,21 @@ const installSub = defineCommand({ await ApiGateway.installLiveBinary(apiUrl, auth, sessionName, binaryId); - logger.log(ui.success('Binary installed successfully')); + out(ui.success('Binary installed successfully')); if (args.wait) { - logger.log(ui.running('Waiting for the device to be ready…')); - await waitForReady(apiUrl, auth, sessionName); - logger.log(ui.success('Device ready')); + out(ui.running('Waiting for the device to be ready…')); + await waitForReady(apiUrl, auth, sessionName, out); + out(ui.success('Device ready')); + } + + if (json) { + logger.json({ + status: 'SUCCESS', + session: sessionName, + appBinaryId: binaryId, + ready: Boolean(args.wait), + }); } }, }); @@ -270,6 +340,7 @@ const execSub = defineCommand({ args: { ...apiFlags, session: { type: 'string', required: true, description: 'Live session name' }, + json: jsonFlag, yaml: { type: 'string', description: 'Maestro YAML commands to execute' }, file: { type: 'string', @@ -296,16 +367,17 @@ const execSub = defineCommand({ throw new CliError('Provide commands to execute via --yaml or --file.'); } + const json = Boolean(args.json); + const out = humanLog(json); + if (args.wait) { - await waitForReady(apiUrl, auth, sessionName); + await waitForReady(apiUrl, auth, sessionName, out); } - logger.log( - ui.running(`Executing commands on session ${colors.highlight(sessionName)}…`), - ); + out(ui.running(`Executing commands on session ${colors.highlight(sessionName)}…`)); const result = await ApiGateway.execLiveYaml(apiUrl, auth, sessionName, yaml); - printExecResult(result); + printExecResult(result, json); }, }); @@ -317,6 +389,7 @@ const runSub = defineCommand({ args: { ...apiFlags, session: { type: 'string', required: true, description: 'Live session name' }, + json: jsonFlag, wait: { type: 'boolean', default: false, @@ -345,11 +418,26 @@ const runSub = defineCommand({ throw new CliError(`Flow file '${flowFile}' has no commands to run.`); } + const json = Boolean(args.json); + const out = humanLog(json); + + // Print the result, then fail with exit 2 if the flow failed. Under --json + // the result object is the output, so exit without printing a second one. + const finish = ( + result: { error?: string; output?: string; success: boolean }, + extra: Record = {}, + ): void => { + printExecResult(result, json, extra); + if (result.success) return; + if (json) logger.fail(result.error || 'Flow failed.', 2); + throw new CliError(result.error || 'Flow failed.', 2); + }; + if (args.wait) { - await waitForReady(apiUrl, auth, sessionName); + await waitForReady(apiUrl, auth, sessionName, out); } - logger.log( + out( ui.running( `Running ${colors.highlight(flowFile)} on session ${colors.highlight(sessionName)}…`, ), @@ -363,8 +451,7 @@ const runSub = defineCommand({ }); if (!submitted.commandId) { - printExecResult(submitted); - if (!submitted.success) throw new CliError(submitted.error || 'Flow failed.', 2); + finish(submitted); return; } @@ -374,8 +461,7 @@ const runSub = defineCommand({ for (;;) { const status = await ApiGateway.getLiveCommand(apiUrl, auth, sessionName, commandId); if (status.done) { - printExecResult(status); - if (!status.success) throw new CliError(status.error || 'Flow failed.', 2); + finish(status, { commandId }); return; } if (Date.now() >= deadline) { @@ -404,6 +490,7 @@ const screenshotSub = defineCommand({ args: { ...apiFlags, session: { type: 'string', required: true, description: 'Live session name' }, + json: jsonFlag, output: { type: 'string', alias: ['o'], @@ -432,6 +519,19 @@ const screenshotSub = defineCommand({ writeFileSync(output, Buffer.from(base64, 'base64')); + if (args.json) { + logger.json({ + status: 'SUCCESS', + path: output, + format: ext, + ...(session.hierarchy && { + width: session.hierarchy.width, + height: session.hierarchy.height, + }), + }); + return; + } + logger.log(ui.success(`Screenshot saved to ${colors.highlight(output)}`)); if (session.hierarchy) { logger.log( @@ -453,7 +553,11 @@ const hierarchySub = defineCommand({ args: { ...apiFlags, session: { type: 'string', required: true, description: 'Live session name' }, - json: { type: 'boolean', description: 'Output the raw hierarchy as JSON' }, + json: { + type: 'boolean', + description: + 'Output the raw hierarchy as JSON (with --output, the file gets the hierarchy and stdout gets { status, path })', + }, output: { type: 'string', alias: ['o'], @@ -477,13 +581,11 @@ const hierarchySub = defineCommand({ } if (json) { - const out = JSON.stringify(hierarchy, null, 2); if (output) { - writeFileSync(output, out); - logger.log(ui.success(`Hierarchy written to ${colors.highlight(output)}`)); + writeFileSync(output, JSON.stringify(hierarchy, null, 2)); + logger.json({ status: 'SUCCESS', path: output }); } else { - // eslint-disable-next-line no-console - console.log(out); + logger.json(hierarchy); } return; } @@ -520,16 +622,23 @@ const stopSub = defineCommand({ args: { ...apiFlags, session: { type: 'string', required: true, description: 'Live session name' }, + json: jsonFlag, }, async run({ args }) { const auth = await requireAuth(args['api-key'] as string | undefined); const apiUrl = resolveApiUrl(args['api-url'] as string | undefined); const sessionName = args.session as string; - logger.log(ui.running(`Stopping session ${colors.highlight(sessionName)}…`)); + const json = Boolean(args.json); + + humanLog(json)(ui.running(`Stopping session ${colors.highlight(sessionName)}…`)); await ApiGateway.stopLiveSession(apiUrl, auth, sessionName); + if (json) { + logger.json({ status: 'SUCCESS', session: sessionName }); + return; + } logger.log(ui.success('Session stopped')); }, }); @@ -539,6 +648,7 @@ const statusSub = defineCommand({ args: { ...apiFlags, session: { type: 'string', required: true, description: 'Live session name' }, + json: jsonFlag, }, async run({ args }) { const auth = await requireAuth(args['api-key'] as string | undefined); @@ -547,6 +657,14 @@ const statusSub = defineCommand({ const session = await ApiGateway.getLiveSession(apiUrl, auth, sessionName); + if (args.json) { + // The screenshot and hierarchy are large; `screenshot` / `hierarchy` + // are the commands for those. + const { hierarchy: _hierarchy, screenshot_data_url: _screenshot, ...rest } = session; + logger.json(rest); + return; + } + const fields: Field[] = [ ['session', colors.highlight(session.session_name)], ['platform', session.platform], @@ -576,39 +694,42 @@ const statusSub = defineCommand({ }, }); +const subCommands = { + start: startSub, + install: installSub, + exec: execSub, + run: runSub, + screenshot: screenshotSub, + hierarchy: hierarchySub, + stop: stopSub, + status: statusSub, +}; + export const liveCommand = defineCommand({ meta: { name: 'live', description: 'Start and interact with a live device session', }, - subCommands: { - start: startSub, - install: installSub, - exec: execSub, - run: runSub, - screenshot: screenshotSub, - hierarchy: hierarchySub, - stop: stopSub, - status: statusSub, - }, + subCommands, // citty's runCommand does not early-return after dispatching to a // subcommand — it still invokes the parent `run` afterwards. So when a // subcommand was matched, bail out here; otherwise the menu would print // after every successful subcommand. run({ rawArgs }) { - const subNames = new Set([ - 'start', - 'install', - 'exec', - 'run', - 'screenshot', - 'hierarchy', - 'stop', - 'status', - ]); + const subNames = new Set(Object.keys(subCommands)); const firstPositional = rawArgs.find((arg) => !arg.startsWith('-')); if (firstPositional && subNames.has(firstPositional)) return; + if (wantsJson(rawArgs)) { + logger.json({ + commands: Object.entries(subCommands).map(([name, sub]) => ({ + name, + description: (sub.meta as CommandMeta).description, + })), + }); + return; + } + logger.log(ui.section('Live Session Commands')); logger.log( ui.branch( diff --git a/src/commands/login.ts b/src/commands/login.ts index ba6e9dc..b438ec2 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 @@ -33,10 +36,18 @@ import { defineCommand } from 'citty'; import { spawn } from 'node:child_process'; 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 { 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'; @@ -76,11 +87,33 @@ 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 }) { const apiUrl = (args['api-url'] as string).replace(/\/$/, ''); 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) => { + if (json) process.stderr.write(`${m}\n`); + 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 @@ -92,11 +125,15 @@ export const loginCommand = defineCommand({ if (existing?.session) { const now = Math.floor(Date.now() / 1000); if (existing.session.expires_at <= now) { - logger.log( + say( ui.info( `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({ @@ -105,9 +142,14 @@ export const loginCommand = defineCommand({ (currentOrg ? ` (org ${currentOrg})` : '') + `. Sign out and log in again?`, initialValue: true, + output: promptOutput, }); if (p.isCancel(ok) || !ok) { - logger.log(ui.info('Keeping existing session.')); + if (json) { + logger.json({ status: 'UNCHANGED', user: existing.session.user_email }); + } else { + logger.log(ui.info('Keeping existing session.')); + } return; } } @@ -123,7 +165,7 @@ export const loginCommand = defineCommand({ const loginUrl = `${frontendUrl}/cli-login?state=${state}&code_challenge=${codeChallenge}`; - logger.log(ui.section('Signing in to devicecloud.dev')); + say(ui.section('Signing in to devicecloud.dev')); const rows: string[] = []; if (noBrowser) { @@ -142,7 +184,7 @@ export const loginCommand = defineCommand({ } } rows.push(ui.note('Waiting for login to complete…')); - logger.log(ui.branch(rows)); + say(ui.branch(rows)); try { const payload = await pollForClaim(apiUrl, state, codeVerifier); @@ -164,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); + 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, @@ -193,6 +245,16 @@ export const loginCommand = defineCommand({ current_org_name: chosen.name, }); + if (json) { + logger.json({ + status: 'SUCCESS', + user: payload.user_email, + org: { id: chosen.id, name: chosen.name }, + env, + }); + return; + } + logger.log(ui.success(`Logged in as ${colors.highlight(payload.user_email)}`)); logger.log( ui.branch([ @@ -203,7 +265,7 @@ export const loginCommand = defineCommand({ ]), ); } catch (error) { - logger.error(error as Error, { exit: 1 }); + logger.error(error as Error, { exit: 1, json }); } }, }); @@ -275,7 +337,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)'; @@ -283,7 +345,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 5d25480..be2a163 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -4,7 +4,7 @@ */ 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'; import { clearConfig, getConfigPath, readConfig } from '../utils/config-store.js'; @@ -16,17 +16,28 @@ export const logoutCommand = defineCommand({ name: 'logout', description: 'Clear the stored devicecloud.dev session', }, - async run() { + args: { + json: jsonFlag, + }, + async run({ args }) { + const json = Boolean(args.json); const config = readConfig(); if (!config?.session) { - logger.log(ui.info(`No active session found (${colors.dim(getConfigPath())}).`)); clearConfig(); + if (json) { + logger.json({ status: 'SUCCESS', loggedOut: false }); + } else { + logger.log(ui.info(`No active session found (${colors.dim(getConfigPath())}).`)); + } 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 }); + return; + } logger.log(ui.success(`Logged out ${colors.dim(`(${config.session.user_email})`)}`)); }, }); diff --git a/src/commands/status.ts b/src/commands/status.ts index 9724897..a629709 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -163,19 +163,12 @@ async function statusMain({ if (isClientApiError(lastError)) { const errorMessage = lastError?.message || 'Unknown error'; if (json) { - // eslint-disable-next-line no-console - console.log( - JSON.stringify( - { - status: 'FAILED' as const, - error: errorMessage, - attempts: attemptsMade, - tests: [], - }, - null, - 2, - ), - ); + logger.json({ + status: 'FAILED' as const, + error: errorMessage, + attempts: attemptsMade, + tests: [], + }); return; } @@ -204,24 +197,17 @@ async function statusMain({ } if (json) { - // eslint-disable-next-line no-console - console.log( - JSON.stringify( - { - status: 'FAILED' as const, - error: errorMessage, - attempts: attemptsMade, - connectivityCheck: { - connected: connectivityCheck.connected, - endpointResults: connectivityCheck.endpointResults, - message: connectivityCheck.message, - }, - tests: [], - }, - null, - 2, - ), - ); + logger.json({ + status: 'FAILED' as const, + error: errorMessage, + attempts: attemptsMade, + connectivityCheck: { + connected: connectivityCheck.connected, + endpointResults: connectivityCheck.endpointResults, + message: connectivityCheck.message, + }, + tests: [], + }); return; } @@ -231,8 +217,7 @@ async function statusMain({ try { if (json) { const { tests, ...rest } = status; - // eslint-disable-next-line no-console - console.log(JSON.stringify({ ...rest, tests }, null, 2)); + logger.json({ ...rest, tests }); return; } diff --git a/src/commands/switch-org.ts b/src/commands/switch-org.ts index 969e97f..60a86e5 100644 --- a/src/commands/switch-org.ts +++ b/src/commands/switch-org.ts @@ -3,14 +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'; @@ -29,6 +32,7 @@ export const switchOrgCommand = defineCommand({ required: false, description: 'Org name to switch to (omit for an interactive picker)', }, + json: jsonFlag, }, async run({ args }) { const config = readConfig(); @@ -40,6 +44,7 @@ export const switchOrgCommand = defineCommand({ // a dev Bearer token to the prod API. const apiUrl = resolveApiUrl(args['api-url'] as string | undefined); const target = args.org as string | undefined; + const json = Boolean(args.json); // sessionOnly: an exported DEVICE_CLOUD_API_KEY must not shadow the // browser session this command requires. @@ -49,9 +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 { - chosen = await pickOrg(orgs); + // Under --json the picker draws on stderr so stdout stays pure JSON. + 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({ @@ -60,10 +73,21 @@ export const switchOrgCommand = defineCommand({ current_org_name: chosen.name, }); + // The switch only changes the stored session; an exported key still wins. + const keyOverride = apiKeyOverride() === 'DEVICE_CLOUD_API_KEY'; + + if (json) { + logger.json({ + status: 'SUCCESS', + org: { id: chosen.id, name: chosen.name, slug: chosen.slug ?? null }, + apiKeyOverride: keyOverride, + }); + return; + } + logger.log(ui.success(`Switched to ${colors.highlight(chosen.name)}`)); - // The switch only changes the stored session; an exported key still wins. - if (apiKeyOverride() === 'DEVICE_CLOUD_API_KEY') { + if (keyOverride) { logger.log( ui.warn( `${colors.highlight('DEVICE_CLOUD_API_KEY')} is set, so other dcd commands still authenticate with that API key and its org, not ${chosen.name}. Unset it to use the session.`, @@ -73,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/upgrade.ts b/src/commands/upgrade.ts index 719ab60..a2b5e9f 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -20,6 +20,7 @@ import { pipeline } from 'node:stream/promises'; import { defineCommand } from 'citty'; +import { jsonFlag } from '../config/flags/output.flags.js'; import { VersionService } from '../services/version.service.js'; import { CliError, @@ -47,7 +48,15 @@ export const upgradeCommand = defineCommand({ name: 'upgrade', description: 'Upgrade dcd in place to the latest released version', }, - async run() { + args: { + json: jsonFlag, + }, + async run({ args }) { + const json = Boolean(args.json); + const out = (m: string) => { + if (!json) logger.log(m); + }; + if (getInstallMethod() !== 'binary') { throw new CliError( '`dcd upgrade` only applies to the standalone binary install. ' + @@ -68,6 +77,10 @@ export const upgradeCommand = defineCommand({ // Reachable, but nothing published on this channel yet (e.g. a stable // install while only betas exist). Not an error — just nothing to do. if (result.version === null) { + if (json) { + logger.json({ status: 'NO_RELEASE', current, channel: result.channel }); + return; + } logger.log( ui.info(`No newer release available on the ${result.channel} channel.`), ); @@ -76,6 +89,10 @@ export const upgradeCommand = defineCommand({ const latest = result.version; if (!versionService.isOutdated(current, latest)) { + if (json) { + logger.json({ status: 'UP_TO_DATE', current, latest, channel: result.channel }); + return; + } logger.log( ui.success(`Already on the latest version (${colors.highlight(current)})`), ); @@ -107,10 +124,8 @@ export const upgradeCommand = defineCommand({ const binaryUrl = `${base}/download/${latest}/${asset}`; const sumsUrl = `${base}/download/${latest}/SHA256SUMS`; - logger.log( - ui.info(`Upgrading ${colors.highlight(current)} → ${colors.highlight(latest)}`), - ); - logger.log(ui.note(` ${binaryUrl}`)); + out(ui.info(`Upgrading ${colors.highlight(current)} → ${colors.highlight(latest)}`)); + out(ui.note(` ${binaryUrl}`)); const execPath = process.execPath; const tmpPath = `${execPath}.new`; @@ -135,6 +150,10 @@ export const upgradeCommand = defineCommand({ ); } + if (json) { + logger.json({ status: 'UPGRADED', current, latest, channel: result.channel }); + return; + } logger.log(ui.success(`Upgraded to ${colors.highlight(latest)}`)); }, }); diff --git a/src/commands/upload.ts b/src/commands/upload.ts index 5933f36..c8c4955 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -138,8 +138,7 @@ export const uploadCommand = defineCommand({ telemetry.recordEncryption({ binary: encryption.enabled }); if (json) { - // eslint-disable-next-line no-console - console.log(JSON.stringify({ appBinaryId }, null, 2)); + logger.json({ appBinaryId }); return; } diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index 3dab8c9..3bbea2c 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -8,6 +8,7 @@ import { defineCommand } from 'citty'; import { apiFlags } from '../config/flags/api.flags.js'; +import { jsonFlag } from '../config/flags/output.flags.js'; import { apiKeyOverride } from '../utils/auth.js'; import { logger } from '../utils/cli.js'; import { readConfig } from '../utils/config-store.js'; @@ -25,10 +26,27 @@ export const whoamiCommand = defineCommand({ description: 'An API key as you would pass it to other commands; whoami says that it takes precedence over the stored session', }, + json: jsonFlag, }, run({ args }) { const config = readConfig(); const keySource = apiKeyOverride(args['api-key'] as string | undefined); + + if (args.json) { + logger.json({ + status: 'SUCCESS', + loggedIn: Boolean(config?.session), + ...(config?.session && { + user: config.session.user_email, + org: config.current_org_id + ? { id: config.current_org_id, name: config.current_org_name ?? null } + : null, + env: config.env, + }), + apiKeySource: keySource ?? null, + }); + return; + } const keyName = colors.highlight(keySource ?? ''); if (!config?.session) { diff --git a/src/config/environments.ts b/src/config/environments.ts index 22a16c8..477ce53 100644 --- a/src/config/environments.ts +++ b/src/config/environments.ts @@ -1,12 +1,10 @@ /** * 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 here. A compiled-in key can never be + * rotated without breaking every pinned CLI version, so it is fetched from the + * API at runtime instead: see `getSupabaseKey` in ./supabase-key.ts (dcd#1112). */ export type DcdEnvName = 'dev' | 'prod'; @@ -17,13 +15,12 @@ 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. + * KEK the platform API unwraps with. **Public — safe to embed**: 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.: @@ -42,8 +39,6 @@ export const ENVIRONMENTS: Record = { supabase: { url: 'https://cloud.devicecloud.dev', projectRef: 'pgydnphbimetinsgfkbo', - anonKey: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBneWRucGhiaW1ldGluc2dma2JvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDc1OTQzNDYsImV4cCI6MjAyMzE3MDM0Nn0.hAYOMFxxwX1exkQkY9xyQJGC_GhGnyogkj2N-kBkMI8', }, kekPublicKey: { version: 1, key: 'wtfyWEwK7nJzwI4PD+9RAW8jxIR1u8kMQq2IhsrVnH4=' }, }, @@ -53,8 +48,6 @@ 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=' }, }, diff --git a/src/config/flags/output.flags.ts b/src/config/flags/output.flags.ts index 304f5b4..c4fdff0 100644 --- a/src/config/flags/output.flags.ts +++ b/src/config/flags/output.flags.ts @@ -1,5 +1,14 @@ import type { ArgsDef } from 'citty'; +/** + * Generic `--json` flag for commands outside `cloud`. With it, stdout carries + * exactly one JSON object; failures print `{ status: 'FAILED', error }`. + */ +export const jsonFlag = { + type: 'boolean', + description: 'Output the result as a single JSON object on stdout', +} as const satisfies ArgsDef[string]; + /** * Output format and artifact download flags */ diff --git a/src/config/supabase-key.ts b/src/config/supabase-key.ts new file mode 100644 index 0000000..5f3ca42 --- /dev/null +++ b/src/config/supabase-key.ts @@ -0,0 +1,60 @@ +/** + * 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`, and rotating it is an env change + * on the API. + * + * Fetched once per process and kept in memory only. It is deliberately not + * cached on disk: a stale key makes GoTrue answer a refresh with a 4xx, which + * `resolveAuth` treats as a dead session and deletes the user's login. + * + * 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 { ENVIRONMENTS, type DcdEnvName } from './environments.js'; + +const FETCH_TIMEOUT_MS = 10_000; + +const cache = new Map>(); + +async function fetchSupabaseKey(env: DcdEnvName): Promise { + const url = `${ENVIRONMENTS[env].apiUrl}/cli-config`; + 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 = (await res.json()) as { supabase_publishable_key?: unknown }; + const key = body.supabase_publishable_key; + if (typeof key !== 'string' || key.length === 0) { + throw new Error(`CLI config from ${url} has no supabase_publishable_key`); + } + return key; +} + +/** The publishable key for `env`. A failed fetch is not cached, so the next call retries. */ +export function getSupabaseKey(env: DcdEnvName): Promise { + let pending = cache.get(env); + if (!pending) { + pending = fetchSupabaseKey(env); + cache.set(env, pending); + pending.catch(() => cache.delete(env)); + } + return pending; +} + +/** + * 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 { + cache.clear(); +} 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/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 ba63c2f..da07bd4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,7 @@ import { upgradeCommand } from './commands/upgrade.js'; import { uploadCommand } from './commands/upload.js'; import { whoamiCommand } from './commands/whoami.js'; import { telemetry } from './services/telemetry.service.js'; -import { CliError, getCliVersion, logger } from './utils/cli.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 @@ -103,9 +103,13 @@ async function run(): Promise { telemetry.recordCommandSuccess(); await telemetry.flush(); } catch (error) { + // Under --json, stdout must stay a single JSON object: no help text, and + // the error itself is printed as `{ status: 'FAILED', error }`. + const json = wantsJson(rawArgs); + // citty throws CLIError (by name — the class isn't exported) for usage // problems like unknown commands or missing required args. - if (error instanceof Error && error.name === 'CLIError') { + if (!json && error instanceof Error && error.name === 'CLIError') { await showHelp(rawArgs); } @@ -114,6 +118,7 @@ async function run(): Promise { // synchronously, and exits with the given code. logger.error(error instanceof Error ? error : String(error), { exit: exitCode, + json, }); } } diff --git a/src/services/report-download.service.ts b/src/services/report-download.service.ts index d1bfc96..c938809 100644 --- a/src/services/report-download.service.ts +++ b/src/services/report-download.service.ts @@ -25,6 +25,14 @@ export interface ReportDownloadOptions extends DownloadOptions { reportType: 'allure' | 'html' | 'html-detailed' | 'junit'; } +/** What one download attempt did; failures are warned about, not thrown. */ +export interface DownloadResult { + error?: string; + ok: boolean; + path: string; + type: 'allure' | 'artifacts' | 'html' | 'junit'; +} + /** * Service for downloading test artifacts and reports */ @@ -32,11 +40,11 @@ export class ReportDownloadService { /** * Download test artifacts as a zip file * @param options Download configuration - * @returns Promise that resolves when download is complete + * @returns What the download did */ public async downloadArtifacts( options: ArtifactsDownloadOptions, - ): Promise { + ): Promise { const { apiUrl, auth, @@ -69,6 +77,8 @@ export class ReportDownloadService { `Test artifacts have been downloaded to ${artifactsPath}`, ); } + + return { ok: true, path: artifactsPath, type: 'artifacts' }; } catch (error) { if (debug && logger) { logger(`[DEBUG] Error downloading artifacts: ${error}`); @@ -80,17 +90,19 @@ export class ReportDownloadService { 'No artifacts found for this upload. Make sure your tests generated results.', error, ); + + return { error: errorText(error), ok: false, path: artifactsPath, type: 'artifacts' }; } } /** * Handle downloading reports based on the report type specified * @param options Report download configuration - * @returns Promise that resolves when download is complete + * @returns What the download did, or undefined for an unknown report type */ public async downloadReports( options: ReportDownloadOptions, - ): Promise { + ): Promise { const { reportType, junitPath, @@ -103,20 +115,18 @@ export class ReportDownloadService { switch (reportType) { case 'junit': { const reportPath = path.resolve(process.cwd(), junitPath || 'report.xml'); - await this.downloadReport('junit', reportPath, { + return this.downloadReport('junit', reportPath, { ...downloadOptions, warnLogger, }); - break; } case 'allure': { const reportPath = path.resolve(process.cwd(), allurePath || 'report.html'); - await this.downloadReport('allure', reportPath, { + return this.downloadReport('allure', reportPath, { ...downloadOptions, warnLogger, }); - break; } case 'html': @@ -124,17 +134,18 @@ export class ReportDownloadService { // The HTML report is a ZIP (report.html plus its screenshots and // assets), so the default name says so. An explicit path is kept as-is. const htmlReportPath = path.resolve(process.cwd(), htmlPath || 'report.zip'); - await this.downloadReport('html', htmlReportPath, { + return this.downloadReport('html', htmlReportPath, { ...downloadOptions, warnLogger, }); - break; } default: { if (warnLogger) { warnLogger(`Unknown report type: ${reportType}`); } + + return undefined; } } } @@ -144,13 +155,13 @@ export class ReportDownloadService { * @param type Report type to download * @param filePath Path where report should be saved * @param options Download configuration - * @returns Promise that resolves when download is complete + * @returns What the download did */ private async downloadReport( type: 'allure' | 'html' | 'junit', filePath: string, options: DownloadOptions, - ): Promise { + ): Promise { const { apiUrl, auth, uploadId, debug = false, logger, warnLogger } = options; try { @@ -172,6 +183,8 @@ export class ReportDownloadService { `${type.toUpperCase()} test report has been downloaded to ${filePath}`, ); } + + return { ok: true, path: filePath, type }; } catch (error) { if (debug && logger) { logger(`[DEBUG] Error downloading ${type.toUpperCase()} report: ${error}`); @@ -183,6 +196,8 @@ export class ReportDownloadService { `No ${type.toUpperCase()} reports found for this upload. Make sure your tests generated results.`, error, ); + + return { error: errorText(error), ok: false, path: filePath, type }; } } @@ -205,7 +220,7 @@ export class ReportDownloadService { return; } - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = errorText(error); warnLogger(`Failed to download ${subject}: ${errorMessage}`); if (errorMessage.includes('404')) { @@ -217,3 +232,7 @@ export class ReportDownloadService { } } } + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 2b4c146..5e338b7 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -10,7 +10,6 @@ */ import { closeSync, openSync, rmSync, statSync } from 'node:fs'; -import { ENVIRONMENTS } from '../config/environments.js'; import { CliAuthGateway, SessionRefreshError, @@ -196,12 +195,11 @@ async function refreshSessionWithLock( return { config: current, session }; } - const { anonKey } = ENVIRONMENTS[current.env].supabase; let refreshed; try { refreshed = await CliAuthGateway.refresh( current.supabase_url, - anonKey, + current.env, session, ); } catch (error) { 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/cli.ts b/src/utils/cli.ts index 6b152cd..31b0033 100644 --- a/src/utils/cli.ts +++ b/src/utils/cli.ts @@ -7,6 +7,7 @@ * sites ported from oclif keep working. */ import { readFileSync } from 'node:fs'; +import { stripVTControlCharacters } from 'node:util'; import { telemetry } from '../services/telemetry.service.js'; @@ -61,8 +62,10 @@ export class CliError extends Error { export interface Logger { log(message: string): void; warn(message: string): void; + json(value: unknown): void; error(message: Error | string, opts?: { exit?: number; json?: boolean }): never; exit(code?: number): never; + fail(message: Error | string, exitCode: number): never; } export const logger: Logger = { @@ -74,6 +77,11 @@ export const logger: Logger = { // eslint-disable-next-line no-console console.warn(symbols.warning + ' ' + message); }, + json(value: unknown): void { + // The one stdout write under --json; everything else is gated off. + // eslint-disable-next-line no-console + console.log(JSON.stringify(value, null, 2)); + }, error( message: Error | string, opts: { exit?: number; json?: boolean } = {}, @@ -82,27 +90,39 @@ export const logger: Logger = { if (opts.json) { // When --json is active, emit failures as JSON on stdout so that // scripts consuming the output never need to parse stderr too. - // eslint-disable-next-line no-console - console.log(JSON.stringify({ status: 'FAILED', error: text }, null, 2)); + // citty colours some of its messages; JSON consumers want plain text. + logger.json({ status: 'FAILED', error: stripVTControlCharacters(text) }); } else { // The literal "Error:" prefix is important for tests and for grep-friendly // CI logs — it survives color stripping and matches /error/i assertions. // eslint-disable-next-line no-console console.error(symbols.error + ' Error: ' + text); } + return logger.fail(message, opts.exit ?? 1); + }, + exit(code: number = 0): never { + process.exit(code); + }, + // Exit as a failure without printing anything — for --json paths that have + // already written their result object and must not print a second one. + fail(message: Error | string, exitCode: number): never { // process.exit bypasses beforeExit, so async fetch in telemetry.flush() // would be killed mid-flight. flushSync uses curl to ship synchronously // before we exit; it's a no-op if telemetry never reached configure(). - const exitCode = opts.exit ?? 1; telemetry.recordCommandFailure({ error: message, exitCode }); telemetry.flushSync(); process.exit(exitCode); }, - exit(code: number = 0): never { - process.exit(code); - }, }; +/** + * Whether the invocation asked for JSON output. Read from raw argv so the + * top-level error handler can honour it even when citty never parsed args. + */ +export function wantsJson(rawArgs: string[]): boolean { + return rawArgs.some((a) => a === '--json' || a === '--json=true'); +} + /** * Validate that a string flag value is one of the allowed options. * Returns the value untouched on success; throws CliError otherwise. diff --git a/src/utils/orgs.ts b/src/utils/orgs.ts index 47e44aa..078ee48 100644 --- a/src/utils/orgs.ts +++ b/src/utils/orgs.ts @@ -1,8 +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 { Readable, Writable } from 'node:stream'; import { CliError } from './cli.js'; @@ -27,13 +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', + { + message = 'Pick an organization', + initialId, + input, + output, + }: PickOrgOptions = {}, ): Promise { if (orgs.length === 0) { throw new CliError('No organizations found for this user.'); @@ -42,7 +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.'); @@ -50,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/json-output.test.ts b/test/unit/json-output.test.ts new file mode 100644 index 0000000..a6573b5 --- /dev/null +++ b/test/unit/json-output.test.ts @@ -0,0 +1,176 @@ +import { expect } from 'chai'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { liveCommand } from '../../src/commands/live.js'; +import { logoutCommand } from '../../src/commands/logout.js'; +import { whoamiCommand } from '../../src/commands/whoami.js'; +import { ApiGateway } from '../../src/gateways/api-gateway.js'; +import type { LiveSession } from '../../src/types/domain/live.types.js'; +import { wantsJson } from '../../src/utils/cli.js'; +import { writeConfig } from '../../src/utils/config-store.js'; + +/** + * Every command takes --json, and with it stdout must be exactly one JSON + * object — scripts pipe it straight into a parser. + */ +describe('--json output', () => { + const ORIGINAL_ENV = { ...process.env }; + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-json-test-')); + process.env.DCD_CONFIG_DIR = tempDir; + process.env.DCD_TELEMETRY_DISABLED = '1'; + delete process.env.DEVICE_CLOUD_API_KEY; + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + fs.rmSync(tempDir, { force: true, recursive: true }); + }); + + const logIn = () => + writeConfig({ + version: 1, + env: 'prod', + api_url: 'https://api.devicecloud.dev', + supabase_url: 'https://cloud.devicecloud.dev', + session: { + access_token: 'a', + refresh_token: 'r', + expires_at: Math.floor(Date.now() / 1000) + 3600, + user_email: 'u@example.com', + user_id: 'u1', + }, + current_org_id: '42', + current_org_name: 'Acme', + }); + + /** Run a command's `run` and parse stdout, which must be a single JSON value. */ + async function runJson( + run: unknown, + ctx: { args?: Record; rawArgs?: string[] }, + ): Promise> { + let out = ''; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string) => { + out += chunk; + return true; + }) as typeof process.stdout.write; + try { + await (run as (c: typeof ctx) => Promise | void)({ args: {}, rawArgs: [], ...ctx }); + } finally { + process.stdout.write = original; + } + + return JSON.parse(out) as Record; + } + + describe('wantsJson', () => { + it('spots --json anywhere in argv', () => { + expect(wantsJson(['whoami', '--json'])).to.equal(true); + expect(wantsJson(['--json=true', 'logout'])).to.equal(true); + }); + + it('ignores look-alike flags', () => { + expect(wantsJson(['cloud', '--json-file'])).to.equal(false); + expect(wantsJson(['whoami'])).to.equal(false); + }); + }); + + describe('dcd whoami --json', () => { + it('reports the session', async () => { + logIn(); + expect(await runJson(whoamiCommand.run, { args: { json: true } })).to.deep.equal({ + status: 'SUCCESS', + loggedIn: true, + user: 'u@example.com', + org: { id: '42', name: 'Acme' }, + env: 'prod', + apiKeySource: null, + }); + }); + + it('reports an exported API key', async () => { + logIn(); + process.env.DEVICE_CLOUD_API_KEY = 'exported-key'; + const out = await runJson(whoamiCommand.run, { args: { json: true } }); + expect(out.apiKeySource).to.equal('DEVICE_CLOUD_API_KEY'); + }); + + it('reports being logged out', async () => { + expect(await runJson(whoamiCommand.run, { args: { json: true } })).to.deep.equal({ + status: 'SUCCESS', + loggedIn: false, + apiKeySource: null, + }); + }); + }); + + describe('dcd logout --json', () => { + it('says nothing was logged out when there is no session', async () => { + expect(await runJson(logoutCommand.run, { args: { json: true } })).to.deep.equal({ + status: 'SUCCESS', + loggedOut: false, + }); + }); + }); + + describe('dcd live --json', () => { + const originalGet = ApiGateway.getLiveSession; + const originalStop = ApiGateway.stopLiveSession; + const liveArgs = { 'api-key': 'k', 'api-url': 'https://api.example.com', json: true }; + const sub = (name: string) => + (liveCommand.subCommands as Record)[name].run; + + afterEach(() => { + ApiGateway.getLiveSession = originalGet; + ApiGateway.stopLiveSession = originalStop; + }); + + it('lists the subcommands from the bare menu', async () => { + const out = await runJson(liveCommand.run, { rawArgs: ['--json'] }); + const names = (out.commands as { name: string }[]).map((c) => c.name); + expect(names).to.include.members(['start', 'run', 'status', 'stop']); + }); + + it('status prints the session without the heavy payloads', async () => { + ApiGateway.getLiveSession = async () => + ({ + session_name: 's1', + platform: 'android', + status: 'RUNNING', + ready: true, + created_at: '2026-01-01T00:00:00Z', + screenshot_data_url: 'data:image/png;base64,AAAA', + hierarchy: { width: 1, height: 1, elements: [] }, + }) as unknown as LiveSession; + + const out = await runJson(sub('status'), { args: { ...liveArgs, session: 's1' } }); + expect(out).to.include({ session_name: 's1', status: 'RUNNING', ready: true }); + expect(out).to.not.have.property('screenshot_data_url'); + expect(out).to.not.have.property('hierarchy'); + }); + + it('hierarchy --output writes the file and prints only a result object', async () => { + const hierarchy = { width: 1, height: 1, elements: [] }; + ApiGateway.getLiveSession = async () => + ({ session_name: 's1', hierarchy }) as unknown as LiveSession; + const file = path.join(tempDir, 'hierarchy.json'); + + expect( + await runJson(sub('hierarchy'), { args: { ...liveArgs, session: 's1', output: file } }), + ).to.deep.equal({ status: 'SUCCESS', path: file }); + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).to.deep.equal(hierarchy); + }); + + it('stop prints only the result object', async () => { + ApiGateway.stopLiveSession = async () => {}; + expect( + await runJson(sub('stop'), { args: { ...liveArgs, session: 's1' } }), + ).to.deep.equal({ status: 'SUCCESS', session: 's1' }); + }); + }); +}); 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/report-download.service.test.ts b/test/unit/report-download.service.test.ts index 9feda29..fa0a072 100644 --- a/test/unit/report-download.service.test.ts +++ b/test/unit/report-download.service.test.ts @@ -201,6 +201,35 @@ describe('ReportDownloadService', () => { expect(logs.join(' ')).to.include(outPath); }); + + // `dcd artifacts --json` reports these, since failures only warn. + it('returns ok: true with the path when the download succeeds', async () => { + const outPath = path.join(tempDir, 'result-ok.zip'); + mockFetch(200, 'data'); + + const result = await service.downloadArtifacts({ + ...BASE, + artifactsPath: outPath, + downloadType: 'ALL', + }); + + expect(result).to.deep.equal({ ok: true, path: outPath, type: 'artifacts' }); + }); + + it('returns ok: false with the error when the download fails', async () => { + const outPath = path.join(tempDir, 'result-fail.zip'); + mockFetch(404, 'not found'); + + const result = await service.downloadArtifacts({ + ...BASE, + artifactsPath: outPath, + downloadType: 'ALL', + }); + + expect(result.ok).to.equal(false); + expect(result.type).to.equal('artifacts'); + expect(result.error).to.be.a('string').and.not.be.empty; + }); }); // ------------------------------------------------------------------------- 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); + }); +});