project-auto-wizard 0.1.12 → 0.1.16

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.
Files changed (47) hide show
  1. package/README.md +7 -5
  2. package/package.json +1 -1
  3. package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +1 -0
  4. package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +1 -0
  5. package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +1 -0
  6. package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +1 -0
  7. package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +1 -0
  8. package/payload/workflows/common/secret-backup/PROJECT-COMMON-SECRET-FILE-UPLOAD.yaml +1 -0
  9. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +1 -0
  10. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +1 -0
  11. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +1 -0
  12. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +1 -0
  13. package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +1 -0
  14. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +1 -0
  15. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +1 -0
  16. package/payload/workflows/flutter/PROJECT-FLUTTER-SUH-LAB-APP-BUILD-TRIGGER.yaml +1 -0
  17. package/payload/workflows/next/PROJECT-NEXT-CI.yaml +1 -0
  18. package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +1 -0
  19. package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +1 -0
  20. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +1 -0
  21. package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +1 -0
  22. package/payload/workflows/react/PROJECT-REACT-CI.yaml +1 -0
  23. package/payload/workflows/react/PROJECT-REACT-CICD.yaml +1 -0
  24. package/payload/workflows/spring/PROJECT-SPRING-GITHUB-PACKAGES-PUBLISH.yml +1 -0
  25. package/payload/workflows/spring/nexus/PROJECT-SPRING-NEXUS-CI.yml +1 -0
  26. package/payload/workflows/spring/nexus/PROJECT-SPRING-NEXUS-PUBLISH.yml +1 -0
  27. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +1 -0
  28. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +1 -0
  29. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +1 -0
  30. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +1 -0
  31. package/src/cli/args.js +36 -10
  32. package/src/cli/help.js +2 -2
  33. package/src/commands/dry-run.js +8 -4
  34. package/src/commands/full.js +7 -2
  35. package/src/commands/interactive.js +2 -2
  36. package/src/commands/revert.js +35 -11
  37. package/src/commands/version.js +7 -1
  38. package/src/commands/workflows.js +4 -1
  39. package/src/context.js +7 -0
  40. package/src/core/copy/gitignore.js +34 -9
  41. package/src/core/copy/workflows.js +39 -16
  42. package/src/core/paths-resolve.js +11 -6
  43. package/src/core/version-yml.js +39 -4
  44. package/src/core/wizard-env.js +10 -1
  45. package/src/index.js +23 -16
  46. package/src/ui/prompts.js +1 -1
  47. package/src/ui/summary.js +14 -20
