@floomhq/floom-mcp-sync 1.0.6 → 1.0.7

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/auto-sync.js CHANGED
@@ -6,7 +6,7 @@ import { getJsonWithEtag, FloomApiError } from "./lib/api.js";
6
6
  import { sha256 } from "./lib/hash.js";
7
7
  import { assertValidSlug } from "./lib/slug.js";
8
8
  import { skillsDir, skillTargetPath } from "./lib/paths.js";
9
- import { ensureSyncManifestDir, manifestKey, markSynced, readSyncManifest, unmarkSynced, writeSyncManifest } from "./lib/manifest.js";
9
+ import { ensureSyncManifestDir, manifestKey, markSynced, readSyncManifest, unmarkSynced, withSyncLock, writeSyncManifest } from "./lib/manifest.js";
10
10
  // Module-level cache: ETag from the last successful (non-304) response, plus
11
11
  // the last time we logged a heartbeat. Survives across setInterval ticks
12
12
  // inside a single MCP server process.
@@ -16,7 +16,13 @@ let lastAuthWarningAt = 0;
16
16
  const HEARTBEAT_MS = 10 * 60 * 1000; // 10 minutes
17
17
  const AUTH_WARNING_MS = 10 * 60 * 1000;
18
18
  const MANIFEST_SEGMENT_RE = /^[A-Za-z0-9._-]{1,128}$/;
19
+ const PACKAGE_FILE_SEGMENT_RE = /^[A-Za-z0-9._-]{1,128}$/;
20
+ const SUPPORT_DIRS = new Set(["references", "examples", "scripts", "assets"]);
19
21
  const FD_PATH_ROOT = "/proc/self/fd";
