@mechasm/cli 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js ADDED
@@ -0,0 +1,581 @@
1
+ #!/usr/bin/env node
2
+ import { open, readFile } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ import { Readable } from 'node:stream';
5
+ import { createWriteStream } from 'node:fs';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { rm, stat } from 'node:fs/promises';
8
+ import { setTimeout as delay } from 'node:timers/promises';
9
+ import { randomUUID } from 'node:crypto';
10
+ import { createCliClient, CliError, DEFAULT_API_URL } from './api-client.js';
11
+ import { runServiceForeground, serviceStart, serviceStatus, serviceStop } from './service.js';
12
+ import { clearLocalApproval, setLocalApproval } from './local-approvals.js';
13
+ import { CLI_VERSION } from './version.js';
14
+ import { runDoctor } from './diagnostics.js';
15
+ import { notifyUpdate } from './notifier.js';
16
+
17
+ const HELP = `Mechasm CLI
18
+
19
+ Usage:
20
+ mechasm login [--api-url URL] [--device-name NAME] [--no-browser] [--json]
21
+ mechasm logout|whoami [--api-url URL] [--token-stdin] [--json]
22
+ mechasm projects list [--api-url URL] [--token-stdin] [--json]
23
+ mechasm projects create --name NAME --url URL [--team TEAM_ID] [--idempotency-key KEY] [options]
24
+ mechasm teams list [options]
25
+ mechasm variables list --project ID [options]
26
+ mechasm variables set KEY --project ID (--value-stdin|--value-file FILE) [options]
27
+ mechasm variables unset KEY --project ID [options]
28
+ mechasm tests create --project ID --name NAME [--prompt TEXT|--prompt-file FILE] [--tags LIST] [--wait] [--idempotency-key KEY] [--json]
29
+ mechasm tests edit --project ID TEST_ID [--prompt TEXT|--prompt-file FILE] [--name NAME] [--tags LIST] [--enabled true|false] [--wait] [--idempotency-key KEY] [--json]
30
+ mechasm tests list --project ID [--offset N] [--limit N] [--json]
31
+ mechasm tests get TEST_ID --project ID [--json]
32
+ mechasm tests run TEST_ID --project ID [--wait] [--idempotency-key KEY] [--json]
33
+ mechasm runs status|get|wait|logs|stop|video|trace|variables RUN_ID --project ID [--follow] [--after CURSOR] [--out FILE] [--json]
34
+ mechasm local register --project ID --url URL --ports LIST [options]
35
+ mechasm local list --project ID [options]
36
+ mechasm local select --project ID DEVICE_ID [options]
37
+ mechasm local ports set --project ID LIST [--url URL] [options]
38
+ mechasm local unregister --project ID [options]
39
+ mechasm service start|stop|status|foreground [options]
40
+ mechasm doctor [--api-url URL] [--token-stdin] [--json]
41
+ mechasm update [--json]
42
+ mechasm --version
43
+
44
+ Options:
45
+ --api-url URL API origin (HTTPS, or HTTP loopback for development)
46
+ --token-stdin Read scoped API token from stdin
47
+ --project ID Explicit project ID
48
+ --team ID Approved team for project creation; optional when only one team is approved
49
+ --value-file FILE Read a project variable value from a file
50
+ --value-stdin Read a project variable value from stdin
51
+ --device-name NAME Initial device name; editable during browser approval
52
+ --prompt TEXT Test prompt
53
+ --prompt-file FILE Read test prompt from file (use - for stdin)
54
+ Use Markdown for headings, lists, and clear steps; plain text also works.
55
+ --name NAME Project or test name
56
+ --tags LIST Comma-separated test tags
57
+ --offset N Test list offset (default 0)
58
+ --limit N Test list page size (1 to 100, default 50)
59
+ --enabled BOOL Enable or disable test
60
+ --idempotency-key KEY Reuse key after an unknown mutation result
61
+ --url URL Project or local app URL
62
+ --ports LIST Comma-separated local ports
63
+ --wait Wait for test generation or run completion; metadata-only operations return immediately
64
+ --follow Follow run logs until it finishes
65
+ --after CURSOR Resume run log history
66
+ --out FILE Save video or trace to a new file
67
+ --json JSON output on stdout; errors on stderr
68
+
69
+ Commands that start a run return its workspace URL; plain output shows it as "View run".
70
+ No command opens a local public listener. Project IDs are required for test and run operations.
71
+ `;
72
+ const RUNNER_MANAGED_VARIABLES = new Set(['CI_TOKEN', 'RUN_WS_TOKEN', 'WS_AUTH_COOKIE']);
73
+
74
+ function parseArgs(argv) {
75
+ const parsed = { positionals: [], optionsSeen: new Set(), apiUrl: DEFAULT_API_URL, json: false, noBrowser: false, tokenStdin: false };
76
+ const values = new Set(['--api-url', '--device-name', '--project', '--team', '--prompt', '--prompt-file', '--name', '--tags', '--enabled', '--url', '--ports', '--out', '--after', '--offset', '--limit', '--idempotency-key', '--manifest-url', '--public-key-file', '--startup-guard-token', '--value-file']);
77
+ const flags = new Set(['--json', '--no-browser', '--token-stdin', '--wait', '--follow', '--value-stdin']);
78
+ for (let i = 0; i < argv.length; i++) {
79
+ const arg = argv[i];
80
+ if (arg === '--help' || arg === '-h') parsed.help = true;
81
+ else if (flags.has(arg)) {
82
+ parsed.optionsSeen.add(arg);
83
+ parsed[arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = true;
84
+ }
85
+ else if (values.has(arg)) {
86
+ parsed.optionsSeen.add(arg);
87
+ const value = argv[++i];
88
+ if (value === undefined || value.startsWith('--') || (value === '' && arg !== '--tags')) throw new CliError(`${arg} needs a value.`, 2);
89
+ parsed[arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value;
90
+ } else if (arg.startsWith('--')) throw new CliError(`Unknown option: ${arg}`, 2);
91
+ else parsed.positionals.push(arg);
92
+ }
93
+ return parsed;
94
+ }
95
+
96
+ function validateOptions(args, allowed) {
97
+ for (const option of args.optionsSeen) if (!allowed.has(option)) throw new CliError(`${option} does not apply to this command.`, 2);
98
+ }
99
+
100
+ async function stdinToken(enabled) {
101
+ if (!enabled) return undefined;
102
+ let value = '';
103
+ for await (const chunk of process.stdin) {
104
+ value += chunk;
105
+ if (Buffer.byteLength(value, 'utf8') > 4096) throw new CliError('Token input exceeds 4096 bytes.', 2);
106
+ }
107
+ return value.trim();
108
+ }
109
+
110
+ async function promptText(args) {
111
+ if (args.prompt !== undefined && args.promptFile !== undefined) throw new CliError('Use either --prompt or --prompt-file.', 2);
112
+ if (args.prompt !== undefined) return args.prompt.trim();
113
+ if (args.promptFile) return (args.promptFile === '-' ? await readStdinText() : await readFile(args.promptFile, 'utf8')).trim();
114
+ if (!process.stdin.isTTY) return (await readStdinText()).trim();
115
+ throw new CliError('Provide a prompt with --prompt, --prompt-file, or stdin.', 2);
116
+ }
117
+
118
+ function promptWillReadFromStdin(args, subcommand) {
119
+ if (args.prompt !== undefined || args.promptFile !== undefined || process.stdin.isTTY) return false;
120
+ return subcommand === 'create' ||
121
+ (subcommand === 'edit' && args.name === undefined && args.tags === undefined && args.enabled === undefined);
122
+ }
123
+
124
+ function parseTags(value) {
125
+ return value.split(',').map((tag) => tag.trim()).filter(Boolean);
126
+ }
127
+
128
+ function mutation(action, key) {
129
+ return (async () => {
130
+ let pause = 250;
131
+ const deadline = Date.now() + 30_000;
132
+ while (true) {
133
+ try { return await action(); }
134
+ catch (error) {
135
+ error.idempotencyKey = key;
136
+ if (error.apiCode !== 'operation_in_progress' || Date.now() >= deadline) throw error;
137
+ await delay(pause);
138
+ pause = Math.min(4000, pause * 2);
139
+ }
140
+ }
141
+ })();
142
+ }
143
+
144
+ async function readStdinText() {
145
+ let value = '';
146
+ for await (const chunk of process.stdin) {
147
+ value += chunk;
148
+ if (Buffer.byteLength(value, 'utf8') > 256 * 1024) throw new CliError('Prompt input exceeds 256 KiB.', 2);
149
+ }
150
+ return value;
151
+ }
152
+
153
+ async function variableValue(args) {
154
+ if (args.valueStdin === Boolean(args.valueFile)) throw new CliError('Pass exactly one of --value-stdin or --value-file.', 2);
155
+ if (args.valueStdin && args.tokenStdin) throw new CliError('Variable value and token cannot both use stdin; use --value-file with --token-stdin.', 2);
156
+ const value = args.valueStdin ? await readStdinText() : await readFile(args.valueFile, 'utf8');
157
+ if (Buffer.byteLength(value, 'utf8') > 64 * 1024) throw new CliError('Variable value exceeds 64 KiB.', 2);
158
+ if (!value.trim()) throw new CliError('Variable value cannot be empty; use variables unset to remove it.', 2);
159
+ return value;
160
+ }
161
+
162
+ function output(value, json) {
163
+ if (json) console.log(JSON.stringify(value));
164
+ else if (value?.runUrl || value?.run?.runUrl) {
165
+ if (value.run) {
166
+ const { runUrl, ...runDetails } = value.run;
167
+ console.log(JSON.stringify(runDetails, null, 2));
168
+ } else {
169
+ if (value.testId) console.log(`Test ID: ${value.testId}`);
170
+ if (value.runId) console.log(`Run ID: ${value.runId}`);
171
+ }
172
+ console.log(`View run: ${value.runUrl || value.run.runUrl}`);
173
+ }
174
+ else if (value?.user) {
175
+ const identity = [value.user.name, value.user.id].filter(Boolean).join(' / ');
176
+ console.log(`Signed in${identity ? ` as ${identity}` : ''}${value.deviceName ? ` (${value.deviceName})` : ''}.`);
177
+ }
178
+ else if (Array.isArray(value?.projects)) {
179
+ if (!value.projects.length) console.log('No projects available.');
180
+ else for (const project of value.projects) console.log(`${project.id} ${project.name} ${project.url}`);
181
+ } else if (Array.isArray(value?.tests)) {
182
+ if (!value.tests.length) console.log('No tests found.');
183
+ else for (const test of value.tests) console.log(`${test.id} ${test.name || test.prompt || ''} ${test.status || ''}`.trimEnd());
184
+ } else if (value?.project) console.log(`${value.project.id} ${value.project.name} ${value.project.url}`);
185
+ else if (value?.variable) console.log(value.variable.key);
186
+ else if (Array.isArray(value?.teams)) {
187
+ if (!value.teams.length) console.log('No teams available.');
188
+ else for (const team of value.teams) console.log(`${team.id} ${team.name} ${team.slug}`.trimEnd());
189
+ } else if (Array.isArray(value?.keys)) {
190
+ if (!value.keys.length) console.log('No variables set.');
191
+ else for (const key of value.keys) console.log(key);
192
+ } else if (value?.test) console.log(JSON.stringify(value.test, null, 2));
193
+ else if (value?.run) console.log(JSON.stringify(value.run, null, 2));
194
+ else if (value?.loggedOut) console.log('Logged out.');
195
+ else console.log(JSON.stringify(value, null, 2));
196
+ }
197
+
198
+ function projectId(args) {
199
+ if (!args.project) throw new CliError('Pass an explicit --project ID.', 2);
200
+ return args.project;
201
+ }
202
+
203
+ function localUrl(value) {
204
+ let url;
205
+ try { url = new URL(value); } catch { throw new CliError('--url must be a valid loopback URL.', 2); }
206
+ if (!['http:', 'https:'].includes(url.protocol) || !['localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname.toLowerCase()) || url.username || url.password) {
207
+ throw new CliError('--url must use localhost, 127.0.0.1, or ::1 without credentials.', 2);
208
+ }
209
+ return url;
210
+ }
211
+
212
+ function localHost(url) {
213
+ return ['::1', '[::1]'].includes(url.hostname.toLowerCase()) ? '::1' : '127.0.0.1';
214
+ }
215
+
216
+ function localPorts(value, url) {
217
+ if (!value) throw new CliError('Pass --ports with a comma-separated port list.', 2);
218
+ const numbers = value.split(',').map((part) => {
219
+ if (!/^\d+$/.test(part.trim())) throw new CliError(`Invalid port: ${part}`, 2);
220
+ const port = Number(part);
221
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new CliError(`Port must be between 1 and 65535: ${part}`, 2);
222
+ return port;
223
+ });
224
+ const ports = [...new Set(numbers)];
225
+ const mainPort = Number(url.port || (url.protocol === 'https:' ? 443 : 80));
226
+ if (!ports.includes(mainPort)) throw new CliError(`Port list must include the URL port ${mainPort}.`, 2);
227
+ if (ports.length > 16) throw new CliError('A project can expose at most 16 local ports.', 2);
228
+ return ports;
229
+ }
230
+
231
+ function localServiceOnline(state) {
232
+ return Boolean(state.running && state.lastHeartbeatAt && !state.lastError && Date.now() - Date.parse(state.lastHeartbeatAt) < 60_000);
233
+ }
234
+
235
+ function commandId(value, label) {
236
+ if (!value) throw new CliError(`Pass ${label} ID.`, 2);
237
+ return value;
238
+ }
239
+
240
+ async function waitForRun(client, project, runId, json, token, logs = false, cursor = undefined) {
241
+ const controller = new AbortController();
242
+ const stop = () => controller.abort();
243
+ process.once('SIGINT', stop);
244
+ process.once('SIGTERM', stop);
245
+ let lastRun;
246
+ try {
247
+ while (!controller.signal.aborted) {
248
+ const page = await longPollLogs(client, project, runId, cursor, token, controller.signal);
249
+ for (const event of page?.events || []) {
250
+ if (logs) output(event, json);
251
+ }
252
+ cursor = page?.nextCursor ?? cursor;
253
+ if (page?.terminal && page?.hasMore === false) {
254
+ lastRun = (await client.getRun(project, runId, token)).run;
255
+ return lastRun;
256
+ }
257
+ }
258
+ throw new CliError(`Stopped waiting for run ${runId}.`, 130);
259
+ } catch (error) {
260
+ if (error.name === 'AbortError' && !logs) throw new CliError(`Stopped waiting for run ${runId}.`, 130);
261
+ if (error.name === 'AbortError') return;
262
+ throw error;
263
+ } finally {
264
+ process.off('SIGINT', stop);
265
+ process.off('SIGTERM', stop);
266
+ if (!json && !logs) process.stderr.write('\n');
267
+ }
268
+ }
269
+
270
+ async function followLogs(client, project, runId, args, token) {
271
+ let cursor = args.after;
272
+ const controller = new AbortController();
273
+ const stop = () => controller.abort();
274
+ process.once('SIGINT', stop);
275
+ process.once('SIGTERM', stop);
276
+ try {
277
+ while (!controller.signal.aborted) {
278
+ const page = await longPollLogs(client, project, runId, cursor, token, controller.signal);
279
+ for (const event of page?.events || []) {
280
+ if (args.json) console.log(JSON.stringify({ type: 'event', event }));
281
+ else output(event, false);
282
+ }
283
+ if (page?.nextCursor != null && page.nextCursor !== cursor) {
284
+ cursor = page.nextCursor;
285
+ if (args.json) console.log(JSON.stringify({ type: 'cursor', cursor }));
286
+ }
287
+ if (page?.terminal && page?.hasMore === false) return;
288
+ }
289
+ throw new CliError(`Stopped following run ${runId}.`, 130);
290
+ } catch (error) {
291
+ if (error.name === 'AbortError') throw new CliError(`Stopped following run ${runId}.`, 130);
292
+ throw error;
293
+ } finally {
294
+ process.off('SIGINT', stop);
295
+ process.off('SIGTERM', stop);
296
+ }
297
+ }
298
+
299
+ async function longPollLogs(client, project, runId, cursor, token, signal) {
300
+ let pause = 1000;
301
+ while (true) {
302
+ try { return await client.runLogs(project, runId, cursor, token, { wait: true, signal }); }
303
+ catch (error) {
304
+ if (signal.aborted || error.name === 'AbortError' || error.exitCode === 2 || error.exitCode === 3 || error.exitCode === 4) throw error;
305
+ await delay(pause, undefined, { signal });
306
+ pause = Math.min(pause * 2, 30_000);
307
+ }
308
+ }
309
+ }
310
+
311
+ async function saveArtifact(client, project, runId, kind, outputPath, token) {
312
+ const download = await client.downloadRunArtifact(project, runId, kind, token);
313
+ const safeId = String(runId).replace(/[^A-Za-z0-9_-]/g, '_');
314
+ const path = resolve(outputPath || `mechasm-${safeId}-${kind}.${kind === 'video' ? 'webm' : 'zip'}`);
315
+ const file = await open(path, 'wx', 0o600);
316
+ try {
317
+ if (!download.body) throw new CliError('Artifact response did not include a download stream.');
318
+ await pipeline(Readable.fromWeb(download.body), file.createWriteStream({ autoClose: true }));
319
+ const bytes = (await stat(path)).size;
320
+ await file.close().catch(() => {});
321
+ return { path, bytes, contentType: download.contentType };
322
+ } catch (error) {
323
+ await file.close().catch(() => {});
324
+ await rm(path, { force: true });
325
+ throw error;
326
+ }
327
+ }
328
+
329
+ function runFailed(run) {
330
+ return ['failed', 'error', 'stopped', 'cancelled', 'canceled'].includes(String(run?.status || '').toLowerCase());
331
+ }
332
+
333
+ export async function runCli() {
334
+ try {
335
+ if (process.argv.slice(2).includes('--version') || process.argv.slice(2).includes('-V')) {
336
+ console.log(CLI_VERSION);
337
+ return;
338
+ }
339
+ const args = parseArgs(process.argv.slice(2));
340
+ const [command, subcommand, id, ...extra] = args.positionals;
341
+ if (args.help || command === 'help' || !command) {
342
+ process.stdout.write(HELP);
343
+ process.exitCode = !command && !args.help ? 2 : 0;
344
+ } else {
345
+ const portList = command === 'local' && subcommand === 'ports' && id === 'set' ? extra[0] : undefined;
346
+ if (extra.length > (portList === undefined ? 0 : 1)) throw new CliError('Unexpected extra arguments. Run `mechasm --help`.', 2);
347
+ if (args.tokenStdin && command === 'login') throw new CliError('--token-stdin applies to authenticated commands, not login.', 2);
348
+ const promptFromStdin = command === 'tests' && ['create', 'edit'].includes(subcommand) && promptWillReadFromStdin(args, subcommand);
349
+ if (args.tokenStdin && command === 'tests' && ['create', 'edit'].includes(subcommand) &&
350
+ (args.promptFile === '-' || promptFromStdin)) {
351
+ throw new CliError('Use --token-stdin with --prompt or --prompt-file FILE; stdin cannot supply both token and prompt.', 2);
352
+ }
353
+ if (args.deviceName && command !== 'login') throw new CliError('--device-name applies only to login.', 2);
354
+ if (args.noBrowser && command !== 'login') throw new CliError('--no-browser applies only to login.', 2);
355
+ if (args.startupGuardToken && (command !== 'service' || subcommand !== 'foreground')) throw new CliError('Startup guard token is internal to service startup.', 2);
356
+ const common = new Set(['--api-url', '--json']);
357
+ const authOptions = new Set([...common, '--token-stdin']);
358
+ const commandOptions = command === 'login'
359
+ ? new Set([...common, '--device-name', '--no-browser'])
360
+ : command === 'whoami' || command === 'logout'
361
+ ? authOptions
362
+ : command === 'projects'
363
+ ? new Set([...authOptions, ...(subcommand === 'create' ? ['--name', '--url', '--team', '--idempotency-key'] : [])])
364
+ : command === 'teams'
365
+ ? authOptions
366
+ : command === 'variables'
367
+ ? new Set([...authOptions, '--project', ...(subcommand === 'set' ? ['--value-stdin', '--value-file'] : [])])
368
+ : command === 'tests' && ['create', 'edit'].includes(subcommand)
369
+ ? new Set([...authOptions, '--project', '--prompt', '--prompt-file', '--name', '--tags', '--enabled', '--wait', '--idempotency-key'])
370
+ : command === 'tests'
371
+ ? new Set([...authOptions, '--project', ...(subcommand === 'run' ? ['--wait', '--idempotency-key'] : subcommand === 'list' ? ['--offset', '--limit'] : [])])
372
+ : command === 'runs' && subcommand === 'logs'
373
+ ? new Set([...authOptions, '--project', '--follow', '--after'])
374
+ : command === 'runs' && ['video', 'trace'].includes(subcommand)
375
+ ? new Set([...authOptions, '--project', '--out'])
376
+ : command === 'runs'
377
+ ? new Set([...authOptions, '--project'])
378
+ : command === 'local' && subcommand === 'register'
379
+ ? new Set([...authOptions, '--project', '--url', '--ports'])
380
+ : command === 'local' && subcommand === 'ports'
381
+ ? new Set([...authOptions, '--project', '--url', '--ports'])
382
+ : command === 'local'
383
+ ? new Set([...authOptions, '--project'])
384
+ : command === 'service'
385
+ ? new Set([...common, ...(subcommand === 'foreground' ? ['--startup-guard-token'] : [])])
386
+ : command === 'update'
387
+ ? new Set(['--json'])
388
+ : command === 'doctor'
389
+ ? authOptions
390
+ : common;
391
+ validateOptions(args, commandOptions);
392
+ const client = createCliClient(args.apiUrl);
393
+ if (command === 'login') {
394
+ const result = await client.login({ deviceName: args.deviceName, noBrowser: args.noBrowser, json: args.json });
395
+ if (args.json) output(result, true);
396
+ } else if (command === 'help') {
397
+ process.stdout.write(HELP);
398
+ } else {
399
+ const token = await stdinToken(args.tokenStdin);
400
+ if (command === 'update') {
401
+ if (subcommand) throw new CliError('Update configuration is no longer supported. Use npm to manage CLI upgrades.', 2);
402
+ output({ message: 'Upgrade with `npm install -g @mechasm/cli@latest`.' }, args.json);
403
+ }
404
+ else if (command === 'doctor') {
405
+ const result = await runDoctor(args.apiUrl, token);
406
+ output(result, args.json);
407
+ if (!result.ok) process.exitCode = 1;
408
+ }
409
+ else if (command === 'whoami') output((await client.whoami(token)).data, args.json);
410
+ else if (command === 'projects' && subcommand === 'list') output((await client.projects(token)).data, args.json);
411
+ else if (command === 'projects' && subcommand === 'create') {
412
+ if (!args.name?.trim() || args.name.trim().length > 120) throw new CliError('projects create needs --name (1 to 120 characters).', 2);
413
+ if (!args.url) throw new CliError('projects create needs --url.', 2);
414
+ let url;
415
+ try { url = new URL(args.url); } catch { throw new CliError('--url must be a valid HTTP or HTTPS URL.', 2); }
416
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new CliError('--url must be a valid HTTP or HTTPS URL without credentials.', 2);
417
+ const key = args.idempotencyKey || randomUUID();
418
+ if (!/^[A-Za-z0-9_-]{8,128}$/.test(key)) throw new CliError('--idempotency-key must be 8 to 128 safe characters.', 2);
419
+ const body = { name: args.name.trim(), url: url.toString() };
420
+ if (args.team) body.teamId = args.team;
421
+ const result = await mutation(() => client.createProject(body, token, key), key);
422
+ output(result, args.json);
423
+ }
424
+ else if (command === 'teams' && subcommand === 'list') output(await client.listTeams(token), args.json);
425
+ else if (command === 'variables') {
426
+ const project = projectId(args);
427
+ const key = id;
428
+ if (subcommand === 'list') {
429
+ const result = await client.projectVariables(project, token);
430
+ output({ keys: Array.isArray(result?.keys) ? result.keys.filter((item) => typeof item === 'string') : [] }, args.json);
431
+ }
432
+ else {
433
+ if (!key || key.length > 255 || key !== key.toUpperCase() || /\s/.test(key)) throw new CliError('Variable keys must be 1 to 255 characters, uppercase, and contain no whitespace.', 2);
434
+ if (RUNNER_MANAGED_VARIABLES.has(key)) throw new CliError('This variable is managed by the runner and cannot be changed.', 2);
435
+ const value = subcommand === 'set' ? await variableValue(args) : undefined;
436
+ try {
437
+ if (subcommand === 'set') {
438
+ await client.setProjectVariable(project, key, value, token);
439
+ } else if (subcommand === 'unset') await client.unsetProjectVariable(project, key, token);
440
+ else throw new CliError('Unknown variables command. Run `mechasm --help`.', 2);
441
+ } catch (error) {
442
+ if (error instanceof CliError && error.exitCode === 2) throw error;
443
+ const safe = new CliError(`Could not ${subcommand === 'unset' ? 'remove' : 'set'} project variable.`, error.exitCode || 1);
444
+ safe.apiCode = error.apiCode;
445
+ throw safe;
446
+ }
447
+ output({ variable: { key }, ...(subcommand === 'unset' ? { removed: true } : { saved: true }) }, args.json);
448
+ }
449
+ }
450
+ else if (command === 'logout') { await client.logout(token); output({ loggedOut: true }, args.json); }
451
+ else if (command === 'tests') {
452
+ const project = projectId(args);
453
+ let result;
454
+ if (subcommand === 'create') {
455
+ const prompt = await promptText(args);
456
+ if (!prompt) throw new CliError('Prompt cannot be empty.', 2);
457
+ if (!args.name?.trim()) throw new CliError('tests create needs --name.', 2);
458
+ const key = args.idempotencyKey || randomUUID();
459
+ if (!/^[A-Za-z0-9_-]{8,128}$/.test(key)) throw new CliError('--idempotency-key must be 8 to 128 safe characters.', 2);
460
+ const body = { prompt, name: args.name.trim() };
461
+ if (args.tags !== undefined) body.tags = parseTags(args.tags);
462
+ result = await mutation(() => client.createTest(project, body, token, key), key);
463
+ } else if (subcommand === 'edit') {
464
+ const testId = commandId(id, 'test');
465
+ const body = {};
466
+ if (args.prompt !== undefined || args.promptFile !== undefined || promptWillReadFromStdin(args, 'edit')) body.prompt = await promptText(args);
467
+ if (args.name) body.name = args.name;
468
+ if (args.tags !== undefined) body.tags = parseTags(args.tags);
469
+ if (args.enabled !== undefined) {
470
+ if (!['true', 'false'].includes(args.enabled)) throw new CliError('--enabled must be true or false.', 2);
471
+ body.enabled = args.enabled === 'true';
472
+ }
473
+ if (body.prompt !== undefined && !body.prompt.trim()) throw new CliError('Prompt cannot be empty.', 2);
474
+ if (!Object.keys(body).length) throw new CliError('Pass --prompt, --prompt-file, stdin, --name, --tags, or --enabled.', 2);
475
+ const key = args.idempotencyKey || randomUUID();
476
+ if (!/^[A-Za-z0-9_-]{8,128}$/.test(key)) throw new CliError('--idempotency-key must be 8 to 128 safe characters.', 2);
477
+ result = await mutation(() => client.editTest(project, testId, body, token, key), key);
478
+ } else if (subcommand === 'list') {
479
+ const offset = args.offset === undefined ? undefined : Number(args.offset);
480
+ const limit = args.limit === undefined ? undefined : Number(args.limit);
481
+ if (offset !== undefined && (!Number.isSafeInteger(offset) || offset < 0)) throw new CliError('--offset must be a non-negative integer.', 2);
482
+ if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)) throw new CliError('--limit must be an integer from 1 to 100.', 2);
483
+ result = await client.listTests(project, token, { offset, limit });
484
+ }
485
+ else if (subcommand === 'get') result = await client.getTest(project, commandId(id, 'test'), token);
486
+ else if (subcommand === 'run') {
487
+ const key = args.idempotencyKey || randomUUID();
488
+ if (!/^[A-Za-z0-9_-]{8,128}$/.test(key)) throw new CliError('--idempotency-key must be 8 to 128 safe characters.', 2);
489
+ result = await mutation(() => client.runTest(project, commandId(id, 'test'), token, key), key);
490
+ }
491
+ else throw new CliError('Unknown tests command. Run `mechasm --help`.', 2);
492
+ if (args.wait) {
493
+ const runId = result.runId;
494
+ if (runId) {
495
+ const run = await waitForRun(client, project, runId, args.json, token);
496
+ result = { ...result, run };
497
+ } else if (subcommand !== 'edit') {
498
+ throw new CliError('This operation did not start a run to wait for.');
499
+ }
500
+ }
501
+ output(result, args.json);
502
+ if (runFailed(result?.run)) process.exitCode = 5;
503
+ } else if (command === 'runs') {
504
+ const project = projectId(args);
505
+ const runId = commandId(id, 'run');
506
+ let result;
507
+ if (subcommand === 'get' || subcommand === 'status') result = await client.getRun(project, runId, token);
508
+ else if (subcommand === 'wait') result = { run: await waitForRun(client, project, runId, args.json, token) };
509
+ else if (subcommand === 'logs') {
510
+ if (args.follow) await followLogs(client, project, runId, args, token);
511
+ else result = await client.runLogs(project, runId, args.after, token);
512
+ } else if (subcommand === 'stop') result = await client.stopRun(project, runId, token) ?? { stopped: true, runId };
513
+ else if (subcommand === 'variables') result = await client.runVariables(project, runId, token);
514
+ else if (subcommand === 'video' || subcommand === 'trace') result = await saveArtifact(client, project, runId, subcommand, args.out, token);
515
+ else throw new CliError('Unknown runs command. Run `mechasm --help`.', 2);
516
+ if (result !== undefined) {
517
+ output(result, args.json);
518
+ if (runFailed(result?.run)) process.exitCode = 5;
519
+ }
520
+ } else if (command === 'local') {
521
+ const project = projectId(args);
522
+ let result;
523
+ if (subcommand === 'register') {
524
+ if (!args.url) throw new CliError('local register needs --url.', 2);
525
+ const service = await serviceStatus();
526
+ if (!localServiceOnline(service)) throw new CliError('Start the local service and confirm it is connected before registering this runner.', 3);
527
+ if (service.apiUrl !== args.apiUrl) throw new CliError(`Local service uses ${service.apiUrl}; pass that origin with --api-url.`, 2);
528
+ const url = localUrl(args.url);
529
+ const ports = localPorts(args.ports, url);
530
+ const deviceId = (await client.whoami(token)).data.deviceId;
531
+ result = await client.registerLocal(project, url.toString(), ports, token);
532
+ await setLocalApproval(client.origin, project, deviceId, ports, localHost(url));
533
+ } else if (subcommand === 'list') result = await client.localDevices(project, token);
534
+ else if (subcommand === 'select') result = await client.selectLocalRunner(project, commandId(id, 'device'), token);
535
+ else if (subcommand === 'unregister') {
536
+ result = await client.unregisterLocal(project, token) ?? { unregistered: true, projectId: project };
537
+ await clearLocalApproval(client.origin, project);
538
+ }
539
+ else if (subcommand === 'ports' && id === 'set') {
540
+ const portsInput = args.ports || portList;
541
+ if (!portsInput) throw new CliError('local ports set needs a comma-separated port list.', 2);
542
+ const service = await serviceStatus();
543
+ if (!localServiceOnline(service)) throw new CliError('Start the local service and confirm it is connected before approving ports.', 3);
544
+ if (service.apiUrl !== args.apiUrl) throw new CliError(`Local service uses ${service.apiUrl}; pass that origin with --api-url.`, 2);
545
+ const identity = (await client.whoami(token)).data;
546
+ const saved = await client.localProject(project, token);
547
+ const selected = saved.selectedDeviceId ?? saved.localRunnerId;
548
+ if (!selected || selected !== identity.deviceId) throw new CliError('This CLI device is not the selected local runner for the project.', 4);
549
+ const url = localUrl(args.url || saved.url).toString();
550
+ const ports = localPorts(portsInput, new URL(url));
551
+ result = await client.registerLocal(project, url, ports, token);
552
+ await setLocalApproval(client.origin, project, identity.deviceId, ports, localHost(new URL(url)));
553
+ } else throw new CliError('Unknown local command. Run `mechasm --help`.', 2);
554
+ output(result, args.json);
555
+ } else if (command === 'service') {
556
+ if (['status', 'stop'].includes(subcommand) && args.tokenStdin) throw new CliError('Local service commands use saved credentials, not --token-stdin.', 2);
557
+ let result;
558
+ if (subcommand === 'start') {
559
+ result = await serviceStart(args.apiUrl);
560
+ }
561
+ else if (subcommand === 'status') result = await serviceStatus();
562
+ else if (subcommand === 'stop') result = await serviceStop();
563
+ else if (subcommand === 'foreground') await runServiceForeground(args.apiUrl, args.startupGuardToken);
564
+ else throw new CliError('Unknown service command. Run `mechasm --help`.', 2);
565
+ if (result !== undefined) output(result, args.json);
566
+ } else {
567
+ throw new CliError('Unknown command. Run `mechasm --help`.', 2);
568
+ }
569
+ }
570
+ }
571
+ } catch (error) {
572
+ const code = error instanceof CliError ? error.exitCode : 1;
573
+ if (process.argv.includes('--json')) console.error(JSON.stringify({ error: error.message, exitCode: code, ...(error.idempotencyKey ? { idempotencyKey: error.idempotencyKey } : {}) }));
574
+ else console.error(`Error: ${error.message}${error.idempotencyKey ? `\nRetry with --idempotency-key ${error.idempotencyKey}.` : ''}`);
575
+ process.exitCode = code;
576
+ }
577
+ finally {
578
+ const argv = process.argv.slice(2);
579
+ if (!argv.includes('--version') && !argv.includes('-V') && !(argv[0] === 'service' && argv[1] === 'foreground')) void notifyUpdate();
580
+ }
581
+ }
@@ -0,0 +1,31 @@
1
+ import { CLI_VERSION } from './version.js';
2
+ import { createCliClient } from './api-client.js';
3
+ import { serviceStatus } from './service.js';
4
+
5
+ export async function runDoctor(apiUrl, stdinToken) {
6
+ const checks = [{ name: 'version', status: 'ok', detail: CLI_VERSION }];
7
+ const client = createCliClient(apiUrl);
8
+ try {
9
+ const response = await fetch(`${client.origin}/health`, { signal: AbortSignal.timeout(3000), redirect: 'error' });
10
+ checks.push({ name: 'api', status: response.ok ? 'ok' : 'fail', detail: response.ok ? 'reachable' : `HTTP ${response.status}` });
11
+ } catch {
12
+ checks.push({ name: 'api', status: 'fail', detail: 'unreachable' });
13
+ }
14
+ if (checks.find(({ name }) => name === 'api').status === 'ok') {
15
+ try {
16
+ await client.whoami(stdinToken, { timeoutMs: 3000 });
17
+ checks.push({ name: 'credentials', status: 'ok', detail: 'signed in' });
18
+ } catch (error) {
19
+ checks.push({ name: 'credentials', status: 'fail', detail: error.exitCode === 3 ? 'not signed in; run mechasm login' : 'could not verify; check API access and run mechasm login' });
20
+ }
21
+ } else {
22
+ checks.push({ name: 'credentials', status: 'info', detail: 'not checked because API is unreachable' });
23
+ }
24
+ try {
25
+ const state = await serviceStatus();
26
+ checks.push({ name: 'service', status: state.running ? (state.lastError ? 'fail' : 'ok') : 'info', detail: state.running ? (state.lastError ? 'running but not responding; run mechasm service status' : 'running') : 'stopped; run mechasm service start for local-app runs' });
27
+ } catch {
28
+ checks.push({ name: 'service', status: 'fail', detail: 'status unavailable' });
29
+ }
30
+ return { checks, ok: checks.every(({ status }) => status !== 'fail') };
31
+ }