@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.
@@ -12,6 +12,7 @@
12
12
  * them (`closeSession`) — the `browser run` command does so in a `finally`.
13
13
  */
14
14
  import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.js";
15
+ import { storedClaimHint } from "./claim-link.js";
15
16
  import { readAccount } from "./agent-account.js";
16
17
  import { registerSecret } from "./logger.js";
17
18
  import { CLI_USER_AGENT } from "./config.js";
@@ -116,7 +117,7 @@ export function browserProblemToError(status, body, method, path) {
116
117
  if (status === 402) {
117
118
  // Out of credits — same exhausted state as the scraper/batch 402.
118
119
  const acct = readAccount();
119
- return quotaExhausted(`${method} ${path}`, acct?.unclaimed ? acct.claimUrl : undefined, {
120
+ return quotaExhausted(`${method} ${path}`, storedClaimHint(acct), {
120
121
  status: 402,
121
122
  detail: parsed.error || parsed.detail || parsed.title || undefined,
122
123
  });
@@ -0,0 +1,237 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { linkSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { accountPath, fetchAccountStatus, isZenrowsHttpsUrl, readAccount, requestClaimLink, writeAccount } from "./agent-account.js";
4
+ import { resolveApiKey } from "./auth.js";
5
+ import { CLAIM_LINK_REFRESH, CLAIM_NEXT_ACTION_PREFIX, ToolkitError, quotaNextAction } from "./errors.js";
6
+ const DAY_MS = 24 * 60 * 60 * 1000;
7
+ /** The backend's claim-link lifetime. A link this old no longer works. */
8
+ export const CLAIM_LINK_TTL_MS = 30 * DAY_MS;
9
+ /** Ask for a fresh link once the held one is this old, before it lapses. */
10
+ export const CLAIM_LINK_REFRESH_AGE_MS = 25 * DAY_MS;
11
+ /** On error paths, check claim status at most this often. */
12
+ export const CLAIM_CHECK_THROTTLE_MS = 6 * 60 * 60 * 1000;
13
+ /** Each claim request gives up after this long, so an error is never held up for long. */
14
+ const CLAIM_REQUEST_TIMEOUT_MS = 5000;
15
+ /** Only an https link on zenrows.com (or a subdomain), with no userinfo, is shown or opened. */
16
+ export function isTrustedClaimUrl(value) {
17
+ return isZenrowsHttpsUrl(value);
18
+ }
19
+ /** Age of the held link in ms, or Infinity when its issue time is unknown. */
20
+ function linkAgeMs(acct, now) {
21
+ const issued = new Date(acct.claimUrlIssuedAt ?? acct.createdAt).getTime();
22
+ return Number.isNaN(issued) ? Infinity : now.getTime() - issued;
23
+ }
24
+ /** The held link if it is trusted and not yet expired, else undefined. No network. */
25
+ export function usableClaimUrl(acct, now = new Date()) {
26
+ if (!isTrustedClaimUrl(acct.claimUrl))
27
+ return undefined;
28
+ return linkAgeMs(acct, now) < CLAIM_LINK_TTL_MS ? acct.claimUrl : undefined;
29
+ }
30
+ /** The claim hint for an error from local state alone. No network. */
31
+ export function storedClaimHint(acct, now = new Date()) {
32
+ if (!acct?.unclaimed)
33
+ return undefined;
34
+ return usableClaimUrl(acct, now) ?? CLAIM_LINK_REFRESH;
35
+ }
36
+ /**
37
+ * Bring the stored claim state up to date, as far as the network allows, and
38
+ * report it. Steps: ask whether the account was claimed; if not, and the held
39
+ * link is missing, untrusted or 25+ days old, mint a fresh one and store it.
40
+ * Network failures are swallowed: the held link is still offered while it is
41
+ * under 30 days old. Unless `force` is set, the network is skipped entirely
42
+ * when the last check was under 6 hours ago. Throws only for a forced call
43
+ * whose fresh-link request failed with no usable link to fall back on.
44
+ */
45
+ export async function refreshClaimState(opts = {}) {
46
+ const { projectRoot } = opts;
47
+ const now = opts.now ?? new Date();
48
+ const first = readAccount(projectRoot);
49
+ if (!first)
50
+ return { status: "none" };
51
+ if (!first.unclaimed)
52
+ return { status: "claimed" };
53
+ if (!opts.force && throttled(first, now))
54
+ return offlineState(first, now);
55
+ const key = opts.apiKey ?? resolveApiKey(projectRoot).key;
56
+ if (!key)
57
+ return offlineState(first, now);
58
+ // Several `zenrows` processes can hit a 402 at once. Only the one holding the
59
+ // lock talks to the claim endpoints; the others use what is stored.
60
+ const release = acquireClaimLock(projectRoot);
61
+ if (!release)
62
+ return offlineState(readAccount(projectRoot) ?? first, now);
63
+ try {
64
+ return await refreshLocked(key, opts, now);
65
+ }
66
+ finally {
67
+ release();
68
+ }
69
+ }
70
+ async function refreshLocked(key, opts, now) {
71
+ const { projectRoot } = opts;
72
+ // Re-read under the lock: another process may have just finished a refresh.
73
+ let acct = readAccount(projectRoot);
74
+ if (!acct)
75
+ return { status: "none" };
76
+ if (!acct.unclaimed)
77
+ return { status: "claimed" };
78
+ if (!opts.force && throttled(acct, now))
79
+ return offlineState(acct, now);
80
+ // Take the 6-hour slot before any network call, so a failing backend is not
81
+ // retried by every command that follows.
82
+ acct = { ...acct, lastClaimCheckAt: now.toISOString() };
83
+ writeAccount(acct, projectRoot);
84
+ const base = opts.fetchImpl ?? fetch;
85
+ const fetchImpl = ((url, init) => base(url, { ...init, signal: AbortSignal.timeout(CLAIM_REQUEST_TIMEOUT_MS) }));
86
+ try {
87
+ const remote = await fetchAccountStatus(key, { url: opts.accountUrl, fetchImpl, projectRoot });
88
+ if (remote.claimed === true)
89
+ return markClaimed(acct, projectRoot);
90
+ }
91
+ catch {
92
+ // Status unknown: carry on and judge the held link on its own.
93
+ }
94
+ // Re-read before minting: minting replaces the link everyone else holds.
95
+ acct = readAccount(projectRoot) ?? acct;
96
+ if (!acct.unclaimed)
97
+ return { status: "claimed" };
98
+ if (!needsNewLink(acct, now))
99
+ return offlineState(acct, now);
100
+ let fresh;
101
+ try {
102
+ fresh = await requestClaimLink(key, { url: opts.claimUrl, fetchImpl, projectRoot });
103
+ }
104
+ catch (err) {
105
+ if (opts.force && !usableClaimUrl(acct, now)) {
106
+ if (err instanceof ToolkitError)
107
+ throw err;
108
+ throw new ToolkitError({
109
+ code: "BACKEND_UNAVAILABLE",
110
+ message: "Could not reach Zenrows to get a fresh claim link.",
111
+ likely_cause: err instanceof Error ? err.message : String(err),
112
+ next_action: "The stored claim link has expired. Check connectivity and run `zenrows account claim` again.",
113
+ suggested_commands: ["zenrows account claim"],
114
+ });
115
+ }
116
+ return offlineState(acct, now);
117
+ }
118
+ // Both 409s are final: a person owns the account, so there is nothing to claim.
119
+ if (fresh.status === "claimed" || fresh.status === "not_agent")
120
+ return markClaimed(acct, projectRoot);
121
+ if (!isTrustedClaimUrl(fresh.claimUrl)) {
122
+ if (opts.force && !usableClaimUrl(acct, now)) {
123
+ throw new ToolkitError({
124
+ code: "FETCH_FAILED",
125
+ message: "The claim-link endpoint returned a URL outside zenrows.com; it was not used.",
126
+ likely_cause: "The signup/claim endpoint override points at an unexpected server.",
127
+ next_action: "Check ZENROWS_AGENT_SIGNUP_URL / `signupUrl` in the zenrows config, then retry.",
128
+ });
129
+ }
130
+ return offlineState(acct, now);
131
+ }
132
+ const latest = readAccount(projectRoot) ?? acct;
133
+ writeAccount({ ...latest, claimUrl: fresh.claimUrl, claimUrlIssuedAt: now.toISOString() }, projectRoot);
134
+ return { status: "unclaimed", claimUrl: fresh.claimUrl, refreshed: true };
135
+ }
136
+ function throttled(acct, now) {
137
+ if (!acct.lastClaimCheckAt)
138
+ return false;
139
+ const last = new Date(acct.lastClaimCheckAt).getTime();
140
+ return !Number.isNaN(last) && now.getTime() - last < CLAIM_CHECK_THROTTLE_MS;
141
+ }
142
+ function needsNewLink(acct, now) {
143
+ return !isTrustedClaimUrl(acct.claimUrl) || linkAgeMs(acct, now) >= CLAIM_LINK_REFRESH_AGE_MS;
144
+ }
145
+ function offlineState(acct, now) {
146
+ if (!acct.unclaimed)
147
+ return { status: "claimed" };
148
+ return { status: "unclaimed", claimUrl: usableClaimUrl(acct, now), refreshed: false };
149
+ }
150
+ /** A lock older than this is from a process that died mid-refresh. */
151
+ const CLAIM_LOCK_STALE_MS = 60 * 1000;
152
+ /**
153
+ * Create `account.json.lock` exclusively, holding a random token. Returns a
154
+ * release function, or null when another live process holds the lock.
155
+ *
156
+ * A lock older than CLAIM_LOCK_STALE_MS is taken over by renaming it aside
157
+ * first (only one process can rename it away). If what was renamed turns out
158
+ * to be live, it is put back and this process gives up. Release deletes the
159
+ * lock only while it still holds this process's token.
160
+ */
161
+ function acquireClaimLock(projectRoot) {
162
+ const lock = `${accountPath(projectRoot)}.lock`;
163
+ const token = randomBytes(16).toString("hex");
164
+ const release = () => {
165
+ try {
166
+ if (readFileSync(lock, "utf8") === token)
167
+ unlinkSync(lock);
168
+ }
169
+ catch {
170
+ }
171
+ };
172
+ for (let attempt = 0; attempt < 2; attempt++) {
173
+ try {
174
+ writeFileSync(lock, token, { flag: "wx", mode: 0o600 });
175
+ return readFileSync(lock, "utf8") === token ? release : null;
176
+ }
177
+ catch (err) {
178
+ if (err.code !== "EEXIST")
179
+ return null;
180
+ try {
181
+ if (Date.now() - statSync(lock).mtimeMs < CLAIM_LOCK_STALE_MS)
182
+ return null;
183
+ const aside = `${lock}.${token}.stale`;
184
+ renameSync(lock, aside);
185
+ if (Date.now() - statSync(aside).mtimeMs < CLAIM_LOCK_STALE_MS) {
186
+ // Another process replaced the stale lock between our checks: restore it.
187
+ try {
188
+ linkSync(aside, lock);
189
+ }
190
+ catch {
191
+ }
192
+ unlinkSync(aside);
193
+ return null;
194
+ }
195
+ unlinkSync(aside);
196
+ }
197
+ catch {
198
+ return null;
199
+ }
200
+ }
201
+ }
202
+ return null;
203
+ }
204
+ /** Test-only: the lock path, so tests can simulate other processes. */
205
+ export function _claimLockPath(projectRoot) {
206
+ return `${accountPath(projectRoot)}.lock`;
207
+ }
208
+ function markClaimed(acct, projectRoot) {
209
+ writeAccount({ ...acct, unclaimed: false }, projectRoot);
210
+ return { status: "claimed" };
211
+ }
212
+ /** The error hint for a refreshed state. */
213
+ export function claimHintFor(state) {
214
+ if (state.status !== "unclaimed")
215
+ return undefined;
216
+ return state.claimUrl ?? CLAIM_LINK_REFRESH;
217
+ }
218
+ /**
219
+ * Bring a quota error's claim advice up to date before it is shown. Errors are
220
+ * built synchronously from `account.json`; this runs the throttled refresh and
221
+ * rewrites `next_action` if the account was claimed or the link changed. Never
222
+ * throws: on any failure the error is returned as it was.
223
+ */
224
+ export async function refreshClaimAdvice(err, opts = {}) {
225
+ try {
226
+ if (!(err instanceof ToolkitError) || !err.next_action.startsWith(CLAIM_NEXT_ACTION_PREFIX))
227
+ return err;
228
+ const state = await refreshClaimState({ ...opts, force: false });
229
+ const next = quotaNextAction(claimHintFor(state));
230
+ if (next === err.next_action)
231
+ return err;
232
+ return new ToolkitError({ ...err.toJSON(), next_action: next });
233
+ }
234
+ catch {
235
+ return err;
236
+ }
237
+ }
@@ -14,7 +14,7 @@ export const CONFIG_VERSION = "0.1.0";
14
14
  * entrypoint) so core modules — telemetry, signup provenance — can read it
15
15
  * without an import cycle. `VERSION` in `cli/index.ts` re-exports this.
16
16
  */
17
- export const CLI_VERSION = "1.6.2"; // x-release-please-version
17
+ export const CLI_VERSION = "1.8.0"; // x-release-please-version
18
18
  /** The User-Agent on every request to a Zenrows API. The gateway reads the version from it. */
19
19
  export const CLI_USER_AGENT = `zenrows-cli/${CLI_VERSION}`;
20
20
  /** Env var to override the Fetch and Extract API base (local/staging testing). */
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { Readable } from "node:stream";
17
17
  import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.js";
18
+ import { storedClaimHint } from "./claim-link.js";
18
19
  import { readAccount } from "./agent-account.js";
19
20
  import { registerSecret } from "./logger.js";
20
21
  import { CLI_USER_AGENT, loadConfig } from "./config.js";
@@ -245,7 +246,7 @@ function classifyProblem(status, problem, body, request, serverCode, retryAfter)
245
246
  return new ToolkitError({ ...keyCapReached(request, { status, detail }).toJSON(), code: "CRAWL_KEY_CAP_REACHED" });
246
247
  }
247
248
  const acct = readAccount();
248
- const quota = quotaExhausted(request, acct?.unclaimed ? acct.claimUrl : undefined, { status, detail });
249
+ const quota = quotaExhausted(request, storedClaimHint(acct), { status, detail });
249
250
  return new ToolkitError({ ...quota.toJSON(), code: "CRAWL_INSUFFICIENT_CREDITS" });
250
251
  }
251
252
  if (status === 409) {
@@ -15,6 +15,7 @@ export async function ensureApiKey(policy, opts = {}) {
15
15
  claimUrl: res.claimUrl,
16
16
  createdAt: new Date().toISOString(),
17
17
  };
18
+ account.claimUrlIssuedAt = account.createdAt;
18
19
  writeAccount(account, opts.projectRoot);
19
20
  opts.onProvision?.(account);
20
21
  return res.apiKey;
@@ -13,6 +13,8 @@ export class ToolkitError extends Error {
13
13
  suggested_commands;
14
14
  server_code;
15
15
  crawl_id;
16
+ job_id;
17
+ idempotency_key;
16
18
  retry_after;
17
19
  constructor(shape) {
18
20
  super(shape.message);
@@ -23,6 +25,8 @@ export class ToolkitError extends Error {
23
25
  this.suggested_commands = shape.suggested_commands ?? [];
24
26
  this.server_code = shape.server_code;
25
27
  this.crawl_id = shape.crawl_id;
28
+ this.job_id = shape.job_id;
29
+ this.idempotency_key = shape.idempotency_key;
26
30
  this.retry_after = shape.retry_after;
27
31
  }
28
32
  toJSON() {
@@ -34,6 +38,8 @@ export class ToolkitError extends Error {
34
38
  suggested_commands: this.suggested_commands,
35
39
  ...(this.server_code ? { server_code: this.server_code } : {}),
36
40
  ...(this.crawl_id ? { crawl_id: this.crawl_id } : {}),
41
+ ...(this.job_id ? { job_id: this.job_id } : {}),
42
+ ...(this.idempotency_key ? { idempotency_key: this.idempotency_key } : {}),
37
43
  ...(this.retry_after !== undefined ? { retry_after: this.retry_after } : {}),
38
44
  };
39
45
  }
@@ -49,26 +55,36 @@ export class ToolkitError extends Error {
49
55
  * credits stopped: its status call succeeded, so there is no HTTP error to cite,
50
56
  * only `url` (the crawl).
51
57
  */
52
- export function quotaExhausted(url, claimUrl, opts = {}) {
53
- // Say that the allowance comes back. Without it this reads as a permanent paywall,
54
- // which is how the API's own AUTH004 text reads ("Purchase a new subscription to
55
- // continue") and why exhausted clients retry-loop instead of waiting or upgrading —
56
- // one account spent seven days at ~3 req/s against this wall. `zenrows usage` prints
57
- // the exact `period_ends_at`, so the date is one command away rather than guessed here.
58
- const renewLine = "Credits renew at the end of the billing period — run `zenrows usage` for the date.";
59
- const claimLine = claimUrl
60
- ? `You are on the Zenrows Free plan. Claim your account to keep your usage and add credits: ${claimUrl}. ${renewLine}`
61
- : `You are out of Zenrows credits. ${renewLine} To carry on now, add a credit pack (${BILLING_TOPUP_URL} opens the purchase directly) or upgrade your plan: ${PLANS_URL}`;
58
+ export function quotaExhausted(url, claim, opts = {}) {
62
59
  const detail = opts.detail ? `${opts.detail.replace(/\.\s*$/, "")}. ` : "";
63
60
  const where = opts.status === null ? `Stopped ${url}` : `HTTP ${opts.status ?? 429} for ${url}`;
64
61
  return new ToolkitError({
65
62
  code: "POLICY_MAX_CREDITS_EXCEEDED",
66
63
  message: "Zenrows request quota exhausted.",
67
64
  likely_cause: `${detail}${where}`,
68
- next_action: claimLine,
65
+ next_action: quotaNextAction(claim),
69
66
  suggested_commands: ["zenrows usage"],
70
67
  });
71
68
  }
69
+ export const CLAIM_LINK_REFRESH = Symbol("claim-link-refresh");
70
+ /** Start of `next_action` on a quota error for an unclaimed account. */
71
+ export const CLAIM_NEXT_ACTION_PREFIX = "You are on the Zenrows Free plan.";
72
+ /** The `next_action` of a quota error for the given claim state. */
73
+ export function quotaNextAction(claim) {
74
+ // Say that the allowance comes back. Without it this reads as a permanent paywall,
75
+ // which is how the API's own AUTH004 text reads ("Purchase a new subscription to
76
+ // continue") and why exhausted clients retry-loop instead of waiting or upgrading.
77
+ // `zenrows usage` prints the exact `period_ends_at`, so the date is one command away
78
+ // rather than guessed here.
79
+ const renewLine = "Credits renew at the end of the billing period — run `zenrows usage` for the date.";
80
+ if (claim === CLAIM_LINK_REFRESH) {
81
+ return `${CLAIM_NEXT_ACTION_PREFIX} Claim your account to keep your usage and add credits: run \`zenrows account claim\` for a fresh claim link. ${renewLine}`;
82
+ }
83
+ if (claim) {
84
+ return `${CLAIM_NEXT_ACTION_PREFIX} Claim your account to keep your usage and add credits: ${claim}. ${renewLine}`;
85
+ }
86
+ return `You are out of Zenrows credits. ${renewLine} To carry on now, add a credit pack (${BILLING_TOPUP_URL} opens the purchase directly) or upgrade your plan: ${PLANS_URL}`;
87
+ }
72
88
  /** Where an account manages its API keys and their credit caps. */
73
89
  export const API_KEYS_SETTINGS_URL = "https://app.zenrows.com/settings/api-keys";
74
90
  /**
@@ -6,6 +6,7 @@
6
6
  * from any logged URL.
7
7
  */
8
8
  import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.js";
9
+ import { storedClaimHint, usableClaimUrl } from "./claim-link.js";
9
10
  import { readAccount } from "./agent-account.js";
10
11
  import { ENV_KEY, resolveApiKey } from "./auth.js";
11
12
  import { registerSecret } from "./logger.js";
@@ -143,7 +144,7 @@ export async function scrape(apiBase, apiKey, params, opts = {}) {
143
144
  message: "The auto-provisioned Free plan key was rejected by the Zenrows API.",
144
145
  likely_cause: `${cause}. The signup endpoint issued this key, but the scraping API did not accept it.`,
145
146
  next_action: "If you are testing against a local/staging backend, ensure `apiBase` (zenrows config) points at the same environment that issued the key. Otherwise re-provision with `zenrows signup --agent`, or claim the account: " +
146
- acct.claimUrl,
147
+ (usableClaimUrl(acct) ?? "run `zenrows account claim` for a link"),
147
148
  suggested_commands: ["zenrows status", "zenrows signup --agent"],
148
149
  });
149
150
  }
@@ -184,7 +185,7 @@ export async function scrape(apiBase, apiKey, params, opts = {}) {
184
185
  // out of credits. This is NOT scraped content — surface it as a credits
185
186
  // error (claim link for an unclaimed Free plan, dashboard/upgrade otherwise).
186
187
  const acct = readAccount();
187
- throw quotaExhausted(redacted, acct?.unclaimed ? acct.claimUrl : undefined, {
188
+ throw quotaExhausted(redacted, storedClaimHint(acct), {
188
189
  status: 402,
189
190
  detail: zrErrorDetail(body) ?? undefined,
190
191
  });
@@ -196,7 +197,7 @@ export async function scrape(apiBase, apiKey, params, opts = {}) {
196
197
  // limit; otherwise advise a brief retry.
197
198
  if (isQuotaError(body)) {
198
199
  const acct = readAccount();
199
- throw quotaExhausted(redacted, acct?.unclaimed ? acct.claimUrl : undefined);
200
+ throw quotaExhausted(redacted, storedClaimHint(acct));
200
201
  }
201
202
  const detail = zrErrorDetail(body) ?? snippet(body);
202
203
  throw new ToolkitError({
@@ -1,5 +1,6 @@
1
1
  import { readAccount, writeAccount } from "./agent-account.js";
2
2
  import { resolveApiKey } from "./auth.js";
3
+ import { usableClaimUrl } from "./claim-link.js";
3
4
  import { log } from "./logger.js";
4
5
  import { fetchUsage } from "./usage.js";
5
6
  /** How high usage must be (percent) before we nudge. */
@@ -15,7 +16,8 @@ export function nudgeMessage(account, usagePercent, _now) {
15
16
  if (!account.unclaimed)
16
17
  return null;
17
18
  if (typeof usagePercent === "number" && usagePercent >= USAGE_NUDGE_PERCENT) {
18
- return `You've used ${usagePercent}% of your Zenrows Free plan — claim your account to keep your usage & history: ${account.claimUrl}`;
19
+ const link = usableClaimUrl(account, _now) ?? "run `zenrows account claim` for a claim link";
20
+ return `You've used ${usagePercent}% of your Zenrows Free plan — claim your account to keep your usage & history: ${link}`;
19
21
  }
20
22
  return null;
21
23
  }
@@ -1,9 +1,16 @@
1
1
  import { ToolkitError } from "./errors.js";
2
2
  import { findWorkspace, readJson, workspacePaths, writeJson } from "./workspace.js";
3
+ /**
4
+ * Safe defaults. The per-run caps let a default run submit up to 10,000 URLs
5
+ * in any mode: an Adaptive Stealth Mode task is estimated at its 25-credit
6
+ * upper bound, so 10,000 of them estimate at 250,000 credits. Larger runs
7
+ * (the Batch API takes up to 100,000 tasks per job) need the caps raised
8
+ * explicitly with `zenrows policy set`.
9
+ */
3
10
  export function defaultPolicy() {
4
11
  return {
5
- max_credits_per_run: 10000,
6
- max_pages_per_run: 1000,
12
+ max_credits_per_run: 250_000,
13
+ max_pages_per_run: 10_000,
7
14
  max_concurrency: 20,
8
15
  allow_browser: true,
9
16
  allow_experimental: false,
@@ -94,8 +101,8 @@ export function assertWithinLimits(requested, policy, context = "run") {
94
101
  code: "POLICY_LIMIT_EXCEEDED",
95
102
  message: `This ${context} submits ${requested.pages} page(s), over the policy cap of ${policy.max_pages_per_run}.`,
96
103
  likely_cause: "policy.max_pages_per_run bounds how many URLs a single run may submit.",
97
- next_action: `Split the job into runs of ≤${policy.max_pages_per_run} page(s), or raise max_pages_per_run in policy.json.`,
98
- suggested_commands: ["zenrows policy show"],
104
+ next_action: `Split the job into runs of at most ${policy.max_pages_per_run} page(s), or raise the cap: \`zenrows policy set max_pages_per_run ${requested.pages}\` (default ${defaultPolicy().max_pages_per_run}).`,
105
+ suggested_commands: [`zenrows policy set max_pages_per_run ${requested.pages}`, "zenrows policy show"],
99
106
  });
100
107
  }
101
108
  if (requested.credits !== undefined && requested.credits > policy.max_credits_per_run) {
@@ -103,8 +110,8 @@ export function assertWithinLimits(requested, policy, context = "run") {
103
110
  code: "POLICY_LIMIT_EXCEEDED",
104
111
  message: `This ${context} is estimated at ${requested.credits} credit(s), over the policy cap of ${policy.max_credits_per_run}.`,
105
112
  likely_cause: "policy.max_credits_per_run bounds the estimated credit cost of a single run.",
106
- next_action: "Reduce the job size or per-task cost (e.g. drop premium_proxy/js_render), or raise max_credits_per_run in policy.json.",
107
- suggested_commands: ["zenrows policy show"],
113
+ next_action: `Reduce the job size or per-task cost (e.g. drop premium_proxy/js_render), or raise the cap: \`zenrows policy set max_credits_per_run ${requested.credits}\` (default ${defaultPolicy().max_credits_per_run}). The estimate is an upper bound: an Adaptive Stealth Mode task counts 25 credits, and is billed 1 to 25.`,
114
+ suggested_commands: [`zenrows policy set max_credits_per_run ${requested.credits}`, "zenrows policy show"],
108
115
  });
109
116
  }
110
117
  }
@@ -10,17 +10,25 @@
10
10
  function hasPrefix(env, prefix) {
11
11
  return Object.keys(env).some((k) => k.startsWith(prefix));
12
12
  }
13
+ /**
14
+ * Variables the agent itself sets in the shells it runs commands in, so they
15
+ * mean the agent is driving this command (the same signals as agent-client.ts).
16
+ * Variables that only show the agent is installed or configured (CODEX_HOME,
17
+ * ANTHROPIC_API_KEY, OPENAI_API_KEY, other CLAUDE_* settings) are not used.
18
+ */
19
+ const CLAUDE_VARS = ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_CHILD_SESSION"];
20
+ const CODEX_VARS = ["CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", "CODEX_THREAD_ID"];
13
21
  /** Detect the host agent brand + runtime, best-effort, from env vars. */
14
22
  export function detectClient(env = process.env) {
15
23
  const term = env.TERM_PROGRAM;
16
24
  let client = "unknown";
17
- if (env.CLAUDECODE || hasPrefix(env, "CLAUDE_CODE") || hasPrefix(env, "CLAUDE_")) {
25
+ if (CLAUDE_VARS.some((v) => env[v])) {
18
26
  client = "claude-code";
19
27
  }
20
28
  else if (env.CURSOR_TRACE_ID || hasPrefix(env, "CURSOR_") || term === "cursor") {
21
29
  client = "cursor";
22
30
  }
23
- else if (hasPrefix(env, "OPENAI_") || hasPrefix(env, "CODEX_")) {
31
+ else if (CODEX_VARS.some((v) => env[v])) {
24
32
  client = "openai-codex";
25
33
  }
26
34
  else if (hasPrefix(env, "WINDSURF")) {
@@ -7,6 +7,7 @@
7
7
  * safe to poll. The API key is registered as a secret so it is redacted in logs.
8
8
  */
9
9
  import { ToolkitError, quotaExhausted } from "./errors.js";
10
+ import { storedClaimHint } from "./claim-link.js";
10
11
  import { isQuotaError, zrErrorDetail } from "./http.js";
11
12
  import { readAccount } from "./agent-account.js";
12
13
  import { ENV_KEY, resolveApiKey } from "./auth.js";
@@ -63,7 +64,7 @@ export async function fetchUsage(apiBase, apiKey, opts = {}) {
63
64
  // scraper 402. Surface it as a credits error (claim link for an unclaimed
64
65
  // Free plan account, dashboard/upgrade otherwise) instead of a generic "retry".
65
66
  const acct = readAccount();
66
- throw quotaExhausted(url, acct?.unclaimed ? acct.claimUrl : undefined, {
67
+ throw quotaExhausted(url, storedClaimHint(acct), {
67
68
  status: res.status,
68
69
  detail: zrErrorDetail(body) ?? undefined,
69
70
  });
@@ -5,7 +5,7 @@
5
5
  * assets, traces, and logs. Secrets live in `.zenrows/secrets.json`, which is
6
6
  * gitignored and never written into run artifacts.
7
7
  */
8
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
9
9
  import { dirname, join, resolve } from "node:path";
10
10
  export const WORKSPACE_DIRNAME = ".zenrows";
11
11
  /** Subdirectories created inside `.zenrows/`. */
@@ -96,3 +96,31 @@ export function writeJson(file, value) {
96
96
  mkdirSync(dirname(file), { recursive: true });
97
97
  writeFileSync(file, JSON.stringify(value, null, 2) + "\n");
98
98
  }
99
+ /**
100
+ * Write a file only its owner may read (secrets, account metadata). The data
101
+ * goes to a temp file created 0600 and is renamed into place, so the target is
102
+ * never readable by others, not even for a moment, and a reader never sees a
103
+ * half-written file. A directory this creates is 0700.
104
+ */
105
+ export function writePrivateJson(file, value) {
106
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
107
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
108
+ try {
109
+ writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600, flag: "wx" });
110
+ try {
111
+ chmodSync(tmp, 0o600); // the umask can only remove bits, but be explicit
112
+ }
113
+ catch {
114
+ // best-effort on platforms without POSIX permissions
115
+ }
116
+ renameSync(tmp, file);
117
+ }
118
+ catch (err) {
119
+ try {
120
+ unlinkSync(tmp);
121
+ }
122
+ catch {
123
+ }
124
+ throw err;
125
+ }
126
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenrows/cli",
3
- "version": "1.6.2",
3
+ "version": "1.8.0",
4
4
  "description": "Open-source CLI, MCP, skills, templates, workflows, recipes, and evals layer for giving AI agents reliable access to protected web data through Zenrows cloud infrastructure.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,11 +23,14 @@ zenrows batch estimate jobs.jsonl
23
23
  ```
24
24
 
25
25
  Swap `jobs.jsonl` for your own spec. Per-line keys (`js_render`,
26
- `premium_proxy`, `proxy_country`, `mode`, `autoparse`, `external_id`) tune each
27
- URL; cost multipliers are basic 1x / JS 5x / proxy 10x / both 25x.
26
+ `premium_proxy`, `proxy_country`, `mode`, `autoparse`, `external_id`,
27
+ `metadata`, `method`, `body`) tune each URL; cost multipliers are basic 1x / JS
28
+ 5x / proxy 10x / both 25x, and Adaptive Stealth Mode costs 1x to 25x. Pass
29
+ `create`'s job-level flags (`--premium-proxy`, `--js-render`, `--manual`) to
30
+ `estimate` to price the same request.
28
31
 
29
32
  Once the spec is clean, submit it:
30
33
  ```
31
- zenrows batch create jobs.jsonl --wait
34
+ zenrows batch create jobs.jsonl --follow
32
35
  ```
33
36
  See [[batch-jobs]] for the full cloud flow.