@venturewild/workspace 0.1.9 → 0.1.10

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.
@@ -42,7 +42,7 @@ function currentUid() { return typeof process.getuid === 'function' ? process.ge
42
42
 
43
43
  /** Is per-user autostart implemented for this platform yet? */
44
44
  export function isSupported(platform = process.platform) {
45
- return platform === 'win32' || platform === 'darwin';
45
+ return platform === 'win32' || platform === 'darwin' || platform === 'linux';
46
46
  }
47
47
 
48
48
  /** Shared: read the supervisor's pidfile and report whether that pid is alive. */
@@ -225,6 +225,108 @@ async function macStatus({ dir, launchAgentsDir, execFileImpl, probeImpl, uid, p
225
225
  return { installed, runValue: installed ? plist : null, supervisorPid, supervisorAlive, serverUp, loaded };
226
226
  }
227
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
+
228
330
  // --- public API (platform dispatch) ----------------------------------------
229
331
 
230
332
  const unsupported = (platform, key) => ({
@@ -245,6 +347,13 @@ export async function installService(opts = {}, deps = {}) {
245
347
  launchAgentsDir: deps.launchAgentsDir || defaultLaunchAgentsDir(),
246
348
  });
247
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
+ }
248
357
  return unsupported(platform, 'installed');
249
358
  }
250
359
 
@@ -266,6 +375,14 @@ export async function uninstallService(deps = {}) {
266
375
  uid: deps.uid ?? currentUid(),
267
376
  });
268
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
+ }
269
386
  return unsupported(platform, 'uninstalled');
270
387
  }
271
388
 
@@ -289,5 +406,14 @@ export async function serviceStatus(opts = {}, deps = {}) {
289
406
  port: opts.port || 5173,
290
407
  });
291
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
+ }
292
418
  return { supported: false, platform };
293
419
  }
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { SignJWT, jwtVerify } from 'jose';
5
5
  import { nanoid } from 'nanoid';
6
+ import { readFileSync, writeFileSync } from 'node:fs';
6
7
 
7
8
  const SHARE_ISSUER = 'wild-workspace';
8
9
 
@@ -56,15 +57,48 @@ export function buildShareUrl({ shareBaseUrl, workspaceId, token }) {
56
57
  return `${base}/share/${encodeURIComponent(workspaceId)}?t=${encodeURIComponent(token)}`;
57
58
  }
58
59
 
59
- // Simple in-memory token registry so the partner can list + revoke.
60
- // Reset on process restart acceptable for v1; persisted in bmo-sync in v1.x.
60
+ // Token registry so the partner can list + revoke. The revocation set (and the
61
+ // active-token list) is persisted to `<dataDir>/revoked.json` so a "revoked"
62
+ // share token stays revoked across a server restart (concern C8) — without it,
63
+ // the in-memory set reset on restart and a previously-revoked-but-unexpired JWT
64
+ // validated again by signature. No `persistPath` → in-memory only (tests).
61
65
  export class TokenRegistry {
62
- constructor() {
63
- this.tokens = new Map(); // jti -> { sub, role, workspaceId, exp, label, createdAt }
66
+ constructor({ persistPath = null } = {}) {
67
+ this.persistPath = persistPath;
68
+ this.tokens = new Map(); // sub -> { sub, role, workspaceId, exp, label, createdAt }
64
69
  this.revoked = new Set();
70
+ this._load();
71
+ }
72
+ _load() {
73
+ if (!this.persistPath) return;
74
+ try {
75
+ const data = JSON.parse(readFileSync(this.persistPath, 'utf8'));
76
+ if (Array.isArray(data.revoked)) this.revoked = new Set(data.revoked);
77
+ if (Array.isArray(data.tokens)) {
78
+ for (const t of data.tokens) if (t?.sub) this.tokens.set(t.sub, t);
79
+ }
80
+ } catch {
81
+ /* missing / corrupt — start empty */
82
+ }
83
+ }
84
+ _persist() {
85
+ if (!this.persistPath) return;
86
+ try {
87
+ const now = Math.floor(Date.now() / 1000);
88
+ // Don't carry expired tokens forward; the revoked set stays (small).
89
+ const tokens = [...this.tokens.values()].filter((r) => r.exp > now);
90
+ writeFileSync(
91
+ this.persistPath,
92
+ JSON.stringify({ revoked: [...this.revoked], tokens }, null, 2),
93
+ { mode: 0o600 },
94
+ );
95
+ } catch {
96
+ /* read-only fs — revocation degrades to in-memory for this run */
97
+ }
65
98
  }
66
99
  add(record) {
67
100
  this.tokens.set(record.sub, record);
101
+ this._persist();
68
102
  }
69
103
  list() {
70
104
  const now = Math.floor(Date.now() / 1000);
@@ -73,6 +107,7 @@ export class TokenRegistry {
73
107
  revoke(sub) {
74
108
  this.revoked.add(sub);
75
109
  this.tokens.delete(sub);
110
+ this._persist();
76
111
  }
77
112
  isRevoked(sub) {
78
113
  return this.revoked.has(sub);