@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@venturewild/workspace",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Claude Code Web — Replit/Lovable-style chat-first browser UI that wraps the AI agent already installed on your machine.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -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
- // The URL to open for the LOCAL owner. A slug-linked install runs in public
574
- // mode (the server denies anon — C1), so the owner must authenticate: append
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
+ }