@os-eco/overstory-cli 0.7.4 → 0.7.6
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 +10 -8
- package/package.json +1 -1
- package/src/commands/agents.ts +21 -3
- package/src/commands/completions.ts +7 -1
- package/src/commands/coordinator.test.ts +3 -1
- package/src/commands/coordinator.ts +6 -3
- package/src/commands/costs.test.ts +45 -2
- package/src/commands/costs.ts +42 -13
- package/src/commands/doctor.ts +3 -1
- package/src/commands/init.test.ts +366 -27
- package/src/commands/init.ts +194 -2
- package/src/commands/monitor.ts +4 -3
- package/src/commands/supervisor.ts +4 -3
- package/src/doctor/providers.test.ts +373 -0
- package/src/doctor/providers.ts +250 -0
- package/src/doctor/types.ts +2 -1
- package/src/e2e/init-sling-lifecycle.test.ts +12 -7
- package/src/index.ts +11 -2
- package/src/metrics/pricing.ts +57 -2
- package/src/metrics/store.test.ts +38 -0
- package/src/metrics/store.ts +10 -0
- package/src/metrics/transcript.test.ts +84 -2
- package/src/metrics/transcript.ts +1 -1
- package/src/runtimes/claude.test.ts +40 -0
- package/src/runtimes/claude.ts +8 -1
- package/src/runtimes/copilot.test.ts +507 -0
- package/src/runtimes/copilot.ts +226 -0
- package/src/runtimes/pi.test.ts +28 -0
- package/src/runtimes/pi.ts +5 -1
- package/src/runtimes/registry.test.ts +20 -0
- package/src/runtimes/registry.ts +2 -0
- package/src/runtimes/types.ts +2 -0
|
@@ -2,15 +2,22 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { readdir } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { cleanupTempDir, createTempGitRepo, runGitInDir } from "../test-helpers.ts";
|
|
5
|
-
import {
|
|
5
|
+
import type { Spawner } from "./init.ts";
|
|
6
|
+
import { initCommand, OVERSTORY_GITIGNORE, OVERSTORY_README, resolveToolSet } from "./init.ts";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Tests for `overstory init` -- agent definition deployment.
|
|
9
10
|
*
|
|
10
11
|
* Uses real temp git repos. Suppresses stdout to keep test output clean.
|
|
11
12
|
* process.cwd() is saved/restored because initCommand uses it to find the project root.
|
|
13
|
+
*
|
|
14
|
+
* Tests that don't exercise ecosystem bootstrap pass a no-op spawner via _spawner
|
|
15
|
+
* so they don't require ml/sd/cn CLIs to be installed (they aren't available in CI).
|
|
12
16
|
*/
|
|
13
17
|
|
|
18
|
+
/** No-op spawner that treats all ecosystem tools as "not installed". */
|
|
19
|
+
const noopSpawner: Spawner = async () => ({ exitCode: 1, stdout: "", stderr: "not found" });
|
|
20
|
+
|
|
14
21
|
const AGENT_DEF_FILES = [
|
|
15
22
|
"scout.md",
|
|
16
23
|
"builder.md",
|
|
@@ -46,7 +53,7 @@ describe("initCommand: agent-defs deployment", () => {
|
|
|
46
53
|
});
|
|
47
54
|
|
|
48
55
|
test("creates .overstory/agent-defs/ with all 7 agent definition files (supervisor deprecated)", async () => {
|
|
49
|
-
await initCommand({});
|
|
56
|
+
await initCommand({ _spawner: noopSpawner });
|
|
50
57
|
|
|
51
58
|
const agentDefsDir = join(tempDir, ".overstory", "agent-defs");
|
|
52
59
|
const files = await readdir(agentDefsDir);
|
|
@@ -56,7 +63,7 @@ describe("initCommand: agent-defs deployment", () => {
|
|
|
56
63
|
});
|
|
57
64
|
|
|
58
65
|
test("copied files match source content", async () => {
|
|
59
|
-
await initCommand({});
|
|
66
|
+
await initCommand({ _spawner: noopSpawner });
|
|
60
67
|
|
|
61
68
|
for (const fileName of AGENT_DEF_FILES) {
|
|
62
69
|
const sourcePath = join(SOURCE_AGENTS_DIR, fileName);
|
|
@@ -71,7 +78,7 @@ describe("initCommand: agent-defs deployment", () => {
|
|
|
71
78
|
|
|
72
79
|
test("--force reinit overwrites existing agent def files", async () => {
|
|
73
80
|
// First init
|
|
74
|
-
await initCommand({});
|
|
81
|
+
await initCommand({ _spawner: noopSpawner });
|
|
75
82
|
|
|
76
83
|
// Tamper with one of the deployed files
|
|
77
84
|
const tamperPath = join(tempDir, ".overstory", "agent-defs", "scout.md");
|
|
@@ -82,7 +89,7 @@ describe("initCommand: agent-defs deployment", () => {
|
|
|
82
89
|
expect(tampered).toBe("# tampered content\n");
|
|
83
90
|
|
|
84
91
|
// Reinit with --force
|
|
85
|
-
await initCommand({ force: true });
|
|
92
|
+
await initCommand({ force: true, _spawner: noopSpawner });
|
|
86
93
|
|
|
87
94
|
// Verify the file was overwritten with the original source
|
|
88
95
|
const sourceContent = await Bun.file(join(SOURCE_AGENTS_DIR, "scout.md")).text();
|
|
@@ -91,7 +98,7 @@ describe("initCommand: agent-defs deployment", () => {
|
|
|
91
98
|
});
|
|
92
99
|
|
|
93
100
|
test("Stop hook includes mulch learn command", async () => {
|
|
94
|
-
await initCommand({});
|
|
101
|
+
await initCommand({ _spawner: noopSpawner });
|
|
95
102
|
|
|
96
103
|
const hooksPath = join(tempDir, ".overstory", "hooks.json");
|
|
97
104
|
const content = await Bun.file(hooksPath).text();
|
|
@@ -104,7 +111,7 @@ describe("initCommand: agent-defs deployment", () => {
|
|
|
104
111
|
});
|
|
105
112
|
|
|
106
113
|
test("PostToolUse hooks include Bash-matched mulch diff hook", async () => {
|
|
107
|
-
await initCommand({});
|
|
114
|
+
await initCommand({ _spawner: noopSpawner });
|
|
108
115
|
|
|
109
116
|
const hooksPath = join(tempDir, ".overstory", "hooks.json");
|
|
110
117
|
const content = await Bun.file(hooksPath).text();
|
|
@@ -146,7 +153,7 @@ describe("initCommand: .overstory/.gitignore", () => {
|
|
|
146
153
|
});
|
|
147
154
|
|
|
148
155
|
test("creates .overstory/.gitignore with wildcard+whitelist model", async () => {
|
|
149
|
-
await initCommand({});
|
|
156
|
+
await initCommand({ _spawner: noopSpawner });
|
|
150
157
|
|
|
151
158
|
const gitignorePath = join(tempDir, ".overstory", ".gitignore");
|
|
152
159
|
const content = await Bun.file(gitignorePath).text();
|
|
@@ -166,7 +173,7 @@ describe("initCommand: .overstory/.gitignore", () => {
|
|
|
166
173
|
|
|
167
174
|
test("gitignore is always written when init completes", async () => {
|
|
168
175
|
// Init should write gitignore
|
|
169
|
-
await initCommand({});
|
|
176
|
+
await initCommand({ _spawner: noopSpawner });
|
|
170
177
|
|
|
171
178
|
const gitignorePath = join(tempDir, ".overstory", ".gitignore");
|
|
172
179
|
const content = await Bun.file(gitignorePath).text();
|
|
@@ -181,7 +188,7 @@ describe("initCommand: .overstory/.gitignore", () => {
|
|
|
181
188
|
|
|
182
189
|
test("--force reinit overwrites stale .overstory/.gitignore", async () => {
|
|
183
190
|
// First init
|
|
184
|
-
await initCommand({});
|
|
191
|
+
await initCommand({ _spawner: noopSpawner });
|
|
185
192
|
|
|
186
193
|
const gitignorePath = join(tempDir, ".overstory", ".gitignore");
|
|
187
194
|
|
|
@@ -194,7 +201,7 @@ describe("initCommand: .overstory/.gitignore", () => {
|
|
|
194
201
|
expect(tampered).not.toContain("!.gitignore\n");
|
|
195
202
|
|
|
196
203
|
// Reinit with --force
|
|
197
|
-
await initCommand({ force: true });
|
|
204
|
+
await initCommand({ force: true, _spawner: noopSpawner });
|
|
198
205
|
|
|
199
206
|
// Verify the file was overwritten with the new wildcard+whitelist format
|
|
200
207
|
const restored = await Bun.file(gitignorePath).text();
|
|
@@ -205,7 +212,7 @@ describe("initCommand: .overstory/.gitignore", () => {
|
|
|
205
212
|
|
|
206
213
|
test("subsequent init without --force does not overwrite gitignore", async () => {
|
|
207
214
|
// First init
|
|
208
|
-
await initCommand({});
|
|
215
|
+
await initCommand({ _spawner: noopSpawner });
|
|
209
216
|
|
|
210
217
|
const gitignorePath = join(tempDir, ".overstory", ".gitignore");
|
|
211
218
|
|
|
@@ -217,7 +224,7 @@ describe("initCommand: .overstory/.gitignore", () => {
|
|
|
217
224
|
expect(tampered).toBe("# custom content\n");
|
|
218
225
|
|
|
219
226
|
// Second init without --force should return early (not overwrite)
|
|
220
|
-
await initCommand({});
|
|
227
|
+
await initCommand({ _spawner: noopSpawner });
|
|
221
228
|
|
|
222
229
|
// Verify the file was NOT overwritten (early return prevented it)
|
|
223
230
|
const afterSecondInit = await Bun.file(gitignorePath).text();
|
|
@@ -247,7 +254,7 @@ describe("initCommand: .overstory/README.md", () => {
|
|
|
247
254
|
});
|
|
248
255
|
|
|
249
256
|
test("creates .overstory/README.md with expected content", async () => {
|
|
250
|
-
await initCommand({});
|
|
257
|
+
await initCommand({ _spawner: noopSpawner });
|
|
251
258
|
|
|
252
259
|
const readmePath = join(tempDir, ".overstory", "README.md");
|
|
253
260
|
const exists = await Bun.file(readmePath).exists();
|
|
@@ -263,7 +270,7 @@ describe("initCommand: .overstory/README.md", () => {
|
|
|
263
270
|
|
|
264
271
|
test("--force reinit overwrites README.md", async () => {
|
|
265
272
|
// First init
|
|
266
|
-
await initCommand({});
|
|
273
|
+
await initCommand({ _spawner: noopSpawner });
|
|
267
274
|
|
|
268
275
|
const readmePath = join(tempDir, ".overstory", "README.md");
|
|
269
276
|
|
|
@@ -273,7 +280,7 @@ describe("initCommand: .overstory/README.md", () => {
|
|
|
273
280
|
expect(tampered).toBe("# tampered\n");
|
|
274
281
|
|
|
275
282
|
// Reinit with --force
|
|
276
|
-
await initCommand({ force: true });
|
|
283
|
+
await initCommand({ force: true, _spawner: noopSpawner });
|
|
277
284
|
|
|
278
285
|
// Verify restored to canonical content
|
|
279
286
|
const restored = await Bun.file(readmePath).text();
|
|
@@ -282,7 +289,7 @@ describe("initCommand: .overstory/README.md", () => {
|
|
|
282
289
|
|
|
283
290
|
test("subsequent init without --force does not overwrite README.md", async () => {
|
|
284
291
|
// First init
|
|
285
|
-
await initCommand({});
|
|
292
|
+
await initCommand({ _spawner: noopSpawner });
|
|
286
293
|
|
|
287
294
|
const readmePath = join(tempDir, ".overstory", "README.md");
|
|
288
295
|
|
|
@@ -292,7 +299,7 @@ describe("initCommand: .overstory/README.md", () => {
|
|
|
292
299
|
expect(tampered).toBe("# custom content\n");
|
|
293
300
|
|
|
294
301
|
// Second init without --force returns early
|
|
295
|
-
await initCommand({});
|
|
302
|
+
await initCommand({ _spawner: noopSpawner });
|
|
296
303
|
|
|
297
304
|
// Verify tampered content preserved (early return)
|
|
298
305
|
const afterSecondInit = await Bun.file(readmePath).text();
|
|
@@ -328,7 +335,7 @@ describe("initCommand: canonical branch detection", () => {
|
|
|
328
335
|
// Switch to a non-standard branch name
|
|
329
336
|
await runGitInDir(tempDir, ["switch", "-c", "trunk"]);
|
|
330
337
|
|
|
331
|
-
await initCommand({});
|
|
338
|
+
await initCommand({ _spawner: noopSpawner });
|
|
332
339
|
|
|
333
340
|
const configPath = join(tempDir, ".overstory", "config.yaml");
|
|
334
341
|
const content = await Bun.file(configPath).text();
|
|
@@ -337,7 +344,7 @@ describe("initCommand: canonical branch detection", () => {
|
|
|
337
344
|
|
|
338
345
|
test("standard branch names (main) still work as canonicalBranch", async () => {
|
|
339
346
|
// createTempGitRepo defaults to main branch
|
|
340
|
-
await initCommand({});
|
|
347
|
+
await initCommand({ _spawner: noopSpawner });
|
|
341
348
|
|
|
342
349
|
const configPath = join(tempDir, ".overstory", "config.yaml");
|
|
343
350
|
const content = await Bun.file(configPath).text();
|
|
@@ -368,14 +375,14 @@ describe("initCommand: --yes flag", () => {
|
|
|
368
375
|
|
|
369
376
|
test("--yes reinitializes when .overstory/ already exists", async () => {
|
|
370
377
|
// First init
|
|
371
|
-
await initCommand({});
|
|
378
|
+
await initCommand({ _spawner: noopSpawner });
|
|
372
379
|
|
|
373
380
|
// Tamper with config to verify reinit happens
|
|
374
381
|
const configPath = join(tempDir, ".overstory", "config.yaml");
|
|
375
382
|
await Bun.write(configPath, "# tampered\n");
|
|
376
383
|
|
|
377
384
|
// Second init with --yes should reinitialize (not return early)
|
|
378
|
-
await initCommand({ yes: true });
|
|
385
|
+
await initCommand({ yes: true, _spawner: noopSpawner });
|
|
379
386
|
|
|
380
387
|
// Verify config was regenerated (not the tampered content)
|
|
381
388
|
const content = await Bun.file(configPath).text();
|
|
@@ -384,7 +391,7 @@ describe("initCommand: --yes flag", () => {
|
|
|
384
391
|
});
|
|
385
392
|
|
|
386
393
|
test("--yes works on fresh project (no .overstory/ yet)", async () => {
|
|
387
|
-
await initCommand({ yes: true });
|
|
394
|
+
await initCommand({ yes: true, _spawner: noopSpawner });
|
|
388
395
|
|
|
389
396
|
const configPath = join(tempDir, ".overstory", "config.yaml");
|
|
390
397
|
const exists = await Bun.file(configPath).exists();
|
|
@@ -396,14 +403,14 @@ describe("initCommand: --yes flag", () => {
|
|
|
396
403
|
|
|
397
404
|
test("--yes overwrites agent-defs on reinit", async () => {
|
|
398
405
|
// First init
|
|
399
|
-
await initCommand({});
|
|
406
|
+
await initCommand({ _spawner: noopSpawner });
|
|
400
407
|
|
|
401
408
|
// Tamper with an agent def
|
|
402
409
|
const scoutPath = join(tempDir, ".overstory", "agent-defs", "scout.md");
|
|
403
410
|
await Bun.write(scoutPath, "TAMPERED CONTENT");
|
|
404
411
|
|
|
405
412
|
// Reinit with --yes should overwrite
|
|
406
|
-
await initCommand({ yes: true });
|
|
413
|
+
await initCommand({ yes: true, _spawner: noopSpawner });
|
|
407
414
|
|
|
408
415
|
const restored = await Bun.file(scoutPath).text();
|
|
409
416
|
expect(restored).not.toBe("TAMPERED CONTENT");
|
|
@@ -432,7 +439,7 @@ describe("initCommand: --name flag", () => {
|
|
|
432
439
|
});
|
|
433
440
|
|
|
434
441
|
test("--name overrides auto-detected project name", async () => {
|
|
435
|
-
await initCommand({ name: "custom-project" });
|
|
442
|
+
await initCommand({ name: "custom-project", _spawner: noopSpawner });
|
|
436
443
|
|
|
437
444
|
const configPath = join(tempDir, ".overstory", "config.yaml");
|
|
438
445
|
const content = await Bun.file(configPath).text();
|
|
@@ -440,7 +447,7 @@ describe("initCommand: --name flag", () => {
|
|
|
440
447
|
});
|
|
441
448
|
|
|
442
449
|
test("--name combined with --yes works for fully non-interactive init", async () => {
|
|
443
|
-
await initCommand({ yes: true, name: "scripted-project" });
|
|
450
|
+
await initCommand({ yes: true, name: "scripted-project", _spawner: noopSpawner });
|
|
444
451
|
|
|
445
452
|
const configPath = join(tempDir, ".overstory", "config.yaml");
|
|
446
453
|
const content = await Bun.file(configPath).text();
|
|
@@ -448,3 +455,335 @@ describe("initCommand: --name flag", () => {
|
|
|
448
455
|
expect(content).toContain("# Overstory configuration");
|
|
449
456
|
});
|
|
450
457
|
});
|
|
458
|
+
|
|
459
|
+
// ---- Ecosystem Bootstrap Tests ----
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Build a Spawner that returns preset responses keyed by "arg0 arg1 ..." prefix.
|
|
463
|
+
* Records all calls for assertion.
|
|
464
|
+
*/
|
|
465
|
+
function createMockSpawner(
|
|
466
|
+
responses: Record<string, { exitCode: number; stdout: string; stderr: string }>,
|
|
467
|
+
): {
|
|
468
|
+
spawner: Spawner;
|
|
469
|
+
calls: string[][];
|
|
470
|
+
} {
|
|
471
|
+
const calls: string[][] = [];
|
|
472
|
+
const spawner: Spawner = async (args) => {
|
|
473
|
+
calls.push(args);
|
|
474
|
+
const key = args.join(" ");
|
|
475
|
+
// Longest prefix match
|
|
476
|
+
let bestMatch = "";
|
|
477
|
+
let bestResponse = { exitCode: 1, stdout: "", stderr: "not found" };
|
|
478
|
+
for (const [pattern, response] of Object.entries(responses)) {
|
|
479
|
+
if (key.startsWith(pattern) && pattern.length > bestMatch.length) {
|
|
480
|
+
bestMatch = pattern;
|
|
481
|
+
bestResponse = response;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return bestResponse;
|
|
485
|
+
};
|
|
486
|
+
return { spawner, calls };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
describe("resolveToolSet", () => {
|
|
490
|
+
test("default (no opts) returns all three tools in order", () => {
|
|
491
|
+
const tools = resolveToolSet({});
|
|
492
|
+
expect(tools.map((t) => t.name)).toEqual(["mulch", "seeds", "canopy"]);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("--skip-mulch removes mulch", () => {
|
|
496
|
+
const tools = resolveToolSet({ skipMulch: true });
|
|
497
|
+
expect(tools.map((t) => t.name)).toEqual(["seeds", "canopy"]);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("--skip-seeds removes seeds", () => {
|
|
501
|
+
const tools = resolveToolSet({ skipSeeds: true });
|
|
502
|
+
expect(tools.map((t) => t.name)).toEqual(["mulch", "canopy"]);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("--skip-canopy removes canopy", () => {
|
|
506
|
+
const tools = resolveToolSet({ skipCanopy: true });
|
|
507
|
+
expect(tools.map((t) => t.name)).toEqual(["mulch", "seeds"]);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("multiple skip flags combine", () => {
|
|
511
|
+
const tools = resolveToolSet({ skipMulch: true, skipSeeds: true });
|
|
512
|
+
expect(tools.map((t) => t.name)).toEqual(["canopy"]);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("--tools overrides to specific tools", () => {
|
|
516
|
+
const tools = resolveToolSet({ tools: "mulch,seeds" });
|
|
517
|
+
expect(tools.map((t) => t.name)).toEqual(["mulch", "seeds"]);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test("--tools single tool", () => {
|
|
521
|
+
const tools = resolveToolSet({ tools: "canopy" });
|
|
522
|
+
expect(tools.map((t) => t.name)).toEqual(["canopy"]);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("--tools with unknown name filters it out", () => {
|
|
526
|
+
const tools = resolveToolSet({ tools: "mulch,unknown" });
|
|
527
|
+
expect(tools.map((t) => t.name)).toEqual(["mulch"]);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test("--tools overrides skip flags", () => {
|
|
531
|
+
// --tools takes precedence over --skip-* flags
|
|
532
|
+
const tools = resolveToolSet({ tools: "mulch", skipMulch: true });
|
|
533
|
+
expect(tools.map((t) => t.name)).toEqual(["mulch"]);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("all skip flags returns empty array", () => {
|
|
537
|
+
const tools = resolveToolSet({ skipMulch: true, skipSeeds: true, skipCanopy: true });
|
|
538
|
+
expect(tools).toHaveLength(0);
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
describe("initCommand: ecosystem bootstrap", () => {
|
|
543
|
+
let tempDir: string;
|
|
544
|
+
let originalCwd: string;
|
|
545
|
+
let originalWrite: typeof process.stdout.write;
|
|
546
|
+
|
|
547
|
+
beforeEach(async () => {
|
|
548
|
+
tempDir = await createTempGitRepo();
|
|
549
|
+
originalCwd = process.cwd();
|
|
550
|
+
process.chdir(tempDir);
|
|
551
|
+
originalWrite = process.stdout.write;
|
|
552
|
+
process.stdout.write = (() => true) as typeof process.stdout.write;
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
afterEach(async () => {
|
|
556
|
+
process.chdir(originalCwd);
|
|
557
|
+
process.stdout.write = originalWrite;
|
|
558
|
+
await cleanupTempDir(tempDir);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("all tools installed and init succeeds → status initialized", async () => {
|
|
562
|
+
const { spawner, calls } = createMockSpawner({
|
|
563
|
+
"ml --version": { exitCode: 0, stdout: "0.6.3", stderr: "" },
|
|
564
|
+
"ml init": { exitCode: 0, stdout: "initialized", stderr: "" },
|
|
565
|
+
"ml onboard": { exitCode: 0, stdout: "appended", stderr: "" },
|
|
566
|
+
"sd --version": { exitCode: 0, stdout: "0.2.4", stderr: "" },
|
|
567
|
+
"sd init": { exitCode: 0, stdout: "initialized", stderr: "" },
|
|
568
|
+
"sd onboard": { exitCode: 0, stdout: "appended", stderr: "" },
|
|
569
|
+
"cn --version": { exitCode: 0, stdout: "0.2.0", stderr: "" },
|
|
570
|
+
"cn init": { exitCode: 0, stdout: "initialized", stderr: "" },
|
|
571
|
+
"cn onboard": { exitCode: 0, stdout: "appended", stderr: "" },
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
await initCommand({ _spawner: spawner });
|
|
575
|
+
|
|
576
|
+
// All three init commands were called
|
|
577
|
+
expect(calls).toContainEqual(["ml", "init"]);
|
|
578
|
+
expect(calls).toContainEqual(["sd", "init"]);
|
|
579
|
+
expect(calls).toContainEqual(["cn", "init"]);
|
|
580
|
+
|
|
581
|
+
// All three onboard commands were called
|
|
582
|
+
expect(calls).toContainEqual(["ml", "onboard"]);
|
|
583
|
+
expect(calls).toContainEqual(["sd", "onboard"]);
|
|
584
|
+
expect(calls).toContainEqual(["cn", "onboard"]);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("tool not installed → init and onboard not called", async () => {
|
|
588
|
+
const { spawner, calls } = createMockSpawner({
|
|
589
|
+
"ml --version": { exitCode: 1, stdout: "", stderr: "command not found" },
|
|
590
|
+
"sd --version": { exitCode: 0, stdout: "0.2.4", stderr: "" },
|
|
591
|
+
"sd init": { exitCode: 0, stdout: "initialized", stderr: "" },
|
|
592
|
+
"sd onboard": { exitCode: 0, stdout: "appended", stderr: "" },
|
|
593
|
+
"cn --version": { exitCode: 0, stdout: "0.2.0", stderr: "" },
|
|
594
|
+
"cn init": { exitCode: 0, stdout: "initialized", stderr: "" },
|
|
595
|
+
"cn onboard": { exitCode: 0, stdout: "appended", stderr: "" },
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
await initCommand({ _spawner: spawner });
|
|
599
|
+
|
|
600
|
+
// mulch init should NOT have been called
|
|
601
|
+
expect(calls).not.toContainEqual(["ml", "init"]);
|
|
602
|
+
// seeds and canopy should still be called
|
|
603
|
+
expect(calls).toContainEqual(["sd", "init"]);
|
|
604
|
+
expect(calls).toContainEqual(["cn", "init"]);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test("tool init non-zero + dir exists → already_initialized", async () => {
|
|
608
|
+
// Create .mulch/ directory to simulate existing mulch init
|
|
609
|
+
const { mkdir } = await import("node:fs/promises");
|
|
610
|
+
await mkdir(join(tempDir, ".mulch"), { recursive: true });
|
|
611
|
+
|
|
612
|
+
const { spawner } = createMockSpawner({
|
|
613
|
+
"ml --version": { exitCode: 0, stdout: "0.6.3", stderr: "" },
|
|
614
|
+
"ml init": { exitCode: 1, stdout: "", stderr: "already initialized" },
|
|
615
|
+
"ml onboard": { exitCode: 0, stdout: "appended", stderr: "" },
|
|
616
|
+
"sd --version": { exitCode: 0, stdout: "0.2.4", stderr: "" },
|
|
617
|
+
"sd init": { exitCode: 0, stdout: "initialized", stderr: "" },
|
|
618
|
+
"sd onboard": { exitCode: 0, stdout: "appended", stderr: "" },
|
|
619
|
+
"cn --version": { exitCode: 0, stdout: "0.2.0", stderr: "" },
|
|
620
|
+
"cn init": { exitCode: 0, stdout: "initialized", stderr: "" },
|
|
621
|
+
"cn onboard": { exitCode: 0, stdout: "appended", stderr: "" },
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
// Should not throw — already_initialized is not an error
|
|
625
|
+
await initCommand({ _spawner: spawner });
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test("--skip-onboard skips onboard calls", async () => {
|
|
629
|
+
const { spawner, calls } = createMockSpawner({
|
|
630
|
+
"ml --version": { exitCode: 0, stdout: "0.6.3", stderr: "" },
|
|
631
|
+
"ml init": { exitCode: 0, stdout: "initialized", stderr: "" },
|
|
632
|
+
"sd --version": { exitCode: 0, stdout: "0.2.4", stderr: "" },
|
|
633
|
+
"sd init": { exitCode: 0, stdout: "initialized", stderr: "" },
|
|
634
|
+
"cn --version": { exitCode: 0, stdout: "0.2.0", stderr: "" },
|
|
635
|
+
"cn init": { exitCode: 0, stdout: "initialized", stderr: "" },
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
await initCommand({ skipOnboard: true, _spawner: spawner });
|
|
639
|
+
|
|
640
|
+
expect(calls).not.toContainEqual(["ml", "onboard"]);
|
|
641
|
+
expect(calls).not.toContainEqual(["sd", "onboard"]);
|
|
642
|
+
expect(calls).not.toContainEqual(["cn", "onboard"]);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
test("--skip-mulch skips mulch entirely", async () => {
|
|
646
|
+
const { spawner, calls } = createMockSpawner({
|
|
647
|
+
"sd --version": { exitCode: 0, stdout: "0.2.4", stderr: "" },
|
|
648
|
+
"sd init": { exitCode: 0, stdout: "initialized", stderr: "" },
|
|
649
|
+
"sd onboard": { exitCode: 0, stdout: "appended", stderr: "" },
|
|
650
|
+
"cn --version": { exitCode: 0, stdout: "0.2.0", stderr: "" },
|
|
651
|
+
"cn init": { exitCode: 0, stdout: "initialized", stderr: "" },
|
|
652
|
+
"cn onboard": { exitCode: 0, stdout: "appended", stderr: "" },
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
await initCommand({ skipMulch: true, _spawner: spawner });
|
|
656
|
+
|
|
657
|
+
expect(calls.filter((c) => c[0] === "ml")).toHaveLength(0);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
test("--json outputs JSON envelope with tools and onboard status", async () => {
|
|
661
|
+
const { spawner } = createMockSpawner({
|
|
662
|
+
"ml --version": { exitCode: 0, stdout: "0.6.3", stderr: "" },
|
|
663
|
+
"ml init": { exitCode: 0, stdout: "initialized", stderr: "" },
|
|
664
|
+
"ml onboard": { exitCode: 0, stdout: "appended", stderr: "" },
|
|
665
|
+
"sd --version": { exitCode: 0, stdout: "0.2.4", stderr: "" },
|
|
666
|
+
"sd init": { exitCode: 0, stdout: "initialized", stderr: "" },
|
|
667
|
+
"sd onboard": { exitCode: 0, stdout: "appended", stderr: "" },
|
|
668
|
+
"cn --version": { exitCode: 0, stdout: "0.2.0", stderr: "" },
|
|
669
|
+
"cn init": { exitCode: 0, stdout: "initialized", stderr: "" },
|
|
670
|
+
"cn onboard": { exitCode: 0, stdout: "appended", stderr: "" },
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
let capturedOutput = "";
|
|
674
|
+
const restoreWrite = process.stdout.write;
|
|
675
|
+
process.stdout.write = ((chunk: unknown) => {
|
|
676
|
+
capturedOutput += String(chunk);
|
|
677
|
+
return true;
|
|
678
|
+
}) as typeof process.stdout.write;
|
|
679
|
+
|
|
680
|
+
await initCommand({ json: true, _spawner: spawner });
|
|
681
|
+
|
|
682
|
+
process.stdout.write = restoreWrite;
|
|
683
|
+
|
|
684
|
+
// Find the JSON line (last line with JSON content)
|
|
685
|
+
const jsonLine = capturedOutput.split("\n").find((line) => line.startsWith('{"success":'));
|
|
686
|
+
|
|
687
|
+
expect(jsonLine).toBeDefined();
|
|
688
|
+
const parsed = JSON.parse(jsonLine ?? "{}") as Record<string, unknown>;
|
|
689
|
+
expect(parsed.success).toBe(true);
|
|
690
|
+
expect(parsed.command).toBe("init");
|
|
691
|
+
expect(parsed.tools).toBeDefined();
|
|
692
|
+
expect(parsed.onboard).toBeDefined();
|
|
693
|
+
expect(typeof parsed.gitattributes).toBe("boolean");
|
|
694
|
+
|
|
695
|
+
const tools = parsed.tools as Record<string, { status: string }>;
|
|
696
|
+
expect(tools.overstory?.status).toBe("initialized");
|
|
697
|
+
expect(tools.mulch?.status).toBe("initialized");
|
|
698
|
+
expect(tools.seeds?.status).toBe("initialized");
|
|
699
|
+
expect(tools.canopy?.status).toBe("initialized");
|
|
700
|
+
});
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
describe("initCommand: .gitattributes setup", () => {
|
|
704
|
+
let tempDir: string;
|
|
705
|
+
let originalCwd: string;
|
|
706
|
+
let originalWrite: typeof process.stdout.write;
|
|
707
|
+
|
|
708
|
+
beforeEach(async () => {
|
|
709
|
+
tempDir = await createTempGitRepo();
|
|
710
|
+
originalCwd = process.cwd();
|
|
711
|
+
process.chdir(tempDir);
|
|
712
|
+
originalWrite = process.stdout.write;
|
|
713
|
+
process.stdout.write = (() => true) as typeof process.stdout.write;
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
afterEach(async () => {
|
|
717
|
+
process.chdir(originalCwd);
|
|
718
|
+
process.stdout.write = originalWrite;
|
|
719
|
+
await cleanupTempDir(tempDir);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test("creates .gitattributes with merge=union entries", async () => {
|
|
723
|
+
// Use a spawner that skips all ecosystem tools so only gitattributes step runs
|
|
724
|
+
const { spawner } = createMockSpawner({});
|
|
725
|
+
await initCommand({ skipMulch: true, skipSeeds: true, skipCanopy: true, _spawner: spawner });
|
|
726
|
+
|
|
727
|
+
const gitattrsPath = join(tempDir, ".gitattributes");
|
|
728
|
+
const exists = await Bun.file(gitattrsPath).exists();
|
|
729
|
+
expect(exists).toBe(true);
|
|
730
|
+
|
|
731
|
+
const content = await Bun.file(gitattrsPath).text();
|
|
732
|
+
expect(content).toContain(".mulch/expertise/*.jsonl merge=union");
|
|
733
|
+
expect(content).toContain(".seeds/issues.jsonl merge=union");
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
test("does not duplicate entries on reinit with --force", async () => {
|
|
737
|
+
const { spawner } = createMockSpawner({});
|
|
738
|
+
|
|
739
|
+
// First init
|
|
740
|
+
await initCommand({ skipMulch: true, skipSeeds: true, skipCanopy: true, _spawner: spawner });
|
|
741
|
+
|
|
742
|
+
// Second init with --force
|
|
743
|
+
await initCommand({
|
|
744
|
+
force: true,
|
|
745
|
+
skipMulch: true,
|
|
746
|
+
skipSeeds: true,
|
|
747
|
+
skipCanopy: true,
|
|
748
|
+
_spawner: spawner,
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
const gitattrsPath = join(tempDir, ".gitattributes");
|
|
752
|
+
const content = await Bun.file(gitattrsPath).text();
|
|
753
|
+
|
|
754
|
+
// Count occurrences — should be exactly one each
|
|
755
|
+
const mulchCount = (content.match(/\.mulch\/expertise\/\*\.jsonl merge=union/g) ?? []).length;
|
|
756
|
+
const seedsCount = (content.match(/\.seeds\/issues\.jsonl merge=union/g) ?? []).length;
|
|
757
|
+
expect(mulchCount).toBe(1);
|
|
758
|
+
expect(seedsCount).toBe(1);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
test("preserves existing .gitattributes content", async () => {
|
|
762
|
+
// Pre-create .gitattributes with existing content
|
|
763
|
+
const existingContent = "*.lock binary\n*.png binary\n";
|
|
764
|
+
await Bun.write(join(tempDir, ".gitattributes"), existingContent);
|
|
765
|
+
|
|
766
|
+
const { spawner } = createMockSpawner({});
|
|
767
|
+
await initCommand({ skipMulch: true, skipSeeds: true, skipCanopy: true, _spawner: spawner });
|
|
768
|
+
|
|
769
|
+
const content = await Bun.file(join(tempDir, ".gitattributes")).text();
|
|
770
|
+
expect(content).toContain("*.lock binary");
|
|
771
|
+
expect(content).toContain("*.png binary");
|
|
772
|
+
expect(content).toContain(".mulch/expertise/*.jsonl merge=union");
|
|
773
|
+
expect(content).toContain(".seeds/issues.jsonl merge=union");
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test("no-op when entries already present", async () => {
|
|
777
|
+
// Pre-create .gitattributes with the entries already
|
|
778
|
+
const existingContent =
|
|
779
|
+
".mulch/expertise/*.jsonl merge=union\n.seeds/issues.jsonl merge=union\n";
|
|
780
|
+
await Bun.write(join(tempDir, ".gitattributes"), existingContent);
|
|
781
|
+
|
|
782
|
+
const { spawner } = createMockSpawner({});
|
|
783
|
+
await initCommand({ skipMulch: true, skipSeeds: true, skipCanopy: true, _spawner: spawner });
|
|
784
|
+
|
|
785
|
+
const content = await Bun.file(join(tempDir, ".gitattributes")).text();
|
|
786
|
+
// Content should be unchanged
|
|
787
|
+
expect(content).toBe(existingContent);
|
|
788
|
+
});
|
|
789
|
+
});
|