@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
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { StatePaths } from './paths.js';
|
|
2
|
+
import type { StateResult } from './result.js';
|
|
3
|
+
export type CredentialSource = 'env' | 'keychain' | 'file';
|
|
4
|
+
export type CredentialStorage = Exclude<CredentialSource, 'env'>;
|
|
5
|
+
export interface KeyringEntry {
|
|
6
|
+
getPassword: () => string | null | Promise<string | null>;
|
|
7
|
+
setPassword: (password: string) => void | Promise<void>;
|
|
8
|
+
deletePassword: () => boolean | Promise<boolean>;
|
|
9
|
+
}
|
|
10
|
+
export type KeyringEntryConstructor = new (service: string, account: string) => KeyringEntry;
|
|
11
|
+
export type KeyringLoader = () => Promise<KeyringEntryConstructor | null>;
|
|
12
|
+
export interface AuthStateOptions {
|
|
13
|
+
paths?: StatePaths;
|
|
14
|
+
loadKeyring?: KeyringLoader;
|
|
15
|
+
}
|
|
16
|
+
export interface ResolveCredentialOptions extends AuthStateOptions {
|
|
17
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
18
|
+
}
|
|
19
|
+
export interface ResolvedCredential {
|
|
20
|
+
token: string;
|
|
21
|
+
source: CredentialSource;
|
|
22
|
+
}
|
|
23
|
+
export interface CredentialStatus {
|
|
24
|
+
authenticated: boolean;
|
|
25
|
+
source?: CredentialSource;
|
|
26
|
+
}
|
|
27
|
+
export interface FileCredentialFallbackWarning {
|
|
28
|
+
_tag: 'FileCredentialFallback';
|
|
29
|
+
reason: 'unavailable' | 'write-failed';
|
|
30
|
+
path: string;
|
|
31
|
+
message: string;
|
|
32
|
+
}
|
|
33
|
+
export interface SavedCredential {
|
|
34
|
+
storage: CredentialStorage;
|
|
35
|
+
warning?: FileCredentialFallbackWarning;
|
|
36
|
+
}
|
|
37
|
+
export interface ClearedCredential {
|
|
38
|
+
removed: boolean;
|
|
39
|
+
}
|
|
40
|
+
export declare function loadOptionalKeyring(): Promise<KeyringEntryConstructor | null>;
|
|
41
|
+
export declare function loadStoredCredential(options?: AuthStateOptions): Promise<StateResult<ResolvedCredential | null>>;
|
|
42
|
+
export declare function resolveCredential(options: ResolveCredentialOptions): Promise<StateResult<ResolvedCredential | null>>;
|
|
43
|
+
export declare function getCredentialStatus(options: ResolveCredentialOptions): Promise<StateResult<CredentialStatus>>;
|
|
44
|
+
export declare function saveCredential(value: string, options?: AuthStateOptions): Promise<StateResult<SavedCredential>>;
|
|
45
|
+
export declare function clearCredential(options?: AuthStateOptions): Promise<StateResult<ClearedCredential>>;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import { invalidState, readStateJson, removeStateFile, writeStateJson } from './files.js';
|
|
10
|
+
import { defaultStatePaths } from './paths.js';
|
|
11
|
+
import { err, ok } from './result.js';
|
|
12
|
+
const AUTH_SCHEMA_VERSION = 1;
|
|
13
|
+
const KEYRING_SERVICE = 'nuxtseo';
|
|
14
|
+
const KEYRING_ACCOUNT = 'team-api-token';
|
|
15
|
+
function isRecord(value) {
|
|
16
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
function parseToken(value) {
|
|
19
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
20
|
+
}
|
|
21
|
+
function parseAuthMarker(value, paths) {
|
|
22
|
+
if (!isRecord(value))
|
|
23
|
+
return invalidState(paths, 'auth', 'expected a JSON object.');
|
|
24
|
+
if (value.version !== AUTH_SCHEMA_VERSION)
|
|
25
|
+
return invalidState(paths, 'auth', `expected version ${AUTH_SCHEMA_VERSION}.`);
|
|
26
|
+
if (value.storage === 'keychain') {
|
|
27
|
+
const unknownKey = Object.keys(value).find(key => key !== 'version' && key !== 'storage');
|
|
28
|
+
return unknownKey
|
|
29
|
+
? invalidState(paths, 'auth', `unknown field ${JSON.stringify(unknownKey)}.`)
|
|
30
|
+
: ok({ version: AUTH_SCHEMA_VERSION, storage: 'keychain' });
|
|
31
|
+
}
|
|
32
|
+
if (value.storage === 'file') {
|
|
33
|
+
const unknownKey = Object.keys(value).find(key => key !== 'version' && key !== 'storage' && key !== 'token');
|
|
34
|
+
if (unknownKey)
|
|
35
|
+
return invalidState(paths, 'auth', `unknown field ${JSON.stringify(unknownKey)}.`);
|
|
36
|
+
const token = parseToken(value.token);
|
|
37
|
+
return token
|
|
38
|
+
? ok({ version: AUTH_SCHEMA_VERSION, storage: 'file', token })
|
|
39
|
+
: invalidState(paths, 'auth', 'file credentials require a non-empty token.');
|
|
40
|
+
}
|
|
41
|
+
return invalidState(paths, 'auth', 'storage must be "keychain" or "file".');
|
|
42
|
+
}
|
|
43
|
+
async function readAuthMarker(paths) {
|
|
44
|
+
const content = await readStateJson(paths, 'auth');
|
|
45
|
+
if (content._tag === 'Err')
|
|
46
|
+
return content;
|
|
47
|
+
if (content.value === null)
|
|
48
|
+
return ok(null);
|
|
49
|
+
return parseAuthMarker(content.value, paths);
|
|
50
|
+
}
|
|
51
|
+
export async function loadOptionalKeyring() {
|
|
52
|
+
// This package is optional because native keychain support is not available on
|
|
53
|
+
// every platform. A static import would prevent the documented file fallback.
|
|
54
|
+
const moduleName = '@napi-rs/keyring';
|
|
55
|
+
const loaded = await import(__rewriteRelativeImportExtension(moduleName)).catch(() => {
|
|
56
|
+
// Missing native support is expected and selects secure file storage.
|
|
57
|
+
return null;
|
|
58
|
+
});
|
|
59
|
+
if (!loaded || typeof loaded.Entry !== 'function')
|
|
60
|
+
return null;
|
|
61
|
+
return loaded.Entry;
|
|
62
|
+
}
|
|
63
|
+
function keyringFailure(operation, paths, cause) {
|
|
64
|
+
return err({
|
|
65
|
+
_tag: 'KeychainFailure',
|
|
66
|
+
operation,
|
|
67
|
+
path: paths.authFile,
|
|
68
|
+
message: `Could not ${operation} the NuxtSEO credential in the OS keychain. Local auth state is recorded at ${paths.authFile}.`,
|
|
69
|
+
cause,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
async function resolveKeyring(options, paths) {
|
|
73
|
+
const Keyring = await (options.loadKeyring ?? loadOptionalKeyring)().catch(cause => cause);
|
|
74
|
+
if (typeof Keyring !== 'function') {
|
|
75
|
+
return err({
|
|
76
|
+
_tag: 'KeychainUnavailable',
|
|
77
|
+
path: paths.authFile,
|
|
78
|
+
message: `OS keychain support is unavailable. Local auth state is recorded at ${paths.authFile}.`,
|
|
79
|
+
...(Keyring === null ? {} : { cause: Keyring }),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return ok(Keyring);
|
|
83
|
+
}
|
|
84
|
+
export async function loadStoredCredential(options = {}) {
|
|
85
|
+
const paths = options.paths ?? defaultStatePaths();
|
|
86
|
+
const marker = await readAuthMarker(paths);
|
|
87
|
+
if (marker._tag === 'Err')
|
|
88
|
+
return marker;
|
|
89
|
+
if (marker.value === null)
|
|
90
|
+
return ok(null);
|
|
91
|
+
if (marker.value.storage === 'file')
|
|
92
|
+
return ok({ token: marker.value.token, source: 'file' });
|
|
93
|
+
const keyring = await resolveKeyring(options, paths);
|
|
94
|
+
if (keyring._tag === 'Err')
|
|
95
|
+
return keyring;
|
|
96
|
+
const Entry = keyring.value;
|
|
97
|
+
const read = await Promise.resolve()
|
|
98
|
+
.then(() => new Entry(KEYRING_SERVICE, KEYRING_ACCOUNT).getPassword())
|
|
99
|
+
.then(value => ok(value))
|
|
100
|
+
.catch(cause => keyringFailure('read', paths, cause));
|
|
101
|
+
if (read._tag === 'Err')
|
|
102
|
+
return read;
|
|
103
|
+
const parsedToken = parseToken(read.value);
|
|
104
|
+
return parsedToken
|
|
105
|
+
? ok({ token: parsedToken, source: 'keychain' })
|
|
106
|
+
: err({
|
|
107
|
+
_tag: 'KeychainFailure',
|
|
108
|
+
operation: 'read',
|
|
109
|
+
path: paths.authFile,
|
|
110
|
+
message: `The OS keychain credential referenced by ${paths.authFile} is missing. Run nuxtseo login to replace it.`,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
export async function resolveCredential(options) {
|
|
114
|
+
const environmentToken = options.env.NUXTSEO_TOKEN;
|
|
115
|
+
if (environmentToken !== undefined) {
|
|
116
|
+
const token = parseToken(environmentToken);
|
|
117
|
+
return token
|
|
118
|
+
? ok({ token, source: 'env' })
|
|
119
|
+
: err({
|
|
120
|
+
_tag: 'InvalidCredential',
|
|
121
|
+
source: 'environment',
|
|
122
|
+
message: 'NUXTSEO_TOKEN is set but empty.',
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return loadStoredCredential(options);
|
|
126
|
+
}
|
|
127
|
+
export async function getCredentialStatus(options) {
|
|
128
|
+
const environmentToken = options.env.NUXTSEO_TOKEN;
|
|
129
|
+
if (environmentToken !== undefined) {
|
|
130
|
+
return parseToken(environmentToken)
|
|
131
|
+
? ok({ authenticated: true, source: 'env' })
|
|
132
|
+
: err({
|
|
133
|
+
_tag: 'InvalidCredential',
|
|
134
|
+
source: 'environment',
|
|
135
|
+
message: 'NUXTSEO_TOKEN is set but empty.',
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const credential = await loadStoredCredential(options);
|
|
139
|
+
if (credential._tag === 'Err')
|
|
140
|
+
return credential;
|
|
141
|
+
return credential.value
|
|
142
|
+
? ok({ authenticated: true, source: credential.value.source })
|
|
143
|
+
: ok({ authenticated: false });
|
|
144
|
+
}
|
|
145
|
+
export async function saveCredential(value, options = {}) {
|
|
146
|
+
const token = parseToken(value);
|
|
147
|
+
if (!token) {
|
|
148
|
+
return err({
|
|
149
|
+
_tag: 'InvalidCredential',
|
|
150
|
+
source: 'input',
|
|
151
|
+
message: 'The NuxtSEO API token cannot be empty.',
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const paths = options.paths ?? defaultStatePaths();
|
|
155
|
+
const existing = await readAuthMarker(paths);
|
|
156
|
+
if (existing._tag === 'Err')
|
|
157
|
+
return existing;
|
|
158
|
+
const keyring = await resolveKeyring(options, paths);
|
|
159
|
+
let fallbackReason = 'unavailable';
|
|
160
|
+
if (keyring._tag === 'Ok') {
|
|
161
|
+
const Entry = keyring.value;
|
|
162
|
+
const stored = await Promise.resolve()
|
|
163
|
+
.then(() => {
|
|
164
|
+
const entry = new Entry(KEYRING_SERVICE, KEYRING_ACCOUNT);
|
|
165
|
+
return Promise.resolve(entry.setPassword(token)).then(() => entry);
|
|
166
|
+
})
|
|
167
|
+
.then(entry => ok(entry))
|
|
168
|
+
.catch(cause => keyringFailure('write', paths, cause));
|
|
169
|
+
if (stored._tag === 'Ok') {
|
|
170
|
+
const marker = await writeStateJson(paths, 'auth', {
|
|
171
|
+
version: AUTH_SCHEMA_VERSION,
|
|
172
|
+
storage: 'keychain',
|
|
173
|
+
});
|
|
174
|
+
if (marker._tag === 'Ok')
|
|
175
|
+
return ok({ storage: 'keychain' });
|
|
176
|
+
// The marker is required to find this keychain entry later. Removing the
|
|
177
|
+
// just-written secret is best-effort while preserving the filesystem error.
|
|
178
|
+
await Promise.resolve(stored.value.deletePassword()).catch(() => false);
|
|
179
|
+
return marker;
|
|
180
|
+
}
|
|
181
|
+
fallbackReason = 'write-failed';
|
|
182
|
+
}
|
|
183
|
+
const written = await writeStateJson(paths, 'auth', {
|
|
184
|
+
version: AUTH_SCHEMA_VERSION,
|
|
185
|
+
storage: 'file',
|
|
186
|
+
token,
|
|
187
|
+
});
|
|
188
|
+
if (written._tag === 'Err')
|
|
189
|
+
return written;
|
|
190
|
+
return ok({
|
|
191
|
+
storage: 'file',
|
|
192
|
+
warning: {
|
|
193
|
+
_tag: 'FileCredentialFallback',
|
|
194
|
+
reason: fallbackReason,
|
|
195
|
+
path: paths.authFile,
|
|
196
|
+
message: `${fallbackReason === 'unavailable' ? 'OS keychain support is unavailable' : 'The OS keychain could not store the credential'}. The token was stored in ${paths.authFile} with mode 0600.`,
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
export async function clearCredential(options = {}) {
|
|
201
|
+
const paths = options.paths ?? defaultStatePaths();
|
|
202
|
+
const marker = await readAuthMarker(paths);
|
|
203
|
+
if (marker._tag === 'Err')
|
|
204
|
+
return marker;
|
|
205
|
+
if (marker.value === null)
|
|
206
|
+
return ok({ removed: false });
|
|
207
|
+
if (marker.value.storage === 'keychain') {
|
|
208
|
+
const keyring = await resolveKeyring(options, paths);
|
|
209
|
+
if (keyring._tag === 'Err')
|
|
210
|
+
return keyring;
|
|
211
|
+
const Entry = keyring.value;
|
|
212
|
+
const entry = new Entry(KEYRING_SERVICE, KEYRING_ACCOUNT);
|
|
213
|
+
const existing = await Promise.resolve()
|
|
214
|
+
.then(() => entry.getPassword())
|
|
215
|
+
.then(value => ok(value))
|
|
216
|
+
.catch(cause => keyringFailure('read', paths, cause));
|
|
217
|
+
if (existing._tag === 'Err')
|
|
218
|
+
return existing;
|
|
219
|
+
if (parseToken(existing.value)) {
|
|
220
|
+
const removed = await Promise.resolve()
|
|
221
|
+
.then(() => entry.deletePassword())
|
|
222
|
+
.then(() => ok(undefined))
|
|
223
|
+
.catch(cause => keyringFailure('delete', paths, cause));
|
|
224
|
+
if (removed._tag === 'Err')
|
|
225
|
+
return removed;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const removedMarker = await removeStateFile(paths, 'auth');
|
|
229
|
+
return removedMarker._tag === 'Err'
|
|
230
|
+
? removedMarker
|
|
231
|
+
: ok({ removed: removedMarker.value });
|
|
232
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { StatePaths } from './paths.js';
|
|
2
|
+
import type { StateResult } from './result.js';
|
|
3
|
+
export declare const PRODUCTION_API_URL = "https://nuxtseo.com";
|
|
4
|
+
export interface NuxtSeoConfig {
|
|
5
|
+
apiUrl?: string;
|
|
6
|
+
siteId?: string;
|
|
7
|
+
}
|
|
8
|
+
export type ApiUrlSource = 'argument' | 'environment' | 'config' | 'default';
|
|
9
|
+
export interface ResolvedApiUrl {
|
|
10
|
+
apiUrl: string;
|
|
11
|
+
source: ApiUrlSource;
|
|
12
|
+
}
|
|
13
|
+
export type SiteIdSource = 'argument' | 'environment' | 'config';
|
|
14
|
+
export interface ResolvedSiteId {
|
|
15
|
+
siteId: string;
|
|
16
|
+
source: SiteIdSource;
|
|
17
|
+
}
|
|
18
|
+
export interface InvalidSiteIdError {
|
|
19
|
+
_tag: 'InvalidSiteId';
|
|
20
|
+
source: Exclude<SiteIdSource, 'config'>;
|
|
21
|
+
message: string;
|
|
22
|
+
}
|
|
23
|
+
export interface ConfigStateOptions {
|
|
24
|
+
paths?: StatePaths;
|
|
25
|
+
}
|
|
26
|
+
export interface ResolveApiUrlOptions extends ConfigStateOptions {
|
|
27
|
+
apiUrl?: string;
|
|
28
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
29
|
+
}
|
|
30
|
+
export interface ResolveSiteIdOptions {
|
|
31
|
+
siteId?: string;
|
|
32
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
33
|
+
config: NuxtSeoConfig;
|
|
34
|
+
}
|
|
35
|
+
export declare function readConfig(options?: ConfigStateOptions): Promise<StateResult<NuxtSeoConfig>>;
|
|
36
|
+
export declare function writeConfig(config: NuxtSeoConfig, options?: ConfigStateOptions): Promise<StateResult<NuxtSeoConfig>>;
|
|
37
|
+
export declare function updateConfig(patch: Partial<NuxtSeoConfig>, options?: ConfigStateOptions): Promise<StateResult<NuxtSeoConfig>>;
|
|
38
|
+
export declare function resolveApiUrl(options: ResolveApiUrlOptions): Promise<StateResult<ResolvedApiUrl>>;
|
|
39
|
+
export declare function resolveSiteId(options: ResolveSiteIdOptions): StateResult<ResolvedSiteId | null, InvalidSiteIdError>;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { invalidState, readStateJson, writeStateJson } from './files.js';
|
|
2
|
+
import { defaultStatePaths } from './paths.js';
|
|
3
|
+
import { err, ok } from './result.js';
|
|
4
|
+
export const PRODUCTION_API_URL = 'https://nuxtseo.com';
|
|
5
|
+
function isRecord(value) {
|
|
6
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
function normalizeApiUrl(value) {
|
|
9
|
+
const input = value.trim();
|
|
10
|
+
if (!input)
|
|
11
|
+
return null;
|
|
12
|
+
const parsed = URL.parse(input);
|
|
13
|
+
if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:'))
|
|
14
|
+
return null;
|
|
15
|
+
if (parsed.username || parsed.password || parsed.search || parsed.hash)
|
|
16
|
+
return null;
|
|
17
|
+
return parsed.toString().replace(/\/+$/, '');
|
|
18
|
+
}
|
|
19
|
+
function parseConfig(value, paths) {
|
|
20
|
+
if (!isRecord(value))
|
|
21
|
+
return invalidState(paths, 'config', 'expected a JSON object.');
|
|
22
|
+
const unknownKey = Object.keys(value).find(key => key !== 'apiUrl' && key !== 'siteId');
|
|
23
|
+
if (unknownKey)
|
|
24
|
+
return invalidState(paths, 'config', `unknown field ${JSON.stringify(unknownKey)}.`);
|
|
25
|
+
const config = {};
|
|
26
|
+
if (value.apiUrl !== undefined) {
|
|
27
|
+
if (typeof value.apiUrl !== 'string')
|
|
28
|
+
return invalidState(paths, 'config', 'apiUrl must be a string.');
|
|
29
|
+
const apiUrl = normalizeApiUrl(value.apiUrl);
|
|
30
|
+
if (!apiUrl)
|
|
31
|
+
return invalidState(paths, 'config', 'apiUrl must be an HTTP or HTTPS URL without credentials, a query, or a fragment.');
|
|
32
|
+
config.apiUrl = apiUrl;
|
|
33
|
+
}
|
|
34
|
+
if (value.siteId !== undefined) {
|
|
35
|
+
if (typeof value.siteId !== 'string' || !value.siteId.trim())
|
|
36
|
+
return invalidState(paths, 'config', 'siteId must be a non-empty string.');
|
|
37
|
+
config.siteId = value.siteId.trim();
|
|
38
|
+
}
|
|
39
|
+
return ok(config);
|
|
40
|
+
}
|
|
41
|
+
function parseConfigInput(config) {
|
|
42
|
+
const parsed = {};
|
|
43
|
+
if (config.apiUrl !== undefined) {
|
|
44
|
+
const apiUrl = normalizeApiUrl(config.apiUrl);
|
|
45
|
+
if (!apiUrl) {
|
|
46
|
+
return err({
|
|
47
|
+
_tag: 'InvalidConfig',
|
|
48
|
+
message: 'apiUrl must be an HTTP or HTTPS URL without credentials, a query, or a fragment.',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
parsed.apiUrl = apiUrl;
|
|
52
|
+
}
|
|
53
|
+
if (config.siteId !== undefined) {
|
|
54
|
+
if (typeof config.siteId !== 'string' || !config.siteId.trim())
|
|
55
|
+
return err({ _tag: 'InvalidConfig', message: 'siteId must be a non-empty string.' });
|
|
56
|
+
parsed.siteId = config.siteId.trim();
|
|
57
|
+
}
|
|
58
|
+
return ok(parsed);
|
|
59
|
+
}
|
|
60
|
+
export async function readConfig(options = {}) {
|
|
61
|
+
const paths = options.paths ?? defaultStatePaths();
|
|
62
|
+
const content = await readStateJson(paths, 'config');
|
|
63
|
+
if (content._tag === 'Err')
|
|
64
|
+
return content;
|
|
65
|
+
return content.value === null ? ok({}) : parseConfig(content.value, paths);
|
|
66
|
+
}
|
|
67
|
+
export async function writeConfig(config, options = {}) {
|
|
68
|
+
const parsed = parseConfigInput(config);
|
|
69
|
+
if (parsed._tag === 'Err')
|
|
70
|
+
return parsed;
|
|
71
|
+
const current = await readConfig(options);
|
|
72
|
+
if (current._tag === 'Err')
|
|
73
|
+
return current;
|
|
74
|
+
return persistConfig(parsed.value, options);
|
|
75
|
+
}
|
|
76
|
+
async function persistConfig(config, options) {
|
|
77
|
+
const paths = options.paths ?? defaultStatePaths();
|
|
78
|
+
const written = await writeStateJson(paths, 'config', config);
|
|
79
|
+
return written._tag === 'Err' ? written : ok(config);
|
|
80
|
+
}
|
|
81
|
+
export async function updateConfig(patch, options = {}) {
|
|
82
|
+
const current = await readConfig(options);
|
|
83
|
+
if (current._tag === 'Err')
|
|
84
|
+
return current;
|
|
85
|
+
const next = parseConfigInput({ ...current.value, ...patch });
|
|
86
|
+
if (next._tag === 'Err')
|
|
87
|
+
return next;
|
|
88
|
+
return persistConfig(next.value, options);
|
|
89
|
+
}
|
|
90
|
+
export async function resolveApiUrl(options) {
|
|
91
|
+
const argumentValue = options.apiUrl;
|
|
92
|
+
if (argumentValue !== undefined) {
|
|
93
|
+
const apiUrl = normalizeApiUrl(argumentValue);
|
|
94
|
+
return apiUrl
|
|
95
|
+
? ok({ apiUrl, source: 'argument' })
|
|
96
|
+
: err({
|
|
97
|
+
_tag: 'InvalidApiUrl',
|
|
98
|
+
source: 'argument',
|
|
99
|
+
message: '--api-url must be an HTTP or HTTPS URL without credentials, a query, or a fragment.',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const environmentValue = options.env.NUXTSEO_API_URL;
|
|
103
|
+
if (environmentValue !== undefined) {
|
|
104
|
+
const apiUrl = normalizeApiUrl(environmentValue);
|
|
105
|
+
return apiUrl
|
|
106
|
+
? ok({ apiUrl, source: 'environment' })
|
|
107
|
+
: err({
|
|
108
|
+
_tag: 'InvalidApiUrl',
|
|
109
|
+
source: 'environment',
|
|
110
|
+
message: 'NUXTSEO_API_URL must be an HTTP or HTTPS URL without credentials, a query, or a fragment.',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const config = await readConfig(options);
|
|
114
|
+
if (config._tag === 'Err')
|
|
115
|
+
return config;
|
|
116
|
+
return config.value.apiUrl
|
|
117
|
+
? ok({ apiUrl: config.value.apiUrl, source: 'config' })
|
|
118
|
+
: ok({ apiUrl: PRODUCTION_API_URL, source: 'default' });
|
|
119
|
+
}
|
|
120
|
+
export function resolveSiteId(options) {
|
|
121
|
+
if (options.siteId !== undefined) {
|
|
122
|
+
const siteId = options.siteId.trim();
|
|
123
|
+
return siteId
|
|
124
|
+
? ok({ siteId, source: 'argument' })
|
|
125
|
+
: err({
|
|
126
|
+
_tag: 'InvalidSiteId',
|
|
127
|
+
source: 'argument',
|
|
128
|
+
message: '--site cannot be empty.',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const environmentSiteId = options.env.NUXTSEO_SITE_ID;
|
|
132
|
+
if (environmentSiteId !== undefined) {
|
|
133
|
+
const siteId = environmentSiteId.trim();
|
|
134
|
+
return siteId
|
|
135
|
+
? ok({ siteId, source: 'environment' })
|
|
136
|
+
: err({
|
|
137
|
+
_tag: 'InvalidSiteId',
|
|
138
|
+
source: 'environment',
|
|
139
|
+
message: 'NUXTSEO_SITE_ID is set but empty.',
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return options.config.siteId
|
|
143
|
+
? ok({ siteId: options.config.siteId, source: 'config' })
|
|
144
|
+
: ok(null);
|
|
145
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { StatePaths } from './paths.js';
|
|
2
|
+
import type { StateKind, StateResult } from './result.js';
|
|
3
|
+
export declare function readStateJson(paths: StatePaths, kind: StateKind): Promise<StateResult<unknown | null>>;
|
|
4
|
+
export declare function invalidState(paths: StatePaths, kind: StateKind, detail: string, cause?: unknown): StateResult<never>;
|
|
5
|
+
export declare function writeStateJson(paths: StatePaths, kind: StateKind, value: unknown): Promise<StateResult<void>>;
|
|
6
|
+
export declare function removeStateFile(paths: StatePaths, kind: StateKind): Promise<StateResult<boolean>>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { err, ok } from './result.js';
|
|
4
|
+
function errorCode(cause) {
|
|
5
|
+
if (typeof cause !== 'object' || cause === null || !('code' in cause))
|
|
6
|
+
return undefined;
|
|
7
|
+
return typeof cause.code === 'string' ? cause.code : undefined;
|
|
8
|
+
}
|
|
9
|
+
function isMissing(cause) {
|
|
10
|
+
return errorCode(cause) === 'ENOENT';
|
|
11
|
+
}
|
|
12
|
+
function isReadOnly(cause) {
|
|
13
|
+
const code = errorCode(cause);
|
|
14
|
+
return code === 'EACCES' || code === 'EPERM' || code === 'EROFS';
|
|
15
|
+
}
|
|
16
|
+
function statePath(paths, kind) {
|
|
17
|
+
return kind === 'auth' ? paths.authFile : paths.configFile;
|
|
18
|
+
}
|
|
19
|
+
function corruptState(kind, path, detail, cause) {
|
|
20
|
+
const common = {
|
|
21
|
+
path,
|
|
22
|
+
message: `Could not parse NuxtSEO ${kind} state at ${path}: ${detail}`,
|
|
23
|
+
...(cause === undefined ? {} : { cause }),
|
|
24
|
+
};
|
|
25
|
+
return kind === 'auth'
|
|
26
|
+
? { _tag: 'CorruptAuth', ...common }
|
|
27
|
+
: { _tag: 'CorruptConfig', ...common };
|
|
28
|
+
}
|
|
29
|
+
export async function readStateJson(paths, kind) {
|
|
30
|
+
const path = statePath(paths, kind);
|
|
31
|
+
const content = await readFile(path, 'utf8').catch(cause => cause);
|
|
32
|
+
if (content instanceof Error || typeof content !== 'string') {
|
|
33
|
+
if (isMissing(content))
|
|
34
|
+
return ok(null);
|
|
35
|
+
return err({
|
|
36
|
+
_tag: 'StateFilesystemError',
|
|
37
|
+
kind,
|
|
38
|
+
operation: 'read',
|
|
39
|
+
path,
|
|
40
|
+
message: `Could not read NuxtSEO ${kind} state at ${path}.`,
|
|
41
|
+
cause: content,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (!content.trim())
|
|
45
|
+
return err(corruptState(kind, path, 'the file is empty.'));
|
|
46
|
+
const parsed = await Promise.resolve()
|
|
47
|
+
.then(() => JSON.parse(content))
|
|
48
|
+
.then(value => ok(value))
|
|
49
|
+
.catch(cause => invalidState(paths, kind, 'the file does not contain valid JSON.', cause));
|
|
50
|
+
return parsed;
|
|
51
|
+
}
|
|
52
|
+
export function invalidState(paths, kind, detail, cause) {
|
|
53
|
+
return err(corruptState(kind, statePath(paths, kind), detail, cause));
|
|
54
|
+
}
|
|
55
|
+
export async function writeStateJson(paths, kind, value) {
|
|
56
|
+
const path = statePath(paths, kind);
|
|
57
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
58
|
+
let temporaryCreated = false;
|
|
59
|
+
const written = await Promise.resolve()
|
|
60
|
+
.then(async () => {
|
|
61
|
+
await mkdir(paths.directory, { recursive: true, mode: 0o700 });
|
|
62
|
+
await chmod(paths.directory, 0o700);
|
|
63
|
+
await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, {
|
|
64
|
+
encoding: 'utf8',
|
|
65
|
+
flag: 'wx',
|
|
66
|
+
mode: 0o600,
|
|
67
|
+
});
|
|
68
|
+
temporaryCreated = true;
|
|
69
|
+
await chmod(temporaryPath, 0o600);
|
|
70
|
+
await rename(temporaryPath, path);
|
|
71
|
+
})
|
|
72
|
+
.then(() => ok(undefined))
|
|
73
|
+
.catch(cause => err(isReadOnly(cause)
|
|
74
|
+
? {
|
|
75
|
+
_tag: 'ReadOnlyFilesystem',
|
|
76
|
+
kind,
|
|
77
|
+
operation: 'write',
|
|
78
|
+
path,
|
|
79
|
+
message: `Could not write NuxtSEO ${kind} state at ${path}: the filesystem is read-only or permission was denied.`,
|
|
80
|
+
cause,
|
|
81
|
+
}
|
|
82
|
+
: {
|
|
83
|
+
_tag: 'StateFilesystemError',
|
|
84
|
+
kind,
|
|
85
|
+
operation: 'write',
|
|
86
|
+
path,
|
|
87
|
+
message: `Could not write NuxtSEO ${kind} state at ${path}.`,
|
|
88
|
+
cause,
|
|
89
|
+
}));
|
|
90
|
+
if (written._tag === 'Err' && temporaryCreated) {
|
|
91
|
+
// The destination remains untouched. Failure to remove this unique temp file
|
|
92
|
+
// does not hide the original actionable write error.
|
|
93
|
+
await rm(temporaryPath, { force: true }).catch(() => {
|
|
94
|
+
// A unique temp file is inert. Preserve the original actionable error.
|
|
95
|
+
return undefined;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return written;
|
|
99
|
+
}
|
|
100
|
+
export async function removeStateFile(paths, kind) {
|
|
101
|
+
const path = statePath(paths, kind);
|
|
102
|
+
return rm(path)
|
|
103
|
+
.then(() => ok(true))
|
|
104
|
+
.catch((cause) => {
|
|
105
|
+
if (isMissing(cause))
|
|
106
|
+
return ok(false);
|
|
107
|
+
return err(isReadOnly(cause)
|
|
108
|
+
? {
|
|
109
|
+
_tag: 'ReadOnlyFilesystem',
|
|
110
|
+
kind,
|
|
111
|
+
operation: 'delete',
|
|
112
|
+
path,
|
|
113
|
+
message: `Could not remove NuxtSEO ${kind} state at ${path}: the filesystem is read-only or permission was denied.`,
|
|
114
|
+
cause,
|
|
115
|
+
}
|
|
116
|
+
: {
|
|
117
|
+
_tag: 'StateFilesystemError',
|
|
118
|
+
kind,
|
|
119
|
+
operation: 'delete',
|
|
120
|
+
path,
|
|
121
|
+
message: `Could not remove NuxtSEO ${kind} state at ${path}.`,
|
|
122
|
+
cause,
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const STATE_DIRECTORY_NAME = ".nuxtseo";
|
|
2
|
+
export declare const AUTH_FILENAME = "auth.json";
|
|
3
|
+
export declare const CONFIG_FILENAME = "config.json";
|
|
4
|
+
export interface StatePaths {
|
|
5
|
+
directory: string;
|
|
6
|
+
authFile: string;
|
|
7
|
+
configFile: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function createStatePaths(homeDirectory: string): StatePaths;
|
|
10
|
+
export declare function defaultStatePaths(): StatePaths;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'pathe';
|
|
3
|
+
export const STATE_DIRECTORY_NAME = '.nuxtseo';
|
|
4
|
+
export const AUTH_FILENAME = 'auth.json';
|
|
5
|
+
export const CONFIG_FILENAME = 'config.json';
|
|
6
|
+
export function createStatePaths(homeDirectory) {
|
|
7
|
+
const directory = join(homeDirectory, STATE_DIRECTORY_NAME);
|
|
8
|
+
return {
|
|
9
|
+
directory,
|
|
10
|
+
authFile: join(directory, AUTH_FILENAME),
|
|
11
|
+
configFile: join(directory, CONFIG_FILENAME),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function defaultStatePaths() {
|
|
15
|
+
return createStatePaths(homedir());
|
|
16
|
+
}
|