@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/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, JSON.stringify(response));
13
+ writeOutput(runtime, stringifyWithoutAnsi(response));
9
14
  }
10
15
  export function writeCliResponse(runtime, response) {
11
- writeOutput(runtime, JSON.stringify(response));
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 { cp, mkdir, stat } from 'node:fs/promises';
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 written = await mkdir(dirname(destination), { recursive: true })
43
- .then(() => cp(source, destination, {
44
- recursive: true,
45
- force: true,
46
- filter: (entry) => {
47
- const [segment] = relative(source, entry).split('/');
48
- return !segment || !SKILL_LOCAL_ONLY.has(segment);
49
- },
50
- }))
51
- .then(() => ok(undefined))
52
- .catch(cause => fail(EXIT_CODE.infrastructure, `skill_install_failed: Could not write the skill to ${destination}.\nCheck the directory permissions, then run the command again.`, cause));
53
- if (written._tag === 'Err')
54
- return written;
55
- return ok({ agent: options.agent, source, destination });
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
  }
@@ -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 NuxtSEO credential in the OS keychain. Local auth state is recorded at ${paths.authFile}.`,
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 NuxtSEO API token cannot be empty.',
151
+ message: 'The Nuxt SEO API token cannot be empty.',
152
152
  });
153
153
  }
154
154
  const paths = options.paths ?? defaultStatePaths();
@@ -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 NuxtSEO ${kind} state at ${path}: ${detail}`,
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 NuxtSEO ${kind} state at ${path}.`,
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 NuxtSEO ${kind} state at ${path}: the filesystem is read-only or permission was denied.`,
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 NuxtSEO ${kind} state at ${path}.`,
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 NuxtSEO ${kind} state at ${path}: the filesystem is read-only or permission was denied.`,
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 NuxtSEO ${kind} state at ${path}.`,
121
+ message: `Could not remove Nuxt SEO ${kind} state at ${path}.`,
122
122
  cause,
123
123
  });
124
124
  });
@@ -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;