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