@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/README.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# NuxtSEO CLI
|
|
2
|
+
|
|
3
|
+
Use NuxtSEO public Site operations from a terminal, script, or coding agent.
|
|
4
|
+
The CLI sends feature requests through `@nuxtseo/sdk`. It does not call MCP,
|
|
5
|
+
private routes, or providers.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Install [Node.js](https://nodejs.org) 22 or newer.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install --global @nuxtseo/cli
|
|
13
|
+
nuxtseo --version
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
You can also run one command without a global install:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npx @nuxtseo/cli sites list
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## First Site read
|
|
23
|
+
|
|
24
|
+
Set a Team API token through your shell or CI secret store, then list the Sites
|
|
25
|
+
the token can access:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
export NUXTSEO_TOKEN='your-team-api-token'
|
|
29
|
+
nuxtseo sites list
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Select a default Site and inspect a Page:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
nuxtseo sites use site_123
|
|
36
|
+
nuxtseo page inspect https://example.com/about
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
For an automated read, keep the Site explicit and request JSON:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
nuxtseo page inspect https://example.com/about \
|
|
43
|
+
--site site_123 \
|
|
44
|
+
--json
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Authentication
|
|
48
|
+
|
|
49
|
+
Run `nuxtseo login`. It prints a pairing code, opens your browser on the
|
|
50
|
+
approval page, and waits. Check that the code in the browser matches the code in
|
|
51
|
+
your terminal, choose a role, and approve. The CLI then receives a Team API
|
|
52
|
+
token and stores it.
|
|
53
|
+
|
|
54
|
+
The token is an ordinary Team API token labelled `CLI on <hostname>`. It appears
|
|
55
|
+
under Settings > API tokens and is revoked there like any other.
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
nuxtseo login # pair through the browser
|
|
59
|
+
nuxtseo login --no-browser # print the URL instead of opening it
|
|
60
|
+
nuxtseo login --with-token # paste an existing token instead
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
A pairing request stays open for ten minutes. Denying it creates nothing.
|
|
64
|
+
|
|
65
|
+
Token resolution order is:
|
|
66
|
+
|
|
67
|
+
1. `NUXTSEO_TOKEN`
|
|
68
|
+
2. The stored credential
|
|
69
|
+
|
|
70
|
+
Outside an interactive terminal, `login` reads a token from stdin instead of
|
|
71
|
+
pairing, so CI keeps working. Tokens are never accepted as positional arguments
|
|
72
|
+
or options.
|
|
73
|
+
|
|
74
|
+
The CLI validates the token with a public Site read, then stores it in the OS
|
|
75
|
+
keychain through optional `@napi-rs/keyring`. When keychain support is
|
|
76
|
+
unavailable, it warns on stderr and uses `~/.nuxtseo/auth.json` with mode `0600`.
|
|
77
|
+
|
|
78
|
+
`nuxtseo logout` removes the local credential. Revoke the token from the
|
|
79
|
+
NuxtSEO dashboard when it must stop working everywhere. An environment token
|
|
80
|
+
always wins and is unchanged by login or logout.
|
|
81
|
+
|
|
82
|
+
API host resolution order is:
|
|
83
|
+
|
|
84
|
+
1. `--api-url`
|
|
85
|
+
2. `NUXTSEO_API_URL`
|
|
86
|
+
3. The host stored by `nuxtseo config`
|
|
87
|
+
4. `https://nuxtseo.com`
|
|
88
|
+
|
|
89
|
+
## Site selection
|
|
90
|
+
|
|
91
|
+
Site scoped commands currently resolve a Site in this order:
|
|
92
|
+
|
|
93
|
+
1. `--site <site-id>`
|
|
94
|
+
2. `NUXTSEO_SITE_ID`
|
|
95
|
+
3. The Site saved by `nuxtseo sites use <site-id>`
|
|
96
|
+
4. The only accessible Site
|
|
97
|
+
5. A selection prompt when several Sites are available in a TTY
|
|
98
|
+
|
|
99
|
+
Scripts and agents should pass `--site`. Multiple Sites outside a TTY produce
|
|
100
|
+
exit `5`; the CLI never guesses.
|
|
101
|
+
|
|
102
|
+
An explicit argument, environment value, or stored Site ID goes directly to the
|
|
103
|
+
requested operation. It does not add a `sites.list` request or require
|
|
104
|
+
`sites:read` scope.
|
|
105
|
+
|
|
106
|
+
## JSON output
|
|
107
|
+
|
|
108
|
+
For a public API success, `--json` writes the complete protocol response
|
|
109
|
+
envelope to stdout, followed by one newline. The CLI does not unwrap `data`,
|
|
110
|
+
rename fields, rank results, or add local fields. Warnings and diagnostics use
|
|
111
|
+
stderr.
|
|
112
|
+
|
|
113
|
+
The [OpenAPI 3.1 document](https://nuxtseo.com/docs/api/openapi.json) holds the
|
|
114
|
+
exact paths, request schemas, responses, scopes, and errors. Read it to predict
|
|
115
|
+
an envelope before you call for it.
|
|
116
|
+
|
|
117
|
+
The SDK validates and parses the HTTP body before the CLI serializes it. The
|
|
118
|
+
JSON value and protocol structure are preserved. Whitespace and object key
|
|
119
|
+
order from the wire are not a byte level guarantee.
|
|
120
|
+
|
|
121
|
+
When the server returns a valid protocol error body, `--json` writes that body
|
|
122
|
+
to stdout and writes the readable recovery message to stderr.
|
|
123
|
+
|
|
124
|
+
Local outcomes have no server protocol body. With `--json`, commands such as
|
|
125
|
+
bare `nuxtseo`, `config`, `logout`, and `sites use` emit a tagged CLI-owned JSON
|
|
126
|
+
value with `schemaVersion: 1`. Local failures emit `CliError` with a stable code
|
|
127
|
+
and exit code. These tags are separate from protocol envelopes.
|
|
128
|
+
|
|
129
|
+
`--help --json` returns the selected command, global options, arguments, and
|
|
130
|
+
subcommands as `CliHelp`. Option names are strict; an unknown option exits `2`
|
|
131
|
+
before authentication or network work.
|
|
132
|
+
|
|
133
|
+
## Automation controls
|
|
134
|
+
|
|
135
|
+
The CLI never prompts outside a TTY. `CI=1` or `--no-input` suppresses
|
|
136
|
+
interactive mode in a terminal session. `--json` also implies `--no-input`.
|
|
137
|
+
|
|
138
|
+
Mutations require confirmation. Pass `--yes` or `-y` in automation:
|
|
139
|
+
|
|
140
|
+
```sh
|
|
141
|
+
nuxtseo page scan https://example.com/about --site site_123 --yes --json
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Agent controls:
|
|
145
|
+
|
|
146
|
+
| Option | Behavior |
|
|
147
|
+
| --- | --- |
|
|
148
|
+
| `--no-input` | Disable every prompt even when stdin and stdout are TTYs |
|
|
149
|
+
| `--timeout-ms <milliseconds>` | Apply one deadline to Site resolution, SDK retries, and the operation; default `30000`, maximum `300000` |
|
|
150
|
+
|
|
151
|
+
Request timeouts return `CliError` code `request_timeout` and exit `6`.
|
|
152
|
+
`SIGINT` and `SIGTERM` abort the active request and return exit `130`.
|
|
153
|
+
|
|
154
|
+
## Commands and paging
|
|
155
|
+
|
|
156
|
+
```text
|
|
157
|
+
nuxtseo login
|
|
158
|
+
nuxtseo logout
|
|
159
|
+
nuxtseo whoami
|
|
160
|
+
nuxtseo config
|
|
161
|
+
nuxtseo sites list
|
|
162
|
+
nuxtseo sites use <site-id>
|
|
163
|
+
nuxtseo usage
|
|
164
|
+
nuxtseo actions list
|
|
165
|
+
nuxtseo actions show <action-id>
|
|
166
|
+
nuxtseo actions resolve <action-id>
|
|
167
|
+
nuxtseo backlinks recoverable
|
|
168
|
+
nuxtseo mentions list
|
|
169
|
+
nuxtseo page inspect <url>
|
|
170
|
+
nuxtseo page scan <url>
|
|
171
|
+
nuxtseo performance
|
|
172
|
+
nuxtseo search status
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The CLI fetches one page per invocation. It does not auto-page or merge
|
|
176
|
+
responses.
|
|
177
|
+
|
|
178
|
+
| Command | Paging inputs | Default |
|
|
179
|
+
| --- | --- | --- |
|
|
180
|
+
| `actions list` | `--limit 1..25`, `--offset >=0` | `--limit 10 --offset 0` |
|
|
181
|
+
| `actions show` | `--group-id`, `--cursor`, `--limit 1..100` | `--limit 50` |
|
|
182
|
+
| `page inspect` | `--limit 1..200`, `--offset >=0` | `--limit 100 --offset 0` |
|
|
183
|
+
| `backlinks recoverable` | `--limit 1..200`, `--offset >=0` | `--limit 100 --offset 0` |
|
|
184
|
+
| `mentions list` | `--limit 1..200` | `--limit 100` |
|
|
185
|
+
|
|
186
|
+
Keep the server order for actions. For another page, pass the next offset or
|
|
187
|
+
cursor reported by the response. A cursor is opaque; do not edit or infer it.
|
|
188
|
+
|
|
189
|
+
## Coding agents
|
|
190
|
+
|
|
191
|
+
The package ships an agent skill that teaches Claude Code how to drive the CLI.
|
|
192
|
+
Copy it into a project after install:
|
|
193
|
+
|
|
194
|
+
```sh
|
|
195
|
+
mkdir -p .claude/skills
|
|
196
|
+
cp -R node_modules/@nuxtseo/cli/skills/nuxtseo-cli .claude/skills/
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The skill covers the JSON contract, Site selection, paging, mutation consent,
|
|
200
|
+
and what to do for each exit code.
|
|
201
|
+
|
|
202
|
+
## Exit codes and recovery
|
|
203
|
+
|
|
204
|
+
| Code | Meaning | Typical recovery |
|
|
205
|
+
| ---: | --- | --- |
|
|
206
|
+
| `0` | Success, including a closed stdout pipe | Continue |
|
|
207
|
+
| `2` | Invalid input or missing mutation confirmation | Fix arguments; use `--yes` for a deliberate mutation |
|
|
208
|
+
| `3` | Missing, invalid, or expired authentication | Replace `NUXTSEO_TOKEN` or run `nuxtseo login` |
|
|
209
|
+
| `4` | Forbidden, scope, or entitlement failure | Use a token with the required access or change plan |
|
|
210
|
+
| `5` | Conflict, stale evidence, or ambiguous Site | Refresh the read; pass `--site` when selection is ambiguous |
|
|
211
|
+
| `6` | Rate, quota, provider, or request timeout | Read retry metadata on stderr and retry later |
|
|
212
|
+
| `7` | Local state, network, contract, or infrastructure failure | Fix the named path or network issue; keep the request ID |
|
|
213
|
+
| `8` | Resource or accessible Site not found | Run `sites list`, then `sites use` or pass `--site` |
|
|
214
|
+
| `130` | Interrupted or cancelled | Confirm no mutation result before retrying |
|
|
215
|
+
|
|
216
|
+
The CLI prints the exact server error code. When available, stderr also includes
|
|
217
|
+
the request ID, retry delay, rate limit, reset time, and structured details.
|
|
218
|
+
The CLI never falls back to MCP, a private route, or cached feature data.
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PublicV1Client } from '@nuxtseo/sdk';
|
|
2
|
+
import type { CliResult } from './failures.js';
|
|
3
|
+
import type { GlobalOptions } from './parse.js';
|
|
4
|
+
import type { CliRuntime } from './runtime.js';
|
|
5
|
+
import type { CredentialSource, NuxtSeoConfig, StateError } from './state/index.js';
|
|
6
|
+
export interface ApiContext {
|
|
7
|
+
apiUrl: string;
|
|
8
|
+
client: PublicV1Client;
|
|
9
|
+
config: NuxtSeoConfig;
|
|
10
|
+
credentialSource: CredentialSource;
|
|
11
|
+
}
|
|
12
|
+
export declare function fromStateError(error: StateError): CliResult<never>;
|
|
13
|
+
export declare function createApiClient(apiUrl: string, token: string): PublicV1Client;
|
|
14
|
+
export declare function loadApiContext(runtime: CliRuntime, globals: GlobalOptions): Promise<CliResult<ApiContext>>;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createBearerTransport, createPublicV1Client } from '@nuxtseo/sdk';
|
|
2
|
+
import { EXIT_CODE, fail, ok } from './failures.js';
|
|
3
|
+
import { readConfig, resolveApiUrl, resolveCredential } from './state/index.js';
|
|
4
|
+
export function fromStateError(error) {
|
|
5
|
+
const exitCode = error._tag === 'InvalidCredential'
|
|
6
|
+
? EXIT_CODE.authentication
|
|
7
|
+
: error._tag === 'InvalidApiUrl' || error._tag === 'InvalidConfig'
|
|
8
|
+
? EXIT_CODE.invalidInput
|
|
9
|
+
: EXIT_CODE.infrastructure;
|
|
10
|
+
const code = error._tag === 'InvalidCredential'
|
|
11
|
+
? 'authentication_required'
|
|
12
|
+
: error._tag === 'InvalidApiUrl' || error._tag === 'InvalidConfig'
|
|
13
|
+
? 'invalid_cli_input'
|
|
14
|
+
: 'state_error';
|
|
15
|
+
return fail(exitCode, `${error._tag}: ${error.message}`, 'cause' in error ? error.cause : undefined, code);
|
|
16
|
+
}
|
|
17
|
+
export function createApiClient(apiUrl, token) {
|
|
18
|
+
return createPublicV1Client({
|
|
19
|
+
transport: createBearerTransport({ baseUrl: apiUrl, token }),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export async function loadApiContext(runtime, globals) {
|
|
23
|
+
const [apiUrl, credential, config] = await Promise.all([
|
|
24
|
+
resolveApiUrl({ apiUrl: globals.apiUrl, env: runtime.env, paths: runtime.paths }),
|
|
25
|
+
resolveCredential({ env: runtime.env, paths: runtime.paths }),
|
|
26
|
+
readConfig({ paths: runtime.paths }),
|
|
27
|
+
]);
|
|
28
|
+
if (apiUrl._tag === 'Err')
|
|
29
|
+
return fromStateError(apiUrl.error);
|
|
30
|
+
if (credential._tag === 'Err')
|
|
31
|
+
return fromStateError(credential.error);
|
|
32
|
+
if (config._tag === 'Err')
|
|
33
|
+
return fromStateError(config.error);
|
|
34
|
+
if (!credential.value) {
|
|
35
|
+
return fail(EXIT_CODE.authentication, 'authentication_required: Set NUXTSEO_TOKEN or run `nuxtseo login`.\nCreate a Team API token at https://nuxtseo.com/pro/dashboard/settings/api-tokens.');
|
|
36
|
+
}
|
|
37
|
+
return ok({
|
|
38
|
+
apiUrl: apiUrl.value.apiUrl,
|
|
39
|
+
client: createApiClient(apiUrl.value.apiUrl, credential.value.token),
|
|
40
|
+
config: config.value,
|
|
41
|
+
credentialSource: credential.value.source,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort browser open. Every failure mode here (headless box, no
|
|
3
|
+
* `xdg-open`, sandboxed spawn) is expected, so this reports whether it worked
|
|
4
|
+
* and the caller prints the URL instead. It must never throw, because the poll
|
|
5
|
+
* loop that follows is what actually completes the login.
|
|
6
|
+
*/
|
|
7
|
+
export declare function openBrowser(url: string): boolean;
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
/**
|
|
4
|
+
* Best-effort browser open. Every failure mode here (headless box, no
|
|
5
|
+
* `xdg-open`, sandboxed spawn) is expected, so this reports whether it worked
|
|
6
|
+
* and the caller prints the URL instead. It must never throw, because the poll
|
|
7
|
+
* loop that follows is what actually completes the login.
|
|
8
|
+
*/
|
|
9
|
+
export function openBrowser(url) {
|
|
10
|
+
const [command, args] = process.platform === 'darwin'
|
|
11
|
+
? ['open', [url]]
|
|
12
|
+
: process.platform === 'win32'
|
|
13
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
14
|
+
: ['xdg-open', [url]];
|
|
15
|
+
try {
|
|
16
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
17
|
+
child.on('error', () => { });
|
|
18
|
+
child.unref();
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { runCli } from './cli.js';
|
|
5
|
+
import { createStatePaths } from './state/index.js';
|
|
6
|
+
const abortController = new AbortController();
|
|
7
|
+
const abort = () => abortController.abort(new DOMException('Command interrupted.', 'AbortError'));
|
|
8
|
+
function outputError(cause) {
|
|
9
|
+
if (cause.code === 'EPIPE')
|
|
10
|
+
process.exit(0);
|
|
11
|
+
throw cause;
|
|
12
|
+
}
|
|
13
|
+
process.once('SIGINT', abort);
|
|
14
|
+
process.once('SIGTERM', abort);
|
|
15
|
+
process.stdout.once('error', outputError);
|
|
16
|
+
async function readStdin() {
|
|
17
|
+
process.stdin.setEncoding('utf8');
|
|
18
|
+
let input = '';
|
|
19
|
+
for await (const chunk of process.stdin)
|
|
20
|
+
input += chunk;
|
|
21
|
+
return input;
|
|
22
|
+
}
|
|
23
|
+
const interactive = process.stdin.isTTY === true
|
|
24
|
+
&& process.stdout.isTTY === true
|
|
25
|
+
&& process.stderr.isTTY === true
|
|
26
|
+
&& !process.env.CI;
|
|
27
|
+
process.exitCode = await runCli(process.argv.slice(2), {
|
|
28
|
+
env: process.env,
|
|
29
|
+
paths: createStatePaths(homedir()),
|
|
30
|
+
input: process.stdin,
|
|
31
|
+
output: process.stdout,
|
|
32
|
+
error: process.stderr,
|
|
33
|
+
inputIsTTY: process.stdin.isTTY === true,
|
|
34
|
+
interactive,
|
|
35
|
+
signal: abortController.signal,
|
|
36
|
+
requestSignal: abortController.signal,
|
|
37
|
+
readStdin,
|
|
38
|
+
});
|
|
39
|
+
process.removeListener('SIGINT', abort);
|
|
40
|
+
process.removeListener('SIGTERM', abort);
|
|
41
|
+
process.stdout.removeListener('error', outputError);
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import * as prompts from '@clack/prompts';
|
|
2
|
+
import { renderUsage, runCommand } from 'citty';
|
|
3
|
+
import { fromStateError, loadApiContext } from './api.js';
|
|
4
|
+
import { commandWithGlobalOptions, describeCommand, resolveCommand, validateCommandOptions, } from './command-contract.js';
|
|
5
|
+
import { createRootCommand } from './commands.js';
|
|
6
|
+
import { EXIT_CODE, fail, fromSdkFailure, ok, unexpectedFailure } from './failures.js';
|
|
7
|
+
import { extractGlobalOptions } from './parse.js';
|
|
8
|
+
import { writeCliResponse, writeDiagnostic, writeOutput, writeProtocolResponse } from './runtime.js';
|
|
9
|
+
import { getCredentialStatus, readConfig, resolveApiUrl, resolveSiteId, updateConfig } from './state/index.js';
|
|
10
|
+
import { VERSION } from './version.js';
|
|
11
|
+
function report(runtime, failure, json = false) {
|
|
12
|
+
if (json) {
|
|
13
|
+
if (failure.protocolResponse !== undefined) {
|
|
14
|
+
writeProtocolResponse(runtime, failure.protocolResponse);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
writeCliResponse(runtime, {
|
|
18
|
+
_tag: 'CliError',
|
|
19
|
+
schemaVersion: 1,
|
|
20
|
+
error: {
|
|
21
|
+
code: failure.code,
|
|
22
|
+
exitCode: failure.exitCode,
|
|
23
|
+
message: failure.message,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
writeDiagnostic(runtime, failure.message);
|
|
29
|
+
return failure.exitCode;
|
|
30
|
+
}
|
|
31
|
+
function isCittyInputError(cause) {
|
|
32
|
+
return typeof cause === 'object'
|
|
33
|
+
&& cause !== null
|
|
34
|
+
&& 'code' in cause
|
|
35
|
+
&& typeof cause.code === 'string'
|
|
36
|
+
&& ['EARG', 'E_NO_COMMAND', 'E_UNKNOWN_COMMAND'].includes(cause.code);
|
|
37
|
+
}
|
|
38
|
+
async function rootUsage(runtime, globals) {
|
|
39
|
+
return renderUsage(createRootCommand(runtime, globals, { result: null }));
|
|
40
|
+
}
|
|
41
|
+
async function requestedUsage(runtime, globals, args) {
|
|
42
|
+
const root = createRootCommand(runtime, globals, { result: null });
|
|
43
|
+
const resolved = resolveCommand(root, args.filter(argument => argument !== '--help' && argument !== '-h'));
|
|
44
|
+
const parentName = resolved.path.length > 1 ? resolved.path.slice(0, -1).join(' ') : undefined;
|
|
45
|
+
return renderUsage(commandWithGlobalOptions(root, resolved.command), parentName ? { meta: { name: parentName, version: VERSION } } : undefined);
|
|
46
|
+
}
|
|
47
|
+
async function runExplicit(args, runtime, globals) {
|
|
48
|
+
const execution = { result: null };
|
|
49
|
+
const command = createRootCommand(runtime, globals, execution);
|
|
50
|
+
const validOptions = validateCommandOptions(command, args);
|
|
51
|
+
if (validOptions._tag === 'Err')
|
|
52
|
+
return validOptions;
|
|
53
|
+
return runCommand(command, { rawArgs: args })
|
|
54
|
+
.then(() => execution.result ?? fail(EXIT_CODE.infrastructure, 'unexpected: Command completed without a result.'))
|
|
55
|
+
.catch((cause) => {
|
|
56
|
+
if (isCittyInputError(cause))
|
|
57
|
+
return fail(EXIT_CODE.invalidInput, `invalid_cli_input: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
58
|
+
return { _tag: 'Err', error: unexpectedFailure(cause) };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
async function nonInteractiveBare(runtime, globals) {
|
|
62
|
+
const [auth, apiUrl, config] = await Promise.all([
|
|
63
|
+
getCredentialStatus({ env: runtime.env, paths: runtime.paths }),
|
|
64
|
+
resolveApiUrl({ apiUrl: globals.apiUrl, env: runtime.env, paths: runtime.paths }),
|
|
65
|
+
readConfig({ paths: runtime.paths }),
|
|
66
|
+
]);
|
|
67
|
+
if (auth._tag === 'Err')
|
|
68
|
+
return fromStateError(auth.error);
|
|
69
|
+
if (apiUrl._tag === 'Err')
|
|
70
|
+
return fromStateError(apiUrl.error);
|
|
71
|
+
if (config._tag === 'Err')
|
|
72
|
+
return fromStateError(config.error);
|
|
73
|
+
const site = resolveSiteId({ siteId: globals.siteId, env: runtime.env, config: config.value });
|
|
74
|
+
if (site._tag === 'Err')
|
|
75
|
+
return fail(EXIT_CODE.invalidInput, `${site.error._tag}: ${site.error.message}`);
|
|
76
|
+
if (globals.json) {
|
|
77
|
+
writeCliResponse(runtime, {
|
|
78
|
+
_tag: 'CliStatus',
|
|
79
|
+
schemaVersion: 1,
|
|
80
|
+
cliVersion: VERSION,
|
|
81
|
+
authentication: auth.value,
|
|
82
|
+
api: { url: apiUrl.value.apiUrl, source: apiUrl.value.source },
|
|
83
|
+
site: site.value,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
writeOutput(runtime, await rootUsage(runtime, globals));
|
|
88
|
+
writeOutput(runtime, [
|
|
89
|
+
`Authentication: ${auth.value.authenticated ? auth.value.source : 'not configured'}`,
|
|
90
|
+
`API: ${apiUrl.value.apiUrl}`,
|
|
91
|
+
`Selected Site: ${site.value?.siteId ?? 'none'}`,
|
|
92
|
+
].join('\n'));
|
|
93
|
+
}
|
|
94
|
+
return ok(undefined);
|
|
95
|
+
}
|
|
96
|
+
async function switchSite(runtime, globals) {
|
|
97
|
+
const api = await loadApiContext(runtime, globals);
|
|
98
|
+
if (api._tag === 'Err')
|
|
99
|
+
return api;
|
|
100
|
+
const listed = await api.value.client.sites.list({}, { signal: runtime.requestSignal });
|
|
101
|
+
if (listed._tag === 'Err')
|
|
102
|
+
return fromSdkFailure(listed.error);
|
|
103
|
+
if (listed.value.data.sites.length === 0)
|
|
104
|
+
return fail(EXIT_CODE.notFound, 'site_empty: This token has no accessible Sites.', undefined, 'site_empty');
|
|
105
|
+
const selected = await prompts.select({
|
|
106
|
+
message: 'Current Site',
|
|
107
|
+
options: listed.value.data.sites.map(site => ({
|
|
108
|
+
value: site.id,
|
|
109
|
+
label: site.name ?? site.url ?? site.id,
|
|
110
|
+
hint: site.id,
|
|
111
|
+
})),
|
|
112
|
+
initialValue: api.value.config.siteId,
|
|
113
|
+
input: runtime.input,
|
|
114
|
+
output: runtime.error,
|
|
115
|
+
signal: runtime.signal,
|
|
116
|
+
});
|
|
117
|
+
if (prompts.isCancel(selected))
|
|
118
|
+
return fail(EXIT_CODE.interrupted, 'interrupted: Site selection cancelled.');
|
|
119
|
+
const saved = await updateConfig({ siteId: selected }, { paths: runtime.paths });
|
|
120
|
+
if (saved._tag === 'Err')
|
|
121
|
+
return fromStateError(saved.error);
|
|
122
|
+
writeOutput(runtime, `Selected Site: ${selected}`);
|
|
123
|
+
return ok(undefined);
|
|
124
|
+
}
|
|
125
|
+
async function promptPageUrl(runtime, action) {
|
|
126
|
+
const url = await prompts.text({
|
|
127
|
+
message: `${action} which Page?`,
|
|
128
|
+
placeholder: 'https://example.com/page',
|
|
129
|
+
input: runtime.input,
|
|
130
|
+
output: runtime.error,
|
|
131
|
+
signal: runtime.signal,
|
|
132
|
+
validate: value => typeof value === 'string' && URL.canParse(value) ? undefined : 'Enter an absolute URL.',
|
|
133
|
+
});
|
|
134
|
+
return prompts.isCancel(url)
|
|
135
|
+
? fail(EXIT_CODE.interrupted, `interrupted: ${action} cancelled.`)
|
|
136
|
+
: ok(url);
|
|
137
|
+
}
|
|
138
|
+
async function interactiveBare(runtime, globals) {
|
|
139
|
+
const [auth, apiUrl, config] = await Promise.all([
|
|
140
|
+
getCredentialStatus({ env: runtime.env, paths: runtime.paths }),
|
|
141
|
+
resolveApiUrl({ apiUrl: globals.apiUrl, env: runtime.env, paths: runtime.paths }),
|
|
142
|
+
readConfig({ paths: runtime.paths }),
|
|
143
|
+
]);
|
|
144
|
+
if (auth._tag === 'Err')
|
|
145
|
+
return fromStateError(auth.error);
|
|
146
|
+
if (apiUrl._tag === 'Err')
|
|
147
|
+
return fromStateError(apiUrl.error);
|
|
148
|
+
if (config._tag === 'Err')
|
|
149
|
+
return fromStateError(config.error);
|
|
150
|
+
prompts.intro(`NuxtSEO ${VERSION}`, { input: runtime.input, output: runtime.error });
|
|
151
|
+
prompts.note([
|
|
152
|
+
`Authentication: ${auth.value.authenticated ? auth.value.source : 'not configured'}`,
|
|
153
|
+
`API: ${apiUrl.value.apiUrl}`,
|
|
154
|
+
`Current Site: ${config.value.siteId ?? 'none'}`,
|
|
155
|
+
].join('\n'), 'Status', { input: runtime.input, output: runtime.error });
|
|
156
|
+
const selected = await prompts.select({
|
|
157
|
+
message: 'What would you like to do?',
|
|
158
|
+
options: [
|
|
159
|
+
...(!auth.value.authenticated ? [{ value: 'login', label: 'Log in', hint: 'Team API token' }] : []),
|
|
160
|
+
{ value: 'site', label: 'Switch Site', hint: config.value.siteId ?? 'none selected' },
|
|
161
|
+
{ value: 'actions', label: 'Next actions' },
|
|
162
|
+
{ value: 'inspect', label: 'Inspect a Page' },
|
|
163
|
+
{ value: 'scan', label: 'Scan a Page' },
|
|
164
|
+
{ value: 'performance', label: 'Performance overview' },
|
|
165
|
+
{ value: 'search', label: 'Search Console status' },
|
|
166
|
+
{ value: 'usage', label: 'Account usage' },
|
|
167
|
+
{ value: 'config', label: 'Configuration' },
|
|
168
|
+
{ value: 'exit', label: 'Exit' },
|
|
169
|
+
],
|
|
170
|
+
input: runtime.input,
|
|
171
|
+
output: runtime.error,
|
|
172
|
+
signal: runtime.signal,
|
|
173
|
+
});
|
|
174
|
+
if (prompts.isCancel(selected) || selected === 'exit')
|
|
175
|
+
return ok(undefined);
|
|
176
|
+
if (selected === 'site')
|
|
177
|
+
return switchSite(runtime, globals);
|
|
178
|
+
if (selected === 'inspect' || selected === 'scan') {
|
|
179
|
+
const url = await promptPageUrl(runtime, selected === 'inspect' ? 'Inspect' : 'Scan');
|
|
180
|
+
return url._tag === 'Err'
|
|
181
|
+
? url
|
|
182
|
+
: runExplicit(['page', selected, url.value], runtime, globals);
|
|
183
|
+
}
|
|
184
|
+
const commands = {
|
|
185
|
+
login: ['login'],
|
|
186
|
+
actions: ['actions', 'list'],
|
|
187
|
+
performance: ['performance'],
|
|
188
|
+
search: ['search', 'status'],
|
|
189
|
+
usage: ['usage'],
|
|
190
|
+
config: ['config'],
|
|
191
|
+
};
|
|
192
|
+
return runExplicit(commands[selected], runtime, globals);
|
|
193
|
+
}
|
|
194
|
+
export async function runCli(rawArgs, runtime) {
|
|
195
|
+
const jsonRequested = rawArgs.includes('--json');
|
|
196
|
+
const parsed = extractGlobalOptions(rawArgs);
|
|
197
|
+
if (parsed._tag === 'Err')
|
|
198
|
+
return report(runtime, parsed.error, jsonRequested);
|
|
199
|
+
const { args, options: globals } = parsed.value;
|
|
200
|
+
const effectiveRuntime = {
|
|
201
|
+
...runtime,
|
|
202
|
+
interactive: runtime.interactive && !globals.json && !globals.noInput,
|
|
203
|
+
requestSignal: AbortSignal.any([
|
|
204
|
+
runtime.requestSignal,
|
|
205
|
+
AbortSignal.timeout(globals.timeoutMs),
|
|
206
|
+
]),
|
|
207
|
+
};
|
|
208
|
+
if (args.length === 1 && (args[0] === '--version' || args[0] === '-v')) {
|
|
209
|
+
if (globals.json)
|
|
210
|
+
writeCliResponse(effectiveRuntime, { _tag: 'CliVersion', schemaVersion: 1, version: VERSION });
|
|
211
|
+
else
|
|
212
|
+
writeOutput(effectiveRuntime, VERSION);
|
|
213
|
+
return EXIT_CODE.success;
|
|
214
|
+
}
|
|
215
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
216
|
+
const command = createRootCommand(effectiveRuntime, globals, { result: null });
|
|
217
|
+
const validOptions = validateCommandOptions(command, args);
|
|
218
|
+
if (validOptions._tag === 'Err')
|
|
219
|
+
return report(effectiveRuntime, validOptions.error, globals.json);
|
|
220
|
+
if (globals.json)
|
|
221
|
+
writeCliResponse(effectiveRuntime, describeCommand(command, args, VERSION));
|
|
222
|
+
else
|
|
223
|
+
writeOutput(effectiveRuntime, await requestedUsage(effectiveRuntime, globals, args));
|
|
224
|
+
return EXIT_CODE.success;
|
|
225
|
+
}
|
|
226
|
+
const result = args.length === 0
|
|
227
|
+
? effectiveRuntime.interactive
|
|
228
|
+
? await interactiveBare(effectiveRuntime, globals)
|
|
229
|
+
: await nonInteractiveBare(effectiveRuntime, globals)
|
|
230
|
+
: await runExplicit(args, effectiveRuntime, globals);
|
|
231
|
+
return result._tag === 'Ok' ? EXIT_CODE.success : report(effectiveRuntime, result.error, globals.json);
|
|
232
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { CommandDef } from 'citty';
|
|
2
|
+
import type { CliResult } from './failures.js';
|
|
3
|
+
export interface ResolvedCommand {
|
|
4
|
+
command: CommandDef<any>;
|
|
5
|
+
path: string[];
|
|
6
|
+
remaining: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
export interface CliHelpArgument {
|
|
9
|
+
name: string;
|
|
10
|
+
type: 'boolean' | 'enum' | 'positional' | 'string';
|
|
11
|
+
required: boolean;
|
|
12
|
+
description?: string;
|
|
13
|
+
valueHint?: string;
|
|
14
|
+
aliases?: string[];
|
|
15
|
+
options?: string[];
|
|
16
|
+
}
|
|
17
|
+
export interface CliHelpResponse {
|
|
18
|
+
_tag: 'CliHelp';
|
|
19
|
+
schemaVersion: 1;
|
|
20
|
+
cliVersion: string;
|
|
21
|
+
command: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
globalOptions: CliHelpArgument[];
|
|
24
|
+
arguments: CliHelpArgument[];
|
|
25
|
+
subcommands: Array<{
|
|
26
|
+
name: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
}>;
|
|
29
|
+
}
|
|
30
|
+
export declare function resolveCommand(root: CommandDef<any>, rawArgs: readonly string[]): ResolvedCommand;
|
|
31
|
+
export declare function describeCommand(root: CommandDef<any>, rawArgs: readonly string[], cliVersion: string): CliHelpResponse;
|
|
32
|
+
export declare function validateCommandOptions(root: CommandDef<any>, rawArgs: readonly string[]): CliResult<void>;
|
|
33
|
+
export declare function commandWithGlobalOptions(root: CommandDef<any>, command: CommandDef<any>): CommandDef<any>;
|