From a789a6b85192298b67446a51c891ab33816c8ec9 Mon Sep 17 00:00:00 2001 From: finalerock44 Date: Wed, 21 Oct 2026 16:27:05 +0100 Subject: [PATCH 1/2] feat(login): reuse the last org and add --org dcd login no longer hangs where nobody can answer a prompt, such as a script, CI, or a shell an agent runs. - The org picker starts on the org from the last login, so Enter keeps it. - Without a TTY, the last login's org is used and one line says so. With no last org, or one that is gone, login fails with an error that names --org and lists the available orgs. - --org picks the org without the picker, and fails if the user isn't a member. - Without a TTY, the "already logged in" confirm takes its default and signs in again. The old session stays until the new login succeeds. - The last org only counts when the last login was to the same API, because org ids differ between environments. - A bare dcd switch-org without a TTY now fails with the org names instead of hanging the same way. Closes #241 --- CLAUDE.md | 2 +- src/commands/login.ts | 45 ++++- src/commands/switch-org.ts | 40 ++-- src/utils/ci.ts | 10 + src/utils/orgs.ts | 105 ++++++++++- test/unit/org-selection.test.ts | 311 ++++++++++++++++++++++++++++++++ 6 files changed, 475 insertions(+), 38 deletions(-) create mode 100644 test/unit/org-selection.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index bc760b8..d2fe424 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ Top-level `defineCommand` in `src/index.ts` wires eleven subcommands (`cloud`, ` **Auth.** Every command calls `resolveAuth({ apiKeyFlag })` (`src/utils/auth.ts`) once and threads the returned `AuthContext` into gateways/services. `ApiGateway` and `fetchCompatibilityData` spread `auth.headers` into fetch headers — they no longer accept a raw api key. Precedence: `--api-key` flag > `DEVICE_CLOUD_API_KEY` env > stored session from `dcd login`. `resolveAuth` refreshes expiring Supabase sessions via `CliAuthGateway.refresh` and rewrites the config atomically. A session's access token lasts about an hour, so code that can outlive it re-checks with `isAuthExpiring` / `refreshAuth`: the `dcd cloud` poll loop and the report downloads after it, the MCP context, and `dcd_run_cloud_test`'s wait loop. `refreshAuth` keeps the org the command started with and leaves API-key contexts untouched. -**Config store.** `dcd login` writes `$XDG_CONFIG_HOME/dcd/config.json` (fallback `~/.dcd/config.json`, 0600). Shape: `{ version, env, api_url, supabase_url, session: { access_token, refresh_token, expires_at, user_email, user_id }, current_org_id, current_org_name }`. `DCD_CONFIG_DIR` overrides the directory (used by tests). The login command itself (`src/commands/login.ts`) uses PKCE (S256) with a server rendezvous — no loopback server: it mints `state`, `code_verifier`, and `code_challenge`, opens `/cli-login?state=...&code_challenge=...`, then polls the dcd API's `POST /cli-login/claim` with `{state, code_verifier}` while the frontend POSTs proof of identity (the browser's access token) to `POST /cli-login/handoff`; the API verifies the token, **mints a dedicated Supabase session for the CLI** (its own refresh-token family — sharing the browser's tokens caused "Invalid Refresh Token: Already Used" whenever either client rotated them), stores it keyed by state, then on claim verifies `sha256(verifier) === challenge` and returns it. After claiming, the CLI fetches `/me/orgs` and prompts for an org (the same picker `dcd switch-org` uses). The frontend lives in `../dcd/frontend/app/features/cli-login/CliLoginScreen.tsx`. +**Config store.** `dcd login` writes `$XDG_CONFIG_HOME/dcd/config.json` (fallback `~/.dcd/config.json`, 0600). Shape: `{ version, env, api_url, supabase_url, session: { access_token, refresh_token, expires_at, user_email, user_id }, current_org_id, current_org_name }`. `DCD_CONFIG_DIR` overrides the directory (used by tests). The login command itself (`src/commands/login.ts`) uses PKCE (S256) with a server rendezvous — no loopback server: it mints `state`, `code_verifier`, and `code_challenge`, opens `/cli-login?state=...&code_challenge=...`, then polls the dcd API's `POST /cli-login/claim` with `{state, code_verifier}` while the frontend POSTs proof of identity (the browser's access token) to `POST /cli-login/handoff`; the API verifies the token, **mints a dedicated Supabase session for the CLI** (its own refresh-token family — sharing the browser's tokens caused "Invalid Refresh Token: Already Used" whenever either client rotated them), stores it keyed by state, then on claim verifies `sha256(verifier) === challenge` and returns it. After claiming, the CLI fetches `/me/orgs` and picks the org with `selectOrg` (`src/utils/orgs.ts`): `--org ` if given, else the only org, else the picker `dcd switch-org` uses, starting on the last login's org. That org only counts when the stored `api_url` matches, because org ids are per environment. Without a TTY (`canPrompt` in `src/utils/ci.ts`) the prompts can't be answered: the "already logged in" confirm takes its default and signs in again, the last login's org is used, and with none the command fails pointing at `--org`. `dcd logout` deletes the whole config, so nothing is remembered after it. The frontend lives in `../dcd/frontend/app/features/cli-login/CliLoginScreen.tsx`. **Cross-repo auth surface.** The dcd API's `ApiKeyGuard` accepts either `x-app-api-key` (existing) or `Authorization: Bearer ` + `x-dcd-org: `. For Bearer it verifies the JWT, checks `user_org_profile` membership, and injects the org's api_key back into the request headers so existing `@Headers(APP_API_KEY_HEADER)` controller code keeps working unchanged. `dcd switch-org` calls `GET /me/orgs`, a JWT-only endpoint at `../dcd/api/src/apps/me/me.controller.ts`. diff --git a/src/commands/login.ts b/src/commands/login.ts index 12771c6..fe15f7a 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 @@ -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'; @@ -77,6 +81,10 @@ export const loginCommand = defineCommand({ default: true, description: 'Open the login URL in a browser (pass --no-browser to just print it)', }, + org: { + type: 'string', + description: 'Organization to use, by name or id (skips the picker)', + }, json: jsonFlag, }, async run({ args }) { @@ -84,6 +92,12 @@ export const loginCommand = defineCommand({ const frontendOverride = args['frontend-url'] as string | undefined; const noBrowser = args.browser === false; const json = Boolean(args.json); + // citty gives '' for a bare `--org`. Refuse it, and an empty `--org "$ORG"`, + // rather than fall back to an org the caller didn't ask for. + const requestedOrg = (args.org as string | undefined)?.trim(); + if (requestedOrg === '') { + throw new CliError('--org needs an organization name or id.'); + } // Login is interactive, so under --json the human rows and prompts move to // stderr (the user still needs the URL) and stdout gets only the result. const say = (m: string) => { @@ -91,6 +105,9 @@ export const loginCommand = defineCommand({ else logger.log(m); }; const promptOutput = json ? process.stderr : undefined; + // Without a TTY (a script, CI, a shell an agent runs) nobody can answer a + // prompt, so each one below falls back to the answer Enter would give. + const interactive = canPrompt(promptOutput); // If there's an existing stored session, make the user confirm before we // overwrite it. Silent clobber is fine for power users but surprising if @@ -107,6 +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({ @@ -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, diff --git a/src/commands/switch-org.ts b/src/commands/switch-org.ts index 87de101..60a86e5 100644 --- a/src/commands/switch-org.ts +++ b/src/commands/switch-org.ts @@ -3,15 +3,17 @@ * * With an argument (`dcd switch-org `): update config immediately after * confirming the user is a member via GET /me/orgs. - * Without an argument: fetch orgs and prompt the user to pick one. + * Without an argument: fetch orgs and prompt the user to pick one (an error + * instead when there's no TTY to answer the prompt). */ import { defineCommand } from 'citty'; import { jsonFlag } from '../config/flags/output.flags.js'; import { apiKeyOverride, resolveAuth } from '../utils/auth.js'; +import { canPrompt } from '../utils/ci.js'; import { CliError, logger } from '../utils/cli.js'; import { readConfig, resolveApiUrl, writeConfig } from '../utils/config-store.js'; -import { fetchOrgs, pickOrg, OrgListItem } from '../utils/orgs.js'; +import { fetchOrgs, matchOrg, pickOrg, OrgListItem } from '../utils/orgs.js'; import { colors } from '../utils/styling.js'; import { ui } from '../utils/ui.js'; @@ -52,10 +54,17 @@ export const switchOrgCommand = defineCommand({ let chosen: OrgListItem; if (target) { - chosen = matchOrg(orgs, target); + chosen = matchOrg(orgs, target, '`dcd switch-org` without arguments'); } else { // Under --json the picker draws on stderr so stdout stays pure JSON. - chosen = await pickOrg(orgs, undefined, json ? process.stderr : undefined); + const output = json ? process.stderr : undefined; + // Without a TTY the picker would wait forever for a keypress. + if (orgs.length > 1 && !canPrompt(output)) { + throw new CliError( + `There's no terminal to pick an organization in. Run \`dcd switch-org \` with one of: ${orgs.map((o) => o.name).join(', ')}`, + ); + } + chosen = await pickOrg(orgs, { output }); } writeConfig({ @@ -88,27 +97,4 @@ export const switchOrgCommand = defineCommand({ }, }); -/** - * Match a user-supplied string to an org. Prefers case-insensitive name match. - * Falls back to slug / id so existing scripts keep working, but error output - * only surfaces names. - */ -function matchOrg(orgs: OrgListItem[], target: string): OrgListItem { - const needle = target.toLowerCase(); - const nameMatches = orgs.filter((o) => o.name.toLowerCase() === needle); - if (nameMatches.length > 1) { - throw new CliError( - `Multiple orgs named "${target}". Run \`dcd switch-org\` without arguments to pick interactively.`, - ); - } - const chosen = - nameMatches[0] ?? orgs.find((o) => o.slug === target || o.id === target); - if (!chosen) { - throw new CliError( - `No org named "${target}". Available: ${orgs.map((o) => o.name).join(', ')}`, - ); - } - return chosen; -} - export default switchOrgCommand; diff --git a/src/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/orgs.ts b/src/utils/orgs.ts index 97a2c60..078ee48 100644 --- a/src/utils/orgs.ts +++ b/src/utils/orgs.ts @@ -1,9 +1,9 @@ /** - * Shared helpers for fetching and picking from /me/orgs. Used by both - * `dcd login` (after the PKCE claim returns a session) and `dcd switch-org`. + * Shared helpers for fetching, matching and picking from /me/orgs. Used by + * both `dcd login` (after the PKCE claim returns a session) and `dcd switch-org`. */ import * as p from '@clack/prompts'; -import type { Writable } from 'node:stream'; +import type { Readable, Writable } from 'node:stream'; import { CliError } from './cli.js'; @@ -28,14 +28,54 @@ export async function fetchOrgs( return body.orgs ?? []; } +/** + * Match a user-supplied string to an org. Prefers case-insensitive name match. + * Falls back to slug / id so existing scripts keep working, but error output + * only surfaces names. `pickCommand` is how to pick interactively instead, + * for when two orgs share a name. + */ +export function matchOrg( + orgs: OrgListItem[], + target: string, + pickCommand: string, +): OrgListItem { + const needle = target.toLowerCase(); + const nameMatches = orgs.filter((o) => o.name.toLowerCase() === needle); + if (nameMatches.length > 1) { + throw new CliError( + `Multiple orgs named "${target}". Run ${pickCommand} to pick interactively.`, + ); + } + const chosen = + nameMatches[0] ?? orgs.find((o) => o.slug === target || o.id === target); + if (!chosen) { + throw new CliError( + `No org named "${target}". Available: ${orgs.map((o) => o.name).join(', ')}`, + ); + } + return chosen; +} + +export interface PickOrgOptions { + message?: string; + /** Org to start the picker on, so Enter confirms it. Ignored if not listed. */ + initialId?: string; + input?: Readable; + output?: Writable; +} + /** * Interactive org picker. Auto-selects when there's only one org (returns it * without prompting). Throws on zero orgs or user cancellation. */ export async function pickOrg( orgs: OrgListItem[], - message = 'Pick an organization', - output?: Writable, + { + message = 'Pick an organization', + initialId, + input, + output, + }: PickOrgOptions = {}, ): Promise { if (orgs.length === 0) { throw new CliError('No organizations found for this user.'); @@ -44,8 +84,11 @@ export async function pickOrg( const picked = await p.select({ message, + input, output, options: orgs.map((o) => ({ value: o.id, label: o.name })), + // clack starts on the first option when this isn't one of them. + initialValue: initialId, }); if (p.isCancel(picked)) throw new CliError('Cancelled.'); @@ -53,3 +96,55 @@ export async function pickOrg( if (!chosen) throw new CliError('No organization selected.'); return chosen; } + +export interface SelectOrgOptions { + /** `dcd login --org`: a name, slug or id. Skips the picker. */ + requested?: string; + /** The org the last login chose. */ + lastOrgId?: string; + /** Whether anyone can answer the picker (see canPrompt). */ + interactive: boolean; + input?: Readable; + output?: Writable; +} + +/** + * Choose the org for `dcd login`: `--org` if given, else the only org, else + * the picker starting on the last login's org. Without a TTY nobody can + * answer the picker, so the last login's org is used without asking, and + * when there isn't one the error names `--org` instead of hanging. + * `fromLastLogin` is set when that fallback chose the org, so the caller + * can say so. + */ +export async function selectOrg( + orgs: OrgListItem[], + { requested, lastOrgId, interactive, input, output }: SelectOrgOptions, +): Promise<{ org: OrgListItem; fromLastLogin: boolean }> { + if (orgs.length === 0) { + throw new CliError('No organizations found for this user.'); + } + if (requested !== undefined) { + return { + org: matchOrg(orgs, requested, '`dcd login` without --org'), + fromLastLogin: false, + }; + } + if (orgs.length === 1) return { org: orgs[0], fromLastLogin: false }; + + if (!interactive) { + const last = orgs.find((o) => o.id === lastOrgId); + if (last) return { org: last, fromLastLogin: true }; + + const reason = lastOrgId + ? "The organization from your last login is no longer available, and there's no terminal to pick another in." + : "You belong to more than one organization, and there's no terminal to pick one in."; + throw new CliError( + `${reason} Run \`dcd login --org \` with one of: ${orgs.map((o) => o.name).join(', ')}`, + ); + } + + return { + org: await pickOrg(orgs, { initialId: lastOrgId, input, output }), + fromLastLogin: false, + }; +} diff --git a/test/unit/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(''); + }); + }); +}); From d4c698deba56dd61ee1da493385596f060fab306 Mon Sep 17 00:00:00 2001 From: finalerock44 Date: Wed, 21 Oct 2026 16:44:41 +0100 Subject: [PATCH 2/2] chore(deps): bump hono override to 4.13.11 to clear audit pnpm audit flagged GHSA-5r4p-p66f-jhc7 (moderate) on hono >=4.12.4 <4.13.11, which comes in through @modelcontextprotocol/sdk. In those versions serveStatic decodes the request path twice. The advisory was published on 2026-10-20, after dev's last green run, so the lint-and-test audit step now fails on every branch. Dependabot opened an alert but no fix PR, because the exact-pin override holds hono at 4.13.7. 4.13.11 is the lowest patched version, as with the earlier pins. The audit passes apart from the advisory that was already ignored. Lint, typecheck, unit tests and build pass, and dcd-mcp answers initialize and tools/list over stdio with all five tools. --- package.json | 2 +- pnpm-lock.yaml | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index f46705e..4db0b30 100644 --- a/package.json +++ b/package.json @@ -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 65a2879..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 @@ -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==} @@ -1000,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: @@ -1858,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: @@ -1892,7 +1892,7 @@ snapshots: '@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 @@ -1902,7 +1902,7 @@ 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 @@ -2610,7 +2610,7 @@ snapshots: dependencies: function-bind: 1.1.2 - hono@4.13.7: {} + hono@4.13.11: {} hookified@1.15.1: {}