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
5 changes: 5 additions & 0 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ No stable dcd release is available yet.
}
}

# Only a release version goes into the download URLs below.
if ($version -cnotmatch '^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?\z') {
throw "Invalid version '$version'. Use a release version such as 5.0.0 or 5.0.0-beta.1."
}

$url = "$DownloadBase/download/$version/$asset"
$sumsUrl = "$DownloadBase/download/$version/SHA256SUMS"

Expand Down
14 changes: 14 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@ no_stable_release_err() {
exit 1
}

# A release version: 5.0.0 or 5.0.0-beta.1 (a leading v is fine). The case
# check comes first because grep matches line by line, so it would accept a
# value with a newline in it on its first line alone.
valid_version() {
case "$1" in
'' | *[!0-9A-Za-z.-]*) return 1 ;;
esac
printf '%s\n' "$1" | grep -Eq '^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'
}

# Find a dcd on PATH other than the one we just installed — usually a leftover
# `npm install -g @devicecloud.dev/dcd` that can shadow this binary. Runs in a
# subshell so the temporary IFS change never leaks back to the caller.
Expand Down Expand Up @@ -224,6 +234,10 @@ main() {
detail "${CYAN}${version}${RESET} ${DIM}(latest ${channel})${RESET}"
fi

# Only a release version goes into the download URLs below.
valid_version "$version" \
|| err "Invalid version '$version'. Use a release version such as 5.0.0 or 5.0.0-beta.1."

url="$DOWNLOAD_BASE/download/${version}/${asset}"
sums_url="$DOWNLOAD_BASE/download/${version}/SHA256SUMS"

Expand Down
13 changes: 11 additions & 2 deletions src/commands/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import { jsonFlag } from '../config/flags/output.flags.js';
import {
type DownloadResult,
ReportDownloadService,
reportFormatDeprecation,
} from '../services/report-download.service.js';
import { resolveAuth } from '../utils/auth.js';
import { CliError, logger, validateEnum } from '../utils/cli.js';
import { resolveApiUrl } from '../utils/config-store.js';
import { ui } from '../utils/ui.js';

const DOWNLOAD_OPTIONS = ['ALL', 'FAILED'] as const;
const REPORT_OPTIONS = ['allure', 'html', 'html-detailed', 'junit'] as const;
Expand Down Expand Up @@ -43,12 +45,12 @@ export const artifactsCommand = defineCommand({
report: {
type: 'string',
description:
'Download a test report in the specified format (options: allure, html, html-detailed, junit)',
'Download a test report in the specified format (options: junit, html, html-detailed, allure). allure and html-detailed are deprecated and will be removed on 7 December 2026.',
},
'allure-path': {
type: 'string',
description:
'Custom file path for downloaded Allure report (default: ./report.html)',
'Custom file path for downloaded Allure report (default: ./report.html). Deprecated: Allure reports will be removed on 7 December 2026.',
},
'html-path': {
type: 'string',
Expand Down Expand Up @@ -85,6 +87,13 @@ export const artifactsCommand = defineCommand({
// Success lines are dropped under --json; warnings stay on stderr.
const log = json ? undefined : (m: string) => logger.log(m);

// This command fetches no notices and submits nothing, so neither the
// deprecation notice nor the API's submit warning reaches it.
const deprecation = reportFormatDeprecation(report);
if (deprecation) {
process.stderr.write(`${ui.deprecation(deprecation)}\n`);
}

const auth = await resolveAuth({ apiKeyFlag });

if (downloadArtifacts && report) {
Expand Down
3 changes: 3 additions & 0 deletions src/commands/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,9 @@ export const cloudCommand = defineCommand({
// As requested ('default' when not passed), not as the API resolves
// it: the gpu1 retirement notice is aimed at runs that ask for gpu1.
runner_type: runnerType,
// As passed (undefined without --report): the allure and
// html-detailed retirement notices are gated on it.
report,
cli_version: cliVersion,
ci_provider: ciContext.provider,
ci_wrapper_version: ciContext.wrapperVersion,
Expand Down
49 changes: 47 additions & 2 deletions src/commands/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import { resolve as resolvePath } from 'node:path';

import { apiFlags } from '../config/flags/api.flags.js';
import { jsonFlag } from '../config/flags/output.flags.js';
import { DeviceApiError, DeviceGateway } from '../gateways/device-gateway.js';
import {
DeviceApiError,
DeviceGateway,
START_RECOVERY_WINDOW_MS,
startOutcomeUnknown,
} from '../gateways/device-gateway.js';
import type { AuthContext } from '../types/domain/auth.types.js';
import {
type DeviceCatalogue,
Expand Down Expand Up @@ -204,6 +209,39 @@ async function resolveApp(
return { binaryUploadId };
}

/**
* A start that got no answer, or a proxy's 5xx in the API's place, may still
* have made a session, which is billed while it runs. Someone who just runs
* `dcd device start` again would have two. So name the sessions started in
* the last couple of minutes, and say to check before starting again.
*/
async function failedStartError(ctx: Ctx, error: unknown): Promise<DeviceApiError> {
const reason = error instanceof Error ? error.message : String(error);
const status = error instanceof DeviceApiError ? error.status : 502;
const head =
'Starting the session failed, but the request may still have started a session, and a session is billed while it runs.';
// What to do first: a network error's message runs to a dozen lines.
const failed = (advice: string, session: DeviceSession | null = null) =>
new DeviceApiError(`${head} ${advice}\n\nThe error: ${reason}`, status, null, session);
const minutes = START_RECOVERY_WINDOW_MS / 60_000;
const recent = await DeviceGateway.recentlyStarted(ctx.apiUrl, ctx.auth);
if (recent === null) {
return failed('Check for one with `dcd device list` before you start another.');
}

if (recent.length === 0) {
return failed(`No session was started in the last ${minutes} minutes, so it is safe to start one again.`);
}

const listed = recent
.map((s) => `${s.id} (${s.client_label ?? 'no label'}, ${s.state}, started ${s.created_at})`)
.join(', ');
return failed(
`Open sessions started in the last ${minutes} minutes: ${listed}. If one is yours, use it instead of starting another; check it with \`dcd device status <id>\`. Otherwise stop it with \`dcd device stop <id>\`.`,
recent.length === 1 ? recent[0] : null,
);
}

function parsePositiveInt(value: unknown, flag: string): number | undefined {
if (value === undefined || value === '') return undefined;
const n = Number(value);
Expand Down Expand Up @@ -353,7 +391,14 @@ const startSub = defineCommand({
if (ttl) body.maxDurationMin = ttl;

ctx.progress(ui.running(`Starting ${platform} device session…`));
let { session } = await DeviceGateway.start(ctx.apiUrl, ctx.auth, body);
let session: DeviceSession;
try {
({ session } = await DeviceGateway.start(ctx.apiUrl, ctx.auth, body));
} catch (error) {
if (!startOutcomeUnknown(error)) throw error;
throw await failedStartError(ctx, error);
}

ctx.progress(ui.success(`Session ${session.id} started`));

// The session exists and bills from here on: every later failure must
Expand Down
4 changes: 2 additions & 2 deletions src/config/flags/output.flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const outputFlags = {
'allure-path': {
type: 'string',
description:
'Custom file path for downloaded Allure report (requires --report allure, default: ./report.html)',
'Custom file path for downloaded Allure report (requires --report allure, default: ./report.html). Deprecated: Allure reports will be removed on 7 December 2026.',
},
'html-path': {
type: 'string',
Expand Down Expand Up @@ -79,6 +79,6 @@ export const outputFlags = {
type: 'string',
alias: ['format'],
description:
'Generate and download test reports in the specified format (options: allure, junit, html, html-detailed). Use "allure" for a complete HTML report.',
'Generate and download test reports in the specified format (options: junit, html, html-detailed, allure). allure and html-detailed are deprecated and will be removed on 7 December 2026: from that date html-detailed gives the standard HTML report and allure gives none, and runs still run.',
},
} as const satisfies ArgsDef;
45 changes: 43 additions & 2 deletions src/gateways/device-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ import { ApiGateway } from './api-gateway.js';
*/
export class DeviceApiError extends Error {
code: null | string;
/**
* The API itself answered (its `{ statusCode, message }` envelope), not a
* proxy in front of it. A proxy's 502 can come after the API did the work.
*/
fromApi: boolean;
session: DeviceSession | null;
status: number;

Expand All @@ -32,23 +37,39 @@ export class DeviceApiError extends Error {
status: number,
code: null | string,
session: DeviceSession | null,
fromApi = false,
) {
super(message);
this.name = 'DeviceApiError';
this.status = status;
this.code = code;
this.session = session;
this.fromApi = fromApi;
}
}

/** How recent an open session must be to count as one a failed start may have made. */
export const START_RECOVERY_WINDOW_MS = 2 * 60 * 1000;

/**
* Whether a failed start may still have made a session: no answer arrived
* (the connection failed, or the reply was unreadable), or a proxy answered
* with a 5xx in the API's place. The API's own errors are refusals made
* before any session exists.
*/
export function startOutcomeUnknown(error: unknown): boolean {
if (!(error instanceof DeviceApiError)) return true;
return error.status >= 500 && !error.fromApi;
}

/**
* The API's error envelope: `{ statusCode, message, error? }`, where `error`
* is either Nest's status text or a machine code such as LIVE_NOT_FOUND.
* Only an upper-case code is kept, so "Not Found" never reads as one.
*/
async function toDeviceApiError(res: Response, path: string): Promise<DeviceApiError> {
const text = await res.text();
let body: { error?: unknown; message?: unknown; session?: unknown } = {};
let body: { error?: unknown; message?: unknown; session?: unknown; statusCode?: unknown } = {};
try {
body = JSON.parse(text);
} catch {
Expand Down Expand Up @@ -76,7 +97,10 @@ async function toDeviceApiError(res: Response, path: string): Promise<DeviceApiE

const session =
body.session && typeof body.session === 'object' ? (body.session as DeviceSession) : null;
return new DeviceApiError(message, res.status, code, session);
// Nest's envelope always has a numeric statusCode. Railway's proxy answers
// `{ status, code, message }`, or a page that isn't JSON at all.
const fromApi = typeof body.statusCode === 'number';
return new DeviceApiError(message, res.status, code, session, fromApi);
}

async function request<T>(
Expand Down Expand Up @@ -211,6 +235,23 @@ export const DeviceGateway = {
return request(baseUrl, auth, 'GET', '/device/models');
},

/**
* The open sessions started in the last START_RECOVERY_WINDOW_MS: the ones
* a start that got no answer may have made. Null when they can't be read.
*/
async recentlyStarted(
baseUrl: string,
auth: AuthContext,
now = Date.now(),
): Promise<DeviceSession[] | null> {
try {
const { sessions } = await DeviceGateway.list(baseUrl, auth);
return sessions.filter((s) => now - Date.parse(s.created_at) <= START_RECOVERY_WINDOW_MS);
} catch {
return null;
}
},

registerBundle(
baseUrl: string,
auth: AuthContext,
Expand Down
13 changes: 3 additions & 10 deletions src/mcp/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,10 @@ import {
type Tool,
} from '@modelcontextprotocol/sdk/types.js';

import { DeviceGateway } from '../gateways/device-gateway.js';
import { DeviceGateway, START_RECOVERY_WINDOW_MS } from '../gateways/device-gateway.js';
import { processDependencies, readTestYamlFileAsJson } from '../services/execution-plan.utils.js';
import { computeCommonRoot } from '../services/flow-paths.js';
import type { AuthContext } from '../types/domain/auth.types.js';
import type { DeviceSession } from '../types/domain/device-session.types.js';
import { uploadLocalApp } from '../utils/app-upload.js';
import { getCliVersion } from '../utils/cli.js';
import { uploadFlowBundle } from '../utils/device-bundle.js';
Expand Down Expand Up @@ -165,9 +164,6 @@ export async function resolveLocalInputs(
return args;
}

/** How recent an open session must be to count as one a failed start may have made. */
export const START_RECOVERY_WINDOW_MS = 2 * 60 * 1000;

/**
* What to tell the agent when a start_device call failed without an answer
* (the connection dropped, or the server never replied). The request may
Expand All @@ -181,11 +177,8 @@ export async function explainFailedStart(
now = Date.now(),
): Promise<string> {
const head = `start_device failed: ${message}. The request may still have started a session, and a session is billed while it runs.`;
let recent: DeviceSession[];
try {
const { sessions } = await DeviceGateway.list(ctx.apiUrl, ctx.auth);
recent = sessions.filter((s) => now - Date.parse(s.created_at) <= START_RECOVERY_WINDOW_MS);
} catch {
const recent = await DeviceGateway.recentlyStarted(ctx.apiUrl, ctx.auth, now);
if (recent === null) {
return `${head} Call list_devices to check for one before you call start_device again.`;
}

Expand Down
4 changes: 3 additions & 1 deletion src/mcp/tools/download-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ export function registerDownloadArtifacts(server: McpServer): void {
report: z
.enum(['junit', 'allure', 'html'])
.optional()
.describe('Also download a formatted report of this type'),
.describe(
'Also download a formatted report of this type. allure is deprecated and will be removed on 7 December 2026; prefer junit or html.',
),
reportPath: z
.string()
.optional()
Expand Down
24 changes: 24 additions & 0 deletions src/services/report-download.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,30 @@ export interface ReportDownloadOptions extends DownloadOptions {
reportType: 'allure' | 'html' | 'html-detailed' | 'junit';
}

/**
* The report formats retired on 7 December 2026, and what each one
* does from that date. `dcd cloud` users are told by the API's submit message
* and a notice; `dcd artifacts` fetches neither, so it prints this itself.
* The values stay valid after the date: a run that asks for one must not fail.
*/
export function reportFormatDeprecation(
report: string | undefined,
): string | undefined {
switch (report) {
case 'allure': {
return '--report allure is deprecated and will be removed on 7 December 2026. From that date runs get no Allure report. Use --report html or --report junit instead.';
}

case 'html-detailed': {
return '--report html-detailed is deprecated and will be removed on 7 December 2026. From that date it gives the standard HTML report. Use --report html instead.';
}

default: {
return undefined;
}
}
}

/** What one download attempt did; failures are warned about, not thrown. */
export interface DownloadResult {
error?: string;
Expand Down
26 changes: 26 additions & 0 deletions test/integration/artifacts.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,32 @@ describe('Artifacts Command Integration Tests', () => {
});
});

// This command fetches no notices, so it prints the deprecation
// itself. On stderr, before any download is tried.
describe('report format deprecation', () => {
it('warns that --report allure goes on 7 December 2026', async () => {
const { stderr } = await run(
`--upload-id ${mockUploadId} --report allure --api-key ${mockApiKey} --api-url ${DEAD_API_URL}`,
);
expect(stderr).to.include('--report allure is deprecated');
expect(stderr).to.include('7 December 2026');
});

it('warns about --report html-detailed', async () => {
const { stderr } = await run(
`--upload-id ${mockUploadId} --report html-detailed --api-key ${mockApiKey} --api-url ${DEAD_API_URL}`,
);
expect(stderr).to.include('--report html-detailed is deprecated');
});

it('says nothing for --report junit', async () => {
const { stderr } = await run(
`--upload-id ${mockUploadId} --report junit --api-key ${mockApiKey} --api-url ${DEAD_API_URL}`,
);
expect(stderr).to.not.include('deprecated');
});
});

describe('help', () => {
it('should display help with all expected flags', async () => {
const { stdout } = await exec(`${CLI} artifacts --help`, {
Expand Down
Loading
Loading