fraim-hub 2.0.326 → 2.0.327
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/ai-hub/conversation-store.js +1 -1
- package/dist/src/ai-hub/desktop-installer-setup.js +79 -0
- package/dist/src/ai-hub/desktop-launcher.js +4 -0
- package/dist/src/ai-hub/desktop-main.js +18 -9
- package/dist/src/ai-hub/hub-app-materializer.js +10 -20
- package/dist/src/ai-hub/server.js +2 -1
- package/dist/src/cli/mcp/fraim-mcp-latest-launcher.js +31 -130
- package/dist/src/cli/mcp/mcp-server-registry.js +1 -6
- package/dist/src/cli/utils/managed-agent-paths.js +14 -3
- package/dist/src/cli/utils/managed-node-runtime.js +22 -19
- package/dist/src/first-run/session-service.js +17 -11
- package/package.json +4 -3
- package/public/ai-hub/index.html +1 -1
- package/public/ai-hub/script.js +87 -13
- package/public/ai-hub/styles.css +17 -8
|
@@ -337,7 +337,7 @@ function normalizeConversation(projectPath, raw) {
|
|
|
337
337
|
status: value.status,
|
|
338
338
|
// Issue #904: carry pauseReason explicitly so it survives the ...value spread round-trip.
|
|
339
339
|
...(value.pauseReason !== undefined && { pauseReason: value.pauseReason }),
|
|
340
|
-
createdAt: value.createdAt ?? new Date().toISOString(),
|
|
340
|
+
createdAt: value.createdAt ?? value.lastUpdatedAt ?? new Date().toISOString(),
|
|
341
341
|
lastUpdatedAt: value.lastUpdatedAt ?? value.createdAt ?? new Date().toISOString(),
|
|
342
342
|
};
|
|
343
343
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
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.readInstallerSetupApiKey = readInstallerSetupApiKey;
|
|
7
|
+
exports.repairInstallerIdeMcpEntries = repairInstallerIdeMcpEntries;
|
|
8
|
+
exports.runDesktopInstallerSetup = runDesktopInstallerSetup;
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
11
|
+
const child_process_1 = require("child_process");
|
|
12
|
+
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
13
|
+
const ide_detector_1 = require("../cli/setup/ide-detector");
|
|
14
|
+
const auto_mcp_setup_1 = require("../cli/setup/auto-mcp-setup");
|
|
15
|
+
const fraim_mcp_latest_launcher_1 = require("../cli/mcp/fraim-mcp-latest-launcher");
|
|
16
|
+
const managed_node_runtime_1 = require("../cli/utils/managed-node-runtime");
|
|
17
|
+
const win32_cmd_spawn_1 = require("../cli/utils/win32-cmd-spawn");
|
|
18
|
+
function readInstallerSetupApiKey(configPath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'config.json')) {
|
|
19
|
+
if (!fs_1.default.existsSync(configPath))
|
|
20
|
+
return null;
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
|
|
23
|
+
return typeof parsed.apiKey === 'string' && parsed.apiKey.trim() ? parsed.apiKey.trim() : null;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function repairInstallerIdeMcpEntries(options = {}) {
|
|
30
|
+
const logger = options.logger ?? console;
|
|
31
|
+
const fraimKey = readInstallerSetupApiKey(options.configPath);
|
|
32
|
+
if (!fraimKey) {
|
|
33
|
+
logger.log('[fraim-installer-setup] no existing config.json apiKey; IDE MCP repair will run from first-run');
|
|
34
|
+
return { status: 'skipped', ideCount: 0, reason: 'missing-config-api-key' };
|
|
35
|
+
}
|
|
36
|
+
const detectedIDEs = (options.detectInstalledIDEs ?? ide_detector_1.detectInstalledIDEs)();
|
|
37
|
+
const ideNames = detectedIDEs.map((ide) => ide.name).filter(Boolean);
|
|
38
|
+
if (ideNames.length === 0) {
|
|
39
|
+
logger.log('[fraim-installer-setup] no supported IDEs detected for MCP repair');
|
|
40
|
+
return { status: 'skipped', ideCount: 0, reason: 'no-detected-ides' };
|
|
41
|
+
}
|
|
42
|
+
const previousNonInteractive = process.env.FRAIM_NON_INTERACTIVE;
|
|
43
|
+
process.env.FRAIM_NON_INTERACTIVE = '1';
|
|
44
|
+
try {
|
|
45
|
+
await (options.autoConfigureMCP ?? auto_mcp_setup_1.autoConfigureMCP)(fraimKey, ideNames);
|
|
46
|
+
logger.log(`[fraim-installer-setup] repaired MCP config for ${ideNames.length} detected IDE(s)`);
|
|
47
|
+
return { status: 'repaired', ideCount: ideNames.length };
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
51
|
+
logger.warn(`[fraim-installer-setup] IDE MCP repair skipped after error: ${message}`);
|
|
52
|
+
return { status: 'failed', ideCount: ideNames.length, reason: message };
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
if (previousNonInteractive === undefined)
|
|
56
|
+
delete process.env.FRAIM_NON_INTERACTIVE;
|
|
57
|
+
else
|
|
58
|
+
process.env.FRAIM_NON_INTERACTIVE = previousNonInteractive;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function warmInstallerCliCache() {
|
|
62
|
+
const cliShim = (0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)();
|
|
63
|
+
const options = { env: process.env, encoding: 'utf8', stdio: 'inherit' };
|
|
64
|
+
const result = process.platform === 'win32'
|
|
65
|
+
? (0, win32_cmd_spawn_1.spawnViaCmdExe)(cliShim, ['--version'], options)
|
|
66
|
+
: (0, child_process_1.spawnSync)(cliShim, ['--version'], options);
|
|
67
|
+
if (result.status !== 0) {
|
|
68
|
+
throw new Error(`FRAIM CLI bootstrap exited with code ${result.status ?? 'unknown'}.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function runDesktopInstallerSetup(logger = console) {
|
|
72
|
+
await (0, managed_node_runtime_1.ensureManagedNpm)({
|
|
73
|
+
onProgress: (message) => logger.log(`[fraim-installer-setup] ${message}`),
|
|
74
|
+
});
|
|
75
|
+
(0, fraim_mcp_latest_launcher_1.ensureFraimMcpLatestLauncher)();
|
|
76
|
+
warmInstallerCliCache();
|
|
77
|
+
await repairInstallerIdeMcpEntries({ logger });
|
|
78
|
+
logger.log('[fraim-installer-setup] stable CLI and MCP launchers are ready');
|
|
79
|
+
}
|
|
@@ -20,6 +20,10 @@ async function bootstrapFrom(entryPath) {
|
|
|
20
20
|
await entry.bootstrap();
|
|
21
21
|
}
|
|
22
22
|
async function launch() {
|
|
23
|
+
if (process.argv.includes('--fraim-installer-setup')) {
|
|
24
|
+
await bootstrapFrom(BUNDLED_ENTRY_PATH);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
23
27
|
// Issue #1415: deterministic escape hatch for integration testing — skips the real npm
|
|
24
28
|
// resolution (and its dependence on whatever happens to be published at test time) so a test can
|
|
25
29
|
// prove the Electron main-process boot chain itself (resolveHubAppEntry -> require -> bootstrap
|
|
@@ -20,7 +20,7 @@ const window_open_decision_1 = require("./window-open-decision");
|
|
|
20
20
|
const bundled_asset_resolver_1 = require("./bundled-asset-resolver");
|
|
21
21
|
const server_2 = require("../first-run/server");
|
|
22
22
|
const session_service_1 = require("../first-run/session-service");
|
|
23
|
-
const
|
|
23
|
+
const desktop_installer_setup_1 = require("./desktop-installer-setup");
|
|
24
24
|
const desktop_auto_updater_1 = require("./desktop-auto-updater");
|
|
25
25
|
const hub_app_materializer_1 = require("./hub-app-materializer");
|
|
26
26
|
const hub_instance_reconciliation_1 = require("./hub-instance-reconciliation");
|
|
@@ -89,13 +89,6 @@ function applyUserDataOverride() {
|
|
|
89
89
|
fs_1.default.mkdirSync(userDataDir, { recursive: true });
|
|
90
90
|
electron_1.app.setPath('userData', userDataDir);
|
|
91
91
|
}
|
|
92
|
-
function configurePackagedFraimRuntime() {
|
|
93
|
-
if (!electron_1.app.isPackaged && process.env.FRAIM_DESKTOP_FIRST_RUN !== '1')
|
|
94
|
-
return;
|
|
95
|
-
process.env.FRAIM_PACKAGED_CLI_EXECUTABLE = process.execPath;
|
|
96
|
-
process.env.FRAIM_PACKAGED_CLI_SCRIPT = (0, hub_app_materializer_1.resolvePackagedCliScriptPath)(__dirname);
|
|
97
|
-
(0, fraim_mcp_latest_launcher_1.ensureFraimMcpLatestLauncher)();
|
|
98
|
-
}
|
|
99
92
|
function shouldShowEmbeddedFirstRun() {
|
|
100
93
|
if (!electron_1.app.isPackaged && process.env.FRAIM_DESKTOP_FIRST_RUN !== '1')
|
|
101
94
|
return false;
|
|
@@ -153,6 +146,16 @@ async function configureAutoUpdater() {
|
|
|
153
146
|
});
|
|
154
147
|
return result.action === 'installing';
|
|
155
148
|
}
|
|
149
|
+
async function runPackagedInstallerRepair() {
|
|
150
|
+
if (!electron_1.app.isPackaged || process.env.FRAIM_INSTALLER_LIFECYCLE_TEST === '1')
|
|
151
|
+
return;
|
|
152
|
+
try {
|
|
153
|
+
await (0, desktop_installer_setup_1.runDesktopInstallerSetup)(console);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
console.warn('[fraim] packaged installer repair failed:', error);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
156
159
|
// Issue #1415: shared by every path that needs to know "is a newer FRAIM Hub published than the
|
|
157
160
|
// one currently running" — the automatic second-instance/activate recheck below, and the
|
|
158
161
|
// manually-triggered restart from the web UI's update badge (see restartToLatestNow). Compares
|
|
@@ -655,6 +658,12 @@ async function bootstrap() {
|
|
|
655
658
|
preferredPort: options.preferredPort,
|
|
656
659
|
hasProjectPath: Boolean(options.projectPath),
|
|
657
660
|
});
|
|
661
|
+
if (process.argv.includes('--fraim-installer-setup')) {
|
|
662
|
+
await electron_1.app.whenReady();
|
|
663
|
+
await (0, desktop_installer_setup_1.runDesktopInstallerSetup)(console);
|
|
664
|
+
electron_1.app.exit(0);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
658
667
|
electron_1.app.on('child-process-gone', (_event, details) => {
|
|
659
668
|
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('app.child_process_gone', details);
|
|
660
669
|
});
|
|
@@ -679,7 +688,6 @@ async function bootstrap() {
|
|
|
679
688
|
});
|
|
680
689
|
await electron_1.app.whenReady();
|
|
681
690
|
electron_1.app.setName(displayName(options.runtimeId));
|
|
682
|
-
configurePackagedFraimRuntime();
|
|
683
691
|
// Issue #1415 (follow-up): app.requestSingleInstanceLock() above only sees another instance of
|
|
684
692
|
// *this same identity* — it cannot see a Hub already running via a different launch path (e.g.
|
|
685
693
|
// `npx fraim-hub`'s raw electron.exe, confirmed live to end up under a different userData scope
|
|
@@ -710,6 +718,7 @@ async function bootstrap() {
|
|
|
710
718
|
// First-launch housekeeping (idempotent, fast on subsequent runs)
|
|
711
719
|
if (process.env.FRAIM_INSTALLER_LIFECYCLE_TEST !== '1') {
|
|
712
720
|
ensureLoginItem();
|
|
721
|
+
await runPackagedInstallerRepair();
|
|
713
722
|
const installingUpdate = await configureAutoUpdater();
|
|
714
723
|
if (installingUpdate)
|
|
715
724
|
return;
|
|
@@ -5,11 +5,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.HUB_APP_ENTRY_RELATIVE = exports.HUB_APP_PACKAGE_NAME = void 0;
|
|
7
7
|
exports.redactCredentialLikeSubstrings = redactCredentialLikeSubstrings;
|
|
8
|
-
exports.resolvePackagedCliScriptPath = resolvePackagedCliScriptPath;
|
|
9
8
|
exports.planHubAppMaterialization = planHubAppMaterialization;
|
|
10
9
|
exports.resolveHubAppCacheDir = resolveHubAppCacheDir;
|
|
11
10
|
exports.isHubAppCacheComplete = isHubAppCacheComplete;
|
|
12
11
|
exports.listCachedHubAppVersions = listCachedHubAppVersions;
|
|
12
|
+
exports.runNpmInstall = runNpmInstall;
|
|
13
13
|
exports.ensureMaterializedHubApp = ensureMaterializedHubApp;
|
|
14
14
|
exports.resolveHubAppEntry = resolveHubAppEntry;
|
|
15
15
|
const fs_1 = __importDefault(require("fs"));
|
|
@@ -55,19 +55,6 @@ function redactCredentialLikeSubstrings(text) {
|
|
|
55
55
|
exports.HUB_APP_PACKAGE_NAME = 'fraim-hub';
|
|
56
56
|
/** Relative path, from a materialized (or bundled) package root, to the Electron main entry. */
|
|
57
57
|
exports.HUB_APP_ENTRY_RELATIVE = path_1.default.join('dist', 'src', 'ai-hub', 'desktop-main.js');
|
|
58
|
-
/**
|
|
59
|
-
* Where the packaged CLI/MCP shim (`ensureFraimMcpLatestLauncher()`) should find `fraim`'s CLI
|
|
60
|
-
* script, derived from `desktop-main.ts`'s own directory (`<packageRoot>/dist/src/ai-hub/`) rather
|
|
61
|
-
* than Electron's `app.getAppPath()`. `desktop-launcher.ts` may have loaded `desktop-main.js` from a
|
|
62
|
-
* materialized `fraim-hub@<latest>` copy under `~/.fraim/bin/hub-app/<version>/` rather than from
|
|
63
|
-
* the original app.asar, and `app.getAppPath()` always names the latter regardless of which copy is
|
|
64
|
-
* actually running. `moduleDir` tracks whichever copy is really executing. Kept in this
|
|
65
|
-
* Electron-free module (rather than inlined in `desktop-main.ts`) so it is unit-testable without an
|
|
66
|
-
* Electron `app` object.
|
|
67
|
-
*/
|
|
68
|
-
function resolvePackagedCliScriptPath(moduleDir) {
|
|
69
|
-
return path_1.default.join(moduleDir, '..', '..', '..', 'node_modules', 'fraim', 'bin', 'fraim.js');
|
|
70
|
-
}
|
|
71
58
|
function pickNewestComplete(cachedVersions) {
|
|
72
59
|
const versions = cachedVersions.filter((entry) => entry.complete).map((entry) => entry.version);
|
|
73
60
|
if (versions.length === 0)
|
|
@@ -178,16 +165,19 @@ function listCachedHubAppVersions(fraimDir = (0, project_fraim_paths_1.getUserFr
|
|
|
178
165
|
* every dependency to that top level; nothing here relies on nested-dependency resolution.
|
|
179
166
|
*
|
|
180
167
|
* The `npm` executable itself is resolved via `ensureManagedNpm()` (system PATH, an already-managed
|
|
181
|
-
* runtime, or a freshly downloaded one) rather than assumed present on PATH
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
* purposes and never downloads a standalone npm (see managed-node-runtime.ts's own header comment).
|
|
168
|
+
* runtime, or a freshly downloaded one) rather than assumed present on PATH. Installer setup
|
|
169
|
+
* prepares that shared runtime, while this fallback also supports older installs and recovery
|
|
170
|
+
* when installer setup did not finish.
|
|
185
171
|
*/
|
|
186
172
|
function runNpmInstall(npmPath, args) {
|
|
173
|
+
// POSIX npm uses /usr/bin/env node. A GUI launch may have no system Node
|
|
174
|
+
// on PATH even after ensureManagedNpm has downloaded a complete runtime.
|
|
175
|
+
const npmPathValue = [path_1.default.dirname(npmPath), process.env.PATH].filter(Boolean).join(path_1.default.delimiter);
|
|
176
|
+
const env = { ...process.env, PATH: npmPathValue, ...(process.platform === 'win32' ? { Path: npmPathValue } : {}) };
|
|
187
177
|
if (process.platform !== 'win32') {
|
|
188
|
-
return (0, child_process_1.spawnSync)(npmPath, args, { encoding: 'utf8' });
|
|
178
|
+
return (0, child_process_1.spawnSync)(npmPath, args, { encoding: 'utf8', env });
|
|
189
179
|
}
|
|
190
|
-
return (0, win32_cmd_spawn_1.spawnViaCmdExe)(npmPath, args, { encoding: 'utf8' });
|
|
180
|
+
return (0, win32_cmd_spawn_1.spawnViaCmdExe)(npmPath, args, { encoding: 'utf8', env });
|
|
191
181
|
}
|
|
192
182
|
const defaultInstallPackage = async (version, destDir) => {
|
|
193
183
|
fs_1.default.mkdirSync(destDir, { recursive: true });
|
|
@@ -3826,8 +3826,9 @@ class AiHubServer {
|
|
|
3826
3826
|
try {
|
|
3827
3827
|
const ref = this.rawEventLogStore.append((0, conversation_store_1.conversationScopeKey)(run.scope, run.projectPath), run.conversationId || run.id, run.id, { channel: 'system', text, hubKind: kind, hostId: run.baseHostId || run.hostId, createdAt });
|
|
3828
3828
|
run.eventLogRefs = upsertEventLogRef(run.eventLogRefs, ref);
|
|
3829
|
-
if (ref.eventCount === 1)
|
|
3829
|
+
if (ref.eventCount === 1 && this.runRegistry.get(run.id)) {
|
|
3830
3830
|
this.persistRunConversationNow(run, run.managedByRunId ? undefined : (run.conversationId || run.id));
|
|
3831
|
+
}
|
|
3831
3832
|
}
|
|
3832
3833
|
catch (error) {
|
|
3833
3834
|
run.events.push((0, hosts_1.createHubEvent)('system', 'Event archive write failed; some Micro-manage entries are unavailable.'));
|
|
@@ -8,15 +8,12 @@ exports.getFraimMcpShimPath = getFraimMcpShimPath;
|
|
|
8
8
|
exports.getFraimNpxShimPath = getFraimNpxShimPath;
|
|
9
9
|
exports.getFraimCliShimPath = getFraimCliShimPath;
|
|
10
10
|
exports.getPackagedFraimRuntimePath = getPackagedFraimRuntimePath;
|
|
11
|
-
exports.getPackagedFraimRuntime = getPackagedFraimRuntime;
|
|
12
|
-
exports.hasPackagedFraimRuntime = hasPackagedFraimRuntime;
|
|
13
11
|
exports.ensureFraimMcpLatestLauncher = ensureFraimMcpLatestLauncher;
|
|
14
12
|
const fs_1 = __importDefault(require("fs"));
|
|
15
13
|
const os_1 = __importDefault(require("os"));
|
|
16
14
|
const path_1 = __importDefault(require("path"));
|
|
17
15
|
const windows_shim_path_search_1 = require("../../core/utils/windows-shim-path-search");
|
|
18
16
|
const LAUNCHER_VERSION = 1;
|
|
19
|
-
const PACKAGED_RUNTIME_VERSION = 1;
|
|
20
17
|
const launcherSource = `#!/usr/bin/env node
|
|
21
18
|
const fs = require('fs');
|
|
22
19
|
const os = require('os');
|
|
@@ -32,7 +29,9 @@ const CACHE_ROOT = path.join(USER_DIR, 'npm-cache', 'mcp');
|
|
|
32
29
|
const commandName = (name) => process.platform === 'win32' ? name + '.cmd' : name;
|
|
33
30
|
const npmCommand = commandName('npm');
|
|
34
31
|
const npxCommand = commandName('npx');
|
|
35
|
-
const
|
|
32
|
+
const rawArgs = process.argv.slice(2);
|
|
33
|
+
const cliMode = rawArgs[0] === '--fraim-cli';
|
|
34
|
+
const cliArgs = cliMode ? rawArgs.slice(1) : (rawArgs.length > 0 ? rawArgs : ['mcp']);
|
|
36
35
|
|
|
37
36
|
function quoteCmdArg(arg) {
|
|
38
37
|
const value = String(arg);
|
|
@@ -133,75 +132,6 @@ function getFraimCliShimPath() {
|
|
|
133
132
|
function getPackagedFraimRuntimePath() {
|
|
134
133
|
return path_1.default.join(process.env.FRAIM_USER_DIR || path_1.default.join(os_1.default.homedir(), '.fraim'), 'bin', 'packaged-fraim-runtime.json');
|
|
135
134
|
}
|
|
136
|
-
function runtimeFromEnv(env = process.env) {
|
|
137
|
-
return env.FRAIM_PACKAGED_CLI_EXECUTABLE && env.FRAIM_PACKAGED_CLI_SCRIPT
|
|
138
|
-
? { executable: env.FRAIM_PACKAGED_CLI_EXECUTABLE, script: env.FRAIM_PACKAGED_CLI_SCRIPT }
|
|
139
|
-
: null;
|
|
140
|
-
}
|
|
141
|
-
// Regular Node.js (and ELECTRON_RUN_AS_NODE) cannot see inside Electron's
|
|
142
|
-
// .asar archives. For paths like …/app.asar/node_modules/…, verify the
|
|
143
|
-
// archive file itself rather than the internal entry.
|
|
144
|
-
function existsOrAsarExists(filePath) {
|
|
145
|
-
const normalized = filePath.replace(/\\/g, '/');
|
|
146
|
-
const asarSep = normalized.indexOf('.asar/');
|
|
147
|
-
if (asarSep === -1)
|
|
148
|
-
return fs_1.default.existsSync(filePath);
|
|
149
|
-
return fs_1.default.existsSync(filePath.slice(0, asarSep + '.asar'.length));
|
|
150
|
-
}
|
|
151
|
-
function getPackagedFraimRuntime(env = process.env) {
|
|
152
|
-
const envRuntime = runtimeFromEnv(env);
|
|
153
|
-
if (envRuntime)
|
|
154
|
-
return envRuntime;
|
|
155
|
-
try {
|
|
156
|
-
const raw = JSON.parse(fs_1.default.readFileSync(getPackagedFraimRuntimePath(), 'utf8'));
|
|
157
|
-
if (!raw.executable || !raw.script)
|
|
158
|
-
return null;
|
|
159
|
-
if (!fs_1.default.existsSync(raw.executable) || !existsOrAsarExists(raw.script))
|
|
160
|
-
return null;
|
|
161
|
-
return { executable: raw.executable, script: raw.script };
|
|
162
|
-
}
|
|
163
|
-
catch {
|
|
164
|
-
return null;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
function hasPackagedFraimRuntime(env = process.env) {
|
|
168
|
-
return getPackagedFraimRuntime(env) !== null;
|
|
169
|
-
}
|
|
170
|
-
function posixSingleQuote(value) {
|
|
171
|
-
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
172
|
-
}
|
|
173
|
-
function windowsBatchPath(value) {
|
|
174
|
-
return value.replace(/%/g, '%%').replace(/"/g, '""');
|
|
175
|
-
}
|
|
176
|
-
function writePackagedRuntimeMetadata(runtime) {
|
|
177
|
-
writeFileIfChanged(getPackagedFraimRuntimePath(), JSON.stringify({
|
|
178
|
-
version: PACKAGED_RUNTIME_VERSION,
|
|
179
|
-
executable: runtime.executable,
|
|
180
|
-
script: runtime.script,
|
|
181
|
-
updatedAt: new Date().toISOString(),
|
|
182
|
-
}, null, 2) + '\n');
|
|
183
|
-
}
|
|
184
|
-
function packagedFraimShimSource(command, runtime) {
|
|
185
|
-
const { executable, script } = runtime;
|
|
186
|
-
const commandArg = command === 'mcp' ? ' mcp' : '';
|
|
187
|
-
if (process.platform === 'win32') {
|
|
188
|
-
return [
|
|
189
|
-
'@echo off',
|
|
190
|
-
'setlocal',
|
|
191
|
-
'set "ELECTRON_RUN_AS_NODE=1"',
|
|
192
|
-
`"${windowsBatchPath(executable)}" "${windowsBatchPath(script)}"${commandArg} %*`,
|
|
193
|
-
'exit /b %ERRORLEVEL%',
|
|
194
|
-
'',
|
|
195
|
-
].join('\r\n');
|
|
196
|
-
}
|
|
197
|
-
return [
|
|
198
|
-
'#!/usr/bin/env sh',
|
|
199
|
-
'set -eu',
|
|
200
|
-
'export ELECTRON_RUN_AS_NODE=1',
|
|
201
|
-
`exec ${posixSingleQuote(executable)} ${posixSingleQuote(script)}${commandArg} "$@"`,
|
|
202
|
-
'',
|
|
203
|
-
].join('\n');
|
|
204
|
-
}
|
|
205
135
|
const windowsFraimMcpShimSource = `@echo off
|
|
206
136
|
setlocal
|
|
207
137
|
for %%I in ("%~dp0..") do set "FRAIM_USER_DIR_VALUE=%%~fI"
|
|
@@ -225,6 +155,9 @@ for /f "delims=" %%D in ('dir /b /ad /o-n "%FRAIM_USER_DIR_VALUE%\\node\\node-v*
|
|
|
225
155
|
echo [fraim-mcp] Could not find node on PATH or in "%FRAIM_USER_DIR_VALUE%\\node" 1>&2
|
|
226
156
|
exit /b 1
|
|
227
157
|
`;
|
|
158
|
+
const windowsFraimCliShimSource = windowsFraimMcpShimSource
|
|
159
|
+
.replace(/fraim-mcp-latest\.js" %\*/g, 'fraim-mcp-latest.js" --fraim-cli %*')
|
|
160
|
+
.replace(/\[fraim-mcp\]/g, '[fraim]');
|
|
228
161
|
const windowsFraimNpxShimSource = `@echo off
|
|
229
162
|
setlocal
|
|
230
163
|
for %%I in ("%~dp0..") do set "FRAIM_USER_DIR_VALUE=%%~fI"
|
|
@@ -246,47 +179,31 @@ for /f "delims=" %%D in ('dir /b /ad /o-n "%FRAIM_USER_DIR_VALUE%\\node\\node-v*
|
|
|
246
179
|
echo [fraim-npx] Could not find npx on PATH or in "%FRAIM_USER_DIR_VALUE%\\node" 1>&2
|
|
247
180
|
exit /b 1
|
|
248
181
|
`;
|
|
249
|
-
|
|
182
|
+
function posixFraimShimSource(executable) {
|
|
183
|
+
const args = executable === 'node' ? '"$FRAIM_USER_DIR_VALUE/bin/fraim-mcp-latest.js" "$@"' : '"$@"';
|
|
184
|
+
const label = executable === 'node' ? 'fraim-mcp' : 'fraim-npx';
|
|
185
|
+
return `#!/usr/bin/env sh
|
|
250
186
|
set -eu
|
|
251
187
|
SHIM_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
252
188
|
FRAIM_USER_DIR_VALUE=$(dirname "$SHIM_DIR")
|
|
253
|
-
LAUNCHER="$FRAIM_USER_DIR_VALUE/bin/fraim-mcp-latest.js"
|
|
254
189
|
|
|
255
|
-
if command -v
|
|
256
|
-
exec
|
|
190
|
+
if command -v ${executable} >/dev/null 2>&1; then
|
|
191
|
+
exec ${executable} ${args}
|
|
257
192
|
fi
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
echo "[fraim-mcp] Could not find node on PATH or in $FRAIM_USER_DIR_VALUE/node" >&2
|
|
270
|
-
exit 1
|
|
271
|
-
`;
|
|
272
|
-
const posixFraimNpxShimSource = `#!/usr/bin/env sh
|
|
273
|
-
set -eu
|
|
274
|
-
SHIM_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
275
|
-
FRAIM_USER_DIR_VALUE=$(dirname "$SHIM_DIR")
|
|
276
|
-
|
|
277
|
-
if command -v npx >/dev/null 2>&1; then
|
|
278
|
-
exec npx "$@"
|
|
279
|
-
fi
|
|
280
|
-
if [ -x "$FRAIM_USER_DIR_VALUE/node/bin/npx" ]; then
|
|
281
|
-
exec "$FRAIM_USER_DIR_VALUE/node/bin/npx" "$@"
|
|
282
|
-
fi
|
|
283
|
-
if [ -x "$FRAIM_USER_DIR_VALUE/node/npx" ]; then
|
|
284
|
-
exec "$FRAIM_USER_DIR_VALUE/node/npx" "$@"
|
|
285
|
-
fi
|
|
286
|
-
|
|
287
|
-
echo "[fraim-npx] Could not find npx on PATH or in $FRAIM_USER_DIR_VALUE/node" >&2
|
|
193
|
+
# Match the generating host's distribution layout; staging and other architectures
|
|
194
|
+
# cannot match this suffix. Legacy installs remain valid fallback locations.
|
|
195
|
+
for NODE_BIN in "$FRAIM_USER_DIR_VALUE/node"/node-v*-${process.platform}-${process.arch}/bin "$FRAIM_USER_DIR_VALUE/node/bin" "$FRAIM_USER_DIR_VALUE/node"; do
|
|
196
|
+
if [ -x "$NODE_BIN/node" ] && [ -x "$NODE_BIN/${executable}" ]; then
|
|
197
|
+
PATH="$NODE_BIN:$PATH"
|
|
198
|
+
export PATH
|
|
199
|
+
exec "$NODE_BIN/${executable}" ${args}
|
|
200
|
+
fi
|
|
201
|
+
done
|
|
202
|
+
|
|
203
|
+
echo "[${label}] Could not find ${executable} on PATH or in $FRAIM_USER_DIR_VALUE/node" >&2
|
|
288
204
|
exit 1
|
|
289
205
|
`;
|
|
206
|
+
}
|
|
290
207
|
const writeFileIfChanged = (filePath, content, mode) => {
|
|
291
208
|
if (!fs_1.default.existsSync(filePath) || fs_1.default.readFileSync(filePath, 'utf8') !== content) {
|
|
292
209
|
fs_1.default.writeFileSync(filePath, content, 'utf8');
|
|
@@ -306,30 +223,14 @@ function ensureFraimMcpLatestLauncher() {
|
|
|
306
223
|
fs_1.default.mkdirSync(launcherDir, { recursive: true });
|
|
307
224
|
const fraimMcpShimPath = getFraimMcpShimPath();
|
|
308
225
|
const fraimNpxShimPath = getFraimNpxShimPath();
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
const fraimCliShimPath = getFraimCliShimPath();
|
|
312
|
-
if (runtimeFromEnv()) {
|
|
313
|
-
writePackagedRuntimeMetadata(packagedRuntime);
|
|
314
|
-
}
|
|
315
|
-
writeFileIfChanged(fraimMcpShimPath, packagedFraimShimSource('mcp', packagedRuntime), process.platform === 'win32' ? undefined : 0o755);
|
|
316
|
-
writeFileIfChanged(fraimCliShimPath, packagedFraimShimSource('cli', packagedRuntime), process.platform === 'win32' ? undefined : 0o755);
|
|
317
|
-
// Issue #1474: keep the npx companion shim in parity with the non-packaged branch below.
|
|
318
|
-
// The packaged fraim-mcp shim itself never invokes npx, but git/playwright base MCP servers
|
|
319
|
-
// (added later via the connect-mcp skill once a system npm is available) and doctor's
|
|
320
|
-
// launch-plan diagnostic both expect this file to exist alongside the stable fraim shim.
|
|
321
|
-
// Without it, doctor's "stable shim missing on disk" check could never be satisfied on a
|
|
322
|
-
// packaged install, and --fix-mcp/add-ide (which call this same function) could never fix it.
|
|
323
|
-
writeFileIfChanged(fraimNpxShimPath, process.platform === 'win32' ? windowsFraimNpxShimSource : posixFraimNpxShimSource, process.platform === 'win32' ? undefined : 0o755);
|
|
324
|
-
return {
|
|
325
|
-
command: fraimMcpShimPath,
|
|
326
|
-
args: [],
|
|
327
|
-
path: packagedRuntime.script,
|
|
328
|
-
};
|
|
329
|
-
}
|
|
226
|
+
const fraimCliShimPath = getFraimCliShimPath();
|
|
227
|
+
fs_1.default.rmSync(getPackagedFraimRuntimePath(), { force: true });
|
|
330
228
|
writeFileIfChanged(launcherPath, launcherSource, process.platform === 'win32' ? undefined : 0o755);
|
|
331
|
-
writeFileIfChanged(fraimMcpShimPath, process.platform === 'win32' ? windowsFraimMcpShimSource :
|
|
332
|
-
writeFileIfChanged(fraimNpxShimPath, process.platform === 'win32' ? windowsFraimNpxShimSource :
|
|
229
|
+
writeFileIfChanged(fraimMcpShimPath, process.platform === 'win32' ? windowsFraimMcpShimSource : posixFraimShimSource('node'), process.platform === 'win32' ? undefined : 0o755);
|
|
230
|
+
writeFileIfChanged(fraimNpxShimPath, process.platform === 'win32' ? windowsFraimNpxShimSource : posixFraimShimSource('npx'), process.platform === 'win32' ? undefined : 0o755);
|
|
231
|
+
writeFileIfChanged(fraimCliShimPath, process.platform === 'win32' ? windowsFraimCliShimSource : posixFraimShimSource('node')
|
|
232
|
+
.replace(/fraim-mcp-latest\.js" "\$@"/g, 'fraim-mcp-latest.js" --fraim-cli "$@"')
|
|
233
|
+
.replace(/\[fraim-mcp\]/g, '[fraim]'), process.platform === 'win32' ? undefined : 0o755);
|
|
333
234
|
return {
|
|
334
235
|
command: fraimMcpShimPath,
|
|
335
236
|
args: [],
|
|
@@ -56,12 +56,7 @@ exports.BASE_MCP_SERVERS = [
|
|
|
56
56
|
}
|
|
57
57
|
];
|
|
58
58
|
function activeBaseMCPServers() {
|
|
59
|
-
|
|
60
|
-
// npm. Keep third-party npx MCP servers out of fresh desktop-generated config;
|
|
61
|
-
// users can add them later through connect-mcp when a system npm is available.
|
|
62
|
-
return (0, fraim_mcp_latest_launcher_1.hasPackagedFraimRuntime)()
|
|
63
|
-
? exports.BASE_MCP_SERVERS.filter((server) => server.id === 'fraim')
|
|
64
|
-
: exports.BASE_MCP_SERVERS;
|
|
59
|
+
return exports.BASE_MCP_SERVERS;
|
|
65
60
|
}
|
|
66
61
|
// ============================================================================
|
|
67
62
|
// PROVIDER MCP SERVER BUILDER (uses provider registry)
|
|
@@ -36,12 +36,12 @@ Object.defineProperty(exports, "resolveMachinePathCached", { enumerable: true, g
|
|
|
36
36
|
function getManagedNodeRoot() {
|
|
37
37
|
return path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'node');
|
|
38
38
|
}
|
|
39
|
-
function getPortableNodeBinPath() {
|
|
39
|
+
function getPortableNodeBinPath(platform = process.platform, arch = process.arch) {
|
|
40
40
|
const nodeRoot = getManagedNodeRoot();
|
|
41
|
-
if (
|
|
41
|
+
if (platform === 'win32') {
|
|
42
42
|
if (fs_1.default.existsSync(nodeRoot)) {
|
|
43
43
|
const extractedDir = fs_1.default.readdirSync(nodeRoot, { withFileTypes: true })
|
|
44
|
-
.filter((entry) => entry.isDirectory() && entry.name.startsWith('node-v'))
|
|
44
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith('node-v') && !entry.name.includes('.staging-'))
|
|
45
45
|
.sort((a, b) => b.name.localeCompare(a.name))[0];
|
|
46
46
|
if (extractedDir) {
|
|
47
47
|
return path_1.default.join(nodeRoot, extractedDir.name);
|
|
@@ -49,6 +49,17 @@ function getPortableNodeBinPath() {
|
|
|
49
49
|
}
|
|
50
50
|
return nodeRoot;
|
|
51
51
|
}
|
|
52
|
+
if (fs_1.default.existsSync(nodeRoot)) {
|
|
53
|
+
const versioned = fs_1.default.readdirSync(nodeRoot, { withFileTypes: true })
|
|
54
|
+
.filter(entry => entry.isDirectory() && /^node-v\d+\.\d+\.\d+-/.test(entry.name)
|
|
55
|
+
&& entry.name.endsWith(`-${platform}-${arch}`))
|
|
56
|
+
.sort((a, b) => b.name.localeCompare(a.name, undefined, { numeric: true }));
|
|
57
|
+
for (const entry of versioned) {
|
|
58
|
+
const bin = path_1.default.join(nodeRoot, entry.name, 'bin');
|
|
59
|
+
if (fs_1.default.existsSync(path_1.default.join(bin, 'node')) && fs_1.default.existsSync(path_1.default.join(bin, 'npm')))
|
|
60
|
+
return bin;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
52
63
|
return path_1.default.join(nodeRoot, 'bin');
|
|
53
64
|
}
|
|
54
65
|
function getManagedAgentBinDirs() {
|
|
@@ -17,16 +17,11 @@ const path_1 = __importDefault(require("path"));
|
|
|
17
17
|
const https_1 = __importDefault(require("https"));
|
|
18
18
|
const crypto_1 = __importDefault(require("crypto"));
|
|
19
19
|
const managed_agent_paths_1 = require("./managed-agent-paths");
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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`.
|
|
20
|
+
// Desktop-only users may have no working system npm/npx. Installer setup, Hub
|
|
21
|
+
// materialization, first-run setup, and agent installation share this managed
|
|
22
|
+
// runtime resolver so each can prepare or reuse standalone Node and npm.
|
|
28
23
|
//
|
|
29
|
-
// This module is the one place that guarantees a real, usable `npm`/`node` exists
|
|
24
|
+
// This module is the one place that guarantees a real, usable `npm`/`node` exists for its
|
|
30
25
|
// consumers: prefer whatever is already on system PATH (common for technical users), then an
|
|
31
26
|
// already-downloaded managed runtime (shared with the CLI installer's `~/.fraim/node/` convention
|
|
32
27
|
// via `managed-agent-paths.ts` — reused, not duplicated, so a user who has ever run the `fraim` CLI
|
|
@@ -65,11 +60,14 @@ function findExecutableOnPath(name, pathValue = process.env.PATH) {
|
|
|
65
60
|
function hasWorkingNodeAndNpm(dir, platform = process.platform) {
|
|
66
61
|
return fs_1.default.existsSync(path_1.default.join(dir, npmExecutableName(platform))) && fs_1.default.existsSync(path_1.default.join(dir, nodeExecutableName(platform)));
|
|
67
62
|
}
|
|
68
|
-
|
|
63
|
+
function nodeBinDir(root, platform) {
|
|
64
|
+
return platform === 'win32' ? root : path_1.default.join(root, 'bin');
|
|
65
|
+
}
|
|
66
|
+
/** Whether `dir` (a versioned distribution root) holds a complete extraction for `version`. */
|
|
69
67
|
function isManagedNodeVersionComplete(dir, version, platform = process.platform) {
|
|
70
68
|
try {
|
|
71
69
|
const recorded = fs_1.default.readFileSync(path_1.default.join(dir, 'fraim-managed-node-version'), 'utf8').trim();
|
|
72
|
-
return recorded === version && hasWorkingNodeAndNpm(dir, platform);
|
|
70
|
+
return recorded === version && hasWorkingNodeAndNpm(nodeBinDir(dir, platform), platform);
|
|
73
71
|
}
|
|
74
72
|
catch {
|
|
75
73
|
return false;
|
|
@@ -99,6 +97,9 @@ function assertPathSafeNodeVersion(version) {
|
|
|
99
97
|
}
|
|
100
98
|
/** 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
99
|
function nodeVersionedDirName(request) {
|
|
100
|
+
if (!/^[a-z0-9_]+$/.test(request.arch)) {
|
|
101
|
+
throw new Error('Refusing to use an invalid managed Node.js architecture.');
|
|
102
|
+
}
|
|
102
103
|
return `node-v${assertPathSafeNodeVersion(request.version)}-${nodePlatformName(request.platform)}-${request.arch}`;
|
|
103
104
|
}
|
|
104
105
|
function nodeArchiveFileName(request) {
|
|
@@ -203,15 +204,16 @@ const defaultExtractNodeArchive = async (archivePath, destDir, request) => {
|
|
|
203
204
|
async function downloadAndPromoteVersionedNodeDir(args) {
|
|
204
205
|
const { nodeRoot, request, downloadArchive, extractArchive } = args;
|
|
205
206
|
const destDir = path_1.default.join(nodeRoot, nodeVersionedDirName(request));
|
|
206
|
-
const stagingDir = `${destDir}.staging-${process.pid}`;
|
|
207
207
|
fs_1.default.mkdirSync(nodeRoot, { recursive: true });
|
|
208
|
-
fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
|
|
209
208
|
const archivePath = await downloadArchive(request);
|
|
209
|
+
let stagingDir;
|
|
210
210
|
try {
|
|
211
|
+
// tar requires an existing cwd. Each invocation owns a separate directory,
|
|
212
|
+
// including simultaneous requests within the same process.
|
|
213
|
+
stagingDir = fs_1.default.mkdtempSync(`${destDir}.staging-${process.pid}-`);
|
|
211
214
|
await extractArchive(archivePath, stagingDir, request);
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
// the archive's internal folder) — handle both without assuming either.
|
|
215
|
+
// Official tar and ZIP archives preserve their top-level distribution folder.
|
|
216
|
+
// Also accept a flattened root from a supplied extractor.
|
|
215
217
|
const nestedCandidate = path_1.default.join(stagingDir, nodeVersionedDirName(request));
|
|
216
218
|
const extractedRoot = fs_1.default.existsSync(nestedCandidate) ? nestedCandidate : stagingDir;
|
|
217
219
|
fs_1.default.writeFileSync(path_1.default.join(extractedRoot, 'fraim-managed-node-version'), request.version, 'utf8');
|
|
@@ -233,9 +235,10 @@ async function downloadAndPromoteVersionedNodeDir(args) {
|
|
|
233
235
|
}
|
|
234
236
|
finally {
|
|
235
237
|
fs_1.default.rmSync(archivePath, { force: true });
|
|
236
|
-
|
|
238
|
+
if (stagingDir)
|
|
239
|
+
fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
|
|
237
240
|
}
|
|
238
|
-
return destDir;
|
|
241
|
+
return nodeBinDir(destDir, request.platform);
|
|
239
242
|
}
|
|
240
243
|
/**
|
|
241
244
|
* Resolve a working `npm` executable, preferring (in order): system PATH, an already-downloaded
|
|
@@ -252,7 +255,7 @@ async function ensureManagedNpm(options = {}) {
|
|
|
252
255
|
if (systemNpm) {
|
|
253
256
|
return { npmPath: systemNpm, binDir: path_1.default.dirname(systemNpm), source: 'system' };
|
|
254
257
|
}
|
|
255
|
-
const existingBinDir = (0, managed_agent_paths_1.getPortableNodeBinPath)();
|
|
258
|
+
const existingBinDir = (0, managed_agent_paths_1.getPortableNodeBinPath)(platform, arch);
|
|
256
259
|
if (hasWorkingNodeAndNpm(existingBinDir, platform)) {
|
|
257
260
|
return { npmPath: path_1.default.join(existingBinDir, npmExecutableName(platform)), binDir: existingBinDir, source: 'managed-existing' };
|
|
258
261
|
}
|
|
@@ -323,7 +323,7 @@ function buildFirstRunHubLaunchArgs() {
|
|
|
323
323
|
class FirstRunSessionService {
|
|
324
324
|
constructor(options) {
|
|
325
325
|
// Issue #646: the key may be absent when first-run is launched by the macOS
|
|
326
|
-
// installer (.
|
|
326
|
+
// installer (.dmg) instead of `fraim first-run --key=…`. In that case the
|
|
327
327
|
// wizard prompts the user to paste their key (see setKey / needsKey).
|
|
328
328
|
this.key = options.key || '';
|
|
329
329
|
this.headless = options.headless === true;
|
|
@@ -393,13 +393,15 @@ class FirstRunSessionService {
|
|
|
393
393
|
// Real detection — populate row statuses from `command -v` style probes.
|
|
394
394
|
const nodeRow = this.getRow('node');
|
|
395
395
|
const nodeVer = this.embeddedDesktop ? process.version : commandVersion('node');
|
|
396
|
-
|
|
396
|
+
const embeddedLaunchersReady = !this.embeddedDesktop
|
|
397
|
+
|| (fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)()) && fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimMcpShimPath)()));
|
|
398
|
+
if (nodeVer && embeddedLaunchersReady) {
|
|
397
399
|
nodeRow.status = 'ok';
|
|
398
400
|
nodeRow.verb = this.embeddedDesktop ? `${nodeVer} bundled with FRAIM` : `${nodeVer} detected`;
|
|
399
401
|
}
|
|
400
402
|
else {
|
|
401
403
|
nodeRow.status = 'pending';
|
|
402
|
-
nodeRow.verb = "we'll install";
|
|
404
|
+
nodeRow.verb = this.embeddedDesktop ? 'installer repair required' : "we'll install";
|
|
403
405
|
}
|
|
404
406
|
const gitRow = this.getRow('git');
|
|
405
407
|
const gitVer = commandVersion('git');
|
|
@@ -669,11 +671,16 @@ class FirstRunSessionService {
|
|
|
669
671
|
async runNodeRow() {
|
|
670
672
|
const row = this.getRow('node');
|
|
671
673
|
if (this.embeddedDesktop) {
|
|
674
|
+
if (!this.fakeMode && (!fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)()) || !fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimMcpShimPath)()))) {
|
|
675
|
+
this.setRowError(row, 'Checking the native installer setup', 'Stable FRAIM CLI/MCP launchers are missing. Re-run the native installer in repair mode.', [{ id: 'retry', label: 'Check again', variant: 'primary' }]);
|
|
676
|
+
this.persist();
|
|
677
|
+
return this.respond('FRAIM installer repair is required.', false);
|
|
678
|
+
}
|
|
672
679
|
persistShellPath(this.embeddedDesktop);
|
|
673
680
|
row.status = 'ok';
|
|
674
|
-
row.verb =
|
|
681
|
+
row.verb = 'Node and FRAIM launchers ready';
|
|
675
682
|
this.persist();
|
|
676
|
-
return this.respond(
|
|
683
|
+
return this.respond('FRAIM command-line tools are ready.', true);
|
|
677
684
|
}
|
|
678
685
|
// Node is bootstrapped by the install template before the wizard starts.
|
|
679
686
|
// If node is missing here something is severely wrong; fall through to error.
|
|
@@ -737,10 +744,10 @@ class FirstRunSessionService {
|
|
|
737
744
|
return this.respond('FRAIM configured.', true);
|
|
738
745
|
}
|
|
739
746
|
try {
|
|
740
|
-
if (this.embeddedDesktop) {
|
|
741
|
-
(
|
|
747
|
+
if (this.embeddedDesktop && (!fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)()) || !fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimMcpShimPath)()))) {
|
|
748
|
+
throw new Error('Stable FRAIM launchers are missing. Re-run the native installer in repair mode.');
|
|
742
749
|
}
|
|
743
|
-
else if (!forceConfigure && !commandVersion('fraim')) {
|
|
750
|
+
else if (!this.embeddedDesktop && !forceConfigure && !commandVersion('fraim')) {
|
|
744
751
|
const prefix = path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'node');
|
|
745
752
|
fs_1.default.mkdirSync(prefix, { recursive: true });
|
|
746
753
|
row.streamOutput = 'Installing FRAIM on this machine...';
|
|
@@ -1051,12 +1058,11 @@ class FirstRunSessionService {
|
|
|
1051
1058
|
};
|
|
1052
1059
|
}
|
|
1053
1060
|
if (this.embeddedDesktop) {
|
|
1054
|
-
(0, fraim_mcp_latest_launcher_1.
|
|
1055
|
-
if (!fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)())) {
|
|
1061
|
+
if (!fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)()) || !fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimMcpShimPath)())) {
|
|
1056
1062
|
return {
|
|
1057
1063
|
ok: false,
|
|
1058
1064
|
needsAgentSetup: true,
|
|
1059
|
-
message: 'FRAIM CLI
|
|
1065
|
+
message: 'FRAIM CLI/MCP launchers are missing. Re-run the native installer in repair mode before opening Hub.',
|
|
1060
1066
|
};
|
|
1061
1067
|
}
|
|
1062
1068
|
appendInstallLog('hub-opened embedded-desktop');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-hub",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.327",
|
|
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",
|
|
@@ -168,7 +168,8 @@
|
|
|
168
168
|
"shortcutName": "FRAIM Hub",
|
|
169
169
|
"uninstallDisplayName": "FRAIM Hub",
|
|
170
170
|
"artifactName": "FRAIM-Setup.${ext}",
|
|
171
|
-
"runAfterFinish":
|
|
171
|
+
"runAfterFinish": false,
|
|
172
|
+
"include": "build/installer.nsh"
|
|
172
173
|
},
|
|
173
174
|
"mac": {
|
|
174
175
|
"icon": "build/icon.icns",
|
|
@@ -213,7 +214,7 @@
|
|
|
213
214
|
"electron-updater": "^6.8.9",
|
|
214
215
|
"express": "^5.2.1",
|
|
215
216
|
"extract-zip": "^2.0.1",
|
|
216
|
-
"fraim": "2.0.
|
|
217
|
+
"fraim": "2.0.327",
|
|
217
218
|
"mongodb": "^7.0.0",
|
|
218
219
|
"node-cron": "4.2.1",
|
|
219
220
|
"node-edge-tts": "^1.2.10",
|
package/public/ai-hub/index.html
CHANGED
|
@@ -442,6 +442,7 @@
|
|
|
442
442
|
<span class="panel-summary-copy">
|
|
443
443
|
<span class="panel-kicker" id="thread-panel-kicker">Manage</span>
|
|
444
444
|
</span>
|
|
445
|
+
<button class="conversation-focus-btn" id="conversation-focus-btn" type="button" aria-label="Expand conversation" title="Expand conversation" onclick="event.preventDefault(); event.stopPropagation();">↗</button>
|
|
445
446
|
</summary>
|
|
446
447
|
<!-- Issue #1065: find a passage inside the open conversation (R44-R46). A result that
|
|
447
448
|
promises "+6 more matches" is an unkept promise without this: the manager lands in
|
|
@@ -475,7 +476,6 @@
|
|
|
475
476
|
</div>
|
|
476
477
|
</details>
|
|
477
478
|
<div class="manage-header-actions">
|
|
478
|
-
<button class="conversation-focus-btn" id="conversation-focus-btn" type="button" aria-label="Expand conversation" title="Expand conversation" onclick="event.preventDefault(); event.stopPropagation();">↗</button>
|
|
479
479
|
<button class="micro-manage-toggle" id="micro-manage-toggle" type="button" role="switch" aria-checked="false" aria-controls="manage-body"><span>Micro-Manage</span><span class="micro-switch-track" aria-hidden="true"></span></button>
|
|
480
480
|
</div>
|
|
481
481
|
</div>
|
package/public/ai-hub/script.js
CHANGED
|
@@ -114,6 +114,18 @@ const state = {
|
|
|
114
114
|
|
|
115
115
|
const bootstrapCache = new Map();
|
|
116
116
|
const conversationBodyFetches = new Map();
|
|
117
|
+
const deletedConversationIds = new Set();
|
|
118
|
+
let activeConversationId = state.activeId;
|
|
119
|
+
Object.defineProperty(state, 'activeId', {
|
|
120
|
+
get() {
|
|
121
|
+
return activeConversationId && deletedConversationIds.has(activeConversationId) ? null : activeConversationId;
|
|
122
|
+
},
|
|
123
|
+
set(value) {
|
|
124
|
+
activeConversationId = value && !deletedConversationIds.has(value) ? value : null;
|
|
125
|
+
},
|
|
126
|
+
enumerable: true,
|
|
127
|
+
configurable: true,
|
|
128
|
+
});
|
|
117
129
|
|
|
118
130
|
const els = {};
|
|
119
131
|
|
|
@@ -1406,6 +1418,7 @@ function clearConversationBodyPendingFor(conv, fallbackProjectPath, id) {
|
|
|
1406
1418
|
|
|
1407
1419
|
async function hydrateConversationBody(projectPath, id, options = {}) {
|
|
1408
1420
|
if (!id) return null;
|
|
1421
|
+
if (deletedConversationIds.has(id)) return null;
|
|
1409
1422
|
const existing = findConversation(id);
|
|
1410
1423
|
if (!options.force && conversationHasBody(existing) && !conversationBodyIsStale(existing)) {
|
|
1411
1424
|
clearConversationBodyPendingFor(existing, projectPath, id);
|
|
@@ -1418,7 +1431,9 @@ async function hydrateConversationBody(projectPath, id, options = {}) {
|
|
|
1418
1431
|
const promise = (async () => {
|
|
1419
1432
|
try {
|
|
1420
1433
|
const payload = await requestJson(`/api/ai-hub/conversations?${params.toString()}`);
|
|
1434
|
+
if (deletedConversationIds.has(id)) return null;
|
|
1421
1435
|
const body = payload && payload.conversation;
|
|
1436
|
+
if (body && deletedConversationIds.has(body.id)) return null;
|
|
1422
1437
|
if (!body) throw new Error('Conversation activity is unavailable');
|
|
1423
1438
|
activityLoadErrors.delete(manageKey(body));
|
|
1424
1439
|
normalizeGeminiConversationMessages(body);
|
|
@@ -1613,7 +1628,8 @@ async function hydrateConversationsFromServer() {
|
|
|
1613
1628
|
try {
|
|
1614
1629
|
for (const [scope, key] of [['manager', MANAGER_CONV_KEY], ['company', COMPANY_CONV_KEY]]) {
|
|
1615
1630
|
const scopePayload = await requestJson(`/api/ai-hub/conversations?scope=${scope}`);
|
|
1616
|
-
const scopeConvs = Array.isArray(scopePayload.conversations) ? scopePayload.conversations : []
|
|
1631
|
+
const scopeConvs = (Array.isArray(scopePayload.conversations) ? scopePayload.conversations : [])
|
|
1632
|
+
.filter((conv) => conv && !deletedConversationIds.has(conv.id));
|
|
1617
1633
|
for (const conv of scopeConvs) normalizeGeminiConversationMessages(conv);
|
|
1618
1634
|
// Only materialize a scope bucket when it has content (or already exists). Creating
|
|
1619
1635
|
// empty @manager/@company buckets on every project hydrate pollutes state.conversations
|
|
@@ -1633,7 +1649,13 @@ async function hydrateConversationsFromServer() {
|
|
|
1633
1649
|
const projectPath = state.projectPath;
|
|
1634
1650
|
const enc = encodeURIComponent(projectPath);
|
|
1635
1651
|
const pickActive = (list, serverActiveId) => {
|
|
1636
|
-
const
|
|
1652
|
+
const suppressServerActive = !state.activeId && (state.cpBrowseAll || state.cpSelectedJob);
|
|
1653
|
+
if (suppressServerActive) {
|
|
1654
|
+
state.activeId = null;
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
const activeCandidates = [state.activeId, suppressServerActive ? null : serverActiveId]
|
|
1658
|
+
.filter((id) => id && !deletedConversationIds.has(id));
|
|
1637
1659
|
// Issue #1408: a manager/company-scoped conversation must not become the Projects
|
|
1638
1660
|
// workspace active conversation — it's shown in its own Company/Manager panel via
|
|
1639
1661
|
// tfActiveOrgConv/tfActiveMgrConv instead. Decided by scope, not by job id.
|
|
@@ -1647,7 +1669,8 @@ async function hydrateConversationsFromServer() {
|
|
|
1647
1669
|
// the selected conversation body. Never fetch the full project body on the interactive path.
|
|
1648
1670
|
const headerPayload = await requestJson(`/api/ai-hub/conversations?projectPath=${enc}&headersOnly=1`);
|
|
1649
1671
|
if (state.projectPath !== projectPath) return; // user switched projects mid-load
|
|
1650
|
-
const headers = Array.isArray(headerPayload.conversations) ? headerPayload.conversations : []
|
|
1672
|
+
const headers = (Array.isArray(headerPayload.conversations) ? headerPayload.conversations : [])
|
|
1673
|
+
.filter((conv) => conv && !deletedConversationIds.has(conv.id));
|
|
1651
1674
|
const mergedHeaders = mergeConversationListHeaders(state.conversations[projectPath] || [], headers);
|
|
1652
1675
|
state.conversations[projectPath] = mergedHeaders;
|
|
1653
1676
|
pickActive(mergedHeaders, headerPayload.activeId);
|
|
@@ -1668,7 +1691,8 @@ async function hydrateConversationsFromServer() {
|
|
|
1668
1691
|
async function hydrateProjectConversationHeaders(projectPath) {
|
|
1669
1692
|
if (!projectPath) return false;
|
|
1670
1693
|
const payload = await requestJson(`/api/ai-hub/conversations?projectPath=${encodeURIComponent(projectPath)}&headersOnly=1`);
|
|
1671
|
-
const incoming = Array.isArray(payload.conversations) ? payload.conversations : []
|
|
1694
|
+
const incoming = (Array.isArray(payload.conversations) ? payload.conversations : [])
|
|
1695
|
+
.filter((conv) => conv && !deletedConversationIds.has(conv.id));
|
|
1672
1696
|
const existing = state.conversations[projectPath] || [];
|
|
1673
1697
|
const merged = mergeConversationListHeaders(existing, incoming);
|
|
1674
1698
|
const changed = existing.length !== merged.length
|
|
@@ -2037,6 +2061,9 @@ function findConversation(id) {
|
|
|
2037
2061
|
}
|
|
2038
2062
|
|
|
2039
2063
|
function activeConversation() {
|
|
2064
|
+
if (state.activeId && deletedConversationIds.has(state.activeId)) {
|
|
2065
|
+
state.activeId = null;
|
|
2066
|
+
}
|
|
2040
2067
|
if (!state.activeId) return null;
|
|
2041
2068
|
return findConversation(state.activeId);
|
|
2042
2069
|
}
|
|
@@ -2380,11 +2407,20 @@ async function deleteConversation(id) {
|
|
|
2380
2407
|
const target = findConversation(id);
|
|
2381
2408
|
if (!target) return false;
|
|
2382
2409
|
const params = conversationLookupParams(target, target.projectPath);
|
|
2410
|
+
// Tombstone before the async delete so any in-flight header/body hydrate that
|
|
2411
|
+
// resolves during the server round trip cannot reselect or reinsert this run.
|
|
2412
|
+
deletedConversationIds.add(id);
|
|
2413
|
+
if (activeConversationId === id || state.activeId === id) {
|
|
2414
|
+
activeConversationId = null;
|
|
2415
|
+
state.activeId = null;
|
|
2416
|
+
if (state.pollHandle) { window.clearInterval(state.pollHandle); state.pollHandle = null; }
|
|
2417
|
+
}
|
|
2383
2418
|
try {
|
|
2384
2419
|
await requestJson(`/api/ai-hub/conversations/${encodeURIComponent(id)}?${params.toString()}`, {
|
|
2385
2420
|
method: 'DELETE',
|
|
2386
2421
|
});
|
|
2387
2422
|
} catch (error) {
|
|
2423
|
+
deletedConversationIds.delete(id);
|
|
2388
2424
|
showStatus(error instanceof Error ? error.message : String(error), true);
|
|
2389
2425
|
return false;
|
|
2390
2426
|
}
|
|
@@ -2394,16 +2430,19 @@ async function deleteConversation(id) {
|
|
|
2394
2430
|
const next = list.filter((c) => c && c.id !== id);
|
|
2395
2431
|
if (next.length !== list.length) { state.conversations[key] = next; removed = true; }
|
|
2396
2432
|
}
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
activityLoadErrors.delete(manageKey(target));
|
|
2401
|
-
if (state.activeId === id) {
|
|
2433
|
+
const clearDeletedActive = () => {
|
|
2434
|
+
if (!(activeConversationId === id || state.activeId === id || deletedConversationIds.has(activeConversationId) || deletedConversationIds.has(state.activeId))) return;
|
|
2435
|
+
activeConversationId = null;
|
|
2402
2436
|
state.activeId = null;
|
|
2403
2437
|
// Deleting the active run: stop its poll so no zombie interval keeps ticking
|
|
2404
2438
|
// against a conversation that no longer exists.
|
|
2405
2439
|
if (state.pollHandle) { window.clearInterval(state.pollHandle); state.pollHandle = null; }
|
|
2406
|
-
}
|
|
2440
|
+
};
|
|
2441
|
+
clearDeletedActive();
|
|
2442
|
+
if (!removed) return false;
|
|
2443
|
+
manageViews.delete(manageKey(target));
|
|
2444
|
+
microArchiveViews.delete(manageKey(target));
|
|
2445
|
+
activityLoadErrors.delete(manageKey(target));
|
|
2407
2446
|
if (state.panelState) delete state.panelState[id];
|
|
2408
2447
|
// Drop any learning-run pointer that referenced this conversation.
|
|
2409
2448
|
if (typeof tfLearningRunByScope === 'object' && tfLearningRunByScope) {
|
|
@@ -2413,6 +2452,7 @@ async function deleteConversation(id) {
|
|
|
2413
2452
|
}
|
|
2414
2453
|
if (typeof refreshStatusSurfaces === 'function') refreshStatusSurfaces();
|
|
2415
2454
|
if (typeof renderActive === 'function') renderActive();
|
|
2455
|
+
clearDeletedActive();
|
|
2416
2456
|
if (typeof showStatus === 'function') showStatus('Run deleted.', false);
|
|
2417
2457
|
return true;
|
|
2418
2458
|
}
|
|
@@ -7938,6 +7978,7 @@ function parseCpSearch(text) {
|
|
|
7938
7978
|
function focusComposerForDelegate(opts) {
|
|
7939
7979
|
state.cpActionContext = (opts && opts.actionContext) || managementActionContext(state.pendingNextJobArea || undefined);
|
|
7940
7980
|
state.activeId = null;
|
|
7981
|
+
persistActiveConversationSelection(null, { projectPath: state.projectPath || '' });
|
|
7941
7982
|
state.showProjectInfo = false;
|
|
7942
7983
|
if (tf.area === 'company' || tf.area === 'manager') {
|
|
7943
7984
|
tf.areaSelection[tf.area] = 'delegate';
|
|
@@ -8036,6 +8077,9 @@ function renderComposerSuggestions(searchText) {
|
|
|
8036
8077
|
// and never reaches this function. This is the only thing that changes
|
|
8037
8078
|
// across every path; the box itself never does.
|
|
8038
8079
|
async function sendFromComposer() {
|
|
8080
|
+
const submitDelegate = (typeof window !== 'undefined' && typeof window.submitComposerDelegate === 'function')
|
|
8081
|
+
? window.submitComposerDelegate
|
|
8082
|
+
: submitComposerDelegate;
|
|
8039
8083
|
if (!state.activeId) {
|
|
8040
8084
|
// Issue #989: a next-job-recommendation chip can pre-select a job (and
|
|
8041
8085
|
// pre-fill instructions) without going through the suggestion list at
|
|
@@ -8053,7 +8097,7 @@ async function sendFromComposer() {
|
|
|
8053
8097
|
}
|
|
8054
8098
|
const job = state.cpSelectedJob;
|
|
8055
8099
|
state.cpSelectedJob = null;
|
|
8056
|
-
await
|
|
8100
|
+
await submitDelegate(job, text);
|
|
8057
8101
|
return;
|
|
8058
8102
|
}
|
|
8059
8103
|
if (state.cpHighlightIndex >= 0 && state.cpRows[state.cpHighlightIndex]) {
|
|
@@ -8061,7 +8105,7 @@ async function sendFromComposer() {
|
|
|
8061
8105
|
} else {
|
|
8062
8106
|
const text = els['coach-text'].value.trim();
|
|
8063
8107
|
if (!text) return;
|
|
8064
|
-
await
|
|
8108
|
+
await submitDelegate({ id: '__freeform__', title: 'Ad-hoc task', intent: '' }, text);
|
|
8065
8109
|
}
|
|
8066
8110
|
return;
|
|
8067
8111
|
}
|
|
@@ -11452,6 +11496,12 @@ function convFindOpen(term) {
|
|
|
11452
11496
|
convFind.term = cleaned;
|
|
11453
11497
|
convFind.index = 0;
|
|
11454
11498
|
convFindApply({ scrollToCurrent: true });
|
|
11499
|
+
setTimeout(() => {
|
|
11500
|
+
if (convFind.open && convFind.term === cleaned) convFindApply({ scrollToCurrent: true });
|
|
11501
|
+
}, 100);
|
|
11502
|
+
setTimeout(() => {
|
|
11503
|
+
if (convFind.open && convFind.term === cleaned) convFindApply({ scrollToCurrent: true });
|
|
11504
|
+
}, 300);
|
|
11455
11505
|
}
|
|
11456
11506
|
|
|
11457
11507
|
function convFindClose() {
|
|
@@ -11562,8 +11612,31 @@ function convFindHighlightCurrent(scrollIntoView) {
|
|
|
11562
11612
|
hits.forEach((hit, index) => hit.classList.toggle('on', index === convFind.index));
|
|
11563
11613
|
const current = hits[convFind.index];
|
|
11564
11614
|
if (current && scrollIntoView) {
|
|
11615
|
+
const scrollCurrentIntoMessages = () => {
|
|
11616
|
+
let host = document.getElementById('messages');
|
|
11617
|
+
for (let node = current.parentElement; node && node !== document.body; node = node.parentElement) {
|
|
11618
|
+
if (node.scrollHeight > node.clientHeight + 1) {
|
|
11619
|
+
host = node;
|
|
11620
|
+
break;
|
|
11621
|
+
}
|
|
11622
|
+
if (node.id === 'thread-panel') break;
|
|
11623
|
+
}
|
|
11624
|
+
if (!host) return;
|
|
11625
|
+
const hostBox = host.getBoundingClientRect();
|
|
11626
|
+
const currentBox = current.getBoundingClientRect();
|
|
11627
|
+
const targetTop = host.scrollTop
|
|
11628
|
+
+ (currentBox.top - hostBox.top)
|
|
11629
|
+
- (host.clientHeight / 2)
|
|
11630
|
+
+ (currentBox.height / 2);
|
|
11631
|
+
host.scrollTop = Math.max(0, targetTop);
|
|
11632
|
+
};
|
|
11565
11633
|
// The thread panel scrolls, and a match can be hundreds of messages down (R45).
|
|
11566
|
-
|
|
11634
|
+
// scrollIntoView can target the page/details wrapper in Chromium; move the
|
|
11635
|
+
// actual overflowing message ancestor directly.
|
|
11636
|
+
scrollCurrentIntoMessages();
|
|
11637
|
+
requestAnimationFrame(scrollCurrentIntoMessages);
|
|
11638
|
+
setTimeout(scrollCurrentIntoMessages, 0);
|
|
11639
|
+
setTimeout(scrollCurrentIntoMessages, 50);
|
|
11567
11640
|
}
|
|
11568
11641
|
}
|
|
11569
11642
|
|
|
@@ -18365,4 +18438,5 @@ if (typeof window !== 'undefined') {
|
|
|
18365
18438
|
// Issue #1067: let the suite trigger the create-employee form so scope picker
|
|
18366
18439
|
// tests can assert the rendered controls without depending on click coordinates.
|
|
18367
18440
|
window.openInlineCreateEmployee = openInlineCreateEmployee;
|
|
18441
|
+
window.submitComposerDelegate = submitComposerDelegate;
|
|
18368
18442
|
}
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -5738,10 +5738,11 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
|
|
|
5738
5738
|
.hub-cobrand[hidden] { display: none; }
|
|
5739
5739
|
.hub-cobrand-mark { width: 17px; height: 17px; border-radius: 5px; display: block; }
|
|
5740
5740
|
.hub-cobrand-mark img { width: 100%; height: 100%; object-fit: contain; display: block; border-radius: 5px; }
|
|
5741
|
-
/* Tablet-width navs cannot hold the company lockup, tabs, search, co-mark,
|
|
5742
|
-
account control together. Keep the
|
|
5741
|
+
/* Tablet-width navs cannot hold the company lockup, tabs, search, co-mark,
|
|
5742
|
+
update badge, and account control together. Keep the avatar reachable. */
|
|
5743
5743
|
@media (max-width: 820px) {
|
|
5744
5744
|
.hub-cobrand { display: none; }
|
|
5745
|
+
.hub-update-badge { display: none; }
|
|
5745
5746
|
}
|
|
5746
5747
|
/* Very narrow windows (the Hub is desktop/Electron-first, but keep the dense nav
|
|
5747
5748
|
from forcing horizontal page scroll): drop the co-mark and the company name,
|
|
@@ -5751,12 +5752,6 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
|
|
|
5751
5752
|
.hub-brand { padding: 0 2px 0 10px; }
|
|
5752
5753
|
.hub-brand-divider { margin: 0 4px 0 8px; }
|
|
5753
5754
|
.hub-tab { padding: 10px 12px; }
|
|
5754
|
-
/* #1379: the badge's own text ("Updates available") is exactly what forced the same
|
|
5755
|
-
horizontal-scroll problem this block already exists to prevent - measured at 375px:
|
|
5756
|
-
95px of overflow with the badge shown, none without it. Same treatment as the
|
|
5757
|
-
co-mark above: the update is still reachable via #avatar-btn -> #am-update at this
|
|
5758
|
-
width, just not duplicated in the top bar where there's no room for it. */
|
|
5759
|
-
.hub-update-badge { display: none; }
|
|
5760
5755
|
}
|
|
5761
5756
|
|
|
5762
5757
|
/* Brand editor (Company tab → Brand accordion). */
|
|
@@ -5778,6 +5773,20 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
|
|
|
5778
5773
|
[data-theme="dark"] .brand-editor-actions .send-button { color: var(--bg); }
|
|
5779
5774
|
.brand-preview { border: 1px solid var(--line); border-radius: 12px; overflow: hidden; margin-top: 14px; }
|
|
5780
5775
|
.brand-preview-cap { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); padding: 10px 14px; background: var(--bg); border-bottom: 1px solid var(--line); }
|
|
5776
|
+
.brand-preview .hub-tabs { width: 100%; min-width: 0; max-width: 100%; overflow: hidden; }
|
|
5777
|
+
.brand-preview .hub-brand { min-width: 0; }
|
|
5778
|
+
.brand-preview .hub-brand-name { max-width: min(220px, 24vw); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
5779
|
+
.brand-preview .nav-right { min-width: 0; flex-shrink: 0; }
|
|
5780
|
+
@media (max-width: 820px) {
|
|
5781
|
+
.brand-preview .hub-cobrand { display: none; }
|
|
5782
|
+
.brand-preview .hub-brand-name { max-width: 120px; }
|
|
5783
|
+
.brand-preview .hub-tab { padding-left: 12px; padding-right: 12px; }
|
|
5784
|
+
}
|
|
5785
|
+
@media (max-width: 700px) {
|
|
5786
|
+
.brand-preview .hub-brand-name { display: none; }
|
|
5787
|
+
.brand-preview .hub-brand { padding-left: 8px; }
|
|
5788
|
+
.brand-preview .hub-tab { padding-left: 10px; padding-right: 10px; }
|
|
5789
|
+
}
|
|
5781
5790
|
|
|
5782
5791
|
/* ── New-project: folder picker row ── */
|
|
5783
5792
|
.np-folder-row { display:flex; gap:8px; }
|