@venturewild/workspace 0.1.8 → 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.
- package/package.json +1 -1
- package/server/bin/wild-workspace.mjs +35 -1
- package/server/src/config.mjs +18 -7
- package/server/src/index.mjs +257 -62
- package/server/src/logpaths.mjs +1 -0
- package/server/src/service.mjs +127 -1
- package/server/src/share.mjs +39 -4
- package/web/dist/assets/{index-Ci8BkBhX.js → index-n0-hsCzL.js} +17 -17
- package/web/dist/index.html +1 -1
package/server/src/service.mjs
CHANGED
|
@@ -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
|
}
|
package/server/src/share.mjs
CHANGED
|
@@ -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
|
-
//
|
|
60
|
-
//
|
|
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.
|
|
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);
|