@nuxtseo/cli 0.2.1 → 0.3.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.
package/dist/ansi.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Removes every ANSI escape sequence from one string.
3
+ */
4
+ export declare function stripAnsi(value: string): string;
5
+ /**
6
+ * Serializes a value as JSON with every string value free of ANSI escapes.
7
+ */
8
+ export declare function stringifyWithoutAnsi(value: unknown): string;
package/dist/ansi.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Terminal styling never belongs in machine output.
3
+ *
4
+ * `citty` builds its own argument errors with `cyan(...)`, and it decides once,
5
+ * at import time, whether to emit colour. The CLI cannot turn that decision off
6
+ * after the fact, and the same risk exists for any other dependency whose text
7
+ * reaches a JSON envelope. So the CLI strips escape sequences at the boundary
8
+ * where machine output is written, which covers every producer at once.
9
+ */
10
+ /**
11
+ * A control sequence: `ESC [`, parameter and intermediate bytes, then one final
12
+ * byte. Colour, cursor moves and erase sequences all take this form.
13
+ */
14
+ // eslint-disable-next-line no-control-regex
15
+ const ANSI_CONTROL_SEQUENCE = /\u001B\[[\u0030-\u003F]*[\u0020-\u002F]*[\u0040-\u007E]/g;
16
+ /**
17
+ * An operating system command, such as a terminal hyperlink or a window title.
18
+ * It ends with BEL or with `ESC \`.
19
+ */
20
+ // eslint-disable-next-line no-control-regex
21
+ const ANSI_OPERATING_SYSTEM_COMMAND = /\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)/g;
22
+ /**
23
+ * Removes every ANSI escape sequence from one string.
24
+ */
25
+ export function stripAnsi(value) {
26
+ return value
27
+ .replace(ANSI_OPERATING_SYSTEM_COMMAND, '')
28
+ .replace(ANSI_CONTROL_SEQUENCE, '');
29
+ }
30
+ /**
31
+ * Serializes a value as JSON with every string value free of ANSI escapes.
32
+ */
33
+ export function stringifyWithoutAnsi(value) {
34
+ return JSON.stringify(value, (_key, entry) => typeof entry === 'string' ? stripAnsi(entry) : entry);
35
+ }
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as prompts from '@clack/prompts';
2
2
  import { renderUsage, runCommand } from 'citty';
3
+ import { stripAnsi } from './ansi.js';
3
4
  import { fromStateError, loadApiContext } from './api.js';
4
5
  import { commandWithGlobalOptions, describeCommand, resolveCommand, validateCommandOptions, } from './command-contract.js';
5
6
  import { createRootCommand } from './commands.js';
@@ -15,6 +16,9 @@ function writeUpdateNotice(runtime, notice) {
15
16
  }
