@nuxtseo/cli 0.1.1 → 0.1.3

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.
package/README.md CHANGED
@@ -172,6 +172,7 @@ nuxtseo page scan <url>
172
172
  nuxtseo performance
173
173
  nuxtseo search status
174
174
  nuxtseo search analytics <pages|keywords>
175
+ nuxtseo search indexing <summary|urls>
175
176
  nuxtseo research overview
176
177
  nuxtseo research keywords <topic>
177
178
  nuxtseo research serp <keyword>
@@ -189,6 +190,7 @@ performance, research, content, and account setup. The menu prints the direct
189
190
  command it runs.
190
191
 
191
192
  `search status` reads stored connection state. It never waits for Google.
193
+ `search indexing summary` reads retained URL Inspection coverage.
192
194
  `research keywords`, `research serp`, and `research rankings` can use the Team
193
195
  research allowance. Keyword responses report cache use in `evidence`. SERP and
194
196
  ranking responses report `cached: true`. Cached responses use no unit.
@@ -204,6 +206,7 @@ responses.
204
206
  | `backlinks recoverable` | `--limit 1..200`, `--offset >=0` | `--limit 100 --offset 0` |
205
207
  | `mentions list` | `--limit 1..200` | `--limit 100` |
206
208
  | `search analytics` | `--limit 1..100`, `--page >=1` | `--limit 25 --page 1` |
209
+ | `search indexing` | `--limit 1..500`, `--offset >=0` | `--limit 50 --offset 0` |
207
210
  | `content briefs list` | `--limit 1..100`, `--offset >=0` | `--limit 25 --offset 0` |
208
211
 
209
212
  Keep the server order for actions. For another page, pass the next offset or
package/dist/cli.js CHANGED
@@ -7,16 +7,28 @@ import { EXIT_CODE, fail, fromSdkFailure, ok, unexpectedFailure } from './failur
7
7
  import { extractGlobalOptions } from './parse.js';
8
8
  import { writeCliResponse, writeDiagnostic, writeOutput, writeProtocolResponse } from './runtime.js';
9
9
  import { getCredentialStatus, readConfig, resolveApiUrl, resolveSiteId, updateConfig } from './state/index.js';
10
+ import { checkForUpdate, updateNoticeLine } from './update-check.js';
10
11
  import { VERSION } from './version.js';
