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 06:59:37 +00:00
co-authored by Claude Opus 5
parent f736d11049
commit 17178f0c4d
16 changed files with 1746 additions and 39 deletions
+438
View File
@@ -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
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 {
+54
View File
@@ -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}`,
);
}
+68
View File
@@ -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');
}