@floomhq/floom-mcp-sync 1.0.27 → 1.0.29
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 +39 -31
- package/dist/lib/config.js +47 -2
- package/dist/server.js +1 -1
- package/package.json +1 -1
package/dist/auto-sync.js
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
2
|
import { lstat, mkdir, open } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
-
import { apiUrlFromConfig, readConfig } from "./lib/config.js";
|
|
5
|
-
import {
|
|
4
|
+
import { apiUrlFromConfig, readConfig, refreshConfigIfNeeded } from "./lib/config.js";
|
|
5
|
+
import { getJson, 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
9
|
import { ensureSyncManifestDir, manifestKey, markSynced, readSyncManifest, unmarkSynced, withSyncLock, writeSyncManifest } from "./lib/manifest.js";
|
|
10
|
-
// Module-level cache:
|
|
11
|
-
//
|
|
12
|
-
// inside a single MCP server process.
|
|
13
|
-
let cachedEtag = null;
|
|
10
|
+
// Module-level cache: the last time we logged a heartbeat. Survives across
|
|
11
|
+
// setInterval ticks inside a single MCP server process.
|
|
14
12
|
let lastHeartbeatAt = 0;
|
|
15
13
|
let lastAuthWarningAt = 0;
|
|
16
14
|
const HEARTBEAT_MS = 10 * 60 * 1000; // 10 minutes
|
|
@@ -349,45 +347,31 @@ async function manifestHasMissingTrackedFile(manifest, root) {
|
|
|
349
347
|
return false;
|
|
350
348
|
}
|
|
351
349
|
export async function autoSync(log = (message) => process.stderr.write(`${message}\n`), signal) {
|
|
352
|
-
const
|
|
353
|
-
if (!
|
|
350
|
+
const initialCfg = await readConfig();
|
|
351
|
+
if (!initialCfg) {
|
|
354
352
|
maybeAuthWarning(log, "[floom] not signed in; skipping sync (run `npx -y @floomhq/floom login`)");
|
|
355
353
|
return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
|
|
356
354
|
}
|
|
355
|
+
let cfg = initialCfg;
|
|
357
356
|
await ensureSyncManifestDir();
|
|
358
357
|
return await withSyncLock(async () => {
|
|
359
358
|
const manifest = await readSyncManifest();
|
|
360
359
|
const root = skillsDir();
|
|
361
360
|
const apiUrl = apiUrlFromConfig(cfg);
|
|
362
|
-
let
|
|
361
|
+
let payload;
|
|
363
362
|
try {
|
|
364
|
-
|
|
363
|
+
payload = await loadSyncPayload(apiUrl, cfg.accessToken, signal);
|
|
365
364
|
}
|
|
366
365
|
catch (err) {
|
|
367
|
-
if (err instanceof FloomApiError
|
|
366
|
+
if (!(err instanceof FloomApiError) || err.status !== 401)
|
|
367
|
+
throw err;
|
|
368
|
+
const refreshed = await refreshConfigIfNeeded(cfg, { force: true });
|
|
369
|
+
if (refreshed.accessToken === cfg.accessToken) {
|
|
368
370
|
maybeAuthWarning(log, "[floom] sign-in expired; skipping sync (run `npx -y @floomhq/floom login` again)");
|
|
369
371
|
return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
|
|
370
372
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
if (response.status === 304) {
|
|
374
|
-
if (await manifestHasMissingTrackedFile(manifest, root)) {
|
|
375
|
-
response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, cfg.accessToken, null, signal);
|
|
376
|
-
}
|
|
377
|
-
else {
|
|
378
|
-
maybeHeartbeat(log);
|
|
379
|
-
return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
if (response.status === 304) {
|
|
383
|
-
maybeHeartbeat(log);
|
|
384
|
-
return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
|
|
385
|
-
}
|
|
386
|
-
if (response.etag)
|
|
387
|
-
cachedEtag = response.etag;
|
|
388
|
-
const payload = response.body;
|
|
389
|
-
if (!payload) {
|
|
390
|
-
return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
|
|
373
|
+
cfg = refreshed;
|
|
374
|
+
payload = await loadSyncPayload(apiUrl, cfg.accessToken, signal);
|
|
391
375
|
}
|
|
392
376
|
await mkdir(skillsDir(), { recursive: true, mode: 0o700 });
|
|
393
377
|
if (!Array.isArray(payload.skills)) {
|
|
@@ -545,6 +529,30 @@ export async function autoSync(log = (message) => process.stderr.write(`${messag
|
|
|
545
529
|
return { synced: total, unchanged, updated, conflicts };
|
|
546
530
|
});
|
|
547
531
|
}
|
|
532
|
+
async function loadSyncPayload(apiUrl, token, signal) {
|
|
533
|
+
const all = [];
|
|
534
|
+
let fullSync = false;
|
|
535
|
+
let cursor;
|
|
536
|
+
const seenCursors = new Set();
|
|
537
|
+
for (let page = 0; page < 1000; page += 1) {
|
|
538
|
+
const url = new URL(`${apiUrl}/api/v1/me/skills`);
|
|
539
|
+
url.searchParams.set("limit", "25");
|
|
540
|
+
if (cursor)
|
|
541
|
+
url.searchParams.set("cursor", cursor);
|
|
542
|
+
const payload = await getJson(url.toString(), token, signal);
|
|
543
|
+
if (!Array.isArray(payload.skills))
|
|
544
|
+
throw new Error("Invalid sync response");
|
|
545
|
+
all.push(...payload.skills);
|
|
546
|
+
fullSync = payload.full_sync === true;
|
|
547
|
+
if (!payload.next_cursor)
|
|
548
|
+
return { skills: all, full_sync: fullSync };
|
|
549
|
+
if (seenCursors.has(payload.next_cursor))
|
|
550
|
+
throw new Error("Invalid sync response");
|
|
551
|
+
seenCursors.add(payload.next_cursor);
|
|
552
|
+
cursor = payload.next_cursor;
|
|
553
|
+
}
|
|
554
|
+
throw new Error("Invalid sync response");
|
|
555
|
+
}
|
|
548
556
|
async function emitSyncCompleted(apiUrl, token, props, signal) {
|
|
549
557
|
try {
|
|
550
558
|
const init = {
|
package/dist/lib/config.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
1
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
2
3
|
import { configPath } from "./paths.js";
|
|
3
4
|
export const DEFAULT_API_URL = "https://floom.dev";
|
|
4
5
|
export function apiUrlFromConfig(cfg) {
|
|
@@ -10,13 +11,14 @@ export async function readConfig() {
|
|
|
10
11
|
const parsed = JSON.parse(raw);
|
|
11
12
|
if (typeof parsed.accessToken !== "string" || parsed.accessToken.length === 0)
|
|
12
13
|
return null;
|
|
13
|
-
|
|
14
|
+
const cfg = {
|
|
14
15
|
accessToken: parsed.accessToken,
|
|
15
16
|
...(typeof parsed.apiUrl === "string" ? { apiUrl: parsed.apiUrl } : {}),
|
|
16
17
|
...(typeof parsed.refreshToken === "string" ? { refreshToken: parsed.refreshToken } : {}),
|
|
17
18
|
...(typeof parsed.expiresAt === "number" ? { expiresAt: parsed.expiresAt } : {}),
|
|
18
19
|
...(typeof parsed.email === "string" || parsed.email === null ? { email: parsed.email } : {}),
|
|
19
20
|
};
|
|
21
|
+
return await refreshConfigIfNeeded(cfg);
|
|
20
22
|
}
|
|
21
23
|
catch (err) {
|
|
22
24
|
if (err.code === "ENOENT")
|
|
@@ -24,3 +26,46 @@ export async function readConfig() {
|
|
|
24
26
|
throw err;
|
|
25
27
|
}
|
|
26
28
|
}
|
|
29
|
+
export async function refreshConfigIfNeeded(cfg, opts = {}) {
|
|
30
|
+
if (!cfg.refreshToken)
|
|
31
|
+
return cfg;
|
|
32
|
+
const now = Math.floor(Date.now() / 1000);
|
|
33
|
+
if (!opts.force && (typeof cfg.expiresAt !== "number" || cfg.expiresAt > now + 120))
|
|
34
|
+
return cfg;
|
|
35
|
+
try {
|
|
36
|
+
const apiUrl = apiUrlFromConfig(cfg);
|
|
37
|
+
const res = await fetch(`${apiUrl}/api/auth/refresh`, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: { "content-type": "application/json" },
|
|
40
|
+
body: JSON.stringify({ refresh_token: cfg.refreshToken }),
|
|
41
|
+
});
|
|
42
|
+
if (!res.ok)
|
|
43
|
+
throw new Error(`refresh failed with ${res.status}`);
|
|
44
|
+
const data = (await res.json());
|
|
45
|
+
if (!data.access_token || !data.refresh_token)
|
|
46
|
+
throw new Error("refresh response missing tokens");
|
|
47
|
+
const expiresIn = Number(data.expires_in ?? "3600");
|
|
48
|
+
const refreshed = {
|
|
49
|
+
...cfg,
|
|
50
|
+
accessToken: data.access_token,
|
|
51
|
+
refreshToken: data.refresh_token,
|
|
52
|
+
expiresAt: Math.floor(Date.now() / 1000) + (Number.isFinite(expiresIn) ? expiresIn : 3600),
|
|
53
|
+
...(typeof data.email === "string" || data.email === null
|
|
54
|
+
? { email: data.email }
|
|
55
|
+
: typeof cfg.email === "string" || cfg.email === null
|
|
56
|
+
? { email: cfg.email }
|
|
57
|
+
: {}),
|
|
58
|
+
};
|
|
59
|
+
await writeConfig(refreshed);
|
|
60
|
+
return refreshed;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return cfg;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function writeConfig(cfg) {
|
|
67
|
+
const target = configPath();
|
|
68
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
69
|
+
await writeFile(target, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 });
|
|
70
|
+
await chmod(target, 0o600);
|
|
71
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -5,7 +5,7 @@ import { autoSync } from "./auto-sync.js";
|
|
|
5
5
|
import { getSkill } from "./tools/get.js";
|
|
6
6
|
import { searchSkills } from "./tools/search.js";
|
|
7
7
|
import { syncStatus } from "./tools/status.js";
|
|
8
|
-
const SERVER_VERSION = "1.0.
|
|
8
|
+
const SERVER_VERSION = "1.0.29";
|
|
9
9
|
const DEFAULT_INTERVAL_MS = 60_000;
|
|
10
10
|
const MIN_INTERVAL_MS = 10_000;
|
|
11
11
|
const SEARCH_TYPES = new Set(["knowledge", "instruction", "workflow", "skill"]);
|