@venturewild/workspace 0.1.2 → 0.1.3
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 +75 -75
- package/server/bin/wild-workspace.mjs +725 -725
- package/server/src/agent.mjs +356 -356
- package/server/src/config.mjs +302 -302
- package/server/src/daemon-supervisor.mjs +216 -216
- package/server/src/inbox.mjs +86 -86
- package/server/src/index.mjs +1330 -1330
- package/server/src/service.mjs +202 -32
- package/server/src/sync.mjs +248 -248
package/server/src/service.mjs
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
// service.mjs — installs / removes the per-OS, NO-ADMIN autostart entry that
|
|
2
|
-
// launches the WorkspaceSupervisor
|
|
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).
|
|
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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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) {
|
|
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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
|
105
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
}
|