bopodev 0.1.15 → 0.1.16

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.
Files changed (2) hide show
  1. package/dist/index.js +456 -83
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { Command } from "commander";
5
5
  import { cancel, outro } from "@clack/prompts";
6
6
 
7
7
  // src/lib/checks.ts
8
- import { access as access2, constants, mkdir } from "fs/promises";
8
+ import { access as access2, constants, stat } from "fs/promises";
9
9
  import { homedir } from "os";
10
10
  import { join as join2, resolve as resolve2 } from "path";
11
11
 
@@ -27,6 +27,12 @@ async function runCommandCapture(command, args, options) {
27
27
  });
28
28
  let stdout = "";
29
29
  let stderr = "";
30
+ const timeoutMs = Math.max(0, Math.floor(options?.timeoutMs ?? 1e4));
31
+ const timeoutHandle = timeoutMs > 0 ? setTimeout(() => {
32
+ stderr = `${stderr}
33
+ Command '${command}' timed out after ${timeoutMs}ms.`.trim();
34
+ child.kill("SIGTERM");
35
+ }, timeoutMs) : null;
30
36
  child.stdout.on("data", (chunk) => {
31
37
  stdout += String(chunk);
32
38
  });
@@ -34,6 +40,9 @@ async function runCommandCapture(command, args, options) {
34
40
  stderr += String(chunk);
35
41
  });