16
17
  function report(runtime, failure, json = false, notice = null) {
17
18
  if (json) {
19
+ // `--json` means a program reads both streams. Terminal styling is noise
20
+ // there, so stderr is stripped as well as stdout.
21
+ failure = { ...failure, message: stripAnsi(failure.message) };
18
22
  if (failure.protocolResponse !== undefined) {
19
23
  writeProtocolResponse(runtime, failure.protocolResponse);
20
24
  }
@@ -405,7 +409,7 @@ export async function runCli(rawArgs, runtime) {
405
409
  // Started before parsing so the registry round trip overlaps the command. The
406
410
  // check reads a local cache and never rejects; a slow registry only delays
407
411
  // this line, never the command result itself.
408
- const updateCheck = checkForUpdate({ paths: runtime.paths, env: runtime.env });
412
+ const updateCheck = checkForUpdate({ paths: runtime.paths, env: runtime.env, onDiagnostic: line => writeDiagnostic(runtime, line) });
409
413
  const parsed = extractGlobalOptions(rawArgs);
410
414
  if (parsed._tag === 'Err') {
411
415
  const notice = await updateCheck;
@@ -416,10 +420,15 @@ export async function runCli(rawArgs, runtime) {
416
420
  const effectiveRuntime = {
417
421
  ...runtime,
418
422
  interactive: runtime.interactive && !globals.json && !globals.noInput,
419
- requestSignal: AbortSignal.any([
420
- runtime.requestSignal,
421
- AbortSignal.timeout(globals.timeoutMs),
422
- ]),
423
+ // A getter, so each request reads a fresh `--timeout-ms` deadline. One timer
424
+ // made here covered the whole process: an `--all` walk of healthy pages
425
+ // exited 6 once their SUM passed the per-request limit.
426
+ get requestSignal() {
427
+ return AbortSignal.any([
428
+ runtime.requestSignal,
429
+ AbortSignal.timeout(globals.timeoutMs),
430
+ ]);
431
+ },
423
432
  requestTimeoutMs: globals.timeoutMs,
424
433
  };
425
434
  if (args.length === 1 && (args[0] === '--version' || args[0] === '-v')) {
@@ -24,4 +24,11 @@ export type NextPage = {
24
24
  } | {
25
25
  _tag: 'Done';
26
26
  };
27
+ /**
28
+ * The seed rule is known before the request runs. The provider answers a
29
+ * refused seed with 200, an empty list, and a `message`, which reads as "no
30
+ * demand for this topic". So the CLI checks the rule first and exits 2 without
31
+ * spending a research request.
32
+ */
33
+ export declare function parseResearchTopic(topic: string): CliResult<string>;
27
34
  export declare function createRootCommand(runtime: CliRuntime, globals: GlobalOptions, execution: CommandExecution): CommandDef<any>;
package/dist/commands.js CHANGED
@@ -8,17 +8,37 @@ import { openBrowser } from './browser.js';
8
8
  import { EXIT_CODE, fail, fromSdkFailure, ok } from './failures.js';
9
9
  import { awaitPairingApproval, createCliPairingClient, startPairing } from './pairing.js';
10
10
  import { parseAbsolutePageUrl, parseChoice, parseInteger, parsePageUrlOrPath, parseRatio } from './parse.js';
11
+ import { PULL_PERIODS, runPull } from './pull.js';
11
12
  import { renderAccountToken, renderAction, renderActionDismissal, renderActionResolution, renderActions, renderAnalytics, renderAnnotation, renderAnnotationDeleted, renderAnnotations, renderAuditChanges, renderBacklinkAnchors, renderBacklinksHistory, renderBacklinksSummary, renderContentBrief, renderContentBriefCreated, renderContentBriefs, renderContentDecay, renderDomainAvailability, renderDomainTraffic, renderDuplicateClusters, renderFieldVitalFindings, renderFieldVitals, renderIndexCohorts, renderIndexingDiagnostics, renderIndexingHistory, renderKeywordResearch, renderLinkOpportunities, renderLinkStructure, renderMentions, renderMonitoredPages, renderPageInspection, renderPageIssues, renderPageScan, renderPerformance, renderRankingsResearch, renderRecoverableBacklinks, renderReferringDomains, renderResearchOverview, renderScanDetail, renderScans, renderSearchAnalytics, renderSearchStatus, renderSerpResearch, renderSitemapAction, renderSitemaps, renderSitemapUrls, renderSites, renderSiteStatus, renderTimeline, renderUrlInspection, renderUsage, } from './render.js';
12
13
  import { writeCliResponse, writeDiagnostic, writeOutput, writeProtocolResponse } from './runtime.js';
13
14
  import { resolveSite } from './site.js';
14
15
  import { installSkill, SKILL_AGENTS } from './skill.js';
15
16
  import { clearCredential, getCredentialStatus, readConfig, resolveApiUrl, saveCredential, updateConfig, } from './state/index.js';
16
17
  import { VERSION } from './version.js';
18
+ /**
19
+ * Some read operations answer 200 with an empty list and a `message` that says
20
+ * why it is empty. A refused argument and a genuinely empty result look the
21
+ * same in the envelope, so a caller that reads only the list draws the wrong
22
+ * conclusion. Text output already prints the note. Machine output must not
23
+ * change the envelope, so the note goes to stderr, where it cannot be lost.
24
+ */
25
+ function reportDataNote(runtime, value) {
26
+ const data = typeof value === 'object' && value !== null ? value.data : undefined;
27
+ if (typeof data !== 'object' || data === null)
28
+ return;
29
+ const { message, tip } = data;
30
+ const lines = [message, tip].filter((line) => typeof line === 'string' && line.trim().length > 0);
31
+ if (lines.length > 0)
32
+ writeDiagnostic(runtime, `Note: ${lines.join(' ')}`);
33
+ }
17
34
  function emit(runtime, globals, value, render) {
18
- if (globals.json)
35
+ if (globals.json) {
19
36
  writeProtocolResponse(runtime, value);
20
- else
37
+ reportDataNote(runtime, value);
38
+ }
39
+ else {
21
40
  writeOutput(runtime, render(value));
41
+ }
22
42
  }
23
43
  function present(runtime, globals, result, render) {
24
44
  if (result._tag === 'Err')
@@ -718,7 +738,37 @@ async function researchOverview(runtime, globals) {
718
738
  }, { signal: runtime.requestSignal }));
719
739
  return present(runtime, globals, response, value => renderResearchOverview(value.data));
720
740
  }
741
+ /**
742
+ * DataForSEO refuses these characters, so the research provider removes them
743
+ * before it counts the words of a seed. Mirrored from
744
+ * `modules/dataforseo/src/runtime/server/utils/seo-tools/keyword-research.ts`.
745
+ */
746
+ const RESEARCH_FORBIDDEN_CHARS = /[`!@%^()={};<>?\\|#/~\u2015\u{1F000}-\u{1FFFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu;
747
+ /** The provider accepts 1 to 3 words per seed keyword. */
748
+ const RESEARCH_SEED_MAX_WORDS = 3;
749
+ function sanitizeResearchSeed(seed) {
750
+ return seed.replace(RESEARCH_FORBIDDEN_CHARS, '').replace(/\s+/g, ' ').trim();
751
+ }
752
+ /**
753
+ * The seed rule is known before the request runs. The provider answers a
754
+ * refused seed with 200, an empty list, and a `message`, which reads as "no
755
+ * demand for this topic". So the CLI checks the rule first and exits 2 without
756
+ * spending a research request.
757
+ */
758
+ export function parseResearchTopic(topic) {
759
+ const seeds = topic.split(',').map(sanitizeResearchSeed).filter(seed => seed.length >= 2);
760
+ if (seeds.length === 0)
761
+ return fail(EXIT_CODE.invalidInput, 'topic must hold letters or digits. Characters like @ # / % ! are removed.');
762
+ const tooLong = seeds.find(seed => seed.split(' ').length > RESEARCH_SEED_MAX_WORDS);
763
+ if (tooLong !== undefined) {
764
+ return fail(EXIT_CODE.invalidInput, `topic seed "${tooLong}" holds more than ${RESEARCH_SEED_MAX_WORDS} words. Use 1 to 3 words per seed. Separate seeds with a comma.`);
765
+ }
766
+ return ok(topic);
767
+ }
721
768
  async function researchKeywordIdeas(runtime, globals, args) {
769
+ const topic = parseResearchTopic(args.topic);
770
+ if (topic._tag === 'Err')
771
+ return topic;
722
772
  const minVolume = parseInteger(args.minVolume, { name: '--min-volume', minimum: 0, defaultValue: 10 });
723
773
  if (minVolume._tag === 'Err')
724
774
  return minVolume;
@@ -747,7 +797,7 @@ async function researchKeywordIdeas(runtime, globals, args) {
747
797
  const response = await withSpinner(runtime, 'Researching keyword ideas', () => resolved.value.api.client.research.keywords({
748
798
  params: { siteId: resolved.value.siteId },
749
799
  query: {
750
- topic: args.topic,
800
+ topic: topic.value,
751
801
  minVolume: minVolume.value,
752
802
  maxVolume: maxVolume.value,
753
803
  minDifficulty: minDifficulty.value,
@@ -954,6 +1004,10 @@ async function analyticsQuery(runtime, globals, args) {
954
1004
  }, { signal: runtime.requestSignal }));
955
1005
  return present(runtime, globals, response, value => renderAnalytics(view.value, value.data));
956
1006
  }
1007
+ /** A count with its noun, so output never reads "1 files". */
1008
+ function count(total, noun) {
1009
+ return `${total} ${noun}${total === 1 ? '' : 's'}`;
1010
+ }
957
1011
  async function skillInstallCommand(runtime, globals, args) {
958
1012
  const agent = parseChoice(args.agent, { name: '--agent', choices: SKILL_AGENTS, defaultValue: 'claude' });
959
1013
  if (agent._tag === 'Err')
@@ -961,7 +1015,7 @@ async function skillInstallCommand(runtime, globals, args) {
961
1015
  // The state directory is `<home>/.nuxtseo`, so its parent is the home the
962
1016
  // agent directories sit beside.
963
1017
  const homeDirectory = dirname(runtime.paths.directory);
964
- const installed = await installSkill({ agent: agent.value, homeDirectory, target: args.target });
1018
+ const installed = await installSkill({ agent: agent.value, homeDirectory, target: args.target, paths: runtime.paths });
965
1019
  if (installed._tag === 'Err')
966
1020
  return installed;
967
1021
  if (globals.json) {
@@ -969,12 +1023,20 @@ async function skillInstallCommand(runtime, globals, args) {
969
1023
  _tag: 'CliSkillInstall',
970
1024
  schemaVersion: 1,
971
1025
  agent: installed.value.agent,
1026
+ version: installed.value.version,
972
1027
  source: installed.value.source,
973
1028
  destination: installed.value.destination,
1029
+ resolvedDestination: installed.value.resolvedDestination,
1030
+ written: installed.value.written,
1031
+ pruned: installed.value.pruned,
974
1032
  });
975
1033
  }
976
1034
  else {
977
- writeOutput(runtime, `Installed the nuxtseo-cli skill for ${installed.value.agent} at ${installed.value.destination}.`);
1035
+ writeOutput(runtime, [
1036
+ `Installed the nuxtseo-cli skill ${installed.value.version} for ${installed.value.agent}.`,
1037
+ `Location: ${installed.value.resolvedDestination}`,
1038
+ `Wrote ${count(installed.value.written, 'file')}. Removed ${count(installed.value.pruned, 'stale file')}.`,
1039
+ ].join('\n'));
978
1040
  }
979
1041
  return ok(undefined);
980
1042
  }
@@ -2108,6 +2170,23 @@ export function createRootCommand(runtime, globals, execution) {
2108
2170
  }),
2109
2171
  },
2110
2172
  });
2173
+ const pull = defineCommand({
2174
+ meta: { name: 'pull', description: 'Write every stored-evidence read for one Site as NDJSON' },
2175
+ args: {
2176
+ 'include': { type: 'string', description: 'Comma separated command names to run instead of the default set' },
2177
+ 'exclude': { type: 'string', description: 'Comma separated command names to drop' },
2178
+ 'with-research': { type: 'boolean', description: 'Add the reads that can start live research; they draw on the Team allowance' },
2179
+ 'period': { type: 'enum', options: [...PULL_PERIODS], description: 'Search Console period for the search analytics reads' },
2180
+ 'concurrency': { type: 'string', description: 'Reads in flight at once, 1 to 8' },
2181
+ },
2182
+ run: ({ args }) => capture(execution, () => runPull(runtime, globals, {
2183
+ include: args.include,
2184
+ exclude: args.exclude,
2185
+ withResearch: args['with-research'],
2186
+ period: args.period,
2187
+ concurrency: args.concurrency,
2188
+ }, apiAndSite), args._, 0)(),
2189
+ });
2111
2190
  const timeline = defineCommand({
2112
2191
  meta: { name: 'timeline', description: 'Read the Timeline — what changed on the Site' },
2113
2192
  subCommands: {
@@ -2219,6 +2298,7 @@ export function createRootCommand(runtime, globals, execution) {
2219
2298
  run: ({ args }) => capture(execution, () => performance(runtime, globals), args._, 0)(),
2220
2299
  }),
2221
2300
  annotations,
2301
+ pull,
2222
2302
  research,
2223
2303
  scans,
2224
2304
  search,
@@ -12,7 +12,7 @@ export declare const EXIT_CODE: {
12
12
  readonly interrupted: 130;
13
13
  };
14
14
  export type ExitCode = typeof EXIT_CODE[keyof typeof EXIT_CODE];
15
- export type LocalFailureCode = 'authentication_required' | 'authorization_required' | 'confirmation_required' | 'conflict' | 'contract_violation' | 'infrastructure_failure' | 'interrupted' | 'invalid_cli_input' | 'not_found' | 'paging_cap_reached' | 'request_timeout' | 'retryable_failure' | 'site_ambiguous' | 'site_empty' | 'site_not_accessible' | 'state_error' | 'token_input_required' | 'transport_failure' | 'unexpected';
15
+ export type LocalFailureCode = 'authentication_required' | 'authorization_required' | 'confirmation_required' | 'conflict' | 'contract_violation' | 'infrastructure_failure' | 'interrupted' | 'invalid_cli_input' | 'not_found' | 'paging_cap_reached' | 'pull_incomplete' | 'request_timeout' | 'retryable_failure' | 'site_ambiguous' | 'site_empty' | 'site_not_accessible' | 'state_error' | 'token_input_required' | 'transport_failure' | 'unexpected';
16
16
  export type CliFailureCode = LocalFailureCode | Extract<SdkFailure, {
17
17
  _tag: 'ApiFailure';
18
18
  }>['code'];
@@ -24,14 +24,54 @@ export interface CliFailure {
24
24
  cause?: unknown;
25
25
  protocolResponse?: unknown;
26
26
  }
27
+ export interface CliErr {
28
+ _tag: 'Err';
29
+ error: CliFailure;
30
+ }
27
31
  export type CliResult<T> = {
28
32
  _tag: 'Ok';
29
33
  value: T;
34
+ } | CliErr;
35
+ export declare function ok<T>(value: T): CliResult<T>;
36
+ export declare function fail(exitCode: ExitCode, message: string, cause?: unknown, code?: CliFailureCode): CliErr;
37
+ /**
38
+ * Which side of a `contract_violation` runs the older contract.
39
+ *
40
+ * The CLI knows the contract version it was built against. The API reports its
41
+ * own in the `X-NuxtSEO-Version` response header, which the SDK keeps on
42
+ * `metadata.version`. When the two differ, the direction of the skew is a fact.
43
+ * When they match, or the header is absent, the CLI cannot observe a direction,
44
+ * so the message must not name one.
45
+ *
46
+ * Bug 2026-09-21: a field landed in a client response schema before the API
47
+ * deployed it. A newer CLI rejected an older API response, and the old message
48
+ * told the operator to update the CLI, which was already the newest part.
49
+ */
50
+ export type ContractSkew = {
51
+ _tag: 'CliContractOlder';
52
+ cliContract: string;
53
+ apiContract: string;
30
54
  } | {
31
- _tag: 'Err';
32
- error: CliFailure;
55
+ _tag: 'ApiContractOlder';
56
+ cliContract: string;
57
+ apiContract: string;
58
+ } | {
59
+ _tag: 'ContractVersionsMatch';
60
+ contract: string;
61
+ } | {
62
+ _tag: 'ApiContractUnknown';
63
+ cliContract: string;
33
64
  };
34
- export declare function ok<T>(value: T): CliResult<T>;
35
- export declare function fail(exitCode: ExitCode, message: string, cause?: unknown, code?: CliFailureCode): CliResult<never>;
36
- export declare function fromSdkFailure(error: SdkFailure): CliResult<never>;
65
+ /**
66
+ * Classify the skew from what the response actually carried.
67
+ *
68
+ * `apiContract` is the value of the `X-NuxtSEO-Version` response header, or
69
+ * `undefined` when the API sent no header the SDK could read.
70
+ */
71
+ export declare function classifyContractSkew(apiContract: string | undefined, cliContract?: string): ContractSkew;
72
+ /**
73
+ * The remediation a caller can act on, and nothing the CLI cannot observe.
74
+ */
75
+ export declare function contractSkewLines(skew: ContractSkew): string[];
76
+ export declare function fromSdkFailure(error: SdkFailure): CliErr;
37
77
  export declare function unexpectedFailure(cause: unknown): CliFailure;
package/dist/failures.js CHANGED
@@ -1,11 +1,6 @@
1
+ import { PUBLIC_V1_VERSION } from '@nuxtseo/protocol/v1/core';
1
2
  import { VERSION } from './version.js';
2
3
  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(' ');
9
4
  export const EXIT_CODE = {
10
5
  success: 0,
11
6
  invalidInput: 2,
@@ -103,6 +98,99 @@ function requestIssueLine(issue) {
103
98
  const text = typeof message === 'string' && message ? message : JSON.stringify(issue);
104
99
  return field ? `${field}: ${text}` : text;
105
100
  }
101
+ function contractOrder(version) {
102
+ const parts = version.split('.');
103
+ const numbers = parts.map(part => Number(part));
104
+ return numbers.every(part => Number.isSafeInteger(part) && part >= 0) ? numbers : undefined;
105
+ }
106
+ function compareContracts(left, right) {
107
+ for (let index = 0; index < Math.max(left.length, right.length); index++) {
108
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
109
+ if (difference !== 0)
110
+ return difference;
111
+ }
112
+ return 0;
113
+ }
114
+ /**
115
+ * Classify the skew from what the response actually carried.
116
+ *
117
+ * `apiContract` is the value of the `X-NuxtSEO-Version` response header, or
118
+ * `undefined` when the API sent no header the SDK could read.
119
+ */
120
+ export function classifyContractSkew(apiContract, cliContract = PUBLIC_V1_VERSION) {
121
+ if (!apiContract)
122
+ return { _tag: 'ApiContractUnknown', cliContract };
123
+ if (apiContract === cliContract)
124
+ return { _tag: 'ContractVersionsMatch', contract: cliContract };
125
+ const api = contractOrder(apiContract);
126
+ const cli = contractOrder(cliContract);
127
+ if (!api || !cli)
128
+ return { _tag: 'ApiContractUnknown', cliContract };
129
+ const difference = compareContracts(cli, api);
130
+ if (difference < 0)
131
+ return { _tag: 'CliContractOlder', cliContract, apiContract };
132
+ if (difference > 0)
133
+ return { _tag: 'ApiContractOlder', cliContract, apiContract };
134
+ return { _tag: 'ContractVersionsMatch', contract: cliContract };
135
+ }
136
+ /**
137
+ * The remediation a caller can act on, and nothing the CLI cannot observe.
138
+ */
139
+ export function contractSkewLines(skew) {
140
+ switch (skew._tag) {
141
+ case 'CliContractOlder':
142
+ return [
143
+ `Contract version: this CLI expects ${skew.cliContract}. The API reported ${skew.apiContract}.`,
144
+ 'The API runs a newer contract than this CLI.',
145
+ `Update the CLI with ${UPDATE_COMMAND}, then run the command again.`,
146
+ ];
147
+ case 'ApiContractOlder':
148
+ return [
149
+ `Contract version: this CLI expects ${skew.cliContract}. The API reported ${skew.apiContract}.`,
150
+ 'The API runs an older contract than this CLI.',
151
+ 'Wait for the API deploy, then run the command again.',
152
+ ];
153
+ case 'ContractVersionsMatch':
154
+ return [
155
+ `Contract version: both sides reported ${skew.contract}.`,
156
+ 'The CLI cannot tell which side changed.',
157
+ `If this CLI is older than the deployed API, update it with ${UPDATE_COMMAND}.`,
158
+ 'If the deployed API is older than this CLI, wait for its deploy.',
159
+ ];
160
+ case 'ApiContractUnknown':
161
+ return [
162
+ `Contract version: this CLI expects ${skew.cliContract}. The API reported none.`,
163
+ 'The CLI cannot tell which side changed.',
164
+ `If this CLI is older than the deployed API, update it with ${UPDATE_COMMAND}.`,
165
+ 'If the deployed API is older than this CLI, wait for its deploy.',
166
+ ];
167
+ }
168
+ }
169
+ const MAX_SCHEMA_ISSUES = 5;
170
+ /**
171
+ * The field paths that failed validation.
172
+ *
173
+ * The SDK already holds the zod issues at the point of failure, and the path is
174
+ * the one fact that names the unmet expectation. The old message dropped them.
175
+ */
176
+ function contractIssueLines(issues) {
177
+ if (issues.length === 0)
178
+ return [];
179
+ const shown = issues.slice(0, MAX_SCHEMA_ISSUES).map(issue => ` ${requestIssueLine(issue)}`);
180
+ const hidden = issues.length - shown.length;
181
+ return [
182
+ 'Schema issues:',
183
+ ...shown,
184
+ ...(hidden > 0 ? [` and ${hidden} more.`] : []),
185
+ ];
186
+ }
187
+ function contractLines(apiContract, issues) {
188
+ return [
189
+ ...contractIssueLines(issues),
190
+ ...contractSkewLines(classifyContractSkew(apiContract)),
191
+ `CLI version: ${VERSION}.`,
192
+ ];
193
+ }
106
194
  export function fromSdkFailure(error) {
107
195
  switch (error._tag) {
108
196
  case 'RequestFailure':
@@ -125,16 +213,19 @@ export function fromSdkFailure(error) {
125
213
  error.code === 'auth_expired'
126
214
  ? 'Open site settings in the dashboard, then Search Console, to reconnect.'
127
215
  : undefined,
128
- error.code === 'contract_violation'
129
- ? CONTRACT_REMEDIATION
130
- : undefined,
216
+ // The API reported the violation, so it carries no client-side issues.
217
+ ...(error.code === 'contract_violation' ? contractLines(error.metadata.version, []) : []),
131
218
  ].filter((line) => line !== undefined).join('\n'),
132
219
  protocolResponse: error.response,
133
220
  },
134
221
  };
135
222
  case 'ContractFailure':
136
- return fail(EXIT_CODE.infrastructure, [`contract_violation: ${error.message}`, error.requestId ? `Request ID: ${error.requestId}` : undefined, CONTRACT_REMEDIATION, `Current CLI version: ${VERSION}`]
223
+ return fail(EXIT_CODE.infrastructure, [
224
+ `contract_violation: ${error.message}`,
225
+ error.requestId ? `Request ID: ${error.requestId}` : undefined,
226
+ ]
137
227
  .filter((line) => line !== undefined)
228
+ .concat(contractLines(error.metadata.version, error.issues))
138
229
  .join('\n'), undefined, 'contract_violation');
139
230
  case 'TransportFailure': {
140
231
  const timedOut = error.reason === 'aborted'
package/dist/pairing.js CHANGED
@@ -5,10 +5,8 @@ import { VERSION } from './version.js';
5
5
  /**
6
6
  * Per-poll request deadline.
7
7
  *
8
- * `runtime.requestSignal` is NOT usable here: it is built once at startup as
9
- * `AbortSignal.any([runtime.signal, AbortSignal.timeout(globals.timeoutMs)])`,
10
- * so a poll loop bound to it would die after the default 30s. Each poll gets a
11
- * short deadline of its own, and the pairing deadline is tracked in the loop.
8
+ * Each poll gets a short deadline of its own, shorter than `--timeout-ms`, and
9
+ * the pairing deadline is tracked in the loop.
12
10
  */
13
11
  const POLL_REQUEST_TIMEOUT_MS = 8_000;
14
12
  export const CLIENT_NAME = 'nuxtseo-cli';
package/dist/pull.d.ts ADDED
@@ -0,0 +1,81 @@
1
+ import type { PublicSdkResult, PublicV1Client, SdkFailure } from '@nuxtseo/sdk';
2
+ import type { ApiContext } from './api.js';
3
+ import type { CliFailure, CliResult, ExitCode } from './failures.js';
4
+ import type { GlobalOptions } from './parse.js';
5
+ import type { CliRuntime } from './runtime.js';
6
+ type ProtocolResult = PublicSdkResult<unknown, SdkFailure>;
7
+ /** Search Console periods `pull` accepts, mirrored from `search analytics`. */
8
+ export declare const PULL_PERIODS: readonly ['7d', '28d', '3m', '6m', '12m'];
9
+ export type PullPeriod = typeof PULL_PERIODS[number];
10
+ export interface PullContext {
11
+ client: PublicV1Client;
12
+ siteId: string;
13
+ period: PullPeriod;
14
+ signal: AbortSignal;
15
+ }
16
+ /**
17
+ * One read `pull` performs, named by the command a caller would run alone.
18
+ *
19
+ * `spendsResearch` marks a read that can start live research, which draws on
20
+ * the Team research allowance. Those operations stay out of the default set.
21
+ */
22
+ export interface PullOperation {
23
+ name: string;
24
+ spendsResearch: boolean;
25
+ request: (context: PullContext) => Promise<ProtocolResult>;
26
+ }
27
+ /**
28
+ * Every read `pull` knows, in the order it writes them.
29
+ *
30
+ * Only Site scoped, read-only, stored-evidence operations belong here. A
31
+ * mutation is excluded because a dump must never change server state. An
32
+ * operation that needs an argument a dump cannot invent, such as a URL, an
33
+ * action ID or a Scan ID, is excluded because there is nothing to pass.
34
+ */
35
+ export declare const PULL_OPERATIONS: readonly PullOperation[];
36
+ export interface PullSelection {
37
+ include?: string;
38
+ exclude?: string;
39
+ withResearch?: boolean;
40
+ }
41
+ /**
42
+ * The operations one `pull` run performs.
43
+ *
44
+ * The default set is every spend-free read. `--with-research` adds the reads
45
+ * that can start live research. `--include` narrows to the named commands, and
46
+ * naming a research read there is the same consent as `--with-research`.
47
+ * `--exclude` then removes what is left. An unknown name fails before any
48
+ * request, so a typo costs nothing.
49
+ */
50
+ export declare function selectPullOperations(selection: PullSelection, operations?: readonly PullOperation[]): CliResult<readonly PullOperation[]>;
51
+ export declare function worstExitCode(codes: readonly ExitCode[]): ExitCode;
52
+ export interface PullOutcome {
53
+ /** The NDJSON line this read produced. */
54
+ line: unknown;
55
+ exitCode: ExitCode;
56
+ failure?: CliFailure;
57
+ }
58
+ /**
59
+ * One read as one NDJSON line.
60
+ *
61
+ * The server envelope is passed through whole, with one `command` field added
62
+ * beside `data` and `meta`. Nothing is merged, renamed, re-ranked or
63
+ * unwrapped, so a line reads exactly like the single command's own output. A
64
+ * failure with no server body writes a `CliError` value instead, tagged the
65
+ * same way.
66
+ */
67
+ export declare function pullLine(command: string, result: ProtocolResult): PullOutcome;
68
+ export interface PullArguments {
69
+ include?: string;
70
+ exclude?: string;
71
+ withResearch?: boolean;
72
+ period?: string;
73
+ concurrency?: string;
74
+ }
75
+ export declare const PULL_DEFAULT_CONCURRENCY = 4;
76
+ export declare const PULL_MAX_CONCURRENCY = 8;
77
+ export declare function runPull(runtime: CliRuntime, globals: GlobalOptions, args: PullArguments, resolve: (runtime: CliRuntime, globals: GlobalOptions) => Promise<CliResult<{
78
+ api: ApiContext;
79
+ siteId: string;
80
+ }>>, catalogue?: readonly PullOperation[]): Promise<CliResult<void>>;
81
+ export {};