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,147 @@
1
+ /**
2
+ * Config resolution for stikfix-host.
3
+ * D-07: token resolution order --token -> STIKFIX_TOKEN -> crypto.randomUUID()
4
+ * D-09: ensureNotesDir creates notesDir + .gitkeep (HOST-12)
5
+ * D-10: resolveConfig rejects notesDir outside root (HOST-09)
6
+ * Pattern 11: VERSION read from package.json at runtime via import.meta.url
7
+ * Windows-PowerShell compat: npm 11.x on Windows strips unknown flags and exposes
8
+ * them as process.env.npm_config_<key>. resolveConfig accepts all three sources
9
+ * with precedence: parsed flag > STIKFIX_* env > npm_config_* env.
10
+ */
11
+ import { readFileSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs';
12
+ import { resolve, join, basename, dirname } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { randomUUID } from 'node:crypto';
15
+ import { isInsideDir } from './security.js';
16
+ // ---------------------------------------------------------------------------
17
+ // VERSION — read from package.json at runtime (Pattern 11)
18
+ // dist/host/src/config.js → ../../../package.json
19
+ // ---------------------------------------------------------------------------
20
+ const __dirname = dirname(fileURLToPath(import.meta.url));
21
+ const _pkg = JSON.parse(readFileSync(join(__dirname, '../../../package.json'), 'utf8'));
22
+ export const VERSION = _pkg.version;
23
+ // ---------------------------------------------------------------------------
24
+ // resolveConfigValues — three-tier env resolution (exported for testing)
25
+ // ---------------------------------------------------------------------------
26
+ /**
27
+ * Merge parsed CLI flags with env-variable fallbacks.
28
+ *
29
+ * Precedence (first defined wins) per key:
30
+ * 1. Real parsed flag (values.root, values.origin, …) — git bash / macOS / Linux
31
+ * 2. STIKFIX_* env (STIKFIX_ROOT, STIKFIX_ORIGINS, …) — explicit env override
32
+ * 3. npm_config_* env (npm_config_root, npm_config_origin, …) — npm-on-Windows PowerShell
33
+ *
34
+ * Callers must not assume root is present — resolveConfig validates below.
35
+ */
36
+ export function resolveConfigValues(values, env = process.env) {
37
+ // root
38
+ const root = values['root'] ??
39
+ env['STIKFIX_ROOT'] ??
40
+ env['npm_config_root'];
41
+ // origin / origins — flag: string[] (multiple:true); env: comma-separated string
42
+ const originFlag = values['origin'];
43
+ let origins;
44
+ if (originFlag !== undefined && originFlag.length > 0) {
45
+ origins = originFlag;
46
+ }
47
+ else {
48
+ const originsEnv = env['STIKFIX_ORIGINS'] ??
49
+ env['npm_config_origin'];
50
+ if (originsEnv !== undefined && originsEnv !== '') {
51
+ origins = originsEnv.split(',').map((s) => s.trim()).filter(Boolean);
52
+ }
53
+ }
54
+ // name
55
+ const name = values['name'] ??
56
+ env['STIKFIX_NAME'] ??
57
+ env['npm_config_name'];
58
+ // notes-dir
59
+ const notesDir = values['notes-dir'] ??
60
+ env['STIKFIX_NOTES_DIR'] ??
61
+ env['npm_config_notes_dir'];
62
+ // port
63
+ const port = values['port'] ??
64
+ env['STIKFIX_PORT'] ??
65
+ env['npm_config_port'];
66
+ // token — D-07 (STIKFIX_TOKEN already documented in PRD §8.1)
67
+ const token = values['token'] ??
68
+ env['STIKFIX_TOKEN'] ??
69
+ env['npm_config_token'];
70
+ return {
71
+ root,
72
+ origin: origins,
73
+ name,
74
+ 'notes-dir': notesDir,
75
+ port,
76
+ token,
77
+ };
78
+ }
79
+ // ---------------------------------------------------------------------------
80
+ // resolveConfig
81
+ // ---------------------------------------------------------------------------
82
+ /**
83
+ * Resolve CLI parseArgs values into a validated Config.
84
+ * Applies three-tier env resolution via resolveConfigValues before validation.
85
+ * Throws if notesDir resolves outside root (D-10).
86
+ */
87
+ export function resolveConfig(values) {
88
+ const v = resolveConfigValues(values);
89
+ if (typeof v['root'] !== 'string' || !v['root']) {
90
+ throw new Error('--root is required');
91
+ }
92
+ const root = resolve(v['root']);
93
+ const name = v['name'] ?? basename(root);
94
+ // origins: resolved by resolveConfigValues, fallback to []
95
+ const origins = v['origin'] ?? [];
96
+ const notesDirRaw = v['notes-dir'];
97
+ const notesDir = resolve(notesDirRaw ?? join(root, 'notes'));
98
+ // D-10: notesDir must be inside root
99
+ if (!isInsideDir(root, notesDir)) {
100
+ throw new Error(`--notes-dir must be inside --root.\n root: ${root}\n notesDir: ${notesDir}`);
101
+ }
102
+ const portStr = v['port'];
103
+ let port;
104
+ if (portStr !== undefined) {
105
+ // WR-05: validate port — Number('abc') → NaN, Number('0x10') → 16, etc.
106
+ port = Number(portStr);
107
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
108
+ throw new Error(`--port must be an integer 1-65535, got: ${portStr}`);
109
+ }
110
+ }
111
+ // D-07 token resolution order (npm_config_token handled in resolveConfigValues)
112
+ const token = v['token'] ?? randomUUID();
113
+ return { root, notesDir, name, origins, port, token };
114
+ }
115
+ // ---------------------------------------------------------------------------
116
+ // ensureNotesDir (HOST-12)
117
+ // ---------------------------------------------------------------------------
118
+ /**
119
+ * Create notesDir (recursively) if it does not exist.
120
+ * Write a .gitkeep file if absent so the empty dir is tracked by git.
121
+ */
122
+ export function ensureNotesDir(notesDir) {
123
+ mkdirSync(notesDir, { recursive: true });
124
+ const gitkeep = join(notesDir, '.gitkeep');
125
+ if (!existsSync(gitkeep)) {
126
+ writeFileSync(gitkeep, '');
127
+ }
128
+ }
129
+ // ---------------------------------------------------------------------------
130
+ // writeTokenFile (HOST-12)
131
+ // ---------------------------------------------------------------------------
132
+ /**
133
+ * Write the resolved token to <root>/.stikfix-token for developer convenience.
134
+ * The file is already gitignored (verified in Phase 1).
135
+ *
136
+ * The token is a credential, so the file is created owner-only (mode 0o600).
137
+ * Any pre-existing file is removed first so a prior, looser-permissioned inode
138
+ * (e.g. 0o644) is not reused. POSIX mode bits are honored on macOS/Linux; on
139
+ * Windows they are largely ignored by the filesystem, which is acceptable.
140
+ */
141
+ export function writeTokenFile(root, token) {
142
+ const tokenPath = join(root, '.stikfix-token');
143
+ if (existsSync(tokenPath)) {
144
+ rmSync(tokenPath, { force: true });
145
+ }
146
+ writeFileSync(tokenPath, token, { encoding: 'utf8', mode: 0o600 });
147
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * deriveExtensionId — deterministic Chrome extension ID derivation from a
3
+ * base64-encoded SPKI/DER public key (the same value used in manifest.json `key`).
4
+ *
5
+ * Algorithm (Phase 9 / RESEARCH Pattern 3):
6
+ * 1. Base64-decode the manifest `key` field to get DER bytes.
7
+ * 2. SHA-256 hash the DER bytes.
8
+ * 3. Take the first 16 bytes (= first 32 hex nibbles).
9
+ * 4. Map each nibble 0-f to the letter a-p (0→a, 1→b, … 9→j, a→k, … f→p).
10
+ *
11
+ * This matches how Chrome computes the extension ID from the key field.
12
+ * [VERIFIED: developer.chrome.com/docs/extensions/reference/manifest/key]
13
+ *
14
+ * Node builtins only — no WXT, no Chrome imports.
15
+ */
16
+ import { createHash } from 'node:crypto';
17
+ /**
18
+ * The stable extension ID derived from the committed public key in wxt.config.ts.
19
+ * Generated during Phase 9 bootstrapping (2026-06-05).
20
+ * Private key is at .keys/stikfix-extension.pem (gitignored).
21
+ *
22
+ * If you regenerate the keypair, update wxt.config.ts `key` field AND this constant.
23
+ */
24
+ export const STABLE_EXTENSION_ID = 'ccdfmbhdcafhmnnnfjpbhgebfkfgjgca';
25
+ /**
26
+ * The base64 SPKI/DER public key matching STABLE_EXTENSION_ID.
27
+ * Committed in wxt.config.ts `key` field and stored in .keys/manifest-key.txt.
28
+ */
29
+ export const MANIFEST_PUBLIC_KEY = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1RFrtpIlsiHwm21+ISi8v5381HQeJq2pw4lgbvqQ2a6o2uZ6H1uHfT+1xRy2msqHzXMOJOhwfAKuwBoebATIFDcms132Msz11DJPHUgoVX29sh9PWxUN5aJ/KovtPgIXoEDTKg/QtV+C9Hza0bcdncqymi9xBw5De/rRn/ZQdkXx2ZFiIm6AuHE0Q4dJrSPLqRLEFxP7mf+/SNPQ0LGDYsWUBbDLz8ksMU0VrrDDtRbDSPhBglxNzVYv00MYpwPEHijBCG9wQ57a34tDuA2/TFvNSwpbkWIYiGe6GyN5DvVHdIZgHcmTxSoY43Xu8EAvX+isRp3DdK8j3tAx1C/wIwIDAQAB';
30
+ /**
31
+ * Derive the Chrome extension ID from a base64-encoded SPKI/DER public key.
32
+ *
33
+ * @param publicKeyBase64 - Base64-encoded DER/SPKI public key (the manifest `key` value).
34
+ * @returns 32-character lowercase extension ID using the a-p alphabet.
35
+ *
36
+ * @example
37
+ * const id = deriveExtensionId(MANIFEST_PUBLIC_KEY);
38
+ * // → 'ccdfmbhdcafhmnnnfjpbhgebfkfgjgca'
39
+ */
40
+ export function deriveExtensionId(publicKeyBase64) {
41
+ const derBytes = Buffer.from(publicKeyBase64, 'base64');
42
+ const hash = createHash('sha256').update(derBytes).digest('hex');
43
+ // Take first 32 hex nibbles (16 bytes) and map 0-f → a-p
44
+ return hash
45
+ .slice(0, 32)
46
+ .split('')
47
+ .map((c) => String.fromCharCode('a'.charCodeAt(0) + parseInt(c, 16)))
48
+ .join('');
49
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * OS native folder-picker dialog for stikfix (D-04).
3
+ * Spawns the OS dialog binary via execFile (NEVER exec, NEVER shell:true).
4
+ * Arguments are a static array — no user-supplied strings interpolated into
5
+ * shell command context (T-09-01 / RESEARCH Pattern 5).
6
+ *
7
+ * Node builtins only — no WXT, no Chrome imports.
8
+ */
9
+ import { execFile } from 'node:child_process';
10
+ /**
11
+ * Build the command + argument array for the OS folder-picker dialog.
12
+ *
13
+ * Security contract (T-09-01):
14
+ * - Uses execFile (not exec) — no shell spawned
15
+ * - The `title` is the only variable input; it is escaped for safe use in
16
+ * the dialog command but never allows shell metacharacters to pass through
17
+ * - No user-controlled input (origin, username, path) is interpolated into
18
+ * the command string beyond the developer-static dialog title
19
+ *
20
+ * @param plat - NodeJS.Platform override for testing
21
+ * @param title - Static dialog title (developer-controlled)
22
+ */
23
+ export function buildPickerArgs(plat = process.platform, title = 'Choose folder') {
24
+ switch (plat) {
25
+ case 'win32': {
26
+ // PowerShell FolderBrowserDialog — fixed arg array, no shell interpolation.
27
+ // Single-quote escape: replace ' with '' (PowerShell string literal escape).
28
+ // Title is developer-controlled (static label) — not user/origin input.
29
+ const safeTitle = title.replace(/'/g, "''");
30
+ return {
31
+ cmd: 'powershell.exe',
32
+ args: [
33
+ '-NoProfile',
34
+ '-NonInteractive',
35
+ '-OutputFormat',
36
+ 'Text',
37
+ '-Command',
38
+ `Add-Type -AssemblyName System.Windows.Forms;` +
39
+ `$owner = New-Object System.Windows.Forms.Form;` +
40
+ `$owner.TopMost = $true; $owner.ShowInTaskbar = $false;` +
41
+ `$owner.Opacity = 0; $owner.Show(); $owner.Activate();` +
42
+ `$d = New-Object System.Windows.Forms.FolderBrowserDialog;` +
43
+ `$d.Description = '${safeTitle}';` +
44
+ `$null = $d.ShowDialog($owner);` +
45
+ `$owner.Dispose();` +
46
+ `$d.SelectedPath`,
47
+ ],
48
+ };
49
+ }
50
+ case 'darwin': {
51
+ // osascript choose folder — fixed arg array
52
+ return {
53
+ cmd: 'osascript',
54
+ args: ['-e', `choose folder with prompt "Choose a project folder"`],
55
+ };
56
+ }
57
+ case 'linux':
58
+ default: {
59
+ // zenity (GNOME primary), kdialog fallback handled at runtime in pickFolder
60
+ return {
61
+ cmd: 'zenity',
62
+ args: ['--file-selection', '--directory', `--title=${title}`],
63
+ };
64
+ }
65
+ }
66
+ }
67
+ // ---------------------------------------------------------------------------
68
+ // pickFolder — invoke the OS dialog and return the chosen path
69
+ // ---------------------------------------------------------------------------
70
+ /**
71
+ * Open a native OS folder-picker dialog and return the chosen absolute path,
72
+ * or null if the user cancels, the dialog binary is not available, or the
73
+ * timeout (120 s) is exceeded.
74
+ *
75
+ * Uses execFile (not exec) with a static argument array — T-09-01.
76
+ * The returned path should be validated with isInsideDir before use.
77
+ *
78
+ * @param title - Dialog title (developer-static label, not user-controlled)
79
+ * @param plat - Platform override for testing
80
+ */
81
+ export async function pickFolder(title = 'Choose a project folder', plat = process.platform) {
82
+ if (plat === 'darwin') {
83
+ // osascript returns an HFS+ alias path; convert to POSIX
84
+ return new Promise((resolve) => {
85
+ const { args } = buildPickerArgs('darwin', title);
86
+ execFile('osascript', args, { timeout: 120_000 }, (err, stdout) => {
87
+ if (err || !stdout.trim()) {
88
+ resolve(null);
89
+ return;
90
+ }
91
+ const raw = stdout.trim();
92
+ // Convert alias to POSIX path via second osascript call
93
+ execFile('osascript', ['-e', `POSIX path of (${raw})`], {}, (e2, out2) => {
94
+ resolve(e2 ? null : out2.trim() || null);
95
+ });
96
+ });
97
+ });
98
+ }
99
+ if (plat === 'win32') {
100
+ const { cmd, args } = buildPickerArgs('win32', title);
101
+ return new Promise((resolve) => {
102
+ execFile(cmd, args, { timeout: 120_000 }, (err, stdout) => {
103
+ resolve(err ? null : stdout.trim() || null);
104
+ });
105
+ });
106
+ }
107
+ // Linux: try zenity first, then kdialog
108
+ const zenityResult = await tryLinuxPicker('zenity', title);
109
+ if (zenityResult !== undefined)
110
+ return zenityResult;
111
+ const kdialogResult = await tryLinuxPicker('kdialog', title);
112
+ if (kdialogResult !== undefined)
113
+ return kdialogResult;
114
+ // Headless fallback: return null (native host falls back to --root config)
115
+ return null;
116
+ }
117
+ /**
118
+ * Try a Linux folder-picker tool.
119
+ * Returns the path string if successful, null if user cancelled,
120
+ * undefined if the tool is not found.
121
+ */
122
+ function tryLinuxPicker(tool, title) {
123
+ return new Promise((resolve) => {
124
+ const args = tool === 'zenity'
125
+ ? ['--file-selection', '--directory', `--title=${title}`]
126
+ : ['--getexistingdirectory', '/home'];
127
+ execFile(tool, args, { timeout: 120_000 }, (err, stdout) => {
128
+ if (err) {
129
+ // Distinguish "not found" (ENOENT) from "user cancelled" (non-zero exit)
130
+ const e = err;
131
+ if (e.code === 'ENOENT') {
132
+ resolve(undefined); // tool not installed — try next
133
+ }
134
+ else {
135
+ resolve(null); // user cancelled or dialog error
136
+ }
137
+ return;
138
+ }
139
+ resolve(stdout.trim() || null);
140
+ });
141
+ });
142
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * stikfix-host CLI entry point.
3
+ * D-08/HOST-01/HOST-02/HOST-03: bind 127.0.0.1 only; honor --port or scan 39240-39260
4
+ * D-07/HOST-12: writeTokenFile to <root>/.stikfix-token
5
+ * Startup JSON line: {app,name,root,port,token,notesDir,origins}
6
+ * Pattern 1: bind-or-fail loop (EADDRINUSE -> retry next port)
7
+ * WR-06: bindServer/tryListen extracted to bind.ts; removeAllListeners between attempts
8
+ * Pitfall 2: server runs indefinitely (smoke test uses spawn+readline, not spawnSync)
9
+ * FIX-SI: single-instance guard — probeExistingHost (probe.ts) reads .stikfix-port + probes /status
10
+ * FIX-TP: token persistence — reuse .stikfix-token across restarts if no explicit token
11
+ */
12
+ import { writeFileSync, readFileSync, existsSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { parseArgs } from 'node:util';
15
+ import { resolveConfig, resolveConfigValues, ensureNotesDir, writeTokenFile } from './config.js';
16
+ import { createHostServer } from './server.js';
17
+ import { bindServer, BIND_HOST } from './bind.js';
18
+ import { probeExistingHost } from './probe.js';
19
+ // ---------------------------------------------------------------------------
20
+ // CLI parsing (preserve Phase 1 stub options block — HOST-13)
21
+ // ---------------------------------------------------------------------------
22
+ const { values: rawValues } = parseArgs({
23
+ options: {
24
+ root: { type: 'string' },
25
+ origin: { type: 'string', multiple: true },
26
+ name: { type: 'string' },
27
+ 'notes-dir': { type: 'string' },
28
+ port: { type: 'string' },
29
+ token: { type: 'string' },
30
+ },
31
+ strict: false,
32
+ });
33
+ // Apply three-tier env resolution (flag > STIKFIX_* > npm_config_*) so that
34
+ // `npm run host -- --root <dir>` works in Windows PowerShell, where npm 11.x
35
+ // intercepts unknown flags and re-exposes them as npm_config_<key> env vars.
36
+ const values = resolveConfigValues(rawValues);
37
+ if (!values['root']) {
38
+ console.error('stikfix-host: --root is required');
39
+ process.exit(1);
40
+ }
41
+ // ---------------------------------------------------------------------------
42
+ // Config resolution + filesystem setup
43
+ // ---------------------------------------------------------------------------
44
+ const cfg = resolveConfig(values);
45
+ ensureNotesDir(cfg.notesDir);
46
+ // ---------------------------------------------------------------------------
47
+ // FIX-SI: Single-instance guard — BEFORE any file mutation
48
+ // ---------------------------------------------------------------------------
49
+ const portFilePath = join(cfg.root, '.stikfix-port');
50
+ if (existsSync(portFilePath)) {
51
+ const portStr = readFileSync(portFilePath, 'utf8').trim();
52
+ const existingPort = Number(portStr);
53
+ if (Number.isInteger(existingPort) && existingPort > 0 && existingPort <= 65535) {
54
+ const live = await probeExistingHost(cfg.root, existingPort);
55
+ if (live !== null) {
56
+ console.log(`stikfix-host: already running for ${cfg.root} on port ${live.port} — not starting a second instance.`);
57
+ process.exit(0);
58
+ }
59
+ // else: stale port file — continue with normal startup
60
+ }
61
+ }
62
+ // ---------------------------------------------------------------------------
63
+ // FIX-TP: Token persistence — reuse existing token if no explicit token given
64
+ // ---------------------------------------------------------------------------
65
+ // An "explicit" token is one supplied via --token / STIKFIX_TOKEN / npm_config_token.
66
+ // resolveConfig already resolved these; if none was supplied, randomUUID() was called.
67
+ // We detect the "no explicit token" case by checking the raw resolved values BEFORE
68
+ // randomUUID was applied.
69
+ const rawToken = values['token'] ??
70
+ (process.env['STIKFIX_TOKEN']) ??
71
+ (process.env['npm_config_token']);
72
+ const tokenFilePath = join(cfg.root, '.stikfix-token');
73
+ let finalToken = cfg.token;
74
+ if (!rawToken) {
75
+ // No explicit token provided — try to reuse existing token file
76
+ if (existsSync(tokenFilePath)) {
77
+ const existing = readFileSync(tokenFilePath, 'utf8').trim();
78
+ if (existing.length > 0) {
79
+ finalToken = existing;
80
+ }
81
+ // else: empty file — keep freshly generated UUID from resolveConfig
82
+ }
83
+ // else: no existing file — keep freshly generated UUID from resolveConfig
84
+ }
85
+ // Write the final token (idempotent — fixes perms to 0o600 via writeTokenFile)
86
+ writeTokenFile(cfg.root, finalToken);
87
+ // ---------------------------------------------------------------------------
88
+ // Port discovery — bind-or-fail loop on 127.0.0.1 (Pattern 1 / D-08)
89
+ // ---------------------------------------------------------------------------
90
+ const server = createHostServer({ ...cfg, token: finalToken });
91
+ const boundPort = await bindServer(server, cfg.port);
92
+ // Safety assertion: binding must be 127.0.0.1 (T-02-bind)
93
+ const addr = server.address();
94
+ if (addr.address !== BIND_HOST) {
95
+ throw new Error(`FATAL: server bound to ${addr.address} instead of ${BIND_HOST}`);
96
+ }
97
+ // Publish bound port to disk so the native host can read it without a port scan
98
+ // (RESEARCH Open Question 2 / A5). Mode 0o600 alongside existing token file.
99
+ writeFileSync(join(cfg.root, '.stikfix-port'), String(boundPort), { encoding: 'utf8', mode: 0o600 });
100
+ // Startup JSON line — read by smoke test via readline (Pattern 12)
101
+ console.log(JSON.stringify({
102
+ app: 'stikfix',
103
+ name: cfg.name,
104
+ root: cfg.root,
105
+ port: boundPort,
106
+ token: finalToken,
107
+ notesDir: cfg.notesDir,
108
+ origins: cfg.origins,
109
+ }));
110
+ // Server runs indefinitely (Pitfall 2 — no process.exit here)
@@ -0,0 +1,143 @@
1
+ /**
2
+ * stikfix native-messaging host entry point.
3
+ *
4
+ * Chrome spawns this process via the registered native-messaging manifest
5
+ * (com.stikfix.host). It responds to GET_TOKEN with { type:'TOKEN', ... }
6
+ * from disk-backed files, then exits (sendNativeMessage one-shot — Pitfall 3).
7
+ *
8
+ * ONB-02: Reads the token from <root>/.stikfix-token and delivers it to the SW.
9
+ * ONB-04: Chrome spawns this on demand; no persistent process or HTTP server.
10
+ *
11
+ * Security:
12
+ * T-09-07: MUST NOT call createHostServer, bindServer, or listen on any port.
13
+ * T-09-09: Token read from disk file (mode 0o600), sent only over native messaging.
14
+ *
15
+ * Node builtins only — no WXT, no Chrome imports.
16
+ */
17
+ import { readFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { homedir } from 'node:os';
20
+ import { sendNativeMessage, readNativeMessages } from './native-msg.js';
21
+ import { pickFolder } from './folder-picker.js';
22
+ import { validateChosenFolder } from './validate-folder.js';
23
+ // Re-export so existing importers (native-host.test.ts) keep working unchanged
24
+ // after the validation logic moved to validate-folder.ts (single source of truth).
25
+ export { validateChosenFolder };
26
+ // ---------------------------------------------------------------------------
27
+ // Config + token/port resolution
28
+ // ---------------------------------------------------------------------------
29
+ const CONFIG_PATH = join(homedir(), '.config', 'stikfix', 'config.json');
30
+ // ---------------------------------------------------------------------------
31
+ // PICK_FOLDER handler — open the OS dialog, validate, respond, exit (Pitfall 8)
32
+ // Folder validation lives in validate-folder.ts (shared with the HTTP server).
33
+ // ---------------------------------------------------------------------------
34
+ /**
35
+ * Handle a PICK_FOLDER native message: open the OS folder dialog, validate the
36
+ * chosen path, send a FOLDER_PICKED frame echoing the origin, then exit(0).
37
+ *
38
+ * Pitfall 8: Chrome spawns a FRESH process per sendNativeMessage, so PICK_FOLDER
39
+ * is handled per-spawn entirely separately from GET_TOKEN — the blocking folder
40
+ * dialog can never delay a token fetch (which is a different spawn).
41
+ *
42
+ * `pickFn` is injectable so tests can stub the OS dialog.
43
+ */
44
+ export async function handlePickFolder(origin, pickFn = pickFolder, plat = process.platform, out) {
45
+ let chosen;
46
+ try {
47
+ chosen = await pickFn('Choose a folder for ' + (origin ?? 'this site'));
48
+ }
49
+ catch {
50
+ // Dialog spawn error — never crash; respond with null (no silent drop).
51
+ chosen = null;
52
+ }
53
+ const folder = validateChosenFolder(chosen, plat);
54
+ if (out) {
55
+ sendNativeMessage({ type: 'FOLDER_PICKED', origin, folder }, out);
56
+ }
57
+ else {
58
+ sendNativeMessage({ type: 'FOLDER_PICKED', origin, folder });
59
+ }
60
+ }
61
+ // ---------------------------------------------------------------------------
62
+ // Message dispatch — handle one message and exit (Pitfall 3)
63
+ // ---------------------------------------------------------------------------
64
+ /**
65
+ * Native-host entry point. Reads config from disk (required), then dispatches
66
+ * the single inbound native message and exits. The token/port are read LAZILY
67
+ * inside the GET_TOKEN branch only — PICK_FOLDER does not need them.
68
+ *
69
+ * Kept as a function (not top-level side effects) so the pure helpers above
70
+ * (validateChosenFolder / handlePickFolder) can be imported by unit tests
71
+ * WITHOUT triggering a config read + process.exit on import.
72
+ */
73
+ export function main() {
74
+ let cfg;
75
+ try {
76
+ cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
77
+ }
78
+ catch {
79
+ // Config missing — respond with a structured error so the SW gets {ok:false}
80
+ // rather than Chrome reporting "native host exited unexpectedly"
81
+ sendNativeMessage({ type: 'ERROR', error: 'Config not found. Run: npx stikfix init' });
82
+ process.exit(1);
83
+ }
84
+ // NOTE: the token (and optional port) are read LAZILY inside the GET_TOKEN
85
+ // branch below — NOT upfront. PICK_FOLDER does not need the token, so reading
86
+ // it here would wrongly fail the folder dialog whenever .stikfix-token is
87
+ // absent (e.g. host never started). Config is the only upfront requirement.
88
+ readNativeMessages((msg) => {
89
+ const m = msg;
90
+ if (m.type === 'GET_TOKEN') {
91
+ // Token is required ONLY for GET_TOKEN — read it lazily here.
92
+ let token;
93
+ try {
94
+ token = readFileSync(join(cfg.root, '.stikfix-token'), 'utf8').trim();
95
+ }
96
+ catch {
97
+ sendNativeMessage({ type: 'ERROR', error: '.stikfix-token not found. Start the host first.' });
98
+ process.exit(1);
99
+ }
100
+ // Port is optional — read if present; SW falls back to port scan (A5 fallback)
101
+ let port;
102
+ try {
103
+ const raw = readFileSync(join(cfg.root, '.stikfix-port'), 'utf8').trim();
104
+ const parsed = parseInt(raw, 10);
105
+ if (!isNaN(parsed)) {
106
+ port = parsed;
107
+ }
108
+ }
109
+ catch {
110
+ // .stikfix-port absent is OK — SW re-probes (A5)
111
+ }
112
+ // Send token + port (if known) + host identity, then exit (one-shot)
113
+ sendNativeMessage({
114
+ type: 'TOKEN',
115
+ token,
116
+ port,
117
+ name: cfg.name,
118
+ notesDir: cfg.notesDir,
119
+ });
120
+ process.exit(0);
121
+ }
122
+ if (m.type === 'PICK_FOLDER') {
123
+ // PICK_FOLDER is a SEPARATE spawn from GET_TOKEN (Pitfall 8) — the dialog
124
+ // never blocks a token fetch. It does NOT require the token (read lazily
125
+ // for GET_TOKEN only), so it works even before the host has ever started.
126
+ // Open the dialog, validate, respond, exit.
127
+ handlePickFolder(m.origin).then(() => process.exit(0), () => process.exit(0));
128
+ return;
129
+ }
130
+ // Unknown message type — exit cleanly (Chrome expects process to exit)
131
+ process.exit(0);
132
+ });
133
+ }
134
+ // Run only when invoked as the entry point (esbuild CJS bundle), never on import.
135
+ // In the esbuild CJS bundle (stikfix-native.cjs), `require`/`module` are CJS
136
+ // scope locals and `require.main === module` is true when run directly. The
137
+ // node:test compile path emits ESM where these are undefined at runtime — the
138
+ // `typeof` guards keep main() from running on import there.
139
+ if (typeof require !== 'undefined' &&
140
+ typeof module !== 'undefined' &&
141
+ require.main === module) {
142
+ main();
143
+ }