@zenrows/cli 1.9.0 → 1.10.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.
package/README.md CHANGED
@@ -83,6 +83,18 @@ unclaimed Zenrows Free plan account**, stores the key in `.zenrows/secrets.json`
83
83
  (0600, gitignored), and records the account in `.zenrows/account.json`
84
84
  (no secret — just the accountId, Free-period info, and a claim link).
85
85
 
86
+ Zenrows may first ask the agent one short question before it creates the
87
+ account. The command then fails with `SIGNUP_CHALLENGE` and prints the
88
+ question; answer it and re-run the original command:
89
+
90
+ ```bash
91
+ zenrows signup --answer "<your answer>"
92
+ ```
93
+
94
+ A wrong or late answer gets a new question. Too many questions or wrong
95
+ answers from one network pause signups there for an hour
96
+ (`SIGNUP_CHALLENGE_LOCKED`).
97
+
86
98
  When you (or your agent) want to keep the usage and history, **claim** the
87
99
  account — this confirms an email (Google, GitHub or a 6-digit code); the same
88
100
  key keeps working:
@@ -96,10 +108,11 @@ zenrows usage # plan usage, credits & concurrency (does not count again
96
108
  Opt out of auto-provisioning at any time:
97
109
 
98
110
  ```bash
99
- zenrows fetch <url> --no-signup # do not auto-create an account for this call
100
- zenrows signup --agent # explicitly provision a Free plan account, headless
101
- zenrows login --api-key <key> # use an existing key (stored 0600, gitignored)
102
- zenrows login --env # use the ZENROWS_API_KEY environment variable
111
+ zenrows fetch <url> --no-signup # do not auto-create an account for this call
112
+ zenrows signup --agent # explicitly provision a Free plan account, headless
113
+ zenrows signup --answer "<text>" # answer the signup question (SIGNUP_CHALLENGE)
114
+ zenrows login --api-key <key> # use an existing key (stored 0600, gitignored)
115
+ zenrows login --env # use the ZENROWS_API_KEY environment variable
103
116
  ```
104
117
 
105
118
  Set `auto_signup: false` in `.zenrows/policy.json` to disable auto-provisioning
@@ -27,8 +27,10 @@ cannot handle. **Escalation only.**
27
27
  6. Always `browser_close` when done
28
28
 
29
29
  Other useful tools: `browser_evaluate`, cookies (`browser_get_cookies` /
30
- `browser_set_cookies` / `browser_clear_cookies`), `browser_local_storage`,
31
- tabs (`browser_new_tab` / `browser_switch_tab`), `browser_generate_pdf`.
30
+ `browser_set_cookies` / `browser_clear_cookies`), local storage
31
+ (`browser_get_local_storage` / `browser_set_local_storage` /
32
+ `browser_clear_local_storage`), tabs (`browser_new_tab` / `browser_switch_tab`),
33
+ `browser_generate_pdf`.
32
34
 
33
35
  ## Rules
34
36
 
@@ -20,7 +20,10 @@ You do **not** need an API key in the plugin package.
20
20
 
21
21
  - **stdio** (`npx -y @zenrows/mcp`): on first cloud call the server can
22
22
  auto-create a **free, unclaimed** Zenrows Free plan account and persist the
23
- key locally. When the Free plan nears its limit (or the user asks), surface
23
+ key locally. Zenrows may first ask one short question (which word is a
24
+ bird, which word does not belong): the tool call
25
+ returns it; answer it yourself with the `account_signup_answer` tool, then
26
+ retry the call. When the Free plan nears its limit (or the user asks), surface
24
27
  the **claim URL** so a human can claim the account (confirming an email
25
28
  with Google, GitHub or a 6-digit code). Claim links expire after 30 days. The
26
29
  same key keeps working after claim. Never handle payment or upgrades —
@@ -4,9 +4,13 @@
4
4
  * There is no documented programmatic signup API, so we never fake account
5
5
  * creation: we explain what is needed, open the signup URL, and hand off to
6
6
  * `login`.
7
+ *
8
+ * `--agent` provisions a Free plan account headless, and `--answer` answers the
9
+ * one short question signup may ask an agent first (SIGNUP_CHALLENGE).
7
10
  */
8
- import { saveApiKey } from "../../core/auth.js";
9
- import { readAccount, signupAgent, writeAccount } from "../../core/agent-account.js";
11
+ import { answerSignupChallenge, readAccount, signupAgent } from "../../core/agent-account.js";
12
+ import { storeSignup } from "../../core/ensure-key.js";
13
+ import { ToolkitError } from "../../core/errors.js";
10
14
  import { attributionEnabled } from "../../core/config.js";
