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 14:53:00 +08:00
co-authored by Claude Opus 5
parent 3d0b6a3454
commit f37fe0bba7
16 changed files with 1746 additions and 39 deletions
+100 -16
View File
@@ -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 {