@uluops/setup 0.11.0 → 0.13.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/CHANGELOG.md +805 -0
- package/README.md +81 -32
- package/dist/cli.js +7 -1
- package/dist/commands/helpers.js +70 -7
- package/dist/commands/per-harness.d.ts +5 -0
- package/dist/commands/per-harness.js +5 -0
- package/dist/commands/setup.d.ts +7 -0
- package/dist/commands/setup.js +100 -35
- package/dist/commands/uninstall.d.ts +7 -0
- package/dist/commands/uninstall.js +36 -9
- package/dist/commands/verify.d.ts +5 -0
- package/dist/commands/verify.js +5 -0
- package/dist/harnesses/claude-code.js +15 -7
- package/dist/harnesses/codex.js +35 -8
- package/dist/harnesses/gemini-cli.js +13 -6
- package/dist/harnesses/index.d.ts +8 -0
- package/dist/harnesses/index.js +10 -0
- package/dist/harnesses/opencode.js +25 -7
- package/dist/lib/asset-catalog.js +15 -2
- package/dist/lib/atomic-write.d.ts +6 -0
- package/dist/lib/atomic-write.js +10 -0
- package/dist/lib/config-merger.js +27 -8
- package/dist/lib/display.d.ts +8 -0
- package/dist/lib/display.js +27 -1
- package/dist/lib/file-ops.d.ts +13 -4
- package/dist/lib/file-ops.js +69 -18
- package/dist/lib/install-lock.js +45 -13
- package/dist/lib/json-guards.d.ts +15 -0
- package/dist/lib/json-guards.js +30 -0
- package/dist/lib/manifest.d.ts +9 -2
- package/dist/lib/manifest.js +66 -8
- package/dist/lib/mcp-packages.d.ts +17 -15
- package/dist/lib/mcp-packages.js +15 -13
- package/dist/lib/settings-merger.js +53 -9
- package/dist/lib/version.js +19 -2
- package/dist/lib/write-coordinator.d.ts +50 -0
- package/dist/lib/write-coordinator.js +89 -0
- package/dist/steps/agent-metrics-cli.d.ts +6 -0
- package/dist/steps/agent-metrics-cli.js +19 -1
- package/dist/steps/agents.js +22 -4
- package/dist/steps/auth.js +53 -13
- package/dist/steps/cli.js +14 -1
- package/dist/steps/commands.js +28 -10
- package/dist/steps/mcp.js +18 -9
- package/dist/steps/metrics.js +77 -7
- package/dist/steps/shell.d.ts +4 -1
- package/dist/steps/shell.js +44 -6
- package/dist/steps/signup.d.ts +4 -0
- package/dist/steps/signup.js +18 -2
- package/dist/steps/skills.d.ts +11 -0
- package/dist/steps/skills.js +35 -6
- package/dist/steps/username.js +10 -2
- package/dist/steps/verify.js +55 -6
- package/package.json +7 -4
- package/dist/lib/agent-transform.d.ts +0 -12
- package/dist/lib/agent-transform.js +0 -129
package/dist/lib/file-ops.js
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
|
-
import { readFile,
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { readFile, mkdir, unlink, readdir } from "node:fs/promises";
|
|
2
|
+
import { join, resolve, relative, isAbsolute } from "node:path";
|
|
3
3
|
import { fileHash } from "./hash.js";
|
|
4
|
+
import { atomicWrite } from "./atomic-write.js";
|
|
5
|
+
/**
|
|
6
|
+
* True when `err` is fs ENOENT — the ONLY read error that means "absent".
|
|
7
|
+
* Every read-then-overwrite path must use this before treating a file as
|
|
8
|
+
* fresh: EACCES/EISDIR/EIO also land in a catch, and inferring "absent" from
|
|
9
|
+
* them turns an unreadable-but-present config into a fresh-file overwrite
|
|
10
|
+
* that destroys the user's content. (This class was fixed once at
|
|
11
|
+
* steps/mcp.ts's gitignore path and recurred at five other sites — hence a
|
|
12
|
+
* shared predicate rather than five inline checks.)
|
|
13
|
+
*/
|
|
14
|
+
export function isEnoent(err) {
|
|
15
|
+
return err?.code === "ENOENT";
|
|
16
|
+
}
|
|
4
17
|
/**
|
|
5
18
|
* Copy a file if its content has changed (hash comparison). Returns "copied" or "skipped".
|
|
6
19
|
*/
|
|
@@ -13,11 +26,17 @@ export async function copyIfChanged(srcPath, destPath, dryRun) {
|
|
|
13
26
|
return "skipped";
|
|
14
27
|
}
|
|
15
28
|
}
|
|
16
|
-
catch {
|
|
17
|
-
//
|
|
29
|
+
catch (err) {
|
|
30
|
+
// Only genuine absence means "copy fresh" — an unreadable-but-present
|
|
31
|
+
// destination should surface, not be silently overwritten (isEnoent doc).
|
|
32
|
+
if (!isEnoent(err))
|
|
33
|
+
throw err;
|
|
18
34
|
}
|
|
19
35
|
if (!dryRun) {
|
|
20
|
-
|
|
36
|
+
// Atomic: a crash mid-copy must not leave a truncated agent/command file
|
|
37
|
+
// whose hash matches neither side (re-run would fix it, but the harness
|
|
38
|
+
// may load the torn file first).
|
|
39
|
+
await atomicWrite(destPath, srcContent);
|
|
21
40
|
}
|
|
22
41
|
return "copied";
|
|
23
42
|
}
|
|
@@ -33,26 +52,51 @@ export async function writeIfChanged(destPath, content, dryRun) {
|
|
|
33
52
|
return "skipped";
|
|
34
53
|
}
|
|
35
54
|
}
|
|
36
|
-
catch {
|
|
37
|
-
|
|
55
|
+
catch (err) {
|
|
56
|
+
if (!isEnoent(err))
|
|
57
|
+
throw err; // see copyIfChanged
|
|
38
58
|
}
|
|
39
59
|
if (!dryRun) {
|
|
40
|
-
await
|
|
60
|
+
await atomicWrite(destPath, content);
|
|
41
61
|
}
|
|
42
62
|
return "copied";
|
|
43
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Containment gate for manifest-supplied file names (CWE-22): the manifest
|
|
66
|
+
* is a same-UID-writable JSON file, and a hand-edited or foreign-written
|
|
67
|
+
* entry containing `../` would otherwise turn uninstall into an
|
|
68
|
+
* arbitrary-delete primitive. Only paths that resolve INSIDE `dir` pass.
|
|
69
|
+
*/
|
|
70
|
+
function resolveContained(dir, file) {
|
|
71
|
+
const base = resolve(dir);
|
|
72
|
+
const target = resolve(base, file);
|
|
73
|
+
const rel = relative(base, target);
|
|
74
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel))
|
|
75
|
+
return null;
|
|
76
|
+
return target;
|
|
77
|
+
}
|
|
44
78
|
/**
|
|
45
79
|
* Remove files from a directory. Returns count of successfully removed files.
|
|
46
80
|
*/
|
|
47
81
|
export async function unlinkFiles(dir, files) {
|
|
48
82
|
let removed = 0;
|
|
49
83
|
for (const file of files) {
|
|
84
|
+
const target = resolveContained(dir, file);
|
|
85
|
+
if (target === null) {
|
|
86
|
+
console.warn(` ⚠ Refusing to remove ${JSON.stringify(file)} — resolves outside ${dir}`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
50
89
|
try {
|
|
51
|
-
await unlink(
|
|
90
|
+
await unlink(target);
|
|
52
91
|
removed++;
|
|
53
92
|
}
|
|
54
|
-
catch {
|
|
55
|
-
//
|
|
93
|
+
catch (err) {
|
|
94
|
+
// ENOENT = already gone (the dominant, idempotent case). Anything
|
|
95
|
+
// else is a file we FAILED to remove — say so, because the caller's
|
|
96
|
+
// count alone reads as success and the manifest may be deleted next.
|
|
97
|
+
if (!isEnoent(err)) {
|
|
98
|
+
console.warn(` ⚠ Could not remove ${join(dir, file)}: ${err instanceof Error ? err.message : String(err)}`);
|
|
99
|
+
}
|
|
56
100
|
}
|
|
57
101
|
}
|
|
58
102
|
return removed;
|
|
@@ -64,10 +108,9 @@ export async function unlinkFiles(dir, files) {
|
|
|
64
108
|
* (whether or not the unlink actually ran in dry-run mode).
|
|
65
109
|
*
|
|
66
110
|
* Extracted from three near-identical blocks in syncAssets, installAgents,
|
|
67
|
-
* and installCommands.
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* can usefully perform mid-loop.
|
|
111
|
+
* and installCommands. ENOENT unlink failures are tolerated silently (the
|
|
112
|
+
* dominant, idempotent case); any other failure is warned by name and
|
|
113
|
+
* excluded from the removed count.
|
|
71
114
|
*/
|
|
72
115
|
export async function removeStaleFiles(destDir, oldManifestFiles, currentFiles, dryRun) {
|
|
73
116
|
if (!oldManifestFiles)
|
|
@@ -75,12 +118,20 @@ export async function removeStaleFiles(destDir, oldManifestFiles, currentFiles,
|
|
|
75
118
|
let removed = 0;
|
|
76
119
|
for (const oldFile of oldManifestFiles) {
|
|
77
120
|
if (!currentFiles.includes(oldFile)) {
|
|
121
|
+
const staleTarget = resolveContained(destDir, oldFile);
|
|
122
|
+
if (staleTarget === null) {
|
|
123
|
+
console.warn(` ⚠ Refusing to remove stale ${JSON.stringify(oldFile)} — resolves outside ${destDir}`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
78
126
|
if (!dryRun) {
|
|
79
127
|
try {
|
|
80
|
-
await unlink(
|
|
128
|
+
await unlink(staleTarget);
|
|
81
129
|
}
|
|
82
|
-
catch {
|
|
83
|
-
|
|
130
|
+
catch (err) {
|
|
131
|
+
if (!isEnoent(err)) {
|
|
132
|
+
console.warn(` ⚠ Could not remove stale ${join(destDir, oldFile)}: ${err instanceof Error ? err.message : String(err)}`);
|
|
133
|
+
continue; // failed — must not count as removed
|
|
134
|
+
}
|
|
84
135
|
}
|
|
85
136
|
}
|
|
86
137
|
removed++;
|
package/dist/lib/install-lock.js
CHANGED
|
@@ -26,7 +26,9 @@ const POLL_INTERVAL_MS = 500;
|
|
|
26
26
|
export class InstallLockHeldError extends Error {
|
|
27
27
|
holder;
|
|
28
28
|
constructor(holder) {
|
|
29
|
-
super(
|
|
29
|
+
super(holder.pid > 0
|
|
30
|
+
? `Another uluops-setup process is already running (PID ${holder.pid} on ${holder.hostname}, started ${Math.round(holder.ageMs / 1000)}s ago).`
|
|
31
|
+
: `Another uluops-setup process appears to be running but could not be identified (${holder.hostname}). If no other setup is running, re-run in a moment or remove ~/.uluops/install.lock manually.`);
|
|
30
32
|
this.holder = holder;
|
|
31
33
|
this.name = "InstallLockHeldError";
|
|
32
34
|
}
|
|
@@ -61,7 +63,9 @@ export async function acquireInstallLock(opts = {}) {
|
|
|
61
63
|
hostname: hostname(),
|
|
62
64
|
startedAt: Date.now(),
|
|
63
65
|
};
|
|
64
|
-
await writeFile(join(lockDir, META_FILENAME), JSON.stringify(meta)
|
|
66
|
+
await writeFile(join(lockDir, META_FILENAME), JSON.stringify(meta), {
|
|
67
|
+
mode: 0o600,
|
|
68
|
+
});
|
|
65
69
|
return registerHandle(lockDir);
|
|
66
70
|
}
|
|
67
71
|
catch (err) {
|
|
@@ -89,11 +93,16 @@ export async function acquireInstallLock(opts = {}) {
|
|
|
89
93
|
// Retry once after wait.
|
|
90
94
|
continue;
|
|
91
95
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
if (verdict.kind === "live") {
|
|
97
|
+
throw new InstallLockHeldError({
|
|
98
|
+
pid: verdict.meta.pid,
|
|
99
|
+
hostname: verdict.meta.hostname,
|
|
100
|
+
ageMs: Date.now() - verdict.meta.startedAt,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
// "held" — present but unverifiable (unreadable meta). Never reclaim:
|
|
104
|
+
// stealing a possibly-live lock disarms the mutual exclusion.
|
|
105
|
+
throw new InstallLockHeldError({ pid: -1, hostname: verdict.reason, ageMs: 0 });
|
|
97
106
|
}
|
|
98
107
|
// Both attempts exhausted without acquiring.
|
|
99
108
|
throw new InstallLockHeldError({ pid: -1, hostname: "unknown", ageMs: 0 });
|
|
@@ -104,9 +113,26 @@ async function inspectHeldLock(lockDir, maxAgeMs) {
|
|
|
104
113
|
try {
|
|
105
114
|
raw = await readFile(metaPath, "utf-8");
|
|
106
115
|
}
|
|
107
|
-
catch {
|
|
108
|
-
|
|
109
|
-
|
|
116
|
+
catch (err) {
|
|
117
|
+
if (err?.code !== "ENOENT") {
|
|
118
|
+
// Unreadable-but-present meta is UNVERIFIABLE, not stale: classifying
|
|
119
|
+
// it stale lets a second process rm -rf a live holder's lock and
|
|
120
|
+
// disarms the mutual exclusion entirely.
|
|
121
|
+
return {
|
|
122
|
+
kind: "held",
|
|
123
|
+
reason: `meta.json unreadable (${err instanceof Error ? err.message : String(err)})`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// Genuinely missing meta: usually a crashed holder — but also the
|
|
127
|
+
// window between the winner's mkdir and its meta write. Grace-recheck:
|
|
128
|
+
// a race resolves in milliseconds, a crash leaves it missing forever.
|
|
129
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
130
|
+
try {
|
|
131
|
+
raw = await readFile(metaPath, "utf-8");
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return { kind: "stale", reason: "missing-meta" };
|
|
135
|
+
}
|
|
110
136
|
}
|
|
111
137
|
let meta;
|
|
112
138
|
try {
|
|
@@ -175,7 +201,6 @@ function registerHandle(lockDir) {
|
|
|
175
201
|
if (released)
|
|
176
202
|
return;
|
|
177
203
|
released = true;
|
|
178
|
-
heldLockDirs.delete(lockDir);
|
|
179
204
|
try {
|
|
180
205
|
await unlink(join(lockDir, META_FILENAME));
|
|
181
206
|
}
|
|
@@ -185,9 +210,16 @@ function registerHandle(lockDir) {
|
|
|
185
210
|
try {
|
|
186
211
|
await rm(lockDir, { recursive: true, force: true });
|
|
187
212
|
}
|
|
188
|
-
catch {
|
|
189
|
-
//
|
|
213
|
+
catch (err) {
|
|
214
|
+
// force:true tolerates ENOENT, so this is a REAL failure
|
|
215
|
+
// (EACCES/EBUSY). Best-effort — never throw from release() — but
|
|
216
|
+
// say so; a surviving lock blocks the next run until staleness.
|
|
217
|
+
console.warn(` ⚠ Could not remove install lock at ${lockDir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
190
218
|
}
|
|
219
|
+
// Deregister only AFTER the dir is actually gone: a signal landing
|
|
220
|
+
// mid-release must still find the dir in the set so the sync handler
|
|
221
|
+
// can clean it (deleting first opened a leak window).
|
|
222
|
+
heldLockDirs.delete(lockDir);
|
|
191
223
|
},
|
|
192
224
|
};
|
|
193
225
|
}
|
|
@@ -19,4 +19,19 @@
|
|
|
19
19
|
* portal, schema breakage) and should surface to the user rather than be
|
|
20
20
|
* papered over as "logged in with no email."
|
|
21
21
|
*/
|
|
22
|
+
/**
|
|
23
|
+
* Recursively delete `__proto__` OWN keys from parsed-JSON data. Our own
|
|
24
|
+
* merges are spread-based (CreateDataProperty semantics — they cannot be
|
|
25
|
+
* polluted), but a config we read and write BACK would hand a `__proto__`
|
|
26
|
+
* own-key to every other consumer of the file, some of which merge with
|
|
27
|
+
* assign semantics. Strip at the read boundary so the hazard never
|
|
28
|
+
* round-trips. Mutates in place and returns the input.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately __proto__ ONLY: assign-semantics pollution runs through the
|
|
31
|
+
* `__proto__` setter; own-property `constructor`/`prototype` keys assigned
|
|
32
|
+
* by Object.assign land as plain data properties and pollute nothing —
|
|
33
|
+
* while stripping them would silently eat legitimate keys from user configs
|
|
34
|
+
* (JSON-schema fragments, template maps) on the round-trip.
|
|
35
|
+
*/
|
|
36
|
+
export declare function stripDangerousKeys<T>(value: T): T;
|
|
22
37
|
export declare function extractEmail(body: unknown): string | null;
|
package/dist/lib/json-guards.js
CHANGED
|
@@ -19,6 +19,36 @@
|
|
|
19
19
|
* portal, schema breakage) and should surface to the user rather than be
|
|
20
20
|
* papered over as "logged in with no email."
|
|
21
21
|
*/
|
|
22
|
+
/**
|
|
23
|
+
* Recursively delete `__proto__` OWN keys from parsed-JSON data. Our own
|
|
24
|
+
* merges are spread-based (CreateDataProperty semantics — they cannot be
|
|
25
|
+
* polluted), but a config we read and write BACK would hand a `__proto__`
|
|
26
|
+
* own-key to every other consumer of the file, some of which merge with
|
|
27
|
+
* assign semantics. Strip at the read boundary so the hazard never
|
|
28
|
+
* round-trips. Mutates in place and returns the input.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately __proto__ ONLY: assign-semantics pollution runs through the
|
|
31
|
+
* `__proto__` setter; own-property `constructor`/`prototype` keys assigned
|
|
32
|
+
* by Object.assign land as plain data properties and pollute nothing —
|
|
33
|
+
* while stripping them would silently eat legitimate keys from user configs
|
|
34
|
+
* (JSON-schema fragments, template maps) on the round-trip.
|
|
35
|
+
*/
|
|
36
|
+
export function stripDangerousKeys(value) {
|
|
37
|
+
if (typeof value !== "object" || value === null)
|
|
38
|
+
return value;
|
|
39
|
+
if (Array.isArray(value)) {
|
|
40
|
+
for (const item of value)
|
|
41
|
+
stripDangerousKeys(item);
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
const record = value;
|
|
45
|
+
if (Object.prototype.hasOwnProperty.call(record, "__proto__")) {
|
|
46
|
+
delete record["__proto__"];
|
|
47
|
+
}
|
|
48
|
+
for (const v of Object.values(record))
|
|
49
|
+
stripDangerousKeys(v);
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
22
52
|
export function extractEmail(body) {
|
|
23
53
|
if (typeof body !== "object" || body === null) {
|
|
24
54
|
throw new Error("API returned an unexpected response shape (not an object). The endpoint may have changed — try --skip-validation to continue offline.");
|
package/dist/lib/manifest.d.ts
CHANGED
|
@@ -86,5 +86,12 @@ export declare function validateManifest(manifest: Manifest): Promise<ManifestVa
|
|
|
86
86
|
export declare function loadManifest(): Promise<Manifest | null>;
|
|
87
87
|
/** Save the install manifest to ~/.uluops/manifest.json. Creates directory if needed. */
|
|
88
88
|
export declare function saveManifest(manifest: Manifest): Promise<void>;
|
|
89
|
-
/**
|
|
90
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Delete the install manifest file from disk. Tries both locations.
|
|
91
|
+
* Returns the paths that could NOT be removed (non-ENOENT failures) so the
|
|
92
|
+
* caller can report the truth instead of an unconditional success —
|
|
93
|
+
* a manifest that survives keeps claiming a full install.
|
|
94
|
+
*/
|
|
95
|
+
export declare function deleteManifest(): Promise<{
|
|
96
|
+
failed: string[];
|
|
97
|
+
}>;
|
package/dist/lib/manifest.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { getManifestPath, getLegacyManifestPath, getUluopsDir } from "./paths.js";
|
|
4
4
|
import { fileHash } from "./hash.js";
|
|
5
5
|
import { atomicWrite } from "./atomic-write.js";
|
|
6
|
+
import { isEnoent } from "./file-ops.js";
|
|
6
7
|
function isNewManifest(obj) {
|
|
7
8
|
if (typeof obj !== "object" || obj === null)
|
|
8
9
|
return false;
|
|
@@ -28,10 +29,25 @@ function isNewManifest(obj) {
|
|
|
28
29
|
const hm = h;
|
|
29
30
|
if (typeof hm["mcpConfigPath"] !== "string" || typeof hm["defsPath"] !== "string")
|
|
30
31
|
return false;
|
|
32
|
+
// defsScope is load-bearing (the prev-list inheritance gate branches on
|
|
33
|
+
// it) — an absent/invalid value reads as a permanent scope flip that
|
|
34
|
+
// orphans every recorded file. Refuse-by-name like every other shape.
|
|
35
|
+
if (hm["defsScope"] !== "global" && hm["defsScope"] !== "local")
|
|
36
|
+
return false;
|
|
31
37
|
if (!Array.isArray(hm["agents"]) || !Array.isArray(hm["commands"]))
|
|
32
38
|
return false;
|
|
33
|
-
|
|
39
|
+
// Element typing: a hand-edited agents: [1,2] otherwise reaches
|
|
40
|
+
// join(dir, file) in uninstall and TypeErrors mid-removal.
|
|
41
|
+
if (!hm["agents"].every((x) => typeof x === "string"))
|
|
42
|
+
return false;
|
|
43
|
+
if (!hm["commands"].every((x) => typeof x === "string"))
|
|
34
44
|
return false;
|
|
45
|
+
if ("skills" in hm) {
|
|
46
|
+
if (!Array.isArray(hm["skills"]))
|
|
47
|
+
return false;
|
|
48
|
+
if (!hm["skills"].every((x) => typeof x === "string"))
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
35
51
|
if ("partial" in hm) {
|
|
36
52
|
const p = hm["partial"];
|
|
37
53
|
if (p !== null && p !== "agents" && p !== "commands" && p !== "skills" && p !== "metrics") {
|
|
@@ -49,6 +65,10 @@ function isLegacyManifest(obj) {
|
|
|
49
65
|
typeof m["installedAt"] === "string" &&
|
|
50
66
|
typeof m["mcpConfigPath"] === "string" &&
|
|
51
67
|
typeof m["defsPath"] === "string" &&
|
|
68
|
+
// defsScope is load-bearing post-migration (the inheritance gate
|
|
69
|
+
// branches on it) — validate here so a bad value hits the
|
|
70
|
+
// unrecognized-shape refusal instead of migrating to undefined.
|
|
71
|
+
(m["defsScope"] === "global" || m["defsScope"] === "local") &&
|
|
52
72
|
Array.isArray(m["agents"]) &&
|
|
53
73
|
Array.isArray(m["commands"]) &&
|
|
54
74
|
!("harnesses" in m));
|
|
@@ -118,8 +138,15 @@ export async function validateManifest(manifest) {
|
|
|
118
138
|
raw = await readFile(candidate, "utf-8");
|
|
119
139
|
break;
|
|
120
140
|
}
|
|
121
|
-
catch {
|
|
122
|
-
|
|
141
|
+
catch (err) {
|
|
142
|
+
if (!isEnoent(err)) {
|
|
143
|
+
// Warning-only path (hash tamper check) — an unreadable candidate
|
|
144
|
+
// must not masquerade as "no manifest on disk"; name it and skip
|
|
145
|
+
// the hash check rather than silently treating it as absent.
|
|
146
|
+
warnings.push(`Cannot read manifest at ${candidate} to verify content hash (${err instanceof Error ? err.message : String(err)})`);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
// Absent — try next candidate.
|
|
123
150
|
}
|
|
124
151
|
}
|
|
125
152
|
if (raw !== null) {
|
|
@@ -166,12 +193,26 @@ async function findMissingFiles(baseDir, subDir, files) {
|
|
|
166
193
|
return results.filter((r) => !r.exists).map((r) => r.file);
|
|
167
194
|
}
|
|
168
195
|
async function readManifestFile(path) {
|
|
196
|
+
let raw;
|
|
197
|
+
try {
|
|
198
|
+
raw = await readFile(path, "utf-8");
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
if (isEnoent(err))
|
|
202
|
+
return null; // genuinely absent
|
|
203
|
+
// Unreadable-but-PRESENT must never read as "no manifest": loadManifest's
|
|
204
|
+
// null flows into saveManifest overwriting the file we couldn't read,
|
|
205
|
+
// orphaning every recorded agent/command/hook — and into uninstall's
|
|
206
|
+
// "nothing to uninstall". Same class, same rule as the config readers.
|
|
207
|
+
throw new Error(`Could not read the install manifest at ${path} (${err instanceof Error ? err.message : String(err)}) — refusing to continue rather than overwrite the record of what is installed. Nothing was modified.`);
|
|
208
|
+
}
|
|
169
209
|
try {
|
|
170
|
-
const raw = await readFile(path, "utf-8");
|
|
171
210
|
return JSON.parse(raw);
|
|
172
211
|
}
|
|
173
212
|
catch {
|
|
174
|
-
|
|
213
|
+
// Malformed is not absent either: proceeding would rewrite the file and
|
|
214
|
+
// orphan everything it recorded. Name the path and the way out.
|
|
215
|
+
throw new Error(`The install manifest at ${path} contains invalid JSON — fix or remove it and re-run. (Detected before any UluOps change; nothing was modified. Removing it makes setup treat this as a fresh install; previously installed files will not be tracked for uninstall.)`);
|
|
175
216
|
}
|
|
176
217
|
}
|
|
177
218
|
/** Load the install manifest. Tries new location first, falls back to legacy, auto-migrates. */
|
|
@@ -188,6 +229,13 @@ export async function loadManifest() {
|
|
|
188
229
|
// Also check if legacy location has new format (written by newer version but not yet moved)
|
|
189
230
|
if (legacyData && isNewManifest(legacyData))
|
|
190
231
|
return legacyData;
|
|
232
|
+
// A PRESENT file that matches no known shape is the last surviving form
|
|
233
|
+
// of "read problem means absent": returning null here lets setup build a
|
|
234
|
+
// fresh manifest and overwrite the record. Refuse by name instead.
|
|
235
|
+
if (newData !== null || legacyData !== null) {
|
|
236
|
+
const path = newData !== null ? getManifestPath() : getLegacyManifestPath();
|
|
237
|
+
throw new Error(`The install manifest at ${path} has an unrecognized shape — fix or remove it and re-run. (Detected before any UluOps change; nothing was modified. Removing it makes setup treat this as a fresh install; previously installed files will not be tracked for uninstall.)`);
|
|
238
|
+
}
|
|
191
239
|
return null;
|
|
192
240
|
}
|
|
193
241
|
/** Save the install manifest to ~/.uluops/manifest.json. Creates directory if needed. */
|
|
@@ -203,14 +251,24 @@ export async function saveManifest(manifest) {
|
|
|
203
251
|
const final = JSON.stringify({ ...withoutHash, contentHash: hash }, null, 2) + "\n";
|
|
204
252
|
await atomicWrite(getManifestPath(), final);
|
|
205
253
|
}
|
|
206
|
-
/**
|
|
254
|
+
/**
|
|
255
|
+
* Delete the install manifest file from disk. Tries both locations.
|
|
256
|
+
* Returns the paths that could NOT be removed (non-ENOENT failures) so the
|
|
257
|
+
* caller can report the truth instead of an unconditional success —
|
|
258
|
+
* a manifest that survives keeps claiming a full install.
|
|
259
|
+
*/
|
|
207
260
|
export async function deleteManifest() {
|
|
261
|
+
const failed = [];
|
|
208
262
|
for (const path of [getManifestPath(), getLegacyManifestPath()]) {
|
|
209
263
|
try {
|
|
210
264
|
await unlink(path);
|
|
211
265
|
}
|
|
212
|
-
catch {
|
|
213
|
-
|
|
266
|
+
catch (err) {
|
|
267
|
+
if (!isEnoent(err)) {
|
|
268
|
+
failed.push(`${path} (${err instanceof Error ? err.message : String(err)})`);
|
|
269
|
+
}
|
|
270
|
+
// ENOENT — already gone.
|
|
214
271
|
}
|
|
215
272
|
}
|
|
273
|
+
return { failed };
|
|
216
274
|
}
|
|
@@ -13,22 +13,24 @@
|
|
|
13
13
|
* silently picks up a downstream regression; a missed bump shows up as
|
|
14
14
|
* the next setup release stamping the previous combination.
|
|
15
15
|
*
|
|
16
|
-
* The
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
16
|
+
* The availability probe checks the PINNED VERSION, not the bare name.
|
|
17
|
+
* This used to be deliberate the other way ("a registry blip on an older
|
|
18
|
+
* version shouldn't fail setup when latest is reachable") — reversed
|
|
19
|
+
* 2026-08-21: the harness runs `npx -y <PINNED SPEC>`, so the pin being
|
|
20
|
+
* unresolvable is exactly the condition that must fail loudly at install
|
|
21
|
+
* time instead of surfacing hours later as an opaque npx error at first
|
|
22
|
+
* MCP launch. A pin is not a publish; the probe is what enforces that here.
|
|
22
23
|
*/
|
|
23
24
|
export declare const OPS_MCP_PACKAGE = "@uluops/ops-mcp";
|
|
24
|
-
export declare const OPS_MCP_VERSION = "0.
|
|
25
|
-
export declare const OPS_MCP_SPEC: "@uluops/ops-mcp@0.
|
|
25
|
+
export declare const OPS_MCP_VERSION = "0.17.2";
|
|
26
|
+
export declare const OPS_MCP_SPEC: "@uluops/ops-mcp@0.17.2";
|
|
26
27
|
export declare const REGISTRY_MCP_PACKAGE = "@uluops/registry-mcp";
|
|
27
|
-
export declare const REGISTRY_MCP_VERSION = "0.3.
|
|
28
|
-
export declare const REGISTRY_MCP_SPEC: "@uluops/registry-mcp@0.3.
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
export declare const REGISTRY_MCP_VERSION = "0.3.7";
|
|
29
|
+
export declare const REGISTRY_MCP_SPEC: "@uluops/registry-mcp@0.3.7";
|
|
30
|
+
/** Probe targets: the pinned versions the harness will actually resolve. */
|
|
31
|
+
export declare const MCP_PROBE_TARGETS: readonly {
|
|
32
|
+
pkg: string;
|
|
33
|
+
version: string;
|
|
34
|
+
}[];
|
|
35
|
+
/** Bare names, kept for display and for callers that only need identity. */
|
|
34
36
|
export declare const MCP_PACKAGES: readonly string[];
|
package/dist/lib/mcp-packages.js
CHANGED
|
@@ -13,24 +13,26 @@
|
|
|
13
13
|
* silently picks up a downstream regression; a missed bump shows up as
|
|
14
14
|
* the next setup release stamping the previous combination.
|
|
15
15
|
*
|
|
16
|
-
* The
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
16
|
+
* The availability probe checks the PINNED VERSION, not the bare name.
|
|
17
|
+
* This used to be deliberate the other way ("a registry blip on an older
|
|
18
|
+
* version shouldn't fail setup when latest is reachable") — reversed
|
|
19
|
+
* 2026-08-21: the harness runs `npx -y <PINNED SPEC>`, so the pin being
|
|
20
|
+
* unresolvable is exactly the condition that must fail loudly at install
|
|
21
|
+
* time instead of surfacing hours later as an opaque npx error at first
|
|
22
|
+
* MCP launch. A pin is not a publish; the probe is what enforces that here.
|
|
22
23
|
*/
|
|
23
24
|
export const OPS_MCP_PACKAGE = "@uluops/ops-mcp";
|
|
24
|
-
export const OPS_MCP_VERSION = "0.
|
|
25
|
+
export const OPS_MCP_VERSION = "0.17.2";
|
|
25
26
|
export const OPS_MCP_SPEC = `${OPS_MCP_PACKAGE}@${OPS_MCP_VERSION}`;
|
|
26
27
|
export const REGISTRY_MCP_PACKAGE = "@uluops/registry-mcp";
|
|
27
|
-
export const REGISTRY_MCP_VERSION = "0.3.
|
|
28
|
+
export const REGISTRY_MCP_VERSION = "0.3.7";
|
|
28
29
|
export const REGISTRY_MCP_SPEC = `${REGISTRY_MCP_PACKAGE}@${REGISTRY_MCP_VERSION}`;
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
/** Probe targets: the pinned versions the harness will actually resolve. */
|
|
31
|
+
export const MCP_PROBE_TARGETS = [
|
|
32
|
+
{ pkg: OPS_MCP_PACKAGE, version: OPS_MCP_VERSION },
|
|
33
|
+
{ pkg: REGISTRY_MCP_PACKAGE, version: REGISTRY_MCP_VERSION },
|
|
34
|
+
];
|
|
35
|
+
/** Bare names, kept for display and for callers that only need identity. */
|
|
34
36
|
export const MCP_PACKAGES = [
|
|
35
37
|
OPS_MCP_PACKAGE,
|
|
36
38
|
REGISTRY_MCP_PACKAGE,
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { readFile } from "node:fs/promises";
|
|
8
8
|
import { atomicWrite } from "./atomic-write.js";
|
|
9
|
+
import { stripDangerousKeys } from "./json-guards.js";
|
|
10
|
+
import { isEnoent } from "./file-ops.js";
|
|
9
11
|
/**
|
|
10
12
|
* Substring used purely as an *ownership sentinel* — present in every hook
|
|
11
13
|
* command we install, so we can identify our own entries in `settings.json`
|
|
@@ -68,15 +70,41 @@ export async function readSettings(path) {
|
|
|
68
70
|
try {
|
|
69
71
|
raw = await readFile(path, "utf-8");
|
|
70
72
|
}
|
|
71
|
-
catch {
|
|
72
|
-
|
|
73
|
+
catch (err) {
|
|
74
|
+
if (isEnoent(err))
|
|
75
|
+
return {}; // File doesn't exist — fresh config
|
|
76
|
+
// Unreadable-but-PRESENT must never read as fresh (see isEnoent's doc).
|
|
77
|
+
throw new Error(`Could not read settings at ${path} (${err instanceof Error ? err.message : String(err)}) — refusing to continue rather than overwrite a file that exists but could not be read. Nothing was modified.`);
|
|
73
78
|
}
|
|
79
|
+
let parsed;
|
|
74
80
|
try {
|
|
75
|
-
|
|
81
|
+
parsed = JSON.parse(raw);
|
|
76
82
|
}
|
|
77
83
|
catch {
|
|
78
|
-
|
|
84
|
+
// The read happens BEFORE any write — say so, or a pre-existing broken
|
|
85
|
+
// file reads as UluOps-caused corruption.
|
|
86
|
+
throw new Error(`Failed to parse settings at ${path} — file contains invalid JSON. ` +
|
|
87
|
+
`(Detected before any UluOps change; nothing was modified. Fix or remove the file and re-run.)`);
|
|
88
|
+
}
|
|
89
|
+
// Same rationale as the JSON throw above: a shape we can't merge into must
|
|
90
|
+
// surface, not crash mid-merge or silently corrupt on spread. Valid JSON
|
|
91
|
+
// that isn't an object (or whose hooks aren't matcher arrays) is treated
|
|
92
|
+
// as unmergeable, not coerced.
|
|
93
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
94
|
+
throw new Error(`Failed to parse settings at ${path} — expected a JSON object at the top level`);
|
|
95
|
+
}
|
|
96
|
+
const hooks = parsed.hooks;
|
|
97
|
+
if (hooks !== undefined) {
|
|
98
|
+
const hooksValid = typeof hooks === "object" &&
|
|
99
|
+
hooks !== null &&
|
|
100
|
+
!Array.isArray(hooks) &&
|
|
101
|
+
Object.values(hooks).every((v) => Array.isArray(v) &&
|
|
102
|
+
v.every((m) => typeof m === "object" && m !== null));
|
|
103
|
+
if (!hooksValid) {
|
|
104
|
+
throw new Error(`Failed to parse settings at ${path} — 'hooks' has an unexpected shape; fix or remove it and re-run`);
|
|
105
|
+
}
|
|
79
106
|
}
|
|
107
|
+
return stripDangerousKeys(parsed);
|
|
80
108
|
}
|
|
81
109
|
/**
|
|
82
110
|
* Write settings back to file with stable formatting.
|
|
@@ -86,6 +114,22 @@ export async function writeSettings(path, settings) {
|
|
|
86
114
|
mode: 0o600,
|
|
87
115
|
});
|
|
88
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* True when a matcher entry is a UluOps-owned hook. THE single ownership
|
|
119
|
+
* predicate — merge, remove, and has must all use it, or they disagree on
|
|
120
|
+
* malformed shapes (the exact defect this replaced: merge was defensive
|
|
121
|
+
* while remove/has dereferenced m.hooks unguarded and crashed uninstall/
|
|
122
|
+
* verify on hand-edited files). Defensive by design: readSettings' gate
|
|
123
|
+
* deliberately tolerates unknown-shaped user entries (no hooks array,
|
|
124
|
+
* non-string commands) so the merge can preserve them — anything not
|
|
125
|
+
* positively identifiable as ours is user data: preserved by remove,
|
|
126
|
+
* invisible to has, never a crash.
|
|
127
|
+
*/
|
|
128
|
+
function isUluopsMatcher(m) {
|
|
129
|
+
return (Array.isArray(m?.hooks) &&
|
|
130
|
+
m.hooks.some((h) => typeof h?.command === "string" &&
|
|
131
|
+
h.command.includes(HOOK_OWNERSHIP_SIGNATURE)));
|
|
132
|
+
}
|
|
89
133
|
/**
|
|
90
134
|
* Merge the UluOps hook into settings, preserving all other
|
|
91
135
|
* hooks and settings. If a UluOps hook already exists, it is replaced.
|
|
@@ -94,7 +138,7 @@ export function mergeUluopsHook(settings, hookCommand, hookTypeOverride, matcher
|
|
|
94
138
|
const hookType = hookTypeOverride ?? getDefaultHookEventType();
|
|
95
139
|
const hooks = settings.hooks ?? {};
|
|
96
140
|
const existing = hooks[hookType] ?? [];
|
|
97
|
-
const filtered = existing.filter((m) => !m
|
|
141
|
+
const filtered = existing.filter((m) => !isUluopsMatcher(m));
|
|
98
142
|
const uluopsHook = {
|
|
99
143
|
hooks: [
|
|
100
144
|
{
|
|
@@ -125,9 +169,9 @@ export function removeUluopsHook(settings, hookTypeOverride) {
|
|
|
125
169
|
if (!hooks)
|
|
126
170
|
return settings;
|
|
127
171
|
const hookEntries = hooks[hookType];
|
|
128
|
-
if (!hookEntries)
|
|
172
|
+
if (!Array.isArray(hookEntries))
|
|
129
173
|
return settings;
|
|
130
|
-
const filtered = hookEntries.filter((m) => !m
|
|
174
|
+
const filtered = hookEntries.filter((m) => !isUluopsMatcher(m));
|
|
131
175
|
const updatedHooks = { ...hooks };
|
|
132
176
|
if (filtered.length === 0) {
|
|
133
177
|
delete updatedHooks[hookType];
|
|
@@ -150,7 +194,7 @@ export function removeUluopsHook(settings, hookTypeOverride) {
|
|
|
150
194
|
export function hasUluopsHook(settings, hookTypeOverride) {
|
|
151
195
|
const hookType = hookTypeOverride ?? getDefaultHookEventType();
|
|
152
196
|
const hookEntries = settings.hooks?.[hookType];
|
|
153
|
-
if (!hookEntries)
|
|
197
|
+
if (!Array.isArray(hookEntries))
|
|
154
198
|
return false;
|
|
155
|
-
return hookEntries.some(
|
|
199
|
+
return hookEntries.some(isUluopsMatcher);
|
|
156
200
|
}
|