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.
- package/README.md +10 -0
- package/dist/index.js +435 -55
- 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
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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 (
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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 (
|
|
1077
|
-
|
|
1078
|
-
|
|
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/
|
|
1362
|
-
var
|
|
1363
|
-
|
|
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
|
|
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.
|
|
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",
|