package/README.md CHANGED
@@ -16,7 +16,7 @@ npx project-auto-wizard
16
16
  [![node](https://img.shields.io/badge/node-%3E%3D20.12-brightgreen)](package.json)
17
17
 
18
18
  <!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
19
- ## 최신 버전 : v0.1.11 (2026-08-03)
19
+ ## 최신 버전 : v0.1.14 (2026-08-04)
20
20
 
21
21
  [전체 버전 기록 보기](CHANGELOG.md)
22
22
 
@@ -46,6 +46,7 @@ npx project-auto-wizard --mode full --force --type spring,react # CI에서 비
46
46
 
47
47
  - **멀티타입**: `--type spring,react,python` — 한 레포에 여러 타입 공존
48
48
  - **모노레포**: `--paths "flutter=app,react=client"` — 타입별 서브폴더 지정 (마커 파일 자동 감지)
49
+ - `spring`/`flutter`/`react`/`next`/`python` 5개 타입은 아래 "타입별 워크플로우 구성"처럼 전용 CI/CD가 설치됩니다. `node`/`react-native`/`react-native-expo`/`basic`은 타입 전용 CI 없이 릴리스 자동화(버전 관리·체인지로그·AI 요약)를 담당하는 공통 워크플로우만 설치됩니다 — 빌드/배포 CI는 직접 추가해서 확장할 수 있습니다.
49
50
 
50
51
  ### 질문 문구 커스터마이징
51
52
 
@@ -65,13 +66,14 @@ flutter.APP_ARTIFACT_NAME:
65
66
  `spring`/`flutter`는 아래처럼 단일 CI 이상으로 깊게 구성되어 있습니다:
66
67
 
67
68
  - **flutter**: Android(Firebase/Playstore/Selfhosted/TestAPK 배포), iOS(TestFlight/Test-TestFlight), CI, Lab 트리거까지 8종
68
- - **spring**: 무중단 배포 2종(Nginx/Traefik) + 단일 서버 배포 + PR 프리뷰 + Nexus publish(opt-in)
69
+ - **spring**: 단일 서버 배포(SIMPLE-CICD, **기본 활성**) + 무중단 배포 2종(Nginx/Traefik, opt-in) + PR 프리뷰 + GitHub Packages publish(항상 설치) + Nexus publish(`--nexus` opt-in)
70
+ - 무중단 배포 워크플로우는 파일은 설치되지만 `push` 트리거가 기본적으로 비활성(`workflow_dispatch`만 활성)입니다. 기본 배포는 SIMPLE-CICD가 담당하며, 무중단 배포로 전환하려면 해당 워크플로우 YAML의 `push.branches` 주석을 해제하고 SIMPLE-CICD의 `push` 트리거는 주석 처리해야 합니다(각 YAML 상단 주석에 안내되어 있습니다).
69
71
  - **react/next**: CI와 CI+CD 분리 구성
70
72
  - **python**: CI / PR 프리뷰 / SimpleCICD
71
73
 
72
74
  ### 되돌리기(`--mode revert`)
73
75
 
74
- `npx project-auto-wizard --mode revert`는 payload가 설치한 파일명과 **정확히 일치하는 것만** 제거합니다. 사용자가 직접 만든 워크플로우, `version.yml`, `README.md`, `.gitignore`는 건드리지 않습니다. 설치 시 충돌 처리로 생성된 `.bak`/`.template.yaml` 파생 파일도 함께 정리됩니다.
76
+ `npx project-auto-wizard --mode revert --force`는 payload가 설치한 파일명과 **정확히 일치하는 것만** 제거합니다. 사용자가 직접 만든 워크플로우, `version.yml`, `README.md`, `.gitignore`는 건드리지 않습니다. 설치 시 충돌 처리로 생성된 `.bak`/`.template.yaml` 파생 파일도 함께 정리됩니다.
75
77
 
76
78
  ### 완전 삭제(`--mode uninstall`)
77
79
 
@@ -137,7 +139,7 @@ npx project-auto-wizard [옵션]
137
139
  --purge-readme --mode uninstall --force 시 README.md 버전 섹션도 제거
138
140
  --purge-gitignore --mode uninstall --force 시 .gitignore 자동 추가 항목도 제거
139
141
  --purge-version --mode uninstall --force 시 version.yml도 제거
140
- --force 전 질문 생략 (CI용)
142
+ --force full/version/workflows/revert 실행에 필수 (전 질문 생략, CI용)
141
143
  ```
142
144
 
143
145
  ## 설치 상태 확인 · 진단 · 미리보기
@@ -205,7 +207,7 @@ flowchart TB
205
207
  ## 개발
206
208
 
207
209
  ```bash
208
- npm test # node --test + python unittest (node 219 + py 87)
210
+ npm test # node --test + python unittest (node 222 + py 87)
209
211
  npm run test:node
210
212
  npm run test:py
211
213
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-auto-wizard",
3
- "version": "0.1.12",
3
+ "version": "0.1.16",
4
4
  "description": "One command DevOps: npx wizard that installs GitHub-native AI Release Automation into any project",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # PROJECT-COMMON-AI-PR-SUMMARY.yaml
3
4
  # Self-hosted AI PR summary bot — no third-party service required.
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml
3
4
  # Release PR pipeline: version confirm + AI changelog + automerge
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # PROJECT-COMMON-README-VERSION-UPDATE.yaml
3
4
  # Automatic README version update workflow
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # PROJECT-COMMON-RELEASE-PUBLISH.yaml
3
4
  # Tag + GitHub Release publisher (dual-mode)
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # PROJECT-COMMON-VERSION-CONTROL.yaml
3
4
  # Safety-net automatic version bump
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # PROJECT-COMMON-SECRET-FILE-UPLOAD.yaml
3
4
  # GitHub Secret 파일 서버(SSH) 자동 업로드
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # Flutter Android Firebase App Distribution 배포
3
4
  # ===================================================================
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # Flutter Android Play Store 내부 테스트 배포 워크플로우
3
4
  # ===================================================================
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # Flutter Android 자체 서버(SMB) 배포 워크플로우
3
4
  # ===================================================================
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # Flutter Android 테스트용 APK 빌드 워크플로우
3
4
  # ===================================================================
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ==========================================================================================
2
3
  # Flutter CI (코드 분석 및 빌드 검증) 워크플로우
3
4
  # ==========================================================================================
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # Flutter iOS 테스트용 TestFlight 배포 워크플로우
3
4
  # ===================================================================
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # Flutter iOS TestFlight 자동 배포 워크플로우
3
4
  # ===================================================================
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # Flutter 테스트 앱 빌드 트리거 워크플로우 (SUH-LAB)
3
4
  # ===================================================================
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # Next.js CI (코드 분석 및 빌드 검증)
3
4
  # ===================================================================
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # Next.js CI/CD 배포
3
4
  # ===================================================================
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # Python FastAPI 빌드 검증 워크플로우 (CI Only)
3
4
  # ===================================================================
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # Python/FastAPI PR Preview (SSH + Docker + Traefik)
3
4
  # ===================================================================
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # Python FastAPI CI/CD 배포 (SSH + Docker)
3
4
  # ===================================================================
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # React CI (코드 분석 및 빌드 검증)
3
4
  # ===================================================================
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # React CI/CD 배포
3
4
  # ===================================================================
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # Spring GitHub Packages 라이브러리 배포 워크플로우
3
4
  # ===================================================================
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # Spring Nexus CI 워크플로우
3
4
  # ===================================================================
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # Spring Nexus 라이브러리 배포 워크플로우
3
4
  # ===================================================================
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # Spring Boot 무중단 배포 (Nginx Blue-Green, SSH + Docker)
3
4
  # ===================================================================
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # Spring Boot 무중단 배포 (Traefik Blue-Green, SSH + Docker)
3
4
  # ===================================================================
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # Spring Boot PR/Issue Preview 시스템 (SSH + Docker)
3
4
  # ===================================================================
@@ -1,3 +1,4 @@
1
+ # project-auto-wizard:managed-workflow
1
2
  # ===================================================================
2
3
  # Spring Boot CI/CD 배포 (SSH + Docker)
3
4
  # ===================================================================
package/src/cli/args.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // CLI 인자 파싱 (.sh top-level while-case 등가) — template_integrator.sh 842~920.
2
- import { VALID_TYPES } from "../context.js";
2
+ import { VALID_TYPES, VALID_MODES } from "../context.js";
3
3
 
4
4
  // argv(process.argv.slice(2)) → 파싱 결과. 오류 시 throw(호출부에서 exit 1).
5
5
  export function parseArgs(argv) {
@@ -32,6 +32,7 @@ export function parseArgs(argv) {
32
32
  keepScripts: false,
33
33
  };
34
34
  const args = [...argv];
35
+ const seenFlags = new Set(); // L7: --nexus류 상호 모순 플래그 검증용
35
36
  while (args.length > 0) {
36
37
  const a = args.shift();
37
38
  switch (a) {
@@ -74,20 +75,45 @@ export function parseArgs(argv) {
74
75
  case "--keep-changelog": result.keepChangelog = true; break;
75
76
  case "--keep-workflows": result.keepWorkflows = true; break;
76
77
  case "--keep-scripts": result.keepScripts = true; break;
77
- case "--nexus": result.includeNexus = true; break;
78
- case "--no-nexus": result.includeNexus = false; break;
79
- case "--secret-backup": result.includeSecretBackup = true; break;
80
- case "--no-secret-backup": result.includeSecretBackup = false; break;
81
- case "--semver-auto": result.includeSemverAuto = true; break;
82
- case "--no-semver-auto": result.includeSemverAuto = false; break;
78
+ case "--nexus":
79
+ if (seenFlags.has("--no-nexus")) throw new CliError("--nexus와 --no-nexus는 동시에 지정할 수 없습니다");
80
+ seenFlags.add("--nexus"); result.includeNexus = true; break;
81
+ case "--no-nexus":
82
+ if (seenFlags.has("--nexus")) throw new CliError("--nexus와 --no-nexus는 동시에 지정할 수 없습니다");
83
+ seenFlags.add("--no-nexus"); result.includeNexus = false; break;
84
+ case "--secret-backup":
85
+ if (seenFlags.has("--no-secret-backup")) throw new CliError("--secret-backup과 --no-secret-backup은 동시에 지정할 수 없습니다");
86
+ seenFlags.add("--secret-backup"); result.includeSecretBackup = true; break;
87
+ case "--no-secret-backup":
88
+ if (seenFlags.has("--secret-backup")) throw new CliError("--secret-backup과 --no-secret-backup은 동시에 지정할 수 없습니다");
89
+ seenFlags.add("--no-secret-backup"); result.includeSecretBackup = false; break;
90
+ case "--semver-auto":
91
+ if (seenFlags.has("--no-semver-auto")) throw new CliError("--semver-auto와 --no-semver-auto는 동시에 지정할 수 없습니다");
92
+ seenFlags.add("--semver-auto"); result.includeSemverAuto = true; break;
93
+ case "--no-semver-auto":
94
+ if (seenFlags.has("--semver-auto")) throw new CliError("--semver-auto와 --no-semver-auto는 동시에 지정할 수 없습니다");
95
+ seenFlags.add("--no-semver-auto"); result.includeSemverAuto = false; break;
83
96
  case "--paths": result.pathsCsv = args.shift() ?? ""; break;
84
- case "--main-branch": result.mainBranch = args.shift() ?? ""; break;
85
- case "--develop-branch": result.developBranch = args.shift() ?? ""; break;
97
+ case "--main-branch": {
98
+ const v = args.shift();
99
+ if (!v) throw new CliError("--main-branch에 빈 값을 지정할 수 없습니다");
100
+ result.mainBranch = v; break;
101
+ }
102
+ case "--develop-branch": {
103
+ const v = args.shift();
104
+ if (!v) throw new CliError("--develop-branch에 빈 값을 지정할 수 없습니다");
105
+ result.developBranch = v; break;
106
+ }
86
107
  case "-h": case "--help": result.help = true; break;
87
108
  default:
88
109
  throw new CliError(`알 수 없는 옵션: ${a}`);
89
110
  }
90
111
  }
112
+ if (!VALID_MODES.includes(result.mode)) {
113
+ throw new CliError(
114
+ `지원하지 않는 모드: '${result.mode}'\n지원 모드: interactive full version workflows revert uninstall status doctor`
115
+ );
116
+ }
91
117
  return result;
92
118
  }
93
119
 
@@ -109,7 +135,7 @@ export function parsePathsCsv(csv) {
109
135
  for (const pair of csv.split(",")) {
110
136
  if (pair.trim() === "") continue;
111
137
  const eq = pair.indexOf("=");
112
- const type = (eq >= 0 ? pair.slice(0, eq) : pair).trim();
138
+ const type = (eq >= 0 ? pair.slice(0, eq) : pair).replace(/\s/g, "");
113
139
  const rawPath = eq >= 0 ? pair.slice(eq + 1) : "";
114
140
  if (!VALID_TYPES.includes(type)) {
115
141
  throw new CliError(`--paths에 지원하지 않는 타입: '${type}'`);
package/src/cli/help.js CHANGED
@@ -19,7 +19,7 @@ export const HELP_TEXT = `project-auto-wizard — One command DevOps: GitHub-nat
19
19
  --nexus / --no-nexus Nexus 라이브러리 publish 워크플로우 포함/제외
20
20
  --secret-backup / --no-secret-backup Secret 백업 워크플로우 포함/제외
21
21
  --semver-auto / --no-semver-auto 커밋 타입 기반 자동 major/minor/patch 승격 (기본: 사용함)
22
- --force 모든 확인 생략, 비대화형 기본값 사용
22
+ --force full/version/workflows/revert 실행에 필수 (모든 확인 생략, 비대화형 기본값 사용)
23
23
  --dry-run 실제 파일 변경 없이 무엇이 바뀔지만 미리 보여줌 (full/version/workflows/revert/uninstall 전체 지원)
24
24
  --purge-readme --mode uninstall --force 시 README.md 버전 섹션도 제거
25
25
  --purge-gitignore --mode uninstall --force 시 .gitignore 자동 추가 항목도 제거
@@ -29,7 +29,7 @@ export const HELP_TEXT = `project-auto-wizard — One command DevOps: GitHub-nat
29
29
 
30
30
  예시:
31
31
  npx project-auto-wizard --mode full --force --type spring,react
32
- npx project-auto-wizard --mode workflows --type flutter --paths "flutter=app"
32
+ npx project-auto-wizard --mode workflows --force --type flutter --paths "flutter=app"
33
33
  npx project-auto-wizard --mode status
34
34
  npx project-auto-wizard --mode doctor
35
35
  npx project-auto-wizard --mode full --force --type node --dry-run
@@ -6,7 +6,7 @@ import { PATHS } from "../core/paths.js";
6
6
  import { planWorkflows } from "../core/copy/workflows.js";
7
7
  import { planRevert } from "./revert.js";
8
8
  import { planUninstall } from "./uninstall.js";
9
- import { buildVersionYml } from "../core/version-yml.js";
9
+ import { buildVersionYml, parseExisting } from "../core/version-yml.js";
10
10
  import { readVersionYmlTemplate } from "../core/assets.js";
11
11
  import { markerForType } from "../core/detect.js";
12
12
 
@@ -17,18 +17,22 @@ function versionYmlPreview(context, payloadRoot, targetRoot) {
17
17
  includeSemverAuto } = context;
18
18
  const pathMarkers = new Map();
19
19
  for (const [t] of paths) pathMarkers.set(t, markerForType(t));
20
+
21
+ const vyPath = join(targetRoot, PATHS.versionFile);
22
+ const existingRaw = existsSync(vyPath) ? readFileSync(vyPath, "utf8") : null;
23
+ const extraTopLevel = existingRaw !== null ? parseExisting(existingRaw).extraTopLevel : [];
24
+
20
25
  const wouldBe = buildVersionYml({
21
26
  templateText: readVersionYmlTemplate(payloadRoot),
22
27
  version, types, paths, pathMarkers, branch, branches: context.branches, versionCode, now, today,
28
+ extraTopLevel,
23
29
  templateOptions: {
24
30
  templateVersion, includeNexus, includeSecretBackup,
25
31
  includeSemverAuto: includeSemverAuto !== false,
26
32
  optionsDate: today,
27
33
  },
28
34
  });
29
- const vyPath = join(targetRoot, PATHS.versionFile);
30
- const existing = existsSync(vyPath) ? readFileSync(vyPath, "utf8") : null;
31
- return { existed: existing !== null, changed: existing !== wouldBe };
35
+ return { existed: existingRaw !== null, changed: existingRaw !== wouldBe };
32
36
  }
33
37
 
34
38
  // mode: "full" | "version" | "workflows" | "revert" | "uninstall". 읽기 전용 — 아무 파일도 쓰지 않는다.
@@ -3,9 +3,10 @@
3
3
  // gitignore는 충돌 백업 부산물(.bak/.template.yaml)이 이번 실행에서 실제로 생겼을 때만 갱신한다 — issue #7.
4
4
  // (원본의 util/issue/discussion/setup-guide/config 설치는 project-auto-wizard 스코프에서 제외 — DESIGN-SPEC §2)
5
5
  import { join } from "node:path";
6
+ import { existsSync, readFileSync } from "node:fs";
6
7
  import { writeText } from "../core/fsutil.js";
7
8
  import { PATHS } from "../core/paths.js";
8
- import { buildVersionYml } from "../core/version-yml.js";
9
+ import { buildVersionYml, parseExisting } from "../core/version-yml.js";
9
10
  import { readVersionYmlTemplate } from "../core/assets.js";
10
11
  import { markerForType } from "../core/detect.js";
11
12
  import { addVersionSectionToReadme } from "../core/copy/readme.js";
@@ -31,12 +32,16 @@ export function runFull(context, payloadRoot, targetRoot = ".", hooks = {}) {
31
32
  const wfCounters = copyWorkflows(context, payloadRoot, targetRoot, hooks);
32
33
  const deployValues = wfCounters.deployValues || new Map(); // Map<type, Map<key,value>>
33
34
 
35
+ // 기존 version.yml의 알려지지 않은 최상위 필드를 재생성 시 보존한다 (issue #20 M8).
36
+ const vyPath = join(targetRoot, PATHS.versionFile);
37
+ const extraTopLevel = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")).extraTopLevel : [];
38
+
34
39
  // 2. version.yml 생성 (payload/version.yml.template 렌더링 — 전체 재생성 전략 D4)
35
40
  writeText(join(targetRoot, PATHS.versionFile),
36
41
  buildVersionYml({
37
42
  templateText: readVersionYmlTemplate(payloadRoot),
38
43
  version, types, paths, pathMarkers, branch, branches: context.branches, versionCode, now, today,
39
- deployValues,
44
+ deployValues, extraTopLevel,
40
45
  templateOptions: { templateVersion, includeNexus, includeSecretBackup, includeSemverAuto: includeSemverAuto !== false, optionsDate: today },
41
46
  }));
42
47
 
@@ -240,9 +240,9 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
240
240
  // 완료 요약 (.sh print_summary L5438)
241
241
  io.summary?.({
242
242
  mode, types, version, branches,
243
- counters: { workflows: result?.workflows?.copied ?? 0 },
243
+ copiedFiles: result?.workflows?.copiedFiles ?? [],
244
244
  gitignoreUpdated: result?.gitignoreUpdated === true,
245
- }, cwd);
245
+ });
246
246
  io.outro?.(`통합 완료 — ${mode} 모드로 설치했습니다.`);
247
247
  return 0;
248
248
  }
@@ -1,12 +1,21 @@
1
- // revert 모드 — payload 유래 파일만 제거 (DESIGN-SPEC §4 되돌리기).
2
- // 원칙: payload에 존재하는 파일명과 정확히 일치하는 것만 지운다.
1
+ // revert 모드 — payload 유래 파일 + 마커로 식별되는 파일을 제거 (DESIGN-SPEC §4 되돌리기).
2
+ // 원칙: (a) 현재 payload에 존재하는 파일명과 정확히 일치하는 것, (b) 설치된 워크플로우 파일 중
3
+ // 마법사 관리 마커(MANAGED_WORKFLOW_MARKER)로 시작하는 것 — 이 둘의 합집합을 제거 대상으로 삼는다.
4
+ // (a)만으로는 과거 버전에서 설치된 뒤 이후 릴리스에서 payload 파일명이 바뀌거나 삭제된 파일을
5
+ // 인식하지 못했다 (issue #20 L12 원래 버그). 반대로 (b)만으로는 이 수정 이전에 설치되어 마커가
6
+ // 없는(그러나 파일명은 여전히 현재 payload와 일치하는) 기존 설치 전체를 인식하지 못하는 회귀가
7
+ // 생긴다 — 그래서 두 방식을 합집합으로 병행한다. 마커는 이 수정 이후 배포되는 payload 템플릿부터
8
+ // 포함되므로, (b) 경로가 실제로 새로 잡아내는 것은 "이름이 바뀌거나 삭제된, 마커가 있는" 파일뿐이다.
3
9
  // 사용자가 직접 만든 워크플로우·version.yml·README·.gitignore는 건드리지 않는다
4
10
  // (version.yml은 사용자 버전 데이터 — 제거 대상이 아니라 산출물이다).
5
11
  import { join } from "node:path";
6
- import { existsSync, readdirSync } from "node:fs";
12
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
7
13
  import { PATHS, PAYLOAD } from "../core/paths.js";
8
14
  import { remove } from "../core/fsutil.js";
9
15
 
16
+ // payload/workflows/**/*.yaml 첫 줄에 심어둔 고정 마커 — 이 값이 바뀌면 과거 설치분과의 매칭이 끊긴다.
17
+ export const MANAGED_WORKFLOW_MARKER = "# project-auto-wizard:managed-workflow";
18
+
10
19
  // payload/workflows/** 전체(하위 폴더 포함)의 yaml 파일명 집합.
11
20
  function payloadWorkflowNames(payloadRoot) {
12
21
  const names = new Set();
@@ -18,26 +27,41 @@ function payloadWorkflowNames(payloadRoot) {
18
27
  return names;
19
28
  }
20
29
 
21
- // payload에 존재하는 파일명과 정확히 일치하는 것만 제거 대상으로 계획한다.
30
+ // 설치된 워크플로우 디렉토리(평면 구조)에서 관리 마커로 시작하는 파일명 집합.
31
+ // .bak/.template.yaml 백업본도 원본 텍스트를 그대로 복사한 것이라 마커를 그대로 갖고 있어
32
+ // 확장자나 파일명 유추 없이 동일한 방식으로 인식된다 (issue #20 L12).
33
+ function markedWorkflowNames(wfDir) {
34
+ const names = new Set();
35
+ if (!existsSync(wfDir)) return names;
36
+ for (const entry of readdirSync(wfDir, { withFileTypes: true })) {
37
+ if (!entry.isFile()) continue;
38
+ const firstLine = readFileSync(join(wfDir, entry.name), "utf8").split("\n", 1)[0].replace(/\r$/, "");
39
+ if (firstLine === MANAGED_WORKFLOW_MARKER) names.add(entry.name);
40
+ }
41
+ return names;
42
+ }
43
+
22
44
  // 아무것도 지우지 않는 순수 함수 — --dry-run과 status류 기능에서 재사용.
23
45
  export function planRevert(payloadRoot, targetRoot = ".") {
24
- const removedWf = [];
46
+ const removedWf = new Set();
25
47
  const removedScripts = [];
26
48
  const wfDir = join(targetRoot, PATHS.workflowsDir);
27
- const names = payloadWorkflowNames(payloadRoot);
28
49
  if (existsSync(wfDir)) {
29
- for (const name of names) {
50
+ // (a) 현재 payload와 파일명이 일치하는 것 — 기존 동작 그대로(마커 유무 무관, 기존 설치 회귀 방지).
51
+ for (const name of payloadWorkflowNames(payloadRoot)) {
30
52
  const p = join(wfDir, name);
31
- if (existsSync(p)) removedWf.push(name);
53
+ if (existsSync(p)) removedWf.add(name);
32
54
  const templateName = (name.endsWith(".yaml") ? name.slice(0, -".yaml".length) : name) + ".template.yaml";
33
- if (existsSync(join(wfDir, templateName))) removedWf.push(templateName);
34
- if (existsSync(p + ".bak")) removedWf.push(name + ".bak");
55
+ if (existsSync(join(wfDir, templateName))) removedWf.add(templateName);
56
+ if (existsSync(p + ".bak")) removedWf.add(name + ".bak");
35
57
  }
58
+ // (b) 관리 마커로 시작하는 것 — payload에서 이름이 바뀌거나 삭제된 파일도 인식 (issue #20 L12).
59
+ for (const name of markedWorkflowNames(wfDir)) removedWf.add(name);
36
60
  }
37
61
  for (const s of ["version_manager.py", "changelog_manager.py"]) {
38
62
  if (existsSync(join(targetRoot, PATHS.scriptsDir, s))) removedScripts.push(s);
39
63
  }
40
- return { workflows: removedWf, scripts: removedScripts };
64
+ return { workflows: [...removedWf], scripts: removedScripts };
41
65
  }
42
66
 
43
67
  // 반환: { workflows: [...제거된 파일명], scripts: [...] } — planRevert와 동일한 형태.
@@ -3,9 +3,10 @@
3
3
  // (워크플로우를 복사하지 않으므로 충돌 백업 부산물이 생길 수 없다 — gitignore 갱신 대상 없음, issue #7.
4
4
  // util·issue·setup-guide는 스코프 제외.)
5
5
  import { join } from "node:path";
6
+ import { existsSync, readFileSync } from "node:fs";
6
7
  import { writeText } from "../core/fsutil.js";
7
8
  import { PATHS } from "../core/paths.js";
8
- import { buildVersionYml } from "../core/version-yml.js";
9
+ import { buildVersionYml, parseExisting } from "../core/version-yml.js";
9
10
  import { readVersionYmlTemplate } from "../core/assets.js";
10
11
  import { markerForType } from "../core/detect.js";
11
12
  import { addVersionSectionToReadme } from "../core/copy/readme.js";
@@ -20,10 +21,15 @@ export function runVersion(context, payloadRoot, targetRoot = ".") {
20
21
  const pathMarkers = new Map();
21
22
  for (const [t] of paths) pathMarkers.set(t, markerForType(t));
22
23
 
24
+ // 기존 version.yml의 알려지지 않은 최상위 필드를 재생성 시 보존한다 (issue #20 M8).
25
+ const vyPath = join(targetRoot, PATHS.versionFile);
26
+ const extraTopLevel = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")).extraTopLevel : [];
27
+
23
28
  writeText(join(targetRoot, PATHS.versionFile),
24
29
  buildVersionYml({
25
30
  templateText: readVersionYmlTemplate(payloadRoot),
26
31
  version, types, paths, pathMarkers, branch, branches: context.branches, versionCode, now, today,
32
+ extraTopLevel,
27
33
  templateOptions: { templateVersion, includeNexus, includeSecretBackup, includeSemverAuto: includeSemverAuto !== false, optionsDate: today },
28
34
  }));
29
35
  addVersionSectionToReadme(version, targetRoot);
@@ -7,6 +7,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { PATHS } from "../core/paths.js";
8
8
  import { copyWorkflows } from "../core/copy/workflows.js";
9
9
  import { copyScripts } from "../core/copy/simple.js";
10
+ import { escapeYamlDoubleQuoted } from "../core/wizard-env.js";
10
11
 
11
12
  export function runWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}) {
12
13
  const wf = copyWorkflows(context, payloadRoot, targetRoot, hooks);
@@ -42,7 +43,9 @@ export function upsertDeployBlock(content, deployValues) {
42
43
  text += `\ndeploy: # 마법사가 기억하는 배포 설정 (비민감 / 직접 수정 가능)\n`;
43
44
  for (const t of deployTypes) {
44
45
  text += ` ${t}:\n`;
45
- for (const [k, v] of deployValues.get(t)) text += ` ${k}: "${v}"\n`;
46
+ // 동일한 이스케이프를 재사용 deploy 값도 @wizard ask 값과 같은 경로로 들어오므로
47
+ // 큰따옴표가 섞이면 YAML이 깨진다 (issue #20 L9, 세 번째 지점).
48
+ for (const [k, v] of deployValues.get(t)) text += ` ${k}: "${escapeYamlDoubleQuoted(v)}"\n`;
46
49
  }
47
50
  }
48
51
  return text;
package/src/context.js CHANGED
@@ -4,6 +4,13 @@ export const VALID_TYPES = [
4
4
  "react-native", "react-native-expo", "node", "python", "basic",
5
5
  ];
6
6
 
7
+ // --mode 화이트리스트 (issue #19) — 알 수 없는 값은 부수효과(브랜치 조회 등) 이전에 즉시 거부해야 한다.
8
+ // purge는 --help/대화형 메뉴에 노출하지 않는 숨김 모드(issue #6)이지만 검증 대상에는 포함한다.
9
+ export const VALID_MODES = [
10
+ "interactive", "full", "version", "workflows",
11
+ "revert", "uninstall", "status", "doctor", "purge",
12
+ ];
13
+
7
14
  export const DEFAULT_VERSION = "0.0.0"; // 패키지 버전 읽기 실패 시 폴백 (배너용 — breaking 비교엔 안 씀)
8
15
 
9
16
  export function createContext(overrides = {}) {
@@ -1,10 +1,11 @@
1
1
  // .gitignore 보장 — 마법사 자신이 만드는 충돌 백업 부산물(*.bak, *.template.yaml)만 대상으로 한다.
2
2
  // 마법사가 설치하는 것과 무관한 개인 개발환경 설정(IDE 등)은 마법사 책임 범위 밖 — issue #7.
3
- // 주의(의도된 트레이드오프): 변경 이전 버전으로 설치해 /.idea·/.claude/settings.local.json이 이미
4
- // 배너 블록에 남아있는 레포에서 removeAutoAddedEntriesFromGitignore()를 실행하면, 배너는 제거되지만
5
- // REQUIRED_ENTRIES가 이상 항목과 일치하지 않아 줄은 지워지지 않고 배너 표시 없이 남는다.
6
- // 이슈 #7이 "이미 설치된 레포의 .gitignore는 소급 처리하지 않고 사용자 판단에 맡긴다"고 명시하므로
7
- // 별도 마이그레이션 로직을 추가하지 않는다 남은 항목은 무해하며 필요하면 사용자가 직접 지운다.
3
+ // 배너 블록은 종료 마커(BANNER_END) 범위가 명확히 구분되어 있어, 안에 사용자가 다른 줄을
4
+ // 끼워넣어도 REQUIRED_ENTRIES만 정확히 개별 제거하고 나머지는 보존한다 (issue #20 M7).
5
+ // 주의(의도된 트레이드오프): 수정 이전 버전으로 설치되어 종료 마커가 없는 배너가 이미 있는
6
+ // 레포에서는, 배너 직후 REQUIRED_ENTRIES가 연속으로 이어지는 동안만 제거하는 방식으로
7
+ // 폴백한다 issue #7이 "이미 설치된 레포의 .gitignore는 소급 처리하지 않고 사용자 판단에
8
+ // 맡긴다"고 명시하므로 별도 마이그레이션 로직을 추가하지 않는다.
8
9
  import { join } from "node:path";
9
10
  import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
10
11
 
@@ -57,19 +58,28 @@ export function ensureGitignore(targetRoot = ".") {
57
58
  content += "# project-auto-wizard: Auto-added entries\n";
58
59
  content += "# ====================================================================\n";
59
60
  for (const e of toAdd) content += e + "\n";
61
+ content += BANNER_END;
60
62
  writeFileSync(p, content);
61
63
  return { created: false, added: toAdd };
62
64
  }
63
65
 
64
66
  // ensureGitignore가 기존 파일에 배너 블록을 추가할 때 항상 이 정확한 시퀀스로 시작한다
65
- // (빈 줄 하나 + 3줄 배너). 배너 직후, REQUIRED_ENTRIES 일치하는 라인이 연속되는 동안만
66
- // "마법사가 추가한 항목"이다 뒤에 사용자가 나중에 추가한 항목은 절대 건드리지 않는다.
67
+ // (빈 줄 하나 + 3줄 배너), 그리고 REQUIRED_ENTRIES 뒤에 BANNER_END로 끝난다. 배너~BANNER_END
68
+ // 범위 안에서 REQUIRED_ENTRIES와 일치하는 줄만 개별 제거하고, 사용자가 사이/뒤에 추가한
69
+ // 줄은 절대 건드리지 않는다 (issue #20 M7).
67
70
  const BANNER =
68
71
  "\n" +
69
72
  "# ====================================================================\n" +
70
73
  "# project-auto-wizard: Auto-added entries\n" +
71
74
  "# ====================================================================\n";
72
75
 
76
+ // REQUIRED_ENTRIES 뒤에 오는 종료 마커 — 배너 블록의 "끝"을 명확히 구분해, 그 사이에 사용자가
77
+ // 다른 줄을 끼워넣어도 removeAutoAddedEntriesFromGitignore가 정확한 범위 안에서 개별 제거할 수
78
+ // 있게 한다 (issue #20 M7). ensureGitignore가 기존 파일에 배너를 추가할 때만 함께 쓴다
79
+ // (신규 파일 생성 케이스는 NEW_FILE_CONTENT를 통째로 쓰고 종료 마커는 쓰지 않는다 —
80
+ // startsWith 전체 prefix 매칭이라 별도 마커가 필요 없다).
81
+ const BANNER_END = "# ==== project-auto-wizard: end of auto-added entries ====\n";
82
+
73
83
  // .gitignore에 마법사가 추가한 항목이 있는지 확인 (체크리스트 노출 판단용).
74
84
  // 두 케이스: (1) 원래 없던 파일을 통째로(또는 그 뒤에 사용자가 이어 쓴 형태로) 새로 만든 경우
75
85
  // (2) 기존 파일에 배너 블록을 붙인 경우.
@@ -97,11 +107,26 @@ export function removeAutoAddedEntriesFromGitignore(targetRoot = ".") {
97
107
  return "removed";
98
108
  }
99
109
 
100
- // 기존 파일에 배너 블록이 붙은 경우 — 배너 직후 REQUIRED_ENTRIES와 일치하는 라인이 연속되는
101
- // 동안만 제거하고, 그 뒤(사용자가 나중에 추가한 항목)는 그대로 둔다.
110
+ // 기존 파일에 배너 블록이 붙은 경우.
102
111
  const idx = content.indexOf(BANNER);
103
112
  if (idx === -1) return "skip-not-found";
104
113
  const afterBanner = idx + BANNER.length;
114
+
115
+ // 종료 마커가 있으면(이 수정 이후 설치분) 그 범위 안에서 REQUIRED_ENTRIES만 개별적으로
116
+ // 제거하고, 사용자가 그 사이에 끼워넣은 줄은 순서·연속 여부와 무관하게 그대로 보존한다
117
+ // (issue #20 M7).
118
+ const endIdx = content.indexOf(BANNER_END, afterBanner);
119
+ if (endIdx !== -1) {
120
+ const region = content.slice(afterBanner, endIdx).split("\n");
121
+ const kept = region.filter((line) =>
122
+ !REQUIRED_ENTRIES.some((e) => normalizeGitignoreEntry(line) === normalizeGitignoreEntry(e)));
123
+ const afterEndMarker = content.slice(endIdx + BANNER_END.length);
124
+ writeFileSync(p, content.slice(0, idx) + kept.join("\n") + afterEndMarker);
125
+ return "removed";
126
+ }
127
+
128
+ // 종료 마커가 없는 구버전 설치(이 수정 이전) — 배너 직후 REQUIRED_ENTRIES가 연속으로
129
+ // 이어지는 동안만 제거하는 기존 방식으로 폴백한다 (issue #7과 동일한 소급 미처리 원칙).
105
130
  const lines = content.slice(afterBanner).split("\n");
106
131
  let consumed = 0;
107
132
  for (const line of lines) {
@@ -69,27 +69,26 @@ export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}
69
69
  const counters = { copied: 0, skipped: 0, templateAdded: 0, optionalCopied: 0, backupAdded: 0 };
70
70
  const deployValues = new Map(); // Map<type, Map<key,value>> — deploy 블록용 ask 값
71
71
  counters.deployValues = deployValues;
72
+ counters.copiedFiles = []; // 이번 실행에서 실제로 새로 쓰여진 파일명 (issue #19 — printSummary 정확성용)
72
73
  const srcText = makeSrcText(context.branches || null);
73
74
  // values/useDefaults는 치환 경로에서만 의미 (isUnchanged는 내부에서 useDefaults:true 강제 — 가상 비교 무손상)
74
75
  const envOptsFor = (type) => ({ type, projectPath: paths.get(type) || ".", repoName, resolvers, values: envValues, useDefaults: envUseDefaults });
75
76
 
76
- // (1) common — unchanged면 스킵, 아니면 무조건 덮어쓰기.
77
+ // (1) common — 타입별 워크플로우와 동일한 3지선(README 계약, issue #20 H3): unchanged면 스킵,
78
+ // changed면 decisions Map 결정에 따름(미지정 시 skip — 기존 사용자 수정 보존).
77
79
  // trunk-based 모드는 VERSION-CONTROL·AUTO-CHANGELOG 미설치 (RELEASE-PUBLISH 단독).
78
80
  const branchMode = context.branches?.mode || "pr-flow";
79
81
  const commonDir = join(projectTypesDir, "common");
80
82
  if (exists(commonDir)) {
81
- for (const filename of listYamlFiles(commonDir)) {
82
- if (branchMode === "trunk-based" && TRUNK_BASED_EXCLUDED.has(filename)) continue;
83
- const src = join(commonDir, filename);
84
- const dst = join(workflowsDir, filename);
85
- const body = srcText(src);
86
- if (existsSync(dst) && isUnchanged(body, readFileSync(dst, "utf8"), envOptsFor("common"))) {
87
- counters.skipped++;
88
- continue;
89
- }
90
- writeText(dst, body);
83
+ const notExcluded = (filename) => !(branchMode === "trunk-based" && TRUNK_BASED_EXCLUDED.has(filename));
84
+ const { newFiles, unchanged, changed } = classify(commonDir, workflowsDir, envOptsFor("common"), srcText);
85
+ counters.skipped += unchanged.filter(notExcluded).length;
86
+ for (const f of newFiles.filter(notExcluded)) {
87
+ writeText(join(workflowsDir, f), srcText(join(commonDir, f)));
91
88
  counters.copied++;
89
+ counters.copiedFiles.push(f);
92
90
  }
91
+ for (const f of changed.filter(notExcluded)) applyDecision(decisions.get(f), commonDir, workflowsDir, f, counters, srcText);
93
92
  }
94
93
 
95
94
  // (2~4) 타입별
@@ -108,6 +107,7 @@ export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}
108
107
  writeText(dst, srcText(join(secretDir, filename)));
109
108
  counters.optionalCopied++;
110
109
  counters.copied++;
110
+ counters.copiedFiles.push(filename);
111
111
  }
112
112
  }
113
113
 
@@ -125,6 +125,7 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcTe
125
125
  writeText(dst, srcText(src));
126
126
  counters.copied++;
127
127
  counters.backupAdded++;
128
+ counters.copiedFiles.push(filename);
128
129
  return;
129
130
  }
130
131
  if (decision === "template") {
@@ -132,6 +133,7 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcTe
132
133
  const templateName = (filename.endsWith(".yaml") ? filename.slice(0, -".yaml".length) : filename) + ".template.yaml";
133
134
  writeText(join(workflowsDir, templateName), srcText(src)); // 기존 .template.yaml 덮어씀(.sh rm -f + cp 등가)
134
135
  counters.templateAdded++;
136
+ counters.copiedFiles.push(templateName);
135
137
  return;
136
138
  }
137
139
  counters.skipped++; // 'skip'/미지정/ESC → 기존 유지 (.sh S)·force 기본)
@@ -139,12 +141,24 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcTe
139
141
 
140
142
  // 대상 워크플로우 디렉토리에서 changed(충돌) 파일 목록만 뽑는다 — copyWorkflowsInteractive의 사전 조사용.
141
143
  // copyWorkflows 본체와 동일한 classify 기준을 써야 결정 Map이 실제 처리 대상과 1:1로 맞는다.
144
+ // common도 타입별과 동일하게 스캔한다 (issue #20 H3 — 이전에는 common 충돌이 질문조차 되지 않았다).
142
145
  export function listWorkflowConflicts(context, payloadRoot, targetRoot = ".") {
143
146
  const { types = [], paths = new Map(), includeNexus = false, repoName = "", resolvers = {} } = context;
144
147
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
145
148
  const projectTypesDir = join(payloadRoot, PAYLOAD.workflowsDir);
146
149
  const srcText = makeSrcText(context.branches || null);
147
- const conflicts = []; // [{ filename, type }] — 엔진 처리 순서와 동일 (타입 순회 → 직하위 → server-deploy)
150
+ const branchMode = context.branches?.mode || "pr-flow";
151
+ const conflicts = []; // [{ filename, type }] — 엔진 처리 순서와 동일 (common → 타입 순회 → 직하위 → server-deploy)
152
+
153
+ const commonDir = join(projectTypesDir, "common");
154
+ if (exists(commonDir)) {
155
+ const envOpts = { type: "common", projectPath: ".", repoName, resolvers };
156
+ for (const f of classify(commonDir, workflowsDir, envOpts, srcText).changed) {
157
+ if (branchMode === "trunk-based" && TRUNK_BASED_EXCLUDED.has(f)) continue;
158
+ conflicts.push({ filename: f, type: "common" });
159
+ }
160
+ }
161
+
148
162
  for (const type of types) {
149
163
  const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers };
150
164
  const typeDir = join(projectTypesDir, type);
@@ -185,7 +199,11 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
185
199
  const { newFiles, unchanged, changed } = classify(typeDir, workflowsDir, envOpts, srcText);
186
200
  unchangedNames = unchanged.slice();
187
201
  for (const f of unchanged) counters.skipped++;
188
- for (const f of newFiles) { writeText(join(workflowsDir, f), srcText(join(typeDir, f))); counters.copied++; }
202
+ for (const f of newFiles) {
203
+ writeText(join(workflowsDir, f), srcText(join(typeDir, f)));
204
+ counters.copied++;
205
+ counters.copiedFiles.push(f);
206
+ }
189
207
  // changed: 결정 Map에 따라 처리 (미지정=skip → 현행 force 동작과 동일)
190
208
  for (const f of changed) applyDecision(decisions.get(f), typeDir, workflowsDir, f, counters, srcText);
191
209
  }
@@ -198,7 +216,11 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
198
216
  } else {
199
217
  const { newFiles, unchanged, changed } = classify(serverDeployDir, workflowsDir, envOpts, srcText);
200
218
  for (const f of unchanged) counters.skipped++;
201
- for (const f of newFiles) { writeText(join(workflowsDir, f), srcText(join(serverDeployDir, f))); counters.copied++; }
219
+ for (const f of newFiles) {
220
+ writeText(join(workflowsDir, f), srcText(join(serverDeployDir, f)));
221
+ counters.copied++;
222
+ counters.copiedFiles.push(f);
223
+ }
202
224
  for (const f of changed) applyDecision(decisions.get(f), serverDeployDir, workflowsDir, f, counters, srcText);
203
225
  }
204
226
  }
@@ -218,6 +240,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
218
240
  writeText(dst, body);
219
241
  counters.optionalCopied++;
220
242
  counters.copied++;
243
+ counters.copiedFiles.push(filename);
221
244
  }
