fraim-framework 2.0.162 → 2.0.164
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/desktop-main.js +4 -1
- package/dist/src/ai-hub/hosts.js +97 -12
- package/dist/src/ai-hub/preferences.js +1 -1
- package/dist/src/ai-hub/server.js +49 -123
- package/dist/src/cli/commands/init-project.js +15 -14
- package/dist/src/cli/commands/sync.js +38 -0
- package/dist/src/cli/doctor/check-runner.js +3 -1
- package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +261 -2
- package/dist/src/cli/utils/git-org-sync.js +56 -0
- package/dist/src/cli/utils/org-migration.js +50 -0
- package/dist/src/cli/utils/org-pack-sync.js +208 -0
- package/dist/src/cli/utils/project-bootstrap.js +20 -7
- package/dist/src/cli/utils/user-config.js +68 -0
- package/dist/src/core/fraim-config-schema.generated.js +10 -0
- package/dist/src/first-run/types.js +8 -0
- package/dist/src/local-mcp-server/agent-token-prices.js +30 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +78 -29
- package/dist/src/local-mcp-server/stdio-server.js +30 -0
- package/index.js +1 -1
- package/package.json +2 -3
- package/public/ai-hub/index.html +5 -5
- package/public/ai-hub/powerpoint-taskpane/index.html +236 -236
- package/public/ai-hub/powerpoint-taskpane/manifest.xml +29 -29
- package/public/ai-hub/review.css +15 -15
- package/public/ai-hub/script.js +254 -195
- package/public/ai-hub/styles.css +206 -16
- package/public/first-run/styles.css +73 -73
- package/dist/src/ai-hub/word-sideload.js +0 -95
- package/dist/src/cli/commands/test-mcp.js +0 -171
- package/dist/src/cli/setup/first-run.js +0 -242
- package/dist/src/core/config-writer.js +0 -75
- package/dist/src/core/utils/job-aliases.js +0 -47
- package/dist/src/core/utils/workflow-parser.js +0 -174
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* MCP connectivity checks for FRAIM doctor command
|
|
4
4
|
* Tests FRAIM server connectivity and validates IDE MCP configurations
|
|
5
5
|
* Issue #144: Enhanced doctor command
|
|
6
|
+
* Issue #532: Stdio MCP runtime connectivity check
|
|
6
7
|
*/
|
|
7
8
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
8
9
|
if (k2 === undefined) k2 = k;
|
|
@@ -43,6 +44,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
43
44
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
44
45
|
exports.getNpmMajorVersion = getNpmMajorVersion;
|
|
45
46
|
exports.diagnoseFraimMcpLaunchPlan = diagnoseFraimMcpLaunchPlan;
|
|
47
|
+
exports.spawnAndHandshake = spawnAndHandshake;
|
|
48
|
+
exports.getStdioMCPRuntimeChecks = getStdioMCPRuntimeChecks;
|
|
46
49
|
exports.getMCPConnectivityChecks = getMCPConnectivityChecks;
|
|
47
50
|
const fs_1 = __importDefault(require("fs"));
|
|
48
51
|
const path_1 = __importDefault(require("path"));
|
|
@@ -52,7 +55,16 @@ const axios_1 = __importDefault(require("axios"));
|
|
|
52
55
|
const toml = __importStar(require("toml"));
|
|
53
56
|
const ide_detector_1 = require("../../setup/ide-detector");
|
|
54
57
|
const fraim_mcp_latest_launcher_1 = require("../../mcp/fraim-mcp-latest-launcher");
|
|
58
|
+
const command_resolution_1 = require("../../mcp/command-resolution");
|
|
59
|
+
// Cache the npm major version so execFileSync is called at most once per process.
|
|
60
|
+
// Without caching, each IDE config check calls diagnoseFraimMcpLaunchPlan which calls
|
|
61
|
+
// getNpmMajorVersion, and execFileSync blocks the event loop for ~2s on Windows.
|
|
62
|
+
// With 8+ IDE checks running via Promise.all the blocking stacks sequentially.
|
|
63
|
+
let _npmMajorVersionCache = undefined;
|
|
55
64
|
function getNpmMajorVersion() {
|
|
65
|
+
if (_npmMajorVersionCache !== undefined) {
|
|
66
|
+
return _npmMajorVersionCache;
|
|
67
|
+
}
|
|
56
68
|
try {
|
|
57
69
|
const command = process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : 'npm';
|
|
58
70
|
const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'npm --version'] : ['--version'];
|
|
@@ -61,11 +73,12 @@ function getNpmMajorVersion() {
|
|
|
61
73
|
stdio: ['ignore', 'pipe', 'ignore']
|
|
62
74
|
});
|
|
63
75
|
const major = Number.parseInt(output.trim().split('.')[0], 10);
|
|
64
|
-
|
|
76
|
+
_npmMajorVersionCache = Number.isFinite(major) ? major : null;
|
|
65
77
|
}
|
|
66
78
|
catch {
|
|
67
|
-
|
|
79
|
+
_npmMajorVersionCache = null;
|
|
68
80
|
}
|
|
81
|
+
return _npmMajorVersionCache;
|
|
69
82
|
}
|
|
70
83
|
function diagnoseFraimMcpLaunchPlan(fraimServer, platform = process.platform, npmMajorVersion = getNpmMajorVersion()) {
|
|
71
84
|
const command = String(fraimServer?.command || '');
|
|
@@ -419,6 +432,248 @@ async function validateIDEMCPConfig(ide) {
|
|
|
419
432
|
};
|
|
420
433
|
}
|
|
421
434
|
}
|
|
435
|
+
// ============================================================
|
|
436
|
+
// Issue #532: Stdio MCP Runtime Connectivity Check
|
|
437
|
+
// ============================================================
|
|
438
|
+
const STDIO_HANDSHAKE_TIMEOUT_MS = 15000;
|
|
439
|
+
/**
|
|
440
|
+
* Spawn a stdio MCP server, perform a two-phase JSON-RPC handshake
|
|
441
|
+
* (initialize then tools/list), and return a structured result.
|
|
442
|
+
*
|
|
443
|
+
* On Windows, wraps the command in cmd.exe /d /s /c so that .cmd wrappers
|
|
444
|
+
* resolve correctly. The same pattern is used by resolveManagedCommand and
|
|
445
|
+
* fraim-mcp-latest-launcher.
|
|
446
|
+
*/
|
|
447
|
+
async function spawnAndHandshake(command, args, timeoutMs = STDIO_HANDSHAKE_TIMEOUT_MS) {
|
|
448
|
+
return new Promise((resolve) => {
|
|
449
|
+
const startTime = Date.now();
|
|
450
|
+
let spawnCommand;
|
|
451
|
+
let spawnArgs;
|
|
452
|
+
// spawnOptions is typed loosely so we can set windowsVerbatimArguments below.
|
|
453
|
+
let spawnOptions = {
|
|
454
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
455
|
+
env: process.env
|
|
456
|
+
};
|
|
457
|
+
if (process.platform === 'win32') {
|
|
458
|
+
// On Windows, wrap in cmd.exe so .cmd wrappers are resolved.
|
|
459
|
+
// cmd.exe /d /s /c requires special handling when the command path contains spaces:
|
|
460
|
+
// cmd /c ""path with spaces" arg1 arg2"
|
|
461
|
+
// The outer pair of double-quotes is mandatory when the first token is quoted.
|
|
462
|
+
// We must also pass windowsVerbatimArguments: true so Node's CreateProcess call
|
|
463
|
+
// does not re-escape our already-quoted command string.
|
|
464
|
+
const comSpec = process.env.ComSpec || 'cmd.exe';
|
|
465
|
+
const quotedTokens = [command, ...args].map((a) => {
|
|
466
|
+
const v = String(a);
|
|
467
|
+
return /[\s"&|<>^]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
|
|
468
|
+
});
|
|
469
|
+
const innerCmd = quotedTokens.join(' ');
|
|
470
|
+
// Wrap in outer quotes only when the command contains spaces (i.e. was itself quoted).
|
|
471
|
+
const needsOuterQuote = /[\s"&|<>^]/.test(String(command));
|
|
472
|
+
const cmdString = needsOuterQuote ? `"${innerCmd}"` : innerCmd;
|
|
473
|
+
spawnCommand = comSpec;
|
|
474
|
+
spawnArgs = ['/d', '/s', '/c', cmdString];
|
|
475
|
+
spawnOptions = { ...spawnOptions, windowsVerbatimArguments: true };
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
spawnCommand = command;
|
|
479
|
+
spawnArgs = args;
|
|
480
|
+
}
|
|
481
|
+
let child;
|
|
482
|
+
try {
|
|
483
|
+
child = (0, child_process_1.spawn)(spawnCommand, spawnArgs, spawnOptions);
|
|
484
|
+
}
|
|
485
|
+
catch (spawnErr) {
|
|
486
|
+
resolve({
|
|
487
|
+
status: 'error',
|
|
488
|
+
phase: 'spawn',
|
|
489
|
+
message: `Failed to spawn MCP server: ${spawnErr.message}`,
|
|
490
|
+
suggestion: 'Verify that npx is installed and accessible on PATH.',
|
|
491
|
+
details: { command, args, error: spawnErr.message }
|
|
492
|
+
});
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const timer = setTimeout(() => {
|
|
496
|
+
try {
|
|
497
|
+
child.kill('SIGKILL');
|
|
498
|
+
}
|
|
499
|
+
catch { /* best effort */ }
|
|
500
|
+
resolve({
|
|
501
|
+
status: 'error',
|
|
502
|
+
phase: 'initialize',
|
|
503
|
+
message: `MCP server did not respond within ${timeoutMs}ms`,
|
|
504
|
+
suggestion: 'The server may be slow to start. Check that the package is installed and npx cache is warm.',
|
|
505
|
+
details: { command, args, timeoutMs, elapsed: Date.now() - startTime }
|
|
506
|
+
});
|
|
507
|
+
}, timeoutMs);
|
|
508
|
+
let stderrBuffer = '';
|
|
509
|
+
let stdoutBuffer = '';
|
|
510
|
+
let spawnError = null;
|
|
511
|
+
child.stderr?.on('data', (d) => {
|
|
512
|
+
stderrBuffer += d.toString();
|
|
513
|
+
});
|
|
514
|
+
child.on('error', (err) => {
|
|
515
|
+
spawnError = err;
|
|
516
|
+
});
|
|
517
|
+
child.stdout?.on('data', (d) => {
|
|
518
|
+
stdoutBuffer += d.toString();
|
|
519
|
+
});
|
|
520
|
+
child.on('close', (code) => {
|
|
521
|
+
clearTimeout(timer);
|
|
522
|
+
if (spawnError) {
|
|
523
|
+
const msg = spawnError.message || '';
|
|
524
|
+
const isEnoent = msg.includes('ENOENT') || msg.includes('not found');
|
|
525
|
+
resolve({
|
|
526
|
+
status: 'error',
|
|
527
|
+
phase: 'spawn',
|
|
528
|
+
message: `Failed to start MCP server: ${msg}`,
|
|
529
|
+
suggestion: isEnoent
|
|
530
|
+
? 'Verify that npx is installed. Run: npm install -g npm to update npm/npx.'
|
|
531
|
+
: 'Check that the required package can be installed via npx.',
|
|
532
|
+
details: { command, args, error: msg }
|
|
533
|
+
});
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (code !== 0 && stdoutBuffer.trim() === '') {
|
|
537
|
+
resolve({
|
|
538
|
+
status: 'error',
|
|
539
|
+
phase: 'spawn',
|
|
540
|
+
message: `MCP server exited with code ${code} before responding`,
|
|
541
|
+
suggestion: 'Run: npx -y <package> manually to check for installation errors.',
|
|
542
|
+
details: { command, args, exitCode: code, stderr: stderrBuffer.slice(0, 500) }
|
|
543
|
+
});
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
// Parse all JSON-RPC messages received from stdout
|
|
547
|
+
const lines = stdoutBuffer.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
548
|
+
let initializeResponse = null;
|
|
549
|
+
let toolsListResponse = null;
|
|
550
|
+
for (const line of lines) {
|
|
551
|
+
try {
|
|
552
|
+
const msg = JSON.parse(line);
|
|
553
|
+
if (msg?.id === 1 && msg?.result !== undefined) {
|
|
554
|
+
initializeResponse = msg;
|
|
555
|
+
}
|
|
556
|
+
if (msg?.id === 2 && msg?.result !== undefined) {
|
|
557
|
+
toolsListResponse = msg;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
// Ignore non-JSON lines (e.g., startup log output)
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
if (!initializeResponse) {
|
|
565
|
+
resolve({
|
|
566
|
+
status: 'error',
|
|
567
|
+
phase: 'initialize',
|
|
568
|
+
message: 'MCP server did not return a valid initialize response',
|
|
569
|
+
suggestion: 'The server may have crashed during startup. Check stderr for error details.',
|
|
570
|
+
details: { command, args, exitCode: code, stderr: stderrBuffer.slice(0, 500) }
|
|
571
|
+
});
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (!toolsListResponse) {
|
|
575
|
+
resolve({
|
|
576
|
+
status: 'error',
|
|
577
|
+
phase: 'tools-list',
|
|
578
|
+
message: 'MCP server did not respond to tools/list',
|
|
579
|
+
suggestion: 'The server initialized but did not expose any tools. Check the package documentation.',
|
|
580
|
+
details: { command, args, exitCode: code }
|
|
581
|
+
});
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const toolCount = Array.isArray(toolsListResponse.result?.tools)
|
|
585
|
+
? toolsListResponse.result.tools.length
|
|
586
|
+
: 0;
|
|
587
|
+
const elapsed = Date.now() - startTime;
|
|
588
|
+
resolve({
|
|
589
|
+
status: 'passed',
|
|
590
|
+
message: `MCP server responded with ${toolCount} tool(s) in ${elapsed}ms`,
|
|
591
|
+
details: { command, args, toolCount, elapsed }
|
|
592
|
+
});
|
|
593
|
+
});
|
|
594
|
+
// Send two-phase JSON-RPC handshake
|
|
595
|
+
try {
|
|
596
|
+
const initMsg = JSON.stringify({
|
|
597
|
+
jsonrpc: '2.0',
|
|
598
|
+
id: 1,
|
|
599
|
+
method: 'initialize',
|
|
600
|
+
params: {
|
|
601
|
+
protocolVersion: '2024-11-05',
|
|
602
|
+
capabilities: {},
|
|
603
|
+
clientInfo: { name: 'fraim-doctor', version: '1.0' }
|
|
604
|
+
}
|
|
605
|
+
}) + '\n';
|
|
606
|
+
child.stdin?.write(initMsg);
|
|
607
|
+
const toolsMsg = JSON.stringify({
|
|
608
|
+
jsonrpc: '2.0',
|
|
609
|
+
id: 2,
|
|
610
|
+
method: 'tools/list',
|
|
611
|
+
params: {}
|
|
612
|
+
}) + '\n';
|
|
613
|
+
child.stdin?.write(toolsMsg);
|
|
614
|
+
child.stdin?.end();
|
|
615
|
+
}
|
|
616
|
+
catch {
|
|
617
|
+
// stdin may not be writable if the process exited immediately
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Run a connectivity check for a single named stdio MCP server.
|
|
623
|
+
*/
|
|
624
|
+
async function testStdioMCPServer(serverName, command, args) {
|
|
625
|
+
if (process.env.NODE_ENV === 'test') {
|
|
626
|
+
return {
|
|
627
|
+
status: 'warning',
|
|
628
|
+
message: `Stdio MCP runtime check for "${serverName}" skipped (test mode)`,
|
|
629
|
+
details: { testMode: true, skipped: true, serverName }
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
const result = await spawnAndHandshake(command, args, STDIO_HANDSHAKE_TIMEOUT_MS);
|
|
633
|
+
if (result.status === 'passed') {
|
|
634
|
+
return {
|
|
635
|
+
status: 'passed',
|
|
636
|
+
message: `${serverName} stdio MCP: ${result.message}`,
|
|
637
|
+
details: result.details
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
return {
|
|
641
|
+
status: 'error',
|
|
642
|
+
message: `${serverName} stdio MCP connectivity failed (phase: ${result.phase}): ${result.message}`,
|
|
643
|
+
suggestion: result.suggestion,
|
|
644
|
+
details: { ...result.details, phase: result.phase }
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Build the 3 stdio MCP runtime checks (git, playwright, fraim).
|
|
649
|
+
* Exported separately so callers can obtain just the runtime checks if needed.
|
|
650
|
+
*/
|
|
651
|
+
function getStdioMCPRuntimeChecks() {
|
|
652
|
+
const npx = (0, command_resolution_1.resolveManagedCommand)('npx');
|
|
653
|
+
// For the fraim MCP server we use the latest launcher so the test
|
|
654
|
+
// exercises the same path that IDE configs use.
|
|
655
|
+
const fraimLauncherPath = (0, fraim_mcp_latest_launcher_1.getFraimMcpLatestLauncherPath)();
|
|
656
|
+
return [
|
|
657
|
+
{
|
|
658
|
+
name: 'git stdio runtime check',
|
|
659
|
+
category: 'mcpConnectivity',
|
|
660
|
+
critical: false,
|
|
661
|
+
run: () => testStdioMCPServer('git', npx, ['-y', '@cyanheads/git-mcp-server'])
|
|
662
|
+
},
|
|
663
|
+
{
|
|
664
|
+
name: 'playwright stdio runtime check',
|
|
665
|
+
category: 'mcpConnectivity',
|
|
666
|
+
critical: false,
|
|
667
|
+
run: () => testStdioMCPServer('playwright', npx, ['-y', '@playwright/mcp'])
|
|
668
|
+
},
|
|
669
|
+
{
|
|
670
|
+
name: 'fraim stdio runtime check',
|
|
671
|
+
category: 'mcpConnectivity',
|
|
672
|
+
critical: false,
|
|
673
|
+
run: () => testStdioMCPServer('fraim', process.execPath, [fraimLauncherPath])
|
|
674
|
+
}
|
|
675
|
+
];
|
|
676
|
+
}
|
|
422
677
|
/**
|
|
423
678
|
* Get all MCP connectivity checks
|
|
424
679
|
*/
|
|
@@ -441,5 +696,9 @@ function getMCPConnectivityChecks() {
|
|
|
441
696
|
run: async () => validateIDEMCPConfig(ide)
|
|
442
697
|
});
|
|
443
698
|
}
|
|
699
|
+
// Check 3 (Issue #532): Confirm stdio MCP servers respond at runtime.
|
|
700
|
+
// "Disconnected" in mcp list is expected on-demand-spawn behavior. These checks
|
|
701
|
+
// verify that each server can actually start and complete a JSON-RPC handshake.
|
|
702
|
+
checks.push(...getStdioMCPRuntimeChecks());
|
|
444
703
|
return checks;
|
|
445
704
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
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.fetchOrgRepoSnapshot = fetchOrgRepoSnapshot;
|
|
7
|
+
/**
|
|
8
|
+
* Shallow snapshot of a customer-owned org repo (issue #563, git backend).
|
|
9
|
+
*
|
|
10
|
+
* Uses execSync git like the rest of the CLI (see core/utils/git-utils.ts);
|
|
11
|
+
* no new git dependency. The snapshot is a depth-1 clone of the default
|
|
12
|
+
* branch into a temp directory the caller copies from and then cleans up.
|
|
13
|
+
*/
|
|
14
|
+
const child_process_1 = require("child_process");
|
|
15
|
+
const fs_1 = __importDefault(require("fs"));
|
|
16
|
+
const os_1 = __importDefault(require("os"));
|
|
17
|
+
const path_1 = __importDefault(require("path"));
|
|
18
|
+
/**
|
|
19
|
+
* Org repo URLs are restricted to real transport schemes plus scp-style
|
|
20
|
+
* SSH shorthand. This blocks git's command-executing transports
|
|
21
|
+
* (`ext::`, `fd::`) and option injection via URLs starting with `-`.
|
|
22
|
+
*/
|
|
23
|
+
const ALLOWED_GIT_URL = /^(https?:\/\/|ssh:\/\/|git:\/\/|file:\/\/|[\w.-]+@[\w.-]+:)/;
|
|
24
|
+
/**
|
|
25
|
+
* Shallow-clone the org repo's default branch (R7.4). Throws on any git
|
|
26
|
+
* failure; callers translate failures into stale/absent outcomes.
|
|
27
|
+
*/
|
|
28
|
+
function fetchOrgRepoSnapshot(gitUrl) {
|
|
29
|
+
if (!ALLOWED_GIT_URL.test(gitUrl)) {
|
|
30
|
+
throw new Error(`Org repo URL has an unsupported scheme: ${gitUrl}`);
|
|
31
|
+
}
|
|
32
|
+
const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'fraim-org-snap-'));
|
|
33
|
+
try {
|
|
34
|
+
// Arg-array exec (no shell) and `--` so the URL can never be parsed
|
|
35
|
+
// as a git option or shell metacharacters.
|
|
36
|
+
(0, child_process_1.execFileSync)('git', ['clone', '--depth=1', '--quiet', '--', gitUrl, '.'], {
|
|
37
|
+
cwd: dir,
|
|
38
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
39
|
+
timeout: 60_000
|
|
40
|
+
});
|
|
41
|
+
const sha = (0, child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], {
|
|
42
|
+
cwd: dir,
|
|
43
|
+
encoding: 'utf8',
|
|
44
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
45
|
+
}).trim();
|
|
46
|
+
return {
|
|
47
|
+
dir,
|
|
48
|
+
sha,
|
|
49
|
+
cleanup: () => fs_1.default.rmSync(dir, { recursive: true, force: true })
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
fs_1.default.rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
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.detectLegacyOrgArtifacts = detectLegacyOrgArtifacts;
|
|
7
|
+
exports.archiveLegacyOrgArtifacts = archiveLegacyOrgArtifacts;
|
|
8
|
+
/**
|
|
9
|
+
* Legacy org artifact migration (issue #563, R8).
|
|
10
|
+
*
|
|
11
|
+
* Before #563, organization-onboarding wrote org_context.md and org_rules.md
|
|
12
|
+
* machine-locally under ~/.fraim/personalized-employee/. These helpers detect
|
|
13
|
+
* those files for a one-time publish offer and archive them (never delete)
|
|
14
|
+
* once the user accepts. Declining is safe: detection is read-only and the
|
|
15
|
+
* legacy resolution tier keeps working (AC8).
|
|
16
|
+
*
|
|
17
|
+
* Manager files are personal by design and are never migrated (R3.3).
|
|
18
|
+
*/
|
|
19
|
+
const fs_1 = __importDefault(require("fs"));
|
|
20
|
+
const path_1 = __importDefault(require("path"));
|
|
21
|
+
const project_fraim_paths_1 = require("../../core/utils/project-fraim-paths");
|
|
22
|
+
const LEGACY_ORG_RELATIVE_PATHS = [
|
|
23
|
+
'context/org_context.md',
|
|
24
|
+
'rules/org_rules.md'
|
|
25
|
+
];
|
|
26
|
+
function detectLegacyOrgArtifacts() {
|
|
27
|
+
const root = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'personalized-employee');
|
|
28
|
+
const detected = [];
|
|
29
|
+
for (const relativePath of LEGACY_ORG_RELATIVE_PATHS) {
|
|
30
|
+
const absolutePath = path_1.default.join(root, relativePath);
|
|
31
|
+
if (fs_1.default.existsSync(absolutePath)) {
|
|
32
|
+
detected.push({ absolutePath, relativePath });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return detected;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Move legacy org artifacts into a timestamped directory under
|
|
39
|
+
* ~/.fraim/backups/ (R8.1). Returns the backup directory path.
|
|
40
|
+
*/
|
|
41
|
+
function archiveLegacyOrgArtifacts(artifacts) {
|
|
42
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
43
|
+
const backupDir = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'backups', `org-migration-${stamp}`);
|
|
44
|
+
for (const artifact of artifacts) {
|
|
45
|
+
const destination = path_1.default.join(backupDir, artifact.relativePath);
|
|
46
|
+
fs_1.default.mkdirSync(path_1.default.dirname(destination), { recursive: true });
|
|
47
|
+
fs_1.default.renameSync(artifact.absolutePath, destination);
|
|
48
|
+
}
|
|
49
|
+
return backupDir;
|
|
50
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
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.ORG_CACHE_MANAGED_HEADER = exports.ORG_SYNC_METADATA_FILE = exports.ORG_CACHE_DIRNAME = void 0;
|
|
7
|
+
exports.getOrgCacheDir = getOrgCacheDir;
|
|
8
|
+
exports.readOrgCacheMetadata = readOrgCacheMetadata;
|
|
9
|
+
exports.getOrgCacheAgeHours = getOrgCacheAgeHours;
|
|
10
|
+
exports.syncOrgCache = syncOrgCache;
|
|
11
|
+
/**
|
|
12
|
+
* Org cache sync (issue #563).
|
|
13
|
+
*
|
|
14
|
+
* Materializes the organization's shared context/rules/learnings into the
|
|
15
|
+
* managed read-only cache at ~/.fraim/org/, from either backend:
|
|
16
|
+
* - git: shallow snapshot of the customer-owned org repo (R7)
|
|
17
|
+
* - fraim-cloud: GET /api/org/pack from the FRAIM server (R6)
|
|
18
|
+
*
|
|
19
|
+
* Cache files are managed content: marked, overwritten on every sync, and
|
|
20
|
+
* never a write target for agents (R2.2). A failed refresh never throws:
|
|
21
|
+
* an existing cache is served stale with its age (R2.3, R4.3).
|
|
22
|
+
*/
|
|
23
|
+
const axios_1 = __importDefault(require("axios"));
|
|
24
|
+
const fs_1 = __importDefault(require("fs"));
|
|
25
|
+
const path_1 = __importDefault(require("path"));
|
|
26
|
+
const project_fraim_paths_1 = require("../../core/utils/project-fraim-paths");
|
|
27
|
+
const user_config_1 = require("./user-config");
|
|
28
|
+
const git_org_sync_1 = require("./git-org-sync");
|
|
29
|
+
exports.ORG_CACHE_DIRNAME = 'org';
|
|
30
|
+
exports.ORG_SYNC_METADATA_FILE = '.org-sync-metadata.json';
|
|
31
|
+
exports.ORG_CACHE_MANAGED_HEADER = '<!-- FRAIM_ORG_SYNC_MANAGED_CONTENT -->';
|
|
32
|
+
/** Subdirectories of the org pack that sync into the cache (spec R7.1). */
|
|
33
|
+
const ORG_PACK_DIRS = ['context', 'rules', 'learnings'];
|
|
34
|
+
/**
|
|
35
|
+
* Pack files must stay inside the three org pack directories. Applied to
|
|
36
|
+
* every relativePath before it touches the filesystem, so a compromised
|
|
37
|
+
* backend response can never write outside the cache directory.
|
|
38
|
+
*/
|
|
39
|
+
const SAFE_PACK_RELATIVE_PATH = /^(context|rules|learnings)\/[\w.-]+\.md$/;
|
|
40
|
+
function getOrgCacheDir() {
|
|
41
|
+
return path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), exports.ORG_CACHE_DIRNAME);
|
|
42
|
+
}
|
|
43
|
+
function readOrgCacheMetadata() {
|
|
44
|
+
try {
|
|
45
|
+
const metadataPath = path_1.default.join(getOrgCacheDir(), exports.ORG_SYNC_METADATA_FILE);
|
|
46
|
+
if (!fs_1.default.existsSync(metadataPath))
|
|
47
|
+
return null;
|
|
48
|
+
return JSON.parse(fs_1.default.readFileSync(metadataPath, 'utf8'));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function getOrgCacheAgeHours() {
|
|
55
|
+
const metadata = readOrgCacheMetadata();
|
|
56
|
+
if (!metadata?.syncedAt)
|
|
57
|
+
return null;
|
|
58
|
+
const syncedAt = Date.parse(metadata.syncedAt);
|
|
59
|
+
if (Number.isNaN(syncedAt))
|
|
60
|
+
return null;
|
|
61
|
+
return Math.max(0, (Date.now() - syncedAt) / 3_600_000);
|
|
62
|
+
}
|
|
63
|
+
function decorateManagedOrgFile(content, backend) {
|
|
64
|
+
const normalized = content.replace(/^/, '');
|
|
65
|
+
if (normalized.startsWith(exports.ORG_CACHE_MANAGED_HEADER))
|
|
66
|
+
return normalized;
|
|
67
|
+
const writePath = backend === 'git'
|
|
68
|
+
? 'open a pull request against your organization repo'
|
|
69
|
+
: 'update it through the organization-onboarding flow';
|
|
70
|
+
const marker = [
|
|
71
|
+
exports.ORG_CACHE_MANAGED_HEADER,
|
|
72
|
+
'> [!IMPORTANT]',
|
|
73
|
+
'> Synced from your organization\'s shared context. Local edits are overwritten on the next sync.',
|
|
74
|
+
`> To change this content, ${writePath}.`,
|
|
75
|
+
''
|
|
76
|
+
].join('\n');
|
|
77
|
+
return `${marker}\n${normalized}`;
|
|
78
|
+
}
|
|
79
|
+
function collectGitPackFiles(snapshotDir) {
|
|
80
|
+
const files = [];
|
|
81
|
+
for (const dirName of ORG_PACK_DIRS) {
|
|
82
|
+
const dirPath = path_1.default.join(snapshotDir, dirName);
|
|
83
|
+
if (!fs_1.default.existsSync(dirPath))
|
|
84
|
+
continue;
|
|
85
|
+
for (const entry of fs_1.default.readdirSync(dirPath, { withFileTypes: true })) {
|
|
86
|
+
if (!entry.isFile() || !entry.name.endsWith('.md'))
|
|
87
|
+
continue;
|
|
88
|
+
files.push({
|
|
89
|
+
relativePath: `${dirName}/${entry.name}`,
|
|
90
|
+
content: fs_1.default.readFileSync(path_1.default.join(dirPath, entry.name), 'utf8')
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return files;
|
|
95
|
+
}
|
|
96
|
+
async function fetchCloudPack(remoteUrl, apiKey) {
|
|
97
|
+
const response = await axios_1.default.get(`${remoteUrl.replace(/\/$/, '')}/api/org/pack`, {
|
|
98
|
+
headers: { 'x-api-key': apiKey },
|
|
99
|
+
timeout: 30_000
|
|
100
|
+
});
|
|
101
|
+
const files = Array.isArray(response.data?.files) ? response.data.files : [];
|
|
102
|
+
return {
|
|
103
|
+
files: files.filter((f) => typeof f?.relativePath === 'string' &&
|
|
104
|
+
SAFE_PACK_RELATIVE_PATH.test(f.relativePath) &&
|
|
105
|
+
typeof f?.content === 'string'),
|
|
106
|
+
version: String(response.data?.version ?? '0')
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** Replace the cache contents atomically: stage fully, then swap. */
|
|
110
|
+
function materializeCache(files, metadata) {
|
|
111
|
+
const cacheDir = getOrgCacheDir();
|
|
112
|
+
const stagingDir = `${cacheDir}.staging-${process.pid}`;
|
|
113
|
+
fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
|
|
114
|
+
fs_1.default.mkdirSync(stagingDir, { recursive: true });
|
|
115
|
+
for (const file of files) {
|
|
116
|
+
if (!SAFE_PACK_RELATIVE_PATH.test(file.relativePath))
|
|
117
|
+
continue;
|
|
118
|
+
const destination = path_1.default.join(stagingDir, file.relativePath);
|
|
119
|
+
fs_1.default.mkdirSync(path_1.default.dirname(destination), { recursive: true });
|
|
120
|
+
fs_1.default.writeFileSync(destination, decorateManagedOrgFile(file.content, metadata.backend));
|
|
121
|
+
}
|
|
122
|
+
fs_1.default.writeFileSync(path_1.default.join(stagingDir, exports.ORG_SYNC_METADATA_FILE), JSON.stringify(metadata, null, 2));
|
|
123
|
+
fs_1.default.rmSync(cacheDir, { recursive: true, force: true });
|
|
124
|
+
fs_1.default.renameSync(stagingDir, cacheDir);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* New-machine onboarding (R1.3, AC1): when this machine has an API key but no
|
|
128
|
+
* organization block yet, probe the FRAIM server. If the account owns org
|
|
129
|
+
* artifacts, persist the fraim-cloud organization block so `fraim setup` +
|
|
130
|
+
* `fraim sync` is the complete second-machine flow. Quietly resolves null on
|
|
131
|
+
* any failure or when the account has no org.
|
|
132
|
+
*/
|
|
133
|
+
async function discoverCloudOrganization(options) {
|
|
134
|
+
try {
|
|
135
|
+
const apiKey = options?.apiKey || (0, user_config_1.readUserFraimConfig)().apiKey;
|
|
136
|
+
if (!apiKey || apiKey === 'local-dev' || apiKey === 'test-mode-key')
|
|
137
|
+
return null;
|
|
138
|
+
const remoteUrl = options?.remoteUrl || process.env.FRAIM_REMOTE_URL || 'https://fraim.wellnessatwork.me';
|
|
139
|
+
const pack = await fetchCloudPack(remoteUrl, apiKey);
|
|
140
|
+
if (pack.files.length === 0)
|
|
141
|
+
return null;
|
|
142
|
+
(0, user_config_1.writeUserFraimConfig)({ organization: { backend: 'fraim-cloud' } });
|
|
143
|
+
return (0, user_config_1.getOrganizationConfig)();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function failureOutcome(error) {
|
|
150
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
151
|
+
const existing = readOrgCacheMetadata();
|
|
152
|
+
if (existing) {
|
|
153
|
+
return {
|
|
154
|
+
status: 'stale',
|
|
155
|
+
metadata: existing,
|
|
156
|
+
ageHours: getOrgCacheAgeHours() ?? 0,
|
|
157
|
+
error: message
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return { status: 'absent', error: message };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Refresh the org cache from the configured backend (R4.1). Never throws:
|
|
164
|
+
* returns 'disabled' (no org configured), 'synced', 'stale' (refresh failed,
|
|
165
|
+
* existing cache served with age), or 'absent' (refresh failed, no cache).
|
|
166
|
+
*/
|
|
167
|
+
async function syncOrgCache(options) {
|
|
168
|
+
let organization = (0, user_config_1.getOrganizationConfig)();
|
|
169
|
+
if (!organization) {
|
|
170
|
+
organization = await discoverCloudOrganization(options);
|
|
171
|
+
if (!organization)
|
|
172
|
+
return { status: 'disabled' };
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
if (organization.backend === 'git') {
|
|
176
|
+
const snapshot = (0, git_org_sync_1.fetchOrgRepoSnapshot)(organization.gitUrl);
|
|
177
|
+
try {
|
|
178
|
+
const metadata = {
|
|
179
|
+
version: snapshot.sha,
|
|
180
|
+
backend: 'git',
|
|
181
|
+
source: organization.gitUrl,
|
|
182
|
+
syncedAt: new Date().toISOString()
|
|
183
|
+
};
|
|
184
|
+
materializeCache(collectGitPackFiles(snapshot.dir), metadata);
|
|
185
|
+
return { status: 'synced', metadata };
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
snapshot.cleanup();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const remoteUrl = options?.remoteUrl || process.env.FRAIM_REMOTE_URL || 'https://fraim.wellnessatwork.me';
|
|
192
|
+
const apiKey = options?.apiKey || (0, user_config_1.readUserFraimConfig)().apiKey;
|
|
193
|
+
if (!apiKey)
|
|
194
|
+
throw new Error('No FRAIM API key available for the fraim-cloud org backend.');
|
|
195
|
+
const pack = await fetchCloudPack(remoteUrl, apiKey);
|
|
196
|
+
const metadata = {
|
|
197
|
+
version: pack.version,
|
|
198
|
+
backend: 'fraim-cloud',
|
|
199
|
+
source: remoteUrl,
|
|
200
|
+
syncedAt: new Date().toISOString()
|
|
201
|
+
};
|
|
202
|
+
materializeCache(pack.files, metadata);
|
|
203
|
+
return { status: 'synced', metadata };
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
return failureOutcome(error);
|
|
207
|
+
}
|
|
208
|
+
}
|