Files
tea-sdlc/scripts/install-verify.js
T
JefferyandClaude Opus 5 73b9cd9f75 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>
2026-09-17 18:25:32 +08:00

200 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 安裝完成等於驗過能用。
*
* 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;
}