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.
@@ -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 false;
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.unref();
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 openedDesktop = wantDesktop && openDesktopWindow(projectPath, preferredPort, runtimeId);
247
- if (!openedDesktop) {
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
- console.log('AI Hub desktop shell launched.');
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 project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
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 #1002: the manager level is the default home for a custom employee, so
27
- // an employee the manager created is available in every project on the machine.
28
- // Two roots, matching capability precedence: the writable home wins over the
29
- // synced cache, so a just-created employee appears before any publish or sync.
30
- function managerEmployeesDirs() {
31
- return {
32
- local: path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'personalized-employee', 'employees'),
33
- cache: path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'manager', 'employees'),
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
- for (const emp of readEmployeesFromDir(dirs.cache, 'manager'))
91
- byKey.set(emp.key, emp);
92
- for (const emp of readEmployeesFromDir(dirs.local, 'manager'))
93
- byKey.set(emp.key, emp);
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
- const dir = scope === 'project' ? employeesDir(projectDir) : managerEmployeesDirs().local;
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
- const dirs = managerEmployeesDirs();
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(dirs.local, `${slug}.json`),
128
- path_1.default.join(dirs.cache, `${slug}.json`),
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\<guid> =
7
- * ABSOLUTE FILE PATH to manifest.xml. This is exactly what Microsoft's
8
- * `office-addin-dev-settings register` writes. A URL value is for
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(/(https?:\/\/(?:localhost|127\.0\.0\.1)):43091/g, `$1:${httpPort}`);
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 winRegisteredValue(guid) {
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
- const line = r.stdout.split(/\r?\n/).find(l => l.includes('REG_SZ'));
85
- if (!line)
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
- const idx = line.indexOf('REG_SZ');
88
- return line.slice(idx + 'REG_SZ'.length).trim() || null;
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-key value = absolute file path to manifest.xml (NOT a URL).
121
- const r = (0, child_process_1.spawnSync)('reg', [
122
- 'add', WEF_DEVELOPER_KEY,
123
- '/v', entry.guid, '/t', 'REG_SZ', '/d', sideloadPath, '/f',
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] || 'text/plain; charset=utf-8';
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] || 'text/plain';
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: 'project',
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
- /** Pack subdirectories expected in every local-folder backend. */
45
- exports.LOCAL_FOLDER_PACK_DIRS = ['context', 'rules', 'learnings'];
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
- * Count conflict-copy files directly under dirPath/learnings (non-recursive).
60
- * Used by the Hub UI to surface an amber "N conflicts" indicator.
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 countConflictCopies(localPath) {
63
- let count = 0;
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
- try {
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
- for (const dirName of exports.LOCAL_FOLDER_PACK_DIRS) {
88
- const dir = path_1.default.join(localPath, dirName);
89
- if (!fs_1.default.existsSync(dir))
90
- continue;
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
- for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
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 — missing subdirs are fine */ }
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
- const mtimes = [];
157
- for (const dirName of exports.LOCAL_FOLDER_PACK_DIRS) {
158
- const dir = path_1.default.join(localPath, dirName);
159
- if (!fs_1.default.existsSync(dir))
160
- continue;
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
- for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
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 { /* non-fatal */ }
172
- }
173
- if (mtimes.length === 0)
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(mtimes.sort((a, b) => a - b).join(','));
228
+ hash.update(entries.sort().join(','));
177
229
  return hash.digest('hex').slice(0, 12);
178
230
  }