@gscdump/cli 3.7.0 → 4.0.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.
Files changed (73) hide show
  1. package/README.md +113 -30
  2. package/bin/gscdump.mjs +21 -1
  3. package/dist/analysis-local.mjs +172 -53
  4. package/dist/auth-state.mjs +21 -6
  5. package/dist/auth.mjs +27 -18
  6. package/dist/bing-data.mjs +6 -2
  7. package/dist/bing-hosted.mjs +1 -1
  8. package/dist/cli-args.mjs +8 -0
  9. package/dist/cli.d.mts +5 -1
  10. package/dist/cli.mjs +41 -10
  11. package/dist/cloud-google.mjs +8 -1
  12. package/dist/command-meta.mjs +4 -4
  13. package/dist/command-registry.mjs +86 -10
  14. package/dist/commands/analyze.mjs +84 -43
  15. package/dist/commands/auth.mjs +46 -37
  16. package/dist/commands/bing.mjs +3 -3
  17. package/dist/commands/compact.mjs +12 -8
  18. package/dist/commands/config.mjs +14 -14
  19. package/dist/commands/doctor.mjs +26 -26
  20. package/dist/commands/dump.mjs +203 -105
  21. package/dist/commands/entities.mjs +10 -113
  22. package/dist/commands/gc.mjs +4 -3
  23. package/dist/commands/indexing-urls.mjs +185 -0
  24. package/dist/commands/indexing.mjs +12 -8
  25. package/dist/commands/init.mjs +77 -18
  26. package/dist/commands/inspect.mjs +97 -110
  27. package/dist/commands/mcp.mjs +37 -57
  28. package/dist/commands/papercut.mjs +1 -1
  29. package/dist/commands/profile.mjs +9 -17
  30. package/dist/commands/query.mjs +219 -210
  31. package/dist/commands/report.mjs +68 -85
  32. package/dist/commands/rollups.mjs +9 -6
  33. package/dist/commands/sitemaps.mjs +51 -104
  34. package/dist/commands/sites.mjs +40 -24
  35. package/dist/commands/stats.mjs +14 -17
  36. package/dist/commands/store-purge.mjs +9 -5
  37. package/dist/commands/store.mjs +0 -12
  38. package/dist/commands/sync.mjs +774 -296
  39. package/dist/config.mjs +7 -4
  40. package/dist/context.mjs +51 -21
  41. package/dist/coverage.mjs +251 -0
  42. package/dist/dump-bing.mjs +71 -0
  43. package/dist/dump-writers.mjs +246 -0
  44. package/dist/error-handler.mjs +108 -38
  45. package/dist/filters.mjs +125 -0
  46. package/dist/hosted-site.mjs +91 -0
  47. package/dist/inspect-urls.mjs +76 -0
  48. package/dist/inspection-record.mjs +93 -0
  49. package/dist/local-entities.mjs +658 -0
  50. package/dist/mcp/errors.mjs +71 -18
  51. package/dist/mcp/handlers/diagnostics.mjs +15 -12
  52. package/dist/mcp/handlers/reports.mjs +33 -42
  53. package/dist/mcp/server/index.mjs +80 -38
  54. package/dist/mcp/types.mjs +7 -7
  55. package/dist/package.mjs +1 -1
  56. package/dist/quota-ledger.mjs +370 -0
  57. package/dist/render/analysis.mjs +8 -1
  58. package/dist/render/report.mjs +8 -1
  59. package/dist/request-pacer.mjs +29 -0
  60. package/dist/route.mjs +295 -0
  61. package/dist/sitemap.mjs +4 -0
  62. package/dist/sql-views.mjs +117 -0
  63. package/dist/store-sites.mjs +134 -0
  64. package/dist/sync-plan.mjs +129 -0
  65. package/dist/sync-run.mjs +96 -0
  66. package/dist/table-sources.mjs +57 -0
  67. package/dist/token-info.mjs +54 -0
  68. package/dist/utils.mjs +28 -19
  69. package/dist/window.mjs +159 -0
  70. package/package.json +17 -14
  71. package/skills/gscdump/SKILL.md +167 -37
  72. package/dist/commands/export.mjs +0 -76
  73. package/dist/native-duckdb.mjs +0 -46
