@venturewild/workspace 0.1.12 → 0.1.14

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.
@@ -1,419 +1,419 @@
1
- // service.mjs — installs / removes the per-OS, NO-ADMIN autostart entry that
2
- // launches the WorkspaceSupervisor at login. See docs/always-on-design.md.
3
- //
4
- // Windows (proven end-to-end incl. a real reboot, 2026-05-30): writes a tiny VBS
5
- // that runs `node <cli> service run` with no window, and registers it under
6
- // HKCU\...\Run (per-user, NO admin / UAC).
7
- //
8
- // macOS (code-complete + unit-tested 2026-06-01; real-Mac reboot proof pending):
9
- // writes a `~/Library/LaunchAgents/<label>.plist` (RunAtLoad + KeepAlive +
10
- // ThrottleInterval). Install does NOT start it now (no `launchctl bootstrap`) —
11
- // launchd auto-loads the plist at the NEXT login, mirroring the Windows HKCU\Run
12
- // model. (Starting it at install time would grab :5173 and collide with the
13
- // `wild-workspace` the user runs this session.) Uninstall uses `launchctl bootout`
14
- // to stop any instance loaded from a prior login. Runs as the user, NO admin —
15
- // macOS 13+ shows a one-time "Background item added" toast + a Login Items toggle
16
- // (consent, not admin). launchd provides crash-restart for free; the supervisor
17
- // still owns the singleton lock + child watchdog.
18
- //
19
- // Linux (systemd --user) is designed but not yet implemented — it returns a clear
20
- // "not yet" result so callers degrade gracefully (the user runs `wild-workspace`).
21
- //
22
- // All state (the VBS / plist, service.json, the supervisor's lock/logs) lives in
23
- // the machine-global dir (~/.wild-workspace) or ~/Library/LaunchAgents, NEVER the
24
- // synced workspace (locked principle #1). Every external touch-point (reg.exe,
25
- // launchctl, kill) is an injected seam for testability.
26
-
27
- import { execFile } from 'node:child_process';
28
- import { promisify } from 'node:util';
29
- import fs from 'node:fs';
30
- import os from 'node:os';
31
- import path from 'node:path';
32
-
33
- const execFileP = promisify(execFile);
34
-
35
- export const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
36
- export const RUN_VALUE_NAME = 'WildWorkspace';
37
- export const LAUNCHD_LABEL = 'llc.venturewild.workspace';
38
-
39
- export function globalDir() { return path.join(os.homedir(), '.wild-workspace'); }
40
- function defaultLaunchAgentsDir() { return path.join(os.homedir(), 'Library', 'LaunchAgents'); }
41
- function currentUid() { return typeof process.getuid === 'function' ? process.getuid() : 0; }
42
-
43
- /** Is per-user autostart implemented for this platform yet? */
44
- export function isSupported(platform = process.platform) {
45
- return platform === 'win32' || platform === 'darwin' || platform === 'linux';
46
- }
47
-
48
- /** Shared: read the supervisor's pidfile and report whether that pid is alive. */
49
- function supervisorLiveness(dir) {
50
- let supervisorPid = null, supervisorAlive = false;
51
- try { supervisorPid = Number(fs.readFileSync(path.join(dir, 'supervisor.lock'), 'utf8').trim()) || null; } catch { /* none */ }
52
- if (supervisorPid) {
53
- try { process.kill(supervisorPid, 0); supervisorAlive = true; } catch (e) { supervisorAlive = !!(e && e.code === 'EPERM'); }
54
- }
55
- return { supervisorPid, supervisorAlive };
56
- }
57
-
58
- // --- Windows implementation -------------------------------------------------
59
-
60
- // A VBS that runs `node <cli> service run` with NO window (0 = SW_HIDE,
61
- // False = don't wait). VBS string literals escape a `"` as `""`.
62
- export function buildVbs(node, cli) {
63
- const cmd = `"${node}" "${cli}" service run`;
64
- const vbsArg = '"' + cmd.replace(/"/g, '""') + '"';
65
- return [
66
- "' wild-workspace always-on launcher (generated by `wild-workspace service install`).",
67
- "' Starts the workspace supervisor hidden at login. Disable via",
68
- "' `wild-workspace service uninstall` (removes the HKCU\\...\\Run value).",
69
- `CreateObject("WScript.Shell").Run ${vbsArg}, 0, False`,
70
- '',
71
- ].join('\r\n');
72
- }
73
-
74
- /** The HKCU\Run value: launch the VBS via the windowless wscript host. */
75
- export function buildRunValue(vbs) { return `wscript.exe "${vbs}"`; }
76
-
77
- async function winInstall({ node, cli, workspaceDir, port, version }, { dir, execFileImpl }) {
78
- fs.mkdirSync(dir, { recursive: true });
79
- const vbs = path.join(dir, 'launch-hidden.vbs');
80
- const serviceJson = path.join(dir, 'service.json');
81
- fs.writeFileSync(vbs, buildVbs(node, cli), 'utf8');
82
- fs.writeFileSync(
83
- serviceJson,
84
- JSON.stringify({ node, cli, workspaceDir, port, version, installedAt: new Date().toISOString() }, null, 2),
85
- 'utf8',
86
- );
87
- const runValue = buildRunValue(vbs);
88
- await execFileImpl('reg', ['add', RUN_KEY, '/v', RUN_VALUE_NAME, '/t', 'REG_SZ', '/d', runValue, '/f']);
89
- return { installed: true, mechanism: 'HKCU\\Run', launcher: vbs, vbs, runValue, serviceJson };
90
- }
91
-
92
- async function winUninstall({ dir, execFileImpl, killImpl }) {
93
- let removedKey = false;
94
- try { await execFileImpl('reg', ['delete', RUN_KEY, '/v', RUN_VALUE_NAME, '/f']); removedKey = true; } catch { /* not present */ }
95
- let stoppedPid = null;
96
- try {
97
- const pid = Number(fs.readFileSync(path.join(dir, 'supervisor.lock'), 'utf8').trim());
98
- if (pid) { killImpl(pid); stoppedPid = pid; }
99
- } catch { /* none running */ }
100
- for (const f of ['launch-hidden.vbs', 'service.json']) { try { fs.unlinkSync(path.join(dir, f)); } catch { /* gone */ } }
101
- return { uninstalled: true, removedKey, stoppedPid };
102
- }
103
-
104
- async function winStatus({ dir, execFileImpl, probeImpl, port }) {
105
- let installed = false, runValue = null;
106
- try {
107
- const { stdout } = await execFileImpl('reg', ['query', RUN_KEY, '/v', RUN_VALUE_NAME]);
108
- installed = new RegExp(RUN_VALUE_NAME, 'i').test(stdout);
109
- runValue = (stdout.match(/REG_SZ\s+(.*?)\s*$/m) || [])[1] || null;
110
- } catch { /* value absent → not installed */ }
111
- const { supervisorPid, supervisorAlive } = supervisorLiveness(dir);
112
- const serverUp = await probeImpl(port);
113
- return { installed, runValue, supervisorPid, supervisorAlive, serverUp };
114
- }
115
-
116
- // --- macOS implementation ---------------------------------------------------
117
-
118
- export function plistPath(launchAgentsDir) {
119
- return path.join(launchAgentsDir, `${LAUNCHD_LABEL}.plist`);
120
- }
121
-
122
- function xmlEscape(s) {
123
- return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
124
- }
125
-
126
- // A LaunchAgent that runs `node <cli> service run` at login + relaunches it if it
127
- // dies (KeepAlive). ThrottleInterval bounds the restart rate. Output is logged to
128
- // ~/.wild-workspace so a silent death is debuggable (footgun checklist §8).
129
- export function buildPlist({ node, cli, workspaceDir, outLog, errLog, label = LAUNCHD_LABEL }) {
130
- const args = [node, cli, 'service', 'run'].map((a) => ` <string>${xmlEscape(a)}</string>`);
131
- const env = workspaceDir ? [
132
- ' <key>EnvironmentVariables</key>',
133
- ' <dict>',
134
- ' <key>WILD_WORKSPACE_DIR</key>',
135
- ` <string>${xmlEscape(workspaceDir)}</string>`,
136
- ' </dict>',
137
- ] : [];
138
- return [
139
- '<?xml version="1.0" encoding="UTF-8"?>',
140
- '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
141
- '<plist version="1.0">',
142
- '<dict>',
143
- ' <key>Label</key>',
144
- ` <string>${xmlEscape(label)}</string>`,
145
- ' <key>ProgramArguments</key>',
146
- ' <array>',
147
- ...args,
148
- ' </array>',
149
- ' <key>RunAtLoad</key>',
150
- ' <true/>',
151
- ' <key>KeepAlive</key>',
152
- ' <true/>',
153
- ' <key>ThrottleInterval</key>',
154
- ' <integer>10</integer>',
155
- ...env,
156
- ' <key>StandardOutPath</key>',
157
- ` <string>${xmlEscape(outLog)}</string>`,
158
- ' <key>StandardErrorPath</key>',
159
- ` <string>${xmlEscape(errLog)}</string>`,
160
- '</dict>',
161
- '</plist>',
162
- '',
163
- ].join('\n');
164
- }
165
-
166
- async function macInstall({ node, cli, workspaceDir, port, version }, { dir, launchAgentsDir }) {
167
- fs.mkdirSync(dir, { recursive: true });
168
- fs.mkdirSync(launchAgentsDir, { recursive: true });
169
- const plist = plistPath(launchAgentsDir);
170
- const serviceJson = path.join(dir, 'service.json');
171
- const outLog = path.join(dir, 'launchagent.out.log');
172
- const errLog = path.join(dir, 'launchagent.err.log');
173
- fs.writeFileSync(plist, buildPlist({ node, cli, workspaceDir, outLog, errLog }), 'utf8');
174
- fs.writeFileSync(
175
- serviceJson,
176
- JSON.stringify({ node, cli, workspaceDir, port, version, installedAt: new Date().toISOString() }, null, 2),
177
- 'utf8',
178
- );
179
- // Deliberately do NOT `launchctl bootstrap` here. bootstrap + RunAtLoad would
180
- // start the supervisor immediately, grabbing :5173 — then the `wild-workspace`
181
- // the user runs *this session* collides on the port (createServer rejects on
182
- // EADDRINUSE). Dropping the plist into ~/Library/LaunchAgents is enough:
183
- // launchd auto-loads it at the NEXT login (RunAtLoad fires then). This mirrors
184
- // the proven Windows HKCU\Run model, which also only fires at login — so the
185
- // current session is the manual `wild-workspace`, and always-on takes over
186
- // from the next login/reboot (which is also the cleanest B5 proof).
187
- return { installed: true, mechanism: 'LaunchAgent', launcher: plist, plist, label: LAUNCHD_LABEL, runValue: plist, serviceJson, startsAtNextLogin: true };
188
- }
189
-
190
- async function macUninstall({ dir, launchAgentsDir, execFileImpl, killImpl, uid }) {
191
- const plist = plistPath(launchAgentsDir);
192
- const target = `gui/${uid}/${LAUNCHD_LABEL}`;
193
- try {
194
- await execFileImpl('launchctl', ['bootout', target]);
195
- } catch {
196
- try { await execFileImpl('launchctl', ['unload', '-w', plist]); } catch { /* not loaded */ }
197
- }
198
- // launchd's bootout SIGTERMs the launchd-managed supervisor; a manually-started
199
- // one still holds the lock, so stop it too (mirrors the Windows path).
200
- let stoppedPid = null;
201
- try {
202
- const pid = Number(fs.readFileSync(path.join(dir, 'supervisor.lock'), 'utf8').trim());
203
- if (pid) { killImpl(pid); stoppedPid = pid; }
204
- } catch { /* none running */ }
205
- let removedKey = false;
206
- try { if (fs.existsSync(plist)) { fs.unlinkSync(plist); removedKey = true; } } catch { /* gone */ }
207
- try { fs.unlinkSync(path.join(dir, 'service.json')); } catch { /* gone */ }
208
- return { uninstalled: true, removedKey, stoppedPid };
209
- }
210
-
211
- async function macStatus({ dir, launchAgentsDir, execFileImpl, probeImpl, uid, port }) {
212
- const plist = plistPath(launchAgentsDir);
213
- const installed = fs.existsSync(plist); // the plist IS the persistent registration
214
- const target = `gui/${uid}/${LAUNCHD_LABEL}`;
215
- let loaded = false, launchdPid = null;
216
- try {
217
- const { stdout } = await execFileImpl('launchctl', ['print', target]);
218
- launchdPid = Number((stdout.match(/\bpid\s*=\s*(\d+)/i) || [])[1]) || null;
219
- loaded = launchdPid !== null || /state\s*=\s*running/i.test(stdout);
220
- } catch { /* not loaded into launchd */ }
221
- let { supervisorPid, supervisorAlive } = supervisorLiveness(dir);
222
- if (launchdPid) { supervisorPid = launchdPid; supervisorAlive = true; }
223
- else if (loaded) { supervisorAlive = true; }
224
- const serverUp = await probeImpl(port);
225
- return { installed, runValue: installed ? plist : null, supervisorPid, supervisorAlive, serverUp, loaded };
226
- }
227
-
228
- // --- Linux implementation (systemd --user) ----------------------------------
229
- //
230
- // Mirrors the macOS model: write the unit, `enable` it for the NEXT login, but
231
- // do NOT `--now` it at install time (that would start the supervisor and grab
232
- // :5173, colliding with the `wild-workspace` the user runs this session). The
233
- // user systemd manager auto-starts WantedBy=default.target units at login.
234
- // Crash-restart is free via Restart=always (footgun checklist §8). `systemctl
235
- // --user` needs a user DBus/session bus; if it's absent (headless box) the unit
236
- // file is still written and `enabled:false` is reported so the caller can warn.
237
-
238
- export const SYSTEMD_UNIT = 'wild-workspace.service';
239
-
240
- function defaultSystemdUserDir() {
241
- const xdg = process.env.XDG_CONFIG_HOME;
242
- return xdg ? path.join(xdg, 'systemd', 'user') : path.join(os.homedir(), '.config', 'systemd', 'user');
243
- }
244
- export function unitPath(systemdUserDir) {
245
- return path.join(systemdUserDir, SYSTEMD_UNIT);
246
- }
247
-
248
- // A user unit that runs `node <cli> service run` at login + relaunches on exit.
249
- // `WantedBy=default.target` is the USER manager's login target — NOT
250
- // `multi-user.target` (which doesn't exist in user systemd; a real VS Code bug,
251
- // always-on-design.md §7). Paths are double-quoted so spaces in $HOME survive.
252
- export function buildUnit({ node, cli, workspaceDir }) {
253
- const env = workspaceDir ? [`Environment=WILD_WORKSPACE_DIR=${workspaceDir}`] : [];
254
- return [
255
- '[Unit]',
256
- 'Description=wild-workspace always-on supervisor',
257
- 'After=default.target',
258
- '',
259
- '[Service]',
260
- 'Type=simple',
261
- `ExecStart="${node}" "${cli}" service run`,
262
- 'Restart=always',
263
- 'RestartSec=2',
264
- ...env,
265
- '',
266
- '[Install]',
267
- 'WantedBy=default.target',
268
- '',
269
- ].join('\n');
270
- }
271
-
272
- async function linuxInstall({ node, cli, workspaceDir, port, version }, { dir, systemdUserDir, execFileImpl }) {
273
- fs.mkdirSync(dir, { recursive: true });
274
- fs.mkdirSync(systemdUserDir, { recursive: true });
275
- const unit = unitPath(systemdUserDir);
276
- const serviceJson = path.join(dir, 'service.json');
277
- fs.writeFileSync(unit, buildUnit({ node, cli, workspaceDir }), 'utf8');
278
- fs.writeFileSync(
279
- serviceJson,
280
- JSON.stringify({ node, cli, workspaceDir, port, version, installedAt: new Date().toISOString() }, null, 2),
281
- 'utf8',
282
- );
283
- // Enable for the NEXT login (no --now → don't grab :5173 this session). Both
284
- // calls are best-effort: a box without a user session bus still gets the unit
285
- // file, and `enabled:false` tells the caller to warn instead of failing hard.
286
- let enabled = false, note = null;
287
- try {
288
- await execFileImpl('systemctl', ['--user', 'daemon-reload']);
289
- await execFileImpl('systemctl', ['--user', 'enable', SYSTEMD_UNIT]);
290
- enabled = true;
291
- } catch (e) {
292
- note = `unit written but \`systemctl --user enable\` failed (${String(e?.message || e).split('\n')[0]}); ` +
293
- `run it after logging into a graphical session, or start manually with \`wild-workspace\`.`;
294
- }
295
- return { installed: true, mechanism: 'systemd --user', launcher: unit, unit, runValue: unit, serviceJson, enabled, startsAtNextLogin: true, note };
296
- }
297
-
298
- async function linuxUninstall({ dir, systemdUserDir, execFileImpl, killImpl }) {
299
- // disable --now both stops a running instance and removes the login symlink.
300
- let disabled = false;
301
- try { await execFileImpl('systemctl', ['--user', 'disable', '--now', SYSTEMD_UNIT]); disabled = true; } catch { /* not enabled */ }
302
- const unit = unitPath(systemdUserDir);
303
- let removedKey = false;
304
- try { if (fs.existsSync(unit)) { fs.unlinkSync(unit); removedKey = true; } } catch { /* gone */ }
305
- try { await execFileImpl('systemctl', ['--user', 'daemon-reload']); } catch { /* no session bus */ }
306
- // A manually-started supervisor still holds the lock — stop it too (mirror).
307
- let stoppedPid = null;
308
- try {
309
- const pid = Number(fs.readFileSync(path.join(dir, 'supervisor.lock'), 'utf8').trim());
310
- if (pid) { killImpl(pid); stoppedPid = pid; }
311
- } catch { /* none running */ }
312
- try { fs.unlinkSync(path.join(dir, 'service.json')); } catch { /* gone */ }
313
- return { uninstalled: true, removedKey, disabled, stoppedPid };
314
- }
315
-
316
- async function linuxStatus({ dir, systemdUserDir, execFileImpl, probeImpl, port }) {
317
- const unit = unitPath(systemdUserDir);
318
- const installed = fs.existsSync(unit); // the unit file IS the persistent registration
319
- let enabled = false, active = false, mainPid = null;
320
- try { const { stdout } = await execFileImpl('systemctl', ['--user', 'is-enabled', SYSTEMD_UNIT]); enabled = /enabled/.test(stdout); } catch { /* not enabled / no bus */ }
321
- try { const { stdout } = await execFileImpl('systemctl', ['--user', 'is-active', SYSTEMD_UNIT]); active = /^active/m.test(stdout.trim()); } catch { /* inactive */ }
322
- try { const { stdout } = await execFileImpl('systemctl', ['--user', 'show', SYSTEMD_UNIT, '-p', 'MainPID', '--value']); mainPid = Number(String(stdout).trim()) || null; } catch { /* none */ }
323
- let { supervisorPid, supervisorAlive } = supervisorLiveness(dir);
324
- if (mainPid) { supervisorPid = mainPid; supervisorAlive = true; }
325
- else if (active) { supervisorAlive = true; }
326
- const serverUp = await probeImpl(port);
327
- return { installed, runValue: installed ? unit : null, supervisorPid, supervisorAlive, serverUp, enabled, active };
328
- }
329
-
330
- // --- public API (platform dispatch) ----------------------------------------
331
-
332
- const unsupported = (platform, key) => ({
333
- [key]: false,
334
- supported: false,
335
- platform,
336
- message: `always-on autostart for ${platform} is not implemented yet — run \`wild-workspace\` to start manually (see docs/always-on-design.md)`,
337
- });
338
-
339
- export async function installService(opts = {}, deps = {}) {
340
- const platform = deps.platform || process.platform;
341
- if (platform === 'win32') {
342
- return winInstall(opts, { dir: deps.dir || globalDir(), execFileImpl: deps.execFileImpl || execFileP });
343
- }
344
- if (platform === 'darwin') {
345
- return macInstall(opts, {
346
- dir: deps.dir || globalDir(),
347
- launchAgentsDir: deps.launchAgentsDir || defaultLaunchAgentsDir(),
348
- });
349
- }
350
- if (platform === 'linux') {
351
- return linuxInstall(opts, {
352
- dir: deps.dir || globalDir(),
353
- systemdUserDir: deps.systemdUserDir || defaultSystemdUserDir(),
354
- execFileImpl: deps.execFileImpl || execFileP,
355
- });
356
- }
357
- return unsupported(platform, 'installed');
358
- }
359
-
360
- export async function uninstallService(deps = {}) {
361
- const platform = deps.platform || process.platform;
362
- if (platform === 'win32') {
363
- return winUninstall({
364
- dir: deps.dir || globalDir(),
365
- execFileImpl: deps.execFileImpl || execFileP,
366
- killImpl: deps.killImpl || ((pid) => process.kill(pid)),
367
- });
368
- }
369
- if (platform === 'darwin') {
370
- return macUninstall({
371
- dir: deps.dir || globalDir(),
372
- launchAgentsDir: deps.launchAgentsDir || defaultLaunchAgentsDir(),
373
- execFileImpl: deps.execFileImpl || execFileP,
374
- killImpl: deps.killImpl || ((pid) => process.kill(pid)),
375
- uid: deps.uid ?? currentUid(),
376
- });
377
- }
378
- if (platform === 'linux') {
379
- return linuxUninstall({
380
- dir: deps.dir || globalDir(),
381
- systemdUserDir: deps.systemdUserDir || defaultSystemdUserDir(),
382
- execFileImpl: deps.execFileImpl || execFileP,
383
- killImpl: deps.killImpl || ((pid) => process.kill(pid)),
384
- });
385
- }
386
- return unsupported(platform, 'uninstalled');
387
- }
388
-
389
- export async function serviceStatus(opts = {}, deps = {}) {
390
- const platform = deps.platform || process.platform;
391
- if (platform === 'win32') {
392
- return winStatus({
393
- dir: deps.dir || globalDir(),
394
- execFileImpl: deps.execFileImpl || execFileP,
395
- probeImpl: deps.probeImpl || (async () => false),
396
- port: opts.port || 5173,
397
- });
398
- }
399
- if (platform === 'darwin') {
400
- return macStatus({
401
- dir: deps.dir || globalDir(),
402
- launchAgentsDir: deps.launchAgentsDir || defaultLaunchAgentsDir(),
403
- execFileImpl: deps.execFileImpl || execFileP,
404
- probeImpl: deps.probeImpl || (async () => false),
405
- uid: deps.uid ?? currentUid(),
406
- port: opts.port || 5173,
407
- });
408
- }
409
- if (platform === 'linux') {
410
- return linuxStatus({
411
- dir: deps.dir || globalDir(),
412
- systemdUserDir: deps.systemdUserDir || defaultSystemdUserDir(),
413
- execFileImpl: deps.execFileImpl || execFileP,
414
- probeImpl: deps.probeImpl || (async () => false),
415
- port: opts.port || 5173,
416
- });
417
- }
418
- return { supported: false, platform };
419
- }
1
+ // service.mjs — installs / removes the per-OS, NO-ADMIN autostart entry that
2
+ // launches the WorkspaceSupervisor at login. See docs/always-on-design.md.
3
+ //
4
+ // Windows (proven end-to-end incl. a real reboot, 2026-05-30): writes a tiny VBS
5
+ // that runs `node <cli> service run` with no window, and registers it under
6
+ // HKCU\...\Run (per-user, NO admin / UAC).
7
+ //
8
+ // macOS (code-complete + unit-tested 2026-06-01; real-Mac reboot proof pending):
9
+ // writes a `~/Library/LaunchAgents/<label>.plist` (RunAtLoad + KeepAlive +
10
+ // ThrottleInterval). Install does NOT start it now (no `launchctl bootstrap`) —
11
+ // launchd auto-loads the plist at the NEXT login, mirroring the Windows HKCU\Run
12
+ // model. (Starting it at install time would grab :5173 and collide with the
13
+ // `wild-workspace` the user runs this session.) Uninstall uses `launchctl bootout`
14
+ // to stop any instance loaded from a prior login. Runs as the user, NO admin —
15
+ // macOS 13+ shows a one-time "Background item added" toast + a Login Items toggle
16
+ // (consent, not admin). launchd provides crash-restart for free; the supervisor
17
+ // still owns the singleton lock + child watchdog.
18
+ //
19
+ // Linux (systemd --user) is designed but not yet implemented — it returns a clear
20
+ // "not yet" result so callers degrade gracefully (the user runs `wild-workspace`).
21
+ //
22
+ // All state (the VBS / plist, service.json, the supervisor's lock/logs) lives in
23
+ // the machine-global dir (~/.wild-workspace) or ~/Library/LaunchAgents, NEVER the
24
+ // synced workspace (locked principle #1). Every external touch-point (reg.exe,
25
+ // launchctl, kill) is an injected seam for testability.
26
+
27
+ import { execFile } from 'node:child_process';
28
+ import { promisify } from 'node:util';
29
+ import fs from 'node:fs';
30
+ import os from 'node:os';
31
+ import path from 'node:path';
32
+
33
+ const execFileP = promisify(execFile);
34
+
35
+ export const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
36
+ export const RUN_VALUE_NAME = 'WildWorkspace';
37
+ export const LAUNCHD_LABEL = 'llc.venturewild.workspace';
38
+
39
+ export function globalDir() { return path.join(os.homedir(), '.wild-workspace'); }
40
+ function defaultLaunchAgentsDir() { return path.join(os.homedir(), 'Library', 'LaunchAgents'); }
41
+ function currentUid() { return typeof process.getuid === 'function' ? process.getuid() : 0; }
42
+
43
+ /** Is per-user autostart implemented for this platform yet? */
44
+ export function isSupported(platform = process.platform) {
45
+ return platform === 'win32' || platform === 'darwin' || platform === 'linux';
46
+ }
47
+
48
+ /** Shared: read the supervisor's pidfile and report whether that pid is alive. */
49
+ function supervisorLiveness(dir) {
50
+ let supervisorPid = null, supervisorAlive = false;
51
+ try { supervisorPid = Number(fs.readFileSync(path.join(dir, 'supervisor.lock'), 'utf8').trim()) || null; } catch { /* none */ }
52
+ if (supervisorPid) {
53
+ try { process.kill(supervisorPid, 0); supervisorAlive = true; } catch (e) { supervisorAlive = !!(e && e.code === 'EPERM'); }
54
+ }
55
+ return { supervisorPid, supervisorAlive };
56
+ }
57
+
58
+ // --- Windows implementation -------------------------------------------------
59
+
60
+ // A VBS that runs `node <cli> service run` with NO window (0 = SW_HIDE,
61
+ // False = don't wait). VBS string literals escape a `"` as `""`.
62
+ export function buildVbs(node, cli) {
63
+ const cmd = `"${node}" "${cli}" service run`;
64
+ const vbsArg = '"' + cmd.replace(/"/g, '""') + '"';
65
+ return [
66
+ "' wild-workspace always-on launcher (generated by `wild-workspace service install`).",
67
+ "' Starts the workspace supervisor hidden at login. Disable via",
68
+ "' `wild-workspace service uninstall` (removes the HKCU\\...\\Run value).",
69
+ `CreateObject("WScript.Shell").Run ${vbsArg}, 0, False`,
70
+ '',
71
+ ].join('\r\n');
72
+ }
73
+
74
+ /** The HKCU\Run value: launch the VBS via the windowless wscript host. */
75
+ export function buildRunValue(vbs) { return `wscript.exe "${vbs}"`; }
76
+
77
+ async function winInstall({ node, cli, workspaceDir, port, version }, { dir, execFileImpl }) {
78
+ fs.mkdirSync(dir, { recursive: true });
79
+ const vbs = path.join(dir, 'launch-hidden.vbs');
80
+ const serviceJson = path.join(dir, 'service.json');
81
+ fs.writeFileSync(vbs, buildVbs(node, cli), 'utf8');
82
+ fs.writeFileSync(
83
+ serviceJson,
84
+ JSON.stringify({ node, cli, workspaceDir, port, version, installedAt: new Date().toISOString() }, null, 2),
85
+ 'utf8',
86
+ );
87
+ const runValue = buildRunValue(vbs);
88
+ await execFileImpl('reg', ['add', RUN_KEY, '/v', RUN_VALUE_NAME, '/t', 'REG_SZ', '/d', runValue, '/f']);
89
+ return { installed: true, mechanism: 'HKCU\\Run', launcher: vbs, vbs, runValue, serviceJson };
90
+ }
91
+
92
+ async function winUninstall({ dir, execFileImpl, killImpl }) {
93
+ let removedKey = false;
94
+ try { await execFileImpl('reg', ['delete', RUN_KEY, '/v', RUN_VALUE_NAME, '/f']); removedKey = true; } catch { /* not present */ }
95
+ let stoppedPid = null;
96
+ try {
97
+ const pid = Number(fs.readFileSync(path.join(dir, 'supervisor.lock'), 'utf8').trim());
98
+ if (pid) { killImpl(pid); stoppedPid = pid; }
99
+ } catch { /* none running */ }
100
+ for (const f of ['launch-hidden.vbs', 'service.json']) { try { fs.unlinkSync(path.join(dir, f)); } catch { /* gone */ } }
101
+ return { uninstalled: true, removedKey, stoppedPid };
102
+ }
103
+
104
+ async function winStatus({ dir, execFileImpl, probeImpl, port }) {
105
+ let installed = false, runValue = null;
106
+ try {
107
+ const { stdout } = await execFileImpl('reg', ['query', RUN_KEY, '/v', RUN_VALUE_NAME]);
108
+ installed = new RegExp(RUN_VALUE_NAME, 'i').test(stdout);
109
+ runValue = (stdout.match(/REG_SZ\s+(.*?)\s*$/m) || [])[1] || null;
110
+ } catch { /* value absent → not installed */ }
111
+ const { supervisorPid, supervisorAlive } = supervisorLiveness(dir);
112
+ const serverUp = await probeImpl(port);
113
+ return { installed, runValue, supervisorPid, supervisorAlive, serverUp };
114
+ }
115
+
116
+ // --- macOS implementation ---------------------------------------------------
117
+
118
+ export function plistPath(launchAgentsDir) {
119
+ return path.join(launchAgentsDir, `${LAUNCHD_LABEL}.plist`);
120
+ }
121
+
122
+ function xmlEscape(s) {
123
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
124
+ }
125
+
126
+ // A LaunchAgent that runs `node <cli> service run` at login + relaunches it if it
127
+ // dies (KeepAlive). ThrottleInterval bounds the restart rate. Output is logged to
128
+ // ~/.wild-workspace so a silent death is debuggable (footgun checklist §8).
129
+ export function buildPlist({ node, cli, workspaceDir, outLog, errLog, label = LAUNCHD_LABEL }) {
130
+ const args = [node, cli, 'service', 'run'].map((a) => ` <string>${xmlEscape(a)}</string>`);
131
+ const env = workspaceDir ? [
132
+ ' <key>EnvironmentVariables</key>',
133
+ ' <dict>',
134
+ ' <key>WILD_WORKSPACE_DIR</key>',
135
+ ` <string>${xmlEscape(workspaceDir)}</string>`,
136
+ ' </dict>',
137
+ ] : [];
138
+ return [
139
+ '<?xml version="1.0" encoding="UTF-8"?>',
140
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
141
+ '<plist version="1.0">',
142
+ '<dict>',
143
+ ' <key>Label</key>',
144
+ ` <string>${xmlEscape(label)}</string>`,
145
+ ' <key>ProgramArguments</key>',
146
+ ' <array>',
147
+ ...args,
148
+ ' </array>',
149
+ ' <key>RunAtLoad</key>',
150
+ ' <true/>',
151
+ ' <key>KeepAlive</key>',
152
+ ' <true/>',
153
+ ' <key>ThrottleInterval</key>',
154
+ ' <integer>10</integer>',
155
+ ...env,
156
+ ' <key>StandardOutPath</key>',
157
+ ` <string>${xmlEscape(outLog)}</string>`,
158
+ ' <key>StandardErrorPath</key>',
159
+ ` <string>${xmlEscape(errLog)}</string>`,
160
+ '</dict>',
161
+ '</plist>',
162
+ '',
163
+ ].join('\n');
164
+ }
165
+
166
+ async function macInstall({ node, cli, workspaceDir, port, version }, { dir, launchAgentsDir }) {
167
+ fs.mkdirSync(dir, { recursive: true });
168
+ fs.mkdirSync(launchAgentsDir, { recursive: true });
169
+ const plist = plistPath(launchAgentsDir);
170
+ const serviceJson = path.join(dir, 'service.json');
171
+ const outLog = path.join(dir, 'launchagent.out.log');
172
+ const errLog = path.join(dir, 'launchagent.err.log');
173
+ fs.writeFileSync(plist, buildPlist({ node, cli, workspaceDir, outLog, errLog }), 'utf8');
174
+ fs.writeFileSync(
175
+ serviceJson,
176
+ JSON.stringify({ node, cli, workspaceDir, port, version, installedAt: new Date().toISOString() }, null, 2),
177
+ 'utf8',
178
+ );
179
+ // Deliberately do NOT `launchctl bootstrap` here. bootstrap + RunAtLoad would
180
+ // start the supervisor immediately, grabbing :5173 — then the `wild-workspace`
181
+ // the user runs *this session* collides on the port (createServer rejects on
182
+ // EADDRINUSE). Dropping the plist into ~/Library/LaunchAgents is enough:
183
+ // launchd auto-loads it at the NEXT login (RunAtLoad fires then). This mirrors
184
+ // the proven Windows HKCU\Run model, which also only fires at login — so the
185
+ // current session is the manual `wild-workspace`, and always-on takes over
186
+ // from the next login/reboot (which is also the cleanest B5 proof).
187
+ return { installed: true, mechanism: 'LaunchAgent', launcher: plist, plist, label: LAUNCHD_LABEL, runValue: plist, serviceJson, startsAtNextLogin: true };
188
+ }
189
+
190
+ async function macUninstall({ dir, launchAgentsDir, execFileImpl, killImpl, uid }) {
191
+ const plist = plistPath(launchAgentsDir);
192
+ const target = `gui/${uid}/${LAUNCHD_LABEL}`;
193
+ try {
194
+ await execFileImpl('launchctl', ['bootout', target]);
195
+ } catch {
196
+ try { await execFileImpl('launchctl', ['unload', '-w', plist]); } catch { /* not loaded */ }
197
+ }
198
+ // launchd's bootout SIGTERMs the launchd-managed supervisor; a manually-started
199
+ // one still holds the lock, so stop it too (mirrors the Windows path).
200
+ let stoppedPid = null;
201
+ try {
202
+ const pid = Number(fs.readFileSync(path.join(dir, 'supervisor.lock'), 'utf8').trim());
203
+ if (pid) { killImpl(pid); stoppedPid = pid; }
204
+ } catch { /* none running */ }
205
+ let removedKey = false;
206
+ try { if (fs.existsSync(plist)) { fs.unlinkSync(plist); removedKey = true; } } catch { /* gone */ }
207
+ try { fs.unlinkSync(path.join(dir, 'service.json')); } catch { /* gone */ }
208
+ return { uninstalled: true, removedKey, stoppedPid };
209
+ }
210
+
211
+ async function macStatus({ dir, launchAgentsDir, execFileImpl, probeImpl, uid, port }) {
212
+ const plist = plistPath(launchAgentsDir);
213
+ const installed = fs.existsSync(plist); // the plist IS the persistent registration
214
+ const target = `gui/${uid}/${LAUNCHD_LABEL}`;
215
+ let loaded = false, launchdPid = null;
216
+ try {
217
+ const { stdout } = await execFileImpl('launchctl', ['print', target]);
218
+ launchdPid = Number((stdout.match(/\bpid\s*=\s*(\d+)/i) || [])[1]) || null;
219
+ loaded = launchdPid !== null || /state\s*=\s*running/i.test(stdout);
220
+ } catch { /* not loaded into launchd */ }
221
+ let { supervisorPid, supervisorAlive } = supervisorLiveness(dir);
222
+ if (launchdPid) { supervisorPid = launchdPid; supervisorAlive = true; }
223
+ else if (loaded) { supervisorAlive = true; }
224
+ const serverUp = await probeImpl(port);
225
+ return { installed, runValue: installed ? plist : null, supervisorPid, supervisorAlive, serverUp, loaded };
226
+ }
227
+
228
+ // --- Linux implementation (systemd --user) ----------------------------------
229
+ //
230
+ // Mirrors the macOS model: write the unit, `enable` it for the NEXT login, but
231
+ // do NOT `--now` it at install time (that would start the supervisor and grab
232
+ // :5173, colliding with the `wild-workspace` the user runs this session). The
233
+ // user systemd manager auto-starts WantedBy=default.target units at login.
234
+ // Crash-restart is free via Restart=always (footgun checklist §8). `systemctl
235
+ // --user` needs a user DBus/session bus; if it's absent (headless box) the unit
236
+ // file is still written and `enabled:false` is reported so the caller can warn.
237
+
238
+ export const SYSTEMD_UNIT = 'wild-workspace.service';
239
+
240
+ function defaultSystemdUserDir() {
241
+ const xdg = process.env.XDG_CONFIG_HOME;
242
+ return xdg ? path.join(xdg, 'systemd', 'user') : path.join(os.homedir(), '.config', 'systemd', 'user');
243
+ }
244
+ export function unitPath(systemdUserDir) {
245
+ return path.join(systemdUserDir, SYSTEMD_UNIT);
246
+ }
247
+
248
+ // A user unit that runs `node <cli> service run` at login + relaunches on exit.
249
+ // `WantedBy=default.target` is the USER manager's login target — NOT
250
+ // `multi-user.target` (which doesn't exist in user systemd; a real VS Code bug,
251
+ // always-on-design.md §7). Paths are double-quoted so spaces in $HOME survive.
252
+ export function buildUnit({ node, cli, workspaceDir }) {
253
+ const env = workspaceDir ? [`Environment=WILD_WORKSPACE_DIR=${workspaceDir}`] : [];
254
+ return [
255
+ '[Unit]',
256
+ 'Description=wild-workspace always-on supervisor',
257
+ 'After=default.target',
258
+ '',
259
+ '[Service]',
260
+ 'Type=simple',
261
+ `ExecStart="${node}" "${cli}" service run`,
262
+ 'Restart=always',
263
+ 'RestartSec=2',
264
+ ...env,
265
+ '',
266
+ '[Install]',
267
+ 'WantedBy=default.target',
268
+ '',
269
+ ].join('\n');
270
+ }
271
+
272
+ async function linuxInstall({ node, cli, workspaceDir, port, version }, { dir, systemdUserDir, execFileImpl }) {
273
+ fs.mkdirSync(dir, { recursive: true });
274
+ fs.mkdirSync(systemdUserDir, { recursive: true });
275
+ const unit = unitPath(systemdUserDir);
276
+ const serviceJson = path.join(dir, 'service.json');
277
+ fs.writeFileSync(unit, buildUnit({ node, cli, workspaceDir }), 'utf8');
278
+ fs.writeFileSync(
279
+ serviceJson,
280
+ JSON.stringify({ node, cli, workspaceDir, port, version, installedAt: new Date().toISOString() }, null, 2),
281
+ 'utf8',
282
+ );
283
+ // Enable for the NEXT login (no --now → don't grab :5173 this session). Both
284
+ // calls are best-effort: a box without a user session bus still gets the unit
285
+ // file, and `enabled:false` tells the caller to warn instead of failing hard.
286
+ let enabled = false, note = null;
287
+ try {
288
+ await execFileImpl('systemctl', ['--user', 'daemon-reload']);
289
+ await execFileImpl('systemctl', ['--user', 'enable', SYSTEMD_UNIT]);
290
+ enabled = true;
291
+ } catch (e) {
292
+ note = `unit written but \`systemctl --user enable\` failed (${String(e?.message || e).split('\n')[0]}); ` +
293
+ `run it after logging into a graphical session, or start manually with \`wild-workspace\`.`;
294
+ }
295
+ return { installed: true, mechanism: 'systemd --user', launcher: unit, unit, runValue: unit, serviceJson, enabled, startsAtNextLogin: true, note };
296
+ }
297
+
298
+ async function linuxUninstall({ dir, systemdUserDir, execFileImpl, killImpl }) {
299
+ // disable --now both stops a running instance and removes the login symlink.
300
+ let disabled = false;
301
+ try { await execFileImpl('systemctl', ['--user', 'disable', '--now', SYSTEMD_UNIT]); disabled = true; } catch { /* not enabled */ }
302
+ const unit = unitPath(systemdUserDir);
303
+ let removedKey = false;
304
+ try { if (fs.existsSync(unit)) { fs.unlinkSync(unit); removedKey = true; } } catch { /* gone */ }
305
+ try { await execFileImpl('systemctl', ['--user', 'daemon-reload']); } catch { /* no session bus */ }
306
+ // A manually-started supervisor still holds the lock — stop it too (mirror).
307
+ let stoppedPid = null;
308
+ try {
309
+ const pid = Number(fs.readFileSync(path.join(dir, 'supervisor.lock'), 'utf8').trim());
310
+ if (pid) { killImpl(pid); stoppedPid = pid; }
311
+ } catch { /* none running */ }
312
+ try { fs.unlinkSync(path.join(dir, 'service.json')); } catch { /* gone */ }
313
+ return { uninstalled: true, removedKey, disabled, stoppedPid };
314
+ }
315
+
316
+ async function linuxStatus({ dir, systemdUserDir, execFileImpl, probeImpl, port }) {
317
+ const unit = unitPath(systemdUserDir);
318
+ const installed = fs.existsSync(unit); // the unit file IS the persistent registration
319
+ let enabled = false, active = false, mainPid = null;
320
+ try { const { stdout } = await execFileImpl('systemctl', ['--user', 'is-enabled', SYSTEMD_UNIT]); enabled = /enabled/.test(stdout); } catch { /* not enabled / no bus */ }
321
+ try { const { stdout } = await execFileImpl('systemctl', ['--user', 'is-active', SYSTEMD_UNIT]); active = /^active/m.test(stdout.trim()); } catch { /* inactive */ }
322
+ try { const { stdout } = await execFileImpl('systemctl', ['--user', 'show', SYSTEMD_UNIT, '-p', 'MainPID', '--value']); mainPid = Number(String(stdout).trim()) || null; } catch { /* none */ }
323
+ let { supervisorPid, supervisorAlive } = supervisorLiveness(dir);
324
+ if (mainPid) { supervisorPid = mainPid; supervisorAlive = true; }
325
+ else if (active) { supervisorAlive = true; }
326
+ const serverUp = await probeImpl(port);
327
+ return { installed, runValue: installed ? unit : null, supervisorPid, supervisorAlive, serverUp, enabled, active };
328
+ }
329
+
330
+ // --- public API (platform dispatch) ----------------------------------------
331
+
332
+ const unsupported = (platform, key) => ({
333
+ [key]: false,
334
+ supported: false,
335
+ platform,
336
+ message: `always-on autostart for ${platform} is not implemented yet — run \`wild-workspace\` to start manually (see docs/always-on-design.md)`,
337
+ });
338
+
339
+ export async function installService(opts = {}, deps = {}) {
340
+ const platform = deps.platform || process.platform;
341
+ if (platform === 'win32') {
342
+ return winInstall(opts, { dir: deps.dir || globalDir(), execFileImpl: deps.execFileImpl || execFileP });
343
+ }
344
+ if (platform === 'darwin') {
345
+ return macInstall(opts, {
346
+ dir: deps.dir || globalDir(),
347
+ launchAgentsDir: deps.launchAgentsDir || defaultLaunchAgentsDir(),
348
+ });
349
+ }
350
+ if (platform === 'linux') {
351
+ return linuxInstall(opts, {
352
+ dir: deps.dir || globalDir(),
353
+ systemdUserDir: deps.systemdUserDir || defaultSystemdUserDir(),
354
+ execFileImpl: deps.execFileImpl || execFileP,
355
+ });
356
+ }
357
+ return unsupported(platform, 'installed');
358
+ }
359
+
360
+ export async function uninstallService(deps = {}) {
361
+ const platform = deps.platform || process.platform;
362
+ if (platform === 'win32') {
363
+ return winUninstall({
364
+ dir: deps.dir || globalDir(),
365
+ execFileImpl: deps.execFileImpl || execFileP,
366
+ killImpl: deps.killImpl || ((pid) => process.kill(pid)),
367
+ });
368
+ }
369
+ if (platform === 'darwin') {
370
+ return macUninstall({
371
+ dir: deps.dir || globalDir(),
372
+ launchAgentsDir: deps.launchAgentsDir || defaultLaunchAgentsDir(),
373
+ execFileImpl: deps.execFileImpl || execFileP,
374
+ killImpl: deps.killImpl || ((pid) => process.kill(pid)),
375
+ uid: deps.uid ?? currentUid(),
376
+ });
377
+ }
378
+ if (platform === 'linux') {
379
+ return linuxUninstall({
380
+ dir: deps.dir || globalDir(),
381
+ systemdUserDir: deps.systemdUserDir || defaultSystemdUserDir(),
382
+ execFileImpl: deps.execFileImpl || execFileP,
383
+ killImpl: deps.killImpl || ((pid) => process.kill(pid)),
384
+ });
385
+ }
386
+ return unsupported(platform, 'uninstalled');
387
+ }
388
+
389
+ export async function serviceStatus(opts = {}, deps = {}) {
390
+ const platform = deps.platform || process.platform;
391
+ if (platform === 'win32') {
392
+ return winStatus({
393
+ dir: deps.dir || globalDir(),
394
+ execFileImpl: deps.execFileImpl || execFileP,
395
+ probeImpl: deps.probeImpl || (async () => false),
396
+ port: opts.port || 5173,
397
+ });
398
+ }
399
+ if (platform === 'darwin') {
400
+ return macStatus({
401
+ dir: deps.dir || globalDir(),
402
+ launchAgentsDir: deps.launchAgentsDir || defaultLaunchAgentsDir(),
403
+ execFileImpl: deps.execFileImpl || execFileP,
404
+ probeImpl: deps.probeImpl || (async () => false),
405
+ uid: deps.uid ?? currentUid(),
406
+ port: opts.port || 5173,
407
+ });
408
+ }
409
+ if (platform === 'linux') {
410
+ return linuxStatus({
411
+ dir: deps.dir || globalDir(),
412
+ systemdUserDir: deps.systemdUserDir || defaultSystemdUserDir(),
413
+ execFileImpl: deps.execFileImpl || execFileP,
414
+ probeImpl: deps.probeImpl || (async () => false),
415
+ port: opts.port || 5173,
416
+ });
417
+ }
418
+ return { supported: false, platform };
419
+ }