fraim-framework 2.0.182 → 2.0.184
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 +4 -0
- package/dist/src/ai-hub/desktop-main.js +8 -0
- package/dist/src/ai-hub/hosts.js +1 -1
- package/dist/src/ai-hub/manager-turns.js +2 -2
- package/dist/src/ai-hub/preferences.js +99 -0
- package/dist/src/ai-hub/server.js +102 -21
- package/dist/src/cli/distribution/marketplace-bundles.js +47 -29
- package/dist/src/fraim/db-service.js +25 -3
- package/dist/src/routes/oauth-routes.js +40 -5
- package/dist/src/services/account-provisioning.js +38 -0
- package/dist/src/services/admin-service.js +11 -37
- package/package.json +1 -1
- package/public/ai-hub/index.html +6 -0
- package/public/ai-hub/script.js +270 -61
- package/public/ai-hub/styles.css +44 -0
|
@@ -68,6 +68,10 @@ class AiHubConversationStore {
|
|
|
68
68
|
constructor(stateFilePath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'ai-hub-conversations.json')) {
|
|
69
69
|
this.stateFilePath = stateFilePath;
|
|
70
70
|
}
|
|
71
|
+
listProjectPaths() {
|
|
72
|
+
const state = this.readStore();
|
|
73
|
+
return Object.keys(state.projects || {}).map((projectPath) => normalizeProjectPath(projectPath));
|
|
74
|
+
}
|
|
71
75
|
loadProject(projectPath) {
|
|
72
76
|
const state = this.readStore();
|
|
73
77
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
|
@@ -52,6 +52,13 @@ function parseArgs(argv) {
|
|
|
52
52
|
}
|
|
53
53
|
return { projectPath, preferredPort };
|
|
54
54
|
}
|
|
55
|
+
function applyUserDataOverride() {
|
|
56
|
+
const userDataDir = process.env.FRAIM_AI_HUB_USER_DATA_DIR;
|
|
57
|
+
if (!userDataDir)
|
|
58
|
+
return;
|
|
59
|
+
fs_1.default.mkdirSync(userDataDir, { recursive: true });
|
|
60
|
+
electron_1.app.setPath('userData', userDataDir);
|
|
61
|
+
}
|
|
55
62
|
// ---------------------------------------------------------------------------
|
|
56
63
|
// Tray icon resolution — prefers bundled icon, falls back to a 1×1 empty image
|
|
57
64
|
// so the app never crashes if assets aren't present.
|
|
@@ -271,6 +278,7 @@ async function launchDesktopShell(options) {
|
|
|
271
278
|
// ---------------------------------------------------------------------------
|
|
272
279
|
async function bootstrap() {
|
|
273
280
|
const options = parseArgs(process.argv.slice(2));
|
|
281
|
+
applyUserDataOverride();
|
|
274
282
|
// Single-instance lock — if another instance is already running, focus it
|
|
275
283
|
// and exit rather than spawning a second server + window.
|
|
276
284
|
// Skip when FRAIM_AI_HUB_FAKE_HOST=1 (test mode) so Playwright can launch
|
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -592,7 +592,7 @@ function parseFraimInvocation(message) {
|
|
|
592
592
|
const trimmed = message.trim();
|
|
593
593
|
if (!trimmed)
|
|
594
594
|
return null;
|
|
595
|
-
const match = trimmed.match(/^[
|
|
595
|
+
const match = trimmed.match(/^[$/@]fraim(?:\s+(\S+))?\s*([\s\S]*)$/);
|
|
596
596
|
if (!match)
|
|
597
597
|
return null;
|
|
598
598
|
return {
|
|
@@ -6,14 +6,14 @@ exports.buildCommunicationStyleNote = buildCommunicationStyleNote;
|
|
|
6
6
|
exports.buildManagerMessage = buildManagerMessage;
|
|
7
7
|
function extractExplicitFraimInvocation(text) {
|
|
8
8
|
const raw = String(text || '');
|
|
9
|
-
const match = raw.match(/(?:^|\n|\s)([
|
|
9
|
+
const match = raw.match(/(?:^|\n|\s)([$/@]fraim)\s+([a-z0-9][a-z0-9-]*)(?=\s|$)/i);
|
|
10
10
|
if (!match || match.index == null)
|
|
11
11
|
return null;
|
|
12
12
|
const before = raw.slice(0, match.index).trim();
|
|
13
13
|
const after = raw.slice(match.index + match[0].length).trim();
|
|
14
14
|
const remainder = [before, after].filter(Boolean).join('\n\n').trim();
|
|
15
15
|
return {
|
|
16
|
-
symbol: match[1].toLowerCase()
|
|
16
|
+
symbol: match[1].toLowerCase(),
|
|
17
17
|
jobId: match[2].toLowerCase(),
|
|
18
18
|
remainder,
|
|
19
19
|
};
|
|
@@ -4,7 +4,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.AiHubPreferencesStore = void 0;
|
|
7
|
+
exports.normalizeAiHubProjectList = normalizeAiHubProjectList;
|
|
7
8
|
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const crypto_1 = require("crypto");
|
|
8
10
|
const path_1 = __importDefault(require("path"));
|
|
9
11
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
10
12
|
const DEFAULT_CATEGORY = 'marketing';
|
|
@@ -16,7 +18,89 @@ const defaultPreferences = (projectPath) => ({
|
|
|
16
18
|
recentJobIds: [],
|
|
17
19
|
recentJobInstructions: {},
|
|
18
20
|
personaKey: null,
|
|
21
|
+
projects: normalizeAiHubProjectList([], projectPath),
|
|
19
22
|
});
|
|
23
|
+
function normalizeProjectPath(projectPath) {
|
|
24
|
+
return path_1.default.resolve(projectPath || process.cwd());
|
|
25
|
+
}
|
|
26
|
+
function canonicalProjectPath(projectPath) {
|
|
27
|
+
const normalized = normalizeProjectPath(projectPath);
|
|
28
|
+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
29
|
+
}
|
|
30
|
+
function projectPathExists(projectPath) {
|
|
31
|
+
try {
|
|
32
|
+
return fs_1.default.statSync(projectPath).isDirectory();
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function projectIdForPath(projectPath) {
|
|
39
|
+
return `p-${(0, crypto_1.createHash)('sha1').update(canonicalProjectPath(projectPath)).digest('base64url').slice(0, 16)}`;
|
|
40
|
+
}
|
|
41
|
+
function uniqueProjectId(entry, seenIds) {
|
|
42
|
+
const fallbackId = projectIdForPath(entry.folderPath);
|
|
43
|
+
const rawId = typeof entry.id === 'string' ? entry.id.trim() : '';
|
|
44
|
+
const preferredId = rawId && rawId !== 'p-current' ? rawId : fallbackId;
|
|
45
|
+
let id = preferredId;
|
|
46
|
+
let suffix = 2;
|
|
47
|
+
while (seenIds.has(id)) {
|
|
48
|
+
id = `${fallbackId}-${suffix}`;
|
|
49
|
+
suffix += 1;
|
|
50
|
+
}
|
|
51
|
+
seenIds.add(id);
|
|
52
|
+
return id;
|
|
53
|
+
}
|
|
54
|
+
function withUniqueProjectIds(projects) {
|
|
55
|
+
const seenIds = new Set();
|
|
56
|
+
return projects.map((entry) => ({ ...entry, id: uniqueProjectId(entry, seenIds) }));
|
|
57
|
+
}
|
|
58
|
+
function normalizeProjectEntry(raw, fallbackPath) {
|
|
59
|
+
if (!raw || typeof raw !== 'object')
|
|
60
|
+
return null;
|
|
61
|
+
const value = raw;
|
|
62
|
+
const rawPath = typeof value.folderPath === 'string'
|
|
63
|
+
? value.folderPath
|
|
64
|
+
: (typeof value.path === 'string'
|
|
65
|
+
? value.path
|
|
66
|
+
: (typeof value.folder === 'string' ? value.folder : fallbackPath));
|
|
67
|
+
if (!rawPath || typeof rawPath !== 'string')
|
|
68
|
+
return null;
|
|
69
|
+
const folderPath = normalizeProjectPath(rawPath);
|
|
70
|
+
const basename = path_1.default.basename(folderPath) || folderPath;
|
|
71
|
+
const rawId = typeof value.id === 'string' ? value.id.trim() : '';
|
|
72
|
+
// The employee roster is deliberately dropped here (not read, not persisted):
|
|
73
|
+
// it is derived from the bootstrap's hired personas at render time so it stays
|
|
74
|
+
// consistent across machines. Any `team` on incoming/legacy state is discarded.
|
|
75
|
+
return {
|
|
76
|
+
id: rawId || projectIdForPath(folderPath),
|
|
77
|
+
name: typeof value.name === 'string' && value.name.length > 0 ? value.name : basename,
|
|
78
|
+
folderPath,
|
|
79
|
+
intent: typeof value.intent === 'string' ? value.intent : '',
|
|
80
|
+
outcome: typeof value.outcome === 'string' ? value.outcome : '',
|
|
81
|
+
rules: typeof value.rules === 'string' ? value.rules : '',
|
|
82
|
+
brief: typeof value.brief === 'string' ? value.brief : (typeof value.intent === 'string' ? value.intent : ''),
|
|
83
|
+
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : undefined,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function normalizeAiHubProjectList(projects, currentProjectPath, options = {}) {
|
|
87
|
+
const byPath = new Map();
|
|
88
|
+
const currentKey = currentProjectPath ? normalizeProjectPath(currentProjectPath) : null;
|
|
89
|
+
const add = (entry) => {
|
|
90
|
+
if (!entry)
|
|
91
|
+
return;
|
|
92
|
+
const key = normalizeProjectPath(entry.folderPath);
|
|
93
|
+
if (!options.includeMissing && key !== currentKey && !projectPathExists(key))
|
|
94
|
+
return;
|
|
95
|
+
const existing = byPath.get(key);
|
|
96
|
+
byPath.set(key, existing ? { ...existing, ...entry, folderPath: key } : { ...entry, folderPath: key });
|
|
97
|
+
};
|
|
98
|
+
if (currentProjectPath)
|
|
99
|
+
add(normalizeProjectEntry({ folderPath: currentProjectPath }, currentProjectPath));
|
|
100
|
+
for (const project of projects)
|
|
101
|
+
add(normalizeProjectEntry(project));
|
|
102
|
+
return withUniqueProjectIds(Array.from(byPath.values()));
|
|
103
|
+
}
|
|
20
104
|
class AiHubPreferencesStore {
|
|
21
105
|
constructor(stateFilePath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'ai-hub-state.json')) {
|
|
22
106
|
this.stateFilePath = stateFilePath;
|
|
@@ -37,6 +121,7 @@ class AiHubPreferencesStore {
|
|
|
37
121
|
: {},
|
|
38
122
|
personaKey: typeof raw.personaKey === 'string' ? raw.personaKey : null,
|
|
39
123
|
apiKey: typeof raw.apiKey === 'string' && raw.apiKey.length > 0 ? raw.apiKey : undefined,
|
|
124
|
+
projects: normalizeAiHubProjectList(Array.isArray(raw.projects) ? raw.projects : [], projectPath),
|
|
40
125
|
};
|
|
41
126
|
}
|
|
42
127
|
catch {
|
|
@@ -47,6 +132,20 @@ class AiHubPreferencesStore {
|
|
|
47
132
|
fs_1.default.mkdirSync(path_1.default.dirname(this.stateFilePath), { recursive: true });
|
|
48
133
|
fs_1.default.writeFileSync(this.stateFilePath, JSON.stringify(preferences, null, 2));
|
|
49
134
|
}
|
|
135
|
+
saveProjects(projectPath, projects) {
|
|
136
|
+
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
|
137
|
+
const preferences = this.load(normalizedProjectPath);
|
|
138
|
+
const nextProjects = normalizeAiHubProjectList(projects, normalizedProjectPath);
|
|
139
|
+
this.save({ ...preferences, projectPath: normalizedProjectPath, projects: nextProjects });
|
|
140
|
+
return nextProjects;
|
|
141
|
+
}
|
|
142
|
+
mergeProjects(projectPath, projects) {
|
|
143
|
+
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
|
144
|
+
const preferences = this.load(normalizedProjectPath);
|
|
145
|
+
const nextProjects = normalizeAiHubProjectList([...(preferences.projects || []), ...projects], normalizedProjectPath);
|
|
146
|
+
this.save({ ...preferences, projectPath: normalizedProjectPath, projects: nextProjects });
|
|
147
|
+
return nextProjects;
|
|
148
|
+
}
|
|
50
149
|
remember(preferences, jobId, instructions) {
|
|
51
150
|
const recentJobIds = jobId
|
|
52
151
|
? [jobId, ...preferences.recentJobIds.filter((value) => value !== jobId)].slice(0, 8)
|
|
@@ -1009,6 +1009,22 @@ function ensureDirectoryPath(projectPath) {
|
|
|
1009
1009
|
}
|
|
1010
1010
|
return resolved;
|
|
1011
1011
|
}
|
|
1012
|
+
function normalizedDirectoryPath(projectPath) {
|
|
1013
|
+
const resolved = path_1.default.resolve(projectPath);
|
|
1014
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
1015
|
+
}
|
|
1016
|
+
function sameDirectoryPath(left, right) {
|
|
1017
|
+
return normalizedDirectoryPath(left) === normalizedDirectoryPath(right);
|
|
1018
|
+
}
|
|
1019
|
+
function deploymentProjectFilter(rawProjectPath, fallbackProjectPath) {
|
|
1020
|
+
return ensureDirectoryPath(typeof rawProjectPath === 'string' && rawProjectPath.trim() ? rawProjectPath : fallbackProjectPath);
|
|
1021
|
+
}
|
|
1022
|
+
function deploymentBelongsToProject(deployment, projectPath, fallbackProjectPath) {
|
|
1023
|
+
const deploymentProjectPath = typeof deployment.projectPath === 'string' && deployment.projectPath.trim()
|
|
1024
|
+
? deployment.projectPath
|
|
1025
|
+
: fallbackProjectPath;
|
|
1026
|
+
return sameDirectoryPath(deploymentProjectPath, projectPath);
|
|
1027
|
+
}
|
|
1012
1028
|
// ---------------------------------------------------------------------------
|
|
1013
1029
|
// Issue #512 (S3) — Hub bootstrap projection helpers.
|
|
1014
1030
|
// ---------------------------------------------------------------------------
|
|
@@ -1373,6 +1389,19 @@ class AiHubServer {
|
|
|
1373
1389
|
}
|
|
1374
1390
|
}
|
|
1375
1391
|
getHttpsPort() { return this.httpsPort; }
|
|
1392
|
+
knownProjects(projectPath, extras = []) {
|
|
1393
|
+
const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
|
|
1394
|
+
const preferences = this.preferencesStore.load(normalizedProjectPath);
|
|
1395
|
+
const conversationProjects = this.conversationStore.listProjectPaths().map((folderPath) => ({ folderPath }));
|
|
1396
|
+
return (0, preferences_1.normalizeAiHubProjectList)([
|
|
1397
|
+
...(preferences.projects || []),
|
|
1398
|
+
...conversationProjects,
|
|
1399
|
+
...extras,
|
|
1400
|
+
], normalizedProjectPath);
|
|
1401
|
+
}
|
|
1402
|
+
saveKnownProjects(projectPath, projects) {
|
|
1403
|
+
return this.preferencesStore.saveProjects(path_1.default.resolve(projectPath || this.projectPath), this.knownProjects(projectPath, projects));
|
|
1404
|
+
}
|
|
1376
1405
|
async bootstrapResponse(projectPath, apiKey) {
|
|
1377
1406
|
const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
|
|
1378
1407
|
const employees = this.hostRuntime.detectEmployees();
|
|
@@ -1402,6 +1431,9 @@ class AiHubServer {
|
|
|
1402
1431
|
const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath, catalogOptions);
|
|
1403
1432
|
const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(apiKey || preferences.apiKey);
|
|
1404
1433
|
const managerTeam = await this.computeManagerTeam(workspaceId, userKey);
|
|
1434
|
+
const projects = this.knownProjects(normalizedProjectPath);
|
|
1435
|
+
preferences = { ...preferences, projectPath: normalizedProjectPath, projects };
|
|
1436
|
+
this.preferencesStore.save(preferences);
|
|
1405
1437
|
// Issue #347: enrich the activeRun the same way GET /runs/:id does
|
|
1406
1438
|
// so the bootstrap surface (used on first paint) carries stages and
|
|
1407
1439
|
// live totals — not just the raw run state.
|
|
@@ -1418,6 +1450,7 @@ class AiHubServer {
|
|
|
1418
1450
|
personas,
|
|
1419
1451
|
subscriptionActive,
|
|
1420
1452
|
activeRun,
|
|
1453
|
+
projects,
|
|
1421
1454
|
// Issue #512 (S3) — additive manager-flow projections.
|
|
1422
1455
|
firstRun: this.computeFirstRun(normalizedProjectPath, jobs.length, personas),
|
|
1423
1456
|
teamContext: this.computeTeamContext(normalizedProjectPath),
|
|
@@ -1818,12 +1851,9 @@ class AiHubServer {
|
|
|
1818
1851
|
if (!resolvedJobId) {
|
|
1819
1852
|
throw new Error('Choose a FRAIM job before starting a run, or start with /fraim <job-id>.');
|
|
1820
1853
|
}
|
|
1821
|
-
// #
|
|
1822
|
-
//
|
|
1823
|
-
|
|
1824
|
-
// of the bubble so the thread isn't cluttered with machinery. `message` (with all
|
|
1825
|
-
// of it) is what the agent actually receives.
|
|
1826
|
-
const display = (explicit?.remainder || instructions || '').trim();
|
|
1854
|
+
// #696: `display` is the manager conversation bubble. Keep the full generated
|
|
1855
|
+
// turn in `message`, but show only the host-specific FRAIM invocation.
|
|
1856
|
+
const display = (0, manager_turns_1.fraimInvocationFor)(hostId, resolvedJobId) || (explicit?.remainder || instructions || '').trim();
|
|
1827
1857
|
// #521: the shared-browser guidance is injected HERE, at the Hub layer — never
|
|
1828
1858
|
// baked into the registry job/skill. It only appears when a shared browser is
|
|
1829
1859
|
// available (env published at boot).
|
|
@@ -1852,13 +1882,12 @@ class AiHubServer {
|
|
|
1852
1882
|
// as the target of the FRAIM invocation. The server picks the correct
|
|
1853
1883
|
// invocation prefix ($fraim / /fraim) based on run.hostId — the UI never
|
|
1854
1884
|
// passes raw invocation syntax.
|
|
1855
|
-
const effectiveJobId = coachingJobId || run.jobId;
|
|
1856
|
-
// #521: `display` = the manager's own words for the conversation bubble. When the
|
|
1857
|
-
// manager typed nothing (a quick-coach button), fall back to the bare invocation
|
|
1858
|
-
// so the bubble still shows what was triggered — but never the style note.
|
|
1859
1885
|
const explicit = (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
|
|
1886
|
+
const effectiveJobId = explicit?.jobId || coachingJobId || run.jobId;
|
|
1887
|
+
// #696: the thread shows the invocation that the Hub sent, while the employee
|
|
1888
|
+
// still receives the full manager turn plus the style note.
|
|
1860
1889
|
const userText = (explicit?.remainder || instructions || '').trim();
|
|
1861
|
-
const display = userText || (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', '');
|
|
1890
|
+
const display = (0, manager_turns_1.fraimInvocationFor)(run.hostId, effectiveJobId) || userText || (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', '');
|
|
1862
1891
|
return {
|
|
1863
1892
|
message: (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions) + (0, manager_turns_1.buildCommunicationStyleNote)(),
|
|
1864
1893
|
display,
|
|
@@ -2183,6 +2212,26 @@ class AiHubServer {
|
|
|
2183
2212
|
return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist conversation.' });
|
|
2184
2213
|
}
|
|
2185
2214
|
});
|
|
2215
|
+
this.app.get('/api/ai-hub/projects', (req, res) => {
|
|
2216
|
+
const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
2217
|
+
? path_1.default.resolve(req.query.projectPath)
|
|
2218
|
+
: this.projectPath;
|
|
2219
|
+
return res.json({ projectPath, projects: this.knownProjects(projectPath), source: 'disk' });
|
|
2220
|
+
});
|
|
2221
|
+
this.app.put('/api/ai-hub/projects', (req, res) => {
|
|
2222
|
+
try {
|
|
2223
|
+
const body = (req.body ?? {});
|
|
2224
|
+
const projectPath = ensureDirectoryPath(body.projectPath || this.projectPath);
|
|
2225
|
+
if (!Array.isArray(body.projects)) {
|
|
2226
|
+
return res.status(400).json({ error: 'projects array required' });
|
|
2227
|
+
}
|
|
2228
|
+
const projects = this.saveKnownProjects(projectPath, body.projects);
|
|
2229
|
+
return res.json({ projectPath, projects, source: 'disk' });
|
|
2230
|
+
}
|
|
2231
|
+
catch (error) {
|
|
2232
|
+
return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist projects.' });
|
|
2233
|
+
}
|
|
2234
|
+
});
|
|
2186
2235
|
this.app.post('/api/ai-hub/api-key', (req, res) => {
|
|
2187
2236
|
const { apiKey } = req.body;
|
|
2188
2237
|
if (!apiKey || typeof apiKey !== 'string')
|
|
@@ -2925,9 +2974,15 @@ class AiHubServer {
|
|
|
2925
2974
|
this.scheduleDeployment(deployment);
|
|
2926
2975
|
return res.status(201).json(deployment);
|
|
2927
2976
|
});
|
|
2928
|
-
// GET /api/ai-hub/schedules — list
|
|
2929
|
-
this.app.get('/api/ai-hub/schedules', (
|
|
2930
|
-
|
|
2977
|
+
// GET /api/ai-hub/schedules — list scheduled deployments for one project.
|
|
2978
|
+
this.app.get('/api/ai-hub/schedules', (req, res) => {
|
|
2979
|
+
try {
|
|
2980
|
+
const projectPath = deploymentProjectFilter(req.query.projectPath, this.projectPath);
|
|
2981
|
+
return res.json(this.deploymentStore.load().filter((d) => d.type === 'scheduled' && deploymentBelongsToProject(d, projectPath, this.projectPath)));
|
|
2982
|
+
}
|
|
2983
|
+
catch (err) {
|
|
2984
|
+
return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
|
|
2985
|
+
}
|
|
2931
2986
|
});
|
|
2932
2987
|
// DELETE /api/ai-hub/schedules/:id — remove a scheduled deployment.
|
|
2933
2988
|
this.app.delete('/api/ai-hub/schedules/:id', (req, res) => {
|
|
@@ -2945,7 +3000,7 @@ class AiHubServer {
|
|
|
2945
3000
|
// PUT /api/ai-hub/schedules/:id — update an existing scheduled deployment.
|
|
2946
3001
|
this.app.put('/api/ai-hub/schedules/:id', (req, res) => {
|
|
2947
3002
|
const { id } = req.params;
|
|
2948
|
-
const { label, jobId, cronExpr, hostId, instructions, allowConcurrent } = req.body ?? {};
|
|
3003
|
+
const { label, jobId, projectPath, cronExpr, hostId, instructions, allowConcurrent } = req.body ?? {};
|
|
2949
3004
|
if (cronExpr !== undefined) {
|
|
2950
3005
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
2951
3006
|
const cronLib = require('node-cron');
|
|
@@ -2953,6 +3008,14 @@ class AiHubServer {
|
|
|
2953
3008
|
return res.status(400).json({ error: 'Invalid cron expression.' });
|
|
2954
3009
|
}
|
|
2955
3010
|
}
|
|
3011
|
+
let resolvedProjectPath;
|
|
3012
|
+
try {
|
|
3013
|
+
if (projectPath !== undefined)
|
|
3014
|
+
resolvedProjectPath = ensureDirectoryPath(projectPath || this.projectPath);
|
|
3015
|
+
}
|
|
3016
|
+
catch (err) {
|
|
3017
|
+
return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
|
|
3018
|
+
}
|
|
2956
3019
|
const validEmployees = VALID_EMPLOYEE_IDS;
|
|
2957
3020
|
let updated = null;
|
|
2958
3021
|
const ok = this.deploymentStore.update(id, (dep) => {
|
|
@@ -2960,6 +3023,8 @@ class AiHubServer {
|
|
|
2960
3023
|
dep.label = label;
|
|
2961
3024
|
if (jobId !== undefined)
|
|
2962
3025
|
dep.jobId = jobId;
|
|
3026
|
+
if (resolvedProjectPath !== undefined)
|
|
3027
|
+
dep.projectPath = resolvedProjectPath;
|
|
2963
3028
|
if (cronExpr !== undefined)
|
|
2964
3029
|
dep.cronExpr = cronExpr;
|
|
2965
3030
|
if (hostId !== undefined && validEmployees.includes(hostId))
|
|
@@ -3006,12 +3071,18 @@ class AiHubServer {
|
|
|
3006
3071
|
this.deploymentStore.create(deployment);
|
|
3007
3072
|
return res.status(201).json({ ...deployment, inboundUrl: `${this.hubBase}/api/ai-hub/webhooks/${deployment.id}/inbound` });
|
|
3008
3073
|
});
|
|
3009
|
-
// GET /api/ai-hub/webhooks — list
|
|
3010
|
-
this.app.get('/api/ai-hub/webhooks', (
|
|
3074
|
+
// GET /api/ai-hub/webhooks — list webhook deployments for one project.
|
|
3075
|
+
this.app.get('/api/ai-hub/webhooks', (req, res) => {
|
|
3011
3076
|
const hubBase = this.hubBase;
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
.
|
|
3077
|
+
try {
|
|
3078
|
+
const projectPath = deploymentProjectFilter(req.query.projectPath, this.projectPath);
|
|
3079
|
+
return res.json(this.deploymentStore.load()
|
|
3080
|
+
.filter((d) => d.type === 'webhook' && deploymentBelongsToProject(d, projectPath, this.projectPath))
|
|
3081
|
+
.map((d) => ({ ...d, inboundUrl: `${hubBase}/api/ai-hub/webhooks/${d.id}/inbound` })));
|
|
3082
|
+
}
|
|
3083
|
+
catch (err) {
|
|
3084
|
+
return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
|
|
3085
|
+
}
|
|
3015
3086
|
});
|
|
3016
3087
|
// DELETE /api/ai-hub/webhooks/:id — remove a webhook deployment.
|
|
3017
3088
|
this.app.delete('/api/ai-hub/webhooks/:id', (req, res) => {
|
|
@@ -3023,7 +3094,15 @@ class AiHubServer {
|
|
|
3023
3094
|
// PUT /api/ai-hub/webhooks/:id — update an existing webhook deployment.
|
|
3024
3095
|
this.app.put('/api/ai-hub/webhooks/:id', (req, res) => {
|
|
3025
3096
|
const { id } = req.params;
|
|
3026
|
-
const { label, jobId, hostId, instructions, allowConcurrent } = req.body ?? {};
|
|
3097
|
+
const { label, jobId, projectPath, hostId, instructions, allowConcurrent } = req.body ?? {};
|
|
3098
|
+
let resolvedProjectPath;
|
|
3099
|
+
try {
|
|
3100
|
+
if (projectPath !== undefined)
|
|
3101
|
+
resolvedProjectPath = ensureDirectoryPath(projectPath || this.projectPath);
|
|
3102
|
+
}
|
|
3103
|
+
catch (err) {
|
|
3104
|
+
return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
|
|
3105
|
+
}
|
|
3027
3106
|
const validEmployees = VALID_EMPLOYEE_IDS;
|
|
3028
3107
|
let updated = null;
|
|
3029
3108
|
const ok = this.deploymentStore.update(id, (dep) => {
|
|
@@ -3031,6 +3110,8 @@ class AiHubServer {
|
|
|
3031
3110
|
dep.label = label;
|
|
3032
3111
|
if (jobId !== undefined)
|
|
3033
3112
|
dep.jobId = jobId;
|
|
3113
|
+
if (resolvedProjectPath !== undefined)
|
|
3114
|
+
dep.projectPath = resolvedProjectPath;
|
|
3034
3115
|
if (hostId !== undefined && validEmployees.includes(hostId))
|
|
3035
3116
|
dep.hostId = hostId;
|
|
3036
3117
|
if (instructions !== undefined)
|
|
@@ -95,11 +95,6 @@ function asArray(value) {
|
|
|
95
95
|
function stringValue(value) {
|
|
96
96
|
return typeof value === 'string' && value.trim() ? value : null;
|
|
97
97
|
}
|
|
98
|
-
function readPackageVersion(repoRoot, result) {
|
|
99
|
-
const relPath = 'package.json';
|
|
100
|
-
const packageJson = readJsonObject(repoRoot, relPath, result);
|
|
101
|
-
return packageJson ? stringValue(packageJson.version) : null;
|
|
102
|
-
}
|
|
103
98
|
function requireHttpsUrl(value, relPath, field, result) {
|
|
104
99
|
const url = stringValue(value);
|
|
105
100
|
if (!url) {
|
|
@@ -202,7 +197,7 @@ function validateTargetMatrix(repoRoot, result) {
|
|
|
202
197
|
const relPath = path_1.default.join(TARGET_ROOT, 'marketplace-targets.json');
|
|
203
198
|
const targetMatrix = readJsonObject(repoRoot, relPath, result);
|
|
204
199
|
if (!targetMatrix) {
|
|
205
|
-
return { remoteMcpUrl: null, oauthAuthorizationServer: null,
|
|
200
|
+
return { remoteMcpUrl: null, oauthAuthorizationServer: null, contactEmail: null };
|
|
206
201
|
}
|
|
207
202
|
assertEqual(targetMatrix.schema, 'fraim-marketplace-targets-v1', relPath, 'schema', result);
|
|
208
203
|
assertEqual(targetMatrix.issue, 674, relPath, 'issue', result);
|
|
@@ -214,13 +209,12 @@ function validateTargetMatrix(repoRoot, result) {
|
|
|
214
209
|
if (!remoteMcp) {
|
|
215
210
|
addIssue(result.errors, relPath, 'remoteMcp must be an object');
|
|
216
211
|
}
|
|
217
|
-
const
|
|
218
|
-
if (!
|
|
219
|
-
addIssue(result.errors, relPath, 'product.
|
|
212
|
+
const contactEmail = product ? stringValue(product.contactEmail) : null;
|
|
213
|
+
if (!contactEmail) {
|
|
214
|
+
addIssue(result.errors, relPath, 'product.contactEmail must be set');
|
|
220
215
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
assertEqual(productVersion, packageVersion, relPath, 'product.packageVersion', result);
|
|
216
|
+
if (contactEmail && product) {
|
|
217
|
+
assertEqual(product.support, contactEmail, relPath, 'product.support', result);
|
|
224
218
|
}
|
|
225
219
|
const remoteMcpUrl = remoteMcp
|
|
226
220
|
? requireHttpsUrl(remoteMcp.url, relPath, 'remoteMcp.url', result)
|
|
@@ -229,7 +223,7 @@ function validateTargetMatrix(repoRoot, result) {
|
|
|
229
223
|
? requireHttpsUrl(remoteMcp.oauthAuthorizationServer, relPath, 'remoteMcp.oauthAuthorizationServer', result)
|
|
230
224
|
: null;
|
|
231
225
|
validateTargetEntries(repoRoot, relPath, asArray(targetMatrix.targets), result);
|
|
232
|
-
return { remoteMcpUrl, oauthAuthorizationServer,
|
|
226
|
+
return { remoteMcpUrl, oauthAuthorizationServer, contactEmail };
|
|
233
227
|
}
|
|
234
228
|
function validateOpenAiAssets(repoRoot, openAiRoot, result) {
|
|
235
229
|
assertFile(repoRoot, path_1.default.join(openAiRoot, 'README.md'), result);
|
|
@@ -254,6 +248,7 @@ function validateOpenAiPacketFields(packet, relPath, targetDetails, result) {
|
|
|
254
248
|
else {
|
|
255
249
|
assertEqual(app.name, 'FRAIM', relPath, 'app.name', result);
|
|
256
250
|
requireHttpsUrl(app.website, relPath, 'app.website', result);
|
|
251
|
+
assertEqual(app.support, targetDetails.contactEmail, relPath, 'app.support', result);
|
|
257
252
|
requireHttpsUrl(app.privacyPolicy, relPath, 'app.privacyPolicy', result);
|
|
258
253
|
requireHttpsUrl(app.termsOfService, relPath, 'app.termsOfService', result);
|
|
259
254
|
}
|
|
@@ -288,7 +283,7 @@ function validateOpenAiPacket(repoRoot, result, targetDetails) {
|
|
|
288
283
|
assertEqual(packet.schema, 'fraim-openai-app-submission-packet-v1', relPath, 'schema', result);
|
|
289
284
|
validateOpenAiPacketFields(packet, relPath, targetDetails, result);
|
|
290
285
|
}
|
|
291
|
-
function validatePolicyPages(repoRoot, result) {
|
|
286
|
+
function validatePolicyPages(repoRoot, targetDetails, result) {
|
|
292
287
|
for (const relPath of POLICY_PAGE_PATHS) {
|
|
293
288
|
if (!assertFile(repoRoot, relPath, result)) {
|
|
294
289
|
continue;
|
|
@@ -297,7 +292,7 @@ function validatePolicyPages(repoRoot, result) {
|
|
|
297
292
|
if (!content.includes('FRAIM')) {
|
|
298
293
|
addIssue(result.errors, relPath, 'policy page must identify FRAIM');
|
|
299
294
|
}
|
|
300
|
-
if (!content.includes(
|
|
295
|
+
if (targetDetails.contactEmail && !content.includes(targetDetails.contactEmail)) {
|
|
301
296
|
addIssue(result.errors, relPath, 'policy page must include support contact');
|
|
302
297
|
}
|
|
303
298
|
}
|
|
@@ -337,11 +332,14 @@ function validateCodexManifestInterface(repoRoot, pluginRoot, manifestRelPath, m
|
|
|
337
332
|
assertRelativeAsset(repoRoot, pluginRoot, screenshot, manifestRelPath, `interface.screenshots[${index}]`, result);
|
|
338
333
|
}
|
|
339
334
|
}
|
|
340
|
-
function validateCodexManifest(repoRoot, pluginRoot, manifest, manifestRelPath,
|
|
335
|
+
function validateCodexManifest(repoRoot, pluginRoot, manifest, manifestRelPath, contactEmail, result) {
|
|
341
336
|
if (!manifest)
|
|
342
337
|
return;
|
|
343
338
|
assertEqual(manifest.name, 'fraim', manifestRelPath, 'name', result);
|
|
344
|
-
|
|
339
|
+
if (!stringValue(manifest.version)) {
|
|
340
|
+
addIssue(result.errors, manifestRelPath, 'version must be a non-empty string');
|
|
341
|
+
}
|
|
342
|
+
assertEqual(asObject(manifest.author)?.email, contactEmail, manifestRelPath, 'author.email', result);
|
|
345
343
|
assertEqual(manifest.skills, './skills/', manifestRelPath, 'skills', result);
|
|
346
344
|
assertEqual(manifest.mcpServers, './.mcp.json', manifestRelPath, 'mcpServers', result);
|
|
347
345
|
const manifestInterface = asObject(manifest.interface);
|
|
@@ -400,17 +398,21 @@ function validateCodexPackage(repoRoot, result, targetDetails) {
|
|
|
400
398
|
const manifest = readJsonObject(repoRoot, manifestRelPath, result);
|
|
401
399
|
const mcpConfig = readJsonObject(repoRoot, mcpRelPath, result);
|
|
402
400
|
validateCodexMarketplace(marketplace, marketplaceRelPath, result);
|
|
403
|
-
validateCodexManifest(repoRoot, pluginRoot, manifest, manifestRelPath, targetDetails.
|
|
401
|
+
validateCodexManifest(repoRoot, pluginRoot, manifest, manifestRelPath, targetDetails.contactEmail, result);
|
|
404
402
|
validateCodexMcpConfig(mcpConfig, mcpRelPath, targetDetails.remoteMcpUrl, result);
|
|
405
403
|
validateCodexSkill(repoRoot, skillRelPath, result);
|
|
406
404
|
}
|
|
407
|
-
function validateSimpleMarketplaceManifest(marketplace, marketplaceRelPath, expectedName, expectedSource,
|
|
405
|
+
function validateSimpleMarketplaceManifest(marketplace, marketplaceRelPath, expectedName, expectedSource, contactEmail, result) {
|
|
408
406
|
if (!marketplace)
|
|
409
407
|
return;
|
|
410
408
|
assertEqual(marketplace.name, expectedName, marketplaceRelPath, 'name', result);
|
|
411
|
-
|
|
409
|
+
const owner = asObject(marketplace.owner);
|
|
410
|
+
if (!owner) {
|
|
412
411
|
addIssue(result.errors, marketplaceRelPath, 'owner must be an object');
|
|
413
412
|
}
|
|
413
|
+
else {
|
|
414
|
+
assertEqual(owner.email, contactEmail, marketplaceRelPath, 'owner.email', result);
|
|
415
|
+
}
|
|
414
416
|
const entries = asArray(marketplace.plugins);
|
|
415
417
|
const fraimEntry = entries
|
|
416
418
|
.map((entry) => asObject(entry))
|
|
@@ -420,7 +422,13 @@ function validateSimpleMarketplaceManifest(marketplace, marketplaceRelPath, expe
|
|
|
420
422
|
return;
|
|
421
423
|
}
|
|
422
424
|
assertEqual(fraimEntry.source, expectedSource, marketplaceRelPath, 'fraim.source', result);
|
|
423
|
-
|
|
425
|
+
if (!stringValue(fraimEntry.version)) {
|
|
426
|
+
addIssue(result.errors, marketplaceRelPath, 'fraim.version must be a non-empty string');
|
|
427
|
+
}
|
|
428
|
+
const author = asObject(fraimEntry.author);
|
|
429
|
+
if (author) {
|
|
430
|
+
assertEqual(author.email, contactEmail, marketplaceRelPath, 'fraim.author.email', result);
|
|
431
|
+
}
|
|
424
432
|
}
|
|
425
433
|
function validateMcpRegistryPackage(repoRoot, result, targetDetails) {
|
|
426
434
|
const registryRoot = path_1.default.join(TARGET_ROOT, 'mcp-registry');
|
|
@@ -436,7 +444,9 @@ function validateMcpRegistryPackage(repoRoot, result, targetDetails) {
|
|
|
436
444
|
if (!description || description.length > 100) {
|
|
437
445
|
addIssue(result.errors, serverRelPath, 'description must be a non-empty string of at most 100 characters');
|
|
438
446
|
}
|
|
439
|
-
|
|
447
|
+
if (!stringValue(serverJson.version)) {
|
|
448
|
+
addIssue(result.errors, serverRelPath, 'version must be a non-empty string');
|
|
449
|
+
}
|
|
440
450
|
requireHttpsUrl(serverJson.websiteUrl, serverRelPath, 'websiteUrl', result);
|
|
441
451
|
const repository = asObject(serverJson.repository);
|
|
442
452
|
assertEqual(repository?.url, 'https://github.com/mathursrus/FRAIM', serverRelPath, 'repository.url', result);
|
|
@@ -467,11 +477,14 @@ function validateCursorPackage(repoRoot, result, targetDetails) {
|
|
|
467
477
|
const mcpConfig = readJsonObject(repoRoot, mcpRelPath, result);
|
|
468
478
|
assertFile(repoRoot, path_1.default.join(cursorRoot, 'README.md'), result);
|
|
469
479
|
assertFile(repoRoot, path_1.default.join(pluginRoot, 'README.md'), result);
|
|
470
|
-
validateSimpleMarketplaceManifest(rootMarketplace, ROOT_CURSOR_MARKETPLACE_PATH, 'fraim-marketplace', 'marketplaces/fraim/cursor/plugins/fraim', targetDetails.
|
|
471
|
-
validateSimpleMarketplaceManifest(targetMarketplace, targetMarketplaceRelPath, 'fraim-marketplace', 'plugins/fraim', targetDetails.
|
|
480
|
+
validateSimpleMarketplaceManifest(rootMarketplace, ROOT_CURSOR_MARKETPLACE_PATH, 'fraim-marketplace', 'marketplaces/fraim/cursor/plugins/fraim', targetDetails.contactEmail, result);
|
|
481
|
+
validateSimpleMarketplaceManifest(targetMarketplace, targetMarketplaceRelPath, 'fraim-marketplace', 'plugins/fraim', targetDetails.contactEmail, result);
|
|
472
482
|
if (manifest) {
|
|
473
483
|
assertEqual(manifest.name, 'fraim', manifestRelPath, 'name', result);
|
|
474
|
-
|
|
484
|
+
if (!stringValue(manifest.version)) {
|
|
485
|
+
addIssue(result.errors, manifestRelPath, 'version must be a non-empty string');
|
|
486
|
+
}
|
|
487
|
+
assertEqual(asObject(manifest.author)?.email, targetDetails.contactEmail, manifestRelPath, 'author.email', result);
|
|
475
488
|
assertEqual(manifest.rules, 'rules/', manifestRelPath, 'rules', result);
|
|
476
489
|
assertEqual(manifest.skills, 'skills/', manifestRelPath, 'skills', result);
|
|
477
490
|
assertEqual(manifest.commands, 'commands/', manifestRelPath, 'commands', result);
|
|
@@ -514,10 +527,13 @@ function validateCopilotPackage(repoRoot, result, targetDetails) {
|
|
|
514
527
|
const mcpConfig = readJsonObject(repoRoot, mcpRelPath, result);
|
|
515
528
|
assertFile(repoRoot, path_1.default.join(vscodeRoot, 'README.md'), result);
|
|
516
529
|
assertFile(repoRoot, path_1.default.join(pluginRoot, 'README.md'), result);
|
|
517
|
-
validateSimpleMarketplaceManifest(rootMarketplace, ROOT_COPILOT_MARKETPLACE_PATH, 'fraim-copilot-marketplace', 'marketplaces/fraim/vscode/plugins/fraim', targetDetails.
|
|
530
|
+
validateSimpleMarketplaceManifest(rootMarketplace, ROOT_COPILOT_MARKETPLACE_PATH, 'fraim-copilot-marketplace', 'marketplaces/fraim/vscode/plugins/fraim', targetDetails.contactEmail, result);
|
|
518
531
|
if (manifest) {
|
|
519
532
|
assertEqual(manifest.name, 'fraim', manifestRelPath, 'name', result);
|
|
520
|
-
|
|
533
|
+
if (!stringValue(manifest.version)) {
|
|
534
|
+
addIssue(result.errors, manifestRelPath, 'version must be a non-empty string');
|
|
535
|
+
}
|
|
536
|
+
assertEqual(asObject(manifest.author)?.email, targetDetails.contactEmail, manifestRelPath, 'author.email', result);
|
|
521
537
|
assertEqual(manifest.skills, 'skills/', manifestRelPath, 'skills', result);
|
|
522
538
|
assertEqual(manifest.commands, 'commands/', manifestRelPath, 'commands', result);
|
|
523
539
|
assertEqual(manifest.mcpServers, '.mcp.json', manifestRelPath, 'mcpServers', result);
|
|
@@ -540,7 +556,9 @@ function validateGeminiPackage(repoRoot, result, targetDetails) {
|
|
|
540
556
|
if (!manifest)
|
|
541
557
|
return;
|
|
542
558
|
assertEqual(manifest.name, 'fraim', manifestRelPath, 'name', result);
|
|
543
|
-
|
|
559
|
+
if (!stringValue(manifest.version)) {
|
|
560
|
+
addIssue(result.errors, manifestRelPath, 'version must be a non-empty string');
|
|
561
|
+
}
|
|
544
562
|
assertEqual(manifest.contextFileName, 'GEMINI.md', manifestRelPath, 'contextFileName', result);
|
|
545
563
|
const servers = asObject(manifest.mcpServers);
|
|
546
564
|
const fraimServer = asObject(servers?.fraim);
|
|
@@ -559,7 +577,7 @@ function validateMarketplaceBundles(repoRoot = process.cwd()) {
|
|
|
559
577
|
assertFile(repoRoot, ACCEPTANCE_EVIDENCE_PATH, result);
|
|
560
578
|
const targetDetails = validateTargetMatrix(repoRoot, result);
|
|
561
579
|
validateOpenAiPacket(repoRoot, result, targetDetails);
|
|
562
|
-
validatePolicyPages(repoRoot, result);
|
|
580
|
+
validatePolicyPages(repoRoot, targetDetails, result);
|
|
563
581
|
validateCodexPackage(repoRoot, result, targetDetails);
|
|
564
582
|
validateMcpRegistryPackage(repoRoot, result, targetDetails);
|
|
565
583
|
validateCursorPackage(repoRoot, result, targetDetails);
|