@monoedge/jdu-cli 0.4.0 → 0.6.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 (2) hide show
  1. package/dist/cli.js +1956 -589
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -2,11 +2,11 @@
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, readdirSync, statSync, writeFileSync } from "node:fs";
5
+ import fs, { chmodSync, createReadStream, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
6
6
  import process$1 from "node:process";
7
7
  import { stripVTControlCharacters } from "node:util";
8
+ import { homedir, hostname, tmpdir } from "node:os";
8
9
  import { createHash, randomBytes } from "node:crypto";
9
- import { homedir, hostname } from "node:os";
10
10
  import { createServer } from "node:http";
11
11
  import { createInterface } from "node:readline";
12
12
  import { readFile, readdir } from "node:fs/promises";
@@ -2965,75 +2965,6 @@ function useColor() {
2965
2965
  }
2966
2966
  new Command();
2967
2967
  //#endregion
2968
- //#region src/blobs.ts
2969
- /** 流式算 hash,避免把大图整份读进内存。 */
2970
- function sha256File(path) {
2971
- return new Promise((res, rej) => {
2972
- const h = createHash("sha256");
2973
- const rs = createReadStream(path);
2974
- rs.on("error", rej);
2975
- rs.on("data", (chunk) => h.update(chunk));
2976
- rs.on("end", () => res(h.digest("hex")));
2977
- });
2978
- }
2979
- var MIME$1 = Object.freeze({
2980
- ".md": "text/markdown; charset=utf-8",
2981
- ".markdown": "text/markdown; charset=utf-8",
2982
- ".txt": "text/plain; charset=utf-8",
2983
- ".json": "application/json",
2984
- ".js": "text/javascript; charset=utf-8",
2985
- ".mjs": "text/javascript; charset=utf-8",
2986
- ".css": "text/css; charset=utf-8",
2987
- ".html": "text/html; charset=utf-8",
2988
- ".svg": "image/svg+xml",
2989
- ".png": "image/png",
2990
- ".jpg": "image/jpeg",
2991
- ".jpeg": "image/jpeg",
2992
- ".gif": "image/gif",
2993
- ".webp": "image/webp",
2994
- ".avif": "image/avif",
2995
- ".ico": "image/x-icon",
2996
- ".mp4": "video/mp4",
2997
- ".webm": "video/webm",
2998
- ".mov": "video/quicktime",
2999
- ".mp3": "audio/mpeg",
3000
- ".wav": "audio/wav",
3001
- ".pdf": "application/pdf",
3002
- ".zip": "application/zip",
3003
- ".woff2": "font/woff2"
3004
- });
3005
- function guessMime(path) {
3006
- return MIME$1[extname(path).toLowerCase()] ?? "application/octet-stream";
3007
- }
3008
- /**
3009
- * 增量上传:先用 hash 清单跟 server 协商,只 PUT 缺失的。
3010
- * server 返回体不可解析时按「全都缺」处理 —— 宁可多传也不能漏传导致文档半残。
3011
- */
3012
- async function syncBlobs(api, entries) {
3013
- const byHash = /* @__PURE__ */ new Map();
3014
- for (const e of entries) if (!byHash.has(e.hash)) byHash.set(e.hash, e.path);
3015
- const hashes = [...byHash.keys()];
3016
- if (hashes.length === 0) return {
3017
- total: 0,
3018
- uploaded: [],
3019
- reused: 0
3020
- };
3021
- const res = await api.postJson("/api/blobs/check", { hashes });
3022
- const missing = Array.isArray(res?.missing) ? res.missing.filter((h) => typeof h === "string") : hashes;
3023
- const uploaded = [];
3024
- for (const hash of missing) {
3025
- const path = byHash.get(hash);
3026
- if (path === void 0) continue;
3027
- await api.putBytes(`/api/blobs/${hash}`, readFileSync(path), guessMime(path));
3028
- uploaded.push(hash);
3029
- }
3030
- return {
3031
- total: hashes.length,
3032
- uploaded,
3033
- reused: hashes.length - uploaded.length
3034
- };
3035
- }
3036
- //#endregion
3037
2968
  //#region src/errors.ts
3038
2969
  /**
3039
2970
  * 面向用户的可读错误。
@@ -3241,6 +3172,31 @@ function configPath() {
3241
3172
  const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".config");
3242
3173
  return join(base, "jiandu", "config.json");
3243
3174
  }
3175
+ /**
3176
+ * `~/.config/jiandu/cf.json`:`jdu deploy` / `jdu access` 用的 Cloudflare 账号凭据
3177
+ * (OAuth 拿到的 access / refresh token + account id + 邮箱)。与站点凭据分开存,
3178
+ * 因为它是「这个 Cloudflare 账号」的钥匙,不属于任何一台简牍实例。
3179
+ */
3180
+ function cfConfigPath() {
3181
+ const xdg = process.env.XDG_CONFIG_HOME?.trim();
3182
+ const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".config");
3183
+ return join(base, "jiandu", "cf.json");
3184
+ }
3185
+ /** 缺文件返回空对象;解析失败一律当作没有(重新登录即可),不阻塞命令。 */
3186
+ function readCfConfig() {
3187
+ try {
3188
+ const parsed = JSON.parse(readFileSync(cfConfigPath(), "utf8"));
3189
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
3190
+ } catch {
3191
+ return {};
3192
+ }
3193
+ }
3194
+ function writeCfConfig(next) {
3195
+ const path = cfConfigPath();
3196
+ mkdirSync(dirname(path), { recursive: true });
3197
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, { mode: 384 });
3198
+ return path;
3199
+ }
3244
3200
  function readOidc(raw) {
3245
3201
  if (raw === null || typeof raw !== "object") return void 0;
3246
3202
  const obj = raw;
@@ -3272,6 +3228,7 @@ function readStoredConfig() {
3272
3228
  const out = {};
3273
3229
  if (typeof obj.server === "string") out.server = obj.server;
3274
3230
  if (typeof obj.token === "string") out.token = obj.token;
3231
+ if (obj.cfAccess === true) out.cfAccess = true;
3275
3232
  if (typeof obj.user === "string") out.user = obj.user;
3276
3233
  if (typeof obj.proxySecret === "string") out.proxySecret = obj.proxySecret;
3277
3234
  const oidc = readOidc(obj.oidc);
@@ -3283,6 +3240,7 @@ function saveConfig(next) {
3283
3240
  mkdirSync(dirname(path), { recursive: true });
3284
3241
  const body = { server: next.server };
3285
3242
  if (next.token !== void 0) body.token = next.token;
3243
+ if (next.cfAccess === true) body.cfAccess = true;
3286
3244
  if (next.user !== void 0) body.user = next.user;
3287
3245
  if (next.proxySecret !== void 0) body.proxySecret = next.proxySecret;
3288
3246
  if (next.oidc !== void 0) body.oidc = next.oidc;
@@ -3300,16 +3258,36 @@ function loadConfig() {
3300
3258
  const cfg = {
3301
3259
  server,
3302
3260
  token: rawToken && rawToken.length > 0 ? rawToken : void 0,
3261
+ ...stored.cfAccess === true ? { cfAccess: true } : {},
3303
3262
  user: user && user.length > 0 ? user : void 0,
3304
3263
  proxySecret: proxySecret && proxySecret.length > 0 ? proxySecret : void 0
3305
3264
  };
3306
3265
  if (stored.oidc) cfg.oidc = stored.oidc;
3307
3266
  return cfg;
3308
3267
  }
3268
+ /** 未验签地读 JWT 的 exp(只用于本地提前给出「去重新登录」的提示;验签永远在 server 侧)。 */
3269
+ function jwtExpiry(token) {
3270
+ const parts = token.split(".");
3271
+ if (parts.length !== 3 || parts[1] === void 0) return null;
3272
+ try {
3273
+ const json = Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
3274
+ const payload = JSON.parse(json);
3275
+ const exp = payload !== null && typeof payload === "object" ? payload["exp"] : void 0;
3276
+ return typeof exp === "number" ? exp * 1e3 : null;
3277
+ } catch {
3278
+ return null;
3279
+ }
3280
+ }
3309
3281
  /** 运行时配置:没有显式 token 时,用已保存 / 环境变量里的 OIDC 客户端去续期。 */
3310
3282
  async function loadRuntimeConfig() {
3311
3283
  const cfg = loadConfig();
3312
- if (cfg.token) return cfg;
3284
+ if (cfg.token) {
3285
+ if (cfg.cfAccess === true) {
3286
+ const exp = jwtExpiry(cfg.token);
3287
+ if (exp !== null && exp <= Date.now()) throw new CliError(`Cloudflare Access 会话已过期(${cfg.server})`, `重新执行 jdu login --server ${cfg.server},浏览器里的 Cloudflare 会话还在,点一下就续上`);
3288
+ }
3289
+ return cfg;
3290
+ }
3313
3291
  const issuer = cfg.oidc?.issuer ?? process.env["JIANDU_OIDC_ISSUER"]?.trim();
3314
3292
  const clientId = cfg.oidc?.clientId ?? process.env["JIANDU_OIDC_CLIENT_ID"]?.trim();
3315
3293
  if (!issuer || !clientId) return cfg;
@@ -3325,240 +3303,433 @@ async function loadRuntimeConfig() {
3325
3303
  return cfg;
3326
3304
  }
3327
3305
  //#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", " ");
3306
+ //#region src/cf-oauth.ts
3307
+ /**
3308
+ * Cloudflare 账号的 OAuth:`jdu deploy` / `jdu access` 拿一把能配 Access 的 token。
3309
+ *
3310
+ * 为什么不复用 `wrangler login` 的结果:wrangler 的 OAuth 客户端请求的 scope 里没有 Zero Trust,
3311
+ * 实测拿它的 token 打 `POST /accounts/<id>/access/apps` 是 `1010 auth.forbidden`。
3312
+ * Cloudflare 自己的 CLI OAuth 客户端(wrangler 包里以 `CLOUDFLARE_CLIENT_ID` 常量存在)
3313
+ * 的 scope 集合里有 `access:read` / `access:write`,专门干这件事。
3314
+ *
3315
+ * 这条路需要人在浏览器里点一次同意(与 `wrangler login` 同类);想跳过就用
3316
+ * `--cf-token` 或 `CLOUDFLARE_API_TOKEN` 给一把带 Access 权限的 API token。
3317
+ *
3318
+ * 回调固定 `http://localhost:8877/oauth/callback`:OAuth 的 redirect_uri 是精确匹配的,
3319
+ * 不能用随机端口。PKCE(S256)保证 code 拿到也用不了。
3320
+ */
3321
+ var CF_AUTH_URL = "https://dash.cloudflare.com/oauth2/auth";
3322
+ var CF_TOKEN_URL = "https://dash.cloudflare.com/oauth2/token";
3323
+ /** Cloudflare CLI(cf)的 OAuth 客户端;`CLOUDFLARE_CLIENT_ID` 可覆盖(测试 / 换客户端)。 */
3324
+ var CF_CLIENT_ID_ENV = "CLOUDFLARE_CLIENT_ID";
3325
+ var CF_CLIENT_ID_DEFAULT = "cbca97e7-c331-4cdd-8fd8-e25a451b98bf";
3326
+ /** OAuth 客户端里登记的回调地址,端口不能换。 */
3327
+ var CF_REDIRECT_URL = "http://localhost:8877/oauth/callback";
3328
+ var CF_CALLBACK_PORT = 8877;
3329
+ /**
3330
+ * 申请的 scope:Access 配置(access:read/write)+ wrangler 部署同样的 Workers 面。
3331
+ * 这份 token 会以 `CLOUDFLARE_API_TOKEN` 交给 wrangler deploy,所以 workers 那几项不能少。
3332
+ */
3333
+ var CF_SCOPES = [
3334
+ "offline_access",
3335
+ "user:read",
3336
+ "account:read",
3337
+ "access:read",
3338
+ "access:write",
3339
+ "workers:write",
3340
+ "workers_scripts:write",
3341
+ "workers_kv:write",
3342
+ "workers_routes:write",
3343
+ "workers_tail:read",
3344
+ "d1:write",
3345
+ "pages:write",
3346
+ "zone:read"
3347
+ ];
3348
+ function cfClientId(env = process.env) {
3349
+ const fromEnv = env[CF_CLIENT_ID_ENV]?.trim();
3350
+ return fromEnv && fromEnv.length > 0 ? fromEnv : CF_CLIENT_ID_DEFAULT;
3332
3351
  }
3333
- /** owner UUID 且没有显示名时 authorLabel 为空串,退回原始 id 也比空着好认。 */
3334
- function who(c) {
3335
- return `${c.authorLabel || c.author}${c.authorType === "ai" ? " [ai]" : ""}`;
3352
+ /** 浏览器授权码流程:开浏览器 本地回调收 code token。 */
3353
+ async function cfOAuthLogin(opts = {}) {
3354
+ const { verifier, challenge } = pkce();
3355
+ const state = randomBytes(16).toString("base64url");
3356
+ const authURL = new URL(CF_AUTH_URL);
3357
+ authURL.search = new URLSearchParams({
3358
+ response_type: "code",
3359
+ client_id: cfClientId(),
3360
+ redirect_uri: CF_REDIRECT_URL,
3361
+ scope: CF_SCOPES.join(" "),
3362
+ state,
3363
+ code_challenge: challenge,
3364
+ code_challenge_method: "S256"
3365
+ }).toString();
3366
+ const tok = await tokenRequest(CF_TOKEN_URL, {
3367
+ grant_type: "authorization_code",
3368
+ code: await new Promise((resolve, reject) => {
3369
+ const finish = (fn) => (value) => {
3370
+ clearTimeout(timer);
3371
+ server.close();
3372
+ fn(value);
3373
+ };
3374
+ const fail = finish((msg) => reject(new CliError(msg, "不想开浏览器就用 --cf-token <token>(Cloudflare API token,需要 Access 权限)")));
3375
+ const handler = callbackHandler(state, finish(resolve));
3376
+ const server = createServer((req, res) => {
3377
+ if (new URL(req.url ?? "/", "http://localhost").pathname !== "/oauth/callback") {
3378
+ res.writeHead(404).end();
3379
+ return;
3380
+ }
3381
+ handler(req, res);
3382
+ });
3383
+ const timer = setTimeout(() => fail("等待 Cloudflare 授权回调超时"), opts.timeoutMs ?? 3e5);
3384
+ server.on("error", (err) => {
3385
+ fail(`监听 ${CF_REDIRECT_URL} 失败(端口 8877 被占用?):${err.message}`);
3386
+ });
3387
+ server.listen(CF_CALLBACK_PORT, "localhost", () => {
3388
+ process.stderr.write(`在浏览器中授权 Cloudflare 账号:${authURL.toString()}\n`);
3389
+ (opts.open ?? openBrowser)(authURL.toString());
3390
+ });
3391
+ }),
3392
+ redirect_uri: CF_REDIRECT_URL,
3393
+ client_id: cfClientId(),
3394
+ code_verifier: verifier
3395
+ });
3396
+ return {
3397
+ accessToken: tok.access_token,
3398
+ refreshToken: tok.refresh_token,
3399
+ expiry: tok.expiry
3400
+ };
3336
3401
  }
3337
- function indent(text, pad) {
3338
- return text.split("\n").map((line) => `${pad}${line}`).join("\n");
3402
+ /** refresh token 换新 access token;失败返回 null(调用方回落成重新登录)。 */
3403
+ async function cfRefresh(refreshToken) {
3404
+ try {
3405
+ const tok = await tokenRequest(CF_TOKEN_URL, {
3406
+ grant_type: "refresh_token",
3407
+ refresh_token: refreshToken,
3408
+ client_id: cfClientId()
3409
+ });
3410
+ return {
3411
+ accessToken: tok.access_token,
3412
+ refreshToken: tok.refresh_token ?? refreshToken,
3413
+ expiry: tok.expiry
3414
+ };
3415
+ } catch {
3416
+ return null;
3417
+ }
3339
3418
  }
3419
+ //#endregion
3420
+ //#region src/cf-api.ts
3340
3421
  /**
3341
- * 线程列表。stdout 纯文本、一线程一段,给 agent 直接读:
3342
- * 首行 `<threadId> <blockId> <open|resolved> <作者> <时间> [v<seq>]`,引文一行 `> …`,正文缩进两格,回复 `↳` 缩进。
3422
+ * Cloudflare API 薄封装 + Access(Zero Trust)的幂等配置。`jdu deploy` 与 `jdu access` 共用。
3423
+ *
3424
+ * 只做两件事:把 `/client/v4` 的信封拆开(`{success, result, errors}` → 抛 CliError 或返回 result),
3425
+ * 以及把「建 team / 确保 Cloudflare IdP / upsert 保护 /p 的应用与邮箱策略」写成幂等函数。
3426
+ *
3427
+ * 凭据优先级:`--cf-token` / `CLOUDFLARE_API_TOKEN` > 本地缓存(~/.config/jiandu/cf.json)> 浏览器 OAuth。
3428
+ * 缓存的 access token 过期就用 refresh token 换;换不动就重跑 OAuth。
3343
3429
  */
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;
3430
+ var API = "https://api.cloudflare.com/client/v4";
3431
+ function firstError(body) {
3432
+ const err = body.errors?.[0];
3433
+ return {
3434
+ code: err?.code ?? 0,
3435
+ message: err?.message ?? err?.error ?? "未知错误"
3436
+ };
3437
+ }
3438
+ /** 权限不够(Cloudflare 用 1010/10000/9109 表达)时给一句能干的话,别让人对着裸错误码发愣。 */
3439
+ function hintFor(status, code) {
3440
+ if (code === 1010 || code === 9109 || code === 1e4 && status === 403) return "这把 token 没有 Zero Trust(Access)权限:用浏览器登录一次 jdu deploy,或 --cf-token 给一把带「Access: Apps and Policies / Organizations, Identity Providers and Groups」编辑权限的 API token";
3441
+ if (status === 401) return "Cloudflare 凭据已失效:重新执行 jdu deploy(会打开浏览器授权)";
3442
+ }
3443
+ var CfApi = class {
3444
+ credentials;
3445
+ onTokens;
3446
+ fetchImpl;
3447
+ constructor(opts) {
3448
+ this.credentials = opts.credentials;
3449
+ this.onTokens = opts.onTokens;
3450
+ this.fetchImpl = opts.fetchImpl ?? ((input, init) => fetch(input, init));
3451
+ }
3452
+ get accountId() {
3453
+ return this.credentials.accountId;
3454
+ }
3455
+ get email() {
3456
+ return this.credentials.email;
3457
+ }
3458
+ get tokens() {
3459
+ return this.credentials.tokens;
3460
+ }
3461
+ /** 401 时用 refresh token 续一次再重试;续不动就抛错让人重新登录。 */
3462
+ async request(method, path, body) {
3463
+ let res = await this.send(method, path, body);
3464
+ if (res.status === 401 && this.credentials.tokens.refreshToken) {
3465
+ const refreshed = await cfRefresh(this.credentials.tokens.refreshToken);
3466
+ if (refreshed) {
3467
+ this.credentials.tokens = refreshed;
3468
+ this.onTokens?.(refreshed);
3469
+ res = await this.send(method, path, body);
3470
+ }
3471
+ }
3472
+ const text = await res.text();
3473
+ let parsed = {};
3474
+ try {
3475
+ parsed = JSON.parse(text);
3476
+ } catch {}
3477
+ if (!res.ok || parsed.success === false) {
3478
+ const err = firstError(parsed);
3479
+ const detail = err.message !== "未知错误" ? err.message : text.slice(0, 200);
3480
+ throw new CliError(`Cloudflare API ${method} ${path} 失败(HTTP ${res.status}${err.code ? `,code ${err.code}` : ""}):${detail}`, hintFor(res.status, err.code));
3481
+ }
3482
+ return parsed.result;
3483
+ }
3484
+ send(method, path, body) {
3485
+ return this.fetchImpl(`${API}${path}`, {
3486
+ method,
3487
+ headers: {
3488
+ authorization: `Bearer ${this.credentials.tokens.accessToken}`,
3489
+ ...body === void 0 ? {} : { "content-type": "application/json" }
3490
+ },
3491
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
3492
+ });
3351
3493
  }
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);
3494
+ };
3495
+ function isFresh(tokens) {
3496
+ const at = Date.parse(tokens.expiry);
3497
+ return Number.isFinite(at) && at - 6e4 > Date.now();
3498
+ }
3499
+ /**
3500
+ * 拿一份能调 Cloudflare API 的凭据。`--cf-token` / 环境变量优先(CI / 已有 API token 的人),
3501
+ * 其次本地缓存(过期先 refresh),最后才开浏览器走 OAuth。
3502
+ *
3503
+ * 拿到 token 后顺手把 account id / 邮箱补齐(API token 本身不带这两个信息):
3504
+ * 后续所有 API 路径都要 account id,`jdu access` 也就不需要再问。
3505
+ */
3506
+ async function resolveCfCredentials(opts) {
3507
+ const env = opts.env ?? process.env;
3508
+ const given = (opts.cfToken ?? env["JIANDU_CF_TOKEN"] ?? env["CLOUDFLARE_API_TOKEN"] ?? "").trim();
3509
+ const stored = readCfConfig();
3510
+ let source = "oauth";
3511
+ let tokens;
3512
+ if (given !== "") {
3513
+ tokens = {
3514
+ accessToken: given,
3515
+ expiry: new Date(Date.now() + 31536e6).toISOString()
3516
+ };
3517
+ source = "flag";
3518
+ } else {
3519
+ const cached = tokensOf(stored);
3520
+ if (cached && isFresh(cached)) {
3521
+ tokens = cached;
3522
+ source = "cache";
3523
+ } else if (cached?.refreshToken) {
3524
+ const refreshed = await cfRefresh(cached.refreshToken);
3525
+ if (refreshed) {
3526
+ tokens = refreshed;
3527
+ source = "cache";
3528
+ writeCfConfig({
3529
+ ...stored,
3530
+ ...serialize(refreshed)
3531
+ });
3532
+ }
3533
+ }
3534
+ }
3535
+ if (tokens === void 0) {
3536
+ if (opts.allowBrowser === false) throw new CliError("没有可用的 Cloudflare 凭据(本地缓存过期,也没给 --cf-token)", "传 --cf-token <token>(带 Access 权限的 Cloudflare API token),或不带 --json 跑一次让浏览器授权");
3537
+ tokens = await cfOAuthLogin(opts.open ? { open: opts.open } : {});
3538
+ writeCfConfig({
3539
+ ...readCfConfig(),
3540
+ ...serialize(tokens)
3541
+ });
3542
+ }
3543
+ const credentials = {
3544
+ accountId: stored.accountId ?? "",
3545
+ email: stored.email ?? "",
3546
+ tokens,
3547
+ source
3548
+ };
3549
+ if (credentials.accountId === "" || credentials.email === "") {
3550
+ const identity = await readIdentity(new CfApi({
3551
+ credentials,
3552
+ ...opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}
3553
+ }));
3554
+ credentials.accountId = identity.accountId;
3555
+ credentials.email = identity.email;
3556
+ writeCfConfig({
3557
+ ...readCfConfig(),
3558
+ accountId: identity.accountId,
3559
+ email: identity.email
3560
+ });
3358
3561
  }
3562
+ return credentials;
3563
+ }
3564
+ function tokensOf(stored) {
3565
+ if (typeof stored.accessToken !== "string" || stored.accessToken === "") return null;
3566
+ const tokens = {
3567
+ accessToken: stored.accessToken,
3568
+ expiry: typeof stored.expiry === "string" ? stored.expiry : ""
3569
+ };
3570
+ if (typeof stored.refreshToken === "string" && stored.refreshToken !== "") tokens.refreshToken = stored.refreshToken;
3571
+ return tokens;
3572
+ }
3573
+ function serialize(tokens) {
3574
+ const out = {
3575
+ accessToken: tokens.accessToken,
3576
+ expiry: tokens.expiry
3577
+ };
3578
+ if (tokens.refreshToken !== void 0) out.refreshToken = tokens.refreshToken;
3579
+ return out;
3580
+ }
3581
+ /** `GET /user` + `GET /accounts`:只认第一个账号(个人自部署形态就是单人单账号)。 */
3582
+ async function readIdentity(api) {
3583
+ const user = await api.request("GET", "/user");
3584
+ const accountId = (await api.request("GET", "/accounts")).find((a) => typeof a.id === "string" && a.id !== "")?.id ?? "";
3585
+ const email = user.email ?? "";
3586
+ if (accountId === "") throw new CliError("读不到 Cloudflare account id", "GET /accounts 返回空");
3587
+ if (email === "") throw new CliError("读不到 Cloudflare 账号邮箱", "GET /user 没有 email");
3588
+ return {
3589
+ accountId,
3590
+ email: email.toLowerCase()
3591
+ };
3592
+ }
3593
+ /** 应用策略里所有 email 规则(`{email:{email}}`),小写去重、保持原顺序。 */
3594
+ function emailsOf(policy) {
3359
3595
  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("");
3596
+ for (const rule of policy.include ?? []) {
3597
+ if (typeof rule !== "object" || rule === null) continue;
3598
+ const email = rule.email?.email;
3599
+ if (typeof email !== "string") continue;
3600
+ const normalized = email.trim().toLowerCase();
3601
+ if (normalized !== "" && !out.includes(normalized)) out.push(normalized);
3378
3602
  }
3379
- process.stdout.write(out.join("\n"));
3603
+ return out;
3380
3604
  }
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"
3605
+ /** 保留策略里非 email 的规则(人手加的账号成员选择器等),只重写 email 那部分。 */
3606
+ function nonEmailRules(policy) {
3607
+ return (policy.include ?? []).filter((rule) => {
3608
+ if (typeof rule !== "object" || rule === null) return true;
3609
+ return !("email" in rule);
3385
3610
  });
3386
- process.stdout.write(`replied ${String(res?.parentId ?? threadId)} → ${String(res?.id ?? "")}\n`);
3387
3611
  }
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`);
3612
+ function emailRules(emails) {
3613
+ return emails.map((email) => ({ email: { email } }));
3391
3614
  }
3392
- //#endregion
3393
- //#region src/http.ts
3394
- /** 错误响应体太长会淹没终端,只留头部。 */
3395
- var MAX_DETAIL = 400;
3396
- var ApiClient = class {
3397
- server;
3398
- token;
3399
- /** forward-auth 直连身份:作为 X-Forwarded-User 注入(server 默认 userHeader) */
3400
- user;
3401
- /** forward-auth fail-closed 的共享密钥:作为 X-Jiandu-Proxy-Secret 注入 */
3402
- proxySecret;
3403
- constructor(cfg) {
3404
- this.server = cfg.server;
3405
- this.token = cfg.token;
3406
- this.user = cfg.user;
3407
- this.proxySecret = cfg.proxySecret;
3615
+ /** 建好的应用认领方式:先按 host 的 `/p` 目标认,认不到再按名字。 */
3616
+ function appMatchesHost(app, host) {
3617
+ if ((app.destinations ?? []).map((d) => d.uri ?? "").some((uri) => uri.startsWith(`${host}/p`))) return true;
3618
+ return app.domain === `${host}/p` || app.domain === host;
3619
+ }
3620
+ async function listApps(api) {
3621
+ return await api.request("GET", `/accounts/${api.accountId}/access/apps`);
3622
+ }
3623
+ async function findAppForHost(api, host) {
3624
+ return (await listApps(api)).find((app) => appMatchesHost(app, host)) ?? null;
3625
+ }
3626
+ async function appPolicies(api, appId) {
3627
+ return await api.request("GET", `/accounts/${api.accountId}/access/apps/${appId}/policies`);
3628
+ }
3629
+ /** 直接改策略的 include(保留其它规则与 decision / name)。 */
3630
+ async function setPolicyEmails(api, appId, policy, emails) {
3631
+ await api.request("PUT", `/accounts/${api.accountId}/access/apps/${appId}/policies/${policy.id}`, {
3632
+ name: policy.name ?? "jiandu",
3633
+ decision: policy.decision ?? "allow",
3634
+ include: [...emailRules(emails), ...nonEmailRules(policy)]
3635
+ });
3636
+ }
3637
+ async function getOrganization(api) {
3638
+ try {
3639
+ return await api.request("GET", `/accounts/${api.accountId}/access/organizations`);
3640
+ } catch {
3641
+ return null;
3408
3642
  }
3409
- url(path) {
3410
- return `${this.server}${path}`;
3643
+ }
3644
+ /**
3645
+ * 账号还没有 Zero Trust team 时建一个。auth_domain 要全局唯一,撞了就换一个后缀重试。
3646
+ * 名字里的 account id 前缀足够区分,撞名是极小概率,但撞了不能让整个部署挂在这里。
3647
+ */
3648
+ async function ensureOrganization(api, workerName) {
3649
+ const domain = (await getOrganization(api))?.auth_domain;
3650
+ if (typeof domain === "string" && domain !== "") return domain;
3651
+ const base = `${workerName}-${api.accountId.slice(0, 8)}`.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
3652
+ for (const candidate of [base, `${base}-${Date.now().toString(36).slice(-4)}`]) try {
3653
+ const created = await api.request("POST", `/accounts/${api.accountId}/access/organizations`, {
3654
+ name: workerName,
3655
+ auth_domain: candidate
3656
+ });
3657
+ if (typeof created.auth_domain === "string" && created.auth_domain !== "") return created.auth_domain;
3658
+ } catch (err) {
3659
+ const again = await getOrganization(api);
3660
+ if (typeof again?.auth_domain === "string" && again.auth_domain !== "") return again.auth_domain;
3661
+ if (candidate.endsWith("4")) throw err;
3411
3662
  }
3412
- async getJson(path) {
3413
- return this.json("GET", path, void 0, void 0);
3663
+ throw new CliError("建 Zero Trust team 失败", "去 Cloudflare 控制台 Zero Trust 里手建一个 team 再重试");
3664
+ }
3665
+ /** 登录方式必须是 Cloudflare 账号(决策 8):没有就建一个,已存在就用它。 */
3666
+ async function ensureCloudflareIdp(api) {
3667
+ const hit = (await api.request("GET", `/accounts/${api.accountId}/access/identity_providers`)).find((p) => p.type === "cloudflare");
3668
+ if (hit) return hit.id;
3669
+ return (await api.request("POST", `/accounts/${api.accountId}/access/identity_providers`, {
3670
+ name: "Cloudflare",
3671
+ type: "cloudflare",
3672
+ config: { restrict_to_account_members: false }
3673
+ })).id;
3674
+ }
3675
+ /**
3676
+ * 保护 `/p*` 的应用:没有就建,有就保证部署者邮箱在策略里(其余邮箱保留)。
3677
+ * 只挡 /p:公开页(/d、/docs、/official、静态资源)与读 API 都不挂 Access,
3678
+ * 写 API 由 Worker 自己验同一份 JWT——理由见 docs/deploy.md「Cloudflare 形态」。
3679
+ */
3680
+ async function ensureAccessApp(api, input) {
3681
+ const destinations = [{
3682
+ type: "public",
3683
+ uri: `${input.host}/p`
3684
+ }, {
3685
+ type: "public",
3686
+ uri: `${input.host}/p/*`
3687
+ }];
3688
+ const existing = await findAppForHost(api, input.host);
3689
+ const app = existing ?? await api.request("POST", `/accounts/${api.accountId}/access/apps`, {
3690
+ name: input.name,
3691
+ type: "self_hosted",
3692
+ destinations,
3693
+ session_duration: "24h",
3694
+ allowed_idps: [input.idpId],
3695
+ auto_redirect_to_identity: true,
3696
+ skip_interstitial: true,
3697
+ app_launcher_visible: false
3698
+ });
3699
+ const policies = await appPolicies(api, app.id);
3700
+ const policy = policies.find((p) => (p.decision ?? "allow") === "allow") ?? policies[0];
3701
+ const current = policy ? emailsOf(policy) : [];
3702
+ const wanted = ensureEmailIncluded(current, input.emails);
3703
+ if (policy) {
3704
+ if (wanted.length !== current.length || wanted.some((e, i) => current[i] !== e)) await setPolicyEmails(api, app.id, policy, wanted);
3705
+ } else await api.request("POST", `/accounts/${api.accountId}/access/apps/${app.id}/policies`, {
3706
+ name: input.name,
3707
+ decision: "allow",
3708
+ include: emailRules(wanted)
3709
+ });
3710
+ return {
3711
+ app,
3712
+ emails: wanted,
3713
+ existed: existing !== null
3714
+ };
3715
+ }
3716
+ /** 合并邮箱:保序去重,且保证部署者邮箱一定在。 */
3717
+ function ensureEmailIncluded(current, wanted) {
3718
+ const out = [...current];
3719
+ for (const email of wanted) {
3720
+ const normalized = email.trim().toLowerCase();
3721
+ if (normalized !== "" && !out.includes(normalized)) out.push(normalized);
3414
3722
  }
3415
- async postJson(path, body) {
3416
- return this.json("POST", path, JSON.stringify(body), "application/json");
3417
- }
3418
- async putJson(path, body) {
3419
- return this.json("PUT", path, JSON.stringify(body), "application/json");
3420
- }
3421
- async deleteJson(path) {
3422
- return this.json("DELETE", path, void 0, void 0);
3423
- }
3424
- /** 文本路由(`/d/:id.md`):原样返回正文。 */
3425
- async getText(path) {
3426
- return (await this.send("GET", path, void 0, void 0)).text();
3427
- }
3428
- /** widget push 走 multipart:boundary 交给 fetch 生成,别自己设 Content-Type。 */
3429
- async postForm(path, form) {
3430
- const text = await (await this.send("POST", path, form, void 0)).text();
3431
- if (text.trim() === "") return void 0;
3432
- try {
3433
- return JSON.parse(text);
3434
- } catch {
3435
- throw new CliError(`POST ${this.url(path)} 返回的不是合法 JSON:${clip(text)}`);
3436
- }
3437
- }
3438
- /** blob 上传走原始字节,不做任何包装 —— server 直接对 body 校验 sha256。 */
3439
- async putBytes(path, bytes, contentType) {
3440
- await this.send("PUT", path, bytes, contentType);
3441
- }
3442
- async json(method, path, body, contentType) {
3443
- const text = await (await this.send(method, path, body, contentType)).text();
3444
- if (text.trim() === "") return void 0;
3445
- try {
3446
- return JSON.parse(text);
3447
- } catch {
3448
- throw new CliError(`${method} ${this.url(path)} 返回的不是合法 JSON:${clip(text)}`);
3449
- }
3450
- }
3451
- async send(method, path, body, contentType) {
3452
- const url = this.url(path);
3453
- const headers = {};
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;
3457
- if (contentType !== void 0) headers["Content-Type"] = contentType;
3458
- let res;
3459
- try {
3460
- res = await fetch(url, {
3461
- method,
3462
- headers,
3463
- body,
3464
- redirect: "manual"
3465
- });
3466
- } catch (err) {
3467
- throw new CliError(`请求 ${method} ${url} 失败:${describeNetworkError(err)}`, "确认 server 已启动、地址与端口正确");
3468
- }
3469
- if (!res.ok) throw await httpError(method, url, res);
3470
- return res;
3471
- }
3472
- };
3473
- function clip(text) {
3474
- const t = text.trim();
3475
- return t.length > MAX_DETAIL ? `${t.slice(0, MAX_DETAIL)}…` : t;
3476
- }
3477
- /** fetch 失败时真正的原因藏在 cause 里(ECONNREFUSED / ENOTFOUND / 证书错误…)。 */
3478
- function describeNetworkError(err) {
3479
- const cause = err?.cause;
3480
- if (cause instanceof Error) {
3481
- const code = cause.code;
3482
- return code ? `${code} ${cause.message}` : cause.message;
3483
- }
3484
- return err instanceof Error ? err.message : String(err);
3485
- }
3486
- async function httpError(method, url, res) {
3487
- let detail = "";
3488
- try {
3489
- detail = clip(await res.text());
3490
- } catch {
3491
- detail = "";
3492
- }
3493
- if (detail.startsWith("{")) try {
3494
- const obj = JSON.parse(detail);
3495
- const msg = obj.error ?? obj.message;
3496
- if (typeof msg === "string" && msg !== "") detail = msg;
3497
- } catch {}
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;
3499
- const suffix = detail === "" ? "" : `:${detail}`;
3500
- return new CliError(`${method} ${url} 返回 HTTP ${res.status} ${res.statusText}${suffix}`, hint);
3723
+ return out;
3501
3724
  }
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;
3725
+ /** `GET /accounts/:id/workers/subdomain`:拿不到(账号还没注册 workers.dev 子域)返回 null。 */
3726
+ async function workersDevSubdomain(api) {
3555
3727
  try {
3556
- token = JSON.parse(text).token;
3728
+ const result = await api.request("GET", `/accounts/${api.accountId}/workers/subdomain`);
3729
+ return typeof result.subdomain === "string" && result.subdomain !== "" ? result.subdomain : null;
3557
3730
  } catch {
3558
- token = void 0;
3731
+ return null;
3559
3732
  }
3560
- if (typeof token !== "string" || token === "") throw new CliError("server 没有返回 token");
3561
- return token;
3562
3733
  }
3563
3734
  //#endregion
3564
3735
  //#region src/login.ts
@@ -3596,6 +3767,13 @@ async function probeApiWithBearer(server, token) {
3596
3767
  redirect: "manual"
3597
3768
  })).ok;
3598
3769
  }
3770
+ /** Cloudflare 形态的凭据是 Access 应用 token:过边缘用 cf-access-token 头,不是 Bearer。 */
3771
+ async function probeApiWithAccessToken(server, token) {
3772
+ return (await fetch(`${server}/api/docs`, {
3773
+ headers: { "cf-access-token": token },
3774
+ redirect: "manual"
3775
+ })).ok;
3776
+ }
3599
3777
  /**
3600
3778
  * forward-auth / OIDC 分支,外加 --user 直连与 --token 直存。
3601
3779
  * 不是命令入口——入口是 init.ts 的 runLogin,它探完 healthz 才分派到这里。
@@ -3661,153 +3839,887 @@ async function runOidcLogin(opts) {
3661
3839
  };
3662
3840
  }
3663
3841
  //#endregion
3664
- //#region src/init.ts
3842
+ //#region src/access.ts
3665
3843
  /**
3666
- * jdu login:选定站点、拿到凭据、写好本地配置,一条命令把「登录到哪台简牍」定下来。
3667
- *
3668
- * 目标必须显式给出(没有默认站点,官方商用服务尚未上线):
3669
- * --local 本机自部署(http://127.0.0.1:8080)
3670
- * --server <u> 任意自部署地址
3844
+ * `jdu access`:改 Cloudflare 形态实例的准入——Access 策略里的邮箱列表。
3671
3845
  *
3672
- * agent 联动而设计:全 flag 驱动、无交互提示、--json 输出机器可读结果,
3673
- * 拿到 next 字段就知道下一步该干什么(要不要 token、去哪拿)。
3846
+ * 加人 = 把同事的邮箱写进 Allow 策略,对方用自己的 Cloudflare 账号(同一邮箱)登录;
3847
+ * 减人 = 从策略里去掉。jiandu 自己不存成员表,身份与准入只有 Access 这一个真源(决策 5)。
3674
3848
  *
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 之间二选一。
3849
+ * 需要一把有 Access 权限的 Cloudflare 凭据:`jdu deploy` 时授权过就直接用缓存,
3850
+ * 否则 `--cf-token` / `CLOUDFLARE_API_TOKEN`,再不行会开浏览器授权。
3681
3851
  */
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
- };
3852
+ var EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
3853
+ function normalizeEmails(emails) {
3854
+ const out = [];
3855
+ for (const raw of emails) {
3856
+ const email = raw.trim().toLowerCase();
3857
+ if (email === "") continue;
3858
+ if (!EMAIL_RE.test(email)) throw new CliError(`不是合法的邮箱:${raw}`, "邮箱要和对方的 Cloudflare 账号一致");
3859
+ if (!out.includes(email)) out.push(email);
3698
3860
  }
3699
- if (opts.local) return {
3700
- target: "local",
3701
- server: LOCAL_SERVER
3861
+ return out;
3862
+ }
3863
+ /** 站点地址:显式给的优先,其次本地配置里的那台。 */
3864
+ function serverOf(opts) {
3865
+ const server = (opts.server ?? readStoredConfig().server ?? "").trim().replace(/\/+$/, "");
3866
+ if (server === "") throw new CliError("需要知道改哪台实例的 Access 策略", "带 --server <url>,或先 jdu login --server <url>");
3867
+ return server;
3868
+ }
3869
+ /** 只有 Cloudflare 形态的实例有 Access 策略;其它 provider 说清而不是给个看不懂的 API 错误。 */
3870
+ async function ensureCloudflareInstance(server) {
3871
+ const healthz = await fetchHealthz(server);
3872
+ if (healthz.authProvider !== "cloudflare-access") throw new CliError(`${server} 是 ${healthz.authProvider ?? "未知"} 鉴权,没有 Access 邮箱策略`, "Docker / 本机形态的准入用 jdu member invite(passkey)或网关自己的配置");
3873
+ }
3874
+ async function locate(opts) {
3875
+ const server = serverOf(opts);
3876
+ await ensureCloudflareInstance(server);
3877
+ const host = new URL(server).host;
3878
+ const api = new CfApi({ credentials: await resolveCfCredentials({ ...opts.cfToken ? { cfToken: opts.cfToken } : {} }) });
3879
+ const app = await findAppForHost(api, host);
3880
+ if (!app) throw new CliError(`${host} 上没有找到 jiandu 的 Access 应用`, "确认实例是用 jdu deploy 部出来的(Access 应用由它建),或去 Zero Trust → Access 看一眼");
3881
+ const policies = await api.request("GET", `/accounts/${api.accountId}/access/apps/${app.id}/policies`);
3882
+ const policy = policies.find((p) => (p.decision ?? "allow") === "allow") ?? policies[0];
3883
+ if (!policy) throw new CliError(`${host} 的 Access 应用没有策略`, "去 Zero Trust → Access 里给它加一条 Allow 策略再重试");
3884
+ return {
3885
+ api,
3886
+ appId: app.id,
3887
+ policyId: policy.id,
3888
+ emails: emailsOf(policy)
3702
3889
  };
3703
- throw new CliError("jdu login 需要指定站点", "自部署用 --server <url>,本机用 --local");
3704
3890
  }
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
3891
+ async function runAccessList(opts) {
3892
+ const { api, appId, emails } = await locate(opts);
3893
+ return {
3894
+ server: serverOf(opts),
3895
+ accountId: api.accountId,
3896
+ appId,
3897
+ emails
3715
3898
  };
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
- };
3730
- }
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
- };
3899
+ }
3900
+ /** 加邮箱:先把现有的读回来,确保对方不在列表里再加(幂等)。 */
3901
+ async function runAccessAllow(emails, opts) {
3902
+ const wanted = normalizeEmails(emails);
3903
+ if (wanted.length === 0) throw new CliError("没有要加的邮箱", "jdu access allow <邮箱> [<邮箱>...]");
3904
+ const { api, appId, policyId, emails: current } = await locate(opts);
3905
+ const merged = ensureEmailIncluded(current, wanted);
3906
+ if (merged.length !== current.length || merged.some((e, i) => current[i] !== e)) await putEmails(api, appId, policyId, merged);
3907
+ return {
3908
+ server: serverOf(opts),
3909
+ accountId: api.accountId,
3910
+ appId,
3911
+ emails: merged
3912
+ };
3913
+ }
3914
+ async function runAccessDeny(emails, opts) {
3915
+ const unwanted = normalizeEmails(emails);
3916
+ if (unwanted.length === 0) throw new CliError("没有要移除的邮箱", "jdu access deny <邮箱> [<邮箱>...]");
3917
+ const { api, appId, policyId, emails: current } = await locate(opts);
3918
+ const kept = current.filter((email) => !unwanted.includes(email));
3919
+ const missing = unwanted.filter((email) => !current.includes(email));
3920
+ if (missing.length > 0) throw new CliError(`这些邮箱本来就不在策略里:${missing.join(", ")}`, "用 jdu access list 看当前名单(大小写不敏感)");
3921
+ await putEmails(api, appId, policyId, kept);
3922
+ return {
3923
+ server: serverOf(opts),
3924
+ accountId: api.accountId,
3925
+ appId,
3926
+ emails: kept
3927
+ };
3928
+ }
3929
+ /** 重写策略的 include:email 规则用新名单,其它规则(人手加的账号成员选择器等)原样保留。 */
3930
+ async function putEmails(api, appId, policyId, emails) {
3931
+ await setPolicyEmails(api, appId, await api.request("GET", `/accounts/${api.accountId}/access/apps/${appId}/policies/${policyId}`), emails);
3932
+ }
3933
+ //#endregion
3934
+ //#region src/blobs.ts
3935
+ /** 流式算 hash,避免把大图整份读进内存。 */
3936
+ function sha256File(path) {
3937
+ return new Promise((res, rej) => {
3938
+ const h = createHash("sha256");
3939
+ const rs = createReadStream(path);
3940
+ rs.on("error", rej);
3941
+ rs.on("data", (chunk) => h.update(chunk));
3942
+ rs.on("end", () => res(h.digest("hex")));
3943
+ });
3944
+ }
3945
+ var MIME$1 = Object.freeze({
3946
+ ".md": "text/markdown; charset=utf-8",
3947
+ ".markdown": "text/markdown; charset=utf-8",
3948
+ ".txt": "text/plain; charset=utf-8",
3949
+ ".json": "application/json",
3950
+ ".js": "text/javascript; charset=utf-8",
3951
+ ".mjs": "text/javascript; charset=utf-8",
3952
+ ".css": "text/css; charset=utf-8",
3953
+ ".html": "text/html; charset=utf-8",
3954
+ ".svg": "image/svg+xml",
3955
+ ".png": "image/png",
3956
+ ".jpg": "image/jpeg",
3957
+ ".jpeg": "image/jpeg",
3958
+ ".gif": "image/gif",
3959
+ ".webp": "image/webp",
3960
+ ".avif": "image/avif",
3961
+ ".ico": "image/x-icon",
3962
+ ".mp4": "video/mp4",
3963
+ ".webm": "video/webm",
3964
+ ".mov": "video/quicktime",
3965
+ ".mp3": "audio/mpeg",
3966
+ ".wav": "audio/wav",
3967
+ ".pdf": "application/pdf",
3968
+ ".zip": "application/zip",
3969
+ ".woff2": "font/woff2"
3970
+ });
3971
+ function guessMime(path) {
3972
+ return MIME$1[extname(path).toLowerCase()] ?? "application/octet-stream";
3973
+ }
3974
+ /**
3975
+ * 增量上传:先用 hash 清单跟 server 协商,只 PUT 缺失的。
3976
+ * server 返回体不可解析时按「全都缺」处理 —— 宁可多传也不能漏传导致文档半残。
3977
+ */
3978
+ async function syncBlobs(api, entries) {
3979
+ const byHash = /* @__PURE__ */ new Map();
3980
+ for (const e of entries) if (!byHash.has(e.hash)) byHash.set(e.hash, e.path);
3981
+ const hashes = [...byHash.keys()];
3982
+ if (hashes.length === 0) return {
3983
+ total: 0,
3984
+ uploaded: [],
3985
+ reused: 0
3986
+ };
3987
+ const res = await api.postJson("/api/blobs/check", { hashes });
3988
+ const missing = Array.isArray(res?.missing) ? res.missing.filter((h) => typeof h === "string") : hashes;
3989
+ const uploaded = [];
3990
+ for (const hash of missing) {
3991
+ const path = byHash.get(hash);
3992
+ if (path === void 0) continue;
3993
+ await api.putBytes(`/api/blobs/${hash}`, readFileSync(path), guessMime(path));
3994
+ uploaded.push(hash);
3995
+ }
3996
+ return {
3997
+ total: hashes.length,
3998
+ uploaded,
3999
+ reused: hashes.length - uploaded.length
4000
+ };
4001
+ }
4002
+ //#endregion
4003
+ //#region src/comments.ts
4004
+ /** 附件一行:`[图片 ×N] /blob/… /blob/…`,给 agent 的是相对路径,拼上 server 地址就能取 */
4005
+ function attachmentsLine(c) {
4006
+ const list = c.attachments ?? [];
4007
+ return list.length === 0 ? null : `[图片 ×${list.length}] ${list.map((s) => `/blob/${s}`).join(" ")}`;
4008
+ }
4009
+ var QUOTE_MAX = 120;
4010
+ function when(ms) {
4011
+ return new Date(ms).toISOString().slice(0, 16).replace("T", " ");
4012
+ }
4013
+ /** owner 是 UUID 且没有显示名时 authorLabel 为空串,退回原始 id 也比空着好认。 */
4014
+ function who(c) {
4015
+ return `${c.authorLabel || c.author}${c.authorType === "ai" ? " [ai]" : ""}`;
4016
+ }
4017
+ function indent(text, pad) {
4018
+ return text.split("\n").map((line) => `${pad}${line}`).join("\n");
4019
+ }
4020
+ /**
4021
+ * 线程列表。stdout 纯文本、一线程一段,给 agent 直接读:
4022
+ * 首行 `<threadId> <blockId> <open|resolved> <作者> <时间> [v<seq>]`,引文一行 `> …`,正文缩进两格,回复 `↳` 缩进。
4023
+ */
4024
+ async function listComments(api, docId, opts) {
4025
+ const res = await api.getJson(`/api/docs/${encodeURIComponent(docId)}/comments`);
4026
+ const all = Array.isArray(res?.comments) ? res.comments : [];
4027
+ const roots = all.filter((c) => c.parentId === null && (opts.all || !c.resolved));
4028
+ if (roots.length === 0) {
4029
+ process.stdout.write(`${opts.all ? "(无评论)" : "(无未处理评论)"}\n`);
4030
+ return;
4031
+ }
4032
+ const replies = /* @__PURE__ */ new Map();
4033
+ for (const c of all) {
4034
+ if (c.parentId === null) continue;
4035
+ const list = replies.get(c.parentId) ?? [];
4036
+ list.push(c);
4037
+ replies.set(c.parentId, list);
4038
+ }
4039
+ const out = [];
4040
+ for (const root of roots) {
4041
+ const head = [
4042
+ root.id,
4043
+ root.blockId ?? "全文",
4044
+ root.resolved ? "resolved" : "open",
4045
+ who(root),
4046
+ when(root.createdAt)
4047
+ ];
4048
+ if (root.versionSeq !== null) head.push(`v${root.versionSeq}`);
4049
+ out.push(head.join(" "));
4050
+ const quote = root.selection?.quote;
4051
+ if (quote) out.push(` > ${quote.length > QUOTE_MAX ? `${quote.slice(0, QUOTE_MAX)}…` : quote}`);
4052
+ if (root.body) out.push(indent(root.body, " "));
4053
+ const rootPics = attachmentsLine(root);
4054
+ if (rootPics) out.push(` ${rootPics}`);
4055
+ for (const reply of replies.get(root.id) ?? []) {
4056
+ out.push(` ↳ ${reply.id} ${who(reply)} ${when(reply.createdAt)}`);
4057
+ if (reply.body) out.push(indent(reply.body, " "));
4058
+ const pics = attachmentsLine(reply);
4059
+ if (pics) out.push(` ${pics}`);
4060
+ }
4061
+ out.push("");
4062
+ }
4063
+ process.stdout.write(out.join("\n"));
4064
+ }
4065
+ async function replyComment(api, threadId, opts) {
4066
+ const res = await api.postJson(`/api/comments/${encodeURIComponent(threadId)}/reply`, {
4067
+ body: opts.message,
4068
+ authorType: opts.ai ? "ai" : "human"
4069
+ });
4070
+ process.stdout.write(`replied ${String(res?.parentId ?? threadId)} → ${String(res?.id ?? "")}\n`);
4071
+ }
4072
+ async function resolveComment(api, threadId, opts) {
4073
+ const res = await api.postJson(`/api/comments/${encodeURIComponent(threadId)}/resolve`, { resolved: !opts.undo });
4074
+ process.stdout.write(`${opts.undo ? "reopened" : "resolved"} ${String(res?.id ?? threadId)}\n`);
4075
+ }
4076
+ //#endregion
4077
+ //#region src/browser-auth.ts
4078
+ /**
4079
+ * jdu login 的浏览器授权(#66):本机起一个 loopback 回调,打开 server 下发的 authorizeUrl,
4080
+ * 人在浏览器里用 passkey 会话点一次「授权」,回跳带回一次性 code,再用 PKCE verifier 向 server 换 token。
4081
+ *
4082
+ * authorizeUrl 与 server 可能不同源(CLI 连 127.0.0.1:18082,浏览器开 https://md.mason.local)——
4083
+ * 浏览器去前者,code 换 token 打后者。回调端口随机(server 接受任意 loopback 端口),不和 OIDC 的 8085 抢。
4084
+ */
4085
+ async function browserAuthorize(input) {
4086
+ const { verifier, challenge } = pkce();
4087
+ const state = randomBytes(16).toString("base64url");
4088
+ const code = await new Promise((resolve, reject) => {
4089
+ const finish = (fn) => (value) => {
4090
+ clearTimeout(timer);
4091
+ server.close();
4092
+ fn(value);
4093
+ };
4094
+ const fail = finish((msg) => reject(new CliError(msg, "重新执行 jdu login;不想开浏览器就带 --token")));
4095
+ const handler = callbackHandler(state, finish(resolve));
4096
+ const server = createServer((req, res) => {
4097
+ if (new URL(req.url ?? "/", "http://localhost").pathname !== "/callback") {
4098
+ res.writeHead(404).end();
4099
+ return;
4100
+ }
4101
+ handler(req, res);
4102
+ });
4103
+ const timer = setTimeout(() => fail("等待浏览器授权超时"), input.timeoutMs ?? 18e4);
4104
+ server.on("error", (err) => fail(`监听 loopback 回调失败:${err.message}`));
4105
+ server.listen(0, "127.0.0.1", () => {
4106
+ const port = server.address().port;
4107
+ const url = new URL(input.authorizeUrl);
4108
+ url.search = new URLSearchParams({
4109
+ state,
4110
+ code_challenge: challenge,
4111
+ port: String(port),
4112
+ label: input.label ?? hostname()
4113
+ }).toString();
4114
+ process.stderr.write(`在浏览器中完成授权:${url.toString()}\n`);
4115
+ (input.open ?? openBrowser)(url.toString());
4116
+ });
4117
+ });
4118
+ const res = await fetch(`${input.server}/api/cli/token`, {
4119
+ method: "POST",
4120
+ headers: { "content-type": "application/json" },
4121
+ body: JSON.stringify({
4122
+ code,
4123
+ code_verifier: verifier
4124
+ })
4125
+ });
4126
+ const text = await res.text();
4127
+ if (!res.ok) throw new CliError(`code 换 token 失败(HTTP ${res.status}):${text.slice(0, 200)}`, "重新执行 jdu login 再试一次");
4128
+ let token;
4129
+ try {
4130
+ token = JSON.parse(text).token;
4131
+ } catch {
4132
+ token = void 0;
4133
+ }
4134
+ if (typeof token !== "string" || token === "") throw new CliError("server 没有返回 token");
4135
+ return token;
4136
+ }
4137
+ //#endregion
4138
+ //#region src/init.ts
4139
+ /**
4140
+ * jdu login:选定站点、拿到凭据、写好本地配置,一条命令把「登录到哪台简牍」定下来。
4141
+ *
4142
+ * 目标必须显式给出(没有默认站点,官方商用服务尚未上线):
4143
+ * --local 本机自部署(http://127.0.0.1:8080)
4144
+ * --server <u> 任意自部署地址
4145
+ *
4146
+ * 为 agent 联动而设计:全 flag 驱动、无交互提示、--json 输出机器可读结果,
4147
+ * 拿到 next 字段就知道下一步该干什么(要不要 token、去哪拿)。
4148
+ *
4149
+ * 登录方式全听 server 的(healthz.cliAuth,#66),用户不必先知道自己的站点用什么鉴权:
4150
+ * browser → 开浏览器授权换 token
4151
+ * oidc → 委托 runOidcLogin 走 SSO(forward-auth)
4152
+ * token → 把 server 给的 tokenHint 拼进 next,告诉去哪拿
4153
+ * anonymous→ 直接就绪
4154
+ * CLI 不按「官方 / 自部署」猜,也不再要求用户在 init / login 之间二选一。
4155
+ */
4156
+ var LOCAL_SERVER = "http://127.0.0.1:8080";
4157
+ function resolveInitTarget(opts) {
4158
+ if (opts.local && opts.server) throw new CliError("--local 与 --server 只能二选一");
4159
+ if (opts.server !== void 0) {
4160
+ const server = opts.server.trim().replace(/\/+$/, "");
4161
+ let parsed;
4162
+ try {
4163
+ parsed = new URL(server);
4164
+ } catch {
4165
+ throw new CliError(`--server 不是合法 URL:${opts.server}`);
4166
+ }
4167
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || /[\s'"`$\\;|&<>(){}*?#\[\]]/.test(server)) throw new CliError(`--server 需要干净的 http/https 地址(不含空白与 shell 元字符):${opts.server}`);
4168
+ return {
4169
+ target: "custom",
4170
+ server
4171
+ };
4172
+ }
4173
+ if (opts.local) return {
4174
+ target: "local",
4175
+ server: LOCAL_SERVER
4176
+ };
4177
+ throw new CliError("jdu login 需要指定站点", "自部署用 --server <url>,本机用 --local");
4178
+ }
4179
+ async function runLogin(opts) {
4180
+ const { target, server } = resolveInitTarget(opts);
4181
+ const healthz = await (opts.fetchHealthz ?? fetchHealthz)(server);
4182
+ const provider = healthz.authProvider ?? "token";
4183
+ const methods = healthz.cliAuth?.methods ?? fallbackMethods(provider);
4184
+ const base = {
4185
+ target,
4186
+ server,
4187
+ authProvider: provider,
4188
+ authMethods: methods
4189
+ };
4190
+ const cfAccess = provider === "cloudflare-access";
4191
+ if (opts.user !== void 0 && opts.user.trim() !== "") {
4192
+ const r = await runOidcLogin({
4193
+ server,
4194
+ user: opts.user,
4195
+ proxySecret: opts.proxySecret,
4196
+ fetchHealthz: async () => healthz
4197
+ });
4198
+ return {
4199
+ ...base,
4200
+ loggedIn: true,
4201
+ configPath: r.configPath,
4202
+ next: null,
4203
+ warning: r.warning
4204
+ };
4205
+ }
4206
+ if (opts.token !== void 0 && opts.token !== "") {
4207
+ if (!await (opts.probe ?? (cfAccess ? probeApiWithAccessToken : probeApiWithBearer))(server, opts.token)) throw new CliError(cfAccess ? "Access 应用 token 校验失败(/api/docs 未放行)" : "token 校验失败(/api/docs 未放行)", cfAccess ? "token 要没过期、且是本实例 Access 应用签的" : "确认 token 正确、且网关放行 Authorization: Bearer");
4208
+ const path = saveConfig({
4209
+ server,
4210
+ token: opts.token,
4211
+ ...cfAccess ? { cfAccess: true } : {}
4212
+ });
4213
+ return {
4214
+ ...base,
4215
+ loggedIn: true,
4216
+ configPath: path,
4217
+ next: null
4218
+ };
4219
+ }
4220
+ const oidc = healthz.oidc?.issuer && healthz.oidc.clientId ? {
4221
+ issuer: healthz.oidc.issuer,
4222
+ clientId: healthz.oidc.clientId
4223
+ } : void 0;
4224
+ const path = saveConfig(oidc ? {
4225
+ server,
4226
+ oidc
4227
+ } : { server });
4228
+ if (provider === "anonymous") return {
4229
+ ...base,
4230
+ loggedIn: true,
4231
+ configPath: path,
4232
+ next: null
4233
+ };
4234
+ const authorizeUrl = healthz.cliAuth?.authorizeUrl;
4235
+ if (opts.browser && methods.includes("browser") && authorizeUrl) {
4236
+ const withToken = saveConfig({
4237
+ server,
4238
+ token: await (opts.authorize ?? browserAuthorize)({
4239
+ server,
4240
+ authorizeUrl
4241
+ }),
4242
+ ...cfAccess ? { cfAccess: true } : {}
4243
+ });
4244
+ return {
4245
+ ...base,
4246
+ loggedIn: true,
4247
+ configPath: withToken,
4248
+ next: null
4249
+ };
4250
+ }
4251
+ if (cfAccess) return {
4252
+ ...base,
4253
+ loggedIn: false,
4254
+ configPath: path,
4255
+ next: `jdu login --server '${server}'(打开浏览器,用 Cloudflare 账号授权;--json / --no-browser 下不会开浏览器)`
4256
+ };
4257
+ const viaToken = `jdu login --server '${server}' --token <token>(${healthz.cliAuth?.tokenHint ?? "token 在 server 首启 stdout / data/initial-token.txt"})`;
4258
+ if (provider === "forward-auth" && opts.browser) {
4259
+ const r = await runOidcLogin({
4260
+ server,
4261
+ issuer: opts.issuer,
4262
+ clientId: opts.clientId,
4263
+ fetchHealthz: async () => healthz,
4264
+ oidcLogin: opts.oidcLogin,
4265
+ probe: opts.probe
4266
+ });
4267
+ return {
4268
+ ...base,
4269
+ loggedIn: true,
4270
+ configPath: r.configPath,
4271
+ next: null,
4272
+ warning: r.warning
4273
+ };
4274
+ }
4275
+ const next = methods.includes("browser") ? `jdu login --server '${server}'(不带 --json / --no-browser,打开浏览器授权),或 ${viaToken}` : viaToken;
4276
+ return {
4277
+ ...base,
4278
+ loggedIn: false,
4279
+ configPath: path,
4280
+ next
4281
+ };
4282
+ }
4283
+ /** 老 server 的 healthz 没有 cliAuth:按 provider 推断,与 server 侧 cliAuthOf 的保守档一致。 */
4284
+ function fallbackMethods(provider) {
4285
+ if (provider === "anonymous") return [];
4286
+ if (provider === "forward-auth") return ["oidc", "token"];
4287
+ if (provider === "cloudflare-access") return ["browser"];
4288
+ return ["token"];
4289
+ }
4290
+ //#endregion
4291
+ //#region src/deploy.ts
4292
+ /**
4293
+ * `jdu deploy`:在用户自己的 Cloudflare 账号里起一个 jiandu。
4294
+ *
4295
+ * 一条命令要做完的事(顺序有依赖,每一步都幂等):
4296
+ *
4297
+ * OAuth 或 --cf-token ──▶ 读 account id / 邮箱
4298
+ * ──▶ 确保 Zero Trust team 与 Cloudflare IdP
4299
+ * ──▶ upsert Access 应用(只挡 /p*)与邮箱策略
4300
+ * ──▶ wrangler deploy(带 JIANDU_AUTH_* / TEAM_DOMAIN / POLICY_AUD 变量)
4301
+ * ──▶ 退避探 /healthz ──▶ 本地 jdu login(浏览器)
4302
+ *
4303
+ * 为什么不是「点一个 Deploy 按钮」:jiandu 是给 agent 用的,agent 会跑命令不会点按钮。
4304
+ * 为什么不自己调 Cloudflare API 传 Worker:Worker 上传、Durable Object migration、静态资源
4305
+ * 上传会话是三套协议,wrangler 已经把它们做完了,重写一遍只会多一份要跟着 Cloudflare 演进的代码。
4306
+ * 所以 wrangler 只负责「传产物」,Access 与变量由这里直接调 API 配。
4307
+ *
4308
+ * Access 的权限 wrangler 的 OAuth 客户端没有(实测 POST /access/apps → 1010 auth.forbidden),
4309
+ * 而 Cloudflare 自己的 CLI OAuth 客户端有 access:read/write,见 cf-oauth.ts。
4310
+ * 拿到的 token 以 CLOUDFLARE_API_TOKEN 交给 wrangler(一把 token 干完两件事)。
4311
+ */
4312
+ /** npm 上的部署产物包:worker.js + assets/ + wrangler.json。 */
4313
+ var PACKAGE = "@monoedge/jiandu-cf";
4314
+ /** wrangler 主版本锁死:它改 flag 我们要跟着改,不能让用户某天突然拿到 v5。 */
4315
+ var WRANGLER = "wrangler@4";
4316
+ /** healthz 退避探活:#155 的教训——冷启动要种 16 篇官方文档,第一次探活必须允许它慢。 */
4317
+ var HEALTH_TIMEOUT_MS = 45e3;
4318
+ var HEALTH_INTERVAL_MS = 2e3;
4319
+ /** 默认 runner:stdio 继承给用户看(wrangler 的 OAuth 提示、进度条都靠它)。 */
4320
+ var spawnRunner = (cmd, args, opts) => new Promise((ok, fail) => {
4321
+ const child = spawn(cmd, args, {
4322
+ stdio: [
4323
+ opts.input === void 0 ? "inherit" : "pipe",
4324
+ "inherit",
4325
+ "inherit"
4326
+ ],
4327
+ shell: process.platform === "win32",
4328
+ env: {
4329
+ ...process.env,
4330
+ ...opts.env
4331
+ }
4332
+ });
4333
+ if (opts.input !== void 0) child.stdin?.end(opts.input);
4334
+ child.on("error", (err) => fail(new CliError(`跑不起来 ${cmd}:${err.message}`, "需要 node 与 npm 在 PATH 里")));
4335
+ child.on("exit", (code) => code === 0 ? ok() : fail(new CliError(`${cmd} ${args[0] ?? ""} 失败(退出码 ${code})`)));
4336
+ });
4337
+ /**
4338
+ * 取部署产物:`--dist` 指本地目录,否则临时装一份 npm 包。
4339
+ * 返回 wrangler 配置的绝对路径 —— wrangler 按**配置文件所在目录**解析 main 与 assets.directory,
4340
+ * 所以不需要把产物拷到工作目录,也不需要生成任何脚手架。
4341
+ */
4342
+ async function resolveConfig(opts, run) {
4343
+ if (opts.dist !== void 0) {
4344
+ const config = resolve(opts.dist, "wrangler.json");
4345
+ if (!existsSync(config)) throw new CliError(`${config} 不存在`, "--dist 要指向 server/dist-cf 那样的构建产物目录");
4346
+ return {
4347
+ config,
4348
+ cleanup: () => void 0
4349
+ };
4350
+ }
4351
+ const dir = mkdtempSync(join(tmpdir(), "jdu-deploy-"));
4352
+ try {
4353
+ await run("npm", [
4354
+ "install",
4355
+ "--no-audit",
4356
+ "--no-fund",
4357
+ "--prefix",
4358
+ dir,
4359
+ `${PACKAGE}@latest`
4360
+ ], {});
4361
+ } catch (err) {
4362
+ rmSync(dir, {
4363
+ recursive: true,
4364
+ force: true
4365
+ });
4366
+ throw err;
4367
+ }
4368
+ const config = join(dir, "node_modules", PACKAGE, "dist", "wrangler.json");
4369
+ if (!existsSync(config)) {
4370
+ rmSync(dir, {
4371
+ recursive: true,
4372
+ force: true
4373
+ });
4374
+ throw new CliError(`${PACKAGE} 里没有 dist/wrangler.json`, "包版本太老或装坏了,重试一次;仍失败请报 issue");
4375
+ }
4376
+ return {
4377
+ config,
4378
+ cleanup: () => rmSync(dir, {
4379
+ recursive: true,
4380
+ force: true
4381
+ })
4382
+ };
4383
+ }
4384
+ /** wrangler 的结构化输出:NDJSON,deploy 那条带 targets(部署好的地址)。 */
4385
+ function urlFromOutput(file) {
4386
+ if (!existsSync(file)) return null;
4387
+ for (const line of readFileSync(file, "utf8").split("\n")) {
4388
+ if (line.trim() === "") continue;
4389
+ try {
4390
+ const entry = JSON.parse(line);
4391
+ if (entry.type !== "deploy" || !Array.isArray(entry.targets)) continue;
4392
+ const target = entry.targets.find((t) => typeof t === "string" && t.startsWith("http"));
4393
+ if (target !== void 0) return target;
4394
+ } catch {}
4395
+ }
4396
+ return null;
4397
+ }
4398
+ /**
4399
+ * 退避探活:冷启动要建表 + 种官方文档,第一次请求可能落在 DO 还没热身完的窗口里(#155)。
4400
+ * 返回最后一次看到的 healthz;一直不通返回 null。
4401
+ */
4402
+ async function waitHealthy(url, fetchImpl, timeoutMs = HEALTH_TIMEOUT_MS) {
4403
+ const deadline = Date.now() + timeoutMs;
4404
+ for (;;) {
4405
+ try {
4406
+ const res = await fetchImpl(`${url}/healthz`, { headers: { accept: "application/json" } });
4407
+ if (res.ok) {
4408
+ const body = await res.json();
4409
+ if (body.runtime === "cloudflare") return body;
4410
+ }
4411
+ } catch {}
4412
+ if (Date.now() >= deadline) return null;
4413
+ await new Promise((r) => setTimeout(r, HEALTH_INTERVAL_MS));
4414
+ }
4415
+ }
4416
+ /** 部署命令:产物配置 + 变量 + 输出文件。 */
4417
+ function deployArgs(input) {
4418
+ const args = [
4419
+ "--yes",
4420
+ WRANGLER,
4421
+ "deploy",
4422
+ "-c",
4423
+ input.config,
4424
+ "--name",
4425
+ input.name,
4426
+ "--keep-vars"
4427
+ ];
4428
+ for (const [key, value] of Object.entries(input.vars ?? {})) args.push("--var", `${key}:${value}`);
4429
+ return args;
4430
+ }
4431
+ /**
4432
+ * 账号还没注册 workers.dev 子域时的兜底:先不带 Access 变量传一次,让 wrangler 引导注册并
4433
+ * 打印地址;随后配好 Access 再带变量传第二次(那才是真正生效的那次)。
4434
+ */
4435
+ async function warmupDeploy(config, name, env, run) {
4436
+ const outDir = mkdtempSync(join(tmpdir(), "jdu-wrangler-out-"));
4437
+ const outFile = join(outDir, "out.ndjson");
4438
+ try {
4439
+ await run("npx", deployArgs({
4440
+ config,
4441
+ name
4442
+ }), { env: {
4443
+ ...env,
4444
+ WRANGLER_OUTPUT_FILE_PATH: outFile
4445
+ } });
4446
+ const url = urlFromOutput(outFile);
4447
+ if (url === null) throw new CliError("部署命令跑完了,但没能从 wrangler 的输出里读到地址", "去 Cloudflare 控制台看这个 Worker 的 workers.dev 地址,再重跑一次 jdu deploy");
4448
+ return url;
4449
+ } finally {
4450
+ rmSync(outDir, {
4451
+ recursive: true,
4452
+ force: true
4453
+ });
4454
+ }
4455
+ }
4456
+ async function runDeploy(opts) {
4457
+ const run = opts.run ?? spawnRunner;
4458
+ const name = opts.name ?? "jiandu";
4459
+ const empty = {
4460
+ name,
4461
+ url: null,
4462
+ accountId: null,
4463
+ ownerEmail: null,
4464
+ emails: [],
4465
+ accessAppId: null,
4466
+ upgraded: false,
4467
+ loggedIn: false
4468
+ };
4469
+ const { config, cleanup } = await resolveConfig(opts, run);
4470
+ try {
4471
+ if (opts.dryRun) {
4472
+ await run("npx", [
4473
+ "--yes",
4474
+ WRANGLER,
4475
+ "deploy",
4476
+ "--dry-run",
4477
+ "-c",
4478
+ config,
4479
+ "--name",
4480
+ name
4481
+ ], {});
4482
+ return empty;
4483
+ }
4484
+ const fetchImpl = opts.fetchImpl ?? fetch;
4485
+ const credentials = await resolveCfCredentials({
4486
+ ...opts.cfToken ? { cfToken: opts.cfToken } : {},
4487
+ allowBrowser: opts.json !== true,
4488
+ fetchImpl
4489
+ });
4490
+ const api = new CfApi({
4491
+ credentials,
4492
+ fetchImpl,
4493
+ onTokens: (tokens) => writeCfConfig({
4494
+ ...tokens,
4495
+ accountId: credentials.accountId,
4496
+ email: credentials.email
4497
+ })
4498
+ });
4499
+ const identity = {
4500
+ accountId: credentials.accountId,
4501
+ email: credentials.email
4502
+ };
4503
+ const deployEnv = { CLOUDFLARE_API_TOKEN: credentials.tokens.accessToken };
4504
+ const subdomain = await workersDevSubdomain(api);
4505
+ const fromSubdomain = subdomain === null ? null : `${name}.${subdomain}.workers.dev`;
4506
+ const deployed = fromSubdomain === null ? await warmupDeploy(config, name, deployEnv, run) : null;
4507
+ const siteUrl = deployed ?? `https://${fromSubdomain}`;
4508
+ const siteHost = deployed === null ? fromSubdomain : new URL(deployed).host;
4509
+ const authDomain = await ensureOrganization(api, name);
4510
+ const idpId = await ensureCloudflareIdp(api);
4511
+ const { app, emails, existed } = await ensureAccessApp(api, {
4512
+ name,
4513
+ host: siteHost,
4514
+ emails: [identity.email],
4515
+ idpId
4516
+ });
4517
+ const vars = {
4518
+ JIANDU_AUTH_PROVIDER: "cloudflare-access",
4519
+ JIANDU_AUTH_OWNER: identity.accountId,
4520
+ JIANDU_AUTH_OWNER_EMAIL: identity.email,
4521
+ JIANDU_TEAM_DOMAIN: `https://${authDomain}`,
4522
+ JIANDU_POLICY_AUD: app.aud ?? ""
4523
+ };
4524
+ if (app.aud === void 0 || app.aud === "") throw new CliError("Access 应用没有 aud", "去 Cloudflare Zero Trust → Access 里看一眼应用是否建好");
4525
+ if (!siteUrl.startsWith("https://")) throw new CliError(`部署地址不是 https:${siteUrl}`, "Cloudflare 形态必须有 https 才能用 Access");
4526
+ const outDir = mkdtempSync(join(tmpdir(), "jdu-wrangler-out-"));
4527
+ const outFile = join(outDir, "out.ndjson");
4528
+ await run("npx", deployArgs({
4529
+ config,
4530
+ name,
4531
+ vars
4532
+ }), { env: {
4533
+ ...deployEnv,
4534
+ WRANGLER_OUTPUT_FILE_PATH: outFile
4535
+ } });
4536
+ rmSync(outDir, {
4537
+ recursive: true,
4538
+ force: true
4539
+ });
4540
+ const health = await waitHealthy(siteUrl, fetchImpl);
4541
+ if (health === null) throw new CliError(`${siteUrl} 起来了但 /healthz 一直不通(等了 ${HEALTH_TIMEOUT_MS / 1e3}s)`, `跑 npx ${WRANGLER} tail --name ${name} 看 Worker 日志;Access 策略已经配好,修完不用重跑 jdu deploy`);
4542
+ if (health.authProvider !== "cloudflare-access") throw new CliError(`${siteUrl} 的 authProvider 是 ${health.authProvider ?? "未知"},不是 cloudflare-access`, "确认 wrangler 变量写进去了(wrangler deploy 会打印绑定列表)");
4543
+ let loggedIn = false;
4544
+ if (opts.login !== false) loggedIn = (await runLogin({
4545
+ server: siteUrl,
4546
+ browser: true
4547
+ })).loggedIn;
4548
+ return {
4549
+ name,
4550
+ url: siteUrl,
4551
+ accountId: identity.accountId,
4552
+ ownerEmail: identity.email,
4553
+ emails,
4554
+ accessAppId: app.id,
4555
+ upgraded: existed,
4556
+ loggedIn
4557
+ };
4558
+ } finally {
4559
+ cleanup();
4560
+ }
4561
+ }
4562
+ /** 已存在的应用(升级路径):只用来报「这次是升级」,邮箱列表由 ensureAccessApp 保证不动。 */
4563
+ //#endregion
4564
+ //#region src/http.ts
4565
+ /** 错误响应体太长会淹没终端,只留头部。 */
4566
+ var MAX_DETAIL = 400;
4567
+ var ApiClient = class {
4568
+ server;
4569
+ token;
4570
+ /** token 是 Cloudflare Access 应用 token:带上 cf-access-token 头过边缘(见 docs/deploy.md) */
4571
+ cfAccess;
4572
+ /** forward-auth 直连身份:作为 X-Forwarded-User 注入(server 默认 userHeader) */
4573
+ user;
4574
+ /** forward-auth fail-closed 的共享密钥:作为 X-Jiandu-Proxy-Secret 注入 */
4575
+ proxySecret;
4576
+ constructor(cfg) {
4577
+ this.server = cfg.server;
4578
+ this.token = cfg.token;
4579
+ this.cfAccess = cfg.cfAccess === true;
4580
+ this.user = cfg.user;
4581
+ this.proxySecret = cfg.proxySecret;
4582
+ }
4583
+ url(path) {
4584
+ return `${this.server}${path}`;
4585
+ }
4586
+ async getJson(path) {
4587
+ return this.json("GET", path, void 0, void 0);
4588
+ }
4589
+ async postJson(path, body) {
4590
+ return this.json("POST", path, JSON.stringify(body), "application/json");
4591
+ }
4592
+ async putJson(path, body) {
4593
+ return this.json("PUT", path, JSON.stringify(body), "application/json");
4594
+ }
4595
+ async deleteJson(path) {
4596
+ return this.json("DELETE", path, void 0, void 0);
4597
+ }
4598
+ /** 文本路由(`/d/:id.md`):原样返回正文。 */
4599
+ async getText(path) {
4600
+ return (await this.send("GET", path, void 0, void 0)).text();
4601
+ }
4602
+ /** widget push 走 multipart:boundary 交给 fetch 生成,别自己设 Content-Type。 */
4603
+ async postForm(path, form) {
4604
+ const text = await (await this.send("POST", path, form, void 0)).text();
4605
+ if (text.trim() === "") return void 0;
4606
+ try {
4607
+ return JSON.parse(text);
4608
+ } catch {
4609
+ throw new CliError(`POST ${this.url(path)} 返回的不是合法 JSON:${clip(text)}`);
4610
+ }
4611
+ }
4612
+ /** blob 上传走原始字节,不做任何包装 —— server 直接对 body 校验 sha256。 */
4613
+ async putBytes(path, bytes, contentType) {
4614
+ await this.send("PUT", path, bytes, contentType);
4615
+ }
4616
+ async json(method, path, body, contentType) {
4617
+ const text = await (await this.send(method, path, body, contentType)).text();
4618
+ if (text.trim() === "") return void 0;
4619
+ try {
4620
+ return JSON.parse(text);
4621
+ } catch {
4622
+ throw new CliError(`${method} ${this.url(path)} 返回的不是合法 JSON:${clip(text)}`);
4623
+ }
3743
4624
  }
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
- };
4625
+ async send(method, path, body, contentType) {
4626
+ const url = this.url(path);
4627
+ const headers = {};
4628
+ if (this.token !== void 0) {
4629
+ if (this.cfAccess) headers["cf-access-token"] = this.token;
4630
+ else headers.Authorization = `Bearer ${this.token}`;
4631
+ }
4632
+ if (this.user !== void 0) headers["X-Forwarded-User"] = this.user;
4633
+ if (this.proxySecret !== void 0) headers["X-Jiandu-Proxy-Secret"] = this.proxySecret;
4634
+ if (contentType !== void 0) headers["Content-Type"] = contentType;
4635
+ let res;
4636
+ try {
4637
+ res = await fetch(url, {
4638
+ method,
4639
+ headers,
4640
+ body,
4641
+ redirect: "manual"
4642
+ });
4643
+ } catch (err) {
4644
+ throw new CliError(`请求 ${method} ${url} 失败:${describeNetworkError(err)}`, "确认 server 已启动、地址与端口正确");
4645
+ }
4646
+ if (!res.ok) throw await httpError(method, url, res);
4647
+ return res;
3773
4648
  }
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
- };
4649
+ };
4650
+ function clip(text) {
4651
+ const t = text.trim();
4652
+ return t.length > MAX_DETAIL ? `${t.slice(0, MAX_DETAIL)}…` : t;
4653
+ }
4654
+ /** fetch 失败时真正的原因藏在 cause 里(ECONNREFUSED / ENOTFOUND / 证书错误…)。 */
4655
+ function describeNetworkError(err) {
4656
+ const cause = err?.cause;
4657
+ if (cause instanceof Error) {
4658
+ const code = cause.code;
4659
+ return code ? `${code} ${cause.message}` : cause.message;
3791
4660
  }
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
- };
4661
+ return err instanceof Error ? err.message : String(err);
3799
4662
  }
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"];
4663
+ async function httpError(method, url, res) {
4664
+ let detail = "";
4665
+ try {
4666
+ detail = clip(await res.text());
4667
+ } catch {
4668
+ detail = "";
4669
+ }
4670
+ if (detail.startsWith("{")) try {
4671
+ const obj = JSON.parse(detail);
4672
+ const msg = obj.error ?? obj.message;
4673
+ if (typeof msg === "string" && msg !== "") detail = msg;
4674
+ } catch {}
4675
+ 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 ? "网关要登录。Cloudflare 形态下重新执行 jdu login;forward-auth 下先 jdu login,且网关需接受 Authorization: Bearer" : void 0;
4676
+ const suffix = detail === "" ? "" : `:${detail}`;
4677
+ return new CliError(`${method} ${url} 返回 HTTP ${res.status} ${res.statusText}${suffix}`, hint);
3805
4678
  }
