bosun 0.26.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/.env.example +918 -0
  2. package/LICENSE +190 -0
  3. package/README.md +98 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/bosun.config.example.json +115 -0
  13. package/bosun.schema.json +465 -0
  14. package/claude-shell.mjs +708 -0
  15. package/cli.mjs +1028 -0
  16. package/codex-config.mjs +1274 -0
  17. package/codex-model-profiles.mjs +135 -0
  18. package/codex-shell.mjs +762 -0
  19. package/compat.mjs +286 -0
  20. package/config-doctor.mjs +613 -0
  21. package/config.mjs +1724 -0
  22. package/conflict-resolver.mjs +248 -0
  23. package/container-runner.mjs +450 -0
  24. package/copilot-shell.mjs +827 -0
  25. package/daemon-restart-policy.mjs +56 -0
  26. package/diff-stats.mjs +282 -0
  27. package/error-detector.mjs +829 -0
  28. package/fetch-runtime.mjs +34 -0
  29. package/fleet-coordinator.mjs +838 -0
  30. package/get-telegram-chat-id.mjs +71 -0
  31. package/git-safety.mjs +170 -0
  32. package/github-reconciler.mjs +403 -0
  33. package/hook-profiles.mjs +651 -0
  34. package/kanban-adapter.mjs +4491 -0
  35. package/lib/logger.mjs +645 -0
  36. package/maintenance.mjs +828 -0
  37. package/merge-strategy.mjs +1171 -0
  38. package/monitor.mjs +12237 -0
  39. package/package.json +209 -0
  40. package/postinstall.mjs +187 -0
  41. package/pr-cleanup-daemon.mjs +978 -0
  42. package/preflight.mjs +408 -0
  43. package/prepublish-check.mjs +90 -0
  44. package/presence.mjs +328 -0
  45. package/primary-agent.mjs +290 -0
  46. package/publish.mjs +241 -0
  47. package/repo-root.mjs +29 -0
  48. package/restart-controller.mjs +100 -0
  49. package/review-agent.mjs +557 -0
  50. package/rotate-agent-logs.sh +133 -0
  51. package/sdk-conflict-resolver.mjs +973 -0
  52. package/session-tracker.mjs +880 -0
  53. package/setup.mjs +3946 -0
  54. package/shared-knowledge.mjs +410 -0
  55. package/shared-state-manager.mjs +841 -0
  56. package/shared-workspace-cli.mjs +199 -0
  57. package/shared-workspace-registry.mjs +537 -0
  58. package/shared-workspaces.json +18 -0
  59. package/startup-service.mjs +1070 -0
  60. package/sync-engine.mjs +1063 -0
  61. package/task-archiver.mjs +801 -0
  62. package/task-assessment.mjs +550 -0
  63. package/task-claims.mjs +924 -0
  64. package/task-complexity.mjs +581 -0
  65. package/task-executor.mjs +5111 -0
  66. package/task-store.mjs +753 -0
  67. package/telegram-bot.mjs +9683 -0
  68. package/telegram-sentinel.mjs +2010 -0
  69. package/ui/app.js +867 -0
  70. package/ui/app.legacy.js +1464 -0
  71. package/ui/app.monolith.js +2488 -0
  72. package/ui/components/charts.js +226 -0
  73. package/ui/components/chat-view.js +567 -0
  74. package/ui/components/command-palette.js +587 -0
  75. package/ui/components/diff-viewer.js +190 -0
  76. package/ui/components/forms.js +357 -0
  77. package/ui/components/kanban-board.js +451 -0
  78. package/ui/components/session-list.js +305 -0
  79. package/ui/components/shared.js +525 -0
  80. package/ui/demo.html +640 -0
  81. package/ui/index.html +70 -0
  82. package/ui/modules/api.js +297 -0
  83. package/ui/modules/icons.js +461 -0
  84. package/ui/modules/router.js +81 -0
  85. package/ui/modules/settings-schema.js +261 -0
  86. package/ui/modules/state.js +679 -0
  87. package/ui/modules/telegram.js +331 -0
  88. package/ui/modules/utils.js +270 -0
  89. package/ui/styles/animations.css +140 -0
  90. package/ui/styles/base.css +98 -0
  91. package/ui/styles/components.css +2032 -0
  92. package/ui/styles/kanban.css +286 -0
  93. package/ui/styles/layout.css +810 -0
  94. package/ui/styles/sessions.css +841 -0
  95. package/ui/styles/variables.css +188 -0
  96. package/ui/styles.css +141 -0
  97. package/ui/styles.monolith.css +1046 -0
  98. package/ui/tabs/agents.js +1417 -0
  99. package/ui/tabs/chat.js +75 -0
  100. package/ui/tabs/control.js +892 -0
  101. package/ui/tabs/dashboard.js +515 -0
  102. package/ui/tabs/infra.js +537 -0
  103. package/ui/tabs/logs.js +783 -0
  104. package/ui/tabs/settings.js +1509 -0
  105. package/ui/tabs/tasks.js +1385 -0
  106. package/ui-server.mjs +4084 -0
  107. package/update-check.mjs +471 -0
  108. package/utils.mjs +172 -0
  109. package/ve-kanban.mjs +654 -0
  110. package/ve-kanban.ps1 +1365 -0
  111. package/ve-kanban.sh +18 -0
  112. package/ve-orchestrator.mjs +340 -0
  113. package/ve-orchestrator.ps1 +6546 -0
  114. package/ve-orchestrator.sh +18 -0
  115. package/vibe-kanban-wrapper.mjs +41 -0
  116. package/vk-error-resolver.mjs +470 -0
  117. package/vk-log-stream.mjs +914 -0
  118. package/whatsapp-channel.mjs +520 -0
  119. package/workspace-monitor.mjs +581 -0
  120. package/workspace-reaper.mjs +405 -0
  121. package/workspace-registry.mjs +238 -0
  122. package/worktree-manager.mjs +1266 -0
