@os-eco/overstory-cli 0.6.9 → 0.6.10
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 +17 -5
- package/agents/builder.md +6 -15
- package/agents/lead.md +4 -6
- package/agents/merger.md +5 -13
- package/agents/reviewer.md +2 -9
- package/package.json +1 -1
- package/src/agents/hooks-deployer.test.ts +52 -0
- package/src/agents/hooks-deployer.ts +22 -7
- package/src/agents/overlay.test.ts +156 -1
- package/src/agents/overlay.ts +67 -7
- package/src/commands/completions.test.ts +8 -20
- package/src/commands/completions.ts +4 -2
- package/src/commands/doctor.ts +97 -48
- package/src/commands/ecosystem.ts +291 -0
- package/src/commands/feed.ts +2 -2
- package/src/commands/sling.ts +1 -1
- package/src/commands/upgrade.test.ts +46 -0
- package/src/commands/upgrade.ts +259 -0
- package/src/doctor/databases.test.ts +38 -0
- package/src/doctor/databases.ts +7 -10
- package/src/doctor/ecosystem.test.ts +307 -0
- package/src/doctor/ecosystem.ts +155 -0
- package/src/doctor/merge-queue.test.ts +98 -0
- package/src/doctor/merge-queue.ts +23 -0
- package/src/doctor/structure.test.ts +130 -1
- package/src/doctor/structure.ts +87 -1
- package/src/doctor/types.ts +5 -2
- package/src/index.ts +23 -1
package/src/commands/sling.ts
CHANGED
|
@@ -561,7 +561,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
561
561
|
}
|
|
562
562
|
|
|
563
563
|
// 9. Deploy hooks config (capability-specific guards)
|
|
564
|
-
await deployHooks(worktreePath, name, capability);
|
|
564
|
+
await deployHooks(worktreePath, name, capability, config.project.qualityGates);
|
|
565
565
|
|
|
566
566
|
// 9b. Send auto-dispatch mail so it exists when SessionStart hook fires.
|
|
567
567
|
// This eliminates the race where coordinator sends dispatch AFTER agent boots.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the ov upgrade command.
|
|
3
|
+
*
|
|
4
|
+
* Structural tests for CLI registration and option parsing.
|
|
5
|
+
* Network calls and subprocess execution happen via real implementations;
|
|
6
|
+
* this file tests command structure and output format rather than side effects.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { createUpgradeCommand } from "./upgrade.ts";
|
|
11
|
+
|
|
12
|
+
describe("createUpgradeCommand — CLI structure", () => {
|
|
13
|
+
test("command has correct name", () => {
|
|
14
|
+
const cmd = createUpgradeCommand();
|
|
15
|
+
expect(cmd.name()).toBe("upgrade");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("description mentions overstory", () => {
|
|
19
|
+
const cmd = createUpgradeCommand();
|
|
20
|
+
expect(cmd.description().toLowerCase()).toContain("overstory");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("has --check option", () => {
|
|
24
|
+
const cmd = createUpgradeCommand();
|
|
25
|
+
const optionNames = cmd.options.map((o) => o.long);
|
|
26
|
+
expect(optionNames).toContain("--check");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("has --json option", () => {
|
|
30
|
+
const cmd = createUpgradeCommand();
|
|
31
|
+
const optionNames = cmd.options.map((o) => o.long);
|
|
32
|
+
expect(optionNames).toContain("--json");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("has --all option", () => {
|
|
36
|
+
const cmd = createUpgradeCommand();
|
|
37
|
+
const optionNames = cmd.options.map((o) => o.long);
|
|
38
|
+
expect(optionNames).toContain("--all");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("returns a Command instance", () => {
|
|
42
|
+
const cmd = createUpgradeCommand();
|
|
43
|
+
// Commander Command instances have a .parse method
|
|
44
|
+
expect(typeof cmd.parse).toBe("function");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: ov upgrade [--check] [--all] [--json]
|
|
3
|
+
*
|
|
4
|
+
* Upgrades overstory (and optionally all os-eco tools) to their latest npm versions.
|
|
5
|
+
* --check: Compare current vs latest without installing.
|
|
6
|
+
* --all: Upgrade all 4 ecosystem tools (overstory, mulch, seeds, canopy).
|
|
7
|
+
* --json: Output result as JSON envelope.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Command } from "commander";
|
|
11
|
+
import { jsonError, jsonOutput } from "../json.ts";
|
|
12
|
+
import { muted, printError, printHint, printSuccess, printWarning } from "../logging/color.ts";
|
|
13
|
+
|
|
14
|
+
const OVERSTORY_PACKAGE = "@os-eco/overstory-cli";
|
|
15
|
+
|
|
16
|
+
const ALL_PACKAGES = [
|
|
17
|
+
"@os-eco/overstory-cli",
|
|
18
|
+
"@os-eco/mulch-cli",
|
|
19
|
+
"@os-eco/seeds-cli",
|
|
20
|
+
"@os-eco/canopy-cli",
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
export interface UpgradeOptions {
|
|
24
|
+
check?: boolean;
|
|
25
|
+
all?: boolean;
|
|
26
|
+
json?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function getCurrentVersion(): Promise<string> {
|
|
30
|
+
const pkgPath = new URL("../../package.json", import.meta.url);
|
|
31
|
+
const pkg = JSON.parse(await Bun.file(pkgPath).text()) as { version: string };
|
|
32
|
+
return pkg.version;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function fetchLatestVersion(packageName: string): Promise<string> {
|
|
36
|
+
const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`);
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Failed to fetch npm registry for ${packageName}: ${res.status} ${res.statusText}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
const data = (await res.json()) as { version: string };
|
|
43
|
+
return data.version;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function runInstall(packageName: string): Promise<number> {
|
|
47
|
+
const proc = Bun.spawn(["bun", "install", "-g", `${packageName}@latest`], {
|
|
48
|
+
stdout: "inherit",
|
|
49
|
+
stderr: "inherit",
|
|
50
|
+
});
|
|
51
|
+
return proc.exited;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface PackageResult {
|
|
55
|
+
package: string;
|
|
56
|
+
current: string;
|
|
57
|
+
latest: string;
|
|
58
|
+
upToDate: boolean;
|
|
59
|
+
updated: boolean;
|
|
60
|
+
error?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function upgradePackage(packageName: string): Promise<PackageResult> {
|
|
64
|
+
let latest: string;
|
|
65
|
+
try {
|
|
66
|
+
latest = await fetchLatestVersion(packageName);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
69
|
+
return {
|
|
70
|
+
package: packageName,
|
|
71
|
+
current: "unknown",
|
|
72
|
+
latest: "unknown",
|
|
73
|
+
upToDate: false,
|
|
74
|
+
updated: false,
|
|
75
|
+
error,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const exitCode = await runInstall(packageName);
|
|
80
|
+
if (exitCode !== 0) {
|
|
81
|
+
return {
|
|
82
|
+
package: packageName,
|
|
83
|
+
current: "unknown",
|
|
84
|
+
latest,
|
|
85
|
+
upToDate: false,
|
|
86
|
+
updated: false,
|
|
87
|
+
error: `bun install failed with exit code ${exitCode}`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { package: packageName, current: "unknown", latest, upToDate: false, updated: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function executeUpgradeSingle(opts: UpgradeOptions): Promise<void> {
|
|
94
|
+
const json = opts.json ?? false;
|
|
95
|
+
const checkOnly = opts.check ?? false;
|
|
96
|
+
|
|
97
|
+
let current: string;
|
|
98
|
+
let latest: string;
|
|
99
|
+
try {
|
|
100
|
+
[current, latest] = await Promise.all([
|
|
101
|
+
getCurrentVersion(),
|
|
102
|
+
fetchLatestVersion(OVERSTORY_PACKAGE),
|
|
103
|
+
]);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
106
|
+
if (json) {
|
|
107
|
+
jsonError("upgrade", msg);
|
|
108
|
+
} else {
|
|
109
|
+
printError("Failed to check for updates", msg);
|
|
110
|
+
}
|
|
111
|
+
process.exitCode = 1;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const upToDate = current === latest;
|
|
116
|
+
|
|
117
|
+
if (checkOnly) {
|
|
118
|
+
if (json) {
|
|
119
|
+
jsonOutput("upgrade", { current, latest, upToDate });
|
|
120
|
+
} else if (upToDate) {
|
|
121
|
+
printSuccess("Already up to date", current);
|
|
122
|
+
} else {
|
|
123
|
+
printWarning(`Update available: ${current} → ${latest}`);
|
|
124
|
+
printHint("Run 'ov upgrade' to install the latest version");
|
|
125
|
+
process.exitCode = 1;
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (upToDate) {
|
|
131
|
+
if (json) {
|
|
132
|
+
jsonOutput("upgrade", { current, latest, upToDate: true, updated: false });
|
|
133
|
+
} else {
|
|
134
|
+
printSuccess("Already up to date", current);
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!json) {
|
|
140
|
+
process.stdout.write(
|
|
141
|
+
`${muted(`Upgrading ${OVERSTORY_PACKAGE} from ${current} to ${latest}...`)}\n`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const exitCode = await runInstall(OVERSTORY_PACKAGE);
|
|
146
|
+
|
|
147
|
+
if (exitCode !== 0) {
|
|
148
|
+
if (json) {
|
|
149
|
+
jsonError("upgrade", `bun install failed with exit code ${exitCode}`);
|
|
150
|
+
} else {
|
|
151
|
+
printError("Upgrade failed", `bun install exited with code ${exitCode}`);
|
|
152
|
+
}
|
|
153
|
+
process.exitCode = 1;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (json) {
|
|
158
|
+
jsonOutput("upgrade", { current, latest, upToDate: false, updated: true });
|
|
159
|
+
} else {
|
|
160
|
+
printSuccess("Upgraded to", latest);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function executeUpgradeAll(opts: UpgradeOptions): Promise<void> {
|
|
165
|
+
const json = opts.json ?? false;
|
|
166
|
+
const checkOnly = opts.check ?? false;
|
|
167
|
+
|
|
168
|
+
// Fetch all latest versions in parallel
|
|
169
|
+
const latestResults = await Promise.allSettled(
|
|
170
|
+
ALL_PACKAGES.map((pkg) => fetchLatestVersion(pkg)),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
if (checkOnly) {
|
|
174
|
+
// For --check --all, we just report status without installing
|
|
175
|
+
const results: Array<{ package: string; latest: string; error?: string }> = [];
|
|
176
|
+
let anyOutdated = false;
|
|
177
|
+
|
|
178
|
+
for (let i = 0; i < ALL_PACKAGES.length; i++) {
|
|
179
|
+
const pkg = ALL_PACKAGES[i] as string;
|
|
180
|
+
const result = latestResults[i];
|
|
181
|
+
if (result === undefined || result.status === "rejected") {
|
|
182
|
+
const error =
|
|
183
|
+
result?.status === "rejected"
|
|
184
|
+
? result.reason instanceof Error
|
|
185
|
+
? result.reason.message
|
|
186
|
+
: String(result.reason)
|
|
187
|
+
: "unknown error";
|
|
188
|
+
results.push({ package: pkg, latest: "unknown", error });
|
|
189
|
+
anyOutdated = true;
|
|
190
|
+
} else {
|
|
191
|
+
results.push({ package: pkg, latest: result.value });
|
|
192
|
+
// We can't easily get current versions for all tools without spawning them
|
|
193
|
+
// so for --all --check we just report latest versions available
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (json) {
|
|
198
|
+
jsonOutput("upgrade", { packages: results });
|
|
199
|
+
} else {
|
|
200
|
+
for (const r of results) {
|
|
201
|
+
if (r.error) {
|
|
202
|
+
printError(`${r.package}`, r.error);
|
|
203
|
+
} else {
|
|
204
|
+
process.stdout.write(` ${r.package} → ${r.latest}\n`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (anyOutdated) process.exitCode = 1;
|
|
208
|
+
}
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Install all packages
|
|
213
|
+
if (!json) {
|
|
214
|
+
process.stdout.write(`${muted("Upgrading all os-eco tools to latest...")}\n`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const results: PackageResult[] = await Promise.all(
|
|
218
|
+
ALL_PACKAGES.map((pkg) => upgradePackage(pkg)),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const anyError = results.some((r) => r.error !== undefined);
|
|
222
|
+
|
|
223
|
+
if (json) {
|
|
224
|
+
if (anyError) {
|
|
225
|
+
jsonError("upgrade", `One or more packages failed to upgrade`);
|
|
226
|
+
} else {
|
|
227
|
+
jsonOutput("upgrade", { packages: results, updated: true });
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
for (const r of results) {
|
|
231
|
+
if (r.error) {
|
|
232
|
+
printError(`Failed to upgrade ${r.package}`, r.error);
|
|
233
|
+
} else {
|
|
234
|
+
printSuccess(`Upgraded ${r.package} to`, r.latest);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (anyError) process.exitCode = 1;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function executeUpgrade(opts: UpgradeOptions): Promise<void> {
|
|
243
|
+
if (opts.all) {
|
|
244
|
+
await executeUpgradeAll(opts);
|
|
245
|
+
} else {
|
|
246
|
+
await executeUpgradeSingle(opts);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function createUpgradeCommand(): Command {
|
|
251
|
+
return new Command("upgrade")
|
|
252
|
+
.description("Upgrade overstory to the latest version from npm")
|
|
253
|
+
.option("--check", "Check for updates without installing")
|
|
254
|
+
.option("--all", "Upgrade all os-eco ecosystem tools")
|
|
255
|
+
.option("--json", "Output as JSON")
|
|
256
|
+
.action(async (opts: UpgradeOptions) => {
|
|
257
|
+
await executeUpgrade(opts);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
@@ -307,4 +307,42 @@ describe("checkDatabases", () => {
|
|
|
307
307
|
expect(integrityCheck?.status).toBe("fail");
|
|
308
308
|
expect(integrityCheck?.message).toContain("Failed to open or validate");
|
|
309
309
|
});
|
|
310
|
+
|
|
311
|
+
test("fix() enables WAL mode on database", () => {
|
|
312
|
+
// Create mail.db without WAL mode
|
|
313
|
+
const mailDb = new Database(join(tempDir, "mail.db"));
|
|
314
|
+
mailDb.exec(`
|
|
315
|
+
CREATE TABLE messages (
|
|
316
|
+
id TEXT PRIMARY KEY,
|
|
317
|
+
from_agent TEXT NOT NULL,
|
|
318
|
+
to_agent TEXT NOT NULL,
|
|
319
|
+
subject TEXT NOT NULL,
|
|
320
|
+
body TEXT NOT NULL,
|
|
321
|
+
type TEXT NOT NULL DEFAULT 'status',
|
|
322
|
+
priority TEXT NOT NULL DEFAULT 'normal',
|
|
323
|
+
thread_id TEXT,
|
|
324
|
+
payload TEXT,
|
|
325
|
+
read INTEGER NOT NULL DEFAULT 0,
|
|
326
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
327
|
+
)
|
|
328
|
+
`);
|
|
329
|
+
mailDb.close();
|
|
330
|
+
|
|
331
|
+
const checks = checkDatabases(mockConfig, tempDir) as DoctorCheck[];
|
|
332
|
+
|
|
333
|
+
const walCheck = checks.find((c) => c?.name === "mail.db WAL mode");
|
|
334
|
+
expect(walCheck?.status).toBe("warn");
|
|
335
|
+
expect(walCheck?.fix).toBeDefined();
|
|
336
|
+
|
|
337
|
+
const actions = walCheck?.fix?.();
|
|
338
|
+
expect(Array.isArray(actions)).toBe(true);
|
|
339
|
+
expect((actions as string[]).some((a) => a.includes("WAL mode"))).toBe(true);
|
|
340
|
+
expect((actions as string[]).some((a) => a.includes("mail.db"))).toBe(true);
|
|
341
|
+
|
|
342
|
+
// Verify WAL mode is now enabled
|
|
343
|
+
const verifyDb = new Database(join(tempDir, "mail.db"));
|
|
344
|
+
const journalMode = verifyDb.prepare<{ journal_mode: string }, []>("PRAGMA journal_mode").get();
|
|
345
|
+
verifyDb.close();
|
|
346
|
+
expect(journalMode?.journal_mode?.toLowerCase()).toBe("wal");
|
|
347
|
+
});
|
|
310
348
|
});
|
package/src/doctor/databases.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import type { DoctorCheck, DoctorCheckFn } from "./types.ts";
|
|
4
5
|
|
|
@@ -187,6 +188,12 @@ export const checkDatabases: DoctorCheckFn = (_config, overstoryDir): DoctorChec
|
|
|
187
188
|
message: `Database ${dbSpec.name} is not using WAL mode`,
|
|
188
189
|
details: ["WAL mode improves concurrent access performance"],
|
|
189
190
|
fixable: true,
|
|
191
|
+
fix: () => {
|
|
192
|
+
const fixDb = new Database(dbPath);
|
|
193
|
+
fixDb.exec("PRAGMA journal_mode=WAL");
|
|
194
|
+
fixDb.close();
|
|
195
|
+
return [`Enabled WAL mode on ${dbSpec.name}`];
|
|
196
|
+
},
|
|
190
197
|
});
|
|
191
198
|
} else {
|
|
192
199
|
checks.push({
|
|
@@ -222,13 +229,3 @@ export const checkDatabases: DoctorCheckFn = (_config, overstoryDir): DoctorChec
|
|
|
222
229
|
|
|
223
230
|
return checks;
|
|
224
231
|
};
|
|
225
|
-
|
|
226
|
-
/** Helper to check if file exists (synchronous). */
|
|
227
|
-
function existsSync(path: string): boolean {
|
|
228
|
-
try {
|
|
229
|
-
const { existsSync } = require("node:fs");
|
|
230
|
-
return existsSync(path);
|
|
231
|
-
} catch {
|
|
232
|
-
return false;
|
|
233
|
-
}
|
|
234
|
-
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the ecosystem doctor check module.
|
|
3
|
+
*
|
|
4
|
+
* We inject a mock spawner instead of using mock.module() to avoid cross-test
|
|
5
|
+
* leakage (see mulch record mx-56558b on why mock.module() is avoided).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import type { OverstoryConfig } from "../types.ts";
|
|
10
|
+
import { makeCheckEcosystem, parseSemver } from "./ecosystem.ts";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Minimal config fixture
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const mockConfig: OverstoryConfig = {
|
|
17
|
+
project: {
|
|
18
|
+
name: "test-project",
|
|
19
|
+
root: "/tmp/test",
|
|
20
|
+
canonicalBranch: "main",
|
|
21
|
+
},
|
|
22
|
+
agents: {
|
|
23
|
+
manifestPath: "/tmp/.overstory/agent-manifest.json",
|
|
24
|
+
baseDir: "/tmp/.overstory/agents",
|
|
25
|
+
maxConcurrent: 5,
|
|
26
|
+
staggerDelayMs: 1000,
|
|
27
|
+
maxDepth: 2,
|
|
28
|
+
maxSessionsPerRun: 0,
|
|
29
|
+
},
|
|
30
|
+
worktrees: {
|
|
31
|
+
baseDir: "/tmp/.overstory/worktrees",
|
|
32
|
+
},
|
|
33
|
+
taskTracker: {
|
|
34
|
+
backend: "auto",
|
|
35
|
+
enabled: false,
|
|
36
|
+
},
|
|
37
|
+
mulch: {
|
|
38
|
+
enabled: false,
|
|
39
|
+
domains: [],
|
|
40
|
+
primeFormat: "markdown",
|
|
41
|
+
},
|
|
42
|
+
merge: {
|
|
43
|
+
aiResolveEnabled: false,
|
|
44
|
+
reimagineEnabled: false,
|
|
45
|
+
},
|
|
46
|
+
providers: {
|
|
47
|
+
anthropic: { type: "native" },
|
|
48
|
+
},
|
|
49
|
+
watchdog: {
|
|
50
|
+
tier0Enabled: false,
|
|
51
|
+
tier0IntervalMs: 30000,
|
|
52
|
+
tier1Enabled: false,
|
|
53
|
+
tier2Enabled: false,
|
|
54
|
+
staleThresholdMs: 300000,
|
|
55
|
+
zombieThresholdMs: 600000,
|
|
56
|
+
nudgeIntervalMs: 60000,
|
|
57
|
+
},
|
|
58
|
+
models: {},
|
|
59
|
+
logging: {
|
|
60
|
+
verbose: false,
|
|
61
|
+
redactSecrets: true,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Mock spawner helpers
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
type SpawnResponse = { exitCode: number; stdout: string; stderr: string };
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build a mock spawner that dispatches by binary name (first arg).
|
|
73
|
+
*/
|
|
74
|
+
function makeMockSpawner(responses: Record<string, SpawnResponse>) {
|
|
75
|
+
return async (args: string[]): Promise<SpawnResponse> => {
|
|
76
|
+
const bin = args[0] ?? "";
|
|
77
|
+
return (
|
|
78
|
+
responses[bin] ?? {
|
|
79
|
+
exitCode: 127,
|
|
80
|
+
stdout: "",
|
|
81
|
+
stderr: `${bin}: command not found`,
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// parseSemver unit tests
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
describe("parseSemver", () => {
|
|
92
|
+
test("extracts bare semver", () => {
|
|
93
|
+
expect(parseSemver("1.2.3")).toBe("1.2.3");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("extracts semver from prefixed output", () => {
|
|
97
|
+
expect(parseSemver("mulch v1.0.0")).toBe("1.0.0");
|
|
98
|
+
expect(parseSemver("seeds version 2.3.4")).toBe("2.3.4");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("extracts semver with prerelease", () => {
|
|
102
|
+
expect(parseSemver("1.2.3-alpha.1")).toBe("1.2.3-alpha.1");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("extracts semver with build metadata", () => {
|
|
106
|
+
expect(parseSemver("1.2.3+build.42")).toBe("1.2.3+build.42");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("returns null for non-semver strings", () => {
|
|
110
|
+
expect(parseSemver("not-a-version")).toBeNull();
|
|
111
|
+
expect(parseSemver("")).toBeNull();
|
|
112
|
+
expect(parseSemver("v2.0")).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("extracts first semver when multiple exist", () => {
|
|
116
|
+
expect(parseSemver("tool 1.0.0 requires node 18.0.0")).toBe("1.0.0");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// checkEcosystem integration tests
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
describe("checkEcosystem", () => {
|
|
125
|
+
test("returns exactly 3 checks (one per tool)", async () => {
|
|
126
|
+
const spawner = makeMockSpawner({
|
|
127
|
+
ml: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
128
|
+
sd: { exitCode: 0, stdout: "2.0.0\n", stderr: "" },
|
|
129
|
+
cn: { exitCode: 0, stdout: "3.0.0\n", stderr: "" },
|
|
130
|
+
});
|
|
131
|
+
const check = makeCheckEcosystem(spawner);
|
|
132
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
133
|
+
|
|
134
|
+
expect(results).toHaveLength(3);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("check names match tool names", async () => {
|
|
138
|
+
const spawner = makeMockSpawner({
|
|
139
|
+
ml: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
140
|
+
sd: { exitCode: 0, stdout: "2.0.0\n", stderr: "" },
|
|
141
|
+
cn: { exitCode: 0, stdout: "3.0.0\n", stderr: "" },
|
|
142
|
+
});
|
|
143
|
+
const check = makeCheckEcosystem(spawner);
|
|
144
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
145
|
+
|
|
146
|
+
const names = results.map((r) => r.name);
|
|
147
|
+
expect(names).toContain("mulch semver");
|
|
148
|
+
expect(names).toContain("seeds semver");
|
|
149
|
+
expect(names).toContain("canopy semver");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("all checks report category 'ecosystem'", async () => {
|
|
153
|
+
const spawner = makeMockSpawner({
|
|
154
|
+
ml: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
155
|
+
sd: { exitCode: 0, stdout: "2.0.0\n", stderr: "" },
|
|
156
|
+
cn: { exitCode: 0, stdout: "3.0.0\n", stderr: "" },
|
|
157
|
+
});
|
|
158
|
+
const check = makeCheckEcosystem(spawner);
|
|
159
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
160
|
+
|
|
161
|
+
for (const r of results) {
|
|
162
|
+
expect(r.category).toBe("ecosystem");
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("pass when all tools report valid semver", async () => {
|
|
167
|
+
const spawner = makeMockSpawner({
|
|
168
|
+
ml: { exitCode: 0, stdout: "mulch v1.2.3\n", stderr: "" },
|
|
169
|
+
sd: { exitCode: 0, stdout: "seeds 0.5.0\n", stderr: "" },
|
|
170
|
+
cn: { exitCode: 0, stdout: "0.1.0\n", stderr: "" },
|
|
171
|
+
});
|
|
172
|
+
const check = makeCheckEcosystem(spawner);
|
|
173
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
174
|
+
|
|
175
|
+
for (const r of results) {
|
|
176
|
+
expect(r.status).toBe("pass");
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("warn when a tool is not available (non-zero exit code)", async () => {
|
|
181
|
+
const spawner = makeMockSpawner({
|
|
182
|
+
ml: { exitCode: 127, stdout: "", stderr: "ml: command not found" },
|
|
183
|
+
sd: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
184
|
+
cn: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
185
|
+
});
|
|
186
|
+
const check = makeCheckEcosystem(spawner);
|
|
187
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
188
|
+
|
|
189
|
+
const mulch = results.find((r) => r.name === "mulch semver");
|
|
190
|
+
expect(mulch?.status).toBe("warn");
|
|
191
|
+
expect(mulch?.fixable).toBe(true);
|
|
192
|
+
expect(typeof mulch?.fix).toBe("function");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("warn when version output is not valid semver", async () => {
|
|
196
|
+
const spawner = makeMockSpawner({
|
|
197
|
+
ml: { exitCode: 0, stdout: "mulch dev-build\n", stderr: "" },
|
|
198
|
+
sd: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
199
|
+
cn: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
200
|
+
});
|
|
201
|
+
const check = makeCheckEcosystem(spawner);
|
|
202
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
203
|
+
|
|
204
|
+
const mulch = results.find((r) => r.name === "mulch semver");
|
|
205
|
+
expect(mulch?.status).toBe("warn");
|
|
206
|
+
expect(mulch?.message).toContain("not parseable semver");
|
|
207
|
+
expect(mulch?.fixable).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("passing checks include version in message", async () => {
|
|
211
|
+
const spawner = makeMockSpawner({
|
|
212
|
+
ml: { exitCode: 0, stdout: "1.2.3\n", stderr: "" },
|
|
213
|
+
sd: { exitCode: 0, stdout: "1.2.3\n", stderr: "" },
|
|
214
|
+
cn: { exitCode: 0, stdout: "1.2.3\n", stderr: "" },
|
|
215
|
+
});
|
|
216
|
+
const check = makeCheckEcosystem(spawner);
|
|
217
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
218
|
+
|
|
219
|
+
for (const r of results) {
|
|
220
|
+
expect(r.status).toBe("pass");
|
|
221
|
+
expect(r.message).toContain("1.2.3");
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("passing checks include raw output in details", async () => {
|
|
226
|
+
const spawner = makeMockSpawner({
|
|
227
|
+
ml: { exitCode: 0, stdout: "mulch v1.0.0\n", stderr: "" },
|
|
228
|
+
sd: { exitCode: 0, stdout: "seeds 1.0.0\n", stderr: "" },
|
|
229
|
+
cn: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
230
|
+
});
|
|
231
|
+
const check = makeCheckEcosystem(spawner);
|
|
232
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
233
|
+
|
|
234
|
+
const mulch = results.find((r) => r.name === "mulch semver");
|
|
235
|
+
expect(mulch?.details).toContain("mulch v1.0.0");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("unavailable tool details include install hint", async () => {
|
|
239
|
+
const spawner = makeMockSpawner({
|
|
240
|
+
ml: { exitCode: 127, stdout: "", stderr: "not found" },
|
|
241
|
+
sd: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
242
|
+
cn: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
243
|
+
});
|
|
244
|
+
const check = makeCheckEcosystem(spawner);
|
|
245
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
246
|
+
|
|
247
|
+
const mulch = results.find((r) => r.name === "mulch semver");
|
|
248
|
+
const hasHint = mulch?.details?.some((d) => d.includes("@os-eco/mulch-cli"));
|
|
249
|
+
expect(hasHint).toBe(true);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("all checks have required DoctorCheck fields", async () => {
|
|
253
|
+
const spawner = makeMockSpawner({
|
|
254
|
+
ml: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
255
|
+
sd: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
256
|
+
cn: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
257
|
+
});
|
|
258
|
+
const check = makeCheckEcosystem(spawner);
|
|
259
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
260
|
+
|
|
261
|
+
for (const r of results) {
|
|
262
|
+
expect(typeof r.name).toBe("string");
|
|
263
|
+
expect(r.name.length).toBeGreaterThan(0);
|
|
264
|
+
expect(r.category).toBe("ecosystem");
|
|
265
|
+
expect(["pass", "warn", "fail"]).toContain(r.status);
|
|
266
|
+
expect(typeof r.message).toBe("string");
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("failing checks are marked fixable and have a fix closure", async () => {
|
|
271
|
+
const spawner = makeMockSpawner({}); // all tools unavailable
|
|
272
|
+
const check = makeCheckEcosystem(spawner);
|
|
273
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
274
|
+
|
|
275
|
+
for (const r of results) {
|
|
276
|
+
expect(r.status).toBe("warn");
|
|
277
|
+
expect(r.fixable).toBe(true);
|
|
278
|
+
expect(typeof r.fix).toBe("function");
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("handles version in stderr when stdout is empty", async () => {
|
|
283
|
+
const spawner = makeMockSpawner({
|
|
284
|
+
ml: { exitCode: 0, stdout: "", stderr: "1.0.0" },
|
|
285
|
+
sd: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
286
|
+
cn: { exitCode: 0, stdout: "1.0.0\n", stderr: "" },
|
|
287
|
+
});
|
|
288
|
+
const check = makeCheckEcosystem(spawner);
|
|
289
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
290
|
+
|
|
291
|
+
const mulch = results.find((r) => r.name === "mulch semver");
|
|
292
|
+
expect(mulch?.status).toBe("pass");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("handles spawn exception gracefully", async () => {
|
|
296
|
+
const errorSpawner = async (_args: string[]) => {
|
|
297
|
+
throw new Error("spawn failed");
|
|
298
|
+
};
|
|
299
|
+
const check = makeCheckEcosystem(errorSpawner);
|
|
300
|
+
const results = await check(mockConfig, "/tmp/.overstory");
|
|
301
|
+
|
|
302
|
+
expect(results).toHaveLength(3);
|
|
303
|
+
for (const r of results) {
|
|
304
|
+
expect(r.status).toBe("warn");
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
});
|