3806
4679
  /** 只取链接/图片的目标部分,标签内容可以任意嵌套,不参与匹配。 */
3807
4680
  var MD_DEST_RE = /\]\(([^)]*)\)/g;
3808
4681
  var HTML_ATTR_RE = /\b(?:src|href)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'`=<>]+))/gi;
3809
- /** 抽出一份文本里所有可能的引用目标(未过滤外链,原样返回)。 */
3810
- function extractRefs(text) {
4682
+ /** server 的 scanFences 同一条规则(rewrite.ts):缩进 ≤3、3 个以上反引号或波浪号。 */
4683
+ var FENCE_RE = /^([ \t]{0,3})(`{3,}|~{3,})(.*)$/;
4684
+ /**
4685
+ * 去掉 fenced code block 再找引用:代码块里的 `](./a.png)` 是给人看的例子,不是引用。
4686
+ *
4687
+ * 这条规则必须与 server 对齐 —— `rewriteReferences` 就是按 `scanFences` 跳过同样的区间。
4688
+ * 不一致的后果是双向的:CLI 多收就会上传一堆永远不会被改写的文件,还对着代码里的
4689
+ * `steps[i](tx)` 这种写法报「跳过不存在的本地引用」;CLI 少收则会漏传真引用。
4690
+ *
4691
+ * 明知是重复实现:CLI 是零依赖的单文件发布物,为十几行扫描规则给它加一个 workspace 依赖不值。
4692
+ * 规则本身是 CommonMark 定死的,不会漂。
4693
+ */
4694
+ function stripFences(text) {
4695
+ const out = [];
4696
+ let open = null;
4697
+ for (const line of text.split("\n")) {
4698
+ const m = FENCE_RE.exec(line);
4699
+ const marker = m?.[2];
4700
+ if (marker !== void 0) {
4701
+ const char = marker[0];
4702
+ const rest = m?.[3] ?? "";
4703
+ if (open === null) {
4704
+ if (!(char === "`" && rest.includes("`"))) {
4705
+ open = {
4706
+ char,
4707
+ len: marker.length
4708
+ };
4709
+ continue;
4710
+ }
4711
+ } else if (char === open.char && marker.length >= open.len && rest.trim() === "") {
4712
+ open = null;
4713
+ continue;
4714
+ }
4715
+ }
4716
+ if (open === null) out.push(line);
4717
+ }
4718
+ return out.join("\n");
4719
+ }
4720
+ /** 抽出一份文本里所有可能的引用目标(未过滤外链,原样返回)。代码块内的写法不算。 */
4721
+ function extractRefs(raw) {
4722
+ const text = stripFences(raw);
3811
4723
  const out = [];
3812
4724
  for (const m of text.matchAll(MD_DEST_RE)) {
3813
4725
  const dest = destinationOf(m[1] ?? "");
@@ -3922,15 +4834,30 @@ function collectReferences(entryPath, maxDepth = 10) {
3922
4834
  //#region src/push.ts
3923
4835
  /** 可见性三档。link 档已废除:定向分享走读者池(jdu share --to)。 */
3924
4836
  var VISIBILITIES = ["private", "public"];
4837
+ function asLint(v) {
4838
+ if (v === null || typeof v !== "object") return null;
4839
+ const r = v;
4840
+ if (typeof r["code"] !== "string" || typeof r["message"] !== "string") return null;
4841
+ return {
4842
+ code: r["code"],
4843
+ lang: typeof r["lang"] === "string" ? r["lang"] : "",
4844
+ ...typeof r["line"] === "number" ? { line: r["line"] } : {},
4845
+ ...typeof r["blockId"] === "string" ? { blockId: r["blockId"] } : {},
4846
+ message: r["message"]
4847
+ };
4848
+ }
3925
4849
  function log(line) {
3926
4850
  process.stderr.write(`${line}\n`);
3927
4851
  }
3928
- /** 没给 --title 时用首个一级标题,再退化到文件名。 */
4852
+ /** 没给 --title 时用首个一级标题,其次 frontmatter 的 title / name(SKILL.md 形态,#122),再退化到文件名。 */
3929
4853
  function inferTitle(entryPath) {
3930
4854
  try {
3931
4855
  const text = readFileSync(entryPath, "utf8");
3932
4856
  const m = /^[ \t]{0,3}#[ \t]+(.+?)[ \t]*#*[ \t]*$/m.exec(text);
3933
4857
  if (m?.[1]) return m[1].trim();
4858
+ const fm = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text)?.[1];
4859
+ const t = fm ? /^(?:title|name)[ \t]*:[ \t]*(.+?)[ \t]*$/m.exec(fm)?.[1] : void 0;
4860
+ if (t) return /^(["']).*\1$/.test(t) ? t.slice(1, -1) : t;
3934
4861
  } catch {}
3935
4862
  return basename(entryPath, extname(entryPath));
3936
4863
  }
@@ -3977,13 +4904,19 @@ async function pushDoc(api, entryArg, opts) {
3977
4904
  const url = typeof res?.url === "string" && res.url !== "" ? res.url : id !== void 0 ? api.url(`/d/${id}`) : void 0;
3978
4905
  const warnings = Array.isArray(res?.warnings) ? res.warnings.map(String) : [];
3979
4906
  for (const w of warnings) log(`warn: ${w}`);
4907
+ const lint = Array.isArray(res?.lint) ? res.lint.map(asLint).filter((l) => l !== null) : [];
4908
+ for (const l of lint) {
4909
+ const where = l.line !== void 0 ? ` 第 ${l.line} 行` : l.blockId ? ` ${l.blockId}` : "";
4910
+ log(`lint: ${l.code} ${l.lang}${where} — ${l.message}`);
4911
+ }
3980
4912
  if (typeof res?.seq === "number") log(`已发布 ${id ?? ""} v${res.seq}`);
3981
4913
  if (url === void 0) throw new CliError("server 未返回文档 id 或 url,无法给出访问地址");
3982
4914
  return {
3983
4915
  id,
3984
4916
  seq: typeof res?.seq === "number" ? res.seq : void 0,
3985
4917
  url,
3986
- warnings
4918
+ warnings,
4919
+ lint
3987
4920
  };
3988
4921
  }
3989
4922
  //#endregion
@@ -4023,6 +4956,73 @@ function printTable(rows, columns) {
4023
4956
  * 模板原样发出去,怎么填是 agent 的事。agent 侧走 MCP 的 list_templates → read_doc → 按骨架写 → push_doc。
4024
4957
  */
4025
4958
  var TEMPLATE_TAG = "template";
4959
+ /**
4960
+ * 模板元数据(#115):写在模板里的一个 HTML 注释,渲染不可见,agent 侧拿它当 MCP prompt 的 name / description:
4961
+ *
4962
+ * <!-- jiandu-template
4963
+ * name: weekly
4964
+ * description: 每周五给团队同步进展。触发:周报、weekly
4965
+ * -->
4966
+ *
4967
+ * 没有这段也能用:name 退回文档 id,description 退回摘要。
4968
+ * 文首 YAML frontmatter 里的同名字段也认(k7 起内核不渲染 frontmatter,SKILL.md 可以原样发;#122),注释优先。
4969
+ *
4970
+ * `arguments:` 声明 prompt 参数(#121):`week(本周编号,如 2026-W36); owner?(负责人)`——分号分隔,`?` 表示可选,
4971
+ * 括号里是说明。prompts/get 时 `jdu mcp` 把正文里的 `{{week}}` 换成客户端给的值;server 仍不做任何替换。
4972
+ */
4973
+ var META_RE = /<!--\s*jiandu-template\s*\r?\n([\s\S]*?)-->[ \t]*\r?\n?/;
4974
+ /** 与 render 的 splitFrontmatter 同一条正则;cli 不为这一处引整个渲染包 */
4975
+ var FRONTMATTER_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
4976
+ /** prompt name 会拼进 `/mcp__jiandu__<name>` 这类客户端命令,只放行安全字符 */
4977
+ var NAME_RE$1 = /^[A-Za-z0-9_-]{1,64}$/;
4978
+ var ARG_RE = /^([A-Za-z0-9_-]{1,64})(\?)?\s*(?:[((](.*)[))])?$/;
4979
+ function parseArgs(raw) {
4980
+ return raw.split(/[;;]/).map((s) => s.trim()).filter(Boolean).flatMap((item) => {
4981
+ const m = ARG_RE.exec(item);
4982
+ if (!m?.[1]) return [];
4983
+ const desc = m[3]?.trim();
4984
+ return [{
4985
+ name: m[1],
4986
+ required: m[2] === void 0,
4987
+ ...desc ? { description: desc } : {}
4988
+ }];
4989
+ });
4990
+ }
4991
+ /** 一段 `key: value` 行(注释正文或 frontmatter)→ 填进 meta;先到先得,所以调用顺序就是优先级。 */
4992
+ function readFields(block, out) {
4993
+ for (const line of block.split("\n")) {
4994
+ const kv = /^\s*([a-z]+)\s*:\s*(.*?)\s*$/.exec(line);
4995
+ if (!kv?.[2]) continue;
4996
+ const v = /^(["']).*\1$/.test(kv[2]) ? kv[2].slice(1, -1) : kv[2];
4997
+ if (kv[1] === "name" && out.name === void 0 && NAME_RE$1.test(v)) out.name = v;
4998
+ else if (kv[1] === "description" && out.description === void 0) out.description = v;
4999
+ else if (kv[1] === "arguments" && out.arguments.length === 0) out.arguments = parseArgs(v);
5000
+ }
5001
+ }
5002
+ function parseTemplateMeta(md) {
5003
+ const out = {
5004
+ arguments: [],
5005
+ body: md
5006
+ };
5007
+ const fm = FRONTMATTER_RE.exec(md);
5008
+ if (fm) out.body = md.slice(fm[0].length);
5009
+ const m = META_RE.exec(out.body);
5010
+ if (m) {
5011
+ out.body = out.body.replace(m[0], "");
5012
+ readFields(m[1] ?? "", out);
5013
+ }
5014
+ if (fm) readFields(fm[1] ?? "", out);
5015
+ return out;
5016
+ }
5017
+ /** `{{name}}` → 值;没给的可选参数替换成空串。只替换声明过的参数,别的 `{{…}}` 原样留着。 */
5018
+ function fillTemplate(body, args, values) {
5019
+ let out = body;
5020
+ for (const a of args) {
5021
+ const v = typeof values[a.name] === "string" ? values[a.name] : "";
5022
+ out = out.replace(new RegExp(`\\{\\{\\s*${a.name}\\s*\\}\\}`, "g"), v);
5023
+ }
5024
+ return out;
5025
+ }
4026
5026
  var hasTemplateTag = (d) => Array.isArray(d.tags) && d.tags.map(String).includes("template");
4027
5027
  async function listTemplates(api, opts) {
4028
5028
  const [mine, official] = await Promise.all([api.getJson(`/api/docs${opts.all ? "?all=1" : ""}`), api.getJson("/api/official")]);
@@ -4048,6 +5048,10 @@ async function listTemplates(api, opts) {
4048
5048
  key: "title",
4049
5049
  header: "TITLE"
4050
5050
  },
5051
+ {
5052
+ key: "uses",
5053
+ header: "USES"
5054
+ },
4051
5055
  {
4052
5056
  key: "updatedAt",
4053
5057
  header: "UPDATED"
@@ -4069,6 +5073,7 @@ async function pullTemplate(api, id, opts) {
4069
5073
  * (push 的 tags 字段是整体覆盖语义,不先取回就会把别的标签抹掉)。
4070
5074
  */
4071
5075
  async function pushTemplate(api, file, opts) {
5076
+ if (!parseTemplateMeta(readFileSync(file, "utf8")).name) process.stderr.write("warn: 模板没有 <!-- jiandu-template name/description --> 元数据注释(或 frontmatter):agent 侧的 prompt 名会退回文档 id、描述退回摘要\n");
4072
5077
  let tags = [TEMPLATE_TAG];
4073
5078
  if (opts.id) {
4074
5079
  const cur = await api.getJson(`/api/docs/${encodeURIComponent(opts.id)}/tags`).catch(() => ({}));
@@ -4089,8 +5094,12 @@ async function pushTemplate(api, file, opts) {
4089
5094
  * 当可读可写的知识库用(#9 A 段)。凭据复用 ~/.config/jiandu/config.json,server 端零改动。
4090
5095
  *
4091
5096
  * ponytail: 不引 @modelcontextprotocol/sdk——它带 express / hono / zod 一整套,而这里只需要
4092
- * initialize / tools/list / tools/call 三个方法的 JSON-RPC 换行分帧。协议有变再换官方 SDK。
5097
+ * initialize / tools/* / prompts/* 几个方法的 JSON-RPC 换行分帧。协议有变再换官方 SDK。
4093
5098
  * stdout 是协议信道:本文件之外任何写 stdout 的代码都不能在 mcp 模式下被调到(pushDoc 已改为返回值)。
5099
+ *
5100
+ * 模板即 prompt(#115):打了 template 标签的文档同时暴露为 MCP prompts(Claude Code 里是 /mcp__jiandu__<name>),
5101
+ * name / description 来自模板里的 `<!-- jiandu-template -->` 注释。语法三层(#116):常驻 instructions 放最容易错的几条,
5102
+ * get_syntax 按需拿这台实例的完整规则(带 widget= 就是某个组件的完整 schema),push_doc 响应的 lint[] 事后指出会降级的 fence。
4094
5103
  */
4095
5104
  var SUPPORTED_PROTOCOLS = [
4096
5105
  "2025-06-18",
@@ -4099,13 +5108,41 @@ var SUPPORTED_PROTOCOLS = [
4099
5108
  ];
4100
5109
  var SERVER_INFO = {
4101
5110
  name: "jiandu",
4102
- version: "0.2.0"
5111
+ version: "0.6.0"
4103
5112
  };
4104
- var INSTRUCTIONS = "jiandu(简牍)是一个 Markdown 知识库。先用 search_docs / list_docs 找到文档 id,read_doc 拿 markdown 原文;改稿后用 push_doc 发布新版本(带 id 才是更新,不带是新建)。文档里的 widget fence(```widget:Name)保留原样。写新文档前先 list_templates 看有没有对应骨架(周报 / 技术方案…),有就 read_doc 取模板按节填,占位注释自己替换掉。";
5113
+ /**
5114
+ * 常驻开场白(#116 L0):只放**跨工具**的知识——各个工具自己做什么由它们的 description 讲,
5115
+ * 在这里复述一遍等于每轮对话都多付一份 token。留下的是顺序、写作规范,和 fence 那几条最容易错的。
5116
+ */
5117
+ var INSTRUCTIONS = "jiandu(简牍)是一个 Markdown 知识库,这些工具都是它 HTTP API 的薄封装。典型顺序:list_docs 找到 id → read_doc 拿原文 → 改稿 → push_doc 发新版本;写新文档前先 list_templates 看有没有现成骨架。写法:标准 markdown + GFM;一级标题就是文档标题,第一段会截成摘要。可交互组件写 ```widget:Name fence、正文是一个 JSON 对象入参:Name 区分大小写、必须是这台实例已注册的,写错不报错、只会静默变成普通代码块;JSON 字符串里的 markdown 要转义(换行 \\n、引号 \\\")。mermaid / diff / vega-lite 直接写 ```mermaid 这类 fence,正文是 DSL 不是 JSON。用扩展语法前先 get_syntax;push_doc 响应的 lint[] 非空就是有 fence 会降级,按提示改稿重推。已有的 widget fence 保留原样。";
5118
+ /** prompts/get 给模型的开场:模板正文前的一段固定说明,不在模板里另起一套「指令字段」。 */
5119
+ function promptText(t) {
5120
+ return `下面是文档模板「${t.title}」的骨架。按节填写,删掉所有 <!-- --> 占位注释,第一段写一句话摘要;写完用 push_doc 发布(默认 private,进团队传 team),并把链接给用户。模板里的说明文字不要带进正文。
5121
+
5122
+ ---
5123
+
5124
+ ` + t.body;
5125
+ }
4105
5126
  function str(v, name) {
4106
5127
  if (typeof v !== "string" || v.trim() === "") throw new CliError(`${name} 必填且为非空字符串`);
4107
5128
  return v.trim();
4108
5129
  }
