feat(onboarding+kbdb): 8.P0 cron 止血 + §7.8 onboarding + .env.example 範本
kbdb-base 8.P0:scheduled.ts cron 每分鐘 KV list → 單一 key get(lib/cron-index.ts); webhooks-named 維護單 key + 一次性 migrate-cron-index;acr update 自動遷移。1440 list/日 → 0。 self-hosted-init §7.8 onboarding: P0 init 偵測+裝完驗收(lib/preflight.ts,pip 式,冪等) P1 acr whoami(+--json)+ MCP arcrun_whoami(AI 不繞 CLI 猜帳號) P2 mcp-setup 寫完印「請重啟 client」 P3(部分)repo .env.example 範本(每格白話說明、值留空)+ llms.txt 教 AI 幫用戶 cp 建 .env Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -12,12 +12,12 @@ import { CfAccountClient } from '../lib/cf-api.js';
|
||||
import {
|
||||
REQUIRED_KV_NAMESPACES,
|
||||
SECRET_TARGET_WORKERS,
|
||||
wranglerAvailable,
|
||||
downloadAndDeploy,
|
||||
type DeployContext,
|
||||
} from '../lib/deploy.js';
|
||||
import { cmdInstallHarness } from './install-harness.js';
|
||||
import { cmdMcpSetup } from './mcp-setup.js';
|
||||
import { detectEnvironment, printPreflight, verifyInstall } from '../lib/preflight.js';
|
||||
|
||||
const ARCRUN_REGISTER_URL = 'https://cypher.arcrun.dev/register';
|
||||
|
||||
@@ -164,10 +164,13 @@ async function initSelfHosted(
|
||||
console.log(chalk.gray(' Self-hosted 模式:自動部署整套 arcrun 到你的 Cloudflare 帳號\n'));
|
||||
console.log(chalk.gray(' 你只需提供 CF Account ID + API Token,其餘 CLI 自動完成。\n'));
|
||||
|
||||
// 前置:wrangler(CF CLI)
|
||||
if (!wranglerAvailable()) {
|
||||
console.log(chalk.yellow(' ✗ 找不到 wrangler(Cloudflare CLI)。'));
|
||||
console.log(chalk.yellow(' 請先安裝:npm i -g wrangler,然後重新執行 acr init --self-hosted\n'));
|
||||
// §7.8 P0:偵測先於動作(pip 式)——先看環境有什麼,缺前置就停下給補救指令,
|
||||
// 不假設齊備直接動手(test_arcrun/4:D1 缺了 AI 跑去讀原始碼自己想辦法的災難)。
|
||||
const pre = detectEnvironment();
|
||||
printPreflight('環境偵測(安裝前)', pre.items);
|
||||
if (pre.fatal) {
|
||||
console.log(chalk.yellow('\n ✗ 缺少必要前置(見上方 →)。補齊後重新執行 acr init --self-hosted。'));
|
||||
console.log(chalk.gray(' init 冪等:補好重跑,已就緒的會自動跳過。\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -279,6 +282,20 @@ async function initSelfHosted(
|
||||
await callSeedEndpoint(cypherUrl);
|
||||
}
|
||||
|
||||
// §7.8 P0 裝完驗收:實查 CF(KV/D1)+ 打 cypher /health 確認真就緒,缺哪項明確報哪項
|
||||
// + 給一鍵補裝指令(不靜默印灰字)。假綠零容忍(mindset §7):看實際狀態,非看 config 寫了沒。
|
||||
const verify = await verifyInstall({
|
||||
cf,
|
||||
requiredKv: REQUIRED_KV_NAMESPACES,
|
||||
expectD1Name: d1DatabaseId ? 'arcrun-kbdb' : undefined,
|
||||
cypherUrl,
|
||||
});
|
||||
printPreflight('安裝驗收(裝完檢查)', verify.items);
|
||||
if (!verify.allOk) {
|
||||
console.log(chalk.yellow('\n ⚠ 部分項目未就緒(見上方 →)。多數可跑 acr update 冪等補裝。'));
|
||||
console.log(chalk.gray(' (worker 剛部署可能需數十秒生效,可稍候再跑 acr update 重驗。)'));
|
||||
}
|
||||
|
||||
// 結果回報(誠實:部分失敗時明說,不假綠 — mindset §7)
|
||||
console.log(chalk.green(`\n ✓ Cloudflare 資源就緒(${REQUIRED_KV_NAMESPACES.length} KV,免費額度即可,無需綁卡)`));
|
||||
console.log(chalk.green(' ✓ 設定寫入 ~/.arcrun/config.yaml'));
|
||||
|
||||
@@ -53,5 +53,9 @@ export function cmdMcpSetup(): void {
|
||||
if (mcpUrl === DEFAULT_MCP_URL && (!config.mcp_url || config.mcp_url.trim() === '')) {
|
||||
console.log(chalk.gray(` (用平台預設;要連自己/客戶的 MCP,在 config 設 mcp_url 或 ARCRUN_MCP_URL env,再重跑)`));
|
||||
}
|
||||
console.log(chalk.gray(` Claude Code 進此資料夾會自動連這台 MCP。切帳號 = 在對應資料夾重跑 acr mcp-setup。\n`));
|
||||
console.log(chalk.gray(` Claude Code 進此資料夾會自動連這台 MCP。切帳號 = 在對應資料夾重跑 acr mcp-setup。`));
|
||||
// §7.8 P2 D3:project scope MCP 寫進 .mcp.json 後**不會即時生效**,要重啟 IDE/client 才載入。
|
||||
// 不提示 → 用戶開了 MCP 工具卻發現用不了,以為壞了(D3 撞牆)。明說「請重啟」引導,不讓人誤判。
|
||||
console.log(chalk.yellow(`\n ⚠ 請重啟 IDE / Claude Code client,project scope MCP 才會載入生效。`));
|
||||
console.log(chalk.gray(` (重啟後若仍未出現 arcrun 工具,確認該 client 已「信任此工作區」。)\n`));
|
||||
}
|
||||
|
||||
@@ -84,6 +84,20 @@ export async function cmdUpdate(): Promise<void> {
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(` ⚠ seed 失敗(${e instanceof Error ? e.message : e})`));
|
||||
}
|
||||
|
||||
// kbdb-base 8.P0:一次性把舊的 per-key cron-idx:{apiKey}:{name} 折進單一 cron-idx:_all。
|
||||
// 部署 8.P0 後既有 cron workflow 若不重 push 會停擺(scheduled 只讀新集中 key)→ 這裡冪等補上。
|
||||
// 冪等、不刪舊 key、失敗不致命(重跑 acr update 會再試)。
|
||||
process.stdout.write(chalk.gray(' → 遷移 cron index(舊 per-key → 集中 key,冪等)...'));
|
||||
try {
|
||||
const res = await fetch(`${cypherUrl}/webhooks/named/migrate-cron-index`, { method: 'POST' });
|
||||
const body = await res.json().catch(() => null) as { success?: boolean; migrated?: number; skipped?: number } | null;
|
||||
console.log(res.ok && body?.success
|
||||
? chalk.green(` ✓ migrated ${body.migrated ?? 0}, skipped ${body.skipped ?? 0}`)
|
||||
: chalk.yellow(` ⚠ HTTP ${res.status}(可重跑 acr update)`));
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(` ⚠ cron index 遷移失敗(${e instanceof Error ? e.message : e})`));
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* acr whoami — 一眼看「我現在是誰、連哪台、帳號從哪層來」(§7.8 P1,D2 修法)。
|
||||
*
|
||||
* D2 根因(self-hosted-init.md §7.8):config 分層**已實作**(config.ts),但 AI 不用 CLI 讀帳號、
|
||||
* 自己 curl 猜全域帳號 URL → 打到錯帳號。治本不是再改 config,是給 AI 一個無腦入口:
|
||||
* 「問 CLI 拿正確身份,別自己 curl 猜」。與 MCP arcrun_whoami 對齊(薄殼一致,rule 07 §5)。
|
||||
*
|
||||
* 與 acr config 區別:config 印完整欄位表(人用、含敏感欄位遮罩);whoami 印精煉摘要
|
||||
* (mode / 連哪台 cypher / 帳號來源層),AI 讀完就知道該用哪個帳號、打哪個 URL,不必再猜。
|
||||
*
|
||||
* --json:給 AI/MCP 結構化讀取(薄殼一致)。
|
||||
*/
|
||||
import chalk from 'chalk';
|
||||
import {
|
||||
loadConfig,
|
||||
resolveConfigSources,
|
||||
getCypherExecutorUrl,
|
||||
getMcpUrl,
|
||||
activeProjectConfigPath,
|
||||
type ConfigSource,
|
||||
} from '../lib/config.js';
|
||||
|
||||
const SOURCE_LABEL: Record<ConfigSource, string> = {
|
||||
env: 'env 變數',
|
||||
project: '專案層 .arcrun.yaml',
|
||||
global: '全域 ~/.arcrun/config.yaml',
|
||||
default: '預設值',
|
||||
};
|
||||
|
||||
/** 帳號身份欄位(self-hosted=api_key 即 NAMESPACE 分區標籤;standard=平台 api_key)。*/
|
||||
function identitySource(): ConfigSource {
|
||||
const row = resolveConfigSources().find((r) => r.field === 'api_key');
|
||||
return row?.source ?? 'default';
|
||||
}
|
||||
|
||||
function maskKey(v?: string): string {
|
||||
if (!v) return '(未設)';
|
||||
return v.length > 8 ? `${v.slice(0, 8)}…` : v;
|
||||
}
|
||||
|
||||
export async function cmdWhoami(options: { json?: boolean }): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const cypherUrl = getCypherExecutorUrl(config);
|
||||
const mcpUrl = getMcpUrl(config);
|
||||
const accountSource = identitySource();
|
||||
const projectPath = activeProjectConfigPath();
|
||||
|
||||
if (options.json) {
|
||||
// 結構化輸出(AI/MCP 對齊用)。敏感值不全印。
|
||||
console.log(JSON.stringify({
|
||||
mode: config.mode,
|
||||
account: maskKey(config.api_key),
|
||||
account_source: accountSource,
|
||||
cypher_executor_url: cypherUrl,
|
||||
mcp_url: mcpUrl,
|
||||
cloudflare_account_id: config.cloudflare_account_id ?? null,
|
||||
project_config: projectPath ?? null,
|
||||
}, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold('\n arcrun — 目前身份\n'));
|
||||
console.log(` ${chalk.cyan('模式')} ${config.mode}`);
|
||||
console.log(` ${chalk.cyan('帳號')} ${maskKey(config.api_key)} ${chalk.gray(`← ${SOURCE_LABEL[accountSource]}`)}`);
|
||||
console.log(` ${chalk.cyan('連哪台')} ${cypherUrl}`);
|
||||
console.log(` ${chalk.cyan('MCP')} ${mcpUrl}`);
|
||||
if (config.mode === 'self-hosted' && config.cloudflare_account_id) {
|
||||
console.log(` ${chalk.cyan('CF 帳號')} ${config.cloudflare_account_id}`);
|
||||
}
|
||||
console.log('');
|
||||
if (projectPath) {
|
||||
console.log(chalk.gray(` 此資料夾用專案層設定:${projectPath}`));
|
||||
} else {
|
||||
console.log(chalk.gray(' 此資料夾用全域設定(無 .arcrun.yaml)'));
|
||||
}
|
||||
console.log(chalk.gray(' → 部署/觸發前用這個帳號,別自己 curl 全域 URL 猜帳號。\n'));
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { cmdInit } from './commands/init.js';
|
||||
import { cmdConfig } from './commands/config.js';
|
||||
import { cmdWhoami } from './commands/whoami.js';
|
||||
import { cmdCredsPush } from './commands/creds.js';
|
||||
import { cmdPush } from './commands/push.js';
|
||||
import { cmdRun } from './commands/run.js';
|
||||
@@ -62,6 +63,13 @@ program
|
||||
.option('--where', '顯示每個設定值來自哪一層(env > 專案層 .arcrun.yaml > 全域)')
|
||||
.action((options: { where?: boolean }) => cmdConfig(options));
|
||||
|
||||
// acr whoami [--json]:一眼看當前身份(mode / 連哪台 cypher / 帳號來源層)。§7.8 P1 D2 修法。
|
||||
program
|
||||
.command('whoami')
|
||||
.description('顯示目前生效的身份(帳號、連哪台 cypher、來源層)——AI 別自己 curl 猜帳號')
|
||||
.option('--json', '結構化輸出(給 AI / 腳本讀取)')
|
||||
.action((options: { json?: boolean }) => cmdWhoami(options));
|
||||
|
||||
// acr creds push [credentials.yaml]
|
||||
const credsCmd = program.command('creds').description('Credential 管理');
|
||||
credsCmd
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* preflight.ts — self-hosted 安裝的「偵測先於動作 + 裝完驗收」(§7.8 P0,pip 式)。
|
||||
*
|
||||
* 核心判準(self-hosted-init.md §7.8):
|
||||
* - **偵測先於動作**:init 先檢查各前置(node / wrangler / CF 可達),缺的才裝、有的跳過。
|
||||
* 不是假設齊備直接動手 → 缺一個就卡(test_arcrun/4 的 D1 大跑去讀原始碼自己想辦法)。
|
||||
* - **裝完驗收**:部署後逐項確認(KV / D1 / migration / cypher 可達),缺哪項明確報哪項
|
||||
* + 給一鍵補裝指令。不是靜默印灰字(原本 harness/MCP 失敗只 console.log 灰字,用戶不知道)。
|
||||
* - **冪等**:重跑檢查後「什麼也沒動」(ensureKvNamespace / ensureD1Database 本就冪等)。
|
||||
*
|
||||
* 本檔只做「偵測 + 報告」,不自己建資源(建資源仍走 cf-api 的 ensure*,由 init 編排)。
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import chalk from 'chalk';
|
||||
import type { CfAccountClient } from './cf-api.js';
|
||||
|
||||
export interface PreflightItem {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
detail?: string;
|
||||
/** 缺漏時給用戶的一鍵補救指令(沒有則留空)。*/
|
||||
fix?: string;
|
||||
}
|
||||
|
||||
/** node 是否可用 + 版本(init 本身是 node 跑的,能跑到這裡 node 必在,但仍印版本供診斷)。*/
|
||||
function detectNode(): PreflightItem {
|
||||
try {
|
||||
const v = execFileSync('node', ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] })
|
||||
.toString().trim();
|
||||
return { name: 'node', ok: true, detail: v };
|
||||
} catch {
|
||||
return { name: 'node', ok: false, fix: '安裝 Node.js 18+:https://nodejs.org' };
|
||||
}
|
||||
}
|
||||
|
||||
/** wrangler(CF CLI)是否可用 + 版本。self-hosted 部署的硬前置。*/
|
||||
function detectWrangler(): PreflightItem {
|
||||
try {
|
||||
const v = execFileSync('wrangler', ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] })
|
||||
.toString().trim();
|
||||
return { name: 'wrangler', ok: true, detail: v };
|
||||
} catch {
|
||||
return { name: 'wrangler', ok: false, fix: 'npm i -g wrangler' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安裝前偵測(pip 式:先看環境有什麼)。
|
||||
* CF 憑證可達由呼叫端用 CfAccountClient.verifyAccess 接著驗(需要 token,不在這層)。
|
||||
* 回傳所有項目 + 是否有 fatal 缺漏(node/wrangler 缺 = 無法繼續)。
|
||||
*/
|
||||
export function detectEnvironment(): { items: PreflightItem[]; fatal: boolean } {
|
||||
const items = [detectNode(), detectWrangler()];
|
||||
const fatal = items.some((i) => !i.ok);
|
||||
return { items, fatal };
|
||||
}
|
||||
|
||||
/** 印一組偵測結果(✓/✗ + 版本 + 補救指令)。*/
|
||||
export function printPreflight(title: string, items: PreflightItem[]): void {
|
||||
console.log(chalk.bold(`\n ${title}`));
|
||||
for (const it of items) {
|
||||
if (it.ok) {
|
||||
console.log(chalk.green(` ✓ ${it.name}`) + (it.detail ? chalk.gray(` ${it.detail}`) : ''));
|
||||
} else {
|
||||
console.log(chalk.yellow(` ✗ ${it.name}`) + (it.detail ? chalk.gray(` ${it.detail}`) : ''));
|
||||
if (it.fix) console.log(chalk.gray(` → ${it.fix}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 裝完驗收:逐項確認 self-hosted 環境真的就緒(§7.8 D1 根因:安裝不偵測,缺了不報)。
|
||||
* 各項以「實際查 CF / 打 cypher」確認,非看 config 有沒有寫——避免假綠(mindset §7)。
|
||||
*
|
||||
* @returns items(每項 ok + detail/fix)。呼叫端依 allOk 決定是否 exit 非零 / 印補裝指引。
|
||||
*/
|
||||
export async function verifyInstall(opts: {
|
||||
cf: CfAccountClient;
|
||||
requiredKv: readonly string[];
|
||||
expectD1Name?: string;
|
||||
cypherUrl?: string;
|
||||
}): Promise<{ items: PreflightItem[]; allOk: boolean }> {
|
||||
const items: PreflightItem[] = [];
|
||||
|
||||
// KV:實查 CF 上現有 namespace,比對必需清單
|
||||
try {
|
||||
const existing = await opts.cf.listKvNamespaces();
|
||||
const have = new Set(existing.keys());
|
||||
const missing = opts.requiredKv.filter((t) => !have.has(t));
|
||||
items.push(
|
||||
missing.length === 0
|
||||
? { name: `KV namespaces (${opts.requiredKv.length})`, ok: true }
|
||||
: { name: 'KV namespaces', ok: false, detail: `缺 ${missing.join(', ')}`, fix: 'acr update(冪等重建)' },
|
||||
);
|
||||
} catch (e) {
|
||||
items.push({ name: 'KV namespaces', ok: false, detail: msg(e), fix: 'acr update' });
|
||||
}
|
||||
|
||||
// D1:實查 CF 上是否有該庫
|
||||
if (opts.expectD1Name) {
|
||||
try {
|
||||
const dbs = await opts.cf.listD1Databases();
|
||||
items.push(
|
||||
dbs.has(opts.expectD1Name)
|
||||
? { name: `D1 ${opts.expectD1Name}`, ok: true }
|
||||
: { name: `D1 ${opts.expectD1Name}`, ok: false, detail: '不存在', fix: 'acr update(冪等重建 + 套 migration)' },
|
||||
);
|
||||
} catch (e) {
|
||||
items.push({ name: `D1 ${opts.expectD1Name}`, ok: false, detail: msg(e), fix: 'acr update' });
|
||||
}
|
||||
}
|
||||
|
||||
// cypher-executor 可達(打 /health,不只看 config 有 URL)
|
||||
if (opts.cypherUrl) {
|
||||
try {
|
||||
const res = await fetch(`${opts.cypherUrl}/health`, { method: 'GET' });
|
||||
items.push(
|
||||
res.ok
|
||||
? { name: 'cypher-executor 可達', ok: true, detail: opts.cypherUrl }
|
||||
: { name: 'cypher-executor 可達', ok: false, detail: `HTTP ${res.status} @ ${opts.cypherUrl}`, fix: 'acr update(重部署)' },
|
||||
);
|
||||
} catch (e) {
|
||||
items.push({ name: 'cypher-executor 可達', ok: false, detail: msg(e), fix: 'acr update(重部署);或 worker 剛部署稍候再試' });
|
||||
}
|
||||
}
|
||||
|
||||
return { items, allOk: items.every((i) => i.ok) };
|
||||
}
|
||||
|
||||
function msg(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
Reference in New Issue
Block a user