@@ -1,11 +1,11 @@
1
1
  import { inspectCommandMeta } from "../command-meta.mjs";
2
- import { OUTPUT_ARGS, applyOutputMode, logger, parseIntegerOption, readUrlList } from "../utils.mjs";
2
+ import { OUTPUT_ARGS, applyOutputMode, dim, logger, readUrlList, red } from "../utils.mjs";
3
+ import { formatSiteIdCollision, recordStoreSite } from "../store-sites.mjs";
3
4
  import { createCommandContext } from "../context.mjs";
4
- import { gscErrorHandler } from "../error-handler.mjs";
5
- import { loadSitemapUrls } from "../sitemap.mjs";
6
- import process from "node:process";
5
+ import { toInspectionRecord } from "../inspection-record.mjs";
6
+ import { appendInspections, loadInspectionState, materializeInspectionIndex, urlInProperty } from "../local-entities.mjs";
7
+ import { checkInspectionBatch, inspectUrls } from "../inspect-urls.mjs";
7
8
  import { defineCommand } from "citty";
8
- import { batchInspectUrls } from "gscdump/indexing";
9
9
  function verdictTone(verdict) {
10
10
  if (verdict === "PASS") return "\x1B[32m";
11
11
  if (verdict === "NEUTRAL" || verdict === "PARTIAL") return "\x1B[33m";
@@ -76,126 +76,113 @@ function printInspection(url, inspection) {
76
76
  }
77
77
  console.log();
78
78
  }
