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 = path_1.default.join(electron_1.app.getAppPath(), 'node_modules', 'fraim', 'bin', 'fraim.js');
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
- function checkForUpdateAfterSecondInstance() {
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 configureAutoUpdater().catch((err) => {
140
- console.warn('[fraim] second-instance update check failed:', err);
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(checkForUpdateAfterSecondInstance);
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
- if (process.versions.electron && process.type !== 'renderer') {
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
- const outcome = await (0, managed_agent_install_1.installManagedAgent)({ label: option.label, installPackage: option.installPackage, launchCommand: option.launchCommand }, systemPath, { runProcess, commandVersion });
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)(systemPath, outcome.npmGlobalBinDirs);
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.289",
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-main.js",
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.289",
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"