@uluops/setup 0.6.5 → 0.8.1

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 (59) hide show
  1. package/README.md +91 -9
  2. package/assets/codex/skills/uluops-operator/SKILL.md +159 -0
  3. package/dist/cli/select-harnesses.d.ts +91 -0
  4. package/dist/cli/select-harnesses.js +108 -0
  5. package/dist/cli.js +90 -37
  6. package/dist/commands/errors.d.ts +24 -0
  7. package/dist/commands/errors.js +28 -0
  8. package/dist/commands/helpers.d.ts +32 -0
  9. package/dist/commands/helpers.js +146 -3
  10. package/dist/commands/per-harness.d.ts +64 -0
  11. package/dist/commands/per-harness.js +37 -0
  12. package/dist/commands/setup.d.ts +7 -3
  13. package/dist/commands/setup.js +232 -71
  14. package/dist/commands/uninstall-filter.d.ts +36 -0
  15. package/dist/commands/uninstall-filter.js +69 -0
  16. package/dist/commands/uninstall.d.ts +12 -2
  17. package/dist/commands/uninstall.js +190 -82
  18. package/dist/harnesses/codex.d.ts +5 -10
  19. package/dist/harnesses/codex.js +89 -21
  20. package/dist/harnesses/index.js +6 -1
  21. package/dist/harnesses/opencode.d.ts +8 -0
  22. package/dist/harnesses/opencode.js +24 -1
  23. package/dist/harnesses/types.d.ts +2 -0
  24. package/dist/harnesses/types.js +5 -1
  25. package/dist/lib/atomic-write.js +10 -2
  26. package/dist/lib/config-merger.d.ts +6 -3
  27. package/dist/lib/config-merger.js +50 -7
  28. package/dist/lib/display.d.ts +21 -5
  29. package/dist/lib/display.js +118 -13
  30. package/dist/lib/file-ops.d.ts +13 -5
  31. package/dist/lib/file-ops.js +34 -34
  32. package/dist/lib/install-lock.d.ts +47 -0
  33. package/dist/lib/install-lock.js +251 -0
  34. package/dist/lib/json-guards.d.ts +22 -0
  35. package/dist/lib/json-guards.js +33 -0
  36. package/dist/lib/manifest.d.ts +28 -0
  37. package/dist/lib/manifest.js +61 -12
  38. package/dist/lib/paths.d.ts +2 -17
  39. package/dist/lib/paths.js +4 -19
  40. package/dist/lib/settings-merger.js +3 -1
  41. package/dist/steps/agent-metrics-cli.d.ts +72 -0
  42. package/dist/steps/agent-metrics-cli.js +147 -0
  43. package/dist/steps/agents.d.ts +11 -0
  44. package/dist/steps/agents.js +30 -25
  45. package/dist/steps/auth.d.ts +13 -0
  46. package/dist/steps/auth.js +61 -5
  47. package/dist/steps/cli.d.ts +6 -0
  48. package/dist/steps/cli.js +29 -10
  49. package/dist/steps/commands.d.ts +10 -0
  50. package/dist/steps/commands.js +31 -30
  51. package/dist/steps/detect.js +15 -1
  52. package/dist/steps/mcp.js +1 -8
  53. package/dist/steps/metrics.js +10 -3
  54. package/dist/steps/shell.js +3 -13
  55. package/dist/steps/signup.js +14 -1
  56. package/dist/steps/skills.d.ts +14 -0
  57. package/dist/steps/skills.js +95 -0
  58. package/dist/steps/verify.js +195 -91
  59. package/package.json +3 -2
@@ -1,8 +1,39 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { atomicWrite } from "./atomic-write.js";
3
3
  const MCP_PACKAGES = ["@uluops/ops-mcp", "@uluops/registry-mcp"];
