@gscdump/cli 5.0.0 → 5.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.
Files changed (41) hide show
  1. package/dist/analysis-local.mjs +2 -2
  2. package/dist/auth-state.mjs +60 -7
  3. package/dist/bing-hosted.mjs +26 -5
  4. package/dist/cli.d.mts +6 -0
  5. package/dist/cli.mjs +47 -6
  6. package/dist/command-meta.mjs +2 -2
  7. package/dist/commands/analyze.mjs +2 -2
  8. package/dist/commands/auth.mjs +13 -14
  9. package/dist/commands/bing.mjs +7 -2
  10. package/dist/commands/config.mjs +5 -15
  11. package/dist/commands/doctor.mjs +2 -2
  12. package/dist/commands/dump.mjs +2 -4
  13. package/dist/commands/entities.mjs +3 -9
  14. package/dist/commands/indexing-urls.mjs +2 -2
  15. package/dist/commands/indexing-watch.mjs +10 -7
  16. package/dist/commands/indexing.mjs +6 -22
  17. package/dist/commands/init.mjs +3 -6
  18. package/dist/commands/mcp.mjs +1 -1
  19. package/dist/commands/papercut.mjs +2 -5
  20. package/dist/commands/profile.mjs +5 -14
  21. package/dist/commands/query.mjs +16 -40
  22. package/dist/commands/report.mjs +2 -2
  23. package/dist/commands/sitemaps.mjs +3 -10
  24. package/dist/commands/sites.mjs +7 -12
  25. package/dist/commands/skill.mjs +1 -4
  26. package/dist/commands/sync.mjs +2 -8
  27. package/dist/config.mjs +1 -1
  28. package/dist/context.mjs +3 -3
  29. package/dist/error-handler.mjs +72 -16
  30. package/dist/hosted-auth.mjs +9 -1
  31. package/dist/hosted-session.mjs +17 -0
  32. package/dist/hosted-site.mjs +41 -18
  33. package/dist/mcp/errors.mjs +1 -1
  34. package/dist/package.mjs +1 -1
  35. package/dist/route.mjs +5 -9
  36. package/dist/runtime.d.mts +2 -0
  37. package/dist/runtime.mjs +1 -0
  38. package/dist/stop.mjs +23 -0
  39. package/dist/utils.mjs +13 -14
  40. package/package.json +7 -7
  41. package/skills/gscdump/SKILL.md +43 -4
@@ -1,3 +1,4 @@
1
+ import { commandLineError } from "../stop.mjs";
1
2
  import { indexingCommandMeta } from "../command-meta.mjs";
2
3
  import { OUTPUT_ARGS, applyOutputMode, logger, parseIntegerOption, readUrlList } from "../utils.mjs";
3
4
  import { createCommandContext } from "../context.mjs";
@@ -5,7 +6,6 @@ import { loadSitemapUrls } from "../sitemap.mjs";
5
6
  import { indexingSummaryCommand } from "./indexing-summary.mjs";
6
7
  import { indexingUrlsCommand } from "./indexing-urls.mjs";
7
8
  import { indexingWatchCommand } from "./indexing-watch.mjs";
8
- import process from "node:process";
9
9
  import { defineCommand } from "citty";
10
10
  import { batchRequestIndexing, getIndexingMetadata, requestIndexing, runSequentialBatch } from "gscdump/indexing";
