bosun 0.27.3 → 0.27.5

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.
package/.env.example CHANGED
@@ -105,6 +105,14 @@ TELEGRAM_MINIAPP_ENABLED=false
105
105
  # Tunnel mode control: auto | cloudflared | disabled
106
106
  # TELEGRAM_UI_TUNNEL=auto
107
107
 
108
+ # ─── Desktop Portal ────────────────────────────────────────────────────────
109
+ # Auto-start bosun daemon when the desktop portal launches (default: true)
110
+ # BOSUN_DESKTOP_AUTO_START_DAEMON=true
111
+ # Enable auto-updates for packaged desktop builds (default: false)
112
+ # BOSUN_DESKTOP_AUTO_UPDATE=false
113
+ # Optional auto-update feed URL override
114
+ # BOSUN_DESKTOP_UPDATE_URL=https://updates.example.com/bosun
115
+
108
116
  # ─── Telegram Sentinel (independent watchdog) ──────────────────────────────
109
117
  # Keep Telegram command availability even when bosun is down.
110
118
  # Sentinel can auto-restart monitor, detect crash loops, and run repair-agent.
package/autofix.mjs CHANGED
@@ -328,6 +328,39 @@ async function readSourceContext(filePath, errorLine, contextLines = 30) {
328
328
  */
329
329
  let _devModeCache = null;
330
330
 
331
+ function detectBosunRepo(startDir, maxDepth = 6) {
332
+ let cursor = resolve(startDir);
333
+ for (let i = 0; i < maxDepth; i += 1) {
334
+ const pkgPath = resolve(cursor, "package.json");
335
+ if (existsSync(pkgPath)) {
336
+ try {
337
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
338
+ if (pkg?.name === "bosun" && existsSync(resolve(cursor, "monitor.mjs"))) {
339
+ return true;
340
+ }
341
+ } catch {
342
+ /* ignore */
343
+ }
344
+ }
345
+ const monoPkg = resolve(cursor, "scripts", "bosun", "package.json");
346
+ if (existsSync(monoPkg)) {
347
+ try {
348
+ const pkg = JSON.parse(readFileSync(monoPkg, "utf8"));
349
+ if (
350
+ pkg?.name === "bosun" &&
351
+ existsSync(resolve(cursor, "scripts", "bosun", "monitor.mjs"))
352
+ ) {
353
+ return true;
354
+ }
355
+ } catch {
356
+ /* ignore */
357
+ }
358
+ }
359
+ cursor = resolve(cursor, "..");
360
+ }
361
+ return false;
362
+ }
363
+
331
364
  export function isDevMode() {
332
365
  if (_devModeCache !== null) return _devModeCache;
333
366
 
@@ -341,9 +374,21 @@ export function isDevMode() {
341
374
  return false;
342
375
  }
343
376
 
344
- // Check if we're inside node_modules (npm install)
377
+ // Check if we're inside node_modules (npm install). Ignore Vite/Vitest
378
+ // cache paths so local tests that bundle via node_modules/.vite still
379
+ // fall back to repo detection.
345
380
  const normalized = __dirname.replace(/\\/g, "/").toLowerCase();
346
- if (normalized.includes("/node_modules/")) {
381
+ const inNodeModules = normalized.includes("/node_modules/");
382
+ const inViteCache =
383
+ normalized.includes("/node_modules/.vite/") ||
384
+ normalized.includes("/node_modules/.vitest/") ||
385
+ normalized.includes("/node_modules/.cache/");
386
+ if (inNodeModules) {
387
+ if (inViteCache) {
388
+ const fromCwd = detectBosunRepo(process.cwd());
389
+ _devModeCache = fromCwd;
390
+ return fromCwd;
391
+ }
347
392
  _devModeCache = false;
348
393
  return false;
349
394
  }
package/cli.mjs CHANGED
@@ -70,6 +70,9 @@ function showHelp() {
70
70
  --help Show this help
71
71
  --version Show version
72
72
  --portal, --desktop Launch the Bosun desktop portal (Electron)
73
+ --desktop-shortcut Create a desktop shortcut for the portal
74
+ --desktop-shortcut-remove Remove the desktop shortcut
75
+ --desktop-shortcut-status Show desktop shortcut status
73
76
  --update Check for and install latest version
74
77
  --no-update-check Skip automatic update check on startup
75
78
  --no-auto-update Disable background auto-update polling
@@ -423,9 +426,11 @@ function startDaemon() {
423
426
  /* ok */
424
427
  }
425
428
 
429
+ const runAsNode = process.versions?.electron ? ["--run-as-node"] : [];
426
430
  const child = spawn(
427
431
  process.execPath,
428
432
  [
433
+ ...runAsNode,
429
434
  "--max-old-space-size=4096",
430
435
  fileURLToPath(new URL("./cli.mjs", import.meta.url)),
431
436
  ...process.argv.slice(2).filter((a) => a !== "--daemon" && a !== "-d"),
@@ -536,6 +541,47 @@ async function main() {
536
541
  process.exit(0);
537
542
  }
538
543
 
544
+ // Handle desktop shortcut controls
545
+ if (args.includes("--desktop-shortcut")) {
546
+ const { installDesktopShortcut, getDesktopShortcutMethodName } =
547
+ await import("./desktop-shortcut.mjs");
548
+ const result = installDesktopShortcut();
549
+ if (result.success) {
550
+ console.log(` ✅ Desktop shortcut installed (${result.method})`);
551
+ if (result.path) console.log(` Path: ${result.path}`);
552
+ if (result.name) console.log(` Name: ${result.name}`);
553
+ } else {
554
+ const method = getDesktopShortcutMethodName();
555
+ console.error(
556
+ ` ❌ Failed to install desktop shortcut (${method}): ${result.error}`,
557
+ );
558
+ }
559
+ process.exit(result.success ? 0 : 1);
560
+ }
561
+ if (args.includes("--desktop-shortcut-remove")) {
562
+ const { removeDesktopShortcut } = await import("./desktop-shortcut.mjs");
563
+ const result = removeDesktopShortcut();
564
+ if (result.success) {
565
+ console.log(` ✅ Desktop shortcut removed`);
566
+ } else {
567
+ console.error(
568
+ ` ❌ Failed to remove desktop shortcut: ${result.error}`,
569
+ );
570
+ }
571
+ process.exit(result.success ? 0 : 1);
572
+ }
573
+ if (args.includes("--desktop-shortcut-status")) {
574
+ const { getDesktopShortcutStatus } = await import("./desktop-shortcut.mjs");
575
+ const status = getDesktopShortcutStatus();
576
+ if (status.installed) {
577
+ console.log(` Desktop shortcut: installed (${status.method})`);
578
+ if (status.path) console.log(` Path: ${status.path}`);
579
+ } else {
580
+ console.log(` Desktop shortcut: not installed`);
581
+ }
582
+ process.exit(0);
583
+ }
584
+
539
585
  // Handle --portal / --desktop
540
586
  if (args.includes("--portal") || args.includes("--desktop")) {
541
587
  const launcher = resolve(__dirname, "desktop", "launch.mjs");
package/config.mjs CHANGED
@@ -703,6 +703,48 @@ function loadRepoConfig(configDir, configData = {}, options = {}) {
703
703
  ];
704
704
  }
705
705
 
706
+ function loadWorkspaceRepoConfig(configDir, configData = {}, activeWorkspace = "") {
707
+ const workspaces = Array.isArray(configData.workspaces)
708
+ ? configData.workspaces
709
+ : [];
710
+ if (workspaces.length === 0) return [];
711
+
712
+ const targetWorkspaceId = normalizeKey(activeWorkspace || configData.activeWorkspace || "");
713
+ const targetWorkspace =
714
+ (targetWorkspaceId
715
+ ? workspaces.find((workspace) => normalizeKey(workspace?.id) === targetWorkspaceId)
716
+ : null) ||
717
+ workspaces[0];
718
+
719
+ if (!targetWorkspace || !Array.isArray(targetWorkspace.repos)) {
720
+ return [];
721
+ }
722
+
723
+ const workspacePath = resolve(configDir, "workspaces", targetWorkspace.id);
724
+ const activeRepoName = normalizeKey(targetWorkspace.activeRepo || "");
725
+
726
+ return targetWorkspace.repos
727
+ .map((repo, index) => {
728
+ if (!repo || typeof repo !== "object") return null;
729
+ const name = String(repo.name || repo.id || "").trim();
730
+ if (!name) return null;
731
+ const repoPath = resolve(workspacePath, name);
732
+ return {
733
+ name,
734
+ id: normalizeKey(name),
735
+ path: repoPath,
736
+ slug: String(repo.slug || "").trim(),
737
+ url: String(repo.url || "").trim(),
738
+ workspace: String(targetWorkspace.id || "").trim(),
739
+ primary:
740
+ repo.primary === true ||
741
+ (activeRepoName && normalizeKey(name) === activeRepoName) ||
742
+ (!activeRepoName && index === 0),
743
+ };
744
+ })
745
+ .filter(Boolean);
746
+ }
747
+
706
748
  function loadAgentPrompts(configDir, repoRoot, configData) {
707
749
  const resolved = resolveAgentPrompts(configDir, repoRoot, configData);
708
750
  return { ...resolved.prompts, _sources: resolved.sources };
@@ -741,9 +783,16 @@ export function loadConfig(argv = process.argv, options = {}) {
741
783
  configData.defaultWorkspace ||
742
784
  "";
743
785
 
744
- let repositories = loadRepoConfig(configDir, configData, {
745
- repoRootOverride,
746
- });
786
+ let repositories = loadWorkspaceRepoConfig(
787
+ configDir,
788
+ configData,
789
+ activeWorkspace,
790
+ );
791
+ if (!repositories.length) {
792
+ repositories = loadRepoConfig(configDir, configData, {
793
+ repoRootOverride,
794
+ });
795
+ }
747
796
 
748
797
  const repoSelection =
749
798
  cli["repo-name"] ||
@@ -798,7 +847,14 @@ export function loadConfig(argv = process.argv, options = {}) {
798
847
 
799
848
  // Apply profile overrides (executors, repos, etc.)
800
849
  configData = applyProfileOverrides(configData, profile);
801
- repositories = loadRepoConfig(configDir, configData, { repoRootOverride });
850
+ repositories = loadWorkspaceRepoConfig(
851
+ configDir,
852
+ configData,
853
+ activeWorkspace,
854
+ );
855
+ if (!repositories.length) {
856
+ repositories = loadRepoConfig(configDir, configData, { repoRootOverride });
857
+ }
802
858
  selectedRepository =
803
859
  resolveRepoSelection(
804
860
  repositories,
@@ -0,0 +1,62 @@
1
+ # Bosun Desktop — AGENTS Guide
2
+
3
+ ## Module Overview
4
+ - Purpose: Native desktop shell for the Bosun control center, bundling the UI server and opening it in a desktop window.
5
+ - Use when: Updating desktop packaging, launch flow, auto-update, or native window behavior.
6
+ - Key entry points: `scripts/bosun/desktop/main.mjs:1`, `scripts/bosun/desktop/launch.mjs:1`, `scripts/bosun/ui-server.mjs:1`.
7
+
8
+ ## Architecture
9
+ - The desktop app dynamically imports Bosun’s UI server and runs it locally, then loads the UI with a session token.
10
+ - Packaged builds copy the `scripts/bosun/` runtime into app resources and start from there.
11
+ - Entry points:
12
+ - `main.mjs` starts the UI server and creates the BrowserWindow.
13
+ - `launch.mjs` installs Electron (if needed) and runs the desktop app in dev.
14
+
15
+ ```mermaid
16
+ flowchart TD
17
+ Desktop[desktop/main.mjs] --> UiServer[ui-server.mjs]
18
+ UiServer --> UI[ui/index.html]
19
+ Desktop --> Window[BrowserWindow]
20
+ ```
21
+
22
+ ## Core Concepts
23
+ - Local UI server: desktop app embeds the same UI server used by the Telegram Mini App.
24
+ - Session token: desktop loads `/?token=...` so the UI server sets a session cookie.
25
+ - Packaged runtime: `extraResources` copies the Bosun runtime into app resources.
26
+
27
+ ## Usage Examples
28
+
29
+ ### Launch in dev
30
+ ```bash
31
+ node scripts/bosun/desktop/launch.mjs
32
+ ```
33
+
34
+ ### Build installers
35
+ ```bash
36
+ cd scripts/bosun/desktop
37
+ npm install
38
+ npm run dist
39
+ ```
40
+
41
+ ## Implementation Patterns
42
+ - Always resolve the Bosun runtime root with `resolveBosunRoot()` when packaged.
43
+ - Use dynamic import to load `ui-server.mjs` so packaged resources are used.
44
+ - Keep auto-update behind `BOSUN_DESKTOP_AUTO_UPDATE=1` to avoid noisy failures in dev.
45
+
46
+ ## Configuration
47
+ - Desktop packaging config lives in `scripts/bosun/desktop/package.json:20`.
48
+ - Auto-update is opt-in via `BOSUN_DESKTOP_AUTO_UPDATE=1`.
49
+ - Optional update feed override: `BOSUN_DESKTOP_UPDATE_URL`.
50
+ - To skip Electron auto-install in dev, set `BOSUN_DESKTOP_SKIP_INSTALL=1`.
51
+
52
+ ## Testing
53
+ - Bosun tests: `cd scripts/bosun && npm test`
54
+ - Desktop smoke: `node scripts/bosun/desktop/launch.mjs`
55
+
56
+ ## Troubleshooting
57
+ - Electron missing
58
+ - Cause: Desktop dependencies not installed.
59
+ - Fix: `npm -C scripts/bosun/desktop install` or set `BOSUN_DESKTOP_SKIP_INSTALL=1` to avoid auto-install.
60
+ - UI server fails to start
61
+ - Cause: Port conflict or missing runtime files.
62
+ - Fix: Ensure the packaged `bosun/` runtime exists and retry with a clean start.
@@ -0,0 +1,41 @@
1
+ # Bosun Desktop
2
+
3
+ Bosun Desktop is an Electron shell that launches the Bosun UI server locally
4
+ and opens the portal in a native window.
5
+
6
+ ## Development
7
+ ```bash
8
+ cd scripts/bosun/desktop
9
+ npm install
10
+ npm run start
11
+ ```
12
+
13
+ ## Launch via Bosun CLI
14
+ ```bash
15
+ cd scripts/bosun
16
+ node cli.mjs --desktop
17
+ ```
18
+
19
+ ## Desktop shortcut
20
+ ```bash
21
+ cd scripts/bosun
22
+ node cli.mjs --desktop-shortcut
23
+ ```
24
+
25
+ The shortcut launches the desktop portal and will auto-start the bosun daemon
26
+ if it is not already running.
27
+
28
+ ## Build installers
29
+ ```bash
30
+ cd scripts/bosun/desktop
31
+ npm run dist
32
+ ```
33
+
34
+ ## Auto-update
35
+ - Enable with `BOSUN_DESKTOP_AUTO_UPDATE=1`.
36
+ - Optional feed URL override: `BOSUN_DESKTOP_UPDATE_URL=https://.../`.
37
+
38
+ ## Notes
39
+ - Packaged apps bundle the Bosun runtime under `resources/bosun/`.
40
+ - The UI server runs locally; the desktop app loads `/?token=...` to set the
41
+ session cookie.
package/desktop/main.mjs CHANGED
@@ -1,12 +1,9 @@
1
1
  import { app, BrowserWindow } from "electron";
2
- import { dirname, join } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
- import {
5
- getSessionToken,
6
- getTelegramUiUrl,
7
- startTelegramUiServer,
8
- stopTelegramUiServer,
9
- } from "../ui-server.mjs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { execFileSync, spawn } from "node:child_process";
6
+ import { homedir } from "node:os";
10
7
 
11
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
9
 
@@ -14,24 +11,151 @@ let mainWindow = null;
14
11
  let shuttingDown = false;
15
12
  let uiServerStarted = false;
16
13
  let uiOrigin = null;
14
+ let uiApi = null;
15
+
16
+ const DAEMON_PID_FILE = resolve(homedir(), ".cache", "bosun", "daemon.pid");
17
+
18
+ function parseBoolEnv(value, fallback) {
19
+ if (value === undefined || value === null) return fallback;
20
+ const normalized = String(value).trim().toLowerCase();
21
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
22
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
23
+ return fallback;
24
+ }
25
+
26
+ function isProcessAlive(pid) {
27
+ try {
28
+ process.kill(pid, 0);
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ function getDaemonPid() {
36
+ try {
37
+ if (!existsSync(DAEMON_PID_FILE)) return null;
38
+ const pid = parseInt(readFileSync(DAEMON_PID_FILE, "utf8").trim(), 10);
39
+ if (!Number.isFinite(pid)) return null;
40
+ return isProcessAlive(pid) ? pid : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function findGhostDaemonPids() {
47
+ if (process.platform === "win32") return [];
48
+ try {
49
+ const out = execFileSync(
50
+ "pgrep",
51
+ ["-f", "bosun.*--daemon-child|cli\\.mjs.*--daemon-child"],
52
+ { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 3000 },
53
+ ).trim();
54
+ return out
55
+ .split("\n")
56
+ .map((s) => parseInt(s.trim(), 10))
57
+ .filter((n) => Number.isFinite(n) && n > 0 && n !== process.pid);
58
+ } catch {
59
+ return [];
60
+ }
61
+ }
62
+
63
+ async function waitForDaemon(timeoutMs = 5000) {
64
+ const deadline = Date.now() + timeoutMs;
65
+ while (Date.now() < deadline) {
66
+ const pid = getDaemonPid();
67
+ if (pid) return pid;
68
+ await new Promise((resolve) => setTimeout(resolve, 200));
69
+ }
70
+ return null;
71
+ }
72
+
73
+ function resolveBosunRoot() {
74
+ if (app.isPackaged) {
75
+ return resolve(process.resourcesPath, "bosun");
76
+ }
77
+ return resolve(__dirname, "..");
78
+ }
79
+
80
+ function resolveBosunRuntimePath(file) {
81
+ return resolve(resolveBosunRoot(), file);
82
+ }
83
+
84
+ async function loadBosunModule(file) {
85
+ const modulePath = resolveBosunRuntimePath(file);
86
+ return import(pathToFileURL(modulePath).href);
87
+ }
88
+
89
+ async function loadUiServerModule() {
90
+ if (uiApi) return uiApi;
91
+ uiApi = await loadBosunModule("ui-server.mjs");
92
+ return uiApi;
93
+ }
94
+
95
+ async function ensureDaemonRunning() {
96
+ const autoStart = parseBoolEnv(
97
+ process.env.BOSUN_DESKTOP_AUTO_START_DAEMON,
98
+ true,
99
+ );
100
+ if (!autoStart) return;
101
+
102
+ const existing = getDaemonPid();
103
+ if (existing) return;
104
+
105
+ const ghosts = findGhostDaemonPids();
106
+ if (ghosts.length > 0) return;
107
+
108
+ const cliPath = resolveBosunRuntimePath("cli.mjs");
109
+ if (!existsSync(cliPath)) {
110
+ console.warn("[desktop] bosun CLI not found; daemon auto-start skipped");
111
+ return;
112
+ }
113
+
114
+ try {
115
+ const setupModule = await loadBosunModule("setup.mjs");
116
+ if (setupModule?.shouldRunSetup?.()) {
117
+ console.warn(
118
+ "[desktop] setup required before daemon start; run: bosun --setup",
119
+ );
120
+ return;
121
+ }
122
+ } catch (err) {
123
+ console.warn(
124
+ "[desktop] unable to verify setup state; starting daemon anyway",
125
+ );
126
+ }
127
+
128
+ const child = spawn(process.execPath, ["--run-as-node", cliPath, "--daemon"], {
129
+ detached: true,
130
+ stdio: "ignore",
131
+ env: { ...process.env, BOSUN_DESKTOP: "1" },
132
+ cwd: resolveBosunRoot(),
133
+ windowsHide: true,
134
+ });
135
+ child.unref();
136
+
137
+ await waitForDaemon(4000);
138
+ }
17
139
 
18
140
  async function startUiServer() {
19
141
  if (uiServerStarted) return;
20
- const server = await startTelegramUiServer({});
142
+ const api = await loadUiServerModule();
143
+ const server = await api.startTelegramUiServer({});
21
144
  if (!server) {
22
145
  throw new Error("Failed to start Telegram UI server.");
23
146
  }
24
147
  uiServerStarted = true;
25
148
  }
26
149
 
27
- function buildUiUrl() {
28
- const uiServerUrl = getTelegramUiUrl();
150
+ async function buildUiUrl() {
151
+ const api = await loadUiServerModule();
152
+ const uiServerUrl = api.getTelegramUiUrl();
29
153
  if (!uiServerUrl) {
30
154
  throw new Error("Telegram UI server URL is unavailable.");
31
155
  }
32
156
  const targetUrl = new URL(uiServerUrl);
33
157
  uiOrigin = targetUrl.origin;
34
- const sessionToken = getSessionToken();
158
+ const sessionToken = api.getSessionToken();
35
159
  if (sessionToken) {
36
160
  targetUrl.searchParams.set("token", sessionToken);
37
161
  }
@@ -64,20 +188,40 @@ async function createMainWindow() {
64
188
  mainWindow?.show();
65
189
  });
66
190
 
67
- const uiUrl = buildUiUrl();
191
+ const uiUrl = await buildUiUrl();
68
192
  await mainWindow.loadURL(uiUrl);
69
193
  }
70
194
 
71
195
  async function bootstrap() {
72
196
  try {
197
+ app.setAppUserModelId("com.virtengine.bosun");
198
+ process.chdir(resolveBosunRoot());
199
+ await ensureDaemonRunning();
73
200
  await startUiServer();
74
201
  await createMainWindow();
202
+ await maybeAutoUpdate();
75
203
  } catch (error) {
76
204
  console.error("[desktop] startup failed", error);
77
205
  await shutdown("startup_failed");
78
206
  }
79
207
  }
80
208
 
209
+ async function maybeAutoUpdate() {
210
+ if (!app.isPackaged) return;
211
+ if (process.env.BOSUN_DESKTOP_AUTO_UPDATE !== "1") return;
212
+ try {
213
+ const { autoUpdater } = await import("electron-updater");
214
+ const feedUrl = process.env.BOSUN_DESKTOP_UPDATE_URL;
215
+ if (feedUrl) {
216
+ autoUpdater.setFeedURL({ url: feedUrl });
217
+ }
218
+ autoUpdater.autoDownload = true;
219
+ autoUpdater.checkForUpdatesAndNotify().catch(() => {});
220
+ } catch (err) {
221
+ console.warn("[desktop] auto-update unavailable", err?.message || err);
222
+ }
223
+ }
224
+
81
225
  async function shutdown(reason) {
82
226
  if (shuttingDown) return;
83
227
  shuttingDown = true;
@@ -87,7 +231,8 @@ async function shutdown(reason) {
87
231
  }
88
232
 
89
233
  try {
90
- stopTelegramUiServer();
234
+ const api = await loadUiServerModule();
235
+ api.stopTelegramUiServer();
91
236
  } catch (error) {
92
237
  console.error("[desktop] failed to stop ui-server", error);
93
238
  }
@@ -98,7 +243,9 @@ async function shutdown(reason) {
98
243
  app.on("before-quit", () => {
99
244
  shuttingDown = true;
100
245
  try {
101
- stopTelegramUiServer();
246
+ if (uiApi?.stopTelegramUiServer) {
247
+ uiApi.stopTelegramUiServer();
248
+ }
102
249
  } catch (error) {
103
250
  console.error("[desktop] failed to stop ui-server", error);
104
251
  }