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.
- package/LICENSE +21 -0
- package/README.md +187 -0
- package/dist/host/src/bind.js +69 -0
- package/dist/host/src/bootstrap/register.js +556 -0
- package/dist/host/src/config.js +147 -0
- package/dist/host/src/extension-id.js +49 -0
- package/dist/host/src/folder-picker.js +142 -0
- package/dist/host/src/index.js +110 -0
- package/dist/host/src/native-host.js +143 -0
- package/dist/host/src/native-msg.js +100 -0
- package/dist/host/src/probe.js +62 -0
- package/dist/host/src/read-note.js +175 -0
- package/dist/host/src/security.js +76 -0
- package/dist/host/src/serial.js +32 -0
- package/dist/host/src/server.js +451 -0
- package/dist/host/src/types.js +5 -0
- package/dist/host/src/validate-folder.js +68 -0
- package/dist/host/src/write-note.js +158 -0
- package/dist/host/stikfix-init.cjs +500 -0
- package/dist/host/stikfix-native.cjs +257 -0
- package/package.json +66 -0
- package/public/icon/128.png +0 -0
- package/public/icon/16.png +0 -0
- package/public/icon/256.png +0 -0
- package/public/icon/32.png +0 -0
- package/public/icon/48.png +0 -0
- package/public/icon/stikfix.ico +0 -0
- package/public/icon/stikfix.svg +47 -0
|
@@ -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
|
+
}
|