222
245
  }
223
246
 
@@ -234,8 +257,8 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
234
257
  }
235
258
 
236
259
  // 전체 워크플로우 분류(common + 타입별 + server-deploy + nexus opt-in) — status/dry-run 공용.
237
- // listWorkflowConflicts와 달리 common 디렉토리도 포함하고, changed뿐 아니라
238
- // newFiles/unchanged까지 전부 반환한다(읽기 전용 — 실제로 아무 파일도 쓰지 않는다).
260
+ // changed뿐 아니라 newFiles/unchanged까지 전부 반환한다는 점이 listWorkflowConflicts(changed
261
+ // 반환)와 다르다(읽기 전용 — 실제로 아무 파일도 쓰지 않는다).
239
262
  export function planWorkflows(context, payloadRoot, targetRoot = ".") {
240
263
  const { types = [], paths = new Map(), includeNexus = false, includeSecretBackup = false, repoName = "", resolvers = {} } = context;
241
264
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
@@ -10,7 +10,7 @@
10
10
  import { existsSync, readdirSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { markerForType as baseMarkerForType, extraMarkers } from "./detect.js";
13
- import { normalizePath } from "../cli/args.js";
13
+ import { normalizePath, CliError } from "../cli/args.js";
14
14
 
15
15
  // 취소(ESC/Ctrl+C)는 CANCEL 심볼 — ui를 import하지 않고 심볼 여부로만 판정 (core→ui 역참조 방지)
16
16
  const isCancel = (v) => typeof v === "symbol";
@@ -119,7 +119,7 @@ export function findTypePathCandidates(root, type) {
119
119
  // ② 루트에 마커 존재 → "." 자동
120
120
  // ③ existingPaths(version.yml 저장값)
121
121
  // ④ 후보 스캔
122
- // ⑤ 분기 — 비대화형: 기존값→후보1개→루트 폴백 / 대화형: 확인·선택·직접입력
122
+ // ⑤ 분기 — 비대화형: 기존값→후보1개→에러 / 대화형: 확인·선택·직접입력
123
123
  export async function resolveProjectPaths({
124
124
  root, types = [], paths = new Map(), existingPaths = new Map(),
125
125
  force = false, tty = true, io = {},
@@ -146,7 +146,11 @@ export async function resolveProjectPaths({
146
146
 
147
147
  // ① --paths 등으로 이미 지정됨 → 최우선 (.sh L1441~1446)
148
148
  if (result.get(t)) {
149
- say(` ${t} ${result.get(t)} (--paths 지정)`);
149
+ const p = result.get(t);
150
+ if (!existsSync(join(root, p))) {
151
+ throw new CliError(`--paths로 지정한 경로가 존재하지 않습니다: '${t}=${p}'`);
152
+ }
153
+ say(` ${t} → ${p} (--paths 지정)`);
150
154
  continue;
151
155
  }
152
156
 
@@ -165,7 +169,7 @@ export async function resolveProjectPaths({
165
169
  const candidates = findTypePathCandidates(root, t);
166
170
  let chosen = "";
167
171
 
168
- // ── ⑤-a 비대화형 (--force 또는 TTY 없음, .sh L1476~1489) ──
172
+ // ── ⑤-a 비대화형 (--force 또는 TTY 없음, .sh L1476~1489 — M4: root 폴백 의도적 불포함) ──
169
173
  if (force || !tty) {
170
174
  if (existing) {
171
175
  chosen = existing;
@@ -173,9 +177,10 @@ export async function resolveProjectPaths({
173
177
  } else if (candidates.length === 1) {
174
178
  chosen = candidates[0];
175
179
  say(` ${t} → ${chosen} (자동 감지)`);
180
+ } else if (candidates.length === 0) {
181
+ throw new CliError(`${t}: 프로젝트 경로를 찾지 못했습니다. --paths "${t}=경로"로 직접 지정하세요.`);
176
182
  } else {
177
- chosen = ".";
178
- say(` ⚠️ ${t} → 후보 ${candidates.length}개로 자동 확정 불가, 루트(.)로 기록 (--paths "${t}=경로"로 지정 가능)`);
183
+ throw new CliError(`${t}: 경로 후보가 ${candidates.length}개로 모호합니다(${candidates.join(", ")}). --paths "${t}=경로"로 직접 지정하세요.`);
179
184
  }
180
185
  result.set(t, chosen);
181
186
  continue;
@@ -1,7 +1,35 @@
1
+ import { escapeYamlDoubleQuoted } from "./wizard-env.js";
2
+
1
3
  // version.yml 파싱·생성 (.sh create_version_yml 등가, 전체 재생성 전략 D4).
2
4
  // ⚠️ YAML 재직렬화 금지 — 주석이 데이터.
3
5
  // 레이아웃 단일 진실 = payload/version.yml.template (호출부가 templateText로 주입).
4
6
 
7
+ // version.yml.template이 아는 최상위 키 — 이 밖의 최상위 키는 사용자가 직접 추가한 것으로 간주한다.
8
+ const KNOWN_TOP_LEVEL_KEYS = new Set([
9
+ "version", "version_code", "project_types", "project_type", "project_paths", "metadata", "deploy",
10
+ ]);
11
+
12
+ // 최상위 레벨의 알려지지 않은 필드(사용자가 직접 추가한 임의 필드)를 원본 그대로 보존한다
13
+ // (issue #20 M8). 각 알려지지 않은 최상위 키부터 다음 최상위 키 직전까지를 통째로 한 블록으로
14
+ // 캡처한다(중첩 구조가 있어도 유효한 YAML로 남기기 위함). metadata/project_paths/deploy처럼
15
+ // 이 모듈이 이미 아는 블록 "내부"의 알려지지 않은 하위 키는 대상이 아니다(범위 밖 — issue #20 결정).
16
+ export function parseExtraTopLevel(content) {
17
+ const blocks = [];
18
+ let current = null;
19
+ for (const line of String(content || "").split("\n")) {
20
+ // 최상위 키는 하이픈을 포함할 수 있다(YAML 관례) — issue #20 M8 리뷰에서 지적된 놓침 방지.
21
+ const m = line.match(/^([a-zA-Z_][a-zA-Z0-9_-]*):/);
22
+ if (m) {
23
+ if (current) blocks.push(current.join("\n"));
24
+ current = KNOWN_TOP_LEVEL_KEYS.has(m[1]) ? null : [line];
25
+ continue;
26
+ }
27
+ if (current) current.push(line);
28
+ }
29
+ if (current) blocks.push(current.join("\n"));
30
+ return blocks;
31
+ }
32
+
5
33
  // metadata.template.options 상태머신 파싱 (.sh read_template_options L2361~2416 등가).
6
34
  // 반환: { nexus: bool|null, secretBackup: bool|null } — null=미기재.
7
35
  // 구 synology·coderabbit 키 등 다른 키는 어느 분기에도 안 걸려 자연히 무시된다(파싱 에러 없음).
@@ -92,7 +120,7 @@ export function parseExisting(content) {
92
120
  const options = parseTemplateOptions(text);
93
121
  // metadata.template.branches — main/develop/mode (업데이트 모드 재질문 생략용)
94
122
  const branches = parseTemplateBranches(text);
95
- return { version, versionCode, types, paths, templateVersion, options, branches };
123
+ return { version, versionCode, types, paths, templateVersion, options, branches, extraTopLevel: parseExtraTopLevel(text) };
96
124
  }
97
125
 
98
126
  // metadata.template.branches 블록 파싱. 셋 다 있어야 유효 — 아니면 null.
@@ -120,7 +148,8 @@ export function parseTemplateBranches(content) {
120
148
 
121
149
  // version.yml 전체 생성 — payload/version.yml.template 렌더링.
122
150
  // opts: { templateText, version, types:[], primaryType?, paths:Map, pathMarkers?:Map,
123
- // branch, branches?, versionCode, now, today, templateOptions?, deployValues? }
151
+ // branch, branches?, versionCode, now, today, templateOptions?, deployValues?,
152
+ // extraTopLevel?:string[] } ← 기존 version.yml의 알려지지 않은 최상위 필드 보존 (issue #20 M8)
124
153
  // templateText = payload/version.yml.template 원문 (readVersionYmlTemplate — 필수)
125
154
  // now = "YYYY-MM-DD HH:MM:SS" (UTC) — 결정성 위해 주입 / today = "YYYY-MM-DD"
126
155
  // branches = { main, develop, mode } (resolveBranchConfig 결과. 없으면 branch 기반 기본값)
@@ -129,7 +158,7 @@ export function parseTemplateBranches(content) {
129
158
  export function buildVersionYml({
130
159
  templateText, version, types = [], primaryType, paths = new Map(), pathMarkers = new Map(),
131
160
  branch = "main", branches = null, versionCode = 1, now, today,
132
- templateOptions = null, deployValues = new Map(),
161
+ templateOptions = null, deployValues = new Map(), extraTopLevel = [],
133
162
  }) {
134
163
  if (!templateText) throw new Error("version.yml.template 원문이 필요합니다 (payload/version.yml.template 누락?)");
135
164
  const typesJson = types.length ? `[${types.map((t) => `"${t}"`).join(", ")}]` : `["basic"]`;
@@ -159,7 +188,9 @@ export function buildVersionYml({
159
188
  const rows = ["", "deploy: # 마법사가 기억하는 배포 설정 (비민감 / 직접 수정 가능)"];
160
189
  for (const t of deployTypes) {
161
190
  rows.push(` ${t}:`);
162
- for (const [k, v] of deployValues.get(t)) rows.push(` ${k}: "${v}"`);
191
+ // 동일한 이스케이프를 재사용 deploy 값도 @wizard ask 값과 같은 경로로 들어오므로
192
+ // 큰따옴표가 섞이면 setEnvLine과 동일하게 YAML이 깨진다 (issue #20 L9, 두 번째 지점).
193
+ for (const [k, v] of deployValues.get(t)) rows.push(` ${k}: "${escapeYamlDoubleQuoted(v)}"`);
163
194
  }
164
195
  deployBlock = rows.join("\n");
165
196
  }
@@ -185,6 +216,10 @@ export function buildVersionYml({
185
216
  }));
186
217
  }
187
218
  let text = out.join("\n");
219
+ if (extraTopLevel.length) {
220
+ if (!text.endsWith("\n")) text += "\n";
221
+ text += "\n" + extraTopLevel.join("\n\n");
222
+ }
188
223
  if (!text.endsWith("\n")) text += "\n";
189
224
  return text.replace(/\n{3,}$/, "\n"); // 말미 과잉 빈 줄 정리
190
225
  }
@@ -16,6 +16,14 @@ export function parseWizardLine(line) {
16
16
  return { indent: km[1], key: km[2], action: marker[1], arg: marker[2].trim() };
17
17
  }
18
18
 
19
+ // YAML 큰따옴표 문자열 안에 안전하게 넣기 위한 이스케이프(백슬래시 우선 — 그래야 그 다음에 붙이는
20
+ // 큰따옴표 이스케이프가 깨지지 않는다). @wizard 치환(setEnvLine)과 version.yml의 deploy 블록
21
+ // (buildVersionYml, src/core/version-yml.js) 양쪽에서 재사용한다 — issue #20 L9는 두 지점 모두에서
22
+ // 발생하는 동일한 버그다.
23
+ export function escapeYamlDoubleQuoted(value) {
24
+ return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
25
+ }
26
+
19
27
  // .sh _wf_set_env 등가: `KEY: "..."` 따옴표 안 값 치환 + 그 줄 끝 `# @wizard ...` 주석 제거.
20
28
  // 라인 하나에 대해 수행. value가 빈문자면 (.sh는 [ -n "$_val" ] 가드) 치환 스킵.
21
29
  export function setEnvLine(line, key, value) {
@@ -23,10 +31,11 @@ export function setEnvLine(line, key, value) {
23
31
  // CRLF 안전: 라인 끝 \r을 분리해 처리 후 복원 (autocrlf 프로젝트 대응)
24
32
  const cr = line.endsWith("\r") ? "\r" : "";
25
33
  const body = cr ? line.slice(0, -1) : line;
34
+ const escaped = escapeYamlDoubleQuoted(value);
26
35
  // 값 치환: KEY: "기존값" → KEY: "value"
27
36
  let out = body.replace(
28
37
  new RegExp(`^(\\s*${key}:\\s*")[^"]*(")`),
29
- (_m, p1, p2) => `${p1}${value}${p2}`,
38
+ (_m, p1, p2) => `${p1}${escaped}${p2}`,
30
39
  );
31
40
  // 그 줄 끝 # @wizard ... 주석 제거 (앞 공백째)
32
41
  out = out.replace(/(\S)[^\S\r\n]*#[^\S\r\n]*@wizard[^\S\r\n].*$/, "$1");
package/src/index.js CHANGED
@@ -93,9 +93,10 @@ export async function run(argv, {
93
93
 
94
94
  // revert 모드 — payload 유래 파일 제거 (감지·질문 불필요, --force 게이트만)
95
95
  if (opts.mode === "revert") {
96
+ // TTY 여부와 무관하게 --force가 없으면 거부한다 (issue #19 — TTY에서 확인 없이 즉시 실행되던 결함 수정).
96
97
  // --dry-run은 파일을 쓰지 않으므로 --force 게이트를 우회한다 (status/doctor와 동일한 안전성).
97
- if (!opts.force && !opts.dryRun && !process.stdout.isTTY) {
98
- console.error("비대화형 환경에서는 --force 옵션이 필요합니다.");
98
+ if (!opts.force && !opts.dryRun) {
99
+ console.error("revert 모드는 --force 없이 실행할 수 없습니다 (확인 절차가 없습니다).");
99
100
  return 1;
100
101
  }
101
102
  if (opts.dryRun) {
@@ -220,10 +221,11 @@ export async function run(argv, {
220
221
  printDoctorReport(runDoctor(cwd));
221
222
  return 0;
222
223
  }
223
- // 명시 모드인데 --force 없으면 (비대화형 CLI는 --force 필요)
224
+ // 명시 모드(full/version/workflows)인데 --force 없으면 TTY 여부와 무관하게 즉시 거부한다
225
+ // (issue #19 — TTY에서 확인 없이 즉시 설치되던 결함 수정).
224
226
  // --dry-run은 파일을 쓰지 않으므로 --force 게이트를 우회한다 (status/doctor와 동일한 안전성).
225
- if (!opts.force && !opts.dryRun && !process.stdout.isTTY) {
226
- console.error("비대화형 환경에서는 --force 옵션이 필요합니다.");
227
+ if (!opts.force && !opts.dryRun) {
228
+ console.error("--force 없이는 이 모드를 실행할 수 없습니다 (확인 절차가 없습니다).");
227
229
  return 1;
228
230
  }
229
231
 
@@ -238,11 +240,17 @@ export async function run(argv, {
238
240
  const versionCode = existing?.versionCode ?? 1; // 기존 빌드번호 보존 (.sh L2208~2221)
239
241
  const branch = detectDefaultBranch(cwd);
240
242
  const repoName = detectRepoName(cwd);
241
- // 경로 확정 (.sh resolve_project_paths 비대화형 경로 — --paths 우선 → 저장값 → 후보 1개 자동 → 루트 폴백)
242
- const paths = await resolveProjectPaths({
243
- root: cwd, types, paths: parsePathsCsv(opts.pathsCsv),
244
- existingPaths: existing?.paths ?? new Map(), force: true, tty: false, io: {},
245
- });
243
+ // 경로 확정 (.sh resolve_project_paths 비대화형 경로 — --paths 우선 → 저장값 → 후보 1개 자동 → 에러)
244
+ let paths;
245
+ try {
246
+ paths = await resolveProjectPaths({
247
+ root: cwd, types, paths: parsePathsCsv(opts.pathsCsv),
248
+ existingPaths: existing?.paths ?? new Map(), force: true, tty: false, io: {},
249
+ });
250
+ } catch (e) {
251
+ if (e instanceof CliError) { console.error(e.message); return 1; }
252
+ throw e;
253
+ }
246
254
 
247
255
  // 브랜치 구성 (--main-branch/--develop-branch → version.yml 저장값 → 감지 default → main/develop)
248
256
  const branches = resolveBranchConfig({
@@ -265,7 +273,7 @@ export async function run(argv, {
265
273
  const { now, today } = clock || utcNow();
266
274
 
267
275
  const context = createContext({
268
- mode: opts.mode, force: true, types, version, versionCode, branch,
276
+ mode: opts.mode, force: opts.force, types, version, versionCode, branch,
269
277
  branches,
270
278
  paths,
271
279
  // 옵션 워크플로우: CLI 플래그 최우선 → version.yml 저장 옵션(.sh read_template_options 등가) → false
@@ -295,21 +303,20 @@ export async function run(argv, {
295
303
  return 0;
296
304
  }
297
305
 
306
+ // opts.mode는 parseArgs()에서 화이트리스트 검증을 통과했고, interactive/revert/purge/uninstall/status/doctor는
307
+ // 전부 위에서 조기 반환했으므로 이 시점엔 full/version/workflows 중 하나로 보장된다(issue #19 — default 분기 제거).
298
308
  let result = null;
299
309
  switch (opts.mode) {
300
310
  case "full": result = runFull(context, payload, cwd); break;
301
311
  case "version": result = runVersion(context, payload, cwd); break;
302
312
  case "workflows": result = runWorkflows(context, payload, cwd); break;
303
- default:
304
- // 알 수 없는 모드 → .sh와 동일하게 복사 0건, 에러 아님
305
- break;
306
313
  }
307
314
 
308
315
  // 완료 요약 (.sh print_summary — CLI 모드에서도 출력)
309
316
  printSummary({
310
317
  mode: opts.mode, types, version, branches,
311
- counters: { workflows: result?.workflows?.copied ?? 0 },
318
+ copiedFiles: result?.workflows?.copiedFiles ?? [],
312
319
  gitignoreUpdated: result?.gitignoreUpdated === true,
313
- }, cwd);
320
+ });
314
321
  return 0;
315
322
  }
package/src/ui/prompts.js CHANGED
@@ -89,7 +89,7 @@ export function banner(info) { _printBanner(info); }
89
89
  export function detectionLog(info) { _detLog(info); }
90
90
  export function analysisCard(info) { _card(info); }
91
91
  export function installKind(info) { _installKind(info); }
92
- export function summary(ctx, targetRoot) { _summary(ctx, targetRoot); }
92
+ export function summary(ctx) { _summary(ctx); }
93
93
 
94
94
  // env 계획·경로 해석·충돌 메뉴가 쓰는 저수준 엔진 io (env-plan/paths-resolve의 io 계약)
95
95
  export const engineIo = {
package/src/ui/summary.js CHANGED
@@ -1,21 +1,17 @@
1
1
  // 완료 요약 출력 (.sh print_summary 등가). 전부 stderr.
2
- // ctx: { mode, types:[], version, counters:{ workflows }, branches? }
3
- import { existsSync } from "node:fs";
4
- import { join } from "node:path";
5
- import { PATHS, WORKFLOW_PREFIX, WORKFLOW_COMMON_PREFIX } from "../core/paths.js";
6
- import { listYamlFiles } from "../core/fsutil.js";
2
+ // ctx: { mode, types:[], version, copiedFiles:[], branches?, gitignoreUpdated? }
3
+ import { WORKFLOW_PREFIX, WORKFLOW_COMMON_PREFIX } from "../core/paths.js";
7
4
 
8
5
  const SEPARATOR = "────────────────────────────────────────";
9
6
 
10
- export function printSummary(ctx, targetRoot = ".") {
11
- const { mode, types = [], version = "", counters = {}, branches = null, gitignoreUpdated = false } = ctx || {};
7
+ export function printSummary(ctx) {
8
+ const { mode, types = [], version = "", copiedFiles = [], branches = null, gitignoreUpdated = false } = ctx || {};
12
9
  const err = (s = "") => process.stderr.write(`${s}\n`);
13
10
  // 색상은 TTY일 때만 (.sh YELLOW/CYAN/NC 등가)
14
11
  const isTty = !!process.stderr.isTTY;
15
12
  const YELLOW = isTty ? "\x1b[1;33m" : "";
16
13
  const CYAN = isTty ? "\x1b[0;36m" : "";
17
14
  const NC = isTty ? "\x1b[0m" : "";
18
- const workflowsCopied = counters.workflows ?? 0;
19
15
 
20
16
  err("");
21
17
  err(SEPARATOR);
@@ -66,24 +62,22 @@ export function printSummary(ctx, targetRoot = ".") {
66
62
  err("");
67
63
  err("추가된 워크플로우:");
68
64
 
69
- // 실제 복사된 워크플로우 분류
65
+ // 실제로 이번 실행에서 복사된 파일만 분류한다 (copyWorkflows()가 반환한 copiedFiles —
66
+ // 디렉터리 재스캔은 재실행 시 skip된 파일까지 "새로 설치됨"으로 보여주는 결함이 있었다, issue #19).
70
67
  const commonWorkflows = [];
71
68
  const typeWorkflows = [];
72
- const workflowsDir = join(targetRoot, PATHS.workflowsDir);
73
- if (existsSync(workflowsDir)) {
74
- const typePrefixes = types.map((t) => `${WORKFLOW_PREFIX}-${t.toUpperCase()}-`);
75
- for (const filename of listYamlFiles(workflowsDir)) {
76
- if (!filename.startsWith(`${WORKFLOW_PREFIX}-`)) continue; // PROJECT-*.{yaml,yml}만
77
- if (filename.startsWith(`${WORKFLOW_COMMON_PREFIX}-`)) {
78
- commonWorkflows.push(filename);
79
- } else if (typePrefixes.some((p) => filename.startsWith(p))) {
80
- typeWorkflows.push(filename);
81
- }
69
+ const typePrefixes = types.map((t) => `${WORKFLOW_PREFIX}-${t.toUpperCase()}-`);
70
+ for (const filename of copiedFiles) {
71
+ if (!filename.startsWith(`${WORKFLOW_PREFIX}-`)) continue; // PROJECT-*만
72
+ if (filename.startsWith(`${WORKFLOW_COMMON_PREFIX}-`)) {
73
+ commonWorkflows.push(filename);
74
+ } else if (typePrefixes.some((p) => filename.startsWith(p))) {
75
+ typeWorkflows.push(filename);
82
76
  }
83
77
  }
84
78
 
85
79
  if (commonWorkflows.length > 0 || typeWorkflows.length > 0) {
86
- err(` 📦 새로 설치됨 (${workflowsCopied}개):`);
80
+ err(` 📦 새로 설치됨 (${commonWorkflows.length + typeWorkflows.length}개):`);
87
81
  for (const wf of commonWorkflows) err(` 📌 ${wf}`);
88
82
  for (const wf of typeWorkflows) err(` 🎯 ${wf}`);
89
83
  }