@monoedge/jdu-cli 0.2.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +9 -5
  2. package/dist/cli.js +1576 -82
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2,12 +2,13 @@
2
2
  import { EventEmitter } from "node:events";
3
3
  import childProcess, { spawn } from "node:child_process";
4
4
  import path, { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
5
- import fs, { chmodSync, createReadStream, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
5
+ import fs, { chmodSync, createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
6
6
  import process$1 from "node:process";
7
7
  import { stripVTControlCharacters } from "node:util";
8
8
  import { createHash, randomBytes } from "node:crypto";
9
- import { homedir } from "node:os";
9
+ import { homedir, hostname } from "node:os";
10
10
  import { createServer } from "node:http";
11
+ import { createInterface } from "node:readline";
11
12
  import { readFile, readdir } from "node:fs/promises";
12
13
  //#region ../node_modules/.pnpm/commander@15.0.0/node_modules/commander/lib/error.js
13
14
  /**
@@ -2975,7 +2976,7 @@ function sha256File(path) {
2975
2976
  rs.on("end", () => res(h.digest("hex")));
2976
2977
  });
2977
2978
  }
2978
- var MIME = Object.freeze({
2979
+ var MIME$1 = Object.freeze({
2979
2980
  ".md": "text/markdown; charset=utf-8",
2980
2981
  ".markdown": "text/markdown; charset=utf-8",
2981
2982
  ".txt": "text/plain; charset=utf-8",
@@ -3002,7 +3003,7 @@ var MIME = Object.freeze({
3002
3003
  ".woff2": "font/woff2"
3003
3004
  });
3004
3005
  function guessMime(path) {
3005
- return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
3006
+ return MIME$1[extname(path).toLowerCase()] ?? "application/octet-stream";
3006
3007
  }
3007
3008
  /**
3008
3009
  * 增量上传:先用 hash 清单跟 server 协商,只 PUT 缺失的。
@@ -3271,6 +3272,8 @@ function readStoredConfig() {
3271
3272
  const out = {};
3272
3273
  if (typeof obj.server === "string") out.server = obj.server;
3273
3274
  if (typeof obj.token === "string") out.token = obj.token;
3275
+ if (typeof obj.user === "string") out.user = obj.user;
3276
+ if (typeof obj.proxySecret === "string") out.proxySecret = obj.proxySecret;
3274
3277
  const oidc = readOidc(obj.oidc);
3275
3278
  if (oidc) out.oidc = oidc;
3276
3279
  return out;
@@ -3280,6 +3283,8 @@ function saveConfig(next) {
3280
3283
  mkdirSync(dirname(path), { recursive: true });
3281
3284
  const body = { server: next.server };
3282
3285
  if (next.token !== void 0) body.token = next.token;
3286
+ if (next.user !== void 0) body.user = next.user;
3287
+ if (next.proxySecret !== void 0) body.proxySecret = next.proxySecret;
3283
3288
  if (next.oidc !== void 0) body.oidc = next.oidc;
3284
3289
  writeFileSync(path, `${JSON.stringify(body, null, 2)}\n`, { mode: 384 });
3285
3290
  return path;
@@ -3290,9 +3295,13 @@ function loadConfig() {
3290
3295
  const server = (process.env.JIANDU_SERVER ?? stored.server ?? "").trim().replace(/\/+$/, "");
3291
3296
  const rawToken = process.env.JIANDU_TOKEN ?? stored.token;
3292
3297
  if (server === "") throw new CliError("未配置 jiandu server 地址", "执行 jdu login --server <url>,或设置环境变量 JIANDU_SERVER");
3298
+ const user = process.env.JIANDU_FORWARD_AUTH_USER ?? stored.user;
3299
+ const proxySecret = process.env.JIANDU_PROXY_SECRET ?? stored.proxySecret;
3293
3300
  const cfg = {
3294
3301
  server,
3295
- token: rawToken && rawToken.length > 0 ? rawToken : void 0
3302
+ token: rawToken && rawToken.length > 0 ? rawToken : void 0,
3303
+ user: user && user.length > 0 ? user : void 0,
3304
+ proxySecret: proxySecret && proxySecret.length > 0 ? proxySecret : void 0
3296
3305
  };
3297
3306
  if (stored.oidc) cfg.oidc = stored.oidc;
3298
3307
  return cfg;
@@ -3316,15 +3325,86 @@ async function loadRuntimeConfig() {
3316
3325
  return cfg;
3317
3326
  }
3318
3327
  //#endregion
3328
+ //#region src/comments.ts
3329
+ var QUOTE_MAX = 120;
3330
+ function when(ms) {
3331
+ return new Date(ms).toISOString().slice(0, 16).replace("T", " ");
3332
+ }
3333
+ /** owner 是 UUID 且没有显示名时 authorLabel 为空串,退回原始 id 也比空着好认。 */
3334
+ function who(c) {
3335
+ return `${c.authorLabel || c.author}${c.authorType === "ai" ? " [ai]" : ""}`;
3336
+ }
3337
+ function indent(text, pad) {
3338
+ return text.split("\n").map((line) => `${pad}${line}`).join("\n");
3339
+ }
3340
+ /**
3341
+ * 线程列表。stdout 纯文本、一线程一段,给 agent 直接读:
3342
+ * 首行 `<threadId> <blockId> <open|resolved> <作者> <时间> [v<seq>]`,引文一行 `> …`,正文缩进两格,回复 `↳` 缩进。
3343
+ */
3344
+ async function listComments(api, docId, opts) {
3345
+ const res = await api.getJson(`/api/docs/${encodeURIComponent(docId)}/comments`);
3346
+ const all = Array.isArray(res?.comments) ? res.comments : [];
3347
+ const roots = all.filter((c) => c.parentId === null && (opts.all || !c.resolved));
3348
+ if (roots.length === 0) {
3349
+ process.stdout.write(`${opts.all ? "(无评论)" : "(无未处理评论)"}\n`);
3350
+ return;
3351
+ }
3352
+ const replies = /* @__PURE__ */ new Map();
3353
+ for (const c of all) {
3354
+ if (c.parentId === null) continue;
3355
+ const list = replies.get(c.parentId) ?? [];
3356
+ list.push(c);
3357
+ replies.set(c.parentId, list);
3358
+ }
3359
+ const out = [];
3360
+ for (const root of roots) {
3361
+ const head = [
3362
+ root.id,
3363
+ root.blockId ?? "-",
3364
+ root.resolved ? "resolved" : "open",
3365
+ who(root),
3366
+ when(root.createdAt)
3367
+ ];
3368
+ if (root.versionSeq !== null) head.push(`v${root.versionSeq}`);
3369
+ out.push(head.join(" "));
3370
+ const quote = root.selection?.quote;
3371
+ if (quote) out.push(` > ${quote.length > QUOTE_MAX ? `${quote.slice(0, QUOTE_MAX)}…` : quote}`);
3372
+ out.push(indent(root.body, " "));
3373
+ for (const reply of replies.get(root.id) ?? []) {
3374
+ out.push(` ↳ ${reply.id} ${who(reply)} ${when(reply.createdAt)}`);
3375
+ out.push(indent(reply.body, " "));
3376
+ }
3377
+ out.push("");
3378
+ }
3379
+ process.stdout.write(out.join("\n"));
3380
+ }
3381
+ async function replyComment(api, threadId, opts) {
3382
+ const res = await api.postJson(`/api/comments/${encodeURIComponent(threadId)}/reply`, {
3383
+ body: opts.message,
3384
+ authorType: opts.ai ? "ai" : "human"
3385
+ });
3386
+ process.stdout.write(`replied ${String(res?.parentId ?? threadId)} → ${String(res?.id ?? "")}\n`);
3387
+ }
3388
+ async function resolveComment(api, threadId, opts) {
3389
+ const res = await api.postJson(`/api/comments/${encodeURIComponent(threadId)}/resolve`, { resolved: !opts.undo });
3390
+ process.stdout.write(`${opts.undo ? "reopened" : "resolved"} ${String(res?.id ?? threadId)}\n`);
3391
+ }
3392
+ //#endregion
3319
3393
  //#region src/http.ts
3320
3394
  /** 错误响应体太长会淹没终端,只留头部。 */
3321
3395
  var MAX_DETAIL = 400;
3322
3396
  var ApiClient = class {
3323
3397
  server;
3324
3398
  token;
3399
+ /** forward-auth 直连身份:作为 X-Forwarded-User 注入(server 默认 userHeader) */
3400
+ user;
3401
+ /** forward-auth fail-closed 的共享密钥:作为 X-Jiandu-Proxy-Secret 注入 */
3402
+ proxySecret;
3325
3403
  constructor(cfg) {
3326
3404
  this.server = cfg.server;
3327
3405
  this.token = cfg.token;
3406
+ this.user = cfg.user;
3407
+ this.proxySecret = cfg.proxySecret;
3328
3408
  }
3329
3409
  url(path) {
3330
3410
  return `${this.server}${path}`;
@@ -3341,6 +3421,10 @@ var ApiClient = class {
3341
3421
  async deleteJson(path) {
3342
3422
  return this.json("DELETE", path, void 0, void 0);
3343
3423
  }
3424
+ /** 文本路由(`/d/:id.md`):原样返回正文。 */
3425
+ async getText(path) {
3426
+ return (await this.send("GET", path, void 0, void 0)).text();
3427
+ }
3344
3428
  /** widget push 走 multipart:boundary 交给 fetch 生成,别自己设 Content-Type。 */
3345
3429
  async postForm(path, form) {
3346
3430
  const text = await (await this.send("POST", path, form, void 0)).text();
@@ -3368,6 +3452,8 @@ var ApiClient = class {
3368
3452
  const url = this.url(path);
3369
3453
  const headers = {};
3370
3454
  if (this.token !== void 0) headers.Authorization = `Bearer ${this.token}`;
3455
+ if (this.user !== void 0) headers["X-Forwarded-User"] = this.user;
3456
+ if (this.proxySecret !== void 0) headers["X-Jiandu-Proxy-Secret"] = this.proxySecret;
3371
3457
  if (contentType !== void 0) headers["Content-Type"] = contentType;
3372
3458
  let res;
3373
3459
  try {
@@ -3409,11 +3495,72 @@ async function httpError(method, url, res) {
3409
3495
  const msg = obj.error ?? obj.message;
3410
3496
  if (typeof msg === "string" && msg !== "") detail = msg;
3411
3497
  } catch {}
3412
- const hint = res.status === 401 || res.status === 403 ? "token 无效或无权访问,执行 jdu login --server <url>" : res.status === 302 ? "网关要登录。forward-auth 下先 jdu login;若已登录,网关需接受 Authorization: Bearer" : void 0;
3498
+ const hint = res.status === 401 || res.status === 403 ? "token 无效或无权访问,执行 jdu login --server <url>;forward-auth 下确认已设 JIANDU_FORWARD_AUTH_USER 与 JIANDU_PROXY_SECRET" : res.status === 302 ? "网关要登录。forward-auth 下先 jdu login;若已登录,网关需接受 Authorization: Bearer" : void 0;
3413
3499
  const suffix = detail === "" ? "" : `:${detail}`;
3414
3500
  return new CliError(`${method} ${url} 返回 HTTP ${res.status} ${res.statusText}${suffix}`, hint);
3415
3501
  }
3416
3502
  //#endregion
3503
+ //#region src/browser-auth.ts
3504
+ /**
3505
+ * jdu init 的浏览器授权(#66):本机起一个 loopback 回调,打开 server 下发的 authorizeUrl,
3506
+ * 人在浏览器里用 passkey 会话点一次「授权」,回跳带回一次性 code,再用 PKCE verifier 向 server 换 token。
3507
+ *
3508
+ * authorizeUrl 与 server 可能不同源(CLI 连 127.0.0.1:18082,浏览器开 https://md.mason.local)——
3509
+ * 浏览器去前者,code 换 token 打后者。回调端口随机(server 接受任意 loopback 端口),不和 OIDC 的 8085 抢。
3510
+ */
3511
+ async function browserAuthorize(input) {
3512
+ const { verifier, challenge } = pkce();
3513
+ const state = randomBytes(16).toString("base64url");
3514
+ const code = await new Promise((resolve, reject) => {
3515
+ const finish = (fn) => (value) => {
3516
+ clearTimeout(timer);
3517
+ server.close();
3518
+ fn(value);
3519
+ };
3520
+ const fail = finish((msg) => reject(new CliError(msg, "重新执行 jdu init;不想开浏览器就带 --token")));
3521
+ const handler = callbackHandler(state, finish(resolve));
3522
+ const server = createServer((req, res) => {
3523
+ if (new URL(req.url ?? "/", "http://localhost").pathname !== "/callback") {
3524
+ res.writeHead(404).end();
3525
+ return;
3526
+ }
3527
+ handler(req, res);
3528
+ });
3529
+ const timer = setTimeout(() => fail("等待浏览器授权超时"), input.timeoutMs ?? 18e4);
3530
+ server.on("error", (err) => fail(`监听 loopback 回调失败:${err.message}`));
3531
+ server.listen(0, "127.0.0.1", () => {
3532
+ const port = server.address().port;
3533
+ const url = new URL(input.authorizeUrl);
3534
+ url.search = new URLSearchParams({
3535
+ state,
3536
+ code_challenge: challenge,
3537
+ port: String(port),
3538
+ label: input.label ?? hostname()
3539
+ }).toString();
3540
+ process.stderr.write(`在浏览器中完成授权:${url.toString()}\n`);
3541
+ (input.open ?? openBrowser)(url.toString());
3542
+ });
3543
+ });
3544
+ const res = await fetch(`${input.server}/api/cli/token`, {
3545
+ method: "POST",
3546
+ headers: { "content-type": "application/json" },
3547
+ body: JSON.stringify({
3548
+ code,
3549
+ code_verifier: verifier
3550
+ })
3551
+ });
3552
+ const text = await res.text();
3553
+ if (!res.ok) throw new CliError(`code 换 token 失败(HTTP ${res.status}):${text.slice(0, 200)}`, "重新执行 jdu init 再试一次");
3554
+ let token;
3555
+ try {
3556
+ token = JSON.parse(text).token;
3557
+ } catch {
3558
+ token = void 0;
3559
+ }
3560
+ if (typeof token !== "string" || token === "") throw new CliError("server 没有返回 token");
3561
+ return token;
3562
+ }
3563
+ //#endregion
3417
3564
  //#region src/login.ts
3418
3565
  var OIDC_PARAM_HINT = `传入 --issuer / --client-id,或在 server 配置 auth.oidc(healthz 会带出来),或设置 ${OIDC_ISSUER_ENV} / ${OIDC_CLIENT_ID_ENV}`;
3419
3566
  function resolveOidc(input) {
@@ -3454,6 +3601,15 @@ async function runLogin(opts) {
3454
3601
  if (server === "") throw new CliError("--server 不能为空");
3455
3602
  const healthz = await (opts.fetchHealthz ?? fetchHealthz)(server);
3456
3603
  const provider = healthz.authProvider ?? "token";
3604
+ if (opts.user !== void 0 && opts.user.trim() !== "") return {
3605
+ configPath: saveConfig({
3606
+ server,
3607
+ user: opts.user.trim(),
3608
+ proxySecret: opts.proxySecret && opts.proxySecret !== "" ? opts.proxySecret : void 0
3609
+ }),
3610
+ source: "forward-auth",
3611
+ warning: provider !== "forward-auth" ? `这个 server 是 ${provider} 鉴权,--user 只对 forward-auth 生效;当前请求仍会带 X-Forwarded-User 头(无害)。` : void 0
3612
+ };
3457
3613
  if (opts.token !== void 0 && opts.token !== "") return {
3458
3614
  configPath: saveConfig({
3459
3615
  server,
@@ -3465,7 +3621,7 @@ async function runLogin(opts) {
3465
3621
  configPath: saveConfig({ server }),
3466
3622
  source: "anonymous"
3467
3623
  };
3468
- if (provider === "token") throw new CliError("这个 server 用 token 鉴权,需要 --token", "jdu login --server <url> --token <token>,token 在 server 首次启动的 stdout / data/initial-token.txt");
3624
+ if (provider === "token" || provider === "password") throw new CliError(`这个 server 用 ${provider} 鉴权,CLI 需要 --token`, "jdu init --server <url>(站点支持时打开浏览器授权),或 jdu login --server <url> --token <token>(token 在 server 首启 stdout / data/initial-token.txt");
3469
3625
  const oidc = resolveOidc({
3470
3626
  issuer: opts.issuer,
3471
3627
  clientId: opts.clientId,
@@ -3501,31 +3657,117 @@ async function runLogin(opts) {
3501
3657
  };
3502
3658
  }
3503
3659
  //#endregion
3504
- //#region src/output.ts
3505
- /** server 可能返回裸数组,也可能包一层 `{ docs: [...] }`,两种都认。 */
3506
- function asRows(payload, key) {
3507
- const raw = Array.isArray(payload) ? payload : payload !== null && typeof payload === "object" ? payload[key] : void 0;
3508
- if (!Array.isArray(raw)) return [];
3509
- return raw.filter((r) => r !== null && typeof r === "object");
3510
- }
3511
- function cell(value) {
3512
- if (value === null || value === void 0) return "-";
3513
- if (typeof value === "boolean") return value ? "yes" : "no";
3514
- if (typeof value === "number" && value > 0xe8d4a51000) return new Date(value).toISOString().slice(0, 16).replace("T", " ");
3515
- if (typeof value === "object") return JSON.stringify(value);
3516
- return String(value);
3660
+ //#region src/init.ts
3661
+ /**
3662
+ * jdu init:选定部署目标并写好本地配置,一条命令把「指向哪台简牍」定下来。
3663
+ *
3664
+ * 目标三选一:
3665
+ * (默认) 官方线上服务 https://docs.mszhou.com(OFFICIAL_SERVER)
3666
+ * --local 本机自部署(http://127.0.0.1:8080)
3667
+ * --server <u> 任意自部署地址
3668
+ *
3669
+ * agent 联动而设计:全 flag 驱动、无交互提示、--json 输出机器可读结果,
3670
+ * 拿到 next 字段就知道下一步该干什么(要不要 token、去哪拿)。
3671
+ *
3672
+ * 登录方式听 server 的(healthz.cliAuth,#66):声明了 browser 就开浏览器授权换 token;
3673
+ * 只有 token 就把 server 给的 tokenHint 拼进 next。CLI 不按「官方 / 自部署」猜。
3674
+ */
3675
+ /** 官方线上地址。password 鉴权:浏览器 passkey,CLI 走 token,token 向站点管理员申领。 */
3676
+ var OFFICIAL_SERVER = "https://docs.mszhou.com";
3677
+ var LOCAL_SERVER = "http://127.0.0.1:8080";
3678
+ function resolveInitTarget(opts) {
3679
+ if (opts.local && opts.server) throw new CliError("--local 与 --server 只能二选一");
3680
+ if (opts.server !== void 0) {
3681
+ const server = opts.server.trim().replace(/\/+$/, "");
3682
+ let parsed;
3683
+ try {
3684
+ parsed = new URL(server);
3685
+ } catch {
3686
+ throw new CliError(`--server 不是合法 URL:${opts.server}`);
3687
+ }
3688
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || /[\s'"`$\\;|&<>(){}*?#\[\]]/.test(server)) throw new CliError(`--server 需要干净的 http/https 地址(不含空白与 shell 元字符):${opts.server}`);
3689
+ return {
3690
+ target: "custom",
3691
+ server
3692
+ };
3693
+ }
3694
+ if (opts.local) return {
3695
+ target: "local",
3696
+ server: LOCAL_SERVER
3697
+ };
3698
+ return {
3699
+ target: "official",
3700
+ server: OFFICIAL_SERVER
3701
+ };
3517
3702
  }
3518
- /** 定宽文本表,便于人和 agent 直接读;无数据时给一行提示而不是空输出。 */
3519
- function printTable(rows, columns) {
3520
- if (rows.length === 0) {
3521
- process.stdout.write("(空)\n");
3522
- return;
3703
+ async function runInit(opts) {
3704
+ const { target, server } = resolveInitTarget(opts);
3705
+ const healthz = await (opts.fetchHealthz ?? fetchHealthz)(server);
3706
+ const provider = healthz.authProvider ?? "token";
3707
+ const methods = healthz.cliAuth?.methods ?? fallbackMethods(provider);
3708
+ const base = {
3709
+ target,
3710
+ server,
3711
+ authProvider: provider,
3712
+ authMethods: methods
3713
+ };
3714
+ if (opts.token !== void 0 && opts.token !== "") {
3715
+ if (!await (opts.probe ?? probeApiWithBearer)(server, opts.token)) throw new CliError("token 校验失败(/api/docs 未放行)", "确认 token 正确、且网关放行 Authorization: Bearer");
3716
+ const path = saveConfig({
3717
+ server,
3718
+ token: opts.token
3719
+ });
3720
+ return {
3721
+ ...base,
3722
+ loggedIn: true,
3723
+ configPath: path,
3724
+ next: null
3725
+ };
3523
3726
  }
3524
- const body = rows.map((row) => columns.map((c) => cell(row[c.key])));
3525
- const widths = columns.map((c, i) => Math.max(c.header.length, ...body.map((r) => (r[i] ?? "").length)));
3526
- const line = (cells) => cells.map((v, i) => v.padEnd(widths[i] ?? 0)).join(" ").trimEnd();
3527
- process.stdout.write(`${line(columns.map((c) => c.header))}\n`);
3528
- for (const r of body) process.stdout.write(`${line(r)}\n`);
3727
+ const oidc = healthz.oidc?.issuer && healthz.oidc.clientId ? {
3728
+ issuer: healthz.oidc.issuer,
3729
+ clientId: healthz.oidc.clientId
3730
+ } : void 0;
3731
+ const path = saveConfig(oidc ? {
3732
+ server,
3733
+ oidc
3734
+ } : { server });
3735
+ if (provider === "anonymous") return {
3736
+ ...base,
3737
+ loggedIn: true,
3738
+ configPath: path,
3739
+ next: null
3740
+ };
3741
+ const authorizeUrl = healthz.cliAuth?.authorizeUrl;
3742
+ if (opts.browser && methods.includes("browser") && authorizeUrl) {
3743
+ const withToken = saveConfig({
3744
+ server,
3745
+ token: await (opts.authorize ?? browserAuthorize)({
3746
+ server,
3747
+ authorizeUrl
3748
+ })
3749
+ });
3750
+ return {
3751
+ ...base,
3752
+ loggedIn: true,
3753
+ configPath: withToken,
3754
+ next: null
3755
+ };
3756
+ }
3757
+ const viaToken = `jdu init --server '${server}' --token <token>(${healthz.cliAuth?.tokenHint ?? "token 在 server 首启 stdout / data/initial-token.txt"})`;
3758
+ const next = provider === "forward-auth" ? `jdu login --server '${server}'(浏览器 SSO),或 ${viaToken}` : methods.includes("browser") ? `jdu init --server '${server}'(不带 --json / --no-browser,打开浏览器授权),或 ${viaToken}` : viaToken;
3759
+ return {
3760
+ ...base,
3761
+ loggedIn: false,
3762
+ configPath: path,
3763
+ next
3764
+ };
3765
+ }
3766
+ /** 老 server 的 healthz 没有 cliAuth:按 provider 推断,与 server 侧 cliAuthOf 的保守档一致。 */
3767
+ function fallbackMethods(provider) {
3768
+ if (provider === "anonymous") return [];
3769
+ if (provider === "forward-auth") return ["oidc", "token"];
3770
+ return ["token"];
3529
3771
  }
3530
3772
  /** 只取链接/图片的目标部分,标签内容可以任意嵌套,不参与匹配。 */
3531
3773
  var MD_DEST_RE = /\]\(([^)]*)\)/g;
@@ -3644,11 +3886,8 @@ function collectReferences(entryPath, maxDepth = 10) {
3644
3886
  }
3645
3887
  //#endregion
3646
3888
  //#region src/push.ts
3647
- var VISIBILITIES = [
3648
- "private",
3649
- "link",
3650
- "public"
3651
- ];
3889
+ /** 可见性三档。link 档已废除:定向分享走读者池(jdu share --to)。 */
3890
+ var VISIBILITIES = ["private", "public"];
3652
3891
  function log(line) {
3653
3892
  process.stderr.write(`${line}\n`);
3654
3893
  }
@@ -3661,6 +3900,7 @@ function inferTitle(entryPath) {
3661
3900
  } catch {}
3662
3901
  return basename(entryPath, extname(entryPath));
3663
3902
  }
3903
+ /** 收集 → 协商上传 → 发布。进度走 stderr;结果返回给调用方(CLI 打印 URL,mcp 转成 JSON)。 */
3664
3904
  async function pushDoc(api, entryArg, opts) {
3665
3905
  const visibility = opts.visibility;
3666
3906
  if (visibility !== void 0 && !VISIBILITIES.includes(visibility)) throw new CliError(`不支持的 visibility:${String(opts.visibility)}`, `可选值:${VISIBILITIES.join(" | ")}`);
@@ -3700,17 +3940,433 @@ async function pushDoc(api, entryArg, opts) {
3700
3940
  const res = await api.postJson("/api/docs", body);
3701
3941
  const id = typeof res?.id === "string" ? res.id : opts.id;
3702
3942
  const url = typeof res?.url === "string" && res.url !== "" ? res.url : id !== void 0 ? api.url(`/d/${id}`) : void 0;
3943
+ const warnings = Array.isArray(res?.warnings) ? res.warnings.map(String) : [];
3944
+ for (const w of warnings) log(`warn: ${w}`);
3703
3945
  if (typeof res?.seq === "number") log(`已发布 ${id ?? ""} v${res.seq}`);
3704
3946
  if (url === void 0) throw new CliError("server 未返回文档 id 或 url,无法给出访问地址");
3705
- process.stdout.write(`${url}\n`);
3947
+ return {
3948
+ id,
3949
+ seq: typeof res?.seq === "number" ? res.seq : void 0,
3950
+ url,
3951
+ warnings
3952
+ };
3706
3953
  }
3707
3954
  //#endregion
3708
- //#region src/widget.ts
3709
- var SCOPES = [
3710
- "official",
3711
- "team",
3712
- "personal"
3955
+ //#region src/output.ts
3956
+ /** server 可能返回裸数组,也可能包一层 `{ docs: [...] }`,两种都认。 */
3957
+ function asRows(payload, key) {
3958
+ const raw = Array.isArray(payload) ? payload : payload !== null && typeof payload === "object" ? payload[key] : void 0;
3959
+ if (!Array.isArray(raw)) return [];
3960
+ return raw.filter((r) => r !== null && typeof r === "object");
3961
+ }
3962
+ function cell(value) {
3963
+ if (value === null || value === void 0) return "-";
3964
+ if (typeof value === "boolean") return value ? "yes" : "no";
3965
+ if (typeof value === "number" && value > 0xe8d4a51000) return new Date(value).toISOString().slice(0, 16).replace("T", " ");
3966
+ if (typeof value === "object") return JSON.stringify(value);
3967
+ return String(value);
3968
+ }
3969
+ /** 定宽文本表,便于人和 agent 直接读;无数据时给一行提示而不是空输出。 */
3970
+ function printTable(rows, columns) {
3971
+ if (rows.length === 0) {
3972
+ process.stdout.write("(空)\n");
3973
+ return;
3974
+ }
3975
+ const body = rows.map((row) => columns.map((c) => cell(row[c.key])));
3976
+ const widths = columns.map((c, i) => Math.max(c.header.length, ...body.map((r) => (r[i] ?? "").length)));
3977
+ const line = (cells) => cells.map((v, i) => v.padEnd(widths[i] ?? 0)).join(" ").trimEnd();
3978
+ process.stdout.write(`${line(columns.map((c) => c.header))}\n`);
3979
+ for (const r of body) process.stdout.write(`${line(r)}\n`);
3980
+ }
3981
+ //#endregion
3982
+ //#region src/template.ts
3983
+ /**
3984
+ * 文档模板(#7):模板就是打了 `template` 标签的普通文档。
3985
+ *
3986
+ * ponytail: 不开 templates 表、不加 /api/templates——版本链、可见性(personal 私有 / official 全站)、
3987
+ * 标签检索全是现成的,独立表只会把同一套东西再抄一遍。server **不做**任何占位符替换,
3988
+ * 模板原样发出去,怎么填是 agent 的事。agent 侧走 MCP 的 list_templates → read_doc → 按骨架写 → push_doc。
3989
+ */
3990
+ var TEMPLATE_TAG = "template";
3991
+ var hasTemplateTag = (d) => Array.isArray(d.tags) && d.tags.map(String).includes("template");
3992
+ async function listTemplates(api, opts) {
3993
+ const [mine, official] = await Promise.all([api.getJson(`/api/docs${opts.all ? "?all=1" : ""}`), api.getJson("/api/official")]);
3994
+ const mineRows = asRows(mine, "docs").filter(hasTemplateTag);
3995
+ const mineIds = new Set(mineRows.map((d) => String(d["id"])));
3996
+ const officialRows = asRows(official, "docs").filter((d) => hasTemplateTag(d) && !mineIds.has(String(d["id"])));
3997
+ printTable([...mineRows.map((d) => ({
3998
+ ...d,
3999
+ scope: d["visibility"] === "official" ? "official" : "personal"
4000
+ })), ...officialRows.map((d) => ({
4001
+ ...d,
4002
+ scope: "official"
4003
+ }))], [
4004
+ {
4005
+ key: "id",
4006
+ header: "ID"
4007
+ },
4008
+ {
4009
+ key: "scope",
4010
+ header: "SCOPE"
4011
+ },
4012
+ {
4013
+ key: "title",
4014
+ header: "TITLE"
4015
+ },
4016
+ {
4017
+ key: "updatedAt",
4018
+ header: "UPDATED"
4019
+ }
4020
+ ]);
4021
+ }
4022
+ /** 拉模板原文:默认打到 stdout(agent 直接读 / 重定向成新文件),--out 落盘。 */
4023
+ async function pullTemplate(api, id, opts) {
4024
+ const md = await api.getText(`/d/${encodeURIComponent(id)}.md`);
4025
+ if (opts.out) {
4026
+ writeFileSync(opts.out, md, "utf8");
4027
+ process.stderr.write(`已写入 ${opts.out}\n`);
4028
+ return;
4029
+ }
4030
+ process.stdout.write(md.endsWith("\n") ? md : `${md}\n`);
4031
+ }
4032
+ /**
4033
+ * 发布 / 更新模板 = push + 保证带 `template` 标签。更新已有模板时保留它原有的其它标签
4034
+ * (push 的 tags 字段是整体覆盖语义,不先取回就会把别的标签抹掉)。
4035
+ */
4036
+ async function pushTemplate(api, file, opts) {
4037
+ let tags = [TEMPLATE_TAG];
4038
+ if (opts.id) {
4039
+ const cur = await api.getJson(`/api/docs/${encodeURIComponent(opts.id)}/tags`).catch(() => ({}));
4040
+ const existing = Array.isArray(cur.tags) ? cur.tags.map(String) : [];
4041
+ tags = [.../* @__PURE__ */ new Set([...existing, TEMPLATE_TAG])];
4042
+ }
4043
+ return pushDoc(api, file, {
4044
+ title: opts.title,
4045
+ id: opts.id,
4046
+ tag: tags,
4047
+ official: opts.official
4048
+ });
4049
+ }
4050
+ //#endregion
4051
+ //#region src/mcp.ts
4052
+ /**
4053
+ * `jdu mcp`:把已有 HTTP API 包成 MCP server(stdio),给 Claude Code / codex / dsh 这类本机 agent
4054
+ * 当可读可写的知识库用(#9 A 段)。凭据复用 ~/.config/jiandu/config.json,server 端零改动。
4055
+ *
4056
+ * ponytail: 不引 @modelcontextprotocol/sdk——它带 express / hono / zod 一整套,而这里只需要
4057
+ * initialize / tools/list / tools/call 三个方法的 JSON-RPC 换行分帧。协议有变再换官方 SDK。
4058
+ * stdout 是协议信道:本文件之外任何写 stdout 的代码都不能在 mcp 模式下被调到(pushDoc 已改为返回值)。
4059
+ */
4060
+ var SUPPORTED_PROTOCOLS = [
4061
+ "2025-06-18",
4062
+ "2025-03-26",
4063
+ "2024-11-05"
3713
4064
  ];
4065
+ var SERVER_INFO = {
4066
+ name: "jiandu",
4067
+ version: "0.2.0"
4068
+ };
4069
+ var INSTRUCTIONS = "jiandu(简牍)是一个 Markdown 知识库。先用 search_docs / list_docs 找到文档 id,read_doc 拿 markdown 原文;改稿后用 push_doc 发布新版本(带 id 才是更新,不带是新建)。文档里的 widget fence(```widget_component:Name)保留原样。写新文档前先 list_templates 看有没有对应骨架(周报 / 技术方案…),有就 read_doc 取模板按节填,占位注释自己替换掉。";
4070
+ function str(v, name) {
4071
+ if (typeof v !== "string" || v.trim() === "") throw new CliError(`${name} 必填且为非空字符串`);
4072
+ return v.trim();
4073
+ }
4074
+ function asDoc(raw, scope, server) {
4075
+ if (raw === null || typeof raw !== "object") return null;
4076
+ const d = raw;
4077
+ if (typeof d["id"] !== "string") return null;
4078
+ return {
4079
+ id: d["id"],
4080
+ title: typeof d["title"] === "string" ? d["title"] : "",
4081
+ excerpt: typeof d["excerpt"] === "string" ? d["excerpt"] : "",
4082
+ visibility: typeof d["visibility"] === "string" ? d["visibility"] : scope === "official" ? "official" : "",
4083
+ tags: Array.isArray(d["tags"]) ? d["tags"].map(String) : [],
4084
+ scope,
4085
+ ...typeof d["archived"] === "boolean" ? { archived: d["archived"] } : {},
4086
+ ...typeof d["updatedAt"] === "number" ? { updatedAt: d["updatedAt"] } : {},
4087
+ url: typeof d["url"] === "string" ? d["url"] : `${server}/d/${d["id"]}`
4088
+ };
4089
+ }
4090
+ function buildTools(api) {
4091
+ const listOf = async (scope, includeArchived) => {
4092
+ const path = scope === "mine" ? `/api/docs${includeArchived ? "?all=1" : ""}` : scope === "official" ? "/api/official" : "/api/shared";
4093
+ const res = await api.getJson(path);
4094
+ return (Array.isArray(res?.docs) ? res.docs : []).map((d) => asDoc(d, scope, api.server)).filter((d) => d !== null);
4095
+ };
4096
+ const listAll = async (includeArchived) => {
4097
+ const [mine, official, shared] = await Promise.all([
4098
+ listOf("mine", includeArchived),
4099
+ listOf("official", false),
4100
+ listOf("shared", false)
4101
+ ]);
4102
+ const seen = new Set(mine.map((d) => d.id));
4103
+ return [...mine, ...[...official, ...shared].filter((d) => !seen.has(d.id) && seen.add(d.id))];
4104
+ };
4105
+ return [
4106
+ {
4107
+ name: "list_docs",
4108
+ description: "列出文档。scope:mine(我发布的,默认)/ official(官方知识库)/ shared(分享给我的)/ all。返回 id、标题、摘要、可见性、标签、URL。",
4109
+ inputSchema: {
4110
+ type: "object",
4111
+ properties: {
4112
+ scope: {
4113
+ type: "string",
4114
+ enum: [
4115
+ "mine",
4116
+ "official",
4117
+ "shared",
4118
+ "all"
4119
+ ],
4120
+ default: "mine"
4121
+ },
4122
+ includeArchived: {
4123
+ type: "boolean",
4124
+ default: false,
4125
+ description: "仅 mine 生效:包含已归档"
4126
+ }
4127
+ }
4128
+ },
4129
+ run: async (args) => {
4130
+ const scope = typeof args["scope"] === "string" ? args["scope"] : "mine";
4131
+ const includeArchived = args["includeArchived"] === true;
4132
+ const docs = scope === "all" ? await listAll(includeArchived) : scope === "mine" || scope === "official" || scope === "shared" ? await listOf(scope, includeArchived) : (() => {
4133
+ throw new CliError(`scope 只能是 mine / official / shared / all:${scope}`);
4134
+ })();
4135
+ return JSON.stringify(docs, null, 2);
4136
+ }
4137
+ },
4138
+ {
4139
+ name: "search_docs",
4140
+ description: "按关键词搜文档(标题 / 摘要 / 标签,空格分词、全部命中)。范围是我能看到的全部文档。没有全文检索——要读正文用 read_doc。",
4141
+ inputSchema: {
4142
+ type: "object",
4143
+ required: ["query"],
4144
+ properties: {
4145
+ query: { type: "string" },
4146
+ limit: {
4147
+ type: "integer",
4148
+ minimum: 1,
4149
+ maximum: 100,
4150
+ default: 20
4151
+ }
4152
+ }
4153
+ },
4154
+ run: async (args) => {
4155
+ const terms = str(args["query"], "query").toLowerCase().split(/\s+/).filter(Boolean);
4156
+ const limit = Number.isInteger(args["limit"]) ? Math.max(1, Math.min(100, args["limit"])) : 20;
4157
+ const hits = (await listAll(false)).filter((d) => {
4158
+ const hay = `${d.title}\n${d.excerpt}\n${d.tags.join(" ")}`.toLowerCase();
4159
+ return terms.every((t) => hay.includes(t));
4160
+ });
4161
+ return JSON.stringify(hits.slice(0, limit), null, 2);
4162
+ }
4163
+ },
4164
+ {
4165
+ name: "read_doc",
4166
+ description: "读文档的 markdown 原文(本地引用已改写为 /blob/<hash>)。不传 version 读最新版。",
4167
+ inputSchema: {
4168
+ type: "object",
4169
+ required: ["id"],
4170
+ properties: {
4171
+ id: { type: "string" },
4172
+ version: {
4173
+ type: "integer",
4174
+ minimum: 1,
4175
+ description: "版本号 seq,见 list_versions"
4176
+ }
4177
+ }
4178
+ },
4179
+ run: async (args) => {
4180
+ const id = encodeURIComponent(str(args["id"], "id"));
4181
+ const version = args["version"];
4182
+ if (version !== void 0 && !Number.isInteger(version)) throw new CliError("version 必须是整数");
4183
+ return api.getText(version === void 0 ? `/d/${id}.md` : `/d/${id}/v/${String(version)}.md`);
4184
+ }
4185
+ },
4186
+ {
4187
+ name: "list_templates",
4188
+ description: "列出文档模板(打了 template 标签的文档:官方模板 + 我的)。写新文档前先看这里;选中后 read_doc 取原文,按骨架填内容、删掉 <!-- --> 占位注释,再 push_doc。server 不做任何变量替换。",
4189
+ inputSchema: {
4190
+ type: "object",
4191
+ properties: {}
4192
+ },
4193
+ run: async () => {
4194
+ const docs = (await listAll(false)).filter((d) => d.tags.includes(TEMPLATE_TAG));
4195
+ return JSON.stringify(docs, null, 2);
4196
+ }
4197
+ },
4198
+ {
4199
+ name: "list_versions",
4200
+ description: "列出文档全部版本(seq、发布时间、源文 hash)。只追加不覆盖,回滚也是新版本。",
4201
+ inputSchema: {
4202
+ type: "object",
4203
+ required: ["id"],
4204
+ properties: { id: { type: "string" } }
4205
+ },
4206
+ run: async (args) => {
4207
+ const res = await api.getJson(`/api/docs/${encodeURIComponent(str(args["id"], "id"))}/versions`);
4208
+ return JSON.stringify(res, null, 2);
4209
+ }
4210
+ },
4211
+ {
4212
+ name: "push_doc",
4213
+ description: "发布本机的 markdown 文件(递归收集图片 / 嵌套 md 等本地引用并上传)。带 id 是给已有文档发新版本(标题 / 可见性 / 标签不传则保持原值);不带 id 新建,默认 private。path 用绝对路径。",
4214
+ inputSchema: {
4215
+ type: "object",
4216
+ required: ["path"],
4217
+ properties: {
4218
+ path: {
4219
+ type: "string",
4220
+ description: "入口 .md 的绝对路径"
4221
+ },
4222
+ id: {
4223
+ type: "string",
4224
+ description: "要更新的文档 id"
4225
+ },
4226
+ title: { type: "string" },
4227
+ visibility: {
4228
+ type: "string",
4229
+ enum: [...VISIBILITIES]
4230
+ },
4231
+ tags: {
4232
+ type: "array",
4233
+ items: { type: "string" },
4234
+ description: "整体覆盖标签;不传则不动"
4235
+ }
4236
+ }
4237
+ },
4238
+ run: async (args) => {
4239
+ const path = str(args["path"], "path");
4240
+ const tags = Array.isArray(args["tags"]) ? args["tags"].map(String) : void 0;
4241
+ const res = await pushDoc(api, path, {
4242
+ id: typeof args["id"] === "string" ? args["id"] : void 0,
4243
+ title: typeof args["title"] === "string" ? args["title"] : void 0,
4244
+ visibility: typeof args["visibility"] === "string" ? args["visibility"] : void 0,
4245
+ tag: tags
4246
+ });
4247
+ return JSON.stringify(res, null, 2);
4248
+ }
4249
+ }
4250
+ ];
4251
+ }
4252
+ function write(msg) {
4253
+ process.stdout.write(`${JSON.stringify(msg)}\n`);
4254
+ }
4255
+ function rpcError(id, code, message) {
4256
+ write({
4257
+ jsonrpc: "2.0",
4258
+ id,
4259
+ error: {
4260
+ code,
4261
+ message
4262
+ }
4263
+ });
4264
+ }
4265
+ async function runMcp(api) {
4266
+ const tools = buildTools(api);
4267
+ const byName = new Map(tools.map((t) => [t.name, t]));
4268
+ const handle = async (req) => {
4269
+ const { id, method } = req;
4270
+ const params = req.params ?? {};
4271
+ if (typeof method !== "string") {
4272
+ if (id !== void 0) rpcError(id, -32600, "method 缺失");
4273
+ return;
4274
+ }
4275
+ if (id === void 0) return;
4276
+ switch (method) {
4277
+ case "initialize": {
4278
+ const asked = typeof params["protocolVersion"] === "string" ? params["protocolVersion"] : "";
4279
+ write({
4280
+ jsonrpc: "2.0",
4281
+ id,
4282
+ result: {
4283
+ protocolVersion: SUPPORTED_PROTOCOLS.includes(asked) ? asked : SUPPORTED_PROTOCOLS[0],
4284
+ capabilities: { tools: {} },
4285
+ serverInfo: SERVER_INFO,
4286
+ instructions: INSTRUCTIONS
4287
+ }
4288
+ });
4289
+ return;
4290
+ }
4291
+ case "ping":
4292
+ write({
4293
+ jsonrpc: "2.0",
4294
+ id,
4295
+ result: {}
4296
+ });
4297
+ return;
4298
+ case "tools/list":
4299
+ write({
4300
+ jsonrpc: "2.0",
4301
+ id,
4302
+ result: { tools: tools.map(({ name, description, inputSchema }) => ({
4303
+ name,
4304
+ description,
4305
+ inputSchema
4306
+ })) }
4307
+ });
4308
+ return;
4309
+ case "tools/call": {
4310
+ const tool = typeof params["name"] === "string" ? byName.get(params["name"]) : void 0;
4311
+ if (!tool) {
4312
+ rpcError(id, -32602, `未知工具:${String(params["name"])}`);
4313
+ return;
4314
+ }
4315
+ const args = params["arguments"] ?? {};
4316
+ try {
4317
+ write({
4318
+ jsonrpc: "2.0",
4319
+ id,
4320
+ result: { content: [{
4321
+ type: "text",
4322
+ text: await tool.run(args)
4323
+ }] }
4324
+ });
4325
+ } catch (err) {
4326
+ write({
4327
+ jsonrpc: "2.0",
4328
+ id,
4329
+ result: {
4330
+ content: [{
4331
+ type: "text",
4332
+ text: err instanceof CliError ? `${err.message}${err.hint ? `(${err.hint})` : ""}` : String(err)
4333
+ }],
4334
+ isError: true
4335
+ }
4336
+ });
4337
+ }
4338
+ return;
4339
+ }
4340
+ default: rpcError(id, -32601, `不支持的方法:${method}`);
4341
+ }
4342
+ };
4343
+ const rl = createInterface({
4344
+ input: process.stdin,
4345
+ crlfDelay: Infinity
4346
+ });
4347
+ const pending = [];
4348
+ for await (const line of rl) {
4349
+ if (line.trim() === "") continue;
4350
+ let parsed;
4351
+ try {
4352
+ parsed = JSON.parse(line);
4353
+ } catch {
4354
+ rpcError(null, -32700, "JSON 解析失败");
4355
+ continue;
4356
+ }
4357
+ for (const msg of Array.isArray(parsed) ? parsed : [parsed]) {
4358
+ if (msg === null || typeof msg !== "object") {
4359
+ rpcError(null, -32600, "请求不是对象");
4360
+ continue;
4361
+ }
4362
+ pending.push(handle(msg));
4363
+ }
4364
+ }
4365
+ await Promise.all(pending);
4366
+ }
4367
+ //#endregion
4368
+ //#region src/widget.ts
4369
+ var SCOPES = ["official", "personal"];
3714
4370
  function exists(path) {
3715
4371
  const st = statSync(path, { throwIfNoEntry: false });
3716
4372
  return st !== void 0 && st.isFile();
@@ -3830,6 +4486,774 @@ async function pushWidget(api, dirArg) {
3830
4486
  process.stdout.write(`${meta.scope}/${meta.name}@${meta.version}${src}${where}\n`);
3831
4487
  }
3832
4488
  //#endregion
4489
+ //#region src/widget-dev.ts
4490
+ var ARTIFACTS = [
4491
+ "index.js",
4492
+ "index.css",
4493
+ "widget.json"
4494
+ ];
4495
+ var MIME = {
4496
+ "index.js": "text/javascript; charset=utf-8",
4497
+ "index.css": "text/css; charset=utf-8",
4498
+ "widget.json": "application/json; charset=utf-8"
4499
+ };
4500
+ /** 允许直接指向产物目录(如 packages/widgets/dist/Alert),也允许指向作者目录(取其 dist/)。 */
4501
+ function locateDist(dir) {
4502
+ if (existsSync(join(dir, "index.js"))) return dir;
4503
+ if (existsSync(join(dir, "dist", "index.js"))) return join(dir, "dist");
4504
+ if (existsSync(join(dir, "package.json"))) return join(dir, "dist");
4505
+ throw new CliError(`${dir} 下既没有 index.js 也没有 dist/`, "指向 jdu widget create 生成的目录,或某个构建产物目录");
4506
+ }
4507
+ function mtimeOf(dist) {
4508
+ let latest = 0;
4509
+ for (const f of ARTIFACTS) {
4510
+ const st = statSync(join(dist, f), { throwIfNoEntry: false });
4511
+ if (st && st.mtimeMs > latest) latest = st.mtimeMs;
4512
+ }
4513
+ return latest;
4514
+ }
4515
+ function readMeta(dir, dist) {
4516
+ for (const p of [join(dist, "widget.json"), join(dir, "widget.json")]) {
4517
+ if (!existsSync(p)) continue;
4518
+ try {
4519
+ const m = JSON.parse(readFileSync(p, "utf8"));
4520
+ return {
4521
+ name: typeof m["name"] === "string" ? m["name"] : "Widget",
4522
+ version: typeof m["version"] === "string" ? m["version"] : ""
4523
+ };
4524
+ } catch {}
4525
+ }
4526
+ return {
4527
+ name: "Widget",
4528
+ version: ""
4529
+ };
4530
+ }
4531
+ /** 线上 base.css 的地址:抓首页 HTML 里的 `/_v/base.<hash>.css`。拿不到返回 null,页面用兜底样式。 */
4532
+ async function siteBaseCss(server) {
4533
+ if (!server) return null;
4534
+ try {
4535
+ const ctrl = new AbortController();
4536
+ const t = setTimeout(() => ctrl.abort(), 2e3);
4537
+ const html = await (await fetch(`${server}/`, {
4538
+ signal: ctrl.signal,
4539
+ redirect: "follow"
4540
+ })).text();
4541
+ clearTimeout(t);
4542
+ const m = /\/_v\/base\.[a-f0-9]+\.css/.exec(html);
4543
+ return m ? `${server}${m[0]}` : null;
4544
+ } catch {
4545
+ return null;
4546
+ }
4547
+ }
4548
+ var esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
4549
+ function page(meta, baseCss, hasCss, mtime) {
4550
+ return `<!doctype html>
4551
+ <html lang="zh-CN">
4552
+ <head>
4553
+ <meta charset="utf-8">
4554
+ <meta name="viewport" content="width=device-width, initial-scale=1">
4555
+ <title>${esc(meta.name)} · jdu widget dev</title>
4556
+ ${baseCss ? `<link rel="stylesheet" href="${esc(baseCss)}">` : ""}
4557
+ ${hasCss ? `<link rel="stylesheet" href="/index.css?t=${mtime}">` : ""}
4558
+ <style>
4559
+ /* 兜底 token:连不上 jiandu server 时也有亮 / 暗两套 */
4560
+ :root { --jiandu-bg: #fff; --jiandu-bg-subtle: #f6f8fa; --jiandu-fg: #1f2328; --jiandu-fg-muted: #59636e; --jiandu-border: #d0d7de; --jiandu-font-sans: system-ui, sans-serif; --jiandu-font-mono: ui-monospace, monospace; }
4561
+ .dark { --jiandu-bg: #0d1117; --jiandu-bg-subtle: #161b22; --jiandu-fg: #e6edf3; --jiandu-fg-muted: #9198a1; --jiandu-border: #3d444d; }
4562
+ html { background: var(--jiandu-bg); color: var(--jiandu-fg); font-family: var(--jiandu-font-sans); }
4563
+ body { margin: 0; }
4564
+ .dev-bar { display: flex; gap: 1rem; align-items: center; padding: 0.5rem 1rem; border-bottom: 1px solid var(--jiandu-border); font-size: 13px; color: var(--jiandu-fg-muted); }
4565
+ .dev-bar b { color: var(--jiandu-fg); }
4566
+ .dev-bar button { margin-left: auto; padding: 0.25rem 0.6rem; border: 1px solid var(--jiandu-border); border-radius: 6px; background: var(--jiandu-bg-subtle); color: inherit; cursor: pointer; }
4567
+ .dev-main { max-width: 760px; margin: 0 auto; padding: 1.5rem 1rem; }
4568
+ .dev-stage { min-height: 4rem; padding: 1rem; border: 1px dashed var(--jiandu-border); border-radius: 8px; }
4569
+ details { margin-top: 1.5rem; font-size: 13px; color: var(--jiandu-fg-muted); }
4570
+ textarea { width: 100%; min-height: 8rem; margin-top: 0.5rem; padding: 0.5rem; box-sizing: border-box; border: 1px solid var(--jiandu-border); border-radius: 6px; background: var(--jiandu-bg-subtle); color: var(--jiandu-fg); font: 12px/1.5 var(--jiandu-font-mono); }
4571
+ .dev-err { color: #d1242f; white-space: pre-wrap; font-family: var(--jiandu-font-mono); font-size: 12px; }
4572
+ .dev-degraded { padding: 0.75rem; border-radius: 6px; background: var(--jiandu-bg-subtle); font-family: var(--jiandu-font-mono); font-size: 12px; white-space: pre-wrap; }
4573
+ </style>
4574
+ </head>
4575
+ <body>
4576
+ <div class="dev-bar"><b>${esc(meta.name)}</b><span>${esc(meta.version)}</span><span id="status">加载中…</span><button type="button" id="dark">切换暗色</button></div>
4577
+ <main class="dev-main jiandu-doc">
4578
+ <div class="dev-stage"><div id="island" data-jiandu-island="b0-dev00000" data-widget="${esc(meta.name)}"></div></div>
4579
+ <details open>
4580
+ <summary>sampleData(改了即时 update;校验发生在 push 后 server 渲染期,这里不校验)</summary>
4581
+ <textarea id="data" spellcheck="false"></textarea>
4582
+ <div id="err" class="dev-err" role="alert"></div>
4583
+ </details>
4584
+ </main>
4585
+ <script type="module">
4586
+ const $ = (id) => document.getElementById(id);
4587
+ const started = ${mtime};
4588
+ const island = $('island'), ta = $('data'), err = $('err'), status = $('status');
4589
+ $('dark').addEventListener('click', () => { document.documentElement.classList.toggle('dark'); inst?.update?.(ctx(data)); });
4590
+
4591
+ // 与 viewer loader 同一条链路:import → new → mount(el, ctx);抛错就按线上的降级形态显示原始 fence
4592
+ let meta = {}, inst = null, W = null, data = {};
4593
+ const ctx = (d) => ({ data: d, loading: false, blockId: 'b0-dev00000', source: JSON.stringify(d, null, 2), language: 'widget_component:' + (meta.name ?? '') });
4594
+ function degrade(reason) {
4595
+ const pre = document.createElement('pre');
4596
+ pre.className = 'dev-degraded';
4597
+ pre.textContent = '[降级为代码块] ' + reason + '\\n\\n\`\`\`widget_component:' + (meta.name ?? '') + '\\n' + JSON.stringify(data, null, 2) + '\\n\`\`\`';
4598
+ island.replaceChildren(pre);
4599
+ status.textContent = '挂载失败';
4600
+ }
4601
+ function remount() {
4602
+ try { inst?.destroy?.(); } catch {}
4603
+ island.replaceChildren();
4604
+ try { inst = new W(); inst.mount(island, ctx(data)); status.textContent = '已挂载'; }
4605
+ catch (e) { degrade(e instanceof Error ? e.message : String(e)); }
4606
+ }
4607
+ try {
4608
+ meta = await (await fetch('/widget.json', { cache: 'no-store' })).json();
4609
+ data = meta.sampleData ?? {};
4610
+ ta.value = JSON.stringify(data, null, 2);
4611
+ const mod = await import('/index.js?t=' + started);
4612
+ W = mod.default;
4613
+ if (typeof W !== 'function') throw new Error('默认导出不是可构造的 class');
4614
+ remount();
4615
+ } catch (e) { degrade(e instanceof Error ? e.message : String(e)); }
4616
+
4617
+ ta.addEventListener('input', () => {
4618
+ try {
4619
+ data = JSON.parse(ta.value); err.textContent = '';
4620
+ if (inst?.update) inst.update(ctx(data)); else remount();
4621
+ } catch (e) { err.textContent = e instanceof Error ? e.message : String(e); }
4622
+ });
4623
+
4624
+ // 产物 mtime 变了就整页刷新(watch 构建完成 → 这里 <1s 内看到)
4625
+ setInterval(async () => {
4626
+ try {
4627
+ const s = await (await fetch('/__status', { cache: 'no-store' })).json();
4628
+ if (s.mtime !== started && s.mtime > 0) location.reload();
4629
+ } catch {}
4630
+ }, 700);
4631
+ <\/script>
4632
+ </body>
4633
+ </html>
4634
+ `;
4635
+ }
4636
+ async function runWidgetDev(dirArg, opts) {
4637
+ const dir = resolve(dirArg);
4638
+ const dist = locateDist(dir);
4639
+ const baseCss = await siteBaseCss(opts.server);
4640
+ let child = null;
4641
+ const pkgPath = join(dir, "package.json");
4642
+ if (opts.watch && existsSync(pkgPath)) {
4643
+ if (JSON.parse(readFileSync(pkgPath, "utf8")).scripts?.["watch"]) {
4644
+ child = spawn("npm", ["run", "watch"], {
4645
+ cwd: dir,
4646
+ stdio: "inherit",
4647
+ shell: process.platform === "win32"
4648
+ });
4649
+ child.on("exit", (code) => {
4650
+ if (code !== null && code !== 0) process.stderr.write(`jdu: npm run watch 退出(${code}),预览页仍在,产物不再更新\n`);
4651
+ });
4652
+ } else process.stderr.write("jdu: package.json 没有 watch 脚本,不自动构建;改完源码请自行 build\n");
4653
+ }
4654
+ const server = createServer((req, res) => {
4655
+ const url = new URL(req.url ?? "/", "http://localhost");
4656
+ const send = (code, type, body) => {
4657
+ res.writeHead(code, {
4658
+ "Content-Type": type,
4659
+ "Cache-Control": "no-store"
4660
+ });
4661
+ res.end(body);
4662
+ };
4663
+ if (url.pathname === "/") return send(200, "text/html; charset=utf-8", page(readMeta(dir, dist), baseCss, existsSync(join(dist, "index.css")), mtimeOf(dist)));
4664
+ if (url.pathname === "/__status") return send(200, "application/json", JSON.stringify({ mtime: mtimeOf(dist) }));
4665
+ const file = url.pathname.slice(1);
4666
+ if (ARTIFACTS.includes(file)) {
4667
+ const p = join(dist, file);
4668
+ if (!existsSync(p)) return send(404, "text/plain; charset=utf-8", `${file} 还没构建出来`);
4669
+ return send(200, MIME[file], readFileSync(p));
4670
+ }
4671
+ return send(404, "text/plain; charset=utf-8", "not found");
4672
+ });
4673
+ await new Promise((ok, fail) => {
4674
+ server.once("error", fail);
4675
+ server.listen(opts.port, "127.0.0.1", () => ok());
4676
+ }).catch((err) => {
4677
+ child?.kill();
4678
+ throw new CliError(`预览 server 起不来:${err.code ?? err.message}`, `端口 ${opts.port} 可能被占用,换 --port`);
4679
+ });
4680
+ process.stderr.write(`预览 http://127.0.0.1:${opts.port}/\n产物 ${dist}\n${baseCss ? `样式 ${baseCss}\n` : "样式 未连上 jiandu server,用内置兜底 token\n"}Ctrl+C 退出\n`);
4681
+ await new Promise((done) => {
4682
+ const stop = () => {
4683
+ child?.kill();
4684
+ server.close(() => done());
4685
+ };
4686
+ process.once("SIGINT", stop);
4687
+ process.once("SIGTERM", stop);
4688
+ child?.once("exit", () => {});
4689
+ });
4690
+ }
4691
+ //#endregion
4692
+ //#region src/widget-scaffold.ts
4693
+ /**
4694
+ * `jdu widget create`(M3,#38):生成一个能直接 build / dev / push 的 widget 目录。
4695
+ *
4696
+ * 模板刻意「全内联」:契约类型、框架适配基类、构建脚本都拷进作者目录,不依赖任何 jiandu 的 npm 包——
4697
+ * 平台对 widget 只做鸭子类型检查,作者拿到的是自己的代码,改坏了也只影响自己。
4698
+ * dataSchema 从 `src/types.ts` 的 `export interface Data` 生成(JSDoc 直通 description / @default),
4699
+ * 由作者目录里的 ts-json-schema-generator 在 build 时做,CLI 本身不带 TS 编译器。
4700
+ */
4701
+ var TEMPLATES = [
4702
+ "vanilla",
4703
+ "react",
4704
+ "vue"
4705
+ ];
4706
+ var RUNTIMES = ["shared", "custom"];
4707
+ var REACT_SPECIFIERS = [
4708
+ "react",
4709
+ "react-dom",
4710
+ "react-dom/client",
4711
+ "react/jsx-runtime"
4712
+ ];
4713
+ var NAME_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
4714
+ /** `star-rating` / `star_rating` / `StarRating` → `StarRating`(class 名 / widget 名)。 */
4715
+ function pascalCase(name) {
4716
+ return name.split(/[-_]+/).filter(Boolean).map((p) => p[0]?.toUpperCase() + p.slice(1)).join("");
4717
+ }
4718
+ /** `StarRating` → `star-rating`(目录 / package 名 / css class)。 */
4719
+ function kebabCase(name) {
4720
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").toLowerCase();
4721
+ }
4722
+ var CONTRACT_TS = `/**
4723
+ * 平台 mount 契约(与 jiandu 的 packages/render/src/types.ts 同构,拷贝一份免得依赖 jiandu 的包)。
4724
+ * 宿主只做鸭子类型检查:默认导出无参可构造 class,实例有 mount,update / destroy 可选。
4725
+ */
4726
+ export interface WidgetContext<D = unknown> {
4727
+ /** fence 里作者写的 JSON(未声明流式时保证完整且已过 dataSchema 校验) */
4728
+ data: D;
4729
+ /** 流式输出中 data 尚未完整时为 true(未声明流式的 widget 恒为 false) */
4730
+ loading: boolean;
4731
+ /** 所属块的稳定 id,可当 DOM id 前缀用 */
4732
+ blockId: string;
4733
+ /** 原始 fence 正文 */
4734
+ source: string;
4735
+ /** 原始代码块语言标识,如 \`widget_component:StarRating\` */
4736
+ language: string;
4737
+ /**
4738
+ * 嵌套 markdown:dataSchema 里标了 \`contentMediaType: "text/markdown"\` 的字符串字段,宿主渲成已 sanitize 的 HTML
4739
+ * 放在这里(JSON pointer → HTML,如 \`/content\`),widget 直接 innerHTML;宿主没给时退回纯文本。
4740
+ * src/types.ts 里给字段加 JSDoc \`@contentMediaType text/markdown\` 即可声明。
4741
+ */
4742
+ rendered?: Record<string, string>;
4743
+ }
4744
+
4745
+ export interface WidgetConfigDecl {
4746
+ /** 声明 true 才会在流式生成中途被 mount,并要自己处理半截 data;默认 false */
4747
+ streaming?: boolean;
4748
+ }
4749
+
4750
+ export interface WidgetInstance<D = unknown> {
4751
+ mount(el: HTMLElement, ctx: WidgetContext<D>): void;
4752
+ update?(ctx: WidgetContext<D>): void;
4753
+ destroy?(): void;
4754
+ }
4755
+
4756
+ export interface WidgetClass<D = unknown> {
4757
+ new (): WidgetInstance<D>;
4758
+ __widgetConfig?: WidgetConfigDecl;
4759
+ }
4760
+ `;
4761
+ var TYPES_TS = (c) => `// fence 里的 data 长什么样,这里是唯一事实:\`npm run build\` 会把 Data 生成为 widget.json 的 dataSchema
4762
+ // (字段上的 JSDoc → description,@default → default)。文档里的 data 不合法时平台会把 fence 降级成代码块。
4763
+ //
4764
+ // \`\`\`widget_component:${c.name}
4765
+ // { "title": "你好,简牍", "count": 3 }
4766
+ // \`\`\`
4767
+ //
4768
+ // 接口本身别写 JSDoc——那会整段进 schema 的 description。
4769
+
4770
+ export interface Data {
4771
+ /** 标题 */
4772
+ title: string;
4773
+ /**
4774
+ * 计数
4775
+ * @default 0
4776
+ */
4777
+ count?: number;
4778
+ }
4779
+ `;
4780
+ var STYLE_CSS = (c) => `/* 颜色一律走 --jiandu-* token(带兜底值),亮 / 暗主题跟宿主一起变;暗色用 .dark 选择器,不要 prefers-color-scheme */
4781
+ .w-${c.kebab} {
4782
+ display: inline-flex;
4783
+ align-items: center;
4784
+ gap: 0.5rem;
4785
+ padding: 0.5rem 0.75rem;
4786
+ border: 1px solid var(--jiandu-border, #d0d7de);
4787
+ border-radius: 8px;
4788
+ background: var(--jiandu-bg-subtle, #f6f8fa);
4789
+ color: var(--jiandu-fg, #1f2328);
4790
+ font: 14px/1.4 var(--jiandu-font-sans, system-ui, sans-serif);
4791
+ }
4792
+ .w-${c.kebab} button {
4793
+ padding: 0 0.5rem;
4794
+ border: 1px solid var(--jiandu-border, #d0d7de);
4795
+ border-radius: 6px;
4796
+ background: var(--jiandu-bg, #fff);
4797
+ color: inherit;
4798
+ cursor: pointer;
4799
+ }
4800
+ `;
4801
+ var VANILLA_INDEX = (c) => `import type { WidgetContext } from './_base/contract.js';
4802
+ import type { Data } from './types.js';
4803
+ import './style.css';
4804
+
4805
+ /**
4806
+ * ${c.name}:默认导出无参可构造的 class,副作用全在 mount 里(构造函数不做事)。
4807
+ * 默认非流式——mount 时 ctx.data 已完整且过了 dataSchema 校验,不用写守卫。
4808
+ */
4809
+ export default class ${c.cls} {
4810
+ private root?: HTMLElement;
4811
+ private count = 0;
4812
+
4813
+ mount(el: HTMLElement, ctx: WidgetContext<Data>): void {
4814
+ this.root = document.createElement('div');
4815
+ this.root.className = 'w-${c.kebab}';
4816
+ el.replaceChildren(this.root);
4817
+ this.count = ctx.data.count ?? 0;
4818
+ this.render(ctx.data);
4819
+ }
4820
+
4821
+ /** 主题切换、或声明流式后 data 补齐时宿主会调;这里简单重画 */
4822
+ update(ctx: WidgetContext<Data>): void {
4823
+ this.render(ctx.data);
4824
+ }
4825
+
4826
+ destroy(): void {
4827
+ this.root?.remove();
4828
+ }
4829
+
4830
+ private render(data: Data): void {
4831
+ if (!this.root) return;
4832
+ const title = document.createElement('strong');
4833
+ title.textContent = data.title;
4834
+ const num = document.createElement('span');
4835
+ num.textContent = \`× \${this.count}\`;
4836
+ const btn = document.createElement('button');
4837
+ btn.type = 'button';
4838
+ btn.textContent = '+1';
4839
+ btn.addEventListener('click', () => {
4840
+ this.count += 1;
4841
+ num.textContent = \`× \${this.count}\`;
4842
+ });
4843
+ this.root.replaceChildren(title, num, btn);
4844
+ }
4845
+ }
4846
+ `;
4847
+ var REACT_BASE = `import { createElement } from 'react';
4848
+ import type { ComponentType } from 'react';
4849
+ import { createRoot } from 'react-dom/client';
4850
+ import type { Root } from 'react-dom/client';
4851
+ import type { WidgetClass, WidgetConfigDecl, WidgetContext } from './contract.js';
4852
+
4853
+ export interface WidgetProps<D> {
4854
+ data: D;
4855
+ ctx: WidgetContext<D>;
4856
+ }
4857
+
4858
+ /**
4859
+ * React 组件 → mount 契约 class。update 走 React diff 不拆重建,destroy 卸载 root。
4860
+ * 第二个参数原样成为 __widgetConfig(要处理流式半截 data 才传 { streaming: true })。
4861
+ */
4862
+ export function reactWidget<D>(Component: ComponentType<WidgetProps<D>>, config: WidgetConfigDecl = {}): WidgetClass<D> {
4863
+ return class {
4864
+ static __widgetConfig = config;
4865
+ private root?: Root;
4866
+
4867
+ mount(el: HTMLElement, ctx: WidgetContext<D>): void {
4868
+ this.root = createRoot(el);
4869
+ this.root.render(createElement(Component, { data: ctx.data, ctx }));
4870
+ }
4871
+
4872
+ update(ctx: WidgetContext<D>): void {
4873
+ this.root?.render(createElement(Component, { data: ctx.data, ctx }));
4874
+ }
4875
+
4876
+ destroy(): void {
4877
+ this.root?.unmount();
4878
+ }
4879
+ };
4880
+ }
4881
+ `;
4882
+ var REACT_INDEX = `import { reactWidget } from './_base/react-widget.js';
4883
+ import { App } from './App.js';
4884
+
4885
+ export default reactWidget(App);
4886
+ `;
4887
+ var REACT_APP = (c) => `import { useState } from 'react';
4888
+ import type { WidgetProps } from './_base/react-widget.js';
4889
+ import type { Data } from './types.js';
4890
+ import './style.css';
4891
+
4892
+ /** 组件只管渲染 data;mount 时 data 已完整且过了 dataSchema 校验,不用写守卫。 */
4893
+ export function App({ data }: WidgetProps<Data>) {
4894
+ const [count, setCount] = useState(data.count ?? 0);
4895
+ return (
4896
+ <div className="w-${c.kebab}">
4897
+ <strong>{data.title}</strong>
4898
+ <span>× {count}</span>
4899
+ <button type="button" onClick={() => setCount((n) => n + 1)}>
4900
+ +1
4901
+ </button>
4902
+ </div>
4903
+ );
4904
+ }
4905
+ `;
4906
+ var VUE_BASE = `import { createApp, h, shallowReactive } from 'vue';
4907
+ import type { App, Component } from 'vue';
4908
+ import type { WidgetClass, WidgetConfigDecl, WidgetContext } from './contract.js';
4909
+
4910
+ /**
4911
+ * Vue 组件 → mount 契约 class。根组件收 \`data\` 与 \`ctx\` 两个 prop;
4912
+ * update 只改响应式状态,Vue 自己 patch;destroy 卸载 app。
4913
+ */
4914
+ export function vueWidget<D>(Root: Component, config: WidgetConfigDecl = {}): WidgetClass<D> {
4915
+ return class {
4916
+ static __widgetConfig = config;
4917
+ private app?: App;
4918
+ private state = shallowReactive<{ ctx: WidgetContext<D> | null }>({ ctx: null });
4919
+
4920
+ mount(el: HTMLElement, ctx: WidgetContext<D>): void {
4921
+ this.state.ctx = ctx;
4922
+ this.app = createApp({
4923
+ render: () => (this.state.ctx ? h(Root, { data: this.state.ctx.data, ctx: this.state.ctx }) : null),
4924
+ });
4925
+ this.app.mount(el);
4926
+ }
4927
+
4928
+ update(ctx: WidgetContext<D>): void {
4929
+ this.state.ctx = ctx;
4930
+ }
4931
+
4932
+ destroy(): void {
4933
+ this.app?.unmount();
4934
+ }
4935
+ };
4936
+ }
4937
+ `;
4938
+ var VUE_INDEX = `import { vueWidget } from './_base/vue-widget.js';
4939
+ import { App } from './App.js';
4940
+
4941
+ export default vueWidget(App);
4942
+ `;
4943
+ var VUE_APP = (c) => `import { defineComponent, h, ref } from 'vue';
4944
+ import type { PropType } from 'vue';
4945
+ import type { Data } from './types.js';
4946
+ import './style.css';
4947
+
4948
+ // ponytail: 用 h() 渲染函数而不是 .vue 单文件——零编译插件;想写 SFC 就给 scripts/build.mjs 加 esbuild 的 vue 插件
4949
+ export const App = defineComponent({
4950
+ props: { data: { type: Object as PropType<Data>, required: true } },
4951
+ setup(props) {
4952
+ const count = ref(props.data.count ?? 0);
4953
+ return () =>
4954
+ h('div', { class: 'w-${c.kebab}' }, [
4955
+ h('strong', props.data.title),
4956
+ h('span', \`× \${count.value}\`),
4957
+ h('button', { type: 'button', onClick: () => (count.value += 1) }, '+1'),
4958
+ ]);
4959
+ },
4960
+ });
4961
+ `;
4962
+ var BUILD_MJS = (c) => `#!/usr/bin/env node
4963
+ /**
4964
+ * 由 \`jdu widget create\` 生成的构建脚本(全内联,改它就是改你自己的构建):
4965
+ * 1. esbuild 打成单文件 ESM(--platform=browser;产物里出现 node 内置模块 server 会拒收)
4966
+ * 2. widget.json 的 dataSchema 从 src/types.ts 的 \`Data\` 重新生成,写到 dist/ 与源目录
4967
+ * 3. 源码拷到 dist/src —— \`jdu widget push dist\` 会把它一并上传,注册表里不只剩 minify 过的 bundle
4968
+ * \`node scripts/build.mjs --watch\` 监听改动重建,配 \`jdu widget dev\` 的预览页自动刷新。
4969
+ */
4970
+ import { build, context } from 'esbuild';
4971
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4972
+ import { dirname, join } from 'node:path';
4973
+ import { fileURLToPath } from 'node:url';
4974
+
4975
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
4976
+ const DIST = join(ROOT, 'dist');
4977
+ const ENTRY = '${c.template === "react" ? "src/index.tsx" : "src/index.ts"}';
4978
+
4979
+ /**
4980
+ * 平台版本档 runtime(shared 档):这些 bare import 不打进产物,改写成 jiandu 上内容寻址的 runtime URL,
4981
+ * 同档 widget 在浏览器里共享一份下载。null = custom 档,框架全内联进产物(任何框架任何版本都行)。
4982
+ * 值来自 \`GET /api/runtimes\`(create 时抄下来的${c.tierLabel ? `:${c.tierLabel}` : ""});平台升级档位不追溯,重新 create 或手改这里才换。
4983
+ */
4984
+ const RUNTIME_PATHS = ${c.runtime ? JSON.stringify(c.runtime, null, 2) : "null"};
4985
+
4986
+ const runtimePlugin = {
4987
+ name: 'jiandu-runtime',
4988
+ setup(b) {
4989
+ if (!RUNTIME_PATHS) return;
4990
+ b.onResolve({ filter: /^(react|react-dom|react-dom\\/client|react\\/jsx-runtime)$/ }, (args) => ({
4991
+ path: RUNTIME_PATHS[args.path],
4992
+ external: true,
4993
+ }));
4994
+ },
4995
+ };
4996
+
4997
+ async function dataSchema(fallback) {
4998
+ try {
4999
+ const { createGenerator } = await import('ts-json-schema-generator');
5000
+ const schema = createGenerator({
5001
+ path: join(ROOT, 'src/types.ts'),
5002
+ tsconfig: join(ROOT, 'tsconfig.json'),
5003
+ type: 'Data',
5004
+ skipTypeCheck: true,
5005
+ expose: 'none',
5006
+ topRef: false,
5007
+ additionalProperties: true,
5008
+ // 字段 JSDoc 写 @contentMediaType text/markdown → 宿主把该字段渲成 HTML 放进 ctx.rendered
5009
+ extraTags: ['contentMediaType'],
5010
+ }).createSchema('Data');
5011
+ delete schema.$schema;
5012
+ if (schema.definitions && Object.keys(schema.definitions).length === 0) delete schema.definitions;
5013
+ return schema;
5014
+ } catch (err) {
5015
+ console.warn(\`[widget] dataSchema 未重新生成(\${err.message}),沿用 widget.json 里的现值\`);
5016
+ return fallback;
5017
+ }
5018
+ }
5019
+
5020
+ async function emitMeta() {
5021
+ const meta = JSON.parse(readFileSync(join(ROOT, 'widget.json'), 'utf8'));
5022
+ meta.dataSchema = await dataSchema(meta.dataSchema);
5023
+ const text = \`\${JSON.stringify(meta, null, 2)}\\n\`;
5024
+ mkdirSync(DIST, { recursive: true });
5025
+ writeFileSync(join(DIST, 'widget.json'), text);
5026
+ writeFileSync(join(ROOT, 'widget.json'), text);
5027
+ rmSync(join(DIST, 'src'), { recursive: true, force: true });
5028
+ cpSync(join(ROOT, 'src'), join(DIST, 'src'), { recursive: true });
5029
+ }
5030
+
5031
+ const options = {
5032
+ entryPoints: [join(ROOT, ENTRY)],
5033
+ bundle: true,
5034
+ format: 'esm',
5035
+ target: 'es2022',
5036
+ platform: 'browser',
5037
+ minify: true,
5038
+ outfile: join(DIST, 'index.js'),
5039
+ define: { 'process.env.NODE_ENV': '"production"' },
5040
+ jsx: 'automatic',
5041
+ logLevel: 'info',
5042
+ plugins: [
5043
+ runtimePlugin,
5044
+ {
5045
+ name: 'widget-json',
5046
+ setup(b) {
5047
+ b.onEnd(async (result) => {
5048
+ if (result.errors.length === 0) await emitMeta();
5049
+ });
5050
+ },
5051
+ },
5052
+ ],
5053
+ };
5054
+
5055
+ if (process.argv.includes('--watch')) {
5056
+ const ctx = await context(options);
5057
+ await ctx.watch();
5058
+ console.log('[widget] watching src/ …');
5059
+ } else {
5060
+ if (existsSync(DIST)) rmSync(DIST, { recursive: true, force: true });
5061
+ await build(options);
5062
+ }
5063
+ `;
5064
+ var TSCONFIG = `{
5065
+ "compilerOptions": {
5066
+ "target": "ES2022",
5067
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
5068
+ "module": "ESNext",
5069
+ "moduleResolution": "Bundler",
5070
+ "jsx": "react-jsx",
5071
+ "strict": true,
5072
+ "noEmit": true,
5073
+ "skipLibCheck": true,
5074
+ "types": [],
5075
+ "verbatimModuleSyntax": true
5076
+ },
5077
+ "include": ["src"]
5078
+ }
5079
+ `;
5080
+ var PACKAGE_JSON = (c) => {
5081
+ const dev = {
5082
+ esbuild: "^0.28.2",
5083
+ "ts-json-schema-generator": "^2.9.0",
5084
+ typescript: "^5.9.0"
5085
+ };
5086
+ if (c.template === "react") Object.assign(dev, {
5087
+ react: "^19.2.0",
5088
+ "react-dom": "^19.2.0",
5089
+ "@types/react": "^19.2.0",
5090
+ "@types/react-dom": "^19.2.0"
5091
+ });
5092
+ if (c.template === "vue") dev["vue"] = "^3.5.0";
5093
+ const sorted = Object.fromEntries(Object.entries(dev).sort(([a], [b]) => a.localeCompare(b)));
5094
+ return `${JSON.stringify({
5095
+ name: `widget-${c.kebab}`,
5096
+ private: true,
5097
+ type: "module",
5098
+ scripts: {
5099
+ build: "node scripts/build.mjs",
5100
+ watch: "node scripts/build.mjs --watch",
5101
+ dev: "jdu widget dev",
5102
+ push: "npm run build && jdu widget push dist",
5103
+ typecheck: "tsc -p tsconfig.json --noEmit"
5104
+ },
5105
+ devDependencies: sorted
5106
+ }, null, 2)}\n`;
5107
+ };
5108
+ var WIDGET_JSON = (c) => `${JSON.stringify({
5109
+ name: c.name,
5110
+ scope: "personal",
5111
+ version: "1.0.0",
5112
+ dataSchema: {
5113
+ type: "object",
5114
+ required: ["title"],
5115
+ properties: {
5116
+ title: {
5117
+ type: "string",
5118
+ description: "标题"
5119
+ },
5120
+ count: {
5121
+ type: "number",
5122
+ description: "计数",
5123
+ default: 0
5124
+ }
5125
+ },
5126
+ additionalProperties: true
5127
+ },
5128
+ sampleData: {
5129
+ title: "你好,简牍",
5130
+ count: 3
5131
+ }
5132
+ }, null, 2)}\n`;
5133
+ var README = (c) => `# ${c.name}
5134
+
5135
+ 由 \`jdu widget create ${c.name} --template ${c.template}${c.template === "react" ? ` --runtime ${c.runtime ? "shared" : "custom"}` : ""}\` 生成。
5136
+
5137
+ \`\`\`sh
5138
+ npm install
5139
+ npm run dev # = jdu widget dev:起 watch 构建 + 本地预览页(sampleData 挂载,改代码自动刷新)
5140
+ npm run push # = build + jdu widget push dist
5141
+ \`\`\`
5142
+
5143
+ 然后在任何一篇文档里:
5144
+
5145
+ \`\`\`\`markdown
5146
+ \`\`\`widget_component:${c.name}
5147
+ { "title": "你好,简牍", "count": 3 }
5148
+ \`\`\`
5149
+ \`\`\`\`
5150
+
5151
+ ## 改什么
5152
+
5153
+ | 文件 | 作用 |
5154
+ |---|---|
5155
+ | \`src/types.ts\` | \`Data\` 接口 = fence 里 data 的唯一事实。JSDoc → description,\`@default\` → default;\`npm run build\` 生成 widget.json 的 dataSchema |
5156
+ | ${c.template === "vanilla" ? "`src/index.ts`" : c.template === "react" ? "`src/App.tsx`" : "`src/App.ts`"} | 渲染逻辑${c.template === "vanilla" ? "(mount / update / destroy 三个生命周期)" : "(组件收 `data` prop;契约适配在 `src/_base/`,一般不用动)"} |
5157
+ | \`src/style.css\` | 样式。颜色走 \`--jiandu-*\` token 带兜底值,暗色用 \`.dark\` 选择器 |
5158
+ | \`widget.json\` | name / scope / version / sampleData;dataSchema 由 build 生成,不用手写 |
5159
+ | \`scripts/build.mjs\` | 全内联构建配置${c.runtime ? ";`RUNTIME_PATHS` 是平台版本档 runtime 的 URL" : ""} |
5160
+
5161
+ 版本号改 \`widget.json\` 的 \`version\`;同名同版本重推会覆盖产物、不改上线状态。
5162
+
5163
+ ## 契约要点
5164
+
5165
+ - 默认导出无参可构造 class,实例有 \`mount(el, ctx)\`,\`update\` / \`destroy\` 可选;平台只做鸭子类型检查
5166
+ - 默认非流式:mount 时 \`ctx.data\` 完整且已过 dataSchema 校验,**不用写守卫**。要在 AI 流式生成中途就渲染,声明 \`static __widgetConfig = { streaming: true }\` 并自己处理半截 data
5167
+ - 产物必须是浏览器可跑的单文件 ESM:不要 import node 内置模块,server 会拒收
5168
+ - 字段要支持嵌套 Markdown:在 \`src/types.ts\` 给它加 JSDoc \`@contentMediaType text/markdown\`,宿主会渲成已 sanitize 的 HTML 放进 \`ctx.rendered['/字段']\`,直接 innerHTML;\`jdu widget dev\` 预览里没有这一步,会看到原文
5169
+ ${c.runtime ? `- shared 档:\`react\` 等四个 import 改写为平台 runtime URL(${c.tierLabel ?? ""}),产物只有几 KB。要用平台没有的框架 / 版本,把 \`scripts/build.mjs\` 的 \`RUNTIME_PATHS\` 设为 null 即 custom 档` : ""}
5170
+ `;
5171
+ var GITIGNORE = `node_modules/\ndist/\n`;
5172
+ function filesFor(c) {
5173
+ const files = /* @__PURE__ */ new Map();
5174
+ files.set("package.json", PACKAGE_JSON(c));
5175
+ files.set("tsconfig.json", TSCONFIG);
5176
+ files.set("widget.json", WIDGET_JSON(c));
5177
+ files.set("README.md", README(c));
5178
+ files.set(".gitignore", GITIGNORE);
5179
+ files.set("scripts/build.mjs", BUILD_MJS(c));
5180
+ files.set("src/_base/contract.ts", CONTRACT_TS);
5181
+ files.set("src/types.ts", TYPES_TS(c));
5182
+ files.set("src/style.css", STYLE_CSS(c));
5183
+ if (c.template === "vanilla") files.set("src/index.ts", VANILLA_INDEX(c));
5184
+ else if (c.template === "react") {
5185
+ files.set("src/_base/react-widget.ts", REACT_BASE);
5186
+ files.set("src/index.tsx", REACT_INDEX);
5187
+ files.set("src/App.tsx", REACT_APP(c));
5188
+ } else {
5189
+ files.set("src/_base/vue-widget.ts", VUE_BASE);
5190
+ files.set("src/index.ts", VUE_INDEX);
5191
+ files.set("src/App.ts", VUE_APP(c));
5192
+ }
5193
+ return files;
5194
+ }
5195
+ /** 从 server 拿平台默认档的四个 runtime URL。拿不到就报错——静默退成 custom 会让作者以为自己在 shared 档。 */
5196
+ async function fetchRuntime(api) {
5197
+ let client;
5198
+ try {
5199
+ client = await api();
5200
+ } catch (err) {
5201
+ throw new CliError(`shared 档要从 server 取 runtime 地址,但 CLI 未配置:${err instanceof Error ? err.message : String(err)}`, "先 jdu init / login,或改用 --runtime custom(自带 react 全内联,不需要 server)");
5202
+ }
5203
+ const res = await client.getJson("/api/runtimes");
5204
+ const name = typeof res?.default === "string" ? res.default : null;
5205
+ const tier = name ? res.tiers?.[name] : void 0;
5206
+ const paths = tier?.paths;
5207
+ if (!name || !tier || paths === null || typeof paths !== "object") throw new CliError(`server ${client.server} 没有可用的 shared 档 runtime`, "实例可能关了 official 种子(JIANDU_SEED_OFFICIAL=0);改用 --runtime custom");
5208
+ const out = {};
5209
+ for (const spec of REACT_SPECIFIERS) {
5210
+ const url = paths[spec];
5211
+ if (typeof url !== "string") throw new CliError(`/api/runtimes 的 ${name} 档缺 ${spec}`);
5212
+ out[spec] = url.startsWith("/") ? url : `/${url}`;
5213
+ }
5214
+ return {
5215
+ paths: out,
5216
+ label: `${name}${typeof tier.label === "string" ? ` · ${tier.label}` : ""}`
5217
+ };
5218
+ }
5219
+ async function createWidget(nameArg, opts, api) {
5220
+ if (!NAME_RE.test(nameArg)) throw new CliError(`widget 名不合法:${nameArg}`, "字母开头,只允许字母 / 数字 / - / _,最长 64");
5221
+ const template = opts.template;
5222
+ if (!TEMPLATES.includes(template)) throw new CliError(`未知模板:${opts.template}`, `可选:${TEMPLATES.join(" | ")}`);
5223
+ const runtimeOpt = opts.runtime;
5224
+ if (!RUNTIMES.includes(runtimeOpt)) throw new CliError(`未知 runtime:${opts.runtime}`, `可选:${RUNTIMES.join(" | ")}`);
5225
+ const name = pascalCase(nameArg);
5226
+ const dir = resolve(opts.dir ?? kebabCase(name));
5227
+ if (existsSync(dir) && readdirSync(dir).length > 0) throw new CliError(`目录非空:${dir}`, "换个名字,或用 --dir 指定输出目录");
5228
+ let runtime = null;
5229
+ let tierLabel = null;
5230
+ if (template === "react" && runtimeOpt === "shared") {
5231
+ const rt = await fetchRuntime(api);
5232
+ runtime = rt.paths;
5233
+ tierLabel = rt.label;
5234
+ }
5235
+ const files = filesFor({
5236
+ name,
5237
+ cls: name,
5238
+ kebab: kebabCase(name),
5239
+ template,
5240
+ runtime,
5241
+ tierLabel
5242
+ });
5243
+ for (const [rel, content] of files) {
5244
+ const abs = join(dir, rel);
5245
+ mkdirSync(dirname(abs), { recursive: true });
5246
+ writeFileSync(abs, content, "utf8");
5247
+ }
5248
+ return {
5249
+ dir,
5250
+ files: [...files.keys()],
5251
+ template,
5252
+ runtime: template === "react" ? runtime ? "shared" : "custom" : null,
5253
+ tier: tierLabel
5254
+ };
5255
+ }
5256
+ //#endregion
3833
5257
  //#region src/cli.ts
3834
5258
  async function client() {
3835
5259
  return new ApiClient(await loadRuntimeConfig());
@@ -3839,22 +5263,39 @@ function collectTag(value, previous) {
3839
5263
  return [...previous ?? [], value];
3840
5264
  }
3841
5265
  var program = new Command();
3842
- program.name("jdu").description("jiandu 命令行").version("0.1.0").showHelpAfterError();
3843
- program.command("login").description("登录到 jiandu server(forward-auth 会打开浏览器走 SSO)").requiredOption("--server <url>", "jiandu server 地址").option("--token <token>", "直接保存访问 token(token provider / 跳过浏览器)").option("--issuer <url>", "OIDC issuer(缺省读 healthz.oidc 或 JIANDU_OIDC_ISSUER)").option("--client-id <id>", "OIDC client id(缺省读 healthz.oidc 或 JIANDU_OIDC_CLIENT_ID)").action(async (opts) => {
5266
+ program.name("jdu").description("jiandu 命令行").version("0.3.0").showHelpAfterError();
5267
+ program.command("init").description("选定部署目标并初始化本地配置(默认官方线上;自部署 / 本机需显式指定)").option("--local", "目标是本机自部署(http://127.0.0.1:8080)").option("--server <url>", "目标是指定的自部署地址").option("--token <token>", "一并完成 token 登录(server 首启 stdout / data/initial-token.txt)").option("--no-browser", "站点支持浏览器授权时也不打开浏览器,只给 token 指引").option("--json", "机器可读输出(agent 联动用;不会打开浏览器)", false).action(async (opts) => {
5268
+ const result = await runInit({
5269
+ ...opts,
5270
+ browser: opts.browser && !opts.json
5271
+ });
5272
+ if (opts.json) {
5273
+ process.stdout.write(`${JSON.stringify(result)}\n`);
5274
+ return;
5275
+ }
5276
+ process.stdout.write(`目标 ${result.target} · ${result.server}(auth: ${result.authProvider})\n`);
5277
+ process.stdout.write(`配置已写入 ${result.configPath}\n`);
5278
+ process.stdout.write(result.loggedIn ? "凭据就绪,可以直接 jdu push。\n" : `下一步:${result.next ?? ""}\n`);
5279
+ });
5280
+ program.command("login").description("登录到 jiandu server(forward-auth 默认打开浏览器走 SSO;--user 则直连注入身份头)").requiredOption("--server <url>", "jiandu server 地址").option("--token <token>", "直接保存访问 token(token provider / 跳过浏览器)").option("--user <owner>", "forward-auth:以指定 owner 直连(请求注入 X-Forwarded-User 头,跳过 SSO)").option("--proxy-secret <secret>", "forward-auth:网关共享密钥(请求注入 X-Jiandu-Proxy-Secret 头)").option("--issuer <url>", "OIDC issuer(缺省读 healthz.oidc 或 JIANDU_OIDC_ISSUER)").option("--client-id <id>", "OIDC client id(缺省读 healthz.oidc 或 JIANDU_OIDC_CLIENT_ID)").action(async (opts) => {
3844
5281
  const result = await runLogin({
3845
5282
  server: opts.server,
3846
5283
  token: opts.token,
5284
+ user: opts.user,
5285
+ proxySecret: opts.proxySecret,
3847
5286
  issuer: opts.issuer,
3848
5287
  clientId: opts.clientId
3849
5288
  });
3850
5289
  process.stdout.write(`${result.configPath}\n`);
3851
5290
  if (result.source === "anonymous") process.stderr.write("这个 server 是匿名模式,无需登录。\n");
5291
+ if (result.source === "forward-auth") process.stderr.write("已保存 forward-auth 直连身份(X-Forwarded-User)。\n");
3852
5292
  if (result.source === "oidc-cache") process.stderr.write("已复用本机 SSO credential。\n");
3853
5293
  if (result.source === "oidc-browser") process.stderr.write("SSO 登录成功。\n");
3854
5294
  if (result.warning) process.stderr.write(`jdu: ${result.warning}\n`);
3855
5295
  });
3856
5296
  program.command("push").argument("<entry.md>", "入口 markdown 文件").description("递归收集本地引用、增量上传并发布一个新版本").option("--title <title>", "文档标题,默认取首个一级标题").option("--visibility <v>", `${VISIBILITIES.join(" | ")}(新文档默认 private;更新已有文档时不传则保持原值)`).option("--id <docId>", "复用已有文档 id(发布新版本)").option("--tag <name>", "打标签,可重复;不传则保持原有标签", collectTag, void 0).option("--official", "上架到官方知识库(需管理员;同时公开)").action(async (entry, opts) => {
3857
- await pushDoc(await client(), entry, opts);
5297
+ const res = await pushDoc(await client(), entry, opts);
5298
+ process.stdout.write(`${res.url}\n`);
3858
5299
  });
3859
5300
  program.command("list").description("列出文档").option("--all", "包含已归档", false).action(async (opts) => {
3860
5301
  printTable(asRows(await (await client()).getJson(`/api/docs${opts.all ? "?all=1" : ""}`), "docs"), [
@@ -3866,10 +5307,6 @@ program.command("list").description("列出文档").option("--all", "包含已
3866
5307
  key: "visibility",
3867
5308
  header: "VISIBILITY"
3868
5309
  },
3869
- {
3870
- key: "official",
3871
- header: "OFFICIAL"
3872
- },
3873
5310
  {
3874
5311
  key: "archived",
3875
5312
  header: "ARCHIVED"
@@ -3887,16 +5324,16 @@ program.command("versions").argument("<docId>").description("列出某文档的
3887
5324
  header: "SEQ"
3888
5325
  },
3889
5326
  {
3890
- key: "created_at",
5327
+ key: "createdAt",
3891
5328
  header: "CREATED"
3892
5329
  },
3893
5330
  {
3894
- key: "source_hash",
5331
+ key: "sourceHash",
3895
5332
  header: "SOURCE"
3896
5333
  },
3897
5334
  {
3898
- key: "html_hash",
3899
- header: "HTML"
5335
+ key: "resolvedMdHash",
5336
+ header: "RESOLVED"
3900
5337
  }
3901
5338
  ]);
3902
5339
  });
@@ -3963,21 +5400,79 @@ tag.command("rm").argument("<name>").description("删除标签:只解绑,文
3963
5400
  const res = await (await client()).postJson("/api/tags/delete", { name });
3964
5401
  process.stdout.write(`已删除 ${name}(解绑 ${String(res.unlinked ?? 0)} 篇)\n`);
3965
5402
  });
3966
- program.command("share").argument("<docId>").description("改可见性 / 团队归属 / 按人授权").option("--visibility <v>", VISIBILITIES.join(" | ")).option("--team <teamId>", "设为团队可见;传空串取消").option("--to <user>", "授权给某人,可重复(整体覆盖)", collectTag, void 0).action(async (docId, opts) => {
5403
+ program.command("comments").argument("<docId>").description("列出文档的评论线程(默认只列未 resolve;块 id / 引文 / 作者 / 回复)").option("--all", "包含已 resolve 的线程", false).action(async (docId, opts) => {
5404
+ await listComments(await client(), docId, opts);
5405
+ });
5406
+ program.command("reply").argument("<threadId>", "线程 id(jdu comments 首列;给回复 id 也行,一律挂到根)").requiredOption("-m, --message <text>", "回复正文").option("--ai", "以 agent 身份回复(authorType=ai,阅读页会标出来)", false).description("回复一条评论线程").action(async (threadId, opts) => {
5407
+ await replyComment(await client(), threadId, opts);
5408
+ });
5409
+ program.command("resolve").argument("<threadId>").option("--undo", "重新打开", false).description("标记线程已处理(仅文档 owner)").action(async (threadId, opts) => {
5410
+ await resolveComment(await client(), threadId, opts);
5411
+ });
5412
+ var template = program.command("template").description("文档模板(打了 template 标签的文档)");
5413
+ template.command("list").description("列出模板:我的 + 官方").option("--all", "包含已归档", false).action(async (opts) => {
5414
+ await listTemplates(await client(), opts);
5415
+ });
5416
+ template.command("pull").argument("<docId>", "模板 id(jdu template list 首列)").description("取模板原文(stdout;--out 写文件),按骨架填完再 jdu push").option("--out <file>", "写到文件而不是 stdout").action(async (docId, opts) => {
5417
+ await pullTemplate(await client(), docId, opts);
5418
+ });
5419
+ template.command("push").argument("<file.md>", "模板文件").description("发布 / 更新模板(= jdu push 并打上 template 标签;更新时保留原有其它标签)").option("--title <title>", "模板名,默认取首个一级标题").option("--id <docId>", "更新已有模板").option("--official", "上架为官方模板(需管理员)").action(async (file, opts) => {
5420
+ const res = await pushTemplate(await client(), file, opts);
5421
+ process.stdout.write(`${res.url}\n`);
5422
+ });
5423
+ program.command("mcp").description("以 MCP server(stdio)暴露 list_docs / search_docs / read_doc / list_versions / push_doc,给本机 agent 用").action(async () => {
5424
+ await runMcp(await client());
5425
+ });
5426
+ program.command("share").argument("<docId>").description("改可见性,或管理读者池(定向分享,可撤销)").option("--visibility <v>", VISIBILITIES.join(" | ")).option("--to <reader>", "加入读者池(身份串,如邮箱 / forward-auth 的 owner),可重复", collectTag, void 0).option("--revoke <reader>", "从读者池移除,立刻失效,可重复", collectTag, void 0).option("--readers", "列出当前读者池", false).action(async (docId, opts) => {
3967
5427
  const api = await client();
3968
- const id = encodeURIComponent(docId);
3969
- if (opts.visibility !== void 0 || opts.team !== void 0) {
3970
- const body = {};
3971
- if (opts.visibility !== void 0) body.visibility = opts.visibility;
3972
- if (opts.team !== void 0) body.teamId = opts.team;
3973
- const res = await api.putJson(`/api/docs/${id}/visibility`, body);
3974
- process.stdout.write(`可见性 ${String(res.visibility)}${res.teamId ? ` · 团队 ${String(res.teamId)}` : ""}\n`);
3975
- }
3976
- if (opts.to !== void 0) {
3977
- const res = await api.putJson(`/api/docs/${id}/grants`, { grantees: opts.to });
3978
- const list = Array.isArray(res.grantees) ? res.grantees.map(String) : [];
3979
- process.stdout.write(`已授权:${list.join(", ") || "(已清空)"}\n`);
5428
+ const url = `/api/docs/${encodeURIComponent(docId)}`;
5429
+ if (opts.readers) {
5430
+ const res = await api.getJson(`${url}/readers`);
5431
+ const readers = Array.isArray(res.readers) ? res.readers.map(String) : [];
5432
+ process.stdout.write(`${readers.length > 0 ? readers.join("\n") : "(读者池为空)"}\n`);
5433
+ return;
5434
+ }
5435
+ if (opts.to !== void 0 || opts.revoke !== void 0) {
5436
+ const res = await api.putJson(`${url}/readers`, {
5437
+ add: opts.to ?? [],
5438
+ remove: opts.revoke ?? []
5439
+ });
5440
+ const readers = Array.isArray(res.readers) ? res.readers.map(String) : [];
5441
+ process.stdout.write(`${readers.length > 0 ? readers.join("\n") : "(读者池为空)"}\n`);
5442
+ return;
3980
5443
  }
5444
+ if (opts.visibility === void 0) throw new CliError("share 需要 --visibility / --to / --revoke / --readers 之一");
5445
+ const res = await api.putJson(`${url}/visibility`, { visibility: opts.visibility });
5446
+ process.stdout.write(`可见性 ${String(res.visibility)}\n`);
5447
+ });
5448
+ var tokenCmd = program.command("token").description("CLI token(首启那枚与浏览器授权签发的)");
5449
+ tokenCmd.command("list").description("列出我的 token;CURRENT 是本次请求用的那枚").action(async () => {
5450
+ printTable(asRows(await (await client()).getJson("/api/tokens"), "tokens"), [
5451
+ {
5452
+ key: "id",
5453
+ header: "ID"
5454
+ },
5455
+ {
5456
+ key: "label",
5457
+ header: "LABEL"
5458
+ },
5459
+ {
5460
+ key: "createdAt",
5461
+ header: "CREATED"
5462
+ },
5463
+ {
5464
+ key: "lastUsedAt",
5465
+ header: "LAST_USED"
5466
+ },
5467
+ {
5468
+ key: "current",
5469
+ header: "CURRENT"
5470
+ }
5471
+ ]);
5472
+ });
5473
+ tokenCmd.command("rm").argument("<id>", "jdu token list 里的 ID").description("吊销一枚 token(正在使用的那枚不能删)").action(async (id) => {
5474
+ await (await client()).deleteJson(`/api/tokens/${encodeURIComponent(id)}`);
5475
+ process.stdout.write(`revoked ${id}\n`);
3981
5476
  });
3982
5477
  var officialCmd = program.command("official").description("官方知识库上架");
3983
5478
  officialCmd.command("list").description("列出已上架的官方文档").action(async () => {
@@ -3997,22 +5492,6 @@ officialCmd.command("rm").argument("<docId>").description("从官方知识库拿
3997
5492
  await (await client()).putJson(`/api/docs/${encodeURIComponent(docId)}/official`, { official: false });
3998
5493
  process.stdout.write(`unofficial ${docId}\n`);
3999
5494
  });
4000
- program.command("teams").description("列出我所属的团队").action(async () => {
4001
- printTable(asRows(await (await client()).getJson("/api/teams"), "teams"), [
4002
- {
4003
- key: "id",
4004
- header: "ID"
4005
- },
4006
- {
4007
- key: "name",
4008
- header: "NAME"
4009
- },
4010
- {
4011
- key: "members",
4012
- header: "MEMBERS"
4013
- }
4014
- ]);
4015
- });
4016
5495
  program.command("blob").description("内容寻址 blob(shared 档 runtime 等原始资产)").command("push").argument("<files...>", "要上传的文件(如 packages/widgets/dist/_runtime/*.js)").description("按内容 hash 上传为 blob,输出 /blob/<hash> 地址(已存在则跳过)").action(async (files) => {
4017
5496
  const api = await client();
4018
5497
  const entries = await Promise.all(files.map(async (f) => ({
@@ -4025,7 +5504,22 @@ program.command("blob").description("内容寻址 blob(shared 档 runtime 等
4025
5504
  process.stdout.write(`${state} /blob/${e.hash} ${e.path}\n`);
4026
5505
  }
4027
5506
  });
4028
- var widget = program.command("widget").description("widget 注册表");
5507
+ var widget = program.command("widget").description("widget 注册表与作者工作流");
5508
+ widget.command("create").argument("<name>", "widget 名(字母开头,如 StarRating / star-rating)").description("生成一个能直接 build / dev / push 的 widget 目录(契约类型、框架基类、构建脚本全内联)").option("--template <t>", TEMPLATES.join(" | "), "vanilla").option("--runtime <r>", `react 模板:${RUNTIMES.join(" | ")}。shared = 平台版本档 runtime(产物几 KB,需能连 server);custom = 自带 react 全内联`, "shared").option("--dir <path>", "输出目录,默认 ./<name 的 kebab-case>").action(async (name, opts) => {
5509
+ const res = await createWidget(name, opts, client);
5510
+ process.stdout.write(`${res.dir}\n`);
5511
+ process.stderr.write(`已生成 ${res.files.length} 个文件(${res.template}${res.runtime ? ` · runtime ${res.runtime}` : ""}${res.tier ? ` · ${res.tier}` : ""})\n下一步:cd ${res.dir} && npm install && npm run dev\n`);
5512
+ });
5513
+ widget.command("dev").argument("[dir]", "widget 目录(jdu widget create 生成的)或产物目录,默认当前目录", ".").description("本地预览:起 npm run watch + 预览页(sampleData 挂载、改代码自动刷新、暗色切换)").option("--port <n>", "预览端口", "5173").option("--no-watch", "不自动跑 npm run watch").action(async (dir, opts) => {
5514
+ const port = Number.parseInt(opts.port, 10);
5515
+ if (!Number.isInteger(port) || port <= 0) throw new CliError(`端口不合法:${opts.port}`);
5516
+ const server = await loadRuntimeConfig().then((c) => c.server).catch(() => void 0);
5517
+ await runWidgetDev(dir, {
5518
+ port,
5519
+ watch: opts.watch,
5520
+ server
5521
+ });
5522
+ });
4029
5523
  widget.command("push").argument("<dir>", "含 widget.json + index.js[ + index.css] 的构建产物目录").description("发布 widget").action(async (dir) => {
4030
5524
  await pushWidget(await client(), dir);
4031
5525
  });