@nuxtseo/cli 0.2.1 → 0.4.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/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 {};
package/dist/pull.js ADDED
@@ -0,0 +1,358 @@
1
+ import { publicV1BooleanFlagValue } from '@nuxtseo/protocol/v1/core';
2
+ import { EXIT_CODE, fail, fromSdkFailure, ok } from './failures.js';
3
+ import { DEFAULT_TIMEOUT_MS, parseChoice, parseInteger } from './parse.js';
4
+ import { writeDiagnostic, writeProtocolResponse } from './runtime.js';
5
+ /** Search Console periods `pull` accepts, mirrored from `search analytics`. */
6
+ export const PULL_PERIODS = ['7d', '28d', '3m', '6m', '12m'];
7
+ function operation(name, request) {
8
+ return { name, spendsResearch: false, request };
9
+ }
10
+ function researchOperation(name, request) {
11
+ return { name, spendsResearch: true, request };
12
+ }
13
+ const SEARCH_ANALYTICS_VIEWS = ['pages', 'keywords', 'countries', 'devices', 'timeseries'];
14
+ const ANALYTICS_VIEWS = ['performance', 'top-pages', 'source-medium', 'key-events', 'countries', 'devices'];
15
+ /**
16
+ * Per-operation row limits.
17
+ *
18
+ * `search indexing urls` timed out twice at 500 rows on the 30000 ms deadline
19
+ * and answered at 50. A dump needs every operation to finish, so the limits
20
+ * here favour completion over depth. If one read needs more rows, run that
21
+ * command alone with `--all`.
22
+ */
23
+ const SEARCH_INDEXING_URL_LIMIT = 50;
24
+ const SITEMAP_URL_LIMIT = 200;
25
+ /**
26
+ * Every read `pull` knows, in the order it writes them.
27
+ *
28
+ * Only Site scoped, read-only, stored-evidence operations belong here. A
29
+ * mutation is excluded because a dump must never change server state. An
30
+ * operation that needs an argument a dump cannot invent, such as a URL, an
31
+ * action ID or a Scan ID, is excluded because there is nothing to pass.
32
+ */
33
+ export const PULL_OPERATIONS = [
34
+ operation('status', context => context.client.sites.status({
35
+ params: { siteId: context.siteId },
36
+ }, { signal: context.signal })),
37
+ operation('actions list', context => context.client.actions.list({
38
+ params: { siteId: context.siteId },
39
+ query: { limit: 25, offset: 0 },
40
+ }, { signal: context.signal })),
41
+ operation('audit changes', context => context.client.audit.changes({
42
+ params: { siteId: context.siteId },
43
+ query: {},
44
+ }, { signal: context.signal })),
45
+ operation('audit content-decay', context => context.client.audit.contentDecay({
46
+ params: { siteId: context.siteId },
47
+ }, { signal: context.signal })),
48
+ operation('audit duplicates', context => context.client.audit.duplicateClusters({
49
+ params: { siteId: context.siteId },
50
+ }, { signal: context.signal })),
51
+ operation('audit link-opportunities', context => context.client.audit.linkOpportunities({
52
+ params: { siteId: context.siteId },
53
+ }, { signal: context.signal })),
54
+ operation('audit link-structure', context => context.client.audit.linkStructure({
55
+ params: { siteId: context.siteId },
56
+ }, { signal: context.signal })),
57
+ operation('performance', context => context.client.performance.overview({
58
+ params: { siteId: context.siteId },
59
+ }, { signal: context.signal })),
60
+ operation('scans list', context => context.client.performance.scans({
61
+ params: { siteId: context.siteId },
62
+ query: { limit: 25 },
63
+ }, { signal: context.signal })),
64
+ operation('scans pages', context => context.client.performance.monitoredPages({
65
+ params: { siteId: context.siteId },
66
+ }, { signal: context.signal })),
67
+ operation('vitals summary', context => context.client.performance.vitals({
68
+ params: { siteId: context.siteId },
69
+ query: { formFactor: 'PHONE', view: 'summary' },
70
+ }, { signal: context.signal })),
71
+ operation('vitals trend', context => context.client.performance.vitals({
72
+ params: { siteId: context.siteId },
73
+ query: { formFactor: 'PHONE', view: 'trend' },
74
+ }, { signal: context.signal })),
75
+ operation('vitals findings', context => context.client.performance.vitalFindings({
76
+ params: { siteId: context.siteId },
77
+ query: { onlyFailing: publicV1BooleanFlagValue(true), limit: 20, offset: 0 },
78
+ }, { signal: context.signal })),
79
+ operation('search status', context => context.client.search.readStatus({
80
+ params: { siteId: context.siteId },
81
+ }, { signal: context.signal })),
82
+ ...SEARCH_ANALYTICS_VIEWS.map(view => operation(`search analytics ${view}`, context => context.client.search.queryAnalytics({
83
+ params: { siteId: context.siteId },
84
+ query: { view, period: context.period, limit: 25, page: 1, sort: 'clicks', sortDir: 'desc' },
85
+ }, { signal: context.signal }))),
86
+ operation('search indexing summary', context => context.client.search.readIndexingDiagnostics({
87
+ params: { siteId: context.siteId },
88
+ query: { view: 'summary', limit: SEARCH_INDEXING_URL_LIMIT, offset: 0 },
89
+ }, { signal: context.signal })),
90
+ operation('search indexing urls', context => context.client.search.readIndexingDiagnostics({
91
+ params: { siteId: context.siteId },
92
+ query: { view: 'urls', limit: SEARCH_INDEXING_URL_LIMIT, offset: 0 },
93
+ }, { signal: context.signal })),
94
+ operation('search cohorts', context => context.client.search.readIndexingCohorts({
95
+ params: { siteId: context.siteId },
96
+ query: { limit: 12, minSectionPages: 5 },
97
+ }, { signal: context.signal })),
98
+ operation('search index-history', context => context.client.search.readIndexingHistory({
99
+ params: { siteId: context.siteId },
100
+ query: { days: 180 },
101
+ }, { signal: context.signal })),
102
+ operation('sitemaps list', context => context.client.sitemaps.list({
103
+ params: { siteId: context.siteId },
104
+ }, { signal: context.signal })),
105
+ operation('sitemaps urls', context => context.client.sitemaps.listUrls({
106
+ params: { siteId: context.siteId },
107
+ query: { limit: SITEMAP_URL_LIMIT },
108
+ }, { signal: context.signal })),
109
+ ...ANALYTICS_VIEWS.map(view => operation(`analytics ${view}`, context => context.client.analytics.query({
110
+ params: { siteId: context.siteId, view },
111
+ query: {
112
+ period: '28d',
113
+ compare: 'previous',
114
+ stableData: true,
115
+ hostScope: true,
116
+ performancePhase: 'full',
117
+ comparePrior: true,
118
+ fresh: false,
119
+ },
120
+ }, { signal: context.signal }))),
121
+ operation('research overview', context => context.client.research.overview({
122
+ params: { siteId: context.siteId },
123
+ }, { signal: context.signal })),
124
+ operation('backlinks recoverable', context => context.client.backlinks.recoverable({
125
+ params: { siteId: context.siteId },
126
+ query: { limit: 100, offset: 0 },
127
+ }, { signal: context.signal })),
128
+ operation('mentions list', context => context.client.mentions.list({
129
+ params: { siteId: context.siteId },
130
+ query: { limit: 100, includeFiltered: publicV1BooleanFlagValue(false) },
131
+ }, { signal: context.signal })),
132
+ operation('timeline list', context => context.client.timeline.entries({
133
+ params: { siteId: context.siteId },
134
+ query: { limit: 25 },
135
+ }, { signal: context.signal })),
136
+ operation('annotations list', context => context.client.timeline.listAnnotations({
137
+ params: { siteId: context.siteId },
138
+ }, { signal: context.signal })),
139
+ researchOperation('backlinks summary', context => context.client.backlinks.summary({
140
+ params: { siteId: context.siteId },
141
+ }, { signal: context.signal })),
142
+ researchOperation('backlinks referring-domains', context => context.client.backlinks.referringDomains({
143
+ params: { siteId: context.siteId },
144
+ query: { limit: 100 },
145
+ }, { signal: context.signal })),
146
+ researchOperation('backlinks anchors', context => context.client.backlinks.anchors({
147
+ params: { siteId: context.siteId },
148
+ query: { limit: 100 },
149
+ }, { signal: context.signal })),
150
+ researchOperation('backlinks history', context => context.client.backlinks.history({
151
+ params: { siteId: context.siteId },
152
+ query: {},
153
+ }, { signal: context.signal })),
154
+ ];
155
+ function selectorTokens(input) {
156
+ return (input ?? '').split(',').map(token => token.trim()).filter(Boolean);
157
+ }
158
+ /**
159
+ * A token selects an operation when it names that operation, or names a
160
+ * command that contains it. `audit` selects every `audit *` read, so a caller
161
+ * narrows a family without spelling each leaf.
162
+ */
163
+ function matches(token, name) {
164
+ return name === token || name.startsWith(`${token} `);
165
+ }
166
+ /**
167
+ * The operations one `pull` run performs.
168
+ *
169
+ * The default set is every spend-free read. `--with-research` adds the reads
170
+ * that can start live research. `--include` narrows to the named commands, and
171
+ * naming a research read there is the same consent as `--with-research`.
172
+ * `--exclude` then removes what is left. An unknown name fails before any
173
+ * request, so a typo costs nothing.
174
+ */
175
+ export function selectPullOperations(selection, operations = PULL_OPERATIONS) {
176
+ const include = selectorTokens(selection.include);
177
+ const exclude = selectorTokens(selection.exclude);
178
+ const unknown = [...include, ...exclude]
179
+ .filter(token => !operations.some(candidate => matches(token, candidate.name)));
180
+ if (unknown.length > 0) {
181
+ return fail(EXIT_CODE.invalidInput, [
182
+ `invalid_cli_input: No pull operation is named ${unknown.map(token => JSON.stringify(token)).join(', ')}.`,
183
+ `Valid names: ${operations.map(candidate => candidate.name).join(', ')}.`,
184
+ ].join('\n'));
185
+ }
186
+ const selected = operations.filter((candidate) => {
187
+ const chosen = include.length > 0
188
+ ? include.some(token => matches(token, candidate.name))
189
+ : !candidate.spendsResearch || selection.withResearch === true;
190
+ return chosen && !exclude.some(token => matches(token, candidate.name));
191
+ });
192
+ if (selected.length === 0)
193
+ return fail(EXIT_CODE.invalidInput, 'invalid_cli_input: The selected filters leave no operation to run.');
194
+ return ok(selected);
195
+ }
196
+ /**
197
+ * How bad each outcome is, so one run reports its worst.
198
+ *
199
+ * Authentication outranks the rest because it stops the run: every later read
200
+ * would fail the same way. Interrupted outranks that again, because the caller
201
+ * asked for the stop.
202
+ */
203
+ const EXIT_SEVERITY = new Map([
204
+ [EXIT_CODE.success, 0],
205
+ [EXIT_CODE.pagingCapReached, 1],
206
+ [EXIT_CODE.retryable, 2],
207
+ [EXIT_CODE.conflict, 3],
208
+ [EXIT_CODE.notFound, 4],
209
+ [EXIT_CODE.authorization, 5],
210
+ [EXIT_CODE.invalidInput, 6],
211
+ [EXIT_CODE.infrastructure, 7],
212
+ [EXIT_CODE.authentication, 8],
213
+ [EXIT_CODE.interrupted, 9],
214
+ ]);
215
+ export function worstExitCode(codes) {
216
+ return codes.reduce((worst, code) => (EXIT_SEVERITY.get(code) ?? 0) > (EXIT_SEVERITY.get(worst) ?? 0) ? code : worst, EXIT_CODE.success);
217
+ }
218
+ /**
219
+ * One read as one NDJSON line.
220
+ *
221
+ * The server envelope is passed through whole, with one `command` field added
222
+ * beside `data` and `meta`. Nothing is merged, renamed, re-ranked or
223
+ * unwrapped, so a line reads exactly like the single command's own output. A
224
+ * failure with no server body writes a `CliError` value instead, tagged the
225
+ * same way.
226
+ */
227
+ export function pullLine(command, result) {
228
+ if (result._tag === 'Ok')
229
+ return { line: { command, ...result.value }, exitCode: EXIT_CODE.success };
230
+ const converted = fromSdkFailure(result.error);
231
+ const failure = converted.error;
232
+ const body = failure.protocolResponse;
233
+ return {
234
+ line: body !== undefined && body !== null && typeof body === 'object'
235
+ ? { command, ...body }
236
+ : {
237
+ command,
238
+ _tag: 'CliError',
239
+ schemaVersion: 1,
240
+ error: { code: failure.code, exitCode: failure.exitCode, message: failure.message },
241
+ },
242
+ exitCode: failure.exitCode,
243
+ failure,
244
+ };
245
+ }
246
+ async function forEachBounded(items, concurrency, worker) {
247
+ let cursor = 0;
248
+ const next = async () => {
249
+ while (cursor < items.length) {
250
+ const index = cursor++;
251
+ await worker(items[index], index);
252
+ }
253
+ };
254
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, next));
255
+ }
256
+ export const PULL_DEFAULT_CONCURRENCY = 4;
257
+ export const PULL_MAX_CONCURRENCY = 8;
258
+ function spendNotice(operations) {
259
+ const spending = operations.filter(candidate => candidate.spendsResearch);
260
+ return spending.length === 0
261
+ ? undefined
262
+ : [
263
+ `These reads can start live research and draw on the Team research allowance: ${spending.map(candidate => candidate.name).join(', ')}.`,
264
+ 'Each envelope reports cache use in `evidence`. Run `nuxtseo usage` to read the allowance.',
265
+ ].join('\n');
266
+ }
267
+ export async function runPull(runtime, globals, args, resolve, catalogue = PULL_OPERATIONS) {
268
+ // `pull` answers with NDJSON and nothing else. There is no text rendering
269
+ // for a stream of thirty envelopes, so the flag is required rather than
270
+ // silently ignored.
271
+ if (!globals.json)
272
+ return fail(EXIT_CODE.invalidInput, 'invalid_cli_input: `pull` writes NDJSON only. Run it with --json.');
273
+ const period = parseChoice(args.period, { name: '--period', choices: PULL_PERIODS, defaultValue: '28d' });
274
+ if (period._tag === 'Err')
275
+ return period;
276
+ const concurrency = parseInteger(args.concurrency, {
277
+ name: '--concurrency',
278
+ minimum: 1,
279
+ maximum: PULL_MAX_CONCURRENCY,
280
+ defaultValue: PULL_DEFAULT_CONCURRENCY,
281
+ });
282
+ if (concurrency._tag === 'Err')
283
+ return concurrency;
284
+ const selected = selectPullOperations({
285
+ include: args.include,
286
+ exclude: args.exclude,
287
+ withResearch: args.withResearch,
288
+ }, catalogue);
289
+ if (selected._tag === 'Err')
290
+ return selected;
291
+ const resolved = await resolve(runtime, globals);
292
+ if (resolved._tag === 'Err')
293
+ return resolved;
294
+ const operations = selected.value;
295
+ const notice = spendNotice(operations);
296
+ if (notice)
297
+ writeDiagnostic(runtime, notice);
298
+ writeDiagnostic(runtime, `Pulling ${operations.length} reads for ${resolved.value.siteId}, ${concurrency.value} at a time.`);
299
+ // Every read gets its own deadline. The global `--timeout-ms` signal starts
300
+ // when the process starts, so sharing it would abort the later reads of a
301
+ // long run before they were even sent.
302
+ const timeoutMs = runtime.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS;
303
+ const exitCodes = [];
304
+ const skipped = [];
305
+ let stopped = false;
306
+ let completed = 0;
307
+ await forEachBounded(operations, concurrency.value, async (candidate) => {
308
+ if (stopped) {
309
+ skipped.push(candidate.name);
310
+ return;
311
+ }
312
+ const signal = AbortSignal.any([runtime.signal, AbortSignal.timeout(timeoutMs)]);
313
+ const result = await candidate.request({
314
+ client: resolved.value.api.client,
315
+ siteId: resolved.value.siteId,
316
+ period: period.value,
317
+ signal,
318
+ });
319
+ const outcome = pullLine(candidate.name, result);
320
+ writeProtocolResponse(runtime, outcome.line);
321
+ exitCodes.push(outcome.exitCode);
322
+ completed++;
323
+ // An expired or rejected credential fails every later read the same way.
324
+ // Stopping there saves the requests and keeps the reason on one line.
325
+ if (outcome.exitCode === EXIT_CODE.authentication || outcome.exitCode === EXIT_CODE.interrupted)
326
+ stopped = true;
327
+ writeDiagnostic(runtime, `[${completed}/${operations.length}] ${candidate.name} ${outcome.failure ? `failed: ${outcome.failure.code}` : 'ok'}`);
328
+ });
329
+ const failed = exitCodes.filter(code => code !== EXIT_CODE.success).length;
330
+ writeDiagnostic(runtime, [
331
+ `Pull finished: ${exitCodes.length - failed} ok, ${failed} failed`,
332
+ skipped.length > 0 ? `, ${skipped.length} skipped after an authentication failure` : '',
333
+ '.',
334
+ ].join(''));
335
+ if (skipped.length > 0)
336
+ writeDiagnostic(runtime, `Skipped: ${skipped.join(', ')}.`);
337
+ const worst = worstExitCode(exitCodes);
338
+ if (worst === EXIT_CODE.success)
339
+ return ok(undefined);
340
+ const message = `pull_incomplete: ${failed} of ${operations.length} reads failed. Read each failed line for the cause.`;
341
+ // The run summary is the last NDJSON line, so stdout stays one value per
342
+ // line and the caller never has to read stderr to learn the outcome.
343
+ return {
344
+ _tag: 'Err',
345
+ error: {
346
+ _tag: 'CliFailure',
347
+ code: 'pull_incomplete',
348
+ exitCode: worst,
349
+ message,
350
+ protocolResponse: {
351
+ command: 'pull',
352
+ _tag: 'CliError',
353
+ schemaVersion: 1,
354
+ error: { code: 'pull_incomplete', exitCode: worst, message },
355
+ },
356
+ },
357
+ };
358
+ }
package/dist/render.d.ts CHANGED
@@ -3,10 +3,9 @@ import type { AccountTokenData } from '@nuxtseo/protocol/v1/account';
3
3
  import type { ActionDismiss, ActionList, ActionResolve, ActionShow } from '@nuxtseo/protocol/v1/actions';
