@monoedge/jdu-cli 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +953 -264
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -5,8 +5,8 @@ import path, { basename, dirname, extname, join, relative, resolve, sep } from "
5
5
  import fs, { chmodSync, createReadStream, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
6
6
  import process$1 from "node:process";
7
7
  import { stripVTControlCharacters } from "node:util";
8
- import { createHash, randomBytes } from "node:crypto";
9
8
  import { homedir, hostname, tmpdir } from "node:os";
9
+ import { createHash, randomBytes } from "node:crypto";
10
10
  import { createServer } from "node:http";
11
11
  import { createInterface } from "node:readline";
12
12
  import { readFile, readdir } from "node:fs/promises";
@@ -2965,75 +2965,6 @@ function useColor() {
2965
2965
  }
2966
2966
  new Command();
2967
2967
  //#endregion
2968
- //#region src/blobs.ts
2969
- /** 流式算 hash,避免把大图整份读进内存。 */
2970
- function sha256File(path) {
2971
- return new Promise((res, rej) => {
2972
- const h = createHash("sha256");
2973
- const rs = createReadStream(path);
2974
- rs.on("error", rej);
2975
- rs.on("data", (chunk) => h.update(chunk));
2976
- rs.on("end", () => res(h.digest("hex")));
2977
- });
2978
- }
2979
- var MIME$1 = Object.freeze({
2980
- ".md": "text/markdown; charset=utf-8",
2981
- ".markdown": "text/markdown; charset=utf-8",
2982
- ".txt": "text/plain; charset=utf-8",
2983
- ".json": "application/json",
2984
- ".js": "text/javascript; charset=utf-8",
2985
- ".mjs": "text/javascript; charset=utf-8",
2986
- ".css": "text/css; charset=utf-8",
2987
- ".html": "text/html; charset=utf-8",
2988
- ".svg": "image/svg+xml",
2989
- ".png": "image/png",
2990
- ".jpg": "image/jpeg",
2991
- ".jpeg": "image/jpeg",
2992
- ".gif": "image/gif",
2993
- ".webp": "image/webp",
2994
- ".avif": "image/avif",
2995
- ".ico": "image/x-icon",
2996
- ".mp4": "video/mp4",
2997
- ".webm": "video/webm",
2998
- ".mov": "video/quicktime",
2999
- ".mp3": "audio/mpeg",
3000
- ".wav": "audio/wav",
3001
- ".pdf": "application/pdf",
3002
- ".zip": "application/zip",
3003
- ".woff2": "font/woff2"
3004
- });
3005
- function guessMime(path) {
3006
- return MIME$1[extname(path).toLowerCase()] ?? "application/octet-stream";
3007
- }
3008
- /**
3009
- * 增量上传:先用 hash 清单跟 server 协商,只 PUT 缺失的。
3010
- * server 返回体不可解析时按「全都缺」处理 —— 宁可多传也不能漏传导致文档半残。
3011
- */
3012
- async function syncBlobs(api, entries) {
3013
- const byHash = /* @__PURE__ */ new Map();
3014
- for (const e of entries) if (!byHash.has(e.hash)) byHash.set(e.hash, e.path);
3015
- const hashes = [...byHash.keys()];
3016
- if (hashes.length === 0) return {
3017
- total: 0,
3018
- uploaded: [],
3019
- reused: 0
3020
- };
3021
- const res = await api.postJson("/api/blobs/check", { hashes });
3022
- const missing = Array.isArray(res?.missing) ? res.missing.filter((h) => typeof h === "string") : hashes;
3023
- const uploaded = [];
3024
- for (const hash of missing) {
3025
- const path = byHash.get(hash);
3026
- if (path === void 0) continue;
3027
- await api.putBytes(`/api/blobs/${hash}`, readFileSync(path), guessMime(path));
3028
- uploaded.push(hash);
3029
- }
3030
- return {
3031
- total: hashes.length,
3032
- uploaded,
3033
- reused: hashes.length - uploaded.length
3034
- };
3035
- }
3036
- //#endregion
3037
2968
  //#region src/errors.ts
3038
2969
  /**
3039
2970
  * 面向用户的可读错误。
@@ -3241,6 +3172,31 @@ function configPath() {
3241
3172
  const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".config");
3242
3173
  return join(base, "jiandu", "config.json");
3243
3174
  }
3175
+ /**
3176
+ * `~/.config/jiandu/cf.json`:`jdu deploy` / `jdu access` 用的 Cloudflare 账号凭据
3177
+ * (OAuth 拿到的 access / refresh token + account id + 邮箱)。与站点凭据分开存,
3178
+ * 因为它是「这个 Cloudflare 账号」的钥匙,不属于任何一台简牍实例。
3179
+ */
3180
+ function cfConfigPath() {
3181
+ const xdg = process.env.XDG_CONFIG_HOME?.trim();
3182
+ const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".config");
3183
+ return join(base, "jiandu", "cf.json");
3184
+ }
3185
+ /** 缺文件返回空对象;解析失败一律当作没有(重新登录即可),不阻塞命令。 */
3186
+ function readCfConfig() {
3187
+ try {
3188
+ const parsed = JSON.parse(readFileSync(cfConfigPath(), "utf8"));
3189
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
3190
+ } catch {
3191
+ return {};
3192
+ }
3193
+ }
3194
+ function writeCfConfig(next) {
3195
+ const path = cfConfigPath();
3196
+ mkdirSync(dirname(path), { recursive: true });
3197
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, { mode: 384 });
3198
+ return path;
3199
+ }
3244
3200
  function readOidc(raw) {
3245
3201
  if (raw === null || typeof raw !== "object") return void 0;
3246
3202
  const obj = raw;
@@ -3272,6 +3228,7 @@ function readStoredConfig() {
3272
3228
  const out = {};
3273
3229
  if (typeof obj.server === "string") out.server = obj.server;
3274
3230
  if (typeof obj.token === "string") out.token = obj.token;
3231
+ if (obj.cfAccess === true) out.cfAccess = true;
3275
3232
  if (typeof obj.user === "string") out.user = obj.user;
3276
3233
  if (typeof obj.proxySecret === "string") out.proxySecret = obj.proxySecret;
3277
3234
  const oidc = readOidc(obj.oidc);
@@ -3283,6 +3240,7 @@ function saveConfig(next) {
3283
3240
  mkdirSync(dirname(path), { recursive: true });
3284
3241
  const body = { server: next.server };
3285
3242
  if (next.token !== void 0) body.token = next.token;
3243
+ if (next.cfAccess === true) body.cfAccess = true;
3286
3244
  if (next.user !== void 0) body.user = next.user;
3287
3245
  if (next.proxySecret !== void 0) body.proxySecret = next.proxySecret;
3288
3246
  if (next.oidc !== void 0) body.oidc = next.oidc;
@@ -3300,16 +3258,36 @@ function loadConfig() {
3300
3258
  const cfg = {
3301
3259
  server,
3302
3260
  token: rawToken && rawToken.length > 0 ? rawToken : void 0,
3261
+ ...stored.cfAccess === true ? { cfAccess: true } : {},
3303
3262
  user: user && user.length > 0 ? user : void 0,
3304
3263
  proxySecret: proxySecret && proxySecret.length > 0 ? proxySecret : void 0
3305
3264
  };
3306
3265
  if (stored.oidc) cfg.oidc = stored.oidc;
3307
3266
  return cfg;
3308
3267
  }
3268
+ /** 未验签地读 JWT 的 exp(只用于本地提前给出「去重新登录」的提示;验签永远在 server 侧)。 */
3269
+ function jwtExpiry(token) {
3270
+ const parts = token.split(".");
3271
+ if (parts.length !== 3 || parts[1] === void 0) return null;
3272
+ try {
3273
+ const json = Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
3274
+ const payload = JSON.parse(json);
3275
+ const exp = payload !== null && typeof payload === "object" ? payload["exp"] : void 0;
3276
+ return typeof exp === "number" ? exp * 1e3 : null;
3277
+ } catch {
3278
+ return null;
3279
+ }
3280
+ }
3309
3281
  /** 运行时配置:没有显式 token 时,用已保存 / 环境变量里的 OIDC 客户端去续期。 */
3310
3282
  async function loadRuntimeConfig() {
3311
3283
  const cfg = loadConfig();
3312
- if (cfg.token) return cfg;
3284
+ if (cfg.token) {
3285
+ if (cfg.cfAccess === true) {
3286
+ const exp = jwtExpiry(cfg.token);
3287
+ if (exp !== null && exp <= Date.now()) throw new CliError(`Cloudflare Access 会话已过期(${cfg.server})`, `重新执行 jdu login --server ${cfg.server},浏览器里的 Cloudflare 会话还在,点一下就续上`);
3288
+ }
3289
+ return cfg;
3290
+ }
3313
3291
  const issuer = cfg.oidc?.issuer ?? process.env["JIANDU_OIDC_ISSUER"]?.trim();
3314
3292
  const clientId = cfg.oidc?.clientId ?? process.env["JIANDU_OIDC_CLIENT_ID"]?.trim();
3315
3293
  if (!issuer || !clientId) return cfg;
@@ -3325,139 +3303,433 @@ async function loadRuntimeConfig() {
3325
3303
  return cfg;
3326
3304
  }
3327
3305
  //#endregion
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(" ")}`;
3306
+ //#region src/cf-oauth.ts
3307
+ /**
3308
+ * Cloudflare 账号的 OAuth:`jdu deploy` / `jdu access` 拿一把能配 Access 的 token。
3309
+ *
3310
+ * 为什么不复用 `wrangler login` 的结果:wrangler OAuth 客户端请求的 scope 里没有 Zero Trust,
3311
+ * 实测拿它的 token 打 `POST /accounts/<id>/access/apps` 是 `1010 auth.forbidden`。
3312
+ * Cloudflare 自己的 CLI OAuth 客户端(wrangler 包里以 `CLOUDFLARE_CLIENT_ID` 常量存在)
3313
+ * 的 scope 集合里有 `access:read` / `access:write`,专门干这件事。
3314
+ *
3315
+ * 这条路需要人在浏览器里点一次同意(与 `wrangler login` 同类);想跳过就用
3316
+ * `--cf-token` 或 `CLOUDFLARE_API_TOKEN` 给一把带 Access 权限的 API token。
3317
+ *
3318
+ * 回调固定 `http://localhost:8877/oauth/callback`:OAuth 的 redirect_uri 是精确匹配的,
3319
+ * 不能用随机端口。PKCE(S256)保证 code 拿到也用不了。
3320
+ */
3321
+ var CF_AUTH_URL = "https://dash.cloudflare.com/oauth2/auth";
3322
+ var CF_TOKEN_URL = "https://dash.cloudflare.com/oauth2/token";
3323
+ /** Cloudflare CLI(cf)的 OAuth 客户端;`CLOUDFLARE_CLIENT_ID` 可覆盖(测试 / 换客户端)。 */
3324
+ var CF_CLIENT_ID_ENV = "CLOUDFLARE_CLIENT_ID";
3325
+ var CF_CLIENT_ID_DEFAULT = "cbca97e7-c331-4cdd-8fd8-e25a451b98bf";
3326
+ /** OAuth 客户端里登记的回调地址,端口不能换。 */
3327
+ var CF_REDIRECT_URL = "http://localhost:8877/oauth/callback";
3328
+ var CF_CALLBACK_PORT = 8877;
3329
+ /**
3330
+ * 申请的 scope:Access 配置(access:read/write)+ wrangler 部署同样的 Workers 面。
3331
+ * 这份 token 会以 `CLOUDFLARE_API_TOKEN` 交给 wrangler deploy,所以 workers 那几项不能少。
3332
+ */
3333
+ var CF_SCOPES = [
3334
+ "offline_access",
3335
+ "user:read",
3336
+ "account:read",
3337
+ "access:read",
3338
+ "access:write",
3339
+ "workers:write",
3340
+ "workers_scripts:write",
3341
+ "workers_kv:write",
3342
+ "workers_routes:write",
3343
+ "workers_tail:read",
3344
+ "d1:write",
3345
+ "pages:write",
3346
+ "zone:read"
3347
+ ];
3348
+ function cfClientId(env = process.env) {
3349
+ const fromEnv = env[CF_CLIENT_ID_ENV]?.trim();
3350
+ return fromEnv && fromEnv.length > 0 ? fromEnv : CF_CLIENT_ID_DEFAULT;
3333
3351
  }
3334
- var QUOTE_MAX = 120;
3335
- function when(ms) {
3336
- return new Date(ms).toISOString().slice(0, 16).replace("T", " ");
3352
+ /** 浏览器授权码流程:开浏览器 本地回调收 code → 换 token。 */
3353
+ async function cfOAuthLogin(opts = {}) {
3354
+ const { verifier, challenge } = pkce();
3355
+ const state = randomBytes(16).toString("base64url");
3356
+ const authURL = new URL(CF_AUTH_URL);
3357
+ authURL.search = new URLSearchParams({
3358
+ response_type: "code",
3359
+ client_id: cfClientId(),
3360
+ redirect_uri: CF_REDIRECT_URL,
3361
+ scope: CF_SCOPES.join(" "),
3362
+ state,
3363
+ code_challenge: challenge,
3364
+ code_challenge_method: "S256"
3365
+ }).toString();
3366
+ const tok = await tokenRequest(CF_TOKEN_URL, {
3367
+ grant_type: "authorization_code",
3368
+ code: await new Promise((resolve, reject) => {
3369
+ const finish = (fn) => (value) => {
3370
+ clearTimeout(timer);
3371
+ server.close();
3372
+ fn(value);
3373
+ };
3374
+ const fail = finish((msg) => reject(new CliError(msg, "不想开浏览器就用 --cf-token <token>(Cloudflare API token,需要 Access 权限)")));
3375
+ const handler = callbackHandler(state, finish(resolve));
3376
+ const server = createServer((req, res) => {
3377
+ if (new URL(req.url ?? "/", "http://localhost").pathname !== "/oauth/callback") {
3378
+ res.writeHead(404).end();
3379
+ return;
3380
+ }
3381
+ handler(req, res);
3382
+ });
3383
+ const timer = setTimeout(() => fail("等待 Cloudflare 授权回调超时"), opts.timeoutMs ?? 3e5);
3384
+ server.on("error", (err) => {
3385
+ fail(`监听 ${CF_REDIRECT_URL} 失败(端口 8877 被占用?):${err.message}`);
3386
+ });
3387
+ server.listen(CF_CALLBACK_PORT, "localhost", () => {
3388
+ process.stderr.write(`在浏览器中授权 Cloudflare 账号:${authURL.toString()}\n`);
3389
+ (opts.open ?? openBrowser)(authURL.toString());
3390
+ });
3391
+ }),
3392
+ redirect_uri: CF_REDIRECT_URL,
3393
+ client_id: cfClientId(),
3394
+ code_verifier: verifier
3395
+ });
3396
+ return {
3397
+ accessToken: tok.access_token,
3398
+ refreshToken: tok.refresh_token,
3399
+ expiry: tok.expiry
3400
+ };
3337
3401
  }
3338
- /** owner UUID 且没有显示名时 authorLabel 为空串,退回原始 id 也比空着好认。 */
3339
- function who(c) {
3340
- return `${c.authorLabel || c.author}${c.authorType === "ai" ? " [ai]" : ""}`;
3402
+ /** refresh token 换新 access token;失败返回 null(调用方回落成重新登录)。 */
3403
+ async function cfRefresh(refreshToken) {
3404
+ try {
3405
+ const tok = await tokenRequest(CF_TOKEN_URL, {
3406
+ grant_type: "refresh_token",
3407
+ refresh_token: refreshToken,
3408
+ client_id: cfClientId()
3409
+ });
3410
+ return {
3411
+ accessToken: tok.access_token,
3412
+ refreshToken: tok.refresh_token ?? refreshToken,
3413
+ expiry: tok.expiry
3414
+ };
3415
+ } catch {
3416
+ return null;
3417
+ }
3341
3418
  }
3342
- function indent(text, pad) {
3343
- return text.split("\n").map((line) => `${pad}${line}`).join("\n");
3419
+ //#endregion
3420
+ //#region src/cf-api.ts
3421
+ /**
3422
+ * Cloudflare API 薄封装 + Access(Zero Trust)的幂等配置。`jdu deploy` 与 `jdu access` 共用。
3423
+ *
3424
+ * 只做两件事:把 `/client/v4` 的信封拆开(`{success, result, errors}` → 抛 CliError 或返回 result),
3425
+ * 以及把「建 team / 确保 Cloudflare IdP / upsert 保护 /p 的应用与邮箱策略」写成幂等函数。
3426
+ *
3427
+ * 凭据优先级:`--cf-token` / `CLOUDFLARE_API_TOKEN` > 本地缓存(~/.config/jiandu/cf.json)> 浏览器 OAuth。
3428
+ * 缓存的 access token 过期就用 refresh token 换;换不动就重跑 OAuth。
3429
+ */
3430
+ var API = "https://api.cloudflare.com/client/v4";
3431
+ function firstError(body) {
3432
+ const err = body.errors?.[0];
3433
+ return {
3434
+ code: err?.code ?? 0,
3435
+ message: err?.message ?? err?.error ?? "未知错误"
3436
+ };
3437
+ }
3438
+ /** 权限不够(Cloudflare 用 1010/10000/9109 表达)时给一句能干的话,别让人对着裸错误码发愣。 */
3439
+ function hintFor(status, code) {
3440
+ if (code === 1010 || code === 9109 || code === 1e4 && status === 403) return "这把 token 没有 Zero Trust(Access)权限:用浏览器登录一次 jdu deploy,或 --cf-token 给一把带「Access: Apps and Policies / Organizations, Identity Providers and Groups」编辑权限的 API token";
3441
+ if (status === 401) return "Cloudflare 凭据已失效:重新执行 jdu deploy(会打开浏览器授权)";
3442
+ }
3443
+ var CfApi = class {
3444
+ credentials;
3445
+ onTokens;
3446
+ fetchImpl;
3447
+ constructor(opts) {
3448
+ this.credentials = opts.credentials;
3449
+ this.onTokens = opts.onTokens;
3450
+ this.fetchImpl = opts.fetchImpl ?? ((input, init) => fetch(input, init));
3451
+ }
3452
+ get accountId() {
3453
+ return this.credentials.accountId;
3454
+ }
3455
+ get email() {
3456
+ return this.credentials.email;
3457
+ }
3458
+ get tokens() {
3459
+ return this.credentials.tokens;
3460
+ }
3461
+ /** 401 时用 refresh token 续一次再重试;续不动就抛错让人重新登录。 */
3462
+ async request(method, path, body) {
3463
+ let res = await this.send(method, path, body);
3464
+ if (res.status === 401 && this.credentials.tokens.refreshToken) {
3465
+ const refreshed = await cfRefresh(this.credentials.tokens.refreshToken);
3466
+ if (refreshed) {
3467
+ this.credentials.tokens = refreshed;
3468
+ this.onTokens?.(refreshed);
3469
+ res = await this.send(method, path, body);
3470
+ }
3471
+ }
3472
+ const text = await res.text();
3473
+ let parsed = {};
3474
+ try {
3475
+ parsed = JSON.parse(text);
3476
+ } catch {}
3477
+ if (!res.ok || parsed.success === false) {
3478
+ const err = firstError(parsed);
3479
+ const detail = err.message !== "未知错误" ? err.message : text.slice(0, 200);
3480
+ throw new CliError(`Cloudflare API ${method} ${path} 失败(HTTP ${res.status}${err.code ? `,code ${err.code}` : ""}):${detail}`, hintFor(res.status, err.code));
3481
+ }
3482
+ return parsed.result;
3483
+ }
3484
+ send(method, path, body) {
3485
+ return this.fetchImpl(`${API}${path}`, {
3486
+ method,
3487
+ headers: {
3488
+ authorization: `Bearer ${this.credentials.tokens.accessToken}`,
3489
+ ...body === void 0 ? {} : { "content-type": "application/json" }
3490
+ },
3491
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
3492
+ });
3493
+ }
3494
+ };
3495
+ function isFresh(tokens) {
3496
+ const at = Date.parse(tokens.expiry);
3497
+ return Number.isFinite(at) && at - 6e4 > Date.now();
3344
3498
  }
3345
3499
  /**
3346
- * 线程列表。stdout 纯文本、一线程一段,给 agent 直接读:
3347
- * 首行 `<threadId> <blockId> <open|resolved> <作者> <时间> [v<seq>]`,引文一行 `> …`,正文缩进两格,回复 `↳` 缩进。
3500
+ * 拿一份能调 Cloudflare API 的凭据。`--cf-token` / 环境变量优先(CI / 已有 API token 的人),
3501
+ * 其次本地缓存(过期先 refresh),最后才开浏览器走 OAuth。
3502
+ *
3503
+ * 拿到 token 后顺手把 account id / 邮箱补齐(API token 本身不带这两个信息):
3504
+ * 后续所有 API 路径都要 account id,`jdu access` 也就不需要再问。
3348
3505
  */
3349
- async function listComments(api, docId, opts) {
3350
- const res = await api.getJson(`/api/docs/${encodeURIComponent(docId)}/comments`);
3351
- const all = Array.isArray(res?.comments) ? res.comments : [];
3352
- const roots = all.filter((c) => c.parentId === null && (opts.all || !c.resolved));
3353
- if (roots.length === 0) {
3354
- process.stdout.write(`${opts.all ? "(无评论)" : "(无未处理评论)"}\n`);
3355
- return;
3506
+ async function resolveCfCredentials(opts) {
3507
+ const env = opts.env ?? process.env;
3508
+ const given = (opts.cfToken ?? env["JIANDU_CF_TOKEN"] ?? env["CLOUDFLARE_API_TOKEN"] ?? "").trim();
3509
+ const stored = readCfConfig();
3510
+ let source = "oauth";
3511
+ let tokens;
3512
+ if (given !== "") {
3513
+ tokens = {
3514
+ accessToken: given,
3515
+ expiry: new Date(Date.now() + 31536e6).toISOString()
3516
+ };
3517
+ source = "flag";
3518
+ } else {
3519
+ const cached = tokensOf(stored);
3520
+ if (cached && isFresh(cached)) {
3521
+ tokens = cached;
3522
+ source = "cache";
3523
+ } else if (cached?.refreshToken) {
3524
+ const refreshed = await cfRefresh(cached.refreshToken);
3525
+ if (refreshed) {
3526
+ tokens = refreshed;
3527
+ source = "cache";
3528
+ writeCfConfig({
3529
+ ...stored,
3530
+ ...serialize(refreshed)
3531
+ });
3532
+ }
3533
+ }
3356
3534
  }
3357
- const replies = /* @__PURE__ */ new Map();
3358
- for (const c of all) {
3359
- if (c.parentId === null) continue;
3360
- const list = replies.get(c.parentId) ?? [];
3361
- list.push(c);
3362
- replies.set(c.parentId, list);
3535
+ if (tokens === void 0) {
3536
+ if (opts.allowBrowser === false) throw new CliError("没有可用的 Cloudflare 凭据(本地缓存过期,也没给 --cf-token)", "传 --cf-token <token>(带 Access 权限的 Cloudflare API token),或不带 --json 跑一次让浏览器授权");
3537
+ tokens = await cfOAuthLogin(opts.open ? { open: opts.open } : {});
3538
+ writeCfConfig({
3539
+ ...readCfConfig(),
3540
+ ...serialize(tokens)
3541
+ });
3542
+ }
3543
+ const credentials = {
3544
+ accountId: stored.accountId ?? "",
3545
+ email: stored.email ?? "",
3546
+ tokens,
3547
+ source
3548
+ };
3549
+ if (credentials.accountId === "" || credentials.email === "") {
3550
+ const identity = await readIdentity(new CfApi({
3551
+ credentials,
3552
+ ...opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}
3553
+ }));
3554
+ credentials.accountId = identity.accountId;
3555
+ credentials.email = identity.email;
3556
+ writeCfConfig({
3557
+ ...readCfConfig(),
3558
+ accountId: identity.accountId,
3559
+ email: identity.email
3560
+ });
3363
3561
  }
3562
+ return credentials;
3563
+ }
3564
+ function tokensOf(stored) {
3565
+ if (typeof stored.accessToken !== "string" || stored.accessToken === "") return null;
3566
+ const tokens = {
3567
+ accessToken: stored.accessToken,
3568
+ expiry: typeof stored.expiry === "string" ? stored.expiry : ""
3569
+ };
3570
+ if (typeof stored.refreshToken === "string" && stored.refreshToken !== "") tokens.refreshToken = stored.refreshToken;
3571
+ return tokens;
3572
+ }
3573
+ function serialize(tokens) {
3574
+ const out = {
3575
+ accessToken: tokens.accessToken,
3576
+ expiry: tokens.expiry
3577
+ };
3578
+ if (tokens.refreshToken !== void 0) out.refreshToken = tokens.refreshToken;
3579
+ return out;
3580
+ }
3581
+ /** `GET /user` + `GET /accounts`:只认第一个账号(个人自部署形态就是单人单账号)。 */
3582
+ async function readIdentity(api) {
3583
+ const user = await api.request("GET", "/user");
3584
+ const accountId = (await api.request("GET", "/accounts")).find((a) => typeof a.id === "string" && a.id !== "")?.id ?? "";
3585
+ const email = user.email ?? "";
3586
+ if (accountId === "") throw new CliError("读不到 Cloudflare account id", "GET /accounts 返回空");
3587
+ if (email === "") throw new CliError("读不到 Cloudflare 账号邮箱", "GET /user 没有 email");
3588
+ return {
3589
+ accountId,
3590
+ email: email.toLowerCase()
3591
+ };
3592
+ }
3593
+ /** 应用策略里所有 email 规则(`{email:{email}}`),小写去重、保持原顺序。 */
3594
+ function emailsOf(policy) {
3364
3595
  const out = [];
3365
- for (const root of roots) {
3366
- const head = [
3367
- root.id,
3368
- root.blockId ?? "全文",
3369
- root.resolved ? "resolved" : "open",
3370
- who(root),
3371
- when(root.createdAt)
3372
- ];
3373
- if (root.versionSeq !== null) head.push(`v${root.versionSeq}`);
3374
- out.push(head.join(" "));
3375
- const quote = root.selection?.quote;
3376
- if (quote) out.push(` > ${quote.length > QUOTE_MAX ? `${quote.slice(0, QUOTE_MAX)}…` : quote}`);
3377
- if (root.body) out.push(indent(root.body, " "));
3378
- const rootPics = attachmentsLine(root);
3379
- if (rootPics) out.push(` ${rootPics}`);
3380
- for (const reply of replies.get(root.id) ?? []) {
3381
- out.push(` ↳ ${reply.id} ${who(reply)} ${when(reply.createdAt)}`);
3382
- if (reply.body) out.push(indent(reply.body, " "));
3383
- const pics = attachmentsLine(reply);
3384
- if (pics) out.push(` ${pics}`);
3385
- }
3386
- out.push("");
3596
+ for (const rule of policy.include ?? []) {
3597
+ if (typeof rule !== "object" || rule === null) continue;
3598
+ const email = rule.email?.email;
3599
+ if (typeof email !== "string") continue;
3600
+ const normalized = email.trim().toLowerCase();
3601
+ if (normalized !== "" && !out.includes(normalized)) out.push(normalized);
3387
3602
  }
3388
- process.stdout.write(out.join("\n"));
3603
+ return out;
3389
3604
  }
3390
- async function replyComment(api, threadId, opts) {
3391
- const res = await api.postJson(`/api/comments/${encodeURIComponent(threadId)}/reply`, {
3392
- body: opts.message,
3393
- authorType: opts.ai ? "ai" : "human"
3605
+ /** 保留策略里非 email 的规则(人手加的账号成员选择器等),只重写 email 那部分。 */
3606
+ function nonEmailRules(policy) {
3607
+ return (policy.include ?? []).filter((rule) => {
3608
+ if (typeof rule !== "object" || rule === null) return true;
3609
+ return !("email" in rule);
3394
3610
  });
3395
- process.stdout.write(`replied ${String(res?.parentId ?? threadId)} → ${String(res?.id ?? "")}\n`);
3396
3611
  }
3397
- async function resolveComment(api, threadId, opts) {
3398
- const res = await api.postJson(`/api/comments/${encodeURIComponent(threadId)}/resolve`, { resolved: !opts.undo });
3399
- process.stdout.write(`${opts.undo ? "reopened" : "resolved"} ${String(res?.id ?? threadId)}\n`);
3612
+ function emailRules(emails) {
3613
+ return emails.map((email) => ({ email: { email } }));
3614
+ }
3615
+ /** 建好的应用认领方式:先按 host 的 `/p` 目标认,认不到再按名字。 */
3616
+ function appMatchesHost(app, host) {
3617
+ if ((app.destinations ?? []).map((d) => d.uri ?? "").some((uri) => uri.startsWith(`${host}/p`))) return true;
3618
+ return app.domain === `${host}/p` || app.domain === host;
3619
+ }
3620
+ async function listApps(api) {
3621
+ return await api.request("GET", `/accounts/${api.accountId}/access/apps`);
3622
+ }
3623
+ async function findAppForHost(api, host) {
3624
+ return (await listApps(api)).find((app) => appMatchesHost(app, host)) ?? null;
3625
+ }
3626
+ async function appPolicies(api, appId) {
3627
+ return await api.request("GET", `/accounts/${api.accountId}/access/apps/${appId}/policies`);
3628
+ }
3629
+ /** 直接改策略的 include(保留其它规则与 decision / name)。 */
3630
+ async function setPolicyEmails(api, appId, policy, emails) {
3631
+ await api.request("PUT", `/accounts/${api.accountId}/access/apps/${appId}/policies/${policy.id}`, {
3632
+ name: policy.name ?? "jiandu",
3633
+ decision: policy.decision ?? "allow",
3634
+ include: [...emailRules(emails), ...nonEmailRules(policy)]
3635
+ });
3636
+ }
3637
+ async function getOrganization(api) {
3638
+ try {
3639
+ return await api.request("GET", `/accounts/${api.accountId}/access/organizations`);
3640
+ } catch {
3641
+ return null;
3642
+ }
3400
3643
  }
3401
- //#endregion
3402
- //#region src/browser-auth.ts
3403
3644
  /**
3404
- * jdu login 的浏览器授权(#66):本机起一个 loopback 回调,打开 server 下发的 authorizeUrl,
3405
- * 人在浏览器里用 passkey 会话点一次「授权」,回跳带回一次性 code,再用 PKCE verifier 向 server 换 token。
3406
- *
3407
- * authorizeUrl 与 server 可能不同源(CLI 连 127.0.0.1:18082,浏览器开 https://md.mason.local)——
3408
- * 浏览器去前者,code 换 token 打后者。回调端口随机(server 接受任意 loopback 端口),不和 OIDC 的 8085 抢。
3645
+ * 账号还没有 Zero Trust team 时建一个。auth_domain 要全局唯一,撞了就换一个后缀重试。
3646
+ * 名字里的 account id 前缀足够区分,撞名是极小概率,但撞了不能让整个部署挂在这里。
3409
3647
  */
3410
- async function browserAuthorize(input) {
3411
- const { verifier, challenge } = pkce();
3412
- const state = randomBytes(16).toString("base64url");
3413
- const code = await new Promise((resolve, reject) => {
3414
- const finish = (fn) => (value) => {
3415
- clearTimeout(timer);
3416
- server.close();
3417
- fn(value);
3418
- };
3419
- const fail = finish((msg) => reject(new CliError(msg, "重新执行 jdu login;不想开浏览器就带 --token")));
3420
- const handler = callbackHandler(state, finish(resolve));
3421
- const server = createServer((req, res) => {
3422
- if (new URL(req.url ?? "/", "http://localhost").pathname !== "/callback") {
3423
- res.writeHead(404).end();
3424
- return;
3425
- }
3426
- handler(req, res);
3427
- });
3428
- const timer = setTimeout(() => fail("等待浏览器授权超时"), input.timeoutMs ?? 18e4);
3429
- server.on("error", (err) => fail(`监听 loopback 回调失败:${err.message}`));
3430
- server.listen(0, "127.0.0.1", () => {
3431
- const port = server.address().port;
3432
- const url = new URL(input.authorizeUrl);
3433
- url.search = new URLSearchParams({
3434
- state,
3435
- code_challenge: challenge,
3436
- port: String(port),
3437
- label: input.label ?? hostname()
3438
- }).toString();
3439
- process.stderr.write(`在浏览器中完成授权:${url.toString()}\n`);
3440
- (input.open ?? openBrowser)(url.toString());
3648
+ async function ensureOrganization(api, workerName) {
3649
+ const domain = (await getOrganization(api))?.auth_domain;
3650
+ if (typeof domain === "string" && domain !== "") return domain;
3651
+ const base = `${workerName}-${api.accountId.slice(0, 8)}`.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
3652
+ for (const candidate of [base, `${base}-${Date.now().toString(36).slice(-4)}`]) try {
3653
+ const created = await api.request("POST", `/accounts/${api.accountId}/access/organizations`, {
3654
+ name: workerName,
3655
+ auth_domain: candidate
3441
3656
  });
3657
+ if (typeof created.auth_domain === "string" && created.auth_domain !== "") return created.auth_domain;
3658
+ } catch (err) {
3659
+ const again = await getOrganization(api);
3660
+ if (typeof again?.auth_domain === "string" && again.auth_domain !== "") return again.auth_domain;
3661
+ if (candidate.endsWith("4")) throw err;
3662
+ }
3663
+ throw new CliError("建 Zero Trust team 失败", "去 Cloudflare 控制台 Zero Trust 里手建一个 team 再重试");
3664
+ }
3665
+ /** 登录方式必须是 Cloudflare 账号(决策 8):没有就建一个,已存在就用它。 */
3666
+ async function ensureCloudflareIdp(api) {
3667
+ const hit = (await api.request("GET", `/accounts/${api.accountId}/access/identity_providers`)).find((p) => p.type === "cloudflare");
3668
+ if (hit) return hit.id;
3669
+ return (await api.request("POST", `/accounts/${api.accountId}/access/identity_providers`, {
3670
+ name: "Cloudflare",
3671
+ type: "cloudflare",
3672
+ config: { restrict_to_account_members: false }
3673
+ })).id;
3674
+ }
3675
+ /**
3676
+ * 保护 `/p*` 的应用:没有就建,有就保证部署者邮箱在策略里(其余邮箱保留)。
3677
+ * 只挡 /p:公开页(/d、/docs、/official、静态资源)与读 API 都不挂 Access,
3678
+ * 写 API 由 Worker 自己验同一份 JWT——理由见 docs/deploy.md「Cloudflare 形态」。
3679
+ */
3680
+ async function ensureAccessApp(api, input) {
3681
+ const destinations = [{
3682
+ type: "public",
3683
+ uri: `${input.host}/p`
3684
+ }, {
3685
+ type: "public",
3686
+ uri: `${input.host}/p/*`
3687
+ }];
3688
+ const existing = await findAppForHost(api, input.host);
3689
+ const app = existing ?? await api.request("POST", `/accounts/${api.accountId}/access/apps`, {
3690
+ name: input.name,
3691
+ type: "self_hosted",
3692
+ destinations,
3693
+ session_duration: "24h",
3694
+ allowed_idps: [input.idpId],
3695
+ auto_redirect_to_identity: true,
3696
+ skip_interstitial: true,
3697
+ app_launcher_visible: false
3442
3698
  });
3443
- const res = await fetch(`${input.server}/api/cli/token`, {
3444
- method: "POST",
3445
- headers: { "content-type": "application/json" },
3446
- body: JSON.stringify({
3447
- code,
3448
- code_verifier: verifier
3449
- })
3699
+ const policies = await appPolicies(api, app.id);
3700
+ const policy = policies.find((p) => (p.decision ?? "allow") === "allow") ?? policies[0];
3701
+ const current = policy ? emailsOf(policy) : [];
3702
+ const wanted = ensureEmailIncluded(current, input.emails);
3703
+ if (policy) {
3704
+ if (wanted.length !== current.length || wanted.some((e, i) => current[i] !== e)) await setPolicyEmails(api, app.id, policy, wanted);
3705
+ } else await api.request("POST", `/accounts/${api.accountId}/access/apps/${app.id}/policies`, {
3706
+ name: input.name,
3707
+ decision: "allow",
3708
+ include: emailRules(wanted)
3450
3709
  });
3451
- const text = await res.text();
3452
- if (!res.ok) throw new CliError(`code 换 token 失败(HTTP ${res.status}):${text.slice(0, 200)}`, "重新执行 jdu login 再试一次");
3453
- let token;
3710
+ return {
3711
+ app,
3712
+ emails: wanted,
3713
+ existed: existing !== null
3714
+ };
3715
+ }
3716
+ /** 合并邮箱:保序去重,且保证部署者邮箱一定在。 */
3717
+ function ensureEmailIncluded(current, wanted) {
3718
+ const out = [...current];
3719
+ for (const email of wanted) {
3720
+ const normalized = email.trim().toLowerCase();
3721
+ if (normalized !== "" && !out.includes(normalized)) out.push(normalized);
3722
+ }
3723
+ return out;
3724
+ }
3725
+ /** `GET /accounts/:id/workers/subdomain`:拿不到(账号还没注册 workers.dev 子域)返回 null。 */
3726
+ async function workersDevSubdomain(api) {
3454
3727
  try {
3455
- token = JSON.parse(text).token;
3728
+ const result = await api.request("GET", `/accounts/${api.accountId}/workers/subdomain`);
3729
+ return typeof result.subdomain === "string" && result.subdomain !== "" ? result.subdomain : null;
3456
3730
  } catch {
3457
- token = void 0;
3731
+ return null;
3458
3732
  }
3459
- if (typeof token !== "string" || token === "") throw new CliError("server 没有返回 token");
3460
- return token;
3461
3733
  }
3462
3734
  //#endregion
3463
3735
  //#region src/login.ts
@@ -3495,6 +3767,13 @@ async function probeApiWithBearer(server, token) {
3495
3767
  redirect: "manual"
3496
3768
  })).ok;
3497
3769
  }
3770
+ /** Cloudflare 形态的凭据是 Access 应用 token:过边缘用 cf-access-token 头,不是 Bearer。 */
3771
+ async function probeApiWithAccessToken(server, token) {
3772
+ return (await fetch(`${server}/api/docs`, {
3773
+ headers: { "cf-access-token": token },
3774
+ redirect: "manual"
3775
+ })).ok;
3776
+ }
3498
3777
  /**
3499
3778
  * forward-auth / OIDC 分支,外加 --user 直连与 --token 直存。
3500
3779
  * 不是命令入口——入口是 init.ts 的 runLogin,它探完 healthz 才分派到这里。
@@ -3560,6 +3839,302 @@ async function runOidcLogin(opts) {
3560
3839
  };
3561
3840
  }
3562
3841
  //#endregion
3842
+ //#region src/access.ts
3843
+ /**
3844
+ * `jdu access`:改 Cloudflare 形态实例的准入——Access 策略里的邮箱列表。
3845
+ *
3846
+ * 加人 = 把同事的邮箱写进 Allow 策略,对方用自己的 Cloudflare 账号(同一邮箱)登录;
3847
+ * 减人 = 从策略里去掉。jiandu 自己不存成员表,身份与准入只有 Access 这一个真源(决策 5)。
3848
+ *
3849
+ * 需要一把有 Access 权限的 Cloudflare 凭据:`jdu deploy` 时授权过就直接用缓存,
3850
+ * 否则 `--cf-token` / `CLOUDFLARE_API_TOKEN`,再不行会开浏览器授权。
3851
+ */
3852
+ var EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
3853
+ function normalizeEmails(emails) {
3854
+ const out = [];
3855
+ for (const raw of emails) {
3856
+ const email = raw.trim().toLowerCase();
3857
+ if (email === "") continue;
3858
+ if (!EMAIL_RE.test(email)) throw new CliError(`不是合法的邮箱:${raw}`, "邮箱要和对方的 Cloudflare 账号一致");
3859
+ if (!out.includes(email)) out.push(email);
3860
+ }
3861
+ return out;
3862
+ }
3863
+ /** 站点地址:显式给的优先,其次本地配置里的那台。 */
3864
+ function serverOf(opts) {
3865
+ const server = (opts.server ?? readStoredConfig().server ?? "").trim().replace(/\/+$/, "");
3866
+ if (server === "") throw new CliError("需要知道改哪台实例的 Access 策略", "带 --server <url>,或先 jdu login --server <url>");
3867
+ return server;
3868
+ }
3869
+ /** 只有 Cloudflare 形态的实例有 Access 策略;其它 provider 说清而不是给个看不懂的 API 错误。 */
3870
+ async function ensureCloudflareInstance(server) {
3871
+ const healthz = await fetchHealthz(server);
3872
+ if (healthz.authProvider !== "cloudflare-access") throw new CliError(`${server} 是 ${healthz.authProvider ?? "未知"} 鉴权,没有 Access 邮箱策略`, "Docker / 本机形态的准入用 jdu member invite(passkey)或网关自己的配置");
3873
+ }
3874
+ async function locate(opts) {
3875
+ const server = serverOf(opts);
3876
+ await ensureCloudflareInstance(server);
3877
+ const host = new URL(server).host;
3878
+ const api = new CfApi({ credentials: await resolveCfCredentials({ ...opts.cfToken ? { cfToken: opts.cfToken } : {} }) });
3879
+ const app = await findAppForHost(api, host);
3880
+ if (!app) throw new CliError(`${host} 上没有找到 jiandu 的 Access 应用`, "确认实例是用 jdu deploy 部出来的(Access 应用由它建),或去 Zero Trust → Access 看一眼");
3881
+ const policies = await api.request("GET", `/accounts/${api.accountId}/access/apps/${app.id}/policies`);
3882
+ const policy = policies.find((p) => (p.decision ?? "allow") === "allow") ?? policies[0];
3883
+ if (!policy) throw new CliError(`${host} 的 Access 应用没有策略`, "去 Zero Trust → Access 里给它加一条 Allow 策略再重试");
3884
+ return {
3885
+ api,
3886
+ appId: app.id,
3887
+ policyId: policy.id,
3888
+ emails: emailsOf(policy)
3889
+ };
3890
+ }
3891
+ async function runAccessList(opts) {
3892
+ const { api, appId, emails } = await locate(opts);
3893
+ return {
3894
+ server: serverOf(opts),
3895
+ accountId: api.accountId,
3896
+ appId,
3897
+ emails
3898
+ };
3899
+ }
3900
+ /** 加邮箱:先把现有的读回来,确保对方不在列表里再加(幂等)。 */
3901
+ async function runAccessAllow(emails, opts) {
3902
+ const wanted = normalizeEmails(emails);
3903
+ if (wanted.length === 0) throw new CliError("没有要加的邮箱", "jdu access allow <邮箱> [<邮箱>...]");
3904
+ const { api, appId, policyId, emails: current } = await locate(opts);
3905
+ const merged = ensureEmailIncluded(current, wanted);
3906
+ if (merged.length !== current.length || merged.some((e, i) => current[i] !== e)) await putEmails(api, appId, policyId, merged);
3907
+ return {
3908
+ server: serverOf(opts),
3909
+ accountId: api.accountId,
3910
+ appId,
3911
+ emails: merged
3912
+ };
3913
+ }
3914
+ async function runAccessDeny(emails, opts) {
3915
+ const unwanted = normalizeEmails(emails);
3916
+ if (unwanted.length === 0) throw new CliError("没有要移除的邮箱", "jdu access deny <邮箱> [<邮箱>...]");
3917
+ const { api, appId, policyId, emails: current } = await locate(opts);
3918
+ const kept = current.filter((email) => !unwanted.includes(email));
3919
+ const missing = unwanted.filter((email) => !current.includes(email));
3920
+ if (missing.length > 0) throw new CliError(`这些邮箱本来就不在策略里:${missing.join(", ")}`, "用 jdu access list 看当前名单(大小写不敏感)");
3921
+ await putEmails(api, appId, policyId, kept);
3922
+ return {
3923
+ server: serverOf(opts),
3924
+ accountId: api.accountId,
3925
+ appId,
3926
+ emails: kept
3927
+ };
3928
+ }
3929
+ /** 重写策略的 include:email 规则用新名单,其它规则(人手加的账号成员选择器等)原样保留。 */
3930
+ async function putEmails(api, appId, policyId, emails) {
3931
+ await setPolicyEmails(api, appId, await api.request("GET", `/accounts/${api.accountId}/access/apps/${appId}/policies/${policyId}`), emails);
3932
+ }
3933
+ //#endregion
3934
+ //#region src/blobs.ts
3935
+ /** 流式算 hash,避免把大图整份读进内存。 */
3936
+ function sha256File(path) {
3937
+ return new Promise((res, rej) => {
3938
+ const h = createHash("sha256");
3939
+ const rs = createReadStream(path);
3940
+ rs.on("error", rej);
3941
+ rs.on("data", (chunk) => h.update(chunk));
3942
+ rs.on("end", () => res(h.digest("hex")));
3943
+ });
3944
+ }
3945
+ var MIME$1 = Object.freeze({
3946
+ ".md": "text/markdown; charset=utf-8",
3947
+ ".markdown": "text/markdown; charset=utf-8",
3948
+ ".txt": "text/plain; charset=utf-8",
3949
+ ".json": "application/json",
3950
+ ".js": "text/javascript; charset=utf-8",
3951
+ ".mjs": "text/javascript; charset=utf-8",
3952
+ ".css": "text/css; charset=utf-8",
3953
+ ".html": "text/html; charset=utf-8",
3954
+ ".svg": "image/svg+xml",
3955
+ ".png": "image/png",
3956
+ ".jpg": "image/jpeg",
3957
+ ".jpeg": "image/jpeg",
3958
+ ".gif": "image/gif",
3959
+ ".webp": "image/webp",
3960
+ ".avif": "image/avif",
3961
+ ".ico": "image/x-icon",
3962
+ ".mp4": "video/mp4",
3963
+ ".webm": "video/webm",
3964
+ ".mov": "video/quicktime",
3965
+ ".mp3": "audio/mpeg",
3966
+ ".wav": "audio/wav",
3967
+ ".pdf": "application/pdf",
3968
+ ".zip": "application/zip",
3969
+ ".woff2": "font/woff2"
3970
+ });
3971
+ function guessMime(path) {
3972
+ return MIME$1[extname(path).toLowerCase()] ?? "application/octet-stream";
3973
+ }
3974
+ /**
3975
+ * 增量上传:先用 hash 清单跟 server 协商,只 PUT 缺失的。
3976
+ * server 返回体不可解析时按「全都缺」处理 —— 宁可多传也不能漏传导致文档半残。
3977
+ */
3978
+ async function syncBlobs(api, entries) {
3979
+ const byHash = /* @__PURE__ */ new Map();
3980
+ for (const e of entries) if (!byHash.has(e.hash)) byHash.set(e.hash, e.path);
3981
+ const hashes = [...byHash.keys()];
3982
+ if (hashes.length === 0) return {
3983
+ total: 0,
3984
+ uploaded: [],
3985
+ reused: 0
3986
+ };
3987
+ const res = await api.postJson("/api/blobs/check", { hashes });
3988
+ const missing = Array.isArray(res?.missing) ? res.missing.filter((h) => typeof h === "string") : hashes;
3989
+ const uploaded = [];
3990
+ for (const hash of missing) {
3991
+ const path = byHash.get(hash);
3992
+ if (path === void 0) continue;
3993
+ await api.putBytes(`/api/blobs/${hash}`, readFileSync(path), guessMime(path));
3994
+ uploaded.push(hash);
3995
+ }
3996
+ return {
3997
+ total: hashes.length,
3998
+ uploaded,
3999
+ reused: hashes.length - uploaded.length
4000
+ };
4001
+ }
4002
+ //#endregion
4003
+ //#region src/comments.ts
4004
+ /** 附件一行:`[图片 ×N] /blob/… /blob/…`,给 agent 的是相对路径,拼上 server 地址就能取 */
4005
+ function attachmentsLine(c) {
4006
+ const list = c.attachments ?? [];
4007
+ return list.length === 0 ? null : `[图片 ×${list.length}] ${list.map((s) => `/blob/${s}`).join(" ")}`;
4008
+ }
4009
+ var QUOTE_MAX = 120;
4010
+ function when(ms) {
4011
+ return new Date(ms).toISOString().slice(0, 16).replace("T", " ");
4012
+ }
4013
+ /** owner 是 UUID 且没有显示名时 authorLabel 为空串,退回原始 id 也比空着好认。 */
4014
+ function who(c) {
4015
+ return `${c.authorLabel || c.author}${c.authorType === "ai" ? " [ai]" : ""}`;
4016
+ }
4017
+ function indent(text, pad) {
4018
+ return text.split("\n").map((line) => `${pad}${line}`).join("\n");
4019
+ }
4020
+ /**
4021
+ * 线程列表。stdout 纯文本、一线程一段,给 agent 直接读:
4022
+ * 首行 `<threadId> <blockId> <open|resolved> <作者> <时间> [v<seq>]`,引文一行 `> …`,正文缩进两格,回复 `↳` 缩进。
4023
+ */
4024
+ async function listComments(api, docId, opts) {
4025
+ const res = await api.getJson(`/api/docs/${encodeURIComponent(docId)}/comments`);
4026
+ const all = Array.isArray(res?.comments) ? res.comments : [];
4027
+ const roots = all.filter((c) => c.parentId === null && (opts.all || !c.resolved));
4028
+ if (roots.length === 0) {
4029
+ process.stdout.write(`${opts.all ? "(无评论)" : "(无未处理评论)"}\n`);
4030
+ return;
4031
+ }
4032
+ const replies = /* @__PURE__ */ new Map();
4033
+ for (const c of all) {
4034
+ if (c.parentId === null) continue;
4035
+ const list = replies.get(c.parentId) ?? [];
4036
+ list.push(c);
4037
+ replies.set(c.parentId, list);
4038
+ }
4039
+ const out = [];
4040
+ for (const root of roots) {
4041
+ const head = [
4042
+ root.id,
4043
+ root.blockId ?? "全文",
4044
+ root.resolved ? "resolved" : "open",
4045
+ who(root),
4046
+ when(root.createdAt)
4047
+ ];
4048
+ if (root.versionSeq !== null) head.push(`v${root.versionSeq}`);
4049
+ out.push(head.join(" "));
4050
+ const quote = root.selection?.quote;
4051
+ if (quote) out.push(` > ${quote.length > QUOTE_MAX ? `${quote.slice(0, QUOTE_MAX)}…` : quote}`);
4052
+ if (root.body) out.push(indent(root.body, " "));
4053
+ const rootPics = attachmentsLine(root);
4054
+ if (rootPics) out.push(` ${rootPics}`);
4055
+ for (const reply of replies.get(root.id) ?? []) {
4056
+ out.push(` ↳ ${reply.id} ${who(reply)} ${when(reply.createdAt)}`);
4057
+ if (reply.body) out.push(indent(reply.body, " "));
4058
+ const pics = attachmentsLine(reply);
4059
+ if (pics) out.push(` ${pics}`);
4060
+ }
4061
+ out.push("");
4062
+ }
4063
+ process.stdout.write(out.join("\n"));
4064
+ }
4065
+ async function replyComment(api, threadId, opts) {
4066
+ const res = await api.postJson(`/api/comments/${encodeURIComponent(threadId)}/reply`, {
4067
+ body: opts.message,
4068
+ authorType: opts.ai ? "ai" : "human"
4069
+ });
4070
+ process.stdout.write(`replied ${String(res?.parentId ?? threadId)} → ${String(res?.id ?? "")}\n`);
4071
+ }
4072
+ async function resolveComment(api, threadId, opts) {
4073
+ const res = await api.postJson(`/api/comments/${encodeURIComponent(threadId)}/resolve`, { resolved: !opts.undo });
4074
+ process.stdout.write(`${opts.undo ? "reopened" : "resolved"} ${String(res?.id ?? threadId)}\n`);
4075
+ }
4076
+ //#endregion
4077
+ //#region src/browser-auth.ts
4078
+ /**
4079
+ * jdu login 的浏览器授权(#66):本机起一个 loopback 回调,打开 server 下发的 authorizeUrl,
4080
+ * 人在浏览器里用 passkey 会话点一次「授权」,回跳带回一次性 code,再用 PKCE verifier 向 server 换 token。
4081
+ *
4082
+ * authorizeUrl 与 server 可能不同源(CLI 连 127.0.0.1:18082,浏览器开 https://md.mason.local)——
4083
+ * 浏览器去前者,code 换 token 打后者。回调端口随机(server 接受任意 loopback 端口),不和 OIDC 的 8085 抢。
4084
+ */
4085
+ async function browserAuthorize(input) {
4086
+ const { verifier, challenge } = pkce();
4087
+ const state = randomBytes(16).toString("base64url");
4088
+ const code = await new Promise((resolve, reject) => {
4089
+ const finish = (fn) => (value) => {
4090
+ clearTimeout(timer);
4091
+ server.close();
4092
+ fn(value);
4093
+ };
4094
+ const fail = finish((msg) => reject(new CliError(msg, "重新执行 jdu login;不想开浏览器就带 --token")));
4095
+ const handler = callbackHandler(state, finish(resolve));
4096
+ const server = createServer((req, res) => {
4097
+ if (new URL(req.url ?? "/", "http://localhost").pathname !== "/callback") {
4098
+ res.writeHead(404).end();
4099
+ return;
4100
+ }
4101
+ handler(req, res);
4102
+ });
4103
+ const timer = setTimeout(() => fail("等待浏览器授权超时"), input.timeoutMs ?? 18e4);
4104
+ server.on("error", (err) => fail(`监听 loopback 回调失败:${err.message}`));
4105
+ server.listen(0, "127.0.0.1", () => {
4106
+ const port = server.address().port;
4107
+ const url = new URL(input.authorizeUrl);
4108
+ url.search = new URLSearchParams({
4109
+ state,
4110
+ code_challenge: challenge,
4111
+ port: String(port),
4112
+ label: input.label ?? hostname()
4113
+ }).toString();
4114
+ process.stderr.write(`在浏览器中完成授权:${url.toString()}\n`);
4115
+ (input.open ?? openBrowser)(url.toString());
4116
+ });
4117
+ });
4118
+ const res = await fetch(`${input.server}/api/cli/token`, {
4119
+ method: "POST",
4120
+ headers: { "content-type": "application/json" },
4121
+ body: JSON.stringify({
4122
+ code,
4123
+ code_verifier: verifier
4124
+ })
4125
+ });
4126
+ const text = await res.text();
4127
+ if (!res.ok) throw new CliError(`code 换 token 失败(HTTP ${res.status}):${text.slice(0, 200)}`, "重新执行 jdu login 再试一次");
4128
+ let token;
4129
+ try {
4130
+ token = JSON.parse(text).token;
4131
+ } catch {
4132
+ token = void 0;
4133
+ }
4134
+ if (typeof token !== "string" || token === "") throw new CliError("server 没有返回 token");
4135
+ return token;
4136
+ }
4137
+ //#endregion
3563
4138
  //#region src/init.ts
3564
4139
  /**
3565
4140
  * jdu login:选定站点、拿到凭据、写好本地配置,一条命令把「登录到哪台简牍」定下来。
@@ -3612,6 +4187,7 @@ async function runLogin(opts) {
3612
4187
  authProvider: provider,
3613
4188
  authMethods: methods
3614
4189
  };
4190
+ const cfAccess = provider === "cloudflare-access";
3615
4191
  if (opts.user !== void 0 && opts.user.trim() !== "") {
3616
4192
  const r = await runOidcLogin({
3617
4193
  server,
@@ -3628,10 +4204,11 @@ async function runLogin(opts) {
3628
4204
  };
3629
4205
  }
3630
4206
  if (opts.token !== void 0 && opts.token !== "") {
3631
- if (!await (opts.probe ?? probeApiWithBearer)(server, opts.token)) throw new CliError("token 校验失败(/api/docs 未放行)", "确认 token 正确、且网关放行 Authorization: Bearer");
4207
+ if (!await (opts.probe ?? (cfAccess ? probeApiWithAccessToken : probeApiWithBearer))(server, opts.token)) throw new CliError(cfAccess ? "Access 应用 token 校验失败(/api/docs 未放行)" : "token 校验失败(/api/docs 未放行)", cfAccess ? "token 要没过期、且是本实例 Access 应用签的" : "确认 token 正确、且网关放行 Authorization: Bearer");
3632
4208
  const path = saveConfig({
3633
4209
  server,
3634
- token: opts.token
4210
+ token: opts.token,
4211
+ ...cfAccess ? { cfAccess: true } : {}
3635
4212
  });
3636
4213
  return {
3637
4214
  ...base,
@@ -3661,7 +4238,8 @@ async function runLogin(opts) {
3661
4238
  token: await (opts.authorize ?? browserAuthorize)({
3662
4239
  server,
3663
4240
  authorizeUrl
3664
- })
4241
+ }),
4242
+ ...cfAccess ? { cfAccess: true } : {}
3665
4243
  });
3666
4244
  return {
3667
4245
  ...base,
@@ -3670,6 +4248,12 @@ async function runLogin(opts) {
3670
4248
  next: null
3671
4249
  };
3672
4250
  }
4251
+ if (cfAccess) return {
4252
+ ...base,
4253
+ loggedIn: false,
4254
+ configPath: path,
4255
+ next: `jdu login --server '${server}'(打开浏览器,用 Cloudflare 账号授权;--json / --no-browser 下不会开浏览器)`
4256
+ };
3673
4257
  const viaToken = `jdu login --server '${server}' --token <token>(${healthz.cliAuth?.tokenHint ?? "token 在 server 首启 stdout / data/initial-token.txt"})`;
3674
4258
  if (provider === "forward-auth" && opts.browser) {
3675
4259
  const r = await runOidcLogin({
@@ -3700,28 +4284,38 @@ async function runLogin(opts) {
3700
4284
  function fallbackMethods(provider) {
3701
4285
  if (provider === "anonymous") return [];
3702
4286
  if (provider === "forward-auth") return ["oidc", "token"];
4287
+ if (provider === "cloudflare-access") return ["browser"];
3703
4288
  return ["token"];
3704
4289
  }
3705
4290
  //#endregion
3706
4291
  //#region src/deploy.ts
3707
4292
  /**
3708
- * `jdu deploy`:在用户自己的 Cloudflare 账号里起一个 jiandu,一条命令从零到能登录。
4293
+ * `jdu deploy`:在用户自己的 Cloudflare 账号里起一个 jiandu
3709
4294
  *
3710
- * 为什么是 CLI 而不是 Deploy 按钮:jiandu 是给 agent 用的,agent 会跑命令不会点按钮;
3711
- * 而且这条路不要求 GitHub 账号、不在用户账号里留一个仓库与构建流水线,`JIANDU_SETUP_SECRET`
3712
- * 由这里随机生成(用户手填的 secret 强度没法保证)。
4295
+ * 一条命令要做完的事(顺序有依赖,每一步都幂等):
3713
4296
  *
3714
- * 我们不自己调 Cloudflare API:Worker 上传、Durable Object migration、静态资源上传会话
3715
- * 是三套协议,wrangler 已经把它们做完了,重写一遍只会多一份要跟着 Cloudflare 演进的代码。
3716
- * 所以这里只做三件事:把产物取下来、按正确的顺序敲 wrangler、把结果接回 jdu 的登录态。
4297
+ * OAuth --cf-token ──▶ account id / 邮箱
4298
+ * ──▶ 确保 Zero Trust team 与 Cloudflare IdP
4299
+ * ──▶ upsert Access 应用(只挡 /p*)与邮箱策略
4300
+ * ──▶ wrangler deploy(带 JIANDU_AUTH_* / TEAM_DOMAIN / POLICY_AUD 变量)
4301
+ * ──▶ 退避探 /healthz ──▶ 本地 jdu login(浏览器)
3717
4302
  *
3718
- * jdu deploy --owner mason 首次:部署 生成 secret → 自动 jdu login
3719
- * jdu deploy 升级:npm latest 再部署一次,vars secret 原样保留
4303
+ * 为什么不是「点一个 Deploy 按钮」:jiandu 是给 agent 用的,agent 会跑命令不会点按钮。
4304
+ * 为什么不自己调 Cloudflare API Worker:Worker 上传、Durable Object migration、静态资源
4305
+ * 上传会话是三套协议,wrangler 已经把它们做完了,重写一遍只会多一份要跟着 Cloudflare 演进的代码。
4306
+ * 所以 wrangler 只负责「传产物」,Access 与变量由这里直接调 API 配。
4307
+ *
4308
+ * Access 的权限 wrangler 的 OAuth 客户端没有(实测 POST /access/apps → 1010 auth.forbidden),
4309
+ * 而 Cloudflare 自己的 CLI OAuth 客户端有 access:read/write,见 cf-oauth.ts。
4310
+ * 拿到的 token 以 CLOUDFLARE_API_TOKEN 交给 wrangler(一把 token 干完两件事)。
3720
4311
  */
3721
4312
  /** npm 上的部署产物包:worker.js + assets/ + wrangler.json。 */
3722
4313
  var PACKAGE = "@monoedge/jiandu-cf";
3723
4314
  /** wrangler 主版本锁死:它改 flag 我们要跟着改,不能让用户某天突然拿到 v5。 */
3724
4315
  var WRANGLER = "wrangler@4";
4316
+ /** healthz 退避探活:#155 的教训——冷启动要种 16 篇官方文档,第一次探活必须允许它慢。 */
4317
+ var HEALTH_TIMEOUT_MS = 45e3;
4318
+ var HEALTH_INTERVAL_MS = 2e3;
3725
4319
  /** 默认 runner:stdio 继承给用户看(wrangler 的 OAuth 提示、进度条都靠它)。 */
3726
4320
  var spawnRunner = (cmd, args, opts) => new Promise((ok, fail) => {
3727
4321
  const child = spawn(cmd, args, {
@@ -3740,10 +4334,6 @@ var spawnRunner = (cmd, args, opts) => new Promise((ok, fail) => {
3740
4334
  child.on("error", (err) => fail(new CliError(`跑不起来 ${cmd}:${err.message}`, "需要 node 与 npm 在 PATH 里")));
3741
4335
  child.on("exit", (code) => code === 0 ? ok() : fail(new CliError(`${cmd} ${args[0] ?? ""} 失败(退出码 ${code})`)));
3742
4336
  });
3743
- /** 24 字节随机,base64url —— 它会出现在 URL 的 query 里(/setup?invite=),别带需要转义的字符。 */
3744
- function newSecret() {
3745
- return randomBytes(24).toString("base64url");
3746
- }
3747
4337
  /**
3748
4338
  * 取部署产物:`--dist` 指本地目录,否则临时装一份 npm 包。
3749
4339
  * 返回 wrangler 配置的绝对路径 —— wrangler 按**配置文件所在目录**解析 main 与 assets.directory,
@@ -3805,17 +4395,77 @@ function urlFromOutput(file) {
3805
4395
  }
3806
4396
  return null;
3807
4397
  }
3808
- /** 部署完探一次 healthz:缺 secret 的实例冷启动就抛错,所有请求 500。 */
3809
- async function healthy(url) {
4398
+ /**
4399
+ * 退避探活:冷启动要建表 + 种官方文档,第一次请求可能落在 DO 还没热身完的窗口里(#155)。
4400
+ * 返回最后一次看到的 healthz;一直不通返回 null。
4401
+ */
4402
+ async function waitHealthy(url, fetchImpl, timeoutMs = HEALTH_TIMEOUT_MS) {
4403
+ const deadline = Date.now() + timeoutMs;
4404
+ for (;;) {
4405
+ try {
4406
+ const res = await fetchImpl(`${url}/healthz`, { headers: { accept: "application/json" } });
4407
+ if (res.ok) {
4408
+ const body = await res.json();
4409
+ if (body.runtime === "cloudflare") return body;
4410
+ }
4411
+ } catch {}
4412
+ if (Date.now() >= deadline) return null;
4413
+ await new Promise((r) => setTimeout(r, HEALTH_INTERVAL_MS));
4414
+ }
4415
+ }
4416
+ /** 部署命令:产物配置 + 变量 + 输出文件。 */
4417
+ function deployArgs(input) {
4418
+ const args = [
4419
+ "--yes",
4420
+ WRANGLER,
4421
+ "deploy",
4422
+ "-c",
4423
+ input.config,
4424
+ "--name",
4425
+ input.name,
4426
+ "--keep-vars"
4427
+ ];
4428
+ for (const [key, value] of Object.entries(input.vars ?? {})) args.push("--var", `${key}:${value}`);
4429
+ return args;
4430
+ }
4431
+ /**
4432
+ * 账号还没注册 workers.dev 子域时的兜底:先不带 Access 变量传一次,让 wrangler 引导注册并
4433
+ * 打印地址;随后配好 Access 再带变量传第二次(那才是真正生效的那次)。
4434
+ */
4435
+ async function warmupDeploy(config, name, env, run) {
4436
+ const outDir = mkdtempSync(join(tmpdir(), "jdu-wrangler-out-"));
4437
+ const outFile = join(outDir, "out.ndjson");
3810
4438
  try {
3811
- return (await fetch(`${url}/healthz`, { headers: { accept: "application/json" } })).ok;
3812
- } catch {
3813
- return false;
4439
+ await run("npx", deployArgs({
4440
+ config,
4441
+ name
4442
+ }), { env: {
4443
+ ...env,
4444
+ WRANGLER_OUTPUT_FILE_PATH: outFile
4445
+ } });
4446
+ const url = urlFromOutput(outFile);
4447
+ if (url === null) throw new CliError("部署命令跑完了,但没能从 wrangler 的输出里读到地址", "去 Cloudflare 控制台看这个 Worker 的 workers.dev 地址,再重跑一次 jdu deploy");
4448
+ return url;
4449
+ } finally {
4450
+ rmSync(outDir, {
4451
+ recursive: true,
4452
+ force: true
4453
+ });
3814
4454
  }
3815
4455
  }
3816
4456
  async function runDeploy(opts) {
3817
4457
  const run = opts.run ?? spawnRunner;
3818
4458
  const name = opts.name ?? "jiandu";
4459
+ const empty = {
4460
+ name,
4461
+ url: null,
4462
+ accountId: null,
4463
+ ownerEmail: null,
4464
+ emails: [],
4465
+ accessAppId: null,
4466
+ upgraded: false,
4467
+ loggedIn: false
4468
+ };
3819
4469
  const { config, cleanup } = await resolveConfig(opts, run);
3820
4470
  try {
3821
4471
  if (opts.dryRun) {
@@ -3829,74 +4479,87 @@ async function runDeploy(opts) {
3829
4479
  "--name",
3830
4480
  name
3831
4481
  ], {});
3832
- return {
3833
- name,
3834
- url: null,
3835
- setupSecret: null,
3836
- setupUrl: null,
3837
- loggedIn: false
3838
- };
4482
+ return empty;
3839
4483
  }
3840
- await run("npx", [
3841
- "--yes",
3842
- WRANGLER,
3843
- "login"
3844
- ], {});
4484
+ const fetchImpl = opts.fetchImpl ?? fetch;
4485
+ const credentials = await resolveCfCredentials({
4486
+ ...opts.cfToken ? { cfToken: opts.cfToken } : {},
4487
+ allowBrowser: opts.json !== true,
4488
+ fetchImpl
4489
+ });
4490
+ const api = new CfApi({
4491
+ credentials,
4492
+ fetchImpl,
4493
+ onTokens: (tokens) => writeCfConfig({
4494
+ ...tokens,
4495
+ accountId: credentials.accountId,
4496
+ email: credentials.email
4497
+ })
4498
+ });
4499
+ const identity = {
4500
+ accountId: credentials.accountId,
4501
+ email: credentials.email
4502
+ };
4503
+ const deployEnv = { CLOUDFLARE_API_TOKEN: credentials.tokens.accessToken };
4504
+ const subdomain = await workersDevSubdomain(api);
4505
+ const fromSubdomain = subdomain === null ? null : `${name}.${subdomain}.workers.dev`;
4506
+ const deployed = fromSubdomain === null ? await warmupDeploy(config, name, deployEnv, run) : null;
4507
+ const siteUrl = deployed ?? `https://${fromSubdomain}`;
4508
+ const siteHost = deployed === null ? fromSubdomain : new URL(deployed).host;
4509
+ const authDomain = await ensureOrganization(api, name);
4510
+ const idpId = await ensureCloudflareIdp(api);
4511
+ const { app, emails, existed } = await ensureAccessApp(api, {
4512
+ name,
4513
+ host: siteHost,
4514
+ emails: [identity.email],
4515
+ idpId
4516
+ });
4517
+ const vars = {
4518
+ JIANDU_AUTH_PROVIDER: "cloudflare-access",
4519
+ JIANDU_AUTH_OWNER: identity.accountId,
4520
+ JIANDU_AUTH_OWNER_EMAIL: identity.email,
4521
+ JIANDU_TEAM_DOMAIN: `https://${authDomain}`,
4522
+ JIANDU_POLICY_AUD: app.aud ?? ""
4523
+ };
4524
+ if (app.aud === void 0 || app.aud === "") throw new CliError("Access 应用没有 aud", "去 Cloudflare Zero Trust → Access 里看一眼应用是否建好");
4525
+ if (!siteUrl.startsWith("https://")) throw new CliError(`部署地址不是 https:${siteUrl}`, "Cloudflare 形态必须有 https 才能用 Access");
3845
4526
  const outDir = mkdtempSync(join(tmpdir(), "jdu-wrangler-out-"));
3846
4527
  const outFile = join(outDir, "out.ndjson");
3847
- const args = [
3848
- "--yes",
3849
- WRANGLER,
3850
- "deploy",
3851
- "-c",
4528
+ await run("npx", deployArgs({
3852
4529
  config,
3853
- "--name",
3854
4530
  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);
4531
+ vars
4532
+ }), { env: {
4533
+ ...deployEnv,
4534
+ WRANGLER_OUTPUT_FILE_PATH: outFile
4535
+ } });
3860
4536
  rmSync(outDir, {
3861
4537
  recursive: true,
3862
4538
  force: true
3863
4539
  });
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
- }
4540
+ const health = await waitHealthy(siteUrl, fetchImpl);
4541
+ if (health === null) throw new CliError(`${siteUrl} 起来了但 /healthz 一直不通(等了 ${HEALTH_TIMEOUT_MS / 1e3}s)`, `跑 npx ${WRANGLER} tail --name ${name} 看 Worker 日志;Access 策略已经配好,修完不用重跑 jdu deploy`);
4542
+ if (health.authProvider !== "cloudflare-access") throw new CliError(`${siteUrl} 的 authProvider 是 ${health.authProvider ?? "未知"},不是 cloudflare-access`, "确认 wrangler 变量写进去了(wrangler deploy 会打印绑定列表)");
3880
4543
  let loggedIn = false;
3881
- if (setupSecret !== null) {
3882
- await runLogin({
3883
- server: url,
3884
- token: setupSecret,
3885
- browser: false
3886
- });
3887
- loggedIn = true;
3888
- }
4544
+ if (opts.login !== false) loggedIn = (await runLogin({
4545
+ server: siteUrl,
4546
+ browser: true
4547
+ })).loggedIn;
3889
4548
  return {
3890
4549
  name,
3891
- url,
3892
- setupSecret,
3893
- setupUrl: setupSecret === null ? null : `${url}/setup?invite=${setupSecret}`,
4550
+ url: siteUrl,
4551
+ accountId: identity.accountId,
4552
+ ownerEmail: identity.email,
4553
+ emails,
4554
+ accessAppId: app.id,
4555
+ upgraded: existed,
3894
4556
  loggedIn
3895
4557
  };
3896
4558
  } finally {
3897
4559
  cleanup();
3898
4560
  }
3899
4561
  }
4562
+ /** 已存在的应用(升级路径):只用来报「这次是升级」,邮箱列表由 ensureAccessApp 保证不动。 */
3900
4563
  //#endregion
3901
4564
  //#region src/http.ts
3902
4565
  /** 错误响应体太长会淹没终端,只留头部。 */
@@ -3904,6 +4567,8 @@ var MAX_DETAIL = 400;
3904
4567
  var ApiClient = class {
3905
4568
  server;
3906
4569
  token;
4570
+ /** token 是 Cloudflare Access 应用 token:带上 cf-access-token 头过边缘(见 docs/deploy.md) */
4571
+ cfAccess;
3907
4572
  /** forward-auth 直连身份:作为 X-Forwarded-User 注入(server 默认 userHeader) */
3908
4573
  user;
3909
4574
  /** forward-auth fail-closed 的共享密钥:作为 X-Jiandu-Proxy-Secret 注入 */
@@ -3911,6 +4576,7 @@ var ApiClient = class {
3911
4576
  constructor(cfg) {
3912
4577
  this.server = cfg.server;
3913
4578
  this.token = cfg.token;
4579
+ this.cfAccess = cfg.cfAccess === true;
3914
4580
  this.user = cfg.user;
3915
4581
  this.proxySecret = cfg.proxySecret;
3916
4582
  }
@@ -3959,7 +4625,10 @@ var ApiClient = class {
3959
4625
  async send(method, path, body, contentType) {
3960
4626
  const url = this.url(path);
3961
4627
  const headers = {};
3962
- if (this.token !== void 0) headers.Authorization = `Bearer ${this.token}`;
4628
+ if (this.token !== void 0) {
4629
+ if (this.cfAccess) headers["cf-access-token"] = this.token;
4630
+ else headers.Authorization = `Bearer ${this.token}`;
4631
+ }
3963
4632
  if (this.user !== void 0) headers["X-Forwarded-User"] = this.user;
3964
4633
  if (this.proxySecret !== void 0) headers["X-Jiandu-Proxy-Secret"] = this.proxySecret;
3965
4634
  if (contentType !== void 0) headers["Content-Type"] = contentType;
@@ -4003,7 +4672,7 @@ async function httpError(method, url, res) {
4003
4672
  const msg = obj.error ?? obj.message;
4004
4673
  if (typeof msg === "string" && msg !== "") detail = msg;
4005
4674
  } 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;
4675
+ const hint = res.status === 401 || res.status === 403 ? "token 无效或无权访问,执行 jdu login --server <url>;forward-auth 下确认已设 JIANDU_FORWARD_AUTH_USER 与 JIANDU_PROXY_SECRET" : res.status === 302 ? "网关要登录。Cloudflare 形态下重新执行 jdu login;forward-auth 下先 jdu login,且网关需接受 Authorization: Bearer" : void 0;
4007
4676
  const suffix = detail === "" ? "" : `:${detail}`;
4008
4677
  return new CliError(`${method} ${url} 返回 HTTP ${res.status} ${res.statusText}${suffix}`, hint);
4009
4678
  }
@@ -5949,11 +6618,14 @@ async function client() {
5949
6618
  function collectTag(value, previous) {
5950
6619
  return [...previous ?? [], value];
5951
6620
  }
5952
- var VERSION = "0.5.0";
6621
+ var VERSION = "0.6.0";
5953
6622
  var program = new Command();
5954
6623
  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);
6624
+ program.command("deploy").description("把一台简牍部署到你自己的 Cloudflare 账号(Worker + Durable Object,Free 计划够用)").option("--name <name>", "Worker 名字,决定 workers.dev 的子域名前缀", "jiandu").option("--dist <dir>", "用本地构建的产物代替 npm 包(开发用,指向 server/dist-cf)").option("--cf-token <token>", "带 Access 权限的 Cloudflare API token(不给就走浏览器 OAuth / 本地缓存)").option("--no-login", "部署完不自动 jdu login(之后手动跑一次即可)").option("--dry-run", "只校验配置与产物,不上传、不碰你的账号", false).option("--json", "机器可读输出(agent 联动用)", false).action(async (opts) => {
6625
+ const result = await runDeploy({
6626
+ ...opts,
6627
+ login: opts.login && !opts.json
6628
+ });
5957
6629
  if (opts.json) {
5958
6630
  process.stdout.write(`${JSON.stringify(result)}\n`);
5959
6631
  return;
@@ -5962,14 +6634,31 @@ program.command("deploy").description("把一台简牍部署到你自己的 Clou
5962
6634
  process.stdout.write("校验通过:产物与 wrangler 配置都没问题,没有上传任何东西\n");
5963
6635
  return;
5964
6636
  }
5965
- process.stdout.write(`已部署 ${result.url}\n`);
5966
- if (result.setupSecret === null) {
5967
- process.stdout.write("实例本来就在跑,secret 与配置原样保留(这是一次升级)\n");
6637
+ process.stdout.write(`已部署 ${result.url}${result.upgraded ? "(升级:Access 策略与实例数据都没动)" : ""}\n`);
6638
+ process.stdout.write(`\n浏览器打开它,用 Cloudflare 账号(${result.ownerEmail})登录;公开文档匿名可读。\n`);
6639
+ process.stdout.write(`加人:jdu access allow <邮箱>\n`);
6640
+ if (result.loggedIn) process.stdout.write("\njdu 已经连上它了,可以直接 jdu push\n");
6641
+ else process.stdout.write(`\n下一步:jdu login --server ${result.url}\n`);
6642
+ });
6643
+ var access = program.command("access").description("Cloudflare 形态的准入(Access 策略里的邮箱列表)");
6644
+ access.command("list").description("列出能登录这台实例的邮箱").option("--server <url>", "目标站点(缺省用 jdu login 时定下的那台)").option("--cf-token <token>", "带 Access 权限的 Cloudflare API token").option("--json", "机器可读输出", false).action(async (opts) => {
6645
+ const result = await runAccessList(opts);
6646
+ if (opts.json) {
6647
+ process.stdout.write(`${JSON.stringify(result)}\n`);
5968
6648
  return;
5969
6649
  }
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");
6650
+ if (result.emails.length === 0) process.stdout.write(`(${result.server} 的 Access 策略里没有邮箱)\n`);
6651
+ for (const email of result.emails) process.stdout.write(`${email}\n`);
6652
+ });
6653
+ access.command("allow").argument("<email...>", "要允许登录的邮箱(对方的 Cloudflare 账号邮箱)").description("把邮箱写进 Access 策略:对方用自己的 Cloudflare 账号(同一邮箱)登录").option("--server <url>", "目标站点(缺省用 jdu login 时定下的那台)").option("--cf-token <token>", "带 Access 权限的 Cloudflare API token").option("--json", "机器可读输出", false).action(async (emails, opts) => {
6654
+ const result = await runAccessAllow(emails, opts);
6655
+ if (opts.json) process.stdout.write(`${JSON.stringify(result)}\n`);
6656
+ else process.stdout.write(`已允许:${result.emails.join(", ")}\n`);
6657
+ });
6658
+ access.command("deny").argument("<email...>", "要移除的邮箱").description("把邮箱从 Access 策略里去掉:对方即刻失去访问(浏览器与 CLI 同时失效)").option("--server <url>", "目标站点(缺省用 jdu login 时定下的那台)").option("--cf-token <token>", "带 Access 权限的 Cloudflare API token").option("--json", "机器可读输出", false).action(async (emails, opts) => {
6659
+ const result = await runAccessDeny(emails, opts);
6660
+ if (opts.json) process.stdout.write(`${JSON.stringify(result)}\n`);
6661
+ else process.stdout.write(`剩余邮箱:${result.emails.join(", ")}\n`);
5973
6662
  });
5974
6663
  program.command("login").description("登录到一台简牍:定站点、拿凭据、写本地配置(必须指定 --server 或 --local)").option("--local", "目标是本机自部署(http://127.0.0.1:8080)").option("--server <url>", "目标简牍站点地址").option("--token <token>", "直接用 token 登录(无浏览器的机器;token 在 server 首启 stdout / data/initial-token.txt)").option("--user <owner>", "forward-auth:以指定 owner 直连,跳过 SSO(请求注入 X-Forwarded-User 头)").option("--proxy-secret <secret>", "forward-auth:网关共享密钥(X-Jiandu-Proxy-Secret 头)").option("--issuer <url>", "OIDC issuer(缺省读 healthz.oidc 或 JIANDU_OIDC_ISSUER)").option("--client-id <id>", "OIDC client id(缺省读 healthz.oidc 或 JIANDU_OIDC_CLIENT_ID)").option("--no-browser", "站点支持浏览器授权时也不打开浏览器,只给 token 指引").option("--json", "机器可读输出(agent 联动用;不会打开浏览器)", false).action(async (opts) => {
5975
6664
  const result = await runLogin({