22
+ const PACKAGE_FILE_LIMIT = 100;
23
+ const PACKAGE_TOTAL_BYTES_LIMIT = 1_000_000;
24
+ const PACKAGE_FILE_BYTES_LIMIT = 500_000;
25
+ const BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
20
26
  async function localState(path) {
21
27
  try {
22
28
  const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
@@ -25,7 +31,7 @@ async function localState(path) {
25
31
  if (!stat.isFile()) {
26
32
  return { kind: "conflict", reason: "path is blocked by an existing local file or directory" };
27
33
  }
28
- return { kind: "file", hash: sha256(await handle.readFile("utf8")) };
34
+ return { kind: "file", hash: sha256(await handle.readFile()) };
29
35
  }
30
36
  finally {
31
37
  await handle.close();
@@ -75,6 +81,142 @@ function validateSyncSkillShape(skill) {
75
81
  typeof candidate.library_slug !== "string") {
76
82
  throw new Error("Invalid sync response");
77
83
  }
84
+ if (candidate.files !== undefined && !Array.isArray(candidate.files)) {
85
+ throw new Error("Invalid sync response");
86
+ }
87
+ if (candidate.package_files !== undefined && !Array.isArray(candidate.package_files)) {
88
+ throw new Error("Invalid sync response");
89
+ }
90
+ }
91
+ function packageFilesForSkill(skill, skillFileTarget) {
92
+ const packageRoot = dirname(skillFileTarget);
93
+ if ((skill.package_files ?? skill.files ?? []).length > PACKAGE_FILE_LIMIT) {
94
+ throw new Error("Skill package response has too many files");
95
+ }
96
+ const out = [{
97
+ relativePath: "SKILL.md",
98
+ target: skillFileTarget,
99
+ content: Buffer.from(skill.body_md, "utf8"),
100
+ hash: sha256(skill.body_md),
101
+ }];
102
+ const seen = new Set(["SKILL.md"]);
103
+ let totalBytes = Buffer.byteLength(skill.body_md, "utf8");
104
+ for (const file of skill.package_files ?? skill.files ?? []) {
105
+ const relativePath = normalizePackageFilePath(file.path);
106
+ if (relativePath === "SKILL.md")
107
+ continue;
108
+ if (seen.has(relativePath))
109
+ throw new Error(`Duplicate package file: ${relativePath}`);
110
+ seen.add(relativePath);
111
+ const content = packageFileContent(file);
112
+ if (content.length > PACKAGE_FILE_BYTES_LIMIT)
113
+ throw new Error(`Package file is too large: ${file.path}`);
114
+ totalBytes += content.length;
115
+ if (totalBytes > PACKAGE_TOTAL_BYTES_LIMIT)
116
+ throw new Error("Skill package response is too large");
117
+ out.push({
118
+ relativePath,
119
+ target: join(packageRoot, ...relativePath.split("/")),
120
+ content,
121
+ hash: sha256(content),
122
+ });
123
+ }
124
+ return out;
125
+ }
126
+ function packageFileContent(file) {
127
+ if (typeof file.content_base64 === "string") {
128
+ if (file.encoding !== undefined && file.encoding !== "base64") {
129
+ throw new Error(`Invalid package file encoding: ${file.path}`);
130
+ }
131
+ if (file.content_base64.length % 4 !== 0 || !BASE64_RE.test(file.content_base64)) {
132
+ throw new Error(`Invalid package file base64: ${file.path}`);
133
+ }
134
+ const content = Buffer.from(file.content_base64, "base64");
135
+ if (typeof file.size_bytes === "number" && file.size_bytes !== content.length) {
136
+ throw new Error(`Package file size mismatch: ${file.path}`);
137
+ }
138
+ const expectedHash = file.sha256 ?? file.content_hash;
139
+ if (typeof file.sha256 !== "string") {
140
+ throw new Error(`Package file missing sha256: ${file.path}`);
141
+ }
142
+ if (typeof expectedHash === "string" && expectedHash !== sha256(content)) {
143
+ throw new Error(`Package file hash mismatch: ${file.path}`);
144
+ }
145
+ return content;
146
+ }
147
+ const content = file.content ?? file.body ?? file.body_md ?? file.text;
148
+ if (typeof content !== "string")
149
+ throw new Error(`Invalid package file: ${file.path}`);
150
+ const bytes = Buffer.from(content, "utf8");
151
+ if (typeof file.content_hash === "string" && file.content_hash !== sha256(bytes)) {
152
+ throw new Error(`Package file hash mismatch: ${file.path}`);
153
+ }
154
+ return bytes;
155
+ }
156
+ function normalizePackageFilePath(path) {
157
+ if (typeof path !== "string" || path.length === 0 || path.length > 512) {
158
+ throw new Error("Invalid package file path");
159
+ }
160
+ if (isAbsolute(path) || path.includes("\\"))
161
+ throw new Error("Invalid package file path");
162
+ const segments = path.split("/").filter(Boolean);
163
+ if (segments.length === 1 && segments[0] === "SKILL.md")
164
+ return "SKILL.md";
165
+ const first = segments[0];
166
+ if (segments.length < 2 || first === undefined || !SUPPORT_DIRS.has(first)) {
167
+ throw new Error("Invalid package file path");
168
+ }
169
+ if (segments.some((segment) => segment === "." || segment === ".." || !PACKAGE_FILE_SEGMENT_RE.test(segment))) {
170
+ throw new Error("Invalid package file path");
171
+ }
172
+ return segments.join("/");
173
+ }
174
+ async function planPackageSync(root, files, manifest) {
175
+ let missing = 0;
176
+ let unchanged = 0;
177
+ for (const file of files) {
178
+ const targetKey = manifestKey(root, file.target);
179
+ const tracked = manifest.files[targetKey];
180
+ try {
181
+ await assertSafeExistingParentDirectory(root, file.target);
182
+ }
183
+ catch (err) {
184
+ const code = err.code;
185
+ if (code === "ELOOP")
186
+ return { kind: "conflict", target: file.target, reason: "path contains a symbolic link" };
187
+ if (code === "ENOTDIR" || code === "EISDIR")
188
+ return { kind: "conflict", target: file.target, reason: "path is blocked by an existing local file or directory" };
189
+ if (code === "EEXIST" || code === "ENOENT")
190
+ return { kind: "conflict", target: file.target, reason: err instanceof Error ? err.message : "local file changed during Floom sync" };
191
+ throw err;
192
+ }
193
+ const state = await localState(file.target);
194
+ if (state.kind === "conflict")
195
+ return { kind: "conflict", target: file.target, reason: state.reason };
196
+ if (state.kind === "missing") {
197
+ if (tracked && files.length > 1)
198
+ return { kind: "conflict", target: file.target, reason: "local package file missing since the last Floom sync" };
199
+ missing += 1;
200
+ continue;
201
+ }
202
+ if (!tracked)
203
+ return { kind: "conflict", target: file.target, reason: "existing file is not tracked by Floom sync" };
204
+ if (state.hash !== tracked.hash)
205
+ return { kind: "conflict", target: file.target, reason: "local file changed since the last Floom sync" };
206
+ if (state.hash !== file.hash)
207
+ return { kind: "conflict", target: file.target, reason: "remote skill changed; move or delete the local file to accept the Floom version" };
208
+ unchanged += 1;
209
+ }
210
+ if (unchanged === files.length)
211
+ return { kind: "unchanged" };
212
+ if (missing === files.length)
213
+ return { kind: "write" };
214
+ const missingFile = files.find((file) => !manifest.files[manifestKey(root, file.target)]);
215
+ return {
216
+ kind: "conflict",
217
+ target: missingFile?.target ?? files[0]?.target ?? root,
218
+ reason: "local package is only partially installed",
219
+ };
78
220
  }
79
221
  async function manifestHasMissingTrackedFile(manifest, root) {
80
222
  for (const key of Object.keys(manifest.files)) {
@@ -99,219 +241,192 @@ export async function autoSync(log = (message) => process.stderr.write(`${messag
99
241
  return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
100
242
  }
101
243
  await ensureSyncManifestDir();
102
- const manifest = await readSyncManifest();
103
- const root = skillsDir();
104
- const apiUrl = apiUrlFromConfig(cfg);
105
- let response;
106
- try {
107
- response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, cfg.accessToken, cachedEtag);
108
- }
109
- catch (err) {
110
- if (err instanceof FloomApiError && err.status === 401) {
111
- maybeAuthWarning(log, "[floom] sign-in expired; skipping sync (run `npx -y @floomhq/floom login` again)");
112
- return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
244
+ return await withSyncLock(async () => {
245
+ const manifest = await readSyncManifest();
246
+ const root = skillsDir();
247
+ const apiUrl = apiUrlFromConfig(cfg);
248
+ let response;
249
+ try {
250
+ response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, cfg.accessToken, cachedEtag);
113
251
  }
114
- throw err;
115
- }
116
- if (response.status === 304) {
117
- if (await manifestHasMissingTrackedFile(manifest, root)) {
118
- response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, cfg.accessToken, null);
252
+ catch (err) {
253
+ if (err instanceof FloomApiError && err.status === 401) {
254
+ maybeAuthWarning(log, "[floom] sign-in expired; skipping sync (run `npx -y @floomhq/floom login` again)");
255
+ return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
256
+ }
257
+ throw err;
119
258
  }
120
- else {
259
+ if (response.status === 304) {
260
+ if (await manifestHasMissingTrackedFile(manifest, root)) {
261
+ response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, cfg.accessToken, null);
262
+ }
263
+ else {
264
+ maybeHeartbeat(log);
265
+ return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
266
+ }
267
+ }
268
+ if (response.status === 304) {
121
269
  maybeHeartbeat(log);
122
270
  return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
123
271
  }
124
- }
125
- if (response.status === 304) {
126
- maybeHeartbeat(log);
127
- return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
128
- }
129
- if (response.etag)
130
- cachedEtag = response.etag;
131
- const payload = response.body;
132
- if (!payload) {
133
- return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
134
- }
135
- await mkdir(skillsDir(), { recursive: true, mode: 0o700 });
136
- if (!Array.isArray(payload.skills)) {
137
- throw new Error("Invalid sync response");
138
- }
139
- for (const skill of payload.skills)
140
- validateSyncSkillShape(skill);
141
- // Version 1 preview syncs owned published skills only.
142
- const buckets = [
143
- { skills: payload.skills, defaultLib: null },
144
- ];
145
- let unchanged = 0;
146
- let updated = 0;
147
- let conflicts = 0;
148
- let total = 0;
149
- const writtenPaths = new Set();
150
- const activeTargetKeys = new Set();
151
- const pruneBlockedSlugs = new Set();
152
- const noteConflict = (target, reason) => {
153
- conflicts += 1;
154
- log(`[floom] skipped local conflict: ${manifestKey(root, target)} (${reason})`);
155
- };
156
- const noteKeyConflict = (key, reason) => {
157
- conflicts += 1;
158
- log(`[floom] skipped local conflict: ${key} (${reason})`);
159
- };
160
- const noteRemoteConflict = (slug, reason) => {
161
- conflicts += 1;
162
- const label = typeof slug === "string" && slug ? slug : "<invalid>";
163
- log(`[floom] skipped remote conflict: ${label} (${reason})`);
164
- };
165
- for (const bucket of buckets) {
166
- const skills = bucket.skills ?? [];
167
- for (const skill of skills) {
168
- let target;
169
- try {
170
- assertValidSlug(skill.slug);
171
- target = skillTargetPath({
172
- slug: skill.slug,
173
- folder: skill.folder ?? null,
174
- librarySlug: skill.library_slug ?? bucket.defaultLib,
175
- });
176
- }
177
- catch (err) {
178
- if (typeof skill.slug === "string")
179
- pruneBlockedSlugs.add(skill.slug);
180
- noteRemoteConflict(skill.slug, err instanceof Error ? err.message : "invalid remote metadata");
181
- continue;
182
- }
183
- if (writtenPaths.has(target))
184
- continue;
185
- writtenPaths.add(target);
186
- total += 1;
187
- const remoteHash = sha256(skill.body_md);
188
- const targetKey = manifestKey(root, target);
189
- activeTargetKeys.add(targetKey);
190
- const tracked = manifest.files[targetKey];
191
- try {
192
- await assertSafeExistingParentDirectory(root, target);
193
- }
194
- catch (err) {
195
- const code = err.code;
196
- if (code === "ELOOP") {
197
- noteConflict(target, "path contains a symbolic link");
272
+ if (response.etag)
273
+ cachedEtag = response.etag;
274
+ const payload = response.body;
275
+ if (!payload) {
276
+ return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
277
+ }
278
+ await mkdir(skillsDir(), { recursive: true, mode: 0o700 });
279
+ if (!Array.isArray(payload.skills)) {
280
+ throw new Error("Invalid sync response");
281
+ }
282
+ for (const skill of payload.skills)
283
+ validateSyncSkillShape(skill);
284
+ // Version 1 preview syncs owned published skills only.
285
+ const buckets = [
286
+ { skills: payload.skills, defaultLib: null },
287
+ ];
288
+ let unchanged = 0;
289
+ let updated = 0;
290
+ let conflicts = 0;
291
+ let total = 0;
292
+ const writtenPaths = new Set();
293
+ const activeTargetKeys = new Set();
294
+ const pruneBlockedSlugs = new Set();
295
+ const noteConflict = (target, reason) => {
296
+ conflicts += 1;
297
+ log(`[floom] skipped local conflict: ${manifestKey(root, target)} (${reason})`);
298
+ };
299
+ const noteKeyConflict = (key, reason) => {
300
+ conflicts += 1;
301
+ log(`[floom] skipped local conflict: ${key} (${reason})`);
302
+ };
303
+ const noteRemoteConflict = (slug, reason) => {
304
+ conflicts += 1;
305
+ const label = typeof slug === "string" && slug ? slug : "<invalid>";
306
+ log(`[floom] skipped remote conflict: ${label} (${reason})`);
307
+ };
308
+ for (const bucket of buckets) {
309
+ const skills = bucket.skills ?? [];
310
+ for (const skill of skills) {
311
+ let target;
312
+ let packageFiles;
313
+ try {
314
+ assertValidSlug(skill.slug);
315
+ target = skillTargetPath({
316
+ slug: skill.slug,
317
+ folder: skill.folder ?? null,
318
+ librarySlug: skill.library_slug ?? bucket.defaultLib,
319
+ });
320
+ packageFiles = packageFilesForSkill(skill, target);
321
+ }
322
+ catch (err) {
323
+ if (typeof skill.slug === "string")
324
+ pruneBlockedSlugs.add(skill.slug);
325
+ noteRemoteConflict(skill.slug, err instanceof Error ? err.message : "invalid remote metadata");
198
326
  continue;
199
327
  }
200
- if (code === "ENOTDIR" || code === "EISDIR") {
201
- noteConflict(target, "path is blocked by an existing local file or directory");
328
+ if (writtenPaths.has(target))
329
+ continue;
330
+ writtenPaths.add(target);
331
+ total += 1;
332
+ for (const file of packageFiles)
333
+ activeTargetKeys.add(manifestKey(root, file.target));
334
+ const plan = await planPackageSync(root, packageFiles, manifest);
335
+ if (plan.kind === "conflict") {
336
+ noteConflict(plan.target, plan.reason);
202
337
  continue;
203
338
  }
204
- if (code === "EEXIST" || code === "ENOENT") {
205
- noteConflict(target, err instanceof Error ? err.message : "local file changed during Floom sync");
339
+ if (plan.kind === "unchanged") {
340
+ unchanged += 1;
206
341
  continue;
207
342
  }
208
- throw err;
209
- }
210
- const state = await localState(target);
211
- if (state.kind === "conflict") {
212
- noteConflict(target, state.reason);
213
- continue;
214
- }
215
- if (state.kind === "file" && !tracked) {
216
- noteConflict(target, "existing file is not tracked by Floom sync");
217
- continue;
218
- }
219
- if (state.kind === "file" && state.hash !== tracked?.hash) {
220
- noteConflict(target, "local file changed since the last Floom sync");
221
- continue;
222
- }
223
- if (state.kind === "file" && state.hash === remoteHash) {
224
- unchanged += 1;
225
- continue;
226
- }
227
- if (state.kind === "file") {
228
- noteConflict(target, "remote skill changed; move or delete the local file to accept the Floom version");
229
- continue;
230
- }
231
- try {
232
- await writeSyncedFile(target, skill.body_md);
343
+ try {
344
+ for (const file of packageFiles)
345
+ await writeSyncedFile(file.target, file.content);
346
+ }
347
+ catch (err) {
348
+ const code = err.code;
349
+ if (code === "ELOOP") {
350
+ noteConflict(target, "path contains a symbolic link");
351
+ continue;
352
+ }
353
+ if (code === "ENOTDIR" || code === "EISDIR" || code === "EEXIST") {
354
+ noteConflict(target, "path is blocked by an existing local file or directory");
355
+ continue;
356
+ }
357
+ throw err;
358
+ }
359
+ for (const file of packageFiles)
360
+ markSynced(manifest, manifestKey(root, file.target), skill.slug, file.hash);
361
+ await writeSyncManifest(manifest);
362
+ updated += 1;
233
363
  }
234
- catch (err) {
235
- const code = err.code;
236
- if (code === "ELOOP") {
237
- noteConflict(target, "path contains a symbolic link");
364
+ }
365
+ if (payload.full_sync === true) {
366
+ for (const [key, entry] of Object.entries(manifest.files)) {
367
+ if (activeTargetKeys.has(key))
368
+ continue;
369
+ if (pruneBlockedSlugs.has(entry.slug)) {
370
+ noteKeyConflict(key, "remote metadata is invalid for this skill");
238
371
  continue;
239
372
  }
240
- if (code === "ENOTDIR" || code === "EISDIR") {
241
- noteConflict(target, "path is blocked by an existing local file or directory");
373
+ let target;
374
+ try {
375
+ target = targetFromManifestKey(root, key);
376
+ await assertSafeExistingParentDirectory(root, target);
377
+ }
378
+ catch (err) {
379
+ const code = err.code;
380
+ if (code === "ELOOP") {
381
+ noteKeyConflict(key, "path contains a symbolic link");
382
+ continue;
383
+ }
384
+ if (code === "ENOTDIR" || code === "EISDIR") {
385
+ noteKeyConflict(key, "path is blocked by an existing local file or directory");
386
+ continue;
387
+ }
388
+ noteKeyConflict(key, "invalid manifest target path");
242
389
  continue;
243
390
  }
244
- throw err;
245
- }
246
- markSynced(manifest, targetKey, skill.slug, remoteHash);
247
- await writeSyncManifest(manifest);
248
- updated += 1;
249
- }
250
- }
251
- if (payload.full_sync === true) {
252
- for (const [key, entry] of Object.entries(manifest.files)) {
253
- if (activeTargetKeys.has(key))
254
- continue;
255
- if (pruneBlockedSlugs.has(entry.slug)) {
256
- noteKeyConflict(key, "remote metadata is invalid for this skill");
257
- continue;
258
- }
259
- let target;
260
- try {
261
- target = targetFromManifestKey(root, key);
262
- await assertSafeExistingParentDirectory(root, target);
263
- }
264
- catch (err) {
265
- const code = err.code;
266
- if (code === "ELOOP") {
267
- noteKeyConflict(key, "path contains a symbolic link");
391
+ const state = await localState(target);
392
+ if (state.kind === "missing") {
393
+ unmarkSynced(manifest, key);
394
+ await writeSyncManifest(manifest);
268
395
  continue;
269
396
  }
270
- if (code === "ENOTDIR" || code === "EISDIR") {
271
- noteKeyConflict(key, "path is blocked by an existing local file or directory");
397
+ if (state.kind === "conflict") {
398
+ noteConflict(target, state.reason);
399
+ continue;
400
+ }
401
+ if (state.hash !== entry.hash) {
402
+ noteConflict(target, "local file changed since the last Floom sync");
272
403
  continue;
273
404
  }
274
- noteKeyConflict(key, "invalid manifest target path");
275
- continue;
276
- }
277
- const state = await localState(target);
278
- if (state.kind === "missing") {
279
405
  unmarkSynced(manifest, key);
280
406
  await writeSyncManifest(manifest);
281
- continue;
282
- }
283
- if (state.kind === "conflict") {
284
- noteConflict(target, state.reason);
285
- continue;
286
- }
287
- if (state.hash !== entry.hash) {
288
- noteConflict(target, "local file changed since the last Floom sync");
289
- continue;
290
407
  }
291
- unmarkSynced(manifest, key);
292
- await writeSyncManifest(manifest);
293
408
  }
294
- }
295
- // Only log when there's actual movement (skills updated) OR heartbeat is due.
296
- // Steady-state polling stays quiet so it doesn't pollute MCP stderr.
297
- if (updated > 0) {
298
- const conflictNote = conflicts > 0 ? `, ${conflicts} conflicts skipped` : "";
299
- log(`[floom] synced ${total} skills (${unchanged} unchanged, ${updated} updated${conflictNote})`);
300
- lastHeartbeatAt = Date.now();
409
+ // Only log when there's actual movement (skills updated) OR heartbeat is due.
410
+ // Steady-state polling stays quiet so it doesn't pollute MCP stderr.
301
411
  if (updated > 0) {
302
- // Activation telemetry counts syncs that write new content. Best-effort;
303
- // never blocks or throws.
304
- void emitSyncCompleted(apiUrl, cfg.accessToken, { total, updated, unchanged }).catch(() => { });
412
+ const conflictNote = conflicts > 0 ? `, ${conflicts} conflicts skipped` : "";
413
+ log(`[floom] synced ${total} skills (${unchanged} unchanged, ${updated} updated${conflictNote})`);
414
+ lastHeartbeatAt = Date.now();
415
+ if (updated > 0) {
416
+ // Activation telemetry counts syncs that write new content. Best-effort;
417
+ // never blocks or throws.
418
+ void emitSyncCompleted(apiUrl, cfg.accessToken, { total, updated, unchanged }).catch(() => { });
419
+ }
305
420
  }
306
- }
307
- else if (conflicts > 0) {
308
- log(`[floom] synced ${total} skills (${unchanged} unchanged, ${updated} updated, ${conflicts} conflicts skipped)`);
309
- lastHeartbeatAt = Date.now();
310
- }
311
- else {
312
- maybeHeartbeat(log, () => `[floom] heartbeat: ${total} skills tracked, all up-to-date`);
313
- }
314
- return { synced: total, unchanged, updated, conflicts };
421
+ else if (conflicts > 0) {
422
+ log(`[floom] synced ${total} skills (${unchanged} unchanged, ${updated} updated, ${conflicts} conflicts skipped)`);
423
+ lastHeartbeatAt = Date.now();
424
+ }
425
+ else {
426
+ maybeHeartbeat(log, () => `[floom] heartbeat: ${total} skills tracked, all up-to-date`);
427
+ }
428
+ return { synced: total, unchanged, updated, conflicts };
429
+ });
315
430
  }
316
431
  async function emitSyncCompleted(apiUrl, token, props) {
317
432
  try {
@@ -360,8 +475,7 @@ function childCreatePath(parent, fallbackParent, name) {
360
475
  return `${FD_PATH_ROOT}/${parent.fd}/${name}`;
361
476
  return join(resolve(fallbackParent), name);
362
477
  }
363
- async function writeAll(handle, body) {
364
- const buffer = Buffer.from(body, "utf8");
478
+ async function writeAll(handle, buffer) {
365
479
  let offset = 0;
366
480
  while (offset < buffer.length) {
367
481
  const result = await handle.write(buffer, offset, buffer.length - offset, offset);