4
4
  import type { AnalyticsQueryData, AnalyticsView } from '@nuxtseo/protocol/v1/analytics';
5
5
  import type { AuditChanges, AuditContentDecay, AuditDuplicateClusters, AuditLinkOpportunities, AuditLinkStructure } from '@nuxtseo/protocol/v1/audit';
6
- import type { BacklinkAnchors, BacklinksHistory, BacklinksSummary, Mentions, RecoverableBacklinks, ReferringDomains } from '@nuxtseo/protocol/v1/backlinks';
7
- import type { ContentBrief, ContentBriefCreateData, ContentBriefListData } from '@nuxtseo/protocol/v1/content';
6
+ import type { BacklinkAnchors, BacklinksHistory, BacklinksSummary, MentionsData, RecoverableBacklinksData, ReferringDomains } from '@nuxtseo/protocol/v1/backlinks';
8
7
  import type { IndexCohorts, IndexingDiagnosticsData, IndexingHistoryData, SearchAnalyticsData, SearchStatusData, UrlInspectionData } from '@nuxtseo/protocol/v1/gsc';
9
- import type { PageInspect, PageIssues, PageScan } from '@nuxtseo/protocol/v1/pages';
8
+ import type { PageInspectData, PageIssues, PageScan } from '@nuxtseo/protocol/v1/pages';
10
9
  import type { FieldVitalFindings, FieldVitals, MonitoredPages, PerformanceScanDetail, PerformanceScans, SitePerformanceOverview } from '@nuxtseo/protocol/v1/performance';
