@monoedge/jdu-cli 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cli.js +1123 -336
- package/package.json +1 -1
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
8
|
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
-
import { homedir, hostname } from "node:os";
|
|
9
|
+
import { homedir, hostname, tmpdir } 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";
|
|
@@ -3326,6 +3326,11 @@ async function loadRuntimeConfig() {
|
|
|
3326
3326
|
}
|
|
3327
3327
|
//#endregion
|
|
3328
3328
|
//#region src/comments.ts
|
|
3329
|
+
/** 附件一行:`[图片 ×N] /blob/… /blob/…`,给 agent 的是相对路径,拼上 server 地址就能取 */
|
|
3330
|
+
function attachmentsLine(c) {
|
|
3331
|
+
const list = c.attachments ?? [];
|
|
3332
|
+
return list.length === 0 ? null : `[图片 ×${list.length}] ${list.map((s) => `/blob/${s}`).join(" ")}`;
|
|
3333
|
+
}
|
|
3329
3334
|
var QUOTE_MAX = 120;
|
|
3330
3335
|
function when(ms) {
|
|
3331
3336
|
return new Date(ms).toISOString().slice(0, 16).replace("T", " ");
|
|
@@ -3360,7 +3365,7 @@ async function listComments(api, docId, opts) {
|
|
|
3360
3365
|
for (const root of roots) {
|
|
3361
3366
|
const head = [
|
|
3362
3367
|
root.id,
|
|
3363
|
-
root.blockId ?? "
|
|
3368
|
+
root.blockId ?? "全文",
|
|
3364
3369
|
root.resolved ? "resolved" : "open",
|
|
3365
3370
|
who(root),
|
|
3366
3371
|
when(root.createdAt)
|
|
@@ -3369,10 +3374,14 @@ async function listComments(api, docId, opts) {
|
|
|
3369
3374
|
out.push(head.join(" "));
|
|
3370
3375
|
const quote = root.selection?.quote;
|
|
3371
3376
|
if (quote) out.push(` > ${quote.length > QUOTE_MAX ? `${quote.slice(0, QUOTE_MAX)}…` : quote}`);
|
|
3372
|
-
out.push(indent(root.body, " "));
|
|
3377
|
+
if (root.body) out.push(indent(root.body, " "));
|
|
3378
|
+
const rootPics = attachmentsLine(root);
|
|
3379
|
+
if (rootPics) out.push(` ${rootPics}`);
|
|
3373
3380
|
for (const reply of replies.get(root.id) ?? []) {
|
|
3374
3381
|
out.push(` ↳ ${reply.id} ${who(reply)} ${when(reply.createdAt)}`);
|
|
3375
|
-
out.push(indent(reply.body, " "));
|
|
3382
|
+
if (reply.body) out.push(indent(reply.body, " "));
|
|
3383
|
+
const pics = attachmentsLine(reply);
|
|
3384
|
+
if (pics) out.push(` ${pics}`);
|
|
3376
3385
|
}
|
|
3377
3386
|
out.push("");
|
|
3378
3387
|
}
|
|
@@ -3390,119 +3399,9 @@ async function resolveComment(api, threadId, opts) {
|
|
|
3390
3399
|
process.stdout.write(`${opts.undo ? "reopened" : "resolved"} ${String(res?.id ?? threadId)}\n`);
|
|
3391
3400
|
}
|
|
3392
3401
|
//#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;
|
|
3408
|
-
}
|
|
3409
|
-
url(path) {
|
|
3410
|
-
return `${this.server}${path}`;
|
|
3411
|
-
}
|
|
3412
|
-
async getJson(path) {
|
|
3413
|
-
return this.json("GET", path, void 0, void 0);
|
|
3414
|
-
}
|
|
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);
|
|
3501
|
-
}
|
|
3502
|
-
//#endregion
|
|
3503
3402
|
//#region src/browser-auth.ts
|
|
3504
3403
|
/**
|
|
3505
|
-
* jdu
|
|
3404
|
+
* jdu login 的浏览器授权(#66):本机起一个 loopback 回调,打开 server 下发的 authorizeUrl,
|
|
3506
3405
|
* 人在浏览器里用 passkey 会话点一次「授权」,回跳带回一次性 code,再用 PKCE verifier 向 server 换 token。
|
|
3507
3406
|
*
|
|
3508
3407
|
* authorizeUrl 与 server 可能不同源(CLI 连 127.0.0.1:18082,浏览器开 https://md.mason.local)——
|
|
@@ -3517,7 +3416,7 @@ async function browserAuthorize(input) {
|
|
|
3517
3416
|
server.close();
|
|
3518
3417
|
fn(value);
|
|
3519
3418
|
};
|
|
3520
|
-
const fail = finish((msg) => reject(new CliError(msg, "重新执行 jdu
|
|
3419
|
+
const fail = finish((msg) => reject(new CliError(msg, "重新执行 jdu login;不想开浏览器就带 --token")));
|
|
3521
3420
|
const handler = callbackHandler(state, finish(resolve));
|
|
3522
3421
|
const server = createServer((req, res) => {
|
|
3523
3422
|
if (new URL(req.url ?? "/", "http://localhost").pathname !== "/callback") {
|
|
@@ -3550,7 +3449,7 @@ async function browserAuthorize(input) {
|
|
|
3550
3449
|
})
|
|
3551
3450
|
});
|
|
3552
3451
|
const text = await res.text();
|
|
3553
|
-
if (!res.ok) throw new CliError(`code 换 token 失败(HTTP ${res.status}):${text.slice(0, 200)}`, "重新执行 jdu
|
|
3452
|
+
if (!res.ok) throw new CliError(`code 换 token 失败(HTTP ${res.status}):${text.slice(0, 200)}`, "重新执行 jdu login 再试一次");
|
|
3554
3453
|
let token;
|
|
3555
3454
|
try {
|
|
3556
3455
|
token = JSON.parse(text).token;
|
|
@@ -3596,7 +3495,11 @@ async function probeApiWithBearer(server, token) {
|
|
|
3596
3495
|
redirect: "manual"
|
|
3597
3496
|
})).ok;
|
|
3598
3497
|
}
|
|
3599
|
-
|
|
3498
|
+
/**
|
|
3499
|
+
* forward-auth / OIDC 分支,外加 --user 直连与 --token 直存。
|
|
3500
|
+
* 不是命令入口——入口是 init.ts 的 runLogin,它探完 healthz 才分派到这里。
|
|
3501
|
+
*/
|
|
3502
|
+
async function runOidcLogin(opts) {
|
|
3600
3503
|
const server = opts.server.trim().replace(/\/+$/, "");
|
|
3601
3504
|
if (server === "") throw new CliError("--server 不能为空");
|
|
3602
3505
|
const healthz = await (opts.fetchHealthz ?? fetchHealthz)(server);
|
|
@@ -3621,7 +3524,7 @@ async function runLogin(opts) {
|
|
|
3621
3524
|
configPath: saveConfig({ server }),
|
|
3622
3525
|
source: "anonymous"
|
|
3623
3526
|
};
|
|
3624
|
-
if (provider === "token" || provider === "password") throw new CliError(`这个 server 用 ${provider} 鉴权,CLI 需要 --token`, "jdu
|
|
3527
|
+
if (provider === "token" || provider === "password") throw new CliError(`这个 server 用 ${provider} 鉴权,CLI 需要 --token`, "jdu login --server <url> --token <token>(token 在 server 首启 stdout / data/initial-token.txt)");
|
|
3625
3528
|
const oidc = resolveOidc({
|
|
3626
3529
|
issuer: opts.issuer,
|
|
3627
3530
|
clientId: opts.clientId,
|
|
@@ -3659,21 +3562,22 @@ async function runLogin(opts) {
|
|
|
3659
3562
|
//#endregion
|
|
3660
3563
|
//#region src/init.ts
|
|
3661
3564
|
/**
|
|
3662
|
-
* jdu
|
|
3565
|
+
* jdu login:选定站点、拿到凭据、写好本地配置,一条命令把「登录到哪台简牍」定下来。
|
|
3663
3566
|
*
|
|
3664
|
-
*
|
|
3665
|
-
* (默认) 官方线上服务 https://docs.mszhou.com(OFFICIAL_SERVER)
|
|
3567
|
+
* 目标必须显式给出(没有默认站点,官方商用服务尚未上线):
|
|
3666
3568
|
* --local 本机自部署(http://127.0.0.1:8080)
|
|
3667
3569
|
* --server <u> 任意自部署地址
|
|
3668
3570
|
*
|
|
3669
3571
|
* 为 agent 联动而设计:全 flag 驱动、无交互提示、--json 输出机器可读结果,
|
|
3670
3572
|
* 拿到 next 字段就知道下一步该干什么(要不要 token、去哪拿)。
|
|
3671
3573
|
*
|
|
3672
|
-
*
|
|
3673
|
-
*
|
|
3574
|
+
* 登录方式全听 server 的(healthz.cliAuth,#66),用户不必先知道自己的站点用什么鉴权:
|
|
3575
|
+
* browser → 开浏览器授权换 token
|
|
3576
|
+
* oidc → 委托 runOidcLogin 走 SSO(forward-auth)
|
|
3577
|
+
* token → 把 server 给的 tokenHint 拼进 next,告诉去哪拿
|
|
3578
|
+
* anonymous→ 直接就绪
|
|
3579
|
+
* CLI 不按「官方 / 自部署」猜,也不再要求用户在 init / login 之间二选一。
|
|
3674
3580
|
*/
|
|
3675
|
-
/** 官方线上地址。password 鉴权:浏览器 passkey,CLI 走 token,token 向站点管理员申领。 */
|
|
3676
|
-
var OFFICIAL_SERVER = "https://docs.mszhou.com";
|
|
3677
3581
|
var LOCAL_SERVER = "http://127.0.0.1:8080";
|
|
3678
3582
|
function resolveInitTarget(opts) {
|
|
3679
3583
|
if (opts.local && opts.server) throw new CliError("--local 与 --server 只能二选一");
|
|
@@ -3695,12 +3599,9 @@ function resolveInitTarget(opts) {
|
|
|
3695
3599
|
target: "local",
|
|
3696
3600
|
server: LOCAL_SERVER
|
|
3697
3601
|
};
|
|
3698
|
-
|
|
3699
|
-
target: "official",
|
|
3700
|
-
server: OFFICIAL_SERVER
|
|
3701
|
-
};
|
|
3602
|
+
throw new CliError("jdu login 需要指定站点", "自部署用 --server <url>,本机用 --local");
|
|
3702
3603
|
}
|
|
3703
|
-
async function
|
|
3604
|
+
async function runLogin(opts) {
|
|
3704
3605
|
const { target, server } = resolveInitTarget(opts);
|
|
3705
3606
|
const healthz = await (opts.fetchHealthz ?? fetchHealthz)(server);
|
|
3706
3607
|
const provider = healthz.authProvider ?? "token";
|
|
@@ -3711,6 +3612,21 @@ async function runInit(opts) {
|
|
|
3711
3612
|
authProvider: provider,
|
|
3712
3613
|
authMethods: methods
|
|
3713
3614
|
};
|
|
3615
|
+
if (opts.user !== void 0 && opts.user.trim() !== "") {
|
|
3616
|
+
const r = await runOidcLogin({
|
|
3617
|
+
server,
|
|
3618
|
+
user: opts.user,
|
|
3619
|
+
proxySecret: opts.proxySecret,
|
|
3620
|
+
fetchHealthz: async () => healthz
|
|
3621
|
+
});
|
|
3622
|
+
return {
|
|
3623
|
+
...base,
|
|
3624
|
+
loggedIn: true,
|
|
3625
|
+
configPath: r.configPath,
|
|
3626
|
+
next: null,
|
|
3627
|
+
warning: r.warning
|
|
3628
|
+
};
|
|
3629
|
+
}
|
|
3714
3630
|
if (opts.token !== void 0 && opts.token !== "") {
|
|
3715
3631
|
if (!await (opts.probe ?? probeApiWithBearer)(server, opts.token)) throw new CliError("token 校验失败(/api/docs 未放行)", "确认 token 正确、且网关放行 Authorization: Bearer");
|
|
3716
3632
|
const path = saveConfig({
|
|
@@ -3754,8 +3670,25 @@ async function runInit(opts) {
|
|
|
3754
3670
|
next: null
|
|
3755
3671
|
};
|
|
3756
3672
|
}
|
|
3757
|
-
const viaToken = `jdu
|
|
3758
|
-
|
|
3673
|
+
const viaToken = `jdu login --server '${server}' --token <token>(${healthz.cliAuth?.tokenHint ?? "token 在 server 首启 stdout / data/initial-token.txt"})`;
|
|
3674
|
+
if (provider === "forward-auth" && opts.browser) {
|
|
3675
|
+
const r = await runOidcLogin({
|
|
3676
|
+
server,
|
|
3677
|
+
issuer: opts.issuer,
|
|
3678
|
+
clientId: opts.clientId,
|
|
3679
|
+
fetchHealthz: async () => healthz,
|
|
3680
|
+
oidcLogin: opts.oidcLogin,
|
|
3681
|
+
probe: opts.probe
|
|
3682
|
+
});
|
|
3683
|
+
return {
|
|
3684
|
+
...base,
|
|
3685
|
+
loggedIn: true,
|
|
3686
|
+
configPath: r.configPath,
|
|
3687
|
+
next: null,
|
|
3688
|
+
warning: r.warning
|
|
3689
|
+
};
|
|
3690
|
+
}
|
|
3691
|
+
const next = methods.includes("browser") ? `jdu login --server '${server}'(不带 --json / --no-browser,打开浏览器授权),或 ${viaToken}` : viaToken;
|
|
3759
3692
|
return {
|
|
3760
3693
|
...base,
|
|
3761
3694
|
loggedIn: false,
|
|
@@ -3769,11 +3702,355 @@ function fallbackMethods(provider) {
|
|
|
3769
3702
|
if (provider === "forward-auth") return ["oidc", "token"];
|
|
3770
3703
|
return ["token"];
|
|
3771
3704
|
}
|
|
3705
|
+
//#endregion
|
|
3706
|
+
//#region src/deploy.ts
|
|
3707
|
+
/**
|
|
3708
|
+
* `jdu deploy`:在用户自己的 Cloudflare 账号里起一个 jiandu,一条命令从零到能登录。
|
|
3709
|
+
*
|
|
3710
|
+
* 为什么是 CLI 而不是 Deploy 按钮:jiandu 是给 agent 用的,agent 会跑命令不会点按钮;
|
|
3711
|
+
* 而且这条路不要求 GitHub 账号、不在用户账号里留一个仓库与构建流水线,`JIANDU_SETUP_SECRET`
|
|
3712
|
+
* 由这里随机生成(用户手填的 secret 强度没法保证)。
|
|
3713
|
+
*
|
|
3714
|
+
* 我们不自己调 Cloudflare API:Worker 上传、Durable Object migration、静态资源上传会话
|
|
3715
|
+
* 是三套协议,wrangler 已经把它们做完了,重写一遍只会多一份要跟着 Cloudflare 演进的代码。
|
|
3716
|
+
* 所以这里只做三件事:把产物取下来、按正确的顺序敲 wrangler、把结果接回 jdu 的登录态。
|
|
3717
|
+
*
|
|
3718
|
+
* jdu deploy --owner mason 首次:部署 → 生成 secret → 自动 jdu login
|
|
3719
|
+
* jdu deploy 升级:npm 取 latest 再部署一次,vars 与 secret 原样保留
|
|
3720
|
+
*/
|
|
3721
|
+
/** npm 上的部署产物包:worker.js + assets/ + wrangler.json。 */
|
|
3722
|
+
var PACKAGE = "@monoedge/jiandu-cf";
|
|
3723
|
+
/** wrangler 主版本锁死:它改 flag 我们要跟着改,不能让用户某天突然拿到 v5。 */
|
|
3724
|
+
var WRANGLER = "wrangler@4";
|
|
3725
|
+
/** 默认 runner:stdio 继承给用户看(wrangler 的 OAuth 提示、进度条都靠它)。 */
|
|
3726
|
+
var spawnRunner = (cmd, args, opts) => new Promise((ok, fail) => {
|
|
3727
|
+
const child = spawn(cmd, args, {
|
|
3728
|
+
stdio: [
|
|
3729
|
+
opts.input === void 0 ? "inherit" : "pipe",
|
|
3730
|
+
"inherit",
|
|
3731
|
+
"inherit"
|
|
3732
|
+
],
|
|
3733
|
+
shell: process.platform === "win32",
|
|
3734
|
+
env: {
|
|
3735
|
+
...process.env,
|
|
3736
|
+
...opts.env
|
|
3737
|
+
}
|
|
3738
|
+
});
|
|
3739
|
+
if (opts.input !== void 0) child.stdin?.end(opts.input);
|
|
3740
|
+
child.on("error", (err) => fail(new CliError(`跑不起来 ${cmd}:${err.message}`, "需要 node 与 npm 在 PATH 里")));
|
|
3741
|
+
child.on("exit", (code) => code === 0 ? ok() : fail(new CliError(`${cmd} ${args[0] ?? ""} 失败(退出码 ${code})`)));
|
|
3742
|
+
});
|
|
3743
|
+
/** 24 字节随机,base64url —— 它会出现在 URL 的 query 里(/setup?invite=),别带需要转义的字符。 */
|
|
3744
|
+
function newSecret() {
|
|
3745
|
+
return randomBytes(24).toString("base64url");
|
|
3746
|
+
}
|
|
3747
|
+
/**
|
|
3748
|
+
* 取部署产物:`--dist` 指本地目录,否则临时装一份 npm 包。
|
|
3749
|
+
* 返回 wrangler 配置的绝对路径 —— wrangler 按**配置文件所在目录**解析 main 与 assets.directory,
|
|
3750
|
+
* 所以不需要把产物拷到工作目录,也不需要生成任何脚手架。
|
|
3751
|
+
*/
|
|
3752
|
+
async function resolveConfig(opts, run) {
|
|
3753
|
+
if (opts.dist !== void 0) {
|
|
3754
|
+
const config = resolve(opts.dist, "wrangler.json");
|
|
3755
|
+
if (!existsSync(config)) throw new CliError(`${config} 不存在`, "--dist 要指向 server/dist-cf 那样的构建产物目录");
|
|
3756
|
+
return {
|
|
3757
|
+
config,
|
|
3758
|
+
cleanup: () => void 0
|
|
3759
|
+
};
|
|
3760
|
+
}
|
|
3761
|
+
const dir = mkdtempSync(join(tmpdir(), "jdu-deploy-"));
|
|
3762
|
+
try {
|
|
3763
|
+
await run("npm", [
|
|
3764
|
+
"install",
|
|
3765
|
+
"--no-audit",
|
|
3766
|
+
"--no-fund",
|
|
3767
|
+
"--prefix",
|
|
3768
|
+
dir,
|
|
3769
|
+
`${PACKAGE}@latest`
|
|
3770
|
+
], {});
|
|
3771
|
+
} catch (err) {
|
|
3772
|
+
rmSync(dir, {
|
|
3773
|
+
recursive: true,
|
|
3774
|
+
force: true
|
|
3775
|
+
});
|
|
3776
|
+
throw err;
|
|
3777
|
+
}
|
|
3778
|
+
const config = join(dir, "node_modules", PACKAGE, "dist", "wrangler.json");
|
|
3779
|
+
if (!existsSync(config)) {
|
|
3780
|
+
rmSync(dir, {
|
|
3781
|
+
recursive: true,
|
|
3782
|
+
force: true
|
|
3783
|
+
});
|
|
3784
|
+
throw new CliError(`${PACKAGE} 里没有 dist/wrangler.json`, "包版本太老或装坏了,重试一次;仍失败请报 issue");
|
|
3785
|
+
}
|
|
3786
|
+
return {
|
|
3787
|
+
config,
|
|
3788
|
+
cleanup: () => rmSync(dir, {
|
|
3789
|
+
recursive: true,
|
|
3790
|
+
force: true
|
|
3791
|
+
})
|
|
3792
|
+
};
|
|
3793
|
+
}
|
|
3794
|
+
/** wrangler 的结构化输出:NDJSON,deploy 那条带 targets(部署好的地址)。 */
|
|
3795
|
+
function urlFromOutput(file) {
|
|
3796
|
+
if (!existsSync(file)) return null;
|
|
3797
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
3798
|
+
if (line.trim() === "") continue;
|
|
3799
|
+
try {
|
|
3800
|
+
const entry = JSON.parse(line);
|
|
3801
|
+
if (entry.type !== "deploy" || !Array.isArray(entry.targets)) continue;
|
|
3802
|
+
const target = entry.targets.find((t) => typeof t === "string" && t.startsWith("http"));
|
|
3803
|
+
if (target !== void 0) return target;
|
|
3804
|
+
} catch {}
|
|
3805
|
+
}
|
|
3806
|
+
return null;
|
|
3807
|
+
}
|
|
3808
|
+
/** 部署完探一次 healthz:缺 secret 的实例冷启动就抛错,所有请求 500。 */
|
|
3809
|
+
async function healthy(url) {
|
|
3810
|
+
try {
|
|
3811
|
+
return (await fetch(`${url}/healthz`, { headers: { accept: "application/json" } })).ok;
|
|
3812
|
+
} catch {
|
|
3813
|
+
return false;
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
async function runDeploy(opts) {
|
|
3817
|
+
const run = opts.run ?? spawnRunner;
|
|
3818
|
+
const name = opts.name ?? "jiandu";
|
|
3819
|
+
const { config, cleanup } = await resolveConfig(opts, run);
|
|
3820
|
+
try {
|
|
3821
|
+
if (opts.dryRun) {
|
|
3822
|
+
await run("npx", [
|
|
3823
|
+
"--yes",
|
|
3824
|
+
WRANGLER,
|
|
3825
|
+
"deploy",
|
|
3826
|
+
"--dry-run",
|
|
3827
|
+
"-c",
|
|
3828
|
+
config,
|
|
3829
|
+
"--name",
|
|
3830
|
+
name
|
|
3831
|
+
], {});
|
|
3832
|
+
return {
|
|
3833
|
+
name,
|
|
3834
|
+
url: null,
|
|
3835
|
+
setupSecret: null,
|
|
3836
|
+
setupUrl: null,
|
|
3837
|
+
loggedIn: false
|
|
3838
|
+
};
|
|
3839
|
+
}
|
|
3840
|
+
await run("npx", [
|
|
3841
|
+
"--yes",
|
|
3842
|
+
WRANGLER,
|
|
3843
|
+
"login"
|
|
3844
|
+
], {});
|
|
3845
|
+
const outDir = mkdtempSync(join(tmpdir(), "jdu-wrangler-out-"));
|
|
3846
|
+
const outFile = join(outDir, "out.ndjson");
|
|
3847
|
+
const args = [
|
|
3848
|
+
"--yes",
|
|
3849
|
+
WRANGLER,
|
|
3850
|
+
"deploy",
|
|
3851
|
+
"-c",
|
|
3852
|
+
config,
|
|
3853
|
+
"--name",
|
|
3854
|
+
name,
|
|
3855
|
+
"--keep-vars"
|
|
3856
|
+
];
|
|
3857
|
+
if (opts.owner !== void 0) args.push("--var", `JIANDU_AUTH_OWNER:${opts.owner}`);
|
|
3858
|
+
await run("npx", args, { env: { WRANGLER_OUTPUT_FILE_PATH: outFile } });
|
|
3859
|
+
const url = urlFromOutput(outFile);
|
|
3860
|
+
rmSync(outDir, {
|
|
3861
|
+
recursive: true,
|
|
3862
|
+
force: true
|
|
3863
|
+
});
|
|
3864
|
+
if (url === null) throw new CliError("部署命令跑完了,但没能从 wrangler 的输出里读到地址", "去 Cloudflare 控制台看这个 Worker 的地址,再手动 npx wrangler secret put JIANDU_SETUP_SECRET");
|
|
3865
|
+
const setupSecret = await healthy(url) ? null : newSecret();
|
|
3866
|
+
if (setupSecret !== null) {
|
|
3867
|
+
await run("npx", [
|
|
3868
|
+
"--yes",
|
|
3869
|
+
WRANGLER,
|
|
3870
|
+
"secret",
|
|
3871
|
+
"put",
|
|
3872
|
+
"JIANDU_SETUP_SECRET",
|
|
3873
|
+
"-c",
|
|
3874
|
+
config,
|
|
3875
|
+
"--name",
|
|
3876
|
+
name
|
|
3877
|
+
], { input: setupSecret });
|
|
3878
|
+
if (!await healthy(url)) throw new CliError(`${url} 起不来(secret 已设)`, `跑 npx ${WRANGLER} tail --name ${name} 看冷启动日志;常见原因是 JIANDU_AUTH_OWNER 没设或 provider 填了不支持的值`);
|
|
3879
|
+
}
|
|
3880
|
+
let loggedIn = false;
|
|
3881
|
+
if (setupSecret !== null) {
|
|
3882
|
+
await runLogin({
|
|
3883
|
+
server: url,
|
|
3884
|
+
token: setupSecret,
|
|
3885
|
+
browser: false
|
|
3886
|
+
});
|
|
3887
|
+
loggedIn = true;
|
|
3888
|
+
}
|
|
3889
|
+
return {
|
|
3890
|
+
name,
|
|
3891
|
+
url,
|
|
3892
|
+
setupSecret,
|
|
3893
|
+
setupUrl: setupSecret === null ? null : `${url}/setup?invite=${setupSecret}`,
|
|
3894
|
+
loggedIn
|
|
3895
|
+
};
|
|
3896
|
+
} finally {
|
|
3897
|
+
cleanup();
|
|
3898
|
+
}
|
|
3899
|
+
}
|
|
3900
|
+
//#endregion
|
|
3901
|
+
//#region src/http.ts
|
|
3902
|
+
/** 错误响应体太长会淹没终端,只留头部。 */
|
|
3903
|
+
var MAX_DETAIL = 400;
|
|
3904
|
+
var ApiClient = class {
|
|
3905
|
+
server;
|
|
3906
|
+
token;
|
|
3907
|
+
/** forward-auth 直连身份:作为 X-Forwarded-User 注入(server 默认 userHeader) */
|
|
3908
|
+
user;
|
|
3909
|
+
/** forward-auth fail-closed 的共享密钥:作为 X-Jiandu-Proxy-Secret 注入 */
|
|
3910
|
+
proxySecret;
|
|
3911
|
+
constructor(cfg) {
|
|
3912
|
+
this.server = cfg.server;
|
|
3913
|
+
this.token = cfg.token;
|
|
3914
|
+
this.user = cfg.user;
|
|
3915
|
+
this.proxySecret = cfg.proxySecret;
|
|
3916
|
+
}
|
|
3917
|
+
url(path) {
|
|
3918
|
+
return `${this.server}${path}`;
|
|
3919
|
+
}
|
|
3920
|
+
async getJson(path) {
|
|
3921
|
+
return this.json("GET", path, void 0, void 0);
|
|
3922
|
+
}
|
|
3923
|
+
async postJson(path, body) {
|
|
3924
|
+
return this.json("POST", path, JSON.stringify(body), "application/json");
|
|
3925
|
+
}
|
|
3926
|
+
async putJson(path, body) {
|
|
3927
|
+
return this.json("PUT", path, JSON.stringify(body), "application/json");
|
|
3928
|
+
}
|
|
3929
|
+
async deleteJson(path) {
|
|
3930
|
+
return this.json("DELETE", path, void 0, void 0);
|
|
3931
|
+
}
|
|
3932
|
+
/** 文本路由(`/d/:id.md`):原样返回正文。 */
|
|
3933
|
+
async getText(path) {
|
|
3934
|
+
return (await this.send("GET", path, void 0, void 0)).text();
|
|
3935
|
+
}
|
|
3936
|
+
/** widget push 走 multipart:boundary 交给 fetch 生成,别自己设 Content-Type。 */
|
|
3937
|
+
async postForm(path, form) {
|
|
3938
|
+
const text = await (await this.send("POST", path, form, void 0)).text();
|
|
3939
|
+
if (text.trim() === "") return void 0;
|
|
3940
|
+
try {
|
|
3941
|
+
return JSON.parse(text);
|
|
3942
|
+
} catch {
|
|
3943
|
+
throw new CliError(`POST ${this.url(path)} 返回的不是合法 JSON:${clip(text)}`);
|
|
3944
|
+
}
|
|
3945
|
+
}
|
|
3946
|
+
/** blob 上传走原始字节,不做任何包装 —— server 直接对 body 校验 sha256。 */
|
|
3947
|
+
async putBytes(path, bytes, contentType) {
|
|
3948
|
+
await this.send("PUT", path, bytes, contentType);
|
|
3949
|
+
}
|
|
3950
|
+
async json(method, path, body, contentType) {
|
|
3951
|
+
const text = await (await this.send(method, path, body, contentType)).text();
|
|
3952
|
+
if (text.trim() === "") return void 0;
|
|
3953
|
+
try {
|
|
3954
|
+
return JSON.parse(text);
|
|
3955
|
+
} catch {
|
|
3956
|
+
throw new CliError(`${method} ${this.url(path)} 返回的不是合法 JSON:${clip(text)}`);
|
|
3957
|
+
}
|
|
3958
|
+
}
|
|
3959
|
+
async send(method, path, body, contentType) {
|
|
3960
|
+
const url = this.url(path);
|
|
3961
|
+
const headers = {};
|
|
3962
|
+
if (this.token !== void 0) headers.Authorization = `Bearer ${this.token}`;
|
|
3963
|
+
if (this.user !== void 0) headers["X-Forwarded-User"] = this.user;
|
|
3964
|
+
if (this.proxySecret !== void 0) headers["X-Jiandu-Proxy-Secret"] = this.proxySecret;
|
|
3965
|
+
if (contentType !== void 0) headers["Content-Type"] = contentType;
|
|
3966
|
+
let res;
|
|
3967
|
+
try {
|
|
3968
|
+
res = await fetch(url, {
|
|
3969
|
+
method,
|
|
3970
|
+
headers,
|
|
3971
|
+
body,
|
|
3972
|
+
redirect: "manual"
|
|
3973
|
+
});
|
|
3974
|
+
} catch (err) {
|
|
3975
|
+
throw new CliError(`请求 ${method} ${url} 失败:${describeNetworkError(err)}`, "确认 server 已启动、地址与端口正确");
|
|
3976
|
+
}
|
|
3977
|
+
if (!res.ok) throw await httpError(method, url, res);
|
|
3978
|
+
return res;
|
|
3979
|
+
}
|
|
3980
|
+
};
|
|
3981
|
+
function clip(text) {
|
|
3982
|
+
const t = text.trim();
|
|
3983
|
+
return t.length > MAX_DETAIL ? `${t.slice(0, MAX_DETAIL)}…` : t;
|
|
3984
|
+
}
|
|
3985
|
+
/** fetch 失败时真正的原因藏在 cause 里(ECONNREFUSED / ENOTFOUND / 证书错误…)。 */
|
|
3986
|
+
function describeNetworkError(err) {
|
|
3987
|
+
const cause = err?.cause;
|
|
3988
|
+
if (cause instanceof Error) {
|
|
3989
|
+
const code = cause.code;
|
|
3990
|
+
return code ? `${code} ${cause.message}` : cause.message;
|
|
3991
|
+
}
|
|
3992
|
+
return err instanceof Error ? err.message : String(err);
|
|
3993
|
+
}
|
|
3994
|
+
async function httpError(method, url, res) {
|
|
3995
|
+
let detail = "";
|
|
3996
|
+
try {
|
|
3997
|
+
detail = clip(await res.text());
|
|
3998
|
+
} catch {
|
|
3999
|
+
detail = "";
|
|
4000
|
+
}
|
|
4001
|
+
if (detail.startsWith("{")) try {
|
|
4002
|
+
const obj = JSON.parse(detail);
|
|
4003
|
+
const msg = obj.error ?? obj.message;
|
|
4004
|
+
if (typeof msg === "string" && msg !== "") detail = msg;
|
|
4005
|
+
} catch {}
|
|
4006
|
+
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;
|
|
4007
|
+
const suffix = detail === "" ? "" : `:${detail}`;
|
|
4008
|
+
return new CliError(`${method} ${url} 返回 HTTP ${res.status} ${res.statusText}${suffix}`, hint);
|
|
4009
|
+
}
|
|
3772
4010
|
/** 只取链接/图片的目标部分,标签内容可以任意嵌套,不参与匹配。 */
|
|
3773
4011
|
var MD_DEST_RE = /\]\(([^)]*)\)/g;
|
|
3774
4012
|
var HTML_ATTR_RE = /\b(?:src|href)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'`=<>]+))/gi;
|
|
3775
|
-
/**
|
|
3776
|
-
|
|
4013
|
+
/** 与 server 的 scanFences 同一条规则(rewrite.ts):缩进 ≤3、3 个以上反引号或波浪号。 */
|
|
4014
|
+
var FENCE_RE = /^([ \t]{0,3})(`{3,}|~{3,})(.*)$/;
|
|
4015
|
+
/**
|
|
4016
|
+
* 去掉 fenced code block 再找引用:代码块里的 `](./a.png)` 是给人看的例子,不是引用。
|
|
4017
|
+
*
|
|
4018
|
+
* 这条规则必须与 server 对齐 —— `rewriteReferences` 就是按 `scanFences` 跳过同样的区间。
|
|
4019
|
+
* 不一致的后果是双向的:CLI 多收就会上传一堆永远不会被改写的文件,还对着代码里的
|
|
4020
|
+
* `steps[i](tx)` 这种写法报「跳过不存在的本地引用」;CLI 少收则会漏传真引用。
|
|
4021
|
+
*
|
|
4022
|
+
* 明知是重复实现:CLI 是零依赖的单文件发布物,为十几行扫描规则给它加一个 workspace 依赖不值。
|
|
4023
|
+
* 规则本身是 CommonMark 定死的,不会漂。
|
|
4024
|
+
*/
|
|
4025
|
+
function stripFences(text) {
|
|
4026
|
+
const out = [];
|
|
4027
|
+
let open = null;
|
|
4028
|
+
for (const line of text.split("\n")) {
|
|
4029
|
+
const m = FENCE_RE.exec(line);
|
|
4030
|
+
const marker = m?.[2];
|
|
4031
|
+
if (marker !== void 0) {
|
|
4032
|
+
const char = marker[0];
|
|
4033
|
+
const rest = m?.[3] ?? "";
|
|
4034
|
+
if (open === null) {
|
|
4035
|
+
if (!(char === "`" && rest.includes("`"))) {
|
|
4036
|
+
open = {
|
|
4037
|
+
char,
|
|
4038
|
+
len: marker.length
|
|
4039
|
+
};
|
|
4040
|
+
continue;
|
|
4041
|
+
}
|
|
4042
|
+
} else if (char === open.char && marker.length >= open.len && rest.trim() === "") {
|
|
4043
|
+
open = null;
|
|
4044
|
+
continue;
|
|
4045
|
+
}
|
|
4046
|
+
}
|
|
4047
|
+
if (open === null) out.push(line);
|
|
4048
|
+
}
|
|
4049
|
+
return out.join("\n");
|
|
4050
|
+
}
|
|
4051
|
+
/** 抽出一份文本里所有可能的引用目标(未过滤外链,原样返回)。代码块内的写法不算。 */
|
|
4052
|
+
function extractRefs(raw) {
|
|
4053
|
+
const text = stripFences(raw);
|
|
3777
4054
|
const out = [];
|
|
3778
4055
|
for (const m of text.matchAll(MD_DEST_RE)) {
|
|
3779
4056
|
const dest = destinationOf(m[1] ?? "");
|
|
@@ -3888,15 +4165,30 @@ function collectReferences(entryPath, maxDepth = 10) {
|
|
|
3888
4165
|
//#region src/push.ts
|
|
3889
4166
|
/** 可见性三档。link 档已废除:定向分享走读者池(jdu share --to)。 */
|
|
3890
4167
|
var VISIBILITIES = ["private", "public"];
|
|
4168
|
+
function asLint(v) {
|
|
4169
|
+
if (v === null || typeof v !== "object") return null;
|
|
4170
|
+
const r = v;
|
|
4171
|
+
if (typeof r["code"] !== "string" || typeof r["message"] !== "string") return null;
|
|
4172
|
+
return {
|
|
4173
|
+
code: r["code"],
|
|
4174
|
+
lang: typeof r["lang"] === "string" ? r["lang"] : "",
|
|
4175
|
+
...typeof r["line"] === "number" ? { line: r["line"] } : {},
|
|
4176
|
+
...typeof r["blockId"] === "string" ? { blockId: r["blockId"] } : {},
|
|
4177
|
+
message: r["message"]
|
|
4178
|
+
};
|
|
4179
|
+
}
|
|
3891
4180
|
function log(line) {
|
|
3892
4181
|
process.stderr.write(`${line}\n`);
|
|
3893
4182
|
}
|
|
3894
|
-
/** 没给 --title
|
|
4183
|
+
/** 没给 --title 时用首个一级标题,其次 frontmatter 的 title / name(SKILL.md 形态,#122),再退化到文件名。 */
|
|
3895
4184
|
function inferTitle(entryPath) {
|
|
3896
4185
|
try {
|
|
3897
4186
|
const text = readFileSync(entryPath, "utf8");
|
|
3898
4187
|
const m = /^[ \t]{0,3}#[ \t]+(.+?)[ \t]*#*[ \t]*$/m.exec(text);
|
|
3899
4188
|
if (m?.[1]) return m[1].trim();
|
|
4189
|
+
const fm = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text)?.[1];
|
|
4190
|
+
const t = fm ? /^(?:title|name)[ \t]*:[ \t]*(.+?)[ \t]*$/m.exec(fm)?.[1] : void 0;
|
|
4191
|
+
if (t) return /^(["']).*\1$/.test(t) ? t.slice(1, -1) : t;
|
|
3900
4192
|
} catch {}
|
|
3901
4193
|
return basename(entryPath, extname(entryPath));
|
|
3902
4194
|
}
|
|
@@ -3937,18 +4229,25 @@ async function pushDoc(api, entryArg, opts) {
|
|
|
3937
4229
|
if (opts.id !== void 0 && opts.id !== "") body.id = opts.id;
|
|
3938
4230
|
if (opts.tag !== void 0) body.tags = opts.tag.flatMap((t) => t.split(",")).map((t) => t.trim()).filter((t) => t.length > 0);
|
|
3939
4231
|
if (opts.official === true) body.official = true;
|
|
4232
|
+
if (opts.team !== void 0) body.team = opts.team;
|
|
3940
4233
|
const res = await api.postJson("/api/docs", body);
|
|
3941
4234
|
const id = typeof res?.id === "string" ? res.id : opts.id;
|
|
3942
4235
|
const url = typeof res?.url === "string" && res.url !== "" ? res.url : id !== void 0 ? api.url(`/d/${id}`) : void 0;
|
|
3943
4236
|
const warnings = Array.isArray(res?.warnings) ? res.warnings.map(String) : [];
|
|
3944
4237
|
for (const w of warnings) log(`warn: ${w}`);
|
|
4238
|
+
const lint = Array.isArray(res?.lint) ? res.lint.map(asLint).filter((l) => l !== null) : [];
|
|
4239
|
+
for (const l of lint) {
|
|
4240
|
+
const where = l.line !== void 0 ? ` 第 ${l.line} 行` : l.blockId ? ` ${l.blockId}` : "";
|
|
4241
|
+
log(`lint: ${l.code} ${l.lang}${where} — ${l.message}`);
|
|
4242
|
+
}
|
|
3945
4243
|
if (typeof res?.seq === "number") log(`已发布 ${id ?? ""} v${res.seq}`);
|
|
3946
4244
|
if (url === void 0) throw new CliError("server 未返回文档 id 或 url,无法给出访问地址");
|
|
3947
4245
|
return {
|
|
3948
4246
|
id,
|
|
3949
4247
|
seq: typeof res?.seq === "number" ? res.seq : void 0,
|
|
3950
4248
|
url,
|
|
3951
|
-
warnings
|
|
4249
|
+
warnings,
|
|
4250
|
+
lint
|
|
3952
4251
|
};
|
|
3953
4252
|
}
|
|
3954
4253
|
//#endregion
|
|
@@ -3988,6 +4287,73 @@ function printTable(rows, columns) {
|
|
|
3988
4287
|
* 模板原样发出去,怎么填是 agent 的事。agent 侧走 MCP 的 list_templates → read_doc → 按骨架写 → push_doc。
|
|
3989
4288
|
*/
|
|
3990
4289
|
var TEMPLATE_TAG = "template";
|
|
4290
|
+
/**
|
|
4291
|
+
* 模板元数据(#115):写在模板里的一个 HTML 注释,渲染不可见,agent 侧拿它当 MCP prompt 的 name / description:
|
|
4292
|
+
*
|
|
4293
|
+
* <!-- jiandu-template
|
|
4294
|
+
* name: weekly
|
|
4295
|
+
* description: 每周五给团队同步进展。触发:周报、weekly
|
|
4296
|
+
* -->
|
|
4297
|
+
*
|
|
4298
|
+
* 没有这段也能用:name 退回文档 id,description 退回摘要。
|
|
4299
|
+
* 文首 YAML frontmatter 里的同名字段也认(k7 起内核不渲染 frontmatter,SKILL.md 可以原样发;#122),注释优先。
|
|
4300
|
+
*
|
|
4301
|
+
* `arguments:` 声明 prompt 参数(#121):`week(本周编号,如 2026-W36); owner?(负责人)`——分号分隔,`?` 表示可选,
|
|
4302
|
+
* 括号里是说明。prompts/get 时 `jdu mcp` 把正文里的 `{{week}}` 换成客户端给的值;server 仍不做任何替换。
|
|
4303
|
+
*/
|
|
4304
|
+
var META_RE = /<!--\s*jiandu-template\s*\r?\n([\s\S]*?)-->[ \t]*\r?\n?/;
|
|
4305
|
+
/** 与 render 的 splitFrontmatter 同一条正则;cli 不为这一处引整个渲染包 */
|
|
4306
|
+
var FRONTMATTER_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
4307
|
+
/** prompt name 会拼进 `/mcp__jiandu__<name>` 这类客户端命令,只放行安全字符 */
|
|
4308
|
+
var NAME_RE$1 = /^[A-Za-z0-9_-]{1,64}$/;
|
|
4309
|
+
var ARG_RE = /^([A-Za-z0-9_-]{1,64})(\?)?\s*(?:[((](.*)[))])?$/;
|
|
4310
|
+
function parseArgs(raw) {
|
|
4311
|
+
return raw.split(/[;;]/).map((s) => s.trim()).filter(Boolean).flatMap((item) => {
|
|
4312
|
+
const m = ARG_RE.exec(item);
|
|
4313
|
+
if (!m?.[1]) return [];
|
|
4314
|
+
const desc = m[3]?.trim();
|
|
4315
|
+
return [{
|
|
4316
|
+
name: m[1],
|
|
4317
|
+
required: m[2] === void 0,
|
|
4318
|
+
...desc ? { description: desc } : {}
|
|
4319
|
+
}];
|
|
4320
|
+
});
|
|
4321
|
+
}
|
|
4322
|
+
/** 一段 `key: value` 行(注释正文或 frontmatter)→ 填进 meta;先到先得,所以调用顺序就是优先级。 */
|
|
4323
|
+
function readFields(block, out) {
|
|
4324
|
+
for (const line of block.split("\n")) {
|
|
4325
|
+
const kv = /^\s*([a-z]+)\s*:\s*(.*?)\s*$/.exec(line);
|
|
4326
|
+
if (!kv?.[2]) continue;
|
|
4327
|
+
const v = /^(["']).*\1$/.test(kv[2]) ? kv[2].slice(1, -1) : kv[2];
|
|
4328
|
+
if (kv[1] === "name" && out.name === void 0 && NAME_RE$1.test(v)) out.name = v;
|
|
4329
|
+
else if (kv[1] === "description" && out.description === void 0) out.description = v;
|
|
4330
|
+
else if (kv[1] === "arguments" && out.arguments.length === 0) out.arguments = parseArgs(v);
|
|
4331
|
+
}
|
|
4332
|
+
}
|
|
4333
|
+
function parseTemplateMeta(md) {
|
|
4334
|
+
const out = {
|
|
4335
|
+
arguments: [],
|
|
4336
|
+
body: md
|
|
4337
|
+
};
|
|
4338
|
+
const fm = FRONTMATTER_RE.exec(md);
|
|
4339
|
+
if (fm) out.body = md.slice(fm[0].length);
|
|
4340
|
+
const m = META_RE.exec(out.body);
|
|
4341
|
+
if (m) {
|
|
4342
|
+
out.body = out.body.replace(m[0], "");
|
|
4343
|
+
readFields(m[1] ?? "", out);
|
|
4344
|
+
}
|
|
4345
|
+
if (fm) readFields(fm[1] ?? "", out);
|
|
4346
|
+
return out;
|
|
4347
|
+
}
|
|
4348
|
+
/** `{{name}}` → 值;没给的可选参数替换成空串。只替换声明过的参数,别的 `{{…}}` 原样留着。 */
|
|
4349
|
+
function fillTemplate(body, args, values) {
|
|
4350
|
+
let out = body;
|
|
4351
|
+
for (const a of args) {
|
|
4352
|
+
const v = typeof values[a.name] === "string" ? values[a.name] : "";
|
|
4353
|
+
out = out.replace(new RegExp(`\\{\\{\\s*${a.name}\\s*\\}\\}`, "g"), v);
|
|
4354
|
+
}
|
|
4355
|
+
return out;
|
|
4356
|
+
}
|
|
3991
4357
|
var hasTemplateTag = (d) => Array.isArray(d.tags) && d.tags.map(String).includes("template");
|
|
3992
4358
|
async function listTemplates(api, opts) {
|
|
3993
4359
|
const [mine, official] = await Promise.all([api.getJson(`/api/docs${opts.all ? "?all=1" : ""}`), api.getJson("/api/official")]);
|
|
@@ -4013,6 +4379,10 @@ async function listTemplates(api, opts) {
|
|
|
4013
4379
|
key: "title",
|
|
4014
4380
|
header: "TITLE"
|
|
4015
4381
|
},
|
|
4382
|
+
{
|
|
4383
|
+
key: "uses",
|
|
4384
|
+
header: "USES"
|
|
4385
|
+
},
|
|
4016
4386
|
{
|
|
4017
4387
|
key: "updatedAt",
|
|
4018
4388
|
header: "UPDATED"
|
|
@@ -4034,6 +4404,7 @@ async function pullTemplate(api, id, opts) {
|
|
|
4034
4404
|
* (push 的 tags 字段是整体覆盖语义,不先取回就会把别的标签抹掉)。
|
|
4035
4405
|
*/
|
|
4036
4406
|
async function pushTemplate(api, file, opts) {
|
|
4407
|
+
if (!parseTemplateMeta(readFileSync(file, "utf8")).name) process.stderr.write("warn: 模板没有 <!-- jiandu-template name/description --> 元数据注释(或 frontmatter):agent 侧的 prompt 名会退回文档 id、描述退回摘要\n");
|
|
4037
4408
|
let tags = [TEMPLATE_TAG];
|
|
4038
4409
|
if (opts.id) {
|
|
4039
4410
|
const cur = await api.getJson(`/api/docs/${encodeURIComponent(opts.id)}/tags`).catch(() => ({}));
|
|
@@ -4054,8 +4425,12 @@ async function pushTemplate(api, file, opts) {
|
|
|
4054
4425
|
* 当可读可写的知识库用(#9 A 段)。凭据复用 ~/.config/jiandu/config.json,server 端零改动。
|
|
4055
4426
|
*
|
|
4056
4427
|
* ponytail: 不引 @modelcontextprotocol/sdk——它带 express / hono / zod 一整套,而这里只需要
|
|
4057
|
-
* initialize / tools
|
|
4428
|
+
* initialize / tools/* / prompts/* 几个方法的 JSON-RPC 换行分帧。协议有变再换官方 SDK。
|
|
4058
4429
|
* stdout 是协议信道:本文件之外任何写 stdout 的代码都不能在 mcp 模式下被调到(pushDoc 已改为返回值)。
|
|
4430
|
+
*
|
|
4431
|
+
* 模板即 prompt(#115):打了 template 标签的文档同时暴露为 MCP prompts(Claude Code 里是 /mcp__jiandu__<name>),
|
|
4432
|
+
* name / description 来自模板里的 `<!-- jiandu-template -->` 注释。语法三层(#116):常驻 instructions 放最容易错的几条,
|
|
4433
|
+
* get_syntax 按需拿这台实例的完整规则(带 widget= 就是某个组件的完整 schema),push_doc 响应的 lint[] 事后指出会降级的 fence。
|
|
4059
4434
|
*/
|
|
4060
4435
|
var SUPPORTED_PROTOCOLS = [
|
|
4061
4436
|
"2025-06-18",
|
|
@@ -4064,13 +4439,41 @@ var SUPPORTED_PROTOCOLS = [
|
|
|
4064
4439
|
];
|
|
4065
4440
|
var SERVER_INFO = {
|
|
4066
4441
|
name: "jiandu",
|
|
4067
|
-
version: "0.
|
|
4442
|
+
version: "0.6.0"
|
|
4068
4443
|
};
|
|
4069
|
-
|
|
4444
|
+
/**
|
|
4445
|
+
* 常驻开场白(#116 L0):只放**跨工具**的知识——各个工具自己做什么由它们的 description 讲,
|
|
4446
|
+
* 在这里复述一遍等于每轮对话都多付一份 token。留下的是顺序、写作规范,和 fence 那几条最容易错的。
|
|
4447
|
+
*/
|
|
4448
|
+
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 保留原样。";
|
|
4449
|
+
/** prompts/get 给模型的开场:模板正文前的一段固定说明,不在模板里另起一套「指令字段」。 */
|
|
4450
|
+
function promptText(t) {
|
|
4451
|
+
return `下面是文档模板「${t.title}」的骨架。按节填写,删掉所有 <!-- --> 占位注释,第一段写一句话摘要;写完用 push_doc 发布(默认 private,进团队传 team),并把链接给用户。模板里的说明文字不要带进正文。
|
|
4452
|
+
|
|
4453
|
+
---
|
|
4454
|
+
|
|
4455
|
+
` + t.body;
|
|
4456
|
+
}
|
|
4070
4457
|
function str(v, name) {
|
|
4071
4458
|
if (typeof v !== "string" || v.trim() === "") throw new CliError(`${name} 必填且为非空字符串`);
|
|
4072
4459
|
return v.trim();
|
|
4073
4460
|
}
|
|
4461
|
+
var SCOPES$1 = [
|
|
4462
|
+
"mine",
|
|
4463
|
+
"official",
|
|
4464
|
+
"shared",
|
|
4465
|
+
"all"
|
|
4466
|
+
];
|
|
4467
|
+
function asScope(v) {
|
|
4468
|
+
if (typeof v === "string" && SCOPES$1.includes(v)) return v;
|
|
4469
|
+
throw new CliError(`scope 只能是 ${SCOPES$1.join(" / ")}:${String(v)}`);
|
|
4470
|
+
}
|
|
4471
|
+
function strList(v) {
|
|
4472
|
+
return Array.isArray(v) ? v.map(String).filter((s) => s.trim() !== "") : [];
|
|
4473
|
+
}
|
|
4474
|
+
function isRecord(v) {
|
|
4475
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
4476
|
+
}
|
|
4074
4477
|
function asDoc(raw, scope, server) {
|
|
4075
4478
|
if (raw === null || typeof raw !== "object") return null;
|
|
4076
4479
|
const d = raw;
|
|
@@ -4082,8 +4485,10 @@ function asDoc(raw, scope, server) {
|
|
|
4082
4485
|
visibility: typeof d["visibility"] === "string" ? d["visibility"] : scope === "official" ? "official" : "",
|
|
4083
4486
|
tags: Array.isArray(d["tags"]) ? d["tags"].map(String) : [],
|
|
4084
4487
|
scope,
|
|
4488
|
+
...typeof d["team"] === "string" || d["team"] === null ? { team: d["team"] } : {},
|
|
4085
4489
|
...typeof d["archived"] === "boolean" ? { archived: d["archived"] } : {},
|
|
4086
4490
|
...typeof d["updatedAt"] === "number" ? { updatedAt: d["updatedAt"] } : {},
|
|
4491
|
+
...typeof d["uses"] === "number" ? { uses: d["uses"] } : {},
|
|
4087
4492
|
url: typeof d["url"] === "string" ? d["url"] : `${server}/d/${d["id"]}`
|
|
4088
4493
|
};
|
|
4089
4494
|
}
|
|
@@ -4102,152 +4507,345 @@ function buildTools(api) {
|
|
|
4102
4507
|
const seen = new Set(mine.map((d) => d.id));
|
|
4103
4508
|
return [...mine, ...[...official, ...shared].filter((d) => !seen.has(d.id) && seen.add(d.id))];
|
|
4104
4509
|
};
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4510
|
+
const loadTemplates = async () => {
|
|
4511
|
+
const [mine, shared, official] = await Promise.all([
|
|
4512
|
+
listOf("mine", false),
|
|
4513
|
+
listOf("shared", false),
|
|
4514
|
+
listOf("official", false)
|
|
4515
|
+
]);
|
|
4516
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4517
|
+
const docs = [
|
|
4518
|
+
...mine,
|
|
4519
|
+
...shared,
|
|
4520
|
+
...official
|
|
4521
|
+
].filter((d) => d.tags.includes("template") && !seen.has(d.id) && seen.add(d.id));
|
|
4522
|
+
const infos = await Promise.all(docs.map(async (d) => {
|
|
4523
|
+
const meta = parseTemplateMeta(await api.getText(`/d/${encodeURIComponent(d.id)}.md`));
|
|
4524
|
+
return {
|
|
4525
|
+
...d,
|
|
4526
|
+
name: meta.name ?? d.id,
|
|
4527
|
+
description: meta.description ?? (d.excerpt || d.title),
|
|
4528
|
+
arguments: meta.arguments,
|
|
4529
|
+
body: meta.body
|
|
4530
|
+
};
|
|
4531
|
+
}));
|
|
4532
|
+
const names = /* @__PURE__ */ new Set();
|
|
4533
|
+
return infos.filter((t) => !names.has(t.name) && names.add(t.name));
|
|
4534
|
+
};
|
|
4535
|
+
return {
|
|
4536
|
+
tools: [
|
|
4537
|
+
{
|
|
4538
|
+
name: "list_docs",
|
|
4539
|
+
description: "列 / 搜文档,返回 id、标题、摘要、可见性、标签、URL。给 query 就按关键词过滤(标题 / 摘要 / 标签,空格分词、全部命中),不给就是纯列表。scope:mine(我发布的)/ official(官方知识库)/ shared(分享给我的 + 我所属团队的)/ all;不传时——搜(有 query)默认 all、列(无 query)默认 mine。没有全文检索,正文搜不到——要读正文用 read_doc。",
|
|
4540
|
+
inputSchema: {
|
|
4541
|
+
type: "object",
|
|
4542
|
+
properties: {
|
|
4543
|
+
query: {
|
|
4544
|
+
type: "string",
|
|
4545
|
+
description: "关键词,空格分词、全部命中;不给就是纯列表"
|
|
4546
|
+
},
|
|
4547
|
+
scope: {
|
|
4548
|
+
type: "string",
|
|
4549
|
+
enum: [...SCOPES$1]
|
|
4550
|
+
},
|
|
4551
|
+
limit: {
|
|
4552
|
+
type: "integer",
|
|
4553
|
+
minimum: 1,
|
|
4554
|
+
maximum: 200,
|
|
4555
|
+
description: "最多几条;不给时搜默认 20 条、纯列表不截断"
|
|
4556
|
+
},
|
|
4557
|
+
includeArchived: {
|
|
4558
|
+
type: "boolean",
|
|
4559
|
+
default: false,
|
|
4560
|
+
description: "仅 mine 生效:包含已归档"
|
|
4561
|
+
}
|
|
4562
|
+
}
|
|
4563
|
+
},
|
|
4564
|
+
run: async (args) => {
|
|
4565
|
+
const terms = (typeof args["query"] === "string" ? args["query"].trim() : "").toLowerCase().split(/\s+/).filter(Boolean);
|
|
4566
|
+
const scope = args["scope"] === void 0 ? terms.length === 0 ? "mine" : "all" : asScope(args["scope"]);
|
|
4567
|
+
const includeArchived = args["includeArchived"] === true;
|
|
4568
|
+
const docs = scope === "all" ? await listAll(includeArchived) : await listOf(scope, includeArchived);
|
|
4569
|
+
if (terms.length === 0) {
|
|
4570
|
+
const cap = Number.isInteger(args["limit"]) ? Math.max(1, Math.min(200, args["limit"])) : docs.length;
|
|
4571
|
+
return JSON.stringify(docs.slice(0, cap), null, 2);
|
|
4126
4572
|
}
|
|
4573
|
+
const hits = docs.filter((d) => {
|
|
4574
|
+
const hay = `${d.title}\n${d.excerpt}\n${d.tags.join(" ")}`.toLowerCase();
|
|
4575
|
+
return terms.every((t) => hay.includes(t));
|
|
4576
|
+
});
|
|
4577
|
+
const limit = Number.isInteger(args["limit"]) ? Math.max(1, Math.min(200, args["limit"])) : 20;
|
|
4578
|
+
return JSON.stringify(hits.slice(0, limit), null, 2);
|
|
4127
4579
|
}
|
|
4128
4580
|
},
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
type: "object",
|
|
4143
|
-
required: ["query"],
|
|
4144
|
-
properties: {
|
|
4145
|
-
query: { type: "string" },
|
|
4146
|
-
limit: {
|
|
4147
|
-
type: "integer",
|
|
4148
|
-
minimum: 1,
|
|
4149
|
-
maximum: 100,
|
|
4150
|
-
default: 20
|
|
4581
|
+
{
|
|
4582
|
+
name: "read_doc",
|
|
4583
|
+
description: "读文档的 markdown 原文(本地引用已改写为 /blob/<hash>)。不传 version 读最新版。",
|
|
4584
|
+
inputSchema: {
|
|
4585
|
+
type: "object",
|
|
4586
|
+
required: ["id"],
|
|
4587
|
+
properties: {
|
|
4588
|
+
id: { type: "string" },
|
|
4589
|
+
version: {
|
|
4590
|
+
type: "integer",
|
|
4591
|
+
minimum: 1,
|
|
4592
|
+
description: "版本号 seq,见 list_versions"
|
|
4593
|
+
}
|
|
4151
4594
|
}
|
|
4595
|
+
},
|
|
4596
|
+
run: async (args) => {
|
|
4597
|
+
const id = encodeURIComponent(str(args["id"], "id"));
|
|
4598
|
+
const version = args["version"];
|
|
4599
|
+
if (version !== void 0 && !Number.isInteger(version)) throw new CliError("version 必须是整数");
|
|
4600
|
+
return api.getText(version === void 0 ? `/d/${id}.md` : `/d/${id}/v/${String(version)}.md`);
|
|
4152
4601
|
}
|
|
4153
4602
|
},
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
}
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4603
|
+
{
|
|
4604
|
+
name: "list_templates",
|
|
4605
|
+
description: "列出文档模板(打了 template 标签的文档:我的 + 我所属团队的 + 官方)。每条带 name(同时是 MCP prompt 名)和 description(什么场景用)。写新文档前先看这里;选中后 read_doc 取原文,按骨架填内容、删掉 <!-- --> 占位注释,再 push_doc。server 不做任何变量替换。",
|
|
4606
|
+
inputSchema: {
|
|
4607
|
+
type: "object",
|
|
4608
|
+
properties: {}
|
|
4609
|
+
},
|
|
4610
|
+
run: async () => {
|
|
4611
|
+
const out = (await loadTemplates()).map(({ body: _body, ...rest }) => rest);
|
|
4612
|
+
return JSON.stringify(out, null, 2);
|
|
4613
|
+
}
|
|
4614
|
+
},
|
|
4615
|
+
{
|
|
4616
|
+
name: "get_syntax",
|
|
4617
|
+
description: "取这台实例的写作语法(markdown):内核语法、widget fence 与多态 CodeBlock(mermaid / diff / vega-lite…)的规则、已注册 widget 的清单与字段摘要。写含 fence / 组件 / 图表的文档前先调一次(不带参数)。要某几个组件的完整入参就带 widget=名字(多个用逗号分隔),拿到 dataSchema(JSON Schema)、sampleData 和可直接粘进文档的 fence 示例;标了 contentMediaType: text/markdown 的字符串字段可以写 markdown。",
|
|
4618
|
+
inputSchema: {
|
|
4619
|
+
type: "object",
|
|
4620
|
+
properties: { widget: {
|
|
4621
|
+
type: "string",
|
|
4622
|
+
description: "组件名,区分大小写;多个用逗号分隔。不给就返回整份语法说明"
|
|
4623
|
+
} }
|
|
4624
|
+
},
|
|
4625
|
+
run: async (args) => {
|
|
4626
|
+
const want = typeof args["widget"] === "string" ? args["widget"].split(",").map((s) => s.trim()).filter(Boolean) : [];
|
|
4627
|
+
if (want.length === 0) return api.getText("/api/syntax");
|
|
4628
|
+
const res = await api.getJson("/api/widgets");
|
|
4629
|
+
const out = [];
|
|
4630
|
+
const active = [];
|
|
4631
|
+
for (const raw of Array.isArray(res?.widgets) ? res.widgets : []) {
|
|
4632
|
+
if (raw === null || typeof raw !== "object") continue;
|
|
4633
|
+
const w = raw;
|
|
4634
|
+
if (w["status"] !== "active" || typeof w["name"] !== "string") continue;
|
|
4635
|
+
active.push(w["name"]);
|
|
4636
|
+
if (!want.includes(w["name"])) continue;
|
|
4637
|
+
out.push({
|
|
4638
|
+
name: w["name"],
|
|
4639
|
+
scope: typeof w["scope"] === "string" ? w["scope"] : "",
|
|
4640
|
+
version: typeof w["version"] === "string" ? w["version"] : "",
|
|
4641
|
+
description: typeof w["description"] === "string" ? w["description"] : null,
|
|
4642
|
+
dataSchema: w["dataSchema"] ?? null,
|
|
4643
|
+
sampleData: w["sampleData"] ?? null,
|
|
4644
|
+
fence: `\`\`\`widget:${w["name"]}\n${JSON.stringify(w["sampleData"] ?? {}, null, 2)}\n\`\`\``
|
|
4645
|
+
});
|
|
4176
4646
|
}
|
|
4647
|
+
const missing = want.filter((n) => !out.some((w) => w.name === n));
|
|
4648
|
+
if (missing.length > 0) throw new CliError(`这台实例没有注册:${missing.join(" / ")}。可用的是:${active.join(" / ")}`);
|
|
4649
|
+
return JSON.stringify(out, null, 2);
|
|
4177
4650
|
}
|
|
4178
4651
|
},
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
properties: {}
|
|
4652
|
+
{
|
|
4653
|
+
name: "list_versions",
|
|
4654
|
+
description: "列出文档全部版本(seq、发布时间、源文 hash)。只追加不覆盖,回滚也是新版本。",
|
|
4655
|
+
inputSchema: {
|
|
4656
|
+
type: "object",
|
|
4657
|
+
required: ["id"],
|
|
4658
|
+
properties: { id: { type: "string" } }
|
|
4659
|
+
},
|
|
4660
|
+
run: async (args) => {
|
|
4661
|
+
const res = await api.getJson(`/api/docs/${encodeURIComponent(str(args["id"], "id"))}/versions`);
|
|
4662
|
+
return JSON.stringify(res, null, 2);
|
|
4663
|
+
}
|
|
4192
4664
|
},
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4665
|
+
{
|
|
4666
|
+
name: "push_doc",
|
|
4667
|
+
description: "发布 markdown。正文二选一:path(本机入口 .md 的绝对路径,会递归收集图片 / 嵌套 md 等本地引用一起上传)或 content(markdown 字串,没有本地引用可收,标题取一级标题、否则传 title)。带 id 是给已有文档发新版本(标题 / 可见性 / 标签 / 团队不传则保持原值);不带 id 新建,默认 private。响应里的 lint[] 列出会静默降级成普通代码块的 fence(unknown-widget / invalid-json / invalid-data,带行号或 blockId)——发布已成功,但非空就该按提示改稿再 push_doc 一次。",
|
|
4668
|
+
inputSchema: {
|
|
4669
|
+
type: "object",
|
|
4670
|
+
properties: {
|
|
4671
|
+
path: {
|
|
4672
|
+
type: "string",
|
|
4673
|
+
description: "入口 .md 的绝对路径;与 content 二选一"
|
|
4674
|
+
},
|
|
4675
|
+
content: {
|
|
4676
|
+
type: "string",
|
|
4677
|
+
description: "markdown 正文;与 path 二选一"
|
|
4678
|
+
},
|
|
4679
|
+
id: {
|
|
4680
|
+
type: "string",
|
|
4681
|
+
description: "要更新的文档 id"
|
|
4682
|
+
},
|
|
4683
|
+
title: { type: "string" },
|
|
4684
|
+
visibility: {
|
|
4685
|
+
type: "string",
|
|
4686
|
+
enum: [...VISIBILITIES]
|
|
4687
|
+
},
|
|
4688
|
+
tags: {
|
|
4689
|
+
type: "array",
|
|
4690
|
+
items: { type: "string" },
|
|
4691
|
+
description: "整体覆盖标签;不传则不动"
|
|
4692
|
+
},
|
|
4693
|
+
team: {
|
|
4694
|
+
type: "string",
|
|
4695
|
+
description: "进团队的团队 id(需是成员);空串移出团队;不传则不动"
|
|
4696
|
+
}
|
|
4697
|
+
}
|
|
4698
|
+
},
|
|
4699
|
+
run: async (args) => {
|
|
4700
|
+
const content = typeof args["content"] === "string" ? args["content"] : void 0;
|
|
4701
|
+
const hasPath = typeof args["path"] === "string" && args["path"].trim() !== "";
|
|
4702
|
+
if (content === void 0 === !hasPath) throw new CliError("path 与 content 必须且只能传一个");
|
|
4703
|
+
const tmp = content !== void 0 ? mkdtempSync(join(tmpdir(), "jdu-mcp-")) : null;
|
|
4704
|
+
const path = tmp ? join(tmp, "doc.md") : str(args["path"], "path");
|
|
4705
|
+
if (tmp && content !== void 0) writeFileSync(path, content, "utf8");
|
|
4706
|
+
const tags = Array.isArray(args["tags"]) ? args["tags"].map(String) : void 0;
|
|
4707
|
+
try {
|
|
4708
|
+
const res = await pushDoc(api, path, {
|
|
4709
|
+
id: typeof args["id"] === "string" ? args["id"] : void 0,
|
|
4710
|
+
title: typeof args["title"] === "string" ? args["title"] : void 0,
|
|
4711
|
+
visibility: typeof args["visibility"] === "string" ? args["visibility"] : void 0,
|
|
4712
|
+
tag: tags,
|
|
4713
|
+
team: typeof args["team"] === "string" ? args["team"] : void 0
|
|
4714
|
+
});
|
|
4715
|
+
return JSON.stringify(res, null, 2);
|
|
4716
|
+
} finally {
|
|
4717
|
+
if (tmp) rmSync(tmp, {
|
|
4718
|
+
recursive: true,
|
|
4719
|
+
force: true
|
|
4720
|
+
});
|
|
4721
|
+
}
|
|
4722
|
+
}
|
|
4205
4723
|
},
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
},
|
|
4231
|
-
tags: {
|
|
4232
|
-
type: "array",
|
|
4233
|
-
items: { type: "string" },
|
|
4234
|
-
description: "整体覆盖标签;不传则不动"
|
|
4724
|
+
{
|
|
4725
|
+
name: "share_doc",
|
|
4726
|
+
description: "改文档的访问范围,不用重推正文:visibility(private / public)、team(团队 id;空串移出团队)、addReaders / removeReaders(定向读者的身份串,如邮箱 / 用户名)。私有文档谁能读 = 作者 ∪ 指定读者 ∪ 所选团队成员。",
|
|
4727
|
+
inputSchema: {
|
|
4728
|
+
type: "object",
|
|
4729
|
+
required: ["id"],
|
|
4730
|
+
properties: {
|
|
4731
|
+
id: { type: "string" },
|
|
4732
|
+
visibility: {
|
|
4733
|
+
type: "string",
|
|
4734
|
+
enum: [...VISIBILITIES]
|
|
4735
|
+
},
|
|
4736
|
+
team: {
|
|
4737
|
+
type: "string",
|
|
4738
|
+
description: "团队 id;空串移出团队"
|
|
4739
|
+
},
|
|
4740
|
+
addReaders: {
|
|
4741
|
+
type: "array",
|
|
4742
|
+
items: { type: "string" }
|
|
4743
|
+
},
|
|
4744
|
+
removeReaders: {
|
|
4745
|
+
type: "array",
|
|
4746
|
+
items: { type: "string" }
|
|
4747
|
+
}
|
|
4235
4748
|
}
|
|
4749
|
+
},
|
|
4750
|
+
run: async (args) => {
|
|
4751
|
+
const id = encodeURIComponent(str(args["id"], "id"));
|
|
4752
|
+
const access = {};
|
|
4753
|
+
if (typeof args["visibility"] === "string") access["visibility"] = args["visibility"];
|
|
4754
|
+
if (typeof args["team"] === "string") access["team"] = args["team"];
|
|
4755
|
+
const add = strList(args["addReaders"]);
|
|
4756
|
+
const remove = strList(args["removeReaders"]);
|
|
4757
|
+
if (Object.keys(access).length === 0 && add.length === 0 && remove.length === 0) throw new CliError("share_doc 需要 visibility / team / addReaders / removeReaders 之一");
|
|
4758
|
+
const out = { id: args["id"] };
|
|
4759
|
+
if (Object.keys(access).length > 0) Object.assign(out, await api.putJson(`/api/docs/${id}/visibility`, access));
|
|
4760
|
+
if (add.length > 0 || remove.length > 0) Object.assign(out, await api.putJson(`/api/docs/${id}/readers`, {
|
|
4761
|
+
add,
|
|
4762
|
+
remove
|
|
4763
|
+
}));
|
|
4764
|
+
return JSON.stringify(out, null, 2);
|
|
4236
4765
|
}
|
|
4237
4766
|
},
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4767
|
+
{
|
|
4768
|
+
name: "list_comments",
|
|
4769
|
+
description: "列出文档的评论线程(读者在阅读页划词 / 块把手 / 文末评论区留下的)。默认只列未处理的;每条带 blockId(对应正文哪一块,null = 评论整篇文档)、selection.quote 引文、正文、attachments(图片附件的 sha256,取图 GET /blob/<sha256>)、作者、replies。处理流程:按 blockId 找到段落改稿 → push_doc 新版本 → reply_comment 回一句并 resolved=true 关掉。",
|
|
4770
|
+
inputSchema: {
|
|
4771
|
+
type: "object",
|
|
4772
|
+
required: ["id"],
|
|
4773
|
+
properties: {
|
|
4774
|
+
id: { type: "string" },
|
|
4775
|
+
includeResolved: {
|
|
4776
|
+
type: "boolean",
|
|
4777
|
+
default: false
|
|
4778
|
+
}
|
|
4779
|
+
}
|
|
4780
|
+
},
|
|
4781
|
+
run: async (args) => {
|
|
4782
|
+
const id = encodeURIComponent(str(args["id"], "id"));
|
|
4783
|
+
const res = await api.getJson(`/api/docs/${id}/comments`);
|
|
4784
|
+
const all = (Array.isArray(res?.comments) ? res.comments : []).filter(isRecord);
|
|
4785
|
+
const includeResolved = args["includeResolved"] === true;
|
|
4786
|
+
const threads = all.filter((c) => c["parentId"] === null && (includeResolved || c["resolved"] !== true)).map((root) => ({
|
|
4787
|
+
...root,
|
|
4788
|
+
replies: all.filter((c) => c["parentId"] === root["id"])
|
|
4789
|
+
}));
|
|
4790
|
+
return JSON.stringify(threads, null, 2);
|
|
4791
|
+
}
|
|
4792
|
+
},
|
|
4793
|
+
{
|
|
4794
|
+
name: "reply_comment",
|
|
4795
|
+
description: "回复评论线程并/或改它的处理状态。body 回一句(以 agent 身份发出,authorType=ai,读者页里会标出来);resolved=true 标记已处理、false 重新打开(仅文档 owner)。改完稿发了新版本后,通常一次调用把两件事一起做掉。",
|
|
4796
|
+
inputSchema: {
|
|
4797
|
+
type: "object",
|
|
4798
|
+
required: ["threadId"],
|
|
4799
|
+
properties: {
|
|
4800
|
+
threadId: { type: "string" },
|
|
4801
|
+
body: {
|
|
4802
|
+
type: "string",
|
|
4803
|
+
description: "回复正文;只想改状态就不传"
|
|
4804
|
+
},
|
|
4805
|
+
resolved: {
|
|
4806
|
+
type: "boolean",
|
|
4807
|
+
description: "true 关掉线程、false 重新打开;不传就只回复不改状态"
|
|
4808
|
+
}
|
|
4809
|
+
}
|
|
4810
|
+
},
|
|
4811
|
+
run: async (args) => {
|
|
4812
|
+
const tid = encodeURIComponent(str(args["threadId"], "threadId"));
|
|
4813
|
+
const body = typeof args["body"] === "string" && args["body"].trim() !== "" ? args["body"].trim() : null;
|
|
4814
|
+
const resolved = typeof args["resolved"] === "boolean" ? args["resolved"] : null;
|
|
4815
|
+
if (body === null && resolved === null) throw new CliError("reply_comment 需要 body(回复)或 resolved(改状态)之一");
|
|
4816
|
+
const out = { threadId: args["threadId"] };
|
|
4817
|
+
if (body !== null) out["reply"] = await api.postJson(`/api/comments/${tid}/reply`, {
|
|
4818
|
+
body,
|
|
4819
|
+
authorType: "ai"
|
|
4820
|
+
});
|
|
4821
|
+
if (resolved !== null) out["resolve"] = await api.postJson(`/api/comments/${tid}/resolve`, { resolved });
|
|
4822
|
+
return JSON.stringify(out, null, 2);
|
|
4823
|
+
}
|
|
4824
|
+
},
|
|
4825
|
+
{
|
|
4826
|
+
name: "archive_doc",
|
|
4827
|
+
description: "归档文档:从列表撤下、读者打开 404(owner 自己仍可读),版本与评论都保留;undo=true 取消归档。这就是「撤回发布」——没有比它更重的删除给 agent 用。",
|
|
4828
|
+
inputSchema: {
|
|
4829
|
+
type: "object",
|
|
4830
|
+
required: ["id"],
|
|
4831
|
+
properties: {
|
|
4832
|
+
id: { type: "string" },
|
|
4833
|
+
undo: {
|
|
4834
|
+
type: "boolean",
|
|
4835
|
+
default: false,
|
|
4836
|
+
description: "取消归档"
|
|
4837
|
+
}
|
|
4838
|
+
}
|
|
4839
|
+
},
|
|
4840
|
+
run: async (args) => {
|
|
4841
|
+
const id = encodeURIComponent(str(args["id"], "id"));
|
|
4842
|
+
const res = await api.postJson(`/api/docs/${id}/archive`, { archived: args["undo"] !== true });
|
|
4843
|
+
return JSON.stringify(res, null, 2);
|
|
4844
|
+
}
|
|
4248
4845
|
}
|
|
4249
|
-
|
|
4250
|
-
|
|
4846
|
+
],
|
|
4847
|
+
loadTemplates
|
|
4848
|
+
};
|
|
4251
4849
|
}
|
|
4252
4850
|
function write(msg) {
|
|
4253
4851
|
process.stdout.write(`${JSON.stringify(msg)}\n`);
|
|
@@ -4263,7 +4861,7 @@ function rpcError(id, code, message) {
|
|
|
4263
4861
|
});
|
|
4264
4862
|
}
|
|
4265
4863
|
async function runMcp(api) {
|
|
4266
|
-
const tools = buildTools(api);
|
|
4864
|
+
const { tools, loadTemplates } = buildTools(api);
|
|
4267
4865
|
const byName = new Map(tools.map((t) => [t.name, t]));
|
|
4268
4866
|
const handle = async (req) => {
|
|
4269
4867
|
const { id, method } = req;
|
|
@@ -4276,18 +4874,81 @@ async function runMcp(api) {
|
|
|
4276
4874
|
switch (method) {
|
|
4277
4875
|
case "initialize": {
|
|
4278
4876
|
const asked = typeof params["protocolVersion"] === "string" ? params["protocolVersion"] : "";
|
|
4877
|
+
const protocolVersion = SUPPORTED_PROTOCOLS.includes(asked) ? asked : SUPPORTED_PROTOCOLS[0];
|
|
4878
|
+
let instructions = INSTRUCTIONS;
|
|
4879
|
+
try {
|
|
4880
|
+
const templates = await loadTemplates();
|
|
4881
|
+
if (templates.length > 0) instructions += `可用模板(同名 prompt 可直接用):${templates.map((t) => `${t.name}——${t.description}`).join(";")}。`;
|
|
4882
|
+
} catch {}
|
|
4279
4883
|
write({
|
|
4280
4884
|
jsonrpc: "2.0",
|
|
4281
4885
|
id,
|
|
4282
4886
|
result: {
|
|
4283
|
-
protocolVersion
|
|
4284
|
-
capabilities: {
|
|
4887
|
+
protocolVersion,
|
|
4888
|
+
capabilities: {
|
|
4889
|
+
tools: {},
|
|
4890
|
+
prompts: {}
|
|
4891
|
+
},
|
|
4285
4892
|
serverInfo: SERVER_INFO,
|
|
4286
|
-
instructions
|
|
4893
|
+
instructions
|
|
4287
4894
|
}
|
|
4288
4895
|
});
|
|
4289
4896
|
return;
|
|
4290
4897
|
}
|
|
4898
|
+
case "prompts/list":
|
|
4899
|
+
try {
|
|
4900
|
+
write({
|
|
4901
|
+
jsonrpc: "2.0",
|
|
4902
|
+
id,
|
|
4903
|
+
result: { prompts: (await loadTemplates()).map((t) => ({
|
|
4904
|
+
name: t.name,
|
|
4905
|
+
title: t.title,
|
|
4906
|
+
description: t.description,
|
|
4907
|
+
...t.arguments.length > 0 ? { arguments: t.arguments } : {}
|
|
4908
|
+
})) }
|
|
4909
|
+
});
|
|
4910
|
+
} catch (err) {
|
|
4911
|
+
rpcError(id, -32603, `模板列表取不到:${err instanceof CliError ? err.message : String(err)}`);
|
|
4912
|
+
}
|
|
4913
|
+
return;
|
|
4914
|
+
case "prompts/get": {
|
|
4915
|
+
const name = typeof params["name"] === "string" ? params["name"] : "";
|
|
4916
|
+
try {
|
|
4917
|
+
const t = (await loadTemplates()).find((x) => x.name === name);
|
|
4918
|
+
if (!t) {
|
|
4919
|
+
rpcError(id, -32602, `未知 prompt:${name}`);
|
|
4920
|
+
return;
|
|
4921
|
+
}
|
|
4922
|
+
const given = isRecord(params["arguments"]) ? params["arguments"] : {};
|
|
4923
|
+
const missing = t.arguments.filter((a) => a.required && (typeof given[a.name] !== "string" || given[a.name] === ""));
|
|
4924
|
+
if (missing.length > 0) {
|
|
4925
|
+
rpcError(id, -32602, `缺少必填参数:${missing.map((a) => a.name).join(", ")}`);
|
|
4926
|
+
return;
|
|
4927
|
+
}
|
|
4928
|
+
api.postJson(`/api/docs/${encodeURIComponent(t.id)}/used`, {}).catch(() => void 0);
|
|
4929
|
+
const body = t.arguments.length > 0 ? fillTemplate(t.body, t.arguments, given) : t.body;
|
|
4930
|
+
write({
|
|
4931
|
+
jsonrpc: "2.0",
|
|
4932
|
+
id,
|
|
4933
|
+
result: {
|
|
4934
|
+
description: t.description,
|
|
4935
|
+
messages: [{
|
|
4936
|
+
role: "user",
|
|
4937
|
+
content: {
|
|
4938
|
+
type: "text",
|
|
4939
|
+
text: promptText({
|
|
4940
|
+
...t,
|
|
4941
|
+
body
|
|
4942
|
+
})
|
|
4943
|
+
}
|
|
4944
|
+
}]
|
|
4945
|
+
}
|
|
4946
|
+
});
|
|
4947
|
+
} catch (err) {
|
|
4948
|
+
rpcError(id, -32603, `模板取不到:${err instanceof CliError ? err.message : String(err)}`);
|
|
4949
|
+
}
|
|
4950
|
+
return;
|
|
4951
|
+
}
|
|
4291
4952
|
case "ping":
|
|
4292
4953
|
write({
|
|
4293
4954
|
jsonrpc: "2.0",
|
|
@@ -4392,6 +5053,7 @@ async function readWidgetJson(dir) {
|
|
|
4392
5053
|
name,
|
|
4393
5054
|
scope,
|
|
4394
5055
|
version,
|
|
5056
|
+
...typeof obj.description === "string" && obj.description.trim() !== "" ? { description: obj.description.trim() } : {},
|
|
4395
5057
|
dataSchema: obj.dataSchema ?? null,
|
|
4396
5058
|
sampleData: obj.sampleData ?? null
|
|
4397
5059
|
};
|
|
@@ -4486,6 +5148,9 @@ async function pushWidget(api, dirArg) {
|
|
|
4486
5148
|
process.stdout.write(`${meta.scope}/${meta.name}@${meta.version}${src}${where}\n`);
|
|
4487
5149
|
}
|
|
4488
5150
|
//#endregion
|
|
5151
|
+
//#region ../docs/design-system/tokens.css?raw
|
|
5152
|
+
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";
|
|
5153
|
+
//#endregion
|
|
4489
5154
|
//#region src/widget-dev.ts
|
|
4490
5155
|
var ARTIFACTS = [
|
|
4491
5156
|
"index.js",
|
|
@@ -4528,19 +5193,28 @@ function readMeta(dir, dist) {
|
|
|
4528
5193
|
version: ""
|
|
4529
5194
|
};
|
|
4530
5195
|
}
|
|
4531
|
-
/**
|
|
5196
|
+
/**
|
|
5197
|
+
* 线上正文样式表的地址:读 `/_v/manifest.json`(viewer 的产物清单,本来就在那条静态路由下)。
|
|
5198
|
+
* 早先是抓首页 HTML 里的 `/_v/base.<hash>.css`,但站点页换成 React 直出后首页只链 app.css,
|
|
5199
|
+
* 而 widget 预览要的是**正文**那套排版与 token。清单里按名字取,页面怎么改都不影响。
|
|
5200
|
+
* 拿不到返回 null,预览页用内置兜底样式。
|
|
5201
|
+
*/
|
|
4532
5202
|
async function siteBaseCss(server) {
|
|
4533
5203
|
if (!server) return null;
|
|
4534
5204
|
try {
|
|
4535
5205
|
const ctrl = new AbortController();
|
|
4536
5206
|
const t = setTimeout(() => ctrl.abort(), 2e3);
|
|
4537
|
-
const
|
|
5207
|
+
const res = await fetch(`${server}/_v/manifest.json`, {
|
|
4538
5208
|
signal: ctrl.signal,
|
|
4539
5209
|
redirect: "follow"
|
|
4540
|
-
})
|
|
5210
|
+
});
|
|
4541
5211
|
clearTimeout(t);
|
|
4542
|
-
|
|
4543
|
-
|
|
5212
|
+
if (!res.ok) return null;
|
|
5213
|
+
const manifest = await res.json();
|
|
5214
|
+
if (typeof manifest !== "object" || manifest === null) return null;
|
|
5215
|
+
const m = manifest;
|
|
5216
|
+
const file = m["reader.css"] ?? m["app.css"];
|
|
5217
|
+
return typeof file === "string" ? `${server}/_v/${file}` : null;
|
|
4544
5218
|
} catch {
|
|
4545
5219
|
return null;
|
|
4546
5220
|
}
|
|
@@ -4553,23 +5227,25 @@ function page(meta, baseCss, hasCss, mtime) {
|
|
|
4553
5227
|
<meta charset="utf-8">
|
|
4554
5228
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
4555
5229
|
<title>${esc(meta.name)} · jdu widget dev</title>
|
|
5230
|
+
<style>
|
|
5231
|
+
/* 设计系统 tokens(构建期内联的 docs/design-system/tokens.css):widget 只读 token、不带兜底色,
|
|
5232
|
+
预览页必须自己给一份;连上 server 时线上正文样式表再叠在后面 */
|
|
5233
|
+
${tokens_default}
|
|
5234
|
+
</style>
|
|
4556
5235
|
${baseCss ? `<link rel="stylesheet" href="${esc(baseCss)}">` : ""}
|
|
4557
5236
|
${hasCss ? `<link rel="stylesheet" href="/index.css?t=${mtime}">` : ""}
|
|
4558
5237
|
<style>
|
|
4559
|
-
|
|
4560
|
-
:root { --jiandu-bg: #fff; --jiandu-bg-subtle: #f6f8fa; --jiandu-fg: #1f2328; --jiandu-fg-muted: #59636e; --jiandu-border: #d0d7de; --jiandu-font-sans: system-ui, sans-serif; --jiandu-font-mono: ui-monospace, monospace; }
|
|
4561
|
-
.dark { --jiandu-bg: #0d1117; --jiandu-bg-subtle: #161b22; --jiandu-fg: #e6edf3; --jiandu-fg-muted: #9198a1; --jiandu-border: #3d444d; }
|
|
4562
|
-
html { background: var(--jiandu-bg); color: var(--jiandu-fg); font-family: var(--jiandu-font-sans); }
|
|
5238
|
+
html { background: var(--bg); color: var(--fg); font-family: var(--f-sans); }
|
|
4563
5239
|
body { margin: 0; }
|
|
4564
|
-
.dev-bar { display: flex; gap: 1rem; align-items: center; padding: 0.5rem 1rem; border-bottom: 1px solid var(--
|
|
4565
|
-
.dev-bar b { color: var(--
|
|
4566
|
-
.dev-bar button { margin-left: auto; padding: 0
|
|
5240
|
+
.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); }
|
|
5241
|
+
.dev-bar b { color: var(--fg); }
|
|
5242
|
+
.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; }
|
|
4567
5243
|
.dev-main { max-width: 760px; margin: 0 auto; padding: 1.5rem 1rem; }
|
|
4568
|
-
.dev-stage { min-height: 4rem; padding: 1rem; border: 1px dashed var(--
|
|
4569
|
-
details { margin-top: 1.5rem; font-size:
|
|
4570
|
-
textarea { width: 100%; min-height: 8rem; margin-top: 0.5rem; padding: 0.5rem; box-sizing: border-box; border: 1px solid var(--
|
|
4571
|
-
.dev-err { color:
|
|
4572
|
-
.dev-degraded { padding: 0.75rem; border-radius:
|
|
5244
|
+
.dev-stage { min-height: 4rem; padding: 1rem; border: 1px dashed var(--line-strong); border-radius: var(--r-md); }
|
|
5245
|
+
details { margin-top: 1.5rem; font-size: var(--t-sm); color: var(--fg-muted); }
|
|
5246
|
+
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); }
|
|
5247
|
+
.dev-err { color: var(--danger); white-space: pre-wrap; font-family: var(--f-mono); font-size: 12px; }
|
|
5248
|
+
.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; }
|
|
4573
5249
|
</style>
|
|
4574
5250
|
</head>
|
|
4575
5251
|
<body>
|
|
@@ -4590,11 +5266,11 @@ $('dark').addEventListener('click', () => { document.documentElement.classList.t
|
|
|
4590
5266
|
|
|
4591
5267
|
// 与 viewer loader 同一条链路:import → new → mount(el, ctx);抛错就按线上的降级形态显示原始 fence
|
|
4592
5268
|
let meta = {}, inst = null, W = null, data = {};
|
|
4593
|
-
const ctx = (d) => ({ data: d, loading: false, blockId: 'b0-dev00000', source: JSON.stringify(d, null, 2), language: '
|
|
5269
|
+
const ctx = (d) => ({ data: d, loading: false, blockId: 'b0-dev00000', source: JSON.stringify(d, null, 2), language: 'widget:' + (meta.name ?? '') });
|
|
4594
5270
|
function degrade(reason) {
|
|
4595
5271
|
const pre = document.createElement('pre');
|
|
4596
5272
|
pre.className = 'dev-degraded';
|
|
4597
|
-
pre.textContent = '[降级为代码块] ' + reason + '\\n\\n\`\`\`
|
|
5273
|
+
pre.textContent = '[降级为代码块] ' + reason + '\\n\\n\`\`\`widget:' + (meta.name ?? '') + '\\n' + JSON.stringify(data, null, 2) + '\\n\`\`\`';
|
|
4598
5274
|
island.replaceChildren(pre);
|
|
4599
5275
|
status.textContent = '挂载失败';
|
|
4600
5276
|
}
|
|
@@ -4732,7 +5408,7 @@ export interface WidgetContext<D = unknown> {
|
|
|
4732
5408
|
blockId: string;
|
|
4733
5409
|
/** 原始 fence 正文 */
|
|
4734
5410
|
source: string;
|
|
4735
|
-
/** 原始代码块语言标识,如 \`
|
|
5411
|
+
/** 原始代码块语言标识,如 \`widget:StarRating\` */
|
|
4736
5412
|
language: string;
|
|
4737
5413
|
/**
|
|
4738
5414
|
* 嵌套 markdown:dataSchema 里标了 \`contentMediaType: "text/markdown"\` 的字符串字段,宿主渲成已 sanitize 的 HTML
|
|
@@ -4761,7 +5437,7 @@ export interface WidgetClass<D = unknown> {
|
|
|
4761
5437
|
var TYPES_TS = (c) => `// fence 里的 data 长什么样,这里是唯一事实:\`npm run build\` 会把 Data 生成为 widget.json 的 dataSchema
|
|
4762
5438
|
// (字段上的 JSDoc → description,@default → default)。文档里的 data 不合法时平台会把 fence 降级成代码块。
|
|
4763
5439
|
//
|
|
4764
|
-
// \`\`\`
|
|
5440
|
+
// \`\`\`widget:${c.name}
|
|
4765
5441
|
// { "title": "你好,简牍", "count": 3 }
|
|
4766
5442
|
// \`\`\`
|
|
4767
5443
|
//
|
|
@@ -4777,26 +5453,36 @@ export interface Data {
|
|
|
4777
5453
|
count?: number;
|
|
4778
5454
|
}
|
|
4779
5455
|
`;
|
|
4780
|
-
var STYLE_CSS = (c) => `/*
|
|
5456
|
+
var STYLE_CSS = (c) => `/* 颜色、字号、圆角一律走宿主的设计 token(docs/design-system/tokens.css),亮 / 暗主题跟宿主一起变;
|
|
5457
|
+
不写 hex,不写 .dark 分支,不用 prefers-color-scheme。常用的:
|
|
5458
|
+
表面 --bg --s-1 --s-2 --s-3 --s-4 · 描边 --line --line-strong --line-loud · 文字 --fg --fg-muted --fg-subtle
|
|
5459
|
+
强调 --accent --accent-quiet --accent-ring --on-accent · 语义 --success --warn --danger(各配 -quiet)
|
|
5460
|
+
字号 --t-sm(13) --t-ui(14) --t-body(15) · 间距 --s4(8) --s5(12) --s6(16) · 圆角 --r-sm(6) --r-md(10) */
|
|
4781
5461
|
.w-${c.kebab} {
|
|
4782
5462
|
display: inline-flex;
|
|
4783
5463
|
align-items: center;
|
|
4784
|
-
gap:
|
|
4785
|
-
padding:
|
|
4786
|
-
border: 1px solid var(--
|
|
4787
|
-
border-radius:
|
|
4788
|
-
background: var(--
|
|
4789
|
-
color: var(--
|
|
4790
|
-
font:
|
|
5464
|
+
gap: var(--s4);
|
|
5465
|
+
padding: var(--s4) var(--s5);
|
|
5466
|
+
border: 1px solid var(--line);
|
|
5467
|
+
border-radius: var(--r-md);
|
|
5468
|
+
background: var(--s-2);
|
|
5469
|
+
color: var(--fg);
|
|
5470
|
+
font: var(--t-ui) / 1.4 var(--f-sans);
|
|
4791
5471
|
}
|
|
4792
5472
|
.w-${c.kebab} button {
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
border
|
|
4796
|
-
|
|
5473
|
+
height: 26px;
|
|
5474
|
+
padding: 0 var(--s4);
|
|
5475
|
+
border: 1px solid var(--line-strong);
|
|
5476
|
+
border-radius: var(--r-sm);
|
|
5477
|
+
background: var(--s-1);
|
|
4797
5478
|
color: inherit;
|
|
5479
|
+
font: 550 var(--t-sm) var(--f-sans);
|
|
4798
5480
|
cursor: pointer;
|
|
4799
5481
|
}
|
|
5482
|
+
.w-${c.kebab} button:hover {
|
|
5483
|
+
background: var(--s-2);
|
|
5484
|
+
border-color: var(--line-loud);
|
|
5485
|
+
}
|
|
4800
5486
|
`;
|
|
4801
5487
|
var VANILLA_INDEX = (c) => `import type { WidgetContext } from './_base/contract.js';
|
|
4802
5488
|
import type { Data } from './types.js';
|
|
@@ -5109,6 +5795,7 @@ var WIDGET_JSON = (c) => `${JSON.stringify({
|
|
|
5109
5795
|
name: c.name,
|
|
5110
5796
|
scope: "personal",
|
|
5111
5797
|
version: "1.0.0",
|
|
5798
|
+
description: "一句话说清什么场景用这个组件——agent 靠它决定选不选",
|
|
5112
5799
|
dataSchema: {
|
|
5113
5800
|
type: "object",
|
|
5114
5801
|
required: ["title"],
|
|
@@ -5143,7 +5830,7 @@ npm run push # = build + jdu widget push dist
|
|
|
5143
5830
|
然后在任何一篇文档里:
|
|
5144
5831
|
|
|
5145
5832
|
\`\`\`\`markdown
|
|
5146
|
-
\`\`\`
|
|
5833
|
+
\`\`\`widget:${c.name}
|
|
5147
5834
|
{ "title": "你好,简牍", "count": 3 }
|
|
5148
5835
|
\`\`\`
|
|
5149
5836
|
\`\`\`\`
|
|
@@ -5154,7 +5841,7 @@ npm run push # = build + jdu widget push dist
|
|
|
5154
5841
|
|---|---|
|
|
5155
5842
|
| \`src/types.ts\` | \`Data\` 接口 = fence 里 data 的唯一事实。JSDoc → description,\`@default\` → default;\`npm run build\` 生成 widget.json 的 dataSchema |
|
|
5156
5843
|
| ${c.template === "vanilla" ? "`src/index.ts`" : c.template === "react" ? "`src/App.tsx`" : "`src/App.ts`"} | 渲染逻辑${c.template === "vanilla" ? "(mount / update / destroy 三个生命周期)" : "(组件收 `data` prop;契约适配在 `src/_base/`,一般不用动)"} |
|
|
5157
|
-
| \`src/style.css\` |
|
|
5844
|
+
| \`src/style.css\` | 样式。颜色 / 字号 / 圆角走宿主的设计 token(\`--bg\` \`--fg\` \`--line\` \`--accent\` \`--t-ui\` \`--r-md\` …),不写 hex 与 \`.dark\` 分支;主题真源是 \`<html class="dark">\`,token 会自己换值 |
|
|
5158
5845
|
| \`widget.json\` | name / scope / version / sampleData;dataSchema 由 build 生成,不用手写 |
|
|
5159
5846
|
| \`scripts/build.mjs\` | 全内联构建配置${c.runtime ? ";`RUNTIME_PATHS` 是平台版本档 runtime 的 URL" : ""} |
|
|
5160
5847
|
|
|
@@ -5198,7 +5885,7 @@ async function fetchRuntime(api) {
|
|
|
5198
5885
|
try {
|
|
5199
5886
|
client = await api();
|
|
5200
5887
|
} catch (err) {
|
|
5201
|
-
throw new CliError(`shared 档要从 server 取 runtime 地址,但 CLI 未配置:${err instanceof Error ? err.message : String(err)}`, "先 jdu
|
|
5888
|
+
throw new CliError(`shared 档要从 server 取 runtime 地址,但 CLI 未配置:${err instanceof Error ? err.message : String(err)}`, "先 jdu login,或改用 --runtime custom(自带 react 全内联,不需要 server)");
|
|
5202
5889
|
}
|
|
5203
5890
|
const res = await client.getJson("/api/runtimes");
|
|
5204
5891
|
const name = typeof res?.default === "string" ? res.default : null;
|
|
@@ -5262,10 +5949,30 @@ async function client() {
|
|
|
5262
5949
|
function collectTag(value, previous) {
|
|
5263
5950
|
return [...previous ?? [], value];
|
|
5264
5951
|
}
|
|
5952
|
+
var VERSION = "0.5.0";
|
|
5265
5953
|
var program = new Command();
|
|
5266
|
-
program.name("jdu").description("jiandu 命令行").version(
|
|
5267
|
-
program.command("
|
|
5268
|
-
const result = await
|
|
5954
|
+
program.name("jdu").description("jiandu 命令行").version(VERSION).showHelpAfterError();
|
|
5955
|
+
program.command("deploy").description("把一台简牍部署到你自己的 Cloudflare 账号(Worker + Durable Object,Free 计划够用)").option("--owner <name>", "管理员用户名(JIANDU_AUTH_OWNER)。首次部署必填;升级时不给就保持账号上现有的值").option("--name <name>", "Worker 名字,决定 workers.dev 的子域名前缀", "jiandu").option("--dist <dir>", "用本地构建的产物代替 npm 包(开发用,指向 server/dist-cf)").option("--dry-run", "只校验配置与产物,不上传、不碰你的账号", false).option("--json", "机器可读输出(agent 联动用)", false).action(async (opts) => {
|
|
5956
|
+
const result = await runDeploy(opts);
|
|
5957
|
+
if (opts.json) {
|
|
5958
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
5959
|
+
return;
|
|
5960
|
+
}
|
|
5961
|
+
if (opts.dryRun) {
|
|
5962
|
+
process.stdout.write("校验通过:产物与 wrangler 配置都没问题,没有上传任何东西\n");
|
|
5963
|
+
return;
|
|
5964
|
+
}
|
|
5965
|
+
process.stdout.write(`已部署 ${result.url}\n`);
|
|
5966
|
+
if (result.setupSecret === null) {
|
|
5967
|
+
process.stdout.write("实例本来就在跑,secret 与配置原样保留(这是一次升级)\n");
|
|
5968
|
+
return;
|
|
5969
|
+
}
|
|
5970
|
+
process.stdout.write(`\n第一把钥匙(只在这里出现这一次,记下来):\n ${result.setupSecret}\n`);
|
|
5971
|
+
process.stdout.write(`\n打开这个链接绑 passkey:\n ${result.setupUrl}\n`);
|
|
5972
|
+
if (result.loggedIn) process.stdout.write("\njdu 已经连上它了,可以直接 jdu push\n");
|
|
5973
|
+
});
|
|
5974
|
+
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) => {
|
|
5975
|
+
const result = await runLogin({
|
|
5269
5976
|
...opts,
|
|
5270
5977
|
browser: opts.browser && !opts.json
|
|
5271
5978
|
});
|
|
@@ -5276,24 +5983,9 @@ program.command("init").description("选定部署目标并初始化本地配置
|
|
|
5276
5983
|
process.stdout.write(`目标 ${result.target} · ${result.server}(auth: ${result.authProvider})\n`);
|
|
5277
5984
|
process.stdout.write(`配置已写入 ${result.configPath}\n`);
|
|
5278
5985
|
process.stdout.write(result.loggedIn ? "凭据就绪,可以直接 jdu push。\n" : `下一步:${result.next ?? ""}\n`);
|
|
5279
|
-
});
|
|
5280
|
-
program.command("login").description("登录到 jiandu server(forward-auth 默认打开浏览器走 SSO;--user 则直连注入身份头)").requiredOption("--server <url>", "jiandu server 地址").option("--token <token>", "直接保存访问 token(token provider / 跳过浏览器)").option("--user <owner>", "forward-auth:以指定 owner 直连(请求注入 X-Forwarded-User 头,跳过 SSO)").option("--proxy-secret <secret>", "forward-auth:网关共享密钥(请求注入 X-Jiandu-Proxy-Secret 头)").option("--issuer <url>", "OIDC issuer(缺省读 healthz.oidc 或 JIANDU_OIDC_ISSUER)").option("--client-id <id>", "OIDC client id(缺省读 healthz.oidc 或 JIANDU_OIDC_CLIENT_ID)").action(async (opts) => {
|
|
5281
|
-
const result = await runLogin({
|
|
5282
|
-
server: opts.server,
|
|
5283
|
-
token: opts.token,
|
|
5284
|
-
user: opts.user,
|
|
5285
|
-
proxySecret: opts.proxySecret,
|
|
5286
|
-
issuer: opts.issuer,
|
|
5287
|
-
clientId: opts.clientId
|
|
5288
|
-
});
|
|
5289
|
-
process.stdout.write(`${result.configPath}\n`);
|
|
5290
|
-
if (result.source === "anonymous") process.stderr.write("这个 server 是匿名模式,无需登录。\n");
|
|
5291
|
-
if (result.source === "forward-auth") process.stderr.write("已保存 forward-auth 直连身份(X-Forwarded-User)。\n");
|
|
5292
|
-
if (result.source === "oidc-cache") process.stderr.write("已复用本机 SSO credential。\n");
|
|
5293
|
-
if (result.source === "oidc-browser") process.stderr.write("SSO 登录成功。\n");
|
|
5294
5986
|
if (result.warning) process.stderr.write(`jdu: ${result.warning}\n`);
|
|
5295
5987
|
});
|
|
5296
|
-
program.command("push").argument("<entry.md>", "入口 markdown 文件").description("递归收集本地引用、增量上传并发布一个新版本").option("--title <title>", "文档标题,默认取首个一级标题").option("--visibility <v>", `${VISIBILITIES.join(" | ")}(新文档默认 private;更新已有文档时不传则保持原值)`).option("--id <docId>", "复用已有文档 id(发布新版本)").option("--tag <name>", "打标签,可重复;不传则保持原有标签", collectTag, void 0).option("--official", "上架到官方知识库(需管理员;同时公开)").action(async (entry, opts) => {
|
|
5988
|
+
program.command("push").argument("<entry.md>", "入口 markdown 文件").description("递归收集本地引用、增量上传并发布一个新版本").option("--title <title>", "文档标题,默认取首个一级标题").option("--visibility <v>", `${VISIBILITIES.join(" | ")}(新文档默认 private;更新已有文档时不传则保持原值)`).option("--id <docId>", "复用已有文档 id(发布新版本)").option("--tag <name>", "打标签,可重复;不传则保持原有标签", collectTag, void 0).option("--official", "上架到官方知识库(需管理员;同时公开)").option("--team <teamId>", "进团队(需是该团队成员;传空串移出团队;不传则保持原值)").action(async (entry, opts) => {
|
|
5297
5989
|
const res = await pushDoc(await client(), entry, opts);
|
|
5298
5990
|
process.stdout.write(`${res.url}\n`);
|
|
5299
5991
|
});
|
|
@@ -5420,10 +6112,10 @@ template.command("push").argument("<file.md>", "模板文件").description("发
|
|
|
5420
6112
|
const res = await pushTemplate(await client(), file, opts);
|
|
5421
6113
|
process.stdout.write(`${res.url}\n`);
|
|
5422
6114
|
});
|
|
5423
|
-
program.command("mcp").description("以 MCP server(stdio
|
|
6115
|
+
program.command("mcp").description("以 MCP server(stdio)暴露文档 / 评论 / widget / 语法工具,模板同时暴露为 prompts,给本机 agent 用").action(async () => {
|
|
5424
6116
|
await runMcp(await client());
|
|
5425
6117
|
});
|
|
5426
|
-
program.command("share").argument("<docId>").description("
|
|
6118
|
+
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) => {
|
|
5427
6119
|
const api = await client();
|
|
5428
6120
|
const url = `/api/docs/${encodeURIComponent(docId)}`;
|
|
5429
6121
|
if (opts.readers) {
|
|
@@ -5441,9 +6133,12 @@ program.command("share").argument("<docId>").description("改可见性,或管
|
|
|
5441
6133
|
process.stdout.write(`${readers.length > 0 ? readers.join("\n") : "(读者池为空)"}\n`);
|
|
5442
6134
|
return;
|
|
5443
6135
|
}
|
|
5444
|
-
|
|
5445
|
-
|
|
5446
|
-
|
|
6136
|
+
const payload = {};
|
|
6137
|
+
if (opts.visibility !== void 0) payload.visibility = opts.visibility;
|
|
6138
|
+
if (opts.team !== void 0) payload.team = opts.team === false ? "" : opts.team;
|
|
6139
|
+
if (Object.keys(payload).length === 0) throw new CliError("share 需要 --visibility / --team / --no-team / --to / --revoke / --readers 之一");
|
|
6140
|
+
const res = await api.putJson(`${url}/visibility`, payload);
|
|
6141
|
+
process.stdout.write(`可见性 ${String(res.visibility)}${res.team ? `,团队 ${String(res.team)}` : ""}\n`);
|
|
5447
6142
|
});
|
|
5448
6143
|
var tokenCmd = program.command("token").description("CLI token(首启那枚与浏览器授权签发的)");
|
|
5449
6144
|
tokenCmd.command("list").description("列出我的 token;CURRENT 是本次请求用的那枚").action(async () => {
|
|
@@ -5470,10 +6165,102 @@ tokenCmd.command("list").description("列出我的 token;CURRENT 是本次请
|
|
|
5470
6165
|
}
|
|
5471
6166
|
]);
|
|
5472
6167
|
});
|
|
6168
|
+
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) => {
|
|
6169
|
+
const res = await (await client()).postJson("/api/tokens", { label: opts.label });
|
|
6170
|
+
if (typeof res.token !== "string") throw new CliError("server 没有返回 token");
|
|
6171
|
+
process.stderr.write(`id=${String(res.id)} label=${String(res.label)}(明文只显示这一次)\n`);
|
|
6172
|
+
process.stdout.write(`${res.token}\n`);
|
|
6173
|
+
});
|
|
5473
6174
|
tokenCmd.command("rm").argument("<id>", "jdu token list 里的 ID").description("吊销一枚 token(正在使用的那枚不能删)").action(async (id) => {
|
|
5474
6175
|
await (await client()).deleteJson(`/api/tokens/${encodeURIComponent(id)}`);
|
|
5475
6176
|
process.stdout.write(`revoked ${id}\n`);
|
|
5476
6177
|
});
|
|
6178
|
+
var teamCmd = program.command("team").description("团队:共享阅读与评论的空间(实例管理员建团队,团队管理员按用户名加人;文档进团队用 push/share --team)");
|
|
6179
|
+
teamCmd.command("list").description("我所属的团队(实例管理员:全部团队)").action(async () => {
|
|
6180
|
+
printTable(asRows(await (await client()).getJson("/api/teams"), "teams"), [
|
|
6181
|
+
{
|
|
6182
|
+
key: "id",
|
|
6183
|
+
header: "ID"
|
|
6184
|
+
},
|
|
6185
|
+
{
|
|
6186
|
+
key: "name",
|
|
6187
|
+
header: "NAME"
|
|
6188
|
+
},
|
|
6189
|
+
{
|
|
6190
|
+
key: "role",
|
|
6191
|
+
header: "ROLE"
|
|
6192
|
+
},
|
|
6193
|
+
{
|
|
6194
|
+
key: "members",
|
|
6195
|
+
header: "MEMBERS"
|
|
6196
|
+
},
|
|
6197
|
+
{
|
|
6198
|
+
key: "docs",
|
|
6199
|
+
header: "DOCS"
|
|
6200
|
+
}
|
|
6201
|
+
]);
|
|
6202
|
+
});
|
|
6203
|
+
teamCmd.command("create").argument("<name>", "团队名").description("新建团队(实例管理员);你自动成为团队管理员,stdout 是团队 id").action(async (name) => {
|
|
6204
|
+
const res = await (await client()).postJson("/api/teams", { name });
|
|
6205
|
+
process.stdout.write(`${res.id}\n`);
|
|
6206
|
+
process.stderr.write(`团队「${res.name}」已创建:jdu team add ${res.id} <用户名> 加人\n`);
|
|
6207
|
+
});
|
|
6208
|
+
teamCmd.command("add").argument("<team>", "团队 id").argument("<user>", "成员用户名").option("--admin", "设为团队管理员(可加人 / 踢人 / 改名)").description("把成员加进团队;对已在团队的人 = 改角色").action(async (team, user, opts) => {
|
|
6209
|
+
await (await client()).postJson(`/api/teams/${encodeURIComponent(team)}/members`, {
|
|
6210
|
+
owner: user,
|
|
6211
|
+
role: opts.admin ? "admin" : "member"
|
|
6212
|
+
});
|
|
6213
|
+
process.stdout.write(`added ${user} → ${team}${opts.admin ? " (admin)" : ""}\n`);
|
|
6214
|
+
});
|
|
6215
|
+
teamCmd.command("rm").argument("<team>", "团队 id").argument("<user>", "成员用户名").description("把成员移出团队(团队管理员)").action(async (team, user) => {
|
|
6216
|
+
await (await client()).postJson(`/api/teams/${encodeURIComponent(team)}/members/remove`, { owner: user });
|
|
6217
|
+
process.stdout.write(`removed ${user} ← ${team}\n`);
|
|
6218
|
+
});
|
|
6219
|
+
teamCmd.command("leave").argument("<team>", "团队 id").description("自己退出团队").action(async (team) => {
|
|
6220
|
+
await (await client()).postJson(`/api/teams/${encodeURIComponent(team)}/members/remove`, {});
|
|
6221
|
+
process.stdout.write(`left ${team}\n`);
|
|
6222
|
+
});
|
|
6223
|
+
var memberCmd = program.command("member").description("成员(password 鉴权,管理员专用):准入只有邀请,被邀请者用链接自己绑 passkey");
|
|
6224
|
+
memberCmd.command("list").description("成员名单:pending = 已邀请未绑定,bound = 已绑定 passkey").action(async () => {
|
|
6225
|
+
printTable(asRows(await (await client()).getJson("/api/members"), "members"), [
|
|
6226
|
+
{
|
|
6227
|
+
key: "owner",
|
|
6228
|
+
header: "OWNER"
|
|
6229
|
+
},
|
|
6230
|
+
{
|
|
6231
|
+
key: "status",
|
|
6232
|
+
header: "STATUS"
|
|
6233
|
+
},
|
|
6234
|
+
{
|
|
6235
|
+
key: "invitedAt",
|
|
6236
|
+
header: "INVITED"
|
|
6237
|
+
},
|
|
6238
|
+
{
|
|
6239
|
+
key: "boundAt",
|
|
6240
|
+
header: "BOUND"
|
|
6241
|
+
},
|
|
6242
|
+
{
|
|
6243
|
+
key: "tokens",
|
|
6244
|
+
header: "TOKENS"
|
|
6245
|
+
},
|
|
6246
|
+
{
|
|
6247
|
+
key: "docs",
|
|
6248
|
+
header: "DOCS"
|
|
6249
|
+
}
|
|
6250
|
+
]);
|
|
6251
|
+
});
|
|
6252
|
+
memberCmd.command("invite").argument("<name>", "用户名:小写字母 / 数字开头,可含 . _ -,≤30 位").option("--reset", "已绑定的成员重置 passkey(清掉凭据,所有设备与会话即刻失效)").description("签一张 7 天有效的一次性邀请链接,自己交给对方;对方打开后绑定 passkey 即成为成员").action(async (name, opts) => {
|
|
6253
|
+
const res = await (await client()).postJson("/api/members/invite", {
|
|
6254
|
+
owner: name,
|
|
6255
|
+
reset: opts.reset === true
|
|
6256
|
+
});
|
|
6257
|
+
process.stdout.write(`${res.url}\n`);
|
|
6258
|
+
process.stderr.write(`邀请 ${res.owner},${new Date(res.expiresAt).toLocaleString()} 前有效;链接只显示这一次\n`);
|
|
6259
|
+
});
|
|
6260
|
+
memberCmd.command("rm").argument("<name>", "用户名").description("移除成员:删 passkey 与其全部 token;文档 / 评论 / 标签不动,同名重邀即接回").action(async (name) => {
|
|
6261
|
+
await (await client()).postJson("/api/members/remove", { owner: name });
|
|
6262
|
+
process.stdout.write(`removed ${name}\n`);
|
|
6263
|
+
});
|
|
5477
6264
|
var officialCmd = program.command("official").description("官方知识库上架");
|
|
5478
6265
|
officialCmd.command("list").description("列出已上架的官方文档").action(async () => {
|
|
5479
6266
|
printTable(asRows(await (await client()).getJson("/api/official"), "docs"), [{
|