11
- function report(runtime, failure, json = false) {
12
+ function writeUpdateNotice(runtime, notice) {
13
+ if (notice)
14
+ writeDiagnostic(runtime, updateNoticeLine(notice));
15
+ }
16
+ function report(runtime, failure, json = false, notice = null) {
12
17
  if (json) {
13
18
  if (failure.protocolResponse !== undefined) {
14
19
  writeProtocolResponse(runtime, failure.protocolResponse);
15
20
  }
16
21
  else {
22
+ // Exit 2 and exit 7 are where a stale binary masquerades as broken docs
23
+ // or a broken server. Version skew is a plausible cause there, so the
24
+ // envelope names both versions and stderr carries the update command.
25
+ const carriesVersionSkew = failure.exitCode === EXIT_CODE.invalidInput || failure.exitCode === EXIT_CODE.infrastructure;
17
26
  writeCliResponse(runtime, {
18
27
  _tag: 'CliError',
19
28
  schemaVersion: 1,
29
+ ...(carriesVersionSkew
30
+ ? { cliVersion: VERSION, ...(notice ? { latestKnownVersion: notice.latest } : {}) }
31
+ : {}),
20
32
  error: {
21
33
  code: failure.code,
22
34
  exitCode: failure.exitCode,
@@ -240,6 +252,7 @@ async function interactiveBare(runtime, globals) {
240
252
  options: [
241
253
  { value: 'performance', label: 'Performance overview' },
242
254
  { value: 'connection', label: 'Search Console connection' },
255
+ { value: 'indexing', label: 'Search indexing coverage' },
243
256
  { value: 'pages', label: 'Search Console Pages' },
244
257
  { value: 'keywords', label: 'Search Console queries' },
245
258
  ],
@@ -253,7 +266,9 @@ async function interactiveBare(runtime, globals) {
253
266
  ? ['performance']
254
267
  : task === 'connection'
255
268
  ? ['search', 'status']
256
- : ['search', 'analytics', task];
269
+ : task === 'indexing'
270
+ ? ['search', 'indexing', 'summary']
271
+ : ['search', 'analytics', task];
257
272
  return runInteractiveCommand(command, runtime, globals);
258
273
  }
259
274
  if (group === 'research') {
@@ -336,9 +351,16 @@ async function interactiveBare(runtime, globals) {
336
351
  }
337
352
  export async function runCli(rawArgs, runtime) {
338
353
  const jsonRequested = rawArgs.includes('--json');
354
+ // Started before parsing so the registry round trip overlaps the command. The
355
+ // check reads a local cache and never rejects; a slow registry only delays
356
+ // this line, never the command result itself.
357
+ const updateCheck = checkForUpdate({ paths: runtime.paths, env: runtime.env });
339
358
  const parsed = extractGlobalOptions(rawArgs);
340
- if (parsed._tag === 'Err')
341
- return report(runtime, parsed.error, jsonRequested);
359
+ if (parsed._tag === 'Err') {
360
+ const notice = await updateCheck;
361
+ writeUpdateNotice(runtime, notice);
362
+ return report(runtime, parsed.error, jsonRequested, notice);
363
+ }
342
364
  const { args, options: globals } = parsed.value;
343
365
  const effectiveRuntime = {
344
366
  ...runtime,
@@ -350,21 +372,28 @@ export async function runCli(rawArgs, runtime) {
350
372
  requestTimeoutMs: globals.timeoutMs,
351
373
  };
352
374
  if (args.length === 1 && (args[0] === '--version' || args[0] === '-v')) {
375
+ const notice = await updateCheck;
353
376
  if (globals.json)
354
377
  writeCliResponse(effectiveRuntime, { _tag: 'CliVersion', schemaVersion: 1, version: VERSION });
355
378
  else
356
379
  writeOutput(effectiveRuntime, VERSION);
380
+ writeUpdateNotice(effectiveRuntime, notice);
357
381
  return EXIT_CODE.success;
358
382
  }
359
383
  if (args.includes('--help') || args.includes('-h')) {
360
384
  const command = createRootCommand(effectiveRuntime, globals, { result: null });
361
385
  const validOptions = validateCommandOptions(command, args);
362
- if (validOptions._tag === 'Err')
363
- return report(effectiveRuntime, validOptions.error, globals.json);
386
+ if (validOptions._tag === 'Err') {
387
+ const notice = await updateCheck;
388
+ writeUpdateNotice(effectiveRuntime, notice);
389
+ return report(effectiveRuntime, validOptions.error, globals.json, notice);
390
+ }
364
391
  if (globals.json)
365
392
  writeCliResponse(effectiveRuntime, describeCommand(command, args, VERSION));
366
393
  else
367
394
  writeOutput(effectiveRuntime, await requestedUsage(effectiveRuntime, globals, args));
395
+ const notice = await updateCheck;
396
+ writeUpdateNotice(effectiveRuntime, notice);
368
397
  return EXIT_CODE.success;
369
398
  }
370
399
  const result = args.length === 0
@@ -372,7 +401,9 @@ export async function runCli(rawArgs, runtime) {
372
401
  ? await interactiveBare(effectiveRuntime, globals)
373
402
  : await nonInteractiveBare(effectiveRuntime, globals)
374
403
  : await runExplicit(args, effectiveRuntime, globals);
404
+ const notice = await updateCheck;
405
+ writeUpdateNotice(effectiveRuntime, notice);
375
406
  return result._tag === 'Ok'
376
407
  ? EXIT_CODE.success
377
- : report(effectiveRuntime, timeoutRetryFailure(result.error, rawArgs, globals.timeoutMs), globals.json);
408
+ : report(effectiveRuntime, timeoutRetryFailure(result.error, rawArgs, globals.timeoutMs), globals.json, notice);
378
409
  }
package/dist/commands.js CHANGED
@@ -5,7 +5,7 @@ import { openBrowser } from './browser.js';
5
5
  import { EXIT_CODE, fail, fromSdkFailure, ok } from './failures.js';
6
6
  import { awaitPairingApproval, createCliPairingClient, startPairing } from './pairing.js';
7
7
  import { parseAbsolutePageUrl, parseChoice, parseInteger } from './parse.js';
8
- import { renderAction, renderActionResolution, renderActions, renderContentBrief, renderContentBriefCreated, renderContentBriefs, renderContentDecay, renderDuplicateClusters, renderKeywordResearch, renderLinkOpportunities, renderMentions, renderPageInspection, renderPageScan, renderPerformance, renderRankingsResearch, renderRecoverableBacklinks, renderResearchOverview, renderSearchAnalytics, renderSearchStatus, renderSerpResearch, renderSites, renderUsage, } from './render.js';
8
+ import { renderAction, renderActionResolution, renderActions, renderContentBrief, renderContentBriefCreated, renderContentBriefs, renderContentDecay, renderDuplicateClusters, renderIndexingDiagnostics, renderKeywordResearch, renderLinkOpportunities, renderMentions, renderPageInspection, renderPageScan, renderPerformance, renderRankingsResearch, renderRecoverableBacklinks, renderResearchOverview, renderSearchAnalytics, renderSearchStatus, renderSerpResearch, renderSites, renderUsage, } from './render.js';
9
9
  import { writeCliResponse, writeDiagnostic, writeOutput, writeProtocolResponse } from './runtime.js';
10
10
  import { resolveSite } from './site.js';
11
11
  import { clearCredential, getCredentialStatus, readConfig, resolveApiUrl, saveCredential, updateConfig, } from './state/index.js';
@@ -447,6 +447,36 @@ async function searchAnalytics(runtime, globals, args) {
447
447
  }, { signal: runtime.requestSignal }));
448
448
  return present(runtime, globals, response, value => renderSearchAnalytics(value.data));
449
449
  }
450
+ async function searchIndexing(runtime, globals, args) {
451
+ const view = parseChoice(args.view, { name: 'view', choices: ['summary', 'urls'] });
452
+ if (view._tag === 'Err')
453
+ return view;
454
+ const status = args.status === undefined
455
+ ? ok(undefined)
456
+ : parseChoice(args.status, { name: '--status', choices: ['indexed', 'not_indexed', 'pending'] });
457
+ if (status._tag === 'Err')
458
+ return status;
459
+ const limit = parseInteger(args.limit, { name: '--limit', minimum: 1, maximum: 500, defaultValue: 50 });
460
+ if (limit._tag === 'Err')
461
+ return limit;
462
+ const offset = parseInteger(args.offset, { name: '--offset', minimum: 0, defaultValue: 0 });
463
+ if (offset._tag === 'Err')
464
+ return offset;
465
+ const resolved = await apiAndSite(runtime, globals);
466
+ if (resolved._tag === 'Err')
467
+ return resolved;
468
+ const response = await withSpinner(runtime, 'Loading Search Console indexing', () => resolved.value.api.client.search.readIndexingDiagnostics({
469
+ params: { siteId: resolved.value.siteId },
470
+ query: {
471
+ view: view.value,
472
+ issue: args.issue,
473
+ status: status.value,
474
+ limit: limit.value,
475
+ offset: offset.value,
476
+ },
477
+ }, { signal: runtime.requestSignal }));
478
+ return present(runtime, globals, response, value => renderIndexingDiagnostics(value.data));
479
+ }
450
480
  async function researchOverview(runtime, globals) {
451
481
  const resolved = await apiAndSite(runtime, globals);
452
482
  if (resolved._tag === 'Err')
@@ -928,6 +958,23 @@ export function createRootCommand(runtime, globals, execution) {
928
958
  sortDir: args['sort-dir'],
929
959
  }), args._, 1)(),
930
960
  }),
961
+ indexing: defineCommand({
962
+ meta: { name: 'indexing', description: 'Read retained Search Console indexing coverage' },
963
+ args: {
964
+ view: positional('summary-or-urls', 'summary or urls'),
965
+ issue: { type: 'string', description: 'Filter URLs by issue type' },
966
+ status: { type: 'enum', options: ['indexed', 'not_indexed', 'pending'], description: 'Filter URLs by indexing status' },
967
+ limit: { type: 'string', description: 'Maximum URLs, 1 to 500' },
968
+ offset: { type: 'string', description: 'Pagination offset' },
969
+ },
970
+ run: ({ args }) => capture(execution, () => searchIndexing(runtime, globals, {
971
+ view: args.view,
972
+ issue: args.issue,
973
+ status: args.status,
974
+ limit: args.limit,
975
+ offset: args.offset,
976
+ }), args._, 1)(),
977
+ }),
931
978
  },
932
979
  });
933
980
  return defineCommand({
package/dist/failures.js CHANGED
@@ -1,3 +1,11 @@
1
+ import { VERSION } from './version.js';
2
+ const UPDATE_COMMAND = 'pnpm add -g @nuxtseo/cli';
3
+ // `contract_violation` most often means this CLI is older than the server
4
+ // contract. One line routes an agent to update before anything else.
5
+ const CONTRACT_REMEDIATION = [
6
+ 'Most common cause: this CLI is older than the server contract.',
7
+ `Update first with ${UPDATE_COMMAND}, then run the command again.`,
8
+ ].join(' ');
1
9
  export const EXIT_CODE = {
2
10
  success: 0,
3
11
  invalidInput: 2,
@@ -97,12 +105,15 @@ export function fromSdkFailure(error) {
97
105
  error.code === 'auth_expired'
98
106
  ? 'Open site settings in the dashboard, then Search Console, to reconnect.'
99
107
  : undefined,
108
+ error.code === 'contract_violation'
109
+ ? CONTRACT_REMEDIATION
110
+ : undefined,
100
111
  ].filter((line) => line !== undefined).join('\n'),
101
112
  protocolResponse: error.response,
102
113
  },
103
114
  };
104
115
  case 'ContractFailure':
105
- return fail(EXIT_CODE.infrastructure, [`contract_violation: ${error.message}`, error.requestId ? `Request ID: ${error.requestId}` : undefined]
116
+ return fail(EXIT_CODE.infrastructure, [`contract_violation: ${error.message}`, error.requestId ? `Request ID: ${error.requestId}` : undefined, CONTRACT_REMEDIATION, `Current CLI version: ${VERSION}`]
106
117
  .filter((line) => line !== undefined)
107
118
  .join('\n'), undefined, 'contract_violation');
108
119
  case 'TransportFailure': {
package/dist/index.d.ts CHANGED
@@ -2,4 +2,6 @@ export { runCli } from './cli.js';
2
2
  export { EXIT_CODE } from './failures.js';
3
3
  export type { CliFailure, CliFailureCode, CliResult, ExitCode, LocalFailureCode } from './failures.js';
4
4
  export type { CliRuntime } from './runtime.js';
5
+ export { checkForUpdate, isNewerVersion, updateNoticeFor } from './update-check.js';
6
+ export type { UpdateNotice } from './update-check.js';
5
7
  export { VERSION } from './version.js';
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { runCli } from './cli.js';
2
2
  export { EXIT_CODE } from './failures.js';
3
+ export { checkForUpdate, isNewerVersion, updateNoticeFor } from './update-check.js';
3
4
  export { VERSION } from './version.js';
package/dist/render.d.ts CHANGED
@@ -3,7 +3,7 @@ import type { ActionList, ActionResolve, ActionShow } from '@nuxtseo/protocol/v1
3
3
  import type { AuditContentDecay, AuditDuplicateClusters, AuditLinkOpportunities } from '@nuxtseo/protocol/v1/audit';
4
4
  import type { Mentions, RecoverableBacklinks } from '@nuxtseo/protocol/v1/backlinks';
5
5
  import type { ContentBrief, ContentBriefCreateData, ContentBriefListData } from '@nuxtseo/protocol/v1/content';
6
- import type { SearchAnalyticsData, SearchStatusData } from '@nuxtseo/protocol/v1/gsc';
6
+ import type { IndexingDiagnosticsData, SearchAnalyticsData, SearchStatusData } from '@nuxtseo/protocol/v1/gsc';
7
7
  import type { PageInspect, PageScan } from '@nuxtseo/protocol/v1/pages';
8
8
  import type { SitePerformanceOverview } from '@nuxtseo/protocol/v1/performance';
9
9
  import type { KeywordResearchData, RankingsResearchData, SerpResearchData, StoredResearchOverview } from '@nuxtseo/protocol/v1/research';
@@ -17,6 +17,7 @@ export declare function renderPageScan(data: PageScan): string;
17
17
  export declare function renderPerformance(data: SitePerformanceOverview): string;
18
18
  export declare function renderSearchStatus(data: SearchStatusData): string;
19
19
  export declare function renderSearchAnalytics(data: SearchAnalyticsData): string;
20
+ export declare function renderIndexingDiagnostics(data: IndexingDiagnosticsData): string;
20
21
  export declare function renderResearchOverview(data: StoredResearchOverview): string;
21
22
  export declare function renderKeywordResearch(data: KeywordResearchData): string;
22
23
  export declare function renderSerpResearch(data: SerpResearchData): string;
package/dist/render.js CHANGED
@@ -34,6 +34,13 @@ export function renderUsage(response) {
34
34
  });
35
35
  return [`Plan: ${response.data.plan}`, ...meters].join('\n');
36
36
  }
37
+ function evidenceAgeLine(freshness) {
38
+ if (freshness.verdict === 'aged')
39
+ return ` evidence observed ${freshness.ageHours}h ago (${freshness.confidence})`;
40
+ if (freshness.verdict === 'unknown')
41
+ return ' evidence age unknown';
42
+ return '';
43
+ }
37
44
  export function renderActions(data) {
38
45
  if (data.actions.length === 0)
39
46
  return 'No next actions.';
@@ -42,7 +49,8 @@ export function renderActions(data) {
42
49
  ...data.actions.map(action => [
43
50
  `${action.id} ${action.diagnosis}`,
44
51
  ` ${action.status}, effort ${action.effort}, ${action.affectedPages ?? 'unknown'} affected pages`,
45
- ].join('\n')),
52
+ evidenceAgeLine(action.evidence.freshness),
53
+ ].filter(Boolean).join('\n')),
46
54
  data.page.hasMore ? `More actions available after offset ${data.page.offset + data.page.limit}.` : '',
47
55
  ].filter(Boolean).join('\n');
48
56
  }
@@ -76,9 +84,12 @@ export function renderPageInspection(data) {
76
84
  ['SEO', data.performance.lighthouse.seo],
77
85
  ])
