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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
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
18 changes: 9 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 40 additions & 5 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,9 +38,10 @@ import { createHash, randomBytes } from 'node:crypto';

import { ENVIRONMENTS, inferEnvFromApiUrl, resolveFrontendUrl } from '../config/environments.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';

Expand Down Expand Up @@ -77,20 +81,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
Expand All @@ -107,6 +124,10 @@ export const loginCommand = defineCommand({
`Your session for ${existing.session.user_email} has expired — signing in again.`,
),
);
} else if (!interactive) {
// The confirm below defaults to yes. The old session stays until the
// new login succeeds, so this replaces nothing if it never completes.
say(ui.info(`Already logged in as ${existing.session.user_email} — signing in again.`));
} else {
const currentOrg = existing.current_org_name ?? existing.current_org_id;
const ok = await p.confirm({
Expand Down Expand Up @@ -190,7 +211,21 @@ export const loginCommand = defineCommand({
].filter(Boolean);
throw new CliError(`${(err as Error).message} [${parts.join(' | ')}]`);
}
const chosen = await pickOrg(orgs, undefined, promptOutput);
const { org: chosen, fromLastLogin } = await selectOrg(orgs, {
requested: requestedOrg,
// Org ids are per environment (dev org 1 isn't prod org 1), so the
// last login's org only counts if that login was to this same API.
lastOrgId: existing?.api_url === apiUrl ? existing.current_org_id : undefined,
interactive,
output: promptOutput,
});
if (fromLastLogin) {
say(
ui.info(
`No terminal to pick an organization in, so using ${chosen.name} from your last login.`,
),
);
}

writeConfig({
version: 1,
Expand Down
40 changes: 13 additions & 27 deletions src/commands/switch-org.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@
*
* With an argument (`dcd switch-org <name>`): 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';

Expand Down Expand Up @@ -52,10 +54,17 @@ export const switchOrgCommand = defineCommand({

let chosen: OrgListItem;
if (target) {
chosen = matchOrg(orgs, target);
chosen = matchOrg(orgs, target, '`dcd switch-org` without arguments');
} else {
// Under --json the picker draws on stderr so stdout stays pure JSON.
chosen = await pickOrg(orgs, undefined, json ? process.stderr : undefined);
const output = json ? process.stderr : undefined;
// Without a TTY the picker would wait forever for a keypress.
if (orgs.length > 1 && !canPrompt(output)) {
throw new CliError(
`There's no terminal to pick an organization in. Run \`dcd switch-org <name>\` with one of: ${orgs.map((o) => o.name).join(', ')}`,
);
}
chosen = await pickOrg(orgs, { output });
}

writeConfig({
Expand Down Expand Up @@ -88,27 +97,4 @@ export const switchOrgCommand = defineCommand({
},
});

/**
* Match a user-supplied string to an org. Prefers case-insensitive name match.
* Falls back to slug / id so existing scripts keep working, but error output
* only surfaces names.
*/
function matchOrg(orgs: OrgListItem[], target: string): OrgListItem {
const needle = target.toLowerCase();
const nameMatches = orgs.filter((o) => o.name.toLowerCase() === needle);
if (nameMatches.length > 1) {
throw new CliError(
`Multiple orgs named "${target}". Run \`dcd switch-org\` without arguments to pick interactively.`,
);
}
const chosen =
nameMatches[0] ?? orgs.find((o) => o.slug === target || o.id === target);
if (!chosen) {
throw new CliError(
`No org named "${target}". Available: ${orgs.map((o) => o.name).join(', ')}`,
);
}
return chosen;
}

export default switchOrgCommand;
10 changes: 10 additions & 0 deletions src/utils/ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'. */
Expand Down
Loading
Loading