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
+35 -3
View File
@@ -6,7 +6,7 @@
* 過去,回推就自然指向假根,跑的仍是真正的程式碼;順便把「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 { repoRoot, runBin, tmpRoot } from './run-script.js';
@@ -20,7 +20,8 @@ export const fakePrompt = (name) =>
* prompts 要放進 prompts/ 的正本,鍵為指令名;
* omit 故意不建立的目錄,用來造出「plugin 目錄不完整」;
* 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 } = {}) {
mkdirSync(tmpRoot, { recursive: true });
@@ -44,5 +45,36 @@ export function makeFakePlugin(t, { prompts = {}, omit = [], version } = {}) {
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;
}
+318
View File
@@ -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);
});
+12
View File
@@ -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('模組邊界表指得到實際存在的檔案', () => {
const text = agents();