xapi-to 0.1.16 → 0.1.17

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.
Files changed (3) hide show
  1. package/README.md +10 -0
  2. package/dist/index.js +435 -55
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -66,6 +66,16 @@ xapi-to get twitter.tweet_detail # get action schema
66
66
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"1234567890"}' # execute
67
67
  ```
68
68
 
69
+ ### Async Task Commands
70
+
71
+ Task helpers built on top of the `task.poll` capability.
72
+
73
+ ```bash
74
+ xapi-to task poll 550e8400-e29b-41d4-a716-446655440000 # poll once
75
+ xapi-to task wait 550e8400-e29b-41d4-a716-446655440000 # wait until terminal status
76
+ xapi-to task wait 550e8400-e29b-41d4-a716-446655440000 --interval 1s --timeout 10m
77
+ ```
78
+
69
79
  ### OAuth
70
80
 
71
81
  Bind third-party OAuth accounts (e.g. Twitter) to your API key.
package/dist/index.js CHANGED
@@ -100,7 +100,43 @@ import { join } from "path";
100
100
  var XAPI_ACTION_HOST = process.env.XAPI_ACTION_HOST || "action.xapi.to";
101
101
  var XAPI_API_HOST = process.env.XAPI_API_HOST || "api.xapi.to";
102
102
  function scheme(host) {
103
- return host.startsWith("localhost") || host.startsWith("127.") ? "http" : "https";
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
+ }
104
140
  }
105
141
  var CONFIG_DIR = join(homedir(), ".xapi");
106
142
  var CONFIG_FILE = join(CONFIG_DIR, "config.json");
@@ -146,39 +182,128 @@ function showConfig() {
146
182
  // src/client.ts
147
183
  var DEFAULT_TIMEOUT_MS = 3e4;
148
184
  var EXECUTE_TIMEOUT_MS = 6e4;
149
- async function request(url, options, timeoutMs = DEFAULT_TIMEOUT_MS) {
150
- const controller = new AbortController();
151
- const timer = setTimeout(() => controller.abort(), timeoutMs);
152
- try {
153
- const res = await fetch(url, { ...options, signal: controller.signal });
154
- if (!res.ok) {
155
- const text2 = await res.text();
156
- throw new Error(`HTTP ${res.status}: ${text2.slice(0, 300)}`);
157
- }
158
- if (res.status === 204) {
159
- return void 0;
160
- }
161
- const text = await res.text();
162
- if (!text.trim()) {
163
- return void 0;
164
- }
165
- const body = JSON.parse(text);
166
- if (body && typeof body === "object" && "success" in body && body.success === false) {
167
- const data = body.data;
168
- if (data?.statusCode === 401 || data?.error === "Unauthorized") {
185
+ var IDEMPOTENT_RETRIES = 2;
186
+ var RETRY_BASE_DELAY_MS = 500;
187
+ var RETRY_MAX_DELAY_MS = 8e3;
188
+ var HttpError = class extends Error {
189
+ constructor(status, detail, retryAfterMs) {
190
+ super(`HTTP ${status}: ${detail}`);
191
+ this.status = status;
192
+ this.retryAfterMs = retryAfterMs;
193
+ this.name = "HttpError";
194
+ }
195
+ status;
196
+ retryAfterMs;
197
+ };
198
+ var RequestTimeoutError = class extends Error {
199
+ constructor(timeoutMs) {
200
+ super(`request timed out after ${timeoutMs}ms`);
201
+ this.timeoutMs = timeoutMs;
202
+ this.name = "RequestTimeoutError";
203
+ }
204
+ timeoutMs;
205
+ };
206
+ function isRetryableStatus(status) {
207
+ return status === 408 || status === 429 || status === 502 || status === 503 || status === 504;
208
+ }
209
+ function isRetryableNetworkError(e) {
210
+ if (!(e instanceof Error)) return false;
211
+ if (e instanceof HttpError || e instanceof RequestTimeoutError) return false;
212
+ if (e.name === "AbortError") return false;
213
+ return e instanceof TypeError || /network|fetch failed|econn|etimedout|eai_again|socket|dns/i.test(e.message);
214
+ }
215
+ function isRetryableRequestError(e) {
216
+ if (e instanceof HttpError) return isRetryableStatus(e.status);
217
+ if (e instanceof RequestTimeoutError) return true;
218
+ return isRetryableNetworkError(e);
219
+ }
220
+ function retryBaseDelayMs() {
221
+ const override = Number(process.env.XAPI_RETRY_BASE_MS);
222
+ return Number.isFinite(override) && override > 0 ? override : RETRY_BASE_DELAY_MS;
223
+ }
224
+ function backoffDelayMs(attempt, retryAfterMs) {
225
+ if (retryAfterMs !== void 0 && Number.isFinite(retryAfterMs) && retryAfterMs >= 0) {
226
+ return Math.min(retryAfterMs, RETRY_MAX_DELAY_MS);
227
+ }
228
+ const capped = Math.min(retryBaseDelayMs() * 2 ** attempt, RETRY_MAX_DELAY_MS);
229
+ return capped / 2 + Math.random() * (capped / 2);
230
+ }
231
+ function parseRetryAfterMs(res) {
232
+ const header = res.headers.get("retry-after");
233
+ if (!header) return void 0;
234
+ const seconds = Number(header);
235
+ if (Number.isFinite(seconds)) return seconds * 1e3;
236
+ const at = Date.parse(header);
237
+ return Number.isFinite(at) ? Math.max(0, at - Date.now()) : void 0;
238
+ }
239
+ function sleep(ms) {
240
+ return new Promise((resolve) => setTimeout(resolve, ms));
241
+ }
242
+ async function request(url, options, timeoutMs = DEFAULT_TIMEOUT_MS, retries = 0) {
243
+ assertAllowedHost(url);
244
+ let attempt = 0;
245
+ while (true) {
246
+ const controller = new AbortController();
247
+ let timedOut = false;
248
+ const timer = setTimeout(() => {
249
+ timedOut = true;
250
+ controller.abort();
251
+ }, timeoutMs);
252
+ try {
253
+ const res = await fetch(url, { ...options, redirect: "manual", signal: controller.signal });
254
+ if (res.status >= 300 && res.status < 400) {
169
255
  throw new Error(
170
- "Authentication failed: " + (data.message || "Invalid or missing API key") + '. Run "npx xapi-to config set apiKey=<key>" to update your key.'
256
+ `refusing to follow redirect to "${res.headers.get("location") ?? "?"}" (would forward the API key past the host allowlist)`
171
257
  );
172
258
  }
173
- if (data?.error === "OAuth Required" || data?.statusCode === 403 && data?.message?.includes("OAuth")) {
174
- throw new Error(
175
- (data.message || "OAuth authorization required") + '. Run "xapi-to oauth bind" to connect your account.'
176
- );
259
+ if (!res.ok) {
260
+ const retryAfterMs = isRetryableStatus(res.status) ? parseRetryAfterMs(res) : void 0;
261
+ if (isRetryableStatus(res.status) && attempt < retries) {
262
+ await res.text().catch(() => "");
263
+ clearTimeout(timer);
264
+ await sleep(backoffDelayMs(attempt, retryAfterMs));
265
+ attempt++;
266
+ continue;
267
+ }
268
+ const text2 = await res.text();
269
+ throw new HttpError(res.status, text2.slice(0, 300), retryAfterMs);
177
270
  }
271
+ if (res.status === 204) {
272
+ return void 0;
273
+ }
274
+ const text = await res.text();
275
+ if (!text.trim()) {
276
+ return void 0;
277
+ }
278
+ const body = JSON.parse(text);
279
+ if (body && typeof body === "object" && "success" in body && body.success === false) {
280
+ const data = body.data;
281
+ if (data?.statusCode === 401 || data?.error === "Unauthorized") {
282
+ throw new Error(
283
+ "Authentication failed: " + (data.message || "Invalid or missing API key") + '. Run "npx xapi-to config set apiKey=<key>" to update your key.'
284
+ );
285
+ }
286
+ if (data?.error === "OAuth Required" || data?.statusCode === 403 && data?.message?.includes("OAuth")) {
287
+ throw new Error(
288
+ (data.message || "OAuth authorization required") + '. Run "xapi-to oauth bind" to connect your account.'
289
+ );
290
+ }
291
+ }
292
+ return body;
293
+ } catch (e) {
294
+ if (timedOut) {
295
+ throw new RequestTimeoutError(timeoutMs);
296
+ }
297
+ if (isRetryableNetworkError(e) && attempt < retries) {
298
+ clearTimeout(timer);
299
+ await sleep(backoffDelayMs(attempt));
300
+ attempt++;
301
+ continue;
302
+ }
303
+ throw e;
304
+ } finally {
305
+ clearTimeout(timer);
178
306
  }
179
- return body;
180
- } finally {
181
- clearTimeout(timer);
182
307
  }
183
308
  }
184
309
  function headers(apiKey) {
@@ -198,7 +323,9 @@ async function actionList(opts, params = {}) {
198
323
  if (params.service_id) url.searchParams.set("service_id", params.service_id);
199
324
  return request(
200
325
  url.toString(),
201
- { method: "GET", headers: headers(opts.apiKey) }
326
+ { method: "GET", headers: headers(opts.apiKey) },
327
+ DEFAULT_TIMEOUT_MS,
328
+ IDEMPOTENT_RETRIES
202
329
  );
203
330
  }
204
331
  async function actionSearch(query, opts, params = {}) {
@@ -210,7 +337,9 @@ async function actionSearch(query, opts, params = {}) {
210
337
  if (params.page_size) url.searchParams.set("page_size", String(params.page_size));
211
338
  return request(
212
339
  url.toString(),
213
- { method: "GET", headers: headers(opts.apiKey) }
340
+ { method: "GET", headers: headers(opts.apiKey) },
341
+ DEFAULT_TIMEOUT_MS,
342
+ IDEMPOTENT_RETRIES
214
343
  );
215
344
  }
216
345
  async function actionCategories(opts, params = {}) {
@@ -218,16 +347,20 @@ async function actionCategories(opts, params = {}) {
218
347
  if (params.source) url.searchParams.set("source", params.source);
219
348
  return request(
220
349
  url.toString(),
221
- { method: "GET", headers: headers(opts.apiKey) }
350
+ { method: "GET", headers: headers(opts.apiKey) },
351
+ DEFAULT_TIMEOUT_MS,
352
+ IDEMPOTENT_RETRIES
222
353
  );
223
354
  }
224
355
  async function actionGet(id, opts) {
225
356
  return request(
226
357
  `${baseUrl(opts)}/v1/actions/${encodeURIComponent(id)}`,
227
- { method: "GET", headers: headers(opts.apiKey) }
358
+ { method: "GET", headers: headers(opts.apiKey) },
359
+ DEFAULT_TIMEOUT_MS,
360
+ IDEMPOTENT_RETRIES
228
361
  );
229
362
  }
230
- async function actionCall(actionId, input, opts, httpMethod) {
363
+ async function actionCall(actionId, input, opts, httpMethod, retries = 0, timeoutMs = EXECUTE_TIMEOUT_MS) {
231
364
  return request(
232
365
  `${baseUrl(opts)}/v1/actions/execute`,
233
366
  {
@@ -235,7 +368,8 @@ async function actionCall(actionId, input, opts, httpMethod) {
235
368
  headers: headers(opts.apiKey),
236
369
  body: JSON.stringify({ action_id: actionId, ...httpMethod ? { method: httpMethod } : {}, input })
237
370
  },
238
- EXECUTE_TIMEOUT_MS
371
+ Math.min(timeoutMs, EXECUTE_TIMEOUT_MS),
372
+ retries
239
373
  );
240
374
  }
241
375
  async function actionServices(opts, params = {}) {
@@ -245,14 +379,18 @@ async function actionServices(opts, params = {}) {
245
379
  if (params.category) url.searchParams.set("category", params.category);
246
380
  return request(
247
381
  url.toString(),
248
- { method: "GET", headers: headers(opts.apiKey) }
382
+ { method: "GET", headers: headers(opts.apiKey) },
383
+ DEFAULT_TIMEOUT_MS,
384
+ IDEMPOTENT_RETRIES
249
385
  );
250
386
  }
251
387
  async function healthCheck(opts) {
252
388
  return request(
253
389
  `${baseUrl(opts)}/health`,
254
390
  { method: "GET", headers: headers(opts.apiKey) },
255
- 5e3
391
+ 5e3,
392
+ 0
393
+ // health is a quick connectivity probe — fail fast, don't retry
256
394
  );
257
395
  }
258
396
  async function loginWithApiKey(apiKey, apiHost) {
@@ -262,7 +400,10 @@ async function loginWithApiKey(apiKey, apiHost) {
262
400
  method: "POST",
263
401
  headers: { "Content-Type": "application/json" },
264
402
  body: JSON.stringify({ apiKey })
265
- }
403
+ },
404
+ DEFAULT_TIMEOUT_MS,
405
+ IDEMPOTENT_RETRIES
406
+ // auth exchange has no side effect — safe to retry
266
407
  );
267
408
  }
268
409
  function jwtHeaders(jwtToken) {
@@ -271,7 +412,9 @@ function jwtHeaders(jwtToken) {
271
412
  async function listKeys(jwtToken, apiHost) {
272
413
  return request(
273
414
  `${scheme(apiHost)}://${apiHost}/api/keys`,
