@crabcloud/cli 0.1.7 → 0.1.9

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/dist/crab.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // src/index.ts
4
4
  import { hostname, homedir as homedir2 } from "node:os";
5
5
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
6
- import { join as join2 } from "node:path";
6
+ import { basename, join as join2 } from "node:path";
7
7
 
8
8
  // ../../node_modules/.bun/zod@4.6.5/node_modules/zod/v4/core/util.js
9
9
  function getEnumValues(entries) {
@@ -5724,6 +5724,9 @@ function superRefine(fn, params) {
5724
5724
  var accountDomain = "crabcloud.cc";
5725
5725
  var adminDomain = `admin.${accountDomain}`;
5726
5726
  var storageDomain = `storage.${accountDomain}`;
5727
+ function storagePublicUrl(objectId) {
5728
+ return `https://${storageDomain}/o/${objectId}`;
5729
+ }
5727
5730
  var clientTypes = ["web", "agent", "miniapp", "native"];
5728
5731
  var scopes = [
5729
5732
  "account.read",
@@ -5791,7 +5794,7 @@ var registerInput = object({
5791
5794
  code: string2().trim().regex(/^\d{6}$/),
5792
5795
  displayName: string2().trim().min(1).max(80),
5793
5796
  password: string2().min(8).max(256),
5794
- invite: string2().trim().min(16).max(128).optional()
5797
+ invite: string2().trim().max(64).transform((v) => v.toUpperCase()).optional()
5795
5798
  });
5796
5799
  var loginInput = object({
5797
5800
  username: string2().trim().toLowerCase().min(3).max(254),
@@ -5866,12 +5869,23 @@ var adminCreditPackInput = object({
5866
5869
  title: string2().trim().min(1).max(100),
5867
5870
  credits: number2().int().positive().max(1e7),
5868
5871
  priceCents: number2().int().positive(),
5869
- currency: string2().trim().length(3).default("CNY"),
5872
+ currency: string2().trim().length(3).default("USD"),
5870
5873
  sort: number2().int().min(0).max(1e4).default(0)
5871
5874
  });
5872
5875
  var adminCreditPackUpdateInput = adminCreditPackInput.partial().extend({
5873
5876
  status: _enum(creditPackStatuses).optional()
5874
5877
  });
5878
+ var adminPricingOverridesInput = object({
5879
+ overrides: record(string2().trim().min(1).max(100), union([number2().int().positive().max(1e8), _null3()]))
5880
+ });
5881
+ var adminPlanTierStatuses = ["active", "hidden"];
5882
+ var adminPlanTierUpdateInput = object({
5883
+ name: string2().trim().min(1).max(50).optional(),
5884
+ priceCents: number2().int().min(0).max(1e8).nullable().optional(),
5885
+ monthlyCredits: number2().int().min(0).max(1e8).optional(),
5886
+ quotaJson: record(string2(), unknown()).optional(),
5887
+ status: _enum(adminPlanTierStatuses).optional()
5888
+ });
5875
5889
  var giftFulfillments = ["stock", "webhook"];
5876
5890
  var giftMerchantStatuses = ["active", "disabled"];
5877
5891
  var giftProductStatuses = ["draft", "live", "delisted"];
@@ -5977,6 +5991,57 @@ var contactQueryInput = object({
5977
5991
  offset: number2().int().min(0).default(0)
5978
5992
  });
5979
5993
 
5994
+ // ../../packages/cloudkit/src/crypto.ts
5995
+ var enc = new TextEncoder;
5996
+ var b64urlEncode = (bytes) => {
5997
+ let s = "";
5998
+ for (const b of bytes)
5999
+ s += String.fromCharCode(b);
6000
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
6001
+ };
6002
+ var b64urlDecode = (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
6003
+ function randomCode(length = 6) {
6004
+ const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
6005
+ return Array.from(crypto.getRandomValues(new Uint8Array(length)), (byte) => alphabet[byte % alphabet.length]).join("");
6006
+ }
6007
+
6008
+ // ../../packages/cloudkit/src/secretbox.ts
6009
+ var enc2 = new TextEncoder;
6010
+ var utf8 = (s) => enc2.encode(s);
6011
+ var DEFAULT_KEK_ITERATIONS = 210000;
6012
+ async function deriveKekBits(password, salt, iterations = DEFAULT_KEK_ITERATIONS) {
6013
+ const key = await crypto.subtle.importKey("raw", utf8(password), "PBKDF2", false, ["deriveBits"]);
6014
+ return new Uint8Array(await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt, iterations }, key, 256));
6015
+ }
6016
+ async function importAesKey(raw, usages = ["encrypt", "decrypt"]) {
6017
+ return crypto.subtle.importKey("raw", raw, "AES-GCM", false, usages);
6018
+ }
6019
+ function randomKeyBytes(length = 32) {
6020
+ return crypto.getRandomValues(new Uint8Array(length));
6021
+ }
6022
+ async function aeadEncrypt(key, plaintext, aad) {
6023
+ const iv = crypto.getRandomValues(new Uint8Array(12));
6024
+ const params = aad ? { name: "AES-GCM", iv, additionalData: aad } : { name: "AES-GCM", iv };
6025
+ const ciphertext = await crypto.subtle.encrypt(params, key, plaintext);
6026
+ return { iv, ciphertext: new Uint8Array(ciphertext) };
6027
+ }
6028
+ async function aeadDecrypt(key, sealed, aad) {
6029
+ const params = aad ? { name: "AES-GCM", iv: sealed.iv, additionalData: aad } : { name: "AES-GCM", iv: sealed.iv };
6030
+ return new Uint8Array(await crypto.subtle.decrypt(params, key, sealed.ciphertext));
6031
+ }
6032
+ async function unwrapKeyBytes(kek, sealed, aad) {
6033
+ return aeadDecrypt(kek, sealed, aad);
6034
+ }
6035
+ function sealToB64url(sealed) {
6036
+ return `${b64urlEncode(sealed.iv)}.${b64urlEncode(sealed.ciphertext)}`;
6037
+ }
6038
+ function sealFromB64url(s) {
6039
+ const [iv, ct] = s.split(".");
6040
+ if (!iv || !ct)
6041
+ throw new Error("invalid sealed payload");
6042
+ return { iv: b64urlDecode(iv), ciphertext: b64urlDecode(ct) };
6043
+ }
6044
+
5980
6045
  // ../../packages/skills/src/install.ts
5981
6046
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5982
6047
  import { homedir } from "node:os";
@@ -6209,6 +6274,7 @@ function exitCodeForApiError(body) {
6209
6274
  case "bad_request":
6210
6275
  return EXIT_USAGE;
6211
6276
  case "insufficient_credits":
6277
+ case "storage.quotaExceeded":
6212
6278
  return EXIT_QUOTA;
6213
6279
  default:
6214
6280
  return 1;
@@ -6264,10 +6330,47 @@ var usage = `crab — Crab Cloud CLI(Agent-first 入口)
6264
6330
  列出邮件(cursor 分页契约与 Web 工作台一致)
6265
6331
  mail read <id> [--json] 读取邮件正文(自动置已读)
6266
6332
  mail search <query> [--json] 全文搜索(主题/发件人/收件人/正文)
6267
- mail send --to a@x.cc,b@y.cn [--cc c@z.io] --subject "..." --body "..." | --body-file <path>
6268
- [--in-reply-to <message-id>] 回信线程锚点(原信 RFC Message-ID,crab mail read 可见)
6269
- 发送邮件:平台内(@crabcloud.cc)免费,外部出站 2 积分/封
6333
+ mail send --to a@x.cc|联系人名,b@y.cn [--cc c@z.io] --subject "..." --body "..." | --body-file <path>
6334
+ [--attach <云盘id|文件名>,...] [--in-reply-to <message-id>]
6335
+ 回信线程锚点(原信 RFC Message-ID,crab mail read 可见)
6336
+ 发送邮件:不含 @ 的收件人按联系人簿解析(备注名/地址精确
6337
+ 命中采用,多候选报错列出);--attach 引用云盘已有对象
6338
+ (id 或文件名精确唯一命中,未绑信对象,≤10 个,大文件
6339
+ 自动转下载链接);平台内(@crabcloud.cc)免费,外部出站
6340
+ 2 积分/封
6270
6341
  mail archive|trash|delete <id> 移夹 / 废纸篓 / 彻底删除(delete 不可恢复)
6342
+ contacts list [query] [--offset N] [--json] 列出联系人(地址前缀/显示名匹配)
6343
+ contacts add <address> [--name 名] [--note 备注] [--json]
6344
+ 添加联系人(站内地址自动关联对方账号)
6345
+ contacts rm <id|address> 删除联系人
6346
+ contacts invite [--reset] [--json] 我的邀请码、链接与统计(--reset 重置,旧链接失效)
6347
+ contacts invitees [--offset N] [--json]
6348
+ 我邀请的人(直接下级 + 间接计数)
6349
+ storage ls [--q 关键字] [--source files|media|mail] [--category <id|none>]
6350
+ [--type doc|image|video|archive|code] [--tag a,b] [--trash]
6351
+ [--shared] [--recent] [--message-id <id>] [--limit N] [--offset N] [--json]
6352
+ 列出云盘对象(跨来源统一视图,能力与 Web 云盘一致)
6353
+ storage upload <file...> [--json] 上传一个或多个文件(直传优先,自动回退中转)
6354
+ storage download <id|文件名> [-o 输出路径]
6355
+ 下载对象内容(默认按原文件名存到当前目录)
6356
+ storage rename <id|文件名> <新名称> 重命名
6357
+ storage tag <id|文件名> --add a,b [--remove c,d]
6358
+ 增删标签
6359
+ storage category <id|文件名> <categoryId|none>
6360
+ 设置分类(分类 id 见 storage categories)
6361
+ storage publish|unpublish <id|文件名>
6362
+ 公开 / 收回公开(公开时打印公开直链)
6363
+ storage rm|restore|purge <id|文件名>
6364
+ 回收站 / 恢复 / 彻底删除(purge 不可恢复)
6365
+ storage quota [--json] 存储水位(统一容量账:文件/素材/邮件附件一本账)
6366
+ storage share <id|文件名> [--expires 7|30|90|forever] [--passcode 提取码]
6367
+ 创建分享链接(加密文件链接自带解密钥匙 #fragment)
6368
+ storage shares <id|文件名> [--json]
6369
+ 该文件的分享链接列表(状态/下载计数)
6370
+ storage unshare <shareId> 撤销分享
6371
+ storage categories [--json] 分类列表
6372
+ storage mkcat <名称> 新建分类
6373
+ storage tags [--json] 标签汇总
6271
6374
  skills install|list|uninstall [--agent claude,zcode,codex,agents]
6272
6375
  skills 管理(claude/zcode/agents 复制目录,codex 写 AGENTS.md 段)
6273
6376
  logout 撤销当前 Agent Token 并清除本地凭证
@@ -6370,7 +6473,7 @@ async function whoami() {
6370
6473
  if (subscription)
6371
6474
  console.log(` 订阅: ${subscription.tier_name}`);
6372
6475
  if (credits !== undefined)
6373
- console.log(` 积分余额: ${credits}`);
6476
+ console.log(` 积分余额: $${(credits / 100).toFixed(2)} (${credits} credit_cents)`);
6374
6477
  if (apps?.length)
6375
6478
  console.log(` 已启用应用: ${apps.map((a) => a.title).join("、")}`);
6376
6479
  return 0;
@@ -6499,6 +6602,13 @@ async function mailRead(id) {
6499
6602
  console.log(` 回复: ${mail.inReplyTo}`);
6500
6603
  console.log("");
6501
6604
  console.log(mail.bodyText);
6605
+ if (mail.attachments.length > 0) {
6606
+ console.log("");
6607
+ console.log("附件:");
6608
+ for (const att of mail.attachments) {
6609
+ console.log(` ${att.id} ${att.filename} ${att.mimeType} ${formatBytesCli(att.sizeBytes)}`);
6610
+ }
6611
+ }
6502
6612
  if (mail.actors.length > 0) {
6503
6613
  console.log("");
6504
6614
  console.log("操作记录:");
@@ -6534,15 +6644,63 @@ async function mailSearch(query) {
6534
6644
  printMailRow(mail);
6535
6645
  return 0;
6536
6646
  }
6647
+ async function resolveRecipients(credentials, tokens) {
6648
+ const resolved = [];
6649
+ for (const token of tokens) {
6650
+ if (token.includes("@")) {
6651
+ resolved.push(token);
6652
+ continue;
6653
+ }
6654
+ const params = new URLSearchParams({ query: token, limit: "50" });
6655
+ const res = await api2(credentials.apiBase, `/v1/contacts?${params}`, { headers: authHeaders(credentials) });
6656
+ if (res.status !== 200) {
6657
+ console.error(errorMessage(res.body, "查询联系人失败。"));
6658
+ return null;
6659
+ }
6660
+ const items = res.body.data.items;
6661
+ const lowered = token.toLowerCase();
6662
+ const exact = items.filter((c) => c.address.toLowerCase() === lowered || (c.displayName ?? "").toLowerCase() === lowered);
6663
+ if (exact.length === 1) {
6664
+ resolved.push(exact[0].address);
6665
+ continue;
6666
+ }
6667
+ if (exact.length > 1) {
6668
+ console.error(`「${token}」匹配到多个联系人,请改用完整地址:`);
6669
+ for (const c of exact.slice(0, 5))
6670
+ console.error(` ${contactDisplayName(c)} <${c.address}>`);
6671
+ return null;
6672
+ }
6673
+ if (items.length === 1) {
6674
+ console.error(`「${token}」→ ${contactDisplayName(items[0])} <${items[0].address}>`);
6675
+ resolved.push(items[0].address);
6676
+ continue;
6677
+ }
6678
+ console.error(`「${token}」没有唯一命中的联系人。`);
6679
+ if (items.length > 0) {
6680
+ console.error("相近候选:");
6681
+ for (const c of items.slice(0, 5))
6682
+ console.error(` ${contactDisplayName(c)} <${c.address}>`);
6683
+ } else {
6684
+ console.error("联系人簿没有相近条目(可先 crab contacts list 查看)。");
6685
+ }
6686
+ return null;
6687
+ }
6688
+ return resolved;
6689
+ }
6537
6690
  async function mailSend() {
6538
- const to = (readFlag("--to") ?? "").split(",").map((x) => x.trim()).filter(Boolean);
6539
- const cc = (readFlag("--cc") ?? "").split(",").map((x) => x.trim()).filter(Boolean);
6691
+ const toRaw = (readFlag("--to") ?? "").split(",").map((x) => x.trim()).filter(Boolean);
6692
+ const ccRaw = (readFlag("--cc") ?? "").split(",").map((x) => x.trim()).filter(Boolean);
6693
+ const attachRaw = (readFlag("--attach") ?? "").split(",").map((x) => x.trim()).filter(Boolean);
6540
6694
  const subject = readFlag("--subject") ?? "";
6541
6695
  const bodyFlag = readFlag("--body");
6542
6696
  const bodyFile = readFlag("--body-file");
6543
6697
  const inReplyTo = readFlag("--in-reply-to") ?? undefined;
6544
- if (to.length === 0 || !subject || !bodyFlag && !bodyFile) {
6545
- console.error('用法:crab mail send --to a@x.cc[,b@y.cn] [--cc c@z.io] --subject "..." --body "..." | --body-file <path> [--in-reply-to <message-id>]');
6698
+ if (toRaw.length === 0 || !subject || !bodyFlag && !bodyFile) {
6699
+ console.error('用法:crab mail send --to a@x.cc|联系人名[,b@y.cn] [--cc c@z.io] --subject "..." --body "..." | --body-file <path> [--attach <云盘id|文件名>,...] [--in-reply-to <message-id>]');
6700
+ return EXIT_USAGE;
6701
+ }
6702
+ if (attachRaw.length > 10) {
6703
+ console.error("附件最多 10 个。");
6546
6704
  return EXIT_USAGE;
6547
6705
  }
6548
6706
  let body = bodyFlag ?? "";
@@ -6557,10 +6715,29 @@ async function mailSend() {
6557
6715
  const credentials = requireCredentials();
6558
6716
  if (typeof credentials === "number")
6559
6717
  return credentials;
6718
+ const to = await resolveRecipients(credentials, toRaw);
6719
+ if (to === null)
6720
+ return 1;
6721
+ const cc = ccRaw.length > 0 ? await resolveRecipients(credentials, ccRaw) : [];
6722
+ if (cc === null)
6723
+ return 1;
6724
+ const attachmentIds = [];
6725
+ for (const token of attachRaw) {
6726
+ const hit = await resolveStorageObject(credentials, token);
6727
+ if (typeof hit === "number")
6728
+ return hit;
6729
+ if (typeof hit === "object" && hit.isEncrypted) {
6730
+ console.error(`「${token}」是端到端加密文件,暂不能作为邮件附件发送(服务端无法解密)。`);
6731
+ return EXIT_USAGE;
6732
+ }
6733
+ const id = typeof hit === "object" ? hit.id : hit;
6734
+ if (!attachmentIds.includes(id))
6735
+ attachmentIds.push(id);
6736
+ }
6560
6737
  const res = await api2(credentials.apiBase, "/v1/mail/messages", {
6561
6738
  method: "POST",
6562
6739
  headers: authHeaders(credentials),
6563
- body: JSON.stringify({ to, cc, subject, body, ...inReplyTo ? { inReplyTo } : {} })
6740
+ body: JSON.stringify({ to, cc, subject, body, ...inReplyTo ? { inReplyTo } : {}, ...attachmentIds.length > 0 ? { attachmentIds } : {} })
6564
6741
  });
6565
6742
  if (res.status !== 200 && res.status !== 201) {
6566
6743
  console.error(errorMessage(res.body, "发送失败。"));
@@ -6572,8 +6749,10 @@ async function mailSend() {
6572
6749
  return 0;
6573
6750
  }
6574
6751
  console.log(`✓ 已发送(id ${result.message.id})`);
6752
+ if (attachmentIds.length > 0)
6753
+ console.log(` 附件 ${attachmentIds.length} 个(云盘引用,不复制)`);
6575
6754
  if (result.chargedCredits > 0)
6576
- console.log(` 消耗积分:${result.chargedCredits}`);
6755
+ console.log(` 消耗积分:$${(result.chargedCredits / 100).toFixed(2)}(${result.chargedCredits} credit_cents)`);
6577
6756
  if (result.failedExternal > 0)
6578
6757
  console.log(` ⚠ ${result.failedExternal} 个外部收件人发送失败,对应积分已退回`);
6579
6758
  return 0;
@@ -6594,6 +6773,807 @@ async function mailMove(action, id) {
6594
6773
  console.log(`✓ ${action === "archive" ? "已归档" : action === "trash" ? "已移入废纸篓" : "已彻底删除"}(${id})`);
6595
6774
  return 0;
6596
6775
  }
6776
+ function contactDisplayName(contact) {
6777
+ return contact.displayName ?? contact.resolvedName ?? contact.address;
6778
+ }
6779
+ async function contactsList(query) {
6780
+ const credentials = requireCredentials();
6781
+ if (typeof credentials === "number")
6782
+ return credentials;
6783
+ const params = new URLSearchParams;
6784
+ if (query)
6785
+ params.set("query", query);
6786
+ const offset = readFlag("--offset");
6787
+ if (offset)
6788
+ params.set("offset", offset);
6789
+ params.set("limit", readFlag("--limit") ?? "30");
6790
+ const res = await api2(credentials.apiBase, `/v1/contacts?${params}`, { headers: authHeaders(credentials) });
6791
+ if (res.status !== 200) {
6792
+ console.error(errorMessage(res.body, "获取联系人失败。"));
6793
+ return exitCodeForApiError(res.body);
6794
+ }
6795
+ const page = res.body.data;
6796
+ if (hasFlag("--json")) {
6797
+ console.log(JSON.stringify(page, null, 2));
6798
+ return 0;
6799
+ }
6800
+ if (page.items.length === 0) {
6801
+ console.log("还没有联系人。");
6802
+ return 0;
6803
+ }
6804
+ for (const contact of page.items) {
6805
+ const name = contactDisplayName(contact);
6806
+ const address = name === contact.address ? "" : ` <${contact.address}>`;
6807
+ const linked = contact.linkedUsername ? ` [站内 ${contact.linkedUsername}]` : "";
6808
+ console.log(`${contact.id} ${name}${address}${linked}`);
6809
+ }
6810
+ console.log(`共 ${page.total} 条`);
6811
+ if (page.nextOffset !== null)
6812
+ console.log(`(还有更多:contacts list --offset ${page.nextOffset})`);
6813
+ return 0;
6814
+ }
6815
+ async function contactsAdd(address) {
6816
+ if (!address) {
6817
+ console.error("用法:crab contacts add <address> [--name 显示名] [--note 备注] [--json]");
6818
+ return EXIT_USAGE;
6819
+ }
6820
+ const credentials = requireCredentials();
6821
+ if (typeof credentials === "number")
6822
+ return credentials;
6823
+ const body = { address };
6824
+ const name = readFlag("--name");
6825
+ if (name)
6826
+ body.displayName = name;
6827
+ const note = readFlag("--note");
6828
+ if (note)
6829
+ body.note = note;
6830
+ const res = await api2(credentials.apiBase, "/v1/contacts", {
6831
+ method: "POST",
6832
+ headers: authHeaders(credentials),
6833
+ body: JSON.stringify(body)
6834
+ });
6835
+ if (res.status !== 200 && res.status !== 201) {
6836
+ console.error(errorMessage(res.body, "添加失败。"));
6837
+ return exitCodeForApiError(res.body);
6838
+ }
6839
+ const contact = res.body.data;
6840
+ if (hasFlag("--json")) {
6841
+ console.log(JSON.stringify(contact, null, 2));
6842
+ return 0;
6843
+ }
6844
+ console.log(`✓ 已添加:${contactDisplayName(contact)} <${contact.address}>(id ${contact.id})`);
6845
+ return 0;
6846
+ }
6847
+ async function contactsRm(idOrAddress) {
6848
+ if (!idOrAddress) {
6849
+ console.error("用法:crab contacts rm <id|address>");
6850
+ return EXIT_USAGE;
6851
+ }
6852
+ const credentials = requireCredentials();
6853
+ if (typeof credentials === "number")
6854
+ return credentials;
6855
+ let id = idOrAddress;
6856
+ if (idOrAddress.includes("@")) {
6857
+ const res = await api2(credentials.apiBase, `/v1/contacts?query=${encodeURIComponent(idOrAddress)}&limit=50`, {
6858
+ headers: authHeaders(credentials)
6859
+ });
6860
+ if (res.status !== 200) {
6861
+ console.error(errorMessage(res.body, "查找联系人失败。"));
6862
+ return exitCodeForApiError(res.body);
6863
+ }
6864
+ const page = res.body.data;
6865
+ const hit = page.items.find((item) => item.address === idOrAddress.toLowerCase());
6866
+ if (!hit) {
6867
+ console.error("联系人不存在或已删除。");
6868
+ return EXIT_NOT_FOUND;
6869
+ }
6870
+ id = hit.id;
6871
+ }
6872
+ const res = await api2(credentials.apiBase, `/v1/contacts/${encodeURIComponent(id)}`, {
6873
+ method: "DELETE",
6874
+ headers: authHeaders(credentials)
6875
+ });
6876
+ if (res.status !== 200) {
6877
+ console.error(errorMessage(res.body, "删除失败。"));
6878
+ return exitCodeForApiError(res.body);
6879
+ }
6880
+ console.log(`✓ 已删除(${id})`);
6881
+ return 0;
6882
+ }
6883
+ async function contactsInvite(reset) {
6884
+ const credentials = requireCredentials();
6885
+ if (typeof credentials === "number")
6886
+ return credentials;
6887
+ const res = await api2(credentials.apiBase, "/v1/contacts/invite/reset", {
6888
+ method: reset ? "POST" : "GET",
6889
+ headers: authHeaders(credentials)
6890
+ });
6891
+ if (res.status !== 200) {
6892
+ console.error(errorMessage(res.body, "获取邀请链接失败。"));
6893
+ return exitCodeForApiError(res.body);
6894
+ }
6895
+ const overview = res.body.data;
6896
+ if (hasFlag("--json")) {
6897
+ console.log(JSON.stringify(overview, null, 2));
6898
+ return 0;
6899
+ }
6900
+ console.log(`邀请码:${overview.token}`);
6901
+ console.log(`邀请链接:https://crabcloud.cc/register?invite=${overview.token}`);
6902
+ console.log(`已邀请 ${overview.directCount} 人 · 含间接 ${overview.indirectCount} 人`);
6903
+ if (!reset)
6904
+ console.log("(重置链接:crab contacts invite --reset)");
6905
+ return 0;
6906
+ }
6907
+ async function contactsInvitees() {
6908
+ const credentials = requireCredentials();
6909
+ if (typeof credentials === "number")
6910
+ return credentials;
6911
+ const offset = readFlag("--offset");
6912
+ const res = await api2(credentials.apiBase, `/v1/contacts/invitees?offset=${offset ?? "0"}&limit=30`, {
6913
+ headers: authHeaders(credentials)
6914
+ });
6915
+ if (res.status !== 200) {
6916
+ console.error(errorMessage(res.body, "获取邀请列表失败。"));
6917
+ return exitCodeForApiError(res.body);
6918
+ }
6919
+ const page = res.body.data;
6920
+ if (hasFlag("--json")) {
6921
+ console.log(JSON.stringify(page, null, 2));
6922
+ return 0;
6923
+ }
6924
+ if (page.items.length === 0) {
6925
+ console.log("还没有人通过你的链接加入。");
6926
+ return 0;
6927
+ }
6928
+ for (const invitee of page.items) {
6929
+ console.log(`${invitee.username} ${invitee.displayName} ${new Date(invitee.joinedAt).toLocaleDateString("zh-CN")}`);
6930
+ }
6931
+ console.log(`直接 ${page.directTotal} · 间接 ${page.indirectTotal}`);
6932
+ if (page.nextOffset !== null)
6933
+ console.log(`(还有更多:contacts invitees --offset ${page.nextOffset})`);
6934
+ return 0;
6935
+ }
6936
+ var STORAGE_SOURCES = ["files", "media", "mail"];
6937
+ var STORAGE_TYPES = ["doc", "image", "video", "archive", "code"];
6938
+ var MIME_BY_EXT = {
6939
+ png: "image/png",
6940
+ jpg: "image/jpeg",
6941
+ jpeg: "image/jpeg",
6942
+ gif: "image/gif",
6943
+ webp: "image/webp",
6944
+ svg: "image/svg+xml",
6945
+ pdf: "application/pdf",
6946
+ txt: "text/plain",
6947
+ md: "text/markdown",
6948
+ csv: "text/csv",
6949
+ json: "application/json",
6950
+ xml: "application/xml",
6951
+ yaml: "application/x-yaml",
6952
+ yml: "application/x-yaml",
6953
+ js: "text/javascript",
6954
+ zip: "application/zip",
6955
+ gz: "application/gzip",
6956
+ mp3: "audio/mpeg",
6957
+ mp4: "video/mp4"
6958
+ };
6959
+ function mimeFor(filename) {
6960
+ const ext = filename.split(".").pop()?.toLowerCase() ?? "";
6961
+ return MIME_BY_EXT[ext] ?? "application/octet-stream";
6962
+ }
6963
+ function formatBytesCli(n) {
6964
+ if (n >= 1073741824)
6965
+ return `${(n / 1073741824).toFixed(1)} GB`;
6966
+ if (n >= 1048576)
6967
+ return `${(n / 1048576).toFixed(1)} MB`;
6968
+ if (n >= 1024)
6969
+ return `${(n / 1024).toFixed(1)} KB`;
6970
+ return `${n} B`;
6971
+ }
6972
+ function printStorageRow(o) {
6973
+ const flags = [
6974
+ o.source !== "files" ? o.source === "media" ? "素材" : "邮件附件" : null,
6975
+ o.isEncrypted ? "加密" : null,
6976
+ o.isPublic ? "公开" : null
6977
+ ].filter(Boolean);
6978
+ console.log(`${o.id} ${o.filename} ${formatBytesCli(o.sizeBytes)} ${formatTime(o.createdAt)}${flags.length > 0 ? ` [${flags.join(" · ")}]` : ""}`);
6979
+ }
6980
+ async function resolveStorageObject(credentials, token) {
6981
+ for (const trash of [false, true]) {
6982
+ const params = new URLSearchParams({ limit: "100", q: token });
6983
+ if (trash)
6984
+ params.set("trash", "1");
6985
+ const res = await api2(credentials.apiBase, `/v1/storage?${params}`, {
6986
+ headers: authHeaders(credentials)
6987
+ });
6988
+ if (res.status !== 200) {
6989
+ console.error(errorMessage(res.body, "查找对象失败。"));
6990
+ return exitCodeForApiError(res.body);
6991
+ }
6992
+ const exact = res.body.data.objects.filter((o) => o.filename === token);
6993
+ if (exact.length > 1) {
6994
+ console.error(`文件名「${token}」命中多个对象,请用 id 指定(crab storage ls 查询)。`);
6995
+ return EXIT_USAGE;
6996
+ }
6997
+ if (exact.length === 1)
6998
+ return exact[0];
6999
+ }
7000
+ return token;
7001
+ }
7002
+ async function resolveStorageId(credentials, token) {
7003
+ const hit = await resolveStorageObject(credentials, token);
7004
+ if (typeof hit === "string" || typeof hit === "number")
7005
+ return hit;
7006
+ return hit.id;
7007
+ }
7008
+ async function storageList() {
7009
+ const credentials = requireCredentials();
7010
+ if (typeof credentials === "number")
7011
+ return credentials;
7012
+ const params = new URLSearchParams({ limit: readFlag("--limit") ?? "30" });
7013
+ const source = readFlag("--source");
7014
+ if (source) {
7015
+ if (!STORAGE_SOURCES.includes(source)) {
7016
+ console.error(`未知来源:${source}
7017
+ 可用:${STORAGE_SOURCES.join(", ")}`);
7018
+ return EXIT_USAGE;
7019
+ }
7020
+ params.set("source", source);
7021
+ }
7022
+ const type = readFlag("--type");
7023
+ if (type) {
7024
+ if (!STORAGE_TYPES.includes(type)) {
7025
+ console.error(`未知类型:${type}
7026
+ 可用:${STORAGE_TYPES.join(", ")}`);
7027
+ return EXIT_USAGE;
7028
+ }
7029
+ params.set("type", type);
7030
+ }
7031
+ const category = readFlag("--category");
7032
+ if (category)
7033
+ params.set("category", category);
7034
+ const tags = readFlag("--tag");
7035
+ if (tags)
7036
+ params.set("tags", tags);
7037
+ if (hasFlag("--trash"))
7038
+ params.set("trash", "1");
7039
+ if (hasFlag("--shared"))
7040
+ params.set("public", "1");
7041
+ if (hasFlag("--recent"))
7042
+ params.set("since", String(Date.now() - 604800000));
7043
+ const offset = readFlag("--offset");
7044
+ if (offset)
7045
+ params.set("offset", offset);
7046
+ const q = readFlag("--q");
7047
+ if (q)
7048
+ params.set("q", q);
7049
+ const messageId = readFlag("--message-id");
7050
+ if (messageId)
7051
+ params.set("messageId", messageId);
7052
+ const res = await api2(credentials.apiBase, `/v1/storage?${params}`, { headers: authHeaders(credentials) });
7053
+ if (res.status !== 200) {
7054
+ console.error(errorMessage(res.body, "获取云盘列表失败。"));
7055
+ return exitCodeForApiError(res.body);
7056
+ }
7057
+ const page = res.body.data;
7058
+ if (hasFlag("--json")) {
7059
+ console.log(JSON.stringify(page, null, 2));
7060
+ return 0;
7061
+ }
7062
+ if (page.objects.length === 0) {
7063
+ console.log("云盘里没有匹配的对象。");
7064
+ return 0;
7065
+ }
7066
+ for (const object of page.objects)
7067
+ printStorageRow(object);
7068
+ console.log(`共 ${page.total} 条`);
7069
+ if (page.nextOffset !== null)
7070
+ console.log(`(还有更多:storage ls --offset ${page.nextOffset})`);
7071
+ return 0;
7072
+ }
7073
+ var utf82 = (s) => new TextEncoder().encode(s);
7074
+ var MASTER_AAD = utf82("drive-master");
7075
+ var DATA_AAD = utf82("drive-data");
7076
+ var KEK_ITERATIONS = 210000;
7077
+ function drivePassphrase() {
7078
+ return readFlag("--passphrase") ?? process.env.CRAB_PASSPHRASE;
7079
+ }
7080
+ async function driveMasterKey(credentials) {
7081
+ const passphrase = drivePassphrase();
7082
+ if (!passphrase) {
7083
+ console.error("需要云盘口令:--passphrase <口令> 或环境变量 CRAB_PASSPHRASE。");
7084
+ return EXIT_USAGE;
7085
+ }
7086
+ const res = await api2(credentials.apiBase, "/v1/storage/keys", { headers: authHeaders(credentials) });
7087
+ if (res.status === 404) {
7088
+ console.error("云盘口令尚未设置——正在初始化(恢复码只显示一次,请立即抄写):");
7089
+ const salt = b64urlEncode(randomKeyBytes(16));
7090
+ const masterRaw = randomKeyBytes(32);
7091
+ const recoveryCode = randomCode(16);
7092
+ const kek = await importAesKey(await deriveKekBits(passphrase, b64urlDecode(salt), KEK_ITERATIONS));
7093
+ const recoveryKek = await importAesKey(await deriveKekBits(recoveryCode, b64urlDecode(salt), KEK_ITERATIONS));
7094
+ const wrapped = await aeadEncrypt(kek, masterRaw, MASTER_AAD);
7095
+ const recoveryWrapped = await aeadEncrypt(recoveryKek, masterRaw, MASTER_AAD);
7096
+ const put = await api2(credentials.apiBase, "/v1/storage/keys", {
7097
+ method: "PUT",
7098
+ headers: authHeaders(credentials),
7099
+ body: JSON.stringify({
7100
+ kdfSalt: salt,
7101
+ kdfIterations: KEK_ITERATIONS,
7102
+ wrappedMasterKey: `u1.${sealToB64url({ iv: wrapped.iv, ciphertext: wrapped.ciphertext })}`,
7103
+ recoveryWrappedMasterKey: `r1.${sealToB64url({ iv: recoveryWrapped.iv, ciphertext: recoveryWrapped.ciphertext })}`
7104
+ })
7105
+ });
7106
+ if (put.status !== 200 && put.status !== 201) {
7107
+ console.error(errorMessage(put.body, "初始化云盘口令失败。"));
7108
+ return exitCodeForApiError(put.body);
7109
+ }
7110
+ console.log(`★ 恢复码:${recoveryCode}`);
7111
+ console.log(" (口令遗忘时的唯一找回方式;平台无后门,丢失即数据不可解)");
7112
+ return importAesKey(masterRaw);
7113
+ }
7114
+ if (res.status !== 200) {
7115
+ console.error(errorMessage(res.body, "获取云盘密钥失败。"));
7116
+ return exitCodeForApiError(res.body);
7117
+ }
7118
+ const keys = res.body.data;
7119
+ try {
7120
+ const kek = await importAesKey(await deriveKekBits(passphrase, b64urlDecode(keys.kdfSalt), keys.kdfIterations));
7121
+ return await importAesKey(await unwrapKeyBytes(kek, sealFromB64url(keys.wrappedMasterKey.slice(3)), MASTER_AAD));
7122
+ } catch {
7123
+ console.error("口令错误。");
7124
+ return 1;
7125
+ }
7126
+ }
7127
+ async function storageUploadOne(credentials, filePath) {
7128
+ let buf;
7129
+ try {
7130
+ buf = readFileSync2(filePath);
7131
+ } catch {
7132
+ console.error(`读取文件失败:${filePath}`);
7133
+ return EXIT_USAGE;
7134
+ }
7135
+ const filename = basename(filePath);
7136
+ const mimeType = mimeFor(filename);
7137
+ const master = await driveMasterKey(credentials);
7138
+ if (typeof master === "number")
7139
+ return master;
7140
+ const dataKey = randomKeyBytes(32);
7141
+ const sealed = await aeadEncrypt(await importAesKey(dataKey), new Uint8Array(buf), DATA_AAD);
7142
+ const cipher = new Uint8Array([...sealed.iv, ...sealed.ciphertext]);
7143
+ const wrapped = await aeadEncrypt(master, dataKey, DATA_AAD);
7144
+ const wrappedDataKey = `u1.${sealToB64url({ iv: wrapped.iv, ciphertext: wrapped.ciphertext })}`;
7145
+ const presign = await api2(credentials.apiBase, "/v1/storage/presign", {
7146
+ method: "POST",
7147
+ headers: authHeaders(credentials),
7148
+ body: JSON.stringify({ filename, mimeType, sizeBytes: cipher.byteLength, enc: true })
7149
+ });
7150
+ if (presign.status === 200) {
7151
+ const { fileId, uploadUrl } = presign.body.data;
7152
+ const put = await fetch(uploadUrl, { method: "PUT", body: cipher, headers: { "content-type": mimeType } });
7153
+ if (!put.ok) {
7154
+ console.error(`直传失败(${put.status}):${filename}`);
7155
+ return 1;
7156
+ }
7157
+ const complete = await api2(credentials.apiBase, `/v1/storage/${encodeURIComponent(fileId)}/complete`, {
7158
+ method: "POST",
7159
+ headers: authHeaders(credentials),
7160
+ body: JSON.stringify({ isEncrypted: true, wrappedDataKey })
7161
+ });
7162
+ if (complete.status !== 200 && complete.status !== 201) {
7163
+ console.error(errorMessage(complete.body, `记账失败:${filename}`));
7164
+ return exitCodeForApiError(complete.body);
7165
+ }
7166
+ const object = complete.body.data;
7167
+ if (hasFlag("--json"))
7168
+ console.log(JSON.stringify(object, null, 2));
7169
+ else
7170
+ console.log(`✓ 已上传(已加密):${object.filename}(id ${object.id})`);
7171
+ return 0;
7172
+ }
7173
+ if (presign.body?.error?.code === "presign_unavailable") {
7174
+ const form = new FormData;
7175
+ form.append("file", new Blob([cipher], { type: mimeType }), filename);
7176
+ form.append("enc", "1");
7177
+ form.append("wrappedDataKey", wrappedDataKey);
7178
+ const res = await fetch(`${credentials.apiBase}/v1/storage`, {
7179
+ method: "POST",
7180
+ headers: authHeaders(credentials),
7181
+ body: form
7182
+ });
7183
+ const body = await res.json().catch(() => ({}));
7184
+ if (res.status !== 200 && res.status !== 201) {
7185
+ console.error(body.error?.message ?? `上传失败:${filename}`);
7186
+ return exitCodeForApiError(body);
7187
+ }
7188
+ if (hasFlag("--json"))
7189
+ console.log(JSON.stringify(body.data, null, 2));
7190
+ else
7191
+ console.log(`✓ 已上传(已加密):${body.data?.filename}(id ${body.data?.id})`);
7192
+ return 0;
7193
+ }
7194
+ console.error(errorMessage(presign.body, `上传失败:${filename}`));
7195
+ return exitCodeForApiError(presign.body);
7196
+ }
7197
+ async function storageUpload(paths) {
7198
+ const files = [];
7199
+ for (let i = 0;i < paths.length; i++) {
7200
+ if (paths[i].startsWith("--")) {
7201
+ if (paths[i] === "--passphrase")
7202
+ i++;
7203
+ continue;
7204
+ }
7205
+ files.push(paths[i]);
7206
+ }
7207
+ if (files.length === 0) {
7208
+ console.error("用法:crab storage upload <file...> [--passphrase 口令] [--json]");
7209
+ return EXIT_USAGE;
7210
+ }
7211
+ const credentials = requireCredentials();
7212
+ if (typeof credentials === "number")
7213
+ return credentials;
7214
+ let code = 0;
7215
+ for (const file of files) {
7216
+ const result = await storageUploadOne(credentials, file);
7217
+ if (result !== 0 && code === 0)
7218
+ code = result;
7219
+ if (result === EXIT_AUTH || result === EXIT_SCOPE)
7220
+ return result;
7221
+ }
7222
+ return code;
7223
+ }
7224
+ async function storageDownload(token) {
7225
+ if (!token) {
7226
+ console.error("用法:crab storage download <id|文件名|分享链接> [-o 输出路径] [--passphrase 口令] [--passcode 提取码]");
7227
+ return EXIT_USAGE;
7228
+ }
7229
+ const urlMatch = token.match(/\/s\/([0-9a-f]{64})(?:#([A-Za-z0-9_-]+))?$/);
7230
+ const shareToken = urlMatch?.[1];
7231
+ const fragment = urlMatch?.[2];
7232
+ if (shareToken) {
7233
+ const apiBase = process.env.CRAB_API ?? readFlag("--api") ?? DEFAULT_API;
7234
+ const passcode = readFlag("--passcode");
7235
+ const url = `${apiBase}/s/${shareToken}${passcode ? `?passcode=${encodeURIComponent(passcode)}` : ""}`;
7236
+ const res = await fetch(url);
7237
+ if (!res.ok) {
7238
+ const body = await res.json().catch(() => ({}));
7239
+ console.error(errorMessage(body, "下载失败。"));
7240
+ return exitCodeForApiError(body);
7241
+ }
7242
+ let bytes = new Uint8Array(await res.arrayBuffer());
7243
+ if (fragment) {
7244
+ bytes = await aeadDecrypt(await importAesKey(b64urlDecode(fragment)), { iv: bytes.slice(0, 12), ciphertext: bytes.slice(12) }, DATA_AAD);
7245
+ }
7246
+ const disposition = res.headers.get("content-disposition") ?? "";
7247
+ const m = disposition.match(/filename\*=UTF-8''([^;]+)/);
7248
+ const out = readFlag("-o") ?? readFlag("--out") ?? (m ? decodeURIComponent(m[1]) : `${shareToken}.bin`);
7249
+ writeFileSync2(out, Buffer.from(bytes));
7250
+ console.log(`✓ 已下载${fragment ? "并解密" : ""}:${out}(${formatBytesCli(bytes.byteLength)})`);
7251
+ return 0;
7252
+ }
7253
+ const credentials = requireCredentials();
7254
+ if (typeof credentials === "number")
7255
+ return credentials;
7256
+ const id = await resolveStorageId(credentials, token);
7257
+ if (typeof id === "number")
7258
+ return id;
7259
+ const res = await fetch(`${credentials.apiBase}/v1/storage/${encodeURIComponent(id)}/content`, {
7260
+ headers: authHeaders(credentials)
7261
+ });
7262
+ if (!res.ok) {
7263
+ const body = await res.json().catch(() => ({}));
7264
+ console.error(errorMessage(body, "下载失败。"));
7265
+ return exitCodeForApiError(body);
7266
+ }
7267
+ let bytes = new Uint8Array(await res.arrayBuffer());
7268
+ const wrapped = res.headers.get("x-drive-wrapped-key");
7269
+ if (wrapped?.startsWith("u1.")) {
7270
+ const passphrase = drivePassphrase();
7271
+ if (!passphrase) {
7272
+ console.error("该文件已加密:--passphrase <口令> 或 CRAB_PASSPHRASE 提供云盘口令后重试。");
7273
+ return EXIT_USAGE;
7274
+ }
7275
+ const master = await driveMasterKey(credentials);
7276
+ if (typeof master === "number")
7277
+ return master;
7278
+ const dataKey = await unwrapKeyBytes(master, sealFromB64url(wrapped.slice(3)), DATA_AAD);
7279
+ bytes = await aeadDecrypt(await importAesKey(dataKey), { iv: bytes.slice(0, 12), ciphertext: bytes.slice(12) }, DATA_AAD);
7280
+ }
7281
+ const disposition = res.headers.get("content-disposition") ?? "";
7282
+ const match = disposition.match(/filename\*=UTF-8''([^;]+)/);
7283
+ const fallbackName = match ? decodeURIComponent(match[1]) : `${id}.bin`;
7284
+ const out = readFlag("-o") ?? readFlag("--out") ?? fallbackName;
7285
+ writeFileSync2(out, Buffer.from(bytes));
7286
+ console.log(`✓ 已下载:${out}(${formatBytesCli(bytes.byteLength)})`);
7287
+ return 0;
7288
+ }
7289
+ async function storagePatch(credentials, token, patch) {
7290
+ const id = await resolveStorageId(credentials, token);
7291
+ if (typeof id === "number")
7292
+ return id;
7293
+ const res = await api2(credentials.apiBase, `/v1/storage/${encodeURIComponent(id)}`, {
7294
+ method: "PATCH",
7295
+ headers: authHeaders(credentials),
7296
+ body: JSON.stringify(patch)
7297
+ });
7298
+ if (res.status !== 200) {
7299
+ console.error(errorMessage(res.body, "更新失败。"));
7300
+ return exitCodeForApiError(res.body);
7301
+ }
7302
+ return res.body.data;
7303
+ }
7304
+ async function storageRename(token, name) {
7305
+ if (!token || !name) {
7306
+ console.error("用法:crab storage rename <id|文件名> <新名称>");
7307
+ return EXIT_USAGE;
7308
+ }
7309
+ const credentials = requireCredentials();
7310
+ if (typeof credentials === "number")
7311
+ return credentials;
7312
+ const result = await storagePatch(credentials, token, { filename: name });
7313
+ if (typeof result === "number")
7314
+ return result;
7315
+ console.log(`✓ 已重命名:${result.filename}(id ${result.id})`);
7316
+ return 0;
7317
+ }
7318
+ async function storageTag(token) {
7319
+ if (!token || !hasFlag("--add") && !hasFlag("--remove")) {
7320
+ console.error("用法:crab storage tag <id|文件名> --add a,b [--remove c,d]");
7321
+ return EXIT_USAGE;
7322
+ }
7323
+ const credentials = requireCredentials();
7324
+ if (typeof credentials === "number")
7325
+ return credentials;
7326
+ const add = (readFlag("--add") ?? "").split(",").map((t) => t.trim()).filter(Boolean);
7327
+ const remove = (readFlag("--remove") ?? "").split(",").map((t) => t.trim()).filter(Boolean);
7328
+ const patch = {};
7329
+ if (add.length > 0)
7330
+ patch.addTags = add;
7331
+ if (remove.length > 0)
7332
+ patch.removeTags = remove;
7333
+ const result = await storagePatch(credentials, token, patch);
7334
+ if (typeof result === "number")
7335
+ return result;
7336
+ console.log(`✓ 标签已更新:${result.filename} [${result.tags.join(", ") || "无"}]`);
7337
+ return 0;
7338
+ }
7339
+ async function storageCategory(token, value) {
7340
+ if (!token || !value) {
7341
+ console.error("用法:crab storage category <id|文件名> <categoryId|none>(分类 id 见 storage categories)");
7342
+ return EXIT_USAGE;
7343
+ }
7344
+ const credentials = requireCredentials();
7345
+ if (typeof credentials === "number")
7346
+ return credentials;
7347
+ const result = await storagePatch(credentials, token, { categoryId: value === "none" ? null : value });
7348
+ if (typeof result === "number")
7349
+ return result;
7350
+ console.log(`✓ 分类已更新:${result.filename}`);
7351
+ return 0;
7352
+ }
7353
+ async function storagePublish(token, publish) {
7354
+ if (!token) {
7355
+ console.error(`用法:crab storage ${publish ? "publish" : "unpublish"} <id|文件名>`);
7356
+ return EXIT_USAGE;
7357
+ }
7358
+ const credentials = requireCredentials();
7359
+ if (typeof credentials === "number")
7360
+ return credentials;
7361
+ const result = await storagePatch(credentials, token, { isPublic: publish });
7362
+ if (typeof result === "number")
7363
+ return result;
7364
+ console.log(`✓ ${publish ? "已公开" : "已收回公开"}:${result.filename}`);
7365
+ if (publish)
7366
+ console.log(` 公开链接:${storagePublicUrl(result.id)}`);
7367
+ return 0;
7368
+ }
7369
+ async function storageMove(action, token) {
7370
+ if (!token) {
7371
+ console.error(`用法:crab storage ${action} <id|文件名>`);
7372
+ return EXIT_USAGE;
7373
+ }
7374
+ const credentials = requireCredentials();
7375
+ if (typeof credentials === "number")
7376
+ return credentials;
7377
+ const id = await resolveStorageId(credentials, token);
7378
+ if (typeof id === "number")
7379
+ return id;
7380
+ const res = await api2(credentials.apiBase, `/v1/storage/${encodeURIComponent(id)}${action === "restore" ? "/restore" : ""}${action === "purge" ? "/purge" : ""}`, { method: action === "restore" ? "POST" : "DELETE", headers: authHeaders(credentials) });
7381
+ if (res.status !== 200) {
7382
+ console.error(errorMessage(res.body, "操作失败。"));
7383
+ return exitCodeForApiError(res.body);
7384
+ }
7385
+ console.log(`✓ ${action === "rm" ? "已移入回收站(7 天后自动清理)" : action === "restore" ? "已恢复" : "已彻底删除"}(${id})`);
7386
+ return 0;
7387
+ }
7388
+ async function storageShareCmd(token) {
7389
+ if (!token) {
7390
+ console.error("用法:crab storage share <id|文件名> [--expires 7|30|90|forever] [--passcode 提取码]");
7391
+ return EXIT_USAGE;
7392
+ }
7393
+ const expires = readFlag("--expires") ?? "7";
7394
+ const passcode = readFlag("--passcode");
7395
+ if (!["7", "30", "90", "forever"].includes(expires) || passcode !== undefined && !/^[A-Za-z0-9]{4,8}$/.test(passcode)) {
7396
+ console.error("无效的 --expires / --passcode 参数。");
7397
+ return EXIT_USAGE;
7398
+ }
7399
+ const credentials = requireCredentials();
7400
+ if (typeof credentials === "number")
7401
+ return credentials;
7402
+ const hit = await resolveStorageObject(credentials, token);
7403
+ if (typeof hit === "number")
7404
+ return hit;
7405
+ const id = typeof hit === "object" ? hit.id : hit;
7406
+ const needMaster = typeof hit === "object" && hit.isEncrypted === true && (hit.wrappedDataKey ?? "").startsWith("u1.");
7407
+ const master = needMaster ? await driveMasterKey(credentials) : null;
7408
+ if (needMaster && typeof master === "number")
7409
+ return master;
7410
+ const res = await api2(credentials.apiBase, `/v1/storage/${encodeURIComponent(id)}/share`, {
7411
+ method: "POST",
7412
+ headers: authHeaders(credentials),
7413
+ body: JSON.stringify({ expires, ...passcode ? { passcode } : {} })
7414
+ });
7415
+ if (res.status !== 200 && res.status !== 201) {
7416
+ console.error(errorMessage(res.body, "创建分享失败。"));
7417
+ return exitCodeForApiError(res.body);
7418
+ }
7419
+ const data = res.body.data;
7420
+ let link = data.shareUrl;
7421
+ if (data.isEncrypted && data.wrappedDataKey) {
7422
+ const masterKey = master ?? await driveMasterKey(credentials);
7423
+ if (typeof masterKey === "number") {
7424
+ await api2(credentials.apiBase, `/v1/storage/shares/${encodeURIComponent(data.share.id)}`, {
7425
+ method: "DELETE",
7426
+ headers: authHeaders(credentials)
7427
+ }).catch(() => null);
7428
+ console.error("该文件是端到端加密文件,需要云盘口令解出分享钥匙:--passphrase <口令> 或 CRAB_PASSPHRASE(本次分享已撤销,未留孤儿链接)。");
7429
+ return EXIT_USAGE;
7430
+ }
7431
+ const dataKey = await unwrapKeyBytes(masterKey, sealFromB64url(data.wrappedDataKey.slice(3)), DATA_AAD);
7432
+ link = `${link}#${b64urlEncode(dataKey)}`;
7433
+ }
7434
+ console.log(`✓ 分享链接已创建(${expires === "forever" ? "永久" : `${expires} 天`}有效):`);
7435
+ console.log(` ${link}`);
7436
+ if (data.share.hasPasscode)
7437
+ console.log(" (已设提取码,接收方需携带)");
7438
+ if (data.isEncrypted)
7439
+ console.log(" (链接自带解密钥匙,泄露即泄露文件——请走可信渠道传递)");
7440
+ return 0;
7441
+ }
7442
+ async function storageShares(token) {
7443
+ if (!token) {
7444
+ console.error("用法:crab storage shares <id|文件名>");
7445
+ return EXIT_USAGE;
7446
+ }
7447
+ const credentials = requireCredentials();
7448
+ if (typeof credentials === "number")
7449
+ return credentials;
7450
+ const id = await resolveStorageId(credentials, token);
7451
+ if (typeof id === "number")
7452
+ return id;
7453
+ const res = await api2(credentials.apiBase, `/v1/storage/${encodeURIComponent(id)}/shares`, { headers: authHeaders(credentials) });
7454
+ if (res.status !== 200) {
7455
+ console.error(errorMessage(res.body, "获取分享列表失败。"));
7456
+ return exitCodeForApiError(res.body);
7457
+ }
7458
+ const rows = res.body.data.shares;
7459
+ if (hasFlag("--json")) {
7460
+ console.log(JSON.stringify(rows, null, 2));
7461
+ return 0;
7462
+ }
7463
+ if (rows.length === 0) {
7464
+ console.log("该文件还没有分享链接。");
7465
+ return 0;
7466
+ }
7467
+ for (const s of rows) {
7468
+ const state = s.revokedAt ? "已撤销" : s.expiresAt !== null && s.expiresAt <= Date.now() ? "已过期" : "生效中";
7469
+ console.log(`${s.id} …${s.token.slice(-12)} ${state} 下载 ${s.downloadCount} 次 ${formatTime(s.createdAt)}${s.hasPasscode ? " [提取码]" : ""}`);
7470
+ }
7471
+ return 0;
7472
+ }
7473
+ async function storageUnshare(shareId) {
7474
+ if (!shareId) {
7475
+ console.error("用法:crab storage unshare <shareId>(id 见 crab storage shares)");
7476
+ return EXIT_USAGE;
7477
+ }
7478
+ const credentials = requireCredentials();
7479
+ if (typeof credentials === "number")
7480
+ return credentials;
7481
+ const res = await api2(credentials.apiBase, `/v1/storage/shares/${encodeURIComponent(shareId)}`, {
7482
+ method: "DELETE",
7483
+ headers: authHeaders(credentials)
7484
+ });
7485
+ if (res.status !== 200) {
7486
+ console.error(errorMessage(res.body, "撤销失败。"));
7487
+ return exitCodeForApiError(res.body);
7488
+ }
7489
+ console.log(`✓ 已撤销分享(${shareId})。`);
7490
+ return 0;
7491
+ }
7492
+ async function storageQuota() {
7493
+ const credentials = requireCredentials();
7494
+ if (typeof credentials === "number")
7495
+ return credentials;
7496
+ const res = await api2(credentials.apiBase, "/v1/storage/quota", { headers: authHeaders(credentials) });
7497
+ if (res.status !== 200) {
7498
+ console.error(errorMessage(res.body, "获取水位失败。"));
7499
+ return exitCodeForApiError(res.body);
7500
+ }
7501
+ const quota = res.body.data;
7502
+ if (hasFlag("--json")) {
7503
+ console.log(JSON.stringify(quota, null, 2));
7504
+ return 0;
7505
+ }
7506
+ const pct = quota.quotaBytes > 0 ? (quota.usedBytes / quota.quotaBytes * 100).toFixed(1) : "0";
7507
+ console.log(`已用 ${formatBytesCli(quota.usedBytes)} / ${formatBytesCli(quota.quotaBytes)}(${pct}%)`);
7508
+ console.log(` 文件 ${formatBytesCli(quota.bySource.files)} · 素材 ${formatBytesCli(quota.bySource.media)} · 邮件附件 ${formatBytesCli(quota.bySource.mail)} · 回收站 ${formatBytesCli(quota.trashBytes)}`);
7509
+ console.log(` 对象数:全部 ${quota.counts.all ?? 0} · 素材 ${quota.counts.media ?? 0} · 邮件附件 ${quota.counts.mail ?? 0} · 共享 ${quota.counts.shared ?? 0} · 回收站 ${quota.counts.trash ?? 0}`);
7510
+ return 0;
7511
+ }
7512
+ async function storageCategories() {
7513
+ const credentials = requireCredentials();
7514
+ if (typeof credentials === "number")
7515
+ return credentials;
7516
+ const res = await api2(credentials.apiBase, "/v1/storage/categories", { headers: authHeaders(credentials) });
7517
+ if (res.status !== 200) {
7518
+ console.error(errorMessage(res.body, "获取分类失败。"));
7519
+ return exitCodeForApiError(res.body);
7520
+ }
7521
+ const rows = res.body.data.categories;
7522
+ if (hasFlag("--json")) {
7523
+ console.log(JSON.stringify(rows, null, 2));
7524
+ return 0;
7525
+ }
7526
+ if (rows.length === 0) {
7527
+ console.log("还没有分类(crab storage mkcat <名称> 创建)。");
7528
+ return 0;
7529
+ }
7530
+ for (const row of rows)
7531
+ console.log(`${row.id} ${row.name} (${row.fileCount})`);
7532
+ return 0;
7533
+ }
7534
+ async function storageMkCat(name) {
7535
+ if (!name) {
7536
+ console.error("用法:crab storage mkcat <名称>");
7537
+ return EXIT_USAGE;
7538
+ }
7539
+ const credentials = requireCredentials();
7540
+ if (typeof credentials === "number")
7541
+ return credentials;
7542
+ const res = await api2(credentials.apiBase, "/v1/storage/categories", {
7543
+ method: "POST",
7544
+ headers: authHeaders(credentials),
7545
+ body: JSON.stringify({ name })
7546
+ });
7547
+ if (res.status !== 200 && res.status !== 201) {
7548
+ console.error(errorMessage(res.body, "创建分类失败。"));
7549
+ return exitCodeForApiError(res.body);
7550
+ }
7551
+ const row = res.body.data;
7552
+ console.log(`✓ 已创建分类:${row.name}(id ${row.id})`);
7553
+ return 0;
7554
+ }
7555
+ async function storageTags() {
7556
+ const credentials = requireCredentials();
7557
+ if (typeof credentials === "number")
7558
+ return credentials;
7559
+ const res = await api2(credentials.apiBase, "/v1/storage/tags", { headers: authHeaders(credentials) });
7560
+ if (res.status !== 200) {
7561
+ console.error(errorMessage(res.body, "获取标签失败。"));
7562
+ return exitCodeForApiError(res.body);
7563
+ }
7564
+ const rows = res.body.data;
7565
+ if (hasFlag("--json")) {
7566
+ console.log(JSON.stringify(rows, null, 2));
7567
+ return 0;
7568
+ }
7569
+ if (rows.tags.length === 0) {
7570
+ console.log("还没有标签(crab storage tag <id> --add a,b 添加)。");
7571
+ return 0;
7572
+ }
7573
+ for (const row of rows.tags)
7574
+ console.log(`${row.tag} (${row.count})`);
7575
+ return 0;
7576
+ }
6597
7577
  async function init() {
6598
7578
  if (loadCredentials()) {
6599
7579
  console.log("已登录(crab whoami 查看身份)。");
@@ -6666,6 +7646,64 @@ switch (command) {
6666
7646
  process.exit(await mailMove(sub, args[2]));
6667
7647
  console.error(sub ? `未知子命令:mail ${sub}
6668
7648
  ` : `缺少子命令:mail list | read | search | send | archive | trash | delete
7649
+ `);
7650
+ console.log(usage);
7651
+ process.exit(EXIT_USAGE);
7652
+ }
7653
+ case "contacts": {
7654
+ const sub = args[1];
7655
+ if (sub === "list")
7656
+ process.exit(await contactsList(args.slice(2).join(" ") || undefined));
7657
+ if (sub === "add")
7658
+ process.exit(await contactsAdd(args[2]));
7659
+ if (sub === "rm")
7660
+ process.exit(await contactsRm(args[2]));
7661
+ if (sub === "invite")
7662
+ process.exit(await contactsInvite(hasFlag("--reset")));
7663
+ if (sub === "invitees")
7664
+ process.exit(await contactsInvitees());
7665
+ console.error(sub ? `未知子命令:contacts ${sub}
7666
+ ` : `缺少子命令:contacts list | add | rm | invite | invitees
7667
+ `);
7668
+ console.log(usage);
7669
+ process.exit(EXIT_USAGE);
7670
+ }
7671
+ case "storage": {
7672
+ const sub = args[1];
7673
+ if (sub === "ls")
7674
+ process.exit(await storageList());
7675
+ if (sub === "upload")
7676
+ process.exit(await storageUpload(args.slice(2)));
7677
+ if (sub === "download")
7678
+ process.exit(await storageDownload(args[2]));
7679
+ if (sub === "rename")
7680
+ process.exit(await storageRename(args[2], args[3]));
7681
+ if (sub === "tag")
7682
+ process.exit(await storageTag(args[2]));
7683
+ if (sub === "category")
7684
+ process.exit(await storageCategory(args[2], args[3]));
7685
+ if (sub === "publish")
7686
+ process.exit(await storagePublish(args[2], true));
7687
+ if (sub === "unpublish")
7688
+ process.exit(await storagePublish(args[2], false));
7689
+ if (sub === "rm" || sub === "restore" || sub === "purge")
7690
+ process.exit(await storageMove(sub, args[2]));
7691
+ if (sub === "quota")
7692
+ process.exit(await storageQuota());
7693
+ if (sub === "categories")
7694
+ process.exit(await storageCategories());
7695
+ if (sub === "mkcat")
7696
+ process.exit(await storageMkCat(args.slice(2).join(" ") || undefined));
7697
+ if (sub === "tags")
7698
+ process.exit(await storageTags());
7699
+ if (sub === "share")
7700
+ process.exit(await storageShareCmd(args[2]));
7701
+ if (sub === "shares")
7702
+ process.exit(await storageShares(args[2]));
7703
+ if (sub === "unshare")
7704
+ process.exit(await storageUnshare(args[2]));
7705
+ console.error(sub ? `未知子命令:storage ${sub}
7706
+ ` : `缺少子命令:storage ls | upload | download | rename | tag | category | publish | unpublish | rm | restore | purge | quota | categories | mkcat | tags | share | shares | unshare
6669
7707
  `);
6670
7708
  console.log(usage);
6671
7709
  process.exit(EXIT_USAGE);