stikfix 1.3.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,135 +12,174 @@
12
12
  import { writeFileSync, readFileSync, existsSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  import { parseArgs } from 'node:util';
15
+ import { pathToFileURL } from 'node:url';
15
16
  import { resolveConfig, resolveConfigValues, ensureNotesDir, writeTokenFile } from './config.js';
16
17
  import { createHostServer } from './server.js';
17
18
  import { bindServer, BIND_HOST } from './bind.js';
18
19
  import { probeExistingHost } from './probe.js';
19
20
  import { startTray } from './tray.js';
20
- // ---------------------------------------------------------------------------
21
- // CLI parsing (preserve Phase 1 stub options block HOST-13)
22
- // ---------------------------------------------------------------------------
23
- const { values: rawValues } = parseArgs({
24
- options: {
25
- root: { type: 'string' },
26
- origin: { type: 'string', multiple: true },
27
- name: { type: 'string' },
28
- 'notes-dir': { type: 'string' },
29
- port: { type: 'string' },
30
- token: { type: 'string' },
31
- },
32
- strict: false,
33
- });
34
- // Apply three-tier env resolution (flag > STIKFIX_* > npm_config_*) so that
35
- // `npm run host -- --root <dir>` works in Windows PowerShell, where npm 11.x
36
- // intercepts unknown flags and re-exposes them as npm_config_<key> env vars.
37
- const values = resolveConfigValues(rawValues);
38
- if (!values['root']) {
39
- console.error('stikfix-host: --root is required');
40
- process.exit(1);
41
- }
42
- // ---------------------------------------------------------------------------
43
- // Config resolution + filesystem setup
44
- // ---------------------------------------------------------------------------
45
- const cfg = resolveConfig(values);
46
- ensureNotesDir(cfg.notesDir);
47
- // ---------------------------------------------------------------------------
48
- // FIX-SI: Single-instance guard — BEFORE any file mutation
49
- // ---------------------------------------------------------------------------
50
- const portFilePath = join(cfg.root, '.stikfix-port');
51
- if (existsSync(portFilePath)) {
52
- const portStr = readFileSync(portFilePath, 'utf8').trim();
53
- const existingPort = Number(portStr);
54
- if (Number.isInteger(existingPort) && existingPort > 0 && existingPort <= 65535) {
55
- const live = await probeExistingHost(cfg.root, existingPort);
56
- if (live !== null) {
57
- console.log(`stikfix-host: already running for ${cfg.root} on port ${live.port} — not starting a second instance.`);
58
- process.exit(0);
21
+ /**
22
+ * Start the HTTP host. Extracted from the former top-level module body so the
23
+ * single-executable dispatcher (exe-main.ts) can call it as `serve`, while
24
+ * `node dist/host/src/index.js` keeps running it via the direct-entry guard at
25
+ * the bottom of this file. Behavior is byte-for-byte identical to the previous
26
+ * top-level code — the only change is that argv is passed in (defaulting to
27
+ * process.argv.slice(2)) instead of being read from the global process.argv.
28
+ */
29
+ export async function startHttpHost(argv = process.argv.slice(2)) {
30
+ // -------------------------------------------------------------------------
31
+ // CLI parsing (preserve Phase 1 stub options block — HOST-13)
32
+ // -------------------------------------------------------------------------
33
+ const { values: rawValues } = parseArgs({
34
+ args: argv,
35
+ options: {
36
+ root: { type: 'string' },
37
+ origin: { type: 'string', multiple: true },
38
+ name: { type: 'string' },
39
+ 'notes-dir': { type: 'string' },
40
+ port: { type: 'string' },
41
+ token: { type: 'string' },
42
+ 'git-sync': { type: 'boolean' },
43
+ },
44
+ strict: false,
45
+ });
46
+ // Apply three-tier env resolution (flag > STIKFIX_* > npm_config_*) so that
47
+ // `npm run host -- --root <dir>` works in Windows PowerShell, where npm 11.x
48
+ // intercepts unknown flags and re-exposes them as npm_config_<key> env vars.
49
+ const values = resolveConfigValues(rawValues);
50
+ if (!values['root']) {
51
+ console.error('stikfix-host: --root is required');
52
+ process.exit(1);
53
+ }
54
+ // -------------------------------------------------------------------------
55
+ // Config resolution + filesystem setup
56
+ // -------------------------------------------------------------------------
57
+ const cfg = resolveConfig(values);
58
+ ensureNotesDir(cfg.notesDir);
59
+ // -------------------------------------------------------------------------
60
+ // FIX-SI: Single-instance guard — BEFORE any file mutation
61
+ // -------------------------------------------------------------------------
62
+ const portFilePath = join(cfg.root, '.stikfix-port');
63
+ if (existsSync(portFilePath)) {
64
+ const portStr = readFileSync(portFilePath, 'utf8').trim();
65
+ const existingPort = Number(portStr);
66
+ if (Number.isInteger(existingPort) && existingPort > 0 && existingPort <= 65535) {
67
+ const live = await probeExistingHost(cfg.root, existingPort);
68
+ if (live !== null) {
69
+ console.log(`stikfix-host: already running for ${cfg.root} on port ${live.port} — not starting a second instance.`);
70
+ process.exit(0);
71
+ }
72
+ // else: stale port file — continue with normal startup
59
73
  }
60
- // else: stale port file — continue with normal startup
61
74
  }
62
- }
63
- // ---------------------------------------------------------------------------
64
- // FIX-TP: Token persistence — reuse existing token if no explicit token given
65
- // ---------------------------------------------------------------------------
66
- // An "explicit" token is one supplied via --token / STIKFIX_TOKEN / npm_config_token.
67
- // resolveConfig already resolved these; if none was supplied, randomUUID() was called.
68
- // We detect the "no explicit token" case by checking the raw resolved values BEFORE
69
- // randomUUID was applied.
70
- const rawToken = values['token'] ??
71
- (process.env['STIKFIX_TOKEN']) ??
72
- (process.env['npm_config_token']);
73
- const tokenFilePath = join(cfg.root, '.stikfix-token');
74
- let finalToken = cfg.token;
75
- if (!rawToken) {
76
- // No explicit token provided — try to reuse existing token file
77
- if (existsSync(tokenFilePath)) {
78
- const existing = readFileSync(tokenFilePath, 'utf8').trim();
79
- if (existing.length > 0) {
80
- finalToken = existing;
75
+ // -------------------------------------------------------------------------
76
+ // FIX-TP: Token persistence — reuse existing token if no explicit token given
77
+ // -------------------------------------------------------------------------
78
+ // An "explicit" token is one supplied via --token / STIKFIX_TOKEN / npm_config_token.
79
+ // resolveConfig already resolved these; if none was supplied, randomUUID() was called.
80
+ // We detect the "no explicit token" case by checking the raw resolved values BEFORE
81
+ // randomUUID was applied.
82
+ const rawToken = values['token'] ??
83
+ (process.env['STIKFIX_TOKEN']) ??
84
+ (process.env['npm_config_token']);
85
+ const tokenFilePath = join(cfg.root, '.stikfix-token');
86
+ let finalToken = cfg.token;
87
+ if (!rawToken) {
88
+ // No explicit token provided — try to reuse existing token file
89
+ if (existsSync(tokenFilePath)) {
90
+ const existing = readFileSync(tokenFilePath, 'utf8').trim();
91
+ if (existing.length > 0) {
92
+ finalToken = existing;
93
+ }
94
+ // else: empty file — keep freshly generated UUID from resolveConfig
95
+ }
96
+ // else: no existing file — keep freshly generated UUID from resolveConfig
97
+ }
98
+ // Write the final token (idempotent — fixes perms to 0o600 via writeTokenFile)
99
+ writeTokenFile(cfg.root, finalToken);
100
+ // -------------------------------------------------------------------------
101
+ // Port discovery — bind-or-fail loop on 127.0.0.1 (Pattern 1 / D-08)
102
+ // -------------------------------------------------------------------------
103
+ const server = createHostServer({ ...cfg, token: finalToken });
104
+ const boundPort = await bindServer(server, cfg.port);
105
+ // Safety assertion: binding must be 127.0.0.1 (T-02-bind)
106
+ const addr = server.address();
107
+ if (addr.address !== BIND_HOST) {
108
+ throw new Error(`FATAL: server bound to ${addr.address} instead of ${BIND_HOST}`);
109
+ }
110
+ // Publish bound port to disk so the native host can read it without a port scan
111
+ // (RESEARCH Open Question 2 / A5). Mode 0o600 alongside existing token file.
112
+ writeFileSync(join(cfg.root, '.stikfix-port'), String(boundPort), { encoding: 'utf8', mode: 0o600 });
113
+ // Startup JSON line — read by smoke test via readline (Pattern 12)
114
+ console.log(JSON.stringify({
115
+ app: 'stikfix',
116
+ name: cfg.name,
117
+ root: cfg.root,
118
+ port: boundPort,
119
+ token: finalToken,
120
+ notesDir: cfg.notesDir,
121
+ origins: cfg.origins,
122
+ }));
123
+ // -------------------------------------------------------------------------
124
+ // Windows system-tray indicator (best-effort, cosmetic, win32-only)
125
+ // -------------------------------------------------------------------------
126
+ // Only reached on genuine startup — the single-instance guard above exits(0)
127
+ // before here, so a second instance never spawns a duplicate tray.
128
+ const tray = startTray({
129
+ port: boundPort,
130
+ root: cfg.root,
131
+ name: cfg.name,
132
+ notesDir: cfg.notesDir,
133
+ hostPid: process.pid,
134
+ });
135
+ // Kill the tray child when the host shuts down so a dead host has no tray.
136
+ // (No prior signal handling existed here; these are additive.)
137
+ let shuttingDown = false;
138
+ function shutdown(signal) {
139
+ if (shuttingDown)
140
+ return;
141
+ shuttingDown = true;
142
+ try {
143
+ tray?.kill();
81
144
  }
82
- // else: empty file keep freshly generated UUID from resolveConfig
145
+ catch { /* best-effort */ }
146
+ server.close(() => process.exit(0));
147
+ // Fallback: exit even if server.close hangs on lingering connections.
148
+ setTimeout(() => process.exit(0), 1000).unref();
149
+ void signal;
83
150
  }
84
- // else: no existing file — keep freshly generated UUID from resolveConfig
151
+ process.on('SIGINT', () => shutdown('SIGINT'));
152
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
153
+ // Belt-and-suspenders: also reap the tray on plain process exit.
154
+ process.on('exit', () => { try {
155
+ tray?.kill();
156
+ }
157
+ catch { /* best-effort */ } });
158
+ // Server runs indefinitely (Pitfall 2 — no process.exit here)
85
159
  }
86
- // Write the final token (idempotent — fixes perms to 0o600 via writeTokenFile)
87
- writeTokenFile(cfg.root, finalToken);
88
- // ---------------------------------------------------------------------------
89
- // Port discovery — bind-or-fail loop on 127.0.0.1 (Pattern 1 / D-08)
90
- // ---------------------------------------------------------------------------
91
- const server = createHostServer({ ...cfg, token: finalToken });
92
- const boundPort = await bindServer(server, cfg.port);
93
- // Safety assertion: binding must be 127.0.0.1 (T-02-bind)
94
- const addr = server.address();
95
- if (addr.address !== BIND_HOST) {
96
- throw new Error(`FATAL: server bound to ${addr.address} instead of ${BIND_HOST}`);
160
+ function isBundled() {
161
+ try {
162
+ return typeof __STIKFIX_BUNDLED__ !== 'undefined' && __STIKFIX_BUNDLED__ === true;
163
+ }
164
+ catch {
165
+ return false;
166
+ }
97
167
  }
98
- // Publish bound port to disk so the native host can read it without a port scan
99
- // (RESEARCH Open Question 2 / A5). Mode 0o600 alongside existing token file.
100
- writeFileSync(join(cfg.root, '.stikfix-port'), String(boundPort), { encoding: 'utf8', mode: 0o600 });
101
- // Startup JSON line — read by smoke test via readline (Pattern 12)
102
- console.log(JSON.stringify({
103
- app: 'stikfix',
104
- name: cfg.name,
105
- root: cfg.root,
106
- port: boundPort,
107
- token: finalToken,
108
- notesDir: cfg.notesDir,
109
- origins: cfg.origins,
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;
168
+ function isDirectEntry() {
169
+ // In the single-executable bundle, exe-main.ts dispatches explicitly this
170
+ // guard must never fire there (it would double-start the host).
171
+ if (isBundled())
172
+ return false;
130
173
  try {
131
- tray?.kill();
174
+ return import.meta.url === pathToFileURL(process.argv[1] ?? '').href;
175
+ }
176
+ catch {
177
+ return false;
132
178
  }
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
179
  }
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();
180
+ if (isDirectEntry()) {
181
+ startHttpHost().catch((err) => {
182
+ console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
183
+ process.exit(1);
184
+ });
144
185
  }
145
- catch { /* best-effort */ } });
146
- // Server runs indefinitely (Pitfall 2 — no process.exit here)
@@ -78,11 +78,19 @@ export function handleStartHost(cfg, root, spawnFn = spawn, out) {
78
78
  }
79
79
  const nodePath = typeof cfg.nodePath === 'string' && cfg.nodePath.length > 0 ? cfg.nodePath : process.execPath;
80
80
  const hostEntry = resolveHostEntry(cfg);
81
+ // Standalone-exe install path: when config points at a single-executable host,
82
+ // spawn `<exe> serve --root <root>` (the exe self-detects the subcommand).
83
+ // Otherwise keep the exact prior behavior: `node <hostEntry> --root <root>`.
84
+ const hostExe = typeof cfg.hostExe === 'string' && cfg.hostExe.length > 0 ? cfg.hostExe : undefined;
85
+ const spawnCmd = hostExe ?? nodePath;
86
+ const spawnArgs = hostExe
87
+ ? ['serve', '--root', resolvedRoot]
88
+ : [hostEntry, '--root', resolvedRoot];
81
89
  // CRITICAL (T-09-07): the native host MUST NOT bind/listen itself. It only
82
90
  // spawns a SEPARATE, detached process that is the HTTP host. Detached +
83
91
  // stdio:'ignore' + unref() so the child outlives this one-shot native process.
84
92
  try {
85
- const child = spawnFn(nodePath, [hostEntry, '--root', resolvedRoot], {
93
+ const child = spawnFn(spawnCmd, spawnArgs, {
86
94
  detached: true,
87
95
  stdio: 'ignore',
88
96
  windowsHide: true,
@@ -239,12 +247,31 @@ export function main() {
239
247
  process.exit(0);
240
248
  });
241
249
  }
242
- // Run only when invoked as the entry point (esbuild CJS bundle), never on import.
243
- // In the esbuild CJS bundle (stikfix-native.cjs), `require`/`module` are CJS
244
- // scope locals and `require.main === module` is true when run directly. The
245
- // node:test compile path emits ESM where these are undefined at runtime the
246
- // `typeof` guards keep main() from running on import there.
247
- if (typeof require !== 'undefined' &&
250
+ /**
251
+ * Async-friendly entry alias for the SEA dispatcher (exe-main.ts). The native
252
+ * message loop is event-driven, so this resolves immediately after wiring up the
253
+ * stdin listener; the process stays alive until stdin closes (Chrome closes the
254
+ * pipe) or a handler calls process.exit. Delegates to main() so the direct
255
+ * require.main path below is unchanged.
256
+ */
257
+ export async function runNativeHost() {
258
+ main();
259
+ }
260
+ function bundledInExe() {
261
+ try {
262
+ return typeof __STIKFIX_BUNDLED__ !== 'undefined' && __STIKFIX_BUNDLED__ === true;
263
+ }
264
+ catch {
265
+ return false;
266
+ }
267
+ }
268
+ // Run only when invoked as the entry point (standalone stikfix-native.cjs),
269
+ // never on import. In that esbuild CJS bundle `require`/`module` are CJS scope
270
+ // locals and `require.main === module` is true when run directly. The node:test
271
+ // compile path emits ESM where these are undefined at runtime — the `typeof`
272
+ // guards keep main() from running on import there.
273
+ if (!bundledInExe() &&
274
+ typeof require !== 'undefined' &&
248
275
  typeof module !== 'undefined' &&
249
276
  require.main === module) {
250
277
  main();
@@ -16,6 +16,7 @@ import { withSerialLock, getNextSerial } from './serial.js';
16
16
  import { writeNote } from './write-note.js';
17
17
  import { listAnnotations, editNote, deleteNote } from './read-note.js';
18
18
  import { validateChosenFolder } from './validate-folder.js';
19
+ import { isGitRepo, getLastGitSyncStatus, gitSyncNote } from './git-sync.js';
19
20
  // ---------------------------------------------------------------------------
20
21
  // Per-request notes-dir resolution (D-04 — targetDir support)
21
22
  // ---------------------------------------------------------------------------
@@ -81,7 +82,7 @@ function setPreflightHeaders(req, res) {
81
82
  // ---------------------------------------------------------------------------
82
83
  // Route handlers
83
84
  // ---------------------------------------------------------------------------
84
- function handleStatus(req, res, cfg) {
85
+ async function handleStatus(req, res, cfg) {
85
86
  setCorsHeaders(req, res);
86
87
  const body = JSON.stringify({
87
88
  app: 'stikfix',
@@ -90,6 +91,10 @@ function handleStatus(req, res, cfg) {
90
91
  root: cfg.root,
91
92
  notesDir: cfg.notesDir,
92
93
  origins: cfg.origins,
94
+ // git-sync surface (shared contract): is cfg.root a git work tree, and the
95
+ // result of the most recent git-sync attempt (null if none yet).
96
+ gitRepo: await isGitRepo(cfg.root),
97
+ gitSyncStatus: getLastGitSyncStatus(),
93
98
  });
94
99
  res.writeHead(200, { 'Content-Type': 'application/json' });
95
100
  res.end(body);
@@ -162,12 +167,21 @@ async function handleAnnotation(req, res, cfg) {
162
167
  // Writes are confined to <notesDir> (= cfg.notesDir OR <validated targetDir>/notes).
163
168
  // targetDir is NOT persisted into the note frontmatter (routing-only field).
164
169
  try {
170
+ let serialNum = 0;
165
171
  const { file, serial } = await withSerialLock(async () => {
166
- const serialNum = getNextSerial(notesDir);
172
+ serialNum = getNextSerial(notesDir);
167
173
  return writeNote(notesDir, payload, serialNum);
168
174
  });
169
175
  res.writeHead(200, { 'Content-Type': 'application/json' });
170
176
  res.end(JSON.stringify({ ok: true, file, serial }));
177
+ // git-sync (opt-in) — AFTER the 200 is sent, outside withSerialLock, and
178
+ // fire-and-forget so Send stays instant. Runs iff the CLI/machine default
179
+ // (cfg.gitSync) OR the per-send flag (payload.gitSync) is true. Its own queue
180
+ // serializes concurrent notes; .catch-guarded so it can never crash the host.
181
+ const doSync = cfg.gitSync === true || payload.gitSync === true;
182
+ if (doSync) {
183
+ void gitSyncNote({ root: cfg.root, notesDir, serial: serialNum }).catch(() => { });
184
+ }
171
185
  }
172
186
  catch (e) {
173
187
  // CR-02: propagate statusCode from write-phase errors (e.g. bad screenshot → 400)
@@ -384,7 +398,13 @@ export function createHostServer(cfg) {
384
398
  return;
385
399
  }
386
400
  if (method === 'GET' && path === '/status') {
387
- handleStatus(req, res, cfg);
401
+ handleStatus(req, res, cfg).catch((e) => {
402
+ if (!res.headersSent) {
403
+ setCorsHeaders(req, res);
404
+ res.writeHead(500, { 'Content-Type': 'application/json' });
405
+ res.end(JSON.stringify({ ok: false, error: String(e) }));
406
+ }
407
+ });
388
408
  return;
389
409
  }
390
410
  if (method === 'POST' && path === '/annotation') {
@@ -30,6 +30,8 @@ var REG_CHROME_KEY = `HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${NA
30
30
  var REG_EDGE_KEY = `HKCU\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\${NATIVE_HOST_NAME}`;
31
31
  var REG_FIREFOX_KEY = `HKCU\\Software\\Mozilla\\NativeMessagingHosts\\${NATIVE_HOST_NAME}`;
32
32
  var DEFAULT_GECKO_ID = "stikfix@stikfix.com";
33
+ var CONFIG_DIR = (home) => (0, import_node_path2.join)(home, ".config", "stikfix");
34
+ var CONFIG_PATH = (home) => (0, import_node_path2.join)(CONFIG_DIR(home), "config.json");
33
35
  var LAUNCHER_BATCH_FILENAME = "stikfix-host.bat";
34
36
  var LAUNCHER_VBS_FILENAME = "stikfix-host.vbs";
35
37
  var LAUNCHER_LNK_FILENAME = "Stikfix Host.lnk";
@@ -176,14 +178,17 @@ function createLauncherFiles(opts) {
176
178
  const hostEntry = opts.hostEntryPath;
177
179
  const rootArg = opts.root;
178
180
  const portArg = opts.port !== void 0 ? ` --port ${opts.port}` : "";
181
+ const useExe = typeof opts.hostExe === "string" && opts.hostExe.length > 0;
182
+ const hostExe = opts.hostExe ?? "";
179
183
  if (plat === "win32") {
184
+ const batchLaunch = useExe ? `"${hostExe}" serve --root "${rootArg}"${portArg}` : `"${nodeCmd}" "${hostEntry}" --root "${rootArg}"${portArg}`;
180
185
  const batchContent = [
181
186
  "@echo off",
182
187
  `rem Stikfix HTTP host launcher \u2014 double-click to start the backend`,
183
188
  `rem Generated by: npx stikfix init`,
184
189
  `rem Root: ${rootArg}`,
185
190
  ``,
186
- `"${nodeCmd}" "${hostEntry}" --root "${rootArg}"${portArg}`,
191
+ batchLaunch,
187
192
  `if %ERRORLEVEL% NEQ 0 pause`
188
193
  ].join("\r\n");
189
194
  const batchPath = paths.launcher;
@@ -191,17 +196,20 @@ function createLauncherFiles(opts) {
191
196
  written.push(batchPath);
192
197
  const vq = (s) => s.replace(/"/g, '""');
193
198
  const vbsHostEntry = vq(hostEntry);
199
+ const vbsHostExe = vq(hostExe);
194
200
  const vbsRoot = vq(rootArg);
195
201
  const vbsPortArg = opts.port !== void 0 ? ` --port ${opts.port}` : "";
202
+ const vbsRunLine = useExe ? `sh.Run "cmd /c """ & hostExe & """ serve --root """ & root & """${vbsPortArg}", 0, False` : `sh.Run "cmd /c node """ & hostEntry & """ --root """ & root & """${vbsPortArg}", 0, False`;
196
203
  const vbsContent = [
197
204
  "Option Explicit",
198
- "Dim sh, fso, hostEntry, root, portFile, msg, port, f",
205
+ "Dim sh, fso, hostEntry, hostExe, root, portFile, msg, port, f",
199
206
  'Set sh = CreateObject("WScript.Shell")',
200
207
  'Set fso = CreateObject("Scripting.FileSystemObject")',
201
208
  `hostEntry = "${vbsHostEntry}"`,
209
+ `hostExe = "${vbsHostExe}"`,
202
210
  `root = "${vbsRoot}"`,
203
211
  "' Launch the host hidden (window style 0), do not wait",
204
- `sh.Run "cmd /c node """ & hostEntry & """ --root """ & root & """${vbsPortArg}", 0, False`,
212
+ vbsRunLine,
205
213
  "' Give it a moment to bind and write the port file",
206
214
  "WScript.Sleep 1800",
207
215
  'msg = "Stikfix host is running." & vbCrLf & vbCrLf & "You can start dropping notes."',
@@ -218,21 +226,22 @@ function createLauncherFiles(opts) {
218
226
  (0, import_node_fs.writeFileSync)(vbsPath, vbsContent, { encoding: "utf8" });
219
227
  written.push(vbsPath);
220
228
  if (paths.shortcut) {
221
- const lnkPath = paths.shortcut;
229
+ const guessedLnkPath = paths.shortcut;
222
230
  const iconPath = opts.iconPath ?? "";
223
- const safeLnkPath = lnkPath.replace(/'/g, "''");
231
+ const safeLnkFileName = LAUNCHER_LNK_FILENAME.replace(/'/g, "''");
224
232
  const safeIconPath = iconPath.replace(/'/g, "''");
225
233
  const wscriptPath = (0, import_node_path2.join)(process.env["SystemRoot"] ?? "C:\\Windows", "System32", "wscript.exe");
226
234
  const safeWscript = wscriptPath.replace(/'/g, "''");
227
235
  const safeVbsPath = vbsPath.replace(/'/g, "''");
228
- const psScript = `$ws = New-Object -ComObject WScript.Shell;$s = $ws.CreateShortcut('${safeLnkPath}');$s.TargetPath = '${safeWscript}';$s.Arguments = '"${safeVbsPath}"';$s.Description = 'Start the Stikfix HTTP backend host';` + (safeIconPath ? `$s.IconLocation = '${safeIconPath},0';` : "") + `$s.Save()`;
236
+ const psScript = `$desktop = [Environment]::GetFolderPath('Desktop');$lnkPath = Join-Path $desktop '${safeLnkFileName}';$ws = New-Object -ComObject WScript.Shell;$s = $ws.CreateShortcut($lnkPath);$s.TargetPath = '${safeWscript}';$s.Arguments = '"${safeVbsPath}"';$s.Description = 'Start the Stikfix HTTP backend host';` + (safeIconPath ? `$s.IconLocation = '${safeIconPath},0';` : "") + `$s.Save();Write-Output $lnkPath`;
229
237
  try {
230
- (0, import_node_child_process.execFileSync)(
238
+ const stdout = (0, import_node_child_process.execFileSync)(
231
239
  "powershell.exe",
232
240
  ["-NoProfile", "-NonInteractive", "-Command", psScript],
233
- { timeout: 15e3, stdio: "ignore" }
241
+ { timeout: 15e3, encoding: "utf8" }
234
242
  );
235
- written.push(lnkPath);
243
+ const resolvedLnkPath = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).pop() ?? guessedLnkPath;
244
+ written.push(resolvedLnkPath);
236
245
  } catch (err) {
237
246
  warnings.push(
238
247
  `Desktop shortcut creation failed (non-fatal): ${String(err.message)}. The batch file at ${batchPath} can be used directly.`
@@ -245,7 +254,7 @@ function createLauncherFiles(opts) {
245
254
  `# Stikfix HTTP host launcher \u2014 double-click in Finder or drag to Dock`,
246
255
  `# Generated by: npx stikfix init`,
247
256
  ``,
248
- `exec "${nodeCmd}" "${hostEntry}" --root "${rootArg}"${portArg}`
257
+ useExe ? `exec "${hostExe}" serve --root "${rootArg}"${portArg}` : `exec "${nodeCmd}" "${hostEntry}" --root "${rootArg}"${portArg}`
249
258
  ].join("\n");
250
259
  const commandPath = paths.launcher;
251
260
  (0, import_node_fs.writeFileSync)(commandPath, commandContent, { encoding: "utf8", mode: 493 });
@@ -256,7 +265,7 @@ function createLauncherFiles(opts) {
256
265
  `# Stikfix HTTP host launcher`,
257
266
  `# Generated by: npx stikfix init`,
258
267
  ``,
259
- `exec "${nodeCmd}" "${hostEntry}" --root "${rootArg}"${portArg}`
268
+ useExe ? `exec "${hostExe}" serve --root "${rootArg}"${portArg}` : `exec "${nodeCmd}" "${hostEntry}" --root "${rootArg}"${portArg}`
260
269
  ].join("\n");
261
270
  const shPath = paths.launcher;
262
271
  (0, import_node_fs.writeFileSync)(shPath, shContent, { encoding: "utf8" });
@@ -321,8 +330,8 @@ function registerNativeHost(opts) {
321
330
  const home = opts.home ?? (0, import_node_os.homedir)();
322
331
  const browser = opts.browser ?? "chrome";
323
332
  const manifestPath = nativeManifestPath(plat, home, browser);
324
- const wrapperPath = writeNativeWrapper(opts.hostBinPath, plat, home, browser);
325
- const manifest = buildManifest(opts.extensionId, wrapperPath, browser);
333
+ const manifestTarget = opts.directPath ? (0, import_node_path2.resolve)(opts.hostBinPath) : writeNativeWrapper(opts.hostBinPath, plat, home, browser);
334
+ const manifest = buildManifest(opts.extensionId, manifestTarget, browser);
326
335
  writeManifest(manifest, manifestPath);
327
336
  if (plat === "win32") {
328
337
  const execReg = opts.execReg ?? ((args) => {
@@ -373,6 +382,34 @@ function unregisterNativeHost(opts = {}) {
373
382
  }
374
383
  }
375
384
  }
385
+ function teardownHost(opts = {}) {
386
+ const plat = opts.plat ?? process.platform;
387
+ const home = opts.home ?? (0, import_node_os.homedir)();
388
+ const browser = opts.browser ?? "chrome";
389
+ const result = {
390
+ manifestRemoved: true,
391
+ configPath: CONFIG_PATH(home),
392
+ configRemoved: false
393
+ };
394
+ try {
395
+ unregisterNativeHost({ plat, home, browser });
396
+ } catch (err) {
397
+ result.manifestRemoved = false;
398
+ result.manifestError = err instanceof Error ? err.message : String(err);
399
+ }
400
+ try {
401
+ unregisterStartup({ plat, home });
402
+ } catch (err) {
403
+ result.startupError = err instanceof Error ? err.message : String(err);
404
+ }
405
+ try {
406
+ (0, import_node_fs.rmSync)(result.configPath, { force: true });
407
+ result.configRemoved = true;
408
+ } catch {
409
+ result.configRemoved = false;
410
+ }
411
+ return result;
412
+ }
376
413
 
377
414
  // host/src/extension-id.ts
378
415
  var import_node_crypto2 = require("node:crypto");
@@ -430,8 +467,8 @@ function resolveStartupChoice(forceOn, forceOff, isTTY) {
430
467
  }
431
468
  return false;
432
469
  }
433
- var CONFIG_DIR = (0, import_node_path3.join)((0, import_node_os2.homedir)(), ".config", "stikfix");
434
- var CONFIG_PATH = (0, import_node_path3.join)(CONFIG_DIR, "config.json");
470
+ var CONFIG_DIR2 = (0, import_node_path3.join)((0, import_node_os2.homedir)(), ".config", "stikfix");
471
+ var CONFIG_PATH2 = (0, import_node_path3.join)(CONFIG_DIR2, "config.json");
435
472
  if (subcommand === "init") {
436
473
  const rawRoot = values["root"];
437
474
  const rawExtId = values["extension-id"];
@@ -449,9 +486,9 @@ if (subcommand === "init") {
449
486
  const name = (0, import_node_path3.basename)(root);
450
487
  const notesDir = (0, import_node_path3.join)(root, "notes");
451
488
  const hostEntryPath = (0, import_node_path3.resolve)((0, import_node_path3.join)(__dirname, "src", "index.js"));
452
- (0, import_node_fs2.mkdirSync)(CONFIG_DIR, { recursive: true });
489
+ (0, import_node_fs2.mkdirSync)(CONFIG_DIR2, { recursive: true });
453
490
  const config = { root, name, notesDir, hostEntry: hostEntryPath, nodePath: process.execPath };
454
- (0, import_node_fs2.writeFileSync)(CONFIG_PATH, JSON.stringify(config, null, 2), { encoding: "utf8", mode: 384 });
491
+ (0, import_node_fs2.writeFileSync)(CONFIG_PATH2, JSON.stringify(config, null, 2), { encoding: "utf8", mode: 384 });
455
492
  const hostBinPath = (0, import_node_path3.resolve)((0, import_node_path3.join)(__dirname, "stikfix-native.cjs"));
456
493
  try {
457
494
  registerNativeHost({ extensionId, hostBinPath, browser });
@@ -540,9 +577,9 @@ if (subcommand === "init") {
540
577
  console.log("");
541
578
  console.log(" 2. Start the backend \u2014 double-click the desktop launcher:");
542
579
  if (process.platform === "win32") {
543
- const lnkPath = (0, import_node_path3.join)((0, import_node_os2.homedir)(), "Desktop", "Stikfix Host.lnk");
580
+ const lnkPath = launcherResult.written.find((p) => p.endsWith(".lnk")) ?? "";
544
581
  const batchPath = launcherResult.written.find((p) => p.endsWith(".bat")) ?? "";
545
- if ((0, import_node_fs2.existsSync)(lnkPath)) {
582
+ if (lnkPath && (0, import_node_fs2.existsSync)(lnkPath)) {
546
583
  console.log(' Desktop shortcut: "Stikfix Host" (icon on your Desktop)');
547
584
  } else if (batchPath) {
548
585
  console.log(" Batch file: " + batchPath);
@@ -567,17 +604,13 @@ if (subcommand === "init") {
567
604
  console.log(" npx --yes stikfix@latest init --root " + root);
568
605
  } else if (subcommand === "uninstall") {
569
606
  const browser = resolveBrowser(values["browser"]);
570
- try {
571
- unregisterNativeHost({ browser });
572
- } catch (err) {
573
- console.error("stikfix uninstall: error removing native-host manifest:", String(err));
607
+ const teardown = teardownHost({ browser });
608
+ if (teardown.manifestError) {
609
+ console.error("stikfix uninstall: error removing native-host manifest:", teardown.manifestError);
574
610
  }
575
- try {
576
- unregisterStartup();
577
- } catch (err) {
578
- console.error("stikfix uninstall: error removing startup entry:", String(err));
611
+ if (teardown.startupError) {
612
+ console.error("stikfix uninstall: error removing startup entry:", teardown.startupError);
579
613
  }
580
- (0, import_node_fs2.rmSync)(CONFIG_PATH, { force: true });
581
614
  console.log("stikfix: native host unregistered.");
582
615
  console.log(" manifest removed");
583
616
  console.log(" launcher files removed");