11
15
  import { log } from "../../core/logger.js";
12
16
  import { openUrl, SIGNUP_URL, withParams } from "../../core/open-url.js";
@@ -15,9 +19,42 @@ import { parse } from "../command.js";
15
19
  export const signup = {
16
20
  name: "signup",
17
21
  summary: "Create a Zenrows account / obtain an API key (opens the browser).",
18
- usage: "zenrows signup [--agent] [--no-open]",
22
+ usage: 'zenrows signup [--agent] [--no-open] [--answer "<text>"]',
23
+ help: [
24
+ "Without flags, opens the Zenrows signup page in the browser.",
25
+ "",
26
+ " --agent Create a Free plan account headless and store its key.",
27
+ " --no-open Print the signup URL instead of opening it.",
28
+ ' --answer "<text>" Answer the question Zenrows asked before creating an account',
29
+ " (error SIGNUP_CHALLENGE). Answer it yourself, then re-run",
30
+ " your original command.",
31
+ ].join("\n"),
19
32
  async run(argv, ctx) {
20
- const { values } = parse(argv, { "no-open": { type: "boolean" }, agent: { type: "boolean" } });
33
+ const { values } = parse(argv, {
34
+ "no-open": { type: "boolean" },
35
+ agent: { type: "boolean" },
36
+ answer: { type: "string" },
37
+ });
38
+ if (values.answer !== undefined) {
39
+ const answer = String(values.answer).trim();
40
+ if (!answer) {
41
+ throw new ToolkitError({
42
+ code: "INVALID_USAGE",
43
+ message: "--answer needs the answer text.",
44
+ likely_cause: "An empty value was passed to --answer.",
45
+ next_action: 'Run: zenrows signup --answer "<your answer>"',
46
+ });
47
+ }
48
+ const res = await answerSignupChallenge(answer);
49
+ const account = storeSignup(res);
50
+ log.success("Right answer. Created a Zenrows Free plan account and stored its API key.");
51
+ log.dim(`Claim it anytime (keeps your usage): ${account.claimUrl}`);
52
+ log.info("Now re-run your original command.");
53
+ if (ctx.json) {
54
+ log.out(JSON.stringify({ ok: true, accountId: res.accountId, claimUrl: res.claimUrl, next_action: "Re-run your original command." }, null, 2));
55
+ }
56
+ return 0;
57
+ }
21
58
  if (values.agent) {
22
59
  const account = readAccount();
23
60
  if (account) {
@@ -25,9 +62,7 @@ export const signup = {
25
62
  return 0;
26
63
  }
27
64
  const res = await signupAgent();
28
- saveApiKey(res.apiKey);
29
- const now = new Date().toISOString();
30
- writeAccount({ accountId: res.accountId, unclaimed: true, claimUrl: res.claimUrl, createdAt: now, claimUrlIssuedAt: now });
65
+ storeSignup(res);
31
66
  log.success("Created a Zenrows Free plan account.");
32
67
  log.dim(`Claim it anytime: ${res.claimUrl}`);
33
68
  if (ctx.json)
@@ -12,6 +12,7 @@ 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
14
  import { findWorkspace, readJson, workspacePaths, writePrivateJson } from "./workspace.js";
15
+ import { challengeError, challengeExpired, challengeLockedError, clearChallenge, isChallengeLocked, noChallengeError, parseChallenge, readChallenge, wasWrongAnswer, writeChallenge, } from "./signup-challenge.js";
15
16
  /** Env var to override the signup endpoint (highest priority; for local testing). */
16
17
  export const SIGNUP_URL_ENV = "ZENROWS_AGENT_SIGNUP_URL";
17
18
  /** Env var to override the discovery base origin (for local testing). */
@@ -117,8 +118,25 @@ export function clearAccount(projectRoot) {
117
118
  return true;
118
119
  }
119
120
  export async function signupAgent(opts = {}) {
120
- const urls = opts.url ? [opts.url] : await signupCandidates(undefined, { fetchImpl: opts.fetchImpl });
121
- const doFetch = opts.fetchImpl ?? fetch;
121
+ const urls = opts.url ? [opts.url] : await signupCandidates(opts.projectRoot, { fetchImpl: opts.fetchImpl });
122
+ return postSignup(urls, undefined, opts);
123
+ }
124
+ /**
125
+ * Answer the pending signup question (see signup-challenge.ts). Posts it to
126
+ * the signup URL that asked it; a right answer creates the account like a
127
+ * plain signup does. A wrong answer stores and reports the new question.
128
+ */
129
+ export async function answerSignupChallenge(answer, opts = {}) {
130
+ const pending = readChallenge(opts.projectRoot);
131
+ if (!pending)
132
+ throw noChallengeError(false);
133
+ if (challengeExpired(pending)) {
134
+ clearChallenge(opts.projectRoot);
135
+ throw noChallengeError(true);
136
+ }
137
+ return postSignup([pending.signupUrl], { challenge_id: pending.id, answer }, opts);
138
+ }
139
+ function signupHeaders(projectRoot) {
122
140
  // Signup is the one functional request the CLI must make — no separate
123
141
  // telemetry beacon exists. When attribution is enabled we ride anonymous
124
142
  // provenance on it (a random id + coarse host/runtime brand). No PII, no URLs.
@@ -126,7 +144,7 @@ export async function signupAgent(opts = {}) {
126
144
  const headers = {
127
145
  "content-type": "application/json",
128
146
  "User-Agent": CLI_USER_AGENT,
129
- ...agentClientHeader(),
147
+ ...agentClientHeader({ projectRoot }),
130
148
  };
131
149
  if (attributionEnabled()) {
132
150
  const p = detectClient();
@@ -138,14 +156,21 @@ export async function signupAgent(opts = {}) {
138
156
  headers["X-ZR-Node"] = p.node;
139
157
  headers["X-ZR-CI"] = p.ci ? "1" : "0";
140
158
  }
159
+ return headers;
160
+ }
161
+ async function postSignup(urls, body, opts) {
162
+ const doFetch = opts.fetchImpl ?? fetch;
163
+ const headers = signupHeaders(opts.projectRoot);
141
164
  // Try each candidate in order; on any failure fall back to the next (the last
142
165
  // is always the built-in default). This is the safety net: a wrong/blocked
143
166
  // discovered endpoint can never leave provisioning worse off than no discovery.
167
+ // A signup question or the wrong-answer lock is a real answer from a working
168
+ // endpoint, so it ends the loop.
144
169
  let lastErr;
145
170
  for (const url of urls) {
146
171
  let res;
147
172
  try {
148
- res = await doFetch(url, { method: "POST", headers });
173
+ res = await doFetch(url, { method: "POST", headers, ...(body ? { body: JSON.stringify(body) } : {}) });
149
174
  }
150
175
  catch (err) {
151
176
  lastErr = new ToolkitError({
@@ -156,9 +181,24 @@ export async function signupAgent(opts = {}) {
156
181
  });
157
182
  continue;
158
183
  }
159
- if (res.status === 201)
184
+ if (res.status === 201) {
185
+ clearChallenge(opts.projectRoot);
160
186
  return (await res.json());
161
- lastErr = signupError(res.status, await res.text());
187
+ }
188
+ const text = await res.text();
189
+ if (res.status === 428) {
190
+ const challenge = parseChallenge(text, url);
191
+ if (challenge) {
192
+ writeChallenge(challenge, opts.projectRoot);
193
+ throw challengeError(challenge, { wrong: body !== undefined || wasWrongAnswer(text) });
194
+ }
195
+ }
196
+ if (res.status === 429 && isChallengeLocked(text)) {
197
+ clearChallenge(opts.projectRoot);
198
+ const ra = Number(res.headers.get("retry-after"));
199
+ throw challengeLockedError(Number.isFinite(ra) && ra > 0 ? ra : undefined);
200
+ }
201
+ lastErr = signupError(res.status, text);
162
202
  }
163
203
  throw (lastErr ??
164
204
  new ToolkitError({
@@ -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.9.0"; // x-release-please-version
17
+ export const CLI_VERSION = "1.10.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). */
@@ -7,8 +7,15 @@ export async function ensureApiKey(policy, opts = {}) {
7
7
  if (!policy.auto_signup)
8
8
  return requireApiKey(opts.projectRoot); // throws AUTH_MISSING
9
9
  const doSignup = opts.signupImpl ?? signupAgent;
10
- const res = await doSignup();
11
- saveApiKey(res.apiKey, opts.projectRoot);
10
+ // May throw SIGNUP_CHALLENGE: the agent answers with `zenrows signup --answer`.
11
+ const res = await doSignup({ projectRoot: opts.projectRoot });
12
+ const account = storeSignup(res, opts.projectRoot);
13
+ opts.onProvision?.(account);
14
+ return res.apiKey;
15
+ }
16
+ /** Persist a fresh agent signup: the key (0600) and the account metadata. */
17
+ export function storeSignup(res, projectRoot) {
18
+ saveApiKey(res.apiKey, projectRoot);
12
19
  const account = {
13
20
  accountId: res.accountId,
14
21
  unclaimed: true,
@@ -16,7 +23,6 @@ export async function ensureApiKey(policy, opts = {}) {
16
23
  createdAt: new Date().toISOString(),
17
24
  };
18
25
  account.claimUrlIssuedAt = account.createdAt;
19
- writeAccount(account, opts.projectRoot);
20
- opts.onProvision?.(account);
21
- return res.apiKey;
26
+ writeAccount(account, projectRoot);
27
+ return account;
22
28
  }
@@ -16,6 +16,7 @@ export class ToolkitError extends Error {
16
16
  job_id;
17
17
  idempotency_key;
18
18
  retry_after;
19
+ challenge;
19
20
  constructor(shape) {
20
21
  super(shape.message);
21
22
  this.name = "ToolkitError";
@@ -28,6 +29,7 @@ export class ToolkitError extends Error {
28
29
  this.job_id = shape.job_id;
29
30
  this.idempotency_key = shape.idempotency_key;
30
31
  this.retry_after = shape.retry_after;
32
+ this.challenge = shape.challenge;
31
33
  }
32
34
  toJSON() {
33
35
  return {
@@ -41,6 +43,7 @@ export class ToolkitError extends Error {
41
43
  ...(this.job_id ? { job_id: this.job_id } : {}),
42
44
  ...(this.idempotency_key ? { idempotency_key: this.idempotency_key } : {}),
43
45
  ...(this.retry_after !== undefined ? { retry_after: this.retry_after } : {}),
46
+ ...(this.challenge ? { challenge: this.challenge } : {}),
44
47
  };
45
48
  }
46
49
  }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The signup challenge: before it creates an account, the signup endpoint may
3
+ * answer 428 with one short natural-language question for the agent running
4
+ * the CLI to answer itself. The CLI never tries to solve it.
5
+ *
6
+ * Each CLI run is its own process, so the pending question is kept in
7
+ * `.zenrows/signup-challenge.json` (no secret: the id, the question, when it
8
+ * expires, and the signup URL that issued it, which is where the answer goes).
9
+ */
10
+ import { existsSync, unlinkSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { ToolkitError } from "./errors.js";
13
+ import { findWorkspace, readJson, workspacePaths, writeJson } from "./workspace.js";
14
+ /** The command an agent runs to answer the pending question. */
15
+ export const ANSWER_COMMAND = 'zenrows signup --answer "<your answer>"';
16
+ export function challengePath(projectRoot) {
17
+ const ws = projectRoot ? workspacePaths(projectRoot) : (findWorkspace() ?? workspacePaths());
18
+ return join(ws.dir, "signup-challenge.json");
19
+ }
20
+ export function readChallenge(projectRoot) {
21
+ try {
22
+ const c = readJson(challengePath(projectRoot));
23
+ if (!c || typeof c.id !== "string" || typeof c.prompt !== "string" || typeof c.signupUrl !== "string")
24
+ return null;
25
+ return c;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ export function writeChallenge(c, projectRoot) {
32
+ writeJson(challengePath(projectRoot), c);
33
+ }
34
+ export function clearChallenge(projectRoot) {
35
+ const file = challengePath(projectRoot);
36
+ if (!existsSync(file))
37
+ return;
38
+ try {
39
+ unlinkSync(file);
40
+ }
41
+ catch {
42
+ // best effort
43
+ }
44
+ }
45
+ /** True when the stored question can no longer be answered. */
46
+ export function challengeExpired(c, now = Date.now()) {
47
+ const t = Date.parse(c.expiresAt);
48
+ return Number.isFinite(t) && t <= now;
49
+ }
50
+ function parseBody(body) {
51
+ try {
52
+ const json = JSON.parse(body);
53
+ return json && typeof json === "object" ? json : null;
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ /**
60
+ * Parse a 428 `challenge_required` body into a pending challenge, or null when
61
+ * the body is not one (then the caller reports a generic failure).
62
+ */
63
+ export function parseChallenge(body, signupUrl, now = Date.now()) {
64
+ const json = parseBody(body);
65
+ if (!json || json.error !== "challenge_required")
66
+ return null;
67
+ const ch = json.challenge;
68
+ if (!ch || typeof ch.id !== "string" || !ch.id || typeof ch.prompt !== "string" || !ch.prompt.trim())
69
+ return null;
70
+ const ttl = typeof ch.expires_in === "number" && ch.expires_in > 0 ? ch.expires_in : 600;
71
+ return { id: ch.id, prompt: ch.prompt.trim(), expiresAt: new Date(now + ttl * 1000).toISOString(), signupUrl };
72
+ }
73
+ /** True when a 428 says the previous answer was wrong or came too late. */
74
+ export function wasWrongAnswer(body) {
75
+ const msg = parseBody(body)?.message;
76
+ return typeof msg === "string" && /^wrong or expired/i.test(msg.trim());
77
+ }
78
+ /** True when a 429 is the per-network lock after too many questions or wrong answers. */
79
+ export function isChallengeLocked(body) {
80
+ return parseBody(body)?.error === "challenge_locked";
81
+ }
82
+ /** The error an agent sees while a question is waiting for its answer. */
83
+ export function challengeError(c, opts = {}) {
84
+ const lead = opts.wrong ? "That answer was wrong or came too late, so here is a new question. " : "";
85
+ return new ToolkitError({
86
+ code: "SIGNUP_CHALLENGE",
87
+ message: `${lead}Zenrows needs one quick answer before it creates an account: ${c.prompt}`,
88
+ likely_cause: "No API key is configured, and new accounts created by agents answer one short question first.",
89
+ next_action: `Answer the question yourself, then run: ${ANSWER_COMMAND}. When it succeeds, re-run your original command.`,
90
+ suggested_commands: [ANSWER_COMMAND, "zenrows login --api-key <your-key>"],
91
+ challenge: { id: c.id, question: c.prompt, expires_at: c.expiresAt },
92
+ });
93
+ }
94
+ export function challengeLockedError(retryAfter) {
95
+ return new ToolkitError({
96
+ code: "SIGNUP_CHALLENGE_LOCKED",
97
+ message: "Too many signup questions or wrong answers from this network, so Zenrows paused account creation here.",
98
+ likely_cause: "Too many signup questions asked, or wrong answers given, from this network within an hour.",
99
+ next_action: "Try again in an hour by re-running your original command, which asks a new question. If you already have a Zenrows API key, use it now: zenrows login --api-key <key> (or set ZENROWS_API_KEY).",
100
+ suggested_commands: ["zenrows login --api-key <your-key>"],
101
+ ...(retryAfter !== undefined ? { retry_after: retryAfter } : {}),
102
+ });
103
+ }
104
+ export function noChallengeError(expired) {
105
+ return new ToolkitError({
106
+ code: "SIGNUP_CHALLENGE_MISSING",
107
+ message: expired ? "The signup question expired before it was answered." : "There is no signup question waiting for an answer.",
108
+ likely_cause: expired
109
+ ? "Questions are valid for a few minutes."
110
+ : "No signup was attempted from this directory, or the question was already answered.",
111
+ next_action: "Re-run your original command (for example zenrows fetch <url>) to get a new question, then answer it with: " + ANSWER_COMMAND,
112
+ suggested_commands: ["zenrows fetch <url>"],
113
+ });
114
+ }
@@ -70,6 +70,7 @@ export function ensureGitignore(paths) {
70
70
  `${WORKSPACE_DIRNAME}/secrets.json`,
71
71
  `${WORKSPACE_DIRNAME}/config.json`,
72
72
  `${WORKSPACE_DIRNAME}/account.json`,
73
+ `${WORKSPACE_DIRNAME}/signup-challenge.json`,
73
74
  `${WORKSPACE_DIRNAME}/runs/`,
74
75
  `${WORKSPACE_DIRNAME}/traces/`,
75
76
  `${WORKSPACE_DIRNAME}/logs/`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenrows/cli",
3
- "version": "1.9.0",
3
+ "version": "1.10.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",
@@ -20,6 +20,13 @@ the toolkit automatically creates a **free, unclaimed Zenrows Free plan account*
20
20
  key locally — no human step required. To opt out, pass `--no-signup` or set
21
21
  `auto_signup: false` in `.zenrows/policy.json`, then use `zenrows login --api-key <key>`.
22
22
 
23
+ Before it creates the account, Zenrows may ask you one short question (error code
24
+ `SIGNUP_CHALLENGE`, with the question in the message and in `error.challenge.question`).
25
+ Answer it yourself, do not ask the human, then run `zenrows signup --answer "<your answer>"`
26
+ and re-run your original command. A wrong answer returns a new question the same way.
27
+ After too many questions or wrong answers from one network (`SIGNUP_CHALLENGE_LOCKED`), wait an hour or use
28
+ `zenrows login --api-key <key>`.
29
+
23
30
  When the Free plan nears its limit (or the user asks), tell the human they can **claim** the
24
31
  account to keep their usage and history: run `zenrows account claim` (or open the claim URL
25
32
  shown on quota errors). It prints a valid link, requesting a fresh one when the stored link is