xapi-to 0.1.18 → 0.1.20

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/dist/index.js CHANGED
@@ -1,549 +1,54 @@
1
1
  #!/usr/bin/env node
2
- var __defProp = Object.defineProperty;
3
- var __export = (target, all) => {
4
- for (var name in all)
5
- __defProp(target, name, { get: all[name], enumerable: true });
6
- };
7
-
8
- // src/config.ts
9
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
10
-
11
- // src/format.ts
12
- function getFormat() {
13
- const f = process.env.XAPI_OUTPUT || "json";
14
- if (f === "pretty" || f === "table") return f;
15
- return "json";
16
- }
17
- function output(data, format) {
18
- const fmt = format || getFormat();
19
- if (fmt === "json") {
20
- console.log(JSON.stringify(data));
21
- return;
22
- }
23
- if (fmt === "pretty") {
24
- console.log(JSON.stringify(data, null, 2));
25
- return;
26
- }
27
- if (fmt === "table") {
28
- const rows = tableRows(data);
29
- if (rows) {
30
- printTable(rows);
31
- return;
32
- }
33
- }
34
- console.log(JSON.stringify(data, null, 2));
35
- }
36
- function tableRows(data) {
37
- if (Array.isArray(data)) return normalizeRows(data, "value");
38
- if (!data || typeof data !== "object") return null;
39
- const obj = data;
40
- const preferredKeys = ["items", "actions", "results", "services", "categories", "bindings", "providers"];
41
- for (const key of preferredKeys) {
42
- const value = obj[key];
43
- if (Array.isArray(value)) return normalizeRows(value, singularKey(key));
44
- }
45
- const firstArray = Object.entries(obj).find(([, value]) => Array.isArray(value));
46
- return firstArray ? normalizeRows(firstArray[1], singularKey(firstArray[0])) : null;
47
- }
48
- function normalizeRows(rows, primitiveKey) {
49
- return rows.map((row) => {
50
- if (row && typeof row === "object" && !Array.isArray(row)) {
51
- return row;
52
- }
53
- return { [primitiveKey]: row };
54
- });
55
- }
56
- function singularKey(key) {
57
- if (key === "categories") return "category";
58
- if (key.endsWith("ies")) return `${key.slice(0, -3)}y`;
59
- if (key.endsWith("s")) return key.slice(0, -1);
60
- return "value";
61
- }
62
- function formatCell(value) {
63
- if (value === null || value === void 0) return "";
64
- if (typeof value === "object") return JSON.stringify(value);
65
- return String(value);
66
- }
67
- function printTable(rows) {
68
- if (rows.length === 0) {
69
- console.log("(empty)");
70
- return;
71
- }
72
- const keys = Object.keys(rows[0]);
73
- const widths = keys.map(
74
- (k) => Math.min(40, Math.max(k.length, ...rows.map((r) => formatCell(r[k]).length)))
75
- );
76
- const sep = widths.map((w) => "-".repeat(w)).join(" ");
77
- const header = keys.map((k, i) => k.padEnd(widths[i])).join(" ");
78
- console.log(header);
79
- console.log(sep);
80
- for (const row of rows) {
81
- const line = keys.map((k, i) => formatCell(row[k]).slice(0, widths[i]).padEnd(widths[i])).join(" ");
82
- console.log(line);
83
- }
84
- }
85
- function err(msg, detail) {
86
- if (process.stderr.isTTY) {
87
- console.error(`Error: ${msg}`);
88
- if (detail !== void 0) console.error(` ${detail}`);
89
- } else {
90
- const out = { error: msg };
91
- if (detail !== void 0) out.detail = detail;
92
- console.error(JSON.stringify(out));
93
- }
94
- process.exit(1);
95
- }
96
-
97
- // src/config.ts
98
- import { homedir } from "os";
99
- import { join } from "path";
100
- var XAPI_ACTION_HOST = process.env.XAPI_ACTION_HOST || "action.xapi.to";
101
- var XAPI_API_HOST = process.env.XAPI_API_HOST || "api.xapi.to";
102
- function scheme(host) {
103
- return isLoopbackHost(host) ? "http" : "https";
104
- }
105
- var ALLOWED_HOST_EXACT = ["xapi.to", "xapi.xyz"];
106
- var ALLOWED_HOST_SUFFIXES = [".xapi.to", ".xapi.xyz"];
107
- function hostnameOf(hostOrUrl) {
108
- const raw = hostOrUrl.includes("://") ? hostOrUrl : `http://${hostOrUrl}`;
109
- try {
110
- return new URL(raw).hostname.toLowerCase();
111
- } catch {
112
- return "";
113
- }
114
- }
115
- function isLoopbackIPv4(h) {
116
- const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
117
- if (!m) return false;
118
- const octets = m.slice(1).map(Number);
119
- return octets.every((o) => o <= 255) && octets[0] === 127;
120
- }
121
- function isLoopbackHostname(h) {
122
- return h === "localhost" || h.endsWith(".localhost") || h === "::1" || h === "[::1]" || isLoopbackIPv4(h);
123
- }
124
- function isLoopbackHost(hostOrUrl) {
125
- return isLoopbackHostname(hostnameOf(hostOrUrl));
126
- }
127
- function isAllowedHost(hostOrUrl) {
128
- const h = hostnameOf(hostOrUrl);
129
- if (!h) return false;
130
- if (isLoopbackHostname(h)) return true;
131
- if (ALLOWED_HOST_EXACT.includes(h)) return true;
132
- return ALLOWED_HOST_SUFFIXES.some((suffix) => h.endsWith(suffix));
133
- }
134
- function assertAllowedHost(hostOrUrl) {
135
- if (!isAllowedHost(hostOrUrl)) {
136
- throw new Error(
137
- `refusing to contact untrusted host "${hostnameOf(hostOrUrl) || hostOrUrl}": the xapi API key may only be sent to *.xapi.to, *.xapi.xyz, or localhost`
138
- );
139
- }
140
- }
141
- var CONFIG_DIR = join(homedir(), ".xapi");
142
- var CONFIG_FILE = join(CONFIG_DIR, "config.json");
143
- function loadFileConfig() {
144
- if (!existsSync(CONFIG_FILE)) return {};
145
- try {
146
- return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
147
- } catch {
148
- return {};
149
- }
150
- }
151
- function getConfig() {
152
- const file = loadFileConfig();
153
- return {
154
- actionHost: XAPI_ACTION_HOST,
155
- apiKey: process.env.XAPI_KEY || process.env.XAPI_API_KEY || file.apiKey
156
- };
157
- }
158
- function requireApiKey(cfg) {
159
- if (!cfg.apiKey) {
160
- err("API key not configured", 'Run "npx xapi-to register" to create an account, or "npx xapi-to config set apiKey=<key>" to set an existing key.');
161
- }
162
- }
163
- function saveConfig(updates) {
164
- const current = loadFileConfig();
165
- const merged = { ...current, ...updates };
166
- if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
167
- writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2), { mode: 384 });
168
- }
169
- function showConfig() {
170
- const cfg = getConfig();
171
- const file = loadFileConfig();
172
- console.log(JSON.stringify({
173
- actionHost: cfg.actionHost,
174
- apiKey: cfg.apiKey ? `${cfg.apiKey.slice(0, 8)}...` : void 0,
175
- source: {
176
- apiKey: process.env.XAPI_KEY || process.env.XAPI_API_KEY ? "env" : file.apiKey ? "file" : "none"
177
- },
178
- configFile: CONFIG_FILE
179
- }, null, 2));
180
- }
181
-
182
- // src/client.ts
183
- import { open, rm } from "fs/promises";
184
- import { resolve } from "path";
185
- import { Readable, Transform } from "stream";
186
- import { pipeline } from "stream/promises";
187
- var DEFAULT_TIMEOUT_MS = 3e4;
188
- var EXECUTE_TIMEOUT_MS = 6e4;
189
- var IDEMPOTENT_RETRIES = 2;
190
- var RETRY_BASE_DELAY_MS = 500;
191
- var RETRY_MAX_DELAY_MS = 8e3;
192
- var HttpError = class extends Error {
193
- constructor(status, detail, retryAfterMs) {
194
- super(`HTTP ${status}: ${detail}`);
195
- this.status = status;
196
- this.retryAfterMs = retryAfterMs;
197
- this.name = "HttpError";
198
- }
199
- status;
200
- retryAfterMs;
201
- };
202
- var RequestTimeoutError = class extends Error {
203
- constructor(timeoutMs) {
204
- super(`request timed out after ${timeoutMs}ms`);
205
- this.timeoutMs = timeoutMs;
206
- this.name = "RequestTimeoutError";
207
- }
208
- timeoutMs;
209
- };
210
- function isRetryableStatus(status) {
211
- return status === 408 || status === 429 || status === 502 || status === 503 || status === 504;
212
- }
213
- function isRetryableNetworkError(e) {
214
- if (!(e instanceof Error)) return false;
215
- if (e instanceof HttpError || e instanceof RequestTimeoutError) return false;
216
- if (e.name === "AbortError") return false;
217
- return e instanceof TypeError || /network|fetch failed|econn|etimedout|eai_again|socket|dns/i.test(e.message);
218
- }
219
- function isRetryableRequestError(e) {
220
- if (e instanceof HttpError) return isRetryableStatus(e.status);
221
- if (e instanceof RequestTimeoutError) return true;
222
- return isRetryableNetworkError(e);
223
- }
224
- function retryBaseDelayMs() {
225
- const override = Number(process.env.XAPI_RETRY_BASE_MS);
226
- return Number.isFinite(override) && override > 0 ? override : RETRY_BASE_DELAY_MS;
227
- }
228
- function backoffDelayMs(attempt, retryAfterMs) {
229
- if (retryAfterMs !== void 0 && Number.isFinite(retryAfterMs) && retryAfterMs >= 0) {
230
- return Math.min(retryAfterMs, RETRY_MAX_DELAY_MS);
231
- }
232
- const capped = Math.min(retryBaseDelayMs() * 2 ** attempt, RETRY_MAX_DELAY_MS);
233
- return capped / 2 + Math.random() * (capped / 2);
234
- }
235
- function parseRetryAfterMs(res) {
236
- const header = res.headers.get("retry-after");
237
- if (!header) return void 0;
238
- const seconds = Number(header);
239
- if (Number.isFinite(seconds)) return seconds * 1e3;
240
- const at = Date.parse(header);
241
- return Number.isFinite(at) ? Math.max(0, at - Date.now()) : void 0;
242
- }
243
- function sleep(ms) {
244
- return new Promise((resolve2) => setTimeout(resolve2, ms));
245
- }
246
- async function request(url, options, timeoutMs = DEFAULT_TIMEOUT_MS, retries = 0) {
247
- assertAllowedHost(url);
248
- let attempt = 0;
249
- while (true) {
250
- const controller = new AbortController();
251
- let timedOut = false;
252
- const timer = setTimeout(() => {
253
- timedOut = true;
254
- controller.abort();
255
- }, timeoutMs);
256
- try {
257
- const res = await fetch(url, { ...options, redirect: "manual", signal: controller.signal });
258
- if (res.status >= 300 && res.status < 400) {
259
- throw new Error(
260
- `refusing to follow redirect to "${res.headers.get("location") ?? "?"}" (would forward the API key past the host allowlist)`
261
- );
262
- }
263
- if (!res.ok) {
264
- const retryAfterMs = isRetryableStatus(res.status) ? parseRetryAfterMs(res) : void 0;
265
- if (isRetryableStatus(res.status) && attempt < retries) {
266
- await res.text().catch(() => "");
267
- clearTimeout(timer);
268
- await sleep(backoffDelayMs(attempt, retryAfterMs));
269
- attempt++;
270
- continue;
271
- }
272
- const text2 = await res.text();
273
- throw new HttpError(res.status, text2.slice(0, 300), retryAfterMs);
274
- }
275
- if (res.status === 204) {
276
- return void 0;
277
- }
278
- const text = await res.text();
279
- if (!text.trim()) {
280
- return void 0;
281
- }
282
- const body = JSON.parse(text);
283
- if (body && typeof body === "object" && "success" in body && body.success === false) {
284
- const data = body.data;
285
- if (data?.statusCode === 401 || data?.error === "Unauthorized") {
286
- throw new Error(
287
- "Authentication failed: " + (data.message || "Invalid or missing API key") + '. Run "npx xapi-to config set apiKey=<key>" to update your key.'
288
- );
289
- }
290
- if (data?.error === "OAuth Required" || data?.statusCode === 403 && data?.message?.includes("OAuth")) {
291
- throw new Error(
292
- (data.message || "OAuth authorization required") + '. Run "xapi-to oauth bind" to connect your account.'
293
- );
294
- }
295
- }
296
- return body;
297
- } catch (e) {
298
- if (timedOut) {
299
- throw new RequestTimeoutError(timeoutMs);
300
- }
301
- if (isRetryableNetworkError(e) && attempt < retries) {
302
- clearTimeout(timer);
303
- await sleep(backoffDelayMs(attempt));
304
- attempt++;
305
- continue;
306
- }
307
- throw e;
308
- } finally {
309
- clearTimeout(timer);
310
- }
311
- }
312
- }
313
- function headers(apiKey) {
314
- const h = { "Content-Type": "application/json" };
315
- if (apiKey) h["XAPI-Key"] = apiKey;
316
- return h;
317
- }
318
- function baseUrl(opts) {
319
- return `${scheme(opts.actionHost)}://${opts.actionHost}`;
320
- }
321
- async function actionList(opts, params = {}) {
322
- const url = new URL(`${baseUrl(opts)}/v1/actions`);
323
- if (params.page) url.searchParams.set("page", String(params.page));
324
- if (params.page_size) url.searchParams.set("page_size", String(params.page_size));
325
- if (params.category) url.searchParams.set("category", params.category);
326
- if (params.source) url.searchParams.set("source", params.source);
327
- if (params.service_id) url.searchParams.set("service_id", params.service_id);
328
- return request(
329
- url.toString(),
330
- { method: "GET", headers: headers(opts.apiKey) },
331
- DEFAULT_TIMEOUT_MS,
332
- IDEMPOTENT_RETRIES
333
- );
334
- }
335
- async function actionSearch(query, opts, params = {}) {
336
- const url = new URL(`${baseUrl(opts)}/v1/actions/search`);
337
- url.searchParams.set("q", query);
338
- if (params.category) url.searchParams.set("category", params.category);
339
- if (params.source) url.searchParams.set("source", params.source);
340
- if (params.page) url.searchParams.set("page", String(params.page));
341
- if (params.page_size) url.searchParams.set("page_size", String(params.page_size));
342
- return request(
343
- url.toString(),
344
- { method: "GET", headers: headers(opts.apiKey) },
345
- DEFAULT_TIMEOUT_MS,
346
- IDEMPOTENT_RETRIES
347
- );
348
- }
349
- async function actionCategories(opts, params = {}) {
350
- const url = new URL(`${baseUrl(opts)}/v1/actions/categories`);
351
- if (params.source) url.searchParams.set("source", params.source);
352
- return request(
353
- url.toString(),
354
- { method: "GET", headers: headers(opts.apiKey) },
355
- DEFAULT_TIMEOUT_MS,
356
- IDEMPOTENT_RETRIES
357
- );
358
- }
359
- async function actionGet(id, opts) {
360
- return request(
361
- `${baseUrl(opts)}/v1/actions/${encodeURIComponent(id)}`,
362
- { method: "GET", headers: headers(opts.apiKey) },
363
- DEFAULT_TIMEOUT_MS,
364
- IDEMPOTENT_RETRIES
365
- );
366
- }
367
- async function actionCall(actionId, input, opts, httpMethod, retries = 0, timeoutMs = EXECUTE_TIMEOUT_MS) {
368
- return request(
369
- `${baseUrl(opts)}/v1/actions/execute`,
370
- {
371
- method: "POST",
372
- headers: headers(opts.apiKey),
373
- body: JSON.stringify({ action_id: actionId, ...httpMethod ? { method: httpMethod } : {}, input })
374
- },
375
- Math.min(timeoutMs, EXECUTE_TIMEOUT_MS),
376
- retries
377
- );
378
- }
379
- async function actionDownload(actionId, input, opts, outputPath, httpMethod) {
380
- const controller = new AbortController();
381
- let timedOut = false;
382
- const timer = setTimeout(() => {
383
- timedOut = true;
384
- controller.abort();
385
- }, EXECUTE_TIMEOUT_MS);
386
- const target = resolve(outputPath);
387
- let file;
388
- let complete = false;
389
- try {
390
- try {
391
- file = await open(target, "wx");
392
- } catch (error) {
393
- if (error?.code === "EEXIST") {
394
- throw new Error(`Output file already exists: ${target}`);
395
- }
396
- throw error;
397
- }
398
- const url = `${baseUrl(opts)}/v1/actions/execute`;
399
- assertAllowedHost(url);
400
- const res = await fetch(url, {
401
- method: "POST",
402
- headers: headers(opts.apiKey),
403
- body: JSON.stringify({
404
- action_id: actionId,
405
- ...httpMethod ? { method: httpMethod } : {},
406
- input,
407
- response_mode: "raw"
408
- }),
409
- redirect: "manual",
410
- signal: controller.signal
411
- });
412
- if (res.status >= 300 && res.status < 400) {
413
- throw new Error(
414
- `refusing to follow redirect to "${res.headers.get("location") ?? "?"}" (would forward the API key past the host allowlist)`
415
- );
416
- }
417
- if (!res.ok) {
418
- const text = await res.text();
419
- throw new HttpError(
420
- res.status,
421
- text.slice(0, 300),
422
- isRetryableStatus(res.status) ? parseRetryAfterMs(res) : void 0
423
- );
424
- }
425
- let bytes = 0;
426
- if (res.body) {
427
- const source = Readable.fromWeb(res.body);
428
- const counter = new Transform({
429
- transform(chunk, _encoding, callback) {
430
- bytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk);
431
- callback(null, chunk);
432
- }
433
- });
434
- await pipeline(source, counter, file.createWriteStream());
435
- } else {
436
- await file.close();
437
- }
438
- complete = true;
439
- return {
440
- output: target,
441
- bytes,
442
- contentType: res.headers.get("content-type") || void 0,
443
- contentDisposition: res.headers.get("content-disposition") || void 0,
444
- status: res.status
445
- };
446
- } catch (error) {
447
- if (timedOut) throw new RequestTimeoutError(EXECUTE_TIMEOUT_MS);
448
- throw error;
449
- } finally {
450
- clearTimeout(timer);
451
- if (!complete && file) {
452
- await file.close().catch(() => void 0);
453
- await rm(target, { force: true }).catch(() => void 0);
454
- }
455
- }
456
- }
457
- async function actionServices(opts, params = {}) {
458
- const url = new URL(`${baseUrl(opts)}/v1/actions/services`);
459
- if (params.page) url.searchParams.set("page", String(params.page));
460
- if (params.page_size) url.searchParams.set("page_size", String(params.page_size));
461
- if (params.category) url.searchParams.set("category", params.category);
462
- return request(
463
- url.toString(),
464
- { method: "GET", headers: headers(opts.apiKey) },
465
- DEFAULT_TIMEOUT_MS,
466
- IDEMPOTENT_RETRIES
467
- );
468
- }
469
- async function healthCheck(opts) {
470
- return request(
471
- `${baseUrl(opts)}/health`,
472
- { method: "GET", headers: headers(opts.apiKey) },
473
- 5e3,
474
- 0
475
- // health is a quick connectivity probe — fail fast, don't retry
476
- );
477
- }
478
- async function loginWithApiKey(apiKey, apiHost) {
479
- return request(
480
- `${scheme(apiHost)}://${apiHost}/api/auth/login/apikey`,
481
- {
482
- method: "POST",
483
- headers: { "Content-Type": "application/json" },
484
- body: JSON.stringify({ apiKey })
485
- },
486
- DEFAULT_TIMEOUT_MS,
487
- IDEMPOTENT_RETRIES
488
- // auth exchange has no side effect — safe to retry
489
- );
490
- }
491
- function jwtHeaders(jwtToken) {
492
- return { "Content-Type": "application/json", Authorization: `Bearer ${jwtToken}` };
493
- }
494
- async function listKeys(jwtToken, apiHost) {
495
- return request(
496
- `${scheme(apiHost)}://${apiHost}/api/keys`,
497
- { method: "GET", headers: jwtHeaders(jwtToken) },
498
- DEFAULT_TIMEOUT_MS,
499
- IDEMPOTENT_RETRIES
500
- );
501
- }
502
- async function enableOAuthForKey(keyId, plaintextKey, jwtToken, apiHost) {
503
- return request(
504
- `${scheme(apiHost)}://${apiHost}/api/keys/${keyId}/enable-oauth`,
505
- {
506
- method: "POST",
507
- headers: jwtHeaders(jwtToken),
508
- body: JSON.stringify({ plaintextKey })
509
- }
510
- );
511
- }
512
- async function listOAuthProviders(apiHost) {
513
- return request(
514
- `${scheme(apiHost)}://${apiHost}/api/oauth/providers`,
515
- { method: "GET", headers: { "Content-Type": "application/json" } },
516
- DEFAULT_TIMEOUT_MS,
517
- IDEMPOTENT_RETRIES
518
- );
519
- }
520
- async function initiateOAuth(apiKeyId, providerId, jwtToken, apiHost, scopes) {
521
- const body = { apiKeyId, providerId };
522
- if (scopes) body.scopes = scopes;
523
- return request(
524
- `${scheme(apiHost)}://${apiHost}/api/oauth/authorize`,
525
- {
526
- method: "POST",
527
- headers: jwtHeaders(jwtToken),
528
- body: JSON.stringify(body)
529
- }
530
- );
531
- }
532
- async function listOAuthBindings(jwtToken, apiHost) {
533
- return request(
534
- `${scheme(apiHost)}://${apiHost}/api/oauth/bindings`,
535
- { method: "GET", headers: jwtHeaders(jwtToken) },
536
- DEFAULT_TIMEOUT_MS,
537
- IDEMPOTENT_RETRIES
538
- );
539
- }
540
- async function deleteOAuthBinding(bindingId, jwtToken, apiHost) {
541
- const result = await request(
542
- `${scheme(apiHost)}://${apiHost}/api/oauth/bindings/${bindingId}`,
543
- { method: "DELETE", headers: jwtHeaders(jwtToken) }
544
- );
545
- return result ?? { success: true };
546
- }
2
+ import {
3
+ HttpError,
4
+ XAPI_API_HOST,
5
+ XAPI_SANDBOX_HOST,
6
+ __export,
7
+ actionBatch,
8
+ actionCall,
9
+ actionCategories,
10
+ actionDownload,
11
+ actionGet,
12
+ actionList,
13
+ actionSearch,
14
+ actionServices,
15
+ actionStream,
16
+ assertAllowedHost,
17
+ deleteOAuthBinding,
18
+ enableOAuthForKey,
19
+ err,
20
+ getApiKeySource,
21
+ getConfig,
22
+ getFormat,
23
+ healthCheck,
24
+ initiateOAuth,
25
+ isRetryableRequestError,
26
+ listKeys,
27
+ listOAuthBindings,
28
+ listOAuthProviders,
29
+ loginWithApiKey,
30
+ output,
31
+ request,
32
+ requireApiKey,
33
+ sandboxAudit,
34
+ sandboxCreate,
35
+ sandboxExec,
36
+ sandboxExtension,
37
+ sandboxFileList,
38
+ sandboxFileRead,
39
+ sandboxFileWrite,
40
+ sandboxGet,
41
+ sandboxHistory,
42
+ sandboxList,
43
+ sandboxOfferings,
44
+ sandboxPort,
45
+ sandboxQuote,
46
+ sandboxStateAction,
47
+ sandboxWait,
48
+ saveConfig,
49
+ scheme,
50
+ showConfig
51
+ } from "./chunk-TYY6JR6O.js";
547
52
 
