bopodev 0.1.28 → 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 +166 -26
  2. package/package.json +1 -1
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,6 +491,78 @@ 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";
@@ -628,11 +745,19 @@ async function runOnboardFlow(options, deps = defaultDeps) {
628
745
  } else {
629
746
  printCheck("ok", "Dependencies", "Already installed");
630
747
  }
631
- const envPath = join3(workspaceRoot, ".env");
632
- const preEnvValues = await fileExists2(envPath) ? await readEnvValues(envPath) : {};
633
748
  let checks = [];
634
749
  let passed = 0;
635
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
+ }
759
+ const envPath = join3(workspaceRoot, ".env");
760
+ const preEnvValues = await fileExists2(envPath) ? await readEnvValues(envPath) : {};
636
761
  let companyName = preEnvValues[DEFAULT_COMPANY_NAME_ENV]?.trim() ?? "";
637
762
  if (companyName.length > 0) {
638
763
  printCheck("ok", "Default company", companyName);
@@ -780,12 +905,6 @@ async function runOnboardFlow(options, deps = defaultDeps) {
780
905
  );
781
906
  }
782
907
  if (!options.start) {
783
- const doctorSpin = spinner();
784
- doctorSpin.start("Running doctor checks");
785
- checks = await deps.runDoctor(workspaceRoot);
786
- doctorSpin.stop("Doctor checks complete");
787
- passed = checks.filter((check) => check.ok).length;
788
- warnings = checks.length - passed;
789
908
  printCheck("ok", "Doctor", "checks complete");
790
909
  printCheck("ok", "Doctor summary", `${passed} passed, ${warnings} warning${warnings === 1 ? "" : "s"}`);
791
910
  if (warnings === 0) {
@@ -1205,6 +1324,27 @@ program.command("doctor").description("Run local preflight checks").action(async
1205
1324
  process.exitCode = 1;
1206
1325
  }
1207
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
+ );
1208
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) => {
1209
1349
  try {
1210
1350
  await runUpgradeCommand(process.cwd(), {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bopodev",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "bin": {