#!/usr/bin/env node // 技能直连 OSS 工具:列举桶内对象与目录结构(ADR-004 凭据边界,零硬编码) // 用法: // node oss.mjs # 桶顶层:按一级目录分组统计 // node oss.mjs recordings/ # 列出该前缀全部对象(按目录分组) // node oss.mjs recordings/ --json # JSON 输出 {domain,prefix,objects} // node oss.mjs --find-env # 打印实际采用的 .env 路径 // node oss.mjs records/ --env # 显式指定 .env // 凭据回退链: --env > SMARTBADGE_ENV_FILE > 自动发现(见 lib.mjs) > 进程环境已有 OSS_* > 技能仓库根 .env // OSS 客户端两态: 有服务端项目(server/services/oss-client.js)时复用其模块;否则回退内置零依赖实现(oss-client-mini.mjs) import fs from 'fs'; import path from 'path'; import { pathToFileURL } from 'url'; import { findEnvFile, loadEnv, fail, SCRIPTS_DIR } from './lib.mjs'; const args = process.argv.slice(2); const isFlag = (a) => a.startsWith('--'); const envIdx = args.indexOf('--env'); const envPathFlag = envIdx >= 0 ? args[envIdx + 1] : undefined; // 非 flag 且非 flag 值 的参数才视为 prefix const prefix = args.filter((a, i) => !isFlag(a) && !(envIdx >= 0 && (i === envIdx + 1)))[0] ?? ''; const json = args.includes('--json'); const findEnv = args.includes('--find-env'); function groupKey(name, pfx) { const rest = name.slice(pfx.length); const seg = rest.split('/')[0]; return rest.includes('/') ? `${seg}/` : seg; } async function main() { const env = findEnvFile({ envPath: envPathFlag }); if (findEnv) { if (env.hit) { console.log(`生效 .env: ${env.hit}`); return; } console.error('未找到 .env,候选路径:'); for (const c of env.candidates) console.error(` ${c}`); process.exit(2); } // 凭据来源: .env 文件 > 进程环境已有 OSS_*(Docker -e 注入场景),文件缺失但环境变量齐备时允许继续 const hasOssEnv = ['OSS_ACCESS_KEY_ID', 'OSS_ACCESS_KEY_SECRET', 'OSS_BUCKET'].every((k) => process.env[k]); if (!env.hit && !hasOssEnv) { console.error('未找到 OSS 凭据文件(探测序列如下),三选一:'); for (const c of env.candidates) console.error(` ${c}`); console.error(' → 1. 复制 .env.example 为 <技能仓库>/.env 并填入 OSS_* 四项'); console.error(' → 2. node oss.mjs --env (服务端项目在别处时)'); console.error(' → 3. 直接导出 OSS_* 环境变量(与 server/.env 同键名)'); process.exit(2); } if (env.hit) loadEnv(env.hit); const serverDir = env.hit ? path.dirname(env.hit) : null; const missing = ['OSS_ACCESS_KEY_ID', 'OSS_ACCESS_KEY_SECRET', 'OSS_BUCKET'].filter((k) => !process.env[k]); if (missing.length) fail(`OSS 凭据不全,缺: ${missing.join(', ')}。请检查 --env 指向的 .env 或环境变量`, 4); // 客户端两态: 服务端项目模块优先(行为一致),否则内置零依赖实现 const ossClientPath = serverDir ? path.join(serverDir, 'services', 'oss-client.js') : null; const { listObjects, publicDomain } = ossClientPath && fs.existsSync(ossClientPath) ? await import(pathToFileURL(ossClientPath)) : await import(pathToFileURL(path.join(SCRIPTS_DIR, 'oss-client-mini.mjs'))); const objects = await listObjects(prefix); const domain = publicDomain(); if (json) { console.log(JSON.stringify({ domain, prefix, objects: objects.map((o) => ({ name: o.name, size: o.size, lastModified: o.lastModified })), })); return; } if (!prefix) { const groups = new Map(); for (const o of objects) { const seg = groupKey(o.name, ''); if (!seg) continue; if (!groups.has(seg)) groups.set(seg, { count: 0, bytes: 0 }); groups.get(seg).count++; groups.get(seg).bytes += o.size; } console.log(`OSS 桶顶层目录(共 ${objects.length} 个对象): ${domain}`); for (const [seg, g] of [...groups.entries()].sort()) { console.log(` ${seg.padEnd(16)} ${g.count} 个文件, ${(g.bytes / 1024 / 1024).toFixed(2)} MB`); } console.log('使用: node oss.mjs 查看子目录(如 recordings/ cleansed/ transcripts/ reports/)'); return; } if (objects.length === 0) { console.log(`[${prefix}] 无对象`); return; } console.log(`[${prefix}] 共 ${objects.length} 个对象:`); for (const o of objects) { console.log(` ${o.name} (${(o.size / 1024 / 1024).toFixed(2)} MB, ${(o.lastModified || '').slice(0, 10)})`); } const bytes = objects.reduce((s, o) => s + o.size, 0); console.log(`合计 ${(bytes / 1024 / 1024).toFixed(2)} MB`); } main().catch((err) => { console.error(err.message); process.exit(1); });