@scrappycoco/cli 0.8.3 → 0.8.4

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 = {
@@ -1238,9 +1276,11 @@ async function emitExecution(response, options, command) {
1238
1276
  await emit(summary, jsonMode);
1239
1277
  process.stderr.write(`Saved ${records.length} records to ${options.output}
1240
1278
  `);
1279
+ if (response.status === "failed") process.exitCode = EXIT.api;
1241
1280
  return;
1242
1281
  }
1243
1282
  await emit(response, jsonMode);
1283
+ if (response.status === "failed") process.exitCode = EXIT.api;
1244
1284
  }
1245
1285
  function executionSummary(response) {
1246
1286
  const records = response.records;
@@ -1266,6 +1306,7 @@ function executionSummary(response) {
1266
1306
  "result_count",
1267
1307
  "latency_ms",
1268
1308
  "estimated_cost_usd",
1309
+ "provider_http_status",
1269
1310
  "error"
1270
1311
  ]));
1271
1312
  }
@@ -1303,6 +1344,7 @@ function executionSummary(response) {
1303
1344
  "result_count",
1304
1345
  "latency_ms",
1305
1346
  "estimated_cost_usd",
1347
+ "provider_http_status",
1306
1348
  "error"
1307
1349
  ]));
1308
1350
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrappycoco/cli",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "CLI for Scrappycoco scraper discovery and execution",
5
5
  "type": "module",
6
6
  "bin": {