@floomhq/floom 1.0.13 → 1.0.16
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 +36 -30
- package/dist/cli.js +123 -228
- package/dist/doctor.js +117 -34
- package/dist/errors.js +1 -1
- package/dist/info.js +1 -1
- package/dist/init.js +87 -92
- package/dist/install.js +140 -66
- package/dist/library.js +2 -3
- package/dist/list.js +1 -1
- package/dist/login.js +81 -41
- package/dist/mcp.js +3 -4
- package/dist/package.js +313 -0
- package/dist/publish.js +51 -49
- package/dist/scan.js +18 -23
- package/dist/secrets.js +3 -29
- package/dist/setup.js +12 -14
- package/dist/sync-manifest.js +65 -16
- package/dist/sync.js +216 -172
- package/package.json +3 -2
- package/dist/targets.js +0 -16
package/dist/install.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
2
|
import { lstat, mkdir, open } from "node:fs/promises";
|
|
3
|
-
import {
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import ora from "ora";
|
|
6
6
|
import { readConfig, resolveApiUrl } from "./config.js";
|
|
7
7
|
import { getJson } from "./lib/api.js";
|
|
8
8
|
import { c, symbols } from "./ui.js";
|
|
9
9
|
import { FloomError } from "./errors.js";
|
|
10
|
-
import {
|
|
10
|
+
import { normalizeRemotePackageFiles, packageHash, sha256Bytes } from "./package.js";
|
|
11
|
+
import { manifestKey, markSynced, readSyncManifest, withSyncLock, writeSyncManifest } from "./sync-manifest.js";
|
|
11
12
|
const SLUG_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
12
13
|
const FD_PATH_ROOT = "/proc/self/fd";
|
|
13
14
|
function slugFromInput(input) {
|
|
@@ -21,23 +22,33 @@ function slugFromInput(input) {
|
|
|
21
22
|
return trimmed.replace(/\.(md|json)$/i, "");
|
|
22
23
|
}
|
|
23
24
|
}
|
|
25
|
+
function skillsDir(target) {
|
|
26
|
+
if (target === "codex") {
|
|
27
|
+
const codexHome = process.env.CODEX_HOME ?? join(homedir(), ".codex");
|
|
28
|
+
return process.env.CODEX_SKILLS_DIR ?? join(codexHome, "skills");
|
|
29
|
+
}
|
|
30
|
+
return process.env.CLAUDE_SKILLS_DIR ?? join(homedir(), ".claude", "skills");
|
|
31
|
+
}
|
|
24
32
|
function skillPath(root, slug) {
|
|
33
|
+
return join(root, slug, "SKILL.md");
|
|
34
|
+
}
|
|
35
|
+
function legacySkillPath(root, slug) {
|
|
25
36
|
return join(root, `${slug}.md`);
|
|
26
37
|
}
|
|
38
|
+
function skillsDirHint(target) {
|
|
39
|
+
return target === "codex" ? "CODEX_SKILLS_DIR" : "CLAUDE_SKILLS_DIR";
|
|
40
|
+
}
|
|
27
41
|
function setupCommand(target) {
|
|
28
42
|
return `npx -y @floomhq/floom setup --target ${target} --yes`;
|
|
29
43
|
}
|
|
30
|
-
function
|
|
31
|
-
return createHash("sha256").update(input).digest("hex");
|
|
32
|
-
}
|
|
33
|
-
async function localHash(path) {
|
|
44
|
+
async function readLocalFile(path) {
|
|
34
45
|
try {
|
|
35
46
|
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
36
47
|
try {
|
|
37
48
|
const stat = await handle.stat();
|
|
38
49
|
if (!stat.isFile())
|
|
39
50
|
throw new FloomError("Local path is blocked by an existing file or directory.");
|
|
40
|
-
return
|
|
51
|
+
return await handle.readFile();
|
|
41
52
|
}
|
|
42
53
|
finally {
|
|
43
54
|
await handle.close();
|
|
@@ -55,6 +66,47 @@ async function localHash(path) {
|
|
|
55
66
|
throw err;
|
|
56
67
|
}
|
|
57
68
|
}
|
|
69
|
+
async function localPackageHash(root, slug, target, files) {
|
|
70
|
+
const main = await readLocalFile(target);
|
|
71
|
+
if (main === null) {
|
|
72
|
+
const legacy = await readLocalFile(legacySkillPath(root, slug));
|
|
73
|
+
if (legacy !== null && files.length === 0)
|
|
74
|
+
return packageHash(legacy.toString("utf8"), []);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const localFiles = [];
|
|
78
|
+
for (const file of files) {
|
|
79
|
+
const bytes = await readLocalFile(join(dirname(target), file.path));
|
|
80
|
+
if (bytes === null)
|
|
81
|
+
return null;
|
|
82
|
+
localFiles.push({ path: file.path, bytes, sha256: file.sha256 });
|
|
83
|
+
}
|
|
84
|
+
return packageHash(main.toString("utf8"), localFiles);
|
|
85
|
+
}
|
|
86
|
+
async function markInstallSynced(root, slug, files) {
|
|
87
|
+
const manifest = await readSyncManifest();
|
|
88
|
+
for (const file of files) {
|
|
89
|
+
markSynced(manifest, manifestKey(root, file.target), slug, file.hash);
|
|
90
|
+
}
|
|
91
|
+
await writeSyncManifest(manifest);
|
|
92
|
+
}
|
|
93
|
+
async function preflightInstallPackage(root, files, opts) {
|
|
94
|
+
for (const file of files) {
|
|
95
|
+
await ensureSafeParentDirectory(root, file.target);
|
|
96
|
+
const existing = await readLocalFile(file.target);
|
|
97
|
+
if (existing === null)
|
|
98
|
+
continue;
|
|
99
|
+
if (sha256Buffer(existing) === file.hash)
|
|
100
|
+
continue;
|
|
101
|
+
if (opts.force)
|
|
102
|
+
continue;
|
|
103
|
+
return file.target;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
function sha256Buffer(input) {
|
|
108
|
+
return sha256Bytes(input);
|
|
109
|
+
}
|
|
58
110
|
async function writeInstallFile(root, target, body) {
|
|
59
111
|
const parent = await openSafeParentDirectory(root, target);
|
|
60
112
|
let handle = null;
|
|
@@ -67,8 +119,9 @@ async function writeInstallFile(root, target, body) {
|
|
|
67
119
|
await parent.close();
|
|
68
120
|
}
|
|
69
121
|
}
|
|
70
|
-
async function overwriteInstallFile(target, body) {
|
|
71
|
-
const
|
|
122
|
+
async function overwriteInstallFile(root, target, body) {
|
|
123
|
+
const parent = await openSafeParentDirectory(root, target);
|
|
124
|
+
const handle = await open(childCreatePath(parent, dirname(target), basename(target)), constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o600);
|
|
72
125
|
try {
|
|
73
126
|
const stat = await handle.stat();
|
|
74
127
|
if (!stat.isFile())
|
|
@@ -77,6 +130,7 @@ async function overwriteInstallFile(target, body) {
|
|
|
77
130
|
}
|
|
78
131
|
finally {
|
|
79
132
|
await handle.close();
|
|
133
|
+
await parent.close();
|
|
80
134
|
}
|
|
81
135
|
}
|
|
82
136
|
async function openSafeParentDirectory(root, target) {
|
|
@@ -89,7 +143,7 @@ function childCreatePath(parent, fallbackParent, name) {
|
|
|
89
143
|
return join(resolve(fallbackParent), name);
|
|
90
144
|
}
|
|
91
145
|
async function writeAll(handle, body) {
|
|
92
|
-
const buffer = Buffer.from(body, "utf8");
|
|
146
|
+
const buffer = Buffer.isBuffer(body) ? body : Buffer.from(body, "utf8");
|
|
93
147
|
let offset = 0;
|
|
94
148
|
while (offset < buffer.length) {
|
|
95
149
|
const result = await handle.write(buffer, offset, buffer.length - offset, offset);
|
|
@@ -142,14 +196,14 @@ async function assertSafeDirectory(path) {
|
|
|
142
196
|
}
|
|
143
197
|
export async function install(slugInput, opts = {}) {
|
|
144
198
|
const targetAgent = opts.target ?? "claude";
|
|
145
|
-
const root =
|
|
199
|
+
const root = skillsDir(targetAgent);
|
|
146
200
|
const slug = slugFromInput(slugInput);
|
|
147
201
|
if (!SLUG_RE.test(slug)) {
|
|
148
202
|
throw new FloomError(`Invalid skill slug: ${slugInput}`);
|
|
149
203
|
}
|
|
150
204
|
const cfg = await readConfig();
|
|
151
205
|
const apiUrl = resolveApiUrl(cfg);
|
|
152
|
-
const spinner =
|
|
206
|
+
const spinner = ora({ text: c.dim(`Adding ${slug}...`), color: "yellow" }).start();
|
|
153
207
|
let detail;
|
|
154
208
|
try {
|
|
155
209
|
detail = await getJson(`${apiUrl}/api/v1/skills/${encodeURIComponent(slug)}`, "fetch skill", cfg?.accessToken);
|
|
@@ -158,74 +212,94 @@ export async function install(slugInput, opts = {}) {
|
|
|
158
212
|
}
|
|
159
213
|
}
|
|
160
214
|
catch (err) {
|
|
161
|
-
spinner
|
|
162
|
-
throw err;
|
|
163
|
-
}
|
|
164
|
-
try {
|
|
165
|
-
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
166
|
-
}
|
|
167
|
-
catch (err) {
|
|
168
|
-
if (err.code === "EEXIST") {
|
|
169
|
-
throw new FloomError(`${skillsDirHint(targetAgent)} points to a file, not a directory.`, `Set ${skillsDirHint(targetAgent)} to a directory, or remove the file blocking it.`);
|
|
170
|
-
}
|
|
215
|
+
spinner.stop();
|
|
171
216
|
throw err;
|
|
172
217
|
}
|
|
173
218
|
const target = skillPath(root, slug);
|
|
174
|
-
const
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
219
|
+
const remotePackageFiles = normalizeRemotePackageFiles(detail.package_files ?? detail.files);
|
|
220
|
+
const installFiles = [
|
|
221
|
+
{ target, bytes: detail.body_md, hash: sha256Bytes(detail.body_md) },
|
|
222
|
+
...remotePackageFiles.map((file) => ({
|
|
223
|
+
target: join(dirname(target), file.path),
|
|
224
|
+
bytes: file.bytes,
|
|
225
|
+
hash: file.sha256,
|
|
226
|
+
})),
|
|
227
|
+
];
|
|
228
|
+
const remoteHash = packageHash(detail.body_md, remotePackageFiles);
|
|
229
|
+
let action = "installed";
|
|
230
|
+
let manifestWarning = null;
|
|
231
|
+
await withSyncLock(async () => {
|
|
181
232
|
try {
|
|
182
|
-
await
|
|
233
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
183
234
|
}
|
|
184
235
|
catch (err) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
236
|
+
if (err.code === "EEXIST") {
|
|
237
|
+
throw new FloomError(`${skillsDirHint(targetAgent)} points to a file, not a directory.`, `Set ${skillsDirHint(targetAgent)} to a directory, or remove the file blocking it.`);
|
|
238
|
+
}
|
|
188
239
|
throw err;
|
|
189
240
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
else {
|
|
196
|
-
try {
|
|
197
|
-
await writeInstallFile(root, target, detail.body_md);
|
|
241
|
+
await ensureSafeParentDirectory(root, target);
|
|
242
|
+
const existing = await localPackageHash(root, slug, target, remotePackageFiles);
|
|
243
|
+
const conflictingTarget = await preflightInstallPackage(root, installFiles, opts.force ? { force: true } : {});
|
|
244
|
+
if (conflictingTarget) {
|
|
245
|
+
throw new FloomError("Local skill already exists with different content.", `Run \`npx -y @floomhq/floom add <link> --force\` to replace it, or move the local file first: ${relative(root, conflictingTarget).split(sep).join("/")}`);
|
|
198
246
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
247
|
+
if (existing === remoteHash) {
|
|
248
|
+
action = "unchanged";
|
|
249
|
+
}
|
|
250
|
+
else if (existing !== null && opts.force) {
|
|
251
|
+
try {
|
|
252
|
+
await overwriteInstallFile(root, target, detail.body_md);
|
|
253
|
+
for (const file of remotePackageFiles) {
|
|
254
|
+
await overwriteInstallFile(root, join(dirname(target), file.path), file.bytes);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
const code = err.code;
|
|
259
|
+
if (code === "ELOOP")
|
|
260
|
+
throw new FloomError("Local path is a symbolic link.", "Move or delete the local path, then run `npx -y @floomhq/floom add` again.");
|
|
261
|
+
throw err;
|
|
203
262
|
}
|
|
204
|
-
|
|
205
|
-
|
|
263
|
+
action = "updated";
|
|
264
|
+
}
|
|
265
|
+
else if (existing !== null) {
|
|
266
|
+
throw new FloomError("Local skill already exists with different content.", "Run `npx -y @floomhq/floom add <link> --force` to replace it, or move the local file first.");
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
try {
|
|
270
|
+
await writeInstallFile(root, target, detail.body_md);
|
|
271
|
+
for (const file of remotePackageFiles) {
|
|
272
|
+
await writeInstallFile(root, join(dirname(target), file.path), file.bytes);
|
|
273
|
+
}
|
|
206
274
|
}
|
|
207
|
-
|
|
208
|
-
|
|
275
|
+
catch (err) {
|
|
276
|
+
const code = err.code;
|
|
277
|
+
if (code === "EEXIST") {
|
|
278
|
+
throw new FloomError("Local skill already exists with different content.", "Move or delete the local file, then run `npx -y @floomhq/floom add` again.");
|
|
279
|
+
}
|
|
280
|
+
if (code === "ELOOP") {
|
|
281
|
+
throw new FloomError("Local path is a symbolic link.", "Move or delete the local path, then run `npx -y @floomhq/floom add` again.");
|
|
282
|
+
}
|
|
283
|
+
if (code === "ENOENT") {
|
|
284
|
+
throw new FloomError("Local path changed during install.", "Run `npx -y @floomhq/floom add` again.");
|
|
285
|
+
}
|
|
286
|
+
throw err;
|
|
209
287
|
}
|
|
210
|
-
|
|
288
|
+
action = "installed";
|
|
211
289
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
content_hash: remoteHash,
|
|
221
|
-
};
|
|
222
|
-
spinner?.stop();
|
|
223
|
-
if (opts.json) {
|
|
224
|
-
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
290
|
+
try {
|
|
291
|
+
await markInstallSynced(root, slug, installFiles);
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
manifestWarning = err instanceof Error ? err.message : String(err);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
spinner.stop();
|
|
227
298
|
process.stdout.write(`\n${symbols.ok} [floom] ${action} ${c.bold(slug)}\n`);
|
|
228
|
-
process.stdout.write(` ${c.dim(target)}\n\n`);
|
|
299
|
+
process.stdout.write(` ${c.dim(dirname(target))}\n\n`);
|
|
300
|
+
if (manifestWarning) {
|
|
301
|
+
process.stdout.write(` ${c.yellow("!")} ${c.dim(`Installed, but sync tracking was not updated: ${manifestWarning}`)}\n\n`);
|
|
302
|
+
}
|
|
229
303
|
process.stdout.write(` ${c.bold("Next")}\n`);
|
|
230
304
|
if (opts.setup) {
|
|
231
305
|
process.stdout.write(` ${c.dim("1.")} Floom is connecting ${targetAgent === "claude" ? "Claude Code" : "Codex"} now.\n`);
|
package/dist/library.js
CHANGED
|
@@ -3,7 +3,6 @@ import { readConfig, resolveApiUrl } from "./config.js";
|
|
|
3
3
|
import { deleteRequest, getJson, postJson, putJson } from "./lib/api.js";
|
|
4
4
|
import { c, symbols } from "./ui.js";
|
|
5
5
|
import { FloomError } from "./errors.js";
|
|
6
|
-
import { resolveSkillsDir } from "./targets.js";
|
|
7
6
|
function formatLibraryRow(lib) {
|
|
8
7
|
const name = lib.name ?? c.dim("(unnamed)");
|
|
9
8
|
const vis = c.dim(`[${lib.visibility}]`);
|
|
@@ -46,7 +45,7 @@ export async function libraryCreate(opts) {
|
|
|
46
45
|
});
|
|
47
46
|
process.stdout.write(`\n${symbols.ok} Library created: ${c.cyan(result.slug)}\n`);
|
|
48
47
|
process.stdout.write(` ${c.dim("API:")} ${apiUrl}/api/v1/libraries/${result.slug}\n`);
|
|
49
|
-
process.stdout.write(` ${c.dim("Sync:")}
|
|
48
|
+
process.stdout.write(` ${c.dim("Sync:")} floom library subscribe ${result.slug}\n\n`);
|
|
50
49
|
}
|
|
51
50
|
export async function libraryAddSkill(opts) {
|
|
52
51
|
const cfg = await readConfig();
|
|
@@ -79,7 +78,7 @@ export async function librarySubscribe(slug) {
|
|
|
79
78
|
const apiUrl = resolveApiUrl(cfg);
|
|
80
79
|
await postJson(`${apiUrl}/api/v1/me/subscriptions`, "subscribe to library", cfg.accessToken, { library_slug: slug });
|
|
81
80
|
process.stdout.write(`\n${symbols.ok} Subscribed to ${c.cyan(slug)}\n`);
|
|
82
|
-
process.stdout.write(` ${c.dim(`
|
|
81
|
+
process.stdout.write(` ${c.dim("Run `floom sync --target claude` or `floom sync --target codex` to pull this library locally.")}\n\n`);
|
|
83
82
|
}
|
|
84
83
|
export async function libraryUnsubscribe(slug) {
|
|
85
84
|
const cfg = await readConfig();
|
package/dist/list.js
CHANGED
|
@@ -54,7 +54,7 @@ export async function list(opts) {
|
|
|
54
54
|
}
|
|
55
55
|
process.stdout.write(`\n${symbols.dot} ${c.bold("Published")} ${c.dim(`(${published.length})`)}\n\n`);
|
|
56
56
|
if (published.length === 0) {
|
|
57
|
-
process.stdout.write(` ${c.dim("Nothing published yet. Try `npx -y @floomhq/floom publish skill
|
|
57
|
+
process.stdout.write(` ${c.dim("Nothing published yet. Try `npx -y @floomhq/floom publish my-skill`.")}\n`);
|
|
58
58
|
}
|
|
59
59
|
else {
|
|
60
60
|
for (const s of published)
|
package/dist/login.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
|
-
import {
|
|
2
|
+
import { createServer as createNetServer } from "node:net";
|
|
3
3
|
import open from "open";
|
|
4
4
|
import ora from "ora";
|
|
5
5
|
import { getApiUrl, writeConfig } from "./config.js";
|
|
@@ -9,6 +9,7 @@ const DEFAULT_PORT = 7456;
|
|
|
9
9
|
const TIMEOUT_MS = 5 * 60 * 1000;
|
|
10
10
|
export async function login() {
|
|
11
11
|
const apiUrl = getApiUrl();
|
|
12
|
+
const port = await pickPort();
|
|
12
13
|
process.stdout.write(header());
|
|
13
14
|
process.stdout.write(`${symbols.arrow} Opening browser to sign in with Google...\n\n`);
|
|
14
15
|
const spinner = ora({
|
|
@@ -17,12 +18,12 @@ export async function login() {
|
|
|
17
18
|
}).start();
|
|
18
19
|
let tokens;
|
|
19
20
|
try {
|
|
20
|
-
tokens = await waitForCallback();
|
|
21
|
+
tokens = await waitForCallback(port);
|
|
21
22
|
}
|
|
22
23
|
catch (err) {
|
|
23
24
|
spinner.stop();
|
|
24
25
|
if (err instanceof Error && /timed out/i.test(err.message)) {
|
|
25
|
-
throw new FloomError("No worries
|
|
26
|
+
throw new FloomError("No worries - try `npx -y @floomhq/floom login` again when ready.");
|
|
26
27
|
}
|
|
27
28
|
throw err;
|
|
28
29
|
}
|
|
@@ -57,20 +58,47 @@ export async function login() {
|
|
|
57
58
|
process.stdout.write(`${symbols.ok} Signed in as ${c.bold(me.email ?? me.id)}\n`);
|
|
58
59
|
process.stdout.write(` ${c.dim("Your token is saved at ~/.floom/config.json")}\n\n`);
|
|
59
60
|
}
|
|
60
|
-
|
|
61
|
+
/** Reserve a free port. Prefers 7456 for existing Supabase CLI auth setups. */
|
|
62
|
+
async function pickPort() {
|
|
63
|
+
if (await canListen(DEFAULT_PORT))
|
|
64
|
+
return DEFAULT_PORT;
|
|
65
|
+
return reserveEphemeralPort();
|
|
66
|
+
}
|
|
67
|
+
function canListen(port) {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const server = createNetServer();
|
|
70
|
+
server.once("error", () => resolve(false));
|
|
71
|
+
server.listen(port, "127.0.0.1", () => {
|
|
72
|
+
server.close(() => resolve(true));
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function reserveEphemeralPort() {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const server = createNetServer();
|
|
79
|
+
server.once("error", reject);
|
|
80
|
+
server.listen(0, "127.0.0.1", () => {
|
|
81
|
+
const address = server.address();
|
|
82
|
+
if (!address || typeof address === "string") {
|
|
83
|
+
server.close();
|
|
84
|
+
reject(new FloomError("Could not reserve a local sign-in port."));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const port = address.port;
|
|
88
|
+
server.close(() => resolve(port));
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function waitForCallback(port) {
|
|
61
93
|
return new Promise((resolve, reject) => {
|
|
62
94
|
const apiUrl = getApiUrl();
|
|
63
|
-
const allowedOrigin = new URL(apiUrl).origin;
|
|
64
|
-
const state = randomBytes(24).toString("base64url");
|
|
65
95
|
let settled = false;
|
|
66
|
-
let retriedEphemeralPort = false;
|
|
67
96
|
const server = createServer((req, res) => {
|
|
68
97
|
// CORS preflight from the browser bridge page.
|
|
69
|
-
const origin = req.headers.origin;
|
|
70
|
-
const corsOrigin = origin === allowedOrigin ? origin : "null";
|
|
98
|
+
const origin = req.headers.origin ?? "*";
|
|
71
99
|
if (req.method === "OPTIONS") {
|
|
72
100
|
res.writeHead(204, {
|
|
73
|
-
"access-control-allow-origin":
|
|
101
|
+
"access-control-allow-origin": origin,
|
|
74
102
|
"access-control-allow-methods": "POST, OPTIONS",
|
|
75
103
|
"access-control-allow-headers": "content-type",
|
|
76
104
|
"access-control-allow-private-network": "true",
|
|
@@ -87,24 +115,15 @@ function waitForCallback() {
|
|
|
87
115
|
const data = parseCallbackBody(body, req.headers["content-type"]);
|
|
88
116
|
if (!data.access_token || !data.refresh_token) {
|
|
89
117
|
res.writeHead(400, {
|
|
90
|
-
"access-control-allow-origin":
|
|
118
|
+
"access-control-allow-origin": origin,
|
|
91
119
|
"access-control-allow-private-network": "true",
|
|
92
120
|
"content-type": "text/html; charset=utf-8",
|
|
93
121
|
});
|
|
94
122
|
res.end(localCallbackPage("Missing tokens from OAuth response."));
|
|
95
123
|
return;
|
|
96
124
|
}
|
|
97
|
-
if (data.state !== state) {
|
|
98
|
-
res.writeHead(400, {
|
|
99
|
-
"access-control-allow-origin": corsOrigin,
|
|
100
|
-
"access-control-allow-private-network": "true",
|
|
101
|
-
"content-type": "text/html; charset=utf-8",
|
|
102
|
-
});
|
|
103
|
-
res.end(localCallbackPage("Invalid OAuth state."));
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
125
|
res.writeHead(200, {
|
|
107
|
-
"access-control-allow-origin":
|
|
126
|
+
"access-control-allow-origin": origin,
|
|
108
127
|
"access-control-allow-private-network": "true",
|
|
109
128
|
"content-type": "text/html; charset=utf-8",
|
|
110
129
|
});
|
|
@@ -114,7 +133,7 @@ function waitForCallback() {
|
|
|
114
133
|
resolve(data);
|
|
115
134
|
}
|
|
116
135
|
catch {
|
|
117
|
-
res.writeHead(400, { "content-type": "text/html; charset=utf-8", "access-control-allow-origin":
|
|
136
|
+
res.writeHead(400, { "content-type": "text/html; charset=utf-8", "access-control-allow-origin": origin });
|
|
118
137
|
res.end(localCallbackPage("Invalid OAuth response."));
|
|
119
138
|
}
|
|
120
139
|
});
|
|
@@ -135,28 +154,14 @@ function waitForCallback() {
|
|
|
135
154
|
server.close();
|
|
136
155
|
}
|
|
137
156
|
server.on("error", (err) => {
|
|
138
|
-
const code = err.code;
|
|
139
|
-
if (!settled && !retriedEphemeralPort && code === "EADDRINUSE") {
|
|
140
|
-
retriedEphemeralPort = true;
|
|
141
|
-
server.listen(0, "127.0.0.1");
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
157
|
if (settled)
|
|
145
158
|
return;
|
|
146
159
|
settled = true;
|
|
147
160
|
clearTimeout(timer);
|
|
148
|
-
reject(new FloomError(
|
|
161
|
+
reject(new FloomError(`Local auth server failed on port ${port}.`, `Is port ${port} already in use? (${err.message})`));
|
|
149
162
|
});
|
|
150
|
-
server.listen(
|
|
151
|
-
const
|
|
152
|
-
if (!address || typeof address === "string") {
|
|
153
|
-
settled = true;
|
|
154
|
-
cleanup();
|
|
155
|
-
reject(new FloomError("Could not reserve a local sign-in port."));
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
const port = address.port;
|
|
159
|
-
const target = `${apiUrl}/auth/cli?port=${port}&state=${encodeURIComponent(state)}`;
|
|
163
|
+
server.listen(port, "127.0.0.1", () => {
|
|
164
|
+
const target = `${apiUrl}/auth/cli?port=${port}`;
|
|
160
165
|
open(target).catch((e) => {
|
|
161
166
|
const msg = e instanceof Error ? e.message : String(e);
|
|
162
167
|
process.stdout.write(c.yellow(`Could not auto-open browser (${msg}).\n`) +
|
|
@@ -170,7 +175,7 @@ function parseCallbackBody(body, contentType) {
|
|
|
170
175
|
if (type.includes("application/x-www-form-urlencoded")) {
|
|
171
176
|
const params = new URLSearchParams(body);
|
|
172
177
|
const parsed = {};
|
|
173
|
-
for (const key of ["access_token", "refresh_token", "expires_in", "token_type"
|
|
178
|
+
for (const key of ["access_token", "refresh_token", "expires_in", "token_type"]) {
|
|
174
179
|
const value = params.get(key);
|
|
175
180
|
if (value)
|
|
176
181
|
parsed[key] = value;
|
|
@@ -180,5 +185,40 @@ function parseCallbackBody(body, contentType) {
|
|
|
180
185
|
return JSON.parse(body);
|
|
181
186
|
}
|
|
182
187
|
function localCallbackPage(message) {
|
|
183
|
-
|
|
188
|
+
const safeMessage = escapeHtml(message);
|
|
189
|
+
return `<!doctype html>
|
|
190
|
+
<html lang="en">
|
|
191
|
+
<head>
|
|
192
|
+
<meta charset="utf-8">
|
|
193
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
194
|
+
<title>Floom CLI sign-in</title>
|
|
195
|
+
<style>
|
|
196
|
+
:root { color-scheme: light; --ink: #111827; --muted: #4b5563; --line: #e5e7eb; --accent: #0f766e; --bg: #f8fafc; }
|
|
197
|
+
* { box-sizing: border-box; }
|
|
198
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--bg); color: var(--ink); font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
199
|
+
main { width: min(560px, calc(100vw - 40px)); padding: 40px; background: #fff; border: 1px solid var(--line); border-radius: 16px; box-shadow: 0 24px 80px rgba(15, 23, 42, 0.08); }
|
|
200
|
+
.mark { display: inline-flex; align-items: center; gap: 10px; margin-bottom: 28px; font-weight: 700; letter-spacing: 0.02em; }
|
|
201
|
+
.dot { width: 10px; height: 10px; border-radius: 999px; background: var(--accent); display: inline-block; }
|
|
202
|
+
h1 { margin: 0; font-size: clamp(32px, 7vw, 56px); line-height: 0.95; letter-spacing: 0; }
|
|
203
|
+
p { margin: 20px 0 0; font-size: 18px; line-height: 1.55; color: var(--muted); }
|
|
204
|
+
code { display: inline-block; margin-top: 24px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 8px; background: #f9fafb; color: var(--ink); font-size: 14px; }
|
|
205
|
+
</style>
|
|
206
|
+
</head>
|
|
207
|
+
<body>
|
|
208
|
+
<main>
|
|
209
|
+
<div class="mark"><span class="dot"></span><span>floom</span></div>
|
|
210
|
+
<h1>${safeMessage}</h1>
|
|
211
|
+
<p>Return to your terminal to continue. Your Floom CLI now has the token it needs.</p>
|
|
212
|
+
<code>floom doctor</code>
|
|
213
|
+
</main>
|
|
214
|
+
</body>
|
|
215
|
+
</html>`;
|
|
216
|
+
}
|
|
217
|
+
function escapeHtml(value) {
|
|
218
|
+
return value
|
|
219
|
+
.replace(/&/g, "&")
|
|
220
|
+
.replace(/</g, "<")
|
|
221
|
+
.replace(/>/g, ">")
|
|
222
|
+
.replace(/"/g, """)
|
|
223
|
+
.replace(/'/g, "'");
|
|
184
224
|
}
|
package/dist/mcp.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import { c } from "./ui.js";
|
|
2
2
|
export function printMcpSetup() {
|
|
3
3
|
const snippet = `## Floom
|
|
4
|
-
-
|
|
4
|
+
- Use Floom skills from the local Floom skills folder when they match the task.
|
|
5
5
|
- To install a shared skill, run \`npx -y @floomhq/floom add <slug-or-url> --target claude\` or \`npx -y @floomhq/floom add <slug-or-url> --target codex\`.
|
|
6
|
-
-
|
|
7
|
-
- If MCP tools are not available, run \`npx -y @floomhq/floom search <query>\`.
|
|
6
|
+
- To find reusable behavior, run \`npx -y @floomhq/floom search <query>\`.
|
|
8
7
|
- MCP sync is optional preview behavior; use it only while the Floom MCP server is configured and running.`;
|
|
9
8
|
process.stdout.write(`\n${c.bold("Floom MCP setup")}\n\n`);
|
|
10
9
|
process.stdout.write(`${c.dim("Pick your tool:")}\n\n`);
|
|
11
10
|
process.stdout.write(` ${c.bold("Claude Code")}\n`);
|
|
12
11
|
process.stdout.write(` ${c.cyan("claude mcp add floom -- npx -y @floomhq/floom-mcp-sync")}\n\n`);
|
|
13
12
|
process.stdout.write(` ${c.bold("Codex CLI")}\n`);
|
|
14
|
-
process.stdout.write(` ${c.cyan("codex mcp add floom --
|
|
13
|
+
process.stdout.write(` ${c.cyan("codex mcp add floom -- npx -y @floomhq/floom-mcp-sync")}\n\n`);
|
|
15
14
|
process.stdout.write(`${c.dim("Full guide:")} ${c.cyan("https://floom.dev/docs/mcp")}\n\n`);
|
|
16
15
|
process.stdout.write(`${c.dim("Recommended agent instruction snippet:")}\n\n`);
|
|
17
16
|
process.stdout.write(`${snippet}\n\n`);
|