@loviusc/sa-spec 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +23 -7
- package/README.ja.md +22 -6
- package/README.ko.md +22 -6
- package/README.zh-CN.md +22 -6
- package/README.zh-TW.md +49 -14
- package/install.bat +43 -0
- package/install.sh +43 -0
- package/package.json +8 -2
- package/readme.md +49 -14
- package/src/cli.js +235 -10
- package/src/pdf.js +605 -0
- package/templates/AGENTS.md +13 -8
- package/templates/readme.md +49 -14
- package/templates/skills/system-analysis/SKILL.md +14 -9
- package/uninstall.bat +22 -0
- package/uninstall.sh +15 -0
- package/update.bat +43 -0
- package/update.sh +30 -0
package/readme.md
CHANGED
|
@@ -32,6 +32,7 @@ sa-spec init --full
|
|
|
32
32
|
- `spec:propose` (`scp`) $\to$ **AI 自動建立 feature 分支與草稿目錄**(零手動指令)
|
|
33
33
|
- `spec:analyze` (`sca`) $\to$ AI 衝突檢查並產出規格文件草稿
|
|
34
34
|
- `spec:commit` (`scc`) $\to$ **AI 自動向主分支領取新號並自動合併回主分支!**
|
|
35
|
+
- `spec:pdf` (`spd`) $\to$ **一鍵將單一 `.md` 或整個需求規格書轉為高品質 PDF!**
|
|
35
36
|
|
|
36
37
|
👉 **Language Guides: [English](./README.en.md) | [繁體中文](./README.zh-TW.md) | [简体中文](./README.zh-CN.md) | [日本語](./README.ja.md) | [한국어](./README.ko.md)**
|
|
37
38
|
|
|
@@ -39,7 +40,7 @@ sa-spec init --full
|
|
|
39
40
|
|
|
40
41
|
`sa-spec` 是一個輕量的系統分析文件工作台,用固定流程把需求想法整理成可確認、可歸檔、可檢查的文件。
|
|
41
42
|
|
|
42
|
-
AI
|
|
43
|
+
AI 工作流使用五個指令,並支援簡寫別名(兩者等價):
|
|
43
44
|
|
|
44
45
|
| 標準指令 | 簡寫 | 說明 |
|
|
45
46
|
|---|---|---|
|
|
@@ -47,6 +48,7 @@ AI 工作流只使用四個指令,並支援簡寫別名(兩者等價):
|
|
|
47
48
|
| `spec:propose` | `scp` | 提出需求並整理摘要 |
|
|
48
49
|
| `spec:analyze` | `sca` | 衝突檢查與規格起草 |
|
|
49
50
|
| `spec:commit` | `scc` | 確認歸檔並建立 Git commit |
|
|
51
|
+
| `spec:pdf` | `spd` | 將指定 `.md` 或需求目錄匯出為 PDF |
|
|
50
52
|
|
|
51
53
|
每個需求歸檔後固定包含:
|
|
52
54
|
|
|
@@ -96,6 +98,25 @@ npm --version
|
|
|
96
98
|
git --version
|
|
97
99
|
```
|
|
98
100
|
|
|
101
|
+
## 一鍵腳本管理(安裝 / 更新 / 解除安裝)
|
|
102
|
+
|
|
103
|
+
專案內建獨立的跨平台腳本,直接執行對應檔案即可完成安裝、更新或解除安裝:
|
|
104
|
+
|
|
105
|
+
### macOS / Linux (`.sh`)
|
|
106
|
+
```bash
|
|
107
|
+
./install.sh # 安裝 sa-spec
|
|
108
|
+
./update.sh # 更新 sa-spec 至最新版本
|
|
109
|
+
./uninstall.sh # 解除安裝 sa-spec
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Windows (`.bat`)
|
|
113
|
+
可直接雙擊執行,或在 CMD / PowerShell 輸入:
|
|
114
|
+
```bat
|
|
115
|
+
install.bat &:: 安裝 sa-spec
|
|
116
|
+
update.bat &:: 更新 sa-spec 至最新版本
|
|
117
|
+
uninstall.bat &:: 解除安裝 sa-spec
|
|
118
|
+
```
|
|
119
|
+
|
|
99
120
|
## macOS 安裝
|
|
100
121
|
|
|
101
122
|
使用 npm 安裝:
|
|
@@ -275,6 +296,7 @@ sa-spec init --full
|
|
|
275
296
|
| `sa-spec init [--full] [--lang zh-TW|en|zh-CN|ja|ko]` | 建立文件骨架與規則文件(支援 5 種語言,預設 `zh-TW`)。 |
|
|
276
297
|
| `sa-spec new [--id REQ-001] --name login [--branch]` | 建立需求資料夾與空白文件模板(未帶 `--id` 時啟用多人草稿模式,可選 `--branch` 自動建立並切換至 `feat/<name>` 分支)。 |
|
|
277
298
|
| `sa-spec finalize --name login` | 將草稿需求定號歸檔為最新序號(自動計算最新號碼、重命名目錄、批次置換 `{{REQ_ID}}` 並重建索引)。 |
|
|
299
|
+
| `sa-spec pdf <target> [--split] [--output out.pdf]` | 將指定 `.md` 轉為同名 `.pdf`,或將需求編號/目錄(如 `REQ-001`)整包合併匯出成一份完整規格書 PDF(加 `--split` 則各檔獨立轉出,`--all` 匯出全專案)。 |
|
|
278
300
|
| `sa-spec reindex` | 掃描 `project/REQ-*` 目錄,依序號重新生成乾淨無衝突的 `project/index.md`。 |
|
|
279
301
|
| `sa-spec check` | 檢查基本文件是否存在。 |
|
|
280
302
|
| `sa-spec status` | 顯示系統規格與需求索引狀態。 |
|
|
@@ -324,6 +346,7 @@ Markdown、PlantUML、YAML、GitLens 這類套件可以提升閱讀與檢查體
|
|
|
324
346
|
| AI 工作流程 | `spec:propose` / `scp` | VS Code AI 對話側邊欄 | 整理使用者提出的需求。 |
|
|
325
347
|
| AI 工作流程 | `spec:analyze` / `sca` | VS Code AI 對話側邊欄 | 檢查衝突並產出待確認草稿。 |
|
|
326
348
|
| AI 工作流程 | `spec:commit` / `scc` | VS Code AI 對話側邊欄 | 歸檔已確認版本並建立本機 Git commit。 |
|
|
349
|
+
| AI 工作流程 | `spec:pdf` / `spd` | VS Code AI 對話側邊欄 | 將指定 `.md` 或需求目錄匯出為 PDF 規格書。 |
|
|
327
350
|
|
|
328
351
|
建議操作順序:
|
|
329
352
|
|
|
@@ -353,6 +376,7 @@ spec:commit # 或簡寫 scc
|
|
|
353
376
|
| `spec:propose` | `scp` | 使用者提出需求後,整理需求摘要、目標、初步流程與待確認事項。 |
|
|
354
377
|
| `spec:analyze` | `sca` | 檢查是否與既有需求衝突;無衝突才產出 SRS、UML、OpenAPI、DB Schema 草稿。 |
|
|
355
378
|
| `spec:commit` | `scc` | 使用者確認無衝突版本後,將版本歸檔到 `project/`,並建立本機 Git commit。 |
|
|
379
|
+
| `spec:pdf` | `spd` | 將指定的 `.md` 檔或需求(如 `REQ-001`)匯出為高品質 PDF。 |
|
|
356
380
|
|
|
357
381
|
狀態機:
|
|
358
382
|
|
|
@@ -385,7 +409,7 @@ commit (scc) -> 歸檔 -> 本機 Git commit
|
|
|
385
409
|
docs(REQ-001): 番茄時鐘 - 新增專注與休息計時需求
|
|
386
410
|
```
|
|
387
411
|
|
|
388
|
-
|
|
412
|
+
**鐵律**:只要 Git repository 有設定 remote(例如 `origin`),在 `scp` 建立分支與 `scc` 歸檔合併時,都會**自動執行 `git push` 推送至 remote**。如果 push 失敗,只回報認證或網路問題,不會回滾已完成的文件歸檔與本機 commit。
|
|
389
413
|
|
|
390
414
|
## 多人協作解決方案(Git-Flow 與延遲綁定 ID)
|
|
391
415
|
|
|
@@ -397,12 +421,12 @@ docs(REQ-001): 番茄時鐘 - 新增專注與休息計時需求
|
|
|
397
421
|
|
|
398
422
|
```mermaid
|
|
399
423
|
flowchart TD
|
|
400
|
-
A["開新需求: scp 或 sa-spec new --branch"] --> B["建立並切換至 feat/cart 分支<br/>以 project/cart/ 建立草稿目錄"]
|
|
424
|
+
A["開新需求: scp 或 sa-spec new --branch"] --> B["建立並切換至 feat/cart 分支<br/>若有 remote 自動推分支至遠端<br/>以 project/cart/ 建立草稿目錄"]
|
|
401
425
|
B --> C["AI 分析與起草: sca<br/>內部使用 {{REQ_ID}} 佔位<br/>自動同步 master 規格防護"]
|
|
402
426
|
C --> D{"使用者確認通過<br/>執行 scc 或 finalize"}
|
|
403
|
-
D --> E["
|
|
427
|
+
D --> E["先推分支至 remote 並同步遠端狀態<br/>再查詢主分支最新序號 N"]
|
|
404
428
|
E --> F["自動定號為 project/REQ-N+1-cart/<br/>批次置換 {{REQ_ID}} 並建立 commit"]
|
|
405
|
-
F --> G["自動合併至 master
|
|
429
|
+
F --> G["自動合併至 master 主分支並推上 remote<br/>動態重新生成 project/index.md"]
|
|
406
430
|
G --> H["完成閉環:零衝突、序號永遠連續!"]
|
|
407
431
|
```
|
|
408
432
|
|
|
@@ -415,7 +439,7 @@ flowchart TD
|
|
|
415
439
|
```text
|
|
416
440
|
scp 購物車結帳需求
|
|
417
441
|
```
|
|
418
|
-
AI 收到後,會**自動在背景執行 `git checkout -b feat/cart-checkout`
|
|
442
|
+
AI 收到後,會**自動在背景執行 `git checkout -b feat/cart-checkout` 建立並切換分支**,檢查若有設定 Git remote 則自動將分支推上遠端(`git push -u origin feat/cart-checkout`),並在 `project/cart-checkout/` 建立草稿目錄!
|
|
419
443
|
* **方式 B(CLI 終端指令)**:
|
|
420
444
|
```bash
|
|
421
445
|
sa-spec new --name cart-checkout --branch
|
|
@@ -425,7 +449,7 @@ flowchart TD
|
|
|
425
449
|
#### 2. 分析與衝突檢查(規格基線同步)
|
|
426
450
|
在分支上執行 `spec:analyze`(或 `sca`)前,工具會確認已取得 `master`(或 `origin/master`)的最新規格,確保是以全專案最新基準進行影響評估,避免因分支落後而漏檢權限、API 或資料表衝突。
|
|
427
451
|
|
|
428
|
-
#### 3.
|
|
452
|
+
#### 3. 確認歸檔與延遲定號(先推分支 + 自動領號 + 自動合併並推上 Remote)
|
|
429
453
|
當需求規格經使用者確認無誤,準備歸檔時:
|
|
430
454
|
|
|
431
455
|
* **方式 A(純 AI 自動化,最推薦)**:在 AI 側邊欄直接輸入:
|
|
@@ -433,15 +457,16 @@ flowchart TD
|
|
|
433
457
|
scc
|
|
434
458
|
```
|
|
435
459
|
AI 收到後會自動完成全自動 Git-Flow 閉環作業:
|
|
436
|
-
1.
|
|
437
|
-
2.
|
|
438
|
-
3.
|
|
439
|
-
4.
|
|
460
|
+
1. **先推分支至 Remote 並同步遠端**:若 Git 有設定 remote,先將目前分支推送至遠端並執行 `git fetch` 同步最新狀態。
|
|
461
|
+
2. **自動向主分支查詢並取得新號**:查詢 master/main(含遠端主分支)目前最大序號(例如最大是 `REQ-005`,自動領取最新號 `REQ-006`),避免號碼競爭。
|
|
462
|
+
3. **自動定名更名**:使用 `git mv` 將目錄重命名為 `project/REQ-006-cart-checkout`,並將目錄內所有檔案的 `{{REQ_ID}}` 批次置換為 `REQ-006`。
|
|
463
|
+
4. **自動建立提交並推送分支**:在 feature 分支自動建立 commit `docs(REQ-006): cart-checkout - 規格確認歸檔`,若有 remote 則同步推送至遠端。
|
|
464
|
+
5. **自動合併至主分支並推送至 Remote**:自動切換至主分支(`git checkout master`),將 feature 分支自動合併回主分支(`git merge feat/cart-checkout`),重新生成主分支的 `project/index.md`,若有 remote 則自動將主分支推送至遠端(`git push origin master`),保證主分支規格最新且永遠不衝突!
|
|
440
465
|
* **方式 B(CLI 終端指令)**:
|
|
441
466
|
```bash
|
|
442
467
|
sa-spec finalize --name cart-checkout
|
|
443
468
|
```
|
|
444
|
-
CLI
|
|
469
|
+
CLI 同樣會先推分支至 remote、查詢主分支最新序號定號、置換佔位符、完成提交、自動合併回 master 並推送至 remote!若想保留在當前分支手動處理,可加上 `--no-merge` 參數。
|
|
445
470
|
|
|
446
471
|
#### 4. 消除索引合併衝突(`sa-spec reindex`)
|
|
447
472
|
`project/index.md` 改為由目錄動態生成的「衍生清單」,不再依賴脆弱的手動 append:
|
|
@@ -702,7 +727,7 @@ git status
|
|
|
702
727
|
git log -1 --oneline
|
|
703
728
|
```
|
|
704
729
|
|
|
705
|
-
|
|
730
|
+
若 Git 有設定 remote,AI 與 CLI 會在建立分支與歸檔合併時自動執行 `git push` 推送至遠端;若當時尚未設定 remote,事後也可手動執行:
|
|
706
731
|
|
|
707
732
|
```bash
|
|
708
733
|
git push origin main
|
|
@@ -719,11 +744,21 @@ git push origin master
|
|
|
719
744
|
- 未經使用者確認,不得標示為 `已審核`。
|
|
720
745
|
- `spec:analyze` 發現衝突時不得產出最終文件,也不得修改既有需求文件。
|
|
721
746
|
- `project/system-spec.md` 只能在 `spec:commit` 依使用者確認內容更新。
|
|
722
|
-
-
|
|
747
|
+
- **鐵律**:只要 Git 有設定 remote,`scp` 與 `scc` 都會自動將分支與主分支推送至 remote。
|
|
723
748
|
- 一次最多問 3 個待確認問題,優先詢問會影響範圍、資料、權限、API、DB 或驗收的問題。
|
|
724
749
|
|
|
725
750
|
## 更新紀錄
|
|
726
751
|
|
|
752
|
+
### v0.2.7
|
|
753
|
+
- **新增 Markdown 轉 PDF 指令(`spec:pdf` / `spd` / `sa-spec pdf`)與跨平台獨立安裝腳本**:
|
|
754
|
+
- **Markdown 轉 PDF 支援**:新增 AI 指令 `spec:pdf`(簡寫 `spd`)與 CLI 指令 `sa-spec pdf <target> [--split] [--output out.pdf]`(及 `--all`),支援單一 `.md` 轉換、需求目錄整包合併匯出成一份完整規格書 PDF,或加 `--split` 各檔獨立匯出。
|
|
755
|
+
- **跨平台獨立管理腳本**:新增 macOS / Linux(`install.sh`、`update.sh`、`uninstall.sh`)與 Windows(`install.bat`、`update.bat`、`uninstall.bat`)6 個獨立執行腳本,一鍵完成安裝、更新與解除安裝。
|
|
756
|
+
|
|
757
|
+
### v0.2.6
|
|
758
|
+
- **Git Remote 自動推送鐵律(Remote Auto-Push)**:
|
|
759
|
+
- **`scp` 建立分支自動推上遠端**:執行 `spec:propose`(`scp`)或 `sa-spec new --branch` 建立 feature 分支後,自動偵測是否有設定 Git remote;若有則立即將新分支推送至遠端(`git push -u <remote> feat/<name>`)。
|
|
760
|
+
- **`scc` 先推分支再領號與主分支自動推送**:執行 `spec:commit`(`scc`)或 `sa-spec finalize` 時,若有設定 Git remote,會**先將目前分支推送至 remote 並同步遠端狀態**,接著才查詢主分支(`master`/`main`)最新可用序號定號,完成 feature 分支提交與推送、自動合併回主分支後,**自動將主分支推送至 remote**。
|
|
761
|
+
|
|
727
762
|
### v0.2.5
|
|
728
763
|
- **支援前端 UI 線框圖(PlantUML Salt Code-as-Wireframe)**:
|
|
729
764
|
- **純文字代碼化線框圖**:在 `uml/` 目錄新增標準 `uml/wireframe.puml`,使用 PlantUML Salt 語法(`@startsalt ... @endsalt`)以純文字定義頁面排版、輸入欄位、選項與按鈕,可直接在 VS Code 渲染預覽,並可納入 Git 版本追蹤。
|
package/src/cli.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
2
|
const path = require("path");
|
|
3
3
|
const { spawnSync } = require("child_process");
|
|
4
|
+
const {
|
|
5
|
+
buildHtmlDocument,
|
|
6
|
+
convertMarkdownFileToPdf,
|
|
7
|
+
buildMergedRequirementMarkdown,
|
|
8
|
+
renderHtmlToPdf
|
|
9
|
+
} = require("./pdf");
|
|
4
10
|
|
|
5
11
|
const cwd = process.cwd();
|
|
6
12
|
const rootDir = path.resolve(__dirname, "..");
|
|
@@ -38,6 +44,9 @@ function main() {
|
|
|
38
44
|
case "template":
|
|
39
45
|
runTemplate(args.slice(1));
|
|
40
46
|
return;
|
|
47
|
+
case "pdf":
|
|
48
|
+
runPdf(args.slice(1));
|
|
49
|
+
return;
|
|
41
50
|
case "version":
|
|
42
51
|
case "--version":
|
|
43
52
|
case "-v":
|
|
@@ -306,6 +315,12 @@ function createReqTemplate(args, label = "new") {
|
|
|
306
315
|
} else {
|
|
307
316
|
console.log(`提示: 分支切換略過 (${(branchRes.stderr || "").trim() || "可能是已有同名分支或未在 git 目錄"})`);
|
|
308
317
|
}
|
|
318
|
+
|
|
319
|
+
const remote = getDefaultRemote();
|
|
320
|
+
const activeBranch = getCurrentBranch() || branchName;
|
|
321
|
+
if (remote && activeBranch) {
|
|
322
|
+
pushBranchToRemote(remote, activeBranch);
|
|
323
|
+
}
|
|
309
324
|
}
|
|
310
325
|
|
|
311
326
|
const reqDir = path.join("project", slug);
|
|
@@ -349,6 +364,32 @@ function createReqTemplate(args, label = "new") {
|
|
|
349
364
|
printResultList(label, results);
|
|
350
365
|
}
|
|
351
366
|
|
|
367
|
+
function getDefaultRemote() {
|
|
368
|
+
const res = spawnSync("git", ["remote"], { cwd, encoding: "utf8" });
|
|
369
|
+
if (res.status === 0 && res.stdout) {
|
|
370
|
+
const remotes = res.stdout
|
|
371
|
+
.split("\n")
|
|
372
|
+
.map((r) => r.trim())
|
|
373
|
+
.filter(Boolean);
|
|
374
|
+
if (remotes.includes("origin")) return "origin";
|
|
375
|
+
if (remotes.length > 0) return remotes[0];
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function pushBranchToRemote(remote, branch) {
|
|
381
|
+
if (!remote || !branch) return false;
|
|
382
|
+
console.log(`[Git] 偵測到遠端 (${remote}),正在將分支 ${branch} 推送至 ${remote}/${branch}...`);
|
|
383
|
+
const pushRes = spawnSync("git", ["push", "-u", remote, branch], { cwd, encoding: "utf8" });
|
|
384
|
+
if (pushRes.status === 0) {
|
|
385
|
+
console.log(`[Git] 已成功將分支 ${branch} 推送至 ${remote}/${branch}`);
|
|
386
|
+
return true;
|
|
387
|
+
} else {
|
|
388
|
+
console.warn(`[Git 警告] 推送分支 ${branch} 至 ${remote} 失敗: ${(pushRes.stderr || pushRes.stdout || "").trim()}`);
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
352
393
|
function detectMainBranch() {
|
|
353
394
|
const branches = ["main", "master"];
|
|
354
395
|
for (const b of branches) {
|
|
@@ -367,7 +408,7 @@ function getCurrentBranch() {
|
|
|
367
408
|
return null;
|
|
368
409
|
}
|
|
369
410
|
|
|
370
|
-
function getLatestMaxReqId(projectDir, mainBranch) {
|
|
411
|
+
function getLatestMaxReqId(projectDir, mainBranch, remoteName = "origin") {
|
|
371
412
|
let maxIdNum = 0;
|
|
372
413
|
// 1. 掃描本地工作目錄既有 REQ-xxx
|
|
373
414
|
if (fs.existsSync(projectDir)) {
|
|
@@ -400,7 +441,8 @@ function getLatestMaxReqId(projectDir, mainBranch) {
|
|
|
400
441
|
}
|
|
401
442
|
|
|
402
443
|
// 3. 掃描遠端主分支 (若有設定 remote)
|
|
403
|
-
const
|
|
444
|
+
const targetRemote = remoteName || "origin";
|
|
445
|
+
const gitRemoteLs = spawnSync("git", ["ls-tree", `${targetRemote}/${mainBranch}:project`], { cwd, encoding: "utf8" });
|
|
404
446
|
if (gitRemoteLs.status === 0 && gitRemoteLs.stdout) {
|
|
405
447
|
for (const line of gitRemoteLs.stdout.split("\n")) {
|
|
406
448
|
const parts = line.split("\t");
|
|
@@ -464,10 +506,17 @@ function finalizeReq(args) {
|
|
|
464
506
|
return;
|
|
465
507
|
}
|
|
466
508
|
|
|
467
|
-
// 1.
|
|
509
|
+
// 1. 先檢查是否有 remote,若有則先將目前分支推上 remote 並同步遠端狀態,接著才查詢主分支最新 REQ 序號
|
|
510
|
+
const remote = getDefaultRemote();
|
|
468
511
|
const mainBranch = detectMainBranch();
|
|
469
512
|
const currentBranch = getCurrentBranch();
|
|
470
|
-
|
|
513
|
+
|
|
514
|
+
if (remote && currentBranch) {
|
|
515
|
+
pushBranchToRemote(remote, currentBranch);
|
|
516
|
+
spawnSync("git", ["fetch", remote], { cwd, encoding: "utf8" });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const maxIdNum = getLatestMaxReqId(projectDir, mainBranch, remote);
|
|
471
520
|
|
|
472
521
|
const nextIdNum = maxIdNum + 1;
|
|
473
522
|
const nextId = `REQ-${String(nextIdNum).padStart(3, "0")}`;
|
|
@@ -518,14 +567,17 @@ function finalizeReq(args) {
|
|
|
518
567
|
|
|
519
568
|
console.log(`\n 需求已成功定號歸檔為: ${nextId} (${finalDirName})`);
|
|
520
569
|
|
|
521
|
-
// 5.
|
|
570
|
+
// 5. 自動提交、自動合併回主分支與自動推送至 remote (Git-Flow 閉環)
|
|
522
571
|
const shouldAutoMerge = !args.includes("--no-merge");
|
|
523
572
|
if (currentBranch && mainBranch && currentBranch !== mainBranch) {
|
|
524
573
|
console.log(`[Git] 正在為分支 ${currentBranch} 建立歸檔 commit...`);
|
|
525
574
|
spawnSync("git", ["add", "project/"], { cwd, encoding: "utf8" });
|
|
526
|
-
const commitRes = spawnSync("git", ["commit", "-m", `docs(${nextId}): ${
|
|
575
|
+
const commitRes = spawnSync("git", ["commit", "-m", `docs(${nextId}): ${rawName} - 規格確認歸檔`], { cwd, encoding: "utf8" });
|
|
527
576
|
if (commitRes.status === 0) {
|
|
528
|
-
console.log(`[Git] 分支 ${currentBranch} 提交完成: docs(${nextId}): ${
|
|
577
|
+
console.log(`[Git] 分支 ${currentBranch} 提交完成: docs(${nextId}): ${rawName} - 規格確認歸檔`);
|
|
578
|
+
}
|
|
579
|
+
if (remote) {
|
|
580
|
+
pushBranchToRemote(remote, currentBranch);
|
|
529
581
|
}
|
|
530
582
|
|
|
531
583
|
if (shouldAutoMerge) {
|
|
@@ -546,7 +598,10 @@ function finalizeReq(args) {
|
|
|
546
598
|
spawnSync("git", ["add", "project/index.md"], { cwd, encoding: "utf8" });
|
|
547
599
|
spawnSync("git", ["commit", "-m", `chore: update project/index.md for ${nextId}`], { cwd, encoding: "utf8" });
|
|
548
600
|
}
|
|
549
|
-
|
|
601
|
+
if (remote) {
|
|
602
|
+
pushBranchToRemote(remote, mainBranch);
|
|
603
|
+
}
|
|
604
|
+
console.log(` 全部歸檔流程完成!已自動領取新號 ${nextId} 並自動合併回主分支 ${mainBranch}${remote ? ` 且推送至 ${remote}` : ""}。`);
|
|
550
605
|
} else {
|
|
551
606
|
console.error(`[Git 警告] 合併至 ${mainBranch} 發生衝突,請手動確認衝突狀況:\n${(mergeRes.stderr || mergeRes.stdout || "").trim()}`);
|
|
552
607
|
}
|
|
@@ -556,12 +611,15 @@ function finalizeReq(args) {
|
|
|
556
611
|
}
|
|
557
612
|
} else if (currentBranch && currentBranch === mainBranch) {
|
|
558
613
|
spawnSync("git", ["add", "project/"], { cwd, encoding: "utf8" });
|
|
559
|
-
spawnSync("git", ["commit", "-m", `docs(${nextId}): ${
|
|
614
|
+
spawnSync("git", ["commit", "-m", `docs(${nextId}): ${rawName} - 規格確認歸檔`], { cwd, encoding: "utf8" });
|
|
560
615
|
console.log(`[Git] 已直接於主分支 ${mainBranch} 建立提交完成。`);
|
|
616
|
+
if (remote) {
|
|
617
|
+
pushBranchToRemote(remote, mainBranch);
|
|
618
|
+
}
|
|
561
619
|
} else {
|
|
562
620
|
console.log(`建議接下來執行:`);
|
|
563
621
|
console.log(` git add project/`);
|
|
564
|
-
console.log(` git commit -m "docs(${nextId}): ${
|
|
622
|
+
console.log(` git commit -m "docs(${nextId}): ${rawName} - 規格確認歸檔"`);
|
|
565
623
|
}
|
|
566
624
|
}
|
|
567
625
|
|
|
@@ -972,6 +1030,171 @@ function printResultList(label, results) {
|
|
|
972
1030
|
}
|
|
973
1031
|
}
|
|
974
1032
|
|
|
1033
|
+
function resolveReqTargetDir(target) {
|
|
1034
|
+
const directAbs = path.resolve(cwd, target);
|
|
1035
|
+
if (fs.existsSync(directAbs) && fs.statSync(directAbs).isDirectory()) {
|
|
1036
|
+
return directAbs;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const projectDir = path.join(cwd, "project");
|
|
1040
|
+
if (!fs.existsSync(projectDir)) return null;
|
|
1041
|
+
|
|
1042
|
+
const inProjectAbs = path.join(projectDir, target);
|
|
1043
|
+
if (fs.existsSync(inProjectAbs) && fs.statSync(inProjectAbs).isDirectory()) {
|
|
1044
|
+
return inProjectAbs;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const norm = target.toLowerCase().trim();
|
|
1048
|
+
const entries = fs.readdirSync(projectDir, { withFileTypes: true });
|
|
1049
|
+
for (const entry of entries) {
|
|
1050
|
+
if (!entry.isDirectory()) continue;
|
|
1051
|
+
const nameLower = entry.name.toLowerCase();
|
|
1052
|
+
if (
|
|
1053
|
+
nameLower === norm ||
|
|
1054
|
+
nameLower.startsWith(`${norm}-`) ||
|
|
1055
|
+
nameLower.endsWith(`-${slugify(target)}`)
|
|
1056
|
+
) {
|
|
1057
|
+
return path.join(projectDir, entry.name);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
return null;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function exportReqDirToPdf(reqAbsDir, options = {}) {
|
|
1064
|
+
const { split = false, output = null, browser = null } = options;
|
|
1065
|
+
const relDir = path.relative(cwd, reqAbsDir) || path.basename(reqAbsDir);
|
|
1066
|
+
const results = [];
|
|
1067
|
+
|
|
1068
|
+
if (split) {
|
|
1069
|
+
const entries = fs.readdirSync(reqAbsDir, { withFileTypes: true });
|
|
1070
|
+
const mdFiles = entries
|
|
1071
|
+
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith(".md"))
|
|
1072
|
+
.map((e) => path.join(reqAbsDir, e.name));
|
|
1073
|
+
|
|
1074
|
+
for (const mdFile of mdFiles) {
|
|
1075
|
+
const res = convertMarkdownFileToPdf(mdFile, null, browser);
|
|
1076
|
+
const relPdf = path.relative(cwd, mdFile.replace(/\.md$/i, ".pdf"));
|
|
1077
|
+
if (res.ok) {
|
|
1078
|
+
results.push({ action: "create", target: relPdf, message: `由 ${path.relative(cwd, mdFile)} 轉換 (${res.engine})` });
|
|
1079
|
+
} else {
|
|
1080
|
+
results.push({ action: "warn", target: relPdf, message: res.error });
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
return results;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const merged = buildMergedRequirementMarkdown(reqAbsDir);
|
|
1087
|
+
if (!merged.markdown.trim()) {
|
|
1088
|
+
results.push({ action: "warn", target: relDir, message: "目錄內無可匯出的規格文件內容" });
|
|
1089
|
+
return results;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const defaultPdfPath = path.join(reqAbsDir, `${path.basename(reqAbsDir)}.pdf`);
|
|
1093
|
+
const targetPdfPath = output ? path.resolve(cwd, output) : defaultPdfPath;
|
|
1094
|
+
const html = buildHtmlDocument(merged.title, merged.markdown);
|
|
1095
|
+
const res = renderHtmlToPdf(html, targetPdfPath, browser);
|
|
1096
|
+
const relPdf = path.relative(cwd, targetPdfPath);
|
|
1097
|
+
|
|
1098
|
+
if (res.ok) {
|
|
1099
|
+
results.push({
|
|
1100
|
+
action: "create",
|
|
1101
|
+
target: relPdf,
|
|
1102
|
+
message: `已整包合併匯出完整需求規格書 PDF (${res.engine})`
|
|
1103
|
+
});
|
|
1104
|
+
} else {
|
|
1105
|
+
results.push({ action: "warn", target: relPdf, message: res.error });
|
|
1106
|
+
}
|
|
1107
|
+
return results;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function runPdf(args = []) {
|
|
1111
|
+
const split = args.includes("--split");
|
|
1112
|
+
const all = args.includes("--all");
|
|
1113
|
+
const output = getOption(args, "--output") || getOption(args, "-o");
|
|
1114
|
+
const browser = getOption(args, "--browser");
|
|
1115
|
+
|
|
1116
|
+
const positional = [];
|
|
1117
|
+
for (let i = 0; i < args.length; i++) {
|
|
1118
|
+
const a = args[i];
|
|
1119
|
+
if (a === "--output" || a === "-o" || a === "--browser" || a === "--name" || a === "--target") {
|
|
1120
|
+
i++;
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
1123
|
+
if (a.startsWith("-")) continue;
|
|
1124
|
+
positional.push(a);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const target = getOption(args, "--name") || getOption(args, "--target") || positional[0];
|
|
1128
|
+
|
|
1129
|
+
if (!target && !all) {
|
|
1130
|
+
fail(
|
|
1131
|
+
"Usage:\n sa-spec pdf <file.md> [--output out.pdf]\n sa-spec pdf <REQ-001|name|dir> [--split] [--output out.pdf]\n sa-spec pdf --all [--split]"
|
|
1132
|
+
);
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const results = [];
|
|
1137
|
+
|
|
1138
|
+
if (all) {
|
|
1139
|
+
const projectDir = path.join(cwd, "project");
|
|
1140
|
+
if (!fs.existsSync(projectDir)) {
|
|
1141
|
+
fail("找不到 project/ 目錄。");
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
for (const rootMd of ["system-spec.md", "index.md"]) {
|
|
1145
|
+
const mdPath = path.join(projectDir, rootMd);
|
|
1146
|
+
if (fs.existsSync(mdPath)) {
|
|
1147
|
+
const res = convertMarkdownFileToPdf(mdPath, null, browser);
|
|
1148
|
+
const relPdf = path.relative(cwd, mdPath.replace(/\.md$/i, ".pdf"));
|
|
1149
|
+
results.push(
|
|
1150
|
+
res.ok
|
|
1151
|
+
? { action: "create", target: relPdf, message: `已轉換 (${res.engine})` }
|
|
1152
|
+
: { action: "warn", target: relPdf, message: res.error }
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
const entries = fs.readdirSync(projectDir, { withFileTypes: true });
|
|
1157
|
+
for (const entry of entries) {
|
|
1158
|
+
if (entry.isDirectory()) {
|
|
1159
|
+
const reqDir = path.join(projectDir, entry.name);
|
|
1160
|
+
results.push(...exportReqDirToPdf(reqDir, { split, browser }));
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
printResultList("pdf", results);
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// 1. Check if target is a single .md file
|
|
1168
|
+
const directFileAbs = path.resolve(cwd, target);
|
|
1169
|
+
if (fs.existsSync(directFileAbs) && fs.statSync(directFileAbs).isFile()) {
|
|
1170
|
+
const res = convertMarkdownFileToPdf(directFileAbs, output, browser);
|
|
1171
|
+
const targetPdf = output
|
|
1172
|
+
? path.relative(cwd, path.resolve(cwd, output))
|
|
1173
|
+
: path.relative(cwd, directFileAbs.replace(/\.[^.]+$/, ".pdf"));
|
|
1174
|
+
if (res.ok) {
|
|
1175
|
+
results.push({ action: "create", target: targetPdf, message: `單檔轉換完成 (${res.engine})` });
|
|
1176
|
+
} else {
|
|
1177
|
+
results.push({ action: "warn", target: targetPdf, message: res.error });
|
|
1178
|
+
process.exitCode = 1;
|
|
1179
|
+
}
|
|
1180
|
+
printResultList("pdf", results);
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// 2. Check if target resolves to a requirement directory
|
|
1185
|
+
const reqDirAbs = resolveReqTargetDir(target);
|
|
1186
|
+
if (reqDirAbs) {
|
|
1187
|
+
results.push(...exportReqDirToPdf(reqDirAbs, { split, output, browser }));
|
|
1188
|
+
if (results.some((r) => r.action === "warn")) {
|
|
1189
|
+
process.exitCode = 1;
|
|
1190
|
+
}
|
|
1191
|
+
printResultList("pdf", results);
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
fail(`找不到指定的 Markdown 檔案或需求目錄: ${target}`);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
975
1198
|
function showHelp() {
|
|
976
1199
|
console.log(`sa-spec
|
|
977
1200
|
|
|
@@ -979,6 +1202,8 @@ Usage:
|
|
|
979
1202
|
sa-spec init [--full] [--lang zh-TW|en|zh-CN|ja|ko]
|
|
980
1203
|
sa-spec new [--id REQ-001] --name login [--branch]
|
|
981
1204
|
sa-spec finalize --name login [--no-merge]
|
|
1205
|
+
sa-spec pdf <file.md|REQ-001|name> [--split] [--output out.pdf]
|
|
1206
|
+
sa-spec pdf --all [--split]
|
|
982
1207
|
sa-spec reindex
|
|
983
1208
|
sa-spec check
|
|
984
1209
|
sa-spec upgrade [--dry-run]
|