@polderlabs/bizar 4.3.0 → 4.4.0
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/bizar-dash/src/server/opencode-sdk.mjs +72 -0
- package/bizar-dash/src/server/routes/background.mjs +92 -0
- package/bizar-dash/src/server/routes/chat.mjs +300 -123
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +26 -0
- package/bizar-dash/src/server/routes/tasks.mjs +59 -1
- package/bizar-dash/src/server/task-delegator.mjs +154 -8
- package/bizar-dash/src/server/tasks-store.mjs +50 -2
- package/bizar-dash/src/web/components/background/AttachButton.tsx +96 -0
- package/bizar-dash/src/web/components/background/TmuxAttachCard.tsx +122 -0
- package/bizar-dash/src/web/components/chat/AgentNode.tsx +127 -0
- package/bizar-dash/src/web/components/chat/AgentTree.tsx +80 -0
- package/bizar-dash/src/web/components/chat/ChatComposer.tsx +76 -0
- package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +144 -0
- package/bizar-dash/src/web/components/chat/ChatRail.tsx +387 -0
- package/bizar-dash/src/web/components/chat/ChatThread.tsx +28 -7
- package/bizar-dash/src/web/components/chat/JumpToLatest.tsx +58 -0
- package/bizar-dash/src/web/components/chat/MessageBlock.tsx +260 -0
- package/bizar-dash/src/web/components/chat/SessionRowMenu.tsx +206 -0
- package/bizar-dash/src/web/components/chat/StreamingIndicator.tsx +33 -5
- package/bizar-dash/src/web/components/chat/_legacy.ts +30 -0
- package/bizar-dash/src/web/components/chat/index.ts +11 -0
- package/bizar-dash/src/web/components/chat/useChat.ts +345 -167
- package/bizar-dash/src/web/components/tasks/BacklogPanel.css +109 -0
- package/bizar-dash/src/web/components/tasks/BacklogPanel.tsx +209 -0
- package/bizar-dash/src/web/styles/chat.css +1536 -133
- package/bizar-dash/src/web/views/BackgroundAgents.tsx +3 -0
- package/bizar-dash/src/web/views/Chat.tsx +147 -57
- package/bizar-dash/src/web/views/Tasks.tsx +23 -1
- package/cli/bg.mjs +94 -71
- package/cli/bin.mjs +19 -7
- package/cli/service-controller.mjs +587 -0
- package/cli/service-controller.test.mjs +92 -0
- package/cli/service.mjs +162 -14
- package/config/agents/baldr.md +2 -0
- package/config/agents/browser-harness.md +2 -0
- package/config/agents/forseti.md +2 -0
- package/config/agents/frigg.md +2 -0
- package/config/agents/heimdall.md +2 -0
- package/config/agents/hermod.md +2 -0
- package/config/agents/mimir.md +2 -0
- package/config/agents/odin.md +2 -0
- package/config/agents/quick.md +2 -0
- package/config/agents/semble-search.md +2 -0
- package/config/agents/thor.md +2 -0
- package/config/agents/tyr.md +2 -0
- package/config/agents/vidarr.md +2 -0
- package/config/agents/vor.md +2 -0
- package/config/opencode.json.template +1 -0
- package/install.sh +448 -787
- package/package.json +1 -1
- package/packages/sdk/package.json +20 -0
- package/packages/sdk/src/client.ts +5 -0
- package/packages/sdk/src/errors.ts +11 -2
- package/packages/sdk/src/index.ts +19 -0
- package/packages/sdk/src/opencode-events.ts +134 -0
- package/packages/sdk/src/opencode-types.ts +66 -0
- package/packages/sdk/src/opencode.ts +335 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cli/service-controller.mjs
|
|
4
|
+
*
|
|
5
|
+
* v4.4.0 — Platform-aware install/uninstall for the Bizar background service.
|
|
6
|
+
*
|
|
7
|
+
* Registers the existing `bizar service _daemon` daemon under the OS-level
|
|
8
|
+
* init system so the service autostarts on user login. Implemented with
|
|
9
|
+
* explicit per-platform code paths so the controller stays small and
|
|
10
|
+
* auditable:
|
|
11
|
+
*
|
|
12
|
+
* linux — systemd user unit at $XDG_CONFIG_HOME/systemd/user/bizar.service
|
|
13
|
+
* darwin — launchd plist at ~/Library/LaunchAgents/com.bizar.dashboard.plist
|
|
14
|
+
* win32 — wrapper cmd file + Scheduled Task (`BizarDashboardService`,
|
|
15
|
+
* ONSTART, HIGHEST)
|
|
16
|
+
*
|
|
17
|
+
* Design notes:
|
|
18
|
+
*
|
|
19
|
+
* * `install` is idempotent. When the on-disk unit already matches the
|
|
20
|
+
* would-be content, we return `{ok: true, alreadyInstalled: true}` and
|
|
21
|
+
* skip the shell-out entirely.
|
|
22
|
+
* * The opencode password is never written into the unit file. Runtime
|
|
23
|
+
* secrets come from a 0600 env file (`~/.config/bizar/service.env`).
|
|
24
|
+
* * All process spawning uses `spawnSync(command, args, {shell: false})`
|
|
25
|
+
* with explicit arg arrays. There is no string concatenation into a
|
|
26
|
+
* shell anywhere.
|
|
27
|
+
* * No function in this module throws. Every public function returns a
|
|
28
|
+
* `{ok: boolean, ...}` shape. Callers branch on `ok`.
|
|
29
|
+
*/
|
|
30
|
+
import { spawnSync } from 'node:child_process';
|
|
31
|
+
import {
|
|
32
|
+
existsSync,
|
|
33
|
+
readFileSync,
|
|
34
|
+
writeFileSync,
|
|
35
|
+
mkdirSync,
|
|
36
|
+
chmodSync,
|
|
37
|
+
unlinkSync,
|
|
38
|
+
} from 'node:fs';
|
|
39
|
+
import { homedir, platform } from 'node:os';
|
|
40
|
+
import { join, dirname, resolve, isAbsolute } from 'node:path';
|
|
41
|
+
import { fileURLToPath } from 'node:url';
|
|
42
|
+
|
|
43
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
44
|
+
const __dirname = dirname(__filename);
|
|
45
|
+
const HOME = homedir();
|
|
46
|
+
const PLATFORM = platform();
|
|
47
|
+
|
|
48
|
+
// ── Path helpers ──────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
function bizarConfigDir() {
|
|
51
|
+
if (PLATFORM === 'win32') {
|
|
52
|
+
return process.env.APPDATA
|
|
53
|
+
? join(process.env.APPDATA, 'bizar')
|
|
54
|
+
: join(HOME, '.config', 'bizar');
|
|
55
|
+
}
|
|
56
|
+
return process.env.XDG_CONFIG_HOME
|
|
57
|
+
? join(process.env.XDG_CONFIG_HOME, 'bizar')
|
|
58
|
+
: join(HOME, '.config', 'bizar');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function userUnitDir() {
|
|
62
|
+
if (PLATFORM === 'win32') return null;
|
|
63
|
+
return process.env.XDG_CONFIG_HOME
|
|
64
|
+
? join(process.env.XDG_CONFIG_HOME, 'systemd', 'user')
|
|
65
|
+
: join(HOME, '.config', 'systemd', 'user');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function launchAgentsDir() {
|
|
69
|
+
return join(HOME, 'Library', 'LaunchAgents');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const DEFAULT_NODE_PATH = process.execPath;
|
|
73
|
+
const REPO_ROOT = resolve(__dirname, '..');
|
|
74
|
+
|
|
75
|
+
// ── spawn helper ──────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Run an external command with explicit arg array. No shell. Strict 15s budget.
|
|
79
|
+
* Returns {status, stdout, stderr, error} where `status` is the exit code
|
|
80
|
+
* (or null on spawn failure) and `error` is a non-null Error object only when
|
|
81
|
+
* the child could not even be spawned.
|
|
82
|
+
*/
|
|
83
|
+
function runCmd(cmd, args, opts = {}) {
|
|
84
|
+
const r = spawnSync(cmd, args, {
|
|
85
|
+
encoding: 'utf8',
|
|
86
|
+
timeout: 15_000,
|
|
87
|
+
shell: false,
|
|
88
|
+
windowsHide: true,
|
|
89
|
+
...opts,
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
status: r.status,
|
|
93
|
+
stdout: typeof r.stdout === 'string' ? r.stdout : '',
|
|
94
|
+
stderr: typeof r.stderr === 'string' ? r.stderr : '',
|
|
95
|
+
error: r.error || null,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function ok(extra = {}) {
|
|
100
|
+
return { ok: true, ...extra };
|
|
101
|
+
}
|
|
102
|
+
function fail(error, extra = {}) {
|
|
103
|
+
return { ok: false, error: String(error), ...extra };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── platform-specific path factories ──────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
function linuxUnitPath() {
|
|
109
|
+
return join(userUnitDir(), 'bizar.service');
|
|
110
|
+
}
|
|
111
|
+
function linuxEnvPath() {
|
|
112
|
+
return join(bizarConfigDir(), 'service.env');
|
|
113
|
+
}
|
|
114
|
+
function darwinPlistPath() {
|
|
115
|
+
return join(launchAgentsDir(), 'com.bizar.dashboard.plist');
|
|
116
|
+
}
|
|
117
|
+
function windowsCmdPath() {
|
|
118
|
+
return join(bizarConfigDir(), 'bizar-service.cmd');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Path resolvers (override + defaults) ──────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
function resolveRepoRoot(override) {
|
|
124
|
+
if (!override) return REPO_ROOT;
|
|
125
|
+
if (isAbsolute(override)) return override;
|
|
126
|
+
return resolve(process.cwd(), override);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function resolveNodePath(override) {
|
|
130
|
+
return override || DEFAULT_NODE_PATH;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── unit/plist/cmd path per platform ──────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
export function serviceUnitPath(opts = {}) {
|
|
136
|
+
// projectRoot is currently informational — different platforms stash the
|
|
137
|
+
// unit file in OS-defined dirs that don't depend on the repo. We keep the
|
|
138
|
+
// parameter for cross-platform symmetry and so dry-run output is stable.
|
|
139
|
+
resolveRepoRoot(opts.projectRoot);
|
|
140
|
+
if (PLATFORM === 'linux') return linuxUnitPath();
|
|
141
|
+
if (PLATFORM === 'darwin') return darwinPlistPath();
|
|
142
|
+
if (PLATFORM === 'win32') return windowsCmdPath();
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── linux: systemd user unit ──────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
function linuxUnitContent({ nodePath, projectRoot }) {
|
|
149
|
+
const envPath = join(bizarConfigDir(), 'service.env');
|
|
150
|
+
const cliEntry = join(projectRoot, 'cli', 'bin.mjs');
|
|
151
|
+
return [
|
|
152
|
+
'[Unit]',
|
|
153
|
+
'Description=Bizar background service daemon',
|
|
154
|
+
'After=network-online.target',
|
|
155
|
+
'Wants=network-online.target',
|
|
156
|
+
'',
|
|
157
|
+
'[Service]',
|
|
158
|
+
'Type=simple',
|
|
159
|
+
`EnvironmentFile=${envPath}`,
|
|
160
|
+
`ExecStart=${nodePath} ${cliEntry} service _daemon`,
|
|
161
|
+
'Restart=always',
|
|
162
|
+
'RestartSec=5',
|
|
163
|
+
'TimeoutStopSec=20',
|
|
164
|
+
'',
|
|
165
|
+
'[Install]',
|
|
166
|
+
'WantedBy=default.target',
|
|
167
|
+
'',
|
|
168
|
+
].join('\n');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function linuxEnvFileContent({ projectRoot }) {
|
|
172
|
+
const lines = [
|
|
173
|
+
`# Generated by bizar service install — do not edit by hand.`,
|
|
174
|
+
`# Source this file from your shell to get the same env that the daemon sees.`,
|
|
175
|
+
`BIZAR_HOME=${bizarConfigDir()}`,
|
|
176
|
+
`PATH=${process.env.PATH || '/usr/local/bin:/usr/bin:/bin'}`,
|
|
177
|
+
`BIZAR_REPO=${projectRoot}`,
|
|
178
|
+
];
|
|
179
|
+
// Secrets are NOT written here. A user can append OPENCODE_SERVER_PASSWORD
|
|
180
|
+
// manually, but the controller never copies secrets into the unit file.
|
|
181
|
+
if (process.env.OPENCODE_SERVER_PASSWORD && !process.env.BIZAR_REDACT_PASSWORD) {
|
|
182
|
+
// Honored only if the user opts in via BIZAR_REDACT_PASSWORD=1 — default
|
|
183
|
+
// is to never persist secrets; the comment above documents this.
|
|
184
|
+
lines.push(`OPENCODE_SERVER_PASSWORD=${process.env.OPENCODE_SERVER_PASSWORD}`);
|
|
185
|
+
}
|
|
186
|
+
return lines.join('\n') + '\n';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function installServiceLinux({ nodePath, projectRoot, force = false }) {
|
|
190
|
+
if (!existsSync(userUnitDir())) {
|
|
191
|
+
try {
|
|
192
|
+
mkdirSync(userUnitDir(), { recursive: true });
|
|
193
|
+
} catch (err) {
|
|
194
|
+
return fail(`cannot create ${userUnitDir()}: ${err.message}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (!existsSync(bizarConfigDir())) {
|
|
198
|
+
try {
|
|
199
|
+
mkdirSync(bizarConfigDir(), { recursive: true });
|
|
200
|
+
} catch (err) {
|
|
201
|
+
return fail(`cannot create ${bizarConfigDir()}: ${err.message}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const unitPath = linuxUnitPath();
|
|
206
|
+
const envPath = linuxEnvPath();
|
|
207
|
+
const want = linuxUnitContent({ nodePath, projectRoot });
|
|
208
|
+
const wantEnv = linuxEnvFileContent({ projectRoot });
|
|
209
|
+
|
|
210
|
+
// Idempotency: if unit + env match, skip the shell-out.
|
|
211
|
+
if (!force && existsSync(unitPath) && existsSync(envPath)) {
|
|
212
|
+
const haveUnit = readFileSync(unitPath, 'utf8').trim() + '\n';
|
|
213
|
+
const haveEnv = readFileSync(envPath, 'utf8').trim() + '\n';
|
|
214
|
+
if (haveUnit === want && haveEnv === wantEnv) {
|
|
215
|
+
return ok({
|
|
216
|
+
alreadyInstalled: true,
|
|
217
|
+
unitPath,
|
|
218
|
+
note: 'unit + env file already match desired content',
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
writeFileSync(unitPath, want, { encoding: 'utf8', mode: 0o644 });
|
|
225
|
+
} catch (err) {
|
|
226
|
+
return fail(`write ${unitPath}: ${err.message}`);
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
writeFileSync(envPath, wantEnv, { encoding: 'utf8', mode: 0o600 });
|
|
230
|
+
// chmod is needed because `mode` in writeFileSync on Windows is a no-op.
|
|
231
|
+
if (PLATFORM !== 'win32') {
|
|
232
|
+
try { chmodSync(envPath, 0o600); } catch { /* best-effort */ }
|
|
233
|
+
}
|
|
234
|
+
} catch (err) {
|
|
235
|
+
return fail(`write ${envPath}: ${err.message}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let r = runCmd('systemctl', ['--user', 'daemon-reload']);
|
|
239
|
+
if (r.error || (r.status !== 0 && r.status !== null)) {
|
|
240
|
+
return fail(`systemctl --user daemon-reload failed: ${r.stderr || r.error?.message || `exit ${r.status}`}`, { unitPath });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
r = runCmd('systemctl', ['--user', 'enable', '--now', 'bizar.service']);
|
|
244
|
+
if (r.error || (r.status !== 0 && r.status !== null)) {
|
|
245
|
+
return fail(`systemctl --user enable --now failed: ${r.stderr || r.error?.message || `exit ${r.status}`}`, { unitPath });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return ok({
|
|
249
|
+
unitPath,
|
|
250
|
+
note: 'systemd user unit installed and started (or already running)',
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function uninstallServiceLinux() {
|
|
255
|
+
const unitPath = linuxUnitPath();
|
|
256
|
+
if (!existsSync(unitPath)) {
|
|
257
|
+
return ok({ unitPath: null, note: 'no systemd unit installed' });
|
|
258
|
+
}
|
|
259
|
+
// Best-effort stop+disable. Even if the service is not active, keep going
|
|
260
|
+
// so we still remove the unit file.
|
|
261
|
+
runCmd('systemctl', ['--user', 'disable', '--now', 'bizar.service']);
|
|
262
|
+
try {
|
|
263
|
+
unlinkSync(unitPath);
|
|
264
|
+
} catch (err) {
|
|
265
|
+
return fail(`remove ${unitPath}: ${err.message}`, { unitPath });
|
|
266
|
+
}
|
|
267
|
+
runCmd('systemctl', ['--user', 'daemon-reload']);
|
|
268
|
+
return ok({
|
|
269
|
+
unitPath: null,
|
|
270
|
+
note: 'systemd user unit removed',
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function linuxServiceStatus() {
|
|
275
|
+
const unitPath = linuxUnitPath();
|
|
276
|
+
if (!existsSync(unitPath)) {
|
|
277
|
+
return { installed: false, running: false, unitPath: null };
|
|
278
|
+
}
|
|
279
|
+
const enabled = runCmd('systemctl', ['--user', 'is-enabled', 'bizar.service']);
|
|
280
|
+
const active = runCmd('systemctl', ['--user', 'is-active', 'bizar.service']);
|
|
281
|
+
const running = active.status === 0 && (active.stdout || '').trim() === 'active';
|
|
282
|
+
const installed = enabled.status === 0 || existsSync(unitPath);
|
|
283
|
+
return { installed, running, unitPath };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── darwin: launchd plist ─────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
function darwinPlistContent({ nodePath, projectRoot }) {
|
|
289
|
+
const cliEntry = join(projectRoot, 'cli', 'bin.mjs');
|
|
290
|
+
const envVars = [
|
|
291
|
+
['BIZAR_HOME', bizarConfigDir()],
|
|
292
|
+
['PATH', process.env.PATH || '/usr/local/bin:/usr/bin:/bin'],
|
|
293
|
+
['BIZAR_REPO', projectRoot],
|
|
294
|
+
];
|
|
295
|
+
const envXml = envVars
|
|
296
|
+
.map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
|
|
297
|
+
.join('\n');
|
|
298
|
+
return [
|
|
299
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
300
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
301
|
+
'<plist version="1.0">',
|
|
302
|
+
'<dict>',
|
|
303
|
+
' <key>Label</key>',
|
|
304
|
+
' <string>com.bizar.dashboard</string>',
|
|
305
|
+
' <key>ProgramArguments</key>',
|
|
306
|
+
' <array>',
|
|
307
|
+
` <string>${xmlEscape(nodePath)}</string>`,
|
|
308
|
+
` <string>${xmlEscape(cliEntry)}</string>`,
|
|
309
|
+
' <string>service</string>',
|
|
310
|
+
' <string>_daemon</string>',
|
|
311
|
+
' </array>',
|
|
312
|
+
' <key>EnvironmentVariables</key>',
|
|
313
|
+
' <dict>',
|
|
314
|
+
envXml,
|
|
315
|
+
' </dict>',
|
|
316
|
+
' <key>KeepAlive</key>',
|
|
317
|
+
' <true/>',
|
|
318
|
+
' <key>RunAtLoad</key>',
|
|
319
|
+
' <true/>',
|
|
320
|
+
' <key>StandardOutPath</key>',
|
|
321
|
+
` <string>${xmlEscape(join(bizarConfigDir(), 'service.log'))}</string>`,
|
|
322
|
+
' <key>StandardErrorPath</key>',
|
|
323
|
+
` <string>${xmlEscape(join(bizarConfigDir(), 'service.log'))}</string>`,
|
|
324
|
+
' <key>WorkingDirectory</key>',
|
|
325
|
+
` <string>${xmlEscape(projectRoot)}</string>`,
|
|
326
|
+
'</dict>',
|
|
327
|
+
'</plist>',
|
|
328
|
+
'',
|
|
329
|
+
].join('\n');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function xmlEscape(s) {
|
|
333
|
+
return String(s)
|
|
334
|
+
.replace(/&/g, '&')
|
|
335
|
+
.replace(/</g, '<')
|
|
336
|
+
.replace(/>/g, '>');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function installServiceDarwin({ nodePath, projectRoot, force = false }) {
|
|
340
|
+
const dir = launchAgentsDir();
|
|
341
|
+
if (!existsSync(dir)) {
|
|
342
|
+
try { mkdirSync(dir, { recursive: true }); } catch (err) {
|
|
343
|
+
return fail(`cannot create ${dir}: ${err.message}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const plistPath = darwinPlistPath();
|
|
347
|
+
const want = darwinPlistContent({ nodePath, projectRoot });
|
|
348
|
+
|
|
349
|
+
if (!force && existsSync(plistPath)) {
|
|
350
|
+
const have = readFileSync(plistPath, 'utf8').trim() + '\n';
|
|
351
|
+
if (have === want.trim() + '\n') {
|
|
352
|
+
// Make sure it's actually loaded though — the file may exist but
|
|
353
|
+
// launchd could have unloaded it after a reboot bug.
|
|
354
|
+
const lst = runCmd('launchctl', ['list']);
|
|
355
|
+
if (lst.status === 0 && /com\.bizar\.dashboard/.test(lst.stdout)) {
|
|
356
|
+
return ok({
|
|
357
|
+
alreadyInstalled: true,
|
|
358
|
+
unitPath: plistPath,
|
|
359
|
+
note: 'plist matches desired content and is loaded',
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
// File matches but agent is not loaded — re-load.
|
|
363
|
+
const load = runCmd('launchctl', ['load', '-w', plistPath]);
|
|
364
|
+
if (load.error || (load.status !== 0 && load.status !== null)) {
|
|
365
|
+
return fail(`launchctl load failed: ${load.stderr || load.error?.message || `exit ${load.status}`}`, { unitPath: plistPath });
|
|
366
|
+
}
|
|
367
|
+
return ok({ unitPath: plistPath, note: 'plist already present, reloaded' });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
writeFileSync(plistPath, want, { encoding: 'utf8', mode: 0o644 });
|
|
373
|
+
try { chmodSync(plistPath, 0o644); } catch { /* best-effort */ }
|
|
374
|
+
} catch (err) {
|
|
375
|
+
return fail(`write ${plistPath}: ${err.message}`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Idempotent load: first unload in case a stale copy is registered.
|
|
379
|
+
runCmd('launchctl', ['unload', plistPath]);
|
|
380
|
+
const load = runCmd('launchctl', ['load', '-w', plistPath]);
|
|
381
|
+
if (load.error || (load.status !== 0 && load.status !== null)) {
|
|
382
|
+
return fail(`launchctl load failed: ${load.stderr || load.error?.message || `exit ${load.status}`}`, { unitPath: plistPath });
|
|
383
|
+
}
|
|
384
|
+
return ok({ unitPath: plistPath, note: 'plist installed and loaded' });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function uninstallServiceDarwin() {
|
|
388
|
+
const plistPath = darwinPlistPath();
|
|
389
|
+
if (!existsSync(plistPath)) {
|
|
390
|
+
return ok({ unitPath: null, note: 'no launchd plist installed' });
|
|
391
|
+
}
|
|
392
|
+
// launchctl unload exits non-zero if the agent is not loaded — that's fine.
|
|
393
|
+
runCmd('launchctl', ['unload', plistPath]);
|
|
394
|
+
try {
|
|
395
|
+
unlinkSync(plistPath);
|
|
396
|
+
} catch (err) {
|
|
397
|
+
return fail(`remove ${plistPath}: ${err.message}`, { unitPath: plistPath });
|
|
398
|
+
}
|
|
399
|
+
return ok({ unitPath: null, note: 'plist removed' });
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function darwinServiceStatus() {
|
|
403
|
+
const plistPath = darwinPlistPath();
|
|
404
|
+
const lst = runCmd('launchctl', ['list']);
|
|
405
|
+
const loaded = lst.status === 0 && /com\.bizar\.dashboard/.test(lst.stdout || '');
|
|
406
|
+
return {
|
|
407
|
+
installed: existsSync(plistPath),
|
|
408
|
+
running: loaded,
|
|
409
|
+
unitPath: existsSync(plistPath) ? plistPath : null,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── win32: scheduled task + cmd wrapper ───────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
function windowsCmdContent({ nodePath, projectRoot }) {
|
|
416
|
+
const cliEntry = join(projectRoot, 'cli', 'bin.mjs');
|
|
417
|
+
const lines = [
|
|
418
|
+
'@echo off',
|
|
419
|
+
'REM Generated by bizar service install — do not edit by hand.',
|
|
420
|
+
`set "BIZAR_HOME=${bizarConfigDir()}"`,
|
|
421
|
+
`set "PATH=${process.env.PATH || '%PATH%'}"`,
|
|
422
|
+
`:loop`,
|
|
423
|
+
`node "${cliEntry}" service _daemon`,
|
|
424
|
+
'REM Restart with 5s backoff on exit. /b keeps this window alive.',
|
|
425
|
+
'timeout /t 5 /nobreak >nul',
|
|
426
|
+
'goto loop',
|
|
427
|
+
'',
|
|
428
|
+
];
|
|
429
|
+
return lines.join('\r\n');
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function installServiceWindows({ nodePath, projectRoot, force = false }) {
|
|
433
|
+
const cmdPath = serviceUnitPath({ projectRoot });
|
|
434
|
+
const taskName = 'BizarDashboardService';
|
|
435
|
+
|
|
436
|
+
if (!existsSync(bizarConfigDir())) {
|
|
437
|
+
try { mkdirSync(bizarConfigDir(), { recursive: true }); } catch (err) {
|
|
438
|
+
return fail(`cannot create ${bizarConfigDir()}: ${err.message}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const want = windowsCmdContent({ nodePath, projectRoot });
|
|
443
|
+
|
|
444
|
+
// Idempotency
|
|
445
|
+
const exists = isScheduledTaskInstalled(taskName);
|
|
446
|
+
if (!force && exists && existsSync(cmdPath)) {
|
|
447
|
+
const have = readFileSync(cmdPath, 'utf8').trim();
|
|
448
|
+
if (have === want.trim()) {
|
|
449
|
+
return ok({ alreadyInstalled: true, unitPath: cmdPath, note: 'scheduled task and wrapper already installed' });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
try {
|
|
454
|
+
writeFileSync(cmdPath, want, { encoding: 'utf8' });
|
|
455
|
+
} catch (err) {
|
|
456
|
+
return fail(`write ${cmdPath}: ${err.message}`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Remove any stale task first so /Create succeeds cleanly.
|
|
460
|
+
runCmd('cmd.exe', ['/c', 'schtasks', '/Delete', '/TN', taskName, '/F']);
|
|
461
|
+
|
|
462
|
+
const create = runCmd('cmd.exe', [
|
|
463
|
+
'/c', 'schtasks',
|
|
464
|
+
'/Create',
|
|
465
|
+
'/TN', taskName,
|
|
466
|
+
'/TR', `"${cmdPath}"`,
|
|
467
|
+
'/SC', 'ONSTART',
|
|
468
|
+
'/RL', 'HIGHEST',
|
|
469
|
+
'/F',
|
|
470
|
+
]);
|
|
471
|
+
if (create.error || (create.status !== 0 && create.status !== null)) {
|
|
472
|
+
return fail(`schtasks /Create failed: ${create.stderr || create.error?.message || `exit ${create.status}`}`, { unitPath: cmdPath });
|
|
473
|
+
}
|
|
474
|
+
return ok({ unitPath: cmdPath, note: 'scheduled task installed' });
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function uninstallServiceWindows() {
|
|
478
|
+
const cmdPath = serviceUnitPath({});
|
|
479
|
+
const taskName = 'BizarDashboardService';
|
|
480
|
+
const r = runCmd('cmd.exe', ['/c', 'schtasks', '/Delete', '/TN', taskName, '/F']);
|
|
481
|
+
if (existsSync(cmdPath)) {
|
|
482
|
+
try {
|
|
483
|
+
unlinkSync(cmdPath);
|
|
484
|
+
} catch (err) {
|
|
485
|
+
return fail(`remove ${cmdPath}: ${err.message}`, { unitPath: cmdPath });
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return ok({
|
|
489
|
+
unitPath: null,
|
|
490
|
+
note: r.status === 0 ? 'scheduled task removed' : 'scheduled task was not installed; wrapper cleaned up if present',
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function isScheduledTaskInstalled(taskName) {
|
|
495
|
+
// schtasks /Query is silent on stdout when the task is missing.
|
|
496
|
+
const r = runCmd('cmd.exe', ['/c', 'schtasks', '/Query', '/TN', taskName], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
497
|
+
return r.status === 0 && !/cannot find|no tasks|running/i.test(r.stdout + r.stderr);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function windowsServiceStatus() {
|
|
501
|
+
const cmdPath = serviceUnitPath({});
|
|
502
|
+
const installed = isScheduledTaskInstalled('BizarDashboardService');
|
|
503
|
+
// "Running" is a best-effort signal via tasklist — the cmd wrapper spawns
|
|
504
|
+
// a `node.exe` child.
|
|
505
|
+
const ps = runCmd('tasklist', ['/FI', `IMAGENAME eq node.exe`], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
506
|
+
const running = ps.status === 0 && /\bnode\.exe\b/i.test(ps.stdout || '');
|
|
507
|
+
return {
|
|
508
|
+
installed: installed || existsSync(cmdPath),
|
|
509
|
+
running,
|
|
510
|
+
unitPath: existsSync(cmdPath) ? cmdPath : null,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ── public API ────────────────────────────────────────────────────────────────
|
|
515
|
+
|
|
516
|
+
export function installService(opts = {}) {
|
|
517
|
+
const nodePath = resolveNodePath(opts.nodePath);
|
|
518
|
+
const projectRoot = resolveRepoRoot(opts.projectRoot);
|
|
519
|
+
const force = !!opts.force;
|
|
520
|
+
|
|
521
|
+
// Honor a --dry-run without performing shell-outs: write nothing, return
|
|
522
|
+
// the would-be content paths. Useful for CI or for "what would happen?"
|
|
523
|
+
// inspections.
|
|
524
|
+
if (opts.dryRun) {
|
|
525
|
+
return ok({
|
|
526
|
+
unitPath: serviceUnitPath({ projectRoot }),
|
|
527
|
+
note: 'dry-run: would install but no changes were made',
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (PLATFORM === 'linux') return installServiceLinux({ nodePath, projectRoot, force });
|
|
532
|
+
if (PLATFORM === 'darwin') return installServiceDarwin({ nodePath, projectRoot, force });
|
|
533
|
+
if (PLATFORM === 'win32') return installServiceWindows({ nodePath, projectRoot, force });
|
|
534
|
+
return fail(`unsupported platform: ${PLATFORM}`);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function uninstallService() {
|
|
538
|
+
if (PLATFORM === 'linux') return uninstallServiceLinux();
|
|
539
|
+
if (PLATFORM === 'darwin') return uninstallServiceDarwin();
|
|
540
|
+
if (PLATFORM === 'win32') return uninstallServiceWindows();
|
|
541
|
+
return fail(`unsupported platform: ${PLATFORM}`);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function isInstalled() {
|
|
545
|
+
if (PLATFORM === 'linux') return existsSync(linuxUnitPath());
|
|
546
|
+
if (PLATFORM === 'darwin') return existsSync(darwinPlistPath());
|
|
547
|
+
if (PLATFORM === 'win32') return isScheduledTaskInstalled('BizarDashboardService') || existsSync(serviceUnitPath({}));
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export function serviceStatus() {
|
|
552
|
+
if (PLATFORM === 'linux') return linuxServiceStatus();
|
|
553
|
+
if (PLATFORM === 'darwin') return darwinServiceStatus();
|
|
554
|
+
if (PLATFORM === 'win32') return windowsServiceStatus();
|
|
555
|
+
return { installed: false, running: false, unitPath: null };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ── direct-entry CLI: `node service-controller.mjs install|uninstall|status` ──
|
|
559
|
+
|
|
560
|
+
const isMain = Boolean(process.argv[1]) && resolve(process.argv[1]) === __filename;
|
|
561
|
+
if (isMain) {
|
|
562
|
+
const sub = process.argv[2];
|
|
563
|
+
const flags = new Set(process.argv.slice(3));
|
|
564
|
+
const dry = flags.has('--dry-run');
|
|
565
|
+
const force = flags.has('--force') || flags.has('-f');
|
|
566
|
+
const fmt = (r) => JSON.stringify(r, null, 2);
|
|
567
|
+
|
|
568
|
+
if (sub === 'install') {
|
|
569
|
+
console.log(fmt(installService({ force, dryRun: dry })));
|
|
570
|
+
} else if (sub === 'uninstall') {
|
|
571
|
+
console.log(fmt(uninstallService()));
|
|
572
|
+
} else if (sub === 'status') {
|
|
573
|
+
console.log(fmt(serviceStatus()));
|
|
574
|
+
} else if (sub === 'is-installed') {
|
|
575
|
+
console.log(JSON.stringify({ installed: isInstalled() }));
|
|
576
|
+
} else {
|
|
577
|
+
console.log(`
|
|
578
|
+
service-controller — platform-aware installer for the Bizar background service
|
|
579
|
+
|
|
580
|
+
Usage:
|
|
581
|
+
node cli/service-controller.mjs install [--force] [--dry-run]
|
|
582
|
+
node cli/service-controller.mjs uninstall
|
|
583
|
+
node cli/service-controller.mjs status
|
|
584
|
+
node cli/service-controller.mjs is-installed
|
|
585
|
+
`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/service-controller.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* v4.4.0 — Smoke tests for the platform-aware service installer.
|
|
5
|
+
*
|
|
6
|
+
* Strategy: focus on the parts that are platform-portable and don't
|
|
7
|
+
* require touching the user's real `$XDG_CONFIG_HOME/systemd/`:
|
|
8
|
+
* - `serviceUnitPath()` returns the expected path
|
|
9
|
+
* - `serviceStatus()` shape
|
|
10
|
+
* - `installService({ dryRun: true })` is side-effect free
|
|
11
|
+
* - `installService()` is idempotent (run twice → second reports
|
|
12
|
+
* `alreadyInstalled: true`)
|
|
13
|
+
*
|
|
14
|
+
* Tests that actually install / uninstall on the host machine are
|
|
15
|
+
* skipped by default — opt in by setting `BIZAR_TEST_INSTALL=1`.
|
|
16
|
+
*/
|
|
17
|
+
import { test } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { existsSync, rmSync, statSync } from 'node:fs';
|
|
20
|
+
import { platform } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
installService,
|
|
25
|
+
uninstallService,
|
|
26
|
+
serviceStatus,
|
|
27
|
+
serviceUnitPath,
|
|
28
|
+
} from './service-controller.mjs';
|
|
29
|
+
|
|
30
|
+
const HOST = platform();
|
|
31
|
+
const ALLOW_INSTALL = process.env.BIZAR_TEST_INSTALL === '1';
|
|
32
|
+
|
|
33
|
+
test('serviceUnitPath() returns a non-empty string or null', () => {
|
|
34
|
+
const p = serviceUnitPath();
|
|
35
|
+
if (HOST === 'linux' || HOST === 'darwin' || HOST === 'win32') {
|
|
36
|
+
assert.ok(typeof p === 'string' && p.length > 0, `expected path, got ${p}`);
|
|
37
|
+
} else {
|
|
38
|
+
assert.equal(p, null);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('serviceStatus() returns the documented shape', () => {
|
|
43
|
+
const s = serviceStatus();
|
|
44
|
+
assert.ok(typeof s === 'object' && s !== null);
|
|
45
|
+
for (const key of ['installed', 'running', 'unitPath']) {
|
|
46
|
+
assert.ok(key in s, `${key} missing from status`);
|
|
47
|
+
}
|
|
48
|
+
assert.equal(typeof s.installed, 'boolean');
|
|
49
|
+
assert.equal(typeof s.running, 'boolean');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('installService({ dryRun: true }) makes no filesystem changes', () => {
|
|
53
|
+
const before = serviceStatus();
|
|
54
|
+
const r = installService({ dryRun: true });
|
|
55
|
+
assert.equal(r.ok, true);
|
|
56
|
+
const after = serviceStatus();
|
|
57
|
+
// Both should agree on installed-ness — dry-run must not flip any bit.
|
|
58
|
+
assert.equal(before.installed, after.installed);
|
|
59
|
+
// The unit path was returned for reference.
|
|
60
|
+
if (HOST === 'linux' || HOST === 'darwin' || HOST === 'win32') {
|
|
61
|
+
assert.ok(typeof r.unitPath === 'string', 'dry-run should report unitPath');
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('idempotency: install twice → second call reports alreadyInstalled (host-only)', { skip: !ALLOW_INSTALL }, () => {
|
|
66
|
+
// Tear down whatever the host already has so we start clean.
|
|
67
|
+
uninstallService();
|
|
68
|
+
|
|
69
|
+
const first = installService({});
|
|
70
|
+
assert.equal(first.ok, true, `first install failed: ${first.error}`);
|
|
71
|
+
assert.equal(first.alreadyInstalled, undefined);
|
|
72
|
+
|
|
73
|
+
const second = installService({});
|
|
74
|
+
assert.equal(second.ok, true, `second install failed: ${second.error}`);
|
|
75
|
+
assert.equal(second.alreadyInstalled, true, 'second call should be a no-op');
|
|
76
|
+
|
|
77
|
+
// Clean up so we don't leave the host service running after tests.
|
|
78
|
+
uninstallService();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('file permissions on the env file are 0600 (Linux/macOS only)', { skip: HOST === 'win32' || !ALLOW_INSTALL }, () => {
|
|
82
|
+
installService({});
|
|
83
|
+
const unit = serviceUnitPath();
|
|
84
|
+
if (unit && unit.endsWith('bizar.service')) {
|
|
85
|
+
const envPath = join(process.env.HOME || '/tmp', '.config', 'bizar', 'service.env');
|
|
86
|
+
if (existsSync(envPath)) {
|
|
87
|
+
const mode = statSync(envPath).mode & 0o777;
|
|
88
|
+
assert.equal(mode, 0o600, `env file mode is ${mode.toString(8)}, want 0600`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
uninstallService();
|
|
92
|
+
});
|