stikfix 1.5.0 → 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.
- package/README.md +27 -7
- package/dist/host/src/bootstrap/register.js +108 -17
- package/dist/host/src/config.js +23 -7
- package/dist/host/src/doctor.js +459 -0
- package/dist/host/src/exe-main.js +254 -0
- package/dist/host/src/index.js +156 -118
- package/dist/host/src/native-host.js +34 -7
- package/dist/host/stikfix-init.cjs +61 -28
- package/dist/host/stikfix-native.cjs +17 -2
- package/dist/lib/pin-position.js +69 -0
- package/package.json +8 -1
|
@@ -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
|
+
});
|
package/dist/host/src/index.js
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
process.
|
|
144
|
-
|
|
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)
|
|
@@ -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(
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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();
|