@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 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
- await rm(path, { force: true });
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 url or urls for web.extract_content").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>", "batch concurrency (default 3, maximum 10)").option("--limit <number>", "maximum records").option("--idempotency-key <key>", "stable retry key").option("--retry-failed <run-id>", "retry only failed URLs 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) => {
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrappycoco/cli",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "CLI for Scrappycoco scraper discovery and execution",
5
5
  "type": "module",
6
6
  "bin": {