@venturewild/workspace 0.6.3 → 0.6.4

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 (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +112 -112
  3. package/package.json +85 -85
  4. package/server/bin/wild-workspace.mjs +1096 -1096
  5. package/server/src/account.mjs +114 -114
  6. package/server/src/agent-login.mjs +146 -146
  7. package/server/src/agent-readiness.mjs +200 -200
  8. package/server/src/agent.mjs +468 -468
  9. package/server/src/bazaar/core.mjs +974 -974
  10. package/server/src/bazaar/index.mjs +88 -88
  11. package/server/src/bazaar/mcp-server.mjs +429 -429
  12. package/server/src/bazaar/mock-tickup.mjs +97 -97
  13. package/server/src/bazaar/preview-server.mjs +95 -95
  14. package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -23
  15. package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -24
  16. package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -29
  17. package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -25
  18. package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -21
  19. package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -24
  20. package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -31
  21. package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -25
  22. package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -79
  23. package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +40 -40
  24. package/server/src/canvas/core.mjs +446 -446
  25. package/server/src/canvas/index.mjs +42 -42
  26. package/server/src/canvas/mcp-server.mjs +253 -253
  27. package/server/src/canvas-rails.mjs +108 -108
  28. package/server/src/config.mjs +404 -404
  29. package/server/src/daemon-bin.mjs +110 -110
  30. package/server/src/daemon-supervisor.mjs +285 -285
  31. package/server/src/doctor.mjs +375 -375
  32. package/server/src/inbox.mjs +86 -86
  33. package/server/src/index.mjs +3332 -3332
  34. package/server/src/listings-rails.mjs +156 -156
  35. package/server/src/logpaths.mjs +98 -98
  36. package/server/src/observability.mjs +45 -45
  37. package/server/src/operator.mjs +92 -92
  38. package/server/src/pairing.mjs +137 -137
  39. package/server/src/service.mjs +515 -515
  40. package/server/src/session-reporter.mjs +201 -201
  41. package/server/src/settings.mjs +145 -145
  42. package/server/src/share.mjs +182 -182
  43. package/server/src/skills.mjs +213 -213
  44. package/server/src/supervisor.mjs +647 -647
  45. package/server/src/support-consent.mjs +133 -133
  46. package/server/src/sync.mjs +248 -248
  47. package/server/src/transcript.mjs +121 -121
  48. package/server/src/turn-mcp.mjs +46 -46
  49. package/server/src/usage.mjs +405 -405
  50. package/server/src/workspace-registry.mjs +295 -295
  51. package/server/src/workspaces.mjs +145 -145
@@ -1,1096 +1,1096 @@
1
- #!/usr/bin/env node
2
- // `wild-workspace` CLI entry — the bin field in package.json.
3
- // Starts the workspace server; also exposes a `daemon` subcommand for
4
- // inspecting / controlling the bmo-sync sync daemon.
5
-
6
- import path from 'node:path';
7
- import url from 'node:url';
8
- import os from 'node:os';
9
- import { createServer } from '../src/index.mjs';
10
- import { APP_VERSION, buildConfig } from '../src/config.mjs';
11
- import { DaemonSupervisor } from '../src/daemon-supervisor.mjs';
12
- import { SyncControl } from '../src/sync.mjs';
13
- import {
14
- decodeLoginPayload,
15
- saveAccount,
16
- loadAccount,
17
- clearAccount,
18
- } from '../src/account.mjs';
19
- import { readFileSync } from 'node:fs';
20
- import { WorkspaceSupervisor, probeHealth } from '../src/supervisor.mjs';
21
- import { installService, uninstallService, serviceStatus, globalDir } from '../src/service.mjs';
22
- import { appendLine, listLogs, tailFile } from '../src/logpaths.mjs';
23
- import { runDoctor, renderDoctor, writeDoctorBundle } from '../src/doctor.mjs';
24
- import { enableOperator, disableOperator, operatorStatus } from '../src/operator.mjs';
25
- import { loadObservabilityConsent, setObservabilityConsent } from '../src/observability.mjs';
26
- import {
27
- grantConsent, revokeConsent, consentStatus, readAudit, MAX_TIER, MAX_GRANT_MINUTES, OPERATE_TIER,
28
- } from '../src/support-consent.mjs';
29
- import {
30
- AutoUpdater, PACKAGE_NAME, npmInstall, recordUpdate,
31
- loadUpdateSettings, setUpdateEnabled, setUpdateChannel,
32
- } from '../src/auto-update.mjs';
33
- import { openOwnerBrowser } from '../src/owner-browser.mjs';
34
- import { planReset, applyReset, RESET_KEEPS } from '../src/reset.mjs';
35
- import { createWorkspaceRails } from '../src/workspaces.mjs';
36
-
37
- const __filename = url.fileURLToPath(import.meta.url);
38
- const __dirname = path.dirname(__filename);
39
-
40
- function printUsage() {
41
- console.log(`wild-workspace v${APP_VERSION}
42
-
43
- Usage:
44
- wild-workspace start the workspace server in the current directory
45
- wild-workspace --port 5173 override port (default 5173)
46
- wild-workspace --no-open don't auto-open browser
47
- wild-workspace --host 0.0.0.0 bind to all interfaces (for share-by-URL hosting)
48
- wild-workspace daemon status is the bmo-sync daemon running?
49
- wild-workspace daemon start start the sync daemon now
50
- wild-workspace daemon stop stop the sync daemon
51
- wild-workspace daemon conflicts list list open conflicts
52
- wild-workspace daemon conflicts show <wid> <path> view one conflict
53
- wild-workspace daemon conflicts resolve <wid> <path> <keep_mine|take_theirs>
54
- wild-workspace login <payload> bind this install to a slug
55
- (paste the blob from workspace.venturewild.llc)
56
- wild-workspace logout clear the bound account (slug + token)
57
- wild-workspace reset [--yes] back to the beginning: unlink + reset onboarding +
58
- flush local config (preview without --yes; keeps your files)
59
- wild-workspace whoami show the currently-bound account
60
- wild-workspace rotate-token mint a new account token; invalidates the old one
61
- wild-workspace workspace create "<name>" [--slug s] create a shared workspace (its own slug, owned by you)
62
- wild-workspace workspace list shared workspaces you belong to
63
- wild-workspace workspace add-member <slug> <email> add a teammate (they must have a slug already)
64
- wild-workspace workspace remove-member <slug> <email-or-accountId> remove a teammate
65
- wild-workspace doctor [--share] diagnose this machine's install (✅/⚠️/❌ + logs)
66
- wild-workspace logs [name] [--tail N] list logs, or tail one (cli/server/daemon/…)
67
- wild-workspace operator enable let the wild-workspace team help with your install (mints a token)
68
- wild-workspace operator disable revoke the support token
69
- wild-workspace operator status is the support channel on?
70
- wild-workspace support allow --tier 1 --minutes 60 allow time-boxed support (works even when offline)
71
- wild-workspace support revoke revoke support access now
72
- wild-workspace support status is support access on, and for how long?
73
- wild-workspace support audit show what support has done (the audit feed)
74
- wild-workspace observability [on|off|status] share session + install health so we can help (default on; never chat content)
75
- wild-workspace update [apply] check for / install a newer version (auto by default)
76
- wild-workspace update on|off toggle background auto-update
77
- wild-workspace update channel stable|beta choose the update channel
78
- wild-workspace service install keep your workspace always-on (starts at login, no admin)
79
- wild-workspace service uninstall turn always-on off
80
- wild-workspace service status show always-on status (installed? supervisor? server?)
81
- wild-workspace install (info) how the sync daemon is managed
82
- wild-workspace --help this message
83
- wild-workspace --version print version
84
-
85
- The bmo-sync sync daemon starts automatically in the background when you run
86
- \`wild-workspace\`, and keeps running after you close the browser.
87
-
88
- Environment:
89
- WILD_WORKSPACE_PORT, WILD_WORKSPACE_HOST,
90
- WILD_WORKSPACE_DIR, WILD_WORKSPACE_DATA_DIR,
91
- WILD_WORKSPACE_PARTNER_TOKEN, WILD_WORKSPACE_SHARE_SECRET,
92
- WILD_WORKSPACE_NO_OPEN=1, WILD_WORKSPACE_DAEMON_AUTOSTART=0
93
- `);
94
- }
95
-
96
- function parseArgs(argv) {
97
- const opts = {};
98
- const positional = [];
99
- for (let i = 0; i < argv.length; i++) {
100
- const arg = argv[i];
101
- if (arg === '--help' || arg === '-h') opts.help = true;
102
- else if (arg === '--version' || arg === '-v') opts.version = true;
103
- else if (arg === '--no-open') opts.openBrowser = false;
104
- else if (arg === '--port') { opts.port = Number(argv[++i]); }
105
- else if (arg === '--host') { opts.host = argv[++i]; }
106
- else if (arg === '--workspace') { opts.workspaceDir = argv[++i]; }
107
- else if (arg === '--tail') { opts.tail = Number(argv[++i]); }
108
- else if (arg === '--share') { opts.share = true; }
109
- else if (arg === '--rotate') { opts.rotate = true; }
110
- else if (arg === '--yes' || arg === '-y') { opts.yes = true; }
111
- else if (arg === '--kind') { opts.kind = argv[++i]; }
112
- else if (arg === '--slug') { opts.slug = argv[++i]; }
113
- else if (arg === '--limit') { opts.limit = Number(argv[++i]); }
114
- else if (arg === '--tier') { opts.tier = Number(argv[++i]); }
115
- else if (arg === '--minutes') { opts.minutes = Number(argv[++i]); }
116
- else if (arg.startsWith('--')) {
117
- // ignore unknown flags
118
- } else {
119
- positional.push(arg);
120
- }
121
- }
122
- opts.positional = positional;
123
- return opts;
124
- }
125
-
126
- // `wild-workspace daemon [status|start|stop|conflicts ...]`
127
- async function runDaemonCommand(action = 'status', rest = []) {
128
- const config = buildConfig({});
129
- const sup = new DaemonSupervisor({
130
- httpBase: config.daemonHttpUrl,
131
- // b-ii: so `wild-workspace daemon start` also opens the proxy link.
132
- accountToken: config.accountToken,
133
- serverUrl: config.bmoSyncServerUrl,
134
- });
135
-
136
- if (action === 'conflicts') {
137
- return runConflictsCommand(config, rest);
138
- }
139
- if (action === 'status') {
140
- const s = await sup.status();
141
- console.log(`bmo-sync daemon: ${s.running ? 'running' : 'stopped'}`);
142
- console.log(` api : ${s.httpBase}`);
143
- if (s.pid) console.log(` pid : ${s.pid}`);
144
- console.log(` log : ${s.logFile}`);
145
- return;
146
- }
147
- if (action === 'start') {
148
- const r = await sup.ensureRunning();
149
- if (r.alreadyRunning) {
150
- console.log('bmo-sync daemon: already running');
151
- } else if (r.started) {
152
- const healthy = await sup.waitForHealthy();
153
- console.log(
154
- healthy
155
- ? `bmo-sync daemon: started (pid ${r.pid})`
156
- : `bmo-sync daemon: launched (pid ${r.pid}) — not yet answering, check the log`,
157
- );
158
- } else if (r.error === 'daemon-binary-not-found') {
159
- console.log('bmo-sync daemon: cannot start — the daemon binary is not installed.');
160
- console.log(' build it from the bmo-sync workspace and place it under vendor/,');
161
- console.log(' or install the @venturewild/workspace-daemon-<platform> package.');
162
- } else {
163
- console.log(`bmo-sync daemon: could not start — ${r.error}`);
164
- }
165
- return;
166
- }
167
- if (action === 'stop') {
168
- const r = await sup.stop();
169
- console.log(
170
- r.stopped
171
- ? `bmo-sync daemon: stopped (pid ${r.pid})`
172
- : `bmo-sync daemon: not stopped — ${r.reason}`,
173
- );
174
- return;
175
- }
176
- console.log(`unknown daemon action: ${action} (use status | start | stop | conflicts)`);
177
- }
178
-
179
- // `wild-workspace daemon conflicts [list|show <wid> <path>|resolve <wid> <path> <action>]`
180
- async function runConflictsCommand(config, args) {
181
- const sync = new SyncControl({
182
- daemonHttpUrl: config.daemonHttpUrl,
183
- bmoSyncServerUrl: config.bmoSyncServerUrl,
184
- });
185
- const sub = (args[0] || 'list').toLowerCase();
186
- if (sub === 'list' || !sub) {
187
- const conflicts = await sync.listConflicts();
188
- if (!conflicts.length) {
189
- console.log('no open conflicts');
190
- return;
191
- }
192
- console.log(`open conflicts: ${conflicts.length}`);
193
- for (const c of conflicts) {
194
- const detected = new Date((c.detectedAt || 0) * 1000).toISOString();
195
- console.log(` [${c.workspaceId}] ${c.path}`);
196
- console.log(` resolution : ${c.resolution}`);
197
- console.log(` detected_at : ${detected}`);
198
- if (c.peerBackOfficePath) {
199
- console.log(` peer_bytes_at : ${c.peerBackOfficePath}`);
200
- }
201
- if (c.mineSha256) console.log(` mine_sha256 : ${c.mineSha256}`);
202
- if (c.theirsSha256) console.log(` theirs_sha256 : ${c.theirsSha256}`);
203
- }
204
- return;
205
- }
206
- if (sub === 'show') {
207
- const wid = args[1];
208
- const path = args[2];
209
- if (!wid || !path) {
210
- console.log('usage: wild-workspace daemon conflicts show <workspace_id> <path>');
211
- return;
212
- }
213
- const view = await sync.viewConflict(wid, path);
214
- if (!view) {
215
- console.log(`no open conflict for ${wid}:${path}`);
216
- return;
217
- }
218
- console.log(JSON.stringify(view, null, 2));
219
- return;
220
- }
221
- if (sub === 'resolve') {
222
- const wid = args[1];
223
- const path = args[2];
224
- const action = args[3];
225
- if (!wid || !path || !action) {
226
- console.log(
227
- 'usage: wild-workspace daemon conflicts resolve <workspace_id> <path> <keep_mine|take_theirs>',
228
- );
229
- return;
230
- }
231
- try {
232
- await sync.resolveConflict(wid, path, action);
233
- console.log(`resolved: ${wid}:${path} (${action})`);
234
- } catch (e) {
235
- console.error(`resolve failed: ${e.message || e}`);
236
- process.exitCode = 1;
237
- }
238
- return;
239
- }
240
- console.log(
241
- `unknown conflicts subcommand: ${sub} (use list | show <wid> <path> | resolve <wid> <path> <action>)`,
242
- );
243
- }
244
-
245
- // `wild-workspace login <base64url-payload>` — bind this install to a slug.
246
- // The payload comes from workspace.venturewild.llc on signup. It's an opaque
247
- // blob the user copies once; we decode + persist + print a friendly summary.
248
- async function runLoginCommand(args) {
249
- if (!args.length) {
250
- console.error('usage: wild-workspace login <payload>');
251
- console.error('');
252
- console.error('The payload is the blob you copied from workspace.venturewild.llc');
253
- console.error('after claiming your slug. Run that signup, then come back here.');
254
- process.exitCode = 1;
255
- return;
256
- }
257
- // Trim the obvious "wild-workspace login " prefix in case the user pasted
258
- // the whole command line, and surrounding quotes if a shell preserved them.
259
- let payload = args.join(' ').trim();
260
- payload = payload.replace(/^wild-workspace\s+login\s+/i, '').trim();
261
- payload = payload.replace(/^['"]|['"]$/g, '');
262
- let parsed;
263
- try {
264
- parsed = decodeLoginPayload(payload);
265
- } catch (e) {
266
- console.error(`Couldn't decode the login payload: ${e.message || e}`);
267
- process.exitCode = 1;
268
- return;
269
- }
270
- const config = buildConfig({});
271
- let saved;
272
- try {
273
- saved = saveAccount(config.dataDir, parsed);
274
- } catch (e) {
275
- console.error(`Couldn't save account to ${config.dataDir}: ${e.message || e}`);
276
- process.exitCode = 1;
277
- return;
278
- }
279
- console.log(`✓ logged in as ${saved.email}`);
280
- console.log(` slug : ${saved.slug}`);
281
- console.log(` url : https://${saved.slug}.venturewild.llc (once the tunnel is configured)`);
282
- console.log(` saved to : ${config.dataDir}/account.json`);
283
- console.log('');
284
- console.log('Run `wild-workspace` in any folder to start your workspace.');
285
-
286
- // Arm always-on so the workspace comes back on its own (best-effort — never
287
- // blocks login). On a platform without autostart yet, just nudge the user.
288
- try {
289
- const svc = await installService({
290
- node: process.execPath, cli: __filename, workspaceDir: config.workspaceDir, port: config.port, version: APP_VERSION,
291
- });
292
- if (svc.installed) console.log(' always-on : enabled — starts at login (disable: wild-workspace service uninstall)');
293
- else if (svc.supported === false) console.log(` always-on : not yet on ${svc.platform} — run \`wild-workspace\` to start it`);
294
- } catch { /* never block login */ }
295
- }
296
-
297
- async function runLogoutCommand() {
298
- const config = buildConfig({});
299
- const before = loadAccount(config.dataDir);
300
- const removed = clearAccount(config.dataDir);
301
- if (!removed && !before) {
302
- console.log('not logged in.');
303
- return;
304
- }
305
- console.log(`logged out — cleared ${before?.email || 'account'} from ${config.dataDir}.`);
306
- }
307
-
308
- // `wild-workspace reset [--yes]` — back to the beginning: unlink the account,
309
- // reset onboarding, flush local config/state. NEVER touches workspace files.
310
- // Without --yes it's a dry run (prints exactly what it WOULD remove).
311
- async function runResetCommand(opts) {
312
- const config = buildConfig(opts);
313
- const gdir = globalDir();
314
- const before = loadAccount(config.dataDir);
315
-
316
- // Collect every data dir that might hold this install's account/onboarding:
317
- // the one this invocation resolves (cwd-keyed) + the always-on workspace's
318
- // (recorded in service.json), in case `reset` is run from a different folder.
319
- const dataDirs = new Set([config.dataDir]);
320
- try {
321
- const svc = JSON.parse(readFileSync(path.join(gdir, 'service.json'), 'utf8'));
322
- if (svc.workspaceDir) dataDirs.add(path.join(path.resolve(svc.workspaceDir), '.wild-workspace'));
323
- } catch { /* no always-on registration — fine */ }
324
-
325
- const targets = planReset({ dataDirs: [...dataDirs], globalDir: gdir, includeMarketplace: true });
326
- const present = targets.filter((t) => t.exists);
327
-
328
- console.log('wild-workspace reset — back to the beginning\n');
329
- if (before) {
330
- console.log(` currently linked: ${before.email} (slug: ${before.slug})`);
331
- console.log('');
332
- }
333
- if (present.length === 0) {
334
- console.log(' Nothing to clear — this install is already at a clean state.');
335
- return;
336
- }
337
-
338
- console.log(` ${opts.yes ? 'Removing' : 'Would remove'}:`);
339
- for (const t of present) console.log(` - ${t.path}${t.kind === 'dir' ? ' (folder)' : ''}`);
340
- console.log('');
341
- console.log(' Keeps (untouched):');
342
- for (const k of RESET_KEEPS) console.log(` · ${k}`);
343
- console.log('');
344
-
345
- if (!opts.yes) {
346
- console.log(' This was a PREVIEW. Re-run to actually reset:');
347
- console.log(' wild-workspace reset --yes');
348
- return;
349
- }
350
-
351
- const { removed, failed } = applyReset(present);
352
- console.log(` ✓ cleared ${removed.length} item(s).`);
353
- if (failed.length) {
354
- console.log(` ⚠ ${failed.length} could not be removed (in use? close the app + retry):`);
355
- for (const f of failed) console.log(` - ${f.path}: ${f.error}`);
356
- process.exitCode = 1;
357
- }
358
- console.log('');
359
- // The running server still holds the old account/secrets in memory — a restart
360
- // is what makes the reset take effect (and re-arms a fresh onboarding).
361
- if (await probeHealth(config.port)) {
362
- console.log(' ⚠ a workspace server is still running with the old state. Restart it:');
363
- console.log(` stop it (close the app / kill :${config.port}); always-on restarts it clean,`);
364
- console.log(' or run `wild-workspace` yourself.');
365
- console.log('');
366
- }
367
- console.log(' Next:');
368
- console.log(' • `wild-workspace login <blob>` — re-link to a slug, then `wild-workspace`');
369
- console.log(' • or just `wild-workspace` — start fresh and re-run onboarding');
370
- console.log('');
371
- console.log(' Note: the canvas LAYOUT is stored in your browser, not here — open the');
372
- console.log(' workspace in a fresh/incognito window (or clear site data) for a blank canvas.');
373
- }
374
-
375
- async function runRotateTokenCommand() {
376
- const config = buildConfig({});
377
- const account = loadAccount(config.dataDir);
378
- if (!account) {
379
- console.log('not logged in. Nothing to rotate.');
380
- console.log('Run `wild-workspace login <payload>` after claiming a slug.');
381
- process.exitCode = 1;
382
- return;
383
- }
384
- const url = `${config.bmoSyncServerUrl.replace(/\/$/, '')}/api/account/rotate-token`;
385
- let resp;
386
- try {
387
- resp = await fetch(url, {
388
- method: 'POST',
389
- headers: { authorization: `Bearer ${account.accountToken}` },
390
- });
391
- } catch (e) {
392
- console.error(`Couldn't reach ${config.bmoSyncServerUrl}: ${e.message || e}`);
393
- process.exitCode = 2;
394
- return;
395
- }
396
- if (!resp.ok) {
397
- let body;
398
- try { body = await resp.text(); } catch { body = ''; }
399
- console.error(`Rotate failed (HTTP ${resp.status}): ${body.slice(0, 200)}`);
400
- process.exitCode = 1;
401
- return;
402
- }
403
- let payload;
404
- try {
405
- payload = await resp.json();
406
- } catch (e) {
407
- console.error(`Server returned non-JSON: ${e.message || e}`);
408
- process.exitCode = 1;
409
- return;
410
- }
411
- if (!payload || !payload.account_token) {
412
- console.error('Server response missing account_token. Aborting.');
413
- process.exitCode = 1;
414
- return;
415
- }
416
- const updated = {
417
- ...account,
418
- accountToken: payload.account_token,
419
- loggedInAt: Date.now(),
420
- };
421
- try {
422
- saveAccount(config.dataDir, updated);
423
- } catch (e) {
424
- console.error(`Got new token but failed to persist it: ${e.message || e}`);
425
- console.error(`New token (save manually): ${payload.account_token}`);
426
- process.exitCode = 1;
427
- return;
428
- }
429
- console.log('✓ token rotated.');
430
- console.log(` slug : ${updated.slug}`);
431
- console.log(` saved to : ${config.dataDir}/account.json`);
432
- console.log('');
433
- console.log('Your daemon will reconnect with the new token automatically');
434
- console.log('on its next restart. Run `wild-workspace daemon stop && wild-workspace`');
435
- console.log('to force an immediate reconnect.');
436
- }
437
-
438
- // `wild-workspace workspace {create|list|add-member|remove-member}` — multi-host
439
- // step 2 (membership). The FOUNDATION's drivable surface: create a shared
440
- // workspace (its own slug, owned by you), manage its member set, and read the
441
- // shared workspaces you belong to. The polished invite/join/lobby UX is later;
442
- // these are operator/dev affordances over the rails (you never copy-paste a
443
- // token — they read your bound account). Requires `wild-workspace login` first.
444
- async function runWorkspaceCommand(action, opts) {
445
- const config = buildConfig(opts);
446
- if (!config.accountToken) {
447
- console.error('not logged in. Run `wild-workspace login <payload>` after claiming a slug.');
448
- process.exitCode = 1;
449
- return;
450
- }
451
- const rails = createWorkspaceRails(config, { accountToken: config.accountToken });
452
- const fail = (r) => {
453
- console.error(`✗ ${r.message || r.code || 'failed'}${r.code ? ` (${r.code})` : ''}`);
454
- process.exitCode = 1;
455
- };
456
-
457
- if (action === 'create') {
458
- const name = opts.positional.slice(2).join(' ').trim();
459
- if (!name) {
460
- console.error('usage: wild-workspace workspace create "<name>" [--slug <slug>]');
461
- process.exitCode = 1;
462
- return;
463
- }
464
- const r = await rails.create(name, opts.slug || null);
465
- if (!r.ok) return fail(r);
466
- console.log(`✓ created shared workspace "${r.data.name}"`);
467
- console.log(` slug : ${r.data.slug}`);
468
- console.log(` url : https://${r.data.slug}.venturewild.llc (once routing lands — step 3)`);
469
- console.log('');
470
- console.log(' Add a teammate (they must have claimed a slug already):');
471
- console.log(` wild-workspace workspace add-member ${r.data.slug} <their-email>`);
472
- return;
473
- }
474
-
475
- if (action === 'list') {
476
- const r = await rails.list();
477
- if (!r.ok) return fail(r);
478
- const ws = r.data.workspaces || [];
479
- if (!ws.length) {
480
- console.log('you belong to no shared workspaces yet.');
481
- console.log(' create one: wild-workspace workspace create "<name>"');
482
- return;
483
- }
484
- console.log(`shared workspaces you belong to: ${ws.length}`);
485
- for (const w of ws) {
486
- console.log(` ${w.slug.padEnd(20)} ${w.role.padEnd(7)} "${w.name}" (owner: ${w.owner_email})`);
487
- }
488
- return;
489
- }
490
-
491
- if (action === 'add-member') {
492
- const slug = opts.positional[2];
493
- const email = opts.positional[3];
494
- if (!slug || !email) {
495
- console.error('usage: wild-workspace workspace add-member <slug> <email>');
496
- process.exitCode = 1;
497
- return;
498
- }
499
- const r = await rails.addMember(slug, email);
500
- if (!r.ok) return fail(r);
501
- console.log(`✓ added ${email} to ${slug} (account ${r.data.account_id}).`);
502
- return;
503
- }
504
-
505
- if (action === 'remove-member') {
506
- const slug = opts.positional[2];
507
- const ref = opts.positional[3];
508
- if (!slug || !ref) {
509
- console.error('usage: wild-workspace workspace remove-member <slug> <email-or-accountId>');
510
- process.exitCode = 1;
511
- return;
512
- }
513
- const r = await rails.removeMember(slug, ref);
514
- if (!r.ok) return fail(r);
515
- console.log(
516
- r.data.removed
517
- ? `✓ removed ${ref} from ${slug}.`
518
- : `${ref} was not a member of ${slug} (nothing to remove).`,
519
- );
520
- return;
521
- }
522
-
523
- console.log('unknown workspace action. use: create | list | add-member | remove-member');
524
- console.log(' wild-workspace workspace create "<name>" [--slug <slug>]');
525
- console.log(' wild-workspace workspace list');
526
- console.log(' wild-workspace workspace add-member <slug> <email>');
527
- console.log(' wild-workspace workspace remove-member <slug> <email-or-accountId>');
528
- }
529
-
530
- async function runWhoamiCommand() {
531
- const config = buildConfig({});
532
- const account = loadAccount(config.dataDir);
533
- if (!account) {
534
- console.log('not logged in.');
535
- console.log('Run `wild-workspace login <payload>` after claiming a slug at workspace.venturewild.llc.');
536
- return;
537
- }
538
- console.log(`logged in as ${account.email}`);
539
- console.log(` slug : ${account.slug}`);
540
- console.log(` accountId : ${account.accountId}`);
541
- if (account.displayName) console.log(` name : ${account.displayName}`);
542
- console.log(` loggedIn : ${new Date(account.loggedInAt).toISOString()}`);
543
- }
544
-
545
- // `wild-workspace doctor [--share]` — one diagnostic of this machine's install.
546
- // Prints ✅/⚠️/❌ per check + where the logs are, and writes a JSON bundle under
547
- // ~/.wild-workspace/diagnostics/. Exits non-zero if any check failed.
548
- async function runDoctorCommand(opts) {
549
- const config = buildConfig(opts);
550
- const report = await runDoctor({ config });
551
- console.log(renderDoctor(report));
552
- const bundle = writeDoctorBundle(report);
553
- if (bundle) {
554
- console.log('');
555
- console.log(`Full report: ${bundle}`);
556
- }
557
- if (opts.share) {
558
- const shared = await shareDoctor(config, report);
559
- console.log(
560
- shared.ok
561
- ? '✓ shared with the wild-workspace team — they can see this diagnostic now.'
562
- : `Couldn't auto-share (${shared.reason}). Send the file above instead.`,
563
- );
564
- }
565
- process.exitCode = report.summary.fail > 0 ? 1 : 0;
566
- }
567
-
568
- // Upload a doctor report to bmo-sync. `--share` is an explicit user action, so it
569
- // goes even if the passive observability feed is off — but still respects the
570
- // hard kill switch and needs an account to key it to.
571
- async function shareDoctor(config, report) {
572
- if (!config.accountToken) return { ok: false, reason: 'not logged in' };
573
- if (process.env.WILD_WORKSPACE_NO_TELEMETRY === '1') return { ok: false, reason: 'telemetry disabled' };
574
- const url = `${config.bmoSyncServerUrl.replace(/\/$/, '')}/api/telemetry`;
575
- const ctrl = new AbortController();
576
- const t = setTimeout(() => ctrl.abort(), 5000);
577
- try {
578
- const res = await fetch(url, {
579
- method: 'POST',
580
- headers: { 'content-type': 'application/json' },
581
- body: JSON.stringify({
582
- account_token: config.accountToken,
583
- slug: config.account?.slug || null,
584
- workspace_id: config.workspaceId,
585
- kind: 'doctor-share',
586
- doctor: report,
587
- sent_at: Math.floor(Date.now() / 1000),
588
- }),
589
- signal: ctrl.signal,
590
- });
591
- return { ok: res.ok, reason: res.ok ? null : `HTTP ${res.status}` };
592
- } catch (e) {
593
- return { ok: false, reason: String(e?.message || e) };
594
- } finally {
595
- clearTimeout(t);
596
- }
597
- }
598
-
599
- // `wild-workspace logs [name] [--tail N]` — list the logs, or tail one by name.
600
- async function runLogsCommand(opts) {
601
- const name = opts.positional[1];
602
- const n = opts.tail || 40;
603
- const logs = listLogs();
604
- if (!name) {
605
- console.log(`logs dir: ${path.dirname(logs[0].file)}`);
606
- for (const l of logs) {
607
- console.log(` ${l.name.padEnd(10)} ${l.exists ? `${l.size} bytes` : '(none yet)'} ${l.file}`);
608
- }
609
- console.log('');
610
- console.log(`Show one: wild-workspace logs <name> [--tail N] (names: ${logs.map((l) => l.name).join(', ')})`);
611
- return;
612
- }
613
- const match = logs.find((l) => l.name === name);
614
- if (!match) {
615
- console.log(`unknown log "${name}". names: ${logs.map((l) => l.name).join(', ')}`);
616
- process.exitCode = 1;
617
- return;
618
- }
619
- console.log(`# ${match.name} — ${match.file} (last ${n} lines)`);
620
- console.log(tailFile(match.file, n) || '(empty)');
621
- }
622
-
623
- // `wild-workspace ops <slug>` — OPERATOR read of a user's observability feed.
624
- // Reads the bmo-sync admin endpoint with the admin key at ~/.bmo-sync-admin-key
625
- // (so it only works on an operator's machine). This is how we see a stuck/broken
626
- // user without them having to ask. Filters: --kind feed|install-down|transcript,
627
- // --limit N.
628
- async function runOpsCommand(opts) {
629
- const slug = opts.positional[1];
630
- if (!slug) {
631
- console.error('usage: wild-workspace ops <slug> [--kind feed|install-down|transcript] [--limit N]');
632
- process.exitCode = 1;
633
- return;
634
- }
635
- const config = buildConfig(opts);
636
- const keyPath = path.join(os.homedir(), '.bmo-sync-admin-key');
637
- let adminKey;
638
- try {
639
- adminKey = readFileSync(keyPath, 'utf8').trim();
640
- } catch {
641
- console.error(`No admin key at ${keyPath} — \`ops\` is an operator-only command.`);
642
- process.exitCode = 1;
643
- return;
644
- }
645
- const params = new URLSearchParams();
646
- if (opts.kind) params.set('kind', opts.kind);
647
- params.set('limit', String(opts.limit || 50));
648
- const url = `${config.bmoSyncServerUrl.replace(/\/$/, '')}/api/admin/accounts/${encodeURIComponent(slug)}/activity?${params}`;
649
- let res;
650
- try {
651
- res = await fetch(url, { headers: { 'x-admin-key': adminKey } });
652
- } catch (e) {
653
- console.error(`Couldn't reach ${config.bmoSyncServerUrl}: ${e.message || e}`);
654
- process.exitCode = 2;
655
- return;
656
- }
657
- if (res.status === 404) {
658
- console.log(`No account/slug "${slug}" yet (unclaimed, or it hasn't reported).`);
659
- return;
660
- }
661
- if (res.status === 403 || res.status === 401) {
662
- console.error('admin key rejected.');
663
- process.exitCode = 1;
664
- return;
665
- }
666
- if (!res.ok) {
667
- console.error(`HTTP ${res.status}`);
668
- process.exitCode = 1;
669
- return;
670
- }
671
- const data = await res.json();
672
- console.log(`account ${data.account_id}${data.slug ? ` (${data.slug})` : ''} — ${data.count} recent event(s)`);
673
- for (const r of data.rows || []) {
674
- const when = new Date((r.reported_at || 0) * 1000).toISOString();
675
- let detail = '';
676
- if (r.kind === 'feed' && Array.isArray(r.payload?.events)) {
677
- const counts = {};
678
- for (const e of r.payload.events) counts[e.type] = (counts[e.type] || 0) + 1;
679
- detail = Object.entries(counts).map(([k, v]) => `${k}×${v}`).join(' ');
680
- } else if ((r.kind === 'install-down' || r.kind === 'doctor-share') && r.payload?.doctor?.summary) {
681
- const s = r.payload.doctor.summary;
682
- detail = `doctor: ${s.fail} fail / ${s.warn} warn`;
683
- } else if (r.kind === 'transcript') {
684
- detail = `transcript ${r.payload?.date || ''} (${(r.payload?.markdown || '').length} chars)`;
685
- }
686
- console.log(` ${when} [${r.kind}]${r.os ? ` ${r.os}` : ''} ${detail}`);
687
- }
688
- }
689
-
690
- // `wild-workspace observability [on|off|status]` — the consented session +
691
- // install-health feed (default ON). Streams WHAT happened + install health to the
692
- // Venturewild team so they can help if something breaks — never your chat words
693
- // (that's the separate transcript channel). See observability.mjs.
694
- async function runObservabilityCommand(action = 'status', opts = {}) {
695
- const config = buildConfig(opts);
696
- if (action === 'on' || action === 'off') {
697
- const rec = setObservabilityConsent(config.dataDir, action === 'on');
698
- console.log(
699
- rec.enabled
700
- ? '✓ observability ON — the Venturewild team can see how your workspace is doing'
701
- : '✓ observability OFF — no session/health feed leaves this machine.',
702
- );
703
- if (rec.enabled) {
704
- console.log(' (events + install health only — never your chat content)');
705
- }
706
- console.log(' Applies the next time your workspace starts (or toggle it live in the app).');
707
- return;
708
- }
709
- const rec = loadObservabilityConsent(config.dataDir);
710
- console.log(`observability: ${rec.enabled ? 'ON' : 'OFF'}${rec.decidedAt ? '' : ' (default)'}`);
711
- console.log(' what : events + install health → lets us help if something breaks (never chat content)');
712
- console.log(' toggle: wild-workspace observability on | off');
713
- return;
714
- }
715
-
716
- // `wild-workspace update [apply|on|off|channel <stable|beta>]` — Phase 2
717
- // auto-update (docs/remote-support-and-self-healing-design.md). With no
718
- // sub-command it checks the channel for a newer release; `apply` installs it now;
719
- // `on`/`off` toggle the default-on background updater; `channel` switches
720
- // stable/beta. The always-on supervisor does this automatically — this is the
721
- // manual lever + the off switch.
722
- async function runUpdateCommand(opts) {
723
- const sub = opts.positional[1];
724
- const gdir = globalDir();
725
-
726
- if (sub === 'on' || sub === 'off') {
727
- const rec = setUpdateEnabled(gdir, sub === 'on');
728
- console.log(
729
- rec.enabled
730
- ? '✓ auto-update ON — wild-workspace keeps itself up to date in the background.'
731
- : '✓ auto-update OFF — update manually with `wild-workspace update apply`.',
732
- );
733
- console.log(` channel: ${rec.channel}`);
734
- return;
735
- }
736
- if (sub === 'channel') {
737
- const chan = opts.positional[2];
738
- if (chan !== 'stable' && chan !== 'beta') {
739
- console.log('usage: wild-workspace update channel stable|beta');
740
- return;
741
- }
742
- console.log(`✓ update channel set to ${setUpdateChannel(gdir, chan).channel}.`);
743
- return;
744
- }
745
-
746
- const settings = loadUpdateSettings(gdir);
747
- const c = await new AutoUpdater({ globalDir: gdir }).check();
748
- console.log(`wild-workspace ${c.current} (channel: ${c.channel}, auto-update: ${settings.enabled ? 'on' : 'off'})`);
749
- if (!c.latest) {
750
- console.log(' could not reach the npm registry to check for updates.');
751
- process.exitCode = 1;
752
- return;
753
- }
754
- if (!c.available) {
755
- console.log(` up to date — ${c.latest} is the latest on ${c.channel}.`);
756
- return;
757
- }
758
- console.log(` update available: ${c.current} → ${c.latest}`);
759
- if (sub !== 'apply') {
760
- console.log(' run `wild-workspace update apply` to install it now.');
761
- return;
762
- }
763
- console.log(` installing ${c.latest}…`);
764
- const res = await npmInstall(`${PACKAGE_NAME}@${c.latest}`);
765
- if (res.code === 0) {
766
- recordUpdate(gdir, { from: c.current, to: c.latest, at: Date.now(), status: 'installed' });
767
- console.log(` ✓ installed ${c.latest}.`);
768
- console.log(' The always-on supervisor will restart into the new version shortly,');
769
- console.log(' or restart `wild-workspace` yourself to use it now.');
770
- } else {
771
- console.log(` ✗ install failed (code ${res.code}).`);
772
- if (res.output) console.log(' ' + res.output.split('\n').filter(Boolean).slice(-3).join('\n '));
773
- process.exitCode = 1;
774
- }
775
- }
776
-
777
- // `wild-workspace support [allow|revoke|status]` — Phase 3 consent for the
778
- // daemon-hosted, out-of-band support channel (reachable even when your workspace
779
- // is offline). OFF by default + time-boxed; the bmo-sync daemon enforces it
780
- // locally and revoke is instant (this writes/deletes ~/.wild-workspace/
781
- // support-consent.json, which the daemon re-reads). Runs as its own process so it
782
- // works when the server is down.
783
- function fmtRemaining(ms) {
784
- const m = Math.round(ms / 60000);
785
- if (m < 60) return `${m} min`;
786
- return `${Math.floor(m / 60)}h ${m % 60}m`;
787
- }
788
- async function runSupportCommand(action = 'status', opts = {}) {
789
- const gdir = globalDir();
790
-
791
- if (action === 'allow') {
792
- const tier = opts.tier || 1;
793
- const minutes = opts.minutes || 60;
794
- if (tier < 1 || tier > MAX_TIER) {
795
- console.error(
796
- `tier must be 1..${MAX_TIER} (1 = read-only diagnostics/logs, 2 = curated fixes, ` +
797
- `3 = let support operate your agent, 4 = raw shell).`,
798
- );
799
- process.exitCode = 1;
800
- return;
801
- }
802
- // Tiers 3–4 let support OPERATE the machine (drive the agent / run commands).
803
- // Require an explicit --yes so it's never a slip of the keyboard.
804
- if (tier >= OPERATE_TIER && !opts.yes) {
805
- console.error(
806
- `tier ${tier} lets VentureWild support ${tier >= 4 ? 'run shell commands on' : 'operate the agent on'} ` +
807
- `your machine (time-boxed, audited, revocable). Re-run with --yes to confirm:\n` +
808
- ` wild-workspace support allow --tier ${tier} --minutes ${minutes} --yes`,
809
- );
810
- process.exitCode = 1;
811
- return;
812
- }
813
- const rec = grantConsent(gdir, { tier, minutes });
814
- if (!rec) {
815
- console.error(`Could not write support consent to ${gdir}.`);
816
- process.exitCode = 1;
817
- return;
818
- }
819
- const tierDesc =
820
- rec.tier >= 4
821
- ? 'read diagnostics/logs + curated fixes + operate your agent + run raw shell commands'
822
- : rec.tier >= 3
823
- ? 'read diagnostics/logs + curated fixes + operate your agent on a task'
824
- : rec.tier >= 2
825
- ? 'read diagnostics/logs + run curated fixes (restart sync, relink, reinstall)'
826
- : 'read diagnostics + logs only';
827
- console.log(`✓ VentureWild support allowed at tier ${rec.tier} for ${fmtRemaining(rec.expiresAt - rec.grantedAt)} (max ${MAX_GRANT_MINUTES / 60}h).`);
828
- console.log(` tier ${rec.tier} = ${tierDesc}.`);
829
- console.log(' It auto-expires; revoke anytime with: wild-workspace support revoke');
830
- return;
831
- }
832
- if (action === 'revoke') {
833
- const removed = revokeConsent(gdir);
834
- console.log(removed ? '✓ support access revoked.' : 'support access was not enabled.');
835
- return;
836
- }
837
- if (action === 'audit') {
838
- const entries = readAudit(gdir, { limit: opts.limit || 20 });
839
- if (!entries.length) {
840
- console.log('no support actions recorded yet.');
841
- return;
842
- }
843
- console.log('recent support actions (newest first):');
844
- for (const e of entries) {
845
- const when = e.ts ? new Date(e.ts).toISOString() : '?';
846
- console.log(` ${when} ${e.ok ? '✓' : '✗'} ${e.action} (tier ${e.tier ?? '?'})`);
847
- }
848
- return;
849
- }
850
- // status (default)
851
- const s = consentStatus(gdir);
852
- if (s.enabled) {
853
- console.log(`support access: ON (tier ${s.tier}) — ${fmtRemaining(s.remainingMs)} remaining.`);
854
- console.log(' revoke : wild-workspace support revoke');
855
- } else {
856
- console.log('support access: OFF (default).');
857
- console.log(' allow : wild-workspace support allow --tier 1 --minutes 60');
858
- }
859
- console.log(` file : ${s.file}`);
860
- }
861
-
862
- // `wild-workspace operator [enable|disable|status]` — the consented support
863
- // channel (docs/SECURITY.md). OFF by default; `enable` mints a token to hand to
864
- // the wild-workspace team so they can diagnose + run a fixed set of safe fixes.
865
- async function runOperatorCommand(action = 'status', opts = {}) {
866
- const config = buildConfig(opts);
867
- if (action === 'enable') {
868
- const token = enableOperator(config.dataDir, { rotate: opts.rotate });
869
- if (!token) {
870
- console.error(`Could not enable operator channel (couldn't write to ${config.dataDir}).`);
871
- process.exitCode = 1;
872
- return;
873
- }
874
- const slug = config.account?.slug;
875
- console.log('✓ operator channel enabled (consented support access).');
876
- console.log(` token : ${token}`);
877
- console.log(' Share this token with the wild-workspace team so they can help with your install.');
878
- if (slug) console.log(` reach : https://${slug}.venturewild.llc/api/operator/diag (Authorization: Bearer <token>)`);
879
- console.log(' off : wild-workspace operator disable');
880
- console.log('');
881
- console.log(' Scope: read diagnostics + a fixed set of safe fixes (restart sync, re-detect');
882
- console.log(' Claude, re-link your account, reinstall the sync daemon). It cannot run');
883
- console.log(' arbitrary commands or drive your agent, and every action is logged.');
884
- return;
885
- }
886
- if (action === 'disable') {
887
- const removed = disableOperator(config.dataDir);
888
- console.log(removed ? '✓ operator channel disabled (token revoked).' : 'operator channel was not enabled.');
889
- return;
890
- }
891
- if (action === 'status') {
892
- const s = operatorStatus(config.dataDir);
893
- console.log(`operator channel: ${s.enabled ? 'ENABLED' : 'disabled'}`);
894
- console.log(` token file: ${s.file}`);
895
- console.log(s.enabled ? ' disable : wild-workspace operator disable' : ' enable : wild-workspace operator enable');
896
- return;
897
- }
898
- console.log(`unknown operator action: ${action} (use enable | disable | status)`);
899
- }
900
-
901
- // `wild-workspace service [install|uninstall|status|run]` — the always-on
902
- // autostart (docs/always-on-design.md). `run` is the HIDDEN supervisor entry
903
- // the per-OS launcher invokes at login; the others manage registration. All
904
- // per-user, no admin.
905
- // First-run browser orchestration lives in a shebang-free module so it's
906
- // importable + unit-testable (server/test/owner-browser.test.mjs).
907
-
908
- async function runServiceCommand(action = 'status', opts = {}) {
909
- const config = buildConfig(opts);
910
-
911
- if (action === 'install') {
912
- const r = await installService({
913
- node: process.execPath, cli: __filename, workspaceDir: config.workspaceDir, port: config.port, version: APP_VERSION,
914
- });
915
- if (!r.installed) {
916
- console.log(`service install: ${r.message || 'not supported on this platform'}`);
917
- process.exitCode = 1;
918
- return;
919
- }
920
- console.log('✓ always-on enabled — your workspace starts automatically at login.');
921
- console.log(` mechanism : ${r.mechanism} (per-user, no admin)`);
922
- console.log(` launcher : ${r.launcher || r.vbs}`);
923
- console.log(` workspace : ${config.workspaceDir}`);
924
- if (r.note) console.log(` note : ${r.note}`);
925
- console.log(' disable : wild-workspace service uninstall');
926
- return;
927
- }
928
-
929
- if (action === 'uninstall') {
930
- const r = await uninstallService();
931
- if (r.supported === false) { console.log(`service: nothing to do — autostart not implemented for ${r.platform} yet`); return; }
932
- console.log(`✓ always-on disabled${r.removedKey ? '' : ' (no autostart entry was set)'}${r.stoppedPid ? ` — stopped supervisor pid ${r.stoppedPid}` : ''}.`);
933
- return;
934
- }
935
-
936
- if (action === 'status') {
937
- const s = await serviceStatus({ port: config.port }, { probeImpl: (p) => probeHealth(p) });
938
- if (s.supported === false) { console.log(`always-on: autostart not implemented for ${s.platform} yet (run \`wild-workspace\` manually)`); return; }
939
- console.log(`always-on : ${s.installed ? 'installed' : 'NOT installed'}`);
940
- if (s.runValue) console.log(` run entry : ${s.runValue}`);
941
- console.log(` supervisor: ${s.supervisorAlive ? `running (pid ${s.supervisorPid})` : 'not running'}`);
942
- console.log(` server : ${s.serverUp ? `up on http://127.0.0.1:${config.port}` : 'down'}`);
943
- return;
944
- }
945
-
946
- if (action === 'run') {
947
- // Hidden entrypoint launched by the autostart VBS at login. The OS gives us
948
- // an arbitrary cwd, so read the workspace dir persisted at install time.
949
- let svc = {};
950
- try { svc = JSON.parse(readFileSync(path.join(globalDir(), 'service.json'), 'utf8')); } catch { /* fall back to config */ }
951
- const sup = new WorkspaceSupervisor({
952
- workspaceDir: svc.workspaceDir || config.workspaceDir,
953
- port: svc.port || config.port,
954
- });
955
- const r = sup.start();
956
- if (!r.started) process.exit(0); // another supervisor already owns the lock
957
- // else: the supervision interval keeps this process alive.
958
- return;
959
- }
960
-
961
- console.log(`unknown service action: ${action} (use install | uninstall | status | run)`);
962
- }
963
-
964
- async function main() {
965
- // First-run capture: log every invocation BEFORE doing anything, so even a
966
- // crash-on-start leaves a trace we (or `wild-workspace doctor`) can read.
967
- appendLine('cli', `invoke argv=[${process.argv.slice(2).join(' ')}] cwd=${process.cwd()} node=${process.version} v${APP_VERSION}`);
968
- const opts = parseArgs(process.argv.slice(2));
969
- if (opts.help) return printUsage();
970
- if (opts.version) { console.log(APP_VERSION); return; }
971
-
972
- if (opts.positional[0] === 'daemon') {
973
- return runDaemonCommand(opts.positional[1], opts.positional.slice(2));
974
- }
975
-
976
- if (opts.positional[0] === 'login') {
977
- return runLoginCommand(opts.positional.slice(1));
978
- }
979
- if (opts.positional[0] === 'logout') {
980
- return runLogoutCommand();
981
- }
982
- if (opts.positional[0] === 'reset') {
983
- return runResetCommand(opts);
984
- }
985
- if (opts.positional[0] === 'whoami') {
986
- return runWhoamiCommand();
987
- }
988
- if (opts.positional[0] === 'rotate-token') {
989
- return runRotateTokenCommand();
990
- }
991
- if (opts.positional[0] === 'workspace') {
992
- return runWorkspaceCommand(opts.positional[1], opts);
993
- }
994
- if (opts.positional[0] === 'doctor') {
995
- return runDoctorCommand(opts);
996
- }
997
- if (opts.positional[0] === 'logs') {
998
- return runLogsCommand(opts);
999
- }
1000
- if (opts.positional[0] === 'update') {
1001
- return runUpdateCommand(opts);
1002
- }
1003
- if (opts.positional[0] === 'support') {
1004
- return runSupportCommand(opts.positional[1], opts);
1005
- }
1006
- if (opts.positional[0] === 'operator') {
1007
- return runOperatorCommand(opts.positional[1], opts);
1008
- }
1009
- if (opts.positional[0] === 'observability') {
1010
- return runObservabilityCommand(opts.positional[1], opts);
1011
- }
1012
- if (opts.positional[0] === 'ops') {
1013
- return runOpsCommand(opts);
1014
- }
1015
- if (opts.positional[0] === 'service') {
1016
- return runServiceCommand(opts.positional[1], opts);
1017
- }
1018
-
1019
- if (opts.positional[0] === 'install') {
1020
- console.log('wild-workspace manages the bmo-sync sync daemon for you.');
1021
- console.log('');
1022
- console.log('It starts automatically in the background whenever you run');
1023
- console.log('`wild-workspace`, and keeps running after you close the browser —');
1024
- console.log('so there is no separate install step.');
1025
- console.log('');
1026
- console.log(' wild-workspace daemon status check whether it is running');
1027
- console.log(' wild-workspace daemon stop stop it');
1028
- return;
1029
- }
1030
- if (opts.positional[0] === 'share') {
1031
- console.log('Use the in-app Share button to issue viewer URLs. CLI share command is v1.x.');
1032
- return;
1033
- }
1034
-
1035
- // If a workspace server is already serving this port — always-on started it at
1036
- // login, or another `wild-workspace` is running — don't fight it for the socket
1037
- // (createServer would reject on EADDRINUSE and crash). Just open the browser to
1038
- // the one already up. This is the common case now that always-on is real.
1039
- {
1040
- const probeCfg = buildConfig(opts);
1041
- if (await probeHealth(probeCfg.port)) {
1042
- const host = probeCfg.host === '0.0.0.0' ? '127.0.0.1' : probeCfg.host;
1043
- const displayUrl = `http://${host}:${probeCfg.port}`; // shown without the token
1044
- console.log(`\n wild-workspace is already running at ${displayUrl}`);
1045
- if (probeCfg.openBrowser) {
1046
- console.log(' opening it in your browser…');
1047
- await openOwnerBrowser(probeCfg);
1048
- }
1049
- return;
1050
- }
1051
- }
1052
-
1053
- const server = await createServer(opts);
1054
- const { config } = server;
1055
- console.log(`\n wild-workspace v${APP_VERSION}`);
1056
- console.log(` workspace : ${config.workspaceDir}`);
1057
- console.log(` url : http://${config.host}:${config.port}`);
1058
- console.log(` agent : ${server.getActiveAgent()?.label || '(none detected — install Claude Code: npm i -g @anthropic-ai/claude-code)'}`);
1059
-
1060
- // Report how the sync daemon's autostart went (best-effort — never fatal).
1061
- try {
1062
- const d = await server.daemonReady;
1063
- if (d.alreadyRunning) console.log(' sync : bmo-sync daemon already running');
1064
- else if (d.started) console.log(` sync : bmo-sync daemon started (pid ${d.pid})`);
1065
- else if (d.skipped) console.log(' sync : daemon autostart disabled');
1066
- else if (d.error === 'daemon-binary-not-found')
1067
- console.log(' sync : daemon binary not installed — sync is off (see `wild-workspace daemon`)');
1068
- else if (d.error) console.log(` sync : daemon did not start — ${d.error}`);
1069
- } catch {}
1070
- console.log('');
1071
-
1072
- if (config.publicMode) {
1073
- // Public mode denies anon — tell the owner how to reach it authenticated.
1074
- console.log(` mode : PUBLIC — opening with a one-time sign-in token`);
1075
- }
1076
- if (config.openBrowser) {
1077
- await openOwnerBrowser(config);
1078
- }
1079
-
1080
- // Hard safety net: even if stop() ever wedges, never leave the user staring at
1081
- // "shutting down…". Unref'd so the timer itself doesn't keep us alive.
1082
- const forceExitSoon = () => { setTimeout(() => process.exit(0), 3000).unref(); };
1083
- process.on('SIGINT', async () => {
1084
- console.log('\nshutting down…');
1085
- forceExitSoon();
1086
- try { await server.stop(); } catch {}
1087
- process.exit(0);
1088
- });
1089
- process.on('SIGTERM', async () => { forceExitSoon(); try { await server.stop(); } catch {} process.exit(0); });
1090
- }
1091
-
1092
- main().catch((err) => {
1093
- appendLine('cli', `FATAL ${err?.stack || err}`);
1094
- console.error('wild-workspace failed:', err);
1095
- process.exit(1);
1096
- });
1
+ #!/usr/bin/env node
2
+ // `wild-workspace` CLI entry — the bin field in package.json.
3
+ // Starts the workspace server; also exposes a `daemon` subcommand for
4
+ // inspecting / controlling the bmo-sync sync daemon.
5
+
6
+ import path from 'node:path';
7
+ import url from 'node:url';
8
+ import os from 'node:os';
9
+ import { createServer } from '../src/index.mjs';
10
+ import { APP_VERSION, buildConfig } from '../src/config.mjs';
11
+ import { DaemonSupervisor } from '../src/daemon-supervisor.mjs';
12
+ import { SyncControl } from '../src/sync.mjs';
13
+ import {
14
+ decodeLoginPayload,
15
+ saveAccount,
16
+ loadAccount,
17
+ clearAccount,
18
+ } from '../src/account.mjs';
19
+ import { readFileSync } from 'node:fs';
20
+ import { WorkspaceSupervisor, probeHealth } from '../src/supervisor.mjs';
21
+ import { installService, uninstallService, serviceStatus, globalDir } from '../src/service.mjs';
22
+ import { appendLine, listLogs, tailFile } from '../src/logpaths.mjs';
23
+ import { runDoctor, renderDoctor, writeDoctorBundle } from '../src/doctor.mjs';
24
+ import { enableOperator, disableOperator, operatorStatus } from '../src/operator.mjs';
25
+ import { loadObservabilityConsent, setObservabilityConsent } from '../src/observability.mjs';
26
+ import {
27
+ grantConsent, revokeConsent, consentStatus, readAudit, MAX_TIER, MAX_GRANT_MINUTES, OPERATE_TIER,
28
+ } from '../src/support-consent.mjs';
29
+ import {
30
+ AutoUpdater, PACKAGE_NAME, npmInstall, recordUpdate,
31
+ loadUpdateSettings, setUpdateEnabled, setUpdateChannel,
32
+ } from '../src/auto-update.mjs';
33
+ import { openOwnerBrowser } from '../src/owner-browser.mjs';
34
+ import { planReset, applyReset, RESET_KEEPS } from '../src/reset.mjs';
35
+ import { createWorkspaceRails } from '../src/workspaces.mjs';
36
+
37
+ const __filename = url.fileURLToPath(import.meta.url);
38
+ const __dirname = path.dirname(__filename);
39
+
40
+ function printUsage() {
41
+ console.log(`wild-workspace v${APP_VERSION}
42
+
43
+ Usage:
44
+ wild-workspace start the workspace server in the current directory
45
+ wild-workspace --port 5173 override port (default 5173)
46
+ wild-workspace --no-open don't auto-open browser
47
+ wild-workspace --host 0.0.0.0 bind to all interfaces (for share-by-URL hosting)
48
+ wild-workspace daemon status is the bmo-sync daemon running?
49
+ wild-workspace daemon start start the sync daemon now
50
+ wild-workspace daemon stop stop the sync daemon
51
+ wild-workspace daemon conflicts list list open conflicts
52
+ wild-workspace daemon conflicts show <wid> <path> view one conflict
53
+ wild-workspace daemon conflicts resolve <wid> <path> <keep_mine|take_theirs>
54
+ wild-workspace login <payload> bind this install to a slug
55
+ (paste the blob from workspace.venturewild.llc)
56
+ wild-workspace logout clear the bound account (slug + token)
57
+ wild-workspace reset [--yes] back to the beginning: unlink + reset onboarding +
58
+ flush local config (preview without --yes; keeps your files)
59
+ wild-workspace whoami show the currently-bound account
60
+ wild-workspace rotate-token mint a new account token; invalidates the old one
61
+ wild-workspace workspace create "<name>" [--slug s] create a shared workspace (its own slug, owned by you)
62
+ wild-workspace workspace list shared workspaces you belong to
63
+ wild-workspace workspace add-member <slug> <email> add a teammate (they must have a slug already)
64
+ wild-workspace workspace remove-member <slug> <email-or-accountId> remove a teammate
65
+ wild-workspace doctor [--share] diagnose this machine's install (✅/⚠️/❌ + logs)
66
+ wild-workspace logs [name] [--tail N] list logs, or tail one (cli/server/daemon/…)
67
+ wild-workspace operator enable let the wild-workspace team help with your install (mints a token)
68
+ wild-workspace operator disable revoke the support token
69
+ wild-workspace operator status is the support channel on?
70
+ wild-workspace support allow --tier 1 --minutes 60 allow time-boxed support (works even when offline)
71
+ wild-workspace support revoke revoke support access now
72
+ wild-workspace support status is support access on, and for how long?
73
+ wild-workspace support audit show what support has done (the audit feed)
74
+ wild-workspace observability [on|off|status] share session + install health so we can help (default on; never chat content)
75
+ wild-workspace update [apply] check for / install a newer version (auto by default)
76
+ wild-workspace update on|off toggle background auto-update
77
+ wild-workspace update channel stable|beta choose the update channel
78
+ wild-workspace service install keep your workspace always-on (starts at login, no admin)
79
+ wild-workspace service uninstall turn always-on off
80
+ wild-workspace service status show always-on status (installed? supervisor? server?)
81
+ wild-workspace install (info) how the sync daemon is managed
82
+ wild-workspace --help this message
83
+ wild-workspace --version print version
84
+
85
+ The bmo-sync sync daemon starts automatically in the background when you run
86
+ \`wild-workspace\`, and keeps running after you close the browser.
87
+
88
+ Environment:
89
+ WILD_WORKSPACE_PORT, WILD_WORKSPACE_HOST,
90
+ WILD_WORKSPACE_DIR, WILD_WORKSPACE_DATA_DIR,
91
+ WILD_WORKSPACE_PARTNER_TOKEN, WILD_WORKSPACE_SHARE_SECRET,
92
+ WILD_WORKSPACE_NO_OPEN=1, WILD_WORKSPACE_DAEMON_AUTOSTART=0
93
+ `);
94
+ }
95
+
96
+ function parseArgs(argv) {
97
+ const opts = {};
98
+ const positional = [];
99
+ for (let i = 0; i < argv.length; i++) {
100
+ const arg = argv[i];
101
+ if (arg === '--help' || arg === '-h') opts.help = true;
102
+ else if (arg === '--version' || arg === '-v') opts.version = true;
103
+ else if (arg === '--no-open') opts.openBrowser = false;
104
+ else if (arg === '--port') { opts.port = Number(argv[++i]); }
105
+ else if (arg === '--host') { opts.host = argv[++i]; }
106
+ else if (arg === '--workspace') { opts.workspaceDir = argv[++i]; }
107
+ else if (arg === '--tail') { opts.tail = Number(argv[++i]); }
108
+ else if (arg === '--share') { opts.share = true; }
109
+ else if (arg === '--rotate') { opts.rotate = true; }
110
+ else if (arg === '--yes' || arg === '-y') { opts.yes = true; }
111
+ else if (arg === '--kind') { opts.kind = argv[++i]; }
112
+ else if (arg === '--slug') { opts.slug = argv[++i]; }
113
+ else if (arg === '--limit') { opts.limit = Number(argv[++i]); }
114
+ else if (arg === '--tier') { opts.tier = Number(argv[++i]); }
115
+ else if (arg === '--minutes') { opts.minutes = Number(argv[++i]); }
116
+ else if (arg.startsWith('--')) {
117
+ // ignore unknown flags
118
+ } else {
119
+ positional.push(arg);
120
+ }
121
+ }
122
+ opts.positional = positional;
123
+ return opts;
124
+ }
125
+
126
+ // `wild-workspace daemon [status|start|stop|conflicts ...]`
127
+ async function runDaemonCommand(action = 'status', rest = []) {
128
+ const config = buildConfig({});
129
+ const sup = new DaemonSupervisor({
130
+ httpBase: config.daemonHttpUrl,
131
+ // b-ii: so `wild-workspace daemon start` also opens the proxy link.
132
+ accountToken: config.accountToken,
133
+ serverUrl: config.bmoSyncServerUrl,
134
+ });
135
+
136
+ if (action === 'conflicts') {
137
+ return runConflictsCommand(config, rest);
138
+ }
139
+ if (action === 'status') {
140
+ const s = await sup.status();
141
+ console.log(`bmo-sync daemon: ${s.running ? 'running' : 'stopped'}`);
142
+ console.log(` api : ${s.httpBase}`);
143
+ if (s.pid) console.log(` pid : ${s.pid}`);
144
+ console.log(` log : ${s.logFile}`);
145
+ return;
146
+ }
147
+ if (action === 'start') {
148
+ const r = await sup.ensureRunning();
149
+ if (r.alreadyRunning) {
150
+ console.log('bmo-sync daemon: already running');
151
+ } else if (r.started) {
152
+ const healthy = await sup.waitForHealthy();
153
+ console.log(
154
+ healthy
155
+ ? `bmo-sync daemon: started (pid ${r.pid})`
156
+ : `bmo-sync daemon: launched (pid ${r.pid}) — not yet answering, check the log`,
157
+ );
158
+ } else if (r.error === 'daemon-binary-not-found') {
159
+ console.log('bmo-sync daemon: cannot start — the daemon binary is not installed.');
160
+ console.log(' build it from the bmo-sync workspace and place it under vendor/,');
161
+ console.log(' or install the @venturewild/workspace-daemon-<platform> package.');
162
+ } else {
163
+ console.log(`bmo-sync daemon: could not start — ${r.error}`);
164
+ }
165
+ return;
166
+ }
167
+ if (action === 'stop') {
168
+ const r = await sup.stop();
169
+ console.log(
170
+ r.stopped
171
+ ? `bmo-sync daemon: stopped (pid ${r.pid})`
172
+ : `bmo-sync daemon: not stopped — ${r.reason}`,
173
+ );
174
+ return;
175
+ }
176
+ console.log(`unknown daemon action: ${action} (use status | start | stop | conflicts)`);
177
+ }
178
+
179
+ // `wild-workspace daemon conflicts [list|show <wid> <path>|resolve <wid> <path> <action>]`
180
+ async function runConflictsCommand(config, args) {
181
+ const sync = new SyncControl({
182
+ daemonHttpUrl: config.daemonHttpUrl,
183
+ bmoSyncServerUrl: config.bmoSyncServerUrl,
184
+ });
185
+ const sub = (args[0] || 'list').toLowerCase();
186
+ if (sub === 'list' || !sub) {
187
+ const conflicts = await sync.listConflicts();
188
+ if (!conflicts.length) {
189
+ console.log('no open conflicts');
190
+ return;
191
+ }
192
+ console.log(`open conflicts: ${conflicts.length}`);
193
+ for (const c of conflicts) {
194
+ const detected = new Date((c.detectedAt || 0) * 1000).toISOString();
195
+ console.log(` [${c.workspaceId}] ${c.path}`);
196
+ console.log(` resolution : ${c.resolution}`);
197
+ console.log(` detected_at : ${detected}`);
198
+ if (c.peerBackOfficePath) {
199
+ console.log(` peer_bytes_at : ${c.peerBackOfficePath}`);
200
+ }
201
+ if (c.mineSha256) console.log(` mine_sha256 : ${c.mineSha256}`);
202
+ if (c.theirsSha256) console.log(` theirs_sha256 : ${c.theirsSha256}`);
203
+ }
204
+ return;
205
+ }
206
+ if (sub === 'show') {
207
+ const wid = args[1];
208
+ const path = args[2];
209
+ if (!wid || !path) {
210
+ console.log('usage: wild-workspace daemon conflicts show <workspace_id> <path>');
211
+ return;
212
+ }
213
+ const view = await sync.viewConflict(wid, path);
214
+ if (!view) {
215
+ console.log(`no open conflict for ${wid}:${path}`);
216
+ return;
217
+ }
218
+ console.log(JSON.stringify(view, null, 2));
219
+ return;
220
+ }
221
+ if (sub === 'resolve') {
222
+ const wid = args[1];
223
+ const path = args[2];
224
+ const action = args[3];
225
+ if (!wid || !path || !action) {
226
+ console.log(
227
+ 'usage: wild-workspace daemon conflicts resolve <workspace_id> <path> <keep_mine|take_theirs>',
228
+ );
229
+ return;
230
+ }
231
+ try {
232
+ await sync.resolveConflict(wid, path, action);
233
+ console.log(`resolved: ${wid}:${path} (${action})`);
234
+ } catch (e) {
235
+ console.error(`resolve failed: ${e.message || e}`);
236
+ process.exitCode = 1;
237
+ }
238
+ return;
239
+ }
240
+ console.log(
241
+ `unknown conflicts subcommand: ${sub} (use list | show <wid> <path> | resolve <wid> <path> <action>)`,
242
+ );
243
+ }
244
+
245
+ // `wild-workspace login <base64url-payload>` — bind this install to a slug.
246
+ // The payload comes from workspace.venturewild.llc on signup. It's an opaque
247
+ // blob the user copies once; we decode + persist + print a friendly summary.
248
+ async function runLoginCommand(args) {
249
+ if (!args.length) {
250
+ console.error('usage: wild-workspace login <payload>');
251
+ console.error('');
252
+ console.error('The payload is the blob you copied from workspace.venturewild.llc');
253
+ console.error('after claiming your slug. Run that signup, then come back here.');
254
+ process.exitCode = 1;
255
+ return;
256
+ }
257
+ // Trim the obvious "wild-workspace login " prefix in case the user pasted
258
+ // the whole command line, and surrounding quotes if a shell preserved them.
259
+ let payload = args.join(' ').trim();
260
+ payload = payload.replace(/^wild-workspace\s+login\s+/i, '').trim();
261
+ payload = payload.replace(/^['"]|['"]$/g, '');
262
+ let parsed;
263
+ try {
264
+ parsed = decodeLoginPayload(payload);
265
+ } catch (e) {
266
+ console.error(`Couldn't decode the login payload: ${e.message || e}`);
267
+ process.exitCode = 1;
268
+ return;
269
+ }
270
+ const config = buildConfig({});
271
+ let saved;
272
+ try {
273
+ saved = saveAccount(config.dataDir, parsed);
274
+ } catch (e) {
275
+ console.error(`Couldn't save account to ${config.dataDir}: ${e.message || e}`);
276
+ process.exitCode = 1;
277
+ return;
278
+ }
279
+ console.log(`✓ logged in as ${saved.email}`);
280
+ console.log(` slug : ${saved.slug}`);
281
+ console.log(` url : https://${saved.slug}.venturewild.llc (once the tunnel is configured)`);
282
+ console.log(` saved to : ${config.dataDir}/account.json`);
283
+ console.log('');
284
+ console.log('Run `wild-workspace` in any folder to start your workspace.');
285
+
286
+ // Arm always-on so the workspace comes back on its own (best-effort — never
287
+ // blocks login). On a platform without autostart yet, just nudge the user.
288
+ try {
289
+ const svc = await installService({
290
+ node: process.execPath, cli: __filename, workspaceDir: config.workspaceDir, port: config.port, version: APP_VERSION,
291
+ });
292
+ if (svc.installed) console.log(' always-on : enabled — starts at login (disable: wild-workspace service uninstall)');
293
+ else if (svc.supported === false) console.log(` always-on : not yet on ${svc.platform} — run \`wild-workspace\` to start it`);
294
+ } catch { /* never block login */ }
295
+ }
296
+
297
+ async function runLogoutCommand() {
298
+ const config = buildConfig({});
299
+ const before = loadAccount(config.dataDir);
300
+ const removed = clearAccount(config.dataDir);
301
+ if (!removed && !before) {
302
+ console.log('not logged in.');
303
+ return;
304
+ }
305
+ console.log(`logged out — cleared ${before?.email || 'account'} from ${config.dataDir}.`);
306
+ }
307
+
308
+ // `wild-workspace reset [--yes]` — back to the beginning: unlink the account,
309
+ // reset onboarding, flush local config/state. NEVER touches workspace files.
310
+ // Without --yes it's a dry run (prints exactly what it WOULD remove).
311
+ async function runResetCommand(opts) {
312
+ const config = buildConfig(opts);
313
+ const gdir = globalDir();
314
+ const before = loadAccount(config.dataDir);
315
+
316
+ // Collect every data dir that might hold this install's account/onboarding:
317
+ // the one this invocation resolves (cwd-keyed) + the always-on workspace's
318
+ // (recorded in service.json), in case `reset` is run from a different folder.
319
+ const dataDirs = new Set([config.dataDir]);
320
+ try {
321
+ const svc = JSON.parse(readFileSync(path.join(gdir, 'service.json'), 'utf8'));
322
+ if (svc.workspaceDir) dataDirs.add(path.join(path.resolve(svc.workspaceDir), '.wild-workspace'));
323
+ } catch { /* no always-on registration — fine */ }
324
+
325
+ const targets = planReset({ dataDirs: [...dataDirs], globalDir: gdir, includeMarketplace: true });
326
+ const present = targets.filter((t) => t.exists);
327
+
328
+ console.log('wild-workspace reset — back to the beginning\n');
329
+ if (before) {
330
+ console.log(` currently linked: ${before.email} (slug: ${before.slug})`);
331
+ console.log('');
332
+ }
333
+ if (present.length === 0) {
334
+ console.log(' Nothing to clear — this install is already at a clean state.');
335
+ return;
336
+ }
337
+
338
+ console.log(` ${opts.yes ? 'Removing' : 'Would remove'}:`);
339
+ for (const t of present) console.log(` - ${t.path}${t.kind === 'dir' ? ' (folder)' : ''}`);
340
+ console.log('');
341
+ console.log(' Keeps (untouched):');
342
+ for (const k of RESET_KEEPS) console.log(` · ${k}`);
343
+ console.log('');
344
+
345
+ if (!opts.yes) {
346
+ console.log(' This was a PREVIEW. Re-run to actually reset:');
347
+ console.log(' wild-workspace reset --yes');
348
+ return;
349
+ }
350
+
351
+ const { removed, failed } = applyReset(present);
352
+ console.log(` ✓ cleared ${removed.length} item(s).`);
353
+ if (failed.length) {
354
+ console.log(` ⚠ ${failed.length} could not be removed (in use? close the app + retry):`);
355
+ for (const f of failed) console.log(` - ${f.path}: ${f.error}`);
356
+ process.exitCode = 1;
357
+ }
358
+ console.log('');
359
+ // The running server still holds the old account/secrets in memory — a restart
360
+ // is what makes the reset take effect (and re-arms a fresh onboarding).
361
+ if (await probeHealth(config.port)) {
362
+ console.log(' ⚠ a workspace server is still running with the old state. Restart it:');
363
+ console.log(` stop it (close the app / kill :${config.port}); always-on restarts it clean,`);
364
+ console.log(' or run `wild-workspace` yourself.');
365
+ console.log('');
366
+ }
367
+ console.log(' Next:');
368
+ console.log(' • `wild-workspace login <blob>` — re-link to a slug, then `wild-workspace`');
369
+ console.log(' • or just `wild-workspace` — start fresh and re-run onboarding');
370
+ console.log('');
371
+ console.log(' Note: the canvas LAYOUT is stored in your browser, not here — open the');
372
+ console.log(' workspace in a fresh/incognito window (or clear site data) for a blank canvas.');
373
+ }
374
+
375
+ async function runRotateTokenCommand() {
376
+ const config = buildConfig({});
377
+ const account = loadAccount(config.dataDir);
378
+ if (!account) {
379
+ console.log('not logged in. Nothing to rotate.');
380
+ console.log('Run `wild-workspace login <payload>` after claiming a slug.');
381
+ process.exitCode = 1;
382
+ return;
383
+ }
384
+ const url = `${config.bmoSyncServerUrl.replace(/\/$/, '')}/api/account/rotate-token`;
385
+ let resp;
386
+ try {
387
+ resp = await fetch(url, {
388
+ method: 'POST',
389
+ headers: { authorization: `Bearer ${account.accountToken}` },
390
+ });
391
+ } catch (e) {
392
+ console.error(`Couldn't reach ${config.bmoSyncServerUrl}: ${e.message || e}`);
393
+ process.exitCode = 2;
394
+ return;
395
+ }
396
+ if (!resp.ok) {
397
+ let body;
398
+ try { body = await resp.text(); } catch { body = ''; }
399
+ console.error(`Rotate failed (HTTP ${resp.status}): ${body.slice(0, 200)}`);
400
+ process.exitCode = 1;
401
+ return;
402
+ }
403
+ let payload;
404
+ try {
405
+ payload = await resp.json();
406
+ } catch (e) {
407
+ console.error(`Server returned non-JSON: ${e.message || e}`);
408
+ process.exitCode = 1;
409
+ return;
410
+ }
411
+ if (!payload || !payload.account_token) {
412
+ console.error('Server response missing account_token. Aborting.');
413
+ process.exitCode = 1;
414
+ return;
415
+ }
416
+ const updated = {
417
+ ...account,
418
+ accountToken: payload.account_token,
419
+ loggedInAt: Date.now(),
420
+ };
421
+ try {
422
+ saveAccount(config.dataDir, updated);
423
+ } catch (e) {
424
+ console.error(`Got new token but failed to persist it: ${e.message || e}`);
425
+ console.error(`New token (save manually): ${payload.account_token}`);
426
+ process.exitCode = 1;
427
+ return;
428
+ }
429
+ console.log('✓ token rotated.');
430
+ console.log(` slug : ${updated.slug}`);
431
+ console.log(` saved to : ${config.dataDir}/account.json`);
432
+ console.log('');
433
+ console.log('Your daemon will reconnect with the new token automatically');
434
+ console.log('on its next restart. Run `wild-workspace daemon stop && wild-workspace`');
435
+ console.log('to force an immediate reconnect.');
436
+ }
437
+
438
+ // `wild-workspace workspace {create|list|add-member|remove-member}` — multi-host
439
+ // step 2 (membership). The FOUNDATION's drivable surface: create a shared
440
+ // workspace (its own slug, owned by you), manage its member set, and read the
441
+ // shared workspaces you belong to. The polished invite/join/lobby UX is later;
442
+ // these are operator/dev affordances over the rails (you never copy-paste a
443
+ // token — they read your bound account). Requires `wild-workspace login` first.
444
+ async function runWorkspaceCommand(action, opts) {
445
+ const config = buildConfig(opts);
446
+ if (!config.accountToken) {
447
+ console.error('not logged in. Run `wild-workspace login <payload>` after claiming a slug.');
448
+ process.exitCode = 1;
449
+ return;
450
+ }
451
+ const rails = createWorkspaceRails(config, { accountToken: config.accountToken });
452
+ const fail = (r) => {
453
+ console.error(`✗ ${r.message || r.code || 'failed'}${r.code ? ` (${r.code})` : ''}`);
454
+ process.exitCode = 1;
455
+ };
456
+
457
+ if (action === 'create') {
458
+ const name = opts.positional.slice(2).join(' ').trim();
459
+ if (!name) {
460
+ console.error('usage: wild-workspace workspace create "<name>" [--slug <slug>]');
461
+ process.exitCode = 1;
462
+ return;
463
+ }
464
+ const r = await rails.create(name, opts.slug || null);
465
+ if (!r.ok) return fail(r);
466
+ console.log(`✓ created shared workspace "${r.data.name}"`);
467
+ console.log(` slug : ${r.data.slug}`);
468
+ console.log(` url : https://${r.data.slug}.venturewild.llc (once routing lands — step 3)`);
469
+ console.log('');
470
+ console.log(' Add a teammate (they must have claimed a slug already):');
471
+ console.log(` wild-workspace workspace add-member ${r.data.slug} <their-email>`);
472
+ return;
473
+ }
474
+
475
+ if (action === 'list') {
476
+ const r = await rails.list();
477
+ if (!r.ok) return fail(r);
478
+ const ws = r.data.workspaces || [];
479
+ if (!ws.length) {
480
+ console.log('you belong to no shared workspaces yet.');
481
+ console.log(' create one: wild-workspace workspace create "<name>"');
482
+ return;
483
+ }
484
+ console.log(`shared workspaces you belong to: ${ws.length}`);
485
+ for (const w of ws) {
486
+ console.log(` ${w.slug.padEnd(20)} ${w.role.padEnd(7)} "${w.name}" (owner: ${w.owner_email})`);
487
+ }
488
+ return;
489
+ }
490
+
491
+ if (action === 'add-member') {
492
+ const slug = opts.positional[2];
493
+ const email = opts.positional[3];
494
+ if (!slug || !email) {
495
+ console.error('usage: wild-workspace workspace add-member <slug> <email>');
496
+ process.exitCode = 1;
497
+ return;
498
+ }
499
+ const r = await rails.addMember(slug, email);
500
+ if (!r.ok) return fail(r);
501
+ console.log(`✓ added ${email} to ${slug} (account ${r.data.account_id}).`);
502
+ return;
503
+ }
504
+
505
+ if (action === 'remove-member') {
506
+ const slug = opts.positional[2];
507
+ const ref = opts.positional[3];
508
+ if (!slug || !ref) {
509
+ console.error('usage: wild-workspace workspace remove-member <slug> <email-or-accountId>');
510
+ process.exitCode = 1;
511
+ return;
512
+ }
513
+ const r = await rails.removeMember(slug, ref);
514
+ if (!r.ok) return fail(r);
515
+ console.log(
516
+ r.data.removed
517
+ ? `✓ removed ${ref} from ${slug}.`
518
+ : `${ref} was not a member of ${slug} (nothing to remove).`,
519
+ );
520
+ return;
521
+ }
522
+
523
+ console.log('unknown workspace action. use: create | list | add-member | remove-member');
524
+ console.log(' wild-workspace workspace create "<name>" [--slug <slug>]');
525
+ console.log(' wild-workspace workspace list');
526
+ console.log(' wild-workspace workspace add-member <slug> <email>');
527
+ console.log(' wild-workspace workspace remove-member <slug> <email-or-accountId>');
528
+ }
529
+
530
+ async function runWhoamiCommand() {
531
+ const config = buildConfig({});
532
+ const account = loadAccount(config.dataDir);
533
+ if (!account) {
534
+ console.log('not logged in.');
535
+ console.log('Run `wild-workspace login <payload>` after claiming a slug at workspace.venturewild.llc.');
536
+ return;
537
+ }
538
+ console.log(`logged in as ${account.email}`);
539
+ console.log(` slug : ${account.slug}`);
540
+ console.log(` accountId : ${account.accountId}`);
541
+ if (account.displayName) console.log(` name : ${account.displayName}`);
542
+ console.log(` loggedIn : ${new Date(account.loggedInAt).toISOString()}`);
543
+ }
544
+
545
+ // `wild-workspace doctor [--share]` — one diagnostic of this machine's install.
546
+ // Prints ✅/⚠️/❌ per check + where the logs are, and writes a JSON bundle under
547
+ // ~/.wild-workspace/diagnostics/. Exits non-zero if any check failed.
548
+ async function runDoctorCommand(opts) {
549
+ const config = buildConfig(opts);
550
+ const report = await runDoctor({ config });
551
+ console.log(renderDoctor(report));
552
+ const bundle = writeDoctorBundle(report);
553
+ if (bundle) {
554
+ console.log('');
555
+ console.log(`Full report: ${bundle}`);
556
+ }
557
+ if (opts.share) {
558
+ const shared = await shareDoctor(config, report);
559
+ console.log(
560
+ shared.ok
561
+ ? '✓ shared with the wild-workspace team — they can see this diagnostic now.'
562
+ : `Couldn't auto-share (${shared.reason}). Send the file above instead.`,
563
+ );
564
+ }
565
+ process.exitCode = report.summary.fail > 0 ? 1 : 0;
566
+ }
567
+
568
+ // Upload a doctor report to bmo-sync. `--share` is an explicit user action, so it
569
+ // goes even if the passive observability feed is off — but still respects the
570
+ // hard kill switch and needs an account to key it to.
571
+ async function shareDoctor(config, report) {
572
+ if (!config.accountToken) return { ok: false, reason: 'not logged in' };
573
+ if (process.env.WILD_WORKSPACE_NO_TELEMETRY === '1') return { ok: false, reason: 'telemetry disabled' };
574
+ const url = `${config.bmoSyncServerUrl.replace(/\/$/, '')}/api/telemetry`;
575
+ const ctrl = new AbortController();
576
+ const t = setTimeout(() => ctrl.abort(), 5000);
577
+ try {
578
+ const res = await fetch(url, {
579
+ method: 'POST',
580
+ headers: { 'content-type': 'application/json' },
581
+ body: JSON.stringify({
582
+ account_token: config.accountToken,
583
+ slug: config.account?.slug || null,
584
+ workspace_id: config.workspaceId,
585
+ kind: 'doctor-share',
586
+ doctor: report,
587
+ sent_at: Math.floor(Date.now() / 1000),
588
+ }),
589
+ signal: ctrl.signal,
590
+ });
591
+ return { ok: res.ok, reason: res.ok ? null : `HTTP ${res.status}` };
592
+ } catch (e) {
593
+ return { ok: false, reason: String(e?.message || e) };
594
+ } finally {
595
+ clearTimeout(t);
596
+ }
597
+ }
598
+
599
+ // `wild-workspace logs [name] [--tail N]` — list the logs, or tail one by name.
600
+ async function runLogsCommand(opts) {
601
+ const name = opts.positional[1];
602
+ const n = opts.tail || 40;
603
+ const logs = listLogs();
604
+ if (!name) {
605
+ console.log(`logs dir: ${path.dirname(logs[0].file)}`);
606
+ for (const l of logs) {
607
+ console.log(` ${l.name.padEnd(10)} ${l.exists ? `${l.size} bytes` : '(none yet)'} ${l.file}`);
608
+ }
609
+ console.log('');
610
+ console.log(`Show one: wild-workspace logs <name> [--tail N] (names: ${logs.map((l) => l.name).join(', ')})`);
611
+ return;
612
+ }
613
+ const match = logs.find((l) => l.name === name);
614
+ if (!match) {
615
+ console.log(`unknown log "${name}". names: ${logs.map((l) => l.name).join(', ')}`);
616
+ process.exitCode = 1;
617
+ return;
618
+ }
619
+ console.log(`# ${match.name} — ${match.file} (last ${n} lines)`);
620
+ console.log(tailFile(match.file, n) || '(empty)');
621
+ }
622
+
623
+ // `wild-workspace ops <slug>` — OPERATOR read of a user's observability feed.
624
+ // Reads the bmo-sync admin endpoint with the admin key at ~/.bmo-sync-admin-key
625
+ // (so it only works on an operator's machine). This is how we see a stuck/broken
626
+ // user without them having to ask. Filters: --kind feed|install-down|transcript,
627
+ // --limit N.
628
+ async function runOpsCommand(opts) {
629
+ const slug = opts.positional[1];
630
+ if (!slug) {
631
+ console.error('usage: wild-workspace ops <slug> [--kind feed|install-down|transcript] [--limit N]');
632
+ process.exitCode = 1;
633
+ return;
634
+ }
635
+ const config = buildConfig(opts);
636
+ const keyPath = path.join(os.homedir(), '.bmo-sync-admin-key');
637
+ let adminKey;
638
+ try {
639
+ adminKey = readFileSync(keyPath, 'utf8').trim();
640
+ } catch {
641
+ console.error(`No admin key at ${keyPath} — \`ops\` is an operator-only command.`);
642
+ process.exitCode = 1;
643
+ return;
644
+ }
645
+ const params = new URLSearchParams();
646
+ if (opts.kind) params.set('kind', opts.kind);
647
+ params.set('limit', String(opts.limit || 50));
648
+ const url = `${config.bmoSyncServerUrl.replace(/\/$/, '')}/api/admin/accounts/${encodeURIComponent(slug)}/activity?${params}`;
649
+ let res;
650
+ try {
651
+ res = await fetch(url, { headers: { 'x-admin-key': adminKey } });
652
+ } catch (e) {
653
+ console.error(`Couldn't reach ${config.bmoSyncServerUrl}: ${e.message || e}`);
654
+ process.exitCode = 2;
655
+ return;
656
+ }
657
+ if (res.status === 404) {
658
+ console.log(`No account/slug "${slug}" yet (unclaimed, or it hasn't reported).`);
659
+ return;
660
+ }
661
+ if (res.status === 403 || res.status === 401) {
662
+ console.error('admin key rejected.');
663
+ process.exitCode = 1;
664
+ return;
665
+ }
666
+ if (!res.ok) {
667
+ console.error(`HTTP ${res.status}`);
668
+ process.exitCode = 1;
669
+ return;
670
+ }
671
+ const data = await res.json();
672
+ console.log(`account ${data.account_id}${data.slug ? ` (${data.slug})` : ''} — ${data.count} recent event(s)`);
673
+ for (const r of data.rows || []) {
674
+ const when = new Date((r.reported_at || 0) * 1000).toISOString();
675
+ let detail = '';
676
+ if (r.kind === 'feed' && Array.isArray(r.payload?.events)) {
677
+ const counts = {};
678
+ for (const e of r.payload.events) counts[e.type] = (counts[e.type] || 0) + 1;
679
+ detail = Object.entries(counts).map(([k, v]) => `${k}×${v}`).join(' ');
680
+ } else if ((r.kind === 'install-down' || r.kind === 'doctor-share') && r.payload?.doctor?.summary) {
681
+ const s = r.payload.doctor.summary;
682
+ detail = `doctor: ${s.fail} fail / ${s.warn} warn`;
683
+ } else if (r.kind === 'transcript') {
684
+ detail = `transcript ${r.payload?.date || ''} (${(r.payload?.markdown || '').length} chars)`;
685
+ }
686
+ console.log(` ${when} [${r.kind}]${r.os ? ` ${r.os}` : ''} ${detail}`);
687
+ }
688
+ }
689
+
690
+ // `wild-workspace observability [on|off|status]` — the consented session +
691
+ // install-health feed (default ON). Streams WHAT happened + install health to the
692
+ // Venturewild team so they can help if something breaks — never your chat words
693
+ // (that's the separate transcript channel). See observability.mjs.
694
+ async function runObservabilityCommand(action = 'status', opts = {}) {
695
+ const config = buildConfig(opts);
696
+ if (action === 'on' || action === 'off') {
697
+ const rec = setObservabilityConsent(config.dataDir, action === 'on');
698
+ console.log(
699
+ rec.enabled
700
+ ? '✓ observability ON — the Venturewild team can see how your workspace is doing'
701
+ : '✓ observability OFF — no session/health feed leaves this machine.',
702
+ );
703
+ if (rec.enabled) {
704
+ console.log(' (events + install health only — never your chat content)');
705
+ }
706
+ console.log(' Applies the next time your workspace starts (or toggle it live in the app).');
707
+ return;
708
+ }
709
+ const rec = loadObservabilityConsent(config.dataDir);
710
+ console.log(`observability: ${rec.enabled ? 'ON' : 'OFF'}${rec.decidedAt ? '' : ' (default)'}`);
711
+ console.log(' what : events + install health → lets us help if something breaks (never chat content)');
712
+ console.log(' toggle: wild-workspace observability on | off');
713
+ return;
714
+ }
715
+
716
+ // `wild-workspace update [apply|on|off|channel <stable|beta>]` — Phase 2
717
+ // auto-update (docs/remote-support-and-self-healing-design.md). With no
718
+ // sub-command it checks the channel for a newer release; `apply` installs it now;
719
+ // `on`/`off` toggle the default-on background updater; `channel` switches
720
+ // stable/beta. The always-on supervisor does this automatically — this is the
721
+ // manual lever + the off switch.
722
+ async function runUpdateCommand(opts) {
723
+ const sub = opts.positional[1];
724
+ const gdir = globalDir();
725
+
726
+ if (sub === 'on' || sub === 'off') {
727
+ const rec = setUpdateEnabled(gdir, sub === 'on');
728
+ console.log(
729
+ rec.enabled
730
+ ? '✓ auto-update ON — wild-workspace keeps itself up to date in the background.'
731
+ : '✓ auto-update OFF — update manually with `wild-workspace update apply`.',
732
+ );
733
+ console.log(` channel: ${rec.channel}`);
734
+ return;
735
+ }
736
+ if (sub === 'channel') {
737
+ const chan = opts.positional[2];
738
+ if (chan !== 'stable' && chan !== 'beta') {
739
+ console.log('usage: wild-workspace update channel stable|beta');
740
+ return;
741
+ }
742
+ console.log(`✓ update channel set to ${setUpdateChannel(gdir, chan).channel}.`);
743
+ return;
744
+ }
745
+
746
+ const settings = loadUpdateSettings(gdir);
747
+ const c = await new AutoUpdater({ globalDir: gdir }).check();
748
+ console.log(`wild-workspace ${c.current} (channel: ${c.channel}, auto-update: ${settings.enabled ? 'on' : 'off'})`);
749
+ if (!c.latest) {
750
+ console.log(' could not reach the npm registry to check for updates.');
751
+ process.exitCode = 1;
752
+ return;
753
+ }
754
+ if (!c.available) {
755
+ console.log(` up to date — ${c.latest} is the latest on ${c.channel}.`);
756
+ return;
757
+ }
758
+ console.log(` update available: ${c.current} → ${c.latest}`);
759
+ if (sub !== 'apply') {
760
+ console.log(' run `wild-workspace update apply` to install it now.');
761
+ return;
762
+ }
763
+ console.log(` installing ${c.latest}…`);
764
+ const res = await npmInstall(`${PACKAGE_NAME}@${c.latest}`);
765
+ if (res.code === 0) {
766
+ recordUpdate(gdir, { from: c.current, to: c.latest, at: Date.now(), status: 'installed' });
767
+ console.log(` ✓ installed ${c.latest}.`);
768
+ console.log(' The always-on supervisor will restart into the new version shortly,');
769
+ console.log(' or restart `wild-workspace` yourself to use it now.');
770
+ } else {
771
+ console.log(` ✗ install failed (code ${res.code}).`);
772
+ if (res.output) console.log(' ' + res.output.split('\n').filter(Boolean).slice(-3).join('\n '));
773
+ process.exitCode = 1;
774
+ }
775
+ }
776
+
777
+ // `wild-workspace support [allow|revoke|status]` — Phase 3 consent for the
778
+ // daemon-hosted, out-of-band support channel (reachable even when your workspace
779
+ // is offline). OFF by default + time-boxed; the bmo-sync daemon enforces it
780
+ // locally and revoke is instant (this writes/deletes ~/.wild-workspace/
781
+ // support-consent.json, which the daemon re-reads). Runs as its own process so it
782
+ // works when the server is down.
783
+ function fmtRemaining(ms) {
784
+ const m = Math.round(ms / 60000);
785
+ if (m < 60) return `${m} min`;
786
+ return `${Math.floor(m / 60)}h ${m % 60}m`;
787
+ }
788
+ async function runSupportCommand(action = 'status', opts = {}) {
789
+ const gdir = globalDir();
790
+
791
+ if (action === 'allow') {
792
+ const tier = opts.tier || 1;
793
+ const minutes = opts.minutes || 60;
794
+ if (tier < 1 || tier > MAX_TIER) {
795
+ console.error(
796
+ `tier must be 1..${MAX_TIER} (1 = read-only diagnostics/logs, 2 = curated fixes, ` +
797
+ `3 = let support operate your agent, 4 = raw shell).`,
798
+ );
799
+ process.exitCode = 1;
800
+ return;
801
+ }
802
+ // Tiers 3–4 let support OPERATE the machine (drive the agent / run commands).
803
+ // Require an explicit --yes so it's never a slip of the keyboard.
804
+ if (tier >= OPERATE_TIER && !opts.yes) {
805
+ console.error(
806
+ `tier ${tier} lets VentureWild support ${tier >= 4 ? 'run shell commands on' : 'operate the agent on'} ` +
807
+ `your machine (time-boxed, audited, revocable). Re-run with --yes to confirm:\n` +
808
+ ` wild-workspace support allow --tier ${tier} --minutes ${minutes} --yes`,
809
+ );
810
+ process.exitCode = 1;
811
+ return;
812
+ }
813
+ const rec = grantConsent(gdir, { tier, minutes });
814
+ if (!rec) {
815
+ console.error(`Could not write support consent to ${gdir}.`);
816
+ process.exitCode = 1;
817
+ return;
818
+ }
819
+ const tierDesc =
820
+ rec.tier >= 4
821
+ ? 'read diagnostics/logs + curated fixes + operate your agent + run raw shell commands'
822
+ : rec.tier >= 3
823
+ ? 'read diagnostics/logs + curated fixes + operate your agent on a task'
824
+ : rec.tier >= 2
825
+ ? 'read diagnostics/logs + run curated fixes (restart sync, relink, reinstall)'
826
+ : 'read diagnostics + logs only';
827
+ console.log(`✓ VentureWild support allowed at tier ${rec.tier} for ${fmtRemaining(rec.expiresAt - rec.grantedAt)} (max ${MAX_GRANT_MINUTES / 60}h).`);
828
+ console.log(` tier ${rec.tier} = ${tierDesc}.`);
829
+ console.log(' It auto-expires; revoke anytime with: wild-workspace support revoke');
830
+ return;
831
+ }
832
+ if (action === 'revoke') {
833
+ const removed = revokeConsent(gdir);
834
+ console.log(removed ? '✓ support access revoked.' : 'support access was not enabled.');
835
+ return;
836
+ }
837
+ if (action === 'audit') {
838
+ const entries = readAudit(gdir, { limit: opts.limit || 20 });
839
+ if (!entries.length) {
840
+ console.log('no support actions recorded yet.');
841
+ return;
842
+ }
843
+ console.log('recent support actions (newest first):');
844
+ for (const e of entries) {
845
+ const when = e.ts ? new Date(e.ts).toISOString() : '?';
846
+ console.log(` ${when} ${e.ok ? '✓' : '✗'} ${e.action} (tier ${e.tier ?? '?'})`);
847
+ }
848
+ return;
849
+ }
850
+ // status (default)
851
+ const s = consentStatus(gdir);
852
+ if (s.enabled) {
853
+ console.log(`support access: ON (tier ${s.tier}) — ${fmtRemaining(s.remainingMs)} remaining.`);
854
+ console.log(' revoke : wild-workspace support revoke');
855
+ } else {
856
+ console.log('support access: OFF (default).');
857
+ console.log(' allow : wild-workspace support allow --tier 1 --minutes 60');
858
+ }
859
+ console.log(` file : ${s.file}`);
860
+ }
861
+
862
+ // `wild-workspace operator [enable|disable|status]` — the consented support
863
+ // channel (docs/SECURITY.md). OFF by default; `enable` mints a token to hand to
864
+ // the wild-workspace team so they can diagnose + run a fixed set of safe fixes.
865
+ async function runOperatorCommand(action = 'status', opts = {}) {
866
+ const config = buildConfig(opts);
867
+ if (action === 'enable') {
868
+ const token = enableOperator(config.dataDir, { rotate: opts.rotate });
869
+ if (!token) {
870
+ console.error(`Could not enable operator channel (couldn't write to ${config.dataDir}).`);
871
+ process.exitCode = 1;
872
+ return;
873
+ }
874
+ const slug = config.account?.slug;
875
+ console.log('✓ operator channel enabled (consented support access).');
876
+ console.log(` token : ${token}`);
877
+ console.log(' Share this token with the wild-workspace team so they can help with your install.');
878
+ if (slug) console.log(` reach : https://${slug}.venturewild.llc/api/operator/diag (Authorization: Bearer <token>)`);
879
+ console.log(' off : wild-workspace operator disable');
880
+ console.log('');
881
+ console.log(' Scope: read diagnostics + a fixed set of safe fixes (restart sync, re-detect');
882
+ console.log(' Claude, re-link your account, reinstall the sync daemon). It cannot run');
883
+ console.log(' arbitrary commands or drive your agent, and every action is logged.');
884
+ return;
885
+ }
886
+ if (action === 'disable') {
887
+ const removed = disableOperator(config.dataDir);
888
+ console.log(removed ? '✓ operator channel disabled (token revoked).' : 'operator channel was not enabled.');
889
+ return;
890
+ }
891
+ if (action === 'status') {
892
+ const s = operatorStatus(config.dataDir);
893
+ console.log(`operator channel: ${s.enabled ? 'ENABLED' : 'disabled'}`);
894
+ console.log(` token file: ${s.file}`);
895
+ console.log(s.enabled ? ' disable : wild-workspace operator disable' : ' enable : wild-workspace operator enable');
896
+ return;
897
+ }
898
+ console.log(`unknown operator action: ${action} (use enable | disable | status)`);
899
+ }
900
+
901
+ // `wild-workspace service [install|uninstall|status|run]` — the always-on
902
+ // autostart (docs/always-on-design.md). `run` is the HIDDEN supervisor entry
903
+ // the per-OS launcher invokes at login; the others manage registration. All
904
+ // per-user, no admin.
905
+ // First-run browser orchestration lives in a shebang-free module so it's
906
+ // importable + unit-testable (server/test/owner-browser.test.mjs).
907
+
908
+ async function runServiceCommand(action = 'status', opts = {}) {
909
+ const config = buildConfig(opts);
910
+
911
+ if (action === 'install') {
912
+ const r = await installService({
913
+ node: process.execPath, cli: __filename, workspaceDir: config.workspaceDir, port: config.port, version: APP_VERSION,
914
+ });
915
+ if (!r.installed) {
916
+ console.log(`service install: ${r.message || 'not supported on this platform'}`);
917
+ process.exitCode = 1;
918
+ return;
919
+ }
920
+ console.log('✓ always-on enabled — your workspace starts automatically at login.');
921
+ console.log(` mechanism : ${r.mechanism} (per-user, no admin)`);
922
+ console.log(` launcher : ${r.launcher || r.vbs}`);
923
+ console.log(` workspace : ${config.workspaceDir}`);
924
+ if (r.note) console.log(` note : ${r.note}`);
925
+ console.log(' disable : wild-workspace service uninstall');
926
+ return;
927
+ }
928
+
929
+ if (action === 'uninstall') {
930
+ const r = await uninstallService();
931
+ if (r.supported === false) { console.log(`service: nothing to do — autostart not implemented for ${r.platform} yet`); return; }
932
+ console.log(`✓ always-on disabled${r.removedKey ? '' : ' (no autostart entry was set)'}${r.stoppedPid ? ` — stopped supervisor pid ${r.stoppedPid}` : ''}.`);
933
+ return;
934
+ }
935
+
936
+ if (action === 'status') {
937
+ const s = await serviceStatus({ port: config.port }, { probeImpl: (p) => probeHealth(p) });
938
+ if (s.supported === false) { console.log(`always-on: autostart not implemented for ${s.platform} yet (run \`wild-workspace\` manually)`); return; }
939
+ console.log(`always-on : ${s.installed ? 'installed' : 'NOT installed'}`);
940
+ if (s.runValue) console.log(` run entry : ${s.runValue}`);
941
+ console.log(` supervisor: ${s.supervisorAlive ? `running (pid ${s.supervisorPid})` : 'not running'}`);
942
+ console.log(` server : ${s.serverUp ? `up on http://127.0.0.1:${config.port}` : 'down'}`);
943
+ return;
944
+ }
945
+
946
+ if (action === 'run') {
947
+ // Hidden entrypoint launched by the autostart VBS at login. The OS gives us
948
+ // an arbitrary cwd, so read the workspace dir persisted at install time.
949
+ let svc = {};
950
+ try { svc = JSON.parse(readFileSync(path.join(globalDir(), 'service.json'), 'utf8')); } catch { /* fall back to config */ }
951
+ const sup = new WorkspaceSupervisor({
952
+ workspaceDir: svc.workspaceDir || config.workspaceDir,
953
+ port: svc.port || config.port,
954
+ });
955
+ const r = sup.start();
956
+ if (!r.started) process.exit(0); // another supervisor already owns the lock
957
+ // else: the supervision interval keeps this process alive.
958
+ return;
959
+ }
960
+
961
+ console.log(`unknown service action: ${action} (use install | uninstall | status | run)`);
962
+ }
963
+
964
+ async function main() {
965
+ // First-run capture: log every invocation BEFORE doing anything, so even a
966
+ // crash-on-start leaves a trace we (or `wild-workspace doctor`) can read.
967
+ appendLine('cli', `invoke argv=[${process.argv.slice(2).join(' ')}] cwd=${process.cwd()} node=${process.version} v${APP_VERSION}`);
968
+ const opts = parseArgs(process.argv.slice(2));
969
+ if (opts.help) return printUsage();
970
+ if (opts.version) { console.log(APP_VERSION); return; }
971
+
972
+ if (opts.positional[0] === 'daemon') {
973
+ return runDaemonCommand(opts.positional[1], opts.positional.slice(2));
974
+ }
975
+
976
+ if (opts.positional[0] === 'login') {
977
+ return runLoginCommand(opts.positional.slice(1));
978
+ }
979
+ if (opts.positional[0] === 'logout') {
980
+ return runLogoutCommand();
981
+ }
982
+ if (opts.positional[0] === 'reset') {
983
+ return runResetCommand(opts);
984
+ }
985
+ if (opts.positional[0] === 'whoami') {
986
+ return runWhoamiCommand();
987
+ }
988
+ if (opts.positional[0] === 'rotate-token') {
989
+ return runRotateTokenCommand();
990
+ }
991
+ if (opts.positional[0] === 'workspace') {
992
+ return runWorkspaceCommand(opts.positional[1], opts);
993
+ }
994
+ if (opts.positional[0] === 'doctor') {
995
+ return runDoctorCommand(opts);
996
+ }
997
+ if (opts.positional[0] === 'logs') {
998
+ return runLogsCommand(opts);
999
+ }
1000
+ if (opts.positional[0] === 'update') {
1001
+ return runUpdateCommand(opts);
1002
+ }
1003
+ if (opts.positional[0] === 'support') {
1004
+ return runSupportCommand(opts.positional[1], opts);
1005
+ }
1006
+ if (opts.positional[0] === 'operator') {
1007
+ return runOperatorCommand(opts.positional[1], opts);
1008
+ }
1009
+ if (opts.positional[0] === 'observability') {
1010
+ return runObservabilityCommand(opts.positional[1], opts);
1011
+ }
1012
+ if (opts.positional[0] === 'ops') {
1013
+ return runOpsCommand(opts);
1014
+ }
1015
+ if (opts.positional[0] === 'service') {
1016
+ return runServiceCommand(opts.positional[1], opts);
1017
+ }
1018
+
1019
+ if (opts.positional[0] === 'install') {
1020
+ console.log('wild-workspace manages the bmo-sync sync daemon for you.');
1021
+ console.log('');
1022
+ console.log('It starts automatically in the background whenever you run');
1023
+ console.log('`wild-workspace`, and keeps running after you close the browser —');
1024
+ console.log('so there is no separate install step.');
1025
+ console.log('');
1026
+ console.log(' wild-workspace daemon status check whether it is running');
1027
+ console.log(' wild-workspace daemon stop stop it');
1028
+ return;
1029
+ }
1030
+ if (opts.positional[0] === 'share') {
1031
+ console.log('Use the in-app Share button to issue viewer URLs. CLI share command is v1.x.');
1032
+ return;
1033
+ }
1034
+
1035
+ // If a workspace server is already serving this port — always-on started it at
1036
+ // login, or another `wild-workspace` is running — don't fight it for the socket
1037
+ // (createServer would reject on EADDRINUSE and crash). Just open the browser to
1038
+ // the one already up. This is the common case now that always-on is real.
1039
+ {
1040
+ const probeCfg = buildConfig(opts);
1041
+ if (await probeHealth(probeCfg.port)) {
1042
+ const host = probeCfg.host === '0.0.0.0' ? '127.0.0.1' : probeCfg.host;
1043
+ const displayUrl = `http://${host}:${probeCfg.port}`; // shown without the token
1044
+ console.log(`\n wild-workspace is already running at ${displayUrl}`);
1045
+ if (probeCfg.openBrowser) {
1046
+ console.log(' opening it in your browser…');
1047
+ await openOwnerBrowser(probeCfg);
1048
+ }
1049
+ return;
1050
+ }
1051
+ }
1052
+
1053
+ const server = await createServer(opts);
1054
+ const { config } = server;
1055
+ console.log(`\n wild-workspace v${APP_VERSION}`);
1056
+ console.log(` workspace : ${config.workspaceDir}`);
1057
+ console.log(` url : http://${config.host}:${config.port}`);
1058
+ console.log(` agent : ${server.getActiveAgent()?.label || '(none detected — install Claude Code: npm i -g @anthropic-ai/claude-code)'}`);
1059
+
1060
+ // Report how the sync daemon's autostart went (best-effort — never fatal).
1061
+ try {
1062
+ const d = await server.daemonReady;
1063
+ if (d.alreadyRunning) console.log(' sync : bmo-sync daemon already running');
1064
+ else if (d.started) console.log(` sync : bmo-sync daemon started (pid ${d.pid})`);
1065
+ else if (d.skipped) console.log(' sync : daemon autostart disabled');
1066
+ else if (d.error === 'daemon-binary-not-found')
1067
+ console.log(' sync : daemon binary not installed — sync is off (see `wild-workspace daemon`)');
1068
+ else if (d.error) console.log(` sync : daemon did not start — ${d.error}`);
1069
+ } catch {}
1070
+ console.log('');
1071
+
1072
+ if (config.publicMode) {
1073
+ // Public mode denies anon — tell the owner how to reach it authenticated.
1074
+ console.log(` mode : PUBLIC — opening with a one-time sign-in token`);
1075
+ }
1076
+ if (config.openBrowser) {
1077
+ await openOwnerBrowser(config);
1078
+ }
1079
+
1080
+ // Hard safety net: even if stop() ever wedges, never leave the user staring at
1081
+ // "shutting down…". Unref'd so the timer itself doesn't keep us alive.
1082
+ const forceExitSoon = () => { setTimeout(() => process.exit(0), 3000).unref(); };
1083
+ process.on('SIGINT', async () => {
1084
+ console.log('\nshutting down…');
1085
+ forceExitSoon();
1086
+ try { await server.stop(); } catch {}
1087
+ process.exit(0);
1088
+ });
1089
+ process.on('SIGTERM', async () => { forceExitSoon(); try { await server.stop(); } catch {} process.exit(0); });
1090
+ }
1091
+
1092
+ main().catch((err) => {
1093
+ appendLine('cli', `FATAL ${err?.stack || err}`);
1094
+ console.error('wild-workspace failed:', err);
1095
+ process.exit(1);
1096
+ });