Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,23 @@ try {
Invoke-WebRequest -Uri $url -OutFile $tmp -UseBasicParsing

# --- verify checksum ---
$sums = (Invoke-WebRequest -Uri $sumsUrl -UseBasicParsing).Content
# GitHub serves SHA256SUMS as application/octet-stream, so under
# -UseBasicParsing on Windows PowerShell 5.x .Content comes back as a
# Byte[] (not a string) and -split would never match. Decode to UTF-8 text.
$sumsResp = Invoke-WebRequest -Uri $sumsUrl -UseBasicParsing
$sums = if ($sumsResp.Content -is [byte[]]) {
[System.Text.Encoding]::UTF8.GetString($sumsResp.Content)
} else {
[string]$sumsResp.Content
}
$expected = ($sums -split "`n" |
Where-Object { $_ -match "^([a-f0-9]{64})\s+$([regex]::Escape($asset))\s*$" } |
ForEach-Object { $matches[1] } |
Select-Object -First 1)
if (-not $expected) { throw "SHA256SUMS has no entry for $asset" }
$actual = (Get-FileHash -Path $tmp -Algorithm SHA256).Hash.ToLower()
if ($expected -ne $actual) {
throw "Checksum mismatch for $asset: expected $expected, got $actual"
throw "Checksum mismatch for ${asset}: expected $expected, got $actual"
}

# --- install ---
Expand Down
21 changes: 20 additions & 1 deletion scripts/build-binaries.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ import { fileURLToPath } from 'node:url';
const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const outDir = join(repoRoot, 'dist-bin');

// The compiled binary can't read package.json off disk (it isn't bundled), so
// stamp the version in at compile time via `bun --define`. getCliVersion()
// prefers this constant and falls back to reading package.json on the npm path.
const { version } = JSON.parse(
readFileSync(join(repoRoot, 'package.json'), 'utf8'),
);

// Each entry maps a Bun cross-compile target to the GitHub Release asset name.
// outName excludes `.exe` because Bun appends it automatically for windows targets.
const targets = [
Expand All @@ -41,7 +48,19 @@ for (const { target, outName, asset } of targets) {
console.log(`→ ${target}`);
execFileSync(
'bun',
['build', '--compile', `--target=${target}`, 'src/index.ts', '--outfile', out],
[
'build',
'--compile',
`--target=${target}`,
// bun wants the space-separated `--define KEY=value` form (the colon form
// `--define:KEY=value` silently no-ops). JSON.stringify supplies the
// surrounding quotes bun expects for a string-literal replacement.
'--define',
`__DCD_CLI_VERSION__=${JSON.stringify(version)}`,
'src/index.ts',
'--outfile',
out,
],
{ cwd: repoRoot, stdio: 'inherit' },
);
const produced = join(outDir, asset);
Expand Down
50 changes: 41 additions & 9 deletions src/commands/cloud.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable complexity */
import { defineCommand } from 'citty';
import { existsSync } from 'node:fs';
import * as path from 'node:path';

import { flags as allFlags } from '../constants.js';
Expand Down Expand Up @@ -36,6 +37,7 @@ import { isCI } from '../utils/ci.js';
import {
CliError,
coerceArray,
collectRepeatedFlag,
getCliVersion,
getUpgradeCommand,
logger,
Expand Down Expand Up @@ -109,7 +111,7 @@ export const cloudCommand = defineCommand({
},
},
// eslint-disable-next-line complexity
async run({ args }) {
async run({ args, rawArgs }) {
const cliVersion = getCliVersion();
const deviceValidationService = new DeviceValidationService();
const moropoService = new MoropoService();
Expand All @@ -119,12 +121,16 @@ export const cloudCommand = defineCommand({
const versionService = new VersionService();

const versionCheck = async () => {
const latestVersion = await versionService.checkLatestCliVersion();
if (latestVersion && versionService.isOutdated(cliVersion, latestVersion)) {
const result = await versionService.checkLatestCliVersion(cliVersion);
if (
result.ok &&
result.version &&
versionService.isOutdated(cliVersion, result.version)
) {
out(ui.warn(colors.bold('Update available')));
out(
ui.branch([
`A new version of the DeviceCloud CLI is available: ${colors.highlight(latestVersion)}`,
`A new version of the DeviceCloud CLI is available: ${colors.highlight(result.version)}`,
`${colors.dim('Run:')} ${colors.info(getUpgradeCommand())}`,
]),
);
Expand Down Expand Up @@ -165,18 +171,23 @@ export const cloudCommand = defineCommand({
'download-artifacts',
);
const dryRun = Boolean(args['dry-run']);
const env = coerceArray(args.env as string | string[] | undefined, false);
// Repeatable flags are collected from rawArgs: citty/parseArgs only keeps
// the last occurrence, so reading args.* directly drops earlier values.
const env = coerceArray(
collectRepeatedFlag(rawArgs, ['--env', '-e']),
false,
);
const excludeFlows = coerceArray(
args['exclude-flows'] as string | string[] | undefined,
collectRepeatedFlag(rawArgs, ['--exclude-flows']),
);
const excludeTags = coerceArray(
args['exclude-tags'] as string | string[] | undefined,
collectRepeatedFlag(rawArgs, ['--exclude-tags']),
);
let flows = args.flows as string | undefined;
const googlePlay = Boolean(args['google-play']);
const ignoreShaCheck = Boolean(args['ignore-sha-check']);
const includeTags = coerceArray(
args['include-tags'] as string | string[] | undefined,
collectRepeatedFlag(rawArgs, ['--include-tags']),
);
const iOSDevice = validateEnum(
args['ios-device'] as string | undefined,
Expand Down Expand Up @@ -204,7 +215,7 @@ export const cloudCommand = defineCommand({
const jsonFileName = args['json-file-name'] as string | undefined;
const maestroVersion = args['maestro-version'] as string | undefined;
const metadata = coerceArray(
args.metadata as string | string[] | undefined,
collectRepeatedFlag(rawArgs, ['--metadata', '-m']),
false,
);
const mitmHost = args.mitmHost as string | undefined;
Expand Down Expand Up @@ -437,6 +448,14 @@ export const cloudCommand = defineCommand({
out(`[DEBUG] Found .app bundle at: ${finalAppFile}`);
}
}

// Validate the resolved local app file early — dry-run otherwise skips
// the upload that would surface a missing file, so a typo'd path would
// pass a dry-run and only fail on the real run. (URL/.tar.gz inputs are
// already resolved to existing temp paths by this point.)
if (!existsSync(finalAppFile)) {
throw new CliError(`App file does not exist: ${finalAppFile}`);
}
}

if (debug) {
Expand Down Expand Up @@ -619,14 +638,27 @@ export const cloudCommand = defineCommand({
]);
// Only log canonical flag keys (skip citty-populated alias duplicates like apiURL/apiUrl).
const canonicalFlagKeys = new Set(Object.keys(allFlags));
// Repeatable flags are recovered from rawArgs (args.* only holds the last
// occurrence), so echo the fully-collected values rather than args.*.
const repeatableDisplay: Record<string, string[]> = {
env,
metadata,
'include-tags': includeTags,
'exclude-tags': excludeTags,
'exclude-flows': excludeFlows,
};
for (const [k, v] of Object.entries(args)) {
if (!canonicalFlagKeys.has(k)) continue;
if (k in repeatableDisplay) continue;
if (v === undefined || v === null || v === false) continue;
const asString = String(v);
if (asString.length > 0 && !sensitiveFlags.has(k)) {
flagLogs.push(`${k}: ${asString}`);
}
}
for (const [k, values] of Object.entries(repeatableDisplay)) {
if (values.length > 0) flagLogs.push(`${k}: ${values.join(', ')}`);
}

const overridesEntries = Object.entries(flowOverrides);
const hasOverrides = overridesEntries.some(
Expand Down
11 changes: 8 additions & 3 deletions src/commands/list.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineCommand } from 'citty';

import { apiFlags } from '../config/flags/api.flags.js';
import { resolveFrontendUrl } from '../config/environments.js';
import { ApiGateway } from '../gateways/api-gateway.js';
import { resolveAuth } from '../utils/auth.js';
import { CliError, logger, parseIntFlag } from '../utils/cli.js';
Expand Down Expand Up @@ -41,8 +42,12 @@ function detectShellExpansion(name: string): void {
}
}

function displayResults(response: ListResponse): void {
function displayResults(response: ListResponse, apiUrl: string): void {
const { uploads, total, limit, offset } = response;
// Build console links from the env the CLI is pointed at, rather than the
// API-supplied consoleUrl (which is hardcoded to prod) — so dev/staging users
// get links that actually resolve.
const frontendUrl = resolveFrontendUrl(apiUrl);

if (uploads.length === 0) {
logger.log(ui.info('No uploads found matching your criteria.'));
Expand Down Expand Up @@ -70,7 +75,7 @@ function displayResults(response: ListResponse): void {
...ui.fields([
['id', formatId(upload.id)],
['created', formattedDate],
['console', formatUrl(upload.consoleUrl)],
['console', formatUrl(`${frontendUrl}/results?upload=${upload.id}`)],
]),
]),
);
Expand Down Expand Up @@ -169,7 +174,7 @@ export const listCommand = defineCommand({
return;
}

displayResults(response);
displayResults(response, apiUrl);
} catch (error) {
throw new CliError(
`Failed to list uploads: ${(error as Error).message}`,
Expand Down
11 changes: 9 additions & 2 deletions src/commands/status.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineCommand } from 'citty';

import { apiFlags } from '../config/flags/api.flags.js';
import { resolveFrontendUrl } from '../config/environments.js';
import { ApiGateway } from '../gateways/api-gateway.js';
import { formatDurationSeconds } from '../methods.js';
import { resolveAuth } from '../utils/auth.js';
Expand Down Expand Up @@ -248,8 +249,14 @@ async function statusMain({
if (status.createdAt) {
fields.push(['created', formatDateTime(status.createdAt)]);
}
if (status.consoleUrl) {
fields.push(['console', formatUrl(status.consoleUrl)]);
// Prefer a console link built from the env the CLI is pointed at (the
// API-supplied consoleUrl is hardcoded to prod, so it misdirects
// dev/staging users); fall back to the API value if we have no uploadId.
const consoleUrl = status.uploadId
? `${resolveFrontendUrl(apiUrl)}/results?upload=${status.uploadId}`
: status.consoleUrl;
if (consoleUrl) {
fields.push(['console', formatUrl(consoleUrl)]);
}

logger.log(ui.section('Upload Status'));
Expand Down
21 changes: 17 additions & 4 deletions src/commands/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,24 @@ export const upgradeCommand = defineCommand({

const current = getCliVersion();
const versionService = new VersionService();
const latest = await versionService.checkLatestCliVersion();
const result = await versionService.checkLatestCliVersion(current);

if (!latest) {
if (!result.ok) {
throw new CliError(
'Could not reach the update manifest. Check your network connection and try again.',
`Could not reach the update manifest (${result.error}). Check your network connection and try again.`,
);
}

// Reachable, but nothing published on this channel yet (e.g. a stable
// install while only betas exist). Not an error — just nothing to do.
if (result.version === null) {
logger.log(
ui.info(`No newer release available on the ${result.channel} channel.`),
);
return;
}

const latest = result.version;
if (!versionService.isOutdated(current, latest)) {
logger.log(
ui.success(`Already on the latest version (${colors.highlight(current)})`),
Expand All @@ -85,8 +95,11 @@ export const upgradeCommand = defineCommand({
if (process.platform === 'win32') {
// Windows can't replace a running .exe; defer to a re-run of the installer.
const base = process.env.DCD_DOWNLOAD_BASE ?? DEFAULT_DOWNLOAD_BASE;
// Prerelease users need the beta channel opt-in or the installer resolves
// the (currently non-existent) stable release.
const betaHint = result.channel === 'beta' ? '$env:DCD_BETA=1; ' : '';
throw new CliError(
`Automatic upgrade on Windows is not yet supported. Re-run the installer:\n irm ${base}/install.ps1 | iex`,
`Automatic upgrade on Windows is not yet supported. Re-run the installer:\n ${betaHint}irm ${base}/install.ps1 | iex`,
);
}

Expand Down
10 changes: 10 additions & 0 deletions src/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Build-time constants injected by `bun --define` (see
* scripts/build-binaries.mjs). Declared as an ambient global — this file must
* stay free of top-level import/export or it stops being a global declaration.
*
* `__DCD_CLI_VERSION__` holds the package version stamped into the compiled
* standalone binary. On the npm/tsx path it is never defined; `getCliVersion()`
* guards every read with `typeof`.
*/
declare const __DCD_CLI_VERSION__: string | undefined;
20 changes: 8 additions & 12 deletions src/methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -924,8 +924,9 @@ async function uploadToBackblaze(
console.error(`[DEBUG] Backblaze upload failed with status ${response.status}: ${errorText}`);
}

// Don't throw - we don't want Backblaze failures to block the primary upload
console.warn(`Warning: Backblaze upload failed with status ${response.status}`);
// Don't throw and don't warn — Backblaze is the primary attempt and the
// Supabase fallback usually recovers. A user-facing error is raised only
// if every strategy fails (see validateUploadResults).
return false;
}

Expand All @@ -950,13 +951,10 @@ async function uploadToBackblaze(
if (debug) {
console.error('[DEBUG] Network error detected - could be DNS, connection, or SSL issue');
}

console.warn('Warning: Backblaze upload failed due to network error');
} else {
// Don't throw - we don't want Backblaze failures to block the primary upload
console.warn(`Warning: Backblaze upload failed: ${error instanceof Error ? error.message : String(error)}`);
}

// Don't throw and don't warn — the Supabase fallback usually recovers, and
// validateUploadResults raises a user-facing error only if all fail.
return false;
}
}
Expand Down Expand Up @@ -1088,11 +1086,9 @@ function logBackblazeUploadError(error: unknown, debug: boolean): void {
}
}

if (error instanceof Error && error.message.includes('network error')) {
console.warn('Warning: Backblaze large file upload failed due to network error');
} else {
console.warn(`Warning: Backblaze large file upload failed: ${error instanceof Error ? error.message : String(error)}`);
}
// No user-facing warning: Backblaze is the primary attempt and the Supabase
// fallback usually recovers; validateUploadResults raises the only
// user-facing error, and only when every strategy fails.
}

/**
Expand Down
16 changes: 14 additions & 2 deletions src/services/results-polling.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { formatDurationSeconds } from '../methods.js';
import type { AuthContext } from '../types/domain/auth.types.js';
import { paths } from '../types/generated/schema.types.js';
import { checkInternetConnectivity } from '../utils/connectivity.js';
import { isCI } from '../utils/ci.js';
import { ux } from '../utils/progress.js';
import { colors, formatTestSummary, statusPalette, table } from '../utils/styling.js';
import { type Field, ui } from '../utils/ui.js';
Expand Down Expand Up @@ -160,8 +161,16 @@ export class ResultsPollingService {
let realtimeEnabled = false;
let statusBody = '';
let nextPollAt: null | number = null;
// The animated footer/countdown only makes sense on a TTY. In CI/pipes it
// would flood logs (a fresh line per frame), so we drop it and let the
// progress adapter print one line per distinct status change instead.
const interactive = !json && !isCI();
const renderStatus = () => {
if (json) return;
if (!interactive) {
ux.action.status = statusBody;
return;
}
const footer = this.buildStatusFooter(
realtimeEnabled,
subscription?.isConnected() ?? false,
Expand Down Expand Up @@ -201,8 +210,11 @@ export class ResultsPollingService {

// Tick the live footer once a second so the countdown actually counts down
// (the spinner's own frames don't recompute our message). Unref'd so it
// never keeps the process alive on its own.
const ticker: NodeJS.Timeout | null = json ? null : setInterval(renderStatus, 1000);
// never keeps the process alive on its own. Only on an interactive TTY —
// a 1s ticker in CI would reprint the status every second.
const ticker: NodeJS.Timeout | null = interactive
? setInterval(renderStatus, 1000)
: null;
ticker?.unref?.();

try {
Expand Down
Loading
Loading