mobile-agent-orchestrator 0.0.1 → 0.2.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.
@@ -0,0 +1,934 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ constants as fsConstants,
4
+ copyFileSync,
5
+ existsSync,
6
+ lstatSync,
7
+ mkdirSync,
8
+ readdirSync,
9
+ rmdirSync,
10
+ readFileSync,
11
+ renameSync,
12
+ rmSync,
13
+ unlinkSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import { dirname, join, relative, resolve, sep } from "node:path";
17
+ import { resolveUniqueDestinations } from "./hosts.mjs";
18
+
19
+ export const MANIFEST_NAME = ".mobile-agent-orchestrator.manifest.json";
20
+ const SKIPPED_DIRECTORIES = [".git", "node_modules", ".codegraph"];
21
+
22
+ export function sha256(content) {
23
+ return createHash("sha256").update(content).digest("hex");
24
+ }
25
+
26
+ /** Canonical managed files: every file under the skill root, discovered recursively and sorted. */
27
+ export function discoverPackageFiles(skillRoot) {
28
+ const files = [];
29
+ const visit = (directory) => {
30
+ for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name < right.name ? -1 : 1)) {
31
+ if (SKIPPED_DIRECTORIES.includes(entry.name)) continue;
32
+ const path = join(directory, entry.name);
33
+ if (entry.isDirectory()) visit(path);
34
+ else files.push({ path: toRelativePath(relative(skillRoot, path)), source: path });
35
+ }
36
+ };
37
+ visit(skillRoot);
38
+ return files;
39
+ }
40
+
41
+ function toRelativePath(path) {
42
+ return path.replaceAll(sep, "/");
43
+ }
44
+
45
+ /** List files under a directory without following symlinks: symlinked entries are reported as-is. */
46
+ function listFiles(directory) {
47
+ if (!existsSync(directory)) return [];
48
+ const files = [];
49
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
50
+ const path = join(directory, entry.name);
51
+ if (entry.isDirectory() && !entry.isSymbolicLink()) files.push(...listFiles(path));
52
+ else files.push(path);
53
+ }
54
+ return files;
55
+ }
56
+
57
+ function readManifestFile(manifestPath) {
58
+ if (!existsSync(manifestPath)) return null;
59
+ try {
60
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
61
+ if (
62
+ !parsed
63
+ || typeof parsed !== "object"
64
+ || typeof parsed.installerVersion !== "string"
65
+ || !Array.isArray(parsed.files)
66
+ || parsed.files.some((file) => typeof file?.path !== "string" || typeof file?.sha256 !== "string")
67
+ ) {
68
+ return null;
69
+ }
70
+ return parsed;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ function toDestinationPath(skillDirectory, relativePath) {
77
+ return join(skillDirectory, ...relativePath.split("/"));
78
+ }
79
+
80
+ /**
81
+ * First intermediate component between the skill directory and a recorded file that is not a real
82
+ * directory (a symlink or some other entry kind), or null when every intermediate is a real
83
+ * directory. Checking only the final component with lstat would let a symlinked intermediate (e.g.
84
+ * `references`) redirect recorded-file reads outside the skill directory. An absent intermediate is
85
+ * not a failure here: the final-component lstat then reports the file itself as missing.
86
+ */
87
+ function unsafeIntermediateComponent(skillDirectory, relativePath) {
88
+ const components = relativePath.split("/");
89
+ let current = skillDirectory;
90
+ for (const component of components.slice(0, -1)) {
91
+ current = join(current, component);
92
+ let stats = null;
93
+ try {
94
+ stats = lstatSync(current);
95
+ } catch (error) {
96
+ if (error.code === "ENOENT") return null;
97
+ if (error.code !== "ENOTDIR") throw error;
98
+ return component;
99
+ }
100
+ if (stats.isSymbolicLink() || !stats.isDirectory()) return component;
101
+ }
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * A manifest-recorded path is canonical only when it is already in its simplest form relative to
107
+ * the skill directory: a non-empty string with no backslash, not absolute, and every
108
+ * `/`-separated segment non-empty and neither `.` nor `..`. Non-canonical records are untrusted
109
+ * as a whole: `toDestinationPath` joins with `join()`, which normalizes `.` and `..` away, so
110
+ * without this check a crafted record like `missing/../references/notes.md` could slip past the
111
+ * intermediate-component inspection (which stops at the first absent component) and follow a
112
+ * symlinked directory outside the skill.
113
+ */
114
+ function isCanonicalRecordedPath(relativePath) {
115
+ if (typeof relativePath !== "string" || relativePath.length === 0) return false;
116
+ if (relativePath.includes("\\") || relativePath.startsWith("/")) return false;
117
+ return relativePath.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
118
+ }
119
+
120
+ /** Inspect the manifest path without following links: valid and dangling symlinks are both rejected. */
121
+ function inspectManifestPath(manifestPath) {
122
+ let stats;
123
+ try {
124
+ stats = lstatSync(manifestPath);
125
+ } catch (error) {
126
+ // ENOTDIR (POSIX) means an ancestor of the manifest path is a regular file, so no manifest can
127
+ // exist there; Windows maps the same situation to ENOENT. Treat both as absent instead of
128
+ // failing whole-command planning on one platform and degrading per-destination on the other.
129
+ if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error;
130
+ return { kind: "absent" };
131
+ }
132
+ if (stats.isSymbolicLink()) return { kind: "symlink" };
133
+ if (!stats.isFile()) return { kind: "nonregular" };
134
+ return { kind: "regular" };
135
+ }
136
+
137
+ function siblingTempPath(destinationPath) {
138
+ return `${destinationPath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
139
+ }
140
+
141
+ /** Physical file identity as BigInt dev:ino, or null when the filesystem does not expose an inode. */
142
+ function physicalIdentity(stats) {
143
+ if (!stats.isFile() || stats.ino === 0n) return null;
144
+ return `${stats.dev}:${stats.ino}`;
145
+ }
146
+
147
+ /** Physical identity of an existing regular file (BigInt dev:ino), or null when absent or inode-less. */
148
+ function fileIdentity(path) {
149
+ let stats;
150
+ try {
151
+ stats = lstatSync(path, { bigint: true });
152
+ } catch {
153
+ return null;
154
+ }
155
+ return physicalIdentity(stats);
156
+ }
157
+
158
+ /**
159
+ * Physical identity of an existing real directory (BigInt dev:ino), or null when the path is
160
+ * absent, not a real directory (a symlink is never followed), or inode-less (`ino === 0n`).
161
+ * Unlike a final-component lstat kind check, comparing this identity across time detects a
162
+ * symlinked ANCESTOR swap: path resolution follows ancestor links, so the dev:ino a recorded
163
+ * path resolves to changes when an ancestor is replaced, even though every per-file lstat along
164
+ * that path still looks ordinary.
165
+ */
166
+ function directoryIdentity(path) {
167
+ let stats;
168
+ try {
169
+ stats = lstatSync(path, { bigint: true });
170
+ } catch {
171
+ return null;
172
+ }
173
+ if (!stats.isDirectory() || stats.isSymbolicLink() || stats.ino === 0n) return null;
174
+ return `${stats.dev}:${stats.ino}`;
175
+ }
176
+
177
+ /** Publish content through an exclusive sibling temp file and atomic rename; any failure keeps the old file. */
178
+ function writeFileAtomic(destinationPath, content) {
179
+ const tempPath = siblingTempPath(destinationPath);
180
+ try {
181
+ writeFileSync(tempPath, content, { flag: "wx" });
182
+ renameSync(tempPath, destinationPath);
183
+ } catch (error) {
184
+ if (error.code !== "EEXIST") rmSync(tempPath, { force: true });
185
+ throw error;
186
+ }
187
+ }
188
+
189
+ /** Copy one managed file through an exclusive sibling temp file so an interrupted install never publishes a partial file. */
190
+ function copyFileAtomic(sourcePath, destinationPath) {
191
+ const tempPath = siblingTempPath(destinationPath);
192
+ try {
193
+ copyFileSync(sourcePath, tempPath, fsConstants.COPYFILE_EXCL);
194
+ renameSync(tempPath, destinationPath);
195
+ } catch (error) {
196
+ if (error.code !== "EEXIST") rmSync(tempPath, { force: true });
197
+ throw error;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Classify one canonical file against the destination. Symlinks are never followed or written
203
+ * through: a symlink at a managed path is reported as unmanaged. `replace` is granted only when
204
+ * the on-disk content matches a manifest record (version-update scenario).
205
+ */
206
+ function classifyFile(destinationPath, filePath, sourceChecksum, manifestChecksums, destinationUsable) {
207
+ if (!destinationUsable) return { classification: "conflict", action: "conflict" };
208
+ let stats = null;
209
+ try {
210
+ stats = lstatSync(destinationPath);
211
+ } catch {
212
+ stats = null;
213
+ }
214
+ if (stats === null) return { classification: "absent", action: "create" };
215
+ if (stats.isSymbolicLink()) return { classification: "unmanaged", action: "conflict" };
216
+ const onDiskChecksum = sha256(readFileSync(destinationPath));
217
+ if (onDiskChecksum === sourceChecksum) return { classification: "identical", action: "keep" };
218
+ if (manifestChecksums.get(filePath) === onDiskChecksum) {
219
+ return { classification: "modified", action: "replace" };
220
+ }
221
+ return { classification: "modified", action: "conflict" };
222
+ }
223
+
224
+ function resolveDestinationStatus(files, conflicts, validCurrentManifest) {
225
+ if (conflicts.length > 0) return "aborted";
226
+ if (validCurrentManifest && files.every((file) => file.action === "keep")) return "up-to-date";
227
+ return "install";
228
+ }
229
+
230
+ function resolvePlanStatus(destinations) {
231
+ if (destinations.some((destination) => destination.status === "aborted")) return "aborted";
232
+ return destinations.every((destination) => destination.status === "up-to-date") ? "up-to-date" : "install";
233
+ }
234
+
235
+ /**
236
+ * Pure plan builder: classifies canonical files per destination and computes per-file actions.
237
+ * `create` for absent files, `keep` for identical ones, `replace` only for manifest-recorded
238
+ * content, and `conflict` for anything else (modified, unmanaged, symlinked, corrupt manifest,
239
+ * or a destination skill path that is not a directory).
240
+ */
241
+ export function buildPlan({ targets: selectedTargets, scope, cwd, home, skillRoot, installerVersion }) {
242
+ const canonicalFiles = discoverPackageFiles(skillRoot);
243
+ const sourceChecksums = new Map(
244
+ canonicalFiles.map((file) => [file.path, sha256(readFileSync(file.source))]),
245
+ );
246
+ const canonicalPaths = new Set(canonicalFiles.map((file) => file.path));
247
+ const manifestPayload = {
248
+ installerVersion,
249
+ files: canonicalFiles.map((file) => ({ path: file.path, sha256: sourceChecksums.get(file.path) })),
250
+ };
251
+
252
+ const destinations = resolveUniqueDestinations(selectedTargets, scope, { cwd, home }).map(
253
+ ({ destination, targets: resolvedTargets }) => {
254
+ const skillDirectory = resolve(destination);
255
+ const manifestPath = join(dirname(skillDirectory), MANIFEST_NAME);
256
+ const conflicts = [];
257
+ const unmanagedFiles = [];
258
+
259
+ let destinationStats = null;
260
+ try {
261
+ destinationStats = lstatSync(skillDirectory);
262
+ } catch {
263
+ destinationStats = null;
264
+ }
265
+ const destinationUsable = destinationStats === null
266
+ || (destinationStats.isDirectory() && !destinationStats.isSymbolicLink());
267
+ if (!destinationUsable) conflicts.push(`skill destination is not a managed directory: ${skillDirectory}`);
268
+
269
+ const manifestKind = inspectManifestPath(manifestPath).kind;
270
+ if (manifestKind === "symlink") conflicts.push(`manifest is a symlink: ${manifestPath}`);
271
+ else if (manifestKind === "nonregular") conflicts.push(`manifest is not a regular file: ${manifestPath}`);
272
+ const manifest = manifestKind === "regular" ? readManifestFile(manifestPath) : null;
273
+ if (manifestKind === "regular" && manifest === null) conflicts.push(`corrupt manifest: ${manifestPath}`);
274
+ const manifestChecksums = new Map(manifest?.files.map((file) => [file.path, file.sha256]));
275
+ const validCurrentManifest = Boolean(manifest)
276
+ && manifest.installerVersion === installerVersion
277
+ && manifestChecksums.size === canonicalPaths.size
278
+ && canonicalFiles.every((file) => manifestChecksums.get(file.path) === sourceChecksums.get(file.path));
279
+
280
+ const files = canonicalFiles.map((file) => {
281
+ const destinationPath = toDestinationPath(skillDirectory, file.path);
282
+ const { classification, action } = classifyFile(
283
+ destinationPath,
284
+ file.path,
285
+ sourceChecksums.get(file.path),
286
+ manifestChecksums,
287
+ destinationUsable,
288
+ );
289
+ if (classification === "unmanaged") conflicts.push(`${file.path} (unmanaged)`);
290
+ else if (action === "conflict") conflicts.push(`${file.path} (modified)`);
291
+ return { path: file.path, source: file.source, destination: destinationPath, classification, action };
292
+ });
293
+
294
+ // Never remove a stale path that is the same physical file as a canonical destination: on a
295
+ // case-insensitive filesystem the two spellings share one inode, so a case-only rename there must
296
+ // conflict instead of deleting the file the canonical path keeps. Identity is BigInt dev:ino, which is
297
+ // casing-independent and still distinguishes the two spellings on a case-sensitive filesystem.
298
+ const canonicalIdentities = new Map();
299
+ for (const file of files) {
300
+ const identity = fileIdentity(file.destination);
301
+ if (identity !== null) canonicalIdentities.set(identity, file.path);
302
+ }
303
+ const removals = [];
304
+ if (destinationUsable && destinationStats !== null) {
305
+ for (const existingPath of listFiles(skillDirectory)) {
306
+ const relativePath = toRelativePath(relative(skillDirectory, existingPath));
307
+ if (canonicalPaths.has(relativePath)) continue;
308
+ const existingStats = lstatSync(existingPath);
309
+ const recordedChecksum = manifestChecksums.get(relativePath);
310
+ const existingIdentity = existingStats.isFile() ? fileIdentity(existingPath) : null;
311
+ const canonicalAlias = existingIdentity === null ? undefined : canonicalIdentities.get(existingIdentity);
312
+ if (canonicalAlias !== undefined) {
313
+ conflicts.push(`${relativePath} (aliases managed file ${canonicalAlias})`);
314
+ } else if (existingStats.isFile() && existingIdentity === null) {
315
+ // The filesystem exposes no inode: identity is unknown, so this path could alias a canonical file.
316
+ // Fall back to the conservative unmanaged conflict and never delete it.
317
+ unmanagedFiles.push(relativePath);
318
+ conflicts.push(`${relativePath} (unmanaged)`);
319
+ } else if (existingStats.isFile() && recordedChecksum !== undefined && sha256(readFileSync(existingPath)) === recordedChecksum) {
320
+ removals.push({ path: relativePath, destination: existingPath, classification: "stale", action: "remove" });
321
+ } else if (existingStats.isFile() && recordedChecksum !== undefined) {
322
+ conflicts.push(`${relativePath} (modified)`);
323
+ } else {
324
+ unmanagedFiles.push(relativePath);
325
+ conflicts.push(`${relativePath} (unmanaged)`);
326
+ }
327
+ }
328
+ }
329
+ files.push(...removals);
330
+
331
+ return {
332
+ destination: skillDirectory,
333
+ manifestPath,
334
+ targets: resolvedTargets,
335
+ files,
336
+ unmanagedFiles,
337
+ conflicts,
338
+ manifest: manifestPayload,
339
+ status: resolveDestinationStatus(files, conflicts, validCurrentManifest),
340
+ };
341
+ },
342
+ );
343
+
344
+ return { installerVersion, scope, destinations, status: resolvePlanStatus(destinations) };
345
+ }
346
+
347
+ /** Execute a plan. Dry runs write nothing; conflicts abort the whole install before any write. */
348
+ export function executePlan(plan, { dryRun = false } = {}) {
349
+ const conflicts = plan.destinations.flatMap((destination) => destination.conflicts);
350
+ if (plan.status === "aborted" || dryRun) {
351
+ return {
352
+ status: plan.status === "aborted" ? "aborted" : plan.status === "up-to-date" ? "up-to-date" : "installed",
353
+ writtenFiles: [],
354
+ removedFiles: [],
355
+ conflicts,
356
+ plan,
357
+ };
358
+ }
359
+
360
+ const writtenFiles = [];
361
+ const removedFiles = [];
362
+ for (const destination of plan.destinations) {
363
+ if (destination.status === "up-to-date") continue;
364
+ for (const file of destination.files) {
365
+ if (file.action === "keep" || file.action === "conflict") continue;
366
+ if (file.action === "remove") {
367
+ unlinkSync(file.destination);
368
+ removedFiles.push(file.destination);
369
+ continue;
370
+ }
371
+ mkdirSync(dirname(file.destination), { recursive: true });
372
+ copyFileAtomic(file.source, file.destination);
373
+ writtenFiles.push(file.destination);
374
+ }
375
+ mkdirSync(dirname(destination.manifestPath), { recursive: true });
376
+ writeFileAtomic(destination.manifestPath, `${JSON.stringify(destination.manifest, null, 2)}\n`);
377
+ writtenFiles.push(destination.manifestPath);
378
+ }
379
+ return {
380
+ status: writtenFiles.length > 0 || removedFiles.length > 0 ? "installed" : "up-to-date",
381
+ writtenFiles,
382
+ removedFiles,
383
+ conflicts,
384
+ plan,
385
+ };
386
+ }
387
+
388
+ /**
389
+ * Classify one destination for uninstall (pure, zero writes). Status precedence: absent (no skill
390
+ * directory), unknown (destination or manifest fails the same trust bar as `inspectInstallations`),
391
+ * blocked (a manifest-recorded file cannot be safely removed), uninstall (clean).
392
+ */
393
+ function classifyUninstallDestination(destination, destinationTargets, destinationScopes) {
394
+ const skillDirectory = resolve(destination);
395
+ const manifestPath = join(dirname(skillDirectory), MANIFEST_NAME);
396
+ const details = [];
397
+ const unmanagedFiles = [];
398
+ const entry = {
399
+ destination: skillDirectory,
400
+ manifestPath,
401
+ rootIdentity: null,
402
+ parentIdentity: null,
403
+ scopes: destinationScopes,
404
+ targets: destinationTargets,
405
+ removals: [],
406
+ directories: [],
407
+ manifestRemoved: false,
408
+ status: "unknown",
409
+ details,
410
+ unmanagedFiles,
411
+ };
412
+
413
+ let directoryStats = null;
414
+ try {
415
+ directoryStats = lstatSync(skillDirectory);
416
+ } catch (error) {
417
+ // ENOTDIR (POSIX) for a non-directory ancestor of the destination means no managed
418
+ // installation can exist under it: treat it as absent instead of failing the whole report
419
+ // (matches the inspectManifestPath ENOTDIR handling).
420
+ if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error;
421
+ }
422
+ if (directoryStats === null) {
423
+ entry.status = "absent";
424
+ return entry;
425
+ }
426
+ if (directoryStats.isSymbolicLink() || !directoryStats.isDirectory()) {
427
+ details.push(`skill destination is not a managed directory: ${skillDirectory}`);
428
+ return entry;
429
+ }
430
+
431
+ // Physical-identity pinning (round-2 P1): the final-component lstat checks (here and at
432
+ // execution time) cannot see a symlinked ANCESTOR of the destination — path resolution
433
+ // follows ancestor links, so a junction swapped in for a parent still presents every recorded
434
+ // file as an ordinary regular file with the recorded checksum. Plan time therefore records
435
+ // the BigInt dev:ino of the destination and of its parent (the manifest's directory); execute
436
+ // time re-computes both and refuses the whole destination on any mismatch. On an inode-less
437
+ // filesystem (`ino === 0n`) the recorded identity is null and execution falls back to the
438
+ // existing lstat-based checks alone — an honest limitation, not a guarantee.
439
+ entry.rootIdentity = directoryIdentity(skillDirectory);
440
+ entry.parentIdentity = directoryIdentity(dirname(skillDirectory));
441
+
442
+ const manifestKind = inspectManifestPath(manifestPath).kind;
443
+ if (manifestKind === "symlink") details.push(`manifest is a symlink: ${manifestPath}`);
444
+ else if (manifestKind === "nonregular") details.push(`manifest is not a regular file: ${manifestPath}`);
445
+ else if (manifestKind === "absent") details.push(`manifest is missing: ${manifestPath}`);
446
+ const manifest = manifestKind === "regular" ? readManifestFile(manifestPath) : null;
447
+ if (manifestKind === "regular" && manifest === null) details.push(`corrupt manifest: ${manifestPath}`);
448
+ if (manifest === null) return entry;
449
+
450
+ // Same canonical + containment gate as `inspectInstallations`: a crafted manifest must never
451
+ // drive lstat, checksum reads, or unlinks outside the skill directory.
452
+ const unsafeRecordedPaths = manifest.files.filter((file) => {
453
+ if (!isCanonicalRecordedPath(file.path)) return true;
454
+ const resolvedPath = resolve(toDestinationPath(skillDirectory, file.path));
455
+ return resolvedPath !== skillDirectory && !resolvedPath.startsWith(skillDirectory + sep);
456
+ });
457
+ if (unsafeRecordedPaths.length > 0) {
458
+ for (const file of unsafeRecordedPaths) details.push(`${file.path} (unsafe manifest path)`);
459
+ return entry;
460
+ }
461
+
462
+ const recordedChecksums = new Map(manifest.files.map((file) => [file.path, file.sha256]));
463
+ const blocks = new Set();
464
+ const alreadyAbsentDetails = [];
465
+ const verifiedRecordedPaths = [];
466
+ for (const [recordedPath, recordedChecksum] of recordedChecksums) {
467
+ const recordedDestinationPath = toDestinationPath(skillDirectory, recordedPath);
468
+ const unsafeIntermediate = unsafeIntermediateComponent(skillDirectory, recordedPath);
469
+ // Resolve the final component ONLY when every intermediate is safe (the #29 lesson: check
470
+ // intermediates BEFORE any read or unlink, and never resolve through a symlink).
471
+ let fileStats = null;
472
+ if (unsafeIntermediate === null) {
473
+ try {
474
+ fileStats = lstatSync(recordedDestinationPath);
475
+ } catch (error) {
476
+ if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error;
477
+ }
478
+ }
479
+ if (unsafeIntermediate !== null) {
480
+ blocks.add(`${recordedPath} (intermediate ${unsafeIntermediate} is a symlink or not a directory)`);
481
+ } else if (fileStats === null) {
482
+ // A partial installation is not a block: report it as already gone (S7) and proceed.
483
+ alreadyAbsentDetails.push(`${recordedPath} (already absent)`);
484
+ } else if (fileStats.isSymbolicLink() || !fileStats.isFile()) {
485
+ blocks.add(`${recordedPath} (symlinked or not a regular file)`);
486
+ } else if (sha256(readFileSync(recordedDestinationPath)) !== recordedChecksum) {
487
+ blocks.add(`${recordedPath} (modified)`);
488
+ } else {
489
+ verifiedRecordedPaths.push(recordedPath);
490
+ }
491
+ }
492
+
493
+ // Removal candidates come from the real listing, never from manifest paths (the #26 lesson):
494
+ // `listFiles` only recurses real non-symlink directories, so every intermediate component of a
495
+ // real-listing path is a real directory by construction, and the final component is re-checked
496
+ // with lstat below before the file can become a removal candidate.
497
+ const realFiles = listFiles(skillDirectory).map((existingPath) => ({
498
+ absolutePath: existingPath,
499
+ relativePath: toRelativePath(relative(skillDirectory, existingPath)),
500
+ }));
501
+ const realRelativePaths = new Set(realFiles.map((file) => file.relativePath));
502
+ for (const realFile of realFiles) {
503
+ const recordedChecksum = recordedChecksums.get(realFile.relativePath);
504
+ if (recordedChecksum === undefined) {
505
+ // Decision (recorded per task T1): unmanaged files never block the uninstall (S3). A user's
506
+ // own files coexist with managed content; only a manifest-recorded file that differs blocks.
507
+ // They are never removed and are listed in the details.
508
+ unmanagedFiles.push(realFile.relativePath);
509
+ continue;
510
+ }
511
+ let fileStats = null;
512
+ try {
513
+ fileStats = lstatSync(realFile.absolutePath);
514
+ } catch (error) {
515
+ // ENOENT: the file was deleted between `listFiles` and this lstat. Treat it as already
516
+ // absent (consistent with the recorded-path handling) instead of failing planning for
517
+ // every destination; anything else is a real filesystem error and propagates.
518
+ if (error.code !== "ENOENT") throw error;
519
+ alreadyAbsentDetails.push(`${realFile.relativePath} (already absent)`);
520
+ continue;
521
+ }
522
+ if (fileStats.isSymbolicLink() || !fileStats.isFile()) {
523
+ blocks.add(`${realFile.relativePath} (symlinked or not a regular file)`);
524
+ } else if (sha256(readFileSync(realFile.absolutePath)) !== recordedChecksum) {
525
+ blocks.add(`${realFile.relativePath} (modified)`);
526
+ } else {
527
+ entry.removals.push({ path: realFile.relativePath, destination: realFile.absolutePath, sha256: recordedChecksum });
528
+ }
529
+ }
530
+ // A recorded path whose content verifies but which the real listing spells differently (a
531
+ // case-insensitive alias) must block: removing the manifest would orphan managed content that
532
+ // the real listing correctly refuses to classify as removable.
533
+ for (const recordedPath of verifiedRecordedPaths) {
534
+ if (!realRelativePaths.has(recordedPath)) {
535
+ blocks.add(`${recordedPath} (not present under its recorded name)`);
536
+ }
537
+ }
538
+
539
+ details.push(...blocks, ...unmanagedFiles.map((path) => `${path} (unmanaged)`), ...alreadyAbsentDetails);
540
+ entry.status = blocks.size > 0 ? "blocked" : "uninstall";
541
+ if (entry.status !== "uninstall") {
542
+ // All-or-nothing per destination (Design decision 4): a blocked destination removes nothing,
543
+ // so its plan carries zero removals, no directory cleanup, and no manifest removal. Unmanaged
544
+ // files stay listed in the details and in `unmanagedFiles` (S3 reporting).
545
+ entry.removals = [];
546
+ return entry;
547
+ }
548
+
549
+ entry.manifestRemoved = true;
550
+ // Directory cleanup plan (S4, computed here, executed by `executeUninstallPlan`): only
551
+ // directories on recorded-file paths may be removed, bottom-up, and only the ones that end
552
+ // empty once the removable files are gone. Unmanaged content keeps its directory (S3).
553
+ const removalPaths = new Set(entry.removals.map((removal) => removal.path));
554
+ const candidateDirectories = new Set();
555
+ for (const recordedPath of recordedChecksums.keys()) {
556
+ const components = recordedPath.split("/");
557
+ let current = skillDirectory;
558
+ for (const component of components.slice(0, -1)) {
559
+ current = join(current, component);
560
+ candidateDirectories.add(current);
561
+ }
562
+ }
563
+ const remainingFiles = realFiles
564
+ .filter((file) => !removalPaths.has(file.relativePath))
565
+ .map((file) => file.relativePath);
566
+ const endsEmpty = (directory) => {
567
+ const directoryRelative = toRelativePath(relative(skillDirectory, directory));
568
+ return !remainingFiles.some((path) => path.startsWith(`${directoryRelative}/`));
569
+ };
570
+ // Sorting by path length descending puts every directory before its ancestors, so children are
571
+ // always removed first.
572
+ entry.directories = [...candidateDirectories].filter(endsEmpty).sort((left, right) => right.length - left.length);
573
+ // The skill directory itself is removed only when nothing survives the removals.
574
+ if (remainingFiles.length === 0) entry.directories.push(skillDirectory);
575
+ return entry;
576
+ }
577
+
578
+ /**
579
+ * Merge every scope's resolved destinations into one entry per absolute destination: when two
580
+ * scopes resolve to the SAME directory (e.g. `cwd === home` makes the user and project scopes
581
+ * collide), the entry carries the union of targets and the requested scopes in request order
582
+ * instead of duplicating the destination — a duplicate would classify, report, and (for
583
+ * uninstall) execute the same installation twice, and the second pass would always find its
584
+ * planned files already removed.
585
+ */
586
+ function mergeScopedDestinations(selectedTargets, selectedScopes, { cwd, home }) {
587
+ const merged = new Map();
588
+ for (const scope of selectedScopes) {
589
+ for (const { destination, targets: destinationTargets } of resolveUniqueDestinations(selectedTargets, scope, { cwd, home })) {
590
+ const skillDirectory = resolve(destination);
591
+ const existing = merged.get(skillDirectory);
592
+ if (existing) {
593
+ for (const target of destinationTargets) {
594
+ if (!existing.targets.includes(target)) existing.targets.push(target);
595
+ }
596
+ existing.scopes.push(scope);
597
+ } else {
598
+ merged.set(skillDirectory, { destination: skillDirectory, targets: [...destinationTargets], scopes: [scope] });
599
+ }
600
+ }
601
+ }
602
+ return [...merged.values()];
603
+ }
604
+
605
+ /**
606
+ * Pure plan builder for uninstall (issue #16 T1): for each unique destination across the
607
+ * requested scopes, classify the installation and compute the removals, directory cleanup, and
608
+ * manifest removal that a clean uninstall would perform. Never writes; recorded-but-absent files
609
+ * are reported, modified or unsafe content blocks its whole destination, and unmanaged content is
610
+ * preserved.
611
+ */
612
+ export function buildUninstallPlan({ targets: selectedTargets, scopes: selectedScopes, cwd, home }) {
613
+ const destinations = mergeScopedDestinations(selectedTargets, selectedScopes, { cwd, home }).map(
614
+ ({ destination, targets: destinationTargets, scopes: destinationScopes }) =>
615
+ classifyUninstallDestination(destination, destinationTargets, destinationScopes),
616
+ );
617
+ const status = destinations.some((entry) => entry.status === "blocked" || entry.status === "unknown")
618
+ ? "blocked"
619
+ : destinations.every((entry) => entry.status === "absent") ? "absent" : "uninstall";
620
+ return { destinations, status };
621
+ }
622
+
623
+ /**
624
+ * Execute an uninstall plan. Dry runs write nothing; removal is all-or-nothing per destination
625
+ * (one blocked destination never prevents the removal of others, and an execution-time failure
626
+ * refuses only its own destination). A pre-verification pass re-checks the physical identities
627
+ * (BigInt dev:ino of the destination ROOT and its parent, both recorded at plan time), every
628
+ * removal candidate (intermediate components, final file kind, manifest checksum), the
629
+ * destination ROOT itself, and the manifest before any write, so — in all but race conditions —
630
+ * a refusal happens before anything is removed. The identity comparison is what catches an
631
+ * ancestor swap (a junction or symlink in a path component ABOVE the destination), which every
632
+ * final-component lstat check is blind to; the per-file checks catch everything else. If a race
633
+ * is detected mid-loop, the destination stops immediately: the returned `removedFiles`
634
+ * honestly reports exactly what was removed and the entry is marked blocked. No atomicity beyond
635
+ * that is claimed or implemented. The manifest is removed LAST (the commit point): a destination
636
+ * whose manifest is gone therefore implies its files and directory cleanup already succeeded, so
637
+ * re-running the uninstall converges after any earlier partial failure.
638
+ */
639
+ export function executeUninstallPlan(plan, { dryRun = false } = {}) {
640
+ const removedFiles = [];
641
+ const removedDirectories = [];
642
+ const blocked = plan.destinations
643
+ .filter((entry) => entry.status === "blocked" || entry.status === "unknown")
644
+ .flatMap((entry) => entry.details);
645
+ const unmanaged = plan.destinations.flatMap((entry) => entry.unmanagedFiles);
646
+ let status = plan.status === "blocked" ? "blocked" : plan.status === "absent" ? "absent" : "uninstalled";
647
+
648
+ const refuse = (entry, detail) => {
649
+ entry.status = "blocked";
650
+ entry.details.push(detail);
651
+ blocked.push(detail);
652
+ status = "blocked";
653
+ };
654
+
655
+ // Physical-identity re-check (round-2 P1): recompute the BigInt dev:ino of the destination
656
+ // root and its parent and compare them against the identities recorded at plan time. A
657
+ // mismatch — including a vanished path — means the destination or an ancestor was swapped
658
+ // after planning, which no final-component lstat can see; the whole destination is refused
659
+ // and nothing is removed. A null recorded identity (inode-less filesystem, `ino === 0n`)
660
+ // skips that comparison and falls back to the lstat-based checks alone.
661
+ const identityRefusal = (entry) => {
662
+ if (entry.rootIdentity !== null && directoryIdentity(entry.destination) !== entry.rootIdentity) {
663
+ return "skill destination (or its parent) changed identity since planning";
664
+ }
665
+ if (entry.parentIdentity !== null && directoryIdentity(dirname(entry.destination)) !== entry.parentIdentity) {
666
+ return "skill destination (or its parent) changed identity since planning";
667
+ }
668
+ return null;
669
+ };
670
+ // The manifest lives in the destination's PARENT, and the manifest unlink happens after the
671
+ // destination directory itself has already been removed (by design, as the commit point), so
672
+ // the manifest's guard compares the parent identity only.
673
+ const parentIdentityRefusal = (entry) => {
674
+ if (entry.parentIdentity !== null && directoryIdentity(dirname(entry.destination)) !== entry.parentIdentity) {
675
+ return "skill destination (or its parent) changed identity since planning";
676
+ }
677
+ return null;
678
+ };
679
+
680
+ // Re-check one removal candidate immediately before its unlink (defense in depth, PR#29 round 3):
681
+ // - the destination ROOT itself must still be a real, non-symlink directory (PR round-1 P1
682
+ // finding): a junction swapped in for the skill directory after planning redirects every
683
+ // per-file check through the link, so the full-path lstat would see external content as a
684
+ // regular file with the recorded checksum and the unlink would delete content OUTSIDE the
685
+ // installation;
686
+ // - every intermediate component must still be a real directory: a junction or symlink swapped
687
+ // in after planning would make the plain full-path lstat see the redirected file as a regular
688
+ // file and the unlink would delete content OUTSIDE the skill directory;
689
+ // - the final component must still be a regular file (never follow or unlink a symlink);
690
+ // - the content must still match the manifest checksum that made it a removal candidate: only
691
+ // checksum-verified manifest content is ever removed (S1/S2).
692
+ const verifyDestinationRoot = (entry) => {
693
+ let directoryStats = null;
694
+ try {
695
+ directoryStats = lstatSync(entry.destination);
696
+ } catch {
697
+ directoryStats = null;
698
+ }
699
+ if (directoryStats === null || directoryStats.isSymbolicLink() || !directoryStats.isDirectory()) {
700
+ return `skill destination is no longer a managed directory: ${entry.destination}`;
701
+ }
702
+ return identityRefusal(entry);
703
+ };
704
+ const verifyRemovalCandidate = (entry, removal) => {
705
+ const rootRefusal = verifyDestinationRoot(entry);
706
+ if (rootRefusal !== null) return rootRefusal;
707
+ const unsafeIntermediate = unsafeIntermediateComponent(entry.destination, removal.path);
708
+ if (unsafeIntermediate !== null) {
709
+ return `${removal.path} (intermediate ${unsafeIntermediate} is a symlink or not a directory at execution time)`;
710
+ }
711
+ let stats = null;
712
+ try {
713
+ stats = lstatSync(removal.destination);
714
+ } catch {
715
+ stats = null;
716
+ }
717
+ if (stats === null || stats.isSymbolicLink() || !stats.isFile()) {
718
+ return `${removal.path} (no longer a regular file at execution time)`;
719
+ }
720
+ if (sha256(readFileSync(removal.destination)) !== removal.sha256) {
721
+ return `${removal.path} (modified after planning)`;
722
+ }
723
+ return null;
724
+ };
725
+
726
+ if (!dryRun) {
727
+ for (const entry of plan.destinations) {
728
+ if (entry.status !== "uninstall") continue;
729
+ // Pre-verification pass: re-validate every removal candidate and the manifest before
730
+ // touching anything, so in all but race conditions a refusal happens before any write.
731
+ // Errors are isolated per destination (a changed destination refuses only itself).
732
+ let refused = null;
733
+ try {
734
+ // The destination ROOT is re-validated first, before ANY unlink of this destination (the
735
+ // per-unlink re-checks below re-run it through `verifyRemovalCandidate`).
736
+ refused = verifyDestinationRoot(entry);
737
+ if (refused === null) {
738
+ for (const removal of entry.removals) {
739
+ refused = verifyRemovalCandidate(entry, removal);
740
+ if (refused !== null) break;
741
+ }
742
+ }
743
+ if (refused === null) {
744
+ let manifestStats = null;
745
+ try {
746
+ manifestStats = lstatSync(entry.manifestPath);
747
+ } catch (error) {
748
+ refused = `manifest (changed during execution: ${error.message})`;
749
+ }
750
+ if (refused === null && (manifestStats.isSymbolicLink() || !manifestStats.isFile())) {
751
+ refused = `manifest is no longer a regular file: ${entry.manifestPath}`;
752
+ }
753
+ if (refused === null) {
754
+ // The manifest lives in the destination's parent: that parent's physical identity
755
+ // is re-checked here too, since a swapped parent would redirect the manifest unlink
756
+ // outside the installation even though the manifest itself is still a regular file.
757
+ refused = parentIdentityRefusal(entry);
758
+ }
759
+ }
760
+ } catch (error) {
761
+ refused = `destination changed during execution: ${error.message}`;
762
+ }
763
+ if (refused !== null) {
764
+ refuse(entry, refused);
765
+ continue;
766
+ }
767
+ try {
768
+ for (const removal of entry.removals) {
769
+ // Re-verify immediately before EACH unlink (the PR#29 round-3 lesson, extended to
770
+ // intermediate components and manifest checksums).
771
+ refused = verifyRemovalCandidate(entry, removal);
772
+ if (refused !== null) break;
773
+ unlinkSync(removal.destination);
774
+ removedFiles.push(removal.destination);
775
+ }
776
+ if (refused === null) {
777
+ for (const directory of entry.directories) {
778
+ // The root + parent identities are re-checked before every destructive operation,
779
+ // including each rmdir: a swap mid-loop refuses the rest of the destination.
780
+ refused = verifyDestinationRoot(entry);
781
+ if (refused !== null) break;
782
+ try {
783
+ rmdirSync(directory);
784
+ removedDirectories.push(directory);
785
+ } catch (error) {
786
+ // ENOTEMPTY means unmanaged content (including an untracked empty directory) keeps
787
+ // the directory alive: a skip, not an error. ENOENT means it is already gone.
788
+ if (error.code !== "ENOTEMPTY" && error.code !== "ENOENT") throw error;
789
+ }
790
+ }
791
+ }
792
+ if (refused === null) {
793
+ // The manifest is the commit point: it is unlinked only after every file and directory
794
+ // removal succeeded, so a destination without a manifest is fully cleaned and re-running
795
+ // the uninstall converges. Its guard compares the PARENT identity only: the destination
796
+ // directory itself has already been removed at this point (by design), so only the
797
+ // parent — where the manifest lives — can still be compared. ENOENT means something
798
+ // else already removed it.
799
+ refused = parentIdentityRefusal(entry);
800
+ }
801
+ if (refused === null) {
802
+ try {
803
+ unlinkSync(entry.manifestPath);
804
+ removedFiles.push(entry.manifestPath);
805
+ } catch (error) {
806
+ if (error.code !== "ENOENT") throw error;
807
+ }
808
+ }
809
+ } catch (error) {
810
+ refused = `destination changed during execution: ${error.message}`;
811
+ }
812
+ if (refused !== null) {
813
+ refuse(entry, refused);
814
+ }
815
+ }
816
+ }
817
+
818
+ return { status, removedFiles, removedDirectories, blocked, unmanaged, plan };
819
+ }
820
+
821
+ /**
822
+ * Read-only classification for the `status` command (issue #15 T1): for one unique destination
823
+ * (deduplicated across the requested scopes), report exactly one status in precedence order:
824
+ * absent, unknown, modified, outdated, managed. Never writes and never follows symlinks
825
+ * (manifests are checked with `inspectManifestPath` and managed files with `lstatSync`).
826
+ *
827
+ * Decision (recorded per task T1): extra unmanaged files beside a fully matching manifest keep
828
+ * the status `managed` and are only listed in `details`. The installer treats unmanaged content
829
+ * as a write-time conflict, but this report is read-only-informational: a verified install whose
830
+ * checksums all match must not be downgraded merely because the user added their own files.
831
+ */
832
+ export function inspectInstallations({ targets: selectedTargets, scopes: selectedScopes, cwd, home, installerVersion: currentInstallerVersion }) {
833
+ const entries = [];
834
+ for (const { destination, targets: destinationTargets, scopes: destinationScopes } of mergeScopedDestinations(selectedTargets, selectedScopes, { cwd, home })) {
835
+ const skillDirectory = destination;
836
+ const manifestPath = join(dirname(skillDirectory), MANIFEST_NAME);
837
+ const details = [];
838
+ let manifestVersion = null;
839
+
840
+ let directoryStats = null;
841
+ try {
842
+ directoryStats = lstatSync(skillDirectory);
843
+ } catch (error) {
844
+ // ENOTDIR (POSIX) for a non-directory ancestor of the destination means no managed
845
+ // installation can exist under it: treat it as absent (matches inspectManifestPath).
846
+ if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error;
847
+ }
848
+ if (directoryStats === null) {
849
+ entries.push({ status: "absent", scopes: destinationScopes, destination: skillDirectory, manifestPath, targets: destinationTargets, manifestVersion, details });
850
+ continue;
851
+ }
852
+ if (directoryStats.isSymbolicLink() || !directoryStats.isDirectory()) {
853
+ details.push(`skill destination is not a managed directory: ${skillDirectory}`);
854
+ entries.push({ status: "unknown", scopes: destinationScopes, destination: skillDirectory, manifestPath, targets: destinationTargets, manifestVersion, details });
855
+ continue;
856
+ }
857
+
858
+ const manifestKind = inspectManifestPath(manifestPath).kind;
859
+ if (manifestKind === "symlink") details.push(`manifest is a symlink: ${manifestPath}`);
860
+ else if (manifestKind === "nonregular") details.push(`manifest is not a regular file: ${manifestPath}`);
861
+ else if (manifestKind === "absent") details.push(`manifest is missing: ${manifestPath}`);
862
+ const manifest = manifestKind === "regular" ? readManifestFile(manifestPath) : null;
863
+ if (manifestKind === "regular" && manifest === null) details.push(`corrupt manifest: ${manifestPath}`);
864
+ if (manifest === null) {
865
+ entries.push({ status: "unknown", scopes: destinationScopes, destination: skillDirectory, manifestPath, targets: destinationTargets, manifestVersion, details });
866
+ continue;
867
+ }
868
+
869
+ manifestVersion = manifest.installerVersion;
870
+ // Never inspect recorded paths that escape the skill directory: a crafted manifest must not
871
+ // drive checksum reads outside it. Such a manifest is untrustworthy as a whole, so the
872
+ // destination is unknown instead of modified (nothing was verified against it). A
873
+ // non-canonical record (backslash, empty/`.`/`..` segment) is rejected here too: every
874
+ // surviving record is canonical, so `join()` in `toDestinationPath` performs no
875
+ // normalization and `unsafeIntermediateComponent` below validates exactly the components of
876
+ // the path that will actually be read — no normalization gap remains.
877
+ const unsafeRecordedPaths = manifest.files.filter((file) => {
878
+ if (!isCanonicalRecordedPath(file.path)) return true;
879
+ const resolvedPath = resolve(toDestinationPath(skillDirectory, file.path));
880
+ return resolvedPath !== skillDirectory && !resolvedPath.startsWith(skillDirectory + sep);
881
+ });
882
+ if (unsafeRecordedPaths.length > 0) {
883
+ for (const file of unsafeRecordedPaths) details.push(`${file.path} (unsafe manifest path)`);
884
+ entries.push({ status: "unknown", scopes: destinationScopes, destination: skillDirectory, manifestPath, targets: destinationTargets, manifestVersion, details });
885
+ continue;
886
+ }
887
+ const recordedPaths = new Set(manifest.files.map((file) => file.path));
888
+ let managedFileMismatch = false;
889
+ for (const file of manifest.files) {
890
+ const destinationPath = toDestinationPath(skillDirectory, file.path);
891
+ const unsafeIntermediate = unsafeIntermediateComponent(skillDirectory, file.path);
892
+ // Resolve the final component ONLY when every intermediate is safe: lstat still traverses
893
+ // intermediate symlinks during path resolution, so a cyclic one would throw ELOOP here
894
+ // (POSIX) and abort the whole inspection instead of degrading to a per-file mismatch.
895
+ let fileStats = null;
896
+ if (unsafeIntermediate === null) {
897
+ try {
898
+ fileStats = lstatSync(destinationPath);
899
+ } catch (error) {
900
+ // ENOTDIR (and the Windows-mapped ENOENT for traversal through a regular file) mean an
901
+ // intermediate is not a directory: degrade to a per-file mismatch, never abort the report.
902
+ if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error;
903
+ }
904
+ }
905
+ if (unsafeIntermediate !== null) {
906
+ // A symlinked or non-directory intermediate must never be read through: the recorded file
907
+ // is unverifiable, so it counts as a mismatch instead of confirming managed content.
908
+ managedFileMismatch = true;
909
+ details.push(`${file.path} (intermediate ${unsafeIntermediate} is a symlink or not a directory)`);
910
+ } else if (fileStats === null) {
911
+ managedFileMismatch = true;
912
+ details.push(`${file.path} (missing)`);
913
+ } else if (fileStats.isSymbolicLink() || !fileStats.isFile()) {
914
+ // Never follow symlinks to read a checksum: an unverifiable managed path counts as a mismatch.
915
+ managedFileMismatch = true;
916
+ details.push(`${file.path} (symlinked or not a regular file)`);
917
+ } else if (sha256(readFileSync(destinationPath)) !== file.sha256) {
918
+ managedFileMismatch = true;
919
+ details.push(`${file.path} (modified)`);
920
+ }
921
+ }
922
+ for (const existingPath of listFiles(skillDirectory)) {
923
+ const relativePath = toRelativePath(relative(skillDirectory, existingPath));
924
+ if (recordedPaths.has(relativePath)) continue;
925
+ details.push(`${relativePath} (unmanaged)`);
926
+ }
927
+
928
+ const status = managedFileMismatch
929
+ ? "modified"
930
+ : manifest.installerVersion === currentInstallerVersion ? "managed" : "outdated";
931
+ entries.push({ status, scopes: destinationScopes, destination: skillDirectory, manifestPath, targets: destinationTargets, manifestVersion, details });
932
+ }
933
+ return entries;
934
+ }