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