xapi-to 0.1.15 → 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 +11 -0
- package/dist/index.js +651 -68
- 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.
|
|
@@ -140,6 +150,7 @@ Config is stored at `~/.xapi/config.json`.
|
|
|
140
150
|
| `twitter.user_by_screen_name` | Get user profile by username |
|
|
141
151
|
| `twitter.user_by_screen_names` | Batch get user profiles by usernames |
|
|
142
152
|
| `twitter.user_tweets` | Get tweets from a user |
|
|
153
|
+
| `twitter.user_tweets_and_replies` | Get tweets and replies from a user |
|
|
143
154
|
| `twitter.user_media` | Get media posts from a user |
|
|
144
155
|
| `twitter.following` | Get user following list |
|
|
145
156
|
| `twitter.followers` | Get user followers |
|
package/dist/index.js
CHANGED
|
@@ -24,12 +24,46 @@ function output(data, format) {
|
|
|
24
24
|
console.log(JSON.stringify(data, null, 2));
|
|
25
25
|
return;
|
|
26
26
|
}
|
|
27
|
-
if (fmt === "table"
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
if (fmt === "table") {
|
|
28
|
+
const rows = tableRows(data);
|
|
29
|
+
if (rows) {
|
|
30
|
+
printTable(rows);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
30
33
|
}
|
|
31
34
|
console.log(JSON.stringify(data, null, 2));
|
|
32
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
|
+
}
|
|
33
67
|
function printTable(rows) {
|
|
34
68
|
if (rows.length === 0) {
|
|
35
69
|
console.log("(empty)");
|
|
@@ -37,14 +71,14 @@ function printTable(rows) {
|
|
|
37
71
|
}
|
|
38
72
|
const keys = Object.keys(rows[0]);
|
|
39
73
|
const widths = keys.map(
|
|
40
|
-
(k) => Math.min(40, Math.max(k.length, ...rows.map((r) =>
|
|
74
|
+
(k) => Math.min(40, Math.max(k.length, ...rows.map((r) => formatCell(r[k]).length)))
|
|
41
75
|
);
|
|
42
76
|
const sep = widths.map((w) => "-".repeat(w)).join(" ");
|
|
43
77
|
const header = keys.map((k, i) => k.padEnd(widths[i])).join(" ");
|
|
44
78
|
console.log(header);
|
|
45
79
|
console.log(sep);
|
|
46
80
|
for (const row of rows) {
|
|
47
|
-
const line = keys.map((k, i) =>
|
|
81
|
+
const line = keys.map((k, i) => formatCell(row[k]).slice(0, widths[i]).padEnd(widths[i])).join(" ");
|
|
48
82
|
console.log(line);
|
|
49
83
|
}
|
|
50
84
|
}
|
|
@@ -66,7 +100,43 @@ import { join } from "path";
|
|
|
66
100
|
var XAPI_ACTION_HOST = process.env.XAPI_ACTION_HOST || "action.xapi.to";
|
|
67
101
|
var XAPI_API_HOST = process.env.XAPI_API_HOST || "api.xapi.to";
|
|
68
102
|
function scheme(host) {
|
|
69
|
-
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
|
+
}
|
|
70
140
|
}
|
|
71
141
|
var CONFIG_DIR = join(homedir(), ".xapi");
|
|
72
142
|
var CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
@@ -112,32 +182,128 @@ function showConfig() {
|
|
|
112
182
|
// src/client.ts
|
|
113
183
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
114
184
|
var EXECUTE_TIMEOUT_MS = 6e4;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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) {
|
|
128
255
|
throw new Error(
|
|
129
|
-
|
|
256
|
+
`refusing to follow redirect to "${res.headers.get("location") ?? "?"}" (would forward the API key past the host allowlist)`
|
|
130
257
|
);
|
|
131
258
|
}
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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);
|
|
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
|
+
}
|
|
136
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);
|
|
137
306
|
}
|
|
138
|
-
return body;
|
|
139
|
-
} finally {
|
|
140
|
-
clearTimeout(timer);
|
|
141
307
|
}
|
|
142
308
|
}
|
|
143
309
|
function headers(apiKey) {
|
|
@@ -157,7 +323,9 @@ async function actionList(opts, params = {}) {
|
|
|
157
323
|
if (params.service_id) url.searchParams.set("service_id", params.service_id);
|
|
158
324
|
return request(
|
|
159
325
|
url.toString(),
|
|
160
|
-
{ method: "GET", headers: headers(opts.apiKey) }
|
|
326
|
+
{ method: "GET", headers: headers(opts.apiKey) },
|
|
327
|
+
DEFAULT_TIMEOUT_MS,
|
|
328
|
+
IDEMPOTENT_RETRIES
|
|
161
329
|
);
|
|
162
330
|
}
|
|
163
331
|
async function actionSearch(query, opts, params = {}) {
|
|
@@ -169,7 +337,9 @@ async function actionSearch(query, opts, params = {}) {
|
|
|
169
337
|
if (params.page_size) url.searchParams.set("page_size", String(params.page_size));
|
|
170
338
|
return request(
|
|
171
339
|
url.toString(),
|
|
172
|
-
{ method: "GET", headers: headers(opts.apiKey) }
|
|
340
|
+
{ method: "GET", headers: headers(opts.apiKey) },
|
|
341
|
+
DEFAULT_TIMEOUT_MS,
|
|
342
|
+
IDEMPOTENT_RETRIES
|
|
173
343
|
);
|
|
174
344
|
}
|
|
175
345
|
async function actionCategories(opts, params = {}) {
|
|
@@ -177,16 +347,20 @@ async function actionCategories(opts, params = {}) {
|
|
|
177
347
|
if (params.source) url.searchParams.set("source", params.source);
|
|
178
348
|
return request(
|
|
179
349
|
url.toString(),
|
|
180
|
-
{ method: "GET", headers: headers(opts.apiKey) }
|
|
350
|
+
{ method: "GET", headers: headers(opts.apiKey) },
|
|
351
|
+
DEFAULT_TIMEOUT_MS,
|
|
352
|
+
IDEMPOTENT_RETRIES
|
|
181
353
|
);
|
|
182
354
|
}
|
|
183
355
|
async function actionGet(id, opts) {
|
|
184
356
|
return request(
|
|
185
357
|
`${baseUrl(opts)}/v1/actions/${encodeURIComponent(id)}`,
|
|
186
|
-
{ method: "GET", headers: headers(opts.apiKey) }
|
|
358
|
+
{ method: "GET", headers: headers(opts.apiKey) },
|
|
359
|
+
DEFAULT_TIMEOUT_MS,
|
|
360
|
+
IDEMPOTENT_RETRIES
|
|
187
361
|
);
|
|
188
362
|
}
|
|
189
|
-
async function actionCall(actionId, input, opts, httpMethod) {
|
|
363
|
+
async function actionCall(actionId, input, opts, httpMethod, retries = 0, timeoutMs = EXECUTE_TIMEOUT_MS) {
|
|
190
364
|
return request(
|
|
191
365
|
`${baseUrl(opts)}/v1/actions/execute`,
|
|
192
366
|
{
|
|
@@ -194,7 +368,8 @@ async function actionCall(actionId, input, opts, httpMethod) {
|
|
|
194
368
|
headers: headers(opts.apiKey),
|
|
195
369
|
body: JSON.stringify({ action_id: actionId, ...httpMethod ? { method: httpMethod } : {}, input })
|
|
196
370
|
},
|
|
197
|
-
EXECUTE_TIMEOUT_MS
|
|
371
|
+
Math.min(timeoutMs, EXECUTE_TIMEOUT_MS),
|
|
372
|
+
retries
|
|
198
373
|
);
|
|
199
374
|
}
|
|
200
375
|
async function actionServices(opts, params = {}) {
|
|
@@ -204,14 +379,18 @@ async function actionServices(opts, params = {}) {
|
|
|
204
379
|
if (params.category) url.searchParams.set("category", params.category);
|
|
205
380
|
return request(
|
|
206
381
|
url.toString(),
|
|
207
|
-
{ method: "GET", headers: headers(opts.apiKey) }
|
|
382
|
+
{ method: "GET", headers: headers(opts.apiKey) },
|
|
383
|
+
DEFAULT_TIMEOUT_MS,
|
|
384
|
+
IDEMPOTENT_RETRIES
|
|
208
385
|
);
|
|
209
386
|
}
|
|
210
387
|
async function healthCheck(opts) {
|
|
211
388
|
return request(
|
|
212
389
|
`${baseUrl(opts)}/health`,
|
|
213
390
|
{ method: "GET", headers: headers(opts.apiKey) },
|
|
214
|
-
5e3
|
|
391
|
+
5e3,
|
|
392
|
+
0
|
|
393
|
+
// health is a quick connectivity probe — fail fast, don't retry
|
|
215
394
|
);
|
|
216
395
|
}
|
|
217
396
|
async function loginWithApiKey(apiKey, apiHost) {
|
|
@@ -221,7 +400,10 @@ async function loginWithApiKey(apiKey, apiHost) {
|
|
|
221
400
|
method: "POST",
|
|
222
401
|
headers: { "Content-Type": "application/json" },
|
|
223
402
|
body: JSON.stringify({ apiKey })
|
|
224
|
-
}
|
|
403
|
+
},
|
|
404
|
+
DEFAULT_TIMEOUT_MS,
|
|
405
|
+
IDEMPOTENT_RETRIES
|
|
406
|
+
// auth exchange has no side effect — safe to retry
|
|
225
407
|
);
|
|
226
408
|
}
|
|
227
409
|
function jwtHeaders(jwtToken) {
|
|
@@ -230,7 +412,9 @@ function jwtHeaders(jwtToken) {
|
|
|
230
412
|
async function listKeys(jwtToken, apiHost) {
|
|
231
413
|
return request(
|
|
232
414
|
`${scheme(apiHost)}://${apiHost}/api/keys`,
|
|
233
|
-
{ method: "GET", headers: jwtHeaders(jwtToken) }
|
|
415
|
+
{ method: "GET", headers: jwtHeaders(jwtToken) },
|
|
416
|
+
DEFAULT_TIMEOUT_MS,
|
|
417
|
+
IDEMPOTENT_RETRIES
|
|
234
418
|
);
|
|
235
419
|
}
|
|
236
420
|
async function enableOAuthForKey(keyId, plaintextKey, jwtToken, apiHost) {
|
|
@@ -246,30 +430,37 @@ async function enableOAuthForKey(keyId, plaintextKey, jwtToken, apiHost) {
|
|
|
246
430
|
async function listOAuthProviders(apiHost) {
|
|
247
431
|
return request(
|
|
248
432
|
`${scheme(apiHost)}://${apiHost}/api/oauth/providers`,
|
|
249
|
-
{ method: "GET", headers: { "Content-Type": "application/json" } }
|
|
433
|
+
{ method: "GET", headers: { "Content-Type": "application/json" } },
|
|
434
|
+
DEFAULT_TIMEOUT_MS,
|
|
435
|
+
IDEMPOTENT_RETRIES
|
|
250
436
|
);
|
|
251
437
|
}
|
|
252
|
-
async function initiateOAuth(apiKeyId, providerId, jwtToken, apiHost) {
|
|
438
|
+
async function initiateOAuth(apiKeyId, providerId, jwtToken, apiHost, scopes) {
|
|
439
|
+
const body = { apiKeyId, providerId };
|
|
440
|
+
if (scopes) body.scopes = scopes;
|
|
253
441
|
return request(
|
|
254
442
|
`${scheme(apiHost)}://${apiHost}/api/oauth/authorize`,
|
|
255
443
|
{
|
|
256
444
|
method: "POST",
|
|
257
445
|
headers: jwtHeaders(jwtToken),
|
|
258
|
-
body: JSON.stringify(
|
|
446
|
+
body: JSON.stringify(body)
|
|
259
447
|
}
|
|
260
448
|
);
|
|
261
449
|
}
|
|
262
450
|
async function listOAuthBindings(jwtToken, apiHost) {
|
|
263
451
|
return request(
|
|
264
452
|
`${scheme(apiHost)}://${apiHost}/api/oauth/bindings`,
|
|
265
|
-
{ method: "GET", headers: jwtHeaders(jwtToken) }
|
|
453
|
+
{ method: "GET", headers: jwtHeaders(jwtToken) },
|
|
454
|
+
DEFAULT_TIMEOUT_MS,
|
|
455
|
+
IDEMPOTENT_RETRIES
|
|
266
456
|
);
|
|
267
457
|
}
|
|
268
458
|
async function deleteOAuthBinding(bindingId, jwtToken, apiHost) {
|
|
269
|
-
|
|
459
|
+
const result = await request(
|
|
270
460
|
`${scheme(apiHost)}://${apiHost}/api/oauth/bindings/${bindingId}`,
|
|
271
461
|
{ method: "DELETE", headers: jwtHeaders(jwtToken) }
|
|
272
462
|
);
|
|
463
|
+
return result ?? { success: true };
|
|
273
464
|
}
|
|
274
465
|
|
|
275
466
|
// src/codegen.ts
|
|
@@ -659,6 +850,7 @@ function getSource(flags) {
|
|
|
659
850
|
async function actionList2(args, flags) {
|
|
660
851
|
showHelpIfRequested(flags, LIST_HELP);
|
|
661
852
|
const cfg = getConfig();
|
|
853
|
+
const fmt = flags.format || getFormat();
|
|
662
854
|
try {
|
|
663
855
|
const res = await actionList(cfg, {
|
|
664
856
|
source: getSource(flags),
|
|
@@ -668,7 +860,7 @@ async function actionList2(args, flags) {
|
|
|
668
860
|
service_id: flags["service-id"]
|
|
669
861
|
});
|
|
670
862
|
const actions = res.actions || [];
|
|
671
|
-
if (
|
|
863
|
+
if (fmt === "table") {
|
|
672
864
|
output(actions.map((a) => ({
|
|
673
865
|
id: a.id,
|
|
674
866
|
method: a.method ?? "",
|
|
@@ -690,6 +882,7 @@ async function actionSearch2(args, flags) {
|
|
|
690
882
|
const query = args[0];
|
|
691
883
|
if (!query) err("usage: xapi-to search <query>");
|
|
692
884
|
const cfg = getConfig();
|
|
885
|
+
const fmt = flags.format || getFormat();
|
|
693
886
|
try {
|
|
694
887
|
const res = await actionSearch(query, cfg, {
|
|
695
888
|
source: getSource(flags),
|
|
@@ -698,7 +891,7 @@ async function actionSearch2(args, flags) {
|
|
|
698
891
|
page_size: flags["page-size"] ? parseInt(flags["page-size"]) : void 0
|
|
699
892
|
});
|
|
700
893
|
const results = res.results || [];
|
|
701
|
-
if (
|
|
894
|
+
if (fmt === "table") {
|
|
702
895
|
output(results.map((a) => ({
|
|
703
896
|
id: a.id,
|
|
704
897
|
method: a.method ?? "",
|
|
@@ -717,9 +910,10 @@ async function actionSearch2(args, flags) {
|
|
|
717
910
|
}
|
|
718
911
|
async function actionCategories2(args, flags) {
|
|
719
912
|
const cfg = getConfig();
|
|
913
|
+
const fmt = flags.format || getFormat();
|
|
720
914
|
try {
|
|
721
915
|
const res = await actionCategories(cfg, { source: getSource(flags) });
|
|
722
|
-
if (
|
|
916
|
+
if (fmt === "table") {
|
|
723
917
|
output(res.categories.map((c) => ({ category: c })), "table");
|
|
724
918
|
} else {
|
|
725
919
|
output(res, flags.format);
|
|
@@ -730,6 +924,7 @@ async function actionCategories2(args, flags) {
|
|
|
730
924
|
}
|
|
731
925
|
async function actionServices2(args, flags) {
|
|
732
926
|
const cfg = getConfig();
|
|
927
|
+
const fmt = flags.format || getFormat();
|
|
733
928
|
try {
|
|
734
929
|
const res = await actionServices(cfg, {
|
|
735
930
|
page: flags.page ? parseInt(flags.page) : void 0,
|
|
@@ -737,7 +932,7 @@ async function actionServices2(args, flags) {
|
|
|
737
932
|
category: flags.category
|
|
738
933
|
});
|
|
739
934
|
const services = res.services || [];
|
|
740
|
-
if (
|
|
935
|
+
if (fmt === "table") {
|
|
741
936
|
output(services.map((s) => ({
|
|
742
937
|
id: s.id,
|
|
743
938
|
name: s.name ?? "",
|
|
@@ -826,6 +1021,7 @@ __export(config_exports, {
|
|
|
826
1021
|
configSet: () => configSet,
|
|
827
1022
|
configShow: () => configShow
|
|
828
1023
|
});
|
|
1024
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
829
1025
|
var CONFIG_HELP = `xapi-to config - Manage CLI configuration
|
|
830
1026
|
|
|
831
1027
|
USAGE
|
|
@@ -833,7 +1029,7 @@ USAGE
|
|
|
833
1029
|
|
|
834
1030
|
COMMANDS
|
|
835
1031
|
show Show current config (host, apiKey path, etc.)
|
|
836
|
-
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)
|
|
837
1033
|
health Check backend connectivity (alias: xapi-to health)
|
|
838
1034
|
|
|
839
1035
|
FLAGS
|
|
@@ -842,6 +1038,7 @@ FLAGS
|
|
|
842
1038
|
EXAMPLES
|
|
843
1039
|
xapi-to config show
|
|
844
1040
|
xapi-to config set apiKey=xapi_abc123
|
|
1041
|
+
echo "$XAPI_KEY" | xapi-to config set apiKey=- # keeps the key out of shell history
|
|
845
1042
|
xapi-to config health
|
|
846
1043
|
`;
|
|
847
1044
|
async function configShow(args, flags) {
|
|
@@ -856,7 +1053,12 @@ async function configSet(args, flags) {
|
|
|
856
1053
|
const key = arg.slice(0, eq);
|
|
857
1054
|
if (key === "host") err("host is built-in and cannot be configured");
|
|
858
1055
|
if (key !== "apiKey") err(`unknown config key: ${key} (only apiKey is configurable)`);
|
|
859
|
-
|
|
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;
|
|
860
1062
|
}
|
|
861
1063
|
saveConfig(updates);
|
|
862
1064
|
console.log(JSON.stringify({ ok: true, updated: Object.keys(updates) }));
|
|
@@ -875,6 +1077,7 @@ async function configHealth(args, flags) {
|
|
|
875
1077
|
|
|
876
1078
|
// src/commands/register.ts
|
|
877
1079
|
async function registerAccount(referralCode) {
|
|
1080
|
+
assertAllowedHost(XAPI_API_HOST);
|
|
878
1081
|
const controller = new AbortController();
|
|
879
1082
|
const timer = setTimeout(() => controller.abort(), 15e3);
|
|
880
1083
|
try {
|
|
@@ -895,6 +1098,11 @@ async function registerAccount(referralCode) {
|
|
|
895
1098
|
}
|
|
896
1099
|
async function register(args, flags) {
|
|
897
1100
|
try {
|
|
1101
|
+
const cfg = getConfig();
|
|
1102
|
+
const force = flags.force === "true" || flags.force === "1" || flags.force === "yes";
|
|
1103
|
+
if (cfg.apiKey && !force) {
|
|
1104
|
+
err("register would overwrite existing apiKey", 'Run "xapi-to register --force" to create a new account and replace the saved key.');
|
|
1105
|
+
}
|
|
898
1106
|
const rawReferral = flags["referral-code"] ?? flags["referralCode"] ?? args[0];
|
|
899
1107
|
const referralCode = typeof rawReferral === "string" && rawReferral !== "true" && rawReferral.length > 0 ? rawReferral : void 0;
|
|
900
1108
|
const res = await registerAccount(referralCode);
|
|
@@ -910,7 +1118,7 @@ async function register(args, flags) {
|
|
|
910
1118
|
},
|
|
911
1119
|
tweetTemplate: res.tweetTemplate,
|
|
912
1120
|
...referralCode ? { referredBy: referralCode } : {},
|
|
913
|
-
note: "apiKey saved to ~/.xapi/config.json"
|
|
1121
|
+
note: force && cfg.apiKey ? "apiKey replaced in ~/.xapi/config.json" : "apiKey saved to ~/.xapi/config.json"
|
|
914
1122
|
}, flags.format);
|
|
915
1123
|
} catch (e) {
|
|
916
1124
|
err("register failed", e.message);
|
|
@@ -967,7 +1175,8 @@ __export(oauth_exports, {
|
|
|
967
1175
|
oauthBind: () => oauthBind,
|
|
968
1176
|
oauthProviders: () => oauthProviders,
|
|
969
1177
|
oauthStatus: () => oauthStatus,
|
|
970
|
-
oauthUnbind: () => oauthUnbind
|
|
1178
|
+
oauthUnbind: () => oauthUnbind,
|
|
1179
|
+
pollForBinding: () => pollForBinding
|
|
971
1180
|
});
|
|
972
1181
|
import { spawnSync } from "child_process";
|
|
973
1182
|
function openBrowser(url) {
|
|
@@ -977,14 +1186,22 @@ function openBrowser(url) {
|
|
|
977
1186
|
} catch {
|
|
978
1187
|
}
|
|
979
1188
|
}
|
|
980
|
-
|
|
1189
|
+
function bindingChangedAfter(binding, startedAtMs, existingBindingIds) {
|
|
1190
|
+
const changedAt = Date.parse(binding.updatedAt || binding.createdAt || "");
|
|
1191
|
+
if (!Number.isFinite(changedAt)) return !existingBindingIds.has(binding.id);
|
|
1192
|
+
return changedAt >= startedAtMs;
|
|
1193
|
+
}
|
|
1194
|
+
async function pollForBinding(apiKeyId, providerId, jwtToken, startedAt, existingBindingIds = /* @__PURE__ */ new Set(), timeoutMs = 5 * 60 * 1e3, intervalMs = 3e3) {
|
|
981
1195
|
const deadline = Date.now() + timeoutMs;
|
|
982
1196
|
const isTTY = process.stdout.isTTY;
|
|
1197
|
+
const startedAtMs = startedAt.getTime() - 5e3;
|
|
983
1198
|
while (Date.now() < deadline) {
|
|
984
1199
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
985
1200
|
try {
|
|
986
1201
|
const bindings = await listOAuthBindings(jwtToken, XAPI_API_HOST);
|
|
987
|
-
const match = Array.isArray(bindings) ? bindings.find(
|
|
1202
|
+
const match = Array.isArray(bindings) ? bindings.find(
|
|
1203
|
+
(b) => b.apiKeyId === apiKeyId && b.providerId === providerId && bindingChangedAfter(b, startedAtMs, existingBindingIds)
|
|
1204
|
+
) : null;
|
|
988
1205
|
if (match) return match;
|
|
989
1206
|
} catch {
|
|
990
1207
|
}
|
|
@@ -1008,13 +1225,106 @@ async function findCurrentKeyRecord(plaintextKey, jwtToken) {
|
|
|
1008
1225
|
if (!Array.isArray(keys) || keys.length === 0) {
|
|
1009
1226
|
throw new Error("No API keys found for this account");
|
|
1010
1227
|
}
|
|
1011
|
-
if (keys.length === 1) return keys[0];
|
|
1012
1228
|
const prefix = plaintextKey.substring(0, 7);
|
|
1013
1229
|
const match = keys.find((k) => k.keyPreview.startsWith(prefix));
|
|
1014
|
-
if (
|
|
1015
|
-
|
|
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
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
function resolveScopeDefs(provider) {
|
|
1237
|
+
if (Array.isArray(provider.scopeDefinitions) && provider.scopeDefinitions.length > 0) {
|
|
1238
|
+
return provider.scopeDefinitions;
|
|
1239
|
+
}
|
|
1240
|
+
const raw = (provider.defaultScopes || "").split(/[\s,]+/).filter(Boolean);
|
|
1241
|
+
return raw.map((s) => ({
|
|
1242
|
+
scope: s,
|
|
1243
|
+
label: s,
|
|
1244
|
+
description: "",
|
|
1245
|
+
required: false,
|
|
1246
|
+
category: ""
|
|
1247
|
+
}));
|
|
1248
|
+
}
|
|
1249
|
+
async function selectScopesInteractive(provider) {
|
|
1250
|
+
const defs = resolveScopeDefs(provider);
|
|
1251
|
+
if (defs.length === 0) return "";
|
|
1252
|
+
const required = defs.filter((d) => d.required);
|
|
1253
|
+
const optional = defs.filter((d) => !d.required);
|
|
1254
|
+
const selected = new Set(defs.map((d) => d.scope));
|
|
1255
|
+
if (optional.length === 0) {
|
|
1256
|
+
return required.map((d) => d.scope).join(" ");
|
|
1016
1257
|
}
|
|
1017
|
-
|
|
1258
|
+
const out = process.stderr;
|
|
1259
|
+
let cursor = 0;
|
|
1260
|
+
const hint = " \u2191\u2193 navigate \xB7 space toggle \xB7 a all \xB7 n none \xB7 enter confirm";
|
|
1261
|
+
const buildFrame = () => {
|
|
1262
|
+
const lines = [];
|
|
1263
|
+
for (const d of required) {
|
|
1264
|
+
const desc = d.description ? ` \u2014 ${d.description}` : "";
|
|
1265
|
+
lines.push(` \x1B[2m[*] ${d.label}${desc} (required)\x1B[0m`);
|
|
1266
|
+
}
|
|
1267
|
+
for (let i = 0; i < optional.length; i++) {
|
|
1268
|
+
const d = optional[i];
|
|
1269
|
+
const ptr = cursor === i ? " \x1B[36m\u276F\x1B[0m" : " ";
|
|
1270
|
+
const chk = selected.has(d.scope) ? "\x1B[32m\u2714\x1B[0m" : " ";
|
|
1271
|
+
const desc = d.description ? ` \x1B[2m\u2014 ${d.description}\x1B[0m` : "";
|
|
1272
|
+
lines.push(` ${ptr} [${chk}] ${d.label}${desc}`);
|
|
1273
|
+
}
|
|
1274
|
+
lines.push(`\x1B[2m${hint}\x1B[0m`);
|
|
1275
|
+
return lines.join("\n");
|
|
1276
|
+
};
|
|
1277
|
+
out.write("\n Scopes:\n");
|
|
1278
|
+
out.write("\x1B[s");
|
|
1279
|
+
out.write("\x1B[?25l");
|
|
1280
|
+
out.write(buildFrame());
|
|
1281
|
+
const redraw = () => {
|
|
1282
|
+
out.write("\x1B[u");
|
|
1283
|
+
out.write("\x1B[J");
|
|
1284
|
+
out.write(buildFrame());
|
|
1285
|
+
};
|
|
1286
|
+
return new Promise((resolve) => {
|
|
1287
|
+
const { stdin } = process;
|
|
1288
|
+
const wasRaw = stdin.isRaw;
|
|
1289
|
+
stdin.setRawMode(true);
|
|
1290
|
+
stdin.resume();
|
|
1291
|
+
const finish = (result) => {
|
|
1292
|
+
stdin.removeListener("data", onData);
|
|
1293
|
+
stdin.setRawMode(wasRaw ?? false);
|
|
1294
|
+
stdin.pause();
|
|
1295
|
+
out.write("\x1B[?25h");
|
|
1296
|
+
out.write("\n");
|
|
1297
|
+
resolve(result);
|
|
1298
|
+
};
|
|
1299
|
+
const onData = (buf) => {
|
|
1300
|
+
const key = buf.toString();
|
|
1301
|
+
if (key === "\r" || key === "\n") {
|
|
1302
|
+
finish(Array.from(selected).join(" "));
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
if (key === "") {
|
|
1306
|
+
finish("");
|
|
1307
|
+
process.exit(130);
|
|
1308
|
+
}
|
|
1309
|
+
if (key === "\x1B[A" || key === "k") {
|
|
1310
|
+
cursor = (cursor - 1 + optional.length) % optional.length;
|
|
1311
|
+
} else if (key === "\x1B[B" || key === "j") {
|
|
1312
|
+
cursor = (cursor + 1) % optional.length;
|
|
1313
|
+
} else if (key === " ") {
|
|
1314
|
+
const scope = optional[cursor].scope;
|
|
1315
|
+
if (selected.has(scope)) selected.delete(scope);
|
|
1316
|
+
else selected.add(scope);
|
|
1317
|
+
} else if (key === "a") {
|
|
1318
|
+
for (const d of optional) selected.add(d.scope);
|
|
1319
|
+
} else if (key === "n") {
|
|
1320
|
+
for (const d of optional) selected.delete(d.scope);
|
|
1321
|
+
} else {
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
redraw();
|
|
1325
|
+
};
|
|
1326
|
+
stdin.on("data", onData);
|
|
1327
|
+
});
|
|
1018
1328
|
}
|
|
1019
1329
|
var OAUTH_HELP = `xapi-to oauth - Manage OAuth bindings
|
|
1020
1330
|
|
|
@@ -1029,11 +1339,13 @@ COMMANDS
|
|
|
1029
1339
|
|
|
1030
1340
|
FLAGS
|
|
1031
1341
|
--provider <name> OAuth provider (default: twitter)
|
|
1342
|
+
--scopes <scopes> Space-separated scopes (skips interactive selection)
|
|
1032
1343
|
--format json|pretty|table Output format
|
|
1033
1344
|
|
|
1034
1345
|
EXAMPLES
|
|
1035
1346
|
xapi-to oauth bind
|
|
1036
1347
|
xapi-to oauth bind --provider twitter
|
|
1348
|
+
xapi-to oauth bind --scopes "tweet.read users.read"
|
|
1037
1349
|
xapi-to oauth status
|
|
1038
1350
|
xapi-to oauth status --format pretty
|
|
1039
1351
|
xapi-to oauth unbind abc123
|
|
@@ -1063,13 +1375,49 @@ async function oauthBind(args, flags) {
|
|
|
1063
1375
|
`Provider "${providerName}" not found. Available: ${available}`
|
|
1064
1376
|
);
|
|
1065
1377
|
}
|
|
1066
|
-
|
|
1378
|
+
let scopes;
|
|
1379
|
+
let headerPrinted = false;
|
|
1380
|
+
const isTTY = Boolean(
|
|
1381
|
+
process.stdout.isTTY && process.stdin.isTTY && typeof process.stdin.setRawMode === "function"
|
|
1382
|
+
);
|
|
1383
|
+
if (flags.scopes) {
|
|
1384
|
+
scopes = flags.scopes;
|
|
1385
|
+
} else if (isTTY) {
|
|
1386
|
+
const defs = resolveScopeDefs(provider);
|
|
1387
|
+
if (defs.length > 0) {
|
|
1388
|
+
console.error(`
|
|
1389
|
+
Provider : ${provider.name}`);
|
|
1390
|
+
console.error(` API Key : ${keyRecord.keyPreview}`);
|
|
1391
|
+
headerPrinted = true;
|
|
1392
|
+
scopes = await selectScopesInteractive(provider) || void 0;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
const existingBindingIds = /* @__PURE__ */ new Set();
|
|
1396
|
+
if (isTTY) {
|
|
1397
|
+
try {
|
|
1398
|
+
const existingBindings = await listOAuthBindings(jwtToken, XAPI_API_HOST);
|
|
1399
|
+
if (Array.isArray(existingBindings)) {
|
|
1400
|
+
for (const binding of existingBindings) {
|
|
1401
|
+
if (binding.apiKeyId === keyRecord.id && binding.providerId === provider.id) {
|
|
1402
|
+
existingBindingIds.add(binding.id);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
} catch {
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
const authorizationStartedAt = /* @__PURE__ */ new Date();
|
|
1410
|
+
const result = await initiateOAuth(keyRecord.id, provider.id, jwtToken, XAPI_API_HOST, scopes);
|
|
1067
1411
|
const { authorizationUrl } = result;
|
|
1068
|
-
const isTTY = process.stdout.isTTY;
|
|
1069
1412
|
if (isTTY) {
|
|
1070
|
-
|
|
1413
|
+
if (!headerPrinted) {
|
|
1414
|
+
console.error(`
|
|
1071
1415
|
Provider : ${provider.name}`);
|
|
1072
|
-
|
|
1416
|
+
console.error(` API Key : ${keyRecord.keyPreview}`);
|
|
1417
|
+
}
|
|
1418
|
+
if (scopes) {
|
|
1419
|
+
console.error(` Scopes : ${scopes}`);
|
|
1420
|
+
}
|
|
1073
1421
|
console.error(`
|
|
1074
1422
|
Authorization URL:
|
|
1075
1423
|
${authorizationUrl}
|
|
@@ -1077,14 +1425,20 @@ async function oauthBind(args, flags) {
|
|
|
1077
1425
|
console.error(" Opening browser...");
|
|
1078
1426
|
openBrowser(authorizationUrl);
|
|
1079
1427
|
console.error(" Waiting for you to complete authorization in the browser...\n");
|
|
1080
|
-
const binding = await pollForBinding(
|
|
1428
|
+
const binding = await pollForBinding(
|
|
1429
|
+
keyRecord.id,
|
|
1430
|
+
provider.id,
|
|
1431
|
+
jwtToken,
|
|
1432
|
+
authorizationStartedAt,
|
|
1433
|
+
existingBindingIds
|
|
1434
|
+
);
|
|
1081
1435
|
if (process.stdout.isTTY) process.stdout.write("\n");
|
|
1082
1436
|
if (binding) {
|
|
1083
1437
|
const account = binding.providerAccountName || "unknown";
|
|
1084
1438
|
console.error(`
|
|
1085
1439
|
Authorization complete! Bound to @${account}
|
|
1086
1440
|
`);
|
|
1087
|
-
output({ status: "success", provider: provider.name, account }, flags.format);
|
|
1441
|
+
output({ status: "success", provider: provider.name, account, scopes }, flags.format);
|
|
1088
1442
|
} else {
|
|
1089
1443
|
err("oauth bind timed out", 'Authorization was not completed within 5 minutes. Run "xapi-to oauth bind" again.');
|
|
1090
1444
|
}
|
|
@@ -1093,7 +1447,8 @@ async function oauthBind(args, flags) {
|
|
|
1093
1447
|
status: "pending",
|
|
1094
1448
|
provider: provider.name,
|
|
1095
1449
|
apiKey: keyRecord.keyPreview,
|
|
1096
|
-
authorizationUrl
|
|
1450
|
+
authorizationUrl,
|
|
1451
|
+
scopes
|
|
1097
1452
|
}, flags.format);
|
|
1098
1453
|
}
|
|
1099
1454
|
} catch (e) {
|
|
@@ -1156,19 +1511,217 @@ async function oauthProviders(args, flags) {
|
|
|
1156
1511
|
}
|
|
1157
1512
|
}
|
|
1158
1513
|
|
|
1159
|
-
// src/
|
|
1160
|
-
var
|
|
1161
|
-
|
|
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
|
|
1162
1703
|
function parseArgs(argv) {
|
|
1163
1704
|
const positional = [];
|
|
1164
1705
|
const flags = {};
|
|
1165
1706
|
let i = 0;
|
|
1707
|
+
let onlyPositional = false;
|
|
1166
1708
|
while (i < argv.length) {
|
|
1167
1709
|
const arg = argv[i];
|
|
1168
|
-
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
|
+
}
|
|
1169
1722
|
const key = arg.slice(2);
|
|
1170
1723
|
const next = argv[i + 1];
|
|
1171
|
-
if (next && !next.startsWith("--")) {
|
|
1724
|
+
if (next !== void 0 && !next.startsWith("--")) {
|
|
1172
1725
|
flags[key] = next;
|
|
1173
1726
|
i += 2;
|
|
1174
1727
|
} else {
|
|
@@ -1182,6 +1735,10 @@ function parseArgs(argv) {
|
|
|
1182
1735
|
}
|
|
1183
1736
|
return { positional, flags };
|
|
1184
1737
|
}
|
|
1738
|
+
|
|
1739
|
+
// src/index.ts
|
|
1740
|
+
var { CONFIG_HELP: CONFIG_HELP2 } = config_exports;
|
|
1741
|
+
var { OAUTH_HELP: OAUTH_HELP2 } = oauth_exports;
|
|
1185
1742
|
var HELP = `xapi-to - agent-friendly CLI for xapi
|
|
1186
1743
|
|
|
1187
1744
|
USAGE
|
|
@@ -1209,6 +1766,12 @@ COMMANDS
|
|
|
1209
1766
|
--code <target> Generate code snippet instead of executing
|
|
1210
1767
|
Variants: python.requests, python.httpx, javascript.fetch, javascript.axios
|
|
1211
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
|
+
|
|
1212
1775
|
oauth bind [--provider twitter] Bind Twitter OAuth to your API key
|
|
1213
1776
|
oauth status List current OAuth bindings
|
|
1214
1777
|
oauth unbind <binding-id> Remove an OAuth binding
|
|
@@ -1216,6 +1779,7 @@ COMMANDS
|
|
|
1216
1779
|
|
|
1217
1780
|
register [referral-code] Create a new user account (apiKey saved automatically)
|
|
1218
1781
|
--referral-code <code> Register with an inviter's referral code (also: --referralCode, or as positional arg)
|
|
1782
|
+
--force Replace an existing saved apiKey
|
|
1219
1783
|
balance Show current account balance
|
|
1220
1784
|
topup [--amount <usd>] [--method stripe|x402] Generate payment URL
|
|
1221
1785
|
|
|
@@ -1246,6 +1810,8 @@ EXAMPLES
|
|
|
1246
1810
|
xapi-to get twitter.tweet_detail --code py --format pretty
|
|
1247
1811
|
xapi-to call twitter.tweet_detail --input '{"tweet_id":"1234567890"}'
|
|
1248
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
|
|
1249
1815
|
xapi-to categories
|
|
1250
1816
|
xapi-to services --format table
|
|
1251
1817
|
xapi-to config set apiKey=xapi_abc123
|
|
@@ -1273,6 +1839,23 @@ async function main() {
|
|
|
1273
1839
|
return actionGet2(rest, flags);
|
|
1274
1840
|
case "call":
|
|
1275
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
|
+
}
|
|
1276
1859
|
// ── OAuth commands ──
|
|
1277
1860
|
case "oauth": {
|
|
1278
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",
|