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,1273 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* ndomo install — TypeScript port of scripts/install.sh
|
|
4
|
+
*
|
|
5
|
+
* Installs agents, skills, and config into ~/.config/opencode/.
|
|
6
|
+
* Supports preset application, plugin registration, 3-strategy package install,
|
|
7
|
+
* HTTP auto-prompt, and DCP opt-in.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* bun run src/cli/install.ts
|
|
11
|
+
* bun run src/cli/install.ts --preset=budget --enable-http
|
|
12
|
+
* bun run src/cli/install.ts --dry-run --skip-deps
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, mkdirSync, copyFileSync, symlinkSync, lstatSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
16
|
+
import { join, basename, dirname } from "node:path";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { resolveConfigDir, type HttpConfig, type NdomoConfig } from "../config/schema.ts";
|
|
19
|
+
|
|
20
|
+
// ─── ANSI colors (no external deps) ──────────────────────────────────────────
|
|
21
|
+
const RED = "\x1b[0;31m";
|
|
22
|
+
const GREEN = "\x1b[0;32m";
|
|
23
|
+
const YELLOW = "\x1b[1;33m";
|
|
24
|
+
const BLUE = "\x1b[0;34m";
|
|
25
|
+
const BOLD = "\x1b[1m";
|
|
26
|
+
const NC = "\x1b[0m";
|
|
27
|
+
|
|
28
|
+
const info = (msg: string): void => console.log(`${BLUE}[info]${NC} ${msg}`);
|
|
29
|
+
const ok = (msg: string): void => console.log(`${GREEN}[ok]${NC} ${msg}`);
|
|
30
|
+
const warn = (msg: string): void => console.error(`${YELLOW}[warn]${NC} ${msg}`);
|
|
31
|
+
const err = (msg: string): void => console.error(`${RED}[error]${NC} ${msg}`);
|
|
32
|
+
const die = (msg: string): never => {
|
|
33
|
+
err(msg);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Stream a child process, returning stdout/stderr. On non-zero exit, die() with truncated output. */
|
|
38
|
+
async function streamSpawn(
|
|
39
|
+
cmd: string[],
|
|
40
|
+
opts: { cwd?: string; label?: string; nothrow?: boolean } = {},
|
|
41
|
+
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
42
|
+
const spawnOpts: { cwd?: string; stdout: "pipe"; stderr: "pipe" } = {
|
|
43
|
+
stdout: "pipe",
|
|
44
|
+
stderr: "pipe",
|
|
45
|
+
};
|
|
46
|
+
if (opts.cwd !== undefined) spawnOpts.cwd = opts.cwd;
|
|
47
|
+
const proc = Bun.spawn(cmd, spawnOpts);
|
|
48
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
49
|
+
new Response(proc.stdout).text(),
|
|
50
|
+
new Response(proc.stderr).text(),
|
|
51
|
+
proc.exited,
|
|
52
|
+
]);
|
|
53
|
+
if (exitCode !== 0 && !opts.nothrow) {
|
|
54
|
+
const truncate = (s: string) => (s.length > 1024 ? s.slice(0, 1024) + "\n... [truncated]" : s);
|
|
55
|
+
const label = opts.label ?? cmd.join(" ");
|
|
56
|
+
die(`${label} failed (exit ${exitCode})\nstderr:\n${truncate(stderr)}\nstdout:\n${truncate(stdout)}`);
|
|
57
|
+
}
|
|
58
|
+
return { exitCode, stdout, stderr };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Path traversal protection ────────────────────────────────────────────────
|
|
62
|
+
const SAFE_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
63
|
+
|
|
64
|
+
function assertSafeFilename(name: string): boolean {
|
|
65
|
+
return SAFE_NAME_RE.test(name);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Type definitions ─────────────────────────────────────────────────────────
|
|
69
|
+
export type InstallFlags = {
|
|
70
|
+
preset: string;
|
|
71
|
+
provider: string;
|
|
72
|
+
noProviderPrompt: boolean;
|
|
73
|
+
withDcp: boolean;
|
|
74
|
+
dryRun: boolean;
|
|
75
|
+
skipDeps: boolean;
|
|
76
|
+
enableHttp: boolean;
|
|
77
|
+
disableHttp: boolean;
|
|
78
|
+
corsOrigins: string;
|
|
79
|
+
port: number;
|
|
80
|
+
authRequired: boolean;
|
|
81
|
+
uninstall: boolean;
|
|
82
|
+
help: boolean;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export type PresetEntry = {
|
|
86
|
+
model?: string;
|
|
87
|
+
temperature?: number;
|
|
88
|
+
reasoning_effort?: string;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// ─── Flag parsing ─────────────────────────────────────────────────────────────
|
|
92
|
+
export function parseFlags(args: string[]): InstallFlags {
|
|
93
|
+
const flags: InstallFlags = {
|
|
94
|
+
preset: "default",
|
|
95
|
+
provider: "",
|
|
96
|
+
noProviderPrompt: false,
|
|
97
|
+
withDcp: false,
|
|
98
|
+
dryRun: false,
|
|
99
|
+
skipDeps: false,
|
|
100
|
+
enableHttp: false,
|
|
101
|
+
disableHttp: false,
|
|
102
|
+
corsOrigins: "*",
|
|
103
|
+
port: 4097,
|
|
104
|
+
authRequired: true,
|
|
105
|
+
uninstall: false,
|
|
106
|
+
help: false,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
for (const arg of args) {
|
|
110
|
+
if (arg === "--help" || arg === "-h") {
|
|
111
|
+
flags.help = true;
|
|
112
|
+
} else if (arg === "--with-dcp") {
|
|
113
|
+
flags.withDcp = true;
|
|
114
|
+
} else if (arg === "--dry-run") {
|
|
115
|
+
flags.dryRun = true;
|
|
116
|
+
} else if (arg === "--skip-deps") {
|
|
117
|
+
flags.skipDeps = true;
|
|
118
|
+
} else if (arg === "--enable-http") {
|
|
119
|
+
flags.enableHttp = true;
|
|
120
|
+
} else if (arg === "--disable-http") {
|
|
121
|
+
flags.disableHttp = true;
|
|
122
|
+
} else if (arg === "--no-provider-prompt") {
|
|
123
|
+
flags.noProviderPrompt = true;
|
|
124
|
+
} else if (arg.startsWith("--preset=")) {
|
|
125
|
+
flags.preset = arg.slice("--preset=".length);
|
|
126
|
+
} else if (arg.startsWith("--provider=")) {
|
|
127
|
+
flags.provider = arg.slice("--provider=".length);
|
|
128
|
+
} else if (arg.startsWith("--cors-origins=")) {
|
|
129
|
+
flags.corsOrigins = arg.slice("--cors-origins=".length);
|
|
130
|
+
} else if (arg.startsWith("--port=")) {
|
|
131
|
+
const val = Number(arg.slice("--port=".length));
|
|
132
|
+
if (!Number.isNaN(val) && val > 0 && val < 65536) {
|
|
133
|
+
flags.port = val;
|
|
134
|
+
}
|
|
135
|
+
} else if (arg.startsWith("--auth-required=")) {
|
|
136
|
+
flags.authRequired = arg.slice("--auth-required=".length) !== "false";
|
|
137
|
+
} else if (arg === "--uninstall") {
|
|
138
|
+
flags.uninstall = true;
|
|
139
|
+
} else if (arg.startsWith("-")) {
|
|
140
|
+
throw new Error(`Unknown option: ${arg} (try --help)`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return flags;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── Help text ────────────────────────────────────────────────────────────────
|
|
148
|
+
export function printHelp(): void {
|
|
149
|
+
console.log(`${BOLD}ndomo installer — agent preset & provider tool${NC}
|
|
150
|
+
|
|
151
|
+
${BOLD}Usage:${NC}
|
|
152
|
+
bun run src/cli/install.ts [OPTIONS]
|
|
153
|
+
ndomo install [OPTIONS]
|
|
154
|
+
|
|
155
|
+
${BOLD}Options:${NC}
|
|
156
|
+
--preset=NAME Use preset from ndomo.config.json (default: "default")
|
|
157
|
+
--provider=ID Override provider prefix (e.g., opencode, anthropic)
|
|
158
|
+
--no-provider-prompt Skip interactive provider override prompt
|
|
159
|
+
--with-dcp Also install @tarquinen/opencode-dcp
|
|
160
|
+
--dry-run Print planned changes, do not write
|
|
161
|
+
--skip-deps Skip 'bun install' step
|
|
162
|
+
--enable-http Auto-enable HTTP server (writes http block to ndomo.config.json)
|
|
163
|
+
--disable-http Skip HTTP auto-prompt (default in non-TTY)
|
|
164
|
+
--cors-origins=CSV Override CORS origins (default: *)
|
|
165
|
+
--port=N Override HTTP port (default: 4097)
|
|
166
|
+
--auth-required=BOOL Override auth requirement (default: true)
|
|
167
|
+
--uninstall Run uninstaller (compat, execs scripts/uninstall.sh)
|
|
168
|
+
--help, -h Show this help
|
|
169
|
+
|
|
170
|
+
${BOLD}Environment:${NC}
|
|
171
|
+
NDOMO_SKIP_PACKAGE_INSTALL=1 Skip ndomo package installation
|
|
172
|
+
XDG_CONFIG_HOME Override config directory (default: ~/.config)
|
|
173
|
+
|
|
174
|
+
${BOLD}Examples:${NC}
|
|
175
|
+
bun run src/cli/install.ts # apply default preset
|
|
176
|
+
bun run src/cli/install.ts --preset=budget # apply budget preset
|
|
177
|
+
bun run src/cli/install.ts --provider=opencode # swap provider prefix
|
|
178
|
+
bun run src/cli/install.ts --enable-http # enable HTTP server
|
|
179
|
+
bun run src/cli/install.ts --dry-run # preview changes`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─── Project root detection ───────────────────────────────────────────────────
|
|
183
|
+
export function detectProjectRoot(): string {
|
|
184
|
+
// Walk up from __dirname to find package.json
|
|
185
|
+
let dir = import.meta.dir;
|
|
186
|
+
while (dir !== "/" && dir !== homedir()) {
|
|
187
|
+
if (existsSync(join(dir, "package.json"))) {
|
|
188
|
+
// Verify it's ndomo (has agents/ dir or src/cli/)
|
|
189
|
+
if (existsSync(join(dir, "agents")) || existsSync(join(dir, "src", "cli"))) {
|
|
190
|
+
return dir;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
dir = dirname(dir);
|
|
194
|
+
}
|
|
195
|
+
// Fallback: two levels up from src/cli/
|
|
196
|
+
return join(import.meta.dir, "..", "..");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Step helpers ─────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/** Step 1: Install dependencies. */
|
|
202
|
+
export async function stepInstallDeps(projectRoot: string, dryRun: boolean): Promise<void> {
|
|
203
|
+
info("Installing dependencies...");
|
|
204
|
+
if (dryRun) {
|
|
205
|
+
info("[dry-run] would run: bun install --frozen-lockfile");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const frozen = await streamSpawn(["bun", "install", "--frozen-lockfile"], {
|
|
209
|
+
cwd: projectRoot,
|
|
210
|
+
label: "bun install --frozen-lockfile",
|
|
211
|
+
nothrow: true,
|
|
212
|
+
});
|
|
213
|
+
if (frozen.exitCode !== 0) {
|
|
214
|
+
// Fallback to non-frozen
|
|
215
|
+
warn("frozen lockfile failed, retrying without --frozen-lockfile...");
|
|
216
|
+
await streamSpawn(["bun", "install"], {
|
|
217
|
+
cwd: projectRoot,
|
|
218
|
+
label: "bun install",
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
ok("Dependencies installed");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Step 2: Copy agents with timestamped backup. */
|
|
225
|
+
export function stepCopyAgents(
|
|
226
|
+
projectRoot: string,
|
|
227
|
+
configDir: string,
|
|
228
|
+
backupDir: string,
|
|
229
|
+
dryRun: boolean,
|
|
230
|
+
): number {
|
|
231
|
+
const agentSrc = join(projectRoot, "agents");
|
|
232
|
+
const agentDst = join(configDir, "agent");
|
|
233
|
+
|
|
234
|
+
if (!existsSync(agentSrc)) {
|
|
235
|
+
warn("No agents/ directory found in project root");
|
|
236
|
+
return 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
mkdirSync(agentDst, { recursive: true });
|
|
240
|
+
|
|
241
|
+
const files = readdirSafe(agentSrc).filter((f) => f.endsWith(".md"));
|
|
242
|
+
let backedUp = 0;
|
|
243
|
+
let copied = 0;
|
|
244
|
+
|
|
245
|
+
for (const file of files) {
|
|
246
|
+
const srcFile = join(agentSrc, file);
|
|
247
|
+
const dstFile = join(agentDst, file);
|
|
248
|
+
|
|
249
|
+
// Backup existing
|
|
250
|
+
if (existsSync(dstFile)) {
|
|
251
|
+
if (backedUp === 0) {
|
|
252
|
+
mkdirSync(backupDir, { recursive: true });
|
|
253
|
+
info(`Backing up existing agents to ${backupDir}`);
|
|
254
|
+
}
|
|
255
|
+
if (!dryRun) {
|
|
256
|
+
copyFileSync(dstFile, join(backupDir, file));
|
|
257
|
+
}
|
|
258
|
+
backedUp++;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (dryRun) {
|
|
262
|
+
info(`[dry-run] would copy agent: ${file}`);
|
|
263
|
+
} else {
|
|
264
|
+
copyFileSync(srcFile, dstFile);
|
|
265
|
+
}
|
|
266
|
+
copied++;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (backedUp > 0) {
|
|
270
|
+
ok(`Backed up ${backedUp} existing agent(s)`);
|
|
271
|
+
}
|
|
272
|
+
if (copied > 0) {
|
|
273
|
+
ok(`Copied ${copied} agent(s) to ${agentDst}`);
|
|
274
|
+
} else {
|
|
275
|
+
warn("No agent .md files found");
|
|
276
|
+
}
|
|
277
|
+
return copied;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Step 3: Copy skills with timestamped backup. */
|
|
281
|
+
export function stepCopySkills(
|
|
282
|
+
projectRoot: string,
|
|
283
|
+
configDir: string,
|
|
284
|
+
backupDir: string,
|
|
285
|
+
dryRun: boolean,
|
|
286
|
+
): number {
|
|
287
|
+
const skillSrc = join(projectRoot, "skills");
|
|
288
|
+
const skillDst = join(configDir, "skills");
|
|
289
|
+
|
|
290
|
+
if (!existsSync(skillSrc)) {
|
|
291
|
+
warn("No skills/ directory found in project root");
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
mkdirSync(skillDst, { recursive: true });
|
|
296
|
+
|
|
297
|
+
const dirs = readdirSafe(skillSrc).filter((d) => {
|
|
298
|
+
const full = join(skillSrc, d);
|
|
299
|
+
return existsSync(full) && lstatSync(full).isDirectory();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
let backedUp = 0;
|
|
303
|
+
let copied = 0;
|
|
304
|
+
|
|
305
|
+
for (const name of dirs) {
|
|
306
|
+
const srcDir = join(skillSrc, name);
|
|
307
|
+
const dstDir = join(skillDst, name);
|
|
308
|
+
|
|
309
|
+
// Backup existing
|
|
310
|
+
if (existsSync(dstDir)) {
|
|
311
|
+
if (backedUp === 0) {
|
|
312
|
+
mkdirSync(join(backupDir, "skills"), { recursive: true });
|
|
313
|
+
info(`Backing up existing skills to ${backupDir}/skills`);
|
|
314
|
+
}
|
|
315
|
+
if (!dryRun) {
|
|
316
|
+
cpSyncRecursive(dstDir, join(backupDir, "skills", name));
|
|
317
|
+
}
|
|
318
|
+
backedUp++;
|
|
319
|
+
// Remove existing before copy (bash: rm -rf then cp -r)
|
|
320
|
+
if (!dryRun) {
|
|
321
|
+
rmSync(dstDir, { recursive: true, force: true });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (dryRun) {
|
|
326
|
+
info(`[dry-run] would copy skill: ${name}/`);
|
|
327
|
+
} else {
|
|
328
|
+
cpSyncRecursive(srcDir, dstDir);
|
|
329
|
+
}
|
|
330
|
+
copied++;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (backedUp > 0) {
|
|
334
|
+
ok(`Backed up ${backedUp} existing skill(s)`);
|
|
335
|
+
}
|
|
336
|
+
if (copied > 0) {
|
|
337
|
+
ok(`Copied ${copied} skill(s) to ${skillDst}`);
|
|
338
|
+
}
|
|
339
|
+
return copied;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ─── Preset application ──────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Parse YAML frontmatter from an agent .md file.
|
|
346
|
+
* Returns { frontmatter: Record<string, string>, body: string, raw: string }.
|
|
347
|
+
* Frontmatter is between the first two '---' lines.
|
|
348
|
+
*/
|
|
349
|
+
export function parseFrontmatter(content: string): {
|
|
350
|
+
frontmatter: Record<string, string>;
|
|
351
|
+
body: string;
|
|
352
|
+
startIdx: number;
|
|
353
|
+
endIdx: number;
|
|
354
|
+
} {
|
|
355
|
+
const lines = content.split("\n");
|
|
356
|
+
let startIdx = -1;
|
|
357
|
+
let endIdx = -1;
|
|
358
|
+
|
|
359
|
+
for (let i = 0; i < lines.length; i++) {
|
|
360
|
+
const line = lines[i];
|
|
361
|
+
if (line !== undefined && line.trim() === "---") {
|
|
362
|
+
if (startIdx === -1) {
|
|
363
|
+
startIdx = i;
|
|
364
|
+
} else {
|
|
365
|
+
endIdx = i;
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (startIdx === -1 || endIdx === -1) {
|
|
372
|
+
return { frontmatter: {}, body: content, startIdx: -1, endIdx: -1 };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const fm: Record<string, string> = {};
|
|
376
|
+
for (let i = startIdx + 1; i < endIdx; i++) {
|
|
377
|
+
const line = lines[i];
|
|
378
|
+
if (line === undefined) continue;
|
|
379
|
+
const colonIdx = line.indexOf(":");
|
|
380
|
+
if (colonIdx > 0) {
|
|
381
|
+
const key = line.slice(0, colonIdx).trim();
|
|
382
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
383
|
+
fm[key] = value;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const body = lines.slice(endIdx + 1).join("\n");
|
|
388
|
+
return { frontmatter: fm, body, startIdx, endIdx };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Serialize frontmatter + body back to a string.
|
|
393
|
+
*/
|
|
394
|
+
export function serializeFrontmatter(frontmatter: Record<string, string>, body: string): string {
|
|
395
|
+
const fmLines = Object.entries(frontmatter).map(([k, v]) => `${k}: ${v}`);
|
|
396
|
+
return `---\n${fmLines.join("\n")}\n---\n${body}`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Apply preset values to an agent .md file's frontmatter.
|
|
401
|
+
* Handles reasoningEffort 3-tier insert fallback:
|
|
402
|
+
* 1. Update existing reasoningEffort line
|
|
403
|
+
* 2. Insert after temperature (if present)
|
|
404
|
+
* 3. Insert after model (if present)
|
|
405
|
+
* 4. Insert after opening ---
|
|
406
|
+
*/
|
|
407
|
+
export function applyPresetToFile(
|
|
408
|
+
filePath: string,
|
|
409
|
+
preset: PresetEntry,
|
|
410
|
+
dryRun: boolean,
|
|
411
|
+
): "updated" | "skipped" {
|
|
412
|
+
const content = readFileSync(filePath, "utf-8");
|
|
413
|
+
const { frontmatter, body, startIdx, endIdx } = parseFrontmatter(content);
|
|
414
|
+
|
|
415
|
+
if (startIdx === -1 || endIdx === -1) {
|
|
416
|
+
warn(`No frontmatter found in ${basename(filePath)}, skipping`);
|
|
417
|
+
return "skipped";
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
let changed = false;
|
|
421
|
+
|
|
422
|
+
// Apply model
|
|
423
|
+
if (preset.model) {
|
|
424
|
+
frontmatter["model"] = preset.model;
|
|
425
|
+
changed = true;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Apply temperature
|
|
429
|
+
if (preset.temperature !== undefined) {
|
|
430
|
+
frontmatter["temperature"] = String(preset.temperature);
|
|
431
|
+
changed = true;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Apply reasoningEffort (snake_case → camelCase)
|
|
435
|
+
if (preset.reasoning_effort) {
|
|
436
|
+
frontmatter["reasoningEffort"] = preset.reasoning_effort;
|
|
437
|
+
changed = true;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (!changed) {
|
|
441
|
+
return "skipped";
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Serialize back preserving order: model, temperature, reasoningEffort, then others
|
|
445
|
+
const ordered: Record<string, string> = {};
|
|
446
|
+
const priority = ["model", "temperature", "reasoningEffort"];
|
|
447
|
+
for (const key of priority) {
|
|
448
|
+
if (frontmatter[key] !== undefined) {
|
|
449
|
+
ordered[key] = frontmatter[key];
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
for (const [key, value] of Object.entries(frontmatter)) {
|
|
453
|
+
if (!(key in ordered)) {
|
|
454
|
+
ordered[key] = value;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (dryRun) {
|
|
459
|
+
info(`[dry-run] would update ${basename(filePath)}: model=${preset.model ?? "(keep)"}, temp=${preset.temperature ?? "(keep)"}, effort=${preset.reasoning_effort ?? "(keep)"}`);
|
|
460
|
+
} else {
|
|
461
|
+
writeFileSync(filePath, serializeFrontmatter(ordered, body));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return "updated";
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Apply provider prefix to model lines in agent .md files.
|
|
469
|
+
* Replaces the provider/ prefix on model: lines (e.g., "opencode/gpt-4" → "anthropic/gpt-4").
|
|
470
|
+
*/
|
|
471
|
+
export function applyProviderPrefix(
|
|
472
|
+
agentDir: string,
|
|
473
|
+
provider: string,
|
|
474
|
+
dryRun: boolean,
|
|
475
|
+
): number {
|
|
476
|
+
const files = readdirSafe(agentDir).filter((f) => f.endsWith(".md"));
|
|
477
|
+
let updated = 0;
|
|
478
|
+
|
|
479
|
+
for (const file of files) {
|
|
480
|
+
const filePath = join(agentDir, file);
|
|
481
|
+
const content = readFileSync(filePath, "utf-8");
|
|
482
|
+
const lines = content.split("\n");
|
|
483
|
+
let changed = false;
|
|
484
|
+
|
|
485
|
+
for (let i = 0; i < lines.length; i++) {
|
|
486
|
+
const line = lines[i];
|
|
487
|
+
if (line === undefined) continue;
|
|
488
|
+
if (line.startsWith("model: ")) {
|
|
489
|
+
const value = line.slice("model: ".length);
|
|
490
|
+
const slashIdx = value.indexOf("/");
|
|
491
|
+
if (slashIdx > 0) {
|
|
492
|
+
const newValue = `${provider}/${value.slice(slashIdx + 1)}`;
|
|
493
|
+
lines[i] = `model: ${newValue}`;
|
|
494
|
+
changed = true;
|
|
495
|
+
break; // Only first model line
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (changed) {
|
|
501
|
+
if (dryRun) {
|
|
502
|
+
info(`[dry-run] would apply provider prefix '${provider}/' to ${file}`);
|
|
503
|
+
} else {
|
|
504
|
+
writeFileSync(filePath, lines.join("\n"));
|
|
505
|
+
}
|
|
506
|
+
updated++;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return updated;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Step 3.5: Apply preset + optional provider prefix override. */
|
|
514
|
+
export function stepApplyPreset(
|
|
515
|
+
configDir: string,
|
|
516
|
+
configJson: NdomoConfig,
|
|
517
|
+
preset: string,
|
|
518
|
+
provider: string,
|
|
519
|
+
dryRun: boolean,
|
|
520
|
+
): void {
|
|
521
|
+
const agentDir = join(configDir, "agent");
|
|
522
|
+
|
|
523
|
+
if (!existsSync(agentDir)) {
|
|
524
|
+
warn("No agent directory found, skipping preset application");
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const presets = configJson.presets;
|
|
529
|
+
if (!presets || !presets[preset]) {
|
|
530
|
+
warn(`Preset '${preset}' not found in ndomo.config.json, skipping`);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const presetData = presets[preset];
|
|
535
|
+
const files = readdirSafe(agentDir).filter((f) => f.endsWith(".md"));
|
|
536
|
+
let updated = 0;
|
|
537
|
+
let skipped = 0;
|
|
538
|
+
|
|
539
|
+
for (const file of files) {
|
|
540
|
+
const name = file.replace(/\.md$/, "");
|
|
541
|
+
if (!assertSafeFilename(name)) {
|
|
542
|
+
warn(`Skipping invalid agent name: '${name}'`);
|
|
543
|
+
skipped++;
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const entry = presetData[name];
|
|
548
|
+
if (!entry) {
|
|
549
|
+
warn(`Agent '${name}' has no entry in preset '${preset}', skipping`);
|
|
550
|
+
skipped++;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const result = applyPresetToFile(join(agentDir, file), entry, dryRun);
|
|
555
|
+
if (result === "updated") {
|
|
556
|
+
updated++;
|
|
557
|
+
} else {
|
|
558
|
+
skipped++;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
ok(`Applied preset '${preset}' — ${updated} agent(s) updated`);
|
|
563
|
+
if (skipped > 0) {
|
|
564
|
+
warn(`${skipped} agent(s) skipped`);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Apply provider prefix if requested
|
|
568
|
+
if (provider) {
|
|
569
|
+
const updatedP = applyProviderPrefix(agentDir, provider, dryRun);
|
|
570
|
+
ok(`Provider prefix override '${provider}/' applied to ${updatedP} agent(s)`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ─── Step 4: Copy config files ───────────────────────────────────────────────
|
|
575
|
+
export function stepCopyConfig(
|
|
576
|
+
projectRoot: string,
|
|
577
|
+
configDir: string,
|
|
578
|
+
backupDir: string,
|
|
579
|
+
dryRun: boolean,
|
|
580
|
+
): void {
|
|
581
|
+
const configJson = join(projectRoot, "config", "ndomo.config.json");
|
|
582
|
+
const schemaJson = join(projectRoot, "config", "ndomo.schema.json");
|
|
583
|
+
|
|
584
|
+
if (existsSync(configJson)) {
|
|
585
|
+
const dst = join(configDir, "ndomo.json");
|
|
586
|
+
if (existsSync(dst)) {
|
|
587
|
+
mkdirSync(backupDir, { recursive: true });
|
|
588
|
+
if (!dryRun) {
|
|
589
|
+
copyFileSync(dst, join(backupDir, "ndomo.json"));
|
|
590
|
+
}
|
|
591
|
+
info("Backed up existing ndomo.json");
|
|
592
|
+
}
|
|
593
|
+
if (dryRun) {
|
|
594
|
+
info("[dry-run] would copy ndomo.config.json -> ndomo.json");
|
|
595
|
+
} else {
|
|
596
|
+
copyFileSync(configJson, dst);
|
|
597
|
+
}
|
|
598
|
+
ok("Copied config.json -> ndomo.json");
|
|
599
|
+
} else {
|
|
600
|
+
warn("No config/ndomo.config.json found");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (existsSync(schemaJson)) {
|
|
604
|
+
const dst = join(configDir, "ndomo.schema.json");
|
|
605
|
+
if (existsSync(dst)) {
|
|
606
|
+
mkdirSync(backupDir, { recursive: true });
|
|
607
|
+
if (!dryRun) {
|
|
608
|
+
copyFileSync(dst, join(backupDir, "ndomo.schema.json"));
|
|
609
|
+
}
|
|
610
|
+
info("Backed up existing ndomo.schema.json");
|
|
611
|
+
}
|
|
612
|
+
if (dryRun) {
|
|
613
|
+
info("[dry-run] would copy ndomo.schema.json");
|
|
614
|
+
} else {
|
|
615
|
+
copyFileSync(schemaJson, dst);
|
|
616
|
+
}
|
|
617
|
+
ok("Copied ndomo.schema.json");
|
|
618
|
+
} else {
|
|
619
|
+
warn("No config/ndomo.schema.json found");
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// ─── Step 4.5: Register plugins in opencode.json ─────────────────────────────
|
|
624
|
+
export function stepRegisterPlugins(
|
|
625
|
+
configDir: string,
|
|
626
|
+
configJson: NdomoConfig,
|
|
627
|
+
backupDir: string,
|
|
628
|
+
dryRun: boolean,
|
|
629
|
+
): void {
|
|
630
|
+
const opencodeJsonPath = join(configDir, "opencode.json");
|
|
631
|
+
|
|
632
|
+
// Create opencode.json if missing
|
|
633
|
+
if (!existsSync(opencodeJsonPath)) {
|
|
634
|
+
if (!dryRun) {
|
|
635
|
+
writeFileSync(opencodeJsonPath, "{}");
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Backup
|
|
640
|
+
if (existsSync(opencodeJsonPath)) {
|
|
641
|
+
const backupPath = join(backupDir, "opencode.json");
|
|
642
|
+
if (!existsSync(backupPath)) {
|
|
643
|
+
mkdirSync(backupDir, { recursive: true });
|
|
644
|
+
if (!dryRun) {
|
|
645
|
+
copyFileSync(opencodeJsonPath, backupPath);
|
|
646
|
+
}
|
|
647
|
+
info("Backed up opencode.json");
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Extract deduped union of plugins + optionalPlugins
|
|
652
|
+
const plugins = configJson.plugins ?? [];
|
|
653
|
+
const optionalPlugins = configJson.optionalPlugins ?? [];
|
|
654
|
+
const allPlugins = [...new Set([...plugins, ...optionalPlugins])].filter(
|
|
655
|
+
(p) => typeof p === "string" && p.length > 0,
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
if (allPlugins.length === 0) {
|
|
659
|
+
info("No ndomo plugins found to register");
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (dryRun) {
|
|
664
|
+
info(`[dry-run] would register ${allPlugins.length} plugin(s): ${allPlugins.join(", ")}`);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Read existing opencode.json
|
|
669
|
+
let opencode: Record<string, unknown> = {};
|
|
670
|
+
try {
|
|
671
|
+
opencode = JSON.parse(readFileSync(opencodeJsonPath, "utf-8"));
|
|
672
|
+
} catch {
|
|
673
|
+
opencode = {};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Merge with dedup
|
|
677
|
+
const existingPlugins: string[] = Array.isArray(opencode.plugin) ? opencode.plugin : [];
|
|
678
|
+
const merged = [...new Set([...existingPlugins, ...allPlugins])];
|
|
679
|
+
opencode.plugin = merged;
|
|
680
|
+
|
|
681
|
+
writeFileSync(opencodeJsonPath, JSON.stringify(opencode, null, 2) + "\n");
|
|
682
|
+
ok(`Registered ${allPlugins.length} ndomo plugin(s) in opencode.json`);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ─── Step 4.6: Install ndomo package (3 strategies) ──────────────────────────
|
|
686
|
+
|
|
687
|
+
function isSymlink(p: string): boolean {
|
|
688
|
+
try {
|
|
689
|
+
return lstatSync(p).isSymbolicLink();
|
|
690
|
+
} catch {
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Strategy 1: file: dep + bun install → real copy (no symlink).
|
|
697
|
+
* Mutates package.json to add ndomo as file: dep, then runs bun install.
|
|
698
|
+
*/
|
|
699
|
+
async function strategyFileDep(
|
|
700
|
+
projectRoot: string,
|
|
701
|
+
configDir: string,
|
|
702
|
+
): Promise<boolean> {
|
|
703
|
+
const pkgJsonPath = join(configDir, "package.json");
|
|
704
|
+
const nmNdomo = join(configDir, "node_modules", "ndomo");
|
|
705
|
+
|
|
706
|
+
info(`Adding ndomo file: dep to ${pkgJsonPath}`);
|
|
707
|
+
|
|
708
|
+
try {
|
|
709
|
+
let pkg: Record<string, unknown> = {};
|
|
710
|
+
try {
|
|
711
|
+
pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
|
|
712
|
+
} catch {
|
|
713
|
+
return false;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Mutate dependencies
|
|
717
|
+
const deps = (pkg.dependencies as Record<string, string>) ?? {};
|
|
718
|
+
deps.ndomo = `file://${projectRoot}`;
|
|
719
|
+
pkg.dependencies = deps;
|
|
720
|
+
writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
721
|
+
|
|
722
|
+
// Run bun install
|
|
723
|
+
const result = await streamSpawn(["bun", "install", "--no-frozen-lockfile"], {
|
|
724
|
+
cwd: configDir,
|
|
725
|
+
label: "bun install (file: dep)",
|
|
726
|
+
nothrow: true,
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
if (result.exitCode === 0 && existsSync(nmNdomo) && !isSymlink(nmNdomo)) {
|
|
730
|
+
ok("ndomo installed via bun (file: dep) — real copy, no symlink");
|
|
731
|
+
return true;
|
|
732
|
+
}
|
|
733
|
+
} catch {
|
|
734
|
+
// fall through to strategy 2
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
warn("bun install did not materialize ndomo as real copy, trying bun link...");
|
|
738
|
+
return false;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Strategy 2: bun link → managed symlink (bun-tracked).
|
|
743
|
+
*/
|
|
744
|
+
async function strategyBunLink(projectRoot: string, configDir: string): Promise<boolean> {
|
|
745
|
+
info("Trying bun link...");
|
|
746
|
+
|
|
747
|
+
try {
|
|
748
|
+
// bun link in project root (registers package)
|
|
749
|
+
const linkResult = await streamSpawn(["bun", "link"], {
|
|
750
|
+
cwd: projectRoot,
|
|
751
|
+
label: "bun link",
|
|
752
|
+
nothrow: true,
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
if (linkResult.exitCode !== 0) {
|
|
756
|
+
warn("bun link in project root failed");
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// bun link ndomo in config dir (links package)
|
|
761
|
+
const linkNdomo = await streamSpawn(["bun", "link", "ndomo"], {
|
|
762
|
+
cwd: configDir,
|
|
763
|
+
label: "bun link ndomo",
|
|
764
|
+
nothrow: true,
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
const nmNdomo = join(configDir, "node_modules", "ndomo");
|
|
768
|
+
if (linkNdomo.exitCode === 0 && existsSync(nmNdomo)) {
|
|
769
|
+
ok("ndomo linked via bun link (managed symlink)");
|
|
770
|
+
warn("bun link uses symlinks — run 'bun run dev:bust' if cache goes stale");
|
|
771
|
+
return true;
|
|
772
|
+
}
|
|
773
|
+
} catch {
|
|
774
|
+
// fall through to strategy 3
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
warn("bun link failed, falling back to manual symlink");
|
|
778
|
+
return false;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Strategy 3: manual symlink (last resort).
|
|
783
|
+
*/
|
|
784
|
+
function strategyManualSymlink(projectRoot: string, configDir: string): boolean {
|
|
785
|
+
const nmNdomo = join(configDir, "node_modules", "ndomo");
|
|
786
|
+
|
|
787
|
+
info(`Creating manual symlink: ${nmNdomo} -> ${projectRoot}`);
|
|
788
|
+
mkdirSync(join(configDir, "node_modules"), { recursive: true });
|
|
789
|
+
|
|
790
|
+
try {
|
|
791
|
+
symlinkSync(projectRoot, nmNdomo, "dir");
|
|
792
|
+
ok(`ndomo symlinked at ${nmNdomo} (last resort)`);
|
|
793
|
+
warn("manual symlink may cause Bun cache stale — run 'bun run dev:bust' to recover");
|
|
794
|
+
return true;
|
|
795
|
+
} catch {
|
|
796
|
+
err("Failed to install ndomo package");
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
export async function stepInstallPackage(
|
|
802
|
+
projectRoot: string,
|
|
803
|
+
configDir: string,
|
|
804
|
+
dryRun: boolean,
|
|
805
|
+
): Promise<void> {
|
|
806
|
+
// Skip if user opted out
|
|
807
|
+
if (process.env.NDOMO_SKIP_PACKAGE_INSTALL === "1") {
|
|
808
|
+
info("Skipping ndomo package install (NDOMO_SKIP_PACKAGE_INSTALL=1)");
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const nmNdomo = join(configDir, "node_modules", "ndomo");
|
|
813
|
+
|
|
814
|
+
// If existing install is a symlink, remove it
|
|
815
|
+
if (isSymlink(nmNdomo)) {
|
|
816
|
+
warn("Existing ndomo install is a symlink (causes Bun cache stale in dev)");
|
|
817
|
+
info("Removing symlink, will reinstall as real copy...");
|
|
818
|
+
if (!dryRun) {
|
|
819
|
+
rmSync(nmNdomo, { force: true });
|
|
820
|
+
}
|
|
821
|
+
} else if (existsSync(nmNdomo)) {
|
|
822
|
+
info(`ndomo already installed at ${nmNdomo}`);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (dryRun) {
|
|
827
|
+
info("[dry-run] would install ndomo package via 3-strategy cascade");
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Need package.json for strategy 1+2
|
|
832
|
+
const pkgJsonPath = join(configDir, "package.json");
|
|
833
|
+
if (!existsSync(pkgJsonPath)) {
|
|
834
|
+
warn(`${pkgJsonPath} not found, falling back to manual symlink`);
|
|
835
|
+
strategyManualSymlink(projectRoot, configDir);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Strategy 1: file: dep + bun install
|
|
840
|
+
if (await strategyFileDep(projectRoot, configDir)) {
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Strategy 2: bun link
|
|
845
|
+
if (await strategyBunLink(projectRoot, configDir)) {
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Strategy 3: manual symlink (last resort)
|
|
850
|
+
if (!strategyManualSymlink(projectRoot, configDir)) {
|
|
851
|
+
die("Failed to install ndomo package");
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// ─── Step 4.7: Copy custom tools ─────────────────────────────────────────────
|
|
856
|
+
// npm distribution: tools live inside the installed ndomo package, so symlink
|
|
857
|
+
// dance (used in old repo-based install) is obsolete. Copy .ts files directly.
|
|
858
|
+
export function stepCopyTools(
|
|
859
|
+
projectRoot: string,
|
|
860
|
+
configDir: string,
|
|
861
|
+
dryRun: boolean,
|
|
862
|
+
): number {
|
|
863
|
+
const src = join(projectRoot, "tools");
|
|
864
|
+
const dst = join(configDir, "tools");
|
|
865
|
+
|
|
866
|
+
if (!existsSync(src)) {
|
|
867
|
+
warn(`No tools/ directory found at ${src} — skipping`);
|
|
868
|
+
return 0;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
if (dryRun) {
|
|
872
|
+
info(`[dry-run] would copy tools from ${src} to ${dst}`);
|
|
873
|
+
return 0;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
mkdirSync(configDir, { recursive: true });
|
|
877
|
+
mkdirSync(dst, { recursive: true });
|
|
878
|
+
let copied = 0;
|
|
879
|
+
const entries = readdirSafe(src);
|
|
880
|
+
for (const entry of entries) {
|
|
881
|
+
const srcPath = join(src, entry);
|
|
882
|
+
const dstPath = join(dst, entry);
|
|
883
|
+
const stat = lstatSync(srcPath);
|
|
884
|
+
if (!stat.isFile() || !srcPath.endsWith(".ts")) {
|
|
885
|
+
// Skip non-.ts files (subdirs, etc.)
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
if (existsSync(dstPath)) {
|
|
889
|
+
// Idempotent: skip if content matches
|
|
890
|
+
const srcContent = readFileSync(srcPath, "utf-8");
|
|
891
|
+
const dstContent = readFileSync(dstPath, "utf-8");
|
|
892
|
+
if (srcContent === dstContent) {
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
// Backup changed file
|
|
896
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
897
|
+
const backupPath = join(configDir, `.backup-${ts}`, "tools", entry);
|
|
898
|
+
mkdirSync(dirname(backupPath), { recursive: true });
|
|
899
|
+
copyFileSync(dstPath, backupPath);
|
|
900
|
+
}
|
|
901
|
+
copyFileSync(srcPath, dstPath);
|
|
902
|
+
copied++;
|
|
903
|
+
}
|
|
904
|
+
if (copied > 0) {
|
|
905
|
+
ok(`Copied ${copied} tool file(s) to ${dst}`);
|
|
906
|
+
} else {
|
|
907
|
+
info(`Tool files already up to date at ${dst}`);
|
|
908
|
+
}
|
|
909
|
+
return copied;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// ─── Step 5: Inject preset name into ndomo.json ──────────────────────────────
|
|
913
|
+
export function stepInjectPreset(configDir: string, preset: string, dryRun: boolean): void {
|
|
914
|
+
const ndomoJsonPath = join(configDir, "ndomo.json");
|
|
915
|
+
|
|
916
|
+
if (!existsSync(ndomoJsonPath)) {
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
if (dryRun) {
|
|
921
|
+
info(`[dry-run] would inject preset '${preset}' into ndomo.json`);
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
try {
|
|
926
|
+
const ndomo: Record<string, unknown> = JSON.parse(readFileSync(ndomoJsonPath, "utf-8"));
|
|
927
|
+
ndomo.preset = preset;
|
|
928
|
+
writeFileSync(ndomoJsonPath, JSON.stringify(ndomo, null, 2) + "\n");
|
|
929
|
+
ok(`Preset '${preset}' written to ndomo.json`);
|
|
930
|
+
} catch (e) {
|
|
931
|
+
warn(`Failed to inject preset into ndomo.json: ${e}`);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// ─── Step 6: Optional DCP install ────────────────────────────────────────────
|
|
936
|
+
export async function stepInstallDcp(dryRun: boolean): Promise<void> {
|
|
937
|
+
info("Installing @tarquinen/opencode-dcp (AGPL-3.0)...");
|
|
938
|
+
if (dryRun) {
|
|
939
|
+
info("[dry-run] would run: opencode plugin @tarquinen/opencode-dcp --global");
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const result = await streamSpawn(["opencode", "plugin", "@tarquinen/opencode-dcp", "--global"], {
|
|
944
|
+
label: "opencode plugin dcp",
|
|
945
|
+
nothrow: true,
|
|
946
|
+
});
|
|
947
|
+
if (result.exitCode === 0) {
|
|
948
|
+
ok("DCP plugin installed");
|
|
949
|
+
} else {
|
|
950
|
+
warn("DCP plugin install failed (non-fatal)");
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// ─── HTTP auto-prompt ─────────────────────────────────────────────────────────
|
|
955
|
+
/**
|
|
956
|
+
* Build HttpConfig from flags + defaults.
|
|
957
|
+
*/
|
|
958
|
+
export function buildHttpConfig(flags: InstallFlags): HttpConfig {
|
|
959
|
+
return {
|
|
960
|
+
enabled: true,
|
|
961
|
+
port: flags.port,
|
|
962
|
+
cors: {
|
|
963
|
+
origins: flags.corsOrigins?.split(",").map((s: string) => s.trim()) ?? ["*"],
|
|
964
|
+
},
|
|
965
|
+
auth: {
|
|
966
|
+
required: flags.authRequired,
|
|
967
|
+
},
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Write http block to ndomo.config.json (source-of-truth in project, not config dir).
|
|
973
|
+
*/
|
|
974
|
+
export function writeHttpBlock(projectRoot: string, httpConfig: HttpConfig, dryRun: boolean): void {
|
|
975
|
+
const configPath = join(projectRoot, "config", "ndomo.config.json");
|
|
976
|
+
if (!existsSync(configPath)) {
|
|
977
|
+
warn("config/ndomo.config.json not found, cannot write http block");
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
try {
|
|
982
|
+
const config: Record<string, unknown> = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
983
|
+
config.http = httpConfig;
|
|
984
|
+
|
|
985
|
+
if (dryRun) {
|
|
986
|
+
info(`[dry-run] would write http block to ${configPath}:`);
|
|
987
|
+
console.log(JSON.stringify(httpConfig, null, 2));
|
|
988
|
+
} else {
|
|
989
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
990
|
+
ok("HTTP server config written to config/ndomo.config.json");
|
|
991
|
+
info(` port: ${httpConfig.port}, cors: ${httpConfig.cors.origins.join(",")}, auth: ${httpConfig.auth.required}`);
|
|
992
|
+
}
|
|
993
|
+
} catch (e) {
|
|
994
|
+
warn(`Failed to write http block: ${e}`);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Prompt user interactively to enable HTTP server (TTY only).
|
|
1000
|
+
* Returns true if user accepts, false otherwise.
|
|
1001
|
+
*/
|
|
1002
|
+
export async function promptHttpEnable(): Promise<boolean> {
|
|
1003
|
+
console.log("");
|
|
1004
|
+
console.log("[?] Enable ndomo HTTP server? Allows programmatic plan/task control via API.");
|
|
1005
|
+
console.log(" Recommended for users integrating ndomo with other tools (port 4097, auth required).");
|
|
1006
|
+
process.stdout.write(" Enable now? [Y/n]: ");
|
|
1007
|
+
|
|
1008
|
+
return new Promise((resolve) => {
|
|
1009
|
+
process.stdin.setEncoding("utf-8");
|
|
1010
|
+
process.stdin.resume();
|
|
1011
|
+
|
|
1012
|
+
let input = "";
|
|
1013
|
+
const onData = (chunk: string) => {
|
|
1014
|
+
input += chunk;
|
|
1015
|
+
if (input.includes("\n")) {
|
|
1016
|
+
cleanup();
|
|
1017
|
+
const answer = input.trim().toLowerCase();
|
|
1018
|
+
resolve(answer === "" || answer === "y" || answer === "yes");
|
|
1019
|
+
}
|
|
1020
|
+
};
|
|
1021
|
+
|
|
1022
|
+
const cleanup = () => {
|
|
1023
|
+
process.stdin.removeListener("data", onData);
|
|
1024
|
+
process.stdin.pause();
|
|
1025
|
+
};
|
|
1026
|
+
|
|
1027
|
+
process.stdin.on("data", onData);
|
|
1028
|
+
|
|
1029
|
+
// Timeout after 30s
|
|
1030
|
+
setTimeout(() => {
|
|
1031
|
+
cleanup();
|
|
1032
|
+
console.log("\n(timeout — skipping HTTP enable)");
|
|
1033
|
+
resolve(false);
|
|
1034
|
+
}, 30_000);
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Handle HTTP auto-prompt logic:
|
|
1040
|
+
* - --enable-http → write block immediately
|
|
1041
|
+
* - --disable-http → skip entirely
|
|
1042
|
+
* - Otherwise → prompt in TTY, skip in non-TTY
|
|
1043
|
+
*/
|
|
1044
|
+
export async function stepHttpPrompt(
|
|
1045
|
+
flags: InstallFlags,
|
|
1046
|
+
projectRoot: string,
|
|
1047
|
+
dryRun: boolean,
|
|
1048
|
+
): Promise<void> {
|
|
1049
|
+
if (flags.enableHttp) {
|
|
1050
|
+
const httpConfig = buildHttpConfig(flags);
|
|
1051
|
+
writeHttpBlock(projectRoot, httpConfig, dryRun);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
if (flags.disableHttp) {
|
|
1056
|
+
info("HTTP auto-prompt disabled (--disable-http)");
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// Interactive prompt only in TTY
|
|
1061
|
+
if (!process.stdin.isTTY) {
|
|
1062
|
+
info("Non-TTY mode — skipping HTTP prompt (use --enable-http to enable)");
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
const accepted = await promptHttpEnable();
|
|
1067
|
+
if (accepted) {
|
|
1068
|
+
const httpConfig = buildHttpConfig(flags);
|
|
1069
|
+
writeHttpBlock(projectRoot, httpConfig, dryRun);
|
|
1070
|
+
} else {
|
|
1071
|
+
info("HTTP server not enabled (can be enabled later with --enable-http)");
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// ─── Summary ─────────────────────────────────────────────────────────────────
|
|
1076
|
+
function printSummary(configDir: string, preset: string, provider: string, withDcp: boolean): void {
|
|
1077
|
+
const agentDir = join(configDir, "agent");
|
|
1078
|
+
const skillDir = join(configDir, "skills");
|
|
1079
|
+
|
|
1080
|
+
console.log("");
|
|
1081
|
+
console.log(`${GREEN}${BOLD}════════════════════════════════════════${NC}`);
|
|
1082
|
+
console.log(`${GREEN}${BOLD} ndomo installed successfully!${NC}`);
|
|
1083
|
+
console.log(`${GREEN}${BOLD}════════════════════════════════════════${NC}`);
|
|
1084
|
+
console.log("");
|
|
1085
|
+
console.log(`${BOLD}Installed agents:${NC}`);
|
|
1086
|
+
|
|
1087
|
+
if (existsSync(agentDir)) {
|
|
1088
|
+
const agents = readdirSafe(agentDir).filter((f) => f.endsWith(".md"));
|
|
1089
|
+
for (const a of agents) {
|
|
1090
|
+
console.log(` ${a.replace(/\.md$/, "").padEnd(20)} ${a}`);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
console.log("");
|
|
1095
|
+
if (existsSync(skillDir)) {
|
|
1096
|
+
const skills = readdirSafe(skillDir).filter((d) => {
|
|
1097
|
+
const full = join(skillDir, d);
|
|
1098
|
+
return existsSync(full) && lstatSync(full).isDirectory();
|
|
1099
|
+
});
|
|
1100
|
+
console.log(`${BOLD}Installed skills:${NC} ${skills.join(", ")}`);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
console.log("");
|
|
1104
|
+
console.log(`${BOLD}Config:${NC} ${configDir}/ndomo.json`);
|
|
1105
|
+
console.log(`${BOLD}OpenCode config:${NC} ${configDir}/opencode.json (ndomo registered)`);
|
|
1106
|
+
console.log(`${BOLD}Preset:${NC} ${preset}`);
|
|
1107
|
+
if (provider) {
|
|
1108
|
+
console.log(`${BOLD}Provider:${NC} ${provider}`);
|
|
1109
|
+
}
|
|
1110
|
+
if (withDcp) {
|
|
1111
|
+
console.log(`${BOLD}DCP:${NC} installed`);
|
|
1112
|
+
}
|
|
1113
|
+
console.log("");
|
|
1114
|
+
console.log(`${BOLD}Next steps:${NC}`);
|
|
1115
|
+
console.log(` Run ${BLUE}opencode${NC} then ${BLUE}ping all agents${NC} to verify.`);
|
|
1116
|
+
console.log("");
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// ─── Uninstall shim ──────────────────────────────────────────────────────────
|
|
1120
|
+
function runUninstall(projectRoot: string): void {
|
|
1121
|
+
const uninstallScript = join(projectRoot, "scripts", "uninstall.sh");
|
|
1122
|
+
if (!existsSync(uninstallScript)) {
|
|
1123
|
+
die("scripts/uninstall.sh not found");
|
|
1124
|
+
}
|
|
1125
|
+
info("Running uninstaller...");
|
|
1126
|
+
const proc = Bun.spawn(["bash", uninstallScript], {
|
|
1127
|
+
cwd: projectRoot,
|
|
1128
|
+
stdout: "inherit",
|
|
1129
|
+
stderr: "inherit",
|
|
1130
|
+
});
|
|
1131
|
+
// Wait for process to finish
|
|
1132
|
+
proc.exited.then((code) => {
|
|
1133
|
+
process.exit(code ?? 0);
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// ─── Safe directory listing ──────────────────────────────────────────────────
|
|
1138
|
+
function readdirSafe(dir: string): string[] {
|
|
1139
|
+
try {
|
|
1140
|
+
const { readdirSync } = require("node:fs");
|
|
1141
|
+
return readdirSync(dir) as string[];
|
|
1142
|
+
} catch {
|
|
1143
|
+
return [];
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// ─── Recursive copy helper ───────────────────────────────────────────────────
|
|
1148
|
+
function cpSyncRecursive(src: string, dst: string): void {
|
|
1149
|
+
mkdirSync(dst, { recursive: true });
|
|
1150
|
+
const entries = readdirSafe(src);
|
|
1151
|
+
for (const entry of entries) {
|
|
1152
|
+
const srcPath = join(src, entry);
|
|
1153
|
+
const dstPath = join(dst, entry);
|
|
1154
|
+
const stat = lstatSync(srcPath);
|
|
1155
|
+
if (stat.isDirectory()) {
|
|
1156
|
+
cpSyncRecursive(srcPath, dstPath);
|
|
1157
|
+
} else {
|
|
1158
|
+
copyFileSync(srcPath, dstPath);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
1164
|
+
export async function runInstall(args: string[]): Promise<void> {
|
|
1165
|
+
const flags = parseFlags(args);
|
|
1166
|
+
|
|
1167
|
+
if (flags.help) {
|
|
1168
|
+
printHelp();
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// Uninstall shortcut
|
|
1173
|
+
if (flags.uninstall) {
|
|
1174
|
+
const projectRoot = detectProjectRoot();
|
|
1175
|
+
runUninstall(projectRoot);
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// Detect paths
|
|
1180
|
+
const projectRoot = detectProjectRoot();
|
|
1181
|
+
const configDir = resolveConfigDir();
|
|
1182
|
+
const timestamp = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 15);
|
|
1183
|
+
const backupDir = join(configDir, `.backup-${timestamp}`);
|
|
1184
|
+
|
|
1185
|
+
console.log("");
|
|
1186
|
+
console.log(`${BOLD}ndomo installer${NC} — preset: ${flags.preset}, config: ${configDir}`);
|
|
1187
|
+
console.log("");
|
|
1188
|
+
|
|
1189
|
+
// Load config for preset validation
|
|
1190
|
+
const configJsonPath = join(projectRoot, "config", "ndomo.config.json");
|
|
1191
|
+
let configJson: NdomoConfig = {};
|
|
1192
|
+
try {
|
|
1193
|
+
configJson = JSON.parse(readFileSync(configJsonPath, "utf-8"));
|
|
1194
|
+
} catch {
|
|
1195
|
+
warn("Could not read config/ndomo.config.json — preset application may fail");
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// Validate preset
|
|
1199
|
+
const presets = configJson.presets;
|
|
1200
|
+
if (presets && !presets[flags.preset]) {
|
|
1201
|
+
const available = Object.keys(presets).join(", ");
|
|
1202
|
+
die(`Unknown preset: '${flags.preset}' (available: ${available})`);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// Dry-run banner
|
|
1206
|
+
if (flags.dryRun) {
|
|
1207
|
+
console.log(`${YELLOW}${BOLD}[DRY-RUN] No files will be written${NC}`);
|
|
1208
|
+
console.log("");
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// Step 1: Install deps
|
|
1212
|
+
if (!flags.skipDeps) {
|
|
1213
|
+
await stepInstallDeps(projectRoot, flags.dryRun);
|
|
1214
|
+
} else {
|
|
1215
|
+
info("Skipping dependency installation (--skip-deps)");
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// Step 2: Copy agents
|
|
1219
|
+
mkdirSync(join(configDir, "agent"), { recursive: true });
|
|
1220
|
+
mkdirSync(join(configDir, "skills"), { recursive: true });
|
|
1221
|
+
stepCopyAgents(projectRoot, configDir, backupDir, flags.dryRun);
|
|
1222
|
+
|
|
1223
|
+
// Step 3: Copy skills
|
|
1224
|
+
stepCopySkills(projectRoot, configDir, backupDir, flags.dryRun);
|
|
1225
|
+
|
|
1226
|
+
// Step 3.5: Apply preset
|
|
1227
|
+
stepApplyPreset(configDir, configJson, flags.preset, flags.provider, flags.dryRun);
|
|
1228
|
+
|
|
1229
|
+
// Step 4: Copy config
|
|
1230
|
+
stepCopyConfig(projectRoot, configDir, backupDir, flags.dryRun);
|
|
1231
|
+
|
|
1232
|
+
// Step 4.5: Register plugins
|
|
1233
|
+
// Reload config from configDir (just copied)
|
|
1234
|
+
let installedConfig: NdomoConfig = {};
|
|
1235
|
+
const ndomoJsonPath = join(configDir, "ndomo.json");
|
|
1236
|
+
try {
|
|
1237
|
+
installedConfig = JSON.parse(readFileSync(ndomoJsonPath, "utf-8"));
|
|
1238
|
+
} catch {
|
|
1239
|
+
// Use original
|
|
1240
|
+
installedConfig = configJson;
|
|
1241
|
+
}
|
|
1242
|
+
stepRegisterPlugins(configDir, installedConfig, backupDir, flags.dryRun);
|
|
1243
|
+
|
|
1244
|
+
// Step 4.6: Install package
|
|
1245
|
+
await stepInstallPackage(projectRoot, configDir, flags.dryRun);
|
|
1246
|
+
|
|
1247
|
+
// Step 4.7: Copy tools (npm distribution — no symlink)
|
|
1248
|
+
stepCopyTools(projectRoot, configDir, flags.dryRun);
|
|
1249
|
+
|
|
1250
|
+
// Step 5: Inject preset
|
|
1251
|
+
stepInjectPreset(configDir, flags.preset, flags.dryRun);
|
|
1252
|
+
|
|
1253
|
+
// Step 6: Optional DCP
|
|
1254
|
+
if (flags.withDcp) {
|
|
1255
|
+
await stepInstallDcp(flags.dryRun);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// HTTP auto-prompt (closes Phase-1 gap)
|
|
1259
|
+
await stepHttpPrompt(flags, projectRoot, flags.dryRun);
|
|
1260
|
+
|
|
1261
|
+
// Summary
|
|
1262
|
+
if (!flags.dryRun) {
|
|
1263
|
+
printSummary(configDir, flags.preset, flags.provider, flags.withDcp);
|
|
1264
|
+
} else {
|
|
1265
|
+
console.log("");
|
|
1266
|
+
console.log(`${YELLOW}[dry-run] Complete. No files were modified.${NC}`);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// Direct execution
|
|
1271
|
+
if (import.meta.main) {
|
|
1272
|
+
await runInstall(process.argv.slice(2));
|
|
1273
|
+
}
|