@venturewild/workspace 0.1.14 → 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.
Files changed (39) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +112 -112
  3. package/package.json +83 -76
  4. package/server/bin/wild-workspace.mjs +827 -763
  5. package/server/src/agent.mjs +453 -386
  6. package/server/src/bazaar/core.mjs +579 -0
  7. package/server/src/bazaar/index.mjs +75 -0
  8. package/server/src/bazaar/mcp-server.mjs +328 -0
  9. package/server/src/bazaar/mock-tickup.mjs +97 -0
  10. package/server/src/bazaar/preview-server.mjs +95 -0
  11. package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -0
  12. package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -0
  13. package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -0
  14. package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -0
  15. package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -0
  16. package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -0
  17. package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -0
  18. package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -0
  19. package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -0
  20. package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +32 -0
  21. package/server/src/canvas/core.mjs +324 -0
  22. package/server/src/canvas/index.mjs +42 -0
  23. package/server/src/canvas/mcp-server.mjs +253 -0
  24. package/server/src/config.mjs +365 -365
  25. package/server/src/daemon-supervisor.mjs +216 -216
  26. package/server/src/inbox.mjs +86 -86
  27. package/server/src/index.mjs +1948 -1721
  28. package/server/src/logpaths.mjs +98 -98
  29. package/server/src/owner-browser.mjs +84 -0
  30. package/server/src/reset.mjs +78 -0
  31. package/server/src/service.mjs +419 -419
  32. package/server/src/share.mjs +182 -148
  33. package/server/src/sync.mjs +248 -248
  34. package/server/src/turn-mcp.mjs +46 -0
  35. package/web/dist/assets/index-DVWgeTl_.js +91 -0
  36. package/web/dist/assets/index-Dl0VT5e6.css +1 -0
  37. package/web/dist/index.html +2 -2
  38. package/web/dist/assets/index-Bj-mdLGj.css +0 -1
  39. package/web/dist/assets/index-Dc6jo84c.js +0 -89
