@monoedge/jdu-cli 0.2.0 → 0.4.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 +8 -4
  2. package/dist/cli.js +1695 -92
  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 login 的浏览器授权(#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 login;不想开浏览器就带 --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 login 再试一次");
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) {
@@ -3449,11 +3596,24 @@ async function probeApiWithBearer(server, token) {
3449
3596
  redirect: "manual"
3450
3597
  })).ok;
3451
3598
  }
3452
- async function runLogin(opts) {
3599
+ /**
3600
+ * forward-auth / OIDC 分支,外加 --user 直连与 --token 直存。
3601
+ * 不是命令入口——入口是 init.ts 的 runLogin,它探完 healthz 才分派到这里。
3602
+ */
3603
+ async function runOidcLogin(opts) {
3453
3604
  const server = opts.server.trim().replace(/\/+$/, "");
3454
3605
  if (server === "") throw new CliError("--server 不能为空");
3455
3606
  const healthz = await (opts.fetchHealthz ?? fetchHealthz)(server);
3456
3607
  const provider = healthz.authProvider ?? "token";
3608
+ if (opts.user !== void 0 && opts.user.trim() !== "") return {
3609
+ configPath: saveConfig({
3610
+ server,
3611
+ user: opts.user.trim(),
3612
+ proxySecret: opts.proxySecret && opts.proxySecret !== "" ? opts.proxySecret : void 0
3613
+ }),
3614
+ source: "forward-auth",
3615
+ warning: provider !== "forward-auth" ? `这个 server 是 ${provider} 鉴权,--user 只对 forward-auth 生效;当前请求仍会带 X-Forwarded-User 头(无害)。` : void 0
3616
+ };
3457
3617
  if (opts.token !== void 0 && opts.token !== "") return {
3458
3618
  configPath: saveConfig({
3459
3619
  server,
@@ -3465,7 +3625,7 @@ async function runLogin(opts) {
3465
3625
  configPath: saveConfig({ server }),
3466
3626
  source: "anonymous"
3467
3627
  };
3468
- if (provider === "token") throw new CliError("这个 server 用 token 鉴权,需要 --token", "jdu login --server <url> --token <token>,token 在 server 首次启动的 stdout / data/initial-token.txt");
3628
+ if (provider === "token" || provider === "password") throw new CliError(`这个 server 用 ${provider} 鉴权,CLI 需要 --token`, "jdu login --server <url> --token <token>(token 在 server 首启 stdout / data/initial-token.txt");
3469
3629
  const oidc = resolveOidc({
3470
3630
  issuer: opts.issuer,
3471
3631
  clientId: opts.clientId,
@@ -3501,31 +3661,147 @@ async function runLogin(opts) {
3501
3661
  };
3502
3662
  }
3503
3663
  //#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);
3664
+ //#region src/init.ts
3665
+ /**
3666
+ * jdu login:选定站点、拿到凭据、写好本地配置,一条命令把「登录到哪台简牍」定下来。
3667
+ *
3668
+ * 目标必须显式给出(没有默认站点,官方商用服务尚未上线):
3669
+ * --local 本机自部署(http://127.0.0.1:8080)
3670
+ * --server <u> 任意自部署地址
3671
+ *
3672
+ * agent 联动而设计:全 flag 驱动、无交互提示、--json 输出机器可读结果,
3673
+ * 拿到 next 字段就知道下一步该干什么(要不要 token、去哪拿)。
3674
+ *
3675
+ * 登录方式全听 server 的(healthz.cliAuth,#66),用户不必先知道自己的站点用什么鉴权:
3676
+ * browser → 开浏览器授权换 token
3677
+ * oidc → 委托 runOidcLogin 走 SSO(forward-auth)
3678
+ * token → 把 server 给的 tokenHint 拼进 next,告诉去哪拿
3679
+ * anonymous→ 直接就绪
3680
+ * CLI 不按「官方 / 自部署」猜,也不再要求用户在 init / login 之间二选一。
3681
+ */
3682
+ var LOCAL_SERVER = "http://127.0.0.1:8080";
3683
+ function resolveInitTarget(opts) {
3684
+ if (opts.local && opts.server) throw new CliError("--local 与 --server 只能二选一");
3685
+ if (opts.server !== void 0) {
3686
+ const server = opts.server.trim().replace(/\/+$/, "");
3687
+ let parsed;
3688
+ try {
3689
+ parsed = new URL(server);
3690
+ } catch {
3691
+ throw new CliError(`--server 不是合法 URL:${opts.server}`);
3692
+ }
3693
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || /[\s'"`$\\;|&<>(){}*?#\[\]]/.test(server)) throw new CliError(`--server 需要干净的 http/https 地址(不含空白与 shell 元字符):${opts.server}`);
3694
+ return {
3695
+ target: "custom",
3696
+ server
3697
+ };
3698
+ }
3699
+ if (opts.local) return {
3700
+ target: "local",
3701
+ server: LOCAL_SERVER
3702
+ };
3703
+ throw new CliError("jdu login 需要指定站点", "自部署用 --server <url>,本机用 --local");
3517
3704
  }
3518
- /** 定宽文本表,便于人和 agent 直接读;无数据时给一行提示而不是空输出。 */
3519
- function printTable(rows, columns) {
3520
- if (rows.length === 0) {
3521
- process.stdout.write("(空)\n");
3522
- return;
3705
+ async function runLogin(opts) {
3706
+ const { target, server } = resolveInitTarget(opts);
3707
+ const healthz = await (opts.fetchHealthz ?? fetchHealthz)(server);
3708
+ const provider = healthz.authProvider ?? "token";
3709
+ const methods = healthz.cliAuth?.methods ?? fallbackMethods(provider);
3710
+ const base = {
3711
+ target,
3712
+ server,
3713
+ authProvider: provider,
3714
+ authMethods: methods
3715
+ };
3716
+ if (opts.user !== void 0 && opts.user.trim() !== "") {
3717
+ const r = await runOidcLogin({
3718
+ server,
3719
+ user: opts.user,
3720
+ proxySecret: opts.proxySecret,
3721
+ fetchHealthz: async () => healthz
3722
+ });
3723
+ return {
3724
+ ...base,
3725
+ loggedIn: true,
3726
+ configPath: r.configPath,
3727
+ next: null,
3728
+ warning: r.warning
3729
+ };
3523
3730
  }
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`);
3731
+ if (opts.token !== void 0 && opts.token !== "") {
3732
+ if (!await (opts.probe ?? probeApiWithBearer)(server, opts.token)) throw new CliError("token 校验失败(/api/docs 未放行)", "确认 token 正确、且网关放行 Authorization: Bearer");
3733
+ const path = saveConfig({
3734
+ server,
3735
+ token: opts.token
3736
+ });
3737
+ return {
3738
+ ...base,
3739
+ loggedIn: true,
3740
+ configPath: path,
3741
+ next: null
3742
+ };
3743
+ }
3744
+ const oidc = healthz.oidc?.issuer && healthz.oidc.clientId ? {
3745
+ issuer: healthz.oidc.issuer,
3746
+ clientId: healthz.oidc.clientId
3747
+ } : void 0;
3748
+ const path = saveConfig(oidc ? {
3749
+ server,
3750
+ oidc
3751
+ } : { server });
3752
+ if (provider === "anonymous") return {
3753
+ ...base,
3754
+ loggedIn: true,
3755
+ configPath: path,
3756
+ next: null
3757
+ };
3758
+ const authorizeUrl = healthz.cliAuth?.authorizeUrl;
3759
+ if (opts.browser && methods.includes("browser") && authorizeUrl) {
3760
+ const withToken = saveConfig({
3761
+ server,
3762
+ token: await (opts.authorize ?? browserAuthorize)({
3763
+ server,
3764
+ authorizeUrl
3765
+ })
3766
+ });
3767
+ return {
3768
+ ...base,
3769
+ loggedIn: true,
3770
+ configPath: withToken,
3771
+ next: null
3772
+ };
3773
+ }
3774
+ const viaToken = `jdu login --server '${server}' --token <token>(${healthz.cliAuth?.tokenHint ?? "token 在 server 首启 stdout / data/initial-token.txt"})`;
3775
+ if (provider === "forward-auth" && opts.browser) {
3776
+ const r = await runOidcLogin({
3777
+ server,
3778
+ issuer: opts.issuer,
3779
+ clientId: opts.clientId,
3780
+ fetchHealthz: async () => healthz,
3781
+ oidcLogin: opts.oidcLogin,
3782
+ probe: opts.probe
3783
+ });
3784
+ return {
3785
+ ...base,
3786
+ loggedIn: true,
3787
+ configPath: r.configPath,
3788
+ next: null,
3789
+ warning: r.warning
3790
+ };
3791
+ }
3792
+ const next = methods.includes("browser") ? `jdu login --server '${server}'(不带 --json / --no-browser,打开浏览器授权),或 ${viaToken}` : viaToken;
3793
+ return {
3794
+ ...base,
3795
+ loggedIn: false,
3796
+ configPath: path,
3797
+ next
3798
+ };
3799
+ }
3800
+ /** 老 server 的 healthz 没有 cliAuth:按 provider 推断,与 server 侧 cliAuthOf 的保守档一致。 */
3801
+ function fallbackMethods(provider) {
3802
+ if (provider === "anonymous") return [];
3803
+ if (provider === "forward-auth") return ["oidc", "token"];
3804
+ return ["token"];
3529
3805
  }
3530
3806
  /** 只取链接/图片的目标部分,标签内容可以任意嵌套,不参与匹配。 */
3531
3807
  var MD_DEST_RE = /\]\(([^)]*)\)/g;
@@ -3644,11 +3920,8 @@ function collectReferences(entryPath, maxDepth = 10) {
3644
3920
  }
3645
3921
  //#endregion
3646
3922
  //#region src/push.ts
3647
- var VISIBILITIES = [
3648
- "private",
3649
- "link",
3650
- "public"
3651
- ];
3923
+ /** 可见性三档。link 档已废除:定向分享走读者池(jdu share --to)。 */
3924
+ var VISIBILITIES = ["private", "public"];
3652
3925
  function log(line) {
3653
3926
  process.stderr.write(`${line}\n`);
3654
3927
  }
@@ -3661,6 +3934,7 @@ function inferTitle(entryPath) {
3661
3934
  } catch {}
3662
3935
  return basename(entryPath, extname(entryPath));
3663
3936
  }
3937
+ /** 收集 → 协商上传 → 发布。进度走 stderr;结果返回给调用方(CLI 打印 URL,mcp 转成 JSON)。 */
3664
3938
  async function pushDoc(api, entryArg, opts) {
3665
3939
  const visibility = opts.visibility;
3666
3940
  if (visibility !== void 0 && !VISIBILITIES.includes(visibility)) throw new CliError(`不支持的 visibility:${String(opts.visibility)}`, `可选值:${VISIBILITIES.join(" | ")}`);
@@ -3697,20 +3971,437 @@ async function pushDoc(api, entryArg, opts) {
3697
3971
  if (opts.id !== void 0 && opts.id !== "") body.id = opts.id;
3698
3972
  if (opts.tag !== void 0) body.tags = opts.tag.flatMap((t) => t.split(",")).map((t) => t.trim()).filter((t) => t.length > 0);
3699
3973
  if (opts.official === true) body.official = true;
3974
+ if (opts.team !== void 0) body.team = opts.team;
3700
3975
  const res = await api.postJson("/api/docs", body);
3701
3976
  const id = typeof res?.id === "string" ? res.id : opts.id;
3702
3977
  const url = typeof res?.url === "string" && res.url !== "" ? res.url : id !== void 0 ? api.url(`/d/${id}`) : void 0;
3978
+ const warnings = Array.isArray(res?.warnings) ? res.warnings.map(String) : [];
3979
+ for (const w of warnings) log(`warn: ${w}`);
3703
3980
  if (typeof res?.seq === "number") log(`已发布 ${id ?? ""} v${res.seq}`);
3704
3981
  if (url === void 0) throw new CliError("server 未返回文档 id 或 url,无法给出访问地址");
3705
- process.stdout.write(`${url}\n`);
3982
+ return {
3983
+ id,
3984
+ seq: typeof res?.seq === "number" ? res.seq : void 0,
3985
+ url,
3986
+ warnings
3987
+ };
3706
3988
  }
3707
3989
  //#endregion
3708
- //#region src/widget.ts
3709
- var SCOPES = [
3710
- "official",
3711
- "team",
3712
- "personal"
3990
+ //#region src/output.ts
3991
+ /** server 可能返回裸数组,也可能包一层 `{ docs: [...] }`,两种都认。 */
3992
+ function asRows(payload, key) {
3993
+ const raw = Array.isArray(payload) ? payload : payload !== null && typeof payload === "object" ? payload[key] : void 0;
3994
+ if (!Array.isArray(raw)) return [];
3995
+ return raw.filter((r) => r !== null && typeof r === "object");
3996
+ }
3997
+ function cell(value) {
3998
+ if (value === null || value === void 0) return "-";
3999
+ if (typeof value === "boolean") return value ? "yes" : "no";
4000
+ if (typeof value === "number" && value > 0xe8d4a51000) return new Date(value).toISOString().slice(0, 16).replace("T", " ");
4001
+ if (typeof value === "object") return JSON.stringify(value);
4002
+ return String(value);
4003
+ }
4004
+ /** 定宽文本表,便于人和 agent 直接读;无数据时给一行提示而不是空输出。 */
4005
+ function printTable(rows, columns) {
4006
+ if (rows.length === 0) {
4007
+ process.stdout.write("(空)\n");
4008
+ return;
4009
+ }
4010
+ const body = rows.map((row) => columns.map((c) => cell(row[c.key])));
4011
+ const widths = columns.map((c, i) => Math.max(c.header.length, ...body.map((r) => (r[i] ?? "").length)));
4012
+ const line = (cells) => cells.map((v, i) => v.padEnd(widths[i] ?? 0)).join(" ").trimEnd();
4013
+ process.stdout.write(`${line(columns.map((c) => c.header))}\n`);
4014
+ for (const r of body) process.stdout.write(`${line(r)}\n`);
4015
+ }
4016
+ //#endregion
4017
+ //#region src/template.ts
4018
+ /**
4019
+ * 文档模板(#7):模板就是打了 `template` 标签的普通文档。
4020
+ *
4021
+ * ponytail: 不开 templates 表、不加 /api/templates——版本链、可见性(personal 私有 / official 全站)、
4022
+ * 标签检索全是现成的,独立表只会把同一套东西再抄一遍。server **不做**任何占位符替换,
4023
+ * 模板原样发出去,怎么填是 agent 的事。agent 侧走 MCP 的 list_templates → read_doc → 按骨架写 → push_doc。
4024
+ */
4025
+ var TEMPLATE_TAG = "template";
4026
+ var hasTemplateTag = (d) => Array.isArray(d.tags) && d.tags.map(String).includes("template");
4027
+ async function listTemplates(api, opts) {
4028
+ const [mine, official] = await Promise.all([api.getJson(`/api/docs${opts.all ? "?all=1" : ""}`), api.getJson("/api/official")]);
4029
+ const mineRows = asRows(mine, "docs").filter(hasTemplateTag);
4030
+ const mineIds = new Set(mineRows.map((d) => String(d["id"])));
4031
+ const officialRows = asRows(official, "docs").filter((d) => hasTemplateTag(d) && !mineIds.has(String(d["id"])));
4032
+ printTable([...mineRows.map((d) => ({
4033
+ ...d,
4034
+ scope: d["visibility"] === "official" ? "official" : "personal"
4035
+ })), ...officialRows.map((d) => ({
4036
+ ...d,
4037
+ scope: "official"
4038
+ }))], [
4039
+ {
4040
+ key: "id",
4041
+ header: "ID"
4042
+ },
4043
+ {
4044
+ key: "scope",
4045
+ header: "SCOPE"
4046
+ },
4047
+ {
4048
+ key: "title",
4049
+ header: "TITLE"
4050
+ },
4051
+ {
4052
+ key: "updatedAt",
4053
+ header: "UPDATED"
4054
+ }
4055
+ ]);
4056
+ }
4057
+ /** 拉模板原文:默认打到 stdout(agent 直接读 / 重定向成新文件),--out 落盘。 */
4058
+ async function pullTemplate(api, id, opts) {
4059
+ const md = await api.getText(`/d/${encodeURIComponent(id)}.md`);
4060
+ if (opts.out) {
4061
+ writeFileSync(opts.out, md, "utf8");
4062
+ process.stderr.write(`已写入 ${opts.out}\n`);
4063
+ return;
4064
+ }
4065
+ process.stdout.write(md.endsWith("\n") ? md : `${md}\n`);
4066
+ }
4067
+ /**
4068
+ * 发布 / 更新模板 = push + 保证带 `template` 标签。更新已有模板时保留它原有的其它标签
4069
+ * (push 的 tags 字段是整体覆盖语义,不先取回就会把别的标签抹掉)。
4070
+ */
4071
+ async function pushTemplate(api, file, opts) {
4072
+ let tags = [TEMPLATE_TAG];
4073
+ if (opts.id) {
4074
+ const cur = await api.getJson(`/api/docs/${encodeURIComponent(opts.id)}/tags`).catch(() => ({}));
4075
+ const existing = Array.isArray(cur.tags) ? cur.tags.map(String) : [];
4076
+ tags = [.../* @__PURE__ */ new Set([...existing, TEMPLATE_TAG])];
4077
+ }
4078
+ return pushDoc(api, file, {
4079
+ title: opts.title,
4080
+ id: opts.id,
4081
+ tag: tags,
4082
+ official: opts.official
4083
+ });
4084
+ }
4085
+ //#endregion
4086
+ //#region src/mcp.ts
4087
+ /**
4088
+ * `jdu mcp`:把已有 HTTP API 包成 MCP server(stdio),给 Claude Code / codex / dsh 这类本机 agent
4089
+ * 当可读可写的知识库用(#9 A 段)。凭据复用 ~/.config/jiandu/config.json,server 端零改动。
4090
+ *
4091
+ * ponytail: 不引 @modelcontextprotocol/sdk——它带 express / hono / zod 一整套,而这里只需要
4092
+ * initialize / tools/list / tools/call 三个方法的 JSON-RPC 换行分帧。协议有变再换官方 SDK。
4093
+ * stdout 是协议信道:本文件之外任何写 stdout 的代码都不能在 mcp 模式下被调到(pushDoc 已改为返回值)。
4094
+ */
4095
+ var SUPPORTED_PROTOCOLS = [
4096
+ "2025-06-18",
4097
+ "2025-03-26",
4098
+ "2024-11-05"
3713
4099
  ];
4100
+ var SERVER_INFO = {
4101
+ name: "jiandu",
4102
+ version: "0.2.0"
4103
+ };
4104
+ var INSTRUCTIONS = "jiandu(简牍)是一个 Markdown 知识库。先用 search_docs / list_docs 找到文档 id,read_doc 拿 markdown 原文;改稿后用 push_doc 发布新版本(带 id 才是更新,不带是新建)。文档里的 widget fence(```widget:Name)保留原样。写新文档前先 list_templates 看有没有对应骨架(周报 / 技术方案…),有就 read_doc 取模板按节填,占位注释自己替换掉。";
4105
+ function str(v, name) {
4106
+ if (typeof v !== "string" || v.trim() === "") throw new CliError(`${name} 必填且为非空字符串`);
4107
+ return v.trim();
4108
+ }
4109
+ function asDoc(raw, scope, server) {
4110
+ if (raw === null || typeof raw !== "object") return null;
4111
+ const d = raw;
4112
+ if (typeof d["id"] !== "string") return null;
4113
+ return {
4114
+ id: d["id"],
4115
+ title: typeof d["title"] === "string" ? d["title"] : "",
4116
+ excerpt: typeof d["excerpt"] === "string" ? d["excerpt"] : "",
4117
+ visibility: typeof d["visibility"] === "string" ? d["visibility"] : scope === "official" ? "official" : "",
4118
+ tags: Array.isArray(d["tags"]) ? d["tags"].map(String) : [],
4119
+ scope,
4120
+ ...typeof d["archived"] === "boolean" ? { archived: d["archived"] } : {},
4121
+ ...typeof d["updatedAt"] === "number" ? { updatedAt: d["updatedAt"] } : {},
4122
+ url: typeof d["url"] === "string" ? d["url"] : `${server}/d/${d["id"]}`
4123
+ };
4124
+ }
4125
+ function buildTools(api) {
4126
+ const listOf = async (scope, includeArchived) => {
4127
+ const path = scope === "mine" ? `/api/docs${includeArchived ? "?all=1" : ""}` : scope === "official" ? "/api/official" : "/api/shared";
4128
+ const res = await api.getJson(path);
4129
+ return (Array.isArray(res?.docs) ? res.docs : []).map((d) => asDoc(d, scope, api.server)).filter((d) => d !== null);
4130
+ };
4131
+ const listAll = async (includeArchived) => {
4132
+ const [mine, official, shared] = await Promise.all([
4133
+ listOf("mine", includeArchived),
4134
+ listOf("official", false),
4135
+ listOf("shared", false)
4136
+ ]);
4137
+ const seen = new Set(mine.map((d) => d.id));
4138
+ return [...mine, ...[...official, ...shared].filter((d) => !seen.has(d.id) && seen.add(d.id))];
4139
+ };
4140
+ return [
4141
+ {
4142
+ name: "list_docs",
4143
+ description: "列出文档。scope:mine(我发布的,默认)/ official(官方知识库)/ shared(分享给我的)/ all。返回 id、标题、摘要、可见性、标签、URL。",
4144
+ inputSchema: {
4145
+ type: "object",
4146
+ properties: {
4147
+ scope: {
4148
+ type: "string",
4149
+ enum: [
4150
+ "mine",
4151
+ "official",
4152
+ "shared",
4153
+ "all"
4154
+ ],
4155
+ default: "mine"
4156
+ },
4157
+ includeArchived: {
4158
+ type: "boolean",
4159
+ default: false,
4160
+ description: "仅 mine 生效:包含已归档"
4161
+ }
4162
+ }
4163
+ },
4164
+ run: async (args) => {
4165
+ const scope = typeof args["scope"] === "string" ? args["scope"] : "mine";
4166
+ const includeArchived = args["includeArchived"] === true;
4167
+ const docs = scope === "all" ? await listAll(includeArchived) : scope === "mine" || scope === "official" || scope === "shared" ? await listOf(scope, includeArchived) : (() => {
4168
+ throw new CliError(`scope 只能是 mine / official / shared / all:${scope}`);
4169
+ })();
4170
+ return JSON.stringify(docs, null, 2);
4171
+ }
4172
+ },
4173
+ {
4174
+ name: "search_docs",
4175
+ description: "按关键词搜文档(标题 / 摘要 / 标签,空格分词、全部命中)。范围是我能看到的全部文档。没有全文检索——要读正文用 read_doc。",
4176
+ inputSchema: {
4177
+ type: "object",
4178
+ required: ["query"],
4179
+ properties: {
4180
+ query: { type: "string" },
4181
+ limit: {
4182
+ type: "integer",
4183
+ minimum: 1,
4184
+ maximum: 100,
4185
+ default: 20
4186
+ }
4187
+ }
4188
+ },
4189
+ run: async (args) => {
4190
+ const terms = str(args["query"], "query").toLowerCase().split(/\s+/).filter(Boolean);
4191
+ const limit = Number.isInteger(args["limit"]) ? Math.max(1, Math.min(100, args["limit"])) : 20;
4192
+ const hits = (await listAll(false)).filter((d) => {
4193
+ const hay = `${d.title}\n${d.excerpt}\n${d.tags.join(" ")}`.toLowerCase();
4194
+ return terms.every((t) => hay.includes(t));
4195
+ });
4196
+ return JSON.stringify(hits.slice(0, limit), null, 2);
4197
+ }
4198
+ },
4199
+ {
4200
+ name: "read_doc",
4201
+ description: "读文档的 markdown 原文(本地引用已改写为 /blob/<hash>)。不传 version 读最新版。",
4202
+ inputSchema: {
4203
+ type: "object",
4204
+ required: ["id"],
4205
+ properties: {
4206
+ id: { type: "string" },
4207
+ version: {
4208
+ type: "integer",
4209
+ minimum: 1,
4210
+ description: "版本号 seq,见 list_versions"
4211
+ }
4212
+ }
4213
+ },
4214
+ run: async (args) => {
4215
+ const id = encodeURIComponent(str(args["id"], "id"));
4216
+ const version = args["version"];
4217
+ if (version !== void 0 && !Number.isInteger(version)) throw new CliError("version 必须是整数");
4218
+ return api.getText(version === void 0 ? `/d/${id}.md` : `/d/${id}/v/${String(version)}.md`);
4219
+ }
4220
+ },
4221
+ {
4222
+ name: "list_templates",
4223
+ description: "列出文档模板(打了 template 标签的文档:官方模板 + 我的)。写新文档前先看这里;选中后 read_doc 取原文,按骨架填内容、删掉 <!-- --> 占位注释,再 push_doc。server 不做任何变量替换。",
4224
+ inputSchema: {
4225
+ type: "object",
4226
+ properties: {}
4227
+ },
4228
+ run: async () => {
4229
+ const docs = (await listAll(false)).filter((d) => d.tags.includes(TEMPLATE_TAG));
4230
+ return JSON.stringify(docs, null, 2);
4231
+ }
4232
+ },
4233
+ {
4234
+ name: "list_versions",
4235
+ description: "列出文档全部版本(seq、发布时间、源文 hash)。只追加不覆盖,回滚也是新版本。",
4236
+ inputSchema: {
4237
+ type: "object",
4238
+ required: ["id"],
4239
+ properties: { id: { type: "string" } }
4240
+ },
4241
+ run: async (args) => {
4242
+ const res = await api.getJson(`/api/docs/${encodeURIComponent(str(args["id"], "id"))}/versions`);
4243
+ return JSON.stringify(res, null, 2);
4244
+ }
4245
+ },
4246
+ {
4247
+ name: "push_doc",
4248
+ description: "发布本机的 markdown 文件(递归收集图片 / 嵌套 md 等本地引用并上传)。带 id 是给已有文档发新版本(标题 / 可见性 / 标签不传则保持原值);不带 id 新建,默认 private。path 用绝对路径。",
4249
+ inputSchema: {
4250
+ type: "object",
4251
+ required: ["path"],
4252
+ properties: {
4253
+ path: {
4254
+ type: "string",
4255
+ description: "入口 .md 的绝对路径"
4256
+ },
4257
+ id: {
4258
+ type: "string",
4259
+ description: "要更新的文档 id"
4260
+ },
4261
+ title: { type: "string" },
4262
+ visibility: {
4263
+ type: "string",
4264
+ enum: [...VISIBILITIES]
4265
+ },
4266
+ tags: {
4267
+ type: "array",
4268
+ items: { type: "string" },
4269
+ description: "整体覆盖标签;不传则不动"
4270
+ }
4271
+ }
4272
+ },
4273
+ run: async (args) => {
4274
+ const path = str(args["path"], "path");
4275
+ const tags = Array.isArray(args["tags"]) ? args["tags"].map(String) : void 0;
4276
+ const res = await pushDoc(api, path, {
4277
+ id: typeof args["id"] === "string" ? args["id"] : void 0,
4278
+ title: typeof args["title"] === "string" ? args["title"] : void 0,
4279
+ visibility: typeof args["visibility"] === "string" ? args["visibility"] : void 0,
4280
+ tag: tags
4281
+ });
4282
+ return JSON.stringify(res, null, 2);
4283
+ }
4284
+ }
4285
+ ];
4286
+ }
4287
+ function write(msg) {
4288
+ process.stdout.write(`${JSON.stringify(msg)}\n`);
4289
+ }
4290
+ function rpcError(id, code, message) {
4291
+ write({
4292
+ jsonrpc: "2.0",
4293
+ id,
4294
+ error: {
4295
+ code,
4296
+ message
4297
+ }
4298
+ });
4299
+ }
4300
+ async function runMcp(api) {
4301
+ const tools = buildTools(api);
4302
+ const byName = new Map(tools.map((t) => [t.name, t]));
4303
+ const handle = async (req) => {
4304
+ const { id, method } = req;
4305
+ const params = req.params ?? {};
4306
+ if (typeof method !== "string") {
4307
+ if (id !== void 0) rpcError(id, -32600, "method 缺失");
4308
+ return;
4309
+ }
4310
+ if (id === void 0) return;
4311
+ switch (method) {
4312
+ case "initialize": {
4313
+ const asked = typeof params["protocolVersion"] === "string" ? params["protocolVersion"] : "";
4314
+ write({
4315
+ jsonrpc: "2.0",
4316
+ id,
4317
+ result: {
4318
+ protocolVersion: SUPPORTED_PROTOCOLS.includes(asked) ? asked : SUPPORTED_PROTOCOLS[0],
4319
+ capabilities: { tools: {} },
4320
+ serverInfo: SERVER_INFO,
4321
+ instructions: INSTRUCTIONS
4322
+ }
4323
+ });
4324
+ return;
4325
+ }
4326
+ case "ping":
4327
+ write({
4328
+ jsonrpc: "2.0",
4329
+ id,
4330
+ result: {}
4331
+ });
4332
+ return;
4333
+ case "tools/list":
4334
+ write({
4335
+ jsonrpc: "2.0",
4336
+ id,
4337
+ result: { tools: tools.map(({ name, description, inputSchema }) => ({
4338
+ name,
4339
+ description,
4340
+ inputSchema
4341
+ })) }
4342
+ });
4343
+ return;
4344
+ case "tools/call": {
4345
+ const tool = typeof params["name"] === "string" ? byName.get(params["name"]) : void 0;
4346
+ if (!tool) {
4347
+ rpcError(id, -32602, `未知工具:${String(params["name"])}`);
4348
+ return;
4349
+ }
4350
+ const args = params["arguments"] ?? {};
4351
+ try {
4352
+ write({
4353
+ jsonrpc: "2.0",
4354
+ id,
4355
+ result: { content: [{
4356
+ type: "text",
4357
+ text: await tool.run(args)
4358
+ }] }
4359
+ });
4360
+ } catch (err) {
4361
+ write({
4362
+ jsonrpc: "2.0",
4363
+ id,
4364
+ result: {
4365
+ content: [{
4366
+ type: "text",
4367
+ text: err instanceof CliError ? `${err.message}${err.hint ? `(${err.hint})` : ""}` : String(err)
4368
+ }],
4369
+ isError: true
4370
+ }
4371
+ });
4372
+ }
4373
+ return;
4374
+ }
4375
+ default: rpcError(id, -32601, `不支持的方法:${method}`);
4376
+ }
4377
+ };
4378
+ const rl = createInterface({
4379
+ input: process.stdin,
4380
+ crlfDelay: Infinity
4381
+ });
4382
+ const pending = [];
4383
+ for await (const line of rl) {
4384
+ if (line.trim() === "") continue;
4385
+ let parsed;
4386
+ try {
4387
+ parsed = JSON.parse(line);
4388
+ } catch {
4389
+ rpcError(null, -32700, "JSON 解析失败");
4390
+ continue;
4391
+ }
4392
+ for (const msg of Array.isArray(parsed) ? parsed : [parsed]) {
4393
+ if (msg === null || typeof msg !== "object") {
4394
+ rpcError(null, -32600, "请求不是对象");
4395
+ continue;
4396
+ }
4397
+ pending.push(handle(msg));
4398
+ }
4399
+ }
4400
+ await Promise.all(pending);
4401
+ }
4402
+ //#endregion
4403
+ //#region src/widget.ts
4404
+ var SCOPES = ["official", "personal"];
3714
4405
  function exists(path) {
3715
4406
  const st = statSync(path, { throwIfNoEntry: false });
3716
4407
  return st !== void 0 && st.isFile();
@@ -3830,6 +4521,774 @@ async function pushWidget(api, dirArg) {
3830
4521
  process.stdout.write(`${meta.scope}/${meta.name}@${meta.version}${src}${where}\n`);
3831
4522
  }
3832
4523
  //#endregion
4524
+ //#region src/widget-dev.ts
4525
+ var ARTIFACTS = [
4526
+ "index.js",
4527
+ "index.css",
4528
+ "widget.json"
4529
+ ];
4530
+ var MIME = {
4531
+ "index.js": "text/javascript; charset=utf-8",
4532
+ "index.css": "text/css; charset=utf-8",
4533
+ "widget.json": "application/json; charset=utf-8"
4534
+ };
4535
+ /** 允许直接指向产物目录(如 packages/widgets/dist/Alert),也允许指向作者目录(取其 dist/)。 */
4536
+ function locateDist(dir) {
4537
+ if (existsSync(join(dir, "index.js"))) return dir;
4538
+ if (existsSync(join(dir, "dist", "index.js"))) return join(dir, "dist");
4539
+ if (existsSync(join(dir, "package.json"))) return join(dir, "dist");
4540
+ throw new CliError(`${dir} 下既没有 index.js 也没有 dist/`, "指向 jdu widget create 生成的目录,或某个构建产物目录");
4541
+ }
4542
+ function mtimeOf(dist) {
4543
+ let latest = 0;
4544
+ for (const f of ARTIFACTS) {
4545
+ const st = statSync(join(dist, f), { throwIfNoEntry: false });
4546
+ if (st && st.mtimeMs > latest) latest = st.mtimeMs;
4547
+ }
4548
+ return latest;
4549
+ }
4550
+ function readMeta(dir, dist) {
4551
+ for (const p of [join(dist, "widget.json"), join(dir, "widget.json")]) {
4552
+ if (!existsSync(p)) continue;
4553
+ try {
4554
+ const m = JSON.parse(readFileSync(p, "utf8"));
4555
+ return {
4556
+ name: typeof m["name"] === "string" ? m["name"] : "Widget",
4557
+ version: typeof m["version"] === "string" ? m["version"] : ""
4558
+ };
4559
+ } catch {}
4560
+ }
4561
+ return {
4562
+ name: "Widget",
4563
+ version: ""
4564
+ };
4565
+ }
4566
+ /** 线上 base.css 的地址:抓首页 HTML 里的 `/_v/base.<hash>.css`。拿不到返回 null,页面用兜底样式。 */
4567
+ async function siteBaseCss(server) {
4568
+ if (!server) return null;
4569
+ try {
4570
+ const ctrl = new AbortController();
4571
+ const t = setTimeout(() => ctrl.abort(), 2e3);
4572
+ const html = await (await fetch(`${server}/`, {
4573
+ signal: ctrl.signal,
4574
+ redirect: "follow"
4575
+ })).text();
4576
+ clearTimeout(t);
4577
+ const m = /\/_v\/base\.[a-f0-9]+\.css/.exec(html);
4578
+ return m ? `${server}${m[0]}` : null;
4579
+ } catch {
4580
+ return null;
4581
+ }
4582
+ }
4583
+ var esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
4584
+ function page(meta, baseCss, hasCss, mtime) {
4585
+ return `<!doctype html>
4586
+ <html lang="zh-CN">
4587
+ <head>
4588
+ <meta charset="utf-8">
4589
+ <meta name="viewport" content="width=device-width, initial-scale=1">
4590
+ <title>${esc(meta.name)} · jdu widget dev</title>
4591
+ ${baseCss ? `<link rel="stylesheet" href="${esc(baseCss)}">` : ""}
4592
+ ${hasCss ? `<link rel="stylesheet" href="/index.css?t=${mtime}">` : ""}
4593
+ <style>
4594
+ /* 兜底 token:连不上 jiandu server 时也有亮 / 暗两套 */
4595
+ :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; }
4596
+ .dark { --jiandu-bg: #0d1117; --jiandu-bg-subtle: #161b22; --jiandu-fg: #e6edf3; --jiandu-fg-muted: #9198a1; --jiandu-border: #3d444d; }
4597
+ html { background: var(--jiandu-bg); color: var(--jiandu-fg); font-family: var(--jiandu-font-sans); }
4598
+ body { margin: 0; }
4599
+ .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); }
4600
+ .dev-bar b { color: var(--jiandu-fg); }
4601
+ .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; }
4602
+ .dev-main { max-width: 760px; margin: 0 auto; padding: 1.5rem 1rem; }
4603
+ .dev-stage { min-height: 4rem; padding: 1rem; border: 1px dashed var(--jiandu-border); border-radius: 8px; }
4604
+ details { margin-top: 1.5rem; font-size: 13px; color: var(--jiandu-fg-muted); }
4605
+ 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); }
4606
+ .dev-err { color: #d1242f; white-space: pre-wrap; font-family: var(--jiandu-font-mono); font-size: 12px; }
4607
+ .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; }
4608
+ </style>
4609
+ </head>
4610
+ <body>
4611
+ <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>
4612
+ <main class="dev-main jiandu-doc">
4613
+ <div class="dev-stage"><div id="island" data-jiandu-island="b0-dev00000" data-widget="${esc(meta.name)}"></div></div>
4614
+ <details open>
4615
+ <summary>sampleData(改了即时 update;校验发生在 push 后 server 渲染期,这里不校验)</summary>
4616
+ <textarea id="data" spellcheck="false"></textarea>
4617
+ <div id="err" class="dev-err" role="alert"></div>
4618
+ </details>
4619
+ </main>
4620
+ <script type="module">
4621
+ const $ = (id) => document.getElementById(id);
4622
+ const started = ${mtime};
4623
+ const island = $('island'), ta = $('data'), err = $('err'), status = $('status');
4624
+ $('dark').addEventListener('click', () => { document.documentElement.classList.toggle('dark'); inst?.update?.(ctx(data)); });
4625
+
4626
+ // 与 viewer loader 同一条链路:import → new → mount(el, ctx);抛错就按线上的降级形态显示原始 fence
4627
+ let meta = {}, inst = null, W = null, data = {};
4628
+ const ctx = (d) => ({ data: d, loading: false, blockId: 'b0-dev00000', source: JSON.stringify(d, null, 2), language: 'widget:' + (meta.name ?? '') });
4629
+ function degrade(reason) {
4630
+ const pre = document.createElement('pre');
4631
+ pre.className = 'dev-degraded';
4632
+ pre.textContent = '[降级为代码块] ' + reason + '\\n\\n\`\`\`widget:' + (meta.name ?? '') + '\\n' + JSON.stringify(data, null, 2) + '\\n\`\`\`';
4633
+ island.replaceChildren(pre);
4634
+ status.textContent = '挂载失败';
4635
+ }
4636
+ function remount() {
4637
+ try { inst?.destroy?.(); } catch {}
4638
+ island.replaceChildren();
4639
+ try { inst = new W(); inst.mount(island, ctx(data)); status.textContent = '已挂载'; }
4640
+ catch (e) { degrade(e instanceof Error ? e.message : String(e)); }
4641
+ }
4642
+ try {
4643
+ meta = await (await fetch('/widget.json', { cache: 'no-store' })).json();
4644
+ data = meta.sampleData ?? {};
4645
+ ta.value = JSON.stringify(data, null, 2);
4646
+ const mod = await import('/index.js?t=' + started);
4647
+ W = mod.default;
4648
+ if (typeof W !== 'function') throw new Error('默认导出不是可构造的 class');
4649
+ remount();
4650
+ } catch (e) { degrade(e instanceof Error ? e.message : String(e)); }
4651
+
4652
+ ta.addEventListener('input', () => {
4653
+ try {
4654
+ data = JSON.parse(ta.value); err.textContent = '';
4655
+ if (inst?.update) inst.update(ctx(data)); else remount();
4656
+ } catch (e) { err.textContent = e instanceof Error ? e.message : String(e); }
4657
+ });
4658
+
4659
+ // 产物 mtime 变了就整页刷新(watch 构建完成 → 这里 <1s 内看到)
4660
+ setInterval(async () => {
4661
+ try {
4662
+ const s = await (await fetch('/__status', { cache: 'no-store' })).json();
4663
+ if (s.mtime !== started && s.mtime > 0) location.reload();
4664
+ } catch {}
4665
+ }, 700);
4666
+ <\/script>
4667
+ </body>
4668
+ </html>
4669
+ `;
4670
+ }
4671
+ async function runWidgetDev(dirArg, opts) {
4672
+ const dir = resolve(dirArg);
4673
+ const dist = locateDist(dir);
4674
+ const baseCss = await siteBaseCss(opts.server);
4675
+ let child = null;
4676
+ const pkgPath = join(dir, "package.json");
4677
+ if (opts.watch && existsSync(pkgPath)) {
4678
+ if (JSON.parse(readFileSync(pkgPath, "utf8")).scripts?.["watch"]) {
4679
+ child = spawn("npm", ["run", "watch"], {
4680
+ cwd: dir,
4681
+ stdio: "inherit",
4682
+ shell: process.platform === "win32"
4683
+ });
4684
+ child.on("exit", (code) => {
4685
+ if (code !== null && code !== 0) process.stderr.write(`jdu: npm run watch 退出(${code}),预览页仍在,产物不再更新\n`);
4686
+ });
4687
+ } else process.stderr.write("jdu: package.json 没有 watch 脚本,不自动构建;改完源码请自行 build\n");
4688
+ }
4689
+ const server = createServer((req, res) => {
4690
+ const url = new URL(req.url ?? "/", "http://localhost");
4691
+ const send = (code, type, body) => {
4692
+ res.writeHead(code, {
4693
+ "Content-Type": type,
4694
+ "Cache-Control": "no-store"
4695
+ });
4696
+ res.end(body);
4697
+ };
4698
+ if (url.pathname === "/") return send(200, "text/html; charset=utf-8", page(readMeta(dir, dist), baseCss, existsSync(join(dist, "index.css")), mtimeOf(dist)));
4699
+ if (url.pathname === "/__status") return send(200, "application/json", JSON.stringify({ mtime: mtimeOf(dist) }));
4700
+ const file = url.pathname.slice(1);
4701
+ if (ARTIFACTS.includes(file)) {
4702
+ const p = join(dist, file);
4703
+ if (!existsSync(p)) return send(404, "text/plain; charset=utf-8", `${file} 还没构建出来`);
4704
+ return send(200, MIME[file], readFileSync(p));
4705
+ }
4706
+ return send(404, "text/plain; charset=utf-8", "not found");
4707
+ });
4708
+ await new Promise((ok, fail) => {
4709
+ server.once("error", fail);
4710
+ server.listen(opts.port, "127.0.0.1", () => ok());
4711
+ }).catch((err) => {
4712
+ child?.kill();
4713
+ throw new CliError(`预览 server 起不来:${err.code ?? err.message}`, `端口 ${opts.port} 可能被占用,换 --port`);
4714
+ });
4715
+ process.stderr.write(`预览 http://127.0.0.1:${opts.port}/\n产物 ${dist}\n${baseCss ? `样式 ${baseCss}\n` : "样式 未连上 jiandu server,用内置兜底 token\n"}Ctrl+C 退出\n`);
4716
+ await new Promise((done) => {
4717
+ const stop = () => {
4718
+ child?.kill();
4719
+ server.close(() => done());
4720
+ };
4721
+ process.once("SIGINT", stop);
4722
+ process.once("SIGTERM", stop);
4723
+ child?.once("exit", () => {});
4724
+ });
4725
+ }
4726
+ //#endregion
4727
+ //#region src/widget-scaffold.ts
4728
+ /**
4729
+ * `jdu widget create`(M3,#38):生成一个能直接 build / dev / push 的 widget 目录。
4730
+ *
4731
+ * 模板刻意「全内联」:契约类型、框架适配基类、构建脚本都拷进作者目录,不依赖任何 jiandu 的 npm 包——
4732
+ * 平台对 widget 只做鸭子类型检查,作者拿到的是自己的代码,改坏了也只影响自己。
4733
+ * dataSchema 从 `src/types.ts` 的 `export interface Data` 生成(JSDoc 直通 description / @default),
4734
+ * 由作者目录里的 ts-json-schema-generator 在 build 时做,CLI 本身不带 TS 编译器。
4735
+ */
4736
+ var TEMPLATES = [
4737
+ "vanilla",
4738
+ "react",
4739
+ "vue"
4740
+ ];
4741
+ var RUNTIMES = ["shared", "custom"];
4742
+ var REACT_SPECIFIERS = [
4743
+ "react",
4744
+ "react-dom",
4745
+ "react-dom/client",
4746
+ "react/jsx-runtime"
4747
+ ];
4748
+ var NAME_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
4749
+ /** `star-rating` / `star_rating` / `StarRating` → `StarRating`(class 名 / widget 名)。 */
4750
+ function pascalCase(name) {
4751
+ return name.split(/[-_]+/).filter(Boolean).map((p) => p[0]?.toUpperCase() + p.slice(1)).join("");
4752
+ }
4753
+ /** `StarRating` → `star-rating`(目录 / package 名 / css class)。 */
4754
+ function kebabCase(name) {
4755
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").toLowerCase();
4756
+ }
4757
+ var CONTRACT_TS = `/**
4758
+ * 平台 mount 契约(与 jiandu 的 packages/render/src/types.ts 同构,拷贝一份免得依赖 jiandu 的包)。
4759
+ * 宿主只做鸭子类型检查:默认导出无参可构造 class,实例有 mount,update / destroy 可选。
4760
+ */
4761
+ export interface WidgetContext<D = unknown> {
4762
+ /** fence 里作者写的 JSON(未声明流式时保证完整且已过 dataSchema 校验) */
4763
+ data: D;
4764
+ /** 流式输出中 data 尚未完整时为 true(未声明流式的 widget 恒为 false) */
4765
+ loading: boolean;
4766
+ /** 所属块的稳定 id,可当 DOM id 前缀用 */
4767
+ blockId: string;
4768
+ /** 原始 fence 正文 */
4769
+ source: string;
4770
+ /** 原始代码块语言标识,如 \`widget:StarRating\` */
4771
+ language: string;
4772
+ /**
4773
+ * 嵌套 markdown:dataSchema 里标了 \`contentMediaType: "text/markdown"\` 的字符串字段,宿主渲成已 sanitize 的 HTML
4774
+ * 放在这里(JSON pointer → HTML,如 \`/content\`),widget 直接 innerHTML;宿主没给时退回纯文本。
4775
+ * src/types.ts 里给字段加 JSDoc \`@contentMediaType text/markdown\` 即可声明。
4776
+ */
4777
+ rendered?: Record<string, string>;
4778
+ }
4779
+
4780
+ export interface WidgetConfigDecl {
4781
+ /** 声明 true 才会在流式生成中途被 mount,并要自己处理半截 data;默认 false */
4782
+ streaming?: boolean;
4783
+ }
4784
+
4785
+ export interface WidgetInstance<D = unknown> {
4786
+ mount(el: HTMLElement, ctx: WidgetContext<D>): void;
4787
+ update?(ctx: WidgetContext<D>): void;
4788
+ destroy?(): void;
4789
+ }
4790
+
4791
+ export interface WidgetClass<D = unknown> {
4792
+ new (): WidgetInstance<D>;
4793
+ __widgetConfig?: WidgetConfigDecl;
4794
+ }
4795
+ `;
4796
+ var TYPES_TS = (c) => `// fence 里的 data 长什么样,这里是唯一事实:\`npm run build\` 会把 Data 生成为 widget.json 的 dataSchema
4797
+ // (字段上的 JSDoc → description,@default → default)。文档里的 data 不合法时平台会把 fence 降级成代码块。
4798
+ //
4799
+ // \`\`\`widget:${c.name}
4800
+ // { "title": "你好,简牍", "count": 3 }
4801
+ // \`\`\`
4802
+ //
4803
+ // 接口本身别写 JSDoc——那会整段进 schema 的 description。
4804
+
4805
+ export interface Data {
4806
+ /** 标题 */
4807
+ title: string;
4808
+ /**
4809
+ * 计数
4810
+ * @default 0
4811
+ */
4812
+ count?: number;
4813
+ }
4814
+ `;
4815
+ var STYLE_CSS = (c) => `/* 颜色一律走 --jiandu-* token(带兜底值),亮 / 暗主题跟宿主一起变;暗色用 .dark 选择器,不要 prefers-color-scheme */
4816
+ .w-${c.kebab} {
4817
+ display: inline-flex;
4818
+ align-items: center;
4819
+ gap: 0.5rem;
4820
+ padding: 0.5rem 0.75rem;
4821
+ border: 1px solid var(--jiandu-border, #d0d7de);
4822
+ border-radius: 8px;
4823
+ background: var(--jiandu-bg-subtle, #f6f8fa);
4824
+ color: var(--jiandu-fg, #1f2328);
4825
+ font: 14px/1.4 var(--jiandu-font-sans, system-ui, sans-serif);
4826
+ }
4827
+ .w-${c.kebab} button {
4828
+ padding: 0 0.5rem;
4829
+ border: 1px solid var(--jiandu-border, #d0d7de);
4830
+ border-radius: 6px;
4831
+ background: var(--jiandu-bg, #fff);
4832
+ color: inherit;
4833
+ cursor: pointer;
4834
+ }
4835
+ `;
4836
+ var VANILLA_INDEX = (c) => `import type { WidgetContext } from './_base/contract.js';
4837
+ import type { Data } from './types.js';
4838
+ import './style.css';
4839
+
4840
+ /**
4841
+ * ${c.name}:默认导出无参可构造的 class,副作用全在 mount 里(构造函数不做事)。
4842
+ * 默认非流式——mount 时 ctx.data 已完整且过了 dataSchema 校验,不用写守卫。
4843
+ */
4844
+ export default class ${c.cls} {
4845
+ private root?: HTMLElement;
4846
+ private count = 0;
4847
+
4848
+ mount(el: HTMLElement, ctx: WidgetContext<Data>): void {
4849
+ this.root = document.createElement('div');
4850
+ this.root.className = 'w-${c.kebab}';
4851
+ el.replaceChildren(this.root);
4852
+ this.count = ctx.data.count ?? 0;
4853
+ this.render(ctx.data);
4854
+ }
4855
+
4856
+ /** 主题切换、或声明流式后 data 补齐时宿主会调;这里简单重画 */
4857
+ update(ctx: WidgetContext<Data>): void {
4858
+ this.render(ctx.data);
4859
+ }
4860
+
4861
+ destroy(): void {
4862
+ this.root?.remove();
4863
+ }
4864
+
4865
+ private render(data: Data): void {
4866
+ if (!this.root) return;
4867
+ const title = document.createElement('strong');
4868
+ title.textContent = data.title;
4869
+ const num = document.createElement('span');
4870
+ num.textContent = \`× \${this.count}\`;
4871
+ const btn = document.createElement('button');
4872
+ btn.type = 'button';
4873
+ btn.textContent = '+1';
4874
+ btn.addEventListener('click', () => {
4875
+ this.count += 1;
4876
+ num.textContent = \`× \${this.count}\`;
4877
+ });
4878
+ this.root.replaceChildren(title, num, btn);
4879
+ }
4880
+ }
4881
+ `;
4882
+ var REACT_BASE = `import { createElement } from 'react';
4883
+ import type { ComponentType } from 'react';
4884
+ import { createRoot } from 'react-dom/client';
4885
+ import type { Root } from 'react-dom/client';
4886
+ import type { WidgetClass, WidgetConfigDecl, WidgetContext } from './contract.js';
4887
+
4888
+ export interface WidgetProps<D> {
4889
+ data: D;
4890
+ ctx: WidgetContext<D>;
4891
+ }
4892
+
4893
+ /**
4894
+ * React 组件 → mount 契约 class。update 走 React diff 不拆重建,destroy 卸载 root。
4895
+ * 第二个参数原样成为 __widgetConfig(要处理流式半截 data 才传 { streaming: true })。
4896
+ */
4897
+ export function reactWidget<D>(Component: ComponentType<WidgetProps<D>>, config: WidgetConfigDecl = {}): WidgetClass<D> {
4898
+ return class {
4899
+ static __widgetConfig = config;
4900
+ private root?: Root;
4901
+
4902
+ mount(el: HTMLElement, ctx: WidgetContext<D>): void {
4903
+ this.root = createRoot(el);
4904
+ this.root.render(createElement(Component, { data: ctx.data, ctx }));
4905
+ }
4906
+
4907
+ update(ctx: WidgetContext<D>): void {
4908
+ this.root?.render(createElement(Component, { data: ctx.data, ctx }));
4909
+ }
4910
+
4911
+ destroy(): void {
4912
+ this.root?.unmount();
4913
+ }
4914
+ };
4915
+ }
4916
+ `;
4917
+ var REACT_INDEX = `import { reactWidget } from './_base/react-widget.js';
4918
+ import { App } from './App.js';
4919
+
4920
+ export default reactWidget(App);
4921
+ `;
4922
+ var REACT_APP = (c) => `import { useState } from 'react';
4923
+ import type { WidgetProps } from './_base/react-widget.js';
4924
+ import type { Data } from './types.js';
4925
+ import './style.css';
4926
+
4927
+ /** 组件只管渲染 data;mount 时 data 已完整且过了 dataSchema 校验,不用写守卫。 */
4928
+ export function App({ data }: WidgetProps<Data>) {
4929
+ const [count, setCount] = useState(data.count ?? 0);
4930
+ return (
4931
+ <div className="w-${c.kebab}">
4932
+ <strong>{data.title}</strong>
4933
+ <span>× {count}</span>
4934
+ <button type="button" onClick={() => setCount((n) => n + 1)}>
4935
+ +1
4936
+ </button>
4937
+ </div>
4938
+ );
4939
+ }
4940
+ `;
4941
+ var VUE_BASE = `import { createApp, h, shallowReactive } from 'vue';
4942
+ import type { App, Component } from 'vue';
4943
+ import type { WidgetClass, WidgetConfigDecl, WidgetContext } from './contract.js';
4944
+
4945
+ /**
4946
+ * Vue 组件 → mount 契约 class。根组件收 \`data\` 与 \`ctx\` 两个 prop;
4947
+ * update 只改响应式状态,Vue 自己 patch;destroy 卸载 app。
4948
+ */
4949
+ export function vueWidget<D>(Root: Component, config: WidgetConfigDecl = {}): WidgetClass<D> {
4950
+ return class {
4951
+ static __widgetConfig = config;
4952
+ private app?: App;
4953
+ private state = shallowReactive<{ ctx: WidgetContext<D> | null }>({ ctx: null });
4954
+
4955
+ mount(el: HTMLElement, ctx: WidgetContext<D>): void {
4956
+ this.state.ctx = ctx;
4957
+ this.app = createApp({
4958
+ render: () => (this.state.ctx ? h(Root, { data: this.state.ctx.data, ctx: this.state.ctx }) : null),
4959
+ });
4960
+ this.app.mount(el);
4961
+ }
4962
+
4963
+ update(ctx: WidgetContext<D>): void {
4964
+ this.state.ctx = ctx;
4965
+ }
4966
+
4967
+ destroy(): void {
4968
+ this.app?.unmount();
4969
+ }
4970
+ };
4971
+ }
4972
+ `;
4973
+ var VUE_INDEX = `import { vueWidget } from './_base/vue-widget.js';
4974
+ import { App } from './App.js';
4975
+
4976
+ export default vueWidget(App);
4977
+ `;
4978
+ var VUE_APP = (c) => `import { defineComponent, h, ref } from 'vue';
4979
+ import type { PropType } from 'vue';
4980
+ import type { Data } from './types.js';
4981
+ import './style.css';
4982
+
4983
+ // ponytail: 用 h() 渲染函数而不是 .vue 单文件——零编译插件;想写 SFC 就给 scripts/build.mjs 加 esbuild 的 vue 插件
4984
+ export const App = defineComponent({
4985
+ props: { data: { type: Object as PropType<Data>, required: true } },
4986
+ setup(props) {
4987
+ const count = ref(props.data.count ?? 0);
4988
+ return () =>
4989
+ h('div', { class: 'w-${c.kebab}' }, [
4990
+ h('strong', props.data.title),
4991
+ h('span', \`× \${count.value}\`),
4992
+ h('button', { type: 'button', onClick: () => (count.value += 1) }, '+1'),
4993
+ ]);
4994
+ },
4995
+ });
4996
+ `;
4997
+ var BUILD_MJS = (c) => `#!/usr/bin/env node
4998
+ /**
4999
+ * 由 \`jdu widget create\` 生成的构建脚本(全内联,改它就是改你自己的构建):
5000
+ * 1. esbuild 打成单文件 ESM(--platform=browser;产物里出现 node 内置模块 server 会拒收)
5001
+ * 2. widget.json 的 dataSchema 从 src/types.ts 的 \`Data\` 重新生成,写到 dist/ 与源目录
5002
+ * 3. 源码拷到 dist/src —— \`jdu widget push dist\` 会把它一并上传,注册表里不只剩 minify 过的 bundle
5003
+ * \`node scripts/build.mjs --watch\` 监听改动重建,配 \`jdu widget dev\` 的预览页自动刷新。
5004
+ */
5005
+ import { build, context } from 'esbuild';
5006
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5007
+ import { dirname, join } from 'node:path';
5008
+ import { fileURLToPath } from 'node:url';
5009
+
5010
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
5011
+ const DIST = join(ROOT, 'dist');
5012
+ const ENTRY = '${c.template === "react" ? "src/index.tsx" : "src/index.ts"}';
5013
+
5014
+ /**
5015
+ * 平台版本档 runtime(shared 档):这些 bare import 不打进产物,改写成 jiandu 上内容寻址的 runtime URL,
5016
+ * 同档 widget 在浏览器里共享一份下载。null = custom 档,框架全内联进产物(任何框架任何版本都行)。
5017
+ * 值来自 \`GET /api/runtimes\`(create 时抄下来的${c.tierLabel ? `:${c.tierLabel}` : ""});平台升级档位不追溯,重新 create 或手改这里才换。
5018
+ */
5019
+ const RUNTIME_PATHS = ${c.runtime ? JSON.stringify(c.runtime, null, 2) : "null"};
5020
+
5021
+ const runtimePlugin = {
5022
+ name: 'jiandu-runtime',
5023
+ setup(b) {
5024
+ if (!RUNTIME_PATHS) return;
5025
+ b.onResolve({ filter: /^(react|react-dom|react-dom\\/client|react\\/jsx-runtime)$/ }, (args) => ({
5026
+ path: RUNTIME_PATHS[args.path],
5027
+ external: true,
5028
+ }));
5029
+ },
5030
+ };
5031
+
5032
+ async function dataSchema(fallback) {
5033
+ try {
5034
+ const { createGenerator } = await import('ts-json-schema-generator');
5035
+ const schema = createGenerator({
5036
+ path: join(ROOT, 'src/types.ts'),
5037
+ tsconfig: join(ROOT, 'tsconfig.json'),
5038
+ type: 'Data',
5039
+ skipTypeCheck: true,
5040
+ expose: 'none',
5041
+ topRef: false,
5042
+ additionalProperties: true,
5043
+ // 字段 JSDoc 写 @contentMediaType text/markdown → 宿主把该字段渲成 HTML 放进 ctx.rendered
5044
+ extraTags: ['contentMediaType'],
5045
+ }).createSchema('Data');
5046
+ delete schema.$schema;
5047
+ if (schema.definitions && Object.keys(schema.definitions).length === 0) delete schema.definitions;
5048
+ return schema;
5049
+ } catch (err) {
5050
+ console.warn(\`[widget] dataSchema 未重新生成(\${err.message}),沿用 widget.json 里的现值\`);
5051
+ return fallback;
5052
+ }
5053
+ }
5054
+
5055
+ async function emitMeta() {
5056
+ const meta = JSON.parse(readFileSync(join(ROOT, 'widget.json'), 'utf8'));
5057
+ meta.dataSchema = await dataSchema(meta.dataSchema);
5058
+ const text = \`\${JSON.stringify(meta, null, 2)}\\n\`;
5059
+ mkdirSync(DIST, { recursive: true });
5060
+ writeFileSync(join(DIST, 'widget.json'), text);
5061
+ writeFileSync(join(ROOT, 'widget.json'), text);
5062
+ rmSync(join(DIST, 'src'), { recursive: true, force: true });
5063
+ cpSync(join(ROOT, 'src'), join(DIST, 'src'), { recursive: true });
5064
+ }
5065
+
5066
+ const options = {
5067
+ entryPoints: [join(ROOT, ENTRY)],
5068
+ bundle: true,
5069
+ format: 'esm',
5070
+ target: 'es2022',
5071
+ platform: 'browser',
5072
+ minify: true,
5073
+ outfile: join(DIST, 'index.js'),
5074
+ define: { 'process.env.NODE_ENV': '"production"' },
5075
+ jsx: 'automatic',
5076
+ logLevel: 'info',
5077
+ plugins: [
5078
+ runtimePlugin,
5079
+ {
5080
+ name: 'widget-json',
5081
+ setup(b) {
5082
+ b.onEnd(async (result) => {
5083
+ if (result.errors.length === 0) await emitMeta();
5084
+ });
5085
+ },
5086
+ },
5087
+ ],
5088
+ };
5089
+
5090
+ if (process.argv.includes('--watch')) {
5091
+ const ctx = await context(options);
5092
+ await ctx.watch();
5093
+ console.log('[widget] watching src/ …');
5094
+ } else {
5095
+ if (existsSync(DIST)) rmSync(DIST, { recursive: true, force: true });
5096
+ await build(options);
5097
+ }
5098
+ `;
5099
+ var TSCONFIG = `{
5100
+ "compilerOptions": {
5101
+ "target": "ES2022",
5102
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
5103
+ "module": "ESNext",
5104
+ "moduleResolution": "Bundler",
5105
+ "jsx": "react-jsx",
5106
+ "strict": true,
5107
+ "noEmit": true,
5108
+ "skipLibCheck": true,
5109
+ "types": [],
5110
+ "verbatimModuleSyntax": true
5111
+ },
5112
+ "include": ["src"]
5113
+ }
5114
+ `;
5115
+ var PACKAGE_JSON = (c) => {
5116
+ const dev = {
5117
+ esbuild: "^0.28.2",
5118
+ "ts-json-schema-generator": "^2.9.0",
5119
+ typescript: "^5.9.0"
5120
+ };
5121
+ if (c.template === "react") Object.assign(dev, {
5122
+ react: "^19.2.0",
5123
+ "react-dom": "^19.2.0",
5124
+ "@types/react": "^19.2.0",
5125
+ "@types/react-dom": "^19.2.0"
5126
+ });
5127
+ if (c.template === "vue") dev["vue"] = "^3.5.0";
5128
+ const sorted = Object.fromEntries(Object.entries(dev).sort(([a], [b]) => a.localeCompare(b)));
5129
+ return `${JSON.stringify({
5130
+ name: `widget-${c.kebab}`,
5131
+ private: true,
5132
+ type: "module",
5133
+ scripts: {
5134
+ build: "node scripts/build.mjs",
5135
+ watch: "node scripts/build.mjs --watch",
5136
+ dev: "jdu widget dev",
5137
+ push: "npm run build && jdu widget push dist",
5138
+ typecheck: "tsc -p tsconfig.json --noEmit"
5139
+ },
5140
+ devDependencies: sorted
5141
+ }, null, 2)}\n`;
5142
+ };
5143
+ var WIDGET_JSON = (c) => `${JSON.stringify({
5144
+ name: c.name,
5145
+ scope: "personal",
5146
+ version: "1.0.0",
5147
+ dataSchema: {
5148
+ type: "object",
5149
+ required: ["title"],
5150
+ properties: {
5151
+ title: {
5152
+ type: "string",
5153
+ description: "标题"
5154
+ },
5155
+ count: {
5156
+ type: "number",
5157
+ description: "计数",
5158
+ default: 0
5159
+ }
5160
+ },
5161
+ additionalProperties: true
5162
+ },
5163
+ sampleData: {
5164
+ title: "你好,简牍",
5165
+ count: 3
5166
+ }
5167
+ }, null, 2)}\n`;
5168
+ var README = (c) => `# ${c.name}
5169
+
5170
+ 由 \`jdu widget create ${c.name} --template ${c.template}${c.template === "react" ? ` --runtime ${c.runtime ? "shared" : "custom"}` : ""}\` 生成。
5171
+
5172
+ \`\`\`sh
5173
+ npm install
5174
+ npm run dev # = jdu widget dev:起 watch 构建 + 本地预览页(sampleData 挂载,改代码自动刷新)
5175
+ npm run push # = build + jdu widget push dist
5176
+ \`\`\`
5177
+
5178
+ 然后在任何一篇文档里:
5179
+
5180
+ \`\`\`\`markdown
5181
+ \`\`\`widget:${c.name}
5182
+ { "title": "你好,简牍", "count": 3 }
5183
+ \`\`\`
5184
+ \`\`\`\`
5185
+
5186
+ ## 改什么
5187
+
5188
+ | 文件 | 作用 |
5189
+ |---|---|
5190
+ | \`src/types.ts\` | \`Data\` 接口 = fence 里 data 的唯一事实。JSDoc → description,\`@default\` → default;\`npm run build\` 生成 widget.json 的 dataSchema |
5191
+ | ${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/`,一般不用动)"} |
5192
+ | \`src/style.css\` | 样式。颜色走 \`--jiandu-*\` token 带兜底值,暗色用 \`.dark\` 选择器 |
5193
+ | \`widget.json\` | name / scope / version / sampleData;dataSchema 由 build 生成,不用手写 |
5194
+ | \`scripts/build.mjs\` | 全内联构建配置${c.runtime ? ";`RUNTIME_PATHS` 是平台版本档 runtime 的 URL" : ""} |
5195
+
5196
+ 版本号改 \`widget.json\` 的 \`version\`;同名同版本重推会覆盖产物、不改上线状态。
5197
+
5198
+ ## 契约要点
5199
+
5200
+ - 默认导出无参可构造 class,实例有 \`mount(el, ctx)\`,\`update\` / \`destroy\` 可选;平台只做鸭子类型检查
5201
+ - 默认非流式:mount 时 \`ctx.data\` 完整且已过 dataSchema 校验,**不用写守卫**。要在 AI 流式生成中途就渲染,声明 \`static __widgetConfig = { streaming: true }\` 并自己处理半截 data
5202
+ - 产物必须是浏览器可跑的单文件 ESM:不要 import node 内置模块,server 会拒收
5203
+ - 字段要支持嵌套 Markdown:在 \`src/types.ts\` 给它加 JSDoc \`@contentMediaType text/markdown\`,宿主会渲成已 sanitize 的 HTML 放进 \`ctx.rendered['/字段']\`,直接 innerHTML;\`jdu widget dev\` 预览里没有这一步,会看到原文
5204
+ ${c.runtime ? `- shared 档:\`react\` 等四个 import 改写为平台 runtime URL(${c.tierLabel ?? ""}),产物只有几 KB。要用平台没有的框架 / 版本,把 \`scripts/build.mjs\` 的 \`RUNTIME_PATHS\` 设为 null 即 custom 档` : ""}
5205
+ `;
5206
+ var GITIGNORE = `node_modules/\ndist/\n`;
5207
+ function filesFor(c) {
5208
+ const files = /* @__PURE__ */ new Map();
5209
+ files.set("package.json", PACKAGE_JSON(c));
5210
+ files.set("tsconfig.json", TSCONFIG);
5211
+ files.set("widget.json", WIDGET_JSON(c));
5212
+ files.set("README.md", README(c));
5213
+ files.set(".gitignore", GITIGNORE);
5214
+ files.set("scripts/build.mjs", BUILD_MJS(c));
5215
+ files.set("src/_base/contract.ts", CONTRACT_TS);
5216
+ files.set("src/types.ts", TYPES_TS(c));
5217
+ files.set("src/style.css", STYLE_CSS(c));
5218
+ if (c.template === "vanilla") files.set("src/index.ts", VANILLA_INDEX(c));
5219
+ else if (c.template === "react") {
5220
+ files.set("src/_base/react-widget.ts", REACT_BASE);
5221
+ files.set("src/index.tsx", REACT_INDEX);
5222
+ files.set("src/App.tsx", REACT_APP(c));
5223
+ } else {
5224
+ files.set("src/_base/vue-widget.ts", VUE_BASE);
5225
+ files.set("src/index.ts", VUE_INDEX);
5226
+ files.set("src/App.ts", VUE_APP(c));
5227
+ }
5228
+ return files;
5229
+ }
5230
+ /** 从 server 拿平台默认档的四个 runtime URL。拿不到就报错——静默退成 custom 会让作者以为自己在 shared 档。 */
5231
+ async function fetchRuntime(api) {
5232
+ let client;
5233
+ try {
5234
+ client = await api();
5235
+ } catch (err) {
5236
+ throw new CliError(`shared 档要从 server 取 runtime 地址,但 CLI 未配置:${err instanceof Error ? err.message : String(err)}`, "先 jdu login,或改用 --runtime custom(自带 react 全内联,不需要 server)");
5237
+ }
5238
+ const res = await client.getJson("/api/runtimes");
5239
+ const name = typeof res?.default === "string" ? res.default : null;
5240
+ const tier = name ? res.tiers?.[name] : void 0;
5241
+ const paths = tier?.paths;
5242
+ if (!name || !tier || paths === null || typeof paths !== "object") throw new CliError(`server ${client.server} 没有可用的 shared 档 runtime`, "实例可能关了 official 种子(JIANDU_SEED_OFFICIAL=0);改用 --runtime custom");
5243
+ const out = {};
5244
+ for (const spec of REACT_SPECIFIERS) {
5245
+ const url = paths[spec];
5246
+ if (typeof url !== "string") throw new CliError(`/api/runtimes 的 ${name} 档缺 ${spec}`);
5247
+ out[spec] = url.startsWith("/") ? url : `/${url}`;
5248
+ }
5249
+ return {
5250
+ paths: out,
5251
+ label: `${name}${typeof tier.label === "string" ? ` · ${tier.label}` : ""}`
5252
+ };
5253
+ }
5254
+ async function createWidget(nameArg, opts, api) {
5255
+ if (!NAME_RE.test(nameArg)) throw new CliError(`widget 名不合法:${nameArg}`, "字母开头,只允许字母 / 数字 / - / _,最长 64");
5256
+ const template = opts.template;
5257
+ if (!TEMPLATES.includes(template)) throw new CliError(`未知模板:${opts.template}`, `可选:${TEMPLATES.join(" | ")}`);
5258
+ const runtimeOpt = opts.runtime;
5259
+ if (!RUNTIMES.includes(runtimeOpt)) throw new CliError(`未知 runtime:${opts.runtime}`, `可选:${RUNTIMES.join(" | ")}`);
5260
+ const name = pascalCase(nameArg);
5261
+ const dir = resolve(opts.dir ?? kebabCase(name));
5262
+ if (existsSync(dir) && readdirSync(dir).length > 0) throw new CliError(`目录非空:${dir}`, "换个名字,或用 --dir 指定输出目录");
5263
+ let runtime = null;
5264
+ let tierLabel = null;
5265
+ if (template === "react" && runtimeOpt === "shared") {
5266
+ const rt = await fetchRuntime(api);
5267
+ runtime = rt.paths;
5268
+ tierLabel = rt.label;
5269
+ }
5270
+ const files = filesFor({
5271
+ name,
5272
+ cls: name,
5273
+ kebab: kebabCase(name),
5274
+ template,
5275
+ runtime,
5276
+ tierLabel
5277
+ });
5278
+ for (const [rel, content] of files) {
5279
+ const abs = join(dir, rel);
5280
+ mkdirSync(dirname(abs), { recursive: true });
5281
+ writeFileSync(abs, content, "utf8");
5282
+ }
5283
+ return {
5284
+ dir,
5285
+ files: [...files.keys()],
5286
+ template,
5287
+ runtime: template === "react" ? runtime ? "shared" : "custom" : null,
5288
+ tier: tierLabel
5289
+ };
5290
+ }
5291
+ //#endregion
3833
5292
  //#region src/cli.ts
3834
5293
  async function client() {
3835
5294
  return new ApiClient(await loadRuntimeConfig());
@@ -3839,22 +5298,24 @@ function collectTag(value, previous) {
3839
5298
  return [...previous ?? [], value];
3840
5299
  }
3841
5300
  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>", "直接保存访问 tokentoken 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) => {
5301
+ program.name("jdu").description("jiandu 命令行").version("0.4.0").showHelpAfterError();
5302
+ program.command("login").description("登录到一台简牍:定站点、拿凭据、写本地配置(必须指定 --server --local)").option("--local", "目标是本机自部署(http://127.0.0.1:8080)").option("--server <url>", "目标简牍站点地址").option("--token <token>", "直接用 token 登录(无浏览器的机器;token server 首启 stdout / data/initial-token.txt)").option("--user <owner>", "forward-auth:以指定 owner 直连,跳过 SSO(请求注入 X-Forwarded-User 头)").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)").option("--no-browser", "站点支持浏览器授权时也不打开浏览器,只给 token 指引").option("--json", "机器可读输出(agent 联动用;不会打开浏览器)", false).action(async (opts) => {
3844
5303
  const result = await runLogin({
3845
- server: opts.server,
3846
- token: opts.token,
3847
- issuer: opts.issuer,
3848
- clientId: opts.clientId
5304
+ ...opts,
5305
+ browser: opts.browser && !opts.json
3849
5306
  });
3850
- process.stdout.write(`${result.configPath}\n`);
3851
- if (result.source === "anonymous") process.stderr.write("这个 server 是匿名模式,无需登录。\n");
3852
- if (result.source === "oidc-cache") process.stderr.write("已复用本机 SSO credential。\n");
3853
- if (result.source === "oidc-browser") process.stderr.write("SSO 登录成功。\n");
5307
+ if (opts.json) {
5308
+ process.stdout.write(`${JSON.stringify(result)}\n`);
5309
+ return;
5310
+ }
5311
+ process.stdout.write(`目标 ${result.target} · ${result.server}(auth: ${result.authProvider})\n`);
5312
+ process.stdout.write(`配置已写入 ${result.configPath}\n`);
5313
+ process.stdout.write(result.loggedIn ? "凭据就绪,可以直接 jdu push。\n" : `下一步:${result.next ?? ""}\n`);
3854
5314
  if (result.warning) process.stderr.write(`jdu: ${result.warning}\n`);
3855
5315
  });
3856
- 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);
5316
+ 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", "上架到官方知识库(需管理员;同时公开)").option("--team <teamId>", "进团队(需是该团队成员;传空串移出团队;不传则保持原值)").action(async (entry, opts) => {
5317
+ const res = await pushDoc(await client(), entry, opts);
5318
+ process.stdout.write(`${res.url}\n`);
3858
5319
  });
3859
5320
  program.command("list").description("列出文档").option("--all", "包含已归档", false).action(async (opts) => {
3860
5321
  printTable(asRows(await (await client()).getJson(`/api/docs${opts.all ? "?all=1" : ""}`), "docs"), [
@@ -3866,10 +5327,6 @@ program.command("list").description("列出文档").option("--all", "包含已
3866
5327
  key: "visibility",
3867
5328
  header: "VISIBILITY"
3868
5329
  },
3869
- {
3870
- key: "official",
3871
- header: "OFFICIAL"
3872
- },
3873
5330
  {
3874
5331
  key: "archived",
3875
5332
  header: "ARCHIVED"
@@ -3887,16 +5344,16 @@ program.command("versions").argument("<docId>").description("列出某文档的
3887
5344
  header: "SEQ"
3888
5345
  },
3889
5346
  {
3890
- key: "created_at",
5347
+ key: "createdAt",
3891
5348
  header: "CREATED"
3892
5349
  },
3893
5350
  {
3894
- key: "source_hash",
5351
+ key: "sourceHash",
3895
5352
  header: "SOURCE"
3896
5353
  },
3897
5354
  {
3898
- key: "html_hash",
3899
- header: "HTML"
5355
+ key: "resolvedMdHash",
5356
+ header: "RESOLVED"
3900
5357
  }
3901
5358
  ]);
3902
5359
  });
@@ -3963,21 +5420,168 @@ tag.command("rm").argument("<name>").description("删除标签:只解绑,文
3963
5420
  const res = await (await client()).postJson("/api/tags/delete", { name });
3964
5421
  process.stdout.write(`已删除 ${name}(解绑 ${String(res.unlinked ?? 0)} 篇)\n`);
3965
5422
  });
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) => {
5423
+ program.command("comments").argument("<docId>").description("列出文档的评论线程(默认只列未 resolve;块 id / 引文 / 作者 / 回复)").option("--all", "包含已 resolve 的线程", false).action(async (docId, opts) => {
5424
+ await listComments(await client(), docId, opts);
5425
+ });
5426
+ program.command("reply").argument("<threadId>", "线程 id(jdu comments 首列;给回复 id 也行,一律挂到根)").requiredOption("-m, --message <text>", "回复正文").option("--ai", "以 agent 身份回复(authorType=ai,阅读页会标出来)", false).description("回复一条评论线程").action(async (threadId, opts) => {
5427
+ await replyComment(await client(), threadId, opts);
5428
+ });
5429
+ program.command("resolve").argument("<threadId>").option("--undo", "重新打开", false).description("标记线程已处理(仅文档 owner)").action(async (threadId, opts) => {
5430
+ await resolveComment(await client(), threadId, opts);
5431
+ });
5432
+ var template = program.command("template").description("文档模板(打了 template 标签的文档)");
5433
+ template.command("list").description("列出模板:我的 + 官方").option("--all", "包含已归档", false).action(async (opts) => {
5434
+ await listTemplates(await client(), opts);
5435
+ });
5436
+ template.command("pull").argument("<docId>", "模板 id(jdu template list 首列)").description("取模板原文(stdout;--out 写文件),按骨架填完再 jdu push").option("--out <file>", "写到文件而不是 stdout").action(async (docId, opts) => {
5437
+ await pullTemplate(await client(), docId, opts);
5438
+ });
5439
+ template.command("push").argument("<file.md>", "模板文件").description("发布 / 更新模板(= jdu push 并打上 template 标签;更新时保留原有其它标签)").option("--title <title>", "模板名,默认取首个一级标题").option("--id <docId>", "更新已有模板").option("--official", "上架为官方模板(需管理员)").action(async (file, opts) => {
5440
+ const res = await pushTemplate(await client(), file, opts);
5441
+ process.stdout.write(`${res.url}\n`);
5442
+ });
5443
+ program.command("mcp").description("以 MCP server(stdio)暴露 list_docs / search_docs / read_doc / list_versions / push_doc,给本机 agent 用").action(async () => {
5444
+ await runMcp(await client());
5445
+ });
5446
+ program.command("share").argument("<docId>").description("改可见性 / 进出团队,或管理读者池(定向分享,可撤销)").option("--visibility <v>", VISIBILITIES.join(" | ")).option("--team <teamId>", "进团队(需是该团队成员)").option("--no-team", "移出团队").option("--to <reader>", "加入读者池(身份串,如邮箱 / forward-auth 的 owner),可重复", collectTag, void 0).option("--revoke <reader>", "从读者池移除,立刻失效,可重复", collectTag, void 0).option("--readers", "列出当前读者池", false).action(async (docId, opts) => {
3967
5447
  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`);
5448
+ const url = `/api/docs/${encodeURIComponent(docId)}`;
5449
+ if (opts.readers) {
5450
+ const res = await api.getJson(`${url}/readers`);
5451
+ const readers = Array.isArray(res.readers) ? res.readers.map(String) : [];
5452
+ process.stdout.write(`${readers.length > 0 ? readers.join("\n") : "(读者池为空)"}\n`);
5453
+ return;
3980
5454
  }
5455
+ if (opts.to !== void 0 || opts.revoke !== void 0) {
5456
+ const res = await api.putJson(`${url}/readers`, {
5457
+ add: opts.to ?? [],
5458
+ remove: opts.revoke ?? []
5459
+ });
5460
+ const readers = Array.isArray(res.readers) ? res.readers.map(String) : [];
5461
+ process.stdout.write(`${readers.length > 0 ? readers.join("\n") : "(读者池为空)"}\n`);
5462
+ return;
5463
+ }
5464
+ const payload = {};
5465
+ if (opts.visibility !== void 0) payload.visibility = opts.visibility;
5466
+ if (opts.team !== void 0) payload.team = opts.team === false ? "" : opts.team;
5467
+ if (Object.keys(payload).length === 0) throw new CliError("share 需要 --visibility / --team / --no-team / --to / --revoke / --readers 之一");
5468
+ const res = await api.putJson(`${url}/visibility`, payload);
5469
+ process.stdout.write(`可见性 ${String(res.visibility)}${res.team ? `,团队 ${String(res.team)}` : ""}\n`);
5470
+ });
5471
+ var tokenCmd = program.command("token").description("CLI token(首启那枚与浏览器授权签发的)");
5472
+ tokenCmd.command("list").description("列出我的 token;CURRENT 是本次请求用的那枚").action(async () => {
5473
+ printTable(asRows(await (await client()).getJson("/api/tokens"), "tokens"), [
5474
+ {
5475
+ key: "id",
5476
+ header: "ID"
5477
+ },
5478
+ {
5479
+ key: "label",
5480
+ header: "LABEL"
5481
+ },
5482
+ {
5483
+ key: "createdAt",
5484
+ header: "CREATED"
5485
+ },
5486
+ {
5487
+ key: "lastUsedAt",
5488
+ header: "LAST_USED"
5489
+ },
5490
+ {
5491
+ key: "current",
5492
+ header: "CURRENT"
5493
+ }
5494
+ ]);
5495
+ });
5496
+ tokenCmd.command("rm").argument("<id>", "jdu token list 里的 ID").description("吊销一枚 token(正在使用的那枚不能删)").action(async (id) => {
5497
+ await (await client()).deleteJson(`/api/tokens/${encodeURIComponent(id)}`);
5498
+ process.stdout.write(`revoked ${id}\n`);
5499
+ });
5500
+ var teamCmd = program.command("team").description("团队:共享阅读与评论的空间(实例管理员建团队,团队管理员按用户名加人;文档进团队用 push/share --team)");
5501
+ teamCmd.command("list").description("我所属的团队(实例管理员:全部团队)").action(async () => {
5502
+ printTable(asRows(await (await client()).getJson("/api/teams"), "teams"), [
5503
+ {
5504
+ key: "id",
5505
+ header: "ID"
5506
+ },
5507
+ {
5508
+ key: "name",
5509
+ header: "NAME"
5510
+ },
5511
+ {
5512
+ key: "role",
5513
+ header: "ROLE"
5514
+ },
5515
+ {
5516
+ key: "members",
5517
+ header: "MEMBERS"
5518
+ },
5519
+ {
5520
+ key: "docs",
5521
+ header: "DOCS"
5522
+ }
5523
+ ]);
5524
+ });
5525
+ teamCmd.command("create").argument("<name>", "团队名").description("新建团队(实例管理员);你自动成为团队管理员,stdout 是团队 id").action(async (name) => {
5526
+ const res = await (await client()).postJson("/api/teams", { name });
5527
+ process.stdout.write(`${res.id}\n`);
5528
+ process.stderr.write(`团队「${res.name}」已创建:jdu team add ${res.id} <用户名> 加人\n`);
5529
+ });
5530
+ teamCmd.command("add").argument("<team>", "团队 id").argument("<user>", "成员用户名").option("--admin", "设为团队管理员(可加人 / 踢人 / 改名)").description("把成员加进团队;对已在团队的人 = 改角色").action(async (team, user, opts) => {
5531
+ await (await client()).postJson(`/api/teams/${encodeURIComponent(team)}/members`, {
5532
+ owner: user,
5533
+ role: opts.admin ? "admin" : "member"
5534
+ });
5535
+ process.stdout.write(`added ${user} → ${team}${opts.admin ? " (admin)" : ""}\n`);
5536
+ });
5537
+ teamCmd.command("rm").argument("<team>", "团队 id").argument("<user>", "成员用户名").description("把成员移出团队(团队管理员)").action(async (team, user) => {
5538
+ await (await client()).postJson(`/api/teams/${encodeURIComponent(team)}/members/remove`, { owner: user });
5539
+ process.stdout.write(`removed ${user} ← ${team}\n`);
5540
+ });
5541
+ teamCmd.command("leave").argument("<team>", "团队 id").description("自己退出团队").action(async (team) => {
5542
+ await (await client()).postJson(`/api/teams/${encodeURIComponent(team)}/members/remove`, {});
5543
+ process.stdout.write(`left ${team}\n`);
5544
+ });
5545
+ var memberCmd = program.command("member").description("成员(password 鉴权,管理员专用):准入只有邀请,被邀请者用链接自己绑 passkey");
5546
+ memberCmd.command("list").description("成员名单:pending = 已邀请未绑定,bound = 已绑定 passkey").action(async () => {
5547
+ printTable(asRows(await (await client()).getJson("/api/members"), "members"), [
5548
+ {
5549
+ key: "owner",
5550
+ header: "OWNER"
5551
+ },
5552
+ {
5553
+ key: "status",
5554
+ header: "STATUS"
5555
+ },
5556
+ {
5557
+ key: "invitedAt",
5558
+ header: "INVITED"
5559
+ },
5560
+ {
5561
+ key: "boundAt",
5562
+ header: "BOUND"
5563
+ },
5564
+ {
5565
+ key: "tokens",
5566
+ header: "TOKENS"
5567
+ },
5568
+ {
5569
+ key: "docs",
5570
+ header: "DOCS"
5571
+ }
5572
+ ]);
5573
+ });
5574
+ memberCmd.command("invite").argument("<name>", "用户名:小写字母 / 数字开头,可含 . _ -,≤30 位").option("--reset", "已绑定的成员重置 passkey(清掉凭据,所有设备与会话即刻失效)").description("签一张 7 天有效的一次性邀请链接,自己交给对方;对方打开后绑定 passkey 即成为成员").action(async (name, opts) => {
5575
+ const res = await (await client()).postJson("/api/members/invite", {
5576
+ owner: name,
5577
+ reset: opts.reset === true
5578
+ });
5579
+ process.stdout.write(`${res.url}\n`);
5580
+ process.stderr.write(`邀请 ${res.owner},${new Date(res.expiresAt).toLocaleString()} 前有效;链接只显示这一次\n`);
5581
+ });
5582
+ memberCmd.command("rm").argument("<name>", "用户名").description("移除成员:删 passkey 与其全部 token;文档 / 评论 / 标签不动,同名重邀即接回").action(async (name) => {
5583
+ await (await client()).postJson("/api/members/remove", { owner: name });
5584
+ process.stdout.write(`removed ${name}\n`);
3981
5585
  });
3982
5586
  var officialCmd = program.command("official").description("官方知识库上架");
3983
5587
  officialCmd.command("list").description("列出已上架的官方文档").action(async () => {
@@ -3997,22 +5601,6 @@ officialCmd.command("rm").argument("<docId>").description("从官方知识库拿
3997
5601
  await (await client()).putJson(`/api/docs/${encodeURIComponent(docId)}/official`, { official: false });
3998
5602
  process.stdout.write(`unofficial ${docId}\n`);
3999
5603
  });
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
5604
  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
5605
  const api = await client();
4018
5606
  const entries = await Promise.all(files.map(async (f) => ({
@@ -4025,7 +5613,22 @@ program.command("blob").description("内容寻址 blob(shared 档 runtime 等
4025
5613
  process.stdout.write(`${state} /blob/${e.hash} ${e.path}\n`);
4026
5614
  }
4027
5615
  });
4028
- var widget = program.command("widget").description("widget 注册表");
5616
+ var widget = program.command("widget").description("widget 注册表与作者工作流");
5617
+ 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) => {
5618
+ const res = await createWidget(name, opts, client);
5619
+ process.stdout.write(`${res.dir}\n`);
5620
+ 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`);
5621
+ });
5622
+ 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) => {
5623
+ const port = Number.parseInt(opts.port, 10);
5624
+ if (!Number.isInteger(port) || port <= 0) throw new CliError(`端口不合法:${opts.port}`);
5625
+ const server = await loadRuntimeConfig().then((c) => c.server).catch(() => void 0);
5626
+ await runWidgetDev(dir, {
5627
+ port,
5628
+ watch: opts.watch,
5629
+ server
5630
+ });
5631
+ });
4029
5632
  widget.command("push").argument("<dir>", "含 widget.json + index.js[ + index.css] 的构建产物目录").description("发布 widget").action(async (dir) => {
4030
5633
  await pushWidget(await client(), dir);
4031
5634
  });