@gscdump/cli 5.0.0 → 5.1.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,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,23 @@
1
- import { HOSTED_KEY_REJECTED } from "./error-handler.mjs";
2
1
  import { useCliRuntime } from "./runtime.mjs";
2
+ import { stopError } from "./stop.mjs";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import fs from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { z } from "zod";
7
7
  const HOSTED_SESSION_REJECTED = "gscdump.com rejected the CLI session. Run `gscdump auth login --mode hosted` again.";
8
+ const HOSTED_KEY_REJECTED = "gscdump.com rejected the API key. Run `gscdump auth login --mode hosted --api-key KEY` with a valid key.";
8
9
  const LOCAL_MODE_REQUIRED = [
9
10
  "This command calls Google, so it needs Local mode.",
10
11
  "Hosted mode reads your gscdump.com record and never calls Google.",
11
12
  "If you want Local mode, run `gscdump auth login --mode local`."
12
13
  ].join("\n");
14
+ function localModeRequired(detail) {
15
+ return stopError({
16
+ code: "LOCAL_MODE_REQUIRED",
17
+ message: detail ? `${detail}\n${LOCAL_MODE_REQUIRED}` : LOCAL_MODE_REQUIRED,
18
+ nextCommand: "gscdump auth login --mode local"
19
+ });
20
+ }
13
21
  const HOSTED_NOT_SET_UP = "Hosted credentials are missing. Run `gscdump auth login --mode hosted`, or set GSCDUMP_API_KEY.";
14
22
  const SAVED_STATE_INVALID = "The saved access mode is invalid. Run `gscdump auth login --mode local` or `gscdump auth login --mode hosted`.";
15
23
  const apiRootSchema = z.url().transform((value) => value.replace(/\/+$/, "")).refine((value) => {
@@ -129,7 +137,11 @@ async function resolveAuthentication() {
129
137
  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
138
  return state;
131
139
  }
132
- throw new Error(HOSTED_NOT_SET_UP);
140
+ throw stopError({
141
+ code: "HOSTED_CREDENTIALS_MISSING",
142
+ message: HOSTED_NOT_SET_UP,
143
+ nextCommand: "gscdump auth login --mode hosted"
144
+ });
133
145
  }
134
146
  const hostedSiteSchema = z.object({
135
147
  siteId: z.string(),
@@ -162,18 +174,41 @@ async function hostedRequest(state, route, options = {}) {
162
174
  redirect: "error"
163
175
  });
164
176
  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), {
177
+ const details = {
167
178
  statusCode: response.status,
168
179
  retryAfter: response.headers.get("retry-after"),
169
180
  response
170
- });
181
+ };
182
+ if (response.status === 401) {
183
+ const session = "sessionId" in state;
184
+ throw Object.assign(stopError({
185
+ code: "HOSTED_CREDENTIALS_REJECTED",
186
+ message: session ? HOSTED_SESSION_REJECTED : HOSTED_KEY_REJECTED,
187
+ nextCommand: session ? "gscdump auth login --mode hosted" : null
188
+ }), details);
189
+ }
190
+ throw Object.assign(/* @__PURE__ */ new Error(`Hosted request failed (${response.status}) for ${route.split("?")[0]}. Check \`gscdump auth status\`.`), details);
171
191
  }
172
192
  return response.status === 204 ? void 0 : response.json();
173
193
  }
174
194
  function hostedCredential(state) {
175
195
  return "sessionId" in state ? state.sessionId : state.apiKey;
176
196
  }
197
+ function siteManagerOf(state) {
198
+ return "sessionId" in state ? { _tag: "gscdump" } : { _tag: "key_issuer" };
199
+ }
200
+ function connectSiteStep(manager) {
201
+ 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.";
204
+ }
205
+ }
206
+ function siteSettingsPlace(manager) {
207
+ switch (manager._tag) {
208
+ case "gscdump": return "in the Site settings on gscdump.com";
209
+ case "key_issuer": return "in the Site settings of the app that issued this API key";
210
+ }
211
+ }
177
212
  async function getHostedAccount(state) {
178
213
  const result = accountSchema.safeParse(await hostedRequest(state, "/cli/me"));
179
214
  if (!result.success) throw new Error("gscdump.com returned invalid account data.");
@@ -183,4 +218,4 @@ function formatHostedSync(site) {
183
218
  const status = site.syncStatus ?? "pending";
184
219
  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
220
  }
186
- export { HOSTED_NOT_SET_UP, HOSTED_SESSION_REJECTED, LOCAL_MODE_REQUIRED, clearAuthentication, formatHostedSync, getHostedAccount, hostedCredential, hostedRequest, parseAuthMode, parseAuthentication, resolveAccessMode, resolveAuthentication, revokeHostedSession, saveAuthentication };
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 };
@@ -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)));
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.mjs CHANGED
@@ -1,10 +1,10 @@
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";
4
2
  import { parseAuthMode } from "./auth-state.mjs";
5
3
  import { checkCliArgs } from "./cli-args.mjs";
4
+ import { CLI_SUBCOMMANDS, resolveUsageTarget } from "./command-registry.mjs";
6
5
  import { resolveCliEnvironment } from "./environment.mjs";