5130
+ var SCOPES$1 = [
5131
+ "mine",
5132
+ "official",
5133
+ "shared",
5134
+ "all"
5135
+ ];
5136
+ function asScope(v) {
5137
+ if (typeof v === "string" && SCOPES$1.includes(v)) return v;
5138
+ throw new CliError(`scope 只能是 ${SCOPES$1.join(" / ")}:${String(v)}`);
5139
+ }
5140
+ function strList(v) {
5141
+ return Array.isArray(v) ? v.map(String).filter((s) => s.trim() !== "") : [];
5142
+ }
5143
+ function isRecord(v) {
5144
+ return v !== null && typeof v === "object" && !Array.isArray(v);
5145
+ }
4109
5146
  function asDoc(raw, scope, server) {
4110
5147
  if (raw === null || typeof raw !== "object") return null;
4111
5148
  const d = raw;
@@ -4117,8 +5154,10 @@ function asDoc(raw, scope, server) {
4117
5154
  visibility: typeof d["visibility"] === "string" ? d["visibility"] : scope === "official" ? "official" : "",
4118
5155
  tags: Array.isArray(d["tags"]) ? d["tags"].map(String) : [],
4119
5156
  scope,
5157
+ ...typeof d["team"] === "string" || d["team"] === null ? { team: d["team"] } : {},
4120
5158
  ...typeof d["archived"] === "boolean" ? { archived: d["archived"] } : {},
4121
5159
  ...typeof d["updatedAt"] === "number" ? { updatedAt: d["updatedAt"] } : {},
5160
+ ...typeof d["uses"] === "number" ? { uses: d["uses"] } : {},
4122
5161
  url: typeof d["url"] === "string" ? d["url"] : `${server}/d/${d["id"]}`
4123
5162
  };
4124
5163
  }
