@gscdump/cli 3.7.0 → 4.0.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 (73) hide show
  1. package/README.md +113 -30
  2. package/bin/gscdump.mjs +21 -1
  3. package/dist/analysis-local.mjs +172 -53
  4. package/dist/auth-state.mjs +21 -6
  5. package/dist/auth.mjs +27 -18
  6. package/dist/bing-data.mjs +6 -2
  7. package/dist/bing-hosted.mjs +1 -1
  8. package/dist/cli-args.mjs +8 -0
  9. package/dist/cli.d.mts +5 -1
  10. package/dist/cli.mjs +41 -10
  11. package/dist/cloud-google.mjs +8 -1
  12. package/dist/command-meta.mjs +4 -4
  13. package/dist/command-registry.mjs +86 -10
  14. package/dist/commands/analyze.mjs +84 -43
  15. package/dist/commands/auth.mjs +46 -37
  16. package/dist/commands/bing.mjs +3 -3
  17. package/dist/commands/compact.mjs +12 -8
  18. package/dist/commands/config.mjs +14 -14
  19. package/dist/commands/doctor.mjs +26 -26
  20. package/dist/commands/dump.mjs +203 -105
  21. package/dist/commands/entities.mjs +10 -113
  22. package/dist/commands/gc.mjs +4 -3
  23. package/dist/commands/indexing-urls.mjs +185 -0
  24. package/dist/commands/indexing.mjs +12 -8
  25. package/dist/commands/init.mjs +77 -18
  26. package/dist/commands/inspect.mjs +97 -110
  27. package/dist/commands/mcp.mjs +37 -57
  28. package/dist/commands/papercut.mjs +1 -1
  29. package/dist/commands/profile.mjs +9 -17
  30. package/dist/commands/query.mjs +219 -210
  31. package/dist/commands/report.mjs +68 -85
  32. package/dist/commands/rollups.mjs +9 -6
  33. package/dist/commands/sitemaps.mjs +51 -104
  34. package/dist/commands/sites.mjs +40 -24
  35. package/dist/commands/stats.mjs +14 -17
  36. package/dist/commands/store-purge.mjs +9 -5
  37. package/dist/commands/store.mjs +0 -12
  38. package/dist/commands/sync.mjs +774 -296
  39. package/dist/config.mjs +7 -4
  40. package/dist/context.mjs +51 -21
  41. package/dist/coverage.mjs +251 -0
  42. package/dist/dump-bing.mjs +71 -0
  43. package/dist/dump-writers.mjs +246 -0
  44. package/dist/error-handler.mjs +108 -38
  45. package/dist/filters.mjs +125 -0
  46. package/dist/hosted-site.mjs +91 -0
  47. package/dist/inspect-urls.mjs +76 -0
  48. package/dist/inspection-record.mjs +93 -0
  49. package/dist/local-entities.mjs +658 -0
  50. package/dist/mcp/errors.mjs +71 -18
  51. package/dist/mcp/handlers/diagnostics.mjs +15 -12
  52. package/dist/mcp/handlers/reports.mjs +33 -42
  53. package/dist/mcp/server/index.mjs +80 -38
  54. package/dist/mcp/types.mjs +7 -7
  55. package/dist/package.mjs +1 -1
  56. package/dist/quota-ledger.mjs +370 -0
  57. package/dist/render/analysis.mjs +8 -1
  58. package/dist/render/report.mjs +8 -1
  59. package/dist/request-pacer.mjs +29 -0
  60. package/dist/route.mjs +295 -0
  61. package/dist/sitemap.mjs +4 -0
  62. package/dist/sql-views.mjs +117 -0
  63. package/dist/store-sites.mjs +134 -0
  64. package/dist/sync-plan.mjs +129 -0
  65. package/dist/sync-run.mjs +96 -0
  66. package/dist/table-sources.mjs +57 -0
  67. package/dist/token-info.mjs +54 -0
  68. package/dist/utils.mjs +28 -19
  69. package/dist/window.mjs +159 -0
  70. package/package.json +17 -14
  71. package/skills/gscdump/SKILL.md +167 -37
  72. package/dist/commands/export.mjs +0 -76
  73. package/dist/native-duckdb.mjs +0 -46
package/README.md CHANGED
@@ -28,13 +28,13 @@ gscdump auth login --mode local
28
28
  gscdump sites
29
29
 
30
30
  # Sync 90 days to the Store
31
- gscdump sync --site sc-domain:example.com --days 90 --tables pages,queries,page_queries,countries,dates
31
+ gscdump sync --site example.com --days 90 --tables pages,queries,page_queries,countries,dates
32
32
 
33
33
  # Query the Store
