@gscdump/cli 5.1.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.
@@ -1,3 +1,4 @@
1
+ import { HOSTED_SESSION_EXPIRED, isSessionExpiredBody } from "./hosted-session.mjs";
1
2
  import { useCliRuntime } from "./runtime.mjs";
2
3
  import { stopError } from "./stop.mjs";
3
4
  import { randomUUID } from "node:crypto";
@@ -156,12 +157,18 @@ const hostedSiteSchema = z.object({
156
157
  newestDateSynced: z.string().nullable().optional(),
157
158
  oldestDateSynced: z.string().nullable().optional()
158
159
  }).passthrough();
160
+ const partnerNameSchema = z.string().trim().min(1).max(80).regex(/^\P{Cc}+$/u);
161
+ const keyIssuerSchema = z.discriminatedUnion("_tag", [z.object({ _tag: z.literal("gscdump") }), z.object({
162
+ _tag: z.literal("partner"),
163
+ name: partnerNameSchema
164
+ })]);
159
165
  const accountSchema = z.object({
160
166
  user: z.object({
161
167
  publicId: z.string(),
162
168
  email: z.string()
163
169
  }),
164
- sites: z.array(hostedSiteSchema)
170
+ sites: z.array(hostedSiteSchema),
171
+ issuer: keyIssuerSchema.optional().catch(void 0)
165
172
  });
