stikfix 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,556 @@
1
+ /**
2
+ * Native-messaging manifest writer, per-OS path resolver, Windows registry
3
+ * registration, uninstall enumerator, and desktop launcher creator for stikfix.
4
+ *
5
+ * Node builtins only — no WXT, no Chrome imports.
6
+ *
7
+ * Security:
8
+ * - T-09-02: buildManifest resolves an ABSOLUTE path (Pitfall 4)
9
+ * - T-09-03: Windows registry writes use HKCU (Pitfall 5), execFileSync (never exec)
10
+ * - T-09-05: enumerateArtifacts lists every init-created artifact (ONB-05)
11
+ * - Launcher shortcut: execFile(powershell, [argArray]) — NEVER exec or shell interpolation
12
+ *
13
+ * Analog: host/src/config.ts (mkdirSync/writeFileSync/existsSync/rmSync patterns)
14
+ */
15
+ import { mkdirSync, writeFileSync, rmSync, chmodSync } from 'node:fs';
16
+ import { resolve, join, dirname } from 'node:path';
17
+ import { homedir } from 'node:os';
18
+ import { execFileSync } from 'node:child_process';
19
+ import { isInsideDir } from '../security.js';
20
+ // The native host name — must match the manifest JSON `name` field and the
21
+ // value passed to chrome.runtime.sendNativeMessage in background.ts.
22
+ const NATIVE_HOST_NAME = 'com.stikfix.host';
23
+ const MANIFEST_FILENAME = `${NATIVE_HOST_NAME}.json`;
24
+ // On Windows the Firefox manifest shares the stikfix data dir with the Chrome
25
+ // one, so it needs a distinct filename to avoid clobbering the Chrome manifest.
26
+ // (On macOS/Linux Firefox uses a separate Mozilla dir, so this filename only
27
+ // differs the win32 on-disk JSON; the registry value points straight at it.)
28
+ const FIREFOX_MANIFEST_FILENAME = `${NATIVE_HOST_NAME}.firefox.json`;
29
+ // Native-messaging launcher wrapper file names. On Windows, Chrome launches the
30
+ // native host via CreateProcess, which cannot execute a .cjs directly — so the
31
+ // manifest must point at a wrapper that runs `node <abs cjs>`.
32
+ const NATIVE_WRAPPER_WIN = `${NATIVE_HOST_NAME}.bat`;
33
+ const NATIVE_WRAPPER_NIX = `${NATIVE_HOST_NAME}.sh`;
34
+ // Firefox wrapper file names — MUST be distinct from the Chrome wrapper. On
35
+ // Windows both browsers share the stikfix data dir, so a shared wrapper file
36
+ // would be deleted by a Firefox uninstall and break a co-installed Chrome whose
37
+ // manifest still references that absolute path (cross-browser regression). The
38
+ // suffix mirrors FIREFOX_MANIFEST_FILENAME so manifest+wrapper stay paired.
39
+ const NATIVE_WRAPPER_WIN_FIREFOX = `${NATIVE_HOST_NAME}.firefox.bat`;
40
+ const NATIVE_WRAPPER_NIX_FIREFOX = `${NATIVE_HOST_NAME}.firefox.sh`;
41
+ // Chrome/Edge registry key prefixes (Windows HKCU — Pitfall 5: never HKLM)
42
+ const REG_CHROME_KEY = `HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${NATIVE_HOST_NAME}`;
43
+ const REG_EDGE_KEY = `HKCU\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\${NATIVE_HOST_NAME}`;
44
+ // Firefox registry key prefix (Windows HKCU — same HKCU rule as Chrome/Edge)
45
+ const REG_FIREFOX_KEY = `HKCU\\Software\\Mozilla\\NativeMessagingHosts\\${NATIVE_HOST_NAME}`;
46
+ // The default add-on id used by the Firefox path. Must match
47
+ // browser_specific_settings.gecko.id in wxt.config.ts and the
48
+ // allowed_extensions entry in the Firefox native-messaging manifest.
49
+ export const DEFAULT_GECKO_ID = 'stikfix@stikfix.com';
50
+ // Config file location (read by native host at startup)
51
+ const CONFIG_DIR = (home) => join(home, '.config', 'stikfix');
52
+ const CONFIG_PATH = (home) => join(CONFIG_DIR(home), 'config.json');
53
+ // Launcher file names used across functions
54
+ const LAUNCHER_BATCH_FILENAME = 'stikfix-host.bat';
55
+ const LAUNCHER_VBS_FILENAME = 'stikfix-host.vbs';
56
+ const LAUNCHER_LNK_FILENAME = 'Stikfix Host.lnk';
57
+ const LAUNCHER_COMMAND_FILENAME = 'stikfix-host.command';
58
+ const LAUNCHER_DESKTOP_FILENAME = 'stikfix-host.desktop';
59
+ const LAUNCHER_SH_FILENAME = 'stikfix-host.sh';
60
+ // ---------------------------------------------------------------------------
61
+ // nativeManifestPath — per-OS path resolver
62
+ // ---------------------------------------------------------------------------
63
+ /**
64
+ * Return the absolute path where the native-messaging manifest JSON should
65
+ * be written for the given platform and home directory.
66
+ *
67
+ * Paths verified via Chrome + Edge native-messaging docs (RESEARCH Pattern 4):
68
+ * darwin: ~/Library/App Support/Google/Chrome/NativeMessagingHosts/
69
+ * linux: ~/.config/google-chrome/NativeMessagingHosts/
70
+ * win32: ~/.local/share/stikfix/ (manifest only; registry key written separately)
71
+ */
72
+ export function nativeManifestPath(plat = process.platform, home = homedir(), browser = 'chrome') {
73
+ if (browser === 'firefox') {
74
+ switch (plat) {
75
+ case 'darwin':
76
+ return join(home, 'Library', 'Application Support', 'Mozilla', 'NativeMessagingHosts', MANIFEST_FILENAME);
77
+ case 'linux':
78
+ return join(home, '.mozilla', 'native-messaging-hosts', MANIFEST_FILENAME);
79
+ case 'win32':
80
+ // Windows: the manifest JSON lives on disk in the stikfix data dir
81
+ // (same place as the Chrome one would, but a distinct filename via the
82
+ // firefox suffix so both can coexist); the registry key points at it.
83
+ return join(home, '.local', 'share', 'stikfix', FIREFOX_MANIFEST_FILENAME);
84
+ default:
85
+ throw new Error(`Unsupported platform: ${plat}`);
86
+ }
87
+ }
88
+ switch (plat) {
89
+ case 'darwin':
90
+ return join(home, 'Library', 'Application Support', 'Google', 'Chrome', 'NativeMessagingHosts', MANIFEST_FILENAME);
91
+ case 'linux':
92
+ return join(home, '.config', 'google-chrome', 'NativeMessagingHosts', MANIFEST_FILENAME);
93
+ case 'win32':
94
+ return join(home, '.local', 'share', 'stikfix', MANIFEST_FILENAME);
95
+ default:
96
+ throw new Error(`Unsupported platform: ${plat}`);
97
+ }
98
+ }
99
+ // ---------------------------------------------------------------------------
100
+ // nativeWrapperPath / writeNativeWrapper — native-messaging launcher wrapper
101
+ // ---------------------------------------------------------------------------
102
+ /**
103
+ * Return the path of the native-messaging launcher wrapper, in the SAME
104
+ * directory as the manifest. On win32 a .bat, otherwise a .sh.
105
+ */
106
+ export function nativeWrapperPath(plat = process.platform, home = homedir(), browser = 'chrome') {
107
+ const dir = dirname(nativeManifestPath(plat, home, browser));
108
+ const winName = browser === 'firefox' ? NATIVE_WRAPPER_WIN_FIREFOX : NATIVE_WRAPPER_WIN;
109
+ const nixName = browser === 'firefox' ? NATIVE_WRAPPER_NIX_FIREFOX : NATIVE_WRAPPER_NIX;
110
+ return join(dir, plat === 'win32' ? winName : nixName);
111
+ }
112
+ /**
113
+ * Write the per-OS launcher wrapper that runs `node <abs cjs>`, returning its
114
+ * path. Chrome's CreateProcess (Windows) cannot execute a .cjs directly; the
115
+ * manifest must point at this wrapper instead of the raw .cjs.
116
+ *
117
+ * Security: `abs` is a developer-controlled absolute path (no user input);
118
+ * this is a file write, not exec — no injection vector.
119
+ */
120
+ export function writeNativeWrapper(hostBinPath, plat = process.platform, home = homedir(), browser = 'chrome') {
121
+ const wrapperPath = nativeWrapperPath(plat, home, browser);
122
+ mkdirSync(dirname(wrapperPath), { recursive: true });
123
+ const abs = resolve(hostBinPath);
124
+ if (plat === 'win32') {
125
+ const content = `@echo off\r\n"node" "${abs}" %*\r\n`;
126
+ writeFileSync(wrapperPath, content, { encoding: 'utf8' });
127
+ }
128
+ else {
129
+ const content = `#!/bin/sh\nexec node "${abs}" "$@"\n`;
130
+ writeFileSync(wrapperPath, content, { encoding: 'utf8', mode: 0o755 });
131
+ }
132
+ return wrapperPath;
133
+ }
134
+ // ---------------------------------------------------------------------------
135
+ // launcherDir — where launcher files are stored (alongside the manifest on win32)
136
+ // ---------------------------------------------------------------------------
137
+ /**
138
+ * Return the directory where launcher files (batch, .command, .desktop) are written.
139
+ * On win32: ~/.local/share/stikfix/ (same dir as the manifest)
140
+ * On darwin/linux: ~/.config/stikfix/
141
+ */
142
+ export function launcherDir(plat = process.platform, home = homedir()) {
143
+ if (plat === 'win32') {
144
+ return join(home, '.local', 'share', 'stikfix');
145
+ }
146
+ return join(home, '.config', 'stikfix');
147
+ }
148
+ /**
149
+ * Return the expected paths of all launcher files for the given platform.
150
+ * These paths are used by both createLauncherFiles and enumerateArtifacts.
151
+ */
152
+ export function getLauncherPaths(plat = process.platform, home = homedir()) {
153
+ const dir = launcherDir(plat, home);
154
+ if (plat === 'win32') {
155
+ return {
156
+ launcher: join(dir, LAUNCHER_BATCH_FILENAME),
157
+ shortcut: join(home, 'Desktop', LAUNCHER_LNK_FILENAME),
158
+ desktopEntry: null,
159
+ };
160
+ }
161
+ if (plat === 'darwin') {
162
+ return {
163
+ launcher: join(dir, LAUNCHER_COMMAND_FILENAME),
164
+ shortcut: null,
165
+ desktopEntry: null,
166
+ };
167
+ }
168
+ // linux
169
+ return {
170
+ launcher: join(dir, LAUNCHER_SH_FILENAME),
171
+ shortcut: null,
172
+ desktopEntry: join(home, '.local', 'share', 'applications', LAUNCHER_DESKTOP_FILENAME),
173
+ };
174
+ }
175
+ // ---------------------------------------------------------------------------
176
+ // buildManifest — manifest object builder
177
+ // ---------------------------------------------------------------------------
178
+ /** Regex: exactly 32 lowercase a-p characters (Chrome extension ID alphabet) */
179
+ const EXT_ID_RE = /^[a-p]{32}$/;
180
+ /**
181
+ * Regex: a Firefox/gecko add-on id. Firefox accepts either an email-style id
182
+ * (`name@domain`) or a UUID in braces; stikfix ships the email-style id
183
+ * `stikfix@stikfix.com`. We validate the `local@domain` shape (no spaces,
184
+ * a single @, a dot-bearing domain) rather than the Chrome a-p alphabet.
185
+ */
186
+ const GECKO_ID_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
187
+ /**
188
+ * Build a native-messaging manifest object for the given extension identity
189
+ * and host binary path.
190
+ *
191
+ * - hostBinPath is resolved to an ABSOLUTE path (Pitfall 4).
192
+ * - browser 'chrome' (default): `extensionId` must be exactly 32 lowercase a-p
193
+ * chars; emits `allowed_origins: ["chrome-extension://<id>/"]`.
194
+ * - browser 'firefox': `extensionId` is the gecko add-on id (e.g.
195
+ * `stikfix@stikfix.com`); emits `allowed_extensions: [<gecko-id>]`.
196
+ * Firefox rejects a manifest carrying `allowed_origins`, so the two shapes
197
+ * are mutually exclusive.
198
+ */
199
+ export function buildManifest(extensionId, hostBinPath, browser = 'chrome') {
200
+ const absPath = resolve(hostBinPath);
201
+ if (browser === 'firefox') {
202
+ if (!GECKO_ID_RE.test(extensionId)) {
203
+ throw new Error(`Invalid Firefox add-on id "${extensionId}": must be a gecko id like "name@domain.tld".`);
204
+ }
205
+ return {
206
+ name: NATIVE_HOST_NAME,
207
+ description: 'stikfix native messaging host',
208
+ path: absPath,
209
+ type: 'stdio',
210
+ allowed_extensions: [extensionId],
211
+ };
212
+ }
213
+ if (!EXT_ID_RE.test(extensionId)) {
214
+ throw new Error(`Invalid extension ID "${extensionId}": must be exactly 32 lowercase a-p characters.`);
215
+ }
216
+ return {
217
+ name: NATIVE_HOST_NAME,
218
+ description: 'stikfix native messaging host',
219
+ path: absPath,
220
+ type: 'stdio',
221
+ allowed_origins: [`chrome-extension://${extensionId}/`],
222
+ };
223
+ }
224
+ // ---------------------------------------------------------------------------
225
+ // writeManifest — write manifest JSON to disk
226
+ // ---------------------------------------------------------------------------
227
+ /**
228
+ * Write the manifest object to `manifestPath`, creating parent directories
229
+ * if needed. Mode 0o644 (non-credential — readable by Chrome process).
230
+ *
231
+ * Analog: ensureNotesDir (mkdirSync recursive) + writeTokenFile pattern.
232
+ */
233
+ export function writeManifest(manifest, manifestPath) {
234
+ mkdirSync(dirname(manifestPath), { recursive: true });
235
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), {
236
+ encoding: 'utf8',
237
+ mode: 0o644,
238
+ });
239
+ }
240
+ /**
241
+ * Create a double-click launcher that starts the HTTP host backend,
242
+ * so the user never has to open a terminal.
243
+ *
244
+ * Windows: writes stikfix-host.bat in the stikfix data dir, then creates
245
+ * a Desktop shortcut (.lnk) pointing at that batch file. If the .lnk creation
246
+ * fails, the batch file alone is the acceptable fallback.
247
+ *
248
+ * macOS: writes an executable stikfix-host.command (bash script).
249
+ *
250
+ * Linux: writes stikfix-host.sh + a .desktop entry.
251
+ *
252
+ * Security: the ONLY subprocess allowed is the Windows .lnk creation via
253
+ * execFile(powershell, [argArray]) — mirroring folder-picker.ts safety exactly.
254
+ * No user-controlled values are interpolated into the PowerShell script string;
255
+ * all paths are developer-controlled absolute paths validated before use.
256
+ *
257
+ * Node builtins only (fs, child_process.execFile).
258
+ */
259
+ export function createLauncherFiles(opts) {
260
+ const plat = opts.plat ?? process.platform;
261
+ const home = opts.home ?? homedir();
262
+ const written = [];
263
+ const warnings = [];
264
+ const paths = getLauncherPaths(plat, home);
265
+ const launcherDirPath = launcherDir(plat, home);
266
+ mkdirSync(launcherDirPath, { recursive: true });
267
+ const nodeCmd = 'node';
268
+ const hostEntry = opts.hostEntryPath;
269
+ const rootArg = opts.root;
270
+ const portArg = opts.port !== undefined ? ` --port ${opts.port}` : '';
271
+ if (plat === 'win32') {
272
+ // Write batch file — plain text, no exec required
273
+ const batchContent = [
274
+ '@echo off',
275
+ `rem Stikfix HTTP host launcher — double-click to start the backend`,
276
+ `rem Generated by: npx stikfix init`,
277
+ `rem Root: ${rootArg}`,
278
+ ``,
279
+ `"${nodeCmd}" "${hostEntry}" --root "${rootArg}"${portArg}`,
280
+ `if %ERRORLEVEL% NEQ 0 pause`,
281
+ ].join('\r\n');
282
+ const batchPath = paths.launcher;
283
+ writeFileSync(batchPath, batchContent, { encoding: 'utf8' });
284
+ written.push(batchPath);
285
+ // Write a VBScript that launches the host HIDDEN (no console window) and
286
+ // shows an auto-dismissing native Windows dialog confirming it's running.
287
+ // VBS string-literal escaping: a literal " is doubled ("").
288
+ const vq = (s) => s.replace(/"/g, '""');
289
+ const vbsHostEntry = vq(hostEntry);
290
+ const vbsRoot = vq(rootArg);
291
+ const vbsPortArg = opts.port !== undefined ? ` --port ${opts.port}` : '';
292
+ const vbsContent = [
293
+ 'Option Explicit',
294
+ 'Dim sh, fso, hostEntry, root, portFile, msg, port, f',
295
+ 'Set sh = CreateObject("WScript.Shell")',
296
+ 'Set fso = CreateObject("Scripting.FileSystemObject")',
297
+ `hostEntry = "${vbsHostEntry}"`,
298
+ `root = "${vbsRoot}"`,
299
+ "' Launch the host hidden (window style 0), do not wait",
300
+ `sh.Run "cmd /c node """ & hostEntry & """ --root """ & root & """${vbsPortArg}", 0, False`,
301
+ "' Give it a moment to bind and write the port file",
302
+ 'WScript.Sleep 1800',
303
+ 'msg = "Stikfix host is running." & vbCrLf & vbCrLf & "You can start dropping notes."',
304
+ 'portFile = root & "\\.stikfix-port"',
305
+ 'If fso.FileExists(portFile) Then',
306
+ ' Set f = fso.OpenTextFile(portFile, 1)',
307
+ ' port = Trim(f.ReadAll)',
308
+ ' f.Close',
309
+ ' If Len(port) > 0 Then msg = "Stikfix host is running on port " & port & "." & vbCrLf & vbCrLf & "You can start dropping notes."',
310
+ 'End If',
311
+ 'sh.Popup msg, 5, "Stikfix", 64',
312
+ ].join('\r\n');
313
+ const vbsPath = join(launcherDir(plat, home), LAUNCHER_VBS_FILENAME);
314
+ writeFileSync(vbsPath, vbsContent, { encoding: 'utf8' });
315
+ written.push(vbsPath);
316
+ // Create Desktop shortcut (.lnk) via PowerShell WScript.Shell.
317
+ // The shortcut launches the VBS via wscript.exe so the console stays HIDDEN.
318
+ // Security: ALL paths are developer-controlled absolute strings — no user input.
319
+ // execFile (not exec) — no shell spawned, no injection vector.
320
+ // If this fails for any reason, fall through to the warning (batch file is the fallback).
321
+ if (paths.shortcut) {
322
+ const lnkPath = paths.shortcut;
323
+ const iconPath = opts.iconPath ?? '';
324
+ // Build PowerShell script as a single string — paths passed as PS variables
325
+ // set from the -Command string. No shell metacharacter interpolation from
326
+ // external data; all values are constants determined at registration time.
327
+ // PowerShell single-quote escaping: replace ' with '' in path strings.
328
+ const safeLnkPath = lnkPath.replace(/'/g, "''");
329
+ const safeIconPath = iconPath.replace(/'/g, "''");
330
+ const wscriptPath = join(process.env['SystemRoot'] ?? 'C:\\Windows', 'System32', 'wscript.exe');
331
+ const safeWscript = wscriptPath.replace(/'/g, "''");
332
+ const safeVbsPath = vbsPath.replace(/'/g, "''");
333
+ const psScript = `$ws = New-Object -ComObject WScript.Shell;` +
334
+ `$s = $ws.CreateShortcut('${safeLnkPath}');` +
335
+ `$s.TargetPath = '${safeWscript}';` +
336
+ `$s.Arguments = '"${safeVbsPath}"';` +
337
+ `$s.Description = 'Start the Stikfix HTTP backend host';` +
338
+ (safeIconPath ? `$s.IconLocation = '${safeIconPath},0';` : '') +
339
+ `$s.Save()`;
340
+ // execFileSync — NEVER exec; arg array is static with respect to user input.
341
+ // Synchronous so init reports the shortcut accurately (the async variant
342
+ // raced existsSync in the CLI and printed a false "skipped" message).
343
+ try {
344
+ execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', psScript], { timeout: 15_000, stdio: 'ignore' });
345
+ // Only record the shortcut once it actually exists on disk.
346
+ written.push(lnkPath);
347
+ }
348
+ catch (err) {
349
+ // Non-fatal: batch file is the acceptable fallback
350
+ warnings.push(`Desktop shortcut creation failed (non-fatal): ${String(err.message)}. ` +
351
+ `The batch file at ${batchPath} can be used directly.`);
352
+ }
353
+ }
354
+ }
355
+ else if (plat === 'darwin') {
356
+ // macOS: executable .command file (double-click in Finder to run in Terminal)
357
+ const commandContent = [
358
+ '#!/bin/bash',
359
+ `# Stikfix HTTP host launcher — double-click in Finder or drag to Dock`,
360
+ `# Generated by: npx stikfix init`,
361
+ ``,
362
+ `exec "${nodeCmd}" "${hostEntry}" --root "${rootArg}"${portArg}`,
363
+ ].join('\n');
364
+ const commandPath = paths.launcher;
365
+ writeFileSync(commandPath, commandContent, { encoding: 'utf8', mode: 0o755 });
366
+ written.push(commandPath);
367
+ }
368
+ else {
369
+ // Linux: executable shell script + .desktop entry
370
+ const shContent = [
371
+ '#!/bin/sh',
372
+ `# Stikfix HTTP host launcher`,
373
+ `# Generated by: npx stikfix init`,
374
+ ``,
375
+ `exec "${nodeCmd}" "${hostEntry}" --root "${rootArg}"${portArg}`,
376
+ ].join('\n');
377
+ const shPath = paths.launcher;
378
+ writeFileSync(shPath, shContent, { encoding: 'utf8' });
379
+ try {
380
+ chmodSync(shPath, 0o755);
381
+ }
382
+ catch {
383
+ warnings.push(`Could not chmod 755 ${shPath} — run: chmod +x "${shPath}"`);
384
+ }
385
+ written.push(shPath);
386
+ // .desktop entry
387
+ if (paths.desktopEntry) {
388
+ const desktopDir = dirname(paths.desktopEntry);
389
+ mkdirSync(desktopDir, { recursive: true });
390
+ const iconLine = opts.iconPath ? `Icon=${opts.iconPath}` : 'Icon=utilities-terminal';
391
+ const desktopContent = [
392
+ '[Desktop Entry]',
393
+ 'Version=1.0',
394
+ 'Type=Application',
395
+ 'Name=Stikfix Host',
396
+ 'Comment=Start the Stikfix HTTP backend host',
397
+ `Exec=${shPath}`,
398
+ iconLine,
399
+ 'Terminal=true',
400
+ 'Categories=Development;',
401
+ ].join('\n');
402
+ writeFileSync(paths.desktopEntry, desktopContent, { encoding: 'utf8', mode: 0o644 });
403
+ written.push(paths.desktopEntry);
404
+ }
405
+ }
406
+ return { written, warnings };
407
+ }
408
+ /**
409
+ * Write the native-messaging manifest and, on Windows, register the matching
410
+ * HKCU registry key(s):
411
+ * - chrome → Google\Chrome + Microsoft\Edge keys, `allowed_origins`
412
+ * - firefox → Mozilla key, `allowed_extensions`
413
+ *
414
+ * execFileSync is used (NEVER exec, NEVER shell) — T-09-03.
415
+ */
416
+ export function registerNativeHost(opts) {
417
+ const plat = opts.plat ?? process.platform;
418
+ const home = opts.home ?? homedir();
419
+ const browser = opts.browser ?? 'chrome';
420
+ const manifestPath = nativeManifestPath(plat, home, browser);
421
+ // Chrome's CreateProcess (and Firefox on Windows) cannot run a .cjs directly —
422
+ // point the manifest at a per-OS wrapper that runs `node <abs cjs>` instead.
423
+ const wrapperPath = writeNativeWrapper(opts.hostBinPath, plat, home, browser);
424
+ const manifest = buildManifest(opts.extensionId, wrapperPath, browser);
425
+ writeManifest(manifest, manifestPath);
426
+ if (plat === 'win32') {
427
+ const execReg = opts.execReg ?? ((args) => {
428
+ execFileSync('reg', args);
429
+ });
430
+ if (browser === 'firefox') {
431
+ // Register for Firefox (HKCU — Pitfall 5, never HKLM)
432
+ execReg(['ADD', REG_FIREFOX_KEY, '/ve', '/t', 'REG_SZ', '/d', manifestPath, '/f']);
433
+ }
434
+ else {
435
+ // Register for Chrome (HKCU — Pitfall 5, never HKLM)
436
+ execReg(['ADD', REG_CHROME_KEY, '/ve', '/t', 'REG_SZ', '/d', manifestPath, '/f']);
437
+ // Register for Edge (drop-in, D-05)
438
+ execReg(['ADD', REG_EDGE_KEY, '/ve', '/t', 'REG_SZ', '/d', manifestPath, '/f']);
439
+ }
440
+ }
441
+ }
442
+ /**
443
+ * Remove the native-messaging manifest, launcher files, Desktop shortcut,
444
+ * and on Windows delete the matching HKCU registry key(s):
445
+ * - chrome → Google\Chrome + Microsoft\Edge keys
446
+ * - firefox → Mozilla key
447
+ *
448
+ * Idempotent: does not throw if any artifact is already absent.
449
+ * execFileSync is used (NEVER exec) with the /f flag to tolerate absent keys.
450
+ */
451
+ export function unregisterNativeHost(opts = {}) {
452
+ const plat = opts.plat ?? process.platform;
453
+ const home = opts.home ?? homedir();
454
+ const browser = opts.browser ?? 'chrome';
455
+ const manifestPath = opts.manifestPath ?? nativeManifestPath(plat, home, browser);
456
+ rmSync(manifestPath, { force: true });
457
+ rmSync(nativeWrapperPath(plat, home, browser), { force: true });
458
+ // Remove launcher files
459
+ const defaultPaths = getLauncherPaths(plat, home);
460
+ const lPaths = {
461
+ launcher: opts.launcherPaths?.launcher ?? defaultPaths.launcher,
462
+ shortcut: opts.launcherPaths?.shortcut !== undefined
463
+ ? opts.launcherPaths.shortcut
464
+ : defaultPaths.shortcut,
465
+ desktopEntry: opts.launcherPaths?.desktopEntry !== undefined
466
+ ? opts.launcherPaths.desktopEntry
467
+ : defaultPaths.desktopEntry,
468
+ };
469
+ rmSync(lPaths.launcher, { force: true });
470
+ if (lPaths.shortcut)
471
+ rmSync(lPaths.shortcut, { force: true });
472
+ if (lPaths.desktopEntry)
473
+ rmSync(lPaths.desktopEntry, { force: true });
474
+ // Remove the hidden-launch VBS (win32 only — written by createLauncherFiles)
475
+ if (plat === 'win32') {
476
+ rmSync(join(launcherDir(plat, home), LAUNCHER_VBS_FILENAME), { force: true });
477
+ }
478
+ if (plat === 'win32') {
479
+ // /f flag suppresses "are you sure?" prompt; tolerates absent key (non-zero exit ignored)
480
+ if (browser === 'firefox') {
481
+ try {
482
+ execFileSync('reg', ['DELETE', REG_FIREFOX_KEY, '/f']);
483
+ }
484
+ catch {
485
+ // Key may not exist — ignore
486
+ }
487
+ }
488
+ else {
489
+ try {
490
+ execFileSync('reg', ['DELETE', REG_CHROME_KEY, '/f']);
491
+ }
492
+ catch {
493
+ // Key may not exist — ignore
494
+ }
495
+ try {
496
+ execFileSync('reg', ['DELETE', REG_EDGE_KEY, '/f']);
497
+ }
498
+ catch {
499
+ // Key may not exist — ignore
500
+ }
501
+ }
502
+ }
503
+ }
504
+ /**
505
+ * Return the complete list of filesystem paths and registry keys that
506
+ * `registerNativeHost` + `createLauncherFiles` create, so `uninstall` can
507
+ * remove everything and leave no orphaned artifacts (ONB-05 / T-09-05).
508
+ *
509
+ * paths includes:
510
+ * - native-messaging manifest JSON
511
+ * - stikfix config file (~/.config/stikfix/config.json)
512
+ * - <root>/.stikfix-port (written by HTTP host on startup)
513
+ * - launcher batch/command/sh file
514
+ * - Desktop shortcut (.lnk on win32) or .desktop entry (linux)
515
+ *
516
+ * registryKeys (win32 only):
517
+ * - HKCU Chrome NativeMessagingHosts key
518
+ * - HKCU Edge NativeMessagingHosts key
519
+ */
520
+ export function enumerateArtifacts(opts = {}) {
521
+ const plat = opts.plat ?? process.platform;
522
+ const home = opts.home ?? homedir();
523
+ const root = opts.root;
524
+ const browser = opts.browser ?? 'chrome';
525
+ const paths = [
526
+ nativeManifestPath(plat, home, browser),
527
+ nativeWrapperPath(plat, home, browser),
528
+ CONFIG_PATH(home),
529
+ ];
530
+ // .stikfix-port is written by the HTTP host alongside .stikfix-token
531
+ if (root) {
532
+ paths.push(join(root, '.stikfix-port'));
533
+ }
534
+ else {
535
+ // Include a generic indicator when root is unknown
536
+ paths.push('.stikfix-port');
537
+ }
538
+ // Launcher files created by createLauncherFiles
539
+ const lPaths = getLauncherPaths(plat, home);
540
+ paths.push(lPaths.launcher);
541
+ if (lPaths.shortcut)
542
+ paths.push(lPaths.shortcut);
543
+ if (lPaths.desktopEntry)
544
+ paths.push(lPaths.desktopEntry);
545
+ if (plat === 'win32') {
546
+ paths.push(join(launcherDir(plat, home), LAUNCHER_VBS_FILENAME));
547
+ }
548
+ const registryKeys = plat === 'win32'
549
+ ? browser === 'firefox'
550
+ ? [REG_FIREFOX_KEY]
551
+ : [REG_CHROME_KEY, REG_EDGE_KEY]
552
+ : [];
553
+ return { paths, registryKeys };
554
+ }
555
+ // Re-export isInsideDir for consumers that need path validation
556
+ export { isInsideDir };