@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +805 -0
  2. package/README.md +81 -32
  3. package/dist/cli.js +7 -1
  4. package/dist/commands/helpers.js +70 -7
  5. package/dist/commands/per-harness.d.ts +5 -0
  6. package/dist/commands/per-harness.js +5 -0
  7. package/dist/commands/setup.d.ts +7 -0
  8. package/dist/commands/setup.js +100 -35
  9. package/dist/commands/uninstall.d.ts +7 -0
  10. package/dist/commands/uninstall.js +36 -9
  11. package/dist/commands/verify.d.ts +5 -0
  12. package/dist/commands/verify.js +5 -0
  13. package/dist/harnesses/claude-code.js +15 -7
  14. package/dist/harnesses/codex.js +35 -8
  15. package/dist/harnesses/gemini-cli.js +13 -6
  16. package/dist/harnesses/index.d.ts +8 -0
  17. package/dist/harnesses/index.js +10 -0
  18. package/dist/harnesses/opencode.js +25 -7
  19. package/dist/lib/asset-catalog.js +15 -2
  20. package/dist/lib/atomic-write.d.ts +6 -0
  21. package/dist/lib/atomic-write.js +10 -0
  22. package/dist/lib/config-merger.js +27 -8
  23. package/dist/lib/display.d.ts +8 -0
  24. package/dist/lib/display.js +27 -1
  25. package/dist/lib/file-ops.d.ts +13 -4
  26. package/dist/lib/file-ops.js +69 -18
  27. package/dist/lib/install-lock.js +45 -13
  28. package/dist/lib/json-guards.d.ts +15 -0
  29. package/dist/lib/json-guards.js +30 -0
  30. package/dist/lib/manifest.d.ts +9 -2
  31. package/dist/lib/manifest.js +66 -8
  32. package/dist/lib/mcp-packages.d.ts +17 -15
  33. package/dist/lib/mcp-packages.js +15 -13
  34. package/dist/lib/settings-merger.js +53 -9
  35. package/dist/lib/version.js +19 -2
  36. package/dist/lib/write-coordinator.d.ts +50 -0
  37. package/dist/lib/write-coordinator.js +89 -0
  38. package/dist/steps/agent-metrics-cli.d.ts +6 -0
  39. package/dist/steps/agent-metrics-cli.js +19 -1
  40. package/dist/steps/agents.js +22 -4
  41. package/dist/steps/auth.js +53 -13
  42. package/dist/steps/cli.js +14 -1
  43. package/dist/steps/commands.js +28 -10
  44. package/dist/steps/mcp.js +18 -9
  45. package/dist/steps/metrics.js +77 -7
  46. package/dist/steps/shell.d.ts +4 -1
  47. package/dist/steps/shell.js +44 -6
  48. package/dist/steps/signup.d.ts +4 -0
  49. package/dist/steps/signup.js +18 -2
  50. package/dist/steps/skills.d.ts +11 -0
  51. package/dist/steps/skills.js +35 -6
  52. package/dist/steps/username.js +10 -2
  53. package/dist/steps/verify.js +55 -6
  54. package/package.json +7 -4
  55. package/dist/lib/agent-transform.d.ts +0 -12
  56. package/dist/lib/agent-transform.js +0 -129
@@ -1,6 +1,19 @@
1
- import { readFile, writeFile, mkdir, unlink, readdir } from "node:fs/promises";
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
- // File doesn't exist yet
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
- await writeFile(destPath, srcContent);
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
- // File doesn't exist yet
55
+ catch (err) {
56
+ if (!isEnoent(err))
57
+ throw err; // see copyIfChanged
38
58
  }
39
59
  if (!dryRun) {
40
- await writeFile(destPath, content);
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(join(dir, file));
90
+ await unlink(target);
52
91
  removed++;
53
92
  }
54
- catch {
55
- // Already gone
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. Errors from unlink are swallowed silently — the
68
- * "already gone" case is the dominant one (idempotent re-run, manual user
69
- * deletion, prior failed install), and there's no recovery the caller
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(join(destDir, oldFile));
128
+ await unlink(staleTarget);
81
129
  }
82
- catch {
83
- // Already gone
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++;
@@ -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(`Another uluops-setup process is already running (PID ${holder.pid} on ${holder.hostname}, started ${Math.round(holder.ageMs / 1000)}s ago).`);
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
- throw new InstallLockHeldError({
93
- pid: verdict.meta.pid,
94
- hostname: verdict.meta.hostname,
95
- ageMs: Date.now() - verdict.meta.startedAt,
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
- // Lock dir exists but meta missing or unreadable — treat as stale.
109
- return { kind: "stale", reason: "missing-meta" };
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
- // Best-effort; do not throw from release().
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;
@@ -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.");
@@ -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
- /** Delete the install manifest file from disk. Tries both locations. */
90
- export declare function deleteManifest(): Promise<void>;
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
+ }>;
@@ -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
- if ("skills" in hm && !Array.isArray(hm["skills"]))
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
- // Try next candidate
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
- return null;
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
- /** Delete the install manifest file from disk. Tries both locations. */
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
- // Already gone
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 bare package NAMES (without versions) remain available as
17
- * `MCP_PACKAGES` for the npm availability probe — that probe asks
18
- * "does this name exist on the registry" not "does this version exist".
19
- * Pinning the probe to a specific version would turn a temporary
20
- * registry blip on an older version into a setup failure even when
21
- * the latest version was reachable.
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.11.0";
25
- export declare const OPS_MCP_SPEC: "@uluops/ops-mcp@0.11.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.5";
28
- export declare const REGISTRY_MCP_SPEC: "@uluops/registry-mcp@0.3.5";
29
- /**
30
- * Bare package names for the npm availability probe. The probe checks
31
- * existence on the registry, not version-specific resolvability, so it
32
- * uses these unversioned names.
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[];
@@ -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 bare package NAMES (without versions) remain available as
17
- * `MCP_PACKAGES` for the npm availability probe — that probe asks
18
- * "does this name exist on the registry" not "does this version exist".
19
- * Pinning the probe to a specific version would turn a temporary
20
- * registry blip on an older version into a setup failure even when
21
- * the latest version was reachable.
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.11.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.5";
28
+ export const REGISTRY_MCP_VERSION = "0.3.7";
28
29
  export const REGISTRY_MCP_SPEC = `${REGISTRY_MCP_PACKAGE}@${REGISTRY_MCP_VERSION}`;
29
- /**
30
- * Bare package names for the npm availability probe. The probe checks
31
- * existence on the registry, not version-specific resolvability, so it
32
- * uses these unversioned names.
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
- return {}; // File doesn't exist — fresh config
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
- return JSON.parse(raw);
81
+ parsed = JSON.parse(raw);
76
82
  }
77
83
  catch {
78
- throw new Error(`Failed to parse settings at ${path} — file contains invalid JSON`);
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.hooks.some((h) => h.command.includes(HOOK_OWNERSHIP_SIGNATURE)));
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.hooks.some((h) => h.command.includes(HOOK_OWNERSHIP_SIGNATURE)));
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((m) => m.hooks.some((h) => h.command.includes(HOOK_OWNERSHIP_SIGNATURE)));
199
+ return hookEntries.some(isUluopsMatcher);
156
200
  }