feat(install): validate agent CLI runtime registries
This commit is contained in:
@@ -3,8 +3,10 @@
|
||||
*
|
||||
* install 寫完轉接檔之後,這裡把那條叫用鏈真的走一遍:
|
||||
*
|
||||
* 轉接檔 → PATH 上的 tea-sdlc → 流程正本
|
||||
* 轉接檔 → PATH 上的 tea-sdlc → AI Agent CLI runtime registry
|
||||
*
|
||||
* PATH 上的 tea-sdlc 仍會取回流程正本;runtime verifier 再依平台的安全 probe
|
||||
* 確認六個流程都出現在正確的本機 registry。沒有安全 probe 的平台回報 not-supported。
|
||||
* 最脆弱的是中間那一環。套件裝在某個 Node 版本底下,換個版本管理器或改 npm prefix
|
||||
* 就找不到了,而轉接檔本身看起來完全正常——使用者要到第一次打 /sdlc-plan 才發現,
|
||||
* 那時他已經離開安裝的心智狀態很久了。所以這裡不是「檢查檔案在不在」,而是真的到
|
||||
@@ -19,6 +21,7 @@
|
||||
* 「有點舊但能用」變成什麼都沒有。而且最可能的病灶是「PATH 上找不到 tea-sdlc」,
|
||||
* 那不是轉接檔的問題,刪掉它一點幫助也沒有——所以報告把叫用鏈與轉接檔分開講。
|
||||
*/
|
||||
import { verifyRuntimePlatforms } from './runtime-verify.js';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { onPath } from './lib.js';
|
||||
@@ -50,7 +53,8 @@ export function invocation(name, version) {
|
||||
*/
|
||||
export function verifyInstall({ version, prompt, platforms }) {
|
||||
const chain = verifyChain(prompt, version);
|
||||
const reports = platforms.map((platform) => verifyPlatform(platform, version));
|
||||
const runtime = verifyRuntimePlatforms(platforms);
|
||||
const reports = platforms.map((platform, index) => verifyPlatform(platform, version, runtime[index]));
|
||||
|
||||
return {
|
||||
ok: chain.ok && reports.every((report) => report.ok),
|
||||
@@ -169,12 +173,20 @@ export function 診斷(verify) {
|
||||
* 讀回磁碟上的內容而不是相信剛才寫出去的字串:這一步要驗的正是「寫出去之後檔案
|
||||
* 真的長那樣」,拿記憶體裡的原稿來比等於自己驗自己。
|
||||
*/
|
||||
function verifyPlatform(platform, version) {
|
||||
const failures = platform.adapters
|
||||
function verifyPlatform(platform, version, runtime) {
|
||||
const adapterFailures = platform.adapters
|
||||
.map((adapter) => checkAdapter(adapter, version))
|
||||
.filter((failure) => failure !== null);
|
||||
const failures = [...adapterFailures, ...runtime.failures];
|
||||
|
||||
return { name: platform.name, ok: failures.length === 0, checked: platform.adapters.length, failures };
|
||||
return {
|
||||
name: platform.name,
|
||||
ok: failures.length === 0,
|
||||
status: runtime.status,
|
||||
checked: platform.adapters.length,
|
||||
failures,
|
||||
runtime,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+42
-1
@@ -47,7 +47,7 @@ const PLATFORMS = [
|
||||
{ name: 'claude', label: 'Claude Code', base: 'home', detect: ['.claude'], target: ['.claude', 'commands'], kind: 'command' },
|
||||
{ name: 'codex', label: 'Codex', base: 'home', detect: ['.codex'], target: ['.codex', 'prompts'], kind: 'command' },
|
||||
{ name: 'opencode', label: 'OpenCode', base: 'home', detect: ['.config', 'opencode'], target: ['.config', 'opencode', 'command'], kind: 'command' },
|
||||
{ name: 'oh-my-pi', label: 'oh-my-pi', base: 'home', detect: ['.omp'], target: ['.omp', 'commands'], kind: 'command' },
|
||||
{ name: 'oh-my-pi', label: 'oh-my-pi', base: 'home', detect: ['.omp'], target: ['.omp', 'agent', 'commands'], kind: 'command' },
|
||||
{ name: 'antigravity', label: 'Antigravity', base: 'home', detect: ['.gemini'], target: ['.gemini', 'skills'], kind: 'skill' },
|
||||
{ name: 'kiro', label: 'Kiro', base: 'home', detect: ['.kiro'], target: ['.kiro', 'skills'], kind: 'skill' },
|
||||
{ name: 'copilot', label: 'GitHub Copilot', base: 'cwd', detect: ['.github'], target: ['.github', 'skills'], kind: 'skill' },
|
||||
@@ -220,7 +220,48 @@ export function platformReport() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 供 verify 使用的唯讀目標。它只看目前偵測到的平台,不進互動選擇,也不寫檔。
|
||||
* @param {string|undefined} flag --platform 的值
|
||||
* @returns {{name: string, adapters: {name: string, path: string}[]}[]}
|
||||
*/
|
||||
export function verificationTargets(flag) {
|
||||
const detected = PLATFORMS.filter((platform) => existsSync(pathOf(platform, platform.detect)));
|
||||
const chosen = flag === undefined
|
||||
? detected
|
||||
: (() => {
|
||||
const names = parsePlatformFlag(flag);
|
||||
const missing = names.filter((name) => !detected.some((platform) => platform.name === name));
|
||||
if (missing.length > 0) {
|
||||
throw new ScriptError(
|
||||
'PLATFORM_NOT_DETECTED',
|
||||
`這台機器上偵測不到 ${missing.join('、')};請先把該平台裝起來再重跑`,
|
||||
);
|
||||
}
|
||||
return PLATFORMS.filter((platform) => names.includes(platform.name));
|
||||
})();
|
||||
|
||||
if (chosen.length === 0) {
|
||||
throw new ScriptError(
|
||||
'NO_PLATFORM_DETECTED',
|
||||
`偵測不到任何 agent 平台(找過 ${PLATFORMS.map(detectLabel).join('、')});請先安裝其中至少一個`,
|
||||
);
|
||||
}
|
||||
|
||||
const names = readPrompts().map((prompt) => prompt.name);
|
||||
return chosen.map((platform) => ({
|
||||
name: platform.name,
|
||||
adapters: names.map((name) => ({ name, path: adapterPath(platform, name) })),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** @returns {{name: string, text: string}} */
|
||||
export function verificationPrompt() {
|
||||
const prompt = readPrompts()[0];
|
||||
return { name: prompt.name, text: prompt.text };
|
||||
}
|
||||
// ── 平台挑選 ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { onPath } from './lib.js';
|
||||
|
||||
/** Runtime probes are deliberately local, non-interactive, and network-free. */
|
||||
const SPECS = {
|
||||
'oh-my-pi': { binary: 'omp', supported: true, root: (home) => join(home, '.omp', 'agent', 'commands') },
|
||||
claude: { binary: 'claude', supported: true, root: (home) => join(home, '.claude', 'commands') },
|
||||
codex: { binary: 'codex', supported: true, root: (home) => join(home, '.codex', 'prompts') },
|
||||
opencode: { binary: 'opencode', supported: true, root: (home) => join(home, '.config', 'opencode', 'command') },
|
||||
antigravity: { binary: 'gemini', supported: false },
|
||||
kiro: { binary: 'kiro', supported: false },
|
||||
copilot: { binary: 'gh', supported: false },
|
||||
};
|
||||
|
||||
const PROBE_VERSION = 1;
|
||||
|
||||
/**
|
||||
* @param {{name: string, adapters: {name: string, path: string}[]}[]} platforms
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function verifyRuntimePlatforms(platforms) {
|
||||
return platforms.map((platform) => verifyRuntimePlatform(platform));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{name: string, adapters: {name: string, path: string}[]}} platform
|
||||
* @returns {object}
|
||||
*/
|
||||
function verifyRuntimePlatform(platform) {
|
||||
const spec = SPECS[platform.name];
|
||||
if (!spec || !spec.supported) {
|
||||
return {
|
||||
name: platform.name,
|
||||
status: 'not-supported',
|
||||
probe: { command: spec?.binary ?? null, args: [], network: false, interactive: false, version: PROBE_VERSION },
|
||||
resolved: spec ? onPath(spec.binary) : null,
|
||||
commands: [],
|
||||
missing: [],
|
||||
failures: [],
|
||||
remediation: '此 AI Agent CLI 沒有可供 tea-sdlc 安全呼叫的 command registry probe;請在該 CLI 內手動確認指令。',
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = onPath(spec.binary);
|
||||
const probe = {
|
||||
command: 'filesystem-registry',
|
||||
args: [spec.binary],
|
||||
network: false,
|
||||
interactive: false,
|
||||
version: PROBE_VERSION,
|
||||
};
|
||||
if (resolved === null) {
|
||||
return unsupportedRuntime(
|
||||
platform,
|
||||
probe,
|
||||
null,
|
||||
`找不到 ${spec.binary},沒有可安全執行的 runtime probe`,
|
||||
`請安裝 ${spec.binary} 並把它加入 PATH 後再重跑 tea-sdlc verify`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const root = dirname(platform.adapters[0]?.path ?? spec.root(process.env.HOME || homedir()));
|
||||
const expected = platform.adapters.map(({ name }) => name);
|
||||
const available = new Set(
|
||||
existsSync(root)
|
||||
? readdirSync(root, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
||||
.map((entry) => basename(entry.name, '.md'))
|
||||
: [],
|
||||
);
|
||||
const commands = expected.filter((name) => available.has(name));
|
||||
const missing = expected.filter((name) => !available.has(name));
|
||||
if (missing.length > 0) {
|
||||
return failedRuntime(
|
||||
platform,
|
||||
{ ...probe, registry: root },
|
||||
resolved,
|
||||
`runtime registry 找不到:${missing.join('、')}`,
|
||||
`確認 ${root} 是 ${spec.binary} 的 command registry,然後重跑 tea-sdlc install`,
|
||||
commands,
|
||||
missing,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name: platform.name,
|
||||
status: 'pass',
|
||||
probe: { ...probe, registry: root },
|
||||
resolved,
|
||||
commands,
|
||||
missing,
|
||||
failures: [],
|
||||
remediation: null,
|
||||
};
|
||||
}
|
||||
|
||||
function unsupportedRuntime(platform, probe, resolved, message, remediation) {
|
||||
return {
|
||||
name: platform.name,
|
||||
status: 'not-supported',
|
||||
probe,
|
||||
resolved,
|
||||
commands: [],
|
||||
missing: platform.adapters.map(({ name }) => name),
|
||||
failures: [],
|
||||
remediation: `${message};${remediation}`,
|
||||
};
|
||||
}
|
||||
|
||||
function failedRuntime(platform, probe, resolved, message, remediation, commands = [], missing = platform.adapters.map(({ name }) => name)) {
|
||||
const failure = {
|
||||
path: probe.registry ?? null,
|
||||
病灶: message,
|
||||
修復: remediation,
|
||||
};
|
||||
return {
|
||||
name: platform.name,
|
||||
status: 'fail',
|
||||
probe,
|
||||
resolved,
|
||||
commands,
|
||||
missing,
|
||||
failures: [failure],
|
||||
remediation,
|
||||
};
|
||||
}
|
||||
|
||||
/** @returns {number} */
|
||||
export function runtimeProbeVersion() {
|
||||
return PROBE_VERSION;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Failure, packageVersion, parseFlags } from './lib.js';
|
||||
import { verificationPrompt, verificationTargets } from './install.js';
|
||||
import { 診斷, verifyInstall } from './install-verify.js';
|
||||
|
||||
/**
|
||||
* 不寫檔的完整安裝/runtime 驗證;install 寫完轉接檔後也走同一條鏈。
|
||||
* @param {string[]} argv
|
||||
* @returns {object|Failure}
|
||||
*/
|
||||
export function runVerify(argv) {
|
||||
const flags = parseFlags(argv, { optional: ['platform'] });
|
||||
const verify = verifyInstall({
|
||||
version: packageVersion(),
|
||||
prompt: verificationPrompt(),
|
||||
platforms: verificationTargets(flags.platform),
|
||||
});
|
||||
return verify.ok ? verify : new Failure('RUNTIME_VERIFY_FAILED', 診斷(verify), verify);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { ScriptError, onPath, packageVersion, promptsDir } from './lib.js';
|
||||
|
||||
/**
|
||||
* 回報這一份 tea-sdlc 的版本、實際 executable 與可用流程;不連網、不改檔。
|
||||
* @param {string[]} argv
|
||||
* @returns {object}
|
||||
*/
|
||||
export function runVersion(argv) {
|
||||
if (argv.length > 0) {
|
||||
throw new ScriptError('UNKNOWN_FLAG', 'sdlc-version 不接受參數');
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'tea-sdlc',
|
||||
version: packageVersion(),
|
||||
executable: onPath('tea-sdlc'),
|
||||
commands: readdirSync(promptsDir())
|
||||
.filter((entry) => entry.endsWith('.md'))
|
||||
.map((entry) => entry.slice(0, -3))
|
||||
.sort(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user