stikfix 1.5.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,254 @@
1
+ /**
2
+ * stikfix-host.exe — single-executable dispatcher (Node SEA entry).
3
+ *
4
+ * One binary multiplexes every host role by subcommand, plus a native-messaging
5
+ * auto-detect so the SAME exe can be registered directly as the Chrome/Edge
6
+ * native-messaging host (no node + .cjs wrapper needed):
7
+ *
8
+ * stikfix-host serve [--root <dir> ...] → HTTP host (index.ts startHttpHost)
9
+ * stikfix-host native → native-messaging stdio host
10
+ * stikfix-host register [flags] → non-interactive installer wiring
11
+ * stikfix-host uninstall [flags] → remove native host + launchers + config
12
+ * stikfix-host doctor → diagnostics (stub for now)
13
+ * stikfix-host --version|-v → print version
14
+ * stikfix-host --help|-h|(no args) → usage
15
+ *
16
+ * NATIVE AUTO-DETECT: a Chromium browser launches its native host as
17
+ * stikfix-host.exe "chrome-extension://<id>/" --parent-window=<hwnd>
18
+ * so if the first positional looks like a chrome-extension:// origin, or any arg
19
+ * starts with --parent-window, we route to the native handler — never treating
20
+ * the origin as a subcommand.
21
+ *
22
+ * Node builtins + bundled deps only — no WXT, no Chrome imports.
23
+ */
24
+ import { mkdirSync, writeFileSync } from 'node:fs';
25
+ import { resolve, join, basename } from 'node:path';
26
+ import { homedir } from 'node:os';
27
+ import { parseArgs } from 'node:util';
28
+ import { VERSION } from './config.js';
29
+ import { startHttpHost } from './index.js';
30
+ import { runNativeHost } from './native-host.js';
31
+ import { runDoctor } from './doctor.js';
32
+ import { registerNativeHost, createLauncherFiles, registerStartup, unregisterStartup, teardownHost } from './bootstrap/register.js';
33
+ import { STABLE_EXTENSION_ID } from './extension-id.js';
34
+ // ---------------------------------------------------------------------------
35
+ // Argument extraction — robust across `node script.js` and a Node SEA exe
36
+ // ---------------------------------------------------------------------------
37
+ /**
38
+ * User args, independent of how the process was started. On every path that
39
+ * runs this dispatcher — `node …/exe-main.js`, `node …/bundle.cjs`, and the
40
+ * Node SEA exe (which mirrors execPath into argv[1]) — user args begin at
41
+ * argv[2]. Verified against the built stikfix-host.exe.
42
+ */
43
+ function getUserArgs() {
44
+ return process.argv.slice(2);
45
+ }
46
+ // ---------------------------------------------------------------------------
47
+ // register subcommand
48
+ // ---------------------------------------------------------------------------
49
+ const CONFIG_DIR = join(homedir(), '.config', 'stikfix');
50
+ const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
51
+ /** Default notes root for the installer when --root is omitted. */
52
+ function defaultRoot() {
53
+ if (process.platform === 'win32') {
54
+ return join(homedir(), 'Documents', 'stikfix-notes');
55
+ }
56
+ return join(homedir(), 'stikfix-notes');
57
+ }
58
+ async function runRegister(args) {
59
+ const { values } = parseArgs({
60
+ args,
61
+ options: {
62
+ root: { type: 'string' },
63
+ 'extension-id': { type: 'string' },
64
+ port: { type: 'string' },
65
+ startup: { type: 'boolean' },
66
+ 'no-startup': { type: 'boolean' },
67
+ 'host-exe': { type: 'string' },
68
+ },
69
+ strict: false,
70
+ });
71
+ const hostExe = resolve(typeof values['host-exe'] === 'string' && values['host-exe'] ? values['host-exe'] : process.execPath);
72
+ const root = resolve(typeof values['root'] === 'string' && values['root'] ? values['root'] : defaultRoot());
73
+ const extensionId = typeof values['extension-id'] === 'string' && values['extension-id']
74
+ ? values['extension-id']
75
+ : STABLE_EXTENSION_ID;
76
+ const port = typeof values['port'] === 'string' && values['port'] ? parseInt(values['port'], 10) : undefined;
77
+ const name = basename(root);
78
+ const notesDir = join(root, 'notes');
79
+ // Ensure the root exists so START_HOST's directory check passes later.
80
+ mkdirSync(root, { recursive: true });
81
+ // (a) config.json — hostExe drives START_HOST/launchers to run the exe `serve`.
82
+ mkdirSync(CONFIG_DIR, { recursive: true });
83
+ const config = {
84
+ root,
85
+ name,
86
+ notesDir,
87
+ hostEntry: hostExe,
88
+ nodePath: hostExe,
89
+ hostExe,
90
+ };
91
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { encoding: 'utf8', mode: 0o600 });
92
+ // (b) native-messaging manifest points DIRECTLY at the exe (self-detects native mode).
93
+ registerNativeHost({ extensionId, hostBinPath: hostExe, directPath: true });
94
+ // (c) desktop launcher runs `<exe> serve --root <root>`.
95
+ const launcherResult = createLauncherFiles({
96
+ hostEntryPath: hostExe,
97
+ root,
98
+ port,
99
+ hostExe,
100
+ });
101
+ // (d) Windows login autoload per flags (Windows-only; no-op elsewhere).
102
+ let startupRegistered = false;
103
+ const wantStartup = values['startup'] === true && values['no-startup'] !== true;
104
+ if (process.platform === 'win32') {
105
+ if (wantStartup) {
106
+ try {
107
+ registerStartup();
108
+ startupRegistered = true;
109
+ }
110
+ catch {
111
+ startupRegistered = false;
112
+ }
113
+ }
114
+ else {
115
+ try {
116
+ unregisterStartup();
117
+ }
118
+ catch {
119
+ // idempotent
120
+ }
121
+ }
122
+ }
123
+ console.log(JSON.stringify({
124
+ ok: true,
125
+ action: 'register',
126
+ hostExe,
127
+ root,
128
+ notesDir,
129
+ extensionId,
130
+ port: port ?? null,
131
+ configPath: CONFIG_PATH,
132
+ launchersWritten: launcherResult.written,
133
+ launcherWarnings: launcherResult.warnings,
134
+ startupRegistered,
135
+ }));
136
+ }
137
+ // ---------------------------------------------------------------------------
138
+ // uninstall subcommand
139
+ // ---------------------------------------------------------------------------
140
+ /**
141
+ * Mirror bin/stikfix.ts's `uninstall` teardown exactly, via the SAME shared
142
+ * `teardownHost` helper (host/src/bootstrap/register.ts) — so the exe's
143
+ * uninstall route is equivalent: native-messaging manifest + HKCU registry
144
+ * keys removed, unregisterStartup() called, launcher files/config removed.
145
+ * Idempotent — a missing key/file is fine (teardownHost tolerates it).
146
+ *
147
+ * --extension-id / --root are accepted (teardown doesn't need either to
148
+ * locate the manifest/registry keys — those are derived from home + browser)
149
+ * and echoed back in the JSON result for operator visibility.
150
+ */
151
+ async function runUninstall(args) {
152
+ const { values } = parseArgs({
153
+ args,
154
+ options: {
155
+ 'extension-id': { type: 'string' },
156
+ root: { type: 'string' },
157
+ },
158
+ strict: false,
159
+ });
160
+ const extensionId = typeof values['extension-id'] === 'string' && values['extension-id']
161
+ ? values['extension-id']
162
+ : null;
163
+ const root = typeof values['root'] === 'string' && values['root']
164
+ ? resolve(values['root'])
165
+ : null;
166
+ const result = teardownHost({});
167
+ console.log(JSON.stringify({
168
+ ok: true,
169
+ action: 'uninstall',
170
+ extensionId,
171
+ root,
172
+ manifestRemoved: result.manifestRemoved,
173
+ manifestError: result.manifestError ?? null,
174
+ startupError: result.startupError ?? null,
175
+ configPath: result.configPath,
176
+ configRemoved: result.configRemoved,
177
+ }));
178
+ }
179
+ // ---------------------------------------------------------------------------
180
+ // usage
181
+ // ---------------------------------------------------------------------------
182
+ function printUsage() {
183
+ console.log([
184
+ 'stikfix-host ' + VERSION,
185
+ '',
186
+ 'Usage: stikfix-host <command> [options]',
187
+ '',
188
+ 'Commands:',
189
+ ' serve [--root <dir>] [--port <n>] [--git-sync] Start the HTTP host',
190
+ ' native Native-messaging stdio host',
191
+ ' register [--root <dir>] [--extension-id <id>] Register the native host + launchers',
192
+ ' [--port <n>] [--startup|--no-startup]',
193
+ ' [--host-exe <path>]',
194
+ ' uninstall [--extension-id <id>] [--root <dir>] Remove the native host + launchers + config',
195
+ ' doctor Print environment diagnostics',
196
+ '',
197
+ 'Options:',
198
+ ' -v, --version Print version and exit',
199
+ ' -h, --help Print this help and exit',
200
+ '',
201
+ 'Note: when launched by a Chromium browser as a native-messaging host',
202
+ ' (first arg chrome-extension://…, or --parent-window=…), the exe',
203
+ ' auto-detects native mode regardless of subcommand.',
204
+ ].join('\n'));
205
+ }
206
+ // ---------------------------------------------------------------------------
207
+ // dispatch
208
+ // ---------------------------------------------------------------------------
209
+ async function main() {
210
+ const args = getUserArgs();
211
+ // NATIVE-MODE AUTO-DETECT (must be first): a Chromium browser invokes
212
+ // stikfix-host.exe "chrome-extension://<id>/" --parent-window=<hwnd>
213
+ const first = args[0] ?? '';
214
+ const looksNative = /^chrome-extension:\/\//i.test(first) || args.some((a) => a.startsWith('--parent-window'));
215
+ if (looksNative) {
216
+ await runNativeHost();
217
+ return;
218
+ }
219
+ const cmd = args[0];
220
+ switch (cmd) {
221
+ case 'serve':
222
+ await startHttpHost(args.slice(1));
223
+ return;
224
+ case 'native':
225
+ await runNativeHost();
226
+ return;
227
+ case 'register':
228
+ await runRegister(args.slice(1));
229
+ return;
230
+ case 'uninstall':
231
+ await runUninstall(args.slice(1));
232
+ return;
233
+ case 'doctor':
234
+ await runDoctor(args.slice(1));
235
+ return;
236
+ case '--version':
237
+ case '-v':
238
+ console.log(VERSION);
239
+ return;
240
+ case '--help':
241
+ case '-h':
242
+ case undefined:
243
+ printUsage();
244
+ return;
245
+ default:
246
+ console.error(`stikfix-host: unknown command "${cmd}"`);
247
+ printUsage();
248
+ process.exit(1);
249
+ }
250
+ }
251
+ main().catch((err) => {
252
+ console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
253
+ process.exit(1);
254
+ });
@@ -12,136 +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
- 'git-sync': { type: 'boolean' },
32
- },
33
- strict: false,
34
- });
35
- // Apply three-tier env resolution (flag > STIKFIX_* > npm_config_*) so that
36
- // `npm run host -- --root <dir>` works in Windows PowerShell, where npm 11.x
37
- // intercepts unknown flags and re-exposes them as npm_config_<key> env vars.
38
- const values = resolveConfigValues(rawValues);
39
- if (!values['root']) {
40
- console.error('stikfix-host: --root is required');
41
- process.exit(1);
42
- }
43
- // ---------------------------------------------------------------------------
44
- // Config resolution + filesystem setup
45
- // ---------------------------------------------------------------------------
46
- const cfg = resolveConfig(values);
47
- ensureNotesDir(cfg.notesDir);
48
- // ---------------------------------------------------------------------------
49
- // FIX-SI: Single-instance guard — BEFORE any file mutation
50
- // ---------------------------------------------------------------------------
51
- const portFilePath = join(cfg.root, '.stikfix-port');
52
- if (existsSync(portFilePath)) {
53
- const portStr = readFileSync(portFilePath, 'utf8').trim();
54
- const existingPort = Number(portStr);
55
- if (Number.isInteger(existingPort) && existingPort > 0 && existingPort <= 65535) {
56
- const live = await probeExistingHost(cfg.root, existingPort);
57
- if (live !== null) {
58
- console.log(`stikfix-host: already running for ${cfg.root} on port ${live.port} — not starting a second instance.`);
59
- 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
60
73
  }
61
- // else: stale port file — continue with normal startup
62
74
  }
63
- }
64
- // ---------------------------------------------------------------------------
65
- // FIX-TP: Token persistence — reuse existing token if no explicit token given
66
- // ---------------------------------------------------------------------------
67
- // An "explicit" token is one supplied via --token / STIKFIX_TOKEN / npm_config_token.
68
- // resolveConfig already resolved these; if none was supplied, randomUUID() was called.
69
- // We detect the "no explicit token" case by checking the raw resolved values BEFORE
70
- // randomUUID was applied.
71
- const rawToken = values['token'] ??
72
- (process.env['STIKFIX_TOKEN']) ??
73
- (process.env['npm_config_token']);
74
- const tokenFilePath = join(cfg.root, '.stikfix-token');
75
- let finalToken = cfg.token;
76
- if (!rawToken) {
77
- // No explicit token provided — try to reuse existing token file
78
- if (existsSync(tokenFilePath)) {
79
- const existing = readFileSync(tokenFilePath, 'utf8').trim();
80
- if (existing.length > 0) {
81
- 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();
82
144
  }
83
- // 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;
84
150
  }
85
- // 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)
86
159
  }
