stikfix 1.3.2 → 1.6.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.
@@ -0,0 +1,459 @@
1
+ /**
2
+ * `stikfix-host doctor` — environment / registration diagnostics.
3
+ *
4
+ * Runs a series of independent checks (config presence, HTTP host reachability,
5
+ * token/port files, native-messaging manifest + registry wiring, force-install
6
+ * policy, notes dir writability) and reports a checklist — human-readable by
7
+ * default, or a single JSON object with `--json`.
8
+ *
9
+ * Every check is wrapped individually so one thrown check never aborts the
10
+ * rest (a thrown check becomes a 'fail' entry with the error message).
11
+ *
12
+ * win32-focused: on non-win32 platforms the registry-dependent checks
13
+ * (native-manifest, native-registry, forcelist-policy) degrade to a 'warn'
14
+ * with a "this check is win32-only" detail instead of failing or crashing.
15
+ *
16
+ * Node builtins only — no WXT, no Chrome imports. Registry access is
17
+ * READ-ONLY (`reg QUERY` via execFileSync — never `exec`, never a shell
18
+ * string built from external data).
19
+ */
20
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { homedir } from 'node:os';
23
+ import * as http from 'node:http';
24
+ import { execFileSync } from 'node:child_process';
25
+ import { randomUUID } from 'node:crypto';
26
+ import { parseArgs } from 'node:util';
27
+ import { nativeManifestPath } from './bootstrap/register.js';
28
+ import { STABLE_EXTENSION_ID } from './extension-id.js';
29
+ // ---------------------------------------------------------------------------
30
+ // Constants (must match host/src/bootstrap/register.ts + extension-id.ts)
31
+ // ---------------------------------------------------------------------------
32
+ // Must match NATIVE_HOST_NAME in bootstrap/register.ts (not exported there).
33
+ const NATIVE_HOST_NAME = 'com.stikfix.host';
34
+ const NATIVE_MSG_REG_KEY = {
35
+ chrome: `HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${NATIVE_HOST_NAME}`,
36
+ edge: `HKCU\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\${NATIVE_HOST_NAME}`,
37
+ };
38
+ const BROWSER_LABEL = {
39
+ chrome: 'Chrome',
40
+ edge: 'Edge',
41
+ brave: 'Brave',
42
+ };
43
+ // Relative exe paths under Program Files / Program Files (x86) / LocalAppData.
44
+ const BROWSER_EXE_REL = {
45
+ chrome: join('Google', 'Chrome', 'Application', 'chrome.exe'),
46
+ edge: join('Microsoft', 'Edge', 'Application', 'msedge.exe'),
47
+ brave: join('BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'),
48
+ };
49
+ const FORCELIST_REG_KEY_SUFFIX = {
50
+ chrome: 'Software\\Policies\\Google\\Chrome\\ExtensionInstallForcelist',
51
+ edge: 'Software\\Policies\\Microsoft\\Edge\\ExtensionInstallForcelist',
52
+ brave: 'Software\\Policies\\BraveSoftware\\Brave\\ExtensionInstallForcelist',
53
+ };
54
+ // ---------------------------------------------------------------------------
55
+ // Config + default resolution
56
+ // ---------------------------------------------------------------------------
57
+ function configPath(home) {
58
+ return join(home, '.config', 'stikfix', 'config.json');
59
+ }
60
+ /** Default notes root, mirroring exe-main.ts defaultRoot(). */
61
+ function defaultRoot(plat, home) {
62
+ if (plat === 'win32') {
63
+ return join(home, 'Documents', 'stikfix-notes');
64
+ }
65
+ return join(home, 'stikfix-notes');
66
+ }
67
+ function loadConfig(home) {
68
+ const p = configPath(home);
69
+ if (!existsSync(p)) {
70
+ return { data: null, problem: 'missing' };
71
+ }
72
+ try {
73
+ const raw = readFileSync(p, 'utf8');
74
+ const data = JSON.parse(raw);
75
+ return { data };
76
+ }
77
+ catch (err) {
78
+ return {
79
+ data: null,
80
+ problem: 'invalid',
81
+ errorMessage: err instanceof Error ? err.message : String(err),
82
+ };
83
+ }
84
+ }
85
+ // ---------------------------------------------------------------------------
86
+ // reg.exe helpers — pure parsers exported for unit testing, plus the
87
+ // execFileSync wrappers that call reg QUERY (read-only, never write).
88
+ // ---------------------------------------------------------------------------
89
+ /** Parse the `(Default)` value out of `reg QUERY <key> /ve` output. */
90
+ export function parseRegDefaultOutput(output) {
91
+ const m = output.match(/REG_SZ\s+(.+)/);
92
+ return m ? m[1].trim() : null;
93
+ }
94
+ /** Parse every named value out of `reg QUERY <key>` output. */
95
+ export function parseRegValues(output) {
96
+ const lines = output.split(/\r?\n/);
97
+ const result = [];
98
+ for (const line of lines) {
99
+ const m = line.match(/^\s{4}(\S+)\s+REG_(?:SZ|EXPAND_SZ|MULTI_SZ)\s+(.*)$/);
100
+ if (m) {
101
+ result.push({ name: m[1], data: m[2].trim() });
102
+ }
103
+ }
104
+ return result;
105
+ }
106
+ function queryRegDefault(key) {
107
+ try {
108
+ const out = execFileSync('reg', ['QUERY', key, '/ve'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
109
+ return parseRegDefaultOutput(out);
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ }
115
+ function queryRegValuesAt(key) {
116
+ try {
117
+ const out = execFileSync('reg', ['QUERY', key], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
118
+ return parseRegValues(out);
119
+ }
120
+ catch {
121
+ return [];
122
+ }
123
+ }
124
+ // ---------------------------------------------------------------------------
125
+ // Browser detection
126
+ // ---------------------------------------------------------------------------
127
+ function isBrowserInstalled(browser) {
128
+ const rel = BROWSER_EXE_REL[browser];
129
+ const bases = [process.env['ProgramFiles'], process.env['ProgramFiles(x86)'], process.env['LocalAppData']].filter((b) => typeof b === 'string' && b.length > 0);
130
+ return bases.some((base) => existsSync(join(base, rel)));
131
+ }
132
+ /**
133
+ * Broader detection for the forcelist check: exe path OR — for Chrome/Edge,
134
+ * the two browsers stikfix itself registers a native-messaging host for —
135
+ * the presence of our own NativeMessagingHosts key, which is only ever
136
+ * written if that browser was present when `register`/`init` ran. Brave has
137
+ * no native-messaging registration in this project, so only the exe-path
138
+ * signal applies there.
139
+ */
140
+ function isBrowserInstalledForForcelist(browser) {
141
+ if (isBrowserInstalled(browser))
142
+ return true;
143
+ if (browser === 'chrome' || browser === 'edge') {
144
+ return queryRegDefault(NATIVE_MSG_REG_KEY[browser]) !== null;
145
+ }
146
+ return false;
147
+ }
148
+ // ---------------------------------------------------------------------------
149
+ // /status HTTP probe
150
+ // ---------------------------------------------------------------------------
151
+ function fetchStatus(port, timeoutMs) {
152
+ return new Promise((resolvePromise, reject) => {
153
+ const req = http.request({ host: '127.0.0.1', port, path: '/status', method: 'GET', timeout: timeoutMs }, (res) => {
154
+ let data = '';
155
+ res.setEncoding('utf8');
156
+ res.on('data', (chunk) => {
157
+ data += chunk;
158
+ });
159
+ res.on('end', () => {
160
+ if (res.statusCode !== 200) {
161
+ reject(new Error(`unexpected status code ${String(res.statusCode)}`));
162
+ return;
163
+ }
164
+ try {
165
+ resolvePromise(JSON.parse(data));
166
+ }
167
+ catch (err) {
168
+ reject(err instanceof Error ? err : new Error(String(err)));
169
+ }
170
+ });
171
+ });
172
+ req.on('timeout', () => {
173
+ req.destroy(new Error('timed out waiting for /status'));
174
+ });
175
+ req.on('error', (err) => reject(err));
176
+ req.end();
177
+ });
178
+ }
179
+ // ---------------------------------------------------------------------------
180
+ // Individual check implementations — each returns {status, detail} and is
181
+ // invoked through runCheck() below so a thrown error never escapes.
182
+ // ---------------------------------------------------------------------------
183
+ async function checkHostRunning(root) {
184
+ const HOST_NOT_RUNNING_DETAIL = 'host not running — start it via the desktop launcher or it will auto-start on login';
185
+ const portPath = join(root, '.stikfix-port');
186
+ if (!existsSync(portPath)) {
187
+ return { status: 'warn', detail: HOST_NOT_RUNNING_DETAIL };
188
+ }
189
+ const portStr = readFileSync(portPath, 'utf8').trim();
190
+ const port = Number(portStr);
191
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
192
+ return {
193
+ status: 'warn',
194
+ detail: `${HOST_NOT_RUNNING_DETAIL} (invalid port file content: "${portStr}")`,
195
+ };
196
+ }
197
+ try {
198
+ const body = await fetchStatus(port, 1500);
199
+ if (body['app'] === 'stikfix') {
200
+ return {
201
+ status: 'pass',
202
+ detail: `reachable on 127.0.0.1:${port} (version=${String(body['version'] ?? 'unknown')}, root=${String(body['root'] ?? 'unknown')}, notesDir=${String(body['notesDir'] ?? 'unknown')})`,
203
+ };
204
+ }
205
+ return {
206
+ status: 'warn',
207
+ detail: `unexpected response from 127.0.0.1:${port}/status: ${JSON.stringify(body)}`,
208
+ };
209
+ }
210
+ catch {
211
+ return { status: 'warn', detail: HOST_NOT_RUNNING_DETAIL };
212
+ }
213
+ }
214
+ function checkTokenPortFiles(root) {
215
+ const portPath = join(root, '.stikfix-port');
216
+ const tokenPath = join(root, '.stikfix-token');
217
+ const portExists = existsSync(portPath);
218
+ const tokenExists = existsSync(tokenPath);
219
+ if (portExists && tokenExists) {
220
+ return { status: 'pass', detail: `${portPath} and ${tokenPath} both present` };
221
+ }
222
+ const missing = [];
223
+ if (!portExists)
224
+ missing.push(portPath);
225
+ if (!tokenExists)
226
+ missing.push(tokenPath);
227
+ return { status: 'fail', detail: `missing: ${missing.join(', ')}` };
228
+ }
229
+ function checkNativeManifest(plat, home, extensionId) {
230
+ if (plat !== 'win32') {
231
+ return { status: 'warn', detail: `native-manifest check is win32-only; skipped on platform "${plat}"` };
232
+ }
233
+ const manifestPath = nativeManifestPath(plat, home, 'chrome');
234
+ if (!existsSync(manifestPath)) {
235
+ return { status: 'fail', detail: `manifest not found at ${manifestPath}` };
236
+ }
237
+ let manifest;
238
+ try {
239
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
240
+ }
241
+ catch (err) {
242
+ return {
243
+ status: 'fail',
244
+ detail: `manifest at ${manifestPath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
245
+ };
246
+ }
247
+ const hostPath = manifest['path'];
248
+ if (typeof hostPath !== 'string' || !existsSync(hostPath)) {
249
+ return {
250
+ status: 'fail',
251
+ detail: `manifest "path" field ("${String(hostPath)}") does not point at a file that exists on disk`,
252
+ };
253
+ }
254
+ const origins = Array.isArray(manifest['allowed_origins']) ? manifest['allowed_origins'] : [];
255
+ const expectedOrigin = `chrome-extension://${extensionId}/`;
256
+ if (!origins.includes(expectedOrigin)) {
257
+ return {
258
+ status: 'fail',
259
+ detail: `allowed_origins is missing "${expectedOrigin}" (found: ${JSON.stringify(origins)})`,
260
+ };
261
+ }
262
+ return { status: 'pass', detail: `${manifestPath} -> ${hostPath} (allowed_origins OK)` };
263
+ }
264
+ function checkNativeRegistry(plat, manifestPath) {
265
+ if (plat !== 'win32') {
266
+ return { status: 'warn', detail: `native-registry check is win32-only; skipped on platform "${plat}"` };
267
+ }
268
+ const lines = [];
269
+ let anyFail = false;
270
+ let anyWarn = false;
271
+ for (const browser of ['chrome', 'edge']) {
272
+ const installed = isBrowserInstalled(browser);
273
+ const key = NATIVE_MSG_REG_KEY[browser];
274
+ const regValue = queryRegDefault(key);
275
+ if (regValue === null) {
276
+ if (installed) {
277
+ anyFail = true;
278
+ lines.push(`${BROWSER_LABEL[browser]}: MISSING (key not found: ${key})`);
279
+ }
280
+ else {
281
+ anyWarn = true;
282
+ lines.push(`${BROWSER_LABEL[browser]}: not installed — skipped`);
283
+ }
284
+ continue;
285
+ }
286
+ if (regValue === manifestPath) {
287
+ lines.push(`${BROWSER_LABEL[browser]}: OK (${key} -> ${regValue})`);
288
+ }
289
+ else {
290
+ anyFail = true;
291
+ lines.push(`${BROWSER_LABEL[browser]}: MISMATCH (${key} -> "${regValue}", expected "${manifestPath}")`);
292
+ }
293
+ }
294
+ const status = anyFail ? 'fail' : anyWarn ? 'warn' : 'pass';
295
+ return { status, detail: lines.join('\n') };
296
+ }
297
+ function checkForcelistForBrowser(browser, extensionId) {
298
+ const installed = isBrowserInstalledForForcelist(browser);
299
+ if (!installed) {
300
+ return { installed: false, status: 'warn', line: `${BROWSER_LABEL[browser]}: not installed — skipped` };
301
+ }
302
+ const suffix = FORCELIST_REG_KEY_SUFFIX[browser];
303
+ const values = [...queryRegValuesAt(`HKLM\\${suffix}`), ...queryRegValuesAt(`HKCU\\${suffix}`)];
304
+ const match = values.find((v) => v.data.startsWith(`${extensionId};`));
305
+ if (match) {
306
+ const updateUrl = match.data.slice(extensionId.length + 1);
307
+ return { installed: true, status: 'pass', line: `${BROWSER_LABEL[browser]}: OK (update_url=${updateUrl})` };
308
+ }
309
+ return {
310
+ installed: true,
311
+ status: 'warn',
312
+ line: `${BROWSER_LABEL[browser]}: extension not force-installed for ${BROWSER_LABEL[browser]} — run the installer or load unpacked`,
313
+ };
314
+ }
315
+ function checkForcelistPolicy(plat, extensionId) {
316
+ if (plat !== 'win32') {
317
+ return { status: 'warn', detail: `forcelist-policy check is win32-only; skipped on platform "${plat}"` };
318
+ }
319
+ const results = ['chrome', 'edge', 'brave'].map((b) => checkForcelistForBrowser(b, extensionId));
320
+ const anyInstalled = results.some((r) => r.installed);
321
+ if (!anyInstalled) {
322
+ return { status: 'warn', detail: 'No Chromium browsers detected (Chrome, Edge, Brave all missing)' };
323
+ }
324
+ // Never 'fail' — an absent forcelist entry is expected pre-installer (WARN only).
325
+ const status = results.some((r) => r.status === 'warn') ? 'warn' : 'pass';
326
+ return { status, detail: results.map((r) => r.line).join('\n') };
327
+ }
328
+ function checkNotesDir(notesDir) {
329
+ if (!existsSync(notesDir)) {
330
+ return { status: 'fail', detail: `notes directory does not exist: ${notesDir}` };
331
+ }
332
+ const probe = join(notesDir, `.doctor-write-test-${randomUUID()}`);
333
+ try {
334
+ writeFileSync(probe, 'doctor write test');
335
+ unlinkSync(probe);
336
+ return { status: 'pass', detail: `writable: ${notesDir}` };
337
+ }
338
+ catch (err) {
339
+ return {
340
+ status: 'fail',
341
+ detail: `notes directory exists but is not writable: ${notesDir} (${err instanceof Error ? err.message : String(err)})`,
342
+ };
343
+ }
344
+ }
345
+ // ---------------------------------------------------------------------------
346
+ // runCheck — try/catch wrapper so one failing check never aborts doctor
347
+ // ---------------------------------------------------------------------------
348
+ async function runCheck(id, label, fn) {
349
+ try {
350
+ const r = await fn();
351
+ return { id, label, status: r.status, detail: r.detail };
352
+ }
353
+ catch (err) {
354
+ return {
355
+ id,
356
+ label,
357
+ status: 'fail',
358
+ detail: `check threw: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
359
+ };
360
+ }
361
+ }
362
+ // ---------------------------------------------------------------------------
363
+ // collectDoctorResult — runs all checks, returns the structured result.
364
+ // Exported so tests can exercise the full pipeline without going through the
365
+ // CLI argv/print/exitCode plumbing.
366
+ // ---------------------------------------------------------------------------
367
+ export async function collectDoctorResult(opts = {}) {
368
+ const plat = opts.plat ?? process.platform;
369
+ const home = opts.home ?? homedir();
370
+ const loaded = loadConfig(home);
371
+ const cfgData = loaded.data ?? {};
372
+ const root = opts.rootOverride ?? (typeof cfgData['root'] === 'string' ? cfgData['root'] : defaultRoot(plat, home));
373
+ const notesDir = !opts.rootOverride && typeof cfgData['notesDir'] === 'string'
374
+ ? cfgData['notesDir']
375
+ : join(root, 'notes');
376
+ const extensionId = opts.extensionIdOverride ??
377
+ (typeof cfgData['extensionId'] === 'string' ? cfgData['extensionId'] : STABLE_EXTENSION_ID);
378
+ const manifestPath = nativeManifestPath(plat, home, 'chrome');
379
+ const checks = [];
380
+ checks.push(await runCheck('config', 'Config file present and parseable', () => {
381
+ if (loaded.problem === 'missing') {
382
+ return {
383
+ status: 'fail',
384
+ detail: `config.json not found at ${configPath(home)} — using defaults (root=${root}, extensionId=${extensionId})`,
385
+ };
386
+ }
387
+ if (loaded.problem === 'invalid') {
388
+ return {
389
+ status: 'fail',
390
+ detail: `config.json at ${configPath(home)} is not valid JSON: ${loaded.errorMessage ?? 'unknown error'}`,
391
+ };
392
+ }
393
+ return { status: 'pass', detail: `root=${root}, extensionId=${extensionId}` };
394
+ }));
395
+ checks.push(await runCheck('host-running', 'HTTP host reachable', () => checkHostRunning(root)));
396
+ checks.push(await runCheck('token-port-files', 'Token + port files present', () => checkTokenPortFiles(root)));
397
+ checks.push(await runCheck('native-manifest', 'Native-messaging manifest valid', () => checkNativeManifest(plat, home, extensionId)));
398
+ checks.push(await runCheck('native-registry', 'Native-messaging registry keys', () => checkNativeRegistry(plat, manifestPath)));
399
+ checks.push(await runCheck('forcelist-policy', 'Extension force-install policy', () => checkForcelistPolicy(plat, extensionId)));
400
+ checks.push(await runCheck('notes-dir', 'Notes directory exists + writable', () => checkNotesDir(notesDir)));
401
+ const summary = { pass: 0, warn: 0, fail: 0 };
402
+ for (const c of checks) {
403
+ summary[c.status] += 1;
404
+ }
405
+ return { ok: summary.fail === 0, summary, checks };
406
+ }
407
+ // ---------------------------------------------------------------------------
408
+ // Human-readable printer — plain ASCII only (Windows console codepage safe).
409
+ // ---------------------------------------------------------------------------
410
+ function printHuman(result) {
411
+ console.log('Stikfix Doctor');
412
+ console.log('==============');
413
+ console.log('');
414
+ for (const c of result.checks) {
415
+ const tag = c.status === 'pass' ? '[OK]' : c.status === 'warn' ? '[WARN]' : '[FAIL]';
416
+ console.log(`${tag} ${c.label}`);
417
+ for (const line of c.detail.split('\n')) {
418
+ console.log(` ${line}`);
419
+ }
420
+ }
421
+ console.log('');
422
+ const { pass, warn, fail } = result.summary;
423
+ console.log(`${pass} passed, ${warn} warnings, ${fail} failed`);
424
+ }
425
+ // ---------------------------------------------------------------------------
426
+ // runDoctor — CLI entry point (exe-main.ts: `stikfix-host doctor`)
427
+ // ---------------------------------------------------------------------------
428
+ /**
429
+ * Run the doctor diagnostics. `argv` is the args AFTER the `doctor` subcommand.
430
+ *
431
+ * Flags:
432
+ * --json print a single JSON object instead of the checklist
433
+ * --root <path> override the detected/default root
434
+ * --extension-id <id> override the detected/default extension id
435
+ *
436
+ * Sets process.exitCode (not process.exit — this must stay safe to call from
437
+ * a test process) to 1 if any check failed, 0 otherwise.
438
+ */
439
+ export async function runDoctor(argv = []) {
440
+ const { values } = parseArgs({
441
+ args: argv,
442
+ options: {
443
+ json: { type: 'boolean' },
444
+ root: { type: 'string' },
445
+ 'extension-id': { type: 'string' },
446
+ },
447
+ strict: false,
448
+ });
449
+ const rootOverride = typeof values['root'] === 'string' ? values['root'] : undefined;
450
+ const extensionIdOverride = typeof values['extension-id'] === 'string' ? values['extension-id'] : undefined;
451
+ const result = await collectDoctorResult({ rootOverride, extensionIdOverride });
452
+ if (values['json'] === true) {
453
+ console.log(JSON.stringify(result));
454
+ }
455
+ else {
456
+ printHuman(result);
457
+ }
458
+ process.exitCode = result.summary.fail > 0 ? 1 : 0;
459
+ }