@uluops/setup 0.6.5 → 0.7.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/dist/cli.js +12 -0
- package/dist/commands/helpers.d.ts +25 -0
- package/dist/commands/helpers.js +66 -0
- package/dist/commands/setup.d.ts +2 -0
- package/dist/commands/setup.js +103 -68
- package/dist/commands/uninstall.js +117 -85
- package/dist/lib/install-lock.d.ts +47 -0
- package/dist/lib/install-lock.js +241 -0
- package/dist/lib/manifest.d.ts +8 -0
- package/dist/lib/paths.d.ts +2 -0
- package/dist/lib/paths.js +4 -0
- package/dist/steps/agent-metrics-cli.d.ts +64 -0
- package/dist/steps/agent-metrics-cli.js +101 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import chalk from "chalk";
|
|
|
4
4
|
import { info, printAgentList } from "./lib/display.js";
|
|
5
5
|
import { getVersion } from "./lib/version.js";
|
|
6
6
|
import { resolveHarnessName, listHarnesses, detectHarnesses, HarnessNotTestedError, } from "./harnesses/index.js";
|
|
7
|
+
import { InstallLockHeldError } from "./lib/install-lock.js";
|
|
7
8
|
import { runSetup } from "./commands/setup.js";
|
|
8
9
|
import { runUninstall } from "./commands/uninstall.js";
|
|
9
10
|
import { runVerify } from "./commands/verify.js";
|
|
@@ -21,6 +22,8 @@ async function main() {
|
|
|
21
22
|
.option("--shell", "Write API key export to shell profile", false)
|
|
22
23
|
.option("--with-cli", "Install @uluops/cli globally without prompting")
|
|
23
24
|
.option("--no-cli", "Skip @uluops/cli install without prompting (takes precedence over --with-cli)")
|
|
25
|
+
.option("--with-agent-metrics-cli", "Install @uluops/agent-metrics globally without prompting")
|
|
26
|
+
.option("--no-agent-metrics-cli", "Skip @uluops/agent-metrics install without prompting (takes precedence over --with-agent-metrics-cli)")
|
|
24
27
|
.option("--skip-validation", "Accept API key without verifying", false)
|
|
25
28
|
.option("--list", "Show available agents and workflows without installing")
|
|
26
29
|
.option("--verify", "Check existing installation health")
|
|
@@ -96,6 +99,8 @@ async function main() {
|
|
|
96
99
|
shell: opts.shell,
|
|
97
100
|
withCli: opts.withCli,
|
|
98
101
|
cli: opts.cli,
|
|
102
|
+
withAgentMetricsCli: opts.withAgentMetricsCli,
|
|
103
|
+
agentMetricsCli: opts.agentMetricsCli,
|
|
99
104
|
skipValidation: opts.skipValidation,
|
|
100
105
|
dryRun: opts.dryRun,
|
|
101
106
|
yes: opts.yes,
|
|
@@ -107,6 +112,13 @@ main().catch((err) => {
|
|
|
107
112
|
console.error(chalk.yellow(`\n ${err.message}\n`));
|
|
108
113
|
process.exit(1);
|
|
109
114
|
}
|
|
115
|
+
if (err instanceof InstallLockHeldError) {
|
|
116
|
+
console.error(chalk.yellow(`\n ${err.message}\n`));
|
|
117
|
+
console.error(chalk.dim(" Wait for the other process to finish, or — if it crashed —\n" +
|
|
118
|
+
" the lock auto-releases after 30 minutes or when the held\n" +
|
|
119
|
+
" PID is detected as no longer running.\n"));
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
110
122
|
const msg = err instanceof Error ? err.message : String(err);
|
|
111
123
|
console.error(chalk.red(`\n Error: ${msg}\n`));
|
|
112
124
|
process.exit(1);
|
|
@@ -4,6 +4,7 @@ import type { AgentsResult } from "../steps/agents.js";
|
|
|
4
4
|
import type { CommandsResult } from "../steps/commands.js";
|
|
5
5
|
import type { MetricsResult } from "../steps/metrics.js";
|
|
6
6
|
import type { CliExecutor, CliInstallResult } from "../steps/cli.js";
|
|
7
|
+
import type { AgentMetricsCliExecutor, AgentMetricsCliInstallResult } from "../steps/agent-metrics-cli.js";
|
|
7
8
|
import type { HarnessProfile } from "../harnesses/index.js";
|
|
8
9
|
/** Resolve API key via flag, env, file, signup, or interactive prompt. Returns env detection + key. */
|
|
9
10
|
export declare function initContext(opts: {
|
|
@@ -54,6 +55,30 @@ export declare function configureCliStep(opts: {
|
|
|
54
55
|
dryRun: boolean;
|
|
55
56
|
executor?: CliExecutor;
|
|
56
57
|
}): Promise<CliInstallResult | null>;
|
|
58
|
+
/**
|
|
59
|
+
* Decide whether to install `@uluops/agent-metrics` globally and do it.
|
|
60
|
+
*
|
|
61
|
+
* Only meaningful when the SubagentStop hook actually got configured —
|
|
62
|
+
* otherwise the CLI has no captures to read. Caller gates on
|
|
63
|
+
* `metricsResult.hookConfigured`.
|
|
64
|
+
*
|
|
65
|
+
* Decision matrix mirrors `configureCliStep`:
|
|
66
|
+
* - `--no-agent-metrics-cli` (opts.agentMetricsCli === false) → skip, no prompt
|
|
67
|
+
* - `--with-agent-metrics-cli` (opts.withAgentMetricsCli === true) → install, no prompt
|
|
68
|
+
* - Neither flag + non-interactive (--yes / --api-key / no TTY) → skip
|
|
69
|
+
* - Neither flag + interactive → prompt (default Y)
|
|
70
|
+
*
|
|
71
|
+
* Returns null when the step did not run (skipped). Returns an install result
|
|
72
|
+
* when an attempt was made, for manifest recording.
|
|
73
|
+
*/
|
|
74
|
+
export declare function configureAgentMetricsCliStep(opts: {
|
|
75
|
+
withAgentMetricsCli?: boolean;
|
|
76
|
+
agentMetricsCli?: boolean;
|
|
77
|
+
yes: boolean;
|
|
78
|
+
apiKey?: string;
|
|
79
|
+
dryRun: boolean;
|
|
80
|
+
executor?: AgentMetricsCliExecutor;
|
|
81
|
+
}): Promise<AgentMetricsCliInstallResult | null>;
|
|
57
82
|
/** Ping tracker and registry health endpoints. */
|
|
58
83
|
export declare function runHealthCheck(opts: {
|
|
59
84
|
skipValidation: boolean;
|
package/dist/commands/helpers.js
CHANGED
|
@@ -9,6 +9,7 @@ import { installAgents } from "../steps/agents.js";
|
|
|
9
9
|
import { installCommands } from "../steps/commands.js";
|
|
10
10
|
import { installMetrics } from "../steps/metrics.js";
|
|
11
11
|
import { installCli, CLI_PACKAGE } from "../steps/cli.js";
|
|
12
|
+
import { installAgentMetricsCli, AGENT_METRICS_PACKAGE, AGENT_METRICS_BIN, } from "../steps/agent-metrics-cli.js";
|
|
12
13
|
import { writeShellExport } from "../steps/shell.js";
|
|
13
14
|
import { probeHookSupport } from "../lib/settings-merger.js";
|
|
14
15
|
import { findProjectRoot, ASSETS_DIR } from "../lib/paths.js";
|
|
@@ -213,6 +214,71 @@ export async function configureCliStep(opts) {
|
|
|
213
214
|
}
|
|
214
215
|
return res;
|
|
215
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* Decide whether to install `@uluops/agent-metrics` globally and do it.
|
|
219
|
+
*
|
|
220
|
+
* Only meaningful when the SubagentStop hook actually got configured —
|
|
221
|
+
* otherwise the CLI has no captures to read. Caller gates on
|
|
222
|
+
* `metricsResult.hookConfigured`.
|
|
223
|
+
*
|
|
224
|
+
* Decision matrix mirrors `configureCliStep`:
|
|
225
|
+
* - `--no-agent-metrics-cli` (opts.agentMetricsCli === false) → skip, no prompt
|
|
226
|
+
* - `--with-agent-metrics-cli` (opts.withAgentMetricsCli === true) → install, no prompt
|
|
227
|
+
* - Neither flag + non-interactive (--yes / --api-key / no TTY) → skip
|
|
228
|
+
* - Neither flag + interactive → prompt (default Y)
|
|
229
|
+
*
|
|
230
|
+
* Returns null when the step did not run (skipped). Returns an install result
|
|
231
|
+
* when an attempt was made, for manifest recording.
|
|
232
|
+
*/
|
|
233
|
+
export async function configureAgentMetricsCliStep(opts) {
|
|
234
|
+
if (opts.agentMetricsCli === false) {
|
|
235
|
+
info(chalk.dim(`Skipped global ${AGENT_METRICS_PACKAGE} install (--no-agent-metrics-cli)`));
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
let shouldInstall;
|
|
239
|
+
if (opts.withAgentMetricsCli === true) {
|
|
240
|
+
shouldInstall = true;
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
const nonInteractive = opts.yes || !!opts.apiKey || !process.stdin.isTTY;
|
|
244
|
+
if (nonInteractive) {
|
|
245
|
+
info(chalk.dim(`Skipped global ${AGENT_METRICS_PACKAGE} install (non-interactive — pass --with-agent-metrics-cli to install)`));
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
const { confirm } = await import("@inquirer/prompts");
|
|
249
|
+
shouldInstall = await confirm({
|
|
250
|
+
message: `Install ${AGENT_METRICS_PACKAGE} globally (provides the ${chalk.cyan(AGENT_METRICS_BIN)} command for reading captures)?`,
|
|
251
|
+
default: true,
|
|
252
|
+
});
|
|
253
|
+
if (!shouldInstall) {
|
|
254
|
+
info(chalk.dim(`Skipped global ${AGENT_METRICS_PACKAGE} install`));
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const res = await installAgentMetricsCli({
|
|
259
|
+
dryRun: opts.dryRun,
|
|
260
|
+
executor: opts.executor,
|
|
261
|
+
});
|
|
262
|
+
if (opts.dryRun && !res.alreadyPresent) {
|
|
263
|
+
ok(`Would install ${AGENT_METRICS_PACKAGE} globally`);
|
|
264
|
+
return res;
|
|
265
|
+
}
|
|
266
|
+
if (res.alreadyPresent) {
|
|
267
|
+
ok(`${AGENT_METRICS_PACKAGE} already installed${res.version ? ` (${res.version})` : ""} — no change`);
|
|
268
|
+
return res;
|
|
269
|
+
}
|
|
270
|
+
if (res.installed) {
|
|
271
|
+
ok(`${AGENT_METRICS_PACKAGE} installed globally${res.version ? ` (${res.version})` : ""}`);
|
|
272
|
+
return res;
|
|
273
|
+
}
|
|
274
|
+
warn(`Could not install ${AGENT_METRICS_PACKAGE} globally — try ${chalk.cyan(`npm install -g ${AGENT_METRICS_PACKAGE}`)} manually`);
|
|
275
|
+
if (res.error) {
|
|
276
|
+
const oneLine = res.error.split("\n")[0]?.slice(0, 120) ?? "";
|
|
277
|
+
if (oneLine)
|
|
278
|
+
info(chalk.dim(` ${oneLine}`));
|
|
279
|
+
}
|
|
280
|
+
return res;
|
|
281
|
+
}
|
|
216
282
|
/** Ping tracker and registry health endpoints. */
|
|
217
283
|
export async function runHealthCheck(opts) {
|
|
218
284
|
if (!opts.skipValidation && !opts.dryRun) {
|
package/dist/commands/setup.d.ts
CHANGED
package/dist/commands/setup.js
CHANGED
|
@@ -5,7 +5,8 @@ import { findProjectRoot } from "../lib/paths.js";
|
|
|
5
5
|
import { info, printSetupSummary } from "../lib/display.js";
|
|
6
6
|
import { getVersion } from "../lib/version.js";
|
|
7
7
|
import { getProfile } from "../harnesses/index.js";
|
|
8
|
-
import {
|
|
8
|
+
import { acquireInstallLock, } from "../lib/install-lock.js";
|
|
9
|
+
import { initContext, checkConflicts, configureMcpStep, installAgentsDefs, installCommandsDefs, configureMetricsStep, configureCliStep, configureAgentMetricsCliStep, runHealthCheck, configureShell, } from "./helpers.js";
|
|
9
10
|
export async function runSetup(opts) {
|
|
10
11
|
const version = await getVersion();
|
|
11
12
|
const profile = getProfile(opts.harness);
|
|
@@ -20,74 +21,108 @@ export async function runSetup(opts) {
|
|
|
20
21
|
}
|
|
21
22
|
const { env, apiKey } = await initContext(opts);
|
|
22
23
|
console.log();
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
console.log();
|
|
29
|
-
}
|
|
30
|
-
else if (existingHarness) {
|
|
31
|
-
info(chalk.dim(`Already at v${version} — checking for changes`));
|
|
32
|
-
console.log();
|
|
33
|
-
}
|
|
34
|
-
// Check for conflicts on first install for this harness
|
|
35
|
-
if (!existingHarness && !opts.yes && !opts.dryRun) {
|
|
36
|
-
await checkConflicts(profile, opts.localDefs);
|
|
37
|
-
}
|
|
38
|
-
const mcpResult = await configureMcpStep(profile, apiKey, opts);
|
|
39
|
-
const agentsResult = await installAgentsDefs(profile, opts, existingHarness?.agents);
|
|
40
|
-
const commandsResult = await installCommandsDefs(profile, opts, existingHarness?.commands);
|
|
41
|
-
const metricsResult = await configureMetricsStep(profile, opts);
|
|
42
|
-
const cliResult = await configureCliStep({
|
|
43
|
-
withCli: opts.withCli,
|
|
44
|
-
cli: opts.cli,
|
|
45
|
-
yes: opts.yes,
|
|
46
|
-
apiKey: opts.apiKey,
|
|
47
|
-
dryRun: opts.dryRun,
|
|
48
|
-
});
|
|
49
|
-
await runHealthCheck(opts);
|
|
50
|
-
const shellModified = await configureShell(env, apiKey, opts);
|
|
51
|
-
// Save manifest
|
|
24
|
+
// Acquire the install lock before touching any shared state. Skipped on
|
|
25
|
+
// dry-run (read-only). The lock excludes a second concurrent `uluops-setup`
|
|
26
|
+
// from racing the manifest / MCP config / shell-profile / settings.json
|
|
27
|
+
// read-merge-write windows. Released in `finally` below.
|
|
28
|
+
let lock = null;
|
|
52
29
|
if (!opts.dryRun) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
: profile.paths.home,
|
|
63
|
-
agents: agentsResult.files,
|
|
64
|
-
commands: commandsResult.files,
|
|
65
|
-
hooksInstalled: metricsResult.hookConfigured,
|
|
66
|
-
hooksInstalledVersion: metricsResult.hooksInstalledVersion,
|
|
67
|
-
};
|
|
68
|
-
const manifest = existingManifest ?? {
|
|
69
|
-
version,
|
|
70
|
-
installedAt: now,
|
|
71
|
-
shellModified: false,
|
|
72
|
-
harnesses: {},
|
|
73
|
-
};
|
|
74
|
-
manifest.version = version;
|
|
75
|
-
manifest.installedAt = now;
|
|
76
|
-
manifest.shellModified = shellModified || manifest.shellModified;
|
|
77
|
-
manifest.harnesses[profile.name] = harnessEntry;
|
|
78
|
-
// Only flip cliInstalled to true when WE installed it (not when user-installed).
|
|
79
|
-
// Once true, persist across re-runs so uninstall remains symmetric — until the
|
|
80
|
-
// user explicitly removes it with --no-cli + uninstall, this manifest owns it.
|
|
81
|
-
if (cliResult && cliResult.installed && !cliResult.alreadyPresent) {
|
|
82
|
-
manifest.cliInstalled = true;
|
|
83
|
-
manifest.cliInstalledVersion = cliResult.version;
|
|
30
|
+
lock = await acquireInstallLock();
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
// Load existing manifest for update detection
|
|
34
|
+
const existingManifest = await loadManifest();
|
|
35
|
+
const existingHarness = existingManifest?.harnesses[profile.name];
|
|
36
|
+
if (existingManifest && existingManifest.version !== version) {
|
|
37
|
+
info(`Updating ${chalk.dim(existingManifest.version)} → ${chalk.green(version)}`);
|
|
38
|
+
console.log();
|
|
84
39
|
}
|
|
85
|
-
|
|
40
|
+
else if (existingHarness) {
|
|
41
|
+
info(chalk.dim(`Already at v${version} — checking for changes`));
|
|
42
|
+
console.log();
|
|
43
|
+
}
|
|
44
|
+
// Check for conflicts on first install for this harness
|
|
45
|
+
if (!existingHarness && !opts.yes && !opts.dryRun) {
|
|
46
|
+
await checkConflicts(profile, opts.localDefs);
|
|
47
|
+
}
|
|
48
|
+
const mcpResult = await configureMcpStep(profile, apiKey, opts);
|
|
49
|
+
const agentsResult = await installAgentsDefs(profile, opts, existingHarness?.agents);
|
|
50
|
+
const commandsResult = await installCommandsDefs(profile, opts, existingHarness?.commands);
|
|
51
|
+
const metricsResult = await configureMetricsStep(profile, opts);
|
|
52
|
+
const cliResult = await configureCliStep({
|
|
53
|
+
withCli: opts.withCli,
|
|
54
|
+
cli: opts.cli,
|
|
55
|
+
yes: opts.yes,
|
|
56
|
+
apiKey: opts.apiKey,
|
|
57
|
+
dryRun: opts.dryRun,
|
|
58
|
+
});
|
|
59
|
+
// Only offer the agent-metrics CLI when the hook itself got configured —
|
|
60
|
+
// the CLI's purpose is reading captures the hook produces, so without the
|
|
61
|
+
// hook there's nothing for the CLI to surface.
|
|
62
|
+
const agentMetricsCliResult = metricsResult.hookConfigured
|
|
63
|
+
? await configureAgentMetricsCliStep({
|
|
64
|
+
withAgentMetricsCli: opts.withAgentMetricsCli,
|
|
65
|
+
agentMetricsCli: opts.agentMetricsCli,
|
|
66
|
+
yes: opts.yes,
|
|
67
|
+
apiKey: opts.apiKey,
|
|
68
|
+
dryRun: opts.dryRun,
|
|
69
|
+
})
|
|
70
|
+
: null;
|
|
71
|
+
await runHealthCheck(opts);
|
|
72
|
+
const shellModified = await configureShell(env, apiKey, opts);
|
|
73
|
+
// Save manifest
|
|
74
|
+
if (!opts.dryRun) {
|
|
75
|
+
const now = new Date().toISOString();
|
|
76
|
+
const harnessEntry = {
|
|
77
|
+
installedAt: now,
|
|
78
|
+
setupVersion: version,
|
|
79
|
+
mcpScope: opts.scope,
|
|
80
|
+
mcpConfigPath: mcpResult.configPath,
|
|
81
|
+
defsScope: opts.localDefs ? "local" : "global",
|
|
82
|
+
defsPath: opts.localDefs
|
|
83
|
+
? join(await findProjectRoot(), "uluops")
|
|
84
|
+
: profile.paths.home,
|
|
85
|
+
agents: agentsResult.files,
|
|
86
|
+
commands: commandsResult.files,
|
|
87
|
+
hooksInstalled: metricsResult.hookConfigured,
|
|
88
|
+
hooksInstalledVersion: metricsResult.hooksInstalledVersion,
|
|
89
|
+
};
|
|
90
|
+
const manifest = existingManifest ?? {
|
|
91
|
+
version,
|
|
92
|
+
installedAt: now,
|
|
93
|
+
shellModified: false,
|
|
94
|
+
harnesses: {},
|
|
95
|
+
};
|
|
96
|
+
manifest.version = version;
|
|
97
|
+
manifest.installedAt = now;
|
|
98
|
+
manifest.shellModified = shellModified || manifest.shellModified;
|
|
99
|
+
manifest.harnesses[profile.name] = harnessEntry;
|
|
100
|
+
// Only flip cliInstalled to true when WE installed it (not when user-installed).
|
|
101
|
+
// Once true, persist across re-runs so uninstall remains symmetric — until the
|
|
102
|
+
// user explicitly removes it with --no-cli + uninstall, this manifest owns it.
|
|
103
|
+
if (cliResult && cliResult.installed && !cliResult.alreadyPresent) {
|
|
104
|
+
manifest.cliInstalled = true;
|
|
105
|
+
manifest.cliInstalledVersion = cliResult.version;
|
|
106
|
+
}
|
|
107
|
+
// Same ownership rule for agent-metrics CLI — only manifest a global install
|
|
108
|
+
// we performed ourselves.
|
|
109
|
+
if (agentMetricsCliResult &&
|
|
110
|
+
agentMetricsCliResult.installed &&
|
|
111
|
+
!agentMetricsCliResult.alreadyPresent) {
|
|
112
|
+
manifest.agentMetricsCliInstalled = true;
|
|
113
|
+
manifest.agentMetricsCliInstalledVersion = agentMetricsCliResult.version;
|
|
114
|
+
}
|
|
115
|
+
await saveManifest(manifest);
|
|
116
|
+
}
|
|
117
|
+
await printSetupSummary({
|
|
118
|
+
profile,
|
|
119
|
+
agentCount: agentsResult.files.length,
|
|
120
|
+
commandCount: commandsResult.files.length,
|
|
121
|
+
apiKey,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
if (lock)
|
|
126
|
+
await lock.release();
|
|
86
127
|
}
|
|
87
|
-
await printSetupSummary({
|
|
88
|
-
profile,
|
|
89
|
-
agentCount: agentsResult.files.length,
|
|
90
|
-
commandCount: commandsResult.files.length,
|
|
91
|
-
apiKey,
|
|
92
|
-
});
|
|
93
128
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { loadManifest, deleteManifest, validateManifest, } from "../lib/manifest.js";
|
|
3
|
+
import { acquireInstallLock, } from "../lib/install-lock.js";
|
|
3
4
|
import { uninstallMcp } from "../steps/mcp.js";
|
|
4
5
|
import { uninstallAgents } from "../steps/agents.js";
|
|
5
6
|
import { uninstallCommands } from "../steps/commands.js";
|
|
6
7
|
import { removeShellExport } from "../steps/shell.js";
|
|
7
8
|
import { uninstallMetrics } from "../steps/metrics.js";
|
|
8
9
|
import { uninstallCli, CLI_PACKAGE } from "../steps/cli.js";
|
|
10
|
+
import { uninstallAgentMetricsCli, AGENT_METRICS_PACKAGE, } from "../steps/agent-metrics-cli.js";
|
|
9
11
|
import { ok, warn, fail, info } from "../lib/display.js";
|
|
10
12
|
import { getVersion } from "../lib/version.js";
|
|
11
13
|
import { getProfile } from "../harnesses/index.js";
|
|
@@ -17,110 +19,140 @@ export async function runUninstall(opts) {
|
|
|
17
19
|
if (opts.dryRun) {
|
|
18
20
|
info(chalk.dim("(dry run — no changes will be made)\n"));
|
|
19
21
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const validation = await validateManifest(manifest);
|
|
26
|
-
if (!validation.valid) {
|
|
27
|
-
fail("Manifest references paths that no longer exist:");
|
|
28
|
-
for (const err of validation.errors)
|
|
29
|
-
info(` ${err}`);
|
|
30
|
-
console.log();
|
|
31
|
-
info("Uninstall may be incomplete. Proceeding with what's available.");
|
|
32
|
-
console.log();
|
|
33
|
-
}
|
|
34
|
-
if (validation.warnings.length > 0) {
|
|
35
|
-
for (const w of validation.warnings)
|
|
36
|
-
warn(w);
|
|
37
|
-
console.log();
|
|
22
|
+
// Same lock as runSetup — concurrent setup+uninstall would race the same
|
|
23
|
+
// shared state. Skipped on dry-run (read-only).
|
|
24
|
+
let lock = null;
|
|
25
|
+
if (!opts.dryRun) {
|
|
26
|
+
lock = await acquireInstallLock();
|
|
38
27
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
catch {
|
|
45
|
-
warn(`Unknown harness "${harnessName}" in manifest — skipping`);
|
|
46
|
-
continue;
|
|
28
|
+
try {
|
|
29
|
+
const manifest = await loadManifest();
|
|
30
|
+
if (!manifest) {
|
|
31
|
+
warn("No manifest found — nothing to uninstall.");
|
|
32
|
+
return;
|
|
47
33
|
}
|
|
48
|
-
|
|
49
|
-
if (!
|
|
50
|
-
|
|
51
|
-
|
|
34
|
+
const validation = await validateManifest(manifest);
|
|
35
|
+
if (!validation.valid) {
|
|
36
|
+
fail("Manifest references paths that no longer exist:");
|
|
37
|
+
for (const err of validation.errors)
|
|
38
|
+
info(` ${err}`);
|
|
39
|
+
console.log();
|
|
40
|
+
info("Uninstall may be incomplete. Proceeding with what's available.");
|
|
41
|
+
console.log();
|
|
52
42
|
}
|
|
53
|
-
|
|
54
|
-
|
|
43
|
+
if (validation.warnings.length > 0) {
|
|
44
|
+
for (const w of validation.warnings)
|
|
45
|
+
warn(w);
|
|
46
|
+
console.log();
|
|
55
47
|
}
|
|
56
|
-
|
|
48
|
+
for (const [harnessName, hm] of Object.entries(manifest.harnesses)) {
|
|
49
|
+
let profile;
|
|
50
|
+
try {
|
|
51
|
+
profile = getProfile(harnessName);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
warn(`Unknown harness "${harnessName}" in manifest — skipping`);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
info(chalk.bold(profile.displayName));
|
|
57
58
|
if (!opts.dryRun) {
|
|
58
|
-
const
|
|
59
|
-
ok(`Removed ${
|
|
59
|
+
const agentCount = await uninstallAgents(hm.agents, hm.defsPath);
|
|
60
|
+
ok(`Removed ${agentCount} agent(s)`);
|
|
60
61
|
}
|
|
61
62
|
else {
|
|
62
|
-
ok(`Would remove ${hm.
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
if (!opts.dryRun) {
|
|
66
|
-
try {
|
|
67
|
-
await uninstallMcp(profile, hm.mcpConfigPath);
|
|
68
|
-
ok(`Removed MCP servers from ${hm.mcpConfigPath}`);
|
|
63
|
+
ok(`Would remove ${hm.agents.length} agent(s)`);
|
|
69
64
|
}
|
|
70
|
-
|
|
71
|
-
|
|
65
|
+
if (hm.commands.length > 0) {
|
|
66
|
+
if (!opts.dryRun) {
|
|
67
|
+
const cmdCount = await uninstallCommands(hm.commands, hm.defsPath);
|
|
68
|
+
ok(`Removed ${cmdCount} command(s)`);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
ok(`Would remove ${hm.commands.length} command(s)`);
|
|
72
|
+
}
|
|
72
73
|
}
|
|
73
|
-
}
|
|
74
|
-
else {
|
|
75
|
-
ok(`Would remove MCP servers from ${hm.mcpConfigPath}`);
|
|
76
|
-
}
|
|
77
|
-
if (hm.hooksInstalled) {
|
|
78
74
|
if (!opts.dryRun) {
|
|
79
|
-
|
|
80
|
-
|
|
75
|
+
try {
|
|
76
|
+
await uninstallMcp(profile, hm.mcpConfigPath);
|
|
77
|
+
ok(`Removed MCP servers from ${hm.mcpConfigPath}`);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
warn(`Could not remove MCP servers from ${hm.mcpConfigPath}`);
|
|
81
|
+
}
|
|
81
82
|
}
|
|
82
83
|
else {
|
|
83
|
-
ok(
|
|
84
|
+
ok(`Would remove MCP servers from ${hm.mcpConfigPath}`);
|
|
84
85
|
}
|
|
86
|
+
if (hm.hooksInstalled) {
|
|
87
|
+
if (!opts.dryRun) {
|
|
88
|
+
await uninstallMetrics(profile, false);
|
|
89
|
+
ok("Removed agent-metrics hook and tool files");
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
ok("Would remove agent-metrics hook and tool files");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
console.log();
|
|
85
96
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
+
// Remove global @uluops/cli if WE installed it (per manifest).
|
|
98
|
+
// We never auto-remove a user-installed CLI — manifest.cliInstalled gates this.
|
|
99
|
+
if (manifest.cliInstalled) {
|
|
100
|
+
const res = await uninstallCli({ dryRun: opts.dryRun });
|
|
101
|
+
if (opts.dryRun) {
|
|
102
|
+
ok(`Would remove ${CLI_PACKAGE} (global)`);
|
|
103
|
+
}
|
|
104
|
+
else if (res.removed) {
|
|
105
|
+
ok(`Removed ${CLI_PACKAGE} (global)`);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
warn(`Could not remove ${CLI_PACKAGE} (global) — try \`npm uninstall -g ${CLI_PACKAGE}\` manually`);
|
|
109
|
+
if (res.error) {
|
|
110
|
+
const oneLine = res.error.split("\n")[0]?.slice(0, 120) ?? "";
|
|
111
|
+
if (oneLine)
|
|
112
|
+
info(` ${oneLine}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
97
115
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
116
|
+
// Same ownership rule for the agent-metrics CLI.
|
|
117
|
+
if (manifest.agentMetricsCliInstalled) {
|
|
118
|
+
const res = await uninstallAgentMetricsCli({ dryRun: opts.dryRun });
|
|
119
|
+
if (opts.dryRun) {
|
|
120
|
+
ok(`Would remove ${AGENT_METRICS_PACKAGE} (global)`);
|
|
121
|
+
}
|
|
122
|
+
else if (res.removed) {
|
|
123
|
+
ok(`Removed ${AGENT_METRICS_PACKAGE} (global)`);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
warn(`Could not remove ${AGENT_METRICS_PACKAGE} (global) — try \`npm uninstall -g ${AGENT_METRICS_PACKAGE}\` manually`);
|
|
127
|
+
if (res.error) {
|
|
128
|
+
const oneLine = res.error.split("\n")[0]?.slice(0, 120) ?? "";
|
|
129
|
+
if (oneLine)
|
|
130
|
+
info(` ${oneLine}`);
|
|
131
|
+
}
|
|
104
132
|
}
|
|
105
133
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
134
|
+
// Remove shell export
|
|
135
|
+
if (manifest.shellModified) {
|
|
136
|
+
const { getShellProfile } = await import("../lib/paths.js");
|
|
137
|
+
const shellProfile = getShellProfile();
|
|
138
|
+
if (shellProfile && !opts.dryRun) {
|
|
139
|
+
await removeShellExport(shellProfile.path);
|
|
140
|
+
ok(`Removed export from ${shellProfile.path}`);
|
|
141
|
+
}
|
|
142
|
+
else if (shellProfile) {
|
|
143
|
+
ok(`Would remove export from ${shellProfile.path}`);
|
|
144
|
+
}
|
|
114
145
|
}
|
|
115
|
-
|
|
116
|
-
|
|
146
|
+
if (!opts.dryRun) {
|
|
147
|
+
await deleteManifest();
|
|
148
|
+
ok("Manifest deleted");
|
|
117
149
|
}
|
|
150
|
+
console.log();
|
|
151
|
+
info("UluOps has been removed. Restart your harness to complete.");
|
|
152
|
+
console.log();
|
|
118
153
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
154
|
+
finally {
|
|
155
|
+
if (lock)
|
|
156
|
+
await lock.release();
|
|
122
157
|
}
|
|
123
|
-
console.log();
|
|
124
|
-
info("UluOps has been removed. Restart your harness to complete.");
|
|
125
|
-
console.log();
|
|
126
158
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-level mutex for uluops-setup install/uninstall operations.
|
|
3
|
+
*
|
|
4
|
+
* Solves the TOCTOU race surfaced by ship-pipeline code-auditor (AF-006):
|
|
5
|
+
* two concurrent `npx @uluops/setup` invocations both read shared state,
|
|
6
|
+
* each merges and writes its own version, second write clobbers first.
|
|
7
|
+
*
|
|
8
|
+
* Uses `mkdir` atomicity (POSIX + NTFS, ~50 years stable) instead of a
|
|
9
|
+
* dependency. Meta file inside the lock dir carries PID + hostname +
|
|
10
|
+
* start timestamp so stale locks can be reclaimed automatically.
|
|
11
|
+
*
|
|
12
|
+
* Scope is intentionally narrow: this protects setup-vs-setup races only.
|
|
13
|
+
* Setup-vs-harness races (the harness CLI writing to its own settings file
|
|
14
|
+
* concurrently) require a separate compare-and-swap design.
|
|
15
|
+
*/
|
|
16
|
+
export interface LockHandle {
|
|
17
|
+
release(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
export interface AcquireOptions {
|
|
20
|
+
/** Max time a held lock is considered valid before being reclaimed (default 30 min). */
|
|
21
|
+
maxAgeMs?: number;
|
|
22
|
+
/** How long to wait for a held lock before failing (default 0). */
|
|
23
|
+
waitMs?: number;
|
|
24
|
+
/** Override the lock directory (test seam). */
|
|
25
|
+
lockDir?: string;
|
|
26
|
+
}
|
|
27
|
+
/** Thrown when another process holds the lock and waiting has exhausted. */
|
|
28
|
+
export declare class InstallLockHeldError extends Error {
|
|
29
|
+
readonly holder: {
|
|
30
|
+
pid: number;
|
|
31
|
+
hostname: string;
|
|
32
|
+
ageMs: number;
|
|
33
|
+
};
|
|
34
|
+
constructor(holder: {
|
|
35
|
+
pid: number;
|
|
36
|
+
hostname: string;
|
|
37
|
+
ageMs: number;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Acquire the install lock. Resolves with a handle whose `release()` must be
|
|
42
|
+
* called in a `finally` block. Throws `InstallLockHeldError` if the lock is
|
|
43
|
+
* held by a live process and waiting (if any) has exhausted.
|
|
44
|
+
*/
|
|
45
|
+
export declare function acquireInstallLock(opts?: AcquireOptions): Promise<LockHandle>;
|
|
46
|
+
/** Test-only: detach handlers and clear lock-dir tracking so tests can rebind. */
|
|
47
|
+
export declare function __resetSignalHandlersForTesting(): void;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-level mutex for uluops-setup install/uninstall operations.
|
|
3
|
+
*
|
|
4
|
+
* Solves the TOCTOU race surfaced by ship-pipeline code-auditor (AF-006):
|
|
5
|
+
* two concurrent `npx @uluops/setup` invocations both read shared state,
|
|
6
|
+
* each merges and writes its own version, second write clobbers first.
|
|
7
|
+
*
|
|
8
|
+
* Uses `mkdir` atomicity (POSIX + NTFS, ~50 years stable) instead of a
|
|
9
|
+
* dependency. Meta file inside the lock dir carries PID + hostname +
|
|
10
|
+
* start timestamp so stale locks can be reclaimed automatically.
|
|
11
|
+
*
|
|
12
|
+
* Scope is intentionally narrow: this protects setup-vs-setup races only.
|
|
13
|
+
* Setup-vs-harness races (the harness CLI writing to its own settings file
|
|
14
|
+
* concurrently) require a separate compare-and-swap design.
|
|
15
|
+
*/
|
|
16
|
+
import { mkdir, readFile, rm, unlink, writeFile, } from "node:fs/promises";
|
|
17
|
+
import { rmSync } from "node:fs";
|
|
18
|
+
import { hostname } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { getInstallLockDir } from "./paths.js";
|
|
21
|
+
const META_FILENAME = "meta.json";
|
|
22
|
+
const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000; // 30 min
|
|
23
|
+
const DEFAULT_WAIT_MS = 0;
|
|
24
|
+
const POLL_INTERVAL_MS = 500;
|
|
25
|
+
/** Thrown when another process holds the lock and waiting has exhausted. */
|
|
26
|
+
export class InstallLockHeldError extends Error {
|
|
27
|
+
holder;
|
|
28
|
+
constructor(holder) {
|
|
29
|
+
super(`Another uluops-setup process is already running (PID ${holder.pid} on ${holder.hostname}, started ${Math.round(holder.ageMs / 1000)}s ago).`);
|
|
30
|
+
this.holder = holder;
|
|
31
|
+
this.name = "InstallLockHeldError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Acquire the install lock. Resolves with a handle whose `release()` must be
|
|
36
|
+
* called in a `finally` block. Throws `InstallLockHeldError` if the lock is
|
|
37
|
+
* held by a live process and waiting (if any) has exhausted.
|
|
38
|
+
*/
|
|
39
|
+
export async function acquireInstallLock(opts = {}) {
|
|
40
|
+
const lockDir = opts.lockDir ?? getInstallLockDir();
|
|
41
|
+
const maxAgeMs = opts.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
|
42
|
+
const waitMs = opts.waitMs ?? DEFAULT_WAIT_MS;
|
|
43
|
+
const deadline = Date.now() + waitMs;
|
|
44
|
+
// First try (and one retry after stale-lock reclaim).
|
|
45
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
46
|
+
try {
|
|
47
|
+
await mkdir(lockDir, { recursive: false });
|
|
48
|
+
// Won the race. Write metadata, register handlers, return handle.
|
|
49
|
+
const meta = {
|
|
50
|
+
pid: process.pid,
|
|
51
|
+
hostname: hostname(),
|
|
52
|
+
startedAt: Date.now(),
|
|
53
|
+
};
|
|
54
|
+
await writeFile(join(lockDir, META_FILENAME), JSON.stringify(meta));
|
|
55
|
+
return registerHandle(lockDir);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
const code = err.code;
|
|
59
|
+
if (code !== "EEXIST")
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
// Lock exists. Inspect it.
|
|
63
|
+
const verdict = await inspectHeldLock(lockDir, maxAgeMs);
|
|
64
|
+
if (verdict.kind === "stale") {
|
|
65
|
+
// Reclaim and retry once.
|
|
66
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
// Live holder. Optionally wait.
|
|
70
|
+
if (Date.now() < deadline) {
|
|
71
|
+
while (Date.now() < deadline) {
|
|
72
|
+
await sleep(POLL_INTERVAL_MS);
|
|
73
|
+
const recheck = await inspectHeldLock(lockDir, maxAgeMs);
|
|
74
|
+
if (recheck.kind === "stale") {
|
|
75
|
+
// Holder released or died during wait.
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Retry once after wait.
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
throw new InstallLockHeldError({
|
|
83
|
+
pid: verdict.meta.pid,
|
|
84
|
+
hostname: verdict.meta.hostname,
|
|
85
|
+
ageMs: Date.now() - verdict.meta.startedAt,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
// Both attempts exhausted without acquiring.
|
|
89
|
+
throw new InstallLockHeldError({ pid: -1, hostname: "unknown", ageMs: 0 });
|
|
90
|
+
}
|
|
91
|
+
async function inspectHeldLock(lockDir, maxAgeMs) {
|
|
92
|
+
const metaPath = join(lockDir, META_FILENAME);
|
|
93
|
+
let raw;
|
|
94
|
+
try {
|
|
95
|
+
raw = await readFile(metaPath, "utf-8");
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Lock dir exists but meta missing or unreadable — treat as stale.
|
|
99
|
+
return { kind: "stale", reason: "missing-meta" };
|
|
100
|
+
}
|
|
101
|
+
let meta;
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(raw);
|
|
104
|
+
if (!isLockMeta(parsed)) {
|
|
105
|
+
return { kind: "stale", reason: "malformed-meta" };
|
|
106
|
+
}
|
|
107
|
+
meta = parsed;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return { kind: "stale", reason: "invalid-json" };
|
|
111
|
+
}
|
|
112
|
+
if (Date.now() - meta.startedAt > maxAgeMs) {
|
|
113
|
+
return { kind: "stale", reason: "timeout" };
|
|
114
|
+
}
|
|
115
|
+
// Same host: probe the PID. Different host: trust the meta until timeout.
|
|
116
|
+
if (meta.hostname === hostname()) {
|
|
117
|
+
if (!isPidAlive(meta.pid)) {
|
|
118
|
+
return { kind: "stale", reason: "dead-pid" };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { kind: "live", meta };
|
|
122
|
+
}
|
|
123
|
+
function isLockMeta(value) {
|
|
124
|
+
if (typeof value !== "object" || value === null)
|
|
125
|
+
return false;
|
|
126
|
+
const v = value;
|
|
127
|
+
return (typeof v["pid"] === "number" &&
|
|
128
|
+
typeof v["hostname"] === "string" &&
|
|
129
|
+
typeof v["startedAt"] === "number");
|
|
130
|
+
}
|
|
131
|
+
function isPidAlive(pid) {
|
|
132
|
+
if (pid <= 0 || !Number.isInteger(pid))
|
|
133
|
+
return false;
|
|
134
|
+
try {
|
|
135
|
+
process.kill(pid, 0);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
const code = err.code;
|
|
140
|
+
if (code === "ESRCH")
|
|
141
|
+
return false;
|
|
142
|
+
if (code === "EPERM")
|
|
143
|
+
return true; // exists but we lack permission
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function sleep(ms) {
|
|
148
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
149
|
+
}
|
|
150
|
+
// ─── Signal handler registry ─────────────────────────────────────────────────
|
|
151
|
+
//
|
|
152
|
+
// All active lock dirs are tracked at module scope so signal handlers can
|
|
153
|
+
// release every held lock synchronously before re-raising the signal.
|
|
154
|
+
const heldLockDirs = new Set();
|
|
155
|
+
let signalHandlersInstalled = false;
|
|
156
|
+
let installedSigintHandler = null;
|
|
157
|
+
let installedSigtermHandler = null;
|
|
158
|
+
let installedUncaughtHandler = null;
|
|
159
|
+
function registerHandle(lockDir) {
|
|
160
|
+
heldLockDirs.add(lockDir);
|
|
161
|
+
ensureSignalHandlers();
|
|
162
|
+
let released = false;
|
|
163
|
+
return {
|
|
164
|
+
async release() {
|
|
165
|
+
if (released)
|
|
166
|
+
return;
|
|
167
|
+
released = true;
|
|
168
|
+
heldLockDirs.delete(lockDir);
|
|
169
|
+
try {
|
|
170
|
+
await unlink(join(lockDir, META_FILENAME));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// Already gone or unreadable; proceed to rmdir.
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// Best-effort; do not throw from release().
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
function ensureSignalHandlers() {
|
|
185
|
+
if (signalHandlersInstalled)
|
|
186
|
+
return;
|
|
187
|
+
signalHandlersInstalled = true;
|
|
188
|
+
const cleanup = (signal) => {
|
|
189
|
+
for (const dir of heldLockDirs) {
|
|
190
|
+
try {
|
|
191
|
+
rmSync(dir, { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// Best-effort.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
heldLockDirs.clear();
|
|
198
|
+
// Re-raise the signal so default Node behavior runs (exit with the
|
|
199
|
+
// conventional 128 + signum code). Listeners were already triggered.
|
|
200
|
+
process.removeListener(signal, cleanup);
|
|
201
|
+
process.kill(process.pid, signal);
|
|
202
|
+
};
|
|
203
|
+
const uncaught = (err) => {
|
|
204
|
+
for (const dir of heldLockDirs) {
|
|
205
|
+
try {
|
|
206
|
+
rmSync(dir, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// Best-effort.
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
heldLockDirs.clear();
|
|
213
|
+
// Restore default behavior: print stack and exit 1.
|
|
214
|
+
console.error(err);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
};
|
|
217
|
+
installedSigintHandler = cleanup;
|
|
218
|
+
installedSigtermHandler = cleanup;
|
|
219
|
+
installedUncaughtHandler = uncaught;
|
|
220
|
+
process.on("SIGINT", cleanup);
|
|
221
|
+
process.on("SIGTERM", cleanup);
|
|
222
|
+
process.on("uncaughtException", uncaught);
|
|
223
|
+
}
|
|
224
|
+
// ─── Test seams ──────────────────────────────────────────────────────────────
|
|
225
|
+
/** Test-only: detach handlers and clear lock-dir tracking so tests can rebind. */
|
|
226
|
+
export function __resetSignalHandlersForTesting() {
|
|
227
|
+
if (installedSigintHandler) {
|
|
228
|
+
process.removeListener("SIGINT", installedSigintHandler);
|
|
229
|
+
installedSigintHandler = null;
|
|
230
|
+
}
|
|
231
|
+
if (installedSigtermHandler) {
|
|
232
|
+
process.removeListener("SIGTERM", installedSigtermHandler);
|
|
233
|
+
installedSigtermHandler = null;
|
|
234
|
+
}
|
|
235
|
+
if (installedUncaughtHandler) {
|
|
236
|
+
process.removeListener("uncaughtException", installedUncaughtHandler);
|
|
237
|
+
installedUncaughtHandler = null;
|
|
238
|
+
}
|
|
239
|
+
signalHandlersInstalled = false;
|
|
240
|
+
heldLockDirs.clear();
|
|
241
|
+
}
|
package/dist/lib/manifest.d.ts
CHANGED
|
@@ -45,6 +45,14 @@ export interface Manifest {
|
|
|
45
45
|
cliInstalled?: boolean;
|
|
46
46
|
/** Version reported by `ulu --version` at install time, for drift detection. */
|
|
47
47
|
cliInstalledVersion?: string | null;
|
|
48
|
+
/**
|
|
49
|
+
* Tracks whether `@uluops/agent-metrics` was installed globally by this
|
|
50
|
+
* setup run. Same ownership semantics as `cliInstalled` — uninstall only
|
|
51
|
+
* removes the global package when this is true.
|
|
52
|
+
*/
|
|
53
|
+
agentMetricsCliInstalled?: boolean;
|
|
54
|
+
/** Version reported by `agent-metrics --version` at install time. */
|
|
55
|
+
agentMetricsCliInstalledVersion?: string | null;
|
|
48
56
|
contentHash?: string;
|
|
49
57
|
}
|
|
50
58
|
export interface ManifestValidationResult {
|
package/dist/lib/paths.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export declare function getLocalMcpPath(): Promise<string>;
|
|
|
15
15
|
export declare function getUluopsDir(): string;
|
|
16
16
|
/** Return the path to the UluOps install manifest file (new location). */
|
|
17
17
|
export declare function getManifestPath(): string;
|
|
18
|
+
/** Return the directory used as a process-level install/uninstall mutex. */
|
|
19
|
+
export declare function getInstallLockDir(): string;
|
|
18
20
|
/** Return the legacy manifest path for migration. */
|
|
19
21
|
export declare function getLegacyManifestPath(): string;
|
|
20
22
|
/**
|
package/dist/lib/paths.js
CHANGED
|
@@ -90,6 +90,10 @@ export function getUluopsDir() {
|
|
|
90
90
|
export function getManifestPath() {
|
|
91
91
|
return join(getUluopsDir(), "manifest.json");
|
|
92
92
|
}
|
|
93
|
+
/** Return the directory used as a process-level install/uninstall mutex. */
|
|
94
|
+
export function getInstallLockDir() {
|
|
95
|
+
return join(getUluopsDir(), "install.lock");
|
|
96
|
+
}
|
|
93
97
|
/** Return the legacy manifest path for migration. */
|
|
94
98
|
export function getLegacyManifestPath() {
|
|
95
99
|
return join(getClaudeHome(), "uluops-manifest.json");
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global install of the @uluops/agent-metrics CLI.
|
|
3
|
+
*
|
|
4
|
+
* Companion to src/steps/cli.ts (which handles @uluops/cli). The agent-metrics
|
|
5
|
+
* package is ALREADY copied into the harness tree by src/steps/metrics.ts
|
|
6
|
+
* (so the SubagentStop hook can invoke dist/hook.js at a fixed path), but
|
|
7
|
+
* that copy never goes on PATH. This step makes the `agent-metrics` command
|
|
8
|
+
* available to users who want to inspect the captures the hook produces.
|
|
9
|
+
*
|
|
10
|
+
* Gated externally by the helper in src/commands/helpers.ts — only invoked
|
|
11
|
+
* when the metrics hook actually got configured.
|
|
12
|
+
*/
|
|
13
|
+
export declare const AGENT_METRICS_PACKAGE = "@uluops/agent-metrics";
|
|
14
|
+
export declare const AGENT_METRICS_BIN = "agent-metrics";
|
|
15
|
+
export interface AgentMetricsCliExecutor {
|
|
16
|
+
/** Returns the installed CLI version, or null if `agent-metrics` is not on PATH or fails to run. */
|
|
17
|
+
detect: () => string | null;
|
|
18
|
+
/** Best-effort `npm install -g`. Returns ok + captured error for surface display. */
|
|
19
|
+
install: () => {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
error?: string;
|
|
22
|
+
};
|
|
23
|
+
/** Best-effort `npm uninstall -g`. Returns ok + captured error. */
|
|
24
|
+
uninstall: () => {
|
|
25
|
+
ok: boolean;
|
|
26
|
+
error?: string;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Default executor — shells out to `agent-metrics` and `npm`. */
|
|
30
|
+
export declare const defaultAgentMetricsExecutor: AgentMetricsCliExecutor;
|
|
31
|
+
export interface AgentMetricsCliInstallResult {
|
|
32
|
+
/** `agent-metrics` is on PATH after this step, regardless of how it got there. */
|
|
33
|
+
installed: boolean;
|
|
34
|
+
/** Version string from `agent-metrics --version`, if detectable. */
|
|
35
|
+
version: string | null;
|
|
36
|
+
/** True when `agent-metrics` was already on PATH before we did anything. */
|
|
37
|
+
alreadyPresent: boolean;
|
|
38
|
+
/** Set when our `npm install -g` attempt failed; caller decides how to surface. */
|
|
39
|
+
error?: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Install `@uluops/agent-metrics` globally if not already present.
|
|
43
|
+
*
|
|
44
|
+
* Mirrors `installCli` semantics — never aborts the parent setup flow:
|
|
45
|
+
* - If `agent-metrics` is already on PATH, returns `{ installed: true, alreadyPresent: true }` without touching npm.
|
|
46
|
+
* - If `npm install -g` fails, returns `{ installed: false, error }` so the caller can warn-and-continue.
|
|
47
|
+
* - In dryRun mode, no executor calls happen.
|
|
48
|
+
*/
|
|
49
|
+
export declare function installAgentMetricsCli(opts: {
|
|
50
|
+
dryRun: boolean;
|
|
51
|
+
executor?: AgentMetricsCliExecutor;
|
|
52
|
+
}): Promise<AgentMetricsCliInstallResult>;
|
|
53
|
+
export interface AgentMetricsCliUninstallResult {
|
|
54
|
+
removed: boolean;
|
|
55
|
+
error?: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Uninstall `@uluops/agent-metrics` globally. Best-effort: if the package
|
|
59
|
+
* isn't there, npm exits non-zero on some platforms — we treat that as success.
|
|
60
|
+
*/
|
|
61
|
+
export declare function uninstallAgentMetricsCli(opts: {
|
|
62
|
+
dryRun: boolean;
|
|
63
|
+
executor?: AgentMetricsCliExecutor;
|
|
64
|
+
}): Promise<AgentMetricsCliUninstallResult>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Global install of the @uluops/agent-metrics CLI.
|
|
4
|
+
*
|
|
5
|
+
* Companion to src/steps/cli.ts (which handles @uluops/cli). The agent-metrics
|
|
6
|
+
* package is ALREADY copied into the harness tree by src/steps/metrics.ts
|
|
7
|
+
* (so the SubagentStop hook can invoke dist/hook.js at a fixed path), but
|
|
8
|
+
* that copy never goes on PATH. This step makes the `agent-metrics` command
|
|
9
|
+
* available to users who want to inspect the captures the hook produces.
|
|
10
|
+
*
|
|
11
|
+
* Gated externally by the helper in src/commands/helpers.ts — only invoked
|
|
12
|
+
* when the metrics hook actually got configured.
|
|
13
|
+
*/
|
|
14
|
+
export const AGENT_METRICS_PACKAGE = "@uluops/agent-metrics";
|
|
15
|
+
export const AGENT_METRICS_BIN = "agent-metrics";
|
|
16
|
+
/** Default executor — shells out to `agent-metrics` and `npm`. */
|
|
17
|
+
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
|
+
},
|
|
27
|
+
install: () => {
|
|
28
|
+
const r = spawnSync("npm", ["install", "-g", AGENT_METRICS_PACKAGE], {
|
|
29
|
+
encoding: "utf-8",
|
|
30
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
31
|
+
});
|
|
32
|
+
if (r.status === 0)
|
|
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}` };
|
|
37
|
+
},
|
|
38
|
+
uninstall: () => {
|
|
39
|
+
const r = spawnSync("npm", ["uninstall", "-g", AGENT_METRICS_PACKAGE], {
|
|
40
|
+
encoding: "utf-8",
|
|
41
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
42
|
+
});
|
|
43
|
+
if (r.status === 0)
|
|
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}` };
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Install `@uluops/agent-metrics` globally if not already present.
|
|
52
|
+
*
|
|
53
|
+
* Mirrors `installCli` semantics — never aborts the parent setup flow:
|
|
54
|
+
* - If `agent-metrics` is already on PATH, returns `{ installed: true, alreadyPresent: true }` without touching npm.
|
|
55
|
+
* - If `npm install -g` fails, returns `{ installed: false, error }` so the caller can warn-and-continue.
|
|
56
|
+
* - In dryRun mode, no executor calls happen.
|
|
57
|
+
*/
|
|
58
|
+
export async function installAgentMetricsCli(opts) {
|
|
59
|
+
const executor = opts.executor ?? defaultAgentMetricsExecutor;
|
|
60
|
+
const existing = executor.detect();
|
|
61
|
+
if (existing !== null) {
|
|
62
|
+
return { installed: true, version: existing, alreadyPresent: true };
|
|
63
|
+
}
|
|
64
|
+
if (opts.dryRun) {
|
|
65
|
+
return { installed: false, version: null, alreadyPresent: false };
|
|
66
|
+
}
|
|
67
|
+
const res = executor.install();
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
return {
|
|
70
|
+
installed: false,
|
|
71
|
+
version: null,
|
|
72
|
+
alreadyPresent: false,
|
|
73
|
+
error: res.error,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const after = executor.detect();
|
|
77
|
+
return {
|
|
78
|
+
installed: after !== null,
|
|
79
|
+
version: after,
|
|
80
|
+
alreadyPresent: false,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Uninstall `@uluops/agent-metrics` globally. Best-effort: if the package
|
|
85
|
+
* isn't there, npm exits non-zero on some platforms — we treat that as success.
|
|
86
|
+
*/
|
|
87
|
+
export async function uninstallAgentMetricsCli(opts) {
|
|
88
|
+
const executor = opts.executor ?? defaultAgentMetricsExecutor;
|
|
89
|
+
if (opts.dryRun)
|
|
90
|
+
return { removed: true };
|
|
91
|
+
const before = executor.detect();
|
|
92
|
+
if (before === null)
|
|
93
|
+
return { removed: true };
|
|
94
|
+
const res = executor.uninstall();
|
|
95
|
+
if (res.ok)
|
|
96
|
+
return { removed: true };
|
|
97
|
+
const after = executor.detect();
|
|
98
|
+
if (after === null)
|
|
99
|
+
return { removed: true };
|
|
100
|
+
return { removed: false, error: res.error };
|
|
101
|
+
}
|