@gscdump/cli 3.8.0 → 4.0.1

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 +115 -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 +301 -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 +172 -37
  72. package/dist/commands/export.mjs +0 -76
  73. package/dist/native-duckdb.mjs +0 -46
@@ -0,0 +1,185 @@
1
+ import { terminalOutputOptions } from "../render/terminal.mjs";
2
+ import { applyOutputMode, logger, parseIntegerOption, toCSV } from "../utils.mjs";
3
+ import { renderTable } from "../render/layout.mjs";
4
+ import { HOSTED_ARGS, resolveHostedSite } from "../hosted-site.mjs";
5
+ import { defineCommand } from "citty";
6
+ const STATUSES = [
7
+ "indexed",
8
+ "not_indexed",
9
+ "pending"
10
+ ];
11
+ const FORMATS = [
12
+ "table",
13
+ "json",
14
+ "csv"
15
+ ];
16
+ const MAX_PAGE_SIZE = 500;
17
+ const CSV_COLUMNS = [
18
+ "url",
19
+ "verdict",
20
+ "coverageState",
21
+ "issueType",
22
+ "lastCrawlTime",
23
+ "lastCheckedAt",
24
+ "googleCanonical",
25
+ "userCanonical",
26
+ "sitemaps"
27
+ ];
28
+ function parseChoice(value, flag, allowed) {
29
+ if (value === void 0 || value === null || value === "") return void 0;
30
+ if (!allowed.includes(String(value))) throw new Error(`Invalid ${flag}: ${String(value)}. Use ${allowed.join(", ")}.`);
31
+ return String(value);
32
+ }
33
+ async function fetchAllPages(client, site, query) {
34
+ const urls = [];
35
+ let first;
36
+ while (true) {
37
+ const { data } = await client.listSiteIndexingUrls({
38
+ params: { siteId: site.siteId },
39
+ query: {
40
+ ...query,
41
+ limit: MAX_PAGE_SIZE,
42
+ offset: urls.length
43
+ }
44
+ });
45
+ first ??= data;
46
+ if (data.pagination.offset !== urls.length || data.pagination.hasMore && data.urls.length === 0) throw new Error("The hosted API returned inconsistent indexing pagination. Run the command again.");
47
+ urls.push(...data.urls);
48
+ if (!data.pagination.hasMore) return {
49
+ ...first,
50
+ urls,
51
+ pagination: {
52
+ ...data.pagination,
53
+ total: urls.length,
54
+ limit: urls.length,
55
+ offset: 0,
56
+ hasMore: false
57
+ }
58
+ };
59
+ }
60
+ }
61
+ function csvRow(row) {
62
+ return {
63
+ ...row,
64
+ sitemaps: (row.sitemaps ?? []).join(" ")
65
+ };
66
+ }
67
+ const indexingUrlsCommand = defineCommand({
68
+ meta: {
69
+ name: "urls",
70
+ description: "List Google URL Inspection results for a Site's URLs, filtered by status (hosted)"
71
+ },
72
+ args: {
73
+ ...HOSTED_ARGS,
74
+ status: {
75
+ type: "string",
76
+ description: `Filter by status: ${STATUSES.join(", ")}`
77
+ },
78
+ search: {
79
+ type: "string",
80
+ description: "Only URLs that contain this text"
81
+ },
82
+ limit: {
83
+ type: "string",
84
+ alias: "l",
85
+ default: "100",
86
+ description: `Rows per page, maximum ${MAX_PAGE_SIZE}`
87
+ },
88
+ offset: {
89
+ type: "string",
90
+ default: "0",
91
+ description: "Rows to skip before the page"
92
+ },
93
+ all: {
94
+ type: "boolean",
95
+ default: false,
96
+ description: "Read every page; ignores --limit and --offset"
97
+ },
98
+ format: {
99
+ type: "string",
100
+ alias: "f",
101
+ default: "table",
102
+ description: `Output format: ${FORMATS.join(", ")}`
103
+ },
104
+ json: {
105
+ type: "boolean",
106
+ default: false,
107
+ description: "Output as JSON (same as --format json)"
108
+ },
109
+ quiet: {
110
+ type: "boolean",
111
+ alias: "q",
112
+ default: false,
113
+ description: "Suppress info output"
114
+ }
115
+ },
116
+ async run({ args }) {
117
+ const format = args.json ? "json" : parseChoice(args.format, "--format", FORMATS) ?? "table";
118
+ const status = parseChoice(args.status, "--status", STATUSES);
119
+ const limit = parseIntegerOption(args.limit, "--limit") ?? 100;
120
+ const offset = parseIntegerOption(args.offset, "--offset", 0) ?? 0;
121
+ if (limit > MAX_PAGE_SIZE) throw new Error(`--limit must be ${MAX_PAGE_SIZE} or less. Use --all to read every page.`);
122
+ const search = args.search ? String(args.search) : void 0;
123
+ applyOutputMode({
124
+ json: format !== "table",
125
+ quiet: args.quiet
126
+ });
127
+ const filters = {
128
+ ...status ? { status } : {},
129
+ ...search ? { search } : {}
130
+ };
131
+ const { client, site } = await resolveHostedSite(args, {
132
+ name: "indexing urls",
133
+ localAlternative: "pipe `gscdump sitemaps urls <sitemap-url>` into `gscdump inspect --site <site>`"
134
+ });
135
+ const page = args.all ? await fetchAllPages(client, site, filters) : (await client.listSiteIndexingUrls({
136
+ params: { siteId: site.siteId },
137
+ query: {
138
+ ...filters,
139
+ limit,
140
+ offset
141
+ }
142
+ })).data;
143
+ if (format === "json") {
144
+ console.log(JSON.stringify(page, null, 2));
145
+ return;
146
+ }
147
+ if (format === "csv") {
148
+ console.log(toCSV(page.urls.map(csvRow), [...CSV_COLUMNS]));
149
+ return;
150
+ }
151
+ const options = terminalOutputOptions();
152
+ if (page.urls.length === 0) {
153
+ logger.info(`No URLs${status ? ` with status ${status}` : ""} for ${site.siteUrl}.`);
154
+ return;
155
+ }
156
+ for (const line of renderTable(page.urls.map((row) => ({
157
+ url: row.url,
158
+ coverage: row.coverageState ?? row.verdict ?? "unknown",
159
+ lastCrawl: row.lastCrawlTime?.slice(0, 10) ?? "never",
160
+ sitemaps: row.sitemaps?.length ?? 0
161
+ })), [
162
+ {
163
+ key: "url",
164
+ label: "URL"
165
+ },
166
+ {
167
+ key: "coverage",
168
+ label: "Coverage"
169
+ },
170
+ {
171
+ key: "lastCrawl",
172
+ label: "Last crawl"
173
+ },
174
+ {
175
+ key: "sitemaps",
176
+ label: "Sitemaps",
177
+ numeric: true
178
+ }
179
+ ], options)) console.log(line);
180
+ const { total, offset: start, hasMore } = page.pagination;
181
+ logger.info(`Showing ${start + 1}-${start + page.urls.length} of ${total} URLs for ${site.siteUrl}.`);
182
+ if (hasMore) logger.info(`For the next page, pass --offset ${start + page.urls.length}. To read every page, pass --all.`);
183
+ }
184
+ });
185
+ export { indexingUrlsCommand };
@@ -1,8 +1,8 @@
1
1
  import { indexingCommandMeta } from "../command-meta.mjs";