@@ -4137,152 +5176,345 @@ function buildTools(api) {
4137
5176
  const seen = new Set(mine.map((d) => d.id));
4138
5177
  return [...mine, ...[...official, ...shared].filter((d) => !seen.has(d.id) && seen.add(d.id))];
4139
5178
  };
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 生效:包含已归档"
5179
+ const loadTemplates = async () => {
5180
+ const [mine, shared, official] = await Promise.all([
5181
+ listOf("mine", false),
5182
+ listOf("shared", false),
5183
+ listOf("official", false)
5184
+ ]);
5185
+ const seen = /* @__PURE__ */ new Set();
5186
+ const docs = [
5187
+ ...mine,
5188
+ ...shared,
5189
+ ...official
5190
+ ].filter((d) => d.tags.includes("template") && !seen.has(d.id) && seen.add(d.id));
5191
+ const infos = await Promise.all(docs.map(async (d) => {
5192
+ const meta = parseTemplateMeta(await api.getText(`/d/${encodeURIComponent(d.id)}.md`));
5193
+ return {
5194
+ ...d,
5195
+ name: meta.name ?? d.id,
5196
+ description: meta.description ?? (d.excerpt || d.title),
5197
+ arguments: meta.arguments,
5198
+ body: meta.body
5199
+ };
5200
+ }));
5201
+ const names = /* @__PURE__ */ new Set();
5202
+ return infos.filter((t) => !names.has(t.name) && names.add(t.name));
5203
+ };
5204
+ return {
5205
+ tools: [
5206
+ {
5207
+ name: "list_docs",
5208
+ description: "列 / 搜文档,返回 id、标题、摘要、可见性、标签、URL。给 query 就按关键词过滤(标题 / 摘要 / 标签,空格分词、全部命中),不给就是纯列表。scope:mine(我发布的)/ official(官方知识库)/ shared(分享给我的 + 我所属团队的)/ all;不传时——搜(有 query)默认 all、列(无 query)默认 mine。没有全文检索,正文搜不到——要读正文用 read_doc。",
5209
+ inputSchema: {
5210
+ type: "object",
5211
+ properties: {
5212
+ query: {
5213
+ type: "string",
5214
+ description: "关键词,空格分词、全部命中;不给就是纯列表"
5215
+ },
5216
+ scope: {
5217
+ type: "string",
5218
+ enum: [...SCOPES$1]
5219
+ },
5220
+ limit: {
5221
+ type: "integer",
5222
+ minimum: 1,
5223
+ maximum: 200,
5224
+ description: "最多几条;不给时搜默认 20 条、纯列表不截断"
5225
+ },
5226
+ includeArchived: {
5227
+ type: "boolean",
5228
+ default: false,
5229
+ description: "仅 mine 生效:包含已归档"
5230
+ }
5231
+ }
5232
+ },
5233
+ run: async (args) => {
5234
+ const terms = (typeof args["query"] === "string" ? args["query"].trim() : "").toLowerCase().split(/\s+/).filter(Boolean);
5235
+ const scope = args["scope"] === void 0 ? terms.length === 0 ? "mine" : "all" : asScope(args["scope"]);
5236
+ const includeArchived = args["includeArchived"] === true;
5237
+ const docs = scope === "all" ? await listAll(includeArchived) : await listOf(scope, includeArchived);
5238
+ if (terms.length === 0) {
5239
+ const cap = Number.isInteger(args["limit"]) ? Math.max(1, Math.min(200, args["limit"])) : docs.length;
5240
+ return JSON.stringify(docs.slice(0, cap), null, 2);
4161
5241
  }
5242
+ const hits = docs.filter((d) => {
5243
+ const hay = `${d.title}\n${d.excerpt}\n${d.tags.join(" ")}`.toLowerCase();
5244
+ return terms.every((t) => hay.includes(t));
5245
+ });
5246
+ const limit = Number.isInteger(args["limit"]) ? Math.max(1, Math.min(200, args["limit"])) : 20;
5247
+ return JSON.stringify(hits.slice(0, limit), null, 2);
4162
5248
  }
4163
5249
  },
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
5250
+ {
5251
+ name: "read_doc",
5252
+ description: "读文档的 markdown 原文(本地引用已改写为 /blob/<hash>)。不传 version 读最新版。",
5253
+ inputSchema: {
5254
+ type: "object",
5255
+ required: ["id"],
5256
+ properties: {
5257
+ id: { type: "string" },
5258
+ version: {
5259
+ type: "integer",
5260
+ minimum: 1,
5261
+ description: "版本号 seq,见 list_versions"
5262
+ }
4186
5263
  }
5264
+ },
5265
+ run: async (args) => {
5266
+ const id = encodeURIComponent(str(args["id"], "id"));
5267
+ const version = args["version"];
5268
+ if (version !== void 0 && !Number.isInteger(version)) throw new CliError("version 必须是整数");
5269
+ return api.getText(version === void 0 ? `/d/${id}.md` : `/d/${id}/v/${String(version)}.md`);
4187
5270
  }
4188
5271
  },
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"
5272
+ {
5273
+ name: "list_templates",
5274
+ description: "列出文档模板(打了 template 标签的文档:我的 + 我所属团队的 + 官方)。每条带 name(同时是 MCP prompt 名)和 description(什么场景用)。写新文档前先看这里;选中后 read_doc 取原文,按骨架填内容、删掉 <!-- --> 占位注释,再 push_doc。server 不做任何变量替换。",
5275
+ inputSchema: {
5276
+ type: "object",
5277
+ properties: {}
5278
+ },
5279
+ run: async () => {
5280
+ const out = (await loadTemplates()).map(({ body: _body, ...rest }) => rest);
5281
+ return JSON.stringify(out, null, 2);
5282
+ }
5283
+ },
5284
+ {
5285
+ name: "get_syntax",
5286
+ description: "取这台实例的写作语法(markdown):内核语法、widget fence 与多态 CodeBlock(mermaid / diff / vega-lite…)的规则、已注册 widget 的清单与字段摘要。写含 fence / 组件 / 图表的文档前先调一次(不带参数)。要某几个组件的完整入参就带 widget=名字(多个用逗号分隔),拿到 dataSchema(JSON Schema)、sampleData 和可直接粘进文档的 fence 示例;标了 contentMediaType: text/markdown 的字符串字段可以写 markdown。",
5287
+ inputSchema: {
5288
+ type: "object",
5289
+ properties: { widget: {
5290
+ type: "string",
5291
+ description: "组件名,区分大小写;多个用逗号分隔。不给就返回整份语法说明"
5292
+ } }
5293
+ },
5294
+ run: async (args) => {
5295
+ const want = typeof args["widget"] === "string" ? args["widget"].split(",").map((s) => s.trim()).filter(Boolean) : [];
5296
+ if (want.length === 0) return api.getText("/api/syntax");
5297
+ const res = await api.getJson("/api/widgets");
5298
+ const out = [];
5299
+ const active = [];
5300
+ for (const raw of Array.isArray(res?.widgets) ? res.widgets : []) {
5301
+ if (raw === null || typeof raw !== "object") continue;
5302
+ const w = raw;
5303
+ if (w["status"] !== "active" || typeof w["name"] !== "string") continue;
5304
+ active.push(w["name"]);
5305
+ if (!want.includes(w["name"])) continue;
5306
+ out.push({
5307
+ name: w["name"],
5308
+ scope: typeof w["scope"] === "string" ? w["scope"] : "",
5309
+ version: typeof w["version"] === "string" ? w["version"] : "",
5310
+ description: typeof w["description"] === "string" ? w["description"] : null,
5311
+ dataSchema: w["dataSchema"] ?? null,
5312
+ sampleData: w["sampleData"] ?? null,
5313
+ fence: `\`\`\`widget:${w["name"]}\n${JSON.stringify(w["sampleData"] ?? {}, null, 2)}\n\`\`\``
5314
+ });
4211
5315
  }
5316
+ const missing = want.filter((n) => !out.some((w) => w.name === n));
5317
+ if (missing.length > 0) throw new CliError(`这台实例没有注册:${missing.join(" / ")}。可用的是:${active.join(" / ")}`);
5318
+ return JSON.stringify(out, null, 2);
4212
5319
  }
4213
5320
  },
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: {}
5321
+ {
5322
+ name: "list_versions",
5323
+ description: "列出文档全部版本(seq、发布时间、源文 hash)。只追加不覆盖,回滚也是新版本。",
5324
+ inputSchema: {
5325
+ type: "object",
5326
+ required: ["id"],
5327
+ properties: { id: { type: "string" } }
5328
+ },
5329
+ run: async (args) => {
5330
+ const res = await api.getJson(`/api/docs/${encodeURIComponent(str(args["id"], "id"))}/versions`);
5331
+ return JSON.stringify(res, null, 2);
5332
+ }
4227
5333
  },
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" } }
5334
+ {
5335
+ name: "push_doc",
5336
+ description: "发布 markdown。正文二选一:path(本机入口 .md 的绝对路径,会递归收集图片 / 嵌套 md 等本地引用一起上传)或 content(markdown 字串,没有本地引用可收,标题取一级标题、否则传 title)。带 id 是给已有文档发新版本(标题 / 可见性 / 标签 / 团队不传则保持原值);不带 id 新建,默认 private。响应里的 lint[] 列出会静默降级成普通代码块的 fence(unknown-widget / invalid-json / invalid-data,带行号或 blockId)——发布已成功,但非空就该按提示改稿再 push_doc 一次。",
5337
+ inputSchema: {
5338
+ type: "object",
5339
+ properties: {
5340
+ path: {
5341
+ type: "string",
5342
+ description: "入口 .md 的绝对路径;与 content 二选一"
5343
+ },
5344
+ content: {
5345
+ type: "string",
5346
+ description: "markdown 正文;与 path 二选一"
5347
+ },
5348
+ id: {
5349
+ type: "string",
5350
+ description: "要更新的文档 id"
5351
+ },
5352
+ title: { type: "string" },
5353
+ visibility: {
5354
+ type: "string",
5355
+ enum: [...VISIBILITIES]
5356
+ },
5357
+ tags: {
5358
+ type: "array",
5359
+ items: { type: "string" },
5360
+ description: "整体覆盖标签;不传则不动"
5361
+ },
5362
+ team: {
5363
+ type: "string",
5364
+ description: "进团队的团队 id(需是成员);空串移出团队;不传则不动"
5365
+ }
5366
+ }
5367
+ },
5368
+ run: async (args) => {
5369
+ const content = typeof args["content"] === "string" ? args["content"] : void 0;
5370
+ const hasPath = typeof args["path"] === "string" && args["path"].trim() !== "";
5371
+ if (content === void 0 === !hasPath) throw new CliError("path 与 content 必须且只能传一个");
5372
+ const tmp = content !== void 0 ? mkdtempSync(join(tmpdir(), "jdu-mcp-")) : null;
5373
+ const path = tmp ? join(tmp, "doc.md") : str(args["path"], "path");
5374
+ if (tmp && content !== void 0) writeFileSync(path, content, "utf8");
5375
+ const tags = Array.isArray(args["tags"]) ? args["tags"].map(String) : void 0;
5376
+ try {
5377
+ const res = await pushDoc(api, path, {
5378
+ id: typeof args["id"] === "string" ? args["id"] : void 0,
5379
+ title: typeof args["title"] === "string" ? args["title"] : void 0,
5380
+ visibility: typeof args["visibility"] === "string" ? args["visibility"] : void 0,
5381
+ tag: tags,
5382
+ team: typeof args["team"] === "string" ? args["team"] : void 0
5383
+ });
5384
+ return JSON.stringify(res, null, 2);
5385
+ } finally {
5386
+ if (tmp) rmSync(tmp, {
5387
+ recursive: true,
5388
+ force: true
5389
+ });
5390
+ }
5391
+ }
4240
5392
  },
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: "整体覆盖标签;不传则不动"
5393
+ {
5394
+ name: "share_doc",
5395
+ description: "改文档的访问范围,不用重推正文:visibility(private / public)、team(团队 id;空串移出团队)、addReaders / removeReaders(定向读者的身份串,如邮箱 / 用户名)。私有文档谁能读 = 作者 ∪ 指定读者 ∪ 所选团队成员。",
5396
+ inputSchema: {
5397
+ type: "object",
5398
+ required: ["id"],
5399
+ properties: {
5400
+ id: { type: "string" },
5401
+ visibility: {
5402
+ type: "string",
5403
+ enum: [...VISIBILITIES]
5404
+ },
5405
+ team: {
5406
+ type: "string",
5407
+ description: "团队 id;空串移出团队"
5408
+ },
5409
+ addReaders: {
5410
+ type: "array",
5411
+ items: { type: "string" }
5412
+ },
5413
+ removeReaders: {
5414
+ type: "array",
5415
+ items: { type: "string" }
5416
+ }
4270
5417
  }
5418
+ },
5419
+ run: async (args) => {
5420
+ const id = encodeURIComponent(str(args["id"], "id"));
5421
+ const access = {};
5422
+ if (typeof args["visibility"] === "string") access["visibility"] = args["visibility"];
5423
+ if (typeof args["team"] === "string") access["team"] = args["team"];
5424
+ const add = strList(args["addReaders"]);
5425
+ const remove = strList(args["removeReaders"]);
5426
+ if (Object.keys(access).length === 0 && add.length === 0 && remove.length === 0) throw new CliError("share_doc 需要 visibility / team / addReaders / removeReaders 之一");
5427
+ const out = { id: args["id"] };
5428
+ if (Object.keys(access).length > 0) Object.assign(out, await api.putJson(`/api/docs/${id}/visibility`, access));
5429
+ if (add.length > 0 || remove.length > 0) Object.assign(out, await api.putJson(`/api/docs/${id}/readers`, {
5430
+ add,
5431
+ remove
5432
+ }));
5433
+ return JSON.stringify(out, null, 2);
4271
5434
  }
4272
5435
  },
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);
5436
+ {
5437
+ name: "list_comments",
5438
+ description: "列出文档的评论线程(读者在阅读页划词 / 块把手 / 文末评论区留下的)。默认只列未处理的;每条带 blockId(对应正文哪一块,null = 评论整篇文档)、selection.quote 引文、正文、attachments(图片附件的 sha256,取图 GET /blob/<sha256>)、作者、replies。处理流程:按 blockId 找到段落改稿 → push_doc 新版本 → reply_comment 回一句并 resolved=true 关掉。",
5439
+ inputSchema: {
5440
+ type: "object",
5441
+ required: ["id"],
5442
+ properties: {
5443
+ id: { type: "string" },
5444
+ includeResolved: {
5445
+ type: "boolean",
5446
+ default: false
5447
+ }
5448
+ }
5449
+ },
5450
+ run: async (args) => {
5451
+ const id = encodeURIComponent(str(args["id"], "id"));
5452
+ const res = await api.getJson(`/api/docs/${id}/comments`);
5453
+ const all = (Array.isArray(res?.comments) ? res.comments : []).filter(isRecord);
5454
+ const includeResolved = args["includeResolved"] === true;
5455
+ const threads = all.filter((c) => c["parentId"] === null && (includeResolved || c["resolved"] !== true)).map((root) => ({
5456
+ ...root,
5457
+ replies: all.filter((c) => c["parentId"] === root["id"])
5458
+ }));
5459
+ return JSON.stringify(threads, null, 2);
5460
+ }
5461
+ },
5462
+ {
5463
+ name: "reply_comment",
5464
+ description: "回复评论线程并/或改它的处理状态。body 回一句(以 agent 身份发出,authorType=ai,读者页里会标出来);resolved=true 标记已处理、false 重新打开(仅文档 owner)。改完稿发了新版本后,通常一次调用把两件事一起做掉。",
5465
+ inputSchema: {
5466
+ type: "object",
5467
+ required: ["threadId"],
5468
+ properties: {
5469
+ threadId: { type: "string" },
5470
+ body: {
5471
+ type: "string",
5472
+ description: "回复正文;只想改状态就不传"
5473
+ },
5474
+ resolved: {
5475
+ type: "boolean",
5476
+ description: "true 关掉线程、false 重新打开;不传就只回复不改状态"
5477
+ }
5478
+ }
5479
+ },
5480
+ run: async (args) => {
5481
+ const tid = encodeURIComponent(str(args["threadId"], "threadId"));
5482
+ const body = typeof args["body"] === "string" && args["body"].trim() !== "" ? args["body"].trim() : null;
5483
+ const resolved = typeof args["resolved"] === "boolean" ? args["resolved"] : null;
5484
+ if (body === null && resolved === null) throw new CliError("reply_comment 需要 body(回复)或 resolved(改状态)之一");
5485
+ const out = { threadId: args["threadId"] };
5486
+ if (body !== null) out["reply"] = await api.postJson(`/api/comments/${tid}/reply`, {
5487
+ body,
5488
+ authorType: "ai"
5489
+ });
5490
+ if (resolved !== null) out["resolve"] = await api.postJson(`/api/comments/${tid}/resolve`, { resolved });
5491
+ return JSON.stringify(out, null, 2);
5492
+ }
5493
+ },
5494
+ {
5495
+ name: "archive_doc",
5496
+ description: "归档文档:从列表撤下、读者打开 404(owner 自己仍可读),版本与评论都保留;undo=true 取消归档。这就是「撤回发布」——没有比它更重的删除给 agent 用。",
5497
+ inputSchema: {
5498
+ type: "object",
5499
+ required: ["id"],
5500
+ properties: {
5501
+ id: { type: "string" },
5502
+ undo: {
5503
+ type: "boolean",
5504
+ default: false,
5505
+ description: "取消归档"
5506
+ }
5507
+ }
5508
+ },
5509
+ run: async (args) => {
5510
+ const id = encodeURIComponent(str(args["id"], "id"));
5511
+ const res = await api.postJson(`/api/docs/${id}/archive`, { archived: args["undo"] !== true });
5512
+ return JSON.stringify(res, null, 2);
5513
+ }
4283
5514
  }
4284
- }
4285
- ];
5515
+ ],
5516
+ loadTemplates
5517
+ };
4286
5518
  }
