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,48 @@
|
||||
/**
|
||||
* 在測試暫存裡建一棵完整的假 plugin 根,並從那裡啟動指令入口。
|
||||
*
|
||||
* 為什麼要整棵複製而不是加一個環境變數:入口回推 plugin 根的那段明寫「不依賴 cwd
|
||||
* 也不依賴環境變數」,為測試破它等於把要驗的東西驗掉。把 bin/ 與 scripts/ 原封複製
|
||||
* 過去,回推就自然指向假根,跑的仍是真正的程式碼;順便把「plugin 目錄不完整」
|
||||
* 那條路徑一起測得到——少給哪個目錄由測試自己決定。
|
||||
*/
|
||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { repoRoot, runBin, tmpRoot } from './run-script.js';
|
||||
|
||||
/** 一份假的流程正本,格式與真正的正本一致:頭兩行是 name 與 description */
|
||||
export const fakePrompt = (name) =>
|
||||
`name: ${name}\ndescription: 僅由 /${name} 指令叫用。假的正本,只用於測試。\n\n# ${name}\n\n第一段。\n\n## 小節\n\n第二段。\n`;
|
||||
|
||||
/**
|
||||
* @param {object} t node:test 的 TestContext
|
||||
* @param {{prompts?: Record<string,string>, omit?: string[], version?: string}} options
|
||||
* prompts 要放進 prompts/ 的正本,鍵為指令名;
|
||||
* omit 故意不建立的目錄,用來造出「plugin 目錄不完整」;
|
||||
* version 覆寫假根的套件版本
|
||||
* @returns {{root: string, run: (args: string[], opts?: object) => Promise<object>}}
|
||||
*/
|
||||
export function makeFakePlugin(t, { prompts = {}, omit = [], version } = {}) {
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
const root = mkdtempSync(join(tmpRoot, 'plugin-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
// 真正的程式碼,不是替身:測到的就是使用者會執行的東西
|
||||
for (const dir of ['bin', 'scripts']) {
|
||||
cpSync(join(repoRoot, dir), join(root, dir), { recursive: true });
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8'));
|
||||
if (version !== undefined) manifest.version = version;
|
||||
writeFileSync(join(root, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
|
||||
for (const dir of ['prompts', 'templates', 'references']) {
|
||||
if (omit.includes(dir)) continue;
|
||||
mkdirSync(join(root, dir), { recursive: true });
|
||||
}
|
||||
for (const [name, text] of Object.entries(prompts)) {
|
||||
writeFileSync(join(root, 'prompts', `${name}.md`), text);
|
||||
}
|
||||
|
||||
return { root, run: (args, opts = {}) => runBin(args, { ...opts, root }) };
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* 以子行程執行 scripts/ 底下的腳本,回傳它印出的 JSON 與 exit code。
|
||||
* 以子行程執行 scripts/ 底下的腳本或 bin/ 的指令入口,回傳它印出的東西與 exit code。
|
||||
* 這是本專案唯一的測試接縫:測到的東西就是使用者真正會執行的東西。
|
||||
*/
|
||||
import { execFile, execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, symlinkSync } from 'node:fs';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, symlinkSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
@@ -13,14 +13,52 @@ export const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'
|
||||
/** 所有測試暫存的落點;已被 .gitignore 忽略,也不會被 node --test 探索到 */
|
||||
export const tmpRoot = join(repoRoot, '.tmp');
|
||||
|
||||
/** 套件 manifest。版本只有這一個來源,測試也從同一個地方讀,不另外寫死一份。 */
|
||||
export function manifest() {
|
||||
return JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name 腳本檔名,例如 "labels-list.js"
|
||||
* @param {string[]} args 具名 flag 陣列
|
||||
* @param {{env?: Record<string,string>, cwd?: string, path?: string}} opts
|
||||
* @param {{env?: Record<string,string>, cwd?: string, path?: string, root?: string}} opts
|
||||
* env 額外環境變數;path 覆寫 PATH(用來模擬缺少 git / tea)
|
||||
* @returns {Promise<{code: number, stdout: string, stderr: string, json: object}>}
|
||||
*/
|
||||
export function runScript(name, args = [], opts = {}) {
|
||||
export async function runScript(name, args = [], opts = {}) {
|
||||
const result = await runNode(join(opts.root ?? repoRoot, 'scripts', name), args, opts);
|
||||
// 腳本的輸出契約是單行 JSON,這裡就地斷言:多印一行或印出非 JSON 都在這裡炸掉
|
||||
return { ...result, json: parseSingleLine(result.stdout) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 以子行程執行 bin/ 的指令入口,第一個參數是子指令。
|
||||
*
|
||||
* 回傳的 `json` 與 `raw` 是兩種並存的斷言,取用哪一個由測試決定:`json` 取用時才
|
||||
* 斷言單行 JSON(原樣輸出的子指令碰不到它),`raw` 永遠是逐字的 stdout。
|
||||
*
|
||||
* @param {string[]} args 子指令與其後的具名 flag
|
||||
* @param {{env?: Record<string,string>, cwd?: string, path?: string, root?: string}} opts
|
||||
* root 覆寫 plugin 根,用來從測試暫存裡的假 plugin 根啟動
|
||||
* @returns {Promise<{code: number, stdout: string, stderr: string, json: object, raw: string}>}
|
||||
*/
|
||||
export async function runBin(args = [], opts = {}) {
|
||||
const result = await runNode(join(opts.root ?? repoRoot, 'bin', 'tea-sdlc.js'), args, opts);
|
||||
|
||||
return {
|
||||
...result,
|
||||
raw: result.stdout,
|
||||
get json() {
|
||||
return parseSingleLine(result.stdout);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 真的開一個 node 子行程跑指定檔案。runScript 與 runBin 的共用地基。
|
||||
* @param {string} file 要執行的檔案絕對路徑
|
||||
*/
|
||||
function runNode(file, args, opts = {}) {
|
||||
const { env = {}, cwd = repoRoot, path } = opts;
|
||||
// 先把繼承來的 TEA_SDLC_* 清乾淨,測試結果才不會隨開發者的 shell 而變
|
||||
const inherited = Object.fromEntries(
|
||||
@@ -32,7 +70,7 @@ export function runScript(name, args = [], opts = {}) {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[join(repoRoot, 'scripts', name), ...args],
|
||||
[file, ...args],
|
||||
// 預設 1MB 會在長輸出時砍掉子行程,那是測試工具的限制而非腳本的問題
|
||||
{ cwd, env: childEnv, maxBuffer: 64 * 1024 * 1024 },
|
||||
(error, stdout, stderr) => {
|
||||
@@ -40,7 +78,6 @@ export function runScript(name, args = [], opts = {}) {
|
||||
code: typeof error?.code === 'number' ? error.code : error ? 1 : 0,
|
||||
stdout,
|
||||
stderr,
|
||||
json: parseSingleLine(stdout),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -48,7 +85,7 @@ export function runScript(name, args = [], opts = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 腳本的輸出契約是「單行 JSON」。這裡順便把契約本身斷言掉:
|
||||
* 「單行 JSON」的輸出契約。這裡順便把契約本身斷言掉:
|
||||
* 多印一行、印出非 JSON,都會在這裡就炸掉。
|
||||
*/
|
||||
function parseSingleLine(stdout) {
|
||||
|
||||
Reference in New Issue
Block a user