@collectui/bdui 0.1.10 → 0.2.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@collectui/bdui",
3
- "version": "0.1.10",
3
+ "version": "0.2.0",
4
4
  "description": "Add BoardUI components to your project.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/add.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { spawn } from "child_process";
4
4
  import ora from "ora";
5
+ import { readSavedKey, resolveKey } from "./auth.js";
5
6
  import c from "./color.js";
6
7
  import { itemHints } from "./hints.js";
7
8
  import { detectProject, installCommand } from "./project.js";
@@ -60,8 +61,8 @@ export async function cmdAdd(names, flags) {
60
61
  return;
61
62
  }
62
63
 
63
- // 递归收集:name → item(公共 + pro),依赖闭包先装;pro 用 --key
64
- const key = flags.key;
64
+ // 递归收集:name → item(公共 + pro),依赖闭包先装;pro 的 key 三级解析(flag/env/login 落盘)
65
+ const key = resolveKey(flags.key);
65
66
  const resolved = new Map();
66
67
  const missing = [];
67
68
  async function collect(name) {
@@ -177,4 +178,12 @@ export async function cmdAdd(names, flags) {
177
178
  )
178
179
  );
179
180
  }
181
+ // 显式 --key 且本机未存过:一次性引导 login(存过/已用 login 就不再唠叨)
182
+ if (flags.key && !readSavedKey()) {
183
+ console.log(
184
+ c.dim(
185
+ `\nTip: run \`bdui login --key <key>\` once — future commands won't need --key on this machine.`
186
+ )
187
+ );
188
+ }
180
189
  }
