@gscdump/cli 3.6.2 → 3.7.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
@@ -20,7 +20,7 @@ npx @gscdump/cli
20
20
  ## Quick start
21
21
 
22
22
  ```bash
23
- # Set up local Google OAuth
23
+ # Connect Google for free local CLI use
24
24
  gscdump init --mode local
25
25
  gscdump auth login --mode local
26
26
 
@@ -280,16 +280,22 @@ This section covers local Google credentials. See [shared authentication](#hoste
280
280
  Credentials are saved under `~/.config/gscdump/` on XDG systems, or the platform equivalent.
281
281
  Use `gscdump auth login --mode local` to connect Google and save local mode.
282
282
 
283
- Local Google browser login uses a temporary listener on `127.0.0.1` with a random port.
283
+ If you configure your own Google OAuth client, browser login uses a temporary listener on `127.0.0.1` with a random port.
284
284
  Each attempt uses state validation and PKCE S256 to bind the authorization response to that attempt.
285
285
  The listener closes after authorization, denial, or a five-minute timeout.
286
286
 
287
- For manual setup:
287
+ By default, login opens gscdump.com. No Google Cloud project is required.
288
+ The platform handles Google login and token refresh. Data queries call Google directly.
289
+ This grants read-only Search Console access. It does not activate hosted sync, storage, or hosted MCP.
290
+ Hosted Pro is free during beta and will require payment after launch.
291
+ For Google write operations, configure your own OAuth client with the required scopes.
292
+
293
+ For your own OAuth client:
288
294
 
289
295
  1. Create a Google Cloud project.
290
296
  2. Enable **Search Console API**, **Web Search Indexing API**, and **Site Verification API**.
291
297
  3. Create OAuth2 credentials (Desktop app).
292
- 4. Run `gscdump init --mode local` to configure credentials and a Store directory.
298
+ 4. Set `GSC_CLIENT_ID` and `GSC_CLIENT_SECRET` for your Desktop app.
293
299
  5. Run `gscdump auth login --mode local` to save local mode.
294
300
 
295
301
  ### BYOK (Bring Your Own Key)
@@ -328,8 +334,9 @@ gscdump auth login --mode local --no-browser
328
334
  # Open the printed URL in your browser.
329
335
  ```
330
336
 
331
- This uses the same Desktop application OAuth client and loopback flow as browser login.
332
- Google's device flow does not support the required scopes.
337
+ Default login works from a remote terminal without port forwarding. Keep the command running until you approve the browser request.
338
+
339
+ With your own OAuth client, login uses the Desktop application loopback flow.
333
340
 
334
341
  If the CLI runs on another host, forward its printed loopback port before opening the URL.
335
342
  Keep the login command running on that host.
