@floomhq/floom-mcp-sync 1.0.6 → 1.0.8
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/README.md +56 -25
- package/dist/auto-sync.js +304 -191
- package/dist/lib/manifest.js +62 -8
- package/dist/lib/paths.js +19 -22
- package/dist/server.js +42 -142
- package/dist/tools/get.js +111 -0
- package/dist/tools/status.js +89 -0
- package/package.json +1 -1
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(
|
|
34
|
+
return { kind: "file", hash: sha256(await handle.readFile()) };
|
|
29
35
|
}
|
|
30
36
|
finally {
|
|
31
37
|
await handle.close();
|
|
@@ -75,6 +81,141 @@ 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
|
+
let firstMissingTarget = null;
|
|
178
|
+
for (const file of files) {
|
|
179
|
+
const targetKey = manifestKey(root, file.target);
|
|
180
|
+
const tracked = manifest.files[targetKey];
|
|
181
|
+
try {
|
|
182
|
+
await assertSafeExistingParentDirectory(root, file.target);
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
const code = err.code;
|
|
186
|
+
if (code === "ELOOP")
|
|
187
|
+
return { kind: "conflict", target: file.target, reason: "path contains a symbolic link" };
|
|
188
|
+
if (code === "ENOTDIR" || code === "EISDIR")
|
|
189
|
+
return { kind: "conflict", target: file.target, reason: "path is blocked by an existing local file or directory" };
|
|
190
|
+
if (code === "EEXIST" || code === "ENOENT")
|
|
191
|
+
return { kind: "conflict", target: file.target, reason: err instanceof Error ? err.message : "local file changed during Floom sync" };
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
const state = await localState(file.target);
|
|
195
|
+
if (state.kind === "conflict")
|
|
196
|
+
return { kind: "conflict", target: file.target, reason: state.reason };
|
|
197
|
+
if (state.kind === "missing") {
|
|
198
|
+
firstMissingTarget ??= file.target;
|
|
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
|
+
return {
|
|
215
|
+
kind: "conflict",
|
|
216
|
+
target: firstMissingTarget ?? files[0]?.target ?? root,
|
|
217
|
+
reason: "local package is only partially installed",
|
|
218
|
+
};
|
|
78
219
|
}
|
|
79
220
|
async function manifestHasMissingTrackedFile(manifest, root) {
|
|
80
221
|
for (const key of Object.keys(manifest.files)) {
|
|
@@ -99,219 +240,192 @@ export async function autoSync(log = (message) => process.stderr.write(`${messag
|
|
|
99
240
|
return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
|
|
100
241
|
}
|
|
101
242
|
await ensureSyncManifestDir();
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 };
|
|
243
|
+
return await withSyncLock(async () => {
|
|
244
|
+
const manifest = await readSyncManifest();
|
|
245
|
+
const root = skillsDir();
|
|
246
|
+
const apiUrl = apiUrlFromConfig(cfg);
|
|
247
|
+
let response;
|
|
248
|
+
try {
|
|
249
|
+
response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, cfg.accessToken, cachedEtag);
|
|
113
250
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
251
|
+
catch (err) {
|
|
252
|
+
if (err instanceof FloomApiError && err.status === 401) {
|
|
253
|
+
maybeAuthWarning(log, "[floom] sign-in expired; skipping sync (run `npx -y @floomhq/floom login` again)");
|
|
254
|
+
return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
|
|
255
|
+
}
|
|
256
|
+
throw err;
|
|
119
257
|
}
|
|
120
|
-
|
|
258
|
+
if (response.status === 304) {
|
|
259
|
+
if (await manifestHasMissingTrackedFile(manifest, root)) {
|
|
260
|
+
response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, cfg.accessToken, null);
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
maybeHeartbeat(log);
|
|
264
|
+
return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (response.status === 304) {
|
|
121
268
|
maybeHeartbeat(log);
|
|
122
269
|
return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
|
|
123
270
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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");
|
|
271
|
+
if (response.etag)
|
|
272
|
+
cachedEtag = response.etag;
|
|
273
|
+
const payload = response.body;
|
|
274
|
+
if (!payload) {
|
|
275
|
+
return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
|
|
276
|
+
}
|
|
277
|
+
await mkdir(skillsDir(), { recursive: true, mode: 0o700 });
|
|
278
|
+
if (!Array.isArray(payload.skills)) {
|
|
279
|
+
throw new Error("Invalid sync response");
|
|
280
|
+
}
|
|
281
|
+
for (const skill of payload.skills)
|
|
282
|
+
validateSyncSkillShape(skill);
|
|
283
|
+
// Version 1 preview syncs owned published skills only.
|
|
284
|
+
const buckets = [
|
|
285
|
+
{ skills: payload.skills, defaultLib: null },
|
|
286
|
+
];
|
|
287
|
+
let unchanged = 0;
|
|
288
|
+
let updated = 0;
|
|
289
|
+
let conflicts = 0;
|
|
290
|
+
let total = 0;
|
|
291
|
+
const writtenPaths = new Set();
|
|
292
|
+
const activeTargetKeys = new Set();
|
|
293
|
+
const pruneBlockedSlugs = new Set();
|
|
294
|
+
const noteConflict = (target, reason) => {
|
|
295
|
+
conflicts += 1;
|
|
296
|
+
log(`[floom] skipped local conflict: ${manifestKey(root, target)} (${reason})`);
|
|
297
|
+
};
|
|
298
|
+
const noteKeyConflict = (key, reason) => {
|
|
299
|
+
conflicts += 1;
|
|
300
|
+
log(`[floom] skipped local conflict: ${key} (${reason})`);
|
|
301
|
+
};
|
|
302
|
+
const noteRemoteConflict = (slug, reason) => {
|
|
303
|
+
conflicts += 1;
|
|
304
|
+
const label = typeof slug === "string" && slug ? slug : "<invalid>";
|
|
305
|
+
log(`[floom] skipped remote conflict: ${label} (${reason})`);
|
|
306
|
+
};
|
|
307
|
+
for (const bucket of buckets) {
|
|
308
|
+
const skills = bucket.skills ?? [];
|
|
309
|
+
for (const skill of skills) {
|
|
310
|
+
let target;
|
|
311
|
+
let packageFiles;
|
|
312
|
+
try {
|
|
313
|
+
assertValidSlug(skill.slug);
|
|
314
|
+
target = skillTargetPath({
|
|
315
|
+
slug: skill.slug,
|
|
316
|
+
folder: skill.folder ?? null,
|
|
317
|
+
librarySlug: skill.library_slug ?? bucket.defaultLib,
|
|
318
|
+
});
|
|
319
|
+
packageFiles = packageFilesForSkill(skill, target);
|
|
320
|
+
}
|
|
321
|
+
catch (err) {
|
|
322
|
+
if (typeof skill.slug === "string")
|
|
323
|
+
pruneBlockedSlugs.add(skill.slug);
|
|
324
|
+
noteRemoteConflict(skill.slug, err instanceof Error ? err.message : "invalid remote metadata");
|
|
198
325
|
continue;
|
|
199
326
|
}
|
|
200
|
-
if (
|
|
201
|
-
|
|
327
|
+
if (writtenPaths.has(target))
|
|
328
|
+
continue;
|
|
329
|
+
writtenPaths.add(target);
|
|
330
|
+
total += 1;
|
|
331
|
+
for (const file of packageFiles)
|
|
332
|
+
activeTargetKeys.add(manifestKey(root, file.target));
|
|
333
|
+
const plan = await planPackageSync(root, packageFiles, manifest);
|
|
334
|
+
if (plan.kind === "conflict") {
|
|
335
|
+
noteConflict(plan.target, plan.reason);
|
|
202
336
|
continue;
|
|
203
337
|
}
|
|
204
|
-
if (
|
|
205
|
-
|
|
338
|
+
if (plan.kind === "unchanged") {
|
|
339
|
+
unchanged += 1;
|
|
206
340
|
continue;
|
|
207
341
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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);
|
|
342
|
+
try {
|
|
343
|
+
for (const file of packageFiles)
|
|
344
|
+
await writeSyncedFile(file.target, file.content);
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
const code = err.code;
|
|
348
|
+
if (code === "ELOOP") {
|
|
349
|
+
noteConflict(target, "path contains a symbolic link");
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (code === "ENOTDIR" || code === "EISDIR" || code === "EEXIST") {
|
|
353
|
+
noteConflict(target, "path is blocked by an existing local file or directory");
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
throw err;
|
|
357
|
+
}
|
|
358
|
+
for (const file of packageFiles)
|
|
359
|
+
markSynced(manifest, manifestKey(root, file.target), skill.slug, file.hash);
|
|
360
|
+
await writeSyncManifest(manifest);
|
|
361
|
+
updated += 1;
|
|
233
362
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
363
|
+
}
|
|
364
|
+
if (payload.full_sync === true) {
|
|
365
|
+
for (const [key, entry] of Object.entries(manifest.files)) {
|
|
366
|
+
if (activeTargetKeys.has(key))
|
|
367
|
+
continue;
|
|
368
|
+
if (pruneBlockedSlugs.has(entry.slug)) {
|
|
369
|
+
noteKeyConflict(key, "remote metadata is invalid for this skill");
|
|
238
370
|
continue;
|
|
239
371
|
}
|
|
240
|
-
|
|
241
|
-
|
|
372
|
+
let target;
|
|
373
|
+
try {
|
|
374
|
+
target = targetFromManifestKey(root, key);
|
|
375
|
+
await assertSafeExistingParentDirectory(root, target);
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
const code = err.code;
|
|
379
|
+
if (code === "ELOOP") {
|
|
380
|
+
noteKeyConflict(key, "path contains a symbolic link");
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (code === "ENOTDIR" || code === "EISDIR") {
|
|
384
|
+
noteKeyConflict(key, "path is blocked by an existing local file or directory");
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
noteKeyConflict(key, "invalid manifest target path");
|
|
242
388
|
continue;
|
|
243
389
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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");
|
|
390
|
+
const state = await localState(target);
|
|
391
|
+
if (state.kind === "missing") {
|
|
392
|
+
unmarkSynced(manifest, key);
|
|
393
|
+
await writeSyncManifest(manifest);
|
|
268
394
|
continue;
|
|
269
395
|
}
|
|
270
|
-
if (
|
|
271
|
-
|
|
396
|
+
if (state.kind === "conflict") {
|
|
397
|
+
noteConflict(target, state.reason);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (state.hash !== entry.hash) {
|
|
401
|
+
noteConflict(target, "local file changed since the last Floom sync");
|
|
272
402
|
continue;
|
|
273
403
|
}
|
|
274
|
-
noteKeyConflict(key, "invalid manifest target path");
|
|
275
|
-
continue;
|
|
276
|
-
}
|
|
277
|
-
const state = await localState(target);
|
|
278
|
-
if (state.kind === "missing") {
|
|
279
404
|
unmarkSynced(manifest, key);
|
|
280
405
|
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
406
|
}
|
|
291
|
-
unmarkSynced(manifest, key);
|
|
292
|
-
await writeSyncManifest(manifest);
|
|
293
407
|
}
|
|
294
|
-
|
|
295
|
-
|
|
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();
|
|
408
|
+
// Only log when there's actual movement (skills updated) OR heartbeat is due.
|
|
409
|
+
// Steady-state polling stays quiet so it doesn't pollute MCP stderr.
|
|
301
410
|
if (updated > 0) {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
411
|
+
const conflictNote = conflicts > 0 ? `, ${conflicts} conflicts skipped` : "";
|
|
412
|
+
log(`[floom] synced ${total} skills (${unchanged} unchanged, ${updated} updated${conflictNote})`);
|
|
413
|
+
lastHeartbeatAt = Date.now();
|
|
414
|
+
if (updated > 0) {
|
|
415
|
+
// Activation telemetry counts syncs that write new content. Best-effort;
|
|
416
|
+
// never blocks or throws.
|
|
417
|
+
void emitSyncCompleted(apiUrl, cfg.accessToken, { total, updated, unchanged }).catch(() => { });
|
|
418
|
+
}
|
|
305
419
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
420
|
+
else if (conflicts > 0) {
|
|
421
|
+
log(`[floom] synced ${total} skills (${unchanged} unchanged, ${updated} updated, ${conflicts} conflicts skipped)`);
|
|
422
|
+
lastHeartbeatAt = Date.now();
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
maybeHeartbeat(log, () => `[floom] heartbeat: ${total} skills tracked, all up-to-date`);
|
|
426
|
+
}
|
|
427
|
+
return { synced: total, unchanged, updated, conflicts };
|
|
428
|
+
});
|
|
315
429
|
}
|
|
316
430
|
async function emitSyncCompleted(apiUrl, token, props) {
|
|
317
431
|
try {
|
|
@@ -360,8 +474,7 @@ function childCreatePath(parent, fallbackParent, name) {
|
|
|
360
474
|
return `${FD_PATH_ROOT}/${parent.fd}/${name}`;
|
|
361
475
|
return join(resolve(fallbackParent), name);
|
|
362
476
|
}
|
|
363
|
-
async function writeAll(handle,
|
|
364
|
-
const buffer = Buffer.from(body, "utf8");
|
|
477
|
+
async function writeAll(handle, buffer) {
|
|
365
478
|
let offset = 0;
|
|
366
479
|
while (offset < buffer.length) {
|
|
367
480
|
const result = await handle.write(buffer, offset, buffer.length - offset, offset);
|