@venturewild/workspace 0.1.2 → 0.1.4

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,16 +1,25 @@
1
1
  // service.mjs — installs / removes the per-OS, NO-ADMIN autostart entry that
2
- // launches the WorkspaceSupervisor hidden at login. See docs/always-on-design.md.
2
+ // launches the WorkspaceSupervisor at login. See docs/always-on-design.md.
3
3
  //
4
4
  // Windows (proven end-to-end incl. a real reboot, 2026-05-30): writes a tiny VBS
5
5
  // that runs `node <cli> service run` with no window, and registers it under
6
- // HKCU\...\Run (per-user, NO admin / UAC). macOS (LaunchAgent + KeepAlive) and
7
- // Linux (systemd --user + Restart=always) are designed but not yet implemented —
8
- // they return a clear "not yet" result so callers degrade gracefully (the user
9
- // can still run `wild-workspace` manually).
6
+ // HKCU\...\Run (per-user, NO admin / UAC).
10
7
  //
11
- // All state (the VBS, service.json, the supervisor's lock/logs) lives in the
12
- // machine-global dir (~/.wild-workspace), NEVER the synced workspace (locked
13
- // principle #1). Every external touch-point (reg.exe, kill) is an injected seam.
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) and registers it in the user's GUI domain via
11
+ // `launchctl bootstrap gui/<uid>` (legacy `launchctl load -w` fallback). Runs as
12
+ // the user, NO admin — macOS 13+ shows a one-time "Background item added" toast +
13
+ // a Login Items toggle (consent, not admin). launchd provides crash-restart for
14
+ // free; the supervisor still owns the singleton lock + child watchdog.
15
+ //
16
+ // Linux (systemd --user) is designed but not yet implemented — it returns a clear
17
+ // "not yet" result so callers degrade gracefully (the user runs `wild-workspace`).
18
+ //
19
+ // All state (the VBS / plist, service.json, the supervisor's lock/logs) lives in
20
+ // the machine-global dir (~/.wild-workspace) or ~/Library/LaunchAgents, NEVER the
21
+ // synced workspace (locked principle #1). Every external touch-point (reg.exe,
22
+ // launchctl, kill) is an injected seam for testability.
14
23
 
15
24
  import { execFile } from 'node:child_process';
16
25
  import { promisify } from 'node:util';
@@ -22,11 +31,28 @@ const execFileP = promisify(execFile);
22
31
 
23
32
  export const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
24
33
  export const RUN_VALUE_NAME = 'WildWorkspace';
34
+ export const LAUNCHD_LABEL = 'llc.venturewild.workspace';
25
35
 
26
36
  export function globalDir() { return path.join(os.homedir(), '.wild-workspace'); }
37
+ function defaultLaunchAgentsDir() { return path.join(os.homedir(), 'Library', 'LaunchAgents'); }
38
+ function currentUid() { return typeof process.getuid === 'function' ? process.getuid() : 0; }
27
39
 
28
40
  /** Is per-user autostart implemented for this platform yet? */
29
- export function isSupported(platform = process.platform) { return platform === 'win32'; }
41
+ export function isSupported(platform = process.platform) {
42
+ return platform === 'win32' || platform === 'darwin';
43
+ }
44
+
45
+ /** Shared: read the supervisor's pidfile and report whether that pid is alive. */
46
+ function supervisorLiveness(dir) {
47
+ let supervisorPid = null, supervisorAlive = false;
48
+ try { supervisorPid = Number(fs.readFileSync(path.join(dir, 'supervisor.lock'), 'utf8').trim()) || null; } catch { /* none */ }
49
+ if (supervisorPid) {
50
+ try { process.kill(supervisorPid, 0); supervisorAlive = true; } catch (e) { supervisorAlive = !!(e && e.code === 'EPERM'); }
51
+ }
52
+ return { supervisorPid, supervisorAlive };
53
+ }
54
+
55
+ // --- Windows implementation -------------------------------------------------
30
56
 
31
57
  // A VBS that runs `node <cli> service run` with NO window (0 = SW_HIDE,
32
58
  // False = don't wait). VBS string literals escape a `"` as `""`.
