fraim-hub 2.0.289 → 2.0.291

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.
@@ -46,15 +46,10 @@ const git_utils_1 = require("../core/utils/git-utils");
46
46
  const path_1 = __importDefault(require("path"));
47
47
  const child_process_1 = require("child_process");
48
48
  const fs_1 = __importDefault(require("fs"));
49
- const net_1 = __importDefault(require("net"));
50
- const http_1 = __importDefault(require("http"));
51
- const tree_kill_1 = __importDefault(require("tree-kill"));
52
- const hub_launch_decision_1 = require("./hub-launch-decision");
53
49
  const hub_runtime_file_1 = require("./hub-runtime-file");
54
50
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
55
- const version_utils_1 = require("../cli/utils/version-utils");
56
51
  const electron_dist_1 = require("./electron-dist");
57
- const process_liveness_1 = require("./process-liveness");
52
+ const hub_instance_reconciliation_1 = require("./hub-instance-reconciliation");
58
53
  function resolveDesktopEntry() {
59
54
  const candidates = [
60
55
  path_1.default.resolve(__dirname, 'desktop-main.js'),
@@ -113,96 +108,6 @@ function openBrowser(url) {
113
108
  const child = (0, child_process_1.spawn)('xdg-open', [url], { detached: true, stdio: 'ignore' });
114
109
  child.unref();
115
110
  }
116
- function killPid(pid) {
117
- try {
118
- if (process.platform === 'win32') {
119
- // /T kills the entire process tree; /F forces termination without prompting.
120
- (0, child_process_1.execFileSync)('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
121
- }
122
- else {
123
- // tree-kill sends SIGTERM to the process group so child Electron helper processes
124
- // are also terminated, not just the root node process (#921).
125
- (0, tree_kill_1.default)(pid, 'SIGTERM');
126
- }
127
- }
128
- catch {
129
- /* already gone */
130
- }
131
- }
132
- // #921: after killing the registered pid, probe ports 43091-43200 for any Hub HTTP
133
- // response. Any Hub found on those ports that is NOT the already-killed pid is an
134
- // orphan (e.g. an older version started via bare `npx` that never wrote hub-runtime.json).
135
- // GET /api/ai-hub/pid identifies the pid; we then kill it and wait for its port to free.
136
- async function fetchHubPid(port) {
137
- return new Promise((resolve) => {
138
- const req = http_1.default.get({ host: '127.0.0.1', port, path: '/api/ai-hub/pid', timeout: 1000 }, (res) => {
139
- if (res.statusCode !== 200) {
140
- res.resume();
141
- return resolve(null);
142
- }
143
- let body = '';
144
- res.on('data', (c) => { body += c; });
145
- res.on('end', () => { try {
146
- resolve(JSON.parse(body).pid ?? null);
147
- }
148
- catch {
149
- resolve(null);
150
- } });
151
- });
152
- req.on('error', () => resolve(null));
153
- req.on('timeout', () => { req.destroy(); resolve(null); });
154
- });
155
- }
156
- async function scanAndKillOrphanHubs(excludePid) {
157
- const HUB_PORT_SCAN_START = 43091;
158
- const HUB_PORT_SCAN_END = 43200;
159
- for (let port = HUB_PORT_SCAN_START; port <= HUB_PORT_SCAN_END; port++) {
160
- const version = await fetchRunningHubVersion(port);
161
- if (!version)
162
- continue; // port not a Hub
163
- const pid = await fetchHubPid(port);
164
- if (pid === null || pid === excludePid)
165
- continue; // same process we already killed
166
- console.log(`Killing orphan Hub (pid ${pid}) on port ${port}`);
167
- killPid(pid);
168
- await waitForPortFree(port);
169
- }
170
- }
171
- function isPortFree(port, timeoutMs = 500) {
172
- return new Promise((resolve) => {
173
- const sock = net_1.default.connect({ host: '127.0.0.1', port }, () => { sock.destroy(); resolve(false); });
174
- sock.on('error', () => resolve(true));
175
- sock.setTimeout(timeoutMs, () => { sock.destroy(); resolve(true); });
176
- });
177
- }
178
- async function waitForPortFree(port, totalMs = 5000) {
179
- const start = Date.now();
180
- while (Date.now() - start < totalMs) {
181
- if (await isPortFree(port))
182
- return;
183
- await new Promise((r) => setTimeout(r, 200));
184
- }
185
- }
186
- function fetchRunningHubVersion(port) {
187
- return new Promise((resolve) => {
188
- const req = http_1.default.get({ host: '127.0.0.1', port, path: '/api/ai-hub/version', timeout: 1000 }, (res) => {
189
- if (res.statusCode !== 200) {
190
- res.resume();
191
- return resolve(null);
192
- }
193
- let body = '';
194
- res.on('data', (c) => { body += c; });
195
- res.on('end', () => { try {
196
- resolve(JSON.parse(body).version ?? null);
197
- }
198
- catch {
199
- resolve(null);
200
- } });
201
- });
202
- req.on('error', () => resolve(null));
203
- req.on('timeout', () => { req.destroy(); resolve(null); });
204
- });
205
- }
206
111
  // #1110: the desktop shell's cold-start cost is work this launcher can neither see nor bound.
207
112
  // Before the Hub can answer /api/ai-hub/version it pays for Electron process boot, top-level
208
113
  // evaluation of the src/ai-hub/server.ts module graph, cert load plus a blocking certutil
@@ -278,7 +183,7 @@ async function waitForDesktopHubReady(child, preferredPort, options = {}) {
278
183
  while (Date.now() - start < timeoutMs) {
279
184
  failIfChildDied();
280
185
  for (const port of readinessProbePorts(fraimDir, runtimeId, preferredPort)) {
281
- const version = await fetchRunningHubVersion(port);
186
+ const version = await (0, hub_instance_reconciliation_1.fetchRunningHubVersion)(port);
282
187
  if (version) {
283
188
  return { port, version };
284
189
  }
@@ -298,32 +203,6 @@ async function waitForDesktopHubReady(child, preferredPort, options = {}) {
298
203
  + `${stillRunning}`
299
204
  + ` Set ${exports.DESKTOP_HUB_READY_TIMEOUT_ENV} to a larger value if this machine needs longer.`);
300
205
  }
301
- async function reconcileRunningHub(flags, runtimeId = 'hub') {
302
- const running = (0, hub_runtime_file_1.readHubRuntimeFile)((0, project_fraim_paths_1.getUserFraimDirPath)(), runtimeId);
303
- const confirmedVersion = running ? await fetchRunningHubVersion(running.port) : null;
304
- const live = !!(running && confirmedVersion && (0, process_liveness_1.isPidAlive)(running.pid));
305
- const effective = live && running ? { ...running, version: confirmedVersion } : null;
306
- const decision = (0, hub_launch_decision_1.decideHubLaunch)({
307
- running: effective,
308
- pidAlive: live,
309
- cliVersion: (0, version_utils_1.getFraimVersion)(),
310
- restart: flags.restart,
311
- noRestart: flags.keepRunning,
312
- });
313
- if (decision.action === 'replace' && effective) {
314
- console.log(`Replacing running Hub (v${effective.version}, pid ${effective.pid}) - ${decision.reason}`);
315
- killPid(effective.pid);
316
- await waitForPortFree(effective.port);
317
- // #921: after killing the registered pid, scan for orphan Hub processes that were
318
- // never registered in hub-runtime.json (e.g. older versions started via bare npx).
319
- if (runtimeId === 'hub') {
320
- await scanAndKillOrphanHubs(effective.pid);
321
- }
322
- }
323
- else if (decision.action === 'focus-existing' && effective) {
324
- console.log(`A Hub (v${effective.version}) is already running - focusing it. Use --restart to replace it.`);
325
- }
326
- }
327
206
  async function runHub(options) {
328
207
  const { AiHubServer, findAvailablePort } = await Promise.resolve().then(() => __importStar(require('./server')));
329
208
  const preferredPort = options.port || (0, git_utils_1.getPort)() + 100;
@@ -332,7 +211,7 @@ async function runHub(options) {
332
211
  if (options.open) {
333
212
  const wantDesktop = !options.browser;
334
213
  if (wantDesktop) {
335
- await reconcileRunningHub({ restart: !!options.restart, keepRunning: !!options.keepRunning }, runtimeId);
214
+ await (0, hub_instance_reconciliation_1.reconcileRunningHub)({ restart: !!options.restart, keepRunning: !!options.keepRunning }, runtimeId);
336
215
  }
337
216
  const desktopChild = wantDesktop ? await openDesktopWindow(projectPath, preferredPort, runtimeId) : null;
338
217
  if (!desktopChild) {
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const electron_1 = require("electron");
7
+ const path_1 = __importDefault(require("path"));
8
+ const hub_app_materializer_1 = require("./hub-app-materializer");
9
+ // Issue #1415: this is the packaged Electron `main` entry (see packages/fraim-hub/package.json
10
+ // `"main"`). It is deliberately thin and changes rarely: its only job is to resolve which version
11
+ // of the actual Hub application (the code in desktop-main.ts) to run — the latest published to
12
+ // npm, materializing it once per machine if not already cached — and hand off to it. The native
13
+ // installer only ever needs to ship a new build of this file and the Electron shell itself;
14
+ // every ordinary `fraim-hub` release reaches an already-installed user the moment this
15
+ // resolution runs on their next launch, with no new installer download.
16
+ const BUNDLED_ENTRY_PATH = path_1.default.join(__dirname, 'desktop-main.js');
17
+ async function bootstrapFrom(entryPath) {
18
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
19
+ const entry = require(entryPath);
20
+ await entry.bootstrap();
21
+ }
22
+ async function launch() {
23
+ // Issue #1415: deterministic escape hatch for integration testing — skips the real npm
24
+ // resolution (and its dependence on whatever happens to be published at test time) so a test can
25
+ // prove the Electron main-process boot chain itself (resolveHubAppEntry -> require -> bootstrap
26
+ // -> healthy server) without a network round-trip. Not used in any real launch path; mirrors the
27
+ // existing `FRAIM_INSTALLER_LIFECYCLE_TEST` kill-switch pattern already used for testability.
28
+ const resolved = process.env.FRAIM_HUB_SKIP_MATERIALIZE === '1'
29
+ ? { entryPath: BUNDLED_ENTRY_PATH, source: 'bundled', version: electron_1.app.getVersion() }
30
+ : await (0, hub_app_materializer_1.resolveHubAppEntry)({
31
+ bundledVersion: electron_1.app.getVersion(),
32
+ bundledEntryPath: BUNDLED_ENTRY_PATH,
33
+ onProgress: (message) => console.log(`[fraim-hub-launcher] ${message}`),
34
+ }).catch((error) => {
35
+ // resolveHubAppEntry is designed to never throw (every failure mode falls back internally),
36
+ // but a launch must survive even a bug in that resolution rather than fail here.
37
+ console.error('[fraim-hub-launcher] resolution failed unexpectedly; using the bundled build:', error);
38
+ return { entryPath: BUNDLED_ENTRY_PATH, source: 'bundled', version: electron_1.app.getVersion() };
39
+ });
40
+ console.log(`[fraim-hub-launcher] running FRAIM Hub ${resolved.version} (${resolved.source})`);
41
+ try {
42
+ await bootstrapFrom(resolved.entryPath);
43
+ }
44
+ catch (error) {
45
+ if (resolved.entryPath === BUNDLED_ENTRY_PATH)
46
+ throw error;
47
+ // A materialized copy that fails to load (corrupt cache, an ABI mismatch some future
48
+ // dependency introduces) must not strand the user on a broken launch — fall back to the
49
+ // build that shipped with this installer, which is always known-good for this Electron binary.
50
+ console.error(`[fraim-hub-launcher] materialized FRAIM Hub ${resolved.version} failed to load; falling back to the bundled build:`, error);
51
+ await bootstrapFrom(BUNDLED_ENTRY_PATH);
52
+ }
53
+ }
54
+ if (process.versions.electron && process.type !== 'renderer') {
55
+ launch().catch((error) => {
56
+ console.error(error instanceof Error ? error.message : error);
57
+ try {
58
+ electron_1.dialog.showErrorBox('FRAIM Hub failed to start', error instanceof Error ? error.message : String(error));
59
+ }
60
+ catch {
61
+ // dialog can be unavailable this early in rare startup failures; the console error above
62
+ // is the fallback record.
63
+ }
64
+ electron_1.app.exit(1);
65
+ });
66
+ }
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.launchDesktopShell = launchDesktopShell;
7
+ exports.bootstrap = bootstrap;
7
8
  const electron_1 = require("electron");
8
9
  const path_1 = __importDefault(require("path"));
9
10
  const fs_1 = __importDefault(require("fs"));
@@ -20,6 +21,8 @@ const server_2 = require("../first-run/server");
20
21
  const session_service_1 = require("../first-run/session-service");
21
22
  const fraim_mcp_latest_launcher_1 = require("../cli/mcp/fraim-mcp-latest-launcher");
22
23
  const desktop_auto_updater_1 = require("./desktop-auto-updater");
24
+ const hub_app_materializer_1 = require("./hub-app-materializer");
25
+ const hub_instance_reconciliation_1 = require("./hub-instance-reconciliation");
23
26
  // Keep installed, running, and user-pinned Windows shortcuts grouped under the
24
27
  // stable identity declared in packages/fraim-hub/package.json.
25
28
  electron_1.app.setAppUserModelId('ai.fraim.hub');
@@ -73,7 +76,7 @@ function configurePackagedFraimRuntime() {
73
76
  if (!electron_1.app.isPackaged && process.env.FRAIM_DESKTOP_FIRST_RUN !== '1')
74
77
  return;
75
78
  process.env.FRAIM_PACKAGED_CLI_EXECUTABLE = process.execPath;
76
- process.env.FRAIM_PACKAGED_CLI_SCRIPT = path_1.default.join(electron_1.app.getAppPath(), 'node_modules', 'fraim', 'bin', 'fraim.js');
79
+ process.env.FRAIM_PACKAGED_CLI_SCRIPT = (0, hub_app_materializer_1.resolvePackagedCliScriptPath)(__dirname);
77
80
  (0, fraim_mcp_latest_launcher_1.ensureFraimMcpLatestLauncher)();
78
81
  }
79
82
  function shouldShowEmbeddedFirstRun() {
@@ -133,11 +136,65 @@ async function configureAutoUpdater() {
133
136
  });
134
137
  return result.action === 'installing';
135
138
  }
136
- function checkForUpdateAfterSecondInstance() {
139
+ // Issue #1415: shared by every path that needs to know "is a newer FRAIM Hub published than the
140
+ // one currently running" — the automatic second-instance/activate recheck below, and the
141
+ // manually-triggered restart from the web UI's update badge (see restartToLatestNow). Compares
142
+ // `getFraimVersion()` (not `app.getVersion()`), because it resolves from wherever the
143
+ // currently-running code actually lives — the bundled app.asar or a previously materialized copy
144
+ // — while `app.getVersion()` always names the former.
145
+ async function resolveLatestHubAppVersion() {
146
+ const runningVersion = (0, version_utils_1.getFraimVersion)();
147
+ const resolved = await (0, hub_app_materializer_1.resolveHubAppEntry)({
148
+ bundledVersion: runningVersion,
149
+ bundledEntryPath: __filename,
150
+ });
151
+ return { upgradable: resolved.version !== runningVersion, latest: resolved.version };
152
+ }
153
+ // Issue #1415: a second-instance activation (the user re-clicking the icon while the Hub is
154
+ // already running) is the common case once login-item autostart + hide-to-tray keep the Hub
155
+ // resident for days, and it is the one launch-shaped event where the desktop-launcher.ts
156
+ // materializer never gets a chance to run — bootstrap() has already won the single-instance lock
157
+ // by the time it fires. Without this check, an already-running Hub could only ever pick up a new
158
+ // `fraim-hub` release through the slower electron-updater feed below, which is the exact lagging
159
+ // signal #1415 was filed about.
160
+ async function checkForHubAppUpdateOnSecondInstance() {
161
+ const { upgradable, latest } = await resolveLatestHubAppVersion();
162
+ if (!upgradable)
163
+ return false;
164
+ console.log(`[fraim] newer FRAIM Hub ${latest} available (was running ${(0, version_utils_1.getFraimVersion)()}); restarting to apply it`);
165
+ electron_1.app.relaunch();
166
+ electron_1.app.quit();
167
+ return true;
168
+ }
169
+ // Issue #1415: the web UI's persistent update badge (#1379) used to just tell the user to quit
170
+ // and relaunch by hand. This is the same "resolve latest, relaunch" as the automatic checks above,
171
+ // just triggered by an explicit click instead of a relaunch-shaped Electron event — wired into
172
+ // AiHubServer as the `restartToLatest` option (see launchDesktopShell) so POST
173
+ // /api/ai-hub/restart-to-latest can call it. The relaunch/quit is deferred a beat so the HTTP
174
+ // response reaches the browser (which uses it to start polling for the new instance) before this
175
+ // process starts tearing down.
176
+ async function restartToLatestNow() {
177
+ const { upgradable, latest } = await resolveLatestHubAppVersion();
178
+ if (!upgradable)
179
+ return { restarting: false, latest };
180
+ console.log(`[fraim] manual restart requested; relaunching to FRAIM Hub ${latest}`);
181
+ setTimeout(() => {
182
+ electron_1.app.relaunch();
183
+ electron_1.app.quit();
184
+ }, 300);
185
+ return { restarting: true, latest };
186
+ }
187
+ // Issue #1415: shared by both re-launch-shaped events on an already-running Hub — Windows/Linux's
188
+ // `second-instance` (a new process lost the single-instance lock) and macOS's `activate` (the
189
+ // Dock icon click that never spawns a new process at all, so `second-instance` never fires there).
190
+ // Before this, neither path re-checked npm-latest, only the older, slower electron-updater feed.
191
+ function checkForRelaunchAttemptUpdate() {
137
192
  if (!electron_1.app.isPackaged || process.env.FRAIM_INSTALLER_LIFECYCLE_TEST === '1')
138
193
  return;
139
- void configureAutoUpdater().catch((err) => {
140
- console.warn('[fraim] second-instance update check failed:', err);
194
+ void checkForHubAppUpdateOnSecondInstance()
195
+ .then((restarting) => (restarting ? undefined : configureAutoUpdater().then(() => undefined)))
196
+ .catch((err) => {
197
+ console.warn('[fraim] relaunch-attempt update check failed:', err);
141
198
  });
142
199
  }
143
200
  // ---------------------------------------------------------------------------
@@ -404,6 +461,7 @@ async function launchDesktopShell(options) {
404
461
  });
405
462
  return result.canceled || result.filePaths.length === 0 ? null : result.filePaths[0];
406
463
  },
464
+ restartToLatest: restartToLatestNow,
407
465
  });
408
466
  await server.start(httpPort);
409
467
  const resolvedProjectPath = server.getProjectPath();
@@ -469,6 +527,9 @@ async function launchDesktopShell(options) {
469
527
  // ---------------------------------------------------------------------------
470
528
  // Bootstrap
471
529
  // ---------------------------------------------------------------------------
530
+ // Issue #1415: exported so `desktop-launcher.ts` can `require()` this module (whether the copy
531
+ // bundled in this installer or a materialized npm-latest copy) and invoke bootstrap explicitly,
532
+ // instead of relying on the module's own top-level self-execution below.
472
533
  async function bootstrap() {
473
534
  const options = parseArgs(process.argv.slice(2));
474
535
  applyUserDataOverride();
@@ -483,7 +544,7 @@ async function bootstrap() {
483
544
  return;
484
545
  }
485
546
  electron_1.app.on('second-instance', () => {
486
- void electron_1.app.whenReady().then(checkForUpdateAfterSecondInstance);
547
+ void electron_1.app.whenReady().then(checkForRelaunchAttemptUpdate);
487
548
  if (mainWindow) {
488
549
  mainWindow.show();
489
550
  mainWindow.focus();
@@ -492,6 +553,25 @@ async function bootstrap() {
492
553
  await electron_1.app.whenReady();
493
554
  electron_1.app.setName(displayName(options.runtimeId));
494
555
  configurePackagedFraimRuntime();
556
+ // Issue #1415 (follow-up): app.requestSingleInstanceLock() above only sees another instance of
557
+ // *this same identity* — it cannot see a Hub already running via a different launch path (e.g.
558
+ // `npx fraim-hub`'s raw electron.exe, confirmed live to end up under a different userData scope
559
+ // than this packaged app). hub-runtime.json is the identity-agnostic record both launch paths
560
+ // already write (#755); reconcile against it — and sweep for unregistered orphans (#921) —
561
+ // before binding our own port, so a direct double-click launch takes over whatever Hub is
562
+ // already running instead of silently opening a second, independent one on a fallback port.
563
+ // `restart: true` because a materialized/bundled launch is by construction already resolved to
564
+ // the current latest — there is nothing to compare, only to replace. Runs after whenReady, like
565
+ // every other network-touching step in this function (checkForRelaunchAttemptUpdate,
566
+ // configureAutoUpdater) — reconcileRunningHub makes real HTTP probes and process kills, and this
567
+ // keeps it on the same footing as those instead of racing Electron's own startup.
568
+ // Gated on the same FRAIM_AI_HUB_FAKE_HOST flag as the single-instance lock above, not on
569
+ // FRAIM_INSTALLER_LIFECYCLE_TEST: the orphan sweep probes real system ports 43091-43200
570
+ // regardless of FRAIM_USER_DIR isolation, so a test double (FAKE_HOST=1) skips it for the same
571
+ // reason it skips the lock — it must never disturb a real Hub already running on this machine.
572
+ if (!skipSingleInstance) {
573
+ await (0, hub_instance_reconciliation_1.reconcileRunningHub)({ restart: true, keepRunning: false }, options.runtimeId);
574
+ }
495
575
  // macOS reads the dock icon from the app bundle once packaged, but an
496
576
  // unpackaged `npm run hub:desktop` / `npx fraim-hub` run shows Electron's
497
577
  // own icon there unless we set it explicitly (#1329).
@@ -508,7 +588,10 @@ async function bootstrap() {
508
588
  return;
509
589
  }
510
590
  electron_1.app.on('activate', () => {
511
- // macOS: clicking dock icon re-shows the window
591
+ // macOS: clicking dock icon re-shows the window. Unlike Windows/Linux, this never spawns a
592
+ // second process (so 'second-instance' never fires here) — it is macOS's equivalent
593
+ // relaunch-attempt event, so it gets the same npm-latest re-check (#1415).
594
+ checkForRelaunchAttemptUpdate();
512
595
  if (mainWindow) {
513
596
  mainWindow.show();
514
597
  mainWindow.focus();
@@ -533,7 +616,12 @@ async function bootstrap() {
533
616
  });
534
617
  await launchDesktopShell(options);
535
618
  }
536
- if (process.versions.electron && process.type !== 'renderer') {
619
+ // Issue #1415: self-execution is now gated on `require.main === module` in addition to the
620
+ // existing Electron-main-process check, so `desktop-launcher.ts` requiring this file (bundled or
621
+ // materialized) does not double-run bootstrap — the launcher calls the exported `bootstrap`
622
+ // explicitly instead. Direct invocation (`npm run hub:desktop`, Electron-launching test suites)
623
+ // is unaffected: this file is still `require.main` in that case.
624
+ if (require.main === module && process.versions.electron && process.type !== 'renderer') {
537
625
  bootstrap().catch(async (error) => {
538
626
  console.error(error instanceof Error ? error.message : error);
539
627
  await stopServerOnce();