@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.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
- 只有在使用者明確要求推送 remote,或專案設定啟用 auto-push 時,才會執行 `git push`。如果 push 失敗,只回報認證或網路問題,不會回滾已完成的文件歸檔與本機 commit。
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["自動查詢主分支最新序號 N"]
427
+ D --> E["先推分支至 remote 並同步遠端狀態<br/>再查詢主分支最新序號 N"]
404
428
  E --> F["自動定號為 project/REQ-N+1-cart/<br/>批次置換 {{REQ_ID}} 並建立 commit"]
405
- F --> G["自動合併至 master 主分支<br/>動態重新生成 project/index.md"]
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` 建立並切換分支**,並在 `project/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. **自動向主分支查詢並取得新號**:查詢 master/main 目前最大序號(例如最大是 `REQ-005`,自動領取最新號 `REQ-006`),避免號碼競爭。
437
- 2. **自動定名更名**:使用 `git mv` 將目錄重命名為 `project/REQ-006-cart-checkout`,並將目錄內所有檔案的 `{{REQ_ID}}` 批次置換為 `REQ-006`。
438
- 3. **自動建立本機提交**:在 feature 分支自動建立 commit `docs(REQ-006): cart-checkout - 規格確認歸檔`。
439
- 4. **自動合併至主分支**:自動切換至主分支(`git checkout master`),將 feature 分支自動合併回主分支(`git merge feat/cart-checkout`),並重新生成主分支的 `project/index.md`,保證主分支規格最新且永遠不衝突!
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 同樣會自動查詢主分支最新序號定號、置換佔位符、完成提交並自動合併回 master!若想保留在當前分支手動處理,可加上 `--no-merge` 參數。
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
- 若要推送到 remote,需要明確要求 AI 執行 push,或自行執行:
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
- - `spec:commit` 會自動建立本機 Git commit;remote push 需使用者明確要求或啟用 auto-push。
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 gitRemoteLs = spawnSync("git", ["ls-tree", `origin/${mainBranch}:project`], { cwd, encoding: "utf8" });
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. 自動向本地與主分支查詢最新已使用的最大 REQ 序號
509
+ // 1. 先檢查是否有 remote,若有則先將目前分支推上 remote 並同步遠端狀態,接著才查詢主分支最新 REQ 序號
510
+ const remote = getDefaultRemote();
468
511
  const mainBranch = detectMainBranch();
469
512
  const currentBranch = getCurrentBranch();
470
- const maxIdNum = getLatestMaxReqId(projectDir, mainBranch);
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. 自動提交與自動合併回主分支 (Git-Flow 閉環)
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}): ${name} - 規格確認歸檔`], { cwd, encoding: "utf8" });
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}): ${name} - 規格確認歸檔`);
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
- console.log(` 全部歸檔流程完成!已自動領取新號 ${nextId} 並自動合併回主分支 ${mainBranch}。`);
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}): ${name} - 規格確認歸檔`], { cwd, encoding: "utf8" });
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}): ${name} - 規格確認歸檔"`);
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]