@nac3/forge-cli 1.20.2 → 1.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/doctor.d.ts +69 -7
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +694 -329
- package/dist/commands/doctor.js.map +1 -1
- package/dist/core/brain_config.d.ts +11 -0
- package/dist/core/brain_config.d.ts.map +1 -1
- package/dist/core/brain_config.js +21 -5
- package/dist/core/brain_config.js.map +1 -1
- package/dist/core/provider_keys.d.ts +39 -0
- package/dist/core/provider_keys.d.ts.map +1 -1
- package/dist/core/provider_keys.js +54 -0
- package/dist/core/provider_keys.js.map +1 -1
- package/dist/core/whats_new.d.ts.map +1 -1
- package/dist/core/whats_new.js +27 -0
- package/dist/core/whats_new.js.map +1 -1
- package/dist/license/auto_sync.d.ts.map +1 -1
- package/dist/license/auto_sync.js +14 -0
- package/dist/license/auto_sync.js.map +1 -1
- package/dist/license/index.d.ts +15 -0
- package/dist/license/index.d.ts.map +1 -1
- package/dist/license/index.js +41 -0
- package/dist/license/index.js.map +1 -1
- package/dist/relay/board_snapshot.d.ts +14 -0
- package/dist/relay/board_snapshot.d.ts.map +1 -1
- package/dist/relay/board_snapshot.js +82 -2
- package/dist/relay/board_snapshot.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/voice/registry.d.ts +24 -0
- package/dist/voice/registry.d.ts.map +1 -1
- package/dist/voice/registry.js +13 -0
- package/dist/voice/registry.js.map +1 -1
- package/package.json +1 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -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 {
|
|
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.
|
|
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 (
|
|
26
|
-
console.log(' ' + c.dim('
|
|
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) =>
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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;
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
...(
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
332
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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
|
-
|
|
350
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
664
|
+
readable = stat.isDirectory();
|
|
665
|
+
checks.push(mk({
|
|
666
|
+
id: 'cwd_readable',
|
|
360
667
|
name: 'Current working directory readable',
|
|
361
|
-
ok:
|
|
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
|
-
|
|
377
|
-
|
|
676
|
+
checks.push(mk({
|
|
677
|
+
id: 'cwd_readable',
|
|
378
678
|
name: 'Current working directory readable',
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
679
|
+
status: 'fail',
|
|
680
|
+
source: cwd,
|
|
681
|
+
expected: 'a readable directory',
|
|
682
|
+
found: 'stat failed',
|
|
683
|
+
detail: firstLine(err),
|
|
684
|
+
}));
|
|
382
685
|
}
|
|
383
|
-
|
|
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(
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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
|
-
|
|
417
|
-
|
|
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
|
-
|
|
420
|
-
|
|
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
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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:
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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
|
-
|
|
470
|
-
|
|
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
|