@@ -45,8 +71,6 @@ export function buildVbs(node, cli) {
45
71
  /** The HKCU\Run value: launch the VBS via the windowless wscript host. */
46
72
  export function buildRunValue(vbs) { return `wscript.exe "${vbs}"`; }
47
73
 
48
- // --- Windows implementation -------------------------------------------------
49
-
50
74
  async function winInstall({ node, cli, workspaceDir, port, version }, { dir, execFileImpl }) {
51
75
  fs.mkdirSync(dir, { recursive: true });
52
76
  const vbs = path.join(dir, 'launch-hidden.vbs');
@@ -59,7 +83,7 @@ async function winInstall({ node, cli, workspaceDir, port, version }, { dir, exe
59
83
  );
60
84
  const runValue = buildRunValue(vbs);
61
85
  await execFileImpl('reg', ['add', RUN_KEY, '/v', RUN_VALUE_NAME, '/t', 'REG_SZ', '/d', runValue, '/f']);
62
- return { installed: true, mechanism: 'HKCU\\Run', vbs, runValue, serviceJson };
86
+ return { installed: true, mechanism: 'HKCU\\Run', launcher: vbs, vbs, runValue, serviceJson };
63
87
  }
64
88
 
65
89
  async function winUninstall({ dir, execFileImpl, killImpl }) {
@@ -81,15 +105,128 @@ async function winStatus({ dir, execFileImpl, probeImpl, port }) {
81
105
  installed = new RegExp(RUN_VALUE_NAME, 'i').test(stdout);
82
106
  runValue = (stdout.match(/REG_SZ\s+(.*?)\s*$/m) || [])[1] || null;
83
107
  } catch { /* value absent → not installed */ }
84
- let supervisorPid = null, supervisorAlive = false;
85
- try { supervisorPid = Number(fs.readFileSync(path.join(dir, 'supervisor.lock'), 'utf8').trim()) || null; } catch { /* none */ }
86
- if (supervisorPid) {
87
- try { process.kill(supervisorPid, 0); supervisorAlive = true; } catch (e) { supervisorAlive = !!(e && e.code === 'EPERM'); }
88
- }
108
+ const { supervisorPid, supervisorAlive } = supervisorLiveness(dir);
89
109
  const serverUp = await probeImpl(port);
90
110
  return { installed, runValue, supervisorPid, supervisorAlive, serverUp };
91
111
  }
92
112
 
113
+ // --- macOS implementation ---------------------------------------------------
114
+
115
+ export function plistPath(launchAgentsDir) {
116
+ return path.join(launchAgentsDir, `${LAUNCHD_LABEL}.plist`);
117
+ }
118
+
119
+ function xmlEscape(s) {
120
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
121
+ }
122
+
123
+ // A LaunchAgent that runs `node <cli> service run` at login + relaunches it if it
124
+ // dies (KeepAlive). ThrottleInterval bounds the restart rate. Output is logged to
125
+ // ~/.wild-workspace so a silent death is debuggable (footgun checklist §8).
126
+ export function buildPlist({ node, cli, workspaceDir, outLog, errLog, label = LAUNCHD_LABEL }) {
127
+ const args = [node, cli, 'service', 'run'].map((a) => ` <string>${xmlEscape(a)}</string>`);
128
+ const env = workspaceDir ? [
129
+ ' <key>EnvironmentVariables</key>',
130
+ ' <dict>',
131
+ ' <key>WILD_WORKSPACE_DIR</key>',
132
+ ` <string>${xmlEscape(workspaceDir)}</string>`,
133
+ ' </dict>',
134
+ ] : [];
135
+ return [
136
+ '<?xml version="1.0" encoding="UTF-8"?>',
137
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
138
+ '<plist version="1.0">',
139
+ '<dict>',
140
+ ' <key>Label</key>',
141
+ ` <string>${xmlEscape(label)}</string>`,
142
+ ' <key>ProgramArguments</key>',
143
+ ' <array>',
144
+ ...args,
145
+ ' </array>',
146
+ ' <key>RunAtLoad</key>',
147
+ ' <true/>',
148
+ ' <key>KeepAlive</key>',
149
+ ' <true/>',
150
+ ' <key>ThrottleInterval</key>',
151
+ ' <integer>10</integer>',
152
+ ...env,
153
+ ' <key>StandardOutPath</key>',
154
+ ` <string>${xmlEscape(outLog)}</string>`,
155
+ ' <key>StandardErrorPath</key>',
156
+ ` <string>${xmlEscape(errLog)}</string>`,
157
+ '</dict>',
158
+ '</plist>',
159
+ '',
160
+ ].join('\n');
161
+ }
162
+
163
+ async function macInstall({ node, cli, workspaceDir, port, version }, { dir, launchAgentsDir, execFileImpl, uid }) {
164
+ fs.mkdirSync(dir, { recursive: true });
165
+ fs.mkdirSync(launchAgentsDir, { recursive: true });
166
+ const plist = plistPath(launchAgentsDir);
167
+ const serviceJson = path.join(dir, 'service.json');
168
+ const outLog = path.join(dir, 'launchagent.out.log');
169
+ const errLog = path.join(dir, 'launchagent.err.log');
170
+ fs.writeFileSync(plist, buildPlist({ node, cli, workspaceDir, outLog, errLog }), 'utf8');
171
+ fs.writeFileSync(
172
+ serviceJson,
173
+ JSON.stringify({ node, cli, workspaceDir, port, version, installedAt: new Date().toISOString() }, null, 2),
174
+ 'utf8',
175
+ );
176
+ const domain = `gui/${uid}`;
177
+ const target = `${domain}/${LAUNCHD_LABEL}`;
178
+ // Clear any prior registration so a changed plist is picked up (launchd caches
179
+ // the loaded plist; bootout→bootstrap makes re-install idempotent).
180
+ try { await execFileImpl('launchctl', ['bootout', target]); } catch { /* not loaded */ }
181
+ let loadVerb = 'bootstrap';
182
+ try {
183
+ await execFileImpl('launchctl', ['bootstrap', domain, plist]);
184
+ } catch {
185
+ // Pre-Yosemite-style macOS without `bootstrap` — fall back to the legacy verb.
186
+ await execFileImpl('launchctl', ['load', '-w', plist]);
187
+ loadVerb = 'load';
188
+ }
189
+ return { installed: true, mechanism: 'LaunchAgent', launcher: plist, plist, label: LAUNCHD_LABEL, runValue: target, serviceJson, loadVerb };
190
+ }
191
+
192
+ async function macUninstall({ dir, launchAgentsDir, execFileImpl, killImpl, uid }) {
193
+ const plist = plistPath(launchAgentsDir);
194
+ const target = `gui/${uid}/${LAUNCHD_LABEL}`;
195
+ try {
196
+ await execFileImpl('launchctl', ['bootout', target]);
197
+ } catch {
198
+ try { await execFileImpl('launchctl', ['unload', '-w', plist]); } catch { /* not loaded */ }
199
+ }
200
+ // launchd's bootout SIGTERMs the launchd-managed supervisor; a manually-started
201
+ // one still holds the lock, so stop it too (mirrors the Windows path).
202
+ let stoppedPid = null;
203
+ try {
204
+ const pid = Number(fs.readFileSync(path.join(dir, 'supervisor.lock'), 'utf8').trim());
205
+ if (pid) { killImpl(pid); stoppedPid = pid; }
206
+ } catch { /* none running */ }
207
+ let removedKey = false;
208
+ try { if (fs.existsSync(plist)) { fs.unlinkSync(plist); removedKey = true; } } catch { /* gone */ }
209
+ try { fs.unlinkSync(path.join(dir, 'service.json')); } catch { /* gone */ }
210
+ return { uninstalled: true, removedKey, stoppedPid };
211
+ }
212
+
213
+ async function macStatus({ dir, launchAgentsDir, execFileImpl, probeImpl, uid, port }) {
214
+ const plist = plistPath(launchAgentsDir);
215
+ const installed = fs.existsSync(plist); // the plist IS the persistent registration
216
+ const target = `gui/${uid}/${LAUNCHD_LABEL}`;
217
+ let loaded = false, launchdPid = null;
218
+ try {
219
+ const { stdout } = await execFileImpl('launchctl', ['print', target]);
220
+ launchdPid = Number((stdout.match(/\bpid\s*=\s*(\d+)/i) || [])[1]) || null;
221
+ loaded = launchdPid !== null || /state\s*=\s*running/i.test(stdout);
222
+ } catch { /* not loaded into launchd */ }
223
+ let { supervisorPid, supervisorAlive } = supervisorLiveness(dir);
224
+ if (launchdPid) { supervisorPid = launchdPid; supervisorAlive = true; }
225
+ else if (loaded) { supervisorAlive = true; }
226
+ const serverUp = await probeImpl(port);
227
+ return { installed, runValue: installed ? plist : null, supervisorPid, supervisorAlive, serverUp, loaded };
228
+ }
229
+
93
230
  // --- public API (platform dispatch) ----------------------------------------
94
231
 
95
232
  const unsupported = (platform, key) => ({
@@ -101,27 +238,60 @@ const unsupported = (platform, key) => ({
101
238
 
102
239
  export async function installService(opts = {}, deps = {}) {
103
240
  const platform = deps.platform || process.platform;
104
- if (platform !== 'win32') return unsupported(platform, 'installed');
105
- return winInstall(opts, { dir: deps.dir || globalDir(), execFileImpl: deps.execFileImpl || execFileP });
241
+ if (platform === 'win32') {
242
+ return winInstall(opts, { dir: deps.dir || globalDir(), execFileImpl: deps.execFileImpl || execFileP });
243
+ }
244
+ if (platform === 'darwin') {
245
+ return macInstall(opts, {
246
+ dir: deps.dir || globalDir(),
247
+ launchAgentsDir: deps.launchAgentsDir || defaultLaunchAgentsDir(),
248
+ execFileImpl: deps.execFileImpl || execFileP,
249
+ uid: deps.uid ?? currentUid(),
250
+ });
251
+ }
252
+ return unsupported(platform, 'installed');
106
253
  }
107
254
 
108
255
  export async function uninstallService(deps = {}) {
109
256
  const platform = deps.platform || process.platform;
110
- if (platform !== 'win32') return unsupported(platform, 'uninstalled');
111
- return winUninstall({
112
- dir: deps.dir || globalDir(),
113
- execFileImpl: deps.execFileImpl || execFileP,
114
- killImpl: deps.killImpl || ((pid) => process.kill(pid)),
115
- });
257
+ if (platform === 'win32') {
258
+ return winUninstall({
259
+ dir: deps.dir || globalDir(),
260
+ execFileImpl: deps.execFileImpl || execFileP,
261
+ killImpl: deps.killImpl || ((pid) => process.kill(pid)),
262
+ });
263
+ }
264
+ if (platform === 'darwin') {
265
+ return macUninstall({
266
+ dir: deps.dir || globalDir(),
267
+ launchAgentsDir: deps.launchAgentsDir || defaultLaunchAgentsDir(),
268
+ execFileImpl: deps.execFileImpl || execFileP,
269
+ killImpl: deps.killImpl || ((pid) => process.kill(pid)),
270
+ uid: deps.uid ?? currentUid(),
271
+ });
272
+ }
273
+ return unsupported(platform, 'uninstalled');
116
274
  }
117
275
 
118
276
  export async function serviceStatus(opts = {}, deps = {}) {
119
277
  const platform = deps.platform || process.platform;
120
- if (platform !== 'win32') return { supported: false, platform };
121
- return winStatus({
122
- dir: deps.dir || globalDir(),
123
- execFileImpl: deps.execFileImpl || execFileP,
124
- probeImpl: deps.probeImpl || (async () => false),
125
- port: opts.port || 5173,
126
- });
278
+ if (platform === 'win32') {
279
+ return winStatus({
280
+ dir: deps.dir || globalDir(),
281
+ execFileImpl: deps.execFileImpl || execFileP,
282
+ probeImpl: deps.probeImpl || (async () => false),
283
+ port: opts.port || 5173,
284
+ });
285
+ }
286
+ if (platform === 'darwin') {
287
+ return macStatus({
288
+ dir: deps.dir || globalDir(),
289
+ launchAgentsDir: deps.launchAgentsDir || defaultLaunchAgentsDir(),
290
+ execFileImpl: deps.execFileImpl || execFileP,
291
+ probeImpl: deps.probeImpl || (async () => false),
292
+ uid: deps.uid ?? currentUid(),
293
+ port: opts.port || 5173,
294
+ });
295
+ }
296
+ return { supported: false, platform };
127
297
  }