From db3383088fb478559333bf0c2d3ba3b1b5253110 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Fri, 12 Jun 2026 17:57:35 +0100 Subject: [PATCH 1/5] fix: address correctness and security findings from full code review - telemetry: redact --api-key/--env/--app-url values from argv before shipping events; pass auth headers to curl via 0600 config file instead of process arguments - polling: report PASSED only when every test passed (unknown/cancelled statuses and empty result sets no longer exit 0 in CI); retry on empty results; stop spinner on fatal polling errors; follow retry_of chains when grouping retries; fix off-by-one in failure limit - cloud: defer process.exit until after finally so --json output prints on failed runs and temp files are cleaned; ship failure telemetry on RUN_FAILED; re-emit non-punycode warnings; guard failure-path artifact downloads from masking the run-failed exit code - index: replace citty runMain with a replica that records failure telemetry, honors CliError.exitCode, and prints clean errors for all commands instead of stack traces - gateways: stream downloads via pipeline() so mid-download network errors fail fast instead of hanging or truncating silently; typed ApiError with HTTP status; parse JSON responses inside error handling with operation context; auth-mode-neutral 401 message - auth: serialize session refresh behind a lockfile and re-read/merge config to survive concurrent invocations (CI matrices); sessionOnly option so switch-org works with DEVICE_CLOUD_API_KEY exported; switch-org defaults to the logged-in env's API URL instead of prod - paths: segment-aware common-root computation and anchored prefix stripping shared via src/utils/paths.ts (replaceAll corrupted paths when the root substring recurred or collapsed to '') - methods: abort before upload on 401/403 from SHA dedup check; accept zips without explicit directory entries (ignoring __MACOSX/.DS_Store) and close zip handles on all paths; remove dead code - execution-plan: apply --exclude-flows to workspace config globs; fix double directory join for string runFlow/runScript deps; parse YAML docs beyond the second separator; dedupe sequential flows - cloud/upload: positional flow file works with --app-file/--app-url; '.apk' extension check no longer matches 'myapk'; moropo temp dirs cleaned up after runs and on download errors - metadata-extractor: route sync throws in zip ready handler to reject instead of crashing; close handles on all paths; dedupe parseInfoPlist - misc: corrupt config warns instead of silently logging out; version compare handles prereleases; pagination hint uses actual page size; parseIntFlag rejects negatives/garbage; abort timer cleanup in connectivity checks; no retries on 4xx for Expo URL downloads; env-aware console URLs; correct --json/--json-file exit-code docs Co-Authored-By: Claude Fable 5 --- src/commands/cloud.ts | 145 +++++++++------ src/commands/list.ts | 2 +- src/commands/status.ts | 48 +++-- src/commands/switch-org.ts | 17 +- src/commands/upload.ts | 2 +- src/config/environments.ts | 9 + src/config/flags/output.flags.ts | 4 +- src/gateways/api-gateway.ts | 198 +++++++++++---------- src/gateways/supabase-gateway.ts | 2 +- src/index.ts | 87 +++++++-- src/methods.ts | 95 +++++----- src/services/device-validation.service.ts | 149 +++++++++------- src/services/execution-plan.service.ts | 29 ++- src/services/execution-plan.utils.ts | 59 ++---- src/services/metadata-extractor.service.ts | 119 ++++++------- src/services/moropo.service.ts | 43 ++--- src/services/report-download.service.ts | 60 +++++-- src/services/results-polling.service.ts | 63 +++++-- src/services/telemetry.service.ts | 64 ++++++- src/services/test-submission.service.ts | 3 +- src/services/version.service.ts | 11 +- src/utils/auth.ts | 134 +++++++++++--- src/utils/cli.ts | 8 +- src/utils/config-store.ts | 30 +++- src/utils/connectivity.ts | 10 +- src/utils/expo.ts | 17 +- src/utils/paths.ts | 25 +++ src/utils/styling.ts | 21 +-- 28 files changed, 902 insertions(+), 552 deletions(-) create mode 100644 src/utils/paths.ts diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index 9e0c72b..a15583c 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -13,6 +13,7 @@ import { ResultsPollingService, RunFailedError, } from '../services/results-polling.service'; +import { telemetry } from '../services/telemetry.service'; import { TestSubmissionService } from '../services/test-submission.service'; import { VersionService } from '../services/version.service'; import { @@ -36,6 +37,7 @@ import { fetchCompatibilityData, } from '../utils/compatibility'; import { downloadExpoUrl, extractTarGz, findAppBundle, isUrl } from '../utils/expo'; +import { toPortableRelativePath } from '../utils/paths'; import { box, colors, @@ -48,15 +50,19 @@ import { symbols, } from '../utils/styling'; -// Suppress punycode deprecation warning (caused by whatwg, supabase dependency) +// Suppress punycode deprecation warning (caused by whatwg, supabase dependency). +// Every other warning must still reach the user — removeAllListeners drops +// Node's default printer, so re-emit manually. process.removeAllListeners('warning'); process.on('warning', (warning) => { if ( warning.name === 'DeprecationWarning' && warning.message.includes('punycode') ) { - // Ignore punycode deprecation warnings + return; } + // eslint-disable-next-line no-console + console.warn(warning.stack ?? `${warning.name}: ${warning.message}`); }); const DOWNLOAD_OPTIONS = ['ALL', 'FAILED'] as const; @@ -123,6 +129,7 @@ export const cloudCommand = defineCommand({ let output: unknown = null; let debugFlag = false; let jsonFile = false; + let caughtError: unknown = null; const tempFiles: string[] = []; // json is captured early so the progress-suppressor works if we fail before destructuring. const jsonFlag = Boolean(args.json); @@ -308,6 +315,7 @@ export const cloudCommand = defineCommand({ logger: (m: string) => out(m), quiet, }); + tempFiles.push(flows); } const auth = await resolveAuth({ apiKeyFlag }); @@ -427,6 +435,10 @@ export const cloudCommand = defineCommand({ throw new CliError('You cannot provide both an appBinaryId and a binary file'); } flowFile = flows ?? firstFile; + } else if ((appFile || appUrl) && !flowFile) { + // The app came from a flag, so the first positional (if any) is the + // flow file — previously it was silently dropped. + flowFile = firstFile; } if (!flowFile) { @@ -525,15 +537,21 @@ export const cloudCommand = defineCommand({ ...referencedFiles, ].sort((a, b) => a.split(path.sep).length - b.split(path.sep).length); - let commonRoot = path.parse(process.cwd()).root; - const folders = pathsShortestToLongest[0].split(path.sep); - for (const [index] of folders.entries()) { - const folderPath = folders.slice(0, index).join(path.sep); - const isRoot = pathsShortestToLongest.every((file) => - file.startsWith(folderPath), - ); - if (isRoot) commonRoot = folderPath; + // Longest whole-segment directory prefix shared by every path. Segment + // comparison (not startsWith) so sibling dirs like `flows`/`flows-extra` + // can't merge, and the file segment itself is never consumed. '' when + // the paths share no root at all. + const splitPaths = pathsShortestToLongest.map((p) => p.split(path.sep)); + const shortestSegments = splitPaths[0]; + let matchedSegments = 0; + for (let i = 0; i < shortestSegments.length - 1; i++) { + if (splitPaths.every((segments) => segments[i] === shortestSegments[i])) { + matchedSegments = i + 1; + } else { + break; + } } + const commonRoot = shortestSegments.slice(0, matchedSegments).join(path.sep); if (debug) { out(`[DEBUG] Common root directory: ${commonRoot}`); @@ -541,10 +559,7 @@ export const cloudCommand = defineCommand({ const testMetadataMap: Record = {}; for (const [absolutePath, meta] of Object.entries(flowMetadata)) { - const normalizedPath = absolutePath - .replaceAll(commonRoot, '.') - .split(path.sep) - .join('/'); + const normalizedPath = toPortableRelativePath(absolutePath, commonRoot); const metadataRecord = meta as Record | null; const flowName = (metadataRecord?.name as string) || path.parse(absolutePath).name; @@ -576,7 +591,7 @@ export const cloudCommand = defineCommand({ } if ( - !['apk', '.app', '.zip', '.tar.gz'].some((ext) => + !['.apk', '.app', '.zip', '.tar.gz'].some((ext) => (finalAppFile as string).endsWith(ext), ) ) { @@ -594,7 +609,14 @@ export const cloudCommand = defineCommand({ } const flagLogs: string[] = []; - const sensitiveFlags = new Set(['api-key', 'apiKey', 'moropo-v1-api-key']); + // app-url carries a signed (bearer-style) download URL — treat as secret. + const sensitiveFlags = new Set([ + 'api-key', + 'apiKey', + 'moropo-v1-api-key', + 'app-url', + 'appUrl', + ]); // Only log canonical flag keys (skip citty-populated alias duplicates like apiURL/apiUrl). const canonicalFlagKeys = new Set(Object.keys(allFlags)); for (const [k, v] of Object.entries(args)) { @@ -830,7 +852,6 @@ export const cloudCommand = defineCommand({ const pollingResult = await resultsPollingService .pollUntilComplete( - results, { auth, apiUrl, @@ -859,32 +880,44 @@ export const cloudCommand = defineCommand({ output = jsonOutput; } - if (downloadArtifacts) { - await reportDownloadService.downloadArtifacts({ - auth, - apiUrl, - artifactsPath, - debug, - downloadType: downloadArtifacts, - logger: (m: string) => out(m), - uploadId: results[0].test_upload_id as string, - warnLogger: (m: string) => warnOut(m), - }); - } - - if (report) { - await reportDownloadService.downloadReports({ - allurePath, - auth, - apiUrl, - debug, - htmlPath, - junitPath, - logger: (m: string) => out(m), - reportType: report, - uploadId: results[0].test_upload_id as string, - warnLogger: (m: string) => warnOut(m), - }); + // A download failure must not mask the run-failed signal (it + // would flip exit code 2 → 1 and drop the JSON output). + try { + if (downloadArtifacts) { + await reportDownloadService.downloadArtifacts({ + auth, + apiUrl, + artifactsPath, + debug, + downloadType: downloadArtifacts, + logger: (m: string) => out(m), + uploadId: results[0].test_upload_id as string, + warnLogger: (m: string) => warnOut(m), + }); + } + + if (report) { + await reportDownloadService.downloadReports({ + allurePath, + auth, + apiUrl, + debug, + htmlPath, + junitPath, + logger: (m: string) => out(m), + reportType: report, + uploadId: results[0].test_upload_id as string, + warnLogger: (m: string) => warnOut(m), + }); + } + } catch (downloadError) { + warnOut( + `Failed to download artifacts/reports for the failed run: ${ + downloadError instanceof Error + ? downloadError.message + : String(downloadError) + }`, + ); } throw new Error('RUN_FAILED'); @@ -939,17 +972,12 @@ export const cloudCommand = defineCommand({ out(`[DEBUG] Error stack: ${error.stack}`); } - if (error instanceof Error && error.message === 'RUN_FAILED') { - if (jsonFile) { - process.exit(0); - } - process.exit(2); - } else { - logger.error(error as Error, { exit: 1, json: jsonFlag }); - } + // Defer exiting until after the finally block — process.exit here would + // skip it, dropping the --json output and leaking temp files. + caughtError = error; } finally { + const fsp = await import('node:fs/promises'); for (const p of tempFiles) { - const fsp = await import('node:fs/promises'); await fsp.rm(p, { recursive: true, force: true }).catch(() => {}); } @@ -958,6 +986,19 @@ export const cloudCommand = defineCommand({ console.log(JSON.stringify(output, null, 2)); } } + + if (caughtError) { + if (caughtError instanceof Error && caughtError.message === 'RUN_FAILED') { + // --json-file keeps exit 0 on a failed run (documented contract); + // otherwise 2 distinguishes test failure from infra errors (1). + const exitCode = jsonFile ? 0 : 2; + telemetry.recordCommandFailure({ error: 'RUN_FAILED', exitCode }); + telemetry.flushSync(); + process.exit(exitCode); + } + + logger.error(caughtError as Error, { exit: 1, json: jsonFlag }); + } }, }); diff --git a/src/commands/list.ts b/src/commands/list.ts index 4498401..8e6b9b8 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -78,7 +78,7 @@ function displayResults(response: ListResponse): void { if (total > offset + uploads.length) { const remaining = total - (offset + uploads.length); logger.log( - ` ${colors.dim('Use')} --offset ${offset + limit} ${colors.dim('to see the next')} ${Math.min(remaining, limit)} ${colors.dim('uploads')}\n`, + ` ${colors.dim('Use')} --offset ${offset + uploads.length} ${colors.dim('to see the next')} ${Math.min(remaining, limit)} ${colors.dim('uploads')}\n`, ); } diff --git a/src/commands/status.ts b/src/commands/status.ts index e33e5dd..f55f075 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -36,6 +36,19 @@ type StatusResponse = { uploadId?: string; }; +/** Errors the API gateway surfaces for 4xx-class failures — retrying is pointless. */ +function isClientApiError(error: Error | null): boolean { + if (!error) return false; + return ( + error.message.includes('Invalid request:') || + error.message.includes('Resource not found') || + error.message.includes('Authentication failed') || + error.message.includes('Access denied') || + error.message.includes('Invalid API key') || + error.message.includes('Rate limit exceeded') + ); +} + function formatDateTime(isoString: string): string { try { const date = new Date(isoString); @@ -125,25 +138,17 @@ async function statusMain({ const isNetworkError = lastError.name === 'NetworkError' || (error instanceof TypeError && lastError.message === 'fetch failed'); - const isClientError = - lastError.message.includes('Invalid request:') || - lastError.message.includes('Resource not found') || - lastError.message.includes('Authentication failed') || - lastError.message.includes('Access denied') || - lastError.message.includes('Invalid API key') || - lastError.message.includes('Rate limit exceeded'); - - if (isClientError) { + + if (isClientApiError(lastError)) { break; } - if (attempt < 5 && isNetworkError) { - logger.log(`Network error on attempt ${attempt}/5. Retrying...`); - await new Promise((resolve) => { - setTimeout(resolve, 1000 * attempt); - }); - } else if (attempt < 5) { - logger.log(`Request failed on attempt ${attempt}/5. Retrying...`); + if (attempt < 5) { + logger.log( + isNetworkError + ? `Network error on attempt ${attempt}/5. Retrying...` + : `Request failed on attempt ${attempt}/5. Retrying...`, + ); await new Promise((resolve) => { setTimeout(resolve, 1000 * attempt); }); @@ -152,16 +157,7 @@ async function statusMain({ } if (!status) { - const isClientError = - lastError && - (lastError.message.includes('Invalid request:') || - lastError.message.includes('Resource not found') || - lastError.message.includes('Authentication failed') || - lastError.message.includes('Access denied') || - lastError.message.includes('Invalid API key') || - lastError.message.includes('Rate limit exceeded')); - - if (isClientError) { + if (isClientApiError(lastError)) { const errorMessage = lastError?.message || 'Unknown error'; if (json) { // eslint-disable-next-line no-console diff --git a/src/commands/switch-org.ts b/src/commands/switch-org.ts index f7cf4e6..064b3a4 100644 --- a/src/commands/switch-org.ts +++ b/src/commands/switch-org.ts @@ -21,8 +21,7 @@ export const switchOrgCommand = defineCommand({ args: { 'api-url': { type: 'string', - default: 'https://api.devicecloud.dev', - description: 'API base URL', + description: 'API base URL (defaults to the URL stored by `dcd login`)', }, org: { type: 'positional', @@ -36,13 +35,17 @@ export const switchOrgCommand = defineCommand({ throw new CliError('Not logged in. Run `dcd login` first.'); } - const apiUrl = args['api-url'] as string; + // 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 target = args.org as string | undefined; - const auth = await resolveAuth({ apiKeyFlag: undefined }); - if (auth.mode !== 'bearer') { - throw new CliError('`dcd switch-org` requires a browser login, not an API key.'); - } + // sessionOnly: an exported DEVICE_CLOUD_API_KEY must not shadow the + // browser session this command requires. + const auth = await resolveAuth({ apiKeyFlag: undefined, sessionOnly: true }); const orgs = await fetchOrgs(apiUrl, auth.headers); diff --git a/src/commands/upload.ts b/src/commands/upload.ts index 9b646bc..bc5ac69 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -69,7 +69,7 @@ export const uploadCommand = defineCommand({ } } - if (!['apk', '.app', '.zip', '.tar.gz'].some((ext) => resolvedFile!.endsWith(ext))) { + if (!['.apk', '.app', '.zip', '.tar.gz'].some((ext) => resolvedFile!.endsWith(ext))) { throw new CliError( 'App file must be a .apk for Android, .app/.zip for iOS, or .tar.gz (Expo iOS build)', ); diff --git a/src/config/environments.ts b/src/config/environments.ts index bcebce3..9dd7011 100644 --- a/src/config/environments.ts +++ b/src/config/environments.ts @@ -44,6 +44,15 @@ export const ENVIRONMENTS: Record = { }, }; +/** Exact-match lookup of a known environment by API URL (trailing slashes ignored). */ +export function findEnvByApiUrl(apiUrl: string): DcdEnvironment | undefined { + const normalize = (u: string) => u.replace(/\/+$/, ''); + const needle = normalize(apiUrl); + return Object.values(ENVIRONMENTS).find( + (env) => normalize(env.apiUrl) === needle, + ); +} + /** Map a caller-supplied API URL to one of the known environments. */ export function inferEnvFromApiUrl(apiUrl: string): DcdEnvName { if (apiUrl.includes('api.dev.') || apiUrl.includes('localhost')) return 'dev'; diff --git a/src/config/flags/output.flags.ts b/src/config/flags/output.flags.ts index 85301ff..a5fcffc 100644 --- a/src/config/flags/output.flags.ts +++ b/src/config/flags/output.flags.ts @@ -48,12 +48,12 @@ export const outputFlags = { json: { type: 'boolean', description: - 'Output results in JSON format - note: will always provide exit code 0', + 'Output results in JSON format. Exit codes: 0 on success, 2 if the test run fails, 1 on CLI/infrastructure errors', }, 'json-file': { type: 'boolean', description: - 'Write JSON output to a file. File will be called _dcd.json unless you supply the --json-file-name flag - note: will always exit with code 0', + 'Write JSON output to a file. File will be called _dcd.json unless you supply the --json-file-name flag - note: exits with code 0 even if the test run fails (CLI/infrastructure errors still exit 1)', }, 'json-file-name': { type: 'string', diff --git a/src/gateways/api-gateway.ts b/src/gateways/api-gateway.ts index a3802ce..022d9bc 100644 --- a/src/gateways/api-gateway.ts +++ b/src/gateways/api-gateway.ts @@ -1,9 +1,42 @@ +import { createWriteStream, mkdirSync } from 'node:fs'; +import { homedir } from 'node:os'; import * as path from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; import { TAppMetadata } from '../types'; import type { AuthContext } from '../types/domain/auth.types'; import { paths } from '../types/generated/schema.types'; +/** + * Error thrown for non-OK API responses, carrying the HTTP status so callers + * can branch on auth failures etc. without string matching. + */ +export class ApiError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'ApiError'; + this.status = status; + } +} + +/** + * Parses a successful response body as JSON, wrapping parse failures in a + * descriptive error (a 200 with an HTML body otherwise surfaces as a bare + * SyntaxError with no context about which call failed). + */ +async function parseJsonResponse(res: Response, operation: string): Promise { + try { + return (await res.json()) as T; + } catch (error) { + throw new Error( + `${operation}: API returned an invalid JSON response (${error instanceof Error ? error.message : String(error)})`, + ); + } +} + export const ApiGateway = { /** * Enhances generic "fetch failed" errors with more specific diagnostic information @@ -57,46 +90,86 @@ export const ApiGateway = { // Add context and improve readability switch (res.status) { case 400: { - throw new Error(`Invalid request: ${userMessage}`); + throw new ApiError(`Invalid request: ${userMessage}`, 400); } case 401: { - throw new Error(`Authentication failed. Please check your API key.`); + // Auth-mode-neutral: the CLI accepts both API keys and Bearer sessions. + // status.ts matches on the "Authentication failed" / "API key" substrings. + let message = + `Authentication failed — your credentials are invalid or expired. ` + + `Re-run \`dcd login\` or check your API key. (${operation})`; + if (userMessage) { + message += `\nServer response: ${userMessage}`; + } + + throw new ApiError(message, 401); } case 403: { // For 403, use the server's error message directly as it's now detailed // If the message suggests an API key issue, provide additional guidance if (userMessage.toLowerCase().includes('api key')) { - throw new Error( + throw new ApiError( `${userMessage}\n\nTroubleshooting steps:\n` + ` 1. Verify DEVICE_CLOUD_API_KEY environment variable is set\n` + ` 2. Check you're using the correct API key for this environment\n` + ` 3. Ensure the API key hasn't been deleted or revoked\n` + ` 4. Confirm you're connecting to the correct API URL`, + 403, ); } - throw new Error(`Access denied. ${userMessage}`); + throw new ApiError(`Access denied. ${userMessage}`, 403); } case 404: { - throw new Error(`Resource not found. ${userMessage}`); + throw new ApiError(`Resource not found. ${userMessage}`, 404); } case 429: { - throw new Error(`Rate limit exceeded. Please try again later.`); + throw new ApiError(`Rate limit exceeded. Please try again later. (${operation})`, 429); } case 500: { - throw new Error(`Server error occurred. Please try again or contact support.`); + throw new ApiError(`Server error occurred. Please try again or contact support. (${operation})`, 500); } default: { - throw new Error(`${operation} failed: ${userMessage} (HTTP ${res.status})`); + throw new ApiError(`${operation} failed: ${userMessage} (HTTP ${res.status})`, res.status); } } }, + + /** + * Streams a fetch response body to disk, expanding a leading tilde and + * creating the destination directory if needed. Internal helper shared by + * the download methods. + */ + async streamResponseToFile(res: Response, destinationPath: string, operation: string) { + if (res.body === null) { + throw new Error(`${operation}: server response contained no body to download`); + } + + // Handle tilde expansion for home directory + const expandedPath = destinationPath.replace(/^~(?=$|\/|\\)/, homedir()); + + // Create directory structure if it doesn't exist + const directory = path.dirname(expandedPath); + if (directory !== '.') { + mkdirSync(directory, { recursive: true }); + } + + // Use 'w' flag to overwrite existing files instead of failing. + // pipeline (unlike .pipe) propagates source-stream errors, so a + // mid-download network failure rejects instead of hanging forever. + const fileStream = createWriteStream(expandedPath, { flags: 'w' }); + await pipeline( + Readable.fromWeb(res.body as Parameters[0]), + fileStream, + ); + }, + async checkForExistingUpload( baseUrl: string, auth: AuthContext, @@ -116,9 +189,9 @@ export const ApiGateway = { await this.handleApiError(res, 'Failed to check for existing upload'); } - return res.json() as Promise< + return await parseJsonResponse< paths['/uploads/checkForExistingUpload']['post']['responses']['201']['content']['application/json'] - >; + >(res, 'Failed to check for existing upload'); } catch (error) { // Handle network-level errors (DNS, connection refused, timeout, etc.) if (error instanceof TypeError && error.message === 'fetch failed') { @@ -129,7 +202,7 @@ export const ApiGateway = { } }, - + async downloadArtifactsZip( baseUrl: string, auth: AuthContext, @@ -150,40 +223,7 @@ export const ApiGateway = { await this.handleApiError(res, 'Failed to download artifacts'); } - // Handle tilde expansion for home directory - if (artifactsPath.startsWith('~/') || artifactsPath === '~') { - artifactsPath = artifactsPath.replace( - /^~(?=$|\/|\\)/, - // eslint-disable-next-line unicorn/prefer-module - require('node:os').homedir(), - ); - } - - // Create directory structure if it doesn't exist - // eslint-disable-next-line unicorn/prefer-module - const { dirname } = require('node:path'); - // eslint-disable-next-line unicorn/prefer-module - const { createWriteStream, mkdirSync } = require('node:fs'); - // eslint-disable-next-line unicorn/prefer-module - const { finished } = require('node:stream/promises'); - // eslint-disable-next-line unicorn/prefer-module - const { Readable } = require('node:stream'); - - const directory = dirname(artifactsPath); - if (directory !== '.') { - try { - mkdirSync(directory, { recursive: true }); - } catch (error) { - // Ignore if directory already exists - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { - throw error; - } - } - } - - const fileStream = createWriteStream(artifactsPath, { flags: 'w' }); - - await finished(Readable.fromWeb(res.body).pipe(fileStream)); + await this.streamResponseToFile(res, artifactsPath, 'Failed to download artifacts'); } catch (error) { if (error instanceof TypeError && error.message === 'fetch failed') { throw this.enhanceFetchError(error, `${baseUrl}/results/${uploadId}/download`); @@ -201,7 +241,7 @@ export const ApiGateway = { id: string; metadata: TAppMetadata; path: string; - sha: string; + sha?: string; supabaseSuccess: boolean; }) { const { baseUrl, auth, id, metadata, path, sha, supabaseSuccess, backblazeSuccess, bytes } = config; @@ -213,7 +253,7 @@ export const ApiGateway = { id, metadata, path, // This is tempPath for TUS uploads - sha, + ...(sha ? { sha } : {}), supabaseSuccess, }), headers: { @@ -226,9 +266,9 @@ export const ApiGateway = { await this.handleApiError(res, 'Failed to finalize upload'); } - return res.json() as Promise< + return await parseJsonResponse< paths['/uploads/finaliseUpload']['post']['responses']['201']['content']['application/json'] - >; + >(res, 'Failed to finalize upload'); } catch (error) { if (error instanceof TypeError && error.message === 'fetch failed') { throw this.enhanceFetchError(error, `${baseUrl}/uploads/finaliseUpload`); @@ -257,9 +297,9 @@ export const ApiGateway = { await this.handleApiError(res, 'Failed to get upload URL'); } - return res.json() as Promise< + return await parseJsonResponse< paths['/uploads/getBinaryUploadUrl']['post']['responses']['201']['content']['application/json'] - >; + >(res, 'Failed to get upload URL'); } catch (error) { if (error instanceof TypeError && error.message === 'fetch failed') { throw this.enhanceFetchError(error, `${baseUrl}/uploads/getBinaryUploadUrl`); @@ -288,7 +328,7 @@ export const ApiGateway = { await this.handleApiError(res, 'Failed to finish large file'); } - return res.json(); + return await parseJsonResponse(res, 'Failed to finish large file'); } catch (error) { if (error instanceof TypeError && error.message === 'fetch failed') { throw this.enhanceFetchError(error, `${baseUrl}/uploads/finishLargeFile`); @@ -312,9 +352,9 @@ export const ApiGateway = { await this.handleApiError(res, 'Failed to get results'); } - return res.json() as Promise< + return await parseJsonResponse< paths['/results/{uploadId}']['get']['responses']['200']['content']['application/json'] - >; + >(res, 'Failed to get results'); } catch (error) { if (error instanceof TypeError && error.message === 'fetch failed') { throw this.enhanceFetchError(error, `${baseUrl}/results/${uploadId}`); @@ -349,7 +389,7 @@ export const ApiGateway = { await this.handleApiError(response, 'Failed to get upload status'); } - return response.json() as Promise<{ + return await parseJsonResponse<{ createdAt?: string; name?: string; status: 'CANCELLED' | 'FAILED' | 'PASSED' | 'PENDING'; @@ -360,7 +400,7 @@ export const ApiGateway = { name: string; status: 'CANCELLED' | 'FAILED' | 'PASSED' | 'PENDING'; }>; - }>; + }>(response, 'Failed to get upload status'); } catch (error) { if (error instanceof TypeError && error.message === 'fetch failed') { throw this.enhanceFetchError(error, `${baseUrl}/uploads/status?${queryParams}`); @@ -415,7 +455,7 @@ export const ApiGateway = { await this.handleApiError(response, 'Failed to list uploads'); } - return response.json() as Promise<{ + return await parseJsonResponse<{ limit: number; offset: number; total: number; @@ -425,7 +465,7 @@ export const ApiGateway = { id: string; name: null | string; }>; - }>; + }>(response, 'Failed to list uploads'); } catch (error) { if (error instanceof TypeError && error.message === 'fetch failed') { throw this.enhanceFetchError(error, url); @@ -452,9 +492,9 @@ export const ApiGateway = { await this.handleApiError(res, 'Failed to upload test flows'); } - return res.json() as Promise< + return await parseJsonResponse< paths['/uploads/flow']['post']['responses']['201']['content']['application/json'] - >; + >(res, 'Failed to upload test flows'); } catch (error) { if (error instanceof TypeError && error.message === 'fetch failed') { throw this.enhanceFetchError(error, `${baseUrl}/uploads/flow`); @@ -525,43 +565,7 @@ export const ApiGateway = { throw new Error(`${errorPrefix}: ${res.status} ${errorText}`); } - // Handle tilde expansion for home directory (applies to all report types) - let expandedPath = finalReportPath; - if (finalReportPath.startsWith('~/') || finalReportPath === '~') { - expandedPath = finalReportPath.replace( - /^~/, - // eslint-disable-next-line unicorn/prefer-module - require('node:os').homedir(), - ); - } - - // Create directory structure if it doesn't exist - // eslint-disable-next-line unicorn/prefer-module - const { dirname } = require('node:path'); - // eslint-disable-next-line unicorn/prefer-module - const { createWriteStream, mkdirSync } = require('node:fs'); - // eslint-disable-next-line unicorn/prefer-module - const { finished } = require('node:stream/promises'); - // eslint-disable-next-line unicorn/prefer-module - const { Readable } = require('node:stream'); - - const directory = dirname(expandedPath); - - if (directory !== '.') { - try { - mkdirSync(directory, { recursive: true }); - } catch (error) { - // Ignore EEXIST errors (directory already exists) - if (error.code !== 'EEXIST') { - throw error; - } - } - } - - // Write the file using streaming for better memory efficiency - // Use 'w' flag to overwrite existing files instead of failing - const fileStream = createWriteStream(expandedPath, { flags: 'w' }); - await finished(Readable.fromWeb(res.body).pipe(fileStream)); + await this.streamResponseToFile(res, finalReportPath, errorPrefix); } catch (error) { if (error instanceof TypeError && error.message === 'fetch failed') { throw this.enhanceFetchError(error, url); diff --git a/src/gateways/supabase-gateway.ts b/src/gateways/supabase-gateway.ts index 3b14847..9fb4b07 100644 --- a/src/gateways/supabase-gateway.ts +++ b/src/gateways/supabase-gateway.ts @@ -22,7 +22,7 @@ export class SupabaseGateway { debug = false, onProgress?: (bytesUploaded: number, bytesTotal: number) => void, ): Promise { - const { url: SUPABASE_URL, anonKey: SUPABASE_PUBLIC_KEY, projectRef } = + const { anonKey: SUPABASE_PUBLIC_KEY, projectRef } = ENVIRONMENTS[env].supabase; const storageUrl = `https://${projectRef}.storage.supabase.co`; diff --git a/src/index.ts b/src/index.ts index bdc8c5f..4f05d42 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node -import { defineCommand, runMain } from 'citty'; +import type { CommandDef, SubCommandsDef } from 'citty'; +import { defineCommand, runCommand, showUsage } from 'citty'; import { artifactsCommand } from './commands/artifacts'; import { cloudCommand } from './commands/cloud'; @@ -13,7 +14,7 @@ import { upgradeCommand } from './commands/upgrade'; import { uploadCommand } from './commands/upload'; import { whoamiCommand } from './commands/whoami'; import { telemetry } from './services/telemetry.service'; -import { getCliVersion } from './utils/cli'; +import { CliError, getCliVersion, logger } from './utils/cli'; const main = defineCommand({ meta: { @@ -37,23 +38,71 @@ const main = defineCommand({ }, }); -// Wrap runMain so we can record start/success/failure around it. Configure is -// deferred to `resolveAuth` (only authenticated commands ship telemetry — see -// telemetry.service.ts for the rationale). If the user runs `--help` or -// errors out before reaching auth, telemetry buffers in memory and is dropped -// on exit, which is the desired behaviour. -telemetry.recordCommandStart(); -runMain(main).then( - async () => { +// citty's runMain catches every error internally and calls process.exit(1), +// so its returned promise never rejects — failure telemetry hooked onto it +// would be dead code, CliError.exitCode would be ignored, and raw stack +// traces would be printed for usage errors. This replicates runMain's +// help/version/usage behaviour with those three problems fixed. Telemetry +// configure is deferred to `resolveAuth` (only authenticated commands ship +// telemetry — see telemetry.service.ts); unauthenticated invocations buffer +// in memory and drop on exit, which is the desired behaviour. + +async function resolveValue(input: T | (() => T | Promise)): Promise { + return typeof input === 'function' + ? (input as () => T | Promise)() + : input; +} + +// Mirrors citty's internal (unexported) resolveSubCommand so usage errors can +// show the help of the subcommand that failed rather than the root command. +async function resolveSubCommand( + cmd: CommandDef, + rawArgs: string[], + parent?: CommandDef, +): Promise<[CommandDef, CommandDef?]> { + const subCommands = await resolveValue(cmd.subCommands as SubCommandsDef | undefined); + if (subCommands && Object.keys(subCommands).length > 0) { + const subCommandArgIndex = rawArgs.findIndex((arg) => !arg.startsWith('-')); + const subCommandName = rawArgs[subCommandArgIndex]; + const subCommand = await resolveValue(subCommands[subCommandName]); + if (subCommand) { + return resolveSubCommand( + subCommand, + rawArgs.slice(subCommandArgIndex + 1), + cmd, + ); + } + } + return [cmd, parent]; +} + +async function run(): Promise { + const rawArgs = process.argv.slice(2); + telemetry.recordCommandStart(); + try { + if (rawArgs.includes('--help') || rawArgs.includes('-h')) { + await showUsage(...(await resolveSubCommand(main, rawArgs))); + } else if (rawArgs.length === 1 && rawArgs[0] === '--version') { + logger.log(getCliVersion()); + } else { + await runCommand(main, { rawArgs }); + } telemetry.recordCommandSuccess(); await telemetry.flush(); - }, - (error: unknown) => { - telemetry.recordCommandFailure({ - error: error instanceof Error ? error : String(error), - exitCode: 1, + } catch (error) { + // 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') { + await showUsage(...(await resolveSubCommand(main, rawArgs))); + } + + const exitCode = error instanceof CliError ? error.exitCode : 1; + // logger.error prints the message, records failure telemetry, flushes + // synchronously, and exits with the given code. + logger.error(error instanceof Error ? error : String(error), { + exit: exitCode, }); - telemetry.flushSync(); - throw error; - }, -); + } +} + +void run(); diff --git a/src/methods.ts b/src/methods.ts index 0c1a086..363f60d 100644 --- a/src/methods.ts +++ b/src/methods.ts @@ -7,7 +7,7 @@ import * as StreamZip from 'node-stream-zip'; import * as yazl from 'yazl'; import { inferEnvFromApiUrl } from './config/environments'; -import { ApiGateway } from './gateways/api-gateway'; +import { ApiError, ApiGateway } from './gateways/api-gateway'; import { SupabaseGateway } from './gateways/supabase-gateway'; import { MetadataExtractorService } from './services/metadata-extractor.service'; import { TAppMetadata } from './types'; @@ -67,9 +67,11 @@ export const compressFilesFromRelativePath = async ( ): Promise => { const zipfile = new yazl.ZipFile(); for (const file of files) { + // Anchored prefix strip — replace() would remove the first occurrence + // of commonRoot anywhere in the path, not just at the start. zipfile.addFile( path.resolve(basePath, file), - toZipEntryName(file.replace(commonRoot, '')), + toZipEntryName(file.startsWith(commonRoot) ? file.slice(commonRoot.length) : file), ); } return zipToBuffer(zipfile); @@ -81,20 +83,30 @@ export const verifyAppZip = async (zipPath: string) => { file: zipPath, storeEntries: true, }); - const entries = await zip.entries(); - const topLevelEntries = Object.values(entries).filter( - (entry) => !entry.name.split('/')[1], - ); - if ( - topLevelEntries.length !== 1 || - !topLevelEntries[0].name.endsWith('.app/') - ) { - throw new Error( - 'Zip file must contain exactly one entry which is a .app, check the contents of the zip file', + try { + const entries = await zip.entries(); + // Derive top-level names from all entries rather than requiring an + // explicit directory entry — zips created without directory entries + // (e.g. Python's zipfile) are valid but have no ".app/" entry itself. + // macOS metadata (__MACOSX resource forks, .DS_Store) doesn't count + // toward the "exactly one .app" rule. + const topLevelNames = new Set( + Object.values(entries) + .filter( + (entry) => + !entry.name.startsWith('__MACOSX/') && + entry.name.split('/').pop() !== '.DS_Store', + ) + .map((entry) => entry.name.split('/')[0]), ); + if (topLevelNames.size !== 1 || ![...topLevelNames][0].endsWith('.app')) { + throw new Error( + 'Zip file must contain exactly one entry which is a .app, check the contents of the zip file', + ); + } + } finally { + zip.close(); } - - zip.close(); }; interface UploadBinaryConfig { @@ -171,16 +183,6 @@ export const uploadBinary = async (config: UploadBinaryConfig) => { console.error(`[DEBUG] Failed after ${Date.now() - startTime}ms`); } - // Add helpful context for common errors - if (error instanceof Error) { - if (error.name === 'NetworkError') { - throw error; // NetworkError already has detailed troubleshooting info - } - - // Re-throw with original message - throw error; - } - throw error; } }; @@ -340,6 +342,12 @@ async function checkExistingUpload( return { binaryId: appBinaryId, exists }; } catch (error) { + // Invalid credentials will fail every subsequent request — surface now + // rather than after the user has waited through a potentially huge upload. + if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { + throw error; + } + if (debug) { console.error('[DEBUG] === SHA CHECK FAILED ==='); console.error('[DEBUG] Continuing with upload despite SHA check failure'); @@ -697,7 +705,8 @@ async function performUpload(config: PerformUploadConfig): Promise { id, metadata, path: tempPath, - sha: sha as string, + // sha is undefined when hash calculation failed — omit it explicitly + ...(sha ? { sha } : {}), supabaseSuccess: supabaseResult.success, }); @@ -1015,13 +1024,6 @@ async function uploadLargeFileToBackblaze(config: LargeFileUploadConfig): Promis }); } - // Validate all parts were uploaded - if (partSha1Array.length !== uploadPartUrls.length) { - const errorMsg = `Part count mismatch: uploaded ${partSha1Array.length} parts but expected ${uploadPartUrls.length}`; - if (debug) console.error(`[DEBUG] ${errorMsg}`); - throw new Error(errorMsg); - } - if (debug) { console.log('[DEBUG] Finishing large file upload...'); console.log(`[DEBUG] Finalizing ${partSha1Array.length} parts with fileId: ${fileId}`); @@ -1039,29 +1041,14 @@ async function uploadLargeFileToBackblaze(config: LargeFileUploadConfig): Promis } async function getFileHashFromFile(file: File): Promise { - return new Promise((resolve, reject) => { - const hash = createHash('sha256'); - const stream = file.stream(); - const reader = stream.getReader(); - - const processChunks = async () => { - try { - let readerResult = await reader.read(); - while (!readerResult.done) { - const { value } = readerResult; - hash.update(value); - - readerResult = await reader.read(); - } - - resolve(hash.digest('hex')); - } catch (error) { - reject(error); - } - }; + const hash = createHash('sha256'); + // Node's web ReadableStream is async-iterable at runtime; the cast covers + // TS lib variants whose ReadableStream type lacks Symbol.asyncIterator. + for await (const chunk of file.stream() as unknown as AsyncIterable) { + hash.update(chunk); + } - processChunks(); - }); + return hash.digest('hex'); } /** diff --git a/src/services/device-validation.service.ts b/src/services/device-validation.service.ts index 92918ad..4613e5e 100644 --- a/src/services/device-validation.service.ts +++ b/src/services/device-validation.service.ts @@ -27,49 +27,28 @@ export class DeviceValidationService { compatibilityData: CompatibilityData, options: DeviceValidationOptions = {}, ): void { - const { debug = false, logger } = options; - - if (!androidApiLevel && !androidDevice) { - return; - } - - const androidDeviceID = androidDevice || 'pixel-7'; - const lookup = googlePlay - ? compatibilityData.androidPlay - : compatibilityData.android; - const supportedAndroidVersions: string[] = - lookup?.[androidDeviceID as EAndroidDevices] || []; - const version = androidApiLevel || '34'; - - if (supportedAndroidVersions.length === 0) { - throw new Error( + this.validateDevice({ + debugLines: (deviceID, version, supportedVersions) => [ + `[DEBUG] Android device: ${deviceID}`, + `[DEBUG] Android API level: ${version}`, + `[DEBUG] Google Play enabled: ${googlePlay}`, + `[DEBUG] Supported Android versions: ${supportedVersions.join(', ')}`, + ], + defaultDevice: 'pixel-7', + defaultVersion: '34', + device: androidDevice as EAndroidDevices | undefined, + lookup: googlePlay + ? compatibilityData.androidPlay + : compatibilityData.android, + noSupportMessage: () => `We don't support that device configuration - please check the docs for supported devices: https://docs.devicecloud.dev/getting-started/devices-configuration`, - ); - } - - if ( - Array.isArray(supportedAndroidVersions) && - !supportedAndroidVersions.includes(version) - ) { - throw new Error( - `${androidDeviceID} ${ + options, + unsupportedVersionMessage: (deviceID, supportedVersions) => + `${deviceID} ${ googlePlay ? '(Play Store) ' : '' - }only supports these Android API levels: ${supportedAndroidVersions.join( - ', ', - )}`, - ); - } - - if (debug && logger) { - logger(`[DEBUG] Android device: ${androidDeviceID}`); - logger(`[DEBUG] Android API level: ${version}`); - logger(`[DEBUG] Google Play enabled: ${googlePlay}`); - logger( - `[DEBUG] Supported Android versions: ${supportedAndroidVersions.join( - ', ', - )}`, - ); - } + }only supports these Android API levels: ${supportedVersions.join(', ')}`, + version: androidApiLevel, + }); } /** @@ -87,40 +66,86 @@ export class DeviceValidationService { compatibilityData: CompatibilityData, options: DeviceValidationOptions = {}, ): void { + this.validateDevice({ + debugLines: (deviceID, version, supportedVersions) => [ + `[DEBUG] iOS device: ${deviceID}`, + `[DEBUG] iOS version: ${version}`, + `[DEBUG] Supported iOS versions: ${supportedVersions.join(', ')}`, + ], + defaultDevice: 'iphone-14', + defaultVersion: '17', + device: iOSDevice as EiOSDevices | undefined, + lookup: compatibilityData?.ios, + noSupportMessage: (deviceID) => + `Device ${deviceID} is not supported. Please check the docs for supported devices: https://docs.devicecloud.dev/getting-started/devices-configuration`, + options, + unsupportedVersionMessage: (deviceID, supportedVersions) => + `${deviceID} only supports these iOS versions: ${supportedVersions.join(', ')}`, + version: iOSVersion, + }); + } + + /** + * Shared validation flow for both platforms: apply the default device, + * look up its supported versions, then check the requested version + * @param config Platform-specific lookup table, defaults, and messages + * @returns void + * @throws Error if device/version combination is not supported + */ + private validateDevice(config: { + debugLines: ( + deviceID: string, + version: string, + supportedVersions: string[], + ) => string[]; + defaultDevice: string; + defaultVersion: string; + device: string | undefined; + lookup: Record | undefined; + noSupportMessage: (deviceID: string) => string; + options: DeviceValidationOptions; + unsupportedVersionMessage: ( + deviceID: string, + supportedVersions: string[], + ) => string; + version: string | undefined; + }): void { + const { + debugLines, + defaultDevice, + defaultVersion, + device, + lookup, + noSupportMessage, + options, + unsupportedVersionMessage, + version, + } = config; const { debug = false, logger } = options; - if (!iOSVersion && !iOSDevice) { + if (!version && !device) { return; } - const iOSDeviceID = iOSDevice || 'iphone-14'; - const supportediOSVersions: string[] = - compatibilityData?.ios?.[iOSDeviceID as EiOSDevices] || []; - const version = iOSVersion || '17'; + const deviceID = device || defaultDevice; + const supportedVersions: string[] = lookup?.[deviceID] || []; + const requestedVersion = version || defaultVersion; - if (supportediOSVersions.length === 0) { - throw new Error( - `Device ${iOSDeviceID} is not supported. Please check the docs for supported devices: https://docs.devicecloud.dev/getting-started/devices-configuration`, - ); + if (supportedVersions.length === 0) { + throw new Error(noSupportMessage(deviceID)); } if ( - Array.isArray(supportediOSVersions) && - !supportediOSVersions.includes(version) + Array.isArray(supportedVersions) && + !supportedVersions.includes(requestedVersion) ) { - throw new Error( - `${iOSDeviceID} only supports these iOS versions: ${supportediOSVersions.join( - ', ', - )}`, - ); + throw new Error(unsupportedVersionMessage(deviceID, supportedVersions)); } if (debug && logger) { - logger(`[DEBUG] iOS device: ${iOSDeviceID}`); - logger(`[DEBUG] iOS version: ${version}`); - logger( - `[DEBUG] Supported iOS versions: ${supportediOSVersions.join(', ')}`, - ); + for (const line of debugLines(deviceID, requestedVersion, supportedVersions)) { + logger(line); + } } } } diff --git a/src/services/execution-plan.service.ts b/src/services/execution-plan.service.ts index 743124f..903a670 100644 --- a/src/services/execution-plan.service.ts +++ b/src/services/execution-plan.service.ts @@ -250,6 +250,7 @@ async function planSingleFile( * @param normalizedInput - Normalized path to the workspace directory * @param unfilteredFlowFiles - List of all discovered flow files * @param configFile - Optional custom config file path + * @param excludeFlows - --exclude-flows patterns to re-apply to glob matches * @returns Filtered list of flow file paths matching the globs */ async function applyFlowGlobs( @@ -257,6 +258,7 @@ async function applyFlowGlobs( normalizedInput: string, unfilteredFlowFiles: string[], configFile?: string, + excludeFlows?: string[], ): Promise { if (workspaceConfig.flows) { const globs = workspaceConfig.flows.map((g) => g); @@ -271,7 +273,7 @@ async function applyFlowGlobs( } }); - return matchedFiles + const globbedFlowFiles = matchedFiles .filter((file: string) => { if (file === 'config.yaml' || file === 'config.yml') return false; if (configFile && file === path.basename(configFile)) return false; @@ -284,6 +286,10 @@ async function applyFlowGlobs( return true; }) .map((file) => path.resolve(normalizedInput, file)); + + // Re-globbing from disk bypasses the earlier --exclude-flows filter, so + // re-apply it here or excluded flows sneak back in via `flows:` globs. + return filterFlowFiles(globbedFlowFiles, excludeFlows); } return unfilteredFlowFiles.filter( @@ -315,15 +321,19 @@ function resolveSequentialFlows( console.log('[DEBUG] Available flow names:', Object.keys(pathsByName)); } - const flowsToRunInSequence = workspaceConfig.executionOrder.flowsOrder - .flatMap((flowOrder) => { - const normalizedFlowOrder = flowOrder.replace(/\.ya?ml$/i, ''); - if (debug && flowOrder !== normalizedFlowOrder) { - console.log(`[DEBUG] Stripping trailing extension: "${flowOrder}" -> "${normalizedFlowOrder}"`); - } + // Dedupe so a flow listed twice in flowsOrder isn't run twice. + const flowsToRunInSequence = [ + ...new Set( + workspaceConfig.executionOrder.flowsOrder.flatMap((flowOrder) => { + const normalizedFlowOrder = flowOrder.replace(/\.ya?ml$/i, ''); + if (debug && flowOrder !== normalizedFlowOrder) { + console.log(`[DEBUG] Stripping trailing extension: "${flowOrder}" -> "${normalizedFlowOrder}"`); + } - return getFlowsToRunInSequence(pathsByName, [normalizedFlowOrder], debug); - }); + return getFlowsToRunInSequence(pathsByName, [normalizedFlowOrder], debug); + }), + ), + ]; if (debug) { console.log(`[DEBUG] Sequential flows resolved: ${flowsToRunInSequence.length} flow(s)`); @@ -411,6 +421,7 @@ export async function plan(options: PlanOptions): Promise { normalizedInput, unfilteredFlowFiles, configFile, + excludeFlows, ); if (unfilteredFlowFiles.length === 0) { diff --git a/src/services/execution-plan.utils.ts b/src/services/execution-plan.utils.ts index d086a76..9484dc5 100644 --- a/src/services/execution-plan.utils.ts +++ b/src/services/execution-plan.utils.ts @@ -18,22 +18,23 @@ export function getFlowsToRunInSequence( return []; } - const orderSet = new Set(flowOrder); const availableNames = Object.keys(paths); if (debug) { - console.log(`[DEBUG] getFlowsToRunInSequence: Looking for flows in order: [${[...orderSet].join(', ')}]`); + console.log(`[DEBUG] getFlowsToRunInSequence: Looking for flows in order: [${flowOrder.join(', ')}]`); console.log(`[DEBUG] getFlowsToRunInSequence: Available flow names: [${availableNames.join(', ')}]`); } - const namesInOrder = availableNames.filter((key) => orderSet.has(key)); + const namesInOrder = flowOrder.filter((name) => + Object.hasOwn(paths, name), + ); if (debug) { console.log(`[DEBUG] getFlowsToRunInSequence: Matched ${namesInOrder.length} flow(s): [${namesInOrder.join(', ')}]`); } if (namesInOrder.length === 0) { - const notFound = [...orderSet].filter((item) => !availableNames.includes(item)); + const notFound = flowOrder.filter((name) => !availableNames.includes(name)); console.warn( `Warning: Could not find flows specified in executionOrder.flowsOrder: ${notFound.join(', ')}\n` + @@ -43,38 +44,7 @@ export function getFlowsToRunInSequence( return []; } - const result = [...orderSet].filter((item) => namesInOrder.includes(item)); - - if (result.length === 0) { - const notFound = [...orderSet].filter((item) => !namesInOrder.includes(item)); - console.warn( - `Warning: Could not find flows needed for execution in order: ${notFound.join(', ')}\n` + - `This may be intentional if flows were excluded by tags.\n` + - `Available flow names:\n${availableNames.join('\n')}`, - ); - return []; - } - - if ( - flowOrder - .slice(0, result.length) - .every((value, index) => value === result[index]) - ) { - const resolvedPaths = result.map((item) => paths[item]); - - if (debug) { - console.log(`[DEBUG] getFlowsToRunInSequence: Order matches, returning ${resolvedPaths.length} path(s)`); - } - - return resolvedPaths; - } - - throw new Error( - `Flow order mismatch in executionOrder.flowsOrder.\n\n` + - `Expected order: [${flowOrder.slice(0, result.length).join(', ')}]\n` + - `Actual order: [${result.join(', ')}]\n\n` + - `Please ensure flows are specified in the correct order.`, - ); + return namesInOrder.map((name) => paths[name]); } export function isFlowFile(filePath: string): boolean { @@ -124,17 +94,18 @@ export const readTestYamlFileAsJson = (filePath: string) => { if (normalizedText.includes('\n---\n')) { const yamlTexts = normalizedText.split('\n---\n'); const config = yaml.load(yamlTexts[0]) as Record; - const testSteps = yaml.load(yamlTexts[1]) as Record[]; - if (Object.keys(config ?? {}).length > 0) { + // Rejoin everything after the first separator so step documents beyond + // a second `---` aren't silently dropped. + const testSteps = yaml.load(yamlTexts.slice(1).join('\n')) as Record< + string, + unknown + >[]; + if (config && Object.keys(config).length > 0) { return { config, testSteps }; } } const testSteps = yaml.load(yamlText) as Record[]; - if (Object.keys(testSteps).length > 0) { - return { config: null, testSteps }; - } - return { config: null, testSteps }; } catch (error) { const message = `Error parsing YAML file ${filePath}: ${error}`; @@ -187,9 +158,9 @@ export const checkIfFilesExistInWorkspace = ( files.push(absoluteFilePath); }; - // simple command + // simple command — processFilePath already resolves against `directory` if (typeof command === 'string') { - processFilePath(path.normalize(path.join(directory, command))); + processFilePath(command); } // array command diff --git a/src/services/metadata-extractor.service.ts b/src/services/metadata-extractor.service.ts index 649e6fd..ed579c3 100644 --- a/src/services/metadata-extractor.service.ts +++ b/src/services/metadata-extractor.service.ts @@ -18,6 +18,25 @@ export interface IMetadataExtractor { extract(filePath: string): Promise; } +/** + * Parses an Info.plist buffer (XML, UTF-8 BOM'd XML, or binary bplist). + * Shared by the .app and .zip extractors. + */ +function parseInfoPlist(buffer: Buffer): { CFBundleIdentifier: string } { + let data; + const bufferType = buffer[0]; + // 60 = '<' (XML plist), 239 = UTF-8 BOM, 98 = 'b' (binary "bplist") + if (bufferType === 60 || bufferType === 239) { + data = parse(buffer.toString()); + } else if (bufferType === 98) { + data = parseBuffer(buffer)[0]; + } else { + throw new Error('Unknown plist buffer type.'); + } + + return data; +} + /** * Extracts metadata from Android APK files */ @@ -48,30 +67,10 @@ export class IosAppMetadataExtractor implements IMetadataExtractor { async extract(filePath: string): Promise { const infoPlistPath = path.normalize(path.join(filePath, 'Info.plist')); const buffer = await readFile(infoPlistPath); - const data = await this.parseInfoPlist(buffer); + const data = parseInfoPlist(buffer); const appId = data.CFBundleIdentifier; return { appId, platform: 'ios' }; } - - private async parseInfoPlist( - buffer: Buffer, - ): Promise<{ CFBundleIdentifier: string }> { - let data; - const bufferType = buffer[0]; - if ( - bufferType === 60 || - (bufferType as unknown as string) === '<' || - bufferType === 239 - ) { - data = parse(buffer.toString()); - } else if (bufferType === 98) { - data = parseBuffer(buffer)[0]; - } else { - throw new Error('Unknown plist buffer type.'); - } - - return data; - } } /** @@ -86,58 +85,44 @@ export class IosZipMetadataExtractor implements IMetadataExtractor { return new Promise((resolve, reject) => { const zip = new StreamZip({ file: filePath }); + // A throw inside an emitter callback escapes the caller's try/catch and + // crashes the process, so route all failures through reject explicitly. zip.on('ready', () => { - // Get all entries and sort them by path depth - const entries = Object.values(zip.entries()); - const sortedEntries = entries.sort((a, b) => { - const aDepth = a.name.split('/').length; - const bDepth = b.name.split('/').length; - return aDepth - bDepth; - }); - - // Find the first Info.plist in the shallowest directory - const infoPlist = sortedEntries.find((e) => - e.name.endsWith('.app/Info.plist'), - ); - - if (!infoPlist) { - reject(new Error('Failed to find info plist')); - return; + try { + // Get all entries and sort them by path depth + const entries = Object.values(zip.entries()); + const sortedEntries = entries.sort((a, b) => { + const aDepth = a.name.split('/').length; + const bDepth = b.name.split('/').length; + return aDepth - bDepth; + }); + + // Find the first Info.plist in the shallowest directory + const infoPlist = sortedEntries.find((e) => + e.name.endsWith('.app/Info.plist'), + ); + + if (!infoPlist) { + reject(new Error('Failed to find info plist')); + return; + } + + const buffer = zip.entryDataSync(infoPlist.name); + const data = parseInfoPlist(buffer); + resolve({ appId: data.CFBundleIdentifier, platform: 'ios' }); + } catch (error) { + reject(error); + } finally { + zip.close(); } - - const buffer = zip.entryDataSync(infoPlist.name); - this.parseInfoPlist(buffer) - .then((data) => { - const appId = data.CFBundleIdentifier; - zip.close(); - resolve({ appId, platform: 'ios' }); - }) - .catch(reject); }); - zip.on('error', reject); + zip.on('error', (error) => { + zip.close(); + reject(error); + }); }); } - - private async parseInfoPlist( - buffer: Buffer, - ): Promise<{ CFBundleIdentifier: string }> { - let data; - const bufferType = buffer[0]; - if ( - bufferType === 60 || - (bufferType as unknown as string) === '<' || - bufferType === 239 - ) { - data = parse(buffer.toString()); - } else if (bufferType === 98) { - data = parseBuffer(buffer)[0]; - } else { - throw new Error('Unknown plist buffer type.'); - } - - return data; - } } /** diff --git a/src/services/moropo.service.ts b/src/services/moropo.service.ts index e91023a..1d86c10 100644 --- a/src/services/moropo.service.ts +++ b/src/services/moropo.service.ts @@ -2,6 +2,8 @@ import { ux } from '../utils/progress'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; import StreamZip = require('node-stream-zip'); @@ -33,6 +35,8 @@ export class MoropoService { this.logDebug(debug, logger, '[DEBUG] Moropo v1 API key detected, downloading tests from Moropo API'); this.logDebug(debug, logger, `[DEBUG] Using branch name: ${branchName}`); + let moropoDir: string | undefined; + try { if (!quiet && !json) { ux.action.start('Downloading Moropo tests', 'Initializing', { @@ -54,7 +58,7 @@ export class MoropoService { ); } - const moropoDir = path.join( + moropoDir = path.join( os.tmpdir(), `moropo-tests-${Date.now()}`, ); @@ -91,6 +95,11 @@ export class MoropoService { ux.action.stop('failed'); } + // Remove the temp directory (and any partially-written zip inside it) + if (moropoDir) { + fs.rmSync(moropoDir, { recursive: true, force: true }); + } + this.logDebug(debug, logger, `[DEBUG] Error downloading/extracting Moropo tests: ${error}`); throw new Error(`Failed to download/extract Moropo tests: ${error}`); } @@ -111,33 +120,27 @@ export class MoropoService { const totalSize = contentLength ? Number.parseInt(contentLength, 10) : 0; let downloadedSize = 0; - const fileStream = fs.createWriteStream(zipPath); - const reader = response.body?.getReader(); - - if (!reader) { + if (!response.body) { throw new Error('Failed to get response reader'); } - let readerResult = await reader.read(); - while (!readerResult.done) { - const { value } = readerResult; - downloadedSize += value.length; + const source = Readable.fromWeb( + response.body as Parameters[0], + ); - if (!quiet && !json && totalSize) { + if (!quiet && !json && totalSize) { + // Progress tap — pipeline below still owns the flow/backpressure + source.on('data', (chunk: Buffer) => { + downloadedSize += chunk.length; const progress = Math.round((downloadedSize / totalSize) * 100); ux.action.status = `Downloading: ${progress}%`; - } - - fileStream.write(value); - readerResult = await reader.read(); + }); } - fileStream.end(); - await new Promise((resolve) => { - fileStream.on('finish', () => { - resolve(); - }); - }); + // pipeline (unlike a bare 'finish' wait) propagates errors from both + // streams, so disk-full or a stalled download rejects instead of + // crashing or hanging. + await pipeline(source, fs.createWriteStream(zipPath)); } private async extractZipFile(zipPath: string, extractPath: string): Promise { diff --git a/src/services/report-download.service.ts b/src/services/report-download.service.ts index f023a5f..198722f 100644 --- a/src/services/report-download.service.ts +++ b/src/services/report-download.service.ts @@ -71,9 +71,12 @@ export class ReportDownloadService { logger(`[DEBUG] Error downloading artifacts: ${error}`); } - if (warnLogger) { - warnLogger('Failed to download artifacts'); - } + this.warnDownloadFailure( + warnLogger, + 'artifacts', + 'No artifacts found for this upload. Make sure your tests generated results.', + error, + ); } } @@ -168,20 +171,43 @@ export class ReportDownloadService { logger(`[DEBUG] Error downloading ${type.toUpperCase()} report: ${error}`); } - const errorMessage = error instanceof Error ? error.message : String(error); - if (warnLogger) { - warnLogger(`Failed to download ${type.toUpperCase()} report: ${errorMessage}`); - - if (errorMessage.includes('404')) { - warnLogger( - `No ${type.toUpperCase()} reports found for this upload. Make sure your tests generated results.`, - ); - } else if (errorMessage.includes('EACCES') || errorMessage.includes('EPERM')) { - warnLogger('Permission denied. Check write permissions for the current directory.'); - } else if (errorMessage.includes('ENOENT')) { - warnLogger('Directory does not exist. Make sure you have write access to the current directory.'); - } - } + this.warnDownloadFailure( + warnLogger, + `${type.toUpperCase()} report`, + `No ${type.toUpperCase()} reports found for this upload. Make sure your tests generated results.`, + error, + ); + } + } + + /** + * Warn about a failed download with the underlying cause plus hints for + * common error classes (missing results, permissions, bad paths) + * @param warnLogger Warning logger, if configured + * @param subject What was being downloaded, e.g. 'artifacts' or 'JUNIT report' + * @param notFoundHint Message to show when the error looks like a 404 + * @param error The error that occurred + * @returns void + */ + private warnDownloadFailure( + warnLogger: ((message: string) => void) | undefined, + subject: string, + notFoundHint: string, + error: unknown, + ): void { + if (!warnLogger) { + return; + } + + const errorMessage = error instanceof Error ? error.message : String(error); + warnLogger(`Failed to download ${subject}: ${errorMessage}`); + + if (errorMessage.includes('404')) { + warnLogger(notFoundHint); + } else if (errorMessage.includes('EACCES') || errorMessage.includes('EPERM')) { + warnLogger('Permission denied. Check write permissions for the current directory.'); + } else if (errorMessage.includes('ENOENT')) { + warnLogger('Directory does not exist. Make sure you have write access to the current directory.'); } } } diff --git a/src/services/results-polling.service.ts b/src/services/results-polling.service.ts index 3da9631..a6f83e6 100644 --- a/src/services/results-polling.service.ts +++ b/src/services/results-polling.service.ts @@ -68,13 +68,28 @@ export class ResultsPollingService { /** * Poll for test results until all tests complete - * @param results Initial test results from submission * @param options Polling configuration * @param testMetadata Optional metadata map for each test (flowName, tags) * @returns Promise that resolves with final test results or rejects if tests fail */ public async pollUntilComplete( - results: TestResult[], + options: PollingOptions, + testMetadata?: Record, + ): Promise { + try { + return await this.pollLoop(options, testMetadata); + } catch (error) { + // RunFailedError is a completed run — the spinner was already stopped by + // displayFinalResults. Anything else aborts mid-poll with the spinner + // still live, which would corrupt the terminal under the error output. + if (!options.json && !(error instanceof RunFailedError)) { + ux.action.stop(colors.error('failed')); + } + throw error; + } + } + + private async pollLoop( options: PollingOptions, testMetadata?: Record, ): Promise { @@ -156,9 +171,11 @@ export class ResultsPollingService { return { consoleUrl, - status: resultsWithoutEarlierTries.some((result) => result.status === 'FAILED') - ? 'FAILED' - : 'PASSED', + // Anything other than an explicit pass (CANCELLED, ERROR, a status we + // don't know about yet) must fail the run — this gates CI exit codes. + status: resultsWithoutEarlierTries.every((result) => result.status === 'PASSED') + ? 'PASSED' + : 'FAILED', tests: resultsWithoutEarlierTries.map((r) => ({ durationSeconds: r.duration_seconds, failReason: @@ -317,7 +334,8 @@ export class ResultsPollingService { const { results: updatedResults } = await ApiGateway.getResultsForUpload(apiUrl, auth, uploadId); - if (!updatedResults) { + // An empty array would otherwise read as "all complete, all passed". + if (!updatedResults || updatedResults.length === 0) { throw new Error('no results'); } @@ -334,13 +352,30 @@ export class ResultsPollingService { } private filterLatestResults(results: TestResult[]): TestResult[] { - return results.filter((result) => { - const originalTryId = result.retry_of || result.id; - const tries = results.filter( - (r) => r.retry_of === originalTryId || r.id === originalTryId, - ); - return result.id === Math.max(...tries.map((t) => t.id)); - }); + // Resolve each row to the root of its retry chain (a retry's `retry_of` + // may point at the previous retry rather than the original attempt), then + // keep only the newest row per root. + const byId = new Map(results.map((result) => [result.id, result])); + const rootIdOf = (result: TestResult): TestResult['id'] => { + let current = result; + const seen = new Set([current.id]); + while (current.retry_of) { + const parent = byId.get(current.retry_of); + if (!parent || seen.has(parent.id)) return current.retry_of; + seen.add(parent.id); + current = parent; + } + return current.id; + }; + const latestByRoot = new Map(); + for (const result of results) { + const rootId = rootIdOf(result); + const existing = latestByRoot.get(rootId); + if (!existing || result.id > existing.id) { + latestByRoot.set(rootId, result); + } + } + return results.filter((result) => latestByRoot.get(rootIdOf(result)) === result); } /** @@ -401,7 +436,7 @@ export class ResultsPollingService { logger(`[DEBUG] Sequential poll failures: ${sequentialPollFailures}`); } - if (sequentialPollFailures > this.MAX_SEQUENTIAL_FAILURES) { + if (sequentialPollFailures >= this.MAX_SEQUENTIAL_FAILURES) { if (debug && logger) { logger('[DEBUG] Checking internet connectivity...'); } diff --git a/src/services/telemetry.service.ts b/src/services/telemetry.service.ts index 741f083..e3c098b 100644 --- a/src/services/telemetry.service.ts +++ b/src/services/telemetry.service.ts @@ -14,6 +14,9 @@ */ import { execFileSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import type { AuthContext } from '../types/domain/auth.types'; import { getCliVersion, getInstallMethod } from '../utils/cli'; @@ -64,7 +67,7 @@ class Telemetry { recordCommandStart() { this.startedAt = Date.now(); this.enqueue('info', 'cli.lifecycle', 'command started', { - argv: process.argv.slice(2), + argv: scrubArgv(process.argv.slice(2)), }); } @@ -136,22 +139,29 @@ class Telemetry { if (this.disabled || !this.config || this.buffer.length === 0) return; const events = this.buffer.splice(0, this.buffer.length); const body = JSON.stringify({ events, meta: this.buildMeta() }); - const headerArgs: string[] = ['-H', 'content-type: application/json']; - for (const [k, v] of Object.entries(this.config.auth.headers)) { - headerArgs.push('-H', `${k}: ${v}`); - } + // Headers carry the API key / Bearer token, so they must not appear in + // curl's argv (world-readable via ps//proc while curl runs). They go in a + // 0600 config file inside a fresh 0700 temp dir instead; stdin carries the + // body, so it can't double as the config channel. + let configDir: string | undefined; try { + configDir = mkdtempSync(join(tmpdir(), 'dcd-telemetry-')); + const configPath = join(configDir, 'curl.cfg'); + const headerLines = ['header = "content-type: application/json"']; + for (const [k, v] of Object.entries(this.config.auth.headers)) { + headerLines.push(`header = "${k}: ${v}"`); + } + writeFileSync(configPath, headerLines.join('\n'), { mode: 0o600 }); execFileSync( 'curl', [ '-sS', '-m', '3', - '-o', - '/dev/null', '-X', 'POST', - ...headerArgs, + '-K', + configPath, '--data-binary', '@-', `${this.config.apiUrl}/cli/logs`, @@ -160,6 +170,8 @@ class Telemetry { ); } catch { // Telemetry failures must never surface — silently drop. + } finally { + if (configDir) rmSync(configDir, { recursive: true, force: true }); } } @@ -182,6 +194,42 @@ class Telemetry { } } +// Flags whose values are credential material (API keys, signed URLs) or +// user-provided env pairs that routinely carry test-account secrets. Their +// values must never reach the telemetry backend. +const SENSITIVE_FLAG_NAMES = new Set([ + '--api-key', + '--apiKey', + '--moropo-v1-api-key', + '--app-url', + '--appUrl', + '-e', + '--env', +]); + +function scrubArgv(args: string[]): string[] { + const scrubbed: string[] = []; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const eqIndex = arg.indexOf('='); + const flagName = eqIndex === -1 ? arg : arg.slice(0, eqIndex); + if (arg.startsWith('-') && SENSITIVE_FLAG_NAMES.has(flagName)) { + if (eqIndex === -1) { + scrubbed.push(arg); + if (i + 1 < args.length) { + scrubbed.push(''); + i++; + } + } else { + scrubbed.push(`${flagName}=`); + } + } else { + scrubbed.push(arg); + } + } + return scrubbed; +} + // argv layout: node|tsx, script, command, ...flags. The first non-flag after // the script is the subcommand. Falls back to 'help' for `--help`/`--version` // invocations and to 'unknown' if we can't decide. diff --git a/src/services/test-submission.service.ts b/src/services/test-submission.service.ts index fb4a4da..da6c6ba 100644 --- a/src/services/test-submission.service.ts +++ b/src/services/test-submission.service.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import * as path from 'node:path'; import { compressFilesFromRelativePath } from '../methods'; +import { toPortableRelativePath } from '../utils/paths'; import { IExecutionPlan } from './execution-plan.service'; export interface TestSubmissionConfig { @@ -260,7 +261,7 @@ export class TestSubmissionService { } private normalizeFilePath(filePath: string, commonRoot: string): string { - return filePath.replaceAll(commonRoot, '.').split(path.sep).join('/'); + return toPortableRelativePath(filePath, commonRoot); } private normalizePathMap( diff --git a/src/services/version.service.ts b/src/services/version.service.ts index 4d8ff1c..bc0e1be 100644 --- a/src/services/version.service.ts +++ b/src/services/version.service.ts @@ -35,8 +35,15 @@ export class VersionService { * @returns true if current is older than latest */ isOutdated(current: string, latest: string): boolean { - const currentParts = current.split('.').map(Number); - const latestParts = latest.split('.').map(Number); + // Strip any prerelease suffix ("1.2.3-beta.1" -> "1.2.3") and default + // missing segments to 0 so short/prerelease versions still compare. + const parts = (version: string): number[] => { + const nums = version.split('-')[0].split('.').map(Number); + return [nums[0] || 0, nums[1] || 0, nums[2] || 0]; + }; + + const currentParts = parts(current); + const latestParts = parts(latest); for (let i = 0; i < 3; i++) { if (currentParts[i] < latestParts[i]) return true; diff --git a/src/utils/auth.ts b/src/utils/auth.ts index ac49c29..addbd67 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -7,50 +7,71 @@ * use the returned AuthContext for the duration of the command — one resolve * per invocation, not per request. */ +import { closeSync, openSync, rmSync, statSync } from 'node:fs'; + import { ENVIRONMENTS } from '../config/environments'; import { CliAuthGateway } from '../gateways/cli-auth-gateway'; import { telemetry } from '../services/telemetry.service'; import type { AuthContext } from '../types/domain/auth.types'; import { CliError } from './cli'; -import { readConfig, writeConfig } from './config-store'; +import { + StoredConfig, + StoredSession, + getConfigPath, + readConfig, + writeConfig, +} from './config-store'; const REFRESH_SKEW_SECONDS = 60; +// Refresh lock tuning: a refresh is a single HTTP round-trip, so anything +// holding the lock longer than this is presumed dead. +const LOCK_STALE_MS = 10_000; +const LOCK_WAIT_MS = 10_000; +const LOCK_POLL_MS = 250; export interface ResolveAuthOptions { apiKeyFlag: string | undefined; /** When true, bypass stored session entirely (for `dcd login` itself). */ skipSession?: boolean; + /** + * When true, ignore the --api-key flag / DEVICE_CLOUD_API_KEY env and + * resolve straight from the stored session (for `dcd switch-org`, which + * needs a browser login even when an API key is exported). + */ + sessionOnly?: boolean; } export async function resolveAuth( opts: ResolveAuthOptions, ): Promise { - const flag = opts.apiKeyFlag?.trim(); - if (flag) { - const auth: AuthContext = { - mode: 'apiKey', - headers: { 'x-app-api-key': flag }, - }; - telemetry.configure({ auth }); - return auth; - } + if (!opts.sessionOnly) { + const flag = opts.apiKeyFlag?.trim(); + if (flag) { + const auth: AuthContext = { + mode: 'apiKey', + headers: { 'x-app-api-key': flag }, + }; + telemetry.configure({ auth }); + return auth; + } - const env = process.env.DEVICE_CLOUD_API_KEY?.trim(); - if (env) { - const auth: AuthContext = { - mode: 'apiKey', - headers: { 'x-app-api-key': env }, - }; - telemetry.configure({ auth }); - return auth; + const env = process.env.DEVICE_CLOUD_API_KEY?.trim(); + if (env) { + const auth: AuthContext = { + mode: 'apiKey', + headers: { 'x-app-api-key': env }, + }; + telemetry.configure({ auth }); + return auth; + } } if (opts.skipSession) { throw missingCredentialsError(); } - const config = readConfig(); + let config = readConfig(); if (!config?.session) { throw missingCredentialsError(); } @@ -59,14 +80,7 @@ export async function resolveAuth( let session = config.session; if (session.expires_at <= now + REFRESH_SKEW_SECONDS) { - const { anonKey } = ENVIRONMENTS[config.env].supabase; - const refreshed = await CliAuthGateway.refresh( - config.supabase_url, - anonKey, - session, - ); - session = refreshed; - writeConfig({ ...config, session: refreshed }); + ({ config, session } = await refreshSessionWithLock(config)); } if (!config.current_org_id) { @@ -88,6 +102,72 @@ export async function resolveAuth( return auth; } +/** + * Refresh the stored session under a config-adjacent lockfile so two + * concurrent `dcd` invocations (CI matrices) can't both consume the same + * Supabase refresh token — token rotation would revoke the session family. + */ +async function refreshSessionWithLock( + initial: StoredConfig, +): Promise<{ config: StoredConfig; session: StoredSession }> { + const lockPath = `${getConfigPath()}.lock`; + await acquireRefreshLock(lockPath); + try { + // Re-read: another process may have refreshed while we waited. + const current = readConfig() ?? initial; + const session = current.session ?? initial.session!; + const now = Math.floor(Date.now() / 1000); + if (session.expires_at > now + REFRESH_SKEW_SECONDS) { + return { config: current, session }; + } + + const { anonKey } = ENVIRONMENTS[current.env].supabase; + const refreshed = await CliAuthGateway.refresh( + current.supabase_url, + anonKey, + session, + ); + // Re-read again and merge only `session` so a concurrent `switch-org` + // write (org fields) isn't reverted by our pre-refresh snapshot. + const merged: StoredConfig = { ...(readConfig() ?? current), session: refreshed }; + writeConfig(merged); + return { config: merged, session: refreshed }; + } finally { + try { rmSync(lockPath, { force: true }); } catch { /* best effort */ } + } +} + +async function acquireRefreshLock(lockPath: string): Promise { + const deadline = Date.now() + LOCK_WAIT_MS; + for (;;) { + try { + closeSync(openSync(lockPath, 'wx')); + return; + } catch { + if (Date.now() >= deadline) { + // Don't hang the command forever: steal the lock if possible and + // proceed regardless — worst case we race like the pre-lock code did. + try { rmSync(lockPath, { force: true }); } catch { /* best effort */ } + try { closeSync(openSync(lockPath, 'wx')); } catch { /* best effort */ } + return; + } + + try { + if (Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS) { + // Holder presumed dead — take over. + rmSync(lockPath, { force: true }); + continue; + } + } catch { + // Lock vanished between open and stat — fall through to a short + // sleep (not an immediate retry) so persistent fs errors can't spin. + } + + await new Promise((resolve) => { setTimeout(resolve, LOCK_POLL_MS); }); + } + } +} + function missingCredentialsError(): CliError { return new CliError( 'Not authenticated. Provide an API key via --api-key or the DEVICE_CLOUD_API_KEY environment variable, or run `dcd login`.', diff --git a/src/utils/cli.ts b/src/utils/cli.ts index 0849bd4..903bd3a 100644 --- a/src/utils/cli.ts +++ b/src/utils/cli.ts @@ -132,9 +132,11 @@ export function parseIntFlag( flagName: string, ): number | undefined { if (value === undefined || value === null || value === '') return undefined; - const n = Number.parseInt(String(value), 10); - if (!Number.isFinite(n)) { + // All integer flags (limit/offset/retry) are non-negative; also rejects + // trailing garbage that parseInt would silently accept ("20abc" -> 20). + const trimmed = String(value).trim(); + if (!/^\d+$/.test(trimmed)) { throw new CliError(`Invalid integer value for --${flagName}: "${value}"`); } - return n; + return Number.parseInt(trimmed, 10); } diff --git a/src/utils/config-store.ts b/src/utils/config-store.ts index 3e124dd..aff7aa0 100644 --- a/src/utils/config-store.ts +++ b/src/utils/config-store.ts @@ -12,6 +12,7 @@ import { existsSync, mkdirSync, readFileSync, + readdirSync, renameSync, statSync, unlinkSync, @@ -58,9 +59,21 @@ export function readConfig(): StoredConfig | null { try { const raw = readFileSync(p, 'utf8'); const parsed = JSON.parse(raw) as StoredConfig; - if (parsed.version !== CONFIG_SCHEMA_VERSION) return null; + if (parsed.version !== CONFIG_SCHEMA_VERSION) { + // eslint-disable-next-line no-console + console.warn( + `Warning: config at ${p} was written by an incompatible CLI version (config version ${parsed.version}); ignoring it. Run \`dcd login\` to recreate it.`, + ); + return null; + } return parsed; } catch { + // Surface the corruption instead of silently behaving as logged-out, so + // downstream "Not authenticated" errors aren't mystifying. + // eslint-disable-next-line no-console + console.warn( + `Warning: could not parse config at ${p}; treating as logged out. Run \`dcd login\` to recreate it.`, + ); return null; } } @@ -74,6 +87,21 @@ export function writeConfig(config: StoredConfig): void { } const finalPath = getConfigPath(); + + // Best-effort cleanup of orphaned tmp files left behind by crashed writes. + // Only remove old ones — a concurrent process may be between its own + // writeFileSync and renameSync right now. + try { + const base = path.basename(finalPath); + for (const entry of readdirSync(dir)) { + if (!entry.startsWith(`${base}.`) || !entry.endsWith('.tmp')) continue; + const tmp = path.join(dir, entry); + try { + if (Date.now() - statSync(tmp).mtimeMs > 60_000) unlinkSync(tmp); + } catch { /* best effort */ } + } + } catch { /* best effort */ } + const tmpPath = `${finalPath}.${randomBytes(6).toString('hex')}.tmp`; writeFileSync(tmpPath, JSON.stringify(config, null, 2), { mode: 0o600 }); try { chmodSync(tmpPath, 0o600); } catch { /* best effort on platforms w/o chmod */ } diff --git a/src/utils/connectivity.ts b/src/utils/connectivity.ts index add42c3..62d4e0f 100644 --- a/src/utils/connectivity.ts +++ b/src/utils/connectivity.ts @@ -42,10 +42,9 @@ export async function checkInternetConnectivity(): Promise controller.abort(), 3000); // 3 second timeout try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout - const response = await fetch(url, { method: 'HEAD', // Use HEAD to minimize data transfer signal: controller.signal, @@ -53,7 +52,6 @@ export async function checkInternetConnectivity(): Promise= 400 && response.status < 500; + let error: Error; if (response.status === 403 || response.status === 401) { - throw new Error( + error = new Error( `Failed to download Expo build from URL (HTTP ${response.status}). Expo signed URLs expire after ~1 hour — please generate a fresh URL with 'eas build' and try again.`, ); + } else { + error = new Error(`Failed to download Expo build from URL (HTTP ${response.status}).`); } - throw new Error(`Failed to download Expo build from URL (HTTP ${response.status}).`); + if (permanent) { + (error as Error & { permanent?: boolean }).permanent = true; + } + + throw error; } if (!response.body) { @@ -70,8 +80,9 @@ export async function downloadExpoUrl(url: string, debug: boolean): Promise {}); + const isPermanent = Boolean((error as Error & { permanent?: boolean })?.permanent); const isLastAttempt = attempt === DOWNLOAD_RETRY_ATTEMPTS; - if (isLastAttempt) { + if (isPermanent || isLastAttempt) { throw error; } diff --git a/src/utils/paths.ts b/src/utils/paths.ts new file mode 100644 index 0000000..713af1f --- /dev/null +++ b/src/utils/paths.ts @@ -0,0 +1,25 @@ +import * as path from 'node:path'; + +/** + * Convert an absolute path into the portable './'-prefixed, forward-slash + * relative form used as flow keys across submission, metadata maps, and + * polling. `commonRoot` must be a whole-segment prefix of `absolutePath` + * without a trailing separator (or '' when no common root exists, in which + * case the path is kept whole apart from separator normalization). + * + * Replaces the old `replaceAll(commonRoot, '.')` pattern, which corrupted + * paths when the root substring recurred mid-path or collapsed to ''. + */ +export function toPortableRelativePath( + absolutePath: string, + commonRoot: string, +): string { + let relative = absolutePath; + if (commonRoot && absolutePath.startsWith(commonRoot)) { + relative = absolutePath.slice(commonRoot.length); + } + if (!relative.startsWith(path.sep)) { + relative = path.sep + relative; + } + return ('.' + relative).split(path.sep).join('/'); +} diff --git a/src/utils/styling.ts b/src/utils/styling.ts index a699fa7..65fb869 100644 --- a/src/utils/styling.ts +++ b/src/utils/styling.ts @@ -1,10 +1,16 @@ import chalk = require('chalk'); +import { findEnvByApiUrl } from '../config/environments'; + /** * Centralized styling utilities for CLI output * Provides consistent, developer-friendly visual formatting */ +/** Strip ANSI color escape sequences for visible-width calculations. */ +// eslint-disable-next-line no-control-regex -- matches ANSI escape sequences +const stripAnsi = (s: string): string => s.replace(/\u001B\[[0-9;]*m/g, ''); + /** * Status symbols with associated colors */ @@ -163,8 +169,6 @@ export function formatTestSummary(summary: { */ export function box(content: string): string { const lines = content.split('\n'); - // eslint-disable-next-line no-control-regex -- matches ANSI escape sequences - const stripAnsi = (s: string): string => s.replace(/\[[0-9;]*m/g, ''); const visibleLen = (s: string): number => stripAnsi(s).length; const maxLength = Math.max(...lines.map((l) => visibleLen(l))); const top = chalk.gray('┌' + '─'.repeat(maxLength + 2) + '┐'); @@ -193,8 +197,6 @@ export function table( const keys = Object.keys(columns); const headers = keys.map((k) => columns[k].header ?? k); - // eslint-disable-next-line no-control-regex -- matches ANSI escape sequences - const stripAnsi = (s: string): string => s.replace(/\u001B\[[0-9;]*m/g, ''); const cells: string[][] = rows.map((row) => keys.map((k) => String(columns[k].get(row) ?? '')), ); @@ -218,16 +220,15 @@ export function table( /** * Generate console URL based on API URL - * If a non-default API URL is used, prepends "dev." to the console subdomain + * Derives the console host from the known environment matching the API URL; + * unknown API URLs fall back to the dev console (historical behavior). * @param apiUrl - The API URL being used * @param uploadId - The upload ID * @param resultId - The result ID * @returns The appropriate console URL */ export function getConsoleUrl(apiUrl: string, uploadId: number | string, resultId: number | string): string { - const DEFAULT_API_URL = 'https://api.devicecloud.dev'; - const isDefaultApi = apiUrl === DEFAULT_API_URL; - - const consoleSubdomain = isDefaultApi ? 'console' : 'dev.console'; - return `https://${consoleSubdomain}.devicecloud.dev/results?upload=${uploadId}&result=${resultId}`; + const env = findEnvByApiUrl(apiUrl); + const base = env?.frontendUrl ?? 'https://dev.console.devicecloud.dev'; + return `${base}/results?upload=${uploadId}&result=${resultId}`; } From 360af06a6c4be6f2756714eb71c349b9bd914ef3 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Fri, 12 Jun 2026 17:57:51 +0100 Subject: [PATCH 2/5] test: isolate config dir, poll mock API readiness, fix swallowed assertion - test-runner: run the suite with an isolated DCD_CONFIG_DIR so the CLI under test can never read or refresh the developer's real session (five auth tests previously depended on local login state) - test-runner: replace the fixed 3s sleep with readiness polling against the mock API (30s deadline, fails loudly), forward mock API output with a prefix instead of leaving pipes unread, fail the run if the server dies mid-suite, kill the server's whole process group on POSIX, and treat signal-killed mocha as failure instead of exit 0 - metadata-extractor test: the expect.fail call was caught by its own catch block (AssertionError instanceof Error), so the test could never fail; assert via a threw flag instead Co-Authored-By: Claude Fable 5 --- scripts/test-runner.mjs | 111 +++++++++++++++++-- test/unit/metadata-extractor.service.test.ts | 4 +- 2 files changed, 105 insertions(+), 10 deletions(-) diff --git a/scripts/test-runner.mjs b/scripts/test-runner.mjs index 8735131..33adcba 100755 --- a/scripts/test-runner.mjs +++ b/scripts/test-runner.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node import { spawn } from 'child_process'; +import fs from 'fs'; +import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -13,19 +15,83 @@ const mockApiDir = path.resolve(__dirname, '../../dcd/mock-api'); const cliDir = path.resolve(__dirname, '..'); +const MOCK_API_URL = 'http://localhost:3001/'; +const READY_DEADLINE_MS = 30_000; +const READY_POLL_INTERVAL_MS = 500; + let mockApiProcess; +let mockApiExited = false; +let testsFinished = false; -function cleanup() { - if (mockApiProcess && !mockApiProcess.killed) { - console.log('Stopping mock API...'); - mockApiProcess.kill('SIGTERM'); +// Isolated config dir so the CLI under test can never read (or refresh — +// Supabase rotates refresh tokens) the developer's real ~/.config/dcd session. +const testConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-test-config-')); + +function killMockApi() { + if (!mockApiProcess || mockApiExited) return; + console.log('Stopping mock API...'); + // The mock API is spawned via `npm run` with `shell: true`, so signalling + // only the wrapper can orphan the actual server and leave port 3001 bound. + // On POSIX we spawned it detached (its own process group) and signal the + // whole group; Windows has no process groups, so fall back to kill(). + if (process.platform === 'win32') { + if (!mockApiProcess.killed) mockApiProcess.kill('SIGTERM'); + } else { + try { + process.kill(-mockApiProcess.pid, 'SIGTERM'); + } catch { + // Process group already gone — nothing to clean up. + } } } +function cleanup() { + killMockApi(); + fs.rmSync(testConfigDir, { recursive: true, force: true }); +} + process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup); process.on('exit', cleanup); +// Forward a child stream line-by-line with a prefix so mock API boot +// failures are visible and the pipe buffer never fills up unread. +function forwardOutput(stream, write) { + if (!stream) return; + let buffered = ''; + stream.setEncoding('utf8'); + stream.on('data', (chunk) => { + buffered += chunk; + const lines = buffered.split('\n'); + buffered = lines.pop(); + for (const line of lines) write(`[mock-api] ${line}\n`); + }); + stream.on('end', () => { + if (buffered) write(`[mock-api] ${buffered}\n`); + }); +} + +// Poll until the mock API accepts connections. Any HTTP response (even a +// 404) counts as "listening"; only connection errors count as not-ready. +async function waitForMockApi() { + const deadline = Date.now() + READY_DEADLINE_MS; + while (Date.now() < deadline) { + if (mockApiExited) { + throw new Error('Mock API exited before becoming ready'); + } + try { + await fetch(MOCK_API_URL); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, READY_POLL_INTERVAL_MS)); + } + } + killMockApi(); + throw new Error( + `Mock API did not become ready at ${MOCK_API_URL} within ${READY_DEADLINE_MS / 1000}s` + ); +} + async function runTests() { try { // Build CLI first for faster test execution @@ -51,11 +117,35 @@ async function runTests() { mockApiProcess = spawn('npm', ['run', 'start:auth'], { cwd: mockApiDir, stdio: ['ignore', 'pipe', 'pipe'], - shell: true + shell: true, + // Own process group on POSIX so killMockApi() can signal `npm run` + // *and* the server it spawns, not just the wrapper. + detached: process.platform !== 'win32', + }); + + forwardOutput(mockApiProcess.stdout, (text) => process.stdout.write(text)); + forwardOutput(mockApiProcess.stderr, (text) => process.stderr.write(text)); + + mockApiProcess.on('error', (error) => { + console.error('Mock API failed to start:', error); + if (!testsFinished) { + process.exit(1); + } + }); + + mockApiProcess.on('exit', (code, signal) => { + mockApiExited = true; + if (!testsFinished) { + console.error( + `Mock API exited before tests finished (code ${code}, signal ${signal})` + ); + process.exit(1); + } }); - // Wait for mock API to start - await new Promise((resolve) => setTimeout(resolve, 3000)); + console.log('Waiting for mock API to be ready...'); + await waitForMockApi(); + console.log('Mock API is ready.'); // Run tests. Mocha + .mocharc.json handle TypeScript loading via `tsx` // (see `node-option: ["import=tsx"]` there). Mocha 11 imports files as @@ -71,11 +161,14 @@ async function runTests() { cwd: cliDir, stdio: 'inherit', shell: true, + env: { ...process.env, DCD_CONFIG_DIR: testConfigDir }, }); testProcess.on('close', (code) => { + testsFinished = true; cleanup(); - process.exit(code); + // `code` is null when mocha is killed by a signal — treat as failure. + process.exit(code ?? 1); }); } catch (error) { @@ -85,4 +178,4 @@ async function runTests() { } } -runTests().catch(console.error); \ No newline at end of file +runTests().catch(console.error); diff --git a/test/unit/metadata-extractor.service.test.ts b/test/unit/metadata-extractor.service.test.ts index e42ada6..bf4d3fa 100644 --- a/test/unit/metadata-extractor.service.test.ts +++ b/test/unit/metadata-extractor.service.test.ts @@ -32,14 +32,16 @@ describe('AndroidMetadataExtractor', () => { const fakeApk = path.join(tempDir, 'not-really.apk'); fs.writeFileSync(fakeApk, 'definitely not an apk'); + let threw = false; try { await extractor.extract(fakeApk); - expect.fail('expected extract() to throw on invalid APK'); } catch (error) { + threw = true; expect(error).to.be.instanceOf(Error); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } + expect(threw, 'expected extract() to throw on invalid APK').to.equal(true); }); }); From 906126c27366ef3614643ef06fbbef9819d27d50 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Fri, 12 Jun 2026 17:57:51 +0100 Subject: [PATCH 3/5] ci: harden workflows and install scripts - cli-ci: drop --ignore-registry-errors so a failed audit can't pass silently; scope the workflow token to contents: read - npm-publish: refuse prod-tag publishes from any ref other than the production branch (workflow_dispatch could previously publish any non-beta version to npm latest, bypassing release-please) - install.sh: wrap the body in main() so a truncated curl | sh download executes nothing - install.ps1: pin TLS 1.2 on Windows PowerShell 5.x; read the raw unexpanded User PATH and write it back as ExpandString (previously froze %VAR% entries); append instead of prepend and skip when already present Co-Authored-By: Claude Fable 5 --- .github/workflows/cli-ci.yml | 5 +- .github/workflows/npm-publish.yml | 12 ++ install.ps1 | 26 ++++- install.sh | 185 ++++++++++++++++-------------- 4 files changed, 133 insertions(+), 95 deletions(-) diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index 97198a3..623dab2 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -7,6 +7,9 @@ on: branches: [ dev ] workflow_dispatch: +permissions: + contents: read + jobs: lint-and-test: runs-on: k8s @@ -64,4 +67,4 @@ jobs: - name: Security audit working-directory: ./cli - run: pnpm audit --audit-level moderate --ignore-registry-errors + run: pnpm audit --audit-level moderate diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index cdb5b2f..691bb3e 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -41,6 +41,18 @@ jobs: - run: pnpm install --frozen-lockfile + # Prod publishes land on the npm `latest` tag and must only ever come + # from the `production` branch (release-please's prod target branch). + # Without this guard, workflow_dispatch could publish any ref whose + # version lacks a -beta suffix as `latest`, bypassing release-please. + # workflow_call is unaffected: release-please.yml only requests a prod + # release on pushes to `production`, so github.ref_name matches there. + - name: Enforce production branch for prod releases + if: ${{ inputs.release_type == 'prod' && github.ref_name != 'production' }} + run: | + echo "Error: prod releases may only be published from the 'production' branch (got '${{ github.ref_name }}')" + exit 1 + # Version validation for production release - name: Validate Production Version if: ${{ inputs.release_type == 'prod' }} diff --git a/install.ps1 b/install.ps1 index e50d5c6..b6db89a 100644 --- a/install.ps1 +++ b/install.ps1 @@ -10,6 +10,12 @@ $ErrorActionPreference = 'Stop' +# Windows PowerShell 5.x defaults to TLS 1.0/1.1, which modern hosts reject. +# PowerShell 6+ negotiates TLS correctly on its own, so only patch 5.x. +if ($PSVersionTable.PSVersion.Major -lt 6) { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +} + $DownloadBase = if ($env:DCD_DOWNLOAD_BASE) { $env:DCD_DOWNLOAD_BASE } else { 'https://get.devicecloud.dev' } $InstallDir = if ($env:DCD_INSTALL_DIR) { $env:DCD_INSTALL_DIR } else { Join-Path $env:USERPROFILE '.dcd\bin' } @@ -62,11 +68,21 @@ try { } # --- PATH update (user scope) --- -$userPath = [Environment]::GetEnvironmentVariable('Path', 'User') -$pathParts = ($userPath -split ';') | Where-Object { $_ -ne '' } -if ($pathParts -notcontains $InstallDir) { - $newPath = (@($InstallDir) + $pathParts) -join ';' - [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') +# Read the raw, unexpanded registry value (REG_EXPAND_SZ entries such as +# %USERPROFILE% must survive the round-trip; [Environment]::GetEnvironmentVariable +# would expand and freeze them). Append the install dir rather than rewriting +# the whole value, and skip entirely if it is already present. +$regKey = Get-Item -Path 'HKCU:\Environment' +$rawPath = [string]$regKey.GetValue( + 'Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames +) +$normalizedDir = $InstallDir.TrimEnd('\') +$alreadyOnPath = @( + ($rawPath -split ';') | Where-Object { $_ -and ($_.TrimEnd('\') -ieq $normalizedDir) } +).Count -gt 0 +if (-not $alreadyOnPath) { + $newPath = if ($rawPath -eq '') { $InstallDir } else { $rawPath.TrimEnd(';') + ';' + $InstallDir } + Set-ItemProperty -Path 'HKCU:\Environment' -Name 'Path' -Value $newPath -Type ExpandString Write-Host '' Write-Host "Installed dcd $version to $InstallDir\dcd.exe" Write-Host "Added $InstallDir to your user PATH. Open a new terminal to pick it up." diff --git a/install.sh b/install.sh index 6ea59a1..d213ebb 100755 --- a/install.sh +++ b/install.sh @@ -8,12 +8,12 @@ # DCD_VERSION Pin a specific version (default: latest) # DCD_INSTALL_DIR Override install location (default: $HOME/.dcd/bin) # DCD_DOWNLOAD_BASE Override the download host (default: https://get.devicecloud.dev) +# +# The whole script is wrapped in main() and only invoked on the last line, so +# a truncated download (curl | sh executes as it streams) runs nothing at all. set -eu -DOWNLOAD_BASE="${DCD_DOWNLOAD_BASE:-https://get.devicecloud.dev}" -INSTALL_DIR="${DCD_INSTALL_DIR:-$HOME/.dcd/bin}" - err() { printf 'error: %s\n' "$1" >&2 exit 1 @@ -23,89 +23,96 @@ info() { printf '%s\n' "$1" } -# --- detect platform --- -os=$(uname -s) -case "$os" in - Darwin) os_id=darwin ;; - Linux) os_id=linux ;; - *) err "Unsupported OS: $os. Try the Windows installer (install.ps1)." ;; -esac - -arch=$(uname -m) -case "$arch" in - arm64|aarch64) arch_id=arm64 ;; - x86_64|amd64) arch_id=x64 ;; - *) err "Unsupported architecture: $arch" ;; -esac - -asset="dcd-${os_id}-${arch_id}" - -# --- resolve version --- -if [ -n "${DCD_VERSION:-}" ]; then - version="$DCD_VERSION" -else - info "Resolving latest version..." - # /latest.json returns { "version": "5.1.0", ... } - version=$( - curl -fsSL "$DOWNLOAD_BASE/latest.json" \ - | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ - | head -n1 - ) - [ -z "$version" ] && err "Could not resolve latest version from $DOWNLOAD_BASE/latest.json" -fi - -url="$DOWNLOAD_BASE/download/${version}/${asset}" -sums_url="$DOWNLOAD_BASE/download/${version}/SHA256SUMS" - -info "Installing dcd ${version} (${os_id}-${arch_id})" -info " from: $url" -info " to: $INSTALL_DIR/dcd" - -# --- download --- -mkdir -p "$INSTALL_DIR" -tmp=$(mktemp "${TMPDIR:-/tmp}/dcd-XXXXXX") -trap 'rm -f "$tmp" "$tmp.sums"' EXIT -curl -fSL --progress-bar "$url" -o "$tmp" \ - || err "Download failed: $url" - -# --- verify checksum --- -curl -fsSL "$sums_url" -o "$tmp.sums" \ - || err "Could not fetch checksums: $sums_url" -expected=$(grep -F " ${asset}" "$tmp.sums" | awk '{print $1}' | head -n1) -[ -z "$expected" ] && err "SHA256SUMS has no entry for $asset" - -if command -v sha256sum >/dev/null 2>&1; then - actual=$(sha256sum "$tmp" | awk '{print $1}') -elif command -v shasum >/dev/null 2>&1; then - actual=$(shasum -a 256 "$tmp" | awk '{print $1}') -else - err "Need sha256sum or shasum to verify download" -fi - -if [ "$expected" != "$actual" ]; then - err "Checksum mismatch for $asset: expected $expected, got $actual" -fi - -# --- install --- -chmod +x "$tmp" -mv "$tmp" "$INSTALL_DIR/dcd" -trap - EXIT # tmp has been moved; nothing to clean up - -# --- PATH hint --- -case ":$PATH:" in - *":$INSTALL_DIR:"*) - info "" - info "✓ Installed: $($INSTALL_DIR/dcd --version 2>/dev/null || echo "$version")" - info " Try: dcd --help" - ;; - *) - info "" - info "✓ Installed dcd $version to $INSTALL_DIR/dcd" - info "" - info " $INSTALL_DIR is not on your PATH. Add this to your shell rc:" - info " export PATH=\"$INSTALL_DIR:\$PATH\"" - info "" - info " Then restart your shell, or run:" - info " export PATH=\"$INSTALL_DIR:\$PATH\"" - ;; -esac +main() { + DOWNLOAD_BASE="${DCD_DOWNLOAD_BASE:-https://get.devicecloud.dev}" + INSTALL_DIR="${DCD_INSTALL_DIR:-$HOME/.dcd/bin}" + + # --- detect platform --- + os=$(uname -s) + case "$os" in + Darwin) os_id=darwin ;; + Linux) os_id=linux ;; + *) err "Unsupported OS: $os. Try the Windows installer (install.ps1)." ;; + esac + + arch=$(uname -m) + case "$arch" in + arm64|aarch64) arch_id=arm64 ;; + x86_64|amd64) arch_id=x64 ;; + *) err "Unsupported architecture: $arch" ;; + esac + + asset="dcd-${os_id}-${arch_id}" + + # --- resolve version --- + if [ -n "${DCD_VERSION:-}" ]; then + version="$DCD_VERSION" + else + info "Resolving latest version..." + # /latest.json returns { "version": "5.1.0", ... } + version=$( + curl -fsSL "$DOWNLOAD_BASE/latest.json" \ + | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ + | head -n1 + ) + [ -z "$version" ] && err "Could not resolve latest version from $DOWNLOAD_BASE/latest.json" + fi + + url="$DOWNLOAD_BASE/download/${version}/${asset}" + sums_url="$DOWNLOAD_BASE/download/${version}/SHA256SUMS" + + info "Installing dcd ${version} (${os_id}-${arch_id})" + info " from: $url" + info " to: $INSTALL_DIR/dcd" + + # --- download --- + mkdir -p "$INSTALL_DIR" + tmp=$(mktemp "${TMPDIR:-/tmp}/dcd-XXXXXX") + trap 'rm -f "$tmp" "$tmp.sums"' EXIT + curl -fSL --progress-bar "$url" -o "$tmp" \ + || err "Download failed: $url" + + # --- verify checksum --- + curl -fsSL "$sums_url" -o "$tmp.sums" \ + || err "Could not fetch checksums: $sums_url" + expected=$(grep -F " ${asset}" "$tmp.sums" | awk '{print $1}' | head -n1) + [ -z "$expected" ] && err "SHA256SUMS has no entry for $asset" + + if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$tmp" | awk '{print $1}') + elif command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 "$tmp" | awk '{print $1}') + else + err "Need sha256sum or shasum to verify download" + fi + + if [ "$expected" != "$actual" ]; then + err "Checksum mismatch for $asset: expected $expected, got $actual" + fi + + # --- install --- + chmod +x "$tmp" + mv "$tmp" "$INSTALL_DIR/dcd" + trap - EXIT # tmp has been moved; nothing to clean up + + # --- PATH hint --- + case ":$PATH:" in + *":$INSTALL_DIR:"*) + info "" + info "✓ Installed: $($INSTALL_DIR/dcd --version 2>/dev/null || echo "$version")" + info " Try: dcd --help" + ;; + *) + info "" + info "✓ Installed dcd $version to $INSTALL_DIR/dcd" + info "" + info " $INSTALL_DIR is not on your PATH. Add this to your shell rc:" + info " export PATH=\"$INSTALL_DIR:\$PATH\"" + info "" + info " Then restart your shell, or run:" + info " export PATH=\"$INSTALL_DIR:\$PATH\"" + ;; + esac +} + +main "$@" From db1fe300fe875ad96c028518a4d7f96394a3bede Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Fri, 12 Jun 2026 17:57:51 +0100 Subject: [PATCH 4/5] chore: remove stale schema.types duplicate, fix CLAUDE.md auth docs - src/types/schema.types.ts was a byte-identical, hand-maintained copy of the generated file with no importers; delete it and its eslint ignore entry - CLAUDE.md described the abandoned loopback-server login flow; it now documents the implemented PKCE + claim-polling flow, and the telemetry section reflects the new index.ts runner Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 4 +- eslint.config.js | 1 - src/types/schema.types.ts | 1949 ------------------------------------- 3 files changed, 2 insertions(+), 1952 deletions(-) delete mode 100644 src/types/schema.types.ts diff --git a/CLAUDE.md b/CLAUDE.md index d04bcf9..66fd340 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,8 +33,8 @@ Top-level `defineCommand` in `src/index.ts` wires ten subcommands (`cloud`, `upl **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. -**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`) spins up a loopback HTTP server on 127.0.0.1, generates a `state` token, opens `/cli-login?state=...&port=...`, and waits for the browser to redirect back with the encoded Supabase session. 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 the session to `POST /cli-login/handoff`; the API verifies `sha256(verifier) === challenge` and returns the session. 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`. **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`. -**Telemetry.** `src/services/telemetry.service.ts` ships lifecycle (`command started` / `command completed` / `command failed`) and error events to the dcd API's `/cli/logs` proxy → Axiom `cli-dev` / `cli-prod`. Wired in at three points: `src/index.ts` wraps `runMain` to record start/success/failure; `src/utils/auth.ts` calls `telemetry.configure({ auth })` from `resolveAuth` so the token never has to be re-derived; `src/utils/cli.ts` `logger.error` calls `telemetry.flushSync()` (which shells out to `curl` because `process.exit` bypasses `beforeExit`) before exiting. Unauthenticated invocations (`--help`, `--version`, `dcd login` pre-success) buffer in memory and drop on exit — by design, since there's no identity to attach. Opt out per-invocation with `DCD_TELEMETRY_DISABLED=1`. +**Telemetry.** `src/services/telemetry.service.ts` ships lifecycle (`command started` / `command completed` / `command failed`) and error events to the dcd API's `/cli/logs` proxy → Axiom `cli-dev` / `cli-prod`. Wired in at three points: `src/index.ts` replicates citty's `runMain` (which would otherwise swallow errors and exit 1) to record start/success/failure and honor `CliError.exitCode`; `src/utils/auth.ts` calls `telemetry.configure({ auth })` from `resolveAuth` so the token never has to be re-derived; `src/utils/cli.ts` `logger.error` calls `telemetry.flushSync()` (which shells out to `curl` because `process.exit` bypasses `beforeExit`) before exiting. Unauthenticated invocations (`--help`, `--version`, `dcd login` pre-success) buffer in memory and drop on exit — by design, since there's no identity to attach. Opt out per-invocation with `DCD_TELEMETRY_DISABLED=1`. diff --git a/eslint.config.js b/eslint.config.js index 532e455..6be1e43 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,7 +18,6 @@ module.exports = tseslint.config( 'dist/**', 'node_modules/**', 'src/types/generated/**', - 'src/types/schema.types.ts', ], }, js.configs.recommended, diff --git a/src/types/schema.types.ts b/src/types/schema.types.ts deleted file mode 100644 index f1bcbc0..0000000 --- a/src/types/schema.types.ts +++ /dev/null @@ -1,1949 +0,0 @@ -/* eslint-disable prettier/prettier */ - -/** - * This file was auto-generated by openapi-typescript. - * Do not make direct changes to the file. - */ - -export interface paths { - "/uploads/getBinaryUploadUrl": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["UploadsController_getBinaryUploadUrl"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/uploads/checkForExistingUpload": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["UploadsController_checkForExistingUpload"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/uploads/finaliseUpload": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["UploadsController_finaliseUpload"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/uploads/finishLargeFile": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["UploadsController_finishLargeFile"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/uploads/flow": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["UploadsController_createTest"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/uploads/retryTest": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["UploadsController_retryTest"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/uploads/cancelTest": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["UploadsController_cancelTest"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/uploads/status": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["UploadsController_getUploadStatus"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/uploads/list": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["UploadsController_listUploads"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/uploads/{uploadId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - delete: operations["UploadsController_deleteUpload"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/results/{uploadId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["ResultsController_getResults"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/results/{uploadId}/download": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["ResultsController_getTestRunArtifacts"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/results/notify/{uploadId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["ResultsController_notifyTestRunComplete"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/results/{uploadId}/report": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["ResultsController_downloadReport"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/results/{uploadId}/html-report": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["ResultsController_downloadHtmlReport"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/results/{resultId}/html-report-single": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["ResultsController_downloadSingleHtmlReport"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/results/compatibility/data": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["ResultsController_getCompatibilityData"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/allure/{uploadId}/download": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Download Allure report as HTML - * @description Downloads a single-file Allure report as HTML containing all test results. Report is generated once and stored in Supabase Storage for subsequent downloads. - */ - get: operations["AllureController_downloadAllureReport"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/webhooks": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["WebhooksController_getWebhook"]; - put?: never; - post: operations["WebhooksController_setWebhook"]; - delete: operations["WebhooksController_deleteWebhook"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/webhooks/regenerate-secret": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["WebhooksController_regenerateWebhookSecret"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/webhooks/test": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["WebhooksController_testWebhook"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/org/paddle-webhook": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["OrgController_handlePaddleWebhook"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/org/update-name": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["OrgController_updateOrgName"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/org/invite-team-member": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["OrgController_inviteTeamMember"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/org/accept-invite": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["OrgController_acceptInvite"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/org/subscriptions": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["OrgController_getAllSubscriptions"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/org/update-overage-limit": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["OrgController_updateOverageLimit"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/org/usage-history": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["OrgController_getUsageHistory"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/frontend/check-domain-saml": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["FrontendController_checkDomainSaml"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/frontend/validate-email": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["FrontendController_validateEmail"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/health": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["HealthController_health"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/billing/create-subscription": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["BillingController_createSubscription"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/stats/marketing": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["StatsController_getMarketingStats"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; -} -export type webhooks = Record; -export interface components { - schemas: { - IDBResult: { - binary_upload_id: string; - cost: number | null; - created_at: string; - env: Record; - id: number; - org_id: number; - platform: string; - simulator_name: string; - status: string; - test_file_name: string; - test_upload_id: string; - }; - IGetBinaryUploadUrlArgs: { - /** - * @description Platform for the binary upload (ios or android) - * @enum {string} - */ - platform: "ios" | "android"; - /** @description File size in bytes (optional, for Backblaze upload strategy) */ - fileSize?: number; - /** @description Whether client uses TUS resumable uploads (true for new clients, undefined/false for legacy) */ - useTus?: boolean; - }; - B2SimpleUpload: { - uploadUrl: string; - authorizationToken: string; - }; - B2UploadPartUrl: { - uploadUrl: string; - authorizationToken: string; - }; - B2LargeUpload: { - fileId: string; - fileName: string; - uploadPartUrls: components["schemas"]["B2UploadPartUrl"][]; - }; - B2UploadStrategy: { - /** @enum {string} */ - strategy: "simple" | "large"; - simple?: components["schemas"]["B2SimpleUpload"]; - large?: components["schemas"]["B2LargeUpload"]; - }; - IGetBinaryUploadUrlResponse: { - /** @description Temporary upload path in uploads/ folder for TUS upload */ - path: string; - /** @description Temporary upload path (same as path) */ - tempPath: string; - /** @description Final path where file will be moved after upload completes */ - finalPath: string; - /** @description Upload ID */ - id: string; - /** @description Backblaze upload strategy if configured */ - b2?: components["schemas"]["B2UploadStrategy"]; - /** @description Signed upload URL token for legacy clients (deprecated) */ - token?: string; - }; - ICheckForExistingUploadArgs: { - /** @description SHA-256 hash of the binary file */ - sha: string; - }; - ICheckForExistingUploadResponse: { - appBinaryId: string; - exists: boolean; - }; - IFinaliseUploadArgs: { - /** @description Unique upload identifier */ - id: string; - /** @description Storage path for the uploaded file */ - path: string; - /** @description File metadata (bundle ID, package name, platform) - required for new clients */ - metadata?: Record; - /** @description SHA-256 hash of the file - required for new clients */ - sha?: string; - /** - * @description Whether the Supabase upload was successful - * @default true - */ - supabaseSuccess: boolean; - /** - * @description Whether the Backblaze upload was successful - * @default false - */ - backblazeSuccess: boolean; - /** @description Whether client uses TUS resumable uploads (true for new clients, undefined/false for legacy) */ - useTus?: boolean; - /** @description File size in bytes */ - bytes?: number; - }; - IFinaliseUploadResponse: Record; - IFinishLargeFileArgs: { - /** - * @description The Backblaze file ID from the large file upload - * @example abc123xyz - */ - fileId: string; - /** - * @description Array of SHA1 hashes for each uploaded part - * @example [ - * "sha1hash1", - * "sha1hash2" - * ] - */ - partSha1Array: string[]; - }; - IFinishLargeFileResponse: { - success: boolean; - result: Record; - }; - ICreateTestUploadArgs: { - /** - * Format: binary - * @description This file must be a zip file - */ - file: string; - testFileNames?: string; - sequentialFlows?: string; - /** @enum {string} */ - androidApiLevel?: "29" | "30" | "31" | "32" | "33" | "34" | "35" | "36"; - /** @enum {string} */ - androidDevice?: "pixel-6" | "pixel-6-pro" | "pixel-7" | "pixel-7-pro" | "generic-tablet"; - apiKey?: string; - apiUrl?: string; - appBinaryId: string; - appFile?: string; - env: string; - /** @enum {string} */ - iOSVersion?: "16" | "17" | "18" | "26"; - /** @enum {string} */ - iOSDevice?: "iphone-14" | "iphone-15" | "iphone-16" | "iphone-16-plus" | "iphone-16-pro" | "iphone-16-pro-max" | "ipad-pro-6th-gen"; - platform?: string; - googlePlay: boolean; - config: string; - name?: string; - /** @enum {string} */ - runnerType?: "m4" | "m1" | "default" | "gpu1" | "cpu1"; - metadata?: string; - workspaceConfig?: string; - flowMetadata?: string; - testFileOverrides?: string; - /** @description SHA-256 hash of the flow ZIP file */ - sha?: string; - }; - IRetryTestArgs: { - resultId: number; - }; - ICancelTestArgs: { - /** @description ID of a specific result to cancel. Either resultId or uploadId must be provided, but not both. */ - resultId?: number; - /** @description ID of an upload to cancel all pending results for. Either resultId or uploadId must be provided, but not both. */ - uploadId?: string; - }; - TResultResponse: { - id: number; - test_file_name: string; - status: string; - retry_of?: number; - fail_reason?: string; - duration_seconds?: number; - }; - UpdateOrgNameDto: { - /** - * @description Organization ID - * @example 123 - */ - orgId: number; - /** - * @description Organization name - * @example Acme Corporation - */ - name: string; - }; - AcceptInviteDto: { - /** - * @description Organization ID - * @example 1 - */ - orgId: string; - /** - * @description User email address - * @example user@example.com - */ - email: string; - }; - }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; -} -export type $defs = Record; -export interface operations { - UploadsController_getBinaryUploadUrl: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["IGetBinaryUploadUrlArgs"]; - }; - }; - responses: { - /** @description The url has been successfully created. */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["IGetBinaryUploadUrlResponse"]; - }; - }; - }; - }; - UploadsController_checkForExistingUpload: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["ICheckForExistingUploadArgs"]; - }; - }; - responses: { - /** @description The url has been successfully created. */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ICheckForExistingUploadResponse"]; - }; - }; - }; - }; - UploadsController_finaliseUpload: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["IFinaliseUploadArgs"]; - }; - }; - responses: { - /** @description The upload has been completed. */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["IFinaliseUploadResponse"]; - }; - }; - }; - }; - UploadsController_finishLargeFile: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["IFinishLargeFileArgs"]; - }; - }; - responses: { - /** @description The large file upload has been completed. */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["IFinishLargeFileResponse"]; - }; - }; - }; - }; - UploadsController_createTest: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "multipart/form-data": components["schemas"]["ICreateTestUploadArgs"]; - }; - }; - responses: { - /** @description The record has been successfully created. */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - message?: string; - results?: components["schemas"]["IDBResult"][]; - }; - }; - }; - }; - }; - UploadsController_retryTest: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["IRetryTestArgs"]; - }; - }; - responses: { - /** @description The record has been successfully created. */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - message?: string; - results?: components["schemas"]["IDBResult"][]; - }; - }; - }; - }; - }; - UploadsController_cancelTest: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["ICancelTestArgs"]; - }; - }; - responses: { - /** @description The record has been successfully cancelled. */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - message?: string; - success?: boolean; - cancelledCount?: number; - }; - }; - }; - }; - }; - UploadsController_getUploadStatus: { - parameters: { - query?: { - /** @description Upload ID to get status for */ - uploadId?: string; - /** @description Upload name to get status for */ - name?: string; - }; - header: { - "x-app-api-key": string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Upload status */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - /** - * @example { - * "uploadId": "upload-123", - * "status": "PENDING", - * "tests": [ - * { - * "id": 1, - * "test_file_name": "test-flow.yaml", - * "status": "PENDING" - * } - * ] - * } - */ - "application/json": Record; - }; - }; - }; - }; - UploadsController_listUploads: { - parameters: { - query?: { - /** @description Filter by upload name (supports * wildcard) */ - name?: string; - /** @description Filter uploads created on or after this date (ISO 8601) */ - from?: string; - /** @description Filter uploads created on or before this date (ISO 8601) */ - to?: string; - /** @description Maximum number of uploads to return (default: 20) */ - limit?: number; - /** @description Number of uploads to skip (default: 0) */ - offset?: number; - }; - header: { - "x-app-api-key": string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description List of flow uploads. Use GET /uploads/status for detailed test results. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - /** - * @example { - * "uploads": [ - * { - * "id": "upload-123", - * "name": "Test Upload", - * "created_at": "2024-01-01T00:00:00Z", - * "consoleUrl": "https://console.devicecloud.dev/results/upload-123" - * } - * ], - * "total": 1, - * "limit": 20, - * "offset": 0 - * } - */ - "application/json": { - uploads?: { - id?: string; - name?: string | null; - created_at?: string; - consoleUrl?: string; - }[]; - total?: number; - limit?: number; - offset?: number; - }; - }; - }; - }; - }; - UploadsController_deleteUpload: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path: { - uploadId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description The upload has been successfully deleted. */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - success?: boolean; - message?: string; - }; - }; - }; - }; - }; - ResultsController_getResults: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path: { - uploadId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description The record has been successfully created. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - statusCode?: number; - results?: components["schemas"]["TResultResponse"][]; - }; - }; - }; - }; - }; - ResultsController_getTestRunArtifacts: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path: { - uploadId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 201: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - ResultsController_notifyTestRunComplete: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path: { - uploadId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Send results summary email. */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": string; - }; - }; - }; - }; - ResultsController_downloadReport: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path: { - uploadId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Download combined JUNIT test report (report.xml) for the upload */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": string; - }; - }; - }; - }; - ResultsController_downloadHtmlReport: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path: { - uploadId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Download combined HTML test report with assets (report.zip) for the upload */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": string; - }; - }; - }; - }; - ResultsController_downloadSingleHtmlReport: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path: { - resultId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Download HTML test report with assets (report.zip) for a single result */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": string; - }; - }; - }; - }; - ResultsController_getCompatibilityData: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Device compatibility lookup data including Maestro versions */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - /** - * @example { - * "statusCode": 200, - * "data": { - * "ios": { - * "iphone-14": { - * "name": "iPhone 14", - * "versions": [ - * "16", - * "17", - * "18" - * ], - * "deprecated": false - * }, - * "iphone-15": { - * "name": "iPhone 15", - * "versions": [ - * "17" - * ], - * "deprecated": false - * }, - * "iphone-16": { - * "name": "iPhone 16", - * "versions": [ - * "18", - * "26" - * ], - * "deprecated": false - * }, - * "iphone-16-plus": { - * "name": "iPhone 16 Plus", - * "versions": [ - * "18", - * "26" - * ], - * "deprecated": false - * }, - * "iphone-16-pro": { - * "name": "iPhone 16 Pro", - * "versions": [ - * "18", - * "26" - * ], - * "deprecated": false - * }, - * "iphone-16-pro-max": { - * "name": "iPhone 16 Pro Max", - * "versions": [ - * "18", - * "26" - * ], - * "deprecated": false - * }, - * "ipad-pro-6th-gen": { - * "name": "iPad Pro (6th gen)", - * "versions": [ - * "18", - * "26" - * ], - * "deprecated": false - * } - * }, - * "android": { - * "pixel-6": { - * "name": "Pixel 6", - * "apiLevels": [ - * "29", - * "30", - * "31", - * "32", - * "33", - * "34", - * "35", - * "36" - * ], - * "deprecated": false - * }, - * "pixel-6-pro": { - * "name": "Pixel 6 Pro", - * "apiLevels": [ - * "33", - * "34", - * "35", - * "36" - * ], - * "deprecated": false - * }, - * "pixel-7": { - * "name": "Pixel 7", - * "apiLevels": [ - * "33", - * "34", - * "35", - * "36" - * ], - * "deprecated": false - * }, - * "pixel-7-pro": { - * "name": "Pixel 7 Pro", - * "apiLevels": [ - * "33", - * "34", - * "35", - * "36" - * ], - * "deprecated": false - * }, - * "generic-tablet": { - * "name": "Generic Tablet", - * "apiLevels": [ - * "33", - * "34", - * "35", - * "36" - * ], - * "deprecated": false - * } - * }, - * "androidPlay": { - * "pixel-6": { - * "name": "Pixel 6 (Google Play)", - * "apiLevels": [ - * "34", - * "35", - * "36" - * ], - * "deprecated": false - * }, - * "pixel-7": { - * "name": "Pixel 7 (Google Play)", - * "apiLevels": [ - * "34", - * "35", - * "36" - * ], - * "deprecated": false - * } - * }, - * "maestro": { - * "supportedVersions": [ - * "1.39.0", - * "1.39.2", - * "1.39.5", - * "1.39.7", - * "1.40.3", - * "1.41.0", - * "2.0.2", - * "2.0.3", - * "2.0.4", - * "2.0.9", - * "2.1.0" - * ], - * "defaultVersion": "1.41.0", - * "latestVersion": "2.1.0" - * } - * } - * } - */ - "application/json": { - statusCode?: number; - data?: { - ios?: Record; - android?: Record; - androidPlay?: Record; - maestro?: { - supportedVersions?: string[]; - defaultVersion?: string; - latestVersion?: string; - }; - }; - }; - }; - }; - }; - }; - AllureController_downloadAllureReport: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path: { - /** @description The upload ID to generate Allure report for */ - uploadId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Allure report HTML file download */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "text/html": string; - }; - }; - /** @description Upload not found or no results available */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - WebhooksController_getWebhook: { - parameters: { - query?: { - /** @description Set to true to return full secret instead of masked version */ - show_secret?: boolean; - }; - header: { - "x-app-api-key": string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Current webhook configuration */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - webhook_url?: string; - /** @description Full secret (only when show_secret=true) */ - secret_key?: string; - /** @description Masked secret (default) */ - secret_key_masked?: string; - /** Format: date-time */ - created_at?: string; - /** Format: date-time */ - updated_at?: string; - }; - }; - }; - }; - }; - WebhooksController_setWebhook: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": { - /** - * Format: uri - * @example https://api.example.com/webhook - */ - url: string; - }; - }; - }; - responses: { - /** @description Webhook URL set successfully */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": Record; - }; - }; - }; - }; - WebhooksController_deleteWebhook: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Webhook configuration deleted successfully */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": Record; - }; - }; - }; - }; - WebhooksController_regenerateWebhookSecret: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Webhook secret regenerated successfully */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": Record; - }; - }; - }; - }; - WebhooksController_testWebhook: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": { - /** Format: uri */ - url?: string; - }; - }; - }; - responses: { - /** @description Test webhook sent successfully */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": Record; - }; - }; - }; - }; - OrgController_handlePaddleWebhook: { - parameters: { - query?: never; - header: { - "paddle-signature": string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Paddle webhook handler. */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": string; - }; - }; - }; - }; - OrgController_updateOrgName: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateOrgNameDto"]; - }; - }; - responses: { - /** @description Organization name updated successfully. */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": boolean; - }; - }; - }; - }; - OrgController_inviteTeamMember: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": { - inviteEmail: string; - requesterEmail: string; - link: string; - orgId: string; - orgName: string; - }; - }; - }; - responses: { - /** @description Team member invited successfully. */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": boolean; - }; - }; - }; - }; - OrgController_acceptInvite: { - parameters: { - query?: never; - header: { - authorization: string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AcceptInviteDto"]; - }; - }; - responses: { - /** @description Team invite accepted successfully. */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": boolean; - }; - }; - }; - }; - OrgController_getAllSubscriptions: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": { - orgId: string; - }; - }; - }; - responses: { - /** @description All subscription data fetched successfully. */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": Record; - }; - }; - }; - }; - OrgController_updateOverageLimit: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": { - orgId: string; - overageLimit: number; - }; - }; - }; - responses: { - /** @description Overage limit updated successfully. */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": Record; - }; - }; - }; - }; - OrgController_getUsageHistory: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": { - orgId: string; - /** @enum {string} */ - format?: "json" | "csv"; - /** Format: date-time */ - startDate?: string; - /** Format: date-time */ - endDate?: string; - }; - }; - }; - responses: { - /** @description Usage history fetched successfully. */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown[]; - }; - }; - }; - }; - FrontendController_checkDomainSaml: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** @description Domain to check for SAML configuration */ - requestBody: { - content: { - "application/json": { - /** @example example.com */ - domain: string; - }; - }; - }; - responses: { - /** @description SAML status for the domain */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - forceSaml?: boolean; - }; - }; - }; - 201: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Bad request - invalid domain or API error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - error?: string; - }; - }; - }; - }; - }; - FrontendController_validateEmail: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** @description Email address to validate */ - requestBody: { - content: { - "application/json": { - /** @example user@example.com */ - email: string; - }; - }; - }; - responses: { - /** @description Email validation result */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - valid?: boolean; - reason?: string; - }; - }; - }; - 201: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Bad request - invalid email or API error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - error?: string; - }; - }; - }; - }; - }; - HealthController_health: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Health check endpoint */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - /** - * @example { - * "status": "ok" - * } - */ - "application/json": { - /** @example ok */ - status?: string; - }; - }; - }; - }; - }; - BillingController_createSubscription: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": { - items: { - price_id?: string; - quantity?: number; - }[]; - customer_email?: string; - custom_data?: { - orgId?: string; - userId?: string; - }; - billing_details?: { - enable_checkout?: boolean; - }; - }; - }; - }; - responses: { - /** @description Subscription created successfully. */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": Record; - }; - }; - }; - }; - StatsController_getMarketingStats: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Public marketing statistics */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @example 150000 */ - total_count?: number; - }; - }; - }; - }; - }; -} From 3e5f439b522b22d13f17a9d78e98c61377a576b8 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Fri, 12 Jun 2026 18:00:58 +0100 Subject: [PATCH 5/5] ci: fix dcd sparse checkout under git 2.51 cone-mode validation The self-hosted runner's git upgrade made cone-mode sparse-checkout hard-error on file patterns ("'api/swagger.json' is not a directory"), breaking every CI run since June 10 with no repo change. Switch the dcd checkout to non-cone patterns, which support file paths. Co-Authored-By: Claude Fable 5 --- .github/workflows/cli-ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index 623dab2..d524c23 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -26,9 +26,12 @@ jobs: repository: moropo-com/dcd path: dcd ssh-key: ${{ secrets.DCD_SSH_DEPLOY_KEY }} + # api/swagger.json is a file, which cone-mode sparse checkout rejects + # as of git 2.51 ("is not a directory") — use non-cone patterns. + sparse-checkout-cone-mode: false sparse-checkout: | - mock-api - api/swagger.json + /mock-api/ + /api/swagger.json - name: Setup pnpm uses: pnpm/action-setup@v4