34
- gscdump query --site sc-domain:example.com --dimensions page,query --limit 50
34
+ gscdump query --site example.com --dimensions page,query --limit 50
35
35
 
36
36
  # Run an Analyzer
37
- gscdump analyze striking-distance --site sc-domain:example.com
37
+ gscdump analyze striking-distance --site example.com
38
38
 
39
39
  # Start the MCP server
40
40
  gscdump mcp
@@ -44,7 +44,7 @@ gscdump mcp
44
44
 
45
45
  | Command | Description |
46
46
  |---|---|
47
- | `init` | Full setup (OAuth + dataDir; offers to write a `.env` for later use) |
47
+ | `init` | Full setup (OAuth + dataDir; offers to write a `.env` for later use). `--mode cloud\|local` saves the auth mode. Without a terminal it never prompts |
48
48
  | `auth` | Manage authentication (`status`, `login`, `logout`, `refresh`) |
49
49
  | `bing` | Connect Bing, list sites, dump datasets, inspect URLs, and check hosted verification |
50
50
  | `config` | Manage CLI configuration (`show`, `set`, `unset`, `path`, `validate`) |
@@ -52,18 +52,17 @@ gscdump mcp
52
52
  | `sites [--owner-only] [--with-sitemaps]` | List available GSC sites |
53
53
  | `sites add <url>` / `sites delete <url> [--yes]` | Register or remove a Site in Search Console (add registers in unverified state) |
54
54
  | `sites verify-token <url> [--method]` / `sites verify <url> [--method]` | Get a verification token, then trigger ownership verification (META/FILE/DNS_TXT/DNS_CNAME/ANALYTICS/TAG_MANAGER) |
