@floomhq/floom 1.0.14 → 1.0.17
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 +127 -233
- package/dist/doctor.js +119 -38
- package/dist/errors.js +1 -1
- package/dist/info.js +1 -1
- package/dist/init.js +87 -92
- package/dist/install.js +140 -67
- package/dist/library.js +4 -8
- package/dist/list.js +7 -8
- package/dist/login.js +81 -46
- package/dist/mcp.js +4 -7
- package/dist/package.js +318 -0
- package/dist/publish.js +51 -51
- 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,75 +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`);
|
|
229
|
-
|
|
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
|
+
}
|
|
230
303
|
process.stdout.write(` ${c.bold("Next")}\n`);
|
|
231
304
|
if (opts.setup) {
|
|
232
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,8 +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("
|
|
50
|
-
process.stdout.write(` ${c.dim("Sync:")} npx -y @floomhq/floom sync\n\n`);
|
|
48
|
+
process.stdout.write(` ${c.dim("Sync:")} floom library subscribe ${result.slug}\n\n`);
|
|
51
49
|
}
|
|
52
50
|
export async function libraryAddSkill(opts) {
|
|
53
51
|
const cfg = await readConfig();
|
|
@@ -79,10 +77,8 @@ export async function librarySubscribe(slug) {
|
|
|
79
77
|
throw new FloomError("Not signed in.", "Run `npx -y @floomhq/floom login` first.");
|
|
80
78
|
const apiUrl = resolveApiUrl(cfg);
|
|
81
79
|
await postJson(`${apiUrl}/api/v1/me/subscriptions`, "subscribe to library", cfg.accessToken, { library_slug: slug });
|
|
82
|
-
process.stdout.write(`\n${symbols.ok}
|
|
83
|
-
process.stdout.write(` ${c.dim("
|
|
84
|
-
process.stdout.write(` ${c.dim(`Run \`npx -y @floomhq/floom sync\` to write skills under ${resolveSkillsDir("claude")}/${slug}/ by default.`)}\n`);
|
|
85
|
-
process.stdout.write(` ${c.dim("Run `npx -y @floomhq/floom mcp` to keep followed libraries updated while your agent is connected.")}\n\n`);
|
|
80
|
+
process.stdout.write(`\n${symbols.ok} Subscribed to ${c.cyan(slug)}\n`);
|
|
81
|
+
process.stdout.write(` ${c.dim("Run `floom sync --target claude` or `floom sync --target codex` to pull this library locally.")}\n\n`);
|
|
86
82
|
}
|
|
87
83
|
export async function libraryUnsubscribe(slug) {
|
|
88
84
|
const cfg = await readConfig();
|
|
@@ -90,7 +86,7 @@ export async function libraryUnsubscribe(slug) {
|
|
|
90
86
|
throw new FloomError("Not signed in.", "Run `npx -y @floomhq/floom login` first.");
|
|
91
87
|
const apiUrl = resolveApiUrl(cfg);
|
|
92
88
|
await deleteRequest(`${apiUrl}/api/v1/me/subscriptions/${encodeURIComponent(slug)}`, "unsubscribe from library", cfg.accessToken);
|
|
93
|
-
process.stdout.write(`\n${symbols.ok}
|
|
89
|
+
process.stdout.write(`\n${symbols.ok} Unsubscribed from ${c.cyan(slug)}\n\n`);
|
|
94
90
|
}
|
|
95
91
|
export async function moveSkill(opts) {
|
|
96
92
|
const cfg = await readConfig();
|
package/dist/list.js
CHANGED
|
@@ -40,25 +40,24 @@ export async function list(opts) {
|
|
|
40
40
|
}
|
|
41
41
|
const apiUrl = resolveApiUrl(cfg);
|
|
42
42
|
const spinner = opts.json ? null : ora({ text: c.dim("Loading skills..."), color: "yellow" }).start();
|
|
43
|
-
let
|
|
43
|
+
let published = [];
|
|
44
44
|
try {
|
|
45
45
|
const mine = await getJson(`${apiUrl}/api/v1/me/skills`, "load your skills", cfg.accessToken);
|
|
46
|
-
|
|
46
|
+
published = mine.skills ?? [];
|
|
47
47
|
}
|
|
48
48
|
finally {
|
|
49
49
|
spinner?.stop();
|
|
50
50
|
}
|
|
51
51
|
if (opts.json) {
|
|
52
|
-
process.stdout.write(`${JSON.stringify({
|
|
52
|
+
process.stdout.write(`${JSON.stringify({ published }, null, 2)}\n`);
|
|
53
53
|
return;
|
|
54
54
|
}
|
|
55
|
-
process.stdout.write(`\n${symbols.dot} ${c.bold("
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
process.stdout.write(` ${c.dim("Nothing in your library yet. Add a shared link, save a skill on floom.dev, or publish `npx -y @floomhq/floom publish skill.md`.")}\n`);
|
|
55
|
+
process.stdout.write(`\n${symbols.dot} ${c.bold("Published")} ${c.dim(`(${published.length})`)}\n\n`);
|
|
56
|
+
if (published.length === 0) {
|
|
57
|
+
process.stdout.write(` ${c.dim("Nothing published yet. Try `npx -y @floomhq/floom publish my-skill`.")}\n`);
|
|
59
58
|
}
|
|
60
59
|
else {
|
|
61
|
-
for (const s of
|
|
60
|
+
for (const s of published)
|
|
62
61
|
process.stdout.write(`${formatRow(s)}\n`);
|
|
63
62
|
}
|
|
64
63
|
process.stdout.write("\n");
|
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
|
}
|
|
@@ -56,26 +57,48 @@ export async function login() {
|
|
|
56
57
|
spinner.stop();
|
|
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
|
-
process.stdout.write(` ${c.bold("Account mode is on")}\n`);
|
|
60
|
-
process.stdout.write(` ${c.dim("Publish skills, save skills on floom.dev, follow libraries, and sync your account library locally.")}\n\n`);
|
|
61
|
-
process.stdout.write(` ${c.dim("Publish:")} ${c.cyan("npx -y @floomhq/floom publish <file.md>")}\n`);
|
|
62
|
-
process.stdout.write(` ${c.dim("Sync:")} ${c.cyan("npx -y @floomhq/floom sync")}\n`);
|
|
63
|
-
process.stdout.write(` ${c.dim("MCP:")} ${c.cyan("npx -y @floomhq/floom mcp")}\n\n`);
|
|
64
60
|
}
|
|
65
|
-
|
|
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) {
|
|
66
93
|
return new Promise((resolve, reject) => {
|
|
67
94
|
const apiUrl = getApiUrl();
|
|
68
|
-
const allowedOrigin = new URL(apiUrl).origin;
|
|
69
|
-
const state = randomBytes(24).toString("base64url");
|
|
70
95
|
let settled = false;
|
|
71
|
-
let retriedEphemeralPort = false;
|
|
72
96
|
const server = createServer((req, res) => {
|
|
73
97
|
// CORS preflight from the browser bridge page.
|
|
74
|
-
const origin = req.headers.origin;
|
|
75
|
-
const corsOrigin = origin === allowedOrigin ? origin : "null";
|
|
98
|
+
const origin = req.headers.origin ?? "*";
|
|
76
99
|
if (req.method === "OPTIONS") {
|
|
77
100
|
res.writeHead(204, {
|
|
78
|
-
"access-control-allow-origin":
|
|
101
|
+
"access-control-allow-origin": origin,
|
|
79
102
|
"access-control-allow-methods": "POST, OPTIONS",
|
|
80
103
|
"access-control-allow-headers": "content-type",
|
|
81
104
|
"access-control-allow-private-network": "true",
|
|
@@ -92,24 +115,15 @@ function waitForCallback() {
|
|
|
92
115
|
const data = parseCallbackBody(body, req.headers["content-type"]);
|
|
93
116
|
if (!data.access_token || !data.refresh_token) {
|
|
94
117
|
res.writeHead(400, {
|
|
95
|
-
"access-control-allow-origin":
|
|
118
|
+
"access-control-allow-origin": origin,
|
|
96
119
|
"access-control-allow-private-network": "true",
|
|
97
120
|
"content-type": "text/html; charset=utf-8",
|
|
98
121
|
});
|
|
99
122
|
res.end(localCallbackPage("Missing tokens from OAuth response."));
|
|
100
123
|
return;
|
|
101
124
|
}
|
|
102
|
-
if (data.state !== state) {
|
|
103
|
-
res.writeHead(400, {
|
|
104
|
-
"access-control-allow-origin": corsOrigin,
|
|
105
|
-
"access-control-allow-private-network": "true",
|
|
106
|
-
"content-type": "text/html; charset=utf-8",
|
|
107
|
-
});
|
|
108
|
-
res.end(localCallbackPage("Invalid OAuth state."));
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
125
|
res.writeHead(200, {
|
|
112
|
-
"access-control-allow-origin":
|
|
126
|
+
"access-control-allow-origin": origin,
|
|
113
127
|
"access-control-allow-private-network": "true",
|
|
114
128
|
"content-type": "text/html; charset=utf-8",
|
|
115
129
|
});
|
|
@@ -119,7 +133,7 @@ function waitForCallback() {
|
|
|
119
133
|
resolve(data);
|
|
120
134
|
}
|
|
121
135
|
catch {
|
|
122
|
-
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 });
|
|
123
137
|
res.end(localCallbackPage("Invalid OAuth response."));
|
|
124
138
|
}
|
|
125
139
|
});
|
|
@@ -140,28 +154,14 @@ function waitForCallback() {
|
|
|
140
154
|
server.close();
|
|
141
155
|
}
|
|
142
156
|
server.on("error", (err) => {
|
|
143
|
-
const code = err.code;
|
|
144
|
-
if (!settled && !retriedEphemeralPort && code === "EADDRINUSE") {
|
|
145
|
-
retriedEphemeralPort = true;
|
|
146
|
-
server.listen(0, "127.0.0.1");
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
157
|
if (settled)
|
|
150
158
|
return;
|
|
151
159
|
settled = true;
|
|
152
160
|
clearTimeout(timer);
|
|
153
|
-
reject(new FloomError(
|
|
161
|
+
reject(new FloomError(`Local auth server failed on port ${port}.`, `Is port ${port} already in use? (${err.message})`));
|
|
154
162
|
});
|
|
155
|
-
server.listen(
|
|
156
|
-
const
|
|
157
|
-
if (!address || typeof address === "string") {
|
|
158
|
-
settled = true;
|
|
159
|
-
cleanup();
|
|
160
|
-
reject(new FloomError("Could not reserve a local sign-in port."));
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
const port = address.port;
|
|
164
|
-
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}`;
|
|
165
165
|
open(target).catch((e) => {
|
|
166
166
|
const msg = e instanceof Error ? e.message : String(e);
|
|
167
167
|
process.stdout.write(c.yellow(`Could not auto-open browser (${msg}).\n`) +
|
|
@@ -175,7 +175,7 @@ function parseCallbackBody(body, contentType) {
|
|
|
175
175
|
if (type.includes("application/x-www-form-urlencoded")) {
|
|
176
176
|
const params = new URLSearchParams(body);
|
|
177
177
|
const parsed = {};
|
|
178
|
-
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"]) {
|
|
179
179
|
const value = params.get(key);
|
|
180
180
|
if (value)
|
|
181
181
|
parsed[key] = value;
|
|
@@ -185,5 +185,40 @@ function parseCallbackBody(body, contentType) {
|
|
|
185
185
|
return JSON.parse(body);
|
|
186
186
|
}
|
|
187
187
|
function localCallbackPage(message) {
|
|
188
|
-
|
|
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, "'");
|
|
189
224
|
}
|
package/dist/mcp.js
CHANGED
|
@@ -1,19 +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
|
-
-
|
|
8
|
-
- Shared-link installs work with \`add\` and no account for public or unlisted links.
|
|
9
|
-
- MCP sync requires \`npx -y @floomhq/floom login\` and keeps saved, published, and followed library skills updated locally.`;
|
|
6
|
+
- To find reusable behavior, run \`npx -y @floomhq/floom search <query>\`.
|
|
7
|
+
- MCP sync is optional preview behavior; use it only while the Floom MCP server is configured and running.`;
|
|
10
8
|
process.stdout.write(`\n${c.bold("Floom MCP setup")}\n\n`);
|
|
11
9
|
process.stdout.write(`${c.dim("Pick your tool:")}\n\n`);
|
|
12
|
-
process.stdout.write(`${c.dim("Shared links work with `npx -y @floomhq/floom add` and no account. MCP is for account-backed saved, published, and followed library sync.")}\n\n`);
|
|
13
10
|
process.stdout.write(` ${c.bold("Claude Code")}\n`);
|
|
14
11
|
process.stdout.write(` ${c.cyan("claude mcp add floom -- npx -y @floomhq/floom-mcp-sync")}\n\n`);
|
|
15
12
|
process.stdout.write(` ${c.bold("Codex CLI")}\n`);
|
|
16
|
-
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`);
|
|
17
14
|
process.stdout.write(`${c.dim("Full guide:")} ${c.cyan("https://floom.dev/docs/mcp")}\n\n`);
|
|
18
15
|
process.stdout.write(`${c.dim("Recommended agent instruction snippet:")}\n\n`);
|
|
19
16
|
process.stdout.write(`${snippet}\n\n`);
|