/** * acr creds push/list/replace/delete * * credential-store-migration T5(2026-07-03,Arcrun#2)已把 server 端寫入路徑從 * 「client AES-GCM 加密 + {name,encrypted,iv}」改為「明文值 + TLS 傳輸」(§2.4 選項甲: * arcrun 不再自管 ENCRYPTION_KEY,密文改由 CF Workers Secrets 託管)。T9(§3 治理端點) * 對應把 CLI 薄殼換成新的 list/replace/delete 三支指令:全部只做「讀 argv/yaml → 呼叫 * cypher-executor API → 印結果」,不做任何加解密或業務邏輯(rule 07 薄殼原則)。 * * 移除任何「印出 credential 值」的路徑(D19:擁有目錄,不擁有內容物,連 owner 都讀不回)。 */ import { readFileSync } from 'node:fs'; import yaml from 'js-yaml'; import chalk from 'chalk'; import ora from 'ora'; import { loadConfig, getCypherExecutorUrl, type ArcrunConfig } from '../lib/config.js'; function requireApiKey(config: ArcrunConfig): string { if (!config.api_key) { if (config.mode === 'self-hosted') { console.error(chalk.red('缺少 NAMESPACE(你的資料分區標籤)。')); console.log(chalk.gray('在專案 .env 設一行(明碼即可,這是分區標籤不是密碼):')); console.log(chalk.cyan(' NAMESPACE=leo')); } else { console.error(chalk.red('缺少 api_key,請重新執行 acr init。')); } process.exit(1); } return config.api_key; } interface CredentialRow { name: string; service: string | null; sensitivity: string; created_at: number; last_used_at: number | null; } /** acr creds list — 讀 D1 目錄顯示(不含值,D19:讀不回內容物)。 */ export async function cmdCredsList(): Promise { const config = loadConfig(); const apiKey = requireApiKey(config); const baseUrl = getCypherExecutorUrl(config); const res = await fetch(`${baseUrl}/credentials`, { headers: { 'X-Arcrun-API-Key': apiKey }, }); const body = (await res.json().catch(() => null)) as | { success?: boolean; credentials?: CredentialRow[]; error?: string } | null; if (!res.ok || !body?.success) { console.error(chalk.red(`讀取失敗:${body?.error ?? `HTTP ${res.status}`}`)); process.exit(1); } const rows = body.credentials ?? []; if (rows.length === 0) { console.log(chalk.gray('(尚無 credential)')); return; } console.log(chalk.bold(`\n ${rows.length} 個 credential(只顯示目錄,值不可讀回)\n`)); for (const r of rows) { const lastUsed = r.last_used_at ? new Date(r.last_used_at * 1000).toISOString() : '(從未使用)'; console.log(` ${chalk.cyan(r.name)} service=${r.service ?? '-'} sensitivity=${r.sensitivity} last_used=${lastUsed}`); } console.log(); } /** acr creds replace — 整筆覆寫(只能 replace,不能 edit 局部,D19/§3)。 */ export async function cmdCredsReplace( name: string, value: string, options: { service?: string; sensitivity?: string }, ): Promise { const config = loadConfig(); const apiKey = requireApiKey(config); const baseUrl = getCypherExecutorUrl(config); const spinner = ora(` 覆寫 ${name}`).start(); try { const res = await fetch(`${baseUrl}/credentials/${encodeURIComponent(name)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': apiKey }, body: JSON.stringify({ value, service: options.service, sensitivity: options.sensitivity }), }); const body = (await res.json().catch(() => null)) as { success?: boolean; error?: string } | null; if (!res.ok || !body?.success) { throw new Error(body?.error ?? `HTTP ${res.status}`); } spinner.succeed(chalk.green(` ✓ ${name} 已覆寫`)); } catch (e) { spinner.fail(chalk.red(` ✗ ${name} 失敗:${e instanceof Error ? e.message : e}`)); process.exit(1); } } /** acr creds delete — 刪除 credential(目錄 row + Workers Secret 本體)。 */ export async function cmdCredsDelete(name: string): Promise { const config = loadConfig(); const apiKey = requireApiKey(config); const baseUrl = getCypherExecutorUrl(config); const spinner = ora(` 刪除 ${name}`).start(); try { const res = await fetch(`${baseUrl}/credentials/${encodeURIComponent(name)}`, { method: 'DELETE', headers: { 'X-Arcrun-API-Key': apiKey }, }); const body = (await res.json().catch(() => null)) as { success?: boolean; error?: string } | null; if (!res.ok || !body?.success) { throw new Error(body?.error ?? `HTTP ${res.status}`); } spinner.succeed(chalk.green(` ✓ ${name} 已刪除`)); } catch (e) { spinner.fail(chalk.red(` ✗ ${name} 失敗:${e instanceof Error ? e.message : e}`)); process.exit(1); } } /** * acr creds push [credentials.yaml] —(保留 bulk 匯入的既有慣用法,內部改走新的明文 PUT * 路徑,不再 client 端 AES-GCM 加密——舊實作對應的 server 格式已被 T5 取代,直接沿用會 400)。 */ export async function cmdCredsPush(filePath: string): Promise { const config = loadConfig(); const apiKey = requireApiKey(config); 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; } 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 res = await fetch(`${baseUrl}/credentials`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': apiKey }, body: JSON.stringify({ name, value: String(value) }), }); 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 已存進 Workers Secrets(cypher 唯寫,讀不回值)。執行 workflow 時會自動注入。\n')); }