@scrappycoco/cli 0.4.0 → 0.4.1
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 +28 -1
- package/dist/index.js +330 -76
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -12,7 +12,23 @@ npx --yes @scrappycoco/cli setup
|
|
|
12
12
|
|
|
13
13
|
`setup` opens OAuth in the browser. New users can create an account in that
|
|
14
14
|
flow. After authentication, it installs the public Scrappycoco skill, verifies
|
|
15
|
-
the live catalog, and tells you when to reload or restart your agent.
|
|
15
|
+
the live catalog, and tells you when to reload or restart your agent. Browser
|
|
16
|
+
authorization is not complete until the terminal confirms that the credential
|
|
17
|
+
was saved and the catalog check succeeded.
|
|
18
|
+
|
|
19
|
+
When the CLI runs on a remote or headless host whose `127.0.0.1` is not the
|
|
20
|
+
browser's localhost, keep the command running and use the manual callback
|
|
21
|
+
flow:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npx --yes @scrappycoco/cli setup --no-browser --manual-callback
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Open the printed authorization URL, then paste the browser's final callback
|
|
28
|
+
URL into that same terminal. The callback contains a short-lived
|
|
29
|
+
authorization code: never paste it into chat, logs, or a saved file. A timed
|
|
30
|
+
out or exited login cannot be resumed; start a new command and use its new
|
|
31
|
+
URL.
|
|
16
32
|
|
|
17
33
|
Individual commands remain available:
|
|
18
34
|
|
|
@@ -22,6 +38,7 @@ npx --yes @scrappycoco/cli catalog list --available --json
|
|
|
22
38
|
npx --yes @scrappycoco/cli catalog inspect web.extract_content --json
|
|
23
39
|
npx --yes @scrappycoco/cli run web.extract_content --file request.json --json
|
|
24
40
|
npx --yes @scrappycoco/cli discover --file discovery.json --json
|
|
41
|
+
npx --yes @scrappycoco/cli discover --id DISCOVERY_ID --test --input '{"url":"https://example.com"}' --json
|
|
25
42
|
npx --yes @scrappycoco/cli discover --id DISCOVERY_ID --finalize --json
|
|
26
43
|
npx --yes @scrappycoco/cli run --config DISCOVERY_ID --input '{}' --json
|
|
27
44
|
```
|
|
@@ -35,6 +52,10 @@ assistant, agent, workflow, or schedule commands.
|
|
|
35
52
|
|
|
36
53
|
Node.js 20 or newer is required. Interactive use authenticates with Clerk OAuth Authorization Code + PKCE. CI can set `SCRAPPYCOCO_API_KEY`.
|
|
37
54
|
|
|
55
|
+
OAuth and API requests have bounded timeouts. Set
|
|
56
|
+
`SCRAPPYCOCO_AUTH_TIMEOUT_MS` or `SCRAPPYCOCO_API_TIMEOUT_MS` only when a slow
|
|
57
|
+
network requires a longer window.
|
|
58
|
+
|
|
38
59
|
Use `--json` for machine-readable responses. Execution commands support
|
|
39
60
|
`--format json|jsonl|csv` with `--output`, provider-native
|
|
40
61
|
`--provider-options`, batch `--concurrency`, and an explicit
|
|
@@ -43,6 +64,12 @@ poll for completion; set `SCRAPPYCOCO_JOB_TIMEOUT_MS` to change the 20-minute
|
|
|
43
64
|
local wait. If a command times out while its job continues, inspect it with
|
|
44
65
|
`scrappycoco jobs get <job-id>`.
|
|
45
66
|
|
|
67
|
+
When provider or configuration choice is uncertain, define 2 to 5 named
|
|
68
|
+
`candidates` in a Discover route. Each candidate has its own `id`, `provider`,
|
|
69
|
+
and `options`, so one discovery can test both `zyte-http` with
|
|
70
|
+
`{"browser_html":false}` and `zyte-browser` with
|
|
71
|
+
`{"browser_html":true}` against the same input.
|
|
72
|
+
|
|
46
73
|
Use `scrappycoco --help` for the complete command reference. See the
|
|
47
74
|
[Scrappycoco API documentation](https://scrappycoco.ai/docs) for the public
|
|
48
75
|
contract.
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { randomUUID as
|
|
4
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
5
5
|
import { readFileSync } from "fs";
|
|
6
6
|
import { Command, CommanderError, Option } from "commander";
|
|
7
7
|
|
|
8
8
|
// src/auth.ts
|
|
9
9
|
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
|
10
10
|
import { createServer } from "http";
|
|
11
|
+
import { createInterface } from "readline/promises";
|
|
11
12
|
import open2 from "open";
|
|
12
13
|
|
|
13
14
|
// src/errors.ts
|
|
@@ -32,19 +33,25 @@ var CliError = class extends Error {
|
|
|
32
33
|
};
|
|
33
34
|
|
|
34
35
|
// src/storage.ts
|
|
35
|
-
import {
|
|
36
|
+
import { randomUUID } from "crypto";
|
|
37
|
+
import { chmod, mkdir, open, readFile, rename, rm, stat, writeFile } from "fs/promises";
|
|
36
38
|
import { homedir } from "os";
|
|
37
39
|
import { dirname, join } from "path";
|
|
38
40
|
import { deletePassword, getPassword, setPassword } from "cross-keychain";
|
|
39
41
|
var SERVICE = "scrappycoco-cli";
|
|
40
42
|
var ACCOUNT = "refresh-token";
|
|
41
43
|
function fallbackCredentialPath() {
|
|
44
|
+
const configured = process.env.SCRAPPYCOCO_CONFIG_HOME;
|
|
45
|
+
if (configured) return join(configured, "credentials.json");
|
|
42
46
|
const base = process.platform === "win32" ? process.env.APPDATA || join(homedir(), "AppData", "Roaming") : process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
43
47
|
return join(base, "scrappycoco", "credentials.json");
|
|
44
48
|
}
|
|
45
49
|
function credentialRefreshLockPath() {
|
|
46
50
|
return `${fallbackCredentialPath()}.refresh.lock`;
|
|
47
51
|
}
|
|
52
|
+
function credentialStatePath() {
|
|
53
|
+
return `${fallbackCredentialPath()}.state`;
|
|
54
|
+
}
|
|
48
55
|
var LOCK_RETRY_MS = 100;
|
|
49
56
|
var LOCK_TIMEOUT_MS = 3e4;
|
|
50
57
|
var LOCK_STALE_MS = 6e4;
|
|
@@ -93,23 +100,89 @@ async function readFallback() {
|
|
|
93
100
|
return null;
|
|
94
101
|
}
|
|
95
102
|
}
|
|
96
|
-
async function
|
|
103
|
+
async function readKeychain() {
|
|
97
104
|
try {
|
|
98
105
|
const value = await getPassword(SERVICE, ACCOUNT);
|
|
99
|
-
|
|
106
|
+
return value || null;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function readCredentialState() {
|
|
112
|
+
try {
|
|
113
|
+
const value = JSON.parse(await readFile(credentialStatePath(), "utf8"));
|
|
114
|
+
return value.storage === "keychain" || value.storage === "file" || value.storage === "none" ? value.storage : null;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async function writePrivateJson(path, value) {
|
|
120
|
+
await mkdir(dirname(path), { recursive: true, mode: 448 });
|
|
121
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
122
|
+
try {
|
|
123
|
+
await writeFile(temporary, JSON.stringify(value), { mode: 384 });
|
|
124
|
+
if (process.platform !== "win32") await chmod(temporary, 384);
|
|
125
|
+
await rename(temporary, path);
|
|
126
|
+
if (process.platform !== "win32") await chmod(path, 384);
|
|
127
|
+
} finally {
|
|
128
|
+
await rm(temporary, { force: true });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async function writeCredentialState(storage) {
|
|
132
|
+
await writePrivateJson(credentialStatePath(), { storage });
|
|
133
|
+
}
|
|
134
|
+
async function loadRefreshTokenCandidates() {
|
|
135
|
+
const state = await readCredentialState();
|
|
136
|
+
if (state === "none") return [];
|
|
137
|
+
if (state === "file") {
|
|
138
|
+
const value = await readFallback();
|
|
139
|
+
return value ? [{ value, storage: "file" }] : [];
|
|
140
|
+
}
|
|
141
|
+
if (state === "keychain") {
|
|
142
|
+
const value = await readKeychain();
|
|
143
|
+
return value ? [{ value, storage: "keychain" }] : [];
|
|
144
|
+
}
|
|
145
|
+
const [keychain, file] = await Promise.all([readKeychain(), readFallback()]);
|
|
146
|
+
const candidates = [];
|
|
147
|
+
if (keychain) candidates.push({ value: keychain, storage: "keychain" });
|
|
148
|
+
if (file && file !== keychain) candidates.push({ value: file, storage: "file" });
|
|
149
|
+
return candidates;
|
|
150
|
+
}
|
|
151
|
+
async function prepareCredentialStorage() {
|
|
152
|
+
const state = await readCredentialState();
|
|
153
|
+
if (state === "file") {
|
|
154
|
+
await assertFallbackWritable();
|
|
155
|
+
return "file";
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
await getPassword(SERVICE, ACCOUNT);
|
|
159
|
+
return "keychain";
|
|
100
160
|
} catch {
|
|
161
|
+
await assertFallbackWritable();
|
|
162
|
+
return "file";
|
|
101
163
|
}
|
|
102
|
-
|
|
164
|
+
}
|
|
165
|
+
async function assertFallbackWritable() {
|
|
166
|
+
const path = fallbackCredentialPath();
|
|
167
|
+
await mkdir(dirname(path), { recursive: true, mode: 448 });
|
|
168
|
+
const probe = `${path}.write-test-${process.pid}-${randomUUID()}`;
|
|
169
|
+
const handle = await open(probe, "w", 384);
|
|
170
|
+
await handle.close();
|
|
171
|
+
await rm(probe, { force: true });
|
|
103
172
|
}
|
|
104
173
|
async function saveRefreshToken(refreshToken) {
|
|
105
174
|
try {
|
|
106
175
|
await setPassword(SERVICE, ACCOUNT, refreshToken);
|
|
176
|
+
await rm(fallbackCredentialPath(), { force: true });
|
|
177
|
+
await writeCredentialState("keychain");
|
|
107
178
|
return "keychain";
|
|
108
179
|
} catch {
|
|
109
|
-
|
|
110
|
-
await
|
|
111
|
-
|
|
112
|
-
|
|
180
|
+
await writePrivateJson(fallbackCredentialPath(), { refresh_token: refreshToken });
|
|
181
|
+
await writeCredentialState("file");
|
|
182
|
+
try {
|
|
183
|
+
await deletePassword(SERVICE, ACCOUNT);
|
|
184
|
+
} catch {
|
|
185
|
+
}
|
|
113
186
|
return "file";
|
|
114
187
|
}
|
|
115
188
|
}
|
|
@@ -119,11 +192,42 @@ async function clearRefreshToken() {
|
|
|
119
192
|
} catch {
|
|
120
193
|
}
|
|
121
194
|
await rm(fallbackCredentialPath(), { force: true });
|
|
195
|
+
await writeCredentialState("none");
|
|
122
196
|
}
|
|
123
197
|
|
|
124
198
|
// src/auth.ts
|
|
125
199
|
var cachedAccessToken;
|
|
126
200
|
var refreshesInFlight = /* @__PURE__ */ new Map();
|
|
201
|
+
var DEFAULT_AUTH_HTTP_TIMEOUT_MS = 15e3;
|
|
202
|
+
function positiveInteger(value, fallback) {
|
|
203
|
+
if (!value) return fallback;
|
|
204
|
+
const parsed = Number(value);
|
|
205
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
206
|
+
}
|
|
207
|
+
function authHttpTimeoutMs() {
|
|
208
|
+
return positiveInteger(process.env.SCRAPPYCOCO_AUTH_TIMEOUT_MS, DEFAULT_AUTH_HTTP_TIMEOUT_MS);
|
|
209
|
+
}
|
|
210
|
+
function networkMessage(error) {
|
|
211
|
+
if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) {
|
|
212
|
+
return `timed out after ${Math.ceil(authHttpTimeoutMs() / 1e3)} seconds`;
|
|
213
|
+
}
|
|
214
|
+
return "failed to reach the authentication service";
|
|
215
|
+
}
|
|
216
|
+
async function oauthFetch(url, init, phase, exitCode) {
|
|
217
|
+
try {
|
|
218
|
+
return await fetch(url, {
|
|
219
|
+
...init,
|
|
220
|
+
signal: AbortSignal.timeout(authHttpTimeoutMs())
|
|
221
|
+
});
|
|
222
|
+
} catch (error) {
|
|
223
|
+
const timeout = error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError");
|
|
224
|
+
throw new CliError(
|
|
225
|
+
`OAuth ${phase} ${networkMessage(error)}. ${phase === "authorization-code exchange" ? "Authorization was received but was not saved; run `scrappycoco auth login` again." : "Check the connection and try again."}`,
|
|
226
|
+
timeout ? EXIT.timeout : exitCode,
|
|
227
|
+
{ phase, network_error: true }
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
127
231
|
async function oauthConfig(apiUrl) {
|
|
128
232
|
const clientId = process.env.SCRAPPYCOCO_OAUTH_CLIENT_ID;
|
|
129
233
|
if (clientId) {
|
|
@@ -133,7 +237,12 @@ async function oauthConfig(apiUrl) {
|
|
|
133
237
|
scopes: ["openid", "profile", "email"]
|
|
134
238
|
};
|
|
135
239
|
}
|
|
136
|
-
const response = await
|
|
240
|
+
const response = await oauthFetch(
|
|
241
|
+
`${apiUrl.replace(/\/$/, "")}/api/v1/oauth/cli-config`,
|
|
242
|
+
{},
|
|
243
|
+
"configuration request",
|
|
244
|
+
EXIT.api
|
|
245
|
+
);
|
|
137
246
|
const payload = await response.json().catch(() => ({}));
|
|
138
247
|
if (!response.ok || !payload.issuer || !payload.client_id || !Array.isArray(payload.scopes)) {
|
|
139
248
|
throw new CliError(payload.detail || "Scrappycoco CLI OAuth is not configured.", EXIT.auth);
|
|
@@ -156,49 +265,99 @@ function statesMatch(expected, received) {
|
|
|
156
265
|
const right = Buffer.from(received);
|
|
157
266
|
return left.length === right.length && timingSafeEqual(left, right);
|
|
158
267
|
}
|
|
159
|
-
async function tokenRequest(issuer, body) {
|
|
160
|
-
const response = await
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
268
|
+
async function tokenRequest(issuer, body, phase) {
|
|
269
|
+
const response = await oauthFetch(
|
|
270
|
+
`${issuer.replace(/\/$/, "")}/oauth/token`,
|
|
271
|
+
{
|
|
272
|
+
method: "POST",
|
|
273
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
274
|
+
body
|
|
275
|
+
},
|
|
276
|
+
phase,
|
|
277
|
+
EXIT.auth
|
|
278
|
+
);
|
|
165
279
|
const payload = await response.json().catch(() => ({}));
|
|
166
280
|
if (!response.ok || typeof payload.access_token !== "string") {
|
|
167
|
-
throw new CliError(
|
|
281
|
+
throw new CliError(
|
|
282
|
+
String(payload.error_description || payload.error || `OAuth ${phase} failed.`),
|
|
283
|
+
EXIT.auth,
|
|
284
|
+
{
|
|
285
|
+
phase,
|
|
286
|
+
status: response.status,
|
|
287
|
+
...typeof payload.error === "string" ? { oauth_error: payload.error } : {}
|
|
288
|
+
}
|
|
289
|
+
);
|
|
168
290
|
}
|
|
169
291
|
return payload;
|
|
170
292
|
}
|
|
293
|
+
function callbackResult(rawUrl, expectedState, redirectUri) {
|
|
294
|
+
const current = new URL(rawUrl, redirectUri);
|
|
295
|
+
const expected = new URL(redirectUri);
|
|
296
|
+
if (current.protocol !== expected.protocol || current.hostname !== expected.hostname || current.port !== expected.port || current.pathname !== expected.pathname) {
|
|
297
|
+
throw new CliError("OAuth callback URL did not match this login attempt.", EXIT.auth);
|
|
298
|
+
}
|
|
299
|
+
const state = current.searchParams.get("state") || "";
|
|
300
|
+
const providerError = current.searchParams.get("error");
|
|
301
|
+
if (!statesMatch(expectedState, state)) {
|
|
302
|
+
throw new CliError("OAuth state validation failed.", EXIT.auth);
|
|
303
|
+
}
|
|
304
|
+
if (providerError) {
|
|
305
|
+
const description = current.searchParams.get("error_description") || providerError;
|
|
306
|
+
throw new CliError(`OAuth authorization was denied: ${description}`, EXIT.auth);
|
|
307
|
+
}
|
|
308
|
+
const code = current.searchParams.get("code") || "";
|
|
309
|
+
if (!code) throw new CliError("OAuth callback did not include an authorization code.", EXIT.auth);
|
|
310
|
+
return { code, redirectUri };
|
|
311
|
+
}
|
|
171
312
|
async function login(options) {
|
|
172
313
|
const config = await oauthConfig(options.apiUrl);
|
|
173
314
|
const clientId = config.client_id;
|
|
174
315
|
const pkce = createPkce();
|
|
175
316
|
let timeout;
|
|
317
|
+
let manualReader;
|
|
176
318
|
const callback = new Promise((resolve, reject) => {
|
|
319
|
+
let settled = false;
|
|
320
|
+
const finish = (result, error) => {
|
|
321
|
+
if (settled) return;
|
|
322
|
+
settled = true;
|
|
323
|
+
if (timeout) clearTimeout(timeout);
|
|
324
|
+
manualReader?.close();
|
|
325
|
+
server.close();
|
|
326
|
+
if (error) reject(error);
|
|
327
|
+
else if (result) resolve(result);
|
|
328
|
+
};
|
|
177
329
|
const server = createServer((request, response) => {
|
|
178
|
-
const
|
|
179
|
-
if (
|
|
180
|
-
response.writeHead(
|
|
330
|
+
const address = server.address();
|
|
331
|
+
if (!address || typeof address === "string") {
|
|
332
|
+
response.writeHead(500, { "content-type": "text/plain" }).end("OAuth callback failed.");
|
|
333
|
+
finish(void 0, new CliError("OAuth callback failed.", EXIT.auth));
|
|
181
334
|
return;
|
|
182
335
|
}
|
|
183
|
-
const
|
|
184
|
-
const
|
|
185
|
-
if (
|
|
186
|
-
response.writeHead(
|
|
187
|
-
clearTimeout(timeout);
|
|
188
|
-
server.close();
|
|
189
|
-
reject(new CliError("OAuth state validation failed.", EXIT.auth));
|
|
336
|
+
const redirectUri2 = `http://127.0.0.1:${address.port}/callback`;
|
|
337
|
+
const current = new URL(request.url || "/", redirectUri2);
|
|
338
|
+
if (current.pathname !== "/callback") {
|
|
339
|
+
response.writeHead(404, { "content-type": "text/plain" }).end("Not found");
|
|
190
340
|
return;
|
|
191
341
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
342
|
+
try {
|
|
343
|
+
const result = callbackResult(request.url || "/", pkce.state, redirectUri2);
|
|
344
|
+
response.writeHead(200, { "content-type": "text/plain" }).end(
|
|
345
|
+
"Scrappycoco authorization received. Keep the terminal open while it securely saves the login; the terminal will report when it is complete."
|
|
346
|
+
);
|
|
347
|
+
finish(result);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
response.writeHead(400, { "content-type": "text/plain" }).end(
|
|
350
|
+
error instanceof Error ? error.message : "Invalid OAuth callback."
|
|
351
|
+
);
|
|
352
|
+
finish(void 0, error);
|
|
353
|
+
}
|
|
198
354
|
});
|
|
199
355
|
server.listen(0, "127.0.0.1", async () => {
|
|
200
356
|
const address = server.address();
|
|
201
|
-
if (!address || typeof address === "string")
|
|
357
|
+
if (!address || typeof address === "string") {
|
|
358
|
+
finish(void 0, new CliError("Could not start OAuth callback.", EXIT.auth));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
202
361
|
const redirectUri2 = `http://127.0.0.1:${address.port}/callback`;
|
|
203
362
|
const authorize = new URL(`${config.issuer.replace(/\/$/, "")}/oauth/authorize`);
|
|
204
363
|
authorize.search = new URLSearchParams({
|
|
@@ -213,11 +372,32 @@ async function login(options) {
|
|
|
213
372
|
process.stderr.write(`Open this URL to authenticate:
|
|
214
373
|
${authorize.toString()}
|
|
215
374
|
`);
|
|
216
|
-
if (
|
|
375
|
+
if (options.manualCallback) {
|
|
376
|
+
process.stderr.write(
|
|
377
|
+
"After signing in, paste the final callback URL into this terminal. Never paste it into chat.\n"
|
|
378
|
+
);
|
|
379
|
+
manualReader = createInterface({ input: process.stdin, output: process.stderr });
|
|
380
|
+
void manualReader.question("Callback URL: ").then((rawUrl) => {
|
|
381
|
+
try {
|
|
382
|
+
finish(callbackResult(rawUrl.trim(), pkce.state, redirectUri2));
|
|
383
|
+
} catch (error) {
|
|
384
|
+
finish(void 0, error);
|
|
385
|
+
}
|
|
386
|
+
}).catch((error) => finish(void 0, error));
|
|
387
|
+
}
|
|
388
|
+
if (!options.noBrowser) {
|
|
389
|
+
try {
|
|
390
|
+
await open2(authorize.toString());
|
|
391
|
+
} catch {
|
|
392
|
+
process.stderr.write("Could not open a browser automatically; use the URL above.\n");
|
|
393
|
+
}
|
|
394
|
+
}
|
|
217
395
|
});
|
|
218
396
|
timeout = setTimeout(() => {
|
|
219
|
-
|
|
220
|
-
|
|
397
|
+
finish(void 0, new CliError(
|
|
398
|
+
"OAuth login timed out. The printed URL and callback port are no longer valid; start a new login.",
|
|
399
|
+
EXIT.timeout
|
|
400
|
+
));
|
|
221
401
|
}, 5 * 6e4);
|
|
222
402
|
});
|
|
223
403
|
const { code, redirectUri } = await callback;
|
|
@@ -227,7 +407,7 @@ ${authorize.toString()}
|
|
|
227
407
|
code,
|
|
228
408
|
code_verifier: pkce.verifier,
|
|
229
409
|
redirect_uri: redirectUri
|
|
230
|
-
}));
|
|
410
|
+
}), "authorization-code exchange");
|
|
231
411
|
if (!tokens.refresh_token) throw new CliError("OAuth response did not include a refresh token.", EXIT.auth);
|
|
232
412
|
invalidateAccessToken();
|
|
233
413
|
return { storage: await saveRefreshToken(tokens.refresh_token) };
|
|
@@ -251,16 +431,29 @@ async function accessToken(apiUrl) {
|
|
|
251
431
|
return cachedAccessToken.value;
|
|
252
432
|
}
|
|
253
433
|
const config = await oauthConfig(normalizedApiUrl);
|
|
254
|
-
const
|
|
255
|
-
if (!
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
434
|
+
const candidates = await loadRefreshTokenCandidates();
|
|
435
|
+
if (!candidates.length) throw new CliError("Not logged in. Run `scrappycoco auth login`.", EXIT.auth);
|
|
436
|
+
let tokens;
|
|
437
|
+
let acceptedRefreshToken;
|
|
438
|
+
let lastAuthError;
|
|
439
|
+
for (const candidate of candidates) {
|
|
440
|
+
try {
|
|
441
|
+
tokens = await tokenRequest(config.issuer, new URLSearchParams({
|
|
442
|
+
grant_type: "refresh_token",
|
|
443
|
+
client_id: config.client_id,
|
|
444
|
+
refresh_token: candidate.value
|
|
445
|
+
}), "token refresh");
|
|
446
|
+
acceptedRefreshToken = candidate.value;
|
|
447
|
+
break;
|
|
448
|
+
} catch (error) {
|
|
449
|
+
if (!(error instanceof CliError) || error.exitCode !== EXIT.auth || typeof error.details === "object" && error.details !== null && "network_error" in error.details) {
|
|
450
|
+
throw error;
|
|
451
|
+
}
|
|
452
|
+
lastAuthError = error;
|
|
453
|
+
}
|
|
263
454
|
}
|
|
455
|
+
if (!tokens || !acceptedRefreshToken) throw lastAuthError || new CliError("Stored OAuth login is no longer valid.", EXIT.auth);
|
|
456
|
+
await saveRefreshToken(tokens.refresh_token || acceptedRefreshToken);
|
|
264
457
|
cachedAccessToken = {
|
|
265
458
|
apiUrl: normalizedApiUrl,
|
|
266
459
|
value: tokens.access_token,
|
|
@@ -277,14 +470,18 @@ async function accessToken(apiUrl) {
|
|
|
277
470
|
}
|
|
278
471
|
}
|
|
279
472
|
}
|
|
473
|
+
async function loadRefreshToken() {
|
|
474
|
+
return (await loadRefreshTokenCandidates())[0]?.value || null;
|
|
475
|
+
}
|
|
280
476
|
|
|
281
477
|
// src/client.ts
|
|
282
|
-
import { randomUUID } from "crypto";
|
|
478
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
283
479
|
var DEFAULT_API_URL = process.env.SCRAPPYCOCO_API_URL || "https://api.scrappycoco.ai";
|
|
284
480
|
var DEFAULT_JOB_TIMEOUT_MS = 20 * 60 * 1e3;
|
|
285
481
|
var DEFAULT_JOB_POLL_INITIAL_MS = 250;
|
|
286
482
|
var DEFAULT_JOB_POLL_MAX_MS = 5e3;
|
|
287
|
-
|
|
483
|
+
var DEFAULT_API_REQUEST_TIMEOUT_MS = 3e4;
|
|
484
|
+
function positiveInteger2(value, fallback) {
|
|
288
485
|
if (!value) return fallback;
|
|
289
486
|
const parsed = Number(value);
|
|
290
487
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
@@ -315,12 +512,28 @@ var ApiClient = class {
|
|
|
315
512
|
async request(method, path, body, headers, signal) {
|
|
316
513
|
const url = `${this.baseUrl.replace(/\/$/, "")}/api/v1${path}`;
|
|
317
514
|
const requestBody = body === void 0 ? void 0 : JSON.stringify(body);
|
|
318
|
-
const send = async () =>
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
signal
|
|
323
|
-
|
|
515
|
+
const send = async () => {
|
|
516
|
+
const timeoutSignal = AbortSignal.timeout(
|
|
517
|
+
positiveInteger2(process.env.SCRAPPYCOCO_API_TIMEOUT_MS, DEFAULT_API_REQUEST_TIMEOUT_MS)
|
|
518
|
+
);
|
|
519
|
+
const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
520
|
+
const requestHeaders = await this.headers(headers);
|
|
521
|
+
try {
|
|
522
|
+
return await fetch(url, {
|
|
523
|
+
method,
|
|
524
|
+
headers: requestHeaders,
|
|
525
|
+
body: requestBody,
|
|
526
|
+
signal: requestSignal
|
|
527
|
+
});
|
|
528
|
+
} catch (error) {
|
|
529
|
+
const timeout = error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError");
|
|
530
|
+
throw new CliError(
|
|
531
|
+
timeout ? `Scrappycoco API request timed out: ${method} ${path}.` : `Could not reach the Scrappycoco API: ${method} ${path}.`,
|
|
532
|
+
timeout ? EXIT.timeout : EXIT.api,
|
|
533
|
+
{ method, path }
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
};
|
|
324
537
|
let response = await send();
|
|
325
538
|
if (response.status === 401 && !process.env.SCRAPPYCOCO_API_KEY) {
|
|
326
539
|
invalidateAccessToken();
|
|
@@ -338,17 +551,17 @@ var ApiClient = class {
|
|
|
338
551
|
get(path, signal) {
|
|
339
552
|
return this.request("GET", path, void 0, void 0, signal);
|
|
340
553
|
}
|
|
341
|
-
post(path, body, key =
|
|
554
|
+
post(path, body, key = randomUUID2()) {
|
|
342
555
|
return this.request("POST", path, body, { "Idempotency-Key": key });
|
|
343
556
|
}
|
|
344
|
-
async postJob(path, body, key =
|
|
557
|
+
async postJob(path, body, key = randomUUID2()) {
|
|
345
558
|
const submitted = await this.post(path, body, key);
|
|
346
|
-
const timeoutMs =
|
|
347
|
-
const initialDelayMs =
|
|
559
|
+
const timeoutMs = positiveInteger2(process.env.SCRAPPYCOCO_JOB_TIMEOUT_MS, DEFAULT_JOB_TIMEOUT_MS);
|
|
560
|
+
const initialDelayMs = positiveInteger2(
|
|
348
561
|
process.env.SCRAPPYCOCO_JOB_POLL_INITIAL_MS,
|
|
349
562
|
DEFAULT_JOB_POLL_INITIAL_MS
|
|
350
563
|
);
|
|
351
|
-
const maxDelayMs =
|
|
564
|
+
const maxDelayMs = positiveInteger2(process.env.SCRAPPYCOCO_JOB_POLL_MAX_MS, DEFAULT_JOB_POLL_MAX_MS);
|
|
352
565
|
const deadline = Date.now() + timeoutMs;
|
|
353
566
|
let delayMs = Math.min(initialDelayMs, maxDelayMs);
|
|
354
567
|
let job = submitted;
|
|
@@ -490,7 +703,20 @@ async function detectSkillInstallerAgent(detector = determineAgent) {
|
|
|
490
703
|
if (!detected.isAgent || !detected.agent) return "universal";
|
|
491
704
|
return SKILL_INSTALLER_AGENTS[detected.agent.name] || "universal";
|
|
492
705
|
}
|
|
493
|
-
function skillInstallInvocation(agent = "universal") {
|
|
706
|
+
function skillInstallInvocation(agent = "universal", npmExecPath = process.env.npm_execpath) {
|
|
707
|
+
if (npmExecPath && /(?:^|[/\\])pnpm(?:\.c?js)?$/i.test(npmExecPath)) {
|
|
708
|
+
return {
|
|
709
|
+
command: process.execPath,
|
|
710
|
+
args: [
|
|
711
|
+
npmExecPath,
|
|
712
|
+
"dlx",
|
|
713
|
+
"skills",
|
|
714
|
+
...SKILL_INSTALL_ARGS.slice(2),
|
|
715
|
+
"--agent",
|
|
716
|
+
agent
|
|
717
|
+
]
|
|
718
|
+
};
|
|
719
|
+
}
|
|
494
720
|
return {
|
|
495
721
|
command: npxCommand(),
|
|
496
722
|
args: [...SKILL_INSTALL_ARGS, "--agent", agent]
|
|
@@ -558,6 +784,7 @@ function defaultDependencies(apiUrl) {
|
|
|
558
784
|
clearRefreshToken: clearRefreshToken2,
|
|
559
785
|
loadRefreshToken,
|
|
560
786
|
login,
|
|
787
|
+
prepareCredentialStorage,
|
|
561
788
|
installSkill,
|
|
562
789
|
listAvailableScrapers: () => client2.get("/scrapers?available_only=true")
|
|
563
790
|
};
|
|
@@ -567,10 +794,15 @@ async function performSetup(options, dependencies = defaultDependencies(options.
|
|
|
567
794
|
let authentication = usingApiKey ? "api_key" : "oauth";
|
|
568
795
|
let credentialStorage = usingApiKey ? "environment" : "existing";
|
|
569
796
|
const hadStoredToken = !usingApiKey && Boolean(await dependencies.loadRefreshToken());
|
|
797
|
+
if (!usingApiKey && !hadStoredToken) {
|
|
798
|
+
await dependencies.prepareCredentialStorage();
|
|
799
|
+
}
|
|
800
|
+
await dependencies.installSkill();
|
|
570
801
|
if (!usingApiKey && !hadStoredToken) {
|
|
571
802
|
const authenticated = await dependencies.login({
|
|
572
803
|
apiUrl: options.apiUrl,
|
|
573
|
-
noBrowser: options.noBrowser
|
|
804
|
+
noBrowser: options.noBrowser,
|
|
805
|
+
manualCallback: options.manualCallback
|
|
574
806
|
});
|
|
575
807
|
authentication = "oauth";
|
|
576
808
|
credentialStorage = authenticated.storage;
|
|
@@ -585,12 +817,12 @@ async function performSetup(options, dependencies = defaultDependencies(options.
|
|
|
585
817
|
await dependencies.clearRefreshToken();
|
|
586
818
|
const authenticated = await dependencies.login({
|
|
587
819
|
apiUrl: options.apiUrl,
|
|
588
|
-
noBrowser: options.noBrowser
|
|
820
|
+
noBrowser: options.noBrowser,
|
|
821
|
+
manualCallback: options.manualCallback
|
|
589
822
|
});
|
|
590
823
|
credentialStorage = authenticated.storage;
|
|
591
824
|
catalog2 = await dependencies.listAvailableScrapers();
|
|
592
825
|
}
|
|
593
|
-
await dependencies.installSkill();
|
|
594
826
|
return {
|
|
595
827
|
authenticated: true,
|
|
596
828
|
authentication,
|
|
@@ -622,10 +854,11 @@ function client(command) {
|
|
|
622
854
|
function collect(value, previous) {
|
|
623
855
|
return [...previous, value];
|
|
624
856
|
}
|
|
625
|
-
program.command("setup").description("Authenticate, install the Scrappycoco skill, and verify the connection").option("--no-browser", "print the authorization URL without opening it").action(async (options, command) => {
|
|
857
|
+
program.command("setup").description("Authenticate, install the Scrappycoco skill, and verify the connection").option("--no-browser", "print the authorization URL without opening it").option("--manual-callback", "paste the final callback URL in this terminal for remote/headless login").action(async (options, command) => {
|
|
626
858
|
const result = await performSetup({
|
|
627
859
|
apiUrl: globals(command).apiUrl,
|
|
628
|
-
noBrowser: options.browser === false
|
|
860
|
+
noBrowser: options.browser === false,
|
|
861
|
+
manualCallback: options.manualCallback
|
|
629
862
|
});
|
|
630
863
|
await emit(result, globals(command).json || false);
|
|
631
864
|
});
|
|
@@ -664,8 +897,12 @@ async function emitExecution(response, options, command) {
|
|
|
664
897
|
await emit(response, jsonMode);
|
|
665
898
|
}
|
|
666
899
|
var auth = program.command("auth").description("Manage Clerk OAuth credentials");
|
|
667
|
-
auth.command("login").option("--no-browser", "print the authorization URL without opening it").action(async (options, command) => {
|
|
668
|
-
const result = await login({
|
|
900
|
+
auth.command("login").option("--no-browser", "print the authorization URL without opening it").option("--manual-callback", "paste the final callback URL in this terminal for remote/headless login").action(async (options, command) => {
|
|
901
|
+
const result = await login({
|
|
902
|
+
noBrowser: options.browser === false,
|
|
903
|
+
manualCallback: options.manualCallback,
|
|
904
|
+
apiUrl: globals(command).apiUrl
|
|
905
|
+
});
|
|
669
906
|
await emit({ authenticated: true, storage: result.storage }, globals(command).json || false);
|
|
670
907
|
});
|
|
671
908
|
auth.command("status").action(async (_options, command) => {
|
|
@@ -676,7 +913,24 @@ auth.command("status").action(async (_options, command) => {
|
|
|
676
913
|
process.exitCode = EXIT.auth;
|
|
677
914
|
return;
|
|
678
915
|
}
|
|
679
|
-
|
|
916
|
+
let me;
|
|
917
|
+
try {
|
|
918
|
+
me = await client(command).get("/me");
|
|
919
|
+
} catch (error) {
|
|
920
|
+
if (error instanceof CliError && error.exitCode === EXIT.auth) {
|
|
921
|
+
await emit(
|
|
922
|
+
{
|
|
923
|
+
authenticated: false,
|
|
924
|
+
reason: error.message,
|
|
925
|
+
next_action: usingApiKey ? "Verify or replace the existing SCRAPPYCOCO_API_KEY environment variable." : "Run `scrappycoco auth login` to replace the stored OAuth login."
|
|
926
|
+
},
|
|
927
|
+
globals(command).json || false
|
|
928
|
+
);
|
|
929
|
+
process.exitCode = EXIT.auth;
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
throw error;
|
|
933
|
+
}
|
|
680
934
|
await emit(
|
|
681
935
|
{ authenticated: true, method: usingApiKey ? "api_key" : "oauth", account: me },
|
|
682
936
|
globals(command).json || false
|
|
@@ -731,14 +985,14 @@ function executionCommand(name) {
|
|
|
731
985
|
const response = await client(command).postJob(
|
|
732
986
|
name === "run" ? "/scrapers/jobs" : "/scrapers/compare/jobs",
|
|
733
987
|
payload,
|
|
734
|
-
options.idempotencyKey ||
|
|
988
|
+
options.idempotencyKey || randomUUID3()
|
|
735
989
|
);
|
|
736
990
|
await emitExecution(response, options, command);
|
|
737
991
|
});
|
|
738
992
|
}
|
|
739
993
|
executionCommand("run");
|
|
740
994
|
executionCommand("compare");
|
|
741
|
-
program.command("run [capability-id]").description("Run a capability directly; discovery is optional").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>", "
|
|
995
|
+
program.command("run [capability-id]").description("Run a capability directly; discovery is optional").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").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) => {
|
|
742
996
|
if (options.retryFailed) {
|
|
743
997
|
if (capabilityId || options.config) {
|
|
744
998
|
throw new CliError("Do not combine --retry-failed with a capability ID or --config.", EXIT.usage);
|
|
@@ -746,7 +1000,7 @@ program.command("run [capability-id]").description("Run a capability directly; d
|
|
|
746
1000
|
const response2 = await client(command).postJob(
|
|
747
1001
|
`/runs/${encodeURIComponent(options.retryFailed)}/retry-failed`,
|
|
748
1002
|
{},
|
|
749
|
-
options.idempotencyKey ||
|
|
1003
|
+
options.idempotencyKey || randomUUID3()
|
|
750
1004
|
);
|
|
751
1005
|
await emitExecution(response2, options, command);
|
|
752
1006
|
return;
|
|
@@ -764,7 +1018,7 @@ program.command("run [capability-id]").description("Run a capability directly; d
|
|
|
764
1018
|
input,
|
|
765
1019
|
limit: Number(options.limit ?? fromFile.limit ?? 25)
|
|
766
1020
|
},
|
|
767
|
-
options.idempotencyKey ||
|
|
1021
|
+
options.idempotencyKey || randomUUID3()
|
|
768
1022
|
);
|
|
769
1023
|
await emitExecution(response2, options, command);
|
|
770
1024
|
return;
|
|
@@ -777,7 +1031,7 @@ program.command("run [capability-id]").description("Run a capability directly; d
|
|
|
777
1031
|
const response = await client(command).postJob(
|
|
778
1032
|
"/scrapers/jobs",
|
|
779
1033
|
payload,
|
|
780
|
-
options.idempotencyKey ||
|
|
1034
|
+
options.idempotencyKey || randomUUID3()
|
|
781
1035
|
);
|
|
782
1036
|
await emitExecution(response, options, command);
|
|
783
1037
|
});
|
|
@@ -832,7 +1086,7 @@ discoveries.command("run <discovery-id>").option("-f, --file <path>", "request J
|
|
|
832
1086
|
const response = await client(command).postJob(
|
|
833
1087
|
`/discoveries/${encodeURIComponent(discoveryId)}/jobs`,
|
|
834
1088
|
payload,
|
|
835
|
-
options.idempotencyKey ||
|
|
1089
|
+
options.idempotencyKey || randomUUID3()
|
|
836
1090
|
);
|
|
837
1091
|
await emitExecution(response, options, command);
|
|
838
1092
|
});
|
|
@@ -880,7 +1134,7 @@ program.command("discover").description("Save, sample-test, or finalize an agent
|
|
|
880
1134
|
const response = await client(command).postJob(
|
|
881
1135
|
`/discoveries/${encodeURIComponent(options.id)}/jobs`,
|
|
882
1136
|
{ input, limit: 25 },
|
|
883
|
-
options.idempotencyKey ||
|
|
1137
|
+
options.idempotencyKey || randomUUID3()
|
|
884
1138
|
);
|
|
885
1139
|
await emit(response, globals(command).json || false);
|
|
886
1140
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scrappycoco/cli",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "CLI for Scrappycoco scraper discovery
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "CLI for Scrappycoco scraper discovery and execution",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"scrappycoco": "dist/index.js"
|