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/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: {} 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(''); + }); + }); +});