diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 5441432..07e852f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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" diff --git a/AGENTS.md b/AGENTS.md index f053288..e819d79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` 裝出 `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 | 目前為空;指令以轉接檔形式佈署 | ## 慣例 diff --git a/README.md b/README.md index eb4c11d..83c5960 100644 --- a/README.md +++ b/README.md @@ -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 ` 目前只支援 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/`)。 diff --git a/bin/tea-sdlc.js b/bin/tea-sdlc.js new file mode 100644 index 0000000..4278f01 --- /dev/null +++ b/bin/tea-sdlc.js @@ -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); +}); diff --git a/package.json b/package.json index 01fa9d9..6c86b73 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/install.js b/scripts/install.js new file mode 100644 index 0000000..ad0b374 --- /dev/null +++ b/scripts/install.js @@ -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(`', + '', + `執行 \`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(); +} diff --git a/scripts/lib.js b/scripts/lib.js index 378f2d7..3ecca47 100644 --- a/scripts/lib.js +++ b/scripts/lib.js @@ -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} run 回傳要放進 data 的物件 + * 回傳 RawText 時改印原樣內容,不包 envelope,其餘行為不變。 + * @param {() => Promise|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 { diff --git a/scripts/prompt.js b/scripts/prompt.js new file mode 100644 index 0000000..de37b44 --- /dev/null +++ b/scripts/prompt.js @@ -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}`, + ); +} diff --git a/scripts/status.js b/scripts/status.js new file mode 100644 index 0000000..67e0a26 --- /dev/null +++ b/scripts/status.js @@ -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} 放進 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'); +} diff --git a/test/bin-entry.test.js b/test/bin-entry.test.js new file mode 100644 index 0000000..9dccd64 --- /dev/null +++ b/test/bin-entry.test.js @@ -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} 不該被發給使用者`, + ); + } +}); diff --git a/test/helpers/fake-plugin.js b/test/helpers/fake-plugin.js new file mode 100644 index 0000000..739b7b9 --- /dev/null +++ b/test/helpers/fake-plugin.js @@ -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, omit?: string[], version?: string}} options + * prompts 要放進 prompts/ 的正本,鍵為指令名; + * omit 故意不建立的目錄,用來造出「plugin 目錄不完整」; + * version 覆寫假根的套件版本 + * @returns {{root: string, run: (args: string[], opts?: object) => Promise}} + */ +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 }) }; +} diff --git a/test/helpers/run-script.js b/test/helpers/run-script.js index d3f1f13..3467f35 100644 --- a/test/helpers/run-script.js +++ b/test/helpers/run-script.js @@ -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, cwd?: string, path?: string}} opts + * @param {{env?: Record, 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, 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) { diff --git a/test/install.test.js b/test/install.test.js new file mode 100644 index 0000000..7c28325 --- /dev/null +++ b/test/install.test.js @@ -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, ''); +}); diff --git a/test/prompt.test.js b/test/prompt.test.js new file mode 100644 index 0000000..97dc002 --- /dev/null +++ b/test/prompt.test.js @@ -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/); +}); diff --git a/test/readme-install.test.js b/test/readme-install.test.js new file mode 100644 index 0000000..c9c696b --- /dev/null +++ b/test/readme-install.test.js @@ -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'); +}); diff --git a/test/status.test.js b/test/status.test.js new file mode 100644 index 0000000..63371b7 --- /dev/null +++ b/test/status.test.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); +});