2
2
  import { OUTPUT_ARGS, applyOutputMode, logger, parseIntegerOption, readUrlList } from "../utils.mjs";
3
3
  import { createCommandContext } from "../context.mjs";
4
- import { gscErrorHandler } from "../error-handler.mjs";
5
4
  import { loadSitemapUrls } from "../sitemap.mjs";
5
+ import { indexingUrlsCommand } from "./indexing-urls.mjs";
6
6
  import process from "node:process";
7
7
  import { defineCommand } from "citty";
8
8
  import { batchRequestIndexing, getIndexingMetadata, requestIndexing, runSequentialBatch } from "gscdump/indexing";
@@ -21,7 +21,10 @@ async function resolveUrlSource(args) {
21
21
  if (!result.value.complete) logger.warn("Sitemap walk was incomplete; indexing only the URLs that were read");
22
22
  return result.value.urls;
23
23
  }
24
- return readUrlList(args);
24
+ return readUrlList({
25
+ file: args.file,
26
+ positionals: args._
27
+ });
25
28
  }
26
29
  const submitCommand = defineCommand({
27
30
  meta: {
@@ -43,7 +46,7 @@ const submitCommand = defineCommand({
43
46
  needsAuth: true,
44
47
  fetchOptions: { retry: parseIntegerOption(args.retries, "--retries", 0) }
45
48
  });
46
- const result = await requestIndexing(ctx.client, args.url, { type: "URL_UPDATED" }).catch(gscErrorHandler);
49
+ const result = await requestIndexing(ctx.client, args.url, { type: "URL_UPDATED" });
47
50
  if (args.json) {
48
51
  console.log(JSON.stringify(result, null, 2));
49
52
  return;
@@ -72,7 +75,7 @@ const removeCommand = defineCommand({
72
75
  needsAuth: true,
73
76
  fetchOptions: { retry: parseIntegerOption(args.retries, "--retries", 0) }
74
77
  });
75
- const result = await requestIndexing(ctx.client, args.url, { type: "URL_DELETED" }).catch(gscErrorHandler);
78
+ const result = await requestIndexing(ctx.client, args.url, { type: "URL_DELETED" });
76
79
  if (args.json) {
77
80
  console.log(JSON.stringify(result, null, 2));
78
81
  return;
@@ -107,7 +110,7 @@ const statusCommand = defineCommand({
107
110
  async run({ args }) {
108
111
  applyOutputMode(args);
109
112
  const ctx = await createCommandContext({ needsAuth: true });
110
- const meta = await getIndexingMetadata(ctx.client, args.url).catch(gscErrorHandler);
113
+ const meta = await getIndexingMetadata(ctx.client, args.url);
111
114
  if (args.json) {
112
115
  console.log(JSON.stringify(meta, null, 2));
113
116
  return;
@@ -234,7 +237,7 @@ const batchCommand = defineCommand({
234
237
  delayMs,
235
238
  concurrency,
236
239
  onProgress: args.json || args.quiet ? void 0 : (r, i, total) => logger.info(`[${i + 1}/${total}] ${r.url}`)
237
- }).catch(gscErrorHandler);
240
+ });
238
241
  if (args.json) {
239
242
  console.log(JSON.stringify(results, null, 2));
240
243
  return;
@@ -298,7 +301,7 @@ const batchStatusCommand = defineCommand({
298
301
  delayMs,
299
302
  concurrency,
300
303
  onProgress: args.json || args.quiet ? void 0 : (r, i, total) => logger.info(`[${i + 1}/${total}] ${r.url}`)
301
- }).catch(gscErrorHandler);
304
+ });
302
305
  if (args.json) {
303
306
  console.log(JSON.stringify(results, null, 2));
304
307
  return;
@@ -314,7 +317,8 @@ const indexingCommand = defineCommand({
314
317
  "status": statusCommand,
315
318
  "batch": batchCommand,
316
319
  "batch-status": batchStatusCommand,
317
- "quota": quotaCommand
320
+ "quota": quotaCommand,
321
+ "urls": indexingUrlsCommand
318
322
  }
319
323
  });
320
324
  export { indexingCommand };
@@ -1,8 +1,11 @@
1
1
  import { initCommandMeta } from "../command-meta.mjs";
2
+ import { useCliRuntime } from "../runtime.mjs";
3
+ import { saveAuthentication } from "../auth-state.mjs";
2
4
  import { defaultDataDir, loadConfig, saveConfig } from "../config.mjs";
3
5
  import { applyCliEnvironment } from "../environment.mjs";
4
6
  import { OUTPUT_ARGS, applyOutputMode, displayPath, logger } from "../utils.mjs";
5
7
  import { authenticate, getAuth, loadTokens, resolveBYOK, saveTokens } from "../auth.mjs";
8
+ import { loginCloud } from "./auth.mjs";
6
9
  import process from "node:process";
7
10
  import { defineCommand } from "citty";
8
11
  import fs from "node:fs/promises";
@@ -10,8 +13,12 @@ import path from "node:path";
10
13
  import { confirm, isCancel, text } from "@clack/prompts";
11
14
  import { googleSearchConsole } from "gscdump/client";
12
15
  const ENV_LINE_RE = /^([^=]+)=(.*)$/;
16
+ function canPrompt() {
17
+ return process.stdin.isTTY === true;
18
+ }
13
19
  async function promptDataDir(existing) {
14
20
  const fallback = existing ?? defaultDataDir();
21
+ if (!canPrompt()) return fallback;
15
22
  const answer = await text({
16
23
  message: "Where should Parquet data be stored?",
17
24
  placeholder: fallback,
@@ -46,16 +53,35 @@ const initCommand = defineCommand({
46
53
  alias: "f",
47
54
  description: "Force re-initialization"
48
55
  },
49
- "no-store": {
56
+ "store": {
50
57
  type: "boolean",
51
- default: false,
52
- description: "Skip dataDir prompt (auth-only setup)"
58
+ default: true,
59
+ description: "Ask where to keep the local Store",
60
+ negativeDescription: "Skip the Store location prompt (authentication only)"
61
+ },
62
+ "mode": {
63
+ type: "string",
64
+ description: "Authentication mode to save: cloud or local"
65
+ },
66
+ "api-key": {
67
+ type: "string",
68
+ description: "gscdump user API key for --mode cloud; defaults to GSCDUMP_API_KEY"
69
+ },
70
+ "api-root": {
71
+ type: "string",
72
+ description: "Cloud API root for --mode cloud; defaults to GSCDUMP_API_ROOT or https://gscdump.com/api"
53
73
  },
54
74
  ...OUTPUT_ARGS
55
75
  },
56
76
  async run({ args }) {
57
77
  applyOutputMode(args);
58
78
  const config = await loadConfig();
79
+ const mode = useCliRuntime().authModeOverride;
80
+ if (mode === "cloud") {
81
+ await loginCloud(args);
82
+ printNextSteps();
83
+ return;
84
+ }
59
85
  if (config.clientId && config.clientSecret && !args.force) {
60
86
  logger.info("Already configured");
61
87
  const tokens = await loadTokens();
@@ -64,20 +90,23 @@ const initCommand = defineCommand({
64
90
  const isExpired = tokens.expiry_date && tokens.expiry_date < Date.now();
65
91
  if (isExpired && tokens.refresh_token) logger.info("Tokens expired but refresh available. Any live command will auto-refresh, or run `gscdump auth refresh`.");
66
92
  else if (isExpired) logger.warn("Tokens expired without a refresh token. Run `gscdump auth login` to re-authenticate.");
67
- else logger.info("Next: `gscdump sites` to list properties, or `gscdump query --help`.");
68
93
  }
94
+ if (mode === "local") await saveAuthentication({ _tag: "Local" });
69
95
  logger.info("Run `gscdump init --force` to reconfigure.");
96
+ printNextSteps();
70
97
  return;
71
98
  }
72
99
  const byok = resolveBYOK();
73
100
  if (byok) {
74
- const dataDir = args["no-store"] ? void 0 : await promptDataDir(config.dataDir);
101
+ const dataDir = args.store ? config.dataDir ?? defaultDataDir() : config.dataDir;
75
102
  await saveConfig({
76
103
  ...config,
77
- dataDir: dataDir ?? config.dataDir
104
+ ...dataDir ? { dataDir } : {}
78
105
  });
79
- logger.success(`BYOK detected (${typeof byok === "string" ? "access-token" : "refresh-token"}) — auth setup skipped`);
80
- logger.success("Setup complete! Run gscdump to get started.");
106
+ await saveAuthentication({ _tag: "Local" });
107
+ logger.success(`BYOK detected (${typeof byok === "string" ? "access-token" : "refresh-token"}). Auth setup skipped.`);
108
+ logger.success("Setup complete.");
109
+ printNextSteps();
81
110
  return;
82
111
  }
83
112
  const envFile = await loadEnvFile();
@@ -108,27 +137,56 @@ const initCommand = defineCommand({
108
137
  refresh_token: creds.refresh_token || envRef,
109
138
  expiry_date: creds.expiry_date
110
139
  });
140
+ await saveAuthentication({ _tag: "Local" });
111
141
  console.log();
112
- logger.success("Setup complete using .env credentials! Run gscdump to get started.");
142
+ logger.success("Setup complete using .env credentials.");
143
+ printNextSteps();
144
+ return;
145
+ }
146
+ if (!canPrompt()) {
147
+ if (!await loadTokens()) {
148
+ logger.error([
149
+ "No Google credentials found. Init cannot prompt without a terminal.",
150
+ "Run `gscdump auth login` in a terminal, or set GSC_CLIENT_ID, GSC_CLIENT_SECRET and GSC_REFRESH_TOKEN.",
151
+ "To use gscdump.com, run `gscdump init --mode cloud --api-key <key>`."
152
+ ].join("\n"));
153
+ process.exit(1);
154
+ }
155
+ await saveConfig({
156
+ ...config,
157
+ dataDir: config.dataDir ?? defaultDataDir()
158
+ });
159
+ await saveAuthentication({ _tag: "Local" });
160
+ logger.success("Setup complete with the saved Google login.");
161
+ printNextSteps();
113
162
  return;
114
163
  }
115
164
  console.log();
116
165
  console.log(" \x1B[1mWelcome to GSCDump!\x1B[0m");
117
166
  console.log(" \x1B[90mGoogle Search Console data extraction CLI\x1B[0m");
118
167
  console.log();
119
- const dataDir = args["no-store"] ? void 0 : await promptDataDir(config.dataDir);
168
+ const dataDir = args.store ? await promptDataDir(config.dataDir) : void 0;
120
169
  await saveConfig({
121
170
  ...config,
122
171
  ...dataDir ? { dataDir } : {}
123
172
  });
124
- await smokeTest(await getAuth({ interactive: true }));
173
+ const sites = await runSmokeTest(await getAuth({ interactive: true }));
174
+ await saveAuthentication({ _tag: "Local" });
125
175
  if (config.clientId && config.clientSecret) await maybeWriteEnvFile(config.clientId, config.clientSecret);
126
176
  console.log();
127
- logger.success("Setup complete! Run gscdump to get started.");
177
+ logger.success("Setup complete.");
178
+ printNextSteps(sites);
128
179
  }
129
180
  });
130
- async function smokeTest(oauth) {
131
- await runSmokeTest(oauth);
181
+ function printNextSteps(sites = []) {
182
+ const site = sites.length === 1 ? sites[0] : "<site>";
183
+ console.log();
184
+ console.log(" Next:");
185
+ const line = (command, note) => console.log(` ${command.padEnd(44)} # ${note}`);
186
+ if (sites.length !== 1) line("gscdump sites", "list your Sites");
187
+ line(`gscdump sync --site ${site}`, "fetch the last 28 days, then catch up on each run");
188
+ line(`gscdump sync --status --site ${site}`, "see what the Store holds");
189
+ console.log();
132
190
  }
133
191
  async function runSmokeTest(oauth) {
134
192
  const sites = await googleSearchConsole(oauth).sites().catch((e) => e);
@@ -141,18 +199,19 @@ async function runSmokeTest(oauth) {
141
199
  const url = project ? `https://console.developers.google.com/apis/api/searchconsole.googleapis.com/overview?project=${project}` : "https://console.developers.google.com/apis/api/searchconsole.googleapis.com/overview";
142
200
  logger.info(`Enable it here, then retry: ${url}`);
143
201
  logger.info("Note: it can take a few minutes to propagate after enabling.");
144
- return;
202
+ return [];
145
203
  }
146
204
  if (/insufficient|scope|forbidden|403/i.test(msg)) {
147
205
  logger.warn(`Smoke test failed (likely missing scopes): ${msg}`);
148
206
  logger.info("Run `gscdump auth login --force` to re-consent with the required scopes.");
149
- return;
207
+ return [];
150
208
  }
151
209
  logger.warn(`Smoke test failed: ${msg}`);
152
210
  logger.info("Auth saved, but verify via `gscdump auth status` / `gscdump doctor`.");
153
- return;
211
+ return [];
154
212
  }
155
213
  logger.success(`Verified: ${sites.length} GSC site(s) accessible`);
214
+ return sites.flatMap((site) => site.siteUrl ? [site.siteUrl] : []);
156
215
  }
157
216
  async function maybeWriteEnvFile(clientId, clientSecret) {
158
217
  const tokens = await loadTokens();
@@ -182,4 +241,4 @@ async function maybeWriteEnvFile(clientId, clientSecret) {
182
241
  await fs.writeFile(envPath, content, { mode: 384 });
183
242
  logger.success(`Wrote ${displayPath(envPath)}`);
184
243
  }
185
- export { initCommand, runSmokeTest };
244
+ export { initCommand, printNextSteps, runSmokeTest };