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.
- package/LICENSE +21 -0
- package/README.md +270 -2
- package/bin/mobile-agent-orchestrator.mjs +513 -0
- package/lib/detect.mjs +117 -0
- package/lib/hosts.mjs +48 -0
- package/lib/installer.mjs +934 -0
- package/lib/prompt.mjs +250 -0
- package/package.json +36 -8
- package/skills/mobile-agent-orchestrator/SKILL.md +48 -0
- package/skills/mobile-agent-orchestrator/references/agent-hosts.md +45 -0
- package/skills/mobile-agent-orchestrator/references/guided-install.md +89 -0
- package/skills/mobile-agent-orchestrator/references/platform-matrix.md +73 -0
- package/skills/mobile-agent-orchestrator/references/verification-and-recovery.md +64 -0
|
@@ -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
|
+
}
|