@uluops/setup 0.7.0 → 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.
- package/README.md +91 -9
- package/assets/codex/skills/uluops-operator/SKILL.md +159 -0
- package/dist/cli/select-harnesses.d.ts +91 -0
- package/dist/cli/select-harnesses.js +108 -0
- package/dist/cli.js +78 -37
- package/dist/commands/errors.d.ts +24 -0
- package/dist/commands/errors.js +28 -0
- package/dist/commands/helpers.d.ts +7 -0
- package/dist/commands/helpers.js +80 -3
- package/dist/commands/per-harness.d.ts +64 -0
- package/dist/commands/per-harness.js +37 -0
- package/dist/commands/setup.d.ts +5 -3
- package/dist/commands/setup.js +174 -48
- package/dist/commands/uninstall-filter.d.ts +36 -0
- package/dist/commands/uninstall-filter.js +69 -0
- package/dist/commands/uninstall.d.ts +12 -2
- package/dist/commands/uninstall.js +121 -45
- package/dist/harnesses/codex.d.ts +5 -10
- package/dist/harnesses/codex.js +89 -21
- package/dist/harnesses/index.js +6 -1
- package/dist/harnesses/opencode.d.ts +8 -0
- package/dist/harnesses/opencode.js +24 -1
- package/dist/harnesses/types.d.ts +2 -0
- package/dist/harnesses/types.js +5 -1
- package/dist/lib/atomic-write.js +10 -2
- package/dist/lib/config-merger.d.ts +6 -3
- package/dist/lib/config-merger.js +50 -7
- package/dist/lib/display.d.ts +21 -5
- package/dist/lib/display.js +118 -13
- package/dist/lib/file-ops.d.ts +13 -5
- package/dist/lib/file-ops.js +34 -34
- package/dist/lib/install-lock.js +11 -1
- package/dist/lib/json-guards.d.ts +22 -0
- package/dist/lib/json-guards.js +33 -0
- package/dist/lib/manifest.d.ts +20 -0
- package/dist/lib/manifest.js +61 -12
- package/dist/lib/paths.d.ts +0 -17
- package/dist/lib/paths.js +0 -19
- package/dist/lib/settings-merger.js +3 -1
- package/dist/steps/agent-metrics-cli.d.ts +9 -1
- package/dist/steps/agent-metrics-cli.js +66 -20
- package/dist/steps/agents.d.ts +11 -0
- package/dist/steps/agents.js +30 -25
- package/dist/steps/auth.d.ts +13 -0
- package/dist/steps/auth.js +61 -5
- package/dist/steps/cli.d.ts +6 -0
- package/dist/steps/cli.js +29 -10
- package/dist/steps/commands.d.ts +10 -0
- package/dist/steps/commands.js +31 -30
- package/dist/steps/detect.js +15 -1
- package/dist/steps/mcp.js +1 -8
- package/dist/steps/metrics.js +10 -3
- package/dist/steps/shell.js +3 -13
- package/dist/steps/signup.js +14 -1
- package/dist/steps/skills.d.ts +14 -0
- package/dist/steps/skills.js +95 -0
- package/dist/steps/verify.js +195 -91
- package/package.json +3 -2
package/dist/lib/install-lock.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { mkdir, readFile, rm, unlink, writeFile, } from "node:fs/promises";
|
|
17
17
|
import { rmSync } from "node:fs";
|
|
18
18
|
import { hostname } from "node:os";
|
|
19
|
-
import { join } from "node:path";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
20
|
import { getInstallLockDir } from "./paths.js";
|
|
21
21
|
const META_FILENAME = "meta.json";
|
|
22
22
|
const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000; // 30 min
|
|
@@ -41,6 +41,16 @@ export async function acquireInstallLock(opts = {}) {
|
|
|
41
41
|
const maxAgeMs = opts.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
|
42
42
|
const waitMs = opts.waitMs ?? DEFAULT_WAIT_MS;
|
|
43
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 });
|
|
44
54
|
// First try (and one retry after stale-lock reclaim).
|
|
45
55
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
46
56
|
try {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrow guards for JSON responses from the UluOps API.
|
|
3
|
+
*
|
|
4
|
+
* Centralised so the same envelope shape (`{ data: { ... } }`) is decoded
|
|
5
|
+
* consistently across call sites (`steps/auth.ts`, `steps/verify.ts`,
|
|
6
|
+
* `steps/signup.ts`). Each guard returns `null` on absent/wrong-typed fields
|
|
7
|
+
* and throws a plain `Error` only when the top-level body shape is so wrong
|
|
8
|
+
* that proceeding would silently coerce garbage into a typed result.
|
|
9
|
+
*
|
|
10
|
+
* Why plain `Error`, not `TypeError`: call sites translate `TypeError` into
|
|
11
|
+
* "Can't reach api.uluops.ai" (fetch's network-failure shape). A `TypeError`
|
|
12
|
+
* here would be misclassified as a network outage.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Narrow `{ data: { email: string } }` from an unknown response body.
|
|
16
|
+
* Returns the email string when present and well-typed, null otherwise.
|
|
17
|
+
* Throws when `body` is not an object at all — that indicates the endpoint
|
|
18
|
+
* is no longer the one we expect (HTML error page, redirect to a captive
|
|
19
|
+
* portal, schema breakage) and should surface to the user rather than be
|
|
20
|
+
* papered over as "logged in with no email."
|
|
21
|
+
*/
|
|
22
|
+
export declare function extractEmail(body: unknown): string | null;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrow guards for JSON responses from the UluOps API.
|
|
3
|
+
*
|
|
4
|
+
* Centralised so the same envelope shape (`{ data: { ... } }`) is decoded
|
|
5
|
+
* consistently across call sites (`steps/auth.ts`, `steps/verify.ts`,
|
|
6
|
+
* `steps/signup.ts`). Each guard returns `null` on absent/wrong-typed fields
|
|
7
|
+
* and throws a plain `Error` only when the top-level body shape is so wrong
|
|
8
|
+
* that proceeding would silently coerce garbage into a typed result.
|
|
9
|
+
*
|
|
10
|
+
* Why plain `Error`, not `TypeError`: call sites translate `TypeError` into
|
|
11
|
+
* "Can't reach api.uluops.ai" (fetch's network-failure shape). A `TypeError`
|
|
12
|
+
* here would be misclassified as a network outage.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Narrow `{ data: { email: string } }` from an unknown response body.
|
|
16
|
+
* Returns the email string when present and well-typed, null otherwise.
|
|
17
|
+
* Throws when `body` is not an object at all — that indicates the endpoint
|
|
18
|
+
* is no longer the one we expect (HTML error page, redirect to a captive
|
|
19
|
+
* portal, schema breakage) and should surface to the user rather than be
|
|
20
|
+
* papered over as "logged in with no email."
|
|
21
|
+
*/
|
|
22
|
+
export function extractEmail(body) {
|
|
23
|
+
if (typeof body !== "object" || body === null) {
|
|
24
|
+
throw new Error("API returned an unexpected response shape (not an object). The endpoint may have changed — try --skip-validation to continue offline.");
|
|
25
|
+
}
|
|
26
|
+
const data = body.data;
|
|
27
|
+
if (data === undefined || data === null)
|
|
28
|
+
return null;
|
|
29
|
+
if (typeof data !== "object")
|
|
30
|
+
return null;
|
|
31
|
+
const email = data.email;
|
|
32
|
+
return typeof email === "string" ? email : null;
|
|
33
|
+
}
|
package/dist/lib/manifest.d.ts
CHANGED
|
@@ -11,6 +11,14 @@
|
|
|
11
11
|
* assumption is documented in the schema, not the comments.
|
|
12
12
|
*/
|
|
13
13
|
export type HarnessInstanceKey = string;
|
|
14
|
+
/**
|
|
15
|
+
* Names of per-harness pipeline steps that can throw before producing a
|
|
16
|
+
* complete result. Recorded in `HarnessManifest.partial` so verify can warn
|
|
17
|
+
* and re-runs can re-prompt conflicts (spec §7.6.3). `configureMcpStep` is
|
|
18
|
+
* NOT in this set — an MCP throw produces no manifest entry at all because
|
|
19
|
+
* the entry depends on the MCP-derived `mcpConfigPath`.
|
|
20
|
+
*/
|
|
21
|
+
export type PartialStep = "agents" | "commands" | "skills" | "metrics";
|
|
14
22
|
/** Per-harness installation state. */
|
|
15
23
|
export interface HarnessManifest {
|
|
16
24
|
installedAt: string;
|
|
@@ -21,6 +29,7 @@ export interface HarnessManifest {
|
|
|
21
29
|
defsPath: string;
|
|
22
30
|
agents: string[];
|
|
23
31
|
commands: string[];
|
|
32
|
+
skills?: string[];
|
|
24
33
|
hooksInstalled: boolean;
|
|
25
34
|
/**
|
|
26
35
|
* Version of @uluops/agent-metrics whose dist/ was copied into the harness tree.
|
|
@@ -29,6 +38,17 @@ export interface HarnessManifest {
|
|
|
29
38
|
* the shared version ledger across the setup↔agent-metrics seam.
|
|
30
39
|
*/
|
|
31
40
|
hooksInstalledVersion?: string | null;
|
|
41
|
+
/**
|
|
42
|
+
* When non-null, names the per-harness pipeline step that threw before
|
|
43
|
+
* producing a complete result. Earlier steps' file lists are accurate;
|
|
44
|
+
* later steps were never attempted. Verify surfaces this as a WARNING.
|
|
45
|
+
* Re-run treats a partial entry as "checkConflicts must run again" (the
|
|
46
|
+
* user never confirmed it on the failing run — spec §7.6.5).
|
|
47
|
+
*
|
|
48
|
+
* Absent (undefined) on manifests written by pre-multi-target versions —
|
|
49
|
+
* those harnesses are assumed fully installed.
|
|
50
|
+
*/
|
|
51
|
+
partial?: PartialStep | null;
|
|
32
52
|
}
|
|
33
53
|
/** Top-level manifest with per-harness entries. */
|
|
34
54
|
export interface Manifest {
|
package/dist/lib/manifest.js
CHANGED
|
@@ -13,8 +13,15 @@ function isNewManifest(obj) {
|
|
|
13
13
|
typeof m["harnesses"] !== "object" ||
|
|
14
14
|
m["harnesses"] === null)
|
|
15
15
|
return false;
|
|
16
|
-
//
|
|
16
|
+
// An on-disk manifest must reference at least one harness installation.
|
|
17
|
+
// The empty-harnesses case used to pass vacuously (the for-loop iterated
|
|
18
|
+
// zero times), letting a truncated `{...harnesses:{}}` file masquerade as
|
|
19
|
+
// valid — and a subsequent uninstall would then iterate zero harnesses,
|
|
20
|
+
// delete the manifest, and report success while leaving every MCP config,
|
|
21
|
+
// agent, hook, and shell export in place.
|
|
17
22
|
const harnesses = m["harnesses"];
|
|
23
|
+
if (Object.keys(harnesses).length === 0)
|
|
24
|
+
return false;
|
|
18
25
|
for (const h of Object.values(harnesses)) {
|
|
19
26
|
if (typeof h !== "object" || h === null)
|
|
20
27
|
return false;
|
|
@@ -23,6 +30,14 @@ function isNewManifest(obj) {
|
|
|
23
30
|
return false;
|
|
24
31
|
if (!Array.isArray(hm["agents"]) || !Array.isArray(hm["commands"]))
|
|
25
32
|
return false;
|
|
33
|
+
if ("skills" in hm && !Array.isArray(hm["skills"]))
|
|
34
|
+
return false;
|
|
35
|
+
if ("partial" in hm) {
|
|
36
|
+
const p = hm["partial"];
|
|
37
|
+
if (p !== null && p !== "agents" && p !== "commands" && p !== "skills" && p !== "metrics") {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
26
41
|
}
|
|
27
42
|
return true;
|
|
28
43
|
}
|
|
@@ -83,20 +98,54 @@ export async function validateManifest(manifest) {
|
|
|
83
98
|
warnings.push(`[${harnessName}] Command files missing from disk: ${missing.join(", ")}`);
|
|
84
99
|
}
|
|
85
100
|
}
|
|
101
|
+
if ((hm.skills?.length ?? 0) > 0 && defsExists) {
|
|
102
|
+
const missing = await findMissingFiles(hm.defsPath, "skills", hm.skills ?? []);
|
|
103
|
+
if (missing.length > 0) {
|
|
104
|
+
warnings.push(`[${harnessName}] Skill files missing from disk: ${missing.join(", ")}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
86
107
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
108
|
+
// Hash verification reads whichever manifest file actually exists. The
|
|
109
|
+
// previous implementation hardcoded the new path, which produced a false
|
|
110
|
+
// "Cannot read manifest file to verify content hash" warning on every
|
|
111
|
+
// uninstall after `loadManifest` migrated a legacy manifest in memory
|
|
112
|
+
// without writing it back to the new location. Silently skip the hash
|
|
113
|
+
// check when no manifest is on disk in either location (in-memory-only
|
|
114
|
+
// manifest, or both locations missing).
|
|
115
|
+
let raw = null;
|
|
116
|
+
for (const candidate of [getManifestPath(), getLegacyManifestPath()]) {
|
|
117
|
+
try {
|
|
118
|
+
raw = await readFile(candidate, "utf-8");
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Try next candidate
|
|
96
123
|
}
|
|
97
124
|
}
|
|
98
|
-
|
|
99
|
-
|
|
125
|
+
if (raw !== null) {
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(raw);
|
|
128
|
+
const { contentHash: storedHash, ...withoutHash } = parsed;
|
|
129
|
+
const canonical = JSON.stringify(withoutHash, null, 2) + "\n";
|
|
130
|
+
const currentHash = fileHash(canonical);
|
|
131
|
+
// Typeof check rather than truthiness — a malformed manifest with
|
|
132
|
+
// `contentHash: 0`, `contentHash: false`, `contentHash: ""`, or no
|
|
133
|
+
// contentHash key at all would all evaluate `storedHash && ...` to
|
|
134
|
+
// false and silently skip tamper detection. Only a missing-key
|
|
135
|
+
// (undefined) manifest is legitimately exempt (legacy / pre-hash);
|
|
136
|
+
// a present-but-not-a-string value is suspect and should warn.
|
|
137
|
+
if (typeof storedHash === "string") {
|
|
138
|
+
if (storedHash !== currentHash) {
|
|
139
|
+
warnings.push("Manifest file has been modified since installation — content hash mismatch");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else if (storedHash !== undefined) {
|
|
143
|
+
warnings.push(`Manifest contentHash has wrong type (${typeof storedHash}); tamper detection skipped`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
warnings.push("Manifest file is unparseable JSON");
|
|
148
|
+
}
|
|
100
149
|
}
|
|
101
150
|
return { valid: errors.length === 0, errors, warnings };
|
|
102
151
|
}
|
package/dist/lib/paths.d.ts
CHANGED
|
@@ -19,23 +19,6 @@ export declare function getManifestPath(): string;
|
|
|
19
19
|
export declare function getInstallLockDir(): string;
|
|
20
20
|
/** Return the legacy manifest path for migration. */
|
|
21
21
|
export declare function getLegacyManifestPath(): string;
|
|
22
|
-
/**
|
|
23
|
-
* Return the backup directory for a harness's **config** files.
|
|
24
|
-
*
|
|
25
|
-
* Scope is deliberately narrow: this directory holds copies of mutable
|
|
26
|
-
* user-owned config surfaces (the MCP config file, the shell profile),
|
|
27
|
-
* NOT vendor-owned tool files in `~/.claude/tools/agent-metrics/`. Tool
|
|
28
|
-
* files are treated as disposable — they can be regenerated by re-running
|
|
29
|
-
* setup, and the source of truth lives in the npm-installed
|
|
30
|
-
* `@uluops/agent-metrics` package. Backing them up would require a
|
|
31
|
-
* different ritual (versioned snapshots tied to the manifest's
|
|
32
|
-
* hooksInstalledVersion) and is not provided here.
|
|
33
|
-
*
|
|
34
|
-
* Renaming this to `getConfigBackupDir` would be more honest but is a
|
|
35
|
-
* public surface change deferred until we have a tool-file backup to
|
|
36
|
-
* disambiguate against.
|
|
37
|
-
*/
|
|
38
|
-
export declare function getBackupDir(harnessName: string): string;
|
|
39
22
|
/** Detect the user's shell and return its name and profile path, or null if unsupported. */
|
|
40
23
|
export declare function getShellProfile(): {
|
|
41
24
|
shell: string;
|
package/dist/lib/paths.js
CHANGED
|
@@ -98,25 +98,6 @@ export function getInstallLockDir() {
|
|
|
98
98
|
export function getLegacyManifestPath() {
|
|
99
99
|
return join(getClaudeHome(), "uluops-manifest.json");
|
|
100
100
|
}
|
|
101
|
-
/**
|
|
102
|
-
* Return the backup directory for a harness's **config** files.
|
|
103
|
-
*
|
|
104
|
-
* Scope is deliberately narrow: this directory holds copies of mutable
|
|
105
|
-
* user-owned config surfaces (the MCP config file, the shell profile),
|
|
106
|
-
* NOT vendor-owned tool files in `~/.claude/tools/agent-metrics/`. Tool
|
|
107
|
-
* files are treated as disposable — they can be regenerated by re-running
|
|
108
|
-
* setup, and the source of truth lives in the npm-installed
|
|
109
|
-
* `@uluops/agent-metrics` package. Backing them up would require a
|
|
110
|
-
* different ritual (versioned snapshots tied to the manifest's
|
|
111
|
-
* hooksInstalledVersion) and is not provided here.
|
|
112
|
-
*
|
|
113
|
-
* Renaming this to `getConfigBackupDir` would be more honest but is a
|
|
114
|
-
* public surface change deferred until we have a tool-file backup to
|
|
115
|
-
* disambiguate against.
|
|
116
|
-
*/
|
|
117
|
-
export function getBackupDir(harnessName) {
|
|
118
|
-
return join(getUluopsDir(), "backups", harnessName);
|
|
119
|
-
}
|
|
120
101
|
/** Detect the user's shell and return its name and profile path, or null if unsupported. */
|
|
121
102
|
export function getShellProfile() {
|
|
122
103
|
const shell = process.env["SHELL"] ?? "";
|
|
@@ -82,7 +82,9 @@ export async function readSettings(path) {
|
|
|
82
82
|
* Write settings back to file with stable formatting.
|
|
83
83
|
*/
|
|
84
84
|
export async function writeSettings(path, settings) {
|
|
85
|
-
await atomicWrite(path, JSON.stringify(settings, null, 2) + "\n"
|
|
85
|
+
await atomicWrite(path, JSON.stringify(settings, null, 2) + "\n", {
|
|
86
|
+
mode: 0o600,
|
|
87
|
+
});
|
|
86
88
|
}
|
|
87
89
|
/**
|
|
88
90
|
* Merge the UluOps hook into settings, preserving all other
|
|
@@ -26,7 +26,15 @@ export interface AgentMetricsCliExecutor {
|
|
|
26
26
|
error?: string;
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Parse `npm ls -g --json` output and extract the installed version of
|
|
31
|
+
* AGENT_METRICS_PACKAGE, or null if absent or unparseable.
|
|
32
|
+
*
|
|
33
|
+
* Exported for direct unit testing — keeps the JSON shape contract explicit.
|
|
34
|
+
*/
|
|
35
|
+
export declare function parseGlobalAgentMetricsVersion(stdout: string | undefined): string | null;
|
|
36
|
+
export declare function detectGlobalAgentMetrics(): string | null;
|
|
37
|
+
/** Default executor — queries npm directly to avoid npx-transient-PATH false positives. */
|
|
30
38
|
export declare const defaultAgentMetricsExecutor: AgentMetricsCliExecutor;
|
|
31
39
|
export interface AgentMetricsCliInstallResult {
|
|
32
40
|
/** `agent-metrics` is on PATH after this step, regardless of how it got there. */
|
|
@@ -13,38 +13,84 @@ import { spawnSync } from "node:child_process";
|
|
|
13
13
|
*/
|
|
14
14
|
export const AGENT_METRICS_PACKAGE = "@uluops/agent-metrics";
|
|
15
15
|
export const AGENT_METRICS_BIN = "agent-metrics";
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Parse `npm ls -g --json` output and extract the installed version of
|
|
18
|
+
* AGENT_METRICS_PACKAGE, or null if absent or unparseable.
|
|
19
|
+
*
|
|
20
|
+
* Exported for direct unit testing — keeps the JSON shape contract explicit.
|
|
21
|
+
*/
|
|
22
|
+
export function parseGlobalAgentMetricsVersion(stdout) {
|
|
23
|
+
if (!stdout)
|
|
24
|
+
return null;
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(stdout);
|
|
27
|
+
const entry = parsed.dependencies?.[AGENT_METRICS_PACKAGE];
|
|
28
|
+
return entry?.version ?? null;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Detect whether `@uluops/agent-metrics` is installed in npm's GLOBAL prefix.
|
|
36
|
+
*
|
|
37
|
+
* IMPORTANT: this cannot be a simple `spawnSync("agent-metrics", ["--version"])`.
|
|
38
|
+
* `@uluops/agent-metrics` is a runtime dependency of `@uluops/setup` (used by
|
|
39
|
+
* `findMetricsSource` to resolve files to copy into the harness tree). When
|
|
40
|
+
* setup runs under `npx @uluops/setup`, npx prepends its transient cache
|
|
41
|
+
* `.bin/` to PATH for the spawned process — `agent-metrics` resolves there
|
|
42
|
+
* even when the user has nothing installed globally. The check then returns
|
|
43
|
+
* "already installed" and setup skips the global install, leaving the user
|
|
44
|
+
* with `command not found` after npx exits. This was the actual bug behavior
|
|
45
|
+
* observed in v0.7.0 on the first ship.
|
|
46
|
+
*
|
|
47
|
+
* We query npm directly via `npm ls -g --depth=0 --json` — answers the actual
|
|
48
|
+
* question ("is it in the user's global install") instead of a PATH-resolution
|
|
49
|
+
* proxy. `npm ls` exits non-zero when the queried package is missing but still
|
|
50
|
+
* emits valid JSON, so we rely on the JSON content rather than the exit code.
|
|
51
|
+
*/
|
|
52
|
+
/** Same bounds as the @uluops/cli installer — see src/steps/cli.ts rationale. */
|
|
53
|
+
const NPM_TIMEOUT_MS = 5 * 60_000;
|
|
54
|
+
const DETECT_TIMEOUT_MS = 30_000;
|
|
55
|
+
function summarizeNpmResult(r, op) {
|
|
56
|
+
if (r.status === 0)
|
|
57
|
+
return { ok: true };
|
|
58
|
+
if (r.signal === "SIGTERM" && r.status === null) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
error: `npm ${op} exceeded ${NPM_TIMEOUT_MS / 1000}s timeout and was terminated`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const stderr = (r.stderr ?? "").toString().trim();
|
|
65
|
+
const stdout = (r.stdout ?? "").toString().trim();
|
|
66
|
+
return { ok: false, error: stderr || stdout || `exit ${r.status}` };
|
|
67
|
+
}
|
|
68
|
+
export function detectGlobalAgentMetrics() {
|
|
69
|
+
const r = spawnSync("npm", ["ls", "-g", AGENT_METRICS_PACKAGE, "--depth=0", "--json"], {
|
|
70
|
+
encoding: "utf-8",
|
|
71
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
72
|
+
timeout: DETECT_TIMEOUT_MS,
|
|
73
|
+
});
|
|
74
|
+
return parseGlobalAgentMetricsVersion(r.stdout);
|
|
75
|
+
}
|
|
76
|
+
/** Default executor — queries npm directly to avoid npx-transient-PATH false positives. */
|
|
17
77
|
export const defaultAgentMetricsExecutor = {
|
|
18
|
-
detect:
|
|
19
|
-
const r = spawnSync(AGENT_METRICS_BIN, ["--version"], {
|
|
20
|
-
encoding: "utf-8",
|
|
21
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
22
|
-
});
|
|
23
|
-
if (r.status !== 0 || !r.stdout)
|
|
24
|
-
return null;
|
|
25
|
-
return r.stdout.trim() || null;
|
|
26
|
-
},
|
|
78
|
+
detect: detectGlobalAgentMetrics,
|
|
27
79
|
install: () => {
|
|
28
80
|
const r = spawnSync("npm", ["install", "-g", AGENT_METRICS_PACKAGE], {
|
|
29
81
|
encoding: "utf-8",
|
|
30
82
|
stdio: ["ignore", "pipe", "pipe"],
|
|
83
|
+
timeout: NPM_TIMEOUT_MS,
|
|
31
84
|
});
|
|
32
|
-
|
|
33
|
-
return { ok: true };
|
|
34
|
-
const stderr = (r.stderr ?? "").toString().trim();
|
|
35
|
-
const stdout = (r.stdout ?? "").toString().trim();
|
|
36
|
-
return { ok: false, error: stderr || stdout || `exit ${r.status}` };
|
|
85
|
+
return summarizeNpmResult(r, "install");
|
|
37
86
|
},
|
|
38
87
|
uninstall: () => {
|
|
39
88
|
const r = spawnSync("npm", ["uninstall", "-g", AGENT_METRICS_PACKAGE], {
|
|
40
89
|
encoding: "utf-8",
|
|
41
90
|
stdio: ["ignore", "pipe", "pipe"],
|
|
91
|
+
timeout: NPM_TIMEOUT_MS,
|
|
42
92
|
});
|
|
43
|
-
|
|
44
|
-
return { ok: true };
|
|
45
|
-
const stderr = (r.stderr ?? "").toString().trim();
|
|
46
|
-
const stdout = (r.stdout ?? "").toString().trim();
|
|
47
|
-
return { ok: false, error: stderr || stdout || `exit ${r.status}` };
|
|
93
|
+
return summarizeNpmResult(r, "uninstall");
|
|
48
94
|
},
|
|
49
95
|
};
|
|
50
96
|
/**
|
package/dist/steps/agents.d.ts
CHANGED
|
@@ -4,6 +4,17 @@ export interface AgentsResult {
|
|
|
4
4
|
skipped: number;
|
|
5
5
|
removed: number;
|
|
6
6
|
files: string[];
|
|
7
|
+
/**
|
|
8
|
+
* Per-file copy failures. The loop continues past errors so a single bad
|
|
9
|
+
* file (EACCES, ENOSPC, ENAMETOOLONG) cannot abort the whole install and
|
|
10
|
+
* leave the destination half-populated. The caller surfaces these via
|
|
11
|
+
* `warn()` and a re-run will pick up the failed files. Files in this list
|
|
12
|
+
* are NOT counted in `copied` (or `skipped`).
|
|
13
|
+
*/
|
|
14
|
+
failures: {
|
|
15
|
+
file: string;
|
|
16
|
+
error: string;
|
|
17
|
+
}[];
|
|
7
18
|
}
|
|
8
19
|
/** Copy pre-rendered agent definitions from harness-specific assets to the target directory. */
|
|
9
20
|
export declare function installAgents(profile: HarnessProfile, localDefs: boolean, dryRun: boolean, existingManifestAgents?: string[]): Promise<AgentsResult>;
|
package/dist/steps/agents.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { readdir, mkdir
|
|
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 } from "../lib/file-ops.js";
|
|
4
|
+
import { copyIfChanged, unlinkFiles, removeStaleFiles, } 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");
|
|
@@ -17,35 +17,40 @@ export async function installAgents(profile, localDefs, dryRun, existingManifest
|
|
|
17
17
|
files = (await readdir(srcDir)).filter((f) => f.endsWith(ext));
|
|
18
18
|
}
|
|
19
19
|
catch {
|
|
20
|
-
return { copied: 0, skipped: 0, removed: 0, files: [] };
|
|
20
|
+
return { copied: 0, skipped: 0, removed: 0, files: [], failures: [] };
|
|
21
21
|
}
|
|
22
22
|
let copied = 0;
|
|
23
23
|
let skipped = 0;
|
|
24
|
+
const installedFiles = [];
|
|
25
|
+
const failures = [];
|
|
24
26
|
for (const file of files) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
copied
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
27
|
+
try {
|
|
28
|
+
const result = await copyIfChanged(join(srcDir, file), join(destDir, file), dryRun);
|
|
29
|
+
if (result === "copied")
|
|
30
|
+
copied++;
|
|
31
|
+
else
|
|
32
|
+
skipped++;
|
|
33
|
+
// Only files that actually landed on disk enter installedFiles.
|
|
34
|
+
// A failed copy must not appear here — the manifest treats this list
|
|
35
|
+
// as authoritative, and uninstall would later attempt to unlink a
|
|
36
|
+
// file that was never written. Aligned with installCommands /
|
|
37
|
+
// installSkills behavior; required by the multi-target install
|
|
38
|
+
// partial-state contract (§7.6.2 / §7.6.6).
|
|
39
|
+
installedFiles.push(file);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
// Continue past per-file failures so one bad file (EACCES, ENOSPC,
|
|
43
|
+
// ENAMETOOLONG) does not abort the whole install and leave the
|
|
44
|
+
// destination half-populated. The caller `warn()`s these and a re-run
|
|
45
|
+
// will retry — setup is idempotent on success.
|
|
46
|
+
failures.push({
|
|
47
|
+
file,
|
|
48
|
+
error: err instanceof Error ? err.message : String(err),
|
|
49
|
+
});
|
|
46
50
|
}
|
|
47
51
|
}
|
|
48
|
-
|
|
52
|
+
const removed = await removeStaleFiles(destDir, existingManifestAgents, installedFiles, dryRun);
|
|
53
|
+
return { copied, skipped, removed, files: installedFiles, failures };
|
|
49
54
|
}
|
|
50
55
|
/** Remove previously installed agent files by name. */
|
|
51
56
|
export async function uninstallAgents(files, defsPath) {
|
package/dist/steps/auth.d.ts
CHANGED
|
@@ -8,6 +8,19 @@ export interface AuthResult {
|
|
|
8
8
|
* Existence-only — does not validate the file's shape or contents.
|
|
9
9
|
*/
|
|
10
10
|
export declare function hasCredentialsFile(): Promise<boolean>;
|
|
11
|
+
/**
|
|
12
|
+
* Persist an API key to ~/.uluops/credentials.json so @uluops/cli and the SDK
|
|
13
|
+
* can resolve it from disk without ULUOPS_API_KEY in the shell environment.
|
|
14
|
+
*
|
|
15
|
+
* Merges into the existing file when present: only the `default` profile is
|
|
16
|
+
* replaced; any other named profiles are preserved. Creates ~/.uluops/ with
|
|
17
|
+
* mode 0o700 if missing. Writes the file at 0o600 via atomicWrite.
|
|
18
|
+
*/
|
|
19
|
+
export declare function writeCredentialsFile(apiKey: string, opts?: {
|
|
20
|
+
email?: string | null;
|
|
21
|
+
source?: "signup" | "flag" | "prompt";
|
|
22
|
+
dryRun?: boolean;
|
|
23
|
+
}): Promise<void>;
|
|
11
24
|
/**
|
|
12
25
|
* Resolve API key from flags, env, credentials file, or interactive prompt.
|
|
13
26
|
*/
|
package/dist/steps/auth.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { readFile, access } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { readFile, access, mkdir } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
+
import { extractEmail } from "../lib/json-guards.js";
|
|
5
|
+
import { atomicWrite } from "../lib/atomic-write.js";
|
|
4
6
|
function getKeyPrefix() {
|
|
5
7
|
return process.env["ULUOPS_KEY_PREFIX"] ?? "ulr_";
|
|
6
8
|
}
|
|
@@ -21,6 +23,50 @@ export async function hasCredentialsFile() {
|
|
|
21
23
|
return false;
|
|
22
24
|
}
|
|
23
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Persist an API key to ~/.uluops/credentials.json so @uluops/cli and the SDK
|
|
28
|
+
* can resolve it from disk without ULUOPS_API_KEY in the shell environment.
|
|
29
|
+
*
|
|
30
|
+
* Merges into the existing file when present: only the `default` profile is
|
|
31
|
+
* replaced; any other named profiles are preserved. Creates ~/.uluops/ with
|
|
32
|
+
* mode 0o700 if missing. Writes the file at 0o600 via atomicWrite.
|
|
33
|
+
*/
|
|
34
|
+
export async function writeCredentialsFile(apiKey, opts) {
|
|
35
|
+
if (opts?.dryRun)
|
|
36
|
+
return;
|
|
37
|
+
const credsPath = credentialsPath();
|
|
38
|
+
await mkdir(dirname(credsPath), { recursive: true, mode: 0o700 });
|
|
39
|
+
// Merge: preserve any non-default profiles already on disk.
|
|
40
|
+
let existing = {};
|
|
41
|
+
try {
|
|
42
|
+
const raw = await readFile(credsPath, "utf-8");
|
|
43
|
+
const parsed = JSON.parse(raw);
|
|
44
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
45
|
+
existing = parsed;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// No existing file, or unparseable — start fresh.
|
|
50
|
+
}
|
|
51
|
+
const merged = {
|
|
52
|
+
...existing,
|
|
53
|
+
default: {
|
|
54
|
+
// type discriminant must match the shape @uluops/cli writes via
|
|
55
|
+
// saveCredentials and the shape @uluops/sdk-core's StoredProfile expects.
|
|
56
|
+
// Without it, `ulu auth logout` reads creds.type !== 'api_key' and
|
|
57
|
+
// dispatches the revocation call with no bearer header — the server-side
|
|
58
|
+
// key survives a "successful" logout.
|
|
59
|
+
type: "api_key",
|
|
60
|
+
apiKey,
|
|
61
|
+
email: opts?.email ?? null,
|
|
62
|
+
createdAt: new Date().toISOString(),
|
|
63
|
+
source: opts?.source ?? "flag",
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
await atomicWrite(credsPath, JSON.stringify(merged, null, 2) + "\n", {
|
|
67
|
+
mode: 0o600,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
24
70
|
/**
|
|
25
71
|
* Resolve API key from flags, env, credentials file, or interactive prompt.
|
|
26
72
|
*/
|
|
@@ -102,9 +148,19 @@ async function validateKey(apiKey) {
|
|
|
102
148
|
if (!res.ok) {
|
|
103
149
|
throw new Error(`API returned ${res.status}. Try --skip-validation to continue offline.`);
|
|
104
150
|
}
|
|
105
|
-
// ops-uluops-api wraps user payloads as { data: { email, ... } }
|
|
106
|
-
|
|
107
|
-
|
|
151
|
+
// ops-uluops-api wraps user payloads as { data: { email, ... } }.
|
|
152
|
+
// Guard against malformed responses (HTML error pages, proxy interception,
|
|
153
|
+
// schema drift) — bare `as` would let `data` be a string, number, or null
|
|
154
|
+
// and produce a silent `email: null` result that looks like a normal
|
|
155
|
+
// logged-in user with no email on record.
|
|
156
|
+
let body;
|
|
157
|
+
try {
|
|
158
|
+
body = await res.json();
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
throw new Error("API returned a non-JSON response. The endpoint may be down or behind a captive portal — try --skip-validation to continue offline.");
|
|
162
|
+
}
|
|
163
|
+
return { email: extractEmail(body) };
|
|
108
164
|
}
|
|
109
165
|
catch (err) {
|
|
110
166
|
// fetch() throws TypeError for network failures (ENOTFOUND, ECONNREFUSED).
|
package/dist/steps/cli.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
1
2
|
export declare const CLI_PACKAGE = "@uluops/cli";
|
|
2
3
|
export declare const CLI_BIN = "ulu";
|
|
3
4
|
export interface CliExecutor {
|
|
@@ -14,6 +15,11 @@ export interface CliExecutor {
|
|
|
14
15
|
error?: string;
|
|
15
16
|
};
|
|
16
17
|
}
|
|
18
|
+
/** @internal — exported for test access to the timeout-summarization branch. */
|
|
19
|
+
export declare function summarizeSpawnResult(r: ReturnType<typeof spawnSync>, op: string): {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
error?: string;
|
|
22
|
+
};
|
|
17
23
|
/** Default executor — shells out to `ulu` and `npm`. */
|
|
18
24
|
export declare const defaultExecutor: CliExecutor;
|
|
19
25
|
export interface CliInstallResult {
|