Files
tea-sdlc/scripts/install.js
T

510 lines
20 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.
/**
* 轉接檔的產生與移除。全專案唯一知道各平台目錄結構的地方。
*
* 轉接檔裡沒有路徑,只有一句「執行 tea-sdlc prompt --name <指令名>」。正本在哪由
* PATH 上的 tea-sdlc 自己回推,所以升級 Node、換版本管理器、改 npm prefix 都不會讓
* 七個平台的轉接檔同時指向不存在的檔案。
*
* 用法(由 bin/tea-sdlc.js 轉入):
* tea-sdlc install [--platform a,b] [--dry-run]
* tea-sdlc uninstall [--platform a,b] [--dry-run]
*/
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
readSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { basename, dirname, join } from 'node:path';
import {
Failure,
ScriptError,
checkPluginLayout,
missingBinaries,
packageVersion,
parseFlags,
promptsDir,
} from './lib.js';
import { invocation, verifyInstall, 診斷 } from './install-verify.js';
/**
* 七個平台。`detect` 是「這台機器裝了它沒有」的判準,`target` 是轉接檔的落點,
* 兩者都相對於 `base`——`home` 是家目錄,`cwd` 是目前的專案(Copilot 讀的是 repo 內的
* .github/,不是家目錄)。
*
* `kind` 決定轉接檔長什麼樣:`command` 的四個平台吃 commands/<名>.md,
* `skill` 的三個平台吃 skills/<名>/SKILL.md。
*
* 已接受的取捨:Antigravity、Copilot、Kiro 無法關閉自動觸發,只能靠窄化 description
* 降低誤觸;四個 command 平台則實際設上旗標。
*/
const PLATFORMS = [
{ name: 'claude', label: 'Claude Code', base: 'home', detect: ['.claude'], target: ['.claude', 'commands'], kind: 'command' },
{ name: 'codex', label: 'Codex', base: 'home', detect: ['.codex'], target: ['.codex', 'prompts'], kind: 'command' },
{ name: 'opencode', label: 'OpenCode', base: 'home', detect: ['.config', 'opencode'], target: ['.config', 'opencode', 'command'], kind: 'command' },
{ name: 'oh-my-pi', label: 'oh-my-pi', base: 'home', detect: ['.omp'], target: ['.omp', 'agent', 'commands'], kind: 'command' },
{ name: 'antigravity', label: 'Antigravity', base: 'home', detect: ['.gemini'], target: ['.gemini', 'skills'], kind: 'skill' },
{ name: 'kiro', label: 'Kiro', base: 'home', detect: ['.kiro'], target: ['.kiro', 'skills'], kind: 'skill' },
{ name: 'copilot', label: 'GitHub Copilot', base: 'cwd', detect: ['.github'], target: ['.github', 'skills'], kind: 'skill' },
];
/**
* 產生標記。它同時是三件事的依據:
* 1. 這個檔是不是我們產生的——移除時只刪帶標記的,使用者自己寫的同名檔一律留著
* 2. 是哪一版產生的——status 靠它判斷過時
* 3. 給讀到檔案的人一句「別手改這裡」
*/
const MARKER = 'tea-sdlc-adapter';
const MARKER_RE = new RegExp(`<!-- ${MARKER} v([^\\s:]+)`);
/**
* 兩種轉接檔形式的差別全部收在這裡:檔案擺哪、怎麼把已經擺好的找回來、
* frontmatter 要不要多一行、刪完要不要收殼。散在四個 if 裡的話,日後多一種形式
* 就得在四個地方各改一次,而漏掉哪一個不會有人發現。
*/
const KINDS = {
/** 一個指令一個檔:<目錄>/<指令名>.md */
command: {
path: (dir, name) => join(dir, `${name}.md`),
list: (dir) => readdirSync(dir).map((entry) => join(dir, entry)),
// 支援關閉自動觸發的平台就關掉
frontmatter: ['disable-model-invocation: true'],
cleanup: () => {},
},
/** 一個指令一層目錄:<目錄>/<指令名>/SKILL.md */
skill: {
path: (dir, name) => join(dir, name, 'SKILL.md'),
list: (dir) => readdirSync(dir).map((entry) => join(dir, entry, 'SKILL.md')),
// 這三個平台關不掉自動觸發,只能靠 description 已經窄到不會被誤判
frontmatter: [],
cleanup: (path) => rmEmptyDir(dirname(path)),
},
};
// ── 產生 ───────────────────────────────────────────────────────────
/**
* @param {string[]} argv bin 取走子指令之後剩下的參數
* @returns {object} 放進 data 的內容
*/
export function runInstall(argv) {
const flags = parseFlags(argv, { optional: ['platform'], booleans: ['dry-run'] });
// 只警告不中止:缺 tea 完全不影響轉接檔產生,硬擋等於逼使用者為了裝 plugin 先去裝 tea。
// 但完全不查也不行——安裝是一次性動作,使用者裝完就走,沒被提醒他會以為一切就緒。
const { missing, hint } = missingBinaries(['node', 'git', 'tea']);
const prompts = readPrompts();
const version = packageVersion();
const chosen = choose(flags.platform);
const dryRun = flags['dry-run'] === true;
const written = chosen.map((platform) => {
const files = prompts.map((prompt) => ({
name: prompt.name,
path: adapterPath(platform, prompt.name),
text: adapterText(platform, prompt, version),
}));
if (!dryRun) {
for (const file of files) {
mkdirSync(dirname(file.path), { recursive: true });
writeFileSync(file.path, file.text);
}
}
return { name: platform.name, kind: platform.kind, files };
});
const verify = dryRun
? { skipped: true, reason: '--dry-run 沒有寫入任何轉接檔,沒有東西可以驗' }
: verifyInstall({
version,
// 取一份就夠了:要驗的是這條鏈通不通,不是每一份正本的內容
prompt: { name: prompts[0].name, text: prompts[0].text },
// 刻意只交出 name 與 path,不交 text:驗證要驗的正是「寫出去之後檔案真的長那樣」,
// 把剛才那份原稿也遞過去,它就有機會拿記憶體裡的字串來比,等於自己驗自己
platforms: written.map(({ name, files }) => ({
name,
adapters: files.map(({ name: 指令, path }) => ({ name: 指令, path })),
})),
});
const data = {
dryRun,
version,
commands: prompts.map((prompt) => prompt.name),
platforms: written.map(({ name, kind, files }) => ({
name,
kind,
adapters: files.map((file) => file.path),
})),
missingBinaries: missing,
warning: hint === '' ? null : hint,
verify,
};
// 驗不過就回失敗,但轉接檔一份都不刪:見 install-verify 開頭對「失敗不回滾」的交代。
// data 照樣交出去,使用者才看得到已經寫了哪些、以及是哪一段不通。
return verify.skipped || verify.ok ? data : new Failure('INSTALL_VERIFY_FAILED', 診斷(verify), data);
}
// ── 移除 ───────────────────────────────────────────────────────────
/**
* 只移除轉接檔,正本一動不動——正本在套件裡,這支腳本連碰都不碰它。
* 平台自己的 commands/、skills/ 目錄也留著:那不是我們建的東西。
*/
export function runUninstall(argv) {
const flags = parseFlags(argv, { optional: ['platform'], booleans: ['dry-run'] });
const requested = flags.platform === undefined ? null : parsePlatformFlag(flags.platform);
const removed = [];
const kept = [];
// 與 install 不同,這裡不要求平台偵測得到:平台被移掉之後留下的轉接檔更需要清,
// 而掃一個不存在的目錄本來就沒有東西可刪,不需要為此報錯。
for (const platform of PLATFORMS) {
if (requested && !requested.includes(platform.name)) continue;
for (const path of adaptersUnder(platform)) {
// 逐版累積的舊轉接檔也要清掉,所以掃的是目錄而不是這一版的指令清單
if (adapterVersion(path) === null) {
kept.push({ path, reason: `不是 tea-sdlc 產生的(沒有 ${MARKER} 標記)` });
continue;
}
removed.push(path);
if (flags['dry-run']) continue;
rmSync(path);
KINDS[platform.kind].cleanup(path);
}
}
return { dryRun: flags['dry-run'] === true, removed, kept };
}
// ── 現況 ───────────────────────────────────────────────────────────
/**
* 各平台的轉接檔現況,供 status 回報。平台目錄結構的知識只有這一份,
* status 不複製一份自己的。
* @returns {{name: string, kind: string, detected: boolean, adapters: number, versions: string[], stale: boolean}[]}
*/
export function platformReport() {
const current = packageVersion();
const expected = countPrompts();
return PLATFORMS.map((platform) => {
const versions = adaptersUnder(platform).map(adapterVersion).filter((v) => v !== null);
return {
name: platform.name,
kind: platform.kind,
dir: pathOf(platform, platform.target),
detected: existsSync(pathOf(platform, platform.detect)),
adapters: {
// 應該裝幾份 vs 實際裝了幾份:少了就是有指令沒佈署到
expected,
present: versions.length,
stale: versions.some((version) => version !== current),
},
};
});
}
/**
* 供 verify 使用的唯讀目標。它只看目前偵測到的平台,不進互動選擇,也不寫檔。
* @param {string|undefined} flag --platform 的值
* @returns {{name: string, adapters: {name: string, path: string}[]}[]}
*/
export function verificationTargets(flag) {
const detected = PLATFORMS.filter((platform) => existsSync(pathOf(platform, platform.detect)));
const chosen = flag === undefined
? detected
: (() => {
const names = parsePlatformFlag(flag);
const missing = names.filter((name) => !detected.some((platform) => platform.name === name));
if (missing.length > 0) {
throw new ScriptError(
'PLATFORM_NOT_DETECTED',
`這台機器上偵測不到 ${missing.join('、')};請先把該平台裝起來再重跑`,
);
}
return PLATFORMS.filter((platform) => names.includes(platform.name));
})();
if (chosen.length === 0) {
throw new ScriptError(
'NO_PLATFORM_DETECTED',
`偵測不到任何 agent 平台(找過 ${PLATFORMS.map(detectLabel).join('、')});請先安裝其中至少一個`,
);
}
const names = readPrompts().map((prompt) => prompt.name);
return chosen.map((platform) => ({
name: platform.name,
adapters: names.map((name) => ({ name, path: adapterPath(platform, name) })),
}));
}
/** @returns {{name: string, text: string}} */
export function verificationPrompt() {
const prompt = readPrompts()[0];
return { name: prompt.name, text: prompt.text };
}
// ── 平台挑選 ───────────────────────────────────────────────────────
/**
* 決定要動哪些平台。三條路:`--platform` 指名、TTY 上勾選、非互動時全裝。
* 指名了沒裝的平台要擋下來——那多半是打錯字,替它建目錄只會在機器上留下孤兒。
*/
function choose(flag) {
const detected = PLATFORMS.filter((platform) => existsSync(pathOf(platform, platform.detect)));
if (flag !== undefined) {
const names = parsePlatformFlag(flag);
const missing = names.filter((name) => !detected.some((platform) => platform.name === name));
if (missing.length > 0) {
throw new ScriptError(
'PLATFORM_NOT_DETECTED',
`這台機器上偵測不到 ${missing.join('、')};若確定要裝,請先把該平台裝起來再重跑`,
);
}
return PLATFORMS.filter((platform) => names.includes(platform.name));
}
if (detected.length === 0) {
throw new ScriptError(
'NO_PLATFORM_DETECTED',
`偵測不到任何 agent 平台(找過 ${PLATFORMS.map(detectLabel).join('、')});` +
'請先安裝其中至少一個,或用 --platform 指名',
);
}
// CI 與腳本一律不進互動提示:沒有 TTY 就照預設全裝
return process.stdin.isTTY ? select(detected) : detected;
}
/** `--platform a,b` 的值。認不得的名字要當場指出來,不要默默少裝一個。 */
function parsePlatformFlag(value) {
const names = String(value)
.split(',')
.map((name) => name.trim())
.filter((name) => name !== '');
const known = PLATFORMS.map((platform) => platform.name);
const unknown = names.filter((name) => !known.includes(name));
if (names.length === 0 || unknown.length > 0) {
throw new ScriptError(
'UNKNOWN_PLATFORM',
`不認得的平台 ${unknown.join('、') || '(空值)'};可用的是:${known.join('、')}`,
);
}
return names;
}
/**
* 在終端機上列出偵測到的平台讓人勾選,預設全勾。
* 問句走 stderr:stdout 是給呼叫端讀的單行 JSON,不能混進人看的字。
*/
function select(detected) {
process.stderr.write('要安裝到哪些平台?\n');
for (const line of checklist(detected)) process.stderr.write(`${line}\n`);
return selectPlatforms(detected, readLine('直接按 Enter 全裝,或輸入要裝的編號/名稱(逗號分隔):'));
}
/** 勾選清單的每一行。預設全勾,所以每一項都是 [x]。 */
export function checklist(detected) {
return detected.map((platform, i) => ` [x] ${i + 1}. ${platform.label}(${platform.name})`);
}
/**
* 把使用者打的那一行變成要安裝的平台。空字串是「全部」——預設全勾,直接按 Enter 就過。
* 與 I/O 分開是為了測得到:真的開一個 TTY 來測這段,測的會是 pty 而不是這個規則。
* @param {object[]} detected 剛才列出來的平台,順序即編號
* @param {string} answer 使用者打的那一行
*/
export function selectPlatforms(detected, answer) {
if (answer.trim() === '') return detected;
const picked = answer
.split(',')
.map((token) => token.trim())
.filter((token) => token !== '')
.map((token) => {
const byIndex = /^\d+$/.test(token) ? detected[Number(token) - 1] : undefined;
const platform = byIndex ?? detected.find((candidate) => candidate.name === token);
if (!platform) {
throw new ScriptError('UNKNOWN_PLATFORM', `勾選的 ${token} 不在剛才列出的平台裡`);
}
return platform;
});
return detected.filter((platform) => picked.includes(platform));
}
/**
* 從 stdin 讀一行。非同步讀在這裡沒有用武之地——問完就要等答案,後面什麼都不能做。
* EAGAIN 是 TTY 還沒有東西可讀,等一下再試,不要空轉燒 CPU。
*/
function readLine(question) {
process.stderr.write(question);
const buffer = Buffer.alloc(256);
let answer = '';
while (!answer.includes('\n')) {
let read;
try {
read = readSync(0, buffer, 0, buffer.length, null);
} catch (error) {
if (error.code !== 'EAGAIN') throw error;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
continue;
}
if (read === 0) break;
answer += buffer.toString('utf8', 0, read);
}
process.stderr.write('\n');
return answer.split('\n')[0].trim();
}
// ── 轉接檔 ─────────────────────────────────────────────────────────
/**
* 產生一份轉接檔。內容就是一句指向 tea-sdlc 的話:正本只有一份,改規則不會出現
* 各平台版本分歧,也不必為了改規則重跑安裝。
*/
function adapterText(platform, prompt, version) {
const front = [
'---',
`name: ${prompt.name}`,
`description: ${prompt.description}`,
...KINDS[platform.kind].frontmatter,
'---',
];
return [
...front,
'',
`<!-- ${MARKER} v${version}:由 tea-sdlc install 產生,請勿手動編輯。`,
' 改流程請改流程正本(不必重裝);指令數量變了才需要重跑 tea-sdlc install。 -->',
'',
`執行 \`${invocation(prompt.name, version).line}\`,並完全遵照它印出的內容執行。`,
'',
].join('\n');
}
/** 轉接檔的落點 */
function adapterPath(platform, name) {
return KINDS[platform.kind].path(pathOf(platform, platform.target), name);
}
/** 這個平台的目標目錄底下,所有長得像轉接檔的檔案(還沒判斷是不是我們產生的) */
function adaptersUnder(platform) {
const dir = pathOf(platform, platform.target);
if (!isDir(dir)) return [];
return KINDS[platform.kind].list(dir).filter(isFile).sort();
}
/**
* 這個檔是我們哪一版產生的;沒有標記就回 null。
* 「是不是我們的」與「是哪一版」是同一次讀檔的兩個答案,分成兩支函式會讓每份轉接檔被讀兩次。
* @returns {string|null}
*/
function adapterVersion(path) {
return readFileSync(path, 'utf8').match(MARKER_RE)?.[1] ?? null;
}
// ── 流程正本 ───────────────────────────────────────────────────────
/**
* 有哪些指令可以裝。以 prompts/ 裡實際存在的正本為準,不是寫死的六個名字——
* 裝出一個指向不存在正本的轉接檔,使用者只會看到 PROMPT_NOT_FOUND。
*
* 連 text 一起帶出來,是因為驗證要拿它跟「PATH 上的 tea-sdlc 取回來的那一份」逐字比對。
* 那邊讀的是同一個檔案、同樣的 utf8,所以兩邊本來就該一字不差。
* @returns {{name: string, description: string, text: string}[]}
*/
function readPrompts() {
checkPluginLayout();
const prompts = readdirSync(promptsDir())
.filter((entry) => entry.endsWith('.md'))
.sort()
.map((entry) => {
const name = basename(entry, '.md');
const text = readFileSync(join(promptsDir(), entry), 'utf8');
const description = text.match(/^description:\s*(.+)$/m)?.[1]?.trim();
if (!description) {
throw new ScriptError(
'PROMPT_NO_DESCRIPTION',
`流程正本 ${entry} 沒有 description 那一行,轉接檔沒有東西可抄`,
);
}
// 前綴是這套指令「不自動觸發」的最後一道防線:三個平台關不掉自動觸發,
// 全靠這句把 description 窄到不會被誤判。抄過去之前就要擋,不能等使用者發現誤觸。
const prefix = `僅由 /${name} 指令叫用。`;
if (!description.startsWith(prefix)) {
throw new ScriptError(
'PROMPT_BAD_DESCRIPTION',
`流程正本 ${entry} 的 description 必須以「${prefix}」起頭,目前是:${description}`,
);
}
return { name, description, text };
});
if (prompts.length === 0) {
throw new ScriptError('NO_PROMPTS', `${promptsDir()} 裡沒有任何流程正本,沒有東西可以佈署`);
}
return prompts;
}
/**
* 應該裝幾份轉接檔。status 只要數量,不該為了數數就因為某份正本的 description
* 寫壞而整支失敗——回報現況的指令不該比被回報的東西更容易倒。
*/
function countPrompts() {
try {
return readdirSync(promptsDir()).filter((entry) => entry.endsWith('.md')).length;
} catch {
return 0;
}
}
// ── 路徑小工具 ─────────────────────────────────────────────────────
function pathOf(platform, segments) {
return join(platform.base === 'home' ? homedir() : process.cwd(), ...segments);
}
/** 偵測目錄的人類可讀寫法,例如 ~/.claude */
function detectLabel(platform) {
return `${platform.base === 'home' ? '~/' : './'}${platform.detect.join('/')}`;
}
function rmEmptyDir(dir) {
if (readdirSync(dir).length === 0) rmSync(dir, { recursive: true });
}
function isDir(path) {
return existsSync(path) && statSync(path).isDirectory();
}
function isFile(path) {
return existsSync(path) && statSync(path).isFile();
}