@@ -1,98 +1,98 @@
1
- // logpaths — one registry for the machine-global logs so `doctor`, `logs`, and
2
- // the operator channel all read the same set, and a tiny append/rotate/tail
3
- // helper for the new logs we write ourselves (cli, operator).
4
- //
5
- // WHY a registry and not just scattered paths: a brand-new user's install is the
6
- // riskiest moment (no Claude yet, wrong Node, port busy, daemon won't resolve),
7
- // and "I can't see what happened on their machine" is the #1 un-debuggable
8
- // failure. Centralising the log locations is what lets `wild-workspace doctor`
9
- // gather them and the operator channel tail them by name — never by arbitrary
10
- // path (the tail allowlist is TAILABLE below).
11
- //
12
- // NOTE: the supervisor + daemon-supervisor keep writing their logs at the
13
- // global-dir root (supervisor.log / server.out.log / daemon.log) — those paths
14
- // are reboot-proven and pinned by tests via an injected globalDir, so we mirror
15
- // them here for READING rather than moving them. Everything lives under
16
- // ~/.wild-workspace (NEVER the synced workspace — CLAUDE.md principle #1).
17
-
18
- import os from 'node:os';
19
- import path from 'node:path';
20
- import fs from 'node:fs';
21
-
22
- // THE machine-global dir. Mirrors service.mjs globalDir() + the supervisor
23
- // defaults. Overridable via env for tests / unusual homes.
24
- export function globalDir(env = process.env) {
25
- return env.WILD_WORKSPACE_GLOBAL_DIR || path.join(os.homedir(), '.wild-workspace');
26
- }
27
-
28
- // Logical name → filename. The first three are written by existing components;
29
- // cli + operator are new. Doctor bundles go under diagnosticsDir() (below).
30
- export const LOG_FILES = Object.freeze({
31
- supervisor: 'supervisor.log', // WorkspaceSupervisor watchdog
32
- server: 'server.out.log', // the :5173 server's stdout/stderr (launcher-redirected)
33
- daemon: 'daemon.log', // the bmo-sync daemon
34
- cli: 'cli.log', // every `wild-workspace …` invocation (first-run capture)
35
- operator: 'operator.log', // the consented operator channel's audit trail
36
- audit: 'audit.log', // privileged action audit trail (share/sync/agent — S8)
37
- });
38
-
39
- // Logs the operator channel may tail BY NAME — never an arbitrary path.
40
- export const TAILABLE = Object.freeze(Object.keys(LOG_FILES));
41
-
42
- export function logFile(name, env = process.env) {
43
- return path.join(globalDir(env), LOG_FILES[name] || `${name}.log`);
44
- }
45
-
46
- export function diagnosticsDir(env = process.env) {
47
- return path.join(globalDir(env), 'diagnostics');
48
- }
49
-
50
- const MAX_LOG_BYTES = 2 * 1024 * 1024; // 2 MB → rotate to .1 (keep one prior gen)
51
-
52
- // Rotate `file` to `file.1` once it grows past maxBytes. Best-effort, no throw.
53
- export function rotateIfBig(file, maxBytes = MAX_LOG_BYTES) {
54
- try {
55
- if (fs.statSync(file).size > maxBytes) fs.renameSync(file, `${file}.1`);
56
- } catch {
57
- /* missing / racing rename — nothing to rotate */
58
- }
59
- }
60
-
61
- // Append a timestamped line to a named log; ensures the dir + rotates. Returns
62
- // the file path. Never throws — logging must not break the caller's path.
63
- export function appendLine(name, line, env = process.env) {
64
- const file = logFile(name, env);
65
- try {
66
- fs.mkdirSync(path.dirname(file), { recursive: true });
67
- rotateIfBig(file);
68
- fs.appendFileSync(file, `[${new Date().toISOString()}] ${line}\n`);
69
- } catch {
70
- /* read-only fs etc. — degrade silently */
71
- }
72
- return file;
73
- }
74
-
75
- // Last `n` lines of a file (logs are size-capped, so reading whole is cheap).
76
- // Returns '' when the file is missing/unreadable.
77
- export function tailFile(file, n = 200) {
78
- try {
79
- // Drop the trailing newline so the last line isn't an empty entry.
80
- const lines = fs.readFileSync(file, 'utf8').replace(/\r?\n$/, '').split(/\r?\n/);
81
- return lines.slice(Math.max(0, lines.length - n)).join('\n');
82
- } catch {
83
- return '';
84
- }
85
- }
86
-
87
- // All known logs with on-disk size + mtime (null when absent) — for doctor/logs.
88
- export function listLogs(env = process.env) {
89
- return TAILABLE.map((name) => {
90
- const file = logFile(name, env);
91
- try {
92
- const st = fs.statSync(file);
93
- return { name, file, exists: true, size: st.size, mtime: st.mtimeMs };
94
- } catch {
95
- return { name, file, exists: false, size: null, mtime: null };
96
- }
97
- });
98
- }
1
+ // logpaths — one registry for the machine-global logs so `doctor`, `logs`, and
2
+ // the operator channel all read the same set, and a tiny append/rotate/tail
3
+ // helper for the new logs we write ourselves (cli, operator).
4
+ //
5
+ // WHY a registry and not just scattered paths: a brand-new user's install is the
6
+ // riskiest moment (no Claude yet, wrong Node, port busy, daemon won't resolve),
7
+ // and "I can't see what happened on their machine" is the #1 un-debuggable
8
+ // failure. Centralising the log locations is what lets `wild-workspace doctor`
9
+ // gather them and the operator channel tail them by name — never by arbitrary
10
+ // path (the tail allowlist is TAILABLE below).
11
+ //
12
+ // NOTE: the supervisor + daemon-supervisor keep writing their logs at the
13
+ // global-dir root (supervisor.log / server.out.log / daemon.log) — those paths
14
+ // are reboot-proven and pinned by tests via an injected globalDir, so we mirror
15
+ // them here for READING rather than moving them. Everything lives under
16
+ // ~/.wild-workspace (NEVER the synced workspace — CLAUDE.md principle #1).
17
+
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import fs from 'node:fs';
21
+
22
+ // THE machine-global dir. Mirrors service.mjs globalDir() + the supervisor
23
+ // defaults. Overridable via env for tests / unusual homes.
24
+ export function globalDir(env = process.env) {
25
+ return env.WILD_WORKSPACE_GLOBAL_DIR || path.join(os.homedir(), '.wild-workspace');
26
+ }
27
+
28
+ // Logical name → filename. The first three are written by existing components;
29
+ // cli + operator are new. Doctor bundles go under diagnosticsDir() (below).
30
+ export const LOG_FILES = Object.freeze({
31
+ supervisor: 'supervisor.log', // WorkspaceSupervisor watchdog
32
+ server: 'server.out.log', // the :5173 server's stdout/stderr (launcher-redirected)
33
+ daemon: 'daemon.log', // the bmo-sync daemon
34
+ cli: 'cli.log', // every `wild-workspace …` invocation (first-run capture)
35
+ operator: 'operator.log', // the consented operator channel's audit trail
36
+ audit: 'audit.log', // privileged action audit trail (share/sync/agent — S8)
37
+ });
38
+
39
+ // Logs the operator channel may tail BY NAME — never an arbitrary path.
40
+ export const TAILABLE = Object.freeze(Object.keys(LOG_FILES));
41
+
42
+ export function logFile(name, env = process.env) {
43
+ return path.join(globalDir(env), LOG_FILES[name] || `${name}.log`);
44
+ }
45
+
46
+ export function diagnosticsDir(env = process.env) {
47
+ return path.join(globalDir(env), 'diagnostics');
48
+ }
49
+
50
+ const MAX_LOG_BYTES = 2 * 1024 * 1024; // 2 MB → rotate to .1 (keep one prior gen)
51
+
52
+ // Rotate `file` to `file.1` once it grows past maxBytes. Best-effort, no throw.
53
+ export function rotateIfBig(file, maxBytes = MAX_LOG_BYTES) {
54
+ try {
55
+ if (fs.statSync(file).size > maxBytes) fs.renameSync(file, `${file}.1`);
56
+ } catch {
57
+ /* missing / racing rename — nothing to rotate */
58
+ }
59
+ }
60
+
61
+ // Append a timestamped line to a named log; ensures the dir + rotates. Returns
62
+ // the file path. Never throws — logging must not break the caller's path.
63
+ export function appendLine(name, line, env = process.env) {
64
+ const file = logFile(name, env);
65
+ try {
66
+ fs.mkdirSync(path.dirname(file), { recursive: true });
67
+ rotateIfBig(file);
68
+ fs.appendFileSync(file, `[${new Date().toISOString()}] ${line}\n`);
69
+ } catch {
70
+ /* read-only fs etc. — degrade silently */
71
+ }
72
+ return file;
73
+ }
74
+
75
+ // Last `n` lines of a file (logs are size-capped, so reading whole is cheap).
76
+ // Returns '' when the file is missing/unreadable.
77
+ export function tailFile(file, n = 200) {
78
+ try {
79
+ // Drop the trailing newline so the last line isn't an empty entry.
80
+ const lines = fs.readFileSync(file, 'utf8').replace(/\r?\n$/, '').split(/\r?\n/);
81
+ return lines.slice(Math.max(0, lines.length - n)).join('\n');
82
+ } catch {
83
+ return '';
84
+ }
85
+ }
86
+
87
+ // All known logs with on-disk size + mtime (null when absent) — for doctor/logs.
88
+ export function listLogs(env = process.env) {
89
+ return TAILABLE.map((name) => {
90
+ const file = logFile(name, env);
91
+ try {
92
+ const st = fs.statSync(file);
93
+ return { name, file, exists: true, size: st.size, mtime: st.mtimeMs };
94
+ } catch {
95
+ return { name, file, exists: false, size: null, mtime: null };
96
+ }
97
+ });
98
+ }
@@ -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
+ }