feat(install): 安裝完成等於驗過能用

install 寫完轉接檔後,把叫用鏈真的走一遍:轉接檔 → PATH 上的 tea-sdlc → 流程正本。

最脆弱的是中間那一環。套件裝在某個 Node 版本底下,換個版本就找不到了,而轉接檔本身
看起來完全正常——沒有這道驗證,使用者要到第一次打 /sdlc-plan 才發現,那時他已經離開
安裝的心智狀態很久了。所以不是查檔案在不在,而是真的到 PATH 上把 tea-sdlc 找出來執行
一次,再把取回的正本跟套件裡的那一份逐字比對:找不到、叫不動、或叫到的是另一份安裝,
三種都驗得出來。轉接檔則逐一回磁碟讀,比對存在且內容含正確的叫用行。

驗證不碰網路,也與 Gitea 登入、時間追蹤無關,所以無條件執行。

驗不過回 ok:false,但已經寫好的轉接檔一份都不刪。回滾在升級情境下是淨損失:原本有一組
能用的舊轉接檔,覆蓋後驗證失敗再刪掉,使用者就從「有點舊但能用」變成什麼都沒有;何況
最可能的病灶是「PATH 上找不到 tea-sdlc」,那不是轉接檔的問題。

為此 lib 多一個 Failure:有一種失敗是事情做完了、檔案也寫出去了,只是驗不過,那時最該
交出去的正是「已經寫了哪些、哪一段不通」。envelope 形狀不變,只是 {ok:false, error}
旁邊多一個 data,只讀 error.code 的呼叫端照常運作。

--dry-run 不寫入,也就沒有東西可驗,verify 標成 skipped。

Closes #59

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-09-17 18:25:32 +08:00
co-authored by Claude Opus 5
parent c2ca7fbf07
commit 73b9cd9f75
8 changed files with 653 additions and 15 deletions
+40 -10
View File
@@ -22,6 +22,7 @@ import {
import { homedir } from 'node:os';
import { basename, dirname, join } from 'node:path';
import {
Failure,
ScriptError,
checkPluginLayout,
missingBinaries,
@@ -29,6 +30,7 @@ import {
parseFlags,
promptsDir,
} from './lib.js';
import { invocation, verifyInstall, 診斷 } from './install-verify.js';
/**
* 七個平台。`detect` 是「這台機器裝了它沒有」的判準,`target` 是轉接檔的落點,
@@ -102,28 +104,54 @@ export function runInstall(argv) {
const version = packageVersion();
const chosen = choose(flags.platform);
const platforms = chosen.map((platform) => {
const dryRun = flags['dry-run'] === true;
const written = chosen.map((platform) => {
const files = prompts.map((prompt) => ({
name: prompt.name,
path: adapterPath(platform, prompt.name),
text: adapterText(platform, prompt, version),
}));
if (!flags['dry-run']) {
if (!dryRun) {
for (const file of files) {
mkdirSync(dirname(file.path), { recursive: true });
writeFileSync(file.path, file.text);
}
}
return { name: platform.name, kind: platform.kind, adapters: files.map((file) => file.path) };
return { name: platform.name, kind: platform.kind, files };
});
return {
dryRun: flags['dry-run'] === true,
const verify = dryRun
? { skipped: true, reason: '--dry-run 沒有寫入任何轉接檔,沒有東西可以驗' }
: verifyInstall({
version,
// 取一份就夠了:要驗的是這條鏈通不通,不是每一份正本的內容
prompt: { name: prompts[0].name, text: prompts[0].text },
// 刻意只交出 name 與 path,不交 text:驗證要驗的正是「寫出去之後檔案真的長那樣」,
// 把剛才那份原稿也遞過去,它就有機會拿記憶體裡的字串來比,等於自己驗自己
platforms: written.map(({ name, files }) => ({
name,
adapters: files.map(({ name: 指令, path }) => ({ name: 指令, path })),
})),
});
const data = {
dryRun,
version,
commands: prompts.map((prompt) => prompt.name),
platforms,
platforms: written.map(({ name, kind, files }) => ({
name,
kind,
adapters: files.map((file) => file.path),
})),
missingBinaries: missing,
warning: hint === '' ? null : hint,
verify,
};
// 驗不過就回失敗,但轉接檔一份都不刪:見 install-verify 開頭對「失敗不回滾」的交代。
// data 照樣交出去,使用者才看得到已經寫了哪些、以及是哪一段不通。
return verify.skipped || verify.ok ? data : new Failure('INSTALL_VERIFY_FAILED', 診斷(verify), data);
}
@@ -331,8 +359,7 @@ function adapterText(platform, prompt, version) {
`<!-- ${MARKER} v${version}:由 tea-sdlc install 產生,請勿手動編輯。`,
' 改流程請改流程正本(不必重裝);指令數量變了才需要重跑 tea-sdlc install。 -->',
'',
`執行 \`tea-sdlc prompt --name ${prompt.name} --adapter-version ${version}\`,` +
'並完全遵照它印出的內容執行。',
`執行 \`${invocation(prompt.name, version).line}\`,並完全遵照它印出的內容執行。`,
'',
].join('\n');
}
@@ -365,7 +392,10 @@ function adapterVersion(path) {
/**
* 有哪些指令可以裝。以 prompts/ 裡實際存在的正本為準,不是寫死的六個名字——
* 裝出一個指向不存在正本的轉接檔,使用者只會看到 PROMPT_NOT_FOUND。
* @returns {{name: string, description: string}[]}
*
* 連 text 一起帶出來,是因為驗證要拿它跟「PATH 上的 tea-sdlc 取回來的那一份」逐字比對。
* 那邊讀的是同一個檔案、同樣的 utf8,所以兩邊本來就該一字不差。
* @returns {{name: string, description: string, text: string}[]}
*/
function readPrompts() {
checkPluginLayout();
@@ -392,7 +422,7 @@ function readPrompts() {
`流程正本 ${entry} 的 description 必須以「${prefix}」起頭,目前是:${description}`,
);
}
return { name, description };
return { name, description, text };
});
if (prompts.length === 0) {