@venturewild/workspace 0.2.0 → 0.2.2
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 +148 -75
- package/server/src/auto-update.mjs +277 -0
- package/server/src/config.mjs +6 -0
- package/server/src/doctor.mjs +75 -1
- package/server/src/index.mjs +75 -2
- package/server/src/operator.mjs +27 -0
- package/server/src/owner-browser.mjs +84 -0
- package/server/src/reset.mjs +78 -0
- package/server/src/supervisor.mjs +137 -0
- package/server/src/tunnel-watchdog.mjs +153 -0
package/package.json
CHANGED
|
@@ -23,6 +23,12 @@ import { appendLine, listLogs, tailFile } from '../src/logpaths.mjs';
|
|
|
23
23
|
import { runDoctor, renderDoctor, writeDoctorBundle } from '../src/doctor.mjs';
|
|
24
24
|
import { enableOperator, disableOperator, operatorStatus } from '../src/operator.mjs';
|
|
25
25
|
import { loadObservabilityConsent, setObservabilityConsent } from '../src/observability.mjs';
|
|
26
|
+
import {
|
|
27
|
+
AutoUpdater, PACKAGE_NAME, npmInstall, recordUpdate,
|
|
28
|
+
loadUpdateSettings, setUpdateEnabled, setUpdateChannel,
|
|
29
|
+
} from '../src/auto-update.mjs';
|
|
30
|
+
import { openOwnerBrowser } from '../src/owner-browser.mjs';
|
|
31
|
+
import { planReset, applyReset, RESET_KEEPS } from '../src/reset.mjs';
|
|
26
32
|
|
|
27
33
|
const __filename = url.fileURLToPath(import.meta.url);
|
|
28
34
|
const __dirname = path.dirname(__filename);
|
|
@@ -44,6 +50,8 @@ Usage:
|
|
|
44
50
|
wild-workspace login <payload> bind this install to a slug
|
|
45
51
|
(paste the blob from workspace.venturewild.llc)
|
|
46
52
|
wild-workspace logout clear the bound account (slug + token)
|
|
53
|
+
wild-workspace reset [--yes] back to the beginning: unlink + reset onboarding +
|
|
54
|
+
flush local config (preview without --yes; keeps your files)
|
|
47
55
|
wild-workspace whoami show the currently-bound account
|
|
48
56
|
wild-workspace rotate-token mint a new account token; invalidates the old one
|
|
49
57
|
wild-workspace doctor [--share] diagnose this machine's install (✅/⚠️/❌ + logs)
|
|
@@ -52,6 +60,9 @@ Usage:
|
|
|
52
60
|
wild-workspace operator disable revoke the support token
|
|
53
61
|
wild-workspace operator status is the support channel on?
|
|
54
62
|
wild-workspace observability [on|off|status] share session + install health so we can help (default on; never chat content)
|
|
63
|
+
wild-workspace update [apply] check for / install a newer version (auto by default)
|
|
64
|
+
wild-workspace update on|off toggle background auto-update
|
|
65
|
+
wild-workspace update channel stable|beta choose the update channel
|
|
55
66
|
wild-workspace service install keep your workspace always-on (starts at login, no admin)
|
|
56
67
|
wild-workspace service uninstall turn always-on off
|
|
57
68
|
wild-workspace service status show always-on status (installed? supervisor? server?)
|
|
@@ -84,6 +95,7 @@ function parseArgs(argv) {
|
|
|
84
95
|
else if (arg === '--tail') { opts.tail = Number(argv[++i]); }
|
|
85
96
|
else if (arg === '--share') { opts.share = true; }
|
|
86
97
|
else if (arg === '--rotate') { opts.rotate = true; }
|
|
98
|
+
else if (arg === '--yes' || arg === '-y') { opts.yes = true; }
|
|
87
99
|
else if (arg === '--kind') { opts.kind = argv[++i]; }
|
|
88
100
|
else if (arg === '--limit') { opts.limit = Number(argv[++i]); }
|
|
89
101
|
else if (arg.startsWith('--')) {
|
|
@@ -278,6 +290,73 @@ async function runLogoutCommand() {
|
|
|
278
290
|
console.log(`logged out — cleared ${before?.email || 'account'} from ${config.dataDir}.`);
|
|
279
291
|
}
|
|
280
292
|
|
|
293
|
+
// `wild-workspace reset [--yes]` — back to the beginning: unlink the account,
|
|
294
|
+
// reset onboarding, flush local config/state. NEVER touches workspace files.
|
|
295
|
+
// Without --yes it's a dry run (prints exactly what it WOULD remove).
|
|
296
|
+
async function runResetCommand(opts) {
|
|
297
|
+
const config = buildConfig(opts);
|
|
298
|
+
const gdir = globalDir();
|
|
299
|
+
const before = loadAccount(config.dataDir);
|
|
300
|
+
|
|
301
|
+
// Collect every data dir that might hold this install's account/onboarding:
|
|
302
|
+
// the one this invocation resolves (cwd-keyed) + the always-on workspace's
|
|
303
|
+
// (recorded in service.json), in case `reset` is run from a different folder.
|
|
304
|
+
const dataDirs = new Set([config.dataDir]);
|
|
305
|
+
try {
|
|
306
|
+
const svc = JSON.parse(readFileSync(path.join(gdir, 'service.json'), 'utf8'));
|
|
307
|
+
if (svc.workspaceDir) dataDirs.add(path.join(path.resolve(svc.workspaceDir), '.wild-workspace'));
|
|
308
|
+
} catch { /* no always-on registration — fine */ }
|
|
309
|
+
|
|
310
|
+
const targets = planReset({ dataDirs: [...dataDirs], globalDir: gdir, includeMarketplace: true });
|
|
311
|
+
const present = targets.filter((t) => t.exists);
|
|
312
|
+
|
|
313
|
+
console.log('wild-workspace reset — back to the beginning\n');
|
|
314
|
+
if (before) {
|
|
315
|
+
console.log(` currently linked: ${before.email} (slug: ${before.slug})`);
|
|
316
|
+
console.log('');
|
|
317
|
+
}
|
|
318
|
+
if (present.length === 0) {
|
|
319
|
+
console.log(' Nothing to clear — this install is already at a clean state.');
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
console.log(` ${opts.yes ? 'Removing' : 'Would remove'}:`);
|
|
324
|
+
for (const t of present) console.log(` - ${t.path}${t.kind === 'dir' ? ' (folder)' : ''}`);
|
|
325
|
+
console.log('');
|
|
326
|
+
console.log(' Keeps (untouched):');
|
|
327
|
+
for (const k of RESET_KEEPS) console.log(` · ${k}`);
|
|
328
|
+
console.log('');
|
|
329
|
+
|
|
330
|
+
if (!opts.yes) {
|
|
331
|
+
console.log(' This was a PREVIEW. Re-run to actually reset:');
|
|
332
|
+
console.log(' wild-workspace reset --yes');
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const { removed, failed } = applyReset(present);
|
|
337
|
+
console.log(` ✓ cleared ${removed.length} item(s).`);
|
|
338
|
+
if (failed.length) {
|
|
339
|
+
console.log(` ⚠ ${failed.length} could not be removed (in use? close the app + retry):`);
|
|
340
|
+
for (const f of failed) console.log(` - ${f.path}: ${f.error}`);
|
|
341
|
+
process.exitCode = 1;
|
|
342
|
+
}
|
|
343
|
+
console.log('');
|
|
344
|
+
// The running server still holds the old account/secrets in memory — a restart
|
|
345
|
+
// is what makes the reset take effect (and re-arms a fresh onboarding).
|
|
346
|
+
if (await probeHealth(config.port)) {
|
|
347
|
+
console.log(' ⚠ a workspace server is still running with the old state. Restart it:');
|
|
348
|
+
console.log(` stop it (close the app / kill :${config.port}); always-on restarts it clean,`);
|
|
349
|
+
console.log(' or run `wild-workspace` yourself.');
|
|
350
|
+
console.log('');
|
|
351
|
+
}
|
|
352
|
+
console.log(' Next:');
|
|
353
|
+
console.log(' • `wild-workspace login <blob>` — re-link to a slug, then `wild-workspace`');
|
|
354
|
+
console.log(' • or just `wild-workspace` — start fresh and re-run onboarding');
|
|
355
|
+
console.log('');
|
|
356
|
+
console.log(' Note: the canvas LAYOUT is stored in your browser, not here — open the');
|
|
357
|
+
console.log(' workspace in a fresh/incognito window (or clear site data) for a blank canvas.');
|
|
358
|
+
}
|
|
359
|
+
|
|
281
360
|
async function runRotateTokenCommand() {
|
|
282
361
|
const config = buildConfig({});
|
|
283
362
|
const account = loadAccount(config.dataDir);
|
|
@@ -527,6 +606,67 @@ async function runObservabilityCommand(action = 'status', opts = {}) {
|
|
|
527
606
|
return;
|
|
528
607
|
}
|
|
529
608
|
|
|
609
|
+
// `wild-workspace update [apply|on|off|channel <stable|beta>]` — Phase 2
|
|
610
|
+
// auto-update (docs/remote-support-and-self-healing-design.md). With no
|
|
611
|
+
// sub-command it checks the channel for a newer release; `apply` installs it now;
|
|
612
|
+
// `on`/`off` toggle the default-on background updater; `channel` switches
|
|
613
|
+
// stable/beta. The always-on supervisor does this automatically — this is the
|
|
614
|
+
// manual lever + the off switch.
|
|
615
|
+
async function runUpdateCommand(opts) {
|
|
616
|
+
const sub = opts.positional[1];
|
|
617
|
+
const gdir = globalDir();
|
|
618
|
+
|
|
619
|
+
if (sub === 'on' || sub === 'off') {
|
|
620
|
+
const rec = setUpdateEnabled(gdir, sub === 'on');
|
|
621
|
+
console.log(
|
|
622
|
+
rec.enabled
|
|
623
|
+
? '✓ auto-update ON — wild-workspace keeps itself up to date in the background.'
|
|
624
|
+
: '✓ auto-update OFF — update manually with `wild-workspace update apply`.',
|
|
625
|
+
);
|
|
626
|
+
console.log(` channel: ${rec.channel}`);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (sub === 'channel') {
|
|
630
|
+
const chan = opts.positional[2];
|
|
631
|
+
if (chan !== 'stable' && chan !== 'beta') {
|
|
632
|
+
console.log('usage: wild-workspace update channel stable|beta');
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
console.log(`✓ update channel set to ${setUpdateChannel(gdir, chan).channel}.`);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const settings = loadUpdateSettings(gdir);
|
|
640
|
+
const c = await new AutoUpdater({ globalDir: gdir }).check();
|
|
641
|
+
console.log(`wild-workspace ${c.current} (channel: ${c.channel}, auto-update: ${settings.enabled ? 'on' : 'off'})`);
|
|
642
|
+
if (!c.latest) {
|
|
643
|
+
console.log(' could not reach the npm registry to check for updates.');
|
|
644
|
+
process.exitCode = 1;
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (!c.available) {
|
|
648
|
+
console.log(` up to date — ${c.latest} is the latest on ${c.channel}.`);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
console.log(` update available: ${c.current} → ${c.latest}`);
|
|
652
|
+
if (sub !== 'apply') {
|
|
653
|
+
console.log(' run `wild-workspace update apply` to install it now.');
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
console.log(` installing ${c.latest}…`);
|
|
657
|
+
const res = await npmInstall(`${PACKAGE_NAME}@${c.latest}`);
|
|
658
|
+
if (res.code === 0) {
|
|
659
|
+
recordUpdate(gdir, { from: c.current, to: c.latest, at: Date.now(), status: 'installed' });
|
|
660
|
+
console.log(` ✓ installed ${c.latest}.`);
|
|
661
|
+
console.log(' The always-on supervisor will restart into the new version shortly,');
|
|
662
|
+
console.log(' or restart `wild-workspace` yourself to use it now.');
|
|
663
|
+
} else {
|
|
664
|
+
console.log(` ✗ install failed (code ${res.code}).`);
|
|
665
|
+
if (res.output) console.log(' ' + res.output.split('\n').filter(Boolean).slice(-3).join('\n '));
|
|
666
|
+
process.exitCode = 1;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
530
670
|
// `wild-workspace operator [enable|disable|status]` — the consented support
|
|
531
671
|
// channel (docs/SECURITY.md). OFF by default; `enable` mints a token to hand to
|
|
532
672
|
// the wild-workspace team so they can diagnose + run a fixed set of safe fixes.
|
|
@@ -570,81 +710,8 @@ async function runOperatorCommand(action = 'status', opts = {}) {
|
|
|
570
710
|
// autostart (docs/always-on-design.md). `run` is the HIDDEN supervisor entry
|
|
571
711
|
// the per-OS launcher invokes at login; the others manage registration. All
|
|
572
712
|
// per-user, no admin.
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
// the partner token, which the SPA immediately exchanges for an HttpOnly cookie
|
|
576
|
-
// and strips from the address bar (S1). A localhost-only install needs no token.
|
|
577
|
-
// The token is only ever placed in the URL we hand the browser — never printed.
|
|
578
|
-
function localBrowserUrl(config) {
|
|
579
|
-
const host = config.host === '0.0.0.0' ? '127.0.0.1' : config.host;
|
|
580
|
-
const base = `http://${host}:${config.port}`;
|
|
581
|
-
return config.publicMode ? `${base}/?t=${encodeURIComponent(config.partnerToken)}` : base;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
// Ask the running local server (over genuine loopback) for a one-time sign-in
|
|
585
|
-
// link to the PUBLIC url. Returns the URL or null (no slug / older server).
|
|
586
|
-
async function fetchPublicBootstrapUrl(config) {
|
|
587
|
-
const host = config.host === '0.0.0.0' ? '127.0.0.1' : config.host;
|
|
588
|
-
try {
|
|
589
|
-
const ac = new AbortController();
|
|
590
|
-
const t = setTimeout(() => ac.abort(), 4000);
|
|
591
|
-
const r = await fetch(`http://${host}:${config.port}/api/auth/bootstrap`, {
|
|
592
|
-
method: 'POST',
|
|
593
|
-
signal: ac.signal,
|
|
594
|
-
});
|
|
595
|
-
clearTimeout(t);
|
|
596
|
-
if (!r.ok) return null;
|
|
597
|
-
const body = await r.json().catch(() => ({}));
|
|
598
|
-
return typeof body.url === 'string' ? body.url : null;
|
|
599
|
-
} catch {
|
|
600
|
-
return null;
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
// Poll the public url's /api/health until the tunnel forwards (200) or we give
|
|
605
|
-
// up — so we never open the owner onto a 502 "warming up" page.
|
|
606
|
-
async function publicTunnelReady(shareBaseUrl, { tries = 6, gapMs = 1300 } = {}) {
|
|
607
|
-
const base = String(shareBaseUrl || '').replace(/\/$/, '');
|
|
608
|
-
if (!/^https?:\/\//.test(base)) return false;
|
|
609
|
-
for (let i = 0; i < tries; i += 1) {
|
|
610
|
-
try {
|
|
611
|
-
const ac = new AbortController();
|
|
612
|
-
const t = setTimeout(() => ac.abort(), 2500);
|
|
613
|
-
const r = await fetch(`${base}/api/health`, { signal: ac.signal });
|
|
614
|
-
clearTimeout(t);
|
|
615
|
-
if (r.ok) return true;
|
|
616
|
-
} catch { /* not up yet */ }
|
|
617
|
-
if (i < tries - 1) await new Promise((res) => setTimeout(res, gapMs));
|
|
618
|
-
}
|
|
619
|
-
return false;
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
// Open the owner's browser the friendliest way for THIS install:
|
|
623
|
-
// - slug-linked + public: land them signed-in on <slug>.venturewild.llc (their
|
|
624
|
-
// real, bookmarkable home) via a one-time bootstrap link, once the tunnel is
|
|
625
|
-
// confirmed up. If it isn't ready yet, fall back to localhost (always works
|
|
626
|
-
// locally) and tell them their public url is warming up.
|
|
627
|
-
// - localhost-only: just open localhost.
|
|
628
|
-
// Tokens only ever reach the browser via open() — never printed to stdout (B1/S1).
|
|
629
|
-
async function openOwnerBrowser(config) {
|
|
630
|
-
let open;
|
|
631
|
-
try { open = (await import('open')).default; } catch { return; }
|
|
632
|
-
const slugLinked = config.publicMode && config.account?.slug && config.shareBaseUrl;
|
|
633
|
-
if (slugLinked) {
|
|
634
|
-
const url = await fetchPublicBootstrapUrl(config);
|
|
635
|
-
if (url && (await publicTunnelReady(config.shareBaseUrl))) {
|
|
636
|
-
console.log(` opening your workspace at ${config.shareBaseUrl} …`);
|
|
637
|
-
try { await open(url); } catch { /* best-effort */ }
|
|
638
|
-
return;
|
|
639
|
-
}
|
|
640
|
-
// Tunnel not up yet (or older server) — open locally so first run is never a
|
|
641
|
-
// dead page; the public url comes alive on its own as the daemon links.
|
|
642
|
-
try { await open(localBrowserUrl(config)); } catch { /* best-effort */ }
|
|
643
|
-
console.log(` your workspace will be live at ${config.shareBaseUrl} shortly (warming up the tunnel)…`);
|
|
644
|
-
return;
|
|
645
|
-
}
|
|
646
|
-
try { await open(localBrowserUrl(config)); } catch { /* best-effort */ }
|
|
647
|
-
}
|
|
713
|
+
// First-run browser orchestration lives in a shebang-free module so it's
|
|
714
|
+
// importable + unit-testable (server/test/owner-browser.test.mjs).
|
|
648
715
|
|
|
649
716
|
async function runServiceCommand(action = 'status', opts = {}) {
|
|
650
717
|
const config = buildConfig(opts);
|
|
@@ -720,6 +787,9 @@ async function main() {
|
|
|
720
787
|
if (opts.positional[0] === 'logout') {
|
|
721
788
|
return runLogoutCommand();
|
|
722
789
|
}
|
|
790
|
+
if (opts.positional[0] === 'reset') {
|
|
791
|
+
return runResetCommand(opts);
|
|
792
|
+
}
|
|
723
793
|
if (opts.positional[0] === 'whoami') {
|
|
724
794
|
return runWhoamiCommand();
|
|
725
795
|
}
|
|
@@ -732,6 +802,9 @@ async function main() {
|
|
|
732
802
|
if (opts.positional[0] === 'logs') {
|
|
733
803
|
return runLogsCommand(opts);
|
|
734
804
|
}
|
|
805
|
+
if (opts.positional[0] === 'update') {
|
|
806
|
+
return runUpdateCommand(opts);
|
|
807
|
+
}
|
|
735
808
|
if (opts.positional[0] === 'operator') {
|
|
736
809
|
return runOperatorCommand(opts.positional[1], opts);
|
|
737
810
|
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// AutoUpdater — Phase 2 (Pillar B) of the self-healing epic
|
|
2
|
+
// (docs/remote-support-and-self-healing-design.md). Kills the manual `npm i -g`
|
|
3
|
+
// re-install treadmill that turned the first external install into a copy-paste
|
|
4
|
+
// marathon: the always-on supervisor periodically checks the published version on
|
|
5
|
+
// the user's channel, and on a new release it installs it, restarts the supervised
|
|
6
|
+
// server (reusing the version-drift restart RC1b already ships), health-checks the
|
|
7
|
+
// result, and ROLLS BACK to the pinned previous version if the new one doesn't come
|
|
8
|
+
// up healthy.
|
|
9
|
+
//
|
|
10
|
+
// Tuan's locked decisions (design doc Part 6): auto-update is DEFAULT-ON (the
|
|
11
|
+
// OneDrive bar — it just updates itself) with a visible "updated to vX" note + an
|
|
12
|
+
// off switch; channels are `stable` (default) and `beta` (opt-in).
|
|
13
|
+
//
|
|
14
|
+
// Like observability.mjs/operator.mjs, settings live in their own file in the
|
|
15
|
+
// machine-global dir (~/.wild-workspace, NEVER the synced workspace — locked
|
|
16
|
+
// principle #1) so they survive the supervisor relaunching from a different cwd.
|
|
17
|
+
//
|
|
18
|
+
// Every external touch-point (npm install, registry fetch, health probe, restart,
|
|
19
|
+
// clock, sleep) is an injected seam so the suite never spawns a process, hits the
|
|
20
|
+
// network, or actually waits.
|
|
21
|
+
|
|
22
|
+
import { spawn } from 'node:child_process';
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { globalDir as defaultGlobalDir } from './logpaths.mjs';
|
|
26
|
+
import { installedVersion, probeHealthVersion } from './supervisor.mjs';
|
|
27
|
+
import { ensureToolPath } from './agent.mjs';
|
|
28
|
+
|
|
29
|
+
export const PACKAGE_NAME = '@venturewild/workspace';
|
|
30
|
+
const DEFAULT_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h — releases are not frequent
|
|
31
|
+
|
|
32
|
+
// --- persisted settings (~/.wild-workspace/update.json) ----------------------
|
|
33
|
+
|
|
34
|
+
function updateFile(dir) { return path.join(dir, 'update.json'); }
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* DEFAULT-ON: an absent file means "auto-update enabled, stable channel" — the
|
|
38
|
+
* disclosure rides onboarding + the visible update note, mirroring observability.
|
|
39
|
+
*/
|
|
40
|
+
export function loadUpdateSettings(dir = defaultGlobalDir()) {
|
|
41
|
+
try {
|
|
42
|
+
const p = JSON.parse(fs.readFileSync(updateFile(dir), 'utf8'));
|
|
43
|
+
return {
|
|
44
|
+
enabled: p.enabled !== false,
|
|
45
|
+
channel: p.channel === 'beta' ? 'beta' : 'stable',
|
|
46
|
+
lastCheckAt: Number(p.lastCheckAt) || 0,
|
|
47
|
+
lastUpdate: p.lastUpdate || null, // { from, to, at, status }
|
|
48
|
+
};
|
|
49
|
+
} catch {
|
|
50
|
+
return { enabled: true, channel: 'stable', lastCheckAt: 0, lastUpdate: null };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function writeSettings(dir, rec) {
|
|
55
|
+
try {
|
|
56
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
57
|
+
fs.writeFileSync(updateFile(dir), JSON.stringify(rec, null, 2), { mode: 0o600 });
|
|
58
|
+
} catch {
|
|
59
|
+
/* read-only fs — fall back to in-memory for this run */
|
|
60
|
+
}
|
|
61
|
+
return rec;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function setUpdateEnabled(dir, enabled) {
|
|
65
|
+
return writeSettings(dir, { ...loadUpdateSettings(dir), enabled: Boolean(enabled) });
|
|
66
|
+
}
|
|
67
|
+
export function setUpdateChannel(dir, channel) {
|
|
68
|
+
return writeSettings(dir, { ...loadUpdateSettings(dir), channel: channel === 'beta' ? 'beta' : 'stable' });
|
|
69
|
+
}
|
|
70
|
+
export function touchLastCheck(dir, at) {
|
|
71
|
+
return writeSettings(dir, { ...loadUpdateSettings(dir), lastCheckAt: at });
|
|
72
|
+
}
|
|
73
|
+
/** Record the outcome of an update attempt; returns the stored record. */
|
|
74
|
+
export function recordUpdate(dir, rec) {
|
|
75
|
+
return writeSettings(dir, { ...loadUpdateSettings(dir), lastUpdate: rec }).lastUpdate;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- semver compare (no dep) -------------------------------------------------
|
|
79
|
+
|
|
80
|
+
function parseVersion(v) {
|
|
81
|
+
const [core, pre = ''] = String(v).replace(/^v/, '').split('-');
|
|
82
|
+
const nums = core.split('.').map((n) => parseInt(n, 10) || 0);
|
|
83
|
+
while (nums.length < 3) nums.push(0);
|
|
84
|
+
return { nums, pre };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** True iff version `a` is strictly newer than `b` (good enough for dist-tags). */
|
|
88
|
+
export function isNewer(a, b) {
|
|
89
|
+
if (!a || !b) return false;
|
|
90
|
+
const pa = parseVersion(a), pb = parseVersion(b);
|
|
91
|
+
for (let i = 0; i < 3; i++) {
|
|
92
|
+
if (pa.nums[i] !== pb.nums[i]) return pa.nums[i] > pb.nums[i];
|
|
93
|
+
}
|
|
94
|
+
// Equal core: a release (no prerelease) outranks a prerelease; else lexical.
|
|
95
|
+
if (pa.pre && !pb.pre) return false;
|
|
96
|
+
if (!pa.pre && pb.pre) return true;
|
|
97
|
+
if (pa.pre && pb.pre) return pa.pre > pb.pre;
|
|
98
|
+
return false; // identical
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- registry + npm primitives -----------------------------------------------
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The version published on `channel`'s dist-tag (stable→latest, beta→beta), or
|
|
105
|
+
* null on any failure. Uses the abbreviated registry document (smaller payload).
|
|
106
|
+
*/
|
|
107
|
+
export async function fetchLatestVersion(channel, {
|
|
108
|
+
fetchImpl = fetch, packageName = PACKAGE_NAME, timeoutMs = 8000,
|
|
109
|
+
} = {}) {
|
|
110
|
+
const tag = channel === 'beta' ? 'beta' : 'latest';
|
|
111
|
+
const ctrl = new AbortController();
|
|
112
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
113
|
+
try {
|
|
114
|
+
const res = await fetchImpl(`https://registry.npmjs.org/${packageName.replace('/', '%2f')}`, {
|
|
115
|
+
signal: ctrl.signal,
|
|
116
|
+
headers: { accept: 'application/vnd.npm.install-v1+json' },
|
|
117
|
+
});
|
|
118
|
+
if (!res || !res.ok) return null;
|
|
119
|
+
const body = await res.json();
|
|
120
|
+
return body?.['dist-tags']?.[tag] || null;
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
} finally {
|
|
124
|
+
clearTimeout(t);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Run `npm i -g <spec>`. Resolves {code, output, timedOut?, error?}; never rejects. */
|
|
129
|
+
export function npmInstall(spec, {
|
|
130
|
+
spawnImpl = spawn, timeoutMs = 180000, ensurePathImpl = ensureToolPath, env = process.env,
|
|
131
|
+
} = {}) {
|
|
132
|
+
return new Promise((resolve) => {
|
|
133
|
+
const cmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
134
|
+
// The always-on supervisor (our caller in the field) runs under launchd/GUI,
|
|
135
|
+
// which inherits a MINIMAL PATH omitting ~/.npm-global, /usr/local/bin,
|
|
136
|
+
// Homebrew, nvm — so a bare `npm` spawn would ENOENT (the 0.1.8 `claude`
|
|
137
|
+
// bug class). Augment PATH the same way agent.mjs does before spawning. We
|
|
138
|
+
// copy env so we never mutate the caller's process.env.
|
|
139
|
+
const childEnv = { ...env };
|
|
140
|
+
try { ensurePathImpl(childEnv); } catch { /* best-effort — fall back to inherited PATH */ }
|
|
141
|
+
let child;
|
|
142
|
+
try {
|
|
143
|
+
child = spawnImpl(cmd, ['i', '-g', spec], { windowsHide: true, env: childEnv });
|
|
144
|
+
} catch (e) {
|
|
145
|
+
return resolve({ code: -1, error: e?.message || String(e), output: '' });
|
|
146
|
+
}
|
|
147
|
+
let out = '';
|
|
148
|
+
const cap = (d) => { out += String(d); if (out.length > 20000) out = out.slice(-20000); };
|
|
149
|
+
child.stdout?.on?.('data', cap);
|
|
150
|
+
child.stderr?.on?.('data', cap);
|
|
151
|
+
const timer = setTimeout(() => { try { child.kill?.(); } catch { /* gone */ } resolve({ code: -1, timedOut: true, output: out }); }, timeoutMs);
|
|
152
|
+
child.on?.('exit', (code) => { clearTimeout(timer); resolve({ code, output: out }); });
|
|
153
|
+
child.on?.('error', (e) => { clearTimeout(timer); resolve({ code: -1, error: e?.message || String(e), output: out }); });
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
158
|
+
|
|
159
|
+
// --- the updater -------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
export class AutoUpdater {
|
|
162
|
+
constructor({
|
|
163
|
+
globalDir = defaultGlobalDir(),
|
|
164
|
+
packageName = PACKAGE_NAME,
|
|
165
|
+
port = Number(process.env.WILD_WORKSPACE_PORT || 5173),
|
|
166
|
+
installedVersionImpl = () => installedVersion(),
|
|
167
|
+
fetchLatestImpl = fetchLatestVersion,
|
|
168
|
+
installImpl = npmInstall,
|
|
169
|
+
// Ask the owner (the supervisor) to restart the server child so the freshly
|
|
170
|
+
// installed code is loaded. Default no-op for standalone/manual use.
|
|
171
|
+
restartImpl = async () => {},
|
|
172
|
+
// Read the RUNNING server's version (probeHealthVersion bound to our port).
|
|
173
|
+
healthVersionImpl = (port_) => probeHealthVersion(port_),
|
|
174
|
+
nowImpl = () => Date.now(),
|
|
175
|
+
logImpl = () => {},
|
|
176
|
+
env = process.env,
|
|
177
|
+
checkIntervalMs = DEFAULT_CHECK_INTERVAL_MS,
|
|
178
|
+
verifyAttempts = 10,
|
|
179
|
+
verifyDelayMs = 3000,
|
|
180
|
+
sleepImpl = sleep,
|
|
181
|
+
onUpdate = null, // (rec) => void — surface the "updated to vX" note
|
|
182
|
+
} = {}) {
|
|
183
|
+
Object.assign(this, {
|
|
184
|
+
globalDir, packageName, port, installedVersionImpl, fetchLatestImpl, installImpl,
|
|
185
|
+
restartImpl, healthVersionImpl, nowImpl, logImpl, env,
|
|
186
|
+
checkIntervalMs, verifyAttempts, verifyDelayMs, sleepImpl, onUpdate,
|
|
187
|
+
});
|
|
188
|
+
this.inProgress = false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
enabled() {
|
|
192
|
+
if (this.env.WILD_WORKSPACE_NO_AUTOUPDATE === '1') return false; // hard kill switch
|
|
193
|
+
return loadUpdateSettings(this.globalDir).enabled;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
channel() { return loadUpdateSettings(this.globalDir).channel; }
|
|
197
|
+
|
|
198
|
+
dueForCheck(settings = loadUpdateSettings(this.globalDir)) {
|
|
199
|
+
return this.nowImpl() - (settings.lastCheckAt || 0) >= this.checkIntervalMs;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** What's installed vs what's published — { current, latest, channel, available }. */
|
|
203
|
+
async check() {
|
|
204
|
+
const current = this.installedVersionImpl();
|
|
205
|
+
const channel = this.channel();
|
|
206
|
+
const latest = await this.fetchLatestImpl(channel, { packageName: this.packageName });
|
|
207
|
+
return { current, latest, channel, available: isNewer(latest, current) };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Poll the running server until it reports `expected`, up to verifyAttempts. */
|
|
211
|
+
async verify(expected) {
|
|
212
|
+
for (let i = 0; i < this.verifyAttempts; i++) {
|
|
213
|
+
let running = null;
|
|
214
|
+
try { running = await this.healthVersionImpl(this.port); } catch { running = null; }
|
|
215
|
+
if (running && running === expected) return true;
|
|
216
|
+
await this.sleepImpl(this.verifyDelayMs);
|
|
217
|
+
}
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Install `target`, restart, verify healthy; on failure roll back to `from`.
|
|
223
|
+
* Returns { ok, stage?, rolledBack?, rec }. Records the outcome to update.json.
|
|
224
|
+
*/
|
|
225
|
+
async applyUpdate(target, { from } = {}) {
|
|
226
|
+
this.logImpl(`auto-update: installing ${this.packageName}@${target} (from ${from || 'unknown'})`);
|
|
227
|
+
const install = await this.installImpl(`${this.packageName}@${target}`);
|
|
228
|
+
if (install.code !== 0) {
|
|
229
|
+
this.logImpl(`auto-update: install failed code=${install.code}${install.timedOut ? ' (timeout)' : ''}`);
|
|
230
|
+
const rec = recordUpdate(this.globalDir, { from, to: target, at: this.nowImpl(), status: 'install-failed' });
|
|
231
|
+
this.onUpdate?.(rec);
|
|
232
|
+
return { ok: false, stage: 'install', install, rec };
|
|
233
|
+
}
|
|
234
|
+
await this.restartImpl();
|
|
235
|
+
if (await this.verify(target)) {
|
|
236
|
+
this.logImpl(`auto-update: now running ${target}`);
|
|
237
|
+
const rec = recordUpdate(this.globalDir, { from, to: target, at: this.nowImpl(), status: 'ok' });
|
|
238
|
+
this.onUpdate?.(rec);
|
|
239
|
+
return { ok: true, rec };
|
|
240
|
+
}
|
|
241
|
+
// New version didn't come up healthy → roll back to the pinned previous.
|
|
242
|
+
this.logImpl(`auto-update: ${target} unhealthy — rolling back to ${from}`);
|
|
243
|
+
let rolledBack = false;
|
|
244
|
+
if (from) {
|
|
245
|
+
const rb = await this.installImpl(`${this.packageName}@${from}`);
|
|
246
|
+
if (rb.code === 0) { await this.restartImpl(); rolledBack = await this.verify(from); }
|
|
247
|
+
}
|
|
248
|
+
const status = rolledBack ? 'rolled-back' : 'rollback-failed';
|
|
249
|
+
this.logImpl(`auto-update: ${status} (target ${target})`);
|
|
250
|
+
const rec = recordUpdate(this.globalDir, { from, to: target, at: this.nowImpl(), status });
|
|
251
|
+
this.onUpdate?.(rec);
|
|
252
|
+
return { ok: false, stage: 'verify', rolledBack, rec };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* One auto-update cycle, called on the supervisor's slow timer. Self-rate-limits
|
|
257
|
+
* via dueForCheck so the timer cadence and the check interval are independent.
|
|
258
|
+
* Returns a short status string (exposed for tests/logging).
|
|
259
|
+
*/
|
|
260
|
+
async tick() {
|
|
261
|
+
if (this.inProgress) return 'busy';
|
|
262
|
+
if (!this.enabled()) return 'disabled';
|
|
263
|
+
if (!this.dueForCheck()) return 'not-due';
|
|
264
|
+
this.inProgress = true;
|
|
265
|
+
try {
|
|
266
|
+
touchLastCheck(this.globalDir, this.nowImpl());
|
|
267
|
+
const c = await this.check();
|
|
268
|
+
if (!c.latest) return 'check-failed';
|
|
269
|
+
if (!c.available) return 'up-to-date';
|
|
270
|
+
this.logImpl(`auto-update: ${c.current} → ${c.latest} (${c.channel})`);
|
|
271
|
+
const r = await this.applyUpdate(c.latest, { from: c.current });
|
|
272
|
+
return r.ok ? 'updated' : (r.rolledBack ? 'rolled-back' : 'failed');
|
|
273
|
+
} finally {
|
|
274
|
+
this.inProgress = false;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
package/server/src/config.mjs
CHANGED
|
@@ -312,6 +312,12 @@ export function buildConfig(overrides = {}) {
|
|
|
312
312
|
overrides.operatorToken ??
|
|
313
313
|
env.WILD_WORKSPACE_OPERATOR_TOKEN ??
|
|
314
314
|
loadOperatorToken(dataDir),
|
|
315
|
+
// RC1 hot-reload seam: the EXPLICIT token (override or env), with NO disk read.
|
|
316
|
+
// When this is null the live auth path re-reads the token file on each request
|
|
317
|
+
// (getOperatorToken) so `operator enable` takes effect with no restart; when a
|
|
318
|
+
// test/env pins a token, that value stays authoritative. (See index.mjs.)
|
|
319
|
+
operatorTokenExplicit:
|
|
320
|
+
overrides.operatorToken ?? env.WILD_WORKSPACE_OPERATOR_TOKEN ?? null,
|
|
315
321
|
workspaceId:
|
|
316
322
|
overrides.workspaceId ||
|
|
317
323
|
env.WILD_WORKSPACE_ID ||
|