@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 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 { getJsonWithEtag, FloomApiError } from "./lib/api.js";
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: ETag from the last successful (non-304) response, plus
11
- // the last time we logged a heartbeat. Survives across setInterval ticks
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 cfg = await readConfig();
353
- if (!cfg) {
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 response;
361
+ let payload;
363
362
  try {
364
- response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, cfg.accessToken, cachedEtag, signal);
363
+ payload = await loadSyncPayload(apiUrl, cfg.accessToken, signal);
365
364
  }
366
365
  catch (err) {
367
- if (err instanceof FloomApiError && err.status === 401) {
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
- throw err;
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 = {
@@ -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
- return {
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.27";
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"]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floomhq/floom-mcp-sync",
3
- "version": "1.0.27",
3
+ "version": "1.0.29",
4
4
  "description": "Lightweight Floom MCP server for installing, publishing, and startup-syncing skills.",
5
5
  "license": "MIT",
6
6
  "type": "module",