7
6
  import { applyProfileFromCli } from "./commands/profile-selection.mjs";
7
+ import { reportCliError } from "./error-handler.mjs";
8
8
  import { resolveOutputOptions, terminalOutputOptions } from "./render/terminal.mjs";
9
9
  import { VERSION, setNoColor, showSplash, withConfiguredOutput } from "./utils.mjs";
10
10
  import process from "node:process";
@@ -96,6 +96,7 @@ async function runCli(opts = {}) {
96
96
  runtime.rawArgs = [...input];
97
97
  return runWithCliRuntime(runtime, async () => {
98
98
  let rawArgs = [...input];
99
+ runtime.jsonOutput = false;
99
100
  try {
100
101
  rawArgs = prepareCliArgs(input);
101
102
  runtime.rawArgs = [...rawArgs];
@@ -106,6 +107,7 @@ async function runCli(opts = {}) {
106
107
  } catch (error) {
107
108
  await reportCliError(error, {
108
109
  color: stderrColor(runtime, rawArgs),
110
+ json: runtime.jsonOutput,
109
111
  usage: async () => renderUsage(...await resolveUsageTarget(main, rawArgs)),
110
112
  authSources: async () => (await import("./auth.mjs")).formatAuthProvenance()
111
113
  });
@@ -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
2
  import { clearAuthentication, formatHostedSync, getHostedAccount, parseAuthMode, parseAuthentication, resolveAuthentication, revokeHostedSession, saveAuthentication } 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";
@@ -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,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))}`
343
343
  }];
344
344
  return [
345
345
  {
@@ -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 { siteManagerOf, 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, authentication } = 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, siteManagerOf(authentication));
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, authentication } = 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, siteManagerOf(authentication));
183
186
  }
184
187
  });
185
188
  const indexingWatchCommand = defineCommand({
@@ -1,6 +1,6 @@
1
- import { initCommandMeta } from "../command-meta.mjs";
2
1
  import { useCliRuntime } from "../runtime.mjs";
3
2
  import { saveAuthentication } from "../auth-state.mjs";
3
+ import { initCommandMeta } from "../command-meta.mjs";
4
4
  import { defaultDataDir, loadConfig, saveConfig } from "../config.mjs";
5
5
  import { OUTPUT_ARGS, applyOutputMode, logger } from "../utils.mjs";
6
6
  import { ACCESS_NOT_SET_UP, getAuth, loadTokens, resolveBYOK, resolveServiceAccount } from "../auth.mjs";
@@ -1,5 +1,5 @@
1
- import { mcpCommandMeta } from "../command-meta.mjs";
2
1
  import { runWithCliRuntime, useCliRuntime } from "../runtime.mjs";
2
+ import { mcpCommandMeta } from "../command-meta.mjs";
3
3
  import { VERSION } from "../utils.mjs";
4
4
  import { probeAuth } from "../auth.mjs";
5
5
  import { createCommandContext } from "../context.mjs";
@@ -1,5 +1,5 @@
1
- import { papercutCommandMeta } from "../command-meta.mjs";
2
1
  import { useCliRuntime } from "../runtime.mjs";
2
+ import { papercutCommandMeta } from "../command-meta.mjs";
3
3
  import { OUTPUT_ARGS, applyOutputMode, logger } from "../utils.mjs";
4
4
  import { buildPapercutBody, resolvePapercutUrl, submitPapercut } from "../papercut.mjs";
5
5
  import process from "node:process";
@@ -1,5 +1,5 @@
1
- import { profileCommandMeta } from "../command-meta.mjs";
2
1
  import { useCliRuntime } from "../runtime.mjs";
2
+ import { profileCommandMeta } from "../command-meta.mjs";
3
3
  import { getConfigDir, setConfigDir } from "../config.mjs";
4
4
  import { resolveCliEnvironment } from "../environment.mjs";
5
5
  import { ACTIVE_MARKER, PROFILES_DIR, ROOT_DIR, getProfileDir, readActiveMarkerSync } from "./profile-selection.mjs";
@@ -1,10 +1,10 @@
1
- import { queryCommandMeta } from "../command-meta.mjs";
2
- import { queryHostedRows } from "../hosted-query.mjs";
3
1
  import { useCliRuntime } from "../runtime.mjs";
4
2
  import { resolveAuthentication } from "../auth-state.mjs";
3
+ import { queryCommandMeta } from "../command-meta.mjs";
5
4
  import { loadConfig } from "../config.mjs";
5
+ import { queryHostedRows } from "../hosted-query.mjs";
6
6
  import { terminalOutputOptions } from "../render/terminal.mjs";
7
- import { ALL_SEARCH_TYPES, logger, parseSearchType, toCSV } from "../utils.mjs";
7
+ import { ALL_SEARCH_TYPES, logger, parseSearchType, setJsonOutput, toCSV } from "../utils.mjs";
8
8
  import { allTables } from "../local-store.mjs";
9
9
  import { createCommandContext, formatSiteResolution } from "../context.mjs";
10
10
  import { decideRoute, describeStop, liveNote, readRouteState, readSiteStates, resolveReadSite, stopAtRoute } from "../route.mjs";
@@ -215,6 +215,7 @@ const queryCommand = defineCommand({
215
215
  logger.error("Invalid --format. Use table, json, or csv.");
216
216
  process.exit(1);
217
217
  }
218
+ setJsonOutput(format === "json");
218
219
  if (args.sql || args.schema) {
219
220
  await runSqlMode({
220
221
  mode: args.schema ? { kind: "schema" } : {
@@ -341,7 +342,6 @@ const queryCommand = defineCommand({
341
342
  auth
342
343
  });
343
344
  const route = decideRoute(req, routeState);
344
- const json = format === "json";
345
345
  if (route.kind === "live") {
346
346
  const live = await connect();
347
347
  const siteUrl = site ?? await live.resolveSite(void 0);
@@ -406,7 +406,7 @@ const queryCommand = defineCommand({
406
406
  }, null, 2));
407
407
  return;
408
408
  }
409
- if (route.kind === "syncing" || route.kind === "prompt") stopAtRoute(route, req, routeState.auth, { json });
409
+ if (route.kind === "syncing" || route.kind === "prompt") stopAtRoute(route, req, routeState.auth);
410
410
  if (dataState || aggregationType) logger.warn("--data-state / --aggregation-type are ignored without --live");
411
411
  const siteUrl = site;
412
412
  if (!args.quiet) logger.debug(`Querying ${siteUrl} from local Parquet store...`);
@@ -595,7 +595,7 @@ async function runSqlMode(opts) {
595
595
  });
596
596
  if (route.kind === "syncing" || route.kind === "prompt") {
597
597
  views.close();
598
- stopAtRoute(route, req, routeState.auth, { json: opts.format === "json" });
598
+ stopAtRoute(route, req, routeState.auth);
599
599
  }
600
600
  }
601
601
  }
@@ -1,6 +1,6 @@
1
1
  import { reportCommandMeta } from "../command-meta.mjs";
2
2
  import { terminalOutputOptions } from "../render/terminal.mjs";
3
- import { logger, parseFetchBudget } from "../utils.mjs";
3
+ import { logger, parseFetchBudget, setJsonOutput } from "../utils.mjs";
4
4
  import { COMPARISON_FLAGS, PERIOD_FLAGS, parseWindowFlags, windowFlagErrorToException } from "../window.mjs";
5
5
  import { analysisNeeds, analyzerSources, analyzerTables, resolveAnalysisSource } from "../analysis-local.mjs";
6
6
  import { renderCliReport } from "../render/report.mjs";
@@ -127,6 +127,7 @@ function makeReportCommand(report) {
127
127
  ...reportArgs
128
128
  },
129
129
  async run({ args }) {
130
+ setJsonOutput(Boolean(args.json));
130
131
  const flags = windowFlags(args);
131
132
  const params = buildReportParams(report, args);
132
133
  const fetchBudget = parseFetchBudget(args["fetch-budget"]);
@@ -158,7 +159,6 @@ function makeReportCommand(report) {
158
159
  const { source, siteUrl, anchor, isLive } = await resolveAnalysisSource({
159
160
  site: args.site,
160
161
  live: !!args.live,
161
- json: !!args.json,
162
162
  label: `report ${report.id}`,
163
163
  types: provisional.map((step) => step.type),
164
164
  sources: reportSources(provisional),
@@ -1,8 +1,8 @@
1
1
  import { sitemapsCommandMeta } from "../command-meta.mjs";
2
2
  import { OUTPUT_ARGS, applyOutputMode, logger, parseIntegerOption, plural } from "../utils.mjs";
3
3
  import { createCommandContext } from "../context.mjs";
4
- import { discoverLiveSitemap, loadSitemapUrls } from "../sitemap.mjs";
5
4
  import { HOSTED_ARGS, resolveHostedSite } from "../hosted-site.mjs";
5
+ import { discoverLiveSitemap, loadSitemapUrls } from "../sitemap.mjs";
6
6
  import process from "node:process";
7
7
  import { defineCommand } from "citty";
8
8
  import { fetchSitemap } from "gscdump/sites";
@@ -1,5 +1,5 @@
1
+ import { connectSiteStep, formatHostedSync, getHostedAccount, localModeRequired, resolveAuthentication, siteManagerOf } from "../auth-state.mjs";
1
2
  import { sitesCommandMeta } from "../command-meta.mjs";
2
- import { LOCAL_MODE_REQUIRED, formatHostedSync, getHostedAccount, resolveAuthentication } from "../auth-state.mjs";
3
3
  import { OUTPUT_ARGS, applyOutputMode, logger, plural } from "../utils.mjs";
4
4
  import { createCommandContext, formatSiteResolution } from "../context.mjs";
5
5
  import process from "node:process";
@@ -422,7 +422,7 @@ const LIST_ARGS = {
422
422
  }
423
423
  };
424
424
  async function runListHostedSites(args, authentication) {
425
- if (args["with-sitemaps"] || args["owner-only"]) throw new Error(`--with-sitemaps and --owner-only read Search Console permissions.\n${LOCAL_MODE_REQUIRED}`);
425
+ if (args["with-sitemaps"] || args["owner-only"]) throw localModeRequired("--with-sitemaps and --owner-only read Search Console permissions.");
426
426
  const { sites } = await getHostedAccount(authentication);
427
427
  if (args.json) {
428
428
  console.log(JSON.stringify(sites.map((site) => ({
@@ -438,7 +438,7 @@ async function runListHostedSites(args, authentication) {
438
438
  return;
439
439
  }
440
440
  if (sites.length === 0) {
441
- logger.warn("Your hosted record has no Sites. Connect a Site at https://gscdump.com/app/onboarding?step=connect-sites.");
441
+ logger.warn(`Your hosted record has no Sites. ${connectSiteStep(siteManagerOf(authentication))}`);
442
442
  return;
443
443
  }
444
444
  logger.success(`Found ${plural(sites.length, "hosted Site", "hosted Sites")}:`);
package/dist/config.mjs CHANGED
@@ -2,8 +2,8 @@ import { useCliRuntime } from "./runtime.mjs";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
- import { SearchTypes } from "gscdump/query";
6
5
  import os from "node:os";
6
+ import { SearchTypes } from "gscdump/query";
7
7
  function setConfigDir(dir) {
8
8
  useCliRuntime().configDir = dir;
9
9
  }
package/dist/context.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { openQuotaLedger, quotaFetchOptions } from "./quota-ledger.mjs";
2
- import { LOCAL_MODE_REQUIRED, resolveAuthentication } from "./auth-state.mjs";
1
+ import { localModeRequired, resolveAuthentication } from "./auth-state.mjs";
3
2
  import { loadResolvedConfig } from "./config.mjs";
3
+ import { openQuotaLedger, quotaFetchOptions } from "./quota-ledger.mjs";
4
4
  import { resolveAuth } from "./auth.mjs";
5
5
  import { createLocalStore } from "./local-store.mjs";
6
6
  import { listStoreSites } from "./store-sites.mjs";
@@ -12,7 +12,7 @@ const FULL_SITE_URL_RE = /^(?:sc-domain:|https?:\/\/)/i;
12
12
  async function createCommandContext(opts = {}) {
13
13
  const { needsAuth = false, needsStore = false, interactive = false, byok, quota = true } = opts;
14
14
  const { config, dataDir } = await loadResolvedConfig();
15
- if (needsAuth && (await resolveAuthentication())._tag === "Hosted") throw new Error(LOCAL_MODE_REQUIRED);
15
+ if (needsAuth && (await resolveAuthentication())._tag === "Hosted") throw localModeRequired();
16
16
  const fetchOptions = needsAuth && quota ? quotaFetchOptions(await openQuotaLedger({ dataDir }), opts.fetchOptions) : opts.fetchOptions;
17
17
  const auth = needsAuth ? await resolveAuth({
18
18
  interactive,
@@ -1,13 +1,12 @@
1
+ import { stopDetailsOf } from "./stop.mjs";
1
2
  import { isUsageError } from "./command-registry.mjs";
2
3
  import { describeRecordReadRefusal } from "./hosted-query.mjs";
3
4
  import { quotaStopOf } from "./quota-ledger.mjs";
5
+ import { isQueryError } from "gscdump/query";
4
6
  import { isAnalysisError } from "@gscdump/analysis/errors";
5
7
  import { parseRecordReadRefusal } from "@gscdump/contracts";
6
8
  import { isEngineError } from "@gscdump/engine/errors";
7
9
  import { classifyError } from "gscdump/errors";
8
- import { isQueryError } from "gscdump/query";
9
- const HOSTED_KEY_REJECTED_REASON = "gscdump.com rejected the API key";
10
- const HOSTED_KEY_REJECTED = `${HOSTED_KEY_REJECTED_REASON}. Run \`gscdump auth login --mode hosted --api-key KEY\` with a valid key.`;
11
10
  const QUOTA_MESSAGE_RE = /quota|rate\s*limit/i;
12
11
  const ANSI_RE = /\x1B\[[0-9;]*m/g;
13
12
  function suggestionForGscError(error) {
@@ -109,19 +108,35 @@ function recordReadRefusalOf(error) {
109
108
  if (!(error instanceof Error) || error.name !== "GscdumpV1Error") return null;
110
109
  return parseRecordReadRefusal(error.details);
111
110
  }
111
+ function recordReadStop(refusal, text) {
112
+ return {
113
+ code: refusal.reason === "record_not_ready" ? "RECORD_NOT_READY" : "RANGE_NOT_SYNCED",
114
+ message: text.hint ? `${text.message} ${text.hint}` : text.message,
115
+ nextCommand: null
116
+ };
117
+ }
112
118
  function describeCliError(error) {
113
119
  if (isUsageError(error)) return {
114
120
  kind: "usage",
115
121
  message: error.message
116
122
  };
123
+ const stop = stopDetailsOf(error);
117
124
  if (error instanceof Error && error.routeStop !== void 0) return {
118
125
  kind: "stop",
119
- message: error.message
126
+ message: error.message,
127
+ ...stop ? { stop } : {}
120
128
  };
121
129
  if (error instanceof Error && quotaStopOf(error)) return {
122
130
  kind: "stop",
123
131
  message: error.message
124
132
  };
133
+ if (error instanceof Error && stop) return {
134
+ kind: "expected",
135
+ message: error.message,
136
+ hint: "",
137
+ showAuthSources: false,
138
+ stop
139
+ };
125
140
  if (error instanceof TypeError && error.message === "fetch failed") {
126
141
  const cause = error.cause;
127
142
  return {
@@ -143,18 +158,16 @@ function describeCliError(error) {
143
158
  showAuthSources: false
144
159
  };
145
160
  const refusal = recordReadRefusalOf(error);
146
- if (refusal) return {
147
- kind: "expected",
148
- ...describeRecordReadRefusal(refusal),
149
- showAuthSources: false
150
- };
161
+ if (refusal) {
162
+ const text = describeRecordReadRefusal(refusal);
163
+ return {
164
+ kind: "expected",
165
+ ...text,
166
+ showAuthSources: false,
167
+ stop: recordReadStop(refusal, text)
168
+ };
169
+ }
151
170
  const classified = classifyError(error);
152
- if (classified.message === HOSTED_KEY_REJECTED) return {
153
- kind: "expected",
154
- message: classified.message,
155
- hint: "",
156
- showAuthSources: false
157
- };
158
171
  return {
159
172
  kind: "expected",
160
173
  message: classified.message.replace(PARAM_NAME_RE, (name) => flagForParam(name)),
@@ -164,6 +177,7 @@ function describeCliError(error) {
164
177
  }
165
178
  async function reportCliError(error, options) {
166
179
  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));
167
181
  const red = (text) => options.color ? `\x1B[31m${text}\x1B[0m` : text;
168
182
  const lines = [];
169
183
  switch (report.kind) {
@@ -184,4 +198,4 @@ async function reportCliError(error, options) {
184
198
  const text = lines.join("\n");
185
199
  (options.write ?? ((value) => console.error(value)))(options.color ? text : text.replace(ANSI_RE, ""));
186
200
  }
187
- export { HOSTED_KEY_REJECTED, HOSTED_KEY_REJECTED_REASON, LocalStoreUnsupportedError, describeCliError, flagForParam, isAuthError, reportCliError };
201
+ export { LocalStoreUnsupportedError, describeCliError, flagForParam, isAuthError, reportCliError };
@@ -1,8 +1,7 @@
1
- import { getHostedAccount, hostedCredential, parseAuthentication, resolveAuthentication } from "./auth-state.mjs";
1
+ import { stopError } from "./stop.mjs";
2
+ import { connectSiteStep, getHostedAccount, hostedCredential, parseAuthentication, resolveAuthentication, siteManagerOf } from "./auth-state.mjs";
2
3
  import { loadConfig } from "./config.mjs";
3
4
  import { resolveCliEnvironment } from "./environment.mjs";
4
- import { logger } from "./utils.mjs";
5
- import process from "node:process";
6
5
  import { createGscdumpV1Client } from "@gscdump/sdk/v1";
7
6
  const HOSTED_ARGS = {
8
7
  "site": {
@@ -57,35 +56,56 @@ function matchHostedSite(sites, target) {
57
56
  sites: [...sites]
58
57
  };
59
58
  }
60
- function describeMatchFailure(match) {
61
- if (match.kind === "ambiguous") return `Multiple hosted Sites match "${match.target}": ${match.matches.map((site) => site.siteUrl).join(", ")}. Pass the exact Site URL with --site.`;
62
- if (match.sites.length === 0) return "Your hosted account has no Sites. Add a Site at https://gscdump.com.";
63
- const available = `Hosted Sites: ${match.sites.map((site) => site.siteUrl).join(", ")}.`;
64
- return match.target ? `No hosted Site matches "${match.target}". ${available}` : `Pass --site. ${available}`;
59
+ function noSitesStop(manager) {
60
+ return {
61
+ code: "NO_SITES",
62
+ message: `Your hosted record has no Sites. ${connectSiteStep(manager)} Then run the command again.`,
63
+ nextCommand: null
64
+ };
65
65
  }
66
- function fail(message) {
67
- logger.error(message);
68
- process.exit(1);
66
+ function siteMatchStop(match, manager) {
67
+ const nextCommand = "gscdump sites --json";
68
+ if (match.kind === "ambiguous") return {
69
+ code: "SITE_AMBIGUOUS",
70
+ message: `Multiple hosted Sites match "${match.target}": ${match.matches.map((site) => site.siteUrl).join(", ")}. Pass the exact Site URL with --site.`,
71
+ nextCommand
72
+ };
73
+ if (match.sites.length === 0) return noSitesStop(manager);
74
+ const available = `Hosted Sites: ${match.sites.map((site) => site.siteUrl).join(", ")}.`;
75
+ return match.target ? {
76
+ code: "SITE_NOT_FOUND",
77
+ message: `No hosted Site matches "${match.target}". ${available}`,
78
+ nextCommand
79
+ } : {
80
+ code: "SITE_REQUIRED",
81
+ message: `Pass --site. ${available}`,
82
+ nextCommand
83
+ };
69
84
  }
70
85
  async function resolveHostedSite(args, command) {
71
- if (args._?.length) fail(`\`gscdump ${command.name}\` does not accept a positional Site ID. Pass --site instead.`);
86
+ if (args._?.length) throw new Error(`\`gscdump ${command.name}\` does not accept a positional Site ID. Pass --site instead.`);
72
87
  const environment = resolveCliEnvironment().values;
73
88
  const authentication = args["api-key"] ? parseAuthentication({
74
89
  _tag: "Hosted",
75
90
  apiKey: args["api-key"],
76
91
  apiRoot: String(args["api-root"] || environment.GSCDUMP_API_ROOT || "https://gscdump.com/api")
77
92
  }) : await resolveAuthentication();
78
- if (authentication._tag !== "Hosted") fail(`\`gscdump ${command.name}\` reads the hosted record, so it needs Hosted mode. Run \`gscdump auth login --mode hosted\`, or pass --api-key. In Local mode, ${command.localAlternative}.`);
79
- if (args["api-root"] && String(args["api-root"]).replace(/\/+$/, "") !== authentication.apiRoot) fail("The API root changed. Pass --api-key for the new API root.");
93
+ if (authentication._tag !== "Hosted") throw stopError({
94
+ code: "HOSTED_MODE_REQUIRED",
95
+ message: `\`gscdump ${command.name}\` reads the hosted record, so it needs Hosted mode. Run \`gscdump auth login --mode hosted\`, or pass --api-key. In Local mode, ${command.localAlternative}.`,
96
+ nextCommand: "gscdump auth login --mode hosted"
97
+ });
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.");
80
99
  const target = args.site ? String(args.site) : (await loadConfig()).defaultSite;
81
100
  const match = matchHostedSite((await getHostedAccount(authentication)).sites, target);
82
- if (match.kind !== "found") fail(describeMatchFailure(match));
101
+ if (match.kind !== "found") throw stopError(siteMatchStop(match, siteManagerOf(authentication)));
83
102
  return {
84
103
  client: createGscdumpV1Client({
85
104
  apiRoot: authentication.apiRoot,
86
105
  credential: hostedCredential(authentication)
87
106
  }),
88
- site: match.site
107
+ site: match.site,
108
+ authentication
89
109
  };
90
110
  }
91
- export { HOSTED_ARGS, matchHostedSite, resolveHostedSite };
111
+ export { HOSTED_ARGS, matchHostedSite, noSitesStop, resolveHostedSite, siteMatchStop };
@@ -1,9 +1,9 @@
1
1
  import { resolveBYOK, resolveServiceAccount } from "../auth.mjs";
2
2
  import { COMPARISON_FLAGS, PERIOD_FLAGS } from "../window.mjs";
3
+ import { isQueryError } from "gscdump/query";
3
4
  import { isAnalysisError } from "@gscdump/analysis/errors";
4
5
  import { isEngineError } from "@gscdump/engine/errors";
5
6
  import { classifyError } from "gscdump/errors";
6
- import { isQueryError } from "gscdump/query";
7
7
  const PERIODS = PERIOD_FLAGS;
8
8
  const COMPARISONS = COMPARISON_FLAGS;
9
9
  const mcpHandlerErrors = {
package/dist/package.mjs CHANGED
@@ -1,2 +1,2 @@
1
- var version = "5.0.0";
1
+ var version = "5.1.0";
2
2
  export { version };
package/dist/route.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { stopError } from "./stop.mjs";
1
2
  import { probeAuth } from "./auth.mjs";
2
3
  import { listStoreSites } from "./store-sites.mjs";
3
4
  import { formatSiteResolution, siteArg } from "./context.mjs";
@@ -199,11 +200,8 @@ function stopCode(route) {
199
200
  case "partial": return "STORE_RANGE_NOT_COVERED";
200
201
  }
201
202
  }
202
- function routeStopError(route, message) {
203
- return Object.assign(new Error(message), {
204
- name: "RouteStopError",
205
- routeStop: route
206
- });
203
+ function routeStopError(route, details) {
204
+ return Object.assign(stopError(details, "RouteStopError"), { routeStop: route });
207
205
  }
208
206
  async function needCoverage(needs, states) {
209
207
  const reader = syncStateCoverageReader(states);
@@ -299,9 +297,7 @@ function describeStop(route, req, auth) {
299
297
  } } : {}
300
298
  };
301
299
  }
302
- function stopAtRoute(route, req, auth, opts) {
303
- const details = describeStop(route, req, auth);
304
- if (opts.json) console.log(JSON.stringify({ error: details }, null, 2));
305
- throw routeStopError(route, details.message);
300
+ function stopAtRoute(route, req, auth) {
301
+ throw routeStopError(route, describeStop(route, req, auth));
306
302
  }
307
303
  export { decideRoute, describeStop, liveCommand, liveNote, needCoverage, readRouteState, readSiteStates, resolveReadSite, routeMessage, routeStopError, stopAtRoute, stopCode, syncCommandFor };
@@ -7,6 +7,8 @@ export interface CliRuntime {
7
7
  configDir: string;
8
8
  configDirOverridden: boolean;
9
9
  environment: CliEnvironmentSource;
10
+ /** The command writes JSON to stdout. A stop then prints its JSON form there too. */
11
+ jsonOutput: boolean;
10
12
  logger: ConsolaInstance;
11
13
  quiet: boolean;
12
14
  rawArgs: string[];
package/dist/runtime.mjs CHANGED
@@ -21,6 +21,7 @@ function createCliRuntime(opts = {}) {
21
21
  configDir: opts.configDir ?? path.join(os.homedir(), ".config", "gscdump"),
22
22
  configDirOverridden: false,
23
23
  environment: opts.environment ?? process.env,
24
+ jsonOutput: false,
24
25
  logger: baseLogger.withTag("gscdump"),
25
26
  quiet: false,
26
27
  rawArgs: [...opts.rawArgs ?? process.argv.slice(2)]
package/dist/stop.mjs ADDED
@@ -0,0 +1,10 @@
1
+ function stopError(details, name = "StopError") {
2
+ return Object.assign(new Error(details.message), {
3
+ name,
4
+ stopDetails: details
5
+ });
6
+ }
7
+ function stopDetailsOf(error) {
8
+ return error instanceof Error ? error.stopDetails : void 0;
9
+ }
10
+ export { stopDetailsOf, stopError };
package/dist/utils.mjs CHANGED
@@ -2,8 +2,8 @@ import { useCliRuntime } from "./runtime.mjs";
2
2
  import { version } from "./package.mjs";
3
3
  import process from "node:process";
4
4
  import fs from "node:fs/promises";
5
- import { SearchTypes } from "gscdump/query";
6
5
  import os from "node:os";
6
+ import { SearchTypes } from "gscdump/query";
7
7
  import { Buffer } from "node:buffer";
8
8
  import { MAX_FETCH_BUDGET } from "@gscdump/engine/analysis-types";
9
9
  const ALL_SEARCH_TYPES = Object.values(SearchTypes);
@@ -40,10 +40,14 @@ const OUTPUT_ARGS = {
40
40
  description: "Suppress info/success output"
41
41
  }
42
42
  };
43
+ function setJsonOutput(json) {
44
+ useCliRuntime().jsonOutput = json;
45
+ }
43
46
  function applyOutputMode(args) {
44
47
  const json = Boolean(args.json);
45
48
  const quiet = json || Boolean(args.quiet);
46
49
  setQuiet(quiet);
50
+ setJsonOutput(json);
47
51
  return {
48
52
  json,
49
53
  quiet
@@ -148,10 +152,7 @@ function clearLine() {
148
152
  process.stdout.write("\r\x1B[K");
149
153
  }
150
154
  function displayPath(absPath) {
151
- const cwd = process.cwd();
152
155
  const home = os.homedir();
153
- if (absPath === cwd) return ".";
154
- if (absPath.startsWith(`${cwd}/`)) return absPath.slice(cwd.length + 1);
155
156
  if (absPath === home) return "~";
156
157
  if (absPath.startsWith(`${home}/`)) return `~/${absPath.slice(home.length + 1)}`;
157
158
  return absPath;
@@ -196,4 +197,4 @@ async function readUrlList(input) {
196
197
  for await (const chunk of process.stdin) chunks.push(chunk);
197
198
  return parseUrlLines(Buffer.concat(chunks).toString("utf-8"));
198
199
  }
199
- export { ALL_SEARCH_TYPES, OUTPUT_ARGS, VERSION, applyOutputMode, clearLine, color, cyan, dim, displayPath, formatAge, isColorEnabled, logger, parseFetchBudget, parseIntegerOption, parseNameList, parseSearchType, plural, progressBar, readUrlList, red, runWithConcurrency, setNoColor, setQuiet, showSplash, toCSV, withConfiguredOutput };
200
+ export { ALL_SEARCH_TYPES, OUTPUT_ARGS, VERSION, applyOutputMode, clearLine, color, cyan, dim, displayPath, formatAge, isColorEnabled, logger, parseFetchBudget, parseIntegerOption, parseNameList, parseSearchType, plural, progressBar, readUrlList, red, runWithConcurrency, setJsonOutput, setNoColor, setQuiet, showSplash, toCSV, withConfiguredOutput };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/cli",
3
3
  "type": "module",
4
- "version": "5.0.0",
4
+ "version": "5.1.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.0.0",
48
- "@gscdump/contracts": "^5.0.0",
49
- "@gscdump/engine": "^5.0.0",
50
- "@gscdump/engine-gsc-api": "^5.0.0",
51
- "@gscdump/sdk": "^5.0.0",
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",
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.0.0",
56
+ "gscdump": "^5.1.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.
14
+ A failed command prints one `Error:` line to stderr and exits 1. A [stop](#stops) with JSON output also prints `{ "error" }` on stdout.
15
15
  Example: `gscdump query --site=SITE --start=DATE --end=DATE -d page -f json`.
16
16
 
17
17
  ## Start each task
@@ -43,6 +43,9 @@ gscdump has 2 access modes. Check `gscdump auth status --json` before queries. R
43
43
  In Local mode, `googleAuthenticated: true` means Google accepted the credentials. When it is false, `googleError` says why.
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
+ 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.
48
+ An API key connects Sites in the app that issued the key. That app can be a partner app, not gscdump.com.
46
49
 
47
50
  `--mode local|hosted` overrides one invocation. `GSCDUMP_AUTH_MODE` also overrides the saved mode.
48
51
  A successful login saves the mode per profile.
@@ -128,8 +131,7 @@ mixes Store rows and live rows.
128
131
  - `--live` always asks Search Console. It needs Local mode with Google credentials.
129
132
  - `query --sql` reads the Store only. It stops when no table it names has data.
130
133
  - JSON output carries `meta.source`: `local`, `live`, or `hosted`.
131
- - A stop with `--format json` or `--json` prints `{ "error": { "code", "message", "nextCommand" } }` on stdout and exits 1.
132
- Codes: `NOT_CONNECTED`, `STORE_RANGE_NOT_COVERED` (with `missingDates`), `SYNC_RUNNING` (with `sync.done` and `sync.total`), `NO_SYNCED_DATA`, `STORE_ONLY`, `LIVE_ONLY`.
134
+ - A routing stop with JSON output prints `{ "error" }` on stdout and exits 1. See [Stops](#stops).
133
135
  Partial coverage and a running sync are normal progress. Run `nextCommand`, or tell the user to.
134
136
  - A sync is running only while its heartbeat is recent. A killed sync does not block reads.
135
137
  - Every Search Analytics, URL Inspection, and Indexing API call spends the shared quota ledger in the data dir.
@@ -153,7 +155,8 @@ gscdump skill install --agent claude # Codex: --agent codex
153
155
  ```
154
156
 
155
157
  After upgrading the CLI, run this command again to update the installed skill.
156
- The command prints where it wrote the skill. Clients without a skill
158
+ The command prints where it wrote the skill, as a path that works from any directory, such as `~/.claude/skills/gscdump`.
159
+ With `--json`, `destination` is the absolute path. Clients without a skill
157
160
  directory can read `gscdump --help` and `gscdump <command> --help` instead.
158
161
 
159
162
  ## Local mode setup
@@ -220,6 +223,35 @@ Parse JSON. Never scrape human output.
220
223
  If the user requests JSON, return the CLI JSON unchanged. Do not replace it with a table.
221
224
  Do not rewrite rows, estimate metrics, or add manually calculated totals.
222
225
 
226
+ ## Stops
227
+
228
+ 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" } }`.
230
+ A command writes JSON with `--json`, with `--format json`, and for `query` with its default format.
231
+ Read `code`, not the message. If `nextCommand` is not null, run it, or tell the user to run it.
232
+ A `gscdump auth login` command needs the user. Tell the user to run it. If `nextCommand` is null, tell the user the `message`.
233
+ A routing stop also has `siteUrl`. `STORE_RANGE_NOT_COVERED` adds `missingDates`, and `SYNC_RUNNING` adds `sync.done` and `sync.total`.
234
+
235
+ | Code | Meaning |
236
+ | --- | --- |
237
+ | `NOT_CONNECTED` | The Store cannot answer, and Google is not connected |
238
+ | `STORE_RANGE_NOT_COVERED` | The Store does not have `missingDates` |
239
+ | `SYNC_RUNNING` | A sync is still filling the dates |
240
+ | `NO_SYNCED_DATA` | A Store-only read found no synced data |
241
+ | `STORE_ONLY` | The read needs the Store, so `--live` cannot run it |
242
+ | `LIVE_ONLY` | Only `--live` can answer the read |
243
+ | `LOCAL_MODE_REQUIRED` | The command calls Google, and Hosted mode is selected |
244
+ | `HOSTED_MODE_REQUIRED` | The command reads the hosted record, and Local mode is selected |
245
+ | `HOSTED_CREDENTIALS_MISSING` | Hosted mode has no CLI session and no API key |
246
+ | `HOSTED_CREDENTIALS_REJECTED` | gscdump.com rejected the CLI session or API key. For an API key, `nextCommand` is null: the user needs a new key from the app that issued it |
247
+ | `NO_SITES` | The hosted record has no Sites. The message says where the user connects one |
248
+ | `SITE_NOT_FOUND` | No hosted Site matches `--site` |
249
+ | `SITE_AMBIGUOUS` | More than one hosted Site matches `--site` |
250
+ | `SITE_REQUIRED` | The hosted record has more than one Site. Pass `--site` |
251
+ | `BING_NOT_CONNECTED` | The Site has no hosted Bing connection |
252
+ | `RECORD_NOT_READY` | gscdump has not prepared the Site's record for reads yet |
253
+ | `RANGE_NOT_SYNCED` | The hosted record does not hold the requested dates |
254
+
223
255
  ## Commands
224
256
 
225
257
  | Command | Use it for |