stikfix 1.2.0 → 1.3.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.
@@ -405,6 +405,64 @@ export function createLauncherFiles(opts) {
405
405
  }
406
406
  return { written, warnings };
407
407
  }
408
+ // ---------------------------------------------------------------------------
409
+ // registerStartup / unregisterStartup — HKCU Run entry (Windows startup autoload)
410
+ // ---------------------------------------------------------------------------
411
+ // HKCU Run key + value name for the "start host on Windows login" feature.
412
+ // HKCU (Pitfall 5 — never HKLM); the value data launches the existing hidden
413
+ // VBS launcher via wscript.exe so no console window ever appears.
414
+ const REG_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
415
+ const REG_RUN_VALUE = 'stikfix-host';
416
+ /**
417
+ * Register the stikfix host to start automatically on Windows login by adding
418
+ * an HKCU Run entry that launches the existing hidden VBS launcher (created by
419
+ * createLauncherFiles) via wscript.exe — so no console window is shown.
420
+ *
421
+ * Value data: `wscript.exe "<abs vbsPath>"` — reuses the same VBS path that
422
+ * createLauncherFiles/getLauncherPaths compute (LAUNCHER_VBS_FILENAME in the
423
+ * stikfix data dir).
424
+ *
425
+ * Non-win32: no-op (startup autoload is Windows-only for now).
426
+ * execFileSync is used (NEVER exec, NEVER shell) — mirrors registerNativeHost.
427
+ */
428
+ export function registerStartup(opts = {}) {
429
+ const plat = opts.plat ?? process.platform;
430
+ if (plat !== 'win32') {
431
+ // Startup autoload is Windows-only for now — no-op on other platforms.
432
+ return;
433
+ }
434
+ const home = opts.home ?? homedir();
435
+ const vbsPath = join(launcherDir(plat, home), LAUNCHER_VBS_FILENAME);
436
+ const wscriptPath = join(process.env['SystemRoot'] ?? 'C:\\Windows', 'System32', 'wscript.exe');
437
+ // Value data: wscript.exe "<abs vbsPath>" — the VBS launches the host hidden.
438
+ const runData = `"${wscriptPath}" "${vbsPath}"`;
439
+ const execReg = opts.execReg ?? ((args) => {
440
+ execFileSync('reg', args);
441
+ });
442
+ // /f overwrites any existing value without prompting (idempotent re-register).
443
+ execReg(['ADD', REG_RUN_KEY, '/v', REG_RUN_VALUE, '/t', 'REG_SZ', '/d', runData, '/f']);
444
+ }
445
+ /**
446
+ * Remove the HKCU Run entry created by registerStartup. Idempotent: does not
447
+ * throw if the value is already absent (reg DELETE /f tolerates a missing value;
448
+ * a non-zero exit is swallowed). Non-win32: no-op.
449
+ */
450
+ export function unregisterStartup(opts = {}) {
451
+ const plat = opts.plat ?? process.platform;
452
+ if (plat !== 'win32') {
453
+ return;
454
+ }
455
+ const execReg = opts.execReg ?? ((args) => {
456
+ execFileSync('reg', args);
457
+ });
458
+ // /f suppresses the confirmation prompt and tolerates an absent value.
459
+ try {
460
+ execReg(['DELETE', REG_RUN_KEY, '/v', REG_RUN_VALUE, '/f']);
461
+ }
462
+ catch {
463
+ // Value may not exist — ignore (idempotent removal).
464
+ }
465
+ }
408
466
  /**
409
467
  * Write the native-messaging manifest and, on Windows, register the matching
410
468
  * HKCU registry key(s):
@@ -16,6 +16,7 @@ import { resolveConfig, resolveConfigValues, ensureNotesDir, writeTokenFile } fr
16
16
  import { createHostServer } from './server.js';
17
17
  import { bindServer, BIND_HOST } from './bind.js';
18
18
  import { probeExistingHost } from './probe.js';
19
+ import { startTray } from './tray.js';
19
20
  // ---------------------------------------------------------------------------
20
21
  // CLI parsing (preserve Phase 1 stub options block — HOST-13)
21
22
  // ---------------------------------------------------------------------------
@@ -107,4 +108,39 @@ console.log(JSON.stringify({
107
108
  notesDir: cfg.notesDir,
108
109
  origins: cfg.origins,
109
110
  }));
111
+ // ---------------------------------------------------------------------------
112
+ // Windows system-tray indicator (best-effort, cosmetic, win32-only)
113
+ // ---------------------------------------------------------------------------
114
+ // Only reached on genuine startup — the single-instance guard above exits(0)
115
+ // before here, so a second instance never spawns a duplicate tray.
116
+ const tray = startTray({
117
+ port: boundPort,
118
+ root: cfg.root,
119
+ name: cfg.name,
120
+ notesDir: cfg.notesDir,
121
+ hostPid: process.pid,
122
+ });
123
+ // Kill the tray child when the host shuts down so a dead host has no tray.
124
+ // (No prior signal handling existed here; these are additive.)
125
+ let shuttingDown = false;
126
+ function shutdown(signal) {
127
+ if (shuttingDown)
128
+ return;
129
+ shuttingDown = true;
130
+ try {
131
+ tray?.kill();
132
+ }
133
+ catch { /* best-effort */ }
134
+ server.close(() => process.exit(0));
135
+ // Fallback: exit even if server.close hangs on lingering connections.
136
+ setTimeout(() => process.exit(0), 1000).unref();
137
+ void signal;
138
+ }
139
+ process.on('SIGINT', () => shutdown('SIGINT'));
140
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
141
+ // Belt-and-suspenders: also reap the tray on plain process exit.
142
+ process.on('exit', () => { try {
143
+ tray?.kill();
144
+ }
145
+ catch { /* best-effort */ } });
110
146
  // Server runs indefinitely (Pitfall 2 — no process.exit here)
