/** * acr creds push [credentials.yaml] * * 讀取 credentials.yaml,以 ENCRYPTION_KEY 加密後 POST 至 cypher.arcrun.dev/credentials。 * Server 以 {api_key}:cred:{name} 為 KV key 存入 CREDENTIALS_KV(多租戶隔離)。 * * 不再需要用戶提供 CF API Token 或 KV Namespace ID。 */ import { readFileSync } from 'node:fs'; import yaml from 'js-yaml'; import chalk from 'chalk'; import ora from 'ora'; import { loadConfig, getCypherExecutorUrl } from '../lib/config.js'; async function encryptValue(value: string, encryptionKey: string): Promise<{ encrypted: string; iv: string }> { const keyBytes = hexToUint8Array(encryptionKey); const cryptoKey = await crypto.subtle.importKey( 'raw', keyBytes.buffer as ArrayBuffer, { name: 'AES-GCM' }, false, ['encrypt'], ); const ivBytes = crypto.getRandomValues(new Uint8Array(12)); const encoded = new TextEncoder().encode(value); const cipherBuffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: ivBytes }, cryptoKey, encoded); return { encrypted: Buffer.from(new Uint8Array(cipherBuffer)).toString('base64'), iv: Buffer.from(ivBytes).toString('base64'), }; } function hexToUint8Array(hex: string): Uint8Array { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); return bytes; } export async function cmdCredsPush(filePath: string): Promise { const config = loadConfig(); if (config.mode === 'local') { console.error(chalk.red('Local 模式不支援 acr creds push。')); console.log(chalk.gray('請先執行 acr init 設定 Standard 模式,取得 API Key。')); process.exit(1); } if (!config.api_key) { // self-hosted 用「資料分區標籤」(明碼,用戶在 .env 設 NAMESPACE)當 KV 前綴,非平台發的 api_key。 if (config.mode === 'self-hosted') { console.error(chalk.red('缺少 NAMESPACE(你的資料分區標籤)。')); console.log(chalk.gray('在專案 .env 設一行(明碼即可,這是分區標籤不是密碼):')); console.log(chalk.cyan(' NAMESPACE=leo')); console.log(chalk.gray('(要防外部呼叫請對 webhook 加保護;見 README「讓 AI 連到對的 arcrun」段)')); } else { console.error(chalk.red('缺少 api_key,請重新執行 acr init。')); } process.exit(1); } // 讀取 credentials.yaml let creds: Record; try { const raw = readFileSync(filePath, 'utf8'); creds = yaml.load(raw) as Record; } catch (e) { console.error(chalk.red(`無法讀取 ${filePath}:${e instanceof Error ? e.message : e}`)); process.exit(1); } const entries = Object.entries(creds).filter(([, v]) => typeof v === 'string' && v.length > 0); if (entries.length === 0) { console.log(chalk.yellow('credentials.yaml 中沒有有效的 credential(請取消注解並填入值)')); return; } // 加密金鑰:優先從 config 讀(含 .env 的 ENCRYPTION_KEY / ARCRUN_ENCRYPTION_KEY,見 config.ts loadDotEnvOnce), // 其次環境變數。self-hosted:你自己保管這把(工具不生成、不外傳),須與 worker 的 ENCRYPTION_KEY secret 一致。 const encryptionKey = config.encryption_key ?? process.env.ARCRUN_ENCRYPTION_KEY ?? ''; if (!encryptionKey || encryptionKey.length < 64) { if (config.mode === 'self-hosted') { console.error(chalk.red('缺少 encryption_key(或長度不足,需 ≥64 hex chars = 256-bit)。')); console.log(chalk.gray('在專案 .env 設(你自己保管,忘了就解不開已上傳的 credential):')); console.log(chalk.cyan(' ENCRYPTION_KEY=<64+ hex> # 產生:node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"')); console.log(chalk.gray('同一把也要設進 worker:wrangler secret put ENCRYPTION_KEY(見 acr init 提示)')); } else { console.error(chalk.red('缺少 encryption_key。請重新執行 acr init 取得設定。')); } process.exit(1); } const baseUrl = getCypherExecutorUrl(config); console.log(chalk.bold(`\n 上傳 ${entries.length} 個 credentials 至 ${baseUrl}\n`)); for (const [name, value] of entries) { const spinner = ora(` ${name}`).start(); try { const { encrypted, iv } = await encryptValue(String(value), encryptionKey); const res = await fetch(`${baseUrl}/credentials`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': config.api_key!, }, body: JSON.stringify({ name, encrypted, iv }), }); if (!res.ok) { const errBody = await res.text().catch(() => ''); throw new Error(`HTTP ${res.status}: ${errBody.slice(0, 200)}`); } spinner.succeed(chalk.green(` ✓ ${name}`)); } catch (e) { spinner.fail(chalk.red(` ✗ ${name} 失敗:${e instanceof Error ? e.message : e}`)); } } console.log(chalk.gray('\n Credential 已加密儲存。執行 workflow 時會自動注入,無需在 --input 手動帶 token。\n')); }