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>
This commit is contained in:
2026-09-17 06:59:37 +00:00
co-authored by Claude Opus 5
parent f736d11049
commit 17178f0c4d
16 changed files with 1746 additions and 39 deletions
+412
View File
@@ -0,0 +1,412 @@
/**
* 轉接檔的產生與移除。
*
* 全部在臨時家目錄上跑:真的寫檔、真的刪檔,然後斷言家目錄裡剩下什麼。
* 只驗「函式有沒有被呼叫」不會發現多建了一層目錄或少刪了一個檔,而那正是這支腳本
* 唯一會出的錯。
*/
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, '');
});