@@ -14,9 +14,10 @@
14
14
  *
15
15
  * Node builtins only — no WXT, no Chrome imports.
16
16
  */
17
- import { readFileSync } from 'node:fs';
18
- import { join } from 'node:path';
17
+ import { readFileSync, statSync } from 'node:fs';
18
+ import { join, basename, resolve } from 'node:path';
19
19
  import { homedir } from 'node:os';
20
+ import { spawn } from 'node:child_process';
20
21
  import { sendNativeMessage, readNativeMessages } from './native-msg.js';
21
22
  import { pickFolder } from './folder-picker.js';
22
23
  import { validateChosenFolder } from './validate-folder.js';
@@ -27,6 +28,74 @@ export { validateChosenFolder };
27
28
  // Config + token/port resolution
28
29
  // ---------------------------------------------------------------------------
29
30
  const CONFIG_PATH = join(homedir(), '.config', 'stikfix', 'config.json');
31
+ /**
32
+ * Resolve the absolute path to the HTTP host entry bundle. Prefers the
33
+ * `hostEntry` field written into config.json by `npx stikfix init`. If absent
34
+ * (older config), falls back to resolving `src/index.js` relative to THIS
35
+ * native host bundle's own directory — the shipped layout is
36
+ * dist/host/stikfix-native.cjs alongside dist/host/src/index.js, so __dirname/src/index.js
37
+ * is the co-located HTTP host entry.
38
+ */
39
+ function resolveHostEntry(cfg) {
40
+ if (typeof cfg.hostEntry === 'string' && cfg.hostEntry.length > 0) {
41
+ return cfg.hostEntry;
42
+ }
43
+ // Fallback: dist/host/stikfix-native.cjs → dist/host/src/index.js
44
+ // In the esbuild CJS bundle, __dirname is the bundle's directory.
45
+ return join(__dirname, 'src', 'index.js');
46
+ }
47
+ /**
48
+ * Handle a START_HOST native message: validate `root` is an existing directory,
49
+ * then SPAWN the HTTP host as a DETACHED process (never bind/listen here —
50
+ * invariant T-09-07) so it outlives this one-shot native process. Replies
51
+ * HOST_STARTING on success, ERROR on invalid input. `out` is injectable for tests.
52
+ *
53
+ * Returns true if the host was spawned (caller should exit 0), false on a
54
+ * validation error (caller should exit 1). The actual process.exit is left to
55
+ * the caller so this helper stays unit-testable without side effects.
56
+ */
57
+ export function handleStartHost(cfg, root, spawnFn = spawn, out) {
58
+ const emit = (msg) => (out ? sendNativeMessage(msg, out) : sendNativeMessage(msg));
59
+ if (typeof root !== 'string' || root.length === 0) {
60
+ emit({ type: 'ERROR', error: 'START_HOST requires a non-empty "root" string.' });
61
+ return false;
62
+ }
63
+ // Normalize the incoming root (collapse `..`, consistent separators) before
64
+ // using it for the directory check and the spawned host's --root arg. No
65
+ // confinement check — cross-project roots over the extension-exclusive native
66
+ // channel are intentional; normalization is the correct hardening.
67
+ const resolvedRoot = resolve(root);
68
+ // Validate root is an existing directory (no silent failure).
69
+ try {
70
+ if (!statSync(resolvedRoot).isDirectory()) {
71
+ emit({ type: 'ERROR', error: `START_HOST root is not a directory: ${resolvedRoot}` });
72
+ return false;
73
+ }
74
+ }
75
+ catch {
76
+ emit({ type: 'ERROR', error: `START_HOST root does not exist: ${resolvedRoot}` });
77
+ return false;
78
+ }
79
+ const nodePath = typeof cfg.nodePath === 'string' && cfg.nodePath.length > 0 ? cfg.nodePath : process.execPath;
80
+ const hostEntry = resolveHostEntry(cfg);
81
+ // CRITICAL (T-09-07): the native host MUST NOT bind/listen itself. It only
82
+ // spawns a SEPARATE, detached process that is the HTTP host. Detached +
83
+ // stdio:'ignore' + unref() so the child outlives this one-shot native process.
84
+ try {
85
+ const child = spawnFn(nodePath, [hostEntry, '--root', resolvedRoot], {
86
+ detached: true,
87
+ stdio: 'ignore',
88
+ windowsHide: true,
89
+ });
90
+ child.unref();
91
+ }
92
+ catch (err) {
93
+ emit({ type: 'ERROR', error: `Failed to start host: ${String(err.message)}` });
94
+ return false;
95
+ }
96
+ emit({ type: 'HOST_STARTING', root: resolvedRoot });
97
+ return true;
98
+ }
30
99
  // ---------------------------------------------------------------------------
