Files
tea-sdlc/test/install.test.js
T
jiantw83andClaude Opus 5 17178f0c4d feat(佈署): 以 npm 裝出 tea-sdlc 指令並產生各平台轉接檔
單一入口 bin/tea-sdlc.js 認四個子指令。第一個位置參數是子指令,其餘 argv 原樣
交出去——既有的 flag 解析拒絕位置參數,所以子指令必須在那之前就被取走。

轉接檔裡沒有路徑,只有一句 tea-sdlc prompt --name <指令名>。正本在哪由 PATH 上
的 tea-sdlc 自己回推:fnm 把 Node 版號寫進全域安裝路徑,寫死路徑的話升一次
Node,七個平台的轉接檔會同時指向不存在的檔案,而且不會有任何錯誤訊息。

prompt 是全專案唯一輸出非 JSON 的路徑,理由只有一個:它的輸出要餵給模型讀。
失敗仍走 envelope——成功是內容,失敗才需要結構。

status 的 ok 不兼差表達環境好壞,健康與否放在 data.healthy:呼叫端要分得出
「status 掛了」與「status 成功查到你環境有問題」。

install 只寫進偵測得到的平台;缺 git/tea 只警告不中止,因為那兩個完全不影響
轉接檔產生,硬擋等於逼使用者為了裝 plugin 先去裝 tea。uninstall 只刪帶產生標記
的檔案,使用者自己寫的同名檔案一律留著並在輸出裡交代。裝哪些指令以 prompts/ 裡
實際存在的正本為準,不是寫死的六個名字——裝出指向不存在正本的轉接檔,使用者只會
看到 PROMPT_NOT_FOUND。

流程正本的 description 前綴在抄進轉接檔之前就檢查:有三個平台關不掉自動觸發,
全靠那句話把 description 窄到不會被誤判,不能等使用者發現誤觸才知道漏了。

議題 #26 #27 #28 #29 #17

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 06:59:37 +00:00

