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:
+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 {
|
||||
|
||||
Reference in New Issue
Block a user