@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,5 +1,5 @@
1
- import { LocalStoreUnsupportedError } from "./error-handler.mjs";
2
1
  import { useCliRuntime } from "./runtime.mjs";
2
+ import { LocalStoreUnsupportedError } from "./error-handler.mjs";
3
3
  import { logger } from "./utils.mjs";
4
4
  import { inferTable } from "./local-store.mjs";
5
5
  import { createCommandContext } from "./context.mjs";
@@ -174,7 +174,7 @@ async function resolveAnalysisSource(args) {
174
174
  auth
175
175
  });
176
176
  const route = decideRoute(req, state);
177
- if (route.kind === "syncing" || route.kind === "prompt") stopAtRoute(route, req, state.auth, { json: Boolean(args.json) });
177
+ if (route.kind === "syncing" || route.kind === "prompt") stopAtRoute(route, req, state.auth);
178
178
  if (route.kind === "local") {
179
179
  const source = createEngineQuerySource({
180
180
  engine: store.engine,
@@ -1,15 +1,24 @@
1
- import { HOSTED_KEY_REJECTED } from "./error-handler.mjs";
1
+ import { HOSTED_SESSION_EXPIRED, isSessionExpiredBody } from "./hosted-session.mjs";
2
2
  import { useCliRuntime } from "./runtime.mjs";
3
+ import { stopError } from "./stop.mjs";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import fs from "node:fs/promises";
5
6
  import path from "node:path";
6
7
  import { z } from "zod";
7
8
  const HOSTED_SESSION_REJECTED = "gscdump.com rejected the CLI session. Run `gscdump auth login --mode hosted` again.";
9
+ const HOSTED_KEY_REJECTED = "gscdump.com rejected the API key. Run `gscdump auth login --mode hosted --api-key KEY` with a valid key.";
8
10
  const LOCAL_MODE_REQUIRED = [
9
11
  "This command calls Google, so it needs Local mode.",
10
12
  "Hosted mode reads your gscdump.com record and never calls Google.",
11
13
  "If you want Local mode, run `gscdump auth login --mode local`."
12
14
  ].join("\n");
15
+ function localModeRequired(detail) {
16
+ return stopError({
17
+ code: "LOCAL_MODE_REQUIRED",
18
+ message: detail ? `${detail}\n${LOCAL_MODE_REQUIRED}` : LOCAL_MODE_REQUIRED,
19
+ nextCommand: "gscdump auth login --mode local"
20
+ });
21
+ }
13
22
  const HOSTED_NOT_SET_UP = "Hosted credentials are missing. Run `gscdump auth login --mode hosted`, or set GSCDUMP_API_KEY.";
14
23
  const SAVED_STATE_INVALID = "The saved access mode is invalid. Run `gscdump auth login --mode local` or `gscdump auth login --mode hosted`.";
15
24
  const apiRootSchema = z.url().transform((value) => value.replace(/\/+$/, "")).refine((value) => {
@@ -129,7 +138,11 @@ async function resolveAuthentication() {
129
138
  if (env.GSCDUMP_API_ROOT && env.GSCDUMP_API_ROOT.replace(/\/+$/, "") !== state.apiRoot) throw new Error("The API root changed. Run `gscdump auth login --mode hosted` for the new API root.");
130
139
  return state;
131
140
  }
132
- throw new Error(HOSTED_NOT_SET_UP);
141
+ throw stopError({
142
+ code: "HOSTED_CREDENTIALS_MISSING",
143
+ message: HOSTED_NOT_SET_UP,
144
+ nextCommand: "gscdump auth login --mode hosted"
145
+ });
133
146
  }
134
147
  const hostedSiteSchema = z.object({
135
148
  siteId: z.string(),
@@ -144,12 +157,18 @@ const hostedSiteSchema = z.object({
144
157
  newestDateSynced: z.string().nullable().optional(),
145
158
  oldestDateSynced: z.string().nullable().optional()
146
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
+ })]);
147
165
  const accountSchema = z.object({
148
166
  user: z.object({
149
167
  publicId: z.string(),
150
168
  email: z.string()
151
169
  }),
152
- sites: z.array(hostedSiteSchema)
170
+ sites: z.array(hostedSiteSchema),
171
+ issuer: keyIssuerSchema.optional().catch(void 0)
153
172
  });
154
173
  async function hostedRequest(state, route, options = {}) {
155
174
  const response = await fetch(`${state.apiRoot}${route}`, {
@@ -162,18 +181,52 @@ async function hostedRequest(state, route, options = {}) {
162
181
  redirect: "error"
163
182
  });
164
183
  if (!response.ok) {
165
- const message = response.status === 401 ? "sessionId" in state ? HOSTED_SESSION_REJECTED : HOSTED_KEY_REJECTED : `Hosted request failed (${response.status}) for ${route.split("?")[0]}. Check \`gscdump auth status\`.`;
166
- throw Object.assign(new Error(message), {
184
+ const details = {
167
185
  statusCode: response.status,
168
186
  retryAfter: response.headers.get("retry-after"),
169
187
  response
170
- });
188
+ };
189
+ if (response.status === 401) {
190
+ const session = "sessionId" in state;
191
+ const expired = session && isSessionExpiredBody(await response.json().catch(() => null));
192
+ throw Object.assign(stopError(expired ? HOSTED_SESSION_EXPIRED : {
193
+ code: "HOSTED_CREDENTIALS_REJECTED",
194
+ message: session ? HOSTED_SESSION_REJECTED : HOSTED_KEY_REJECTED,
195
+ nextCommand: session ? "gscdump auth login --mode hosted" : null
196
+ }), details);
197
+ }
198
+ throw Object.assign(/* @__PURE__ */ new Error(`Hosted request failed (${response.status}) for ${route.split("?")[0]}. Check \`gscdump auth status\`.`), details);
171
199
  }
172
200
  return response.status === 204 ? void 0 : response.json();
173
201
  }
174
202
  function hostedCredential(state) {
175
203
  return "sessionId" in state ? state.sessionId : state.apiKey;
176
204
  }
205
+ function siteManagerOf(state, issuer) {
206
+ if ("sessionId" in state) return { _tag: "gscdump" };
207
+ return issuer ?? { _tag: "key_issuer" };
208
+ }
209
+ function connectSitePlace(manager) {
210
+ switch (manager._tag) {
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";
214
+ }
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
+ }
223
+ function siteSettingsPlace(manager) {
224
+ switch (manager._tag) {
225
+ case "gscdump": return "in the Site settings on gscdump.com";
226
+ case "partner": return `in the Site settings of ${manager.name}`;
227
+ case "key_issuer": return "in the Site settings of the app that issued this API key";
228
+ }
229
+ }
177
230
  async function getHostedAccount(state) {
178
231
  const result = accountSchema.safeParse(await hostedRequest(state, "/cli/me"));
179
232
  if (!result.success) throw new Error("gscdump.com returned invalid account data.");
@@ -183,4 +236,4 @@ function formatHostedSync(site) {
183
236
  const status = site.syncStatus ?? "pending";
184
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}` : ""}`;
185
238
  }
186
- export { HOSTED_NOT_SET_UP, HOSTED_SESSION_REJECTED, LOCAL_MODE_REQUIRED, clearAuthentication, formatHostedSync, getHostedAccount, hostedCredential, hostedRequest, parseAuthMode, parseAuthentication, resolveAccessMode, resolveAuthentication, revokeHostedSession, saveAuthentication };
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 };
@@ -1,7 +1,10 @@
1
- import { getHostedAccount, hostedCredential } from "./auth-state.mjs";
1
+ import { stopError } from "./stop.mjs";
2
+ import { getHostedAccount, hostedCredential, siteManagerOf } from "./auth-state.mjs";
2
3
  import { logger } from "./utils.mjs";
3
4
  import { writeBingDump } from "./bing-data.mjs";
5
+ import { noSitesStop } from "./hosted-site.mjs";
4
6
  import { createGscdumpV1Client } from "@gscdump/sdk/v1";
7
+ const BING_SITES_COMMAND = "gscdump bing sites --json";
5
8
  function hostedBingClient(state) {
6
9
  return createGscdumpV1Client({
7
10
  apiRoot: state.apiRoot,
@@ -30,15 +33,33 @@ async function loadHostedBingConnections(state, sites, options = {}) {
30
33
  async function resolveHostedBingSites(state, input) {
31
34
  if (Boolean(input.site) === Boolean(input.allSites)) throw new Error("Choose --site or --all-sites.");
32
35
  const account = await getHostedAccount(state);
36
+ if (account.sites.length === 0) throw stopError(noSitesStop(siteManagerOf(state, account.issuer)));
33
37
  const exact = account.sites.filter((site) => site.siteId === input.site || site.siteUrl === input.site);
34
38
  const normalize = (value) => URL.parse(value.startsWith("sc-domain:") ? `https://${value.slice(10)}/` : value)?.toString();
35
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);
36
- if (requested.length === 0) throw new Error("No matching Bing site. Run `gscdump bing sites`.");
37
- if (!input.allSites && requested.length > 1) throw new Error("Multiple Bing sites match. Use a site ID from `gscdump bing sites`.");
40
+ if (requested.length === 0) throw stopError({
41
+ code: "SITE_NOT_FOUND",
42
+ message: "No matching Bing site. Run `gscdump bing sites`.",
43
+ nextCommand: BING_SITES_COMMAND
44
+ });
45
+ if (!input.allSites && requested.length > 1) throw stopError({
46
+ code: "SITE_AMBIGUOUS",
47
+ message: "Multiple Bing sites match. Use a site ID from `gscdump bing sites`.",
48
+ nextCommand: BING_SITES_COMMAND
49
+ });
38
50
  const connections = await loadHostedBingConnections(state, requested, { tolerateFailures: Boolean(input.allSites) });
39
51
  const selected = input.allSites ? connections.filter((site) => site.connection._tag === "connected") : connections;
40
- if (selected.length === 0) throw new Error("No matching Bing site. Run `gscdump bing sites`.");
41
- if (input.requireConnected !== false && selected.some((site) => site.connection._tag !== "connected")) throw new Error("Bing is not connected. Run `gscdump bing login --site SITE_ID`.");
52
+ if (selected.length === 0) throw stopError({
53
+ code: "SITE_NOT_FOUND",
54
+ message: "No matching Bing site. Run `gscdump bing sites`.",
55
+ nextCommand: BING_SITES_COMMAND
56
+ });
57
+ const unconnected = input.requireConnected === false ? void 0 : selected.find((site) => site.connection._tag !== "connected");
58
+ if (unconnected) throw stopError({
59
+ code: "BING_NOT_CONNECTED",
60
+ message: `Bing is not connected for ${unconnected.siteUrl}. Run \`gscdump bing login --site ${unconnected.siteId}\`.`,
61
+ nextCommand: `gscdump bing login --site ${unconnected.siteId}`
62
+ });
42
63
  return selected;
43
64
  }
44
65
  async function dumpHostedBingSite(state, site, outDir, options) {
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,10 +1,11 @@
1
- import { CLI_SUBCOMMANDS, resolveUsageTarget } from "./command-registry.mjs";
2
- import { reportCliError } from "./error-handler.mjs";
3
1
  import { createCliRuntime, runWithCliRuntime, useCliRuntime } from "./runtime.mjs";
2
+ import { commandLineError } from "./stop.mjs";
4
3
  import { parseAuthMode } from "./auth-state.mjs";
5
4
  import { checkCliArgs } from "./cli-args.mjs";
5
+ import { CLI_SUBCOMMANDS, resolveUsageTarget } from "./command-registry.mjs";
6
6
  import { resolveCliEnvironment } from "./environment.mjs";
7
7
  import { applyProfileFromCli } from "./commands/profile-selection.mjs";
8
+ import { reportCliError } from "./error-handler.mjs";
8
9
  import { resolveOutputOptions, terminalOutputOptions } from "./render/terminal.mjs";
9
10
  import { VERSION, setNoColor, showSplash, withConfiguredOutput } from "./utils.mjs";
10
11
  import process from "node:process";
@@ -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,
@@ -96,21 +130,28 @@ async function runCli(opts = {}) {
96
130
  runtime.rawArgs = [...input];
97
131
  return runWithCliRuntime(runtime, async () => {
98
132
  let rawArgs = [...input];
133
+ runtime.jsonOutput = false;
134
+ const stdout = watchStdout();
99
135
  try {
136
+ runtime.jsonOutput = asksForJson(input);
100
137
  rawArgs = prepareCliArgs(input);
101
138
  runtime.rawArgs = [...rawArgs];
139
+ runtime.jsonOutput = asksForJson(rawArgs);
102
140
  const argumentError = await checkCliArgs(main, rawArgs);
103
- if (argumentError) throw new Error(argumentError);
141
+ if (argumentError) throw commandLineError(argumentError);
104
142
  await withConfiguredOutput(() => runMainCommand(rawArgs));
105
143
  return 0;
106
144
  } catch (error) {
107
145
  await reportCliError(error, {
108
146
  color: stderrColor(runtime, rawArgs),
147
+ json: runtime.jsonOutput && !stdout.wrote(),
109
148
  usage: async () => renderUsage(...await resolveUsageTarget(main, rawArgs)),
110
149
  authSources: async () => (await import("./auth.mjs")).formatAuthProvenance()
111
150
  });
112
151
  return 1;
152
+ } finally {
153
+ stdout.restore();
113
154
  }
114
155
  });
115
156
  }
116
- 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,6 +1,6 @@
1
1
  import { analyzeCommandMeta } from "../command-meta.mjs";
2
2
  import { terminalOutputOptions } from "../render/terminal.mjs";
3
- import { logger, parseFetchBudget, parseIntegerOption, toCSV } from "../utils.mjs";
3
+ import { logger, parseFetchBudget, parseIntegerOption, setJsonOutput, toCSV } from "../utils.mjs";
4
4
  import { DEFAULT_WINDOW, PERIOD_FLAGS, parseWindowFlags, windowFlagErrorToException } from "../window.mjs";
5
5
  import { analysisNeeds, analyzerTables, resolveAnalysisSource } from "../analysis-local.mjs";
6
6
  import { coverageWarning, renderAnalysis } from "../render/analysis.mjs";
@@ -176,10 +176,10 @@ function makeToolCommand(tool) {
176
176
  ].includes(args.format ?? "table")) throw new Error("Invalid --format. Use table, json, or csv.");
177
177
  const baseParams = buildParams(tool, args);
178
178
  const format = args.json ? "json" : String(args.format ?? "table");
179
+ setJsonOutput(format === "json");
179
180
  const { runAnalysis, siteUrl, anchor } = await resolveAnalysisSource({
180
181
  site: args.site,
181
182
  live: !!args.live,
182
- json: format === "json",
183
183
  label: `analyze ${tool}`,
184
184
  types: [tool],
185
185
  anchorTables: analyzerTables(baseParams),
@@ -1,6 +1,6 @@
1
- import { authCommandMeta } from "../command-meta.mjs";
2
1
  import { useCliRuntime } from "../runtime.mjs";
3
- 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
+ import { authCommandMeta } from "../command-meta.mjs";
4
4
  import { loadConfig, saveConfig } from "../config.mjs";
5
5
  import { OUTPUT_ARGS, applyOutputMode, logger } from "../utils.mjs";
6
6
  import { ACCESS_NOT_SET_UP, clearTokens, formatAuthProvenance, getAuth, loadServiceAccount, loadTokens, resolveBYOK, saveTokens } from "../auth.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,6 +1,7 @@
1
- import { bingCommandMeta } from "../command-meta.mjs";
2
1
  import { useCliRuntime } from "../runtime.mjs";
2
+ import { stopError } from "../stop.mjs";
3
3
  import { resolveAuthentication, saveAuthentication } from "../auth-state.mjs";
4
+ import { bingCommandMeta } from "../command-meta.mjs";
4
5
  import { OUTPUT_ARGS, applyOutputMode, logger, plural } from "../utils.mjs";
5
6
  import { BING_REDIRECT_URI, clearBingCredentials, getBingClient, loginBingOAuth, saveBingCredentials } from "../bing-auth.mjs";
6
7
  import { BING_DATASETS, dumpBingSite, parseBingDumpOptions, resolveBingSites, unwrapBing } from "../bing-data.mjs";
@@ -323,7 +324,11 @@ const bingCommand = defineCommand({
323
324
  async run({ args }) {
324
325
  applyOutputMode(args);
325
326
  const authentication = await resolveAuthentication();
326
- if (authentication._tag !== "Hosted") throw new Error("Hosted Bing connection verification needs Hosted mode. Pass --mode hosted.");
327
+ if (authentication._tag !== "Hosted") throw stopError({
328
+ code: "HOSTED_MODE_REQUIRED",
329
+ message: "Hosted Bing connection verification needs Hosted mode. Pass --mode hosted.",
330
+ nextCommand: "gscdump auth login --mode hosted"
331
+ });
327
332
  const [site] = await resolveHostedBingSites(authentication, {
328
333
  site: args.site,
329
334
  requireConnected: false
@@ -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);
@@ -1,5 +1,5 @@
1
+ import { connectSiteStep, getHostedAccount, resolveAuthentication, siteManagerOf } from "../auth-state.mjs";
1
2
  import { doctorCommandMeta } from "../command-meta.mjs";
2
- import { getHostedAccount, resolveAuthentication } from "../auth-state.mjs";
3
3
  import { loadConfig } from "../config.mjs";
4
4
  import { pickCliEnvironmentValue, resolveCliEnvironment } from "../environment.mjs";
5
5
  import { OUTPUT_ARGS, applyOutputMode, displayPath, logger } from "../utils.mjs";
@@ -339,7 +339,7 @@ async function checkHostedConnection(authentication) {
339
339
  } : {
340
340
  name: "hosted.sites",
341
341
  status: "warn",
342
- detail: "no hosted Sites. Connect a Site at https://gscdump.com/app/onboarding?step=connect-sites"
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({
@@ -121,8 +121,8 @@ const indexingUrlsCommand = defineCommand({
121
121
  if (limit > MAX_PAGE_SIZE) throw new Error(`--limit must be ${MAX_PAGE_SIZE} or less. Use --all to read every page.`);
122
122
  const search = args.search ? String(args.search) : void 0;
123
123
  applyOutputMode({
124
- json: format !== "table",
125
- quiet: args.quiet
124
+ json: format === "json",
125
+ quiet: Boolean(args.quiet) || format === "csv"
126
126
  });
127
127
  const filters = {
128
128
  ...status ? { status } : {},
@@ -1,3 +1,4 @@
1
+ import { siteSettingsPlace } from "../auth-state.mjs";
1
2
  import { terminalOutputOptions } from "../render/terminal.mjs";
2
3
  import { applyOutputMode, logger, readUrlList } from "../utils.mjs";
3
4
  import { renderTable } from "../render/layout.mjs";
@@ -39,12 +40,14 @@ function watchedRow(entry) {
39
40
  dueAt: entry.dueAt.slice(0, 10)
40
41
  };
41
42
  }
42
- const SKIP_HINTS = { inspection_disabled: "gscdump never inspects this Site, so a Watched URL gets no Checkpoint. To use Watched URLs, turn on URL Inspection in the Site settings on gscdump.com." };
43
- function printChange(verb, result, siteUrl) {
43
+ function skipHint(reason, manager) {
44
+ return reason === "inspection_disabled" ? `gscdump never inspects this Site, so a Watched URL gets no Checkpoint. To use Watched URLs, turn on URL Inspection ${siteSettingsPlace(manager)}.` : void 0;
45
+ }
46
+ function printChange(verb, result, siteUrl, manager) {
44
47
  for (const url of result.changed) console.log(`${verb}: ${url}`);
45
48
  for (const url of result.unchanged) console.log(`${verb === "Added" ? "Already watched" : "Not watched"}: ${url}`);
46
49
  for (const { url, reason } of result.skipped) console.log(`Skipped (${reason}): ${url}`);
47
- for (const hint of new Set(result.skipped.map(({ reason }) => SKIP_HINTS[reason]).filter(Boolean))) console.log(hint);
50
+ for (const hint of new Set(result.skipped.map(({ reason }) => skipHint(reason, manager)).filter(Boolean))) console.log(hint);
48
51
  logger.info(`${siteUrl} has ${result.total} of ${result.limit} Watched URLs.`);
49
52
  }
50
53
  async function readUrls(args) {
@@ -133,7 +136,7 @@ const addCommand = defineCommand({
133
136
  async run({ args }) {
134
137
  const { json } = applyOutputMode(args);
135
138
  const urls = await readUrls(args);
136
- const { client, site } = await resolveHostedSite({
139
+ const { client, site, siteManager } = await resolveHostedSite({
137
140
  ...args,
138
141
  _: []
139
142
  }, {
@@ -148,7 +151,7 @@ const addCommand = defineCommand({
148
151
  console.log(JSON.stringify(data, null, 2));
149
152
  return;
150
153
  }
151
- printChange("Added", data, site.siteUrl);
154
+ printChange("Added", data, site.siteUrl, siteManager);
152
155
  }
153
156
  });
154
157
  const removeCommand = defineCommand({
@@ -164,7 +167,7 @@ const removeCommand = defineCommand({
164
167
  async run({ args }) {
165
168
  const { json } = applyOutputMode(args);
166
169
  const urls = await readUrls(args);
167
- const { client, site } = await resolveHostedSite({
170
+ const { client, site, siteManager } = await resolveHostedSite({
168
171
  ...args,
169
172
  _: []
170
173
  }, {
@@ -179,7 +182,7 @@ const removeCommand = defineCommand({
179
182
  console.log(JSON.stringify(data, null, 2));
180
183
  return;
181
184
  }
182
- printChange("Removed", data, site.siteUrl);
185
+ printChange("Removed", data, site.siteUrl, siteManager);
183
186
  }
184
187
  });
185
188
  const indexingWatchCommand = defineCommand({