87
- // Write the final token (idempotent — fixes perms to 0o600 via writeTokenFile)
88
- writeTokenFile(cfg.root, finalToken);
89
- // ---------------------------------------------------------------------------
90
- // Port discovery — bind-or-fail loop on 127.0.0.1 (Pattern 1 / D-08)
91
- // ---------------------------------------------------------------------------
92
- const server = createHostServer({ ...cfg, token: finalToken });
93
- const boundPort = await bindServer(server, cfg.port);
94
- // Safety assertion: binding must be 127.0.0.1 (T-02-bind)
95
- const addr = server.address();
96
- if (addr.address !== BIND_HOST) {
97
- 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
+ }
98
167
  }
99
- // Publish bound port to disk so the native host can read it without a port scan
100
- // (RESEARCH Open Question 2 / A5). Mode 0o600 alongside existing token file.
101
- writeFileSync(join(cfg.root, '.stikfix-port'), String(boundPort), { encoding: 'utf8', mode: 0o600 });
102
- // Startup JSON line — read by smoke test via readline (Pattern 12)
103
- console.log(JSON.stringify({
104
- app: 'stikfix',
105
- name: cfg.name,
106
- root: cfg.root,
107
- port: boundPort,
108
- token: finalToken,
109
- notesDir: cfg.notesDir,
110
- origins: cfg.origins,
111
- }));
112
- // ---------------------------------------------------------------------------
113
- // Windows system-tray indicator (best-effort, cosmetic, win32-only)
114
- // ---------------------------------------------------------------------------
115
- // Only reached on genuine startup — the single-instance guard above exits(0)
116
- // before here, so a second instance never spawns a duplicate tray.
117
- const tray = startTray({
118
- port: boundPort,
119
- root: cfg.root,
120
- name: cfg.name,
121
- notesDir: cfg.notesDir,
122
- hostPid: process.pid,
123
- });
124
- // Kill the tray child when the host shuts down so a dead host has no tray.
125
- // (No prior signal handling existed here; these are additive.)
126
- let shuttingDown = false;
127
- function shutdown(signal) {
128
- if (shuttingDown)
129
- return;
130
- 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;
131
173
  try {
132
- tray?.kill();
174
+ return import.meta.url === pathToFileURL(process.argv[1] ?? '').href;
175
+ }
176
+ catch {
177
+ return false;
133
178
  }
134
- catch { /* best-effort */ }
135
- server.close(() => process.exit(0));
136
- // Fallback: exit even if server.close hangs on lingering connections.
137
- setTimeout(() => process.exit(0), 1000).unref();
138
- void signal;
139
179
  }
140
- process.on('SIGINT', () => shutdown('SIGINT'));
141
- process.on('SIGTERM', () => shutdown('SIGTERM'));
142
- // Belt-and-suspenders: also reap the tray on plain process exit.
143
- process.on('exit', () => { try {
144
- 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
+ });
145
185
  }
146
- catch { /* best-effort */ } });
147
- // Server runs indefinitely (Pitfall 2 — no process.exit here)