@floomhq/floom-mcp-sync 1.0.12 → 1.0.24
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 +16 -8
- package/dist/lib/api.js +53 -21
- package/dist/lib/config.js +58 -3
- package/dist/server.js +1 -1
- package/package.json +1 -1
package/dist/auto-sync.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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";
|
|
4
|
+
import { apiUrlFromConfig, readConfig, refreshConfig } from "./lib/config.js";
|
|
5
5
|
import { getJsonWithEtag, FloomApiError } from "./lib/api.js";
|
|
6
6
|
import { sha256 } from "./lib/hash.js";
|
|
7
7
|
import { assertValidSlug } from "./lib/slug.js";
|
|
@@ -254,21 +254,29 @@ export async function autoSync(log = (message) => process.stderr.write(`${messag
|
|
|
254
254
|
return await withSyncLock(async () => {
|
|
255
255
|
const manifest = await readSyncManifest();
|
|
256
256
|
const root = skillsDir();
|
|
257
|
-
|
|
257
|
+
let activeCfg = cfg;
|
|
258
|
+
const apiUrl = apiUrlFromConfig(activeCfg);
|
|
258
259
|
let response;
|
|
259
260
|
try {
|
|
260
|
-
response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`,
|
|
261
|
+
response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, activeCfg.accessToken, cachedEtag, signal);
|
|
261
262
|
}
|
|
262
263
|
catch (err) {
|
|
263
264
|
if (err instanceof FloomApiError && err.status === 401) {
|
|
264
|
-
|
|
265
|
-
|
|
265
|
+
const refreshed = await refreshConfig(activeCfg);
|
|
266
|
+
if (!refreshed) {
|
|
267
|
+
maybeAuthWarning(log, "[floom] sign-in expired; skipping sync (run `npx -y @floomhq/floom login` again)");
|
|
268
|
+
return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
|
|
269
|
+
}
|
|
270
|
+
activeCfg = refreshed;
|
|
271
|
+
response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, activeCfg.accessToken, cachedEtag, signal);
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
throw err;
|
|
266
275
|
}
|
|
267
|
-
throw err;
|
|
268
276
|
}
|
|
269
277
|
if (response.status === 304) {
|
|
270
278
|
if (await manifestHasMissingTrackedFile(manifest, root)) {
|
|
271
|
-
response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`,
|
|
279
|
+
response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, activeCfg.accessToken, null, signal);
|
|
272
280
|
}
|
|
273
281
|
else {
|
|
274
282
|
maybeHeartbeat(log);
|
|
@@ -428,7 +436,7 @@ export async function autoSync(log = (message) => process.stderr.write(`${messag
|
|
|
428
436
|
if (updated > 0) {
|
|
429
437
|
// Activation telemetry counts syncs that write new content. Best-effort;
|
|
430
438
|
// never blocks or throws.
|
|
431
|
-
void emitSyncCompleted(apiUrl,
|
|
439
|
+
void emitSyncCompleted(apiUrl, activeCfg.accessToken, { total, updated, unchanged }, signal).catch(() => { });
|
|
432
440
|
}
|
|
433
441
|
}
|
|
434
442
|
else if (conflicts > 0) {
|
package/dist/lib/api.js
CHANGED
|
@@ -6,6 +6,53 @@ export class FloomApiError extends Error {
|
|
|
6
6
|
this.name = "FloomApiError";
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
|
+
export const DEFAULT_TIMEOUT_MS = 15_000;
|
|
10
|
+
function withTimeout(signal) {
|
|
11
|
+
const controller = new AbortController();
|
|
12
|
+
const timeoutMs = requestTimeoutMs();
|
|
13
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
14
|
+
const abort = () => controller.abort();
|
|
15
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
16
|
+
if (signal?.aborted)
|
|
17
|
+
controller.abort();
|
|
18
|
+
const aborted = new Promise((_, reject) => {
|
|
19
|
+
controller.signal.addEventListener("abort", () => {
|
|
20
|
+
reject(new FloomApiError(408, "Request timed out while contacting Floom."));
|
|
21
|
+
}, { once: true });
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
signal: controller.signal,
|
|
25
|
+
aborted,
|
|
26
|
+
cleanup: () => {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
signal?.removeEventListener("abort", abort);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function requestTimeoutMs() {
|
|
33
|
+
const fromEnv = Number(process.env.FLOOM_REQUEST_TIMEOUT_MS);
|
|
34
|
+
if (Number.isFinite(fromEnv) && fromEnv > 0)
|
|
35
|
+
return fromEnv;
|
|
36
|
+
return DEFAULT_TIMEOUT_MS;
|
|
37
|
+
}
|
|
38
|
+
async function fetchWithTimeout(url, init, signal) {
|
|
39
|
+
const timeout = withTimeout(signal);
|
|
40
|
+
try {
|
|
41
|
+
return await Promise.race([
|
|
42
|
+
fetch(url, { ...init, signal: timeout.signal }),
|
|
43
|
+
timeout.aborted,
|
|
44
|
+
]);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
if (err.name === "AbortError") {
|
|
48
|
+
throw new FloomApiError(408, "Request timed out while contacting Floom.");
|
|
49
|
+
}
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
timeout.cleanup();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
9
56
|
async function readError(res) {
|
|
10
57
|
const text = await res.text();
|
|
11
58
|
if (!text)
|
|
@@ -24,10 +71,7 @@ export async function getJson(url, token, signal) {
|
|
|
24
71
|
const headers = {};
|
|
25
72
|
if (token)
|
|
26
73
|
headers.authorization = `Bearer ${token}`;
|
|
27
|
-
const
|
|
28
|
-
if (signal)
|
|
29
|
-
init.signal = signal;
|
|
30
|
-
const res = await fetch(url, init);
|
|
74
|
+
const res = await fetchWithTimeout(url, { headers }, signal);
|
|
31
75
|
if (!res.ok)
|
|
32
76
|
throw new FloomApiError(res.status, await readError(res));
|
|
33
77
|
return (await res.json());
|
|
@@ -40,10 +84,7 @@ export async function getJsonWithEtag(url, token, etag, signal) {
|
|
|
40
84
|
const headers = { authorization: `Bearer ${token}` };
|
|
41
85
|
if (etag)
|
|
42
86
|
headers["if-none-match"] = etag;
|
|
43
|
-
const
|
|
44
|
-
if (signal)
|
|
45
|
-
init.signal = signal;
|
|
46
|
-
const res = await fetch(url, init);
|
|
87
|
+
const res = await fetchWithTimeout(url, { headers }, signal);
|
|
47
88
|
const responseEtag = res.headers.get("etag");
|
|
48
89
|
if (res.status === 304) {
|
|
49
90
|
return { status: 304, body: null, etag: responseEtag ?? etag };
|
|
@@ -54,10 +95,7 @@ export async function getJsonWithEtag(url, token, etag, signal) {
|
|
|
54
95
|
return { status: res.status, body, etag: responseEtag };
|
|
55
96
|
}
|
|
56
97
|
export async function getText(url, signal) {
|
|
57
|
-
const
|
|
58
|
-
if (signal)
|
|
59
|
-
init.signal = signal;
|
|
60
|
-
const res = await fetch(url, init);
|
|
98
|
+
const res = await fetchWithTimeout(url, {}, signal);
|
|
61
99
|
if (!res.ok)
|
|
62
100
|
throw new FloomApiError(res.status, await readError(res));
|
|
63
101
|
return res.text();
|
|
@@ -71,9 +109,7 @@ export async function postJson(url, token, body, signal) {
|
|
|
71
109
|
},
|
|
72
110
|
body: JSON.stringify(body),
|
|
73
111
|
};
|
|
74
|
-
|
|
75
|
-
init.signal = signal;
|
|
76
|
-
const res = await fetch(url, init);
|
|
112
|
+
const res = await fetchWithTimeout(url, init, signal);
|
|
77
113
|
if (!res.ok)
|
|
78
114
|
throw new FloomApiError(res.status, await readError(res));
|
|
79
115
|
return (await res.json());
|
|
@@ -87,9 +123,7 @@ export async function putJson(url, token, body, signal) {
|
|
|
87
123
|
},
|
|
88
124
|
body: JSON.stringify(body),
|
|
89
125
|
};
|
|
90
|
-
|
|
91
|
-
init.signal = signal;
|
|
92
|
-
const res = await fetch(url, init);
|
|
126
|
+
const res = await fetchWithTimeout(url, init, signal);
|
|
93
127
|
if (!res.ok)
|
|
94
128
|
throw new FloomApiError(res.status, await readError(res));
|
|
95
129
|
return (await res.json());
|
|
@@ -99,9 +133,7 @@ export async function deleteRequest(url, token, signal) {
|
|
|
99
133
|
method: "DELETE",
|
|
100
134
|
headers: { authorization: `Bearer ${token}` },
|
|
101
135
|
};
|
|
102
|
-
|
|
103
|
-
init.signal = signal;
|
|
104
|
-
const res = await fetch(url, init);
|
|
136
|
+
const res = await fetchWithTimeout(url, init, signal);
|
|
105
137
|
if (!res.ok)
|
|
106
138
|
throw new FloomApiError(res.status, await readError(res));
|
|
107
139
|
return (await res.json());
|
package/dist/lib/config.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
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";
|
|
5
|
+
const REFRESH_SKEW_SECONDS = 5 * 60;
|
|
4
6
|
export function apiUrlFromConfig(cfg) {
|
|
5
|
-
return (
|
|
7
|
+
return (process.env.FLOOM_API_URL ?? cfg.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
|
|
6
8
|
}
|
|
7
9
|
export async function readConfig() {
|
|
8
10
|
try {
|
|
@@ -10,13 +12,16 @@ export async function readConfig() {
|
|
|
10
12
|
const parsed = JSON.parse(raw);
|
|
11
13
|
if (typeof parsed.accessToken !== "string" || parsed.accessToken.length === 0)
|
|
12
14
|
return null;
|
|
13
|
-
|
|
15
|
+
const cfg = {
|
|
14
16
|
accessToken: parsed.accessToken,
|
|
15
17
|
...(typeof parsed.apiUrl === "string" ? { apiUrl: parsed.apiUrl } : {}),
|
|
16
18
|
...(typeof parsed.refreshToken === "string" ? { refreshToken: parsed.refreshToken } : {}),
|
|
17
19
|
...(typeof parsed.expiresAt === "number" ? { expiresAt: parsed.expiresAt } : {}),
|
|
18
20
|
...(typeof parsed.email === "string" || parsed.email === null ? { email: parsed.email } : {}),
|
|
19
21
|
};
|
|
22
|
+
if (needsRefresh(cfg))
|
|
23
|
+
return refreshConfig(cfg);
|
|
24
|
+
return cfg;
|
|
20
25
|
}
|
|
21
26
|
catch (err) {
|
|
22
27
|
if (err.code === "ENOENT")
|
|
@@ -24,3 +29,53 @@ export async function readConfig() {
|
|
|
24
29
|
throw err;
|
|
25
30
|
}
|
|
26
31
|
}
|
|
32
|
+
export function needsRefresh(cfg) {
|
|
33
|
+
if (!cfg.refreshToken)
|
|
34
|
+
return false;
|
|
35
|
+
return !Number.isFinite(cfg.expiresAt) || Number(cfg.expiresAt) <= Math.floor(Date.now() / 1000) + REFRESH_SKEW_SECONDS;
|
|
36
|
+
}
|
|
37
|
+
export async function refreshConfig(cfg) {
|
|
38
|
+
if (!cfg.refreshToken)
|
|
39
|
+
return cfg;
|
|
40
|
+
const apiUrl = apiUrlFromConfig(cfg);
|
|
41
|
+
let res;
|
|
42
|
+
try {
|
|
43
|
+
res = await fetch(`${apiUrl}/api/v1/auth/refresh`, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "content-type": "application/json" },
|
|
46
|
+
body: JSON.stringify({ refresh_token: cfg.refreshToken }),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
if (!res.ok)
|
|
53
|
+
return null;
|
|
54
|
+
let data;
|
|
55
|
+
try {
|
|
56
|
+
data = await res.json();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (typeof data.access_token !== "string" || typeof data.refresh_token !== "string")
|
|
62
|
+
return null;
|
|
63
|
+
const expiresAt = typeof data.expires_at === "number" && Number.isFinite(data.expires_at)
|
|
64
|
+
? data.expires_at
|
|
65
|
+
: Math.floor(Date.now() / 1000) + (typeof data.expires_in === "number" && Number.isFinite(data.expires_in) ? data.expires_in : 3600);
|
|
66
|
+
const refreshed = {
|
|
67
|
+
apiUrl,
|
|
68
|
+
accessToken: data.access_token,
|
|
69
|
+
refreshToken: data.refresh_token,
|
|
70
|
+
expiresAt,
|
|
71
|
+
email: typeof data.email === "string" || data.email === null ? data.email : cfg.email ?? null,
|
|
72
|
+
};
|
|
73
|
+
await writeConfig(refreshed);
|
|
74
|
+
return refreshed;
|
|
75
|
+
}
|
|
76
|
+
async function writeConfig(cfg) {
|
|
77
|
+
const target = configPath();
|
|
78
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
79
|
+
await writeFile(target, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
80
|
+
await chmod(target, 0o600);
|
|
81
|
+
}
|
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.24";
|
|
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"]);
|