@uluops/setup 0.11.0 → 0.12.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 +772 -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 +21 -4
  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
@@ -5,6 +5,23 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
5
5
  /** Read the package version from package.json. */
6
6
  export async function getVersion() {
7
7
  const pkgPath = join(__dirname, "..", "..", "package.json");
8
- const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
9
- return pkg.version;
8
+ let pkg;
9
+ try {
10
+ pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
11
+ }
12
+ catch (err) {
13
+ // A truncated package.json (interrupted npx cache write) should surface
14
+ // as the same deliberate broken-publish error as a missing version.
15
+ throw new Error(`Malformed package.json at ${pkgPath}: ${err instanceof Error ? err.message : String(err)}`);
16
+ }
17
+ const version = typeof pkg === "object" && pkg !== null
18
+ ? pkg.version
19
+ : undefined;
20
+ if (typeof version !== "string" || !version) {
21
+ // Our own package.json — this firing means a broken publish, not user
22
+ // error. Fail loudly rather than stamping "undefined" into banners and
23
+ // the manifest's setupVersion.
24
+ throw new Error(`Malformed package.json at ${pkgPath}: missing version`);
25
+ }
26
+ return version;
10
27
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Per-file write coordinator.
3
+ *
4
+ * Two jobs, one choke point:
5
+ *
6
+ * 1. **Serialization** — every read-merge-write cycle against a config file
7
+ * goes through `serialize(path, op)`, a per-resolved-path promise chain.
8
+ * Harness profiles where two steps target the same file (Gemini CLI's
9
+ * MCP config and hook both live in `~/.gemini/settings.json`) get their
10
+ * cycles strictly ordered, so no cycle can read a file another cycle is
11
+ * mid-way through rewriting — including if step orchestration ever
12
+ * becomes concurrent.
13
+ *
14
+ * 2. **Write attestation** — `recordWrite` keeps a sha-256 of the last bytes
15
+ * this process wrote to each path (called from `atomicWrite`, so every
16
+ * config write is attested with no call-site churn). NOTE:
17
+ * `fileMatchesLastWrite`/`hasRecordedWrite` have NO production consumer
18
+ * today — the package deliberately has no backup/rollback mechanism
19
+ * (see CHANGELOG: backups were removed as never-read). They exist as the
20
+ * guard any future rollback MUST use before writing over a file: only
21
+ * content this process provably wrote may be replaced. Until such a
22
+ * caller exists, attestation is bookkeeping, not an active safety
23
+ * property.
24
+ *
25
+ * Why not coalesce to one physical write per file per run: the hook entry
26
+ * can only be written after the metrics tool files land on disk (a hook
27
+ * pointing at a nonexistent hook.js fires a failing command in the user's
28
+ * harness), so the MCP write and the hook write have a real data dependency.
29
+ * Coalescing would couple MCP-config success to the metrics step's file
30
+ * operations; serialized, attested, individually-atomic writes do not.
31
+ */
32
+ /**
33
+ * Run `op` exclusively with respect to every other serialized operation on
34
+ * the same (resolved) path. Failures propagate to the caller but never
35
+ * poison the chain for subsequent operations.
36
+ */
37
+ export declare function serialize<T>(path: string, op: () => Promise<T>): Promise<T>;
38
+ /** Attest that this process wrote exactly `content` to `path`. */
39
+ export declare function recordWrite(path: string, content: string): void;
40
+ /**
41
+ * Restore guard: true when the file's current bytes are exactly the last
42
+ * bytes this process wrote to it (or the file is missing — a failed write
43
+ * can legitimately leave no file). False when we never wrote the path, or
44
+ * when someone else has touched it since our write.
45
+ */
46
+ export declare function fileMatchesLastWrite(path: string): Promise<boolean>;
47
+ /** Whether this process has attested a write to `path` at all. */
48
+ export declare function hasRecordedWrite(path: string): boolean;
49
+ /** Test seam: clear all coordinator state. */
50
+ export declare function resetCoordinatorForTests(): void;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Per-file write coordinator.
3
+ *
4
+ * Two jobs, one choke point:
5
+ *
6
+ * 1. **Serialization** — every read-merge-write cycle against a config file
7
+ * goes through `serialize(path, op)`, a per-resolved-path promise chain.
8
+ * Harness profiles where two steps target the same file (Gemini CLI's
9
+ * MCP config and hook both live in `~/.gemini/settings.json`) get their
10
+ * cycles strictly ordered, so no cycle can read a file another cycle is
11
+ * mid-way through rewriting — including if step orchestration ever
12
+ * becomes concurrent.
13
+ *
14
+ * 2. **Write attestation** — `recordWrite` keeps a sha-256 of the last bytes
15
+ * this process wrote to each path (called from `atomicWrite`, so every
16
+ * config write is attested with no call-site churn). NOTE:
17
+ * `fileMatchesLastWrite`/`hasRecordedWrite` have NO production consumer
18
+ * today — the package deliberately has no backup/rollback mechanism
19
+ * (see CHANGELOG: backups were removed as never-read). They exist as the
20
+ * guard any future rollback MUST use before writing over a file: only
21
+ * content this process provably wrote may be replaced. Until such a
22
+ * caller exists, attestation is bookkeeping, not an active safety
23
+ * property.
24
+ *
25
+ * Why not coalesce to one physical write per file per run: the hook entry
26
+ * can only be written after the metrics tool files land on disk (a hook
27
+ * pointing at a nonexistent hook.js fires a failing command in the user's
28
+ * harness), so the MCP write and the hook write have a real data dependency.
29
+ * Coalescing would couple MCP-config success to the metrics step's file
30
+ * operations; serialized, attested, individually-atomic writes do not.
31
+ */
32
+ import { createHash } from "node:crypto";
33
+ import { readFile } from "node:fs/promises";
34
+ import { resolve } from "node:path";
35
+ const chains = new Map();
36
+ const lastWritten = new Map();
37
+ function sha256(content) {
38
+ return createHash("sha256").update(content, "utf-8").digest("hex");
39
+ }
40
+ /**
41
+ * Run `op` exclusively with respect to every other serialized operation on
42
+ * the same (resolved) path. Failures propagate to the caller but never
43
+ * poison the chain for subsequent operations.
44
+ */
45
+ export async function serialize(path, op) {
46
+ const key = resolve(path);
47
+ const prev = chains.get(key) ?? Promise.resolve();
48
+ const run = prev.then(op, op);
49
+ // Store a settled-safe tail so one failed op doesn't reject the chain.
50
+ chains.set(key, run.then(() => undefined, () => undefined));
51
+ return run;
52
+ }
53
+ /** Attest that this process wrote exactly `content` to `path`. */
54
+ export function recordWrite(path, content) {
55
+ lastWritten.set(resolve(path), sha256(content));
56
+ }
57
+ /**
58
+ * Restore guard: true when the file's current bytes are exactly the last
59
+ * bytes this process wrote to it (or the file is missing — a failed write
60
+ * can legitimately leave no file). False when we never wrote the path, or
61
+ * when someone else has touched it since our write.
62
+ */
63
+ export async function fileMatchesLastWrite(path) {
64
+ const key = resolve(path);
65
+ const expected = lastWritten.get(key);
66
+ if (expected === undefined)
67
+ return false;
68
+ let current;
69
+ try {
70
+ current = await readFile(key, "utf-8");
71
+ }
72
+ catch (err) {
73
+ // ONLY a missing file means "nothing of anyone else's to clobber". An
74
+ // unreadable-but-present file (EACCES/EIO) is unverifiable — a guard
75
+ // that answers true on an error it never inspected authorizes exactly
76
+ // the clobber it exists to prevent.
77
+ return err?.code === "ENOENT";
78
+ }
79
+ return sha256(current) === expected;
80
+ }
81
+ /** Whether this process has attested a write to `path` at all. */
82
+ export function hasRecordedWrite(path) {
83
+ return lastWritten.has(resolve(path));
84
+ }
85
+ /** Test seam: clear all coordinator state. */
86
+ export function resetCoordinatorForTests() {
87
+ chains.clear();
88
+ lastWritten.clear();
89
+ }
@@ -33,6 +33,12 @@ export interface AgentMetricsCliExecutor {
33
33
  * Exported for direct unit testing — keeps the JSON shape contract explicit.
34
34
  */
35
35
  export declare function parseGlobalAgentMetricsVersion(stdout: string | undefined): string | null;
36
+ /**
37
+ * Detect a globally-installed `@uluops/agent-metrics` via `npm ls -g`.
38
+ * Returns the installed version string, or null when absent (or when npm
39
+ * itself fails/times out — absence and detection failure are deliberately
40
+ * indistinguishable: both mean "offer the install").
41
+ */
36
42
  export declare function detectGlobalAgentMetrics(): string | null;
37
43
  /** Default executor — queries npm directly to avoid npx-transient-PATH false positives. */
38
44
  export declare const defaultAgentMetricsExecutor: AgentMetricsCliExecutor;
@@ -55,16 +55,34 @@ const DETECT_TIMEOUT_MS = 30_000;
55
55
  function summarizeNpmResult(r, op) {
56
56
  if (r.status === 0)
57
57
  return { ok: true };
58
+ // Timeout FIRST: a spawnSync timeout sets BOTH r.error (ETIMEDOUT) and
59
+ // signal SIGTERM — the specific diagnosis must win over the generic one.
58
60
  if (r.signal === "SIGTERM" && r.status === null) {
59
61
  return {
60
62
  ok: false,
61
63
  error: `npm ${op} exceeded ${NPM_TIMEOUT_MS / 1000}s timeout and was terminated`,
62
64
  };
63
65
  }
66
+ // Spawn failure (npm not on PATH, ENOENT) sets r.error with status null —
67
+ // mirror src/steps/cli.ts so it never renders as "exit null".
68
+ if (r.error) {
69
+ return { ok: false, error: `npm could not be run: ${r.error.message}` };
70
+ }
64
71
  const stderr = (r.stderr ?? "").toString().trim();
65
72
  const stdout = (r.stdout ?? "").toString().trim();
66
- return { ok: false, error: stderr || stdout || `exit ${r.status}` };
73
+ let error = stderr || stdout || `exit ${r.status}`;
74
+ // Mirror src/steps/cli.ts: name the root-owned-prefix cause on EACCES.
75
+ if (/EACCES|EPERM|permission denied/i.test(error)) {
76
+ error += ` — your npm global prefix isn't writable. A Node version manager (nvm/fnm) avoids this permanently; see docs.npmjs.com/resolving-eacces-permissions-errors`;
77
+ }
78
+ return { ok: false, error };
67
79
  }
80
+ /**
81
+ * Detect a globally-installed `@uluops/agent-metrics` via `npm ls -g`.
82
+ * Returns the installed version string, or null when absent (or when npm
83
+ * itself fails/times out — absence and detection failure are deliberately
84
+ * indistinguishable: both mean "offer the install").
85
+ */
68
86
  export function detectGlobalAgentMetrics() {
69
87
  const r = spawnSync("npm", ["ls", "-g", AGENT_METRICS_PACKAGE, "--depth=0", "--json"], {
70
88
  encoding: "utf-8",
@@ -1,7 +1,7 @@
1
1
  import { readdir, mkdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { ASSETS_DIR, findProjectRoot } from "../lib/paths.js";
4
- import { copyIfChanged, unlinkFiles, removeStaleFiles, } from "../lib/file-ops.js";
4
+ import { copyIfChanged, unlinkFiles, removeStaleFiles, isEnoent, } from "../lib/file-ops.js";
5
5
  /** Copy pre-rendered agent definitions from harness-specific assets to the target directory. */
6
6
  export async function installAgents(profile, localDefs, dryRun, existingManifestAgents) {
7
7
  const srcDir = join(ASSETS_DIR, profile.name, "agents");
@@ -16,7 +16,12 @@ export async function installAgents(profile, localDefs, dryRun, existingManifest
16
16
  try {
17
17
  files = (await readdir(srcDir)).filter((f) => f.endsWith(ext));
18
18
  }
19
- catch {
19
+ catch (err) {
20
+ // ENOENT = this harness ships no agents (legit empty). Anything else
21
+ // must THROW: returning files:[] here is persisted to the manifest as
22
+ // authoritative and orphans every previously-recorded agent.
23
+ if (!isEnoent(err))
24
+ throw err;
20
25
  return { copied: 0, skipped: 0, removed: 0, files: [], failures: [] };
21
26
  }
22
27
  let copied = 0;
@@ -49,8 +54,21 @@ export async function installAgents(profile, localDefs, dryRun, existingManifest
49
54
  });
50
55
  }
51
56
  }
52
- const removed = await removeStaleFiles(destDir, existingManifestAgents, installedFiles, dryRun);
53
- return { copied, skipped, removed, files: installedFiles, failures };
57
+ // Stale = "no longer SHIPPED", never "failed to copy today": reconciling
58
+ // against installedFiles deleted a previously-working file whenever its
59
+ // copy failed (ENOSPC wiped the whole installed set). Reconcile against
60
+ // the source list; a failed file's prior copy survives on disk.
61
+ const removed = await removeStaleFiles(destDir, existingManifestAgents, files, dryRun);
62
+ // The manifest record keeps failed files that were previously installed —
63
+ // their prior copy is still on disk (see above) and uninstall must be able
64
+ // to remove it. Never-installed failures stay out (nothing to unlink).
65
+ const recordedFiles = [
66
+ ...installedFiles,
67
+ ...failures
68
+ .map((f) => f.file)
69
+ .filter((f) => existingManifestAgents?.includes(f) ?? false),
70
+ ];
71
+ return { copied, skipped, removed, files: recordedFiles, failures };
54
72
  }
55
73
  /** Remove previously installed agent files by name. */
56
74
  export async function uninstallAgents(files, defsPath) {
@@ -3,6 +3,18 @@ import { dirname, join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { extractEmail } from "../lib/json-guards.js";
5
5
  import { atomicWrite } from "../lib/atomic-write.js";
6
+ import { isEnoent } from "../lib/file-ops.js";
7
+ /**
8
+ * Expected API-key prefix, overridable via ULUOPS_KEY_PREFIX for dev/test
9
+ * environments whose local API mints differently-prefixed keys.
10
+ *
11
+ * ADVISORY ONLY — deliberate divergence from @uluops/sdk-core, which
12
+ * hardcodes "ulr_". The prefix here gates nothing: a mismatched key gets a
13
+ * hint in the prompt and a proceed-anyway warning, and server validation
14
+ * remains the sole authority. Keep it that way: making this blocking would
15
+ * turn the env override into a footgun (keys accepted here, rejected by
16
+ * every sdk-core consumer).
17
+ */
6
18
  function getKeyPrefix() {
7
19
  return process.env["ULUOPS_KEY_PREFIX"] ?? "ulr_";
8
20
  }
@@ -19,8 +31,10 @@ export async function hasCredentialsFile() {
19
31
  await access(credentialsPath());
20
32
  return true;
21
33
  }
22
- catch {
23
- return false;
34
+ catch (err) {
35
+ // Unreadable-but-present counts as PRESENT: answering "absent" steers a
36
+ // returning user into the new-account branch and a duplicate signup.
37
+ return !isEnoent(err);
24
38
  }
25
39
  }
26
40
  /**
@@ -36,18 +50,33 @@ export async function writeCredentialsFile(apiKey, opts) {
36
50
  return;
37
51
  const credsPath = credentialsPath();
38
52
  await mkdir(dirname(credsPath), { recursive: true, mode: 0o700 });
39
- // Merge: preserve any non-default profiles already on disk.
53
+ // Merge: preserve any non-default profiles already on disk. The preserve
54
+ // promise means we may only start fresh when the file is genuinely ABSENT
55
+ // — an unreadable or unparseable file may hold profiles (e.g. @uluops/cli's
56
+ // `work` profile) that a fresh write would destroy.
40
57
  let existing = {};
58
+ let raw = null;
41
59
  try {
42
- const raw = await readFile(credsPath, "utf-8");
43
- const parsed = JSON.parse(raw);
60
+ raw = await readFile(credsPath, "utf-8");
61
+ }
62
+ catch (err) {
63
+ if (!isEnoent(err)) {
64
+ throw new Error(`Could not read ${credsPath} (${err instanceof Error ? err.message : String(err)}) — refusing to write credentials over a file that exists but could not be read. Nothing was modified.`);
65
+ }
66
+ // Absent — fresh file.
67
+ }
68
+ if (raw !== null) {
69
+ let parsed;
70
+ try {
71
+ parsed = JSON.parse(raw);
72
+ }
73
+ catch {
74
+ throw new Error(`Existing credentials file at ${credsPath} contains invalid JSON — it may hold other profiles, so it will not be overwritten. Fix or remove it and re-run. Nothing was modified.`);
75
+ }
44
76
  if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
45
77
  existing = parsed;
46
78
  }
47
79
  }
48
- catch {
49
- // No existing file, or unparseable — start fresh.
50
- }
51
80
  const merged = {
52
81
  ...existing,
53
82
  default: {
@@ -116,8 +145,10 @@ async function readCredentialsFile() {
116
145
  try {
117
146
  raw = await readFile(credsPath, "utf-8");
118
147
  }
119
- catch {
120
- return undefined; // File doesn't exist
148
+ catch (err) {
149
+ if (isEnoent(err))
150
+ return undefined; // File doesn't exist
151
+ throw new Error(`Could not read credentials file at ${credsPath}: ${err instanceof Error ? err.message : String(err)}`);
121
152
  }
122
153
  let creds;
123
154
  try {
@@ -130,7 +161,14 @@ async function readCredentialsFile() {
130
161
  return undefined;
131
162
  const profiles = creds;
132
163
  const defaultProfile = profiles["default"];
133
- return defaultProfile?.apiKey ?? defaultProfile?.api_key;
164
+ if (typeof defaultProfile !== "object" || defaultProfile === null) {
165
+ return undefined;
166
+ }
167
+ const p = defaultProfile;
168
+ // Only ever return a string — a malformed file (apiKey: 42, apiKey: {...})
169
+ // must read as "no stored key", not flow a non-string into Bearer headers.
170
+ const candidate = p.apiKey ?? p.api_key;
171
+ return typeof candidate === "string" && candidate ? candidate : undefined;
134
172
  }
135
173
  async function validateKey(apiKey) {
136
174
  // Self-identity lives in ops-uluops-api (`/api/v1/auth/me`), not the
@@ -163,9 +201,11 @@ async function validateKey(apiKey) {
163
201
  return { email: extractEmail(body) };
164
202
  }
165
203
  catch (err) {
166
- // fetch() throws TypeError for network failures (ENOTFOUND, ECONNREFUSED).
204
+ // fetch() throws TypeError for network failures (ENOTFOUND, ECONNREFUSED);
205
+ // AbortSignal.timeout rejects with a DOMException named "TimeoutError" —
206
+ // the slow-network case this friendly message was written for.
167
207
  // Re-thrown errors from the res.status checks above are plain Error instances.
168
- if (err instanceof TypeError) {
208
+ if (err instanceof TypeError || err?.name === "TimeoutError") {
169
209
  throw new Error("Can't reach api.uluops.ai — check your connection. Use --skip-validation to continue offline.");
170
210
  }
171
211
  throw err;
package/dist/steps/cli.js CHANGED
@@ -15,15 +15,28 @@ const DETECT_TIMEOUT_MS = 30_000;
15
15
  export function summarizeSpawnResult(r, op) {
16
16
  if (r.status === 0)
17
17
  return { ok: true };
18
+ // Timeout FIRST: a spawnSync timeout sets BOTH r.error (ETIMEDOUT) and
19
+ // signal SIGTERM — the specific diagnosis must win over the generic one.
18
20
  if (r.signal === "SIGTERM" && r.status === null) {
19
21
  return {
20
22
  ok: false,
21
23
  error: `npm ${op} exceeded ${NPM_TIMEOUT_MS / 1000}s timeout and was terminated`,
22
24
  };
23
25
  }
26
+ // Spawn failure (npm not on PATH, ENOENT) sets r.error with status null —
27
+ // without this check it renders as the useless "exit null".
28
+ if (r.error) {
29
+ return { ok: false, error: `npm could not be run: ${r.error.message}` };
30
+ }
24
31
  const stderr = (r.stderr ?? "").toString().trim();
25
32
  const stdout = (r.stdout ?? "").toString().trim();
26
- return { ok: false, error: stderr || stdout || `exit ${r.status}` };
33
+ let error = stderr || stdout || `exit ${r.status}`;
34
+ // The most common global-install failure is a root-owned npm prefix —
35
+ // "try manually" without saying so sends the user into the same wall.
36
+ if (/EACCES|EPERM|permission denied/i.test(error)) {
37
+ error += ` — your npm global prefix isn't writable. A Node version manager (nvm/fnm) avoids this permanently; see docs.npmjs.com/resolving-eacces-permissions-errors`;
38
+ }
39
+ return { ok: false, error };
27
40
  }
28
41
  /** Default executor — shells out to `ulu` and `npm`. */
29
42
  export const defaultExecutor = {
@@ -1,7 +1,7 @@
1
1
  import { readdir, mkdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { ASSETS_DIR, findProjectRoot } from "../lib/paths.js";
4
- import { copyIfChanged, unlinkFiles, removeStaleFiles, } from "../lib/file-ops.js";
4
+ import { copyIfChanged, unlinkFiles, removeStaleFiles, isEnoent, } from "../lib/file-ops.js";
5
5
  const SUBDIRS = ["agents", "workflows", "pipelines"];
6
6
  /** Install pre-rendered command files from harness-specific assets. */
7
7
  export async function installCommands(profile, localDefs, dryRun, existingManifestCommands) {
@@ -15,7 +15,12 @@ export async function installCommands(profile, localDefs, dryRun, existingManife
15
15
  await readdir(srcBase);
16
16
  hasSrcDir = true;
17
17
  }
18
- catch {
18
+ catch (err) {
19
+ // Only genuine absence means "this harness ships no commands" — an
20
+ // EACCES here previously rendered as the 'coming soon' capability
21
+ // message while wiping the manifest's commands list.
22
+ if (!isEnoent(err))
23
+ throw err;
19
24
  hasSrcDir = false;
20
25
  }
21
26
  if (!hasSrcDir) {
@@ -35,6 +40,7 @@ export async function installCommands(profile, localDefs, dryRun, existingManife
35
40
  let pipelineCommands = 0;
36
41
  let skipped = 0;
37
42
  const allFiles = [];
43
+ const sourceFiles = [];
38
44
  const failures = [];
39
45
  for (const subdir of SUBDIRS) {
40
46
  const srcDir = join(srcBase, subdir);
@@ -46,7 +52,9 @@ export async function installCommands(profile, localDefs, dryRun, existingManife
46
52
  try {
47
53
  files = (await readdir(srcDir)).filter((f) => f.endsWith(".md") || f.endsWith(".toml"));
48
54
  }
49
- catch {
55
+ catch (err) {
56
+ if (!isEnoent(err))
57
+ throw err; // see the srcBase probe above
50
58
  continue;
51
59
  }
52
60
  for (const file of files) {
@@ -64,11 +72,6 @@ export async function installCommands(profile, localDefs, dryRun, existingManife
64
72
  else {
65
73
  skipped++;
66
74
  }
67
- // Only track files that actually made it into a known state. Failed
68
- // copies do NOT enter allFiles — otherwise the manifest's "remove
69
- // stale entries on re-run" diff would treat a never-copied file as
70
- // present, and an `--uninstall` would later try to unlink something
71
- // that was never written.
72
75
  allFiles.push(relativePath);
73
76
  }
74
77
  catch (err) {
@@ -78,16 +81,31 @@ export async function installCommands(profile, localDefs, dryRun, existingManife
78
81
  error: err instanceof Error ? err.message : String(err),
79
82
  });
80
83
  }
84
+ finally {
85
+ // Source census regardless of copy outcome — stale reconciliation
86
+ // must compare against what the package SHIPS, or a failed copy
87
+ // reads as "no longer shipped" and the prior working file is
88
+ // deleted (the ENOSPC-wipes-everything shape).
89
+ sourceFiles.push(relativePath);
90
+ }
81
91
  }
82
92
  }
83
- const removed = await removeStaleFiles(destBase, existingManifestCommands, allFiles, dryRun);
93
+ const removed = await removeStaleFiles(destBase, existingManifestCommands, sourceFiles, dryRun);
94
+ // Record keeps failed-but-previously-installed files (prior copy is still
95
+ // on disk after source-based reconciliation) so uninstall can remove them.
96
+ const recordedCommandFiles = [
97
+ ...allFiles,
98
+ ...failures
99
+ .map((f) => f.file)
100
+ .filter((f) => existingManifestCommands?.includes(f) ?? false),
101
+ ];
84
102
  return {
85
103
  agentCommands,
86
104
  workflowCommands,
87
105
  pipelineCommands,
88
106
  skipped,
89
107
  removed,
90
- files: allFiles,
108
+ files: recordedCommandFiles,
91
109
  failures,
92
110
  };
93
111
  }
package/dist/steps/mcp.js CHANGED
@@ -3,21 +3,28 @@ import { join } from "node:path";
3
3
  import { checkMcpPackageAvailability } from "../lib/config-merger.js";
4
4
  import { findProjectRoot } from "../lib/paths.js";
5
5
  import { atomicWrite } from "../lib/atomic-write.js";
6
+ import { serialize } from "../lib/write-coordinator.js";
7
+ import { warn } from "../lib/display.js";
6
8
  /** Write UluOps MCP server entries into a harness's config file. */
7
9
  export async function installMcp(profile, apiKey, scope, dryRun) {
8
10
  const configPath = scope === "global"
9
11
  ? profile.paths.globalMcpConfig
10
12
  : join(await findProjectRoot(), profile.paths.localMcpConfig);
11
- const config = await profile.mcpConfig.read(configPath);
12
- const merged = profile.mcpConfig.merge(config, apiKey);
13
13
  const packageWarnings = [];
14
14
  const { missing } = await checkMcpPackageAvailability();
15
15
  if (missing.length > 0) {
16
16
  packageWarnings.push(`npm packages not found in registry: ${missing.join(", ")}. MCP servers may fail to start.`);
17
17
  }
18
- if (!dryRun) {
19
- await profile.mcpConfig.write(configPath, merged);
20
- }
18
+ // Serialized read-merge-write: on profiles where the MCP config file is
19
+ // also the hooks settings file (Gemini CLI), this cycle and the hook
20
+ // install's cycle must never interleave.
21
+ await serialize(configPath, async () => {
22
+ const config = await profile.mcpConfig.read(configPath);
23
+ const merged = profile.mcpConfig.merge(config, apiKey);
24
+ if (!dryRun) {
25
+ await profile.mcpConfig.write(configPath, merged);
26
+ }
27
+ });
21
28
  if (scope === "local" && !dryRun) {
22
29
  await addToGitignore(profile.paths.localMcpConfig);
23
30
  }
@@ -25,9 +32,11 @@ export async function installMcp(profile, apiKey, scope, dryRun) {
25
32
  }
26
33
  /** Remove UluOps MCP server entries from the harness config. */
27
34
  export async function uninstallMcp(profile, configPath) {
28
- const config = await profile.mcpConfig.read(configPath);
29
- const cleaned = profile.mcpConfig.remove(config);
30
- await profile.mcpConfig.write(configPath, cleaned);
35
+ await serialize(configPath, async () => {
36
+ const config = await profile.mcpConfig.read(configPath);
37
+ const cleaned = profile.mcpConfig.remove(config);
38
+ await profile.mcpConfig.write(configPath, cleaned);
39
+ });
31
40
  }
32
41
  async function addToGitignore(localConfigFilename) {
33
42
  const root = await findProjectRoot();
@@ -60,7 +69,7 @@ export async function ensureGitignoreEntry(gitignorePath, entry, reader = (p) =>
60
69
  await atomicWrite(gitignorePath, `${entry}\n`);
61
70
  return;
62
71
  }
63
- console.warn(`Warning: could not read ${gitignorePath} (${err.message}). Skipping .gitignore update.`);
72
+ warn(`Could not read ${gitignorePath} (${err.message}). Skipping .gitignore update.`);
64
73
  return;
65
74
  }
66
75
  if (content.includes(entry))