31
100
  // PICK_FOLDER handler — open the OS dialog, validate, respond, exit (Pitfall 8)
32
101
  // Folder validation lives in validate-folder.ts (shared with the HTTP server).
@@ -88,19 +157,52 @@ export function main() {
88
157
  readNativeMessages((msg) => {
89
158
  const m = msg;
90
159
  if (m.type === 'GET_TOKEN') {
160
+ // Optional `root`: if the inbound message carries a valid existing
161
+ // directory, read the token/port/identity from THAT root instead of
162
+ // cfg.root. Absent/invalid → exact current behavior (backward compatible).
163
+ let tokenRoot = cfg.root;
164
+ let name = cfg.name;
165
+ let notesDir = cfg.notesDir;
166
+ if (typeof m.root === 'string' && m.root.length > 0) {
167
+ // Caller explicitly asked for a specific root. Normalize it (collapse
168
+ // `..`, consistent separators), then require it to be an existing
169
+ // directory. NEVER silently fall back to cfg.root — that would return a
170
+ // DIFFERENT project's token mislabeled as the requested one.
171
+ const resolvedRoot = resolve(m.root);
172
+ let isDir = false;
173
+ try {
174
+ isDir = statSync(resolvedRoot).isDirectory();
175
+ }
176
+ catch {
177
+ isDir = false;
178
+ }
179
+ if (!isDir) {
180
+ sendNativeMessage({
181
+ type: 'ERROR',
182
+ error: 'GET_TOKEN root not accessible: ' + resolvedRoot,
183
+ });
184
+ process.exit(1);
185
+ }
186
+ tokenRoot = resolvedRoot;
187
+ name = basename(resolvedRoot);
188
+ notesDir = join(resolvedRoot, 'notes');
189
+ }
91
190
  // Token is required ONLY for GET_TOKEN — read it lazily here.
92
191
  let token;
93
192
  try {
94
- token = readFileSync(join(cfg.root, '.stikfix-token'), 'utf8').trim();
193
+ token = readFileSync(join(tokenRoot, '.stikfix-token'), 'utf8').trim();
95
194
  }
96
195
  catch {
97
- sendNativeMessage({ type: 'ERROR', error: '.stikfix-token not found. Start the host first.' });
196
+ sendNativeMessage({
197
+ type: 'ERROR',
198
+ error: 'No .stikfix-token in ' + tokenRoot + '. Start the host first.',
199
+ });
98
200
  process.exit(1);
99
201
  }
100
202
  // Port is optional — read if present; SW falls back to port scan (A5 fallback)
101
203
  let port;
102
204
  try {
103
- const raw = readFileSync(join(cfg.root, '.stikfix-port'), 'utf8').trim();
205
+ const raw = readFileSync(join(tokenRoot, '.stikfix-port'), 'utf8').trim();
104
206
  const parsed = parseInt(raw, 10);
105
207
  if (!isNaN(parsed)) {
106
208
  port = parsed;
@@ -114,11 +216,17 @@ export function main() {
114
216
  type: 'TOKEN',
115
217
  token,
116
218
  port,
117
- name: cfg.name,
118
- notesDir: cfg.notesDir,
219
+ name,
220
+ notesDir,
119
221
  });
120
222
  process.exit(0);
121
223
  }
224
+ if (m.type === 'START_HOST') {
225
+ // Spawn a DETACHED HTTP host for the requested root, then exit (one-shot).
226
+ // The native host itself NEVER binds/listens (T-09-07) — it only spawns.
227
+ const ok = handleStartHost(cfg, m.root);
228
+ process.exit(ok ? 0 : 1);
229
+ }
122
230
  if (m.type === 'PICK_FOLDER') {
123
231
  // PICK_FOLDER is a SEPARATE spawn from GET_TOKEN (Pitfall 8) — the dialog
124
232
  // never blocks a token fetch. It does NOT require the token (read lazily
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Windows system-tray indicator for stikfix-host.
3
+ *
4
+ * Best-effort, cosmetic, Windows-only. Spawns a hidden PowerShell WinForms
5
+ * NotifyIcon helper that polls the host's token-less /status endpoint and shows
6
+ * running (green) / not-responding (grey) state, plus a context menu to open the
7
+ * notes folder, stop the host, or quit the tray.
8
+ *
9
+ * Hard constraints (see project CLAUDE.md):
10
+ * - Node builtins only — NO new npm dependency. The tray is a PowerShell helper,
11
+ * not a Node native tray lib.
12
+ * - Windows-only: complete no-op (returns null) on macOS/Linux.
13
+ * - Never crash the host: any failure to write the script or spawn PowerShell is
14
+ * caught, logged as a single stderr line, and swallowed. A dropped note or a
15
+ * failed host start because of the tray would be an unacceptable regression.
16
+ * - Security: the tray only GETs http://127.0.0.1:<port>/status (existing
17
+ * token-less health endpoint). It weakens nothing.
18
+ *
19
+ * No side effects at import time: startTray() must be *called* to spawn anything.
20
+ */
21
+ import { spawn } from 'node:child_process';
22
+ import { writeFileSync, mkdirSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { homedir } from 'node:os';
25
+ /**
26
+ * Directory + path where the embedded tray script is written on each start.
27
+ * Rewriting every start keeps the tray script in lockstep with the host version.
28
+ * Exported (pure) for unit testing without spawning PowerShell.
29
+ */
30
+ export function trayScriptPath() {
31
+ return join(homedir(), '.local', 'share', 'stikfix', 'stikfix-tray.ps1');
32
+ }
33
+ /**
34
+ * The embedded PowerShell tray script. A template constant so writing it is a
35
+ * plain fs write — no external file to ship, always matches this host version.
36
+ *
37
+ * Design notes:
38
+ * - Robust icon: try to load a shipped stikfix.ico from a few candidate paths
39
+ * (relative to the host bundle and to $Root); fall back to SystemIcons.
40
+ * - Running/stopped state is shown *robustly* by switching between two stock
41
+ * SystemIcons (Application = running, Warning = not responding) AND always
42
+ * reflecting state in the tooltip text, so the state is obvious regardless of
43
+ * whether the custom .ico loaded.
44
+ * - A Timer (~3s) polls /status with a short timeout. Throw / non-200 => grey.
45
+ * - The timer also checks the host process: if Get-Process -Id $HostPid is gone,
46
+ * dispose the icon and exit, so a dead host removes the tray (honest presence).
47
+ * - Proper WinForms message loop via ApplicationContext so menu + timer work.
48
+ */
49
+ export const TRAY_PS1 = String.raw `param(
50
+ [int]$Port,
51
+ [string]$Root,
52
+ [string]$Name,
53
+ [string]$NotesDir,
54
+ [int]$HostPid
55
+ )
56
+
57
+ $ErrorActionPreference = 'SilentlyContinue'
58
+
59
+ try {
60
+ Add-Type -AssemblyName System.Windows.Forms
61
+ Add-Type -AssemblyName System.Drawing
62
+ } catch {
63
+ # Without WinForms/Drawing there is no tray to draw. Exit quietly.
64
+ return
65
+ }
66
+
67
+ # --- Resolve a custom app icon, else fall back to a stock icon -------------
68
+ function Get-BaseIcon {
69
+ $candidates = @(
70
+ (Join-Path $PSScriptRoot 'stikfix.ico'),
71
+ (Join-Path $Root '.output\chrome-mv3\icon\stikfix.ico'),
72
+ (Join-Path $Root 'public\icon\stikfix.ico')
73
+ )
74
+ foreach ($p in $candidates) {
75
+ if ($p -and (Test-Path -LiteralPath $p)) {
76
+ try { return New-Object System.Drawing.Icon($p) } catch { }
77
+ }
78
+ }
79
+ return $null
80
+ }
81
+
82
+ $customIcon = Get-BaseIcon
83
+ $iconRunning = if ($customIcon) { $customIcon } else { [System.Drawing.SystemIcons]::Application }
84
+ $iconStopped = [System.Drawing.SystemIcons]::Warning
85
+
86
+ # --- NotifyIcon -------------------------------------------------------------
87
+ $notify = New-Object System.Windows.Forms.NotifyIcon
88
+ $notify.Icon = $iconRunning
89
+ $notify.Visible = $true
90
+
91
+ # NotifyIcon.Text has a 63-char limit; guard so a long $Name never throws.
92
+ function Set-SafeText([string]$t) {
93
+ if ($t.Length -gt 63) { $t = $t.Substring(0, 60) + '...' }
94
+ $notify.Text = $t
95
+ }
96
+ Set-SafeText "stikfix - $Name - starting on :$Port"
97
+
98
+ # --- Context menu -----------------------------------------------------------
99
+ $menu = New-Object System.Windows.Forms.ContextMenuStrip
100
+
101
+ $miOpen = New-Object System.Windows.Forms.ToolStripMenuItem
102
+ $miOpen.Text = 'Open notes folder'
103
+ $miOpen.Add_Click({
104
+ try { Start-Process explorer.exe -ArgumentList @($NotesDir) } catch { }
105
+ })
106
+
107
+ $miStop = New-Object System.Windows.Forms.ToolStripMenuItem
108
+ $miStop.Text = 'Stop host'
109
+ $miStop.Add_Click({
110
+ try { Stop-Process -Id $HostPid -Force -ErrorAction SilentlyContinue } catch { }
111
+ try { $notify.Visible = $false; $notify.Dispose() } catch { }
112
+ [System.Windows.Forms.Application]::Exit()
113
+ })
114
+
115
+ $miQuit = New-Object System.Windows.Forms.ToolStripMenuItem
116
+ $miQuit.Text = 'Quit tray (host keeps running)'
117
+ $miQuit.Add_Click({
118
+ try { $notify.Visible = $false; $notify.Dispose() } catch { }
119
+ [System.Windows.Forms.Application]::Exit()
120
+ })
121
+
122
+ [void]$menu.Items.Add($miOpen)
123
+ [void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
124
+ [void]$menu.Items.Add($miStop)
125
+ [void]$menu.Items.Add($miQuit)
126
+ $notify.ContextMenuStrip = $menu
127
+
128
+ # --- Status polling ---------------------------------------------------------
129
+ function Test-HostAlive {
130
+ # $HostPid gone => host is dead.
131
+ $proc = Get-Process -Id $HostPid -ErrorAction SilentlyContinue
132
+ return [bool]$proc
133
+ }
134
+
135
+ function Test-StatusOk {
136
+ try {
137
+ $req = [System.Net.WebRequest]::Create("http://127.0.0.1:$Port/status")
138
+ $req.Method = 'GET'
139
+ $req.Timeout = 1500
140
+ $resp = $req.GetResponse()
141
+ $code = [int]$resp.StatusCode
142
+ $resp.Close()
143
+ return ($code -eq 200)
144
+ } catch {
145
+ return $false
146
+ }
147
+ }
148
+
149
+ function Update-State {
150
+ # If the host process is gone, remove the tray entirely (honest presence).
151
+ if (-not (Test-HostAlive)) {
152
+ try { $notify.Visible = $false; $notify.Dispose() } catch { }
153
+ [System.Windows.Forms.Application]::Exit()
154
+ return
155
+ }
156
+
157
+ if (Test-StatusOk) {
158
+ $notify.Icon = $iconRunning
159
+ Set-SafeText "stikfix - $Name - running on :$Port"
160
+ } else {
161
+ $notify.Icon = $iconStopped
162
+ Set-SafeText "stikfix - $Name - not responding"
163
+ }
164
+ }
165
+
166
+ $timer = New-Object System.Windows.Forms.Timer
167
+ $timer.Interval = 3000
168
+ $timer.Add_Tick({ Update-State })
169
+ $timer.Start()
170
+
171
+ # First update immediately so the tooltip is correct without waiting 3s.
172
+ Update-State
173
+
174
+ # --- Message loop -----------------------------------------------------------
175
+ $ctx = New-Object System.Windows.Forms.ApplicationContext
176
+ [System.Windows.Forms.Application]::Run($ctx)
177
+
178
+ # Cleanup on exit.
179
+ try { $timer.Stop(); $timer.Dispose() } catch { }
180
+ try { $notify.Visible = $false; $notify.Dispose() } catch { }
181
+ `;
182
+ /**
183
+ * Start the Windows tray helper.
184
+ *
185
+ * Returns the spawned ChildProcess (so the host can kill it on shutdown), or
186
+ * null on any non-win32 platform or on any failure. NEVER throws.
187
+ */
188
+ export function startTray(opts) {
189
+ if (process.platform !== 'win32') {
190
+ return null;
191
+ }
192
+ try {
193
+ const ps1Path = trayScriptPath();
194
+ mkdirSync(join(homedir(), '.local', 'share', 'stikfix'), { recursive: true });
195
+ writeFileSync(ps1Path, TRAY_PS1, { encoding: 'utf8' });
196
+ const child = spawn('powershell.exe', [
197
+ '-NoProfile',
198
+ '-ExecutionPolicy',
199
+ 'Bypass',
200
+ '-WindowStyle',
201
+ 'Hidden',
202
+ '-File',
203
+ ps1Path,
204
+ '-Port',
205
+ String(opts.port),
206
+ '-Root',
207
+ opts.root,
208
+ '-Name',
209
+ opts.name,
210
+ '-NotesDir',
211
+ opts.notesDir,
212
+ '-HostPid',
213
+ String(opts.hostPid),
214
+ ], {
215
+ // Not detached: we want it to be a child the host can kill on exit.
216
+ windowsHide: true,
217
+ stdio: 'ignore',
218
+ });
219
+ // A spawn error (e.g. powershell.exe missing) arrives asynchronously; swallow
220
+ // it so it never becomes an unhandled 'error' event that crashes the host.
221
+ child.on('error', (e) => {
222
+ console.error('stikfix: tray unavailable:', e instanceof Error ? e.message : String(e));
223
+ });
224
+ return child;
225
+ }
226
+ catch (e) {
227
+ console.error('stikfix: tray unavailable:', e instanceof Error ? e.message : String(e));
228
+ return null;
229
+ }
230
+ }
@@ -287,6 +287,35 @@ function createLauncherFiles(opts) {
287
287
  }
288
288
  return { written, warnings };
289
289
  }
290
+ var REG_RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
291
+ var REG_RUN_VALUE = "stikfix-host";
292
+ function registerStartup(opts = {}) {
293
+ const plat = opts.plat ?? process.platform;
294
+ if (plat !== "win32") {
295
+ return;
296
+ }
297
+ const home = opts.home ?? (0, import_node_os.homedir)();
298
+ const vbsPath = (0, import_node_path2.join)(launcherDir(plat, home), LAUNCHER_VBS_FILENAME);
299
+ const wscriptPath = (0, import_node_path2.join)(process.env["SystemRoot"] ?? "C:\\Windows", "System32", "wscript.exe");
300
+ const runData = `"${wscriptPath}" "${vbsPath}"`;
301
+ const execReg = opts.execReg ?? ((args) => {
302
+ (0, import_node_child_process.execFileSync)("reg", args);
303
+ });
304
+ execReg(["ADD", REG_RUN_KEY, "/v", REG_RUN_VALUE, "/t", "REG_SZ", "/d", runData, "/f"]);
305
+ }
306
+ function unregisterStartup(opts = {}) {
307
+ const plat = opts.plat ?? process.platform;
308
+ if (plat !== "win32") {
309
+ return;
310
+ }
311
+ const execReg = opts.execReg ?? ((args) => {
312
+ (0, import_node_child_process.execFileSync)("reg", args);
313
+ });
314
+ try {
315
+ execReg(["DELETE", REG_RUN_KEY, "/v", REG_RUN_VALUE, "/f"]);
316
+ } catch {
317
+ }
318
+ }
290
319
  function registerNativeHost(opts) {
291
320
  const plat = opts.plat ?? process.platform;
292
321
  const home = opts.home ?? (0, import_node_os.homedir)();
@@ -356,7 +385,9 @@ var { values, positionals } = (0, import_node_util.parseArgs)({
356
385
  root: { type: "string" },
357
386
  "extension-id": { type: "string" },
358
387
  port: { type: "string" },
359
- browser: { type: "string" }
388
+ browser: { type: "string" },
389
+ startup: { type: "boolean" },
390
+ "no-startup": { type: "boolean" }
360
391
  },
361
392
  strict: false
362
393
  });
@@ -369,6 +400,36 @@ function resolveBrowser(raw) {
369
400
  }
370
401
  return raw.toLowerCase() === "firefox" ? "firefox" : "chrome";
371
402
  }
403
+ function promptLineSync() {
404
+ const { readSync } = require("node:fs");
405
+ const buf = Buffer.alloc(1);
406
+ let line = "";
407
+ for (; ; ) {
408
+ let bytes = 0;
409
+ try {
410
+ bytes = readSync(0, buf, 0, 1, null);
411
+ } catch {
412
+ break;
413
+ }
414
+ if (bytes === 0) break;
415
+ const ch = buf.toString("utf8", 0, 1);
416
+ if (ch === "\n") break;
417
+ if (ch === "\r") continue;
418
+ line += ch;
419
+ }
420
+ return line.trim();
421
+ }
422
+ function resolveStartupChoice(forceOn, forceOff, isTTY) {
423
+ if (forceOn) return true;
424
+ if (forceOff) return false;
425
+ if (isTTY) {
426
+ process.stdout.write("Start stikfix host automatically on Windows login? [Y/n] ");
427
+ const answer = promptLineSync().toLowerCase();
428
+ if (answer === "n" || answer === "no") return false;
429
+ return true;
430
+ }
431
+ return false;
432
+ }
372
433
  var CONFIG_DIR = (0, import_node_path3.join)((0, import_node_os2.homedir)(), ".config", "stikfix");
373
434
  var CONFIG_PATH = (0, import_node_path3.join)(CONFIG_DIR, "config.json");
374
435
  if (subcommand === "init") {
@@ -388,8 +449,9 @@ if (subcommand === "init") {
388
449
  const root = (0, import_node_path3.resolve)(rawRoot);
389
450
  const name = (0, import_node_path3.basename)(root);
390
451
  const notesDir = (0, import_node_path3.join)(root, "notes");
452
+ const hostEntryPath = (0, import_node_path3.resolve)((0, import_node_path3.join)(__dirname, "src", "index.js"));
391
453
  (0, import_node_fs2.mkdirSync)(CONFIG_DIR, { recursive: true });
392
- const config = { root, name, notesDir };
454
+ const config = { root, name, notesDir, hostEntry: hostEntryPath, nodePath: process.execPath };
393
455
  (0, import_node_fs2.writeFileSync)(CONFIG_PATH, JSON.stringify(config, null, 2), { encoding: "utf8", mode: 384 });
394
456
  const hostBinPath = (0, import_node_path3.resolve)((0, import_node_path3.join)(__dirname, "stikfix-native.cjs"));
395
457
  try {
@@ -398,7 +460,6 @@ if (subcommand === "init") {
398
460
  console.error("stikfix init: failed to register native host:", String(err));
399
461
  process.exit(1);
400
462
  }
401
- const hostEntryPath = (0, import_node_path3.resolve)((0, import_node_path3.join)(__dirname, "src", "index.js"));
402
463
  const projectRoot = (0, import_node_path3.resolve)((0, import_node_path3.join)(__dirname, "..", ".."));
403
464
  const firefoxOutputCandidates = [
404
465
  (0, import_node_path3.join)(projectRoot, ".output", "firefox-mv2"),
@@ -426,6 +487,32 @@ if (subcommand === "init") {
426
487
  for (const warn of launcherResult.warnings) {
427
488
  console.warn(" [warn] " + warn);
428
489
  }
490
+ if (process.platform === "win32") {
491
+ const wantStartup = resolveStartupChoice(
492
+ values["startup"] === true,
493
+ values["no-startup"] === true,
494
+ Boolean(process.stdin.isTTY)
495
+ );
496
+ if (wantStartup) {
497
+ try {
498
+ registerStartup();
499
+ console.log(" Startup: enabled \u2014 the host will start on Windows login.");
500
+ } catch (err) {
501
+ console.warn(
502
+ " [warn] Could not register Windows startup autoload (non-fatal): " + String(err)
503
+ );
504
+ }
505
+ } else {
506
+ try {
507
+ unregisterStartup();
508
+ } catch {
509
+ }
510
+ const hint = values["no-startup"] !== true && !process.stdin.isTTY ? " (enable later with: npx stikfix init --root <dir> --startup)" : "";
511
+ console.log(" Startup: not enabled" + hint + ".");
512
+ }
513
+ } else if (values["startup"] === true) {
514
+ console.log(" Startup: skipped \u2014 startup autoload is Windows-only for now.");
515
+ }
429
516
  console.log("");
430
517
  console.log("stikfix: native host registered successfully.");
431
518
  console.log("");
@@ -486,10 +573,16 @@ if (subcommand === "init") {
486
573
  } catch (err) {
487
574
  console.error("stikfix uninstall: error removing native-host manifest:", String(err));
488
575
  }
576
+ try {
577
+ unregisterStartup();
578
+ } catch (err) {
579
+ console.error("stikfix uninstall: error removing startup entry:", String(err));
580
+ }
489
581
  (0, import_node_fs2.rmSync)(CONFIG_PATH, { force: true });
490
582
  console.log("stikfix: native host unregistered.");
491
583
  console.log(" manifest removed");
492
584
  console.log(" launcher files removed");
585
+ console.log(" startup entry removed");
493
586
  console.log(" config removed");
494
587
  } else {
495
588
  console.error("Usage: npx stikfix <init|uninstall> [--root <dir>] [--browser <chrome|firefox>] [--extension-id <id>] [--port <port>]");
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var native_host_exports = {};
22
22
  __export(native_host_exports, {
23
23
  handlePickFolder: () => handlePickFolder,
24
+ handleStartHost: () => handleStartHost,
24
25
  main: () => main,
25
26
  validateChosenFolder: () => validateChosenFolder
26
27
  });
@@ -28,6 +29,7 @@ module.exports = __toCommonJS(native_host_exports);
28
29
  var import_node_fs2 = require("node:fs");
29
30
  var import_node_path2 = require("node:path");
30
31
  var import_node_os = require("node:os");
32
+ var import_node_child_process2 = require("node:child_process");
31
33
 
32
34
  // host/src/native-msg.ts
33
35
  function encodeNativeMessage(msg) {
@@ -107,11 +109,11 @@ function buildPickerArgs(plat = process.platform, title = "Choose folder") {
107
109
  }
108
110
  async function pickFolder(title = "Choose a project folder", plat = process.platform) {
109
111
  if (plat === "darwin") {
110
- return new Promise((resolve2) => {
112
+ return new Promise((resolve3) => {
111
113
  const { args } = buildPickerArgs("darwin", title);
112
114
  (0, import_node_child_process.execFile)("osascript", args, { timeout: 12e4 }, (err, stdout) => {
113
115
  if (err || !stdout.trim()) {
114
- resolve2(null);
116
+ resolve3(null);
115
117
  return;
116
118
  }
117
119
  const raw = stdout.trim();
@@ -120,7 +122,7 @@ async function pickFolder(title = "Choose a project folder", plat = process.plat
120
122
  ["-e", `POSIX path of (${raw})`],
121
123
  {},
122
124
  (e2, out2) => {
123
- resolve2(e2 ? null : out2.trim() || null);
125
+ resolve3(e2 ? null : out2.trim() || null);
124
126
  }
125
127
  );
126
128
  });
@@ -128,9 +130,9 @@ async function pickFolder(title = "Choose a project folder", plat = process.plat
128
130
  }
129
131
  if (plat === "win32") {
130
132
  const { cmd, args } = buildPickerArgs("win32", title);
131
- return new Promise((resolve2) => {
133
+ return new Promise((resolve3) => {
132
134
  (0, import_node_child_process.execFile)(cmd, args, { timeout: 12e4 }, (err, stdout) => {
133
- resolve2(err ? null : stdout.trim() || null);
135
+ resolve3(err ? null : stdout.trim() || null);
134
136
  });
135
137
  });
136
138
  }
@@ -141,19 +143,19 @@ async function pickFolder(title = "Choose a project folder", plat = process.plat
141
143
  return null;
142
144
  }
143
145
  function tryLinuxPicker(tool, title) {
144
- return new Promise((resolve2) => {
146
+ return new Promise((resolve3) => {
145
147
  const args = tool === "zenity" ? ["--file-selection", "--directory", `--title=${title}`] : ["--getexistingdirectory", "/home"];
146
148
  (0, import_node_child_process.execFile)(tool, args, { timeout: 12e4 }, (err, stdout) => {
147
149
  if (err) {
148
150
  const e = err;
149
151
  if (e.code === "ENOENT") {
150
- resolve2(void 0);
152
+ resolve3(void 0);
151
153
  } else {
152
- resolve2(null);
154
+ resolve3(null);
153
155
  }
154
156
  return;
155
157
  }
156
- resolve2(stdout.trim() || null);
158
+ resolve3(stdout.trim() || null);
157
159
  });
158
160
  });
159
161
  }
@@ -186,6 +188,44 @@ function validateChosenFolder(folder, plat = process.platform) {
186
188
 
187
189
  // host/src/native-host.ts
188
190
  var CONFIG_PATH = (0, import_node_path2.join)((0, import_node_os.homedir)(), ".config", "stikfix", "config.json");
191
+ function resolveHostEntry(cfg) {
192
+ if (typeof cfg.hostEntry === "string" && cfg.hostEntry.length > 0) {
193
+ return cfg.hostEntry;
194
+ }
195
+ return (0, import_node_path2.join)(__dirname, "src", "index.js");
196
+ }
197
+ function handleStartHost(cfg, root, spawnFn = import_node_child_process2.spawn, out) {
198
+ const emit = (msg) => out ? sendNativeMessage(msg, out) : sendNativeMessage(msg);
199
+ if (typeof root !== "string" || root.length === 0) {
200
+ emit({ type: "ERROR", error: 'START_HOST requires a non-empty "root" string.' });
201
+ return false;
202
+ }
203
+ const resolvedRoot = (0, import_node_path2.resolve)(root);
204
+ try {
205
+ if (!(0, import_node_fs2.statSync)(resolvedRoot).isDirectory()) {
206
+ emit({ type: "ERROR", error: `START_HOST root is not a directory: ${resolvedRoot}` });
207
+ return false;
208
+ }
209
+ } catch {
210
+ emit({ type: "ERROR", error: `START_HOST root does not exist: ${resolvedRoot}` });
211
+ return false;
212
+ }
213
+ const nodePath = typeof cfg.nodePath === "string" && cfg.nodePath.length > 0 ? cfg.nodePath : process.execPath;
214
+ const hostEntry = resolveHostEntry(cfg);
215
+ try {
216
+ const child = spawnFn(nodePath, [hostEntry, "--root", resolvedRoot], {
217
+ detached: true,
218
+ stdio: "ignore",
219
+ windowsHide: true
220
+ });
221
+ child.unref();
222
+ } catch (err) {
223
+ emit({ type: "ERROR", error: `Failed to start host: ${String(err.message)}` });
224
+ return false;
225
+ }
226
+ emit({ type: "HOST_STARTING", root: resolvedRoot });
227
+ return true;
228
+ }
189
229
  async function handlePickFolder(origin, pickFn = pickFolder, plat = process.platform, out) {
190
230
  let chosen;
191
231
  try {
@@ -211,16 +251,41 @@ function main() {
211
251
  readNativeMessages((msg) => {
212
252
  const m = msg;
213
253
  if (m.type === "GET_TOKEN") {
254
+ let tokenRoot = cfg.root;
255
+ let name = cfg.name;
256
+ let notesDir = cfg.notesDir;
257
+ if (typeof m.root === "string" && m.root.length > 0) {
258
+ const resolvedRoot = (0, import_node_path2.resolve)(m.root);
259
+ let isDir = false;
260
+ try {
261
+ isDir = (0, import_node_fs2.statSync)(resolvedRoot).isDirectory();
262
+ } catch {
263
+ isDir = false;
264
+ }
265
+ if (!isDir) {
266
+ sendNativeMessage({
267
+ type: "ERROR",
268
+ error: "GET_TOKEN root not accessible: " + resolvedRoot
269
+ });
270
+ process.exit(1);
271
+ }
272
+ tokenRoot = resolvedRoot;
273
+ name = (0, import_node_path2.basename)(resolvedRoot);
274
+ notesDir = (0, import_node_path2.join)(resolvedRoot, "notes");
275
+ }
214
276
  let token;
215
277
  try {
216
- token = (0, import_node_fs2.readFileSync)((0, import_node_path2.join)(cfg.root, ".stikfix-token"), "utf8").trim();
278
+ token = (0, import_node_fs2.readFileSync)((0, import_node_path2.join)(tokenRoot, ".stikfix-token"), "utf8").trim();
217
279
  } catch {
218
- sendNativeMessage({ type: "ERROR", error: ".stikfix-token not found. Start the host first." });
280
+ sendNativeMessage({
281
+ type: "ERROR",
282
+ error: "No .stikfix-token in " + tokenRoot + ". Start the host first."
283
+ });
219
284
  process.exit(1);
220
285
  }
221
286
  let port;
222
287
  try {
223
- const raw = (0, import_node_fs2.readFileSync)((0, import_node_path2.join)(cfg.root, ".stikfix-port"), "utf8").trim();
288
+ const raw = (0, import_node_fs2.readFileSync)((0, import_node_path2.join)(tokenRoot, ".stikfix-port"), "utf8").trim();
224
289
  const parsed = parseInt(raw, 10);
225
290
  if (!isNaN(parsed)) {
226
291
  port = parsed;
@@ -231,11 +296,15 @@ function main() {
231
296
  type: "TOKEN",
232
297
  token,
233
298
  port,
234
- name: cfg.name,
235
- notesDir: cfg.notesDir
299
+ name,
300
+ notesDir
236
301
  });
237
302
  process.exit(0);
238
303
  }
304
+ if (m.type === "START_HOST") {
305
+ const ok = handleStartHost(cfg, m.root);
306
+ process.exit(ok ? 0 : 1);
307
+ }
239
308
  if (m.type === "PICK_FOLDER") {
240
309
  handlePickFolder(m.origin).then(
241
310
  () => process.exit(0),
@@ -252,6 +321,7 @@ if (typeof require !== "undefined" && typeof module !== "undefined" && require.m
252
321
  // Annotate the CommonJS export names for ESM import in node:
253
322
  0 && (module.exports = {
254
323
  handlePickFolder,
324
+ handleStartHost,
255
325
  main,
256
326
  validateChosenFolder
257
327
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stikfix",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Pin sticky notes on any page — your AI reads them.",
5
5
  "license": "MIT",
6
6
  "author": "Omer Nesher <omernesher@gmail.com>",