Merge pull request 'feat/npm-deploy-adapters/main' (#36) from feat/npm-deploy-adapters/main into master

Reviewed-on: #36
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #36.
This commit is contained in:
2026-09-17 07:03:35 +00:00
16 changed files with 1746 additions and 39 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "tea-sdlc",
"version": "0.0.1",
"description": "以 tea 驅動 SDLC 全流程的跨平台指令組(Claude Code / Codex / Antigravity / OpenCode / Copilot / Kiro / oh-my-pi)。流程正本為平台中立 markdown,由 install.js 產生各平台轉接檔。",
"description": "以 tea 驅動 SDLC 全流程的跨平台指令組(Claude Code / Codex / Antigravity / OpenCode / Copilot / Kiro / oh-my-pi)。流程正本為平台中立 markdown,由 tea-sdlc install 產生各平台轉接檔。",
"skills": "./skills",
"author": {
"name": "JSC"
+7 -3
View File
@@ -2,9 +2,12 @@
本 repo 提供六個顯式指令(`/sdlc-plan`、`/sdlc-analyze`、`/sdlc-feat`、`/sdlc-fix`、`/sdlc-sync`、`/sdlc-report`),
把軟體開發流程的各階段固定成可重複的步驟。流程正本只寫一份平台中立 markdown,
由 `install.js` 產生 Claude Code、Codex、Antigravity、Copilot、Kiro、oh-my-pi、OpenCode 各自的薄轉接檔。
由 `scripts/install.js` 產生 Claude Code、Codex、Antigravity、Copilot、Kiro、oh-my-pi、OpenCode 各自的薄轉接檔。
> 目前處於骨架階段:目錄已就位,六個指令與腳本尚未實作。進度見
本 repo 以 npm 佈署:`npm i -g <git url>` 裝出 `tea-sdlc` 指令,`tea-sdlc install` 產生各平台轉接檔。
轉接檔裡沒有路徑,只有一句 `tea-sdlc prompt --name <指令名>`,正本在哪由入口自己回推。
> 六個流程正本尚未到齊,安裝器只佈署 `prompts/` 裡已經存在的指令。進度見
> [議題 #1](https://gitea.jsc.idv.tw/plugins/tea-sdlc/issues/1) 底下的工作包。
## 模組邊界
@@ -17,7 +20,8 @@
| `scripts/` | 所有副作用(Gitea API、git、檔案系統)的唯一出口 | Node、零外部套件,僅用內建 `fetch` / `child_process` / `fs` |
| `templates/` | 所有產出格式(議題、PR、報表、總覽網頁) | 以 `{{變數}}` 佔位,不含邏輯。唯一例外是 `overview-artifact.html`:它是一份要在瀏覽器裡開的網頁,需要一段把 mermaid 圖畫出來的腳本 |
| `references/` | 規則正本(實作規範、註解格式對照表、可行性檢查清單) | 由流程正本指名讀取,不自行散落於 prompts |
| `install.js` | 平台偵測與轉接檔產生 | 唯一知道各平台目錄結構的地方 |
| `bin/tea-sdlc.js` | 指令入口:取走子指令,其餘 argv 原樣交出去 | 不含任何平台目錄知識,也不自己動手做事 |
| `scripts/install.js` | 平台偵測與轉接檔產生/移除 | 唯一知道各平台目錄結構的地方 |
| `skills/` | 各助理原生 plugin 機制讀取的 skills | 目前為空;指令以轉接檔形式佈署 |
## 慣例
+104 -11
View File
@@ -7,8 +7,9 @@
- **副作用集中**:所有對 Gitea 與 git 的呼叫下沉到 `scripts/` 的零相依 Node 腳本,統一 JSON 輸入輸出。
- **產出有固定形狀**:議題、PR、報表一律套 `templates/` 的模板。
> **目前處於骨架階段。** 目錄與 manifest 已就位,六個指令與腳本尚未實作。
> 完整需求見[議題 #1](https://gitea.jsc.idv.tw/plugins/tea-sdlc/issues/1),實作進度見其底下的工作包。
> **目前仍在實作中。** 安裝、佈署與腳本已經可用,六個流程正本尚未到齊——
> `tea-sdlc install` 只會佈署 `prompts/` 裡已經存在的指令。
> 完整需求見[議題 #1](https://gitea.jsc.idv.tw/plugins/tea-sdlc/issues/1),進度見其底下的工作包。
---
@@ -33,12 +34,13 @@ tea-sdlc/
├── scripts/ # 所有 Gitea / git 副作用的唯一出口(Node,零相依)
├── templates/ # 議題、PR、報表、總覽網頁的輸出模板
├── references/ # 規則正本:實作規範、註解格式、可行性檢查清單
├── bin/tea-sdlc.js # 單一指令入口:install / uninstall / prompt / status
├── skills/ # 各助理原生 plugin 機制讀取的 skills(目前為空)
├── .claude-plugin/ # Claude Code 的 plugin / marketplace manifest
├── .codex-plugin/ # Codex 的 plugin manifest
├── .agents/plugins/ # Codex 的 marketplace manifest
├── plugin.json # Antigravity 的 plugin manifest
├── package.json # 僅用於測試入口,無任何相依套件
├── package.json # npm 打包與測試入口,無任何相依套件
├── AGENTS.md # 給 AI 助理的模組邊界與慣例
└── README.md
```
@@ -57,12 +59,103 @@ tea-sdlc/
---
## 安裝 / 更新 / 移除(各助理)
## 安裝
> 以下為 plugin 機制的安裝方式。指令本身之後會改由 `install.js` 產生各平台轉接檔佈署,
> 屆時本節會一併更新。
一行裝好,終端機就多出一個 `tea-sdlc` 指令:
### Claude Code
```bash
npm i -g https://gitea.jsc.idv.tw/plugins/tea-sdlc.git
```
要裝特定版本就在網址後面接上 tag:`...tea-sdlc.git#v0.1.0`。不必設定任何 registry 或憑證。
接著把六個流程指令佈署到你裝了的每個 agent 平台:
```bash
tea-sdlc install
```
它會列出偵測到的平台讓你勾選(預設全勾),只寫進已經存在的平台目錄,不在沒裝的平台留下孤兒目錄。
沒有終端機可問時(CI、腳本)不會停下來等輸入,直接照預設全裝。
`--platform` 直接指定安裝對象,`--dry-run` 先看會動到哪些檔案:
```bash
tea-sdlc install --platform claude,codex
tea-sdlc install --dry-run
```
| 平台 | 偵測 | 轉接檔落點 | 形式 |
| --- | --- | --- | --- |
| `claude`(Claude Code) | `~/.claude/` | `~/.claude/commands/sdlc-*.md` | command |
| `codex`(Codex) | `~/.codex/` | `~/.codex/prompts/sdlc-*.md` | command |
| `opencode`(OpenCode) | `~/.config/opencode/` | `~/.config/opencode/command/sdlc-*.md` | command |
| `oh-my-pi` | `~/.omp/` | `~/.omp/commands/sdlc-*.md` | command |
| `antigravity`(Antigravity) | `~/.gemini/` | `~/.gemini/skills/sdlc-*/SKILL.md` | skill |
| `kiro`(Kiro) | `~/.kiro/` | `~/.kiro/skills/sdlc-*/SKILL.md` | skill |
| `copilot`(GitHub Copilot) | 專案裡的 `.github/` | `.github/skills/sdlc-*/SKILL.md` | skill |
轉接檔裡**沒有路徑**,只有一句「執行 `tea-sdlc prompt --name sdlc-plan`」。正本在哪由 PATH 上的
`tea-sdlc` 自己回推——升級 Node、換版本管理器、改 npm prefix 都不會讓七個平台的轉接檔同時失效。
還沒裝 `git` 或 [`tea`](https://gitea.com/gitea/tea) 也可以先裝:轉接檔的產生不需要它們,
安裝會把缺的東西列在輸出的 `missingBinaries` 與 `warning` 裡,但不會替你安裝,也不會因此中止。
真正需要它們的是流程指令本身,跑之前補上即可。
---
## 更新 / 移除
改流程正本、腳本、模板或規則,只要重裝套件,下一次叫用就讀到新的,**不必重新佈署**;
只有指令數量或轉接檔模板本身變了,才需要再跑一次 `tea-sdlc install`(轉接檔過時時,
叫用它會在輸出最前面提醒你)。
| 想做的事 | 指令 |
| --- | --- |
| 更新到最新版 | `npm i -g https://gitea.jsc.idv.tw/plugins/tea-sdlc.git` |
| 指令數量變了之後重新佈署 | `tea-sdlc install` |
| 查目前版本、正本位置與環境 | `tea-sdlc status` |
| 只移除轉接檔(正本不動) | `tea-sdlc uninstall` |
| 連套件一起移除 | `tea-sdlc uninstall` → `npm rm -g tea-sdlc` |
> **不要用 `npm update -g`。** 這個套件是從 git URL 裝的,`npm update -g` 認不得它的來源,
> 不會有任何更新發生,也不會報錯——重裝(上表第一行)才是更新的方式。
>
> **移除的順序不能顛倒。** 先 `npm rm -g tea-sdlc` 會把 `tea-sdlc` 指令一起帶走,
> 留在七個平台目錄裡的轉接檔就再也沒有東西能刪它們了,只能手動一個個找出來。
`tea-sdlc uninstall` 只刪自己產生的檔案:每份轉接檔裡都有產生標記,沒有標記的同名檔案一律留著,
並在輸出裡告訴你留了哪些。
---
## 給 agent 的入口
轉接檔指向的就是這一支。它把流程正本原樣印到 stdout,不包 JSON——那些內容是要給模型讀的。
```bash
tea-sdlc prompt --name sdlc-plan
```
其餘子指令與所有腳本一樣輸出單行 JSON `{ok, data, error:{code, message}}`,exit 0 或 1。
```bash
tea-sdlc status # 版本、正本位置、是否 link 模式、node/git/tea/登入、各平台轉接檔現況
```
`status` 在環境不健康時**指令本身仍算成功**(`ok` 為 `true`、`data.healthy` 為 `false`):
「查詢失敗」與「成功查到你環境有問題」的下一步完全不同,不該由同一個旗標表示。
---
## 附錄
### 其他安裝方式(plugin / marketplace 機制)
> 這些是各助理原生的 plugin / marketplace 機制。它們目前**裝不出任何指令**——`skills/` 還是空的,
> 真正會生效的是上面 `tea-sdlc install` 產生的轉接檔。保留在這裡是因為這些機制本身有用
> (自動更新、plugin 清單),等 `skills/` 有內容時會回來。
#### Claude Code
```bash
claude plugin marketplace add https://gitea.jsc.idv.tw/plugins/tea-sdlc.git
@@ -80,7 +173,7 @@ claude plugin marketplace remove tea-sdlc
工作階段內 slash 版(等價):把 `claude plugin` 換成 `/plugin`。
本機開發(免 push):`claude plugin marketplace add /root/plugins/tea-sdlc`(本地路徑)後再 install。
### Codex
#### Codex
```bash
codex plugin marketplace add https://gitea.jsc.idv.tw/plugins/tea-sdlc.git
@@ -96,7 +189,7 @@ codex plugin marketplace remove tea-sdlc
安裝 token `tea-sdlc@tea-sdlc` = plugin 名(`.codex-plugin/plugin.json` 的 `name`)@ marketplace 名(`.agents/plugins/marketplace.json` 的 `name`)。安裝後重啟 Codex。
### Antigravity(`agy`)
#### Antigravity(`agy`)
> `agy plugin install <url>` 目前只支援 github.com;Gitea 等自架 git 請先 clone 再用本地路徑安裝。
@@ -110,7 +203,7 @@ agy plugin uninstall tea-sdlc
agy plugin install ~/plugins/tea-sdlc
```
### GitHub Copilot CLI
#### GitHub Copilot CLI
```bash
copilot plugin marketplace add https://gitea.jsc.idv.tw/plugins/tea-sdlc.git
@@ -125,7 +218,7 @@ copilot plugin uninstall tea-sdlc@tea-sdlc
copilot plugin marketplace remove tea-sdlc
```
### OpenCode
#### OpenCode
OpenCode 的「plugin」是 TypeScript/npm 套件,不適用於本 repo;改用目錄安裝。
OpenCode 會讀 `~/.config/opencode/skills/`(也會讀 `~/.claude/skills/`、`~/.agents/skills/`)。
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env node
/**
* tea-sdlc 的單一入口。使用者與 agent 都從這裡進來:
*
* tea-sdlc install [--platform a,b] [--dry-run]
* tea-sdlc uninstall [--platform a,b] [--dry-run]
* tea-sdlc prompt --name sdlc-plan [--adapter-version 0.0.1]
* tea-sdlc status
*
* 這一層只做兩件事:把第一個位置參數當成子指令取走,其餘 argv 原樣交出去。
* 它不知道任何平台的目錄結構,也不自己動手做事——那些在 scripts/ 底下。
*
* 輸入輸出契約與 scripts/ 底下每一支腳本一致:只接受具名 flag、單行 JSON、exit 0 或 1。
* 唯一的例外是 prompt 的成功路徑,它印出原樣 markdown(理由見 lib 的 RawText)。
*/
import { ScriptError, main } from '../scripts/lib.js';
import { runInstall, runUninstall } from '../scripts/install.js';
import { runPrompt } from '../scripts/prompt.js';
import { runStatus } from '../scripts/status.js';
/** 子指令名 → 實作。名字就是使用者打的字,也是轉接檔裡寫的字。 */
const SUBCOMMANDS = {
install: runInstall,
uninstall: runUninstall,
prompt: runPrompt,
status: runStatus,
};
main(async () => {
const [name, ...argv] = process.argv.slice(2);
const names = Object.keys(SUBCOMMANDS).join('、');
if (name === undefined) {
throw new ScriptError('MISSING_SUBCOMMAND', `請指定子指令:${names}`);
}
// 位置參數只有這一個,而且必須在這裡就被取走:其後的 flag 解析一律拒絕位置參數
const run = SUBCOMMANDS[name];
if (run === undefined) {
throw new ScriptError('UNKNOWN_SUBCOMMAND', `不認得的子指令 ${name};可用的是:${names}`);
}
return run(argv);
});
+12 -1
View File
@@ -1,10 +1,21 @@
{
"name": "tea-sdlc",
"version": "0.0.1",
"private": true,
"type": "module",
"description": "以 tea 驅動 SDLC 全流程的跨平台指令組。",
"license": "MIT",
"bin": {
"tea-sdlc": "bin/tea-sdlc.js"
},
"files": [
"bin/",
"prompts/",
"scripts/",
"templates/",
"references/",
"skills/",
"AGENTS.md"
],
"engines": {
"node": ">=20"
},
+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');
}
+100
View File
@@ -0,0 +1,100 @@
/**
* 指令入口的契約:第一個位置參數是子指令,其餘 argv 原樣交給子指令。
*
* 入口是唯一收位置參數的地方——既有的 flag 解析明確拒絕位置參數,所以子指令必須
* 在交出去之前先被取走。這裡釘住的是「取走」這件事本身,以及取不到、取錯時的說法。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { repoRoot, manifest, runBin } from './helpers/run-script.js';
/** 入口必須認得的四個名字,缺一個就有轉接檔或文件會指向不存在的子指令 */
const SUBCOMMANDS = ['install', 'uninstall', 'prompt', 'status'];
// ── 子指令解析 ─────────────────────────────────────────────────────
test('未帶子指令時說清楚缺的是什麼,而不是印出用法就當作成功', async () => {
const { code, json } = await runBin([]);
assert.equal(code, 1);
assert.equal(json.ok, false);
assert.equal(json.error.code, 'MISSING_SUBCOMMAND');
// 訊息要列得出可用的名字,使用者才不用回頭翻文件
for (const name of SUBCOMMANDS) assert.match(json.error.message, new RegExp(name));
});
test('不認得的子指令與「flag 打錯」分得開', async () => {
const { code, json } = await runBin(['instal']);
assert.equal(code, 1);
assert.equal(json.error.code, 'UNKNOWN_SUBCOMMAND');
assert.match(json.error.message, /instal/);
});
test('四個子指令的名字都被認得,沒有一個掉進 UNKNOWN_SUBCOMMAND', async () => {
for (const name of SUBCOMMANDS) {
const { json } = await runBin([name, '--no-such-flag']);
assert.notEqual(
json.error?.code,
'UNKNOWN_SUBCOMMAND',
`子指令 ${name} 沒被入口認得`,
);
}
});
test('子指令之後的未知 flag 沿用既有的 UNKNOWN_FLAG,不另立一套', async () => {
const { code, json } = await runBin(['status', '--verbose']);
assert.equal(code, 1);
assert.equal(json.error.code, 'UNKNOWN_FLAG');
assert.match(json.error.message, /--verbose/);
});
test('子指令當成位置參數被取走,不會再被 flag 解析當成不認得的參數', async () => {
const { json } = await runBin(['status']);
assert.equal(json.ok, true);
});
// ── 打包 ───────────────────────────────────────────────────────────
test('manifest 可發佈:移除 private、有 bin、有 files 白名單,且沒有 prepare', () => {
const pkg = manifest();
assert.equal(pkg.private, undefined);
assert.equal(pkg.bin['tea-sdlc'], 'bin/tea-sdlc.js');
assert.ok(Array.isArray(pkg.files) && pkg.files.length > 0);
assert.equal(pkg.scripts.prepare, undefined);
});
test('零外部套件這條線沒有因為要發佈而鬆掉', () => {
const pkg = manifest();
assert.equal(pkg.dependencies, undefined);
assert.equal(pkg.devDependencies, undefined);
});
test('打包內容以白名單決定:四個正本目錄都在,測試與暫存都不在', () => {
const packed = JSON.parse(
execFileSync('npm', ['pack', '--dry-run', '--json'], { cwd: repoRoot, encoding: 'utf8' }),
);
const files = packed[0].files.map((file) => file.path);
for (const dir of ['prompts/', 'scripts/', 'templates/', 'references/', 'bin/']) {
assert.ok(
files.some((file) => file.startsWith(dir)),
`打包內容少了 ${dir}`,
);
}
for (const dir of ['test/', '.tmp/']) {
assert.equal(
files.some((file) => file.startsWith(dir)),
false,
`${dir} 不該被發給使用者`,
);
}
});
+48
View File
@@ -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 }) };
}
+44 -7
View File
@@ -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) {
+412
View File
@@ -0,0 +1,412 @@
/**
* 轉接檔的產生與移除。
*
* 全部在臨時家目錄上跑:真的寫檔、真的刪檔,然後斷言家目錄裡剩下什麼。
* 只驗「函式有沒有被呼叫」不會發現多建了一層目錄或少刪了一個檔,而那正是這支腳本
* 唯一會出的錯。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { pathWithOnly, tmpRoot } from './helpers/run-script.js';
import { fakePrompt, makeFakePlugin } from './helpers/fake-plugin.js';
/** 七個平台的偵測目錄,相對於家目錄;copilot 那個是相對於工作目錄 */
const DETECT = {
claude: '.claude',
codex: '.codex',
opencode: '.config/opencode',
'oh-my-pi': '.omp',
antigravity: '.gemini',
kiro: '.kiro',
};
const PROMPTS = { 'sdlc-plan': fakePrompt('sdlc-plan'), 'sdlc-feat': fakePrompt('sdlc-feat') };
/**
* 一個臨時家目錄,只「裝了」指定的平台。
* @param {string[]} platforms 要建出偵測目錄的平台名;'copilot' 建在工作目錄底下
*/
function makeHome(t, platforms = []) {
mkdirSync(tmpRoot, { recursive: true });
const home = mkdtempSync(join(tmpRoot, 'home-'));
t.after(() => rmSync(home, { recursive: true, force: true }));
for (const name of platforms) {
const dir = name === 'copilot' ? join(home, 'work', '.github') : join(home, DETECT[name]);
mkdirSync(dir, { recursive: true });
}
mkdirSync(join(home, 'work'), { recursive: true });
return home;
}
/** 從臨時家目錄執行入口:HOME 指過去,cwd 指到它底下的 work/(copilot 的 .github 在那) */
const inHome = (plugin, home) => (args) =>
plugin.run(args, { env: { HOME: home }, cwd: join(home, 'work') });
/** 列出家目錄底下所有檔案的相對路徑 */
function filesUnder(dir, prefix = '') {
const out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) out.push(...filesUnder(join(dir, entry.name), rel));
else out.push(rel);
}
return out;
}
// ── 偵測 ───────────────────────────────────────────────────────────
test('只裝到偵測得到的平台,沒裝的平台不留下孤兒目錄', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude', 'kiro']);
const { code, json } = await inHome(plugin, home)(['install']);
assert.equal(code, 0);
assert.deepEqual(json.data.platforms.map((p) => p.name).sort(), ['claude', 'kiro']);
assert.ok(existsSync(join(home, '.claude', 'commands', 'sdlc-plan.md')));
assert.equal(existsSync(join(home, '.codex')), false);
assert.equal(existsSync(join(home, '.config')), false);
});
test('一個平台都沒偵測到時明講,而不是回報裝了零個當作成功', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, []);
const { code, json } = await inHome(plugin, home)(['install']);
assert.equal(code, 1);
assert.equal(json.error.code, 'NO_PLATFORM_DETECTED');
});
test('status 的 platforms 欄位報得出偵測結果、轉接檔數與過時狀態', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
await inHome(plugin, home)(['install']);
const { json } = await inHome(plugin, home)(['status']);
const byName = Object.fromEntries(json.data.platforms.map((p) => [p.name, p]));
assert.equal(byName.claude.detected, true);
assert.equal(byName.claude.dir, join(home, '.claude', 'commands'));
assert.deepEqual(byName.claude.adapters, { expected: 2, present: 2, stale: false });
assert.equal(byName.codex.detected, false);
assert.deepEqual(byName.codex.adapters, { expected: 2, present: 0, stale: false });
});
test('少裝了指令時 expected 與 present 對不起來,看得出來還有東西沒佈署', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
await inHome(plugin, home)(['install']);
rmSync(join(home, '.claude', 'commands', 'sdlc-feat.md'));
const { json } = await inHome(plugin, home)(['status']);
const claude = json.data.platforms.find((p) => p.name === 'claude');
assert.equal(claude.adapters.expected, 2);
assert.equal(claude.adapters.present, 1);
});
test('轉接檔版本與套件不符時,status 把該平台標成過時', async (t) => {
const old = makeFakePlugin(t, { prompts: PROMPTS, version: '0.0.1' });
const home = makeHome(t, ['claude']);
await inHome(old, home)(['install']);
const next = makeFakePlugin(t, { prompts: PROMPTS, version: '0.2.0' });
const { json } = await inHome(next, home)(['status']);
const claude = json.data.platforms.find((p) => p.name === 'claude');
assert.equal(claude.adapters.stale, true);
assert.equal(claude.adapters.present, 2);
});
// ── 指定安裝對象 ───────────────────────────────────────────────────
test('--platform a,b 可非互動指定安裝對象', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude', 'kiro', 'codex']);
const { json } = await inHome(plugin, home)(['install', '--platform', 'claude,codex']);
assert.deepEqual(json.data.platforms.map((p) => p.name), ['claude', 'codex']);
assert.equal(existsSync(join(home, '.kiro', 'skills')), false);
});
test('--platform 給了不存在的平台名時列出可用的名字', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
const { json } = await inHome(plugin, home)(['install', '--platform', 'vscode']);
assert.equal(json.error.code, 'UNKNOWN_PLATFORM');
assert.match(json.error.message, /vscode/);
assert.match(json.error.message, /claude/);
});
test('--platform 指名一個沒裝的平台時擋下,不替它建目錄', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
const { json } = await inHome(plugin, home)(['install', '--platform', 'kiro']);
assert.equal(json.error.code, 'PLATFORM_NOT_DETECTED');
assert.equal(existsSync(join(home, '.kiro')), false);
});
// ── 轉接檔內容 ─────────────────────────────────────────────────────
test('支援 command 的四個平台產生 command 轉接檔,另外三個產生 SKILL.md', async (t) => {
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': fakePrompt('sdlc-plan') } });
const home = makeHome(t, ['claude', 'codex', 'opencode', 'oh-my-pi', 'antigravity', 'kiro', 'copilot']);
const { json } = await inHome(plugin, home)(['install']);
assert.equal(json.data.platforms.length, 7);
assert.deepEqual(filesUnder(join(home, '.claude')), ['commands/sdlc-plan.md']);
assert.deepEqual(filesUnder(join(home, '.codex')), ['prompts/sdlc-plan.md']);
assert.deepEqual(filesUnder(join(home, '.config', 'opencode')), ['command/sdlc-plan.md']);
assert.deepEqual(filesUnder(join(home, '.omp')), ['commands/sdlc-plan.md']);
assert.deepEqual(filesUnder(join(home, '.gemini')), ['skills/sdlc-plan/SKILL.md']);
assert.deepEqual(filesUnder(join(home, '.kiro')), ['skills/sdlc-plan/SKILL.md']);
assert.deepEqual(filesUnder(join(home, 'work', '.github')), ['skills/sdlc-plan/SKILL.md']);
});
test('轉接檔內容是一句指向 tea-sdlc 的話,不含任何檔案路徑', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS, version: '1.2.3' });
const home = makeHome(t, ['claude']);
await inHome(plugin, home)(['install']);
const text = readFileSync(join(home, '.claude', 'commands', 'sdlc-plan.md'), 'utf8');
assert.match(text, /tea-sdlc prompt --name sdlc-plan --adapter-version 1\.2\.3/);
// 路徑一旦寫進去,升一次 Node 就會同時指向不存在的檔案,而且不會有任何錯誤訊息
assert.equal(text.includes(plugin.root), false);
assert.equal(text.includes('prompts/sdlc-plan.md'), false);
assert.equal(/(^|\s)\/\w/.test(text.replace(/\/sdlc-\S+/g, '')), false, '轉接檔裡不該有絕對路徑');
});
test('description 前綴一律是「僅由 /sdlc-xxx 指令叫用。」,從正本抄過來而不是另寫一份', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude', 'kiro']);
await inHome(plugin, home)(['install']);
for (const path of [
join(home, '.claude', 'commands', 'sdlc-plan.md'),
join(home, '.kiro', 'skills', 'sdlc-plan', 'SKILL.md'),
]) {
const description = readFileSync(path, 'utf8').match(/^description:\s*(.+)$/m)[1];
assert.ok(description.startsWith('僅由 /sdlc-plan 指令叫用。'), `${path}:${description}`);
}
});
test('支援關閉自動觸發的 command 平台設上旗標;三個 skill 平台沒有那個旗標可設', async (t) => {
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': fakePrompt('sdlc-plan') } });
const home = makeHome(t, ['claude', 'codex', 'opencode', 'oh-my-pi', 'antigravity', 'kiro', 'copilot']);
await inHome(plugin, home)(['install']);
const commands = [
join(home, '.claude', 'commands', 'sdlc-plan.md'),
join(home, '.codex', 'prompts', 'sdlc-plan.md'),
join(home, '.config', 'opencode', 'command', 'sdlc-plan.md'),
join(home, '.omp', 'commands', 'sdlc-plan.md'),
];
const skills = [
join(home, '.gemini', 'skills', 'sdlc-plan', 'SKILL.md'),
join(home, '.kiro', 'skills', 'sdlc-plan', 'SKILL.md'),
join(home, 'work', '.github', 'skills', 'sdlc-plan', 'SKILL.md'),
];
for (const path of commands) {
assert.match(readFileSync(path, 'utf8'), /^disable-model-invocation: true$/m, path);
}
for (const path of skills) {
assert.equal(readFileSync(path, 'utf8').includes('disable-model-invocation'), false, path);
}
});
test('重跑安裝是覆蓋而不是疊加,也不會多留一份舊檔', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
await inHome(plugin, home)(['install']);
await inHome(plugin, home)(['install']);
assert.deepEqual(filesUnder(join(home, '.claude')).sort(), [
'commands/sdlc-feat.md',
'commands/sdlc-plan.md',
]);
});
test('正本的 description 前綴不對時擋下,不讓它變成一份會被誤觸的轉接檔', async (t) => {
const plugin = makeFakePlugin(t, {
prompts: { 'sdlc-plan': 'name: sdlc-plan\ndescription: 把需求變成議題。\n\n# sdlc-plan\n' },
});
const home = makeHome(t, ['claude']);
const { code, json } = await inHome(plugin, home)(['install']);
assert.equal(code, 1);
assert.equal(json.error.code, 'PROMPT_BAD_DESCRIPTION');
assert.match(json.error.message, /僅由 \/sdlc-plan 指令叫用。/);
assert.equal(existsSync(join(home, '.claude', 'commands')), false);
});
// ── 勾選 ───────────────────────────────────────────────────────────
test('列出來的每一項預設都是勾起來的,編號從 1 開始', async () => {
const { checklist } = await import('../scripts/install.js');
const lines = checklist([
{ name: 'claude', label: 'Claude Code' },
{ name: 'kiro', label: 'Kiro' },
]);
assert.deepEqual(lines, [
' [x] 1. Claude Code(claude)',
' [x] 2. Kiro(kiro)',
]);
});
test('直接按 Enter 就是全裝', async () => {
const { selectPlatforms } = await import('../scripts/install.js');
const detected = [{ name: 'claude' }, { name: 'kiro' }];
assert.deepEqual(selectPlatforms(detected, ''), detected);
assert.deepEqual(selectPlatforms(detected, ' \n'), detected);
});
test('勾選可以打編號也可以打名稱,順序以列出來的為準', async () => {
const { selectPlatforms } = await import('../scripts/install.js');
const detected = [{ name: 'claude' }, { name: 'kiro' }, { name: 'codex' }];
assert.deepEqual(selectPlatforms(detected, '2,1').map((p) => p.name), ['claude', 'kiro']);
assert.deepEqual(selectPlatforms(detected, 'codex').map((p) => p.name), ['codex']);
});
test('勾了沒列出來的東西時當場說,不要默默少裝一個', async () => {
const { selectPlatforms } = await import('../scripts/install.js');
assert.throws(
() => selectPlatforms([{ name: 'claude' }], '9'),
(error) => error.code === 'UNKNOWN_PLATFORM',
);
});
// ── 移除 ───────────────────────────────────────────────────────────
test('uninstall 之後家目錄裡沒有任何殘留的轉接檔', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude', 'kiro']);
await inHome(plugin, home)(['install']);
const { code, json } = await inHome(plugin, home)(['uninstall']);
assert.equal(code, 0);
assert.equal(json.data.removed.length, 4);
assert.deepEqual(filesUnder(join(home, '.claude')), []);
assert.deepEqual(filesUnder(join(home, '.kiro')), []);
// 平台自己的目錄不是我們建的東西,留著
assert.ok(existsSync(join(home, '.claude')));
});
test('uninstall 不動流程正本', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
await inHome(plugin, home)(['install']);
await inHome(plugin, home)(['uninstall']);
assert.equal(readFileSync(join(plugin.root, 'prompts', 'sdlc-plan.md'), 'utf8'), PROMPTS['sdlc-plan']);
assert.deepEqual(readdirSync(join(plugin.root, 'prompts')).sort(), ['sdlc-feat.md', 'sdlc-plan.md']);
});
test('不是 tea-sdlc 產生的同名檔案一律留著,並在輸出裡交代為什麼', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
await inHome(plugin, home)(['install']);
const mine = join(home, '.claude', 'commands', 'sdlc-plan.md');
writeFileSync(mine, '# 我自己寫的,不要刪\n');
const { json } = await inHome(plugin, home)(['uninstall']);
assert.equal(readFileSync(mine, 'utf8'), '# 我自己寫的,不要刪\n');
assert.equal(json.data.kept.length, 1);
assert.match(json.data.kept[0].path, /sdlc-plan\.md$/);
});
test('舊版留下、正本已經不存在的轉接檔也一起移除', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
await inHome(plugin, home)(['install']);
// 模擬上一版裝過、這一版已經沒有的指令
const orphan = join(home, '.claude', 'commands', 'sdlc-gone.md');
writeFileSync(orphan, readFileSync(join(home, '.claude', 'commands', 'sdlc-plan.md'), 'utf8'));
await inHome(plugin, home)(['uninstall']);
assert.equal(existsSync(orphan), false);
});
test('--dry-run 列出會動到哪些檔案,但一個字都不寫', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
const { json } = await inHome(plugin, home)(['install', '--dry-run']);
assert.equal(json.data.dryRun, true);
assert.equal(json.data.platforms[0].adapters.length, 2);
assert.equal(existsSync(join(home, '.claude', 'commands')), false);
});
// ── 執行環境 ───────────────────────────────────────────────────────
test('缺少 tea 時照樣把轉接檔裝好,但把缺的東西講出來', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
const { code, json } = await plugin.run(['install'], {
env: { HOME: home },
cwd: join(home, 'work'),
path: pathWithOnly(['node', 'git']),
});
// 缺 tea 完全不影響轉接檔產生,硬擋等於逼使用者為了裝 plugin 先去裝 tea
assert.equal(code, 0);
assert.deepEqual(json.data.missingBinaries, ['tea']);
assert.match(json.data.warning, /tea/);
assert.match(json.data.warning, /gitea\.com\/gitea\/tea/);
assert.ok(existsSync(join(home, '.claude', 'commands', 'sdlc-plan.md')));
});
test('該裝的都在時不留下沒有意義的警告', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
const { json } = await inHome(plugin, home)(['install']);
assert.deepEqual(json.data.missingBinaries, []);
assert.equal(json.data.warning, null);
});
test('安裝不會替使用者裝任何東西:從頭到尾沒有跑過套件管理器', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t, ['claude']);
const { stderr } = await plugin.run(['install'], {
env: { HOME: home },
cwd: join(home, 'work'),
// PATH 上只有 node:真要自動安裝什麼,這裡就會失敗而不是靜靜跳過
path: pathWithOnly(['node']),
});
assert.equal(stderr, '');
});
+143
View File
@@ -0,0 +1,143 @@
/**
* prompt 子指令:agent 讀到轉接檔之後,靠它拿到流程正本的原文。
*
* 這是全專案唯一輸出非 JSON 的路徑,所以這裡釘的第一件事就是「逐字」——
* 多一個換行、少一個空白,都會讓下游模型讀到的東西與正本不同。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { manifest, repoRoot, runBin } from './helpers/run-script.js';
import { fakePrompt, makeFakePlugin } from './helpers/fake-plugin.js';
const version = () => manifest().version;
// ── 成功路徑:原樣輸出 ─────────────────────────────────────────────
test('把流程正本逐字印出來,不包 JSON、不補字元', async (t) => {
const text = fakePrompt('sdlc-plan');
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': text } });
const { code, raw } = await plugin.run(['prompt', '--name', 'sdlc-plan']);
assert.equal(code, 0);
assert.equal(raw, text);
});
test('輸出允許多行,且不是 JSON', async (t) => {
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': fakePrompt('sdlc-plan') } });
const { raw } = await plugin.run(['prompt', '--name', 'sdlc-plan']);
assert.ok(raw.split('\n').length > 3);
assert.throws(() => JSON.parse(raw.trim()));
});
test('真正的流程正本也走得通,輸出逐字等於檔案內容', async () => {
const path = join(repoRoot, 'prompts', 'sdlc-plan.md');
const { code, raw } = await runBin(['prompt', '--name', 'sdlc-plan']);
assert.equal(code, 0);
assert.equal(raw, readFileSync(path, 'utf8'));
});
// ── 失敗路徑:仍走單行 JSON ────────────────────────────────────────
test('指令不存在時回可區分的錯誤碼,而不是印出空內容當成成功', async (t) => {
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': fakePrompt('sdlc-plan') } });
const { code, json } = await plugin.run(['prompt', '--name', 'sdlc-nope']);
assert.equal(code, 1);
assert.equal(json.ok, false);
assert.equal(json.error.code, 'PROMPT_NOT_FOUND');
assert.match(json.error.message, /sdlc-nope/);
});
test('未帶 --name 時沿用既有的 MISSING_FLAG', async (t) => {
const plugin = makeFakePlugin(t);
const { code, json } = await plugin.run(['prompt']);
assert.equal(code, 1);
assert.equal(json.error.code, 'MISSING_FLAG');
assert.match(json.error.message, /--name/);
});
test('--name 不能拿來跳出 prompts 目錄', async (t) => {
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': fakePrompt('sdlc-plan') } });
const { code, json } = await plugin.run(['prompt', '--name', '../package.json']);
assert.equal(code, 1);
assert.equal(json.error.code, 'BAD_PROMPT_NAME');
});
test('plugin 目錄不完整時回既有的 PLUGIN_LAYOUT_BROKEN', async (t) => {
const plugin = makeFakePlugin(t, { omit: ['templates'] });
const { code, json } = await plugin.run(['prompt', '--name', 'sdlc-plan']);
assert.equal(code, 1);
assert.equal(json.error.code, 'PLUGIN_LAYOUT_BROKEN');
assert.match(json.error.message, /templates/);
});
// ── 轉接檔版本比對 ─────────────────────────────────────────────────
test('版本相符時的輸出與不帶該 flag 時完全一致', async (t) => {
const text = fakePrompt('sdlc-plan');
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': text }, version: '9.9.9' });
const matched = await plugin.run(['prompt', '--name', 'sdlc-plan', '--adapter-version', '9.9.9']);
const plain = await plugin.run(['prompt', '--name', 'sdlc-plan']);
assert.equal(matched.raw, plain.raw);
assert.equal(matched.raw, text);
});
test('版本不符時第一行是重跑 install 的警告,其後逐字等於正本原文', async (t) => {
const text = fakePrompt('sdlc-plan');
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': text }, version: '9.9.9' });
const { code, raw } = await plugin.run([
'prompt', '--name', 'sdlc-plan', '--adapter-version', '0.0.1',
]);
// 正本仍然要給:過時的轉接檔不該讓流程停擺
assert.equal(code, 0);
const [first, ...rest] = raw.split('\n');
assert.match(first, /tea-sdlc install/);
assert.match(first, /0\.0\.1/);
assert.match(first, /9\.9\.9/);
assert.equal(rest.join('\n').trimStart(), text);
assert.ok(raw.endsWith(text));
});
test('未帶 --adapter-version 時不產生任何警告', async (t) => {
const text = fakePrompt('sdlc-plan');
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': text } });
const { raw } = await plugin.run(['prompt', '--name', 'sdlc-plan']);
assert.equal(raw.includes('tea-sdlc install'), false);
});
test('比對用的版本只有 manifest 一個來源,沒有另外寫死一份', async (t) => {
const plugin = makeFakePlugin(t, {
prompts: { 'sdlc-plan': fakePrompt('sdlc-plan') },
version: '7.7.7',
});
const { raw } = await plugin.run([
'prompt', '--name', 'sdlc-plan', '--adapter-version', version(),
]);
// 假根的版本被改過,帶上真 repo 的版本就該被判為不符
assert.match(raw.split('\n')[0], /7\.7\.7/);
});
+80
View File
@@ -0,0 +1,80 @@
/**
* README 的安裝段。
*
* 這一段是使用者照著打的東西,打錯一個字就裝不起來,所以指令要能逐字複製執行,
* 而且不能留下已經不成立的說法——並列一條「照著做不會出現任何指令」的流程等於在說謊。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { repoRoot } from './helpers/run-script.js';
const readme = () => readFileSync(join(repoRoot, 'README.md'), 'utf8');
const agents = () => readFileSync(join(repoRoot, 'AGENTS.md'), 'utf8');
/** 取出所有 bash 圍欄裡的指令行 */
function commands(text) {
return [...text.matchAll(/```bash\n([\s\S]*?)```/g)]
.flatMap((block) => block[1].split('\n'))
.map((line) => line.trim())
.filter((line) => line !== '' && !line.startsWith('#'));
}
test('安裝以 npm 為唯一建議路徑,指令含完整可複製的 git URL', () => {
const install = commands(readme()).filter((line) => line.startsWith('npm i -g'));
assert.equal(install.length > 0, true, 'README 沒有 npm 安裝指令');
assert.ok(
install.some((line) => line.includes('https://gitea.jsc.idv.tw/plugins/tea-sdlc.git')),
`安裝指令少了完整 git URL:${install.join(' / ')}`,
);
});
test('更新與移除各有完整指令表,順序講清楚先 uninstall 再 npm rm', () => {
const text = readme();
assert.match(text, /npm i -g https:\/\/gitea\.jsc\.idv\.tw\/plugins\/tea-sdlc\.git/);
assert.match(text, /tea-sdlc uninstall/);
assert.match(text, /npm rm -g tea-sdlc/);
// 先砍套件就再也刪不掉那些孤兒轉接檔,順序本身就是內容
assert.ok(
text.indexOf('tea-sdlc uninstall') < text.indexOf('npm rm -g tea-sdlc'),
'uninstall 必須寫在 npm rm -g 之前',
);
});
test('明寫不要用 npm update -g,並說出為什麼', () => {
const text = readme();
assert.match(text, /npm update -g/);
const at = text.indexOf('npm update -g');
assert.match(text.slice(at - 200, at + 200), /不要|別/);
});
test('四個子指令在 README 上都有用途說明', () => {
const text = readme();
for (const name of ['install', 'uninstall', 'prompt', 'status']) {
assert.match(text, new RegExp(`tea-sdlc ${name}`), `README 沒有交代 ${name}`);
}
});
test('七套 marketplace 流程移進附錄保留,內容沒有被刪掉', () => {
const text = readme();
const appendix = text.slice(text.indexOf('## 附錄'));
assert.ok(text.includes('## 附錄'), 'README 沒有附錄');
for (const line of ['claude plugin marketplace add', 'codex plugin marketplace add', 'agy plugin install']) {
assert.ok(appendix.includes(line), `附錄少了 ${line}`);
}
});
test('模組邊界表指得到實際存在的檔案', () => {
const text = agents();
assert.match(text, /`bin\/tea-sdlc\.js`/);
assert.match(text, /`scripts\/install\.js`/);
assert.equal(/\| `install\.js` \|/.test(text), false, '表上還留著已經不存在的根目錄 install.js');
});
+92
View File
@@ -0,0 +1,92 @@
/**
* status 子指令:在出事之前先知道自己是什麼狀態。
*
* 這裡最要緊的一條是 ok 與健康狀態分離:ok 的意思一直是「這支指令跑成功了」,
* 不兼差表達環境好壞。混用的話呼叫端就分不出「status 掛了」與「status 成功查到
* 你環境有問題」,而這兩件事的下一步完全不同。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { manifest, pathWithOnly, repoRoot, runBin } from './helpers/run-script.js';
import { healthyRoutes, stubEnv, withStubGitea } from './helpers/stub-gitea.js';
const REPO = 'plugins/tea-sdlc';
const version = () => manifest().version;
test('回報版本、正本位置、是不是 link 模式、健康旗標與環境四項', async (t) => {
const stub = await withStubGitea(t, healthyRoutes(REPO));
const { code, json } = await runBin(['status'], { env: stubEnv(stub) });
assert.equal(code, 0);
assert.equal(json.ok, true);
assert.equal(json.data.version, version());
assert.equal(json.data.root, repoRoot);
assert.equal(json.data.healthy, true);
assert.deepEqual(Object.keys(json.data.environment).sort(), ['git', 'login', 'node', 'tea']);
});
test('從 working tree 跑的時候是 link 模式,root 指向 working tree', async (t) => {
const stub = await withStubGitea(t, healthyRoutes(REPO));
const { json } = await runBin(['status'], { env: stubEnv(stub) });
assert.equal(json.data.linked, true);
assert.equal(json.data.root.includes('node_modules'), false);
});
test('缺少 git 或 tea 時對應欄位為 false,健康旗標跟著倒下', async (t) => {
const stub = await withStubGitea(t, healthyRoutes(REPO));
const { code, json } = await runBin(['status'], {
env: stubEnv(stub),
path: pathWithOnly(['node']),
});
// 查詢本身仍然成功——它成功查到你的環境有問題
assert.equal(code, 0);
assert.equal(json.ok, true);
assert.equal(json.data.environment.node, true);
assert.equal(json.data.environment.git, false);
assert.equal(json.data.environment.tea, false);
assert.equal(json.data.healthy, false);
});
test('Gitea 登入失效時 ok 仍為 true,healthy 為 false', async (t) => {
const stub = await withStubGitea(t, {
...healthyRoutes(REPO),
'GET /api/v1/user': { status: 401, body: { message: 'token expired' } },
});
const { code, json } = await runBin(['status'], { env: stubEnv(stub) });
assert.equal(code, 0);
assert.equal(json.ok, true);
assert.equal(json.data.environment.login, false);
assert.equal(json.data.healthy, false);
});
test('登入檢查走既有的單一 HTTP 出口,打的是 /user', async (t) => {
const stub = await withStubGitea(t, healthyRoutes(REPO));
await runBin(['status'], { env: stubEnv(stub) });
assert.deepEqual(
stub.requests.map((request) => `${request.method} ${request.path}`),
['GET /api/v1/user'],
);
assert.equal(stub.requests[0].authorization, 'token stub-token');
});
test('根本還沒登入時也只是 login 為 false,不會讓整支指令失敗', async (t) => {
const { code, json } = await runBin(['status'], {
env: { TEA_SDLC_CONFIG: join(repoRoot, '.tmp', 'no-such-tea-config.yml') },
});
assert.equal(code, 0);
assert.equal(json.ok, true);
assert.equal(json.data.environment.login, false);
assert.equal(json.data.healthy, false);
});