@nuxtseo/cli 0.1.1 → 0.1.2

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/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,
@@ -336,9 +348,16 @@ async function interactiveBare(runtime, globals) {
336
348
  }
337
349
  export async function runCli(rawArgs, runtime) {
338
350
  const jsonRequested = rawArgs.includes('--json');
351
+ // Started before parsing so the registry round trip overlaps the command. The
352
+ // check reads a local cache and never rejects; a slow registry only delays
353
+ // this line, never the command result itself.
354
+ const updateCheck = checkForUpdate({ paths: runtime.paths, env: runtime.env });
339
355
  const parsed = extractGlobalOptions(rawArgs);
340
- if (parsed._tag === 'Err')
341
- return report(runtime, parsed.error, jsonRequested);
356
+ if (parsed._tag === 'Err') {
357
+ const notice = await updateCheck;
358
+ writeUpdateNotice(runtime, notice);
359
+ return report(runtime, parsed.error, jsonRequested, notice);
360
+ }
342
361
  const { args, options: globals } = parsed.value;
343
362
  const effectiveRuntime = {
344
363
  ...runtime,
@@ -350,21 +369,28 @@ export async function runCli(rawArgs, runtime) {
350
369
  requestTimeoutMs: globals.timeoutMs,
351
370
  };
352
371
  if (args.length === 1 && (args[0] === '--version' || args[0] === '-v')) {
372
+ const notice = await updateCheck;
353
373
  if (globals.json)
354
374
  writeCliResponse(effectiveRuntime, { _tag: 'CliVersion', schemaVersion: 1, version: VERSION });
355
375
  else
356
376
  writeOutput(effectiveRuntime, VERSION);
377
+ writeUpdateNotice(effectiveRuntime, notice);
357
378
  return EXIT_CODE.success;
358
379
  }
359
380
  if (args.includes('--help') || args.includes('-h')) {
360
381
  const command = createRootCommand(effectiveRuntime, globals, { result: null });
361
382
  const validOptions = validateCommandOptions(command, args);
362
- if (validOptions._tag === 'Err')
363
- return report(effectiveRuntime, validOptions.error, globals.json);
383
+ if (validOptions._tag === 'Err') {
384
+ const notice = await updateCheck;
385
+ writeUpdateNotice(effectiveRuntime, notice);
386
+ return report(effectiveRuntime, validOptions.error, globals.json, notice);
387
+ }
364
388
  if (globals.json)
365
389
  writeCliResponse(effectiveRuntime, describeCommand(command, args, VERSION));
366
390
  else
367
391
  writeOutput(effectiveRuntime, await requestedUsage(effectiveRuntime, globals, args));
392
+ const notice = await updateCheck;
393
+ writeUpdateNotice(effectiveRuntime, notice);
368
394
  return EXIT_CODE.success;
369
395
  }
370
396
  const result = args.length === 0
@@ -372,7 +398,9 @@ export async function runCli(rawArgs, runtime) {
372
398
  ? await interactiveBare(effectiveRuntime, globals)
373
399
  : await nonInteractiveBare(effectiveRuntime, globals)
374
400
  : await runExplicit(args, effectiveRuntime, globals);
401
+ const notice = await updateCheck;
402
+ writeUpdateNotice(effectiveRuntime, notice);
375
403
  return result._tag === 'Ok'
376
404
  ? EXIT_CODE.success
377
- : report(effectiveRuntime, timeoutRetryFailure(result.error, rawArgs, globals.timeoutMs), globals.json);
405
+ : report(effectiveRuntime, timeoutRetryFailure(result.error, rawArgs, globals.timeoutMs), globals.json, notice);
378
406
  }
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.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}`,
@@ -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.2",
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.2"
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.2"
51
48
  },
52
49
  "publishConfig": {
53
50
  "access": "public"
@@ -105,12 +105,12 @@ handling failures, read [CLI protocol](references/protocol.md).
105
105
  | `sites list` | Every accessible Site | Source of Site IDs |
106
106
  | `sites use <site-id>` | – | Persists a default Site for the human, not for you |
107
107
  | `usage` | Plan and meters | `--group integrations\|compute\|capacity` |
108
- | `actions list` | Server-ranked issues and opportunities | `--limit 1..25` (default 10), `--offset` |
108
+ | `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
109
  | `actions show <action-id>` | One issue or opportunity plus evidence | `--limit 1..100` (default 50), `--group-id`, `--cursor` |
110
110
  | `actions resolve <action-id>` | – | Mutation. Claims the issue or opportunity and starts server verification |
111
111
  | `backlinks recoverable` | Stored recoverable backlinks | `--limit 1..200`, `--offset` |
112
112
  | `mentions list` | Stored mentions | `--limit 1..200` |
113
- | `page inspect <url>` | Stored observations, Lighthouse, keywords | `--limit 1..200`, `--offset`, `--include-resolved` |
113
+ | `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
114
  | `page scan <url>` | – | Mutation. Starts mobile and desktop scans |
115
115
  | `performance` | Site performance overview | Medians for perf, a11y, SEO, LCP, TBT, CLS |
116
116
  | `search status` | Stored Search Console connection | Provider free |
@@ -141,7 +141,10 @@ flag, and prefer the table above for anything it already answers.
141
141
  This is the sequence that turns CLI output into a code change:
142
142
 
143
143
  1. `nuxtseo actions list --site <id> --json` gives ranked work with an ID, a
144
- diagnosis, an effort, and an affected page count.
144
+ diagnosis, an effort, and an affected page count. Check
145
+ `evidence.freshness` per row: `verdict: "aged"` with a large `ageHours`
146
+ means the evidence is old. Live-check a cheap sample before fixing, because
147
+ the site may have moved on since the observation.
145
148
  2. `nuxtseo actions show <action-id> --site <id> --json` gives the evidence:
146
149
  which pages, which finding type, and when it was observed.
147
150
  3. Fix the cause in the repository. The evidence names URLs; map them back to
@@ -164,4 +167,6 @@ command exits `5` with `stale_evidence`; re-run step 2 and decide again.
164
167
  - Do not loop over Pages, keywords, or domains unattended. Check `usage` first.
165
168
  - Live research can consume allowance. A cached result does not consume a unit.
166
169
  - 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.
170
+ - Do not treat an empty result as clean. `page inspect` JSON says which empty
171
+ it is through `observations.coverage`; other commands may still have
172
+ 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 |