4
+ /**
5
+ * In-process memoization for the npm availability probe.
6
+ *
7
+ * Setup runs once per process; with multi-harness installs (or any future
8
+ * code path that calls installMcp more than once), the probe was firing
9
+ * redundantly against the npm registry. We cache the in-flight promise so
10
+ * concurrent callers share a single round-trip and subsequent callers get
11
+ * the resolved value instantly. Cache lifetime is the process — setup is
12
+ * one-shot, so a TTL adds state without buying anything.
13
+ *
14
+ * `__resetAvailabilityCacheForTesting` exists ONLY for tests that stub
15
+ * `fetch` per-case. Without a reset, the first test in a file would lock
16
+ * the cached result for every subsequent test in the same process.
17
+ */
18
+ let _availabilityCache = null;
19
+ /** Test-only: drop the memoized availability promise so the next call re-probes. */
20
+ export function __resetAvailabilityCacheForTesting() {
21
+ _availabilityCache = null;
22
+ }
4
23
  /** Check whether the UluOps MCP client packages exist on the npm registry. Returns lists of available and missing packages. */
5
- export async function checkMcpPackageAvailability() {
24
+ export function checkMcpPackageAvailability() {
25
+ if (_availabilityCache)
26
+ return _availabilityCache;
27
+ _availabilityCache = probeAvailability().catch((err) => {
28
+ // If the probe itself throws unexpectedly (not an individual fetch — those
29
+ // are caught by Promise.allSettled), drop the cache so retries don't
30
+ // permanently inherit a poisoned promise.
31
+ _availabilityCache = null;
32
+ throw err;
33
+ });
34
+ return _availabilityCache;
35
+ }
36
+ async function probeAvailability() {
6
37
  const available = [];
7
38
  const missing = [];
8
39
  const results = await Promise.allSettled(MCP_PACKAGES.map((pkg) => fetch(`https://registry.npmjs.org/${pkg}`, {
@@ -10,16 +41,28 @@ export async function checkMcpPackageAvailability() {
10
41
  signal: AbortSignal.timeout(5000),
11
42
  redirect: "follow",
12
43
  }).then((res) => ({ pkg, ok: res.ok }))));
44
+ // Per-index correspondence: results[i] corresponds to MCP_PACKAGES[i] by
45
+ // Promise.allSettled's stable ordering. The previous `?? "unknown"` fallback
46
+ // could emit a literal "unknown" string into `missing`, hiding the real
47
+ // failure reason (DNS error, timeout, 404) under an undiagnosable label.
13
48
  for (let i = 0; i < results.length; i++) {
14
49
  const result = results[i];
15
- if (result.status === "fulfilled" && result.value.ok) {
16
- available.push(result.value.pkg);
50
+ const pkg = MCP_PACKAGES[i];
51
+ if (result.status === "fulfilled") {
52
+ if (result.value.ok) {
53
+ available.push(pkg);
54
+ }
55
+ else {
56
+ // Registry returned non-2xx — package likely missing or unpublished.
57
+ missing.push(pkg);
58
+ }
17
59
  }
18
60
  else {
19
- const pkg = result.status === "fulfilled"
20
- ? result.value.pkg
21
- : MCP_PACKAGES[i] ?? "unknown";
22
- missing.push(pkg);
61
+ // Network failure: AbortError (timeout), DNS, TLS, EAI_AGAIN, etc.
62
+ const reason = result.reason instanceof Error
63
+ ? result.reason.message
64
+ : String(result.reason);
65
+ missing.push(`${pkg} (network: ${reason})`);
23
66
  }
24
67
  }
25
68
  return { available, missing };
@@ -1,13 +1,29 @@
1
- import type { HarnessProfile } from "../harnesses/index.js";
1
+ import type { PerHarnessResult } from "../commands/per-harness.js";
2
2
  declare const ok: (msg: string) => void;
3
3
  declare const warn: (msg: string) => void;
4
4
  declare const fail: (msg: string) => void;
5
5
  declare const info: (msg: string) => void;
6
6
  export { ok, warn, fail, info };
7
- export declare function printSetupSummary(opts: {
8
- profile: HarnessProfile;
9
- agentCount: number;
10
- commandCount: number;
7
+ /**
8
+ * Render the final post-run summary.
9
+ *
10
+ * Single-harness: preserves today's banner format (Setup complete!
11
+ * + agent list + restart instruction) — regression baseline.
12
+ *
13
+ * Multi-harness: aggregate header line, per-harness section block with
14
+ * status icons + counts + re-run hints, single API-key reminder, single
15
+ * restart instruction naming each successfully-installed harness.
16
+ *
17
+ * Status rendering:
18
+ * ok — ✓ green installed (counts)
19
+ * ok+files-failed — same line, the per-step warn()s already surfaced
20
+ * the failed files during install (not re-printed)
21
+ * failed (partial) — ⚠ yellow partial — failed at "<step>"; re-run hint
22
+ * failed (pre-MCP)— ✗ red failed — <error>; re-run hint
23
+ * declined — ⊘ dim skipped — user declined conflict prompt
24
+ */
25
+ export declare function printSetupSummary(input: {
26
+ results: PerHarnessResult[];
11
27
  apiKey: string;
12
28
  }): Promise<void>;
13
29
  export declare function maskKey(key: string): string;
@@ -5,30 +5,135 @@ const warn = (msg) => console.log(` ${chalk.yellow("⚠")} ${msg}`);
5
5
  const fail = (msg) => console.log(` ${chalk.red("✗")} ${msg}`);
6
6
  const info = (msg) => console.log(` ${msg}`);
7
7
  export { ok, warn, fail, info };
8
- export async function printSetupSummary(opts) {
8
+ const DIVIDER = ` ${chalk.dim("━".repeat(46))}`;
9
+ /**
10
+ * Render the final post-run summary.
11
+ *
12
+ * Single-harness: preserves today's banner format (Setup complete!
13
+ * + agent list + restart instruction) — regression baseline.
14
+ *
15
+ * Multi-harness: aggregate header line, per-harness section block with
16
+ * status icons + counts + re-run hints, single API-key reminder, single
17
+ * restart instruction naming each successfully-installed harness.
18
+ *
19
+ * Status rendering:
20
+ * ok — ✓ green installed (counts)
21
+ * ok+files-failed — same line, the per-step warn()s already surfaced
22
+ * the failed files during install (not re-printed)
23
+ * failed (partial) — ⚠ yellow partial — failed at "<step>"; re-run hint
24
+ * failed (pre-MCP)— ✗ red failed — <error>; re-run hint
25
+ * declined — ⊘ dim skipped — user declined conflict prompt
26
+ */
27
+ export async function printSetupSummary(input) {
28
+ const { results, apiKey } = input;
29
+ if (results.length === 0) {
30
+ // runSetup's empty-list branch already printed "nothing to install"
31
+ // and returned; this is defense-in-depth so the summary never crashes
32
+ // on an empty input.
33
+ return;
34
+ }
9
35
  console.log();
10
- console.log(` ${chalk.dim("━".repeat(46))}`);
36
+ console.log(DIVIDER);
11
37
  console.log();
12
- const parts = [`${opts.agentCount} agents`];
13
- if (opts.commandCount > 0)
14
- parts.push(`${opts.commandCount} slash commands`);
15
- if (opts.profile.hooks)
16
- parts.push("metrics");
17
- console.log(` ${chalk.bold("Setup complete!")} ${chalk.dim(`(${opts.profile.displayName})`)} ${parts.join(" · ")}`);
38
+ const installed = results.filter((r) => r.status === "ok").length;
39
+ const failed = results.filter((r) => r.status === "failed").length;
40
+ const declined = results.filter((r) => r.status === "declined").length;
41
+ const total = results.length;
42
+ // Header
43
+ if (total === 1) {
44
+ const only = results[0];
45
+ if (only.status === "ok") {
46
+ console.log(` ${chalk.bold("Setup complete!")} ${chalk.dim(`(${only.profile.displayName})`)} ${renderCounts(only)}`);
47
+ }
48
+ else if (only.status === "declined") {
49
+ console.log(` ${chalk.bold("Setup skipped")} ${chalk.dim(`(${only.profile.displayName})`)} — you declined the conflict prompt`);
50
+ }
51
+ else {
52
+ console.log(` ${chalk.red.bold("Setup failed")} ${chalk.dim(`(${only.profile.displayName})`)} — ${only.error ?? "see output above"}`);
53
+ }
54
+ }
55
+ else {
56
+ const summaryParts = [`${installed} installed`];
57
+ if (failed > 0)
58
+ summaryParts.push(`${failed} failed`);
59
+ if (declined > 0)
60
+ summaryParts.push(`${declined} declined`);
61
+ const allOk = failed === 0 && declined === 0;
62
+ const headerLabel = allOk ? "Setup complete:" : "Setup finished:";
63
+ console.log(` ${chalk.bold(headerLabel)} ${summaryParts.join(", ")} of ${total} harnesses`);
64
+ console.log();
65
+ for (const r of results) {
66
+ printHarnessLine(r);
67
+ }
68
+ }
18
69
  console.log();
19
- if (opts.profile.name === "claude-code") {
70
+ // Agent list — only for single-harness claude-code success (the bulk of
71
+ // single-harness installs). Multi-harness summaries omit it: it's long
72
+ // and per-claude-code, and the multi-harness reader is more interested
73
+ // in the per-harness status block than the agent catalog.
74
+ if (total === 1 &&
75
+ results[0].status === "ok" &&
76
+ results[0].profile.name === "claude-code") {
20
77
  await printAgentList();
21
78
  }
22
- const masked = maskKey(opts.apiKey);
79
+ // API-key reminder — once per run regardless of harness count.
80
+ const masked = maskKey(apiKey);
23
81
  info("For SDK/CLI usage, add to your shell profile:");
24
82
  info(` ${chalk.cyan(`export ULUOPS_API_KEY="${masked}"`)}`);
25
83
  console.log();
26
84
  info(`Run again to update: ${chalk.cyan("npx @uluops/setup")}`);
27
85
  console.log();
28
- console.log(` ${chalk.dim("━".repeat(46))}`);
29
- console.log();
30
- console.log(` ${chalk.yellow.bold(`Restart ${opts.profile.displayName} to load agents.`)}`);
86
+ console.log(DIVIDER);
31
87
  console.log();
88
+ // Restart instruction — names each successfully-installed harness so
89
+ // the user knows what to restart. Suppressed entirely when nothing
90
+ // installed (all declined / all failed pre-MCP) — there's nothing to
91
+ // restart.
92
+ const restartTargets = results.filter((r) => r.status === "ok");
93
+ if (restartTargets.length > 0) {
94
+ const names = restartTargets.map((r) => r.profile.displayName).join(", ");
95
+ const verb = restartTargets.length === 1 ? "Restart" : "Restart each of";
96
+ console.log(` ${chalk.yellow.bold(`${verb} ${names} to load agents.`)}`);
97
+ console.log();
98
+ }
99
+ }
100
+ function printHarnessLine(r) {
101
+ const label = chalk.bold(`[${r.profile.displayName}]`);
102
+ switch (r.status) {
103
+ case "ok": {
104
+ const counts = renderCounts(r);
105
+ console.log(` ${chalk.green("✓")} ${label} installed ${counts}`);
106
+ return;
107
+ }
108
+ case "failed": {
109
+ if (r.partial) {
110
+ console.log(` ${chalk.yellow("⚠")} ${label} partial — failed at "${r.partial}"${r.error ? `: ${r.error}` : ""}`);
111
+ }
112
+ else {
113
+ console.log(` ${chalk.red("✗")} ${label} failed — ${r.error ?? "see output above"}`);
114
+ }
115
+ console.log(` ${chalk.dim(`Re-run: npx @uluops/setup --harness ${r.harnessName}`)}`);
116
+ return;
117
+ }
118
+ case "declined":
119
+ console.log(` ${chalk.dim("⊘")} ${label} skipped — user declined conflict prompt`);
120
+ return;
121
+ }
122
+ }
123
+ function renderCounts(r) {
124
+ const parts = [];
125
+ const agents = r.agentsResult?.files.length ?? 0;
126
+ const commands = r.commandsResult?.files.length ?? 0;
127
+ const skills = r.skillsResult?.files.length ?? 0;
128
+ if (agents > 0)
129
+ parts.push(`${agents} agents`);
130
+ if (commands > 0)
131
+ parts.push(`${commands} commands`);
132
+ if (skills > 0)
133
+ parts.push(`${skills} skills`);
134
+ if (r.metricsResult?.hookConfigured)
135
+ parts.push("metrics");
136
+ return parts.length > 0 ? `(${parts.join(" · ")})` : "";
32
137
  }
33
138
  export function maskKey(key) {
34
139
  if (!key || key.length <= 4)
@@ -11,6 +11,19 @@ export declare function writeIfChanged(destPath: string, content: string, dryRun
11
11
  * Remove files from a directory. Returns count of successfully removed files.
12
12
  */
13
13
  export declare function unlinkFiles(dir: string, files: string[]): Promise<number>;
14
+ /**
15
+ * Reconcile a manifest's old file list against the current source set,
16
+ * unlinking the files that were installed previously but are no longer in
17
+ * the source. Returns the count of files that would have been removed
18
+ * (whether or not the unlink actually ran in dry-run mode).
19
+ *
20
+ * Extracted from three near-identical blocks in syncAssets, installAgents,
21
+ * and installCommands. Errors from unlink are swallowed silently — the
22
+ * "already gone" case is the dominant one (idempotent re-run, manual user
23
+ * deletion, prior failed install), and there's no recovery the caller
24
+ * can usefully perform mid-loop.
25
+ */
26
+ export declare function removeStaleFiles(destDir: string, oldManifestFiles: string[] | undefined, currentFiles: string[], dryRun: boolean): Promise<number>;
14
27
  /**
15
28
  * Ensure a directory exists, then copy matching .md files using hash comparison.
16
29
  * Returns list of copied files, skipped count, and removed count (for old manifest entries).
@@ -27,8 +40,3 @@ export declare function syncAssets(opts: {
27
40
  removed: number;
28
41
  files: string[];
29
42
  }>;
30
- /**
31
- * Back up a file to the UluOps backup directory before modifying it.
32
- * No-op if the source file doesn't exist.
33
- */
34
- export declare function backupFile(srcPath: string, backupDir: string): Promise<void>;
@@ -1,5 +1,5 @@
1
- import { readFile, writeFile, mkdir, unlink, access, copyFile, readdir } from "node:fs/promises";
2
- import { join, basename } from "node:path";
1
+ import { readFile, writeFile, mkdir, unlink, readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
3
  import { fileHash } from "./hash.js";
4
4
  /**
5
5
  * Copy a file if its content has changed (hash comparison). Returns "copied" or "skipped".
@@ -57,6 +57,37 @@ export async function unlinkFiles(dir, files) {
57
57
  }
58
58
  return removed;
59
59
  }
60
+ /**
61
+ * Reconcile a manifest's old file list against the current source set,
62
+ * unlinking the files that were installed previously but are no longer in
63
+ * the source. Returns the count of files that would have been removed
64
+ * (whether or not the unlink actually ran in dry-run mode).
65
+ *
66
+ * 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.
71
+ */
72
+ export async function removeStaleFiles(destDir, oldManifestFiles, currentFiles, dryRun) {
73
+ if (!oldManifestFiles)
74
+ return 0;
75
+ let removed = 0;
76
+ for (const oldFile of oldManifestFiles) {
77
+ if (!currentFiles.includes(oldFile)) {
78
+ if (!dryRun) {
79
+ try {
80
+ await unlink(join(destDir, oldFile));
81
+ }
82
+ catch {
83
+ // Already gone
84
+ }
85
+ }
86
+ removed++;
87
+ }
88
+ }
89
+ return removed;
90
+ }
60
91
  /**
61
92
  * Ensure a directory exists, then copy matching .md files using hash comparison.
62
93
  * Returns list of copied files, skipped count, and removed count (for old manifest entries).
@@ -83,40 +114,9 @@ export async function syncAssets(opts) {
83
114
  }
84
115
  }
85
116
  // Remove files that were in the old manifest but no longer in the package
86
- let removed = 0;
87
- if (opts.oldManifestFiles) {
88
- for (const oldFile of opts.oldManifestFiles) {
89
- if (!assetFiles.includes(oldFile)) {
90
- if (!opts.dryRun) {
91
- try {
92
- await unlink(join(opts.destDir, oldFile));
93
- }
94
- catch {
95
- // Already gone
96
- }
97
- }
98
- removed++;
99
- }
100
- }
101
- }
117
+ const removed = await removeStaleFiles(opts.destDir, opts.oldManifestFiles, assetFiles, opts.dryRun);
102
118
  if (errors.length > 0) {
103
119
  throw new Error(`Failed to copy ${errors.length} file(s):\n ${errors.join("\n ")}`);
104
120
  }
105
121
  return { copied, skipped, removed, files: assetFiles };
106
122
  }
107
- /**
108
- * Back up a file to the UluOps backup directory before modifying it.
109
- * No-op if the source file doesn't exist.
110
- */
111
- export async function backupFile(srcPath, backupDir) {
112
- try {
113
- await access(srcPath);
114
- }
115
- catch {
116
- return; // Nothing to back up
117
- }
118
- await mkdir(backupDir, { recursive: true });
119
- const filename = basename(srcPath);
120
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
121
- await copyFile(srcPath, join(backupDir, `${filename}.${timestamp}.bak`));
122
- }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Process-level mutex for uluops-setup install/uninstall operations.
3
+ *
4
+ * Solves the TOCTOU race surfaced by ship-pipeline code-auditor (AF-006):
5
+ * two concurrent `npx @uluops/setup` invocations both read shared state,
6
+ * each merges and writes its own version, second write clobbers first.
7
+ *
8
+ * Uses `mkdir` atomicity (POSIX + NTFS, ~50 years stable) instead of a
9
+ * dependency. Meta file inside the lock dir carries PID + hostname +
10
+ * start timestamp so stale locks can be reclaimed automatically.
11
+ *
12
+ * Scope is intentionally narrow: this protects setup-vs-setup races only.
13
+ * Setup-vs-harness races (the harness CLI writing to its own settings file
14
+ * concurrently) require a separate compare-and-swap design.
15
+ */
16
+ export interface LockHandle {
17
+ release(): Promise<void>;
18
+ }
19
+ export interface AcquireOptions {
20
+ /** Max time a held lock is considered valid before being reclaimed (default 30 min). */
21
+ maxAgeMs?: number;
22
+ /** How long to wait for a held lock before failing (default 0). */
23
+ waitMs?: number;
24
+ /** Override the lock directory (test seam). */
25
+ lockDir?: string;
26
+ }
27
+ /** Thrown when another process holds the lock and waiting has exhausted. */
28
+ export declare class InstallLockHeldError extends Error {
29
+ readonly holder: {
30
+ pid: number;
31
+ hostname: string;
32
+ ageMs: number;
33
+ };
34
+ constructor(holder: {
35
+ pid: number;
36
+ hostname: string;
37
+ ageMs: number;
38
+ });
39
+ }
40
+ /**
41
+ * Acquire the install lock. Resolves with a handle whose `release()` must be
42
+ * called in a `finally` block. Throws `InstallLockHeldError` if the lock is
43
+ * held by a live process and waiting (if any) has exhausted.
44
+ */
45
+ export declare function acquireInstallLock(opts?: AcquireOptions): Promise<LockHandle>;
46
+ /** Test-only: detach handlers and clear lock-dir tracking so tests can rebind. */
47
+ export declare function __resetSignalHandlersForTesting(): void;
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Process-level mutex for uluops-setup install/uninstall operations.
3
+ *
4
+ * Solves the TOCTOU race surfaced by ship-pipeline code-auditor (AF-006):
5
+ * two concurrent `npx @uluops/setup` invocations both read shared state,
6
+ * each merges and writes its own version, second write clobbers first.
7
+ *
8
+ * Uses `mkdir` atomicity (POSIX + NTFS, ~50 years stable) instead of a
9
+ * dependency. Meta file inside the lock dir carries PID + hostname +
10
+ * start timestamp so stale locks can be reclaimed automatically.
11
+ *
12
+ * Scope is intentionally narrow: this protects setup-vs-setup races only.
13
+ * Setup-vs-harness races (the harness CLI writing to its own settings file
14
+ * concurrently) require a separate compare-and-swap design.
15
+ */
16
+ import { mkdir, readFile, rm, unlink, writeFile, } from "node:fs/promises";
17
+ import { rmSync } from "node:fs";
18
+ import { hostname } from "node:os";
19
+ import { dirname, join } from "node:path";
20
+ import { getInstallLockDir } from "./paths.js";
21
+ const META_FILENAME = "meta.json";
22
+ const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000; // 30 min
23
+ const DEFAULT_WAIT_MS = 0;
24
+ const POLL_INTERVAL_MS = 500;
25
+ /** Thrown when another process holds the lock and waiting has exhausted. */
26
+ export class InstallLockHeldError extends Error {
27
+ holder;
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).`);
30
+ this.holder = holder;
31
+ this.name = "InstallLockHeldError";
32
+ }
33
+ }
34
+ /**
35
+ * Acquire the install lock. Resolves with a handle whose `release()` must be
36
+ * called in a `finally` block. Throws `InstallLockHeldError` if the lock is
37
+ * held by a live process and waiting (if any) has exhausted.
38
+ */
39
+ export async function acquireInstallLock(opts = {}) {
40
+ const lockDir = opts.lockDir ?? getInstallLockDir();
41
+ const maxAgeMs = opts.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
42
+ const waitMs = opts.waitMs ?? DEFAULT_WAIT_MS;
43
+ const deadline = Date.now() + waitMs;
44
+ // Ensure the parent directory exists before the atomic lock mkdir below.
45
+ // The lock dir itself is created with `recursive: false` to preserve
46
+ // mkdir-atomicity as the lock primitive (two racing processes can't both
47
+ // win the mkdir). But if the user has never run setup before, ~/.uluops/
48
+ // doesn't exist yet — and `recursive: false` mkdir surfaces ENOENT on
49
+ // the missing parent rather than EEXIST on the lock itself, so the loop
50
+ // below would treat that as an unrecoverable error. Pre-creating the
51
+ // parent with `recursive: true` is safe (it's idempotent and not part of
52
+ // the atomicity contract — only the lock dir mkdir is).
53
+ await mkdir(dirname(lockDir), { recursive: true });
54
+ // First try (and one retry after stale-lock reclaim).
55
+ for (let attempt = 0; attempt < 2; attempt++) {
56
+ try {
57
+ await mkdir(lockDir, { recursive: false });
58
+ // Won the race. Write metadata, register handlers, return handle.
59
+ const meta = {
60
+ pid: process.pid,
61
+ hostname: hostname(),
62
+ startedAt: Date.now(),
63
+ };
64
+ await writeFile(join(lockDir, META_FILENAME), JSON.stringify(meta));
65
+ return registerHandle(lockDir);
66
+ }
67
+ catch (err) {
68
+ const code = err.code;
69
+ if (code !== "EEXIST")
70
+ throw err;
71
+ }
72
+ // Lock exists. Inspect it.
73
+ const verdict = await inspectHeldLock(lockDir, maxAgeMs);
74
+ if (verdict.kind === "stale") {
75
+ // Reclaim and retry once.
76
+ await rm(lockDir, { recursive: true, force: true });
77
+ continue;
78
+ }
79
+ // Live holder. Optionally wait.
80
+ if (Date.now() < deadline) {
81
+ while (Date.now() < deadline) {
82
+ await sleep(POLL_INTERVAL_MS);
83
+ const recheck = await inspectHeldLock(lockDir, maxAgeMs);
84
+ if (recheck.kind === "stale") {
85
+ // Holder released or died during wait.
86
+ break;
87
+ }
88
+ }
89
+ // Retry once after wait.
90
+ continue;
91
+ }
92
+ throw new InstallLockHeldError({
93
+ pid: verdict.meta.pid,
94
+ hostname: verdict.meta.hostname,
95
+ ageMs: Date.now() - verdict.meta.startedAt,
96
+ });
97
+ }
98
+ // Both attempts exhausted without acquiring.
99
+ throw new InstallLockHeldError({ pid: -1, hostname: "unknown", ageMs: 0 });
100
+ }
101
+ async function inspectHeldLock(lockDir, maxAgeMs) {
102
+ const metaPath = join(lockDir, META_FILENAME);
103
+ let raw;
104
+ try {
105
+ raw = await readFile(metaPath, "utf-8");
106
+ }
107
+ catch {
108
+ // Lock dir exists but meta missing or unreadable — treat as stale.
109
+ return { kind: "stale", reason: "missing-meta" };
110
+ }
111
+ let meta;
112
+ try {
113
+ const parsed = JSON.parse(raw);
114
+ if (!isLockMeta(parsed)) {
115
+ return { kind: "stale", reason: "malformed-meta" };
116
+ }
117
+ meta = parsed;
118
+ }
119
+ catch {
120
+ return { kind: "stale", reason: "invalid-json" };
121
+ }
122
+ if (Date.now() - meta.startedAt > maxAgeMs) {
123
+ return { kind: "stale", reason: "timeout" };
124
+ }
125
+ // Same host: probe the PID. Different host: trust the meta until timeout.
126
+ if (meta.hostname === hostname()) {
127
+ if (!isPidAlive(meta.pid)) {
128
+ return { kind: "stale", reason: "dead-pid" };
129
+ }
130
+ }
131
+ return { kind: "live", meta };
132
+ }
133
+ function isLockMeta(value) {
134
+ if (typeof value !== "object" || value === null)
135
+ return false;
136
+ const v = value;
137
+ return (typeof v["pid"] === "number" &&
138
+ typeof v["hostname"] === "string" &&
139
+ typeof v["startedAt"] === "number");
140
+ }
141
+ function isPidAlive(pid) {
142
+ if (pid <= 0 || !Number.isInteger(pid))
143
+ return false;
144
+ try {
145
+ process.kill(pid, 0);
146
+ return true;
147
+ }
148
+ catch (err) {
149
+ const code = err.code;
150
+ if (code === "ESRCH")
151
+ return false;
152
+ if (code === "EPERM")
153
+ return true; // exists but we lack permission
154
+ return false;
155
+ }
156
+ }
157
+ function sleep(ms) {
158
+ return new Promise((resolve) => setTimeout(resolve, ms));
159
+ }
160
+ // ─── Signal handler registry ─────────────────────────────────────────────────
161
+ //
162
+ // All active lock dirs are tracked at module scope so signal handlers can
163
+ // release every held lock synchronously before re-raising the signal.
164
+ const heldLockDirs = new Set();
165
+ let signalHandlersInstalled = false;
166
+ let installedSigintHandler = null;
167
+ let installedSigtermHandler = null;
168
+ let installedUncaughtHandler = null;
169
+ function registerHandle(lockDir) {
170
+ heldLockDirs.add(lockDir);
171
+ ensureSignalHandlers();
172
+ let released = false;
173
+ return {
174
+ async release() {
175
+ if (released)
176
+ return;
177
+ released = true;
178
+ heldLockDirs.delete(lockDir);
179
+ try {
180
+ await unlink(join(lockDir, META_FILENAME));
181
+ }
182
+ catch {
183
+ // Already gone or unreadable; proceed to rmdir.
184
+ }
185
+ try {
186
+ await rm(lockDir, { recursive: true, force: true });
187
+ }
188
+ catch {
189
+ // Best-effort; do not throw from release().
190
+ }
191
+ },
192
+ };
193
+ }
194
+ function ensureSignalHandlers() {
195
+ if (signalHandlersInstalled)
196
+ return;
197
+ signalHandlersInstalled = true;
198
+ const cleanup = (signal) => {
199
+ for (const dir of heldLockDirs) {
200
+ try {
201
+ rmSync(dir, { recursive: true, force: true });
202
+ }
203
+ catch {
204
+ // Best-effort.
205
+ }
206
+ }
207
+ heldLockDirs.clear();
208
+ // Re-raise the signal so default Node behavior runs (exit with the
209
+ // conventional 128 + signum code). Listeners were already triggered.
210
+ process.removeListener(signal, cleanup);
211
+ process.kill(process.pid, signal);
212
+ };
213
+ const uncaught = (err) => {
214
+ for (const dir of heldLockDirs) {
215
+ try {
216
+ rmSync(dir, { recursive: true, force: true });
217
+ }
218
+ catch {
219
+ // Best-effort.
220
+ }
221
+ }
222
+ heldLockDirs.clear();
223
+ // Restore default behavior: print stack and exit 1.
224
+ console.error(err);
225
+ process.exit(1);
226
+ };
227
+ installedSigintHandler = cleanup;
228
+ installedSigtermHandler = cleanup;
229
+ installedUncaughtHandler = uncaught;
230
+ process.on("SIGINT", cleanup);
231
+ process.on("SIGTERM", cleanup);
232
+ process.on("uncaughtException", uncaught);
233
+ }
234
+ // ─── Test seams ──────────────────────────────────────────────────────────────
235
+ /** Test-only: detach handlers and clear lock-dir tracking so tests can rebind. */
236
+ export function __resetSignalHandlersForTesting() {
237
+ if (installedSigintHandler) {
238
+ process.removeListener("SIGINT", installedSigintHandler);
239
+ installedSigintHandler = null;
240
+ }
241
+ if (installedSigtermHandler) {
242
+ process.removeListener("SIGTERM", installedSigtermHandler);
243
+ installedSigtermHandler = null;
244
+ }
245
+ if (installedUncaughtHandler) {
246
+ process.removeListener("uncaughtException", installedUncaughtHandler);
247
+ installedUncaughtHandler = null;
248
+ }
249
+ signalHandlersInstalled = false;
250
+ heldLockDirs.clear();
251
+ }