@venturewild/workspace 0.1.14 → 0.2.0

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 (37) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +112 -112
  3. package/package.json +83 -76
  4. package/server/bin/wild-workspace.mjs +825 -763
  5. package/server/src/agent.mjs +453 -386
  6. package/server/src/bazaar/core.mjs +579 -0
  7. package/server/src/bazaar/index.mjs +75 -0
  8. package/server/src/bazaar/mcp-server.mjs +328 -0
  9. package/server/src/bazaar/mock-tickup.mjs +97 -0
  10. package/server/src/bazaar/preview-server.mjs +95 -0
  11. package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -0
  12. package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -0
  13. package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -0
  14. package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -0
  15. package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -0
  16. package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -0
  17. package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -0
  18. package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -0
  19. package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -0
  20. package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +32 -0
  21. package/server/src/canvas/core.mjs +324 -0
  22. package/server/src/canvas/index.mjs +42 -0
  23. package/server/src/canvas/mcp-server.mjs +253 -0
  24. package/server/src/config.mjs +365 -365
  25. package/server/src/daemon-supervisor.mjs +216 -216
  26. package/server/src/inbox.mjs +86 -86
  27. package/server/src/index.mjs +1948 -1721
  28. package/server/src/logpaths.mjs +98 -98
  29. package/server/src/service.mjs +419 -419
  30. package/server/src/share.mjs +182 -148
  31. package/server/src/sync.mjs +248 -248
  32. package/server/src/turn-mcp.mjs +46 -0
  33. package/web/dist/assets/index-DVWgeTl_.js +91 -0
  34. package/web/dist/assets/index-Dl0VT5e6.css +1 -0
  35. package/web/dist/index.html +2 -2
  36. package/web/dist/assets/index-Bj-mdLGj.css +0 -1
  37. package/web/dist/assets/index-Dc6jo84c.js +0 -89
