@venturewild/workspace 0.3.7 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +112 -112
- package/package.json +83 -83
- package/server/bin/wild-workspace.mjs +995 -995
- package/server/src/account.mjs +114 -114
- package/server/src/agent-login.mjs +146 -146
- package/server/src/agent-readiness.mjs +200 -200
- package/server/src/agent.mjs +468 -468
- package/server/src/bazaar/core.mjs +579 -579
- package/server/src/bazaar/index.mjs +75 -75
- package/server/src/bazaar/mcp-server.mjs +328 -328
- package/server/src/bazaar/mock-tickup.mjs +97 -97
- package/server/src/bazaar/preview-server.mjs +95 -95
- package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -23
- package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -24
- package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -29
- package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -25
- package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -21
- package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -24
- package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -31
- package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -25
- package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -79
- package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +32 -32
- package/server/src/canvas/core.mjs +446 -421
- package/server/src/canvas/index.mjs +42 -42
- package/server/src/canvas/mcp-server.mjs +253 -253
- package/server/src/canvas-rails.mjs +108 -0
- package/server/src/config.mjs +404 -404
- package/server/src/daemon-bin.mjs +110 -110
- package/server/src/daemon-supervisor.mjs +285 -285
- package/server/src/doctor.mjs +375 -375
- package/server/src/inbox.mjs +86 -86
- package/server/src/index.mjs +2551 -2475
- package/server/src/logpaths.mjs +98 -98
- package/server/src/observability.mjs +45 -45
- package/server/src/operator.mjs +92 -92
- package/server/src/pairing.mjs +137 -137
- package/server/src/service.mjs +515 -515
- package/server/src/session-reporter.mjs +201 -201
- package/server/src/settings.mjs +145 -145
- package/server/src/share.mjs +182 -182
- package/server/src/skills.mjs +213 -213
- package/server/src/supervisor.mjs +647 -647
- package/server/src/support-consent.mjs +133 -133
- package/server/src/sync.mjs +248 -248
- package/server/src/transcript.mjs +121 -121
- package/server/src/turn-mcp.mjs +46 -46
- package/server/src/usage.mjs +405 -405
- package/web/dist/assets/index-B44y93r4.js +91 -0
- package/web/dist/assets/index-NXZN2LU2.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BxRx8EsD.js +0 -91
- package/web/dist/assets/index-DoOPBr3s.css +0 -1
package/server/src/doctor.mjs
CHANGED
|
@@ -1,375 +1,375 @@
|
|
|
1
|
-
// `wild-workspace doctor` — one pre/post-flight diagnostic for a real user's
|
|
2
|
-
// machine. The riskiest moment for a brand-new (non-technical) user is the
|
|
3
|
-
// install itself: no Claude yet, wrong Node, a busy port, a daemon binary that
|
|
4
|
-
// didn't resolve, an unclaimed slug. When something breaks we need to SEE it —
|
|
5
|
-
// ideally fix it — without making them feel stupid (docs/user-experience.md §5).
|
|
6
|
-
//
|
|
7
|
-
// runDoctor() returns a structured report (every check is { id, label, status,
|
|
8
|
-
// detail, hint }); the CLI renders it with ✅/⚠️/❌ and the operator channel
|
|
9
|
-
// serves the same JSON. Every external touch-point (agent detect, auth probe,
|
|
10
|
-
// daemon resolve, port check, account load, service status, registry fetch) is
|
|
11
|
-
// an injected seam so the test suite never spawns a process or hits the network.
|
|
12
|
-
|
|
13
|
-
import os from 'node:os';
|
|
14
|
-
import fs from 'node:fs';
|
|
15
|
-
import path from 'node:path';
|
|
16
|
-
|
|
17
|
-
import { buildConfig, APP_VERSION } from './config.mjs';
|
|
18
|
-
import { detectAgents, pickDefaultAgent } from './agent.mjs';
|
|
19
|
-
import { probeAgentReadiness } from './agent-readiness.mjs';
|
|
20
|
-
import { resolveDaemonBinary, resolveDaemonVersion, expectedDaemonVersion } from './daemon-bin.mjs';
|
|
21
|
-
import { checkPort } from './preview.mjs';
|
|
22
|
-
import { loadAccount } from './account.mjs';
|
|
23
|
-
import { serviceStatus } from './service.mjs';
|
|
24
|
-
import { probeHealth, probeHealthVersion } from './supervisor.mjs';
|
|
25
|
-
import { listLogs, diagnosticsDir, globalDir } from './logpaths.mjs';
|
|
26
|
-
|
|
27
|
-
// The daemon version the currently-RUNNING daemon was spawned under — the marker
|
|
28
|
-
// the supervisor writes to ~/.wild-workspace/daemon-runtime.json (the daemon's
|
|
29
|
-
// own /health reports no version). null when unread (never started / no marker).
|
|
30
|
-
function readRunningDaemonVersion(env = process.env) {
|
|
31
|
-
try {
|
|
32
|
-
const file = path.join(globalDir(env), 'daemon-runtime.json');
|
|
33
|
-
const v = JSON.parse(fs.readFileSync(file, 'utf8'))?.daemonVersion;
|
|
34
|
-
return typeof v === 'string' ? v : null;
|
|
35
|
-
} catch {
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const STATUS_ICON = { ok: '✅', warn: '⚠️', fail: '❌', info: 'ℹ️' };
|
|
41
|
-
|
|
42
|
-
// Native installer is Claude's canonical path today (npm i -g still works as a
|
|
43
|
-
// fallback). Shown verbatim to the user, so keep it copy-pasteable.
|
|
44
|
-
const CLAUDE_INSTALL_HINT =
|
|
45
|
-
'Install Claude Code: curl -fsSL https://claude.ai/install.sh | bash (Windows: irm https://claude.ai/install.ps1 | iex)';
|
|
46
|
-
|
|
47
|
-
function nodeMajor(version = process.version) {
|
|
48
|
-
const m = /^v?(\d+)/.exec(String(version));
|
|
49
|
-
return m ? Number(m[1]) : 0;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// RC3: probe the LIVE public tunnel end-to-end — out to Cloudflare, through the
|
|
53
|
-
// relay, down the daemon's tunnel, back to this server. This is the check the old
|
|
54
|
-
// `doctor` lacked: it only resolved the slug in the registry (claimed in the DB),
|
|
55
|
-
// which stays green even when `<slug>.venturewild.llc` is 502. A 200 here proves
|
|
56
|
-
// the whole chain works; a 5xx/timeout is the exact RC2 "linked but unreachable".
|
|
57
|
-
async function probeTunnel(slug, fetchImpl, timeoutMs = 8000) {
|
|
58
|
-
const url = `https://${encodeURIComponent(slug)}.venturewild.llc/api/health`;
|
|
59
|
-
const ctrl = new AbortController();
|
|
60
|
-
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
61
|
-
try {
|
|
62
|
-
const res = await fetchImpl(url, { signal: ctrl.signal, headers: { 'cache-control': 'no-cache' } });
|
|
63
|
-
let version = null;
|
|
64
|
-
try { version = (await res.json())?.version || null; } catch { /* non-JSON */ }
|
|
65
|
-
return { reachable: true, status: res.status, version, url };
|
|
66
|
-
} catch (e) {
|
|
67
|
-
return { reachable: false, error: String(e?.message || e), url };
|
|
68
|
-
} finally {
|
|
69
|
-
clearTimeout(timer);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Reach the bmo-sync registry: resolve the user's slug if linked, else /health.
|
|
74
|
-
async function probeRegistry(config, fetchImpl) {
|
|
75
|
-
const base = String(config.bmoSyncServerUrl || '').replace(/\/$/, '');
|
|
76
|
-
const slug = config.account?.slug || null;
|
|
77
|
-
const url = slug
|
|
78
|
-
? `${base}/api/slug/resolve/${encodeURIComponent(slug)}`
|
|
79
|
-
: `${base}/api/health`;
|
|
80
|
-
const ctrl = new AbortController();
|
|
81
|
-
const timer = setTimeout(() => ctrl.abort(), 5000);
|
|
82
|
-
try {
|
|
83
|
-
const res = await fetchImpl(url, { signal: ctrl.signal });
|
|
84
|
-
return { reachable: true, status: res.status, slug, url };
|
|
85
|
-
} catch (e) {
|
|
86
|
-
return { reachable: false, error: String(e?.message || e), slug, url };
|
|
87
|
-
} finally {
|
|
88
|
-
clearTimeout(timer);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Run every diagnostic check. All deps are injectable for testing.
|
|
94
|
-
* @returns {{version,generatedAt,platform,summary,checks,logs}}
|
|
95
|
-
*/
|
|
96
|
-
export async function runDoctor(opts = {}, deps = {}) {
|
|
97
|
-
const config = opts.config || deps.config || buildConfig({});
|
|
98
|
-
const env = deps.env || process.env;
|
|
99
|
-
const d = {
|
|
100
|
-
detectAgents: deps.detectAgents || detectAgents,
|
|
101
|
-
probeReadiness: deps.probeAgentReadiness || probeAgentReadiness,
|
|
102
|
-
resolveDaemon: deps.resolveDaemonBinary || resolveDaemonBinary,
|
|
103
|
-
checkPort: deps.checkPort || checkPort,
|
|
104
|
-
loadAccount: deps.loadAccount || loadAccount,
|
|
105
|
-
serviceStatus: deps.serviceStatus || serviceStatus,
|
|
106
|
-
listLogs: deps.listLogs || listLogs,
|
|
107
|
-
fetchImpl: deps.fetchImpl || ((...a) => globalThis.fetch(...a)),
|
|
108
|
-
probeRunningVersion: deps.probeRunningVersion || probeHealthVersion,
|
|
109
|
-
daemonInstalledVersion: deps.daemonInstalledVersion || (() => resolveDaemonVersion({ env })),
|
|
110
|
-
daemonExpectedVersion: deps.daemonExpectedVersion || (() => expectedDaemonVersion()),
|
|
111
|
-
daemonRunningVersion: deps.daemonRunningVersion || (() => readRunningDaemonVersion(env)),
|
|
112
|
-
};
|
|
113
|
-
const checks = [];
|
|
114
|
-
const add = (c) => checks.push(c);
|
|
115
|
-
// Run a check body, capturing a thrown error as a non-fatal 'warn' so one bad
|
|
116
|
-
// check never aborts the whole report.
|
|
117
|
-
const guarded = async (id, label, body) => {
|
|
118
|
-
try {
|
|
119
|
-
add({ id, label, ...(await body()) });
|
|
120
|
-
} catch (e) {
|
|
121
|
-
add({ id, label, status: 'warn', detail: `check errored: ${String(e?.message || e)}`, hint: null });
|
|
122
|
-
}
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
// 1. Node runtime
|
|
126
|
-
await guarded('node', 'Node.js runtime', async () => {
|
|
127
|
-
const major = nodeMajor();
|
|
128
|
-
const detail = `${process.version} on ${os.platform()}-${os.arch()}`;
|
|
129
|
-
return major >= 18
|
|
130
|
-
? { status: 'ok', detail, hint: null }
|
|
131
|
-
: { status: 'fail', detail, hint: 'Claude Code + wild-workspace need Node 18 or newer. Update Node, then retry.' };
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
// 2. Claude installed?
|
|
135
|
-
let claude = null;
|
|
136
|
-
await guarded('agent', 'Claude Code installed', async () => {
|
|
137
|
-
const agents = await d.detectAgents();
|
|
138
|
-
claude = (agents || []).find((a) => a.id === 'claude' && a.available) || null;
|
|
139
|
-
if (!claude) {
|
|
140
|
-
const fallback = pickDefaultAgent(agents || []);
|
|
141
|
-
if (fallback?.available) {
|
|
142
|
-
return { status: 'info', detail: `Claude not found; using ${fallback.label}.`, hint: null };
|
|
143
|
-
}
|
|
144
|
-
return { status: 'fail', detail: 'no `claude` on PATH', hint: CLAUDE_INSTALL_HINT };
|
|
145
|
-
}
|
|
146
|
-
return { status: 'ok', detail: claude.resolvedPath || claude.binary, hint: null };
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
// 3. Claude signed in AND able to run turns?
|
|
150
|
-
if (claude) {
|
|
151
|
-
await guarded('agentAuth', 'Claude ready to think', async () => {
|
|
152
|
-
const v = await d.probeReadiness(claude, undefined, env);
|
|
153
|
-
switch (v.status) {
|
|
154
|
-
case 'ready':
|
|
155
|
-
return { status: 'ok', detail: v.email ? `signed in as ${v.email}` : 'signed in', hint: null };
|
|
156
|
-
case 'subscribe':
|
|
157
|
-
return {
|
|
158
|
-
status: 'warn',
|
|
159
|
-
detail: v.email ? `signed in as ${v.email}, no active plan` : 'signed in, no active plan',
|
|
160
|
-
hint: 'Claude Code needs a Claude Pro plan (or higher). Subscribe at claude.ai, then retry.',
|
|
161
|
-
};
|
|
162
|
-
case 'login':
|
|
163
|
-
return { status: 'fail', detail: 'not signed in', hint: 'Run `claude auth login`, sign in, then retry.' };
|
|
164
|
-
case 'missing':
|
|
165
|
-
return { status: 'fail', detail: 'Claude not installed', hint: CLAUDE_INSTALL_HINT };
|
|
166
|
-
default:
|
|
167
|
-
return { status: 'info', detail: `readiness unknown (${v.status})`, hint: 'Will be confirmed on the first agent turn.' };
|
|
168
|
-
}
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// 4. bmo-sync daemon binary resolvable?
|
|
173
|
-
await guarded('daemonBinary', 'Sync daemon binary', async () => {
|
|
174
|
-
const r = d.resolveDaemon({ env });
|
|
175
|
-
if (!r) {
|
|
176
|
-
return { status: 'fail', detail: 'WILD_WORKSPACE_DAEMON_BIN is set but the file is missing', hint: 'Unset it or point it at a real binary.' };
|
|
177
|
-
}
|
|
178
|
-
if (r.source === 'path') {
|
|
179
|
-
return {
|
|
180
|
-
status: 'warn',
|
|
181
|
-
detail: 'no bundled daemon found — relying on PATH; cross-device sync may be off',
|
|
182
|
-
hint: 'Reinstall: npm i -g @venturewild/workspace (pulls the daemon for your platform).',
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
return { status: 'ok', detail: `${r.path} (${r.source})`, hint: null };
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
// 4b. Daemon version drift (the go-live stale-process finding, Part 8). Three
|
|
189
|
-
// versions should agree: what the meta package PINS (expected), what's actually
|
|
190
|
-
// on disk (installed subpackage), and what the live daemon was spawned under
|
|
191
|
-
// (running marker). A mismatch is the exact "support channel silently 504s after
|
|
192
|
-
// an update" chain — the meta package updated but the daemon binary on disk
|
|
193
|
-
// lagged, or the daemon kept running old code. Surfaced so the fix (reinstall /
|
|
194
|
-
// restart) is obvious instead of invisible.
|
|
195
|
-
await guarded('daemonVersion', 'Sync daemon version', async () => {
|
|
196
|
-
const expected = d.daemonExpectedVersion();
|
|
197
|
-
const installed = d.daemonInstalledVersion();
|
|
198
|
-
const running = d.daemonRunningVersion();
|
|
199
|
-
const bits = [`pinned=${expected || '?'}`, `installed=${installed || 'PATH/vendor'}`, `running=${running || 'not started'}`];
|
|
200
|
-
const detail = bits.join(' ');
|
|
201
|
-
// Meta pins a version but the on-disk daemon subpackage is a DIFFERENT one →
|
|
202
|
-
// `npm i -g` didn't refresh the optionalDependency (the Windows dev box lag).
|
|
203
|
-
if (expected && installed && expected !== installed) {
|
|
204
|
-
return {
|
|
205
|
-
status: 'warn',
|
|
206
|
-
detail,
|
|
207
|
-
hint: `The daemon on disk (${installed}) does not match what this version pins (${expected}). Reinstall to refresh it: npm i -g @venturewild/workspace@latest`,
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
// The live daemon is running an older binary than what's installed → it needs
|
|
211
|
-
// a recycle (the always-on supervisor does this on its next tick).
|
|
212
|
-
if (installed && running && installed !== running) {
|
|
213
|
-
return {
|
|
214
|
-
status: 'warn',
|
|
215
|
-
detail,
|
|
216
|
-
hint: `The running daemon (${running}) is older than installed (${installed}). Always-on recycles it automatically; or restart sync (\`wild-workspace daemon stop\` then \`wild-workspace\`).`,
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
if (!installed) {
|
|
220
|
-
// PATH/vendor resolution — can't compare versions; the daemonBinary check
|
|
221
|
-
// above already warns about the missing bundled binary.
|
|
222
|
-
return { status: 'info', detail, hint: null };
|
|
223
|
-
}
|
|
224
|
-
return { status: 'ok', detail, hint: null };
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
// 5. Workspace port
|
|
228
|
-
await guarded('port', `Workspace port :${config.port}`, async () => {
|
|
229
|
-
const inUse = await d.checkPort(config.port);
|
|
230
|
-
return inUse
|
|
231
|
-
? { status: 'info', detail: 'in use — your workspace is likely already running (or another app holds it)', hint: null }
|
|
232
|
-
: { status: 'ok', detail: 'free', hint: null };
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
// 5b. Running server version vs installed (RC3). `doctor` runs as the
|
|
236
|
-
// freshly-invoked CLI, so APP_VERSION here == the version installed on disk.
|
|
237
|
-
// If a server is answering :port with a DIFFERENT version, it's running stale
|
|
238
|
-
// code from before the last upgrade — the "kept running 0.1.14 after 0.2.1"
|
|
239
|
-
// failure. Surface it so the fix (restart) is obvious instead of invisible.
|
|
240
|
-
await guarded('runningVersion', 'Running version', async () => {
|
|
241
|
-
const running = await d.probeRunningVersion(config.port);
|
|
242
|
-
if (!running) {
|
|
243
|
-
return { status: 'info', detail: `no server answering :${config.port} (not started yet)`, hint: null };
|
|
244
|
-
}
|
|
245
|
-
if (running === APP_VERSION) {
|
|
246
|
-
return { status: 'ok', detail: `v${running} (matches installed)`, hint: null };
|
|
247
|
-
}
|
|
248
|
-
return {
|
|
249
|
-
status: 'warn',
|
|
250
|
-
detail: `running v${running}, but v${APP_VERSION} is installed`,
|
|
251
|
-
hint: 'A workspace server is running older code than what is installed. Restart it (close the app — always-on restarts it clean) to finish the upgrade.',
|
|
252
|
-
};
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
// 6. Account linked (slug)
|
|
256
|
-
let account = null;
|
|
257
|
-
await guarded('account', 'Workspace account linked', async () => {
|
|
258
|
-
account = d.loadAccount(config.dataDir);
|
|
259
|
-
if (account?.slug) {
|
|
260
|
-
return { status: 'ok', detail: `${account.slug} (${account.email || 'no email'}) → https://${account.slug}.venturewild.llc`, hint: null };
|
|
261
|
-
}
|
|
262
|
-
return { status: 'warn', detail: 'not linked yet', hint: 'Run `wild-workspace login <blob>` with the code from workspace.venturewild.llc.' };
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
// 7. Registry reachable + slug status
|
|
266
|
-
await guarded('registry', 'Sync server reachable', async () => {
|
|
267
|
-
const r = await probeRegistry({ ...config, account: account || config.account }, d.fetchImpl);
|
|
268
|
-
if (!r.reachable) {
|
|
269
|
-
return { status: 'fail', detail: `can't reach ${r.url}: ${r.error}`, hint: 'Check the internet connection, then retry.' };
|
|
270
|
-
}
|
|
271
|
-
if (r.slug) {
|
|
272
|
-
if (r.status === 200) return { status: 'ok', detail: `slug "${r.slug}" is claimed`, hint: null };
|
|
273
|
-
if (r.status === 404) return { status: 'warn', detail: `slug "${r.slug}" is not claimed on the server`, hint: 'Re-run the claim, or `wild-workspace login` with a fresh blob.' };
|
|
274
|
-
return { status: 'warn', detail: `slug resolve returned HTTP ${r.status}`, hint: null };
|
|
275
|
-
}
|
|
276
|
-
return r.status < 500
|
|
277
|
-
? { status: 'ok', detail: `reachable (HTTP ${r.status})`, hint: null }
|
|
278
|
-
: { status: 'warn', detail: `server returned HTTP ${r.status}`, hint: null };
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
// 7b. Public URL reachable end-to-end (RC3). Only meaningful once linked. This
|
|
282
|
-
// is the half the old doctor was blind to — the registry check above can be
|
|
283
|
-
// green (slug claimed) while this is red (tunnel down). Together they tell the
|
|
284
|
-
// two apart: claimed-but-unreachable ⟹ the daemon link is broken (RC2), the
|
|
285
|
-
// operator/auto-relink path is the fix.
|
|
286
|
-
await guarded('tunnel', 'Public URL reachable', async () => {
|
|
287
|
-
const slug = account?.slug || config.account?.slug || null;
|
|
288
|
-
if (!slug) {
|
|
289
|
-
return { status: 'info', detail: 'not linked — no public URL yet', hint: null };
|
|
290
|
-
}
|
|
291
|
-
const r = await probeTunnel(slug, d.fetchImpl);
|
|
292
|
-
if (!r.reachable) {
|
|
293
|
-
return {
|
|
294
|
-
status: 'fail',
|
|
295
|
-
detail: `${r.url} unreachable: ${r.error}`,
|
|
296
|
-
hint: 'The public link is down. Restart sync (`wild-workspace daemon stop` then `wild-workspace`), or the operator `relink-account` fix.',
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
if (r.status >= 500) {
|
|
300
|
-
return {
|
|
301
|
-
status: 'fail',
|
|
302
|
-
detail: `${r.url} returned HTTP ${r.status} (tunnel down — slug claimed but not linked)`,
|
|
303
|
-
hint: 'The daemon is not linked to the relay. Restart sync (`wild-workspace daemon stop` then `wild-workspace`).',
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
if (r.status >= 400) {
|
|
307
|
-
// 401/403/404 = the chain works; auth/slug is the nuance, not a tunnel fault.
|
|
308
|
-
return { status: 'warn', detail: `reachable but HTTP ${r.status} (auth/slug check)`, hint: null };
|
|
309
|
-
}
|
|
310
|
-
return { status: 'ok', detail: `live (HTTP ${r.status}${r.version ? `, v${r.version}` : ''})`, hint: null };
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
// 8. Always-on / autostart
|
|
314
|
-
await guarded('service', 'Always-on (autostart)', async () => {
|
|
315
|
-
const s = await d.serviceStatus({ port: config.port }, { probeImpl: (p) => probeHealth(p) });
|
|
316
|
-
if (s.supported === false) {
|
|
317
|
-
return { status: 'info', detail: `not yet on ${s.platform} — run \`wild-workspace\` to start it`, hint: null };
|
|
318
|
-
}
|
|
319
|
-
const bits = [`installed=${s.installed ? 'yes' : 'no'}`, `supervisor=${s.supervisorAlive ? 'up' : 'down'}`, `server=${s.serverUp ? 'up' : 'down'}`];
|
|
320
|
-
return { status: s.installed ? 'ok' : 'info', detail: bits.join(' '), hint: s.installed ? null : 'Enable with `wild-workspace service install`.' };
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
const logs = d.listLogs(env);
|
|
324
|
-
const summary = checks.reduce(
|
|
325
|
-
(acc, c) => ((acc[c.status] = (acc[c.status] || 0) + 1), acc),
|
|
326
|
-
{ ok: 0, warn: 0, fail: 0, info: 0 },
|
|
327
|
-
);
|
|
328
|
-
|
|
329
|
-
return {
|
|
330
|
-
version: APP_VERSION,
|
|
331
|
-
generatedAt: new Date().toISOString(),
|
|
332
|
-
platform: `${os.platform()}-${os.arch()}`,
|
|
333
|
-
summary,
|
|
334
|
-
checks,
|
|
335
|
-
logs,
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// Render a report to a human string (used by the CLI). The operator channel
|
|
340
|
-
// sends the JSON instead.
|
|
341
|
-
export function renderDoctor(report) {
|
|
342
|
-
const lines = [];
|
|
343
|
-
lines.push(`wild-workspace doctor — v${report.version} (${report.platform})`);
|
|
344
|
-
lines.push('');
|
|
345
|
-
for (const c of report.checks) {
|
|
346
|
-
lines.push(`${STATUS_ICON[c.status] || '•'} ${c.label}: ${c.detail}`);
|
|
347
|
-
if (c.hint && (c.status === 'fail' || c.status === 'warn')) {
|
|
348
|
-
lines.push(` → ${c.hint}`);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
lines.push('');
|
|
352
|
-
const { ok, warn, fail } = report.summary;
|
|
353
|
-
lines.push(`Summary: ${ok} ok · ${warn} warning${warn === 1 ? '' : 's'} · ${fail} problem${fail === 1 ? '' : 's'}`);
|
|
354
|
-
lines.push('');
|
|
355
|
-
lines.push('Logs:');
|
|
356
|
-
for (const l of report.logs) {
|
|
357
|
-
lines.push(` ${l.exists ? '·' : ' '} ${l.name.padEnd(10)} ${l.file}${l.exists ? ` (${l.size} bytes)` : ' (none yet)'}`);
|
|
358
|
-
}
|
|
359
|
-
return lines.join('\n');
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// Persist the JSON report under ~/.wild-workspace/diagnostics/. Returns the
|
|
363
|
-
// file path (or null if it couldn't be written). Best-effort.
|
|
364
|
-
export function writeDoctorBundle(report, env = process.env) {
|
|
365
|
-
try {
|
|
366
|
-
const dir = diagnosticsDir(env);
|
|
367
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
368
|
-
const stamp = report.generatedAt.replace(/[:.]/g, '-');
|
|
369
|
-
const file = path.join(dir, `doctor-${stamp}.json`);
|
|
370
|
-
fs.writeFileSync(file, JSON.stringify(report, null, 2));
|
|
371
|
-
return file;
|
|
372
|
-
} catch {
|
|
373
|
-
return null;
|
|
374
|
-
}
|
|
375
|
-
}
|
|
1
|
+
// `wild-workspace doctor` — one pre/post-flight diagnostic for a real user's
|
|
2
|
+
// machine. The riskiest moment for a brand-new (non-technical) user is the
|
|
3
|
+
// install itself: no Claude yet, wrong Node, a busy port, a daemon binary that
|
|
4
|
+
// didn't resolve, an unclaimed slug. When something breaks we need to SEE it —
|
|
5
|
+
// ideally fix it — without making them feel stupid (docs/user-experience.md §5).
|
|
6
|
+
//
|
|
7
|
+
// runDoctor() returns a structured report (every check is { id, label, status,
|
|
8
|
+
// detail, hint }); the CLI renders it with ✅/⚠️/❌ and the operator channel
|
|
9
|
+
// serves the same JSON. Every external touch-point (agent detect, auth probe,
|
|
10
|
+
// daemon resolve, port check, account load, service status, registry fetch) is
|
|
11
|
+
// an injected seam so the test suite never spawns a process or hits the network.
|
|
12
|
+
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { buildConfig, APP_VERSION } from './config.mjs';
|
|
18
|
+
import { detectAgents, pickDefaultAgent } from './agent.mjs';
|
|
19
|
+
import { probeAgentReadiness } from './agent-readiness.mjs';
|
|
20
|
+
import { resolveDaemonBinary, resolveDaemonVersion, expectedDaemonVersion } from './daemon-bin.mjs';
|
|
21
|
+
import { checkPort } from './preview.mjs';
|
|
22
|
+
import { loadAccount } from './account.mjs';
|
|
23
|
+
import { serviceStatus } from './service.mjs';
|
|
24
|
+
import { probeHealth, probeHealthVersion } from './supervisor.mjs';
|
|
25
|
+
import { listLogs, diagnosticsDir, globalDir } from './logpaths.mjs';
|
|
26
|
+
|
|
27
|
+
// The daemon version the currently-RUNNING daemon was spawned under — the marker
|
|
28
|
+
// the supervisor writes to ~/.wild-workspace/daemon-runtime.json (the daemon's
|
|
29
|
+
// own /health reports no version). null when unread (never started / no marker).
|
|
30
|
+
function readRunningDaemonVersion(env = process.env) {
|
|
31
|
+
try {
|
|
32
|
+
const file = path.join(globalDir(env), 'daemon-runtime.json');
|
|
33
|
+
const v = JSON.parse(fs.readFileSync(file, 'utf8'))?.daemonVersion;
|
|
34
|
+
return typeof v === 'string' ? v : null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const STATUS_ICON = { ok: '✅', warn: '⚠️', fail: '❌', info: 'ℹ️' };
|
|
41
|
+
|
|
42
|
+
// Native installer is Claude's canonical path today (npm i -g still works as a
|
|
43
|
+
// fallback). Shown verbatim to the user, so keep it copy-pasteable.
|
|
44
|
+
const CLAUDE_INSTALL_HINT =
|
|
45
|
+
'Install Claude Code: curl -fsSL https://claude.ai/install.sh | bash (Windows: irm https://claude.ai/install.ps1 | iex)';
|
|
46
|
+
|
|
47
|
+
function nodeMajor(version = process.version) {
|
|
48
|
+
const m = /^v?(\d+)/.exec(String(version));
|
|
49
|
+
return m ? Number(m[1]) : 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// RC3: probe the LIVE public tunnel end-to-end — out to Cloudflare, through the
|
|
53
|
+
// relay, down the daemon's tunnel, back to this server. This is the check the old
|
|
54
|
+
// `doctor` lacked: it only resolved the slug in the registry (claimed in the DB),
|
|
55
|
+
// which stays green even when `<slug>.venturewild.llc` is 502. A 200 here proves
|
|
56
|
+
// the whole chain works; a 5xx/timeout is the exact RC2 "linked but unreachable".
|
|
57
|
+
async function probeTunnel(slug, fetchImpl, timeoutMs = 8000) {
|
|
58
|
+
const url = `https://${encodeURIComponent(slug)}.venturewild.llc/api/health`;
|
|
59
|
+
const ctrl = new AbortController();
|
|
60
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetchImpl(url, { signal: ctrl.signal, headers: { 'cache-control': 'no-cache' } });
|
|
63
|
+
let version = null;
|
|
64
|
+
try { version = (await res.json())?.version || null; } catch { /* non-JSON */ }
|
|
65
|
+
return { reachable: true, status: res.status, version, url };
|
|
66
|
+
} catch (e) {
|
|
67
|
+
return { reachable: false, error: String(e?.message || e), url };
|
|
68
|
+
} finally {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Reach the bmo-sync registry: resolve the user's slug if linked, else /health.
|
|
74
|
+
async function probeRegistry(config, fetchImpl) {
|
|
75
|
+
const base = String(config.bmoSyncServerUrl || '').replace(/\/$/, '');
|
|
76
|
+
const slug = config.account?.slug || null;
|
|
77
|
+
const url = slug
|
|
78
|
+
? `${base}/api/slug/resolve/${encodeURIComponent(slug)}`
|
|
79
|
+
: `${base}/api/health`;
|
|
80
|
+
const ctrl = new AbortController();
|
|
81
|
+
const timer = setTimeout(() => ctrl.abort(), 5000);
|
|
82
|
+
try {
|
|
83
|
+
const res = await fetchImpl(url, { signal: ctrl.signal });
|
|
84
|
+
return { reachable: true, status: res.status, slug, url };
|
|
85
|
+
} catch (e) {
|
|
86
|
+
return { reachable: false, error: String(e?.message || e), slug, url };
|
|
87
|
+
} finally {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Run every diagnostic check. All deps are injectable for testing.
|
|
94
|
+
* @returns {{version,generatedAt,platform,summary,checks,logs}}
|
|
95
|
+
*/
|
|
96
|
+
export async function runDoctor(opts = {}, deps = {}) {
|
|
97
|
+
const config = opts.config || deps.config || buildConfig({});
|
|
98
|
+
const env = deps.env || process.env;
|
|
99
|
+
const d = {
|
|
100
|
+
detectAgents: deps.detectAgents || detectAgents,
|
|
101
|
+
probeReadiness: deps.probeAgentReadiness || probeAgentReadiness,
|
|
102
|
+
resolveDaemon: deps.resolveDaemonBinary || resolveDaemonBinary,
|
|
103
|
+
checkPort: deps.checkPort || checkPort,
|
|
104
|
+
loadAccount: deps.loadAccount || loadAccount,
|
|
105
|
+
serviceStatus: deps.serviceStatus || serviceStatus,
|
|
106
|
+
listLogs: deps.listLogs || listLogs,
|
|
107
|
+
fetchImpl: deps.fetchImpl || ((...a) => globalThis.fetch(...a)),
|
|
108
|
+
probeRunningVersion: deps.probeRunningVersion || probeHealthVersion,
|
|
109
|
+
daemonInstalledVersion: deps.daemonInstalledVersion || (() => resolveDaemonVersion({ env })),
|
|
110
|
+
daemonExpectedVersion: deps.daemonExpectedVersion || (() => expectedDaemonVersion()),
|
|
111
|
+
daemonRunningVersion: deps.daemonRunningVersion || (() => readRunningDaemonVersion(env)),
|
|
112
|
+
};
|
|
113
|
+
const checks = [];
|
|
114
|
+
const add = (c) => checks.push(c);
|
|
115
|
+
// Run a check body, capturing a thrown error as a non-fatal 'warn' so one bad
|
|
116
|
+
// check never aborts the whole report.
|
|
117
|
+
const guarded = async (id, label, body) => {
|
|
118
|
+
try {
|
|
119
|
+
add({ id, label, ...(await body()) });
|
|
120
|
+
} catch (e) {
|
|
121
|
+
add({ id, label, status: 'warn', detail: `check errored: ${String(e?.message || e)}`, hint: null });
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// 1. Node runtime
|
|
126
|
+
await guarded('node', 'Node.js runtime', async () => {
|
|
127
|
+
const major = nodeMajor();
|
|
128
|
+
const detail = `${process.version} on ${os.platform()}-${os.arch()}`;
|
|
129
|
+
return major >= 18
|
|
130
|
+
? { status: 'ok', detail, hint: null }
|
|
131
|
+
: { status: 'fail', detail, hint: 'Claude Code + wild-workspace need Node 18 or newer. Update Node, then retry.' };
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// 2. Claude installed?
|
|
135
|
+
let claude = null;
|
|
136
|
+
await guarded('agent', 'Claude Code installed', async () => {
|
|
137
|
+
const agents = await d.detectAgents();
|
|
138
|
+
claude = (agents || []).find((a) => a.id === 'claude' && a.available) || null;
|
|
139
|
+
if (!claude) {
|
|
140
|
+
const fallback = pickDefaultAgent(agents || []);
|
|
141
|
+
if (fallback?.available) {
|
|
142
|
+
return { status: 'info', detail: `Claude not found; using ${fallback.label}.`, hint: null };
|
|
143
|
+
}
|
|
144
|
+
return { status: 'fail', detail: 'no `claude` on PATH', hint: CLAUDE_INSTALL_HINT };
|
|
145
|
+
}
|
|
146
|
+
return { status: 'ok', detail: claude.resolvedPath || claude.binary, hint: null };
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// 3. Claude signed in AND able to run turns?
|
|
150
|
+
if (claude) {
|
|
151
|
+
await guarded('agentAuth', 'Claude ready to think', async () => {
|
|
152
|
+
const v = await d.probeReadiness(claude, undefined, env);
|
|
153
|
+
switch (v.status) {
|
|
154
|
+
case 'ready':
|
|
155
|
+
return { status: 'ok', detail: v.email ? `signed in as ${v.email}` : 'signed in', hint: null };
|
|
156
|
+
case 'subscribe':
|
|
157
|
+
return {
|
|
158
|
+
status: 'warn',
|
|
159
|
+
detail: v.email ? `signed in as ${v.email}, no active plan` : 'signed in, no active plan',
|
|
160
|
+
hint: 'Claude Code needs a Claude Pro plan (or higher). Subscribe at claude.ai, then retry.',
|
|
161
|
+
};
|
|
162
|
+
case 'login':
|
|
163
|
+
return { status: 'fail', detail: 'not signed in', hint: 'Run `claude auth login`, sign in, then retry.' };
|
|
164
|
+
case 'missing':
|
|
165
|
+
return { status: 'fail', detail: 'Claude not installed', hint: CLAUDE_INSTALL_HINT };
|
|
166
|
+
default:
|
|
167
|
+
return { status: 'info', detail: `readiness unknown (${v.status})`, hint: 'Will be confirmed on the first agent turn.' };
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 4. bmo-sync daemon binary resolvable?
|
|
173
|
+
await guarded('daemonBinary', 'Sync daemon binary', async () => {
|
|
174
|
+
const r = d.resolveDaemon({ env });
|
|
175
|
+
if (!r) {
|
|
176
|
+
return { status: 'fail', detail: 'WILD_WORKSPACE_DAEMON_BIN is set but the file is missing', hint: 'Unset it or point it at a real binary.' };
|
|
177
|
+
}
|
|
178
|
+
if (r.source === 'path') {
|
|
179
|
+
return {
|
|
180
|
+
status: 'warn',
|
|
181
|
+
detail: 'no bundled daemon found — relying on PATH; cross-device sync may be off',
|
|
182
|
+
hint: 'Reinstall: npm i -g @venturewild/workspace (pulls the daemon for your platform).',
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return { status: 'ok', detail: `${r.path} (${r.source})`, hint: null };
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// 4b. Daemon version drift (the go-live stale-process finding, Part 8). Three
|
|
189
|
+
// versions should agree: what the meta package PINS (expected), what's actually
|
|
190
|
+
// on disk (installed subpackage), and what the live daemon was spawned under
|
|
191
|
+
// (running marker). A mismatch is the exact "support channel silently 504s after
|
|
192
|
+
// an update" chain — the meta package updated but the daemon binary on disk
|
|
193
|
+
// lagged, or the daemon kept running old code. Surfaced so the fix (reinstall /
|
|
194
|
+
// restart) is obvious instead of invisible.
|
|
195
|
+
await guarded('daemonVersion', 'Sync daemon version', async () => {
|
|
196
|
+
const expected = d.daemonExpectedVersion();
|
|
197
|
+
const installed = d.daemonInstalledVersion();
|
|
198
|
+
const running = d.daemonRunningVersion();
|
|
199
|
+
const bits = [`pinned=${expected || '?'}`, `installed=${installed || 'PATH/vendor'}`, `running=${running || 'not started'}`];
|
|
200
|
+
const detail = bits.join(' ');
|
|
201
|
+
// Meta pins a version but the on-disk daemon subpackage is a DIFFERENT one →
|
|
202
|
+
// `npm i -g` didn't refresh the optionalDependency (the Windows dev box lag).
|
|
203
|
+
if (expected && installed && expected !== installed) {
|
|
204
|
+
return {
|
|
205
|
+
status: 'warn',
|
|
206
|
+
detail,
|
|
207
|
+
hint: `The daemon on disk (${installed}) does not match what this version pins (${expected}). Reinstall to refresh it: npm i -g @venturewild/workspace@latest`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
// The live daemon is running an older binary than what's installed → it needs
|
|
211
|
+
// a recycle (the always-on supervisor does this on its next tick).
|
|
212
|
+
if (installed && running && installed !== running) {
|
|
213
|
+
return {
|
|
214
|
+
status: 'warn',
|
|
215
|
+
detail,
|
|
216
|
+
hint: `The running daemon (${running}) is older than installed (${installed}). Always-on recycles it automatically; or restart sync (\`wild-workspace daemon stop\` then \`wild-workspace\`).`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
if (!installed) {
|
|
220
|
+
// PATH/vendor resolution — can't compare versions; the daemonBinary check
|
|
221
|
+
// above already warns about the missing bundled binary.
|
|
222
|
+
return { status: 'info', detail, hint: null };
|
|
223
|
+
}
|
|
224
|
+
return { status: 'ok', detail, hint: null };
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// 5. Workspace port
|
|
228
|
+
await guarded('port', `Workspace port :${config.port}`, async () => {
|
|
229
|
+
const inUse = await d.checkPort(config.port);
|
|
230
|
+
return inUse
|
|
231
|
+
? { status: 'info', detail: 'in use — your workspace is likely already running (or another app holds it)', hint: null }
|
|
232
|
+
: { status: 'ok', detail: 'free', hint: null };
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// 5b. Running server version vs installed (RC3). `doctor` runs as the
|
|
236
|
+
// freshly-invoked CLI, so APP_VERSION here == the version installed on disk.
|
|
237
|
+
// If a server is answering :port with a DIFFERENT version, it's running stale
|
|
238
|
+
// code from before the last upgrade — the "kept running 0.1.14 after 0.2.1"
|
|
239
|
+
// failure. Surface it so the fix (restart) is obvious instead of invisible.
|
|
240
|
+
await guarded('runningVersion', 'Running version', async () => {
|
|
241
|
+
const running = await d.probeRunningVersion(config.port);
|
|
242
|
+
if (!running) {
|
|
243
|
+
return { status: 'info', detail: `no server answering :${config.port} (not started yet)`, hint: null };
|
|
244
|
+
}
|
|
245
|
+
if (running === APP_VERSION) {
|
|
246
|
+
return { status: 'ok', detail: `v${running} (matches installed)`, hint: null };
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
status: 'warn',
|
|
250
|
+
detail: `running v${running}, but v${APP_VERSION} is installed`,
|
|
251
|
+
hint: 'A workspace server is running older code than what is installed. Restart it (close the app — always-on restarts it clean) to finish the upgrade.',
|
|
252
|
+
};
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// 6. Account linked (slug)
|
|
256
|
+
let account = null;
|
|
257
|
+
await guarded('account', 'Workspace account linked', async () => {
|
|
258
|
+
account = d.loadAccount(config.dataDir);
|
|
259
|
+
if (account?.slug) {
|
|
260
|
+
return { status: 'ok', detail: `${account.slug} (${account.email || 'no email'}) → https://${account.slug}.venturewild.llc`, hint: null };
|
|
261
|
+
}
|
|
262
|
+
return { status: 'warn', detail: 'not linked yet', hint: 'Run `wild-workspace login <blob>` with the code from workspace.venturewild.llc.' };
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// 7. Registry reachable + slug status
|
|
266
|
+
await guarded('registry', 'Sync server reachable', async () => {
|
|
267
|
+
const r = await probeRegistry({ ...config, account: account || config.account }, d.fetchImpl);
|
|
268
|
+
if (!r.reachable) {
|
|
269
|
+
return { status: 'fail', detail: `can't reach ${r.url}: ${r.error}`, hint: 'Check the internet connection, then retry.' };
|
|
270
|
+
}
|
|
271
|
+
if (r.slug) {
|
|
272
|
+
if (r.status === 200) return { status: 'ok', detail: `slug "${r.slug}" is claimed`, hint: null };
|
|
273
|
+
if (r.status === 404) return { status: 'warn', detail: `slug "${r.slug}" is not claimed on the server`, hint: 'Re-run the claim, or `wild-workspace login` with a fresh blob.' };
|
|
274
|
+
return { status: 'warn', detail: `slug resolve returned HTTP ${r.status}`, hint: null };
|
|
275
|
+
}
|
|
276
|
+
return r.status < 500
|
|
277
|
+
? { status: 'ok', detail: `reachable (HTTP ${r.status})`, hint: null }
|
|
278
|
+
: { status: 'warn', detail: `server returned HTTP ${r.status}`, hint: null };
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// 7b. Public URL reachable end-to-end (RC3). Only meaningful once linked. This
|
|
282
|
+
// is the half the old doctor was blind to — the registry check above can be
|
|
283
|
+
// green (slug claimed) while this is red (tunnel down). Together they tell the
|
|
284
|
+
// two apart: claimed-but-unreachable ⟹ the daemon link is broken (RC2), the
|
|
285
|
+
// operator/auto-relink path is the fix.
|
|
286
|
+
await guarded('tunnel', 'Public URL reachable', async () => {
|
|
287
|
+
const slug = account?.slug || config.account?.slug || null;
|
|
288
|
+
if (!slug) {
|
|
289
|
+
return { status: 'info', detail: 'not linked — no public URL yet', hint: null };
|
|
290
|
+
}
|
|
291
|
+
const r = await probeTunnel(slug, d.fetchImpl);
|
|
292
|
+
if (!r.reachable) {
|
|
293
|
+
return {
|
|
294
|
+
status: 'fail',
|
|
295
|
+
detail: `${r.url} unreachable: ${r.error}`,
|
|
296
|
+
hint: 'The public link is down. Restart sync (`wild-workspace daemon stop` then `wild-workspace`), or the operator `relink-account` fix.',
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
if (r.status >= 500) {
|
|
300
|
+
return {
|
|
301
|
+
status: 'fail',
|
|
302
|
+
detail: `${r.url} returned HTTP ${r.status} (tunnel down — slug claimed but not linked)`,
|
|
303
|
+
hint: 'The daemon is not linked to the relay. Restart sync (`wild-workspace daemon stop` then `wild-workspace`).',
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if (r.status >= 400) {
|
|
307
|
+
// 401/403/404 = the chain works; auth/slug is the nuance, not a tunnel fault.
|
|
308
|
+
return { status: 'warn', detail: `reachable but HTTP ${r.status} (auth/slug check)`, hint: null };
|
|
309
|
+
}
|
|
310
|
+
return { status: 'ok', detail: `live (HTTP ${r.status}${r.version ? `, v${r.version}` : ''})`, hint: null };
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// 8. Always-on / autostart
|
|
314
|
+
await guarded('service', 'Always-on (autostart)', async () => {
|
|
315
|
+
const s = await d.serviceStatus({ port: config.port }, { probeImpl: (p) => probeHealth(p) });
|
|
316
|
+
if (s.supported === false) {
|
|
317
|
+
return { status: 'info', detail: `not yet on ${s.platform} — run \`wild-workspace\` to start it`, hint: null };
|
|
318
|
+
}
|
|
319
|
+
const bits = [`installed=${s.installed ? 'yes' : 'no'}`, `supervisor=${s.supervisorAlive ? 'up' : 'down'}`, `server=${s.serverUp ? 'up' : 'down'}`];
|
|
320
|
+
return { status: s.installed ? 'ok' : 'info', detail: bits.join(' '), hint: s.installed ? null : 'Enable with `wild-workspace service install`.' };
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const logs = d.listLogs(env);
|
|
324
|
+
const summary = checks.reduce(
|
|
325
|
+
(acc, c) => ((acc[c.status] = (acc[c.status] || 0) + 1), acc),
|
|
326
|
+
{ ok: 0, warn: 0, fail: 0, info: 0 },
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
version: APP_VERSION,
|
|
331
|
+
generatedAt: new Date().toISOString(),
|
|
332
|
+
platform: `${os.platform()}-${os.arch()}`,
|
|
333
|
+
summary,
|
|
334
|
+
checks,
|
|
335
|
+
logs,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Render a report to a human string (used by the CLI). The operator channel
|
|
340
|
+
// sends the JSON instead.
|
|
341
|
+
export function renderDoctor(report) {
|
|
342
|
+
const lines = [];
|
|
343
|
+
lines.push(`wild-workspace doctor — v${report.version} (${report.platform})`);
|
|
344
|
+
lines.push('');
|
|
345
|
+
for (const c of report.checks) {
|
|
346
|
+
lines.push(`${STATUS_ICON[c.status] || '•'} ${c.label}: ${c.detail}`);
|
|
347
|
+
if (c.hint && (c.status === 'fail' || c.status === 'warn')) {
|
|
348
|
+
lines.push(` → ${c.hint}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
lines.push('');
|
|
352
|
+
const { ok, warn, fail } = report.summary;
|
|
353
|
+
lines.push(`Summary: ${ok} ok · ${warn} warning${warn === 1 ? '' : 's'} · ${fail} problem${fail === 1 ? '' : 's'}`);
|
|
354
|
+
lines.push('');
|
|
355
|
+
lines.push('Logs:');
|
|
356
|
+
for (const l of report.logs) {
|
|
357
|
+
lines.push(` ${l.exists ? '·' : ' '} ${l.name.padEnd(10)} ${l.file}${l.exists ? ` (${l.size} bytes)` : ' (none yet)'}`);
|
|
358
|
+
}
|
|
359
|
+
return lines.join('\n');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Persist the JSON report under ~/.wild-workspace/diagnostics/. Returns the
|
|
363
|
+
// file path (or null if it couldn't be written). Best-effort.
|
|
364
|
+
export function writeDoctorBundle(report, env = process.env) {
|
|
365
|
+
try {
|
|
366
|
+
const dir = diagnosticsDir(env);
|
|
367
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
368
|
+
const stamp = report.generatedAt.replace(/[:.]/g, '-');
|
|
369
|
+
const file = path.join(dir, `doctor-${stamp}.json`);
|
|
370
|
+
fs.writeFileSync(file, JSON.stringify(report, null, 2));
|
|
371
|
+
return file;
|
|
372
|
+
} catch {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
}
|