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:
@@ -22,6 +22,7 @@
|
|||||||
| `references/` | 規則正本(實作規範、註解格式對照表、可行性檢查清單) | 由流程正本指名讀取,不自行散落於 prompts |
|
| `references/` | 規則正本(實作規範、註解格式對照表、可行性檢查清單) | 由流程正本指名讀取,不自行散落於 prompts |
|
||||||
| `bin/tea-sdlc.js` | 指令入口:取走子指令,其餘 argv 原樣交出去 | 不含任何平台目錄知識,也不自己動手做事 |
|
| `bin/tea-sdlc.js` | 指令入口:取走子指令,其餘 argv 原樣交出去 | 不含任何平台目錄知識,也不自己動手做事 |
|
||||||
| `scripts/install.js` | 平台偵測與轉接檔產生/移除 | 唯一知道各平台目錄結構的地方 |
|
| `scripts/install.js` | 平台偵測與轉接檔產生/移除 | 唯一知道各平台目錄結構的地方 |
|
||||||
|
| `scripts/install-verify.js` | 安裝後走一遍叫用鏈(轉接檔 → PATH 上的 tea-sdlc → 流程正本) | 只認拿到的轉接檔路徑,不自己推導平台目錄;不碰網路 |
|
||||||
| `skills/` | 各助理原生 plugin 機制讀取的 skills | 目前為空;指令以轉接檔形式佈署 |
|
| `skills/` | 各助理原生 plugin 機制讀取的 skills | 目前為空;指令以轉接檔形式佈署 |
|
||||||
|
|
||||||
## 慣例
|
## 慣例
|
||||||
|
|||||||
@@ -102,6 +102,23 @@ tea-sdlc install --dry-run
|
|||||||
轉接檔裡**沒有路徑**,只有一句「執行 `tea-sdlc prompt --name sdlc-plan`」。正本在哪由 PATH 上的
|
轉接檔裡**沒有路徑**,只有一句「執行 `tea-sdlc prompt --name sdlc-plan`」。正本在哪由 PATH 上的
|
||||||
`tea-sdlc` 自己回推——升級 Node、換版本管理器、改 npm prefix 都不會讓七個平台的轉接檔同時失效。
|
`tea-sdlc` 自己回推——升級 Node、換版本管理器、改 npm prefix 都不會讓七個平台的轉接檔同時失效。
|
||||||
|
|
||||||
|
### 安裝完成等於驗過能用
|
||||||
|
|
||||||
|
寫完轉接檔之後,`install` 會把那條叫用鏈真的走一遍:**轉接檔 → PATH 上的 `tea-sdlc` → 流程正本**。
|
||||||
|
它實際到 PATH 上把 `tea-sdlc` 找出來執行一次、取回一份正本逐字比對,再逐一比對每個平台的轉接檔
|
||||||
|
存在且內容含正確的叫用行,結果放在輸出的 `verify` 裡,逐平台 pass/fail。**任一環不通,`install`
|
||||||
|
就回 `ok:false`。** 這段完全不碰網路,也與 Gitea 登入、時間追蹤無關。
|
||||||
|
|
||||||
|
最脆弱的是中間那一環:套件裝在某個 Node 版本底下,換個版本就找不到了,而轉接檔本身看起來完全正常——
|
||||||
|
沒有這道驗證,使用者要到第一次打 `/sdlc-plan` 才發現。
|
||||||
|
|
||||||
|
**驗不過也不會回滾**,已經寫好的轉接檔一份都不刪。回滾在升級情境下是淨損失:原本有一組能用的舊
|
||||||
|
轉接檔,覆蓋後驗證失敗再刪掉,就從「有點舊但能用」變成什麼都沒有;何況最可能的病灶是「PATH 上
|
||||||
|
找不到 `tea-sdlc`」,那不是轉接檔的問題。輸出的 `error.message` 會指出病灶與修復方式,修好之後
|
||||||
|
重跑 `tea-sdlc install` 就好。
|
||||||
|
|
||||||
|
`--dry-run` 不寫入任何轉接檔,也就沒有東西可驗,`verify` 會標成 `skipped` 並說明原因。
|
||||||
|
|
||||||
還沒裝 `git` 或 [`tea`](https://gitea.com/gitea/tea) 也可以先裝:轉接檔的產生不需要它們,
|
還沒裝 `git` 或 [`tea`](https://gitea.com/gitea/tea) 也可以先裝:轉接檔的產生不需要它們,
|
||||||
安裝會把缺的東西列在輸出的 `missingBinaries` 與 `warning` 裡,但不會替你安裝,也不會因此中止。
|
安裝會把缺的東西列在輸出的 `missingBinaries` 與 `warning` 裡,但不會替你安裝,也不會因此中止。
|
||||||
真正需要它們的是流程指令本身,跑之前補上即可。
|
真正需要它們的是流程指令本身,跑之前補上即可。
|
||||||
|
|||||||
@@ -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
@@ -22,6 +22,7 @@ import {
|
|||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { basename, dirname, join } from 'node:path';
|
import { basename, dirname, join } from 'node:path';
|
||||||
import {
|
import {
|
||||||
|
Failure,
|
||||||
ScriptError,
|
ScriptError,
|
||||||
checkPluginLayout,
|
checkPluginLayout,
|
||||||
missingBinaries,
|
missingBinaries,
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
parseFlags,
|
parseFlags,
|
||||||
promptsDir,
|
promptsDir,
|
||||||
} from './lib.js';
|
} from './lib.js';
|
||||||
|
import { invocation, verifyInstall, 診斷 } from './install-verify.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 七個平台。`detect` 是「這台機器裝了它沒有」的判準,`target` 是轉接檔的落點,
|
* 七個平台。`detect` 是「這台機器裝了它沒有」的判準,`target` 是轉接檔的落點,
|
||||||
@@ -102,28 +104,54 @@ export function runInstall(argv) {
|
|||||||
const version = packageVersion();
|
const version = packageVersion();
|
||||||
const chosen = choose(flags.platform);
|
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) => ({
|
const files = prompts.map((prompt) => ({
|
||||||
|
name: prompt.name,
|
||||||
path: adapterPath(platform, prompt.name),
|
path: adapterPath(platform, prompt.name),
|
||||||
text: adapterText(platform, prompt, version),
|
text: adapterText(platform, prompt, version),
|
||||||
}));
|
}));
|
||||||
if (!flags['dry-run']) {
|
if (!dryRun) {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
mkdirSync(dirname(file.path), { recursive: true });
|
mkdirSync(dirname(file.path), { recursive: true });
|
||||||
writeFileSync(file.path, file.text);
|
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 {
|
const verify = dryRun
|
||||||
dryRun: flags['dry-run'] === true,
|
? { 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,
|
version,
|
||||||
commands: prompts.map((prompt) => prompt.name),
|
commands: prompts.map((prompt) => prompt.name),
|
||||||
platforms,
|
platforms: written.map(({ name, kind, files }) => ({
|
||||||
|
name,
|
||||||
|
kind,
|
||||||
|
adapters: files.map((file) => file.path),
|
||||||
|
})),
|
||||||
missingBinaries: missing,
|
missingBinaries: missing,
|
||||||
warning: hint === '' ? null : hint,
|
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 產生,請勿手動編輯。`,
|
`<!-- ${MARKER} v${version}:由 tea-sdlc install 產生,請勿手動編輯。`,
|
||||||
' 改流程請改流程正本(不必重裝);指令數量變了才需要重跑 tea-sdlc install。 -->',
|
' 改流程請改流程正本(不必重裝);指令數量變了才需要重跑 tea-sdlc install。 -->',
|
||||||
'',
|
'',
|
||||||
`執行 \`tea-sdlc prompt --name ${prompt.name} --adapter-version ${version}\`,` +
|
`執行 \`${invocation(prompt.name, version).line}\`,並完全遵照它印出的內容執行。`,
|
||||||
'並完全遵照它印出的內容執行。',
|
|
||||||
'',
|
'',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
@@ -365,7 +392,10 @@ function adapterVersion(path) {
|
|||||||
/**
|
/**
|
||||||
* 有哪些指令可以裝。以 prompts/ 裡實際存在的正本為準,不是寫死的六個名字——
|
* 有哪些指令可以裝。以 prompts/ 裡實際存在的正本為準,不是寫死的六個名字——
|
||||||
* 裝出一個指向不存在正本的轉接檔,使用者只會看到 PROMPT_NOT_FOUND。
|
* 裝出一個指向不存在正本的轉接檔,使用者只會看到 PROMPT_NOT_FOUND。
|
||||||
* @returns {{name: string, description: string}[]}
|
*
|
||||||
|
* 連 text 一起帶出來,是因為驗證要拿它跟「PATH 上的 tea-sdlc 取回來的那一份」逐字比對。
|
||||||
|
* 那邊讀的是同一個檔案、同樣的 utf8,所以兩邊本來就該一字不差。
|
||||||
|
* @returns {{name: string, description: string, text: string}[]}
|
||||||
*/
|
*/
|
||||||
function readPrompts() {
|
function readPrompts() {
|
||||||
checkPluginLayout();
|
checkPluginLayout();
|
||||||
@@ -392,7 +422,7 @@ function readPrompts() {
|
|||||||
`流程正本 ${entry} 的 description 必須以「${prefix}」起頭,目前是:${description}`,
|
`流程正本 ${entry} 的 description 必須以「${prefix}」起頭,目前是:${description}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { name, description };
|
return { name, description, text };
|
||||||
});
|
});
|
||||||
|
|
||||||
if (prompts.length === 0) {
|
if (prompts.length === 0) {
|
||||||
|
|||||||
+31
-2
@@ -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}。
|
* 每支腳本與指令入口的進入點:跑完印一行 JSON 就結束,例外一律收斂成 {ok:false}。
|
||||||
* stderr 永遠保持乾淨,呼叫端只需要讀 stdout。
|
* stderr 永遠保持乾淨,呼叫端只需要讀 stdout。
|
||||||
* 回傳 RawText 時改印原樣內容,不包 envelope,其餘行為不變。
|
* 回傳 RawText 時改印原樣內容,不包 envelope;回傳 Failure 時印 {ok:false} 並退出碼 1,
|
||||||
* @param {() => Promise<object|RawText>|object|RawText} run 回傳要放進 data 的物件
|
* 但把 data 一起帶出去。其餘行為不變。
|
||||||
|
* @param {() => Promise<object|RawText|Failure>|object|RawText|Failure} run 回傳要放進 data 的物件
|
||||||
*/
|
*/
|
||||||
export async function main(run) {
|
export async function main(run) {
|
||||||
try {
|
try {
|
||||||
@@ -230,6 +254,11 @@ export async function main(run) {
|
|||||||
write(data.text, 0);
|
write(data.text, 0);
|
||||||
return;
|
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);
|
write(`${JSON.stringify({ ok: true, data })}\n`, 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code = error instanceof ScriptError ? error.code : 'UNEXPECTED';
|
const code = error instanceof ScriptError ? error.code : 'UNEXPECTED';
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* 過去,回推就自然指向假根,跑的仍是真正的程式碼;順便把「plugin 目錄不完整」
|
* 過去,回推就自然指向假根,跑的仍是真正的程式碼;順便把「plugin 目錄不完整」
|
||||||
* 那條路徑一起測得到——少給哪個目錄由測試自己決定。
|
* 那條路徑一起測得到——少給哪個目錄由測試自己決定。
|
||||||
*/
|
*/
|
||||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { repoRoot, runBin, tmpRoot } from './run-script.js';
|
import { repoRoot, runBin, tmpRoot } from './run-script.js';
|
||||||
|
|
||||||
@@ -20,7 +20,8 @@ export const fakePrompt = (name) =>
|
|||||||
* prompts 要放進 prompts/ 的正本,鍵為指令名;
|
* prompts 要放進 prompts/ 的正本,鍵為指令名;
|
||||||
* omit 故意不建立的目錄,用來造出「plugin 目錄不完整」;
|
* omit 故意不建立的目錄,用來造出「plugin 目錄不完整」;
|
||||||
* version 覆寫假根的套件版本
|
* version 覆寫假根的套件版本
|
||||||
* @returns {{root: string, run: (args: string[], opts?: object) => Promise<object>}}
|
* @returns {{root: string, shim: string, run: (args: string[], opts?: object) => Promise<object>}}
|
||||||
|
* shim 是放著這份假 plugin 的 tea-sdlc 的目錄,預設已經加進 run 的 PATH
|
||||||
*/
|
*/
|
||||||
export function makeFakePlugin(t, { prompts = {}, omit = [], version } = {}) {
|
export function makeFakePlugin(t, { prompts = {}, omit = [], version } = {}) {
|
||||||
mkdirSync(tmpRoot, { recursive: true });
|
mkdirSync(tmpRoot, { recursive: true });
|
||||||
@@ -44,5 +45,36 @@ export function makeFakePlugin(t, { prompts = {}, omit = [], version } = {}) {
|
|||||||
writeFileSync(join(root, 'prompts', `${name}.md`), text);
|
writeFileSync(join(root, 'prompts', `${name}.md`), text);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { root, run: (args, opts = {}) => runBin(args, { ...opts, root }) };
|
const shim = makeShim(root);
|
||||||
|
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
shim,
|
||||||
|
// 預設把這份假 plugin 的 tea-sdlc 放進 PATH:真實使用者是 npm i -g 裝的,
|
||||||
|
// 叫用鏈上本來就有這一環。要測「PATH 上找不到」的那條路徑就傳 shim: false。
|
||||||
|
run: (args, { shim: onPath = true, path, ...opts } = {}) =>
|
||||||
|
runBin(args, {
|
||||||
|
...opts,
|
||||||
|
root,
|
||||||
|
path: onPath ? [shim, path ?? process.env.PATH].join(':') : path,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 替一份假 plugin 根造出可以從 PATH 叫到的 `tea-sdlc`。
|
||||||
|
*
|
||||||
|
* install 的驗證會真的去 PATH 上把 tea-sdlc 找出來執行——那正是它要驗的那一環。
|
||||||
|
* 測試裡若沒有這個殼,驗到的就只是「測試環境沒有裝 tea-sdlc」,而不是待驗的東西。
|
||||||
|
* @param {string} root 假 plugin 根
|
||||||
|
* @returns {string} 殼所在的目錄,加進 PATH 就能叫到
|
||||||
|
*/
|
||||||
|
function makeShim(root) {
|
||||||
|
const dir = join(root, 'shim');
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
|
const path = join(dir, 'tea-sdlc');
|
||||||
|
writeFileSync(path, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(join(root, 'bin', 'tea-sdlc.js'))} "$@"\n`);
|
||||||
|
chmodSync(path, 0o755);
|
||||||
|
return dir;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
/**
|
||||||
|
* 安裝完成等於驗過能用:install 寫完轉接檔之後,真的把那條叫用鏈走一遍。
|
||||||
|
*
|
||||||
|
* 為什麼要驗這條鏈,見 scripts/install-verify.js 開頭。這裡只交代測法:一律在臨時家目錄上
|
||||||
|
* 真的寫檔、真的把 tea-sdlc 放上 PATH、真的執行它,再斷言結果。只驗「有沒有呼叫某個函式」
|
||||||
|
* 的話,正好驗不到唯一會壞的那一環。
|
||||||
|
*/
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { manifest, tmpRoot } from './helpers/run-script.js';
|
||||||
|
import { fakePrompt, makeFakePlugin } from './helpers/fake-plugin.js';
|
||||||
|
import { startStubGitea } from './helpers/stub-gitea.js';
|
||||||
|
|
||||||
|
const PROMPTS = { 'sdlc-plan': fakePrompt('sdlc-plan'), 'sdlc-feat': fakePrompt('sdlc-feat') };
|
||||||
|
|
||||||
|
/** 一個只「裝了」claude 與 kiro 的臨時家目錄 */
|
||||||
|
function makeHome(t) {
|
||||||
|
mkdirSync(tmpRoot, { recursive: true });
|
||||||
|
const home = mkdtempSync(join(tmpRoot, 'home-'));
|
||||||
|
t.after(() => rmSync(home, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
for (const dir of ['.claude', '.kiro', 'work']) mkdirSync(join(home, dir), { recursive: true });
|
||||||
|
return home;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inHome = (plugin, home) => (args, opts = {}) =>
|
||||||
|
plugin.run(args, { env: { HOME: home }, cwd: join(home, 'work'), ...opts });
|
||||||
|
|
||||||
|
/** 這次安裝實際寫出去的每一份轉接檔 */
|
||||||
|
const adaptersOf = (json) => json.data.platforms.flatMap((platform) => platform.adapters);
|
||||||
|
|
||||||
|
const byName = (verify) => Object.fromEntries(verify.platforms.map((p) => [p.name, p]));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把這份假 plugin 的 tea-sdlc 放上本行程的 PATH,測試結束後還原。
|
||||||
|
*
|
||||||
|
* 直接叫 verifyInstall 的測試才需要這個:它跟 install 不一樣,走的是本行程的 PATH。
|
||||||
|
* 叫用鏈那一環要是通的,那些測試的 fail 才只可能來自轉接檔。
|
||||||
|
*/
|
||||||
|
function 把tea_sdlc放上PATH(plugin, t) {
|
||||||
|
const 原本的 = process.env.PATH;
|
||||||
|
process.env.PATH = [plugin.shim, 原本的].join(':');
|
||||||
|
t.after(() => { process.env.PATH = 原本的; });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 組出 install 剛寫完轉接檔、正要交給驗證的那個樣子。純粹組資料,不碰環境。
|
||||||
|
* @param {string} home 臨時家目錄
|
||||||
|
* @param {object} plugin 假 plugin,取它的版本
|
||||||
|
*/
|
||||||
|
function 裝好的樣子(home, plugin) {
|
||||||
|
const version = JSON.parse(readFileSync(join(plugin.root, 'package.json'), 'utf8')).version;
|
||||||
|
const names = Object.keys(PROMPTS).sort();
|
||||||
|
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
prompt: { name: names[0], text: PROMPTS[names[0]] },
|
||||||
|
platforms: [
|
||||||
|
{
|
||||||
|
name: 'claude',
|
||||||
|
adapters: names.map((name) => ({ name, path: join(home, '.claude', 'commands', `${name}.md`) })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'kiro',
|
||||||
|
adapters: names.map((name) => ({ name, path: join(home, '.kiro', 'skills', name, 'SKILL.md') })),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── 全部通過 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('轉接檔寫完就驗一次真實的叫用鏈,逐平台回報 pass', async (t) => {
|
||||||
|
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||||
|
const home = makeHome(t);
|
||||||
|
|
||||||
|
const { code, json } = await inHome(plugin, home)(['install']);
|
||||||
|
|
||||||
|
assert.equal(code, 0);
|
||||||
|
assert.equal(json.ok, true);
|
||||||
|
assert.equal(json.data.verify.ok, true);
|
||||||
|
// 逐平台 pass/fail,而不是只有一個總結
|
||||||
|
assert.deepEqual(byName(json.data.verify).claude, { name: 'claude', ok: true, checked: 2, failures: [] });
|
||||||
|
assert.deepEqual(byName(json.data.verify).kiro, { name: 'kiro', ok: true, checked: 2, failures: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('驗證是真的把 PATH 上的 tea-sdlc 找出來執行,不是查有沒有這個檔', async (t) => {
|
||||||
|
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||||
|
const home = makeHome(t);
|
||||||
|
|
||||||
|
const { json } = await inHome(plugin, home)(['install']);
|
||||||
|
const { chain } = json.data.verify;
|
||||||
|
|
||||||
|
assert.equal(chain.ok, true);
|
||||||
|
assert.equal(chain.command, 'tea-sdlc');
|
||||||
|
assert.equal(chain.resolved, join(plugin.shim, 'tea-sdlc'));
|
||||||
|
assert.ok(json.data.commands.includes(chain.prompt), `取回的是 ${chain.prompt}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ── 中間那一環斷掉 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('PATH 上找不到 tea-sdlc 時整體 ok:false,並指出病灶在 PATH 而不是轉接檔', async (t) => {
|
||||||
|
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||||
|
const home = makeHome(t);
|
||||||
|
|
||||||
|
const { code, json } = await inHome(plugin, home)(['install'], { shim: false });
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.equal(json.ok, false);
|
||||||
|
assert.equal(json.data.verify.chain.ok, false);
|
||||||
|
assert.equal(json.data.verify.chain.resolved, null);
|
||||||
|
assert.match(json.data.verify.chain.病灶, /PATH/);
|
||||||
|
assert.match(json.data.verify.chain.修復, /npm/);
|
||||||
|
// 轉接檔本身沒有問題,刪掉它一點幫助也沒有——這裡要分得開
|
||||||
|
assert.equal(byName(json.data.verify).claude.ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PATH 上的 tea-sdlc 是另一份安裝時驗得出來——取回的正本跟這一份不一樣', async (t) => {
|
||||||
|
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||||
|
const 另一份 = makeFakePlugin(t, {
|
||||||
|
prompts: Object.fromEntries(
|
||||||
|
Object.entries(PROMPTS).map(([name, text]) => [name, `${text}\n舊版多出來的一段。\n`]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const home = makeHome(t);
|
||||||
|
|
||||||
|
const { code, json } = await inHome(plugin, home)(['install'], {
|
||||||
|
shim: false,
|
||||||
|
path: [另一份.shim, process.env.PATH].join(':'),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.equal(json.data.verify.chain.ok, false);
|
||||||
|
assert.equal(json.data.verify.chain.resolved, join(另一份.shim, 'tea-sdlc'));
|
||||||
|
assert.match(json.data.verify.chain.病灶, /正本/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('PATH 上的 tea-sdlc 叫得到卻跑不完時,把它的 stderr 當成病灶講出來,自己的 stderr 仍然乾淨', async (t) => {
|
||||||
|
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||||
|
const home = makeHome(t);
|
||||||
|
// 裝壞了的 tea-sdlc:叫得到、跑不完。輸出契約是「stderr 永遠乾淨,呼叫端只讀 stdout」,
|
||||||
|
// 所以子行程罵的話要被收進病灶裡,不能直接漏到我們的 stderr 上。
|
||||||
|
const 壞殼 = join(home, 'bin');
|
||||||
|
mkdirSync(壞殼, { recursive: true });
|
||||||
|
writeFileSync(join(壞殼, 'tea-sdlc'), '#!/bin/sh\necho "Cannot find module node_modules/x" >&2\nexit 1\n');
|
||||||
|
chmodSync(join(壞殼, 'tea-sdlc'), 0o755);
|
||||||
|
|
||||||
|
const { code, stderr, json } = await inHome(plugin, home)(['install'], {
|
||||||
|
shim: false,
|
||||||
|
path: [壞殼, process.env.PATH].join(':'),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.equal(stderr, '', '子行程的 stderr 漏出來了');
|
||||||
|
assert.equal(json.data.verify.chain.ok, false);
|
||||||
|
assert.match(json.data.verify.chain.病灶, /Cannot find module/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('PATH 上的 tea-sdlc 自己裝壞了時,病灶講的是它自己報的錯,不是空泛的 Command failed', async (t) => {
|
||||||
|
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||||
|
// tea-sdlc 的失敗一律是 stdout 上的一行 JSON(stderr 永遠乾淨),病灶要從那裡撈
|
||||||
|
const 裝壞的 = makeFakePlugin(t, { prompts: PROMPTS, omit: ['templates'] });
|
||||||
|
const home = makeHome(t);
|
||||||
|
|
||||||
|
const { code, json } = await inHome(plugin, home)(['install'], {
|
||||||
|
shim: false,
|
||||||
|
path: [裝壞的.shim, process.env.PATH].join(':'),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.equal(json.data.verify.chain.ok, false);
|
||||||
|
assert.match(json.data.verify.chain.病灶, /PLUGIN_LAYOUT_BROKEN/);
|
||||||
|
assert.equal(/Command failed/.test(json.data.verify.chain.病灶), false, '子行程自己說的話被丟掉了');
|
||||||
|
// 使用者一定會看到的地方也要講得出來
|
||||||
|
assert.match(json.error.message, /PLUGIN_LAYOUT_BROKEN/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ── 轉接檔那一環壞掉 ───────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// 這兩支直接叫 verifyInstall,因為 install 會先把轉接檔寫過一遍才驗——從 CLI 進去
|
||||||
|
// 沒有辦法讓它看到一份壞掉的轉接檔。驗的仍然是真的檔案與真的家目錄,只是少了寫入那一步。
|
||||||
|
|
||||||
|
test('轉接檔的叫用行不對時該平台 fail,其他平台照常 pass,整體 ok:false', async (t) => {
|
||||||
|
const { verifyInstall } = await import('../scripts/install-verify.js');
|
||||||
|
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||||
|
const home = makeHome(t);
|
||||||
|
await inHome(plugin, home)(['install']);
|
||||||
|
const 壞掉的 = join(home, '.claude', 'commands', 'sdlc-plan.md');
|
||||||
|
writeFileSync(壞掉的, '執行 `tea-sdlc prompt --name sdlc-plna`,並完全遵照它印出的內容執行。\n');
|
||||||
|
|
||||||
|
把tea_sdlc放上PATH(plugin, t);
|
||||||
|
const verify = verifyInstall(裝好的樣子(home, plugin));
|
||||||
|
|
||||||
|
assert.equal(verify.ok, false);
|
||||||
|
assert.equal(byName(verify).claude.ok, false);
|
||||||
|
assert.equal(byName(verify).kiro.ok, true);
|
||||||
|
assert.deepEqual(byName(verify).claude.failures.map((f) => f.path), [壞掉的]);
|
||||||
|
assert.match(byName(verify).claude.failures[0].病灶, /叫用行/);
|
||||||
|
assert.match(byName(verify).claude.failures[0].修復, /install/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('轉接檔不見了時該平台 fail,病灶講的是檔案不在', async (t) => {
|
||||||
|
const { verifyInstall } = await import('../scripts/install-verify.js');
|
||||||
|
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||||
|
const home = makeHome(t);
|
||||||
|
await inHome(plugin, home)(['install']);
|
||||||
|
const 不見的 = join(home, '.kiro', 'skills', 'sdlc-feat', 'SKILL.md');
|
||||||
|
rmSync(不見的);
|
||||||
|
|
||||||
|
把tea_sdlc放上PATH(plugin, t);
|
||||||
|
const verify = verifyInstall(裝好的樣子(home, plugin));
|
||||||
|
|
||||||
|
assert.equal(verify.ok, false);
|
||||||
|
assert.equal(byName(verify).kiro.ok, false);
|
||||||
|
assert.deepEqual(byName(verify).kiro.failures.map((f) => f.path), [不見的]);
|
||||||
|
assert.match(byName(verify).kiro.failures[0].病灶, /找不到/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('某個平台的轉接檔驗不過時,install 整體回 ok:false,data 照樣交出去', async (t) => {
|
||||||
|
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||||
|
const home = makeHome(t);
|
||||||
|
// 把其中一份轉接檔接到 /dev/null:install 照常寫得進去(不會中途炸掉),
|
||||||
|
// 但讀回來是空的——「寫出去了」與「檔案真的長那樣」不是同一件事,正是這道驗證的理由。
|
||||||
|
const 寫不進去的 = join(home, '.claude', 'commands', 'sdlc-plan.md');
|
||||||
|
mkdirSync(dirname(寫不進去的), { recursive: true });
|
||||||
|
symlinkSync('/dev/null', 寫不進去的);
|
||||||
|
|
||||||
|
const { code, json } = await inHome(plugin, home)(['install']);
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.equal(json.ok, false);
|
||||||
|
assert.equal(json.error.code, 'INSTALL_VERIFY_FAILED');
|
||||||
|
assert.equal(json.data.verify.chain.ok, true, '叫用鏈是通的,壞的只有這一份轉接檔');
|
||||||
|
assert.equal(byName(json.data.verify).claude.ok, false);
|
||||||
|
assert.equal(byName(json.data.verify).kiro.ok, true);
|
||||||
|
// 病灶與修復方式要出現在使用者一定會看到的地方
|
||||||
|
assert.match(json.error.message, /叫用行/);
|
||||||
|
assert.match(json.error.message, /重跑 tea-sdlc install/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ── 失敗不回滾 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('驗證失敗時已經寫好的轉接檔一份都不刪', async (t) => {
|
||||||
|
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||||
|
const home = makeHome(t);
|
||||||
|
|
||||||
|
// 病灶在 PATH,不在轉接檔:刪掉轉接檔只會讓使用者從「有點舊但能用」變成什麼都沒有
|
||||||
|
const { json } = await inHome(plugin, home)(['install'], { shim: false });
|
||||||
|
|
||||||
|
assert.equal(json.ok, false);
|
||||||
|
for (const path of adaptersOf(json)) {
|
||||||
|
assert.ok(existsSync(path), `${path} 被回滾掉了`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('升級情境:驗證失敗也不會把使用者原本能用的舊轉接檔弄不見', async (t) => {
|
||||||
|
const 舊版 = makeFakePlugin(t, { prompts: PROMPTS, version: '0.0.1' });
|
||||||
|
const home = makeHome(t);
|
||||||
|
await inHome(舊版, home)(['install']);
|
||||||
|
|
||||||
|
const 新版 = makeFakePlugin(t, { prompts: PROMPTS, version: '9.9.9' });
|
||||||
|
const { json } = await inHome(新版, home)(['install'], { shim: false });
|
||||||
|
|
||||||
|
assert.equal(json.ok, false);
|
||||||
|
const 轉接檔 = join(home, '.claude', 'commands', 'sdlc-plan.md');
|
||||||
|
assert.ok(existsSync(轉接檔));
|
||||||
|
assert.match(readFileSync(轉接檔, 'utf8'), /--adapter-version 9\.9\.9/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ── 不需要網路、不需要登入 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
test('整個驗證過程一個網路請求都不發', async (t) => {
|
||||||
|
// 取正本是讀套件內的檔案,比對轉接檔是讀本機目錄,兩件事都不該碰到 Gitea
|
||||||
|
const stub = await startStubGitea({});
|
||||||
|
t.after(() => stub.close());
|
||||||
|
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||||
|
const home = makeHome(t);
|
||||||
|
|
||||||
|
const { code } = await inHome(plugin, home)(['install'], {
|
||||||
|
env: { HOME: home, TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(code, 0);
|
||||||
|
assert.deepEqual(stub.requests, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ── 試跑 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('--dry-run 一個字都不寫,也不因為沒東西可驗就報失敗', async (t) => {
|
||||||
|
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||||
|
const home = makeHome(t);
|
||||||
|
|
||||||
|
const { code, json } = await inHome(plugin, home)(['install', '--dry-run']);
|
||||||
|
|
||||||
|
assert.equal(code, 0);
|
||||||
|
assert.equal(json.ok, true);
|
||||||
|
assert.equal(json.data.verify.skipped, true);
|
||||||
|
assert.match(json.data.verify.reason, /dry-run/);
|
||||||
|
assert.equal(existsSync(join(home, '.claude', 'commands')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ── 打包範圍 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('test/ 不在套件白名單裡:驗的是安裝結果,不是開發期的契約', () => {
|
||||||
|
assert.equal(manifest().files.some((entry) => entry.replace(/\/$/, '') === 'test'), false);
|
||||||
|
});
|
||||||
@@ -146,6 +146,18 @@ test('README 裡的 tea-sdlc 指令逐字拿去跑都認得,不會是寫給人
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('README 交代了安裝會自動驗證,以及驗不過時轉接檔不會被回滾', () => {
|
||||||
|
// 「驗不過但檔案還在」如果沒寫出來,使用者看到 ok:false 的第一個念頭會是自己去清乾淨重裝,
|
||||||
|
// 那正好是這個設計要避免的事
|
||||||
|
const text = readme();
|
||||||
|
const section = text.slice(text.indexOf('### 安裝完成等於驗過能用'), text.indexOf('## 更新 / 移除'));
|
||||||
|
|
||||||
|
assert.ok(section.length > 0, 'README 沒有交代安裝後的驗證');
|
||||||
|
assert.match(section, /ok:false/);
|
||||||
|
assert.match(section, /不.{0,4}回滾|一份都不刪/);
|
||||||
|
assert.match(section, /dry-run/);
|
||||||
|
});
|
||||||
|
|
||||||
test('模組邊界表指得到實際存在的檔案', () => {
|
test('模組邊界表指得到實際存在的檔案', () => {
|
||||||
const text = agents();
|
const text = agents();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user