274
- { method: "GET", headers: jwtHeaders(jwtToken) }
415
+ { method: "GET", headers: jwtHeaders(jwtToken) },
416
+ DEFAULT_TIMEOUT_MS,
417
+ IDEMPOTENT_RETRIES
275
418
  );
276
419
  }
277
420
  async function enableOAuthForKey(keyId, plaintextKey, jwtToken, apiHost) {
@@ -287,7 +430,9 @@ async function enableOAuthForKey(keyId, plaintextKey, jwtToken, apiHost) {
287
430
  async function listOAuthProviders(apiHost) {
288
431
  return request(
289
432
  `${scheme(apiHost)}://${apiHost}/api/oauth/providers`,
290
- { method: "GET", headers: { "Content-Type": "application/json" } }
433
+ { method: "GET", headers: { "Content-Type": "application/json" } },
434
+ DEFAULT_TIMEOUT_MS,
435
+ IDEMPOTENT_RETRIES
291
436
  );
292
437
  }
293
438
  async function initiateOAuth(apiKeyId, providerId, jwtToken, apiHost, scopes) {
@@ -305,7 +450,9 @@ async function initiateOAuth(apiKeyId, providerId, jwtToken, apiHost, scopes) {
305
450
  async function listOAuthBindings(jwtToken, apiHost) {
306
451
  return request(
307
452
  `${scheme(apiHost)}://${apiHost}/api/oauth/bindings`,
308
- { method: "GET", headers: jwtHeaders(jwtToken) }
453
+ { method: "GET", headers: jwtHeaders(jwtToken) },
454
+ DEFAULT_TIMEOUT_MS,
455
+ IDEMPOTENT_RETRIES
309
456
  );
310
457
  }
311
458
  async function deleteOAuthBinding(bindingId, jwtToken, apiHost) {
@@ -874,6 +1021,7 @@ __export(config_exports, {
874
1021
  configSet: () => configSet,
875
1022
  configShow: () => configShow
876
1023
  });
1024
+ import { readFileSync as readFileSync2 } from "fs";
877
1025
  var CONFIG_HELP = `xapi-to config - Manage CLI configuration
878
1026
 
879
1027
  USAGE
@@ -881,7 +1029,7 @@ USAGE
881
1029
 
882
1030
  COMMANDS
883
1031
  show Show current config (host, apiKey path, etc.)
884
- set apiKey=<key> Save API key to ~/.xapi/config.json
1032
+ set apiKey=<key> Save API key to ~/.xapi/config.json (apiKey=- reads from stdin)
885
1033
  health Check backend connectivity (alias: xapi-to health)
886
1034
 
887
1035
  FLAGS
@@ -890,6 +1038,7 @@ FLAGS
890
1038
  EXAMPLES
891
1039
  xapi-to config show
892
1040
  xapi-to config set apiKey=xapi_abc123
1041
+ echo "$XAPI_KEY" | xapi-to config set apiKey=- # keeps the key out of shell history
893
1042
  xapi-to config health
894
1043
  `;
895
1044
  async function configShow(args, flags) {
@@ -904,7 +1053,12 @@ async function configSet(args, flags) {
904
1053
  const key = arg.slice(0, eq);
905
1054
  if (key === "host") err("host is built-in and cannot be configured");
906
1055
  if (key !== "apiKey") err(`unknown config key: ${key} (only apiKey is configurable)`);
907
- updates.apiKey = arg.slice(eq + 1);
1056
+ let value = arg.slice(eq + 1);
1057
+ if (value === "-") {
1058
+ value = readFileSync2(0, "utf-8").trim();
1059
+ }
1060
+ if (!value) err("apiKey is empty");
1061
+ updates.apiKey = value;
908
1062
  }
909
1063
  saveConfig(updates);
910
1064
  console.log(JSON.stringify({ ok: true, updated: Object.keys(updates) }));
@@ -923,6 +1077,7 @@ async function configHealth(args, flags) {
923
1077
 
924
1078
  // src/commands/register.ts
925
1079
  async function registerAccount(referralCode) {
1080
+ assertAllowedHost(XAPI_API_HOST);
926
1081
  const controller = new AbortController();
927
1082
  const timer = setTimeout(() => controller.abort(), 15e3);
928
1083
  try {
@@ -1070,15 +1225,13 @@ async function findCurrentKeyRecord(plaintextKey, jwtToken) {
1070
1225
  if (!Array.isArray(keys) || keys.length === 0) {
1071
1226
  throw new Error("No API keys found for this account");
1072
1227
  }
1073
- if (keys.length === 1) return keys[0];
1074
1228
  const prefix = plaintextKey.substring(0, 7);
1075
1229
  const match = keys.find((k) => k.keyPreview.startsWith(prefix));
1076
- if (!match) {
1077
- throw new Error(
1078
- `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.`
1079
- );
1080
- }
1081
- return match;
1230
+ if (match) return match;
1231
+ if (keys.length === 1) return keys[0];
1232
+ throw new Error(
1233
+ `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.`
1234
+ );
1082
1235
  }
1083
1236
  function resolveScopeDefs(provider) {
1084
1237
  if (Array.isArray(provider.scopeDefinitions) && provider.scopeDefinitions.length > 0) {
@@ -1358,19 +1511,217 @@ async function oauthProviders(args, flags) {
1358
1511
  }
1359
1512
  }
1360
1513
 
1361
- // src/index.ts
1362
- var { CONFIG_HELP: CONFIG_HELP2 } = config_exports;
1363
- var { OAUTH_HELP: OAUTH_HELP2 } = oauth_exports;
1514
+ // src/commands/task.ts
1515
+ var POLL_HELP = `xapi-to task poll - Poll an async task once
1516
+
1517
+ USAGE
1518
+ xapi-to task poll <task_id> [flags]
1519
+
1520
+ FLAGS
1521
+ --format json|pretty|table Output format
1522
+
1523
+ EXAMPLES
1524
+ xapi-to task poll 550e8400-e29b-41d4-a716-446655440000
1525
+ `;
1526
+ var WAIT_HELP = `xapi-to task wait - Wait until async task reaches terminal status
1527
+
1528
+ USAGE
1529
+ xapi-to task wait <task_id> [flags]
1530
+
1531
+ FLAGS
1532
+ --interval <duration> Poll interval (default: 2s). Supports ms/s/m/h
1533
+ --timeout <duration> Max wait duration (optional). Supports ms/s/m/h
1534
+ --max-attempts <number> Max poll attempts (optional)
1535
+ --format json|pretty|table Output format
1536
+
1537
+ EXAMPLES
1538
+ xapi-to task wait 550e8400-e29b-41d4-a716-446655440000
1539
+ xapi-to task wait 550e8400-e29b-41d4-a716-446655440000 --interval 1s --timeout 10m
1540
+ `;
1541
+ var TASK_HELP = `xapi-to task - Async task helpers
1542
+
1543
+ USAGE
1544
+ xapi-to task <command> [args] [flags]
1545
+
1546
+ COMMANDS
1547
+ poll <task_id> Poll task status once (wraps action: task.poll)
1548
+ wait <task_id> Poll repeatedly until terminal status
1549
+
1550
+ EXAMPLES
1551
+ xapi-to task poll 550e8400-e29b-41d4-a716-446655440000
1552
+ xapi-to task wait 550e8400-e29b-41d4-a716-446655440000 --interval 2s --timeout 10m
1553
+ `;
1554
+ function showHelpIfRequested2(flags, helpText) {
1555
+ if (flags.help) {
1556
+ console.log(helpText);
1557
+ process.exit(0);
1558
+ }
1559
+ }
1560
+ function parseDurationMs(raw) {
1561
+ const text = raw.trim().toLowerCase();
1562
+ const match = text.match(/^(\d+)(ms|s|m|h)?$/);
1563
+ if (!match) {
1564
+ err(`invalid duration "${raw}". Use formats like 500ms, 2s, 5m, 1h.`);
1565
+ }
1566
+ const value = Number(match[1]);
1567
+ const unit = match[2] || "ms";
1568
+ if (!Number.isFinite(value) || value < 0) {
1569
+ err(`invalid duration "${raw}". Duration must be >= 0.`);
1570
+ }
1571
+ switch (unit) {
1572
+ case "ms":
1573
+ return value;
1574
+ case "s":
1575
+ return value * 1e3;
1576
+ case "m":
1577
+ return value * 6e4;
1578
+ case "h":
1579
+ return value * 36e5;
1580
+ default:
1581
+ return value;
1582
+ }
1583
+ }
1584
+ function parsePositiveDurationMs(raw, flagName) {
1585
+ const ms = parseDurationMs(raw);
1586
+ if (ms <= 0) {
1587
+ err(`${flagName} must be greater than 0`);
1588
+ }
1589
+ return ms;
1590
+ }
1591
+ function parsePositiveInt(raw, flagName) {
1592
+ const n = Number(raw);
1593
+ if (!Number.isInteger(n) || n <= 0) {
1594
+ err(`${flagName} must be a positive integer`);
1595
+ }
1596
+ return n;
1597
+ }
1598
+ function sleep2(ms) {
1599
+ if (ms <= 0) return Promise.resolve();
1600
+ return new Promise((resolve) => setTimeout(resolve, ms));
1601
+ }
1602
+ function extractTaskPayload(res) {
1603
+ if (res && typeof res === "object") {
1604
+ const obj = res;
1605
+ if (typeof obj.status === "string") return obj;
1606
+ if (obj.data && typeof obj.data === "object" && !Array.isArray(obj.data)) {
1607
+ const dataObj = obj.data;
1608
+ if (typeof dataObj.status === "string") return dataObj;
1609
+ }
1610
+ }
1611
+ return res ?? {};
1612
+ }
1613
+ function getStatus(payload) {
1614
+ const status = payload.status;
1615
+ if (status === "pending" || status === "processing" || status === "succeeded" || status === "failed" || status === "expired") {
1616
+ return status;
1617
+ }
1618
+ return void 0;
1619
+ }
1620
+ async function taskPoll(args, flags) {
1621
+ showHelpIfRequested2(flags, POLL_HELP);
1622
+ const taskId = args[0];
1623
+ if (!taskId) err("usage: xapi-to task poll <task_id>");
1624
+ const cfg = getConfig();
1625
+ requireApiKey(cfg);
1626
+ try {
1627
+ const res = await actionCall("task.poll", { task_id: taskId }, cfg, void 0, 2);
1628
+ const payload = extractTaskPayload(res);
1629
+ output(payload, flags.format);
1630
+ } catch (e) {
1631
+ err("task poll failed", e.message);
1632
+ }
1633
+ }
1634
+ async function taskWait(args, flags) {
1635
+ showHelpIfRequested2(flags, WAIT_HELP);
1636
+ const taskId = args[0];
1637
+ if (!taskId) err("usage: xapi-to task wait <task_id>");
1638
+ const intervalMs = parsePositiveDurationMs(flags.interval || "2s", "--interval");
1639
+ const timeoutMs = flags.timeout ? parseDurationMs(flags.timeout) : void 0;
1640
+ const maxAttempts = flags["max-attempts"] ? parsePositiveInt(flags["max-attempts"], "--max-attempts") : void 0;
1641
+ const cfg = getConfig();
1642
+ requireApiKey(cfg);
1643
+ const startedAt = Date.now();
1644
+ const deadline = timeoutMs !== void 0 ? startedAt + timeoutMs : void 0;
1645
+ let attempts = 0;
1646
+ while (true) {
1647
+ if (deadline !== void 0 && Date.now() >= deadline) {
1648
+ err(
1649
+ "task wait timeout",
1650
+ `task_id=${taskId}, elapsed_ms=${Date.now() - startedAt}, timeout_ms=${timeoutMs}`
1651
+ );
1652
+ }
1653
+ attempts += 1;
1654
+ let retryDelayMs;
1655
+ try {
1656
+ const remaining = deadline !== void 0 ? Math.max(1, deadline - Date.now()) : void 0;
1657
+ const res = await actionCall("task.poll", { task_id: taskId }, cfg, void 0, 0, remaining);
1658
+ const payload = extractTaskPayload(res);
1659
+ const status = getStatus(payload);
1660
+ if (!status) {
1661
+ err("task wait failed", "task.poll returned invalid response: missing status");
1662
+ }
1663
+ if (status === "succeeded") {
1664
+ output(payload, flags.format);
1665
+ return;
1666
+ }
1667
+ if (status === "failed" || status === "expired") {
1668
+ output(payload, flags.format);
1669
+ process.exit(1);
1670
+ }
1671
+ } catch (e) {
1672
+ if (e instanceof Error && e.message === "process.exit") {
1673
+ throw e;
1674
+ }
1675
+ if (deadline !== void 0 && Date.now() >= deadline) {
1676
+ err(
1677
+ "task wait timeout",
1678
+ `task_id=${taskId}, elapsed_ms=${Date.now() - startedAt}, timeout_ms=${timeoutMs}`
1679
+ );
1680
+ }
1681
+ if (isRetryableRequestError(e)) {
1682
+ retryDelayMs = e instanceof HttpError ? e.retryAfterMs : void 0;
1683
+ } else {
1684
+ err("task wait failed", e.message);
1685
+ }
1686
+ }
1687
+ if (maxAttempts && attempts >= maxAttempts) {
1688
+ err(
1689
+ "task wait exceeded max attempts",
1690
+ `task_id=${taskId}, attempts=${attempts}, max_attempts=${maxAttempts}`
1691
+ );
1692
+ }
1693
+ const desiredWaitMs = retryDelayMs ?? intervalMs;
1694
+ const waitMs = deadline !== void 0 ? Math.min(desiredWaitMs, Math.max(0, deadline - Date.now())) : desiredWaitMs;
1695
+ await sleep2(waitMs);
1696
+ }
1697
+ }
1698
+ function taskHelp() {
1699
+ return TASK_HELP;
1700
+ }
1701
+
1702
+ // src/args.ts
1364
1703
  function parseArgs(argv) {
1365
1704
  const positional = [];
1366
1705
  const flags = {};
1367
1706
  let i = 0;
1707
+ let onlyPositional = false;
1368
1708
  while (i < argv.length) {
1369
1709
  const arg = argv[i];
1370
- if (arg.startsWith("--")) {
1710
+ if (!onlyPositional && arg === "--") {
1711
+ onlyPositional = true;
1712
+ i++;
1713
+ continue;
1714
+ }
1715
+ if (!onlyPositional && arg.startsWith("--")) {
1716
+ const eq = arg.indexOf("=");
1717
+ if (eq !== -1) {
1718
+ flags[arg.slice(2, eq)] = arg.slice(eq + 1);
1719
+ i++;
1720
+ continue;
1721
+ }
1371
1722
  const key = arg.slice(2);
1372
1723
  const next = argv[i + 1];
1373
- if (next && !next.startsWith("--")) {
1724
+ if (next !== void 0 && !next.startsWith("--")) {
1374
1725
  flags[key] = next;
1375
1726
  i += 2;
1376
1727
  } else {
@@ -1384,6 +1735,10 @@ function parseArgs(argv) {
1384
1735
  }
1385
1736
  return { positional, flags };
1386
1737
  }
1738
+
1739
+ // src/index.ts
1740
+ var { CONFIG_HELP: CONFIG_HELP2 } = config_exports;
1741
+ var { OAUTH_HELP: OAUTH_HELP2 } = oauth_exports;
1387
1742
  var HELP = `xapi-to - agent-friendly CLI for xapi
1388
1743
 
1389
1744
  USAGE
@@ -1411,6 +1766,12 @@ COMMANDS
1411
1766
  --code <target> Generate code snippet instead of executing
1412
1767
  Variants: python.requests, python.httpx, javascript.fetch, javascript.axios
1413
1768
 
1769
+ task poll <task_id> Poll async task once (wraps task.poll)
1770
+ task wait <task_id> Wait until async task completes
1771
+ --interval <duration> Poll interval, e.g. 2s (default: 2s)
1772
+ --timeout <duration> Max wait duration, e.g. 10m
1773
+ --max-attempts <number> Max poll attempts
1774
+
1414
1775
  oauth bind [--provider twitter] Bind Twitter OAuth to your API key
1415
1776
  oauth status List current OAuth bindings
1416
1777
  oauth unbind <binding-id> Remove an OAuth binding
@@ -1449,6 +1810,8 @@ EXAMPLES
1449
1810
  xapi-to get twitter.tweet_detail --code py --format pretty
1450
1811
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"1234567890"}'
1451
1812
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"1234567890"}' --code python
1813
+ xapi-to task poll 550e8400-e29b-41d4-a716-446655440000
1814
+ xapi-to task wait 550e8400-e29b-41d4-a716-446655440000 --interval 2s --timeout 10m
1452
1815
  xapi-to categories
1453
1816
  xapi-to services --format table
1454
1817
  xapi-to config set apiKey=xapi_abc123
@@ -1476,6 +1839,23 @@ async function main() {
1476
1839
  return actionGet2(rest, flags);
1477
1840
  case "call":
1478
1841
  return actionCall2(rest, flags);
1842
+ case "task": {
1843
+ if (rest.length === 0) {
1844
+ console.log(taskHelp());
1845
+ process.exit(0);
1846
+ }
1847
+ const [subCmd, ...subRest] = rest;
1848
+ switch (subCmd) {
1849
+ case "poll":
1850
+ return taskPoll(subRest, flags);
1851
+ case "wait":
1852
+ return taskWait(subRest, flags);
1853
+ default:
1854
+ console.error(JSON.stringify({ error: `unknown task command: ${subCmd}`, hint: "valid commands: poll, wait" }));
1855
+ process.exit(1);
1856
+ }
1857
+ break;
1858
+ }
1479
1859
  // ── OAuth commands ──
1480
1860
  case "oauth": {
1481
1861
  if (flags.help || rest.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xapi-to",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "Agent-friendly CLI for xapi - discover and call capabilities and APIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,7 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "build": "tsup src/index.ts --format esm --target node18 --clean --out-dir dist --tsconfig tsconfig.build.json",
16
+ "typecheck": "tsc --noEmit",
16
17
  "start": "node dist/index.js",
17
18
  "dev": "XAPI_ACTION_HOST=localhost:3003 bun run src/index.ts",
18
19
  "prepublishOnly": "npm run build",