11
10
  import type { DomainAvailability, DomainTraffic, KeywordResearchData, RankingsResearchData, SerpResearchData, StoredResearchOverview } from '@nuxtseo/protocol/v1/research';
12
11
  import type { SiteSitemapActionResponse, SiteSitemapsResponse, SiteSitemapUrlsResponse } from '@nuxtseo/protocol/v1/sitemaps';
@@ -16,7 +15,7 @@ export declare function renderUsage(response: AccountUsageResponse): string;
16
15
  export declare function renderActions(data: ActionList): string;
17
16
  export declare function renderAction(data: ActionShow): string;
18
17
  export declare function renderActionResolution(data: ActionResolve): string;
19
- export declare function renderPageInspection(data: PageInspect): string;
18
+ export declare function renderPageInspection(data: PageInspectData): string;
20
19
  export declare function renderPageScan(data: PageScan): string;
21
20
  export declare function renderPerformance(data: SitePerformanceOverview): string;
22
21
  export declare function renderSearchStatus(data: SearchStatusData): string;
@@ -39,15 +38,12 @@ export declare function renderResearchOverview(data: StoredResearchOverview): st
39
38
  export declare function renderKeywordResearch(data: KeywordResearchData): string;
40
39
  export declare function renderSerpResearch(data: SerpResearchData): string;
