warpmetal 0.1.0

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,609 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+
5
+ import {
6
+ booleanOption,
7
+ integerOption,
8
+ parseArguments,
9
+ rejectUnknownOptions,
10
+ stringOption,
11
+ } from "./args.js";
12
+ import { WarpMetalClient } from "./api.js";
13
+ import { CliError, toErrorMessage } from "./errors.js";
14
+ import { installSkill } from "./install-skill.js";
15
+ import { readSshPublicKey, signSshChallenge } from "./ssh.js";
16
+ import { resolveStateDirectory, StateStore } from "./state.js";
17
+
18
+ const VERSION = "0.1.0";
19
+ const TASK_TERMINAL_STATES = new Set([
20
+ "ready",
21
+ "expired",
22
+ "cancellation_pending",
23
+ "cancelled",
24
+ "failed",
25
+ "manual_review",
26
+ ]);
27
+ const OPERATION_TERMINAL_STATES = new Set(["succeeded", "failed", "manual_review"]);
28
+ const COMMON_OPTIONS = ["base-url", "json", "state-dir", "help"];
29
+
30
+ const HELP = `WarpMetal CLI ${VERSION}
31
+
32
+ Usage:
33
+ warpmetal health [--json]
34
+ warpmetal catalog [--plan <planId>] [--json]
35
+ warpmetal order prepare --plan <planId> --hostname <name> --os <exact-name>
36
+ --ssh-public-key-file <path> [--email <address>] [--idempotency-key <key>]
37
+ warpmetal order status --task <taskId> [--wait] [--timeout-seconds <n>]
38
+ warpmetal checkout challenge --task <taskId>
39
+ warpmetal checkout submit --task <taskId> --payment-signature-file <path>
40
+ [--wait] [--timeout-seconds <n>]
41
+ warpmetal server login --server <serverId> --identity <private-key-path>
42
+ warpmetal server get --server <serverId>
43
+ warpmetal server power --server <serverId> --action <boot|reboot|shutdown>
44
+ --confirm <same-action> [--wait] [--idempotency-key <key>]
45
+ warpmetal operation get --operation <operationId> [--server <serverId>] [--wait]
46
+ warpmetal state list
47
+ warpmetal agent install --target <codex|claude|all> [--scope <user|project>] [--force]
48
+
49
+ Global options:
50
+ --base-url <url> Override https://api.warpmetal.com
51
+ --state-dir <path> Override the private state directory
52
+ --json Emit structured, secret-redacted JSON
53
+ --help Show help
54
+ --version Show the CLI version
55
+
56
+ Credential environment variables:
57
+ WARPMETAL_OWNER_TOKEN Recovery/bootstrap credential for one explicit command
58
+ WARPMETAL_ACCESS_TOKEN Short-lived SSH-derived credential for one explicit command
59
+ WARPMETAL_API_URL Alternate API origin
60
+ WARPMETAL_HOME Alternate state directory
61
+
62
+ The CLI never accepts bearer tokens directly as command-line arguments.
63
+ `;
64
+
65
+ function writeLine(stream, value = "") {
66
+ stream.write(`${value}\n`);
67
+ }
68
+
69
+ function emit(stream, value, json, human) {
70
+ if (json) writeLine(stream, JSON.stringify(value, null, 2));
71
+ else writeLine(stream, human);
72
+ }
73
+
74
+ function idempotencyKey(kind) {
75
+ return `${kind}-${new Date().toISOString().replace(/[^0-9]/g, "").slice(0, 14)}-${randomUUID()}`;
76
+ }
77
+
78
+ function delay(seconds) {
79
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, seconds * 1_000));
80
+ }
81
+
82
+ function timeoutDeadline(seconds) {
83
+ return Date.now() + seconds * 1_000;
84
+ }
85
+
86
+ function ensureBeforeDeadline(deadline, label) {
87
+ if (Date.now() >= deadline) {
88
+ throw new CliError(`${label} did not reach a terminal state before the timeout.`, {
89
+ exitCode: 8,
90
+ });
91
+ }
92
+ }
93
+
94
+ function suggestedDelay(result, fallback = 2) {
95
+ const retry = Number(result.headers?.["retry-after"]);
96
+ const bodyDelay = Number(result.data?.pollAfterSeconds);
97
+ const candidate = Number.isFinite(retry) && retry > 0 ? retry : bodyDelay;
98
+ return Math.max(1, Math.min(30, Number.isFinite(candidate) && candidate > 0 ? candidate : fallback));
99
+ }
100
+
101
+ async function readHeaderValueFile(path, label) {
102
+ const value = (await readFile(resolve(path), "utf8")).trim();
103
+ if (!value || value.includes("\n") || value.includes("\r")) {
104
+ throw new CliError(`${label} must contain one non-empty HTTP header value.`, { exitCode: 2 });
105
+ }
106
+ if (Buffer.byteLength(value) > 64 * 1024) {
107
+ throw new CliError(`${label} is too large.`, { exitCode: 2 });
108
+ }
109
+ return value;
110
+ }
111
+
112
+ async function credentialFromFile(options) {
113
+ const tokenFile = stringOption(options, "token-file");
114
+ return tokenFile ? readHeaderValueFile(tokenFile, "The token file") : undefined;
115
+ }
116
+
117
+ async function requireTaskToken(store, taskId, options, env) {
118
+ const token = (await credentialFromFile(options)) || (await store.taskToken(taskId, env));
119
+ if (!token) {
120
+ throw new CliError(
121
+ `No credential is available for ${taskId}. Restore the private state file, set WARPMETAL_OWNER_TOKEN, or use --token-file.`,
122
+ { exitCode: 4 },
123
+ );
124
+ }
125
+ return token;
126
+ }
127
+
128
+ async function requireServerToken(store, serverId, options, env) {
129
+ const token = (await credentialFromFile(options)) || (await store.serverToken(serverId, env));
130
+ if (!token) {
131
+ throw new CliError(
132
+ `No credential is available for ${serverId}. Run warpmetal server login or provide a recovery token through the environment or --token-file.`,
133
+ { exitCode: 4 },
134
+ );
135
+ }
136
+ return token;
137
+ }
138
+
139
+ function safePreparedOrder(data, stateFile) {
140
+ return {
141
+ task: data.task,
142
+ warning: data.warning,
143
+ credential: {
144
+ stored: true,
145
+ stateFile,
146
+ printed: false,
147
+ },
148
+ };
149
+ }
150
+
151
+ function catalogHuman(data) {
152
+ return data.products
153
+ .map((product) => {
154
+ const header = `${product.id}: $${product.priceUsd}/${product.termDays} days`;
155
+ const systems = product.operatingSystems.map((system) => ` - ${system.name}`).join("\n");
156
+ return `${header}\n${systems}`;
157
+ })
158
+ .join("\n");
159
+ }
160
+
161
+ async function pollTask(client, taskId, token, timeoutSeconds) {
162
+ const deadline = timeoutDeadline(timeoutSeconds);
163
+ while (true) {
164
+ const result = await client.getTask(taskId, token);
165
+ if (TASK_TERMINAL_STATES.has(result.data?.task?.state)) return result;
166
+ ensureBeforeDeadline(deadline, `Task ${taskId}`);
167
+ await delay(suggestedDelay(result));
168
+ }
169
+ }
170
+
171
+ async function pollOperation(client, operationId, token, timeoutSeconds) {
172
+ const deadline = timeoutDeadline(timeoutSeconds);
173
+ while (true) {
174
+ const result = await client.getOperation(operationId, token);
175
+ if (OPERATION_TERMINAL_STATES.has(result.data?.operation?.state)) return result;
176
+ ensureBeforeDeadline(deadline, `Operation ${operationId}`);
177
+ await delay(suggestedDelay(result));
178
+ }
179
+ }
180
+
181
+ function challengeResult(taskId, checkoutBody, response) {
182
+ const paymentRequired = response.headers["payment-required"];
183
+ if (response.status === 402 && !paymentRequired) {
184
+ throw new CliError("WarpMetal returned HTTP 402 without PAYMENT-REQUIRED.");
185
+ }
186
+ return {
187
+ status: response.data?.status,
188
+ taskId,
189
+ paymentAttemptId:
190
+ response.data?.paymentAttemptId || response.headers["x-warpmetal-payment-attempt"],
191
+ paymentRequired,
192
+ checkoutBodySha256: createHash("sha256").update(checkoutBody).digest("hex"),
193
+ };
194
+ }
195
+
196
+ async function handleHealth(client, { json, stdout }) {
197
+ const result = await client.health();
198
+ emit(
199
+ stdout,
200
+ result.data,
201
+ json,
202
+ `Service: ${result.data?.status}\nPurchasing ready: ${Boolean(result.data?.purchasingReady)}`,
203
+ );
204
+ return result.data?.purchasingReady ? 0 : 3;
205
+ }
206
+
207
+ async function handleCatalog(client, options, { json, stdout }) {
208
+ const result = await client.catalog();
209
+ const plan = stringOption(options, "plan");
210
+ const data = plan
211
+ ? { ...result.data, products: result.data.products.filter((product) => product.id === plan) }
212
+ : result.data;
213
+ if (plan && data.products.length === 0) {
214
+ throw new CliError(`Unknown WarpMetal plan: ${plan}`, { exitCode: 2 });
215
+ }
216
+ emit(stdout, data, json, catalogHuman(data));
217
+ return 0;
218
+ }
219
+
220
+ async function handlePrepareOrder(client, store, options, context) {
221
+ const planId = stringOption(options, "plan", { required: true });
222
+ const hostname = stringOption(options, "hostname", { required: true });
223
+ const osName = stringOption(options, "os", { required: true });
224
+ const publicKeyFile = stringOption(options, "ssh-public-key-file", { required: true });
225
+ const email = stringOption(options, "email");
226
+
227
+ const health = await client.health();
228
+ if (!health.data?.purchasingReady) {
229
+ throw new CliError("WarpMetal purchasing is not ready. No order was created.", {
230
+ exitCode: 3,
231
+ details: health.data,
232
+ });
233
+ }
234
+ const catalog = (await client.catalog()).data;
235
+ const product = catalog.products.find((candidate) => candidate.id === planId);
236
+ if (!product) {
237
+ throw new CliError(`Unknown live WarpMetal plan: ${planId}`, { exitCode: 2 });
238
+ }
239
+ if (!product?.operatingSystems?.some((system) => system.name === osName)) {
240
+ throw new CliError(
241
+ `--os must exactly match a live free operating-system name for the ${planId} plan.`,
242
+ { exitCode: 2 },
243
+ );
244
+ }
245
+
246
+ const sshPublicKey = await readSshPublicKey(publicKeyFile);
247
+ const request = { planId, hostname, osName, sshPublicKey };
248
+ if (email) request.email = email;
249
+ const key = stringOption(options, "idempotency-key") || idempotencyKey("order");
250
+ const response = await client.prepareOrder(request, key);
251
+ const checkoutBody = JSON.stringify({ taskId: response.data.task.id });
252
+ await store.savePreparedOrder(response.data, checkoutBody);
253
+ const safe = safePreparedOrder(response.data, store.path);
254
+ emit(
255
+ context.stdout,
256
+ safe,
257
+ context.json,
258
+ `Prepared ${safe.task.id} for server ${safe.task.serverId}.\nRecovery credential saved to ${store.path} and not printed.`,
259
+ );
260
+ return 0;
261
+ }
262
+
263
+ async function handleTaskStatus(client, store, options, context) {
264
+ const taskId = stringOption(options, "task", { required: true });
265
+ const token = await requireTaskToken(store, taskId, options, context.env);
266
+ const timeout = integerOption(options, "timeout-seconds", 900);
267
+ const result = booleanOption(options, "wait")
268
+ ? await pollTask(client, taskId, token, timeout)
269
+ : await client.getTask(taskId, token);
270
+ emit(
271
+ context.stdout,
272
+ result.data,
273
+ context.json,
274
+ `${result.data.task.id}: ${result.data.task.state}${result.data.task.publicIp ? ` (${result.data.task.publicIp})` : ""}`,
275
+ );
276
+ return result.data.task.state === "manual_review" ? 6 : 0;
277
+ }
278
+
279
+ async function handleCheckoutChallenge(client, store, options, context) {
280
+ const taskId = stringOption(options, "task", { required: true });
281
+ const order = await store.order(taskId);
282
+ if (!order?.checkoutPath || !order?.checkoutBody) {
283
+ throw new CliError(`No exact checkout state exists for ${taskId}.`, { exitCode: 2 });
284
+ }
285
+ const token = await requireTaskToken(store, taskId, options, context.env);
286
+ const response = await client.checkout(order.checkoutPath, {
287
+ bodyText: order.checkoutBody,
288
+ token,
289
+ });
290
+ const safe = challengeResult(taskId, order.checkoutBody, response);
291
+ if (safe.paymentRequired) {
292
+ await store.savePaymentChallenge(taskId, safe);
293
+ }
294
+ emit(
295
+ context.stdout,
296
+ safe,
297
+ context.json,
298
+ response.status === 402
299
+ ? `Payment authorization required for ${taskId}.\nPAYMENT-REQUIRED: ${safe.paymentRequired}`
300
+ : `Checkout status for ${taskId}: ${safe.status}`,
301
+ );
302
+ return response.status === 409 ? 6 : response.status === 402 ? 7 : 0;
303
+ }
304
+
305
+ async function handleCheckoutSubmit(client, store, options, context) {
306
+ const taskId = stringOption(options, "task", { required: true });
307
+ const signatureFile = stringOption(options, "payment-signature-file", { required: true });
308
+ const paymentSignature = await readHeaderValueFile(signatureFile, "The payment signature file");
309
+ const order = await store.order(taskId);
310
+ if (!order?.checkoutPath || !order?.checkoutBody) {
311
+ throw new CliError(`No exact checkout state exists for ${taskId}.`, { exitCode: 2 });
312
+ }
313
+ const token = await requireTaskToken(store, taskId, options, context.env);
314
+ const wait = booleanOption(options, "wait");
315
+ const deadline = timeoutDeadline(integerOption(options, "timeout-seconds", 900));
316
+
317
+ while (true) {
318
+ const response = await client.checkout(order.checkoutPath, {
319
+ bodyText: order.checkoutBody,
320
+ token,
321
+ paymentSignature,
322
+ });
323
+ const safe = challengeResult(taskId, order.checkoutBody, response);
324
+ if (safe.paymentRequired) await store.savePaymentChallenge(taskId, safe);
325
+ const retryable =
326
+ response.status === 202 &&
327
+ ["payment_pending", "payment_finalizing"].includes(response.data?.status);
328
+ if (wait && retryable) {
329
+ ensureBeforeDeadline(deadline, `Checkout ${taskId}`);
330
+ await delay(suggestedDelay(response));
331
+ continue;
332
+ }
333
+
334
+ const output = {
335
+ ...safe,
336
+ task: response.data?.task,
337
+ message: response.data?.message,
338
+ };
339
+ emit(
340
+ context.stdout,
341
+ output,
342
+ context.json,
343
+ `Checkout status for ${taskId}: ${output.status}${output.message ? `\n${output.message}` : ""}`,
344
+ );
345
+ if (response.status === 409) return 6;
346
+ if (response.status === 402) return 7;
347
+ if (retryable) return 8;
348
+ return 0;
349
+ }
350
+ }
351
+
352
+ async function handleServerLogin(client, store, options, context) {
353
+ const serverId = stringOption(options, "server", { required: true });
354
+ const identity = stringOption(options, "identity", { required: true });
355
+ const challenge = (await client.issueSshChallenge(serverId)).data;
356
+ const signature = await signSshChallenge(challenge.payload, identity);
357
+ const token = (await client.exchangeSshChallenge(serverId, challenge.challengeId, signature)).data;
358
+ await store.saveAccessToken(serverId, token.accessToken, token.expiresAt);
359
+ const safe = {
360
+ serverId,
361
+ sshFingerprint: challenge.sshFingerprint,
362
+ accessTokenExpiresAt: token.expiresAt,
363
+ credential: { stored: true, stateFile: store.path, printed: false },
364
+ };
365
+ emit(
366
+ context.stdout,
367
+ safe,
368
+ context.json,
369
+ `Authenticated ${serverId} until ${token.expiresAt}. The access token was saved and not printed.`,
370
+ );
371
+ return 0;
372
+ }
373
+
374
+ async function handleServerGet(client, store, options, context) {
375
+ const serverId = stringOption(options, "server", { required: true });
376
+ const token = await requireServerToken(store, serverId, options, context.env);
377
+ const result = await client.getServer(serverId, token);
378
+ emit(
379
+ context.stdout,
380
+ result.data,
381
+ context.json,
382
+ `${serverId}: ${result.data.task.state}${result.data.task.publicIp ? ` (${result.data.task.publicIp})` : ""}`,
383
+ );
384
+ return 0;
385
+ }
386
+
387
+ async function handleServerPower(client, store, options, context) {
388
+ const serverId = stringOption(options, "server", { required: true });
389
+ const action = stringOption(options, "action", { required: true });
390
+ const confirmation = stringOption(options, "confirm", { required: true });
391
+ if (!["boot", "reboot", "shutdown"].includes(action)) {
392
+ throw new CliError("--action must be boot, reboot, or shutdown.", { exitCode: 2 });
393
+ }
394
+ if (confirmation !== action) {
395
+ throw new CliError(`Confirm this operation with --confirm ${action}.`, { exitCode: 2 });
396
+ }
397
+ const token = await requireServerToken(store, serverId, options, context.env);
398
+ const key = stringOption(options, "idempotency-key") || idempotencyKey(`power-${action}`);
399
+ let result = await client.powerServer(serverId, action, token, key);
400
+ const operationId = result.data?.operation?.id;
401
+ if (!operationId) throw new CliError("WarpMetal did not return an operation ID.");
402
+ await store.saveOperation(operationId, serverId, `power:${action}`);
403
+ if (booleanOption(options, "wait")) {
404
+ result = await pollOperation(
405
+ client,
406
+ operationId,
407
+ token,
408
+ integerOption(options, "timeout-seconds", 900),
409
+ );
410
+ }
411
+ emit(
412
+ context.stdout,
413
+ result.data,
414
+ context.json,
415
+ `Power ${action} operation ${operationId}: ${result.data.operation.state}`,
416
+ );
417
+ return result.data.operation.state === "manual_review" ? 6 : 0;
418
+ }
419
+
420
+ async function handleOperationGet(client, store, options, context) {
421
+ const operationId = stringOption(options, "operation", { required: true });
422
+ const saved = await store.operation(operationId);
423
+ const serverId = stringOption(options, "server") || saved?.serverId;
424
+ if (!serverId) {
425
+ throw new CliError("--server is required when the operation is not present in local state.", {
426
+ exitCode: 2,
427
+ });
428
+ }
429
+ const token = await requireServerToken(store, serverId, options, context.env);
430
+ const result = booleanOption(options, "wait")
431
+ ? await pollOperation(
432
+ client,
433
+ operationId,
434
+ token,
435
+ integerOption(options, "timeout-seconds", 900),
436
+ )
437
+ : await client.getOperation(operationId, token);
438
+ emit(
439
+ context.stdout,
440
+ result.data,
441
+ context.json,
442
+ `${operationId}: ${result.data.operation.state}`,
443
+ );
444
+ return result.data.operation.state === "manual_review" ? 6 : 0;
445
+ }
446
+
447
+ async function dispatch(positionals, options, context) {
448
+ const command = positionals.join(" ");
449
+ if (!command || command === "help" || booleanOption(options, "help")) {
450
+ writeLine(context.stdout, HELP.trimEnd());
451
+ return 0;
452
+ }
453
+ if (command === "version") {
454
+ rejectUnknownOptions(options, ["json"]);
455
+ if (context.json) emit(context.stdout, { version: VERSION }, true, VERSION);
456
+ else writeLine(context.stdout, VERSION);
457
+ return 0;
458
+ }
459
+ if (command === "agent install") {
460
+ rejectUnknownOptions(options, [...COMMON_OPTIONS, "target", "scope", "force"]);
461
+ const target = stringOption(options, "target", { required: true });
462
+ const scope = stringOption(options, "scope") || "user";
463
+ const installed = await installSkill(target, {
464
+ scope,
465
+ force: booleanOption(options, "force"),
466
+ cwd: context.cwd,
467
+ env: context.env,
468
+ });
469
+ emit(
470
+ context.stdout,
471
+ { installed },
472
+ context.json,
473
+ installed.map((entry) => `Installed WarpMetal skill for ${entry.target}: ${entry.path}`).join("\n"),
474
+ );
475
+ return 0;
476
+ }
477
+
478
+ const baseUrl = stringOption(options, "base-url");
479
+ const stateDir = stringOption(options, "state-dir") || resolveStateDirectory({ env: context.env });
480
+ const client = new WarpMetalClient({
481
+ baseUrl: baseUrl || context.env.WARPMETAL_API_URL,
482
+ fetchImpl: context.fetchImpl,
483
+ });
484
+ const store = new StateStore(stateDir);
485
+
486
+ switch (command) {
487
+ case "health":
488
+ rejectUnknownOptions(options, COMMON_OPTIONS);
489
+ return handleHealth(client, context);
490
+ case "catalog":
491
+ rejectUnknownOptions(options, [...COMMON_OPTIONS, "plan"]);
492
+ return handleCatalog(client, options, context);
493
+ case "order prepare":
494
+ rejectUnknownOptions(options, [
495
+ ...COMMON_OPTIONS,
496
+ "plan",
497
+ "hostname",
498
+ "os",
499
+ "ssh-public-key-file",
500
+ "email",
501
+ "idempotency-key",
502
+ ]);
503
+ return handlePrepareOrder(client, store, options, context);
504
+ case "order status":
505
+ rejectUnknownOptions(options, [
506
+ ...COMMON_OPTIONS,
507
+ "task",
508
+ "token-file",
509
+ "wait",
510
+ "timeout-seconds",
511
+ ]);
512
+ return handleTaskStatus(client, store, options, context);
513
+ case "checkout challenge":
514
+ rejectUnknownOptions(options, [...COMMON_OPTIONS, "task", "token-file"]);
515
+ return handleCheckoutChallenge(client, store, options, context);
516
+ case "checkout submit":
517
+ rejectUnknownOptions(options, [
518
+ ...COMMON_OPTIONS,
519
+ "task",
520
+ "token-file",
521
+ "payment-signature-file",
522
+ "wait",
523
+ "timeout-seconds",
524
+ ]);
525
+ return handleCheckoutSubmit(client, store, options, context);
526
+ case "server login":
527
+ rejectUnknownOptions(options, [...COMMON_OPTIONS, "server", "identity"]);
528
+ return handleServerLogin(client, store, options, context);
529
+ case "server get":
530
+ rejectUnknownOptions(options, [...COMMON_OPTIONS, "server", "token-file"]);
531
+ return handleServerGet(client, store, options, context);
532
+ case "server power":
533
+ rejectUnknownOptions(options, [
534
+ ...COMMON_OPTIONS,
535
+ "server",
536
+ "token-file",
537
+ "action",
538
+ "confirm",
539
+ "idempotency-key",
540
+ "wait",
541
+ "timeout-seconds",
542
+ ]);
543
+ return handleServerPower(client, store, options, context);
544
+ case "operation get":
545
+ rejectUnknownOptions(options, [
546
+ ...COMMON_OPTIONS,
547
+ "operation",
548
+ "server",
549
+ "token-file",
550
+ "wait",
551
+ "timeout-seconds",
552
+ ]);
553
+ return handleOperationGet(client, store, options, context);
554
+ case "state list": {
555
+ rejectUnknownOptions(options, COMMON_OPTIONS);
556
+ const summary = await store.summary();
557
+ emit(
558
+ context.stdout,
559
+ summary,
560
+ context.json,
561
+ `State: ${summary.stateFile}\nOrders: ${summary.orders.length}\nServers: ${summary.servers.length}\nOperations: ${summary.operations.length}`,
562
+ );
563
+ return 0;
564
+ }
565
+ default:
566
+ throw new CliError(`Unknown command: ${command}`, { exitCode: 2 });
567
+ }
568
+ }
569
+
570
+ export async function main(
571
+ argv,
572
+ {
573
+ stdout = process.stdout,
574
+ stderr = process.stderr,
575
+ env = process.env,
576
+ cwd = process.cwd(),
577
+ fetchImpl = globalThis.fetch,
578
+ } = {},
579
+ ) {
580
+ if (argv.includes("--version")) {
581
+ writeLine(stdout, VERSION);
582
+ return 0;
583
+ }
584
+ const json = argv.includes("--json");
585
+ try {
586
+ const { positionals, options } = parseArguments(argv);
587
+ return await dispatch(positionals, options, { stdout, stderr, env, cwd, fetchImpl, json });
588
+ } catch (error) {
589
+ const message = toErrorMessage(error);
590
+ if (json) {
591
+ writeLine(
592
+ stderr,
593
+ JSON.stringify({
594
+ error: {
595
+ type: error?.name || "Error",
596
+ code: error?.code,
597
+ message,
598
+ retryAfterSeconds: error?.retryAfter ? Number(error.retryAfter) : undefined,
599
+ },
600
+ }),
601
+ );
602
+ } else {
603
+ writeLine(stderr, `Error: ${message}`);
604
+ }
605
+ return error instanceof CliError ? error.exitCode : 1;
606
+ }
607
+ }
608
+
609
+ export { HELP, VERSION };
package/src/errors.js ADDED
@@ -0,0 +1,35 @@
1
+ export class CliError extends Error {
2
+ constructor(message, { exitCode = 1, details = undefined } = {}) {
3
+ super(message);
4
+ this.name = "CliError";
5
+ this.exitCode = exitCode;
6
+ this.details = details;
7
+ }
8
+ }
9
+
10
+ export class ApiError extends CliError {
11
+ constructor(message, { status, code, retryAfter, body } = {}) {
12
+ const exitCode =
13
+ status === 401 || status === 403
14
+ ? 4
15
+ : status === 409
16
+ ? 5
17
+ : status === 429 || status === 503
18
+ ? 3
19
+ : 1;
20
+ super(message, { exitCode, details: body });
21
+ this.name = "ApiError";
22
+ this.status = status;
23
+ this.code = code;
24
+ this.retryAfter = retryAfter;
25
+ this.body = body;
26
+ }
27
+ }
28
+
29
+ export function toErrorMessage(error) {
30
+ if (error instanceof ApiError) {
31
+ const label = [error.status, error.code].filter(Boolean).join(" ");
32
+ return `WarpMetal API ${label}: ${error.message}`;
33
+ }
34
+ return error instanceof Error ? error.message : String(error);
35
+ }