|
@@ -1,34 +1,102 @@
|
|
|
#!/usr/bin/env node
|
|
#!/usr/bin/env node
|
|
|
/**
|
|
/**
|
|
|
* fmode-storage uploader — 对象存储上传/公开链接/ACL
|
|
* fmode-storage uploader — 对象存储上传/公开链接/ACL
|
|
|
- * 零依赖(Node ≥18)。凭据按 5 级优先级自动解析,永不入库。
|
|
|
|
|
|
|
+ * 零依赖(Node ≥18)。凭据按 4 级优先级自动解析,永不入库。
|
|
|
*
|
|
*
|
|
|
- * 第0级(自举):sessionToken → POST /api/storage/credentials 换 STS 临时凭证,
|
|
|
|
|
- * 内存持有直传 OBS(不落盘、不进日志)。后续级为回落链(自建 OBS / 已有
|
|
|
|
|
- * obsutil config 的用户不受影响)。
|
|
|
|
|
|
|
+ * v0.3.0 诚实凭据链(真因修复,见 CHANGELOG.md):
|
|
|
|
|
+ * 旧版第0级调用 POST /api/storage/credentials —— 该端点从未上线(404,
|
|
|
|
|
+ * 设计文档 fmode-studio/docs/obs-cdn/04-API设计.md 状态为"规划中"),
|
|
|
|
|
+ * "登录即可上传"是伪自举。本版改为:
|
|
|
|
|
+ *
|
|
|
|
|
+ * 第1级 环境变量 OBS_AK/OBS_SK(/OBS_ENDPOINT/OBS_BUCKET)
|
|
|
|
|
+ * 第2级 obsutil config 文件(OBSUTIL_CONFIG 或 ~/.obsutilconfig)解析 AK/SK/endpoint/bucket
|
|
|
|
|
+ * 第3级 ~/.fmode/config/user.json 的 fmodeApiToken → 平台签发接口
|
|
|
|
|
+ * (启动时 HEAD 探测该端点:404=未上线→跳过并提示;200=启用自举)
|
|
|
|
|
+ * 第4级 项目级 ./.fmode/config.json
|
|
|
|
|
+ * 全失败:打印初始化向导指引并退出码 2,绝不伪装成功。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 旧 sessionToken 自举保留为 --experimental-sts:仅当显式传入该参数
|
|
|
|
|
+ * 且端点探测返回 200 时启用(面向未来端点上线,默认关闭)。
|
|
|
*
|
|
*
|
|
|
* 用法:
|
|
* 用法:
|
|
|
|
|
+ * node uploader.mjs init [--ak ... --sk ... --endpoint ... --bucket ...]
|
|
|
* node uploader.mjs put <file> --key <objectKey> [--acl public-read] [--endpoint ...] [--bucket ...]
|
|
* node uploader.mjs put <file> --key <objectKey> [--acl public-read] [--endpoint ...] [--bucket ...]
|
|
|
* node uploader.mjs setacl --key <prefix|key> --acl public-read [-r]
|
|
* node uploader.mjs setacl --key <prefix|key> --acl public-read [-r]
|
|
|
|
|
+ * node uploader.mjs test
|
|
|
* node uploader.mjs config
|
|
* node uploader.mjs config
|
|
|
*/
|
|
*/
|
|
|
import fs from 'fs';
|
|
import fs from 'fs';
|
|
|
import os from 'os';
|
|
import os from 'os';
|
|
|
import path from 'path';
|
|
import path from 'path';
|
|
|
import { execSync, spawnSync } from 'child_process';
|
|
import { execSync, spawnSync } from 'child_process';
|
|
|
|
|
+import { fileURLToPath } from 'url';
|
|
|
|
|
|
|
|
const HOME = os.homedir();
|
|
const HOME = os.homedir();
|
|
|
|
|
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
|
|
|
|
-// fmode 网关基址(签发 STS / 下载 URL)
|
|
|
|
|
|
|
+// fmode 网关基址(平台 STS 签发端点所在)
|
|
|
const FMODE_API_BASE = process.env.FMODE_API_BASE || 'https://server.fmode.cn';
|
|
const FMODE_API_BASE = process.env.FMODE_API_BASE || 'https://server.fmode.cn';
|
|
|
const STORAGE_CREDENTIALS_URL = `${FMODE_API_BASE.replace(/\/$/, '')}/api/storage/credentials`;
|
|
const STORAGE_CREDENTIALS_URL = `${FMODE_API_BASE.replace(/\/$/, '')}/api/storage/credentials`;
|
|
|
|
|
+const DEFAULT_ENDPOINT = 'obs.cn-north-4.myhuaweicloud.com';
|
|
|
|
|
+const PROBE_CACHE_FILE = path.join(__dirname, '.sts-probe.json'); // 探测结果缓存(1 小时有效)
|
|
|
|
|
+const PROBE_TTL_MS = 60 * 60 * 1000;
|
|
|
|
|
+
|
|
|
|
|
+/** 打印初始化向导(全链失败时的唯一出口,绝不伪装成功) */
|
|
|
|
|
+function printWizard() {
|
|
|
|
|
+ console.error(`
|
|
|
|
|
+┌─ fmode-storage 初始化向导 ─────────────────────────────────────────────┐
|
|
|
|
|
+│ 当前版本需一次性配置 OBS 子账号 AK/SK;sessionToken 免配置自举待平台 │
|
|
|
|
|
+│ 端点上线(设计文档 fmode-studio/docs/obs-cdn/04-API设计.md,规划中)。 │
|
|
|
|
|
+├────────────────────────────────────────────────────────────────────────┤
|
|
|
|
|
+│ 步骤 1 向 Fmode 平台/管理员申请 OBS 子账号(邮件模板见 README.md │
|
|
|
|
|
+│ "三分钟初始化"章节),拿到 AK/SK 及所属桶/终端节点。 │
|
|
|
|
|
+│ 步骤 2 执行初始化(任选其一): │
|
|
|
|
|
+│ node uploader.mjs init --ak <AK> --sk <SK> \\ │
|
|
|
|
|
+│ --endpoint obs.cn-north-4.myhuaweicloud.com \\ │
|
|
|
|
|
+│ --bucket <bucket> │
|
|
|
|
|
+│ (省略参数则进入交互模式逐项询问) │
|
|
|
|
|
+│ 步骤 3 验证:node uploader.mjs test │
|
|
|
|
|
+│ (上传 1KB 探针文件→删除→报告成功) │
|
|
|
|
|
+└────────────────────────────────────────────────────────────────────────┘`);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ============================================================================
|
|
|
|
|
+// 平台签发端点探测(B 节:诚实探测 + 1 小时缓存)
|
|
|
|
|
+// ============================================================================
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 第0级:解析 sessionToken。
|
|
|
|
|
- * 来源(优先级):FMODE_SESSION_TOKEN 环境变量
|
|
|
|
|
- * → ~/.fmode/config.json 的 sessionToken / user.sessionToken
|
|
|
|
|
- * → ~/.fmode/config/user.json 的 sessionToken
|
|
|
|
|
- * 找到即返回字符串,找不到返回 null(不报错——交给上层决定是否回落)。
|
|
|
|
|
|
|
+ * HEAD 探测平台 STS 签发端点是否上线。结果缓存本文件 .sts-probe.json(1 小时)。
|
|
|
|
|
+ * @returns {Promise<{online: boolean, checkedAt: number, status: number|null}>}
|
|
|
|
|
+ */
|
|
|
|
|
+export async function probeStsEndpoint() {
|
|
|
|
|
+ // 读缓存
|
|
|
|
|
+ try {
|
|
|
|
|
+ const j = JSON.parse(fs.readFileSync(PROBE_CACHE_FILE, 'utf8'));
|
|
|
|
|
+ if (j.checkedAt && Date.now() - j.checkedAt < PROBE_TTL_MS) return j;
|
|
|
|
|
+ } catch { /* 无缓存或损坏 → 重新探测 */ }
|
|
|
|
|
+ let status = null;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch(STORAGE_CREDENTIALS_URL, { method: 'HEAD', signal: AbortSignal.timeout(10000) });
|
|
|
|
|
+ status = res.status;
|
|
|
|
|
+ } catch { /* 网络失败视为未上线,不给假希望 */ }
|
|
|
|
|
+ const result = { online: status === 200, checkedAt: Date.now(), status };
|
|
|
|
|
+ try { fs.writeFileSync(PROBE_CACHE_FILE, JSON.stringify(result)); } catch { /* 只读环境忽略 */ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 探测缓存是否可以强制刷新(--refresh-probe / init / test 命令场景) */
|
|
|
|
|
+function invalidateProbeCache() {
|
|
|
|
|
+ try { fs.rmSync(PROBE_CACHE_FILE, { force: true }); } catch { /* ignore */ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ============================================================================
|
|
|
|
|
+// 旧第0级自举(--experimental-sts 专用,端点上线前是死代码路径)
|
|
|
|
|
+// ============================================================================
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 解析 sessionToken(优先级:FMODE_SESSION_TOKEN → ~/.fmode config 文件)。
|
|
|
|
|
+ * 注意:真实身份字段是 fmodeApiToken(sk- 开头),本函数仅在
|
|
|
|
|
+ * --experimental-sts 且端点探测 200 时被调用,面向未来端点上线。
|
|
|
*/
|
|
*/
|
|
|
export function resolveSessionToken() {
|
|
export function resolveSessionToken() {
|
|
|
if (process.env.FMODE_SESSION_TOKEN) return process.env.FMODE_SESSION_TOKEN.trim();
|
|
if (process.env.FMODE_SESSION_TOKEN) return process.env.FMODE_SESSION_TOKEN.trim();
|
|
@@ -49,29 +117,15 @@ export function resolveSessionToken() {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 第0级:sessionToken → STS 临时凭证(AK/SK/SecurityToken,作用域限定用户 prefix)。
|
|
|
|
|
- *
|
|
|
|
|
- * 权威链路(fmode-studio/docs/obs-cdn/02-架构与路径划分.md §5.2):
|
|
|
|
|
- * sessionToken → POST /api/storage/credentials
|
|
|
|
|
- * → { accessKeyId, secretAccessKey, sessionToken(即 SecurityToken),
|
|
|
|
|
- * endpoint, bucket, prefix, expiration }
|
|
|
|
|
- * → 客户端直传 OBS
|
|
|
|
|
- *
|
|
|
|
|
- * ⚠️ 返回的 STS 仅内存持有:不写文件、不打日志、不进错误信息。
|
|
|
|
|
- *
|
|
|
|
|
- * @returns {Promise<{ ak: string, sk: string, securityToken: string,
|
|
|
|
|
- * endpoint: string, bucket: string, prefix: string } | null>}
|
|
|
|
|
- * 签发失败返回 null(调用方回落第1-4级)。
|
|
|
|
|
|
|
+ * sessionToken → STS 临时凭证。⚠️ 端点未上线(404)前必然返回 null。
|
|
|
|
|
+ * 返回的 STS 仅内存持有:不写文件、不打日志、不进错误信息。
|
|
|
*/
|
|
*/
|
|
|
export async function fetchStsCredentials(sessionToken) {
|
|
export async function fetchStsCredentials(sessionToken) {
|
|
|
if (!sessionToken) return null;
|
|
if (!sessionToken) return null;
|
|
|
try {
|
|
try {
|
|
|
const res = await fetch(STORAGE_CREDENTIALS_URL, {
|
|
const res = await fetch(STORAGE_CREDENTIALS_URL, {
|
|
|
method: 'POST',
|
|
method: 'POST',
|
|
|
- headers: {
|
|
|
|
|
- 'Content-Type': 'application/json',
|
|
|
|
|
- 'Authorization': `Bearer ${sessionToken}`,
|
|
|
|
|
- },
|
|
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${sessionToken}` },
|
|
|
body: JSON.stringify({ op: ['put', 'delete'] }),
|
|
body: JSON.stringify({ op: ['put', 'delete'] }),
|
|
|
signal: AbortSignal.timeout(15000),
|
|
signal: AbortSignal.timeout(15000),
|
|
|
});
|
|
});
|
|
@@ -83,26 +137,22 @@ export async function fetchStsCredentials(sessionToken) {
|
|
|
ak: cred.accessKeyId,
|
|
ak: cred.accessKeyId,
|
|
|
sk: cred.secretAccessKey,
|
|
sk: cred.secretAccessKey,
|
|
|
securityToken: cred.sessionToken || cred.securityToken || '',
|
|
securityToken: cred.sessionToken || cred.securityToken || '',
|
|
|
- endpoint: cred.endpoint || 'obs.cn-south-1.myhuaweicloud.com',
|
|
|
|
|
|
|
+ endpoint: cred.endpoint || DEFAULT_ENDPOINT,
|
|
|
bucket: cred.bucket || null,
|
|
bucket: cred.bucket || null,
|
|
|
prefix: cred.prefix || '',
|
|
prefix: cred.prefix || '',
|
|
|
};
|
|
};
|
|
|
- } catch { /* 网络/签名失败一律回落,不泄露错误细节 */ }
|
|
|
|
|
- return null;
|
|
|
|
|
|
|
+ } catch { return null; }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* 用 STS 临时凭证构造一次性 obsutil 配置目录,执行命令后即删。
|
|
* 用 STS 临时凭证构造一次性 obsutil 配置目录,执行命令后即删。
|
|
|
- * 配置只写进临时目录(0700),命令结束立即删除——STS 不落盘留存、不进日志。
|
|
|
|
|
- *
|
|
|
|
|
- * @param {object} sts fetchStsCredentials() 的返回值
|
|
|
|
|
- * @param {string[]} args obsutil 参数(不含 -e/-i/-k/-t)
|
|
|
|
|
|
|
+ * (配置经 -config= 显式传给 obsutil——旧版 OBSUTIL_CONFIG_FILE 环境变量
|
|
|
|
|
+ * obsutil 并不识别,属无效传递,本版修正。)
|
|
|
*/
|
|
*/
|
|
|
export function runWithSts(sts, args, timeout = 300000) {
|
|
export function runWithSts(sts, args, timeout = 300000) {
|
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fmode-sts-'));
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fmode-sts-'));
|
|
|
fs.chmodSync(tmpDir, 0o700);
|
|
fs.chmodSync(tmpDir, 0o700);
|
|
|
const cfgFile = path.join(tmpDir, '.obsutilconfig');
|
|
const cfgFile = path.join(tmpDir, '.obsutilconfig');
|
|
|
- // STS 写入临时配置仅供 obsutil 本次调用读取,结束后立刻删除
|
|
|
|
|
fs.writeFileSync(cfgFile, [
|
|
fs.writeFileSync(cfgFile, [
|
|
|
`endpoint=${sts.endpoint}`,
|
|
`endpoint=${sts.endpoint}`,
|
|
|
`ak=${sts.ak}`,
|
|
`ak=${sts.ak}`,
|
|
@@ -111,92 +161,281 @@ export function runWithSts(sts, args, timeout = 300000) {
|
|
|
'',
|
|
'',
|
|
|
].join('\n'), { mode: 0o600 });
|
|
].join('\n'), { mode: 0o600 });
|
|
|
try {
|
|
try {
|
|
|
- const r = spawnSync('obsutil', args, {
|
|
|
|
|
- encoding: 'utf8',
|
|
|
|
|
- timeout,
|
|
|
|
|
- env: { ...process.env, OBSUTIL_CONFIG_FILE: cfgFile },
|
|
|
|
|
- });
|
|
|
|
|
- const out = (r.stdout || '') + (r.stderr || '');
|
|
|
|
|
- return { ok: /Upload successfully|Download successfully|Set the acl/i.test(out) || r.status === 0, out };
|
|
|
|
|
|
|
+ return runObsutil(cfg.obsUtilPath, args, { cfgFile, timeout });
|
|
|
} finally {
|
|
} finally {
|
|
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** 5 级凭据/配置解析:第0级 sessionToken 自举 → env → ~/.fmode → ./.fmode → obsutil 默认链 */
|
|
|
|
|
-export async function resolveStorageConfig() {
|
|
|
|
|
|
|
+// ============================================================================
|
|
|
|
|
+// obsutil 执行层
|
|
|
|
|
+// ============================================================================
|
|
|
|
|
+
|
|
|
|
|
+/** 统一 obsutil 调用。成功判定用退出码 + 末行无 "failed",杜绝"failed"文案误判为成功。 */
|
|
|
|
|
+function runObsutil(obsUtilPath, args, { cfgFile = null, timeout = 120000 } = {}) {
|
|
|
|
|
+ const r = spawnSync(obsUtilPath, [...args, ...(cfgFile ? [`-config=${cfgFile}`] : [])], {
|
|
|
|
|
+ encoding: 'utf8',
|
|
|
|
|
+ timeout,
|
|
|
|
|
+ });
|
|
|
|
|
+ const out = (r.stdout || '') + (r.stderr || '');
|
|
|
|
|
+ const ok = r.status === 0 && !/failed/i.test(out.split('\n').filter(Boolean).pop() || '');
|
|
|
|
|
+ return { ok, out, status: r.status };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 公开 URL: cdnDomain 优先, 否则桶域名 */
|
|
|
|
|
+export function publicUrl(cfg, key) {
|
|
|
|
|
+ if (cfg.cdnDomain) return `https://${cfg.cdnDomain}/${key}`;
|
|
|
|
|
+ return `https://${cfg.bucket}.${cfg.endpoint}/${key}`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ============================================================================
|
|
|
|
|
+// 凭据链:诚实 4 级
|
|
|
|
|
+// ============================================================================
|
|
|
|
|
+
|
|
|
|
|
+/** 解析 obsutil config 文件内容 → {endpoint, ak, sk, token, bucket}。密钥不外泄,仅内部使用。 */
|
|
|
|
|
+function parseObsutilConfig(text) {
|
|
|
|
|
+ const c = {};
|
|
|
|
|
+ for (const line of String(text).split('\n')) {
|
|
|
|
|
+ const m = line.match(/^(endpoint|ak|sk|token)=(.*)$/);
|
|
|
|
|
+ if (m) c[m[1]] = m[2].trim();
|
|
|
|
|
+ }
|
|
|
|
|
+ return (c.ak && c.sk) ? c : null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** obsutil config 文件候选路径(OBSUTIL_CONFIG 环境变量优先,其次 ~/.obsutilconfig) */
|
|
|
|
|
+function obsutilConfigPaths() {
|
|
|
|
|
+ const paths = [];
|
|
|
|
|
+ if (process.env.OBSUTIL_CONFIG) paths.push(process.env.OBSUTIL_CONFIG);
|
|
|
|
|
+ paths.push(path.join(HOME, '.obsutilconfig'));
|
|
|
|
|
+ // getpwuid 的 home 可能与 $HOME 不同(容器常见):obsutil 实际按它解析默认配置
|
|
|
|
|
+ try {
|
|
|
|
|
+ const pwHome = os.userInfo().homedir;
|
|
|
|
|
+ if (pwHome && pwHome !== HOME) paths.push(path.join(pwHome, '.obsutilconfig'));
|
|
|
|
|
+ } catch { /* ignore */ }
|
|
|
|
|
+ return paths;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 定位 obsutil 可执行文件:PATH → ~/.local/bin → ~/bin → getpwuid home/bin → /usr/local/bin */
|
|
|
|
|
+function findObsutil() {
|
|
|
|
|
+ const candidates = [];
|
|
|
|
|
+ for (const dir of (process.env.PATH || '').split(':')) {
|
|
|
|
|
+ if (dir) candidates.push(path.join(dir, 'obsutil'));
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const base of [path.join(HOME, '.local', 'bin'), path.join(HOME, 'bin')]) candidates.push(path.join(base, 'obsutil'));
|
|
|
|
|
+ try {
|
|
|
|
|
+ const pwHome = os.userInfo().homedir;
|
|
|
|
|
+ if (pwHome && pwHome !== HOME) candidates.push(path.join(pwHome, 'bin', 'obsutil'));
|
|
|
|
|
+ } catch { /* ignore */ }
|
|
|
|
|
+ candidates.push('/usr/local/bin/obsutil', '/usr/bin/obsutil');
|
|
|
|
|
+ for (const p of candidates) {
|
|
|
|
|
+ try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { /* next */ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return 'obsutil'; // 交给 spawnSync 报"not found"
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 诚实 4 级凭据解析(命中即用,检测不到就换下一级):
|
|
|
|
|
+ * 第1级 环境变量 OBS_AK/OBS_SK(/OBS_ENDPOINT/OBS_BUCKET)
|
|
|
|
|
+ * 第2级 obsutil config 文件(OBSUTIL_CONFIG 或 ~/.obsutilconfig)
|
|
|
|
|
+ * 第3级 ~/.fmode/config/user.json 的 fmodeApiToken → 平台签发接口
|
|
|
|
|
+ * (先 HEAD 探测端点:404=未上线→跳过并提示,不空转)
|
|
|
|
|
+ * 第4级 项目级 ./.fmode/config.json
|
|
|
|
|
+ */
|
|
|
|
|
+export async function resolveStorageConfig({ experimentalSts = false } = {}) {
|
|
|
const cfg = {
|
|
const cfg = {
|
|
|
obsUtilPath: 'obsutil',
|
|
obsUtilPath: 'obsutil',
|
|
|
- endpoint: 'obs.cn-south-1.myhuaweicloud.com',
|
|
|
|
|
|
|
+ endpoint: DEFAULT_ENDPOINT,
|
|
|
bucket: null,
|
|
bucket: null,
|
|
|
cdnDomain: null,
|
|
cdnDomain: null,
|
|
|
- sts: null, // 第0级自举得到的 STS(内存持有)
|
|
|
|
|
|
|
+ sts: null,
|
|
|
via: null, // 命中的解析级别(诊断用,不含任何密钥)
|
|
via: null, // 命中的解析级别(诊断用,不含任何密钥)
|
|
|
|
|
+ ak: null, // 内部传递用,config 命令绝不输出
|
|
|
|
|
+ sk: null,
|
|
|
|
|
+ token: '',
|
|
|
};
|
|
};
|
|
|
- // ---- 第0级自举:sessionToken → STS ----
|
|
|
|
|
- const sessionToken = resolveSessionToken();
|
|
|
|
|
- if (sessionToken) {
|
|
|
|
|
- const sts = await fetchStsCredentials(sessionToken);
|
|
|
|
|
- if (sts) {
|
|
|
|
|
- cfg.sts = sts;
|
|
|
|
|
- cfg.bucket = sts.bucket || cfg.bucket;
|
|
|
|
|
- cfg.endpoint = sts.endpoint || cfg.endpoint;
|
|
|
|
|
- cfg.via = 'level0:sessionToken->STS';
|
|
|
|
|
- return cfg; // 自举成功,无需回落
|
|
|
|
|
|
|
+ cfg.obsUtilPath = findObsutil();
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 第1级:环境变量 ----
|
|
|
|
|
+ if (process.env.OBS_AK && process.env.OBS_SK) {
|
|
|
|
|
+ cfg.ak = process.env.OBS_AK.trim();
|
|
|
|
|
+ cfg.sk = process.env.OBS_SK.trim();
|
|
|
|
|
+ if (process.env.OBS_ENDPOINT) cfg.endpoint = process.env.OBS_ENDPOINT.trim();
|
|
|
|
|
+ if (process.env.OBS_BUCKET) cfg.bucket = process.env.OBS_BUCKET.trim();
|
|
|
|
|
+ cfg.via = 'level1:env(OBS_AK/OBS_SK)';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 第2级:obsutil config 文件 ----
|
|
|
|
|
+ if (!cfg.ak) {
|
|
|
|
|
+ for (const p of obsutilConfigPaths()) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (!fs.existsSync(p)) continue;
|
|
|
|
|
+ const c = parseObsutilConfig(fs.readFileSync(p, 'utf8'));
|
|
|
|
|
+ if (c) {
|
|
|
|
|
+ cfg.ak = c.ak; cfg.sk = c.sk; cfg.token = c.token || '';
|
|
|
|
|
+ if (c.endpoint) cfg.endpoint = c.endpoint;
|
|
|
|
|
+ cfg.via = `level2:obsutilconfig(${p})`;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch { /* try next path */ }
|
|
|
}
|
|
}
|
|
|
- // sessionToken 存在但换取失败:给出明确报错指向重新登录
|
|
|
|
|
- console.error('sessionToken 存在但 STS 换取失败——sessionToken 缺失或失效,请重新登录 FMODE Studio 或配置 FMODE_SESSION_TOKEN');
|
|
|
|
|
- console.error('(将继续尝试第1-4级回落配置)');
|
|
|
|
|
}
|
|
}
|
|
|
- // ---- 第1级:环境变量 ----
|
|
|
|
|
- if (process.env.FMODE_OBS_CONF) {
|
|
|
|
|
- try { Object.assign(cfg, JSON.parse(fs.readFileSync(process.env.FMODE_OBS_CONF, 'utf8'))); cfg.via = 'level1:FMODE_OBS_CONF'; } catch {}
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 第3级:fmodeApiToken → 平台签发接口(端点未上线则跳过并提示)----
|
|
|
|
|
+ if (!cfg.ak) {
|
|
|
|
|
+ const userCfg = path.join(HOME, '.fmode', 'config', 'user.json');
|
|
|
|
|
+ const token = (() => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const j = JSON.parse(fs.readFileSync(userCfg, 'utf8').replace(/^/, ''));
|
|
|
|
|
+ return (j.fmodeApiToken || '').trim() || null;
|
|
|
|
|
+ } catch { return null; }
|
|
|
|
|
+ })();
|
|
|
|
|
+ if (token) {
|
|
|
|
|
+ const probe = await probeStsEndpoint();
|
|
|
|
|
+ if (!probe.online) {
|
|
|
|
|
+ console.error(`平台 STS 签发端点未上线(探测 ${STORAGE_CREDENTIALS_URL} → ${probe.status ?? '网络失败'};设计文档 fmode-studio/docs/obs-cdn/04-API设计.md 状态"规划中"),暂用 obsutil 配置模式`);
|
|
|
|
|
+ } else if (experimentalSts) {
|
|
|
|
|
+ const sts = await fetchStsCredentials(token);
|
|
|
|
|
+ if (sts) {
|
|
|
|
|
+ cfg.sts = sts;
|
|
|
|
|
+ cfg.endpoint = sts.endpoint || cfg.endpoint;
|
|
|
|
|
+ cfg.bucket = sts.bucket || cfg.bucket;
|
|
|
|
|
+ cfg.via = 'level3:experimental-sts';
|
|
|
|
|
+ return cfg;
|
|
|
|
|
+ }
|
|
|
|
|
+ console.error('--experimental-sts 已启用且端点在线,但换取失败:token 缺失或失效');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.error('平台 STS 端点已上线。加 --experimental-sts 启用 sessionToken 自举(或等待默认开启)。');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
- // ---- 第2/3级:~/.fmode/config.json → ./.fmode/config.json ----
|
|
|
|
|
- for (const p of [path.join(HOME, '.fmode', 'config.json'), path.join(process.cwd(), '.fmode', 'config.json')]) {
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 第4级:项目级 ./.fmode/config.json ----
|
|
|
|
|
+ if (!cfg.ak && !cfg.sts) {
|
|
|
try {
|
|
try {
|
|
|
|
|
+ const p = path.join(process.cwd(), '.fmode', 'config.json');
|
|
|
const j = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
const j = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
|
if (j.obsUtilPath) cfg.obsUtilPath = j.obsUtilPath;
|
|
if (j.obsUtilPath) cfg.obsUtilPath = j.obsUtilPath;
|
|
|
- if (j.obsBucket) { cfg.bucket = j.obsBucket; cfg.via = cfg.via || `level:${p}`; }
|
|
|
|
|
|
|
+ if (j.obsBucket) { cfg.bucket = j.obsBucket; cfg.via = `level4:${p}`; }
|
|
|
if (j.obsEndpoint) cfg.endpoint = j.obsEndpoint;
|
|
if (j.obsEndpoint) cfg.endpoint = j.obsEndpoint;
|
|
|
if (j.cdnDomain) cfg.cdnDomain = j.cdnDomain;
|
|
if (j.cdnDomain) cfg.cdnDomain = j.cdnDomain;
|
|
|
- } catch {}
|
|
|
|
|
|
|
+ } catch { /* ignore */ }
|
|
|
}
|
|
}
|
|
|
- // ---- 第4级:obsutil 默认链(已配置则探测 bucket)----
|
|
|
|
|
- if (!cfg.bucket) {
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // ---- bucket 补全:第1/2级命中但缺 bucket → obsutil ls 探测 ----
|
|
|
|
|
+ if (cfg.ak && !cfg.bucket) {
|
|
|
try {
|
|
try {
|
|
|
- const out = execSync(`${cfg.obsUtilPath} ls -limit=1 2>/dev/null`, { encoding: 'utf8', timeout: 15000 });
|
|
|
|
|
- const m = out.match(/obs:\/\/([^/]+)/);
|
|
|
|
|
- if (m) { cfg.bucket = m[1]; cfg.via = 'level4:obsutil-default'; }
|
|
|
|
|
- } catch {}
|
|
|
|
|
|
|
+ const out = execSync(`${JSON.stringify(cfg.obsUtilPath)} ls -limit=1`, { encoding: 'utf8', timeout: 15000 });
|
|
|
|
|
+ const m = out.match(/obs:\/\/([^/\s]+)/);
|
|
|
|
|
+ if (m) { cfg.bucket = m[1]; cfg.via += '+bucket-probe'; }
|
|
|
|
|
+ } catch { /* 探测失败交给上层报错 */ }
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
return cfg;
|
|
return cfg;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** 公开 URL: cdnDomain 优先, 否则桶域名 */
|
|
|
|
|
-export function publicUrl(cfg, key) {
|
|
|
|
|
- if (cfg.cdnDomain) return `https://${cfg.cdnDomain}/${key}`;
|
|
|
|
|
- return `https://${cfg.bucket}.${cfg.endpoint}/${key}`.replace('.myhuaweicloud.com', '.myhuaweicloud.com');
|
|
|
|
|
|
|
+// ============================================================================
|
|
|
|
|
+// 初始化向导(C 节:新用户路径)
|
|
|
|
|
+// ============================================================================
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * node uploader.mjs init [--ak ... --sk ... --endpoint ... --bucket ...]
|
|
|
|
|
+ * 写入 obsutil config(600 权限)+ 本技能 config 段,然后立即 test。
|
|
|
|
|
+ */
|
|
|
|
|
+async function cmdInit(rest) {
|
|
|
|
|
+ const arg = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : null; };
|
|
|
|
|
+ const ask = async (q) => {
|
|
|
|
|
+ process.stdout.write(q);
|
|
|
|
|
+ let buf = '';
|
|
|
|
|
+ for await (const chunk of process.stdin) { buf += chunk; break; }
|
|
|
|
|
+ return buf.trim();
|
|
|
|
|
+ };
|
|
|
|
|
+ const ak = arg('--ak') || await ask('AK: ');
|
|
|
|
|
+ const sk = arg('--sk') || await ask('SK: ');
|
|
|
|
|
+ const endpoint = arg('--endpoint') || await ask(`endpoint [${DEFAULT_ENDPOINT}]: `) || DEFAULT_ENDPOINT;
|
|
|
|
|
+ const bucket = arg('--bucket') || await ask('bucket: ');
|
|
|
|
|
+ if (!ak || !sk || !bucket) {
|
|
|
|
|
+ console.error('init 失败:AK/SK/bucket 均必填(--ak --sk --endpoint --bucket 或交互输入)');
|
|
|
|
|
+ process.exit(2);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 1) 写 obsutil config(600)
|
|
|
|
|
+ const cfgPath = path.join(HOME, '.obsutilconfig');
|
|
|
|
|
+ fs.writeFileSync(cfgPath, [
|
|
|
|
|
+ `endpoint=${endpoint}`,
|
|
|
|
|
+ `ak=${ak}`,
|
|
|
|
|
+ `sk=${sk}`,
|
|
|
|
|
+ 'token=',
|
|
|
|
|
+ 'cname=false',
|
|
|
|
|
+ `endpointCrr=http://your-endpoint`,
|
|
|
|
|
+ `akCrr=*** Provide your Access Key ***`,
|
|
|
|
|
+ `skCrr=*** Provide your Secret Key ***`,
|
|
|
|
|
+ `tokenCrr=`,
|
|
|
|
|
+ '',
|
|
|
|
|
+ ].join('\n'), { mode: 0o600 });
|
|
|
|
|
+
|
|
|
|
|
+ // 2) 写本技能 config 段(~/.fmode/config.json,不含密钥本体)
|
|
|
|
|
+ const fmodeDir = path.join(HOME, '.fmode');
|
|
|
|
|
+ const fmodeCfg = path.join(fmodeDir, 'config.json');
|
|
|
|
|
+ fs.mkdirSync(fmodeDir, { recursive: true });
|
|
|
|
|
+ let j = {};
|
|
|
|
|
+ try { j = JSON.parse(fs.readFileSync(fmodeCfg, 'utf8')); } catch { /* 新文件 */ }
|
|
|
|
|
+ j.obsBucket = bucket;
|
|
|
|
|
+ j.obsEndpoint = endpoint;
|
|
|
|
|
+ fs.writeFileSync(fmodeCfg, JSON.stringify(j, null, 2) + '\n', { mode: 0o600 });
|
|
|
|
|
+ console.error(`已写入 ${cfgPath}(600)与 ${fmodeCfg}(技能 config 段,不含密钥本体)`);
|
|
|
|
|
+
|
|
|
|
|
+ // 3) 立即 test:上传 1KB 探针 → 删除 → 报告
|
|
|
|
|
+ return cmdTest();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function obs(args, cfg, timeout = 120000) {
|
|
|
|
|
- // 第0级命中 → STS 一次性临时配置执行(不落盘留存)
|
|
|
|
|
- if (cfg.sts) return runWithSts(cfg.sts, args, timeout);
|
|
|
|
|
- // 回落:用户自己的 obsutil 配置
|
|
|
|
|
- const r = spawnSync(cfg.obsUtilPath, [...args, '-e', cfg.endpoint], { encoding: 'utf8', timeout });
|
|
|
|
|
- const out = (r.stdout || '') + (r.stderr || '');
|
|
|
|
|
- return { ok: /Upload successfully|Download successfully|Set the acl/i.test(out) || r.status === 0, out };
|
|
|
|
|
|
|
+/** test:上传 1KB 探针文件→删除→报告成功 */
|
|
|
|
|
+async function cmdTest() {
|
|
|
|
|
+ const cfg = await resolveStorageConfig();
|
|
|
|
|
+ if (!cfg.ak && !cfg.sts) { printWizard(); process.exit(2); }
|
|
|
|
|
+ if (!cfg.bucket) { console.error(`凭据已命中(${cfg.via})但未解析到 bucket:请 --bucket 指定或用 init 写入`); process.exit(2); }
|
|
|
|
|
+ const tmp = path.join(os.tmpdir(), `fmode-storage-probe-${process.pid}.txt`);
|
|
|
|
|
+ fs.writeFileSync(tmp, 'fmode-storage probe ' + new Date().toISOString() + '\n');
|
|
|
|
|
+ const key = `skill-storage-selftest/probe-${process.pid}.txt`;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const up = runObsutil(cfg.obsUtilPath, ['cp', tmp, `obs://${cfg.bucket}/${key}`], { timeout: 120000 });
|
|
|
|
|
+ if (!up.ok) { console.error('test 失败(上传):', up.out.slice(-300)); process.exit(1); }
|
|
|
|
|
+ const del = runObsutil(cfg.obsUtilPath, ['rm', `obs://${cfg.bucket}/${key}`, '-f'], { timeout: 60000 });
|
|
|
|
|
+ if (!del.ok) { console.error('test 失败(清理探针文件,上传本身已成功):', del.out.slice(-300)); process.exit(1); }
|
|
|
|
|
+ console.log(JSON.stringify({ ok: true, bucket: cfg.bucket, endpoint: cfg.endpoint, via: cfg.via, probeKey: key, message: '上传 1KB 探针→删除 全部成功' }, null, 2));
|
|
|
|
|
+ return true;
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// ============================================================================
|
|
|
|
|
+// main
|
|
|
|
|
+// ============================================================================
|
|
|
|
|
+
|
|
|
async function main() {
|
|
async function main() {
|
|
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
|
- const cfg = await resolveStorageConfig();
|
|
|
|
|
- if (!cfg.bucket && cmd !== 'help') {
|
|
|
|
|
- console.error('未解析到 bucket:请登录 FMODE Studio(sessionToken 自举)或配置 ~/.fmode/config.json 的 obsBucket/obsEndpoint 或 obsutil config。');
|
|
|
|
|
- console.error('(技能绝不内置密钥;自建 OBS 用户用 obsutil config 自行配置)');
|
|
|
|
|
|
|
+ const arg = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : null; };
|
|
|
|
|
+ const experimentalSts = rest.includes('--experimental-sts');
|
|
|
|
|
+ if (rest.includes('--refresh-probe')) invalidateProbeCache();
|
|
|
|
|
+
|
|
|
|
|
+ if (cmd === 'init') { process.exit(await cmdInit(rest) ? 0 : 1); }
|
|
|
|
|
+ if (cmd === 'test') { process.exit(await cmdTest() ? 0 : 1); }
|
|
|
|
|
+
|
|
|
|
|
+ const cfg = await resolveStorageConfig({ experimentalSts });
|
|
|
|
|
+ if (!cfg.ak && !cfg.sts) {
|
|
|
|
|
+ if (cmd !== 'help') {
|
|
|
|
|
+ console.error('凭据链 4 级全部未命中(env → obsutil config → 平台签发(未上线) → 项目 config)。');
|
|
|
|
|
+ printWizard();
|
|
|
|
|
+ process.exit(2);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (cfg.ak && !cfg.sts && !cfg.bucket && ['put', 'setacl'].includes(cmd)) {
|
|
|
|
|
+ console.error(`凭据已命中(${cfg.via})但未解析到 bucket:用 --bucket 指定,或重新执行 init 写入完整配置。`);
|
|
|
process.exit(2);
|
|
process.exit(2);
|
|
|
}
|
|
}
|
|
|
- const arg = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : null; };
|
|
|
|
|
|
|
+ const obs = (args, timeout = 120000) => cfg.sts
|
|
|
|
|
+ ? runWithSts(cfg.sts, args, timeout)
|
|
|
|
|
+ : runObsutil(cfg.obsUtilPath, args, { timeout });
|
|
|
|
|
|
|
|
if (cmd === 'put') {
|
|
if (cmd === 'put') {
|
|
|
const file = rest[0];
|
|
const file = rest[0];
|
|
@@ -204,7 +443,7 @@ async function main() {
|
|
|
if (!file || !key) { console.error('用法: put <file> --key <objectKey> [--acl public-read]'); process.exit(2); }
|
|
if (!file || !key) { console.error('用法: put <file> --key <objectKey> [--acl public-read]'); process.exit(2); }
|
|
|
const acl = arg('--acl') || 'public-read';
|
|
const acl = arg('--acl') || 'public-read';
|
|
|
const bucketArg = arg('--bucket'); if (bucketArg) cfg.bucket = bucketArg;
|
|
const bucketArg = arg('--bucket'); if (bucketArg) cfg.bucket = bucketArg;
|
|
|
- const r = obs(['cp', file, `obs://${cfg.bucket}/${key}`], cfg);
|
|
|
|
|
|
|
+ const r = obs(['cp', file, `obs://${cfg.bucket}/${key}`, ...(acl ? ['-acl', acl] : [])]);
|
|
|
if (!r.ok) { console.error('上传失败:', r.out.slice(-300)); process.exit(1); }
|
|
if (!r.ok) { console.error('上传失败:', r.out.slice(-300)); process.exit(1); }
|
|
|
console.log(JSON.stringify({ ok: true, key, url: publicUrl(cfg, key), bucket: cfg.bucket, via: cfg.via }, null, 2));
|
|
console.log(JSON.stringify({ ok: true, key, url: publicUrl(cfg, key), bucket: cfg.bucket, via: cfg.via }, null, 2));
|
|
|
return;
|
|
return;
|
|
@@ -212,24 +451,25 @@ async function main() {
|
|
|
if (cmd === 'setacl') {
|
|
if (cmd === 'setacl') {
|
|
|
const key = arg('--key'); const acl = arg('--acl') || 'public-read';
|
|
const key = arg('--key'); const acl = arg('--acl') || 'public-read';
|
|
|
const recursive = rest.includes('-r');
|
|
const recursive = rest.includes('-r');
|
|
|
- const r = obs(['chattri', `obs://${cfg.bucket}/${key}`, '-acl', acl, ...(recursive ? ['-r', '-f'] : ['-f'])], cfg);
|
|
|
|
|
- console.log(JSON.stringify({ ok: r.ok }, null, 2));
|
|
|
|
|
|
|
+ const r = obs(['chattri', `obs://${cfg.bucket}/${key}`, '-acl', acl, ...(recursive ? ['-r', '-f'] : ['-f'])]);
|
|
|
|
|
+ if (!r.ok) { console.error('setacl 失败:', r.out.slice(-300)); process.exit(1); }
|
|
|
|
|
+ console.log(JSON.stringify({ ok: true }, null, 2));
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
if (cmd === 'config') {
|
|
if (cmd === 'config') {
|
|
|
- // 诊断输出:绝不包含 AK/SK/SecurityToken/sessionToken 任何密钥本体
|
|
|
|
|
|
|
+ // 诊断输出:绝不包含 AK/SK/token/sessionToken 任何密钥本体
|
|
|
console.log(JSON.stringify({
|
|
console.log(JSON.stringify({
|
|
|
via: cfg.via,
|
|
via: cfg.via,
|
|
|
bucket: cfg.bucket,
|
|
bucket: cfg.bucket,
|
|
|
endpoint: cfg.endpoint,
|
|
endpoint: cfg.endpoint,
|
|
|
cdnDomain: cfg.cdnDomain,
|
|
cdnDomain: cfg.cdnDomain,
|
|
|
- stsBootstrapped: Boolean(cfg.sts),
|
|
|
|
|
|
|
+ credentialsResolved: Boolean(cfg.ak || cfg.sts),
|
|
|
|
|
+ stsExperimental: Boolean(cfg.sts),
|
|
|
stsPrefix: cfg.sts ? cfg.sts.prefix : null,
|
|
stsPrefix: cfg.sts ? cfg.sts.prefix : null,
|
|
|
- stsExpiresInMemoryOnly: Boolean(cfg.sts),
|
|
|
|
|
}, null, 2));
|
|
}, null, 2));
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
- console.log('用法: put | setacl | config');
|
|
|
|
|
|
|
+ console.log('用法: init | test | put | setacl | config');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-main();
|
|
|
|
|
|
|
+main().catch((e) => { console.error('未预期错误:', e.message); process.exit(1); });
|