package/src/auth.js ADDED
@@ -0,0 +1,124 @@
1
+ // `bdui login/logout` — license key 落盘一次,后续命令自动携带(本机级,0600,不进任何项目仓库)
2
+ // 取 key 优先级:--key 显式 flag > BDUI_KEY env > login 落盘文件
3
+ import {
4
+ chmodSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "fs";
11
+ import os from "os";
12
+ import path from "path";
13
+ import prompts from "prompts";
14
+ import c from "./color.js";
15
+ import { proRegistryBaseUrl } from "./registry.js";
16
+
17
+ // 验证探针:借一个必不存在的条目名打 Pro 端点。服务端路由先鉴权(requireValidKey)
18
+ // 后查条目,所以 404 = key 已通过鉴权,无需下载任何真实条目(零开销验证)。
19
+ const PROBE_NAME = "license-check-probe";
20
+
21
+ export function licenseFile() {
22
+ const dir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
23
+ return path.join(dir, "bdui", "license.json");
24
+ }
25
+
26
+ export function readSavedKey() {
27
+ try {
28
+ return JSON.parse(readFileSync(licenseFile(), "utf8")).key ?? null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /** 统一取 key:flag(显式当次指定永远赢) > env(CI) > login 落盘 */
35
+ export function resolveKey(flagKey) {
36
+ return flagKey ?? process.env.BDUI_KEY ?? readSavedKey();
37
+ }
38
+
39
+ function saveKey(key) {
40
+ const file = licenseFile();
41
+ mkdirSync(path.dirname(file), { recursive: true });
42
+ writeFileSync(
43
+ file,
44
+ `${JSON.stringify({ key, savedAt: new Date().toISOString() }, null, 2)}\n`
45
+ );
46
+ chmodSync(file, 0o600);
47
+ return file;
48
+ }
49
+
50
+ // 401 = key 不存在/无效;423 = 暂停/封禁;429 = 限流;其余(5xx/网络)按"暂不可验证"处理
51
+ async function verifyKey(key) {
52
+ try {
53
+ const res = await fetch(`${proRegistryBaseUrl()}/${PROBE_NAME}`, {
54
+ headers: { Authorization: `Bearer ${key}` },
55
+ });
56
+ if (res.status === 404 || res.status === 200) return { ok: true };
57
+ if (res.status === 401)
58
+ return {
59
+ ok: false,
60
+ message: "Invalid license key — check it and try again.",
61
+ };
62
+ if (res.status === 423)
63
+ return {
64
+ ok: false,
65
+ message:
66
+ "This key is suspended or banned — contact your platform or administrator.",
67
+ };
68
+ if (res.status === 429)
69
+ return {
70
+ ok: false,
71
+ message: "Key check rate-limited — try again in a moment.",
72
+ };
73
+ return {
74
+ ok: false,
75
+ message: `Could not verify the key (registry returned ${res.status}). Try again.`,
76
+ };
77
+ } catch {
78
+ return {
79
+ ok: false,
80
+ message: "Could not reach the registry to verify the key. Try again.",
81
+ };
82
+ }
83
+ }
84
+
85
+ export async function cmdLogin(flags) {
86
+ let key = flags.key?.trim();
87
+ if (!key && process.stdin.isTTY) {
88
+ const r = await prompts({
89
+ type: "password",
90
+ name: "key",
91
+ message: "BoardUI license key:",
92
+ });
93
+ key = r?.key?.trim();
94
+ }
95
+ if (!key)
96
+ throw new Error("No key provided — run: bdui login --key <license-key>");
97
+
98
+ const replaced = Boolean(readSavedKey());
99
+ console.log(`${c.cyan("login")} verifying key…`);
100
+ const verdict = await verifyKey(key);
101
+ if (!verdict.ok) throw new Error(verdict.message);
102
+
103
+ const file = saveKey(key);
104
+ console.log(
105
+ c.green(
106
+ `✓ license key verified${replaced ? " (replaced the previously saved one)" : ""} and saved to ${file}`
107
+ )
108
+ );
109
+ console.log(
110
+ c.dim(
111
+ " add / skill / mcp now work without --key on this machine. Remove it anytime with: bdui logout"
112
+ )
113
+ );
114
+ }
115
+
116
+ export async function cmdLogout() {
117
+ const file = licenseFile();
118
+ if (!existsSync(file)) {
119
+ console.log("No saved license key on this machine.");
120
+ return;
121
+ }
122
+ rmSync(file);
123
+ console.log(c.green(`✓ saved license key removed (${file})`));
124
+ }
package/src/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { Command } from "commander";
3
3
  import { readFileSync } from "fs";
4
4
  import { cmdAdd } from "./add.js";
5
+ import { cmdLogin, cmdLogout } from "./auth.js";
5
6
  import { cmdInit } from "./init.js";
6
7
  import { cmdList } from "./list.js";
7
8
  import { cmdMcp } from "./mcp.js";
@@ -38,13 +39,26 @@ export async function run(argv) {
38
39
  )
39
40
  .action(cmdInit);
40
41
 
42
+ program
43
+ .command("login")
44
+ .description(
45
+ "Save your BoardUI license key once (verified, 0600, this machine only) — add/skill/mcp then work without --key."
46
+ )
47
+ .option("-k, --key <key>", "license key (prompts for it when omitted)")
48
+ .action(cmdLogin);
49
+
50
+ program
51
+ .command("logout")
52
+ .description("Remove the license key saved by `bdui login`.")
53
+ .action(cmdLogout);
54
+
41
55
  program
42
56
  .command("add [components...]")
43
57
  .description("Add components to your project.")
44
58
  .option("-a, --all", "add every component in the registry")
45
59
  .option(
46
60
  "-k, --key <key>",
47
- "license key for Pro components (single command, nothing is saved)"
61
+ "license key for Pro components (or run `bdui login` once to save it on this machine)"
48
62
  )
49
63
  .option(
50
64
  "-p, --path <path>",
@@ -66,11 +80,11 @@ export async function run(argv) {
66
80
  program
67
81
  .command("mcp")
68
82
  .description(
69
- "Start the BoardUI MCP server (stdio) so AI agents can browse and install components. Pass --key to unlock Pro items for the whole session (embed it in the args of your MCP config)."
83
+ "Start the BoardUI MCP server (stdio) so AI agents can browse and install components. Pass --key to unlock Pro items for the whole session, or run `bdui login` once and start it without."
70
84
  )
71
85
  .option(
72
86
  "-k, --key <key>",
73
- "license key for Pro components, active for this server session (nothing is saved)"
87
+ "license key for Pro components, active for this server session (or use the key saved by `bdui login`)"
74
88
  )
75
89
  .action((options) => cmdMcp(options));
76
90
 
@@ -91,7 +105,7 @@ export async function run(argv) {
91
105
  .option("-f, --force", "overwrite an existing install (how you update)")
92
106
  .option(
93
107
  "-k, --key <key>",
94
- "license key when the skill entry is Pro (single command, nothing is saved)"
108
+ "license key when the skill entry is Pro (or use the key saved by `bdui login`)"
95
109
  )
96
110
  .action(cmdSkill);
97
111
 
package/src/hints.js CHANGED
@@ -11,11 +11,12 @@ export function itemHints(item) {
11
11
  const hints = [];
12
12
  const src = (item.files ?? []).map((f) => f.content ?? "").join("\n");
13
13
 
14
- // render 期读当前时间(new Date() / react-aria .toDate()):Next.js 16
15
- // Cache Components 预渲染会炸,官方目前 calendar 系如此
14
+ // render 期读当前时间(new Date() / react-aria .toDate()):在 Next.js
15
+ // Cache Components 下,这类组件参与 prerender 时可能炸 build(是否炸取
16
+ // 决于该组件是否在预渲染路径渲染——实测未渲染于预渲染页时不炸)
16
17
  if (/\bnew Date\(\)|\.toDate\(/.test(src)) {
17
18
  hints.push(
18
- "Note: this item reads the current time while rendering (per current registry source). On Next.js 16 Cache Components, wrap it in <Suspense> or `next build` prerendering fails."
19
+ "Note: this item reads the current time while rendering (per current registry source). Under Next.js Cache Components it can fail `next build` prerendering when it renders during prerender — wrap in <Suspense> if that happens (or preemptively)."
19
20
  );
20
21
  }
21
22
  return hints;
package/src/init.js CHANGED
@@ -182,13 +182,21 @@ export async function cmdInit(flags) {
182
182
  /^\s*import\s+["']\.\/globals\.css["'];?\s*$/.test(l)
183
183
  );
184
184
  const oldGlobals = path.join(project.sourceRoot, "app/globals.css");
185
- const oldIsTw =
186
- existsSync(oldGlobals) &&
187
- readFileSync(oldGlobals, "utf8").includes('@import "tailwindcss"');
188
- if (oldIdx !== -1 && oldIsTw) {
185
+ const oldContent = existsSync(oldGlobals)
186
+ ? readFileSync(oldGlobals, "utf8")
187
+ : "";
188
+ // 接管条件(r3 🔴 实测:no-tailwind CNA 的 body Arial 直接声明压过
189
+ // html 继承的 --font-inter,只追加不接管=全站 Arial):旧 globals 是
190
+ // Tailwind 入口或 CNA 默认存量(--background/--foreground 签名,含
191
+ // tailwind/no-tailwind 两形态)即替换;不像模板的自定义文件才追加
192
+ const oldIsTw = oldContent.includes('@import "tailwindcss"');
193
+ const oldIsCnaStock =
194
+ oldContent.includes("--background") &&
195
+ oldContent.includes("--foreground");
196
+ if (oldIdx !== -1 && (oldIsTw || oldIsCnaStock)) {
189
197
  lines[oldIdx] = 'import "@/styles/globals.css";';
190
198
  console.log(
191
- ` ${c.green("add")} ${rel}: replaced "./globals.css" → "@/styles/globals.css" (old file was a second Tailwind entry; merge custom styles into BoardUI's if needed)`
199
+ ` ${c.green("add")} ${rel}: replaced "./globals.css" → "@/styles/globals.css" (old file was a stock scaffold stylesheet whose body rules would override BoardUI; merge any custom styles into BoardUI's)`
192
200
  );
193
201
  } else {
194
202
  // 插到最后一个顶层 import 之后;没有 import 则插到文件头
@@ -206,6 +214,20 @@ export async function cmdInit(flags) {
206
214
  // 字体接线(r2 P4):theme.css 期望 --font-inter/--font-mono-source 由
207
215
  // next/font 暴露,模板默认 Geist 不定义它们,注入 Inter + JetBrains Mono
208
216
  if (!content.includes("--font-inter")) {
217
+ // 清理模板残留的 Geist 字体(注入 Inter 后纯属浪费带宽,r3 伴随问题)
218
+ const geistImport =
219
+ 'import { Geist, Geist_Mono } from "next/font/google";';
220
+ if (content.includes(geistImport) && content.includes("geistSans")) {
221
+ content = content
222
+ .replace(geistImport + "\n", "")
223
+ .replace(/const geistSans = Geist\(\{[\s\S]*?\}\);?\n+/, "")
224
+ .replace(/const geistMono = Geist_Mono\(\{[\s\S]*?\}\);?\n+/, "")
225
+ .replace(/\$\{geistSans\.variable\}\s*/g, "")
226
+ .replace(/\$\{geistMono\.variable\}\s*/g, "");
227
+ console.log(
228
+ ` ${c.green("add")} ${rel}: removed leftover Geist fonts (Inter takes over)`
229
+ );
230
+ }
209
231
  const lines = content.split("\n");
210
232
  let at = 0;
211
233
  for (let i = 0; i < lines.length; i++)
package/src/mcp.js CHANGED
@@ -8,6 +8,7 @@ import path from "path";
8
8
  import { createInterface } from "readline";
9
9
  import { z } from "zod";
10
10
  import { cmdAdd } from "./add.js";
11
+ import { licenseFile, readSavedKey } from "./auth.js";
11
12
  import { itemHints } from "./hints.js";
12
13
  import { detectProject } from "./project.js";
13
14
  import { fetchIndex, fetchItem } from "./registry.js";
@@ -38,7 +39,7 @@ const SCHEMAS = {
38
39
  .string()
39
40
  .optional()
40
41
  .describe(
41
- "License key for Pro items; optional when the server was started with --key."
42
+ "License key for Pro items; optional when the server was started with --key or a key was saved via `bdui login`."
42
43
  ),
43
44
  }),
44
45
  get_usage_examples: z.object({
@@ -51,7 +52,7 @@ const SCHEMAS = {
51
52
  .string()
52
53
  .optional()
53
54
  .describe(
54
- "License key for Pro items (same as `add --key`); optional when the server was started with --key."
55
+ "License key for Pro items (same as `add --key`); optional when the server was started with --key or a key was saved via `bdui login`."
55
56
  ),
56
57
  overwrite: z.boolean().optional(),
57
58
  installDependencies: z.boolean().optional(),
@@ -118,8 +119,8 @@ function groupLabel(t) {
118
119
  );
119
120
  }
120
121
 
121
- // serverKey:`bdui mcp --key <key>` 启动内嵌(.mcp.json args),整会话兜底;
122
- // 调用级 key 参数优先。两者都不落盘,请求结束即消失。
122
+ // serverKey:`bdui mcp --key <key>` 启动内嵌(.mcp.json args)或 `bdui login` 落盘,
123
+ // 整会话兜底;调用级 key 参数优先。
123
124
  async function loadItem(name, key, serverKey) {
124
125
  return fetchItem(name, key ?? serverKey);
125
126
  }
@@ -137,10 +138,9 @@ async function dispatch(name, args, captured, serverKey) {
137
138
  "3. get_usage_examples — working code for an item.",
138
139
  "4. install_components — writes files, resolves internal deps recursively, installs npm deps.",
139
140
  "",
140
- "Pro items need the license key. Two ways, both leave nothing stored on this machine:",
141
- "- Embed it in this server's config once: `npx -y @collectui/bdui mcp --key <key>` (the args block of your MCP config) — every tool call in the session is unlocked.",
142
- "- Or pass it per call as `key` to install_components (same as the CLI's `add --key <license-key>`).",
143
- "CLI equivalents: npx @collectui/bdui init | list | add <name> [--key <key>] | skill | mcp [--key <key>].",
141
+ "Pro items need a license key. Recommended: save it once with `npx -y @collectui/bdui login --key <key>` — stored at ~/.config/bdui/license.json (0600, this machine only), and every later CLI command and MCP session is unlocked without passing anything.",
142
+ "Alternatives (nothing stored): pass `key` per call to install_components (same as the CLI's `add --key <license-key>`), or start this server with `npx -y @collectui/bdui mcp --key <key>` (embed it in the args block of your MCP config).",
143
+ "CLI equivalents: npx @collectui/bdui init | list | add <name> [--key <key>] | skill | mcp [--key <key>] | login | logout.",
144
144
  ].join("\n");
145
145
 
146
146
  case "list_components": {
@@ -369,12 +369,14 @@ async function call(name, args, serverKey) {
369
369
  }
370
370
 
371
371
  export async function cmdMcp(options = {}) {
372
- const serverKey = options.key ?? undefined; // `bdui mcp --key <key>`:整会话兜底,见 loadItem 注释
373
- // stderr 提示(stdio 协议,stdout 必须干净):key 常嵌 .mcp.json,防 commit 泄漏(盲测 #3)
374
- if (serverKey)
372
+ const serverKey = options.key ?? readSavedKey(); // `bdui mcp --key` 内嵌 or `bdui login` 落盘,见 loadItem 注释
373
+ // stderr 提示(stdio 协议,stdout 必须干净):显式 --key 常嵌 .mcp.json,防 commit 泄漏(盲测 #3)
374
+ if (options.key)
375
375
  console.error(
376
376
  "[bdui] tip: if this key is embedded in .mcp.json, keep that file out of git (add it to .gitignore)."
377
377
  );
378
+ else if (serverKey)
379
+ console.error(`[bdui] using saved key from ${licenseFile()}`);
378
380
  const rl = createInterface({ input: process.stdin });
379
381
  const write = (obj) => process.stdout.write(JSON.stringify(obj) + "\n");
380
382
 
package/src/registry.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Registry 拉取 — shadcn registry.json schema 兼容
2
2
  // 公共: GET {BASE}/r/registry.json 与 /r/<name>.json (免鉴权)
3
- // Pro: GET {BASE}/api/pro/r/<name> Bearer <key>(CLI `add --key` 传入 / MCP server `mcp --key` 启动内嵌;校验/缓存/熔断全在服务端)
4
- // 端点内置,不提供 env 覆盖,本机不存任何状态。
3
+ // Pro: GET {BASE}/api/pro/r/<name> Bearer <key>(resolveKey 三级解析:--key flag > BDUI_KEY env > `bdui login` 落盘;校验/缓存/熔断全在服务端)
4
+ // 端点内置,不提供 env 覆盖。
5
5
  const BASE = "https://boardui.collectui.pro";
6
6
 
7
7
  export const registryBaseUrl = () => `${BASE}/r`;
@@ -43,7 +43,7 @@ export async function fetchItem(name, key) {
43
43
  if (error?.status !== 404 && error?.status !== 401) throw error;
44
44
  if (!key) {
45
45
  throw new Error(
46
- `"${name}" is a BoardUI Pro component. Pass --key <license-key> to install it (CLI), or start the MCP server with --key <key>. The key is distributed by your platform or administrator.`
46
+ `"${name}" is a BoardUI Pro component. Run \`bdui login --key <license-key>\` once to save your key on this machine, or pass --key <license-key> to this command. The key is distributed by your platform or administrator.`
47
47
  );
48
48
  }
49
49
  const response = await fetch(`${proRegistryBaseUrl()}/${name}`, {
package/src/skill.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // `bdui skill` — 从 registry 拉 skill 条目(SKILL.md + references)写入目标目录
2
2
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "fs";
3
3
  import path from "path";
4
+ import { resolveKey } from "./auth.js";
4
5
  import c from "./color.js";
5
6
  import { fetchItem } from "./registry.js";
6
7
 
@@ -15,7 +16,7 @@ export async function cmdSkill(flags) {
15
16
  target = path.join(home, ".agents", "skills", "boardui");
16
17
  else target = path.join(process.cwd(), ".claude", "skills", "boardui");
17
18
 
18
- const skill = await fetchItem("skill", flags.key);
19
+ const skill = await fetchItem("skill", resolveKey(flags.key));
19
20
  if (existsSync(target) && !flags.force) {
20
21
  throw new Error(
21
22
  `${target} already exists — pass --force to overwrite (how you update).`
package/src/write.js CHANGED
@@ -31,9 +31,15 @@ export async function writeItemFiles(item, outRoot, flags = {}) {
31
31
  let wroteStyles = false;
32
32
  const state = { all: false };
33
33
  for (const file of item.files ?? []) {
34
+ // 空元素/空路径守卫:官方条目实发过 assets:[null] 同型数据(2026-11-06
35
+ // sidebar 实测 EISDIR),空 path 会让 target 落在 outRoot/public 目录本身
36
+ if (!file?.path) {
37
+ console.log(` ${pc.yellow("skip")} file entry without a path`);
38
+ continue;
39
+ }
34
40
  // 路径守卫:file.path 不得逃逸 outRoot(防 ../ 遍历;信任官方 registry
35
41
  // 前提下是纵深防御,与 shadcn 同水位再进一步)
36
- const rel = path.normalize(file.path ?? "");
42
+ const rel = path.normalize(file.path);
37
43
  if (rel.startsWith("..") || path.isAbsolute(rel)) {
38
44
  console.log(` ${pc.red("skip")} unsafe path in item: ${file.path}`);
39
45
  continue;
@@ -56,7 +62,13 @@ export async function writeItemFiles(item, outRoot, flags = {}) {
56
62
  }
57
63
  // 二进制 assets(base64):Pro 模板的头像/logo 等图片资源(盲测 r2 P1)
58
64
  for (const asset of item.assets ?? []) {
59
- const rel = path.normalize(asset.path ?? "");
65
+ // 空元素/空路径守卫:官方 sidebar 条目实发 assets:[null]——null 直接读 path
66
+ // 抛 TypeError,空 path 则 target 落在 public 目录本身 writeFileSync 抛 EISDIR
67
+ if (!asset?.path) {
68
+ console.log(` ${pc.yellow("skip")} asset entry without a path`);
69
+ continue;
70
+ }
71
+ const rel = path.normalize(asset.path);
60
72
  if (rel.startsWith("..") || path.isAbsolute(rel)) {
61
73
  console.log(` ${pc.red("skip")} unsafe path in asset: ${asset.path}`);
62
74
  continue;