@nuxtseo/cli 0.2.0 → 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 +8 -0
- package/dist/ansi.js +35 -0
- package/dist/cli.js +23 -14
- package/dist/commands.d.ts +7 -0
- package/dist/commands.js +133 -11
- package/dist/failures.d.ts +46 -6
- package/dist/failures.js +101 -10
- package/dist/pairing.js +2 -4
- package/dist/pull.d.ts +81 -0
- package/dist/pull.js +362 -0
- package/dist/render.d.ts +5 -5
- package/dist/render.js +1 -1
- package/dist/runtime.d.ts +5 -0
- package/dist/runtime.js +7 -2
- package/dist/skill.d.ts +99 -0
- package/dist/skill.js +372 -15
- package/dist/update-check.d.ts +5 -0
- package/dist/update-check.js +18 -1
- package/package.json +6 -6
- package/skills/nuxtseo-cli/SKILL.md +94 -1
- package/skills/nuxtseo-cli/references/commands.md +104 -12
- package/skills/nuxtseo-cli/references/protocol.md +72 -1
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
|
}
|
|
@@ -126,7 +130,7 @@ async function switchSite(runtime, globals) {
|
|
|
126
130
|
output: runtime.error,
|
|
127
131
|
signal: runtime.signal,
|
|
128
132
|
});
|
|
129
|
-
if (
|
|
133
|
+
if (typeof selected === 'symbol')
|
|
130
134
|
return fail(EXIT_CODE.interrupted, 'interrupted: Site selection cancelled.');
|
|
131
135
|
const saved = await updateConfig({ siteId: selected }, { paths: runtime.paths });
|
|
132
136
|
if (saved._tag === 'Err')
|
|
@@ -143,7 +147,7 @@ async function promptPageUrl(runtime, action) {
|
|
|
143
147
|
signal: runtime.signal,
|
|
144
148
|
validate: value => typeof value === 'string' && URL.canParse(value) ? undefined : 'Enter an absolute URL.',
|
|
145
149
|
});
|
|
146
|
-
return
|
|
150
|
+
return typeof url === 'symbol'
|
|
147
151
|
? fail(EXIT_CODE.interrupted, `interrupted: ${action} cancelled.`)
|
|
148
152
|
: ok(url);
|
|
149
153
|
}
|
|
@@ -158,7 +162,7 @@ async function promptTextValue(runtime, options) {
|
|
|
158
162
|
? undefined
|
|
159
163
|
: `${options.label} is required.`,
|
|
160
164
|
});
|
|
161
|
-
return
|
|
165
|
+
return typeof value === 'symbol'
|
|
162
166
|
? fail(EXIT_CODE.interrupted, `interrupted: ${options.label} entry cancelled.`)
|
|
163
167
|
: ok(value.trim());
|
|
164
168
|
}
|
|
@@ -219,7 +223,7 @@ async function interactiveBare(runtime, globals) {
|
|
|
219
223
|
output: runtime.error,
|
|
220
224
|
signal: runtime.signal,
|
|
221
225
|
});
|
|
222
|
-
if (
|
|
226
|
+
if (typeof group === 'symbol' || group === 'exit')
|
|
223
227
|
return ok(undefined);
|
|
224
228
|
if (group === 'login')
|
|
225
229
|
return runInteractiveCommand(['login'], runtime, globals);
|
|
@@ -239,7 +243,7 @@ async function interactiveBare(runtime, globals) {
|
|
|
239
243
|
output: runtime.error,
|
|
240
244
|
signal: runtime.signal,
|
|
241
245
|
});
|
|
242
|
-
if (
|
|
246
|
+
if (typeof task === 'symbol')
|
|
243
247
|
return ok(undefined);
|
|
244
248
|
if (task === 'inspect' || task === 'scan') {
|
|
245
249
|
const url = await promptPageUrl(runtime, task === 'inspect' ? 'Inspect' : 'Scan');
|
|
@@ -278,7 +282,7 @@ async function interactiveBare(runtime, globals) {
|
|
|
278
282
|
output: runtime.error,
|
|
279
283
|
signal: runtime.signal,
|
|
280
284
|
});
|
|
281
|
-
if (
|
|
285
|
+
if (typeof task === 'symbol')
|
|
282
286
|
return ok(undefined);
|
|
283
287
|
const command = task === 'performance'
|
|
284
288
|
? ['performance']
|
|
@@ -318,7 +322,7 @@ async function interactiveBare(runtime, globals) {
|
|
|
318
322
|
output: runtime.error,
|
|
319
323
|
signal: runtime.signal,
|
|
320
324
|
});
|
|
321
|
-
if (
|
|
325
|
+
if (typeof task === 'symbol')
|
|
322
326
|
return ok(undefined);
|
|
323
327
|
if (task === 'overview')
|
|
324
328
|
return runInteractiveCommand(['research', 'overview'], runtime, globals);
|
|
@@ -362,7 +366,7 @@ async function interactiveBare(runtime, globals) {
|
|
|
362
366
|
output: runtime.error,
|
|
363
367
|
signal: runtime.signal,
|
|
364
368
|
});
|
|
365
|
-
if (
|
|
369
|
+
if (typeof task === 'symbol')
|
|
366
370
|
return ok(undefined);
|
|
367
371
|
if (task === 'briefs')
|
|
368
372
|
return runInteractiveCommand(['content', 'briefs', 'list'], runtime, globals);
|
|
@@ -394,7 +398,7 @@ async function interactiveBare(runtime, globals) {
|
|
|
394
398
|
output: runtime.error,
|
|
395
399
|
signal: runtime.signal,
|
|
396
400
|
});
|
|
397
|
-
if (
|
|
401
|
+
if (typeof task === 'symbol')
|
|
398
402
|
return ok(undefined);
|
|
399
403
|
if (task === 'site')
|
|
400
404
|
return switchSite(runtime, globals);
|
|
@@ -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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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')) {
|
package/dist/commands.d.ts
CHANGED
|
@@ -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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as prompts from '@clack/prompts';
|
|
2
|
+
import { accountFeedbackBodySchema } from '@nuxtseo/protocol/v1/account';
|
|
2
3
|
import { publicV1BooleanFlagValue } from '@nuxtseo/protocol/v1/core';
|
|
3
4
|
import { defineCommand } from 'citty';
|
|
4
5
|
import { dirname } from 'pathe';
|
|
@@ -7,17 +8,37 @@ import { openBrowser } from './browser.js';
|
|
|
7
8
|
import { EXIT_CODE, fail, fromSdkFailure, ok } from './failures.js';
|
|
8
9
|
import { awaitPairingApproval, createCliPairingClient, startPairing } from './pairing.js';
|
|
9
10
|
import { parseAbsolutePageUrl, parseChoice, parseInteger, parsePageUrlOrPath, parseRatio } from './parse.js';
|
|
11
|
+
import { PULL_PERIODS, runPull } from './pull.js';
|
|
10
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';
|
|
11
13
|
import { writeCliResponse, writeDiagnostic, writeOutput, writeProtocolResponse } from './runtime.js';
|
|
12
14
|
import { resolveSite } from './site.js';
|
|
13
15
|
import { installSkill, SKILL_AGENTS } from './skill.js';
|
|
14
16
|
import { clearCredential, getCredentialStatus, readConfig, resolveApiUrl, saveCredential, updateConfig, } from './state/index.js';
|
|
15
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
|
+
}
|
|
16
34
|
function emit(runtime, globals, value, render) {
|
|
17
|
-
if (globals.json)
|
|
35
|
+
if (globals.json) {
|
|
18
36
|
writeProtocolResponse(runtime, value);
|
|
19
|
-
|
|
37
|
+
reportDataNote(runtime, value);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
20
40
|
writeOutput(runtime, render(value));
|
|
41
|
+
}
|
|
21
42
|
}
|
|
22
43
|
function present(runtime, globals, result, render) {
|
|
23
44
|
if (result._tag === 'Err')
|
|
@@ -97,7 +118,7 @@ async function confirmMutation(runtime, globals, message) {
|
|
|
97
118
|
signal: runtime.signal,
|
|
98
119
|
initialValue: false,
|
|
99
120
|
});
|
|
100
|
-
if (
|
|
121
|
+
if (typeof confirmed === 'symbol')
|
|
101
122
|
return fail(EXIT_CODE.interrupted, 'interrupted: Mutation cancelled.');
|
|
102
123
|
return ok(confirmed);
|
|
103
124
|
}
|
|
@@ -111,7 +132,7 @@ async function readLoginToken(runtime) {
|
|
|
111
132
|
signal: runtime.signal,
|
|
112
133
|
validate: value => typeof value === 'string' && value.trim() ? undefined : 'Token is required.',
|
|
113
134
|
});
|
|
114
|
-
if (
|
|
135
|
+
if (typeof token === 'symbol')
|
|
115
136
|
return fail(EXIT_CODE.interrupted, 'interrupted: Login cancelled.');
|
|
116
137
|
return ok(token.trim());
|
|
117
138
|
}
|
|
@@ -277,7 +298,7 @@ async function config(runtime, globals) {
|
|
|
277
298
|
output: runtime.error,
|
|
278
299
|
signal: runtime.signal,
|
|
279
300
|
});
|
|
280
|
-
if (
|
|
301
|
+
if (typeof action === 'symbol' || action === 'done')
|
|
281
302
|
return ok(undefined);
|
|
282
303
|
if (action === 'api') {
|
|
283
304
|
const next = await prompts.text({
|
|
@@ -287,7 +308,7 @@ async function config(runtime, globals) {
|
|
|
287
308
|
output: runtime.error,
|
|
288
309
|
signal: runtime.signal,
|
|
289
310
|
});
|
|
290
|
-
if (
|
|
311
|
+
if (typeof next === 'symbol')
|
|
291
312
|
return fail(EXIT_CODE.interrupted, 'interrupted: Configuration cancelled.');
|
|
292
313
|
const written = await updateConfig({ apiUrl: next }, { paths: runtime.paths });
|
|
293
314
|
if (written._tag === 'Err')
|
|
@@ -345,6 +366,25 @@ async function usage(runtime, globals, group) {
|
|
|
345
366
|
const response = await withSpinner(runtime, 'Loading usage', () => api.value.client.account.usage({ query: { group } }, { signal: runtime.requestSignal }));
|
|
346
367
|
return present(runtime, globals, response, renderUsage);
|
|
347
368
|
}
|
|
369
|
+
async function feedbackSubmit(runtime, globals, args) {
|
|
370
|
+
const body = accountFeedbackBodySchema.safeParse({ ...args, cliVersion: VERSION });
|
|
371
|
+
if (!body.success)
|
|
372
|
+
return fail(EXIT_CODE.invalidInput, 'Pass valid --command, --comment, and --agent values. Read feedback submit --help for limits.', undefined, 'invalid_cli_input');
|
|
373
|
+
const confirmed = await confirmMutation(runtime, globals, 'Submit this agent report to NuxtSEO feedback?');
|
|
374
|
+
if (confirmed._tag === 'Err')
|
|
375
|
+
return confirmed;
|
|
376
|
+
if (!confirmed.value) {
|
|
377
|
+
writeDiagnostic(runtime, 'Feedback cancelled.');
|
|
378
|
+
return ok(undefined);
|
|
379
|
+
}
|
|
380
|
+
const api = await loadApiContext(runtime, globals);
|
|
381
|
+
if (api._tag === 'Err')
|
|
382
|
+
return api;
|
|
383
|
+
const response = await withSpinner(runtime, 'Submitting feedback', () => api.value.client.account.feedback({
|
|
384
|
+
body: body.data,
|
|
385
|
+
}, { signal: runtime.requestSignal }));
|
|
386
|
+
return present(runtime, globals, response, value => `Feedback saved: ${value.data.id} (${value.data.status})`);
|
|
387
|
+
}
|
|
348
388
|
async function actionsList(runtime, globals, args) {
|
|
349
389
|
const limit = parseInteger(args.limit, { name: '--limit', minimum: 1, maximum: 25, defaultValue: 10 });
|
|
350
390
|
if (limit._tag === 'Err')
|
|
@@ -698,7 +738,37 @@ async function researchOverview(runtime, globals) {
|
|
|
698
738
|
}, { signal: runtime.requestSignal }));
|
|
699
739
|
return present(runtime, globals, response, value => renderResearchOverview(value.data));
|
|
700
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
|
+
}
|
|
701
768
|
async function researchKeywordIdeas(runtime, globals, args) {
|
|
769
|
+
const topic = parseResearchTopic(args.topic);
|
|
770
|
+
if (topic._tag === 'Err')
|
|
771
|
+
return topic;
|
|
702
772
|
const minVolume = parseInteger(args.minVolume, { name: '--min-volume', minimum: 0, defaultValue: 10 });
|
|
703
773
|
if (minVolume._tag === 'Err')
|
|
704
774
|
return minVolume;
|
|
@@ -727,7 +797,7 @@ async function researchKeywordIdeas(runtime, globals, args) {
|
|
|
727
797
|
const response = await withSpinner(runtime, 'Researching keyword ideas', () => resolved.value.api.client.research.keywords({
|
|
728
798
|
params: { siteId: resolved.value.siteId },
|
|
729
799
|
query: {
|
|
730
|
-
topic:
|
|
800
|
+
topic: topic.value,
|
|
731
801
|
minVolume: minVolume.value,
|
|
732
802
|
maxVolume: maxVolume.value,
|
|
733
803
|
minDifficulty: minDifficulty.value,
|
|
@@ -934,6 +1004,10 @@ async function analyticsQuery(runtime, globals, args) {
|
|
|
934
1004
|
}, { signal: runtime.requestSignal }));
|
|
935
1005
|
return present(runtime, globals, response, value => renderAnalytics(view.value, value.data));
|
|
936
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
|
+
}
|
|
937
1011
|
async function skillInstallCommand(runtime, globals, args) {
|
|
938
1012
|
const agent = parseChoice(args.agent, { name: '--agent', choices: SKILL_AGENTS, defaultValue: 'claude' });
|
|
939
1013
|
if (agent._tag === 'Err')
|
|
@@ -941,7 +1015,7 @@ async function skillInstallCommand(runtime, globals, args) {
|
|
|
941
1015
|
// The state directory is `<home>/.nuxtseo`, so its parent is the home the
|
|
942
1016
|
// agent directories sit beside.
|
|
943
1017
|
const homeDirectory = dirname(runtime.paths.directory);
|
|
944
|
-
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 });
|
|
945
1019
|
if (installed._tag === 'Err')
|
|
946
1020
|
return installed;
|
|
947
1021
|
if (globals.json) {
|
|
@@ -949,12 +1023,20 @@ async function skillInstallCommand(runtime, globals, args) {
|
|
|
949
1023
|
_tag: 'CliSkillInstall',
|
|
950
1024
|
schemaVersion: 1,
|
|
951
1025
|
agent: installed.value.agent,
|
|
1026
|
+
version: installed.value.version,
|
|
952
1027
|
source: installed.value.source,
|
|
953
1028
|
destination: installed.value.destination,
|
|
1029
|
+
resolvedDestination: installed.value.resolvedDestination,
|
|
1030
|
+
written: installed.value.written,
|
|
1031
|
+
pruned: installed.value.pruned,
|
|
954
1032
|
});
|
|
955
1033
|
}
|
|
956
1034
|
else {
|
|
957
|
-
writeOutput(runtime,
|
|
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'));
|
|
958
1040
|
}
|
|
959
1041
|
return ok(undefined);
|
|
960
1042
|
}
|
|
@@ -1877,8 +1959,8 @@ export function createRootCommand(runtime, globals, execution) {
|
|
|
1877
1959
|
'changes': defineCommand({
|
|
1878
1960
|
meta: { name: 'changes', description: 'Compare the latest crawl with its baseline' },
|
|
1879
1961
|
args: {
|
|
1880
|
-
from: { type: 'string', description: 'Baseline crawl
|
|
1881
|
-
to: { type: 'string', description: 'Comparison crawl
|
|
1962
|
+
from: { type: 'string', description: 'Baseline crawl number' },
|
|
1963
|
+
to: { type: 'string', description: 'Comparison crawl number' },
|
|
1882
1964
|
},
|
|
1883
1965
|
run: ({ args }) => capture(execution, () => auditChanges(runtime, globals, {
|
|
1884
1966
|
from: args.from,
|
|
@@ -2088,6 +2170,23 @@ export function createRootCommand(runtime, globals, execution) {
|
|
|
2088
2170
|
}),
|
|
2089
2171
|
},
|
|
2090
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
|
+
});
|
|
2091
2190
|
const timeline = defineCommand({
|
|
2092
2191
|
meta: { name: 'timeline', description: 'Read the Timeline — what changed on the Site' },
|
|
2093
2192
|
subCommands: {
|
|
@@ -2155,6 +2254,28 @@ export function createRootCommand(runtime, globals, execution) {
|
|
|
2155
2254
|
run: ({ args }) => capture(execution, () => config(runtime, globals), args._, 0)(),
|
|
2156
2255
|
}),
|
|
2157
2256
|
sites,
|
|
2257
|
+
feedback: defineCommand({
|
|
2258
|
+
meta: { name: 'feedback', description: 'Report CLI problems to NuxtSEO' },
|
|
2259
|
+
subCommands: {
|
|
2260
|
+
submit: defineCommand({
|
|
2261
|
+
meta: { name: 'submit', description: 'Submit sanitized agent feedback without selecting a Site' },
|
|
2262
|
+
args: {
|
|
2263
|
+
'command': { type: 'string', required: true, description: 'Affected command without secrets or private arguments, maximum 200 characters' },
|
|
2264
|
+
'comment': { type: 'string', required: true, description: 'Agent disclosure, reproduction steps, expected result, actual result, and workaround, maximum 2000 characters' },
|
|
2265
|
+
'agent': { type: 'string', required: true, description: 'Reporting agent name, maximum 100 characters' },
|
|
2266
|
+
'intent': { type: 'enum', options: ['bug', 'improvement'], description: 'Feedback intent, default bug' },
|
|
2267
|
+
'request-id': { type: 'string', description: 'Request ID from the affected response, maximum 128 characters' },
|
|
2268
|
+
},
|
|
2269
|
+
run: ({ args }) => capture(execution, () => feedbackSubmit(runtime, globals, {
|
|
2270
|
+
command: args.command,
|
|
2271
|
+
comment: args.comment,
|
|
2272
|
+
agent: args.agent,
|
|
2273
|
+
intent: args.intent,
|
|
2274
|
+
requestId: args['request-id'],
|
|
2275
|
+
}), args._, 0)(),
|
|
2276
|
+
}),
|
|
2277
|
+
},
|
|
2278
|
+
}),
|
|
2158
2279
|
usage: defineCommand({
|
|
2159
2280
|
meta: { name: 'usage', description: 'Read account usage' },
|
|
2160
2281
|
args: {
|
|
@@ -2177,6 +2298,7 @@ export function createRootCommand(runtime, globals, execution) {
|
|
|
2177
2298
|
run: ({ args }) => capture(execution, () => performance(runtime, globals), args._, 0)(),
|
|
2178
2299
|
}),
|
|
2179
2300
|
annotations,
|
|
2301
|
+
pull,
|
|
2180
2302
|
research,
|
|
2181
2303
|
scans,
|
|
2182
2304
|
search,
|
package/dist/failures.d.ts
CHANGED
|
@@ -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: '
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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, [
|
|
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'
|