uploader.mjs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. #!/usr/bin/env node
  2. /**
  3. * fmode-storage uploader — 对象存储上传/公开链接/ACL
  4. * 零依赖(Node ≥18)。凭据按 4 级优先级自动解析,永不入库。
  5. *
  6. * v0.3.0 诚实凭据链(真因修复,见 CHANGELOG.md):
  7. * 旧版第0级调用 POST /api/storage/credentials —— 该端点从未上线(404,
  8. * 设计文档 fmode-studio/docs/obs-cdn/04-API设计.md 状态为"规划中"),
  9. * "登录即可上传"是伪自举。本版改为:
  10. *
  11. * 第1级 环境变量 OBS_AK/OBS_SK(/OBS_ENDPOINT/OBS_BUCKET)
  12. * 第2级 obsutil config 文件(OBSUTIL_CONFIG 或 ~/.obsutilconfig)解析 AK/SK/endpoint/bucket
  13. * 第3级 ~/.fmode/config/user.json 的 fmodeApiToken → 平台签发接口
  14. * (启动时 HEAD 探测该端点:404=未上线→跳过并提示;200=启用自举)
  15. * 第4级 项目级 ./.fmode/config.json
  16. * 全失败:打印初始化向导指引并退出码 2,绝不伪装成功。
  17. *
  18. * 旧 sessionToken 自举保留为 --experimental-sts:仅当显式传入该参数
  19. * 且端点探测返回 200 时启用(面向未来端点上线,默认关闭)。
  20. *
  21. * 用法:
  22. * node uploader.mjs init [--ak ... --sk ... --endpoint ... --bucket ...]
  23. * node uploader.mjs put <file> --key <objectKey> [--acl public-read] [--endpoint ...] [--bucket ...]
  24. * node uploader.mjs setacl --key <prefix|key> --acl public-read [-r]
  25. * node uploader.mjs test
  26. * node uploader.mjs config
  27. */
  28. import fs from 'fs';
  29. import os from 'os';
  30. import path from 'path';
  31. import { execSync, spawnSync } from 'child_process';
  32. import { fileURLToPath } from 'url';
  33. const HOME = os.homedir();
  34. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  35. // fmode 网关基址(平台 STS 签发端点所在)
  36. const FMODE_API_BASE = process.env.FMODE_API_BASE || 'https://server.fmode.cn';
  37. const STORAGE_CREDENTIALS_URL = `${FMODE_API_BASE.replace(/\/$/, '')}/api/storage/credentials`;
  38. const DEFAULT_ENDPOINT = 'obs.cn-north-4.myhuaweicloud.com';
  39. const PROBE_CACHE_FILE = path.join(__dirname, '.sts-probe.json'); // 探测结果缓存(1 小时有效)
  40. const PROBE_TTL_MS = 60 * 60 * 1000;
  41. /** 打印初始化向导(全链失败时的唯一出口,绝不伪装成功) */
  42. function printWizard() {
  43. console.error(`
  44. ┌─ fmode-storage 初始化向导 ─────────────────────────────────────────────┐
  45. │ 当前版本需一次性配置 OBS 子账号 AK/SK;sessionToken 免配置自举待平台 │
  46. │ 端点上线(设计文档 fmode-studio/docs/obs-cdn/04-API设计.md,规划中)。 │
  47. ├────────────────────────────────────────────────────────────────────────┤
  48. │ 步骤 1 向 Fmode 平台/管理员申请 OBS 子账号(邮件模板见 README.md │
  49. │ "三分钟初始化"章节),拿到 AK/SK 及所属桶/终端节点。 │
  50. │ 步骤 2 执行初始化(任选其一): │
  51. │ node uploader.mjs init --ak <AK> --sk <SK> \\ │
  52. │ --endpoint obs.cn-north-4.myhuaweicloud.com \\ │
  53. │ --bucket <bucket> │
  54. │ (省略参数则进入交互模式逐项询问) │
  55. │ 步骤 3 验证:node uploader.mjs test │
  56. │ (上传 1KB 探针文件→删除→报告成功) │
  57. └────────────────────────────────────────────────────────────────────────┘`);
  58. }
  59. // ============================================================================
  60. // 平台签发端点探测(B 节:诚实探测 + 1 小时缓存)
  61. // ============================================================================
  62. /**
  63. * HEAD 探测平台 STS 签发端点是否上线。结果缓存本文件 .sts-probe.json(1 小时)。
  64. * @returns {Promise<{online: boolean, checkedAt: number, status: number|null}>}
  65. */
  66. export async function probeStsEndpoint() {
  67. // 读缓存
  68. try {
  69. const j = JSON.parse(fs.readFileSync(PROBE_CACHE_FILE, 'utf8'));
  70. if (j.checkedAt && Date.now() - j.checkedAt < PROBE_TTL_MS) return j;
  71. } catch { /* 无缓存或损坏 → 重新探测 */ }
  72. let status = null;
  73. try {
  74. const res = await fetch(STORAGE_CREDENTIALS_URL, { method: 'HEAD', signal: AbortSignal.timeout(10000) });
  75. status = res.status;
  76. } catch { /* 网络失败视为未上线,不给假希望 */ }
  77. const result = { online: status === 200, checkedAt: Date.now(), status };
  78. try { fs.writeFileSync(PROBE_CACHE_FILE, JSON.stringify(result)); } catch { /* 只读环境忽略 */ }
  79. return result;
  80. }
  81. /** 探测缓存是否可以强制刷新(--refresh-probe / init / test 命令场景) */
  82. function invalidateProbeCache() {
  83. try { fs.rmSync(PROBE_CACHE_FILE, { force: true }); } catch { /* ignore */ }
  84. }
  85. // ============================================================================
  86. // 旧第0级自举(--experimental-sts 专用,端点上线前是死代码路径)
  87. // ============================================================================
  88. /**
  89. * 解析 sessionToken(优先级:FMODE_SESSION_TOKEN → ~/.fmode config 文件)。
  90. * 注意:真实身份字段是 fmodeApiToken(sk- 开头),本函数仅在
  91. * --experimental-sts 且端点探测 200 时被调用,面向未来端点上线。
  92. */
  93. export function resolveSessionToken() {
  94. if (process.env.FMODE_SESSION_TOKEN) return process.env.FMODE_SESSION_TOKEN.trim();
  95. const candidates = [
  96. path.join(HOME, '.fmode', 'config.json'),
  97. path.join(process.cwd(), '.fmode', 'config.json'),
  98. path.join(HOME, '.fmode', 'config', 'user.json'),
  99. ];
  100. for (const p of candidates) {
  101. try {
  102. if (!fs.existsSync(p)) continue;
  103. const j = JSON.parse(fs.readFileSync(p, 'utf8').replace(/^/, ''));
  104. const t = j.sessionToken || (j.user && j.user.sessionToken) || null;
  105. if (t && String(t).trim()) return String(t).trim();
  106. } catch { /* try next source */ }
  107. }
  108. return null;
  109. }
  110. /**
  111. * sessionToken → STS 临时凭证。⚠️ 端点未上线(404)前必然返回 null。
  112. * 返回的 STS 仅内存持有:不写文件、不打日志、不进错误信息。
  113. */
  114. export async function fetchStsCredentials(sessionToken) {
  115. if (!sessionToken) return null;
  116. try {
  117. const res = await fetch(STORAGE_CREDENTIALS_URL, {
  118. method: 'POST',
  119. headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${sessionToken}` },
  120. body: JSON.stringify({ op: ['put', 'delete'] }),
  121. signal: AbortSignal.timeout(15000),
  122. });
  123. if (!res.ok) return null;
  124. const body = await res.json().catch(() => null);
  125. const cred = body && (body.credentials || body.data);
  126. if (!cred || !cred.accessKeyId || !cred.secretAccessKey) return null;
  127. return {
  128. ak: cred.accessKeyId,
  129. sk: cred.secretAccessKey,
  130. securityToken: cred.sessionToken || cred.securityToken || '',
  131. endpoint: cred.endpoint || DEFAULT_ENDPOINT,
  132. bucket: cred.bucket || null,
  133. prefix: cred.prefix || '',
  134. };
  135. } catch { return null; }
  136. }
  137. /**
  138. * 用 STS 临时凭证构造一次性 obsutil 配置目录,执行命令后即删。
  139. * (配置经 -config= 显式传给 obsutil——旧版 OBSUTIL_CONFIG_FILE 环境变量
  140. * obsutil 并不识别,属无效传递,本版修正。)
  141. */
  142. export function runWithSts(sts, args, timeout = 300000) {
  143. const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fmode-sts-'));
  144. fs.chmodSync(tmpDir, 0o700);
  145. const cfgFile = path.join(tmpDir, '.obsutilconfig');
  146. fs.writeFileSync(cfgFile, [
  147. `endpoint=${sts.endpoint}`,
  148. `ak=${sts.ak}`,
  149. `sk=${sts.sk}`,
  150. `token=${sts.securityToken}`,
  151. '',
  152. ].join('\n'), { mode: 0o600 });
  153. try {
  154. return runObsutil(cfg.obsUtilPath, args, { cfgFile, timeout });
  155. } finally {
  156. try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
  157. }
  158. }
  159. // ============================================================================
  160. // obsutil 执行层
  161. // ============================================================================
  162. /** 统一 obsutil 调用。成功判定用退出码 + 末行无 "failed",杜绝"failed"文案误判为成功。 */
  163. function runObsutil(obsUtilPath, args, { cfgFile = null, timeout = 120000 } = {}) {
  164. const r = spawnSync(obsUtilPath, [...args, ...(cfgFile ? [`-config=${cfgFile}`] : [])], {
  165. encoding: 'utf8',
  166. timeout,
  167. });
  168. const out = (r.stdout || '') + (r.stderr || '');
  169. const ok = r.status === 0 && !/failed/i.test(out.split('\n').filter(Boolean).pop() || '');
  170. return { ok, out, status: r.status };
  171. }
  172. /** 公开 URL: cdnDomain 优先, 否则桶域名 */
  173. export function publicUrl(cfg, key) {
  174. if (cfg.cdnDomain) return `https://${cfg.cdnDomain}/${key}`;
  175. return `https://${cfg.bucket}.${cfg.endpoint}/${key}`;
  176. }
  177. // ============================================================================
  178. // 凭据链:诚实 4 级
  179. // ============================================================================
  180. /** 解析 obsutil config 文件内容 → {endpoint, ak, sk, token, bucket}。密钥不外泄,仅内部使用。 */
  181. function parseObsutilConfig(text) {
  182. const c = {};
  183. for (const line of String(text).split('\n')) {
  184. const m = line.match(/^(endpoint|ak|sk|token)=(.*)$/);
  185. if (m) c[m[1]] = m[2].trim();
  186. }
  187. return (c.ak && c.sk) ? c : null;
  188. }
  189. /** obsutil config 文件候选路径(OBSUTIL_CONFIG 环境变量优先,其次 ~/.obsutilconfig) */
  190. function obsutilConfigPaths() {
  191. const paths = [];
  192. if (process.env.OBSUTIL_CONFIG) paths.push(process.env.OBSUTIL_CONFIG);
  193. paths.push(path.join(HOME, '.obsutilconfig'));
  194. // getpwuid 的 home 可能与 $HOME 不同(容器常见):obsutil 实际按它解析默认配置
  195. try {
  196. const pwHome = os.userInfo().homedir;
  197. if (pwHome && pwHome !== HOME) paths.push(path.join(pwHome, '.obsutilconfig'));
  198. } catch { /* ignore */ }
  199. return paths;
  200. }
  201. /** 定位 obsutil 可执行文件:PATH → ~/.local/bin → ~/bin → getpwuid home/bin → /usr/local/bin */
  202. function findObsutil() {
  203. const candidates = [];
  204. for (const dir of (process.env.PATH || '').split(':')) {
  205. if (dir) candidates.push(path.join(dir, 'obsutil'));
  206. }
  207. for (const base of [path.join(HOME, '.local', 'bin'), path.join(HOME, 'bin')]) candidates.push(path.join(base, 'obsutil'));
  208. try {
  209. const pwHome = os.userInfo().homedir;
  210. if (pwHome && pwHome !== HOME) candidates.push(path.join(pwHome, 'bin', 'obsutil'));
  211. } catch { /* ignore */ }
  212. candidates.push('/usr/local/bin/obsutil', '/usr/bin/obsutil');
  213. for (const p of candidates) {
  214. try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { /* next */ }
  215. }
  216. return 'obsutil'; // 交给 spawnSync 报"not found"
  217. }
  218. /**
  219. * 诚实 4 级凭据解析(命中即用,检测不到就换下一级):
  220. * 第1级 环境变量 OBS_AK/OBS_SK(/OBS_ENDPOINT/OBS_BUCKET)
  221. * 第2级 obsutil config 文件(OBSUTIL_CONFIG 或 ~/.obsutilconfig)
  222. * 第3级 ~/.fmode/config/user.json 的 fmodeApiToken → 平台签发接口
  223. * (先 HEAD 探测端点:404=未上线→跳过并提示,不空转)
  224. * 第4级 项目级 ./.fmode/config.json
  225. */
  226. export async function resolveStorageConfig({ experimentalSts = false } = {}) {
  227. const cfg = {
  228. obsUtilPath: 'obsutil',
  229. endpoint: DEFAULT_ENDPOINT,
  230. bucket: null,
  231. cdnDomain: null,
  232. sts: null,
  233. via: null, // 命中的解析级别(诊断用,不含任何密钥)
  234. ak: null, // 内部传递用,config 命令绝不输出
  235. sk: null,
  236. token: '',
  237. };
  238. cfg.obsUtilPath = findObsutil();
  239. // ---- 第1级:环境变量 ----
  240. if (process.env.OBS_AK && process.env.OBS_SK) {
  241. cfg.ak = process.env.OBS_AK.trim();
  242. cfg.sk = process.env.OBS_SK.trim();
  243. if (process.env.OBS_ENDPOINT) cfg.endpoint = process.env.OBS_ENDPOINT.trim();
  244. if (process.env.OBS_BUCKET) cfg.bucket = process.env.OBS_BUCKET.trim();
  245. cfg.via = 'level1:env(OBS_AK/OBS_SK)';
  246. }
  247. // ---- 第2级:obsutil config 文件 ----
  248. if (!cfg.ak) {
  249. for (const p of obsutilConfigPaths()) {
  250. try {
  251. if (!fs.existsSync(p)) continue;
  252. const c = parseObsutilConfig(fs.readFileSync(p, 'utf8'));
  253. if (c) {
  254. cfg.ak = c.ak; cfg.sk = c.sk; cfg.token = c.token || '';
  255. if (c.endpoint) cfg.endpoint = c.endpoint;
  256. cfg.via = `level2:obsutilconfig(${p})`;
  257. break;
  258. }
  259. } catch { /* try next path */ }
  260. }
  261. }
  262. // ---- 第3级:fmodeApiToken → 平台签发接口(端点未上线则跳过并提示)----
  263. if (!cfg.ak) {
  264. const userCfg = path.join(HOME, '.fmode', 'config', 'user.json');
  265. const token = (() => {
  266. try {
  267. const j = JSON.parse(fs.readFileSync(userCfg, 'utf8').replace(/^/, ''));
  268. return (j.fmodeApiToken || '').trim() || null;
  269. } catch { return null; }
  270. })();
  271. if (token) {
  272. const probe = await probeStsEndpoint();
  273. if (!probe.online) {
  274. console.error(`平台 STS 签发端点未上线(探测 ${STORAGE_CREDENTIALS_URL} → ${probe.status ?? '网络失败'};设计文档 fmode-studio/docs/obs-cdn/04-API设计.md 状态"规划中"),暂用 obsutil 配置模式`);
  275. } else if (experimentalSts) {
  276. const sts = await fetchStsCredentials(token);
  277. if (sts) {
  278. cfg.sts = sts;
  279. cfg.endpoint = sts.endpoint || cfg.endpoint;
  280. cfg.bucket = sts.bucket || cfg.bucket;
  281. cfg.via = 'level3:experimental-sts';
  282. return cfg;
  283. }
  284. console.error('--experimental-sts 已启用且端点在线,但换取失败:token 缺失或失效');
  285. } else {
  286. console.error('平台 STS 端点已上线。加 --experimental-sts 启用 sessionToken 自举(或等待默认开启)。');
  287. }
  288. }
  289. }
  290. // ---- 第4级:项目级 ./.fmode/config.json ----
  291. if (!cfg.ak && !cfg.sts) {
  292. try {
  293. const p = path.join(process.cwd(), '.fmode', 'config.json');
  294. const j = JSON.parse(fs.readFileSync(p, 'utf8'));
  295. if (j.obsUtilPath) cfg.obsUtilPath = j.obsUtilPath;
  296. if (j.obsBucket) { cfg.bucket = j.obsBucket; cfg.via = `level4:${p}`; }
  297. if (j.obsEndpoint) cfg.endpoint = j.obsEndpoint;
  298. if (j.cdnDomain) cfg.cdnDomain = j.cdnDomain;
  299. } catch { /* ignore */ }
  300. }
  301. // ---- bucket 补全:第1/2级命中但缺 bucket → obsutil ls 探测 ----
  302. if (cfg.ak && !cfg.bucket) {
  303. try {
  304. const out = execSync(`${JSON.stringify(cfg.obsUtilPath)} ls -limit=1`, { encoding: 'utf8', timeout: 15000 });
  305. const m = out.match(/obs:\/\/([^/\s]+)/);
  306. if (m) { cfg.bucket = m[1]; cfg.via += '+bucket-probe'; }
  307. } catch { /* 探测失败交给上层报错 */ }
  308. }
  309. return cfg;
  310. }
  311. // ============================================================================
  312. // 初始化向导(C 节:新用户路径)
  313. // ============================================================================
  314. /**
  315. * node uploader.mjs init [--ak ... --sk ... --endpoint ... --bucket ...]
  316. * 写入 obsutil config(600 权限)+ 本技能 config 段,然后立即 test。
  317. */
  318. async function cmdInit(rest) {
  319. const arg = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : null; };
  320. const ask = async (q) => {
  321. process.stdout.write(q);
  322. let buf = '';
  323. for await (const chunk of process.stdin) { buf += chunk; break; }
  324. return buf.trim();
  325. };
  326. const ak = arg('--ak') || await ask('AK: ');
  327. const sk = arg('--sk') || await ask('SK: ');
  328. const endpoint = arg('--endpoint') || await ask(`endpoint [${DEFAULT_ENDPOINT}]: `) || DEFAULT_ENDPOINT;
  329. const bucket = arg('--bucket') || await ask('bucket: ');
  330. if (!ak || !sk || !bucket) {
  331. console.error('init 失败:AK/SK/bucket 均必填(--ak --sk --endpoint --bucket 或交互输入)');
  332. process.exit(2);
  333. }
  334. // 1) 写 obsutil config(600)
  335. const cfgPath = path.join(HOME, '.obsutilconfig');
  336. fs.writeFileSync(cfgPath, [
  337. `endpoint=${endpoint}`,
  338. `ak=${ak}`,
  339. `sk=${sk}`,
  340. 'token=',
  341. 'cname=false',
  342. `endpointCrr=http://your-endpoint`,
  343. `akCrr=*** Provide your Access Key ***`,
  344. `skCrr=*** Provide your Secret Key ***`,
  345. `tokenCrr=`,
  346. '',
  347. ].join('\n'), { mode: 0o600 });
  348. // 2) 写本技能 config 段(~/.fmode/config.json,不含密钥本体)
  349. const fmodeDir = path.join(HOME, '.fmode');
  350. const fmodeCfg = path.join(fmodeDir, 'config.json');
  351. fs.mkdirSync(fmodeDir, { recursive: true });
  352. let j = {};
  353. try { j = JSON.parse(fs.readFileSync(fmodeCfg, 'utf8')); } catch { /* 新文件 */ }
  354. j.obsBucket = bucket;
  355. j.obsEndpoint = endpoint;
  356. fs.writeFileSync(fmodeCfg, JSON.stringify(j, null, 2) + '\n', { mode: 0o600 });
  357. console.error(`已写入 ${cfgPath}(600)与 ${fmodeCfg}(技能 config 段,不含密钥本体)`);
  358. // 3) 立即 test:上传 1KB 探针 → 删除 → 报告
  359. return cmdTest();
  360. }
  361. /** test:上传 1KB 探针文件→删除→报告成功 */
  362. async function cmdTest() {
  363. const cfg = await resolveStorageConfig();
  364. if (!cfg.ak && !cfg.sts) { printWizard(); process.exit(2); }
  365. if (!cfg.bucket) { console.error(`凭据已命中(${cfg.via})但未解析到 bucket:请 --bucket 指定或用 init 写入`); process.exit(2); }
  366. const tmp = path.join(os.tmpdir(), `fmode-storage-probe-${process.pid}.txt`);
  367. fs.writeFileSync(tmp, 'fmode-storage probe ' + new Date().toISOString() + '\n');
  368. const key = `skill-storage-selftest/probe-${process.pid}.txt`;
  369. try {
  370. const up = runObsutil(cfg.obsUtilPath, ['cp', tmp, `obs://${cfg.bucket}/${key}`], { timeout: 120000 });
  371. if (!up.ok) { console.error('test 失败(上传):', up.out.slice(-300)); process.exit(1); }
  372. const del = runObsutil(cfg.obsUtilPath, ['rm', `obs://${cfg.bucket}/${key}`, '-f'], { timeout: 60000 });
  373. if (!del.ok) { console.error('test 失败(清理探针文件,上传本身已成功):', del.out.slice(-300)); process.exit(1); }
  374. console.log(JSON.stringify({ ok: true, bucket: cfg.bucket, endpoint: cfg.endpoint, via: cfg.via, probeKey: key, message: '上传 1KB 探针→删除 全部成功' }, null, 2));
  375. return true;
  376. } finally {
  377. try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ }
  378. }
  379. }
  380. // ============================================================================
  381. // main
  382. // ============================================================================
  383. async function main() {
  384. const [cmd, ...rest] = process.argv.slice(2);
  385. const arg = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : null; };
  386. const experimentalSts = rest.includes('--experimental-sts');
  387. if (rest.includes('--refresh-probe')) invalidateProbeCache();
  388. if (cmd === 'init') { process.exit(await cmdInit(rest) ? 0 : 1); }
  389. if (cmd === 'test') { process.exit(await cmdTest() ? 0 : 1); }
  390. const cfg = await resolveStorageConfig({ experimentalSts });
  391. if (!cfg.ak && !cfg.sts) {
  392. if (cmd !== 'help') {
  393. console.error('凭据链 4 级全部未命中(env → obsutil config → 平台签发(未上线) → 项目 config)。');
  394. printWizard();
  395. process.exit(2);
  396. }
  397. }
  398. if (cfg.ak && !cfg.sts && !cfg.bucket && ['put', 'setacl'].includes(cmd)) {
  399. console.error(`凭据已命中(${cfg.via})但未解析到 bucket:用 --bucket 指定,或重新执行 init 写入完整配置。`);
  400. process.exit(2);
  401. }
  402. const obs = (args, timeout = 120000) => cfg.sts
  403. ? runWithSts(cfg.sts, args, timeout)
  404. : runObsutil(cfg.obsUtilPath, args, { timeout });
  405. if (cmd === 'put') {
  406. const file = rest[0];
  407. let key = arg('--key');
  408. if (!file || !key) { console.error('用法: put <file> --key <objectKey> [--acl public-read]'); process.exit(2); }
  409. const acl = arg('--acl') || 'public-read';
  410. const bucketArg = arg('--bucket'); if (bucketArg) cfg.bucket = bucketArg;
  411. const r = obs(['cp', file, `obs://${cfg.bucket}/${key}`, ...(acl ? ['-acl', acl] : [])]);
  412. if (!r.ok) { console.error('上传失败:', r.out.slice(-300)); process.exit(1); }
  413. console.log(JSON.stringify({ ok: true, key, url: publicUrl(cfg, key), bucket: cfg.bucket, via: cfg.via }, null, 2));
  414. return;
  415. }
  416. if (cmd === 'setacl') {
  417. const key = arg('--key'); const acl = arg('--acl') || 'public-read';
  418. const recursive = rest.includes('-r');
  419. const r = obs(['chattri', `obs://${cfg.bucket}/${key}`, '-acl', acl, ...(recursive ? ['-r', '-f'] : ['-f'])]);
  420. if (!r.ok) { console.error('setacl 失败:', r.out.slice(-300)); process.exit(1); }
  421. console.log(JSON.stringify({ ok: true }, null, 2));
  422. return;
  423. }
  424. if (cmd === 'config') {
  425. // 诊断输出:绝不包含 AK/SK/token/sessionToken 任何密钥本体
  426. console.log(JSON.stringify({
  427. via: cfg.via,
  428. bucket: cfg.bucket,
  429. endpoint: cfg.endpoint,
  430. cdnDomain: cfg.cdnDomain,
  431. credentialsResolved: Boolean(cfg.ak || cfg.sts),
  432. stsExperimental: Boolean(cfg.sts),
  433. stsPrefix: cfg.sts ? cfg.sts.prefix : null,
  434. }, null, 2));
  435. return;
  436. }
  437. console.log('用法: init | test | put | setacl | config');
  438. }
  439. main().catch((e) => { console.error('未预期错误:', e.message); process.exit(1); });