@@ -1,9 +1,9 @@
1
- import { GSC_INDEXING_SCOPE, GSC_SITE_VERIFICATION_SCOPE, GSC_WRITE_SCOPE, hasGoogleScope } from "gscdump/client";
2
- function missingRequiredScopes(scopes) {
3
- return [
1
+ import { GSC_INDEXING_SCOPE, GSC_READ_SCOPE, GSC_SITE_VERIFICATION_SCOPE, GSC_WRITE_SCOPE, hasGoogleScope } from "gscdump/client";
2
+ function missingRequiredScopes(scopes, provider) {
3
+ return (provider === "gscdump" ? [GSC_READ_SCOPE] : [
4
4
  GSC_WRITE_SCOPE,
5
5
  GSC_INDEXING_SCOPE,
6
6
  GSC_SITE_VERIFICATION_SCOPE
7
- ].filter((scope) => !hasGoogleScope(scopes, scope));
7
+ ]).filter((scope) => !hasGoogleScope(scopes, scope));
8
8
  }
9
9
  export { missingRequiredScopes };
package/dist/auth.mjs CHANGED
@@ -2,6 +2,7 @@ import { getConfigDir, loadConfig } from "./config.mjs";
2
2
  import { pickCliEnvironmentValue, resolveCliEnvironment } from "./environment.mjs";
3
3
  import { getAppliedEnvKeys, getLoadedEnvPath } from "./env-file.mjs";
4
4
  import { displayPath, logger } from "./utils.mjs";
5
+ import { loginWithPlatform, refreshWithPlatform } from "./hosted-auth.mjs";
5
6
  import process from "node:process";
6
7
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
7
8
  import fs from "node:fs/promises";
@@ -11,6 +12,7 @@ import { err, ok, unwrapResult } from "gscdump/result";
11
12
  import { text } from "@clack/prompts";
12
13
  import { createAuth } from "gscdump/client";
13
14
  import { createServer } from "node:http";
15
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
14
16
  import { CodeChallengeMethod, JWT, OAuth2Client } from "google-auth-library";
15
17
  import open from "open";
16
18
  function authErrorToException(error) {
@@ -261,7 +263,8 @@ async function authenticate(credentials, interactive, opts = {}) {
261
263
  }
262
264
  return oauth2Client;
263
265
  }
264
- const existingTokens = !opts.force ? await loadTokens() : null;
266
+ const savedTokens = !opts.force ? await loadTokens() : null;
267
+ const existingTokens = savedTokens?.provider === "gscdump" ? null : savedTokens;
265
268
  let refreshFailed = false;
266
269
  let refreshError = null;
267
270
  if (existingTokens) {
@@ -321,10 +324,44 @@ async function authenticate(credentials, interactive, opts = {}) {
321
324
  }
322
325
  async function getAuth(opts = {}) {
323
326
  const { interactive = true, noBrowser = false, force = false } = opts;
324
- return authenticate(await getAuthCredentials(interactive), interactive, {
327
+ const env = resolveCliEnvironment();
328
+ const config = opts.config ?? await loadConfig();
329
+ if (env.clientId && env.clientSecret || config.clientId && config.clientSecret) return authenticate(await getAuthCredentials(interactive), interactive, {
325
330
  noBrowser,
326
331
  force
327
332
  });
333
+ let tokens = force ? null : await loadTokens();
334
+ if (tokens?.provider !== "gscdump" || !tokens.refresh_token) {
335
+ if (!interactive) throw new Error("Run `gscdump auth login` to connect Google.");
336
+ tokens = await loginWithPlatform({
337
+ force,
338
+ request: fetch,
339
+ now: Date.now,
340
+ wait: setTimeout$1,
341
+ authorize: async (url) => {
342
+ logger.info(`Open this URL to connect Google:\n${url}`);
343
+ if (!noBrowser) await open(url).catch((error) => logger.warn(`Browser could not open: ${error.message}. Open the URL above.`));
344
+ }
345
+ });
346
+ await saveTokens(tokens);
347
+ }
348
+ const refreshToken = tokens.refresh_token;
349
+ const client = new OAuth2Client();
350
+ client.refreshHandler = async () => {
351
+ const refreshed = await refreshWithPlatform(refreshToken);
352
+ await saveTokens({
353
+ provider: "gscdump",
354
+ refresh_token: refreshToken,
355
+ ...refreshed
356
+ });
357
+ return refreshed;
358
+ };
359
+ client.setCredentials({
360
+ access_token: tokens.access_token,
361
+ expiry_date: tokens.expiry_date
362
+ });
363
+ await client.getAccessToken();
364
+ return client;
328
365
  }
329
366
  async function resolveAuth(opts = {}) {
330
367
  const sa = await resolveServiceAccount({ path: opts.serviceAccount });
@@ -0,0 +1,63 @@
1
+ import { parseArgs } from "node:util";
2
+ async function resolve(value) {
3
+ return typeof value === "function" ? value() : value;
4
+ }
5
+ async function checkCliArgs(command, rawArgs, path = "gscdump") {
6
+ const definitions = await resolve(command.args ?? {});
7
+ const options = {
8
+ "help": { type: "boolean" },
9
+ "h": { type: "boolean" },
10
+ "version": { type: "boolean" },
11
+ "no-color": { type: "boolean" }
12
+ };
13
+ for (const [name, definition] of Object.entries(definitions)) {
14
+ if (definition.type === "positional") continue;
15
+ const type = definition.type === "boolean" ? "boolean" : "string";
16
+ const alias = "alias" in definition ? definition.alias : void 0;
17
+ const names = [
18
+ name,
19
+ ...typeof alias === "string" ? [alias] : alias ?? [],
20
+ name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()),
21
+ name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)
22
+ ];
23
+ for (const key of names) {
24
+ options[key] = { type };
25
+ if (type === "boolean" && !key.startsWith("no-")) options[`no-${key}`] = { type };
26
+ }
27
+ }
28
+ const { tokens } = parseArgs({
29
+ args: rawArgs,
30
+ options,
31
+ strict: false,
32
+ allowPositionals: true,
33
+ tokens: true
34
+ });
35
+ const subCommands = await resolve(command.subCommands ?? {});
36
+ const positional = tokens.find((token) => token.kind === "positional");
37
+ const hasSubCommands = Object.keys(subCommands).length > 0;
38
+ const boundary = hasSubCommands && positional ? positional.index : rawArgs.length;
39
+ for (const token of tokens) {
40
+ if (token.index >= boundary || token.kind !== "option") continue;
41
+ const option = options[token.name];
42
+ if (!option) {
43
+ const matches = Object.keys(definitions).filter((name) => definitions[name]?.type !== "positional" && name.startsWith(token.name) && name.length - token.name.length <= 2);
44
+ const suggestion = matches.length === 1 ? ` Use --${matches[0]}.` : "";
45
+ return `Unknown option ${token.rawName}.${suggestion} Run ${path} --help.`;
46
+ }
47
+ if (option.type === "string" && (token.value === void 0 || !token.inlineValue && token.value.startsWith("-"))) return `${token.rawName} requires a value. Use ${token.rawName}=VALUE.`;
48
+ }
49
+ if (hasSubCommands && positional?.kind === "positional") {
50
+ let selected = subCommands[positional.value];
51
+ if (!selected) for (const candidate of Object.values(subCommands)) {
52
+ const meta = await resolve((await resolve(candidate))?.meta ?? {});
53
+ if ((typeof meta.alias === "string" ? [meta.alias] : meta.alias ?? []).includes(positional.value)) {
54
+ selected = candidate;
55
+ break;
56
+ }
57
+ }
58
+ if (!selected) return `Unknown command ${positional.value}. Run ${path} --help.`;
59
+ const child = await resolve(selected);
60
+ if (child) return checkCliArgs(child, rawArgs.slice(boundary + 1), `${path} ${positional.value}`);
61
+ }
62
+ }
63
+ export { checkCliArgs };
package/dist/cli.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createCliRuntime, runWithCliRuntime, useCliRuntime } from "./runtime.mjs";
2
2
  import { parseAuthMode } from "./auth-state.mjs";
3
+ import { checkCliArgs } from "./cli-args.mjs";
3
4
  import { CLI_SUBCOMMANDS } from "./command-registry.mjs";
4
5
  import { resolveCliEnvironment } from "./environment.mjs";
5
6
  import { applyProfileFromCli } from "./commands/profile-selection.mjs";
@@ -78,6 +79,8 @@ async function runCli(opts = {}) {
78
79
  if (opts.loadEnv !== false) loadEnvFromCwd();
79
80
  const rawArgs = prepareCliArgs(input);
80
81
  runtime.rawArgs = [...rawArgs];
82
+ const argumentError = await checkCliArgs(main, rawArgs);
83
+ if (argumentError) throw new Error(argumentError);
81
84
  await withConfiguredOutput(() => runMain(main, { rawArgs }));
82
85
  });
83
86
  }