79
- const batchCommand = defineCommand({
80
- meta: {
81
- name: "batch",
82
- description: "Inspect many URLs from a file or stdin (one URL per line)"
83
- },
79
+ function outcomeJson(outcome) {
80
+ if (outcome.kind === "failed") return {
81
+ url: outcome.url,
82
+ status: "failed",
83
+ error: outcome.error
84
+ };
85
+ const indexStatus = outcome.result?.indexStatusResult;
86
+ return {
87
+ url: outcome.url,
88
+ status: "inspected",
89
+ verdict: indexStatus?.verdict ?? null,
90
+ coverageState: indexStatus?.coverageState ?? null,
91
+ indexingState: indexStatus?.indexingState ?? null,
92
+ lastCrawlTime: indexStatus?.lastCrawlTime ?? null,
93
+ isIndexed: indexStatus?.verdict === "PASS",
94
+ raw: outcome.result ?? null
95
+ };
96
+ }
97
+ function printOutcomeLine(outcome) {
98
+ if (outcome.kind === "failed") {
99
+ console.log(` ${red("ERROR")} ${outcome.url} ${dim(outcome.error)}`);
100
+ return;
101
+ }
102
+ const indexStatus = outcome.result?.indexStatusResult;
103
+ console.log(` ${colorVerdict(indexStatus?.verdict)} ${outcome.url} ${dim(indexStatus?.coverageState ?? "")}`);
104
+ }
105
+ const inspectCommand = defineCommand({
106
+ meta: inspectCommandMeta,
84
107
  args: {
85
108
  ...OUTPUT_ARGS,
86
- "site": {
109
+ site: {
87
110
  type: "string",
88
111
  alias: "s",
89
- description: "Site URL (defaults to config.defaultSite or prompt)"
112
+ description: "Site, for example example.com; defaults to config.defaultSite or a prompt"
90
113
  },
91
- "urls": {
114
+ urls: {
92
115
  type: "positional",
93
116
  required: false,
94
- description: "URLs (or use --file/--from-sitemap/stdin)"
117
+ description: "One or more URLs to inspect"
95
118
  },
96
- "file": {
119
+ file: {
97
120
  type: "string",
98
121
  alias: "f",
99
- description: "File with URLs (one per line)"
100
- },
101
- "from-sitemap": {
102
- type: "string",
103
- description: "Sitemap URL (or sitemap index) to pull URLs from"
104
- },
105
- "delay-ms": {
106
- type: "string",
107
- default: "200",
108
- description: "Delay between requests"
109
- },
110
- "concurrency": {
111
- type: "string",
112
- alias: "c",
113
- default: "1",
114
- description: "Concurrent in-flight requests"
122
+ description: "File with URLs, one per line"
115
123
  }
116
124
  },
117
125
  async run({ args }) {
118
126
  const { json, quiet } = applyOutputMode(args);
119
- const delayMs = parseIntegerOption(args["delay-ms"], "--delay-ms", 0) ?? 200;
120
- const concurrency = parseIntegerOption(args.concurrency, "--concurrency") ?? 1;
121
- let urls;
122
- if (args["from-sitemap"]) {
123
- const result = await loadSitemapUrls(String(args["from-sitemap"]));
124
- if (result._tag === "error") {
125
- logger.error(`Sitemap fetch failed: ${result.message}`);
126
- process.exit(1);
127
- }
128
- if (!result.value.complete) logger.warn("Sitemap walk was incomplete; inspecting only the URLs that were read");
129
- urls = result.value.urls;
130
- } else urls = await readUrlList(args);
131
- if (urls.length === 0) {
132
- logger.error("No URLs provided. Pass URLs as args, --file, --from-sitemap, or stdin.");
133
- process.exit(1);
134
- }
135
- const ctx = await createCommandContext({ needsAuth: true });
127
+ const urls = [...new Set(await readUrlList({
128
+ file: args.file,
129
+ positionals: args._
130
+ }))];
131
+ if (urls.length === 0) throw new Error("No URLs given. Pass URLs as arguments, use --file, or pipe them on stdin.");
132
+ const batch = checkInspectionBatch(urls);
133
+ if (batch.kind === "too-many") throw new Error(batch.message);
134
+ const ctx = await createCommandContext({
135
+ needsAuth: true,
136
+ needsStore: true
137
+ });
138
+ const client = ctx.client;
139
+ const store = ctx.store;
136
140
  const siteUrl = await ctx.resolveSite(args.site ? String(args.site) : void 0);
137
- if (!quiet) logger.info(`Inspecting ${urls.length} URLs ...`);
138
- const results = await batchInspectUrls(ctx.client, siteUrl, urls, {
139
- delayMs,
140
- concurrency,
141
- onProgress: quiet ? void 0 : (r, i, total) => logger.info(`[${i + 1}/${total}] ${r.url} ${r.isIndexed ? "PASS" : "FAIL"}`)
142
- }).catch(gscErrorHandler);
143
- if (json) {
144
- const flattened = results.map((r) => {
145
- const indexStatus = r.inspection?.indexStatusResult;
146
- return {
147
- url: r.url,
148
- verdict: indexStatus?.verdict || null,
149
- coverageState: indexStatus?.coverageState || null,
150
- indexingState: indexStatus?.indexingState || null,
151
- lastCrawlTime: indexStatus?.lastCrawlTime || null,
152
- isIndexed: r.isIndexed,
153
- raw: r.inspection
154
- };
155
- });
156
- console.log(JSON.stringify(flattened, null, 2));
157
- return;
158
- }
159
- const indexed = results.filter((r) => r.isIndexed).length;
160
- if (!quiet) logger.success(`Inspected ${results.length} URLs (${indexed} indexed, ${results.length - indexed} not)`);
161
- }
162
- });
163
- const inspectCommand = defineCommand({
164
- meta: inspectCommandMeta,
165
- args: {
166
- ...OUTPUT_ARGS,
167
- site: {
168
- type: "string",
169
- alias: "s",
170
- description: "Site URL (defaults to config.defaultSite or prompt)"
171
- },
172
- url: {
173
- type: "positional",
174
- required: true,
175
- description: "URL to inspect"
176
- }
177
- },
178
- subCommands: { batch: batchCommand },
179
- async run({ args }) {
180
- const { json } = applyOutputMode(args);
181
- const ctx = await createCommandContext({ needsAuth: true });
182
- const siteUrl = await ctx.resolveSite(args.site ? String(args.site) : void 0);
183
- const result = await ctx.client.inspect(siteUrl, args.url).catch(gscErrorHandler);
184
- const inspection = result?.inspectionResult;
185
- const indexStatus = inspection?.indexStatusResult;
186
- if (json) {
187
- console.log(JSON.stringify({
188
- url: args.url,
189
- verdict: indexStatus?.verdict || null,
190
- coverageState: indexStatus?.coverageState || null,
191
- indexingState: indexStatus?.indexingState || null,
192
- lastCrawlTime: indexStatus?.lastCrawlTime || null,
193
- isIndexed: indexStatus?.verdict === "PASS",
194
- raw: result
195
- }, null, 2));
196
- return;
197
- }
198
- printInspection(args.url, inspection);
141
+ const claim = await recordStoreSite(store.dataDir, siteUrl, { userId: store.userId });
142
+ if (!claim.ok) throw new Error(formatSiteIdCollision(claim.error));
143
+ const tenant = {
144
+ userId: store.userId,
145
+ siteId: store.siteIdFor(siteUrl)
146
+ };
147
+ const { latest } = await loadInspectionState(store.dataSource, tenant, /* @__PURE__ */ new Date());
148
+ const saved = [];
149
+ if (!quiet && urls.length > 1) logger.info(`Inspecting ${urls.length} URLs ...`);
150
+ const run = await inspectUrls({
151
+ urls,
152
+ inspect: async (url) => (await client.inspect(siteUrl, url)).inspectionResult,
153
+ inProperty: (url) => urlInProperty(siteUrl, url),
154
+ onOutcome: async (outcome) => {
155
+ if (outcome.kind === "inspected") {
156
+ const record = toInspectionRecord({
157
+ url: outcome.url,
158
+ result: outcome.result,
159
+ inspectedAt: /* @__PURE__ */ new Date(),
160
+ previous: latest.get(outcome.url)
161
+ });
162
+ await appendInspections(store.dataSource, tenant, [record]);
163
+ saved.push(record);
164
+ }
165
+ if (!json && urls.length > 1) printOutcomeLine(outcome);
166
+ }
167
+ });
168
+ if (saved.length > 0) await materializeInspectionIndex(store.dataSource, tenant, latest, saved);
169
+ const failed = run.outcomes.filter((outcome) => outcome.kind === "failed");
170
+ const remaining = run.stopped?.remaining ?? 0;
171
+ if (json) console.log(JSON.stringify({
172
+ site: siteUrl,
173
+ inspected: saved.length,
174
+ failed: failed.length,
175
+ remaining,
176
+ ...run.stopped ? { stoppedReason: run.stopped.reason } : {},
177
+ results: run.outcomes.map(outcomeJson)
178
+ }, null, 2));
179
+ else if (urls.length === 1 && run.outcomes[0]?.kind === "inspected") printInspection(run.outcomes[0].url, run.outcomes[0].result);
180
+ else if (urls.length === 1 && run.outcomes[0]?.kind === "failed") throw new Error(run.outcomes[0].error);
181
+ const summary = [saved.length > 0 ? `Inspected ${saved.length} of ${urls.length} URL${urls.length === 1 ? "" : "s"} and saved the results to the Store.` : `Inspected ${saved.length} of ${urls.length} URL${urls.length === 1 ? "" : "s"}. Nothing was saved to the Store.`];
182
+ if (failed.length > 0) summary.push(`${failed.length} failed.`);
183
+ if (run.stopped) summary.push(`${remaining} remaining. ${run.stopped.reason} Run the command again after the quota resets.`);
184
+ if (failed.length > 0 || run.stopped) throw new Error(summary.join(" "));
185
+ if (!quiet) logger.success(summary[0]);
199
186
  }
200
187
  });
201
188
  export { inspectCommand };
@@ -1,69 +1,49 @@
1
- import { getCloudAccount, resolveAuthentication } from "../auth-state.mjs";
2
1
  import { mcpCommandMeta } from "../command-meta.mjs";
3
- import { loadConfig } from "../config.mjs";
2
+ import { runWithCliRuntime, useCliRuntime } from "../runtime.mjs";
4
3
  import { VERSION } from "../utils.mjs";
5
- import { loadTokens, resolveBYOK, resolveServiceAccount } from "../auth.mjs";
4
+ import { probeAuth } from "../auth.mjs";
6
5
  import { createCommandContext } from "../context.mjs";
7
6
  import { createGscMcpServer } from "../mcp/server/index.mjs";
8
- import process from "node:process";
9
7
  import { defineCommand } from "citty";
10
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
- async function checkAuth() {
12
- const authentication = await resolveAuthentication();
13
- if (authentication._tag === "Cloud") {
14
- await getCloudAccount(authentication);
15
- return { ok: true };
16
- }
17
- if (await resolveServiceAccount().then(Boolean).catch(() => false)) return { ok: true };
18
- if (resolveBYOK()) return { ok: true };
19
- const config = await loadConfig();
20
- if (!config.clientId && !config.clientSecret) return {
21
- ok: false,
22
- error: `GSCDump not configured.
23
-
24
- Run this command to set up authentication:
25
-
26
- npx @gscdump/cli init
27
-
28
- Or set BYOK env vars: GSC_ACCESS_TOKEN, or GSC_CLIENT_ID + GSC_CLIENT_SECRET + GSC_REFRESH_TOKEN
29
- (GOOGLE_* aliases also accepted).
30
-
31
- Then restart your MCP client.`
32
- };
33
- if (!await loadTokens()) return {
34
- ok: false,
35
- error: `Authentication missing.
36
-
37
- Run this command to authenticate:
38
-
39
- npx @gscdump/cli auth login
40
-
41
- Then restart your MCP client.`
42
- };
43
- return { ok: true };
9
+ const MCP_NO_AUTH_MESSAGE = [
10
+ "gscdump has no Google authentication.",
11
+ "Run `gscdump auth login` in a terminal, then call this tool again.",
12
+ "For cloud mode, set GSCDUMP_API_KEY to a user API key from your gscdump.com settings.",
13
+ "For local mode, set GSC_SERVICE_ACCOUNT_JSON or GOOGLE_APPLICATION_CREDENTIALS to a service-account key file, set GSC_ACCESS_TOKEN, or set GSC_CLIENT_ID, GSC_CLIENT_SECRET, and GSC_REFRESH_TOKEN.",
14
+ "If you set environment variables, set them in the MCP server configuration and restart the MCP client.",
15
+ "If the gscdump command is missing, run `npm install -g @gscdump/cli`."
16
+ ].join(" ");
17
+ const MCP_RETRIES = 1;
18
+ async function startMcpServer(transport, runtime = useCliRuntime()) {
19
+ const server = createGscMcpServer({
20
+ name: "gscdump",
21
+ version: VERSION,
22
+ getContext: async () => {
23
+ if (await probeAuth() === "none") throw new Error(MCP_NO_AUTH_MESSAGE);
24
+ const ctx = await createCommandContext({
25
+ needsAuth: true,
26
+ fetchOptions: { retry: MCP_RETRIES }
27
+ }).catch((error) => {
28
+ if (error.authError?.kind === "not-service-account") throw new Error(`${error.message}. ${MCP_NO_AUTH_MESSAGE}`);
29
+ throw error;
30
+ });
31
+ return {
32
+ authentication: ctx.authentication,
33
+ auth: ctx.auth,
34
+ client: ctx.client
35
+ };
36
+ }
37
+ });
38
+ await server.connect(transport);
39
+ const handle = transport.onmessage;
40
+ if (handle) transport.onmessage = (message, extra) => runWithCliRuntime(runtime, () => handle(message, extra));
41
+ return server;
44
42
  }
45
43
  const mcpCommand = defineCommand({
46
44
  meta: mcpCommandMeta,
47
45
  async run() {
48
- const authCheck = await checkAuth();
49
- if (!authCheck.ok) {
50
- process.stderr.write(`\n${authCheck.error}\n\n`);
51
- process.exit(1);
52
- }
53
- const server = createGscMcpServer({
54
- name: "gscdump",
55
- version: VERSION,
56
- getContext: async () => {
57
- const ctx = await createCommandContext({ needsAuth: true });
58
- return {
59
- authentication: ctx.authentication,
60
- auth: ctx.auth,
61
- client: ctx.client
62
- };
63
- }
64
- });
65
- const transport = new StdioServerTransport();
66
- await server.connect(transport);
46
+ await startMcpServer(new StdioServerTransport());
67
47
  }
68
48
  });
69
- export { mcpCommand };
49
+ export { MCP_NO_AUTH_MESSAGE, mcpCommand, startMcpServer };
@@ -1,5 +1,5 @@
1
- import { useCliRuntime } from "../runtime.mjs";
2
1
  import { papercutCommandMeta } from "../command-meta.mjs";
2
+ import { useCliRuntime } from "../runtime.mjs";
3
3
  import { OUTPUT_ARGS, applyOutputMode, logger } from "../utils.mjs";
4
4
  import { buildPapercutBody, resolvePapercutUrl, submitPapercut } from "../papercut.mjs";
5
5
  import process from "node:process";
@@ -1,9 +1,9 @@
1
- import { useCliRuntime } from "../runtime.mjs";
2
1
  import { profileCommandMeta } from "../command-meta.mjs";
2
+ import { useCliRuntime } from "../runtime.mjs";
3
3
  import { getConfigDir, setConfigDir } from "../config.mjs";
4
4
  import { resolveCliEnvironment } from "../environment.mjs";
5
5
  import { ACTIVE_MARKER, PROFILES_DIR, ROOT_DIR, getProfileDir, readActiveMarkerSync } from "./profile-selection.mjs";
6
- import { OUTPUT_ARGS, applyOutputMode, displayPath, logger, noSubcommandSelected } from "../utils.mjs";
6
+ import { OUTPUT_ARGS, applyOutputMode, displayPath, logger } from "../utils.mjs";
7
7
  import process from "node:process";
8
8
  import { defineCommand } from "citty";
9
9
  import fs from "node:fs/promises";
@@ -151,23 +151,24 @@ const createCmd = defineCommand({
151
151
  },
152
152
  args: {
153
153
  ...OUTPUT_ARGS,
154
- "name": {
154
+ name: {
155
155
  type: "positional",
156
156
  required: true,
157
157
  description: "Profile name"
158
158
  },
159
- "no-use": {
159
+ use: {
160
160
  type: "boolean",
161
- default: false,
162
- description: "Do not mark the new profile as active"
161
+ default: true,
162
+ description: "Make the new profile active",
163
+ negativeDescription: "Create the profile without making it active"
163
164
  }
164
165
  },
165
166
  async run({ args }) {
166
167
  applyOutputMode(args);
167
168
  const name = String(args.name);
168
169
  const dir = await createProfile(name);
169
- if (!args["no-use"]) await setActiveProfile(name);
170
- logger.success(`Created profile: ${name}${args["no-use"] ? "" : " (active)"}`);
170
+ if (args.use) await setActiveProfile(name);
171
+ logger.success(`Created profile: ${name}${args.use ? " (active)" : ""}`);
171
172
  logger.info(displayPath(dir));
172
173
  }
173
174
  });
@@ -240,15 +241,6 @@ const profileCommand = defineCommand({
240
241
  delete: deleteCmd
241
242
  },
242
243
  async run({ args }) {
243
- if (!noSubcommandSelected("profile", [
244
- "list",
245
- "path",
246
- "current",
247
- "use",
248
- "create",
249
- "clear",
250
- "delete"
251
- ])) return;
252
244
  await listCmd.run?.({
253
245
  args,
254
246
  cmd: listCmd,