@nuxtseo/cli 0.1.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/README.md +218 -0
- package/dist/api.d.ts +14 -0
- package/dist/api.js +43 -0
- package/dist/browser.d.ts +7 -0
- package/dist/browser.js +24 -0
- package/dist/cli-entry.d.ts +2 -0
- package/dist/cli-entry.js +41 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +232 -0
- package/dist/command-contract.d.ts +33 -0
- package/dist/command-contract.js +131 -0
- package/dist/commands.d.ts +8 -0
- package/dist/commands.js +626 -0
- package/dist/failures.d.ts +36 -0
- package/dist/failures.js +132 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/pairing.d.ts +13 -0
- package/dist/pairing.js +100 -0
- package/dist/parse.d.ts +23 -0
- package/dist/parse.js +126 -0
- package/dist/render.d.ts +17 -0
- package/dist/render.js +156 -0
- package/dist/runtime.d.ts +18 -0
- package/dist/runtime.js +12 -0
- package/dist/site.d.ts +5 -0
- package/dist/site.js +48 -0
- package/dist/state/auth.d.ts +45 -0
- package/dist/state/auth.js +232 -0
- package/dist/state/config.d.ts +39 -0
- package/dist/state/config.js +145 -0
- package/dist/state/files.d.ts +6 -0
- package/dist/state/files.js +125 -0
- package/dist/state/index.d.ts +4 -0
- package/dist/state/index.js +4 -0
- package/dist/state/paths.d.ts +10 -0
- package/dist/state/paths.js +16 -0
- package/dist/state/result.d.ts +67 -0
- package/dist/state/result.js +6 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +2 -0
- package/package.json +62 -0
- package/skills/nuxtseo-cli/SKILL.md +162 -0
- package/skills/nuxtseo-cli/references/protocol.md +64 -0
package/dist/failures.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
export const EXIT_CODE = {
|
|
2
|
+
success: 0,
|
|
3
|
+
invalidInput: 2,
|
|
4
|
+
authentication: 3,
|
|
5
|
+
authorization: 4,
|
|
6
|
+
conflict: 5,
|
|
7
|
+
retryable: 6,
|
|
8
|
+
infrastructure: 7,
|
|
9
|
+
notFound: 8,
|
|
10
|
+
interrupted: 130,
|
|
11
|
+
};
|
|
12
|
+
export function ok(value) {
|
|
13
|
+
return { _tag: 'Ok', value };
|
|
14
|
+
}
|
|
15
|
+
function defaultFailureCode(exitCode) {
|
|
16
|
+
switch (exitCode) {
|
|
17
|
+
case EXIT_CODE.invalidInput:
|
|
18
|
+
return 'invalid_cli_input';
|
|
19
|
+
case EXIT_CODE.authentication:
|
|
20
|
+
return 'authentication_required';
|
|
21
|
+
case EXIT_CODE.authorization:
|
|
22
|
+
return 'authorization_required';
|
|
23
|
+
case EXIT_CODE.conflict:
|
|
24
|
+
return 'conflict';
|
|
25
|
+
case EXIT_CODE.retryable:
|
|
26
|
+
return 'retryable_failure';
|
|
27
|
+
case EXIT_CODE.notFound:
|
|
28
|
+
return 'not_found';
|
|
29
|
+
case EXIT_CODE.interrupted:
|
|
30
|
+
return 'interrupted';
|
|
31
|
+
default:
|
|
32
|
+
return 'infrastructure_failure';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function fail(exitCode, message, cause, code = defaultFailureCode(exitCode)) {
|
|
36
|
+
return { _tag: 'Err', error: { _tag: 'CliFailure', code, exitCode, message, cause } };
|
|
37
|
+
}
|
|
38
|
+
function apiExitCode(code, retryable) {
|
|
39
|
+
switch (code) {
|
|
40
|
+
case 'invalid_request':
|
|
41
|
+
return EXIT_CODE.invalidInput;
|
|
42
|
+
case 'unauthorized':
|
|
43
|
+
case 'auth_expired':
|
|
44
|
+
return EXIT_CODE.authentication;
|
|
45
|
+
case 'forbidden':
|
|
46
|
+
case 'entitlement_required':
|
|
47
|
+
return EXIT_CODE.authorization;
|
|
48
|
+
case 'conflict':
|
|
49
|
+
case 'site_ambiguous':
|
|
50
|
+
case 'stale_evidence':
|
|
51
|
+
return EXIT_CODE.conflict;
|
|
52
|
+
case 'rate_limited':
|
|
53
|
+
case 'quota_exhausted':
|
|
54
|
+
case 'provider_unavailable':
|
|
55
|
+
return EXIT_CODE.retryable;
|
|
56
|
+
case 'not_found':
|
|
57
|
+
return EXIT_CODE.notFound;
|
|
58
|
+
case 'contract_violation':
|
|
59
|
+
case 'internal_error':
|
|
60
|
+
return EXIT_CODE.infrastructure;
|
|
61
|
+
default:
|
|
62
|
+
return retryable ? EXIT_CODE.retryable : EXIT_CODE.infrastructure;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function metadataLines(error) {
|
|
66
|
+
const metadata = error.metadata;
|
|
67
|
+
const rate = metadata.rateLimit;
|
|
68
|
+
const details = Object.keys(error.details).length > 0 ? JSON.stringify(error.details) : undefined;
|
|
69
|
+
return [
|
|
70
|
+
`Request ID: ${error.requestId}`,
|
|
71
|
+
metadata.retry ? `Retry after: ${metadata.retry.afterMilliseconds}ms` : undefined,
|
|
72
|
+
rate
|
|
73
|
+
? `Rate: ${rate.policy}, ${rate.remaining ?? '?'} remaining, limit ${rate.limit ?? '?'}, window ${rate.windowSeconds ?? '?'}s, resets in ${rate.resetAfterSeconds ?? '?'}s`
|
|
74
|
+
: undefined,
|
|
75
|
+
details ? `Details: ${details}` : undefined,
|
|
76
|
+
].filter((line) => line !== undefined);
|
|
77
|
+
}
|
|
78
|
+
export function fromSdkFailure(error) {
|
|
79
|
+
switch (error._tag) {
|
|
80
|
+
case 'RequestFailure':
|
|
81
|
+
return fail(EXIT_CODE.invalidInput, `${error.operationId}: invalid ${error.location}. ${error.issues.map(String).join('; ')}`);
|
|
82
|
+
case 'ApiFailure':
|
|
83
|
+
return {
|
|
84
|
+
_tag: 'Err',
|
|
85
|
+
error: {
|
|
86
|
+
_tag: 'CliFailure',
|
|
87
|
+
code: error.code,
|
|
88
|
+
exitCode: apiExitCode(error.code, error.retryable),
|
|
89
|
+
message: [
|
|
90
|
+
`${error.code}: ${error.message}`,
|
|
91
|
+
...metadataLines(error),
|
|
92
|
+
error.code === 'unauthorized' || error.code === 'auth_expired'
|
|
93
|
+
? 'Replace NUXTSEO_TOKEN, or run `nuxtseo login` when using a stored credential.'
|
|
94
|
+
: undefined,
|
|
95
|
+
].filter((line) => line !== undefined).join('\n'),
|
|
96
|
+
protocolResponse: error.response,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
case 'ContractFailure':
|
|
100
|
+
return fail(EXIT_CODE.infrastructure, [`contract_violation: ${error.message}`, error.requestId ? `Request ID: ${error.requestId}` : undefined]
|
|
101
|
+
.filter((line) => line !== undefined)
|
|
102
|
+
.join('\n'), undefined, 'contract_violation');
|
|
103
|
+
case 'TransportFailure': {
|
|
104
|
+
const timedOut = error.reason === 'aborted'
|
|
105
|
+
&& error.cause instanceof DOMException
|
|
106
|
+
&& error.cause.name === 'TimeoutError';
|
|
107
|
+
return fail(timedOut
|
|
108
|
+
? EXIT_CODE.retryable
|
|
109
|
+
: error.reason === 'credential'
|
|
110
|
+
? EXIT_CODE.authentication
|
|
111
|
+
: error.reason === 'aborted'
|
|
112
|
+
? EXIT_CODE.interrupted
|
|
113
|
+
: EXIT_CODE.infrastructure, timedOut ? `request_timeout: ${error.message}` : `${error.reason}: ${error.message}`, error.cause, timedOut
|
|
114
|
+
? 'request_timeout'
|
|
115
|
+
: error.reason === 'credential'
|
|
116
|
+
? 'authentication_required'
|
|
117
|
+
: error.reason === 'aborted'
|
|
118
|
+
? 'interrupted'
|
|
119
|
+
: 'transport_failure');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export function unexpectedFailure(cause) {
|
|
124
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
125
|
+
return {
|
|
126
|
+
_tag: 'CliFailure',
|
|
127
|
+
code: 'unexpected',
|
|
128
|
+
exitCode: EXIT_CODE.infrastructure,
|
|
129
|
+
message: `unexpected: ${message}`,
|
|
130
|
+
cause,
|
|
131
|
+
};
|
|
132
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { runCli } from './cli.js';
|
|
2
|
+
export { EXIT_CODE } from './failures.js';
|
|
3
|
+
export type { CliFailure, CliFailureCode, CliResult, ExitCode, LocalFailureCode } from './failures.js';
|
|
4
|
+
export type { CliRuntime } from './runtime.js';
|
|
5
|
+
export { VERSION } from './version.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CliPairingApproved, CliPairingStartResponse, PairingClient } from '@nuxtseo/sdk';
|
|
2
|
+
import type { CliResult } from './failures.js';
|
|
3
|
+
import type { CliRuntime } from './runtime.js';
|
|
4
|
+
export declare const CLIENT_NAME = "nuxtseo-cli";
|
|
5
|
+
export declare function createCliPairingClient(apiUrl: string): PairingClient;
|
|
6
|
+
export declare function startPairing(client: PairingClient, runtime: CliRuntime): Promise<CliResult<CliPairingStartResponse>>;
|
|
7
|
+
/**
|
|
8
|
+
* Poll until the user decides, the pairing expires, or the user interrupts.
|
|
9
|
+
*
|
|
10
|
+
* The loop owns the pairing deadline (ten minutes, from the server), so a login
|
|
11
|
+
* is never cut short by the per-request `--timeout-ms`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function awaitPairingApproval(client: PairingClient, runtime: CliRuntime, pairing: CliPairingStartResponse): Promise<CliResult<CliPairingApproved>>;
|
package/dist/pairing.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { hostname } from 'node:os';
|
|
2
|
+
import { CLI_PAIRING_POLL_INTERVAL_MS, createPairingClient } from '@nuxtseo/sdk';
|
|
3
|
+
import { EXIT_CODE, fail, ok } from './failures.js';
|
|
4
|
+
import { VERSION } from './version.js';
|
|
5
|
+
/**
|
|
6
|
+
* Per-poll request deadline.
|
|
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.
|
|
12
|
+
*/
|
|
13
|
+
const POLL_REQUEST_TIMEOUT_MS = 8_000;
|
|
14
|
+
export const CLIENT_NAME = 'nuxtseo-cli';
|
|
15
|
+
export function createCliPairingClient(apiUrl) {
|
|
16
|
+
return createPairingClient({ baseUrl: apiUrl });
|
|
17
|
+
}
|
|
18
|
+
function describeFailure(error) {
|
|
19
|
+
if (error.reason === 'aborted')
|
|
20
|
+
return fail(EXIT_CODE.interrupted, 'interrupted: Login cancelled.');
|
|
21
|
+
if (error.reason === 'contract')
|
|
22
|
+
return fail(EXIT_CODE.infrastructure, `contract_violation: ${error.message}`, undefined, 'contract_violation');
|
|
23
|
+
if (error.reason === 'status' && error.status === 429)
|
|
24
|
+
return fail(EXIT_CODE.retryable, `rate_limited: ${error.message}`, undefined, 'retryable_failure');
|
|
25
|
+
return fail(EXIT_CODE.infrastructure, `transport_failure: ${error.message}`, undefined, 'transport_failure');
|
|
26
|
+
}
|
|
27
|
+
export async function startPairing(client, runtime) {
|
|
28
|
+
const started = await client.start({
|
|
29
|
+
clientName: CLIENT_NAME,
|
|
30
|
+
clientVersion: VERSION,
|
|
31
|
+
hostname: safeHostname(),
|
|
32
|
+
}, { signal: runtime.requestSignal });
|
|
33
|
+
return started._tag === 'Err' ? describeFailure(started.error) : ok(started.value);
|
|
34
|
+
}
|
|
35
|
+
function safeHostname() {
|
|
36
|
+
try {
|
|
37
|
+
const name = hostname().trim();
|
|
38
|
+
return name ? name.slice(0, 120) : undefined;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function sleep(milliseconds, signal) {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
const timer = setTimeout(finish, milliseconds);
|
|
47
|
+
signal.addEventListener('abort', finish, { once: true });
|
|
48
|
+
function finish() {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
signal.removeEventListener('abort', finish);
|
|
51
|
+
resolve();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Poll until the user decides, the pairing expires, or the user interrupts.
|
|
57
|
+
*
|
|
58
|
+
* The loop owns the pairing deadline (ten minutes, from the server), so a login
|
|
59
|
+
* is never cut short by the per-request `--timeout-ms`.
|
|
60
|
+
*/
|
|
61
|
+
export async function awaitPairingApproval(client, runtime, pairing) {
|
|
62
|
+
const deadline = Date.parse(pairing.expiresAt);
|
|
63
|
+
let waitMs = pairing.pollIntervalMs || CLI_PAIRING_POLL_INTERVAL_MS;
|
|
64
|
+
while (!runtime.signal.aborted && Date.now() < deadline) {
|
|
65
|
+
await sleep(waitMs, runtime.signal);
|
|
66
|
+
if (runtime.signal.aborted)
|
|
67
|
+
break;
|
|
68
|
+
const polled = await client.poll({ pairingId: pairing.pairingId, pollSecret: pairing.pollSecret }, { signal: AbortSignal.any([runtime.signal, AbortSignal.timeout(POLL_REQUEST_TIMEOUT_MS)]) });
|
|
69
|
+
if (polled._tag === 'Err') {
|
|
70
|
+
// A single slow or dropped poll is not a failed login. Keep waiting until
|
|
71
|
+
// the pairing deadline, and only surface a failure that cannot resolve.
|
|
72
|
+
if (polled.error.reason === 'network' || polled.error.reason === 'aborted') {
|
|
73
|
+
if (runtime.signal.aborted)
|
|
74
|
+
break;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
return describeFailure(polled.error);
|
|
78
|
+
}
|
|
79
|
+
const result = polled.value;
|
|
80
|
+
switch (result.status) {
|
|
81
|
+
case 'approved':
|
|
82
|
+
return ok(result);
|
|
83
|
+
case 'denied':
|
|
84
|
+
return fail(EXIT_CODE.authentication, 'authentication_required: The pairing request was denied in the browser.');
|
|
85
|
+
case 'claimed':
|
|
86
|
+
return fail(EXIT_CODE.conflict, 'conflict: This pairing request was already used. Run `nuxtseo login` again.');
|
|
87
|
+
case 'expired':
|
|
88
|
+
return fail(EXIT_CODE.retryable, 'request_timeout: The pairing request expired. Run `nuxtseo login` again.', undefined, 'request_timeout');
|
|
89
|
+
case 'slow_down':
|
|
90
|
+
waitMs = result.retryAfterMs;
|
|
91
|
+
continue;
|
|
92
|
+
case 'pending':
|
|
93
|
+
waitMs = pairing.pollIntervalMs || CLI_PAIRING_POLL_INTERVAL_MS;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (runtime.signal.aborted)
|
|
98
|
+
return fail(EXIT_CODE.interrupted, 'interrupted: Login cancelled.');
|
|
99
|
+
return fail(EXIT_CODE.retryable, 'request_timeout: The pairing request expired before it was approved.', undefined, 'request_timeout');
|
|
100
|
+
}
|
package/dist/parse.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CliResult } from './failures.js';
|
|
2
|
+
export interface GlobalOptions {
|
|
3
|
+
apiUrl?: string;
|
|
4
|
+
siteId?: string;
|
|
5
|
+
json: boolean;
|
|
6
|
+
noInput: boolean;
|
|
7
|
+
timeoutMs: number;
|
|
8
|
+
yes: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface ParsedGlobalOptions {
|
|
11
|
+
args: string[];
|
|
12
|
+
options: GlobalOptions;
|
|
13
|
+
}
|
|
14
|
+
export declare const DEFAULT_TIMEOUT_MS = 30000;
|
|
15
|
+
export declare function extractGlobalOptions(rawArgs: readonly string[]): CliResult<ParsedGlobalOptions>;
|
|
16
|
+
export declare function parseHttpUrl(input: string, label: string): CliResult<string>;
|
|
17
|
+
export declare function parseAbsolutePageUrl(input: string): CliResult<string>;
|
|
18
|
+
export declare function parseInteger(input: string | undefined, options: {
|
|
19
|
+
name: string;
|
|
20
|
+
minimum: number;
|
|
21
|
+
maximum?: number;
|
|
22
|
+
defaultValue: number;
|
|
23
|
+
}): CliResult<number>;
|
package/dist/parse.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { EXIT_CODE, fail, ok } from './failures.js';
|
|
2
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
3
|
+
function optionValue(args, index, name) {
|
|
4
|
+
const argument = args[index];
|
|
5
|
+
const inline = argument.startsWith(`${name}=`) ? argument.slice(name.length + 1) : undefined;
|
|
6
|
+
const value = inline ?? args[index + 1];
|
|
7
|
+
if (!value || value.startsWith('-'))
|
|
8
|
+
return fail(EXIT_CODE.invalidInput, `${name} requires a value.`);
|
|
9
|
+
return ok({ consumed: inline === undefined ? 2 : 1, value });
|
|
10
|
+
}
|
|
11
|
+
export function extractGlobalOptions(rawArgs) {
|
|
12
|
+
const options = {
|
|
13
|
+
json: false,
|
|
14
|
+
noInput: false,
|
|
15
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
16
|
+
yes: false,
|
|
17
|
+
};
|
|
18
|
+
const args = [];
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
for (let index = 0; index < rawArgs.length;) {
|
|
21
|
+
const argument = rawArgs[index];
|
|
22
|
+
if (argument === '--json') {
|
|
23
|
+
options.json = true;
|
|
24
|
+
index++;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (argument.startsWith('--json='))
|
|
28
|
+
return fail(EXIT_CODE.invalidInput, '--json does not accept a value.');
|
|
29
|
+
if (argument === '--no-input') {
|
|
30
|
+
options.noInput = true;
|
|
31
|
+
index++;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (argument.startsWith('--no-input='))
|
|
35
|
+
return fail(EXIT_CODE.invalidInput, '--no-input does not accept a value.');
|
|
36
|
+
if (argument === '--yes' || argument === '-y') {
|
|
37
|
+
options.yes = true;
|
|
38
|
+
index++;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (argument.startsWith('--yes=') || argument.startsWith('-y='))
|
|
42
|
+
return fail(EXIT_CODE.invalidInput, '--yes does not accept a value.');
|
|
43
|
+
if (argument === '--api-url' || argument.startsWith('--api-url=')) {
|
|
44
|
+
if (seen.has('apiUrl'))
|
|
45
|
+
return fail(EXIT_CODE.invalidInput, '--api-url may be provided once.');
|
|
46
|
+
const parsed = optionValue(rawArgs, index, '--api-url');
|
|
47
|
+
if (parsed._tag === 'Err')
|
|
48
|
+
return parsed;
|
|
49
|
+
const url = parseHttpUrl(parsed.value.value, 'API URL');
|
|
50
|
+
if (url._tag === 'Err')
|
|
51
|
+
return url;
|
|
52
|
+
options.apiUrl = url.value;
|
|
53
|
+
seen.add('apiUrl');
|
|
54
|
+
index += parsed.value.consumed;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (argument === '--site' || argument.startsWith('--site=')) {
|
|
58
|
+
if (seen.has('siteId'))
|
|
59
|
+
return fail(EXIT_CODE.invalidInput, '--site may be provided once.');
|
|
60
|
+
const parsed = optionValue(rawArgs, index, '--site');
|
|
61
|
+
if (parsed._tag === 'Err')
|
|
62
|
+
return parsed;
|
|
63
|
+
const siteId = parsed.value.value.trim();
|
|
64
|
+
if (!siteId)
|
|
65
|
+
return fail(EXIT_CODE.invalidInput, '--site cannot be empty.');
|
|
66
|
+
options.siteId = siteId;
|
|
67
|
+
seen.add('siteId');
|
|
68
|
+
index += parsed.value.consumed;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (argument === '--timeout-ms' || argument.startsWith('--timeout-ms=')) {
|
|
72
|
+
if (seen.has('timeoutMs'))
|
|
73
|
+
return fail(EXIT_CODE.invalidInput, '--timeout-ms may be provided once.');
|
|
74
|
+
const value = optionValue(rawArgs, index, '--timeout-ms');
|
|
75
|
+
if (value._tag === 'Err')
|
|
76
|
+
return value;
|
|
77
|
+
const timeout = parseInteger(value.value.value, {
|
|
78
|
+
name: '--timeout-ms',
|
|
79
|
+
minimum: 1,
|
|
80
|
+
maximum: 300_000,
|
|
81
|
+
defaultValue: DEFAULT_TIMEOUT_MS,
|
|
82
|
+
});
|
|
83
|
+
if (timeout._tag === 'Err')
|
|
84
|
+
return timeout;
|
|
85
|
+
options.timeoutMs = timeout.value;
|
|
86
|
+
seen.add('timeoutMs');
|
|
87
|
+
index += value.value.consumed;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
args.push(argument);
|
|
91
|
+
index++;
|
|
92
|
+
}
|
|
93
|
+
return ok({ args, options });
|
|
94
|
+
}
|
|
95
|
+
export function parseHttpUrl(input, label) {
|
|
96
|
+
const trimmed = input.trim();
|
|
97
|
+
if (!URL.canParse(trimmed))
|
|
98
|
+
return fail(EXIT_CODE.invalidInput, `${label} must be an absolute URL.`);
|
|
99
|
+
const url = new URL(trimmed);
|
|
100
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
101
|
+
return fail(EXIT_CODE.invalidInput, `${label} must use http or https.`);
|
|
102
|
+
if (url.username || url.password)
|
|
103
|
+
return fail(EXIT_CODE.invalidInput, `${label} must not contain credentials.`);
|
|
104
|
+
return ok(url.href);
|
|
105
|
+
}
|
|
106
|
+
export function parseAbsolutePageUrl(input) {
|
|
107
|
+
const parsed = parseHttpUrl(input, 'Page URL');
|
|
108
|
+
if (parsed._tag === 'Err')
|
|
109
|
+
return parsed;
|
|
110
|
+
const url = new URL(parsed.value);
|
|
111
|
+
if (url.hash)
|
|
112
|
+
url.hash = '';
|
|
113
|
+
return ok(url.href);
|
|
114
|
+
}
|
|
115
|
+
export function parseInteger(input, options) {
|
|
116
|
+
if (input === undefined)
|
|
117
|
+
return ok(options.defaultValue);
|
|
118
|
+
if (!/^\d+$/.test(input))
|
|
119
|
+
return fail(EXIT_CODE.invalidInput, `${options.name} must be an integer.`);
|
|
120
|
+
const parsed = Number(input);
|
|
121
|
+
if (!Number.isSafeInteger(parsed) || parsed < options.minimum || (options.maximum !== undefined && parsed > options.maximum)) {
|
|
122
|
+
const range = options.maximum === undefined ? `at least ${options.minimum}` : `between ${options.minimum} and ${options.maximum}`;
|
|
123
|
+
return fail(EXIT_CODE.invalidInput, `${options.name} must be ${range}.`);
|
|
124
|
+
}
|
|
125
|
+
return ok(parsed);
|
|
126
|
+
}
|
package/dist/render.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AccountUsageResponse, SitesListResponse } from '@nuxtseo/protocol/v1';
|
|
2
|
+
import type { ActionList, ActionResolve, ActionShow } from '@nuxtseo/protocol/v1/actions';
|
|
3
|
+
import type { Mentions, RecoverableBacklinks } from '@nuxtseo/protocol/v1/backlinks';
|
|
4
|
+
import type { SearchStatusData } from '@nuxtseo/protocol/v1/gsc';
|
|
5
|
+
import type { PageInspect, PageScan } from '@nuxtseo/protocol/v1/pages';
|
|
6
|
+
import type { SitePerformanceOverview } from '@nuxtseo/protocol/v1/performance';
|
|
7
|
+
export declare function renderSites(response: SitesListResponse, selectedSiteId?: string): string;
|
|
8
|
+
export declare function renderUsage(response: AccountUsageResponse): string;
|
|
9
|
+
export declare function renderActions(data: ActionList): string;
|
|
10
|
+
export declare function renderAction(data: ActionShow): string;
|
|
11
|
+
export declare function renderActionResolution(data: ActionResolve): string;
|
|
12
|
+
export declare function renderPageInspection(data: PageInspect): string;
|
|
13
|
+
export declare function renderPageScan(data: PageScan): string;
|
|
14
|
+
export declare function renderPerformance(data: SitePerformanceOverview): string;
|
|
15
|
+
export declare function renderSearchStatus(data: SearchStatusData): string;
|
|
16
|
+
export declare function renderRecoverableBacklinks(data: RecoverableBacklinks): string;
|
|
17
|
+
export declare function renderMentions(data: Mentions): string;
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
function value(value) {
|
|
2
|
+
if (value === null || value === undefined || value === '')
|
|
3
|
+
return 'none';
|
|
4
|
+
if (typeof value === 'boolean')
|
|
5
|
+
return value ? 'yes' : 'no';
|
|
6
|
+
return String(value);
|
|
7
|
+
}
|
|
8
|
+
function fields(rows) {
|
|
9
|
+
const width = Math.max(...rows.map(([label]) => label.length));
|
|
10
|
+
return rows.map(([label, rowValue]) => `${label.padEnd(width)} ${value(rowValue)}`).join('\n');
|
|
11
|
+
}
|
|
12
|
+
function siteLabel(site) {
|
|
13
|
+
return site.name ?? site.url ?? site.id;
|
|
14
|
+
}
|
|
15
|
+
export function renderSites(response, selectedSiteId) {
|
|
16
|
+
if (response.data.sites.length === 0)
|
|
17
|
+
return 'No Sites are accessible to this token.';
|
|
18
|
+
return [
|
|
19
|
+
`Sites (${response.data.sites.length})`,
|
|
20
|
+
...response.data.sites.map((site) => {
|
|
21
|
+
const selected = site.id === selectedSiteId ? '*' : ' ';
|
|
22
|
+
const state = `${site.searchConsole.status}, ${site.monitoring.status}`;
|
|
23
|
+
return `${selected} ${siteLabel(site)}\n ${site.id}\n ${site.url ?? 'URL unavailable'}\n ${state}`;
|
|
24
|
+
}),
|
|
25
|
+
].join('\n');
|
|
26
|
+
}
|
|
27
|
+
export function renderUsage(response) {
|
|
28
|
+
const meters = response.data.meters.length === 0
|
|
29
|
+
? ['No usage meters.']
|
|
30
|
+
: response.data.meters.map((meter) => {
|
|
31
|
+
const allowance = meter.limit === null ? `${meter.used} ${meter.unit}` : `${meter.used}/${meter.limit} ${meter.unit}`;
|
|
32
|
+
const state = meter.blocked ? 'blocked' : meter.overCap ? 'over cap' : 'available';
|
|
33
|
+
return `${meter.label}: ${allowance}, ${state}${meter.resetsAt ? `, resets ${meter.resetsAt}` : ''}`;
|
|
34
|
+
});
|
|
35
|
+
return [`Plan: ${response.data.plan}`, ...meters].join('\n');
|
|
36
|
+
}
|
|
37
|
+
export function renderActions(data) {
|
|
38
|
+
if (data.actions.length === 0)
|
|
39
|
+
return 'No next actions.';
|
|
40
|
+
return [
|
|
41
|
+
`Next actions (${data.page.total})`,
|
|
42
|
+
...data.actions.map(action => [
|
|
43
|
+
`${action.id} ${action.diagnosis}`,
|
|
44
|
+
` ${action.status}, effort ${action.effort}, ${action.affectedPages ?? 'unknown'} affected pages`,
|
|
45
|
+
].join('\n')),
|
|
46
|
+
data.page.hasMore ? `More actions available after offset ${data.page.offset + data.page.limit}.` : '',
|
|
47
|
+
].filter(Boolean).join('\n');
|
|
48
|
+
}
|
|
49
|
+
export function renderAction(data) {
|
|
50
|
+
return [
|
|
51
|
+
fields([
|
|
52
|
+
['Action', data.id],
|
|
53
|
+
['Status', data.status],
|
|
54
|
+
['Diagnosis', data.diagnosis],
|
|
55
|
+
['Do', data.action],
|
|
56
|
+
['Finding', data.findingType],
|
|
57
|
+
['As of', data.asOf],
|
|
58
|
+
]),
|
|
59
|
+
`Evidence groups: ${data.evidenceGroups.length}`,
|
|
60
|
+
].join('\n');
|
|
61
|
+
}
|
|
62
|
+
export function renderActionResolution(data) {
|
|
63
|
+
return fields([
|
|
64
|
+
['Action', data.actionId],
|
|
65
|
+
['Status', data.status],
|
|
66
|
+
['Verification', data.verification],
|
|
67
|
+
]);
|
|
68
|
+
}
|
|
69
|
+
export function renderPageInspection(data) {
|
|
70
|
+
const observations = data.observations.rows.map(row => `${row.severity} ${row.issueType} ${row.pathWithQuery}`);
|
|
71
|
+
const lighthouse = data.performance.lighthouse
|
|
72
|
+
? fields([
|
|
73
|
+
['Performance', data.performance.lighthouse.performance],
|
|
74
|
+
['Accessibility', data.performance.lighthouse.accessibility],
|
|
75
|
+
['Best practices', data.performance.lighthouse.bestPractices],
|
|
76
|
+
['SEO', data.performance.lighthouse.seo],
|
|
77
|
+
])
|
|
78
|
+
: 'No Lighthouse scan.';
|
|
79
|
+
return [
|
|
80
|
+
data.page.url,
|
|
81
|
+
`Observations: ${data.observations.total}`,
|
|
82
|
+
...observations,
|
|
83
|
+
lighthouse,
|
|
84
|
+
`Tracked keywords: ${data.search.keywords.length}`,
|
|
85
|
+
].join('\n');
|
|
86
|
+
}
|
|
87
|
+
export function renderPageScan(data) {
|
|
88
|
+
return [
|
|
89
|
+
`Page scan ${data.status}`,
|
|
90
|
+
`Page: ${data.pageId}${data.pageCreated ? ' (newly monitored)' : ''}`,
|
|
91
|
+
...data.scans.map(scan => `${scan.strategy}: ${scan.scanId}`),
|
|
92
|
+
].join('\n');
|
|
93
|
+
}
|
|
94
|
+
export function renderPerformance(data) {
|
|
95
|
+
if (!data.health)
|
|
96
|
+
return `No stored performance overview. Monitored routes: ${data.routes.length}.`;
|
|
97
|
+
return fields([
|
|
98
|
+
['Scanned pages', data.health.scannedPageCount],
|
|
99
|
+
['Last scan', data.health.lastScanAt],
|
|
100
|
+
['Performance median', data.health.perf.median],
|
|
101
|
+
['Performance worst', data.health.perf.worst],
|
|
102
|
+
['Accessibility median', data.health.a11y.median],
|
|
103
|
+
['SEO median', data.health.seo.median],
|
|
104
|
+
['LCP median', data.health.lcp.median],
|
|
105
|
+
['TBT median', data.health.tbt.median],
|
|
106
|
+
['CLS median', data.health.cls.median],
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
109
|
+
export function renderSearchStatus(data) {
|
|
110
|
+
return fields([
|
|
111
|
+
['Site', data.site],
|
|
112
|
+
['Connected', data.connected],
|
|
113
|
+
['Sync status', data.syncStatus],
|
|
114
|
+
['Progress', `${data.progress.percent}%`],
|
|
115
|
+
['Last sync', data.lastSyncAt],
|
|
116
|
+
['Data available', data.hasData],
|
|
117
|
+
['Coverage', `${data.daysSynced}/${data.daysAvailable} days`],
|
|
118
|
+
]);
|
|
119
|
+
}
|
|
120
|
+
export function renderRecoverableBacklinks(data) {
|
|
121
|
+
if (data.items.length === 0)
|
|
122
|
+
return 'No recoverable Backlinks. Every stored inbound link resolves.';
|
|
123
|
+
const shown = `${data.offset + 1}-${data.offset + data.items.length} of ${data.total}`;
|
|
124
|
+
return [
|
|
125
|
+
`Recoverable Backlinks (${shown})`,
|
|
126
|
+
...data.items.map(item => [
|
|
127
|
+
`${item.destinationStatus ?? 'unknown'} ${item.destinationUrl}`,
|
|
128
|
+
` from ${item.referringUrl}`,
|
|
129
|
+
` ${fields([
|
|
130
|
+
['domain', item.referringDomain],
|
|
131
|
+
['anchor', item.anchor],
|
|
132
|
+
['rank', item.domainRank],
|
|
133
|
+
['dofollow', item.dofollow],
|
|
134
|
+
['last seen', item.lastSeenAt],
|
|
135
|
+
]).split('\n').join('\n ')}`,
|
|
136
|
+
].join('\n')),
|
|
137
|
+
].join('\n');
|
|
138
|
+
}
|
|
139
|
+
export function renderMentions(data) {
|
|
140
|
+
if (data.items.length === 0)
|
|
141
|
+
return 'No Mentions.';
|
|
142
|
+
return [
|
|
143
|
+
`Mentions (${data.items.length})`,
|
|
144
|
+
...data.items.map(item => [
|
|
145
|
+
`${item.source} ${item.title ?? item.url}`,
|
|
146
|
+
` ${item.url}`,
|
|
147
|
+
` ${fields([
|
|
148
|
+
['author', item.authorHandle ?? item.author],
|
|
149
|
+
['rank', item.domainRank],
|
|
150
|
+
['engagement', item.engagement],
|
|
151
|
+
['sentiment', item.sentiment],
|
|
152
|
+
['published', item.publishedAt],
|
|
153
|
+
]).split('\n').join('\n ')}`,
|
|
154
|
+
].join('\n')),
|
|
155
|
+
].join('\n');
|
|
156
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Readable, Writable } from 'node:stream';
|
|
2
|
+
import type { StatePaths } from './state/index.js';
|
|
3
|
+
export interface CliRuntime {
|
|
4
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
5
|
+
paths: StatePaths;
|
|
6
|
+
input: Readable;
|
|
7
|
+
output: Writable;
|
|
8
|
+
error: Writable;
|
|
9
|
+
inputIsTTY: boolean;
|
|
10
|
+
interactive: boolean;
|
|
11
|
+
signal: AbortSignal;
|
|
12
|
+
requestSignal: AbortSignal;
|
|
13
|
+
readStdin: () => Promise<string>;
|
|
14
|
+
}
|
|
15
|
+
export declare function writeOutput(runtime: CliRuntime, text: string): void;
|
|
16
|
+
export declare function writeDiagnostic(runtime: CliRuntime, text: string): void;
|
|
17
|
+
export declare function writeProtocolResponse(runtime: CliRuntime, response: unknown): void;
|
|
18
|
+
export declare function writeCliResponse(runtime: CliRuntime, response: unknown): void;
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function writeOutput(runtime, text) {
|
|
2
|
+
runtime.output.write(text.endsWith('\n') ? text : `${text}\n`);
|
|
3
|
+
}
|
|
4
|
+
export function writeDiagnostic(runtime, text) {
|
|
5
|
+
runtime.error.write(text.endsWith('\n') ? text : `${text}\n`);
|
|
6
|
+
}
|
|
7
|
+
export function writeProtocolResponse(runtime, response) {
|
|
8
|
+
writeOutput(runtime, JSON.stringify(response));
|
|
9
|
+
}
|
|
10
|
+
export function writeCliResponse(runtime, response) {
|
|
11
|
+
writeOutput(runtime, JSON.stringify(response));
|
|
12
|
+
}
|
package/dist/site.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ApiContext } from './api.js';
|
|
2
|
+
import type { CliResult } from './failures.js';
|
|
3
|
+
import type { GlobalOptions } from './parse.js';
|
|
4
|
+
import type { CliRuntime } from './runtime.js';
|
|
5
|
+
export declare function resolveSite(api: ApiContext, runtime: CliRuntime, globals: GlobalOptions): Promise<CliResult<string>>;
|
package/dist/site.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as prompts from '@clack/prompts';
|
|
2
|
+
import { fromStateError } from './api.js';
|
|
3
|
+
import { EXIT_CODE, fail, fromSdkFailure, ok } from './failures.js';
|
|
4
|
+
import { resolveSiteId, updateConfig } from './state/index.js';
|
|
5
|
+
function label(site) {
|
|
6
|
+
return site.name ?? site.url ?? site.id;
|
|
7
|
+
}
|
|
8
|
+
export async function resolveSite(api, runtime, globals) {
|
|
9
|
+
const requested = resolveSiteId({
|
|
10
|
+
siteId: globals.siteId,
|
|
11
|
+
env: runtime.env,
|
|
12
|
+
config: api.config,
|
|
13
|
+
});
|
|
14
|
+
if (requested._tag === 'Err')
|
|
15
|
+
return fail(EXIT_CODE.invalidInput, `${requested.error._tag}: ${requested.error.message}`);
|
|
16
|
+
if (requested.value)
|
|
17
|
+
return ok(requested.value.siteId);
|
|
18
|
+
const listed = await api.client.sites.list({}, { signal: runtime.requestSignal });
|
|
19
|
+
if (listed._tag === 'Err')
|
|
20
|
+
return fromSdkFailure(listed.error);
|
|
21
|
+
const sites = listed.value.data.sites;
|
|
22
|
+
if (sites.length === 0) {
|
|
23
|
+
return fail(EXIT_CODE.notFound, 'site_empty: This token has no accessible Sites. Add a Site in the dashboard, then run `nuxtseo sites list`.', undefined, 'site_empty');
|
|
24
|
+
}
|
|
25
|
+
if (sites.length === 1)
|
|
26
|
+
return ok(sites[0].id);
|
|
27
|
+
if (!runtime.interactive) {
|
|
28
|
+
return fail(EXIT_CODE.conflict, 'site_ambiguous: Multiple Sites are accessible. Run `nuxtseo sites use <site-id>`, pass `--site <site-id>`, or set NUXTSEO_SITE_ID.', undefined, 'site_ambiguous');
|
|
29
|
+
}
|
|
30
|
+
const selectedId = await prompts.select({
|
|
31
|
+
message: 'Select a Site',
|
|
32
|
+
options: sites.map(site => ({
|
|
33
|
+
value: site.id,
|
|
34
|
+
label: label(site),
|
|
35
|
+
hint: site.url ?? site.id,
|
|
36
|
+
})),
|
|
37
|
+
input: runtime.input,
|
|
38
|
+
output: runtime.error,
|
|
39
|
+
signal: runtime.signal,
|
|
40
|
+
});
|
|
41
|
+
if (prompts.isCancel(selectedId))
|
|
42
|
+
return fail(EXIT_CODE.interrupted, 'interrupted: Site selection cancelled.');
|
|
43
|
+
const selected = sites.find(site => site.id === selectedId);
|
|
44
|
+
const persisted = await updateConfig({ siteId: selected.id }, { paths: runtime.paths });
|
|
45
|
+
if (persisted._tag === 'Err')
|
|
46
|
+
return fromStateError(persisted.error);
|
|
47
|
+
return ok(selected.id);
|
|
48
|
+
}
|