synabun 2.0.0-beta → 2.0.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/CHANGELOG.md +8 -0
- package/THIRD-PARTY-LICENSES.md +5 -0
- package/launcher.mjs +40 -0
- package/lib/claude-hooks.js +61 -8
- package/lib/client-config-repair.js +4 -3
- package/lib/data-home-migration.js +4 -0
- package/lib/desktop-pwa.js +206 -0
- package/lib/external-tools.js +221 -0
- package/lib/first-launch-protection.js +5 -0
- package/lib/packaged-runtime.js +110 -0
- package/lib/start-launcher.js +1309 -0
- package/neural-interface/i18n/en.json +41 -2
- package/neural-interface/i18n/pt-BR.json +41 -2
- package/neural-interface/lib/assistant-envelope.js +9 -6
- package/neural-interface/lib/augmented-path.js +23 -3
- package/neural-interface/lib/browser-language.js +83 -0
- package/neural-interface/lib/claude-agent-bridge.js +54 -147
- package/neural-interface/lib/claude-executable.js +241 -0
- package/neural-interface/lib/claude-model-catalog.js +52 -91
- package/neural-interface/lib/native-binary-runtime.js +69 -337
- package/neural-interface/lib/native-loop-providers.js +30 -44
- package/neural-interface/lib/offline-page.js +54 -0
- package/neural-interface/lib/session-title-generator.js +5 -0
- package/neural-interface/lib/synabun-update-plan.js +19 -0
- package/neural-interface/package-lock.json +0 -251
- package/neural-interface/public/install-app.html +31 -0
- package/neural-interface/public/manifest.json +2 -0
- package/neural-interface/public/offline.html +401 -66
- package/neural-interface/public/onboarding.html +252 -146
- package/neural-interface/public/shared/assistant/asst-mascot.js +12 -10
- package/neural-interface/public/shared/assistant/asst-rack-styles.js +0 -3
- package/neural-interface/public/shared/assistant/asst-render.js +58 -49
- package/neural-interface/public/shared/assistant/asst-styles.js +4 -2
- package/neural-interface/public/shared/command-runner-model.js +30 -0
- package/neural-interface/public/shared/html-shell.js +9 -5
- package/neural-interface/public/shared/pwa-install-page.js +34 -0
- package/neural-interface/public/shared/pwa-install.css +12 -0
- package/neural-interface/public/shared/pwa-install.js +28 -0
- package/neural-interface/public/shared/settings/settings-rules.js +160 -0
- package/neural-interface/public/shared/start-bridge.js +583 -0
- package/neural-interface/public/shared/styleguide/sg-colors.js +5 -2
- package/neural-interface/public/shared/styleguide/sg-import-export.js +4 -3
- package/neural-interface/public/shared/styleguide/sg-kit.js +4 -1
- package/neural-interface/public/shared/styleguide/sg-panel.js +9 -1
- package/neural-interface/public/shared/styleguide/sg-typography.js +1 -0
- package/neural-interface/public/shared/styles.css +801 -392
- package/neural-interface/public/shared/term-ready.js +307 -0
- package/neural-interface/public/shared/ui-claude-panel.js +3 -3
- package/neural-interface/public/shared/ui-command-runner.js +251 -468
- package/neural-interface/public/shared/ui-judgments.js +1 -1
- package/neural-interface/public/shared/ui-loading.js +221 -10
- package/neural-interface/public/shared/ui-menubar.js +11 -27
- package/neural-interface/public/shared/ui-settings.js +36 -59
- package/neural-interface/public/shared/ui-terminal.js +130 -144
- package/neural-interface/public/sw.js +3 -1
- package/neural-interface/public/variant/2d/main.js +2 -1
- package/neural-interface/public/variant/3d/main.js +2 -1
- package/neural-interface/scripts/rebuild-pty.js +15 -12
- package/neural-interface/server.js +220 -185
- package/package.json +13 -1
- package/postinstall.js +11 -81
- package/setup.js +72 -4
- package/docs/browser-v2.md +0 -109
- package/docs/hook-integrity.md +0 -89
- package/docs/judgments.md +0 -182
- package/docs/style-guide.md +0 -246
- package/scripts/audit-claude-hook-usage.mjs +0 -12
- package/scripts/audit-memory-duplicates.mjs +0 -44
- package/scripts/measure-claude-hook-overhead.mjs +0 -77
- package/scripts/recover-user-config.mjs +0 -254
|
@@ -0,0 +1,1309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynaBun — the Start Server bridge
|
|
3
|
+
*
|
|
4
|
+
* The offline page cannot ask a server that is down to start itself, so it
|
|
5
|
+
* hands the operating system a synabun://start link. This module owns both
|
|
6
|
+
* ends of that hand-off:
|
|
7
|
+
*
|
|
8
|
+
* registration — what each OS needs to route synabun:// to launcher.mjs
|
|
9
|
+
* (HKCU registry on Windows, ~/.synabun/SynaBun.app on macOS,
|
|
10
|
+
* a .desktop entry on Linux). Written by postinstall.js, by
|
|
11
|
+
* setup.js on every start and by the server at boot, so npm
|
|
12
|
+
* and GitHub installs, new or old, end up the same.
|
|
13
|
+
* launch — what launcher.mjs does when the OS runs it: nothing if the
|
|
14
|
+
* server already answers or a start is under way, otherwise
|
|
15
|
+
* the normal supervisor (setup.js), once.
|
|
16
|
+
*
|
|
17
|
+
* progress — a launch says what it has actually seen happen (itself
|
|
18
|
+
* running, the Terminal asked to open, the supervisor's own
|
|
19
|
+
* phases, the server answering) on a loopback beacon the
|
|
20
|
+
* offline page reads, because that page has no server to ask.
|
|
21
|
+
*
|
|
22
|
+
* Nothing here stays running: the OS starts the launcher per click and it
|
|
23
|
+
* exits once the server answers.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
|
27
|
+
import {
|
|
28
|
+
chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync,
|
|
29
|
+
} from 'node:fs';
|
|
30
|
+
import { createServer } from 'node:http';
|
|
31
|
+
import { createRequire } from 'node:module';
|
|
32
|
+
import { connect } from 'node:net';
|
|
33
|
+
import { homedir, tmpdir } from 'node:os';
|
|
34
|
+
import { basename, dirname, join, posix, win32 } from 'node:path';
|
|
35
|
+
import { randomBytes } from 'node:crypto';
|
|
36
|
+
import { getDataHome, getPlatformDataHome, pathIsInside } from './paths.js';
|
|
37
|
+
import { readPackagedRuntime } from './packaged-runtime.js';
|
|
38
|
+
|
|
39
|
+
export const START_PROTOCOL = 'synabun';
|
|
40
|
+
/** 1 was the handler that ran setup.js directly (no lock, no health check). */
|
|
41
|
+
export const LAUNCHER_REVISION = 2;
|
|
42
|
+
export const DEFAULT_PORT = 3344;
|
|
43
|
+
|
|
44
|
+
const BUNDLE_ID = 'ai.synabun.launcher';
|
|
45
|
+
const LSREGISTER = '/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister';
|
|
46
|
+
const WIN_KEY = 'HKCU\\Software\\Classes\\synabun';
|
|
47
|
+
|
|
48
|
+
/** A start that has not answered after this long no longer blocks a new one. */
|
|
49
|
+
export const LOCK_TTL_MS = 10 * 60 * 1000;
|
|
50
|
+
export const SUPERVISOR_TTL_MS = 15 * 60 * 1000;
|
|
51
|
+
const HEALTH_WAIT_MS = 5 * 60 * 1000;
|
|
52
|
+
/** How often a waiting launch looks: at the port, and at what the supervisor says it is doing. */
|
|
53
|
+
const WAIT_POLL_MS = 200;
|
|
54
|
+
|
|
55
|
+
// ═══════════════════════════════════════════
|
|
56
|
+
// Quoting
|
|
57
|
+
// ═══════════════════════════════════════════
|
|
58
|
+
|
|
59
|
+
/** One POSIX shell word. */
|
|
60
|
+
export function shQuote(value) {
|
|
61
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One argument of a Windows command line (CommandLineToArgvW rules). */
|
|
65
|
+
export function winQuote(value) {
|
|
66
|
+
let out = '"';
|
|
67
|
+
let slashes = 0;
|
|
68
|
+
for (const ch of String(value)) {
|
|
69
|
+
if (ch === '\\') { slashes++; continue; }
|
|
70
|
+
if (ch === '"') { out += '\\'.repeat(slashes * 2 + 1) + '"'; slashes = 0; continue; }
|
|
71
|
+
out += '\\'.repeat(slashes) + ch;
|
|
72
|
+
slashes = 0;
|
|
73
|
+
}
|
|
74
|
+
return `${out}${'\\'.repeat(slashes * 2)}"`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* One argument of a .desktop Exec line: quoted per the Exec rules (" ` $ \
|
|
79
|
+
* escaped), then escaped again as a desktop-entry string, with % doubled so
|
|
80
|
+
* it is not read as a field code.
|
|
81
|
+
*/
|
|
82
|
+
export function desktopExecArg(value) {
|
|
83
|
+
const quoted = `"${String(value).replace(/(["`$\\])/g, '\\$1')}"`;
|
|
84
|
+
return quoted.replace(/\\/g, '\\\\').replace(/%/g, '%%');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function xmlEscape(value) {
|
|
88
|
+
return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ═══════════════════════════════════════════
|
|
92
|
+
// Configuration the launcher must agree with the server on
|
|
93
|
+
// ═══════════════════════════════════════════
|
|
94
|
+
|
|
95
|
+
export function normalizePort(value) {
|
|
96
|
+
const n = Number(value);
|
|
97
|
+
return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readDotEnvValue(dataHome, key) {
|
|
101
|
+
try {
|
|
102
|
+
for (const line of readFileSync(join(dataHome, '.env'), 'utf8').split(/\r?\n/)) {
|
|
103
|
+
const trimmed = line.trim();
|
|
104
|
+
if (!trimmed.startsWith(`${key}=`)) continue;
|
|
105
|
+
return trimmed.slice(key.length + 1).trim().replace(/^(['"])(.*)\1$/, '$2');
|
|
106
|
+
}
|
|
107
|
+
} catch {}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The port the server listens on: the environment, then DATA_HOME/.env, then 3344 — as server.js reads it. */
|
|
112
|
+
export function resolvePort({ explicit = null, env = process.env, dataHome } = {}) {
|
|
113
|
+
return normalizePort(explicit)
|
|
114
|
+
|| normalizePort(env.NEURAL_PORT)
|
|
115
|
+
|| (dataHome ? normalizePort(readDotEnvValue(dataHome, 'NEURAL_PORT')) : null)
|
|
116
|
+
|| DEFAULT_PORT;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* What a registration has to pin because the OS starts the launcher with a
|
|
121
|
+
* bare environment: a data home that is not the platform default, and a port
|
|
122
|
+
* the launcher would not arrive at as 3344 by itself.
|
|
123
|
+
*/
|
|
124
|
+
export function launcherOverrides({ env = process.env, home = homedir(), platform = process.platform } = {}) {
|
|
125
|
+
const dataHome = getDataHome({ env, home, os: platform });
|
|
126
|
+
const standard = getPlatformDataHome({ env, home, os: platform });
|
|
127
|
+
// Read the port the way the launcher will, so setup.js (bare environment)
|
|
128
|
+
// and the server (DATA_HOME/.env loaded) write the same registration.
|
|
129
|
+
const port = resolvePort({ env, dataHome });
|
|
130
|
+
// Where a handler with no --port lands: DATA_HOME/.env, then 3344. A server
|
|
131
|
+
// run with NEURAL_PORT=3344 over a .env that names another port has to pin
|
|
132
|
+
// its 3344, or the next click starts (and waits for) the other one.
|
|
133
|
+
const unpinned = resolvePort({ env: {}, dataHome });
|
|
134
|
+
return {
|
|
135
|
+
dataHome: dataHome !== standard ? dataHome : null,
|
|
136
|
+
port: port !== DEFAULT_PORT || unpinned !== DEFAULT_PORT ? port : null,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ═══════════════════════════════════════════
|
|
141
|
+
// Registration
|
|
142
|
+
// ═══════════════════════════════════════════
|
|
143
|
+
|
|
144
|
+
export function launcherLocations({ platform = process.platform, home = homedir() } = {}) {
|
|
145
|
+
if (platform === 'win32') return { kind: 'registry', key: WIN_KEY };
|
|
146
|
+
if (platform === 'darwin') {
|
|
147
|
+
const app = posix.join(home, '.synabun', 'SynaBun.app');
|
|
148
|
+
return {
|
|
149
|
+
kind: 'app',
|
|
150
|
+
app,
|
|
151
|
+
plist: posix.join(app, 'Contents', 'Info.plist'),
|
|
152
|
+
executable: posix.join(app, 'Contents', 'MacOS', 'synabun-launcher'),
|
|
153
|
+
terminalScript: posix.join(app, 'Contents', 'Resources', 'SynaBun Server.command'),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return { kind: 'desktop', desktopFile: posix.join(home, '.local', 'share', 'applications', 'synabun.desktop') };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function launcherArgs({ dataHome, port, terminalScript }) {
|
|
160
|
+
const args = ['--via=protocol'];
|
|
161
|
+
if (dataHome) args.push('--data-home', dataHome);
|
|
162
|
+
if (port) args.push('--port', String(port));
|
|
163
|
+
if (terminalScript) args.push('--terminal-script', terminalScript);
|
|
164
|
+
return args;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Everything a registration consists of, as data: files to write, registry
|
|
169
|
+
* values to set and the commands that tell the OS about them. No side effects.
|
|
170
|
+
*/
|
|
171
|
+
export function buildLauncherPlan({
|
|
172
|
+
platform = process.platform,
|
|
173
|
+
nodePath = process.execPath,
|
|
174
|
+
packageRoot,
|
|
175
|
+
home = homedir(),
|
|
176
|
+
dataHome = null,
|
|
177
|
+
port = null,
|
|
178
|
+
entry = null,
|
|
179
|
+
} = {}) {
|
|
180
|
+
if (!packageRoot) throw new Error('packageRoot is required');
|
|
181
|
+
const where = launcherLocations({ platform, home });
|
|
182
|
+
const plan = { platform, revision: LAUNCHER_REVISION, where, files: [], registry: [], refresh: [] };
|
|
183
|
+
// A packaged application is reached through its entry executable, which
|
|
184
|
+
// brings its own Node: `<entry> launcher ...` and `<entry> start`.
|
|
185
|
+
const handler = (args, launcherPath) => (entry ? [entry, 'launcher', ...args] : [nodePath, launcherPath, ...args]);
|
|
186
|
+
|
|
187
|
+
if (platform === 'win32') {
|
|
188
|
+
const launcher = win32.join(packageRoot, 'launcher.mjs');
|
|
189
|
+
const command = [...handler(launcherArgs({ dataHome, port }), launcher), '--'].map(winQuote).join(' ') + ' "%1"';
|
|
190
|
+
plan.registry.push(
|
|
191
|
+
{ key: WIN_KEY, name: null, value: 'URL:SynaBun Protocol' },
|
|
192
|
+
{ key: WIN_KEY, name: 'URL Protocol', value: '' },
|
|
193
|
+
{ key: `${WIN_KEY}\\shell\\open\\command`, name: null, value: command },
|
|
194
|
+
);
|
|
195
|
+
return plan;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const launcher = posix.join(packageRoot, 'launcher.mjs');
|
|
199
|
+
const setup = posix.join(packageRoot, 'setup.js');
|
|
200
|
+
|
|
201
|
+
if (platform === 'darwin') {
|
|
202
|
+
const args = launcherArgs({ dataHome, port, terminalScript: where.terminalScript });
|
|
203
|
+
plan.files.push({
|
|
204
|
+
path: where.plist,
|
|
205
|
+
mode: 0o644,
|
|
206
|
+
content: [
|
|
207
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
208
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
209
|
+
'<plist version="1.0">',
|
|
210
|
+
'<dict>',
|
|
211
|
+
` <key>CFBundleIdentifier</key><string>${BUNDLE_ID}</string>`,
|
|
212
|
+
' <key>CFBundleName</key><string>SynaBun</string>',
|
|
213
|
+
' <key>CFBundleExecutable</key><string>synabun-launcher</string>',
|
|
214
|
+
' <key>CFBundlePackageType</key><string>APPL</string>',
|
|
215
|
+
` <key>CFBundleVersion</key><string>${LAUNCHER_REVISION}.0</string>`,
|
|
216
|
+
// No Dock icon: the launcher is gone again within seconds.
|
|
217
|
+
' <key>LSUIElement</key><true/>',
|
|
218
|
+
' <key>CFBundleURLTypes</key>',
|
|
219
|
+
' <array><dict>',
|
|
220
|
+
' <key>CFBundleURLName</key><string>SynaBun Protocol</string>',
|
|
221
|
+
` <key>CFBundleURLSchemes</key><array><string>${xmlEscape(START_PROTOCOL)}</string></array>`,
|
|
222
|
+
' </dict></array>',
|
|
223
|
+
'</dict>',
|
|
224
|
+
'</plist>',
|
|
225
|
+
'',
|
|
226
|
+
].join('\n'),
|
|
227
|
+
}, {
|
|
228
|
+
// macOS delivers the link as an Apple Event, which a script never sees:
|
|
229
|
+
// being launched at all is the request to start.
|
|
230
|
+
path: where.executable,
|
|
231
|
+
mode: 0o755,
|
|
232
|
+
content: [
|
|
233
|
+
'#!/bin/sh',
|
|
234
|
+
`# SynaBun start launcher (revision ${LAUNCHER_REVISION}). macOS runs this for ${START_PROTOCOL}:// links.`,
|
|
235
|
+
'# Generated by SynaBun and rewritten when the install moves. Do not edit.',
|
|
236
|
+
...(entry ? [
|
|
237
|
+
`ENTRY=${shQuote(entry)}`,
|
|
238
|
+
'[ -x "$ENTRY" ] || exit 127',
|
|
239
|
+
`exec "$ENTRY" ${['launcher', ...args].map(shQuote).join(' ')} >/dev/null 2>&1`,
|
|
240
|
+
] : [
|
|
241
|
+
`NODE=${shQuote(nodePath)}`,
|
|
242
|
+
'if [ ! -x "$NODE" ]; then',
|
|
243
|
+
' NODE="$(PATH="$PATH:/opt/homebrew/bin:/usr/local/bin" command -v node 2>/dev/null)"',
|
|
244
|
+
'fi',
|
|
245
|
+
'[ -n "$NODE" ] || exit 127',
|
|
246
|
+
`exec "$NODE" ${[launcher, ...args].map(shQuote).join(' ')} >/dev/null 2>&1`,
|
|
247
|
+
]),
|
|
248
|
+
'',
|
|
249
|
+
].join('\n'),
|
|
250
|
+
}, {
|
|
251
|
+
// Terminal runs this in the user's login shell: the same environment and
|
|
252
|
+
// the same privacy grants as a server they started by hand.
|
|
253
|
+
path: where.terminalScript,
|
|
254
|
+
mode: 0o755,
|
|
255
|
+
content: [
|
|
256
|
+
'#!/bin/sh',
|
|
257
|
+
'# Runs the SynaBun server in this window (opened by the Start Server button).',
|
|
258
|
+
'# Generated by SynaBun and rewritten when the install moves. Do not edit.',
|
|
259
|
+
`printf '\\033]0;SynaBun Server\\007'`,
|
|
260
|
+
...(entry ? [] : [
|
|
261
|
+
`NODE=${shQuote(nodePath)}`,
|
|
262
|
+
'[ -x "$NODE" ] || NODE=node',
|
|
263
|
+
]),
|
|
264
|
+
...(dataHome ? [`export SYNABUN_DATA_HOME=${shQuote(dataHome)}`] : []),
|
|
265
|
+
...(port ? [`export NEURAL_PORT=${shQuote(String(port))}`] : []),
|
|
266
|
+
'export SYNABUN_OPEN_BROWSER=0',
|
|
267
|
+
`cd ${shQuote(packageRoot)} || exit 1`,
|
|
268
|
+
entry
|
|
269
|
+
? `exec ${shQuote(entry)} start`
|
|
270
|
+
: `exec "$NODE" --disable-warning=ExperimentalWarning ${shQuote(setup)}`,
|
|
271
|
+
'',
|
|
272
|
+
].join('\n'),
|
|
273
|
+
});
|
|
274
|
+
plan.refresh.push({ file: LSREGISTER, args: ['-f', where.app] });
|
|
275
|
+
return plan;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const exec = [...handler(launcherArgs({ dataHome, port }), launcher), '--'].map(desktopExecArg).join(' ');
|
|
279
|
+
plan.files.push({
|
|
280
|
+
path: where.desktopFile,
|
|
281
|
+
mode: 0o644,
|
|
282
|
+
content: [
|
|
283
|
+
'[Desktop Entry]',
|
|
284
|
+
'Type=Application',
|
|
285
|
+
'Name=SynaBun',
|
|
286
|
+
`Comment=Starts the SynaBun server (${START_PROTOCOL}:// links)`,
|
|
287
|
+
`Exec=${exec} %u`,
|
|
288
|
+
`MimeType=x-scheme-handler/${START_PROTOCOL};`,
|
|
289
|
+
'NoDisplay=true',
|
|
290
|
+
// The launcher starts the server in the background and exits: no window to keep.
|
|
291
|
+
'Terminal=false',
|
|
292
|
+
`X-SynaBun-Launcher=${LAUNCHER_REVISION}`,
|
|
293
|
+
'',
|
|
294
|
+
].join('\n'),
|
|
295
|
+
});
|
|
296
|
+
plan.refresh.push(
|
|
297
|
+
{ file: 'xdg-mime', args: ['default', basename(where.desktopFile), `x-scheme-handler/${START_PROTOCOL}`] },
|
|
298
|
+
{ file: 'update-desktop-database', args: [dirname(where.desktopFile)] },
|
|
299
|
+
);
|
|
300
|
+
return plan;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function regExe(env = process.env) {
|
|
304
|
+
const root = env.SystemRoot || env.SYSTEMROOT || env.windir;
|
|
305
|
+
return root ? win32.join(root, 'System32', 'reg.exe') : 'reg';
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function defaultRun(file, args, { timeout = 15000 } = {}) {
|
|
309
|
+
return execFileSync(file, args, { encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The current value of one registry entry, or null when it is not there. */
|
|
313
|
+
function readRegistryValue(entry, { run = defaultRun, env = process.env } = {}) {
|
|
314
|
+
try {
|
|
315
|
+
const out = run(regExe(env), ['query', entry.key, ...(entry.name === null ? ['/ve'] : ['/v', entry.name])]);
|
|
316
|
+
for (const line of String(out).split(/\r?\n/)) {
|
|
317
|
+
const at = line.indexOf('REG_SZ');
|
|
318
|
+
if (at < 0) continue;
|
|
319
|
+
return line.slice(at + 'REG_SZ'.length).trim();
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
} catch {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function fileMatches(file) {
|
|
328
|
+
try {
|
|
329
|
+
if (readFileSync(file.path, 'utf8') !== file.content) return false;
|
|
330
|
+
// A launcher that lost its executable bit is as good as missing.
|
|
331
|
+
return !(file.mode & 0o111) || (statSync(file.path).mode & 0o111) !== 0;
|
|
332
|
+
} catch {
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** 'registered' | 'stale' | 'missing', without changing anything. */
|
|
338
|
+
export function inspectLauncher(plan, { run = defaultRun, env = process.env } = {}) {
|
|
339
|
+
let present = 0;
|
|
340
|
+
let current = 0;
|
|
341
|
+
for (const file of plan.files) {
|
|
342
|
+
if (existsSync(file.path)) present++;
|
|
343
|
+
if (fileMatches(file)) current++;
|
|
344
|
+
}
|
|
345
|
+
for (const entry of plan.registry) {
|
|
346
|
+
const value = readRegistryValue(entry, { run, env });
|
|
347
|
+
if (value !== null) present++;
|
|
348
|
+
if (value === entry.value) current++;
|
|
349
|
+
}
|
|
350
|
+
const total = plan.files.length + plan.registry.length;
|
|
351
|
+
if (current === total) return 'registered';
|
|
352
|
+
return present ? 'stale' : 'missing';
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Bring the OS in line with a plan. Writes only what differs, so calling it on
|
|
357
|
+
* every start costs a few file reads. Returns { ok, changed, state, errors }.
|
|
358
|
+
*/
|
|
359
|
+
export function applyLauncherPlan(plan, { run = defaultRun, env = process.env } = {}) {
|
|
360
|
+
const errors = [];
|
|
361
|
+
let changed = 0;
|
|
362
|
+
|
|
363
|
+
for (const file of plan.files) {
|
|
364
|
+
if (fileMatches(file)) continue;
|
|
365
|
+
try {
|
|
366
|
+
mkdirSync(dirname(file.path), { recursive: true });
|
|
367
|
+
const temp = `${file.path}.${process.pid}.tmp`;
|
|
368
|
+
writeFileSync(temp, file.content, { encoding: 'utf8', mode: file.mode });
|
|
369
|
+
chmodSync(temp, file.mode);
|
|
370
|
+
renameSync(temp, file.path);
|
|
371
|
+
changed++;
|
|
372
|
+
} catch (error) {
|
|
373
|
+
errors.push(`${file.path}: ${error.message}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
for (const entry of plan.registry) {
|
|
378
|
+
if (readRegistryValue(entry, { run, env }) === entry.value) continue;
|
|
379
|
+
try {
|
|
380
|
+
run(regExe(env), ['add', entry.key, ...(entry.name === null ? ['/ve'] : ['/v', entry.name]), '/t', 'REG_SZ', '/d', entry.value, '/f']);
|
|
381
|
+
changed++;
|
|
382
|
+
} catch (error) {
|
|
383
|
+
errors.push(`${entry.key}: ${error.message}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// The OS caches its handler table: tell it only when something moved. These
|
|
388
|
+
// are best effort (a minimal Linux has neither tool and still resolves the
|
|
389
|
+
// entry through mimeinfo on most desktops).
|
|
390
|
+
if (changed) {
|
|
391
|
+
for (const command of plan.refresh) {
|
|
392
|
+
try { run(command.file, command.args); } catch {}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const state = errors.length ? inspectLauncher(plan, { run, env }) : 'registered';
|
|
397
|
+
return { ok: errors.length === 0, changed, state, errors };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Whether this process may touch the user's registration. A test run or a
|
|
402
|
+
* throwaway data home must never repoint the real handler at itself.
|
|
403
|
+
*/
|
|
404
|
+
export function launcherRegistrationAllowed({ env = process.env, dataHome = null } = {}) {
|
|
405
|
+
if (env.SYNABUN_LAUNCHER_REGISTER === '0') return false;
|
|
406
|
+
if (env.NODE_TEST_CONTEXT || env.VITEST) return false;
|
|
407
|
+
if (dataHome) {
|
|
408
|
+
try { if (pathIsInside(dataHome, tmpdir())) return false; } catch {}
|
|
409
|
+
}
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** The entry executable of a packaged application, or null for an npm or Git install. */
|
|
414
|
+
function packagedEntry({ packageRoot, env }) {
|
|
415
|
+
return packageRoot ? readPackagedRuntime({ packageRoot, env })?.entry ?? null : null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Register (or repair) the synabun:// handler for this install. Never throws. */
|
|
419
|
+
export function registerStartLauncher({
|
|
420
|
+
platform = process.platform,
|
|
421
|
+
nodePath = process.execPath,
|
|
422
|
+
packageRoot,
|
|
423
|
+
home = homedir(),
|
|
424
|
+
env = process.env,
|
|
425
|
+
run = defaultRun,
|
|
426
|
+
} = {}) {
|
|
427
|
+
try {
|
|
428
|
+
const overrides = launcherOverrides({ env, home, platform });
|
|
429
|
+
if (!launcherRegistrationAllowed({ env, dataHome: overrides.dataHome })) {
|
|
430
|
+
return { ok: true, changed: 0, state: 'skipped', errors: [] };
|
|
431
|
+
}
|
|
432
|
+
const plan = buildLauncherPlan({ platform, nodePath, packageRoot, home, entry: packagedEntry({ packageRoot, env }), ...overrides });
|
|
433
|
+
return applyLauncherPlan(plan, { run, env });
|
|
434
|
+
} catch (error) {
|
|
435
|
+
return { ok: false, changed: 0, state: 'missing', errors: [error.message] };
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Read-only: is this install's handler in place? */
|
|
440
|
+
export function startLauncherState({
|
|
441
|
+
platform = process.platform,
|
|
442
|
+
nodePath = process.execPath,
|
|
443
|
+
packageRoot,
|
|
444
|
+
home = homedir(),
|
|
445
|
+
env = process.env,
|
|
446
|
+
run = defaultRun,
|
|
447
|
+
} = {}) {
|
|
448
|
+
try {
|
|
449
|
+
const plan = buildLauncherPlan({ platform, nodePath, packageRoot, home, entry: packagedEntry({ packageRoot, env }), ...launcherOverrides({ env, home, platform }) });
|
|
450
|
+
return inspectLauncher(plan, { run, env });
|
|
451
|
+
} catch {
|
|
452
|
+
return 'missing';
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ═══════════════════════════════════════════
|
|
457
|
+
// Launch
|
|
458
|
+
// ═══════════════════════════════════════════
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Read the launcher's arguments. Only `start` is an action: a link that asks
|
|
462
|
+
* for anything else is ignored, and nothing from the link is ever passed on.
|
|
463
|
+
*/
|
|
464
|
+
export function parseLauncherArgs(argv = []) {
|
|
465
|
+
const out = { action: 'start', via: 'manual', dataHome: null, port: null, terminalScript: null, link: null };
|
|
466
|
+
let afterDashes = false;
|
|
467
|
+
for (let i = 0; i < argv.length; i++) {
|
|
468
|
+
const arg = String(argv[i] ?? '');
|
|
469
|
+
if (new RegExp(`^${START_PROTOCOL}:`, 'i').test(arg)) {
|
|
470
|
+
out.link = arg;
|
|
471
|
+
out.action = parseStartLink(arg);
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (afterDashes) continue; // whatever else follows `--` came from the link
|
|
475
|
+
if (arg === '--') { afterDashes = true; continue; }
|
|
476
|
+
if (arg === '--via=protocol') out.via = 'protocol';
|
|
477
|
+
else if (arg === '--data-home') out.dataHome = argv[++i] || null;
|
|
478
|
+
else if (arg === '--port') out.port = normalizePort(argv[++i]);
|
|
479
|
+
else if (arg === '--terminal-script') out.terminalScript = argv[++i] || null;
|
|
480
|
+
}
|
|
481
|
+
return out;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** 'start' for synabun://start (any query), otherwise 'ignore'. */
|
|
485
|
+
export function parseStartLink(link) {
|
|
486
|
+
const match = new RegExp(`^${START_PROTOCOL}:(?://)?([a-z-]*)/?(?:[?#].*)?$`, 'i').exec(String(link || '').trim());
|
|
487
|
+
return match && match[1].toLowerCase() === 'start' ? 'start' : 'ignore';
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export function pidAlive(pid) {
|
|
491
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
492
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; }
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function readJson(path) {
|
|
496
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** What a click should do, given what is already there. */
|
|
500
|
+
export function decideLaunch({ healthy, lock = null, supervisor = null, now = Date.now(), alive = pidAlive } = {}) {
|
|
501
|
+
if (healthy) return 'already-running';
|
|
502
|
+
if (lock && alive(lock.pid) && now - Number(lock.startedAt || 0) < LOCK_TTL_MS) return 'already-starting';
|
|
503
|
+
// A supervisor only lives while its server does. One that has been alive
|
|
504
|
+
// this long without a server answering is a reused pid, not a start; one
|
|
505
|
+
// that wrote down its own end is over, whoever has its pid now.
|
|
506
|
+
if (supervisor && !supervisor.endedAt && alive(supervisor.pid) && now - Number(supervisor.startedAt || 0) < SUPERVISOR_TTL_MS) return 'already-starting';
|
|
507
|
+
return 'start';
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export function launcherStatePaths(dataHome) {
|
|
511
|
+
const dir = join(dataHome, 'data');
|
|
512
|
+
const lock = join(dir, 'start-launcher.lock');
|
|
513
|
+
return {
|
|
514
|
+
dir,
|
|
515
|
+
lock,
|
|
516
|
+
mutex: launchMutexPath(lock),
|
|
517
|
+
supervisor: join(dir, 'supervisor.json'),
|
|
518
|
+
log: join(dir, 'launcher.log'),
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* The mutex that goes with a lock file: DATA_HOME/runtime/start-launcher.mutex
|
|
524
|
+
* for DATA_HOME/data/start-launcher.lock. Derived from the lock's own path, so
|
|
525
|
+
* everyone after one lock is behind one mutex, and kept out of data/: an
|
|
526
|
+
* operating-system lock is not data, and a backup that copied it or a restore
|
|
527
|
+
* that replaced it would split it in two.
|
|
528
|
+
*/
|
|
529
|
+
export function launchMutexPath(lockPath) {
|
|
530
|
+
return join(dirname(dirname(lockPath)), 'runtime', `${basename(lockPath, '.lock')}.mutex`);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** What a supervisor says it is doing. Each one is written when it begins, never ahead of it. */
|
|
534
|
+
export const SUPERVISOR_PHASES = Object.freeze([
|
|
535
|
+
'checking', // looking at the install: nothing slow has begun
|
|
536
|
+
'dependencies', // npm install is running (a first start, or the first after an update)
|
|
537
|
+
'browser', // downloading the automation browser
|
|
538
|
+
'build', // compiling the MCP server
|
|
539
|
+
'snapshot', // backing the data home up before the first start of a new version
|
|
540
|
+
'server', // the server process was spawned
|
|
541
|
+
'listening', // the server printed its address
|
|
542
|
+
'failed', // it ended without a server; `error` says why
|
|
543
|
+
]);
|
|
544
|
+
|
|
545
|
+
function supervisorError(error) {
|
|
546
|
+
const exitCode = Number(error?.exitCode);
|
|
547
|
+
return {
|
|
548
|
+
code: String(error?.code || 'SETUP_FAILED').slice(0, 40),
|
|
549
|
+
message: String(error?.message || error || 'the start failed').slice(0, 300),
|
|
550
|
+
...(Number.isInteger(exitCode) ? { exitCode } : {}),
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function writeRecord(path, record, pid) {
|
|
555
|
+
const text = JSON.stringify(record) + '\n';
|
|
556
|
+
const temp = `${path}.${pid}.tmp`;
|
|
557
|
+
try {
|
|
558
|
+
// Replaced whole: a launcher reading it mid-start never sees half a record.
|
|
559
|
+
writeFileSync(temp, text, 'utf8');
|
|
560
|
+
renameSync(temp, path);
|
|
561
|
+
} catch {
|
|
562
|
+
try { writeFileSync(path, text, 'utf8'); } catch {}
|
|
563
|
+
try { rmSync(temp, { force: true }); } catch {}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Called by setup.js: say that a supervisor owns the server from now on, and
|
|
569
|
+
* what it is doing. A second click reads the record and starts nothing; the
|
|
570
|
+
* launcher that started this supervisor passes its phases on to the page.
|
|
571
|
+
* Returns { phase(name, detail), fail(error), release() }: `release` (on
|
|
572
|
+
* exit) removes the record unless it says why the start failed.
|
|
573
|
+
*/
|
|
574
|
+
export function openSupervisorRecord(dataHome, { pid = process.pid, now = () => Date.now() } = {}) {
|
|
575
|
+
const { dir, supervisor } = launcherStatePaths(dataHome);
|
|
576
|
+
const startedAt = now();
|
|
577
|
+
const record = { pid, startedAt, phase: 'checking', phaseAt: startedAt };
|
|
578
|
+
let ended = false;
|
|
579
|
+
const write = () => {
|
|
580
|
+
try { mkdirSync(dir, { recursive: true }); } catch {}
|
|
581
|
+
writeRecord(supervisor, record, pid);
|
|
582
|
+
};
|
|
583
|
+
write();
|
|
584
|
+
return {
|
|
585
|
+
phase(name, detail = null) {
|
|
586
|
+
if (ended || !SUPERVISOR_PHASES.includes(name) || name === 'failed') return;
|
|
587
|
+
record.phase = name;
|
|
588
|
+
record.phaseAt = now();
|
|
589
|
+
if (detail && typeof detail === 'object') record.detail = detail; else delete record.detail;
|
|
590
|
+
write();
|
|
591
|
+
},
|
|
592
|
+
fail(error) {
|
|
593
|
+
if (ended) return;
|
|
594
|
+
ended = true;
|
|
595
|
+
record.phase = 'failed';
|
|
596
|
+
record.phaseAt = record.endedAt = now();
|
|
597
|
+
record.error = supervisorError(error);
|
|
598
|
+
delete record.detail;
|
|
599
|
+
write();
|
|
600
|
+
},
|
|
601
|
+
release() {
|
|
602
|
+
// A record of why it failed stays for the launcher and the next click to
|
|
603
|
+
// read; `endedAt` keeps it from ever counting as a start under way.
|
|
604
|
+
if (ended) return;
|
|
605
|
+
try { if (readJson(supervisor)?.pid === pid) rmSync(supervisor, { force: true }); } catch {}
|
|
606
|
+
},
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* A start that ended before it could open its record (two data homes that
|
|
612
|
+
* disagree, say). Written only into a data directory that is already there:
|
|
613
|
+
* a failed start creates nothing.
|
|
614
|
+
*/
|
|
615
|
+
export function noteSupervisorFailure(dataHome, error, { pid = process.pid, now = Date.now() } = {}) {
|
|
616
|
+
const { dir, supervisor } = launcherStatePaths(dataHome);
|
|
617
|
+
if (!existsSync(dir)) return false;
|
|
618
|
+
writeRecord(supervisor, { pid, startedAt: now, phase: 'failed', phaseAt: now, endedAt: now, error: supervisorError(error) }, pid);
|
|
619
|
+
return true;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const MUTEX_TIMEOUT_MS = 2000;
|
|
623
|
+
const SQLITE_BUSY = 5;
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* node:sqlite, loaded on first use: postinstall.js and setup.js import this
|
|
627
|
+
* module on any Node, and only a launch needs it. The server runs with
|
|
628
|
+
* --disable-warning=ExperimentalWarning; the OS starts the launcher without
|
|
629
|
+
* it, so the one line node prints for this module is kept out of its console.
|
|
630
|
+
*/
|
|
631
|
+
function loadSqlite() {
|
|
632
|
+
const emitWarning = process.emitWarning;
|
|
633
|
+
process.emitWarning = function (warning, ...rest) {
|
|
634
|
+
if (/SQLite is an experimental feature/i.test(String(warning?.message ?? warning))) return undefined;
|
|
635
|
+
return emitWarning.call(this, warning, ...rest);
|
|
636
|
+
};
|
|
637
|
+
try { return createRequire(import.meta.url)('node:sqlite'); } finally { process.emitWarning = emitWarning; }
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Run `fn` as the only process inside, among everyone who uses `path`. The
|
|
642
|
+
* mutex is a write transaction (BEGIN IMMEDIATE) on an empty SQLite database,
|
|
643
|
+
* like the rules installer's lock (neural-interface/lib/rulesets/lock.js):
|
|
644
|
+
* the operating system holds it and gives it back when the process ends,
|
|
645
|
+
* however it ends. Nothing is ever written to the file, there is no stale
|
|
646
|
+
* state to clean up, and so nothing here has to judge or remove someone
|
|
647
|
+
* else's claim. Returns { ran, value }; `ran` is false when another process
|
|
648
|
+
* stayed inside for `timeoutMs`. Throws when the mutex cannot be used at all.
|
|
649
|
+
*/
|
|
650
|
+
function withLaunchMutex(path, fn, { timeoutMs = MUTEX_TIMEOUT_MS } = {}) {
|
|
651
|
+
const { DatabaseSync } = loadSqlite();
|
|
652
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
653
|
+
// Which file the path leads to, by stat alone (never a second descriptor on it).
|
|
654
|
+
const identity = () => {
|
|
655
|
+
try { const info = statSync(path, { bigint: true }); return `${info.dev}:${info.ino}`; } catch { return null; }
|
|
656
|
+
};
|
|
657
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
658
|
+
const before = identity();
|
|
659
|
+
const db = new DatabaseSync(path);
|
|
660
|
+
try {
|
|
661
|
+
db.exec(`PRAGMA busy_timeout=${Math.max(0, Math.floor(Number(timeoutMs) || 0))}`);
|
|
662
|
+
const opened = identity();
|
|
663
|
+
try {
|
|
664
|
+
db.exec('BEGIN IMMEDIATE');
|
|
665
|
+
} catch (error) {
|
|
666
|
+
const busy = (Number(error?.errcode) & 0xff) === SQLITE_BUSY || /database is locked/i.test(String(error?.errstr || error?.message || ''));
|
|
667
|
+
if (busy) return { ran: false };
|
|
668
|
+
throw error;
|
|
669
|
+
}
|
|
670
|
+
try {
|
|
671
|
+
// Deleted or replaced while it was being taken: this transaction is on
|
|
672
|
+
// a file nobody else looks at. Give it back and take the one there now.
|
|
673
|
+
if (opened === null || (before !== null && before !== opened) || identity() !== opened) continue;
|
|
674
|
+
return { ran: true, value: fn() };
|
|
675
|
+
} finally {
|
|
676
|
+
try { db.exec('ROLLBACK'); } catch {}
|
|
677
|
+
}
|
|
678
|
+
} finally {
|
|
679
|
+
try { db.close(); } catch {}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
throw new Error(`its mutex (${path}) was replaced while it was being taken`);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Take the launch lock, or return null when a live launch holds it. Reading
|
|
687
|
+
* the record, judging it dead and replacing it all happen inside the launch
|
|
688
|
+
* mutex, and so does the release: two launchers that found the same dead lock
|
|
689
|
+
* cannot remove each other's fresh one, and a late release cannot remove a
|
|
690
|
+
* lock taken after it. Throws (code LAUNCH_LOCK_UNAVAILABLE) when the lock
|
|
691
|
+
* cannot be taken at all; the caller then starts nothing.
|
|
692
|
+
*/
|
|
693
|
+
export function acquireLaunchLock(lockPath, {
|
|
694
|
+
pid = process.pid, now = Date.now(), alive = pidAlive, port = null, mutexTimeoutMs = MUTEX_TIMEOUT_MS,
|
|
695
|
+
} = {}) {
|
|
696
|
+
const mutexPath = launchMutexPath(lockPath);
|
|
697
|
+
const id = randomBytes(8).toString('hex');
|
|
698
|
+
const record = JSON.stringify({ pid, startedAt: now, port, id }) + '\n';
|
|
699
|
+
let taken;
|
|
700
|
+
try {
|
|
701
|
+
taken = withLaunchMutex(mutexPath, () => {
|
|
702
|
+
const held = readJson(lockPath);
|
|
703
|
+
if (held && alive(held.pid) && now - Number(held.startedAt || 0) < LOCK_TTL_MS) return false;
|
|
704
|
+
// There and unreadable, a moment old: nobody else is writing it now (this
|
|
705
|
+
// is the mutex), so it is what a launcher that died mid-write left, or
|
|
706
|
+
// not ours at all. It gets a few seconds before it counts as debris.
|
|
707
|
+
if (!held) {
|
|
708
|
+
try { if (Date.now() - statSync(lockPath).mtimeMs < 5000) return false; } catch {}
|
|
709
|
+
}
|
|
710
|
+
// Written in place: a reader outside the mutex (decideLaunch) may catch
|
|
711
|
+
// it half written, reads nothing, and then waits here like everyone else.
|
|
712
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
713
|
+
writeFileSync(lockPath, record, 'utf8');
|
|
714
|
+
return true;
|
|
715
|
+
}, { timeoutMs: mutexTimeoutMs });
|
|
716
|
+
} catch (cause) {
|
|
717
|
+
throw Object.assign(new Error(`the launch lock could not be taken (${lockPath}): ${cause?.message || cause}`), { code: 'LAUNCH_LOCK_UNAVAILABLE', cause });
|
|
718
|
+
}
|
|
719
|
+
// Someone else inside the mutex for that long is a launch at work, too.
|
|
720
|
+
if (!taken.ran || !taken.value) return null;
|
|
721
|
+
return {
|
|
722
|
+
release() {
|
|
723
|
+
try {
|
|
724
|
+
withLaunchMutex(mutexPath, () => {
|
|
725
|
+
if (readJson(lockPath)?.id === id) rmSync(lockPath, { force: true });
|
|
726
|
+
}, { timeoutMs: mutexTimeoutMs });
|
|
727
|
+
} catch {}
|
|
728
|
+
},
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** True when something accepts a TCP connection on the port. */
|
|
733
|
+
export function portAccepts(port, { timeoutMs = 400, host = '127.0.0.1', connectImpl = connect } = {}) {
|
|
734
|
+
return new Promise((done) => {
|
|
735
|
+
let settled = false;
|
|
736
|
+
let socket = null;
|
|
737
|
+
const finish = (value) => {
|
|
738
|
+
if (settled) return;
|
|
739
|
+
settled = true;
|
|
740
|
+
try { socket?.destroy(); } catch {}
|
|
741
|
+
done(value);
|
|
742
|
+
};
|
|
743
|
+
try {
|
|
744
|
+
socket = connectImpl({ port, host });
|
|
745
|
+
socket.setTimeout?.(timeoutMs, () => finish(false));
|
|
746
|
+
socket.once('connect', () => finish(true));
|
|
747
|
+
socket.once('error', () => finish(false));
|
|
748
|
+
} catch {
|
|
749
|
+
finish(false);
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* True when a server holds the port: something answers HTTP there (a starting
|
|
756
|
+
* server answers before it is ready), or, when no answer came, something still
|
|
757
|
+
* accepts connections. A server too busy to answer in time is a server all the
|
|
758
|
+
* same, and a second one could not take its port.
|
|
759
|
+
*/
|
|
760
|
+
export async function probeServer(port, { timeoutMs = 1500, fetchImpl = globalThis.fetch, accepts = portAccepts } = {}) {
|
|
761
|
+
try {
|
|
762
|
+
const response = await fetchImpl(`http://127.0.0.1:${port}/api/health`, {
|
|
763
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
764
|
+
headers: { accept: 'application/json' },
|
|
765
|
+
});
|
|
766
|
+
try { await response.body?.cancel(); } catch {}
|
|
767
|
+
return true;
|
|
768
|
+
} catch {
|
|
769
|
+
try { return (await accepts(port)) === true; } catch { return false; }
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ═══════════════════════════════════════════
|
|
774
|
+
// Progress: the start beacon
|
|
775
|
+
// ═══════════════════════════════════════════
|
|
776
|
+
|
|
777
|
+
export const BEACON_VERSION = 1;
|
|
778
|
+
export const BEACON_PATH = '/synabun-start-status';
|
|
779
|
+
/** How long a launch that failed keeps saying so, for a page that has not read it yet. */
|
|
780
|
+
export const BEACON_LINGER_MS = 20 * 1000;
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Where a launch reports while the server is down: the server's port + 10000
|
|
784
|
+
* on the loopback address (3344 → 13344), − 10000 when that is past the last
|
|
785
|
+
* port. The page derives it the same way (`startBeaconPort` in
|
|
786
|
+
* neural-interface/public/shared/start-bridge.js): the two must agree.
|
|
787
|
+
*/
|
|
788
|
+
export function startBeaconPort(port) {
|
|
789
|
+
const p = normalizePort(port) || DEFAULT_PORT;
|
|
790
|
+
return p + 10000 <= 65535 ? p + 10000 : p - 10000;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const DETAIL_NUMBERS = ['files', 'bytes', 'skippedFiles', 'port', 'pid', 'exitCode'];
|
|
794
|
+
const DETAIL_STRINGS = ['name', 'step', 'version'];
|
|
795
|
+
|
|
796
|
+
/** What of a supervisor record goes out on the beacon: known fields, bounded, nothing else from the file. */
|
|
797
|
+
export function publicSupervisorState(record) {
|
|
798
|
+
if (!record || typeof record !== 'object' || !SUPERVISOR_PHASES.includes(record.phase)) return null;
|
|
799
|
+
const out = {
|
|
800
|
+
phase: record.phase,
|
|
801
|
+
at: Number(record.phaseAt) || Number(record.startedAt) || 0,
|
|
802
|
+
startedAt: Number(record.startedAt) || 0,
|
|
803
|
+
};
|
|
804
|
+
if (record.detail && typeof record.detail === 'object') {
|
|
805
|
+
const detail = {};
|
|
806
|
+
for (const key of DETAIL_NUMBERS) if (Number.isFinite(Number(record.detail[key])) && record.detail[key] !== null) detail[key] = Number(record.detail[key]);
|
|
807
|
+
for (const key of DETAIL_STRINGS) if (typeof record.detail[key] === 'string') detail[key] = record.detail[key].slice(0, 80);
|
|
808
|
+
if (Object.keys(detail).length) out.detail = detail;
|
|
809
|
+
}
|
|
810
|
+
if (record.phase === 'failed') out.error = supervisorError(record.error);
|
|
811
|
+
return out;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* The beacon of one launch: a loopback-only HTTP listener that answers one
|
|
816
|
+
* read-only question, "what has this launch seen happen so far?".
|
|
817
|
+
*
|
|
818
|
+
* step(id) — something the launcher itself did or saw, with its time
|
|
819
|
+
* supervisor(rec) — the supervisor's own record, as it stands
|
|
820
|
+
* finish(state, e) — 'started' | 'failed' | 'gave-up'
|
|
821
|
+
*
|
|
822
|
+
* It answers only its own host name (a page elsewhere cannot reach it by
|
|
823
|
+
* rebinding a name), gives the answer only to a page of this server's own
|
|
824
|
+
* loopback origin, and changes nothing. A port it cannot have is not an
|
|
825
|
+
* error: the launch goes on unreported and the page says less.
|
|
826
|
+
*/
|
|
827
|
+
export function createStartBeacon({
|
|
828
|
+
port,
|
|
829
|
+
startedAt = Date.now(),
|
|
830
|
+
now = () => Date.now(),
|
|
831
|
+
launchId = randomBytes(8).toString('hex'),
|
|
832
|
+
logPath = null,
|
|
833
|
+
createServerImpl = createServer,
|
|
834
|
+
} = {}) {
|
|
835
|
+
const beaconPort = startBeaconPort(port);
|
|
836
|
+
const hosts = new Set([`127.0.0.1:${beaconPort}`, `localhost:${beaconPort}`]);
|
|
837
|
+
const origins = new Set([`http://localhost:${port}`, `http://127.0.0.1:${port}`, `http://[::1]:${port}`]);
|
|
838
|
+
const status = { state: 'starting', steps: [{ id: 'received', at: startedAt }], supervisor: null, error: null };
|
|
839
|
+
let server = null;
|
|
840
|
+
let listening = false;
|
|
841
|
+
let closed = false;
|
|
842
|
+
let retryTimer = null;
|
|
843
|
+
let finalReads = 0;
|
|
844
|
+
let onFinalRead = null;
|
|
845
|
+
// Each phase the supervisor was seen in, with the time it gave for it. One
|
|
846
|
+
// that came and went between two looks is not here: this is what was seen.
|
|
847
|
+
const phases = [];
|
|
848
|
+
|
|
849
|
+
const snapshot = () => ({
|
|
850
|
+
synabun: 'start-beacon',
|
|
851
|
+
v: BEACON_VERSION,
|
|
852
|
+
port,
|
|
853
|
+
launchId,
|
|
854
|
+
startedAt,
|
|
855
|
+
now: now(),
|
|
856
|
+
state: status.state,
|
|
857
|
+
steps: status.steps,
|
|
858
|
+
supervisor: status.supervisor,
|
|
859
|
+
error: status.error,
|
|
860
|
+
log: logPath,
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
function handle(req, res) {
|
|
864
|
+
const deny = (code) => { res.writeHead(code, { 'Content-Length': '0', 'Cache-Control': 'no-store' }); res.end(); };
|
|
865
|
+
try {
|
|
866
|
+
if (!hosts.has(String(req.headers.host || '').toLowerCase())) return deny(403);
|
|
867
|
+
const origin = req.headers.origin;
|
|
868
|
+
if (origin !== undefined && !origins.has(origin)) return deny(403);
|
|
869
|
+
if (String(req.url || '').split('?')[0] !== BEACON_PATH) return deny(404);
|
|
870
|
+
const cors = origin ? { 'Access-Control-Allow-Origin': origin, Vary: 'Origin' } : {};
|
|
871
|
+
if (req.method === 'OPTIONS') {
|
|
872
|
+
res.writeHead(204, {
|
|
873
|
+
...cors,
|
|
874
|
+
'Access-Control-Allow-Methods': 'GET',
|
|
875
|
+
'Access-Control-Allow-Private-Network': 'true',
|
|
876
|
+
'Access-Control-Max-Age': '600',
|
|
877
|
+
'Content-Length': '0',
|
|
878
|
+
});
|
|
879
|
+
return res.end();
|
|
880
|
+
}
|
|
881
|
+
if (req.method !== 'GET') return deny(405);
|
|
882
|
+
const body = JSON.stringify(snapshot());
|
|
883
|
+
res.writeHead(200, {
|
|
884
|
+
...cors,
|
|
885
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
886
|
+
'Content-Length': String(Buffer.byteLength(body)),
|
|
887
|
+
'Cache-Control': 'no-store',
|
|
888
|
+
'X-Content-Type-Options': 'nosniff',
|
|
889
|
+
});
|
|
890
|
+
res.end(body);
|
|
891
|
+
if (status.state !== 'starting' && origin) {
|
|
892
|
+
finalReads++;
|
|
893
|
+
onFinalRead?.();
|
|
894
|
+
}
|
|
895
|
+
} catch {
|
|
896
|
+
try { deny(500); } catch {}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** Try to take the port; `retryMs` keeps trying while an earlier launch still holds it. Resolves true | false. */
|
|
901
|
+
function listen({ retryMs = 0, retryEveryMs = 400 } = {}) {
|
|
902
|
+
const deadline = now() + retryMs;
|
|
903
|
+
return new Promise((done) => {
|
|
904
|
+
const attempt = () => {
|
|
905
|
+
retryTimer = null;
|
|
906
|
+
if (closed) return done(false);
|
|
907
|
+
let candidate;
|
|
908
|
+
try {
|
|
909
|
+
candidate = createServerImpl(handle);
|
|
910
|
+
} catch {
|
|
911
|
+
return done(false);
|
|
912
|
+
}
|
|
913
|
+
let bound = false;
|
|
914
|
+
// Kept for the listener's whole life: an error with nobody listening for
|
|
915
|
+
// it would end the launcher, and the beacon is never worth that.
|
|
916
|
+
candidate.on('error', () => {
|
|
917
|
+
if (bound) return;
|
|
918
|
+
try { candidate.close(); } catch {}
|
|
919
|
+
if (closed || now() >= deadline) return done(false);
|
|
920
|
+
retryTimer = setTimeout(attempt, retryEveryMs);
|
|
921
|
+
retryTimer.unref?.();
|
|
922
|
+
});
|
|
923
|
+
candidate.once('listening', () => {
|
|
924
|
+
bound = true;
|
|
925
|
+
if (closed) { try { candidate.close(); } catch {} return done(false); }
|
|
926
|
+
server = candidate;
|
|
927
|
+
listening = true;
|
|
928
|
+
// A slow or silent client cannot hold the launcher open.
|
|
929
|
+
server.headersTimeout = 5000;
|
|
930
|
+
server.requestTimeout = 5000;
|
|
931
|
+
server.keepAliveTimeout = 1000;
|
|
932
|
+
done(true);
|
|
933
|
+
});
|
|
934
|
+
candidate.unref?.();
|
|
935
|
+
candidate.listen({ port: beaconPort, host: '127.0.0.1', exclusive: true });
|
|
936
|
+
};
|
|
937
|
+
attempt();
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
return {
|
|
942
|
+
port: beaconPort,
|
|
943
|
+
launchId,
|
|
944
|
+
get listening() { return listening; },
|
|
945
|
+
get state() { return status.state; },
|
|
946
|
+
snapshot,
|
|
947
|
+
listen,
|
|
948
|
+
step(id, extra = {}) {
|
|
949
|
+
if (status.state !== 'starting' || status.steps.some(s => s.id === id)) return;
|
|
950
|
+
status.steps.push({ id: String(id), at: now(), ...extra });
|
|
951
|
+
},
|
|
952
|
+
supervisor(record) {
|
|
953
|
+
if (status.state !== 'starting') return;
|
|
954
|
+
const next = publicSupervisorState(record);
|
|
955
|
+
if (!next) return;
|
|
956
|
+
if (phases[phases.length - 1]?.phase !== next.phase) phases.push({ phase: next.phase, at: next.at });
|
|
957
|
+
status.supervisor = { ...next, phases: phases.slice(-12) };
|
|
958
|
+
},
|
|
959
|
+
finish(state, error = null) {
|
|
960
|
+
if (status.state !== 'starting') return;
|
|
961
|
+
status.state = state;
|
|
962
|
+
status.error = error ? supervisorError(error) : null;
|
|
963
|
+
status.steps.push({ id: state, at: now() });
|
|
964
|
+
},
|
|
965
|
+
/**
|
|
966
|
+
* Keep answering until a page has read how it ended (and a moment more,
|
|
967
|
+
* for a second tab), `ms` at the most. A beacon nobody can reach is over at once.
|
|
968
|
+
*/
|
|
969
|
+
linger(ms, { afterReadMs = 1500 } = {}) {
|
|
970
|
+
if (!listening || closed || !(ms > 0)) return Promise.resolve();
|
|
971
|
+
return new Promise((done) => {
|
|
972
|
+
let timer = setTimeout(done, ms);
|
|
973
|
+
const soon = () => {
|
|
974
|
+
onFinalRead = null;
|
|
975
|
+
clearTimeout(timer);
|
|
976
|
+
timer = setTimeout(done, afterReadMs);
|
|
977
|
+
};
|
|
978
|
+
if (finalReads > 0) soon(); else onFinalRead = soon;
|
|
979
|
+
});
|
|
980
|
+
},
|
|
981
|
+
close() {
|
|
982
|
+
closed = true;
|
|
983
|
+
listening = false;
|
|
984
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
985
|
+
retryTimer = null;
|
|
986
|
+
onFinalRead = null;
|
|
987
|
+
try { server?.close(); } catch {}
|
|
988
|
+
try { server?.closeAllConnections?.(); } catch {}
|
|
989
|
+
server = null;
|
|
990
|
+
},
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const LOGIN_ENV_SHELLS = new Set(['zsh', 'bash', 'sh', 'dash', 'ksh', 'mksh', 'yash', 'fish']);
|
|
995
|
+
/** What a launch decides for itself. A profile may export its own; the launch's values stand. */
|
|
996
|
+
const LAUNCH_SETTINGS = ['SYNABUN_DATA_HOME', 'NEURAL_PORT', 'SYNABUN_OPEN_BROWSER'];
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* The environment a terminal would give the server. A desktop starts the
|
|
1000
|
+
* launcher with a bare one (no nvm, no Homebrew, no exported keys), so ask the
|
|
1001
|
+
* user's own shell, bounded, and fall back to a PATH that at least finds node.
|
|
1002
|
+
* The shell's answer goes over `env`, except for the launch's own settings:
|
|
1003
|
+
* the server has to listen where the launcher probes and keep its state where
|
|
1004
|
+
* the launcher's lock is.
|
|
1005
|
+
*/
|
|
1006
|
+
export function resolveLaunchEnv({
|
|
1007
|
+
env = process.env,
|
|
1008
|
+
nodePath = process.execPath,
|
|
1009
|
+
platform = process.platform,
|
|
1010
|
+
home = homedir(),
|
|
1011
|
+
timeoutMs = 5000,
|
|
1012
|
+
runShell = spawnSync,
|
|
1013
|
+
} = {}) {
|
|
1014
|
+
// Windows hands a protocol handler the user's full environment already.
|
|
1015
|
+
if (platform === 'win32') return { ...env };
|
|
1016
|
+
const nodeDir = dirname(nodePath);
|
|
1017
|
+
// node first, so npm / npx (and CLIs installed next to it) are the same install.
|
|
1018
|
+
const withNode = (base) => {
|
|
1019
|
+
const parts = String(base.PATH || '').split(':').filter(Boolean);
|
|
1020
|
+
const extra = ['/usr/local/bin', '/opt/homebrew/bin', posix.join(home, '.local', 'bin'), '/usr/bin', '/bin'];
|
|
1021
|
+
const path = [nodeDir, ...parts, ...extra].filter((p, i, all) => all.indexOf(p) === i).join(':');
|
|
1022
|
+
return { ...base, PATH: path };
|
|
1023
|
+
};
|
|
1024
|
+
|
|
1025
|
+
const shell = env.SHELL || '';
|
|
1026
|
+
if (!LOGIN_ENV_SHELLS.has(basename(shell))) return withNode(env);
|
|
1027
|
+
try {
|
|
1028
|
+
const marker = `__SYNABUN_ENV_${randomBytes(8).toString('hex')}__`;
|
|
1029
|
+
const script = `process.stdout.write(${JSON.stringify(marker)}+JSON.stringify(process.env)+${JSON.stringify(marker)})`;
|
|
1030
|
+
const result = runShell(shell, ['-ilc', `${shQuote(nodePath)} -e ${shQuote(script)}`], {
|
|
1031
|
+
encoding: 'utf8',
|
|
1032
|
+
timeout: timeoutMs,
|
|
1033
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1034
|
+
env: { ...env, HOME: env.HOME || home },
|
|
1035
|
+
});
|
|
1036
|
+
const out = String(result?.stdout || '');
|
|
1037
|
+
const start = out.indexOf(marker);
|
|
1038
|
+
const end = out.lastIndexOf(marker);
|
|
1039
|
+
if (start < 0 || end <= start) return withNode(env);
|
|
1040
|
+
const resolved = JSON.parse(out.slice(start + marker.length, end));
|
|
1041
|
+
if (!resolved || typeof resolved !== 'object') return withNode(env);
|
|
1042
|
+
const decided = Object.fromEntries(LAUNCH_SETTINGS.filter(key => env[key]).map(key => [key, env[key]]));
|
|
1043
|
+
return withNode({ ...env, ...resolved, ...decided });
|
|
1044
|
+
} catch {
|
|
1045
|
+
return withNode(env);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function appendLog(logPath, line) {
|
|
1050
|
+
try {
|
|
1051
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
1052
|
+
try { if (statSync(logPath).size > 1024 * 1024) renameSync(logPath, `${logPath}.1`); } catch {}
|
|
1053
|
+
writeFileSync(logPath, `${new Date().toISOString()} ${line}\n`, { encoding: 'utf8', flag: 'a' });
|
|
1054
|
+
} catch {}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
export function noteDesktopLaunch(dataHome, line) {
|
|
1058
|
+
appendLog(launcherStatePaths(dataHome).log, `desktop: ${line}`);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
async function waitForServer({ port, probe, sleep, now, timeoutMs, pollMs = WAIT_POLL_MS, stillGoing = () => true }) {
|
|
1062
|
+
const deadline = now() + timeoutMs;
|
|
1063
|
+
while (now() < deadline) {
|
|
1064
|
+
if (await probe(port)) return true;
|
|
1065
|
+
if (!stillGoing()) return false;
|
|
1066
|
+
await sleep(pollMs);
|
|
1067
|
+
}
|
|
1068
|
+
return false;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Run one launch. Returns { outcome, code, port }:
|
|
1073
|
+
* 'ignored' — the link asked for something other than start
|
|
1074
|
+
* 'already-running' — a server answers
|
|
1075
|
+
* 'already-starting' — another launch (or a supervisor) is on it
|
|
1076
|
+
* 'started' — the server answered after this launch
|
|
1077
|
+
* 'spawned' — started, but it had not answered when the wait ended
|
|
1078
|
+
* 'failed' — it could not be started
|
|
1079
|
+
*
|
|
1080
|
+
* `beacon: true` (launcher.mjs) reports the launch on its beacon while it
|
|
1081
|
+
* lasts, and `lingerMs` keeps a failure readable there for a moment. Every
|
|
1082
|
+
* step is also written to DATA_HOME/data/launcher.log with how long it took.
|
|
1083
|
+
*/
|
|
1084
|
+
export async function runLauncher({
|
|
1085
|
+
argv = [],
|
|
1086
|
+
env = process.env,
|
|
1087
|
+
platform = process.platform,
|
|
1088
|
+
packageRoot,
|
|
1089
|
+
nodePath = process.execPath,
|
|
1090
|
+
home = homedir(),
|
|
1091
|
+
probe = probeServer,
|
|
1092
|
+
spawnImpl = spawn,
|
|
1093
|
+
runShell = spawnSync,
|
|
1094
|
+
sleep = (ms) => new Promise(r => setTimeout(r, ms)),
|
|
1095
|
+
now = () => Date.now(),
|
|
1096
|
+
waitMs = HEALTH_WAIT_MS,
|
|
1097
|
+
pollMs = WAIT_POLL_MS,
|
|
1098
|
+
beacon = false,
|
|
1099
|
+
lingerMs = 0,
|
|
1100
|
+
alive = pidAlive,
|
|
1101
|
+
background = false,
|
|
1102
|
+
} = {}) {
|
|
1103
|
+
const args = parseLauncherArgs(argv);
|
|
1104
|
+
if (args.action !== 'start') return { outcome: 'ignored', code: 0, port: null };
|
|
1105
|
+
|
|
1106
|
+
const launchedAt = now();
|
|
1107
|
+
const since = () => `${Math.max(0, now() - launchedAt)} ms`;
|
|
1108
|
+
const launchEnv = { ...env };
|
|
1109
|
+
if (args.dataHome) launchEnv.SYNABUN_DATA_HOME = args.dataHome;
|
|
1110
|
+
const dataHome = getDataHome({ env: launchEnv, home, os: platform });
|
|
1111
|
+
const port = resolvePort({ explicit: args.port, env: launchEnv, dataHome });
|
|
1112
|
+
// From here on the environment says what was decided, and resolveLaunchEnv
|
|
1113
|
+
// keeps it over anything a profile exports: the port probed below, the data
|
|
1114
|
+
// home the lock is in and the server that gets started are the same ones.
|
|
1115
|
+
Object.assign(launchEnv, { SYNABUN_DATA_HOME: dataHome, NEURAL_PORT: String(port), SYNABUN_OPEN_BROWSER: '0' });
|
|
1116
|
+
const paths = launcherStatePaths(dataHome);
|
|
1117
|
+
const log = (line) => appendLog(paths.log, `[${process.pid}] ${line}`);
|
|
1118
|
+
const makeBeacon = () => {
|
|
1119
|
+
if (!beacon) return null;
|
|
1120
|
+
try {
|
|
1121
|
+
const options = { port, startedAt: launchedAt, now, logPath: paths.log };
|
|
1122
|
+
return typeof beacon === 'function' ? beacon(options) : createStartBeacon(options);
|
|
1123
|
+
} catch {
|
|
1124
|
+
return null;
|
|
1125
|
+
}
|
|
1126
|
+
};
|
|
1127
|
+
|
|
1128
|
+
const healthy = await probe(port);
|
|
1129
|
+
const decision = decideLaunch({ healthy, lock: readJson(paths.lock), supervisor: readJson(paths.supervisor), now: now(), alive });
|
|
1130
|
+
if (decision !== 'start') {
|
|
1131
|
+
log(`${decision} (port ${port}, via ${args.via})`);
|
|
1132
|
+
// A start somebody else began (by hand in a terminal, say) has nobody
|
|
1133
|
+
// reporting it when no launcher is with it: this click does, for as long
|
|
1134
|
+
// as that start lasts. Windows keeps a console open for a launcher that
|
|
1135
|
+
// stays, so there it stands down at once.
|
|
1136
|
+
if (decision === 'already-starting' && (platform !== 'win32' || background)) {
|
|
1137
|
+
const standIn = makeBeacon();
|
|
1138
|
+
if (standIn && await standIn.listen()) {
|
|
1139
|
+
const supervisorOf = () => {
|
|
1140
|
+
const record = readJson(paths.supervisor);
|
|
1141
|
+
if (record) standIn.supervisor(record);
|
|
1142
|
+
return record;
|
|
1143
|
+
};
|
|
1144
|
+
const up = await waitForServer({
|
|
1145
|
+
port, probe, sleep, now, timeoutMs: waitMs, pollMs,
|
|
1146
|
+
stillGoing: () => {
|
|
1147
|
+
const record = supervisorOf();
|
|
1148
|
+
const lock = readJson(paths.lock);
|
|
1149
|
+
return !!(record && !record.endedAt && alive(record.pid)) || !!(lock && alive(lock.pid));
|
|
1150
|
+
},
|
|
1151
|
+
});
|
|
1152
|
+
const record = readJson(paths.supervisor);
|
|
1153
|
+
if (up) standIn.finish('started');
|
|
1154
|
+
else if (record?.phase === 'failed') standIn.finish('failed', record.error);
|
|
1155
|
+
else standIn.finish('gave-up');
|
|
1156
|
+
if (!up) await standIn.linger(lingerMs);
|
|
1157
|
+
}
|
|
1158
|
+
standIn?.close();
|
|
1159
|
+
}
|
|
1160
|
+
return { outcome: decision, code: 0, port };
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
const report = makeBeacon();
|
|
1164
|
+
// An earlier launch that failed may still be saying so: take over when it lets go.
|
|
1165
|
+
report?.listen({ retryMs: 5000 });
|
|
1166
|
+
const fail = (error, line = error.message) => {
|
|
1167
|
+
log(`failed: ${line}`);
|
|
1168
|
+
report?.finish('failed', error);
|
|
1169
|
+
return { outcome: 'failed', code: 1, port };
|
|
1170
|
+
};
|
|
1171
|
+
const conclude = async (result) => {
|
|
1172
|
+
if (report && result.outcome !== 'started') await report.linger(lingerMs);
|
|
1173
|
+
report?.close();
|
|
1174
|
+
return result;
|
|
1175
|
+
};
|
|
1176
|
+
|
|
1177
|
+
let lock;
|
|
1178
|
+
try {
|
|
1179
|
+
lock = acquireLaunchLock(paths.lock, { now: now(), port });
|
|
1180
|
+
} catch (error) {
|
|
1181
|
+
return conclude(fail({ code: 'LOCK_UNAVAILABLE', message: error.message }));
|
|
1182
|
+
}
|
|
1183
|
+
if (!lock) {
|
|
1184
|
+
log(`already-starting (lost the lock, port ${port})`);
|
|
1185
|
+
report?.close();
|
|
1186
|
+
return { outcome: 'already-starting', code: 0, port };
|
|
1187
|
+
}
|
|
1188
|
+
log(`starting (port ${port}, via ${args.via})`);
|
|
1189
|
+
report?.step('locked');
|
|
1190
|
+
|
|
1191
|
+
// What the supervisor of this launch says about itself. One of an earlier
|
|
1192
|
+
// launch (a failure left for the next click to read) is not ours.
|
|
1193
|
+
let supervisor = null;
|
|
1194
|
+
const readSupervisor = () => {
|
|
1195
|
+
const record = readJson(paths.supervisor);
|
|
1196
|
+
if (!record || Number(record.startedAt) < launchedAt - 2000) return;
|
|
1197
|
+
if (!supervisor) log(`the supervisor is running after ${since()}`);
|
|
1198
|
+
if (record.phase && record.phase !== supervisor?.phase) log(`supervisor: ${record.phase}${record.phase === 'failed' ? ` (${record.error?.message || 'no reason given'})` : ''} at ${since()}`);
|
|
1199
|
+
supervisor = record;
|
|
1200
|
+
report?.supervisor(record);
|
|
1201
|
+
};
|
|
1202
|
+
const supervisorEnded = () => !!supervisor && (!!supervisor.endedAt || !alive(supervisor.pid));
|
|
1203
|
+
const supervisorFailure = (fallback) => (supervisor?.phase === 'failed' && supervisor.error
|
|
1204
|
+
? { ...supervisor.error }
|
|
1205
|
+
: fallback);
|
|
1206
|
+
const answered = () => {
|
|
1207
|
+
log(`started: answering after ${since()}`);
|
|
1208
|
+
report?.finish('started');
|
|
1209
|
+
return { outcome: 'started', code: 0, port };
|
|
1210
|
+
};
|
|
1211
|
+
const gaveUp = () => {
|
|
1212
|
+
log(`not answering yet (gave up waiting after ${since()})`);
|
|
1213
|
+
report?.finish('gave-up', { code: 'NO_ANSWER', message: `no answer after ${Math.round(waitMs / 1000)} s` });
|
|
1214
|
+
return { outcome: 'spawned', code: 0, port };
|
|
1215
|
+
};
|
|
1216
|
+
|
|
1217
|
+
const setup = join(packageRoot, 'setup.js');
|
|
1218
|
+
const nodeArgs = ['--disable-warning=ExperimentalWarning', setup];
|
|
1219
|
+
let result;
|
|
1220
|
+
try {
|
|
1221
|
+
result = await (async () => {
|
|
1222
|
+
if (!existsSync(setup)) return fail({ code: 'SETUP_MISSING', message: `${setup} is missing` });
|
|
1223
|
+
|
|
1224
|
+
// ── Windows: the OS opened a console for this process; the server lives in it ──
|
|
1225
|
+
if (platform === 'win32' && !background) {
|
|
1226
|
+
log(`starting in this console (port ${port})`);
|
|
1227
|
+
const child = spawnImpl(nodePath, nodeArgs, {
|
|
1228
|
+
cwd: packageRoot,
|
|
1229
|
+
stdio: 'inherit',
|
|
1230
|
+
env: resolveLaunchEnv({ env: launchEnv, nodePath, platform, home, runShell }),
|
|
1231
|
+
});
|
|
1232
|
+
report?.step('spawned');
|
|
1233
|
+
let exited = null;
|
|
1234
|
+
const done = new Promise((res) => {
|
|
1235
|
+
child.once('error', (error) => { exited = { code: 1, error }; res(); });
|
|
1236
|
+
child.once('exit', (code) => { exited = { code: code ?? 1 }; res(); });
|
|
1237
|
+
});
|
|
1238
|
+
const up = await waitForServer({ port, probe, sleep, now, timeoutMs: waitMs, pollMs, stillGoing: () => { readSupervisor(); return !exited; } });
|
|
1239
|
+
lock.release();
|
|
1240
|
+
if (up) {
|
|
1241
|
+
answered();
|
|
1242
|
+
report?.close(); // the console stays for as long as the server does; the beacon does not
|
|
1243
|
+
} else {
|
|
1244
|
+
readSupervisor();
|
|
1245
|
+
fail(supervisorFailure({
|
|
1246
|
+
code: exited ? 'SUPERVISOR_EXITED' : 'NO_ANSWER',
|
|
1247
|
+
message: exited ? `the supervisor exited (${exited.error?.message || `code ${exited.code}`})` : `no answer after ${Math.round(waitMs / 1000)} s`,
|
|
1248
|
+
exitCode: exited?.code,
|
|
1249
|
+
}), `not answering${exited ? ` (exit ${exited.code})` : ''}`);
|
|
1250
|
+
// Still running, just silent: the console stays, the beacon says so and goes.
|
|
1251
|
+
if (!exited && report) { await report.linger(lingerMs); report.close(); }
|
|
1252
|
+
}
|
|
1253
|
+
await done; // keep the console (and the supervisor in it) until the server stops
|
|
1254
|
+
return { outcome: up ? 'started' : 'failed', code: exited?.code ?? 0, port };
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// ── macOS: a Terminal window, exactly like a server started by hand ──
|
|
1258
|
+
if (!background && platform === 'darwin' && args.terminalScript && existsSync(args.terminalScript)) {
|
|
1259
|
+
const asked = now();
|
|
1260
|
+
const opened = runShell('/usr/bin/open', ['-a', 'Terminal', args.terminalScript], { timeout: 15000, stdio: 'ignore' });
|
|
1261
|
+
if (!opened?.error && opened?.status === 0) {
|
|
1262
|
+
log(`opened Terminal (port ${port}) in ${Math.max(0, now() - asked)} ms`);
|
|
1263
|
+
report?.step('terminal');
|
|
1264
|
+
const up = await waitForServer({ port, probe, sleep, now, timeoutMs: waitMs, pollMs, stillGoing: () => { readSupervisor(); return !supervisorEnded(); } });
|
|
1265
|
+
if (up) return answered();
|
|
1266
|
+
// The supervisor this launch saw start is gone and nothing answers:
|
|
1267
|
+
// it is over, and holding the lock would only block the next click.
|
|
1268
|
+
if (supervisorEnded()) {
|
|
1269
|
+
return fail(supervisorFailure({ code: 'SUPERVISOR_EXITED', message: 'the server stopped before it answered (the Terminal window says why)' }));
|
|
1270
|
+
}
|
|
1271
|
+
return gaveUp();
|
|
1272
|
+
}
|
|
1273
|
+
log(`Terminal did not open (${opened?.error?.message || `status ${opened?.status}`}); starting in the background`);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// ── Linux (and the macOS fallback): detached, with the login shell's environment ──
|
|
1277
|
+
let out = 'ignore';
|
|
1278
|
+
try { out = openSync(paths.log, 'a'); } catch {}
|
|
1279
|
+
const child = spawnImpl(nodePath, nodeArgs, {
|
|
1280
|
+
cwd: packageRoot,
|
|
1281
|
+
detached: true,
|
|
1282
|
+
windowsHide: background,
|
|
1283
|
+
stdio: ['ignore', out, out],
|
|
1284
|
+
// Packaged re-entry and isolated tests can supply an already resolved
|
|
1285
|
+
// environment. Do not read a login profile or add host CLI paths twice.
|
|
1286
|
+
env: background && launchEnv.SYNABUN_LAUNCH_ENV === 'resolved'
|
|
1287
|
+
? launchEnv : resolveLaunchEnv({ env: launchEnv, nodePath, platform, home, runShell }),
|
|
1288
|
+
});
|
|
1289
|
+
let exited = null;
|
|
1290
|
+
child.once('error', (error) => { exited = { code: 1, error }; });
|
|
1291
|
+
child.once('exit', (code) => { exited = { code: code ?? 1 }; });
|
|
1292
|
+
child.unref();
|
|
1293
|
+
if (typeof out === 'number') { try { closeSync(out); } catch {} }
|
|
1294
|
+
log(`started in the background (pid ${child.pid}, port ${port}) after ${since()}`);
|
|
1295
|
+
report?.step('spawned');
|
|
1296
|
+
const up = await waitForServer({ port, probe, sleep, now, timeoutMs: waitMs, pollMs, stillGoing: () => { readSupervisor(); return !exited; } });
|
|
1297
|
+
if (up) return answered();
|
|
1298
|
+
if (exited) {
|
|
1299
|
+
readSupervisor();
|
|
1300
|
+
const reason = `the supervisor exited (${exited.error?.message || `code ${exited.code}`})`;
|
|
1301
|
+
return fail(supervisorFailure({ code: 'SUPERVISOR_EXITED', message: reason, exitCode: exited.code }), reason);
|
|
1302
|
+
}
|
|
1303
|
+
return gaveUp();
|
|
1304
|
+
})();
|
|
1305
|
+
} finally {
|
|
1306
|
+
lock.release();
|
|
1307
|
+
}
|
|
1308
|
+
return conclude(result);
|
|
1309
|
+
}
|