diff --git a/package.json b/package.json index 376b0de..64d6dc4 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,8 @@ "pnpm": { "overrides": { "js-yaml@<3.14.2": ">=3.14.2", + "js-yaml@>=4.0.0 <4.2.0": ">=4.2.0", + "tar@<7.5.16": ">=7.5.16", "@isaacs/brace-expansion": ">=5.0.1", "fast-xml-parser": ">=5.5.7", "flatted": ">=3.4.2", @@ -100,7 +102,7 @@ "brace-expansion@<1.1.13": "1.1.13", "brace-expansion@>=2.0.0 <2.0.3": "2.0.3", "brace-expansion@>=4.0.0 <5.0.6": "5.0.6", - "ws@>=8.0.0 <8.20.1": "8.20.1", + "ws@>=8.0.0 <8.21.0": "8.21.0", "esbuild@<0.28.1": ">=0.28.1", "micromatch>picomatch": "^2.3.2", "tinyglobby>picomatch": "^4.0.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d79dd9..99c37d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,8 @@ settings: overrides: js-yaml@<3.14.2: '>=3.14.2' + js-yaml@>=4.0.0 <4.2.0: '>=4.2.0' + tar@<7.5.16: '>=7.5.16' '@isaacs/brace-expansion': '>=5.0.1' fast-xml-parser: '>=5.5.7' flatted: '>=3.4.2' @@ -24,7 +26,7 @@ overrides: brace-expansion@<1.1.13: 1.1.13 brace-expansion@>=2.0.0 <2.0.3: 2.0.3 brace-expansion@>=4.0.0 <5.0.6: 5.0.6 - ws@>=8.0.0 <8.20.1: 8.20.1 + ws@>=8.0.0 <8.21.0: 8.21.0 esbuild@<0.28.1: '>=0.28.1' micromatch>picomatch: ^2.3.2 tinyglobby>picomatch: ^4.0.4 @@ -49,8 +51,8 @@ importers: specifier: ^0.1.6 version: 0.1.6 js-yaml: - specifier: ^4.1.1 - version: 4.1.1 + specifier: '>=4.2.0' + version: 4.2.0 node-apk: specifier: ^1.2.1 version: 1.2.1 @@ -61,8 +63,8 @@ importers: specifier: ^3.1.0 version: 3.1.0 tar: - specifier: ^7.5.11 - version: 7.5.13 + specifier: '>=7.5.16' + version: 7.5.16 tus-js-client: specifier: ^4.3.1 version: 4.3.1 @@ -1270,8 +1272,8 @@ packages: js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true jsesc@3.1.0: @@ -1753,8 +1755,8 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - tar@7.5.13: - resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} + tar@7.5.16: + resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==} engines: {node: '>=18'} tinyglobby@0.2.16: @@ -1884,8 +1886,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.20.1: - resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -2052,7 +2054,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.2.0 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -2127,7 +2129,7 @@ snapshots: '@supabase/phoenix': 0.4.0 '@types/ws': 8.18.1 tslib: 2.8.1 - ws: 8.20.1 + ws: 8.21.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -3167,7 +3169,7 @@ snapshots: js-base64@3.7.8: {} - js-yaml@4.1.1: + js-yaml@4.2.0: dependencies: argparse: 2.0.1 @@ -3279,7 +3281,7 @@ snapshots: glob: 10.5.0 he: 1.2.0 is-path-inside: 3.0.3 - js-yaml: 4.1.1 + js-yaml: 4.2.0 log-symbols: 4.1.0 minimatch: 9.0.7 ms: 2.1.3 @@ -3683,7 +3685,7 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - tar@7.5.13: + tar@7.5.16: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -3873,7 +3875,7 @@ snapshots: wrappy@1.0.2: {} - ws@8.20.1: {} + ws@8.21.0: {} xmlbuilder@15.1.1: {} diff --git a/src/commands/artifacts.ts b/src/commands/artifacts.ts index 083ed00..202b1f9 100644 --- a/src/commands/artifacts.ts +++ b/src/commands/artifacts.ts @@ -4,6 +4,7 @@ import { apiFlags } from '../config/flags/api.flags'; import { ReportDownloadService } from '../services/report-download.service'; import { resolveAuth } from '../utils/auth'; import { CliError, logger, validateEnum } from '../utils/cli'; +import { resolveApiUrl } from '../utils/config-store'; const DOWNLOAD_OPTIONS = ['ALL', 'FAILED'] as const; const REPORT_OPTIONS = ['allure', 'html', 'html-detailed', 'junit'] as const; @@ -58,7 +59,7 @@ export const artifactsCommand = defineCommand({ }, async run({ args }) { const apiKeyFlag = args['api-key'] as string | undefined; - const apiUrl = args['api-url'] as string; + const apiUrl = resolveApiUrl(args['api-url'] as string | undefined); const debug = Boolean(args.debug); const uploadId = args['upload-id'] as string; const downloadArtifacts = validateEnum( diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index a15583c..883ed37 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -36,6 +36,7 @@ import { CompatibilityData, fetchCompatibilityData, } from '../utils/compatibility'; +import { resolveApiUrl } from '../utils/config-store'; import { downloadExpoUrl, extractTarGz, findAppBundle, isUrl } from '../utils/expo'; import { toPortableRelativePath } from '../utils/paths'; import { @@ -142,7 +143,7 @@ export const cloudCommand = defineCommand({ }; try { const apiKeyFlag = args['api-key'] as string | undefined; - const apiUrl = args['api-url'] as string; + const apiUrl = resolveApiUrl(args['api-url'] as string | undefined); const appBinaryId = args['app-binary-id'] as string | undefined; const appFile = args['app-file'] as string | undefined; const appUrl = args['app-url'] as string | undefined; diff --git a/src/commands/list.ts b/src/commands/list.ts index 8e6b9b8..688bcb5 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -4,6 +4,7 @@ import { apiFlags } from '../config/flags/api.flags'; import { ApiGateway } from '../gateways/api-gateway'; import { resolveAuth } from '../utils/auth'; import { CliError, logger, parseIntFlag } from '../utils/cli'; +import { resolveApiUrl } from '../utils/config-store'; import { colors, formatId, formatUrl, sectionHeader, symbols } from '../utils/styling'; type UploadListItem = { @@ -126,7 +127,7 @@ export const listCommand = defineCommand({ }, async run({ args }) { const apiKeyFlag = args['api-key'] as string | undefined; - const apiUrl = args['api-url'] as string; + const apiUrl = resolveApiUrl(args['api-url'] as string | undefined); const from = args.from as string | undefined; const to = args.to as string | undefined; const name = args.name as string | undefined; diff --git a/src/commands/live.ts b/src/commands/live.ts index 544f592..e585fc0 100644 --- a/src/commands/live.ts +++ b/src/commands/live.ts @@ -6,6 +6,7 @@ import { ApiGateway } from '../gateways/api-gateway'; import type { AuthContext } from '../types/domain/auth.types'; import { resolveAuth } from '../utils/auth'; import { logger, validateEnum } from '../utils/cli'; +import { resolveApiUrl } from '../utils/config-store'; import { colors, sectionHeader, symbols } from '../utils/styling'; const PLATFORM_OPTIONS = ['android', 'ios'] as const; @@ -31,7 +32,7 @@ const startSub = defineCommand({ }, async run({ args }) { const auth = await requireAuth(args['api-key'] as string | undefined); - const apiUrl = args['api-url'] as string; + const apiUrl = resolveApiUrl(args['api-url'] as string | undefined); const platform = validateEnum( args.platform as string | undefined, PLATFORM_OPTIONS, @@ -81,7 +82,7 @@ const installSub = defineCommand({ }, async run({ args }) { const auth = await requireAuth(args['api-key'] as string | undefined); - const apiUrl = args['api-url'] as string; + const apiUrl = resolveApiUrl(args['api-url'] as string | undefined); const sessionName = args.session as string; const binaryId = args['app-binary-id'] as string; @@ -104,7 +105,7 @@ const execSub = defineCommand({ }, async run({ args }) { const auth = await requireAuth(args['api-key'] as string | undefined); - const apiUrl = args['api-url'] as string; + const apiUrl = resolveApiUrl(args['api-url'] as string | undefined); const sessionName = args.session as string; const yaml = args.yaml as string; @@ -140,7 +141,7 @@ const stopSub = defineCommand({ }, async run({ args }) { const auth = await requireAuth(args['api-key'] as string | undefined); - const apiUrl = args['api-url'] as string; + const apiUrl = resolveApiUrl(args['api-url'] as string | undefined); const sessionName = args.session as string; logger.log(`${symbols.running} Stopping session ${colors.highlight(sessionName)}...`); @@ -159,7 +160,7 @@ const statusSub = defineCommand({ }, async run({ args }) { const auth = await requireAuth(args['api-key'] as string | undefined); - const apiUrl = args['api-url'] as string; + const apiUrl = resolveApiUrl(args['api-url'] as string | undefined); const sessionName = args.session as string; const session = await ApiGateway.getLiveSession(apiUrl, auth, sessionName); @@ -189,7 +190,15 @@ export const liveCommand = defineCommand({ stop: stopSub, status: statusSub, }, - run() { + // citty's runCommand does not early-return after dispatching to a + // subcommand — it still invokes the parent `run` afterwards. So when a + // subcommand (start/install/exec/...) was matched, bail out here; otherwise + // the menu would print after every successful subcommand. + run({ rawArgs }) { + const subNames = new Set(['start', 'install', 'exec', 'stop', 'status']); + const firstPositional = rawArgs.find((arg) => !arg.startsWith('-')); + if (firstPositional && subNames.has(firstPositional)) return; + logger.log(sectionHeader('Live Session Commands')); logger.log(` ${colors.bold('start')} Start a new live device session`); logger.log(` ${colors.bold('install')} Install a binary on the device`); diff --git a/src/commands/status.ts b/src/commands/status.ts index f55f075..6dc2b1f 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -5,6 +5,7 @@ import { ApiGateway } from '../gateways/api-gateway'; import { formatDurationSeconds } from '../methods'; import { resolveAuth } from '../utils/auth'; import { CliError, logger } from '../utils/cli'; +import { resolveApiUrl } from '../utils/config-store'; import { ConnectivityCheckResult, checkInternetConnectivity, @@ -81,7 +82,7 @@ export const statusCommand = defineCommand({ // eslint-disable-next-line complexity async run({ args }) { const apiKeyFlag = args['api-key'] as string | undefined; - const apiUrl = args['api-url'] as string; + const apiUrl = resolveApiUrl(args['api-url'] as string | undefined); const json = Boolean(args.json); const name = args.name as string | undefined; const uploadId = args['upload-id'] as string | undefined; diff --git a/src/commands/switch-org.ts b/src/commands/switch-org.ts index 064b3a4..ba51987 100644 --- a/src/commands/switch-org.ts +++ b/src/commands/switch-org.ts @@ -9,7 +9,7 @@ import { defineCommand } from 'citty'; import { resolveAuth } from '../utils/auth'; import { CliError, logger } from '../utils/cli'; -import { readConfig, writeConfig } from '../utils/config-store'; +import { readConfig, resolveApiUrl, writeConfig } from '../utils/config-store'; import { fetchOrgs, pickOrg, OrgListItem } from '../utils/orgs'; import { colors, symbols } from '../utils/styling'; @@ -37,10 +37,7 @@ export const switchOrgCommand = defineCommand({ // Honor the env the user logged into — defaulting to prod here would send // a dev Bearer token to the prod API. - const apiUrl = - (args['api-url'] as string | undefined) ?? - config.api_url ?? - 'https://api.devicecloud.dev'; + const apiUrl = resolveApiUrl(args['api-url'] as string | undefined); const target = args.org as string | undefined; // sessionOnly: an exported DEVICE_CLOUD_API_KEY must not shadow the diff --git a/src/commands/upload.ts b/src/commands/upload.ts index bc5ac69..5b66f65 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -7,6 +7,7 @@ import { outputFlags } from '../config/flags/output.flags'; import { uploadBinary, verifyAppZip } from '../methods'; import { resolveAuth } from '../utils/auth'; import { CliError, logger } from '../utils/cli'; +import { resolveApiUrl } from '../utils/config-store'; import { downloadExpoUrl, extractTarGz, findAppBundle, isUrl } from '../utils/expo'; import { colors, formatId, sectionHeader, symbols } from '../utils/styling'; @@ -37,7 +38,7 @@ export const uploadCommand = defineCommand({ }; try { const apiKeyFlag = args['api-key'] as string | undefined; - const apiUrl = args['api-url'] as string; + const apiUrl = resolveApiUrl(args['api-url'] as string | undefined); const appUrl = args['app-url'] as string | undefined; const ignoreShaCheck = Boolean(args['ignore-sha-check']); const debug = Boolean(args.debug); diff --git a/src/config/flags/api.flags.ts b/src/config/flags/api.flags.ts index ffb5474..8f65c7f 100644 --- a/src/config/flags/api.flags.ts +++ b/src/config/flags/api.flags.ts @@ -13,7 +13,9 @@ export const apiFlags = { 'api-url': { type: 'string', alias: ['apiURL', 'apiUrl'], - default: 'https://api.devicecloud.dev', - description: 'API base URL', + // No citty `default` here: commands resolve the effective URL via + // resolveApiUrl() so a value stored by `dcd login` (e.g. dev/staging) is + // honored instead of always defaulting to prod. See utils/config-store.ts. + description: 'API base URL (defaults to the URL stored by `dcd login`, else prod)', }, } as const satisfies ArgsDef; diff --git a/src/config/flags/environment.flags.ts b/src/config/flags/environment.flags.ts index e002290..e15c3b3 100644 --- a/src/config/flags/environment.flags.ts +++ b/src/config/flags/environment.flags.ts @@ -7,8 +7,9 @@ export const environmentFlags = { env: { type: 'string', alias: ['e'], - description: 'One or more environment variable files to inject into your flows (may be repeated)', - valueHint: 'path', + description: + 'One or more environment variables to inject into your flows (format: KEY=VALUE, may be repeated)', + valueHint: 'KEY=VALUE', }, metadata: { type: 'string', diff --git a/src/gateways/api-gateway.ts b/src/gateways/api-gateway.ts index 028b0ed..9bbf81b 100644 --- a/src/gateways/api-gateway.ts +++ b/src/gateways/api-gateway.ts @@ -141,7 +141,9 @@ export const ApiGateway = { } default: { - throw new ApiError(`${operation} failed: ${userMessage} (HTTP ${res.status})`, res.status); + // `operation` is already phrased as "Failed to …", so don't append + // another "failed" here (avoids "Failed to execute test failed: …"). + throw new ApiError(`${operation}: ${userMessage} (HTTP ${res.status})`, res.status); } } }, diff --git a/src/utils/config-store.ts b/src/utils/config-store.ts index aff7aa0..2be0171 100644 --- a/src/utils/config-store.ts +++ b/src/utils/config-store.ts @@ -21,6 +21,8 @@ import { import { homedir } from 'node:os'; import * as path from 'node:path'; +import { ENVIRONMENTS } from '../config/environments'; + export const CONFIG_SCHEMA_VERSION = 1; export interface StoredSession { @@ -78,6 +80,22 @@ export function readConfig(): StoredConfig | null { } } +/** + * Resolve the API URL a command should talk to. Precedence: + * 1. explicit --api-url flag + * 2. api_url stored by `dcd login` (honors the env the user logged into) + * 3. prod default + * + * Without this, session commands default to prod and a dev/staging Bearer + * token is rejected with a misleading "Invalid or expired JWT". `switch-org` + * has always done this; this helper extends it to every command. + */ +export function resolveApiUrl(flag: string | undefined): string { + const explicit = flag?.trim(); + if (explicit) return explicit; + return readConfig()?.api_url ?? ENVIRONMENTS.prod.apiUrl; +} + export function writeConfig(config: StoredConfig): void { const dir = getConfigDir(); if (!existsSync(dir)) { diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts index 95d0cc6..31bd22e 100644 --- a/test/unit/auth.test.ts +++ b/test/unit/auth.test.ts @@ -9,6 +9,7 @@ import { configFileMode, getConfigPath, readConfig, + resolveApiUrl, writeConfig, } from '../../src/utils/config-store'; @@ -67,6 +68,49 @@ describe('config-store', () => { }); }); +// Regression coverage for #16: session commands must honor the api_url stored +// by `dcd login` instead of always defaulting to prod, otherwise a dev/staging +// Bearer token is sent to prod and rejected as "Invalid or expired JWT". +describe('resolveApiUrl precedence', () => { + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it('prefers an explicit flag over the stored config', async () => { + await withTempConfigDir(() => { + writeConfig({ + version: 1, + env: 'dev', + api_url: 'https://api.dev.devicecloud.dev', + supabase_url: 'https://lbmsowehtjwnqlurpemb.supabase.co', + }); + expect(resolveApiUrl('https://api.devicecloud.dev')).to.equal( + 'https://api.devicecloud.dev', + ); + }); + }); + + it('falls back to the stored api_url when no flag is given', async () => { + await withTempConfigDir(() => { + writeConfig({ + version: 1, + env: 'dev', + api_url: 'https://api.dev.devicecloud.dev', + supabase_url: 'https://lbmsowehtjwnqlurpemb.supabase.co', + }); + expect(resolveApiUrl(undefined)).to.equal('https://api.dev.devicecloud.dev'); + // A blank/whitespace flag is treated as "not provided". + expect(resolveApiUrl(' ')).to.equal('https://api.dev.devicecloud.dev'); + }); + }); + + it('defaults to prod when neither a flag nor a stored config exists', async () => { + await withTempConfigDir(() => { + expect(resolveApiUrl(undefined)).to.equal('https://api.devicecloud.dev'); + }); + }); +}); + describe('resolveAuth precedence', () => { beforeEach(() => { delete process.env.DEVICE_CLOUD_API_KEY; diff --git a/test/unit/live-command.test.ts b/test/unit/live-command.test.ts new file mode 100644 index 0000000..4901275 --- /dev/null +++ b/test/unit/live-command.test.ts @@ -0,0 +1,44 @@ +import { expect } from 'chai'; + +import { liveCommand } from '../../src/commands/live'; + +/** + * Regression coverage for #15: citty invokes a parent command's `run` *after* + * dispatching to a subcommand, so `liveCommand.run` must suppress the + * "Live Session Commands" menu when a subcommand was matched — otherwise the + * menu prints after every `dcd live start|status|install|exec|stop`. + */ +describe('live command menu', () => { + function captureMenu(rawArgs: string[]): boolean { + 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 { + (liveCommand.run as (ctx: { rawArgs: string[] }) => void)({ rawArgs }); + } finally { + process.stdout.write = original; + } + return out.includes('Live Session Commands'); + } + + it('prints the menu when invoked with no subcommand', () => { + expect(captureMenu([])).to.equal(true); + }); + + it('does NOT print the menu when a subcommand is matched', () => { + for (const sub of ['start', 'install', 'exec', 'stop', 'status']) { + expect(captureMenu([sub]), `subcommand ${sub} should not leak the menu`).to.equal( + false, + ); + } + }); + + it('still prints the menu when the first positional is not a subcommand', () => { + // citty would reject this as an unknown command before reaching run(), but + // the menu is the correct fallback if run() is ever reached directly. + expect(captureMenu(['bogus'])).to.equal(true); + }); +});