單一入口 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>
413 lines
17 KiB
JavaScript
413 lines
17 KiB
JavaScript
/**
|
||
* 轉接檔的產生與移除。
|
||
*
|
||
* 全部在臨時家目錄上跑:真的寫檔、真的刪檔,然後斷言家目錄裡剩下什麼。
|
||
* 只驗「函式有沒有被呼叫」不會發現多建了一層目錄或少刪了一個檔,而那正是這支腳本
|
||
* 唯一會出的錯。
|
||
*/
|
||
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, '');
|
||
});
|