@veedubin/neuralgentics 0.9.1 → 0.9.3

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.
@@ -0,0 +1,719 @@
1
+ /**
2
+ * `neuralgentics init` command — TypeScript port of the Python
3
+ * `neuralgentics-cli/src/neuralgentics/init_cmd.py`.
4
+ *
5
+ * Bootstraps a project directory with the neuralgentics OpenCode plugin by:
6
+ * 1. Resolving the requested plugin version (`latest` or `X.Y.Z`).
7
+ * 2. Downloading + verifying the GitHub release tarball.
8
+ * 3. Extracting + placing files per the file-placement table.
9
+ * 4. Merging `opencode.json` (preserves user's provider/mcp/lsp/formatter).
10
+ * 5. Running `npm install --no-audit --no-fund` in `.opencode/`.
11
+ * 6. Interactive container prompt (podman-compose / docker).
12
+ * 7. Writing the state file with the file manifest.
13
+ * 8. Printing a plain-text success summary.
14
+ */
15
+ import { execSync } from "node:child_process";
16
+ import { createHash } from "node:crypto";
17
+ import { promises as fs, existsSync, renameSync, statSync } from "node:fs";
18
+ import * as path from "node:path";
19
+ import * as os from "node:os";
20
+ import * as readline from "node:readline";
21
+ import { randomBytes } from "node:crypto";
22
+ import { downloadTarball, extractTarball, resolveVersion, verifySha256, NeuralgenticsError as DownloadNeuralgenticsError, } from "./download.js";
23
+ /**
24
+ * Local base class for init-flow errors. Extends the download module's
25
+ * `NeuralgenticsError` so all CLI errors share a single `instanceof` root
26
+ * (callers only need to import from `init.js`).
27
+ */
28
+ export class NeuralgenticsError extends DownloadNeuralgenticsError {
29
+ }
30
+ import { mergeOpencodeJsonWithDiff, parseOpencodeJson, serializeOpencodeJson, } from "./merge.js";
31
+ /** CLI version (mirrors `neuralgentics-cli/src/neuralgentics/__init__.py`). */
32
+ export const CLI_VERSION = "0.1.0";
33
+ /** Plugin entry added to the `plugin` array. */
34
+ const PLUGIN_REFERENCE = "@veedubin/neuralgentics";
35
+ /** Regex for a valid `X.Y.Z` semver. */
36
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
37
+ /** Top-level files that are copied only if they don't already exist. */
38
+ const COPY_IF_ABSENT = [
39
+ "docker-compose.yml",
40
+ "podman-compose.yml",
41
+ "compose.example.env",
42
+ "install.sh",
43
+ ];
44
+ /** Top-level directory prefixes that are copied recursively (overwriting). */
45
+ const COPY_TREE_PREFIXES = [
46
+ ".opencode/agents/",
47
+ ".opencode/skills/",
48
+ "docker/",
49
+ "node_modules/@veedubin/neuralgentics/",
50
+ ];
51
+ /** Files that are merged (not copied) when they already exist. */
52
+ const MERGE_FILES = [
53
+ ".opencode/opencode.json",
54
+ ".opencode/package.json",
55
+ ];
56
+ /** Files that are copied only if absent (warn + skip if present). */
57
+ const COPY_IF_ABSENT_WARN = [
58
+ ".opencode/AGENTS.md",
59
+ ".opencode/.gitignore",
60
+ ".opencode/package-lock.json",
61
+ ];
62
+ /** Paths that are never a safe init target unless `--force` is set. */
63
+ const SCARY_PATHS = new Set(["/", "/tmp", "/tmp/"]);
64
+ /** Project markers that indicate a real project dir. */
65
+ const PROJECT_MARKERS = [
66
+ ".git",
67
+ "pyproject.toml",
68
+ "package.json",
69
+ "Cargo.toml",
70
+ ".opencode",
71
+ ];
72
+ /** Name of the state file inside `{target}/.opencode/`. */
73
+ const STATE_FILENAME = ".neuralgentics-state.json";
74
+ /** Read chunk size for SHA256 computation (64 KiB). */
75
+ const CHUNK_SIZE = 64 * 1024;
76
+ // ---------------------------------------------------------------------------
77
+ // Error subclasses (mirrors `errors.py` — exit codes preserved).
78
+ // ---------------------------------------------------------------------------
79
+ export class OpencodeNotFound extends NeuralgenticsError {
80
+ exitCode = 4;
81
+ remediation = "Install OpenCode, then re-run: curl -fsSL https://opencode.ai/install.sh | bash";
82
+ constructor(message, remediation) {
83
+ super(message, remediation);
84
+ this.name = "OpencodeNotFound";
85
+ }
86
+ }
87
+ export class NpmNotFound extends NeuralgenticsError {
88
+ exitCode = 5;
89
+ remediation = "Install Node.js 20+ from https://nodejs.org/, then re-run `npm install` in .opencode/.";
90
+ constructor(message, remediation) {
91
+ super(message, remediation);
92
+ this.name = "NpmNotFound";
93
+ }
94
+ }
95
+ export class NpmInstallFailed extends NeuralgenticsError {
96
+ exitCode = 13;
97
+ remediation = "Check Node.js version, network, and disk space. Try running `npm install` manually.";
98
+ constructor(message, remediation) {
99
+ super(message, remediation);
100
+ this.name = "NpmInstallFailed";
101
+ }
102
+ }
103
+ export class OfflineNoBundle extends NeuralgenticsError {
104
+ exitCode = 15;
105
+ remediation = "Run without --offline. Bundled tarball support is planned for v0.2.0+.";
106
+ constructor(message, remediation) {
107
+ super(message, remediation);
108
+ this.name = "OfflineNoBundle";
109
+ }
110
+ }
111
+ export class TargetNotDirectory extends NeuralgenticsError {
112
+ exitCode = 17;
113
+ remediation = "Create the directory or specify a different target with --target.";
114
+ constructor(message, remediation) {
115
+ super(message, remediation);
116
+ this.name = "TargetNotDirectory";
117
+ }
118
+ }
119
+ export class TargetRefused extends NeuralgenticsError {
120
+ exitCode = 18;
121
+ remediation = "Use --force to proceed anyway, or pick a project directory.";
122
+ constructor(message, remediation) {
123
+ super(message, remediation);
124
+ this.name = "TargetRefused";
125
+ }
126
+ }
127
+ export class BackupFailed extends NeuralgenticsError {
128
+ exitCode = 19;
129
+ remediation = "Check disk space and permissions in the target directory.";
130
+ constructor(message, remediation) {
131
+ super(message, remediation);
132
+ this.name = "BackupFailed";
133
+ }
134
+ }
135
+ export class ComposeNotFound extends NeuralgenticsError {
136
+ exitCode = 11;
137
+ remediation = "Install Docker or podman-compose to set up the database containers.";
138
+ constructor(message, remediation) {
139
+ super(message, remediation);
140
+ this.name = "ComposeNotFound";
141
+ }
142
+ }
143
+ export class ComposeUpFailed extends NeuralgenticsError {
144
+ exitCode = 12;
145
+ remediation = "Check the container runtime status, port conflicts, and disk space.";
146
+ constructor(message, remediation) {
147
+ super(message, remediation);
148
+ this.name = "ComposeUpFailed";
149
+ }
150
+ }
151
+ // ---------------------------------------------------------------------------
152
+ // Public entry point
153
+ // ---------------------------------------------------------------------------
154
+ /**
155
+ * Entry point for `neuralgentics init`. Returns the process exit code.
156
+ */
157
+ export async function runInit(args) {
158
+ const target = path.resolve(args.target);
159
+ const force = args.force;
160
+ const dryRun = args.dryRun;
161
+ // 1. Ensure target exists (mkdir -p), refuse scary locations / symlink .opencode.
162
+ await ensureTarget(target, force);
163
+ // 2. Check opencode is on PATH.
164
+ if (!isOnPath("opencode")) {
165
+ throw new OpencodeNotFound("opencode is not installed or not on PATH.");
166
+ }
167
+ // Resolve the requested version, honoring --offline conflicts.
168
+ if (args.offline && args.version === "latest") {
169
+ throw new OfflineNoBundle("Offline mode requires a bundled tarball, which is not available in this version.");
170
+ }
171
+ const version = await resolveVersion(args.version, args.repo);
172
+ // 3. Validate resolved version is X.Y.Z.
173
+ if (!SEMVER_RE.test(version)) {
174
+ throw new Error(`Resolved version ${JSON.stringify(version)} is not X.Y.Z.`);
175
+ }
176
+ if (dryRun) {
177
+ const wouldBackup = existsSync(path.join(target, ".opencode")) &&
178
+ statSync(path.join(target, ".opencode")).isDirectory();
179
+ const backupDirName = wouldBackup ? backupPath(target).name : null;
180
+ printDryRunSummary(target, version, args.repo, wouldBackup, backupDirName);
181
+ return 0;
182
+ }
183
+ // 4-5. Download + verify + extract.
184
+ const extractDir = await downloadAndExtract(version, args.repo);
185
+ // 6. Pre-flight: compute what would change vs. existing target.
186
+ const changes = await computeChanges(extractDir, target);
187
+ // 7. Backup the existing .opencode/ if anything would change.
188
+ let backupPathStr = null;
189
+ if (shouldBackup(target, changes)) {
190
+ backupPathStr = await doBackup(target);
191
+ }
192
+ // 8. Place files per the file-placement table.
193
+ const manifest = await placeFiles(extractDir, target, version, force, backupPathStr);
194
+ // 9. Run npm install.
195
+ await runNpmInstall(path.join(target, ".opencode"));
196
+ // 10. Interactive container prompt.
197
+ if (args.withBackend) {
198
+ await bringUpBackend(target, args.yes);
199
+ }
200
+ else if (!args.yes) {
201
+ await promptForBackend(target);
202
+ }
203
+ // 11. Write state file.
204
+ await writeState(target, version, manifest, args.repo, backupPathStr);
205
+ // 12. Print summary.
206
+ printSuccessSummary(target, version, extractDir, backupPathStr);
207
+ return 0;
208
+ }
209
+ // ---------------------------------------------------------------------------
210
+ // Helpers
211
+ // ---------------------------------------------------------------------------
212
+ function isOnPath(cmd) {
213
+ try {
214
+ execSync(`command -v ${cmd}`, { stdio: "pipe", shell: process.env.SHELL ?? "bash" });
215
+ return true;
216
+ }
217
+ catch {
218
+ return false;
219
+ }
220
+ }
221
+ async function ensureTarget(target, force) {
222
+ let stat;
223
+ try {
224
+ stat = statSync(target);
225
+ }
226
+ catch {
227
+ // Does not exist — create it.
228
+ await fs.mkdir(target, { recursive: true });
229
+ stat = statSync(target);
230
+ }
231
+ if (!stat.isDirectory()) {
232
+ throw new TargetNotDirectory(`${target} exists and is not a directory.`);
233
+ }
234
+ if (!force) {
235
+ if (isScaryTarget(target)) {
236
+ throw new TargetRefused(`Refusing to init into ${target} (looks like a home/root/tmp dir). Use --force to proceed anyway.`);
237
+ }
238
+ const opencodePath = path.join(target, ".opencode");
239
+ if (existsSync(opencodePath) && statSync(opencodePath).isSymbolicLink?.()) {
240
+ throw new TargetRefused(`${target}/.opencode is a symlink; refusing to move it. Use --force to proceed anyway.`);
241
+ }
242
+ }
243
+ }
244
+ function isScaryTarget(target) {
245
+ const resolved = path.resolve(target);
246
+ if (SCARY_PATHS.has(resolved) || SCARY_PATHS.has(resolved + "/"))
247
+ return true;
248
+ const home = os.homedir();
249
+ if (resolved === home)
250
+ return true;
251
+ const parent = path.dirname(resolved);
252
+ if (parent === home) {
253
+ return !PROJECT_MARKERS.some((m) => existsSync(path.join(resolved, m)));
254
+ }
255
+ return false;
256
+ }
257
+ async function downloadAndExtract(version, repo) {
258
+ const { tarballPath, checksumsPath } = await downloadTarball(version, repo);
259
+ await verifySha256(tarballPath, checksumsPath);
260
+ const extractDir = await fs.mkdtemp(path.join(os.tmpdir(), `neuralgentics-extract-${version}-`));
261
+ await extractTarball(tarballPath, extractDir);
262
+ return extractDir;
263
+ }
264
+ /** Classify a tarball-relative path into its destination relative to target. */
265
+ function classifyDestination(rel) {
266
+ if (COPY_IF_ABSENT.includes(rel) || COPY_IF_ABSENT_WARN.includes(rel) || MERGE_FILES.includes(rel)) {
267
+ return rel;
268
+ }
269
+ for (const prefix of COPY_TREE_PREFIXES) {
270
+ if (rel.startsWith(prefix))
271
+ return rel;
272
+ }
273
+ if (rel.startsWith(".opencode/"))
274
+ return rel;
275
+ return null;
276
+ }
277
+ async function placeFiles(extractDir, target, version, force, backupPathStr) {
278
+ const manifest = {};
279
+ const opencodeDir = path.join(target, ".opencode");
280
+ await fs.mkdir(opencodeDir, { recursive: true });
281
+ const files = await iterFiles(extractDir);
282
+ for (const src of files) {
283
+ const rel = path.relative(extractDir, src).split(path.sep).join("/");
284
+ const destRel = classifyDestination(rel);
285
+ if (destRel === null) {
286
+ process.stderr.write(`Skipping unrecognised tarball entry: ${rel}\n`);
287
+ continue;
288
+ }
289
+ const dest = path.join(target, destRel);
290
+ // Where the user's *previous* version of this file lives now (after a backup).
291
+ let prevDest = dest;
292
+ if (backupPathStr !== null) {
293
+ const innerRel = destRel.startsWith(".opencode/") ? destRel.slice(".opencode/".length) : destRel;
294
+ prevDest = path.join(backupPathStr, innerRel);
295
+ }
296
+ let merged = null;
297
+ if (destRel === ".opencode/opencode.json") {
298
+ await mergeOpencodeJsonFile(src, dest, prevDest, force);
299
+ merged = true;
300
+ }
301
+ else if (destRel === ".opencode/package.json") {
302
+ await mergePackageJsonFile(src, dest, prevDest, force);
303
+ merged = false;
304
+ }
305
+ else if (COPY_IF_ABSENT_WARN.includes(destRel)) {
306
+ if (existsSync(dest) && !force) {
307
+ process.stderr.write(`${destRel} already exists; skipping (use --force to overwrite).\n`);
308
+ continue;
309
+ }
310
+ await fs.mkdir(path.dirname(dest), { recursive: true });
311
+ await fs.copyFile(src, dest);
312
+ merged = false;
313
+ }
314
+ else if (COPY_IF_ABSENT.includes(destRel)) {
315
+ if (existsSync(dest) && !force) {
316
+ continue;
317
+ }
318
+ await fs.mkdir(path.dirname(dest), { recursive: true });
319
+ await fs.copyFile(src, dest);
320
+ merged = false;
321
+ }
322
+ else {
323
+ // Copy-tree entry (agents/, skills/, docker/, node_modules/...).
324
+ await fs.mkdir(path.dirname(dest), { recursive: true });
325
+ await fs.copyFile(src, dest);
326
+ merged = false;
327
+ }
328
+ const sha = await computeFileSha256(dest);
329
+ const shippedSha = await computeFileSha256(src);
330
+ manifest[destRel] = {
331
+ sha256: sha,
332
+ user_modified: false,
333
+ installed_from: version,
334
+ last_known_shipped_sha256: shippedSha,
335
+ merged: destRel === ".opencode/opencode.json" ? merged : null,
336
+ };
337
+ }
338
+ return manifest;
339
+ }
340
+ async function mergeOpencodeJsonFile(src, dest, prevDest, _force) {
341
+ const shippedText = await fs.readFile(src, "utf-8");
342
+ const shipped = parseOpencodeJson(shippedText);
343
+ if (!existsSync(prevDest)) {
344
+ await fs.mkdir(path.dirname(dest), { recursive: true });
345
+ await fs.writeFile(dest, serializeOpencodeJson(shipped), "utf-8");
346
+ return;
347
+ }
348
+ const userText = await fs.readFile(prevDest, "utf-8");
349
+ let user;
350
+ try {
351
+ user = parseOpencodeJson(userText);
352
+ }
353
+ catch {
354
+ // User's existing file is broken JSON. Their data is in the backup.
355
+ await fs.mkdir(path.dirname(dest), { recursive: true });
356
+ await fs.writeFile(dest, serializeOpencodeJson(shipped), "utf-8");
357
+ return;
358
+ }
359
+ const { merged, changes } = mergeOpencodeJsonWithDiff(user, shipped);
360
+ if (changes.length === 0) {
361
+ if (prevDest !== dest) {
362
+ // Restore the user's config to the new dest.
363
+ await fs.mkdir(path.dirname(dest), { recursive: true });
364
+ await fs.writeFile(dest, serializeOpencodeJson(merged), "utf-8");
365
+ }
366
+ return;
367
+ }
368
+ await fs.mkdir(path.dirname(dest), { recursive: true });
369
+ await fs.writeFile(dest, serializeOpencodeJson(merged), "utf-8");
370
+ }
371
+ async function mergePackageJsonFile(src, dest, prevDest, _force) {
372
+ const shippedText = await fs.readFile(src, "utf-8");
373
+ const shipped = JSON.parse(shippedText);
374
+ if (!existsSync(prevDest)) {
375
+ await fs.mkdir(path.dirname(dest), { recursive: true });
376
+ await fs.writeFile(dest, JSON.stringify(shipped, null, 2) + "\n", "utf-8");
377
+ return;
378
+ }
379
+ let user;
380
+ try {
381
+ user = JSON.parse(await fs.readFile(prevDest, "utf-8"));
382
+ }
383
+ catch {
384
+ await fs.mkdir(path.dirname(dest), { recursive: true });
385
+ await fs.writeFile(dest, JSON.stringify(shipped, null, 2) + "\n", "utf-8");
386
+ return;
387
+ }
388
+ const userDeps = user["dependencies"] ?? {};
389
+ const shippedDeps = shipped["dependencies"] ?? {};
390
+ // User wins on key conflict.
391
+ user["dependencies"] = { ...shippedDeps, ...userDeps };
392
+ await fs.mkdir(path.dirname(dest), { recursive: true });
393
+ await fs.writeFile(dest, JSON.stringify(user, null, 2) + "\n", "utf-8");
394
+ }
395
+ async function runNpmInstall(opencodeDir) {
396
+ if (!isOnPath("npm")) {
397
+ throw new NpmNotFound("npm is not installed or not on PATH.");
398
+ }
399
+ try {
400
+ execSync("npm install --no-audit --no-fund", {
401
+ cwd: opencodeDir,
402
+ stdio: "pipe",
403
+ timeout: 300_000,
404
+ });
405
+ }
406
+ catch (exc) {
407
+ const stderr = exc instanceof Error && "stderr" in exc
408
+ ? String(exc.stderr ?? "")
409
+ : exc instanceof Error ? exc.message : String(exc);
410
+ throw new NpmInstallFailed(`npm install failed in ${opencodeDir}: ${stderr.trim()}`);
411
+ }
412
+ }
413
+ async function writeState(target, version, manifest, repo, backupPathStr) {
414
+ const now = new Date().toISOString().replace(/\.\d+Z$/, "Z");
415
+ let lastBackup = null;
416
+ if (backupPathStr !== null) {
417
+ const rel = path.relative(target, backupPathStr);
418
+ lastBackup = rel && rel.length > 0 ? rel : backupPathStr;
419
+ }
420
+ const state = {
421
+ version: 1,
422
+ installed_version: version,
423
+ installed_at: now,
424
+ updated_at: now,
425
+ cli_version: CLI_VERSION,
426
+ source: "github",
427
+ repo,
428
+ target,
429
+ files: manifest,
430
+ last_backup: lastBackup,
431
+ };
432
+ const statePath = path.join(target, ".opencode", STATE_FILENAME);
433
+ await fs.mkdir(path.dirname(statePath), { recursive: true });
434
+ const tmp = statePath + ".tmp";
435
+ await fs.writeFile(tmp, JSON.stringify(state, null, 2), "utf-8");
436
+ // Atomic rename on the same filesystem.
437
+ renameSync(tmp, statePath);
438
+ }
439
+ function shouldUseColor() {
440
+ if (process.env.NO_COLOR)
441
+ return false;
442
+ return process.stdout.isTTY === true;
443
+ }
444
+ function printSuccessSummary(target, version, extractDir, backupPathStr) {
445
+ const agentsDir = path.join(extractDir, ".opencode", "agents");
446
+ let agents = 0;
447
+ try {
448
+ const entries = require("node:fs").readdirSync(agentsDir);
449
+ agents = entries.filter((e) => e.endsWith(".md")).length;
450
+ }
451
+ catch {
452
+ agents = 0;
453
+ }
454
+ const skillsDir = path.join(extractDir, ".opencode", "skills");
455
+ let skills = 0;
456
+ try {
457
+ const entries = require("node:fs").readdirSync(skillsDir);
458
+ skills = entries.filter((e) => {
459
+ try {
460
+ return require("node:fs").statSync(path.join(skillsDir, e)).isDirectory();
461
+ }
462
+ catch {
463
+ return false;
464
+ }
465
+ }).length;
466
+ }
467
+ catch {
468
+ skills = 0;
469
+ }
470
+ const statePath = path.join(target, ".opencode", STATE_FILENAME);
471
+ const useColor = shouldUseColor();
472
+ const check = useColor ? "\u2713" : "OK";
473
+ const backupLine = backupPathStr !== null ? `Backup: ${backupPathStr}\n` : "";
474
+ process.stdout.write(`${check} neuralgentics v${version} initialized in ${target}\n` +
475
+ `\n` +
476
+ `Plugin: ${target}/.opencode/node_modules/@veedubin/neuralgentics/ (after \`npm install\`)\n` +
477
+ `Config: ${target}/.opencode/opencode.json\n` +
478
+ `Agents: ${agents} personas\n` +
479
+ `Skills: ${skills} skills\n` +
480
+ `State: ${statePath}\n` +
481
+ backupLine +
482
+ `\n` +
483
+ `Next: opencode\n` +
484
+ `\n` +
485
+ `Alternative install: curl -fsSL ` +
486
+ `https://raw.githubusercontent.com/Veedubin/neuralgentics/main/scripts/install.sh | bash\n`);
487
+ }
488
+ function printDryRunSummary(target, version, repo, wouldBackup, backupDirName) {
489
+ process.stdout.write(`WOULD initialize neuralgentics v${version} in ${target}\n`);
490
+ process.stdout.write(`WOULD download tarball from github.com/${repo}/releases/download/v${version}/\n`);
491
+ process.stdout.write("WOULD extract + place .opencode/agents/, .opencode/skills/, opencode.json (merged)\n");
492
+ if (wouldBackup) {
493
+ const label = backupDirName ?? ".opencode-bak-{ts}/";
494
+ process.stdout.write(`WOULD back up existing .opencode/ -> ${label}\n`);
495
+ }
496
+ process.stdout.write("WOULD run: npm install --no-audit --no-fund\n");
497
+ process.stdout.write("WOULD write state file.\n");
498
+ }
499
+ async function iterFiles(root) {
500
+ const result = [];
501
+ async function walk(dir) {
502
+ const entries = await fs.readdir(dir, { withFileTypes: true });
503
+ for (const entry of entries) {
504
+ const full = path.join(dir, entry.name);
505
+ if (entry.isDirectory()) {
506
+ await walk(full);
507
+ }
508
+ else if (entry.isFile()) {
509
+ result.push(full);
510
+ }
511
+ }
512
+ }
513
+ await walk(root);
514
+ result.sort();
515
+ return result;
516
+ }
517
+ async function computeChanges(extractDir, target) {
518
+ const changes = {};
519
+ const files = await iterFiles(extractDir);
520
+ for (const src of files) {
521
+ const rel = path.relative(extractDir, src).split(path.sep).join("/");
522
+ const destRel = classifyDestination(rel);
523
+ if (destRel === null)
524
+ continue;
525
+ const dest = path.join(target, destRel);
526
+ let action;
527
+ if (destRel === ".opencode/opencode.json") {
528
+ action = await simulateMergeOpencodeJson(src, dest);
529
+ }
530
+ else if (destRel === ".opencode/package.json") {
531
+ action = await simulateMergePackageJson(src, dest);
532
+ }
533
+ else if (COPY_IF_ABSENT_WARN.includes(destRel) || COPY_IF_ABSENT.includes(destRel)) {
534
+ action = existsSync(dest) ? "no_change" : "would_create";
535
+ }
536
+ else {
537
+ if (!existsSync(dest)) {
538
+ action = "would_create";
539
+ }
540
+ else {
541
+ action = (await sameBytes(src, dest)) ? "no_change" : "would_change";
542
+ }
543
+ }
544
+ changes[destRel] = action;
545
+ }
546
+ return changes;
547
+ }
548
+ async function simulateMergeOpencodeJson(src, dest) {
549
+ if (!existsSync(dest))
550
+ return "would_create";
551
+ const shippedText = await fs.readFile(src, "utf-8");
552
+ let shipped;
553
+ try {
554
+ shipped = parseOpencodeJson(shippedText);
555
+ }
556
+ catch {
557
+ return "would_change";
558
+ }
559
+ const userText = await fs.readFile(dest, "utf-8");
560
+ let user;
561
+ try {
562
+ user = parseOpencodeJson(userText);
563
+ }
564
+ catch {
565
+ return "would_change";
566
+ }
567
+ try {
568
+ const { changes } = mergeOpencodeJsonWithDiff(user, shipped);
569
+ if (changes.length === 0)
570
+ return "no_change";
571
+ const existingCanonical = serializeOpencodeJson(user);
572
+ const mergedCanonical = serializeOpencodeJson(mergeOpencodeJsonWithDiff(user, shipped).merged);
573
+ return mergedCanonical === existingCanonical ? "no_change" : "would_change";
574
+ }
575
+ catch {
576
+ return "would_change";
577
+ }
578
+ }
579
+ async function simulateMergePackageJson(src, dest) {
580
+ if (!existsSync(dest))
581
+ return "would_create";
582
+ let shipped;
583
+ let user;
584
+ try {
585
+ shipped = JSON.parse(await fs.readFile(src, "utf-8"));
586
+ }
587
+ catch {
588
+ return "would_change";
589
+ }
590
+ try {
591
+ user = JSON.parse(await fs.readFile(dest, "utf-8"));
592
+ }
593
+ catch {
594
+ return "would_change";
595
+ }
596
+ const shippedDeps = shipped["dependencies"] ?? {};
597
+ const userDeps = user["dependencies"] ?? {};
598
+ const mergedDeps = { ...shippedDeps, ...userDeps };
599
+ const userDepsObj = userDeps;
600
+ const mergedDepsObj = mergedDeps;
601
+ return JSON.stringify(mergedDepsObj) === JSON.stringify(userDepsObj) ? "no_change" : "would_change";
602
+ }
603
+ async function sameBytes(a, b) {
604
+ return (await computeFileSha256(a)) === (await computeFileSha256(b));
605
+ }
606
+ function shouldBackup(target, changes) {
607
+ const opencodePath = path.join(target, ".opencode");
608
+ if (!existsSync(opencodePath) || !statSync(opencodePath).isDirectory())
609
+ return false;
610
+ return Object.values(changes).some((a) => a === "would_change" || a === "would_create");
611
+ }
612
+ function backupPath(target) {
613
+ const ts = Math.floor(Date.now() / 1000);
614
+ let base = path.join(target, `.opencode-bak-${ts}`);
615
+ if (!existsSync(base)) {
616
+ return { dir: base, name: path.basename(base) };
617
+ }
618
+ // Collision: add a short random suffix.
619
+ while (existsSync(base)) {
620
+ const suffix = randomBytes(2).toString("hex");
621
+ base = path.join(target, `.opencode-bak-${ts}-${suffix}`);
622
+ }
623
+ return { dir: base, name: path.basename(base) };
624
+ }
625
+ async function doBackup(target) {
626
+ const src = path.join(target, ".opencode");
627
+ const { dir: dest } = backupPath(target);
628
+ try {
629
+ // fs.renameSync is atomic on the same filesystem (mirrors shutil.move).
630
+ renameSync(src, dest);
631
+ }
632
+ catch (exc) {
633
+ throw new BackupFailed(`Could not move ${src} to ${dest}: ${exc instanceof Error ? exc.message : String(exc)}`);
634
+ }
635
+ return dest;
636
+ }
637
+ async function computeFileSha256(filePath) {
638
+ const hash = createHash("sha256");
639
+ const handle = await fs.open(filePath, "r");
640
+ try {
641
+ const buffer = Buffer.alloc(CHUNK_SIZE);
642
+ // eslint-disable-next-line no-constant-condition
643
+ while (true) {
644
+ const { bytesRead } = await handle.read(buffer, 0, CHUNK_SIZE, null);
645
+ if (bytesRead === 0)
646
+ break;
647
+ hash.update(buffer.subarray(0, bytesRead));
648
+ }
649
+ }
650
+ finally {
651
+ await handle.close();
652
+ }
653
+ return hash.digest("hex");
654
+ }
655
+ function detectComposeTool() {
656
+ // Check podman-compose first (preferred on Fedora).
657
+ try {
658
+ execSync("command -v podman-compose", { stdio: "pipe", shell: process.env.SHELL ?? "bash" });
659
+ return { tool: "podman-compose", file: "docker-compose.yml" };
660
+ }
661
+ catch {
662
+ // Fall through to docker.
663
+ }
664
+ try {
665
+ execSync("command -v docker", { stdio: "pipe", shell: process.env.SHELL ?? "bash" });
666
+ return { tool: "docker", file: "docker-compose.yml" };
667
+ }
668
+ catch {
669
+ return null;
670
+ }
671
+ }
672
+ async function promptForBackend(target) {
673
+ const tool = detectComposeTool();
674
+ if (tool === null) {
675
+ process.stdout.write(`\nNeither podman-compose nor docker is on PATH.\n` +
676
+ `To set up the database containers manually, see:\n` +
677
+ ` https://github.com/Veedubin/neuralgentics#containers\n\n`);
678
+ return;
679
+ }
680
+ const answer = await askQuestion("Do you want to set up the database containers now? [Y/n] ");
681
+ if (answer.trim().toLowerCase().startsWith("n")) {
682
+ process.stdout.write(`\nSkipping container setup. To do it later:\n` +
683
+ ` cd ${target}\n` +
684
+ ` cp compose.example.env .env\n` +
685
+ ` ${tool.tool === "docker" ? "docker compose up -d" : "podman-compose up -d"}\n` +
686
+ `Docs: https://github.com/Veedubin/neuralgentics#containers\n\n`);
687
+ return;
688
+ }
689
+ await bringUpBackend(target, true);
690
+ }
691
+ async function bringUpBackend(target, _yes) {
692
+ const tool = detectComposeTool();
693
+ if (tool === null) {
694
+ throw new ComposeNotFound("Neither docker nor podman-compose is available on PATH.");
695
+ }
696
+ const composeExample = path.join(target, "compose.example.env");
697
+ const envFile = path.join(target, ".env");
698
+ if (existsSync(composeExample) && !existsSync(envFile)) {
699
+ await fs.copyFile(composeExample, envFile);
700
+ }
701
+ try {
702
+ const cmd = tool.tool === "docker" ? "docker compose up -d" : "podman-compose up -d";
703
+ execSync(cmd, { cwd: target, stdio: "pipe", timeout: 120_000 });
704
+ process.stdout.write(`\nContainers started via \`${cmd}\`.\n`);
705
+ }
706
+ catch (exc) {
707
+ throw new ComposeUpFailed(`compose up -d failed: ${exc instanceof Error ? exc.message : String(exc)}`);
708
+ }
709
+ }
710
+ function askQuestion(prompt) {
711
+ return new Promise((resolve) => {
712
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
713
+ rl.question(prompt, (answer) => {
714
+ rl.close();
715
+ resolve(answer);
716
+ });
717
+ });
718
+ }
719
+ //# sourceMappingURL=init.js.map