From 81009e4fbc14a88a5dd5e57fb3e0843e10c3a390 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Mon, 19 Oct 2026 10:30:14 +0100 Subject: [PATCH] feat(device): add dcd device start, list, status, stop, install, launch and models The first stages of the Agent Devices CLI (dcd plan 3.1 and 3.2). - --json prints one envelope, {ok, session, data, error}, on success and on failure. - Exit codes: 0 ok, 1 usage, 2 failed, 3 limit, 4 session ended or not found. - The session id may be left out when exactly one session is open. - start --app uploads a local build first; an https URL goes to the API's install-from-URL. - Progress goes to stderr, with no spinner unless at a terminal. Hidden from dcd --help until the API reaches production. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA --- src/commands/device.ts | 574 +++++++++++++++++++++++ src/gateways/device-gateway.ts | 153 ++++++ src/index.ts | 2 + src/types/domain/device-session.types.ts | 85 ++++ src/utils/app-upload.ts | 84 ++++ src/utils/device-output.ts | 197 ++++++++ test/unit/device-command.test.ts | 326 +++++++++++++ 7 files changed, 1421 insertions(+) create mode 100644 src/commands/device.ts create mode 100644 src/gateways/device-gateway.ts create mode 100644 src/types/domain/device-session.types.ts create mode 100644 src/utils/app-upload.ts create mode 100644 src/utils/device-output.ts create mode 100644 test/unit/device-command.test.ts diff --git a/src/commands/device.ts b/src/commands/device.ts new file mode 100644 index 0000000..a989870 --- /dev/null +++ b/src/commands/device.ts @@ -0,0 +1,574 @@ +import { defineCommand } from 'citty'; +import { existsSync } from 'node:fs'; + +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 type { AuthContext } from '../types/domain/auth.types.js'; +import { + type DeviceCatalogue, + type DeviceCommandResult, + type DeviceInstalledApp, + type DeviceSession, + ENDED_STATES, + type StartDeviceSessionRequest, +} from '../types/domain/device-session.types.js'; +import { platformOfAppFile, uploadLocalApp } from '../utils/app-upload.js'; +import { resolveAuth } from '../utils/auth.js'; +import { CliError, logger, validateEnum } from '../utils/cli.js'; +import { resolveApiUrl } from '../utils/config-store.js'; +import { + deviceProgress, + printSession, + resolveSessionId, + runDeviceCommand, +} from '../utils/device-output.js'; +import { isUrl } from '../utils/expo.js'; +import { colors } from '../utils/styling.js'; +import { ui } from '../utils/ui.js'; + +const PLATFORMS = ['android', 'ios'] as const; +/** What `--name` defaults to: shown in the console and the session timeline. */ +const DEFAULT_CLIENT_LABEL = 'dcd-cli'; +const WAIT_POLL_MS = 2000; +/** Longer than the API's own queue timeout, so that one is what ends a wait. */ +const WAIT_TIMEOUT_MS = 20 * 60 * 1000; +/** States in which a session takes commands. */ +const USABLE_STATES = new Set(['active', 'needs_human', 'ready']); + +/** Every `dcd device` subcommand takes these. */ +const commonArgs = { ...apiFlags, json: jsonFlag } as const; + +const idArg = { + type: 'positional', + required: false, + description: 'Session id, e.g. brave-teal-otter. Optional when exactly one session is open.', +} as const; + +interface Ctx { + apiUrl: string; + auth: AuthContext; + json: boolean; + progress: (message: string) => void; +} + +async function context(args: Record): Promise { + const json = Boolean(args.json); + return { + apiUrl: resolveApiUrl(args['api-url'] as string | undefined), + auth: await resolveAuth({ apiKeyFlag: args['api-key'] as string | undefined }), + json, + progress: deviceProgress(json), + }; +} + +/** A spinner only for a person at a terminal (plan 4.2: none when not a TTY). */ +function interactive(json: boolean): boolean { + return !json && process.stdout.isTTY === true && process.stdin.isTTY === true; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Poll until the session takes commands. Reports each change of state (and + * queue position) as progress. A session that ends while waiting is exit 4. + */ +async function waitUntilUsable(ctx: Ctx, id: string): Promise { + const deadline = Date.now() + WAIT_TIMEOUT_MS; + let last = ''; + for (;;) { + const { session } = await DeviceGateway.status(ctx.apiUrl, ctx.auth, id); + if (USABLE_STATES.has(session.state)) return session; + if (ENDED_STATES.includes(session.state)) { + throw new DeviceApiError( + `Device session ${id} ended while starting (${session.release_reason ?? session.state}).`, + 410, + 'LIVE_SESSION_ENDED', + session, + ); + } + + const position = session.queue?.position; + const line = position ? `${session.state} (position ${position} in the queue)` : session.state; + if (line !== last) { + last = line; + ctx.progress(` ${colors.dim(line)}`); + } + + if (Date.now() >= deadline) { + throw new DeviceApiError( + `Timed out after ${WAIT_TIMEOUT_MS / 60_000} minutes waiting for ${id} (state: ${session.state}). It is still open: check it with \`dcd device status ${id}\`.`, + 408, + null, + session, + ); + } + + await sleep(WAIT_POLL_MS); + } +} + +/** + * Turn `` into what /install takes: a URL goes to the API's + * install-from-URL, a local file is uploaded first. + */ +async function resolveApp( + ctx: Ctx, + app: string, +): Promise<{ binaryUploadId: string } | { url: string }> { + if (isUrl(app)) return { url: app }; + if (!existsSync(app)) throw new CliError(`No such file: ${app}`); + ctx.progress(ui.running(`Uploading ${colors.highlight(app)}…`)); + const binaryUploadId = await uploadLocalApp({ + apiUrl: ctx.apiUrl, + auth: ctx.auth, + filePath: app, + log: ctx.progress, + showProgress: interactive(ctx.json), + }); + return { binaryUploadId }; +} + +function parsePositiveInt(value: unknown, flag: string): number | undefined { + if (value === undefined || value === '') return undefined; + const n = Number(value); + if (!Number.isInteger(n) || n <= 0) { + throw new CliError(`--${flag} must be a whole number of minutes, got "${String(value)}".`); + } + + return n; +} + +const startSub = defineCommand({ + meta: { name: 'start', description: 'Start a device session' }, + args: { + ...commonArgs, + platform: { + type: 'string', + description: 'ios or android. Optional with --app, which implies it', + }, + model: { + type: 'string', + description: 'Device model, e.g. iphone-16 or pixel-10 (see `dcd device models`)', + }, + os: { + type: 'string', + description: 'iOS major version or Android API level, e.g. 18 or 35', + }, + 'google-play': { + type: 'boolean', + description: '[Android] An image with Google Play services', + }, + app: { + type: 'string', + description: 'A build to install: a local .apk/.app/.zip/.tar.gz (uploaded first) or an https URL', + }, + 'app-binary-id': { + type: 'string', + description: 'An already uploaded build to install', + }, + locale: { + type: 'string', + description: 'Device locale, e.g. de_DE', + }, + name: { + type: 'string', + description: `Who is driving the session, shown in the console (default: ${DEFAULT_CLIENT_LABEL})`, + }, + ttl: { + type: 'string', + description: "Longest the session may run, in minutes (default: your org's limit)", + }, + record: { + type: 'boolean', + default: true, + description: 'Record a video of the session (--no-record to turn off)', + }, + wait: { + type: 'boolean', + default: false, + description: 'Wait until the device is ready for commands', + }, + }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + const app = args.app as string | undefined; + const binaryId = args['app-binary-id'] as string | undefined; + if (app && binaryId) throw new CliError('Pass --app or --app-binary-id, not both.'); + + const platform = + validateEnum(args.platform as string | undefined, PLATFORMS, 'platform') ?? + (app && !isUrl(app) ? platformOfAppFile(app) : undefined); + if (!platform) { + throw new CliError('--platform is required (ios or android).'); + } + + const appPlatform = app && !isUrl(app) ? platformOfAppFile(app) : undefined; + if (appPlatform && appPlatform !== platform) { + throw new CliError(`${app} is a build for ${appPlatform}, not ${platform}.`); + } + + if (args['google-play'] && platform !== 'android') { + throw new CliError('--google-play is only for Android.'); + } + + const ctx = await context(args); + const resolved = app ? await resolveApp(ctx, app) : undefined; + const uploadedId = + binaryId ?? (resolved && 'binaryUploadId' in resolved ? resolved.binaryUploadId : undefined); + + const body: StartDeviceSessionRequest = { + platform, + clientLabel: (args.name as string | undefined) || DEFAULT_CLIENT_LABEL, + ...(uploadedId && { binaryUploadId: uploadedId }), + ...(args.model ? { model: args.model as string } : {}), + ...(args.os ? { os: String(args.os) } : {}), + ...(args['google-play'] ? { googlePlay: true } : {}), + ...(args.locale ? { deviceLocale: args.locale as string } : {}), + ...(args.record === false ? { record: false } : {}), + }; + const ttl = parsePositiveInt(args.ttl, 'ttl'); + if (ttl) body.maxDurationMin = ttl; + + ctx.progress(ui.running(`Starting ${platform} device session…`)); + let { session } = await DeviceGateway.start(ctx.apiUrl, ctx.auth, body); + ctx.progress(ui.success(`Session ${session.id} started`)); + + // The session exists and bills from here on: every later failure must + // still name it, so the caller can stop it. + try { + if (resolved && 'url' in resolved) { + ctx.progress(ui.running('Installing the app from its URL…')); + ({ session } = await DeviceGateway.install(ctx.apiUrl, ctx.auth, session.id, resolved)); + } + + if (args.wait) { + ctx.progress(ui.running('Waiting for the device…')); + session = await waitUntilUsable(ctx, session.id); + } + } catch (error) { + if (error instanceof DeviceApiError && error.code === 'LIVE_SESSION_ENDED') throw error; + const message = `${error instanceof Error ? error.message : String(error)} Session ${session.id} is still open: stop it with \`dcd device stop ${session.id}\`.`; + if (error instanceof DeviceApiError) { + throw new DeviceApiError(message, error.status, error.code, error.session ?? session); + } + + throw new DeviceApiError(message, 500, null, session); + } + + return { + session, + data: { binary_upload_id: uploadedId ?? null }, + print() { + logger.log(ui.section('Device session')); + printSession(session); + logger.log(ui.section('Next steps')); + logger.log( + ui.branch( + ui.fields([ + ['inspect the screen', colors.highlight(`dcd device inspect ${session.id}`)], + ['run a flow', colors.highlight(`dcd device run ${session.id} flow.yaml`)], + ['stop', colors.highlight(`dcd device stop ${session.id}`)], + ]), + ), + ); + }, + }; + }); + }, +}); + +const listSub = defineCommand({ + meta: { name: 'list', description: "List your org's open device sessions" }, + args: { + ...commonArgs, + all: { + type: 'boolean', + default: false, + description: 'Include sessions that ended in the last 24 hours', + }, + }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + const ctx = await context(args); + const { sessions } = await DeviceGateway.list(ctx.apiUrl, ctx.auth, Boolean(args.all)); + return { + session: null, + data: { sessions }, + print() { + if (sessions.length === 0) { + logger.log(ui.info(args.all ? 'No device sessions in the last 24 hours.' : 'No open device sessions.')); + return; + } + + logger.log(ui.section(`Device sessions (${sessions.length})`)); + logger.log( + ui.branch( + ui.fields( + sessions.map((s) => [ + colors.highlight(s.id), + [s.state, s.platform, s.model, s.client_label].filter(Boolean).join(' '), + ]), + ), + ), + ); + }, + }; + }); + }, +}); + +const statusSub = defineCommand({ + meta: { name: 'status', description: "A session's state, time left and cost" }, + args: { ...commonArgs, id: idArg }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + const ctx = await context(args); + const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id as string | undefined); + const { session } = await DeviceGateway.status(ctx.apiUrl, ctx.auth, id); + return { + session, + data: null, + print() { + logger.log(ui.section('Device session')); + printSession(session); + }, + }; + }); + }, +}); + +const stopSub = defineCommand({ + meta: { name: 'stop', description: 'Stop a session, or every open one with --all' }, + args: { + ...commonArgs, + id: idArg, + all: { type: 'boolean', default: false, description: "Stop every open session in your org" }, + }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + const ctx = await context(args); + if (args.all) { + if (args.id) throw new CliError('Pass a session id or --all, not both.'); + const { sessions } = await DeviceGateway.list(ctx.apiUrl, ctx.auth); + const stopped: DeviceSession[] = []; + for (const s of sessions) { + stopped.push((await DeviceGateway.stop(ctx.apiUrl, ctx.auth, s.id)).session); + } + + return { + session: null, + data: { stopped }, + print() { + logger.log( + stopped.length === 0 + ? ui.info('No open device sessions to stop.') + : ui.success(`Stopped ${stopped.map((s) => s.id).join(', ')}`), + ); + }, + }; + } + + const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id as string | undefined); + const { session } = await DeviceGateway.stop(ctx.apiUrl, ctx.auth, id); + return { + session, + data: null, + print() { + logger.log(ui.success(`Stopped ${session.id}`)); + printSession(session); + }, + }; + }); + }, +}); + +/** + * `install [id] `: with one positional it is the app when it looks + * like one (a URL, a file that exists, or a build's extension). + */ +function looksLikeApp(value: string): boolean { + return isUrl(value) || existsSync(value) || platformOfAppFile(value) !== undefined; +} + +const installSub = defineCommand({ + meta: { name: 'install', description: 'Install a build: a local file (uploaded first) or an https URL' }, + args: { + ...commonArgs, + id: idArg, + app: { + type: 'positional', + required: false, + description: 'A local .apk/.app/.zip/.tar.gz, or an https URL to an .apk or a zipped .app', + }, + 'app-binary-id': { type: 'string', description: 'An already uploaded build' }, + wait: { + type: 'boolean', + default: false, + description: 'Wait until the device is ready again after installing', + }, + }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + let id = args.id as string | undefined; + let app = args.app as string | undefined; + if (id && !app && looksLikeApp(id)) [id, app] = [undefined, id]; + const binaryId = args['app-binary-id'] as string | undefined; + if (Boolean(app) === Boolean(binaryId)) { + throw new CliError('Give the build: a file path, an https URL, or --app-binary-id.'); + } + + const ctx = await context(args); + id = await resolveSessionId(ctx.apiUrl, ctx.auth, id); + const body = binaryId ? { binaryUploadId: binaryId } : await resolveApp(ctx, app!); + ctx.progress(ui.running(`Installing on ${colors.highlight(id)}…`)); + const installed = await DeviceGateway.install(ctx.apiUrl, ctx.auth, id, body); + let { session } = installed; + if (args.wait) { + ctx.progress(ui.running('Waiting for the device…')); + session = await waitUntilUsable(ctx, id); + } + + const appInfo: DeviceInstalledApp = installed.app; + return { + session, + data: { app: appInfo }, + print() { + logger.log(ui.success(`Installing ${appInfo.app_id ?? appInfo.binary_upload_id}`)); + logger.log( + ui.branch( + ui.fields([ + ['binary id', appInfo.binary_upload_id], + ...(appInfo.app_id ? [['app id', appInfo.app_id] as [string, string]] : []), + ['session', colors.highlight(session.id)], + ]), + ), + ); + if (!args.wait) { + logger.log(ui.note('The device installs it next. Commands answer "not ready" until it is done.')); + } + }, + }; + }); + }, +}); + +/** Bundle ids and package names have dots; session ids never do. */ +function looksLikeAppId(value: string): boolean { + return value.includes('.'); +} + +const launchSub = defineCommand({ + meta: { name: 'launch', description: 'Launch an app: the installed one, or a bundle id / package name' }, + args: { + ...commonArgs, + id: idArg, + appId: { + type: 'positional', + required: false, + description: 'Bundle id or package name (default: the installed app)', + }, + 'clear-state': { + type: 'boolean', + default: false, + description: "Clear the app's data before launching", + }, + }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + let id = args.id as string | undefined; + let appId = args.appId as string | undefined; + if (id && !appId && looksLikeAppId(id)) [id, appId] = [undefined, id]; + + const ctx = await context(args); + id = await resolveSessionId(ctx.apiUrl, ctx.auth, id); + const { result, session } = await DeviceGateway.launch(ctx.apiUrl, ctx.auth, id, { + ...(appId && { appId }), + ...(args['clear-state'] ? { clearState: true } : {}), + }); + return { + session, + data: { result } as { result: DeviceCommandResult }, + ...(!result.success && { + failure: { code: null, message: result.error || 'The app did not launch.' }, + }), + print() { + logger.log( + result.success + ? ui.success(`Launched ${appId ?? 'the app'}`) + : `${ui.statusSymbol('FAILED')} Launch failed`, + ); + if (result.error) logger.log(colors.error(result.error)); + }, + }; + }); + }, +}); + +const modelsSub = defineCommand({ + meta: { name: 'models', description: 'The devices a session can ask for' }, + args: { + ...commonArgs, + platform: { type: 'string', description: 'Only this platform: ios or android' }, + }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + const platform = validateEnum(args.platform as string | undefined, PLATFORMS, 'platform'); + const ctx = await context(args); + const catalogue = await DeviceGateway.models(ctx.apiUrl, ctx.auth); + const data: Partial = platform ? { [platform]: catalogue[platform] } : catalogue; + return { + session: null, + data, + print() { + for (const p of PLATFORMS) { + const models = data[p]; + if (!models) continue; + logger.log(ui.section(p === 'ios' ? 'iOS' : 'Android')); + logger.log( + ui.branch( + ui.fields( + models.map((m) => [ + m.default ? colors.bold(m.model) : m.model, + `${m.name} ${colors.dim(`os ${m.os.join(', ')} (default ${m.default_os})`)}` + + (m.google_play ? colors.dim(' google play') : '') + + (m.default ? colors.dim(' default') : ''), + ]), + ), + ), + ); + } + }, + }; + }); + }, +}); + +export const deviceCommand = defineCommand({ + meta: { + name: 'device', + description: 'Cloud devices you (or an AI agent) can lease, drive and release [beta]', + // Left out of `dcd --help` until the /device/sessions API is in + // production (Agent Devices plan, Phase 8). It works when called by name. + hidden: true, + }, + subCommands: { + start: startSub, + list: listSub, + status: statusSub, + stop: stopSub, + install: installSub, + launch: launchSub, + models: modelsSub, + }, +}); + diff --git a/src/gateways/device-gateway.ts b/src/gateways/device-gateway.ts new file mode 100644 index 0000000..40494fa --- /dev/null +++ b/src/gateways/device-gateway.ts @@ -0,0 +1,153 @@ +import type { AuthContext } from '../types/domain/auth.types.js'; +import type { + DeviceCatalogue, + DeviceCommandResult, + DeviceInstalledApp, + DeviceSession, + StartDeviceSessionRequest, +} from '../types/domain/device-session.types.js'; +import { ApiGateway } from './api-gateway.js'; + +/** + * An error answer from /device/sessions. Unlike ApiError it keeps the API's + * machine code (`LIVE_NOT_FOUND`, `LIVE_SPEND_CAP`, …) and the `session` + * block some errors carry, because `dcd device` maps the code to an exit code + * and prints the session in its JSON envelope. + */ +export class DeviceApiError extends Error { + code: null | string; + session: DeviceSession | null; + status: number; + + constructor( + message: string, + status: number, + code: null | string, + session: DeviceSession | null, + ) { + super(message); + this.name = 'DeviceApiError'; + this.status = status; + this.code = code; + this.session = session; + } +} + +/** + * 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): Promise { + const text = await res.text(); + let body: { error?: unknown; message?: unknown; session?: unknown } = {}; + try { + body = JSON.parse(text); + } catch { + // Not JSON: a proxy page or plain text. The text is the message. + } + + const raw = Array.isArray(body.message) ? body.message.join('; ') : body.message; + let message = typeof raw === 'string' && raw ? raw : text.trim() || `HTTP ${res.status}`; + if (res.status === 401) { + message = + 'Authentication failed — your credentials are invalid or expired. ' + + `Re-run \`dcd login\` or check your API key. (${message})`; + } + + const code = + typeof body.error === 'string' && /^[A-Z][A-Z0-9_]+$/.test(body.error) ? body.error : null; + const session = + body.session && typeof body.session === 'object' ? (body.session as DeviceSession) : null; + return new DeviceApiError(message, res.status, code, session); +} + +async function request( + baseUrl: string, + auth: AuthContext, + method: 'DELETE' | 'GET' | 'POST', + path: string, + body?: unknown, +): Promise { + const url = `${baseUrl}${path}`; + let res: Response; + try { + res = await fetch(url, { + method, + headers: { + ...auth.headers, + // Recorded as the command's source in the session timeline. + 'x-dcd-source': 'cli', + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + } catch (error) { + if (error instanceof TypeError && error.message === 'fetch failed') { + throw ApiGateway.enhanceFetchError(error, url); + } + + throw error; + } + + if (!res.ok) throw await toDeviceApiError(res); + try { + return (await res.json()) as T; + } catch (error) { + throw new Error( + `${method} ${path}: the API returned an invalid JSON response (${error instanceof Error ? error.message : String(error)})`, + { cause: error }, + ); + } +} + +const sessionPath = (id: string) => `/device/sessions/${encodeURIComponent(id)}`; + +/** The Agent Devices session API (dcd docs/device-v1-plan.md, section 4.1). */ +export const DeviceGateway = { + install( + baseUrl: string, + auth: AuthContext, + id: string, + app: { binaryUploadId: string } | { url: string }, + ): Promise<{ app: DeviceInstalledApp; session: DeviceSession }> { + return request(baseUrl, auth, 'POST', `${sessionPath(id)}/install`, app); + }, + + launch( + baseUrl: string, + auth: AuthContext, + id: string, + body: { appId?: string; clearState?: boolean }, + ): Promise<{ result: DeviceCommandResult; session: DeviceSession }> { + return request(baseUrl, auth, 'POST', `${sessionPath(id)}/launch`, body); + }, + + list( + baseUrl: string, + auth: AuthContext, + all = false, + ): Promise<{ sessions: DeviceSession[] }> { + return request(baseUrl, auth, 'GET', `/device/sessions${all ? '?all=true' : ''}`); + }, + + models(baseUrl: string, auth: AuthContext): Promise { + return request(baseUrl, auth, 'GET', '/device/models'); + }, + + start( + baseUrl: string, + auth: AuthContext, + body: StartDeviceSessionRequest, + ): Promise<{ session: DeviceSession }> { + return request(baseUrl, auth, 'POST', '/device/sessions', body); + }, + + status(baseUrl: string, auth: AuthContext, id: string): Promise<{ session: DeviceSession }> { + return request(baseUrl, auth, 'GET', sessionPath(id)); + }, + + stop(baseUrl: string, auth: AuthContext, id: string): Promise<{ session: DeviceSession }> { + return request(baseUrl, auth, 'DELETE', sessionPath(id)); + }, +}; diff --git a/src/index.ts b/src/index.ts index da07bd4..f8041af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { defineCommand, runCommand, showUsage } from 'citty'; import { artifactsCommand } from './commands/artifacts.js'; import { cloudCommand } from './commands/cloud.js'; +import { deviceCommand } from './commands/device.js'; import { listCommand } from './commands/list.js'; import { liveCommand } from './commands/live.js'; import { loginCommand } from './commands/login.js'; @@ -36,6 +37,7 @@ const main = defineCommand({ list: listCommand, status: statusCommand, artifacts: artifactsCommand, + device: deviceCommand, live: liveCommand, login: loginCommand, logout: logoutCommand, diff --git a/src/types/domain/device-session.types.ts b/src/types/domain/device-session.types.ts new file mode 100644 index 0000000..c27daa8 --- /dev/null +++ b/src/types/domain/device-session.types.ts @@ -0,0 +1,85 @@ +// Hand-defined: the /device/sessions routes are not in the swagger spec the +// CLI generates schema.types.ts from. They mirror the API's own types in +// dcd/api/src/apps/live/live.types.ts and device-sessions.controller.ts. + +export type DeviceSessionState = + | 'active' + | 'expired' + | 'failed' + | 'needs_human' + | 'provisioning' + | 'queued' + | 'ready' + | 'released'; + +/** States a session can no longer leave. */ +export const ENDED_STATES: readonly DeviceSessionState[] = ['released', 'expired', 'failed']; + +/** + * The compact `session` block every /device/sessions response carries + * (ILiveSessionSummary in the API). `id` is the session name, which every + * `dcd device` command takes. + */ +export interface DeviceSession { + id: string; + live_id: number; + state: DeviceSessionState; + release_reason: null | string; + platform: string; + model: null | string; + client_label: null | string; + created_at: string; + queue: { + eta_s: null | number; + position: null | number; + reason: 'fleet_busy' | 'waiting_for_device'; + } | null; + time_remaining_s: null | number; + idle_remaining_s: null | number; + cost_so_far_usd: null | number; + last_crash: { at: string; summary: string } | null; + warnings: string[]; + monitor_url: string; +} + +/** POST /device/sessions. */ +export interface StartDeviceSessionRequest { + platform: 'android' | 'ios'; + binaryUploadId?: string; + model?: string; + os?: string; + googlePlay?: boolean; + deviceLocale?: string; + clientLabel?: string; + maxDurationMin?: number; + record?: boolean; +} + +/** One entry of GET /device/models. */ +export interface DeviceModel { + model: string; + name: string; + os: string[]; + default_os: string; + google_play: boolean; + default: boolean; +} + +export interface DeviceCatalogue { + android: DeviceModel[]; + ios: DeviceModel[]; +} + +/** What POST .../install answers with beside the session. */ +export interface DeviceInstalledApp { + app_id: null | string; + binary_upload_id: string; + reused: boolean; +} + +/** The result of a typed action or a launch. */ +export interface DeviceCommandResult { + error?: string; + output?: string; + success: boolean; +} diff --git a/src/utils/app-upload.ts b/src/utils/app-upload.ts new file mode 100644 index 0000000..9a7449c --- /dev/null +++ b/src/utils/app-upload.ts @@ -0,0 +1,84 @@ +import { rm } from 'node:fs/promises'; + +import { uploadBinary, verifyAppZip } from '../methods.js'; +import type { AuthContext } from '../types/domain/auth.types.js'; +import { detectCiContext } from './ci.js'; +import { CliError, getCliVersion } from './cli.js'; +import { fetchCompatibilityData } from './compatibility.js'; +import { checkKekForEncryption, resolveEncryption } from './envelope.js'; +import { extractTarGz, findAppBundle } from './expo.js'; + +const APP_EXTENSIONS = ['.apk', '.app', '.zip', '.tar.gz'] as const; + +/** The platform a local build is for, from its file name; undefined if unknown. */ +export function platformOfAppFile(filePath: string): 'android' | 'ios' | undefined { + const clean = filePath.replace(/\/+$/, ''); + if (clean.endsWith('.apk')) return 'android'; + if (['.app', '.zip', '.tar.gz'].some((ext) => clean.endsWith(ext))) return 'ios'; + return undefined; +} + +export interface UploadLocalAppOptions { + apiUrl: string; + auth: AuthContext; + filePath: string; + /** Show the upload's progress spinner. Only on a TTY, never under --json. */ + showProgress: boolean; + /** One line of progress for a person, e.g. the encryption notice. */ + log: (message: string) => void; +} + +/** + * Upload a local build the way `dcd upload` does, and return its binary id: + * an Expo `.tar.gz` is unpacked to its `.app`, a `.zip` is checked, and the + * org's encryption default applies. URLs are not handled here: `dcd device` + * hands those to the API's install-from-URL instead. + */ +export async function uploadLocalApp(opts: UploadLocalAppOptions): Promise { + const { apiUrl, auth, log } = opts; + let filePath = opts.filePath.replace(/\/+$/, ''); + if (!APP_EXTENSIONS.some((ext) => filePath.endsWith(ext))) { + throw new CliError( + 'The app must be a .apk for Android, or a .app, .zip or .tar.gz (Expo) for iOS.', + ); + } + + // As in `dcd upload`: a failed compatibility fetch leaves the default off + // rather than failing the upload. + let serverDefault: boolean | undefined; + try { + const ci = detectCiContext(); + const compatibility = await fetchCompatibilityData(apiUrl, auth, { + cliVersion: getCliVersion(), + ciProvider: ci.provider, + ciWrapperVersion: ci.wrapperVersion, + }); + serverDefault = compatibility.encryption?.defaultOn; + } catch { + serverDefault = undefined; + } + + const encryption = checkKekForEncryption(resolveEncryption({ serverDefault }), apiUrl); + if (encryption.notice) log(encryption.notice); + + const tempDirs: string[] = []; + try { + if (filePath.endsWith('.tar.gz')) { + const dir = await extractTarGz(filePath, false); + tempDirs.push(dir); + filePath = await findAppBundle(dir); + } + + if (filePath.endsWith('.zip')) await verifyAppZip(filePath); + + return await uploadBinary({ + apiUrl, + auth, + encrypt: encryption.enabled, + filePath, + log: opts.showProgress, + }); + } finally { + for (const dir of tempDirs) await rm(dir, { force: true, recursive: true }).catch(() => {}); + } +} diff --git a/src/utils/device-output.ts b/src/utils/device-output.ts new file mode 100644 index 0000000..1dd0d34 --- /dev/null +++ b/src/utils/device-output.ts @@ -0,0 +1,197 @@ +import { DeviceApiError, DeviceGateway } from '../gateways/device-gateway.js'; +import type { AuthContext } from '../types/domain/auth.types.js'; +import type { DeviceSession } from '../types/domain/device-session.types.js'; +import { CliError, logger } from './cli.js'; +import { colors, formatUrl } from './styling.js'; +import { type Field, ui } from './ui.js'; + +/** + * Exit codes for `dcd device` (dcd docs/device-v1-plan.md, section 4.2). + * Scripts and agents branch on these, so they are a contract. + */ +export const DEVICE_EXIT = { + ok: 0, + /** Called wrongly: a bad flag, a bad value, or no session to pick. */ + usage: 1, + /** It ran and failed: a flow or action failed, or the API could not do it. */ + failed: 2, + /** A limit: concurrency, spend cap or rate limit. */ + limit: 3, + /** The session has ended, or there is no such session. */ + gone: 4, +} as const; + +const LIMIT_CODES = new Set(['LIVE_CONCURRENCY_LIMIT', 'LIVE_RATE_LIMITED', 'LIVE_SPEND_CAP']); +const GONE_CODES = new Set(['LIVE_NOT_FOUND', 'LIVE_SESSION_ENDED']); +const USAGE_CODES = new Set([ + 'LIVE_INVALID_ACTION', + 'LIVE_INVALID_APP', + 'LIVE_SESSION_REQUIRED', +]); + +export function deviceExitCode(error: unknown): number { + if (error instanceof DeviceApiError) { + if (error.code && GONE_CODES.has(error.code)) return DEVICE_EXIT.gone; + if (error.code && LIMIT_CODES.has(error.code)) return DEVICE_EXIT.limit; + if (error.code && USAGE_CODES.has(error.code)) return DEVICE_EXIT.usage; + if (error.status === 404) return DEVICE_EXIT.gone; + if (error.status === 429) return DEVICE_EXIT.limit; + if ([400, 401, 403].includes(error.status)) return DEVICE_EXIT.usage; + return DEVICE_EXIT.failed; + } + + if (error instanceof CliError) return error.exitCode; + return DEVICE_EXIT.failed; +} + +/** The one object `--json` prints, on success and on failure alike. */ +export interface DeviceEnvelope { + ok: boolean; + session: DeviceSession | null; + data: T | null; + error: { code: null | string; message: string } | null; +} + +export interface DeviceOutcome { + session: DeviceSession | null; + data: T; + /** Prints the result for a person. Not called under --json. */ + print(): void; + /** + * Set when the command ran but did not succeed (a failed launch, a failed + * flow). The CLI then exits 2, still printing `data`. + */ + failure?: { code: null | string; message: string }; +} + +/** + * Runs one `dcd device` command and prints its result: one envelope under + * --json, readable text otherwise. Errors leave with the matching exit code. + * Under --json the error is printed here, as an envelope, so the top-level + * handler's `{ status, error }` never appears. + */ +export async function runDeviceCommand( + json: boolean, + fn: () => Promise>, +): Promise { + let outcome: DeviceOutcome; + try { + outcome = await fn(); + } catch (error) { + const exitCode = deviceExitCode(error); + const message = error instanceof Error ? error.message : String(error); + if (!json) throw new CliError(message, exitCode); + const envelope: DeviceEnvelope = { + ok: false, + session: error instanceof DeviceApiError ? error.session : null, + data: null, + error: { code: error instanceof DeviceApiError ? error.code : null, message }, + }; + logger.json(envelope); + logger.fail(message, exitCode); + } + + if (json) { + const envelope: DeviceEnvelope = { + ok: !outcome.failure, + session: outcome.session, + data: outcome.data, + error: outcome.failure ?? null, + }; + logger.json(envelope); + } else { + outcome.print(); + } + + if (outcome.failure) logger.fail(outcome.failure.message, DEVICE_EXIT.failed); +} + +/** + * Progress for a person, on stderr, so stdout carries only the result. Off + * under --json. These are plain lines, never a spinner, so they read the same + * in a terminal, a CI log or an agent's tool output. + */ +export function deviceProgress(json: boolean): (message: string) => void { + return (message) => { + if (!json) process.stderr.write(`${message}\n`); + }; +} + +/** + * The default-session rule: `` may be left out when the org has exactly + * one open session. With none, or several, the command is a usage error and + * lists what there is to choose from. + */ +export async function resolveSessionId( + apiUrl: string, + auth: AuthContext, + given: string | undefined, +): Promise { + const id = given?.trim(); + if (id) return id; + + const { sessions } = await DeviceGateway.list(apiUrl, auth); + if (sessions.length === 1) return sessions[0].id; + if (sessions.length === 0) { + throw new CliError( + 'No open device session. Start one with `dcd device start --platform ios|android`.', + DEVICE_EXIT.usage, + ); + } + + const candidates = sessions + .map((s) => ` ${s.id} ${s.platform} ${s.state}${s.client_label ? ` ${s.client_label}` : ''}`) + .join('\n'); + throw new CliError( + `${sessions.length} device sessions are open. Name one:\n${candidates}`, + DEVICE_EXIT.usage, + ); +} + +function formatSeconds(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const rest = seconds % 60; + return rest ? `${minutes}m ${rest}s` : `${minutes}m`; +} + +/** The rows that describe a session, for {@link ui.fields}. */ +export function sessionFields(session: DeviceSession): Field[] { + const fields: Field[] = [ + ['session', colors.highlight(session.id)], + ['state', session.release_reason ? `${session.state} (${session.release_reason})` : session.state], + ['platform', session.platform], + ]; + if (session.model) fields.push(['model', session.model]); + if (session.client_label) fields.push(['client', session.client_label]); + if (session.queue) { + const position = session.queue.position === null ? '' : `#${session.queue.position}`; + const eta = session.queue.eta_s === null ? '' : `, about ${formatSeconds(session.queue.eta_s)}`; + fields.push(['queue', `${position || 'waiting'}${eta} (${session.queue.reason.replaceAll('_', ' ')})`]); + } + + if (session.time_remaining_s !== null) { + fields.push(['time left', formatSeconds(session.time_remaining_s)]); + } + + if (session.idle_remaining_s !== null) { + fields.push(['idle timeout', `in ${formatSeconds(session.idle_remaining_s)}`]); + } + + if (session.cost_so_far_usd !== null) { + fields.push(['cost so far', `$${session.cost_so_far_usd.toFixed(2)}`]); + } + + if (session.last_crash) { + fields.push(['last crash', colors.error(`${session.last_crash.summary} (${session.last_crash.at})`)]); + } + + fields.push(['monitor', formatUrl(session.monitor_url)]); + return fields; +} + +/** A session as a branch group, with its warnings after it. */ +export function printSession(session: DeviceSession): void { + logger.log(ui.branch(ui.fields(sessionFields(session)))); + for (const warning of session.warnings) logger.log(ui.warn(warning)); +} diff --git a/test/unit/device-command.test.ts b/test/unit/device-command.test.ts new file mode 100644 index 0000000..29c613a --- /dev/null +++ b/test/unit/device-command.test.ts @@ -0,0 +1,326 @@ +import { expect } from 'chai'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { deviceCommand } from '../../src/commands/device.js'; +import { DeviceApiError } from '../../src/gateways/device-gateway.js'; +import type { DeviceSession } from '../../src/types/domain/device-session.types.js'; +import { platformOfAppFile } from '../../src/utils/app-upload.js'; +import { CliError } from '../../src/utils/cli.js'; +import { DEVICE_EXIT, deviceExitCode, resolveSessionId } from '../../src/utils/device-output.js'; + +const API = 'https://api.devicecloud.dev'; +const auth = { headers: { 'x-app-api-key': 'k' }, mode: 'apiKey' as const }; + +function session(id: string, extra: Partial = {}): DeviceSession { + return { + id, + live_id: 1, + state: 'active', + release_reason: null, + platform: 'ios', + model: 'iphone-16', + client_label: 'dcd-cli', + created_at: '2026-10-19T10:00:00Z', + queue: null, + time_remaining_s: 3600, + idle_remaining_s: 290, + cost_so_far_usd: 0.42, + last_crash: null, + warnings: [], + monitor_url: `https://console.devicecloud.dev/device/${id}`, + ...extra, + }; +} + +interface Call { + body?: unknown; + headers: Record; + method: string; + url: string; +} + +/** Route fetch to `handler`, recording each call. */ +function stubFetch(handler: (call: Call) => { body: unknown; status?: number }): Call[] { + const calls: Call[] = []; + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + const call: Call = { + url: String(input), + method: init?.method ?? 'GET', + headers: (init?.headers ?? {}) as Record, + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }; + calls.push(call); + const { body, status = 200 } = handler(call); + return new Response(JSON.stringify(body), { status }); + }) as typeof fetch; + return calls; +} + +class ExitError extends Error { + constructor(public code: number) { + super(`exit ${code}`); + } +} + +/** + * Run a subcommand's `run` with --json, capturing stdout and the exit code. + * process.exit is replaced by a throw, so a failing command stops there. + */ +async function runJson( + name: string, + args: Record, +): Promise<{ exit: number; out: Record }> { + const sub = (deviceCommand.subCommands as Record Promise }>)[ + name + ]; + let out = ''; + const write = process.stdout.write.bind(process.stdout); + const exit = process.exit; + process.stdout.write = ((chunk: string) => { + out += chunk; + return true; + }) as typeof process.stdout.write; + process.exit = ((code?: number) => { + throw new ExitError(code ?? 0); + }) as typeof process.exit; + let code = 0; + try { + await sub.run({ args: { json: true, 'api-url': API, 'api-key': 'k', ...args }, rawArgs: [] }); + } catch (error) { + if (!(error instanceof ExitError)) throw error; + code = error.code; + } finally { + process.stdout.write = write; + process.exit = exit; + } + + return { exit: code, out: JSON.parse(out) as Record }; +} + +describe('dcd device', () => { + const realFetch = globalThis.fetch; + const ORIGINAL_ENV = { ...process.env }; + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-device-test-')); + process.env.DCD_CONFIG_DIR = tempDir; + process.env.DCD_TELEMETRY_DISABLED = '1'; + }); + + afterEach(() => { + globalThis.fetch = realFetch; + process.env = { ...ORIGINAL_ENV }; + fs.rmSync(tempDir, { force: true, recursive: true }); + }); + + describe('exit codes', () => { + const apiError = (status: number, code: null | string) => + new DeviceApiError('x', status, code, null); + + it('maps limits to 3 and ended or missing sessions to 4', () => { + expect(deviceExitCode(apiError(429, 'LIVE_SPEND_CAP'))).to.equal(DEVICE_EXIT.limit); + expect(deviceExitCode(apiError(409, 'LIVE_CONCURRENCY_LIMIT'))).to.equal(DEVICE_EXIT.limit); + expect(deviceExitCode(apiError(429, null))).to.equal(DEVICE_EXIT.limit); + expect(deviceExitCode(apiError(404, 'LIVE_NOT_FOUND'))).to.equal(DEVICE_EXIT.gone); + expect(deviceExitCode(apiError(409, 'LIVE_SESSION_ENDED'))).to.equal(DEVICE_EXIT.gone); + }); + + it('maps bad input to 1 and everything else to 2', () => { + expect(deviceExitCode(apiError(400, 'LIVE_INVALID_ACTION'))).to.equal(DEVICE_EXIT.usage); + expect(deviceExitCode(apiError(401, null))).to.equal(DEVICE_EXIT.usage); + expect(deviceExitCode(new CliError('bad flag'))).to.equal(DEVICE_EXIT.usage); + expect(deviceExitCode(apiError(409, 'LIVE_HUMAN_IN_CONTROL'))).to.equal(DEVICE_EXIT.failed); + expect(deviceExitCode(apiError(503, null))).to.equal(DEVICE_EXIT.failed); + expect(deviceExitCode(new Error('network'))).to.equal(DEVICE_EXIT.failed); + }); + }); + + describe('the default-session rule', () => { + it('uses the one open session', async () => { + stubFetch(() => ({ body: { sessions: [session('brave-teal-otter')] } })); + expect(await resolveSessionId(API, auth, undefined)).to.equal('brave-teal-otter'); + }); + + it('never lists when an id is given', async () => { + const calls = stubFetch(() => ({ body: {} })); + expect(await resolveSessionId(API, auth, ' calm-red-fox ')).to.equal('calm-red-fox'); + expect(calls).to.have.length(0); + }); + + it('is a usage error listing the candidates when several are open', async () => { + stubFetch(() => ({ body: { sessions: [session('a-b-c'), session('d-e-f')] } })); + try { + await resolveSessionId(API, auth, undefined); + expect.fail('should have thrown'); + } catch (error) { + expect(error).to.be.instanceOf(CliError); + expect((error as CliError).exitCode).to.equal(DEVICE_EXIT.usage); + expect((error as Error).message).to.include('a-b-c').and.include('d-e-f'); + } + }); + + it('is a usage error when none is open', async () => { + stubFetch(() => ({ body: { sessions: [] } })); + try { + await resolveSessionId(API, auth, undefined); + expect.fail('should have thrown'); + } catch (error) { + expect((error as CliError).exitCode).to.equal(DEVICE_EXIT.usage); + expect((error as Error).message).to.include('dcd device start'); + } + }); + }); + + describe('--json envelope', () => { + it('prints { ok, session, data, error } on success', async () => { + stubFetch(() => ({ body: { session: session('brave-teal-otter') } })); + const { exit, out } = await runJson('status', { id: 'brave-teal-otter' }); + expect(exit).to.equal(0); + expect(out).to.have.keys('ok', 'session', 'data', 'error'); + expect(out.ok).to.equal(true); + expect((out.session as DeviceSession).id).to.equal('brave-teal-otter'); + expect(out.error).to.equal(null); + }); + + it('carries the API code, the session and exit 4 for an ended session', async () => { + const ended = session('brave-teal-otter', { state: 'released', release_reason: 'user_stop' }); + stubFetch(() => ({ + status: 409, + body: { + statusCode: 409, + message: 'Device session "brave-teal-otter" has ended (user_stop). Start a new one.', + error: 'LIVE_SESSION_ENDED', + session: ended, + }, + })); + const { exit, out } = await runJson('launch', { id: 'brave-teal-otter' }); + expect(exit).to.equal(DEVICE_EXIT.gone); + expect(out.ok).to.equal(false); + expect(out.data).to.equal(null); + expect(out.error).to.deep.equal({ + code: 'LIVE_SESSION_ENDED', + message: 'Device session "brave-teal-otter" has ended (user_stop). Start a new one.', + }); + expect((out.session as DeviceSession).state).to.equal('released'); + }); + + it('does not read Nest status text as a code', async () => { + stubFetch(() => ({ status: 500, body: { statusCode: 500, message: 'boom', error: 'Internal Server Error' } })); + const { exit, out } = await runJson('status', { id: 'x-y-z' }); + expect(exit).to.equal(DEVICE_EXIT.failed); + expect(out.error).to.deep.equal({ code: null, message: 'boom' }); + }); + + it('exits 2 with the result when a launch fails', async () => { + stubFetch(() => ({ + body: { result: { success: false, error: 'App not installed' }, session: session('a-b-c') }, + })); + const { exit, out } = await runJson('launch', { id: 'a-b-c', appId: 'com.example.app' }); + expect(exit).to.equal(DEVICE_EXIT.failed); + expect(out.ok).to.equal(false); + expect(out.data).to.deep.equal({ result: { success: false, error: 'App not installed' } }); + expect(out.error).to.deep.equal({ code: null, message: 'App not installed' }); + }); + + it('prints a usage error as an envelope too', async () => { + stubFetch(() => ({ body: {} })); + const { exit, out } = await runJson('start', {}); + expect(exit).to.equal(DEVICE_EXIT.usage); + expect(out.ok).to.equal(false); + expect((out.error as { message: string }).message).to.include('--platform'); + }); + }); + + describe('requests', () => { + it('start sends the device, the label and the source header', async () => { + const calls = stubFetch(() => ({ body: { session: session('a-b-c', { state: 'queued' }) } })); + await runJson('start', { + platform: 'android', + model: 'pixel-10', + os: '35', + 'google-play': true, + ttl: '30', + record: false, + }); + expect(calls).to.have.length(1); + expect(calls[0].method).to.equal('POST'); + expect(calls[0].url).to.equal(`${API}/device/sessions`); + expect(calls[0].headers['x-dcd-source']).to.equal('cli'); + expect(calls[0].body).to.deep.equal({ + platform: 'android', + clientLabel: 'dcd-cli', + model: 'pixel-10', + os: '35', + googlePlay: true, + maxDurationMin: 30, + record: false, + }); + }); + + it('start --app with a URL starts, then installs from the URL', async () => { + const calls = stubFetch((call) => + call.url.endsWith('/install') + ? { body: { app: { binary_upload_id: 'b1', app_id: null, reused: false }, session: session('a-b-c') } } + : { body: { session: session('a-b-c', { state: 'queued' }) } }, + ); + const { exit } = await runJson('start', { platform: 'ios', app: 'https://example.com/app.zip' }); + expect(exit).to.equal(0); + expect(calls.map((c) => `${c.method} ${c.url}`)).to.deep.equal([ + `POST ${API}/device/sessions`, + `POST ${API}/device/sessions/a-b-c/install`, + ]); + expect(calls[1].body).to.deep.equal({ url: 'https://example.com/app.zip' }); + }); + + it('install takes the app as the only positional and finds the session', async () => { + const calls = stubFetch((call) => + call.method === 'GET' + ? { body: { sessions: [session('a-b-c')] } } + : { body: { app: { binary_upload_id: 'b1', app_id: 'com.x', reused: false }, session: session('a-b-c') } }, + ); + const { exit, out } = await runJson('install', { id: 'https://example.com/app.apk' }); + expect(exit).to.equal(0); + expect(calls[1].url).to.equal(`${API}/device/sessions/a-b-c/install`); + expect(calls[1].body).to.deep.equal({ url: 'https://example.com/app.apk' }); + expect(out.data).to.deep.equal({ app: { binary_upload_id: 'b1', app_id: 'com.x', reused: false } }); + }); + + it('launch takes a bundle id as the only positional', async () => { + const calls = stubFetch((call) => + call.method === 'GET' + ? { body: { sessions: [session('a-b-c')] } } + : { body: { result: { success: true }, session: session('a-b-c') } }, + ); + await runJson('launch', { id: 'com.example.app', 'clear-state': true }); + expect(calls[1].url).to.equal(`${API}/device/sessions/a-b-c/launch`); + expect(calls[1].body).to.deep.equal({ appId: 'com.example.app', clearState: true }); + }); + + it('stop --all stops every open session', async () => { + const calls = stubFetch((call) => + call.method === 'GET' + ? { body: { sessions: [session('a-b-c'), session('d-e-f')] } } + : { body: { session: session(call.url.split('/').pop()!, { state: 'released' }) } }, + ); + const { out } = await runJson('stop', { all: true }); + expect(calls.filter((c) => c.method === 'DELETE').map((c) => c.url)).to.deep.equal([ + `${API}/device/sessions/a-b-c`, + `${API}/device/sessions/d-e-f`, + ]); + expect((out.data as { stopped: DeviceSession[] }).stopped).to.have.length(2); + }); + }); + + describe('platformOfAppFile', () => { + it('reads the platform from the extension', () => { + expect(platformOfAppFile('build/app.apk')).to.equal('android'); + expect(platformOfAppFile('Build/My.app/')).to.equal('ios'); + expect(platformOfAppFile('my.zip')).to.equal('ios'); + expect(platformOfAppFile('expo.tar.gz')).to.equal('ios'); + expect(platformOfAppFile('notes.txt')).to.equal(undefined); + }); + }); +});