bopodev 0.1.27 → 0.1.29

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 +325 -59
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -20,19 +20,41 @@ async function commandExists(command) {
20
20
  }
21
21
  async function runCommandCapture(command, args, options) {
22
22
  return new Promise((resolvePromise) => {
23
+ const supportsProcessGroups = process.platform !== "win32";
23
24
  const child = spawn(command, args, {
24
25
  cwd: options?.cwd ?? process.cwd(),
25
26
  env: options?.env ?? process.env,
26
27
  stdio: ["ignore", "pipe", "pipe"],
27
- shell: false
28
+ shell: false,
29
+ detached: supportsProcessGroups
28
30
  });
29
31
  let stdout = "";
30
32
  let stderr = "";
33
+ let settled = false;
34
+ let timedOut = false;
31
35
  const timeoutMs = Math.max(0, Math.floor(options?.timeoutMs ?? 1e4));
36
+ let killHandle = null;
37
+ const terminate = (signal) => {
38
+ if (child.killed || child.exitCode !== null) {
39
+ return;
40
+ }
41
+ try {
42
+ if (supportsProcessGroups && child.pid) {
43
+ process.kill(-child.pid, signal);
44
+ } else {
45
+ child.kill(signal);
46
+ }
47
+ } catch {
48
+ }
49
+ };
32
50
  const timeoutHandle = timeoutMs > 0 ? setTimeout(() => {
51
+ timedOut = true;
33
52
  stderr = `${stderr}
34
53
  Command '${command}' timed out after ${timeoutMs}ms.`.trim();
35
- child.kill("SIGTERM");
54
+ terminate("SIGTERM");
55
+ killHandle = setTimeout(() => {
56
+ terminate("SIGKILL");
57
+ }, 5e3);
36
58
  }, timeoutMs) : null;
37
59
  child.stdout.on("data", (chunk) => {
38
60
  stdout += String(chunk);
@@ -41,9 +63,16 @@ Command '${command}' timed out after ${timeoutMs}ms.`.trim();
41
63
  stderr += String(chunk);
42
64
  });
43
65
  child.on("error", (error) => {
66
+ if (settled) {
67
+ return;
68
+ }
69
+ settled = true;
44
70
  if (timeoutHandle) {
45
71
  clearTimeout(timeoutHandle);
46
72
  }
73
+ if (killHandle) {
74
+ clearTimeout(killHandle);
75
+ }
47
76
  resolvePromise({
48
77
  ok: false,
49
78
  code: null,
@@ -53,11 +82,18 @@ ${String(error)}`.trim()
53
82
  });
54
83
  });
55
84
  child.on("close", (code) => {
85
+ if (settled) {
86
+ return;
87
+ }
88
+ settled = true;
56
89
  if (timeoutHandle) {
57
90
  clearTimeout(timeoutHandle);
58
91
  }
92
+ if (killHandle) {
93
+ clearTimeout(killHandle);
94
+ }
59
95
  resolvePromise({
60
- ok: code === 0,
96
+ ok: code === 0 && !timedOut,
61
97
  code,
62
98
  stdout,
63
99
  stderr
@@ -175,41 +211,43 @@ async function runDoctorChecks(options) {
175
211
  ok: nodeMajor >= 20,
176
212
  details: `Detected ${process.versions.node}; requires >= 20`
177
213
  });
178
- const pnpmAvailable = await commandExists("pnpm");
214
+ const codexCommand = process.env.BOPO_CODEX_COMMAND?.trim() || "codex";
215
+ const openCodeCommand = process.env.BOPO_OPENCODE_COMMAND?.trim() || "opencode";
216
+ const claudeCommand = process.env.BOPO_CLAUDE_COMMAND?.trim() || "claude";
217
+ const geminiCommand = process.env.BOPO_GEMINI_COMMAND?.trim() || "gemini";
218
+ const [pnpmAvailable, gitRuntime, codex, openCode, claude, gemini] = await Promise.all([
219
+ commandExists("pnpm"),
220
+ checkRuntimeCommandHealth("git", options?.workspaceRoot),
221
+ checkRuntimeCommandHealth(codexCommand, options?.workspaceRoot),
222
+ checkRuntimeCommandHealth(openCodeCommand, options?.workspaceRoot),
223
+ checkRuntimeCommandHealth(claudeCommand, options?.workspaceRoot),
224
+ checkRuntimeCommandHealth(geminiCommand, options?.workspaceRoot)
225
+ ]);
179
226
  checks.push({
180
227
  label: "pnpm",
181
228
  ok: pnpmAvailable,
182
229
  details: pnpmAvailable ? "pnpm is available" : "pnpm is not installed or not in PATH"
183
230
  });
184
- const codexCommand = process.env.BOPO_CODEX_COMMAND?.trim() || "codex";
185
- const gitRuntime = await checkRuntimeCommandHealth("git", options?.workspaceRoot);
186
231
  checks.push({
187
232
  label: "Git runtime",
188
233
  ok: gitRuntime.available && gitRuntime.exitCode === 0,
189
234
  details: gitRuntime.available && gitRuntime.exitCode === 0 ? "Command 'git' is available (required for repo bootstrap/worktree execution)" : gitRuntime.error ?? "Command 'git' is not available"
190
235
  });
191
- const codex = await checkRuntimeCommandHealth(codexCommand, options?.workspaceRoot);
192
236
  checks.push({
193
237
  label: "Codex runtime",
194
238
  ok: codex.available && codex.exitCode === 0,
195
239
  details: codex.available && codex.exitCode === 0 ? `Command '${codexCommand}' is available` : codex.error ?? `Command '${codexCommand}' exited with ${String(codex.exitCode)}`
196
240
  });
197
- const openCodeCommand = process.env.BOPO_OPENCODE_COMMAND?.trim() || "opencode";
198
- const openCode = await checkRuntimeCommandHealth(openCodeCommand, options?.workspaceRoot);
199
241
  checks.push({
200
242
  label: "OpenCode runtime",
201
243
  ok: openCode.available && openCode.exitCode === 0,
202
244
  details: openCode.available && openCode.exitCode === 0 ? `Command '${openCodeCommand}' is available` : openCode.error ?? `Command '${openCodeCommand}' exited with ${String(openCode.exitCode)}`
203
245
  });
204
- const claudeCommand = process.env.BOPO_CLAUDE_COMMAND?.trim() || "claude";
205
- const claude = await checkRuntimeCommandHealth(claudeCommand, options?.workspaceRoot);
206
246
  checks.push({
207
247
  label: "Claude Code runtime",
208
248
  ok: claude.available && claude.exitCode === 0,
209
249
  details: claude.available && claude.exitCode === 0 ? `Command '${claudeCommand}' is available` : claude.error ?? `Command '${claudeCommand}' exited with ${String(claude.exitCode)}`
210
250
  });
211
- const geminiCommand = process.env.BOPO_GEMINI_COMMAND?.trim() || "gemini";
212
- const gemini = await checkRuntimeCommandHealth(geminiCommand, options?.workspaceRoot);
213
251
  checks.push({
214
252
  label: "Gemini runtime",
215
253
  ok: gemini.available && gemini.exitCode === 0,
@@ -219,19 +257,24 @@ async function runDoctorChecks(options) {
219
257
  const instanceRoot = resolveInstanceRoot2();
220
258
  const storageRoot = join2(instanceRoot, "data", "storage");
221
259
  const workspaceRoot = join2(instanceRoot, "workspaces");
260
+ const [instanceRootWritable, workspaceRootWritable, storageRootWritable] = await Promise.all([
261
+ ensureWritableDirectory(instanceRoot),
262
+ ensureWritableDirectory(workspaceRoot),
263
+ ensureWritableDirectory(storageRoot)
264
+ ]);
222
265
  checks.push({
223
266
  label: "Instance root writable",
224
- ok: await ensureWritableDirectory(instanceRoot),
267
+ ok: instanceRootWritable,
225
268
  details: instanceRoot
226
269
  });
227
270
  checks.push({
228
271
  label: "Workspace root writable",
229
- ok: await ensureWritableDirectory(workspaceRoot),
272
+ ok: workspaceRootWritable,
230
273
  details: workspaceRoot
231
274
  });
232
275
  checks.push({
233
276
  label: "Storage root writable",
234
- ok: await ensureWritableDirectory(storageRoot),
277
+ ok: storageRootWritable,
235
278
  details: storageRoot
236
279
  });
237
280
  } catch (error) {
@@ -242,9 +285,11 @@ async function runDoctorChecks(options) {
242
285
  });
243
286
  }
244
287
  if (options?.workspaceRoot) {
245
- const driftCheck = await runWorkspacePathDriftCheck(options.workspaceRoot);
288
+ const [driftCheck, backfillCheck] = await Promise.all([
289
+ runWorkspacePathDriftCheck(options.workspaceRoot),
290
+ runWorkspaceBackfillDryRunCheck(options.workspaceRoot)
291
+ ]);
246
292
  checks.push(driftCheck);
247
- const backfillCheck = await runWorkspaceBackfillDryRunCheck(options.workspaceRoot);
248
293
  checks.push(backfillCheck);
249
294
  }
250
295
  return checks;
@@ -446,10 +491,82 @@ async function runDoctorCommand(cwd) {
446
491
  printSummaryCard([`Summary: ${passed} passed, ${failed} warnings`]);
447
492
  }
448
493
 
494
+ // src/commands/issue-shell-env.ts
495
+ import process2 from "process";
496
+ function readEnv(name, fallback = "") {
497
+ return (process2.env[name] ?? fallback).trim();
498
+ }
499
+ function buildActorHeaders() {
500
+ const token = readEnv("BOPO_ACTOR_TOKEN") || readEnv("BOPODEV_ACTOR_TOKEN");
501
+ if (token) {
502
+ return { authorization: `Bearer ${token}` };
503
+ }
504
+ return {
505
+ "x-actor-type": "board",
506
+ "x-actor-id": "bopodev-cli",
507
+ "x-actor-companies": "",
508
+ "x-actor-permissions": ""
509
+ };
510
+ }
511
+ async function runIssueShellEnvCommand(issueId, options) {
512
+ const base = options.apiUrl.replace(/\/$/, "");
513
+ const headers = {
514
+ "x-company-id": options.companyId,
515
+ ...buildActorHeaders()
516
+ };
517
+ const issueRes = await fetch(`${base}/issues/${encodeURIComponent(issueId)}`, { headers });
518
+ const issueRaw = await issueRes.json();
519
+ if (!issueRes.ok || !issueRaw.ok) {
520
+ throw new Error(
521
+ !issueRaw.ok && "error" in issueRaw ? String(issueRaw.error) : `Failed to load issue (${issueRes.status})`
522
+ );
523
+ }
524
+ const issue = issueRaw.data;
525
+ const wsRes = await fetch(`${base}/projects/${encodeURIComponent(issue.projectId)}/workspaces`, { headers });
526
+ const wsRaw = await wsRes.json();
527
+ if (!wsRes.ok || !wsRaw.ok) {
528
+ throw new Error(
529
+ !wsRaw.ok && "error" in wsRaw ? String(wsRaw.error) : `Failed to load workspaces (${wsRes.status})`
530
+ );
531
+ }
532
+ const workspaces = wsRaw.data;
533
+ const primary = workspaces.find((w) => w.isPrimary) ?? workspaces[0];
534
+ const cwd = primary?.cwd?.trim() || "";
535
+ const env = {
536
+ BOPODEV_API_BASE_URL: base,
537
+ BOPODEV_COMPANY_ID: options.companyId,
538
+ BOPODEV_ISSUE_ID: issue.id,
539
+ BOPODEV_PROJECT_ID: issue.projectId
540
+ };
541
+ if (options.json) {
542
+ console.log(JSON.stringify({ ...env, suggestedCwd: cwd || null, issueTitle: issue.title }, null, 2));
543
+ return;
544
+ }
545
+ const lines = [
546
+ `# Issue: ${issue.title}`,
547
+ `export BOPODEV_API_BASE_URL=${shellQuote(base)}`,
548
+ `export BOPODEV_COMPANY_ID=${shellQuote(options.companyId)}`,
549
+ `export BOPODEV_ISSUE_ID=${shellQuote(issue.id)}`,
550
+ `export BOPODEV_PROJECT_ID=${shellQuote(issue.projectId)}`
551
+ ];
552
+ if (cwd) {
553
+ lines.push(`cd ${shellQuote(cwd)}`);
554
+ } else {
555
+ lines.push("# No primary workspace cwd set for this project; set cwd in project workspaces in the UI.");
556
+ }
557
+ console.log(lines.join("\n"));
558
+ }
559
+ function shellQuote(value) {
560
+ if (!/[\s'"\\$`!]/.test(value)) {
561
+ return value;
562
+ }
563
+ return `'${value.replace(/'/g, `'\\''`)}'`;
564
+ }
565
+
449
566
  // src/commands/onboard.ts
450
567
  import { access as access3, copyFile, readFile, writeFile } from "fs/promises";
451
568
  import { homedir as homedir3 } from "os";
452
- import { join as join3, resolve as resolve3 } from "path";
569
+ import { basename, dirname, join as join3, resolve as resolve3 } from "path";
453
570
  import { confirm, isCancel, log, select, spinner, text } from "@clack/prompts";
454
571
  import dotenv from "dotenv";
455
572
  var DEFAULT_COMPANY_NAME_ENV = "BOPO_DEFAULT_COMPANY_NAME";
@@ -460,6 +577,8 @@ var DEFAULT_AGENT_MODEL_ENV = "BOPO_DEFAULT_AGENT_MODEL";
460
577
  var DEFAULT_TEMPLATE_ENV = "BOPO_DEFAULT_TEMPLATE_ID";
461
578
  var DEFAULT_DEPLOYMENT_MODE_ENV = "BOPO_DEPLOYMENT_MODE";
462
579
  var DEFAULT_ENV_TEMPLATE = "NEXT_PUBLIC_API_URL=http://localhost:4020\n";
580
+ var DB_INIT_TIMEOUT_MS = 12e4;
581
+ var ONBOARD_SEED_TIMEOUT_MS = 6e4;
463
582
  var CLI_ONBOARD_VISIBLE_PROVIDERS = [
464
583
  { value: "codex", label: "Codex" },
465
584
  { value: "claude_code", label: "Claude Code" },
@@ -483,6 +602,7 @@ var defaultDeps = {
483
602
  initializeDatabase: async (workspaceRoot, dbPath) => {
484
603
  const result = await runCommandCapture("pnpm", ["--filter", "bopodev-api", "db:init"], {
485
604
  cwd: workspaceRoot,
605
+ timeoutMs: DB_INIT_TIMEOUT_MS,
486
606
  env: {
487
607
  ...process.env,
488
608
  ...dbPath ? { BOPO_DB_PATH: dbPath } : {}
@@ -496,6 +616,7 @@ var defaultDeps = {
496
616
  seedOnboardingDatabase: async (workspaceRoot, input) => {
497
617
  const result = await runCommandCapture("pnpm", ["--filter", "bopodev-api", "onboard:seed"], {
498
618
  cwd: workspaceRoot,
619
+ timeoutMs: ONBOARD_SEED_TIMEOUT_MS,
499
620
  env: {
500
621
  ...process.env,
501
622
  [DEFAULT_COMPANY_NAME_ENV]: input.companyName,
@@ -624,23 +745,19 @@ async function runOnboardFlow(options, deps = defaultDeps) {
624
745
  } else {
625
746
  printCheck("ok", "Dependencies", "Already installed");
626
747
  }
748
+ let checks = [];
749
+ let passed = 0;
750
+ let warnings = 0;
751
+ if (!options.start) {
752
+ const doctorSpin = spinner();
753
+ doctorSpin.start("Running doctor checks");
754
+ checks = await deps.runDoctor(workspaceRoot);
755
+ doctorSpin.stop("Doctor checks complete");
756
+ passed = checks.filter((check) => check.ok).length;
757
+ warnings = checks.length - passed;
758
+ }
627
759
  const envPath = join3(workspaceRoot, ".env");
628
760
  const preEnvValues = await fileExists2(envPath) ? await readEnvValues(envPath) : {};
629
- const doctorSpin = spinner();
630
- doctorSpin.start("Running doctor checks");
631
- const checks = await deps.runDoctor(workspaceRoot);
632
- doctorSpin.stop("Doctor checks complete");
633
- const runtimeAvailability = deriveAvailableAgentProviders(checks);
634
- const passed = checks.filter((check) => check.ok).length;
635
- const warnings = checks.length - passed;
636
- printCheck("ok", "Doctor", "checks complete");
637
- printCheck("ok", "Doctor summary", `${passed} passed, ${warnings} warning${warnings === 1 ? "" : "s"}`);
638
- if (warnings === 0) {
639
- printCheck("ok", "Doctor status", "All checks passed");
640
- }
641
- for (const check of checks) {
642
- printCheck(check.ok ? "ok" : "warn", check.label, check.details);
643
- }
644
761
  let companyName = preEnvValues[DEFAULT_COMPANY_NAME_ENV]?.trim() ?? "";
645
762
  if (companyName.length > 0) {
646
763
  printCheck("ok", "Default company", companyName);
@@ -648,7 +765,7 @@ async function runOnboardFlow(options, deps = defaultDeps) {
648
765
  companyName = await deps.promptForCompanyName();
649
766
  printCheck("ok", "Default company", companyName);
650
767
  }
651
- const selectableProviders = runtimeAvailability.length > 0 ? runtimeAvailability : CLI_ONBOARD_VISIBLE_PROVIDERS.map((entry) => entry.value);
768
+ const selectableProviders = CLI_ONBOARD_VISIBLE_PROVIDERS.map((entry) => entry.value);
652
769
  const configuredProvider = parseAgentProvider(preEnvValues[DEFAULT_AGENT_PROVIDER_ENV]);
653
770
  let agentProvider = configuredProvider ?? selectableProviders[0] ?? "codex";
654
771
  const canReuseProvider = Boolean(configuredProvider && selectableProviders.includes(configuredProvider));
@@ -709,7 +826,14 @@ async function runOnboardFlow(options, deps = defaultDeps) {
709
826
  }
710
827
  dotenv.config({ path: envPath, quiet: true });
711
828
  const envValues = await readEnvValues(envPath);
712
- const configuredDbPath = normalizeOptionalEnvValue(envValues.BOPO_DB_PATH);
829
+ const configuredDbPathInfo = await normalizeConfiguredDbPathForOnboarding(normalizeOptionalEnvValue(envValues.BOPO_DB_PATH));
830
+ const configuredDbPath = configuredDbPathInfo.path;
831
+ if (configuredDbPathInfo.rewrittenFrom && configuredDbPath) {
832
+ await updateEnvFile(envPath, {
833
+ BOPO_DB_PATH: configuredDbPath
834
+ });
835
+ printCheck("warn", "Database path", `Updated legacy local DB path to ${configuredDbPath}`);
836
+ }
713
837
  if (configuredDbPath) {
714
838
  process.env.BOPO_DB_PATH = configuredDbPath;
715
839
  } else {
@@ -780,11 +904,23 @@ async function runOnboardFlow(options, deps = defaultDeps) {
780
904
  seedResult.templateApplied ? `Applied ${seedResult.templateId ?? requestedTemplateId}` : `Template not applied (${requestedTemplateId})`
781
905
  );
782
906
  }
907
+ if (!options.start) {
908
+ printCheck("ok", "Doctor", "checks complete");
909
+ printCheck("ok", "Doctor summary", `${passed} passed, ${warnings} warning${warnings === 1 ? "" : "s"}`);
910
+ if (warnings === 0) {
911
+ printCheck("ok", "Doctor status", "All checks passed");
912
+ }
913
+ for (const check of checks) {
914
+ printCheck(check.ok ? "ok" : "warn", check.label, check.details);
915
+ }
916
+ } else {
917
+ printCheck("ok", "Doctor", "Available on demand with `pnpm doctor`.");
918
+ }
783
919
  const dbPathSummary = resolveDbPathSummary(configuredDbPath);
784
920
  printSummaryCard([
785
921
  `Mode ${padSummaryValue("local")}`,
786
922
  `Deploy ${padSummaryValue("local_mac")}`,
787
- `Doctor ${padSummaryValue(`${passed} passed, ${warnings} warning${warnings === 1 ? "" : "s"}`)}`,
923
+ `Doctor ${padSummaryValue(options.start ? "On demand" : `${passed} passed, ${warnings} warning${warnings === 1 ? "" : "s"}`)}`,
788
924
  `Company ${padSummaryValue(`${seedResult.companyName} (${seedResult.companyId})`)}`,
789
925
  `Agent ${padSummaryValue(formatAgentProvider(seedResult.ceoProviderType))}`,
790
926
  `Model ${padSummaryValue(seedResult.ceoRuntimeModel ?? selectedAgentModel ?? "provider default")}`,
@@ -870,6 +1006,28 @@ async function sanitizeBlankDbPathEnvEntry(envPath) {
870
1006
  await writeFile(envPath, nextContent.endsWith("\n") ? nextContent : `${nextContent}
871
1007
  `, "utf8");
872
1008
  }
1009
+ async function normalizeConfiguredDbPathForOnboarding(rawValue) {
1010
+ const configuredPath = normalizeOptionalEnvValue(rawValue);
1011
+ if (!configuredPath || !looksLikeLegacyDbFilePath(configuredPath)) {
1012
+ return {
1013
+ path: configuredPath,
1014
+ rewrittenFrom: void 0
1015
+ };
1016
+ }
1017
+ const resolvedPath = resolve3(expandHomePrefix3(configuredPath));
1018
+ const postgresMarker = join3(resolvedPath, "PG_VERSION");
1019
+ if (await fileExists2(postgresMarker)) {
1020
+ return {
1021
+ path: resolvedPath,
1022
+ rewrittenFrom: void 0
1023
+ };
1024
+ }
1025
+ const normalizedPath = join3(dirname(resolvedPath), "postgres");
1026
+ return {
1027
+ path: normalizedPath,
1028
+ rewrittenFrom: configuredPath
1029
+ };
1030
+ }
873
1031
  async function removeEnvKeys(envPath, keys) {
874
1032
  if (keys.length === 0) {
875
1033
  return;
@@ -887,34 +1045,13 @@ function normalizeOptionalEnvValue(value) {
887
1045
  const normalized = value?.trim();
888
1046
  return normalized && normalized.length > 0 ? normalized : void 0;
889
1047
  }
890
- function deriveAvailableAgentProviders(checks) {
891
- const providers = [];
892
- for (const check of checks) {
893
- if (!check.ok) {
894
- continue;
895
- }
896
- if (check.label === "Codex runtime") {
897
- providers.push("codex");
898
- }
899
- if (check.label === "Claude Code runtime") {
900
- providers.push("claude_code");
901
- }
902
- if (check.label === "Gemini runtime") {
903
- providers.push("gemini_cli");
904
- }
905
- if (check.label === "OpenCode runtime") {
906
- providers.push("opencode");
907
- }
908
- }
909
- return Array.from(new Set(providers));
910
- }
911
1048
  function resolveDbPathSummary(configuredDbPath) {
912
1049
  if (configuredDbPath) {
913
1050
  return resolve3(expandHomePrefix3(configuredDbPath));
914
1051
  }
915
1052
  const home = process.env.BOPO_HOME?.trim() ? expandHomePrefix3(process.env.BOPO_HOME.trim()) : join3(homedir3(), ".bopodev");
916
1053
  const instanceId = process.env.BOPO_INSTANCE_ID?.trim() || "default";
917
- return resolve3(home, "instances", instanceId, "db", "bopodev.db");
1054
+ return resolve3(home, "instances", instanceId, "db", "postgres");
918
1055
  }
919
1056
  function expandHomePrefix3(value) {
920
1057
  if (value === "~") {
@@ -925,6 +1062,10 @@ function expandHomePrefix3(value) {
925
1062
  }
926
1063
  return value;
927
1064
  }
1065
+ function looksLikeLegacyDbFilePath(value) {
1066
+ const name = basename(value).toLowerCase();
1067
+ return name.endsWith(".db");
1068
+ }
928
1069
  function padSummaryValue(value) {
929
1070
  return `| ${value}`;
930
1071
  }
@@ -1056,6 +1197,96 @@ async function runStartCommand(cwd, options) {
1056
1197
  }
1057
1198
  }
1058
1199
 
1200
+ // src/commands/upgrade.ts
1201
+ import { readFile as readFile2 } from "fs/promises";
1202
+ import { homedir as homedir4 } from "os";
1203
+ import { join as join4, resolve as resolve4 } from "path";
1204
+ import dotenv2 from "dotenv";
1205
+ var UPGRADE_TIMEOUT_MS = 12e4;
1206
+ var UNSTICK_TIMEOUT_MS = 3e4;
1207
+ async function runUpgradeCommand(cwd, options) {
1208
+ const workspaceRoot = await resolveWorkspaceRootOrManaged(cwd);
1209
+ if (!workspaceRoot) {
1210
+ throw new Error("Could not find a Bopodev workspace root. Run `bopodev onboard` first.");
1211
+ }
1212
+ printBanner();
1213
+ printSection("bopodev upgrade");
1214
+ printLine(`Workspace: ${workspaceRoot}`);
1215
+ printDivider();
1216
+ const envPath = join4(workspaceRoot, ".env");
1217
+ const envValues = await readEnvValues2(envPath);
1218
+ const configuredDbPath = normalizeOptionalEnvValue2(envValues.BOPO_DB_PATH);
1219
+ const dbPathSummary = resolveDbPathSummary2(configuredDbPath);
1220
+ printCheck("warn", "Backup", `Back up local data before major upgrades if needed: ${dbPathSummary}`);
1221
+ const stopResult = await runCommandCapture("pnpm", ["unstick"], {
1222
+ cwd: workspaceRoot,
1223
+ timeoutMs: UNSTICK_TIMEOUT_MS
1224
+ });
1225
+ if (!stopResult.ok) {
1226
+ throw new Error(renderCommandFailure("pnpm unstick", stopResult.stderr, stopResult.stdout, stopResult.code));
1227
+ }
1228
+ printCheck("ok", "Runtime", "Stopped active local processes");
1229
+ const migrateResult = await runCommandCapture("pnpm", ["db:migrate"], {
1230
+ cwd: workspaceRoot,
1231
+ env: {
1232
+ ...process.env,
1233
+ ...configuredDbPath ? { BOPO_DB_PATH: configuredDbPath } : {}
1234
+ },
1235
+ timeoutMs: UPGRADE_TIMEOUT_MS
1236
+ });
1237
+ if (!migrateResult.ok) {
1238
+ throw new Error(renderCommandFailure("pnpm db:migrate", migrateResult.stderr, migrateResult.stdout, migrateResult.code));
1239
+ }
1240
+ printCheck("ok", "Database", "Migrations applied and schema verified");
1241
+ printSummaryCard([
1242
+ `Mode | local upgrade`,
1243
+ `DB | ${dbPathSummary}`,
1244
+ `Status | migrated`
1245
+ ]);
1246
+ if (options?.start === false) {
1247
+ printSection("Next commands");
1248
+ printLine("- Run: pnpm start:quiet");
1249
+ printLine("- Diagnose: pnpm doctor");
1250
+ return;
1251
+ }
1252
+ printLine("Restarting services after upgrade...");
1253
+ printDivider();
1254
+ await runStartCommand(workspaceRoot, { quiet: options?.quiet !== false });
1255
+ }
1256
+ async function readEnvValues2(path) {
1257
+ try {
1258
+ const content = await readFile2(path, "utf8");
1259
+ return dotenv2.parse(content);
1260
+ } catch {
1261
+ return {};
1262
+ }
1263
+ }
1264
+ function normalizeOptionalEnvValue2(value) {
1265
+ const normalized = value?.trim();
1266
+ return normalized && normalized.length > 0 ? normalized : void 0;
1267
+ }
1268
+ function resolveDbPathSummary2(configuredDbPath) {
1269
+ if (configuredDbPath) {
1270
+ return resolve4(expandHomePrefix4(configuredDbPath));
1271
+ }
1272
+ const home = process.env.BOPO_HOME?.trim() ? expandHomePrefix4(process.env.BOPO_HOME.trim()) : join4(homedir4(), ".bopodev");
1273
+ const instanceId = process.env.BOPO_INSTANCE_ID?.trim() || "default";
1274
+ return resolve4(home, "instances", instanceId, "db", "postgres");
1275
+ }
1276
+ function expandHomePrefix4(value) {
1277
+ if (value === "~") {
1278
+ return homedir4();
1279
+ }
1280
+ if (value.startsWith("~/")) {
1281
+ return resolve4(homedir4(), value.slice(2));
1282
+ }
1283
+ return value;
1284
+ }
1285
+ function renderCommandFailure(command, stderr, stdout, code) {
1286
+ const details = [stderr, stdout].filter((value) => value.trim().length > 0).join("\n").trim();
1287
+ return details.length > 0 ? details : `${command} failed with exit code ${String(code)}`;
1288
+ }
1289
+
1059
1290
  // src/index.ts
1060
1291
  var program = new Command();
1061
1292
  program.name("bopodev").description("Bopodev CLI");
@@ -1093,4 +1324,39 @@ program.command("doctor").description("Run local preflight checks").action(async
1093
1324
  process.exitCode = 1;
1094
1325
  }
1095
1326
  });
1327
+ var issueCommand = program.command("issue").description("Issue helpers for terminal workflows");
1328
+ issueCommand.command("shell-env <issueId>").description("Print shell exports for BOPODEV_* and cd to the project primary workspace cwd when set").option("--api-url <url>", "API base URL", process.env.BOPODEV_API_URL ?? "http://localhost:4020").option("--company-id <id>", "Company id (default: BOPODEV_COMPANY_ID)").option("--json", "Print JSON instead of shell exports", false).action(
1329
+ async (issueId, opts) => {
1330
+ try {
1331
+ const companyId = (opts.companyId ?? process.env.BOPODEV_COMPANY_ID ?? "").trim();
1332
+ if (!companyId) {
1333
+ cancel("Set --company-id or BOPODEV_COMPANY_ID.");
1334
+ process.exitCode = 1;
1335
+ return;
1336
+ }
1337
+ await runIssueShellEnvCommand(issueId, {
1338
+ apiUrl: opts.apiUrl,
1339
+ companyId,
1340
+ json: opts.json
1341
+ });
1342
+ } catch (error) {
1343
+ cancel(String(error));
1344
+ process.exitCode = 1;
1345
+ }
1346
+ }
1347
+ );
1348
+ program.command("upgrade").description("Stop local services, apply migrations, verify schema, and optionally restart").option("--no-start", "Only migrate and verify without restarting services").option("--full-logs", "Use full startup logs instead of quiet mode when restarting", false).action(async (options) => {
1349
+ try {
1350
+ await runUpgradeCommand(process.cwd(), {
1351
+ start: options.start,
1352
+ quiet: !options.fullLogs
1353
+ });
1354
+ if (!options.start) {
1355
+ outro("Upgrade finished.");
1356
+ }
1357
+ } catch (error) {
1358
+ cancel(String(error));
1359
+ process.exitCode = 1;
1360
+ }
1361
+ });
1096
1362
  void program.parseAsync(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bopodev",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  "typescript": "^5.9.2"
27
27
  },
28
28
  "scripts": {
29
- "cli:dev": "tsx src/index.ts",
29
+ "cli:dev": "node --import tsx src/index.ts",
30
30
  "build": "tsup src/index.ts --format esm --platform node --target node20 --out-dir dist",
31
31
  "lint": "tsc -p tsconfig.json --noEmit",
32
32
  "typecheck": "tsc -p tsconfig.json --noEmit"