From 1f74aec7a89e4727fa8da28f7b1b303e6e1aafa9 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Fri, 16 Oct 2026 17:51:51 +0100 Subject: [PATCH 1/7] feat: add --json output to every command (#223) * feat: add --json output to every command Every command and live subcommand now takes --json. With it, stdout carries exactly one JSON object on success and on failure. - index.ts prints thrown errors as { status: 'FAILED', error } when --json is in argv, and skips the help screen on usage errors. - New --json on whoami, logout, switch-org, login, upgrade, artifacts and all live subcommands. login and switch-org draw prompts and the login URL on stderr. - ReportDownloadService returns a result per download so artifacts --json can report failures; it exits 1 if every download failed. - live hierarchy --json --output prints { status, path } instead of a plain-text line. - Adds jsonFlag, logger.json, logger.fail and wantsJson; existing JSON call sites use logger.json with unchanged output. Co-Authored-By: Claude Opus 5.5 * fix(live): report the session name when --wait fails under --json Under --json the 'Live session started' block is dropped, so a waitForReady timeout or polling error left a billed session with no name to stop it by. Catch the error and print { status: 'FAILED', error, session_name, platform, ready: false, consoleUrl } before exiting with the original exit code. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CLAUDE.md | 2 +- STYLE_GUIDE.md | 17 +- src/commands/artifacts.ts | 29 ++- src/commands/cloud.ts | 6 +- src/commands/list.ts | 3 +- src/commands/live.ts | 237 ++++++++++++++++------ src/commands/login.ts | 37 +++- src/commands/logout.ts | 17 +- src/commands/status.ts | 51 ++--- src/commands/switch-org.ts | 21 +- src/commands/upgrade.ts | 29 ++- src/commands/upload.ts | 3 +- src/commands/whoami.ts | 18 ++ src/config/flags/output.flags.ts | 9 + src/index.ts | 9 +- src/services/report-download.service.ts | 45 ++-- src/utils/cli.ts | 32 ++- src/utils/orgs.ts | 3 + test/unit/json-output.test.ts | 176 ++++++++++++++++ test/unit/report-download.service.test.ts | 29 +++ 20 files changed, 625 insertions(+), 148 deletions(-) create mode 100644 test/unit/json-output.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 2917e1e..bc760b8 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. 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/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..12771c6 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -34,6 +34,7 @@ import { spawn } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; import { ENVIRONMENTS, inferEnvFromApiUrl, resolveFrontendUrl } from '../config/environments.js'; +import { jsonFlag } from '../config/flags/output.flags.js'; import { CliError, logger } from '../utils/cli.js'; import { readConfig, writeConfig } from '../utils/config-store.js'; import { fetchOrgs, pickOrg } from '../utils/orgs.js'; @@ -76,11 +77,20 @@ export const loginCommand = defineCommand({ default: true, description: 'Open the login URL in a browser (pass --no-browser to just print it)', }, + 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); + // 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; // 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,7 +102,7 @@ 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.`, ), @@ -105,9 +115,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 +138,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 +157,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); @@ -175,7 +190,7 @@ export const loginCommand = defineCommand({ ].filter(Boolean); throw new CliError(`${(err as Error).message} [${parts.join(' | ')}]`); } - const chosen = await pickOrg(orgs); + const chosen = await pickOrg(orgs, undefined, promptOutput); writeConfig({ version: 1, @@ -193,6 +208,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 +228,7 @@ export const loginCommand = defineCommand({ ]), ); } catch (error) { - logger.error(error as Error, { exit: 1 }); + logger.error(error as Error, { exit: 1, json }); } }, }); diff --git a/src/commands/logout.ts b/src/commands/logout.ts index 5d25480..8072b31 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -5,6 +5,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 +17,29 @@ 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); 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..87de101 100644 --- a/src/commands/switch-org.ts +++ b/src/commands/switch-org.ts @@ -7,6 +7,7 @@ */ import { defineCommand } from 'citty'; +import { jsonFlag } from '../config/flags/output.flags.js'; import { apiKeyOverride, resolveAuth } from '../utils/auth.js'; import { CliError, logger } from '../utils/cli.js'; import { readConfig, resolveApiUrl, writeConfig } from '../utils/config-store.js'; @@ -29,6 +30,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 +42,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. @@ -51,7 +54,8 @@ export const switchOrgCommand = defineCommand({ if (target) { chosen = matchOrg(orgs, target); } else { - chosen = await pickOrg(orgs); + // Under --json the picker draws on stderr so stdout stays pure JSON. + chosen = await pickOrg(orgs, undefined, json ? process.stderr : undefined); } writeConfig({ @@ -60,10 +64,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.`, 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/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/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/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..97a2c60 100644 --- a/src/utils/orgs.ts +++ b/src/utils/orgs.ts @@ -3,6 +3,7 @@ * `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 { CliError } from './cli.js'; @@ -34,6 +35,7 @@ export async function fetchOrgs( export async function pickOrg( orgs: OrgListItem[], message = 'Pick an organization', + output?: Writable, ): Promise { if (orgs.length === 0) { throw new CliError('No organizations found for this user.'); @@ -42,6 +44,7 @@ export async function pickOrg( const picked = await p.select({ message, + output, options: orgs.map((o) => ({ value: o.id, label: o.name })), }); if (p.isCancel(picked)) throw new CliError('Cancelled.'); 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/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; + }); }); // ------------------------------------------------------------------------- From fb443235288c8168573c4d8e560d3518e4b305a3 Mon Sep 17 00:00:00 2001 From: "dcd-release-please[bot]" <296541543+dcd-release-please[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:20:27 +0100 Subject: [PATCH 2/7] chore: merge production into dev after 5.8.0 (#236) * ReleaseAs: 5.8.0 * Release-As: 5.8.0 * Release-As: 5.8.0 * chore(production): release 5.8.0 --------- Co-authored-by: finalerock44 Co-authored-by: finalerock44 <77282157+finalerock44@users.noreply.github.com> Co-authored-by: dcd-release-please[bot] <296541543+dcd-release-please[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 25 +++++++++++++++++++++++++ package.json | 2 +- server.json | 4 ++-- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index cabdafc..ff20275 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "5.7.1" + ".": "5.8.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 02c2a19..736884f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## [5.8.0](https://github.com/devicecloud-dev/dcd-cli/compare/v5.7.1...v5.8.0) (2026-10-16) + + +### Features + +* **cloud:** add --priority flag for within-org run ordering ([#222](https://github.com/devicecloud-dev/dcd-cli/issues/222)) ([510d64b](https://github.com/devicecloud-dev/dcd-cli/commit/510d64baa34e3f7f19c5483f693aced20c0f0758)) +* **cloud:** add --priority flag for within-org run ordering (dcd[#1581](https://github.com/devicecloud-dev/dcd-cli/issues/1581)) ([510d64b](https://github.com/devicecloud-dev/dcd-cli/commit/510d64baa34e3f7f19c5483f693aced20c0f0758)) + + +### Bug Fixes + +* **cloud:** re-send a flow submit whose response was lost ([#226](https://github.com/devicecloud-dev/dcd-cli/issues/226)) ([f4db8f9](https://github.com/devicecloud-dev/dcd-cli/commit/f4db8f9d0b51d358b62153f40c2164abc67cb078)) +* **device:** drop Android API 29-31 from the --android-api-level help ([#227](https://github.com/devicecloud-dev/dcd-cli/issues/227)) ([05ea2dc](https://github.com/devicecloud-dev/dcd-cli/commit/05ea2dc292b1c8d2d453a4ec05b3c34577cf2c61)) +* **validation:** mirror the API's Pixel 7 / API 36 Android default ([#225](https://github.com/devicecloud-dev/dcd-cli/issues/225)) ([3daeb94](https://github.com/devicecloud-dev/dcd-cli/commit/3daeb94e077a3819f4568c74cc5059bd8ac9ba9b)) + + +### Performance + +* **cloud:** poll results with ?view=summary and If-None-Match ([#219](https://github.com/devicecloud-dev/dcd-cli/issues/219)) ([e916c59](https://github.com/devicecloud-dev/dcd-cli/commit/e916c59631ee10aee0c315af016582747afa0b81)) + + +### Dependencies + +* bump the minor-and-patch group with 6 updates ([#220](https://github.com/devicecloud-dev/dcd-cli/issues/220)) ([2b88b64](https://github.com/devicecloud-dev/dcd-cli/commit/2b88b6489d77591f3f47b00c980e11d5e6cc2e27)) + ## [5.7.1](https://github.com/devicecloud-dev/dcd-cli/compare/v5.7.0...v5.7.1) (2026-10-07) diff --git a/package.json b/package.json index 462a3a2..8d733ff 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "test:unit": "node scripts/test-runner.mjs --unit", "typecheck": "tsc --noEmit -p tsconfig.test.json" }, - "version": "5.7.1", + "version": "5.8.0", "bugs": { "url": "https://discord.gg/gm3mJwcNw8" }, diff --git a/server.json b/server.json index 1eee672..5764b41 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "dev.devicecloud/dcd", "description": "Run Maestro mobile app tests on devicecloud.dev: submit flows, check status, download artifacts.", - "version": "5.7.1", + "version": "5.8.0", "repository": { "url": "https://github.com/devicecloud-dev/dcd-cli", "source": "github" @@ -11,7 +11,7 @@ { "registryType": "npm", "identifier": "@devicecloud.dev/dcd", - "version": "5.7.1", + "version": "5.8.0", "runtimeHint": "npx", "runtimeArguments": [ { From aef26dd1a7328e718b6e8ff369bb31a1c7f08f55 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:28:17 +0100 Subject: [PATCH 3/7] chore: bump eslint-plugin-unicorn from 77.0.0 to 78.0.0 (#239) Bumps [eslint-plugin-unicorn](https://github.com/sindresorhus/eslint-plugin-unicorn) from 77.0.0 to 78.0.0. - [Release notes](https://github.com/sindresorhus/eslint-plugin-unicorn/releases) - [Commits](https://github.com/sindresorhus/eslint-plugin-unicorn/compare/v77.0.0...v78.0.0) --- updated-dependencies: - dependency-name: eslint-plugin-unicorn dependency-version: 78.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- pnpm-lock.yaml | 307 ++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 279 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index 8d733ff..f46705e 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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84e357c..5c54ddf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,8 +108,8 @@ importers: specifier: ^10.1.8 version: 10.1.8(eslint@10.12.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.12.0)(typescript@6.0.3) husky: specifier: ^9.1.7 version: 9.1.7 @@ -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': @@ -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,6 +464,9 @@ 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==} @@ -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==} @@ -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'} @@ -1753,9 +1842,9 @@ 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)': @@ -1872,6 +1961,10 @@ snapshots: '@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 @@ -2020,7 +2115,7 @@ snapshots: 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: {} @@ -2219,10 +2326,10 @@ snapshots: dependencies: eslint: 10.12.0 - eslint-plugin-unicorn@77.0.0(eslint@10.12.0): + eslint-plugin-unicorn@78.0.0(eslint@10.12.0)(typescript@6.0.3): dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0) - '@eslint/css-tree': 4.1.1 + '@eslint/css-tree': 4.1.2 browserslist: 4.29.3 change-case: 5.4.4 ci-info: 4.4.0 @@ -2235,6 +2342,8 @@ snapshots: 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: @@ -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 @@ -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: {} From 5fffd7ae00e2e6749271ae859c6cf6c47d98678f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:42:54 +0100 Subject: [PATCH 4/7] deps: bump the minor-and-patch group across 1 directory with 5 updates (#238) Bumps the minor-and-patch group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@supabase/supabase-js](https://github.com/supabase/supabase-js/tree/HEAD/packages/core/supabase-js) | `2.117.3` | `2.118.0` | | [js-yaml](https://github.com/nodeca/js-yaml) | `5.4.3` | `5.5.0` | | [zod](https://github.com/colinhacks/zod) | `4.6.5` | `4.6.7` | | [eslint](https://github.com/eslint/eslint) | `10.12.0` | `10.13.0` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.71.1` | `8.71.2` | Updates `@supabase/supabase-js` from 2.117.3 to 2.118.0 - [Release notes](https://github.com/supabase/supabase-js/releases) - [Changelog](https://github.com/supabase/supabase-js/blob/master/packages/core/supabase-js/CHANGELOG.md) - [Commits](https://github.com/supabase/supabase-js/commits/v2.118.0/packages/core/supabase-js) Updates `js-yaml` from 5.4.3 to 5.5.0 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/5.4.3...5.5.0) Updates `zod` from 4.6.5 to 4.6.7 - [Release notes](https://github.com/colinhacks/zod/releases) - [Commits](https://github.com/colinhacks/zod/compare/v4.6.5...v4.6.7) Updates `eslint` from 10.12.0 to 10.13.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v10.12.0...v10.13.0) Updates `typescript-eslint` from 8.71.1 to 8.71.2 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.2/packages/typescript-eslint) --- updated-dependencies: - dependency-name: "@supabase/supabase-js" dependency-version: 2.118.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-and-patch - dependency-name: eslint dependency-version: 10.13.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: minor-and-patch - dependency-name: js-yaml dependency-version: 5.5.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-and-patch - dependency-name: typescript-eslint dependency-version: 8.71.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: zod dependency-version: 4.6.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 272 ++++++++++++++++++++++++------------------------- 1 file changed, 136 insertions(+), 136 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c54ddf..65a2879 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: ^78.0.0 - version: 78.0.0(eslint@10.12.0)(typescript@6.0.3) + 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: @@ -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' @@ -473,63 +473,63 @@ packages: '@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': @@ -545,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 @@ -564,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==} @@ -815,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: @@ -1110,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: @@ -1633,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 @@ -1712,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: @@ -1819,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': {} @@ -1847,9 +1847,9 @@ snapshots: 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': {} @@ -1890,7 +1890,7 @@ 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) ajv: 8.20.0 @@ -1907,8 +1907,8 @@ snapshots: 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 @@ -1924,37 +1924,37 @@ 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: @@ -1987,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) @@ -2003,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 @@ -2062,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': {} @@ -2085,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: @@ -2109,7 +2109,7 @@ 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: {} @@ -2322,13 +2322,13 @@ 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@78.0.0(eslint@10.12.0)(typescript@6.0.3): + 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-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 @@ -2336,7 +2336,7 @@ snapshots: 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 @@ -2367,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 @@ -2404,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: @@ -2684,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: {} @@ -2918,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 @@ -3257,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 @@ -3317,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: {} From 5f710d55d024c6869d529c606f261530ac9013ad Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:03:39 +0100 Subject: [PATCH 5/7] feat(login): reuse the last org and add --org (#243) * 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 * 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. --- CLAUDE.md | 2 +- package.json | 2 +- pnpm-lock.yaml | 18 +- 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 ++++++++++++++++++++++++++++++++ 8 files changed, 485 insertions(+), 48 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/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(''); + }); + }); +}); From 8cedb4e2f133f4300b669f877896e65c61c38820 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Thu, 22 Oct 2026 08:12:04 +0100 Subject: [PATCH 6/7] feat(auth): fetch the Supabase key from the API instead of compiling it in (#245) Every release so far compiled the Supabase anon key in, so the key could not be rotated without breaking pinned CLI versions (dcd#1112). The CLI now fetches its publishable key from the API's public GET /cli-config, once per process, so a rotation is an env change on the API. - The key is kept in memory only. A stale on-disk copy would make a session refresh fail with a 4xx, which drops the user's login. - A failed key fetch during refresh is a retryable error, never a definitive one, so the stored session survives. - Supabase URLs stay pinned (prod uses the cloud.devicecloud.dev custom domain); only the key comes from the API. - Raw Storage/Auth requests send an sb_publishable_ key as `apikey` only, and a legacy JWT key as a Bearer too. - Realtime keeps a synchronous subscribe() and connects once the key arrives; until then the backstop poll covers it. - The anon keys stay in the gitleaks allowlist, relabelled: they remain in history, which CI scans in full. Co-authored-by: Claude Opus 5.5 --- .gitleaks.toml | 27 ++--- .husky/pre-commit | 2 +- src/commands/login.ts | 18 ++-- src/commands/logout.ts | 4 +- src/config/environments.ts | 19 ++-- src/config/supabase-key.ts | 60 +++++++++++ src/gateways/cli-auth-gateway.ts | 25 +++-- src/gateways/realtime-gateway.ts | 170 +++++++++++++++++-------------- src/gateways/supabase-gateway.ts | 15 +-- src/utils/auth.ts | 4 +- test/unit/supabase-key.test.ts | 96 +++++++++++++++++ 11 files changed, 309 insertions(+), 131 deletions(-) create mode 100644 src/config/supabase-key.ts create mode 100644 test/unit/supabase-key.test.ts 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/src/commands/login.ts b/src/commands/login.ts index fe15f7a..b438ec2 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -36,7 +36,13 @@ 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'; @@ -200,11 +206,7 @@ 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}`, @@ -335,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)'; @@ -343,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 8072b31..be2a163 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -4,7 +4,6 @@ */ 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'; @@ -33,8 +32,7 @@ export const logoutCommand = defineCommand({ 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 }); 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/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/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/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); + }); +}); From 57cf6b1fc0cb74ac4fe9f30b6659095d4321fbfc Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Thu, 22 Oct 2026 08:18:47 +0100 Subject: [PATCH 7/7] chore: pin the 5.9.0 promotion Release-As: 5.9.0