bopodev 0.1.8 → 0.1.9

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 +268 -22
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -4,6 +4,11 @@
4
4
  import { Command } from "commander";
5
5
  import { cancel, outro } from "@clack/prompts";
6
6
 
7
+ // src/lib/checks.ts
8
+ import { access as access2, constants, mkdir } from "fs/promises";
9
+ import { homedir } from "os";
10
+ import { join as join2, resolve as resolve2 } from "path";
11
+
7
12
  // src/lib/process.ts
8
13
  import { spawn } from "child_process";
9
14
  import { access } from "fs/promises";
@@ -105,6 +110,28 @@ async function runDoctorChecks(options) {
105
110
  ok: codex.available && codex.exitCode === 0,
106
111
  details: codex.available && codex.exitCode === 0 ? `Command '${codexCommand}' is available` : codex.error ?? `Command '${codexCommand}' exited with ${String(codex.exitCode)}`
107
112
  });
113
+ const instanceRoot = resolveInstanceRoot();
114
+ const storageRoot = join2(instanceRoot, "data", "storage");
115
+ const workspaceRoot = join2(instanceRoot, "workspaces");
116
+ checks.push({
117
+ label: "Instance root writable",
118
+ ok: await ensureWritableDirectory(instanceRoot),
119
+ details: instanceRoot
120
+ });
121
+ checks.push({
122
+ label: "Workspace root writable",
123
+ ok: await ensureWritableDirectory(workspaceRoot),
124
+ details: workspaceRoot
125
+ });
126
+ checks.push({
127
+ label: "Storage root writable",
128
+ ok: await ensureWritableDirectory(storageRoot),
129
+ details: storageRoot
130
+ });
131
+ if (options?.workspaceRoot) {
132
+ const backfillCheck = await runWorkspaceBackfillDryRunCheck(options.workspaceRoot);
133
+ checks.push(backfillCheck);
134
+ }
108
135
  return checks;
109
136
  }
110
137
  async function checkRuntimeCommandHealth(command, cwd) {
@@ -115,6 +142,76 @@ async function checkRuntimeCommandHealth(command, cwd) {
115
142
  error: result.ok ? void 0 : result.stderr || `Command '${command}' is not available`
116
143
  };
117
144
  }