166
173
  async function hostedRequest(state, route, options = {}) {
167
174
  const response = await fetch(`${state.apiRoot}${route}`, {
@@ -181,7 +188,8 @@ async function hostedRequest(state, route, options = {}) {
181
188
  };
182
189
  if (response.status === 401) {
183
190
  const session = "sessionId" in state;
184
- throw Object.assign(stopError({
191
+ const expired = session && isSessionExpiredBody(await response.json().catch(() => null));
192
+ throw Object.assign(stopError(expired ? HOSTED_SESSION_EXPIRED : {
185
193
  code: "HOSTED_CREDENTIALS_REJECTED",
186
194
  message: session ? HOSTED_SESSION_REJECTED : HOSTED_KEY_REJECTED,
187
195
  nextCommand: session ? "gscdump auth login --mode hosted" : null
@@ -194,18 +202,28 @@ async function hostedRequest(state, route, options = {}) {
194
202
  function hostedCredential(state) {
195
203
  return "sessionId" in state ? state.sessionId : state.apiKey;
196
204
  }
197
- function siteManagerOf(state) {
198
- return "sessionId" in state ? { _tag: "gscdump" } : { _tag: "key_issuer" };
205
+ function siteManagerOf(state, issuer) {
206
+ if ("sessionId" in state) return { _tag: "gscdump" };
207
+ return issuer ?? { _tag: "key_issuer" };
199
208
  }
200
- function connectSiteStep(manager) {
209
+ function connectSitePlace(manager) {
201
210
  switch (manager._tag) {
202
- case "gscdump": return "Connect a Site at https://gscdump.com/app/onboarding?step=connect-sites.";
203
- case "key_issuer": return "Connect a Site in the app that issued this API key.";
211
+ case "gscdump": return "at https://gscdump.com/app/onboarding?step=connect-sites";
212
+ case "partner": return `in ${manager.name}`;
213
+ case "key_issuer": return "in the app that issued this API key";
204
214
  }
205
215
  }
216
+ function connectSiteStep(manager) {
217
+ return `Connect a Site ${connectSitePlace(manager)}.`;
218
+ }
219
+ function noSitesNextStep(manager) {
220
+ const next = `Next: connect a Site ${connectSitePlace(manager)}.`;
221
+ return manager._tag === "gscdump" ? `${next} If Hosted access is not active, gscdump.com asks you to activate it first.` : next;
222
+ }
206
223
  function siteSettingsPlace(manager) {
207
224
  switch (manager._tag) {
208
225
  case "gscdump": return "in the Site settings on gscdump.com";
226
+ case "partner": return `in the Site settings of ${manager.name}`;
209
227
  case "key_issuer": return "in the Site settings of the app that issued this API key";
210
228
  }
211
229
  }
@@ -218,4 +236,4 @@ function formatHostedSync(site) {
218
236
  const status = site.syncStatus ?? "pending";
219
237
  return `${status}${site.syncProgress && site.syncProgress.total > 0 && status !== "synced" ? `: ${site.syncProgress.completed.toLocaleString("en-US")} of ${site.syncProgress.total.toLocaleString("en-US")} days (${Math.round(site.syncProgress.percent)}%)` : ""}${status === "synced" && site.oldestDateSynced && site.newestDateSynced ? `: ${site.oldestDateSynced} to ${site.newestDateSynced}` : ""}`;
220
238
  }
221
- export { HOSTED_KEY_REJECTED, HOSTED_NOT_SET_UP, HOSTED_SESSION_REJECTED, LOCAL_MODE_REQUIRED, clearAuthentication, connectSiteStep, formatHostedSync, getHostedAccount, hostedCredential, hostedRequest, localModeRequired, parseAuthMode, parseAuthentication, resolveAccessMode, resolveAuthentication, revokeHostedSession, saveAuthentication, siteManagerOf, siteSettingsPlace };
239
+ export { HOSTED_KEY_REJECTED, HOSTED_NOT_SET_UP, HOSTED_SESSION_REJECTED, LOCAL_MODE_REQUIRED, clearAuthentication, connectSiteStep, formatHostedSync, getHostedAccount, hostedCredential, hostedRequest, localModeRequired, noSitesNextStep, parseAuthMode, parseAuthentication, resolveAccessMode, resolveAuthentication, revokeHostedSession, saveAuthentication, siteManagerOf, siteSettingsPlace };
@@ -33,7 +33,7 @@ async function loadHostedBingConnections(state, sites, options = {}) {
33
33
  async function resolveHostedBingSites(state, input) {
34
34
  if (Boolean(input.site) === Boolean(input.allSites)) throw new Error("Choose --site or --all-sites.");
35
35
  const account = await getHostedAccount(state);
36
- if (account.sites.length === 0) throw stopError(noSitesStop(siteManagerOf(state)));
36
+ if (account.sites.length === 0) throw stopError(noSitesStop(siteManagerOf(state, account.issuer)));
37
37
  const exact = account.sites.filter((site) => site.siteId === input.site || site.siteUrl === input.site);
38
38
  const normalize = (value) => URL.parse(value.startsWith("sc-domain:") ? `https://${value.slice(10)}/` : value)?.toString();
39
39
  const requested = input.allSites ? account.sites : exact.length ? exact : account.sites.filter((site) => normalize(site.siteUrl) === normalize(input.site) && normalize(input.site) !== void 0);
package/dist/cli.d.mts CHANGED
@@ -1,4 +1,10 @@
1
1
  import { CliRuntime, CreateCliRuntimeOptions, createCliRuntime } from "./runtime.mjs";
2
+ /**
3
+ * The command line asks for JSON output: `--json`, `--format json`, or `query`
4
+ * with no `--format`, since JSON is its default. The command sets the final
5
+ * mode when it runs. This covers failures before that.
6
+ */
7
+ export declare function asksForJson(rawArgs: readonly string[]): boolean;
2
8
  export declare const main: import("citty").CommandDef<import("citty").ArgsDef>;
3
9
  export interface RunCliOptions {
4
10
  rawArgs?: string[];
package/dist/cli.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createCliRuntime, runWithCliRuntime, useCliRuntime } from "./runtime.mjs";
2
+ import { commandLineError } from "./stop.mjs";
2
3
  import { parseAuthMode } from "./auth-state.mjs";
3
4
  import { checkCliArgs } from "./cli-args.mjs";
4
5
  import { CLI_SUBCOMMANDS, resolveUsageTarget } from "./command-registry.mjs";
@@ -23,9 +24,22 @@ function shouldShowSplash(rawArgs) {
23
24
  ]) if (rawArgs.includes(flag)) return false;
24
25
  return true;
25
26
  }
27
+ function formatFlag(rawArgs) {
28
+ for (const [index, arg] of rawArgs.entries()) {
29
+ if (arg === "--format" || arg === "-f") return rawArgs[index + 1];
30
+ const inline = /^(?:--format=|-f=?)(.+)$/.exec(arg);
31
+ if (inline) return inline[1];
32
+ }
33
+ }
34
+ function asksForJson(rawArgs) {
35
+ const format = formatFlag(rawArgs);
36
+ return rawArgs.includes("--json") || format === "json" || rawArgs[0] === "query" && format === void 0;
37
+ }
26
38
  function prepareCliArgs(input) {
27
39
  const rawArgs = [...input];
28
- useCliRuntime().authModeOverride = parseAuthMode(pluckArgValue(rawArgs, "--mode") ?? void 0);
40
+ const mode = pluckArgValue(rawArgs, "--mode");
41
+ if (mode !== null && mode !== "local" && mode !== "hosted") throw commandLineError("Access mode must be local or hosted.");
42
+ useCliRuntime().authModeOverride = parseAuthMode(mode ?? void 0);
29
43
  const env = resolveCliEnvironment();
30
44
  setNoColor(!terminalOutputOptions().color);
31
45
  const profile = pluckArgValue(rawArgs, "--profile", true);
@@ -50,7 +64,7 @@ function pluckArgValue(argv, flag, allowQueryTiming = false) {
50
64
  const inline = a !== flag;
51
65
  const next = inline ? a.slice(flag.length + 1) : argv[i + 1];
52
66
  if (!inline && allowQueryTiming && argv.includes("query") && argv.indexOf("query") < i && (next === void 0 || next.startsWith("-"))) continue;
53
- if (!next || !inline && next.startsWith("-")) throw new Error(`${flag} requires a value. Use ${flag}=VALUE.`);
67
+ if (!next || !inline && next.startsWith("-")) throw commandLineError(`${flag} requires a value. Use ${flag}=VALUE.`);
54
68
  value = next;
55
69
  argv.splice(i, inline ? 1 : 2);
56
70
  i--;
@@ -80,6 +94,26 @@ async function runMainCommand(rawArgs) {
80
94
  }
81
95
  await runCommand(main, { rawArgs });
82
96
  }
97
+ function watchStdout() {
98
+ let wrote = false;
99
+ const write = process.stdout.write;
100
+ const log = console.log;
101
+ process.stdout.write = ((...args) => {
102
+ wrote = true;
103
+ return write.apply(process.stdout, args);
104
+ });
105
+ console.log = (...args) => {
106
+ wrote = true;
107
+ log(...args);
108
+ };
109
+ return {
110
+ wrote: () => wrote,
111
+ restore: () => {
112
+ process.stdout.write = write;
113
+ console.log = log;
114
+ }
115
+ };
116
+ }
83
117
  function stderrColor(runtime, rawArgs) {
84
118
  return resolveOutputOptions({
85
119
  isTTY: process.stderr.isTTY,
@@ -97,22 +131,27 @@ async function runCli(opts = {}) {
97
131
  return runWithCliRuntime(runtime, async () => {
98
132
  let rawArgs = [...input];
99
133
  runtime.jsonOutput = false;
134
+ const stdout = watchStdout();
100
135
  try {
136
+ runtime.jsonOutput = asksForJson(input);
101
137
  rawArgs = prepareCliArgs(input);
102
138
  runtime.rawArgs = [...rawArgs];
139
+ runtime.jsonOutput = asksForJson(rawArgs);
103
140
  const argumentError = await checkCliArgs(main, rawArgs);
104
- if (argumentError) throw new Error(argumentError);
141
+ if (argumentError) throw commandLineError(argumentError);
105
142
  await withConfiguredOutput(() => runMainCommand(rawArgs));
106
143
  return 0;
107
144
  } catch (error) {
108
145
  await reportCliError(error, {
109
146
  color: stderrColor(runtime, rawArgs),
110
- json: runtime.jsonOutput,
147
+ json: runtime.jsonOutput && !stdout.wrote(),
111
148
  usage: async () => renderUsage(...await resolveUsageTarget(main, rawArgs)),
112
149
  authSources: async () => (await import("./auth.mjs")).formatAuthProvenance()
113
150
  });
114
151
  return 1;
152
+ } finally {
153
+ stdout.restore();
115
154
  }
116
155
  });
117
156
  }
118
- export { createCliRuntime, main, runCli };
157
+ export { asksForJson, createCliRuntime, main, runCli };
@@ -1,6 +1,6 @@
1
1
  const initCommandMeta = {
2
2
  name: "init",
3
- description: "Set up GSCDump authentication"
3
+ description: "Set up gscdump authentication"
4
4
  };
5
5
  const dumpCommandMeta = {
6
6
  name: "dump",
@@ -40,7 +40,7 @@ const entitiesCommandMeta = {
40
40
  };
41
41
  const analyzeCommandMeta = {
42
42
  name: "analyze",
43
- description: "SEO analysis tools"
43
+ description: "Run an Analyzer over Search Console rows"
44
44
  };
45
45
  const reportCommandMeta = {
46
46
  name: "report",
@@ -1,5 +1,5 @@
1
1
  import { useCliRuntime } from "../runtime.mjs";
2
- import { clearAuthentication, formatHostedSync, getHostedAccount, parseAuthMode, parseAuthentication, resolveAuthentication, revokeHostedSession, saveAuthentication } from "../auth-state.mjs";
2
+ import { clearAuthentication, formatHostedSync, getHostedAccount, noSitesNextStep, parseAuthMode, parseAuthentication, resolveAuthentication, revokeHostedSession, saveAuthentication, siteManagerOf } from "../auth-state.mjs";
3
3
  import { authCommandMeta } from "../command-meta.mjs";
4
4
  import { loadConfig, saveConfig } from "../config.mjs";
5
5
  import { OUTPUT_ARGS, applyOutputMode, logger } from "../utils.mjs";
@@ -28,6 +28,9 @@ async function requireLocalAuth(args) {
28
28
  if ((await resolveAuthentication())._tag === "Hosted") throw new Error("Google scopes and token refresh apply to Local mode only. Hosted mode uses a gscdump.com CLI session.");
29
29
  }
30
30
  const HOSTED_MODE_NOTE = "Hosted mode reads your gscdump.com record. Commands that call Google need Local mode.";
31
+ function loginNextStep(state, siteCount, issuer) {
32
+ return siteCount > 0 ? "Next: run `gscdump sites` to see each Site and its sync state." : noSitesNextStep(siteManagerOf(state, issuer));
33
+ }
31
34
  const HOSTED_COMMANDS = [
32
35
  "sites",
33
36
  "query",
@@ -71,6 +74,7 @@ async function loginHosted(args) {
71
74
  await saveAuthentication(state);
72
75
  logger.success(`Hosted mode saved for ${account.user.email}`);
73
76
  logger.info(HOSTED_MODE_NOTE);
77
+ logger.info(loginNextStep(state, account.sites.length, account.issuer));
74
78
  return;
75
79
  }
76
80
  const state = parseAuthentication({
@@ -83,6 +87,7 @@ async function loginHosted(args) {
83
87
  await saveAuthentication(state);
84
88
  logger.success(`Hosted mode saved for ${account.user.email}`);
85
89
  logger.info(HOSTED_MODE_NOTE);
90
+ logger.info(loginNextStep(state, account.sites.length, account.issuer));
86
91
  }
87
92
  async function resolveLiveAuthState() {
88
93
  const tokens = await loadTokens();
@@ -158,6 +163,7 @@ async function runStatus(args) {
158
163
  console.log(` API: ${authentication.apiRoot}`);
159
164
  console.log(` Sites: ${sites.length}`);
160
165
  for (const site of sites) console.log(` ${site.siteUrl} ${formatHostedSync(site)}`);
166
+ if (sites.length === 0) console.log(` ${noSitesNextStep(siteManagerOf(authentication, account.value.issuer))}`);
161
167
  console.log(` ${HOSTED_MODE_NOTE}`);
162
168
  console.log(` Hosted commands: ${HOSTED_COMMANDS.join(", ")}`);
163
169
  }
@@ -263,17 +269,13 @@ const refreshCommand = defineCommand({
263
269
  return;
264
270
  }
265
271
  const tokens = await loadTokens();
266
- if (!tokens?.refresh_token) {
267
- logger.error("No saved refresh token. Run `gscdump auth login`.");
268
- process.exit(1);
269
- }
272
+ if (!tokens?.refresh_token) throw new Error("No saved refresh token. Run `gscdump auth login`.");
270
273
  await saveTokens({
271
274
  ...tokens,
272
275
  expiry_date: 1
273
276
  });
274
277
  if ((await getAuth({ interactive: false }).catch((e) => {
275
- logger.error(`Refresh failed: ${e.message}`);
276
- process.exit(1);
278
+ throw new Error(`Refresh failed: ${e.message}`, { cause: e });
277
279
  })).credentials?.access_token) logger.success("Token refreshed");
278
280
  else logger.warn("Refresh completed but no new access token returned");
279
281
  }
@@ -323,12 +325,10 @@ const loginCommand = defineCommand({
323
325
  if (args["service-account"]) {
324
326
  const saPath = path.resolve(String(args["service-account"]));
325
327
  const jwt = await loadServiceAccount(saPath).catch((e) => {
326
- logger.error(`Service-account load failed: ${e.message}`);
327
- process.exit(1);
328
+ throw new Error(`Service-account load failed: ${e.message}`, { cause: e });
328
329
  });
329
330
  await jwt.authorize().catch((e) => {
330
- logger.error(`Service-account auth failed: ${e.message}`);
331
- process.exit(1);
331
+ throw new Error(`Service-account auth failed: ${e.message}`, { cause: e });
332
332
  });
333
333
  const config = await loadConfig();
334
334
  config.serviceAccountPath = saPath;
@@ -343,8 +343,7 @@ const loginCommand = defineCommand({
343
343
  noBrowser: args.browser === false,
344
344
  force: Boolean(args.force)
345
345
  }).catch((e) => {
346
- logger.error(`Login failed: ${e.message}`);
347
- process.exit(1);
346
+ throw new Error(`Login failed: ${e.message}`, { cause: e });
348
347
  });
349
348
  logger.success("Logged in");
350
349
  await runSmokeTest(oauth);
@@ -1,3 +1,4 @@
1
+ import { commandLineError } from "../stop.mjs";
1
2
  import { configCommandMeta } from "../command-meta.mjs";
2
3
  import { getConfigPath, loadConfig, resolveDataDir, saveConfig } from "../config.mjs";
3
4
  import { OUTPUT_ARGS, applyOutputMode, displayPath, logger } from "../utils.mjs";
@@ -71,18 +72,11 @@ const setCommand = defineCommand({
71
72
  },
72
73
  async run({ args }) {
73
74
  applyOutputMode(args);
74
- if (!VALID_KEYS.includes(args.key)) {
75
- logger.error(`Invalid key: ${args.key}`);
76
- logger.info(`Valid keys: ${VALID_KEYS.join(", ")}`);
77
- process.exit(1);
78
- }
75
+ if (!VALID_KEYS.includes(args.key)) throw commandLineError(`Invalid key: ${args.key}. Valid keys: ${VALID_KEYS.join(", ")}`);
79
76
  const config = await loadConfig();
80
77
  const value = NUMERIC_KEYS.has(args.key) ? Number(args.value) : args.key === "defaultSite" ? await canonicalSite(args.value) : args.value;
81
- if (NUMERIC_KEYS.has(args.key) && !Number.isFinite(value)) {
82
- logger.error(`Invalid numeric value for ${args.key}: ${args.value}`);
83
- process.exit(1);
84
- }
85
- config[args.key] = value;
78
+ if (NUMERIC_KEYS.has(args.key) && !Number.isFinite(value)) throw commandLineError(`Invalid numeric value for ${args.key}: ${args.value}`);
79
+ Object.assign(config, { [args.key]: value });
86
80
  await saveConfig(config);
87
81
  logger.success(`Set ${args.key} = ${value}`);
88
82
  }
@@ -102,11 +96,7 @@ const unsetCommand = defineCommand({
102
96
  },
103
97
  async run({ args }) {
104
98
  applyOutputMode(args);
105
- if (!VALID_KEYS.includes(args.key)) {
106
- logger.error(`Invalid key: ${args.key}`);
107
- logger.info(`Valid keys: ${VALID_KEYS.join(", ")}`);
108
- process.exit(1);
109
- }
99
+ if (!VALID_KEYS.includes(args.key)) throw commandLineError(`Invalid key: ${args.key}. Valid keys: ${VALID_KEYS.join(", ")}`);
110
100
  const config = await loadConfig();
111
101
  delete config[args.key];
112
102
  await saveConfig(config);
@@ -339,7 +339,7 @@ async function checkHostedConnection(authentication) {
339
339
  } : {
340
340
  name: "hosted.sites",
341
341
  status: "warn",
342
- detail: `no hosted Sites. ${connectSiteStep(siteManagerOf(authentication))}`
342
+ detail: `no hosted Sites. ${connectSiteStep(siteManagerOf(authentication, account.value.issuer))}`
343
343
  }];
344
344
  return [
345
345
  {
@@ -1,3 +1,4 @@
1
+ import { commandLineError } from "../stop.mjs";
1
2
  import { dumpCommandMeta } from "../command-meta.mjs";
2
3
  import { ALL_SEARCH_TYPES, OUTPUT_ARGS, applyOutputMode, displayPath, logger, parseNameList, parseSearchType } from "../utils.mjs";
3
4
  import { allTables } from "../local-store.mjs";
@@ -70,10 +71,7 @@ const dumpCommand = defineCommand({
70
71
  async run({ args }) {
71
72
  const { json, quiet } = applyOutputMode(args);
72
73
  const format = String(args.format);
73
- if (!isDumpFormat(format)) {
74
- logger.error(`Invalid --format: ${format}. Allowed: ${DUMP_FORMATS.join(", ")}`);
75
- process.exit(1);
76
- }
74
+ if (!isDumpFormat(format)) throw commandLineError(`Invalid --format: ${format}. Allowed: ${DUMP_FORMATS.join(", ")}`);
77
75
  const tablesFilter = args.tables ? new Set(parseNameList(args.tables, [
78
76
  ...allTables(),
79
77
  ...ENTITY_DATASETS,
@@ -55,10 +55,7 @@ const showSubCommand = defineCommand({
55
55
  userId: store.userId,
56
56
  siteId: store.siteIdFor(siteUrl)
57
57
  }, String(args.url));
58
- if (!record) {
59
- logger.warn(`No inspection record for ${args.url}`);
60
- process.exit(1);
61
- }
58
+ if (!record) throw new Error(`No inspection record for ${args.url}`);
62
59
  if (json) {
63
60
  console.log(JSON.stringify(record, null, 2));
64
61
  return;
@@ -114,10 +111,7 @@ const indexingSnapshotSubCommand = defineCommand({
114
111
  return;
115
112
  }
116
113
  const claim = await recordStoreSite(store.dataDir, siteUrl, { userId: store.userId });
117
- if (!claim.ok) {
118
- logger.error(formatSiteIdCollision(claim.error));
119
- process.exit(1);
120
- }
114
+ if (!claim.ok) throw new Error(formatSiteIdCollision(claim.error));
121
115
  const records = [];
122
116
  const failures = [];
123
117
  let completed = 0;
@@ -155,7 +149,7 @@ const indexingSnapshotSubCommand = defineCommand({
155
149
  if (failures.length > 5) console.log(` ... and ${failures.length - 5} more`);
156
150
  }
157
151
  }
158
- if (failures.length > 0) process.exit(1);
152
+ if (failures.length > 0) throw new Error(`${failures.length} of ${urls.length} URL(s) failed. First: ${failures[0].url}: ${failures[0].error}`);
159
153
  }
160
154
  });
161
155
  const indexingSubCommand = defineCommand({
@@ -1,4 +1,4 @@
1
- import { siteManagerOf, siteSettingsPlace } from "../auth-state.mjs";
1
+ import { siteSettingsPlace } from "../auth-state.mjs";
2
2
  import { terminalOutputOptions } from "../render/terminal.mjs";
3
3
  import { applyOutputMode, logger, readUrlList } from "../utils.mjs";
4
4
  import { renderTable } from "../render/layout.mjs";
@@ -136,7 +136,7 @@ const addCommand = defineCommand({
136
136
  async run({ args }) {
137
137
  const { json } = applyOutputMode(args);
138
138
  const urls = await readUrls(args);
139
- const { client, site, authentication } = await resolveHostedSite({
139
+ const { client, site, siteManager } = await resolveHostedSite({
140
140
  ...args,
141
141
  _: []
142
142
  }, {
@@ -151,7 +151,7 @@ const addCommand = defineCommand({
151
151
  console.log(JSON.stringify(data, null, 2));
152
152
  return;
153
153
  }
154
- printChange("Added", data, site.siteUrl, siteManagerOf(authentication));
154
+ printChange("Added", data, site.siteUrl, siteManager);
155
155
  }
156
156
  });
157
157
  const removeCommand = defineCommand({
@@ -167,7 +167,7 @@ const removeCommand = defineCommand({
167
167
  async run({ args }) {
168
168
  const { json } = applyOutputMode(args);
169
169
  const urls = await readUrls(args);
170
- const { client, site, authentication } = await resolveHostedSite({
170
+ const { client, site, siteManager } = await resolveHostedSite({
171
171
  ...args,
172
172
  _: []
173
173
  }, {
@@ -182,7 +182,7 @@ const removeCommand = defineCommand({
182
182
  console.log(JSON.stringify(data, null, 2));
183
183
  return;
184
184
  }
185
- printChange("Removed", data, site.siteUrl, siteManagerOf(authentication));
185
+ printChange("Removed", data, site.siteUrl, siteManager);
186
186
  }
187
187
  });
188
188
  const indexingWatchCommand = defineCommand({
@@ -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 }
@@ -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;
@@ -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,4 +1,5 @@
1
1
  import { useCliRuntime } from "../runtime.mjs";
2
+ import { commandLineError } from "../stop.mjs";
2
3
  import { profileCommandMeta } from "../command-meta.mjs";
3
4
  import { getConfigDir, setConfigDir } from "../config.mjs";
4
5
  import { resolveCliEnvironment } from "../environment.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,4 +1,5 @@
1
1
  import { useCliRuntime } from "../runtime.mjs";
2
+ import { commandLineError } from "../stop.mjs";
2
3
  import { resolveAuthentication } from "../auth-state.mjs";
3
4
  import { queryCommandMeta } from "../command-meta.mjs";
4
5
  import { loadConfig } from "../config.mjs";
@@ -211,10 +212,7 @@ 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.");
218
216
  setJsonOutput(format === "json");
219
217
  if (args.sql || args.schema) {
220
218
  await runSqlMode({
@@ -234,31 +232,19 @@ const queryCommand = defineCommand({
234
232
  const dimNames = await resolveDimensions(args);
235
233
  const windowFlags = await promptRange(args);
236
234
  const invalidWindow = checkWindowFlags(windowFlags);
237
- if (invalidWindow) {
238
- logger.error(invalidWindow.message);
239
- process.exit(1);
240
- }
235
+ if (invalidWindow) throw commandLineError(invalidWindow.message);
241
236
  await promptFilters(args);
242
237
  const limitArg = String(args.limit ?? ctxConfig.defaultLimit ?? 1e3);
243
238
  const rowLimit = Number(limitArg);
244
- if (!POSITIVE_INTEGER_RE.test(limitArg) || !Number.isSafeInteger(rowLimit) || rowLimit < 1) {
245
- logger.error("Invalid --limit. Use a positive safe integer, such as --limit 1000.");
246
- process.exit(1);
247
- }
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.");
248
240
  const filters = parseFilterArgs(args);
249
241
  const searchType = parseSearchType(args.type ?? ctxConfig.defaultSearchType, "--type");
250
242
  const localSearchType = searchType ?? "web";
251
243
  const dataState = args["data-state"] ? String(args["data-state"]) : ctxConfig.defaultDataState;
252
244
  const aggregationType = args["aggregation-type"] ? String(args["aggregation-type"]) : void 0;
253
245
  const filterDims = filterDimensions(filters);
254
- if (dataState && !DATA_STATES.includes(dataState)) {
255
- logger.error(`Invalid --data-state: ${dataState}. Allowed: ${DATA_STATES.join(", ")}`);
256
- process.exit(1);
257
- }
258
- if (aggregationType && !AGGREGATION_TYPES.includes(aggregationType)) {
259
- logger.error(`Invalid --aggregation-type: ${aggregationType}. Allowed: ${AGGREGATION_TYPES.join(", ")}`);
260
- process.exit(1);
261
- }
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(", ")}`);
262
248
  const forceLive = Boolean(args.live);
263
249
  if (!forceLive && (await resolveAuthentication())._tag === "Hosted") {
264
250
  if (dataState || aggregationType) logger.warn("--data-state / --aggregation-type are ignored without --live");
@@ -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) {
@@ -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
  };
@@ -3,7 +3,6 @@ import { OUTPUT_ARGS, applyOutputMode, logger, parseIntegerOption, plural } from
3
3
  import { createCommandContext } from "../context.mjs";
4
4
  import { HOSTED_ARGS, resolveHostedSite } from "../hosted-site.mjs";
5
5
  import { discoverLiveSitemap, loadSitemapUrls } from "../sitemap.mjs";
6
- import process from "node:process";
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,3 +1,4 @@
1
+ import { commandLineError } from "../stop.mjs";
1
2
  import { connectSiteStep, formatHostedSync, getHostedAccount, localModeRequired, resolveAuthentication, siteManagerOf } from "../auth-state.mjs";
2
3
  import { sitesCommandMeta } from "../command-meta.mjs";
3
4
  import { OUTPUT_ARGS, applyOutputMode, logger, plural } from "../utils.mjs";
@@ -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) {
@@ -423,7 +418,7 @@ const LIST_ARGS = {
423
418
  };
424
419
  async function runListHostedSites(args, authentication) {
425
420
  if (args["with-sitemaps"] || args["owner-only"]) throw localModeRequired("--with-sitemaps and --owner-only read Search Console permissions.");
426
- const { sites } = await getHostedAccount(authentication);
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. ${connectSiteStep(siteManagerOf(authentication))}`);
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({
@@ -1,4 +1,5 @@
1
- import { stopDetailsOf } from "./stop.mjs";
1
+ import { HOSTED_SESSION_EXPIRED, isSessionExpiredError } from "./hosted-session.mjs";
2
+ import { isUserStop, stopDetailsOf } from "./stop.mjs";
2
3
  import { isUsageError } from "./command-registry.mjs";
3
4
  import { describeRecordReadRefusal } from "./hosted-query.mjs";
4
5
  import { quotaStopOf } from "./quota-ledger.mjs";
@@ -128,7 +129,17 @@ function describeCliError(error) {
128
129
  };
129
130
  if (error instanceof Error && quotaStopOf(error)) return {
130
131
  kind: "stop",
131
- 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
132
143
  };
133
144
  if (error instanceof Error && stop) return {
134
145
  kind: "expected",
@@ -157,6 +168,13 @@ function describeCliError(error) {
157
168
  hint: error.mode === "local" ? "Pass --live to run against the GSC API." : "",
158
169
  showAuthSources: false
159
170
  };
171
+ if (isSessionExpiredError(error)) return {
172
+ kind: "expected",
173
+ message: HOSTED_SESSION_EXPIRED.message,
174
+ hint: "",
175
+ showAuthSources: false,
176
+ stop: HOSTED_SESSION_EXPIRED
177
+ };
160
178
  const refusal = recordReadRefusalOf(error);
161
179
  if (refusal) {
162
180
  const text = describeRecordReadRefusal(refusal);
@@ -175,9 +193,33 @@ function describeCliError(error) {
175
193
  showAuthSources: classified.kind === "auth-expired" || classified.kind === "permission-denied" || isAuthError(error)
176
194
  };
177
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
+ }
178
220
  async function reportCliError(error, options) {
179
221
  const report = describeCliError(error);
180
- if (options.json && (report.kind === "stop" || report.kind === "expected") && report.stop) (options.writeStdout ?? ((value) => console.log(value)))(JSON.stringify({ error: report.stop }, null, 2));
222
+ if (options.json) (options.writeStdout ?? ((value) => console.log(value)))(JSON.stringify({ error: jsonErrorOf(report) }, null, 2));
181
223
  const red = (text) => options.color ? `\x1B[31m${text}\x1B[0m` : text;
182
224
  const lines = [];
183
225
  switch (report.kind) {
@@ -198,4 +240,4 @@ async function reportCliError(error, options) {
198
240
  const text = lines.join("\n");
199
241
  (options.write ?? ((value) => console.error(value)))(options.color ? text : text.replace(ANSI_RE, ""));
200
242
  }
201
- export { 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 };
@@ -97,15 +97,18 @@ async function resolveHostedSite(args, command) {
97
97
  });
98
98
  if (args["api-root"] && String(args["api-root"]).replace(/\/+$/, "") !== authentication.apiRoot) throw new Error("The API root changed. Pass --api-key for the new API root.");
99
99
  const target = args.site ? String(args.site) : (await loadConfig()).defaultSite;
100
- const match = matchHostedSite((await getHostedAccount(authentication)).sites, target);
101
- if (match.kind !== "found") throw stopError(siteMatchStop(match, siteManagerOf(authentication)));
100
+ const account = await getHostedAccount(authentication);
101
+ const siteManager = siteManagerOf(authentication, account.issuer);
102
+ const match = matchHostedSite(account.sites, target);
103
+ if (match.kind !== "found") throw stopError(siteMatchStop(match, siteManager));
102
104
  return {
103
105
  client: createGscdumpV1Client({
104
106
  apiRoot: authentication.apiRoot,
105
107
  credential: hostedCredential(authentication)
106
108
  }),
107
109
  site: match.site,
108
- authentication
110
+ authentication,
111
+ siteManager
109
112
  };
110
113
  }
111
114
  export { HOSTED_ARGS, matchHostedSite, noSitesStop, resolveHostedSite, siteMatchStop };
package/dist/package.mjs CHANGED
@@ -1,2 +1,2 @@
1
- var version = "5.1.0";
1
+ var version = "5.2.0";
2
2
  export { version };
package/dist/stop.mjs CHANGED
@@ -7,4 +7,17 @@ function stopError(details, name = "StopError") {
7
7
  function stopDetailsOf(error) {
8
8
  return error instanceof Error ? error.stopDetails : void 0;
9
9
  }
10
- export { stopDetailsOf, stopError };
10
+ function userStopError(details) {
11
+ return Object.assign(stopError(details, "UserStopError"), { userStop: true });
12
+ }
13
+ function isUserStop(error) {
14
+ return error instanceof Error && error.userStop === true;
15
+ }
16
+ function commandLineError(message) {
17
+ return stopError({
18
+ code: "USAGE",
19
+ message,
20
+ nextCommand: null
21
+ });
22
+ }
23
+ export { commandLineError, isUserStop, stopDetailsOf, stopError, userStopError };
package/dist/utils.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { useCliRuntime } from "./runtime.mjs";
2
+ import { commandLineError } from "./stop.mjs";
2
3
  import { version } from "./package.mjs";
3
4
  import process from "node:process";
4
5
  import fs from "node:fs/promises";
@@ -56,29 +57,26 @@ function applyOutputMode(args) {
56
57
  function parseSearchType(value, flag = "--search-type") {
57
58
  if (!value) return void 0;
58
59
  const v = String(value);
59
- if (!ALL_SEARCH_TYPES.includes(v)) {
60
- logger.error(`Invalid ${flag}: ${v}. Allowed: ${ALL_SEARCH_TYPES.join(", ")}`);
61
- process.exit(1);
62
- }
60
+ if (!ALL_SEARCH_TYPES.includes(v)) throw commandLineError(`Invalid ${flag}: ${v}. Allowed: ${ALL_SEARCH_TYPES.join(", ")}`);
63
61
  return v;
64
62
  }
65
63
  function parseNameList(value, known, flag) {
66
64
  const names = String(value ?? "").split(",").map((name) => name.trim()).filter(Boolean);
67
65
  const unknown = names.filter((name) => !known.includes(name));
68
- if (unknown.length > 0) throw new Error(`Unknown ${flag} name: ${unknown.join(", ")}. Valid names: ${known.join(", ")}.`);
69
- if (names.length === 0) throw new Error(`${flag} needs at least one name. Valid names: ${known.join(", ")}.`);
66
+ if (unknown.length > 0) throw commandLineError(`Unknown ${flag} name: ${unknown.join(", ")}. Valid names: ${known.join(", ")}.`);
67
+ if (names.length === 0) throw commandLineError(`${flag} needs at least one name. Valid names: ${known.join(", ")}.`);
70
68
  return names;
71
69
  }
72
70
  function parseIntegerOption(value, flag, minimum = 1) {
73
71
  if (value === void 0 || value === null) return void 0;
74
72
  const text = typeof value === "number" || typeof value === "string" ? String(value).trim() : "";
75
73
  const parsed = Number(text);
76
- if (!/^\d+$/.test(text) || !Number.isSafeInteger(parsed) || parsed < minimum) throw new Error(`${flag} must be ${minimum === 0 ? "a non-negative integer" : "a positive integer"}.`);
74
+ if (!/^\d+$/.test(text) || !Number.isSafeInteger(parsed) || parsed < minimum) throw commandLineError(`${flag} must be ${minimum === 0 ? "a non-negative integer" : "a positive integer"}.`);
77
75
  return parsed;
78
76
  }
79
77
  function parseFetchBudget(value) {
80
78
  const budget = parseIntegerOption(value, "--fetch-budget");
81
- if (budget !== void 0 && budget > MAX_FETCH_BUDGET) throw new Error(`--fetch-budget must be at most ${MAX_FETCH_BUDGET}. Larger fetches use up the GSC quota.`);
79
+ if (budget !== void 0 && budget > MAX_FETCH_BUDGET) throw commandLineError(`--fetch-budget must be at most ${MAX_FETCH_BUDGET}. Larger fetches use up the GSC quota.`);
82
80
  return budget;
83
81
  }
84
82
  const ANSI_RE = /\x1B\[[0-9;]*m/g;
@@ -145,7 +143,7 @@ function applyGradient(text) {
145
143
  }
146
144
  function showSplash() {
147
145
  console.log();
148
- console.log(` ${applyGradient("GSC Dump")} v${VERSION}`);
146
+ console.log(` ${applyGradient("gscdump")} v${VERSION}`);
149
147
  console.log();
150
148
  }
151
149
  function clearLine() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/cli",
3
3
  "type": "module",
4
- "version": "5.1.0",
4
+ "version": "5.2.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,16 +44,16 @@
44
44
  "dependencies": {
45
45
  "@clack/prompts": "^1.8.1",
46
46
  "@duckdb/node-api": "1.5.5-r.5",
47
- "@gscdump/analysis": "^5.1.0",
48
- "@gscdump/contracts": "^5.1.0",
49
- "@gscdump/engine": "^5.1.0",
50
- "@gscdump/engine-gsc-api": "^5.1.0",
51
- "@gscdump/sdk": "^5.1.0",
47
+ "@gscdump/analysis": "^5.2.0",
48
+ "@gscdump/contracts": "^5.2.0",
49
+ "@gscdump/engine": "^5.2.0",
50
+ "@gscdump/engine-gsc-api": "^5.2.0",
51
+ "@gscdump/sdk": "^5.2.0",
52
52
  "@modelcontextprotocol/sdk": "^1.30.1",
53
53
  "citty": "^0.2.2",
54
54
  "consola": "^3.4.2",
55
55
  "google-auth-library": "^11.1.0",
56
- "gscdump": "^5.1.0",
56
+ "gscdump": "^5.2.0",
57
57
  "ofetch": "^1.5.1",
58
58
  "open": "^11.0.4",
59
59
  "proper-lockfile": "^4.1.2",
@@ -11,7 +11,7 @@ For `query`, `-s` means `--site`, `-d` means `--dimensions`, and `-f` means `--f
11
11
  Use `--start` and `--end` for dates. `--site=SITE` also works.
12
12
  Use each option once, with either its short or long spelling.
13
13
  Put options after the subcommand name: `gscdump store stats --json`, not `gscdump store --json stats`.
14
- A failed command prints one `Error:` line to stderr and exits 1. A [stop](#stops) with JSON output also prints `{ "error" }` on stdout.
14
+ A failed command prints one `Error:` line to stderr and exits 1. With JSON output it also prints `{ "error" }` on stdout. See [Stops](#stops).
15
15
  Example: `gscdump query --site=SITE --start=DATE --end=DATE -d page -f json`.
16
16
 
17
17
  ## Start each task
@@ -44,8 +44,10 @@ In Local mode, `googleAuthenticated: true` means Google accepted the credentials
44
44
  In Hosted mode, `hostedSync` lists each hosted Site with `syncStatus` and `syncProgress`, and `commands` lists what Hosted mode can run.
45
45
  `gscdump sites` shows the same Sites and progress.
46
46
  An empty `sites` list means the hosted record has no Sites. Commands that need a Site then stop with `NO_SITES`.
47
- Tell the user to connect a Site. A browser login connects Sites on gscdump.com.
47
+ Tell the user to connect a Site in the place the `NO_SITES` message names.
48
+ A browser login connects Sites on gscdump.com.
48
49
  An API key connects Sites in the app that issued the key. That app can be a partner app, not gscdump.com.
50
+ When gscdump.com names the app, the message names it too, for example `Connect a Site in Request Indexing.`
49
51
 
50
52
  `--mode local|hosted` overrides one invocation. `GSCDUMP_AUTH_MODE` also overrides the saved mode.
51
53
  A successful login saves the mode per profile.
@@ -131,7 +133,7 @@ mixes Store rows and live rows.
131
133
  - `--live` always asks Search Console. It needs Local mode with Google credentials.
132
134
  - `query --sql` reads the Store only. It stops when no table it names has data.
133
135
  - JSON output carries `meta.source`: `local`, `live`, or `hosted`.
134
- - A routing stop with JSON output prints `{ "error" }` on stdout and exits 1. See [Stops](#stops).
136
+ - A failure with JSON output prints `{ "error" }` on stdout and exits 1. See [Stops](#stops).
135
137
  Partial coverage and a running sync are normal progress. Run `nextCommand`, or tell the user to.
136
138
  - A sync is running only while its heartbeat is recent. A killed sync does not block reads.
137
139
  - Every Search Analytics, URL Inspection, and Indexing API call spends the shared quota ledger in the data dir.
@@ -226,20 +228,24 @@ Do not rewrite rows, estimate metrics, or add manually calculated totals.
226
228
  ## Stops
227
229
 
228
230
  A stop ends a command at a known condition and exits 1. stderr gets the message.
229
- If the command writes JSON, stdout gets `{ "error": { "code", "message", "nextCommand" } }`.
231
+ If the command writes JSON, every failure prints `{ "error": { "code", "message", "nextCommand" } }` on stdout. A stop has a code from the table below.
232
+ Any other failure has the code `FAILED`, such as a network or API error, or `UNEXPECTED`, a defect in the CLI.
230
233
  A command writes JSON with `--json`, with `--format json`, and for `query` with its default format.
234
+ If a command printed its JSON result before it failed, such as `inspect` after a quota stop, stdout holds only that result.
231
235
  Read `code`, not the message. If `nextCommand` is not null, run it, or tell the user to run it.
232
236
  A `gscdump auth login` command needs the user. Tell the user to run it. If `nextCommand` is null, tell the user the `message`.
233
237
  A routing stop also has `siteUrl`. `STORE_RANGE_NOT_COVERED` adds `missingDates`, and `SYNC_RUNNING` adds `sync.done` and `sync.total`.
234
238
 
235
239
  | Code | Meaning |
236
240
  | --- | --- |
241
+ | `USAGE` | The command line is wrong: an unknown command or option, or a value the option does not accept. Fix it with the command's `--help` |
237
242
  | `NOT_CONNECTED` | The Store cannot answer, and Google is not connected |
238
243
  | `STORE_RANGE_NOT_COVERED` | The Store does not have `missingDates` |
239
244
  | `SYNC_RUNNING` | A sync is still filling the dates |
240
245
  | `NO_SYNCED_DATA` | A Store-only read found no synced data |
241
246
  | `STORE_ONLY` | The read needs the Store, so `--live` cannot run it |
242
247
  | `LIVE_ONLY` | Only `--live` can answer the read |
248
+ | `LOGIN_CANCELLED` | The user selected Cancel on the gscdump.com login page. Ask before you run `nextCommand` |
243
249
  | `LOCAL_MODE_REQUIRED` | The command calls Google, and Hosted mode is selected |
244
250
  | `HOSTED_MODE_REQUIRED` | The command reads the hosted record, and Local mode is selected |
245
251
  | `HOSTED_CREDENTIALS_MISSING` | Hosted mode has no CLI session and no API key |
@@ -251,6 +257,7 @@ A routing stop also has `siteUrl`. `STORE_RANGE_NOT_COVERED` adds `missingDates`
251
257
  | `BING_NOT_CONNECTED` | The Site has no hosted Bing connection |
252
258
  | `RECORD_NOT_READY` | gscdump has not prepared the Site's record for reads yet |
253
259
  | `RANGE_NOT_SYNCED` | The hosted record does not hold the requested dates |
260
+ | `QUOTA_USED_UP` | A Google API quota is used up. The message says when it resets |
254
261
 
255
262
  ## Commands
256
263