/** * 安裝完成等於驗過能用。 * * install 寫完轉接檔之後,這裡把那條叫用鏈真的走一遍: * * 轉接檔 → PATH 上的 tea-sdlc → AI Agent CLI runtime registry * * PATH 上的 tea-sdlc 仍會取回流程正本;runtime verifier 再依平台的安全 probe * 確認六個流程都出現在正確的本機 registry。沒有安全 probe 的平台回報 not-supported。 * 最脆弱的是中間那一環。套件裝在某個 Node 版本底下,換個版本管理器或改 npm prefix * 就找不到了,而轉接檔本身看起來完全正常——使用者要到第一次打 /sdlc-plan 才發現, * 那時他已經離開安裝的心智狀態很久了。所以這裡不是「檢查檔案在不在」,而是真的到 * PATH 上把 tea-sdlc 找出來執行一次,再把取回的正本跟套件裡的那一份逐字比對: * 找不到、叫不動、或叫到的是另一份安裝,三種都驗得出來。 * * **完全不需要網路**:取正本是讀套件內的檔案,比對轉接檔是讀本機目錄。所以它無條件 * 執行,不受 Gitea 登入或時間追蹤狀態影響。 * * **失敗不回滾**,由呼叫端保留已經寫好的轉接檔。回滾在升級情境下是淨損失:使用者 * 原本有一組能用的舊轉接檔,覆蓋後驗證失敗,回滾把新的刪掉、舊的也已經沒了,他從 * 「有點舊但能用」變成什麼都沒有。而且最可能的病灶是「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'; /** 轉接檔叫的就是這個名字。它同時是要到 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 runtime = verifyRuntimePlatforms(platforms); const reports = platforms.map((platform, index) => verifyPlatform(platform, version, runtime[index])); 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} 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, 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, status: runtime.status, checked: platform.adapters.length, failures, runtime, }; } /** * @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; }