145
+ async function ensureWritableDirectory(path) {
146
+ try {
147
+ await mkdir(path, { recursive: true });
148
+ await access2(path, constants.W_OK);
149
+ return true;
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+ function resolveInstanceRoot() {
155
+ const explicit = process.env.BOPO_INSTANCE_ROOT?.trim();
156
+ if (explicit) {
157
+ return resolve2(expandHomePrefix(explicit));
158
+ }
159
+ const home = process.env.BOPO_HOME?.trim() ? expandHomePrefix(process.env.BOPO_HOME.trim()) : join2(homedir(), ".bopodev");
160
+ const instanceId = process.env.BOPO_INSTANCE_ID?.trim() || "default";
161
+ return resolve2(home, "instances", instanceId);
162
+ }
163
+ function expandHomePrefix(value) {
164
+ if (value === "~") {
165
+ return homedir();
166
+ }
167
+ if (value.startsWith("~/")) {
168
+ return resolve2(homedir(), value.slice(2));
169
+ }
170
+ return value;
171
+ }
172
+ async function runWorkspaceBackfillDryRunCheck(workspaceRoot) {
173
+ const result = await runCommandCapture("pnpm", ["--filter", "bopodev-api", "workspaces:backfill"], {
174
+ cwd: workspaceRoot,
175
+ env: {
176
+ ...process.env,
177
+ BOPO_BACKFILL_DRY_RUN: "1"
178
+ }
179
+ });
180
+ if (!result.ok) {
181
+ return {
182
+ label: "Project workspace coverage",
183
+ ok: false,
184
+ details: result.stderr.trim() || "Failed to run workspace coverage check."
185
+ };
186
+ }
187
+ const lines = result.stdout.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
188
+ const lastLine = lines[lines.length - 1];
189
+ if (!lastLine) {
190
+ return {
191
+ label: "Project workspace coverage",
192
+ ok: false,
193
+ details: "Workspace coverage check produced no output."
194
+ };
195
+ }
196
+ try {
197
+ const parsed = JSON.parse(lastLine);
198
+ const missing = Number(parsed.missingWorkspaceLocalPath ?? 0);
199
+ const relative = Number(parsed.relativeWorkspaceLocalPath ?? 0);
200
+ const scanned = Number(parsed.scannedProjects ?? 0);
201
+ const ok = missing === 0 && relative === 0;
202
+ return {
203
+ label: "Project workspace coverage",
204
+ ok,
205
+ details: ok ? `${scanned} projects scanned; all have absolute workspace paths` : `${scanned} projects scanned; ${missing} missing and ${relative} relative workspace path(s)`
206
+ };
207
+ } catch {
208
+ return {
209
+ label: "Project workspace coverage",
210
+ ok: false,
211
+ details: "Workspace coverage check returned invalid JSON."
212
+ };
213
+ }
214
+ }
118
215
 
119
216
  // src/lib/ui.ts
120
217
  import color from "picocolors";
@@ -146,8 +243,8 @@ function printSection(title) {
146
243
  process.stdout.write(`${color.bold(title)}
147
244
  `);
148
245
  }
149
- function printLine(text) {
150
- process.stdout.write(`${text}
246
+ function printLine(text2) {
247
+ process.stdout.write(`${text2}
151
248
  `);
152
249
  }
153
250
  function printDivider() {
@@ -193,10 +290,14 @@ async function runDoctorCommand(cwd) {
193
290
  }
194
291
 
195
292
  // src/commands/onboard.ts
196
- import { access as access2, copyFile } from "fs/promises";
197
- import { join as join2 } from "path";
198
- import { confirm, isCancel, log, spinner } from "@clack/prompts";
293
+ import { access as access3, copyFile, readFile, writeFile } from "fs/promises";
294
+ import { join as join3 } from "path";
295
+ import { confirm, isCancel, log, select, spinner, text } from "@clack/prompts";
199
296
  import dotenv from "dotenv";
297
+ var DEFAULT_COMPANY_NAME_ENV = "BOPO_DEFAULT_COMPANY_NAME";
298
+ var DEFAULT_COMPANY_ID_ENV = "BOPO_DEFAULT_COMPANY_ID";
299
+ var DEFAULT_PUBLIC_COMPANY_ID_ENV = "NEXT_PUBLIC_DEFAULT_COMPANY_ID";
300
+ var DEFAULT_AGENT_PROVIDER_ENV = "BOPO_DEFAULT_AGENT_PROVIDER";
200
301
  var defaultDeps = {
201
302
  installDependencies: async (workspaceRoot) => {
202
303
  const code = await runCommandStreaming("pnpm", ["install"], { cwd: workspaceRoot });
@@ -217,7 +318,54 @@ var defaultDeps = {
217
318
  throw new Error(`db:init failed with exit code ${String(code)}`);
218
319
  }
219
320
  },
220
- startServices: (workspaceRoot) => runCommandStreaming("pnpm", ["start:quiet"], { cwd: workspaceRoot })
321
+ seedOnboardingDatabase: async (workspaceRoot, input) => {
322
+ const result = await runCommandCapture("pnpm", ["--filter", "bopodev-api", "onboard:seed"], {
323
+ cwd: workspaceRoot,
324
+ env: {
325
+ ...process.env,
326
+ [DEFAULT_COMPANY_NAME_ENV]: input.companyName,
327
+ [DEFAULT_AGENT_PROVIDER_ENV]: input.agentProvider,
328
+ ...input.companyId ? { [DEFAULT_COMPANY_ID_ENV]: input.companyId } : {},
329
+ ...input.dbPath ? { BOPO_DB_PATH: input.dbPath } : {}
330
+ }
331
+ });
332
+ if (!result.ok) {
333
+ const details = [result.stderr, result.stdout].filter((value) => value.trim().length > 0).join("\n").trim();
334
+ throw new Error(details.length > 0 ? details : `onboard:seed failed with exit code ${String(result.code)}`);
335
+ }
336
+ return parseSeedResult(result.stdout);
337
+ },
338
+ startServices: (workspaceRoot) => runCommandStreaming("pnpm", ["start:quiet"], { cwd: workspaceRoot }),
339
+ promptForCompanyName: async () => {
340
+ const answer = await text({
341
+ message: "Default company name",
342
+ placeholder: "Acme AI",
343
+ validate: (value) => value.trim().length > 0 ? void 0 : "Company name is required."
344
+ });
345
+ if (isCancel(answer)) {
346
+ throw new Error("Onboarding cancelled.");
347
+ }
348
+ return answer.trim();
349
+ },
350
+ promptForAgentProvider: async () => {
351
+ const answer = await select({
352
+ message: "Primary agent framework",
353
+ initialValue: "codex",
354
+ options: [
355
+ { value: "codex", label: "Codex" },
356
+ { value: "claude_code", label: "Claude Code" },
357
+ { value: "shell", label: "Shell Runtime" }
358
+ ]
359
+ });
360
+ if (isCancel(answer)) {
361
+ throw new Error("Onboarding cancelled.");
362
+ }
363
+ const provider = parseAgentProvider(answer);
364
+ if (!provider) {
365
+ throw new Error("Invalid primary agent framework selected.");
366
+ }
367
+ return provider;
368
+ }
221
369
  };
222
370
  async function runOnboardFlow(options, deps = defaultDeps) {
223
371
  const workspaceRoot = await resolveWorkspaceRoot(options.cwd);
@@ -237,7 +385,7 @@ async function runOnboardFlow(options, deps = defaultDeps) {
237
385
  throw new Error("Onboarding cancelled.");
238
386
  }
239
387
  } else {
240
- log.step("`--yes` enabled: using non-interactive defaults.");
388
+ log.step("`--yes` enabled: using defaults for optional onboarding steps.");
241
389
  }
242
390
  const shouldInstall = options.forceInstall || !await hasExistingInstall(workspaceRoot);
243
391
  if (shouldInstall) {
@@ -252,11 +400,56 @@ async function runOnboardFlow(options, deps = defaultDeps) {
252
400
  envSpin.start("Ensuring .env exists");
253
401
  const envCreated = await ensureEnvFile(workspaceRoot);
254
402
  envSpin.stop(envCreated ? "Created .env from .env.example" : ".env already present");
255
- dotenv.config({ path: join2(workspaceRoot, ".env") });
403
+ const envPath = join3(workspaceRoot, ".env");
404
+ dotenv.config({ path: envPath });
405
+ const envValues = await readEnvValues(envPath);
406
+ let companyName = envValues[DEFAULT_COMPANY_NAME_ENV]?.trim() ?? "";
407
+ if (companyName.length > 0) {
408
+ printCheck("ok", "Default company", companyName);
409
+ } else {
410
+ companyName = await deps.promptForCompanyName();
411
+ await updateEnvFile(envPath, { [DEFAULT_COMPANY_NAME_ENV]: companyName });
412
+ process.env[DEFAULT_COMPANY_NAME_ENV] = companyName;
413
+ printCheck("ok", "Default company", companyName);
414
+ }
415
+ let agentProvider = parseAgentProvider(envValues[DEFAULT_AGENT_PROVIDER_ENV]);
416
+ if (agentProvider) {
417
+ printCheck("ok", "Primary agent framework", formatAgentProvider(agentProvider));
418
+ } else {
419
+ agentProvider = await deps.promptForAgentProvider();
420
+ await updateEnvFile(envPath, { [DEFAULT_AGENT_PROVIDER_ENV]: agentProvider });
421
+ process.env[DEFAULT_AGENT_PROVIDER_ENV] = agentProvider;
422
+ printCheck("ok", "Primary agent framework", formatAgentProvider(agentProvider));
423
+ }
256
424
  const dbSpin = spinner();
257
425
  dbSpin.start("Initializing local database");
258
426
  await deps.initializeDatabase(workspaceRoot, process.env.BOPO_DB_PATH);
259
427
  dbSpin.stop("Database initialized");
428
+ const seedSpin = spinner();
429
+ seedSpin.start("Ensuring default company and CEO agent");
430
+ const seedResult = await deps.seedOnboardingDatabase(workspaceRoot, {
431
+ dbPath: process.env.BOPO_DB_PATH,
432
+ companyName,
433
+ companyId: envValues[DEFAULT_COMPANY_ID_ENV]?.trim() || void 0,
434
+ agentProvider
435
+ });
436
+ seedSpin.stop("Default company and CEO ready");
437
+ await updateEnvFile(envPath, {
438
+ [DEFAULT_COMPANY_NAME_ENV]: seedResult.companyName,
439
+ [DEFAULT_COMPANY_ID_ENV]: seedResult.companyId,
440
+ [DEFAULT_PUBLIC_COMPANY_ID_ENV]: seedResult.companyId,
441
+ [DEFAULT_AGENT_PROVIDER_ENV]: seedResult.ceoProviderType
442
+ });
443
+ process.env[DEFAULT_COMPANY_NAME_ENV] = seedResult.companyName;
444
+ process.env[DEFAULT_COMPANY_ID_ENV] = seedResult.companyId;
445
+ process.env[DEFAULT_PUBLIC_COMPANY_ID_ENV] = seedResult.companyId;
446
+ process.env[DEFAULT_AGENT_PROVIDER_ENV] = seedResult.ceoProviderType;
447
+ printCheck("ok", "Configured company", `${seedResult.companyName}${seedResult.companyCreated ? " (created)" : ""}`);
448
+ printCheck(
449
+ "ok",
450
+ "CEO agent",
451
+ `${seedResult.ceoCreated ? "Created CEO" : seedResult.ceoMigrated ? "Migrated existing CEO" : "CEO already present"} (${formatAgentProvider(seedResult.ceoProviderType)})`
452
+ );
260
453
  const doctorSpin = spinner();
261
454
  doctorSpin.start("Running doctor checks");
262
455
  const checks = await deps.runDoctor(workspaceRoot);
@@ -295,8 +488,8 @@ async function runOnboardFlow(options, deps = defaultDeps) {
295
488
  };
296
489
  }
297
490
  async function ensureEnvFile(workspaceRoot) {
298
- const envPath = join2(workspaceRoot, ".env");
299
- const envExamplePath = join2(workspaceRoot, ".env.example");
491
+ const envPath = join3(workspaceRoot, ".env");
492
+ const envExamplePath = join3(workspaceRoot, ".env.example");
300
493
  const envExists = await fileExists2(envPath);
301
494
  if (envExists) {
302
495
  return false;
@@ -308,34 +501,87 @@ async function ensureEnvFile(workspaceRoot) {
308
501
  await copyFile(envExamplePath, envPath);
309
502
  return true;
310
503
  }
504
+ async function readEnvValues(envPath) {
505
+ const envContent = await readFile(envPath, "utf8");
506
+ return dotenv.parse(envContent);
507
+ }
508
+ async function updateEnvFile(envPath, updates) {
509
+ const existingContent = await readFile(envPath, "utf8");
510
+ const lines = existingContent.split(/\r?\n/);
511
+ const nextLines = [...lines];
512
+ for (const [key, value] of Object.entries(updates)) {
513
+ const serialized = `${key}=${serializeEnvValue(value)}`;
514
+ const existingIndex = nextLines.findIndex((line) => line.startsWith(`${key}=`));
515
+ if (existingIndex >= 0) {
516
+ nextLines[existingIndex] = serialized;
517
+ } else {
518
+ const insertionIndex = nextLines.length > 0 && nextLines[nextLines.length - 1] === "" ? nextLines.length - 1 : nextLines.length;
519
+ nextLines.splice(insertionIndex, 0, serialized);
520
+ }
521
+ }
522
+ const nextContent = nextLines.join("\n");
523
+ await writeFile(envPath, nextContent.endsWith("\n") ? nextContent : `${nextContent}
524
+ `, "utf8");
525
+ }
526
+ function serializeEnvValue(value) {
527
+ return /[\s#"'`]/.test(value) ? JSON.stringify(value) : value;
528
+ }
529
+ function parseSeedResult(stdout) {
530
+ const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
531
+ const lastLine = lines[lines.length - 1];
532
+ if (!lastLine) {
533
+ throw new Error("onboard:seed did not return a result.");
534
+ }
535
+ const parsed = JSON.parse(lastLine);
536
+ if (typeof parsed.companyId !== "string" || typeof parsed.companyName !== "string" || typeof parsed.companyCreated !== "boolean" || typeof parsed.ceoCreated !== "boolean" || typeof parsed.ceoMigrated !== "boolean" || !parseAgentProvider(parsed.ceoProviderType)) {
537
+ throw new Error("onboard:seed returned an invalid result.");
538
+ }
539
+ return {
540
+ companyId: parsed.companyId,
541
+ companyName: parsed.companyName,
542
+ companyCreated: parsed.companyCreated,
543
+ ceoCreated: parsed.ceoCreated,
544
+ ceoProviderType: parseAgentProvider(parsed.ceoProviderType) ?? "shell",
545
+ ceoMigrated: parsed.ceoMigrated
546
+ };
547
+ }
548
+ function parseAgentProvider(value) {
549
+ if (value === "codex" || value === "claude_code" || value === "shell") {
550
+ return value;
551
+ }
552
+ return null;
553
+ }
554
+ function formatAgentProvider(provider) {
555
+ if (provider === "codex") {
556
+ return "Codex";
557
+ }
558
+ if (provider === "claude_code") {
559
+ return "Claude Code";
560
+ }
561
+ return "Shell Runtime";
562
+ }
311
563
  async function fileExists2(path) {
312
564
  try {
313
- await access2(path);
565
+ await access3(path);
314
566
  return true;
315
567
  } catch {
316
568
  return false;
317
569
  }
318
570
  }
319
571
  async function hasExistingInstall(workspaceRoot) {
320
- const pnpmModulesFile = join2(workspaceRoot, "node_modules", ".modules.yaml");
321
- const packageLockfile = join2(workspaceRoot, "pnpm-lock.yaml");
572
+ const pnpmModulesFile = join3(workspaceRoot, "node_modules", ".modules.yaml");
573
+ const packageLockfile = join3(workspaceRoot, "pnpm-lock.yaml");
322
574
  return await fileExists2(pnpmModulesFile) && await fileExists2(packageLockfile);
323
575
  }
324
576
 
325
577
  // src/commands/start.ts
326
- var defaultDeps2 = {
327
- resolveWorkspaceRoot,
328
- runCommandStreaming,
329
- printBanner
330
- };
331
- async function runStartCommand(cwd, options, deps = defaultDeps2) {
332
- const workspaceRoot = await deps.resolveWorkspaceRoot(cwd);
578
+ async function runStartCommand(cwd, options) {
579
+ const workspaceRoot = await resolveWorkspaceRoot(cwd);
333
580
  if (!workspaceRoot) {
334
581
  throw new Error("Could not find a pnpm workspace root. Run this command from inside the Bopodev repo.");
335
582
  }
336
- deps.printBanner();
337
583
  const script = options?.quiet === false ? "start" : "start:quiet";
338
- const code = await deps.runCommandStreaming("pnpm", [script], { cwd: workspaceRoot });
584
+ const code = await runCommandStreaming("pnpm", [script], { cwd: workspaceRoot });
339
585
  if (code !== 0) {
340
586
  throw new Error(`pnpm ${script} failed with exit code ${String(code)}`);
341
587
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bopodev",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "bin": {