55
- | `sitemaps` | List, submit, or delete Google sitemaps; probe live URLs; read hosted snapshots (`current`, `history`, `membership`, `lastmod`, `export`) |
56
- | `inspect <url>` / `inspect batch [--concurrency]` | URL inspection (single URL or batch from file/stdin); renders Indexing Evidence, rich results, and AMP |
57
- | `indexing` | Notify Google about URL changes (`submit`, `remove`, `status`, `batch`, `batch-status`, `quota`); supports `--retries` |
58
- | `sync` | Sync GSC data to the local Parquet Store; `--retry-failed`, `--dry-run` |
59
- | `query` | Run a search analytics query (Store by default; `--live` hits GSC API). Filters: `--query`, `--page`, `--country`, `--device`, `--search-appearance`, `--type`, `--data-state`, `--aggregation-type`. `--explain` previews the request body; `--output -` writes to stdout. |
60
- | `dump` | Export from the Store to a directory (`--format parquet\|json\|ndjson\|csv`, `--tables`, `--all-sites`) |
61
- | `analyze <tool>` | Run an SEO Analyzer against the Store (`--live` for row-based against fresh API) |
62
- | `entities` | Snapshot URL inspections and indexing metadata into the local entity store |
55
+ | `sitemaps` | List, submit, or delete Google sitemaps; probe live URLs; read hosted snapshots (`current`, `history`, `membership`, `lastmod`, `export`) with `--site` |
56
+ | `inspect <url...> [--file]` | URL inspection for one or more URLs; renders Indexing Evidence, rich results, and AMP, and saves each result to the Store |
57
+ | `indexing` | Notify Google about URL changes (`submit`, `remove`, `status`, `batch`, `batch-status`, `quota`); supports `--retries`. `indexing urls --status not_indexed` lists hosted URL Inspection results |
58
+ | `sync` | Sync GSC data, sitemaps, and URL Inspection results to the local Store; `--inspect-limit`, `--max-calls`, `--all-sites`, `--no-sitemaps`, `--no-inspections`, `--retry-failed`, `--dry-run` |
59
+ | `query` | Run a search analytics query (the Store when it covers the dates, see [Routing](#routing); `--live` hits GSC API). Filters: `--query`, `--page`, `--country`, `--device`, `--search-appearance`, `--type`, `--data-state`, `--aggregation-type`. `--explain` previews the request body; `--output -` writes to stdout. `--sql` runs DuckDB SQL over the Store views; `--schema` lists them. |
60
+ | `dump` | Export the Store, inspections, sitemaps, and Bing data (`--format parquet\|csv\|json\|ndjson\|sqlite\|duckdb`, `--tables`, `--all-sites`, `--no-bing`). Every row has `site` and `search_type` |
61
+ | `analyze <tool>` | Run an SEO Analyzer against the Store, or live when the Site has no Store data (`--live` forces live for row-based Analyzers) |
62
+ | `entities` | Read saved URL inspections and snapshot indexing metadata into the local entity store |
63
63
  | `store stats` | Show row/byte counts per table and on-disk footprint |
64
64
  | `store compact` | Compact older data into weekly, monthly, and quarterly tiers (`--dry-run`) |
65
65
  | `store gc` | Delete orphaned objects past the grace window (`--dry-run`) |
66
- | `store export` | Export the live store to a single `.duckdb` file |
67
66
  | `store rm-site` / `store reset` | Delete one Site's data or reset the Store; inspect `--help` before use |
68
67
  | `store rollups rebuild` | Rebuild post-sync rollup tables |
69
68
  | `report <id>` / `report list` | Run or list Reports; `--explain` previews a plan |
@@ -85,7 +84,7 @@ gscdump auth status --json
85
84
 
86
85
  # Google uses the connection on gscdump.com
87
86
  gscdump sites --json
88
- gscdump query --live --site sc-domain:example.com --dimensions page
87
+ gscdump query --live --site example.com --dimensions page
89
88
 
90
89
  # Connect Bing through gscdump.com, then read its data
91
90
  gscdump bing sites --json
@@ -117,6 +116,7 @@ If you change a saved API root, supply the API key explicitly.
117
116
  | Bing connection and CNAME verification | Uses `bing login`, `bing status`, and `bing verify` | Verify sites in Bing Webmaster Tools |
118
117
  | Hosted sitemap membership and history | Supported | Requires hosted authentication |
119
118
  | Store queries and exports | Reads the local Store | Reads the local Store |
119
+ | Hosted sync progress | `status` and `sites` show it | Not available |
120
120
 
121
121
  Hosted Bing access follows the API's plan and preview access rules.
122
122
  `auth logout` removes the saved mode and saved Google and Bing credentials.
@@ -178,7 +178,7 @@ Bing Indexing Evidence preserves uncertainty and does not imply an indexed verdi
178
178
 
179
179
  ```bash
180
180
  # pages under /blog/ with brand mentions in the query
181
- gscdump query --live --site sc-domain:example.com \
181
+ gscdump query --live --site example.com \
182
182
  --page '~/blog/' --query '~brand' --dimensions page,query
183
183
  ```
184
184
 
@@ -186,16 +186,64 @@ gscdump query --live --site sc-domain:example.com \
186
186
  Without saved values, queries use 1000 rows and JSON.
187
187
  Limits must be positive integers. Formats must be `json` or `csv`.
188
188
 
189
- `--start` and `--end` work independently. An omitted date keeps its default.
190
- Non-interactive queries default to 31 days ago through three days ago, using UTC dates.
189
+ Without dates, a query reads the last 28 days that end on the newest synced day of the table it reads.
190
+ With `--live`, the window ends three days ago, Pacific time: the newest final GSC date.
191
+ `--start` alone runs to that end date. `--end` alone reads the 28 days that end on it.
191
192
  Dates must use `YYYY-MM-DD`, and `--start` cannot follow `--end`.
192
193
 
194
+ `--page` accepts a path or a full URL. The Store compares paths, so `https://example.com/a` matches `/a`.
195
+ Live queries expand a path to the Site's origin. For a domain property, a path matches that path on any host.
196
+ Filtered dimensions choose the Store table too: `-d query --page /a` reads `page_queries`.
197
+ If no Store table holds every dimension and filter, the query fails and names `--live`.
198
+
193
199
  ```bash
194
200
  # Export query rows with a CSV header.
195
- gscdump query --live --site sc-domain:example.com \
201
+ gscdump query --live --site example.com \
196
202
  --dimensions page,query --format csv --output rows.csv
197
203
  ```
198
204
 
205
+ ### SQL over the Store
206
+
207
+ `query --sql` runs DuckDB SQL over one view per Store table.
208
+ Run `query --schema` to list the views, their columns, and their date ranges.
209
+
210
+ ```bash
211
+ gscdump query --format json --sql "
212
+ SELECT p.page, SUM(q.clicks) AS clicks, gsc_position(q.sum_position, q.impressions) AS position
213
+ FROM pages p JOIN page_queries q USING (site, search_type, url, date)
214
+ WHERE p.search_type = 'web' AND p.date >= DATE '2026-08-01'
215
+ GROUP BY p.page ORDER BY clicks DESC LIMIT 20"
216
+ ```
217
+
218
+ | Column | Meaning |
219
+ | --- | --- |
220
+ | `site` | The Site URL, such as `sc-domain:example.com` |
221
+ | `search_type` | `web`, `image`, `video`, `news`, `discover`, or `googleNews` |
222
+ | `url`, `page` | The page path. `page` is the same value as `url` |
223
+ | `sum_position` | Zero-based position multiplied by impressions |
224
+
225
+ - The Store keeps every search type. Filter or group by `search_type`, or a `SUM` adds web, image, and Discover rows together.
226
+ - `gsc_position(sum_position, impressions)` returns the impression-weighted average position. Use it with `GROUP BY`.
227
+ - The views cover every Site in the Store. `--site` and `--type` narrow them.
228
+ - Dates return as `YYYY-MM-DD`. Integers return as numbers, and an integer past 2^53 returns as a string.
229
+ - If the SQL names a table with no synced data, the CLI prints a warning.
230
+
231
+ ### Export formats
232
+
233
+ `dump` reads only the Store. It never calls Google to fill a gap.
234
+ Every exported row has `site` and `search_type` columns.
235
+
236
+ | `--format` | Output |
237
+ | --- | --- |
238
+ | `parquet` (default) | `<site>/<search_type>/<table>.parquet` |
239
+ | `csv`, `json`, `ndjson` | `<site>/<search_type>/<table>.<ext>`, plus a per-row `position` |
240
+ | `sqlite` | One `gscdump.sqlite` file. Dates are `YYYY-MM-DD` text |
241
+ | `duckdb` | One `gscdump.duckdb` file |
242
+
243
+ Inspections, sitemaps, and Indexing API metadata go to `<site>/<dataset>.<ext>`, or to their own tables in a database file.
244
+ `manifest.json` lists every dataset with its row count, and the same coverage that `sync --status` reports.
245
+ `--format sqlite` needs Node.js 22.13 or later.
246
+
199
247
  ### Global flags
200
248
 
201
249
  - `--no-color` / `NO_COLOR` env: strip ANSI from stdout (stderr keeps colour for interactive use).
@@ -212,38 +260,73 @@ Saved config rejects invalid values and unknown keys. If parsing fails, fix the
212
260
  `gscdump analyze <tool>` runs one of 29 Analyzers from `@gscdump/analysis`.
213
261
  See the [full list](../../README.md#analyzers) and [Source support](../analysis/README.md#sources).
214
262
 
215
- Each Analyzer accepts `--site`, `--start`, `--end`, `--limit`, and output flags.
216
- `movers` and `decay` also accept `--prev-start` and `--prev-end`.
263
+ Each Analyzer accepts `--site`, `--period`, `--start`, `--end`, `--limit`, `--fetch-budget`, and output flags.
264
+ Windows default to the last 28 days that end on the newest synced day.
265
+ `movers` and `decay` compare with the previous period by default. Override it with `--prev-start` and `--prev-end` together.
266
+ `movers` also accepts `--sort-by`: `clicksDelta` (default) sorts by absolute click change, `clicksDeltaPercent` by percent change.
267
+ `--limit` caps the rows shown. `--fetch-budget` caps the rows each live fetch reads (default 25000, max 100000).
268
+ If a fetch reaches the budget, the output shows a partial-data warning.
217
269
  Use `gscdump analyze <tool> --help` for additional options.
218
270
 
219
- The CLI requires local data unless you pass `--live`.
271
+ `analyze` and `report` follow the [routing rules](#routing).
220
272
  Pass `--live` to use Google explicitly.
221
273
  SQL-only Analyzers require local data.
222
274
 
275
+ ## Routing
276
+
277
+ Login is optional. Log in with Google or a hosted key, or sync a local Store.
278
+ `query`, `analyze`, and `report` pick one source for each run:
279
+
280
+ - If the Store covers every date the run needs, the run reads the Store. This is also true while a sync runs.
281
+ - If the Store has no data for the tables the run needs, and Google is connected, the run asks the live Search Console API.
282
+ stderr prints `No synced data for SITE; answering from the live Search Console API.` JSON output has `meta.source: "live"`.
283
+ - If the Store has no data and Google is not connected, the run stops and names `gscdump init`.
284
+ - If some dates are missing, the run stops and prints the `gscdump sync` command for the missing dates and tables. Pass `--live` to ask Google instead.
285
+ - If a sync for the Site is running and the dates are not covered yet, the run stops with `Sync running: 41 of 90 days done.`
286
+ A sync without a heartbeat in the last 2 minutes counts as stopped.
287
+
288
+ One run never mixes Store rows and live rows. Live results hold the top rows of each request, and synced data keeps more of the long tail.
289
+ `query --sql` reads the Store only.
290
+ With `--format json` or `--json`, a stop prints `{ "error": { "code", "message", "nextCommand" } }` on stdout and exits 1.
291
+
292
+ Every Search Analytics, URL Inspection, and Indexing API call goes through the quota ledger in the data dir, so all commands share one daily budget.
293
+ When a quota is spent, the command stops and says when the quota resets.
294
+
223
295
  ## Sync
224
296
 
225
297
  ```bash
226
- # Default: three days ending three days ago; skip completed dates
227
- gscdump sync --site sc-domain:example.com --tables pages,queries,page_queries,countries,dates
298
+ # Default: catch every table and search type up to the latest final date.
299
+ # A table with no history starts 28 days back. Newest dates come first.
300
+ # Also saves sitemaps and inspects up to 50 due URLs. Skips completed dates.
301
+ gscdump sync --site example.com
302
+
303
+ # Backfill the 16 months Google keeps, plus 14 days Google often still serves
304
+ gscdump sync --site example.com --full
305
+
306
+ # Cap one run at 2,000 Search Analytics calls; the next run continues
307
+ gscdump sync --site example.com --full --max-calls 2000
228
308
 
229
- # Backfill from 450 days ago to three days ago
230
- gscdump sync --site sc-domain:example.com --full --tables pages,queries,page_queries,countries,dates
309
+ # Every verified Site, one after another
310
+ gscdump sync --all-sites
231
311
 
232
312
  # Custom range
233
- gscdump sync --site sc-domain:example.com --start 2026-08-01 --end 2026-08-31 \
313
+ gscdump sync --site example.com --start 2026-08-01 --end 2026-08-31 \
234
314
  --tables pages,queries,page_queries,countries,dates
235
315
 
236
- # Check sync state and watermarks
237
- gscdump sync --site sc-domain:example.com --status
316
+ # Coverage, missing and failed dates per table, and a running sync
317
+ gscdump sync --site example.com --status
238
318
 
239
319
  # Limit concurrent day requests per table
240
- gscdump sync --site sc-domain:example.com --concurrency 4 \
320
+ gscdump sync --site example.com --concurrency 4 \
241
321
  --tables pages,queries,page_queries,countries,dates
242
322
  ```
243
323
 
244
324
  Sync skips completed dates; `--force` refreshes them.
325
+ A day Google still updates stays `pending`, and the next sync fetches it again.
326
+ Sync records Google calls in a quota ledger in the Store directory.
327
+ If Google refuses a call for quota, sync stops, keeps the rest `pending`, and exits 0. The next run continues.
245
328
  Cross-process locks coordinate `sync`, `compact`, and `gc`.
246
- Pagination follows Google's 25,000-row pages, subject to [Google's data limits](https://developers.google.com/webmaster-tools/v1/how-tos/all-your-data).
329
+ Pagination follows Google's 25,000-row pages and stops at the first short page, subject to [Google's data limits](https://developers.google.com/webmaster-tools/v1/how-tos/all-your-data).
247
330
 
248
331
  ## MCP server
249
332
 
package/bin/gscdump.mjs CHANGED
@@ -2,7 +2,27 @@
2
2
 
3
3
  import process from 'node:process'
4
4
 
5
- import ('../dist/cli.mjs').then(({ runCli }) => runCli()).catch((error) => {
5
+ // Node does not flush pending pipe writes on process.exit. Drain a stream
6
+ // first so a failed run cannot truncate piped output.
7
+ function drained(stream) {
8
+ return new Promise((resolve) => {
9
+ stream.once('error', resolve)
10
+ if (stream.write(''))
11
+ resolve()
12
+ else
13
+ stream.once('drain', resolve)
14
+ })
15
+ }
16
+
17
+ import ('../dist/cli.mjs').then(async ({ runCli }) => {
18
+ const code = await runCli()
19
+ // A failed command may leave handles open (DuckDB, sockets). Exit now.
20
+ // On success the process ends by itself, so a server command stays up.
21
+ if (code !== 0) {
22
+ await Promise.all([drained(process.stdout), drained(process.stderr)])
23
+ process.exit(code)
24
+ }
25
+ }).catch((error) => {
6
26
  console.error(error instanceof Error ? error.message : String(error))
7
27
  process.exitCode = 1
8
28
  })
@@ -1,33 +1,127 @@
1
+ import { LocalStoreUnsupportedError } from "./error-handler.mjs";
2
+ import { useCliRuntime } from "./runtime.mjs";
1
3
  import { logger } from "./utils.mjs";
2
- import { createLocalStore } from "./local-store.mjs";
4
+ import { inferTable } from "./local-store.mjs";
3
5
  import { createCommandContext } from "./context.mjs";
4
- import { LocalStoreUnsupportedError } from "./error-handler.mjs";
5
- import process from "node:process";
6
- import { readdir } from "node:fs/promises";
7
- import { join } from "node:path";
6
+ import { decideRoute, liveNote, readRouteState, readSiteStates, resolveReadSite, stopAtRoute } from "./route.mjs";
7
+ import { newestDoneDate } from "./window.mjs";
8
+ import { extractDateRange } from "gscdump/query";
9
+ import { getLatestGscDate } from "gscdump/dates";
10
+ import { err, ok, unwrapResult } from "gscdump/result";
8
11
  import { defaultAnalyzerRegistry } from "@gscdump/analysis/registry";
9
12
  import { createGscApiQuerySource } from "@gscdump/engine-gsc-api";
10
13
  import { AnalyzerCapabilityError, runAnalyzerFromSource } from "@gscdump/engine/analyzer";
11
14
  import { createEngineQuerySource } from "@gscdump/engine/source";
12
- import { err, ok, unwrapResult } from "gscdump/result";
13
- import { decodeSiteId, normalizeSiteUrl } from "gscdump/tenant";
14
- async function hasLocalData(store, siteUrl) {
15
- return (await store.engine.listLive({
16
- userId: store.userId,
17
- siteId: store.siteIdFor(siteUrl)
18
- })).length > 0;
15
+ const PLACEHOLDER_DATE = "2000-01-01";
16
+ const DAILY_PARTITION_RE = /^daily\/(\d{4}-\d{2}-\d{2})$/;
17
+ function sqlPlanReads(params) {
18
+ const analyzer = defaultAnalyzerRegistry.getAnalyzerVariants(params.type)?.sql;
19
+ if (!analyzer) return void 0;
20
+ let plan;
21
+ try {
22
+ plan = analyzer.build(params);
23
+ } catch (error) {
24
+ logger.debug(`Cannot plan ${params.type}: ${error.message}`);
25
+ return;
26
+ }
27
+ if (plan.kind !== "sql") return void 0;
28
+ return [
29
+ {
30
+ period: "current",
31
+ fileSet: plan.current
32
+ },
33
+ ...plan.previous ? [{
34
+ period: "comparison",
35
+ fileSet: plan.previous
36
+ }] : [],
37
+ ...Object.values(plan.extraFiles ?? {}).map((fileSet) => ({
38
+ period: "current",
39
+ fileSet
40
+ }))
41
+ ];
42
+ }
43
+ function analyzerTables(params) {
44
+ const reads = sqlPlanReads({
45
+ startDate: PLACEHOLDER_DATE,
46
+ endDate: PLACEHOLDER_DATE,
47
+ prevStartDate: PLACEHOLDER_DATE,
48
+ prevEndDate: PLACEHOLDER_DATE,
49
+ ...params
50
+ });
51
+ return [...new Set((reads ?? []).map((read) => read.fileSet.table))];
52
+ }
53
+ function isAdapterPlanned(type) {
54
+ return defaultAnalyzerRegistry.getAnalyzerVariants(type)?.sql?.requires.includes("adapter") ?? false;
55
+ }
56
+ function isBuilderState(value) {
57
+ return Boolean(value) && typeof value === "object" && Array.isArray(value.dimensions);
19
58
  }
20
- async function listLocalSites(dataDir, userId = "local") {
21
- const tenantDir = join(dataDir, `u_${userId}`);
22
- return readdir(tenantDir, { withFileTypes: true }).then((entries) => entries.filter((e) => e.isDirectory() && (e.name.startsWith("d_") || e.name.startsWith("h_"))).map((e) => decodeSiteId(e.name))).catch(() => []);
59
+ function builderStateNeed(state, period) {
60
+ const table = inferTable(state.dimensions);
61
+ const { startDate, endDate } = extractDateRange(state.filter);
62
+ const searchType = state.searchType && state.searchType !== "web" ? state.searchType : void 0;
63
+ if (startDate && endDate) return {
64
+ kind: "window",
65
+ period,
66
+ table,
67
+ searchType: searchType ?? "web",
68
+ window: {
69
+ start: startDate,
70
+ end: endDate
71
+ }
72
+ };
73
+ return {
74
+ kind: "any",
75
+ tables: [table],
76
+ ...searchType ? { searchType } : {}
77
+ };
78
+ }
79
+ function builderStateNeeds(params) {
80
+ if (!isAdapterPlanned(params.type)) return [];
81
+ if (!isBuilderState(params.q)) return [{
82
+ kind: "any",
83
+ tables: []
84
+ }];
85
+ const needs = [builderStateNeed(params.q, "current")];
86
+ if (isBuilderState(params.qc)) needs.push(builderStateNeed(params.qc, "comparison"));
87
+ return needs;
88
+ }
89
+ function analysisNeeds(params) {
90
+ const needs = [];
91
+ for (const { period, fileSet } of sqlPlanReads(params) ?? []) {
92
+ const dates = fileSet.partitions.flatMap((partition) => DAILY_PARTITION_RE.exec(partition)?.[1] ?? []).sort();
93
+ if (dates.length === 0) {
94
+ needs.push({
95
+ kind: "any",
96
+ tables: [fileSet.table]
97
+ });
98
+ continue;
99
+ }
100
+ needs.push({
101
+ kind: "window",
102
+ period,
103
+ table: fileSet.table,
104
+ searchType: "web",
105
+ window: {
106
+ start: dates[0],
107
+ end: dates.at(-1)
108
+ }
109
+ });
110
+ }
111
+ if (needs.length > 0) return needs;
112
+ const builderNeeds = builderStateNeeds(params);
113
+ if (builderNeeds.length > 0) return builderNeeds;
114
+ return [{
115
+ kind: "any",
116
+ tables: []
117
+ }];
23
118
  }
24
- function pickLocalSite(siteUrls, hint) {
25
- if (siteUrls.length === 0) return null;
26
- if (!hint) return siteUrls.length === 1 ? siteUrls[0] : null;
27
- const normalized = normalizeSiteUrl(hint);
28
- const exact = siteUrls.find((s) => s === normalized || s === hint);
29
- if (exact) return exact;
30
- return siteUrls.find((s) => s.includes(hint) || hint.includes(s)) ?? null;
119
+ function analyzerSources(types) {
120
+ const variants = types.map((type) => defaultAnalyzerRegistry.getAnalyzerVariants(type));
121
+ return {
122
+ local: variants.every((variant) => Boolean(variant?.sql)),
123
+ live: variants.every((variant) => Boolean(variant?.rows))
124
+ };
31
125
  }
32
126
  async function runAnalysisResult(source, params, mode) {
33
127
  return runAnalyzerFromSource(source, params, defaultAnalyzerRegistry).then(ok).catch((e) => {
@@ -36,56 +130,81 @@ async function runAnalysisResult(source, params, mode) {
36
130
  });
37
131
  }
38
132
  function makeRunAnalysis(source, mode) {
39
- return async (params) => unwrapResult(await runAnalysisResult(source, params, mode), (e) => e);
133
+ return async (params) => {
134
+ const result = unwrapResult(await runAnalysisResult(source, params, mode), (e) => e);
135
+ return {
136
+ ...result,
137
+ meta: {
138
+ ...result.meta,
139
+ source: mode
140
+ }
141
+ };
142
+ };
40
143
  }
41
144
  async function resolveAnalysisSource(args) {
42
- const isLive = !!args.live;
43
- const format = args.json ? "json" : args.format ? String(args.format) : "table";
44
- if (!isLive) {
45
- const { config, dataDir } = await createCommandContext();
46
- const store = createLocalStore({ dataDir });
47
- const siteHint = args.site ? String(args.site) : config.defaultSite;
48
- const localSites = await listLocalSites(dataDir, store.userId);
49
- const siteUrl = pickLocalSite(localSites, siteHint);
50
- if (!siteUrl) {
51
- if (localSites.length === 0) logger.error(`No local data found in ${dataDir}. Run \`gscdump sync\` first, or pass --live.`);
52
- else logger.error(`Could not resolve site${siteHint ? ` from "${siteHint}"` : ""}. Local sites: ${localSites.join(", ")}`);
53
- process.exit(1);
54
- }
55
- if (!await hasLocalData(store, siteUrl).catch(() => false)) {
56
- logger.error(`No local data for ${siteUrl}. Run \`gscdump sync\` first, or pass --live.`);
57
- process.exit(1);
58
- }
145
+ const forceLive = Boolean(args.live);
146
+ const ctx = await createCommandContext({ needsStore: true });
147
+ const store = ctx.store;
148
+ let live;
149
+ const connect = () => live ??= createCommandContext({
150
+ needsAuth: true,
151
+ needsStore: false
152
+ });
153
+ const { site, siteHint, auth } = await resolveReadSite(ctx, args.site ? String(args.site) : void 0, {
154
+ forceLive,
155
+ connect
156
+ });
157
+ const states = site ? await readSiteStates(store, site) : [];
158
+ const anchor = forceLive ? getLatestGscDate() : newestDoneDate(states, args.anchorTables) ?? getLatestGscDate();
159
+ const sources = args.sources ?? analyzerSources(args.types);
160
+ const req = {
161
+ site,
162
+ siteHint,
163
+ label: args.label,
164
+ localCapable: sources.local,
165
+ liveCapable: sources.live,
166
+ forceLive,
167
+ argv: useCliRuntime().rawArgs
168
+ };
169
+ const state = await readRouteState({
170
+ store,
171
+ site,
172
+ needs: args.needs(anchor),
173
+ states,
174
+ auth
175
+ });
176
+ const route = decideRoute(req, state);
177
+ if (route.kind === "syncing" || route.kind === "prompt") stopAtRoute(route, req, state.auth, { json: Boolean(args.json) });
178
+ if (route.kind === "local") {
59
179
  const source = createEngineQuerySource({
60
180
  engine: store.engine,
61
181
  ctx: {
62
182
  userId: store.userId,
63
- siteId: store.siteIdFor(siteUrl)
183
+ siteId: store.siteIdFor(site)
64
184
  }
65
185
  });
66
186
  return {
67
187
  source,
68
- siteUrl,
69
- format,
70
- isLive,
188
+ siteUrl: site,
189
+ isLive: false,
190
+ anchor,
71
191
  runAnalysis: makeRunAnalysis(source, "local")
72
192
  };
73
193
  }
74
- const ctx = await createCommandContext({
75
- needsAuth: true,
76
- needsStore: false
77
- });
78
- const siteUrl = await ctx.resolveSite(args.site ? String(args.site) : void 0);
194
+ const liveCtx = await connect();
195
+ const siteUrl = site ?? await liveCtx.resolveSite(void 0);
196
+ if (route.reason === "no-store-data") logger.warn(liveNote(siteUrl));
79
197
  const source = createGscApiQuerySource({
80
- client: ctx.client,
198
+ client: liveCtx.client,
81
199
  siteUrl
82
200
  });
83
201
  return {
84
202
  source,
85
203
  siteUrl,
86
- format,
87
- isLive,
204
+ isLive: true,
205
+ liveReason: route.reason,
206
+ anchor,
88
207
  runAnalysis: makeRunAnalysis(source, "live")
89
208
  };
90
209
  }
91
- export { hasLocalData, listLocalSites, resolveAnalysisSource };
210
+ export { analysisNeeds, analyzerSources, analyzerTables, resolveAnalysisSource };
@@ -1,7 +1,9 @@
1
+ import { HOSTED_KEY_REJECTED } from "./error-handler.mjs";
1
2
  import { useCliRuntime } from "./runtime.mjs";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import fs from "node:fs/promises";
4
5
  import path from "node:path";
6
+ import { gscdumpAvailableSiteSchema } from "@gscdump/contracts";
5
7
  import { z } from "zod";
6
8
  const apiRootSchema = z.url().transform((value) => value.replace(/\/+$/, "")).refine((value) => {
7
9
  const url = new URL(value);
@@ -97,11 +99,14 @@ async function cloudRequest(state, route, options = {}) {
97
99
  signal: options.signal ?? AbortSignal.timeout(3e4),
98
100
  redirect: "error"
99
101
  });
100
- if (!response.ok) throw Object.assign(/* @__PURE__ */ new Error(`Hosted request failed (${response.status}) for ${route.split("?")[0]}. Check \`gscdump auth status\`.`), {
101
- statusCode: response.status,
102
- retryAfter: response.headers.get("retry-after"),
103
- response
104
- });
102
+ if (!response.ok) {
103
+ const message = response.status === 401 ? HOSTED_KEY_REJECTED : `Hosted request failed (${response.status}) for ${route.split("?")[0]}. Check \`gscdump auth status\`.`;
104
+ throw Object.assign(new Error(message), {
105
+ statusCode: response.status,
106
+ retryAfter: response.headers.get("retry-after"),
107
+ response
108
+ });
109
+ }
105
110
  return response.status === 204 ? void 0 : response.json();
106
111
  }
107
112
  async function getCloudAccount(state) {
@@ -109,4 +114,14 @@ async function getCloudAccount(state) {
109
114
  if (!result.success) throw new Error("The hosted API returned invalid account data.");
110
115
  return result.data;
111
116
  }
112
- export { clearAuthentication, cloudRequest, getCloudAccount, parseAuthMode, parseAuthentication, resolveAuthentication, saveAuthentication };
117
+ async function getCloudSites(state) {
118
+ const result = gscdumpAvailableSiteSchema.array().safeParse(await cloudRequest(state, "/cli/sites/available"));
119
+ if (!result.success) throw new Error("The hosted API returned invalid Site data.");
120
+ return result.data;
121
+ }
122
+ function formatHostedSync(site) {
123
+ if (!site.registered) return void 0;
124
+ const status = site.syncStatus ?? "pending";
125
+ 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}` : ""}`;
126
+ }
127
+ export { clearAuthentication, cloudRequest, formatHostedSync, getCloudAccount, getCloudSites, parseAuthMode, parseAuthentication, resolveAuthentication, saveAuthentication };