78
86
  : 'No Lighthouse scan.';
87
+ const empty = data.observations.coverage === 'issues-open'
88
+ ? ''
89
+ : ` (${data.observations.coverage})`;
79
90
  return [
80
91
  data.page.url,
81
- `Observations: ${data.observations.total}`,
92
+ `Observations: ${data.observations.total}${empty}`,
82
93
  ...observations,
83
94
  lighthouse,
84
95
  `Tracked keywords: ${data.search.keywords.length}`,
@@ -140,6 +151,34 @@ export function renderSearchAnalytics(data) {
140
151
  ['Rows', 'rows' in data ? data.rows.length : data.type === 'timeseries' ? data.daily.length : 0],
141
152
  ]);
142
153
  }
154
+ export function renderIndexingDiagnostics(data) {
155
+ if (data.view === 'summary') {
156
+ const indexedPercent = data.totalUrls > 0
157
+ ? `${(data.indexed / data.totalUrls * 100).toFixed(1)}%`
158
+ : '0.0%';
159
+ const issues = data.issues
160
+ .filter(issue => issue.count > 0)
161
+ .sort((left, right) => right.count - left.count)
162
+ .map(issue => `${issue.count} ${issue.label} ${issue.severity}`);
163
+ return [
164
+ fields([
165
+ ['Known URLs', data.totalUrls],
166
+ ['Indexed', data.indexed],
167
+ ['Not indexed', Math.max(0, data.totalUrls - data.indexed)],
168
+ ['Indexed rate', indexedPercent],
169
+ ['As of', data.asOf],
170
+ ]),
171
+ ...(issues.length > 0 ? ['Issues', ...issues] : []),
172
+ ].join('\n');
173
+ }
174
+ if (data.urls.length === 0)
175
+ return 'No Search Console indexing URLs match these filters.';
176
+ return [
177
+ `Search Console indexing URLs (${data.urls.length} of ${data.total})`,
178
+ ...data.urls.map(row => `${row.verdict ?? 'unknown'} ${row.coverageState ?? 'unknown'}\n ${row.url}`),
179
+ data.hasMore ? `More URLs available after offset ${data.offset + data.limit}.` : '',
180
+ ].filter(Boolean).join('\n');
181
+ }
143
182
  export function renderResearchOverview(data) {
144
183
  const subject = data.subject
145
184
  ? fields([
@@ -1,10 +1,12 @@
1
1
  export declare const STATE_DIRECTORY_NAME = ".nuxtseo";
2
2
  export declare const AUTH_FILENAME = "auth.json";
3
3
  export declare const CONFIG_FILENAME = "config.json";
4
+ export declare const UPDATE_CHECK_FILENAME = "update-check.json";
4
5
  export interface StatePaths {
5
6
  directory: string;
6
7
  authFile: string;
7
8
  configFile: string;
9
+ updateCheckFile: string;
8
10
  }
9
11
  export declare function createStatePaths(homeDirectory: string): StatePaths;
10
12
  export declare function defaultStatePaths(): StatePaths;
@@ -3,12 +3,14 @@ import { join } from 'pathe';
3
3
  export const STATE_DIRECTORY_NAME = '.nuxtseo';
4
4
  export const AUTH_FILENAME = 'auth.json';
5
5
  export const CONFIG_FILENAME = 'config.json';
6
+ export const UPDATE_CHECK_FILENAME = 'update-check.json';
6
7
  export function createStatePaths(homeDirectory) {
7
8
  const directory = join(homeDirectory, STATE_DIRECTORY_NAME);
8
9
  return {
9
10
  directory,
10
11
  authFile: join(directory, AUTH_FILENAME),
11
12
  configFile: join(directory, CONFIG_FILENAME),
13
+ updateCheckFile: join(directory, UPDATE_CHECK_FILENAME),
12
14
  };
13
15
  }
14
16
  export function defaultStatePaths() {
@@ -0,0 +1,25 @@
1
+ import type { StatePaths } from './state/index.js';
2
+ /** How long a registry answer stays authoritative, matching npm's update cache. */
3
+ export declare const UPDATE_CHECK_INTERVAL_MS: number;
4
+ export interface UpdateNotice {
5
+ current: string;
6
+ latest: string;
7
+ }
8
+ export interface UpdateCheckCache {
9
+ lastCheckedAt: string;
10
+ latest: string | null;
11
+ }
12
+ export type RegistryLatestFetch = (url: string, signal: AbortSignal) => Promise<string | null>;
13
+ export interface UpdateCheckOptions {
14
+ paths: StatePaths;
15
+ env?: Readonly<Record<string, string | undefined>>;
16
+ fetch?: RegistryLatestFetch;
17
+ now?: () => Date;
18
+ }
19
+ export declare function isNewerVersion(candidate: string, current: string): boolean;
20
+ export declare function updateNoticeFor(latest: string | null, current: string): UpdateNotice | null;
21
+ export declare function updateCheckDue(cache: UpdateCheckCache | null, now: Date): boolean;
22
+ export declare function parseUpdateCheckCache(content: string): UpdateCheckCache | null;
23
+ export declare function fetchRegistryLatest(url: string, signal: AbortSignal): Promise<string | null>;
24
+ export declare function checkForUpdate(options: UpdateCheckOptions): Promise<UpdateNotice | null>;
25
+ export declare function updateNoticeLine(notice: UpdateNotice): string;
@@ -0,0 +1,107 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { VERSION } from './version.js';
3
+ /** How long a registry answer stays authoritative, matching npm's update cache. */
4
+ export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
5
+ const REGISTRY_LATEST_URL = 'https://registry.npmjs.org/@nuxtseo/cli/latest';
6
+ const FETCH_TIMEOUT_MS = 2_000;
7
+ function updateCheckDisabled(env) {
8
+ return env?.NUXTSEO_NO_UPDATE_CHECK === '1' || env?.NUXTSEO_NO_UPDATE_CHECK === 'true';
9
+ }
10
+ export function isNewerVersion(candidate, current) {
11
+ const core = (value) => {
12
+ const parts = value.trim().replace(/^v/, '').split('-')[0].split('.');
13
+ if (parts.length < 3)
14
+ return null;
15
+ const numbers = parts.slice(0, 3).map(part => Number.parseInt(part, 10));
16
+ return numbers.some(part => Number.isNaN(part)) ? null : numbers;
17
+ };
18
+ const candidateCore = core(candidate);
19
+ const currentCore = core(current);
20
+ if (!candidateCore || !currentCore)
21
+ return false;
22
+ for (let index = 0; index < 3; index++) {
23
+ if (candidateCore[index] !== currentCore[index])
24
+ return candidateCore[index] > currentCore[index];
25
+ }
26
+ // Same core: a prerelease candidate is not an update over the stable current.
27
+ return false;
28
+ }
29
+ export function updateNoticeFor(latest, current) {
30
+ return latest !== null && isNewerVersion(latest, current) ? { current, latest } : null;
31
+ }
32
+ export function updateCheckDue(cache, now) {
33
+ if (!cache)
34
+ return true;
35
+ const checkedAt = Date.parse(cache.lastCheckedAt);
36
+ return Number.isNaN(checkedAt) || now.getTime() - checkedAt >= UPDATE_CHECK_INTERVAL_MS;
37
+ }
38
+ export function parseUpdateCheckCache(content) {
39
+ let value;
40
+ try {
41
+ value = JSON.parse(content);
42
+ }
43
+ catch {
44
+ // A corrupt cache file is disposable state, not an error: refetch instead.
45
+ return null;
46
+ }
47
+ if (typeof value !== 'object' || value === null)
48
+ return null;
49
+ const record = value;
50
+ if (typeof record.lastCheckedAt !== 'string')
51
+ return null;
52
+ if (record.latest !== null && typeof record.latest !== 'string')
53
+ return null;
54
+ return { lastCheckedAt: record.lastCheckedAt, latest: record.latest };
55
+ }
56
+ async function readUpdateCheckCache(paths) {
57
+ const content = await readFile(paths.updateCheckFile, 'utf8').catch(() => {
58
+ // A missing or unreadable cache file is the common first-run case.
59
+ return null;
60
+ });
61
+ return typeof content === 'string' ? parseUpdateCheckCache(content) : null;
62
+ }
63
+ async function writeUpdateCheckCache(paths, cache) {
64
+ // Persisting the cache is best effort. A failed write only costs one extra
65
+ // registry fetch on the next run, so the failure is ignorable by design.
66
+ await mkdir(paths.directory, { recursive: true, mode: 0o700 }).catch(() => {
67
+ // See the comment above: the cache is disposable state.
68
+ return undefined;
69
+ });
70
+ await writeFile(paths.updateCheckFile, `${JSON.stringify(cache, null, 2)}\n`, {
71
+ encoding: 'utf8',
72
+ mode: 0o600,
73
+ }).catch(() => {
74
+ // See the comment above: the cache is disposable state.
75
+ return undefined;
76
+ });
77
+ }
78
+ export async function fetchRegistryLatest(url, signal) {
79
+ const response = await fetch(url, { signal, headers: { accept: 'application/json' } });
80
+ if (!response.ok)
81
+ return null;
82
+ const body = await response.json();
83
+ if (typeof body !== 'object' || body === null)
84
+ return null;
85
+ const version = body.version;
86
+ return typeof version === 'string' && version ? version : null;
87
+ }
88
+ export async function checkForUpdate(options) {
89
+ if (updateCheckDisabled(options.env))
90
+ return null;
91
+ const now = options.now?.() ?? new Date();
92
+ const cache = await readUpdateCheckCache(options.paths);
93
+ if (!updateCheckDue(cache, now))
94
+ return updateNoticeFor(cache.latest, VERSION);
95
+ const fetchLatest = options.fetch ?? fetchRegistryLatest;
96
+ const latest = await fetchLatest(REGISTRY_LATEST_URL, AbortSignal.timeout(FETCH_TIMEOUT_MS)).catch(() => {
97
+ // Registry reachability is best effort. The run continues without a hint.
98
+ return null;
99
+ });
100
+ if (latest !== null)
101
+ await writeUpdateCheckCache(options.paths, { lastCheckedAt: now.toISOString(), latest });
102
+ // A stale cached answer still beats no answer while the registry is unreachable.
103
+ return updateNoticeFor(latest ?? cache?.latest ?? null, VERSION);
104
+ }
105
+ export function updateNoticeLine(notice) {
106
+ return `${notice.latest} available, current ${notice.current}. Update with: pnpm add -g @nuxtseo/cli`;
107
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nuxtseo/cli",
3
3
  "type": "module",
4
- "version": "0.1.1",
4
+ "version": "0.1.3",
5
5
  "description": "Command line interface for the NuxtSEO public API.",
6
6
  "license": "MIT",
7
7
  "homepage": "https://nuxtseo.com/pro",
@@ -30,13 +30,11 @@
30
30
  "engines": {
31
31
  "node": ">=22"
32
32
  },
33
- "peerDependencies": {
34
- "@nuxtseo/sdk": "^0.1.1"
35
- },
36
33
  "dependencies": {
37
34
  "@clack/prompts": "^1.7.0",
38
35
  "citty": "^0.2.2",
39
- "pathe": "^2.0.3"
36
+ "pathe": "^2.0.3",
37
+ "@nuxtseo/sdk": "^0.1.3"
40
38
  },
41
39
  "optionalDependencies": {
42
40
  "@napi-rs/keyring": "^1.3.0"
@@ -46,8 +44,7 @@
46
44
  "@types/node": "^26.2.0",
47
45
  "publint": "^0.3.24",
48
46
  "typescript": "npm:typescript-native-bridge@6.0.3-bridge.10.tsgo.7.0.2",
49
- "@nuxtseo/sdk": "0.1.1",
50
- "@nuxtseo/protocol": "0.1.1"
47
+ "@nuxtseo/protocol": "0.1.3"
51
48
  },
52
49
  "publishConfig": {
53
50
  "access": "public"
@@ -26,6 +26,7 @@ report `cached`.
26
26
 
27
27
  `search status` reads stored connection state. It never waits for Google.
28
28
  `search analytics` reads Search Console rows through the public API.
29
+ `search indexing` reads retained URL Inspection coverage through the public API.
29
30
 
30
31
  ## Get the binary
31
32
 
@@ -105,16 +106,17 @@ handling failures, read [CLI protocol](references/protocol.md).
105
106
  | `sites list` | Every accessible Site | Source of Site IDs |
106
107
  | `sites use <site-id>` | – | Persists a default Site for the human, not for you |
107
108
  | `usage` | Plan and meters | `--group integrations\|compute\|capacity` |
108
- | `actions list` | Server-ranked issues and opportunities | `--limit 1..25` (default 10), `--offset` |
109
+ | `actions list` | Server-ranked issues and opportunities | `--limit 1..25` (default 10), `--offset`. JSON carries `evidence.freshness`; `verdict: "aged"` means the observation is over a day old, so live-check before fixing |
109
110
  | `actions show <action-id>` | One issue or opportunity plus evidence | `--limit 1..100` (default 50), `--group-id`, `--cursor` |
110
111
  | `actions resolve <action-id>` | – | Mutation. Claims the issue or opportunity and starts server verification |
111
112
  | `backlinks recoverable` | Stored recoverable backlinks | `--limit 1..200`, `--offset` |
112
113
  | `mentions list` | Stored mentions | `--limit 1..200` |
113
- | `page inspect <url>` | Stored observations, Lighthouse, keywords | `--limit 1..200`, `--offset`, `--include-resolved` |
114
+ | `page inspect <url>` | Stored observations, Lighthouse, keywords | `--limit 1..200`, `--offset`, `--include-resolved`. JSON carries `observations.coverage`: `never-scanned` means no source recorded this Page, `scanned-clear` means recorded and all clear |
114
115
  | `page scan <url>` | – | Mutation. Starts mobile and desktop scans |
115
116
  | `performance` | Site performance overview | Medians for perf, a11y, SEO, LCP, TBT, CLS |
116
117
  | `search status` | Stored Search Console connection | Provider free |
117
118
  | `search analytics <pages\|keywords>` | Search Console Page or query rows | `--period`, `--limit`, `--page`, `--search` |
119
+ | `search indexing <summary\|urls>` | Retained URL Inspection coverage | `summary` returns indexed counts; `urls` supports `--issue`, `--status`, `--limit`, `--offset` |
118
120
  | `research overview` | Stored Site and competitor research | Metrics, history, gaps, and quick wins |
119
121
  | `research keywords <topic>` | Live keyword ideas | Volume, difficulty, intent, and cost data |
120
122
  | `research serp <keyword>` | Live SERP snapshot | Results, features, and fetch time |
@@ -141,7 +143,10 @@ flag, and prefer the table above for anything it already answers.
141
143
  This is the sequence that turns CLI output into a code change:
142
144
 
143
145
  1. `nuxtseo actions list --site <id> --json` gives ranked work with an ID, a
144
- diagnosis, an effort, and an affected page count.
146
+ diagnosis, an effort, and an affected page count. Check
147
+ `evidence.freshness` per row: `verdict: "aged"` with a large `ageHours`
148
+ means the evidence is old. Live-check a cheap sample before fixing, because
149
+ the site may have moved on since the observation.
145
150
  2. `nuxtseo actions show <action-id> --site <id> --json` gives the evidence:
146
151
  which pages, which finding type, and when it was observed.
147
152
  3. Fix the cause in the repository. The evidence names URLs; map them back to
@@ -164,4 +169,6 @@ command exits `5` with `stale_evidence`; re-run step 2 and decide again.
164
169
  - Do not loop over Pages, keywords, or domains unattended. Check `usage` first.
165
170
  - Live research can consume allowance. A cached result does not consume a unit.
166
171
  - Report failures as they are. The CLI has no MCP or private-route fallback.
167
- - Do not treat an empty result as clean. The Site may have incomplete evidence.
172
+ - Do not treat an empty result as clean. `page inspect` JSON says which empty
173
+ it is through `observations.coverage`; other commands may still have
174
+ incomplete evidence.
@@ -32,6 +32,22 @@ Local outcomes have no server body. They carry `schemaVersion: 1` and one tag:
32
32
 
33
33
  Discriminate on `_tag`. Protocol envelopes never carry one.
34
34
 
35
+ A `CliError` for exit `2` or exit `7` also carries `cliVersion`, and
36
+ `latestKnownVersion` when the CLI knows the registry holds a newer version.
37
+ Those two exits are where a stale binary looks like broken docs or a broken
38
+ server; check the versions before debugging anything else.
39
+
40
+ ## Update hints
41
+
42
+ Every run checks the npm registry for a newer CLI, cached for 24 hours. When a
43
+ newer version is known, one line goes to stderr, for example:
44
+
45
+ ```
46
+ 0.2.0 available, current 0.1.1. Update with: pnpm add -g @nuxtseo/cli
47
+ ```
48
+
49
+ Set `NUXTSEO_NO_UPDATE_CHECK=1` to disable the check, for example in CI.
50
+
35
51
  ## Paging
36
52
 
37
53
  One invocation makes one request. The CLI never auto-pages or merges responses.
@@ -51,7 +67,7 @@ Branch on the exit code, not message text.
51
67
  | `4` | Forbidden, scope, or entitlement failure | Report the plan or token blocker |
52
68
  | `5` | Conflict, stale evidence, or ambiguous Site | Re-read. Pass `--site` if ambiguous |
53
69
  | `6` | Rate limit, quota, provider outage, or timeout | Read retry metadata, then wait |
54
- | `7` | Local state, network, contract, or infrastructure failure | Report it with the request ID |
70
+ | `7` | Local state, network, contract, or infrastructure failure | Report it with the request ID. If the message names `contract_violation`, update the CLI first |
55
71
  | `8` | Resource or Site not found | Run `sites list` for a valid Site ID |
56
72
  | `127` | Shell cannot find `nuxtseo` | Install the CLI |
57
73
  | `130` | Interrupted or cancelled | Check whether a mutation ran before retrying |