@nac3/forge-cli 1.20.1 → 1.21.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.
@@ -1,14 +1,31 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { c, header } from '../ui/colors.js';
4
- import { loadLicense, configDir } from '../license/index.js';
4
+ import { peekLicense, configDir } from '../license/index.js';
5
5
  import { VERSION } from '../version.js';
6
6
  import { Vault } from '../vault/store.js';
7
+ import { parseBrainConfig, defaultBrainConfig, vaultSlotForProvider, } from '../core/brain_config.js';
8
+ import { probeKeysReadOnly } from '../core/provider_keys.js';
9
+ import { VOICE_CREDENTIAL_SOURCES } from '../voice/registry.js';
10
+ import { DEFAULT_VOICE_CONFIG } from '../voice/types.js';
11
+ function mk(check) {
12
+ return { ...check, ok: check.status === 'ok' };
13
+ }
14
+ /** The one place that phrases the third state, so it reads the same
15
+ * everywhere and a test can assert on it. */
16
+ function couldNotVerify(what, why) {
17
+ return 'could not verify ' + what + ': ' + why;
18
+ }
19
+ function firstLine(err) {
20
+ const msg = err instanceof Error ? err.message : String(err);
21
+ return (msg.split('\n')[0] ?? 'unknown error').slice(0, 160);
22
+ }
7
23
  export function registerDoctorCommand(program) {
8
24
  program
9
25
  .command('doctor')
10
26
  .description('Check the local environment + Forge installation + vault + voice + reader deps.')
11
27
  .option('--json', 'emit JSON instead of human-readable output')
28
+ .option('--verbose', 'show, for every check, which source it read and what it expected vs found')
12
29
  .action(async (opts) => {
13
30
  const checks = await runDoctorChecks();
14
31
  if (opts.json) {
@@ -20,20 +37,39 @@ export function registerDoctorCommand(program) {
20
37
  console.log(' Forge CLI ' + c.dim(VERSION));
21
38
  console.log('');
22
39
  for (const check of checks) {
23
- const mark = check.ok ? c.success('✓') : c.warn('✗');
40
+ const mark = check.status === 'ok' ? c.success('+')
41
+ : check.status === 'fail' ? c.warn('x')
42
+ : c.dim('?');
24
43
  console.log(' ' + mark + ' ' + check.name + c.dim(' -- ' + check.detail));
25
- if (!check.ok && check.hint) {
26
- console.log(' ' + c.dim('hint: ' + check.hint));
44
+ if (opts.verbose) {
45
+ console.log(' ' + c.dim('source: ' + check.source));
46
+ if (check.expected)
47
+ console.log(' ' + c.dim('expected: ' + check.expected));
48
+ if (check.found)
49
+ console.log(' ' + c.dim('found: ' + check.found));
50
+ }
51
+ if (check.conflict) {
52
+ console.log(' ' + c.dim('also: ' + check.conflict));
53
+ }
54
+ if (check.status !== 'ok' && check.hint) {
55
+ console.log(' ' + c.dim('hint: ' + check.hint));
27
56
  }
28
57
  }
29
58
  console.log('');
30
- const failing = checks.filter((ch) => !ch.ok).length;
31
- if (failing === 0) {
59
+ const failing = checks.filter((ch) => ch.status === 'fail').length;
60
+ const unknown = checks.filter((ch) => ch.status === 'unknown').length;
61
+ const verified = checks.length - failing - unknown;
62
+ console.log(' ' + verified + ' verified / ' + failing + ' failing / '
63
+ + unknown + ' could not verify (' + checks.length + ' checks)');
64
+ if (failing === 0 && unknown === 0) {
32
65
  console.log(c.success('All checks passed.'));
33
66
  }
34
- else {
67
+ else if (failing > 0) {
35
68
  console.log(c.warn(failing + ' check(s) need attention.'));
36
69
  }
70
+ if (unknown > 0 && !opts.verbose) {
71
+ console.log(c.dim(' run `yf doctor --verbose` to see which source each check read.'));
72
+ }
37
73
  /* PND-135 -- passive (cached) update notice. Best-effort; never blocks. */
38
74
  try {
39
75
  const { checkForUpdate } = await import('../core/self_update.js');
@@ -48,340 +84,634 @@ export function registerDoctorCommand(program) {
48
84
  catch { /* never block doctor on the update check */ }
49
85
  });
50
86
  }
51
- async function runDoctorChecks() {
87
+ export async function runDoctorChecks(env = {}) {
88
+ const dir = env.configDir ?? configDir();
89
+ const cwd = env.cwd ?? process.cwd();
52
90
  const checks = [];
53
- /* 1. Node.js version. */
91
+ await checkNode(checks);
92
+ await checkLicenseState(checks, dir);
93
+ await runHito4DoctorChecks(checks, {
94
+ ...(env.fetchImpl ? { fetchImpl: env.fetchImpl } : {}),
95
+ ...(env.nowMs ? { nowMs: env.nowMs } : {}),
96
+ ...(env.timeoutMs !== undefined ? { timeoutMs: env.timeoutMs } : {}),
97
+ configDir: dir,
98
+ });
99
+ /* ONE read-only probe of the key material, shared by the vault,
100
+ * voice, brain and github checks. They all have to agree, and they
101
+ * all have to be able to say "I could not read the vault". */
102
+ const keys = await probeKeysReadOnly({ configDir: dir });
103
+ await checkCredentialsVault(checks, keys);
104
+ await checkVoiceProviders(checks, dir, keys);
105
+ await checkBrainProvider(checks, dir, keys);
106
+ await checkGithubToken(checks, keys);
107
+ await checkI18nCatalogs(checks);
108
+ await checkHardcodedStrings(checks);
109
+ await checkUserManuals(checks);
110
+ await checkReaderDeps(checks);
111
+ checkRecapMode(checks);
112
+ await checkProjectRoot(checks, cwd);
113
+ return checks;
114
+ }
115
+ /* ------------------------------------------------------------------ */
116
+ /* 1. Node */
117
+ /* ------------------------------------------------------------------ */
118
+ async function checkNode(checks) {
54
119
  const nodeMajor = parseInt(process.versions.node.split('.')[0] ?? '0', 10);
55
- checks.push({
120
+ checks.push(mk({
121
+ id: 'node_version',
56
122
  name: 'Node.js >= 18.17',
57
- ok: nodeMajor >= 18,
123
+ status: nodeMajor >= 18 ? 'ok' : 'fail',
124
+ source: 'process.versions.node (the runtime executing this command)',
125
+ expected: 'major >= 18',
126
+ found: process.versions.node,
58
127
  detail: 'detected ' + process.versions.node,
59
128
  ...(nodeMajor < 18 ? { hint: 'install Node 18.17 or newer from nodejs.org' } : {}),
60
- });
61
- /* 2. License state. */
62
- const lic = await loadLicense();
63
- checks.push({
64
- name: 'License state',
65
- ok: lic.status === 'paid' || lic.status === 'trial',
66
- detail: 'status=' + lic.status + ' (' + lic.source + ')',
67
- ...(lic.status === 'expired' ? { hint: 'run `yf license activate` with your seat token' } : {}),
68
- });
69
- /* 2b. HITO 4 worker checks (PND-031, F104 + license caching).
70
- * Three small probes that surface common HITO 4 mis-configs
71
- * before the user hits them mid-chat:
72
- * (a) prompts.yujin.app reachable -- HTTPS HEAD with a short
73
- * timeout. Surfaces DNS / outage / firewall.
74
- * (b) yujin_license slot configured -- Vault.has() so the
75
- * HMAC bearer construction does not throw mid-call.
76
- * (c) license cached < 24h -- mtime of license.json compared
77
- * to now. The local cache is the canonical source for
78
- * offline use; if it is stale, schedule `yf license sync`. */
79
- await runHito4DoctorChecks(checks);
80
- /* 3. Vault (HITO 0). The vault file may not exist if the
81
- user has never opened the chat panel; that is NOT a
82
- failure, just informational. */
129
+ }));
130
+ }
131
+ /* ------------------------------------------------------------------ */
132
+ /* 2. License state -- READ ONLY (PND-188) */
133
+ /* ------------------------------------------------------------------ */
134
+ async function checkLicenseState(checks, dir) {
135
+ const src = path.join(dir, 'license.json') + ' then ' + path.join(dir, 'trial.json');
136
+ let state;
83
137
  try {
84
- const v = await Vault.open({ configDir: configDir() });
85
- const slotNames = v.list().map((s) => s.name);
86
- checks.push({
87
- name: 'Credentials vault',
88
- ok: true,
89
- detail: slotNames.length + ' slot(s) configured: ' + (slotNames.join(', ') || '(empty)'),
90
- });
138
+ state = await peekLicense();
91
139
  }
92
140
  catch (err) {
93
- /* Vault not yet initialised is the expected state for a
94
- fresh install. The chat panel creates it lazily. */
95
- checks.push({
141
+ checks.push(mk({
142
+ id: 'license_state',
143
+ name: 'License state',
144
+ status: 'unknown',
145
+ source: src,
146
+ expected: 'a parseable license.json or trial.json',
147
+ found: 'a file that exists but could not be parsed',
148
+ detail: couldNotVerify('the license state', firstLine(err)),
149
+ hint: 'inspect ' + path.join(dir, 'trial.json') + ' -- delete it to re-seed the trial',
150
+ }));
151
+ return;
152
+ }
153
+ if (state === null) {
154
+ /* Verified: nothing on disk. The trial starts on the first command
155
+ * that needs a license -- and `yf doctor` is deliberately NOT one
156
+ * of them, so this stays true after the command returns. */
157
+ checks.push(mk({
158
+ id: 'license_state',
159
+ name: 'License state',
160
+ status: 'ok',
161
+ source: src,
162
+ expected: 'license.json (paid seat) or trial.json (trial clock)',
163
+ found: 'neither -- nothing issued on this machine yet',
164
+ detail: 'no license yet; the 30-day trial starts on the first command that needs one '
165
+ + '(doctor does not start it)',
166
+ }));
167
+ return;
168
+ }
169
+ const good = state.status === 'paid' || state.status === 'trial';
170
+ checks.push(mk({
171
+ id: 'license_state',
172
+ name: 'License state',
173
+ status: good ? 'ok' : 'fail',
174
+ source: state.status === 'paid' ? path.join(dir, 'license.json') : path.join(dir, 'trial.json'),
175
+ expected: 'status paid or trial',
176
+ found: 'status=' + state.status
177
+ + (state.trial ? ' (' + state.trial.daysLeft + ' day(s) left)' : ''),
178
+ detail: 'status=' + state.status + ' (' + state.source + ')',
179
+ ...(good ? {} : { hint: 'run `yf license activate` with your seat token' }),
180
+ }));
181
+ }
182
+ /* ------------------------------------------------------------------ */
183
+ /* 3. Credentials vault */
184
+ /* ------------------------------------------------------------------ */
185
+ async function checkCredentialsVault(checks, keys) {
186
+ const src = path.join(keys.config_dir, 'vault.json');
187
+ if (!keys.vault_present) {
188
+ checks.push(mk({
189
+ id: 'credentials_vault',
190
+ name: 'Credentials vault',
191
+ status: 'ok',
192
+ source: src,
193
+ expected: 'an encrypted vault file',
194
+ found: 'absent',
195
+ detail: 'not yet initialised (created when you set the first slot)',
196
+ }));
197
+ return;
198
+ }
199
+ if (!keys.vault_readable) {
200
+ /* The old code answered `ok: true` from this very catch -- a green
201
+ * emitted from the branch that proves it could not look (PND-186). */
202
+ checks.push(mk({
203
+ id: 'credentials_vault',
96
204
  name: 'Credentials vault',
97
- ok: true,
98
- detail: 'not yet initialised (will be created when you set the first slot)',
99
- });
100
- }
101
- /* 4. Voice provider keys (BYOK).
102
- *
103
- * Two sources to consider (in priority order):
104
- * a) ~/.yujin-forge/provider_keys.json -- the new location
105
- * written by `yf voice setup` (encrypted blob synced
106
- * across devices via /v1/keys-sync).
107
- * b) Vault slots `openai` / `google_voice` / `elevenlabs`
108
- * -- legacy slots from the panel-based flow, still
109
- * respected for back-compat. */
110
- let vaultSlotNames = [];
205
+ status: 'unknown',
206
+ source: src,
207
+ expected: 'a vault that opens with this machine fingerprint',
208
+ found: 'present but could not be opened',
209
+ detail: couldNotVerify('the vault contents', keys.vault_error ?? 'unknown error'),
210
+ hint: 'the vault is bound to the machine fingerprint; if you moved the file between '
211
+ + 'machines or users, re-set the slots with `yf keys setup`',
212
+ }));
213
+ return;
214
+ }
215
+ checks.push(mk({
216
+ id: 'credentials_vault',
217
+ name: 'Credentials vault',
218
+ status: 'ok',
219
+ source: src,
220
+ expected: 'a vault that opens with this machine fingerprint',
221
+ found: keys.vault_slots.length + ' slot(s)',
222
+ detail: keys.vault_slots.length + ' slot(s) configured: '
223
+ + (keys.vault_slots.join(', ') || '(empty)'),
224
+ }));
225
+ }
226
+ function voiceReady(kind, id, keys) {
227
+ const src = VOICE_CREDENTIAL_SOURCES[kind][id];
228
+ if (!src)
229
+ return false;
230
+ for (const slot of src.byok)
231
+ if (keys.status[slot])
232
+ return true;
233
+ return keys.vault_slots.includes(src.vault_slot);
234
+ }
235
+ function resolveVoice(kind, configured, keys) {
236
+ const candidates = Object.keys(VOICE_CREDENTIAL_SOURCES[kind]);
237
+ const configuredReady = voiceReady(kind, configured, keys);
238
+ let resolved = configuredReady ? configured : null;
239
+ if (resolved === null) {
240
+ /* Mirrors VoiceRouter.resolveReadyProvider: configured first, then
241
+ * the rest of the registry in insertion order. */
242
+ for (const id of candidates) {
243
+ if (id === configured)
244
+ continue;
245
+ if (voiceReady(kind, id, keys)) {
246
+ resolved = id;
247
+ break;
248
+ }
249
+ }
250
+ }
251
+ return { configured, configuredReady, resolved, candidates };
252
+ }
253
+ async function checkVoiceProviders(checks, dir, keys) {
254
+ const file = path.join(dir, 'voice-config.json');
255
+ const id = 'voice_providers';
256
+ const name = 'Voice providers (STT + TTS)';
257
+ if (!keys.vault_readable) {
258
+ checks.push(mk({
259
+ id, name, status: 'unknown',
260
+ source: file + ' + ' + path.join(dir, 'vault.json'),
261
+ expected: 'the configured providers and their credentials',
262
+ found: 'the vault could not be opened, so provider readiness is unknowable',
263
+ detail: couldNotVerify('voice provider readiness', keys.vault_error ?? 'vault unreadable'),
264
+ }));
265
+ return;
266
+ }
267
+ let cfg;
268
+ let seeded = false;
111
269
  try {
112
- const v = await Vault.open({ configDir: configDir() });
113
- vaultSlotNames = v.list().map((s) => s.name);
270
+ const raw = await fs.readFile(file, 'utf-8');
271
+ const parsed = JSON.parse(raw);
272
+ if (!parsed || typeof parsed !== 'object' || !parsed.config
273
+ || !parsed.config.stt || !parsed.config.tts) {
274
+ throw new Error('voice-config.json has no { version, config: { stt, tts } } shape');
275
+ }
276
+ if (parsed.version !== 1) {
277
+ throw new Error('voice-config.json version mismatch (got ' + String(parsed.version) + ', expected 1)');
278
+ }
279
+ cfg = parsed.config;
280
+ }
281
+ catch (err) {
282
+ if (err.code === 'ENOENT') {
283
+ /* Verified absent. VoiceRouter.loadConfig seeds the defaults on
284
+ * first use -- we report those WITHOUT writing them. */
285
+ cfg = DEFAULT_VOICE_CONFIG;
286
+ seeded = true;
287
+ }
288
+ else {
289
+ checks.push(mk({
290
+ id, name, status: 'unknown',
291
+ source: file,
292
+ expected: 'a voice-config.json with { version: 1, config: { stt, tts } }',
293
+ found: 'present but unreadable',
294
+ detail: couldNotVerify('the voice configuration', firstLine(err)),
295
+ hint: 'delete ' + file + ' to let the router re-seed the defaults, then `yf voice setup`',
296
+ }));
297
+ return;
298
+ }
299
+ }
300
+ const stt = resolveVoice('stt', cfg.stt.provider, keys);
301
+ const tts = resolveVoice('tts', cfg.tts.provider, keys);
302
+ const parts = [];
303
+ for (const [kind, r] of [['STT', stt], ['TTS', tts]]) {
304
+ if (r.resolved === null) {
305
+ parts.push(kind + ': ' + r.configured + ' configured, no credential, nothing else ready');
306
+ }
307
+ else if (r.configuredReady) {
308
+ parts.push(kind + ': ' + r.configured);
309
+ }
310
+ else {
311
+ parts.push(kind + ': ' + r.configured + ' configured but has no credential -- '
312
+ + 'runtime fallback resolves to ' + r.resolved);
313
+ }
114
314
  }
115
- catch { /* vault not yet initialised */ }
116
- let providerKeys = {};
315
+ /* A second file carries stt_provider / tts_provider fields that NO
316
+ * runtime path reads. Name it so the user is not sent to edit it. */
317
+ let conflict;
318
+ const legacy = path.join(dir, 'voice.json');
117
319
  try {
118
- /* alpha.44 C1: keys live in the encrypted Vault. We still need
119
- * the plaintext snapshot here to check "is brain configured?"
120
- * style questions. The helper handles the legacy migration. */
121
- const { readAllPlaintext } = await import('../core/provider_keys.js');
122
- const all = await readAllPlaintext();
123
- /* Preferences live alongside the keys in legacy JSON; if the
124
- * legacy file still exists we read them, otherwise leave them
125
- * undefined and let doctor render "none". */
126
- const { promises: fsp } = await import('node:fs');
127
- const os = await import('node:os');
128
- const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
129
- /* Preferences (brain_provider_preference, model + stt/tts prefs) live in
130
- * the CURRENT provider_keys.json: post-PND-051 that file holds the
131
- * NON-secret metadata (the secret VALUES moved to the encrypted vault,
132
- * read via readAllPlaintext above). Reading ONLY the .legacy.bak (which
133
- * most installs do not have) dropped the preference, so the brain check
134
- * reported "no brain provider configured" even with a provider + vault
135
- * key set. Read both -- the current file wins. */
136
- let prefsJson = {};
137
- for (const f of ['provider_keys.json.legacy.bak', 'provider_keys.json']) {
138
- try {
139
- const raw = await fsp.readFile(path.join(home, '.yujin-forge', f), 'utf-8');
140
- prefsJson = { ...prefsJson, ...JSON.parse(raw) };
141
- }
142
- catch { /* file absent -> skip */ }
320
+ const raw = await fs.readFile(legacy, 'utf-8');
321
+ const j = JSON.parse(raw);
322
+ const disagrees = (j.stt_provider && j.stt_provider !== cfg.stt.provider)
323
+ || (j.tts_provider && j.tts_provider !== cfg.tts.provider);
324
+ if (disagrees) {
325
+ conflict = legacy + ' says stt=' + String(j.stt_provider) + ' tts=' + String(j.tts_provider)
326
+ + ', but no runtime path reads those fields -- voice-config.json governs';
143
327
  }
144
- providerKeys = {
145
- ...prefsJson,
146
- anthropic_api_key: all.anthropic_api_key ?? undefined,
147
- openai_api_key: all.openai_api_key ?? undefined,
148
- google_ai_key: all.google_ai_key ?? undefined,
149
- google_stt_key: all.google_stt_key ?? undefined,
150
- google_tts_key: all.google_tts_key ?? undefined,
151
- whisper_api_key: all.whisper_api_key ?? undefined,
152
- elevenlabs_api_key: all.elevenlabs_api_key ?? undefined,
153
- };
154
- }
155
- catch { /* vault unavailable -- treat as empty */ }
156
- const stt = [];
157
- if (providerKeys.openai_api_key || vaultSlotNames.includes('openai'))
158
- stt.push('OpenAI');
159
- if (providerKeys.google_stt_key || vaultSlotNames.includes('google_voice'))
160
- stt.push('Google');
161
- const tts = [];
162
- if (providerKeys.elevenlabs_api_key || vaultSlotNames.includes('elevenlabs'))
163
- tts.push('ElevenLabs');
164
- if (providerKeys.openai_api_key)
165
- tts.push('OpenAI');
166
- if (providerKeys.google_tts_key || vaultSlotNames.includes('google_voice'))
167
- tts.push('Google');
168
- checks.push({
169
- name: 'Voice providers available (BYOK)',
170
- ok: stt.length > 0 && tts.length > 0,
171
- detail: 'STT: ' + (stt.length > 0 ? stt.join(' + ') : 'none')
172
- + ' / TTS: ' + (tts.length > 0 ? tts.join(' + ') : 'none')
173
- + (providerKeys.stt_provider_preference
174
- ? ' / pref STT: ' + providerKeys.stt_provider_preference : '')
175
- + (providerKeys.tts_provider_preference
176
- ? ' / pref TTS: ' + providerKeys.tts_provider_preference : ''),
177
- ...(stt.length === 0 || tts.length === 0 ? {
178
- hint: 'run `yf keys setup` to configure brain + voice providers. Keys encrypt locally + sync to mobile automatically.',
179
- } : {}),
180
- });
181
- /* 5. Brain provider (BYOK).
182
- *
183
- * Forge is BYOK for the brain (Anthropic / OpenAI / Google AI /
184
- * DeepSeek) -- a single heavy dev burns $100-$200/mo, the
185
- * $10/mo platform fee cannot absorb that. The user picks
186
- * their provider via `yf keys setup` + pastes the key. The
187
- * encrypted key syncs to mobile automatically.
188
- *
189
- * Check: provider_keys.json has a preference + the matching
190
- * key + (optionally) a model alias. */
191
- const brainPref = (providerKeys.brain_provider_preference ?? null);
192
- const brainKey = brainPref === 'anthropic' ? providerKeys.anthropic_api_key
193
- : brainPref === 'openai' ? providerKeys.openai_api_key
194
- : brainPref === 'google' ? providerKeys.google_ai_key
195
- : brainPref === 'deepseek' ? providerKeys.deepseek_api_key
196
- : undefined;
197
- const brainModel = brainPref === 'anthropic' ? providerKeys.anthropic_model_preference
198
- : brainPref === 'openai' ? providerKeys.openai_brain_model_preference
199
- : brainPref === 'google' ? providerKeys.google_brain_model_preference
200
- : brainPref === 'deepseek' ? providerKeys.deepseek_model_preference
201
- : undefined;
202
- if (brainPref && brainKey) {
203
- checks.push({
204
- name: 'Brain provider (BYOK: chat + code)',
205
- ok: true,
206
- detail: 'provider: ' + brainPref
207
- + (brainModel ? ' / model: ' + brainModel : ' / model: (default for tier)'),
208
- });
209
328
  }
210
- else {
211
- checks.push({
212
- name: 'Brain provider (BYOK: chat + code)',
213
- ok: false,
214
- detail: brainPref
215
- ? 'preference is ' + brainPref + ' but no API key on file for it'
216
- : 'no brain provider configured -- chat + code will fail',
217
- hint: 'run `yf keys setup` to pick a provider (Anthropic / OpenAI / Google AI / DeepSeek) and paste your API key',
218
- });
219
- }
220
- /* 6. GitHub token. Optional -- only needed when chat tools
221
- * want to create repos / push commits. Marked OK by default
222
- * with an info detail when missing, so a clean install
223
- * doesn't show a red ✗ for a feature the user may not need. */
224
- const hasGithub = vaultSlotNames.includes('github_token');
225
- checks.push({
226
- name: 'GitHub token (write-class tools)',
227
- ok: true,
228
- detail: hasGithub
329
+ catch { /* absent or unreadable: not this check's subject */ }
330
+ const working = stt.resolved !== null && tts.resolved !== null;
331
+ const drifted = working && (!stt.configuredReady || !tts.configuredReady);
332
+ checks.push(mk({
333
+ id, name,
334
+ status: working ? 'ok' : 'fail',
335
+ source: file + (seeded ? ' (absent -- router defaults reported)' : ''),
336
+ expected: 'the configured STT and TTS providers to have a credential',
337
+ found: 'stt=' + cfg.stt.provider + (stt.configuredReady ? ' ready' : ' no credential')
338
+ + ', tts=' + cfg.tts.provider + (tts.configuredReady ? ' ready' : ' no credential'),
339
+ detail: parts.join(' / '),
340
+ ...(conflict ? { conflict } : {}),
341
+ ...(working
342
+ ? (drifted ? { hint: 'voice works, but the configured provider is not the one that will run; '
343
+ + 'run `yf voice setup` to make the config match, or add the missing key' } : {})
344
+ : { hint: 'run `yf keys setup` to configure voice providers (slot names only: '
345
+ + 'google_stt_key / google_tts_key / elevenlabs / openai)' }),
346
+ }));
347
+ }
348
+ /* ------------------------------------------------------------------ */
349
+ /* 5. Brain provider -- governing source is brain_config.json (F-01) */
350
+ /* ------------------------------------------------------------------ */
351
+ async function checkBrainProvider(checks, dir, keys) {
352
+ const file = path.join(dir, 'brain_config.json');
353
+ const id = 'brain_provider';
354
+ const name = 'Brain provider (chat + code)';
355
+ if (!keys.vault_readable) {
356
+ checks.push(mk({
357
+ id, name, status: 'unknown',
358
+ source: file + ' + ' + path.join(dir, 'vault.json'),
359
+ expected: 'a provider per tier with a key for it',
360
+ found: 'the vault could not be opened, so key presence is unknowable',
361
+ detail: couldNotVerify('brain key presence', keys.vault_error ?? 'vault unreadable'),
362
+ }));
363
+ return;
364
+ }
365
+ let cfg;
366
+ let present = true;
367
+ try {
368
+ cfg = parseBrainConfig(await fs.readFile(file, 'utf-8'));
369
+ }
370
+ catch (err) {
371
+ if (err.code === 'ENOENT') {
372
+ cfg = defaultBrainConfig();
373
+ present = false;
374
+ }
375
+ else {
376
+ checks.push(mk({
377
+ id, name, status: 'unknown',
378
+ source: file,
379
+ expected: 'a brain_config.json with an eco and a frontier tier',
380
+ found: 'present but unreadable',
381
+ detail: couldNotVerify('the brain routing', firstLine(err)),
382
+ hint: 'inspect ' + file + ' -- `yf brain reset` rewrites it with the defaults',
383
+ }));
384
+ return;
385
+ }
386
+ }
387
+ /* The flat brain_provider_preference in provider_keys.json is NOT
388
+ * what routes a chat turn: the cost router reads brain_config.json
389
+ * per tier. It is still live for the HITO 4 worker flows and the
390
+ * mobile brain client, so it is reported as a SEPARATE, derived
391
+ * source -- never as the subject of this check (F-01 / F-02). */
392
+ let conflict;
393
+ try {
394
+ const raw = await fs.readFile(path.join(dir, 'provider_keys.json'), 'utf-8');
395
+ const j = JSON.parse(raw);
396
+ const pref = j.brain_provider_preference;
397
+ if (pref && pref !== cfg[cfg.chat_default_tier].provider) {
398
+ conflict = path.join(dir, 'provider_keys.json') + ' has brain_provider_preference='
399
+ + pref + ', which serves the worker flows and the mobile brain, not this routing '
400
+ + '(see docs/BRAIN_SOURCES_OF_TRUTH.md)';
401
+ }
402
+ }
403
+ catch { /* absent or unreadable: not this check's subject */ }
404
+ const tiers = [
405
+ ['eco', cfg.eco],
406
+ ['frontier', cfg.frontier],
407
+ ['multimodal', cfg.multimodal],
408
+ ];
409
+ const parts = [];
410
+ const missing = [];
411
+ for (const [tier, binding] of tiers) {
412
+ if (!binding)
413
+ continue;
414
+ const slot = vaultSlotForProvider(binding.provider);
415
+ /* ollama is local and keyless; yujin_managed is served by the
416
+ * worker, whose auth is the yf_server_api_key check above. */
417
+ const keyless = binding.provider === 'ollama' || binding.provider === 'yujin_managed';
418
+ const hasIt = keyless || keys.status[slot] === true
419
+ || keys.vault_slots.includes(slot);
420
+ parts.push(tier + ': ' + binding.provider + '/' + binding.model
421
+ + (keyless ? ' (keyless)' : hasIt ? ' (key set)' : ' (NO KEY in slot ' + slot + ')'));
422
+ if (!hasIt)
423
+ missing.push(tier + ' -> slot ' + slot);
424
+ }
425
+ const serving = cfg.eco_enabled ? ['eco', 'frontier'] : ['frontier'];
426
+ const blocking = serving.filter((t) => missing.some((m) => m.startsWith(t + ' ')));
427
+ checks.push(mk({
428
+ id, name,
429
+ status: blocking.length === 0 ? 'ok' : 'fail',
430
+ source: file + (present ? '' : ' (absent -- runtime defaults reported)')
431
+ + ' + ' + path.join(dir, 'vault.json'),
432
+ expected: 'every serving tier to name a provider with a key in its slot',
433
+ found: present
434
+ ? parts.join('; ')
435
+ : 'brain_config.json absent, so the runtime uses the built-in defaults: ' + parts.join('; '),
436
+ detail: parts.join(' / ') + ' / default tier: ' + cfg.chat_default_tier,
437
+ ...(conflict ? { conflict } : {}),
438
+ ...(blocking.length === 0 ? {} : {
439
+ hint: 'the tier that cannot run is ' + blocking.join(' + ') + ' (' + missing.join(', ')
440
+ + '). Set the key for THAT slot with `yf keys setup`, or point the tier at a provider '
441
+ + 'you already have a key for with `yf brain set`',
442
+ }),
443
+ }));
444
+ }
445
+ /* ------------------------------------------------------------------ */
446
+ /* 6. GitHub token */
447
+ /* ------------------------------------------------------------------ */
448
+ async function checkGithubToken(checks, keys) {
449
+ const src = path.join(keys.config_dir, 'vault.json') + ' (slot github_token)';
450
+ if (!keys.vault_readable) {
451
+ checks.push(mk({
452
+ id: 'github_token',
453
+ name: 'GitHub token (write-class tools, optional)',
454
+ status: 'unknown',
455
+ source: src,
456
+ expected: 'the slot github_token present or absent',
457
+ found: 'the vault could not be opened',
458
+ detail: couldNotVerify('whether the github_token slot is set', keys.vault_error ?? 'vault unreadable'),
459
+ }));
460
+ return;
461
+ }
462
+ const has = keys.vault_slots.includes('github_token');
463
+ checks.push(mk({
464
+ id: 'github_token',
465
+ name: 'GitHub token (write-class tools, optional)',
466
+ status: 'ok',
467
+ source: src,
468
+ expected: 'the slot github_token present or absent',
469
+ found: has ? 'present' : 'absent',
470
+ detail: has
229
471
  ? 'github_token slot configured'
230
- : '(optional; not configured -- only needed for forge.create_github_repo / forge.git_push)',
231
- });
232
- /* 5. i18n catalogs + manuals (Fase F). All 10 locales must
233
- load + the 10 manual HTML files must exist on disk. */
472
+ : '(optional; absent -- only needed for forge.create_github_repo / forge.git_push)',
473
+ }));
474
+ }
475
+ /* ------------------------------------------------------------------ */
476
+ /* 7. i18n catalogs */
477
+ /* ------------------------------------------------------------------ */
478
+ async function checkI18nCatalogs(checks) {
479
+ const id = 'i18n_catalogs';
480
+ const name = 'i18n catalogs (10 languages)';
234
481
  try {
235
482
  const { lintCatalogs, SUPPORTED_LANGUAGES } = await import('../i18n/index.js');
236
483
  const lintResult = lintCatalogs();
237
- checks.push({
238
- name: 'i18n catalogs (10 languages)',
239
- ok: lintResult.missing.length === 0,
240
- detail: lintResult.missing.length === 0
484
+ const clean = lintResult.missing.length === 0;
485
+ checks.push(mk({
486
+ id, name,
487
+ status: clean ? 'ok' : 'fail',
488
+ source: 'src/i18n/catalogs/*.json, loaded through src/i18n/index.js (the same loader the panel uses)',
489
+ expected: SUPPORTED_LANGUAGES.length + ' catalogs with no missing key',
490
+ found: clean ? 'complete' : lintResult.missing.length + ' missing key(s)',
491
+ detail: clean
241
492
  ? SUPPORTED_LANGUAGES.length + ' catalogs complete'
242
493
  : lintResult.missing.length + ' missing key(s) -- first: '
243
494
  + lintResult.missing[0].lang + ':' + lintResult.missing[0].key,
244
- ...(lintResult.missing.length > 0 ? { hint: 'run scripts/catalog_lint to see all gaps' } : {}),
245
- });
495
+ ...(clean ? {} : { hint: 'run `npm run catalog:lint` to see all gaps' }),
496
+ }));
246
497
  }
247
498
  catch (err) {
248
- checks.push({
249
- name: 'i18n catalogs (10 languages)',
250
- ok: false,
251
- detail: 'load failed: ' + (err instanceof Error ? err.message : String(err)),
499
+ checks.push(mk({
500
+ id, name,
501
+ status: 'unknown',
502
+ source: 'src/i18n/index.js',
503
+ expected: 'the i18n module to load',
504
+ found: 'import threw',
505
+ detail: couldNotVerify('the catalogs', firstLine(err)),
252
506
  hint: 'check src/i18n/catalogs/*.json',
253
- });
507
+ }));
254
508
  }
255
- /* 5b. Hardcoded user-facing strings -- panel + server source
256
- scanned for literals that should flow through the i18n
257
- catalog. Anti-debt: this catches the same class of bug
258
- the 600-suite followup audit found before we accumulate
259
- more. */
509
+ }
510
+ /* ------------------------------------------------------------------ */
511
+ /* 8. Hardcoded strings lint -- must never DISAPPEAR (PND-187) */
512
+ /* ------------------------------------------------------------------ */
513
+ async function checkHardcodedStrings(checks) {
514
+ const id = 'hardcoded_strings';
515
+ const name = 'Hardcoded user-facing strings (i18n compliance)';
516
+ let script = '(unresolved)';
260
517
  try {
261
- const { execSync } = await import('node:child_process');
262
518
  const { fileURLToPath } = await import('node:url');
263
- const pathMod = await import('node:path');
264
- const here = pathMod.dirname(fileURLToPath(import.meta.url));
265
- /* From dist/commands/ go up two levels to repo root, then
266
- into scripts/. The same path works under tsx because
267
- src/commands/ is also two levels deep. */
268
- const script = pathMod.resolve(here, '..', '..', 'scripts', 'lint_hardcoded_strings.mjs');
269
- let scriptExists = false;
270
- try {
271
- const fsMod = await import('node:fs/promises');
272
- await fsMod.stat(script);
273
- scriptExists = true;
274
- }
275
- catch { /* script not packaged in the published tarball */ }
276
- if (scriptExists) {
277
- try {
278
- const out = execSync('node ' + JSON.stringify(script), {
279
- stdio: ['ignore', 'pipe', 'pipe'],
280
- }).toString();
281
- const clean = /lint_hardcoded_strings: clean\./.test(out);
282
- checks.push({
283
- name: 'Hardcoded user-facing strings (i18n compliance)',
284
- ok: clean,
285
- detail: clean ? 'panel + server source clean' : 'findings present',
286
- ...(clean ? {} : { hint: 'run scripts/lint_hardcoded_strings.mjs to see them' }),
287
- });
288
- }
289
- catch (err) {
290
- const detail = err instanceof Error ? err.message.slice(0, 80) : String(err).slice(0, 80);
291
- checks.push({
292
- name: 'Hardcoded user-facing strings (i18n compliance)',
293
- ok: false,
294
- detail: 'lint failed: ' + detail,
295
- hint: 'run scripts/lint_hardcoded_strings.mjs --strict',
296
- });
297
- }
298
- }
519
+ const here = path.dirname(fileURLToPath(import.meta.url));
520
+ /* From dist/commands/ (and src/commands/ under tsx) go up two
521
+ * levels to the package root, then into scripts/. */
522
+ script = path.resolve(here, '..', '..', 'scripts', 'lint_hardcoded_strings.mjs');
523
+ await fs.stat(script);
299
524
  }
300
525
  catch {
301
- /* node:child_process not available in this runtime; skip. */
526
+ /* scripts/ is not in package.json "files", so EVERY npm install
527
+ * lands here. Previously the check was simply not pushed and the
528
+ * user had no way to know a check had gone missing. */
529
+ checks.push(mk({
530
+ id, name,
531
+ status: 'unknown',
532
+ source: script,
533
+ expected: 'the lint script on disk',
534
+ found: 'absent -- scripts/ is not shipped in the published package',
535
+ detail: couldNotVerify('i18n string compliance', 'the lint script is not part of this install'),
536
+ hint: 'this lint runs in CI and from a source checkout; it is not available from an npm install',
537
+ }));
538
+ return;
302
539
  }
540
+ try {
541
+ const { execFileSync } = await import('node:child_process');
542
+ const out = execFileSync(process.execPath, [script], { stdio: ['ignore', 'pipe', 'pipe'] }).toString();
543
+ const clean = /lint_hardcoded_strings: clean\./.test(out);
544
+ checks.push(mk({
545
+ id, name,
546
+ status: clean ? 'ok' : 'fail',
547
+ source: script,
548
+ expected: 'the marker line "lint_hardcoded_strings: clean."',
549
+ found: clean ? 'clean' : 'findings present',
550
+ detail: clean ? 'panel + server source clean' : 'findings present',
551
+ ...(clean ? {} : { hint: 'run `node scripts/lint_hardcoded_strings.mjs` to see them' }),
552
+ }));
553
+ }
554
+ catch (err) {
555
+ /* The script RAN and exited non-zero, OR the spawn failed. Those
556
+ * are different outcomes: a non-zero exit with output is a real
557
+ * finding, a spawn failure is a tool error we did not verify. */
558
+ const e = err;
559
+ const ranAndFailed = typeof e.status === 'number';
560
+ checks.push(mk({
561
+ id, name,
562
+ status: ranAndFailed ? 'fail' : 'unknown',
563
+ source: script,
564
+ expected: 'the marker line "lint_hardcoded_strings: clean."',
565
+ found: ranAndFailed ? 'exit code ' + e.status : 'the script could not be run',
566
+ detail: ranAndFailed
567
+ ? 'lint reported findings (exit ' + e.status + ')'
568
+ : couldNotVerify('i18n string compliance', firstLine(err)),
569
+ ...(ranAndFailed ? { hint: 'run `node scripts/lint_hardcoded_strings.mjs` to see them' } : {}),
570
+ }));
571
+ }
572
+ }
573
+ /* ------------------------------------------------------------------ */
574
+ /* 9. Manuals */
575
+ /* ------------------------------------------------------------------ */
576
+ async function checkUserManuals(checks) {
577
+ const id = 'user_manuals';
578
+ const name = 'User manuals (HTML, 10 languages)';
303
579
  try {
304
580
  const { listAvailableManuals } = await import('../chat/tools/manual.js');
305
581
  const present = await listAvailableManuals();
306
- checks.push({
307
- name: 'User manuals (HTML, 10 languages)',
308
- ok: present.length === 10,
309
- detail: present.length + '/10 manuals available: ' + present.join(', '),
582
+ checks.push(mk({
583
+ id, name,
584
+ status: present.length === 10 ? 'ok' : 'fail',
585
+ source: 'docs/manuals/, enumerated through chat/tools/manual.js (the same lookup `yf manual` uses)',
586
+ expected: '10 manuals',
587
+ found: present.length + ': ' + (present.join(', ') || '(none)'),
588
+ detail: present.length + '/10 manuals available: ' + (present.join(', ') || '(none)'),
310
589
  ...(present.length < 10 ? { hint: 'reinstall the package or check docs/manuals/' } : {}),
311
- });
590
+ }));
312
591
  }
313
592
  catch (err) {
314
- checks.push({
315
- name: 'User manuals (HTML, 10 languages)',
316
- ok: false,
317
- detail: 'probe failed: ' + (err instanceof Error ? err.message : String(err)),
318
- });
319
- }
320
- /* 6. Reader parser dependencies (HITO 1). PDF + DOCX + XLSX
321
- use lazy-loaded npm deps; verify they resolve. */
322
- for (const [label, modPath] of [
323
- ['pdfjs-dist (PDF reader)', 'pdfjs-dist/legacy/build/pdf.mjs'],
324
- ['mammoth (DOCX reader)', 'mammoth'],
325
- ['xlsx (XLSX reader)', 'xlsx'],
326
- ]) {
593
+ checks.push(mk({
594
+ id, name,
595
+ status: 'unknown',
596
+ source: 'src/chat/tools/manual.js',
597
+ expected: 'the manual lookup to run',
598
+ found: 'probe threw',
599
+ detail: couldNotVerify('the manuals', firstLine(err)),
600
+ }));
601
+ }
602
+ }
603
+ /* ------------------------------------------------------------------ */
604
+ /* 10. Reader deps */
605
+ /* ------------------------------------------------------------------ */
606
+ async function checkReaderDeps(checks) {
607
+ const deps = [
608
+ ['reader_pdfjs', 'pdfjs-dist (PDF reader)', 'pdfjs-dist/legacy/build/pdf.mjs'],
609
+ ['reader_mammoth', 'mammoth (DOCX reader)', 'mammoth'],
610
+ ['reader_xlsx', 'xlsx (XLSX reader)', 'xlsx'],
611
+ ];
612
+ for (const [id, name, modPath] of deps) {
327
613
  try {
614
+ /* The real readiness test: run the same lazy import the reader
615
+ * runs. Nothing short of that proves the dep resolves here. */
328
616
  /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
329
617
  await import(modPath);
330
- checks.push({
331
- name: label,
332
- ok: true,
618
+ checks.push(mk({
619
+ id, name,
620
+ status: 'ok',
621
+ source: "import('" + modPath + "') -- the same specifier the reader uses",
622
+ expected: 'the module to resolve',
623
+ found: 'resolved',
333
624
  detail: 'lazy-import resolved',
334
- });
625
+ }));
335
626
  }
336
627
  catch (err) {
337
- const msg = err instanceof Error ? err.message : String(err);
338
- checks.push({
339
- name: label,
340
- ok: false,
341
- detail: 'import failed: ' + msg.split('\n')[0],
628
+ checks.push(mk({
629
+ id, name,
630
+ status: 'fail',
631
+ source: "import('" + modPath + "') -- the same specifier the reader uses",
632
+ expected: 'the module to resolve',
633
+ found: 'import failed',
634
+ detail: 'import failed: ' + firstLine(err),
342
635
  hint: 'run `npm install` from the Forge install directory',
343
- });
636
+ }));
344
637
  }
345
638
  }
346
- /* 6. Recap summary opt-in env var (informational). */
639
+ }
640
+ /* ------------------------------------------------------------------ */
641
+ /* 11. Recap mode */
642
+ /* ------------------------------------------------------------------ */
643
+ function checkRecapMode(checks) {
347
644
  const recapEnabled = process.env['YF_ENABLE_CLAUDE_RECAP'] === '1';
348
- checks.push({
349
- name: 'Claude recap summary mode',
350
- ok: true,
645
+ checks.push(mk({
646
+ id: 'recap_mode',
647
+ name: 'Claude recap summary mode (informational)',
648
+ status: 'ok',
649
+ source: 'env YF_ENABLE_CLAUDE_RECAP -- the same var src/reader/index.ts reads',
650
+ expected: "'1' to enable",
651
+ found: recapEnabled ? "'1'" : 'unset or not 1',
351
652
  detail: recapEnabled
352
- ? 'enabled (YF_ENABLE_CLAUDE_RECAP=1; charges tokens per recap)'
653
+ ? 'enabled (charges tokens per recap)'
353
654
  : 'disabled (literal mode only; set YF_ENABLE_CLAUDE_RECAP=1 to enable; see docs/RECAP_CLAUDE_COSTS.md)',
354
- });
355
- /* 7. Project root sanity. */
655
+ }));
656
+ }
657
+ /* ------------------------------------------------------------------ */
658
+ /* 12. Project root */
659
+ /* ------------------------------------------------------------------ */
660
+ async function checkProjectRoot(checks, cwd) {
661
+ let readable = false;
356
662
  try {
357
- const cwd = process.cwd();
358
663
  const stat = await fs.stat(cwd);
359
- checks.push({
664
+ readable = stat.isDirectory();
665
+ checks.push(mk({
666
+ id: 'cwd_readable',
360
667
  name: 'Current working directory readable',
361
- ok: stat.isDirectory(),
668
+ status: readable ? 'ok' : 'fail',
669
+ source: cwd,
670
+ expected: 'a readable directory',
671
+ found: readable ? 'directory' : 'not a directory',
362
672
  detail: cwd,
363
- });
364
- /* Look for typical Forge project markers; absence is
365
- informational, not a failure -- yf doctor runs anywhere. */
366
- const hasManifest = await fs.access(path.join(cwd, 'manifest.json')).then(() => true, () => false);
367
- const hasPackage = await fs.access(path.join(cwd, 'package.json')).then(() => true, () => false);
368
- checks.push({
369
- name: 'Project markers',
370
- ok: true,
371
- detail: (hasPackage ? 'package.json' : '') + (hasManifest ? (hasPackage ? ' + ' : '') + 'manifest.json' : '')
372
- || '(neither package.json nor manifest.json in cwd)',
373
- });
673
+ }));
374
674
  }
375
675
  catch (err) {
376
- const msg = err instanceof Error ? err.message : String(err);
377
- checks.push({
676
+ checks.push(mk({
677
+ id: 'cwd_readable',
378
678
  name: 'Current working directory readable',
379
- ok: false,
380
- detail: msg,
381
- });
679
+ status: 'fail',
680
+ source: cwd,
681
+ expected: 'a readable directory',
682
+ found: 'stat failed',
683
+ detail: firstLine(err),
684
+ }));
382
685
  }
383
- return checks;
686
+ if (!readable) {
687
+ checks.push(mk({
688
+ id: 'project_markers',
689
+ name: 'Project markers in the working directory',
690
+ status: 'unknown',
691
+ source: cwd,
692
+ expected: 'package.json and/or manifest.json',
693
+ found: 'the directory could not be read',
694
+ detail: couldNotVerify('the project markers', 'the working directory is not readable'),
695
+ }));
696
+ return;
697
+ }
698
+ const hasManifest = await fs.access(path.join(cwd, 'manifest.json')).then(() => true, () => false);
699
+ const hasPackage = await fs.access(path.join(cwd, 'package.json')).then(() => true, () => false);
700
+ const found = [hasPackage ? 'package.json' : null, hasManifest ? 'manifest.json' : null]
701
+ .filter(Boolean).join(' + ');
702
+ checks.push(mk({
703
+ id: 'project_markers',
704
+ name: 'Project markers in the working directory',
705
+ status: 'ok',
706
+ source: cwd,
707
+ expected: 'package.json and/or manifest.json (absence is informational -- doctor runs anywhere)',
708
+ found: found || 'neither',
709
+ detail: found || '(neither package.json nor manifest.json in cwd)',
710
+ }));
384
711
  }
712
+ /* ------------------------------------------------------------------ */
713
+ /* HITO 4 probes */
714
+ /* ------------------------------------------------------------------ */
385
715
  /** PND-031 -- 3 HITO 4 doctor probes. Extracted so tests can
386
716
  * exercise them with injected fetch + tmp config dir without
387
717
  * spinning the whole doctor runner. */
@@ -390,86 +720,121 @@ export async function runHito4DoctorChecks(checks, opts = {}) {
390
720
  const fetchImpl = opts.fetchImpl ?? fetch;
391
721
  const timeoutMs = opts.timeoutMs ?? 4_000;
392
722
  const now = (opts.nowMs ?? (() => Date.now()))();
723
+ const dir = opts.configDir ?? configDir();
393
724
  /* (a) HITO 4 worker reachable. The worker's health route is /healthz
394
725
  * (Hono app.get('/healthz')); hitting /v1/health returned a misleading
395
726
  * 404 "unhealthy" even when the worker was fully up. */
727
+ const healthUrl = baseUrl.replace(/\/+$/, '') + '/healthz';
396
728
  try {
397
729
  const ctrl = new AbortController();
398
730
  const timer = setTimeout(() => ctrl.abort(), timeoutMs);
399
731
  try {
400
- const r = await fetchImpl(baseUrl.replace(/\/+$/, '') + '/healthz', {
401
- method: 'GET',
402
- signal: ctrl.signal,
403
- });
404
- checks.push({
732
+ const r = await fetchImpl(healthUrl, { method: 'GET', signal: ctrl.signal });
733
+ checks.push(mk({
734
+ id: 'hito4_worker',
405
735
  name: 'HITO 4 worker reachable (' + baseUrl + ')',
406
- ok: r.ok,
736
+ status: r.ok ? 'ok' : 'fail',
737
+ source: 'GET ' + healthUrl,
738
+ expected: 'HTTP 2xx',
739
+ found: 'HTTP ' + r.status,
407
740
  detail: r.ok ? 'HTTP ' + r.status : 'HTTP ' + r.status + ' (worker responded but unhealthy)',
408
741
  ...(r.ok ? {} : { hint: 'check worker status / Cloudflare dashboard' }),
409
- });
742
+ }));
410
743
  }
411
744
  finally {
412
745
  clearTimeout(timer);
413
746
  }
414
747
  }
415
748
  catch (err) {
416
- const msg = err instanceof Error ? err.message : String(err);
417
- checks.push({
749
+ /* No answer at all. We did not verify the worker is DOWN -- we
750
+ * verified we could not reach it from here, which a firewall, a
751
+ * proxy or an offline laptop also produce. */
752
+ checks.push(mk({
753
+ id: 'hito4_worker',
418
754
  name: 'HITO 4 worker reachable (' + baseUrl + ')',
419
- ok: false,
420
- detail: 'unreachable: ' + msg.slice(0, 80),
755
+ status: 'unknown',
756
+ source: 'GET ' + healthUrl,
757
+ expected: 'HTTP 2xx',
758
+ found: 'no response (' + firstLine(err) + ')',
759
+ detail: couldNotVerify('the worker', 'unreachable from this machine: ' + firstLine(err)),
421
760
  hint: 'check internet, DNS, or set YF_HITO4_BASE_URL for a staging override',
422
- });
761
+ }));
423
762
  }
424
763
  /* (b) worker-auth signing key configured. The HITO 4 client signs every
425
764
  * call with the per-handle key (`yf_handle_key`, derived on first use)
426
- * or the root key (`yf_server_api_key`, "the value provided at signup").
427
- * `yujin_license` was a legacy slot name that nothing reads anymore --
428
- * checking it flagged a phantom "missing" on every install. Only needed
429
- * for worker-backed features (mobile relay, managed brain, voice
430
- * gateway, license sync); BYOK desktop chat works without it. */
431
- try {
432
- const v = await Vault.open({ configDir: configDir() });
433
- const has = v.has('yf_handle_key') || v.has('yf_server_api_key');
434
- checks.push({
435
- name: 'Vault slot yf_server_api_key (worker auth)',
436
- ok: has,
765
+ * or the root key (`yf_server_api_key`). Only needed for worker-backed
766
+ * features (mobile relay, managed brain, voice gateway, license sync);
767
+ * BYOK desktop chat works without it. */
768
+ const vaultFile = path.join(dir, 'vault.json');
769
+ let vaultSlots = null;
770
+ let vaultErr = null;
771
+ const vaultPresent = await fs.stat(vaultFile).then(() => true, () => false);
772
+ if (vaultPresent) {
773
+ try {
774
+ const v = await Vault.open({ configDir: dir });
775
+ vaultSlots = v.list().map((s) => s.name);
776
+ }
777
+ catch (err) {
778
+ vaultErr = firstLine(err);
779
+ }
780
+ }
781
+ else {
782
+ vaultSlots = [];
783
+ }
784
+ if (vaultSlots === null) {
785
+ checks.push(mk({
786
+ id: 'worker_auth_key',
787
+ name: 'Worker auth key (mobile relay / managed brain / license sync)',
788
+ status: 'unknown',
789
+ source: vaultFile + ' (slots yf_handle_key, yf_server_api_key)',
790
+ expected: 'one of the two slots present',
791
+ found: 'the vault could not be opened',
792
+ detail: couldNotVerify('the worker auth slots', vaultErr ?? 'vault unreadable'),
793
+ }));
794
+ }
795
+ else {
796
+ const has = vaultSlots.includes('yf_handle_key') || vaultSlots.includes('yf_server_api_key');
797
+ checks.push(mk({
798
+ id: 'worker_auth_key',
799
+ name: 'Worker auth key (mobile relay / managed brain / license sync)',
800
+ status: has ? 'ok' : 'fail',
801
+ source: vaultFile + ' (slots yf_handle_key, yf_server_api_key)',
802
+ expected: 'one of the two slots present',
803
+ found: has ? 'present' : 'neither slot present',
437
804
  detail: has ? 'configured' : 'missing (only needed for mobile relay / managed brain / voice / license sync)',
438
805
  ...(has ? {} : { hint: 'run `yf vault set yf_server_api_key` with the value provided at signup' }),
439
- });
440
- }
441
- catch (err) {
442
- /* Vault not initialised is the expected pre-install state. */
443
- checks.push({
444
- name: 'Vault slot yf_server_api_key (worker auth)',
445
- ok: false,
446
- detail: 'vault not initialised',
447
- hint: 'open the chat panel once (or run `yf vault set yf_server_api_key`) to create the vault',
448
- });
806
+ }));
449
807
  }
450
808
  /* (c) license.json cached < 24h. */
809
+ const licPath = path.join(dir, 'license.json');
451
810
  try {
452
- const licPath = path.join(configDir(), 'license.json');
453
811
  const stat = await fs.stat(licPath);
454
812
  const ageMs = now - stat.mtimeMs;
455
813
  const ageHours = Math.round(ageMs / 3_600_000);
456
814
  const fresh = ageMs < 24 * 3_600_000;
457
- checks.push({
815
+ checks.push(mk({
816
+ id: 'license_cache',
458
817
  name: 'License cache freshness (license.json)',
459
- ok: fresh,
460
- detail: fresh
461
- ? 'updated ' + ageHours + 'h ago'
462
- : 'stale: last updated ' + ageHours + 'h ago (> 24h)',
818
+ status: fresh ? 'ok' : 'fail',
819
+ source: licPath + ' (mtime)',
820
+ expected: 'mtime within 24h',
821
+ found: ageHours + 'h old',
822
+ detail: fresh ? 'updated ' + ageHours + 'h ago' : 'stale: last updated ' + ageHours + 'h ago (> 24h)',
463
823
  ...(fresh ? {} : { hint: 'run `yf license sync` to refresh from the server' }),
464
- });
824
+ }));
465
825
  }
466
- catch {
467
- checks.push({
826
+ catch (err) {
827
+ const absent = err.code === 'ENOENT';
828
+ checks.push(mk({
829
+ id: 'license_cache',
468
830
  name: 'License cache freshness (license.json)',
469
- ok: false,
470
- detail: 'no license.json yet',
831
+ status: absent ? 'fail' : 'unknown',
832
+ source: licPath + ' (mtime)',
833
+ expected: 'mtime within 24h',
834
+ found: absent ? 'file absent' : 'stat failed',
835
+ detail: absent ? 'no license.json yet' : couldNotVerify('the license cache age', firstLine(err)),
471
836
  hint: 'run `yf license activate --user-handle <you@example.com>` then `yf license sync`',
472
- });
837
+ }));
473
838
  }
474
839
  }
475
840
  //# sourceMappingURL=doctor.js.map