package/setup.mjs ADDED
@@ -0,0 +1,3946 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * bosun — Setup Wizard
5
+ *
6
+ * Interactive CLI that configures bosun for a new or existing repository.
7
+ * Handles:
8
+ * - Prerequisites validation
9
+ * - Environment file generation (.env + bosun.config.json)
10
+ * - Executor/model configuration (N executors with weights & failover)
11
+ * - Multi-repo setup (separate backend/frontend repos)
12
+ * - Vibe-Kanban auto-wiring (project, repos, executor profiles, agent appends)
13
+ * - Prompt template scaffolding (.bosun/agents/*.md)
14
+ * - First-run auto-detection (launches automatically on virgin installs)
15
+ *
16
+ * Usage:
17
+ * bosun --setup # interactive wizard
18
+ * bosun-setup # same (bin alias)
19
+ * npx @virtengine/bosun setup
20
+ * node setup.mjs --non-interactive # use env vars, skip prompts
21
+ */
22
+
23
+ import { createInterface } from "node:readline";
24
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
25
+ import { resolve, dirname, basename, relative, isAbsolute } from "node:path";
26
+ import { execSync } from "node:child_process";
27
+ import { execFileSync } from "node:child_process";
28
+ import { fileURLToPath } from "node:url";
29
+ import {
30
+ readCodexConfig,
31
+ getConfigPath,
32
+ hasVibeKanbanMcp,
33
+ auditStreamTimeouts,
34
+ ensureCodexConfig,
35
+ printConfigSummary,
36
+ } from "./codex-config.mjs";
37
+ import {
38
+ ensureAgentPromptWorkspace,
39
+ getAgentPromptDefinitions,
40
+ PROMPT_WORKSPACE_DIR,
41
+ } from "./agent-prompts.mjs";
42
+ import {
43
+ buildHookScaffoldOptionsFromEnv,
44
+ normalizeHookTargets,
45
+ scaffoldAgentHookFiles,
46
+ } from "./hook-profiles.mjs";
47
+ import { detectLegacySetup, applyAllCompatibility } from "./compat.mjs";
48
+
49
+ const __dirname = dirname(fileURLToPath(import.meta.url));
50
+
51
+ const isNonInteractive =
52
+ process.argv.includes("--non-interactive") || process.argv.includes("-y");
53
+
54
+ // ── Zero-dependency terminal styling (replaces chalk) ────────────────────────
55
+ const isTTY = process.stdout.isTTY;
56
+ const chalk = {
57
+ bold: (s) => (isTTY ? `\x1b[1m${s}\x1b[22m` : s),
58
+ dim: (s) => (isTTY ? `\x1b[2m${s}\x1b[22m` : s),
59
+ cyan: (s) => (isTTY ? `\x1b[36m${s}\x1b[39m` : s),
60
+ green: (s) => (isTTY ? `\x1b[32m${s}\x1b[39m` : s),
61
+ yellow: (s) => (isTTY ? `\x1b[33m${s}\x1b[39m` : s),
62
+ red: (s) => (isTTY ? `\x1b[31m${s}\x1b[39m` : s),
63
+ };
64
+
65
+ // ── Helpers ──────────────────────────────────────────────────────────────────
66
+
67
+ function getVersion() {
68
+ try {
69
+ return JSON.parse(readFileSync(resolve(__dirname, "package.json"), "utf8"))
70
+ .version;
71
+ } catch {
72
+ return "0.0.0";
73
+ }
74
+ }
75
+
76
+ function hasSetupMarkers(dir) {
77
+ const markers = [
78
+ ".env",
79
+ "bosun.config.json",
80
+ ".bosun.json",
81
+ "bosun.json",
82
+ ];
83
+ return markers.some((name) => existsSync(resolve(dir, name)));
84
+ }
85
+
86
+ function hasConfigFiles(dir) {
87
+ const markers = [
88
+ "bosun.config.json",
89
+ ".bosun.json",
90
+ "bosun.json",
91
+ ];
92
+ return markers.some((name) => existsSync(resolve(dir, name)));
93
+ }
94
+
95
+ function isPathInside(parent, child) {
96
+ const rel = relative(parent, child);
97
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
98
+ }
99
+
100
+ function resolveConfigDir(repoRoot) {
101
+ const explicit = process.env.BOSUN_DIR;
102
+ if (explicit) return resolve(explicit);
103
+ const repoPath = resolve(repoRoot || process.cwd());
104
+ const packageDir = resolve(__dirname);
105
+ if (isPathInside(repoPath, packageDir) || hasConfigFiles(packageDir)) {
106
+ return packageDir;
107
+ }
108
+ const baseDir =
109
+ process.env.APPDATA ||
110
+ process.env.LOCALAPPDATA ||
111
+ process.env.HOME ||
112
+ process.env.USERPROFILE ||
113
+ process.cwd();
114
+ return resolve(baseDir, "bosun");
115
+ }
116
+
117
+ function printBanner() {
118
+ const ver = getVersion();
119
+ const title = `Codex Monitor — Setup Wizard v${ver}`;
120
+ const pad = Math.max(0, 57 - title.length);
121
+ const left = Math.floor(pad / 2);
122
+ const right = pad - left;
123
+ console.log("");
124
+ console.log(
125
+ " ╔═══════════════════════════════════════════════════════════════╗",
126
+ );
127
+ console.log(` ║${" ".repeat(left + 3)}${title}${" ".repeat(right + 3)}║`);
128
+ console.log(
129
+ " ╚═══════════════════════════════════════════════════════════════╝",
130
+ );
131
+ console.log("");
132
+ console.log(
133
+ chalk.dim(" This wizard will configure bosun for your project."),
134
+ );
135
+ console.log(
136
+ chalk.dim(" Press Enter to accept defaults shown in [brackets]."),
137
+ );
138
+ console.log("");
139
+ }
140
+
141
+ function heading(text) {
142
+ const line = "\u2500".repeat(Math.max(0, 59 - text.length));
143
+ console.log(`\n ${chalk.bold(text)} ${chalk.dim(line)}\n`);
144
+ }
145
+
146
+ function check(label, ok, hint) {
147
+ const icon = ok ? "✅" : "❌";
148
+ console.log(` ${icon} ${label}`);
149
+ if (!ok && hint) console.log(` → ${hint}`);
150
+ return ok;
151
+ }
152
+
153
+ function info(msg) {
154
+ console.log(` ℹ️ ${msg}`);
155
+ }
156
+
157
+ function success(msg) {
158
+ console.log(` ✅ ${msg}`);
159
+ }
160
+
161
+ function warn(msg) {
162
+ console.log(` ⚠️ ${msg}`);
163
+ }
164
+
165
+ function commandExists(cmd) {
166
+ try {
167
+ execSync(`${process.platform === "win32" ? "where" : "which"} ${cmd}`, {
168
+ stdio: "ignore",
169
+ });
170
+ return true;
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+
176
+ function normalizeBaseUrl(raw) {
177
+ const trimmed = String(raw || "").trim();
178
+ if (!trimmed) return "";
179
+ if (/^https?:\/\//i.test(trimmed)) {
180
+ return trimmed.replace(/\/+$/, "");
181
+ }
182
+ return `https://${trimmed.replace(/\/+$/, "")}`;
183
+ }
184
+
185
+ function openUrlInBrowser(url) {
186
+ const target = String(url || "").trim();
187
+ if (!target) return false;
188
+ const escaped = target.replace(/"/g, '\\"');
189
+ try {
190
+ if (process.platform === "darwin") {
191
+ execSync(`open "${escaped}"`);
192
+ return true;
193
+ }
194
+ if (process.platform === "win32") {
195
+ execSync(`cmd /c start "" "${escaped}"`);
196
+ return true;
197
+ }
198
+ if (commandExists("xdg-open")) {
199
+ execSync(`xdg-open "${escaped}"`);
200
+ return true;
201
+ }
202
+ if (commandExists("gio")) {
203
+ execSync(`gio open "${escaped}"`);
204
+ return true;
205
+ }
206
+ } catch {
207
+ return false;
208
+ }
209
+ return false;
210
+ }
211
+
212
+ function buildJiraAuthHeaders(email, token) {
213
+ const credentials = Buffer.from(`${email}:${token}`).toString("base64");
214
+ return {
215
+ Authorization: `Basic ${credentials}`,
216
+ Accept: "application/json",
217
+ "Content-Type": "application/json",
218
+ };
219
+ }
220
+
221
+ async function jiraRequest({ baseUrl, email, token, path, method = "GET", body }) {
222
+ if (!baseUrl || !email || !token) {
223
+ throw new Error("Jira credentials are missing");
224
+ }
225
+ const url = `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
226
+ const response = await fetch(url, {
227
+ method,
228
+ headers: buildJiraAuthHeaders(email, token),
229
+ body: body == null ? undefined : JSON.stringify(body),
230
+ });
231
+ if (!response || typeof response.status !== "number") {
232
+ throw new Error(`Jira API ${method} ${path} failed: no HTTP response`);
233
+ }
234
+ if (response.status === 204) return null;
235
+ const contentType = String(response.headers.get("content-type") || "");
236
+ let payload = null;
237
+ if (contentType.includes("application/json")) {
238
+ payload = await response.json().catch(() => null);
239
+ } else {
240
+ payload = await response.text().catch(() => "");
241
+ }
242
+ if (!response.ok) {
243
+ const message =
244
+ payload?.errorMessages?.join("; ") ||
245
+ (payload?.errors ? Object.values(payload.errors || {}).join("; ") : "");
246
+ throw new Error(
247
+ `Jira API ${method} ${path} failed (${response.status}): ${message || response.statusText || "Unknown error"}`,
248
+ );
249
+ }
250
+ return payload;
251
+ }
252
+
253
+ async function listJiraProjects({ baseUrl, email, token }) {
254
+ const projects = [];
255
+ let startAt = 0;
256
+ while (true) {
257
+ const page = await jiraRequest({
258
+ baseUrl,
259
+ email,
260
+ token,
261
+ path: `/rest/api/3/project/search?startAt=${startAt}&maxResults=50&orderBy=name`,
262
+ });
263
+ const values = Array.isArray(page?.values) ? page.values : [];
264
+ projects.push(...values);
265
+ if (values.length === 0 || page?.isLast) break;
266
+ startAt += values.length;
267
+ }
268
+ return projects.map((project) => ({
269
+ key: String(project.key || project.id || "").trim(),
270
+ name: project.name || project.key || "Unnamed Jira Project",
271
+ id: String(project.id || project.key || ""),
272
+ }));
273
+ }
274
+
275
+ async function listJiraIssueTypes({ baseUrl, email, token }) {
276
+ const data = await jiraRequest({
277
+ baseUrl,
278
+ email,
279
+ token,
280
+ path: "/rest/api/3/issuetype",
281
+ });
282
+ return (Array.isArray(data) ? data : [])
283
+ .map((entry) => ({
284
+ id: String(entry?.id || ""),
285
+ name: String(entry?.name || "").trim(),
286
+ subtask: Boolean(entry?.subtask),
287
+ }))
288
+ .filter((entry) => entry.name);
289
+ }
290
+
291
+ async function listJiraFields({ baseUrl, email, token }) {
292
+ const data = await jiraRequest({
293
+ baseUrl,
294
+ email,
295
+ token,
296
+ path: "/rest/api/3/field",
297
+ });
298
+ return (Array.isArray(data) ? data : [])
299
+ .map((field) => ({
300
+ id: String(field?.id || "").trim(),
301
+ name: String(field?.name || "").trim(),
302
+ custom: String(field?.id || "").startsWith("customfield_"),
303
+ }))
304
+ .filter((field) => field.id && field.name);
305
+ }
306
+
307
+ async function searchJiraUsers({ baseUrl, email, token, query }) {
308
+ const data = await jiraRequest({
309
+ baseUrl,
310
+ email,
311
+ token,
312
+ path: `/rest/api/3/user/search?maxResults=20&query=${encodeURIComponent(
313
+ String(query || "").trim(),
314
+ )}`,
315
+ });
316
+ return (Array.isArray(data) ? data : []).map((user) => ({
317
+ accountId: String(user?.accountId || ""),
318
+ displayName: user?.displayName || "",
319
+ emailAddress: user?.emailAddress || "",
320
+ }));
321
+ }
322
+
323
+ function isSubtaskIssueType(issueType) {
324
+ const name = String(issueType || "")
325
+ .trim()
326
+ .toLowerCase();
327
+ return name.includes("subtask") || name.includes("sub-task");
328
+ }
329
+
330
+ export function getScriptRuntimePrerequisiteStatus(
331
+ platform = process.platform,
332
+ checker = commandExists,
333
+ ) {
334
+ if (platform === "win32") {
335
+ return {
336
+ required: {
337
+ label: "PowerShell (pwsh)",
338
+ command: "pwsh",
339
+ ok: checker("pwsh"),
340
+ hint: "Install: https://github.com/PowerShell/PowerShell",
341
+ },
342
+ optionalPwsh: null,
343
+ };
344
+ }
345
+
346
+ return {
347
+ required: {
348
+ label: "bash",
349
+ command: "bash",
350
+ ok: checker("bash"),
351
+ hint: "Install bash via your system package manager",
352
+ },
353
+ optionalPwsh: {
354
+ label: "PowerShell (pwsh)",
355
+ command: "pwsh",
356
+ ok: checker("pwsh"),
357
+ hint: "Optional on macOS/Linux (needed only for .ps1 scripts)",
358
+ },
359
+ };
360
+ }
361
+
362
+ export function getDefaultOrchestratorScripts(
363
+ platform = process.platform,
364
+ baseDir = __dirname,
365
+ ) {
366
+ const variants = ["ps1", "sh"]
367
+ .map((ext) => {
368
+ const orchestratorPath = resolve(baseDir, `ve-orchestrator.${ext}`);
369
+ const kanbanPath = resolve(baseDir, `ve-kanban.${ext}`);
370
+ return {
371
+ ext,
372
+ orchestratorPath,
373
+ kanbanPath,
374
+ available: existsSync(orchestratorPath) && existsSync(kanbanPath),
375
+ };
376
+ })
377
+ .filter((variant) => variant.available);
378
+
379
+ const preferredExt = platform === "win32" ? "ps1" : "sh";
380
+ const selectedDefault =
381
+ variants.find((variant) => variant.ext === preferredExt) || variants[0] || null;
382
+
383
+ return {
384
+ preferredExt,
385
+ variants,
386
+ selectedDefault,
387
+ };
388
+ }
389
+
390
+ export function formatOrchestratorScriptForEnv(
391
+ scriptPath,
392
+ configDir = __dirname,
393
+ ) {
394
+ const raw = String(scriptPath || "").trim();
395
+ if (!raw) return "";
396
+
397
+ const absolutePath = isAbsolute(raw) ? raw : resolve(configDir, raw);
398
+ const relativePath = relative(configDir, absolutePath);
399
+ if (!relativePath || relativePath === ".") {
400
+ return `./${basename(absolutePath)}`.replace(/\\/g, "/");
401
+ }
402
+
403
+ if (isAbsolute(relativePath)) {
404
+ return absolutePath.replace(/\\/g, "/");
405
+ }
406
+
407
+ const normalized = relativePath.replace(/\\/g, "/");
408
+ if (normalized.startsWith(".") || normalized.startsWith("..")) {
409
+ return normalized;
410
+ }
411
+ return `./${normalized}`;
412
+ }
413
+
414
+ export function resolveSetupOrchestratorDefaults({
415
+ platform = process.platform,
416
+ repoRoot = process.cwd(),
417
+ configDir = __dirname,
418
+ packageDir = __dirname,
419
+ } = {}) {
420
+ const repoScriptDefaults = getDefaultOrchestratorScripts(
421
+ platform,
422
+ resolve(repoRoot, "scripts", "bosun"),
423
+ );
424
+ const packageScriptDefaults = getDefaultOrchestratorScripts(
425
+ platform,
426
+ packageDir,
427
+ );
428
+ const orchestratorDefaults =
429
+ [repoScriptDefaults, packageScriptDefaults].find((defaults) =>
430
+ defaults.variants.some(
431
+ (variant) => variant.ext === defaults.preferredExt,
432
+ ),
433
+ ) ||
434
+ [repoScriptDefaults, packageScriptDefaults].find(
435
+ (defaults) => defaults.variants.length > 0,
436
+ ) ||
437
+ packageScriptDefaults;
438
+ const selectedDefault = orchestratorDefaults.selectedDefault;
439
+
440
+ return {
441
+ repoScriptDefaults,
442
+ packageScriptDefaults,
443
+ orchestratorDefaults,
444
+ selectedDefault,
445
+ orchestratorScriptEnvValue: selectedDefault
446
+ ? formatOrchestratorScriptForEnv(selectedDefault.orchestratorPath, configDir)
447
+ : "",
448
+ };
449
+ }
450
+
451
+ function parseEnvAssignmentLine(line) {
452
+ const raw = String(line || "").trim();
453
+ if (!raw || raw.startsWith("#")) return null;
454
+ const normalized = raw.startsWith("export ") ? raw.slice(7).trim() : raw;
455
+ const match = normalized.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
456
+ if (!match) return null;
457
+
458
+ const key = match[1];
459
+ let value = match[2] ?? "";
460
+ if (
461
+ (value.startsWith('"') && value.endsWith('"')) ||
462
+ (value.startsWith("'") && value.endsWith("'"))
463
+ ) {
464
+ const quote = value[0];
465
+ value = value.slice(1, -1);
466
+ if (quote === '"') {
467
+ value = value
468
+ .replace(/\\n/g, "\n")
469
+ .replace(/\\r/g, "\r")
470
+ .replace(/\\t/g, "\t")
471
+ .replace(/\\"/g, '"')
472
+ .replace(/\\\\/g, "\\");
473
+ }
474
+ } else {
475
+ const hashIdx = value.indexOf("#");
476
+ if (hashIdx >= 0) {
477
+ value = value.slice(0, hashIdx).trimEnd();
478
+ }
479
+ }
480
+
481
+ return { key, value };
482
+ }
483
+
484
+ export function applyEnvFileToProcess(envPath, options = {}) {
485
+ const override = Boolean(options.override);
486
+ const result = {
487
+ path: envPath,
488
+ found: false,
489
+ loaded: 0,
490
+ skipped: 0,
491
+ };
492
+
493
+ if (!envPath || !existsSync(envPath)) {
494
+ return result;
495
+ }
496
+
497
+ result.found = true;
498
+ const content = readFileSync(envPath, "utf8");
499
+ for (const line of content.split(/\r?\n/)) {
500
+ const parsed = parseEnvAssignmentLine(line);
501
+ if (!parsed) continue;
502
+ if (!override && process.env[parsed.key] !== undefined) {
503
+ result.skipped += 1;
504
+ continue;
505
+ }
506
+ process.env[parsed.key] = parsed.value;
507
+ result.loaded += 1;
508
+ }
509
+
510
+ return result;
511
+ }
512
+
513
+ /**
514
+ * Check if a binary exists in the package's own node_modules/.bin/.
515
+ * When installed globally, npm only symlinks the top-level package's bin
516
+ * entries to the global path — transitive dependency binaries (like
517
+ * vibe-kanban) live here instead.
518
+ */
519
+ function bundledBinExists(cmd) {
520
+ const base = resolve(__dirname, "node_modules", ".bin", cmd);
521
+ return existsSync(base) || existsSync(base + ".cmd");
522
+ }
523
+
524
+ function detectRepoSlug(cwd) {
525
+ try {
526
+ const remote = execSync("git remote get-url origin", {
527
+ encoding: "utf8",
528
+ cwd: cwd || process.cwd(),
529
+ stdio: ["pipe", "pipe", "ignore"],
530
+ }).trim();
531
+ const match = remote.match(/github\.com[/:]([^/]+\/[^/.]+)/);
532
+ return match ? match[1] : null;
533
+ } catch {
534
+ return null;
535
+ }
536
+ }
537
+
538
+ function detectRepoRoot(cwd) {
539
+ try {
540
+ return execSync("git rev-parse --show-toplevel", {
541
+ encoding: "utf8",
542
+ cwd: cwd || process.cwd(),
543
+ stdio: ["pipe", "pipe", "ignore"],
544
+ }).trim();
545
+ } catch {
546
+ return cwd || process.cwd();
547
+ }
548
+ }
549
+
550
+ function detectProjectName(repoRoot) {
551
+ const pkgPath = resolve(repoRoot, "package.json");
552
+ if (existsSync(pkgPath)) {
553
+ try {
554
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
555
+ if (pkg.name) return pkg.name.replace(/^@[^/]+\//, "");
556
+ } catch {
557
+ /* skip */
558
+ }
559
+ }
560
+ return basename(repoRoot);
561
+ }
562
+
563
+ function runGhCommand(args, cwd) {
564
+ const normalizedArgs = Array.isArray(args)
565
+ ? args.map((entry) => String(entry))
566
+ : [];
567
+ const output = execFileSync("gh", normalizedArgs, {
568
+ encoding: "utf8",
569
+ cwd: cwd || process.cwd(),
570
+ stdio: ["ignore", "pipe", "pipe"],
571
+ });
572
+ return String(output || "").trim();
573
+ }
574
+
575
+ function formatGhErrorReason(err) {
576
+ if (!err) return "";
577
+ const stderr = String(err.stderr || "").trim();
578
+ const stdout = String(err.stdout || "").trim();
579
+ const message = String(err.message || "").trim();
580
+ return stderr || stdout || message;
581
+ }
582
+
583
+ function detectGitHubUserLogin(cwd) {
584
+ try {
585
+ return runGhCommand(["api", "user", "--jq", ".login"], cwd);
586
+ } catch {
587
+ return "";
588
+ }
589
+ }
590
+
591
+ function collectProjectCandidates(node, out) {
592
+ if (node === null || node === undefined) return;
593
+ if (Array.isArray(node)) {
594
+ for (const item of node) collectProjectCandidates(item, out);
595
+ return;
596
+ }
597
+ if (typeof node !== "object") return;
598
+
599
+ if (
600
+ Object.prototype.hasOwnProperty.call(node, "title") ||
601
+ Object.prototype.hasOwnProperty.call(node, "number") ||
602
+ Object.prototype.hasOwnProperty.call(node, "url") ||
603
+ Object.prototype.hasOwnProperty.call(node, "projectNumber")
604
+ ) {
605
+ out.push(node);
606
+ }
607
+
608
+ for (const value of Object.values(node)) {
609
+ if (value && (Array.isArray(value) || typeof value === "object")) {
610
+ collectProjectCandidates(value, out);
611
+ }
612
+ }
613
+ }
614
+
615
+ function parseGitHubProjectList(rawOutput) {
616
+ const rawText = String(rawOutput || "").trim();
617
+ if (!rawText) return [];
618
+
619
+ let parsed;
620
+ try {
621
+ parsed = JSON.parse(rawText);
622
+ } catch {
623
+ return [];
624
+ }
625
+
626
+ const candidates = [];
627
+ collectProjectCandidates(parsed, candidates);
628
+ return candidates;
629
+ }
630
+
631
+ function extractProjectNumberFromText(value) {
632
+ const text = String(value || "").trim();
633
+ if (!text) return "";
634
+ if (/^\d+$/.test(text)) return text;
635
+
636
+ const patterns = [
637
+ /\/projects\/(\d+)(?:\b|$)/i,
638
+ /\/projects\/v2\/(\d+)(?:\b|$)/i,
639
+ /\bproject\s*(?:number|id)?\s*[:#=-]?\s*(\d+)\b/i,
640
+ /\bnumber\s*[:#=-]\s*(\d+)\b/i,
641
+ ];
642
+
643
+ for (const pattern of patterns) {
644
+ const match = text.match(pattern);
645
+ if (match && match[1]) return match[1];
646
+ }
647
+
648
+ if (/project/i.test(text)) {
649
+ const fallback = text.match(/\b(\d+)\b/);
650
+ if (fallback && fallback[1]) return fallback[1];
651
+ }
652
+
653
+ return "";
654
+ }
655
+
656
+ function extractProjectNumber(value) {
657
+ if (value === null || value === undefined) return "";
658
+
659
+ if (typeof value === "number" && Number.isFinite(value)) {
660
+ const normalized = Math.trunc(value);
661
+ return normalized > 0 ? String(normalized) : "";
662
+ }
663
+
664
+ if (typeof value === "string") {
665
+ return extractProjectNumberFromText(value);
666
+ }
667
+
668
+ if (typeof value === "object") {
669
+ const keys = [
670
+ "number",
671
+ "projectNumber",
672
+ "project_number",
673
+ "url",
674
+ "resourcePath",
675
+ "html_url",
676
+ "id",
677
+ "text",
678
+ "message",
679
+ ];
680
+ for (const key of keys) {
681
+ const nested = extractProjectNumber(value?.[key]);
682
+ if (nested) return nested;
683
+ }
684
+ return extractProjectNumberFromText(JSON.stringify(value));
685
+ }
686
+
687
+ return "";
688
+ }
689
+
690
+ function resolveOrCreateGitHubProject({
691
+ owner,
692
+ title,
693
+ cwd,
694
+ repoOwner,
695
+ githubLogin,
696
+ runCommand = runGhCommand,
697
+ }) {
698
+ const normalizedOwner = String(owner || "").trim();
699
+ const normalizedRepoOwner = String(repoOwner || "").trim();
700
+ const normalizedGithubLogin = String(githubLogin || "").trim();
701
+ const normalizedTitle = String(title || "").trim();
702
+ if (!normalizedTitle) {
703
+ return {
704
+ number: "",
705
+ owner: "",
706
+ reason: "missing GitHub Project title",
707
+ };
708
+ }
709
+
710
+ const ownerCandidates = [];
711
+ for (const candidate of [
712
+ normalizedOwner,
713
+ normalizedGithubLogin,
714
+ normalizedRepoOwner,
715
+ ]) {
716
+ if (!candidate) continue;
717
+ if (!ownerCandidates.includes(candidate)) ownerCandidates.push(candidate);
718
+ }
719
+
720
+ if (ownerCandidates.length === 0) {
721
+ return {
722
+ number: "",
723
+ owner: "",
724
+ reason: "missing GitHub Project owner",
725
+ };
726
+ }
727
+
728
+ const reasons = [];
729
+ const normalizedTitleLower = normalizedTitle.toLowerCase();
730
+
731
+ for (const candidateOwner of ownerCandidates) {
732
+ let listFailed = false;
733
+ let hadListProjects = false;
734
+
735
+ try {
736
+ const listRaw = runCommand(
737
+ ["project", "list", "--owner", candidateOwner, "--format", "json"],
738
+ cwd,
739
+ );
740
+ const projects = parseGitHubProjectList(listRaw);
741
+ hadListProjects = projects.length > 0;
742
+
743
+ const existing = projects.find(
744
+ (project) =>
745
+ String(project?.title || "")
746
+ .trim()
747
+ .toLowerCase() === normalizedTitleLower,
748
+ );
749
+ const existingNumber = extractProjectNumber(existing);
750
+ if (existingNumber) {
751
+ return {
752
+ number: existingNumber,
753
+ owner: candidateOwner,
754
+ reason: "",
755
+ };
756
+ }
757
+ } catch (err) {
758
+ listFailed = true;
759
+ const reason = formatGhErrorReason(err);
760
+ reasons.push(
761
+ reason
762
+ ? `list failed for owner '${candidateOwner}': ${reason}`
763
+ : `list failed for owner '${candidateOwner}'`,
764
+ );
765
+ }
766
+
767
+ try {
768
+ const createRaw = runCommand(
769
+ [
770
+ "project",
771
+ "create",
772
+ "--owner",
773
+ candidateOwner,
774
+ "--title",
775
+ normalizedTitle,
776
+ ],
777
+ cwd,
778
+ );
779
+ const createdNumber = extractProjectNumber(createRaw);
780
+ if (createdNumber) {
781
+ return {
782
+ number: createdNumber,
783
+ owner: candidateOwner,
784
+ reason: "",
785
+ };
786
+ }
787
+
788
+ reasons.push(
789
+ `create returned no project number for owner '${candidateOwner}'`,
790
+ );
791
+ } catch (err) {
792
+ const reason = formatGhErrorReason(err);
793
+ const context = listFailed
794
+ ? "list+create"
795
+ : hadListProjects
796
+ ? "create"
797
+ : "create";
798
+ reasons.push(
799
+ reason
800
+ ? `${context} failed for owner '${candidateOwner}': ${reason}`
801
+ : `${context} failed for owner '${candidateOwner}'`,
802
+ );
803
+ }
804
+ }
805
+
806
+ return {
807
+ number: "",
808
+ owner: ownerCandidates[0] || "",
809
+ reason:
810
+ reasons.find(Boolean) ||
811
+ "no matching project found and project creation failed",
812
+ };
813
+ }
814
+
815
+ function resolveOrCreateGitHubProjectNumber(options) {
816
+ return resolveOrCreateGitHubProject(options).number;
817
+ }
818
+
819
+ function getDefaultPromptOverrides() {
820
+ const entries = getAgentPromptDefinitions().map((def) => [
821
+ def.key,
822
+ `${PROMPT_WORKSPACE_DIR}/${def.filename}`,
823
+ ]);
824
+ return Object.fromEntries(entries);
825
+ }
826
+
827
+ function ensureRepoGitIgnoreEntry(repoRoot, entry) {
828
+ const gitignorePath = resolve(repoRoot, ".gitignore");
829
+ const normalizedEntry = String(entry || "").trim();
830
+ if (!normalizedEntry) return false;
831
+
832
+ let existing = "";
833
+ if (existsSync(gitignorePath)) {
834
+ existing = readFileSync(gitignorePath, "utf8");
835
+ }
836
+
837
+ const hasEntry = existing
838
+ .split(/\r?\n/)
839
+ .map((line) => line.trim())
840
+ .includes(normalizedEntry);
841
+ if (hasEntry) return false;
842
+
843
+ const next =
844
+ existing.endsWith("\n") || !existing ? existing : `${existing}\n`;
845
+ writeFileSync(gitignorePath, `${next}${normalizedEntry}\n`, "utf8");
846
+ return true;
847
+ }
848
+
849
+ function buildRecommendedVsCodeSettings(env = {}) {
850
+ const maxRequests = Math.max(
851
+ 50,
852
+ Number(env.COPILOT_AGENT_MAX_REQUESTS || process.env.COPILOT_AGENT_MAX_REQUESTS || 500),
853
+ );
854
+
855
+ return {
856
+ "github.copilot.chat.searchSubagent.enabled": true,
857
+ "github.copilot.chat.switchAgent.enabled": true,
858
+ "github.copilot.chat.cli.customAgents.enabled": true,
859
+ "github.copilot.chat.cli.mcp.enabled": true,
860
+ "github.copilot.chat.agent.enabled": true,
861
+ "github.copilot.chat.agent.maxRequests": maxRequests,
862
+ "github.copilot.chat.thinking.collapsedTools": "withThinking",
863
+ "github.copilot.chat.thinking.generateTitles": true,
864
+ "github.copilot.chat.confirmEditRequestRemoval": false,
865
+ "github.copilot.chat.confirmRetryRequestRemoval": false,
866
+ "github.copilot.chat.terminal.enableAutoApprove": true,
867
+ "github.copilot.chat.terminal.autoReplyToPrompts": true,
868
+ "github.copilot.chat.tools.autoApprove": true,
869
+ "github.copilot.chat.tools.runSubagent.enabled": true,
870
+ "github.copilot.chat.tools.searchSubagent.enabled": true,
871
+ };
872
+ }
873
+
874
+ function mergePlainObjects(base, updates) {
875
+ const out = { ...(base || {}) };
876
+ for (const [key, value] of Object.entries(updates || {})) {
877
+ if (
878
+ value &&
879
+ typeof value === "object" &&
880
+ !Array.isArray(value) &&
881
+ out[key] &&
882
+ typeof out[key] === "object" &&
883
+ !Array.isArray(out[key])
884
+ ) {
885
+ out[key] = mergePlainObjects(out[key], value);
886
+ } else {
887
+ out[key] = value;
888
+ }
889
+ }
890
+ return out;
891
+ }
892
+
893
+ function writeWorkspaceVsCodeSettings(repoRoot, env) {
894
+ try {
895
+ const vscodeDir = resolve(repoRoot, ".vscode");
896
+ const settingsPath = resolve(vscodeDir, "settings.json");
897
+ mkdirSync(vscodeDir, { recursive: true });
898
+
899
+ let existing = {};
900
+ if (existsSync(settingsPath)) {
901
+ try {
902
+ existing = JSON.parse(readFileSync(settingsPath, "utf8"));
903
+ } catch {
904
+ existing = {};
905
+ }
906
+ }
907
+
908
+ const recommended = buildRecommendedVsCodeSettings(env);
909
+ const merged = mergePlainObjects(existing, recommended);
910
+ writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
911
+ return { path: settingsPath, updated: true };
912
+ } catch (err) {
913
+ return { path: null, updated: false, error: err.message };
914
+ }
915
+ }
916
+
917
+ function buildRecommendedCopilotMcpServers() {
918
+ return {
919
+ context7: {
920
+ command: "npx",
921
+ args: ["-y", "@upstash/context7-mcp"],
922
+ },
923
+ "sequential-thinking": {
924
+ command: "npx",
925
+ args: ["-y", "@modelcontextprotocol/server-sequential-thinking"],
926
+ },
927
+ playwright: {
928
+ command: "npx",
929
+ args: ["-y", "@playwright/mcp@latest"],
930
+ },
931
+ "microsoft-docs": {
932
+ url: "https://learn.microsoft.com/api/mcp",
933
+ },
934
+ };
935
+ }
936
+
937
+ function writeWorkspaceCopilotMcpConfig(repoRoot) {
938
+ try {
939
+ const vscodeDir = resolve(repoRoot, ".vscode");
940
+ const mcpPath = resolve(vscodeDir, "mcp.json");
941
+ mkdirSync(vscodeDir, { recursive: true });
942
+
943
+ let existing = {};
944
+ if (existsSync(mcpPath)) {
945
+ try {
946
+ existing = JSON.parse(readFileSync(mcpPath, "utf8"));
947
+ } catch {
948
+ existing = {};
949
+ }
950
+ }
951
+
952
+ const existingServers =
953
+ existing.mcpServers ||
954
+ existing["github.copilot.mcpServers"] ||
955
+ existing;
956
+
957
+ const recommended = buildRecommendedCopilotMcpServers();
958
+ const mergedServers = {
959
+ ...recommended,
960
+ ...(typeof existingServers === "object" ? existingServers : {}),
961
+ };
962
+
963
+ const next = { mcpServers: mergedServers };
964
+ writeFileSync(mcpPath, JSON.stringify(next, null, 2) + "\n", "utf8");
965
+ return { path: mcpPath, updated: true };
966
+ } catch (err) {
967
+ return { path: null, updated: false, error: err.message };
968
+ }
969
+ }
970
+
971
+ function parseHookCommandInput(rawValue) {
972
+ const raw = String(rawValue || "").trim();
973
+ if (!raw) return null;
974
+ const lowered = raw.toLowerCase();
975
+ if (["none", "off", "disable", "disabled"].includes(lowered)) {
976
+ return [];
977
+ }
978
+ return raw
979
+ .split(/\s*;;\s*|\r?\n/)
980
+ .map((part) => part.trim())
981
+ .filter(Boolean);
982
+ }
983
+
984
+ function printHookScaffoldSummary(result) {
985
+ if (!result || !result.enabled) {
986
+ info("Agent hook scaffolding disabled.");
987
+ return;
988
+ }
989
+
990
+ const totalChanged = result.written.length + result.updated.length;
991
+ if (totalChanged > 0) {
992
+ success(`Configured ${totalChanged} agent hook file(s).`);
993
+ } else {
994
+ info("Agent hook files already existed — no file changes needed.");
995
+ }
996
+
997
+ if (result.written.length > 0) {
998
+ for (const path of result.written) {
999
+ console.log(` + ${path}`);
1000
+ }
1001
+ }
1002
+ if (result.updated.length > 0) {
1003
+ for (const path of result.updated) {
1004
+ console.log(` ~ ${path}`);
1005
+ }
1006
+ }
1007
+ if (result.skipped.length > 0) {
1008
+ for (const path of result.skipped) {
1009
+ console.log(` = ${path} (kept existing)`);
1010
+ }
1011
+ }
1012
+ if (result.warnings.length > 0) {
1013
+ for (const warning of result.warnings) {
1014
+ warn(warning);
1015
+ }
1016
+ }
1017
+ }
1018
+
1019
+ // ── Prompt System ────────────────────────────────────────────────────────────
1020
+
1021
+ function createPrompt() {
1022
+ // Fix for Windows PowerShell readline issues
1023
+ // Only use terminal mode if stdin is actually a TTY
1024
+ // This prevents both double-echo and output duplication
1025
+ const rl = createInterface({
1026
+ input: process.stdin,
1027
+ output: process.stdout,
1028
+ terminal: process.stdin.isTTY && process.stdout.isTTY,
1029
+ });
1030
+
1031
+ return {
1032
+ ask(question, defaultValue) {
1033
+ return new Promise((res) => {
1034
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
1035
+ rl.question(` ${question}${suffix}: `, (answer) => {
1036
+ res(answer.trim() || defaultValue || "");
1037
+ });
1038
+ });
1039
+ },
1040
+ confirm(question, defaultYes = true) {
1041
+ return new Promise((res) => {
1042
+ const hint = defaultYes ? "[Y/n]" : "[y/N]";
1043
+ rl.question(` ${question} ${hint}: `, (answer) => {
1044
+ const a = answer.trim().toLowerCase();
1045
+ if (!a) res(defaultYes);
1046
+ else res(a === "y" || a === "yes");
1047
+ });
1048
+ });
1049
+ },
1050
+ choose(question, options, defaultIdx = 0) {
1051
+ return new Promise((res) => {
1052
+ console.log(` ${question}`);
1053
+ options.forEach((opt, i) => {
1054
+ const marker = i === defaultIdx ? "→" : " ";
1055
+ console.log(` ${marker} ${i + 1}) ${opt}`);
1056
+ });
1057
+ rl.question(` Choice [${defaultIdx + 1}]: `, (answer) => {
1058
+ const idx = answer.trim() ? Number(answer.trim()) - 1 : defaultIdx;
1059
+ res(Math.max(0, Math.min(idx, options.length - 1)));
1060
+ });
1061
+ });
1062
+ },
1063
+ close() {
1064
+ rl.close();
1065
+ },
1066
+ };
1067
+ }
1068
+
1069
+ // ── Executor Templates ───────────────────────────────────────────────────────
1070
+
1071
+ const EXECUTOR_PRESETS = {
1072
+ "copilot-codex": [
1073
+ {
1074
+ name: "copilot-claude",
1075
+ executor: "COPILOT",
1076
+ variant: "CLAUDE_OPUS_4_6",
1077
+ weight: 50,
1078
+ role: "primary",
1079
+ },
1080
+ {
1081
+ name: "codex-default",
1082
+ executor: "CODEX",
1083
+ variant: "DEFAULT",
1084
+ weight: 50,
1085
+ role: "backup",
1086
+ },
1087
+ ],
1088
+ "copilot-only": [
1089
+ {
1090
+ name: "copilot-claude",
1091
+ executor: "COPILOT",
1092
+ variant: "CLAUDE_OPUS_4_6",
1093
+ weight: 100,
1094
+ role: "primary",
1095
+ },
1096
+ ],
1097
+ "codex-only": [
1098
+ {
1099
+ name: "codex-default",
1100
+ executor: "CODEX",
1101
+ variant: "DEFAULT",
1102
+ weight: 100,
1103
+ role: "primary",
1104
+ },
1105
+ ],
1106
+ triple: [
1107
+ {
1108
+ name: "copilot-claude",
1109
+ executor: "COPILOT",
1110
+ variant: "CLAUDE_OPUS_4_6",
1111
+ weight: 40,
1112
+ role: "primary",
1113
+ },
1114
+ {
1115
+ name: "codex-default",
1116
+ executor: "CODEX",
1117
+ variant: "DEFAULT",
1118
+ weight: 35,
1119
+ role: "backup",
1120
+ },
1121
+ {
1122
+ name: "copilot-gpt",
1123
+ executor: "COPILOT",
1124
+ variant: "GPT_4_1",
1125
+ weight: 25,
1126
+ role: "tertiary",
1127
+ },
1128
+ ],
1129
+ };
1130
+
1131
+ const FAILOVER_STRATEGIES = [
1132
+ {
1133
+ name: "next-in-line",
1134
+ desc: "Use the next executor by role priority (primary → backup → tertiary)",
1135
+ },
1136
+ {
1137
+ name: "weighted-random",
1138
+ desc: "Randomly select from remaining executors by weight",
1139
+ },
1140
+ { name: "round-robin", desc: "Cycle through remaining executors evenly" },
1141
+ ];
1142
+
1143
+ const DISTRIBUTION_MODES = [
1144
+ {
1145
+ name: "weighted",
1146
+ desc: "Distribute tasks by configured weight percentages",
1147
+ },
1148
+ { name: "round-robin", desc: "Alternate between executors equally" },
1149
+ {
1150
+ name: "primary-only",
1151
+ desc: "Always use primary; others only for failover",
1152
+ },
1153
+ ];
1154
+
1155
+ const SETUP_PROFILES = [
1156
+ {
1157
+ key: "recommended",
1158
+ label: "Recommended — configure important choices, keep safe defaults",
1159
+ },
1160
+ {
1161
+ key: "advanced",
1162
+ label: "Advanced — full control over all setup options",
1163
+ },
1164
+ ];
1165
+
1166
+ function toPositiveInt(value, fallback) {
1167
+ const n = Number(value);
1168
+ if (!Number.isFinite(n) || n <= 0) return fallback;
1169
+ return Math.round(n);
1170
+ }
1171
+
1172
+ function normalizeEnum(value, allowed, fallback) {
1173
+ const normalized = String(value || "")
1174
+ .trim()
1175
+ .toLowerCase();
1176
+ return allowed.includes(normalized) ? normalized : fallback;
1177
+ }
1178
+
1179
+ function parseBooleanEnvValue(value, fallback = false) {
1180
+ if (value === undefined || value === null || value === "") {
1181
+ return fallback;
1182
+ }
1183
+ const normalized = String(value).trim().toLowerCase();
1184
+ if (["1", "true", "yes", "on", "y"].includes(normalized)) {
1185
+ return true;
1186
+ }
1187
+ if (["0", "false", "no", "off", "n"].includes(normalized)) {
1188
+ return false;
1189
+ }
1190
+ return fallback;
1191
+ }
1192
+
1193
+ function toBooleanEnvString(value, fallback = false) {
1194
+ return parseBooleanEnvValue(value, fallback) ? "true" : "false";
1195
+ }
1196
+
1197
+ function readProcValue(path) {
1198
+ try {
1199
+ return readFileSync(path, "utf8").trim();
1200
+ } catch {
1201
+ return "";
1202
+ }
1203
+ }
1204
+
1205
+ function hasBwrapBinary() {
1206
+ if (process.platform !== "linux") return false;
1207
+ try {
1208
+ execSync("bwrap --version", { stdio: "ignore" });
1209
+ return true;
1210
+ } catch {
1211
+ return false;
1212
+ }
1213
+ }
1214
+
1215
+ function detectBwrapSupport() {
1216
+ if (process.platform !== "linux") return false;
1217
+ const unpriv = readProcValue("/proc/sys/kernel/unprivileged_userns_clone");
1218
+ if (unpriv === "0") return false;
1219
+ const maxUserNs = readProcValue("/proc/sys/user/max_user_namespaces");
1220
+ if (maxUserNs && Number(maxUserNs) === 0) return false;
1221
+ return hasBwrapBinary();
1222
+ }
1223
+
1224
+ function buildDefaultWritableRoots(repoRoot) {
1225
+ if (!repoRoot) return "";
1226
+ const roots = new Set();
1227
+ const repo = String(repoRoot);
1228
+ if (repo) {
1229
+ const parent = dirname(repo);
1230
+ if (parent && parent !== repo) roots.add(parent);
1231
+ roots.add(repo);
1232
+ roots.add(resolve(repo, ".git"));
1233
+ }
1234
+ return Array.from(roots).join(",");
1235
+ }
1236
+
1237
+ function normalizeSetupConfiguration({
1238
+ env,
1239
+ configJson,
1240
+ repoRoot,
1241
+ slug,
1242
+ configDir,
1243
+ }) {
1244
+ env.PROJECT_NAME =
1245
+ env.PROJECT_NAME || configJson.projectName || basename(repoRoot);
1246
+ env.REPO_ROOT = env.REPO_ROOT || repoRoot;
1247
+ env.GITHUB_REPO = env.GITHUB_REPO || slug || "";
1248
+
1249
+ env.MAX_PARALLEL = String(toPositiveInt(env.MAX_PARALLEL || "6", 6));
1250
+ env.TELEGRAM_INTERVAL_MIN = String(
1251
+ toPositiveInt(env.TELEGRAM_INTERVAL_MIN || "10", 10),
1252
+ );
1253
+
1254
+ env.KANBAN_BACKEND = normalizeEnum(
1255
+ env.KANBAN_BACKEND,
1256
+ ["internal", "vk", "github", "jira"],
1257
+ "internal",
1258
+ );
1259
+ env.KANBAN_SYNC_POLICY = normalizeEnum(
1260
+ env.KANBAN_SYNC_POLICY,
1261
+ ["internal-primary", "bidirectional"],
1262
+ "internal-primary",
1263
+ );
1264
+ env.PROJECT_REQUIREMENTS_PROFILE = normalizeEnum(
1265
+ env.PROJECT_REQUIREMENTS_PROFILE,
1266
+ [
1267
+ "simple-feature",
1268
+ "feature",
1269
+ "large-feature",
1270
+ "system",
1271
+ "multi-system",
1272
+ ],
1273
+ "feature",
1274
+ );
1275
+ env.INTERNAL_EXECUTOR_REPLENISH_ENABLED = toBooleanEnvString(
1276
+ env.INTERNAL_EXECUTOR_REPLENISH_ENABLED,
1277
+ false,
1278
+ );
1279
+ env.INTERNAL_EXECUTOR_REPLENISH_MIN_NEW_TASKS = String(
1280
+ toPositiveInt(env.INTERNAL_EXECUTOR_REPLENISH_MIN_NEW_TASKS, 1),
1281
+ );
1282
+ env.INTERNAL_EXECUTOR_REPLENISH_MAX_NEW_TASKS = String(
1283
+ toPositiveInt(env.INTERNAL_EXECUTOR_REPLENISH_MAX_NEW_TASKS, 2),
1284
+ );
1285
+ env.COPILOT_NO_EXPERIMENTAL = toBooleanEnvString(
1286
+ env.COPILOT_NO_EXPERIMENTAL,
1287
+ false,
1288
+ );
1289
+ env.COPILOT_NO_ALLOW_ALL = toBooleanEnvString(
1290
+ env.COPILOT_NO_ALLOW_ALL,
1291
+ false,
1292
+ );
1293
+ env.COPILOT_ENABLE_ASK_USER = toBooleanEnvString(
1294
+ env.COPILOT_ENABLE_ASK_USER,
1295
+ false,
1296
+ );
1297
+ env.COPILOT_ENABLE_ALL_GITHUB_MCP_TOOLS = toBooleanEnvString(
1298
+ env.COPILOT_ENABLE_ALL_GITHUB_MCP_TOOLS,
1299
+ true,
1300
+ );
1301
+ env.COPILOT_AGENT_MAX_REQUESTS = String(
1302
+ toPositiveInt(env.COPILOT_AGENT_MAX_REQUESTS || 500, 500),
1303
+ );
1304
+ env.EXECUTOR_MODE = normalizeEnum(
1305
+ env.EXECUTOR_MODE,
1306
+ ["internal", "vk", "hybrid"],
1307
+ "internal",
1308
+ );
1309
+
1310
+ env.CODEX_MODEL_PROFILE = normalizeEnum(
1311
+ env.CODEX_MODEL_PROFILE,
1312
+ ["xl", "m"],
1313
+ "xl",
1314
+ );
1315
+ env.CODEX_MODEL_PROFILE_SUBAGENT = normalizeEnum(
1316
+ env.CODEX_MODEL_PROFILE_SUBAGENT || env.CODEX_SUBAGENT_PROFILE,
1317
+ ["xl", "m"],
1318
+ "m",
1319
+ );
1320
+ env.CODEX_MODEL_PROFILE_XL_PROVIDER = normalizeEnum(
1321
+ env.CODEX_MODEL_PROFILE_XL_PROVIDER,
1322
+ ["openai", "azure", "compatible"],
1323
+ "openai",
1324
+ );
1325
+ env.CODEX_MODEL_PROFILE_M_PROVIDER = normalizeEnum(
1326
+ env.CODEX_MODEL_PROFILE_M_PROVIDER,
1327
+ ["openai", "azure", "compatible"],
1328
+ "openai",
1329
+ );
1330
+ env.CODEX_MODEL_PROFILE_XL_MODEL =
1331
+ env.CODEX_MODEL_PROFILE_XL_MODEL || "gpt-5.3-codex";
1332
+ env.CODEX_MODEL_PROFILE_M_MODEL =
1333
+ env.CODEX_MODEL_PROFILE_M_MODEL || "gpt-5.1-codex-mini";
1334
+ env.CODEX_SUBAGENT_MODEL =
1335
+ env.CODEX_SUBAGENT_MODEL || env.CODEX_MODEL_PROFILE_M_MODEL;
1336
+ env.CODEX_AGENT_MAX_THREADS = String(
1337
+ toPositiveInt(
1338
+ env.CODEX_AGENT_MAX_THREADS || env.CODEX_AGENTS_MAX_THREADS || "12",
1339
+ 12,
1340
+ ),
1341
+ );
1342
+ env.CODEX_SANDBOX = normalizeEnum(
1343
+ env.CODEX_SANDBOX,
1344
+ ["workspace-write", "danger-full-access", "read-only"],
1345
+ "workspace-write",
1346
+ );
1347
+ env.CODEX_FEATURES_BWRAP = toBooleanEnvString(
1348
+ env.CODEX_FEATURES_BWRAP,
1349
+ detectBwrapSupport(),
1350
+ );
1351
+ env.CODEX_SANDBOX_PERMISSIONS =
1352
+ env.CODEX_SANDBOX_PERMISSIONS || "disk-full-write-access";
1353
+ env.CODEX_SANDBOX_WRITABLE_ROOTS =
1354
+ env.CODEX_SANDBOX_WRITABLE_ROOTS || buildDefaultWritableRoots(repoRoot);
1355
+
1356
+ env.VK_BASE_URL = env.VK_BASE_URL || "http://127.0.0.1:54089";
1357
+ env.VK_RECOVERY_PORT = String(
1358
+ toPositiveInt(env.VK_RECOVERY_PORT || "54089", 54089),
1359
+ );
1360
+
1361
+ env.CODEX_TRANSPORT = normalizeEnum(
1362
+ env.CODEX_TRANSPORT || process.env.CODEX_TRANSPORT,
1363
+ ["sdk", "auto", "cli"],
1364
+ "sdk",
1365
+ );
1366
+ env.COPILOT_TRANSPORT = normalizeEnum(
1367
+ env.COPILOT_TRANSPORT || process.env.COPILOT_TRANSPORT,
1368
+ ["sdk", "auto", "cli", "url"],
1369
+ "sdk",
1370
+ );
1371
+ env.COPILOT_MCP_CONFIG =
1372
+ env.COPILOT_MCP_CONFIG || resolve(repoRoot, ".vscode", "mcp.json");
1373
+ env.CLAUDE_TRANSPORT = normalizeEnum(
1374
+ env.CLAUDE_TRANSPORT || process.env.CLAUDE_TRANSPORT,
1375
+ ["sdk", "auto", "cli"],
1376
+ "sdk",
1377
+ );
1378
+
1379
+ env.WHATSAPP_ENABLED = toBooleanEnvString(env.WHATSAPP_ENABLED, false);
1380
+
1381
+ env.CONTAINER_ENABLED = toBooleanEnvString(env.CONTAINER_ENABLED, false);
1382
+
1383
+ env.CONTAINER_RUNTIME = normalizeEnum(
1384
+ env.CONTAINER_RUNTIME,
1385
+ ["auto", "docker", "podman", "container"],
1386
+ "auto",
1387
+ );
1388
+ if (env.ORCHESTRATOR_SCRIPT) {
1389
+ env.ORCHESTRATOR_SCRIPT = formatOrchestratorScriptForEnv(
1390
+ env.ORCHESTRATOR_SCRIPT,
1391
+ configDir || __dirname,
1392
+ );
1393
+ }
1394
+
1395
+ if (
1396
+ !Array.isArray(configJson.executors) ||
1397
+ configJson.executors.length === 0
1398
+ ) {
1399
+ configJson.executors = EXECUTOR_PRESETS["codex-only"];
1400
+ }
1401
+ configJson.executors = configJson.executors.map((executor, index) => ({
1402
+ ...executor,
1403
+ name: executor.name || `executor-${index + 1}`,
1404
+ executor: String(executor.executor || "CODEX").toUpperCase(),
1405
+ variant: executor.variant || "DEFAULT",
1406
+ weight: toPositiveInt(executor.weight || 1, 1),
1407
+ role:
1408
+ executor.role ||
1409
+ (index === 0
1410
+ ? "primary"
1411
+ : index === 1
1412
+ ? "backup"
1413
+ : `executor-${index + 1}`),
1414
+ enabled: executor.enabled !== false,
1415
+ }));
1416
+
1417
+ configJson.failover = {
1418
+ strategy: normalizeEnum(
1419
+ configJson.failover?.strategy || env.FAILOVER_STRATEGY || "next-in-line",
1420
+ ["next-in-line", "weighted-random", "round-robin"],
1421
+ "next-in-line",
1422
+ ),
1423
+ maxRetries: toPositiveInt(
1424
+ configJson.failover?.maxRetries || env.FAILOVER_MAX_RETRIES || 3,
1425
+ 3,
1426
+ ),
1427
+ cooldownMinutes: toPositiveInt(
1428
+ configJson.failover?.cooldownMinutes || env.FAILOVER_COOLDOWN_MIN || 5,
1429
+ 5,
1430
+ ),
1431
+ disableOnConsecutiveFailures: toPositiveInt(
1432
+ configJson.failover?.disableOnConsecutiveFailures ||
1433
+ env.FAILOVER_DISABLE_AFTER ||
1434
+ 3,
1435
+ 3,
1436
+ ),
1437
+ };
1438
+
1439
+ configJson.distribution = normalizeEnum(
1440
+ configJson.distribution || env.EXECUTOR_DISTRIBUTION || "weighted",
1441
+ ["weighted", "round-robin", "primary-only"],
1442
+ "weighted",
1443
+ );
1444
+
1445
+ if (
1446
+ !Array.isArray(configJson.repositories) ||
1447
+ configJson.repositories.length === 0
1448
+ ) {
1449
+ configJson.repositories = [
1450
+ {
1451
+ name: basename(repoRoot),
1452
+ slug: env.GITHUB_REPO,
1453
+ primary: true,
1454
+ },
1455
+ ];
1456
+ }
1457
+
1458
+ configJson.projectName = env.PROJECT_NAME;
1459
+ configJson.kanban = {
1460
+ ...(configJson.kanban || {}),
1461
+ backend: env.KANBAN_BACKEND,
1462
+ syncPolicy: env.KANBAN_SYNC_POLICY,
1463
+ };
1464
+ configJson.internalExecutor = {
1465
+ ...(configJson.internalExecutor || {}),
1466
+ mode: env.EXECUTOR_MODE,
1467
+ };
1468
+ }
1469
+
1470
+ function formatEnvValue(value) {
1471
+ const raw = String(value ?? "");
1472
+ const needsQuotes = /\s|#|=/.test(raw);
1473
+ if (!needsQuotes) return raw;
1474
+ return `"${raw.replace(/"/g, '\\"')}"`;
1475
+ }
1476
+
1477
+ export function buildStandardizedEnvFile(templateText, envEntries) {
1478
+ const lines = templateText.split(/\r?\n/);
1479
+ const entryMap = new Map(
1480
+ Object.entries(envEntries)
1481
+ .filter(([key]) => !key.startsWith("_"))
1482
+ .map(([key, value]) => [key, String(value ?? "")]),
1483
+ );
1484
+
1485
+ const consumed = new Set();
1486
+ const seenKeys = new Set();
1487
+ const updated = lines.flatMap((line) => {
1488
+ const match = line.match(/^\s*#?\s*([A-Z0-9_]+)=.*$/);
1489
+ if (!match) return [line];
1490
+ const key = match[1];
1491
+ if (seenKeys.has(key)) return [];
1492
+ seenKeys.add(key);
1493
+ if (!entryMap.has(key)) return [line];
1494
+ consumed.add(key);
1495
+ return [`${key}=${formatEnvValue(entryMap.get(key))}`];
1496
+ });
1497
+
1498
+ const extras = [...entryMap.keys()].filter((key) => !consumed.has(key));
1499
+ if (extras.length > 0) {
1500
+ updated.push("");
1501
+ updated.push("# Added by setup wizard");
1502
+ for (const key of extras.sort()) {
1503
+ updated.push(`${key}=${formatEnvValue(entryMap.get(key))}`);
1504
+ }
1505
+ }
1506
+
1507
+ const header = [
1508
+ "# Generated by bosun setup wizard",
1509
+ `# ${new Date().toISOString()}`,
1510
+ "",
1511
+ ];
1512
+ return [...header, ...updated].join("\n") + "\n";
1513
+ }
1514
+
1515
+ // ── Agent Template ───────────────────────────────────────────────────────────
1516
+
1517
+ function generateAgentsMd(projectName, repoSlug) {
1518
+ return `# ${projectName} — Agent Guide
1519
+
1520
+ ## CRITICAL
1521
+
1522
+ Always work on tasks longer than you think are needed to accommodate edge cases, testing, and quality.
1523
+ Ensure tests pass and build is clean with 0 warnings before deciding a task is complete.
1524
+ When working on a task, do not stop until it is COMPLETELY done end-to-end.
1525
+
1526
+ Before finishing a task — create a commit using conventional commits and push.
1527
+
1528
+ ### PR Creation
1529
+
1530
+ After committing:
1531
+ - Run \`gh pr create\` to open the PR
1532
+ - Ensure pre-push hooks pass
1533
+ - Fix any lint or test errors encountered
1534
+
1535
+ ## Overview
1536
+
1537
+ - Repository: \`${repoSlug}\`
1538
+ - Task management: Vibe-Kanban (auto-configured by bosun)
1539
+
1540
+ ## Build & Test
1541
+
1542
+ \`\`\`bash
1543
+ # Add your build commands here
1544
+ npm run build
1545
+ npm test
1546
+ \`\`\`
1547
+
1548
+ ## Commit Conventions
1549
+
1550
+ Use [Conventional Commits](https://www.conventionalcommits.org/):
1551
+
1552
+ \`\`\`
1553
+ type(scope): description
1554
+ \`\`\`
1555
+
1556
+ Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
1557
+
1558
+ ## Pre-commit / Pre-push
1559
+
1560
+ Linting and formatting are enforced before commit.
1561
+ Tests and builds are verified before push.
1562
+ `;
1563
+ }
1564
+
1565
+ // ── VK Auto-Configuration ────────────────────────────────────────────────────
1566
+
1567
+ function generateVkSetupScript(config) {
1568
+ const repoRoot = config.repoRoot.replace(/\\/g, "/");
1569
+ const monitorDir = config.monitorDir.replace(/\\/g, "/");
1570
+
1571
+ return `#!/usr/bin/env bash
1572
+ # Auto-generated by bosun setup
1573
+ # VK workspace setup script for: ${config.projectName}
1574
+
1575
+ set -euo pipefail
1576
+
1577
+ echo "Setting up workspace for ${config.projectName}..."
1578
+
1579
+ # ── PATH propagation ──────────────────────────────────────────────────────────
1580
+ # Ensure common tool directories are on PATH so agents can find gh, pwsh, node,
1581
+ # go, etc. without using full absolute paths. The host user's PATH may not be
1582
+ # inherited by the workspace shell.
1583
+ _add_to_path() { case ":\$PATH:" in *":\$1:"*) ;; *) export PATH="\$1:\$PATH" ;; esac; }
1584
+
1585
+ for _dir in \\
1586
+ /usr/local/bin \\
1587
+ /usr/local/sbin \\
1588
+ /usr/bin \\
1589
+ "\$HOME/.local/bin" \\
1590
+ "\$HOME/bin" \\
1591
+ "\$HOME/go/bin" \\
1592
+ "\$HOME/.cargo/bin" \\
1593
+ /snap/bin \\
1594
+ /opt/homebrew/bin; do
1595
+ [ -d "\$_dir" ] && _add_to_path "\$_dir"
1596
+ done
1597
+
1598
+ # Windows-specific paths (Git Bash / MSYS2 environment)
1599
+ case "\$(uname -s 2>/dev/null)" in
1600
+ MINGW*|MSYS*|CYGWIN*)
1601
+ for _wdir in \\
1602
+ "/c/Program Files/GitHub CLI" \\
1603
+ "/c/Program Files/PowerShell/7" \\
1604
+ "/c/Program Files/nodejs"; do
1605
+ [ -d "\$_wdir" ] && _add_to_path "\$_wdir"
1606
+ done
1607
+ ;;
1608
+ esac
1609
+
1610
+ # ── Git credential guard ─────────────────────────────────────────────────────
1611
+ # NEVER run 'gh auth setup-git' inside a workspace — it writes the container's
1612
+ # gh path into .git/config, corrupting pushes from other environments.
1613
+ # Rely on GH_TOKEN/GITHUB_TOKEN env vars or the global credential helper.
1614
+ if git config --local credential.helper &>/dev/null; then
1615
+ _local_helper=\$(git config --local credential.helper)
1616
+ if echo "\$_local_helper" | grep -qE '/home/.*/gh(\\.exe)?|/tmp/.*/gh'; then
1617
+ echo " [setup] Removing stale local credential.helper: \$_local_helper"
1618
+ git config --local --unset credential.helper || true
1619
+ fi
1620
+ fi
1621
+
1622
+ # ── Git worktree cleanup ─────────────────────────────────────────────────────
1623
+ # Prune stale worktree references to prevent path corruption errors.
1624
+ # This happens when worktree directories are deleted but git metadata remains.
1625
+ if [ -f ".git" ]; then
1626
+ _gitdir=\$(cat .git | sed 's/^gitdir: //')
1627
+ _repo_root=\$(dirname "\$_gitdir" | xargs dirname | xargs dirname)
1628
+ if [ -d "\$_repo_root/.git/worktrees" ]; then
1629
+ echo " [setup] Pruning stale worktrees..."
1630
+ ( cd "\$_repo_root" && git worktree prune -v 2>&1 | sed 's/^/ [prune] /' ) || true
1631
+ fi
1632
+ fi
1633
+
1634
+ # ── GitHub auth verification ─────────────────────────────────────────────────
1635
+ if command -v gh &>/dev/null; then
1636
+ echo " [setup] gh CLI found at: \$(command -v gh)"
1637
+ gh auth status 2>/dev/null || echo " [setup] gh not authenticated — ensure GH_TOKEN is set"
1638
+ else
1639
+ echo " [setup] WARNING: gh CLI not found on PATH"
1640
+ echo " [setup] Current PATH: \$PATH"
1641
+ fi
1642
+
1643
+ # Install dependencies
1644
+ if [ -f "package.json" ]; then
1645
+ if command -v pnpm &>/dev/null; then
1646
+ pnpm install
1647
+ elif command -v npm &>/dev/null; then
1648
+ npm install
1649
+ fi
1650
+ fi
1651
+
1652
+ # Install bosun dependencies
1653
+ if [ -d "${relative(config.repoRoot, monitorDir)}" ]; then
1654
+ cd "${relative(config.repoRoot, monitorDir)}"
1655
+ if command -v pnpm &>/dev/null; then
1656
+ pnpm install
1657
+ elif command -v npm &>/dev/null; then
1658
+ npm install
1659
+ fi
1660
+ cd -
1661
+ fi
1662
+
1663
+ echo "Workspace setup complete."
1664
+ `;
1665
+ }
1666
+
1667
+ function generateVkCleanupScript(config) {
1668
+ return `#!/usr/bin/env bash
1669
+ # Auto-generated by bosun setup
1670
+ # VK workspace cleanup script for: ${config.projectName}
1671
+
1672
+ set -euo pipefail
1673
+
1674
+ echo "Cleaning up workspace for ${config.projectName}..."
1675
+
1676
+ # Create PR if branch has commits
1677
+ BRANCH=$(git branch --show-current 2>/dev/null || true)
1678
+ if [ -n "$BRANCH" ] && [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then
1679
+ COMMITS=$(git log main.."$BRANCH" --oneline 2>/dev/null | wc -l || echo 0)
1680
+ if [ "$COMMITS" -gt 0 ]; then
1681
+ echo "Branch $BRANCH has $COMMITS commit(s) — creating PR..."
1682
+ gh pr create --fill 2>/dev/null || echo "PR creation skipped"
1683
+ fi
1684
+ fi
1685
+
1686
+ echo "Cleanup complete."
1687
+ `;
1688
+ }
1689
+
1690
+ // ── Main Setup Flow ──────────────────────────────────────────────────────────
1691
+
1692
+ async function main() {
1693
+ printBanner();
1694
+
1695
+ // ── Step 1: Prerequisites ───────────────────────────────
1696
+ heading("Step 1 of 9 — Prerequisites");
1697
+ const hasNode = check(
1698
+ "Node.js ≥ 18",
1699
+ Number(process.versions.node.split(".")[0]) >= 18,
1700
+ );
1701
+ const hasGit = check("git", commandExists("git"));
1702
+ const runtimeStatus = getScriptRuntimePrerequisiteStatus();
1703
+ check(
1704
+ runtimeStatus.required.label,
1705
+ runtimeStatus.required.ok,
1706
+ runtimeStatus.required.hint,
1707
+ );
1708
+ if (runtimeStatus.optionalPwsh) {
1709
+ if (runtimeStatus.optionalPwsh.ok) {
1710
+ info(
1711
+ `${runtimeStatus.optionalPwsh.label} detected (${runtimeStatus.optionalPwsh.hint}).`,
1712
+ );
1713
+ } else {
1714
+ warn(
1715
+ `${runtimeStatus.optionalPwsh.label} not found (${runtimeStatus.optionalPwsh.hint}).`,
1716
+ );
1717
+ }
1718
+ }
1719
+ check(
1720
+ "GitHub CLI (gh)",
1721
+ commandExists("gh"),
1722
+ "Recommended: https://cli.github.com/",
1723
+ );
1724
+ const hasVk = check(
1725
+ "Vibe-Kanban CLI",
1726
+ commandExists("vibe-kanban") || bundledBinExists("vibe-kanban"),
1727
+ "Bundled with @virtengine/bosun as a dependency",
1728
+ );
1729
+
1730
+ if (!hasVk) {
1731
+ warn(
1732
+ "vibe-kanban not found. This is bundled with bosun, so this is unexpected.",
1733
+ );
1734
+ info("Try reinstalling:");
1735
+ console.log(" npm uninstall -g @virtengine/bosun");
1736
+ console.log(" npm install -g @virtengine/bosun\n");
1737
+ }
1738
+
1739
+ if (!hasNode) {
1740
+ console.error("\n Node.js 18+ is required. Aborting.\n");
1741
+ process.exit(1);
1742
+ }
1743
+
1744
+ const repoRoot = detectRepoRoot();
1745
+ const configDir = resolveConfigDir(repoRoot);
1746
+ const slug = detectRepoSlug();
1747
+ const projectName = detectProjectName(repoRoot);
1748
+ const envCandidates = [resolve(configDir, ".env"), resolve(repoRoot, ".env")];
1749
+ const seenEnvPaths = new Set();
1750
+ let detectedEnv = false;
1751
+ let loadedEnvEntries = 0;
1752
+ for (const envPath of envCandidates) {
1753
+ if (seenEnvPaths.has(envPath)) continue;
1754
+ seenEnvPaths.add(envPath);
1755
+ const applied = applyEnvFileToProcess(envPath, { override: false });
1756
+ if (applied.found) {
1757
+ detectedEnv = true;
1758
+ loadedEnvEntries += applied.loaded;
1759
+ }
1760
+ }
1761
+ if (detectedEnv) {
1762
+ info(
1763
+ "Detected .env file -> overriding default setting with existing config",
1764
+ );
1765
+ info(
1766
+ `Loaded ${loadedEnvEntries} value(s) from existing environment file(s).`,
1767
+ );
1768
+ }
1769
+
1770
+ const env = {};
1771
+ const configJson = {
1772
+ projectName,
1773
+ executors: [],
1774
+ failover: {},
1775
+ distribution: "weighted",
1776
+ repositories: [],
1777
+ agentPrompts: {},
1778
+ };
1779
+
1780
+ env.REPO_ROOT = process.env.REPO_ROOT || repoRoot;
1781
+
1782
+ if (isNonInteractive) {
1783
+ return runNonInteractive({
1784
+ env,
1785
+ configJson,
1786
+ repoRoot,
1787
+ slug,
1788
+ projectName,
1789
+ configDir,
1790
+ });
1791
+ }
1792
+
1793
+ const prompt = createPrompt();
1794
+
1795
+ try {
1796
+ // ── Step 2: Setup Mode + Project Identity ─────────────
1797
+ heading("Step 2 of 9 — Setup Mode & Project Identity");
1798
+ const setupProfileIdx = await prompt.choose(
1799
+ "How much setup detail do you want?",
1800
+ SETUP_PROFILES.map((profile) => profile.label),
1801
+ 0,
1802
+ );
1803
+ const setupProfile = SETUP_PROFILES[setupProfileIdx]?.key || "recommended";
1804
+ const isAdvancedSetup = setupProfile === "advanced";
1805
+ info(
1806
+ isAdvancedSetup
1807
+ ? "Advanced mode enabled — all sections will prompt for detailed overrides."
1808
+ : "Recommended mode enabled — only key decisions are prompted; safe defaults fill the rest.",
1809
+ );
1810
+
1811
+ env.PROJECT_NAME = await prompt.ask("Project name", projectName);
1812
+ env.GITHUB_REPO = await prompt.ask(
1813
+ "GitHub repo slug (org/repo)",
1814
+ process.env.GITHUB_REPO || slug || "",
1815
+ );
1816
+ configJson.projectName = env.PROJECT_NAME;
1817
+
1818
+ // ── Step 3: Repository ─────────────────────────────────
1819
+ heading("Step 3 of 9 — Repository Configuration");
1820
+ const multiRepo = isAdvancedSetup
1821
+ ? await prompt.confirm(
1822
+ "Do you have multiple repositories (e.g. separate backend/frontend)?",
1823
+ false,
1824
+ )
1825
+ : false;
1826
+
1827
+ if (multiRepo) {
1828
+ info("Configure each repository. The first is the primary.\n");
1829
+ let addMore = true;
1830
+ let repoIdx = 0;
1831
+ while (addMore) {
1832
+ const repoName = await prompt.ask(
1833
+ ` Repo ${repoIdx + 1} — name`,
1834
+ repoIdx === 0 ? basename(repoRoot) : "",
1835
+ );
1836
+ const repoPath = await prompt.ask(
1837
+ ` Repo ${repoIdx + 1} — local path`,
1838
+ repoIdx === 0 ? repoRoot : "",
1839
+ );
1840
+ const repoSlug = await prompt.ask(
1841
+ ` Repo ${repoIdx + 1} — GitHub slug`,
1842
+ repoIdx === 0 ? env.GITHUB_REPO : "",
1843
+ );
1844
+ configJson.repositories.push({
1845
+ name: repoName,
1846
+ path: repoPath,
1847
+ slug: repoSlug,
1848
+ primary: repoIdx === 0,
1849
+ });
1850
+ repoIdx++;
1851
+ addMore = await prompt.confirm("Add another repository?", false);
1852
+ }
1853
+ } else {
1854
+ // Single-repo: omit path — config.mjs auto-detects via git
1855
+ configJson.repositories.push({
1856
+ name: basename(repoRoot),
1857
+ slug: env.GITHUB_REPO,
1858
+ primary: true,
1859
+ });
1860
+ if (!isAdvancedSetup) {
1861
+ info(
1862
+ "Using single-repo defaults (recommended mode). Re-run setup in Advanced mode for multi-repo config.",
1863
+ );
1864
+ }
1865
+ }
1866
+
1867
+ // ── Step 4: Executor Configuration ─────────────────────
1868
+ heading("Step 4 of 9 — Executor / Agent Configuration");
1869
+ console.log(" Executors are the AI agents that work on tasks.\n");
1870
+
1871
+ const presetOptions = isAdvancedSetup
1872
+ ? [
1873
+ "Codex only",
1874
+ "Copilot + Codex (50/50 split)",
1875
+ "Copilot only (Claude Opus 4.6)",
1876
+ "Triple (Copilot Claude 40%, Codex 35%, Copilot GPT 25%)",
1877
+ "Custom — I'll define my own executors",
1878
+ ]
1879
+ : [
1880
+ "Codex only",
1881
+ "Copilot + Codex (50/50 split)",
1882
+ "Copilot only (Claude Opus 4.6)",
1883
+ "Triple (Copilot Claude 40%, Codex 35%, Copilot GPT 25%)",
1884
+ ];
1885
+
1886
+ const presetIdx = await prompt.choose(
1887
+ "Select executor preset:",
1888
+ presetOptions,
1889
+ 0,
1890
+ );
1891
+
1892
+ const presetNames = isAdvancedSetup
1893
+ ? ["codex-only", "copilot-codex", "copilot-only", "triple", "custom"]
1894
+ : ["codex-only", "copilot-codex", "copilot-only", "triple"];
1895
+ const presetKey = presetNames[presetIdx] || "codex-only";
1896
+
1897
+ if (presetKey === "custom") {
1898
+ info("Define your executors. Enter empty name to finish.\n");
1899
+ let execIdx = 0;
1900
+ const roles = ["primary", "backup", "tertiary"];
1901
+ while (true) {
1902
+ const eName = await prompt.ask(
1903
+ ` Executor ${execIdx + 1} — name (empty to finish)`,
1904
+ "",
1905
+ );
1906
+ if (!eName) break;
1907
+ const eType = await prompt.ask(" Executor type", "COPILOT");
1908
+ const eVariant = await prompt.ask(" Model variant", "CLAUDE_OPUS_4_6");
1909
+ const eWeight = Number(await prompt.ask(" Weight (1-100)", "50"));
1910
+ configJson.executors.push({
1911
+ name: eName,
1912
+ executor: eType.toUpperCase(),
1913
+ variant: eVariant,
1914
+ weight: eWeight,
1915
+ role: roles[execIdx] || `executor-${execIdx + 1}`,
1916
+ enabled: true,
1917
+ });
1918
+ execIdx++;
1919
+ }
1920
+ } else {
1921
+ configJson.executors = EXECUTOR_PRESETS[presetKey];
1922
+ }
1923
+
1924
+ // Show executor summary
1925
+ console.log("\n Configured executors:");
1926
+ const totalWeight = configJson.executors.reduce((s, e) => s + e.weight, 0);
1927
+ for (const e of configJson.executors) {
1928
+ const pct = Math.round((e.weight / totalWeight) * 100);
1929
+ console.log(
1930
+ ` ${e.role.padEnd(10)} ${e.executor}:${e.variant} — ${pct}%`,
1931
+ );
1932
+ }
1933
+
1934
+ if (isAdvancedSetup) {
1935
+ console.log();
1936
+ console.log(
1937
+ chalk.dim(" What happens when an executor fails repeatedly?"),
1938
+ );
1939
+ console.log();
1940
+
1941
+ const failoverIdx = await prompt.choose(
1942
+ "Select failover strategy:",
1943
+ FAILOVER_STRATEGIES.map((f) => `${f.name} — ${f.desc}`),
1944
+ 0,
1945
+ );
1946
+ configJson.failover = {
1947
+ strategy: FAILOVER_STRATEGIES[failoverIdx].name,
1948
+ maxRetries: Number(
1949
+ await prompt.ask("Max retries before failover", "3"),
1950
+ ),
1951
+ cooldownMinutes: Number(
1952
+ await prompt.ask("Cooldown after disabling executor (minutes)", "5"),
1953
+ ),
1954
+ disableOnConsecutiveFailures: Number(
1955
+ await prompt.ask(
1956
+ "Disable executor after N consecutive failures",
1957
+ "3",
1958
+ ),
1959
+ ),
1960
+ };
1961
+
1962
+ const distIdx = await prompt.choose(
1963
+ "\n Task distribution mode:",
1964
+ DISTRIBUTION_MODES.map((d) => `${d.name} — ${d.desc}`),
1965
+ 0,
1966
+ );
1967
+ configJson.distribution = DISTRIBUTION_MODES[distIdx].name;
1968
+ } else {
1969
+ configJson.failover = {
1970
+ strategy: "next-in-line",
1971
+ maxRetries: 3,
1972
+ cooldownMinutes: 5,
1973
+ disableOnConsecutiveFailures: 3,
1974
+ };
1975
+ configJson.distribution = "weighted";
1976
+ info(
1977
+ "Using recommended routing defaults: weighted distribution, next-in-line failover.",
1978
+ );
1979
+ }
1980
+
1981
+ // ── Step 5: AI Provider ────────────────────────────────
1982
+ heading("Step 5 of 9 — AI / Codex Provider");
1983
+ console.log(
1984
+ " Codex Monitor uses the Codex SDK for crash analysis & autofix.\n",
1985
+ );
1986
+
1987
+ const providerIdx = await prompt.choose(
1988
+ "Select AI provider:",
1989
+ [
1990
+ "OpenAI (default)",
1991
+ "Azure OpenAI",
1992
+ "Local model (Ollama, vLLM, etc.)",
1993
+ "Other OpenAI-compatible endpoint",
1994
+ "None — disable AI features",
1995
+ ],
1996
+ 0,
1997
+ );
1998
+
1999
+ if (providerIdx < 4) {
2000
+ env.OPENAI_API_KEY = await prompt.ask(
2001
+ "API Key",
2002
+ process.env.OPENAI_API_KEY || "",
2003
+ );
2004
+ }
2005
+ if (providerIdx === 1) {
2006
+ env.OPENAI_BASE_URL = await prompt.ask(
2007
+ "Azure endpoint URL",
2008
+ process.env.OPENAI_BASE_URL || "",
2009
+ );
2010
+ env.CODEX_MODEL = await prompt.ask(
2011
+ "Deployment/model name",
2012
+ process.env.CODEX_MODEL || "",
2013
+ );
2014
+ } else if (providerIdx === 2) {
2015
+ env.OPENAI_API_KEY = env.OPENAI_API_KEY || "ollama";
2016
+ env.OPENAI_BASE_URL = await prompt.ask(
2017
+ "Local API URL",
2018
+ "http://localhost:11434/v1",
2019
+ );
2020
+ env.CODEX_MODEL = await prompt.ask("Model name", "codex");
2021
+ } else if (providerIdx === 3) {
2022
+ env.OPENAI_BASE_URL = await prompt.ask("API Base URL", "");
2023
+ env.CODEX_MODEL = await prompt.ask("Model name", "");
2024
+ } else if (providerIdx === 4) {
2025
+ env.CODEX_SDK_DISABLED = "true";
2026
+ }
2027
+
2028
+ if (providerIdx < 4) {
2029
+ const configureProfiles = await prompt.confirm(
2030
+ "Configure model profiles (xl/m) for one-click switching?",
2031
+ true,
2032
+ );
2033
+ if (configureProfiles) {
2034
+ const activeProfileIdx = await prompt.choose(
2035
+ "Default active profile:",
2036
+ ["xl (high quality)", "m (faster/cheaper)"],
2037
+ 0,
2038
+ );
2039
+ env.CODEX_MODEL_PROFILE = activeProfileIdx === 0 ? "xl" : "m";
2040
+ env.CODEX_MODEL_PROFILE_SUBAGENT = activeProfileIdx === 0 ? "m" : "xl";
2041
+
2042
+ env.CODEX_MODEL_PROFILE_XL_MODEL = await prompt.ask(
2043
+ "XL profile model",
2044
+ process.env.CODEX_MODEL_PROFILE_XL_MODEL ||
2045
+ process.env.CODEX_MODEL ||
2046
+ "gpt-5.3-codex",
2047
+ );
2048
+ env.CODEX_MODEL_PROFILE_M_MODEL = await prompt.ask(
2049
+ "M profile model",
2050
+ process.env.CODEX_MODEL_PROFILE_M_MODEL || "gpt-5.1-codex-mini",
2051
+ );
2052
+
2053
+ const providerName =
2054
+ providerIdx === 1 ? "azure" : providerIdx === 3 ? "compatible" : "openai";
2055
+ env.CODEX_MODEL_PROFILE_XL_PROVIDER =
2056
+ process.env.CODEX_MODEL_PROFILE_XL_PROVIDER || providerName;
2057
+ env.CODEX_MODEL_PROFILE_M_PROVIDER =
2058
+ process.env.CODEX_MODEL_PROFILE_M_PROVIDER || providerName;
2059
+
2060
+ if (!env.CODEX_SUBAGENT_MODEL) {
2061
+ env.CODEX_SUBAGENT_MODEL =
2062
+ env.CODEX_MODEL_PROFILE_M_MODEL || "gpt-5.1-codex-mini";
2063
+ }
2064
+ }
2065
+ }
2066
+
2067
+ // ── Step 6: Telegram ──────────────────────────────────
2068
+ heading("Step 6 of 9 — Telegram Notifications");
2069
+ console.log(
2070
+ " The Telegram bot sends real-time notifications and lets you\n" +
2071
+ " control the orchestrator via /status, /tasks, /restart, etc.\n",
2072
+ );
2073
+
2074
+ const wantTelegram = await prompt.confirm(
2075
+ "Set up Telegram notifications?",
2076
+ true,
2077
+ );
2078
+ if (wantTelegram) {
2079
+ // Step 1: Create bot
2080
+ console.log(
2081
+ "\n" +
2082
+ chalk.bold("Step 1: Create Your Bot") +
2083
+ chalk.dim(" (if you haven't already)"),
2084
+ );
2085
+ console.log(
2086
+ " 1. Open Telegram and search for " + chalk.cyan("@BotFather"),
2087
+ );
2088
+ console.log(" 2. Send: " + chalk.cyan("/newbot"));
2089
+ console.log(" 3. Choose a display name (e.g., 'MyProject Monitor')");
2090
+ console.log(
2091
+ " 4. Choose a username ending in 'bot' (e.g., 'myproject_monitor_bot')",
2092
+ );
2093
+ console.log(" 5. Copy the bot token BotFather gives you");
2094
+ console.log();
2095
+
2096
+ const hasBotReady = await prompt.confirm(
2097
+ "Have you created your bot and have the token ready?",
2098
+ false,
2099
+ );
2100
+
2101
+ if (!hasBotReady) {
2102
+ warn("No problem! You can set up Telegram later by:");
2103
+ console.log(" 1. Adding TELEGRAM_BOT_TOKEN to .env");
2104
+ console.log(" 2. Adding TELEGRAM_CHAT_ID to .env");
2105
+ console.log(" 3. Or re-running: bosun --setup");
2106
+ console.log();
2107
+ } else {
2108
+ // Step 2: Get bot token
2109
+ console.log("\n" + chalk.bold("Step 2: Enter Your Bot Token"));
2110
+ console.log(
2111
+ chalk.dim(
2112
+ " Looks like: 1234567890:ABCdefGHIjklMNOpqrsTUVwxyz-1234567890",
2113
+ ),
2114
+ );
2115
+ console.log();
2116
+
2117
+ env.TELEGRAM_BOT_TOKEN = await prompt.ask(
2118
+ "Bot Token",
2119
+ process.env.TELEGRAM_BOT_TOKEN || "",
2120
+ );
2121
+
2122
+ if (env.TELEGRAM_BOT_TOKEN && env.TELEGRAM_BOT_TOKEN.length > 20) {
2123
+ // Validate token format
2124
+ const tokenValid = /^\d+:[A-Za-z0-9_-]+$/.test(
2125
+ env.TELEGRAM_BOT_TOKEN,
2126
+ );
2127
+ if (!tokenValid) {
2128
+ warn(
2129
+ "Token format looks incorrect. Make sure you copied the full token from BotFather.",
2130
+ );
2131
+ } else {
2132
+ info("✓ Token format looks good");
2133
+ }
2134
+
2135
+ // Step 3: Get chat ID
2136
+ console.log("\n" + chalk.bold("Step 3: Get Your Chat ID"));
2137
+ console.log(" Your chat ID tells the bot where to send messages.");
2138
+ console.log();
2139
+
2140
+ const knowsChatId = await prompt.confirm(
2141
+ "Do you already know your chat ID?",
2142
+ false,
2143
+ );
2144
+
2145
+ if (knowsChatId) {
2146
+ env.TELEGRAM_CHAT_ID = await prompt.ask(
2147
+ "Chat ID (numeric, e.g., 123456789)",
2148
+ process.env.TELEGRAM_CHAT_ID || "",
2149
+ );
2150
+ } else {
2151
+ // Guide user to get chat ID
2152
+ console.log("\n" + chalk.cyan("To get your chat ID:") + "\n");
2153
+ console.log(
2154
+ " 1. Open Telegram and search for your bot's username",
2155
+ );
2156
+ console.log(
2157
+ " 2. Click " +
2158
+ chalk.cyan("START") +
2159
+ " or send any message (e.g., 'Hello')",
2160
+ );
2161
+ console.log(" 3. Come back here and we'll detect your chat ID");
2162
+ console.log();
2163
+
2164
+ const ready = await prompt.confirm(
2165
+ "Ready? (I've messaged my bot)",
2166
+ false,
2167
+ );
2168
+
2169
+ if (ready) {
2170
+ // Try to fetch chat ID from Telegram API
2171
+ info("Fetching your chat ID from Telegram...");
2172
+ try {
2173
+ const response = await fetch(
2174
+ `https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/getUpdates`,
2175
+ );
2176
+ const data = await response.json();
2177
+
2178
+ if (data.ok && data.result && data.result.length > 0) {
2179
+ // Find the most recent message
2180
+ const latestMessage = data.result[data.result.length - 1];
2181
+ const chatId = latestMessage?.message?.chat?.id;
2182
+
2183
+ if (chatId) {
2184
+ env.TELEGRAM_CHAT_ID = String(chatId);
2185
+ info(`✓ Found your chat ID: ${chatId}`);
2186
+ console.log();
2187
+ } else {
2188
+ warn(
2189
+ "Couldn't find a chat ID. Make sure you sent a message to your bot.",
2190
+ );
2191
+ env.TELEGRAM_CHAT_ID = await prompt.ask(
2192
+ "Enter chat ID manually",
2193
+ "",
2194
+ );
2195
+ }
2196
+ } else {
2197
+ warn(
2198
+ "No messages found. Make sure you sent a message to your bot first.",
2199
+ );
2200
+ console.log(
2201
+ chalk.dim(
2202
+ " Or run: bosun-chat-id (after starting the bot)",
2203
+ ),
2204
+ );
2205
+ env.TELEGRAM_CHAT_ID = await prompt.ask(
2206
+ "Enter chat ID manually (or leave empty to set up later)",
2207
+ "",
2208
+ );
2209
+ }
2210
+ } catch (err) {
2211
+ warn(`Failed to fetch chat ID: ${err.message}`);
2212
+ console.log(
2213
+ chalk.dim(
2214
+ " You can run: bosun-chat-id (after starting the bot)",
2215
+ ),
2216
+ );
2217
+ env.TELEGRAM_CHAT_ID = await prompt.ask(
2218
+ "Enter chat ID manually (or leave empty to set up later)",
2219
+ "",
2220
+ );
2221
+ }
2222
+ } else {
2223
+ console.log();
2224
+ info("No problem! You can get your chat ID later by:");
2225
+ console.log(
2226
+ " • Running: " +
2227
+ chalk.cyan("bosun-chat-id") +
2228
+ " (after starting bosun)",
2229
+ );
2230
+ console.log(
2231
+ " • Or manually: " +
2232
+ chalk.cyan(
2233
+ "curl 'https://api.telegram.org/bot<TOKEN>/getUpdates'",
2234
+ ),
2235
+ );
2236
+ console.log(" Then add TELEGRAM_CHAT_ID to .env");
2237
+ console.log();
2238
+ }
2239
+ }
2240
+
2241
+ // Step 4: Verify setup
2242
+ if (env.TELEGRAM_CHAT_ID) {
2243
+ console.log("\n" + chalk.bold("Step 4: Test Your Setup"));
2244
+ const testNow = await prompt.confirm(
2245
+ "Send a test message to verify setup?",
2246
+ true,
2247
+ );
2248
+
2249
+ if (testNow) {
2250
+ info("Sending test message...");
2251
+ try {
2252
+ const testMsg =
2253
+ "🤖 *Telegram Bot Test*\n\n" +
2254
+ "Your bosun Telegram bot is configured correctly!\n\n" +
2255
+ `Project: ${env.PROJECT_NAME || configJson.projectName || "Unknown"}\n` +
2256
+ "Try: /status, /tasks, /help";
2257
+
2258
+ const response = await fetch(
2259
+ `https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage`,
2260
+ {
2261
+ method: "POST",
2262
+ headers: { "Content-Type": "application/json" },
2263
+ body: JSON.stringify({
2264
+ chat_id: env.TELEGRAM_CHAT_ID,
2265
+ text: testMsg,
2266
+ parse_mode: "Markdown",
2267
+ }),
2268
+ },
2269
+ );
2270
+
2271
+ const result = await response.json();
2272
+ if (result.ok) {
2273
+ info("✓ Test message sent! Check your Telegram.");
2274
+ } else {
2275
+ warn(
2276
+ `Test message failed: ${result.description || "Unknown error"}`,
2277
+ );
2278
+ }
2279
+ } catch (err) {
2280
+ warn(`Failed to send test message: ${err.message}`);
2281
+ }
2282
+ }
2283
+ }
2284
+ } else {
2285
+ warn(
2286
+ "Bot token is required for Telegram setup. You can add it to .env later.",
2287
+ );
2288
+ }
2289
+ }
2290
+ }
2291
+
2292
+ // ── Step 7: Kanban + Execution ─────────────────────────
2293
+ heading("Step 7 of 9 — Kanban & Execution");
2294
+ const backendDefault = String(
2295
+ process.env.KANBAN_BACKEND || configJson.kanban?.backend || "internal",
2296
+ )
2297
+ .trim()
2298
+ .toLowerCase();
2299
+ const backendIdx = await prompt.choose(
2300
+ "Select task board backend:",
2301
+ [
2302
+ "Internal Store (internal, recommended primary)",
2303
+ "Vibe-Kanban (vk)",
2304
+ "GitHub Issues (github)",
2305
+ "Jira Issues (jira)",
2306
+ ],
2307
+ backendDefault === "vk"
2308
+ ? 1
2309
+ : backendDefault === "github"
2310
+ ? 2
2311
+ : backendDefault === "jira"
2312
+ ? 3
2313
+ : 0,
2314
+ );
2315
+ const selectedKanbanBackend =
2316
+ backendIdx === 1
2317
+ ? "vk"
2318
+ : backendIdx === 2
2319
+ ? "github"
2320
+ : backendIdx === 3
2321
+ ? "jira"
2322
+ : "internal";
2323
+ env.KANBAN_BACKEND = selectedKanbanBackend;
2324
+ const syncPolicyIdx = await prompt.choose(
2325
+ "Select sync policy:",
2326
+ [
2327
+ "Internal primary (recommended) — external is secondary mirror",
2328
+ "Bidirectional (legacy) — external can drive internal status",
2329
+ ],
2330
+ 0,
2331
+ );
2332
+ const selectedSyncPolicy =
2333
+ syncPolicyIdx === 1 ? "bidirectional" : "internal-primary";
2334
+ env.KANBAN_SYNC_POLICY = selectedSyncPolicy;
2335
+ configJson.kanban = {
2336
+ backend: selectedKanbanBackend,
2337
+ syncPolicy: selectedSyncPolicy,
2338
+ };
2339
+
2340
+ const modeDefault = String(
2341
+ process.env.EXECUTOR_MODE || configJson.internalExecutor?.mode || "internal",
2342
+ )
2343
+ .trim()
2344
+ .toLowerCase();
2345
+ const execModeIdx = await prompt.choose(
2346
+ "Select execution mode:",
2347
+ [
2348
+ "Internal executor (recommended)",
2349
+ "VK executor/orchestrator",
2350
+ "Hybrid (internal + VK)",
2351
+ ],
2352
+ selectedKanbanBackend === "internal" ||
2353
+ selectedKanbanBackend === "github" ||
2354
+ selectedKanbanBackend === "jira"
2355
+ ? 0
2356
+ : modeDefault === "hybrid"
2357
+ ? 2
2358
+ : modeDefault === "internal"
2359
+ ? 0
2360
+ : 1,
2361
+ );
2362
+ const selectedExecutorMode =
2363
+ execModeIdx === 0 ? "internal" : execModeIdx === 1 ? "vk" : "hybrid";
2364
+ env.EXECUTOR_MODE = selectedExecutorMode;
2365
+ configJson.internalExecutor = {
2366
+ ...(configJson.internalExecutor || {}),
2367
+ mode: selectedExecutorMode,
2368
+ };
2369
+
2370
+ const requirementsProfileDefault = String(
2371
+ process.env.PROJECT_REQUIREMENTS_PROFILE ||
2372
+ configJson.projectRequirements?.profile ||
2373
+ "feature",
2374
+ )
2375
+ .trim()
2376
+ .toLowerCase();
2377
+ const profileOptions = [
2378
+ "simple-feature",
2379
+ "feature",
2380
+ "large-feature",
2381
+ "system",
2382
+ "multi-system",
2383
+ ];
2384
+ const profileIdx = await prompt.choose(
2385
+ "Project requirements profile:",
2386
+ [
2387
+ "Simple Feature",
2388
+ "Feature",
2389
+ "Large Feature",
2390
+ "System",
2391
+ "Multi-System",
2392
+ ],
2393
+ Math.max(0, profileOptions.indexOf(requirementsProfileDefault)),
2394
+ );
2395
+ env.PROJECT_REQUIREMENTS_PROFILE = profileOptions[profileIdx] || "feature";
2396
+ const requirementsNotes = await prompt.ask(
2397
+ "Requirements notes (optional)",
2398
+ process.env.PROJECT_REQUIREMENTS_NOTES ||
2399
+ configJson.projectRequirements?.notes ||
2400
+ "",
2401
+ );
2402
+ env.PROJECT_REQUIREMENTS_NOTES = requirementsNotes;
2403
+ configJson.projectRequirements = {
2404
+ profile: env.PROJECT_REQUIREMENTS_PROFILE,
2405
+ notes: env.PROJECT_REQUIREMENTS_NOTES,
2406
+ };
2407
+
2408
+ const replenishEnabled = await prompt.confirm(
2409
+ "Enable experimental autonomous backlog replenishment?",
2410
+ false,
2411
+ );
2412
+ env.INTERNAL_EXECUTOR_REPLENISH_ENABLED = replenishEnabled
2413
+ ? "true"
2414
+ : "false";
2415
+ const replenishMin = replenishEnabled
2416
+ ? await prompt.ask(
2417
+ "Minimum new tasks per completed task (1-2)",
2418
+ process.env.INTERNAL_EXECUTOR_REPLENISH_MIN_NEW_TASKS || "1",
2419
+ )
2420
+ : "1";
2421
+ const replenishMax = replenishEnabled
2422
+ ? await prompt.ask(
2423
+ "Maximum new tasks per completed task (1-3)",
2424
+ process.env.INTERNAL_EXECUTOR_REPLENISH_MAX_NEW_TASKS || "2",
2425
+ )
2426
+ : "2";
2427
+ env.INTERNAL_EXECUTOR_REPLENISH_MIN_NEW_TASKS = replenishMin;
2428
+ env.INTERNAL_EXECUTOR_REPLENISH_MAX_NEW_TASKS = replenishMax;
2429
+ configJson.internalExecutor = {
2430
+ ...(configJson.internalExecutor || {}),
2431
+ backlogReplenishment: {
2432
+ enabled: replenishEnabled,
2433
+ minNewTasks: toPositiveInt(replenishMin, 1),
2434
+ maxNewTasks: toPositiveInt(replenishMax, 2),
2435
+ requirePriority: true,
2436
+ },
2437
+ projectRequirements: {
2438
+ profile: env.PROJECT_REQUIREMENTS_PROFILE,
2439
+ notes: env.PROJECT_REQUIREMENTS_NOTES,
2440
+ },
2441
+ };
2442
+
2443
+ const vkNeeded =
2444
+ selectedKanbanBackend === "vk" ||
2445
+ selectedExecutorMode === "vk" ||
2446
+ selectedExecutorMode === "hybrid";
2447
+
2448
+ if (selectedKanbanBackend === "github") {
2449
+ const [slugOwner, slugRepo] = String(slug || "").split("/", 2);
2450
+ env.GITHUB_REPO_OWNER = await prompt.ask(
2451
+ "GitHub owner/org",
2452
+ process.env.GITHUB_REPO_OWNER || slugOwner || "",
2453
+ );
2454
+ env.GITHUB_REPO_NAME = await prompt.ask(
2455
+ "GitHub repository name",
2456
+ process.env.GITHUB_REPO_NAME || slugRepo || basename(repoRoot),
2457
+ );
2458
+ if (env.GITHUB_REPO_OWNER && env.GITHUB_REPO_NAME) {
2459
+ env.GITHUB_REPOSITORY = `${env.GITHUB_REPO_OWNER}/${env.GITHUB_REPO_NAME}`;
2460
+ env.KANBAN_PROJECT_ID = env.GITHUB_REPOSITORY;
2461
+ }
2462
+
2463
+ const githubTaskModeDefault = String(
2464
+ process.env.GITHUB_PROJECT_MODE ||
2465
+ configJson.kanban?.github?.mode ||
2466
+ "kanban",
2467
+ )
2468
+ .trim()
2469
+ .toLowerCase();
2470
+ const githubTaskModeIdx = await prompt.choose(
2471
+ "Use GitHub backend as:",
2472
+ [
2473
+ "GitHub Projects Kanban (default)",
2474
+ "GitHub Issues only (no Projects board)",
2475
+ ],
2476
+ githubTaskModeDefault === "issues" ? 1 : 0,
2477
+ );
2478
+ const githubTaskMode = githubTaskModeIdx === 1 ? "issues" : "kanban";
2479
+ env.GITHUB_PROJECT_MODE = githubTaskMode;
2480
+
2481
+ const detectedLogin = detectGitHubUserLogin(repoRoot);
2482
+ if (!env.GITHUB_DEFAULT_ASSIGNEE) {
2483
+ env.GITHUB_DEFAULT_ASSIGNEE =
2484
+ process.env.GITHUB_DEFAULT_ASSIGNEE ||
2485
+ detectedLogin ||
2486
+ env.GITHUB_REPO_OWNER ||
2487
+ "";
2488
+ }
2489
+
2490
+ const canonicalLabel = "bosun";
2491
+ const existingScopeLabels = String(
2492
+ process.env.BOSUN_TASK_LABELS || "",
2493
+ )
2494
+ .split(",")
2495
+ .map((entry) => entry.trim().toLowerCase())
2496
+ .filter(Boolean);
2497
+ if (!existingScopeLabels.includes(canonicalLabel)) {
2498
+ existingScopeLabels.unshift(canonicalLabel);
2499
+ }
2500
+ if (!existingScopeLabels.includes("codex-mointor")) {
2501
+ existingScopeLabels.push("codex-mointor");
2502
+ }
2503
+ env.BOSUN_TASK_LABEL = canonicalLabel;
2504
+ env.BOSUN_TASK_LABELS = existingScopeLabels.join(",");
2505
+ env.BOSUN_ENFORCE_TASK_LABEL = "true";
2506
+
2507
+ if (githubTaskMode === "kanban") {
2508
+ env.GITHUB_PROJECT_OWNER =
2509
+ process.env.GITHUB_PROJECT_OWNER || env.GITHUB_REPO_OWNER || "";
2510
+ env.GITHUB_PROJECT_TITLE = await prompt.ask(
2511
+ "GitHub Project title",
2512
+ process.env.GITHUB_PROJECT_TITLE ||
2513
+ configJson.kanban?.github?.projectTitle ||
2514
+ "Bosun",
2515
+ );
2516
+ const resolvedProject = resolveOrCreateGitHubProject({
2517
+ owner: env.GITHUB_PROJECT_OWNER,
2518
+ title: env.GITHUB_PROJECT_TITLE,
2519
+ cwd: repoRoot,
2520
+ repoOwner: env.GITHUB_REPO_OWNER,
2521
+ githubLogin: detectedLogin,
2522
+ });
2523
+ if (resolvedProject.number) {
2524
+ env.GITHUB_PROJECT_NUMBER = resolvedProject.number;
2525
+ const linkedOwner = resolvedProject.owner || env.GITHUB_PROJECT_OWNER;
2526
+ if (linkedOwner) {
2527
+ env.GITHUB_PROJECT_OWNER = linkedOwner;
2528
+ }
2529
+ success(
2530
+ `GitHub Project linked: ${env.GITHUB_PROJECT_OWNER}#${resolvedProject.number} (${env.GITHUB_PROJECT_TITLE})`,
2531
+ );
2532
+ } else {
2533
+ const reasonSuffix = resolvedProject.reason
2534
+ ? ` Reason: ${resolvedProject.reason}`
2535
+ : "";
2536
+ warn(
2537
+ `Could not auto-detect/create GitHub Project. Issues will still be created and can be linked later.${reasonSuffix}`,
2538
+ );
2539
+ }
2540
+ }
2541
+
2542
+ configJson.kanban = {
2543
+ backend: selectedKanbanBackend,
2544
+ syncPolicy: selectedSyncPolicy,
2545
+ github: {
2546
+ mode: githubTaskMode,
2547
+ projectTitle: env.GITHUB_PROJECT_TITLE || "Bosun",
2548
+ projectOwner: env.GITHUB_PROJECT_OWNER || env.GITHUB_REPO_OWNER || "",
2549
+ projectNumber: env.GITHUB_PROJECT_NUMBER || "",
2550
+ taskLabel: env.BOSUN_TASK_LABEL || "bosun",
2551
+ },
2552
+ };
2553
+ info(
2554
+ "GitHub backend configured. bosun-scoped issues are auto-assigned/labeled and can be linked to a Projects kanban board.",
2555
+ );
2556
+ }
2557
+
2558
+ if (selectedKanbanBackend === "jira") {
2559
+ const jiraBaseDefault =
2560
+ process.env.JIRA_BASE_URL || configJson.kanban?.jira?.baseUrl || "";
2561
+ const jiraEmailDefault =
2562
+ process.env.JIRA_EMAIL || configJson.kanban?.jira?.email || "";
2563
+ const jiraTokenDefault =
2564
+ process.env.JIRA_API_TOKEN || configJson.kanban?.jira?.apiToken || "";
2565
+ const jiraProjectDefault =
2566
+ process.env.JIRA_PROJECT_KEY || configJson.kanban?.jira?.projectKey || "";
2567
+ const jiraIssueTypeDefault =
2568
+ process.env.JIRA_ISSUE_TYPE || configJson.kanban?.jira?.issueType || "Task";
2569
+
2570
+ env.JIRA_BASE_URL = normalizeBaseUrl(
2571
+ await prompt.ask("Jira site URL", jiraBaseDefault),
2572
+ );
2573
+ if (env.JIRA_BASE_URL) {
2574
+ const openTokenPage = await prompt.confirm(
2575
+ "Open Jira API token page in your browser?",
2576
+ true,
2577
+ );
2578
+ if (openTokenPage) {
2579
+ const opened = openUrlInBrowser(
2580
+ "https://id.atlassian.com/manage-profile/security/api-tokens",
2581
+ );
2582
+ if (!opened) {
2583
+ warn(
2584
+ "Unable to open browser. Visit https://id.atlassian.com/manage-profile/security/api-tokens",
2585
+ );
2586
+ }
2587
+ }
2588
+ }
2589
+
2590
+ env.JIRA_EMAIL = await prompt.ask("Jira account email", jiraEmailDefault);
2591
+ env.JIRA_API_TOKEN = await prompt.ask(
2592
+ "Jira API token",
2593
+ jiraTokenDefault,
2594
+ );
2595
+
2596
+ const hasJiraCreds =
2597
+ Boolean(env.JIRA_BASE_URL) &&
2598
+ Boolean(env.JIRA_EMAIL) &&
2599
+ Boolean(env.JIRA_API_TOKEN);
2600
+
2601
+ let projects = [];
2602
+ if (hasJiraCreds) {
2603
+ const lookupProjects = await prompt.confirm(
2604
+ "Look up Jira projects now?",
2605
+ true,
2606
+ );
2607
+ if (lookupProjects) {
2608
+ try {
2609
+ projects = await listJiraProjects({
2610
+ baseUrl: env.JIRA_BASE_URL,
2611
+ email: env.JIRA_EMAIL,
2612
+ token: env.JIRA_API_TOKEN,
2613
+ });
2614
+ } catch (err) {
2615
+ warn(`Failed to load Jira projects: ${err.message}`);
2616
+ }
2617
+ }
2618
+ }
2619
+
2620
+ const selectProjectKey = async (projectList, fallbackKey) => {
2621
+ if (!Array.isArray(projectList) || projectList.length === 0) {
2622
+ return await prompt.ask("Jira project key", fallbackKey || "");
2623
+ }
2624
+ const filter = await prompt.ask(
2625
+ "Filter Jira projects (optional)",
2626
+ "",
2627
+ );
2628
+ const normalizedFilter = filter.trim().toLowerCase();
2629
+ const filtered = normalizedFilter
2630
+ ? projectList.filter(
2631
+ (project) =>
2632
+ String(project.name || "").toLowerCase().includes(normalizedFilter) ||
2633
+ String(project.key || "").toLowerCase().includes(normalizedFilter),
2634
+ )
2635
+ : projectList;
2636
+ const visible = filtered.slice(0, 20);
2637
+ const options = visible.map(
2638
+ (project) => `${project.name} (${project.key})`,
2639
+ );
2640
+ options.push("Enter project key manually");
2641
+ options.push("Open Jira Projects page");
2642
+ options.push("Create a new Jira project");
2643
+ const choiceIdx = await prompt.choose(
2644
+ "Select Jira project for bosun tasks:",
2645
+ options,
2646
+ 0,
2647
+ );
2648
+ if (choiceIdx < visible.length) {
2649
+ return visible[choiceIdx].key;
2650
+ }
2651
+ if (choiceIdx === visible.length) {
2652
+ return await prompt.ask("Jira project key", fallbackKey || "");
2653
+ }
2654
+ if (choiceIdx === visible.length + 1) {
2655
+ const url = `${env.JIRA_BASE_URL}/jira/projects`;
2656
+ const opened = openUrlInBrowser(url);
2657
+ if (!opened) warn(`Open this URL manually: ${url}`);
2658
+ const requery = hasJiraCreds
2659
+ ? await prompt.confirm("Re-fetch Jira projects now?", true)
2660
+ : false;
2661
+ if (requery) {
2662
+ try {
2663
+ const refreshed = await listJiraProjects({
2664
+ baseUrl: env.JIRA_BASE_URL,
2665
+ email: env.JIRA_EMAIL,
2666
+ token: env.JIRA_API_TOKEN,
2667
+ });
2668
+ return await selectProjectKey(refreshed, fallbackKey);
2669
+ } catch (err) {
2670
+ warn(`Failed to refresh Jira projects: ${err.message}`);
2671
+ }
2672
+ }
2673
+ return await prompt.ask("Jira project key", fallbackKey || "");
2674
+ }
2675
+ const createUrl = `${env.JIRA_BASE_URL}/jira/projects`;
2676
+ const opened = openUrlInBrowser(createUrl);
2677
+ if (!opened) warn(`Open this URL manually: ${createUrl}`);
2678
+ info("Create the project in Jira, then enter the new project key.");
2679
+ const createdKey = await prompt.ask("New Jira project key", "");
2680
+ if (!createdKey) {
2681
+ return await prompt.ask("Jira project key", fallbackKey || "");
2682
+ }
2683
+ if (hasJiraCreds) {
2684
+ const requery = await prompt.confirm(
2685
+ "Re-fetch Jira projects now?",
2686
+ true,
2687
+ );
2688
+ if (requery) {
2689
+ try {
2690
+ const refreshed = await listJiraProjects({
2691
+ baseUrl: env.JIRA_BASE_URL,
2692
+ email: env.JIRA_EMAIL,
2693
+ token: env.JIRA_API_TOKEN,
2694
+ });
2695
+ const match = refreshed.find(
2696
+ (project) =>
2697
+ String(project.key || "").toUpperCase() ===
2698
+ String(createdKey || "").toUpperCase(),
2699
+ );
2700
+ if (match) return match.key;
2701
+ } catch (err) {
2702
+ warn(`Failed to refresh Jira projects: ${err.message}`);
2703
+ }
2704
+ }
2705
+ }
2706
+ return createdKey;
2707
+ };
2708
+
2709
+ env.JIRA_PROJECT_KEY = String(
2710
+ await selectProjectKey(projects, jiraProjectDefault),
2711
+ )
2712
+ .trim()
2713
+ .toUpperCase();
2714
+
2715
+ let jiraIssueType = jiraIssueTypeDefault;
2716
+ if (hasJiraCreds) {
2717
+ const lookupIssueTypes = await prompt.confirm(
2718
+ "Look up Jira issue types now?",
2719
+ isAdvancedSetup,
2720
+ );
2721
+ if (lookupIssueTypes) {
2722
+ try {
2723
+ const issueTypes = await listJiraIssueTypes({
2724
+ baseUrl: env.JIRA_BASE_URL,
2725
+ email: env.JIRA_EMAIL,
2726
+ token: env.JIRA_API_TOKEN,
2727
+ });
2728
+ if (issueTypes.length > 0) {
2729
+ const issueOptions = issueTypes.map((entry) =>
2730
+ entry.subtask ? `${entry.name} (subtask)` : entry.name,
2731
+ );
2732
+ issueOptions.push("Enter issue type manually");
2733
+ const defaultIdx = Math.max(
2734
+ 0,
2735
+ issueOptions.findIndex(
2736
+ (option) =>
2737
+ option.toLowerCase() === jiraIssueType.toLowerCase() ||
2738
+ option
2739
+ .toLowerCase()
2740
+ .startsWith(jiraIssueType.toLowerCase()),
2741
+ ),
2742
+ );
2743
+ const issueIdx = await prompt.choose(
2744
+ "Select default Jira issue type:",
2745
+ issueOptions,
2746
+ defaultIdx,
2747
+ );
2748
+ if (issueIdx < issueTypes.length) {
2749
+ jiraIssueType = issueTypes[issueIdx].name;
2750
+ } else {
2751
+ jiraIssueType = await prompt.ask(
2752
+ "Default Jira issue type",
2753
+ jiraIssueTypeDefault,
2754
+ );
2755
+ }
2756
+ } else {
2757
+ jiraIssueType = await prompt.ask(
2758
+ "Default Jira issue type",
2759
+ jiraIssueTypeDefault,
2760
+ );
2761
+ }
2762
+ } catch (err) {
2763
+ warn(`Failed to load Jira issue types: ${err.message}`);
2764
+ jiraIssueType = await prompt.ask(
2765
+ "Default Jira issue type",
2766
+ jiraIssueTypeDefault,
2767
+ );
2768
+ }
2769
+ } else {
2770
+ jiraIssueType = await prompt.ask(
2771
+ "Default Jira issue type",
2772
+ jiraIssueTypeDefault,
2773
+ );
2774
+ }
2775
+ } else {
2776
+ jiraIssueType = await prompt.ask(
2777
+ "Default Jira issue type",
2778
+ jiraIssueTypeDefault,
2779
+ );
2780
+ }
2781
+ env.JIRA_ISSUE_TYPE = jiraIssueType;
2782
+
2783
+ if (isSubtaskIssueType(env.JIRA_ISSUE_TYPE)) {
2784
+ env.JIRA_SUBTASK_PARENT_KEY = await prompt.ask(
2785
+ "Parent issue key for subtasks",
2786
+ process.env.JIRA_SUBTASK_PARENT_KEY || "",
2787
+ );
2788
+ }
2789
+
2790
+ const canonicalLabel = "bosun";
2791
+ const jiraScopeLabels = String(
2792
+ process.env.JIRA_TASK_LABELS ||
2793
+ process.env.BOSUN_TASK_LABELS ||
2794
+ "",
2795
+ )
2796
+ .split(",")
2797
+ .map((entry) => entry.trim().toLowerCase())
2798
+ .filter(Boolean);
2799
+ if (!jiraScopeLabels.includes(canonicalLabel)) {
2800
+ jiraScopeLabels.unshift(canonicalLabel);
2801
+ }
2802
+ if (!jiraScopeLabels.includes("codex-mointor")) {
2803
+ jiraScopeLabels.push("codex-mointor");
2804
+ }
2805
+ env.BOSUN_TASK_LABEL = canonicalLabel;
2806
+ env.BOSUN_TASK_LABELS = jiraScopeLabels.join(",");
2807
+ env.BOSUN_ENFORCE_TASK_LABEL = "true";
2808
+ env.JIRA_TASK_LABELS = env.BOSUN_TASK_LABELS;
2809
+ env.JIRA_ENFORCE_TASK_LABEL = "true";
2810
+
2811
+ if (hasJiraCreds) {
2812
+ const wantsAssignee = await prompt.confirm(
2813
+ "Set a default Jira assignee for new tasks?",
2814
+ false,
2815
+ );
2816
+ if (wantsAssignee) {
2817
+ const query = await prompt.ask(
2818
+ "Search users by name/email (optional)",
2819
+ "",
2820
+ );
2821
+ let selectedAccountId = "";
2822
+ if (query) {
2823
+ try {
2824
+ const users = await searchJiraUsers({
2825
+ baseUrl: env.JIRA_BASE_URL,
2826
+ email: env.JIRA_EMAIL,
2827
+ token: env.JIRA_API_TOKEN,
2828
+ query,
2829
+ });
2830
+ if (users.length > 0) {
2831
+ const userOptions = users.map((user) => {
2832
+ const emailSuffix = user.emailAddress
2833
+ ? ` <${user.emailAddress}>`
2834
+ : "";
2835
+ return `${user.displayName}${emailSuffix} (${user.accountId})`;
2836
+ });
2837
+ userOptions.push("Enter account ID manually");
2838
+ const userIdx = await prompt.choose(
2839
+ "Select default Jira assignee:",
2840
+ userOptions,
2841
+ 0,
2842
+ );
2843
+ if (userIdx < users.length) {
2844
+ selectedAccountId = users[userIdx].accountId;
2845
+ }
2846
+ } else {
2847
+ warn("No Jira users matched that search.");
2848
+ }
2849
+ } catch (err) {
2850
+ warn(`Failed to search Jira users: ${err.message}`);
2851
+ }
2852
+ }
2853
+ if (!selectedAccountId) {
2854
+ selectedAccountId = await prompt.ask(
2855
+ "Default assignee account ID",
2856
+ process.env.JIRA_DEFAULT_ASSIGNEE || "",
2857
+ );
2858
+ }
2859
+ env.JIRA_DEFAULT_ASSIGNEE = selectedAccountId;
2860
+ }
2861
+ }
2862
+
2863
+ if (isAdvancedSetup) {
2864
+ env.JIRA_STATUS_TODO = await prompt.ask(
2865
+ "Jira status for TODO",
2866
+ process.env.JIRA_STATUS_TODO ||
2867
+ configJson.kanban?.jira?.statusMapping?.todo ||
2868
+ "To Do",
2869
+ );
2870
+ env.JIRA_STATUS_INPROGRESS = await prompt.ask(
2871
+ "Jira status for IN PROGRESS",
2872
+ process.env.JIRA_STATUS_INPROGRESS ||
2873
+ configJson.kanban?.jira?.statusMapping?.inprogress ||
2874
+ "In Progress",
2875
+ );
2876
+ env.JIRA_STATUS_INREVIEW = await prompt.ask(
2877
+ "Jira status for IN REVIEW",
2878
+ process.env.JIRA_STATUS_INREVIEW ||
2879
+ configJson.kanban?.jira?.statusMapping?.inreview ||
2880
+ "In Review",
2881
+ );
2882
+ env.JIRA_STATUS_DONE = await prompt.ask(
2883
+ "Jira status for DONE",
2884
+ process.env.JIRA_STATUS_DONE ||
2885
+ configJson.kanban?.jira?.statusMapping?.done ||
2886
+ "Done",
2887
+ );
2888
+ env.JIRA_STATUS_CANCELLED = await prompt.ask(
2889
+ "Jira status for CANCELLED",
2890
+ process.env.JIRA_STATUS_CANCELLED ||
2891
+ configJson.kanban?.jira?.statusMapping?.cancelled ||
2892
+ "Cancelled",
2893
+ );
2894
+ }
2895
+
2896
+ const configureSharedState = await prompt.confirm(
2897
+ "Configure Jira shared-state fields now?",
2898
+ isAdvancedSetup,
2899
+ );
2900
+ if (configureSharedState && hasJiraCreds) {
2901
+ let jiraFields = [];
2902
+ try {
2903
+ jiraFields = await listJiraFields({
2904
+ baseUrl: env.JIRA_BASE_URL,
2905
+ email: env.JIRA_EMAIL,
2906
+ token: env.JIRA_API_TOKEN,
2907
+ });
2908
+ } catch (err) {
2909
+ warn(`Failed to load Jira fields: ${err.message}`);
2910
+ }
2911
+ if (jiraFields.length === 0) {
2912
+ const openFields = await prompt.confirm(
2913
+ "Open Jira custom fields page in your browser?",
2914
+ false,
2915
+ );
2916
+ if (openFields) {
2917
+ const url = `${env.JIRA_BASE_URL}/jira/settings/issues/fields`;
2918
+ const opened = openUrlInBrowser(url);
2919
+ if (!opened) warn(`Open this URL manually: ${url}`);
2920
+ }
2921
+ }
2922
+
2923
+ const selectFieldId = async (label, fallbackValue) => {
2924
+ if (!jiraFields.length) {
2925
+ return await prompt.ask(`${label} field id`, fallbackValue || "");
2926
+ }
2927
+ const filter = await prompt.ask(
2928
+ `Filter fields for ${label} (optional)`,
2929
+ "",
2930
+ );
2931
+ const normalized = filter.trim().toLowerCase();
2932
+ const filtered = normalized
2933
+ ? jiraFields.filter((field) =>
2934
+ String(field.name || "")
2935
+ .toLowerCase()
2936
+ .includes(normalized),
2937
+ )
2938
+ : jiraFields;
2939
+ const visible = filtered.slice(0, 20);
2940
+ const options = visible.map(
2941
+ (field) => `${field.name} (${field.id})`,
2942
+ );
2943
+ options.push("Enter field id manually");
2944
+ options.push("Skip");
2945
+ const choiceIdx = await prompt.choose(
2946
+ `Select Jira field for ${label}:`,
2947
+ options,
2948
+ 0,
2949
+ );
2950
+ if (choiceIdx < visible.length) return visible[choiceIdx].id;
2951
+ if (choiceIdx === visible.length) {
2952
+ return await prompt.ask(`${label} field id`, fallbackValue || "");
2953
+ }
2954
+ return "";
2955
+ };
2956
+
2957
+ const storageModeIdx = await prompt.choose(
2958
+ "Shared-state storage mode:",
2959
+ [
2960
+ "Single JSON custom field (recommended)",
2961
+ "Multiple custom fields (advanced)",
2962
+ "Comments only (no custom fields)",
2963
+ ],
2964
+ 0,
2965
+ );
2966
+ if (storageModeIdx === 0) {
2967
+ env.JIRA_CUSTOM_FIELD_SHARED_STATE = await selectFieldId(
2968
+ "shared state JSON",
2969
+ process.env.JIRA_CUSTOM_FIELD_SHARED_STATE || "",
2970
+ );
2971
+ } else if (storageModeIdx === 1) {
2972
+ env.JIRA_CUSTOM_FIELD_OWNER_ID = await selectFieldId(
2973
+ "ownerId",
2974
+ process.env.JIRA_CUSTOM_FIELD_OWNER_ID || "",
2975
+ );
2976
+ env.JIRA_CUSTOM_FIELD_ATTEMPT_TOKEN = await selectFieldId(
2977
+ "attemptToken",
2978
+ process.env.JIRA_CUSTOM_FIELD_ATTEMPT_TOKEN || "",
2979
+ );
2980
+ env.JIRA_CUSTOM_FIELD_ATTEMPT_STARTED = await selectFieldId(
2981
+ "attemptStarted",
2982
+ process.env.JIRA_CUSTOM_FIELD_ATTEMPT_STARTED || "",
2983
+ );
2984
+ env.JIRA_CUSTOM_FIELD_HEARTBEAT = await selectFieldId(
2985
+ "heartbeat",
2986
+ process.env.JIRA_CUSTOM_FIELD_HEARTBEAT || "",
2987
+ );
2988
+ env.JIRA_CUSTOM_FIELD_RETRY_COUNT = await selectFieldId(
2989
+ "retryCount",
2990
+ process.env.JIRA_CUSTOM_FIELD_RETRY_COUNT || "",
2991
+ );
2992
+ env.JIRA_CUSTOM_FIELD_IGNORE_REASON = await selectFieldId(
2993
+ "ignoreReason",
2994
+ process.env.JIRA_CUSTOM_FIELD_IGNORE_REASON || "",
2995
+ );
2996
+ } else {
2997
+ info(
2998
+ "Shared-state will be stored in Jira comments and labels only.",
2999
+ );
3000
+ }
3001
+ }
3002
+
3003
+ configJson.kanban = {
3004
+ backend: selectedKanbanBackend,
3005
+ syncPolicy: selectedSyncPolicy,
3006
+ jira: {
3007
+ baseUrl: env.JIRA_BASE_URL,
3008
+ email: env.JIRA_EMAIL,
3009
+ projectKey: env.JIRA_PROJECT_KEY,
3010
+ issueType: env.JIRA_ISSUE_TYPE || "Task",
3011
+ },
3012
+ };
3013
+ success("Jira backend configured.");
3014
+ }
3015
+
3016
+ if (vkNeeded) {
3017
+ if (isAdvancedSetup) {
3018
+ env.VK_BASE_URL = await prompt.ask(
3019
+ "VK API URL",
3020
+ process.env.VK_BASE_URL || "http://127.0.0.1:54089",
3021
+ );
3022
+ env.VK_RECOVERY_PORT = await prompt.ask(
3023
+ "VK port",
3024
+ process.env.VK_RECOVERY_PORT || "54089",
3025
+ );
3026
+ } else {
3027
+ env.VK_BASE_URL = process.env.VK_BASE_URL || "http://127.0.0.1:54089";
3028
+ env.VK_RECOVERY_PORT = process.env.VK_RECOVERY_PORT || "54089";
3029
+ }
3030
+ const spawnVk = await prompt.confirm(
3031
+ "Auto-spawn vibe-kanban if not running?",
3032
+ true,
3033
+ );
3034
+ if (!spawnVk) env.VK_NO_SPAWN = "true";
3035
+ } else {
3036
+ env.VK_NO_SPAWN = "true";
3037
+ info("VK runtime disabled (not selected as board or executor).");
3038
+ }
3039
+
3040
+ // ── Codex CLI Config (config.toml) ─────────────────────
3041
+ heading("Codex CLI Config");
3042
+ console.log(chalk.dim(" ~/.codex/config.toml — agent-level config\n"));
3043
+
3044
+ const existingToml = readCodexConfig();
3045
+ const configTomlPath = getConfigPath();
3046
+
3047
+ if (!existingToml) {
3048
+ info(
3049
+ "No Codex CLI config found. Will create one with recommended settings.",
3050
+ );
3051
+ } else {
3052
+ info(`Found existing config: ${configTomlPath}`);
3053
+ }
3054
+
3055
+ // Check vibe-kanban MCP
3056
+ if (vkNeeded) {
3057
+ if (existingToml && hasVibeKanbanMcp(existingToml)) {
3058
+ info("Vibe-Kanban MCP server already configured in config.toml.");
3059
+ const updateVk = await prompt.confirm(
3060
+ "Update VK env vars to match your setup values?",
3061
+ true,
3062
+ );
3063
+ if (!updateVk) {
3064
+ env._SKIP_VK_TOML = "1";
3065
+ }
3066
+ } else {
3067
+ info("Will add Vibe-Kanban MCP server to Codex config for agent use.");
3068
+ }
3069
+ } else {
3070
+ env._SKIP_VK_TOML = "1";
3071
+ info(
3072
+ "Skipping Vibe-Kanban MCP setup (VK not selected as board or executor).",
3073
+ );
3074
+ }
3075
+
3076
+ // Check stream timeouts
3077
+ const timeouts = auditStreamTimeouts(existingToml);
3078
+ const lowTimeouts = timeouts.filter((t) => t.needsUpdate);
3079
+ if (lowTimeouts.length > 0) {
3080
+ for (const t of lowTimeouts) {
3081
+ const label =
3082
+ t.currentValue === null
3083
+ ? "not set"
3084
+ : `${(t.currentValue / 1000).toFixed(0)}s`;
3085
+ warn(
3086
+ `[${t.provider}] stream_idle_timeout_ms is ${label} — too low for complex reasoning.`,
3087
+ );
3088
+ }
3089
+ const fixTimeouts = await prompt.confirm(
3090
+ "Set stream timeouts to 60 minutes (recommended for agentic workloads)?",
3091
+ true,
3092
+ );
3093
+ if (!fixTimeouts) {
3094
+ env._SKIP_TIMEOUT_FIX = "1";
3095
+ }
3096
+ } else if (timeouts.length > 0) {
3097
+ success("Stream timeouts look good across all providers.");
3098
+ }
3099
+
3100
+ // ── Orchestrator ──────────────────────────────────────
3101
+ heading("Orchestrator Script");
3102
+ console.log(
3103
+ chalk.dim(
3104
+ " The orchestrator manages task execution and agent spawning.\n",
3105
+ ),
3106
+ );
3107
+
3108
+ // Check for default scripts in repo first, then package fallback.
3109
+ const { orchestratorDefaults, selectedDefault, orchestratorScriptEnvValue } =
3110
+ resolveSetupOrchestratorDefaults({
3111
+ platform: process.platform,
3112
+ repoRoot,
3113
+ configDir,
3114
+ });
3115
+ const hasDefaultScripts = orchestratorDefaults.variants.length > 0;
3116
+
3117
+ if (hasDefaultScripts) {
3118
+ info(`Found default orchestrator scripts in bosun:`);
3119
+ for (const variant of orchestratorDefaults.variants) {
3120
+ const preferredTag =
3121
+ variant.ext === orchestratorDefaults.preferredExt ? " (preferred)" : "";
3122
+ info(
3123
+ ` - ve-orchestrator.${variant.ext} + ve-kanban.${variant.ext}${preferredTag}`,
3124
+ );
3125
+ }
3126
+
3127
+ const useDefault = isAdvancedSetup
3128
+ ? await prompt.confirm(
3129
+ `Use the default ${basename(selectedDefault.orchestratorPath)} script?`,
3130
+ true,
3131
+ )
3132
+ : true;
3133
+
3134
+ if (useDefault) {
3135
+ env.ORCHESTRATOR_SCRIPT = orchestratorScriptEnvValue;
3136
+ success(`Using default ${basename(selectedDefault.orchestratorPath)}`);
3137
+ } else {
3138
+ const customPath = await prompt.ask(
3139
+ "Path to your custom orchestrator script (or leave blank for Vibe-Kanban direct mode)",
3140
+ "",
3141
+ );
3142
+ if (customPath) {
3143
+ env.ORCHESTRATOR_SCRIPT = customPath;
3144
+ } else {
3145
+ info(
3146
+ "No orchestrator script configured. bosun will manage tasks directly via Vibe-Kanban.",
3147
+ );
3148
+ }
3149
+ }
3150
+ } else {
3151
+ const hasOrcScript = isAdvancedSetup
3152
+ ? await prompt.confirm(
3153
+ "Do you have an existing orchestrator script?",
3154
+ false,
3155
+ )
3156
+ : false;
3157
+ if (hasOrcScript) {
3158
+ env.ORCHESTRATOR_SCRIPT = await prompt.ask(
3159
+ "Path to orchestrator script",
3160
+ "",
3161
+ );
3162
+ } else {
3163
+ info(
3164
+ "No orchestrator script configured. bosun will manage tasks directly via Vibe-Kanban.",
3165
+ );
3166
+ }
3167
+ }
3168
+
3169
+ env.MAX_PARALLEL = await prompt.ask(
3170
+ "Max parallel agent slots",
3171
+ process.env.MAX_PARALLEL || "6",
3172
+ );
3173
+
3174
+ // ── Agent Templates ───────────────────────────────────
3175
+ heading("Agent Templates");
3176
+ console.log(
3177
+ chalk.dim(
3178
+ " bosun prompt templates are scaffolded to .bosun/agents and loaded automatically.\n",
3179
+ ),
3180
+ );
3181
+ const generateAgents = isAdvancedSetup
3182
+ ? await prompt.confirm(
3183
+ "Scaffold .bosun/agents prompt files?",
3184
+ true,
3185
+ )
3186
+ : true;
3187
+
3188
+ if (generateAgents) {
3189
+ const promptsResult = ensureAgentPromptWorkspace(repoRoot);
3190
+ const addedGitIgnore = ensureRepoGitIgnoreEntry(
3191
+ repoRoot,
3192
+ "/.bosun/",
3193
+ );
3194
+ configJson.agentPrompts = getDefaultPromptOverrides();
3195
+
3196
+ if (addedGitIgnore) {
3197
+ success("Updated .gitignore with '/.bosun/'");
3198
+ }
3199
+ if (promptsResult.written.length > 0) {
3200
+ success(
3201
+ `Created ${promptsResult.written.length} prompt template file(s) in ${relative(repoRoot, promptsResult.workspaceDir)}`,
3202
+ );
3203
+ } else {
3204
+ info("Prompt templates already exist — keeping existing files");
3205
+ }
3206
+
3207
+ // Optional AGENTS.md seed
3208
+ const agentsMdPath = resolve(repoRoot, "AGENTS.md");
3209
+ if (!existsSync(agentsMdPath)) {
3210
+ const createAgentsGuide = await prompt.confirm(
3211
+ "Create AGENTS.md guide file as well?",
3212
+ true,
3213
+ );
3214
+ if (createAgentsGuide) {
3215
+ writeFileSync(
3216
+ agentsMdPath,
3217
+ generateAgentsMd(env.PROJECT_NAME, env.GITHUB_REPO),
3218
+ "utf8",
3219
+ );
3220
+ success(`Created ${relative(repoRoot, agentsMdPath)}`);
3221
+ }
3222
+ } else {
3223
+ info("AGENTS.md already exists — leaving unchanged");
3224
+ }
3225
+ } else {
3226
+ configJson.agentPrompts = getDefaultPromptOverrides();
3227
+ }
3228
+
3229
+ // ── Agent Hooks ───────────────────────────────────────
3230
+ heading("Agent Hooks");
3231
+ console.log(
3232
+ chalk.dim(
3233
+ " Configure shared hook policies for Codex, Claude Code, and Copilot CLI.\n",
3234
+ ),
3235
+ );
3236
+
3237
+ const scaffoldHooks = isAdvancedSetup
3238
+ ? await prompt.confirm(
3239
+ "Scaffold hook configs for Codex/Claude/Copilot?",
3240
+ true,
3241
+ )
3242
+ : true;
3243
+
3244
+ if (scaffoldHooks) {
3245
+ const profileMap = ["strict", "balanced", "lightweight", "none"];
3246
+ let profile = "balanced";
3247
+ let targets = ["codex", "claude", "copilot"];
3248
+ let prePushRaw = process.env.BOSUN_HOOK_PREPUSH || "";
3249
+ let preCommitRaw = process.env.BOSUN_HOOK_PRECOMMIT || "";
3250
+ let taskCompleteRaw = process.env.BOSUN_HOOK_TASK_COMPLETE || "";
3251
+ let overwriteHooks = false;
3252
+
3253
+ if (isAdvancedSetup) {
3254
+ const profileIdx = await prompt.choose(
3255
+ "Select hook policy:",
3256
+ [
3257
+ "Strict — pre-commit + pre-push + task validation",
3258
+ "Balanced — pre-push + task validation",
3259
+ "Lightweight — session/audit hooks only (no validation gates)",
3260
+ "None — disable bosun built-in validation hooks",
3261
+ ],
3262
+ 0,
3263
+ );
3264
+ profile = profileMap[profileIdx] || "strict";
3265
+
3266
+ const targetIdx = await prompt.choose(
3267
+ "Hook files to scaffold:",
3268
+ [
3269
+ "All agents (Codex + Claude + Copilot)",
3270
+ "Codex + Claude",
3271
+ "Codex + Copilot",
3272
+ "Codex only",
3273
+ "Custom target list",
3274
+ ],
3275
+ 0,
3276
+ );
3277
+
3278
+ if (targetIdx === 0) targets = ["codex", "claude", "copilot"];
3279
+ else if (targetIdx === 1) targets = ["codex", "claude"];
3280
+ else if (targetIdx === 2) targets = ["codex", "copilot"];
3281
+ else if (targetIdx === 3) targets = ["codex"];
3282
+ else {
3283
+ const customTargets = await prompt.ask(
3284
+ "Custom targets (comma-separated: codex,claude,copilot)",
3285
+ "codex,claude,copilot",
3286
+ );
3287
+ targets = normalizeHookTargets(customTargets);
3288
+ }
3289
+
3290
+ console.log(
3291
+ chalk.dim(
3292
+ " Optional command overrides: use ';;' between commands, or 'none' to disable a hook event.\n",
3293
+ ),
3294
+ );
3295
+
3296
+ prePushRaw = await prompt.ask(
3297
+ "Pre-push command override",
3298
+ process.env.BOSUN_HOOK_PREPUSH || "",
3299
+ );
3300
+ preCommitRaw = await prompt.ask(
3301
+ "Pre-commit command override",
3302
+ process.env.BOSUN_HOOK_PRECOMMIT || "",
3303
+ );
3304
+ taskCompleteRaw = await prompt.ask(
3305
+ "Task-complete command override",
3306
+ process.env.BOSUN_HOOK_TASK_COMPLETE || "",
3307
+ );
3308
+
3309
+ overwriteHooks = await prompt.confirm(
3310
+ "Overwrite existing generated hook files when present?",
3311
+ false,
3312
+ );
3313
+ } else {
3314
+ info(
3315
+ "Using recommended hook defaults: balanced policy for codex, claude, and copilot.",
3316
+ );
3317
+ }
3318
+
3319
+ const hookResult = scaffoldAgentHookFiles(repoRoot, {
3320
+ enabled: true,
3321
+ profile,
3322
+ targets,
3323
+ overwriteExisting: overwriteHooks,
3324
+ commands: {
3325
+ PrePush: parseHookCommandInput(prePushRaw),
3326
+ PreCommit: parseHookCommandInput(preCommitRaw),
3327
+ TaskComplete: parseHookCommandInput(taskCompleteRaw),
3328
+ },
3329
+ });
3330
+
3331
+ printHookScaffoldSummary(hookResult);
3332
+ Object.assign(env, hookResult.env);
3333
+ configJson.hookProfiles = {
3334
+ enabled: true,
3335
+ profile,
3336
+ targets,
3337
+ overwriteExisting: overwriteHooks,
3338
+ };
3339
+ } else {
3340
+ const hookResult = scaffoldAgentHookFiles(repoRoot, { enabled: false });
3341
+ Object.assign(env, hookResult.env);
3342
+ configJson.hookProfiles = {
3343
+ enabled: false,
3344
+ };
3345
+ info("Hook scaffolding skipped by user selection.");
3346
+ }
3347
+
3348
+ // ── VK Auto-Wiring ────────────────────────────────────
3349
+ if (vkNeeded) {
3350
+ heading("Vibe-Kanban Auto-Configuration");
3351
+ const autoWireVk = isAdvancedSetup
3352
+ ? await prompt.confirm(
3353
+ "Auto-configure Vibe-Kanban project, repos, and executor profiles?",
3354
+ true,
3355
+ )
3356
+ : true;
3357
+
3358
+ if (autoWireVk) {
3359
+ const vkConfig = {
3360
+ projectName: env.PROJECT_NAME,
3361
+ repoRoot,
3362
+ monitorDir: __dirname,
3363
+ };
3364
+
3365
+ // Generate VK scripts
3366
+ const setupScript = generateVkSetupScript(vkConfig);
3367
+ const cleanupScript = generateVkCleanupScript(vkConfig);
3368
+
3369
+ // Get current PATH for VK executor profiles
3370
+ const currentPath = process.env.PATH || "";
3371
+
3372
+ // Write to config for VK API auto-wiring
3373
+ configJson.vkAutoConfig = {
3374
+ setupScript,
3375
+ cleanupScript,
3376
+ executorProfiles: configJson.executors.map((e) => ({
3377
+ executor: e.executor,
3378
+ variant: e.variant,
3379
+ environmentVariables: {
3380
+ PATH: currentPath,
3381
+ // Ensure GitHub token is available in workspace
3382
+ GH_TOKEN: "${GH_TOKEN}",
3383
+ GITHUB_TOKEN: "${GITHUB_TOKEN}",
3384
+ },
3385
+ })),
3386
+ };
3387
+
3388
+ info("VK configuration will be applied on first launch.");
3389
+ info("Setup and cleanup scripts generated for your workspace.");
3390
+ info(
3391
+ `PATH environment variable configured for ${configJson.executors.length} executor profile(s)`,
3392
+ );
3393
+ }
3394
+ } else {
3395
+ info("Skipping VK auto-configuration (VK not selected).");
3396
+ delete configJson.vkAutoConfig;
3397
+ }
3398
+
3399
+ // ── Step 8: Optional Channels ─────────────────────────
3400
+ heading("Step 8 of 9 — Optional Channels (WhatsApp & Container)");
3401
+
3402
+ console.log(
3403
+ chalk.dim(
3404
+ " These are optional features. Skip them if you only want Telegram.",
3405
+ ),
3406
+ );
3407
+
3408
+ // WhatsApp
3409
+ const enableWhatsApp = await prompt.confirm(
3410
+ "Enable WhatsApp channel?",
3411
+ false,
3412
+ );
3413
+ if (enableWhatsApp) {
3414
+ env.WHATSAPP_ENABLED = "true";
3415
+ env.WHATSAPP_CHAT_ID = await prompt.ask(
3416
+ "WhatsApp Chat/Group ID (JID)",
3417
+ process.env.WHATSAPP_CHAT_ID || "",
3418
+ );
3419
+ env.WHATSAPP_ASSISTANT_NAME = isAdvancedSetup
3420
+ ? await prompt.ask(
3421
+ "WhatsApp assistant display name",
3422
+ env.PROJECT_NAME || "Codex Monitor",
3423
+ )
3424
+ : env.PROJECT_NAME || "Codex Monitor";
3425
+ info(
3426
+ "Run `bosun --whatsapp-auth` after setup to authenticate with WhatsApp.",
3427
+ );
3428
+ } else {
3429
+ env.WHATSAPP_ENABLED = "false";
3430
+ }
3431
+
3432
+ // Container isolation
3433
+ const enableContainer = await prompt.confirm(
3434
+ "Enable container isolation for agent execution?",
3435
+ false,
3436
+ );
3437
+ if (enableContainer) {
3438
+ env.CONTAINER_ENABLED = "true";
3439
+ if (isAdvancedSetup) {
3440
+ const runtimeIdx = await prompt.choose(
3441
+ "Container runtime",
3442
+ ["docker", "podman", "auto-detect"],
3443
+ 2,
3444
+ );
3445
+ env.CONTAINER_RUNTIME = ["docker", "podman", "auto"][runtimeIdx];
3446
+ env.CONTAINER_IMAGE = await prompt.ask(
3447
+ "Container image",
3448
+ process.env.CONTAINER_IMAGE || "node:22-slim",
3449
+ );
3450
+ env.CONTAINER_MEMORY_LIMIT = await prompt.ask(
3451
+ "Memory limit (e.g. 2g)",
3452
+ process.env.CONTAINER_MEMORY_LIMIT || "4g",
3453
+ );
3454
+ } else {
3455
+ env.CONTAINER_RUNTIME = process.env.CONTAINER_RUNTIME || "auto";
3456
+ env.CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || "node:22-slim";
3457
+ }
3458
+ } else {
3459
+ env.CONTAINER_ENABLED = "false";
3460
+ }
3461
+
3462
+ // ── Step 9: Startup Service ────────────────────────────
3463
+ heading("Step 9 of 9 — Startup Service");
3464
+
3465
+ const { getStartupStatus, getStartupMethodName } =
3466
+ await import("./startup-service.mjs");
3467
+ const currentStartup = getStartupStatus();
3468
+ const methodName = getStartupMethodName();
3469
+
3470
+ if (currentStartup.installed) {
3471
+ info(`Startup service already installed via ${currentStartup.method}.`);
3472
+ const reinstall = await prompt.confirm(
3473
+ "Re-install startup service?",
3474
+ false,
3475
+ );
3476
+ env._STARTUP_SERVICE = reinstall ? "1" : "skip";
3477
+ } else {
3478
+ console.log(
3479
+ chalk.dim(
3480
+ ` Auto-start bosun when you log in using ${methodName}.`,
3481
+ ),
3482
+ );
3483
+ console.log(
3484
+ chalk.dim(
3485
+ " It will run in daemon mode (background) with auto-restart on failure.",
3486
+ ),
3487
+ );
3488
+ const enableStartup = await prompt.confirm(
3489
+ "Enable auto-start on login?",
3490
+ true,
3491
+ );
3492
+ env._STARTUP_SERVICE = enableStartup ? "1" : "0";
3493
+ }
3494
+ } finally {
3495
+ prompt.close();
3496
+ }
3497
+
3498
+ // ── Write Files ─────────────────────────────────────────
3499
+ normalizeSetupConfiguration({ env, configJson, repoRoot, slug, configDir });
3500
+ await writeConfigFiles({ env, configJson, repoRoot, configDir });
3501
+ }
3502
+
3503
+ // ── Non-Interactive Mode ─────────────────────────────────────────────────────
3504
+
3505
+ async function runNonInteractive({
3506
+ env,
3507
+ configJson,
3508
+ repoRoot,
3509
+ slug,
3510
+ projectName,
3511
+ configDir,
3512
+ }) {
3513
+ env.PROJECT_NAME = process.env.PROJECT_NAME || projectName;
3514
+ env.REPO_ROOT = process.env.REPO_ROOT || repoRoot;
3515
+ env.GITHUB_REPO = process.env.GITHUB_REPO || slug || "";
3516
+ env.TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || "";
3517
+ env.TELEGRAM_CHAT_ID = process.env.TELEGRAM_CHAT_ID || "";
3518
+ env.KANBAN_BACKEND = process.env.KANBAN_BACKEND || "internal";
3519
+ env.KANBAN_SYNC_POLICY =
3520
+ process.env.KANBAN_SYNC_POLICY || "internal-primary";
3521
+ env.EXECUTOR_MODE = process.env.EXECUTOR_MODE || "internal";
3522
+ env.PROJECT_REQUIREMENTS_PROFILE =
3523
+ process.env.PROJECT_REQUIREMENTS_PROFILE || "feature";
3524
+ env.PROJECT_REQUIREMENTS_NOTES = process.env.PROJECT_REQUIREMENTS_NOTES || "";
3525
+ env.INTERNAL_EXECUTOR_REPLENISH_ENABLED =
3526
+ process.env.INTERNAL_EXECUTOR_REPLENISH_ENABLED || "false";
3527
+ env.INTERNAL_EXECUTOR_REPLENISH_MIN_NEW_TASKS =
3528
+ process.env.INTERNAL_EXECUTOR_REPLENISH_MIN_NEW_TASKS || "1";
3529
+ env.INTERNAL_EXECUTOR_REPLENISH_MAX_NEW_TASKS =
3530
+ process.env.INTERNAL_EXECUTOR_REPLENISH_MAX_NEW_TASKS || "2";
3531
+ env.VK_BASE_URL = process.env.VK_BASE_URL || "http://127.0.0.1:54089";
3532
+ env.VK_RECOVERY_PORT = process.env.VK_RECOVERY_PORT || "54089";
3533
+ env.GITHUB_REPO_OWNER =
3534
+ process.env.GITHUB_REPO_OWNER || (slug ? String(slug).split("/")[0] : "");
3535
+ env.GITHUB_REPO_NAME =
3536
+ process.env.GITHUB_REPO_NAME || (slug ? String(slug).split("/")[1] : "");
3537
+ env.GITHUB_REPOSITORY =
3538
+ process.env.GITHUB_REPOSITORY ||
3539
+ (env.GITHUB_REPO_OWNER && env.GITHUB_REPO_NAME
3540
+ ? `${env.GITHUB_REPO_OWNER}/${env.GITHUB_REPO_NAME}`
3541
+ : "");
3542
+ if (!env.GITHUB_REPO && env.GITHUB_REPOSITORY) {
3543
+ env.GITHUB_REPO = env.GITHUB_REPOSITORY;
3544
+ }
3545
+ env.OPENAI_API_KEY = process.env.OPENAI_API_KEY || "";
3546
+ env.CODEX_MODEL_PROFILE = process.env.CODEX_MODEL_PROFILE || "xl";
3547
+ env.CODEX_MODEL_PROFILE_SUBAGENT =
3548
+ process.env.CODEX_MODEL_PROFILE_SUBAGENT ||
3549
+ process.env.CODEX_SUBAGENT_PROFILE ||
3550
+ "m";
3551
+ env.CODEX_MODEL_PROFILE_XL_MODEL =
3552
+ process.env.CODEX_MODEL_PROFILE_XL_MODEL || "gpt-5.3-codex";
3553
+ env.CODEX_MODEL_PROFILE_M_MODEL =
3554
+ process.env.CODEX_MODEL_PROFILE_M_MODEL || "gpt-5.1-codex-mini";
3555
+ env.CODEX_MODEL_PROFILE_XL_PROVIDER =
3556
+ process.env.CODEX_MODEL_PROFILE_XL_PROVIDER || "openai";
3557
+ env.CODEX_MODEL_PROFILE_M_PROVIDER =
3558
+ process.env.CODEX_MODEL_PROFILE_M_PROVIDER || "openai";
3559
+ env.CODEX_SUBAGENT_MODEL =
3560
+ process.env.CODEX_SUBAGENT_MODEL || env.CODEX_MODEL_PROFILE_M_MODEL;
3561
+ env.CODEX_AGENT_MAX_THREADS =
3562
+ process.env.CODEX_AGENT_MAX_THREADS ||
3563
+ process.env.CODEX_AGENTS_MAX_THREADS ||
3564
+ "12";
3565
+ env.CODEX_SANDBOX = process.env.CODEX_SANDBOX || "workspace-write";
3566
+ env.MAX_PARALLEL = process.env.MAX_PARALLEL || "6";
3567
+ if (!process.env.ORCHESTRATOR_SCRIPT) {
3568
+ const { orchestratorScriptEnvValue } = resolveSetupOrchestratorDefaults({
3569
+ platform: process.platform,
3570
+ repoRoot,
3571
+ configDir,
3572
+ });
3573
+ if (orchestratorScriptEnvValue) {
3574
+ env.ORCHESTRATOR_SCRIPT = orchestratorScriptEnvValue;
3575
+ }
3576
+ } else {
3577
+ env.ORCHESTRATOR_SCRIPT = process.env.ORCHESTRATOR_SCRIPT;
3578
+ }
3579
+
3580
+ // Optional channels
3581
+ env.WHATSAPP_ENABLED = process.env.WHATSAPP_ENABLED || "false";
3582
+ env.WHATSAPP_CHAT_ID = process.env.WHATSAPP_CHAT_ID || "";
3583
+ env.CONTAINER_ENABLED = process.env.CONTAINER_ENABLED || "false";
3584
+ env.CONTAINER_RUNTIME = process.env.CONTAINER_RUNTIME || "auto";
3585
+
3586
+ // Copilot cloud: disabled by default — set to 0 to allow @copilot PR comments
3587
+ env.COPILOT_CLOUD_DISABLED = process.env.COPILOT_CLOUD_DISABLED || "true";
3588
+ env.COPILOT_NO_EXPERIMENTAL =
3589
+ process.env.COPILOT_NO_EXPERIMENTAL || "false";
3590
+ env.COPILOT_NO_ALLOW_ALL = process.env.COPILOT_NO_ALLOW_ALL || "false";
3591
+ env.COPILOT_ENABLE_ASK_USER =
3592
+ process.env.COPILOT_ENABLE_ASK_USER || "false";
3593
+ env.COPILOT_ENABLE_ALL_GITHUB_MCP_TOOLS =
3594
+ process.env.COPILOT_ENABLE_ALL_GITHUB_MCP_TOOLS || "true";
3595
+ env.COPILOT_AGENT_MAX_REQUESTS =
3596
+ process.env.COPILOT_AGENT_MAX_REQUESTS || "500";
3597
+
3598
+ // Parse EXECUTORS env if set, else use default preset
3599
+ if (process.env.EXECUTORS) {
3600
+ const entries = process.env.EXECUTORS.split(",").map((e) => e.trim());
3601
+ const roles = ["primary", "backup", "tertiary"];
3602
+ for (let i = 0; i < entries.length; i++) {
3603
+ const parts = entries[i].split(":");
3604
+ if (parts.length >= 2) {
3605
+ configJson.executors.push({
3606
+ name: `${parts[0].toLowerCase()}-${parts[1].toLowerCase()}`,
3607
+ executor: parts[0].toUpperCase(),
3608
+ variant: parts[1],
3609
+ weight: parts[2]
3610
+ ? Number(parts[2])
3611
+ : Math.floor(100 / entries.length),
3612
+ role: roles[i] || `executor-${i + 1}`,
3613
+ enabled: true,
3614
+ });
3615
+ }
3616
+ }
3617
+ }
3618
+ if (!configJson.executors.length) {
3619
+ configJson.executors = EXECUTOR_PRESETS["codex-only"];
3620
+ }
3621
+
3622
+ configJson.projectName = env.PROJECT_NAME;
3623
+ configJson.kanban = {
3624
+ backend: env.KANBAN_BACKEND || "internal",
3625
+ syncPolicy: env.KANBAN_SYNC_POLICY || "internal-primary",
3626
+ };
3627
+ configJson.projectRequirements = {
3628
+ profile: env.PROJECT_REQUIREMENTS_PROFILE || "feature",
3629
+ notes: env.PROJECT_REQUIREMENTS_NOTES || "",
3630
+ };
3631
+ configJson.internalExecutor = {
3632
+ ...(configJson.internalExecutor || {}),
3633
+ mode: env.EXECUTOR_MODE || "internal",
3634
+ backlogReplenishment: {
3635
+ enabled:
3636
+ String(env.INTERNAL_EXECUTOR_REPLENISH_ENABLED || "false").toLowerCase() ===
3637
+ "true",
3638
+ minNewTasks: toPositiveInt(env.INTERNAL_EXECUTOR_REPLENISH_MIN_NEW_TASKS, 1),
3639
+ maxNewTasks: toPositiveInt(env.INTERNAL_EXECUTOR_REPLENISH_MAX_NEW_TASKS, 2),
3640
+ requirePriority: true,
3641
+ },
3642
+ projectRequirements: {
3643
+ profile: env.PROJECT_REQUIREMENTS_PROFILE || "feature",
3644
+ notes: env.PROJECT_REQUIREMENTS_NOTES || "",
3645
+ },
3646
+ };
3647
+ configJson.failover = {
3648
+ strategy: process.env.FAILOVER_STRATEGY || "next-in-line",
3649
+ maxRetries: Number(process.env.FAILOVER_MAX_RETRIES || "3"),
3650
+ cooldownMinutes: Number(process.env.FAILOVER_COOLDOWN_MIN || "5"),
3651
+ disableOnConsecutiveFailures: Number(
3652
+ process.env.FAILOVER_DISABLE_AFTER || "3",
3653
+ ),
3654
+ };
3655
+ configJson.distribution = process.env.EXECUTOR_DISTRIBUTION || "weighted";
3656
+ configJson.repositories = [
3657
+ {
3658
+ name: basename(repoRoot),
3659
+ slug: env.GITHUB_REPO,
3660
+ primary: true,
3661
+ },
3662
+ ];
3663
+ configJson.agentPrompts = getDefaultPromptOverrides();
3664
+ ensureAgentPromptWorkspace(repoRoot);
3665
+ ensureRepoGitIgnoreEntry(repoRoot, "/.bosun/");
3666
+
3667
+ const hookOptions = buildHookScaffoldOptionsFromEnv(process.env);
3668
+ const hookResult = scaffoldAgentHookFiles(repoRoot, hookOptions);
3669
+ Object.assign(env, hookResult.env);
3670
+ configJson.hookProfiles = {
3671
+ enabled: hookResult.enabled,
3672
+ profile: hookResult.profile,
3673
+ targets: hookResult.targets,
3674
+ overwriteExisting: Boolean(hookOptions.overwriteExisting),
3675
+ };
3676
+ printHookScaffoldSummary(hookResult);
3677
+
3678
+ // Startup service: respect STARTUP_SERVICE env in non-interactive mode
3679
+ if (parseBooleanEnvValue(process.env.STARTUP_SERVICE, false)) {
3680
+ env._STARTUP_SERVICE = "1";
3681
+ } else if (
3682
+ process.env.STARTUP_SERVICE !== undefined &&
3683
+ !parseBooleanEnvValue(process.env.STARTUP_SERVICE, true)
3684
+ ) {
3685
+ env._STARTUP_SERVICE = "0";
3686
+ }
3687
+ // else: don't set — writeConfigFiles will skip silently
3688
+
3689
+ if (
3690
+ (env.KANBAN_BACKEND || "").toLowerCase() !== "vk" &&
3691
+ !["vk", "hybrid"].includes((env.EXECUTOR_MODE || "").toLowerCase())
3692
+ ) {
3693
+ env.VK_NO_SPAWN = "true";
3694
+ delete configJson.vkAutoConfig;
3695
+ }
3696
+
3697
+ normalizeSetupConfiguration({ env, configJson, repoRoot, slug, configDir });
3698
+ await writeConfigFiles({ env, configJson, repoRoot, configDir });
3699
+ }
3700
+
3701
+ // ── File Writing ─────────────────────────────────────────────────────────────
3702
+
3703
+ async function writeConfigFiles({ env, configJson, repoRoot, configDir }) {
3704
+ heading("Writing Configuration");
3705
+ const targetDir = resolve(configDir || __dirname);
3706
+ mkdirSync(targetDir, { recursive: true });
3707
+ ensureAgentPromptWorkspace(repoRoot);
3708
+ ensureRepoGitIgnoreEntry(repoRoot, "/.bosun/");
3709
+ if (
3710
+ !configJson.agentPrompts ||
3711
+ Object.keys(configJson.agentPrompts).length === 0
3712
+ ) {
3713
+ configJson.agentPrompts = getDefaultPromptOverrides();
3714
+ }
3715
+
3716
+ // ── .env file ──────────────────────────────────────────
3717
+ const envPath = resolve(targetDir, ".env");
3718
+ const targetEnvPath = existsSync(envPath)
3719
+ ? resolve(targetDir, ".env.generated")
3720
+ : envPath;
3721
+
3722
+ if (existsSync(envPath)) {
3723
+ warn(`.env already exists. Writing to .env.generated`);
3724
+ }
3725
+
3726
+ const envTemplatePath = resolve(__dirname, ".env.example");
3727
+ const templateText = existsSync(envTemplatePath)
3728
+ ? readFileSync(envTemplatePath, "utf8")
3729
+ : "";
3730
+
3731
+ const envOut = templateText
3732
+ ? buildStandardizedEnvFile(templateText, env)
3733
+ : buildStandardizedEnvFile("", env);
3734
+
3735
+ writeFileSync(targetEnvPath, envOut, "utf8");
3736
+ success(`Environment written to ${relative(repoRoot, targetEnvPath)}`);
3737
+
3738
+ // ── bosun.config.json ──────────────────────────
3739
+ // Write config with schema reference for editor autocomplete
3740
+ const configOut = { $schema: "./bosun.schema.json", ...configJson };
3741
+ // Keep vkAutoConfig in config file for monitor to apply on first launch
3742
+ // (includes executorProfiles with environment variables like PATH)
3743
+ const configPath = resolve(targetDir, "bosun.config.json");
3744
+ writeFileSync(configPath, JSON.stringify(configOut, null, 2) + "\n", "utf8");
3745
+ success(`Config written to ${relative(repoRoot, configPath)}`);
3746
+
3747
+ // If the setup target directory differs from the package dir but a local .env
3748
+ // exists there without a config file, seed a config copy to avoid mismatches.
3749
+ const packageDir = resolve(__dirname);
3750
+ if (resolve(packageDir) !== resolve(targetDir)) {
3751
+ const packageEnvPath = resolve(packageDir, ".env");
3752
+ const packageConfigPath = resolve(packageDir, "bosun.config.json");
3753
+ if (existsSync(packageEnvPath) && !existsSync(packageConfigPath)) {
3754
+ writeFileSync(
3755
+ packageConfigPath,
3756
+ JSON.stringify(configOut, null, 2) + "\n",
3757
+ "utf8",
3758
+ );
3759
+ success(`Config written to ${relative(repoRoot, packageConfigPath)}`);
3760
+ }
3761
+ }
3762
+
3763
+ // ── Workspace VS Code settings ─────────────────────────
3764
+ const vscodeSettingsResult = writeWorkspaceVsCodeSettings(repoRoot, env);
3765
+ if (vscodeSettingsResult.updated) {
3766
+ success(
3767
+ `Workspace settings updated: ${relative(repoRoot, vscodeSettingsResult.path)}`,
3768
+ );
3769
+ } else if (vscodeSettingsResult.error) {
3770
+ warn(`Could not update workspace settings: ${vscodeSettingsResult.error}`);
3771
+ }
3772
+
3773
+ const copilotMcpResult = writeWorkspaceCopilotMcpConfig(repoRoot);
3774
+ if (copilotMcpResult.updated) {
3775
+ success(
3776
+ `Copilot MCP config updated: ${relative(repoRoot, copilotMcpResult.path)}`,
3777
+ );
3778
+ } else if (copilotMcpResult.error) {
3779
+ warn(`Could not update Copilot MCP config: ${copilotMcpResult.error}`);
3780
+ }
3781
+
3782
+ // ── Codex CLI config.toml ─────────────────────────────
3783
+ heading("Codex CLI Config");
3784
+
3785
+ if (env._SKIP_VK_TOML === "1") {
3786
+ info("Skipped Vibe-Kanban MCP config update.");
3787
+ } else {
3788
+ const vkPort = env.VK_RECOVERY_PORT || "54089";
3789
+ const vkBaseUrl = env.VK_BASE_URL || `http://127.0.0.1:${vkPort}`;
3790
+ const kanbanIsVk =
3791
+ (env.KANBAN_BACKEND || "internal").toLowerCase() === "vk" ||
3792
+ ["vk", "hybrid"].includes((env.EXECUTOR_MODE || "internal").toLowerCase());
3793
+ const tomlResult = ensureCodexConfig({
3794
+ vkBaseUrl,
3795
+ skipVk: !kanbanIsVk,
3796
+ dryRun: false,
3797
+ env: {
3798
+ ...process.env,
3799
+ ...env,
3800
+ },
3801
+ });
3802
+ printConfigSummary(tomlResult, (msg) => console.log(msg));
3803
+ }
3804
+
3805
+ // ── Install dependencies ───────────────────────────────
3806
+ heading("Installing Dependencies");
3807
+ try {
3808
+ if (commandExists("pnpm")) {
3809
+ execSync("pnpm install", { cwd: __dirname, stdio: "inherit" });
3810
+ } else {
3811
+ execSync("npm install", { cwd: __dirname, stdio: "inherit" });
3812
+ }
3813
+ success("Dependencies installed");
3814
+ } catch {
3815
+ warn(
3816
+ "Dependency install failed — run manually: pnpm install (or) npm install",
3817
+ );
3818
+ }
3819
+
3820
+ // ── Startup Service ────────────────────────────────────
3821
+ if (env._STARTUP_SERVICE === "1") {
3822
+ heading("Startup Service");
3823
+ try {
3824
+ const { installStartupService } = await import("./startup-service.mjs");
3825
+ const result = await installStartupService({ daemon: true });
3826
+ if (result.success) {
3827
+ success(`Registered via ${result.method}`);
3828
+ if (result.name) info(`Service name: ${result.name}`);
3829
+ if (result.path) info(`Config path: ${result.path}`);
3830
+ } else {
3831
+ warn(`Could not register startup service: ${result.error}`);
3832
+ info("You can try manually later: bosun --enable-startup");
3833
+ }
3834
+ } catch (err) {
3835
+ warn(`Startup service registration failed: ${err.message}`);
3836
+ info("You can try manually later: bosun --enable-startup");
3837
+ }
3838
+ } else if (env._STARTUP_SERVICE === "0") {
3839
+ info(
3840
+ "Startup service skipped — enable anytime: bosun --enable-startup",
3841
+ );
3842
+ }
3843
+
3844
+ // ── Summary ────────────────────────────────────────────
3845
+ console.log("");
3846
+ console.log(
3847
+ " ╔═══════════════════════════════════════════════════════════════╗",
3848
+ );
3849
+ console.log(
3850
+ " ║ ✅ Setup Complete! ║",
3851
+ );
3852
+ console.log(
3853
+ " ╚═══════════════════════════════════════════════════════════════╝",
3854
+ );
3855
+ console.log("");
3856
+
3857
+ // Executor summary
3858
+ const totalWeight = configJson.executors.reduce((s, e) => s + e.weight, 0);
3859
+ console.log(chalk.bold(" Executors:"));
3860
+ for (const e of configJson.executors) {
3861
+ const pct =
3862
+ totalWeight > 0 ? Math.round((e.weight / totalWeight) * 100) : 0;
3863
+ console.log(
3864
+ ` ${e.role.padEnd(10)} ${e.executor}:${e.variant} — ${pct}%`,
3865
+ );
3866
+ }
3867
+ console.log(
3868
+ chalk.dim(
3869
+ ` Strategy: ${configJson.distribution} distribution, ${configJson.failover.strategy} failover`,
3870
+ ),
3871
+ );
3872
+
3873
+ // Missing items
3874
+ console.log("");
3875
+ if (!env.TELEGRAM_BOT_TOKEN) {
3876
+ info("Telegram not configured — add TELEGRAM_BOT_TOKEN to .env later.");
3877
+ }
3878
+ if (
3879
+ !env.OPENAI_API_KEY &&
3880
+ !env.AZURE_OPENAI_API_KEY &&
3881
+ !env.CODEX_MODEL_PROFILE_XL_API_KEY &&
3882
+ !env.CODEX_MODEL_PROFILE_M_API_KEY &&
3883
+ !parseBooleanEnvValue(env.CODEX_SDK_DISABLED, false)
3884
+ ) {
3885
+ info("No API key set — AI analysis & autofix will be disabled.");
3886
+ }
3887
+
3888
+ console.log("");
3889
+ console.log(chalk.bold(" Next steps:"));
3890
+ console.log("");
3891
+ console.log(chalk.green(" bosun"));
3892
+ console.log(chalk.dim(" Start the orchestrator supervisor\n"));
3893
+ console.log(chalk.green(" bosun --setup"));
3894
+ console.log(chalk.dim(" Re-run this wizard anytime\n"));
3895
+ console.log(chalk.green(" bosun --enable-startup"));
3896
+ console.log(chalk.dim(" Register auto-start on login\n"));
3897
+ console.log(chalk.green(" bosun --help"));
3898
+ console.log(chalk.dim(" See all options & env vars\n"));
3899
+ }
3900
+
3901
+ // ── Auto-Launch Detection ────────────────────────────────────────────────────
3902
+
3903
+ /**
3904
+ * Check whether setup should run automatically (first launch detection).
3905
+ * Called from monitor.mjs before starting the main loop.
3906
+ */
3907
+ export function shouldRunSetup() {
3908
+ // Apply legacy compat so BOSUN_DIR is set before resolveConfigDir is called
3909
+ applyAllCompatibility();
3910
+
3911
+ // If a legacy codex-monitor setup exists and the user hasn't migrated yet,
3912
+ // skip the setup wizard — they are already configured.
3913
+ const legacyInfo = detectLegacySetup();
3914
+ if (legacyInfo.hasLegacy) return false;
3915
+
3916
+ const repoRoot = detectRepoRoot();
3917
+ const configDir = resolveConfigDir(repoRoot);
3918
+ return !hasSetupMarkers(configDir);
3919
+ }
3920
+
3921
+ /**
3922
+ * Run setup wizard. Can be imported and called from monitor.mjs.
3923
+ */
3924
+ export async function runSetup() {
3925
+ await main();
3926
+ }
3927
+
3928
+ export {
3929
+ extractProjectNumber,
3930
+ resolveOrCreateGitHubProjectNumber,
3931
+ resolveOrCreateGitHubProject,
3932
+ runGhCommand,
3933
+ buildRecommendedVsCodeSettings,
3934
+ writeWorkspaceVsCodeSettings,
3935
+ };
3936
+
3937
+ // ── Entry Point ──────────────────────────────────────────────────────────────
3938
+
3939
+ // Only run the wizard when executed directly (not when imported by cli.mjs)
3940
+ const __filename_setup = fileURLToPath(import.meta.url);
3941
+ if (process.argv[1] && resolve(process.argv[1]) === resolve(__filename_setup)) {
3942
+ main().catch((err) => {
3943
+ console.error(`\n Setup failed: ${err.message}\n`);
3944
+ process.exit(1);
3945
+ });
3946
+ }