@zenrows/cli 1.6.2 → 1.8.0

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.
@@ -23,6 +23,7 @@ import { newRunId, writeRun } from "../../core/artifacts.js";
23
23
  import { ToolkitError } from "../../core/errors.js";
24
24
  import { parse, asString, asNumber } from "../command.js";
25
25
  import { printError, writeOut } from "../output.js";
26
+ import { refreshClaimAdvice } from "../../core/claim-link.js";
26
27
  import { createSession, sessionCall, closeSession, decodeBinary, connectUrl, normalizeSelectValue, } from "../../core/browser-api.js";
27
28
  const VERBS = {
28
29
  navigate: { verb: "navigate", opts: { url: { type: "string" } }, required: ["url"], body: (v) => ({ url: asString(v.url) }) },
@@ -155,7 +156,9 @@ export const browser = {
155
156
  });
156
157
  }
157
158
  }
158
- catch (err) {
159
+ catch (caught) {
160
+ // A quota error's claim advice is built from local state; check it is still current.
161
+ const err = await refreshClaimAdvice(caught);
159
162
  printError(err, ctx.json);
160
163
  return err instanceof ToolkitError && err.code === "POLICY_BROWSER_DISABLED" ? 2 : 1;
161
164
  }
@@ -18,6 +18,7 @@ import { asNumber, asString, parse } from "../command.js";
18
18
  import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "../../core/errors.js";
19
19
  import { readAccount } from "../../core/agent-account.js";
20
20
  import { printError, writeOut, writeOutStream } from "../output.js";
21
+ import { refreshClaimAdvice, storedClaimHint } from "../../core/claim-link.js";
21
22
  /** The API's default for `max_pages` (and `max_items`) when the flag is unset. */
22
23
  const DEFAULT_MAX_PAGES = 100;
23
24
  /** Largest `depth` / `max_items` / `max_pages` the API takes. */
@@ -33,7 +34,7 @@ export const crawl = {
33
34
  " leave it out for no depth limit (the caps below stop the crawl)",
34
35
  " --max-items <n> stop after keeping n URLs (API default 100)",
35
36
  " --max-pages <n> stop after fetching n pages (API default 100; bounds cost);",
36
- " the local policy max_pages_per_run caps it (default 1000)",
37
+ " the local policy max_pages_per_run caps it (default 10000)",
37
38
  " --include-patterns <p> keep only URLs containing this substring (repeatable)",
38
39
  " --exclude-patterns <p> drop URLs containing this substring (repeatable)",
39
40
  " --output-format <html|markdown> also fetch each kept URL's page, as HTML or Markdown",
@@ -95,7 +96,9 @@ export const crawl = {
95
96
  });
96
97
  }
97
98
  }
