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
+199
View File
@@ -0,0 +1,199 @@
/**
* 安裝完成等於驗過能用。
*
* install 寫完轉接檔之後,這裡把那條叫用鏈真的走一遍:
*
* 轉接檔 → PATH 上的 tea-sdlc → 流程正本
*
* 最脆弱的是中間那一環。套件裝在某個 Node 版本底下,換個版本管理器或改 npm prefix
* 就找不到了,而轉接檔本身看起來完全正常——使用者要到第一次打 /sdlc-plan 才發現,
* 那時他已經離開安裝的心智狀態很久了。所以這裡不是「檢查檔案在不在」,而是真的到
* PATH 上把 tea-sdlc 找出來執行一次,再把取回的正本跟套件裡的那一份逐字比對:
* 找不到、叫不動、或叫到的是另一份安裝,三種都驗得出來。
*
* **完全不需要網路**:取正本是讀套件內的檔案,比對轉接檔是讀本機目錄。所以它無條件
* 執行,不受 Gitea 登入或時間追蹤狀態影響。
*
* **失敗不回滾**,由呼叫端保留已經寫好的轉接檔。回滾在升級情境下是淨損失:使用者
* 原本有一組能用的舊轉接檔,覆蓋後驗證失敗,回滾把新的刪掉、舊的也已經沒了,他從
* 「有點舊但能用」變成什麼都沒有。而且最可能的病灶是「PATH 上找不到 tea-sdlc」,
* 那不是轉接檔的問題,刪掉它一點幫助也沒有——所以報告把叫用鏈與轉接檔分開講。
*/
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { onPath } from './lib.js';
/** 轉接檔叫的就是這個名字。它同時是要到 PATH 上找的東西。 */
const COMMAND = 'tea-sdlc';
/**
* 轉接檔裡那一句叫用行——寫進去的跟等一下要驗的,是同一個函式算出來的。
* 分成兩份寫的話,改了格式只會讓驗證從此永遠 fail,或者更糟:永遠 pass。
* @param {string} name 指令名,例如 sdlc-plan
* @param {string} version 產生這份轉接檔的套件版本
* @returns {{command: string, args: string[], line: string}} line 是寫進轉接檔的字面
*/
export function invocation(name, version) {
const args = ['prompt', '--name', name, '--adapter-version', version];
return { command: COMMAND, args, line: `${COMMAND} ${args.join(' ')}` };
}
/**
* 走一遍叫用鏈,逐平台回報 pass/fail。
*
* @param {object} options
* @param {string} options.version 這次安裝的套件版本
* @param {{name: string, text: string}} options.prompt 要實際取回來比對的那一份正本
* @param {{name: string, adapters: {name: string, path: string}[]}[]} options.platforms
* 這次寫過的平台與它們的轉接檔
* @returns {{ok: boolean, chain: object, platforms: object[]}}
*/
export function verifyInstall({ version, prompt, platforms }) {
const chain = verifyChain(prompt, version);
const reports = platforms.map((platform) => verifyPlatform(platform, version));
return {
ok: chain.ok && reports.every((report) => report.ok),
chain,
platforms: reports,
};
}
/**
* 中間那一環:PATH 上真的有一個 tea-sdlc,叫得動,而且叫到的就是這一份套件。
*
* 比對內容而不是只看它有沒有回 exit 0——機器上裝了不只一份 tea-sdlc 時,
* 轉接檔叫到的會是 PATH 上排在前面的那一份,而它可能是舊版甚至別的專案。
* 那種情況下每一支指令都跑得起來,只是跑的不是使用者剛裝的東西。
*
* @param {{name: string, text: string}} prompt 套件裡的那一份正本,逐字比對用
* @param {string} version
*/
function verifyChain(prompt, version) {
const { command, args, line } = invocation(prompt.name, version);
const resolved = onPath(command);
const 報告 = { command, resolved, prompt: prompt.name, line };
if (resolved === null) {
return {
...報告,
ok: false,
病灶: `PATH 上找不到 ${command},所以轉接檔裡的「${line}」叫不動`,
修復:
`這不是轉接檔的問題,刪掉它沒有幫助。多半是套件裝在另一個 Node 版本底下:` +
`切回安裝時用的那個版本,或重跑 npm i -g(裝好後 \`command -v ${command}\` 要找得到)`,
};
}
let 取回;
try {
// stdio 要指名 pipe。不指名的話 execFileSync 預設會把子行程的 stderr 直接接到我們的
// stderr,破壞「stderr 永遠保持乾淨,呼叫端只需要讀 stdout」那條輸出契約——
// 而且我們要的正是把它收進 error.stderr 當成病灶講出來。
取回 = execFileSync(resolved, args, {
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'pipe'],
});
} catch (error) {
return {
...報告,
ok: false,
病灶: `${resolved} 叫得到但跑不完:${抱怨(error)}`,
修復: `直接跑一次 \`${line}\` 看完整訊息;裝壞了就重跑 npm i -g 把套件蓋回去`,
};
}
if (取回 !== prompt.text) {
return {
...報告,
ok: false,
病灶:
`${resolved} 取回的 ${prompt.name} 正本與這一份套件裡的不一樣,` +
'轉接檔叫到的是另一份 tea-sdlc',
修復:
`機器上裝了不只一份,或 PATH 指到舊的那一份:\`command -v ${command}\` 看它指到哪,` +
'把不要的那一份移除後重跑 tea-sdlc install',
};
}
return { ...報告, ok: true, 病灶: null, 修復: null };
}
/**
* 跑不完的子行程到底在抱怨什麼。
*
* 先看 stdout。tea-sdlc 自己的失敗一律是 stdout 上的一行 JSON(見 lib 的 main 與 write,
* 「stderr 永遠保持乾淨」),所以真正有用的 code 與 message 在那裡;只讀 stderr 的話,
* 病灶會退化成沒有資訊的「Command failed: …」,使用者還是不知道哪裡壞了。
*
* 讀不到就退回 stderr——那是「根本不是 tea-sdlc」的情況,例如同名的別的東西,
* 或殼底下的 node 不見了,那種東西的抱怨只會出現在 stderr。
* @param {Error} error execFileSync 丟出來的錯
* @returns {string} 給人看的一句話
*/
function 抱怨(error) {
try {
const { error: 內層 } = JSON.parse(String(error.stdout).trim().split('\n').at(-1));
if (內層?.message) return `${內層.code} ${內層.message}`;
} catch {
// stdout 不是我們的 JSON envelope,往下退
}
return (String(error.stderr ?? '').trim() || error.message || '').trim();
}
/**
* 把驗證結果收成一句話:病灶在哪、怎麼修。
*
* 報告的形狀由這裡產生,講法就留在同一個模組裡:install 只負責把這句話放進 envelope,
* 不必知道 chain 與 platforms 底下長什麼樣。只讀 error.message 的呼叫端(包括終端機前面
* 的使用者)光看這一句就該知道下一步做什麼。
* @param {ReturnType<typeof verifyInstall>} verify
* @returns {string}
*/
export function 診斷(verify) {
const 壞掉的 = [
...(verify.chain.ok ? [] : [verify.chain]),
...verify.platforms.flatMap((platform) => platform.failures),
];
return [
'轉接檔已經寫好,但驗不過——它們留著沒有刪,修好病灶之後重跑一次就好。',
...壞掉的.map((failure) => `${failure.病灶};${failure.修復}`),
].join('\n');
}
/**
* 一個平台的轉接檔:每一份都要在,而且內容要含正確的叫用行。
*
* 讀回磁碟上的內容而不是相信剛才寫出去的字串:這一步要驗的正是「寫出去之後檔案
* 真的長那樣」,拿記憶體裡的原稿來比等於自己驗自己。
*/
function verifyPlatform(platform, version) {
const failures = platform.adapters
.map((adapter) => checkAdapter(adapter, version))
.filter((failure) => failure !== null);
return { name: platform.name, ok: failures.length === 0, checked: platform.adapters.length, failures };
}
/**
* @returns {{path: string, 病灶: string, 修復: string}|null} 沒問題時回 null
*/
function checkAdapter({ name, path }, version) {
const 重裝 = '重跑 tea-sdlc install 把它蓋回去';
if (!existsSync(path)) {
return { path, 病灶: `找不到 ${name} 的轉接檔`, 修復: 重裝 };
}
const { line } = invocation(name, version);
if (!readFileSync(path, 'utf8').includes(line)) {
return {
path,
病灶: `轉接檔裡沒有正確的叫用行「${line}」,讀到它的助理不會知道要執行什麼`,
修復: `這份檔案被改過或是別的東西產生的;確認沒有自己要留的內容之後,${重裝}`,
};
}
return null;
}
+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) {
+31 -2
View File
@@ -217,11 +217,35 @@ export class RawText {
}
}
/**
* 失敗,但手上的東西還是要交出去。
*
* 丟 ScriptError 的失敗只剩 code 與 message,因為那種失敗通常是「什麼都還沒做」。
* 有一種失敗不是這樣:事情做完了、檔案也寫出去了,只是驗不過。那時使用者最需要
* 知道的正是「已經寫了哪些、哪一個平台不通」,把 data 丟掉等於逼他自己去翻。
*
* envelope 形狀不變,只是 {ok:false, error} 旁邊多一個 data:只讀 error.code 的
* 呼叫端照常運作。
*/
export class Failure {
/**
* @param {string} code 可區分的錯誤碼
* @param {string} message 給人看的訊息,要說得出病灶與修復方式
* @param {object} data 已經做完的部分,原樣放進 envelope
*/
constructor(code, message, data) {
this.code = code;
this.message = message;
this.data = data;
}
}
/**
* 每支腳本與指令入口的進入點:跑完印一行 JSON 就結束,例外一律收斂成 {ok:false}。
* stderr 永遠保持乾淨,呼叫端只需要讀 stdout。
* 回傳 RawText 時改印原樣內容,不包 envelope,其餘行為不變。
* @param {() => Promise<object|RawText>|object|RawText} run 回傳要放進 data 的物件
* 回傳 RawText 時改印原樣內容,不包 envelope;回傳 Failure 時印 {ok:false} 並退出碼 1,
* 但把 data 一起帶出去。其餘行為不變。
* @param {() => Promise<object|RawText|Failure>|object|RawText|Failure} run 回傳要放進 data 的物件
*/
export async function main(run) {
try {
@@ -230,6 +254,11 @@ export async function main(run) {
write(data.text, 0);
return;
}
if (data instanceof Failure) {
const { code, message } = data;
write(`${JSON.stringify({ ok: false, error: { code, message }, data: data.data })}\n`, 1);
return;
}
write(`${JSON.stringify({ ok: true, data })}\n`, 0);
} catch (error) {
const code = error instanceof ScriptError ? error.code : 'UNEXPECTED';