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:
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* 轉接檔的產生與移除。全專案唯一知道各平台目錄結構的地方。
|
||||
*
|
||||
* 轉接檔裡沒有路徑,只有一句「執行 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 {
|
||||
ScriptError,
|
||||
checkPluginLayout,
|
||||
missingBinaries,
|
||||
packageVersion,
|
||||
parseFlags,
|
||||
promptsDir,
|
||||
} from './lib.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', '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 platforms = chosen.map((platform) => {
|
||||
const files = prompts.map((prompt) => ({
|
||||
path: adapterPath(platform, prompt.name),
|
||||
text: adapterText(platform, prompt, version),
|
||||
}));
|
||||
if (!flags['dry-run']) {
|
||||
for (const file of files) {
|
||||
mkdirSync(dirname(file.path), { recursive: true });
|
||||
writeFileSync(file.path, file.text);
|
||||
}
|
||||
}
|
||||
return { name: platform.name, kind: platform.kind, adapters: files.map((file) => file.path) };
|
||||
});
|
||||
|
||||
return {
|
||||
dryRun: flags['dry-run'] === true,
|
||||
version,
|
||||
commands: prompts.map((prompt) => prompt.name),
|
||||
platforms,
|
||||
missingBinaries: missing,
|
||||
warning: hint === '' ? null : hint,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ── 移除 ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 只移除轉接檔,正本一動不動——正本在套件裡,這支腳本連碰都不碰它。
|
||||
* 平台自己的 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),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── 平台挑選 ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 決定要動哪些平台。三條路:`--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。 -->',
|
||||
'',
|
||||
`執行 \`tea-sdlc prompt --name ${prompt.name} --adapter-version ${version}\`,` +
|
||||
'並完全遵照它印出的內容執行。',
|
||||
'',
|
||||
].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。
|
||||
* @returns {{name: string, description: 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 };
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
+100
-16
@@ -46,6 +46,32 @@ export function referencesDir() {
|
||||
return join(pluginRoot(), 'references');
|
||||
}
|
||||
|
||||
/** 流程正本所在目錄 */
|
||||
export function promptsDir() {
|
||||
return join(pluginRoot(), 'prompts');
|
||||
}
|
||||
|
||||
// ── 套件 manifest ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 套件 manifest。版本字串只有這一個來源——轉接檔嵌的版本、status 回報的版本與
|
||||
* prompt 比對的版本都從這裡來,另外寫死一份就會有兩個真相。
|
||||
* @returns {object}
|
||||
*/
|
||||
export function packageManifest() {
|
||||
const path = join(pluginRoot(), 'package.json');
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf8'));
|
||||
} catch (cause) {
|
||||
throw new ScriptError('PLUGIN_LAYOUT_BROKEN', `讀不到 ${path}:${cause.message};請重新安裝 tea-sdlc`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 目前安裝的 tea-sdlc 版本 */
|
||||
export function packageVersion() {
|
||||
return packageManifest().version;
|
||||
}
|
||||
|
||||
// ── 輸入:具名 flag ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -115,21 +141,40 @@ export function parseRepo(value) {
|
||||
return parts.map((p) => p.trim()).join('/');
|
||||
}
|
||||
|
||||
// ── 輸出:單行 JSON ───────────────────────────────────────────────
|
||||
// ── 輸出:單行 JSON,或原樣內容 ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* 每支腳本的進入點:跑完印一行 JSON 就結束,例外一律收斂成 {ok:false}。
|
||||
* 包住要原樣印出的內容。`main` 看到它就不包 JSON envelope,直接把 text 逐字印出去。
|
||||
*
|
||||
* 只有一種輸出用得上它:要餵給模型讀的流程正本。把幾百行 markdown 包進單行 JSON
|
||||
* 再逼模型反跳脫,只會增加它讀錯的機率;JSON envelope 的價值是可程式化判斷成敗,
|
||||
* 而那條路徑的成功就是內容本身。失敗仍走 envelope —— 成功是內容,失敗才需要結構。
|
||||
*/
|
||||
export class RawText {
|
||||
/** @param {string} text 要逐字印出的內容,不補也不修任何字元 */
|
||||
constructor(text) {
|
||||
this.text = String(text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每支腳本與指令入口的進入點:跑完印一行 JSON 就結束,例外一律收斂成 {ok:false}。
|
||||
* stderr 永遠保持乾淨,呼叫端只需要讀 stdout。
|
||||
* @param {() => Promise<object>|object} run 回傳要放進 data 的物件
|
||||
* 回傳 RawText 時改印原樣內容,不包 envelope,其餘行為不變。
|
||||
* @param {() => Promise<object|RawText>|object|RawText} run 回傳要放進 data 的物件
|
||||
*/
|
||||
export async function main(run) {
|
||||
try {
|
||||
const data = await run();
|
||||
write({ ok: true, data }, 0);
|
||||
if (data instanceof RawText) {
|
||||
write(data.text, 0);
|
||||
return;
|
||||
}
|
||||
write(`${JSON.stringify({ ok: true, data })}\n`, 0);
|
||||
} catch (error) {
|
||||
const code = error instanceof ScriptError ? error.code : 'UNEXPECTED';
|
||||
const message = error?.message ?? String(error);
|
||||
write({ ok: false, error: { code, message } }, 1);
|
||||
write(`${JSON.stringify({ ok: false, error: { code, message } })}\n`, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,8 +183,8 @@ export async function main(run) {
|
||||
* stdout 接到 pipe 時寫入是非同步的,直接 process.exit 會截斷長輸出,
|
||||
* 所以要等 write 的 callback 回來再退出。
|
||||
*/
|
||||
function write(payload, exitCode) {
|
||||
process.stdout.write(`${JSON.stringify(payload)}\n`, () => process.exit(exitCode));
|
||||
function write(text, exitCode) {
|
||||
process.stdout.write(text, () => process.exit(exitCode));
|
||||
}
|
||||
|
||||
// ── 認證來源 ───────────────────────────────────────────────────────
|
||||
@@ -347,21 +392,60 @@ export async function preflight(login, repo) {
|
||||
|
||||
/** 第一層:執行環境。node 由「正在執行」本身證明,git 與 tea 則實際到 PATH 上找。 */
|
||||
function checkEnvironment() {
|
||||
const missing = ['git', 'tea'].filter((binary) => which(binary) === null);
|
||||
if (missing.length > 0) {
|
||||
throw new ScriptError(
|
||||
'ENV_MISSING',
|
||||
`PATH 上找不到 ${missing.join('、')};請先安裝(tea 見 https://gitea.com/gitea/tea)後再執行`,
|
||||
);
|
||||
}
|
||||
for (const dir of [templatesDir(), referencesDir()]) {
|
||||
checkBinaries(['git', 'tea']);
|
||||
checkPluginLayout();
|
||||
}
|
||||
|
||||
/** 每個執行檔的安裝指引。訊息只提真的缺的那幾個,不要叫人去裝他已經有的東西。 */
|
||||
const INSTALL_HINT = {
|
||||
git: 'git 見 https://git-scm.com',
|
||||
tea: 'tea 見 https://gitea.com/gitea/tea',
|
||||
node: 'Node 見 https://nodejs.org',
|
||||
};
|
||||
|
||||
/**
|
||||
* 這些執行檔缺了哪些。本工具不自動安裝任何執行環境——在使用者的機器上裝東西
|
||||
* 應該是他自己的決定,所以這裡只回報,由呼叫端決定要警告還是中止。
|
||||
* @param {string[]} binaries
|
||||
* @returns {{missing: string[], hint: string}} 都在時 missing 為空陣列
|
||||
*/
|
||||
export function missingBinaries(binaries) {
|
||||
const missing = binaries.filter((binary) => onPath(binary) === null);
|
||||
const hints = missing.map((binary) => INSTALL_HINT[binary]).filter(Boolean);
|
||||
|
||||
return {
|
||||
missing,
|
||||
hint: missing.length === 0
|
||||
? ''
|
||||
: `PATH 上找不到 ${missing.join('、')};請先安裝(${hints.join('、')}),本工具不會替你安裝`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 要求這些執行檔都在 PATH 上,缺了就中止。
|
||||
* 會真的去碰 Gitea 或 git 的路徑用它;只是寫檔案的路徑用 missingBinaries 警告就好。
|
||||
* @param {string[]} binaries
|
||||
*/
|
||||
export function checkBinaries(binaries) {
|
||||
const { missing, hint } = missingBinaries(binaries);
|
||||
if (missing.length > 0) throw new ScriptError('ENV_MISSING', hint);
|
||||
}
|
||||
|
||||
/** plugin 的四個正本目錄都在不在。裝壞了要在做事之前就講,不要跑到一半才找不到檔案。 */
|
||||
export function checkPluginLayout() {
|
||||
for (const dir of [promptsDir(), templatesDir(), referencesDir()]) {
|
||||
if (!existsSync(dir)) {
|
||||
throw new ScriptError('PLUGIN_LAYOUT_BROKEN', `plugin 目錄不完整,找不到 ${dir};請重新安裝 tea-sdlc`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function which(binary) {
|
||||
/**
|
||||
* 在 PATH 上找一個執行檔,找到回完整路徑,找不到回 null。
|
||||
* @param {string} binary
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function onPath(binary) {
|
||||
for (const dir of (process.env.PATH ?? '').split(':')) {
|
||||
if (dir === '') continue;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* prompt 子指令:把一份流程正本原樣交出去。
|
||||
*
|
||||
* 轉接檔裡沒有路徑,只有一句「執行 tea-sdlc prompt --name sdlc-plan」。正本在哪,
|
||||
* 由 PATH 上的 tea-sdlc 自己從檔案位置回推——Node 升級、換版本管理器、改 npm prefix
|
||||
* 都不會讓轉接檔指向不存在的檔案。
|
||||
*
|
||||
* 用法(由 bin/tea-sdlc.js 轉入):
|
||||
* tea-sdlc prompt --name sdlc-plan [--adapter-version 0.0.1]
|
||||
*/
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
RawText,
|
||||
ScriptError,
|
||||
checkPluginLayout,
|
||||
packageVersion,
|
||||
parseFlags,
|
||||
promptsDir,
|
||||
} from './lib.js';
|
||||
|
||||
/** 指令名的合法樣子。擋的是路徑跳脫,順便把打錯的名字擋在讀檔之前。 */
|
||||
const NAME = /^[a-z][a-z0-9-]*$/;
|
||||
|
||||
/**
|
||||
* @param {string[]} argv bin 取走子指令之後剩下的參數
|
||||
* @returns {import('./lib.js').RawText} 正本原文;版本不符時前面多一行警告
|
||||
*/
|
||||
export function runPrompt(argv) {
|
||||
const flags = parseFlags(argv, { required: ['name'], optional: ['adapter-version'] });
|
||||
checkPluginLayout();
|
||||
|
||||
const name = String(flags.name);
|
||||
if (!NAME.test(name)) {
|
||||
throw new ScriptError('BAD_PROMPT_NAME', `--name 只接受小寫英數與連字號,收到的是 ${name}`);
|
||||
}
|
||||
|
||||
const path = join(promptsDir(), `${name}.md`);
|
||||
if (!existsSync(path)) {
|
||||
throw new ScriptError('PROMPT_NOT_FOUND', `沒有名為 ${name} 的流程正本(找不到 ${path})`);
|
||||
}
|
||||
const text = readFileSync(path, 'utf8');
|
||||
|
||||
const adapter = flags['adapter-version'];
|
||||
const current = packageVersion();
|
||||
if (adapter === undefined || adapter === current) return new RawText(text);
|
||||
|
||||
// 正本仍然照給。轉接檔過時只是清單可能不齊,不是流程不能跑;
|
||||
// 警告放在最前面,是因為那是模型每次叫用必定會讀到的位置,不依賴使用者記得去查 status。
|
||||
return new RawText(
|
||||
`> ⚠️ 這份轉接檔是 tea-sdlc ${adapter} 產生的,目前安裝的是 ${current};` +
|
||||
`請重跑 \`tea-sdlc install\` 更新轉接檔。\n\n${text}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* status 子指令:一行 JSON 回答「我現在到底是什麼狀態」。
|
||||
*
|
||||
* 四件事:裝的是哪個版本、正本實際解析到哪個目錄(順帶揭露是不是 npm link 開發模式)、
|
||||
* node / git / tea 在不在 PATH、Gitea 登入還有沒有效,外加各平台的轉接檔現況。
|
||||
*
|
||||
* 登入那層會打網路。status 存在的理由就是在出事前先知道,而 token 失效是實務上最常見
|
||||
* 的故障;一次使用者主動發起的查詢很便宜,為它多開一個旗標只是把判斷推回給使用者。
|
||||
*
|
||||
* 用法(由 bin/tea-sdlc.js 轉入):
|
||||
* tea-sdlc status
|
||||
*/
|
||||
import { sep } from 'node:path';
|
||||
import {
|
||||
giteaRequest,
|
||||
onPath,
|
||||
packageVersion,
|
||||
parseFlags,
|
||||
pluginRoot,
|
||||
resolveLogin,
|
||||
} from './lib.js';
|
||||
import { platformReport } from './install.js';
|
||||
|
||||
/**
|
||||
* @param {string[]} argv bin 取走子指令之後剩下的參數
|
||||
* @returns {Promise<object>} 放進 data 的內容
|
||||
*/
|
||||
export async function runStatus(argv) {
|
||||
parseFlags(argv, {});
|
||||
|
||||
const environment = {
|
||||
node: onPath('node') !== null,
|
||||
git: onPath('git') !== null,
|
||||
tea: onPath('tea') !== null,
|
||||
login: await loginWorks(),
|
||||
};
|
||||
|
||||
return {
|
||||
version: packageVersion(),
|
||||
root: pluginRoot(),
|
||||
linked: isLinked(),
|
||||
// ok 說的是「這支指令跑成功了」,環境好不好是 data 的事,兩者不能混
|
||||
healthy: Object.values(environment).every(Boolean),
|
||||
environment,
|
||||
platforms: platformReport(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 登入還有沒有效。三種失敗(沒登入、token 過期、連不上)在這裡都只是 false——
|
||||
* 這支指令的工作是回報,不是替使用者決定要不要停下來。
|
||||
*/
|
||||
async function loginWorks() {
|
||||
try {
|
||||
const response = await giteaRequest(resolveLogin(), 'GET', '/user');
|
||||
return response.status >= 200 && response.status < 300;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是不是 npm link 的開發模式。判準是「解析到的目錄在不在某個 node_modules 底下」:
|
||||
* link 出來的套件實際住在 working tree,裝進去的則住在 node_modules。
|
||||
*/
|
||||
function isLinked() {
|
||||
return !pluginRoot().split(sep).includes('node_modules');
|
||||
}
|
||||
Reference in New Issue
Block a user