fraim-hub 2.0.289 → 2.0.290
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,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,7 @@ 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");
|
|
23
25
|
// Keep installed, running, and user-pinned Windows shortcuts grouped under the
|
|
24
26
|
// stable identity declared in packages/fraim-hub/package.json.
|
|
25
27
|
electron_1.app.setAppUserModelId('ai.fraim.hub');
|
|
@@ -73,7 +75,7 @@ function configurePackagedFraimRuntime() {
|
|
|
73
75
|
if (!electron_1.app.isPackaged && process.env.FRAIM_DESKTOP_FIRST_RUN !== '1')
|
|
74
76
|
return;
|
|
75
77
|
process.env.FRAIM_PACKAGED_CLI_EXECUTABLE = process.execPath;
|
|
76
|
-
process.env.FRAIM_PACKAGED_CLI_SCRIPT =
|
|
78
|
+
process.env.FRAIM_PACKAGED_CLI_SCRIPT = (0, hub_app_materializer_1.resolvePackagedCliScriptPath)(__dirname);
|
|
77
79
|
(0, fraim_mcp_latest_launcher_1.ensureFraimMcpLatestLauncher)();
|
|
78
80
|
}
|
|
79
81
|
function shouldShowEmbeddedFirstRun() {
|
|
@@ -133,11 +135,39 @@ async function configureAutoUpdater() {
|
|
|
133
135
|
});
|
|
134
136
|
return result.action === 'installing';
|
|
135
137
|
}
|
|
136
|
-
|
|
138
|
+
// Issue #1415: a second-instance activation (the user re-clicking the icon while the Hub is
|
|
139
|
+
// already running) is the common case once login-item autostart + hide-to-tray keep the Hub
|
|
140
|
+
// resident for days, and it is the one launch-shaped event where the desktop-launcher.ts
|
|
141
|
+
// materializer never gets a chance to run — bootstrap() has already won the single-instance lock
|
|
142
|
+
// by the time it fires. Without this check, an already-running Hub could only ever pick up a new
|
|
143
|
+
// `fraim-hub` release through the slower electron-updater feed below, which is the exact lagging
|
|
144
|
+
// signal #1415 was filed about. `getFraimVersion()` (not `app.getVersion()`) is compared, because
|
|
145
|
+
// it resolves from wherever the currently-running code actually lives — the bundled app.asar or a
|
|
146
|
+
// previously materialized copy — while `app.getVersion()` always names the former.
|
|
147
|
+
async function checkForHubAppUpdateOnSecondInstance() {
|
|
148
|
+
const runningVersion = (0, version_utils_1.getFraimVersion)();
|
|
149
|
+
const resolved = await (0, hub_app_materializer_1.resolveHubAppEntry)({
|
|
150
|
+
bundledVersion: runningVersion,
|
|
151
|
+
bundledEntryPath: __filename,
|
|
152
|
+
});
|
|
153
|
+
if (resolved.version === runningVersion)
|
|
154
|
+
return false;
|
|
155
|
+
console.log(`[fraim] newer FRAIM Hub ${resolved.version} available (was running ${runningVersion}); restarting to apply it`);
|
|
156
|
+
electron_1.app.relaunch();
|
|
157
|
+
electron_1.app.quit();
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
// Issue #1415: shared by both re-launch-shaped events on an already-running Hub — Windows/Linux's
|
|
161
|
+
// `second-instance` (a new process lost the single-instance lock) and macOS's `activate` (the
|
|
162
|
+
// Dock icon click that never spawns a new process at all, so `second-instance` never fires there).
|
|
163
|
+
// Before this, neither path re-checked npm-latest, only the older, slower electron-updater feed.
|
|
164
|
+
function checkForRelaunchAttemptUpdate() {
|
|
137
165
|
if (!electron_1.app.isPackaged || process.env.FRAIM_INSTALLER_LIFECYCLE_TEST === '1')
|
|
138
166
|
return;
|
|
139
|
-
void
|
|
140
|
-
|
|
167
|
+
void checkForHubAppUpdateOnSecondInstance()
|
|
168
|
+
.then((restarting) => (restarting ? undefined : configureAutoUpdater().then(() => undefined)))
|
|
169
|
+
.catch((err) => {
|
|
170
|
+
console.warn('[fraim] relaunch-attempt update check failed:', err);
|
|
141
171
|
});
|
|
142
172
|
}
|
|
143
173
|
// ---------------------------------------------------------------------------
|
|
@@ -469,6 +499,9 @@ async function launchDesktopShell(options) {
|
|
|
469
499
|
// ---------------------------------------------------------------------------
|
|
470
500
|
// Bootstrap
|
|
471
501
|
// ---------------------------------------------------------------------------
|
|
502
|
+
// Issue #1415: exported so `desktop-launcher.ts` can `require()` this module (whether the copy
|
|
503
|
+
// bundled in this installer or a materialized npm-latest copy) and invoke bootstrap explicitly,
|
|
504
|
+
// instead of relying on the module's own top-level self-execution below.
|
|
472
505
|
async function bootstrap() {
|
|
473
506
|
const options = parseArgs(process.argv.slice(2));
|
|
474
507
|
applyUserDataOverride();
|
|
@@ -483,7 +516,7 @@ async function bootstrap() {
|
|
|
483
516
|
return;
|
|
484
517
|
}
|
|
485
518
|
electron_1.app.on('second-instance', () => {
|
|
486
|
-
void electron_1.app.whenReady().then(
|
|
519
|
+
void electron_1.app.whenReady().then(checkForRelaunchAttemptUpdate);
|
|
487
520
|
if (mainWindow) {
|
|
488
521
|
mainWindow.show();
|
|
489
522
|
mainWindow.focus();
|
|
@@ -508,7 +541,10 @@ async function bootstrap() {
|
|
|
508
541
|
return;
|
|
509
542
|
}
|
|
510
543
|
electron_1.app.on('activate', () => {
|
|
511
|
-
// macOS: clicking dock icon re-shows the window
|
|
544
|
+
// macOS: clicking dock icon re-shows the window. Unlike Windows/Linux, this never spawns a
|
|
545
|
+
// second process (so 'second-instance' never fires here) — it is macOS's equivalent
|
|
546
|
+
// relaunch-attempt event, so it gets the same npm-latest re-check (#1415).
|
|
547
|
+
checkForRelaunchAttemptUpdate();
|
|
512
548
|
if (mainWindow) {
|
|
513
549
|
mainWindow.show();
|
|
514
550
|
mainWindow.focus();
|
|
@@ -533,7 +569,12 @@ async function bootstrap() {
|
|
|
533
569
|
});
|
|
534
570
|
await launchDesktopShell(options);
|
|
535
571
|
}
|
|
536
|
-
|
|
572
|
+
// Issue #1415: self-execution is now gated on `require.main === module` in addition to the
|
|
573
|
+
// existing Electron-main-process check, so `desktop-launcher.ts` requiring this file (bundled or
|
|
574
|
+
// materialized) does not double-run bootstrap — the launcher calls the exported `bootstrap`
|
|
575
|
+
// explicitly instead. Direct invocation (`npm run hub:desktop`, Electron-launching test suites)
|
|
576
|
+
// is unaffected: this file is still `require.main` in that case.
|
|
577
|
+
if (require.main === module && process.versions.electron && process.type !== 'renderer') {
|
|
537
578
|
bootstrap().catch(async (error) => {
|
|
538
579
|
console.error(error instanceof Error ? error.message : error);
|
|
539
580
|
await stopServerOnce();
|
|
@@ -0,0 +1,285 @@
|
|
|
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
|
+
exports.HUB_APP_ENTRY_RELATIVE = exports.HUB_APP_PACKAGE_NAME = void 0;
|
|
7
|
+
exports.resolvePackagedCliScriptPath = resolvePackagedCliScriptPath;
|
|
8
|
+
exports.planHubAppMaterialization = planHubAppMaterialization;
|
|
9
|
+
exports.resolveHubAppCacheDir = resolveHubAppCacheDir;
|
|
10
|
+
exports.isHubAppCacheComplete = isHubAppCacheComplete;
|
|
11
|
+
exports.listCachedHubAppVersions = listCachedHubAppVersions;
|
|
12
|
+
exports.ensureMaterializedHubApp = ensureMaterializedHubApp;
|
|
13
|
+
exports.resolveHubAppEntry = resolveHubAppEntry;
|
|
14
|
+
const fs_1 = __importDefault(require("fs"));
|
|
15
|
+
const path_1 = __importDefault(require("path"));
|
|
16
|
+
const child_process_1 = require("child_process");
|
|
17
|
+
const semver_1 = __importDefault(require("semver"));
|
|
18
|
+
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
19
|
+
const hub_latest_version_1 = require("./hub-latest-version");
|
|
20
|
+
const managed_node_runtime_1 = require("../cli/utils/managed-node-runtime");
|
|
21
|
+
// Issue #1415: a packaged FRAIM Hub install can already be behind npm's published "latest" the
|
|
22
|
+
// moment it is downloaded, and its own electron-updater feed (#1387) only advances once a full
|
|
23
|
+
// signed desktop build has finished — which happens on a much slower cadence than npm publishes.
|
|
24
|
+
// This module reconciles the two: on every launch, resolve the version actually published to npm
|
|
25
|
+
// (`fraim-hub` and `fraim` are lockstep-versioned, so `hub-latest-version.ts`'s existing 5-minute-
|
|
26
|
+
// cached npm lookup is reused rather than duplicated), materialize that version's application code
|
|
27
|
+
// once per machine (mirroring `electron-dist.ts`'s "extract once, reuse forever" shared-dist
|
|
28
|
+
// pattern), and hand back the entry point to run. The Electron shell itself — installed once via
|
|
29
|
+
// the native installer — never needs a new install for an ordinary `fraim-hub` point release; only
|
|
30
|
+
// the application payload this module resolves does.
|
|
31
|
+
//
|
|
32
|
+
// The decision (`planHubAppMaterialization`) is a pure function so the whole staleness/cache/
|
|
33
|
+
// offline matrix is unit-testable without touching npm, disk, or Electron — the same separation
|
|
34
|
+
// `hub-launch-decision.ts` (#755) and `desktop-auto-updater.ts` (#1387) already established for
|
|
35
|
+
// this exact class of problem.
|
|
36
|
+
exports.HUB_APP_PACKAGE_NAME = 'fraim-hub';
|
|
37
|
+
/** Relative path, from a materialized (or bundled) package root, to the Electron main entry. */
|
|
38
|
+
exports.HUB_APP_ENTRY_RELATIVE = path_1.default.join('dist', 'src', 'ai-hub', 'desktop-main.js');
|
|
39
|
+
/**
|
|
40
|
+
* Where the packaged CLI/MCP shim (`ensureFraimMcpLatestLauncher()`) should find `fraim`'s CLI
|
|
41
|
+
* script, derived from `desktop-main.ts`'s own directory (`<packageRoot>/dist/src/ai-hub/`) rather
|
|
42
|
+
* than Electron's `app.getAppPath()`. `desktop-launcher.ts` may have loaded `desktop-main.js` from a
|
|
43
|
+
* materialized `fraim-hub@<latest>` copy under `~/.fraim/bin/hub-app/<version>/` rather than from
|
|
44
|
+
* the original app.asar, and `app.getAppPath()` always names the latter regardless of which copy is
|
|
45
|
+
* actually running. `moduleDir` tracks whichever copy is really executing. Kept in this
|
|
46
|
+
* Electron-free module (rather than inlined in `desktop-main.ts`) so it is unit-testable without an
|
|
47
|
+
* Electron `app` object.
|
|
48
|
+
*/
|
|
49
|
+
function resolvePackagedCliScriptPath(moduleDir) {
|
|
50
|
+
return path_1.default.join(moduleDir, '..', '..', '..', 'node_modules', 'fraim', 'bin', 'fraim.js');
|
|
51
|
+
}
|
|
52
|
+
function pickNewestComplete(cachedVersions) {
|
|
53
|
+
const versions = cachedVersions.filter((entry) => entry.complete).map((entry) => entry.version);
|
|
54
|
+
if (versions.length === 0)
|
|
55
|
+
return null;
|
|
56
|
+
return versions.reduce((newest, candidate) => (semver_1.default.gt(candidate, newest) ? candidate : newest));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Decide whether to run a cached materialized version, materialize a new one, or fall back to the
|
|
60
|
+
* version bundled with this installer. Pure: no fs, no network, no Electron.
|
|
61
|
+
*/
|
|
62
|
+
function planHubAppMaterialization(input) {
|
|
63
|
+
const { bundledVersion, latestPublishedVersion, cachedVersions } = input;
|
|
64
|
+
if (latestPublishedVersion === null) {
|
|
65
|
+
const newestCached = pickNewestComplete(cachedVersions);
|
|
66
|
+
if (newestCached) {
|
|
67
|
+
return {
|
|
68
|
+
action: 'use-cached',
|
|
69
|
+
version: newestCached,
|
|
70
|
+
reason: 'npm registry unreachable; reusing the newest complete cached version',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
action: 'use-bundled',
|
|
75
|
+
version: bundledVersion,
|
|
76
|
+
reason: 'npm registry unreachable and no cached version available; falling back to the version bundled with this installer',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (latestPublishedVersion === bundledVersion) {
|
|
80
|
+
return {
|
|
81
|
+
action: 'use-bundled',
|
|
82
|
+
version: bundledVersion,
|
|
83
|
+
reason: 'the installed shell already matches the latest published version',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const alreadyMaterialized = cachedVersions.some((entry) => entry.complete && entry.version === latestPublishedVersion);
|
|
87
|
+
if (alreadyMaterialized) {
|
|
88
|
+
return {
|
|
89
|
+
action: 'use-cached',
|
|
90
|
+
version: latestPublishedVersion,
|
|
91
|
+
reason: 'latest published version is already materialized on disk',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
action: 'materialize',
|
|
96
|
+
version: latestPublishedVersion,
|
|
97
|
+
reason: `latest published version ${latestPublishedVersion} is not yet materialized`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Cache directory layout: ~/.fraim/bin/hub-app/<version>/
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Same shape as an npm version, optionally with a prerelease/build suffix. Anchoring on this
|
|
104
|
+
// before ever joining a version into a path means a hostile string (`../../..`, an absolute path)
|
|
105
|
+
// can never escape the versioned cache directory.
|
|
106
|
+
const SAFE_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
107
|
+
function assertPathSafeVersion(version) {
|
|
108
|
+
if (!SAFE_VERSION.test(version)) {
|
|
109
|
+
throw new Error(`Refusing to use "${version}" as a fraim-hub version: expected an exact release such as 2.0.290.`);
|
|
110
|
+
}
|
|
111
|
+
return version;
|
|
112
|
+
}
|
|
113
|
+
function resolveHubAppCacheDir(version, fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)()) {
|
|
114
|
+
return path_1.default.join(fraimDir, 'bin', 'hub-app', assertPathSafeVersion(version));
|
|
115
|
+
}
|
|
116
|
+
/** Whether `dir` holds a complete materialized install for exactly `version`. */
|
|
117
|
+
function isHubAppCacheComplete(dir, version) {
|
|
118
|
+
try {
|
|
119
|
+
const recorded = fs_1.default.readFileSync(path_1.default.join(dir, 'version'), 'utf8').trim();
|
|
120
|
+
if (recorded !== version)
|
|
121
|
+
return false;
|
|
122
|
+
return fs_1.default.existsSync(path_1.default.join(dir, exports.HUB_APP_ENTRY_RELATIVE));
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Every version directory under the cache root that is a complete, trustworthy install. */
|
|
129
|
+
function listCachedHubAppVersions(fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)()) {
|
|
130
|
+
const root = path_1.default.join(fraimDir, 'bin', 'hub-app');
|
|
131
|
+
let entries;
|
|
132
|
+
try {
|
|
133
|
+
entries = fs_1.default.readdirSync(root, { withFileTypes: true });
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
const versions = [];
|
|
139
|
+
for (const entry of entries) {
|
|
140
|
+
if (!entry.isDirectory() || !semver_1.default.valid(entry.name))
|
|
141
|
+
continue;
|
|
142
|
+
if (isHubAppCacheComplete(path_1.default.join(root, entry.name), entry.name)) {
|
|
143
|
+
versions.push({ version: entry.name, complete: true });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return versions;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Default installer: `npm install fraim-hub@<version>` into a scratch prefix, then flatten the
|
|
150
|
+
* installed package up to `destDir` so callers can treat `destDir` itself as the package root
|
|
151
|
+
* (`destDir/dist/...`), with the package's own dependencies left as siblings under
|
|
152
|
+
* `destDir/node_modules/` where Node's module resolution finds them from any depth under `destDir`.
|
|
153
|
+
* A fresh, empty `--prefix` install of a single package has nothing to conflict with, so npm hoists
|
|
154
|
+
* every dependency to that top level; nothing here relies on nested-dependency resolution.
|
|
155
|
+
*
|
|
156
|
+
* The `npm` executable itself is resolved via `ensureManagedNpm()` (system PATH, an already-managed
|
|
157
|
+
* runtime, or a freshly downloaded one) rather than assumed present on PATH — a genuinely GUI-only
|
|
158
|
+
* install (native installer only, no CLI ever run) has neither system npm nor FRAIM's own managed
|
|
159
|
+
* one, since first-run's embedded-desktop path only needs Electron's own bundled Node for its own
|
|
160
|
+
* purposes and never downloads a standalone npm (see managed-node-runtime.ts's own header comment).
|
|
161
|
+
*/
|
|
162
|
+
// Node's spawnSync fails with EINVAL when a `.cmd` shim (npm's own launcher on Windows) is
|
|
163
|
+
// invoked directly without a shell — the same issue `fraim-mcp-latest-launcher.ts` already works
|
|
164
|
+
// around for `npm`/`npx`. Mirrors that exact fix: run through cmd.exe with args quoted, rather
|
|
165
|
+
// than inventing a different approach for this second call site.
|
|
166
|
+
function quoteCmdArg(arg) {
|
|
167
|
+
return /[\s"&|<>^]/.test(arg) ? `"${arg.replace(/"/g, '""')}"` : arg;
|
|
168
|
+
}
|
|
169
|
+
function runNpmInstall(npmPath, args) {
|
|
170
|
+
if (process.platform !== 'win32') {
|
|
171
|
+
return (0, child_process_1.spawnSync)(npmPath, args, { encoding: 'utf8' });
|
|
172
|
+
}
|
|
173
|
+
return (0, child_process_1.spawnSync)(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', [quoteCmdArg(npmPath), ...args.map(quoteCmdArg)].join(' ')], { encoding: 'utf8' });
|
|
174
|
+
}
|
|
175
|
+
const defaultInstallPackage = async (version, destDir) => {
|
|
176
|
+
fs_1.default.mkdirSync(destDir, { recursive: true });
|
|
177
|
+
const npm = await (0, managed_node_runtime_1.ensureManagedNpm)();
|
|
178
|
+
const result = runNpmInstall(npm.npmPath, ['install', `${exports.HUB_APP_PACKAGE_NAME}@${version}`, '--no-save', '--omit=dev', '--prefix', destDir]);
|
|
179
|
+
if (result.status !== 0) {
|
|
180
|
+
const detail = result.stderr || result.stdout || result.error?.message || 'unknown error';
|
|
181
|
+
throw new Error(`npm install ${exports.HUB_APP_PACKAGE_NAME}@${version} failed via ${npm.source} npm (exit ${result.status}): ${detail}`);
|
|
182
|
+
}
|
|
183
|
+
const installedRoot = path_1.default.join(destDir, 'node_modules', exports.HUB_APP_PACKAGE_NAME);
|
|
184
|
+
if (!fs_1.default.existsSync(installedRoot)) {
|
|
185
|
+
throw new Error(`npm install ${exports.HUB_APP_PACKAGE_NAME}@${version} did not produce ${installedRoot}`);
|
|
186
|
+
}
|
|
187
|
+
for (const entry of fs_1.default.readdirSync(installedRoot)) {
|
|
188
|
+
const from = path_1.default.join(installedRoot, entry);
|
|
189
|
+
const to = path_1.default.join(destDir, entry);
|
|
190
|
+
if (entry === 'node_modules' && fs_1.default.existsSync(to)) {
|
|
191
|
+
// Only reachable if npm nested a dependency inside fraim-hub's own node_modules despite the
|
|
192
|
+
// empty prefix (a version conflict with itself is not possible, but defend anyway): merge
|
|
193
|
+
// child-by-child rather than clobbering the siblings already hoisted to destDir/node_modules.
|
|
194
|
+
for (const nested of fs_1.default.readdirSync(from)) {
|
|
195
|
+
fs_1.default.renameSync(path_1.default.join(from, nested), path_1.default.join(to, nested));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
fs_1.default.renameSync(from, to);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
fs_1.default.rmSync(installedRoot, { recursive: true, force: true });
|
|
203
|
+
};
|
|
204
|
+
/**
|
|
205
|
+
* Return the entry path for `version`, materializing it first if it is not already a complete
|
|
206
|
+
* cache entry. Returns immediately (no npm invocation) when a complete entry already exists.
|
|
207
|
+
*/
|
|
208
|
+
async function ensureMaterializedHubApp(version, options = {}) {
|
|
209
|
+
const fraimDir = options.fraimDir ?? (0, project_fraim_paths_1.getUserFraimDirPath)();
|
|
210
|
+
const installPackage = options.installPackage ?? defaultInstallPackage;
|
|
211
|
+
const destDir = resolveHubAppCacheDir(version, fraimDir);
|
|
212
|
+
const entryPath = path_1.default.join(destDir, exports.HUB_APP_ENTRY_RELATIVE);
|
|
213
|
+
if (isHubAppCacheComplete(destDir, version)) {
|
|
214
|
+
return entryPath;
|
|
215
|
+
}
|
|
216
|
+
const stagingDir = `${destDir}.staging-${process.pid}`;
|
|
217
|
+
fs_1.default.mkdirSync(path_1.default.dirname(destDir), { recursive: true });
|
|
218
|
+
fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
|
|
219
|
+
try {
|
|
220
|
+
await installPackage(version, stagingDir);
|
|
221
|
+
// Written only on success, after the installer's own work, so a throw never leaves a staging
|
|
222
|
+
// tree that could be mistaken for complete.
|
|
223
|
+
fs_1.default.writeFileSync(path_1.default.join(stagingDir, 'version'), version, 'utf8');
|
|
224
|
+
if (!isHubAppCacheComplete(stagingDir, version)) {
|
|
225
|
+
throw new Error(`materialized ${exports.HUB_APP_PACKAGE_NAME}@${version} is missing ${exports.HUB_APP_ENTRY_RELATIVE}; refusing to promote an incomplete install`);
|
|
226
|
+
}
|
|
227
|
+
// A concurrent launch may have finished materializing the same version already; the winner
|
|
228
|
+
// keeps its copy rather than being clobbered by this one (mirrors electron-dist.ts).
|
|
229
|
+
if (!isHubAppCacheComplete(destDir, version)) {
|
|
230
|
+
fs_1.default.rmSync(destDir, { recursive: true, force: true });
|
|
231
|
+
try {
|
|
232
|
+
fs_1.default.renameSync(stagingDir, destDir);
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
if (!isHubAppCacheComplete(destDir, version))
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
|
|
242
|
+
}
|
|
243
|
+
return entryPath;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Resolve which `desktop-main.js` to run: the latest published version, materializing it first if
|
|
247
|
+
* needed, or the bundled build when npm is unreachable, already current, or materialization fails
|
|
248
|
+
* for any reason. A materialization failure must never block a launch — it always falls back to
|
|
249
|
+
* the bundled copy rather than propagating.
|
|
250
|
+
*/
|
|
251
|
+
async function resolveHubAppEntry(options) {
|
|
252
|
+
const fraimDir = options.fraimDir ?? (0, project_fraim_paths_1.getUserFraimDirPath)();
|
|
253
|
+
const getLatest = options.getLatestPublishedVersion ?? hub_latest_version_1.getLatestPublishedVersion;
|
|
254
|
+
const cachedVersions = listCachedHubAppVersions(fraimDir);
|
|
255
|
+
let latestPublishedVersion;
|
|
256
|
+
try {
|
|
257
|
+
latestPublishedVersion = await getLatest();
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
latestPublishedVersion = null;
|
|
261
|
+
}
|
|
262
|
+
const plan = planHubAppMaterialization({
|
|
263
|
+
bundledVersion: options.bundledVersion,
|
|
264
|
+
latestPublishedVersion,
|
|
265
|
+
cachedVersions,
|
|
266
|
+
});
|
|
267
|
+
if (plan.action === 'use-bundled') {
|
|
268
|
+
return { entryPath: options.bundledEntryPath, source: 'bundled', version: plan.version };
|
|
269
|
+
}
|
|
270
|
+
if (plan.action === 'use-cached') {
|
|
271
|
+
return {
|
|
272
|
+
entryPath: path_1.default.join(resolveHubAppCacheDir(plan.version, fraimDir), exports.HUB_APP_ENTRY_RELATIVE),
|
|
273
|
+
source: 'cached',
|
|
274
|
+
version: plan.version,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
options.onProgress?.(`Fetching FRAIM Hub ${plan.version}...`);
|
|
279
|
+
const entryPath = await ensureMaterializedHubApp(plan.version, { fraimDir, installPackage: options.installPackage });
|
|
280
|
+
return { entryPath, source: 'cached', version: plan.version };
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return { entryPath: options.bundledEntryPath, source: 'bundled', version: options.bundledVersion };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
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
|
+
exports.MANAGED_NODE_VERSION = void 0;
|
|
7
|
+
exports.npmExecutableName = npmExecutableName;
|
|
8
|
+
exports.nodeExecutableName = nodeExecutableName;
|
|
9
|
+
exports.findExecutableOnPath = findExecutableOnPath;
|
|
10
|
+
exports.hasWorkingNodeAndNpm = hasWorkingNodeAndNpm;
|
|
11
|
+
exports.isManagedNodeVersionComplete = isManagedNodeVersionComplete;
|
|
12
|
+
exports.nodeVersionedDirName = nodeVersionedDirName;
|
|
13
|
+
exports.ensureManagedNpm = ensureManagedNpm;
|
|
14
|
+
const fs_1 = __importDefault(require("fs"));
|
|
15
|
+
const os_1 = __importDefault(require("os"));
|
|
16
|
+
const path_1 = __importDefault(require("path"));
|
|
17
|
+
const https_1 = __importDefault(require("https"));
|
|
18
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
19
|
+
const managed_agent_paths_1 = require("./managed-agent-paths");
|
|
20
|
+
// Issue #1415 (extended): a genuinely GUI-only FRAIM Hub install — the native installer
|
|
21
|
+
// downloaded from the website, never a CLI — has no working `npm`/`npx` anywhere. First-run's
|
|
22
|
+
// `embeddedDesktop` branch (session-service.ts) deliberately skips installing one (it only needs
|
|
23
|
+
// Electron's own bundled Node for FRAIM's own CLI/MCP shim, via ELECTRON_RUN_AS_NODE), and the
|
|
24
|
+
// separate CLI installer's portable-Node bootstrap (scripts/installer/fraim-install-win.template.cmd)
|
|
25
|
+
// never runs for a desktop-only user. That left two features silently unable to do their real job
|
|
26
|
+
// on such a machine: `hub-app-materializer.ts`'s npm install (this issue), and agent-CLI
|
|
27
|
+
// installation (`managed-agent-install.ts`), which also shells out to a bare `npm`.
|
|
28
|
+
//
|
|
29
|
+
// This module is the one place that guarantees a real, usable `npm`/`node` exists, for both
|
|
30
|
+
// consumers: prefer whatever is already on system PATH (common for technical users), then an
|
|
31
|
+
// already-downloaded managed runtime (shared with the CLI installer's `~/.fraim/node/` convention
|
|
32
|
+
// via `managed-agent-paths.ts` — reused, not duplicated, so a user who has ever run the `fraim` CLI
|
|
33
|
+
// never pays for a second download), and only download a portable Node.js distribution from
|
|
34
|
+
// nodejs.org as a last resort.
|
|
35
|
+
//
|
|
36
|
+
// `~/.fraim/node/` is a *shared* directory — it already holds agent-CLI shims and their own
|
|
37
|
+
// `node_modules/` (managed-agent-install.ts) — not a directory this module exclusively owns like
|
|
38
|
+
// `electron-dist.ts` owns its versioned Electron dist. So the extract-once-reuse-forever,
|
|
39
|
+
// staging-then-atomic-rename transaction that pattern established is applied here to just the one
|
|
40
|
+
// versioned subfolder a Node.js archive naturally extracts as (`node-v<version>-<platform>-<arch>/`,
|
|
41
|
+
// exactly what `getPortableNodeBinPath()` already knows how to find), never to the shared root
|
|
42
|
+
// itself — so a concurrent or failed download can never disturb sibling content already there.
|
|
43
|
+
exports.MANAGED_NODE_VERSION = '20.11.1'; // matches NODE_VERSION in fraim-install-win.template.cmd
|
|
44
|
+
function npmExecutableName(platform = process.platform) {
|
|
45
|
+
return platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
46
|
+
}
|
|
47
|
+
function nodeExecutableName(platform = process.platform) {
|
|
48
|
+
return platform === 'win32' ? 'node.exe' : 'node';
|
|
49
|
+
}
|
|
50
|
+
/** First executable named `name` found by walking `pathValue`'s directories, or null. */
|
|
51
|
+
function findExecutableOnPath(name, pathValue = process.env.PATH) {
|
|
52
|
+
for (const dir of (pathValue ?? '').split(path_1.default.delimiter).filter(Boolean)) {
|
|
53
|
+
const candidate = path_1.default.join(dir, name);
|
|
54
|
+
try {
|
|
55
|
+
if (fs_1.default.existsSync(candidate) && fs_1.default.statSync(candidate).isFile())
|
|
56
|
+
return candidate;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Unreadable entry — treat as absent, not fatal.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
/** Whether `dir` already has a real, runnable node+npm — any version; this function's job is "a working npm", not "this exact one". */
|
|
65
|
+
function hasWorkingNodeAndNpm(dir, platform = process.platform) {
|
|
66
|
+
return fs_1.default.existsSync(path_1.default.join(dir, npmExecutableName(platform))) && fs_1.default.existsSync(path_1.default.join(dir, nodeExecutableName(platform)));
|
|
67
|
+
}
|
|
68
|
+
/** Whether `dir` (a specific versioned bin dir) holds a complete extraction for exactly `version`. */
|
|
69
|
+
function isManagedNodeVersionComplete(dir, version, platform = process.platform) {
|
|
70
|
+
try {
|
|
71
|
+
const recorded = fs_1.default.readFileSync(path_1.default.join(dir, 'fraim-managed-node-version'), 'utf8').trim();
|
|
72
|
+
return recorded === version && hasWorkingNodeAndNpm(dir, platform);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function nodePlatformName(platform) {
|
|
79
|
+
if (platform === 'darwin')
|
|
80
|
+
return 'darwin';
|
|
81
|
+
if (platform === 'win32')
|
|
82
|
+
return 'win';
|
|
83
|
+
return 'linux';
|
|
84
|
+
}
|
|
85
|
+
function nodeArchiveExtension(platform) {
|
|
86
|
+
return platform === 'win32' ? 'zip' : 'tar.gz';
|
|
87
|
+
}
|
|
88
|
+
// `request.version` is always `MANAGED_NODE_VERSION` today, but it is a public option on
|
|
89
|
+
// `ensureManagedNpm`, and this string is joined into a filesystem path below (`nodeRoot/<this>`)
|
|
90
|
+
// that a failed extraction's cleanup recursively deletes. Anchoring on an exact-release shape
|
|
91
|
+
// before it ever reaches a path, same guard shape as `hub-app-materializer.ts`'s
|
|
92
|
+
// `assertPathSafeVersion`, so a hostile or malformed override can never escape `nodeRoot`.
|
|
93
|
+
const SAFE_NODE_VERSION = /^\d+\.\d+\.\d+$/;
|
|
94
|
+
function assertPathSafeNodeVersion(version) {
|
|
95
|
+
if (!SAFE_NODE_VERSION.test(version)) {
|
|
96
|
+
throw new Error(`Refusing to use "${version}" as a managed Node.js version: expected an exact release such as 20.11.1.`);
|
|
97
|
+
}
|
|
98
|
+
return version;
|
|
99
|
+
}
|
|
100
|
+
/** The exact folder name Node's own official archive extracts as — also this module's versioned subfolder name under the shared `~/.fraim/node/` root. */
|
|
101
|
+
function nodeVersionedDirName(request) {
|
|
102
|
+
return `node-v${assertPathSafeNodeVersion(request.version)}-${nodePlatformName(request.platform)}-${request.arch}`;
|
|
103
|
+
}
|
|
104
|
+
function nodeArchiveFileName(request) {
|
|
105
|
+
return `${nodeVersionedDirName(request)}.${nodeArchiveExtension(request.platform)}`;
|
|
106
|
+
}
|
|
107
|
+
function nodeDistBaseUrl(version) {
|
|
108
|
+
return `https://nodejs.org/dist/v${version}`;
|
|
109
|
+
}
|
|
110
|
+
function httpsGetBuffer(url) {
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
https_1.default.get(url, { headers: { 'user-agent': 'fraim-hub-managed-node-runtime' } }, (res) => {
|
|
113
|
+
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
114
|
+
httpsGetBuffer(res.headers.location).then(resolve, reject);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (res.statusCode !== 200) {
|
|
118
|
+
res.resume();
|
|
119
|
+
reject(new Error(`GET ${url} failed: HTTP ${res.statusCode}`));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const chunks = [];
|
|
123
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
124
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
125
|
+
res.on('error', reject);
|
|
126
|
+
}).on('error', reject);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function httpsDownloadToFile(url, destPath) {
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
const file = fs_1.default.createWriteStream(destPath);
|
|
132
|
+
https_1.default.get(url, { headers: { 'user-agent': 'fraim-hub-managed-node-runtime' } }, (res) => {
|
|
133
|
+
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
134
|
+
file.close();
|
|
135
|
+
httpsDownloadToFile(res.headers.location, destPath).then(resolve, reject);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (res.statusCode !== 200) {
|
|
139
|
+
res.resume();
|
|
140
|
+
file.close();
|
|
141
|
+
reject(new Error(`GET ${url} failed: HTTP ${res.statusCode}`));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
res.pipe(file);
|
|
145
|
+
file.on('finish', () => file.close(() => resolve()));
|
|
146
|
+
}).on('error', (err) => {
|
|
147
|
+
file.close();
|
|
148
|
+
reject(err);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Verify `filePath` against the sha256 line naming `fileName` in the release's own
|
|
154
|
+
* `SHASUMS256.txt` — the same integrity check `@electron/get` performs for Electron zips
|
|
155
|
+
* (`electron-dist.ts`), applied to Node's own equivalent publishing convention.
|
|
156
|
+
*/
|
|
157
|
+
async function verifyNodeArchiveChecksum(filePath, fileName, version) {
|
|
158
|
+
const shasums = (await httpsGetBuffer(`${nodeDistBaseUrl(version)}/SHASUMS256.txt`)).toString('utf8');
|
|
159
|
+
const line = shasums.split('\n').find((entry) => entry.trim().endsWith(fileName));
|
|
160
|
+
if (!line) {
|
|
161
|
+
throw new Error(`SHASUMS256.txt for Node v${version} has no entry for ${fileName}; refusing to trust an unverified download`);
|
|
162
|
+
}
|
|
163
|
+
const expected = line.trim().split(/\s+/)[0];
|
|
164
|
+
const actual = crypto_1.default.createHash('sha256').update(fs_1.default.readFileSync(filePath)).digest('hex');
|
|
165
|
+
if (actual !== expected) {
|
|
166
|
+
throw new Error(`checksum mismatch for ${fileName}: expected ${expected}, got ${actual}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const defaultDownloadNodeArchive = async (request) => {
|
|
170
|
+
const fileName = nodeArchiveFileName(request);
|
|
171
|
+
const url = `${nodeDistBaseUrl(request.version)}/${fileName}`;
|
|
172
|
+
const tempPath = path_1.default.join(os_1.default.tmpdir(), `fraim-managed-node-${process.pid}-${Date.now()}-${fileName}`);
|
|
173
|
+
await httpsDownloadToFile(url, tempPath);
|
|
174
|
+
try {
|
|
175
|
+
await verifyNodeArchiveChecksum(tempPath, fileName, request.version);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
fs_1.default.rmSync(tempPath, { force: true });
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
return tempPath;
|
|
182
|
+
};
|
|
183
|
+
const defaultExtractNodeArchive = async (archivePath, destDir, request) => {
|
|
184
|
+
if (request.platform === 'win32') {
|
|
185
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
186
|
+
const extract = require('extract-zip');
|
|
187
|
+
await extract(archivePath, { dir: destDir });
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
191
|
+
const tar = require('tar');
|
|
192
|
+
await tar.x({ file: archivePath, cwd: destDir });
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
/**
|
|
196
|
+
* Download and extract Node v`request.version` into `nodeRoot/<node-vX.Y.Z-platform-arch>/`,
|
|
197
|
+
* touching nothing else in `nodeRoot`. Staging-then-atomic-rename, scoped to just that one
|
|
198
|
+
* versioned subfolder (mirrors `electron-dist.ts`'s transaction, applied to a shared parent
|
|
199
|
+
* directory instead of one this module exclusively owns): a concurrent download that already
|
|
200
|
+
* finished is kept rather than clobbered, and a failed extraction never leaves a versioned
|
|
201
|
+
* subfolder behind that a later launch could mistake for complete.
|
|
202
|
+
*/
|
|
203
|
+
async function downloadAndPromoteVersionedNodeDir(args) {
|
|
204
|
+
const { nodeRoot, request, downloadArchive, extractArchive } = args;
|
|
205
|
+
const destDir = path_1.default.join(nodeRoot, nodeVersionedDirName(request));
|
|
206
|
+
const stagingDir = `${destDir}.staging-${process.pid}`;
|
|
207
|
+
fs_1.default.mkdirSync(nodeRoot, { recursive: true });
|
|
208
|
+
fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
|
|
209
|
+
const archivePath = await downloadArchive(request);
|
|
210
|
+
try {
|
|
211
|
+
await extractArchive(archivePath, stagingDir, request);
|
|
212
|
+
// The archive's own top-level folder (matching nodeVersionedDirName) may land either as the
|
|
213
|
+
// extraction root itself (tar) or as one nested level inside it (some zip extractors preserve
|
|
214
|
+
// the archive's internal folder) — handle both without assuming either.
|
|
215
|
+
const nestedCandidate = path_1.default.join(stagingDir, nodeVersionedDirName(request));
|
|
216
|
+
const extractedRoot = fs_1.default.existsSync(nestedCandidate) ? nestedCandidate : stagingDir;
|
|
217
|
+
fs_1.default.writeFileSync(path_1.default.join(extractedRoot, 'fraim-managed-node-version'), request.version, 'utf8');
|
|
218
|
+
if (!isManagedNodeVersionComplete(extractedRoot, request.version, request.platform)) {
|
|
219
|
+
throw new Error(`extracted Node v${request.version} is missing ${npmExecutableName(request.platform)}/${nodeExecutableName(request.platform)}; refusing to promote an incomplete install`);
|
|
220
|
+
}
|
|
221
|
+
// A concurrent launch may have finished downloading the same version already; the winner
|
|
222
|
+
// keeps its copy rather than being clobbered by this one.
|
|
223
|
+
if (!isManagedNodeVersionComplete(destDir, request.version, request.platform)) {
|
|
224
|
+
fs_1.default.rmSync(destDir, { recursive: true, force: true });
|
|
225
|
+
try {
|
|
226
|
+
fs_1.default.renameSync(extractedRoot, destDir);
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
if (!isManagedNodeVersionComplete(destDir, request.version, request.platform))
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
fs_1.default.rmSync(archivePath, { force: true });
|
|
236
|
+
fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
|
|
237
|
+
}
|
|
238
|
+
return destDir;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Resolve a working `npm` executable, preferring (in order): system PATH, an already-downloaded
|
|
242
|
+
* managed runtime (shared with the CLI installer's convention, any version — this function's job
|
|
243
|
+
* is "a working npm", not "this exact one"), or a freshly downloaded one. Only throws for a
|
|
244
|
+
* genuine download/extraction failure; callers should treat that as "materialization unavailable
|
|
245
|
+
* this launch," not a hard failure — never for "nothing found," which falls through to download.
|
|
246
|
+
*/
|
|
247
|
+
async function ensureManagedNpm(options = {}) {
|
|
248
|
+
const version = options.version ?? exports.MANAGED_NODE_VERSION;
|
|
249
|
+
const platform = options.platform ?? process.platform;
|
|
250
|
+
const arch = options.arch ?? process.arch;
|
|
251
|
+
const systemNpm = findExecutableOnPath(npmExecutableName(platform), options.systemPath ?? process.env.PATH);
|
|
252
|
+
if (systemNpm) {
|
|
253
|
+
return { npmPath: systemNpm, binDir: path_1.default.dirname(systemNpm), source: 'system' };
|
|
254
|
+
}
|
|
255
|
+
const existingBinDir = (0, managed_agent_paths_1.getPortableNodeBinPath)();
|
|
256
|
+
if (hasWorkingNodeAndNpm(existingBinDir, platform)) {
|
|
257
|
+
return { npmPath: path_1.default.join(existingBinDir, npmExecutableName(platform)), binDir: existingBinDir, source: 'managed-existing' };
|
|
258
|
+
}
|
|
259
|
+
const request = { version, platform, arch };
|
|
260
|
+
options.onProgress?.('Preparing FRAIM\'s bundled Node.js runtime (one-time, ~30MB)...');
|
|
261
|
+
const binDir = await downloadAndPromoteVersionedNodeDir({
|
|
262
|
+
nodeRoot: (0, managed_agent_paths_1.getManagedNodeRoot)(),
|
|
263
|
+
request,
|
|
264
|
+
downloadArchive: options.downloadArchive ?? defaultDownloadNodeArchive,
|
|
265
|
+
extractArchive: options.extractArchive ?? defaultExtractNodeArchive,
|
|
266
|
+
});
|
|
267
|
+
options.onProgress?.('FRAIM Node.js runtime ready.');
|
|
268
|
+
return { npmPath: path_1.default.join(binDir, npmExecutableName(platform)), binDir, source: 'managed-downloaded' };
|
|
269
|
+
}
|
|
@@ -52,6 +52,7 @@ const fraim_mcp_latest_launcher_1 = require("../cli/mcp/fraim-mcp-latest-launche
|
|
|
52
52
|
const script_sync_utils_1 = require("../cli/utils/script-sync-utils");
|
|
53
53
|
const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
|
|
54
54
|
const managed_agent_install_1 = require("../cli/utils/managed-agent-install");
|
|
55
|
+
const managed_node_runtime_1 = require("../cli/utils/managed-node-runtime");
|
|
55
56
|
const types_1 = require("./types");
|
|
56
57
|
Object.defineProperty(exports, "FIRST_RUN_ROW_IDS", { enumerable: true, get: function () { return types_1.FIRST_RUN_ROW_IDS; } });
|
|
57
58
|
const install_state_1 = require("./install-state");
|
|
@@ -856,9 +857,18 @@ class FirstRunSessionService {
|
|
|
856
857
|
loginHint: `Sign in to ${option.label} to activate it. A terminal window will open with the sign-in command — complete sign-in there, then return here and click "Check if Ready".`,
|
|
857
858
|
};
|
|
858
859
|
}
|
|
859
|
-
|
|
860
|
+
// Issue #1415 (extended): a genuinely GUI-only install (native installer only, no CLI ever
|
|
861
|
+
// run) has no working npm on `systemPath` at all — `installManagedAgent`'s "standard" attempt
|
|
862
|
+
// needs one to run `npm install -g <agent>` in the first place. Ensure one exists (system,
|
|
863
|
+
// already-managed, or freshly downloaded) and fold its directory into the PATH passed down,
|
|
864
|
+
// so the standard attempt can actually succeed instead of silently falling through.
|
|
865
|
+
const npm = await (0, managed_node_runtime_1.ensureManagedNpm)({
|
|
866
|
+
onProgress: (message) => appendInstallLog(`managed-node: ${message}`),
|
|
867
|
+
});
|
|
868
|
+
const pathWithNpm = (0, managed_agent_paths_1.appendBinDirsToPath)(systemPath, [npm.binDir]);
|
|
869
|
+
const outcome = await (0, managed_agent_install_1.installManagedAgent)({ label: option.label, installPackage: option.installPackage, launchCommand: option.launchCommand }, pathWithNpm, { runProcess, commandVersion });
|
|
860
870
|
if (outcome.outcome === 'standard' && outcome.npmGlobalBinDirs.length > 0) {
|
|
861
|
-
process.env.PATH = (0, managed_agent_paths_1.appendBinDirsToPath)(
|
|
871
|
+
process.env.PATH = (0, managed_agent_paths_1.appendBinDirsToPath)(pathWithNpm, outcome.npmGlobalBinDirs);
|
|
862
872
|
}
|
|
863
873
|
this.setAgentInstallStatus(agentId, 'needs-sign-in', `Sign in to ${option.label} to activate it.`);
|
|
864
874
|
appendInstallLog(outcome.outcome === 'standard' ? `agent-installed-standard ${agentId}` : `agent-installed-managed ${agentId}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-hub",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.290",
|
|
4
4
|
"description": "FRAIM Hub local companion package.",
|
|
5
5
|
"author": "Sid Mathur <sid.mathur@gmail.com>",
|
|
6
6
|
"homepage": "https://github.com/mathursrus/FRAIM#readme",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"fraim-hub": "bin/fraim-hub.js",
|
|
10
10
|
"fraim-hub-2": "bin/fraim-hub-2.js"
|
|
11
11
|
},
|
|
12
|
-
"main": "dist/src/ai-hub/desktop-
|
|
12
|
+
"main": "dist/src/ai-hub/desktop-launcher.js",
|
|
13
13
|
"scripts": {
|
|
14
14
|
"build:windows-installer": "tsx ../../scripts/build-windows-hub-installer.ts",
|
|
15
15
|
"build:macos-installer": "tsx ../../scripts/build-macos-hub-installer.ts",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"dist/src/cli/utils/local-folder-sync.js",
|
|
49
49
|
"dist/src/cli/utils/managed-agent-install.js",
|
|
50
50
|
"dist/src/cli/utils/managed-agent-paths.js",
|
|
51
|
+
"dist/src/cli/utils/managed-node-runtime.js",
|
|
51
52
|
"dist/src/cli/utils/org-publish.js",
|
|
52
53
|
"dist/src/cli/utils/pack-git-publish.js",
|
|
53
54
|
"dist/src/cli/utils/pack-home.js",
|
|
@@ -210,7 +211,7 @@
|
|
|
210
211
|
"electron-updater": "^6.8.9",
|
|
211
212
|
"express": "^5.2.1",
|
|
212
213
|
"extract-zip": "^2.0.1",
|
|
213
|
-
"fraim": "2.0.
|
|
214
|
+
"fraim": "2.0.290",
|
|
214
215
|
"mongodb": "^7.0.0",
|
|
215
216
|
"node-cron": "4.2.1",
|
|
216
217
|
"node-edge-tts": "^1.2.10",
|
|
@@ -220,6 +221,7 @@
|
|
|
220
221
|
"selfsigned": "^5.5.0",
|
|
221
222
|
"semver": "^7.7.4",
|
|
222
223
|
"stripe": "^20.3.1",
|
|
224
|
+
"tar": "^7.4.3",
|
|
223
225
|
"toml": "^3.0.0",
|
|
224
226
|
"tree-kill": "^1.2.2",
|
|
225
227
|
"xml2js": "^0.6.2"
|