11
11
  const RETRIES_ARG = { retries: {
@@ -16,10 +16,7 @@ async function resolveUrlSource(args) {
16
16
  const fromSitemap = args["from-sitemap"];
17
17
  if (fromSitemap) {
18
18
  const result = await loadSitemapUrls(String(fromSitemap));
19
- if (result._tag === "error") {
20
- logger.error(`Sitemap fetch failed: ${result.message}`);
21
- process.exit(1);
22
- }
19
+ if (result._tag === "error") throw new Error(`Sitemap fetch failed: ${result.message}`);
23
20
  if (!result.value.complete) logger.warn("Sitemap walk was incomplete; indexing only the URLs that were read");
24
21
  return result.value.urls;
25
22
  }
@@ -214,20 +211,10 @@ const batchCommand = defineCommand({
214
211
  const delayMs = parseIntegerOption(args["delay-ms"], "--delay-ms", 0) ?? 100;
215
212
  const concurrency = parseIntegerOption(args.concurrency, "--concurrency") ?? 1;
216
213
  const urls = await resolveUrlSource(args);
217
- if (urls.length === 0) {
218
- logger.error("No URLs provided. Pass URLs as args, --file, --from-sitemap, or stdin.");
219
- process.exit(1);
220
- }
214
+ if (urls.length === 0) throw commandLineError("No URLs provided. Pass URLs as args, --file, --from-sitemap, or stdin.");
221
215
  const type = String(args.type);
222
- if (type !== "URL_UPDATED" && type !== "URL_DELETED") {
223
- logger.error(`Invalid --type: ${type}. Use URL_UPDATED or URL_DELETED.`);
224
- process.exit(1);
225
- }
226
- if (urls.length > INDEXING_DAILY_QUOTA && !args.yes && !args.json) {
227
- logger.warn(`Submitting ${urls.length} URLs but the Indexing API daily quota is ${INDEXING_DAILY_QUOTA}/day.`);
228
- logger.warn(`Excess submissions will fail with quota errors. Pass --yes to proceed anyway.`);
229
- process.exit(1);
230
- }
216
+ if (type !== "URL_UPDATED" && type !== "URL_DELETED") throw commandLineError(`Invalid --type: ${type}. Use URL_UPDATED or URL_DELETED.`);
217
+ if (urls.length > INDEXING_DAILY_QUOTA && !args.yes && !args.json) throw commandLineError(`Submitting ${urls.length} URLs but the Indexing API daily quota is ${INDEXING_DAILY_QUOTA}/day. Excess submissions will fail with quota errors. Pass --yes to proceed anyway.`);
231
218
  if (urls.length > INDEXING_DAILY_QUOTA && !args.json && !args.quiet) logger.warn(`Proceeding with ${urls.length} URLs (over the ${INDEXING_DAILY_QUOTA}/day quota). Excess will fail.`);
232
219
  const ctx = await createCommandContext({
233
220
  needsAuth: true,
@@ -290,10 +277,7 @@ const batchStatusCommand = defineCommand({
290
277
  const delayMs = parseIntegerOption(args["delay-ms"], "--delay-ms", 0) ?? 100;
291
278
  const concurrency = parseIntegerOption(args.concurrency, "--concurrency") ?? 1;
292
279
  const urls = await resolveUrlSource(args);
293
- if (urls.length === 0) {
294
- logger.error("No URLs provided. Pass URLs as args, --file, --from-sitemap, or stdin.");
295
- process.exit(1);
296
- }
280
+ if (urls.length === 0) throw commandLineError("No URLs provided. Pass URLs as args, --file, --from-sitemap, or stdin.");
297
281
  const ctx = await createCommandContext({
298
282
  needsAuth: true,
299
283
  fetchOptions: { retry }
@@ -1,6 +1,6 @@
1
- import { initCommandMeta } from "../command-meta.mjs";
2
1
  import { useCliRuntime } from "../runtime.mjs";
3
2
  import { saveAuthentication } from "../auth-state.mjs";
3
+ import { initCommandMeta } from "../command-meta.mjs";
4
4
  import { defaultDataDir, loadConfig, saveConfig } from "../config.mjs";
5
5
  import { OUTPUT_ARGS, applyOutputMode, logger } from "../utils.mjs";
6
6
  import { ACCESS_NOT_SET_UP, getAuth, loadTokens, resolveBYOK, resolveServiceAccount } from "../auth.mjs";
@@ -100,10 +100,7 @@ const initCommand = defineCommand({
100
100
  return;
101
101
  }
102
102
  if (!canPrompt()) {
103
- if (!await loadTokens()) {
104
- logger.error(`Init cannot prompt without a terminal.\n${ACCESS_NOT_SET_UP}`);
105
- process.exit(1);
106
- }
103
+ if (!await loadTokens()) throw new Error(`Init cannot prompt without a terminal.\n${ACCESS_NOT_SET_UP}`);
107
104
  await saveConfig({
108
105
  ...config,
109
106
  dataDir: config.dataDir ?? defaultDataDir()
@@ -114,7 +111,7 @@ const initCommand = defineCommand({
114
111
  return;
115
112
  }
116
113
  console.log();
117
- console.log(" \x1B[1mWelcome to GSCDump!\x1B[0m");
114
+ console.log(" \x1B[1mWelcome to gscdump\x1B[0m");
118
115
  console.log(" \x1B[90mGoogle Search Console data extraction CLI\x1B[0m");
119
116
  console.log();
120
117
  const dataDir = args.store ? await promptDataDir(config.dataDir) : void 0;
@@ -1,5 +1,5 @@
1
- import { mcpCommandMeta } from "../command-meta.mjs";
2
1
  import { runWithCliRuntime, useCliRuntime } from "../runtime.mjs";
2
+ import { mcpCommandMeta } from "../command-meta.mjs";
3
3
  import { VERSION } from "../utils.mjs";
4
4
  import { probeAuth } from "../auth.mjs";
5
5
  import { createCommandContext } from "../context.mjs";
@@ -1,5 +1,5 @@
1
- import { papercutCommandMeta } from "../command-meta.mjs";
2
1
  import { useCliRuntime } from "../runtime.mjs";
2
+ import { papercutCommandMeta } from "../command-meta.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";
@@ -62,10 +62,7 @@ const papercutCommand = defineCommand({
62
62
  fetch: globalThis.fetch,
63
63
  url
64
64
  });
65
- if (result._tag === "Err") {
66
- logger.error(result.message);
67
- process.exit(1);
68
- }
65
+ if (result._tag === "Err") throw new Error(result.message);
69
66
  if (json) {
70
67
  console.log(JSON.stringify(result.receipt));
71
68
  return;
@@ -1,5 +1,6 @@
1
- import { profileCommandMeta } from "../command-meta.mjs";
2
1
  import { useCliRuntime } from "../runtime.mjs";
2
+ import { commandLineError } from "../stop.mjs";
3
+ import { profileCommandMeta } from "../command-meta.mjs";
3
4
  import { getConfigDir, setConfigDir } from "../config.mjs";
4
5
  import { resolveCliEnvironment } from "../environment.mjs";
5
6
  import { ACTIVE_MARKER, PROFILES_DIR, ROOT_DIR, getProfileDir, readActiveMarkerSync } from "./profile-selection.mjs";
@@ -100,10 +101,7 @@ const pathCmd = defineCommand({
100
101
  } },
101
102
  async run({ args }) {
102
103
  const name = args.name ? String(args.name) : resolveActiveProfile();
103
- if (!name) {
104
- logger.error("No profile specified and none active (set --profile, GSCDUMP_PROFILE, or run `gscdump profile use <name>`)");
105
- process.exit(1);
106
- }
104
+ if (!name) throw commandLineError("No profile specified and none active (set --profile, GSCDUMP_PROFILE, or run `gscdump profile use <name>`)");
107
105
  console.log(getProfileDir(name));
108
106
  }
109
107
  });
@@ -135,11 +133,7 @@ const useCmd = defineCommand({
135
133
  applyOutputMode(args);
136
134
  const name = String(args.name);
137
135
  const dir = getProfileDir(name);
138
- if (!await fs.stat(dir).then(() => true).catch(() => false)) {
139
- logger.error(`Profile not found: ${name}`);
140
- logger.info(`Create it with: gscdump profile create ${name}`);
141
- process.exit(1);
142
- }
136
+ if (!await fs.stat(dir).then(() => true).catch(() => false)) throw new Error(`Profile not found: ${name}. Create it with: gscdump profile create ${name}`);
143
137
  await setActiveProfile(name);
144
138
  logger.success(`Active profile: ${name}`);
145
139
  }
@@ -207,10 +201,7 @@ const deleteCmd = defineCommand({
207
201
  applyOutputMode(args);
208
202
  const name = String(args.name);
209
203
  const dir = getProfileDir(name);
210
- if (!await fs.stat(dir).then(() => true).catch(() => false)) {
211
- logger.error(`Profile not found: ${name}`);
212
- process.exit(1);
213
- }
204
+ if (!await fs.stat(dir).then(() => true).catch(() => false)) throw new Error(`Profile not found: ${name}`);
214
205
  if (!args.yes) {
215
206
  const ok = await confirm({
216
207
  message: `Delete profile "${name}" at ${dir}? Tokens and config will be lost.`,
@@ -1,10 +1,11 @@
1
- import { queryCommandMeta } from "../command-meta.mjs";
2
- import { queryHostedRows } from "../hosted-query.mjs";
3
1
  import { useCliRuntime } from "../runtime.mjs";
2
+ import { commandLineError } from "../stop.mjs";
4
3
  import { resolveAuthentication } from "../auth-state.mjs";
4
+ import { queryCommandMeta } from "../command-meta.mjs";
5
5
  import { loadConfig } from "../config.mjs";
6
+ import { queryHostedRows } from "../hosted-query.mjs";
6
7
  import { terminalOutputOptions } from "../render/terminal.mjs";
7
- import { ALL_SEARCH_TYPES, logger, parseSearchType, toCSV } from "../utils.mjs";
8
+ import { ALL_SEARCH_TYPES, logger, parseSearchType, setJsonOutput, toCSV } from "../utils.mjs";
8
9
  import { allTables } from "../local-store.mjs";
9
10
  import { createCommandContext, formatSiteResolution } from "../context.mjs";
10
11
  import { decideRoute, describeStop, liveNote, readRouteState, readSiteStates, resolveReadSite, stopAtRoute } from "../route.mjs";
@@ -211,10 +212,8 @@ const queryCommand = defineCommand({
211
212
  async run({ args }) {
212
213
  const ctxConfig = await loadConfig();
213
214
  const format = args.format ?? ctxConfig.defaultFormat ?? "json";
214
- if (format !== "json" && format !== "csv" && format !== "table") {
215
- logger.error("Invalid --format. Use table, json, or csv.");
216
- process.exit(1);
217
- }
215
+ if (format !== "json" && format !== "csv" && format !== "table") throw commandLineError("Invalid --format. Use table, json, or csv.");
216
+ setJsonOutput(format === "json");
218
217
  if (args.sql || args.schema) {
219
218
  await runSqlMode({
220
219
  mode: args.schema ? { kind: "schema" } : {
@@ -233,31 +232,19 @@ const queryCommand = defineCommand({
233
232
  const dimNames = await resolveDimensions(args);
234
233
  const windowFlags = await promptRange(args);
235
234
  const invalidWindow = checkWindowFlags(windowFlags);
236
- if (invalidWindow) {
237
- logger.error(invalidWindow.message);
238
- process.exit(1);
239
- }
235
+ if (invalidWindow) throw commandLineError(invalidWindow.message);
240
236
  await promptFilters(args);
241
237
  const limitArg = String(args.limit ?? ctxConfig.defaultLimit ?? 1e3);
242
238
  const rowLimit = Number(limitArg);
243
- if (!POSITIVE_INTEGER_RE.test(limitArg) || !Number.isSafeInteger(rowLimit) || rowLimit < 1) {
244
- logger.error("Invalid --limit. Use a positive safe integer, such as --limit 1000.");
245
- process.exit(1);
246
- }
239
+ if (!POSITIVE_INTEGER_RE.test(limitArg) || !Number.isSafeInteger(rowLimit) || rowLimit < 1) throw commandLineError("Invalid --limit. Use a positive safe integer, such as --limit 1000.");
247
240
  const filters = parseFilterArgs(args);
248
241
  const searchType = parseSearchType(args.type ?? ctxConfig.defaultSearchType, "--type");
249
242
  const localSearchType = searchType ?? "web";
250
243
  const dataState = args["data-state"] ? String(args["data-state"]) : ctxConfig.defaultDataState;
251
244
  const aggregationType = args["aggregation-type"] ? String(args["aggregation-type"]) : void 0;
252
245
  const filterDims = filterDimensions(filters);
253
- if (dataState && !DATA_STATES.includes(dataState)) {
254
- logger.error(`Invalid --data-state: ${dataState}. Allowed: ${DATA_STATES.join(", ")}`);
255
- process.exit(1);
256
- }
257
- if (aggregationType && !AGGREGATION_TYPES.includes(aggregationType)) {
258
- logger.error(`Invalid --aggregation-type: ${aggregationType}. Allowed: ${AGGREGATION_TYPES.join(", ")}`);
259
- process.exit(1);
260
- }
246
+ if (dataState && !DATA_STATES.includes(dataState)) throw commandLineError(`Invalid --data-state: ${dataState}. Allowed: ${DATA_STATES.join(", ")}`);
247
+ if (aggregationType && !AGGREGATION_TYPES.includes(aggregationType)) throw commandLineError(`Invalid --aggregation-type: ${aggregationType}. Allowed: ${AGGREGATION_TYPES.join(", ")}`);
261
248
  const forceLive = Boolean(args.live);
262
249
  if (!forceLive && (await resolveAuthentication())._tag === "Hosted") {
263
250
  if (dataState || aggregationType) logger.warn("--data-state / --aggregation-type are ignored without --live");
@@ -341,7 +328,6 @@ const queryCommand = defineCommand({
341
328
  auth
342
329
  });
343
330
  const route = decideRoute(req, routeState);
344
- const json = format === "json";
345
331
  if (route.kind === "live") {
346
332
  const live = await connect();
347
333
  const siteUrl = site ?? await live.resolveSite(void 0);
@@ -406,7 +392,7 @@ const queryCommand = defineCommand({
406
392
  }, null, 2));
407
393
  return;
408
394
  }
409
- if (route.kind === "syncing" || route.kind === "prompt") stopAtRoute(route, req, routeState.auth, { json });
395
+ if (route.kind === "syncing" || route.kind === "prompt") stopAtRoute(route, req, routeState.auth);
410
396
  if (dataState || aggregationType) logger.warn("--data-state / --aggregation-type are ignored without --live");
411
397
  const siteUrl = site;
412
398
  if (!args.quiet) logger.debug(`Querying ${siteUrl} from local Parquet store...`);
@@ -417,10 +403,7 @@ const queryCommand = defineCommand({
417
403
  table,
418
404
  searchType: localSearchType,
419
405
  ...probe ? { profiler: probe.profiler } : {}
420
- }, state).catch((e) => {
421
- logger.error(`Query failed: ${e.message}`);
422
- process.exit(1);
423
- });
406
+ }, state);
424
407
  if (probe) logProfile(probe.spans);
425
408
  await writeOutput({
426
409
  output: {
@@ -443,10 +426,7 @@ const queryCommand = defineCommand({
443
426
  async function resolveDimensions(args) {
444
427
  if (args.dimensions != null) {
445
428
  const dimensions = String(args.dimensions).split(",").map((d) => d.trim());
446
- if (dimensions.some((d) => !DIMENSIONS.includes(d))) {
447
- logger.error(`Invalid --dimensions. Use a comma-separated list from: ${DIMENSIONS.join(", ")}.`);
448
- process.exit(1);
449
- }
429
+ if (dimensions.some((d) => !DIMENSIONS.includes(d))) throw commandLineError(`Invalid --dimensions. Use a comma-separated list from: ${DIMENSIONS.join(", ")}.`);
450
430
  return [...new Set(dimensions)];
451
431
  }
452
432
  if (args.interactive) {
@@ -500,10 +480,7 @@ async function promptRange(args) {
500
480
  }
501
481
  function windowOrExit(flags, anchor) {
502
482
  const window = parseWindowFlags(flags, DEFAULT_WINDOW, anchor);
503
- if (!window.ok) {
504
- logger.error(window.error.message);
505
- process.exit(1);
506
- }
483
+ if (!window.ok) throw commandLineError(window.error.message);
507
484
  return window.value;
508
485
  }
509
486
  async function promptFilters(args) {
@@ -595,7 +572,7 @@ async function runSqlMode(opts) {
595
572
  });
596
573
  if (route.kind === "syncing" || route.kind === "prompt") {
597
574
  views.close();
598
- stopAtRoute(route, req, routeState.auth, { json: opts.format === "json" });
575
+ stopAtRoute(route, req, routeState.auth);
599
576
  }
600
577
  }
601
578
  }
@@ -620,8 +597,7 @@ async function runSql(views, sql) {
620
597
  for (const warning of warnings) logger.warn(warning);
621
598
  return {
622
599
  ...await views.run(sql).catch((e) => {
623
- logger.error(`SQL failed: ${e.message}`);
624
- process.exit(1);
600
+ throw new Error(`SQL failed: ${e.message}`, { cause: e });
625
601
  }),
626
602
  warnings
627
603
  };
@@ -1,6 +1,6 @@
1
1
  import { reportCommandMeta } from "../command-meta.mjs";
2
2
  import { terminalOutputOptions } from "../render/terminal.mjs";
3
- import { logger, parseFetchBudget } from "../utils.mjs";
3
+ import { logger, parseFetchBudget, setJsonOutput } from "../utils.mjs";
4
4
  import { COMPARISON_FLAGS, PERIOD_FLAGS, parseWindowFlags, windowFlagErrorToException } from "../window.mjs";
5
5
  import { analysisNeeds, analyzerSources, analyzerTables, resolveAnalysisSource } from "../analysis-local.mjs";
6
6
  import { renderCliReport } from "../render/report.mjs";
@@ -127,6 +127,7 @@ function makeReportCommand(report) {
127
127
  ...reportArgs
128
128
  },
129
129
  async run({ args }) {
130
+ setJsonOutput(Boolean(args.json));
130
131
  const flags = windowFlags(args);
131
132
  const params = buildReportParams(report, args);
132
133
  const fetchBudget = parseFetchBudget(args["fetch-budget"]);
@@ -158,7 +159,6 @@ function makeReportCommand(report) {
158
159
  const { source, siteUrl, anchor, isLive } = await resolveAnalysisSource({
159
160
  site: args.site,
160
161
  live: !!args.live,
161
- json: !!args.json,
162
162
  label: `report ${report.id}`,
163
163
  types: provisional.map((step) => step.type),
164
164
  sources: reportSources(provisional),
@@ -1,9 +1,8 @@
1
1
  import { sitemapsCommandMeta } from "../command-meta.mjs";
2
2
  import { OUTPUT_ARGS, applyOutputMode, logger, parseIntegerOption, plural } from "../utils.mjs";
3
3
  import { createCommandContext } from "../context.mjs";
4
- import { discoverLiveSitemap, loadSitemapUrls } from "../sitemap.mjs";
5
4
  import { HOSTED_ARGS, resolveHostedSite } from "../hosted-site.mjs";
6
- import process from "node:process";
5
+ import { discoverLiveSitemap, loadSitemapUrls } from "../sitemap.mjs";
7
6
  import { defineCommand } from "citty";
8
7
  import { fetchSitemap } from "gscdump/sites";
9
8
  const HOSTED_SITEMAP_ALTERNATIVE = "read a live sitemap with `gscdump sitemaps urls <sitemap-url>`";
@@ -207,14 +206,8 @@ const discoverCommand = defineCommand({
207
206
  }, null, 2));
208
207
  return;
209
208
  }
210
- if (discovery._tag === "incomplete") {
211
- logger.error(`Sitemap discovery was incomplete: ${discovery.failures[0] ?? "unknown failure"}`);
212
- process.exit(1);
213
- }
214
- if (!url) {
215
- logger.warn(`No sitemap discovered for ${domain}`);
216
- process.exit(1);
217
- }
209
+ if (discovery._tag === "incomplete") throw new Error(`Sitemap discovery was incomplete: ${discovery.failures[0] ?? "unknown failure"}`);
210
+ if (!url) throw new Error(`No sitemap discovered for ${domain}`);
218
211
  logger.success(`Discovered sitemap: ${url}`);
219
212
  }
220
213
  });
@@ -1,5 +1,6 @@
1
+ import { commandLineError } from "../stop.mjs";
2
+ import { connectSiteStep, formatHostedSync, getHostedAccount, localModeRequired, resolveAuthentication, siteManagerOf } from "../auth-state.mjs";
1
3
  import { sitesCommandMeta } from "../command-meta.mjs";
2
- import { LOCAL_MODE_REQUIRED, formatHostedSync, getHostedAccount, resolveAuthentication } from "../auth-state.mjs";
3
4
  import { OUTPUT_ARGS, applyOutputMode, logger, plural } from "../utils.mjs";
4
5
  import { createCommandContext, formatSiteResolution } from "../context.mjs";
5
6
  import process from "node:process";
@@ -20,16 +21,10 @@ function pickDefaultMethod(siteUrl) {
20
21
  }
21
22
  function validateMethod(siteUrl, method) {
22
23
  const upper = method.toUpperCase();
23
- if (!ALL_METHODS.includes(upper)) {
24
- logger.error(`Invalid --method: ${method}. Valid: ${ALL_METHODS.join(", ")}`);
25
- process.exit(1);
26
- }
24
+ if (!ALL_METHODS.includes(upper)) throw commandLineError(`Invalid --method: ${method}. Valid: ${ALL_METHODS.join(", ")}`);
27
25
  const site = siteUrlToVerificationSite(siteUrl);
28
26
  const allowed = verificationMethodsFor(site);
29
- if (!allowed.includes(upper)) {
30
- logger.error(`Method ${upper} not valid for ${site.type === "INET_DOMAIN" ? "domain" : "URL-prefix"} property "${siteUrl}". Valid: ${allowed.join(", ")}`);
31
- process.exit(1);
32
- }
27
+ if (!allowed.includes(upper)) throw commandLineError(`Method ${upper} not valid for ${site.type === "INET_DOMAIN" ? "domain" : "URL-prefix"} property "${siteUrl}". Valid: ${allowed.join(", ")}`);
33
28
  return upper;
34
29
  }
35
30
  function printPlacementInstructions(method, siteUrl, token) {
@@ -422,8 +417,8 @@ const LIST_ARGS = {
422
417
  }
423
418
  };
424
419
  async function runListHostedSites(args, authentication) {
425
- if (args["with-sitemaps"] || args["owner-only"]) throw new Error(`--with-sitemaps and --owner-only read Search Console permissions.\n${LOCAL_MODE_REQUIRED}`);
426
- const { sites } = await getHostedAccount(authentication);
420
+ if (args["with-sitemaps"] || args["owner-only"]) throw localModeRequired("--with-sitemaps and --owner-only read Search Console permissions.");
421
+ const { sites, issuer } = await getHostedAccount(authentication);
427
422
  if (args.json) {
428
423
  console.log(JSON.stringify(sites.map((site) => ({
429
424
  siteId: site.siteId,
@@ -438,7 +433,7 @@ async function runListHostedSites(args, authentication) {
438
433
  return;
439
434
  }
440
435
  if (sites.length === 0) {
441
- logger.warn("Your hosted record has no Sites. Connect a Site at https://gscdump.com/app/onboarding?step=connect-sites.");
436
+ logger.warn(`Your hosted record has no Sites. ${connectSiteStep(siteManagerOf(authentication, issuer))}`);
442
437
  return;
443
438
  }
444
439
  logger.success(`Found ${plural(sites.length, "hosted Site", "hosted Sites")}:`);
@@ -33,10 +33,7 @@ const installCommand = defineCommand({
33
33
  homeDirectory: os.homedir(),
34
34
  target: args.target
35
35
  });
36
- if (result._tag === "Err") {
37
- logger.error(result.message);
38
- process.exit(1);
39
- }
36
+ if (result._tag === "Err") throw new Error(result.message);
40
37
  if (json) {
41
38
  console.log(JSON.stringify(result.installation));
42
39
  return;
@@ -395,10 +395,7 @@ const syncCommand = defineCommand({
395
395
  siteUrls.push(siteUrl);
396
396
  continue;
397
397
  }
398
- if (!args["all-sites"]) {
399
- logger.error(formatSiteIdCollision(claim.error));
400
- process.exit(1);
401
- }
398
+ if (!args["all-sites"]) throw new Error(formatSiteIdCollision(claim.error));
402
399
  logger.warn(`Skipped ${siteUrl}. ${formatSiteIdCollision(claim.error)}`);
403
400
  }
404
401
  const latest = getLatestGscDate();
@@ -416,10 +413,7 @@ const syncCommand = defineCommand({
416
413
  now: Date.now(),
417
414
  isAlive: isProcessAlive
418
415
  });
419
- if (existing.kind === "running") {
420
- logger.error(`Another sync is running (pid ${existing.record.pid}, ${existing.record.done}/${existing.record.planned} days). Wait for it to finish, or run \`gscdump sync --status\`.`);
421
- process.exit(1);
422
- }
416
+ if (existing.kind === "running") throw new Error(`Another sync is running (pid ${existing.record.pid}, ${existing.record.done}/${existing.record.planned} days). Wait for it to finish, or run \`gscdump sync --status\`.`);
423
417
  }
424
418
  const ledger = await openQuotaLedger({ dataDir: store.dataDir });
425
419
  const pacer = createRequestPacer({
package/dist/config.mjs CHANGED
@@ -2,8 +2,8 @@ import { useCliRuntime } from "./runtime.mjs";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
- import { SearchTypes } from "gscdump/query";
6
5
  import os from "node:os";
6
+ import { SearchTypes } from "gscdump/query";
7
7
  function setConfigDir(dir) {
8
8
  useCliRuntime().configDir = dir;
9
9
  }
package/dist/context.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { openQuotaLedger, quotaFetchOptions } from "./quota-ledger.mjs";
2
- import { LOCAL_MODE_REQUIRED, resolveAuthentication } from "./auth-state.mjs";
1
+ import { localModeRequired, resolveAuthentication } from "./auth-state.mjs";
3
2
  import { loadResolvedConfig } from "./config.mjs";
3
+ import { openQuotaLedger, quotaFetchOptions } from "./quota-ledger.mjs";
4
4
  import { resolveAuth } from "./auth.mjs";
5
5
  import { createLocalStore } from "./local-store.mjs";
6
6
  import { listStoreSites } from "./store-sites.mjs";
@@ -12,7 +12,7 @@ const FULL_SITE_URL_RE = /^(?:sc-domain:|https?:\/\/)/i;
12
12
  async function createCommandContext(opts = {}) {
13
13
  const { needsAuth = false, needsStore = false, interactive = false, byok, quota = true } = opts;
14
14
  const { config, dataDir } = await loadResolvedConfig();
15
- if (needsAuth && (await resolveAuthentication())._tag === "Hosted") throw new Error(LOCAL_MODE_REQUIRED);
15
+ if (needsAuth && (await resolveAuthentication())._tag === "Hosted") throw localModeRequired();
16
16
  const fetchOptions = needsAuth && quota ? quotaFetchOptions(await openQuotaLedger({ dataDir }), opts.fetchOptions) : opts.fetchOptions;
17
17
  const auth = needsAuth ? await resolveAuth({
18
18
  interactive,
@@ -1,13 +1,13 @@
1
+ import { HOSTED_SESSION_EXPIRED, isSessionExpiredError } from "./hosted-session.mjs";
2
+ import { isUserStop, stopDetailsOf } from "./stop.mjs";
1
3
  import { isUsageError } from "./command-registry.mjs";
2
4
  import { describeRecordReadRefusal } from "./hosted-query.mjs";
3
5
  import { quotaStopOf } from "./quota-ledger.mjs";
6
+ import { isQueryError } from "gscdump/query";
4
7
  import { isAnalysisError } from "@gscdump/analysis/errors";
5
8
  import { parseRecordReadRefusal } from "@gscdump/contracts";
6
9
  import { isEngineError } from "@gscdump/engine/errors";
7
10
  import { classifyError } from "gscdump/errors";
8
- import { isQueryError } from "gscdump/query";
9
- const HOSTED_KEY_REJECTED_REASON = "gscdump.com rejected the API key";
10
- const HOSTED_KEY_REJECTED = `${HOSTED_KEY_REJECTED_REASON}. Run \`gscdump auth login --mode hosted --api-key KEY\` with a valid key.`;
11
11
  const QUOTA_MESSAGE_RE = /quota|rate\s*limit/i;
12
12
  const ANSI_RE = /\x1B\[[0-9;]*m/g;
13
13
  function suggestionForGscError(error) {
@@ -109,18 +109,44 @@ function recordReadRefusalOf(error) {
109
109
  if (!(error instanceof Error) || error.name !== "GscdumpV1Error") return null;
110
110
  return parseRecordReadRefusal(error.details);
111
111
  }
112
+ function recordReadStop(refusal, text) {
113
+ return {
114
+ code: refusal.reason === "record_not_ready" ? "RECORD_NOT_READY" : "RANGE_NOT_SYNCED",
115
+ message: text.hint ? `${text.message} ${text.hint}` : text.message,
116
+ nextCommand: null
117
+ };
118
+ }
112
119
  function describeCliError(error) {
113
120
  if (isUsageError(error)) return {
114
121
  kind: "usage",
115
122
  message: error.message
116
123
  };
124
+ const stop = stopDetailsOf(error);
117
125
  if (error instanceof Error && error.routeStop !== void 0) return {
118
126
  kind: "stop",
119
- message: error.message
127
+ message: error.message,
128
+ ...stop ? { stop } : {}
120
129
  };
121
130
  if (error instanceof Error && quotaStopOf(error)) return {
122
131
  kind: "stop",
123
- message: error.message
132
+ message: error.message,
133
+ stop: {
134
+ code: "QUOTA_USED_UP",
135
+ message: error.message,
136
+ nextCommand: null
137
+ }
138
+ };
139
+ if (isUserStop(error) && stop) return {
140
+ kind: "stop",
141
+ message: stop.message,
142
+ stop
143
+ };
144
+ if (error instanceof Error && stop) return {
145
+ kind: "expected",
146
+ message: error.message,
147
+ hint: "",
148
+ showAuthSources: false,
149
+ stop
124
150
  };
125
151
  if (error instanceof TypeError && error.message === "fetch failed") {
126
152
  const cause = error.cause;
@@ -142,19 +168,24 @@ function describeCliError(error) {
142
168
  hint: error.mode === "local" ? "Pass --live to run against the GSC API." : "",
143
169
  showAuthSources: false
144
170
  };
145
- const refusal = recordReadRefusalOf(error);
146
- if (refusal) return {
171
+ if (isSessionExpiredError(error)) return {
147
172
  kind: "expected",
148
- ...describeRecordReadRefusal(refusal),
149
- showAuthSources: false
150
- };
151
- const classified = classifyError(error);
152
- if (classified.message === HOSTED_KEY_REJECTED) return {
153
- kind: "expected",
154
- message: classified.message,
173
+ message: HOSTED_SESSION_EXPIRED.message,
155
174
  hint: "",
156
- showAuthSources: false
175
+ showAuthSources: false,
176
+ stop: HOSTED_SESSION_EXPIRED
157
177
  };
178
+ const refusal = recordReadRefusalOf(error);
179
+ if (refusal) {
180
+ const text = describeRecordReadRefusal(refusal);
181
+ return {
182
+ kind: "expected",
183
+ ...text,
184
+ showAuthSources: false,
185
+ stop: recordReadStop(refusal, text)
186
+ };
187
+ }
188
+ const classified = classifyError(error);
158
189
  return {
159
190
  kind: "expected",
160
191
  message: classified.message.replace(PARAM_NAME_RE, (name) => flagForParam(name)),
@@ -162,8 +193,33 @@ function describeCliError(error) {
162
193
  showAuthSources: classified.kind === "auth-expired" || classified.kind === "permission-denied" || isAuthError(error)
163
194
  };
164
195
  }
196
+ function jsonErrorOf(report) {
197
+ switch (report.kind) {
198
+ case "usage": return {
199
+ code: "USAGE",
200
+ message: report.message,
201
+ nextCommand: null
202
+ };
203
+ case "stop": return report.stop ?? {
204
+ code: "FAILED",
205
+ message: report.message,
206
+ nextCommand: null
207
+ };
208
+ case "expected": return report.stop ?? {
209
+ code: "FAILED",
210
+ message: report.hint ? `${report.message} ${report.hint}` : report.message,
211
+ nextCommand: null
212
+ };
213
+ case "defect": return {
214
+ code: "UNEXPECTED",
215
+ message: report.message,
216
+ nextCommand: null
217
+ };
218
+ }
219
+ }
165
220
  async function reportCliError(error, options) {
166
221
  const report = describeCliError(error);
222
+ if (options.json) (options.writeStdout ?? ((value) => console.log(value)))(JSON.stringify({ error: jsonErrorOf(report) }, null, 2));
167
223
  const red = (text) => options.color ? `\x1B[31m${text}\x1B[0m` : text;
168
224
  const lines = [];
169
225
  switch (report.kind) {
@@ -184,4 +240,4 @@ async function reportCliError(error, options) {
184
240
  const text = lines.join("\n");
185
241
  (options.write ?? ((value) => console.error(value)))(options.color ? text : text.replace(ANSI_RE, ""));
186
242
  }
187
- export { HOSTED_KEY_REJECTED, HOSTED_KEY_REJECTED_REASON, LocalStoreUnsupportedError, describeCliError, flagForParam, isAuthError, reportCliError };
243
+ export { LocalStoreUnsupportedError, describeCliError, flagForParam, isAuthError, jsonErrorOf, reportCliError };
@@ -1,3 +1,4 @@
1
+ import { userStopError } from "./stop.mjs";
1
2
  import { z } from "zod";
2
3
  const ORIGIN = "https://gscdump.com";
3
4
  const pollSchema = z.discriminatedUnion("status", [z.object({ status: z.literal("pending") }), z.object({
@@ -5,6 +6,12 @@ const pollSchema = z.discriminatedUnion("status", [z.object({ status: z.literal(
5
6
  sessionId: z.string().regex(/^[a-f0-9]{64}$/)
6
7
  })]);
7
8
  const HOSTED_LOGIN_FAILURE = "Hosted login failed. Run `gscdump auth login --mode hosted` to try again.";
9
+ const LOGIN_CANCELLED = {
10
+ code: "LOGIN_CANCELLED",
11
+ message: "Login cancelled in the browser.",
12
+ nextCommand: "gscdump auth login --mode hosted"
13
+ };
14
+ const loginCancelledBodySchema = z.object({ data: z.object({ details: z.object({ reason: z.literal("login_cancelled") }) }) });
8
15
  async function requestJson(request, route, init = {}) {
9
16
  const response = await request(`${ORIGIN}/api/cli/auth/${route}`, {
10
17
  ...init,
@@ -13,6 +20,7 @@ async function requestJson(request, route, init = {}) {
13
20
  });
14
21
  if (!response.ok) {
15
22
  if (response.status === 429 || response.status >= 500) throw new Error("gscdump.com login is temporarily unavailable. Try again later.");
23
+ if (response.status === 403 && loginCancelledBodySchema.safeParse(await response.json().catch(() => null)).success) throw userStopError(LOGIN_CANCELLED);
16
24
  throw new Error(HOSTED_LOGIN_FAILURE);
17
25
  }
18
26
  return response.json();
@@ -31,4 +39,4 @@ async function loginWithHostedSession(deps) {
31
39
  }
32
40
  throw new Error("Authorization expired. Run `gscdump auth login --mode hosted` to try again.");
33
41
  }
34
- export { loginWithHostedSession };
42
+ export { LOGIN_CANCELLED, loginWithHostedSession };
@@ -0,0 +1,17 @@
1
+ import { z } from "zod";
2
+ const sessionExpiredDetailsSchema = z.object({ reason: z.literal("session_expired") });
3
+ const sessionExpiredBodySchema = z.union([z.object({ data: z.object({ details: sessionExpiredDetailsSchema }) }), z.object({ error: z.object({ details: sessionExpiredDetailsSchema }) })]);
4
+ const HOSTED_SESSION_EXPIRED = {
5
+ code: "HOSTED_CREDENTIALS_REJECTED",
6
+ message: "Your gscdump.com session expired. Run `gscdump auth login --mode hosted`.",
7
+ nextCommand: "gscdump auth login --mode hosted"
8
+ };
9
+ function isSessionExpiredBody(body) {
10
+ return sessionExpiredBodySchema.safeParse(body).success;
11
+ }
12
+ function isSessionExpiredError(error) {
13
+ if (!(error instanceof Error) || error.name !== "GscdumpV1Error") return false;
14
+ const { status, details } = error;
15
+ return status === 401 && sessionExpiredDetailsSchema.safeParse(details).success;
16
+ }
17
+ export { HOSTED_SESSION_EXPIRED, isSessionExpiredBody, isSessionExpiredError };