41
40
  export declare function renderRankingsResearch(data: RankingsResearchData): string;
42
- export declare function renderContentBriefs(data: ContentBriefListData): string;
43
- export declare function renderContentBrief(data: ContentBrief): string;
44
- export declare function renderContentBriefCreated(data: ContentBriefCreateData): string;
45
41
  export declare function renderContentDecay(data: AuditContentDecay): string;
46
42
  export declare function renderDuplicateClusters(data: AuditDuplicateClusters): string;
47
43
  export declare function renderLinkOpportunities(data: AuditLinkOpportunities): string;
48
44
  export declare function renderTimeline(data: TimelineEntries): string;
49
- export declare function renderRecoverableBacklinks(data: RecoverableBacklinks): string;
50
- export declare function renderMentions(data: Mentions): string;
45
+ export declare function renderRecoverableBacklinks(data: RecoverableBacklinksData): string;
46
+ export declare function renderMentions(data: MentionsData): string;
51
47
  export declare function renderFieldVitals(data: FieldVitals): string;
52
48
  export declare function renderFieldVitalFindings(data: FieldVitalFindings): string;
53
49
  export declare function renderBacklinksSummary(data: BacklinksSummary): string;
package/dist/render.js CHANGED
@@ -190,7 +190,7 @@ export function renderSearchAnalytics(data) {
190
190
  return [
191
191
  `${data.preset} (${data.rows.length} of ${data.total}) ${data.description}`,
192
192
  ...data.rows.map(row => [
193
- `${row.clicks} clicks ${row.impressions} impressions position ${row.pos.toFixed(1)}`,
193
+ `${row.clicks} clicks ${row.impressions} impressions position ${row.pos === null ? 'none' : row.pos.toFixed(1)}`,
194
194
  ` ${row.keyword}`,
195
195
  row.page ? ` ${row.page}` : '',
196
196
  ].filter(Boolean).join('\n')),
@@ -482,27 +482,6 @@ export function renderRankingsResearch(data) {
482
482
  ...data.keywords.map(row => `${row.position}. ${row.keyword} ${row.volume} volume ${row.traffic} traffic\n ${row.url}`),
483
483
  ].join('\n');
484
484
  }
485
- export function renderContentBriefs(data) {
486
- if (data.briefs.length === 0)
487
- return 'No Content Briefs match these filters.';
488
- return [
489
- `Content Briefs (${data.briefs.length} of ${data.page.total})`,
490
- ...data.briefs.map(brief => `${brief.id} ${brief.status}\n ${brief.keyword}`),
491
- ].join('\n');
492
- }
493
- export function renderContentBrief(data) {
494
- return fields([
495
- ['Content Brief', data.id],
496
- ['Keyword', data.keyword],
497
- ['Status', data.status],
498
- ['Generated', data.generatedAt],
499
- ['Updated', data.updatedAt],
500
- ['Error', data.error],
501
- ]);
502
- }
503
- export function renderContentBriefCreated(data) {
504
- return `${data.created ? 'Created' : 'Found existing'} Content Brief.\n${renderContentBrief(data.brief)}`;
505
- }
506
485
  export function renderContentDecay(data) {
507
486
  if (!data.connected)
508
487
  return 'Connect Search Console to find content decay.';