98
- catch (err) {
99
+ catch (caught) {
100
+ // A quota error's claim advice is built from local state; check it is still current.
101
+ const err = await refreshClaimAdvice(caught);
99
102
  printError(err, ctx.json);
100
103
  return 1;
101
104
  }
@@ -462,7 +465,7 @@ export function crawlFailure(c) {
462
465
  }
463
466
  if (code === "insufficient_credits") {
464
467
  const acct = readAccount();
465
- const quota = quotaExhausted(where, acct?.unclaimed ? acct.claimUrl : undefined, { status: null, detail }).toJSON();
468
+ const quota = quotaExhausted(where, storedClaimHint(acct), { status: null, detail }).toJSON();
466
469
  return new ToolkitError({ ...quota, ...ids, code: "CRAWL_INSUFFICIENT_CREDITS", suggested_commands: [...(quota.suggested_commands ?? []), getCmd] });
467
470
  }
468
471
  const why = [code, c.error?.detail].filter(Boolean).join(": ");
@@ -17,6 +17,7 @@ import { runExtract } from "../../adapters/extract.js";
17
17
  import { formatRequestCost } from "../../core/http.js";
18
18
  import { parse, asString, asNumber } from "../command.js";
19
19
  import { printError, writeOut } from "../output.js";
20
+ import { refreshClaimAdvice } from "../../core/claim-link.js";
20
21
  export const extract = {
21
22
  name: "extract",
22
23
  summary: "Turn a protected page into structured data (Extract / Autoparse / CSS / Markdown).",
@@ -175,7 +176,9 @@ export const extract = {
175
176
  await maybeNudgeClaim(config, {});
176
177
  return 0;
177
178
  }
178
- catch (err) {
179
+ catch (caught) {
180
+ // A quota error's claim advice is built from local state; check it is still current.
181
+ const err = await refreshClaimAdvice(caught);
179
182
  writeRun({
180
183
  runId,
181
184
  command: "zenrows extract",
@@ -16,6 +16,7 @@ import { runFetch } from "../../adapters/protected-fetch.js";
16
16
  import { formatRequestCost } from "../../core/http.js";
17
17
  import { parse, asString, asNumber } from "../command.js";
18
18
  import { printError, writeOut } from "../output.js";
19
+ import { refreshClaimAdvice } from "../../core/claim-link.js";
19
20
  export const fetch_ = {
20
21
  name: "fetch",
21
22
  summary: "Retrieve a protected page (Fetch).",
@@ -152,7 +153,9 @@ export const fetch_ = {
152
153
  await maybeNudgeClaim(config, {});
153
154
  return 0;
154
155
  }
155
- catch (err) {
156
+ catch (caught) {
157
+ // A quota error's claim advice is built from local state; check it is still current.
158
+ const err = await refreshClaimAdvice(caught);
156
159
  writeRun({
157
160
  runId,
158
161
  command: "zenrows fetch",
@@ -21,12 +21,13 @@ export const signup = {
21
21
  if (values.agent) {
22
22
  const account = readAccount();
23
23
  if (account) {
24
- log.info("An agent account already exists. Claim it: " + account.claimUrl);
24
+ log.info("An agent account already exists. Claim it with: zenrows account claim");
25
25
  return 0;
26
26
  }
27
27
  const res = await signupAgent();
28
28
  saveApiKey(res.apiKey);
29
- writeAccount({ accountId: res.accountId, unclaimed: true, claimUrl: res.claimUrl, createdAt: new Date().toISOString() });
29
+ const now = new Date().toISOString();
30
+ writeAccount({ accountId: res.accountId, unclaimed: true, claimUrl: res.claimUrl, createdAt: now, claimUrlIssuedAt: now });
30
31
  log.success("Created a Zenrows Free plan account.");
31
32
  log.dim(`Claim it anytime: ${res.claimUrl}`);
32
33
  if (ctx.json)
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { CLI_VERSION } from "../core/config.js";
10
10
  import { ToolkitError } from "../core/errors.js";
11
+ import { refreshClaimAdvice } from "../core/claim-link.js";
11
12
  import { log, ANSI, c } from "../core/logger.js";
12
13
  import { makeAssetCommand } from "./asset-command.js";
13
14
  import { printError } from "./output.js";
@@ -82,7 +83,7 @@ export async function main(rawArgv) {
82
83
  return await cmd.run(argv, ctx);
83
84
  }
84
85
  catch (err) {
85
- printError(err, json);
86
+ printError(await refreshClaimAdvice(err), json);
86
87
  // Any error exits 1 — the exit code doesn't encode error kind. Machine
87
88
  // consumers read the precise `error.code` from the --json output instead.
88
89
  return 1;
@@ -5,13 +5,13 @@
5
5
  * It never holds the API key (that lives in secrets.json, 0600) — only the
6
6
  * accountId, the human-facing claim URL, and claim metadata.
7
7
  */
8
- import { chmodSync, existsSync, unlinkSync } from "node:fs";
8
+ import { existsSync, unlinkSync } from "node:fs";
9
9
  import { attributionEnabled, getOrCreateTelemetryId, loadConfig, CLI_USER_AGENT, CLI_VERSION } from "./config.js";
10
10
  import { agentClientHeader } from "./agent-client.js";
11
11
  import { ToolkitError } from "./errors.js";
12
12
  import { AGENT_SIGNUP_API_URL, WELL_KNOWN_PROTECTED_RESOURCE } from "./open-url.js";
13
13
  import { detectClient } from "./provenance.js";
14
- import { findWorkspace, readJson, workspacePaths, writeJson } from "./workspace.js";
14
+ import { findWorkspace, readJson, workspacePaths, writePrivateJson } from "./workspace.js";
15
15
  /** Env var to override the signup endpoint (highest priority; for local testing). */
16
16
  export const SIGNUP_URL_ENV = "ZENROWS_AGENT_SIGNUP_URL";
17
17
  /** Env var to override the discovery base origin (for local testing). */
@@ -49,7 +49,9 @@ export async function discoverSignupUrl(projectRoot, opts = {}) {
49
49
  return null;
50
50
  const json = (await res.json());
51
51
  const endpoint = json?.agent_auth?.signup_endpoint;
52
- if (typeof endpoint === "string" && endpoint.trim())
52
+ // The key is later sent to URLs derived from this one, so only a Zenrows
53
+ // https endpoint is accepted from a document fetched over the network.
54
+ if (typeof endpoint === "string" && isZenrowsHttpsUrl(endpoint.trim()))
53
55
  return endpoint.trim();
54
56
  return null;
55
57
  }
@@ -89,7 +91,7 @@ export async function signupCandidates(projectRoot, opts = {}) {
89
91
  export async function resolveSignupUrl(projectRoot, opts = {}) {
90
92
  return (await signupCandidates(projectRoot, opts))[0];
91
93
  }
92
- function accountPath(projectRoot) {
94
+ export function accountPath(projectRoot) {
93
95
  const ws = projectRoot ? workspacePaths(projectRoot) : (findWorkspace() ?? workspacePaths());
94
96
  return ws.account;
95
97
  }
@@ -98,14 +100,8 @@ export function readAccount(projectRoot) {
98
100
  }
99
101
  export function writeAccount(acct, projectRoot) {
100
102
  const file = accountPath(projectRoot);
101
- writeJson(file, acct);
102
- try {
103
- // account.json holds the single-use claim token — restrict to owner-only.
104
- chmodSync(file, 0o600);
105
- }
106
- catch {
107
- // best-effort on platforms without POSIX permissions
108
- }
103
+ // account.json holds the single-use claim token: owner-only from creation.
104
+ writePrivateJson(file, acct);
109
105
  }
110
106
  /** Remove local agent account metadata (used by `zenrows logout`). */
111
107
  export function clearAccount(projectRoot) {
@@ -201,7 +197,108 @@ function signupError(status, body) {
201
197
  * `signupUrl` override applies here too (local/staging testing).
202
198
  */
203
199
  export async function resolveAccountUrl(projectRoot, opts = {}) {
204
- return (await resolveSignupUrl(projectRoot, opts)).replace(/\/signup\/?$/, "/account");
200
+ return siblingOfSignup(await resolveSignupUrl(projectRoot, opts), "account");
201
+ }
202
+ /**
203
+ * `…/agent/signup` → `…/agent/<name>`. A signup URL that does not end in
204
+ * `/signup` gives no safe sibling, so the built-in endpoint's sibling is used
205
+ * rather than sending the key somewhere unexpected.
206
+ */
207
+ export function siblingOfSignup(signupUrl, name) {
208
+ const re = /\/signup\/?$/;
209
+ // These two endpoints receive the API key. A signup URL from the environment
210
+ // or a project's config (which a cloned repository could ship) is only
211
+ // trusted when it is Zenrows or this machine; anything else gets the
212
+ // built-in endpoint.
213
+ const base = re.test(signupUrl) && isAgentKeyDestination(signupUrl) ? signupUrl : AGENT_SIGNUP_API_URL;
214
+ return base.replace(re, `/${name}`);
215
+ }
216
+ /**
217
+ * Where the CLI may send the API key for the agent account endpoints: a
218
+ * Zenrows https URL, or http(s) on loopback (local testing). No userinfo.
219
+ */
220
+ export function isAgentKeyDestination(value) {
221
+ if (isZenrowsHttpsUrl(value))
222
+ return true;
223
+ try {
224
+ const u = new URL(value);
225
+ const host = u.hostname.toLowerCase();
226
+ return ((u.protocol === "https:" || u.protocol === "http:") &&
227
+ u.username === "" &&
228
+ u.password === "" &&
229
+ (host === "127.0.0.1" || host === "localhost" || host === "[::1]"));
230
+ }
231
+ catch {
232
+ return false;
233
+ }
234
+ }
235
+ /** True for an https URL on zenrows.com or a subdomain, with no userinfo. */
236
+ export function isZenrowsHttpsUrl(value) {
237
+ if (typeof value !== "string")
238
+ return false;
239
+ try {
240
+ const u = new URL(value);
241
+ const host = u.hostname.toLowerCase();
242
+ return (u.protocol === "https:" &&
243
+ u.username === "" &&
244
+ u.password === "" &&
245
+ (host === "zenrows.com" || host.endsWith(".zenrows.com")));
246
+ }
247
+ catch {
248
+ return false;
249
+ }
250
+ }
251
+ /** The fresh-claim-link endpoint, derived from the signup URL like the status one. */
252
+ export async function resolveClaimUrl(projectRoot, opts = {}) {
253
+ return siblingOfSignup(await resolveSignupUrl(projectRoot, opts), "claim");
254
+ }
255
+ /**
256
+ * Mint a fresh claim link for the account behind `apiKey`. Each call
257
+ * invalidates the previous link and the endpoint is rate-limited per key, so
258
+ * callers only ask when the link they hold is missing or near expiry.
259
+ * A 409 `already_claimed` means a person owns the account. Throws ToolkitError
260
+ * on any other failure.
261
+ */
262
+ export async function requestClaimLink(apiKey, opts = {}) {
263
+ const url = opts.url ?? (await resolveClaimUrl(opts.projectRoot, { fetchImpl: opts.fetchImpl }));
264
+ const doFetch = opts.fetchImpl ?? fetch;
265
+ const res = await doFetch(url, {
266
+ method: "POST",
267
+ headers: { "X-API-Key": apiKey, Accept: "application/json", "User-Agent": CLI_USER_AGENT, ...agentClientHeader() },
268
+ });
269
+ const body = await res.text();
270
+ if (res.status === 200) {
271
+ let json = null;
272
+ try {
273
+ json = JSON.parse(body);
274
+ }
275
+ catch {
276
+ // not JSON: reported below
277
+ }
278
+ if (!json || typeof json.claimUrl !== "string") {
279
+ throw new ToolkitError({
280
+ code: "FETCH_FAILED",
281
+ message: "The claim-link endpoint answered HTTP 200 without a claim link (unexpected response body).",
282
+ likely_cause: body.slice(0, 240),
283
+ next_action: "Retry later.",
284
+ });
285
+ }
286
+ return {
287
+ status: "unclaimed",
288
+ claimUrl: json.claimUrl,
289
+ ...(typeof json.expiresAt === "string" ? { expiresAt: json.expiresAt } : {}),
290
+ };
291
+ }
292
+ if (res.status === 409 && /already_claimed/.test(body))
293
+ return { status: "claimed" };
294
+ if (res.status === 409 && /not_agent_account/.test(body))
295
+ return { status: "not_agent" };
296
+ throw new ToolkitError({
297
+ code: res.status === 401 || res.status === 403 ? "AUTH_INVALID" : "FETCH_FAILED",
298
+ message: `Claim link request failed (HTTP ${res.status}).`,
299
+ likely_cause: body.slice(0, 240),
300
+ next_action: res.status === 429 ? "Too many claim links requested for this key; retry in an hour." : "Retry later.",
301
+ });
205
302
  }
206
303
  /**
207
304
  * Ask the backend whether the account behind `apiKey` has been claimed
@@ -209,7 +306,7 @@ export async function resolveAccountUrl(projectRoot, opts = {}) {
209
306
  * `X-API-Key` header. Throws ToolkitError on failure.
210
307
  */
211
308
  export async function fetchAccountStatus(apiKey, opts = {}) {
212
- const url = opts.url ?? (await resolveAccountUrl());
309
+ const url = opts.url ?? (await resolveAccountUrl(opts.projectRoot, { fetchImpl: opts.fetchImpl }));
213
310
  const doFetch = opts.fetchImpl ?? fetch;
214
311
  const res = await doFetch(url, {
215
312
  method: "GET",
@@ -4,11 +4,11 @@
4
4
  * Resolution order: explicit secrets file → ZENROWS_API_KEY env var.
5
5
  * The key is never printed in full and never written into run artifacts.
6
6
  */
7
- import { chmodSync, existsSync } from "node:fs";
7
+ import { existsSync } from "node:fs";
8
8
  import { ToolkitError } from "./errors.js";
9
9
  import { registerSecret } from "./logger.js";
10
10
  import { mask } from "./redact.js";
11
- import { findWorkspace, readJson, workspacePaths, writeJson } from "./workspace.js";
11
+ import { findWorkspace, readJson, workspacePaths, writePrivateJson } from "./workspace.js";
12
12
  export const ENV_KEY = "ZENROWS_API_KEY";
13
13
  function secretsPath(projectRoot) {
14
14
  const ws = projectRoot ? workspacePaths(projectRoot) : (findWorkspace() ?? workspacePaths());
@@ -49,20 +49,14 @@ export function saveApiKey(apiKey, projectRoot) {
49
49
  });
50
50
  }
51
51
  const file = secretsPath(projectRoot);
52
- writeJson(file, { apiKey: trimmed });
53
- try {
54
- chmodSync(file, 0o600);
55
- }
56
- catch {
57
- // best-effort on platforms without POSIX permissions
58
- }
52
+ writePrivateJson(file, { apiKey: trimmed });
59
53
  registerSecret(trimmed);
60
54
  }
61
55
  export function clearApiKey(projectRoot) {
62
56
  const file = secretsPath(projectRoot);
63
57
  if (!existsSync(file))
64
58
  return false;
65
- writeJson(file, {});
59
+ writePrivateJson(file, {});
66
60
  return true;
67
61
  }
68
62
  /** Throw AUTH_MISSING if no key is configured. */