@@ -1,763 +1,825 @@
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
-
27
- const __filename = url.fileURLToPath(import.meta.url);
28
- const __dirname = path.dirname(__filename);
29
-
30
- function printUsage() {
31
- console.log(`wild-workspace v${APP_VERSION}
32
-
33
- Usage:
34
- wild-workspace start the workspace server in the current directory
35
- wild-workspace --port 5173 override port (default 5173)
36
- wild-workspace --no-open don't auto-open browser
37
- wild-workspace --host 0.0.0.0 bind to all interfaces (for share-by-URL hosting)
38
- wild-workspace daemon status is the bmo-sync daemon running?
39
- wild-workspace daemon start start the sync daemon now
40
- wild-workspace daemon stop stop the sync daemon
41
- wild-workspace daemon conflicts list list open conflicts
42
- wild-workspace daemon conflicts show <wid> <path> view one conflict
43
- wild-workspace daemon conflicts resolve <wid> <path> <keep_mine|take_theirs>
44
- wild-workspace login <payload> bind this install to a slug
45
- (paste the blob from workspace.venturewild.llc)
46
- wild-workspace logout clear the bound account (slug + token)
47
- wild-workspace whoami show the currently-bound account
48
- wild-workspace rotate-token mint a new account token; invalidates the old one
49
- wild-workspace doctor [--share] diagnose this machine's install (✅/⚠️/❌ + logs)
50
- wild-workspace logs [name] [--tail N] list logs, or tail one (cli/server/daemon/…)
51
- wild-workspace operator enable let the wild-workspace team help with your install (mints a token)
52
- wild-workspace operator disable revoke the support token
53
- wild-workspace operator status is the support channel on?
54
- wild-workspace observability [on|off|status] share session + install health so we can help (default on; never chat content)
55
- wild-workspace service install keep your workspace always-on (starts at login, no admin)
56
- wild-workspace service uninstall turn always-on off
57
- wild-workspace service status show always-on status (installed? supervisor? server?)
58
- wild-workspace install (info) how the sync daemon is managed
59
- wild-workspace --help this message
60
- wild-workspace --version print version
61
-
62
- The bmo-sync sync daemon starts automatically in the background when you run
63
- \`wild-workspace\`, and keeps running after you close the browser.
64
-
65
- Environment:
66
- WILD_WORKSPACE_PORT, WILD_WORKSPACE_HOST,
67
- WILD_WORKSPACE_DIR, WILD_WORKSPACE_DATA_DIR,
68
- WILD_WORKSPACE_PARTNER_TOKEN, WILD_WORKSPACE_SHARE_SECRET,
69
- WILD_WORKSPACE_NO_OPEN=1, WILD_WORKSPACE_DAEMON_AUTOSTART=0
70
- `);
71
- }
72
-
73
- function parseArgs(argv) {
74
- const opts = {};
75
- const positional = [];
76
- for (let i = 0; i < argv.length; i++) {
77
- const arg = argv[i];
78
- if (arg === '--help' || arg === '-h') opts.help = true;
79
- else if (arg === '--version' || arg === '-v') opts.version = true;
80
- else if (arg === '--no-open') opts.openBrowser = false;
81
- else if (arg === '--port') { opts.port = Number(argv[++i]); }
82
- else if (arg === '--host') { opts.host = argv[++i]; }
83
- else if (arg === '--workspace') { opts.workspaceDir = argv[++i]; }
84
- else if (arg === '--tail') { opts.tail = Number(argv[++i]); }
85
- else if (arg === '--share') { opts.share = true; }
86
- else if (arg === '--rotate') { opts.rotate = true; }
87
- else if (arg === '--kind') { opts.kind = argv[++i]; }
88
- else if (arg === '--limit') { opts.limit = Number(argv[++i]); }
89
- else if (arg.startsWith('--')) {
90
- // ignore unknown flags
91
- } else {
92
- positional.push(arg);
93
- }
94
- }
95
- opts.positional = positional;
96
- return opts;
97
- }
98
-
99
- // `wild-workspace daemon [status|start|stop|conflicts ...]`
100
- async function runDaemonCommand(action = 'status', rest = []) {
101
- const config = buildConfig({});
102
- const sup = new DaemonSupervisor({
103
- httpBase: config.daemonHttpUrl,
104
- // b-ii: so `wild-workspace daemon start` also opens the proxy link.
105
- accountToken: config.accountToken,
106
- serverUrl: config.bmoSyncServerUrl,
107
- });
108
-
109
- if (action === 'conflicts') {
110
- return runConflictsCommand(config, rest);
111
- }
112
- if (action === 'status') {
113
- const s = await sup.status();
114
- console.log(`bmo-sync daemon: ${s.running ? 'running' : 'stopped'}`);
115
- console.log(` api : ${s.httpBase}`);
116
- if (s.pid) console.log(` pid : ${s.pid}`);
117
- console.log(` log : ${s.logFile}`);
118
- return;
119
- }
120
- if (action === 'start') {
121
- const r = await sup.ensureRunning();
122
- if (r.alreadyRunning) {
123
- console.log('bmo-sync daemon: already running');
124
- } else if (r.started) {
125
- const healthy = await sup.waitForHealthy();
126
- console.log(
127
- healthy
128
- ? `bmo-sync daemon: started (pid ${r.pid})`
129
- : `bmo-sync daemon: launched (pid ${r.pid}) — not yet answering, check the log`,
130
- );
131
- } else if (r.error === 'daemon-binary-not-found') {
132
- console.log('bmo-sync daemon: cannot start — the daemon binary is not installed.');
133
- console.log(' build it from the bmo-sync workspace and place it under vendor/,');
134
- console.log(' or install the @venturewild/workspace-daemon-<platform> package.');
135
- } else {
136
- console.log(`bmo-sync daemon: could not start — ${r.error}`);
137
- }
138
- return;
139
- }
140
- if (action === 'stop') {
141
- const r = await sup.stop();
142
- console.log(
143
- r.stopped
144
- ? `bmo-sync daemon: stopped (pid ${r.pid})`
145
- : `bmo-sync daemon: not stopped — ${r.reason}`,
146
- );
147
- return;
148
- }
149
- console.log(`unknown daemon action: ${action} (use status | start | stop | conflicts)`);
150
- }
151
-
152
- // `wild-workspace daemon conflicts [list|show <wid> <path>|resolve <wid> <path> <action>]`
153
- async function runConflictsCommand(config, args) {
154
- const sync = new SyncControl({
155
- daemonHttpUrl: config.daemonHttpUrl,
156
- bmoSyncServerUrl: config.bmoSyncServerUrl,
157
- });
158
- const sub = (args[0] || 'list').toLowerCase();
159
- if (sub === 'list' || !sub) {
160
- const conflicts = await sync.listConflicts();
161
- if (!conflicts.length) {
162
- console.log('no open conflicts');
163
- return;
164
- }
165
- console.log(`open conflicts: ${conflicts.length}`);
166
- for (const c of conflicts) {
167
- const detected = new Date((c.detectedAt || 0) * 1000).toISOString();
168
- console.log(` [${c.workspaceId}] ${c.path}`);
169
- console.log(` resolution : ${c.resolution}`);
170
- console.log(` detected_at : ${detected}`);
171
- if (c.peerBackOfficePath) {
172
- console.log(` peer_bytes_at : ${c.peerBackOfficePath}`);
173
- }
174
- if (c.mineSha256) console.log(` mine_sha256 : ${c.mineSha256}`);
175
- if (c.theirsSha256) console.log(` theirs_sha256 : ${c.theirsSha256}`);
176
- }
177
- return;
178
- }
179
- if (sub === 'show') {
180
- const wid = args[1];
181
- const path = args[2];
182
- if (!wid || !path) {
183
- console.log('usage: wild-workspace daemon conflicts show <workspace_id> <path>');
184
- return;
185
- }
186
- const view = await sync.viewConflict(wid, path);
187
- if (!view) {
188
- console.log(`no open conflict for ${wid}:${path}`);
189
- return;
190
- }
191
- console.log(JSON.stringify(view, null, 2));
192
- return;
193
- }
194
- if (sub === 'resolve') {
195
- const wid = args[1];
196
- const path = args[2];
197
- const action = args[3];
198
- if (!wid || !path || !action) {
199
- console.log(
200
- 'usage: wild-workspace daemon conflicts resolve <workspace_id> <path> <keep_mine|take_theirs>',
201
- );
202
- return;
203
- }
204
- try {
205
- await sync.resolveConflict(wid, path, action);
206
- console.log(`resolved: ${wid}:${path} (${action})`);
207
- } catch (e) {
208
- console.error(`resolve failed: ${e.message || e}`);
209
- process.exitCode = 1;
210
- }
211
- return;
212
- }
213
- console.log(
214
- `unknown conflicts subcommand: ${sub} (use list | show <wid> <path> | resolve <wid> <path> <action>)`,
215
- );
216
- }
217
-
218
- // `wild-workspace login <base64url-payload>` — bind this install to a slug.
219
- // The payload comes from workspace.venturewild.llc on signup. It's an opaque
220
- // blob the user copies once; we decode + persist + print a friendly summary.
221
- async function runLoginCommand(args) {
222
- if (!args.length) {
223
- console.error('usage: wild-workspace login <payload>');
224
- console.error('');
225
- console.error('The payload is the blob you copied from workspace.venturewild.llc');
226
- console.error('after claiming your slug. Run that signup, then come back here.');
227
- process.exitCode = 1;
228
- return;
229
- }
230
- // Trim the obvious "wild-workspace login " prefix in case the user pasted
231
- // the whole command line, and surrounding quotes if a shell preserved them.
232
- let payload = args.join(' ').trim();
233
- payload = payload.replace(/^wild-workspace\s+login\s+/i, '').trim();
234
- payload = payload.replace(/^['"]|['"]$/g, '');
235
- let parsed;
236
- try {
237
- parsed = decodeLoginPayload(payload);
238
- } catch (e) {
239
- console.error(`Couldn't decode the login payload: ${e.message || e}`);
240
- process.exitCode = 1;
241
- return;
242
- }
243
- const config = buildConfig({});
244
- let saved;
245
- try {
246
- saved = saveAccount(config.dataDir, parsed);
247
- } catch (e) {
248
- console.error(`Couldn't save account to ${config.dataDir}: ${e.message || e}`);
249
- process.exitCode = 1;
250
- return;
251
- }
252
- console.log(`✓ logged in as ${saved.email}`);
253
- console.log(` slug : ${saved.slug}`);
254
- console.log(` url : https://${saved.slug}.venturewild.llc (once the tunnel is configured)`);
255
- console.log(` saved to : ${config.dataDir}/account.json`);
256
- console.log('');
257
- console.log('Run `wild-workspace` in any folder to start your workspace.');
258
-
259
- // Arm always-on so the workspace comes back on its own (best-effort — never
260
- // blocks login). On a platform without autostart yet, just nudge the user.
261
- try {
262
- const svc = await installService({
263
- node: process.execPath, cli: __filename, workspaceDir: config.workspaceDir, port: config.port, version: APP_VERSION,
264
- });
265
- if (svc.installed) console.log(' always-on : enabled — starts at login (disable: wild-workspace service uninstall)');
266
- else if (svc.supported === false) console.log(` always-on : not yet on ${svc.platform} — run \`wild-workspace\` to start it`);
267
- } catch { /* never block login */ }
268
- }
269
-
270
- async function runLogoutCommand() {
271
- const config = buildConfig({});
272
- const before = loadAccount(config.dataDir);
273
- const removed = clearAccount(config.dataDir);
274
- if (!removed && !before) {
275
- console.log('not logged in.');
276
- return;
277
- }
278
- console.log(`logged out — cleared ${before?.email || 'account'} from ${config.dataDir}.`);
279
- }
280
-
281
- async function runRotateTokenCommand() {
282
- const config = buildConfig({});
283
- const account = loadAccount(config.dataDir);
284
- if (!account) {
285
- console.log('not logged in. Nothing to rotate.');
286
- console.log('Run `wild-workspace login <payload>` after claiming a slug.');
287
- process.exitCode = 1;
288
- return;
289
- }
290
- const url = `${config.bmoSyncServerUrl.replace(/\/$/, '')}/api/account/rotate-token`;
291
- let resp;
292
- try {
293
- resp = await fetch(url, {
294
- method: 'POST',
295
- headers: { authorization: `Bearer ${account.accountToken}` },
296
- });
297
- } catch (e) {
298
- console.error(`Couldn't reach ${config.bmoSyncServerUrl}: ${e.message || e}`);
299
- process.exitCode = 2;
300
- return;
301
- }
302
- if (!resp.ok) {
303
- let body;
304
- try { body = await resp.text(); } catch { body = ''; }
305
- console.error(`Rotate failed (HTTP ${resp.status}): ${body.slice(0, 200)}`);
306
- process.exitCode = 1;
307
- return;
308
- }
309
- let payload;
310
- try {
311
- payload = await resp.json();
312
- } catch (e) {
313
- console.error(`Server returned non-JSON: ${e.message || e}`);
314
- process.exitCode = 1;
315
- return;
316
- }
317
- if (!payload || !payload.account_token) {
318
- console.error('Server response missing account_token. Aborting.');
319
- process.exitCode = 1;
320
- return;
321
- }
322
- const updated = {
323
- ...account,
324
- accountToken: payload.account_token,
325
- loggedInAt: Date.now(),
326
- };
327
- try {
328
- saveAccount(config.dataDir, updated);
329
- } catch (e) {
330
- console.error(`Got new token but failed to persist it: ${e.message || e}`);
331
- console.error(`New token (save manually): ${payload.account_token}`);
332
- process.exitCode = 1;
333
- return;
334
- }
335
- console.log('✓ token rotated.');
336
- console.log(` slug : ${updated.slug}`);
337
- console.log(` saved to : ${config.dataDir}/account.json`);
338
- console.log('');
339
- console.log('Your daemon will reconnect with the new token automatically');
340
- console.log('on its next restart. Run `wild-workspace daemon stop && wild-workspace`');
341
- console.log('to force an immediate reconnect.');
342
- }
343
-
344
- async function runWhoamiCommand() {
345
- const config = buildConfig({});
346
- const account = loadAccount(config.dataDir);
347
- if (!account) {
348
- console.log('not logged in.');
349
- console.log('Run `wild-workspace login <payload>` after claiming a slug at workspace.venturewild.llc.');
350
- return;
351
- }
352
- console.log(`logged in as ${account.email}`);
353
- console.log(` slug : ${account.slug}`);
354
- console.log(` accountId : ${account.accountId}`);
355
- if (account.displayName) console.log(` name : ${account.displayName}`);
356
- console.log(` loggedIn : ${new Date(account.loggedInAt).toISOString()}`);
357
- }
358
-
359
- // `wild-workspace doctor [--share]` — one diagnostic of this machine's install.
360
- // Prints ✅/⚠️/❌ per check + where the logs are, and writes a JSON bundle under
361
- // ~/.wild-workspace/diagnostics/. Exits non-zero if any check failed.
362
- async function runDoctorCommand(opts) {
363
- const config = buildConfig(opts);
364
- const report = await runDoctor({ config });
365
- console.log(renderDoctor(report));
366
- const bundle = writeDoctorBundle(report);
367
- if (bundle) {
368
- console.log('');
369
- console.log(`Full report: ${bundle}`);
370
- }
371
- if (opts.share) {
372
- const shared = await shareDoctor(config, report);
373
- console.log(
374
- shared.ok
375
- ? '✓ shared with the wild-workspace team — they can see this diagnostic now.'
376
- : `Couldn't auto-share (${shared.reason}). Send the file above instead.`,
377
- );
378
- }
379
- process.exitCode = report.summary.fail > 0 ? 1 : 0;
380
- }
381
-
382
- // Upload a doctor report to bmo-sync. `--share` is an explicit user action, so it
383
- // goes even if the passive observability feed is off — but still respects the
384
- // hard kill switch and needs an account to key it to.
385
- async function shareDoctor(config, report) {
386
- if (!config.accountToken) return { ok: false, reason: 'not logged in' };
387
- if (process.env.WILD_WORKSPACE_NO_TELEMETRY === '1') return { ok: false, reason: 'telemetry disabled' };
388
- const url = `${config.bmoSyncServerUrl.replace(/\/$/, '')}/api/telemetry`;
389
- const ctrl = new AbortController();
390
- const t = setTimeout(() => ctrl.abort(), 5000);
391
- try {
392
- const res = await fetch(url, {
393
- method: 'POST',
394
- headers: { 'content-type': 'application/json' },
395
- body: JSON.stringify({
396
- account_token: config.accountToken,
397
- slug: config.account?.slug || null,
398
- workspace_id: config.workspaceId,
399
- kind: 'doctor-share',
400
- doctor: report,
401
- sent_at: Math.floor(Date.now() / 1000),
402
- }),
403
- signal: ctrl.signal,
404
- });
405
- return { ok: res.ok, reason: res.ok ? null : `HTTP ${res.status}` };
406
- } catch (e) {
407
- return { ok: false, reason: String(e?.message || e) };
408
- } finally {
409
- clearTimeout(t);
410
- }
411
- }
412
-
413
- // `wild-workspace logs [name] [--tail N]` — list the logs, or tail one by name.
414
- async function runLogsCommand(opts) {
415
- const name = opts.positional[1];
416
- const n = opts.tail || 40;
417
- const logs = listLogs();
418
- if (!name) {
419
- console.log(`logs dir: ${path.dirname(logs[0].file)}`);
420
- for (const l of logs) {
421
- console.log(` ${l.name.padEnd(10)} ${l.exists ? `${l.size} bytes` : '(none yet)'} ${l.file}`);
422
- }
423
- console.log('');
424
- console.log(`Show one: wild-workspace logs <name> [--tail N] (names: ${logs.map((l) => l.name).join(', ')})`);
425
- return;
426
- }
427
- const match = logs.find((l) => l.name === name);
428
- if (!match) {
429
- console.log(`unknown log "${name}". names: ${logs.map((l) => l.name).join(', ')}`);
430
- process.exitCode = 1;
431
- return;
432
- }
433
- console.log(`# ${match.name} — ${match.file} (last ${n} lines)`);
434
- console.log(tailFile(match.file, n) || '(empty)');
435
- }
436
-
437
- // `wild-workspace ops <slug>` — OPERATOR read of a user's observability feed.
438
- // Reads the bmo-sync admin endpoint with the admin key at ~/.bmo-sync-admin-key
439
- // (so it only works on an operator's machine). This is how we see a stuck/broken
440
- // user without them having to ask. Filters: --kind feed|install-down|transcript,
441
- // --limit N.
442
- async function runOpsCommand(opts) {
443
- const slug = opts.positional[1];
444
- if (!slug) {
445
- console.error('usage: wild-workspace ops <slug> [--kind feed|install-down|transcript] [--limit N]');
446
- process.exitCode = 1;
447
- return;
448
- }
449
- const config = buildConfig(opts);
450
- const keyPath = path.join(os.homedir(), '.bmo-sync-admin-key');
451
- let adminKey;
452
- try {
453
- adminKey = readFileSync(keyPath, 'utf8').trim();
454
- } catch {
455
- console.error(`No admin key at ${keyPath} — \`ops\` is an operator-only command.`);
456
- process.exitCode = 1;
457
- return;
458
- }
459
- const params = new URLSearchParams();
460
- if (opts.kind) params.set('kind', opts.kind);
461
- params.set('limit', String(opts.limit || 50));
462
- const url = `${config.bmoSyncServerUrl.replace(/\/$/, '')}/api/admin/accounts/${encodeURIComponent(slug)}/activity?${params}`;
463
- let res;
464
- try {
465
- res = await fetch(url, { headers: { 'x-admin-key': adminKey } });
466
- } catch (e) {
467
- console.error(`Couldn't reach ${config.bmoSyncServerUrl}: ${e.message || e}`);
468
- process.exitCode = 2;
469
- return;
470
- }
471
- if (res.status === 404) {
472
- console.log(`No account/slug "${slug}" yet (unclaimed, or it hasn't reported).`);
473
- return;
474
- }
475
- if (res.status === 403 || res.status === 401) {
476
- console.error('admin key rejected.');
477
- process.exitCode = 1;
478
- return;
479
- }
480
- if (!res.ok) {
481
- console.error(`HTTP ${res.status}`);
482
- process.exitCode = 1;
483
- return;
484
- }
485
- const data = await res.json();
486
- console.log(`account ${data.account_id}${data.slug ? ` (${data.slug})` : ''} — ${data.count} recent event(s)`);
487
- for (const r of data.rows || []) {
488
- const when = new Date((r.reported_at || 0) * 1000).toISOString();
489
- let detail = '';
490
- if (r.kind === 'feed' && Array.isArray(r.payload?.events)) {
491
- const counts = {};
492
- for (const e of r.payload.events) counts[e.type] = (counts[e.type] || 0) + 1;
493
- detail = Object.entries(counts).map(([k, v]) => `${k}×${v}`).join(' ');
494
- } else if ((r.kind === 'install-down' || r.kind === 'doctor-share') && r.payload?.doctor?.summary) {
495
- const s = r.payload.doctor.summary;
496
- detail = `doctor: ${s.fail} fail / ${s.warn} warn`;
497
- } else if (r.kind === 'transcript') {
498
- detail = `transcript ${r.payload?.date || ''} (${(r.payload?.markdown || '').length} chars)`;
499
- }
500
- console.log(` ${when} [${r.kind}]${r.os ? ` ${r.os}` : ''} ${detail}`);
501
- }
502
- }
503
-
504
- // `wild-workspace observability [on|off|status]` — the consented session +
505
- // install-health feed (default ON). Streams WHAT happened + install health to the
506
- // Venturewild team so they can help if something breaks — never your chat words
507
- // (that's the separate transcript channel). See observability.mjs.
508
- async function runObservabilityCommand(action = 'status', opts = {}) {
509
- const config = buildConfig(opts);
510
- if (action === 'on' || action === 'off') {
511
- const rec = setObservabilityConsent(config.dataDir, action === 'on');
512
- console.log(
513
- rec.enabled
514
- ? '✓ observability ON — the Venturewild team can see how your workspace is doing'
515
- : '✓ observability OFF — no session/health feed leaves this machine.',
516
- );
517
- if (rec.enabled) {
518
- console.log(' (events + install health only — never your chat content)');
519
- }
520
- console.log(' Applies the next time your workspace starts (or toggle it live in the app).');
521
- return;
522
- }
523
- const rec = loadObservabilityConsent(config.dataDir);
524
- console.log(`observability: ${rec.enabled ? 'ON' : 'OFF'}${rec.decidedAt ? '' : ' (default)'}`);
525
- console.log(' what : events + install health → lets us help if something breaks (never chat content)');
526
- console.log(' toggle: wild-workspace observability on | off');
527
- return;
528
- }
529
-
530
- // `wild-workspace operator [enable|disable|status]` — the consented support
531
- // channel (docs/SECURITY.md). OFF by default; `enable` mints a token to hand to
532
- // the wild-workspace team so they can diagnose + run a fixed set of safe fixes.
533
- async function runOperatorCommand(action = 'status', opts = {}) {
534
- const config = buildConfig(opts);
535
- if (action === 'enable') {
536
- const token = enableOperator(config.dataDir, { rotate: opts.rotate });
537
- if (!token) {
538
- console.error(`Could not enable operator channel (couldn't write to ${config.dataDir}).`);
539
- process.exitCode = 1;
540
- return;
541
- }
542
- const slug = config.account?.slug;
543
- console.log('✓ operator channel enabled (consented support access).');
544
- console.log(` token : ${token}`);
545
- console.log(' Share this token with the wild-workspace team so they can help with your install.');
546
- if (slug) console.log(` reach : https://${slug}.venturewild.llc/api/operator/diag (Authorization: Bearer <token>)`);
547
- console.log(' off : wild-workspace operator disable');
548
- console.log('');
549
- console.log(' Scope: read diagnostics + a fixed set of safe fixes (restart sync, re-detect');
550
- console.log(' Claude, re-link your account, reinstall the sync daemon). It cannot run');
551
- console.log(' arbitrary commands or drive your agent, and every action is logged.');
552
- return;
553
- }
554
- if (action === 'disable') {
555
- const removed = disableOperator(config.dataDir);
556
- console.log(removed ? '✓ operator channel disabled (token revoked).' : 'operator channel was not enabled.');
557
- return;
558
- }
559
- if (action === 'status') {
560
- const s = operatorStatus(config.dataDir);
561
- console.log(`operator channel: ${s.enabled ? 'ENABLED' : 'disabled'}`);
562
- console.log(` token file: ${s.file}`);
563
- console.log(s.enabled ? ' disable : wild-workspace operator disable' : ' enable : wild-workspace operator enable');
564
- return;
565
- }
566
- console.log(`unknown operator action: ${action} (use enable | disable | status)`);
567
- }
568
-
569
- // `wild-workspace service [install|uninstall|status|run]` — the always-on
570
- // autostart (docs/always-on-design.md). `run` is the HIDDEN supervisor entry
571
- // the per-OS launcher invokes at login; the others manage registration. All
572
- // per-user, no admin.
573
- // The URL to open for the LOCAL owner. A slug-linked install runs in public
574
- // mode (the server denies anon — C1), so the owner must authenticate: append
575
- // the partner token, which the SPA immediately exchanges for an HttpOnly cookie
576
- // and strips from the address bar (S1). A localhost-only install needs no token.
577
- // The token is only ever placed in the URL we hand the browser — never printed.
578
- function localBrowserUrl(config) {
579
- const host = config.host === '0.0.0.0' ? '127.0.0.1' : config.host;
580
- const base = `http://${host}:${config.port}`;
581
- return config.publicMode ? `${base}/?t=${encodeURIComponent(config.partnerToken)}` : base;
582
- }
583
-
584
- async function runServiceCommand(action = 'status', opts = {}) {
585
- const config = buildConfig(opts);
586
-
587
- if (action === 'install') {
588
- const r = await installService({
589
- node: process.execPath, cli: __filename, workspaceDir: config.workspaceDir, port: config.port, version: APP_VERSION,
590
- });
591
- if (!r.installed) {
592
- console.log(`service install: ${r.message || 'not supported on this platform'}`);
593
- process.exitCode = 1;
594
- return;
595
- }
596
- console.log('✓ always-on enabled — your workspace starts automatically at login.');
597
- console.log(` mechanism : ${r.mechanism} (per-user, no admin)`);
598
- console.log(` launcher : ${r.launcher || r.vbs}`);
599
- console.log(` workspace : ${config.workspaceDir}`);
600
- if (r.note) console.log(` note : ${r.note}`);
601
- console.log(' disable : wild-workspace service uninstall');
602
- return;
603
- }
604
-
605
- if (action === 'uninstall') {
606
- const r = await uninstallService();
607
- if (r.supported === false) { console.log(`service: nothing to do — autostart not implemented for ${r.platform} yet`); return; }
608
- console.log(`✓ always-on disabled${r.removedKey ? '' : ' (no autostart entry was set)'}${r.stoppedPid ? ` — stopped supervisor pid ${r.stoppedPid}` : ''}.`);
609
- return;
610
- }
611
-
612
- if (action === 'status') {
613
- const s = await serviceStatus({ port: config.port }, { probeImpl: (p) => probeHealth(p) });
614
- if (s.supported === false) { console.log(`always-on: autostart not implemented for ${s.platform} yet (run \`wild-workspace\` manually)`); return; }
615
- console.log(`always-on : ${s.installed ? 'installed' : 'NOT installed'}`);
616
- if (s.runValue) console.log(` run entry : ${s.runValue}`);
617
- console.log(` supervisor: ${s.supervisorAlive ? `running (pid ${s.supervisorPid})` : 'not running'}`);
618
- console.log(` server : ${s.serverUp ? `up on http://127.0.0.1:${config.port}` : 'down'}`);
619
- return;
620
- }
621
-
622
- if (action === 'run') {
623
- // Hidden entrypoint launched by the autostart VBS at login. The OS gives us
624
- // an arbitrary cwd, so read the workspace dir persisted at install time.
625
- let svc = {};
626
- try { svc = JSON.parse(readFileSync(path.join(globalDir(), 'service.json'), 'utf8')); } catch { /* fall back to config */ }
627
- const sup = new WorkspaceSupervisor({
628
- workspaceDir: svc.workspaceDir || config.workspaceDir,
629
- port: svc.port || config.port,
630
- });
631
- const r = sup.start();
632
- if (!r.started) process.exit(0); // another supervisor already owns the lock
633
- // else: the supervision interval keeps this process alive.
634
- return;
635
- }
636
-
637
- console.log(`unknown service action: ${action} (use install | uninstall | status | run)`);
638
- }
639
-
640
- async function main() {
641
- // First-run capture: log every invocation BEFORE doing anything, so even a
642
- // crash-on-start leaves a trace we (or `wild-workspace doctor`) can read.
643
- appendLine('cli', `invoke argv=[${process.argv.slice(2).join(' ')}] cwd=${process.cwd()} node=${process.version} v${APP_VERSION}`);
644
- const opts = parseArgs(process.argv.slice(2));
645
- if (opts.help) return printUsage();
646
- if (opts.version) { console.log(APP_VERSION); return; }
647
-
648
- if (opts.positional[0] === 'daemon') {
649
- return runDaemonCommand(opts.positional[1], opts.positional.slice(2));
650
- }
651
-
652
- if (opts.positional[0] === 'login') {
653
- return runLoginCommand(opts.positional.slice(1));
654
- }
655
- if (opts.positional[0] === 'logout') {
656
- return runLogoutCommand();
657
- }
658
- if (opts.positional[0] === 'whoami') {
659
- return runWhoamiCommand();
660
- }
661
- if (opts.positional[0] === 'rotate-token') {
662
- return runRotateTokenCommand();
663
- }
664
- if (opts.positional[0] === 'doctor') {
665
- return runDoctorCommand(opts);
666
- }
667
- if (opts.positional[0] === 'logs') {
668
- return runLogsCommand(opts);
669
- }
670
- if (opts.positional[0] === 'operator') {
671
- return runOperatorCommand(opts.positional[1], opts);
672
- }
673
- if (opts.positional[0] === 'observability') {
674
- return runObservabilityCommand(opts.positional[1], opts);
675
- }
676
- if (opts.positional[0] === 'ops') {
677
- return runOpsCommand(opts);
678
- }
679
- if (opts.positional[0] === 'service') {
680
- return runServiceCommand(opts.positional[1], opts);
681
- }
682
-
683
- if (opts.positional[0] === 'install') {
684
- console.log('wild-workspace manages the bmo-sync sync daemon for you.');
685
- console.log('');
686
- console.log('It starts automatically in the background whenever you run');
687
- console.log('`wild-workspace`, and keeps running after you close the browser —');
688
- console.log('so there is no separate install step.');
689
- console.log('');
690
- console.log(' wild-workspace daemon status check whether it is running');
691
- console.log(' wild-workspace daemon stop stop it');
692
- return;
693
- }
694
- if (opts.positional[0] === 'share') {
695
- console.log('Use the in-app Share button to issue viewer URLs. CLI share command is v1.x.');
696
- return;
697
- }
698
-
699
- // If a workspace server is already serving this port — always-on started it at
700
- // login, or another `wild-workspace` is running — don't fight it for the socket
701
- // (createServer would reject on EADDRINUSE and crash). Just open the browser to
702
- // the one already up. This is the common case now that always-on is real.
703
- {
704
- const probeCfg = buildConfig(opts);
705
- if (await probeHealth(probeCfg.port)) {
706
- const host = probeCfg.host === '0.0.0.0' ? '127.0.0.1' : probeCfg.host;
707
- const displayUrl = `http://${host}:${probeCfg.port}`; // shown without the token
708
- console.log(`\n wild-workspace is already running at ${displayUrl}`);
709
- if (probeCfg.openBrowser) {
710
- console.log(' opening it in your browser…');
711
- try { const open = (await import('open')).default; await open(localBrowserUrl(probeCfg)); } catch { /* best-effort */ }
712
- }
713
- return;
714
- }
715
- }
716
-
717
- const server = await createServer(opts);
718
- const { config } = server;
719
- console.log(`\n wild-workspace v${APP_VERSION}`);
720
- console.log(` workspace : ${config.workspaceDir}`);
721
- console.log(` url : http://${config.host}:${config.port}`);
722
- console.log(` agent : ${server.getActiveAgent()?.label || '(none detected — install Claude Code: npm i -g @anthropic-ai/claude-code)'}`);
723
-
724
- // Report how the sync daemon's autostart went (best-effort — never fatal).
725
- try {
726
- const d = await server.daemonReady;
727
- if (d.alreadyRunning) console.log(' sync : bmo-sync daemon already running');
728
- else if (d.started) console.log(` sync : bmo-sync daemon started (pid ${d.pid})`);
729
- else if (d.skipped) console.log(' sync : daemon autostart disabled');
730
- else if (d.error === 'daemon-binary-not-found')
731
- console.log(' sync : daemon binary not installed — sync is off (see `wild-workspace daemon`)');
732
- else if (d.error) console.log(` sync : daemon did not start — ${d.error}`);
733
- } catch {}
734
- console.log('');
735
-
736
- if (config.publicMode) {
737
- // Public mode denies anon — tell the owner how to reach it authenticated.
738
- console.log(` mode : PUBLIC opening with a one-time sign-in token`);
739
- }
740
- if (config.openBrowser) {
741
- try {
742
- const open = (await import('open')).default;
743
- open(localBrowserUrl(config));
744
- } catch {}
745
- }
746
-
747
- // Hard safety net: even if stop() ever wedges, never leave the user staring at
748
- // "shutting down…". Unref'd so the timer itself doesn't keep us alive.
749
- const forceExitSoon = () => { setTimeout(() => process.exit(0), 3000).unref(); };
750
- process.on('SIGINT', async () => {
751
- console.log('\nshutting down…');
752
- forceExitSoon();
753
- try { await server.stop(); } catch {}
754
- process.exit(0);
755
- });
756
- process.on('SIGTERM', async () => { forceExitSoon(); try { await server.stop(); } catch {} process.exit(0); });
757
- }
758
-
759
- main().catch((err) => {
760
- appendLine('cli', `FATAL ${err?.stack || err}`);
761
- console.error('wild-workspace failed:', err);
762
- process.exit(1);
763
- });
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
+
27
+ const __filename = url.fileURLToPath(import.meta.url);
28
+ const __dirname = path.dirname(__filename);
29
+
30
+ function printUsage() {
31
+ console.log(`wild-workspace v${APP_VERSION}
32
+
33
+ Usage:
34
+ wild-workspace start the workspace server in the current directory
35
+ wild-workspace --port 5173 override port (default 5173)
36
+ wild-workspace --no-open don't auto-open browser
37
+ wild-workspace --host 0.0.0.0 bind to all interfaces (for share-by-URL hosting)
38
+ wild-workspace daemon status is the bmo-sync daemon running?
39
+ wild-workspace daemon start start the sync daemon now
40
+ wild-workspace daemon stop stop the sync daemon
41
+ wild-workspace daemon conflicts list list open conflicts
42
+ wild-workspace daemon conflicts show <wid> <path> view one conflict
43
+ wild-workspace daemon conflicts resolve <wid> <path> <keep_mine|take_theirs>
44
+ wild-workspace login <payload> bind this install to a slug
45
+ (paste the blob from workspace.venturewild.llc)
46
+ wild-workspace logout clear the bound account (slug + token)
47
+ wild-workspace whoami show the currently-bound account
48
+ wild-workspace rotate-token mint a new account token; invalidates the old one
49
+ wild-workspace doctor [--share] diagnose this machine's install (✅/⚠️/❌ + logs)
50
+ wild-workspace logs [name] [--tail N] list logs, or tail one (cli/server/daemon/…)
51
+ wild-workspace operator enable let the wild-workspace team help with your install (mints a token)
52
+ wild-workspace operator disable revoke the support token
53
+ wild-workspace operator status is the support channel on?
54
+ wild-workspace observability [on|off|status] share session + install health so we can help (default on; never chat content)
55
+ wild-workspace service install keep your workspace always-on (starts at login, no admin)
56
+ wild-workspace service uninstall turn always-on off
57
+ wild-workspace service status show always-on status (installed? supervisor? server?)
58
+ wild-workspace install (info) how the sync daemon is managed
59
+ wild-workspace --help this message
60
+ wild-workspace --version print version
61
+
62
+ The bmo-sync sync daemon starts automatically in the background when you run
63
+ \`wild-workspace\`, and keeps running after you close the browser.
64
+
65
+ Environment:
66
+ WILD_WORKSPACE_PORT, WILD_WORKSPACE_HOST,
67
+ WILD_WORKSPACE_DIR, WILD_WORKSPACE_DATA_DIR,
68
+ WILD_WORKSPACE_PARTNER_TOKEN, WILD_WORKSPACE_SHARE_SECRET,
69
+ WILD_WORKSPACE_NO_OPEN=1, WILD_WORKSPACE_DAEMON_AUTOSTART=0
70
+ `);
71
+ }
72
+
73
+ function parseArgs(argv) {
74
+ const opts = {};
75
+ const positional = [];
76
+ for (let i = 0; i < argv.length; i++) {
77
+ const arg = argv[i];
78
+ if (arg === '--help' || arg === '-h') opts.help = true;
79
+ else if (arg === '--version' || arg === '-v') opts.version = true;
80
+ else if (arg === '--no-open') opts.openBrowser = false;
81
+ else if (arg === '--port') { opts.port = Number(argv[++i]); }
82
+ else if (arg === '--host') { opts.host = argv[++i]; }
83
+ else if (arg === '--workspace') { opts.workspaceDir = argv[++i]; }
84
+ else if (arg === '--tail') { opts.tail = Number(argv[++i]); }
85
+ else if (arg === '--share') { opts.share = true; }
86
+ else if (arg === '--rotate') { opts.rotate = true; }
87
+ else if (arg === '--kind') { opts.kind = argv[++i]; }
88
+ else if (arg === '--limit') { opts.limit = Number(argv[++i]); }
89
+ else if (arg.startsWith('--')) {
90
+ // ignore unknown flags
91
+ } else {
92
+ positional.push(arg);
93
+ }
94
+ }
95
+ opts.positional = positional;
96
+ return opts;
97
+ }
98
+
99
+ // `wild-workspace daemon [status|start|stop|conflicts ...]`
100
+ async function runDaemonCommand(action = 'status', rest = []) {
101
+ const config = buildConfig({});
102
+ const sup = new DaemonSupervisor({
103
+ httpBase: config.daemonHttpUrl,
104
+ // b-ii: so `wild-workspace daemon start` also opens the proxy link.
105
+ accountToken: config.accountToken,
106
+ serverUrl: config.bmoSyncServerUrl,
107
+ });
108
+
109
+ if (action === 'conflicts') {
110
+ return runConflictsCommand(config, rest);
111
+ }
112
+ if (action === 'status') {
113
+ const s = await sup.status();
114
+ console.log(`bmo-sync daemon: ${s.running ? 'running' : 'stopped'}`);
115
+ console.log(` api : ${s.httpBase}`);
116
+ if (s.pid) console.log(` pid : ${s.pid}`);
117
+ console.log(` log : ${s.logFile}`);
118
+ return;
119
+ }
120
+ if (action === 'start') {
121
+ const r = await sup.ensureRunning();
122
+ if (r.alreadyRunning) {
123
+ console.log('bmo-sync daemon: already running');
124
+ } else if (r.started) {
125
+ const healthy = await sup.waitForHealthy();
126
+ console.log(
127
+ healthy
128
+ ? `bmo-sync daemon: started (pid ${r.pid})`
129
+ : `bmo-sync daemon: launched (pid ${r.pid}) — not yet answering, check the log`,
130
+ );
131
+ } else if (r.error === 'daemon-binary-not-found') {
132
+ console.log('bmo-sync daemon: cannot start — the daemon binary is not installed.');
133
+ console.log(' build it from the bmo-sync workspace and place it under vendor/,');
134
+ console.log(' or install the @venturewild/workspace-daemon-<platform> package.');
135
+ } else {
136
+ console.log(`bmo-sync daemon: could not start — ${r.error}`);
137
+ }
138
+ return;
139
+ }
140
+ if (action === 'stop') {
141
+ const r = await sup.stop();
142
+ console.log(
143
+ r.stopped
144
+ ? `bmo-sync daemon: stopped (pid ${r.pid})`
145
+ : `bmo-sync daemon: not stopped — ${r.reason}`,
146
+ );
147
+ return;
148
+ }
149
+ console.log(`unknown daemon action: ${action} (use status | start | stop | conflicts)`);
150
+ }
151
+
152
+ // `wild-workspace daemon conflicts [list|show <wid> <path>|resolve <wid> <path> <action>]`
153
+ async function runConflictsCommand(config, args) {
154
+ const sync = new SyncControl({
155
+ daemonHttpUrl: config.daemonHttpUrl,
156
+ bmoSyncServerUrl: config.bmoSyncServerUrl,
157
+ });
158
+ const sub = (args[0] || 'list').toLowerCase();
159
+ if (sub === 'list' || !sub) {
160
+ const conflicts = await sync.listConflicts();
161
+ if (!conflicts.length) {
162
+ console.log('no open conflicts');
163
+ return;
164
+ }
165
+ console.log(`open conflicts: ${conflicts.length}`);
166
+ for (const c of conflicts) {
167
+ const detected = new Date((c.detectedAt || 0) * 1000).toISOString();
168
+ console.log(` [${c.workspaceId}] ${c.path}`);
169
+ console.log(` resolution : ${c.resolution}`);
170
+ console.log(` detected_at : ${detected}`);
171
+ if (c.peerBackOfficePath) {
172
+ console.log(` peer_bytes_at : ${c.peerBackOfficePath}`);
173
+ }
174
+ if (c.mineSha256) console.log(` mine_sha256 : ${c.mineSha256}`);
175
+ if (c.theirsSha256) console.log(` theirs_sha256 : ${c.theirsSha256}`);
176
+ }
177
+ return;
178
+ }
179
+ if (sub === 'show') {
180
+ const wid = args[1];
181
+ const path = args[2];
182
+ if (!wid || !path) {
183
+ console.log('usage: wild-workspace daemon conflicts show <workspace_id> <path>');
184
+ return;
185
+ }
186
+ const view = await sync.viewConflict(wid, path);
187
+ if (!view) {
188
+ console.log(`no open conflict for ${wid}:${path}`);
189
+ return;
190
+ }
191
+ console.log(JSON.stringify(view, null, 2));
192
+ return;
193
+ }
194
+ if (sub === 'resolve') {
195
+ const wid = args[1];
196
+ const path = args[2];
197
+ const action = args[3];
198
+ if (!wid || !path || !action) {
199
+ console.log(
200
+ 'usage: wild-workspace daemon conflicts resolve <workspace_id> <path> <keep_mine|take_theirs>',
201
+ );
202
+ return;
203
+ }
204
+ try {
205
+ await sync.resolveConflict(wid, path, action);
206
+ console.log(`resolved: ${wid}:${path} (${action})`);
207
+ } catch (e) {
208
+ console.error(`resolve failed: ${e.message || e}`);
209
+ process.exitCode = 1;
210
+ }
211
+ return;
212
+ }
213
+ console.log(
214
+ `unknown conflicts subcommand: ${sub} (use list | show <wid> <path> | resolve <wid> <path> <action>)`,
215
+ );
216
+ }
217
+
218
+ // `wild-workspace login <base64url-payload>` — bind this install to a slug.
219
+ // The payload comes from workspace.venturewild.llc on signup. It's an opaque
220
+ // blob the user copies once; we decode + persist + print a friendly summary.
221
+ async function runLoginCommand(args) {
222
+ if (!args.length) {
223
+ console.error('usage: wild-workspace login <payload>');
224
+ console.error('');
225
+ console.error('The payload is the blob you copied from workspace.venturewild.llc');
226
+ console.error('after claiming your slug. Run that signup, then come back here.');
227
+ process.exitCode = 1;
228
+ return;
229
+ }
230
+ // Trim the obvious "wild-workspace login " prefix in case the user pasted
231
+ // the whole command line, and surrounding quotes if a shell preserved them.
232
+ let payload = args.join(' ').trim();
233
+ payload = payload.replace(/^wild-workspace\s+login\s+/i, '').trim();
234
+ payload = payload.replace(/^['"]|['"]$/g, '');
235
+ let parsed;
236
+ try {
237
+ parsed = decodeLoginPayload(payload);
238
+ } catch (e) {
239
+ console.error(`Couldn't decode the login payload: ${e.message || e}`);
240
+ process.exitCode = 1;
241
+ return;
242
+ }
243
+ const config = buildConfig({});
244
+ let saved;
245
+ try {
246
+ saved = saveAccount(config.dataDir, parsed);
247
+ } catch (e) {
248
+ console.error(`Couldn't save account to ${config.dataDir}: ${e.message || e}`);
249
+ process.exitCode = 1;
250
+ return;
251
+ }
252
+ console.log(`✓ logged in as ${saved.email}`);
253
+ console.log(` slug : ${saved.slug}`);
254
+ console.log(` url : https://${saved.slug}.venturewild.llc (once the tunnel is configured)`);
255
+ console.log(` saved to : ${config.dataDir}/account.json`);
256
+ console.log('');
257
+ console.log('Run `wild-workspace` in any folder to start your workspace.');
258
+
259
+ // Arm always-on so the workspace comes back on its own (best-effort — never
260
+ // blocks login). On a platform without autostart yet, just nudge the user.
261
+ try {
262
+ const svc = await installService({
263
+ node: process.execPath, cli: __filename, workspaceDir: config.workspaceDir, port: config.port, version: APP_VERSION,
264
+ });
265
+ if (svc.installed) console.log(' always-on : enabled — starts at login (disable: wild-workspace service uninstall)');
266
+ else if (svc.supported === false) console.log(` always-on : not yet on ${svc.platform} — run \`wild-workspace\` to start it`);
267
+ } catch { /* never block login */ }
268
+ }
269
+
270
+ async function runLogoutCommand() {
271
+ const config = buildConfig({});
272
+ const before = loadAccount(config.dataDir);
273
+ const removed = clearAccount(config.dataDir);
274
+ if (!removed && !before) {
275
+ console.log('not logged in.');
276
+ return;
277
+ }
278
+ console.log(`logged out — cleared ${before?.email || 'account'} from ${config.dataDir}.`);
279
+ }
280
+
281
+ async function runRotateTokenCommand() {
282
+ const config = buildConfig({});
283
+ const account = loadAccount(config.dataDir);
284
+ if (!account) {
285
+ console.log('not logged in. Nothing to rotate.');
286
+ console.log('Run `wild-workspace login <payload>` after claiming a slug.');
287
+ process.exitCode = 1;
288
+ return;
289
+ }
290
+ const url = `${config.bmoSyncServerUrl.replace(/\/$/, '')}/api/account/rotate-token`;
291
+ let resp;
292
+ try {
293
+ resp = await fetch(url, {
294
+ method: 'POST',
295
+ headers: { authorization: `Bearer ${account.accountToken}` },
296
+ });
297
+ } catch (e) {
298
+ console.error(`Couldn't reach ${config.bmoSyncServerUrl}: ${e.message || e}`);
299
+ process.exitCode = 2;
300
+ return;
301
+ }
302
+ if (!resp.ok) {
303
+ let body;
304
+ try { body = await resp.text(); } catch { body = ''; }
305
+ console.error(`Rotate failed (HTTP ${resp.status}): ${body.slice(0, 200)}`);
306
+ process.exitCode = 1;
307
+ return;
308
+ }
309
+ let payload;
310
+ try {
311
+ payload = await resp.json();
312
+ } catch (e) {
313
+ console.error(`Server returned non-JSON: ${e.message || e}`);
314
+ process.exitCode = 1;
315
+ return;
316
+ }
317
+ if (!payload || !payload.account_token) {
318
+ console.error('Server response missing account_token. Aborting.');
319
+ process.exitCode = 1;
320
+ return;
321
+ }
322
+ const updated = {
323
+ ...account,
324
+ accountToken: payload.account_token,
325
+ loggedInAt: Date.now(),
326
+ };
327
+ try {
328
+ saveAccount(config.dataDir, updated);
329
+ } catch (e) {
330
+ console.error(`Got new token but failed to persist it: ${e.message || e}`);
331
+ console.error(`New token (save manually): ${payload.account_token}`);
332
+ process.exitCode = 1;
333
+ return;
334
+ }
335
+ console.log('✓ token rotated.');
336
+ console.log(` slug : ${updated.slug}`);
337
+ console.log(` saved to : ${config.dataDir}/account.json`);
338
+ console.log('');
339
+ console.log('Your daemon will reconnect with the new token automatically');
340
+ console.log('on its next restart. Run `wild-workspace daemon stop && wild-workspace`');
341
+ console.log('to force an immediate reconnect.');
342
+ }
343
+
344
+ async function runWhoamiCommand() {
345
+ const config = buildConfig({});
346
+ const account = loadAccount(config.dataDir);
347
+ if (!account) {
348
+ console.log('not logged in.');
349
+ console.log('Run `wild-workspace login <payload>` after claiming a slug at workspace.venturewild.llc.');
350
+ return;
351
+ }
352
+ console.log(`logged in as ${account.email}`);
353
+ console.log(` slug : ${account.slug}`);
354
+ console.log(` accountId : ${account.accountId}`);
355
+ if (account.displayName) console.log(` name : ${account.displayName}`);
356
+ console.log(` loggedIn : ${new Date(account.loggedInAt).toISOString()}`);
357
+ }
358
+
359
+ // `wild-workspace doctor [--share]` — one diagnostic of this machine's install.
360
+ // Prints ✅/⚠️/❌ per check + where the logs are, and writes a JSON bundle under
361
+ // ~/.wild-workspace/diagnostics/. Exits non-zero if any check failed.
362
+ async function runDoctorCommand(opts) {
363
+ const config = buildConfig(opts);
364
+ const report = await runDoctor({ config });
365
+ console.log(renderDoctor(report));
366
+ const bundle = writeDoctorBundle(report);
367
+ if (bundle) {
368
+ console.log('');
369
+ console.log(`Full report: ${bundle}`);
370
+ }
371
+ if (opts.share) {
372
+ const shared = await shareDoctor(config, report);
373
+ console.log(
374
+ shared.ok
375
+ ? '✓ shared with the wild-workspace team — they can see this diagnostic now.'
376
+ : `Couldn't auto-share (${shared.reason}). Send the file above instead.`,
377
+ );
378
+ }
379
+ process.exitCode = report.summary.fail > 0 ? 1 : 0;
380
+ }
381
+
382
+ // Upload a doctor report to bmo-sync. `--share` is an explicit user action, so it
383
+ // goes even if the passive observability feed is off — but still respects the
384
+ // hard kill switch and needs an account to key it to.
385
+ async function shareDoctor(config, report) {
386
+ if (!config.accountToken) return { ok: false, reason: 'not logged in' };
387
+ if (process.env.WILD_WORKSPACE_NO_TELEMETRY === '1') return { ok: false, reason: 'telemetry disabled' };
388
+ const url = `${config.bmoSyncServerUrl.replace(/\/$/, '')}/api/telemetry`;
389
+ const ctrl = new AbortController();
390
+ const t = setTimeout(() => ctrl.abort(), 5000);
391
+ try {
392
+ const res = await fetch(url, {
393
+ method: 'POST',
394
+ headers: { 'content-type': 'application/json' },
395
+ body: JSON.stringify({
396
+ account_token: config.accountToken,
397
+ slug: config.account?.slug || null,
398
+ workspace_id: config.workspaceId,
399
+ kind: 'doctor-share',
400
+ doctor: report,
401
+ sent_at: Math.floor(Date.now() / 1000),
402
+ }),
403
+ signal: ctrl.signal,
404
+ });
405
+ return { ok: res.ok, reason: res.ok ? null : `HTTP ${res.status}` };
406
+ } catch (e) {
407
+ return { ok: false, reason: String(e?.message || e) };
408
+ } finally {
409
+ clearTimeout(t);
410
+ }
411
+ }
412
+
413
+ // `wild-workspace logs [name] [--tail N]` — list the logs, or tail one by name.
414
+ async function runLogsCommand(opts) {
415
+ const name = opts.positional[1];
416
+ const n = opts.tail || 40;
417
+ const logs = listLogs();
418
+ if (!name) {
419
+ console.log(`logs dir: ${path.dirname(logs[0].file)}`);
420
+ for (const l of logs) {
421
+ console.log(` ${l.name.padEnd(10)} ${l.exists ? `${l.size} bytes` : '(none yet)'} ${l.file}`);
422
+ }
423
+ console.log('');
424
+ console.log(`Show one: wild-workspace logs <name> [--tail N] (names: ${logs.map((l) => l.name).join(', ')})`);
425
+ return;
426
+ }
427
+ const match = logs.find((l) => l.name === name);
428
+ if (!match) {
429
+ console.log(`unknown log "${name}". names: ${logs.map((l) => l.name).join(', ')}`);
430
+ process.exitCode = 1;
431
+ return;
432
+ }
433
+ console.log(`# ${match.name} — ${match.file} (last ${n} lines)`);
434
+ console.log(tailFile(match.file, n) || '(empty)');
435
+ }
436
+
437
+ // `wild-workspace ops <slug>` — OPERATOR read of a user's observability feed.
438
+ // Reads the bmo-sync admin endpoint with the admin key at ~/.bmo-sync-admin-key
439
+ // (so it only works on an operator's machine). This is how we see a stuck/broken
440
+ // user without them having to ask. Filters: --kind feed|install-down|transcript,
441
+ // --limit N.
442
+ async function runOpsCommand(opts) {
443
+ const slug = opts.positional[1];
444
+ if (!slug) {
445
+ console.error('usage: wild-workspace ops <slug> [--kind feed|install-down|transcript] [--limit N]');
446
+ process.exitCode = 1;
447
+ return;
448
+ }
449
+ const config = buildConfig(opts);
450
+ const keyPath = path.join(os.homedir(), '.bmo-sync-admin-key');
451
+ let adminKey;
452
+ try {
453
+ adminKey = readFileSync(keyPath, 'utf8').trim();
454
+ } catch {
455
+ console.error(`No admin key at ${keyPath} — \`ops\` is an operator-only command.`);
456
+ process.exitCode = 1;
457
+ return;
458
+ }
459
+ const params = new URLSearchParams();
460
+ if (opts.kind) params.set('kind', opts.kind);
461
+ params.set('limit', String(opts.limit || 50));
462
+ const url = `${config.bmoSyncServerUrl.replace(/\/$/, '')}/api/admin/accounts/${encodeURIComponent(slug)}/activity?${params}`;
463
+ let res;
464
+ try {
465
+ res = await fetch(url, { headers: { 'x-admin-key': adminKey } });
466
+ } catch (e) {
467
+ console.error(`Couldn't reach ${config.bmoSyncServerUrl}: ${e.message || e}`);
468
+ process.exitCode = 2;
469
+ return;
470
+ }
471
+ if (res.status === 404) {
472
+ console.log(`No account/slug "${slug}" yet (unclaimed, or it hasn't reported).`);
473
+ return;
474
+ }
475
+ if (res.status === 403 || res.status === 401) {
476
+ console.error('admin key rejected.');
477
+ process.exitCode = 1;
478
+ return;
479
+ }
480
+ if (!res.ok) {
481
+ console.error(`HTTP ${res.status}`);
482
+ process.exitCode = 1;
483
+ return;
484
+ }
485
+ const data = await res.json();
486
+ console.log(`account ${data.account_id}${data.slug ? ` (${data.slug})` : ''} — ${data.count} recent event(s)`);
487
+ for (const r of data.rows || []) {
488
+ const when = new Date((r.reported_at || 0) * 1000).toISOString();
489
+ let detail = '';
490
+ if (r.kind === 'feed' && Array.isArray(r.payload?.events)) {
491
+ const counts = {};
492
+ for (const e of r.payload.events) counts[e.type] = (counts[e.type] || 0) + 1;
493
+ detail = Object.entries(counts).map(([k, v]) => `${k}×${v}`).join(' ');
494
+ } else if ((r.kind === 'install-down' || r.kind === 'doctor-share') && r.payload?.doctor?.summary) {
495
+ const s = r.payload.doctor.summary;
496
+ detail = `doctor: ${s.fail} fail / ${s.warn} warn`;
497
+ } else if (r.kind === 'transcript') {
498
+ detail = `transcript ${r.payload?.date || ''} (${(r.payload?.markdown || '').length} chars)`;
499
+ }
500
+ console.log(` ${when} [${r.kind}]${r.os ? ` ${r.os}` : ''} ${detail}`);
501
+ }
502
+ }
503
+
504
+ // `wild-workspace observability [on|off|status]` — the consented session +
505
+ // install-health feed (default ON). Streams WHAT happened + install health to the
506
+ // Venturewild team so they can help if something breaks — never your chat words
507
+ // (that's the separate transcript channel). See observability.mjs.
508
+ async function runObservabilityCommand(action = 'status', opts = {}) {
509
+ const config = buildConfig(opts);
510
+ if (action === 'on' || action === 'off') {
511
+ const rec = setObservabilityConsent(config.dataDir, action === 'on');
512
+ console.log(
513
+ rec.enabled
514
+ ? '✓ observability ON — the Venturewild team can see how your workspace is doing'
515
+ : '✓ observability OFF — no session/health feed leaves this machine.',
516
+ );
517
+ if (rec.enabled) {
518
+ console.log(' (events + install health only — never your chat content)');
519
+ }
520
+ console.log(' Applies the next time your workspace starts (or toggle it live in the app).');
521
+ return;
522
+ }
523
+ const rec = loadObservabilityConsent(config.dataDir);
524
+ console.log(`observability: ${rec.enabled ? 'ON' : 'OFF'}${rec.decidedAt ? '' : ' (default)'}`);
525
+ console.log(' what : events + install health → lets us help if something breaks (never chat content)');
526
+ console.log(' toggle: wild-workspace observability on | off');
527
+ return;
528
+ }
529
+
530
+ // `wild-workspace operator [enable|disable|status]` — the consented support
531
+ // channel (docs/SECURITY.md). OFF by default; `enable` mints a token to hand to
532
+ // the wild-workspace team so they can diagnose + run a fixed set of safe fixes.
533
+ async function runOperatorCommand(action = 'status', opts = {}) {
534
+ const config = buildConfig(opts);
535
+ if (action === 'enable') {
536
+ const token = enableOperator(config.dataDir, { rotate: opts.rotate });
537
+ if (!token) {
538
+ console.error(`Could not enable operator channel (couldn't write to ${config.dataDir}).`);
539
+ process.exitCode = 1;
540
+ return;
541
+ }
542
+ const slug = config.account?.slug;
543
+ console.log('✓ operator channel enabled (consented support access).');
544
+ console.log(` token : ${token}`);
545
+ console.log(' Share this token with the wild-workspace team so they can help with your install.');
546
+ if (slug) console.log(` reach : https://${slug}.venturewild.llc/api/operator/diag (Authorization: Bearer <token>)`);
547
+ console.log(' off : wild-workspace operator disable');
548
+ console.log('');
549
+ console.log(' Scope: read diagnostics + a fixed set of safe fixes (restart sync, re-detect');
550
+ console.log(' Claude, re-link your account, reinstall the sync daemon). It cannot run');
551
+ console.log(' arbitrary commands or drive your agent, and every action is logged.');
552
+ return;
553
+ }
554
+ if (action === 'disable') {
555
+ const removed = disableOperator(config.dataDir);
556
+ console.log(removed ? '✓ operator channel disabled (token revoked).' : 'operator channel was not enabled.');
557
+ return;
558
+ }
559
+ if (action === 'status') {
560
+ const s = operatorStatus(config.dataDir);
561
+ console.log(`operator channel: ${s.enabled ? 'ENABLED' : 'disabled'}`);
562
+ console.log(` token file: ${s.file}`);
563
+ console.log(s.enabled ? ' disable : wild-workspace operator disable' : ' enable : wild-workspace operator enable');
564
+ return;
565
+ }
566
+ console.log(`unknown operator action: ${action} (use enable | disable | status)`);
567
+ }
568
+
569
+ // `wild-workspace service [install|uninstall|status|run]` — the always-on
570
+ // autostart (docs/always-on-design.md). `run` is the HIDDEN supervisor entry
571
+ // the per-OS launcher invokes at login; the others manage registration. All
572
+ // per-user, no admin.
573
+ // The URL to open for the LOCAL owner. A slug-linked install runs in public
574
+ // mode (the server denies anon — C1), so the owner must authenticate: append
575
+ // the partner token, which the SPA immediately exchanges for an HttpOnly cookie
576
+ // and strips from the address bar (S1). A localhost-only install needs no token.
577
+ // The token is only ever placed in the URL we hand the browser — never printed.
578
+ function localBrowserUrl(config) {
579
+ const host = config.host === '0.0.0.0' ? '127.0.0.1' : config.host;
580
+ const base = `http://${host}:${config.port}`;
581
+ return config.publicMode ? `${base}/?t=${encodeURIComponent(config.partnerToken)}` : base;
582
+ }
583
+
584
+ // Ask the running local server (over genuine loopback) for a one-time sign-in
585
+ // link to the PUBLIC url. Returns the URL or null (no slug / older server).
586
+ async function fetchPublicBootstrapUrl(config) {
587
+ const host = config.host === '0.0.0.0' ? '127.0.0.1' : config.host;
588
+ try {
589
+ const ac = new AbortController();
590
+ const t = setTimeout(() => ac.abort(), 4000);
591
+ const r = await fetch(`http://${host}:${config.port}/api/auth/bootstrap`, {
592
+ method: 'POST',
593
+ signal: ac.signal,
594
+ });
595
+ clearTimeout(t);
596
+ if (!r.ok) return null;
597
+ const body = await r.json().catch(() => ({}));
598
+ return typeof body.url === 'string' ? body.url : null;
599
+ } catch {
600
+ return null;
601
+ }
602
+ }
603
+
604
+ // Poll the public url's /api/health until the tunnel forwards (200) or we give
605
+ // up so we never open the owner onto a 502 "warming up" page.
606
+ async function publicTunnelReady(shareBaseUrl, { tries = 6, gapMs = 1300 } = {}) {
607
+ const base = String(shareBaseUrl || '').replace(/\/$/, '');
608
+ if (!/^https?:\/\//.test(base)) return false;
609
+ for (let i = 0; i < tries; i += 1) {
610
+ try {
611
+ const ac = new AbortController();
612
+ const t = setTimeout(() => ac.abort(), 2500);
613
+ const r = await fetch(`${base}/api/health`, { signal: ac.signal });
614
+ clearTimeout(t);
615
+ if (r.ok) return true;
616
+ } catch { /* not up yet */ }
617
+ if (i < tries - 1) await new Promise((res) => setTimeout(res, gapMs));
618
+ }
619
+ return false;
620
+ }
621
+
622
+ // Open the owner's browser the friendliest way for THIS install:
623
+ // - slug-linked + public: land them signed-in on <slug>.venturewild.llc (their
624
+ // real, bookmarkable home) via a one-time bootstrap link, once the tunnel is
625
+ // confirmed up. If it isn't ready yet, fall back to localhost (always works
626
+ // locally) and tell them their public url is warming up.
627
+ // - localhost-only: just open localhost.
628
+ // Tokens only ever reach the browser via open() — never printed to stdout (B1/S1).
629
+ async function openOwnerBrowser(config) {
630
+ let open;
631
+ try { open = (await import('open')).default; } catch { return; }
632
+ const slugLinked = config.publicMode && config.account?.slug && config.shareBaseUrl;
633
+ if (slugLinked) {
634
+ const url = await fetchPublicBootstrapUrl(config);
635
+ if (url && (await publicTunnelReady(config.shareBaseUrl))) {
636
+ console.log(` opening your workspace at ${config.shareBaseUrl} …`);
637
+ try { await open(url); } catch { /* best-effort */ }
638
+ return;
639
+ }
640
+ // Tunnel not up yet (or older server) — open locally so first run is never a
641
+ // dead page; the public url comes alive on its own as the daemon links.
642
+ try { await open(localBrowserUrl(config)); } catch { /* best-effort */ }
643
+ console.log(` your workspace will be live at ${config.shareBaseUrl} shortly (warming up the tunnel)…`);
644
+ return;
645
+ }
646
+ try { await open(localBrowserUrl(config)); } catch { /* best-effort */ }
647
+ }
648
+
649
+ async function runServiceCommand(action = 'status', opts = {}) {
650
+ const config = buildConfig(opts);
651
+
652
+ if (action === 'install') {
653
+ const r = await installService({
654
+ node: process.execPath, cli: __filename, workspaceDir: config.workspaceDir, port: config.port, version: APP_VERSION,
655
+ });
656
+ if (!r.installed) {
657
+ console.log(`service install: ${r.message || 'not supported on this platform'}`);
658
+ process.exitCode = 1;
659
+ return;
660
+ }
661
+ console.log('✓ always-on enabled — your workspace starts automatically at login.');
662
+ console.log(` mechanism : ${r.mechanism} (per-user, no admin)`);
663
+ console.log(` launcher : ${r.launcher || r.vbs}`);
664
+ console.log(` workspace : ${config.workspaceDir}`);
665
+ if (r.note) console.log(` note : ${r.note}`);
666
+ console.log(' disable : wild-workspace service uninstall');
667
+ return;
668
+ }
669
+
670
+ if (action === 'uninstall') {
671
+ const r = await uninstallService();
672
+ if (r.supported === false) { console.log(`service: nothing to do — autostart not implemented for ${r.platform} yet`); return; }
673
+ console.log(`✓ always-on disabled${r.removedKey ? '' : ' (no autostart entry was set)'}${r.stoppedPid ? ` — stopped supervisor pid ${r.stoppedPid}` : ''}.`);
674
+ return;
675
+ }
676
+
677
+ if (action === 'status') {
678
+ const s = await serviceStatus({ port: config.port }, { probeImpl: (p) => probeHealth(p) });
679
+ if (s.supported === false) { console.log(`always-on: autostart not implemented for ${s.platform} yet (run \`wild-workspace\` manually)`); return; }
680
+ console.log(`always-on : ${s.installed ? 'installed' : 'NOT installed'}`);
681
+ if (s.runValue) console.log(` run entry : ${s.runValue}`);
682
+ console.log(` supervisor: ${s.supervisorAlive ? `running (pid ${s.supervisorPid})` : 'not running'}`);
683
+ console.log(` server : ${s.serverUp ? `up on http://127.0.0.1:${config.port}` : 'down'}`);
684
+ return;
685
+ }
686
+
687
+ if (action === 'run') {
688
+ // Hidden entrypoint launched by the autostart VBS at login. The OS gives us
689
+ // an arbitrary cwd, so read the workspace dir persisted at install time.
690
+ let svc = {};
691
+ try { svc = JSON.parse(readFileSync(path.join(globalDir(), 'service.json'), 'utf8')); } catch { /* fall back to config */ }
692
+ const sup = new WorkspaceSupervisor({
693
+ workspaceDir: svc.workspaceDir || config.workspaceDir,
694
+ port: svc.port || config.port,
695
+ });
696
+ const r = sup.start();
697
+ if (!r.started) process.exit(0); // another supervisor already owns the lock
698
+ // else: the supervision interval keeps this process alive.
699
+ return;
700
+ }
701
+
702
+ console.log(`unknown service action: ${action} (use install | uninstall | status | run)`);
703
+ }
704
+
705
+ async function main() {
706
+ // First-run capture: log every invocation BEFORE doing anything, so even a
707
+ // crash-on-start leaves a trace we (or `wild-workspace doctor`) can read.
708
+ appendLine('cli', `invoke argv=[${process.argv.slice(2).join(' ')}] cwd=${process.cwd()} node=${process.version} v${APP_VERSION}`);
709
+ const opts = parseArgs(process.argv.slice(2));
710
+ if (opts.help) return printUsage();
711
+ if (opts.version) { console.log(APP_VERSION); return; }
712
+
713
+ if (opts.positional[0] === 'daemon') {
714
+ return runDaemonCommand(opts.positional[1], opts.positional.slice(2));
715
+ }
716
+
717
+ if (opts.positional[0] === 'login') {
718
+ return runLoginCommand(opts.positional.slice(1));
719
+ }
720
+ if (opts.positional[0] === 'logout') {
721
+ return runLogoutCommand();
722
+ }
723
+ if (opts.positional[0] === 'whoami') {
724
+ return runWhoamiCommand();
725
+ }
726
+ if (opts.positional[0] === 'rotate-token') {
727
+ return runRotateTokenCommand();
728
+ }
729
+ if (opts.positional[0] === 'doctor') {
730
+ return runDoctorCommand(opts);
731
+ }
732
+ if (opts.positional[0] === 'logs') {
733
+ return runLogsCommand(opts);
734
+ }
735
+ if (opts.positional[0] === 'operator') {
736
+ return runOperatorCommand(opts.positional[1], opts);
737
+ }
738
+ if (opts.positional[0] === 'observability') {
739
+ return runObservabilityCommand(opts.positional[1], opts);
740
+ }
741
+ if (opts.positional[0] === 'ops') {
742
+ return runOpsCommand(opts);
743
+ }
744
+ if (opts.positional[0] === 'service') {
745
+ return runServiceCommand(opts.positional[1], opts);
746
+ }
747
+
748
+ if (opts.positional[0] === 'install') {
749
+ console.log('wild-workspace manages the bmo-sync sync daemon for you.');
750
+ console.log('');
751
+ console.log('It starts automatically in the background whenever you run');
752
+ console.log('`wild-workspace`, and keeps running after you close the browser —');
753
+ console.log('so there is no separate install step.');
754
+ console.log('');
755
+ console.log(' wild-workspace daemon status check whether it is running');
756
+ console.log(' wild-workspace daemon stop stop it');
757
+ return;
758
+ }
759
+ if (opts.positional[0] === 'share') {
760
+ console.log('Use the in-app Share button to issue viewer URLs. CLI share command is v1.x.');
761
+ return;
762
+ }
763
+
764
+ // If a workspace server is already serving this port — always-on started it at
765
+ // login, or another `wild-workspace` is running — don't fight it for the socket
766
+ // (createServer would reject on EADDRINUSE and crash). Just open the browser to
767
+ // the one already up. This is the common case now that always-on is real.
768
+ {
769
+ const probeCfg = buildConfig(opts);
770
+ if (await probeHealth(probeCfg.port)) {
771
+ const host = probeCfg.host === '0.0.0.0' ? '127.0.0.1' : probeCfg.host;
772
+ const displayUrl = `http://${host}:${probeCfg.port}`; // shown without the token
773
+ console.log(`\n wild-workspace is already running at ${displayUrl}`);
774
+ if (probeCfg.openBrowser) {
775
+ console.log(' opening it in your browser…');
776
+ await openOwnerBrowser(probeCfg);
777
+ }
778
+ return;
779
+ }
780
+ }
781
+
782
+ const server = await createServer(opts);
783
+ const { config } = server;
784
+ console.log(`\n wild-workspace v${APP_VERSION}`);
785
+ console.log(` workspace : ${config.workspaceDir}`);
786
+ console.log(` url : http://${config.host}:${config.port}`);
787
+ console.log(` agent : ${server.getActiveAgent()?.label || '(none detected — install Claude Code: npm i -g @anthropic-ai/claude-code)'}`);
788
+
789
+ // Report how the sync daemon's autostart went (best-effort — never fatal).
790
+ try {
791
+ const d = await server.daemonReady;
792
+ if (d.alreadyRunning) console.log(' sync : bmo-sync daemon already running');
793
+ else if (d.started) console.log(` sync : bmo-sync daemon started (pid ${d.pid})`);
794
+ else if (d.skipped) console.log(' sync : daemon autostart disabled');
795
+ else if (d.error === 'daemon-binary-not-found')
796
+ console.log(' sync : daemon binary not installed — sync is off (see `wild-workspace daemon`)');
797
+ else if (d.error) console.log(` sync : daemon did not start — ${d.error}`);
798
+ } catch {}
799
+ console.log('');
800
+
801
+ if (config.publicMode) {
802
+ // Public mode denies anon — tell the owner how to reach it authenticated.
803
+ console.log(` mode : PUBLIC — opening with a one-time sign-in token`);
804
+ }
805
+ if (config.openBrowser) {
806
+ await openOwnerBrowser(config);
807
+ }
808
+
809
+ // Hard safety net: even if stop() ever wedges, never leave the user staring at
810
+ // "shutting down…". Unref'd so the timer itself doesn't keep us alive.
811
+ const forceExitSoon = () => { setTimeout(() => process.exit(0), 3000).unref(); };
812
+ process.on('SIGINT', async () => {
813
+ console.log('\nshutting down…');
814
+ forceExitSoon();
815
+ try { await server.stop(); } catch {}
816
+ process.exit(0);
817
+ });
818
+ process.on('SIGTERM', async () => { forceExitSoon(); try { await server.stop(); } catch {} process.exit(0); });
819
+ }
820
+
821
+ main().catch((err) => {
822
+ appendLine('cli', `FATAL ${err?.stack || err}`);
823
+ console.error('wild-workspace failed:', err);
824
+ process.exit(1);
825
+ });