@venturewild/workspace 0.2.0 → 0.2.1
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 +77 -75
- package/server/src/owner-browser.mjs +84 -0
- package/server/src/reset.mjs +78 -0
package/package.json
CHANGED
|
@@ -23,6 +23,8 @@ 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 { openOwnerBrowser } from '../src/owner-browser.mjs';
|
|
27
|
+
import { planReset, applyReset, RESET_KEEPS } from '../src/reset.mjs';
|
|
26
28
|
|
|
27
29
|
const __filename = url.fileURLToPath(import.meta.url);
|
|
28
30
|
const __dirname = path.dirname(__filename);
|
|
@@ -44,6 +46,8 @@ Usage:
|
|
|
44
46
|
wild-workspace login <payload> bind this install to a slug
|
|
45
47
|
(paste the blob from workspace.venturewild.llc)
|
|
46
48
|
wild-workspace logout clear the bound account (slug + token)
|
|
49
|
+
wild-workspace reset [--yes] back to the beginning: unlink + reset onboarding +
|
|
50
|
+
flush local config (preview without --yes; keeps your files)
|
|
47
51
|
wild-workspace whoami show the currently-bound account
|
|
48
52
|
wild-workspace rotate-token mint a new account token; invalidates the old one
|
|
49
53
|
wild-workspace doctor [--share] diagnose this machine's install (✅/⚠️/❌ + logs)
|
|
@@ -84,6 +88,7 @@ function parseArgs(argv) {
|
|
|
84
88
|
else if (arg === '--tail') { opts.tail = Number(argv[++i]); }
|
|
85
89
|
else if (arg === '--share') { opts.share = true; }
|
|
86
90
|
else if (arg === '--rotate') { opts.rotate = true; }
|
|
91
|
+
else if (arg === '--yes' || arg === '-y') { opts.yes = true; }
|
|
87
92
|
else if (arg === '--kind') { opts.kind = argv[++i]; }
|
|
88
93
|
else if (arg === '--limit') { opts.limit = Number(argv[++i]); }
|
|
89
94
|
else if (arg.startsWith('--')) {
|
|
@@ -278,6 +283,73 @@ async function runLogoutCommand() {
|
|
|
278
283
|
console.log(`logged out — cleared ${before?.email || 'account'} from ${config.dataDir}.`);
|
|
279
284
|
}
|
|
280
285
|
|
|
286
|
+
// `wild-workspace reset [--yes]` — back to the beginning: unlink the account,
|
|
287
|
+
// reset onboarding, flush local config/state. NEVER touches workspace files.
|
|
288
|
+
// Without --yes it's a dry run (prints exactly what it WOULD remove).
|
|
289
|
+
async function runResetCommand(opts) {
|
|
290
|
+
const config = buildConfig(opts);
|
|
291
|
+
const gdir = globalDir();
|
|
292
|
+
const before = loadAccount(config.dataDir);
|
|
293
|
+
|
|
294
|
+
// Collect every data dir that might hold this install's account/onboarding:
|
|
295
|
+
// the one this invocation resolves (cwd-keyed) + the always-on workspace's
|
|
296
|
+
// (recorded in service.json), in case `reset` is run from a different folder.
|
|
297
|
+
const dataDirs = new Set([config.dataDir]);
|
|
298
|
+
try {
|
|
299
|
+
const svc = JSON.parse(readFileSync(path.join(gdir, 'service.json'), 'utf8'));
|
|
300
|
+
if (svc.workspaceDir) dataDirs.add(path.join(path.resolve(svc.workspaceDir), '.wild-workspace'));
|
|
301
|
+
} catch { /* no always-on registration — fine */ }
|
|
302
|
+
|
|
303
|
+
const targets = planReset({ dataDirs: [...dataDirs], globalDir: gdir, includeMarketplace: true });
|
|
304
|
+
const present = targets.filter((t) => t.exists);
|
|
305
|
+
|
|
306
|
+
console.log('wild-workspace reset — back to the beginning\n');
|
|
307
|
+
if (before) {
|
|
308
|
+
console.log(` currently linked: ${before.email} (slug: ${before.slug})`);
|
|
309
|
+
console.log('');
|
|
310
|
+
}
|
|
311
|
+
if (present.length === 0) {
|
|
312
|
+
console.log(' Nothing to clear — this install is already at a clean state.');
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
console.log(` ${opts.yes ? 'Removing' : 'Would remove'}:`);
|
|
317
|
+
for (const t of present) console.log(` - ${t.path}${t.kind === 'dir' ? ' (folder)' : ''}`);
|
|
318
|
+
console.log('');
|
|
319
|
+
console.log(' Keeps (untouched):');
|
|
320
|
+
for (const k of RESET_KEEPS) console.log(` · ${k}`);
|
|
321
|
+
console.log('');
|
|
322
|
+
|
|
323
|
+
if (!opts.yes) {
|
|
324
|
+
console.log(' This was a PREVIEW. Re-run to actually reset:');
|
|
325
|
+
console.log(' wild-workspace reset --yes');
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const { removed, failed } = applyReset(present);
|
|
330
|
+
console.log(` ✓ cleared ${removed.length} item(s).`);
|
|
331
|
+
if (failed.length) {
|
|
332
|
+
console.log(` ⚠ ${failed.length} could not be removed (in use? close the app + retry):`);
|
|
333
|
+
for (const f of failed) console.log(` - ${f.path}: ${f.error}`);
|
|
334
|
+
process.exitCode = 1;
|
|
335
|
+
}
|
|
336
|
+
console.log('');
|
|
337
|
+
// The running server still holds the old account/secrets in memory — a restart
|
|
338
|
+
// is what makes the reset take effect (and re-arms a fresh onboarding).
|
|
339
|
+
if (await probeHealth(config.port)) {
|
|
340
|
+
console.log(' ⚠ a workspace server is still running with the old state. Restart it:');
|
|
341
|
+
console.log(` stop it (close the app / kill :${config.port}); always-on restarts it clean,`);
|
|
342
|
+
console.log(' or run `wild-workspace` yourself.');
|
|
343
|
+
console.log('');
|
|
344
|
+
}
|
|
345
|
+
console.log(' Next:');
|
|
346
|
+
console.log(' • `wild-workspace login <blob>` — re-link to a slug, then `wild-workspace`');
|
|
347
|
+
console.log(' • or just `wild-workspace` — start fresh and re-run onboarding');
|
|
348
|
+
console.log('');
|
|
349
|
+
console.log(' Note: the canvas LAYOUT is stored in your browser, not here — open the');
|
|
350
|
+
console.log(' workspace in a fresh/incognito window (or clear site data) for a blank canvas.');
|
|
351
|
+
}
|
|
352
|
+
|
|
281
353
|
async function runRotateTokenCommand() {
|
|
282
354
|
const config = buildConfig({});
|
|
283
355
|
const account = loadAccount(config.dataDir);
|
|
@@ -570,81 +642,8 @@ async function runOperatorCommand(action = 'status', opts = {}) {
|
|
|
570
642
|
// autostart (docs/always-on-design.md). `run` is the HIDDEN supervisor entry
|
|
571
643
|
// the per-OS launcher invokes at login; the others manage registration. All
|
|
572
644
|
// 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
|
-
}
|
|
645
|
+
// First-run browser orchestration lives in a shebang-free module so it's
|
|
646
|
+
// importable + unit-testable (server/test/owner-browser.test.mjs).
|
|
648
647
|
|
|
649
648
|
async function runServiceCommand(action = 'status', opts = {}) {
|
|
650
649
|
const config = buildConfig(opts);
|
|
@@ -720,6 +719,9 @@ async function main() {
|
|
|
720
719
|
if (opts.positional[0] === 'logout') {
|
|
721
720
|
return runLogoutCommand();
|
|
722
721
|
}
|
|
722
|
+
if (opts.positional[0] === 'reset') {
|
|
723
|
+
return runResetCommand(opts);
|
|
724
|
+
}
|
|
723
725
|
if (opts.positional[0] === 'whoami') {
|
|
724
726
|
return runWhoamiCommand();
|
|
725
727
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// First-run browser orchestration for the LOCAL owner (B1). Extracted from the
|
|
2
|
+
// CLI so it's importable + unit-testable without the bin's shebang. The CLI
|
|
3
|
+
// (bin/wild-workspace.mjs) imports openOwnerBrowser; tests import the helpers.
|
|
4
|
+
|
|
5
|
+
// The URL to open for the LOCAL owner. A slug-linked install runs in public
|
|
6
|
+
// mode (the server denies anon — C1), so the owner must authenticate: append
|
|
7
|
+
// the partner token, which the SPA immediately exchanges for an HttpOnly cookie
|
|
8
|
+
// and strips from the address bar (S1). A localhost-only install needs no token.
|
|
9
|
+
// The token is only ever placed in the URL we hand the browser — never printed.
|
|
10
|
+
export function localBrowserUrl(config) {
|
|
11
|
+
const host = config.host === '0.0.0.0' ? '127.0.0.1' : config.host;
|
|
12
|
+
const base = `http://${host}:${config.port}`;
|
|
13
|
+
return config.publicMode ? `${base}/?t=${encodeURIComponent(config.partnerToken)}` : base;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Ask the running local server (over genuine loopback) for a one-time sign-in
|
|
17
|
+
// link to the PUBLIC url. Returns the URL or null (no slug / older server).
|
|
18
|
+
export async function fetchPublicBootstrapUrl(config) {
|
|
19
|
+
const host = config.host === '0.0.0.0' ? '127.0.0.1' : config.host;
|
|
20
|
+
try {
|
|
21
|
+
const ac = new AbortController();
|
|
22
|
+
const t = setTimeout(() => ac.abort(), 4000);
|
|
23
|
+
const r = await fetch(`http://${host}:${config.port}/api/auth/bootstrap`, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
signal: ac.signal,
|
|
26
|
+
});
|
|
27
|
+
clearTimeout(t);
|
|
28
|
+
if (!r.ok) return null;
|
|
29
|
+
const body = await r.json().catch(() => ({}));
|
|
30
|
+
return typeof body.url === 'string' ? body.url : null;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Poll the public url's /api/health until the tunnel forwards (200) or we give
|
|
37
|
+
// up — so we never open the owner onto a 502 "warming up" page.
|
|
38
|
+
export async function publicTunnelReady(shareBaseUrl, { tries = 6, gapMs = 1300 } = {}) {
|
|
39
|
+
const base = String(shareBaseUrl || '').replace(/\/$/, '');
|
|
40
|
+
if (!/^https?:\/\//.test(base)) return false;
|
|
41
|
+
for (let i = 0; i < tries; i += 1) {
|
|
42
|
+
try {
|
|
43
|
+
const ac = new AbortController();
|
|
44
|
+
const t = setTimeout(() => ac.abort(), 2500);
|
|
45
|
+
const r = await fetch(`${base}/api/health`, { signal: ac.signal });
|
|
46
|
+
clearTimeout(t);
|
|
47
|
+
if (r.ok) return true;
|
|
48
|
+
} catch { /* not up yet */ }
|
|
49
|
+
if (i < tries - 1) await new Promise((res) => setTimeout(res, gapMs));
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Open the owner's browser the friendliest way for THIS install:
|
|
55
|
+
// - slug-linked + public: land them signed-in on <slug>.venturewild.llc (their
|
|
56
|
+
// real, bookmarkable home) via a one-time bootstrap link, once the tunnel is
|
|
57
|
+
// confirmed up. If it isn't ready yet, fall back to localhost (always works
|
|
58
|
+
// locally) and tell them their public url is warming up.
|
|
59
|
+
// - localhost-only: just open localhost.
|
|
60
|
+
// Tokens only ever reach the browser via open() — never printed to stdout (B1/S1).
|
|
61
|
+
// `opts.open` / `opts.ready` are injectable seams for tests; in production the
|
|
62
|
+
// opener is the dynamically-imported `open` package and `ready` uses the defaults.
|
|
63
|
+
export async function openOwnerBrowser(config, opts = {}) {
|
|
64
|
+
let open = opts.open;
|
|
65
|
+
if (!open) {
|
|
66
|
+
try { open = (await import('open')).default; } catch { return; }
|
|
67
|
+
}
|
|
68
|
+
const slugLinked = config.publicMode && config.account?.slug && config.shareBaseUrl;
|
|
69
|
+
if (slugLinked) {
|
|
70
|
+
const link = await fetchPublicBootstrapUrl(config);
|
|
71
|
+
if (link && (await publicTunnelReady(config.shareBaseUrl, opts.ready))) {
|
|
72
|
+
console.log(` opening your workspace at ${config.shareBaseUrl} …`);
|
|
73
|
+
try { await open(link); } catch { /* best-effort */ }
|
|
74
|
+
return 'public';
|
|
75
|
+
}
|
|
76
|
+
// Tunnel not up yet (or older server) — open locally so first run is never a
|
|
77
|
+
// dead page; the public url comes alive on its own as the daemon links.
|
|
78
|
+
try { await open(localBrowserUrl(config)); } catch { /* best-effort */ }
|
|
79
|
+
console.log(` your workspace will be live at ${config.shareBaseUrl} shortly (warming up the tunnel)…`);
|
|
80
|
+
return 'fallback-local';
|
|
81
|
+
}
|
|
82
|
+
try { await open(localBrowserUrl(config)); } catch { /* best-effort */ }
|
|
83
|
+
return 'local';
|
|
84
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// `wild-workspace reset` — take an install back to the beginning so it can be
|
|
2
|
+
// re-onboarded clean. UNLINKS the account (slug/email/computer), RESETS
|
|
3
|
+
// onboarding, and FLUSHES local config/state (device secrets, token registry,
|
|
4
|
+
// chat thread, canvas + bazaar local state).
|
|
5
|
+
//
|
|
6
|
+
// It deliberately NEVER touches the user's workspace files (CLAUDE.md rule #1),
|
|
7
|
+
// nor the always-on registration / consent choices — those are install plumbing,
|
|
8
|
+
// not "the beginning". See RESET_KEEPS for the honest list of what survives.
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
// What survives a reset — documented so the command stays honest about scope.
|
|
14
|
+
export const RESET_KEEPS = [
|
|
15
|
+
"the user's workspace files (everything outside .wild-workspace) — never touched",
|
|
16
|
+
'service.json — always-on registration stays armed',
|
|
17
|
+
'observability.json — your consent choice is preserved',
|
|
18
|
+
'operator.json — the support-channel token',
|
|
19
|
+
'logs/ + diagnostics/ — kept for debugging',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
// Build the list of targets to remove. `dataDirs` are the (possibly several)
|
|
23
|
+
// cwd-keyed `.wild-workspace` dirs that hold the account/onboarding/chat; the
|
|
24
|
+
// stable `globalDir` (~/.wild-workspace) holds device secrets + canvas/bazaar
|
|
25
|
+
// state. Each target is annotated with whether it currently exists.
|
|
26
|
+
export function planReset({ dataDirs = [], globalDir, includeMarketplace = true }) {
|
|
27
|
+
const targets = [];
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
const add = (root, name, kind) => {
|
|
30
|
+
if (!root) return;
|
|
31
|
+
const p = path.join(root, name);
|
|
32
|
+
if (seen.has(p)) return;
|
|
33
|
+
seen.add(p);
|
|
34
|
+
targets.push({ path: p, kind, name });
|
|
35
|
+
};
|
|
36
|
+
// Per-workspace data dirs: the account binding + onboarding + chat thread,
|
|
37
|
+
// plus legacy secret/registry locations.
|
|
38
|
+
for (const dir of dataDirs) {
|
|
39
|
+
add(dir, 'account.json', 'file'); // unlink slug / email / this computer
|
|
40
|
+
add(dir, 'agent-identity.json', 'file'); // re-trigger onboarding
|
|
41
|
+
add(dir, 'chat-session.json', 'file'); // fresh chat thread
|
|
42
|
+
add(dir, 'secrets.json', 'file'); // legacy location (now in globalDir)
|
|
43
|
+
add(dir, 'revoked.json', 'file'); // legacy location
|
|
44
|
+
}
|
|
45
|
+
// Stable global dir: device secrets (regenerated fresh on next start) + the
|
|
46
|
+
// token registry + local UI/marketplace state.
|
|
47
|
+
add(globalDir, 'secrets.json', 'file');
|
|
48
|
+
add(globalDir, 'revoked.json', 'file');
|
|
49
|
+
if (includeMarketplace) {
|
|
50
|
+
add(globalDir, 'canvas', 'dir'); // agent-made blocks + theme.json
|
|
51
|
+
add(globalDir, 'bazaar', 'dir'); // local shelf + ledger
|
|
52
|
+
}
|
|
53
|
+
for (const t of targets) {
|
|
54
|
+
try {
|
|
55
|
+
t.exists = fs.existsSync(t.path);
|
|
56
|
+
} catch {
|
|
57
|
+
t.exists = false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return targets;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Remove every target that exists. Returns what was removed vs. what failed.
|
|
64
|
+
// Idempotent: a missing target is simply skipped.
|
|
65
|
+
export function applyReset(targets) {
|
|
66
|
+
const removed = [];
|
|
67
|
+
const failed = [];
|
|
68
|
+
for (const t of targets) {
|
|
69
|
+
if (!t.exists) continue;
|
|
70
|
+
try {
|
|
71
|
+
fs.rmSync(t.path, { recursive: t.kind === 'dir', force: true });
|
|
72
|
+
removed.push(t.path);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
failed.push({ path: t.path, error: e?.message || String(e) });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { removed, failed };
|
|
78
|
+
}
|