@venturewild/workspace 0.4.2 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +112 -112
  3. package/package.json +85 -83
  4. package/server/bin/wild-workspace.mjs +1096 -1096
  5. package/server/src/account.mjs +114 -114
  6. package/server/src/agent-login.mjs +146 -146
  7. package/server/src/agent-readiness.mjs +200 -200
  8. package/server/src/agent.mjs +468 -468
  9. package/server/src/bazaar/core.mjs +790 -579
  10. package/server/src/bazaar/index.mjs +88 -75
  11. package/server/src/bazaar/mcp-server.mjs +417 -328
  12. package/server/src/bazaar/mock-tickup.mjs +97 -97
  13. package/server/src/bazaar/preview-server.mjs +95 -95
  14. package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -23
  15. package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -24
  16. package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -29
  17. package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -25
  18. package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -21
  19. package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -24
  20. package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -31
  21. package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -25
  22. package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -79
  23. package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +40 -32
  24. package/server/src/canvas/core.mjs +446 -446
  25. package/server/src/canvas/index.mjs +42 -42
  26. package/server/src/canvas/mcp-server.mjs +253 -253
  27. package/server/src/canvas-rails.mjs +108 -108
  28. package/server/src/config.mjs +404 -404
  29. package/server/src/daemon-bin.mjs +110 -110
  30. package/server/src/daemon-supervisor.mjs +285 -285
  31. package/server/src/doctor.mjs +375 -375
  32. package/server/src/inbox.mjs +86 -86
  33. package/server/src/index.mjs +3279 -3032
  34. package/server/src/listings-rails.mjs +126 -0
  35. package/server/src/logpaths.mjs +98 -98
  36. package/server/src/observability.mjs +45 -45
  37. package/server/src/operator.mjs +92 -92
  38. package/server/src/pairing.mjs +137 -137
  39. package/server/src/service.mjs +515 -515
  40. package/server/src/session-reporter.mjs +201 -201
  41. package/server/src/settings.mjs +145 -145
  42. package/server/src/share.mjs +182 -182
  43. package/server/src/skills.mjs +213 -213
  44. package/server/src/supervisor.mjs +647 -647
  45. package/server/src/support-consent.mjs +133 -133
  46. package/server/src/sync.mjs +248 -248
  47. package/server/src/transcript.mjs +121 -121
  48. package/server/src/turn-mcp.mjs +46 -46
  49. package/server/src/usage.mjs +405 -405
  50. package/server/src/workspace-registry.mjs +295 -295
  51. package/server/src/workspaces.mjs +145 -135
  52. package/web/dist/assets/index-B8tHt7x-.css +32 -0
  53. package/web/dist/assets/index-BRY-IKaC.js +131 -0
  54. package/web/dist/index.html +2 -2
  55. package/web/dist/assets/index-DahRXN26.js +0 -91
  56. package/web/dist/assets/index-NXZN2LU2.css +0 -1