4287
5519
  function write(msg) {
4288
5520
  process.stdout.write(`${JSON.stringify(msg)}\n`);
@@ -4298,7 +5530,7 @@ function rpcError(id, code, message) {
4298
5530
  });
4299
5531
  }
4300
5532
  async function runMcp(api) {
4301
- const tools = buildTools(api);
5533
+ const { tools, loadTemplates } = buildTools(api);
4302
5534
  const byName = new Map(tools.map((t) => [t.name, t]));
4303
5535
  const handle = async (req) => {
4304
5536
  const { id, method } = req;
@@ -4311,18 +5543,81 @@ async function runMcp(api) {
4311
5543
  switch (method) {
4312
5544
  case "initialize": {
4313
5545
  const asked = typeof params["protocolVersion"] === "string" ? params["protocolVersion"] : "";
5546
+ const protocolVersion = SUPPORTED_PROTOCOLS.includes(asked) ? asked : SUPPORTED_PROTOCOLS[0];
5547
+ let instructions = INSTRUCTIONS;
5548
+ try {
5549
+ const templates = await loadTemplates();
5550
+ if (templates.length > 0) instructions += `可用模板(同名 prompt 可直接用):${templates.map((t) => `${t.name}——${t.description}`).join(";")}。`;
5551
+ } catch {}
4314
5552
  write({
4315
5553
  jsonrpc: "2.0",
4316
5554
  id,
4317
5555
  result: {
4318
- protocolVersion: SUPPORTED_PROTOCOLS.includes(asked) ? asked : SUPPORTED_PROTOCOLS[0],
4319
- capabilities: { tools: {} },
5556
+ protocolVersion,
5557
+ capabilities: {
5558
+ tools: {},
5559
+ prompts: {}
5560
+ },
4320
5561
  serverInfo: SERVER_INFO,
4321
- instructions: INSTRUCTIONS
5562
+ instructions
4322
5563
  }
4323
5564
  });
4324
5565
  return;
4325
5566
  }
5567
+ case "prompts/list":
5568
+ try {
5569
+ write({
5570
+ jsonrpc: "2.0",
5571
+ id,
5572
+ result: { prompts: (await loadTemplates()).map((t) => ({
5573
+ name: t.name,
5574
+ title: t.title,
5575
+ description: t.description,
5576
+ ...t.arguments.length > 0 ? { arguments: t.arguments } : {}
5577
+ })) }
5578
+ });
5579
+ } catch (err) {
5580
+ rpcError(id, -32603, `模板列表取不到:${err instanceof CliError ? err.message : String(err)}`);
5581
+ }
5582
+ return;
5583
+ case "prompts/get": {
5584
+ const name = typeof params["name"] === "string" ? params["name"] : "";
5585
+ try {
5586
+ const t = (await loadTemplates()).find((x) => x.name === name);
5587
+ if (!t) {
5588
+ rpcError(id, -32602, `未知 prompt:${name}`);
5589
+ return;
5590
+ }
5591
+ const given = isRecord(params["arguments"]) ? params["arguments"] : {};
5592
+ const missing = t.arguments.filter((a) => a.required && (typeof given[a.name] !== "string" || given[a.name] === ""));
5593
+ if (missing.length > 0) {
5594
+ rpcError(id, -32602, `缺少必填参数:${missing.map((a) => a.name).join(", ")}`);
5595
+ return;
5596
+ }
5597
+ api.postJson(`/api/docs/${encodeURIComponent(t.id)}/used`, {}).catch(() => void 0);
5598
+ const body = t.arguments.length > 0 ? fillTemplate(t.body, t.arguments, given) : t.body;
5599
+ write({
5600
+ jsonrpc: "2.0",
5601
+ id,
5602
+ result: {
5603
+ description: t.description,
5604
+ messages: [{
5605
+ role: "user",
5606
+ content: {
5607
+ type: "text",
5608
+ text: promptText({
5609
+ ...t,
5610
+ body
5611
+ })
5612
+ }
5613
+ }]
5614
+ }
5615
+ });
5616
+ } catch (err) {
5617
+ rpcError(id, -32603, `模板取不到:${err instanceof CliError ? err.message : String(err)}`);
5618
+ }
5619
+ return;
5620
+ }
4326
5621
  case "ping":
4327
5622
  write({
4328
5623
  jsonrpc: "2.0",
@@ -4427,6 +5722,7 @@ async function readWidgetJson(dir) {
4427
5722
  name,
4428
5723
  scope,
4429
5724
  version,
5725
+ ...typeof obj.description === "string" && obj.description.trim() !== "" ? { description: obj.description.trim() } : {},
4430
5726
  dataSchema: obj.dataSchema ?? null,
4431
5727
  sampleData: obj.sampleData ?? null
4432
5728
  };
@@ -4521,6 +5817,9 @@ async function pushWidget(api, dirArg) {
4521
5817
  process.stdout.write(`${meta.scope}/${meta.name}@${meta.version}${src}${where}\n`);
4522
5818
  }
4523
5819
  //#endregion
5820
+ //#region ../docs/design-system/tokens.css?raw
5821
+ var tokens_default = "/* ============================================================\n 简牍 Jiandu · Design Tokens (v3 · 2026-09)\n 这是设计系统唯一的取值来源。规则、对比度依据、组件规格见同目录 README.md。\n\n - 双主题 = 同一套语义 token 换值;结构、间距、字号、圆角逐像素一致。\n - 主题真源是 <html class=\"dark\">(跨宿主契约:widget、dsh 都读它);\n 暗色块同时匹配 [data-t=\"dark\"],供设计画布与局部反色容器使用。\n 组件代码里不要写 .dark / [data-t] / prefers-color-scheme 分支,只走 token。\n - accent 家族用 oklch 定义(同 L 同 C 只变 hue),注释里是 oklch,值是出码 hex。\n - 每个文字色都在它实际会落到的最深一档表面(--s-4)上过 WCAG AA 4.5:1。\n 改任何一个值之前先重跑对比度(见 README「校验」)。\n ============================================================ */\n\n/* ---------- 与主题无关的量纲 ---------- */\n:root {\n --f-sans: \"Instrument Sans\", -apple-system, BlinkMacSystemFont, \"Segoe UI\",\n \"PingFang SC\", \"HarmonyOS Sans SC\", \"Noto Sans CJK SC\", \"Microsoft YaHei\", sans-serif;\n --f-serif: \"Instrument Serif\", \"Iowan Old Style\", Palatino, Georgia, serif;\n --f-mono: \"JetBrains Mono\", ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, monospace;\n\n /* 字号阶 · 1.20 主比例,UI 段加密到约 1.07 以保住 13/14/15 三档 */\n --t-micro: 11px; --t-cap: 12px; --t-sm: 13px; --t-ui: 14px;\n --t-body: 15px; --t-lg: 16px; --t-h5: 18px; --t-h4: 20px;\n --t-h3: 24px; --t-h2: 30px; --t-h1: 38px;\n --t-d3: 48px; --t-d2: 60px; --t-d1: 76px;\n\n --lh-tight: 1.05; --lh-head: 1.25; --lh-ui: 1.4; --lh-body: 1.65; --lh-loose: 1.8;\n\n /* 间距阶 · 4 基准,2/6 只用于图标与描边补偿 */\n --s1: 2px; --s2: 4px; --s3: 6px; --s4: 8px; --s5: 12px; --s6: 16px;\n --s7: 20px; --s8: 24px; --s9: 32px; --s10: 40px; --s11: 48px;\n --s12: 64px; --s13: 80px; --s14: 96px; --s15: 128px;\n\n /* 圆角 · 四档,同心圆角规则:外层 = 内层 + padding */\n --r-xs: 4px; --r-sm: 6px; --r-md: 10px; --r-lg: 14px; --r-full: 999px;\n\n --topbar-h: 56px;\n --sidebar-w: 264px;\n --w-content: 1160px;\n --w-reader: 1600px;\n --w-prose: clamp(46rem, 60vw, 60rem);\n --w-marketing: 1200px;\n\n --dur: 140ms;\n --ease: cubic-bezier(.32, .72, 0, 1);\n\n /* 实心按钮顶部那道高光。两套主题同值(它压在 accent / danger 填充上,不压在页面底色上)。 */\n --gloss: inset 0 1px 0 rgba(255, 255, 255, .14);\n /* 对话框背后的遮罩。两套主题同值:亮色下也要压暗,不然弹层浮不起来。 */\n --scrim: rgba(9, 10, 12, .44);\n}\n\n/* ---------- 亮色 ---------- */\n:root, [data-t=\"light\"] {\n color-scheme: light;\n --bg: #FBFBFC;\n --s-1: #FFFFFF;\n --s-2: #F5F6F8;\n --s-3: #EDEFF2;\n --s-4: #E3E6EA;\n --line: #E4E7EB;\n --line-strong: #D6DAE0;\n --line-loud: #C3C9D1;\n --hairline: transparent;\n\n --fg: #101317;\n --fg-muted: #565E68;\n --fg-subtle: #606871;\n\n --accent: #057558;\n --accent-hover: #065E47;\n --accent-quiet: #E3F7EE;\n --accent-ring: #B5DFCE;\n --on-accent: #FFFFFF;\n --on-danger: #FFFFFF;\n\n --amber: #825B0C;\n --amber-quiet: #FAF0E0;\n --danger: #C02B2F;\n --danger-hover: #A21921;\n --danger-quiet: #FFEBE9;\n --danger-ring: #F4C8C4;\n --success: #1B763A;\n --success-quiet: #E6F6E9;\n --warn: #885716;\n --warn-quiet: #FAF0E0;\n\n /* 输入框的内凹。向内凹是「可输入」的信号(按钮向外抬),组件里只引这个 token,\n 不写 .dark 分支 —— 暗色那份在下面的暗色块里换值。 */\n --inset-field: inset 0 1px 2px rgba(16,19,23,.04);\n /* 弹层表面:亮色用卡片色 + 控件描边;暗色整体抬一档(见暗色块) */\n --pop-bg: #FFFFFF;\n --pop-line: #D6DAE0;\n\n /* 头像色相散列。**唯一绕开「竹青只表示可交互」的地方**:它区分人,不表达状态。\n 放进 token 而不是留在组件里,是为了让组件代码保持零 hex、零 .dark 分支。 */\n --av-1-bg: #E3F7EE; --av-1-fg: #045A43;\n --av-2-bg: #E5EFFC; --av-2-fg: #1B4C8F;\n --av-3-bg: #FAF0E0; --av-3-fg: #6D4B08;\n --av-4-bg: #F3E9FB; --av-4-fg: #5A2B8C;\n\n --sh-1: 0 1px 2px rgba(16,19,23,.06), 0 1px 1px rgba(16,19,23,.04);\n --sh-2: 0 4px 12px -2px rgba(16,19,23,.08), 0 2px 4px -1px rgba(16,19,23,.05);\n --sh-3: 0 16px 32px -8px rgba(16,19,23,.14), 0 4px 8px -2px rgba(16,19,23,.06);\n --press: inset 0 2px 4px rgba(16,19,23,.10);\n\n --code-key: #A2145E; --code-str: #0A5B45; --code-num: #1F4FA8;\n --code-com: #7A828C; --code-fn: #6B3FBF; --code-tag: #0F6B36;\n\n /* 图表系列色(Chart widget)。六档固定顺序按槽位分配、不循环,顺序本身就是色盲安全机制:\n 相邻两档在 protan / deutan 模拟下 ΔE ≥ 8.5,全部对表面 ≥ 3:1(dataviz 校验器过的,改值先重跑)。\n 刻意不用 --accent:竹青只表示可交互,数据不是控件。第一档蓝、第二档琥珀(与 --amber 同族但抬亮到标记档)。 */\n --chart-1: #3B6FD4; --chart-2: #A6720C; --chart-3: #1A8F7A;\n --chart-4: #7C55D9; --chart-5: #D2447F; --chart-6: #5E8C2E;\n}\n\n/* ---------- 暗色 ---------- */\n.dark, [data-t=\"dark\"] {\n color-scheme: dark;\n --bg: #090A0C;\n --s-1: #121519;\n --s-2: #1A1E23;\n --s-3: #23282E;\n --s-4: #2D333A;\n --line: #242830;\n --line-strong: #2E333B;\n --line-loud: #3A4048;\n /* 暗色里阴影几乎不可见,层次靠这道顶部高光边 */\n --hairline: rgba(255,255,255,.055);\n\n --fg: #F5F6F8;\n --fg-muted: #B4BBC4;\n --fg-subtle: #939BA5;\n\n --accent: #48C49C;\n --accent-hover: #6DD9B3;\n --accent-quiet: #143B2E;\n --accent-ring: #256550;\n --on-accent: #05130F;\n --on-danger: #200A08;\n\n --amber: #D6A044;\n --amber-quiet: #412F13;\n --danger: #FE7871;\n --danger-hover: #FF8B83;\n --danger-quiet: #492826;\n --danger-ring: #7D4743;\n --success: #68CA80;\n --success-quiet: #1F3A25;\n --warn: #F7AC55;\n --warn-quiet: #412F13;\n\n /* 暗色里 .04 的内凹完全看不见,要加深到 .35 才成立 */\n --inset-field: inset 0 1px 2px rgba(0,0,0,.35);\n --pop-bg: #1A1E23;\n --pop-line: #3A4048;\n\n --av-1-bg: #143B2E; --av-1-fg: #7FDCC0;\n --av-2-bg: #15294A; --av-2-fg: #9CC3F5;\n --av-3-bg: #412F13; --av-3-fg: #E0B96F;\n --av-4-bg: #2E2043; --av-4-fg: #C4A6FF;\n\n --sh-1: 0 1px 2px rgba(0,0,0,.5);\n --sh-2: 0 4px 14px -2px rgba(0,0,0,.6), 0 1px 3px rgba(0,0,0,.4);\n --sh-3: 0 20px 40px -10px rgba(0,0,0,.7), 0 4px 10px rgba(0,0,0,.45);\n --press: inset 0 2px 4px rgba(0,0,0,.34);\n\n --code-key: #FF9CB4; --code-str: #7FDCC0; --code-num: #8FBCFF;\n --code-com: #7C858F; --code-fn: #C4A6FF; --code-tag: #7EE0A0;\n\n /* 同六个色相为暗表面重新取档(OKLCH L 0.48–0.67),不是亮色的直接反转 */\n --chart-1: #5A8BE0; --chart-2: #B8842A; --chart-3: #2FA48C;\n --chart-4: #9878E8; --chart-5: #D45A8E; --chart-6: #7DA34A;\n}\n\n/* ============================================================\n 旧 token 名的别名(过渡层,下个大版本删)\n\n 现在只剩正文排版(viewer/src/reader.css,自 base.css 原样迁入)还在读它们。\n official widget 已全部改走 theme.css 的语义 utility(bg-card / text-muted-foreground …),\n `jdu widget create` 的模板同步换成短名;这里把旧名指向 v3 的新值,reader.css 一行不改就跟着换色板。\n\n 新代码只用短名。改动这一段前先 grep 一遍 `--jiandu-`,确认没人还在读它。\n ============================================================ */\n:root, [data-t=\"light\"], .dark, [data-t=\"dark\"] {\n --jiandu-font-sans: var(--f-sans);\n --jiandu-font-mono: var(--f-mono);\n\n --jiandu-bg: var(--bg);\n --jiandu-bg-subtle: var(--s-2);\n --jiandu-bg-code: var(--s-2);\n --jiandu-bg-inline-code: var(--s-3);\n --jiandu-fg: var(--fg);\n --jiandu-fg-muted: var(--fg-muted);\n --jiandu-fg-subtle: var(--fg-subtle);\n --jiandu-border: var(--line-strong);\n --jiandu-border-muted: var(--line);\n\n --jiandu-link: var(--accent);\n --jiandu-link-hover: var(--accent-hover);\n --jiandu-accent: var(--accent);\n --jiandu-accent-subtle: var(--accent-quiet);\n --jiandu-info: var(--accent);\n --jiandu-info-subtle: var(--accent-quiet);\n --jiandu-success: var(--success);\n --jiandu-success-subtle: var(--success-quiet);\n --jiandu-warning: var(--warn);\n --jiandu-warning-subtle: var(--warn-quiet);\n --jiandu-danger: var(--danger);\n --jiandu-danger-subtle: var(--danger-quiet);\n --jiandu-selection: var(--accent-quiet);\n\n --jiandu-skeleton-base: var(--s-2);\n --jiandu-skeleton-shine: var(--s-3);\n --jiandu-radius: var(--r-md);\n --jiandu-radius-sm: var(--r-sm);\n /* 阅读页正文随视口流体扩展,中文正文保持约 44–58 字/行的可读范围。 */\n --jiandu-doc-width: var(--w-prose);\n\n /* 代码高亮:v2 分了 12 档,v3 的 --code-* 只有 6 档,按语义就近合并 */\n --jiandu-hl-keyword: var(--code-key);\n --jiandu-hl-entity: var(--code-fn);\n --jiandu-hl-string: var(--code-str);\n --jiandu-hl-comment: var(--code-com);\n --jiandu-hl-constant: var(--code-num);\n --jiandu-hl-section: var(--code-num);\n --jiandu-hl-variable: var(--amber);\n --jiandu-hl-tag: var(--code-tag);\n --jiandu-hl-addition-fg: var(--success);\n --jiandu-hl-addition-bg: var(--success-quiet);\n --jiandu-hl-deletion-fg: var(--danger);\n --jiandu-hl-deletion-bg: var(--danger-quiet);\n}\n";
5822
+ //#endregion
4524
5823
  //#region src/widget-dev.ts
4525
5824
  var ARTIFACTS = [
4526
5825
  "index.js",
@@ -4563,19 +5862,28 @@ function readMeta(dir, dist) {
4563
5862
  version: ""
4564
5863
  };
4565
5864
  }
4566
- /** 线上 base.css 的地址:抓首页 HTML 里的 `/_v/base.<hash>.css`。拿不到返回 null,页面用兜底样式。 */
5865
+ /**
5866
+ * 线上正文样式表的地址:读 `/_v/manifest.json`(viewer 的产物清单,本来就在那条静态路由下)。
5867
+ * 早先是抓首页 HTML 里的 `/_v/base.<hash>.css`,但站点页换成 React 直出后首页只链 app.css,
5868
+ * 而 widget 预览要的是**正文**那套排版与 token。清单里按名字取,页面怎么改都不影响。
5869
+ * 拿不到返回 null,预览页用内置兜底样式。
5870
+ */
4567
5871
  async function siteBaseCss(server) {
4568
5872
  if (!server) return null;
4569
5873
  try {
4570
5874
  const ctrl = new AbortController();
4571
5875
  const t = setTimeout(() => ctrl.abort(), 2e3);
4572
- const html = await (await fetch(`${server}/`, {
5876
+ const res = await fetch(`${server}/_v/manifest.json`, {
4573
5877
  signal: ctrl.signal,
4574
5878
  redirect: "follow"
4575
- })).text();
5879
+ });
4576
5880
  clearTimeout(t);
4577
- const m = /\/_v\/base\.[a-f0-9]+\.css/.exec(html);
4578
- return m ? `${server}${m[0]}` : null;
5881
+ if (!res.ok) return null;
5882
+ const manifest = await res.json();
5883
+ if (typeof manifest !== "object" || manifest === null) return null;
5884
+ const m = manifest;
5885
+ const file = m["reader.css"] ?? m["app.css"];
5886
+ return typeof file === "string" ? `${server}/_v/${file}` : null;
4579
5887
  } catch {
4580
5888
  return null;
4581
5889
  }
@@ -4588,23 +5896,25 @@ function page(meta, baseCss, hasCss, mtime) {
4588
5896
  <meta charset="utf-8">
4589
5897
  <meta name="viewport" content="width=device-width, initial-scale=1">
4590
5898
  <title>${esc(meta.name)} · jdu widget dev</title>
5899
+ <style>
5900
+ /* 设计系统 tokens(构建期内联的 docs/design-system/tokens.css):widget 只读 token、不带兜底色,
5901
+ 预览页必须自己给一份;连上 server 时线上正文样式表再叠在后面 */
5902
+ ${tokens_default}
5903
+ </style>
4591
5904
  ${baseCss ? `<link rel="stylesheet" href="${esc(baseCss)}">` : ""}
4592
5905
  ${hasCss ? `<link rel="stylesheet" href="/index.css?t=${mtime}">` : ""}
4593
5906
  <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); }
5907
+ html { background: var(--bg); color: var(--fg); font-family: var(--f-sans); }
4598
5908
  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; }
5909
+ .dev-bar { display: flex; gap: 1rem; align-items: center; padding: 0.5rem 1rem; border-bottom: 1px solid var(--line); font-size: var(--t-sm); color: var(--fg-muted); }
5910
+ .dev-bar b { color: var(--fg); }
5911
+ .dev-bar button { margin-left: auto; height: 26px; padding: 0 var(--s4); border: 1px solid var(--line-strong); border-radius: var(--r-sm); background: var(--s-1); color: var(--fg); font: 550 var(--t-sm) var(--f-sans); cursor: pointer; }
4602
5912
  .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; }
5913
+ .dev-stage { min-height: 4rem; padding: 1rem; border: 1px dashed var(--line-strong); border-radius: var(--r-md); }
5914
+ details { margin-top: 1.5rem; font-size: var(--t-sm); color: var(--fg-muted); }
5915
+ textarea { width: 100%; min-height: 8rem; margin-top: 0.5rem; padding: 0.5rem; box-sizing: border-box; border: 1px solid var(--line-strong); border-radius: var(--r-sm); background: var(--s-1); color: var(--fg); font: 12px/1.5 var(--f-mono); }
5916
+ .dev-err { color: var(--danger); white-space: pre-wrap; font-family: var(--f-mono); font-size: 12px; }
5917
+ .dev-degraded { padding: 0.75rem; border-radius: var(--r-sm); background: var(--s-2); font-family: var(--f-mono); font-size: 12px; white-space: pre-wrap; }
4608
5918
  </style>
4609
5919
  </head>
4610
5920
  <body>
@@ -4812,26 +6122,36 @@ export interface Data {
4812
6122
  count?: number;
4813
6123
  }
4814
6124
  `;
4815
- var STYLE_CSS = (c) => `/* 颜色一律走 --jiandu-* token(带兜底值),亮 / 暗主题跟宿主一起变;暗色用 .dark 选择器,不要 prefers-color-scheme */
6125
+ var STYLE_CSS = (c) => `/* 颜色、字号、圆角一律走宿主的设计 token(docs/design-system/tokens.css),亮 / 暗主题跟宿主一起变;
6126
+ 不写 hex,不写 .dark 分支,不用 prefers-color-scheme。常用的:
6127
+ 表面 --bg --s-1 --s-2 --s-3 --s-4 · 描边 --line --line-strong --line-loud · 文字 --fg --fg-muted --fg-subtle
6128
+ 强调 --accent --accent-quiet --accent-ring --on-accent · 语义 --success --warn --danger(各配 -quiet)
6129
+ 字号 --t-sm(13) --t-ui(14) --t-body(15) · 间距 --s4(8) --s5(12) --s6(16) · 圆角 --r-sm(6) --r-md(10) */
4816
6130
  .w-${c.kebab} {
4817
6131
  display: inline-flex;
4818
6132
  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);
6133
+ gap: var(--s4);
6134
+ padding: var(--s4) var(--s5);
6135
+ border: 1px solid var(--line);
6136
+ border-radius: var(--r-md);
6137
+ background: var(--s-2);
6138
+ color: var(--fg);
6139
+ font: var(--t-ui) / 1.4 var(--f-sans);
4826
6140
  }
4827
6141
  .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);
6142
+ height: 26px;
6143
+ padding: 0 var(--s4);
6144
+ border: 1px solid var(--line-strong);
6145
+ border-radius: var(--r-sm);
6146
+ background: var(--s-1);
4832
6147
  color: inherit;
6148
+ font: 550 var(--t-sm) var(--f-sans);
4833
6149
  cursor: pointer;
4834
6150
  }
6151
+ .w-${c.kebab} button:hover {
6152
+ background: var(--s-2);
6153
+ border-color: var(--line-loud);
6154
+ }
4835
6155
  `;
4836
6156
  var VANILLA_INDEX = (c) => `import type { WidgetContext } from './_base/contract.js';
4837
6157
  import type { Data } from './types.js';
@@ -5144,6 +6464,7 @@ var WIDGET_JSON = (c) => `${JSON.stringify({
5144
6464
  name: c.name,
5145
6465
  scope: "personal",
5146
6466
  version: "1.0.0",
6467
+ description: "一句话说清什么场景用这个组件——agent 靠它决定选不选",
5147
6468
  dataSchema: {
5148
6469
  type: "object",
5149
6470
  required: ["title"],
@@ -5189,7 +6510,7 @@ npm run push # = build + jdu widget push dist
5189
6510
  |---|---|
5190
6511
  | \`src/types.ts\` | \`Data\` 接口 = fence 里 data 的唯一事实。JSDoc → description,\`@default\` → default;\`npm run build\` 生成 widget.json 的 dataSchema |
5191
6512
  | ${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\` 选择器 |
6513
+ | \`src/style.css\` | 样式。颜色 / 字号 / 圆角走宿主的设计 token(\`--bg\` \`--fg\` \`--line\` \`--accent\` \`--t-ui\` \`--r-md\` …),不写 hex 与 \`.dark\` 分支;主题真源是 \`<html class="dark">\`,token 会自己换值 |
5193
6514
  | \`widget.json\` | name / scope / version / sampleData;dataSchema 由 build 生成,不用手写 |
5194
6515
  | \`scripts/build.mjs\` | 全内联构建配置${c.runtime ? ";`RUNTIME_PATHS` 是平台版本档 runtime 的 URL" : ""} |
5195
6516
 
@@ -5297,8 +6618,48 @@ async function client() {
5297
6618
  function collectTag(value, previous) {
5298
6619
  return [...previous ?? [], value];
5299
6620
  }
6621
+ var VERSION = "0.6.0";
5300
6622
  var program = new Command();
5301
- program.name("jdu").description("jiandu 命令行").version("0.4.0").showHelpAfterError();
6623
+ program.name("jdu").description("jiandu 命令行").version(VERSION).showHelpAfterError();
6624
+ program.command("deploy").description("把一台简牍部署到你自己的 Cloudflare 账号(Worker + Durable Object,Free 计划够用)").option("--name <name>", "Worker 名字,决定 workers.dev 的子域名前缀", "jiandu").option("--dist <dir>", "用本地构建的产物代替 npm 包(开发用,指向 server/dist-cf)").option("--cf-token <token>", "带 Access 权限的 Cloudflare API token(不给就走浏览器 OAuth / 本地缓存)").option("--no-login", "部署完不自动 jdu login(之后手动跑一次即可)").option("--dry-run", "只校验配置与产物,不上传、不碰你的账号", false).option("--json", "机器可读输出(agent 联动用)", false).action(async (opts) => {
6625
+ const result = await runDeploy({
6626
+ ...opts,
6627
+ login: opts.login && !opts.json
6628
+ });
6629
+ if (opts.json) {
6630
+ process.stdout.write(`${JSON.stringify(result)}\n`);
6631
+ return;
6632
+ }
6633
+ if (opts.dryRun) {
6634
+ process.stdout.write("校验通过:产物与 wrangler 配置都没问题,没有上传任何东西\n");
6635
+ return;
6636
+ }
6637
+ process.stdout.write(`已部署 ${result.url}${result.upgraded ? "(升级:Access 策略与实例数据都没动)" : ""}\n`);
6638
+ process.stdout.write(`\n浏览器打开它,用 Cloudflare 账号(${result.ownerEmail})登录;公开文档匿名可读。\n`);
6639
+ process.stdout.write(`加人:jdu access allow <邮箱>\n`);
6640
+ if (result.loggedIn) process.stdout.write("\njdu 已经连上它了,可以直接 jdu push\n");
6641
+ else process.stdout.write(`\n下一步:jdu login --server ${result.url}\n`);
6642
+ });
6643
+ var access = program.command("access").description("Cloudflare 形态的准入(Access 策略里的邮箱列表)");
6644
+ access.command("list").description("列出能登录这台实例的邮箱").option("--server <url>", "目标站点(缺省用 jdu login 时定下的那台)").option("--cf-token <token>", "带 Access 权限的 Cloudflare API token").option("--json", "机器可读输出", false).action(async (opts) => {
6645
+ const result = await runAccessList(opts);
6646
+ if (opts.json) {
6647
+ process.stdout.write(`${JSON.stringify(result)}\n`);
6648
+ return;
6649
+ }
6650
+ if (result.emails.length === 0) process.stdout.write(`(${result.server} 的 Access 策略里没有邮箱)\n`);
6651
+ for (const email of result.emails) process.stdout.write(`${email}\n`);
6652
+ });
6653
+ access.command("allow").argument("<email...>", "要允许登录的邮箱(对方的 Cloudflare 账号邮箱)").description("把邮箱写进 Access 策略:对方用自己的 Cloudflare 账号(同一邮箱)登录").option("--server <url>", "目标站点(缺省用 jdu login 时定下的那台)").option("--cf-token <token>", "带 Access 权限的 Cloudflare API token").option("--json", "机器可读输出", false).action(async (emails, opts) => {
6654
+ const result = await runAccessAllow(emails, opts);
6655
+ if (opts.json) process.stdout.write(`${JSON.stringify(result)}\n`);
6656
+ else process.stdout.write(`已允许:${result.emails.join(", ")}\n`);
6657
+ });
6658
+ access.command("deny").argument("<email...>", "要移除的邮箱").description("把邮箱从 Access 策略里去掉:对方即刻失去访问(浏览器与 CLI 同时失效)").option("--server <url>", "目标站点(缺省用 jdu login 时定下的那台)").option("--cf-token <token>", "带 Access 权限的 Cloudflare API token").option("--json", "机器可读输出", false).action(async (emails, opts) => {
6659
+ const result = await runAccessDeny(emails, opts);
6660
+ if (opts.json) process.stdout.write(`${JSON.stringify(result)}\n`);
6661
+ else process.stdout.write(`剩余邮箱:${result.emails.join(", ")}\n`);
6662
+ });
5302
6663
  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) => {
5303
6664
  const result = await runLogin({
5304
6665
  ...opts,
@@ -5440,7 +6801,7 @@ template.command("push").argument("<file.md>", "模板文件").description("发
5440
6801
  const res = await pushTemplate(await client(), file, opts);
5441
6802
  process.stdout.write(`${res.url}\n`);
5442
6803
  });
5443
- program.command("mcp").description("以 MCP server(stdio)暴露 list_docs / search_docs / read_doc / list_versions / push_doc,给本机 agent 用").action(async () => {
6804
+ program.command("mcp").description("以 MCP server(stdio)暴露文档 / 评论 / widget / 语法工具,模板同时暴露为 prompts,给本机 agent 用").action(async () => {
5444
6805
  await runMcp(await client());
5445
6806
  });
5446
6807
  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) => {
@@ -5493,6 +6854,12 @@ tokenCmd.command("list").description("列出我的 token;CURRENT 是本次请
5493
6854
  }
5494
6855
  ]);
5495
6856
  });
6857
+ tokenCmd.command("create").option("--label <label>", "备注(jdu token list 里辨认用),如 agent / ci", "cli").description("再签一枚 token 给别的机器 / headless agent 用;stdout 只有 token,方便 $(jdu token create --label agent)").action(async (opts) => {
6858
+ const res = await (await client()).postJson("/api/tokens", { label: opts.label });
6859
+ if (typeof res.token !== "string") throw new CliError("server 没有返回 token");
6860
+ process.stderr.write(`id=${String(res.id)} label=${String(res.label)}(明文只显示这一次)\n`);
6861
+ process.stdout.write(`${res.token}\n`);
6862
+ });
5496
6863
  tokenCmd.command("rm").argument("<id>", "jdu token list 里的 ID").description("吊销一枚 token(正在使用的那枚不能删)").action(async (id) => {
5497
6864
  await (await client()).deleteJson(`/api/tokens/${encodeURIComponent(id)}`);
5498
6865
  process.stdout.write(`revoked ${id}\n`);