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:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "tea-sdlc",
|
"name": "tea-sdlc",
|
||||||
"version": "0.0.1",
|
"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",
|
"skills": "./skills",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "JSC"
|
"name": "JSC"
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
本 repo 提供六個顯式指令(`/sdlc-plan`、`/sdlc-analyze`、`/sdlc-feat`、`/sdlc-fix`、`/sdlc-sync`、`/sdlc-report`),
|
本 repo 提供六個顯式指令(`/sdlc-plan`、`/sdlc-analyze`、`/sdlc-feat`、`/sdlc-fix`、`/sdlc-sync`、`/sdlc-report`),
|
||||||
把軟體開發流程的各階段固定成可重複的步驟。流程正本只寫一份平台中立 markdown,
|
把軟體開發流程的各階段固定成可重複的步驟。流程正本只寫一份平台中立 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) 底下的工作包。
|
> [議題 #1](https://gitea.jsc.idv.tw/plugins/tea-sdlc/issues/1) 底下的工作包。
|
||||||
|
|
||||||
## 模組邊界
|
## 模組邊界
|
||||||
@@ -17,7 +20,8 @@
|
|||||||
| `scripts/` | 所有副作用(Gitea API、git、檔案系統)的唯一出口 | Node、零外部套件,僅用內建 `fetch` / `child_process` / `fs` |
|
| `scripts/` | 所有副作用(Gitea API、git、檔案系統)的唯一出口 | Node、零外部套件,僅用內建 `fetch` / `child_process` / `fs` |
|
||||||
| `templates/` | 所有產出格式(議題、PR、報表、總覽網頁) | 以 `{{變數}}` 佔位,不含邏輯。唯一例外是 `overview-artifact.html`:它是一份要在瀏覽器裡開的網頁,需要一段把 mermaid 圖畫出來的腳本 |
|
| `templates/` | 所有產出格式(議題、PR、報表、總覽網頁) | 以 `{{變數}}` 佔位,不含邏輯。唯一例外是 `overview-artifact.html`:它是一份要在瀏覽器裡開的網頁,需要一段把 mermaid 圖畫出來的腳本 |
|
||||||
| `references/` | 規則正本(實作規範、註解格式對照表、可行性檢查清單) | 由流程正本指名讀取,不自行散落於 prompts |
|
| `references/` | 規則正本(實作規範、註解格式對照表、可行性檢查清單) | 由流程正本指名讀取,不自行散落於 prompts |
|
||||||
| `install.js` | 平台偵測與轉接檔產生 | 唯一知道各平台目錄結構的地方 |
|
| `bin/tea-sdlc.js` | 指令入口:取走子指令,其餘 argv 原樣交出去 | 不含任何平台目錄知識,也不自己動手做事 |
|
||||||
|
| `scripts/install.js` | 平台偵測與轉接檔產生/移除 | 唯一知道各平台目錄結構的地方 |
|
||||||
| `skills/` | 各助理原生 plugin 機制讀取的 skills | 目前為空;指令以轉接檔形式佈署 |
|
| `skills/` | 各助理原生 plugin 機制讀取的 skills | 目前為空;指令以轉接檔形式佈署 |
|
||||||
|
|
||||||
## 慣例
|
## 慣例
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
- **副作用集中**:所有對 Gitea 與 git 的呼叫下沉到 `scripts/` 的零相依 Node 腳本,統一 JSON 輸入輸出。
|
- **副作用集中**:所有對 Gitea 與 git 的呼叫下沉到 `scripts/` 的零相依 Node 腳本,統一 JSON 輸入輸出。
|
||||||
- **產出有固定形狀**:議題、PR、報表一律套 `templates/` 的模板。
|
- **產出有固定形狀**:議題、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,零相依)
|
├── scripts/ # 所有 Gitea / git 副作用的唯一出口(Node,零相依)
|
||||||
├── templates/ # 議題、PR、報表、總覽網頁的輸出模板
|
├── templates/ # 議題、PR、報表、總覽網頁的輸出模板
|
||||||
├── references/ # 規則正本:實作規範、註解格式、可行性檢查清單
|
├── references/ # 規則正本:實作規範、註解格式、可行性檢查清單
|
||||||
|
├── bin/tea-sdlc.js # 單一指令入口:install / uninstall / prompt / status
|
||||||
├── skills/ # 各助理原生 plugin 機制讀取的 skills(目前為空)
|
├── skills/ # 各助理原生 plugin 機制讀取的 skills(目前為空)
|
||||||
├── .claude-plugin/ # Claude Code 的 plugin / marketplace manifest
|
├── .claude-plugin/ # Claude Code 的 plugin / marketplace manifest
|
||||||
├── .codex-plugin/ # Codex 的 plugin manifest
|
├── .codex-plugin/ # Codex 的 plugin manifest
|
||||||
├── .agents/plugins/ # Codex 的 marketplace manifest
|
├── .agents/plugins/ # Codex 的 marketplace manifest
|
||||||
├── plugin.json # Antigravity 的 plugin manifest
|
├── plugin.json # Antigravity 的 plugin manifest
|
||||||
├── package.json # 僅用於測試入口,無任何相依套件
|
├── package.json # npm 打包與測試入口,無任何相依套件
|
||||||
├── AGENTS.md # 給 AI 助理的模組邊界與慣例
|
├── AGENTS.md # 給 AI 助理的模組邊界與慣例
|
||||||
└── README.md
|
└── 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
|
```bash
|
||||||
claude plugin marketplace add https://gitea.jsc.idv.tw/plugins/tea-sdlc.git
|
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`。
|
工作階段內 slash 版(等價):把 `claude plugin` 換成 `/plugin`。
|
||||||
本機開發(免 push):`claude plugin marketplace add /root/plugins/tea-sdlc`(本地路徑)後再 install。
|
本機開發(免 push):`claude plugin marketplace add /root/plugins/tea-sdlc`(本地路徑)後再 install。
|
||||||
|
|
||||||
### Codex
|
#### Codex
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
codex plugin marketplace add https://gitea.jsc.idv.tw/plugins/tea-sdlc.git
|
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。
|
安裝 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 再用本地路徑安裝。
|
> `agy plugin install <url>` 目前只支援 github.com;Gitea 等自架 git 請先 clone 再用本地路徑安裝。
|
||||||
|
|
||||||
@@ -110,7 +203,7 @@ agy plugin uninstall tea-sdlc
|
|||||||
agy plugin install ~/plugins/tea-sdlc
|
agy plugin install ~/plugins/tea-sdlc
|
||||||
```
|
```
|
||||||
|
|
||||||
### GitHub Copilot CLI
|
#### GitHub Copilot CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
copilot plugin marketplace add https://gitea.jsc.idv.tw/plugins/tea-sdlc.git
|
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
|
copilot plugin marketplace remove tea-sdlc
|
||||||
```
|
```
|
||||||
|
|
||||||
### OpenCode
|
#### OpenCode
|
||||||
|
|
||||||
OpenCode 的「plugin」是 TypeScript/npm 套件,不適用於本 repo;改用目錄安裝。
|
OpenCode 的「plugin」是 TypeScript/npm 套件,不適用於本 repo;改用目錄安裝。
|
||||||
OpenCode 會讀 `~/.config/opencode/skills/`(也會讀 `~/.claude/skills/`、`~/.agents/skills/`)。
|
OpenCode 會讀 `~/.config/opencode/skills/`(也會讀 `~/.claude/skills/`、`~/.agents/skills/`)。
|
||||||
|
|||||||
Executable
+43
@@ -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
@@ -1,10 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "tea-sdlc",
|
"name": "tea-sdlc",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "以 tea 驅動 SDLC 全流程的跨平台指令組。",
|
"description": "以 tea 驅動 SDLC 全流程的跨平台指令組。",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"tea-sdlc": "bin/tea-sdlc.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"bin/",
|
||||||
|
"prompts/",
|
||||||
|
"scripts/",
|
||||||
|
"templates/",
|
||||||
|
"references/",
|
||||||
|
"skills/",
|
||||||
|
"AGENTS.md"
|
||||||
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -46,6 +46,32 @@ export function referencesDir() {
|
|||||||
return join(pluginRoot(), 'references');
|
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 ────────────────────────────────────────────────
|
// ── 輸入:具名 flag ────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,21 +141,40 @@ export function parseRepo(value) {
|
|||||||
return parts.map((p) => p.trim()).join('/');
|
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。
|
* stderr 永遠保持乾淨,呼叫端只需要讀 stdout。
|
||||||
* @param {() => Promise<object>|object} run 回傳要放進 data 的物件
|
* 回傳 RawText 時改印原樣內容,不包 envelope,其餘行為不變。
|
||||||
|
* @param {() => Promise<object|RawText>|object|RawText} run 回傳要放進 data 的物件
|
||||||
*/
|
*/
|
||||||
export async function main(run) {
|
export async function main(run) {
|
||||||
try {
|
try {
|
||||||
const data = await run();
|
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) {
|
} catch (error) {
|
||||||
const code = error instanceof ScriptError ? error.code : 'UNEXPECTED';
|
const code = error instanceof ScriptError ? error.code : 'UNEXPECTED';
|
||||||
const message = error?.message ?? String(error);
|
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 會截斷長輸出,
|
* stdout 接到 pipe 時寫入是非同步的,直接 process.exit 會截斷長輸出,
|
||||||
* 所以要等 write 的 callback 回來再退出。
|
* 所以要等 write 的 callback 回來再退出。
|
||||||
*/
|
*/
|
||||||
function write(payload, exitCode) {
|
function write(text, exitCode) {
|
||||||
process.stdout.write(`${JSON.stringify(payload)}\n`, () => process.exit(exitCode));
|
process.stdout.write(text, () => process.exit(exitCode));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 認證來源 ───────────────────────────────────────────────────────
|
// ── 認證來源 ───────────────────────────────────────────────────────
|
||||||
@@ -347,21 +392,60 @@ export async function preflight(login, repo) {
|
|||||||
|
|
||||||
/** 第一層:執行環境。node 由「正在執行」本身證明,git 與 tea 則實際到 PATH 上找。 */
|
/** 第一層:執行環境。node 由「正在執行」本身證明,git 與 tea 則實際到 PATH 上找。 */
|
||||||
function checkEnvironment() {
|
function checkEnvironment() {
|
||||||
const missing = ['git', 'tea'].filter((binary) => which(binary) === null);
|
checkBinaries(['git', 'tea']);
|
||||||
if (missing.length > 0) {
|
checkPluginLayout();
|
||||||
throw new ScriptError(
|
}
|
||||||
'ENV_MISSING',
|
|
||||||
`PATH 上找不到 ${missing.join('、')};請先安裝(tea 見 https://gitea.com/gitea/tea)後再執行`,
|
/** 每個執行檔的安裝指引。訊息只提真的缺的那幾個,不要叫人去裝他已經有的東西。 */
|
||||||
);
|
const INSTALL_HINT = {
|
||||||
}
|
git: 'git 見 https://git-scm.com',
|
||||||
for (const dir of [templatesDir(), referencesDir()]) {
|
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)) {
|
if (!existsSync(dir)) {
|
||||||
throw new ScriptError('PLUGIN_LAYOUT_BROKEN', `plugin 目錄不完整,找不到 ${dir};請重新安裝 tea-sdlc`);
|
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(':')) {
|
for (const dir of (process.env.PATH ?? '').split(':')) {
|
||||||
if (dir === '') continue;
|
if (dir === '') continue;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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} 不該被發給使用者`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 { 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 { fileURLToPath } from 'node:url';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
@@ -13,14 +13,52 @@ export const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'
|
|||||||
/** 所有測試暫存的落點;已被 .gitignore 忽略,也不會被 node --test 探索到 */
|
/** 所有測試暫存的落點;已被 .gitignore 忽略,也不會被 node --test 探索到 */
|
||||||
export const tmpRoot = join(repoRoot, '.tmp');
|
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} name 腳本檔名,例如 "labels-list.js"
|
||||||
* @param {string[]} args 具名 flag 陣列
|
* @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)
|
* env 額外環境變數;path 覆寫 PATH(用來模擬缺少 git / tea)
|
||||||
* @returns {Promise<{code: number, stdout: string, stderr: string, json: object}>}
|
* @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;
|
const { env = {}, cwd = repoRoot, path } = opts;
|
||||||
// 先把繼承來的 TEA_SDLC_* 清乾淨,測試結果才不會隨開發者的 shell 而變
|
// 先把繼承來的 TEA_SDLC_* 清乾淨,測試結果才不會隨開發者的 shell 而變
|
||||||
const inherited = Object.fromEntries(
|
const inherited = Object.fromEntries(
|
||||||
@@ -32,7 +70,7 @@ export function runScript(name, args = [], opts = {}) {
|
|||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
execFile(
|
execFile(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
[join(repoRoot, 'scripts', name), ...args],
|
[file, ...args],
|
||||||
// 預設 1MB 會在長輸出時砍掉子行程,那是測試工具的限制而非腳本的問題
|
// 預設 1MB 會在長輸出時砍掉子行程,那是測試工具的限制而非腳本的問題
|
||||||
{ cwd, env: childEnv, maxBuffer: 64 * 1024 * 1024 },
|
{ cwd, env: childEnv, maxBuffer: 64 * 1024 * 1024 },
|
||||||
(error, stdout, stderr) => {
|
(error, stdout, stderr) => {
|
||||||
@@ -40,7 +78,6 @@ export function runScript(name, args = [], opts = {}) {
|
|||||||
code: typeof error?.code === 'number' ? error.code : error ? 1 : 0,
|
code: typeof error?.code === 'number' ? error.code : error ? 1 : 0,
|
||||||
stdout,
|
stdout,
|
||||||
stderr,
|
stderr,
|
||||||
json: parseSingleLine(stdout),
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -48,7 +85,7 @@ export function runScript(name, args = [], opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 腳本的輸出契約是「單行 JSON」。這裡順便把契約本身斷言掉:
|
* 「單行 JSON」的輸出契約。這裡順便把契約本身斷言掉:
|
||||||
* 多印一行、印出非 JSON,都會在這裡就炸掉。
|
* 多印一行、印出非 JSON,都會在這裡就炸掉。
|
||||||
*/
|
*/
|
||||||
function parseSingleLine(stdout) {
|
function parseSingleLine(stdout) {
|
||||||
|
|||||||
@@ -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, '');
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user