@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.
- package/LICENSE +21 -21
- package/README.md +112 -112
- package/package.json +76 -76
- package/server/bin/wild-workspace.mjs +763 -763
- package/server/src/agent.mjs +386 -386
- package/server/src/config.mjs +365 -325
- package/server/src/daemon-supervisor.mjs +216 -216
- package/server/src/inbox.mjs +86 -86
- package/server/src/index.mjs +1721 -1566
- package/server/src/logpaths.mjs +98 -98
- package/server/src/pairing.mjs +137 -0
- package/server/src/service.mjs +419 -419
- package/server/src/share.mjs +148 -115
- package/server/src/sync.mjs +248 -248
- package/web/dist/assets/{index-n0-hsCzL.js → index-Dc6jo84c.js} +19 -19
- package/web/dist/index.html +1 -1
package/server/src/service.mjs
CHANGED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
+
}
|