36
42
  child.on("error", (error) => {
43
+ if (timeoutHandle) {
44
+ clearTimeout(timeoutHandle);
45
+ }
37
46
  resolvePromise({
38
47
  ok: false,
39
48
  code: null,
@@ -43,6 +52,9 @@ ${String(error)}`.trim()
43
52
  });
44
53
  });
45
54
  child.on("close", (code) => {
55
+ if (timeoutHandle) {
56
+ clearTimeout(timeoutHandle);
57
+ }
46
58
  resolvePromise({
47
59
  ok: code === 0,
48
60
  code,
@@ -89,6 +101,7 @@ async function fileExists(path) {
89
101
  }
90
102
 
91
103
  // src/lib/checks.ts
104
+ var SAFE_PATH_SEGMENT_RE = /^[a-zA-Z0-9_-]+$/;
92
105
  async function runDoctorChecks(options) {
93
106
  const checks = [];
94
107
  const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
@@ -104,6 +117,12 @@ async function runDoctorChecks(options) {
104
117
  details: pnpmAvailable ? "pnpm is available" : "pnpm is not installed or not in PATH"
105
118
  });
106
119
  const codexCommand = process.env.BOPO_CODEX_COMMAND?.trim() || "codex";
120
+ const gitRuntime = await checkRuntimeCommandHealth("git", options?.workspaceRoot);
121
+ checks.push({
122
+ label: "Git runtime",
123
+ ok: gitRuntime.available && gitRuntime.exitCode === 0,
124
+ details: gitRuntime.available && gitRuntime.exitCode === 0 ? "Command 'git' is available (required for repo bootstrap/worktree execution)" : gitRuntime.error ?? "Command 'git' is not available"
125
+ });
107
126
  const codex = await checkRuntimeCommandHealth(codexCommand, options?.workspaceRoot);
108
127
  checks.push({
109
128
  label: "Codex runtime",
@@ -117,32 +136,56 @@ async function runDoctorChecks(options) {
117
136
  ok: openCode.available && openCode.exitCode === 0,
118
137
  details: openCode.available && openCode.exitCode === 0 ? `Command '${openCodeCommand}' is available` : openCode.error ?? `Command '${openCodeCommand}' exited with ${String(openCode.exitCode)}`
119
138
  });
120
- const instanceRoot = resolveInstanceRoot();
121
- const storageRoot = join2(instanceRoot, "data", "storage");
122
- const workspaceRoot = join2(instanceRoot, "workspaces");
139
+ const claudeCommand = process.env.BOPO_CLAUDE_COMMAND?.trim() || "claude";
140
+ const claude = await checkRuntimeCommandHealth(claudeCommand, options?.workspaceRoot);
123
141
  checks.push({
124
- label: "Instance root writable",
125
- ok: await ensureWritableDirectory(instanceRoot),
126
- details: instanceRoot
142
+ label: "Claude Code runtime",
143
+ ok: claude.available && claude.exitCode === 0,
144
+ details: claude.available && claude.exitCode === 0 ? `Command '${claudeCommand}' is available` : claude.error ?? `Command '${claudeCommand}' exited with ${String(claude.exitCode)}`
127
145
  });
146
+ const geminiCommand = process.env.BOPO_GEMINI_COMMAND?.trim() || "gemini";
147
+ const gemini = await checkRuntimeCommandHealth(geminiCommand, options?.workspaceRoot);
128
148
  checks.push({
129
- label: "Workspace root writable",
130
- ok: await ensureWritableDirectory(workspaceRoot),
131
- details: workspaceRoot
132
- });
133
- checks.push({
134
- label: "Storage root writable",
135
- ok: await ensureWritableDirectory(storageRoot),
136
- details: storageRoot
149
+ label: "Gemini runtime",
150
+ ok: gemini.available && gemini.exitCode === 0,
151
+ details: gemini.available && gemini.exitCode === 0 ? `Command '${geminiCommand}' is available` : gemini.error ?? `Command '${geminiCommand}' exited with ${String(gemini.exitCode)}`
137
152
  });
153
+ try {
154
+ const instanceRoot = resolveInstanceRoot();
155
+ const storageRoot = join2(instanceRoot, "data", "storage");
156
+ const workspaceRoot = join2(instanceRoot, "workspaces");
157
+ checks.push({
158
+ label: "Instance root writable",
159
+ ok: await ensureWritableDirectory(instanceRoot),
160
+ details: instanceRoot
161
+ });
162
+ checks.push({
163
+ label: "Workspace root writable",
164
+ ok: await ensureWritableDirectory(workspaceRoot),
165
+ details: workspaceRoot
166
+ });
167
+ checks.push({
168
+ label: "Storage root writable",
169
+ ok: await ensureWritableDirectory(storageRoot),
170
+ details: storageRoot
171
+ });
172
+ } catch (error) {
173
+ checks.push({
174
+ label: "Instance path configuration",
175
+ ok: false,
176
+ details: String(error)
177
+ });
178
+ }
138
179
  if (options?.workspaceRoot) {
180
+ const driftCheck = await runWorkspacePathDriftCheck(options.workspaceRoot);
181
+ checks.push(driftCheck);
139
182
  const backfillCheck = await runWorkspaceBackfillDryRunCheck(options.workspaceRoot);
140
183
  checks.push(backfillCheck);
141
184
  }
142
185
  return checks;
143
186
  }
144
187
  async function checkRuntimeCommandHealth(command, cwd) {
145
- const result = await runCommandCapture(command, ["--version"], { cwd });
188
+ const result = await runCommandCapture(command, ["--version"], { cwd, timeoutMs: 2500 });
146
189
  return {
147
190
  available: result.code !== null,
148
191
  exitCode: result.code,
@@ -151,7 +194,6 @@ async function checkRuntimeCommandHealth(command, cwd) {
151
194
  }
152
195
  async function ensureWritableDirectory(path) {
153
196
  try {
154
- await mkdir(path, { recursive: true });
155
197
  await access2(path, constants.W_OK);
156
198
  return true;
157
199
  } catch {
@@ -165,6 +207,9 @@ function resolveInstanceRoot() {
165
207
  }
166
208
  const home = process.env.BOPO_HOME?.trim() ? expandHomePrefix(process.env.BOPO_HOME.trim()) : join2(homedir(), ".bopodev");
167
209
  const instanceId = process.env.BOPO_INSTANCE_ID?.trim() || "default";
210
+ if (!SAFE_PATH_SEGMENT_RE.test(instanceId)) {
211
+ throw new Error(`Invalid BOPO_INSTANCE_ID '${instanceId}'.`);
212
+ }
168
213
  return resolve2(home, "instances", instanceId);
169
214
  }
170
215
  function expandHomePrefix(value) {
@@ -219,6 +264,44 @@ async function runWorkspaceBackfillDryRunCheck(workspaceRoot) {
219
264
  };
220
265
  }
221
266
  }
267
+ async function runWorkspacePathDriftCheck(workspaceRoot) {
268
+ const instanceRoot = resolveInstanceRoot();
269
+ const suspiciousEntries = await detectSuspiciousWorkspaceDirectories(workspaceRoot);
270
+ if (suspiciousEntries.length === 0) {
271
+ return {
272
+ label: "Workspace path drift",
273
+ ok: true,
274
+ details: "No suspicious workspace-like directories found outside managed root."
275
+ };
276
+ }
277
+ return {
278
+ label: "Workspace path drift",
279
+ ok: false,
280
+ details: `Found suspicious directories outside '${instanceRoot}': ${suspiciousEntries.join(", ")}`
281
+ };
282
+ }
283
+ async function detectSuspiciousWorkspaceDirectories(workspaceRoot) {
284
+ const candidates = [
285
+ join2(workspaceRoot, "relative"),
286
+ join2(workspaceRoot, "workspaces"),
287
+ join2(workspaceRoot, "workspace")
288
+ ];
289
+ const hits = [];
290
+ for (const candidate of candidates) {
291
+ if (await isDirectory(candidate)) {
292
+ hits.push(candidate);
293
+ }
294
+ }
295
+ return hits;
296
+ }
297
+ async function isDirectory(path) {
298
+ try {
299
+ const entry = await stat(path);
300
+ return entry.isDirectory();
301
+ } catch {
302
+ return false;
303
+ }
304
+ }
222
305
 
223
306
  // src/lib/ui.ts
224
307
  import color from "picocolors";
@@ -258,9 +341,11 @@ function printDivider() {
258
341
  process.stdout.write(`${color.dim("----------------------------------------")}
259
342
  `);
260
343
  }
261
- function printCheck(state, label, details) {
262
- const icon = state === "ok" ? color.green("[ok]") : state === "warn" ? color.yellow("[..]") : color.red("[x]");
263
- process.stdout.write(`${icon} ${color.bold(label)}: ${details}
344
+ function printCheck(state, label, details, options) {
345
+ const status = state === "ok" ? color.green("ok") : state === "warn" ? color.yellow("warn") : color.red("fail");
346
+ const indent = " ".repeat(Math.max(0, options?.indent ?? 0));
347
+ const paddedLabel = `${label}:`.padEnd(26);
348
+ process.stdout.write(`\u2502 ${indent}${color.bold(paddedLabel)} ${details} ${status}
264
349
  `);
265
350
  }
266
351
  function printSummaryCard(lines) {
@@ -298,13 +383,17 @@ async function runDoctorCommand(cwd) {
298
383
 
299
384
  // src/commands/onboard.ts
300
385
  import { access as access3, copyFile, readFile, writeFile } from "fs/promises";
301
- import { join as join3 } from "path";
386
+ import { homedir as homedir2 } from "os";
387
+ import { join as join3, resolve as resolve3 } from "path";
302
388
  import { confirm, isCancel, log, select, spinner, text } from "@clack/prompts";
303
389
  import dotenv from "dotenv";
304
390
  var DEFAULT_COMPANY_NAME_ENV = "BOPO_DEFAULT_COMPANY_NAME";
305
391
  var DEFAULT_COMPANY_ID_ENV = "BOPO_DEFAULT_COMPANY_ID";
306
392
  var DEFAULT_PUBLIC_COMPANY_ID_ENV = "NEXT_PUBLIC_DEFAULT_COMPANY_ID";
307
393
  var DEFAULT_AGENT_PROVIDER_ENV = "BOPO_DEFAULT_AGENT_PROVIDER";
394
+ var DEFAULT_AGENT_MODEL_ENV = "BOPO_DEFAULT_AGENT_MODEL";
395
+ var DEFAULT_TEMPLATE_ENV = "BOPO_DEFAULT_TEMPLATE_ID";
396
+ var DEFAULT_DEPLOYMENT_MODE_ENV = "BOPO_DEPLOYMENT_MODE";
308
397
  var DEFAULT_ENV_TEMPLATE = "NEXT_PUBLIC_API_URL=http://localhost:4020\n";
309
398
  var CLI_ONBOARD_VISIBLE_PROVIDERS = [
310
399
  { value: "codex", label: "Codex" },
@@ -312,24 +401,31 @@ var CLI_ONBOARD_VISIBLE_PROVIDERS = [
312
401
  { value: "gemini_cli", label: "Gemini" },
313
402
  { value: "opencode", label: "OpenCode" }
314
403
  ];
404
+ var CLI_ONBOARD_TEMPLATES = [
405
+ { value: "founder-startup-basic", label: "Founder Startup Basic" },
406
+ { value: "marketing-content-engine", label: "Marketing Content Engine" },
407
+ { value: "__custom__", label: "Custom template id/slug" }
408
+ ];
315
409
  var defaultDeps = {
316
410
  installDependencies: async (workspaceRoot) => {
317
- const code = await runCommandStreaming("pnpm", ["install"], { cwd: workspaceRoot });
318
- if (code !== 0) {
319
- throw new Error(`pnpm install failed with exit code ${String(code)}`);
411
+ const result = await runCommandCapture("pnpm", ["install"], { cwd: workspaceRoot });
412
+ if (!result.ok) {
413
+ const details = [result.stderr, result.stdout].filter((value) => value.trim().length > 0).join("\n").trim();
414
+ throw new Error(details.length > 0 ? details : `pnpm install failed with exit code ${String(result.code)}`);
320
415
  }
321
416
  },
322
417
  runDoctor: (workspaceRoot) => runDoctorChecks({ workspaceRoot }),
323
418
  initializeDatabase: async (workspaceRoot, dbPath) => {
324
- const code = await runCommandStreaming("pnpm", ["--filter", "bopodev-api", "db:init"], {
419
+ const result = await runCommandCapture("pnpm", ["--filter", "bopodev-api", "db:init"], {
325
420
  cwd: workspaceRoot,
326
421
  env: {
327
422
  ...process.env,
328
423
  ...dbPath ? { BOPO_DB_PATH: dbPath } : {}
329
424
  }
330
425
  });
331
- if (code !== 0) {
332
- throw new Error(`db:init failed with exit code ${String(code)}`);
426
+ if (!result.ok) {
427
+ const details = [result.stderr, result.stdout].filter((value) => value.trim().length > 0).join("\n").trim();
428
+ throw new Error(details.length > 0 ? details : `db:init failed with exit code ${String(result.code)}`);
333
429
  }
334
430
  },
335
431
  seedOnboardingDatabase: async (workspaceRoot, input) => {
@@ -339,6 +435,8 @@ var defaultDeps = {
339
435
  ...process.env,
340
436
  [DEFAULT_COMPANY_NAME_ENV]: input.companyName,
341
437
  [DEFAULT_AGENT_PROVIDER_ENV]: input.agentProvider,
438
+ ...process.env[DEFAULT_AGENT_MODEL_ENV] ? { [DEFAULT_AGENT_MODEL_ENV]: process.env[DEFAULT_AGENT_MODEL_ENV] } : {},
439
+ ...input.templateId ? { [DEFAULT_TEMPLATE_ENV]: input.templateId } : {},
342
440
  ...input.companyId ? { [DEFAULT_COMPANY_ID_ENV]: input.companyId } : {},
343
441
  ...input.dbPath ? { BOPO_DB_PATH: input.dbPath } : {}
344
442
  }
@@ -361,11 +459,15 @@ var defaultDeps = {
361
459
  }
362
460
  return answer.trim();
363
461
  },
364
- promptForAgentProvider: async () => {
462
+ promptForAgentProvider: async (input) => {
463
+ const availableProviders = input?.availableProviders && input.availableProviders.length > 0 ? input.availableProviders : CLI_ONBOARD_VISIBLE_PROVIDERS.map((entry) => entry.value);
464
+ const options = CLI_ONBOARD_VISIBLE_PROVIDERS.filter((entry) => availableProviders.includes(entry.value));
465
+ const fallback = options[0]?.value ?? "codex";
466
+ const preferred = input?.preferredProvider && availableProviders.includes(input.preferredProvider) ? input.preferredProvider : fallback;
365
467
  const answer = await select({
366
468
  message: "Primary agent framework",
367
- initialValue: "codex",
368
- options: CLI_ONBOARD_VISIBLE_PROVIDERS
469
+ initialValue: preferred,
470
+ options
369
471
  });
370
472
  if (isCancel(answer)) {
371
473
  throw new Error("Onboarding cancelled.");
@@ -375,6 +477,57 @@ var defaultDeps = {
375
477
  throw new Error("Invalid primary agent framework selected.");
376
478
  }
377
479
  return provider;
480
+ },
481
+ promptForAgentModel: async ({ provider, preferredModel }) => {
482
+ const options = buildModelOptions(provider, preferredModel ?? void 0);
483
+ const defaultOption = options[0];
484
+ const answer = await select({
485
+ message: "Default model",
486
+ initialValue: defaultOption?.value ?? "",
487
+ options
488
+ });
489
+ if (isCancel(answer)) {
490
+ throw new Error("Onboarding cancelled.");
491
+ }
492
+ const selected = typeof answer === "string" ? answer.trim() : "";
493
+ if (selected === "__auto__") {
494
+ return void 0;
495
+ }
496
+ return selected.length > 0 ? selected : void 0;
497
+ },
498
+ promptForTemplateUsage: async ({ currentTemplateId }) => {
499
+ const answer = await confirm({
500
+ message: "Do you want to use a template?",
501
+ initialValue: Boolean(currentTemplateId)
502
+ });
503
+ if (isCancel(answer)) {
504
+ throw new Error("Onboarding cancelled.");
505
+ }
506
+ return Boolean(answer);
507
+ },
508
+ promptForTemplateSelection: async ({ currentTemplateId }) => {
509
+ const matchingDefault = CLI_ONBOARD_TEMPLATES.find((entry) => entry.value === currentTemplateId)?.value;
510
+ const answer = await select({
511
+ message: "Select template",
512
+ initialValue: matchingDefault ?? "founder-startup-basic",
513
+ options: CLI_ONBOARD_TEMPLATES.map((entry) => ({ value: entry.value, label: entry.label }))
514
+ });
515
+ if (isCancel(answer)) {
516
+ throw new Error("Onboarding cancelled.");
517
+ }
518
+ const selected = typeof answer === "string" ? answer.trim() : "";
519
+ if (selected === "__custom__") {
520
+ const custom = await text({
521
+ message: "Template id or slug",
522
+ placeholder: "founder-startup-basic",
523
+ validate: (value) => value.trim().length > 0 ? void 0 : "Template id/slug is required."
524
+ });
525
+ if (isCancel(custom)) {
526
+ throw new Error("Onboarding cancelled.");
527
+ }
528
+ return custom.trim();
529
+ }
530
+ return selected.length > 0 ? selected : void 0;
378
531
  }
379
532
  };
380
533
  async function runOnboardFlow(options, deps = defaultDeps) {
@@ -400,57 +553,132 @@ async function runOnboardFlow(options, deps = defaultDeps) {
400
553
  const shouldInstall = options.forceInstall || !await hasExistingInstall(workspaceRoot);
401
554
  if (shouldInstall) {
402
555
  const installSpin = spinner();
403
- installSpin.start("Installing dependencies");
556
+ installSpin.start("Preparing dependencies");
404
557
  await deps.installDependencies(workspaceRoot);
405
- installSpin.stop("Dependencies installed");
406
- } else {
407
- log.step("Dependencies already present. Skipping install (use --force-install to reinstall).");
408
- }
409
- const envSpin = spinner();
410
- envSpin.start("Ensuring .env exists");
411
- const envResult = await ensureEnvFile(workspaceRoot);
412
- const envCreated = envResult.created;
413
- if (!envResult.created) {
414
- envSpin.stop(".env already present");
415
- } else if (envResult.source === "example") {
416
- envSpin.stop("Created .env from .env.example");
558
+ installSpin.stop("Dependencies ready");
417
559
  } else {
418
- envSpin.stop("Created .env with defaults (.env.example not found)");
560
+ printCheck("ok", "Dependencies", "Already installed");
419
561
  }
420
562
  const envPath = join3(workspaceRoot, ".env");
421
- dotenv.config({ path: envPath });
422
- const envValues = await readEnvValues(envPath);
423
- let companyName = envValues[DEFAULT_COMPANY_NAME_ENV]?.trim() ?? "";
563
+ const preEnvValues = await fileExists2(envPath) ? await readEnvValues(envPath) : {};
564
+ const doctorSpin = spinner();
565
+ doctorSpin.start("Running doctor checks");
566
+ const checks = await deps.runDoctor(workspaceRoot);
567
+ doctorSpin.stop("Doctor checks complete");
568
+ const runtimeAvailability = deriveAvailableAgentProviders(checks);
569
+ const passed = checks.filter((check) => check.ok).length;
570
+ const warnings = checks.length - passed;
571
+ printCheck("ok", "Doctor", "checks complete");
572
+ printCheck("ok", "Doctor summary", `${passed} passed, ${warnings} warning${warnings === 1 ? "" : "s"}`);
573
+ if (warnings === 0) {
574
+ printCheck("ok", "Doctor status", "All checks passed");
575
+ }
576
+ for (const check of checks) {
577
+ printCheck(check.ok ? "ok" : "warn", check.label, check.details);
578
+ }
579
+ let companyName = preEnvValues[DEFAULT_COMPANY_NAME_ENV]?.trim() ?? "";
424
580
  if (companyName.length > 0) {
425
581
  printCheck("ok", "Default company", companyName);
426
582
  } else {
427
583
  companyName = await deps.promptForCompanyName();
428
- await updateEnvFile(envPath, { [DEFAULT_COMPANY_NAME_ENV]: companyName });
429
- process.env[DEFAULT_COMPANY_NAME_ENV] = companyName;
430
584
  printCheck("ok", "Default company", companyName);
431
585
  }
432
- let agentProvider = parseAgentProvider(envValues[DEFAULT_AGENT_PROVIDER_ENV]);
433
- if (agentProvider) {
586
+ const selectableProviders = runtimeAvailability.length > 0 ? runtimeAvailability : CLI_ONBOARD_VISIBLE_PROVIDERS.map((entry) => entry.value);
587
+ const configuredProvider = parseAgentProvider(preEnvValues[DEFAULT_AGENT_PROVIDER_ENV]);
588
+ let agentProvider = configuredProvider ?? selectableProviders[0] ?? "codex";
589
+ const canReuseProvider = Boolean(configuredProvider && selectableProviders.includes(configuredProvider));
590
+ if (canReuseProvider) {
434
591
  printCheck("ok", "Primary agent framework", formatAgentProvider(agentProvider));
435
592
  } else {
436
- agentProvider = await deps.promptForAgentProvider();
437
- await updateEnvFile(envPath, { [DEFAULT_AGENT_PROVIDER_ENV]: agentProvider });
438
- process.env[DEFAULT_AGENT_PROVIDER_ENV] = agentProvider;
593
+ agentProvider = await deps.promptForAgentProvider({
594
+ availableProviders: selectableProviders,
595
+ preferredProvider: configuredProvider
596
+ });
439
597
  printCheck("ok", "Primary agent framework", formatAgentProvider(agentProvider));
440
598
  }
599
+ const preferredModel = normalizeOptionalEnvValue(preEnvValues[DEFAULT_AGENT_MODEL_ENV]) ?? getDefaultModelForProvider(agentProvider);
600
+ const selectedAgentModel = options.yes ? preferredModel ?? void 0 : await deps.promptForAgentModel({
601
+ provider: agentProvider,
602
+ preferredModel
603
+ });
604
+ printCheck("ok", "Default model", selectedAgentModel ?? "Provider default");
605
+ const explicitTemplateId = normalizeOptionalEnvValue(options.template);
606
+ const envTemplateId = normalizeOptionalEnvValue(preEnvValues[DEFAULT_TEMPLATE_ENV]);
607
+ let requestedTemplateId = explicitTemplateId ?? envTemplateId;
608
+ if (!options.yes && !explicitTemplateId) {
609
+ const promptForTemplateUsage = deps.promptForTemplateUsage ?? defaultDeps.promptForTemplateUsage;
610
+ const promptForTemplateSelection = deps.promptForTemplateSelection ?? defaultDeps.promptForTemplateSelection;
611
+ if (!promptForTemplateUsage || !promptForTemplateSelection) {
612
+ throw new Error("Template onboarding prompts are not configured.");
613
+ }
614
+ const wantsTemplate = await promptForTemplateUsage({
615
+ currentTemplateId: envTemplateId ?? null
616
+ });
617
+ if (wantsTemplate) {
618
+ requestedTemplateId = await promptForTemplateSelection({
619
+ currentTemplateId: requestedTemplateId ?? null
620
+ }) ?? void 0;
621
+ } else {
622
+ requestedTemplateId = void 0;
623
+ }
624
+ }
625
+ if (requestedTemplateId) {
626
+ printCheck("ok", "Template", requestedTemplateId);
627
+ } else {
628
+ printCheck("ok", "Template", "Skipped");
629
+ }
630
+ const envSpin = spinner();
631
+ envSpin.start("Ensuring local environment");
632
+ const envResult = await ensureEnvFile(workspaceRoot);
633
+ await sanitizeBlankDbPathEnvEntry(envPath);
634
+ await updateEnvFile(envPath, {
635
+ [DEFAULT_DEPLOYMENT_MODE_ENV]: "local",
636
+ [DEFAULT_COMPANY_NAME_ENV]: companyName,
637
+ [DEFAULT_AGENT_PROVIDER_ENV]: agentProvider ?? "codex",
638
+ ...requestedTemplateId ? { [DEFAULT_TEMPLATE_ENV]: requestedTemplateId } : {},
639
+ ...selectedAgentModel ? { [DEFAULT_AGENT_MODEL_ENV]: selectedAgentModel } : {}
640
+ });
641
+ if (!requestedTemplateId) {
642
+ await removeEnvKeys(envPath, [DEFAULT_TEMPLATE_ENV]);
643
+ }
644
+ dotenv.config({ path: envPath, quiet: true });
645
+ const envValues = await readEnvValues(envPath);
646
+ const configuredDbPath = normalizeOptionalEnvValue(envValues.BOPO_DB_PATH);
647
+ if (configuredDbPath) {
648
+ process.env.BOPO_DB_PATH = configuredDbPath;
649
+ } else {
650
+ delete process.env.BOPO_DB_PATH;
651
+ }
652
+ process.env[DEFAULT_DEPLOYMENT_MODE_ENV] = "local";
653
+ process.env[DEFAULT_COMPANY_NAME_ENV] = companyName;
654
+ process.env[DEFAULT_AGENT_PROVIDER_ENV] = agentProvider ?? "codex";
655
+ if (requestedTemplateId) {
656
+ process.env[DEFAULT_TEMPLATE_ENV] = requestedTemplateId;
657
+ } else {
658
+ delete process.env[DEFAULT_TEMPLATE_ENV];
659
+ }
660
+ if (selectedAgentModel) {
661
+ process.env[DEFAULT_AGENT_MODEL_ENV] = selectedAgentModel;
662
+ } else {
663
+ delete process.env[DEFAULT_AGENT_MODEL_ENV];
664
+ }
665
+ envSpin.stop(
666
+ envResult.created ? envResult.source === "example" ? "Environment configured from .env.example" : "Environment configured from defaults" : "Environment updated"
667
+ );
441
668
  const dbSpin = spinner();
442
- dbSpin.start("Initializing local database");
443
- await deps.initializeDatabase(workspaceRoot, process.env.BOPO_DB_PATH);
444
- dbSpin.stop("Database initialized");
669
+ dbSpin.start("Initializing and migrating database");
670
+ await deps.initializeDatabase(workspaceRoot, configuredDbPath);
671
+ dbSpin.stop("Database ready");
445
672
  const seedSpin = spinner();
446
- seedSpin.start("Ensuring default company and CEO agent");
673
+ seedSpin.start("Seeding default company and CEO");
447
674
  const seedResult = await deps.seedOnboardingDatabase(workspaceRoot, {
448
- dbPath: process.env.BOPO_DB_PATH,
675
+ dbPath: configuredDbPath,
449
676
  companyName,
450
677
  companyId: envValues[DEFAULT_COMPANY_ID_ENV]?.trim() || void 0,
451
- agentProvider
678
+ agentProvider,
679
+ templateId: requestedTemplateId
452
680
  });
453
- seedSpin.stop("Default company and CEO ready");
681
+ seedSpin.stop("Seed complete");
454
682
  await updateEnvFile(envPath, {
455
683
  [DEFAULT_COMPANY_NAME_ENV]: seedResult.companyName,
456
684
  [DEFAULT_COMPANY_ID_ENV]: seedResult.companyId,
@@ -461,33 +689,45 @@ async function runOnboardFlow(options, deps = defaultDeps) {
461
689
  process.env[DEFAULT_COMPANY_ID_ENV] = seedResult.companyId;
462
690
  process.env[DEFAULT_PUBLIC_COMPANY_ID_ENV] = seedResult.companyId;
463
691
  process.env[DEFAULT_AGENT_PROVIDER_ENV] = seedResult.ceoProviderType;
692
+ if (seedResult.ceoRuntimeModel) {
693
+ process.env[DEFAULT_AGENT_MODEL_ENV] = seedResult.ceoRuntimeModel;
694
+ } else if (selectedAgentModel) {
695
+ process.env[DEFAULT_AGENT_MODEL_ENV] = selectedAgentModel;
696
+ } else {
697
+ delete process.env[DEFAULT_AGENT_MODEL_ENV];
698
+ }
699
+ if (seedResult.templateId) {
700
+ process.env[DEFAULT_TEMPLATE_ENV] = seedResult.templateId;
701
+ } else if (!requestedTemplateId) {
702
+ delete process.env[DEFAULT_TEMPLATE_ENV];
703
+ }
464
704
  printCheck("ok", "Configured company", `${seedResult.companyName}${seedResult.companyCreated ? " (created)" : ""}`);
465
705
  printCheck(
466
706
  "ok",
467
707
  "CEO agent",
468
708
  `${seedResult.ceoCreated ? "Created CEO" : seedResult.ceoMigrated ? "Migrated existing CEO" : "CEO already present"} (${formatAgentProvider(seedResult.ceoProviderType)})`
469
709
  );
470
- const doctorSpin = spinner();
471
- doctorSpin.start("Running doctor checks");
472
- const checks = await deps.runDoctor(workspaceRoot);
473
- doctorSpin.stop("Doctor checks complete");
474
- printDivider();
475
- printSection("Doctor");
476
- for (const check of checks) {
477
- printCheck(check.ok ? "ok" : "warn", check.label, check.details);
710
+ if (requestedTemplateId) {
711
+ printCheck(
712
+ seedResult.templateApplied ? "ok" : "warn",
713
+ "Template apply",
714
+ seedResult.templateApplied ? `Applied ${seedResult.templateId ?? requestedTemplateId}` : `Template not applied (${requestedTemplateId})`
715
+ );
478
716
  }
479
- const passed = checks.filter((check) => check.ok).length;
480
- const failed = checks.length - passed;
481
- printLine("");
717
+ const dbPathSummary = resolveDbPathSummary(configuredDbPath);
482
718
  printSummaryCard([
483
- `Summary: ${passed} passed, ${failed} warnings`,
484
- "Web URL: http://127.0.0.1:4010 (auto-fallback if occupied)",
485
- "API URL: http://127.0.0.1:4020 (auto-fallback if occupied)"
719
+ `Mode ${padSummaryValue("local")}`,
720
+ `Deploy ${padSummaryValue("local_mac")}`,
721
+ `Doctor ${padSummaryValue(`${passed} passed, ${warnings} warning${warnings === 1 ? "" : "s"}`)}`,
722
+ `Company ${padSummaryValue(`${seedResult.companyName} (${seedResult.companyId})`)}`,
723
+ `Agent ${padSummaryValue(formatAgentProvider(seedResult.ceoProviderType))}`,
724
+ `Model ${padSummaryValue(seedResult.ceoRuntimeModel ?? selectedAgentModel ?? "provider default")}`,
725
+ `API ${padSummaryValue("http://127.0.0.1:4020")}`,
726
+ `UI ${padSummaryValue("http://127.0.0.1:4010")}`,
727
+ `DB ${padSummaryValue(dbPathSummary)}`
486
728
  ]);
487
- printLine("");
488
729
  if (options.start) {
489
- printSection("Starting services");
490
- printLine("Running `pnpm start:quiet` (production mode)...");
730
+ printLine("Starting services in quiet mode and opening admin...");
491
731
  printDivider();
492
732
  await deps.startServices(workspaceRoot);
493
733
  } else {
@@ -499,7 +739,7 @@ async function runOnboardFlow(options, deps = defaultDeps) {
499
739
  }
500
740
  return {
501
741
  workspaceRoot,
502
- envCreated,
742
+ envCreated: envResult.created,
503
743
  dbInitialized: true,
504
744
  checks
505
745
  };
@@ -542,9 +782,138 @@ async function updateEnvFile(envPath, updates) {
542
782
  await writeFile(envPath, nextContent.endsWith("\n") ? nextContent : `${nextContent}
543
783
  `, "utf8");
544
784
  }
785
+ async function sanitizeBlankDbPathEnvEntry(envPath) {
786
+ const existingContent = await readFile(envPath, "utf8");
787
+ const lines = existingContent.split(/\r?\n/);
788
+ let changed = false;
789
+ const nextLines = lines.map((line) => {
790
+ if (!line.startsWith("BOPO_DB_PATH=")) {
791
+ return line;
792
+ }
793
+ const value = line.slice("BOPO_DB_PATH=".length).trim();
794
+ if (value.length > 0) {
795
+ return line;
796
+ }
797
+ changed = true;
798
+ return "# BOPO_DB_PATH= # optional override; leave unset to use default instance path";
799
+ });
800
+ if (!changed) {
801
+ return;
802
+ }
803
+ const nextContent = nextLines.join("\n");
804
+ await writeFile(envPath, nextContent.endsWith("\n") ? nextContent : `${nextContent}
805
+ `, "utf8");
806
+ }
807
+ async function removeEnvKeys(envPath, keys) {
808
+ if (keys.length === 0) {
809
+ return;
810
+ }
811
+ const existingContent = await readFile(envPath, "utf8");
812
+ const nextLines = existingContent.split(/\r?\n/).filter((line) => !keys.some((key) => line.startsWith(`${key}=`)));
813
+ const nextContent = nextLines.join("\n");
814
+ await writeFile(envPath, nextContent.endsWith("\n") ? nextContent : `${nextContent}
815
+ `, "utf8");
816
+ }
545
817
  function serializeEnvValue(value) {
546
818
  return /[\s#"'`]/.test(value) ? JSON.stringify(value) : value;
547
819
  }
820
+ function normalizeOptionalEnvValue(value) {
821
+ const normalized = value?.trim();
822
+ return normalized && normalized.length > 0 ? normalized : void 0;
823
+ }
824
+ function deriveAvailableAgentProviders(checks) {
825
+ const providers = [];
826
+ for (const check of checks) {
827
+ if (!check.ok) {
828
+ continue;
829
+ }
830
+ if (check.label === "Codex runtime") {
831
+ providers.push("codex");
832
+ }
833
+ if (check.label === "Claude Code runtime") {
834
+ providers.push("claude_code");
835
+ }
836
+ if (check.label === "Gemini runtime") {
837
+ providers.push("gemini_cli");
838
+ }
839
+ if (check.label === "OpenCode runtime") {
840
+ providers.push("opencode");
841
+ }
842
+ }
843
+ return Array.from(new Set(providers));
844
+ }
845
+ function resolveDbPathSummary(configuredDbPath) {
846
+ if (configuredDbPath) {
847
+ return resolve3(expandHomePrefix2(configuredDbPath));
848
+ }
849
+ const home = process.env.BOPO_HOME?.trim() ? expandHomePrefix2(process.env.BOPO_HOME.trim()) : join3(homedir2(), ".bopodev");
850
+ const instanceId = process.env.BOPO_INSTANCE_ID?.trim() || "default";
851
+ return resolve3(home, "instances", instanceId, "db", "bopodev.db");
852
+ }
853
+ function expandHomePrefix2(value) {
854
+ if (value === "~") {
855
+ return homedir2();
856
+ }
857
+ if (value.startsWith("~/")) {
858
+ return resolve3(homedir2(), value.slice(2));
859
+ }
860
+ return value;
861
+ }
862
+ function padSummaryValue(value) {
863
+ return `| ${value}`;
864
+ }
865
+ function getDefaultModelForProvider(provider) {
866
+ if (provider === "codex" || provider === "openai_api") {
867
+ return process.env.BOPO_OPENAI_MODEL?.trim() || "gpt-5";
868
+ }
869
+ if (provider === "claude_code" || provider === "anthropic_api") {
870
+ return process.env.BOPO_ANTHROPIC_MODEL?.trim() || "claude-sonnet-4-6";
871
+ }
872
+ if (provider === "opencode") {
873
+ return process.env.BOPO_OPENCODE_MODEL?.trim() || "opencode/default";
874
+ }
875
+ if (provider === "gemini_cli") {
876
+ return process.env.BOPO_GEMINI_MODEL?.trim() || "gemini-2.5-pro";
877
+ }
878
+ if (provider === "shell") {
879
+ return "n/a";
880
+ }
881
+ return null;
882
+ }
883
+ function buildModelOptions(provider, preferredModel) {
884
+ const providerDefault = { value: "", label: "Provider default" };
885
+ const defaultModel = getDefaultModelForProvider(provider);
886
+ const modelIds = /* @__PURE__ */ new Set();
887
+ const presets = getModelPresetsForProvider(provider);
888
+ for (const model of presets) {
889
+ modelIds.add(model);
890
+ }
891
+ if (preferredModel && preferredModel.trim().length > 0) {
892
+ modelIds.add(preferredModel.trim());
893
+ }
894
+ if (defaultModel) {
895
+ modelIds.add(defaultModel);
896
+ }
897
+ return [
898
+ providerDefault,
899
+ ...Array.from(modelIds).map((model) => ({ value: model, label: model }))
900
+ ];
901
+ }
902
+ function getModelPresetsForProvider(provider) {
903
+ if (provider === "codex" || provider === "openai_api") {
904
+ return ["gpt-5", "gpt-5-mini", "gpt-4.1"];
905
+ }
906
+ if (provider === "claude_code" || provider === "anthropic_api") {
907
+ return ["claude-sonnet-4-6", "claude-opus-4-1"];
908
+ }
909
+ if (provider === "gemini_cli") {
910
+ return ["gemini-2.5-pro", "gemini-2.5-flash"];
911
+ }
912
+ if (provider === "opencode") {
913
+ return ["opencode/default"];
914
+ }
915
+ return [];
916
+ }
548
917
  function parseSeedResult(stdout) {
549
918
  const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
550
919
  const lastLine = lines[lines.length - 1];
@@ -552,7 +921,7 @@ function parseSeedResult(stdout) {
552
921
  throw new Error("onboard:seed did not return a result.");
553
922
  }
554
923
  const parsed = JSON.parse(lastLine);
555
- if (typeof parsed.companyId !== "string" || typeof parsed.companyName !== "string" || typeof parsed.companyCreated !== "boolean" || typeof parsed.ceoCreated !== "boolean" || typeof parsed.ceoMigrated !== "boolean" || !parseAgentProvider(parsed.ceoProviderType)) {
924
+ if (typeof parsed.companyId !== "string" || typeof parsed.companyName !== "string" || typeof parsed.companyCreated !== "boolean" || typeof parsed.ceoCreated !== "boolean" || !(parsed.ceoRuntimeModel === null || typeof parsed.ceoRuntimeModel === "string" || typeof parsed.ceoRuntimeModel === "undefined") || typeof parsed.ceoMigrated !== "boolean" || !parseAgentProvider(parsed.ceoProviderType)) {
556
925
  throw new Error("onboard:seed returned an invalid result.");
557
926
  }
558
927
  return {
@@ -561,7 +930,10 @@ function parseSeedResult(stdout) {
561
930
  companyCreated: parsed.companyCreated,
562
931
  ceoCreated: parsed.ceoCreated,
563
932
  ceoProviderType: parseAgentProvider(parsed.ceoProviderType) ?? "shell",
564
- ceoMigrated: parsed.ceoMigrated
933
+ ceoRuntimeModel: typeof parsed.ceoRuntimeModel === "string" ? parsed.ceoRuntimeModel : null,
934
+ ceoMigrated: parsed.ceoMigrated,
935
+ templateApplied: typeof parsed.templateApplied === "boolean" ? parsed.templateApplied : void 0,
936
+ templateId: typeof parsed.templateId === "string" ? parsed.templateId : null
565
937
  };
566
938
  }
567
939
  function parseAgentProvider(value) {
@@ -621,13 +993,14 @@ async function runStartCommand(cwd, options) {
621
993
  // src/index.ts
622
994
  var program = new Command();
623
995
  program.name("bopodev").description("Bopodev CLI");
624
- program.command("onboard").description("Install, configure, and start Bopodev locally").option("--yes", "Run non-interactively using defaults", false).option("--force-install", "Force reinstall dependencies even if already installed", false).option("--no-start", "Run setup and doctor checks without starting services").action(async (options) => {
996
+ program.command("onboard").description("Install, configure, and start Bopodev locally").option("--yes", "Run non-interactively using defaults", false).option("--force-install", "Force reinstall dependencies even if already installed", false).option("--template <template>", "Apply template by id or slug during onboarding").option("--no-start", "Run setup and doctor checks without starting services").action(async (options) => {
625
997
  try {
626
998
  await runOnboardFlow({
627
999
  cwd: process.cwd(),
628
1000
  yes: options.yes,
629
1001
  start: options.start,
630
- forceInstall: options.forceInstall
1002
+ forceInstall: options.forceInstall,
1003
+ template: options.template
631
1004
  });
632
1005
  if (!options.start) {
633
1006
  outro("Onboarding finished.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bopodev",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "bin": {