413 lines
17 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.
/**
* 轉接檔的產生與移除。
*
* 全部在臨時家目錄上跑:真的寫檔、真的刪檔,然後斷言家目錄裡剩下什麼。
* 只驗「函式有沒有被呼叫」不會發現多建了一層目錄或少刪了一個檔,而那正是這支腳本
* 唯一會出的錯。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { pathWithOnly, tmpRoot } from './helpers/run-script.js';
import { fakePrompt, makeFakePlugin } from './helpers/fake-plugin.js';
/** 七個平台的偵測目錄,相對於家目錄;copilot 那個是相對於工作目錄 */
const DETECT = {
claude: '.claude',
codex: '.codex',
opencode: '.config/opencode',
'oh-my-pi': '.omp',
antigravity: '.gemini',
kiro: '.kiro',
};
const PROMPTS = { 'sdlc-plan': fakePrompt('sdlc-plan'), 'sdlc-feat': fakePrompt('sdlc-feat') };
/**
* 一個臨時家目錄,只「裝了」指定的平台。
* @param {string[]} platforms 要建出偵測目錄的平台名;'copilot' 建在工作目錄底下
*/
function makeHome(t, platforms = []) {
mkdirSync(tmpRoot, { recursive: true });
const home = mkdtempSync(join(tmpRoot, 'home-'));
t.after(() => rmSync(home, { recursive: true, force: true }));
for (const name of platforms) {
const dir = name === 'copilot' ? join(home, 'work', '.github') : join(home, DETECT[name]);
mkdirSync(dir, { recursive: true });
}
mkdirSync(join(home, 'work'), { recursive: true });
return home;
}
/** 從臨時家目錄執行入口:HOME 指過去,cwd 指到它底下的 work/(copilot 的 .github 在那) */
const inHome = (plugin, home) => (args) =>
plugin.run(args, { env: { HOME: home }, cwd: join(home, 'work') });
/** 列出家目錄底下所有檔案的相對路徑 */
function filesUnder(dir, prefix = '') {
const out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) out.push(...filesUnder(join(dir, entry.name), rel));
else out.push(rel);
}
return out;
}
// ── 偵測 ───────────────────────────────────────────────────────────
test('只裝到偵測得到的平台,沒裝的平台不留下孤兒目錄', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude', 'kiro']);
const { code, json } = await inHome(plugin, home)(['install']);
assert.equal(code, 0);
assert.deepEqual(json.data.platforms.map((p) => p.name).sort(), ['claude', 'kiro']);
assert.ok(existsSync(join(home, '.claude', 'commands', 'sdlc-plan.md')));
assert.equal(existsSync(join(home, '.codex')), false);
assert.equal(existsSync(join(home, '.config')), false);
});
test('一個平台都沒偵測到時明講,而不是回報裝了零個當作成功', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, []);
const { code, json } = await inHome(plugin, home)(['install']);
assert.equal(code, 1);
assert.equal(json.error.code, 'NO_PLATFORM_DETECTED');
});
test('status 的 platforms 欄位報得出偵測結果、轉接檔數與過時狀態', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
await inHome(plugin, home)(['install']);
const { json } = await inHome(plugin, home)(['status']);
const byName = Object.fromEntries(json.data.platforms.map((p) => [p.name, p]));
assert.equal(byName.claude.detected, true);
assert.equal(byName.claude.dir, join(home, '.claude', 'commands'));
assert.deepEqual(byName.claude.adapters, { expected: 2, present: 2, stale: false });
assert.equal(byName.codex.detected, false);
assert.deepEqual(byName.codex.adapters, { expected: 2, present: 0, stale: false });
});
test('少裝了指令時 expected 與 present 對不起來,看得出來還有東西沒佈署', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
await inHome(plugin, home)(['install']);
rmSync(join(home, '.claude', 'commands', 'sdlc-feat.md'));
const { json } = await inHome(plugin, home)(['status']);
const claude = json.data.platforms.find((p) => p.name === 'claude');
assert.equal(claude.adapters.expected, 2);
assert.equal(claude.adapters.present, 1);
});
test('轉接檔版本與套件不符時,status 把該平台標成過時', async (t) => {
const old = makeFakePlugin(t, { prompts: PROMPTS, version: '0.0.1' });
const home = makeHome(t, ['claude']);
await inHome(old, home)(['install']);
const next = makeFakePlugin(t, { prompts: PROMPTS, version: '0.2.0' });
const { json } = await inHome(next, home)(['status']);
const claude = json.data.platforms.find((p) => p.name === 'claude');
assert.equal(claude.adapters.stale, true);
assert.equal(claude.adapters.present, 2);
});
// ── 指定安裝對象 ───────────────────────────────────────────────────
test('--platform a,b 可非互動指定安裝對象', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude', 'kiro', 'codex']);
const { json } = await inHome(plugin, home)(['install', '--platform', 'claude,codex']);
assert.deepEqual(json.data.platforms.map((p) => p.name), ['claude', 'codex']);
assert.equal(existsSync(join(home, '.kiro', 'skills')), false);
});
test('--platform 給了不存在的平台名時列出可用的名字', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
const { json } = await inHome(plugin, home)(['install', '--platform', 'vscode']);
assert.equal(json.error.code, 'UNKNOWN_PLATFORM');
assert.match(json.error.message, /vscode/);
assert.match(json.error.message, /claude/);
});
test('--platform 指名一個沒裝的平台時擋下,不替它建目錄', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
const { json } = await inHome(plugin, home)(['install', '--platform', 'kiro']);
assert.equal(json.error.code, 'PLATFORM_NOT_DETECTED');
assert.equal(existsSync(join(home, '.kiro')), false);
});
// ── 轉接檔內容 ─────────────────────────────────────────────────────
test('支援 command 的四個平台產生 command 轉接檔,另外三個產生 SKILL.md', async (t) => {
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': fakePrompt('sdlc-plan') } });
const home = makeHome(t, ['claude', 'codex', 'opencode', 'oh-my-pi', 'antigravity', 'kiro', 'copilot']);
const { json } = await inHome(plugin, home)(['install']);
assert.equal(json.data.platforms.length, 7);
assert.deepEqual(filesUnder(join(home, '.claude')), ['commands/sdlc-plan.md']);
assert.deepEqual(filesUnder(join(home, '.codex')), ['prompts/sdlc-plan.md']);
assert.deepEqual(filesUnder(join(home, '.config', 'opencode')), ['command/sdlc-plan.md']);
assert.deepEqual(filesUnder(join(home, '.omp')), ['commands/sdlc-plan.md']);
assert.deepEqual(filesUnder(join(home, '.gemini')), ['skills/sdlc-plan/SKILL.md']);
assert.deepEqual(filesUnder(join(home, '.kiro')), ['skills/sdlc-plan/SKILL.md']);
assert.deepEqual(filesUnder(join(home, 'work', '.github')), ['skills/sdlc-plan/SKILL.md']);
});
test('轉接檔內容是一句指向 tea-sdlc 的話,不含任何檔案路徑', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS, version: '1.2.3' });
const home = makeHome(t, ['claude']);
await inHome(plugin, home)(['install']);
const text = readFileSync(join(home, '.claude', 'commands', 'sdlc-plan.md'), 'utf8');
assert.match(text, /tea-sdlc prompt --name sdlc-plan --adapter-version 1\.2\.3/);
// 路徑一旦寫進去,升一次 Node 就會同時指向不存在的檔案,而且不會有任何錯誤訊息
assert.equal(text.includes(plugin.root), false);
assert.equal(text.includes('prompts/sdlc-plan.md'), false);
assert.equal(/(^|\s)\/\w/.test(text.replace(/\/sdlc-\S+/g, '')), false, '轉接檔裡不該有絕對路徑');
});
test('description 前綴一律是「僅由 /sdlc-xxx 指令叫用。」,從正本抄過來而不是另寫一份', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude', 'kiro']);
await inHome(plugin, home)(['install']);
for (const path of [
join(home, '.claude', 'commands', 'sdlc-plan.md'),
join(home, '.kiro', 'skills', 'sdlc-plan', 'SKILL.md'),
]) {
const description = readFileSync(path, 'utf8').match(/^description:\s*(.+)$/m)[1];
assert.ok(description.startsWith('僅由 /sdlc-plan 指令叫用。'), `${path}:${description}`);
}
});
test('支援關閉自動觸發的 command 平台設上旗標;三個 skill 平台沒有那個旗標可設', async (t) => {
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': fakePrompt('sdlc-plan') } });
const home = makeHome(t, ['claude', 'codex', 'opencode', 'oh-my-pi', 'antigravity', 'kiro', 'copilot']);
await inHome(plugin, home)(['install']);
const commands = [
join(home, '.claude', 'commands', 'sdlc-plan.md'),
join(home, '.codex', 'prompts', 'sdlc-plan.md'),
join(home, '.config', 'opencode', 'command', 'sdlc-plan.md'),
join(home, '.omp', 'commands', 'sdlc-plan.md'),
];
const skills = [
join(home, '.gemini', 'skills', 'sdlc-plan', 'SKILL.md'),
join(home, '.kiro', 'skills', 'sdlc-plan', 'SKILL.md'),
join(home, 'work', '.github', 'skills', 'sdlc-plan', 'SKILL.md'),
];
for (const path of commands) {
assert.match(readFileSync(path, 'utf8'), /^disable-model-invocation: true$/m, path);
}
for (const path of skills) {
assert.equal(readFileSync(path, 'utf8').includes('disable-model-invocation'), false, path);
}
});
test('重跑安裝是覆蓋而不是疊加,也不會多留一份舊檔', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
await inHome(plugin, home)(['install']);
await inHome(plugin, home)(['install']);
assert.deepEqual(filesUnder(join(home, '.claude')).sort(), [
'commands/sdlc-feat.md',
'commands/sdlc-plan.md',
]);
});
test('正本的 description 前綴不對時擋下,不讓它變成一份會被誤觸的轉接檔', async (t) => {
const plugin = makeFakePlugin(t, {
prompts: { 'sdlc-plan': 'name: sdlc-plan\ndescription: 把需求變成議題。\n\n# sdlc-plan\n' },
});
const home = makeHome(t, ['claude']);
const { code, json } = await inHome(plugin, home)(['install']);
assert.equal(code, 1);
assert.equal(json.error.code, 'PROMPT_BAD_DESCRIPTION');
assert.match(json.error.message, /僅由 \/sdlc-plan 指令叫用。/);
assert.equal(existsSync(join(home, '.claude', 'commands')), false);
});
// ── 勾選 ───────────────────────────────────────────────────────────
test('列出來的每一項預設都是勾起來的,編號從 1 開始', async () => {
const { checklist } = await import('../scripts/install.js');
const lines = checklist([
{ name: 'claude', label: 'Claude Code' },
{ name: 'kiro', label: 'Kiro' },
]);
assert.deepEqual(lines, [
' [x] 1. Claude Code(claude)',
' [x] 2. Kiro(kiro)',
]);
});
test('直接按 Enter 就是全裝', async () => {
const { selectPlatforms } = await import('../scripts/install.js');
const detected = [{ name: 'claude' }, { name: 'kiro' }];
assert.deepEqual(selectPlatforms(detected, ''), detected);
assert.deepEqual(selectPlatforms(detected, ' \n'), detected);
});
test('勾選可以打編號也可以打名稱,順序以列出來的為準', async () => {
const { selectPlatforms } = await import('../scripts/install.js');
const detected = [{ name: 'claude' }, { name: 'kiro' }, { name: 'codex' }];
assert.deepEqual(selectPlatforms(detected, '2,1').map((p) => p.name), ['claude', 'kiro']);
assert.deepEqual(selectPlatforms(detected, 'codex').map((p) => p.name), ['codex']);
});
test('勾了沒列出來的東西時當場說,不要默默少裝一個', async () => {
const { selectPlatforms } = await import('../scripts/install.js');
assert.throws(
() => selectPlatforms([{ name: 'claude' }], '9'),
(error) => error.code === 'UNKNOWN_PLATFORM',
);
});
// ── 移除 ───────────────────────────────────────────────────────────
test('uninstall 之後家目錄裡沒有任何殘留的轉接檔', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude', 'kiro']);
await inHome(plugin, home)(['install']);
const { code, json } = await inHome(plugin, home)(['uninstall']);
assert.equal(code, 0);
assert.equal(json.data.removed.length, 4);
assert.deepEqual(filesUnder(join(home, '.claude')), []);
assert.deepEqual(filesUnder(join(home, '.kiro')), []);
// 平台自己的目錄不是我們建的東西,留著
assert.ok(existsSync(join(home, '.claude')));
});
test('uninstall 不動流程正本', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
await inHome(plugin, home)(['install']);
await inHome(plugin, home)(['uninstall']);
assert.equal(readFileSync(join(plugin.root, 'prompts', 'sdlc-plan.md'), 'utf8'), PROMPTS['sdlc-plan']);
assert.deepEqual(readdirSync(join(plugin.root, 'prompts')).sort(), ['sdlc-feat.md', 'sdlc-plan.md']);
});
test('不是 tea-sdlc 產生的同名檔案一律留著,並在輸出裡交代為什麼', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
await inHome(plugin, home)(['install']);
const mine = join(home, '.claude', 'commands', 'sdlc-plan.md');
writeFileSync(mine, '# 我自己寫的,不要刪\n');
const { json } = await inHome(plugin, home)(['uninstall']);
assert.equal(readFileSync(mine, 'utf8'), '# 我自己寫的,不要刪\n');
assert.equal(json.data.kept.length, 1);
assert.match(json.data.kept[0].path, /sdlc-plan\.md$/);
});
test('舊版留下、正本已經不存在的轉接檔也一起移除', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
await inHome(plugin, home)(['install']);
// 模擬上一版裝過、這一版已經沒有的指令
const orphan = join(home, '.claude', 'commands', 'sdlc-gone.md');
writeFileSync(orphan, readFileSync(join(home, '.claude', 'commands', 'sdlc-plan.md'), 'utf8'));
await inHome(plugin, home)(['uninstall']);
assert.equal(existsSync(orphan), false);
});
test('--dry-run 列出會動到哪些檔案,但一個字都不寫', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
const { json } = await inHome(plugin, home)(['install', '--dry-run']);
assert.equal(json.data.dryRun, true);
assert.equal(json.data.platforms[0].adapters.length, 2);
assert.equal(existsSync(join(home, '.claude', 'commands')), false);
});
// ── 執行環境 ───────────────────────────────────────────────────────
test('缺少 tea 時照樣把轉接檔裝好,但把缺的東西講出來', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
const { code, json } = await plugin.run(['install'], {
env: { HOME: home },
cwd: join(home, 'work'),
path: pathWithOnly(['node', 'git']),
});
// 缺 tea 完全不影響轉接檔產生,硬擋等於逼使用者為了裝 plugin 先去裝 tea
assert.equal(code, 0);
assert.deepEqual(json.data.missingBinaries, ['tea']);
assert.match(json.data.warning, /tea/);
assert.match(json.data.warning, /gitea\.com\/gitea\/tea/);
assert.ok(existsSync(join(home, '.claude', 'commands', 'sdlc-plan.md')));
});
test('該裝的都在時不留下沒有意義的警告', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
const { json } = await inHome(plugin, home)(['install']);
assert.deepEqual(json.data.missingBinaries, []);
assert.equal(json.data.warning, null);
});
test('安裝不會替使用者裝任何東西:從頭到尾沒有跑過套件管理器', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
const { stderr } = await plugin.run(['install'], {
env: { HOME: home },
cwd: join(home, 'work'),
// PATH 上只有 node:真要自動安裝什麼,這裡就會失敗而不是靜靜跳過
path: pathWithOnly(['node']),
});
assert.equal(stderr, '');
});