@nuxtseo/cli 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -8
- package/dist/ansi.d.ts +8 -0
- package/dist/ansi.js +35 -0
- package/dist/cli.js +17 -21
- package/dist/commands.d.ts +7 -0
- package/dist/commands.js +91 -116
- package/dist/failures.d.ts +46 -6
- package/dist/failures.js +101 -10
- package/dist/pairing.js +2 -4
- package/dist/pull.d.ts +81 -0
- package/dist/pull.js +358 -0
- package/dist/render.d.ts +5 -9
- package/dist/render.js +1 -22
- package/dist/runtime.d.ts +5 -0
- package/dist/runtime.js +7 -2
- package/dist/skill.d.ts +99 -0
- package/dist/skill.js +372 -15
- package/dist/state/auth.js +2 -2
- package/dist/state/files.js +6 -6
- package/dist/update-check.d.ts +5 -0
- package/dist/update-check.js +18 -1
- package/package.json +4 -4
- package/skills/nuxtseo-cli/SKILL.md +187 -254
- package/skills/nuxtseo-cli/references/commands.md +111 -18
- package/skills/nuxtseo-cli/references/indexing.md +3 -2
- package/skills/nuxtseo-cli/references/protocol.md +84 -1
package/dist/runtime.d.ts
CHANGED
|
@@ -9,11 +9,16 @@ export interface CliRuntime {
|
|
|
9
9
|
inputIsTTY: boolean;
|
|
10
10
|
interactive: boolean;
|
|
11
11
|
signal: AbortSignal;
|
|
12
|
+
/** Read once per request. Each read carries its own `--timeout-ms` deadline. */
|
|
12
13
|
requestSignal: AbortSignal;
|
|
13
14
|
requestTimeoutMs?: number;
|
|
14
15
|
readStdin: () => Promise<string>;
|
|
15
16
|
}
|
|
16
17
|
export declare function writeOutput(runtime: CliRuntime, text: string): void;
|
|
17
18
|
export declare function writeDiagnostic(runtime: CliRuntime, text: string): void;
|
|
19
|
+
/**
|
|
20
|
+
* Machine output is written through these two functions only. Both strip ANSI
|
|
21
|
+
* escapes, so no dependency can colour a string into a JSON envelope.
|
|
22
|
+
*/
|
|
18
23
|
export declare function writeProtocolResponse(runtime: CliRuntime, response: unknown): void;
|
|
19
24
|
export declare function writeCliResponse(runtime: CliRuntime, response: unknown): void;
|
package/dist/runtime.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
+
import { stringifyWithoutAnsi } from './ansi.js';
|
|
1
2
|
export function writeOutput(runtime, text) {
|
|
2
3
|
runtime.output.write(text.endsWith('\n') ? text : `${text}\n`);
|
|
3
4
|
}
|
|
4
5
|
export function writeDiagnostic(runtime, text) {
|
|
5
6
|
runtime.error.write(text.endsWith('\n') ? text : `${text}\n`);
|
|
6
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Machine output is written through these two functions only. Both strip ANSI
|
|
10
|
+
* escapes, so no dependency can colour a string into a JSON envelope.
|
|
11
|
+
*/
|
|
7
12
|
export function writeProtocolResponse(runtime, response) {
|
|
8
|
-
writeOutput(runtime,
|
|
13
|
+
writeOutput(runtime, stringifyWithoutAnsi(response));
|
|
9
14
|
}
|
|
10
15
|
export function writeCliResponse(runtime, response) {
|
|
11
|
-
writeOutput(runtime,
|
|
16
|
+
writeOutput(runtime, stringifyWithoutAnsi(response));
|
|
12
17
|
}
|
package/dist/skill.d.ts
CHANGED
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
import type { CliResult } from './failures.js';
|
|
2
|
+
import type { StatePaths } from './state/index.js';
|
|
2
3
|
export declare const SKILL_NAME = "nuxtseo-cli";
|
|
4
|
+
/** The stamp an install leaves beside the skill so a later run can read its version. */
|
|
5
|
+
export declare const SKILL_VERSION_FILENAME = ".skill-version.json";
|
|
6
|
+
/** The skill entry file, which also carries the version in its frontmatter. */
|
|
7
|
+
export declare const SKILL_MARKDOWN_FILENAME = "SKILL.md";
|
|
8
|
+
/**
|
|
9
|
+
* How long one answer stays authoritative, matching the npm update check.
|
|
10
|
+
*
|
|
11
|
+
* Declared here rather than imported so `update-check.ts` can depend on this
|
|
12
|
+
* module and the two never form an import cycle.
|
|
13
|
+
*/
|
|
14
|
+
export declare const SKILL_CHECK_INTERVAL_MS: number;
|
|
3
15
|
export declare const SKILL_AGENTS: readonly ['claude', 'codex'];
|
|
4
16
|
export type SkillAgent = typeof SKILL_AGENTS[number];
|
|
5
17
|
export interface SkillInstallation {
|
|
6
18
|
agent: SkillAgent;
|
|
7
19
|
source: string;
|
|
20
|
+
/** The path the caller asked for, which may be a symlink. */
|
|
8
21
|
destination: string;
|
|
22
|
+
/** Where the files actually landed, after following a symlinked destination. */
|
|
23
|
+
resolvedDestination: string;
|
|
24
|
+
version: string;
|
|
25
|
+
/** Files written by this install. */
|
|
26
|
+
written: number;
|
|
27
|
+
/** Files removed because this release no longer ships them. */
|
|
28
|
+
pruned: number;
|
|
9
29
|
}
|
|
10
30
|
/**
|
|
11
31
|
* The skill shipped inside this package. A global install puts it outside the
|
|
@@ -14,9 +34,88 @@ export interface SkillInstallation {
|
|
|
14
34
|
*/
|
|
15
35
|
export declare function skillSourceDirectory(moduleUrl?: string): string;
|
|
16
36
|
export declare function skillDestination(homeDirectory: string, agent: SkillAgent): string;
|
|
37
|
+
/**
|
|
38
|
+
* The install target, followed through a symlink.
|
|
39
|
+
*
|
|
40
|
+
* People keep their skills in one directory and link each agent's skill folder
|
|
41
|
+
* at it. `fs.cp` lstats the target, sees a link rather than a directory, and
|
|
42
|
+
* refuses with `ERR_FS_CP_DIR_TO_NON_DIR` before it copies anything. That
|
|
43
|
+
* reported as a permission problem on a directory the user owned and could
|
|
44
|
+
* write. Follow the link first so the copy writes where the person pointed it.
|
|
45
|
+
*/
|
|
46
|
+
export declare function resolveSkillTarget(destination: string): Promise<string>;
|
|
47
|
+
/** An errno or Node error as a person can act on it: code, call, and path. */
|
|
48
|
+
export declare function describeIoCause(cause: unknown): string;
|
|
49
|
+
/**
|
|
50
|
+
* Write the running version into the skill frontmatter.
|
|
51
|
+
*
|
|
52
|
+
* An agent reads `SKILL.md` and nothing else, so the file has to say which
|
|
53
|
+
* release it came from. Without it a skill from an older CLI hides every
|
|
54
|
+
* command added since, and the agent cannot tell.
|
|
55
|
+
*/
|
|
56
|
+
export declare function stampSkillVersion(markdown: string, version: string): string;
|
|
57
|
+
/** The version recorded in a `SKILL.md` frontmatter, when it carries one. */
|
|
58
|
+
export declare function frontmatterVersion(markdown: string): string | null;
|
|
59
|
+
export type InstalledSkill = {
|
|
60
|
+
_tag: 'SkillMissing';
|
|
61
|
+
} | {
|
|
62
|
+
_tag: 'SkillUnversioned';
|
|
63
|
+
} | {
|
|
64
|
+
_tag: 'SkillVersion';
|
|
65
|
+
version: string;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Which version of the skill sits in a destination directory.
|
|
69
|
+
*
|
|
70
|
+
* Reads the stamp first, then the frontmatter, so a skill installed by hand or
|
|
71
|
+
* by an older release still reports something the caller can compare.
|
|
72
|
+
*/
|
|
73
|
+
export declare function readInstalledSkill(destination: string): Promise<InstalledSkill>;
|
|
74
|
+
export interface SkillNotice {
|
|
75
|
+
agent: SkillAgent;
|
|
76
|
+
installed: string | null;
|
|
77
|
+
current: string;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* A stale installed skill, or `null` when nothing needs saying.
|
|
81
|
+
*
|
|
82
|
+
* An absent skill is not stale: the person may not use one. Any other
|
|
83
|
+
* difference from the running binary is, in both directions, because the two
|
|
84
|
+
* describe different command sets.
|
|
85
|
+
*/
|
|
86
|
+
export declare function skillNoticeFor(installed: InstalledSkill, current: string, agent: SkillAgent): SkillNotice | null;
|
|
87
|
+
export declare function skillNoticeLine(notice: SkillNotice): string;
|
|
88
|
+
export interface SkillCheckCache {
|
|
89
|
+
lastCheckedAt: string;
|
|
90
|
+
cliVersion: string;
|
|
91
|
+
installed: Partial<Record<SkillAgent, string | null>>;
|
|
92
|
+
}
|
|
93
|
+
export declare function parseSkillCheckCache(content: string): SkillCheckCache | null;
|
|
94
|
+
/**
|
|
95
|
+
* Whether the filesystem has to be read again.
|
|
96
|
+
*
|
|
97
|
+
* A cache from a different binary is never reusable: the binary version is half
|
|
98
|
+
* of the comparison, so an upgrade must warn on the very next run.
|
|
99
|
+
*/
|
|
100
|
+
export declare function skillCheckDue(cache: SkillCheckCache | null, now: Date, current: string): boolean;
|
|
101
|
+
export interface SkillCheckOptions {
|
|
102
|
+
paths: StatePaths;
|
|
103
|
+
env?: Readonly<Record<string, string | undefined>>;
|
|
104
|
+
now?: () => Date;
|
|
105
|
+
/** The home the agent directories sit in; defaults to the state directory's parent. */
|
|
106
|
+
homeDirectory?: string;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* One line's worth of "your installed skill is not this binary".
|
|
110
|
+
*
|
|
111
|
+
* Costs one cached read per day, and nothing at all when the caller disabled
|
|
112
|
+
* update checks.
|
|
113
|
+
*/
|
|
114
|
+
export declare function checkSkillVersion(options: SkillCheckOptions): Promise<SkillNotice | null>;
|
|
17
115
|
export declare function installSkill(options: {
|
|
18
116
|
agent: SkillAgent;
|
|
19
117
|
homeDirectory: string;
|
|
20
118
|
target?: string;
|
|
21
119
|
sourceDirectory?: string;
|
|
120
|
+
paths?: StatePaths;
|
|
22
121
|
}): Promise<CliResult<SkillInstallation>>;
|
package/dist/skill.js
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { copyFile, mkdir, readdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import { dirname, join, relative } from 'pathe';
|
|
4
4
|
import { EXIT_CODE, fail, ok } from './failures.js';
|
|
5
|
+
import { VERSION } from './version.js';
|
|
5
6
|
export const SKILL_NAME = 'nuxtseo-cli';
|
|
7
|
+
/** The stamp an install leaves beside the skill so a later run can read its version. */
|
|
8
|
+
export const SKILL_VERSION_FILENAME = '.skill-version.json';
|
|
9
|
+
/** The skill entry file, which also carries the version in its frontmatter. */
|
|
10
|
+
export const SKILL_MARKDOWN_FILENAME = 'SKILL.md';
|
|
11
|
+
const SKILL_CHECK_FILENAME = 'skill-check.json';
|
|
12
|
+
/**
|
|
13
|
+
* How long one answer stays authoritative, matching the npm update check.
|
|
14
|
+
*
|
|
15
|
+
* Declared here rather than imported so `update-check.ts` can depend on this
|
|
16
|
+
* module and the two never form an import cycle.
|
|
17
|
+
*/
|
|
18
|
+
export const SKILL_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
19
|
+
/** The same escape hatch the npm update check honours. */
|
|
20
|
+
function checksDisabled(env) {
|
|
21
|
+
return env?.NUXTSEO_NO_UPDATE_CHECK === '1' || env?.NUXTSEO_NO_UPDATE_CHECK === 'true';
|
|
22
|
+
}
|
|
6
23
|
/**
|
|
7
24
|
* Directories inside the packaged skill that exist for this repository and not
|
|
8
25
|
* for the person installing it. `npm` keeps them out of the tarball through the
|
|
@@ -26,6 +43,314 @@ export function skillSourceDirectory(moduleUrl = import.meta.url) {
|
|
|
26
43
|
export function skillDestination(homeDirectory, agent) {
|
|
27
44
|
return join(homeDirectory, AGENT_DIRECTORIES[agent], 'skills', SKILL_NAME);
|
|
28
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* The install target, followed through a symlink.
|
|
48
|
+
*
|
|
49
|
+
* People keep their skills in one directory and link each agent's skill folder
|
|
50
|
+
* at it. `fs.cp` lstats the target, sees a link rather than a directory, and
|
|
51
|
+
* refuses with `ERR_FS_CP_DIR_TO_NON_DIR` before it copies anything. That
|
|
52
|
+
* reported as a permission problem on a directory the user owned and could
|
|
53
|
+
* write. Follow the link first so the copy writes where the person pointed it.
|
|
54
|
+
*/
|
|
55
|
+
export async function resolveSkillTarget(destination) {
|
|
56
|
+
return await realpath(destination).catch(() => {
|
|
57
|
+
// Nothing installed yet, so there is no link to follow. Create the literal path.
|
|
58
|
+
return destination;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** An errno or Node error as a person can act on it: code, call, and path. */
|
|
62
|
+
export function describeIoCause(cause) {
|
|
63
|
+
if (!cause || typeof cause !== 'object')
|
|
64
|
+
return String(cause);
|
|
65
|
+
const error = cause;
|
|
66
|
+
const parts = [
|
|
67
|
+
typeof error.code === 'string' ? error.code : undefined,
|
|
68
|
+
typeof error.syscall === 'string' ? `during ${error.syscall}` : undefined,
|
|
69
|
+
typeof error.path === 'string' ? `on ${error.path}` : undefined,
|
|
70
|
+
typeof error.dest === 'string' ? `to ${error.dest}` : undefined,
|
|
71
|
+
].filter((part) => part !== undefined);
|
|
72
|
+
if (parts.length > 0)
|
|
73
|
+
return parts.join(' ');
|
|
74
|
+
return typeof error.message === 'string' ? error.message : String(cause);
|
|
75
|
+
}
|
|
76
|
+
async function collectSkillFiles(root, directory = root) {
|
|
77
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
78
|
+
const collected = [];
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
const source = join(directory, entry.name);
|
|
81
|
+
const path = relative(root, source);
|
|
82
|
+
const [segment] = path.split('/');
|
|
83
|
+
if (segment && SKILL_LOCAL_ONLY.has(segment))
|
|
84
|
+
continue;
|
|
85
|
+
if (entry.isDirectory())
|
|
86
|
+
collected.push(...await collectSkillFiles(root, source));
|
|
87
|
+
else if (entry.isFile())
|
|
88
|
+
collected.push({ path, source });
|
|
89
|
+
}
|
|
90
|
+
return collected;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Write the running version into the skill frontmatter.
|
|
94
|
+
*
|
|
95
|
+
* An agent reads `SKILL.md` and nothing else, so the file has to say which
|
|
96
|
+
* release it came from. Without it a skill from an older CLI hides every
|
|
97
|
+
* command added since, and the agent cannot tell.
|
|
98
|
+
*/
|
|
99
|
+
export function stampSkillVersion(markdown, version) {
|
|
100
|
+
const stamp = `version: ${version}`;
|
|
101
|
+
const lines = markdown.split('\n');
|
|
102
|
+
if (lines[0]?.trim() !== '---')
|
|
103
|
+
return `---\n${stamp}\n---\n\n${markdown}`;
|
|
104
|
+
const closing = lines.findIndex((line, index) => index > 0 && line.trim() === '---');
|
|
105
|
+
if (closing === -1)
|
|
106
|
+
return `---\n${stamp}\n---\n\n${markdown}`;
|
|
107
|
+
const block = lines.slice(1, closing).filter(line => !/^version\s*:/.test(line));
|
|
108
|
+
return [lines[0], ...block, stamp, ...lines.slice(closing)].join('\n');
|
|
109
|
+
}
|
|
110
|
+
/** The version recorded in a `SKILL.md` frontmatter, when it carries one. */
|
|
111
|
+
export function frontmatterVersion(markdown) {
|
|
112
|
+
const lines = markdown.split('\n');
|
|
113
|
+
if (lines[0]?.trim() !== '---')
|
|
114
|
+
return null;
|
|
115
|
+
const closing = lines.findIndex((line, index) => index > 0 && line.trim() === '---');
|
|
116
|
+
if (closing === -1)
|
|
117
|
+
return null;
|
|
118
|
+
for (const line of lines.slice(1, closing)) {
|
|
119
|
+
const match = /^version\s*:\s*(\S+)\s*$/.exec(line);
|
|
120
|
+
if (match)
|
|
121
|
+
return match[1];
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
function parseVersionStamp(content) {
|
|
126
|
+
let value;
|
|
127
|
+
try {
|
|
128
|
+
value = JSON.parse(content);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// A corrupt stamp is disposable state; the frontmatter answers instead.
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
if (typeof value !== 'object' || value === null)
|
|
135
|
+
return null;
|
|
136
|
+
const version = value.version;
|
|
137
|
+
return typeof version === 'string' && version ? version : null;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The file manifest a previous install recorded in its stamp.
|
|
141
|
+
*
|
|
142
|
+
* Pruning may only ever touch paths this tool wrote itself. A stamp without a
|
|
143
|
+
* readable manifest (a hand copy, an older release, a mangled file) therefore
|
|
144
|
+
* parses as `null`, and `pruneRemoved` deletes nothing rather than guessing.
|
|
145
|
+
*/
|
|
146
|
+
function parseInstallManifest(content) {
|
|
147
|
+
let value;
|
|
148
|
+
try {
|
|
149
|
+
value = JSON.parse(content);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// Same deal as a corrupt version stamp: never prune on a guess.
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
if (typeof value !== 'object' || value === null)
|
|
156
|
+
return null;
|
|
157
|
+
const files = value.files;
|
|
158
|
+
if (!Array.isArray(files))
|
|
159
|
+
return null;
|
|
160
|
+
const manifest = new Set();
|
|
161
|
+
for (const file of files) {
|
|
162
|
+
if (typeof file !== 'string' || !file)
|
|
163
|
+
return null;
|
|
164
|
+
manifest.add(file);
|
|
165
|
+
}
|
|
166
|
+
return manifest;
|
|
167
|
+
}
|
|
168
|
+
async function readInstallManifest(destination) {
|
|
169
|
+
const content = await readFile(join(destination, SKILL_VERSION_FILENAME), 'utf8').catch(() => {
|
|
170
|
+
// No stamp: nothing was ever recorded, so nothing may be pruned.
|
|
171
|
+
return null;
|
|
172
|
+
});
|
|
173
|
+
return content === null ? null : parseInstallManifest(content);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Which version of the skill sits in a destination directory.
|
|
177
|
+
*
|
|
178
|
+
* Reads the stamp first, then the frontmatter, so a skill installed by hand or
|
|
179
|
+
* by an older release still reports something the caller can compare.
|
|
180
|
+
*/
|
|
181
|
+
export async function readInstalledSkill(destination) {
|
|
182
|
+
const stamp = await readFile(join(destination, SKILL_VERSION_FILENAME), 'utf8').catch(() => {
|
|
183
|
+
// No stamp means an older install or a hand copy. Fall through to the frontmatter.
|
|
184
|
+
return null;
|
|
185
|
+
});
|
|
186
|
+
const stamped = stamp === null ? null : parseVersionStamp(stamp);
|
|
187
|
+
if (stamped !== null)
|
|
188
|
+
return { _tag: 'SkillVersion', version: stamped };
|
|
189
|
+
const markdown = await readFile(join(destination, SKILL_MARKDOWN_FILENAME), 'utf8').catch(() => {
|
|
190
|
+
// No entry file means no skill is installed for this agent.
|
|
191
|
+
return null;
|
|
192
|
+
});
|
|
193
|
+
if (markdown === null)
|
|
194
|
+
return { _tag: 'SkillMissing' };
|
|
195
|
+
const declared = frontmatterVersion(markdown);
|
|
196
|
+
return declared === null ? { _tag: 'SkillUnversioned' } : { _tag: 'SkillVersion', version: declared };
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* A stale installed skill, or `null` when nothing needs saying.
|
|
200
|
+
*
|
|
201
|
+
* An absent skill is not stale: the person may not use one. Any other
|
|
202
|
+
* difference from the running binary is, in both directions, because the two
|
|
203
|
+
* describe different command sets.
|
|
204
|
+
*/
|
|
205
|
+
export function skillNoticeFor(installed, current, agent) {
|
|
206
|
+
if (installed._tag === 'SkillMissing')
|
|
207
|
+
return null;
|
|
208
|
+
if (installed._tag === 'SkillUnversioned')
|
|
209
|
+
return { agent, installed: null, current };
|
|
210
|
+
return installed.version === current ? null : { agent, installed: installed.version, current };
|
|
211
|
+
}
|
|
212
|
+
export function skillNoticeLine(notice) {
|
|
213
|
+
const installed = notice.installed === null ? 'skill version unknown' : `skill ${notice.installed} installed`;
|
|
214
|
+
return `${installed}, CLI ${notice.current}. Refresh with: nuxtseo skill install --agent ${notice.agent}`;
|
|
215
|
+
}
|
|
216
|
+
export function parseSkillCheckCache(content) {
|
|
217
|
+
let value;
|
|
218
|
+
try {
|
|
219
|
+
value = JSON.parse(content);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// A corrupt cache file is disposable state, not an error: recheck instead.
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
if (typeof value !== 'object' || value === null)
|
|
226
|
+
return null;
|
|
227
|
+
const record = value;
|
|
228
|
+
if (typeof record.lastCheckedAt !== 'string' || typeof record.cliVersion !== 'string')
|
|
229
|
+
return null;
|
|
230
|
+
if (typeof record.installed !== 'object' || record.installed === null)
|
|
231
|
+
return null;
|
|
232
|
+
const installed = {};
|
|
233
|
+
for (const agent of SKILL_AGENTS) {
|
|
234
|
+
const entry = record.installed[agent];
|
|
235
|
+
if (typeof entry === 'string' || entry === null)
|
|
236
|
+
installed[agent] = entry;
|
|
237
|
+
}
|
|
238
|
+
return { lastCheckedAt: record.lastCheckedAt, cliVersion: record.cliVersion, installed };
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Whether the filesystem has to be read again.
|
|
242
|
+
*
|
|
243
|
+
* A cache from a different binary is never reusable: the binary version is half
|
|
244
|
+
* of the comparison, so an upgrade must warn on the very next run.
|
|
245
|
+
*/
|
|
246
|
+
export function skillCheckDue(cache, now, current) {
|
|
247
|
+
if (!cache || cache.cliVersion !== current)
|
|
248
|
+
return true;
|
|
249
|
+
const checkedAt = Date.parse(cache.lastCheckedAt);
|
|
250
|
+
return Number.isNaN(checkedAt) || now.getTime() - checkedAt >= SKILL_CHECK_INTERVAL_MS;
|
|
251
|
+
}
|
|
252
|
+
function skillCheckFile(paths) {
|
|
253
|
+
return join(paths.directory, SKILL_CHECK_FILENAME);
|
|
254
|
+
}
|
|
255
|
+
async function readSkillCheckCache(paths) {
|
|
256
|
+
const content = await readFile(skillCheckFile(paths), 'utf8').catch(() => {
|
|
257
|
+
// A missing cache file is the common first-run case.
|
|
258
|
+
return null;
|
|
259
|
+
});
|
|
260
|
+
return typeof content === 'string' ? parseSkillCheckCache(content) : null;
|
|
261
|
+
}
|
|
262
|
+
async function writeSkillCheckCache(paths, cache) {
|
|
263
|
+
// Persisting the cache is best effort. A failed write only costs one extra
|
|
264
|
+
// directory read on the next run, so the failure is ignorable by design.
|
|
265
|
+
await mkdir(paths.directory, { recursive: true, mode: 0o700 }).catch(() => {
|
|
266
|
+
// See the comment above: the cache is disposable state.
|
|
267
|
+
return undefined;
|
|
268
|
+
});
|
|
269
|
+
await writeFile(skillCheckFile(paths), `${JSON.stringify(cache, null, 2)}\n`, {
|
|
270
|
+
encoding: 'utf8',
|
|
271
|
+
mode: 0o600,
|
|
272
|
+
}).catch(() => {
|
|
273
|
+
// See the comment above: the cache is disposable state.
|
|
274
|
+
return undefined;
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
function noticeFromCache(cache, current) {
|
|
278
|
+
for (const agent of SKILL_AGENTS) {
|
|
279
|
+
const version = cache.installed[agent];
|
|
280
|
+
if (version === undefined)
|
|
281
|
+
continue;
|
|
282
|
+
const installed = version === null
|
|
283
|
+
? { _tag: 'SkillUnversioned' }
|
|
284
|
+
: { _tag: 'SkillVersion', version };
|
|
285
|
+
const notice = skillNoticeFor(installed, current, agent);
|
|
286
|
+
if (notice)
|
|
287
|
+
return notice;
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* One line's worth of "your installed skill is not this binary".
|
|
293
|
+
*
|
|
294
|
+
* Costs one cached read per day, and nothing at all when the caller disabled
|
|
295
|
+
* update checks.
|
|
296
|
+
*/
|
|
297
|
+
export async function checkSkillVersion(options) {
|
|
298
|
+
if (checksDisabled(options.env))
|
|
299
|
+
return null;
|
|
300
|
+
const now = options.now?.() ?? new Date();
|
|
301
|
+
const cache = await readSkillCheckCache(options.paths);
|
|
302
|
+
if (cache && !skillCheckDue(cache, now, VERSION))
|
|
303
|
+
return noticeFromCache(cache, VERSION);
|
|
304
|
+
const homeDirectory = options.homeDirectory ?? dirname(options.paths.directory);
|
|
305
|
+
const installed = {};
|
|
306
|
+
let notice = null;
|
|
307
|
+
for (const agent of SKILL_AGENTS) {
|
|
308
|
+
const destination = await resolveSkillTarget(skillDestination(homeDirectory, agent));
|
|
309
|
+
const state = await readInstalledSkill(destination);
|
|
310
|
+
if (state._tag === 'SkillMissing')
|
|
311
|
+
continue;
|
|
312
|
+
installed[agent] = state._tag === 'SkillVersion' ? state.version : null;
|
|
313
|
+
notice ??= skillNoticeFor(state, VERSION, agent);
|
|
314
|
+
}
|
|
315
|
+
await writeSkillCheckCache(options.paths, {
|
|
316
|
+
lastCheckedAt: now.toISOString(),
|
|
317
|
+
cliVersion: VERSION,
|
|
318
|
+
installed,
|
|
319
|
+
});
|
|
320
|
+
return notice;
|
|
321
|
+
}
|
|
322
|
+
function sourceFailure(source, cause) {
|
|
323
|
+
return fail(EXIT_CODE.infrastructure, `skill_source_unreadable: Could not read the packaged skill at ${source}.\nCause: ${describeIoCause(cause)}.\nReinstall @nuxtseo/cli, then run the command again.`, cause);
|
|
324
|
+
}
|
|
325
|
+
function destinationFailure(destination, cause) {
|
|
326
|
+
return fail(EXIT_CODE.infrastructure, `skill_install_failed: Could not write the skill to ${destination}.\nCause: ${describeIoCause(cause)}.\nRead the cause above, repair that path, then run the command again.`, cause);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Remove installed files the previous install recorded and this release no
|
|
330
|
+
* longer ships.
|
|
331
|
+
*
|
|
332
|
+
* A plain recursive copy only adds. A reference file dropped from the package
|
|
333
|
+
* stays in the install forever and keeps answering an agent that asks for it.
|
|
334
|
+
* The prune set is the previous stamp's manifest, nothing more: a file the
|
|
335
|
+
* install never wrote (or cannot prove it wrote) belongs to the person, and
|
|
336
|
+
* deleting it would destroy data this package never shipped.
|
|
337
|
+
*/
|
|
338
|
+
async function pruneRemoved(destination, keep, recorded) {
|
|
339
|
+
if (!recorded)
|
|
340
|
+
return 0;
|
|
341
|
+
const existing = await collectSkillFiles(destination).catch(() => {
|
|
342
|
+
// A fresh install has nothing to prune.
|
|
343
|
+
return [];
|
|
344
|
+
});
|
|
345
|
+
let pruned = 0;
|
|
346
|
+
for (const entry of existing) {
|
|
347
|
+
if (keep.has(entry.path) || !recorded.has(entry.path))
|
|
348
|
+
continue;
|
|
349
|
+
await rm(entry.source, { force: true });
|
|
350
|
+
pruned += 1;
|
|
351
|
+
}
|
|
352
|
+
return pruned;
|
|
353
|
+
}
|
|
29
354
|
export async function installSkill(options) {
|
|
30
355
|
const source = options.sourceDirectory ?? skillSourceDirectory();
|
|
31
356
|
const readable = await stat(source).catch(() => {
|
|
@@ -36,21 +361,53 @@ export async function installSkill(options) {
|
|
|
36
361
|
if (!readable?.isDirectory()) {
|
|
37
362
|
return fail(EXIT_CODE.infrastructure, `skill_source_missing: The packaged skill is missing at ${source}.\nReinstall @nuxtseo/cli, then run the command again.`);
|
|
38
363
|
}
|
|
364
|
+
const files = await collectSkillFiles(source).catch((cause) => cause);
|
|
365
|
+
if (!Array.isArray(files))
|
|
366
|
+
return sourceFailure(source, files);
|
|
39
367
|
const destination = options.target
|
|
40
368
|
? join(options.target, SKILL_NAME)
|
|
41
369
|
: skillDestination(options.homeDirectory, options.agent);
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
370
|
+
const resolvedDestination = await resolveSkillTarget(destination);
|
|
371
|
+
// Read before the stamp is overwritten: the previous install's manifest is
|
|
372
|
+
// the only proof of what a prune may touch.
|
|
373
|
+
const previous = await readInstallManifest(resolvedDestination);
|
|
374
|
+
const keep = new Set([SKILL_VERSION_FILENAME]);
|
|
375
|
+
const write = async () => {
|
|
376
|
+
await mkdir(resolvedDestination, { recursive: true });
|
|
377
|
+
for (const file of files) {
|
|
378
|
+
const target = join(resolvedDestination, file.path);
|
|
379
|
+
await mkdir(dirname(target), { recursive: true });
|
|
380
|
+
if (file.path === SKILL_MARKDOWN_FILENAME)
|
|
381
|
+
await writeFile(target, stampSkillVersion(await readFile(file.source, 'utf8'), VERSION), 'utf8');
|
|
382
|
+
else
|
|
383
|
+
await copyFile(file.source, target);
|
|
384
|
+
keep.add(file.path);
|
|
385
|
+
}
|
|
386
|
+
await writeFile(join(resolvedDestination, SKILL_VERSION_FILENAME), `${JSON.stringify({ version: VERSION, skill: SKILL_NAME, installedAt: new Date().toISOString(), files: [...keep].sort() }, null, 2)}\n`, 'utf8');
|
|
387
|
+
return await pruneRemoved(resolvedDestination, keep, previous);
|
|
388
|
+
};
|
|
389
|
+
const result = await write().then(pruned => ok(pruned)).catch(cause => destinationFailure(resolvedDestination, cause));
|
|
390
|
+
if (result._tag === 'Err')
|
|
391
|
+
return result;
|
|
392
|
+
if (options.paths) {
|
|
393
|
+
// The freshly installed version is now the answer, so clear the stale
|
|
394
|
+
// notice the same run rather than a day later. Merge into the recorded
|
|
395
|
+
// map: another agent's entry still has to answer for its own skill, and
|
|
396
|
+
// replacing the map would hide its stale warning until the cache expires.
|
|
397
|
+
const cached = await readSkillCheckCache(options.paths);
|
|
398
|
+
await writeSkillCheckCache(options.paths, {
|
|
399
|
+
lastCheckedAt: new Date().toISOString(),
|
|
400
|
+
cliVersion: VERSION,
|
|
401
|
+
installed: { ...cached?.installed, [options.agent]: VERSION },
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
return ok({
|
|
405
|
+
agent: options.agent,
|
|
406
|
+
source,
|
|
407
|
+
destination,
|
|
408
|
+
resolvedDestination,
|
|
409
|
+
version: VERSION,
|
|
410
|
+
written: files.length,
|
|
411
|
+
pruned: result.value,
|
|
412
|
+
});
|
|
56
413
|
}
|
package/dist/state/auth.js
CHANGED
|
@@ -65,7 +65,7 @@ function keyringFailure(operation, paths, cause) {
|
|
|
65
65
|
_tag: 'KeychainFailure',
|
|
66
66
|
operation,
|
|
67
67
|
path: paths.authFile,
|
|
68
|
-
message: `Could not ${operation} the
|
|
68
|
+
message: `Could not ${operation} the Nuxt SEO credential in the OS keychain. Local auth state is recorded at ${paths.authFile}.`,
|
|
69
69
|
cause,
|
|
70
70
|
});
|
|
71
71
|
}
|
|
@@ -148,7 +148,7 @@ export async function saveCredential(value, options = {}) {
|
|
|
148
148
|
return err({
|
|
149
149
|
_tag: 'InvalidCredential',
|
|
150
150
|
source: 'input',
|
|
151
|
-
message: 'The
|
|
151
|
+
message: 'The Nuxt SEO API token cannot be empty.',
|
|
152
152
|
});
|
|
153
153
|
}
|
|
154
154
|
const paths = options.paths ?? defaultStatePaths();
|
package/dist/state/files.js
CHANGED
|
@@ -19,7 +19,7 @@ function statePath(paths, kind) {
|
|
|
19
19
|
function corruptState(kind, path, detail, cause) {
|
|
20
20
|
const common = {
|
|
21
21
|
path,
|
|
22
|
-
message: `Could not parse
|
|
22
|
+
message: `Could not parse Nuxt SEO ${kind} state at ${path}: ${detail}`,
|
|
23
23
|
...(cause === undefined ? {} : { cause }),
|
|
24
24
|
};
|
|
25
25
|
return kind === 'auth'
|
|
@@ -37,7 +37,7 @@ export async function readStateJson(paths, kind) {
|
|
|
37
37
|
kind,
|
|
38
38
|
operation: 'read',
|
|
39
39
|
path,
|
|
40
|
-
message: `Could not read
|
|
40
|
+
message: `Could not read Nuxt SEO ${kind} state at ${path}.`,
|
|
41
41
|
cause: content,
|
|
42
42
|
});
|
|
43
43
|
}
|
|
@@ -76,7 +76,7 @@ export async function writeStateJson(paths, kind, value) {
|
|
|
76
76
|
kind,
|
|
77
77
|
operation: 'write',
|
|
78
78
|
path,
|
|
79
|
-
message: `Could not write
|
|
79
|
+
message: `Could not write Nuxt SEO ${kind} state at ${path}: the filesystem is read-only or permission was denied.`,
|
|
80
80
|
cause,
|
|
81
81
|
}
|
|
82
82
|
: {
|
|
@@ -84,7 +84,7 @@ export async function writeStateJson(paths, kind, value) {
|
|
|
84
84
|
kind,
|
|
85
85
|
operation: 'write',
|
|
86
86
|
path,
|
|
87
|
-
message: `Could not write
|
|
87
|
+
message: `Could not write Nuxt SEO ${kind} state at ${path}.`,
|
|
88
88
|
cause,
|
|
89
89
|
}));
|
|
90
90
|
if (written._tag === 'Err' && temporaryCreated) {
|
|
@@ -110,7 +110,7 @@ export async function removeStateFile(paths, kind) {
|
|
|
110
110
|
kind,
|
|
111
111
|
operation: 'delete',
|
|
112
112
|
path,
|
|
113
|
-
message: `Could not remove
|
|
113
|
+
message: `Could not remove Nuxt SEO ${kind} state at ${path}: the filesystem is read-only or permission was denied.`,
|
|
114
114
|
cause,
|
|
115
115
|
}
|
|
116
116
|
: {
|
|
@@ -118,7 +118,7 @@ export async function removeStateFile(paths, kind) {
|
|
|
118
118
|
kind,
|
|
119
119
|
operation: 'delete',
|
|
120
120
|
path,
|
|
121
|
-
message: `Could not remove
|
|
121
|
+
message: `Could not remove Nuxt SEO ${kind} state at ${path}.`,
|
|
122
122
|
cause,
|
|
123
123
|
});
|
|
124
124
|
});
|
package/dist/update-check.d.ts
CHANGED
|
@@ -15,6 +15,11 @@ export interface UpdateCheckOptions {
|
|
|
15
15
|
env?: Readonly<Record<string, string | undefined>>;
|
|
16
16
|
fetch?: RegistryLatestFetch;
|
|
17
17
|
now?: () => Date;
|
|
18
|
+
/**
|
|
19
|
+
* Receives one diagnostic line per stale local artefact, before this check
|
|
20
|
+
* resolves. The caller owns the stream, so this module never writes to one.
|
|
21
|
+
*/
|
|
22
|
+
onDiagnostic?: (line: string) => void;
|
|
18
23
|
}
|
|
19
24
|
export declare function isNewerVersion(candidate: string, current: string): boolean;
|
|
20
25
|
export declare function updateNoticeFor(latest: string | null, current: string): UpdateNotice | null;
|