@@ -3,7 +3,7 @@ import { clearAuthentication, getCloudAccount, parseAuthMode, parseAuthenticatio
3
3
  import { authCommandMeta } from "../command-meta.mjs";
4
4
  import { loadConfig, saveConfig } from "../config.mjs";
5
5
  import { OUTPUT_ARGS, applyOutputMode, logger, noSubcommandSelected } from "../utils.mjs";
6
- import { authenticate, clearTokens, formatAuthProvenance, getAuth, getAuthCredentials, loadServiceAccount, loadTokens, resolveBYOK, saveTokens } from "../auth.mjs";
6
+ import { clearTokens, formatAuthProvenance, getAuth, loadServiceAccount, loadTokens, resolveBYOK, saveTokens } from "../auth.mjs";
7
7
  import { missingRequiredScopes } from "../auth-scopes.mjs";
8
8
  import { clearBingCredentials, getBingClient, inspectBingCredentials } from "../bing-auth.mjs";
9
9
  import { runSmokeTest } from "./init.mjs";
@@ -64,7 +64,7 @@ async function resolveLiveAuthState() {
64
64
  else if (tokens?.access_token) liveToken = tokens.access_token;
65
65
  const tokenInfo = liveToken ? await fetchTokenInfo(liveToken) : null;
66
66
  const scopes = tokenInfo?.scope ? tokenInfo.scope.split(/\s+/).filter(Boolean) : [];
67
- const missing = missingRequiredScopes(scopes);
67
+ const missing = missingRequiredScopes(scopes, byok ? void 0 : tokens?.provider);
68
68
  return {
69
69
  byok,
70
70
  tokens,
@@ -250,15 +250,11 @@ const refreshCommand = defineCommand({
250
250
  logger.error("No saved refresh token. Run `gscdump auth login`.");
251
251
  process.exit(1);
252
252
  }
253
- const credentials = await getAuthCredentials(false).catch((e) => {
254
- logger.error(`Cannot resolve credentials: ${e.message}`);
255
- process.exit(1);
256
- });
257
253
  await saveTokens({
258
254
  ...tokens,
259
255
  expiry_date: 1
260
256
  });
261
- if ((await authenticate(credentials, false).catch((e) => {
257
+ if ((await getAuth({ interactive: false }).catch((e) => {
262
258
  logger.error(`Refresh failed: ${e.message}`);
263
259
  process.exit(1);
264
260
  })).credentials?.access_token) logger.success("Token refreshed");
@@ -141,7 +141,7 @@ async function checkAuth(envKeys) {
141
141
  detail: info.email
142
142
  });
143
143
  const scopes = info.scope ? info.scope.split(/\s+/) : [];
144
- const missing = missingRequiredScopes(scopes);
144
+ const missing = missingRequiredScopes(scopes, byok ? void 0 : tokens?.provider);
145
145
  if (missing.length > 0) checks.push({
146
146
  name: "auth.scopes",
147
147
  status: "warn",
@@ -2,7 +2,7 @@ import { initCommandMeta } from "../command-meta.mjs";
2
2
  import { defaultDataDir, loadConfig, saveConfig } from "../config.mjs";
3
3
  import { applyCliEnvironment } from "../environment.mjs";
4
4
  import { OUTPUT_ARGS, applyOutputMode, displayPath, logger } from "../utils.mjs";
5
- import { authenticate, getAuthCredentials, loadTokens, resolveBYOK, saveTokens } from "../auth.mjs";
5
+ import { authenticate, getAuth, loadTokens, resolveBYOK, saveTokens } from "../auth.mjs";
6
6
  import process from "node:process";
7
7
  import { defineCommand } from "citty";
8
8
  import fs from "node:fs/promises";
@@ -117,15 +117,12 @@ const initCommand = defineCommand({
117
117
  console.log(" \x1B[90mGoogle Search Console data extraction CLI\x1B[0m");
118
118
  console.log();
119
119
  const dataDir = args["no-store"] ? void 0 : await promptDataDir(config.dataDir);
120
- const credentials = await getAuthCredentials(true);
121
120
  await saveConfig({
122
121
  ...config,
123
- ...dataDir ? { dataDir } : {},
124
- clientId: credentials.clientId,
125
- clientSecret: credentials.clientSecret
122
+ ...dataDir ? { dataDir } : {}
126
123
  });
127
- await smokeTest(await authenticate(credentials, true));
128
- await maybeWriteEnvFile(credentials.clientId, credentials.clientSecret);
124
+ await smokeTest(await getAuth({ interactive: true }));
125
+ if (config.clientId && config.clientSecret) await maybeWriteEnvFile(config.clientId, config.clientSecret);
129
126
  console.log();
130
127
  logger.success("Setup complete! Run gscdump to get started.");
131
128
  }
@@ -2,7 +2,7 @@ import { queryCommandMeta } from "../command-meta.mjs";
2
2
  import { loadConfig } from "../config.mjs";
3
3
  import { terminalOutputOptions } from "../render/terminal.mjs";
4
4
  import { ALL_SEARCH_TYPES, logger, parseSearchType, toCSV } from "../utils.mjs";
5
- import { allTables, inferTable } from "../local-store.mjs";
5
+ import { allTables, inferTable, tableDimensions } from "../local-store.mjs";
6
6
  import { createCommandContext } from "../context.mjs";
7
7
  import { gscErrorHandler } from "../error-handler.mjs";
8
8
  import { renderTable } from "../render/layout.mjs";
@@ -12,6 +12,7 @@ import process from "node:process";
12
12
  import { defineCommand } from "citty";
13
13
  import fs from "node:fs/promises";
14
14
  import { and, between, contains, country, date, device, eq, gsc, hour, notRegex, page, query, regex, searchAppearance } from "gscdump/query";
15
+ import { decodeSiteId } from "gscdump/tenant";
15
16
  import { cancel, isCancel, multiselect, text } from "@clack/prompts";
16
17
  import { daysAgoUtc } from "gscdump/dates";
17
18
  import { collectSpans } from "@gscdump/engine/profile";
@@ -279,7 +280,9 @@ const queryCommand = defineCommand({
279
280
  needsStore: !args.live,
280
281
  interactive: Boolean(args.interactive)
281
282
  });
282
- const siteUrl = await ctx.resolveSite(args.site ? String(args.site) : void 0);
283
+ const hint = args.site ? String(args.site) : ctx.config.defaultSite;
284
+ const exactHint = !args.live && hint && /^(?:sc-domain:\S+|https?:\/\/\S+)$/.test(hint) ? hint : void 0;
285
+ const siteUrl = exactHint ? await resolveLocalSite(ctx.store, exactHint) ?? await ctx.resolveSite(hint) : await ctx.resolveSite(hint);
283
286
  if (args.live) {
284
287
  if (args.explain) {
285
288
  const body = {
@@ -339,7 +342,7 @@ const queryCommand = defineCommand({
339
342
  }, null, 2));
340
343
  return;
341
344
  }
342
- await assertRangeCovered(store, siteUrl, table, startDate, endDate, searchType);
345
+ await assertRangeCovered(store, siteUrl, table, startDate, endDate, format === "json", searchType);
343
346
  const probe = Boolean(args.profile) ? collectSpans() : void 0;
344
347
  const result = await store.engine.query({
345
348
  userId: store.userId,
@@ -477,6 +480,14 @@ async function promptFilters(args) {
477
480
  if (ds && String(ds).length > 0) args["data-state"] = String(ds);
478
481
  }
479
482
  }
483
+ async function resolveLocalSite(store, hint) {
484
+ const siteIds = new Set((await store.engine.getWatermarks({ userId: store.userId })).map((w) => w.siteId).filter((siteId) => siteId !== void 0));
485
+ const exact = store.siteIdFor(hint);
486
+ if (siteIds.has(exact)) return hint;
487
+ const lowered = exact.toLowerCase();
488
+ const matches = [...siteIds].filter((siteId) => siteId.toLowerCase() === lowered);
489
+ return matches.length === 1 ? decodeSiteId(matches[0]) : void 0;
490
+ }
480
491
  function buildLocalState(dimNames, startDate, endDate, rowLimit, dimensionFilter) {
481
492
  const dims = dimNames.map((d) => DIM_COLUMNS[d]).filter((c) => Boolean(c));
482
493
  const dateFilter = between(date, startDate, endDate);
@@ -494,25 +505,70 @@ function buildDimensionFilter(args) {
494
505
  if (leaves.length === 1) return leaves[0];
495
506
  return and(...leaves);
496
507
  }
497
- async function assertRangeCovered(store, siteUrl, table, startDate, endDate, searchType) {
498
- const wm = (await store.engine.getWatermarks({
508
+ async function assertRangeCovered(store, siteUrl, table, startDate, endDate, json, searchType) {
509
+ const watermarks = await store.engine.getWatermarks({
499
510
  userId: store.userId,
500
511
  siteId: store.siteIdFor(siteUrl),
501
512
  table,
502
513
  ...searchType !== void 0 ? { searchType } : {}
503
- }))[0];
504
- if (!wm) {
505
- logger.error(`No data synced for ${siteUrl} / ${table}. Run \`gscdump sync\` first, or pass --live.`);
506
- process.exit(1);
507
- }
508
- if (endDate > wm.newestDateSynced) {
509
- logger.error(`Requested end=${endDate} is newer than last sync (${wm.newestDateSynced}). Run \`gscdump sync\` first, or pass --live.`);
510
- process.exit(1);
514
+ });
515
+ const wm = watermarks[0];
516
+ const states = await store.engine.getSyncStates({
517
+ userId: store.userId,
518
+ siteId: store.siteIdFor(siteUrl),
519
+ table,
520
+ searchType: searchType ?? "web",
521
+ state: "done"
522
+ });
523
+ const completed = new Set(states.map((state) => state.date));
524
+ const missingDates = [];
525
+ for (let date = Date.parse(startDate); date <= Date.parse(endDate); date += 864e5) {
526
+ const day = new Date(date).toISOString().slice(0, 10);
527
+ if (!completed.has(day)) missingDates.push(day);
511
528
  }
512
- if (startDate < wm.oldestDateSynced) {
513
- logger.error(`Requested start=${startDate} is older than first sync (${wm.oldestDateSynced}). Run \`gscdump sync --start=${startDate}\` first, or pass --live.`);
514
- process.exit(1);
529
+ if (missingDates.length === 0) return;
530
+ const nextArgs = [
531
+ "sync",
532
+ "--site",
533
+ siteUrl,
534
+ "--start",
535
+ startDate,
536
+ "--end",
537
+ endDate,
538
+ "--tables",
539
+ table,
540
+ "--types",
541
+ searchType ?? "web",
542
+ "--json"
543
+ ];
544
+ const nextCommand = `gscdump ${nextArgs.map((value) => /^[\w:./=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`).join(" ")}`;
545
+ const message = !wm ? `No data synced for ${siteUrl} / ${table}.` : `Store coverage is incomplete for ${missingDates.length} requested dates.`;
546
+ if (json) {
547
+ const available = await store.engine.getWatermarks({
548
+ userId: store.userId,
549
+ siteId: store.siteIdFor(siteUrl)
550
+ });
551
+ console.log(JSON.stringify({ error: {
552
+ code: "STORE_RANGE_NOT_COVERED",
553
+ message,
554
+ siteUrl,
555
+ table,
556
+ range: {
557
+ start: startDate,
558
+ end: endDate
559
+ },
560
+ missingDates,
561
+ watermarks: watermarks.filter((w) => w.table === table),
562
+ availableTables: available.map((w) => ({
563
+ ...w,
564
+ dimensions: tableDimensions(w.table)
565
+ })),
566
+ nextArgs,
567
+ nextCommand
568
+ } }, null, 2));
515
569
  }
570
+ logger.error(`${message} Run ${nextCommand}, or pass --live.`);
571
+ process.exit(1);
516
572
  }
517
573
  async function runRawSqlMode(opts) {
518
574
  if (!isKnownTable(opts.table)) {
@@ -1,6 +1,6 @@
1
1
  import { terminalOutputOptions } from "../render/terminal.mjs";
2
2
  import { OUTPUT_ARGS, applyOutputMode, displayPath, formatAge, logger } from "../utils.mjs";
3
- import { allTables } from "../local-store.mjs";
3
+ import { allTables, tableDimensions } from "../local-store.mjs";
4
4
  import { createCommandContext } from "../context.mjs";
5
5
  import { renderTable, textLines } from "../render/layout.mjs";
6
6
  import { formatMetric } from "../render/metrics.mjs";
@@ -26,15 +26,11 @@ const statsCommand = defineCommand({
26
26
  const { json } = applyOutputMode(args);
27
27
  const store = (await createCommandContext({ needsStore: true })).store;
28
28
  const allEntries = await store.engine.listAll({ userId: store.userId });
29
- let siteId;
30
- if (args.site) {
31
- const known = new Set(allEntries.filter((entry) => entry.retiredAt === void 0 && entry.siteId !== void 0).map((entry) => entry.siteId));
32
- const candidate = store.siteIdFor(args.site);
33
- if (!known.has(candidate)) {
34
- logger.error(`No local data for --site=${args.site}. Known site IDs: ${known.size === 0 ? "(none — run `gscdump sync` first)" : Array.from(known).join(", ")}`);
35
- process.exit(1);
36
- }
37
- siteId = candidate;
29
+ const knownSites = [...new Set(allEntries.filter((entry) => entry.retiredAt === void 0 && entry.siteId !== void 0).map((entry) => entry.siteId))];
30
+ const siteId = args.site ? store.siteIdFor(args.site) : void 0;
31
+ if (args.site && !json && !knownSites.includes(siteId)) {
32
+ logger.error(`No local data for --site=${args.site}. Known site IDs: ${knownSites.length === 0 ? "(none — run `gscdump sync` first)" : knownSites.join(", ")}`);
33
+ process.exit(1);
38
34
  }
39
35
  const selectedEntries = siteId === void 0 ? allEntries : allEntries.filter((entry) => entry.siteId === siteId);
40
36
  const perTable = allTables().map((table) => {
@@ -48,16 +44,17 @@ const statsCommand = defineCommand({
48
44
  const [watermarks, disk] = await Promise.all([store.engine.getWatermarks({
49
45
  userId: store.userId,
50
46
  siteId
51
- }), filesystemStats(store.dataDir).catch(() => ({
52
- files: 0,
53
- bytes: 0
54
- }))]);
47
+ }), filesystemStats(store.dataDir)]);
55
48
  if (json) {
56
49
  const payload = {
57
50
  dataDir: store.dataDir,
51
+ siteId: siteId ?? null,
52
+ knownSites,
53
+ nextCommand: `gscdump sync${args.site ? ` --site '${String(args.site).replaceAll("'", "'\\''")}'` : ""} --status --json`,
58
54
  disk,
59
55
  tables: perTable.map(({ table, live, retired }) => ({
60
56
  table,
57
+ dimensions: tableDimensions(table),
61
58
  liveFiles: live.length,
62
59
  liveRows: sumRows(live),
63
60
  liveBytes: sumBytes(live),
@@ -65,6 +62,7 @@ const statsCommand = defineCommand({
65
62
  retiredBytes: sumBytes(retired),
66
63
  watermarks: watermarks.filter((w) => w.table === table).map((w) => ({
67
64
  siteId: w.siteId ?? null,
65
+ searchType: w.searchType ?? "web",
68
66
  newestDateSynced: w.newestDateSynced,
69
67
  oldestDateSynced: w.oldestDateSynced,
70
68
  lastSyncAt: w.lastSyncAt
@@ -9,6 +9,7 @@ import { resetCommand, rmSiteCommand } from "./store-purge.mjs";
9
9
  import { defineCommand } from "citty";
10
10
  const storeCommand = defineCommand({
11
11
  meta: storeCommandMeta,
12
+ args: statsCommand.args,
12
13
  subCommands: {
13
14
  "stats": statsCommand,
14
15
  "compact": compactCommand,
@@ -276,10 +276,6 @@ const syncCommand = defineCommand({
276
276
  }
277
277
  types.push(t);
278
278
  }
279
- if (types.length === 0) {
280
- logger.warn(`All requested types (${requestedTypes.join(", ")}) are marked empty for this site. Pass --force-types to re-probe.`);
281
- return;
282
- }
283
279
  if (skippedTypes.length > 0 && !quiet) logger.info(`Skipping ${skippedTypes.join(", ")} (marked empty for this site; pass --force-types to re-probe).`);
284
280
  const endDate = args.end ? String(args.end) : daysAgoUtc(DEFAULT_PENDING_DAYS);
285
281
  let startDate;
@@ -292,6 +288,32 @@ const syncCommand = defineCommand({
292
288
  logger.error(`No dates to sync (start=${startDate}, end=${endDate})`);
293
289
  process.exit(1);
294
290
  }
291
+ const printCompletion = async (status, totals, reason, rollupError) => {
292
+ if (!json) return;
293
+ console.log(JSON.stringify({
294
+ status,
295
+ ...reason ? { reason } : {},
296
+ siteUrl,
297
+ range: {
298
+ start: startDate,
299
+ end: endDate
300
+ },
301
+ tables,
302
+ types,
303
+ skippedTypes,
304
+ totals,
305
+ watermarks: await store.engine.getWatermarks({
306
+ userId: store.userId,
307
+ siteId
308
+ }),
309
+ ...rollupError ? { rollupError } : {}
310
+ }, null, 2));
311
+ };
312
+ if (types.length === 0) {
313
+ if (!quiet) logger.warn(`All requested types are marked empty. Pass --force-types to check them again.`);
314
+ await printCompletion("skipped", {}, "empty-types");
315
+ return;
316
+ }
295
317
  if (args["retry-failed"]) {
296
318
  const failedSet = /* @__PURE__ */ new Set();
297
319
  const selectedTables = new Set(tables);
@@ -303,7 +325,8 @@ const syncCommand = defineCommand({
303
325
  for (const s of states) if (selectedTables.has(s.table) && selectedTypes.has(s.searchType ?? "web") && s.state === "failed" && s.date >= startDate && s.date <= endDate) failedSet.add(s.date);
304
326
  dates = dates.filter((d) => failedSet.has(d));
305
327
  if (dates.length === 0) {
306
- logger.success("No failed dates in range — nothing to retry.");
328
+ if (!quiet) logger.success("No failed dates in range. Nothing to retry.");
329
+ await printCompletion("skipped", {}, "no-failed-dates");
307
330
  return;
308
331
  }
309
332
  args.force = true;
@@ -416,6 +439,7 @@ const syncCommand = defineCommand({
416
439
  }
417
440
  }
418
441
  const noRollups = Boolean(args["no-rollups"]);
442
+ let rollupError;
419
443
  const anyRowsSynced = Object.values(totals).some((t) => t.rows > 0);
420
444
  if (!noRollups && anyRowsSynced) {
421
445
  if (!quiet) logger.info(`Rebuilding rollups for [${siteId}] (${DEFAULT_ROLLUPS.length} rollups)…`);
@@ -442,6 +466,7 @@ const syncCommand = defineCommand({
442
466
  },
443
467
  defs: DEFAULT_ROLLUPS
444
468
  }).catch((err) => {
469
+ rollupError = err.message;
445
470
  logger.warn(`Rollup rebuild failed: ${err.message}`);
446
471
  return [];
447
472
  });
@@ -451,7 +476,8 @@ const syncCommand = defineCommand({
451
476
  logger.success(`Rebuilt ${results.length} rollup(s) in ${ms}ms — ${kb.toFixed(1)} KB`);
452
477
  }
453
478
  }
454
- if (anyFailed) process.exit(1);
479
+ await printCompletion(anyFailed || rollupError ? "failed" : "completed", totals, void 0, rollupError);
480
+ if (anyFailed || rollupError) process.exit(1);
455
481
  }
456
482
  });
457
483
  function isKnownTable(name) {
package/dist/config.mjs CHANGED
@@ -27,8 +27,13 @@ const configSchema = z.strictObject({
27
27
  ]).optional(),
28
28
  serviceAccountPath: z.string().optional()
29
29
  });
30
+ const RETIRED_KEYS = ["mode", "cloudUrl"];
31
+ function dropRetiredKeys(value) {
32
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
33
+ return Object.fromEntries(Object.entries(value).filter(([key]) => !RETIRED_KEYS.includes(key)));
34
+ }
30
35
  function parseConfig(value) {
31
- const parsed = configSchema.safeParse(value);
36
+ const parsed = configSchema.safeParse(dropRetiredKeys(value));
32
37
  if (!parsed.success) {
33
38
  const issues = parsed.error.issues.map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`);
34
39
  throw new Error(`Invalid config at ${getConfigPath()}. ${issues.join("; ")}`);
@@ -75,7 +75,7 @@ var LocalStoreUnsupportedError = class extends Error {
75
75
  tool;
76
76
  mode;
77
77
  constructor(tool, mode) {
78
- super(`analysis "${tool}" has no implementation for the ${mode} source`);
78
+ super(mode === "live" ? `The live API cannot run analysis "${tool}". Run gscdump sync, then retry without --live.` : `Local data cannot run analysis "${tool}".`);
79
79
  this.name = "LocalStoreUnsupportedError";
80
80
  this.tool = tool;
81
81
  this.mode = mode;
@@ -0,0 +1,55 @@
1
+ import { z } from "zod";
2
+ const ORIGIN = "https://gscdump.com";
3
+ const accessSchema = z.object({
4
+ accessToken: z.string().min(1),
5
+ expiresAt: z.number().int().positive()
6
+ });
7
+ const pollSchema = z.discriminatedUnion("status", [z.object({ status: z.literal("pending") }), z.object({
8
+ status: z.literal("complete"),
9
+ tokens: accessSchema.extend({ refreshToken: z.string().min(1) })
10
+ })]);
11
+ async function requestJson(request, route, init = {}) {
12
+ const response = await request(`${ORIGIN}/api/cli/auth/${route}`, {
13
+ ...init,
14
+ redirect: "error",
15
+ signal: AbortSignal.timeout(3e4)
16
+ });
17
+ if (!response.ok) {
18
+ if (response.status === 429 || response.status >= 500) throw new Error("Google authorization is temporarily unavailable. Try again later.");
19
+ throw new Error("Google authorization failed. Run `gscdump auth login --mode local --force` to reconnect.");
20
+ }
21
+ return response.json();
22
+ }
23
+ async function refreshWithPlatform(refreshToken, request = fetch) {
24
+ const result = accessSchema.parse(await requestJson(request, "refresh", {
25
+ method: "POST",
26
+ headers: { "Content-Type": "application/json" },
27
+ body: JSON.stringify({ refreshToken })
28
+ }));
29
+ return {
30
+ access_token: result.accessToken,
31
+ expiry_date: result.expiresAt
32
+ };
33
+ }
34
+ async function loginWithPlatform(deps) {
35
+ const init = z.object({
36
+ code: z.string().regex(/^[A-F0-9]{20}$/),
37
+ expiresIn: z.number().int().positive().max(600)
38
+ }).parse(await requestJson(deps.request, "init", { method: "POST" }));
39
+ const deadline = deps.now() + init.expiresIn * 1e3;
40
+ const redirect = `/app/cli/auth?code=${init.code}`;
41
+ const route = deps.force ? `/auth/google?reauth=1&redirect=${encodeURIComponent(redirect)}` : redirect;
42
+ await deps.authorize(`${ORIGIN}${route}`);
43
+ while (deps.now() < deadline) {
44
+ const result = pollSchema.parse(await requestJson(deps.request, `poll?code=${init.code}`));
45
+ if (result.status === "complete") return {
46
+ provider: "gscdump",
47
+ access_token: result.tokens.accessToken,
48
+ refresh_token: result.tokens.refreshToken,
49
+ expiry_date: result.tokens.expiresAt
50
+ };
51
+ await deps.wait(2e3);
52
+ }
53
+ throw new Error("Authorization expired. Run `gscdump auth login` to try again.");
54
+ }
55
+ export { loginWithPlatform, refreshWithPlatform };
@@ -1,7 +1,18 @@
1
1
  import { createNodeHarness } from "@gscdump/engine/node";
2
+ import { SCHEMAS, allTables, inferTable } from "@gscdump/engine/schema";
2
3
  import { TABLE_DIMS, assembleDatesRow } from "@gscdump/engine/ingest";
3
- import { allTables, inferTable } from "@gscdump/engine/schema";
4
+ function tableDimensions(table) {
5
+ return SCHEMAS[table].columns.map((column) => column.name === "url" ? "page" : column.name === "search_appearance" ? "searchAppearance" : column.name).filter((name) => [
6
+ "page",
7
+ "query",
8
+ "date",
9
+ "hour",
10
+ "country",
11
+ "device",
12
+ "searchAppearance"
13
+ ].includes(name));
14
+ }
4
15
  function createLocalStore(opts) {
5
16
  return createNodeHarness(opts);
6
17
  }
7
- export { TABLE_DIMS, allTables, assembleDatesRow, createLocalStore, inferTable };
18
+ export { TABLE_DIMS, allTables, assembleDatesRow, createLocalStore, inferTable, tableDimensions };
package/dist/package.mjs CHANGED
@@ -1,2 +1,2 @@
1
- var version = "3.6.2";
1
+ var version = "3.7.0";
2
2
  export { version };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/cli",
3
3
  "type": "module",
4
- "version": "3.6.2",
4
+ "version": "3.7.0",
5
5
  "description": "CLI for Google Search Console and Bing with hosted or local authentication, data exports, and an MCP server",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -44,15 +44,15 @@
44
44
  "dependencies": {
45
45
  "@clack/prompts": "^1.8.0",
46
46
  "@duckdb/node-api": "1.5.5-r.4",
47
- "@gscdump/analysis": "^3.6.2",
48
- "@gscdump/engine": "^3.6.2",
49
- "@gscdump/engine-gsc-api": "^3.6.2",
50
- "@gscdump/sdk": "^3.6.2",
47
+ "@gscdump/analysis": "^3.7.0",
48
+ "@gscdump/engine": "^3.7.0",
49
+ "@gscdump/engine-gsc-api": "^3.7.0",
50
+ "@gscdump/sdk": "^3.7.0",
51
51
  "@modelcontextprotocol/sdk": "^1.30.0",
52
52
  "citty": "^0.2.2",
53
53
  "consola": "^3.4.2",
54
54
  "google-auth-library": "^11.0.2",
55
- "gscdump": "^3.6.2",
55
+ "gscdump": "^3.7.0",
56
56
  "ofetch": "^1.5.1",
57
57
  "open": "^11.0.2",
58
58
  "sitemapd": "^0.2.2",
@@ -7,6 +7,24 @@ description: Drive the `gscdump` CLI for Google Search Console and Bing with clo
7
7
 
8
8
  `gscdump` reads Google Search Console and Bing with hosted or local authentication.
9
9
  It keeps a local Parquet Store for Google rows. Every command has `--help`.
10
+ For `query`, `-s` means `--site`, `-d` means `--dimensions`, and `-f` means `--format`.
11
+ Use `--start` and `--end` for dates. `--site=SITE` also works.
12
+ Use each option once, with either its short or long spelling.
13
+ Example: `gscdump query --site=SITE --start=DATE --end=DATE -d page -f json`.
14
+
15
+ ## Start each task
16
+
17
+ 1. Before reading traffic, run `gscdump auth status --json`. Do this even when the user says authentication works.
18
+ 2. Keep the requested Site, dates, dimensions, and task scope. A request for pages does not need query dimensions.
19
+ 3. Before local queries, check coverage with `gscdump store stats --site SITE --json`.
20
+ Use `gscdump sync --site SITE --status --json` when you need sync-state details.
21
+ 4. Read the table dimensions and watermarks. Sync only missing tables and the requested dates, once per task.
22
+ 5. Use `sync --json`. Read its completion result before deciding what to do next. Never repeat a successful sync.
23
+
24
+ If the task only asks about deletion, explain the scope and ask for consent.
25
+ You may read Store metadata with `store stats` and `sync --status`.
26
+ Do not query traffic, sync rows, or delete data to explain deletion.
27
+ Call the local data directory the Store in your answer.
10
28
 
11
29
  ## Authentication mode
12
30
 
@@ -104,10 +122,13 @@ If local Google credentials are missing, use one of these paths:
104
122
  | Service account | CI with a service-account key that has Site access | `export GOOGLE_APPLICATION_CREDENTIALS=/abs/path/key.json` |
105
123
  | Interactive OAuth | A person is present | `gscdump init --mode local` |
106
124
 
107
- `init` needs a Google Cloud OAuth client of type Desktop app. Ask the user to
108
- run it; do not guess client credentials. Use `gscdump auth login --mode local --no-browser`
109
- when a browser cannot open.
110
- Run `gscdump auth login --mode local` to save local mode after configuring credentials.
125
+ Default local login opens gscdump.com for free Google login and token refresh.
126
+ Data queries call Google directly. No Google Cloud project or hosted activation is required.
127
+ Use `gscdump auth login --mode local --no-browser` when a browser runs on another host.
128
+ The default grant is read-only Search Console access.
129
+ For Google write operations, use your own OAuth client with the required scopes.
130
+ Set `GSC_CLIENT_ID` and `GSC_CLIENT_SECRET` to use a Desktop app OAuth client.
131
+ Cloud mode requires hosted access. Pro is free during beta, then paid after launch.
111
132
 
112
133
  `--profile <name>` or `GSCDUMP_PROFILE` isolates the selected mode and Google, Bing, and cloud credentials.
113
134
 
@@ -130,11 +151,14 @@ gscdump config set defaultSite sc-domain:example.com
130
151
 
131
152
  ## Output
132
153
 
133
- Pass `--json` on every command that supports it. `query` uses
154
+ Pass `--json` on every command that supports it. `sync --json` includes completion, row counts, skipped dates, and failures.
155
+ `query` uses
134
156
  `--format json` and prints rows to stdout. Progress goes to stderr, so stdout
135
157
  stays parseable. `--quiet` drops progress lines.
136
158
 
137
159
  Parse JSON. Never scrape human output.
160
+ If the user requests JSON, return the CLI JSON unchanged. Do not replace it with a table.
161
+ Do not rewrite rows, estimate metrics, or add manually calculated totals.
138
162
 
139
163
  ## Commands
140
164
 
@@ -172,9 +196,10 @@ The MCP server does not expose Bing tools. Use `gscdump bing` commands through t
172
196
  ## Sync before local analysis
173
197
 
174
198
  ```sh
199
+ gscdump store stats --site sc-domain:example.com --json
175
200
  gscdump sync --site sc-domain:example.com --days 90 \
176
- --tables pages,queries,page_queries,countries
177
- gscdump sync --site sc-domain:example.com --status
201
+ --tables pages,queries,page_queries,countries --json
202
+ gscdump sync --site sc-domain:example.com --status --json
178
203
  ```
179
204
 
180
205
  - Pass an explicit `--tables` list. The default list has a known daily-totals
@@ -183,6 +208,8 @@ gscdump sync --site sc-domain:example.com --status
183
208
  - Sync skips completed dates. `--force` refreshes them. `--retry-failed`
184
209
  reruns only failed dates.
185
210
  - `--dry-run` prints the planned work without calling Google.
211
+ - Use the user's date range. The 90-day example does not authorize a wider sync.
212
+ - Empty Store metadata is expected before the first sync. It does not prove zero traffic.
186
213
 
187
214
  ## Query rows
188
215
 
@@ -195,8 +222,11 @@ gscdump query --site sc-domain:example.com --dimensions page,query \
195
222
  - Filters: `--query`, `--page`, `--country`, `--device`,
196
223
  `--search-appearance`. Prefixes: bare equals, `~` contains, `!~` not
197
224
  contains, `re:` regex, `!re:` not regex, `!` not equals.
198
- - `--live` bypasses the Store. `--search-type`, `--data-state`, and
199
- `--aggregation-type` apply to live mode only.
225
+ - `--live` bypasses the Store. `--type` selects a search type.
226
+ `--data-state` and `--aggregation-type` apply to live mode only.
227
+ - Metrics already include clicks, impressions, CTR, and position. There is no `--metrics` option.
228
+ - If Store coverage is missing, read the JSON error and its bounded `nextArgs` before syncing.
229
+ Do not switch dimensions to make a failed query succeed.
200
230
  - `--explain` prints the request body or planned SQL without executing.
201
231
  - `--sql` runs raw DuckDB SQL over the Store with `{{FILES}}` as the file list.
202
232
 
@@ -270,9 +300,9 @@ the failure and continue. Never retry an uncertain submission.
270
300
  delete local data. `--yes` is consent you borrow from the user.
271
301
  - **Never loop unattended.** One `sync` per Site per task. Inspection batches
272
302
  spend a daily pool. Use `--dry-run` and `--explain` to plan first.
273
- - **Report the result as the CLI gave it.** An empty result is not a clean
274
- Site. Check `sync --status` for the covered date range before reading zero
275
- rows as zero traffic.
303
+ - **Report the result as the CLI gave it.** Zero clicks with impressions means rows exist with no clicks.
304
+ An empty row array means no rows matched. Missing coverage means the Store cannot answer that date range.
305
+ Check `sync --status --json` before interpreting empty rows. None of these results proves a clean Site.
276
306
  - **Do not widen the Site.** A `sc-domain:` property includes every
277
307
  subdomain. Filter with `--page` when the user means one host.
278
308
 
@@ -285,3 +315,14 @@ Hosted exports follow pagination and reject missing, unavailable, or changing da
285
315
  Hosted date ranges span at most 366 days. The default range is the last 366 days.
286
316
  Local date filters only narrow data currently returned by Bing.
287
317
  Do not treat Bing crawl evidence as proof that a URL is indexed.
318
+
319
+ ## Before the next command
320
+
321
+ For a traffic task, run `gscdump auth status --json` now, before any `query` command.
322
+ The user saying credentials work does not replace this check. It identifies the selected authentication mode.
323
+ Then check Store metadata, keep the requested dimensions, and read or sync only the requested dates.
324
+ If the user requests JSON, return the command's JSON unchanged, without a table or a separate totals summary.
325
+ Include that JSON in your final response. Tool output alone is not a final answer.
326
+ Include every returned row. Do not refer the user to results "above".
327
+
328
+ For a deletion explanation, read metadata only if needed. Explain the scope and ask for consent, then stop.