serpkin 0.0.0-stage → 0.2.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.
@@ -0,0 +1,813 @@
1
+ import { hostname as osHostname } from "node:os";
2
+ import { join, relative } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import {
6
+ c,
7
+ out,
8
+ ask,
9
+ confirm,
10
+ choose,
11
+ openBrowser,
12
+ table,
13
+ interactive,
14
+ CliError,
15
+ } from "./ui.mjs";
16
+ import { createClient, deviceLogin, ApiError } from "./api.mjs";
17
+ import {
18
+ apiKeyFor,
19
+ saveCredential,
20
+ credentialsPath,
21
+ loadProject,
22
+ saveProject,
23
+ resolveServer,
24
+ projectServerTrusted,
25
+ PROJECT_FILE,
26
+ } from "./config.mjs";
27
+ import {
28
+ detectApps,
29
+ buildRouteMap,
30
+ publicDir,
31
+ routeMapFramework,
32
+ } from "./detect.mjs";
33
+ import {
34
+ AGENTS,
35
+ detectAgents,
36
+ onPath,
37
+ mergeMcpJson,
38
+ appendCodexConfig,
39
+ codexConfigPath,
40
+ claudeEntry,
41
+ cursorEntry,
42
+ claudeMcpAdd,
43
+ installClaudePlugin,
44
+ pluginInstallCommand,
45
+ } from "./agents.mjs";
46
+ import { writeCursorRule, writeAgentsSection } from "./rules.mjs";
47
+ import { DEFAULT_SERVER } from "./config.mjs";
48
+
49
+ const PREVIEW_HOSTS = /\.(vercel\.app|netlify\.app|pages\.dev)$/i;
50
+ const FRAMEWORK_NAMES = {
51
+ nextjs: "Next.js",
52
+ "nextjs-app": "Next.js (app router)",
53
+ "nextjs-pages": "Next.js (pages router)",
54
+ astro: "Astro",
55
+ sveltekit: "SvelteKit",
56
+ nuxt: "Nuxt",
57
+ remix: "Remix",
58
+ };
59
+
60
+ /** Shared context: injectable for tests. */
61
+ export function context(overrides = {}) {
62
+ return {
63
+ cwd: process.cwd(),
64
+ env: process.env,
65
+ fetchImpl: globalThis.fetch,
66
+ wait: undefined,
67
+ ...overrides,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Resolve the server. .serpkin.json is repository content anyone can commit, so a server named only
73
+ * there must be known (default, or already signed in to) or confirmed; otherwise keys could be sent
74
+ * to an attacker's origin.
75
+ */
76
+ export async function pickServer(flags, project, ctx) {
77
+ const { server, source } = resolveServer(flags.server, project, ctx.env);
78
+ if (source !== "project" || (await projectServerTrusted(server, ctx.env)))
79
+ return server;
80
+ const msg = `${PROJECT_FILE} points to ${server}, a Serpkin server you haven't signed in to.`;
81
+ if (!interactive(flags))
82
+ throw new CliError(
83
+ `${msg} If you trust it, pass --server ${server} or set SERPKIN_SERVER=${server}.`,
84
+ );
85
+ out.warn(msg);
86
+ if (!(await confirm(`Use ${server}?`, { def: false, opts: flags })))
87
+ throw new CliError("Stopped. Pass --server <origin> to choose the server.");
88
+ return server;
89
+ }
90
+
91
+ async function session(flags, ctx, { required = true } = {}) {
92
+ const project = await loadProject(ctx.cwd);
93
+ const server = await pickServer(flags, project, ctx);
94
+ const cred = await apiKeyFor(server, ctx.env);
95
+ if (!cred && required)
96
+ throw new CliError(
97
+ `Not signed in to ${server}. Run \`npx serpkin\` first, or set SERPKIN_API_KEY.`,
98
+ );
99
+ const api = createClient({
100
+ server,
101
+ apiKey: cred?.api_key,
102
+ fetchImpl: ctx.fetchImpl,
103
+ });
104
+ return { project, server, cred, api };
105
+ }
106
+ function siteIdFrom(flags, project, env) {
107
+ const id = flags.site || env.SERPKIN_SITE_ID || project?.site_id;
108
+ if (!id)
109
+ throw new CliError(
110
+ `No site found. Run \`npx serpkin init\` in this repository (it writes ${PROJECT_FILE}), or pass --site <id>.`,
111
+ );
112
+ return id;
113
+ }
114
+ function cleanHost() {
115
+ return (
116
+ osHostname()
117
+ .replace(/\.(local|lan|home)$/i, "")
118
+ .slice(0, 64) || "this machine"
119
+ );
120
+ }
121
+ const sameOrigin = (url, origin) => {
122
+ try {
123
+ return new URL(url).origin === origin;
124
+ } catch {
125
+ return false;
126
+ }
127
+ };
128
+ export function normalizeUrl(input) {
129
+ const s = String(input ?? "").trim();
130
+ if (!s) return null;
131
+ try {
132
+ const u = new URL(/^[a-z]+:\/\//i.test(s) ? s : `https://${s}`);
133
+ if (!["http:", "https:"].includes(u.protocol)) return null;
134
+ return u.origin;
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+ const isLocal = (origin) =>
140
+ /^(localhost|127\.|0\.0\.0\.0|\[::1\])|\.local$/i.test(
141
+ new URL(origin).hostname,
142
+ );
143
+
144
+ /* ─────────────────────────────── init ─────────────────────────────── */
145
+
146
+ export async function init(flags, ctx = context()) {
147
+ const project = await loadProject(ctx.cwd);
148
+ const server = await pickServer(flags, project, ctx);
149
+ out.line(c.bold("Serpkin setup") + c.dim(` ${server}`));
150
+ out.line();
151
+
152
+ // 1. Sign in (device code), unless a working key is already stored.
153
+ let cred = flags.login ? null : await apiKeyFor(server, ctx.env);
154
+ let api = createClient({
155
+ server,
156
+ apiKey: cred?.api_key,
157
+ fetchImpl: ctx.fetchImpl,
158
+ });
159
+ if (cred) {
160
+ try {
161
+ await api.get("/api/records?kind=site&limit=1");
162
+ out.ok(
163
+ `Signed in ${c.dim(`(${cred.source === "env" ? "SERPKIN_API_KEY" : (cred.key_prefix ?? "stored key")})`)}`,
164
+ );
165
+ } catch (e) {
166
+ if (!(e instanceof ApiError) || e.status !== 401) throw e;
167
+ out.warn("The stored key no longer works. Signing in again.");
168
+ cred = null;
169
+ }
170
+ }
171
+ if (!cred) {
172
+ const token = await deviceLogin(api, {
173
+ clientName: "Serpkin CLI",
174
+ hostname: cleanHost(),
175
+ wait: ctx.wait,
176
+ onCode: (d) => {
177
+ // Only show/open approval pages on the server we are signing in to.
178
+ for (const u of [d.verification_uri, d.verification_uri_complete])
179
+ if (!sameOrigin(u, server))
180
+ throw new CliError(
181
+ `The server returned a sign-in page on another origin (${String(u).slice(0, 80)}); stopping.`,
182
+ );
183
+ d.user_code = String(d.user_code)
184
+ .replace(/[^A-Z0-9-]/gi, "")
185
+ .slice(0, 12);
186
+ out.step("Sign in to connect this machine.");
187
+ out.line(
188
+ ` Open ${c.cyan(d.verification_uri)} and confirm the code ${c.bold(d.user_code)}`,
189
+ );
190
+ if (openBrowser(d.verification_uri_complete, flags, server))
191
+ out.note("Opened your browser. Waiting for approval…");
192
+ else out.note("Waiting for approval…");
193
+ },
194
+ });
195
+ const path = await saveCredential(
196
+ server,
197
+ {
198
+ api_key: token.api_key,
199
+ key_prefix: token.key_prefix,
200
+ project_id: token.project_id,
201
+ created_at: new Date().toISOString(),
202
+ },
203
+ ctx.env,
204
+ );
205
+ cred = { ...token, source: "file" };
206
+ api = createClient({
207
+ server,
208
+ apiKey: token.api_key,
209
+ fetchImpl: ctx.fetchImpl,
210
+ });
211
+ out.ok(
212
+ `Signed in. Key saved to ${c.dim(tilde(path, ctx.env))} ${c.dim("(outside this repo)")}`,
213
+ );
214
+ }
215
+ const mcpUrl = `${server}/api/mcp`;
216
+
217
+ // 2. Framework and route map.
218
+ const apps = await detectApps(ctx.cwd);
219
+ let app = null;
220
+ if (flags.app) {
221
+ app =
222
+ apps.find(
223
+ (a) => a.root === flags.app.replace(/^\.\//, "").replace(/\/$/, ""),
224
+ ) ?? null;
225
+ if (!app) throw new CliError(`No supported app found at ${flags.app}.`);
226
+ } else if (apps.length === 1) app = apps[0];
227
+ else if (apps.length > 1)
228
+ app = await choose(
229
+ "Which app is the website?",
230
+ apps.map((a) => ({
231
+ value: a,
232
+ label: `${a.root} ${c.dim(`(${FRAMEWORK_NAMES[a.framework]}, ${a.name})`)}`,
233
+ })),
234
+ {
235
+ opts: flags,
236
+ needed: `Found ${apps.length} apps (${apps.map((a) => a.root).join(", ")}). Pass --app <dir> to choose one.`,
237
+ },
238
+ );
239
+ let routeMap = null;
240
+ if (app) {
241
+ routeMap = await buildRouteMap(app, ctx.cwd);
242
+ const dyn = routeMap.routes.filter((r) => r.dynamic).length;
243
+ out.ok(
244
+ `${FRAMEWORK_NAMES[app.framework]}${app.router ? ` (${app.router.replace("+", " + ")} router)` : ""}` +
245
+ `${app.root !== "." ? ` in ${app.root}` : ""}: ${routeMap.routes.length} routes${dyn ? `, ${dyn} dynamic` : ""}`,
246
+ );
247
+ } else
248
+ out.warn(
249
+ "No supported framework found here. Missions still work by URL; the route map is skipped.",
250
+ );
251
+
252
+ // 3. Coding agents.
253
+ const { usedEnv, plugin } = await setupAgents(flags, ctx, {
254
+ mcpUrl,
255
+ apiKey: cred.api_key,
256
+ });
257
+
258
+ // 4. Register the site.
259
+ let origin =
260
+ normalizeUrl(flags.url) ??
261
+ (flags.url ? null : (project?.origin ?? normalizeUrl(app?.homepage)));
262
+ if (flags.url && !origin)
263
+ throw new CliError(`"${flags.url}" isn't a website address.`);
264
+ if (!origin) {
265
+ const answer = await ask(
266
+ "Production URL of the site (or a preview URL if it isn't live yet):",
267
+ {
268
+ opts: flags,
269
+ needed: "Pass the site's address with --url https://example.com.",
270
+ },
271
+ );
272
+ origin = normalizeUrl(answer);
273
+ if (!origin) throw new CliError(`"${answer}" isn't a website address.`);
274
+ }
275
+ let site = null,
276
+ token = null;
277
+ if (isLocal(origin)) {
278
+ out.warn(`${origin} is on this machine, so Serpkin can't reach it.`);
279
+ out.note(
280
+ "Deploy a preview (for example a vercel.app URL) and run `npx serpkin init --url <preview-url>`.",
281
+ );
282
+ out.note(
283
+ "Foundation checks work on a preview; production verification comes once the domain is live.",
284
+ );
285
+ } else {
286
+ const pkg = app
287
+ ? await readFile(join(app.dir, "package.json"), "utf8").then(
288
+ JSON.parse,
289
+ () => ({}),
290
+ )
291
+ : {};
292
+ const locale = Intl.DateTimeFormat().resolvedOptions().locale || "en-US";
293
+ const [lang, region] = locale.split("-");
294
+ const description =
295
+ flags.description ||
296
+ (await ask("In one sentence, what does the site offer?", {
297
+ opts: flags,
298
+ def:
299
+ app?.description ||
300
+ pkg.description ||
301
+ `The website at ${new URL(origin).hostname}`,
302
+ }));
303
+ const audience =
304
+ flags.audience ||
305
+ (await ask("Who is it for?", {
306
+ opts: flags,
307
+ def: "People searching for what the site offers",
308
+ }));
309
+ const register = (country) =>
310
+ api.tool("serpkin_register_site", {
311
+ origin,
312
+ business_description: description,
313
+ audience,
314
+ ...(app ? { framework: routeMapFramework(app) } : {}),
315
+ country,
316
+ language: flags.language || lang || "en",
317
+ idempotency_key: `cli-${randomUUID()}`,
318
+ });
319
+ const guessed = /^[A-Z]{2}$/.test(region ?? "") ? region : "US";
320
+ let r;
321
+ try {
322
+ r = await register((flags.country || guessed).toUpperCase()).catch(
323
+ (e) => {
324
+ // A guessed (not requested) market that isn't enabled yet falls back to US.
325
+ if (
326
+ !flags.country &&
327
+ guessed !== "US" &&
328
+ e instanceof ApiError &&
329
+ e.code === "UNSUPPORTED_CAPABILITY"
330
+ )
331
+ return register("US");
332
+ throw e;
333
+ },
334
+ );
335
+ } catch (e) {
336
+ if (e instanceof ApiError && e.code === "QUOTA_EXCEEDED")
337
+ throw new CliError(
338
+ `${e.message} Remove a site or upgrade in ${server}/app, then run this again.`,
339
+ );
340
+ throw e;
341
+ }
342
+ site = r.result.site;
343
+ token =
344
+ r.result.ownership_instructions
345
+ ?.join("\n")
346
+ .match(/exactly:\s*(\S+)/)?.[1] ?? null;
347
+ await saveProject(ctx.cwd, {
348
+ server,
349
+ site_id: site.site_id,
350
+ origin: site.origin,
351
+ });
352
+ out.ok(
353
+ `Site registered: ${site.origin} ${c.dim(`(${PROJECT_FILE} saved; it holds no secrets)`)}`,
354
+ );
355
+ if (PREVIEW_HOSTS.test(new URL(origin).hostname))
356
+ out.note(
357
+ "This is a preview address. Run `npx serpkin init --url https://your-domain` once production is live.",
358
+ );
359
+ }
360
+
361
+ // 5. Route map upload.
362
+ if (site && routeMap?.routes.length) {
363
+ const { truncated, ...body } = routeMap;
364
+ await api.post("/api/cli/sites/route-map", {
365
+ site_id: site.site_id,
366
+ ...body,
367
+ });
368
+ out.ok(
369
+ `Route map uploaded${truncated ? " (first 2,000 routes)" : ""}. Missions can now name the files to change.`,
370
+ );
371
+ }
372
+
373
+ // 6. Ownership.
374
+ if (site && site.ownership_status !== "verified")
375
+ await ownership(flags, ctx, { server, site, token, app });
376
+ else if (site) out.ok("Ownership already verified.");
377
+
378
+ // 7. Next step.
379
+ out.line();
380
+ if (usedEnv && cred.source !== "env") {
381
+ out.line(
382
+ "Add your key to your shell profile (for example ~/.zshrc) so your agent can read it:",
383
+ );
384
+ out.line(` ${c.cyan(`export SERPKIN_API_KEY="${cred.api_key}"`)}`);
385
+ // The plugin's MCP server reads its origin from SERPKIN_SERVER (default https://serpkin.com).
386
+ if (plugin && server !== DEFAULT_SERVER)
387
+ out.line(` ${c.cyan(`export SERPKIN_SERVER="${server}"`)}`);
388
+ out.line();
389
+ }
390
+ out.line(
391
+ `${c.bold("Done.")} Ask your agent: ${c.violet('"What should I do for SEO?"')}`,
392
+ );
393
+ return { server, site, routeMap, app };
394
+ }
395
+
396
+ async function setupAgents(flags, ctx, { mcpUrl, apiKey }) {
397
+ const detected = await detectAgents(ctx.cwd, ctx.env);
398
+ let chosen;
399
+ if (flags.agent) {
400
+ chosen = flags.agent.split(",").map((s) => s.trim().toLowerCase());
401
+ for (const a of chosen)
402
+ if (!AGENTS[a])
403
+ throw new CliError(
404
+ `Unknown agent "${a}". Use claude, cursor or codex.`,
405
+ );
406
+ } else if (detected.length && !interactive(flags)) chosen = detected;
407
+ else if (detected.length) {
408
+ chosen = [];
409
+ for (const a of detected)
410
+ if (await confirm(`Connect ${AGENTS[a]}?`, { opts: flags }))
411
+ chosen.push(a);
412
+ } else {
413
+ const pick = await choose(
414
+ "Which coding agent do you use?",
415
+ [
416
+ ...Object.entries(AGENTS).map(([value, label]) => ({ value, label })),
417
+ { value: "none", label: "Skip for now" },
418
+ ],
419
+ { opts: flags, def: "none" },
420
+ );
421
+ chosen = pick === "none" ? [] : [pick];
422
+ }
423
+ if (!chosen.length) {
424
+ out.note(
425
+ "No agent configured. Settings → Agent setup in the web app has the snippets.",
426
+ );
427
+ return { usedEnv: false, plugin: false };
428
+ }
429
+ let usedEnv = false,
430
+ plugin = false;
431
+ for (const a of chosen) {
432
+ try {
433
+ if (a === "claude") {
434
+ const hasClaude = onPath("claude", ctx.env);
435
+ let how = flags.plugin && hasClaude ? "plugin" : "json";
436
+ if (hasClaude && interactive(flags) && !flags.plugin)
437
+ how = await choose(
438
+ "How should Claude Code get Serpkin?",
439
+ [
440
+ {
441
+ value: "plugin",
442
+ label: `Install the Serpkin plugin ${c.dim("(SEO skill, /serpkin:next, /serpkin:ship, /serpkin:check, /serpkin:status and the MCP server; reads $SERPKIN_API_KEY)")}`,
443
+ },
444
+ {
445
+ value: "json",
446
+ label: `Write .mcp.json in this repo ${c.dim("(shareable; reads $SERPKIN_API_KEY)")}`,
447
+ },
448
+ {
449
+ value: "cli",
450
+ label: `Run \`claude mcp add\` ${c.dim("(just you; key stored in your Claude config)")}`,
451
+ },
452
+ ],
453
+ { opts: flags, def: "plugin" },
454
+ );
455
+ if (how === "plugin") {
456
+ const r = (ctx.installPlugin ?? installClaudePlugin)(ctx.env);
457
+ if (r.ok) {
458
+ out.ok(
459
+ "Claude Code: installed the Serpkin plugin. Try /serpkin:next in a new session.",
460
+ );
461
+ usedEnv = plugin = true;
462
+ continue;
463
+ }
464
+ out.warn(`Claude Code plugin: ${r.message}`);
465
+ out.note("Falling back to .mcp.json.");
466
+ how = "json";
467
+ }
468
+ if (how === "cli") {
469
+ const r = claudeMcpAdd(mcpUrl, apiKey);
470
+ if (r.ok)
471
+ out.ok(
472
+ "Claude Code: added the serpkin server with `claude mcp add`.",
473
+ );
474
+ else if (r.exists)
475
+ out.ok(
476
+ "Claude Code: a serpkin server is already configured (left as is).",
477
+ );
478
+ else throw new Error(r.message);
479
+ } else {
480
+ const s = await mergeMcpJson(
481
+ join(ctx.cwd, ".mcp.json"),
482
+ claudeEntry(mcpUrl),
483
+ );
484
+ out.ok(
485
+ `Claude Code: .mcp.json ${s === "unchanged" ? "already set up" : s}.`,
486
+ );
487
+ usedEnv = true;
488
+ }
489
+ out.note(
490
+ `For the Serpkin skill and /serpkin:* commands, install the plugin: ${pluginInstallCommand(ctx.env)}`,
491
+ );
492
+ } else if (a === "cursor") {
493
+ await mkdir(join(ctx.cwd, ".cursor"), { recursive: true });
494
+ const s = await mergeMcpJson(
495
+ join(ctx.cwd, ".cursor", "mcp.json"),
496
+ cursorEntry(mcpUrl),
497
+ );
498
+ out.ok(
499
+ `Cursor: .cursor/mcp.json ${s === "unchanged" ? "already set up" : s}.`,
500
+ );
501
+ usedEnv = true;
502
+ const rule = await writeCursorRule(
503
+ join(ctx.cwd, ".cursor", "rules", "serpkin.mdc"),
504
+ );
505
+ out.ok(
506
+ `Cursor: .cursor/rules/serpkin.mdc ${rule === "unchanged" ? "already up to date" : rule}.`,
507
+ );
508
+ } else if (a === "codex") {
509
+ const path = codexConfigPath(ctx.env);
510
+ const s = await appendCodexConfig(path, mcpUrl);
511
+ if (s === "different")
512
+ out.warn(
513
+ `Codex: ${tilde(path, ctx.env)} already has [mcp_servers.serpkin] with another URL; left as is.`,
514
+ );
515
+ else
516
+ out.ok(
517
+ `Codex: ${tilde(path, ctx.env)} ${s === "unchanged" ? "already set up" : s}.`,
518
+ );
519
+ usedEnv = true;
520
+ const agentsMd = join(ctx.cwd, "AGENTS.md");
521
+ if (
522
+ !interactive(flags) ||
523
+ (await confirm(
524
+ 'Add a short "Serpkin SEO" section to AGENTS.md so Codex knows the loop?',
525
+ { opts: flags },
526
+ ))
527
+ ) {
528
+ const r = await writeAgentsSection(agentsMd);
529
+ out.ok(
530
+ `Codex: ${r === "unchanged" ? "AGENTS.md already has the Serpkin SEO section" : r === "created" ? "created AGENTS.md with a Serpkin SEO section" : r === "added" ? "added a Serpkin SEO section to AGENTS.md" : "updated the Serpkin SEO section in AGENTS.md"}.`,
531
+ );
532
+ } else out.note("Skipped AGENTS.md.");
533
+ }
534
+ } catch (e) {
535
+ out.warn(`${AGENTS[a]}: ${e.message}`);
536
+ out.note(
537
+ "Settings → Agent setup in the web app shows the snippet to add by hand.",
538
+ );
539
+ }
540
+ }
541
+ return { usedEnv, plugin };
542
+ }
543
+
544
+ async function ownership(flags, ctx, { server, site, token, app }) {
545
+ const method =
546
+ flags.ownership ||
547
+ (await choose(
548
+ "Verify that you own the site: with Google Search Console (opens browser) or add a verification file to this repo?",
549
+ [
550
+ { value: "file", label: "Add a verification file to this repo" },
551
+ {
552
+ value: "search-console",
553
+ label: "Google Search Console (opens browser)",
554
+ },
555
+ { value: "skip", label: "Later" },
556
+ ],
557
+ { opts: flags, def: app && token ? "file" : "skip" },
558
+ ));
559
+ if (method === "search-console") {
560
+ const url = `${server}/app/site?connect=google&site_id=${encodeURIComponent(site.site_id)}`;
561
+ out.step(`Connect Search Console: ${c.cyan(url)}`);
562
+ openBrowser(url, flags, server);
563
+ } else if (method === "file") {
564
+ if (!token)
565
+ throw new CliError(
566
+ "The server didn't return a verification token. Use Search Console or the web app.",
567
+ );
568
+ const dir = app ? publicDir(app) : join(ctx.cwd, "public");
569
+ await mkdir(dir, { recursive: true });
570
+ const file = join(dir, "serpkin-verification.txt");
571
+ await writeFile(file, token + "\n");
572
+ out.ok(
573
+ `Wrote ${relative(ctx.cwd, file)}. Deploy, then run \`npx serpkin verify\`.`,
574
+ );
575
+ } else if (method === "skip")
576
+ out.note("Verify later with `npx serpkin verify` or from the web app.");
577
+ else
578
+ throw new CliError(
579
+ `Unknown ownership method "${method}". Use file, search-console or skip.`,
580
+ );
581
+ out.note(
582
+ "Foundation checks and missions work now; production verification follows ownership.",
583
+ );
584
+ }
585
+
586
+ const tilde = (p, env) => {
587
+ const h = env.HOME || env.USERPROFILE;
588
+ return h && p.startsWith(h) ? "~" + p.slice(h.length) : p;
589
+ };
590
+
591
+ /* ─────────────────────────────── check ─────────────────────────────── */
592
+
593
+ export async function check(previewUrl, flags, ctx = context()) {
594
+ if (!previewUrl)
595
+ throw new CliError(
596
+ "Usage: serpkin check <preview-url> [--paths /,/pricing] [--json]",
597
+ );
598
+ const { project, api } = await session(flags, ctx);
599
+ const siteId = siteIdFrom(flags, project, ctx.env);
600
+ const url = /^[a-z]+:\/\//i.test(previewUrl)
601
+ ? previewUrl
602
+ : `https://${previewUrl}`;
603
+ const paths = flags.paths
604
+ ? flags.paths
605
+ .split(",")
606
+ .map((p) => p.trim())
607
+ .filter(Boolean)
608
+ : undefined;
609
+ if (!flags.json) out.step(`Checking ${url} against production…`);
610
+ const r = await api.post("/api/check", {
611
+ site_id: siteId,
612
+ preview_url: url,
613
+ ...(paths ? { paths } : {}),
614
+ });
615
+ if (flags.json) {
616
+ process.stdout.write(JSON.stringify(r, null, 2) + "\n");
617
+ return r.status === "pass" ? 0 : 1;
618
+ }
619
+ printCheck(r);
620
+ return r.status === "pass" ? 0 : 1;
621
+ }
622
+ export function printCheck(r) {
623
+ out.line();
624
+ if (r.regressions.length) {
625
+ out.line(
626
+ c.bold(
627
+ c.red(
628
+ `${r.regressions.length} regression${r.regressions.length === 1 ? "" : "s"}`,
629
+ ),
630
+ ),
631
+ );
632
+ out.line(
633
+ table(
634
+ r.regressions.map((x) => [
635
+ x.path,
636
+ x.check,
637
+ x.production ?? "—",
638
+ x.preview ?? "—",
639
+ ]),
640
+ ["Path", "Check", "Production", "Preview"],
641
+ ),
642
+ );
643
+ out.line();
644
+ for (const x of r.regressions)
645
+ out.line(`${c.red("✗")} ${x.path} ${x.message}`);
646
+ }
647
+ if (r.warnings.length) {
648
+ if (r.regressions.length) out.line();
649
+ for (const w of r.warnings)
650
+ out.line(`${c.yellow("!")} ${w.path} ${c.dim(w.message)}`);
651
+ }
652
+ out.line();
653
+ const n = r.checked_paths.length;
654
+ if (r.status === "pass")
655
+ out.ok(`No SEO regressions across ${n} page${n === 1 ? "" : "s"}.`);
656
+ else
657
+ out.fail(
658
+ `Found regressions on the preview (${n} page${n === 1 ? "" : "s"} checked).`,
659
+ );
660
+ }
661
+
662
+ /* ─────────────────────────────── verify ─────────────────────────────── */
663
+
664
+ export async function verify(flags, ctx = context()) {
665
+ const { project, api } = await session(flags, ctx);
666
+ const siteId = siteIdFrom(flags, project, ctx.env);
667
+ try {
668
+ const s = await api.post("/api/ownership", { site_id: siteId });
669
+ out.ok(`Ownership verified for ${s.origin}.`);
670
+ return 0;
671
+ } catch (e) {
672
+ if (e instanceof ApiError && e.code === "SITE_NOT_VERIFIED") {
673
+ out.fail(e.message);
674
+ out.note(
675
+ "Deploy public/serpkin-verification.txt to production first, or connect Search Console in the web app.",
676
+ );
677
+ return 1;
678
+ }
679
+ throw e;
680
+ }
681
+ }
682
+
683
+ /* ─────────────────────────────── status ─────────────────────────────── */
684
+
685
+ export async function status(flags, ctx = context()) {
686
+ const { project, api, server } = await session(flags, ctx);
687
+ const siteId = siteIdFrom(flags, project, ctx.env);
688
+ const s = (await api.tool("serpkin_get_site_context", { site_id: siteId }))
689
+ .result;
690
+ // serpkin_next_mission is the V3 primary tool; older servers only have serpkin_get_missions.
691
+ let next = null;
692
+ try {
693
+ next = (await api.tool("serpkin_next_mission", { site_id: siteId })).result;
694
+ } catch (e) {
695
+ if (!(e instanceof ApiError) || e.status !== 404) throw e;
696
+ }
697
+ let mission = next?.mission ?? null;
698
+ if (!next) {
699
+ const r = await api
700
+ .tool("serpkin_get_missions", {
701
+ site_id: siteId,
702
+ status: "ready",
703
+ limit: 1,
704
+ })
705
+ .catch(() => null);
706
+ mission = r?.result?.missions?.[0] ?? null;
707
+ }
708
+ const state = await api.get("/api/state?limit=1").catch(() => null);
709
+ const level = next?.level ?? s.level ?? null;
710
+ // CreditState {plan_total, used, reserved, remaining, resets_at} when the server exposes it.
711
+ const credits = state?.credits ?? null;
712
+ const included = state?.limits?.credits ?? null;
713
+ const LEVELS = {
714
+ search_console: "Search Console connected",
715
+ estimated: "Established (estimated rankings)",
716
+ new: "New site",
717
+ };
718
+ const rows = [
719
+ ["Site", s.origin],
720
+ [
721
+ "Ownership",
722
+ s.ownership_status === "verified"
723
+ ? c.green("verified")
724
+ : c.yellow(s.ownership_status ?? "unverified"),
725
+ ],
726
+ [
727
+ "Framework",
728
+ s.framework ? (FRAMEWORK_NAMES[s.framework] ?? s.framework) : "—",
729
+ ],
730
+ ...(level
731
+ ? [["Level", LEVELS[level] ?? String(level).replaceAll("_", " ")]]
732
+ : []),
733
+ [
734
+ "Next mission",
735
+ mission?.title ??
736
+ c.dim(
737
+ next?.suggested_action ??
738
+ "none ready yet; ask your agent to scan the site",
739
+ ),
740
+ ],
741
+ ["Plan", state?.project?.plan ?? "—"],
742
+ ...(credits
743
+ ? [
744
+ [
745
+ "Credits",
746
+ `${credits.remaining} of ${credits.plan_total} left${credits.resets_at ? c.dim(`, resets ${String(credits.resets_at).slice(0, 10)}`) : ""}`,
747
+ ],
748
+ ]
749
+ : included != null
750
+ ? [["Credits", `${included} included per month`]]
751
+ : []),
752
+ ];
753
+ if (flags.json) {
754
+ process.stdout.write(
755
+ JSON.stringify(
756
+ {
757
+ site: s,
758
+ level,
759
+ next_mission: mission,
760
+ credits,
761
+ plan: state?.project?.plan ?? null,
762
+ },
763
+ null,
764
+ 2,
765
+ ) + "\n",
766
+ );
767
+ return 0;
768
+ }
769
+ for (const [k, v] of rows) out.line(`${c.dim(k.padEnd(13))}${v}`);
770
+ return 0;
771
+ }
772
+
773
+ /* ─────────────────────────────── routes ─────────────────────────────── */
774
+
775
+ export async function routes(flags, ctx = context()) {
776
+ const apps = await detectApps(ctx.cwd);
777
+ const project = await loadProject(ctx.cwd);
778
+ const app = flags.app
779
+ ? apps.find((a) => a.root === flags.app.replace(/\/$/, ""))
780
+ : apps.length === 1
781
+ ? apps[0]
782
+ : null;
783
+ if (!app)
784
+ throw new CliError(
785
+ apps.length
786
+ ? `Found ${apps.length} apps (${apps.map((a) => a.root).join(", ")}). Pass --app <dir>.`
787
+ : "No supported framework found in this directory.",
788
+ );
789
+ const map = await buildRouteMap(app, ctx.cwd);
790
+ if (flags.json) process.stdout.write(JSON.stringify(map, null, 2) + "\n");
791
+ else {
792
+ out.line(
793
+ c.dim(
794
+ `${FRAMEWORK_NAMES[app.framework]}${app.root !== "." ? ` in ${app.root}` : ""}: ${map.routes.length} routes`,
795
+ ),
796
+ );
797
+ out.line(
798
+ table(
799
+ map.routes.map((r) => [r.path, r.file, r.dynamic ? "dynamic" : ""]),
800
+ ["Path", "File", ""],
801
+ ),
802
+ );
803
+ }
804
+ if (flags.upload) {
805
+ const { api } = await session(flags, ctx);
806
+ const siteId = siteIdFrom(flags, project, ctx.env);
807
+ const { truncated, ...body } = map;
808
+ await api.post("/api/cli/sites/route-map", { site_id: siteId, ...body });
809
+ if (!flags.json) out.ok("Route map uploaded.");
810
+ } else if (!flags.json)
811
+ out.note("Run `serpkin routes --upload` to send this to Serpkin.");
812
+ return 0;
813
+ }