548
53
  // src/codegen.ts
549
54
  var TARGET_MAP = {
@@ -618,7 +123,7 @@ function validateHost(host) {
618
123
  throw new Error(`invalid actionHost: "${host}" \u2014 must be a valid hostname with optional port`);
619
124
  }
620
125
  }
621
- function baseUrl2(actionHost) {
126
+ function baseUrl(actionHost) {
622
127
  validateHost(actionHost);
623
128
  return `${scheme(actionHost)}://${actionHost}/v1/actions/execute`;
624
129
  }
@@ -634,7 +139,7 @@ function shellEscape(s) {
634
139
  return s.replace(/'/g, "'\\''");
635
140
  }
636
141
  function genCurl(params) {
637
- const url = baseUrl2(params.actionHost);
142
+ const url = baseUrl(params.actionHost);
638
143
  const body = jsonBody(params.actionId, params.input, params.method);
639
144
  return [
640
145
  "# Set XAPI_KEY env var or replace with your key",
@@ -645,7 +150,7 @@ function genCurl(params) {
645
150
  ].join("\n");
646
151
  }
647
152
  function genPython(lib, params) {
648
- const url = baseUrl2(params.actionHost);
153
+ const url = baseUrl(params.actionHost);
649
154
  const payload = { action_id: params.actionId, ...params.method ? { method: params.method } : {}, input: params.input };
650
155
  return [
651
156
  `# pip install ${lib}`,
@@ -665,7 +170,7 @@ function genPython(lib, params) {
665
170
  ].join("\n");
666
171
  }
667
172
  function genJavaScriptFetch(params) {
668
- const url = baseUrl2(params.actionHost);
173
+ const url = baseUrl(params.actionHost);
669
174
  const body = jsonBody(params.actionId, params.input, params.method);
670
175
  return [
671
176
  "// Set XAPI_KEY env var or replace with your key",
@@ -681,7 +186,7 @@ function genJavaScriptFetch(params) {
681
186
  ].join("\n");
682
187
  }
683
188
  function genJavaScriptAxios(params) {
684
- const url = baseUrl2(params.actionHost);
189
+ const url = baseUrl(params.actionHost);
685
190
  const body = jsonBody(params.actionId, params.input, params.method);
686
191
  return [
687
192
  "// npm install axios",
@@ -702,7 +207,7 @@ function genJavaScriptAxios(params) {
702
207
  ].join("\n");
703
208
  }
704
209
  function genTypescriptFetch(params) {
705
- const url = baseUrl2(params.actionHost);
210
+ const url = baseUrl(params.actionHost);
706
211
  const body = jsonBody(params.actionId, params.input, params.method);
707
212
  return [
708
213
  "// Set XAPI_KEY env var or replace with your key",
@@ -719,7 +224,7 @@ function genTypescriptFetch(params) {
719
224
  ].join("\n");
720
225
  }
721
226
  function genGo(params) {
722
- const url = baseUrl2(params.actionHost);
227
+ const url = baseUrl(params.actionHost);
723
228
  const body = jsonBody(params.actionId, params.input, params.method);
724
229
  const escaped = body.replace(/`/g, '` + "`" + `');
725
230
  return [
@@ -801,6 +306,7 @@ function generateCode(target, params) {
801
306
 
802
307
  // src/commands/action.ts
803
308
  var VALID_SOURCES = ["capability", "api"];
309
+ var VALID_SEARCH_SORTS = ["default", "relevance", "price"];
804
310
  var LIST_HELP = `xapi-to list - List all actions
805
311
 
806
312
  USAGE
@@ -829,12 +335,50 @@ FLAGS
829
335
  --category <name> Filter by category
830
336
  --page N Page number (default: 1)
831
337
  --page-size N Results per page
338
+ --sort default|relevance|price
339
+ Recommended (default), strongest match, or lowest
340
+ comparable price after exact-id/local-match guards
341
+ --include-all-versions Include active non-default major versions
832
342
  --format json|pretty|table Output format
833
343
 
834
344
  EXAMPLES
835
345
  xapi-to search twitter
836
346
  xapi-to search "tweet detail" --source api
347
+ xapi-to search "tweet detail" --sort relevance
348
+ xapi-to search weather --sort price
837
349
  xapi-to search weather --category utility --format table
350
+ xapi-to search twitter --include-all-versions
351
+ `;
352
+ var CATEGORIES_HELP = `xapi-to categories - List action categories
353
+
354
+ USAGE
355
+ xapi-to categories [flags]
356
+
357
+ FLAGS
358
+ --source capability|api Filter by source type
359
+ --format json|pretty|table Output format
360
+ `;
361
+ var SERVICES_HELP = `xapi-to services - List services
362
+
363
+ USAGE
364
+ xapi-to services [flags]
365
+
366
+ FLAGS
367
+ --category <name> Filter by category
368
+ --page N Page number
369
+ --page-size N Results per page
370
+ --format json|pretty|table Output format
371
+ `;
372
+ var GET_BATCH_HELP = `xapi-to get-batch - Get multiple action schemas
373
+
374
+ USAGE
375
+ xapi-to get-batch <id> [id ...] [flags]
376
+
377
+ FLAGS
378
+ --format json|pretty|table Output format
379
+
380
+ EXAMPLES
381
+ xapi-to get-batch twitter.tweet_detail crypto.token.price
838
382
  `;
839
383
  var GET_HELP = `xapi-to get - Get action schema
840
384
 
@@ -878,6 +422,7 @@ FLAGS
878
422
  --input <json> Input payload as JSON (required for execution)
879
423
  --method GET|POST|... Override HTTP method
880
424
  --output <path> Save a raw binary response to a new file
425
+ --stream Forward the action's HTTP SSE response unchanged
881
426
  --code <target> Generate code snippet instead of executing
882
427
  --format json|pretty|table Output format
883
428
 
@@ -901,6 +446,7 @@ CODE TARGETS
901
446
  EXAMPLES
902
447
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"1234567890"}'
903
448
  xapi-to call openrouter.audio_speech --input '{"body":{"input":"Hello"}}' --output speech.mp3
449
+ xapi-to call ai.text.chat.fast --input '{"messages":[{"role":"user","content":"Hello"}]}' --stream
904
450
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"123"}' --code py
905
451
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"123"}' --code curl --format pretty
906
452
  `;
@@ -931,6 +477,31 @@ function getSource(flags) {
931
477
  }
932
478
  return flags.source;
933
479
  }
480
+ function getSearchSort(flags) {
481
+ const value = flags.sort;
482
+ if (value === void 0) return void 0;
483
+ if (value === "true") {
484
+ err("--sort requires a value: default, relevance, or price");
485
+ }
486
+ if (!VALID_SEARCH_SORTS.includes(value)) {
487
+ err(`invalid --sort value: "${value}". Must be default, relevance, or price.`);
488
+ }
489
+ return value;
490
+ }
491
+ function positiveIntegerFlag(value, name) {
492
+ if (value === void 0) return void 0;
493
+ if (!/^\d+$/.test(value) || Number(value) < 1) {
494
+ err(`${name} must be a positive integer`);
495
+ }
496
+ return Number(value);
497
+ }
498
+ function httpMethodFlag(value) {
499
+ if (value === void 0) return void 0;
500
+ if (value === "true" || !/^[A-Za-z]+$/.test(value)) {
501
+ err("--method requires an HTTP method, e.g. --method POST");
502
+ }
503
+ return value.toUpperCase();
504
+ }
934
505
  async function actionList2(args, flags) {
935
506
  showHelpIfRequested(flags, LIST_HELP);
936
507
  const cfg = getConfig();
@@ -938,8 +509,8 @@ async function actionList2(args, flags) {
938
509
  try {
939
510
  const res = await actionList(cfg, {
940
511
  source: getSource(flags),
941
- page: flags.page ? parseInt(flags.page) : void 0,
942
- page_size: flags["page-size"] ? parseInt(flags["page-size"]) : void 0,
512
+ page: positiveIntegerFlag(flags.page, "--page"),
513
+ page_size: positiveIntegerFlag(flags["page-size"], "--page-size"),
943
514
  category: flags.category,
944
515
  service_id: flags["service-id"]
945
516
  });
@@ -965,15 +536,24 @@ async function actionSearch2(args, flags) {
965
536
  showHelpIfRequested(flags, SEARCH_HELP);
966
537
  const query = args[0];
967
538
  if (!query) err("usage: xapi-to search <query>");
539
+ const requestedSort = getSearchSort(flags);
968
540
  const cfg = getConfig();
969
541
  const fmt = flags.format || getFormat();
970
542
  try {
971
543
  const res = await actionSearch(query, cfg, {
972
544
  source: getSource(flags),
973
545
  category: flags.category,
974
- page: flags.page ? parseInt(flags.page) : void 0,
975
- page_size: flags["page-size"] ? parseInt(flags["page-size"]) : void 0
546
+ page: positiveIntegerFlag(flags.page, "--page"),
547
+ page_size: positiveIntegerFlag(flags["page-size"], "--page-size"),
548
+ // Backward-compatible aliases from the original feature branch.
549
+ include_all_versions: ["include-all-versions", "all-versions", "include-history"].some((name) => flags[name] === "true"),
550
+ sort: requestedSort
976
551
  });
552
+ if (requestedSort && res.sort !== requestedSort) {
553
+ throw new Error(
554
+ res.sort ? `backend applied sort "${res.sort}" instead of requested "${requestedSort}"` : "backend does not support search sorting yet; deploy the updated backend before using --sort"
555
+ );
556
+ }
977
557
  const results = res.results || [];
978
558
  if (fmt === "table") {
979
559
  output(results.map((a) => ({
@@ -983,7 +563,8 @@ async function actionSearch2(args, flags) {
983
563
  source: a.source ?? "",
984
564
  category: a.meta?.category ?? "",
985
565
  status: a.status ?? "",
986
- cost: a.meta?.cost ?? ""
566
+ price: a.meta?.pricing?.comparable ? a.meta.pricing.listed_price : "",
567
+ pricing: a.meta?.pricing?.billing_type ?? ""
987
568
  })), "table");
988
569
  } else {
989
570
  output(res, flags.format);
@@ -993,6 +574,7 @@ async function actionSearch2(args, flags) {
993
574
  }
994
575
  }
995
576
  async function actionCategories2(args, flags) {
577
+ showHelpIfRequested(flags, CATEGORIES_HELP);
996
578
  const cfg = getConfig();
997
579
  const fmt = flags.format || getFormat();
998
580
  try {
@@ -1007,12 +589,13 @@ async function actionCategories2(args, flags) {
1007
589
  }
1008
590
  }
1009
591
  async function actionServices2(args, flags) {
592
+ showHelpIfRequested(flags, SERVICES_HELP);
1010
593
  const cfg = getConfig();
1011
594
  const fmt = flags.format || getFormat();
1012
595
  try {
1013
596
  const res = await actionServices(cfg, {
1014
- page: flags.page ? parseInt(flags.page) : void 0,
1015
- page_size: flags["page-size"] ? parseInt(flags["page-size"]) : void 0,
597
+ page: positiveIntegerFlag(flags.page, "--page"),
598
+ page_size: positiveIntegerFlag(flags["page-size"], "--page-size"),
1016
599
  category: flags.category
1017
600
  });
1018
601
  const services = res.services || [];
@@ -1037,11 +620,11 @@ async function actionGet2(args, flags) {
1037
620
  const id = args[0];
1038
621
  if (!id) err("usage: xapi-to get <id> [--method GET|POST|DELETE|...]");
1039
622
  if (flags.code) validateCodeFlag(flags);
623
+ const methodFilter = httpMethodFlag(flags.method);
1040
624
  const cfg = getConfig();
1041
625
  try {
1042
626
  const res = await actionGet(id, cfg);
1043
627
  const actions = Array.isArray(res) ? res : [res];
1044
- const methodFilter = flags.method?.toUpperCase();
1045
628
  const filtered = methodFilter ? actions.filter((a) => a.method?.toUpperCase() === methodFilter) : actions;
1046
629
  if (filtered.length === 0) {
1047
630
  err(`no endpoint found for method "${methodFilter}" in action "${id}"`);
@@ -1064,15 +647,30 @@ async function actionGet2(args, flags) {
1064
647
  err("get failed", e.message);
1065
648
  }
1066
649
  }
650
+ async function actionBatchGet(args, flags) {
651
+ showHelpIfRequested(flags, GET_BATCH_HELP);
652
+ if (args.length === 0) err("usage: xapi-to get-batch <id> [id ...]");
653
+ if (args.length > 100) err("get-batch accepts at most 100 action IDs");
654
+ const cfg = getConfig();
655
+ try {
656
+ const res = await actionBatch(args, cfg);
657
+ output(res, flags.format);
658
+ } catch (e) {
659
+ err("get-batch failed", e.message);
660
+ }
661
+ }
1067
662
  async function actionCall2(args, flags) {
1068
663
  showHelpIfRequested(flags, CALL_HELP);
1069
664
  const id = args[0];
1070
665
  if (!id) err(`usage: xapi-to call <id> --input '{"key":"val"}'`);
1071
666
  if (flags.code) validateCodeFlag(flags);
1072
667
  if (flags.output === "true") err("--output requires a file path");
668
+ const stream = flags.stream === "true" || flags.stream === "1" || flags.stream === "yes";
1073
669
  if (flags.code && flags.output) {
1074
670
  err("--output cannot be combined with --code");
1075
671
  }
672
+ if (stream && flags.output) err("--stream cannot be combined with --output");
673
+ if (stream && flags.code) err("--stream cannot be combined with --code");
1076
674
  const cfg = getConfig();
1077
675
  let input = {};
1078
676
  if (flags.input) {
@@ -1086,7 +684,7 @@ async function actionCall2(args, flags) {
1086
684
  }
1087
685
  }
1088
686
  const { method: inputMethod, ...cleanInput } = input;
1089
- const method = flags.method?.toUpperCase() || (typeof inputMethod === "string" ? inputMethod.toUpperCase() : void 0);
687
+ const method = httpMethodFlag(flags.method) || (typeof inputMethod === "string" ? inputMethod.toUpperCase() : void 0);
1090
688
  if (flags.code) {
1091
689
  const result = generateCode(flags.code, { actionId: id, input: cleanInput, actionHost: cfg.actionHost, method });
1092
690
  outputCode(result, flags);
@@ -1094,6 +692,10 @@ async function actionCall2(args, flags) {
1094
692
  }
1095
693
  requireApiKey(cfg);
1096
694
  try {
695
+ if (stream) {
696
+ await actionStream(id, cleanInput, cfg, method);
697
+ return;
698
+ }
1097
699
  if (flags.output) {
1098
700
  const result = await actionDownload(
1099
701
  id,
@@ -1126,11 +728,12 @@ async function actionCall2(args, flags) {
1126
728
  var config_exports = {};
1127
729
  __export(config_exports, {
1128
730
  CONFIG_HELP: () => CONFIG_HELP,
731
+ HEALTH_HELP: () => HEALTH_HELP,
1129
732
  configHealth: () => configHealth,
1130
733
  configSet: () => configSet,
1131
734
  configShow: () => configShow
1132
735
  });
1133
- import { readFileSync as readFileSync2 } from "fs";
736
+ import { readFileSync } from "fs";
1134
737
  var CONFIG_HELP = `xapi-to config - Manage CLI configuration
1135
738
 
1136
739
  USAGE
@@ -1144,14 +747,23 @@ COMMANDS
1144
747
  FLAGS
1145
748
  --format json|pretty|table Output format
1146
749
 
750
+ ENVIRONMENT OVERRIDES
751
+ XAPI_KEY takes precedence over XAPI_API_KEY, which takes precedence over the file.
752
+ Saving a file key does not replace an active environment-variable key.
753
+
1147
754
  EXAMPLES
1148
755
  xapi-to config show
1149
756
  xapi-to config set apiKey=xapi_abc123
1150
757
  echo "$XAPI_KEY" | xapi-to config set apiKey=- # keeps the key out of shell history
1151
758
  xapi-to config health
1152
759
  `;
760
+ var HEALTH_HELP = `xapi-to health - Check backend connectivity
761
+
762
+ USAGE
763
+ xapi-to health [--format json|pretty|table]
764
+ `;
1153
765
  async function configShow(args, flags) {
1154
- showConfig();
766
+ output(showConfig(), flags.format);
1155
767
  }
1156
768
  async function configSet(args, flags) {
1157
769
  if (args.length === 0) err("usage: xapi-to config set apiKey=<key>");
@@ -1164,15 +776,27 @@ async function configSet(args, flags) {
1164
776
  if (key !== "apiKey") err(`unknown config key: ${key} (only apiKey is configurable)`);
1165
777
  let value = arg.slice(eq + 1);
1166
778
  if (value === "-") {
1167
- value = readFileSync2(0, "utf-8").trim();
779
+ value = readFileSync(0, "utf-8").trim();
1168
780
  }
1169
781
  if (!value) err("apiKey is empty");
1170
782
  updates.apiKey = value;
1171
783
  }
784
+ const sourceBeforeSave = getApiKeySource();
1172
785
  saveConfig(updates);
1173
- console.log(JSON.stringify({ ok: true, updated: Object.keys(updates) }));
786
+ const source = sourceBeforeSave === "XAPI_KEY" || sourceBeforeSave === "XAPI_API_KEY" ? sourceBeforeSave : "file";
787
+ output({
788
+ ok: true,
789
+ updated: Object.keys(updates),
790
+ effective: source === "file",
791
+ source,
792
+ ...source === "XAPI_KEY" || source === "XAPI_API_KEY" ? { warning: `${source} still overrides the saved file key` } : {}
793
+ }, flags.format);
1174
794
  }
1175
795
  async function configHealth(args, flags) {
796
+ if (flags.help) {
797
+ console.log(HEALTH_HELP);
798
+ return;
799
+ }
1176
800
  const cfg = getConfig();
1177
801
  const start = Date.now();
1178
802
  try {
@@ -1185,6 +809,30 @@ async function configHealth(args, flags) {
1185
809
  }
1186
810
 
1187
811
  // src/commands/register.ts
812
+ var REGISTER_HELP = `xapi-to register - Create a new xAPI account
813
+
814
+ USAGE
815
+ xapi-to register [referral-code] [flags]
816
+
817
+ FLAGS
818
+ --referral-code <code> Submit an inviter's referral code
819
+ --referralCode <code> Alias for --referral-code
820
+ --force Replace an existing file-based API key
821
+ --format json|pretty|table Output format
822
+
823
+ The API key is saved to ~/.xapi/config.json. If XAPI_KEY or XAPI_API_KEY is set,
824
+ unset it before registering because environment variables override the saved file.
825
+ `;
826
+ function validateRegisterResponse(value) {
827
+ const res = value;
828
+ if (!res || typeof res.apiKey !== "string" || !res.apiKey.trim()) {
829
+ throw new Error("invalid register response: missing apiKey");
830
+ }
831
+ if (typeof res.referralCode !== "string" || !res.user || typeof res.user.id !== "string") {
832
+ throw new Error("invalid register response: missing account details");
833
+ }
834
+ return res;
835
+ }
1188
836
  async function registerAccount(referralCode) {
1189
837
  assertAllowedHost(XAPI_API_HOST);
1190
838
  const controller = new AbortController();
@@ -1194,39 +842,57 @@ async function registerAccount(referralCode) {
1194
842
  method: "POST",
1195
843
  headers: { "Content-Type": "application/json" },
1196
844
  body: JSON.stringify(referralCode ? { referralCode } : {}),
1197
- signal: controller.signal
845
+ signal: controller.signal,
846
+ redirect: "manual"
1198
847
  });
848
+ if (res.status >= 300 && res.status < 400) {
849
+ throw new Error(`refusing to follow redirect to "${res.headers.get("location") ?? "?"}"`);
850
+ }
1199
851
  if (!res.ok) {
1200
852
  const text = await res.text();
1201
853
  throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`);
1202
854
  }
1203
- return res.json();
855
+ return validateRegisterResponse(await res.json());
1204
856
  } finally {
1205
857
  clearTimeout(timer);
1206
858
  }
1207
859
  }
1208
860
  async function register(args, flags) {
861
+ if (flags.help) {
862
+ console.log(REGISTER_HELP);
863
+ return;
864
+ }
1209
865
  try {
1210
866
  const cfg = getConfig();
1211
867
  const force = flags.force === "true" || flags.force === "1" || flags.force === "yes";
868
+ const source = getApiKeySource();
869
+ if (source === "XAPI_KEY" || source === "XAPI_API_KEY") {
870
+ err(
871
+ "register cannot replace an API key supplied by an environment variable",
872
+ `Unset ${source} first; it would continue to override the newly saved key.`
873
+ );
874
+ }
1212
875
  if (cfg.apiKey && !force) {
1213
876
  err("register would overwrite existing apiKey", 'Run "xapi-to register --force" to create a new account and replace the saved key.');
1214
877
  }
1215
878
  const rawReferral = flags["referral-code"] ?? flags["referralCode"] ?? args[0];
1216
- const referralCode = typeof rawReferral === "string" && rawReferral !== "true" && rawReferral.length > 0 ? rawReferral : void 0;
879
+ if (rawReferral === "true") {
880
+ err("--referral-code requires a code");
881
+ }
882
+ const referralCode = typeof rawReferral === "string" && rawReferral.length > 0 ? rawReferral : void 0;
1217
883
  const res = await registerAccount(referralCode);
884
+ const bindUrl = res.bindUrl || res.claimUrl;
1218
885
  saveConfig({ apiKey: res.apiKey });
1219
886
  output({
1220
887
  apiKey: res.apiKey,
1221
888
  user: res.user,
1222
889
  referralCode: res.referralCode,
1223
- claim: {
1224
- code: res.claimCode,
1225
- sessionId: res.claimSessionId,
1226
- url: res.claimUrl
1227
- },
1228
- tweetTemplate: res.tweetTemplate,
1229
- ...referralCode ? { referredBy: referralCode } : {},
890
+ bindUrl,
891
+ // Keep the backend's legacy field visible while clients migrate to bindUrl.
892
+ claimUrl: res.claimUrl || bindUrl,
893
+ // The backend may accept the registration while ignoring an invalid code,
894
+ // so only report that the code was submitted, not that a referral exists.
895
+ ...referralCode ? { referralCodeProvided: referralCode } : {},
1230
896
  note: force && cfg.apiKey ? "apiKey replaced in ~/.xapi/config.json" : "apiKey saved to ~/.xapi/config.json"
1231
897
  }, flags.format);
1232
898
  } catch (e) {
@@ -1236,23 +902,50 @@ async function register(args, flags) {
1236
902
 
1237
903
  // src/commands/topup.ts
1238
904
  var TOPUP_BASE_URL = "https://www.xapi.to/topup/payment";
905
+ var TOPUP_HELP = `xapi-to topup - Generate a private payment URL
906
+
907
+ USAGE
908
+ xapi-to topup [--amount <usd>] [--method stripe|x402]
909
+
910
+ The generated URL can contain your API key. Do not log or share it.
911
+ `;
1239
912
  async function topup(args, flags) {
913
+ if (flags.help) {
914
+ console.log(TOPUP_HELP);
915
+ return;
916
+ }
1240
917
  const cfg = getConfig();
1241
918
  const url = new URL(TOPUP_BASE_URL);
1242
919
  if (cfg.apiKey) url.searchParams.set("apikey", cfg.apiKey);
1243
- if (flags.method) url.searchParams.set("method", flags.method);
920
+ if (flags.method) {
921
+ if (!["stripe", "x402"].includes(flags.method)) {
922
+ err("invalid --method value", "Expected stripe or x402.");
923
+ }
924
+ url.searchParams.set("method", flags.method);
925
+ }
1244
926
  const amountStr = flags.amount || args[0];
1245
927
  if (amountStr) {
1246
- const amountUsd = parseFloat(amountStr);
1247
- if (!isNaN(amountUsd) && amountUsd > 0) {
1248
- url.searchParams.set("amount", String(amountUsd));
928
+ const normalizedAmount = amountStr.trim();
929
+ const amountUsd = Number(normalizedAmount);
930
+ if (!/^(?:\d+(?:\.\d*)?|\.\d+)$/.test(normalizedAmount) || !Number.isFinite(amountUsd) || amountUsd <= 0) {
931
+ err("invalid top-up amount", "Expected a positive USD number, e.g. --amount 10.");
1249
932
  }
933
+ url.searchParams.set("amount", String(amountUsd));
1250
934
  }
1251
935
  output({ url: url.toString() }, flags.format);
1252
936
  }
1253
937
 
1254
938
  // src/commands/balance.ts
939
+ var BALANCE_HELP = `xapi-to balance - Show the current account balance
940
+
941
+ USAGE
942
+ xapi-to balance [--format json|pretty|table]
943
+ `;
1255
944
  async function balance(args, flags) {
945
+ if (flags.help) {
946
+ console.log(BALANCE_HELP);
947
+ return;
948
+ }
1256
949
  const cfg = getConfig();
1257
950
  requireApiKey(cfg);
1258
951
  let token;
@@ -1289,9 +982,10 @@ __export(oauth_exports, {
1289
982
  });
1290
983
  import { spawnSync } from "child_process";
1291
984
  function openBrowser(url) {
1292
- const cmd = process.platform === "win32" ? "start" : process.platform === "darwin" ? "open" : "xdg-open";
985
+ const cmd = process.platform === "win32" ? "rundll32.exe" : process.platform === "darwin" ? "open" : "xdg-open";
986
+ const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
1293
987
  try {
1294
- spawnSync(cmd, [url], { stdio: "ignore" });
988
+ spawnSync(cmd, args, { stdio: "ignore" });
1295
989
  } catch {
1296
990
  }
1297
991
  }
@@ -1335,9 +1029,11 @@ async function findCurrentKeyRecord(plaintextKey, jwtToken) {
1335
1029
  throw new Error("No API keys found for this account");
1336
1030
  }
1337
1031
  const prefix = plaintextKey.substring(0, 7);
1338
- const match = keys.find((k) => k.keyPreview.startsWith(prefix));
1032
+ const suffix = plaintextKey.slice(-4);
1033
+ const expectedPreview = `${prefix}****${suffix}`;
1034
+ const match = keys.find((k) => k.keyPreview === expectedPreview);
1339
1035
  if (match) return match;
1340
- if (keys.length === 1) return keys[0];
1036
+ if (keys.length === 1 && !keys[0].keyPreview.includes("****")) return keys[0];
1341
1037
  throw new Error(
1342
1038
  `Current API key (${prefix}...) was not found in your account keys. Run "xapi-to config set apiKey=<key>" with a valid key before binding OAuth.`
1343
1039
  );
@@ -1454,13 +1150,20 @@ FLAGS
1454
1150
  EXAMPLES
1455
1151
  xapi-to oauth bind
1456
1152
  xapi-to oauth bind --provider twitter
1457
- xapi-to oauth bind --scopes "tweet.read users.read"
1153
+ xapi-to oauth providers # inspect current default scopes
1154
+ xapi-to oauth bind --scopes "<scope list>" # override only when needed
1458
1155
  xapi-to oauth status
1459
1156
  xapi-to oauth status --format pretty
1460
1157
  xapi-to oauth unbind abc123
1461
1158
  xapi-to oauth providers
1462
1159
  `;
1463
1160
  async function oauthBind(args, flags) {
1161
+ if (flags.provider === "true") {
1162
+ err("--provider requires a provider name, e.g. --provider twitter");
1163
+ }
1164
+ if (flags.scopes === "true") {
1165
+ err("--scopes requires a space-separated scope list");
1166
+ }
1464
1167
  const cfg = getConfig();
1465
1168
  requireApiKey(cfg);
1466
1169
  const apiKey = cfg.apiKey;
@@ -1518,6 +1221,16 @@ async function oauthBind(args, flags) {
1518
1221
  const authorizationStartedAt = /* @__PURE__ */ new Date();
1519
1222
  const result = await initiateOAuth(keyRecord.id, provider.id, jwtToken, XAPI_API_HOST, scopes);
1520
1223
  const { authorizationUrl } = result;
1224
+ let authorizationTarget;
1225
+ try {
1226
+ authorizationTarget = new URL(authorizationUrl);
1227
+ } catch {
1228
+ throw new Error("OAuth provider returned an invalid authorization URL");
1229
+ }
1230
+ const localHttp = authorizationTarget.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(authorizationTarget.hostname);
1231
+ if (authorizationTarget.protocol !== "https:" && !localHttp) {
1232
+ throw new Error(`OAuth provider returned an unsupported authorization URL protocol: ${authorizationTarget.protocol}`);
1233
+ }
1521
1234
  if (isTTY) {
1522
1235
  if (!headerPrinted) {
1523
1236
  console.error(`
@@ -1704,7 +1417,7 @@ function parsePositiveInt(raw, flagName) {
1704
1417
  }
1705
1418
  return n;
1706
1419
  }
1707
- function sleep2(ms) {
1420
+ function sleep(ms) {
1708
1421
  if (ms <= 0) return Promise.resolve();
1709
1422
  return new Promise((resolve2) => setTimeout(resolve2, ms));
1710
1423
  }
@@ -1801,13 +1514,891 @@ async function taskWait(args, flags) {
1801
1514
  }
1802
1515
  const desiredWaitMs = retryDelayMs ?? intervalMs;
1803
1516
  const waitMs = deadline !== void 0 ? Math.min(desiredWaitMs, Math.max(0, deadline - Date.now())) : desiredWaitMs;
1804
- await sleep2(waitMs);
1517
+ await sleep(waitMs);
1805
1518
  }
1806
1519
  }
1807
1520
  function taskHelp() {
1808
1521
  return TASK_HELP;
1809
1522
  }
1810
1523
 
1524
+ // src/commands/sandbox.ts
1525
+ import { randomUUID } from "crypto";
1526
+ import { readFile, open, rm } from "fs/promises";
1527
+ import { resolve } from "path";
1528
+ var SANDBOX_HELP = `xapi-to sandbox - Managed, auditable cloud sandboxes
1529
+
1530
+ USAGE
1531
+ xapi-to sandbox <command> [args] [flags]
1532
+
1533
+ QUICK START
1534
+ sandbox run --command <shell> Quote, create, wait, execute, and terminate
1535
+ sandbox run -- <command...> Positional shorthand after a bare --
1536
+
1537
+ LIFECYCLE
1538
+ offerings List available provider offerings
1539
+ quote Quote requirements without creating
1540
+ list List sandbox instances
1541
+ history Search paginated instance history
1542
+ get <id> Get instance state, usage, and cost
1543
+ create Create from a quote, offering, or requirements
1544
+ wait <id> Wait for a state (default: RUNNING)
1545
+ exec <id> --command <shell> Execute a shell command
1546
+ suspend|resume|terminate <id> Change state and wait for completion
1547
+
1548
+ FILES, PORTS, AUDIT
1549
+ file write <id> <remote> --file <local>
1550
+ file write <id> <remote> --content <text>
1551
+ file read <id> <remote> [--output <local>]
1552
+ file list <id> [--path <remote>] [--depth N]
1553
+ port <id> <port> Get a public URL for a listening port
1554
+ extension <id> <extension-id> Invoke an offering-declared extension
1555
+ audit <id> [--kind operations|events|usageSegments|billingPeriods]
1556
+
1557
+ SELECTION FLAGS
1558
+ --provider auto|daytona|cf-edge|e2b|runpod|runloop|modal|vc-sandbox|fly|blaxel|cubesandbox
1559
+ Pin a provider gateway (default: auto)
1560
+ --capabilities exec,files,ports Required capabilities
1561
+ --cpu N --memory N --volume N Minimum resources
1562
+ --gpu-count N --gpu-model NAME GPU requirements
1563
+ --regions a,b Allowed regions
1564
+ --requirements <json> Complete requirements object
1565
+ --max-hourly-usd N Price ceiling (sandbox run default: 0.20)
1566
+
1567
+ COMMON FLAGS
1568
+ --host <sandbox.xapi.to> Override gateway; must be *.xapi.to/localhost
1569
+ --wait-timeout 5m State wait timeout (default: 5m)
1570
+ --interval 2s State polling interval (default: 2s)
1571
+ --format json|pretty|table Output format
1572
+
1573
+ HISTORY FLAGS
1574
+ --state ALL|ACTIVE|HISTORY|RUNNING|SUSPENDED|TERMINATED|FAILED
1575
+ --search <text> --from <ISO time> --to <ISO time>
1576
+ --page N --page-size N Pagination (page size: 1-100)
1577
+
1578
+ SAFETY
1579
+ sandbox run terminates in finally, including command failure. Use --keep only
1580
+ when you intentionally want billing to continue after the CLI exits.
1581
+
1582
+ EXAMPLES
1583
+ xapi-to sandbox offerings --format table
1584
+ xapi-to sandbox quote --capabilities exec,files --max-hourly-usd 0.20
1585
+ xapi-to sandbox run --command 'python3 -c "print(6*7)"'
1586
+ xapi-to sandbox run --provider cf-edge --capabilities exec,files,ports --command 'pwd'
1587
+ xapi-to sandbox exec <id> -- npm test
1588
+ xapi-to sandbox extension <id> runpod.connection_info --input '{}'
1589
+ xapi-to sandbox terminate <id>
1590
+ `;
1591
+ var SANDBOX_COMMAND_HELP = {
1592
+ offerings: `USAGE
1593
+ xapi-to sandbox offerings [--provider NAME] [--format json|pretty|table]
1594
+
1595
+ Lists current resources, capabilities, lifecycle support, and hourly prices.
1596
+ This command does not create or bill an instance.`,
1597
+ quote: `USAGE
1598
+ xapi-to sandbox quote [selection flags] [--max-hourly-usd N]
1599
+
1600
+ SELECTION
1601
+ --capabilities exec,files,ports Required capabilities
1602
+ --cpu N --memory N --volume N Minimum resources
1603
+ --gpu-count N --gpu-model NAME GPU requirements
1604
+ --regions a,b Allowed regions
1605
+ --requirements <json> Complete requirements object
1606
+ --max-hourly-usd N Hard hourly price ceiling
1607
+
1608
+ Returns a short-lived quote without creating or billing an instance.`,
1609
+ list: `USAGE
1610
+ xapi-to sandbox list [--provider NAME] [--format json|pretty|table]
1611
+
1612
+ Lists current Sandbox instances visible to the configured xAPI key.`,
1613
+ history: `USAGE
1614
+ xapi-to sandbox history [--state STATE] [--search TEXT] [--from ISO] [--to ISO]
1615
+ [--page N] [--page-size 1-100] [--format json|pretty|table]
1616
+
1617
+ Searches current and historical Sandbox instances with server-side pagination.`,
1618
+ get: `USAGE
1619
+ xapi-to sandbox get <id> [--format json|pretty|table]
1620
+
1621
+ Returns current state, operations, usage, billing, and service-calculated cost.`,
1622
+ create: `USAGE
1623
+ xapi-to sandbox create [selection flags] [--max-hourly-usd N] [--wait]
1624
+
1625
+ SELECTION MODES
1626
+ --quote-id ID Create from an existing quote
1627
+ --offering-id ID Create an exact offering (cannot use a price ceiling)
1628
+ --requirements <json> Create from requirements
1629
+ --capabilities/--cpu/--memory/... Requirements shortcuts
1630
+
1631
+ CONTROL
1632
+ --idempotency-key KEY Stable retry key; generated and returned if omitted
1633
+ --metadata <json> Instance metadata
1634
+ --resume-on-access Request automatic resume on supported providers
1635
+ --wait Wait until RUNNING
1636
+ --wait-timeout 5m --interval 2s Polling controls
1637
+
1638
+ On a wait failure, the error includes the instance ID and recovery instructions.`,
1639
+ wait: `USAGE
1640
+ xapi-to sandbox wait <id> [--state RUNNING[,STATE]]
1641
+ [--wait-timeout 5m] [--interval 2s]
1642
+
1643
+ State names are case-insensitive and validated before polling.`,
1644
+ exec: `USAGE
1645
+ xapi-to sandbox exec <id> --command <shell> [--cwd PATH] [--timeout SECONDS]
1646
+ [--background]
1647
+ xapi-to sandbox exec <id> -- <command...>
1648
+
1649
+ Executes a command and maps a remote non-zero exit code to the local process.
1650
+ --background requires offering.capabilities.backgroundExec=true and returns a
1651
+ provider-managed session immediately; use it for long-running Web servers.`,
1652
+ file: `USAGE
1653
+ xapi-to sandbox file write <id> <remote> (--file <local>|--content <text>)
1654
+ xapi-to sandbox file read <id> <remote> [--output <local>]
1655
+ xapi-to sandbox file list <id> [--path <remote>] [--depth N]
1656
+
1657
+ Binary local files are transferred as base64. --output never overwrites a file.`,
1658
+ port: `USAGE
1659
+ xapi-to sandbox port <id> <1-65535>
1660
+
1661
+ Returns the provider's temporary public URL for a listening instance port.`,
1662
+ extension: `USAGE
1663
+ xapi-to sandbox extension <id> <extension-id> [--input <json>]
1664
+ [--idempotency-key KEY]
1665
+
1666
+ Invoke only extension IDs declared by the selected offering.`,
1667
+ audit: `USAGE
1668
+ xapi-to sandbox audit <id> [--kind operations|events|usageSegments|billingPeriods]
1669
+ [--page N] [--page-size 1-100] [--format json|pretty|table]`,
1670
+ suspend: `USAGE
1671
+ xapi-to sandbox suspend <id> [--no-wait] [--idempotency-key KEY]
1672
+ [--wait-timeout 5m] [--interval 2s]
1673
+
1674
+ Check offering lifecycle support before suspending; storage may continue billing.`,
1675
+ resume: `USAGE
1676
+ xapi-to sandbox resume <id> [--no-wait] [--idempotency-key KEY]
1677
+ [--wait-timeout 5m] [--interval 2s]`,
1678
+ terminate: `USAGE
1679
+ xapi-to sandbox terminate <id> [--no-wait] [--idempotency-key KEY]
1680
+ [--wait-timeout 5m] [--interval 2s]
1681
+
1682
+ Waits for TERMINATED or FAILED by default.`,
1683
+ run: `USAGE
1684
+ xapi-to sandbox run --command <shell> [selection flags] [run flags]
1685
+ xapi-to sandbox run [selection flags] -- <command...>
1686
+
1687
+ RUN FLAGS
1688
+ --max-hourly-usd N Hard ceiling (default: 0.20)
1689
+ --timeout SECONDS Remote command timeout (default: 60)
1690
+ --cwd PATH Remote working directory
1691
+ --metadata <json> Instance metadata for audit correlation
1692
+ --idempotency-key KEY Stable create retry key
1693
+ --wait-timeout 5m --interval 2s Lifecycle polling controls
1694
+ --keep Keep the instance running and billing
1695
+
1696
+ Runs quote -> create -> wait -> exec -> terminate. Cleanup also runs after
1697
+ command failure, SIGINT, or SIGTERM.`
1698
+ };
1699
+ var COMMON_FLAGS = ["help", "host", "provider", "format"];
1700
+ var SELECTION_FLAGS = [
1701
+ "capabilities",
1702
+ "cpu",
1703
+ "memory",
1704
+ "volume",
1705
+ "gpu-count",
1706
+ "gpu-model",
1707
+ "regions",
1708
+ "requirements",
1709
+ "max-hourly-usd"
1710
+ ];
1711
+ var POLL_FLAGS = ["wait-timeout", "interval"];
1712
+ function help(flags, command) {
1713
+ if (flags.help) {
1714
+ console.log(`xapi-to sandbox ${command}
1715
+
1716
+ ${SANDBOX_COMMAND_HELP[command]}
1717
+
1718
+ COMMON
1719
+ --host HOST --provider NAME --format json|pretty|table --help`);
1720
+ process.exit(0);
1721
+ }
1722
+ }
1723
+ function validateFlags(flags, command, allowed = []) {
1724
+ const valid = /* @__PURE__ */ new Set([...COMMON_FLAGS, ...allowed]);
1725
+ const unknown = Object.keys(flags).filter((flag) => !valid.has(flag));
1726
+ if (unknown.length) {
1727
+ err(`unknown flag${unknown.length > 1 ? "s" : ""} for sandbox ${command}: ${unknown.map((flag) => `--${flag}`).join(", ")}`, {
1728
+ hint: `run xapi-to sandbox ${command} --help`,
1729
+ validFlags: [...valid].sort().map((flag) => `--${flag}`)
1730
+ });
1731
+ }
1732
+ if (flags.format && !["json", "pretty", "table"].includes(flags.format)) {
1733
+ err("--format must be one of: json, pretty, table");
1734
+ }
1735
+ }
1736
+ function collection(data) {
1737
+ if (Array.isArray(data)) return data;
1738
+ if (Array.isArray(data?.items)) return data.items;
1739
+ if (Array.isArray(data?.data)) return data.data;
1740
+ return data ? [data] : [];
1741
+ }
1742
+ function capability(value, name) {
1743
+ const enabled = value?.capabilities?.[name];
1744
+ return enabled === true ? "yes" : enabled === false ? "no" : "";
1745
+ }
1746
+ function sandboxTableRows(view, data) {
1747
+ if (view === "offerings") {
1748
+ return collection(data).map((item) => ({
1749
+ id: item.id,
1750
+ name: item.name,
1751
+ cpu: item.resources?.cpu,
1752
+ memoryGiB: item.resources?.memoryGiB,
1753
+ volumeGiB: item.resources?.volumeGiB,
1754
+ gpu: Array.isArray(item.resources?.gpu) ? item.resources.gpu.map((gpu) => `${gpu.count || 1}x ${gpu.model || "GPU"}`).join(", ") : "",
1755
+ exec: capability(item, "exec"),
1756
+ background: capability(item, "backgroundExec"),
1757
+ files: capability(item, "files"),
1758
+ ports: capability(item, "ports"),
1759
+ suspend: item.lifecycle?.suspension?.supported === true ? "yes" : "no",
1760
+ hourlyUsd: item.billing?.estimatedHourlyUsdByState?.RUNNING,
1761
+ extensions: Array.isArray(item.capabilities?.extensionIds) ? item.capabilities.extensionIds.join(",") : ""
1762
+ }));
1763
+ }
1764
+ if (view === "quote") {
1765
+ return collection(data).map((item) => ({
1766
+ quoteId: item.quoteId || item.id,
1767
+ offeringId: item.offeringId || item.offering?.id,
1768
+ offering: item.offering?.name || item.offeringName,
1769
+ provider: item.provider?.name || item.providerName || item.provider,
1770
+ hourlyUsd: item.estimatedHourlyUsd || item.hourlyUsd || item.offering?.billing?.estimatedHourlyUsdByState?.RUNNING,
1771
+ expiresAt: item.expiresAt
1772
+ }));
1773
+ }
1774
+ if (view === "run") {
1775
+ return collection(data).map((item) => ({
1776
+ instanceId: item.instanceId,
1777
+ provider: item.provider,
1778
+ offering: item.offering?.name || item.offering,
1779
+ exitCode: item.result?.exitCode,
1780
+ finalState: item.finalState,
1781
+ cleanup: item.cleanup?.state || (item.cleanup?.kept ? "KEPT" : ""),
1782
+ totalCost: item.totalCost
1783
+ }));
1784
+ }
1785
+ return collection(data).map((item) => ({
1786
+ id: item.id || item.instanceId,
1787
+ state: item.observedState || item.state || item.finalState,
1788
+ desiredState: item.desiredState,
1789
+ offering: item.offering?.name || item.offeringName || item.offeringId,
1790
+ provider: item.provider?.name || item.providerName || item.provider,
1791
+ createdAt: item.createdAt,
1792
+ updatedAt: item.updatedAt,
1793
+ totalCost: item.totalCost
1794
+ }));
1795
+ }
1796
+ function sandboxOutput(view, data, flags) {
1797
+ const format = flags.format || getFormat();
1798
+ if (format === "table") {
1799
+ output(sandboxTableRows(view, data), "table");
1800
+ return;
1801
+ }
1802
+ output(data, flags.format);
1803
+ }
1804
+ function flagValue(flags, name) {
1805
+ const value = flags[name];
1806
+ if (value === "true") err(`--${name} requires a value`);
1807
+ return value;
1808
+ }
1809
+ function booleanFlag(flags, name) {
1810
+ const raw = flags[name];
1811
+ if (raw === void 0 || raw === "false") return false;
1812
+ if (raw === "true") return true;
1813
+ err(`--${name} must be a boolean flag or --${name}=true|false`);
1814
+ }
1815
+ function positiveNumber(raw, name) {
1816
+ if (raw === void 0) return void 0;
1817
+ const value = Number(raw);
1818
+ if (!Number.isFinite(value) || value <= 0) err(`--${name} must be a positive number`);
1819
+ return value;
1820
+ }
1821
+ function positiveInteger(raw, name) {
1822
+ const value = positiveNumber(raw, name);
1823
+ if (value !== void 0 && !Number.isInteger(value)) err(`--${name} must be a positive integer`);
1824
+ return value;
1825
+ }
1826
+ function durationMs(raw, fallback, name) {
1827
+ if (raw === void 0) return fallback;
1828
+ if (raw === "true") err(`--${name} requires a value`);
1829
+ const match = raw.trim().toLowerCase().match(/^(\d+)(ms|s|m|h)?$/);
1830
+ if (!match || Number(match[1]) <= 0) err(`--${name} must be a duration like 500ms, 2s, 5m, or 1h`);
1831
+ const value = Number(match[1]);
1832
+ return value * { ms: 1, s: 1e3, m: 6e4, h: 36e5 }[match[2] || "ms"];
1833
+ }
1834
+ function jsonObject(raw, name) {
1835
+ try {
1836
+ const value = JSON.parse(raw);
1837
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected an object");
1838
+ return value;
1839
+ } catch (error) {
1840
+ err(`--${name} must be a valid JSON object`, error.message);
1841
+ }
1842
+ }
1843
+ function csv(raw) {
1844
+ if (!raw) return void 0;
1845
+ return raw.split(",").map((value) => value.trim()).filter(Boolean);
1846
+ }
1847
+ function sandboxOptions(flags) {
1848
+ const cfg = getConfig();
1849
+ requireApiKey(cfg);
1850
+ const host = flagValue(flags, "host") || cfg.sandboxHost || XAPI_SANDBOX_HOST;
1851
+ const provider = flagValue(flags, "provider");
1852
+ return { sandboxHost: host, apiKey: cfg.apiKey, ...provider ? { provider } : {} };
1853
+ }
1854
+ function requirementsFromFlags(flags, defaultCapabilities) {
1855
+ const requirements = flagValue(flags, "requirements") ? jsonObject(flagValue(flags, "requirements"), "requirements") : {};
1856
+ const capabilities = csv(flagValue(flags, "capabilities")) || (Array.isArray(requirements.capabilities) ? void 0 : defaultCapabilities);
1857
+ const regions = csv(flagValue(flags, "regions"));
1858
+ const cpu = positiveNumber(flagValue(flags, "cpu"), "cpu");
1859
+ const memory = positiveNumber(flagValue(flags, "memory"), "memory");
1860
+ const volume = positiveNumber(flagValue(flags, "volume"), "volume");
1861
+ const gpuCount = positiveInteger(flagValue(flags, "gpu-count"), "gpu-count");
1862
+ const gpuModel = flagValue(flags, "gpu-model");
1863
+ if (gpuModel && gpuCount === void 0 && !(Number(requirements.gpu?.count) > 0)) {
1864
+ err("--gpu-model requires --gpu-count (or requirements.gpu.count)");
1865
+ }
1866
+ if (capabilities?.length) requirements.capabilities = capabilities;
1867
+ if (regions?.length) requirements.regions = regions;
1868
+ if (cpu !== void 0) requirements.cpu = { ...requirements.cpu || {}, min: cpu };
1869
+ if (memory !== void 0) requirements.memoryGiB = { ...requirements.memoryGiB || {}, min: memory };
1870
+ if (volume !== void 0) requirements.volumeGiB = { ...requirements.volumeGiB || {}, min: volume };
1871
+ if (gpuCount !== void 0 || gpuModel) {
1872
+ requirements.gpu = {
1873
+ ...requirements.gpu || {},
1874
+ ...gpuCount !== void 0 ? { count: gpuCount } : {},
1875
+ ...gpuModel ? { model: gpuModel } : {}
1876
+ };
1877
+ }
1878
+ return requirements;
1879
+ }
1880
+ function quoteBody(flags, defaultCapabilities) {
1881
+ const max = positiveNumber(flagValue(flags, "max-hourly-usd"), "max-hourly-usd");
1882
+ return {
1883
+ requirements: requirementsFromFlags(flags, defaultCapabilities),
1884
+ ...max !== void 0 ? { maxEstimatedHourlyUsd: max.toFixed(8) } : {}
1885
+ };
1886
+ }
1887
+ function createDefaultCapabilities(flags) {
1888
+ if (flagValue(flags, "requirements") || flagValue(flags, "provider") === "runpod" || flagValue(flags, "gpu-count") || flagValue(flags, "gpu-model")) return void 0;
1889
+ return ["exec"];
1890
+ }
1891
+ function waitSettings(flags) {
1892
+ return {
1893
+ timeoutMs: durationMs(flags["wait-timeout"], 3e5, "wait-timeout"),
1894
+ intervalMs: durationMs(flags.interval, 2e3, "interval")
1895
+ };
1896
+ }
1897
+ function commandFrom(args, flags, usage) {
1898
+ const fromFlag = flagValue(flags, "command");
1899
+ const command = fromFlag ?? args.join(" ");
1900
+ if (!command.trim()) err(usage);
1901
+ return command;
1902
+ }
1903
+ function instanceId(args, usage) {
1904
+ if (!args[0]) err(usage);
1905
+ return args[0];
1906
+ }
1907
+ async function terminateAndWait(opts, id, flags) {
1908
+ const { timeoutMs, intervalMs } = waitSettings(flags);
1909
+ const deadline = Date.now() + timeoutMs;
1910
+ const clientIdempotencyKey = flagValue(flags, "idempotency-key") || `cli:terminate:${randomUUID()}`;
1911
+ let operation;
1912
+ while (Date.now() < deadline) {
1913
+ const detail2 = await sandboxGet(opts, id);
1914
+ if (["TERMINATED", "FAILED"].includes(String(detail2.observedState))) {
1915
+ return { operation, sandbox: detail2, clientIdempotencyKey };
1916
+ }
1917
+ try {
1918
+ operation = await sandboxStateAction(opts, id, "terminate", {
1919
+ idempotencyKey: clientIdempotencyKey
1920
+ });
1921
+ break;
1922
+ } catch (error) {
1923
+ if (!(error instanceof HttpError) || error.status !== 409) throw error;
1924
+ await new Promise((resolve2) => setTimeout(resolve2, intervalMs));
1925
+ }
1926
+ }
1927
+ const remaining = Math.max(1, deadline - Date.now());
1928
+ let detail;
1929
+ try {
1930
+ detail = await sandboxWait(opts, id, ["TERMINATED", "FAILED"], remaining, intervalMs);
1931
+ } catch (error) {
1932
+ if (!(error instanceof HttpError) || error.status !== 404 || !opts.provider) throw error;
1933
+ detail = await sandboxWait(
1934
+ { ...opts, provider: void 0 },
1935
+ id,
1936
+ ["TERMINATED", "FAILED"],
1937
+ Math.max(1, deadline - Date.now()),
1938
+ intervalMs
1939
+ );
1940
+ }
1941
+ return { operation, sandbox: detail, clientIdempotencyKey };
1942
+ }
1943
+ function cleanupSummary(cleanup) {
1944
+ if (!cleanup) return void 0;
1945
+ if (cleanup.error) return { error: cleanup.error };
1946
+ return {
1947
+ operationId: cleanup.operation?.id,
1948
+ operationStatus: cleanup.sandbox?.operations?.find?.((item) => item.type === "TERMINATE")?.status || cleanup.operation?.status,
1949
+ state: cleanup.sandbox?.observedState,
1950
+ totalCost: cleanup.sandbox?.totalCost
1951
+ };
1952
+ }
1953
+ function sandboxResultExitCode(result) {
1954
+ const value = result?.exitCode;
1955
+ if (typeof value !== "number" || value === 0) return void 0;
1956
+ return Math.min(255, Math.max(1, Math.trunc(value)));
1957
+ }
1958
+ async function sandboxOfferings2(args, flags) {
1959
+ help(flags, "offerings");
1960
+ validateFlags(flags, "offerings");
1961
+ try {
1962
+ sandboxOutput("offerings", await sandboxOfferings(sandboxOptions(flags)), flags);
1963
+ } catch (error) {
1964
+ err("sandbox offerings failed", error.message);
1965
+ }
1966
+ }
1967
+ async function sandboxQuote2(args, flags) {
1968
+ help(flags, "quote");
1969
+ validateFlags(flags, "quote", SELECTION_FLAGS);
1970
+ try {
1971
+ sandboxOutput("quote", await sandboxQuote(sandboxOptions(flags), quoteBody(flags)), flags);
1972
+ } catch (error) {
1973
+ err("sandbox quote failed", error.message);
1974
+ }
1975
+ }
1976
+ async function sandboxList2(args, flags) {
1977
+ help(flags, "list");
1978
+ validateFlags(flags, "list");
1979
+ try {
1980
+ sandboxOutput("instances", await sandboxList(sandboxOptions(flags)), flags);
1981
+ } catch (error) {
1982
+ err("sandbox list failed", error.message);
1983
+ }
1984
+ }
1985
+ async function sandboxHistory2(args, flags) {
1986
+ help(flags, "history");
1987
+ validateFlags(flags, "history", ["state", "search", "from", "to", "page", "page-size"]);
1988
+ const state = flagValue(flags, "state");
1989
+ const allowedStates = ["ALL", "ACTIVE", "HISTORY", "PROVISIONING", "RUNNING", "SUSPENDED", "TERMINATED", "FAILED", "UNKNOWN"];
1990
+ if (state && !allowedStates.includes(state.toUpperCase())) {
1991
+ err(`--state must be one of: ${allowedStates.join(", ")}`);
1992
+ }
1993
+ const page = positiveInteger(flagValue(flags, "page"), "page") || 1;
1994
+ const pageSize = positiveInteger(flagValue(flags, "page-size"), "page-size") || 100;
1995
+ if (pageSize > 100) err("--page-size must be at most 100");
1996
+ try {
1997
+ sandboxOutput("instances", await sandboxHistory(sandboxOptions(flags), {
1998
+ ...state ? { state: state.toUpperCase() } : {},
1999
+ ...flagValue(flags, "search") ? { search: flagValue(flags, "search") } : {},
2000
+ ...flagValue(flags, "from") ? { from: flagValue(flags, "from") } : {},
2001
+ ...flagValue(flags, "to") ? { to: flagValue(flags, "to") } : {},
2002
+ page,
2003
+ pageSize
2004
+ }), flags);
2005
+ } catch (error) {
2006
+ err("sandbox history failed", error.message);
2007
+ }
2008
+ }
2009
+ async function sandboxGet2(args, flags) {
2010
+ help(flags, "get");
2011
+ validateFlags(flags, "get");
2012
+ const id = instanceId(args, "usage: xapi-to sandbox get <id>");
2013
+ try {
2014
+ sandboxOutput("detail", await sandboxGet(sandboxOptions(flags), id), flags);
2015
+ } catch (error) {
2016
+ err("sandbox get failed", error.message);
2017
+ }
2018
+ }
2019
+ async function sandboxCreate2(args, flags) {
2020
+ help(flags, "create");
2021
+ validateFlags(flags, "create", [
2022
+ ...SELECTION_FLAGS,
2023
+ ...POLL_FLAGS,
2024
+ "quote-id",
2025
+ "offering-id",
2026
+ "metadata",
2027
+ "idempotency-key",
2028
+ "resume-on-access",
2029
+ "wait"
2030
+ ]);
2031
+ const wait = booleanFlag(flags, "wait");
2032
+ const resumeOnAccess = booleanFlag(flags, "resume-on-access");
2033
+ const opts = sandboxOptions(flags);
2034
+ const quoteId = flagValue(flags, "quote-id");
2035
+ const offeringId = flagValue(flags, "offering-id");
2036
+ const maxHourly = flagValue(flags, "max-hourly-usd");
2037
+ const requirementFlags = [
2038
+ "requirements",
2039
+ "capabilities",
2040
+ "cpu",
2041
+ "memory",
2042
+ "volume",
2043
+ "gpu-count",
2044
+ "gpu-model",
2045
+ "regions"
2046
+ ].filter((name) => flagValue(flags, name) !== void 0);
2047
+ if (quoteId && offeringId) err("--quote-id and --offering-id are mutually exclusive");
2048
+ if ((quoteId || offeringId) && requirementFlags.length) {
2049
+ err(`${quoteId ? "--quote-id" : "--offering-id"} cannot be combined with requirement flags`, {
2050
+ conflictingFlags: requirementFlags.map((name) => `--${name}`)
2051
+ });
2052
+ }
2053
+ if (quoteId && maxHourly) {
2054
+ err("--max-hourly-usd cannot be combined with --quote-id; the quote already fixes the price");
2055
+ }
2056
+ if (offeringId && maxHourly) {
2057
+ err("--max-hourly-usd cannot be combined with --offering-id; create from requirements to enforce a price ceiling");
2058
+ }
2059
+ const idempotencyKey = flagValue(flags, "idempotency-key") || `cli:create:${randomUUID()}`;
2060
+ let created;
2061
+ try {
2062
+ let selection;
2063
+ if (quoteId) selection = { quoteId };
2064
+ else if (offeringId) selection = { offeringId };
2065
+ else if (maxHourly) {
2066
+ const quoted = await sandboxQuote(opts, quoteBody(flags, createDefaultCapabilities(flags)));
2067
+ if (!quoted?.quoteId) throw new Error("quote response did not include quoteId");
2068
+ selection = { quoteId: quoted.quoteId };
2069
+ } else selection = { requirements: requirementsFromFlags(flags, createDefaultCapabilities(flags)) };
2070
+ const metadata = flagValue(flags, "metadata") ? jsonObject(flagValue(flags, "metadata"), "metadata") : { client: "xapi-cli" };
2071
+ created = await sandboxCreate(opts, {
2072
+ selection,
2073
+ metadata,
2074
+ idempotencyKey,
2075
+ policy: { resumeOnAccess }
2076
+ });
2077
+ let result = created;
2078
+ if (wait && created.id) {
2079
+ const settings = waitSettings(flags);
2080
+ result = await sandboxWait(opts, created.id, ["RUNNING"], settings.timeoutMs, settings.intervalMs);
2081
+ }
2082
+ sandboxOutput("detail", { ...result, clientIdempotencyKey: idempotencyKey }, flags);
2083
+ } catch (error) {
2084
+ let latest = created;
2085
+ if (created?.id) {
2086
+ try {
2087
+ latest = await sandboxGet(opts, created.id);
2088
+ } catch {
2089
+ }
2090
+ }
2091
+ err("sandbox create failed", {
2092
+ message: error.message,
2093
+ instanceId: created?.id,
2094
+ observedState: latest?.observedState,
2095
+ clientIdempotencyKey: idempotencyKey,
2096
+ recovery: created?.id ? {
2097
+ inspect: `xapi-to sandbox get ${created.id}`,
2098
+ terminate: `xapi-to sandbox terminate ${created.id}`
2099
+ } : {
2100
+ retry: `repeat the create command with --idempotency-key ${idempotencyKey}`,
2101
+ reconcile: "xapi-to sandbox history --state ACTIVE --page-size 100"
2102
+ }
2103
+ });
2104
+ }
2105
+ }
2106
+ async function sandboxWait2(args, flags) {
2107
+ help(flags, "wait");
2108
+ validateFlags(flags, "wait", ["state", ...POLL_FLAGS]);
2109
+ const id = instanceId(args, "usage: xapi-to sandbox wait <id> [--state RUNNING]");
2110
+ const allowedStates = ["PROVISIONING", "RUNNING", "SUSPENDING", "SUSPENDED", "RESUMING", "TERMINATING", "TERMINATED", "FAILED", "UNKNOWN"];
2111
+ const wanted = (csv(flagValue(flags, "state")) || ["RUNNING"]).map((state) => state.toUpperCase());
2112
+ const invalid = wanted.filter((state) => !allowedStates.includes(state));
2113
+ if (invalid.length) err(`--state must contain only: ${allowedStates.join(", ")}`);
2114
+ const settings = waitSettings(flags);
2115
+ try {
2116
+ output(await sandboxWait(sandboxOptions(flags), id, wanted, settings.timeoutMs, settings.intervalMs), flags.format);
2117
+ } catch (error) {
2118
+ err("sandbox wait failed", error.message);
2119
+ }
2120
+ }
2121
+ async function sandboxExec2(args, flags) {
2122
+ help(flags, "exec");
2123
+ validateFlags(flags, "exec", ["command", "timeout", "cwd", "background"]);
2124
+ const id = instanceId(args, "usage: xapi-to sandbox exec <id> --command <shell>");
2125
+ const command = commandFrom(args.slice(1), flags, "usage: xapi-to sandbox exec <id> --command <shell>");
2126
+ const timeoutSeconds = positiveInteger(flagValue(flags, "timeout"), "timeout") || 60;
2127
+ const background = booleanFlag(flags, "background");
2128
+ try {
2129
+ const result = await sandboxExec(sandboxOptions(flags), id, {
2130
+ command,
2131
+ timeoutSeconds,
2132
+ ...flagValue(flags, "cwd") ? { cwd: flagValue(flags, "cwd") } : {},
2133
+ ...background ? { background: true } : {}
2134
+ });
2135
+ output(result, flags.format);
2136
+ const exitCode = sandboxResultExitCode(result);
2137
+ if (exitCode !== void 0) process.exitCode = exitCode;
2138
+ } catch (error) {
2139
+ err("sandbox exec failed", error.message);
2140
+ }
2141
+ }
2142
+ async function sandboxFile(args, flags) {
2143
+ help(flags, "file");
2144
+ validateFlags(flags, "file", ["file", "content", "output", "path", "depth"]);
2145
+ const [action, id, remote] = args;
2146
+ if (!action || !id) err("usage: xapi-to sandbox file <write|read|list> <id> [remote-path]");
2147
+ const opts = sandboxOptions(flags);
2148
+ try {
2149
+ if (action === "write") {
2150
+ if (!remote) err("usage: xapi-to sandbox file write <id> <remote-path> (--file <local>|--content <text>)");
2151
+ const local = flagValue(flags, "file");
2152
+ const inline = flagValue(flags, "content");
2153
+ if (Boolean(local) === Boolean(inline)) err("provide exactly one of --file or --content");
2154
+ const body = local ? { path: remote, content: (await readFile(local)).toString("base64"), encoding: "base64" } : { path: remote, content: inline, encoding: "utf8" };
2155
+ output(await sandboxFileWrite(opts, id, body), flags.format);
2156
+ return;
2157
+ }
2158
+ if (action === "read") {
2159
+ if (!remote) err("usage: xapi-to sandbox file read <id> <remote-path> [--output <local>]");
2160
+ const outputPath = flagValue(flags, "output");
2161
+ const result = await sandboxFileRead(opts, id, remote, outputPath ? "base64" : "utf8");
2162
+ if (!outputPath) {
2163
+ output(result, flags.format);
2164
+ return;
2165
+ }
2166
+ const target = resolve(outputPath);
2167
+ const file = await open(target, "wx");
2168
+ let complete = false;
2169
+ try {
2170
+ const data = result?.encoding === "base64" ? Buffer.from(String(result.content || ""), "base64") : Buffer.from(String(result?.content || ""), "utf8");
2171
+ await file.writeFile(data);
2172
+ complete = true;
2173
+ output({ output: target, bytes: data.length, path: remote }, flags.format);
2174
+ } finally {
2175
+ await file.close();
2176
+ if (!complete) await rm(target, { force: true });
2177
+ }
2178
+ return;
2179
+ }
2180
+ if (action === "list") {
2181
+ const depth = positiveInteger(flagValue(flags, "depth"), "depth") || 2;
2182
+ output(await sandboxFileList(opts, id, flagValue(flags, "path") || remote || ".", depth), flags.format);
2183
+ return;
2184
+ }
2185
+ err(`unknown sandbox file command: ${action}`);
2186
+ } catch (error) {
2187
+ err(`sandbox file ${action} failed`, error.message);
2188
+ }
2189
+ }
2190
+ async function sandboxPort2(args, flags) {
2191
+ help(flags, "port");
2192
+ validateFlags(flags, "port");
2193
+ const id = instanceId(args, "usage: xapi-to sandbox port <id> <port>");
2194
+ const port = positiveInteger(args[1], "port");
2195
+ if (!port || port > 65535) err("port must be between 1 and 65535");
2196
+ try {
2197
+ output(await sandboxPort(sandboxOptions(flags), id, port), flags.format);
2198
+ } catch (error) {
2199
+ err("sandbox port failed", error.message);
2200
+ }
2201
+ }
2202
+ async function sandboxExtension2(args, flags) {
2203
+ help(flags, "extension");
2204
+ validateFlags(flags, "extension", ["input", "idempotency-key"]);
2205
+ const id = instanceId(args, "usage: xapi-to sandbox extension <id> <extension-id> --input <json>");
2206
+ const extensionId = args[1];
2207
+ if (!extensionId) err("usage: xapi-to sandbox extension <id> <extension-id> --input <json>");
2208
+ if (!/^[a-z0-9][a-z0-9._-]{0,119}$/i.test(extensionId)) err("invalid Sandbox extension id");
2209
+ const input = flagValue(flags, "input") ? jsonObject(flagValue(flags, "input"), "input") : {};
2210
+ const clientIdempotencyKey = flagValue(flags, "idempotency-key") || `cli:extension:${extensionId}:${randomUUID()}`;
2211
+ try {
2212
+ const result = await sandboxExtension(sandboxOptions(flags), id, extensionId, {
2213
+ input,
2214
+ idempotencyKey: clientIdempotencyKey
2215
+ });
2216
+ output({ ...result, clientIdempotencyKey }, flags.format);
2217
+ } catch (error) {
2218
+ err("sandbox extension failed", {
2219
+ message: error.message,
2220
+ clientIdempotencyKey,
2221
+ retry: `repeat with --idempotency-key ${clientIdempotencyKey}`
2222
+ });
2223
+ }
2224
+ }
2225
+ async function sandboxAudit2(args, flags) {
2226
+ help(flags, "audit");
2227
+ validateFlags(flags, "audit", ["kind", "page", "page-size"]);
2228
+ const id = instanceId(args, "usage: xapi-to sandbox audit <id> [--kind operations]");
2229
+ const kind = flagValue(flags, "kind") || "operations";
2230
+ const allowed = ["operations", "events", "usageSegments", "billingPeriods"];
2231
+ if (!allowed.includes(kind)) err(`--kind must be one of: ${allowed.join(", ")}`);
2232
+ const page = positiveInteger(flagValue(flags, "page"), "page") || 1;
2233
+ const pageSize = positiveInteger(flagValue(flags, "page-size"), "page-size") || 100;
2234
+ if (pageSize > 100) err("--page-size must be at most 100");
2235
+ try {
2236
+ output(await sandboxAudit(sandboxOptions(flags), id, kind, page, pageSize), flags.format);
2237
+ } catch (error) {
2238
+ err("sandbox audit failed", error.message);
2239
+ }
2240
+ }
2241
+ async function sandboxState(action, args, flags) {
2242
+ help(flags, action);
2243
+ validateFlags(flags, action, [...POLL_FLAGS, "no-wait", "idempotency-key"]);
2244
+ const id = instanceId(args, `usage: xapi-to sandbox ${action} <id>`);
2245
+ const opts = sandboxOptions(flags);
2246
+ const noWait = booleanFlag(flags, "no-wait");
2247
+ const clientIdempotencyKey = flagValue(flags, "idempotency-key") || `cli:${action}:${randomUUID()}`;
2248
+ try {
2249
+ if (action === "terminate" && !noWait) {
2250
+ output(await terminateAndWait(opts, id, flags), flags.format);
2251
+ return;
2252
+ }
2253
+ const operation = await sandboxStateAction(opts, id, action, {
2254
+ idempotencyKey: clientIdempotencyKey
2255
+ });
2256
+ if (noWait) {
2257
+ output({ ...operation, clientIdempotencyKey }, flags.format);
2258
+ return;
2259
+ }
2260
+ const wanted = action === "suspend" ? ["SUSPENDED"] : action === "resume" ? ["RUNNING"] : ["TERMINATED", "FAILED"];
2261
+ const settings = waitSettings(flags);
2262
+ const detail = await sandboxWait(opts, id, wanted, settings.timeoutMs, settings.intervalMs);
2263
+ output({ operation, sandbox: detail, clientIdempotencyKey }, flags.format);
2264
+ } catch (error) {
2265
+ err(`sandbox ${action} failed`, {
2266
+ message: error.message,
2267
+ instanceId: id,
2268
+ clientIdempotencyKey,
2269
+ recovery: {
2270
+ inspect: `xapi-to sandbox get ${id}`,
2271
+ retry: `repeat with --idempotency-key ${clientIdempotencyKey}`
2272
+ }
2273
+ });
2274
+ }
2275
+ }
2276
+ async function sandboxRun(args, flags) {
2277
+ help(flags, "run");
2278
+ validateFlags(flags, "run", [
2279
+ ...SELECTION_FLAGS,
2280
+ ...POLL_FLAGS,
2281
+ "command",
2282
+ "timeout",
2283
+ "cwd",
2284
+ "metadata",
2285
+ "keep",
2286
+ "idempotency-key"
2287
+ ]);
2288
+ const command = commandFrom(args, flags, "usage: xapi-to sandbox run --command <shell>");
2289
+ const opts = sandboxOptions(flags);
2290
+ const maxHourly = flagValue(flags, "max-hourly-usd") || "0.20";
2291
+ positiveNumber(maxHourly, "max-hourly-usd");
2292
+ const timeoutSeconds = positiveInteger(flagValue(flags, "timeout"), "timeout") || 60;
2293
+ const settings = waitSettings(flags);
2294
+ const idempotencyKey = flagValue(flags, "idempotency-key") || `cli:run:${randomUUID()}`;
2295
+ const metadata = flagValue(flags, "metadata") ? jsonObject(flagValue(flags, "metadata"), "metadata") : {};
2296
+ const keep = booleanFlag(flags, "keep");
2297
+ let id;
2298
+ let failure;
2299
+ let quote;
2300
+ let created;
2301
+ let ready;
2302
+ let result;
2303
+ let cleanup;
2304
+ let interruptedBy;
2305
+ const waitAbort = new AbortController();
2306
+ const interrupt = (signal) => {
2307
+ interruptedBy = signal;
2308
+ waitAbort.abort();
2309
+ };
2310
+ const interruptSigint = () => interrupt("SIGINT");
2311
+ const interruptSigterm = () => interrupt("SIGTERM");
2312
+ const throwIfInterrupted = () => {
2313
+ if (interruptedBy) throw new Error(`interrupted by ${interruptedBy}`);
2314
+ };
2315
+ process.once("SIGINT", interruptSigint);
2316
+ process.once("SIGTERM", interruptSigterm);
2317
+ try {
2318
+ quote = await sandboxQuote(
2319
+ opts,
2320
+ quoteBody({ ...flags, "max-hourly-usd": maxHourly }, ["exec"]),
2321
+ waitAbort.signal
2322
+ );
2323
+ if (!quote?.quoteId) throw new Error("quote response did not include quoteId");
2324
+ throwIfInterrupted();
2325
+ created = await sandboxCreate(opts, {
2326
+ selection: { quoteId: quote.quoteId },
2327
+ metadata: { ...metadata, client: "xapi-cli", command: "sandbox run" },
2328
+ policy: { resumeOnAccess: false },
2329
+ idempotencyKey
2330
+ });
2331
+ id = created?.id;
2332
+ if (!id) throw new Error("create response did not include sandbox id");
2333
+ throwIfInterrupted();
2334
+ ready = await sandboxWait(
2335
+ opts,
2336
+ id,
2337
+ ["RUNNING"],
2338
+ settings.timeoutMs,
2339
+ settings.intervalMs,
2340
+ waitAbort.signal
2341
+ );
2342
+ throwIfInterrupted();
2343
+ result = await sandboxExec(opts, id, {
2344
+ command,
2345
+ timeoutSeconds,
2346
+ ...flagValue(flags, "cwd") ? { cwd: flagValue(flags, "cwd") } : {}
2347
+ }, waitAbort.signal);
2348
+ throwIfInterrupted();
2349
+ } catch (error) {
2350
+ failure = error;
2351
+ } finally {
2352
+ if (id && !keep) {
2353
+ try {
2354
+ cleanup = await terminateAndWait(opts, id, flags);
2355
+ } catch (cleanupError) {
2356
+ cleanup = { error: cleanupError.message };
2357
+ if (!failure) failure = new Error(`command completed but cleanup failed: ${cleanupError.message}`);
2358
+ }
2359
+ }
2360
+ process.removeListener("SIGINT", interruptSigint);
2361
+ process.removeListener("SIGTERM", interruptSigterm);
2362
+ }
2363
+ if (failure) {
2364
+ err("sandbox run failed", {
2365
+ message: failure?.message || String(failure),
2366
+ instanceId: id,
2367
+ clientIdempotencyKey: idempotencyKey,
2368
+ cleanup: keep ? { kept: true, warning: "billing continues until terminated" } : cleanupSummary(cleanup),
2369
+ recovery: id ? { inspect: `xapi-to sandbox get ${id}`, terminate: `xapi-to sandbox terminate ${id}` } : {
2370
+ reconcile: "xapi-to sandbox history --state ACTIVE --page-size 100",
2371
+ retryCreateWithSameKey: idempotencyKey
2372
+ }
2373
+ });
2374
+ }
2375
+ let finalDetail;
2376
+ let finalReadError;
2377
+ if (id) {
2378
+ try {
2379
+ finalDetail = await sandboxGet(opts, id);
2380
+ } catch (error) {
2381
+ finalReadError = error.message;
2382
+ }
2383
+ }
2384
+ const summary = {
2385
+ instanceId: id,
2386
+ clientIdempotencyKey: idempotencyKey,
2387
+ provider: opts.provider || "auto",
2388
+ offering: quote?.offering,
2389
+ createdState: created?.observedState,
2390
+ readyState: ready?.observedState,
2391
+ result,
2392
+ cleanup: keep ? { kept: true, warning: "billing continues until terminated" } : cleanupSummary(cleanup),
2393
+ finalState: finalDetail?.observedState || cleanup?.sandbox?.observedState,
2394
+ totalCost: finalDetail?.totalCost || cleanup?.sandbox?.totalCost,
2395
+ ...finalReadError ? { finalReadError } : {}
2396
+ };
2397
+ sandboxOutput("run", summary, flags);
2398
+ const remoteExitCode = sandboxResultExitCode(result);
2399
+ if (remoteExitCode !== void 0) process.exitCode = remoteExitCode;
2400
+ }
2401
+
1811
2402
  // src/args.ts
1812
2403
  function parseArgs(argv) {
1813
2404
  const positional = [];
@@ -1859,10 +2450,12 @@ COMMANDS
1859
2450
  --page N --page-size N Pagination
1860
2451
  --category <name> Filter by category
1861
2452
  --service-id <id> Filter by service
1862
- search <query> Search actions by keyword
2453
+ search <query> Search actions by keyword (--all-versions: \u542B\u975E\u9ED8\u8BA4\u4F46\u4ECD\u5728\u8DD1\u7684\u5927\u7248\u672C)
1863
2454
  --source capability|api Filter by source type
1864
2455
  --category <name> Filter by category
1865
2456
  --page N --page-size N Pagination
2457
+ --sort default|relevance|price Recommended, strongest match, or comparable price
2458
+ --include-all-versions Include active non-default major versions
1866
2459
  categories List all action categories
1867
2460
  --source capability|api Filter by source type
1868
2461
  services List all services
@@ -1870,9 +2463,11 @@ COMMANDS
1870
2463
  --category <name> Filter by category
1871
2464
  get <id> [--method GET|POST|...] Get action schema (filter by HTTP method)
1872
2465
  --code <target> Generate code snippet (curl, py, js, ts, go)
2466
+ get-batch <id> [id ...] Get up to 100 action schemas
1873
2467
  call <id> --input '{"key":"val"}' Execute an action
1874
2468
  --method GET|POST|... Override HTTP method
1875
2469
  --output <path> Save a raw binary response to a new file
2470
+ --stream Forward HTTP SSE frames unchanged
1876
2471
  --code <target> Generate code snippet instead of executing
1877
2472
  Variants: python.requests, python.httpx, javascript.fetch, javascript.axios
1878
2473
 
@@ -1882,6 +2477,12 @@ COMMANDS
1882
2477
  --timeout <duration> Max wait duration, e.g. 10m
1883
2478
  --max-attempts <number> Max poll attempts
1884
2479
 
2480
+ sandbox <command> Managed cloud sandbox lifecycle
2481
+ run --command <shell> Quote, create, execute, and auto-terminate
2482
+ offerings|quote|list|history|get|create|wait|exec
2483
+ file|port|extension|audit|suspend|resume|terminate
2484
+ Run "xapi-to sandbox --help" for selection and safety flags
2485
+
1885
2486
  oauth bind [--provider twitter] Bind Twitter OAuth to your API key
1886
2487
  oauth status List current OAuth bindings
1887
2488
  oauth unbind <binding-id> Remove an OAuth binding
@@ -1889,7 +2490,7 @@ COMMANDS
1889
2490
 
1890
2491
  register [referral-code] Create a new user account (apiKey saved automatically)
1891
2492
  --referral-code <code> Register with an inviter's referral code (also: --referralCode, or as positional arg)
1892
- --force Replace an existing saved apiKey
2493
+ --force Replace an existing file-based apiKey
1893
2494
  balance Show current account balance
1894
2495
  topup [--amount <usd>] [--method stripe|x402] Generate payment URL
1895
2496
 
@@ -1904,9 +2505,13 @@ GLOBAL FLAGS
1904
2505
  --help Show help (use with a command for details, e.g. xapi-to get --help)
1905
2506
 
1906
2507
  ENV VARS
1907
- XAPI_KEY API key (header: XAPI-Key)
2508
+ XAPI_KEY API key (highest precedence; header: XAPI-Key)
2509
+ XAPI_API_KEY Compatible API key alias
1908
2510
  XAPI_ACTION_HOST Action service host (default: action.xapi.to)
2511
+ XAPI_API_HOST Auth/account service host (default: api.xapi.to)
2512
+ XAPI_SANDBOX_HOST Sandbox gateway host (default: sandbox.xapi.to)
1909
2513
  XAPI_OUTPUT Default output format
2514
+ XAPI_TRANSFER_IDLE_TIMEOUT_MS SSE/download idle timeout (default: 60000)
1910
2515
 
1911
2516
  EXAMPLES
1912
2517
  xapi-to register
@@ -1916,6 +2521,7 @@ EXAMPLES
1916
2521
  xapi-to list --source capability
1917
2522
  xapi-to search twitter --source api
1918
2523
  xapi-to get twitter.tweet_detail
2524
+ xapi-to get-batch twitter.tweet_detail crypto.token.price
1919
2525
  xapi-to get twitter.tweet_detail --code curl
1920
2526
  xapi-to get twitter.tweet_detail --code py --format pretty
1921
2527
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"1234567890"}'
@@ -1923,6 +2529,7 @@ EXAMPLES
1923
2529
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"1234567890"}' --code python
1924
2530
  xapi-to task poll 550e8400-e29b-41d4-a716-446655440000
1925
2531
  xapi-to task wait 550e8400-e29b-41d4-a716-446655440000 --interval 2s --timeout 10m
2532
+ xapi-to sandbox run --command 'python3 -c "print(6*7)"'
1926
2533
  xapi-to categories
1927
2534
  xapi-to services --format table
1928
2535
  xapi-to config set apiKey=xapi_abc123
@@ -1934,7 +2541,16 @@ async function main() {
1934
2541
  console.log(HELP);
1935
2542
  process.exit(0);
1936
2543
  }
1937
- if (flags.format) process.env.XAPI_OUTPUT = flags.format;
2544
+ if (flags.format) {
2545
+ if (!["json", "pretty", "table"].includes(flags.format)) {
2546
+ console.error(JSON.stringify({
2547
+ error: `invalid --format value: ${flags.format}`,
2548
+ hint: "expected json, pretty, or table"
2549
+ }));
2550
+ process.exit(1);
2551
+ }
2552
+ process.env.XAPI_OUTPUT = flags.format;
2553
+ }
1938
2554
  const [cmd, ...rest] = positional;
1939
2555
  switch (cmd) {
1940
2556
  // ── Action commands (top-level) ──
@@ -1948,6 +2564,8 @@ async function main() {
1948
2564
  return actionServices2(rest, flags);
1949
2565
  case "get":
1950
2566
  return actionGet2(rest, flags);
2567
+ case "get-batch":
2568
+ return actionBatchGet(rest, flags);
1951
2569
  case "call":
1952
2570
  return actionCall2(rest, flags);
1953
2571
  case "task": {
@@ -1967,6 +2585,54 @@ async function main() {
1967
2585
  }
1968
2586
  break;
1969
2587
  }
2588
+ case "sandbox": {
2589
+ if (rest.length === 0) {
2590
+ console.log(SANDBOX_HELP);
2591
+ process.exit(0);
2592
+ }
2593
+ const [subCmd, ...subRest] = rest;
2594
+ switch (subCmd) {
2595
+ case "offerings":
2596
+ return sandboxOfferings2(subRest, flags);
2597
+ case "quote":
2598
+ return sandboxQuote2(subRest, flags);
2599
+ case "list":
2600
+ return sandboxList2(subRest, flags);
2601
+ case "history":
2602
+ return sandboxHistory2(subRest, flags);
2603
+ case "get":
2604
+ return sandboxGet2(subRest, flags);
2605
+ case "create":
2606
+ return sandboxCreate2(subRest, flags);
2607
+ case "wait":
2608
+ return sandboxWait2(subRest, flags);
2609
+ case "exec":
2610
+ return sandboxExec2(subRest, flags);
2611
+ case "file":
2612
+ return sandboxFile(subRest, flags);
2613
+ case "port":
2614
+ return sandboxPort2(subRest, flags);
2615
+ case "extension":
2616
+ return sandboxExtension2(subRest, flags);
2617
+ case "audit":
2618
+ return sandboxAudit2(subRest, flags);
2619
+ case "suspend":
2620
+ return sandboxState("suspend", subRest, flags);
2621
+ case "resume":
2622
+ return sandboxState("resume", subRest, flags);
2623
+ case "terminate":
2624
+ return sandboxState("terminate", subRest, flags);
2625
+ case "run":
2626
+ return sandboxRun(subRest, flags);
2627
+ default:
2628
+ console.error(JSON.stringify({
2629
+ error: `unknown sandbox command: ${subCmd}`,
2630
+ hint: "run xapi-to sandbox --help"
2631
+ }));
2632
+ process.exit(1);
2633
+ }
2634
+ break;
2635
+ }
1970
2636
  // ── OAuth commands ──
1971
2637
  case "oauth": {
1972
2638
  if (flags.help || rest.length === 0) {