feat(install): validate agent CLI runtime registries #67
@@ -33,7 +33,7 @@ tea-sdlc/
|
|||||||
├── scripts/ # 所有 Gitea / git 副作用的唯一出口(Node,零相依)
|
├── scripts/ # 所有 Gitea / git 副作用的唯一出口(Node,零相依)
|
||||||
├── templates/ # 議題、PR、報表、總覽網頁的輸出模板
|
├── templates/ # 議題、PR、報表、總覽網頁的輸出模板
|
||||||
├── references/ # 規則正本:實作規範、註解格式、可行性檢查清單
|
├── references/ # 規則正本:實作規範、註解格式、可行性檢查清單
|
||||||
├── bin/tea-sdlc.js # 單一指令入口:install / uninstall / prompt / status
|
├── bin/tea-sdlc.js # 單一指令入口:install / uninstall / prompt / status / verify / sdlc-version
|
||||||
├── skills/ # 各助理原生 plugin 機制讀取的 skills(目前為空)
|
├── skills/ # 各助理原生 plugin 機制讀取的 skills(目前為空)
|
||||||
├── .claude-plugin/ # Claude Code 的 plugin / marketplace manifest
|
├── .claude-plugin/ # Claude Code 的 plugin / marketplace manifest
|
||||||
├── .codex-plugin/ # Codex 的 plugin manifest
|
├── .codex-plugin/ # Codex 的 plugin manifest
|
||||||
@@ -94,7 +94,7 @@ tea-sdlc install --dry-run
|
|||||||
| `claude`(Claude Code) | `~/.claude/` | `~/.claude/commands/sdlc-*.md` | command |
|
| `claude`(Claude Code) | `~/.claude/` | `~/.claude/commands/sdlc-*.md` | command |
|
||||||
| `codex`(Codex) | `~/.codex/` | `~/.codex/prompts/sdlc-*.md` | command |
|
| `codex`(Codex) | `~/.codex/` | `~/.codex/prompts/sdlc-*.md` | command |
|
||||||
| `opencode`(OpenCode) | `~/.config/opencode/` | `~/.config/opencode/command/sdlc-*.md` | command |
|
| `opencode`(OpenCode) | `~/.config/opencode/` | `~/.config/opencode/command/sdlc-*.md` | command |
|
||||||
| `oh-my-pi` | `~/.omp/` | `~/.omp/commands/sdlc-*.md` | command |
|
| `oh-my-pi` | `~/.omp/` | `~/.omp/agent/commands/sdlc-*.md` | command |
|
||||||
| `antigravity`(Antigravity) | `~/.gemini/` | `~/.gemini/skills/sdlc-*/SKILL.md` | skill |
|
| `antigravity`(Antigravity) | `~/.gemini/` | `~/.gemini/skills/sdlc-*/SKILL.md` | skill |
|
||||||
| `kiro`(Kiro) | `~/.kiro/` | `~/.kiro/skills/sdlc-*/SKILL.md` | skill |
|
| `kiro`(Kiro) | `~/.kiro/` | `~/.kiro/skills/sdlc-*/SKILL.md` | skill |
|
||||||
| `copilot`(GitHub Copilot) | 專案裡的 `.github/` | `.github/skills/sdlc-*/SKILL.md` | skill |
|
| `copilot`(GitHub Copilot) | 專案裡的 `.github/` | `.github/skills/sdlc-*/SKILL.md` | skill |
|
||||||
@@ -104,10 +104,13 @@ tea-sdlc install --dry-run
|
|||||||
|
|
||||||
### 安裝完成等於驗過能用
|
### 安裝完成等於驗過能用
|
||||||
|
|
||||||
寫完轉接檔之後,`install` 會把那條叫用鏈真的走一遍:**轉接檔 → PATH 上的 `tea-sdlc` → 流程正本**。
|
寫完轉接檔之後,`install` 會把三層叫用鏈驗一遍:
|
||||||
它實際到 PATH 上把 `tea-sdlc` 找出來執行一次、取回一份正本逐字比對,再逐一比對每個平台的轉接檔
|
**轉接檔 → PATH 上的 `tea-sdlc` → AI Agent CLI 的 runtime registry**。
|
||||||
存在且內容含正確的叫用行,結果放在輸出的 `verify` 裡,逐平台 pass/fail。**任一環不通,`install`
|
它會確認 PATH 上的 `tea-sdlc` 能取回正本,並針對 Claude、Codex、oh-my-pi 與 OpenCode
|
||||||
就回 `ok:false`。** 這段完全不碰網路,也與 Gitea 登入、時間追蹤無關。
|
檢查其本機 command registry 是否包含全部六個流程。沒有安全、非互動 registry probe 的平台
|
||||||
|
會明確回報 `not-supported`,不冒充驗證成功。結果放在輸出的 `verify.platforms[].runtime`:
|
||||||
|
`status` 為 `pass`、`fail` 或 `not-supported`,並列出 `resolved`、`commands`、`missing`、
|
||||||
|
`probe` 與 `remediation`。任何 `fail` 都讓 `install` 回 `ok:false`。
|
||||||
|
|
||||||
最脆弱的是中間那一環:套件裝在某個 Node 版本底下,換個版本就找不到了,而轉接檔本身看起來完全正常——
|
最脆弱的是中間那一環:套件裝在某個 Node 版本底下,換個版本就找不到了,而轉接檔本身看起來完全正常——
|
||||||
沒有這道驗證,使用者要到第一次打 `/sdlc-plan` 才發現。
|
沒有這道驗證,使用者要到第一次打 `/sdlc-plan` 才發現。
|
||||||
@@ -119,9 +122,15 @@ tea-sdlc install --dry-run
|
|||||||
|
|
||||||
`--dry-run` 不寫入任何轉接檔,也就沒有東西可驗,`verify` 會標成 `skipped` 並說明原因。
|
`--dry-run` 不寫入任何轉接檔,也就沒有東西可驗,`verify` 會標成 `skipped` 並說明原因。
|
||||||
|
|
||||||
還沒裝 `git` 或 [`tea`](https://gitea.com/gitea/tea) 也可以先裝:轉接檔的產生不需要它們,
|
安裝後可單獨重跑不寫檔的完整驗證:
|
||||||
安裝會把缺的東西列在輸出的 `missingBinaries` 與 `warning` 裡,但不會替你安裝,也不會因此中止。
|
|
||||||
真正需要它們的是流程指令本身,跑之前補上即可。
|
```bash
|
||||||
|
tea-sdlc verify
|
||||||
|
tea-sdlc verify --platform oh-my-pi
|
||||||
|
```
|
||||||
|
|
||||||
|
`tea-sdlc sdlc-version` 是無副作用的健康檢查,回報目前 `tea-sdlc` 的版本、PATH 實際
|
||||||
|
解析到的 executable,以及可用流程;它不執行 AI Agent,也不連網。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -135,6 +144,8 @@ tea-sdlc install --dry-run
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 更新到最新版 | `npm i -g git+https://gitea.jsc.idv.tw/plugins/tea-sdlc.git` |
|
| 更新到最新版 | `npm i -g git+https://gitea.jsc.idv.tw/plugins/tea-sdlc.git` |
|
||||||
| 指令數量變了之後重新佈署 | `tea-sdlc install` |
|
| 指令數量變了之後重新佈署 | `tea-sdlc install` |
|
||||||
|
| 重新驗證已部署的 AI Agent CLI | `tea-sdlc verify` |
|
||||||
|
| 查版本、PATH executable 與流程 | `tea-sdlc sdlc-version` |
|
||||||
| 查目前版本、正本位置與環境 | `tea-sdlc status` |
|
| 查目前版本、正本位置與環境 | `tea-sdlc status` |
|
||||||
| 只移除轉接檔(正本不動) | `tea-sdlc uninstall` |
|
| 只移除轉接檔(正本不動) | `tea-sdlc uninstall` |
|
||||||
| 連套件一起移除 | `tea-sdlc uninstall` → `npm rm -g tea-sdlc` |
|
| 連套件一起移除 | `tea-sdlc uninstall` → `npm rm -g tea-sdlc` |
|
||||||
|
|||||||
+4
-1
@@ -15,15 +15,18 @@
|
|||||||
*/
|
*/
|
||||||
import { ScriptError, main } from '../scripts/lib.js';
|
import { ScriptError, main } from '../scripts/lib.js';
|
||||||
import { runInstall, runUninstall } from '../scripts/install.js';
|
import { runInstall, runUninstall } from '../scripts/install.js';
|
||||||
|
import { runVersion } from '../scripts/version.js';
|
||||||
|
import { runVerify } from '../scripts/verify.js';
|
||||||
import { runPrompt } from '../scripts/prompt.js';
|
import { runPrompt } from '../scripts/prompt.js';
|
||||||
import { runStatus } from '../scripts/status.js';
|
import { runStatus } from '../scripts/status.js';
|
||||||
|
|
||||||
/** 子指令名 → 實作。名字就是使用者打的字,也是轉接檔裡寫的字。 */
|
|
||||||
const SUBCOMMANDS = {
|
const SUBCOMMANDS = {
|
||||||
install: runInstall,
|
install: runInstall,
|
||||||
uninstall: runUninstall,
|
uninstall: runUninstall,
|
||||||
prompt: runPrompt,
|
prompt: runPrompt,
|
||||||
status: runStatus,
|
status: runStatus,
|
||||||
|
'sdlc-version': runVersion,
|
||||||
|
verify: runVerify,
|
||||||
};
|
};
|
||||||
|
|
||||||
main(async () => {
|
main(async () => {
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
*
|
*
|
||||||
* install 寫完轉接檔之後,這裡把那條叫用鏈真的走一遍:
|
* 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
|
* 最脆弱的是中間那一環。套件裝在某個 Node 版本底下,換個版本管理器或改 npm prefix
|
||||||
* 就找不到了,而轉接檔本身看起來完全正常——使用者要到第一次打 /sdlc-plan 才發現,
|
* 就找不到了,而轉接檔本身看起來完全正常——使用者要到第一次打 /sdlc-plan 才發現,
|
||||||
* 那時他已經離開安裝的心智狀態很久了。所以這裡不是「檢查檔案在不在」,而是真的到
|
* 那時他已經離開安裝的心智狀態很久了。所以這裡不是「檢查檔案在不在」,而是真的到
|
||||||
@@ -19,6 +21,7 @@
|
|||||||
* 「有點舊但能用」變成什麼都沒有。而且最可能的病灶是「PATH 上找不到 tea-sdlc」,
|
* 「有點舊但能用」變成什麼都沒有。而且最可能的病灶是「PATH 上找不到 tea-sdlc」,
|
||||||
* 那不是轉接檔的問題,刪掉它一點幫助也沒有——所以報告把叫用鏈與轉接檔分開講。
|
* 那不是轉接檔的問題,刪掉它一點幫助也沒有——所以報告把叫用鏈與轉接檔分開講。
|
||||||
*/
|
*/
|
||||||
|
import { verifyRuntimePlatforms } from './runtime-verify.js';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
import { onPath } from './lib.js';
|
import { onPath } from './lib.js';
|
||||||
@@ -50,7 +53,8 @@ export function invocation(name, version) {
|
|||||||
*/
|
*/
|
||||||
export function verifyInstall({ version, prompt, platforms }) {
|
export function verifyInstall({ version, prompt, platforms }) {
|
||||||
const chain = verifyChain(prompt, version);
|
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 {
|
return {
|
||||||
ok: chain.ok && reports.every((report) => report.ok),
|
ok: chain.ok && reports.every((report) => report.ok),
|
||||||
@@ -169,12 +173,20 @@ export function 診斷(verify) {
|
|||||||
* 讀回磁碟上的內容而不是相信剛才寫出去的字串:這一步要驗的正是「寫出去之後檔案
|
* 讀回磁碟上的內容而不是相信剛才寫出去的字串:這一步要驗的正是「寫出去之後檔案
|
||||||
* 真的長那樣」,拿記憶體裡的原稿來比等於自己驗自己。
|
* 真的長那樣」,拿記憶體裡的原稿來比等於自己驗自己。
|
||||||
*/
|
*/
|
||||||
function verifyPlatform(platform, version) {
|
function verifyPlatform(platform, version, runtime) {
|
||||||
const failures = platform.adapters
|
const adapterFailures = platform.adapters
|
||||||
.map((adapter) => checkAdapter(adapter, version))
|
.map((adapter) => checkAdapter(adapter, version))
|
||||||
.filter((failure) => failure !== null);
|
.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: '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: '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: '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: '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: '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' },
|
{ 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,8 +9,7 @@ import assert from 'node:assert/strict';
|
|||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { repoRoot, manifest, runBin } from './helpers/run-script.js';
|
import { repoRoot, manifest, runBin } from './helpers/run-script.js';
|
||||||
|
|
||||||
/** 入口必須認得的四個名字,缺一個就有轉接檔或文件會指向不存在的子指令 */
|
const SUBCOMMANDS = ['install', 'uninstall', 'prompt', 'status', 'sdlc-version', 'verify'];
|
||||||
const SUBCOMMANDS = ['install', 'uninstall', 'prompt', 'status'];
|
|
||||||
|
|
||||||
|
|
||||||
// ── 子指令解析 ─────────────────────────────────────────────────────
|
// ── 子指令解析 ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -82,9 +82,16 @@ test('轉接檔寫完就驗一次真實的叫用鏈,逐平台回報 pass', asy
|
|||||||
assert.equal(code, 0);
|
assert.equal(code, 0);
|
||||||
assert.equal(json.ok, true);
|
assert.equal(json.ok, true);
|
||||||
assert.equal(json.data.verify.ok, true);
|
assert.equal(json.data.verify.ok, true);
|
||||||
// 逐平台 pass/fail,而不是只有一個總結
|
const claude = byName(json.data.verify).claude;
|
||||||
assert.deepEqual(byName(json.data.verify).claude, { name: 'claude', ok: true, checked: 2, failures: [] });
|
assert.equal(claude.name, 'claude');
|
||||||
assert.deepEqual(byName(json.data.verify).kiro, { name: 'kiro', ok: true, checked: 2, failures: [] });
|
assert.equal(claude.ok, true);
|
||||||
|
assert.equal(claude.status, 'pass');
|
||||||
|
assert.deepEqual(claude.runtime.commands, ['sdlc-feat', 'sdlc-plan']);
|
||||||
|
const kiro = byName(json.data.verify).kiro;
|
||||||
|
assert.equal(kiro.name, 'kiro');
|
||||||
|
assert.equal(kiro.ok, true);
|
||||||
|
assert.equal(kiro.status, 'not-supported');
|
||||||
|
assert.deepEqual(kiro.failures, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('驗證是真的把 PATH 上的 tea-sdlc 找出來執行,不是查有沒有這個檔', async (t) => {
|
test('驗證是真的把 PATH 上的 tea-sdlc 找出來執行,不是查有沒有這個檔', async (t) => {
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ test('支援 command 的四個平台產生 command 轉接檔,另外三個產
|
|||||||
assert.deepEqual(filesUnder(join(home, '.claude')), ['commands/sdlc-plan.md']);
|
assert.deepEqual(filesUnder(join(home, '.claude')), ['commands/sdlc-plan.md']);
|
||||||
assert.deepEqual(filesUnder(join(home, '.codex')), ['prompts/sdlc-plan.md']);
|
assert.deepEqual(filesUnder(join(home, '.codex')), ['prompts/sdlc-plan.md']);
|
||||||
assert.deepEqual(filesUnder(join(home, '.config', 'opencode')), ['command/sdlc-plan.md']);
|
assert.deepEqual(filesUnder(join(home, '.config', 'opencode')), ['command/sdlc-plan.md']);
|
||||||
assert.deepEqual(filesUnder(join(home, '.omp')), ['commands/sdlc-plan.md']);
|
assert.deepEqual(filesUnder(join(home, '.omp')), ['agent/commands/sdlc-plan.md']);
|
||||||
assert.deepEqual(filesUnder(join(home, '.gemini')), ['skills/sdlc-plan/SKILL.md']);
|
assert.deepEqual(filesUnder(join(home, '.gemini')), ['skills/sdlc-plan/SKILL.md']);
|
||||||
assert.deepEqual(filesUnder(join(home, '.kiro')), ['skills/sdlc-plan/SKILL.md']);
|
assert.deepEqual(filesUnder(join(home, '.kiro')), ['skills/sdlc-plan/SKILL.md']);
|
||||||
assert.deepEqual(filesUnder(join(home, 'work', '.github')), ['skills/sdlc-plan/SKILL.md']);
|
assert.deepEqual(filesUnder(join(home, 'work', '.github')), ['skills/sdlc-plan/SKILL.md']);
|
||||||
@@ -213,7 +213,7 @@ test('支援關閉自動觸發的 command 平台設上旗標;三個 skill 平
|
|||||||
join(home, '.claude', 'commands', 'sdlc-plan.md'),
|
join(home, '.claude', 'commands', 'sdlc-plan.md'),
|
||||||
join(home, '.codex', 'prompts', 'sdlc-plan.md'),
|
join(home, '.codex', 'prompts', 'sdlc-plan.md'),
|
||||||
join(home, '.config', 'opencode', 'command', 'sdlc-plan.md'),
|
join(home, '.config', 'opencode', 'command', 'sdlc-plan.md'),
|
||||||
join(home, '.omp', 'commands', 'sdlc-plan.md'),
|
join(home, '.omp', 'agent', 'commands', 'sdlc-plan.md'),
|
||||||
];
|
];
|
||||||
const skills = [
|
const skills = [
|
||||||
join(home, '.gemini', 'skills', 'sdlc-plan', 'SKILL.md'),
|
join(home, '.gemini', 'skills', 'sdlc-plan', 'SKILL.md'),
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fakePrompt, makeFakePlugin } from './helpers/fake-plugin.js';
|
||||||
|
import { tmpRoot } from './helpers/run-script.js';
|
||||||
|
|
||||||
|
const PROMPTS = {
|
||||||
|
'sdlc-analyze': fakePrompt('sdlc-analyze'),
|
||||||
|
'sdlc-plan': fakePrompt('sdlc-plan'),
|
||||||
|
};
|
||||||
|
|
||||||
|
function homeWith(t, platform) {
|
||||||
|
mkdirSync(tmpRoot, { recursive: true });
|
||||||
|
const home = join(tmpRoot, `runtime-home-${platform}`);
|
||||||
|
rmSync(home, { recursive: true, force: true });
|
||||||
|
mkdirSync(join(home, 'work'), { recursive: true });
|
||||||
|
const roots = {
|
||||||
|
'oh-my-pi': ['.omp'],
|
||||||
|
claude: ['.claude'],
|
||||||
|
codex: ['.codex'],
|
||||||
|
kiro: ['.kiro'],
|
||||||
|
};
|
||||||
|
mkdirSync(join(home, ...roots[platform]), { recursive: true });
|
||||||
|
t.after(() => rmSync(home, { recursive: true, force: true }));
|
||||||
|
return home;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runInHome = (plugin, home) => (args) =>
|
||||||
|
plugin.run(args, { env: { HOME: home }, cwd: join(home, 'work') });
|
||||||
|
|
||||||
|
test('sdlc-version 回報版本、實際 tea-sdlc executable 與六個流程', async (t) => {
|
||||||
|
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||||
|
const { json } = await plugin.run(['sdlc-version']);
|
||||||
|
|
||||||
|
assert.equal(json.ok, true);
|
||||||
|
assert.equal(json.data.name, 'tea-sdlc');
|
||||||
|
assert.equal(json.data.version, '0.0.1');
|
||||||
|
assert.equal(json.data.executable, join(plugin.shim, 'tea-sdlc'));
|
||||||
|
assert.deepEqual(json.data.commands, ['sdlc-analyze', 'sdlc-plan']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OMP 使用 ~/.omp/agent/commands 作為 runtime registry,六個 command 全部找到才 pass', async (t) => {
|
||||||
|
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||||
|
const home = homeWith(t, 'oh-my-pi');
|
||||||
|
const run = runInHome(plugin, home);
|
||||||
|
const installed = await run(['install']);
|
||||||
|
|
||||||
|
assert.equal(installed.code, 0);
|
||||||
|
assert.ok(existsSync(join(home, '.omp', 'agent', 'commands', 'sdlc-analyze.md')));
|
||||||
|
assert.equal(installed.json.data.verify.platforms[0].runtime.status, 'pass');
|
||||||
|
assert.deepEqual(installed.json.data.verify.platforms[0].runtime.commands, ['sdlc-analyze', 'sdlc-plan']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime registry 缺少 command 時 verify exit 1 並列出 missing', async (t) => {
|
||||||
|
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||||
|
const home = homeWith(t, 'claude');
|
||||||
|
const run = runInHome(plugin, home);
|
||||||
|
await run(['install']);
|
||||||
|
rmSync(join(home, '.claude', 'commands', 'sdlc-plan.md'));
|
||||||
|
|
||||||
|
const result = await run(['verify', '--platform', 'claude']);
|
||||||
|
assert.equal(result.code, 1);
|
||||||
|
assert.equal(result.json.error.code, 'RUNTIME_VERIFY_FAILED');
|
||||||
|
assert.deepEqual(result.json.data.platforms[0].runtime.missing, ['sdlc-plan']);
|
||||||
|
assert.equal(result.json.data.platforms[0].runtime.status, 'fail');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('沒有安全 registry probe 的平台回報 not-supported 而不阻止 install', async (t) => {
|
||||||
|
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||||
|
const home = homeWith(t, 'kiro');
|
||||||
|
const result = await runInHome(plugin, home)(['install']);
|
||||||
|
|
||||||
|
assert.equal(result.code, 0);
|
||||||
|
assert.equal(result.json.data.verify.platforms[0].runtime.status, 'not-supported');
|
||||||
|
assert.deepEqual(result.json.data.verify.platforms[0].runtime.failures, []);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user