@@ -1,515 +1,515 @@
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, spawn } 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
- // --- self-restart: re-exec the supervisor to load freshly-installed code -----
331
- //
332
- // After an auto-update installs new code, the long-lived SUPERVISOR keeps running
333
- // the OLD code until it restarts — RC1b restarts the server CHILD, never the
334
- // supervisor parent. That's the go-live "stale-process-after-update chain"
335
- // (remote-support-and-self-healing-design.md Part 8): the supervisor's daemon-
336
- // drift recycle logic can't run, so the daemon stays on the old binary and the
337
- // support channel silently 504s. restartSelf() restarts the supervisor itself,
338
- // per-OS, so the whole stack lands new code with NO reboot:
339
- // - macOS: launchctl kickstart -k gui/<uid>/<label> (launchd kills + relaunches us)
340
- // - Linux: systemctl --user restart <unit> (only when systemd-managed)
341
- // - Windows: re-spawn the hidden VBS launcher; the caller then exits so the
342
- // successor takes the singleton lock (no service manager to do it).
343
- //
344
- // SAFE BY CONSTRUCTION — never kill the only supervisor on a non-managed run:
345
- // - mac: kickstart errors when the job isn't loaded (manual `service run`) →
346
- // reported not-restarted, supervisor keeps running (old code, same as
347
- // before this feature) rather than dying.
348
- // - linux: gated on INVOCATION_ID (systemd sets it for its own services); a
349
- // manual run has none → no-op (a `restart` would otherwise spawn a
350
- // SECOND supervisor that collides on the singleton lock).
351
- // - win: only re-spawns when the installed launcher exists.
352
- // On mac/Linux the service manager kills+sequences the restart (no lock race). On
353
- // Windows the caller exits AFTER we've spawned the successor; the successor's node
354
- // boot (~hundreds of ms) outlasts the caller's lock release, so it takes over
355
- // cleanly — and a lost race merely falls back to the next-login launch (no user
356
- // downtime: the server + daemon are independent processes that keep serving).
357
-
358
- async function macRestartSelf({ execFileImpl, uid, label }) {
359
- const target = `gui/${uid}/${label}`;
360
- try {
361
- await execFileImpl('launchctl', ['kickstart', '-k', target]);
362
- return { restarted: true, method: 'launchctl-kickstart', target };
363
- } catch (e) {
364
- return { restarted: false, method: 'launchctl-kickstart', target, error: String(e?.message || e).split('\n')[0] };
365
- }
366
- }
367
-
368
- async function linuxRestartSelf({ execFileImpl, env, unit }) {
369
- if (!env.INVOCATION_ID) {
370
- return { restarted: false, method: 'systemctl', unit, reason: 'not-systemd-managed' };
371
- }
372
- try {
373
- await execFileImpl('systemctl', ['--user', 'restart', unit]);
374
- return { restarted: true, method: 'systemctl', unit };
375
- } catch (e) {
376
- return { restarted: false, method: 'systemctl', unit, error: String(e?.message || e).split('\n')[0] };
377
- }
378
- }
379
-
380
- function winRestartSelf({ dir, spawnImpl }) {
381
- const vbs = path.join(dir, 'launch-hidden.vbs');
382
- if (!fs.existsSync(vbs)) {
383
- return { restarted: false, method: 'win-relaunch', reason: 'launcher-absent' };
384
- }
385
- try {
386
- const child = spawnImpl('wscript.exe', [vbs], { detached: true, windowsHide: true, stdio: 'ignore' });
387
- child?.unref?.();
388
- // willExit: the caller MUST process.exit() so the successor can take the lock.
389
- return { restarted: true, method: 'win-relaunch', launcher: vbs, willExit: true };
390
- } catch (e) {
391
- return { restarted: false, method: 'win-relaunch', launcher: vbs, error: String(e?.message || e).split('\n')[0] };
392
- }
393
- }
394
-
395
- /**
396
- * Restart the always-on supervisor process so freshly-installed supervisor code
397
- * loads (the Part-8 stale-process fix). Returns { restarted, method, ... }; a
398
- * `willExit:true` (Windows) tells the caller to process.exit() after we return so
399
- * the just-spawned successor can take the singleton lock. Never throws.
400
- */
401
- export async function restartSelf(opts = {}, deps = {}) {
402
- const platform = deps.platform || process.platform;
403
- // dir (where the Windows launcher lives) may come from the operational opts
404
- // (the supervisor passes its configured globalDir) or the test deps.
405
- const dir = opts.dir || deps.dir || globalDir();
406
- if (platform === 'darwin') {
407
- return macRestartSelf({
408
- execFileImpl: deps.execFileImpl || execFileP,
409
- uid: deps.uid ?? currentUid(),
410
- label: deps.label || LAUNCHD_LABEL,
411
- });
412
- }
413
- if (platform === 'linux') {
414
- return linuxRestartSelf({
415
- execFileImpl: deps.execFileImpl || execFileP,
416
- env: deps.env || process.env,
417
- unit: deps.unit || SYSTEMD_UNIT,
418
- });
419
- }
420
- if (platform === 'win32') {
421
- return winRestartSelf({ dir, spawnImpl: deps.spawnImpl || spawn });
422
- }
423
- return { restarted: false, supported: false, platform };
424
- }
425
-
426
- // --- public API (platform dispatch) ----------------------------------------
427
-
428
- const unsupported = (platform, key) => ({
429
- [key]: false,
430
- supported: false,
431
- platform,
432
- message: `always-on autostart for ${platform} is not implemented yet — run \`wild-workspace\` to start manually (see docs/always-on-design.md)`,
433
- });
434
-
435
- export async function installService(opts = {}, deps = {}) {
436
- const platform = deps.platform || process.platform;
437
- if (platform === 'win32') {
438
- return winInstall(opts, { dir: deps.dir || globalDir(), execFileImpl: deps.execFileImpl || execFileP });
439
- }
440
- if (platform === 'darwin') {
441
- return macInstall(opts, {
442
- dir: deps.dir || globalDir(),
443
- launchAgentsDir: deps.launchAgentsDir || defaultLaunchAgentsDir(),
444
- });
445
- }
446
- if (platform === 'linux') {
447
- return linuxInstall(opts, {
448
- dir: deps.dir || globalDir(),
449
- systemdUserDir: deps.systemdUserDir || defaultSystemdUserDir(),
450
- execFileImpl: deps.execFileImpl || execFileP,
451
- });
452
- }
453
- return unsupported(platform, 'installed');
454
- }
455
-
456
- export async function uninstallService(deps = {}) {
457
- const platform = deps.platform || process.platform;
458
- if (platform === 'win32') {
459
- return winUninstall({
460
- dir: deps.dir || globalDir(),
461
- execFileImpl: deps.execFileImpl || execFileP,
462
- killImpl: deps.killImpl || ((pid) => process.kill(pid)),
463
- });
464
- }
465
- if (platform === 'darwin') {
466
- return macUninstall({
467
- dir: deps.dir || globalDir(),
468
- launchAgentsDir: deps.launchAgentsDir || defaultLaunchAgentsDir(),
469
- execFileImpl: deps.execFileImpl || execFileP,
470
- killImpl: deps.killImpl || ((pid) => process.kill(pid)),
471
- uid: deps.uid ?? currentUid(),
472
- });
473
- }
474
- if (platform === 'linux') {
475
- return linuxUninstall({
476
- dir: deps.dir || globalDir(),
477
- systemdUserDir: deps.systemdUserDir || defaultSystemdUserDir(),
478
- execFileImpl: deps.execFileImpl || execFileP,
479
- killImpl: deps.killImpl || ((pid) => process.kill(pid)),
480
- });
481
- }
482
- return unsupported(platform, 'uninstalled');
483
- }
484
-
485
- export async function serviceStatus(opts = {}, deps = {}) {
486
- const platform = deps.platform || process.platform;
487
- if (platform === 'win32') {
488
- return winStatus({
489
- dir: deps.dir || globalDir(),
490
- execFileImpl: deps.execFileImpl || execFileP,
491
- probeImpl: deps.probeImpl || (async () => false),
492
- port: opts.port || 5173,
493
- });
494
- }
495
- if (platform === 'darwin') {
496
- return macStatus({
497
- dir: deps.dir || globalDir(),
498
- launchAgentsDir: deps.launchAgentsDir || defaultLaunchAgentsDir(),
499
- execFileImpl: deps.execFileImpl || execFileP,
500
- probeImpl: deps.probeImpl || (async () => false),
501
- uid: deps.uid ?? currentUid(),
502
- port: opts.port || 5173,
503
- });
504
- }
505
- if (platform === 'linux') {
506
- return linuxStatus({
507
- dir: deps.dir || globalDir(),
508
- systemdUserDir: deps.systemdUserDir || defaultSystemdUserDir(),
509
- execFileImpl: deps.execFileImpl || execFileP,
510
- probeImpl: deps.probeImpl || (async () => false),
511
- port: opts.port || 5173,
512
- });
513
- }
514
- return { supported: false, platform };
515
- }
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, spawn } 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
+ // --- self-restart: re-exec the supervisor to load freshly-installed code -----
331
+ //
332
+ // After an auto-update installs new code, the long-lived SUPERVISOR keeps running
333
+ // the OLD code until it restarts — RC1b restarts the server CHILD, never the
334
+ // supervisor parent. That's the go-live "stale-process-after-update chain"
335
+ // (remote-support-and-self-healing-design.md Part 8): the supervisor's daemon-
336
+ // drift recycle logic can't run, so the daemon stays on the old binary and the
337
+ // support channel silently 504s. restartSelf() restarts the supervisor itself,
338
+ // per-OS, so the whole stack lands new code with NO reboot:
339
+ // - macOS: launchctl kickstart -k gui/<uid>/<label> (launchd kills + relaunches us)
340
+ // - Linux: systemctl --user restart <unit> (only when systemd-managed)
341
+ // - Windows: re-spawn the hidden VBS launcher; the caller then exits so the
342
+ // successor takes the singleton lock (no service manager to do it).
343
+ //
344
+ // SAFE BY CONSTRUCTION — never kill the only supervisor on a non-managed run:
345
+ // - mac: kickstart errors when the job isn't loaded (manual `service run`) →
346
+ // reported not-restarted, supervisor keeps running (old code, same as
347
+ // before this feature) rather than dying.
348
+ // - linux: gated on INVOCATION_ID (systemd sets it for its own services); a
349
+ // manual run has none → no-op (a `restart` would otherwise spawn a
350
+ // SECOND supervisor that collides on the singleton lock).
351
+ // - win: only re-spawns when the installed launcher exists.
352
+ // On mac/Linux the service manager kills+sequences the restart (no lock race). On
353
+ // Windows the caller exits AFTER we've spawned the successor; the successor's node
354
+ // boot (~hundreds of ms) outlasts the caller's lock release, so it takes over
355
+ // cleanly — and a lost race merely falls back to the next-login launch (no user
356
+ // downtime: the server + daemon are independent processes that keep serving).
357
+
358
+ async function macRestartSelf({ execFileImpl, uid, label }) {
359
+ const target = `gui/${uid}/${label}`;
360
+ try {
361
+ await execFileImpl('launchctl', ['kickstart', '-k', target]);
362
+ return { restarted: true, method: 'launchctl-kickstart', target };
363
+ } catch (e) {
364
+ return { restarted: false, method: 'launchctl-kickstart', target, error: String(e?.message || e).split('\n')[0] };
365
+ }
366
+ }
367
+
368
+ async function linuxRestartSelf({ execFileImpl, env, unit }) {
369
+ if (!env.INVOCATION_ID) {
370
+ return { restarted: false, method: 'systemctl', unit, reason: 'not-systemd-managed' };
371
+ }
372
+ try {
373
+ await execFileImpl('systemctl', ['--user', 'restart', unit]);
374
+ return { restarted: true, method: 'systemctl', unit };
375
+ } catch (e) {
376
+ return { restarted: false, method: 'systemctl', unit, error: String(e?.message || e).split('\n')[0] };
377
+ }
378
+ }
379
+
380
+ function winRestartSelf({ dir, spawnImpl }) {
381
+ const vbs = path.join(dir, 'launch-hidden.vbs');
382
+ if (!fs.existsSync(vbs)) {
383
+ return { restarted: false, method: 'win-relaunch', reason: 'launcher-absent' };
384
+ }
385
+ try {
386
+ const child = spawnImpl('wscript.exe', [vbs], { detached: true, windowsHide: true, stdio: 'ignore' });
387
+ child?.unref?.();
388
+ // willExit: the caller MUST process.exit() so the successor can take the lock.
389
+ return { restarted: true, method: 'win-relaunch', launcher: vbs, willExit: true };
390
+ } catch (e) {
391
+ return { restarted: false, method: 'win-relaunch', launcher: vbs, error: String(e?.message || e).split('\n')[0] };
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Restart the always-on supervisor process so freshly-installed supervisor code
397
+ * loads (the Part-8 stale-process fix). Returns { restarted, method, ... }; a
398
+ * `willExit:true` (Windows) tells the caller to process.exit() after we return so
399
+ * the just-spawned successor can take the singleton lock. Never throws.
400
+ */
401
+ export async function restartSelf(opts = {}, deps = {}) {
402
+ const platform = deps.platform || process.platform;
403
+ // dir (where the Windows launcher lives) may come from the operational opts
404
+ // (the supervisor passes its configured globalDir) or the test deps.
405
+ const dir = opts.dir || deps.dir || globalDir();
406
+ if (platform === 'darwin') {
407
+ return macRestartSelf({
408
+ execFileImpl: deps.execFileImpl || execFileP,
409
+ uid: deps.uid ?? currentUid(),
410
+ label: deps.label || LAUNCHD_LABEL,
411
+ });
412
+ }
413
+ if (platform === 'linux') {
414
+ return linuxRestartSelf({
415
+ execFileImpl: deps.execFileImpl || execFileP,
416
+ env: deps.env || process.env,
417
+ unit: deps.unit || SYSTEMD_UNIT,
418
+ });
419
+ }
420
+ if (platform === 'win32') {
421
+ return winRestartSelf({ dir, spawnImpl: deps.spawnImpl || spawn });
422
+ }
423
+ return { restarted: false, supported: false, platform };
424
+ }
425
+
426
+ // --- public API (platform dispatch) ----------------------------------------
427
+
428
+ const unsupported = (platform, key) => ({
429
+ [key]: false,
430
+ supported: false,
431
+ platform,
432
+ message: `always-on autostart for ${platform} is not implemented yet — run \`wild-workspace\` to start manually (see docs/always-on-design.md)`,
433
+ });
434
+
435
+ export async function installService(opts = {}, deps = {}) {
436
+ const platform = deps.platform || process.platform;
437
+ if (platform === 'win32') {
438
+ return winInstall(opts, { dir: deps.dir || globalDir(), execFileImpl: deps.execFileImpl || execFileP });
439
+ }
440
+ if (platform === 'darwin') {
441
+ return macInstall(opts, {
442
+ dir: deps.dir || globalDir(),
443
+ launchAgentsDir: deps.launchAgentsDir || defaultLaunchAgentsDir(),
444
+ });
445
+ }
446
+ if (platform === 'linux') {
447
+ return linuxInstall(opts, {
448
+ dir: deps.dir || globalDir(),
449
+ systemdUserDir: deps.systemdUserDir || defaultSystemdUserDir(),
450
+ execFileImpl: deps.execFileImpl || execFileP,
451
+ });
452
+ }
453
+ return unsupported(platform, 'installed');
454
+ }
455
+
456
+ export async function uninstallService(deps = {}) {
457
+ const platform = deps.platform || process.platform;
458
+ if (platform === 'win32') {
459
+ return winUninstall({
460
+ dir: deps.dir || globalDir(),
461
+ execFileImpl: deps.execFileImpl || execFileP,
462
+ killImpl: deps.killImpl || ((pid) => process.kill(pid)),
463
+ });
464
+ }
465
+ if (platform === 'darwin') {
466
+ return macUninstall({
467
+ dir: deps.dir || globalDir(),
468
+ launchAgentsDir: deps.launchAgentsDir || defaultLaunchAgentsDir(),
469
+ execFileImpl: deps.execFileImpl || execFileP,
470
+ killImpl: deps.killImpl || ((pid) => process.kill(pid)),
471
+ uid: deps.uid ?? currentUid(),
472
+ });
473
+ }
474
+ if (platform === 'linux') {
475
+ return linuxUninstall({
476
+ dir: deps.dir || globalDir(),
477
+ systemdUserDir: deps.systemdUserDir || defaultSystemdUserDir(),
478
+ execFileImpl: deps.execFileImpl || execFileP,
479
+ killImpl: deps.killImpl || ((pid) => process.kill(pid)),
480
+ });
481
+ }
482
+ return unsupported(platform, 'uninstalled');
483
+ }
484
+
485
+ export async function serviceStatus(opts = {}, deps = {}) {
486
+ const platform = deps.platform || process.platform;
487
+ if (platform === 'win32') {
488
+ return winStatus({
489
+ dir: deps.dir || globalDir(),
490
+ execFileImpl: deps.execFileImpl || execFileP,
491
+ probeImpl: deps.probeImpl || (async () => false),
492
+ port: opts.port || 5173,
493
+ });
494
+ }
495
+ if (platform === 'darwin') {
496
+ return macStatus({
497
+ dir: deps.dir || globalDir(),
498
+ launchAgentsDir: deps.launchAgentsDir || defaultLaunchAgentsDir(),
499
+ execFileImpl: deps.execFileImpl || execFileP,
500
+ probeImpl: deps.probeImpl || (async () => false),
501
+ uid: deps.uid ?? currentUid(),
502
+ port: opts.port || 5173,
503
+ });
504
+ }
505
+ if (platform === 'linux') {
506
+ return linuxStatus({
507
+ dir: deps.dir || globalDir(),
508
+ systemdUserDir: deps.systemdUserDir || defaultSystemdUserDir(),
509
+ execFileImpl: deps.execFileImpl || execFileP,
510
+ probeImpl: deps.probeImpl || (async () => false),
511
+ port: opts.port || 5173,
512
+ });
513
+ }
514
+ return { supported: false, platform };
515
+ }