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.
Files changed (33) hide show
  1. package/dist/src/ai-hub/desktop-main.js +4 -1
  2. package/dist/src/ai-hub/hosts.js +97 -12
  3. package/dist/src/ai-hub/preferences.js +1 -1
  4. package/dist/src/ai-hub/server.js +49 -123
  5. package/dist/src/cli/commands/init-project.js +15 -14
  6. package/dist/src/cli/commands/sync.js +38 -0
  7. package/dist/src/cli/doctor/check-runner.js +3 -1
  8. package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +261 -2
  9. package/dist/src/cli/utils/git-org-sync.js +56 -0
  10. package/dist/src/cli/utils/org-migration.js +50 -0
  11. package/dist/src/cli/utils/org-pack-sync.js +208 -0
  12. package/dist/src/cli/utils/project-bootstrap.js +20 -7
  13. package/dist/src/cli/utils/user-config.js +68 -0
  14. package/dist/src/core/fraim-config-schema.generated.js +10 -0
  15. package/dist/src/first-run/types.js +8 -0
  16. package/dist/src/local-mcp-server/agent-token-prices.js +30 -0
  17. package/dist/src/local-mcp-server/learning-context-builder.js +78 -29
  18. package/dist/src/local-mcp-server/stdio-server.js +30 -0
  19. package/index.js +1 -1
  20. package/package.json +2 -3
  21. package/public/ai-hub/index.html +5 -5
  22. package/public/ai-hub/powerpoint-taskpane/index.html +236 -236
  23. package/public/ai-hub/powerpoint-taskpane/manifest.xml +29 -29
  24. package/public/ai-hub/review.css +15 -15
  25. package/public/ai-hub/script.js +254 -195
  26. package/public/ai-hub/styles.css +206 -16
  27. package/public/first-run/styles.css +73 -73
  28. package/dist/src/ai-hub/word-sideload.js +0 -95
  29. package/dist/src/cli/commands/test-mcp.js +0 -171
  30. package/dist/src/cli/setup/first-run.js +0 -242
  31. package/dist/src/core/config-writer.js +0 -75
  32. package/dist/src/core/utils/job-aliases.js +0 -47
  33. 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
- return Number.isFinite(major) ? major : null;
76
+ _npmMajorVersionCache = Number.isFinite(major) ? major : null;
65
77
  }
66
78
  catch {
67
- return null;
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
+ }