@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.
- 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 +7 -4
- package/dist/lib/agent-transform.d.ts +0 -12
- package/dist/lib/agent-transform.js +0 -129
package/dist/lib/version.js
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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",
|
package/dist/steps/agents.js
CHANGED
|
@@ -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
|
-
|
|
53
|
-
|
|
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) {
|
package/dist/steps/auth.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
package/dist/steps/commands.js
CHANGED
|
@@ -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,
|
|
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:
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
72
|
+
warn(`Could not read ${gitignorePath} (${err.message}). Skipping .gitignore update.`);
|
|
64
73
|
return;
|
|
65
74
|
}
|
|
66
75
|
if (content.includes(entry))
|