Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 15 additions & 12 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -1,29 +1,32 @@
# 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.
[extend]
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
Expand Down
2 changes: 1 addition & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 `<frontend>/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 `<frontend>/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 <name|id>` 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 <jwt>` + `x-dcd-org: <id>`. 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`.

Expand Down
17 changes: 12 additions & 5 deletions STYLE_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading