fraim-hub 2.0.248 → 2.0.249
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/cli.js +43 -6
- package/dist/src/ai-hub/custom-employees.js +31 -19
- package/dist/src/ai-hub/office-sideload.js +45 -16
- package/dist/src/ai-hub/server.js +18 -4
- package/dist/src/cli/utils/local-folder-sync.js +100 -48
- package/dist/src/cli/utils/org-publish.js +37 -40
- package/dist/src/cli/utils/pack-git-publish.js +248 -0
- package/dist/src/cli/utils/pack-home.js +279 -0
- package/dist/src/cli/utils/user-config.js +10 -2
- package/dist/src/core/capability-pack.js +23 -1
- package/dist/src/local-mcp-server/learning-context-builder.js +29 -8
- package/extensions/office-word/manifest.xml +3 -3
- package/package.json +4 -2
package/dist/src/ai-hub/cli.js
CHANGED
|
@@ -36,6 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.waitForDesktopHubReady = waitForDesktopHubReady;
|
|
39
40
|
exports.runHub = runHub;
|
|
40
41
|
// Hub-owned launcher used by the fraim-hub package. Keep this outside
|
|
41
42
|
// src/cli/commands so the core fraim package has no Hub command implementation.
|
|
@@ -79,7 +80,7 @@ function openDesktopWindow(projectPath, preferredPort, runtimeId) {
|
|
|
79
80
|
const electronBinary = resolveElectronBinary();
|
|
80
81
|
const desktopEntry = resolveDesktopEntry();
|
|
81
82
|
if (!electronBinary || !desktopEntry) {
|
|
82
|
-
return
|
|
83
|
+
return null;
|
|
83
84
|
}
|
|
84
85
|
const args = projectPath
|
|
85
86
|
? [desktopEntry, '--project-path', projectPath, '--port', String(preferredPort)]
|
|
@@ -91,8 +92,7 @@ function openDesktopWindow(projectPath, preferredPort, runtimeId) {
|
|
|
91
92
|
detached: true,
|
|
92
93
|
stdio: 'ignore',
|
|
93
94
|
});
|
|
94
|
-
child
|
|
95
|
-
return true;
|
|
95
|
+
return child;
|
|
96
96
|
}
|
|
97
97
|
function openBrowser(url) {
|
|
98
98
|
if (process.platform === 'win32') {
|
|
@@ -207,6 +207,41 @@ function fetchRunningHubVersion(port) {
|
|
|
207
207
|
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
208
208
|
});
|
|
209
209
|
}
|
|
210
|
+
async function waitForDesktopHubReady(child, preferredPort, options = {}) {
|
|
211
|
+
const timeoutMs = options.timeoutMs ?? 15000;
|
|
212
|
+
const pollMs = options.pollMs ?? 250;
|
|
213
|
+
const runtimeId = options.runtimeId || 'hub';
|
|
214
|
+
const fraimDir = options.fraimDir || (0, project_fraim_paths_1.getUserFraimDirPath)();
|
|
215
|
+
const start = Date.now();
|
|
216
|
+
const childState = {};
|
|
217
|
+
child.once('exit', (code, signal) => {
|
|
218
|
+
childState.exit = { code, signal };
|
|
219
|
+
});
|
|
220
|
+
child.once('error', (error) => {
|
|
221
|
+
childState.error = error;
|
|
222
|
+
});
|
|
223
|
+
while (Date.now() - start < timeoutMs) {
|
|
224
|
+
if (childState.error) {
|
|
225
|
+
throw new Error(`FRAIM Hub desktop shell failed to launch: ${childState.error.message}`);
|
|
226
|
+
}
|
|
227
|
+
if (childState.exit) {
|
|
228
|
+
const detail = childState.exit.signal ? `signal ${childState.exit.signal}` : `exit code ${childState.exit.code ?? 'unknown'}`;
|
|
229
|
+
throw new Error(`FRAIM Hub desktop shell exited before the Hub became ready (${detail}).`);
|
|
230
|
+
}
|
|
231
|
+
const runtime = (0, hub_runtime_file_1.readHubRuntimeFile)(fraimDir, runtimeId);
|
|
232
|
+
const ports = runtime?.port && runtime.port !== preferredPort
|
|
233
|
+
? [runtime.port, preferredPort]
|
|
234
|
+
: [preferredPort];
|
|
235
|
+
for (const port of ports) {
|
|
236
|
+
const version = await fetchRunningHubVersion(port);
|
|
237
|
+
if (version) {
|
|
238
|
+
return { port, version };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
242
|
+
}
|
|
243
|
+
throw new Error(`Timed out waiting for FRAIM Hub desktop shell to become ready on port ${preferredPort}.`);
|
|
244
|
+
}
|
|
210
245
|
async function reconcileRunningHub(flags, runtimeId = 'hub') {
|
|
211
246
|
const running = (0, hub_runtime_file_1.readHubRuntimeFile)((0, project_fraim_paths_1.getUserFraimDirPath)(), runtimeId);
|
|
212
247
|
const confirmedVersion = running ? await fetchRunningHubVersion(running.port) : null;
|
|
@@ -243,8 +278,8 @@ async function runHub(options) {
|
|
|
243
278
|
if (wantDesktop) {
|
|
244
279
|
await reconcileRunningHub({ restart: !!options.restart, keepRunning: !!options.keepRunning }, runtimeId);
|
|
245
280
|
}
|
|
246
|
-
const
|
|
247
|
-
if (!
|
|
281
|
+
const desktopChild = wantDesktop ? openDesktopWindow(projectPath, preferredPort, runtimeId) : null;
|
|
282
|
+
if (!desktopChild) {
|
|
248
283
|
const port = await findAvailablePort(preferredPort);
|
|
249
284
|
const server = new AiHubServer(projectPath ? { projectPath } : {});
|
|
250
285
|
await server.start(port);
|
|
@@ -254,7 +289,9 @@ async function runHub(options) {
|
|
|
254
289
|
openBrowser(url);
|
|
255
290
|
return;
|
|
256
291
|
}
|
|
257
|
-
|
|
292
|
+
const ready = await waitForDesktopHubReady(desktopChild, preferredPort, { runtimeId });
|
|
293
|
+
desktopChild.unref();
|
|
294
|
+
console.log(`AI Hub desktop shell launched at http://127.0.0.1:${ready.port}/ai-hub/ (v${ready.version}).`);
|
|
258
295
|
if (projectPath)
|
|
259
296
|
console.log(`Project path: ${projectPath}`);
|
|
260
297
|
else
|
|
@@ -17,21 +17,22 @@ exports.buildCustomEmployeePersona = buildCustomEmployeePersona;
|
|
|
17
17
|
// evaluatePersonaAccess) must never call resolveEmployee.
|
|
18
18
|
const fs_1 = __importDefault(require("fs"));
|
|
19
19
|
const path_1 = __importDefault(require("path"));
|
|
20
|
-
const
|
|
20
|
+
const pack_home_1 = require("../cli/utils/pack-home");
|
|
21
21
|
const persona_hiring_1 = require("../config/persona-hiring");
|
|
22
22
|
const EMPLOYEES_DIR_REL = path_1.default.join('fraim', 'personalized-employee', 'employees');
|
|
23
23
|
function employeesDir(projectDir) {
|
|
24
24
|
return path_1.default.join(projectDir, EMPLOYEES_DIR_REL);
|
|
25
25
|
}
|
|
26
|
-
// Issue #
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
function
|
|
31
|
-
return
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
26
|
+
// Issue #1043 (review round 2): every layer has exactly one home, named by
|
|
27
|
+
// config, and that home holds the whole layer. There are no caches, so an
|
|
28
|
+
// employee has exactly one authoritative location per level.
|
|
29
|
+
const EMPLOYEES_SUBDIR = 'employees';
|
|
30
|
+
function layerEmployeeReadDirs(layer) {
|
|
31
|
+
return (0, pack_home_1.packReadRoots)(layer).map((root) => path_1.default.join(root, EMPLOYEES_SUBDIR));
|
|
32
|
+
}
|
|
33
|
+
/** The single writable employees directory for a layer. */
|
|
34
|
+
function layerEmployeeWriteDir(layer) {
|
|
35
|
+
return path_1.default.join((0, pack_home_1.resolvePackHome)(layer).root, EMPLOYEES_SUBDIR);
|
|
35
36
|
}
|
|
36
37
|
function readEmployeesFromDir(dir, scope) {
|
|
37
38
|
if (!fs_1.default.existsSync(dir))
|
|
@@ -82,15 +83,23 @@ function safeSlug(key) {
|
|
|
82
83
|
* cache. Reading the manager level here is what makes the manager-level default
|
|
83
84
|
* usable: without it an employee created at the manager level would exist on
|
|
84
85
|
* disk and appear in no roster.
|
|
86
|
+
*
|
|
87
|
+
* Issue #1043 adds the org level at the bottom of that order, so an employee
|
|
88
|
+
* shared company-wide is visible to everyone, and a manager or project copy of
|
|
89
|
+
* the same key still takes precedence over it.
|
|
85
90
|
*/
|
|
86
91
|
function readCustomEmployees(projectDir) {
|
|
87
|
-
const dirs = managerEmployeesDirs();
|
|
88
92
|
const byKey = new Map();
|
|
89
93
|
// Lowest precedence first, so a later write overwrites on key collision.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
// packReadRoots is highest-precedence-first, so it is walked in reverse.
|
|
95
|
+
for (const dir of [...layerEmployeeReadDirs('org')].reverse()) {
|
|
96
|
+
for (const emp of readEmployeesFromDir(dir, 'org'))
|
|
97
|
+
byKey.set(emp.key, emp);
|
|
98
|
+
}
|
|
99
|
+
for (const dir of [...layerEmployeeReadDirs('manager')].reverse()) {
|
|
100
|
+
for (const emp of readEmployeesFromDir(dir, 'manager'))
|
|
101
|
+
byKey.set(emp.key, emp);
|
|
102
|
+
}
|
|
94
103
|
for (const emp of readEmployeesFromDir(employeesDir(projectDir), 'project'))
|
|
95
104
|
byKey.set(emp.key, emp);
|
|
96
105
|
return [...byKey.values()];
|
|
@@ -108,7 +117,9 @@ function writeCustomEmployee(projectDir, employee) {
|
|
|
108
117
|
const slug = safeSlug(record.key);
|
|
109
118
|
if (!slug)
|
|
110
119
|
throw new Error(`Invalid employee key: ${record.key}`);
|
|
111
|
-
|
|
120
|
+
// Issue #1043 (review round 1): the org level now has a real writable home,
|
|
121
|
+
// resolved from config, so an org-scope write no longer has to be refused.
|
|
122
|
+
const dir = scope === 'project' ? employeesDir(projectDir) : layerEmployeeWriteDir(scope);
|
|
112
123
|
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
113
124
|
fs_1.default.writeFileSync(path_1.default.join(dir, `${slug}.json`), JSON.stringify(record, null, 2), 'utf8');
|
|
114
125
|
}
|
|
@@ -121,11 +132,12 @@ function deleteCustomEmployee(projectDir, key) {
|
|
|
121
132
|
const slug = safeSlug(key);
|
|
122
133
|
if (!slug)
|
|
123
134
|
return false;
|
|
124
|
-
|
|
135
|
+
// Every place a copy could live, at every level. A delete that misses one
|
|
136
|
+
// reports success while the record is still listed from that level.
|
|
125
137
|
const candidates = [
|
|
126
138
|
path_1.default.join(employeesDir(projectDir), `${slug}.json`),
|
|
127
|
-
path_1.default.join(
|
|
128
|
-
path_1.default.join(
|
|
139
|
+
...layerEmployeeReadDirs('manager').map((d) => path_1.default.join(d, `${slug}.json`)),
|
|
140
|
+
...layerEmployeeReadDirs('org').map((d) => path_1.default.join(d, `${slug}.json`)),
|
|
129
141
|
];
|
|
130
142
|
let removed = false;
|
|
131
143
|
for (const fp of candidates) {
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* Sideloads Office add-in manifests (Word + PowerPoint) so they appear under
|
|
4
4
|
* Insert > My Add-ins > Developer Add-ins without admin rights or AppSource.
|
|
5
5
|
*
|
|
6
|
-
* Windows: writes HKCU\SOFTWARE\Microsoft\Office\16.0\WEF\Developer
|
|
7
|
-
* ABSOLUTE FILE PATH to manifest.xml. This is
|
|
8
|
-
*
|
|
6
|
+
* Windows: writes HKCU\SOFTWARE\Microsoft\Office\16.0\WEF\Developer\{<guid>}
|
|
7
|
+
* (Default) = ABSOLUTE FILE PATH to manifest.xml. This is the registry
|
|
8
|
+
* shape Word resolves for Developer Add-ins. A URL value is for
|
|
9
9
|
* SharePoint/network-share catalogs and yields "catalog access denied"
|
|
10
10
|
* for a developer sideload — do NOT use a URL here.
|
|
11
11
|
* macOS: copies the manifest into each app's
|
|
@@ -23,6 +23,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
23
23
|
};
|
|
24
24
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
25
|
exports.manifestXmlForPort = manifestXmlForPort;
|
|
26
|
+
exports.winDeveloperManifestSubkey = winDeveloperManifestSubkey;
|
|
26
27
|
exports.isSideloaded = isSideloaded;
|
|
27
28
|
exports.sideloadManifest = sideloadManifest;
|
|
28
29
|
exports.removeSideload = removeSideload;
|
|
@@ -61,7 +62,7 @@ function generatedManifestPath(entry, userDataDir) {
|
|
|
61
62
|
return path_1.default.join(userDataDir, 'office-manifests', entry.guid, 'manifest.xml');
|
|
62
63
|
}
|
|
63
64
|
function manifestXmlForPort(xml, httpPort) {
|
|
64
|
-
return xml.replace(/
|
|
65
|
+
return xml.replace(/https?:\/\/(?:localhost|127\.0\.0\.1):43091/g, `http://127.0.0.1:${httpPort}`);
|
|
65
66
|
}
|
|
66
67
|
function prepareManifestForSideload(entry, sourcePath, options) {
|
|
67
68
|
if (!options.httpPort)
|
|
@@ -76,16 +77,46 @@ function prepareManifestForSideload(entry, sourcePath, options) {
|
|
|
76
77
|
// ---------------------------------------------------------------------------
|
|
77
78
|
// Windows registry helpers
|
|
78
79
|
// ---------------------------------------------------------------------------
|
|
79
|
-
function
|
|
80
|
+
function winDeveloperManifestSubkey(guid) {
|
|
81
|
+
return `${WEF_DEVELOPER_KEY}\\{${guid}}`;
|
|
82
|
+
}
|
|
83
|
+
function parseRegSzValue(stdout) {
|
|
84
|
+
const line = stdout.split(/\r?\n/).find(l => l.includes('REG_SZ'));
|
|
85
|
+
if (!line)
|
|
86
|
+
return null;
|
|
87
|
+
const idx = line.indexOf('REG_SZ');
|
|
88
|
+
return line.slice(idx + 'REG_SZ'.length).trim() || null;
|
|
89
|
+
}
|
|
90
|
+
function winRegisteredRootValue(guid) {
|
|
80
91
|
const r = (0, child_process_1.spawnSync)('reg', ['query', WEF_DEVELOPER_KEY, '/v', guid], { encoding: 'utf8' });
|
|
81
92
|
if (r.status !== 0 || !r.stdout.includes('REG_SZ'))
|
|
82
93
|
return null;
|
|
83
94
|
// Output line looks like: " <guid> REG_SZ C:\path\to\manifest.xml"
|
|
84
|
-
|
|
85
|
-
|
|
95
|
+
return parseRegSzValue(r.stdout);
|
|
96
|
+
}
|
|
97
|
+
function winRegisteredDefaultValue(guid) {
|
|
98
|
+
const r = (0, child_process_1.spawnSync)('reg', ['query', winDeveloperManifestSubkey(guid), '/ve'], { encoding: 'utf8' });
|
|
99
|
+
if (r.status !== 0 || !r.stdout.includes('REG_SZ'))
|
|
86
100
|
return null;
|
|
87
|
-
|
|
88
|
-
return
|
|
101
|
+
// Output line looks like: " (Default) REG_SZ C:\path\to\manifest.xml"
|
|
102
|
+
return parseRegSzValue(r.stdout);
|
|
103
|
+
}
|
|
104
|
+
function winRegisteredValue(guid) {
|
|
105
|
+
// Word resolves the braced GUID subkey. Prefer it even when a legacy root
|
|
106
|
+
// value exists, so a stale effective registration cannot be hidden.
|
|
107
|
+
return winRegisteredDefaultValue(guid) ?? winRegisteredRootValue(guid);
|
|
108
|
+
}
|
|
109
|
+
function winWriteRegisteredValue(guid, manifestPath) {
|
|
110
|
+
const r = (0, child_process_1.spawnSync)('reg', [
|
|
111
|
+
'add', winDeveloperManifestSubkey(guid),
|
|
112
|
+
'/ve', '/t', 'REG_SZ', '/d', manifestPath, '/f',
|
|
113
|
+
], { encoding: 'utf8' });
|
|
114
|
+
if (r.status !== 0)
|
|
115
|
+
return { ok: false, reason: r.stderr || `reg add failed for ${guid}` };
|
|
116
|
+
// Remove the older root-value registration if present. Office does not need
|
|
117
|
+
// it, and keeping two registration shapes makes stale-state diagnosis harder.
|
|
118
|
+
(0, child_process_1.spawnSync)('reg', ['delete', WEF_DEVELOPER_KEY, '/v', guid, '/f'], { encoding: 'utf8' });
|
|
119
|
+
return { ok: true };
|
|
89
120
|
}
|
|
90
121
|
// ---------------------------------------------------------------------------
|
|
91
122
|
// Public API
|
|
@@ -117,13 +148,10 @@ function sideloadManifest(projectPath, options = {}) {
|
|
|
117
148
|
}
|
|
118
149
|
const sideloadPath = prepareManifestForSideload(entry, manifestPath, options);
|
|
119
150
|
if (process.platform === 'win32') {
|
|
120
|
-
// Developer-
|
|
121
|
-
const r = (
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
], { encoding: 'utf8' });
|
|
125
|
-
if (r.status !== 0)
|
|
126
|
-
return { ok: false, reason: r.stderr || `reg add failed for ${entry.guid}` };
|
|
151
|
+
// Developer add-in subkey default value = absolute file path to manifest.xml (NOT a URL).
|
|
152
|
+
const r = winWriteRegisteredValue(entry.guid, sideloadPath);
|
|
153
|
+
if (!r.ok)
|
|
154
|
+
return r;
|
|
127
155
|
continue;
|
|
128
156
|
}
|
|
129
157
|
if (process.platform === 'darwin') {
|
|
@@ -146,6 +174,7 @@ function removeSideload() {
|
|
|
146
174
|
for (const entry of MANIFESTS) {
|
|
147
175
|
if (process.platform === 'win32') {
|
|
148
176
|
(0, child_process_1.spawnSync)('reg', ['delete', WEF_DEVELOPER_KEY, '/v', entry.guid, '/f'], { encoding: 'utf8' });
|
|
177
|
+
(0, child_process_1.spawnSync)('reg', ['delete', winDeveloperManifestSubkey(entry.guid), '/f'], { encoding: 'utf8' });
|
|
149
178
|
}
|
|
150
179
|
else if (process.platform === 'darwin') {
|
|
151
180
|
const target = macWefPath(entry.macContainer, entry.guid);
|
|
@@ -1786,8 +1786,11 @@ class AiHubServer {
|
|
|
1786
1786
|
'.css': 'text/css; charset=utf-8',
|
|
1787
1787
|
'.js': 'application/javascript; charset=utf-8',
|
|
1788
1788
|
'.xml': 'application/xml; charset=utf-8',
|
|
1789
|
+
'.png': 'image/png',
|
|
1790
|
+
'.ico': 'image/x-icon',
|
|
1791
|
+
'.svg': 'image/svg+xml',
|
|
1789
1792
|
};
|
|
1790
|
-
const contentType = contentTypes[ext] || '
|
|
1793
|
+
const contentType = contentTypes[ext] || 'application/octet-stream';
|
|
1791
1794
|
fs_1.default.readFile(filePath, (err, data) => {
|
|
1792
1795
|
if (err) {
|
|
1793
1796
|
next(); // fall through to 404
|
|
@@ -3533,8 +3536,10 @@ class AiHubServer {
|
|
|
3533
3536
|
'.js': 'application/javascript',
|
|
3534
3537
|
'.css': 'text/css',
|
|
3535
3538
|
'.png': 'image/png',
|
|
3539
|
+
'.ico': 'image/x-icon',
|
|
3540
|
+
'.svg': 'image/svg+xml',
|
|
3536
3541
|
};
|
|
3537
|
-
const contentType = contentTypeMap[ext] || '
|
|
3542
|
+
const contentType = contentTypeMap[ext] || 'application/octet-stream';
|
|
3538
3543
|
fs_1.default.readFile(filePath, (err, data) => {
|
|
3539
3544
|
if (err) {
|
|
3540
3545
|
res.status(404).end('Not found');
|
|
@@ -5440,7 +5445,7 @@ class AiHubServer {
|
|
|
5440
5445
|
});
|
|
5441
5446
|
// POST /api/ai-hub/custom-employees — create a new custom employee.
|
|
5442
5447
|
this.app.post('/api/ai-hub/custom-employees', (req, res) => {
|
|
5443
|
-
const { projectPath: reqProjectPath, displayName, role, icon, jobIds } = req.body;
|
|
5448
|
+
const { projectPath: reqProjectPath, displayName, role, icon, jobIds, scope: reqScope } = req.body;
|
|
5444
5449
|
const projectPath = reqProjectPath || this.projectPath;
|
|
5445
5450
|
if (!displayName || typeof displayName !== 'string' || !displayName.trim()) {
|
|
5446
5451
|
return res.status(400).json({ error: 'displayName is required.' });
|
|
@@ -5448,6 +5453,15 @@ class AiHubServer {
|
|
|
5448
5453
|
if (!Array.isArray(jobIds) || jobIds.length === 0) {
|
|
5449
5454
|
return res.status(400).json({ error: 'jobIds must be a non-empty array.' });
|
|
5450
5455
|
}
|
|
5456
|
+
// Issue #1043 bug bash: this handler hardcoded scope 'project', so the
|
|
5457
|
+
// manager and org levels were unreachable from the Hub even though the
|
|
5458
|
+
// store writes all three. An explicit scope is now honored. The default
|
|
5459
|
+
// stays 'project' so existing Hub behavior is unchanged.
|
|
5460
|
+
const VALID_SCOPES = new Set(['manager', 'project', 'org']);
|
|
5461
|
+
if (reqScope !== undefined && !VALID_SCOPES.has(reqScope)) {
|
|
5462
|
+
return res.status(400).json({ error: `Invalid scope '${reqScope}'. Expected manager, project, or org.` });
|
|
5463
|
+
}
|
|
5464
|
+
const scope = (reqScope ?? 'project');
|
|
5451
5465
|
const existing = (0, custom_employees_1.readCustomEmployees)(projectPath).map((e) => e.key);
|
|
5452
5466
|
const key = (0, custom_employees_1.slugifyDisplayName)(displayName.trim(), existing);
|
|
5453
5467
|
const VALID_ICON_KINDS = new Set(['emoji', 'generated', 'image']);
|
|
@@ -5459,7 +5473,7 @@ class AiHubServer {
|
|
|
5459
5473
|
icon: { kind: iconKind, value: icon?.value ?? displayName.trim() },
|
|
5460
5474
|
jobIds,
|
|
5461
5475
|
createdBy: '',
|
|
5462
|
-
scope
|
|
5476
|
+
scope,
|
|
5463
5477
|
createdAt: new Date().toISOString(),
|
|
5464
5478
|
};
|
|
5465
5479
|
(0, custom_employees_1.writeCustomEmployee)(projectPath, employee);
|
|
@@ -37,42 +37,84 @@ exports.isConflictCopy = isConflictCopy;
|
|
|
37
37
|
exports.countConflictCopies = countConflictCopies;
|
|
38
38
|
exports.collectLocalFolderFiles = collectLocalFolderFiles;
|
|
39
39
|
exports.writeLocalFolderFile = writeLocalFolderFile;
|
|
40
|
+
exports.removeLocalFolderFiles = removeLocalFolderFiles;
|
|
40
41
|
exports.localFolderVersion = localFolderVersion;
|
|
41
42
|
const fs_1 = __importDefault(require("fs"));
|
|
42
43
|
const path_1 = __importDefault(require("path"));
|
|
43
44
|
const crypto_1 = __importDefault(require("crypto"));
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
const capability_pack_1 = require("../../core/capability-pack");
|
|
46
|
+
/**
|
|
47
|
+
* Pack subdirectories expected in every local-folder backend.
|
|
48
|
+
*
|
|
49
|
+
* Issue #1043: the capability directories (and `employees`) were missing, so a
|
|
50
|
+
* job, skill, nested rule, or employee record published into a local-folder
|
|
51
|
+
* pack was never collected on the way back down. The pack was write-only for
|
|
52
|
+
* those types. `CAPABILITY_DIRS` is the single allowlist for the capability
|
|
53
|
+
* half; this list is context/rules/learnings plus that, deduplicated because
|
|
54
|
+
* `rules` appears in both.
|
|
55
|
+
*/
|
|
56
|
+
exports.LOCAL_FOLDER_PACK_DIRS = [
|
|
57
|
+
...new Set(['context', 'rules', 'learnings', ...capability_pack_1.CAPABILITY_DIRS]),
|
|
58
|
+
];
|
|
46
59
|
/**
|
|
47
60
|
* Matches the conflict-copy filename patterns produced by OneDrive,
|
|
48
61
|
* SharePoint, Google Drive for Desktop, and Dropbox.
|
|
49
62
|
*
|
|
50
63
|
* Deliberately does NOT match ordinary parenthesised names like
|
|
51
64
|
* "sprint-retro (Q2 2026).md" — only the well-known suffix patterns.
|
|
65
|
+
*
|
|
66
|
+
* Issue #1043 widened the extension from `.md` to `.md|.json`: employee
|
|
67
|
+
* records are JSON pack members and get conflict copies like any other file.
|
|
52
68
|
*/
|
|
53
|
-
exports.CONFLICT_COPY_RE = /[\s-](\([^)]*'s conflicted copy \d{4}-\d{2}-\d{2}\)|\(\d+\)|[\w-]+-PC)\.md$/i;
|
|
69
|
+
exports.CONFLICT_COPY_RE = /[\s-](\([^)]*'s conflicted copy \d{4}-\d{2}-\d{2}\)|\(\d+\)|[\w-]+-PC)\.(md|json)$/i;
|
|
54
70
|
/** True when the filename looks like a sync-client conflict copy. */
|
|
55
71
|
function isConflictCopy(fileName) {
|
|
56
72
|
return exports.CONFLICT_COPY_RE.test(fileName);
|
|
57
73
|
}
|
|
58
74
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
75
|
+
* Walk every pack file under localPath, depth-first, yielding pack-relative
|
|
76
|
+
* paths with forward slashes.
|
|
77
|
+
*
|
|
78
|
+
* Issue #1043: this replaces three separate one-level `readdirSync` loops.
|
|
79
|
+
* Capability members live at `jobs/<category>/<name>.md`, so a one-level scan
|
|
80
|
+
* could never see them — which is why a published job neither synced back down
|
|
81
|
+
* nor moved the pack version.
|
|
61
82
|
*/
|
|
62
|
-
function
|
|
63
|
-
|
|
83
|
+
function walkPackFiles(localPath, visit) {
|
|
84
|
+
const descend = (absDir, relDir) => {
|
|
85
|
+
let entries;
|
|
86
|
+
try {
|
|
87
|
+
entries = fs_1.default.readdirSync(absDir, { withFileTypes: true });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return; // non-fatal — missing or unreadable subdirs are fine
|
|
91
|
+
}
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
const abs = path_1.default.join(absDir, entry.name);
|
|
94
|
+
const rel = `${relDir}/${entry.name}`;
|
|
95
|
+
if (entry.isDirectory())
|
|
96
|
+
descend(abs, rel);
|
|
97
|
+
else if (entry.isFile())
|
|
98
|
+
visit(rel, abs);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
64
101
|
for (const dirName of exports.LOCAL_FOLDER_PACK_DIRS) {
|
|
65
102
|
const dir = path_1.default.join(localPath, dirName);
|
|
66
103
|
if (!fs_1.default.existsSync(dir))
|
|
67
104
|
continue;
|
|
68
|
-
|
|
69
|
-
for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
|
|
70
|
-
if (entry.isFile() && isConflictCopy(entry.name))
|
|
71
|
-
count++;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
catch { /* non-fatal */ }
|
|
105
|
+
descend(dir, dirName);
|
|
75
106
|
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Count conflict-copy files across the pack directories.
|
|
110
|
+
* Used by the Hub UI to surface an amber "N conflicts" indicator.
|
|
111
|
+
*/
|
|
112
|
+
function countConflictCopies(localPath) {
|
|
113
|
+
let count = 0;
|
|
114
|
+
walkPackFiles(localPath, (rel) => {
|
|
115
|
+
if (isConflictCopy(path_1.default.basename(rel)))
|
|
116
|
+
count++;
|
|
117
|
+
});
|
|
76
118
|
return count;
|
|
77
119
|
}
|
|
78
120
|
/**
|
|
@@ -84,25 +126,16 @@ function countConflictCopies(localPath) {
|
|
|
84
126
|
*/
|
|
85
127
|
function collectLocalFolderFiles(localPath, pathGuard) {
|
|
86
128
|
const files = [];
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
129
|
+
walkPackFiles(localPath, (rel, abs) => {
|
|
130
|
+
if (isConflictCopy(path_1.default.basename(rel)))
|
|
131
|
+
return;
|
|
132
|
+
if (!pathGuard(rel))
|
|
133
|
+
return;
|
|
91
134
|
try {
|
|
92
|
-
|
|
93
|
-
if (!entry.isFile())
|
|
94
|
-
continue;
|
|
95
|
-
if (isConflictCopy(entry.name))
|
|
96
|
-
continue;
|
|
97
|
-
const rel = `${dirName}/${entry.name}`;
|
|
98
|
-
if (!pathGuard(rel))
|
|
99
|
-
continue;
|
|
100
|
-
const content = fs_1.default.readFileSync(path_1.default.join(dir, entry.name), 'utf8');
|
|
101
|
-
files.push({ relativePath: rel, content });
|
|
102
|
-
}
|
|
135
|
+
files.push({ relativePath: rel, content: fs_1.default.readFileSync(abs, 'utf8') });
|
|
103
136
|
}
|
|
104
|
-
catch { /* non-fatal —
|
|
105
|
-
}
|
|
137
|
+
catch { /* non-fatal — skip files we cannot read */ }
|
|
138
|
+
});
|
|
106
139
|
return files;
|
|
107
140
|
}
|
|
108
141
|
/**
|
|
@@ -147,32 +180,51 @@ function writeLocalFolderFile(localPath, relativePath, content, lastSyncedAt) {
|
|
|
147
180
|
throw err;
|
|
148
181
|
}
|
|
149
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Remove the source side of a move from a local-folder pack.
|
|
185
|
+
*
|
|
186
|
+
* Call this only after every destination write has landed, so a partial failure
|
|
187
|
+
* never leaves the artifact at neither level. A path that is also a write target
|
|
188
|
+
* is skipped: a same-path move is a no-op, not a delete of what was just written.
|
|
189
|
+
*
|
|
190
|
+
* Returns the absolute paths actually removed.
|
|
191
|
+
*/
|
|
192
|
+
function removeLocalFolderFiles(localPath, deletions, writtenRelativePaths) {
|
|
193
|
+
const written = new Set(writtenRelativePaths);
|
|
194
|
+
const removed = [];
|
|
195
|
+
for (const relativePath of deletions) {
|
|
196
|
+
if (written.has(relativePath))
|
|
197
|
+
continue;
|
|
198
|
+
const target = path_1.default.join(localPath, relativePath);
|
|
199
|
+
if (!fs_1.default.existsSync(target))
|
|
200
|
+
continue;
|
|
201
|
+
fs_1.default.rmSync(target, { force: true });
|
|
202
|
+
removed.push(target);
|
|
203
|
+
}
|
|
204
|
+
return removed;
|
|
205
|
+
}
|
|
150
206
|
/**
|
|
151
207
|
* A stable version string for the local-folder backend — a hex digest
|
|
152
208
|
* of the most recent mtime across all pack files. Changes whenever any
|
|
153
209
|
* file in the folder is written (by this machine or the sync client).
|
|
154
210
|
*/
|
|
155
211
|
function localFolderVersion(localPath) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
212
|
+
// Issue #1043: the digest mixes in the pack-relative path, not just mtimes.
|
|
213
|
+
// Two files written in the same filesystem-clock tick produce identical
|
|
214
|
+
// mtimes, so an mtime-only digest could leave the version unchanged after a
|
|
215
|
+
// publish and make sync skip the update.
|
|
216
|
+
const entries = [];
|
|
217
|
+
walkPackFiles(localPath, (rel, abs) => {
|
|
218
|
+
if (isConflictCopy(path_1.default.basename(rel)))
|
|
219
|
+
return;
|
|
161
220
|
try {
|
|
162
|
-
|
|
163
|
-
if (!entry.isFile() || isConflictCopy(entry.name))
|
|
164
|
-
continue;
|
|
165
|
-
try {
|
|
166
|
-
mtimes.push(fs_1.default.statSync(path_1.default.join(dir, entry.name)).mtimeMs);
|
|
167
|
-
}
|
|
168
|
-
catch { /* skip files we can't stat */ }
|
|
169
|
-
}
|
|
221
|
+
entries.push(`${rel}:${fs_1.default.statSync(abs).mtimeMs}`);
|
|
170
222
|
}
|
|
171
|
-
catch { /*
|
|
172
|
-
}
|
|
173
|
-
if (
|
|
223
|
+
catch { /* skip files we can't stat */ }
|
|
224
|
+
});
|
|
225
|
+
if (entries.length === 0)
|
|
174
226
|
return '0';
|
|
175
227
|
const hash = crypto_1.default.createHash('sha1');
|
|
176
|
-
hash.update(
|
|
228
|
+
hash.update(entries.sort().join(','));
|
|
177
229
|
return hash.digest('hex').slice(0, 12);
|
|
178
230
|
}
|