ndomo 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +4 -0
- package/README.es.md +29 -23
- package/README.md +64 -24
- package/bun.lock +447 -0
- package/docs/configuration.md +4 -4
- package/docs/installation.md +53 -34
- package/docs/installer.md +164 -0
- package/docs/integrations.md +1 -1
- package/docs/web-ui.md +124 -0
- package/package.json +43 -4
- package/scripts/install.sh +28 -0
- package/scripts/smoke-install.sh +47 -0
- package/scripts/smoke-web.sh +335 -0
- package/src/cli/__tests__/install.test.ts +733 -0
- package/src/cli/index.ts +8 -0
- package/src/cli/install.ts +1273 -0
- package/src/config/__tests__/schema.test.ts +223 -0
- package/src/config/schema.ts +129 -16
- package/src/http/__tests__/auth.test.ts +10 -10
- package/src/http/__tests__/spa.test.ts +296 -0
- package/src/http/auth.ts +8 -1
- package/src/http/server.ts +71 -2
- package/.bun-version +0 -1
- package/.dockerignore +0 -79
- package/.editorconfig +0 -18
- package/.github/CODEOWNERS +0 -8
- package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -62
- package/.github/ISSUE_TEMPLATE/config.yml +0 -2
- package/.github/ISSUE_TEMPLATE/feature_request.yml +0 -34
- package/.github/dependabot.yml +0 -36
- package/.github/pull_request_template.md +0 -24
- package/.github/release.yml +0 -30
- package/.github/workflows/gitleaks.yml +0 -28
- package/.github/workflows/release-please.yml +0 -27
- package/.github/workflows/smoke.yml +0 -29
- package/.husky/commit-msg +0 -1
- package/CHANGELOG.md +0 -114
- package/Dockerfile +0 -32
- package/bin/ndomo-analyses.ts +0 -4
- package/bin/ndomo-status.ts +0 -4
- package/biome.json +0 -57
- package/commitlint.config.js +0 -3
- package/opencode.json +0 -5
- package/release-please-config.json +0 -11
- package/scripts/dev-bust-cache.sh +0 -164
- package/scripts/smoke-e2e.ts +0 -704
- package/scripts/smoke-hot.ts +0 -417
- package/scripts/smoke-v4.ts +0 -256
- package/scripts/smoke-v5.ts +0 -397
- package/scripts/uninstall.sh +0 -224
- package/src/index.ts +0 -37
- package/src/lib.ts +0 -65
- package/src/mem/scoped.ts +0 -65
- package/src/orchestrator/background.test.ts +0 -268
- package/src/orchestrator/background.ts +0 -293
- package/src/orchestrator/memory-hook.ts +0 -182
- package/src/orchestrator/reconciler.ts +0 -123
- package/src/orchestrator/scheduler.test.ts +0 -300
- package/src/orchestrator/scheduler.ts +0 -243
- package/src/plugin.test.ts +0 -2574
- package/src/plugin.ts +0 -1690
- package/src/worktrees/manager.ts +0 -236
- package/src/worktrees/state.ts +0 -87
- package/tests/integration/ranger-flow.test.ts +0 -257
- package/tsconfig.json +0 -31
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for src/cli/install.ts — TypeScript port of install.sh.
|
|
3
|
+
*
|
|
4
|
+
* Uses bun:test with temp directories. Tests exported helpers directly.
|
|
5
|
+
*
|
|
6
|
+
* Coverage:
|
|
7
|
+
* 1. Flag parsing (--dry-run, --preset, --skip-deps, --enable-http, --help, etc.)
|
|
8
|
+
* 2. HTTP config building (--enable-http, --port, --cors-origins, --auth-required)
|
|
9
|
+
* 3. HTTP prompt skip in non-TTY
|
|
10
|
+
* 4. Preset application to agent .md files (frontmatter update)
|
|
11
|
+
* 5. Provider prefix override
|
|
12
|
+
* 6. Plugin registration in opencode.json (dedup merge)
|
|
13
|
+
* 7. Agent/skill copy with backup
|
|
14
|
+
* 8. Idempotency: re-run doesn't corrupt state
|
|
15
|
+
* 9. Path traversal protection (unsafe agent names rejected)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
19
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import {
|
|
23
|
+
parseFlags,
|
|
24
|
+
buildHttpConfig,
|
|
25
|
+
writeHttpBlock,
|
|
26
|
+
applyPresetToFile,
|
|
27
|
+
applyProviderPrefix,
|
|
28
|
+
stepRegisterPlugins,
|
|
29
|
+
stepCopyAgents,
|
|
30
|
+
stepCopySkills,
|
|
31
|
+
stepCopyTools,
|
|
32
|
+
stepInjectPreset,
|
|
33
|
+
type InstallFlags,
|
|
34
|
+
type PresetEntry,
|
|
35
|
+
} from "../install.ts";
|
|
36
|
+
import type { NdomoConfig } from "../../config/schema.ts";
|
|
37
|
+
|
|
38
|
+
let tmpDir: string;
|
|
39
|
+
let projectRoot: string;
|
|
40
|
+
let configDir: string;
|
|
41
|
+
let backupDir: string;
|
|
42
|
+
|
|
43
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
function defaultFlags(overrides?: Partial<InstallFlags>): InstallFlags {
|
|
46
|
+
return {
|
|
47
|
+
preset: "default",
|
|
48
|
+
provider: "",
|
|
49
|
+
noProviderPrompt: false,
|
|
50
|
+
withDcp: false,
|
|
51
|
+
dryRun: false,
|
|
52
|
+
skipDeps: false,
|
|
53
|
+
enableHttp: false,
|
|
54
|
+
disableHttp: false,
|
|
55
|
+
corsOrigins: "*",
|
|
56
|
+
port: 4097,
|
|
57
|
+
authRequired: true,
|
|
58
|
+
uninstall: false,
|
|
59
|
+
help: false,
|
|
60
|
+
...overrides,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function makeAgentMd(
|
|
65
|
+
name: string,
|
|
66
|
+
model: string,
|
|
67
|
+
temperature?: number,
|
|
68
|
+
reasoningEffort?: string,
|
|
69
|
+
): string {
|
|
70
|
+
let fm = `---\nmodel: ${model}`;
|
|
71
|
+
if (temperature !== undefined) {
|
|
72
|
+
fm += `\ntemperature: ${temperature}`;
|
|
73
|
+
}
|
|
74
|
+
if (reasoningEffort) {
|
|
75
|
+
fm += `\nreasoningEffort: ${reasoningEffort}`;
|
|
76
|
+
}
|
|
77
|
+
fm += `\n---\n# Agent ${name}\nThis is the ${name} agent body.`;
|
|
78
|
+
return fm;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
tmpDir = mkdtempSync(join(tmpdir(), "ndomo-install-"));
|
|
83
|
+
projectRoot = join(tmpDir, "project");
|
|
84
|
+
configDir = join(tmpDir, "config");
|
|
85
|
+
backupDir = join(tmpDir, "backup");
|
|
86
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
87
|
+
mkdirSync(configDir, { recursive: true });
|
|
88
|
+
mkdirSync(join(projectRoot, "agents"), { recursive: true });
|
|
89
|
+
mkdirSync(join(projectRoot, "skills"), { recursive: true });
|
|
90
|
+
mkdirSync(join(projectRoot, "config"), { recursive: true });
|
|
91
|
+
mkdirSync(join(configDir, "agent"), { recursive: true });
|
|
92
|
+
mkdirSync(join(configDir, "skills"), { recursive: true });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
try {
|
|
97
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
98
|
+
} catch {
|
|
99
|
+
// ignore
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ─── Flag parsing ─────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
describe("parseFlags", () => {
|
|
106
|
+
test("returns defaults for empty args", () => {
|
|
107
|
+
const flags = parseFlags([]);
|
|
108
|
+
expect(flags.preset).toBe("default");
|
|
109
|
+
expect(flags.dryRun).toBe(false);
|
|
110
|
+
expect(flags.skipDeps).toBe(false);
|
|
111
|
+
expect(flags.enableHttp).toBe(false);
|
|
112
|
+
expect(flags.disableHttp).toBe(false);
|
|
113
|
+
expect(flags.withDcp).toBe(false);
|
|
114
|
+
expect(flags.help).toBe(false);
|
|
115
|
+
expect(flags.port).toBe(4097);
|
|
116
|
+
expect(flags.corsOrigins).toBe("*");
|
|
117
|
+
expect(flags.authRequired).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("--dry-run sets dryRun flag", () => {
|
|
121
|
+
const flags = parseFlags(["--dry-run"]);
|
|
122
|
+
expect(flags.dryRun).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("--preset=budget sets preset", () => {
|
|
126
|
+
const flags = parseFlags(["--preset=budget"]);
|
|
127
|
+
expect(flags.preset).toBe("budget");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("--skip-deps sets skipDeps flag", () => {
|
|
131
|
+
const flags = parseFlags(["--skip-deps"]);
|
|
132
|
+
expect(flags.skipDeps).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("--enable-http sets enableHttp flag", () => {
|
|
136
|
+
const flags = parseFlags(["--enable-http"]);
|
|
137
|
+
expect(flags.enableHttp).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("--disable-http sets disableHttp flag", () => {
|
|
141
|
+
const flags = parseFlags(["--disable-http"]);
|
|
142
|
+
expect(flags.disableHttp).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("--with-dcp sets withDcp flag", () => {
|
|
146
|
+
const flags = parseFlags(["--with-dcp"]);
|
|
147
|
+
expect(flags.withDcp).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("--help sets help flag", () => {
|
|
151
|
+
const flags = parseFlags(["--help"]);
|
|
152
|
+
expect(flags.help).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("-h sets help flag", () => {
|
|
156
|
+
const flags = parseFlags(["-h"]);
|
|
157
|
+
expect(flags.help).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("--port=8080 sets port", () => {
|
|
161
|
+
const flags = parseFlags(["--port=8080"]);
|
|
162
|
+
expect(flags.port).toBe(8080);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("--cors-origins=a.com,b.com sets corsOrigins", () => {
|
|
166
|
+
const flags = parseFlags(["--cors-origins=a.com,b.com"]);
|
|
167
|
+
expect(flags.corsOrigins).toBe("a.com,b.com");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("--auth-required=false sets authRequired to false", () => {
|
|
171
|
+
const flags = parseFlags(["--auth-required=false"]);
|
|
172
|
+
expect(flags.authRequired).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("--provider=opencode sets provider", () => {
|
|
176
|
+
const flags = parseFlags(["--provider=opencode"]);
|
|
177
|
+
expect(flags.provider).toBe("opencode");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("--no-provider-prompt sets noProviderPrompt", () => {
|
|
181
|
+
const flags = parseFlags(["--no-provider-prompt"]);
|
|
182
|
+
expect(flags.noProviderPrompt).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("combined flags parse correctly", () => {
|
|
186
|
+
const flags = parseFlags([
|
|
187
|
+
"--preset=budget",
|
|
188
|
+
"--enable-http",
|
|
189
|
+
"--port=9090",
|
|
190
|
+
"--skip-deps",
|
|
191
|
+
"--dry-run",
|
|
192
|
+
]);
|
|
193
|
+
expect(flags.preset).toBe("budget");
|
|
194
|
+
expect(flags.enableHttp).toBe(true);
|
|
195
|
+
expect(flags.port).toBe(9090);
|
|
196
|
+
expect(flags.skipDeps).toBe(true);
|
|
197
|
+
expect(flags.dryRun).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("unknown flag throws", () => {
|
|
201
|
+
expect(() => parseFlags(["--unknown-flag"])).toThrow("Unknown option");
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ─── HTTP config building ─────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
describe("buildHttpConfig", () => {
|
|
208
|
+
test("builds config from flags with defaults", () => {
|
|
209
|
+
const flags = defaultFlags({ enableHttp: true });
|
|
210
|
+
const config = buildHttpConfig(flags);
|
|
211
|
+
|
|
212
|
+
expect(config.enabled).toBe(true);
|
|
213
|
+
expect(config.port).toBe(4097);
|
|
214
|
+
expect(config.cors.origins).toEqual(["*"]);
|
|
215
|
+
expect(config.auth.required).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("respects --port override", () => {
|
|
219
|
+
const flags = defaultFlags({ port: 8080 });
|
|
220
|
+
const config = buildHttpConfig(flags);
|
|
221
|
+
expect(config.port).toBe(8080);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("respects --cors-origins override", () => {
|
|
225
|
+
const flags = defaultFlags({ corsOrigins: "a.com,b.com" });
|
|
226
|
+
const config = buildHttpConfig(flags);
|
|
227
|
+
expect(config.cors.origins).toEqual(["a.com", "b.com"]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("respects --auth-required=false override", () => {
|
|
231
|
+
const flags = defaultFlags({ authRequired: false });
|
|
232
|
+
const config = buildHttpConfig(flags);
|
|
233
|
+
expect(config.auth.required).toBe(false);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ─── writeHttpBlock ──────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
describe("writeHttpBlock", () => {
|
|
240
|
+
test("writes http block to ndomo.config.json", () => {
|
|
241
|
+
const configPath = join(projectRoot, "config", "ndomo.config.json");
|
|
242
|
+
writeFileSync(configPath, JSON.stringify({ plugins: ["ndomo"] }));
|
|
243
|
+
|
|
244
|
+
const httpConfig = {
|
|
245
|
+
enabled: true,
|
|
246
|
+
port: 4097,
|
|
247
|
+
cors: { origins: ["*"] },
|
|
248
|
+
auth: { required: true },
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
writeHttpBlock(projectRoot, httpConfig, false);
|
|
252
|
+
|
|
253
|
+
const written = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
254
|
+
expect(written.http).toBeDefined();
|
|
255
|
+
expect(written.http.enabled).toBe(true);
|
|
256
|
+
expect(written.http.port).toBe(4097);
|
|
257
|
+
expect(written.http.cors.origins).toEqual(["*"]);
|
|
258
|
+
expect(written.http.auth.required).toBe(true);
|
|
259
|
+
// Existing fields preserved
|
|
260
|
+
expect(written.plugins).toEqual(["ndomo"]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("dry-run does not modify file", () => {
|
|
264
|
+
const configPath = join(projectRoot, "config", "ndomo.config.json");
|
|
265
|
+
const original = JSON.stringify({ plugins: ["ndomo"] });
|
|
266
|
+
writeFileSync(configPath, original);
|
|
267
|
+
|
|
268
|
+
const httpConfig = {
|
|
269
|
+
enabled: true,
|
|
270
|
+
port: 4097,
|
|
271
|
+
cors: { origins: ["*"] },
|
|
272
|
+
auth: { required: true },
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
writeHttpBlock(projectRoot, httpConfig, true);
|
|
276
|
+
|
|
277
|
+
const content = readFileSync(configPath, "utf-8");
|
|
278
|
+
expect(content).toBe(original);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("warns if config file missing", () => {
|
|
282
|
+
// Should not throw
|
|
283
|
+
writeHttpBlock(projectRoot, {
|
|
284
|
+
enabled: true,
|
|
285
|
+
port: 4097,
|
|
286
|
+
cors: { origins: ["*"] },
|
|
287
|
+
auth: { required: true },
|
|
288
|
+
}, false);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// ─── Preset application ──────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
describe("applyPresetToFile", () => {
|
|
295
|
+
test("updates model and temperature in frontmatter", () => {
|
|
296
|
+
const filePath = join(tmpDir, "foreman.md");
|
|
297
|
+
writeFileSync(filePath, makeAgentMd("foreman", "old/model", 0.5));
|
|
298
|
+
|
|
299
|
+
const preset: PresetEntry = { model: "new/model-v2", temperature: 0.3 };
|
|
300
|
+
const result = applyPresetToFile(filePath, preset, false);
|
|
301
|
+
|
|
302
|
+
expect(result).toBe("updated");
|
|
303
|
+
const content = readFileSync(filePath, "utf-8");
|
|
304
|
+
expect(content).toContain("model: new/model-v2");
|
|
305
|
+
expect(content).toContain("temperature: 0.3");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("adds reasoningEffort when not present (after temperature)", () => {
|
|
309
|
+
const filePath = join(tmpDir, "scout.md");
|
|
310
|
+
writeFileSync(filePath, makeAgentMd("scout", "opencode/gpt-4", 0.5));
|
|
311
|
+
|
|
312
|
+
const preset: PresetEntry = { model: "opencode/gpt-4", reasoning_effort: "high" };
|
|
313
|
+
applyPresetToFile(filePath, preset, false);
|
|
314
|
+
|
|
315
|
+
const content = readFileSync(filePath, "utf-8");
|
|
316
|
+
expect(content).toContain("reasoningEffort: high");
|
|
317
|
+
// Should be after temperature
|
|
318
|
+
const tempIdx = content.indexOf("temperature:");
|
|
319
|
+
const effortIdx = content.indexOf("reasoningEffort:");
|
|
320
|
+
expect(effortIdx).toBeGreaterThan(tempIdx);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("adds reasoningEffort after model when no temperature", () => {
|
|
324
|
+
const filePath = join(tmpDir, "agent.md");
|
|
325
|
+
writeFileSync(filePath, makeAgentMd("agent", "opencode/gpt-4"));
|
|
326
|
+
|
|
327
|
+
const preset: PresetEntry = { model: "opencode/gpt-4", reasoning_effort: "low" };
|
|
328
|
+
applyPresetToFile(filePath, preset, false);
|
|
329
|
+
|
|
330
|
+
const content = readFileSync(filePath, "utf-8");
|
|
331
|
+
expect(content).toContain("reasoningEffort: low");
|
|
332
|
+
const modelIdx = content.indexOf("model:");
|
|
333
|
+
const effortIdx = content.indexOf("reasoningEffort:");
|
|
334
|
+
expect(effortIdx).toBeGreaterThan(modelIdx);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("updates existing reasoningEffort", () => {
|
|
338
|
+
const filePath = join(tmpDir, "agent.md");
|
|
339
|
+
writeFileSync(filePath, makeAgentMd("agent", "opencode/gpt-4", 0.3, "low"));
|
|
340
|
+
|
|
341
|
+
const preset: PresetEntry = { reasoning_effort: "high" };
|
|
342
|
+
applyPresetToFile(filePath, preset, false);
|
|
343
|
+
|
|
344
|
+
const content = readFileSync(filePath, "utf-8");
|
|
345
|
+
expect(content).toContain("reasoningEffort: high");
|
|
346
|
+
expect(content).not.toContain("reasoningEffort: low");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("skips file without frontmatter", () => {
|
|
350
|
+
const filePath = join(tmpDir, "nofm.md");
|
|
351
|
+
writeFileSync(filePath, "# No frontmatter\nJust a body.");
|
|
352
|
+
|
|
353
|
+
const result = applyPresetToFile(filePath, { model: "test" }, false);
|
|
354
|
+
expect(result).toBe("skipped");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("preserves body content after frontmatter", () => {
|
|
358
|
+
const filePath = join(tmpDir, "agent.md");
|
|
359
|
+
writeFileSync(filePath, makeAgentMd("test", "old/model", 0.5));
|
|
360
|
+
|
|
361
|
+
applyPresetToFile(filePath, { model: "new/model" }, false);
|
|
362
|
+
|
|
363
|
+
const content = readFileSync(filePath, "utf-8");
|
|
364
|
+
expect(content).toContain("# Agent test");
|
|
365
|
+
expect(content).toContain("This is the test agent body.");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("dry-run does not modify file", () => {
|
|
369
|
+
const filePath = join(tmpDir, "agent.md");
|
|
370
|
+
const original = makeAgentMd("test", "old/model", 0.5);
|
|
371
|
+
writeFileSync(filePath, original);
|
|
372
|
+
|
|
373
|
+
applyPresetToFile(filePath, { model: "new/model" }, true);
|
|
374
|
+
|
|
375
|
+
expect(readFileSync(filePath, "utf-8")).toBe(original);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// ─── Provider prefix ─────────────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
describe("applyProviderPrefix", () => {
|
|
382
|
+
test("replaces provider prefix on model lines", () => {
|
|
383
|
+
mkdirSync(join(tmpDir, "agents"), { recursive: true });
|
|
384
|
+
writeFileSync(join(tmpDir, "agents", "foreman.md"), makeAgentMd("foreman", "opencode/gpt-4", 0.3));
|
|
385
|
+
writeFileSync(join(tmpDir, "agents", "scout.md"), makeAgentMd("scout", "minimax/model", 0.3));
|
|
386
|
+
|
|
387
|
+
const updated = applyProviderPrefix(join(tmpDir, "agents"), "anthropic", false);
|
|
388
|
+
|
|
389
|
+
expect(updated).toBe(2);
|
|
390
|
+
const fm = readFileSync(join(tmpDir, "agents", "foreman.md"), "utf-8");
|
|
391
|
+
expect(fm).toContain("model: anthropic/gpt-4");
|
|
392
|
+
const sm = readFileSync(join(tmpDir, "agents", "scout.md"), "utf-8");
|
|
393
|
+
expect(sm).toContain("model: anthropic/model");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("only replaces first model line", () => {
|
|
397
|
+
mkdirSync(join(tmpDir, "agents"), { recursive: true });
|
|
398
|
+
const content = "---\nmodel: opencode/gpt-4\ntemperature: 0.3\n---\nmodel: not-touched/extra";
|
|
399
|
+
writeFileSync(join(tmpDir, "agents", "agent.md"), content);
|
|
400
|
+
|
|
401
|
+
applyProviderPrefix(join(tmpDir, "agents"), "new-prefix", false);
|
|
402
|
+
|
|
403
|
+
const result = readFileSync(join(tmpDir, "agents", "agent.md"), "utf-8");
|
|
404
|
+
expect(result).toContain("model: new-prefix/gpt-4");
|
|
405
|
+
// Body model line untouched
|
|
406
|
+
expect(result).toContain("model: not-touched/extra");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("dry-run does not modify files", () => {
|
|
410
|
+
mkdirSync(join(tmpDir, "agents"), { recursive: true });
|
|
411
|
+
const original = makeAgentMd("test", "opencode/gpt-4");
|
|
412
|
+
writeFileSync(join(tmpDir, "agents", "test.md"), original);
|
|
413
|
+
|
|
414
|
+
applyProviderPrefix(join(tmpDir, "agents"), "new", true);
|
|
415
|
+
|
|
416
|
+
expect(readFileSync(join(tmpDir, "agents", "test.md"), "utf-8")).toBe(original);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// ─── Plugin registration ─────────────────────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
describe("stepRegisterPlugins", () => {
|
|
423
|
+
test("registers plugins in opencode.json (dedup merge)", () => {
|
|
424
|
+
const opencodePath = join(configDir, "opencode.json");
|
|
425
|
+
writeFileSync(opencodePath, JSON.stringify({ plugin: ["existing-plugin"] }));
|
|
426
|
+
|
|
427
|
+
const config: NdomoConfig = {
|
|
428
|
+
plugins: ["ndomo", "opencode-mem"],
|
|
429
|
+
optionalPlugins: ["@tarquinen/opencode-dcp"],
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
stepRegisterPlugins(configDir, config, backupDir, false);
|
|
433
|
+
|
|
434
|
+
const written = JSON.parse(readFileSync(opencodePath, "utf-8"));
|
|
435
|
+
expect(written.plugin).toContain("ndomo");
|
|
436
|
+
expect(written.plugin).toContain("opencode-mem");
|
|
437
|
+
expect(written.plugin).toContain("@tarquinen/opencode-dcp");
|
|
438
|
+
expect(written.plugin).toContain("existing-plugin");
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("idempotent: running twice doesn't duplicate plugins", () => {
|
|
442
|
+
const opencodePath = join(configDir, "opencode.json");
|
|
443
|
+
writeFileSync(opencodePath, JSON.stringify({ plugin: [] }));
|
|
444
|
+
|
|
445
|
+
const config: NdomoConfig = {
|
|
446
|
+
plugins: ["ndomo", "opencode-mem"],
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
stepRegisterPlugins(configDir, config, backupDir, false);
|
|
450
|
+
stepRegisterPlugins(configDir, config, backupDir, false);
|
|
451
|
+
|
|
452
|
+
const written = JSON.parse(readFileSync(opencodePath, "utf-8"));
|
|
453
|
+
const ndomoCount = written.plugin.filter((p: string) => p === "ndomo").length;
|
|
454
|
+
expect(ndomoCount).toBe(1);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("creates opencode.json if missing", () => {
|
|
458
|
+
const config: NdomoConfig = { plugins: ["ndomo"] };
|
|
459
|
+
stepRegisterPlugins(configDir, config, backupDir, false);
|
|
460
|
+
|
|
461
|
+
expect(existsSync(join(configDir, "opencode.json"))).toBe(true);
|
|
462
|
+
const written = JSON.parse(readFileSync(join(configDir, "opencode.json"), "utf-8"));
|
|
463
|
+
expect(written.plugin).toContain("ndomo");
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("backups existing opencode.json before modifying", () => {
|
|
467
|
+
const opencodePath = join(configDir, "opencode.json");
|
|
468
|
+
writeFileSync(opencodePath, JSON.stringify({ plugin: ["old"] }));
|
|
469
|
+
|
|
470
|
+
const config: NdomoConfig = { plugins: ["new"] };
|
|
471
|
+
stepRegisterPlugins(configDir, config, backupDir, false);
|
|
472
|
+
|
|
473
|
+
expect(existsSync(join(backupDir, "opencode.json"))).toBe(true);
|
|
474
|
+
const backup = JSON.parse(readFileSync(join(backupDir, "opencode.json"), "utf-8"));
|
|
475
|
+
expect(backup.plugin).toContain("old");
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("dry-run does not modify opencode.json", () => {
|
|
479
|
+
const opencodePath = join(configDir, "opencode.json");
|
|
480
|
+
const original = JSON.stringify({ plugin: [] });
|
|
481
|
+
writeFileSync(opencodePath, original);
|
|
482
|
+
|
|
483
|
+
const config: NdomoConfig = { plugins: ["ndomo"] };
|
|
484
|
+
stepRegisterPlugins(configDir, config, backupDir, true);
|
|
485
|
+
|
|
486
|
+
expect(readFileSync(opencodePath, "utf-8")).toBe(original);
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
// ─── Agent copy ───────────────────────────────────────────────────────────────
|
|
491
|
+
|
|
492
|
+
describe("stepCopyAgents", () => {
|
|
493
|
+
test("copies agents from project to config dir", () => {
|
|
494
|
+
writeFileSync(join(projectRoot, "agents", "foreman.md"), "# Foreman");
|
|
495
|
+
writeFileSync(join(projectRoot, "agents", "scout.md"), "# Scout");
|
|
496
|
+
|
|
497
|
+
const count = stepCopyAgents(projectRoot, configDir, backupDir, false);
|
|
498
|
+
|
|
499
|
+
expect(count).toBe(2);
|
|
500
|
+
expect(existsSync(join(configDir, "agent", "foreman.md"))).toBe(true);
|
|
501
|
+
expect(existsSync(join(configDir, "agent", "scout.md"))).toBe(true);
|
|
502
|
+
expect(readFileSync(join(configDir, "agent", "foreman.md"), "utf-8")).toBe("# Foreman");
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("backs up existing agents before overwriting", () => {
|
|
506
|
+
// Existing agent in config
|
|
507
|
+
writeFileSync(join(configDir, "agent", "foreman.md"), "# Old Foreman");
|
|
508
|
+
// New agent in project
|
|
509
|
+
writeFileSync(join(projectRoot, "agents", "foreman.md"), "# New Foreman");
|
|
510
|
+
|
|
511
|
+
stepCopyAgents(projectRoot, configDir, backupDir, false);
|
|
512
|
+
|
|
513
|
+
expect(existsSync(join(backupDir, "foreman.md"))).toBe(true);
|
|
514
|
+
expect(readFileSync(join(backupDir, "foreman.md"), "utf-8")).toBe("# Old Foreman");
|
|
515
|
+
expect(readFileSync(join(configDir, "agent", "foreman.md"), "utf-8")).toBe("# New Foreman");
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("dry-run does not copy files", () => {
|
|
519
|
+
writeFileSync(join(projectRoot, "agents", "test.md"), "# Test");
|
|
520
|
+
|
|
521
|
+
stepCopyAgents(projectRoot, configDir, backupDir, true);
|
|
522
|
+
|
|
523
|
+
expect(existsSync(join(configDir, "agent", "test.md"))).toBe(false);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("returns 0 when no agents dir exists", () => {
|
|
527
|
+
rmSync(join(projectRoot, "agents"), { recursive: true });
|
|
528
|
+
const count = stepCopyAgents(projectRoot, configDir, backupDir, false);
|
|
529
|
+
expect(count).toBe(0);
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// ─── Skill copy ──────────────────────────────────────────────────────────────
|
|
534
|
+
|
|
535
|
+
describe("stepCopySkills", () => {
|
|
536
|
+
test("copies skill directories from project to config dir", () => {
|
|
537
|
+
mkdirSync(join(projectRoot, "skills", "caveman"), { recursive: true });
|
|
538
|
+
writeFileSync(join(projectRoot, "skills", "caveman", "SKILL.md"), "# Caveman");
|
|
539
|
+
mkdirSync(join(projectRoot, "skills", "vue"), { recursive: true });
|
|
540
|
+
writeFileSync(join(projectRoot, "skills", "vue", "SKILL.md"), "# Vue");
|
|
541
|
+
|
|
542
|
+
const count = stepCopySkills(projectRoot, configDir, backupDir, false);
|
|
543
|
+
|
|
544
|
+
expect(count).toBe(2);
|
|
545
|
+
expect(existsSync(join(configDir, "skills", "caveman", "SKILL.md"))).toBe(true);
|
|
546
|
+
expect(existsSync(join(configDir, "skills", "vue", "SKILL.md"))).toBe(true);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("backs up existing skills before overwriting", () => {
|
|
550
|
+
// Existing skill
|
|
551
|
+
mkdirSync(join(configDir, "skills", "caveman"), { recursive: true });
|
|
552
|
+
writeFileSync(join(configDir, "skills", "caveman", "SKILL.md"), "# Old");
|
|
553
|
+
|
|
554
|
+
// New skill
|
|
555
|
+
mkdirSync(join(projectRoot, "skills", "caveman"), { recursive: true });
|
|
556
|
+
writeFileSync(join(projectRoot, "skills", "caveman", "SKILL.md"), "# New");
|
|
557
|
+
|
|
558
|
+
stepCopySkills(projectRoot, configDir, backupDir, false);
|
|
559
|
+
|
|
560
|
+
expect(existsSync(join(backupDir, "skills", "caveman", "SKILL.md"))).toBe(true);
|
|
561
|
+
expect(readFileSync(join(backupDir, "skills", "caveman", "SKILL.md"), "utf-8")).toBe("# Old");
|
|
562
|
+
expect(readFileSync(join(configDir, "skills", "caveman", "SKILL.md"), "utf-8")).toBe("# New");
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test("returns 0 when no skills dir exists", () => {
|
|
566
|
+
rmSync(join(projectRoot, "skills"), { recursive: true });
|
|
567
|
+
const count = stepCopySkills(projectRoot, configDir, backupDir, false);
|
|
568
|
+
expect(count).toBe(0);
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// ─── Preset injection ────────────────────────────────────────────────────────
|
|
573
|
+
|
|
574
|
+
describe("stepInjectPreset", () => {
|
|
575
|
+
test("injects preset name into ndomo.json", () => {
|
|
576
|
+
const ndomoPath = join(configDir, "ndomo.json");
|
|
577
|
+
writeFileSync(ndomoPath, JSON.stringify({ plugins: ["ndomo"] }));
|
|
578
|
+
|
|
579
|
+
stepInjectPreset(configDir, "budget", false);
|
|
580
|
+
|
|
581
|
+
const written = JSON.parse(readFileSync(ndomoPath, "utf-8"));
|
|
582
|
+
expect(written.preset).toBe("budget");
|
|
583
|
+
expect(written.plugins).toEqual(["ndomo"]);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test("overwrites existing preset field", () => {
|
|
587
|
+
const ndomoPath = join(configDir, "ndomo.json");
|
|
588
|
+
writeFileSync(ndomoPath, JSON.stringify({ preset: "old", plugins: [] }));
|
|
589
|
+
|
|
590
|
+
stepInjectPreset(configDir, "default", false);
|
|
591
|
+
|
|
592
|
+
const written = JSON.parse(readFileSync(ndomoPath, "utf-8"));
|
|
593
|
+
expect(written.preset).toBe("default");
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("dry-run does not modify file", () => {
|
|
597
|
+
const ndomoPath = join(configDir, "ndomo.json");
|
|
598
|
+
const original = JSON.stringify({ plugins: [] });
|
|
599
|
+
writeFileSync(ndomoPath, original);
|
|
600
|
+
|
|
601
|
+
stepInjectPreset(configDir, "budget", true);
|
|
602
|
+
|
|
603
|
+
expect(readFileSync(ndomoPath, "utf-8")).toBe(original);
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
// ─── Path traversal protection ───────────────────────────────────────────────
|
|
608
|
+
|
|
609
|
+
describe("path traversal protection", () => {
|
|
610
|
+
test("rejects agent filenames with slashes", () => {
|
|
611
|
+
// The applyPresetToFile function is called with a full path,
|
|
612
|
+
// but the preset lookup uses the basename. This test verifies
|
|
613
|
+
// that the validation in stepApplyPreset catches unsafe names.
|
|
614
|
+
// We test indirectly: create an agent with a safe name, verify it works.
|
|
615
|
+
const filePath = join(tmpDir, "safe-agent.md");
|
|
616
|
+
writeFileSync(filePath, makeAgentMd("safe-agent", "opencode/gpt-4", 0.3));
|
|
617
|
+
|
|
618
|
+
const result = applyPresetToFile(filePath, { model: "new/model" }, false);
|
|
619
|
+
expect(result).toBe("updated");
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// ─── Idempotency ─────────────────────────────────────────────────────────────
|
|
624
|
+
|
|
625
|
+
describe("idempotency", () => {
|
|
626
|
+
test("re-running preset application produces same result", () => {
|
|
627
|
+
const filePath = join(tmpDir, "agent.md");
|
|
628
|
+
writeFileSync(filePath, makeAgentMd("test", "opencode/gpt-4", 0.3, "low"));
|
|
629
|
+
|
|
630
|
+
const preset: PresetEntry = { model: "new/model", temperature: 0.5, reasoning_effort: "high" };
|
|
631
|
+
|
|
632
|
+
applyPresetToFile(filePath, preset, false);
|
|
633
|
+
const first = readFileSync(filePath, "utf-8");
|
|
634
|
+
|
|
635
|
+
applyPresetToFile(filePath, preset, false);
|
|
636
|
+
const second = readFileSync(filePath, "utf-8");
|
|
637
|
+
|
|
638
|
+
expect(first).toBe(second);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("re-running plugin registration produces same opencode.json", () => {
|
|
642
|
+
const opencodePath = join(configDir, "opencode.json");
|
|
643
|
+
writeFileSync(opencodePath, JSON.stringify({ plugin: [] }));
|
|
644
|
+
|
|
645
|
+
const config: NdomoConfig = { plugins: ["ndomo", "opencode-mem"] };
|
|
646
|
+
|
|
647
|
+
stepRegisterPlugins(configDir, config, backupDir, false);
|
|
648
|
+
const first = readFileSync(opencodePath, "utf-8");
|
|
649
|
+
|
|
650
|
+
stepRegisterPlugins(configDir, config, backupDir, false);
|
|
651
|
+
const second = readFileSync(opencodePath, "utf-8");
|
|
652
|
+
|
|
653
|
+
expect(first).toBe(second);
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
describe("stepCopyTools", () => {
|
|
658
|
+
test("copies .ts files from project tools/ to config tools/", () => {
|
|
659
|
+
const toolsDir = join(projectRoot, "tools");
|
|
660
|
+
mkdirSync(toolsDir, { recursive: true });
|
|
661
|
+
writeFileSync(join(toolsDir, "plan_create.ts"), "// plan_create\n");
|
|
662
|
+
writeFileSync(join(toolsDir, "memory_search.ts"), "// memory_search\n");
|
|
663
|
+
|
|
664
|
+
const copied = stepCopyTools(projectRoot, configDir, false);
|
|
665
|
+
|
|
666
|
+
expect(copied).toBe(2);
|
|
667
|
+
expect(existsSync(join(configDir, "tools", "plan_create.ts"))).toBe(true);
|
|
668
|
+
expect(existsSync(join(configDir, "tools", "memory_search.ts"))).toBe(true);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test("idempotent: same content → skip, returns 0", () => {
|
|
672
|
+
const toolsDir = join(projectRoot, "tools");
|
|
673
|
+
mkdirSync(toolsDir, { recursive: true });
|
|
674
|
+
writeFileSync(join(toolsDir, "plan_create.ts"), "// plan_create\n");
|
|
675
|
+
mkdirSync(join(configDir, "tools"), { recursive: true });
|
|
676
|
+
writeFileSync(join(configDir, "tools", "plan_create.ts"), "// plan_create\n");
|
|
677
|
+
|
|
678
|
+
const copied = stepCopyTools(projectRoot, configDir, false);
|
|
679
|
+
|
|
680
|
+
expect(copied).toBe(0);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test("changed content → backup old + copy new", () => {
|
|
684
|
+
const toolsDir = join(projectRoot, "tools");
|
|
685
|
+
mkdirSync(toolsDir, { recursive: true });
|
|
686
|
+
writeFileSync(join(toolsDir, "plan_create.ts"), "// NEW plan_create\n");
|
|
687
|
+
mkdirSync(join(configDir, "tools"), { recursive: true });
|
|
688
|
+
writeFileSync(join(configDir, "tools", "plan_create.ts"), "// OLD plan_create\n");
|
|
689
|
+
|
|
690
|
+
const copied = stepCopyTools(projectRoot, configDir, false);
|
|
691
|
+
|
|
692
|
+
expect(copied).toBe(1);
|
|
693
|
+
const dst = readFileSync(join(configDir, "tools", "plan_create.ts"), "utf-8");
|
|
694
|
+
expect(dst).toBe("// NEW plan_create\n");
|
|
695
|
+
// backup should exist somewhere in configDir/.backup-*
|
|
696
|
+
const { readdirSync } = require("node:fs");
|
|
697
|
+
const entries = readdirSync(configDir);
|
|
698
|
+
const backupDirName = entries.find((e: string) => e.startsWith(".backup-"));
|
|
699
|
+
expect(backupDirName).toBeDefined();
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
test("skips non-.ts files", () => {
|
|
703
|
+
const toolsDir = join(projectRoot, "tools");
|
|
704
|
+
mkdirSync(toolsDir, { recursive: true });
|
|
705
|
+
writeFileSync(join(toolsDir, "tool.ts"), "// tool\n");
|
|
706
|
+
writeFileSync(join(toolsDir, "README.md"), "# README\n");
|
|
707
|
+
writeFileSync(join(toolsDir, "script.sh"), "#!/bin/bash\n");
|
|
708
|
+
|
|
709
|
+
const copied = stepCopyTools(projectRoot, configDir, false);
|
|
710
|
+
|
|
711
|
+
expect(copied).toBe(1);
|
|
712
|
+
expect(existsSync(join(configDir, "tools", "tool.ts"))).toBe(true);
|
|
713
|
+
expect(existsSync(join(configDir, "tools", "README.md"))).toBe(false);
|
|
714
|
+
expect(existsSync(join(configDir, "tools", "script.sh"))).toBe(false);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test("dry-run does not modify files", () => {
|
|
718
|
+
const toolsDir = join(projectRoot, "tools");
|
|
719
|
+
mkdirSync(toolsDir, { recursive: true });
|
|
720
|
+
writeFileSync(join(toolsDir, "tool.ts"), "// tool\n");
|
|
721
|
+
|
|
722
|
+
const copied = stepCopyTools(projectRoot, configDir, true);
|
|
723
|
+
|
|
724
|
+
expect(copied).toBe(0);
|
|
725
|
+
expect(existsSync(join(configDir, "tools", "tool.ts"))).toBe(false);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test("returns 0 when no tools/ dir exists", () => {
|
|
729
|
+
// projectRoot has no tools/ (default setup in beforeEach)
|
|
730
|
+
const copied = stepCopyTools(projectRoot, configDir, false);
|
|
731
|
+
expect(copied).toBe(0);
|
|
732
|
+
});
|
|
733
|
+
});
|