@uluops/setup 0.10.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.
- package/CHANGELOG.md +772 -0
- package/README.md +81 -32
- package/dist/cli.js +7 -1
- package/dist/commands/helpers.js +70 -7
- package/dist/commands/per-harness.d.ts +5 -0
- package/dist/commands/per-harness.js +5 -0
- package/dist/commands/setup.d.ts +7 -0
- package/dist/commands/setup.js +100 -35
- package/dist/commands/uninstall.d.ts +7 -0
- package/dist/commands/uninstall.js +36 -9
- package/dist/commands/verify.d.ts +5 -0
- package/dist/commands/verify.js +5 -0
- package/dist/harnesses/claude-code.js +15 -7
- package/dist/harnesses/codex.js +21 -4
- package/dist/harnesses/gemini-cli.js +13 -6
- package/dist/harnesses/index.d.ts +8 -0
- package/dist/harnesses/index.js +10 -0
- package/dist/harnesses/opencode.js +25 -7
- package/dist/lib/asset-catalog.js +15 -2
- package/dist/lib/atomic-write.d.ts +6 -0
- package/dist/lib/atomic-write.js +10 -0
- package/dist/lib/config-merger.js +27 -8
- package/dist/lib/display.d.ts +8 -0
- package/dist/lib/display.js +27 -1
- package/dist/lib/file-ops.d.ts +13 -4
- package/dist/lib/file-ops.js +69 -18
- package/dist/lib/install-lock.js +45 -13
- package/dist/lib/json-guards.d.ts +15 -0
- package/dist/lib/json-guards.js +30 -0
- package/dist/lib/manifest.d.ts +9 -2
- package/dist/lib/manifest.js +66 -8
- package/dist/lib/mcp-packages.d.ts +17 -15
- package/dist/lib/mcp-packages.js +15 -13
- package/dist/lib/settings-merger.js +53 -9
- package/dist/lib/version.js +19 -2
- package/dist/lib/write-coordinator.d.ts +50 -0
- package/dist/lib/write-coordinator.js +89 -0
- package/dist/steps/agent-metrics-cli.d.ts +6 -0
- package/dist/steps/agent-metrics-cli.js +19 -1
- package/dist/steps/agents.js +22 -4
- package/dist/steps/auth.js +53 -13
- package/dist/steps/cli.js +14 -1
- package/dist/steps/commands.js +28 -10
- package/dist/steps/mcp.js +18 -9
- package/dist/steps/metrics.js +77 -7
- package/dist/steps/shell.d.ts +4 -1
- package/dist/steps/shell.js +44 -6
- package/dist/steps/signup.d.ts +4 -0
- package/dist/steps/signup.js +18 -2
- package/dist/steps/skills.d.ts +11 -0
- package/dist/steps/skills.js +35 -6
- package/dist/steps/username.js +10 -2
- package/dist/steps/verify.js +55 -6
- package/package.json +8 -5
- package/dist/lib/agent-transform.d.ts +0 -12
- package/dist/lib/agent-transform.js +0 -129
|
@@ -10,4 +10,11 @@ export interface RunUninstallOpts {
|
|
|
10
10
|
/** Whether --all-detected was passed. */
|
|
11
11
|
allDetected?: boolean;
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* The `--uninstall` flow: removes UluOps-managed artifacts for every harness
|
|
15
|
+
* in the manifest, or — when a harness filter is passed — only the named
|
|
16
|
+
* subset, preserving shared infrastructure (global CLI packages, shell
|
|
17
|
+
* export) that remaining harnesses still need. Fails fast if a named harness
|
|
18
|
+
* is not in the manifest.
|
|
19
|
+
*/
|
|
13
20
|
export declare function runUninstall(opts: RunUninstallOpts): Promise<void>;
|
|
@@ -13,6 +13,13 @@ import { ok, warn, fail, info } from "../lib/display.js";
|
|
|
13
13
|
import { getVersion } from "../lib/version.js";
|
|
14
14
|
import { getProfile } from "../harnesses/index.js";
|
|
15
15
|
import { resolveUninstallFilter, UninstallFilterError, } from "./uninstall-filter.js";
|
|
16
|
+
/**
|
|
17
|
+
* The `--uninstall` flow: removes UluOps-managed artifacts for every harness
|
|
18
|
+
* in the manifest, or — when a harness filter is passed — only the named
|
|
19
|
+
* subset, preserving shared infrastructure (global CLI packages, shell
|
|
20
|
+
* export) that remaining harnesses still need. Fails fast if a named harness
|
|
21
|
+
* is not in the manifest.
|
|
22
|
+
*/
|
|
16
23
|
export async function runUninstall(opts) {
|
|
17
24
|
const version = await getVersion();
|
|
18
25
|
console.log();
|
|
@@ -64,7 +71,12 @@ export async function runUninstall(opts) {
|
|
|
64
71
|
if (err instanceof UninstallFilterError) {
|
|
65
72
|
fail(err.message);
|
|
66
73
|
console.log();
|
|
67
|
-
process.exit
|
|
74
|
+
// Same rule as runSetup: never process.exit inside the lock-guarded
|
|
75
|
+
// try. A plain `return` here makes any statement after the finally
|
|
76
|
+
// unreachable, so the code must ride process.exitCode — safe at this
|
|
77
|
+
// point because no prompt has run (no lingering stdin handle).
|
|
78
|
+
process.exitCode = 1;
|
|
79
|
+
return;
|
|
68
80
|
}
|
|
69
81
|
throw err;
|
|
70
82
|
}
|
|
@@ -120,8 +132,8 @@ export async function runUninstall(opts) {
|
|
|
120
132
|
await uninstallMcp(profile, hm.mcpConfigPath);
|
|
121
133
|
ok(`Removed MCP servers from ${hm.mcpConfigPath}`);
|
|
122
134
|
}
|
|
123
|
-
catch {
|
|
124
|
-
warn(`Could not remove MCP servers from ${hm.mcpConfigPath}`);
|
|
135
|
+
catch (err) {
|
|
136
|
+
warn(`Could not remove MCP servers from ${hm.mcpConfigPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
125
137
|
}
|
|
126
138
|
}
|
|
127
139
|
else {
|
|
@@ -180,8 +192,13 @@ export async function runUninstall(opts) {
|
|
|
180
192
|
const { getShellProfile } = await import("../lib/paths.js");
|
|
181
193
|
const shellProfile = getShellProfile();
|
|
182
194
|
if (shellProfile && !opts.dryRun) {
|
|
183
|
-
await removeShellExport(shellProfile.path);
|
|
184
|
-
|
|
195
|
+
const shellRes = await removeShellExport(shellProfile.path);
|
|
196
|
+
if (shellRes.removed) {
|
|
197
|
+
ok(`Removed export from ${shellProfile.path}`);
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
warn(`Could not remove export from ${shellProfile.path}${shellRes.reason ? ` — ${shellRes.reason}` : ""}. The plaintext API key may still be in the file; remove the UluOps block manually.`);
|
|
201
|
+
}
|
|
185
202
|
}
|
|
186
203
|
else if (shellProfile) {
|
|
187
204
|
ok(`Would remove export from ${shellProfile.path}`);
|
|
@@ -199,8 +216,13 @@ export async function runUninstall(opts) {
|
|
|
199
216
|
// would reject an empty-harnesses file)
|
|
200
217
|
if (!opts.dryRun) {
|
|
201
218
|
if (isFullUninstall) {
|
|
202
|
-
await deleteManifest();
|
|
203
|
-
|
|
219
|
+
const delRes = await deleteManifest();
|
|
220
|
+
if (delRes.failed.length === 0) {
|
|
221
|
+
ok("Manifest deleted");
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
warn(`Could not delete manifest: ${delRes.failed.join("; ")} — it still records this install; remove it manually.`);
|
|
225
|
+
}
|
|
204
226
|
}
|
|
205
227
|
else {
|
|
206
228
|
for (const name of toUninstall) {
|
|
@@ -212,8 +234,13 @@ export async function runUninstall(opts) {
|
|
|
212
234
|
ok(`Manifest updated — ${remaining} harness(es) remain: ${Object.keys(manifest.harnesses).join(", ")}`);
|
|
213
235
|
}
|
|
214
236
|
else {
|
|
215
|
-
await deleteManifest();
|
|
216
|
-
|
|
237
|
+
const delRes2 = await deleteManifest();
|
|
238
|
+
if (delRes2.failed.length === 0) {
|
|
239
|
+
ok("Manifest deleted (no harnesses remain)");
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
warn(`Could not delete manifest: ${delRes2.failed.join("; ")} — remove it manually.`);
|
|
243
|
+
}
|
|
217
244
|
}
|
|
218
245
|
}
|
|
219
246
|
}
|
package/dist/commands/verify.js
CHANGED
|
@@ -2,6 +2,11 @@ import chalk from "chalk";
|
|
|
2
2
|
import { verify } from "../steps/verify.js";
|
|
3
3
|
import { ok, fail, info } from "../lib/display.js";
|
|
4
4
|
import { getVersion } from "../lib/version.js";
|
|
5
|
+
/**
|
|
6
|
+
* The `--verify` flow: prints one check line per verify-step result
|
|
7
|
+
* (manifest, per-harness readiness/MCP/files/hook, API key) and exits 1
|
|
8
|
+
* when any check failed. Read-only — makes no changes.
|
|
9
|
+
*/
|
|
5
10
|
export async function runVerify() {
|
|
6
11
|
const version = await getVersion();
|
|
7
12
|
console.log();
|
|
@@ -9,6 +9,7 @@ import { ULUOPS_SERVERS, } from "./types.js";
|
|
|
9
9
|
import { readConfig, mergeUluopsMcp, removeUluopsMcp, writeConfig, } from "../lib/config-merger.js";
|
|
10
10
|
import { readSettings, writeSettings, mergeUluopsHook, removeUluopsHook, hasUluopsHook, } from "../lib/settings-merger.js";
|
|
11
11
|
import { getClaudeHome, getClaudeJsonPath } from "../lib/paths.js";
|
|
12
|
+
import { serialize } from "../lib/write-coordinator.js";
|
|
12
13
|
class ClaudeCodeMcpConfig {
|
|
13
14
|
async read(path) {
|
|
14
15
|
return readConfig(path);
|
|
@@ -33,18 +34,25 @@ class ClaudeCodeHooks {
|
|
|
33
34
|
async install(settingsPath, hookCommand, dryRun) {
|
|
34
35
|
if (dryRun)
|
|
35
36
|
return true;
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
await
|
|
37
|
+
// Serialized for uniformity with the MCP cycle — the invariant is
|
|
38
|
+
// "every settings RMW is exclusive per path", not "only where files
|
|
39
|
+
// collide today".
|
|
40
|
+
await serialize(settingsPath, async () => {
|
|
41
|
+
const settings = await readSettings(settingsPath);
|
|
42
|
+
// Claude Code uses SubagentStop as the default event for auto-save
|
|
43
|
+
const merged = mergeUluopsHook(settings, hookCommand);
|
|
44
|
+
await writeSettings(settingsPath, merged);
|
|
45
|
+
});
|
|
40
46
|
return true;
|
|
41
47
|
}
|
|
42
48
|
async remove(settingsPath, dryRun) {
|
|
43
49
|
if (dryRun)
|
|
44
50
|
return;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
await serialize(settingsPath, async () => {
|
|
52
|
+
const settings = await readSettings(settingsPath);
|
|
53
|
+
const cleaned = removeUluopsHook(settings);
|
|
54
|
+
await writeSettings(settingsPath, cleaned);
|
|
55
|
+
});
|
|
48
56
|
}
|
|
49
57
|
async check(settingsPath) {
|
|
50
58
|
const settings = await readSettings(settingsPath);
|
package/dist/harnesses/codex.js
CHANGED
|
@@ -11,6 +11,7 @@ import { homedir } from "node:os";
|
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { ULUOPS_SERVERS, } from "./types.js";
|
|
13
13
|
import { atomicWrite } from "../lib/atomic-write.js";
|
|
14
|
+
import { isEnoent } from "../lib/file-ops.js";
|
|
14
15
|
import { OPS_MCP_SPEC, REGISTRY_MCP_SPEC } from "../lib/mcp-packages.js";
|
|
15
16
|
const RAW_TOML = "__rawToml";
|
|
16
17
|
/**
|
|
@@ -146,10 +147,17 @@ function removeServerConfigBlocks(raw, name) {
|
|
|
146
147
|
const kept = [];
|
|
147
148
|
let skipping = false;
|
|
148
149
|
for (const line of lines) {
|
|
149
|
-
const
|
|
150
|
+
const trimmed = line.trim();
|
|
151
|
+
const table = trimmed.match(/^\[([^\]]+)\]$/)?.[1];
|
|
150
152
|
if (table) {
|
|
151
153
|
skipping = isServerTableFor(name, table) || isServerEnvTableFor(name, table);
|
|
152
154
|
}
|
|
155
|
+
else if (trimmed.startsWith("[")) {
|
|
156
|
+
// Any header the regex can't parse (array-of-tables [[x]], quoted ']')
|
|
157
|
+
// still STARTS a new table — the skip must end, or the user's next
|
|
158
|
+
// block is dropped along with ours.
|
|
159
|
+
skipping = false;
|
|
160
|
+
}
|
|
153
161
|
if (!skipping)
|
|
154
162
|
kept.push(line);
|
|
155
163
|
}
|
|
@@ -160,10 +168,15 @@ function removeServerSubtree(raw, name) {
|
|
|
160
168
|
const kept = [];
|
|
161
169
|
let skipping = false;
|
|
162
170
|
for (const line of lines) {
|
|
163
|
-
const
|
|
171
|
+
const trimmed = line.trim();
|
|
172
|
+
const table = trimmed.match(/^\[([^\]]+)\]$/)?.[1];
|
|
164
173
|
if (table) {
|
|
165
174
|
skipping = isServerTableFor(name, table) || isServerSubtableFor(name, table);
|
|
166
175
|
}
|
|
176
|
+
else if (trimmed.startsWith("[")) {
|
|
177
|
+
// See removeServerConfigBlocks: an unparseable header still ends the skip.
|
|
178
|
+
skipping = false;
|
|
179
|
+
}
|
|
167
180
|
if (!skipping)
|
|
168
181
|
kept.push(line);
|
|
169
182
|
}
|
|
@@ -182,8 +195,12 @@ class CodexMcpConfig {
|
|
|
182
195
|
try {
|
|
183
196
|
return { [RAW_TOML]: await readFile(path, "utf-8") };
|
|
184
197
|
}
|
|
185
|
-
catch {
|
|
186
|
-
|
|
198
|
+
catch (err) {
|
|
199
|
+
if (isEnoent(err))
|
|
200
|
+
return { [RAW_TOML]: "" };
|
|
201
|
+
// Unreadable-but-PRESENT must never read as fresh: write() replaces
|
|
202
|
+
// the whole file, so an EACCES here would erase the user's config.
|
|
203
|
+
throw new Error(`Could not read Codex config 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.`);
|
|
187
204
|
}
|
|
188
205
|
}
|
|
189
206
|
merge(config, apiKey) {
|
|
@@ -13,6 +13,7 @@ import { join } from "node:path";
|
|
|
13
13
|
import { ULUOPS_SERVERS, } from "./types.js";
|
|
14
14
|
import { readConfig, mergeUluopsMcp, removeUluopsMcp, writeConfig, } from "../lib/config-merger.js";
|
|
15
15
|
import { readSettings, writeSettings, mergeUluopsHook, removeUluopsHook, hasUluopsHook, } from "../lib/settings-merger.js";
|
|
16
|
+
import { serialize } from "../lib/write-coordinator.js";
|
|
16
17
|
class GeminiMcpConfig {
|
|
17
18
|
async read(path) {
|
|
18
19
|
return readConfig(path);
|
|
@@ -40,17 +41,23 @@ class GeminiHooks {
|
|
|
40
41
|
async install(settingsPath, hookCommand, dryRun) {
|
|
41
42
|
if (dryRun)
|
|
42
43
|
return true;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
await
|
|
44
|
+
// Serialized: settings.json is ALSO this profile's MCP config file —
|
|
45
|
+
// the whole read-merge-write must be exclusive against that cycle.
|
|
46
|
+
await serialize(settingsPath, async () => {
|
|
47
|
+
const settings = await readSettings(settingsPath);
|
|
48
|
+
const merged = mergeUluopsHook(settings, hookCommand, GeminiHooks.HOOK_TYPE, GeminiHooks.MATCHER);
|
|
49
|
+
await writeSettings(settingsPath, merged);
|
|
50
|
+
});
|
|
46
51
|
return true;
|
|
47
52
|
}
|
|
48
53
|
async remove(settingsPath, dryRun) {
|
|
49
54
|
if (dryRun)
|
|
50
55
|
return;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
await serialize(settingsPath, async () => {
|
|
57
|
+
const settings = await readSettings(settingsPath);
|
|
58
|
+
const cleaned = removeUluopsHook(settings, GeminiHooks.HOOK_TYPE);
|
|
59
|
+
await writeSettings(settingsPath, cleaned);
|
|
60
|
+
});
|
|
54
61
|
}
|
|
55
62
|
async check(settingsPath) {
|
|
56
63
|
const settings = await readSettings(settingsPath);
|
|
@@ -23,5 +23,13 @@ export declare function getProfile(name: string): HarnessProfile;
|
|
|
23
23
|
* list = safe to install" is preserved.
|
|
24
24
|
*/
|
|
25
25
|
export declare function detectHarnesses(): HarnessProfile[];
|
|
26
|
+
/**
|
|
27
|
+
* Experimental profiles whose home dir is present — the set detectHarnesses
|
|
28
|
+
* deliberately excludes. Exposed so the CLI can SAY it excluded them
|
|
29
|
+
* (silent exclusion reads as a detection bug to a user who can see the
|
|
30
|
+
* harness installed) while keeping the "detected = safe to install"
|
|
31
|
+
* promise intact.
|
|
32
|
+
*/
|
|
33
|
+
export declare function detectExcludedExperimental(): HarnessProfile[];
|
|
26
34
|
/** List all available harness names (not aliases). */
|
|
27
35
|
export declare function listHarnesses(): string[];
|
package/dist/harnesses/index.js
CHANGED
|
@@ -53,6 +53,16 @@ export function getProfile(name) {
|
|
|
53
53
|
export function detectHarnesses() {
|
|
54
54
|
return ALL_PROFILES.filter((p) => p.status === "stable" && existsSync(p.paths.home));
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Experimental profiles whose home dir is present — the set detectHarnesses
|
|
58
|
+
* deliberately excludes. Exposed so the CLI can SAY it excluded them
|
|
59
|
+
* (silent exclusion reads as a detection bug to a user who can see the
|
|
60
|
+
* harness installed) while keeping the "detected = safe to install"
|
|
61
|
+
* promise intact.
|
|
62
|
+
*/
|
|
63
|
+
export function detectExcludedExperimental() {
|
|
64
|
+
return ALL_PROFILES.filter((p) => p.status !== "stable" && existsSync(p.paths.home));
|
|
65
|
+
}
|
|
56
66
|
/** List all available harness names (not aliases). */
|
|
57
67
|
export function listHarnesses() {
|
|
58
68
|
return ALL_PROFILES.map((p) => p.name);
|
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
import { readFile } from "node:fs/promises";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { join, isAbsolute } from "node:path";
|
|
16
|
-
import { parse as parseJsonc } from "jsonc-parser";
|
|
16
|
+
import { parse as parseJsonc, } from "jsonc-parser";
|
|
17
17
|
import { ULUOPS_SERVERS, ConfigParseError } from "./types.js";
|
|
18
18
|
import { atomicWrite } from "../lib/atomic-write.js";
|
|
19
|
+
import { isEnoent } from "../lib/file-ops.js";
|
|
19
20
|
import { OPS_MCP_SPEC, REGISTRY_MCP_SPEC } from "../lib/mcp-packages.js";
|
|
20
21
|
class OpenCodeMcpConfig {
|
|
21
22
|
/** Maps requested path → actual resolved path (for .jsonc fallback). */
|
|
@@ -30,20 +31,37 @@ class OpenCodeMcpConfig {
|
|
|
30
31
|
this.resolvedPaths.set(path, p);
|
|
31
32
|
break;
|
|
32
33
|
}
|
|
33
|
-
catch {
|
|
34
|
-
|
|
34
|
+
catch (err) {
|
|
35
|
+
if (isEnoent(err))
|
|
36
|
+
continue; // Try next candidate
|
|
37
|
+
// Unreadable-but-PRESENT must never fall through to "no file →
|
|
38
|
+
// fresh config" — the write would replace the file we couldn't read.
|
|
39
|
+
throw new Error(`Could not read OpenCode config at ${p} (${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.`);
|
|
35
40
|
}
|
|
36
41
|
}
|
|
37
42
|
if (raw === null) {
|
|
38
43
|
this.resolvedPaths.set(path, path);
|
|
39
44
|
return {};
|
|
40
45
|
}
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
// jsonc-parser's parse() is error-RECOVERING, never throwing: on a
|
|
47
|
+
// syntax error it silently returns whatever it salvaged, and everything
|
|
48
|
+
// after the error point would be dropped, merged, and written back over
|
|
49
|
+
// the user's file. The errors out-param is the only honest signal.
|
|
50
|
+
const parseErrors = [];
|
|
51
|
+
const parsed = parseJsonc(raw, parseErrors, {
|
|
52
|
+
allowTrailingComma: true,
|
|
53
|
+
});
|
|
54
|
+
if (parseErrors.length > 0) {
|
|
55
|
+
const first = parseErrors[0];
|
|
56
|
+
throw new ConfigParseError(path, new Error(`invalid JSONC (error code ${first.error} at offset ${first.offset}) — fix or remove the file and re-run; nothing was modified`));
|
|
43
57
|
}
|
|
44
|
-
|
|
45
|
-
|
|
58
|
+
// Same top-level gate as config-merger/settings-merger: valid JSONC that
|
|
59
|
+
// isn't an object cannot be merged into — writing it back mangled with
|
|
60
|
+
// no error is worse than refusing.
|
|
61
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
62
|
+
throw new ConfigParseError(path, new Error("expected a JSON object at the top level"));
|
|
46
63
|
}
|
|
64
|
+
return parsed;
|
|
47
65
|
}
|
|
48
66
|
merge(config, apiKey) {
|
|
49
67
|
const raw = config["mcp"];
|
|
@@ -38,12 +38,25 @@ async function scanCommandDir(dir) {
|
|
|
38
38
|
try {
|
|
39
39
|
files = await readdir(dir);
|
|
40
40
|
}
|
|
41
|
-
catch {
|
|
41
|
+
catch (err) {
|
|
42
|
+
if (err?.code !== "ENOENT") {
|
|
43
|
+
// Unreadable bundled dir is a broken build, not "ships no commands".
|
|
44
|
+
console.warn(` ⚠ Could not read bundled commands dir ${dir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
45
|
+
}
|
|
42
46
|
return [];
|
|
43
47
|
}
|
|
44
48
|
const entries = [];
|
|
45
49
|
for (const file of files.filter((f) => f.endsWith(".md"))) {
|
|
46
|
-
|
|
50
|
+
let content;
|
|
51
|
+
try {
|
|
52
|
+
content = await readFile(join(dir, file), "utf-8");
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
// Display/catalog path: an unreadable bundled file (truncated npx
|
|
56
|
+
// cache) must not throw out of the summary — name it and move on.
|
|
57
|
+
console.warn(` ⚠ Could not read bundled ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
47
60
|
const fm = parseFrontmatter(content);
|
|
48
61
|
if (fm["name"]) {
|
|
49
62
|
entries.push({
|
|
@@ -8,4 +8,10 @@ export interface AtomicWriteOptions {
|
|
|
8
8
|
/** File mode (permissions). Defaults to Node's default (0o666 before umask). */
|
|
9
9
|
mode?: number;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Write `content` to `path` atomically: write to a random-suffixed temp file
|
|
13
|
+
* in the same directory, then rename over the target. Readers see either the
|
|
14
|
+
* old file or the new one, never a partial write. The temp file is unlinked
|
|
15
|
+
* on failure.
|
|
16
|
+
*/
|
|
11
17
|
export declare function atomicWrite(path: string, content: string, options?: AtomicWriteOptions): Promise<void>;
|
package/dist/lib/atomic-write.js
CHANGED
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { writeFile, rename, unlink, chmod } from "node:fs/promises";
|
|
8
8
|
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { recordWrite } from "./write-coordinator.js";
|
|
10
|
+
/**
|
|
11
|
+
* Write `content` to `path` atomically: write to a random-suffixed temp file
|
|
12
|
+
* in the same directory, then rename over the target. Readers see either the
|
|
13
|
+
* old file or the new one, never a partial write. The temp file is unlinked
|
|
14
|
+
* on failure.
|
|
15
|
+
*/
|
|
9
16
|
export async function atomicWrite(path, content, options) {
|
|
10
17
|
// Random suffix + 'wx' flag (O_CREAT|O_EXCL) prevents symlink races:
|
|
11
18
|
// an attacker cannot pre-position a symlink at an unpredictable path,
|
|
@@ -22,6 +29,9 @@ export async function atomicWrite(path, content, options) {
|
|
|
22
29
|
await chmod(tmp, options.mode);
|
|
23
30
|
}
|
|
24
31
|
await rename(tmp, path);
|
|
32
|
+
// Attest the write for the coordinator's restore guard — only content
|
|
33
|
+
// this process provably wrote may be rolled back over.
|
|
34
|
+
recordWrite(path, content);
|
|
25
35
|
}
|
|
26
36
|
catch (err) {
|
|
27
37
|
// Clean up temp file on failure
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { atomicWrite } from "./atomic-write.js";
|
|
3
|
-
import {
|
|
3
|
+
import { stripDangerousKeys } from "./json-guards.js";
|
|
4
|
+
import { isEnoent } from "./file-ops.js";
|
|
5
|
+
import { MCP_PROBE_TARGETS, OPS_MCP_SPEC, REGISTRY_MCP_SPEC, } from "./mcp-packages.js";
|
|
4
6
|
/**
|
|
5
7
|
* In-process memoization for the npm availability probe.
|
|
6
8
|
*
|
|
@@ -36,18 +38,21 @@ export function checkMcpPackageAvailability() {
|
|
|
36
38
|
async function probeAvailability() {
|
|
37
39
|
const available = [];
|
|
38
40
|
const missing = [];
|
|
39
|
-
|
|
41
|
+
// Probe the PINNED version endpoints — the harness runs `npx -y <spec>`,
|
|
42
|
+
// so pin resolvability is the question, not name existence.
|
|
43
|
+
const results = await Promise.allSettled(MCP_PROBE_TARGETS.map(({ pkg, version }) => fetch(`https://registry.npmjs.org/${pkg}/${version}`, {
|
|
40
44
|
method: "HEAD",
|
|
41
45
|
signal: AbortSignal.timeout(5000),
|
|
42
46
|
redirect: "follow",
|
|
43
47
|
}).then((res) => ({ pkg, ok: res.ok }))));
|
|
44
|
-
// Per-index correspondence: results[i] corresponds to
|
|
48
|
+
// Per-index correspondence: results[i] corresponds to MCP_PROBE_TARGETS[i] by
|
|
45
49
|
// Promise.allSettled's stable ordering. The previous `?? "unknown"` fallback
|
|
46
50
|
// could emit a literal "unknown" string into `missing`, hiding the real
|
|
47
51
|
// failure reason (DNS error, timeout, 404) under an undiagnosable label.
|
|
48
52
|
for (let i = 0; i < results.length; i++) {
|
|
49
53
|
const result = results[i];
|
|
50
|
-
const
|
|
54
|
+
const target = MCP_PROBE_TARGETS[i];
|
|
55
|
+
const pkg = `${target.pkg}@${target.version}`;
|
|
51
56
|
if (result.status === "fulfilled") {
|
|
52
57
|
if (result.value.ok) {
|
|
53
58
|
available.push(pkg);
|
|
@@ -76,15 +81,29 @@ export async function readConfig(path) {
|
|
|
76
81
|
try {
|
|
77
82
|
raw = await readFile(path, "utf-8");
|
|
78
83
|
}
|
|
79
|
-
catch {
|
|
80
|
-
|
|
84
|
+
catch (err) {
|
|
85
|
+
if (isEnoent(err))
|
|
86
|
+
return {}; // File doesn't exist — fresh config
|
|
87
|
+
// Unreadable-but-PRESENT (EACCES/EISDIR/EIO) must never read as fresh:
|
|
88
|
+
// the {} would be merged and renamed over the file we couldn't read.
|
|
89
|
+
throw new Error(`Could not read config 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.`);
|
|
81
90
|
}
|
|
91
|
+
let parsed;
|
|
82
92
|
try {
|
|
83
|
-
|
|
93
|
+
parsed = JSON.parse(raw);
|
|
84
94
|
}
|
|
85
95
|
catch {
|
|
86
|
-
|
|
96
|
+
// The read happens BEFORE any write — say so, or a pre-existing broken
|
|
97
|
+
// file reads as UluOps-caused corruption.
|
|
98
|
+
throw new Error(`Failed to parse config at ${path} — file contains invalid JSON. ` +
|
|
99
|
+
`(Detected before any UluOps change; nothing was modified. Fix or remove the file and re-run.)`);
|
|
100
|
+
}
|
|
101
|
+
// Same rationale as the JSON throw: valid JSON that isn't an object cannot
|
|
102
|
+
// be merged into — spreading it would corrupt the file we then write back.
|
|
103
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
104
|
+
throw new Error(`Failed to parse config at ${path} — expected a JSON object at the top level`);
|
|
87
105
|
}
|
|
106
|
+
return stripDangerousKeys(parsed);
|
|
88
107
|
}
|
|
89
108
|
/**
|
|
90
109
|
* Merge UluOps MCP server entries into a config, preserving all other keys.
|
package/dist/lib/display.d.ts
CHANGED
|
@@ -4,6 +4,12 @@ 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
|
+
/** Blank spacer line — keeps command modules free of raw console calls. */
|
|
8
|
+
export declare function blank(): void;
|
|
9
|
+
/** Per-harness section header in the multi-harness install loop. */
|
|
10
|
+
export declare function printHarnessHeader(displayName: string): void;
|
|
11
|
+
/** The branded setup banner: logo, tagline, version + target line. */
|
|
12
|
+
export declare function printSetupBanner(version: string, targetSummary: string): void;
|
|
7
13
|
/**
|
|
8
14
|
* Render the final post-run summary.
|
|
9
15
|
*
|
|
@@ -26,5 +32,7 @@ export declare function printSetupSummary(input: {
|
|
|
26
32
|
results: PerHarnessResult[];
|
|
27
33
|
apiKey: string;
|
|
28
34
|
}): Promise<void>;
|
|
35
|
+
/** Mask an API key for display: all but the last 4 chars become `*` (minimum 4 stars). */
|
|
29
36
|
export declare function maskKey(key: string): string;
|
|
37
|
+
/** Render the `--list` output: workflows then agents from the asset catalog, descriptions truncated to fit. */
|
|
30
38
|
export declare function printAgentList(): Promise<void>;
|
package/dist/lib/display.js
CHANGED
|
@@ -5,6 +5,23 @@ 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
|
+
/** Blank spacer line — keeps command modules free of raw console calls. */
|
|
9
|
+
export function blank() {
|
|
10
|
+
console.log();
|
|
11
|
+
}
|
|
12
|
+
/** Per-harness section header in the multi-harness install loop. */
|
|
13
|
+
export function printHarnessHeader(displayName) {
|
|
14
|
+
console.log(chalk.dim(`▸ ${displayName}`));
|
|
15
|
+
}
|
|
16
|
+
/** The branded setup banner: logo, tagline, version + target line. */
|
|
17
|
+
export function printSetupBanner(version, targetSummary) {
|
|
18
|
+
console.log();
|
|
19
|
+
console.log(` ${chalk.dim("⟨u⟩")} ${chalk.cyan.bold("ulu")}${chalk.bold("·ops")}`);
|
|
20
|
+
console.log(` ${chalk.dim("operating intelligence as infrastructure")}`);
|
|
21
|
+
console.log();
|
|
22
|
+
console.log(` Setup v${version} — ${chalk.bold(targetSummary)}`);
|
|
23
|
+
console.log();
|
|
24
|
+
}
|
|
8
25
|
const DIVIDER = ` ${chalk.dim("━".repeat(46))}`;
|
|
9
26
|
/**
|
|
10
27
|
* Render the final post-run summary.
|
|
@@ -74,7 +91,14 @@ export async function printSetupSummary(input) {
|
|
|
74
91
|
if (total === 1 &&
|
|
75
92
|
results[0].status === "ok" &&
|
|
76
93
|
results[0].profile.name === "claude-code") {
|
|
77
|
-
|
|
94
|
+
try {
|
|
95
|
+
await printAgentList();
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
// The catalog is decorative; the export line and restart instruction
|
|
99
|
+
// below are the actionable tail — never lose them to a listing error.
|
|
100
|
+
warn(`Could not list bundled agents: ${err instanceof Error ? err.message : String(err)}`);
|
|
101
|
+
}
|
|
78
102
|
}
|
|
79
103
|
// API-key reminder — once per run regardless of harness count.
|
|
80
104
|
const masked = maskKey(apiKey);
|
|
@@ -135,12 +159,14 @@ function renderCounts(r) {
|
|
|
135
159
|
parts.push("metrics");
|
|
136
160
|
return parts.length > 0 ? `(${parts.join(" · ")})` : "";
|
|
137
161
|
}
|
|
162
|
+
/** Mask an API key for display: all but the last 4 chars become `*` (minimum 4 stars). */
|
|
138
163
|
export function maskKey(key) {
|
|
139
164
|
if (!key || key.length <= 4)
|
|
140
165
|
return "****";
|
|
141
166
|
const last4 = key.slice(-4);
|
|
142
167
|
return `${"*".repeat(Math.max(4, key.length - 4))}${last4}`;
|
|
143
168
|
}
|
|
169
|
+
/** Render the `--list` output: workflows then agents from the asset catalog, descriptions truncated to fit. */
|
|
144
170
|
export async function printAgentList() {
|
|
145
171
|
const workflows = await getWorkflowCommands();
|
|
146
172
|
const agents = await getAgentCommands();
|
package/dist/lib/file-ops.d.ts
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True when `err` is fs ENOENT — the ONLY read error that means "absent".
|
|
3
|
+
* Every read-then-overwrite path must use this before treating a file as
|
|
4
|
+
* fresh: EACCES/EISDIR/EIO also land in a catch, and inferring "absent" from
|
|
5
|
+
* them turns an unreadable-but-present config into a fresh-file overwrite
|
|
6
|
+
* that destroys the user's content. (This class was fixed once at
|
|
7
|
+
* steps/mcp.ts's gitignore path and recurred at five other sites — hence a
|
|
8
|
+
* shared predicate rather than five inline checks.)
|
|
9
|
+
*/
|
|
10
|
+
export declare function isEnoent(err: unknown): boolean;
|
|
1
11
|
/**
|
|
2
12
|
* Copy a file if its content has changed (hash comparison). Returns "copied" or "skipped".
|
|
3
13
|
*/
|
|
@@ -18,10 +28,9 @@ export declare function unlinkFiles(dir: string, files: string[]): Promise<numbe
|
|
|
18
28
|
* (whether or not the unlink actually ran in dry-run mode).
|
|
19
29
|
*
|
|
20
30
|
* Extracted from three near-identical blocks in syncAssets, installAgents,
|
|
21
|
-
* and installCommands.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* can usefully perform mid-loop.
|
|
31
|
+
* and installCommands. ENOENT unlink failures are tolerated silently (the
|
|
32
|
+
* dominant, idempotent case); any other failure is warned by name and
|
|
33
|
+
* excluded from the removed count.
|
|
25
34
|
*/
|
|
26
35
|
export declare function removeStaleFiles(destDir: string, oldManifestFiles: string[] | undefined, currentFiles: string[], dryRun: boolean): Promise<number>;
|
|
27
36
|
/**
|