@scrappycoco/cli 0.8.3 → 0.8.5
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 +3 -0
- package/dist/index.js +48 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,6 +66,9 @@ agent, general workflow, or scheduler commands. For repeated checks, callers
|
|
|
66
66
|
schedule ordinary Runs and pass each returned cursor into the next Run.
|
|
67
67
|
|
|
68
68
|
Node.js 20 or newer is required. Interactive use authenticates with Clerk OAuth Authorization Code + PKCE. CI can set `SCRAPPYCOCO_API_KEY`.
|
|
69
|
+
API keys have no refresh-token rotation step and are preferred for unattended
|
|
70
|
+
concurrent application workloads. OAuth refresh is serialized across CLI
|
|
71
|
+
processes and recovers if another process rotates the stored token first.
|
|
69
72
|
|
|
70
73
|
OAuth and API requests have bounded timeouts. Set
|
|
71
74
|
`SCRAPPYCOCO_AUTH_TIMEOUT_MS` or `SCRAPPYCOCO_API_TIMEOUT_MS` only when a slow
|
package/dist/index.js
CHANGED
|
@@ -60,17 +60,23 @@ function wait(milliseconds) {
|
|
|
60
60
|
}
|
|
61
61
|
async function withCredentialRefreshLock(operation) {
|
|
62
62
|
const path = credentialRefreshLockPath();
|
|
63
|
+
const owner = randomUUID();
|
|
63
64
|
await mkdir(dirname(path), { recursive: true, mode: 448 });
|
|
64
65
|
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
65
66
|
while (true) {
|
|
66
67
|
try {
|
|
67
68
|
const handle = await open(path, "wx", 384);
|
|
68
69
|
try {
|
|
69
|
-
await handle.writeFile(JSON.stringify({ pid: process.pid, created_at: Date.now() }));
|
|
70
|
+
await handle.writeFile(JSON.stringify({ owner, pid: process.pid, created_at: Date.now() }));
|
|
70
71
|
return await operation();
|
|
71
72
|
} finally {
|
|
72
73
|
await handle.close();
|
|
73
|
-
|
|
74
|
+
try {
|
|
75
|
+
const current = JSON.parse(await readFile(path, "utf8"));
|
|
76
|
+
if (current.owner === owner) await rm(path, { force: true });
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error.code !== "ENOENT") throw error;
|
|
79
|
+
}
|
|
74
80
|
}
|
|
75
81
|
} catch (error) {
|
|
76
82
|
const code = error.code;
|
|
@@ -199,6 +205,8 @@ async function clearRefreshToken() {
|
|
|
199
205
|
var cachedAccessToken;
|
|
200
206
|
var refreshesInFlight = /* @__PURE__ */ new Map();
|
|
201
207
|
var DEFAULT_AUTH_HTTP_TIMEOUT_MS = 15e3;
|
|
208
|
+
var ROTATED_TOKEN_RELOAD_ATTEMPTS = 10;
|
|
209
|
+
var ROTATED_TOKEN_RELOAD_DELAY_MS = 100;
|
|
202
210
|
function positiveInteger(value, fallback) {
|
|
203
211
|
if (!value) return fallback;
|
|
204
212
|
const parsed = Number(value);
|
|
@@ -213,6 +221,12 @@ function networkMessage(error) {
|
|
|
213
221
|
}
|
|
214
222
|
return "failed to reach the authentication service";
|
|
215
223
|
}
|
|
224
|
+
function isInvalidGrant(error) {
|
|
225
|
+
return error instanceof CliError && error.exitCode === EXIT.auth && typeof error.details === "object" && error.details !== null && "oauth_error" in error.details && error.details.oauth_error === "invalid_grant";
|
|
226
|
+
}
|
|
227
|
+
function wait2(milliseconds) {
|
|
228
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
229
|
+
}
|
|
216
230
|
async function oauthFetch(url, init, phase, exitCode) {
|
|
217
231
|
try {
|
|
218
232
|
return await fetch(url, {
|
|
@@ -436,7 +450,9 @@ async function accessToken(apiUrl) {
|
|
|
436
450
|
let tokens;
|
|
437
451
|
let acceptedRefreshToken;
|
|
438
452
|
let lastAuthError;
|
|
453
|
+
const attemptedRefreshTokens = /* @__PURE__ */ new Set();
|
|
439
454
|
for (const candidate of candidates) {
|
|
455
|
+
attemptedRefreshTokens.add(candidate.value);
|
|
440
456
|
try {
|
|
441
457
|
tokens = await tokenRequest(config.issuer, new URLSearchParams({
|
|
442
458
|
grant_type: "refresh_token",
|
|
@@ -452,6 +468,28 @@ async function accessToken(apiUrl) {
|
|
|
452
468
|
lastAuthError = error;
|
|
453
469
|
}
|
|
454
470
|
}
|
|
471
|
+
if (!tokens && isInvalidGrant(lastAuthError)) {
|
|
472
|
+
for (let attempt = 0; attempt < ROTATED_TOKEN_RELOAD_ATTEMPTS && !tokens; attempt += 1) {
|
|
473
|
+
await wait2(ROTATED_TOKEN_RELOAD_DELAY_MS);
|
|
474
|
+
const rotatedCandidates = await loadRefreshTokenCandidates();
|
|
475
|
+
const rotated = rotatedCandidates.find(
|
|
476
|
+
(candidate) => !attemptedRefreshTokens.has(candidate.value)
|
|
477
|
+
);
|
|
478
|
+
if (!rotated) continue;
|
|
479
|
+
attemptedRefreshTokens.add(rotated.value);
|
|
480
|
+
try {
|
|
481
|
+
tokens = await tokenRequest(config.issuer, new URLSearchParams({
|
|
482
|
+
grant_type: "refresh_token",
|
|
483
|
+
client_id: config.client_id,
|
|
484
|
+
refresh_token: rotated.value
|
|
485
|
+
}), "token refresh");
|
|
486
|
+
acceptedRefreshToken = rotated.value;
|
|
487
|
+
} catch (error) {
|
|
488
|
+
if (!isInvalidGrant(error)) throw error;
|
|
489
|
+
lastAuthError = error;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
455
493
|
if (!tokens || !acceptedRefreshToken) throw lastAuthError || new CliError("Stored OAuth login is no longer valid.", EXIT.auth);
|
|
456
494
|
await saveRefreshToken(tokens.refresh_token || acceptedRefreshToken);
|
|
457
495
|
cachedAccessToken = {
|
|
@@ -601,6 +639,9 @@ var ApiClient = class {
|
|
|
601
639
|
}
|
|
602
640
|
delayMs = Math.min(delayMs * 2, maxDelayMs);
|
|
603
641
|
}
|
|
642
|
+
if (job.result_expired) {
|
|
643
|
+
throw new CliError(`Job ${job.job_id} results expired after 30 days. Use your saved copy or submit a new execution.`, EXIT.api, job);
|
|
644
|
+
}
|
|
604
645
|
if (!job.result || typeof job.result !== "object" || Array.isArray(job.result)) {
|
|
605
646
|
throw new CliError(`Job ${job.job_id} completed without a result.`, EXIT.api, job);
|
|
606
647
|
}
|
|
@@ -1238,9 +1279,11 @@ async function emitExecution(response, options, command) {
|
|
|
1238
1279
|
await emit(summary, jsonMode);
|
|
1239
1280
|
process.stderr.write(`Saved ${records.length} records to ${options.output}
|
|
1240
1281
|
`);
|
|
1282
|
+
if (response.status === "failed") process.exitCode = EXIT.api;
|
|
1241
1283
|
return;
|
|
1242
1284
|
}
|
|
1243
1285
|
await emit(response, jsonMode);
|
|
1286
|
+
if (response.status === "failed") process.exitCode = EXIT.api;
|
|
1244
1287
|
}
|
|
1245
1288
|
function executionSummary(response) {
|
|
1246
1289
|
const records = response.records;
|
|
@@ -1266,6 +1309,7 @@ function executionSummary(response) {
|
|
|
1266
1309
|
"result_count",
|
|
1267
1310
|
"latency_ms",
|
|
1268
1311
|
"estimated_cost_usd",
|
|
1312
|
+
"provider_http_status",
|
|
1269
1313
|
"error"
|
|
1270
1314
|
]));
|
|
1271
1315
|
}
|
|
@@ -1303,6 +1347,7 @@ function executionSummary(response) {
|
|
|
1303
1347
|
"result_count",
|
|
1304
1348
|
"latency_ms",
|
|
1305
1349
|
"estimated_cost_usd",
|
|
1350
|
+
"provider_http_status",
|
|
1306
1351
|
"error"
|
|
1307
1352
|
]));
|
|
1308
1353
|
}
|
|
@@ -1499,7 +1544,7 @@ catalog.command("inspect <capability-id>").action(async (capabilityId, _options,
|
|
|
1499
1544
|
globals(command).json || false
|
|
1500
1545
|
);
|
|
1501
1546
|
});
|
|
1502
|
-
program.command("run [capability-id]").description("Run a capability directly; use Discover only when configuration is uncertain").option("--config <discovery-id>", "run a finalized multi-step configuration").option("-f, --file <path>", "canonical request JSON file").option("--input <json>", "capability input JSON; use
|
|
1547
|
+
program.command("run [capability-id]").description("Run a capability directly; use Discover only when configuration is uncertain").option("--config <discovery-id>", "run a finalized multi-step configuration").option("-f, --file <path>", "canonical request JSON file").option("--input <json>", "capability input JSON; use items for batches, urls for extraction, queries for search").option("--provider <id>", "provider ID; repeat for an ordered fallback waterfall", collect, []).option("--provider-options <json>", "provider-native options keyed by provider ID").option("--concurrency <number>", "individual-request concurrency; native provider batches use upstream concurrency").option("--limit <number>", "maximum records").option("--idempotency-key <key>", "stable retry key").option("--retry-failed <run-id>", "retry only failed inputs from a partial batch run").option("--detach", "queue the job and return immediately").addOption(new Option("--format <format>", "output record format").choices(["json", "jsonl", "csv"]).default("json")).option("-o, --output <path>", "write records to a file").action(async (capabilityId, options, command) => {
|
|
1503
1548
|
if (options.retryFailed) {
|
|
1504
1549
|
if (capabilityId || options.config) {
|
|
1505
1550
|
throw new CliError("Do not combine --retry-failed with a capability ID or --config.", EXIT.usage);
|