project-auto-wizard 0.1.14 → 0.1.17

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 (49) hide show
  1. package/README.md +1 -1
  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 +30 -9
  32. package/src/commands/dry-run.js +8 -4
  33. package/src/commands/full.js +7 -2
  34. package/src/commands/interactive.js +6 -0
  35. package/src/commands/revert.js +35 -11
  36. package/src/commands/version.js +7 -1
  37. package/src/commands/workflows.js +4 -1
  38. package/src/core/copy/gitignore.js +34 -9
  39. package/src/core/copy/workflows.js +24 -15
  40. package/src/core/detect-fs.js +3 -11
  41. package/src/core/detect.js +4 -2
  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 +11 -5
  46. package/src/ui/ansi.js +10 -2
  47. package/src/ui/prompts.js +2 -0
  48. package/src/ui/readline-engine.js +24 -4
  49. package/src/ui/summary.js +5 -7
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.13 (2026-08-04)
19
+ ## 최신 버전 : v0.1.16 (2026-08-05)
20
20
 
21
21
  [전체 버전 기록 보기](CHANGELOG.md)
22
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-auto-wizard",
3
- "version": "0.1.14",
3
+ "version": "0.1.17",
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
@@ -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,15 +75,35 @@ 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}`);
@@ -114,7 +135,7 @@ export function parsePathsCsv(csv) {
114
135
  for (const pair of csv.split(",")) {
115
136
  if (pair.trim() === "") continue;
116
137
  const eq = pair.indexOf("=");
117
- const type = (eq >= 0 ? pair.slice(0, eq) : pair).trim();
138
+ const type = (eq >= 0 ? pair.slice(0, eq) : pair).replace(/\s/g, "");
118
139
  const rawPath = eq >= 0 ? pair.slice(eq + 1) : "";
119
140
  if (!VALID_TYPES.includes(type)) {
120
141
  throw new CliError(`--paths에 지원하지 않는 타입: '${type}'`);
@@ -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
 
@@ -20,6 +20,8 @@ import { runWorkflows } from "./workflows.js";
20
20
  import { runRevert } from "./revert.js";
21
21
  import { runUninstallFlow } from "./uninstall.js";
22
22
  import * as prompts from "../ui/prompts.js";
23
+ import { runStatus, printStatus } from "./status.js";
24
+ import { runDoctor, printDoctorReport } from "./doctor.js";
23
25
 
24
26
  const CANCEL = prompts.CANCEL;
25
27
  const isCancel = (v) => v === CANCEL || typeof v === "symbol";
@@ -44,6 +46,10 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
44
46
  const mode = await io.selectMode();
45
47
  if (mode === CANCEL || mode == null) { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
46
48
 
49
+ // status/doctor — 읽기 전용, 감지·breaking 게이트 불필요. CLI --mode status/doctor(index.js)와 동일하게 즉시 종료.
50
+ if (mode === "status") { printStatus(runStatus(payload, cwd)); return 0; }
51
+ if (mode === "doctor") { printDoctorReport(runDoctor(cwd)); return 0; }
52
+
47
53
  // revert 모드 — 확인 질문(기본 아니오) 후 payload 유래 파일 제거. 감지·breaking 게이트 불필요.
48
54
  if (mode === "revert") {
49
55
  const ok = await io.askYesNo("마법사가 설치한 워크플로우·스크립트를 제거할까요? (version.yml·README는 보존)", false);
@@ -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;
@@ -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) {
@@ -74,24 +74,21 @@ export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}
74
74
  // values/useDefaults는 치환 경로에서만 의미 (isUnchanged는 내부에서 useDefaults:true 강제 — 가상 비교 무손상)
75
75
  const envOptsFor = (type) => ({ type, projectPath: paths.get(type) || ".", repoName, resolvers, values: envValues, useDefaults: envUseDefaults });
76
76
 
77
- // (1) common — unchanged면 스킵, 아니면 무조건 덮어쓰기.
77
+ // (1) common — 타입별 워크플로우와 동일한 3지선(README 계약, issue #20 H3): unchanged면 스킵,
78
+ // changed면 decisions Map 결정에 따름(미지정 시 skip — 기존 사용자 수정 보존).
78
79
  // trunk-based 모드는 VERSION-CONTROL·AUTO-CHANGELOG 미설치 (RELEASE-PUBLISH 단독).
79
80
  const branchMode = context.branches?.mode || "pr-flow";
80
81
  const commonDir = join(projectTypesDir, "common");
81
82
  if (exists(commonDir)) {
82
- for (const filename of listYamlFiles(commonDir)) {
83
- if (branchMode === "trunk-based" && TRUNK_BASED_EXCLUDED.has(filename)) continue;
84
- const src = join(commonDir, filename);
85
- const dst = join(workflowsDir, filename);
86
- const body = srcText(src);
87
- if (existsSync(dst) && isUnchanged(body, readFileSync(dst, "utf8"), envOptsFor("common"))) {
88
- counters.skipped++;
89
- continue;
90
- }
91
- 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)));
92
88
  counters.copied++;
93
- counters.copiedFiles.push(filename);
89
+ counters.copiedFiles.push(f);
94
90
  }
91
+ for (const f of changed.filter(notExcluded)) applyDecision(decisions.get(f), commonDir, workflowsDir, f, counters, srcText);
95
92
  }
96
93
 
97
94
  // (2~4) 타입별
@@ -144,12 +141,24 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcTe
144
141
 
145
142
  // 대상 워크플로우 디렉토리에서 changed(충돌) 파일 목록만 뽑는다 — copyWorkflowsInteractive의 사전 조사용.
146
143
  // copyWorkflows 본체와 동일한 classify 기준을 써야 결정 Map이 실제 처리 대상과 1:1로 맞는다.
144
+ // common도 타입별과 동일하게 스캔한다 (issue #20 H3 — 이전에는 common 충돌이 질문조차 되지 않았다).
147
145
  export function listWorkflowConflicts(context, payloadRoot, targetRoot = ".") {
148
146
  const { types = [], paths = new Map(), includeNexus = false, repoName = "", resolvers = {} } = context;
149
147
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
150
148
  const projectTypesDir = join(payloadRoot, PAYLOAD.workflowsDir);
151
149
  const srcText = makeSrcText(context.branches || null);
152
- 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
+
153
162
  for (const type of types) {
154
163
  const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers };
155
164
  const typeDir = join(projectTypesDir, type);
@@ -248,8 +257,8 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
248
257
  }
249
258
 
250
259
  // 전체 워크플로우 분류(common + 타입별 + server-deploy + nexus opt-in) — status/dry-run 공용.
251
- // listWorkflowConflicts와 달리 common 디렉토리도 포함하고, changed뿐 아니라
252
- // newFiles/unchanged까지 전부 반환한다(읽기 전용 — 실제로 아무 파일도 쓰지 않는다).
260
+ // changed뿐 아니라 newFiles/unchanged까지 전부 반환한다는 점이 listWorkflowConflicts(changed
261
+ // 반환)와 다르다(읽기 전용 — 실제로 아무 파일도 쓰지 않는다).
253
262
  export function planWorkflows(context, payloadRoot, targetRoot = ".") {
254
263
  const { types = [], paths = new Map(), includeNexus = false, includeSecretBackup = false, repoName = "", resolvers = {} } = context;
255
264
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
@@ -27,13 +27,12 @@ export function detectTypes(root) {
27
27
  return detectTypesFromMarkers({ has: hasFile(root), read: readFile(root) });
28
28
  }
29
29
 
30
- // 버전 감지 — .sh detect_version 순서. jq 유무는 command 존재로 판정.
31
- export function detectVersion(root, { hasJq } = {}) {
30
+ // 버전 감지 — .sh detect_version 순서. jq package.json 파싱에 쓰인 적이 없어 게이트를 제거했다(이슈 #22 L4).
31
+ export function detectVersion(root, { warn = (m) => console.error(m) } = {}) {
32
32
  const read = readFile(root);
33
33
  const readJson = (rel) => { const c = read(rel); try { return c ? JSON.parse(c) : null; } catch { return null; } };
34
- const jq = hasJq ?? hasCommand("jq");
35
34
  const gitTag = gitOut(root, ["describe", "--tags", "--abbrev=0"]);
36
- return detectVersionFromFiles({ read, readJson, hasJq: jq, gitTag });
35
+ return detectVersionFromFiles({ read, readJson, gitTag, warn });
37
36
  }
38
37
 
39
38
  // 기본 브랜치 감지 — symbolic-ref → remote show → main.
@@ -56,13 +55,6 @@ export function detectRepoName(root) {
56
55
  return basename(root);
57
56
  }
58
57
 
59
- function hasCommand(cmd) {
60
- try {
61
- execFileSync(process.platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore" });
62
- return true;
63
- } catch { return false; }
64
- }
65
-
66
58
  // Spring application*.yml 탐색 (.sh resolve_spring_app_yml_dir/path L2767~2780 등가)
67
59
  // find {base} -path "*/src/main/resources/application*.yml" | head -1 의 fs 재귀 구현.
68
60
  // 반환: root 기준 상대경로 (예: "server/src/main/resources/application.yml") 또는 "".
@@ -35,9 +35,10 @@ export function detectTypesFromMarkers({ has, read }) {
35
35
  const VERSION_RE = /^\d+\.\d+\.\d+$/;
36
36
 
37
37
  // 버전 감지 (동작명세 §3.3) — 순서대로 첫 성공. read(relpath)=>string|null 주입.
38
- export function detectVersionFromFiles({ read, readJson, hasJq, gitTag }) {
38
+ // package.json은 이미 Node JSON.parse로 파싱을 마친 값이므로 jq 설치 여부와 무관하게 항상 사용한다(이슈 #22 L4).
39
+ export function detectVersionFromFiles({ read, readJson, gitTag, warn }) {
39
40
  const pkg = readJson?.("package.json");
40
- if (hasJq && pkg?.version && VERSION_RE.test(pkg.version)) return pkg.version;
41
+ if (pkg?.version && VERSION_RE.test(pkg.version)) return pkg.version;
41
42
  const grab = (content, re) => {
42
43
  for (const line of (content || "").split("\n")) {
43
44
  const m = line.match(re);
@@ -50,6 +51,7 @@ export function detectVersionFromFiles({ read, readJson, hasJq, gitTag }) {
50
51
  if ((v = grab(read("pubspec.yaml"), /^version:\s*(\d+\.\d+\.\d+)/))) return v;
51
52
  if ((v = grab(read("pyproject.toml"), /version\s*=\s*["']?(\d+\.\d+\.\d+)/))) return v;
52
53
  if (gitTag) { const t = String(gitTag).replace(/^v/, ""); if (VERSION_RE.test(t)) return t; }
54
+ warn?.("⚠️ 버전을 자동 감지하지 못해 기본값 0.0.1을 사용합니다 — --project-version으로 직접 지정하거나 version.yml을 확인하세요.");
53
55
  return "0.0.1";
54
56
  }
55
57
 
@@ -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
@@ -240,11 +240,17 @@ export async function run(argv, {
240
240
  const versionCode = existing?.versionCode ?? 1; // 기존 빌드번호 보존 (.sh L2208~2221)
241
241
  const branch = detectDefaultBranch(cwd);
242
242
  const repoName = detectRepoName(cwd);
243
- // 경로 확정 (.sh resolve_project_paths 비대화형 경로 — --paths 우선 → 저장값 → 후보 1개 자동 → 루트 폴백)
244
- const paths = await resolveProjectPaths({
245
- root: cwd, types, paths: parsePathsCsv(opts.pathsCsv),
246
- existingPaths: existing?.paths ?? new Map(), force: true, tty: false, io: {},
247
- });
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
+ }
248
254
 
249
255
  // 브랜치 구성 (--main-branch/--develop-branch → version.yml 저장값 → 감지 default → main/develop)
250
256
  const branches = resolveBranchConfig({
package/src/ui/ansi.js CHANGED
@@ -1,4 +1,4 @@
1
- // 공용 ANSI 헬퍼 — banner/status-cards가 공유 (readline-engine 내부 헬퍼와 독립, 의존성 0)
1
+ // 공용 ANSI 헬퍼 — banner/status-cards/summary가 공유 (readline-engine 내부 헬퍼와 독립, 의존성 0)
2
2
  const E = "\x1b[";
3
3
  export const A = {
4
4
  reset: `${E}0m`,
@@ -10,7 +10,15 @@ export const A = {
10
10
  magenta: `${E}35m`,
11
11
  gray: `${E}90m`,
12
12
  };
13
- export const paint = (s, color) => `${color}${s}${A.reset}`;
13
+
14
+ // NO_COLOR(https://no-color.org) 환경변수 또는 대상 스트림이 TTY가 아니면 색상을 끈다.
15
+ // no-color.org 규격상 NO_COLOR는 "값과 무관하게 존재 여부"만 본다 — NO_COLOR=""(빈 문자열)도
16
+ // "설정됨"으로 취급해야 하므로 truthy 체크(`!process.env.NO_COLOR`)가 아니라 존재 체크를 쓴다.
17
+ export function colorEnabled(stream = process.stdout) {
18
+ return process.env.NO_COLOR === undefined && !!stream.isTTY;
19
+ }
20
+
21
+ export const paint = (s, color, enabled = colorEnabled()) => (enabled ? `${color}${s}${A.reset}` : String(s));
14
22
 
15
23
  // 대략적 표시 폭 (CJK 2칸 · ANSI 시퀀스 0칸) — 박스 우변 정렬용
16
24
  export function visualWidth(s) {
package/src/ui/prompts.js CHANGED
@@ -15,6 +15,8 @@ export async function selectMode() {
15
15
  { value: "workflows", label: "워크플로우만 — 빌드·배포 GitHub Actions만 설치" },
16
16
  { value: "revert", label: "되돌리기 — 마법사가 설치한 워크플로우·스크립트 제거" },
17
17
  { value: "uninstall", label: "완전 삭제 — 마법사가 설치·수정한 모든 항목 제거(확인 후, README·gitignore·version.yml 포함)" },
18
+ { value: "status", label: "설치 상태 확인 — 읽기 전용, 버전·타입·드리프트 확인" },
19
+ { value: "doctor", label: "환경 진단 — 읽기 전용, gh CLI·권한·secret 설정 점검" },
18
20
  ],
19
21
  });
20
22
  }
@@ -15,7 +15,10 @@ const c = {
15
15
  reset: `${ESC}0m`, dim: `${ESC}2m`, bold: `${ESC}1m`,
16
16
  cyan: `${ESC}36m`, green: `${ESC}32m`, gray: `${ESC}90m`, yellow: `${ESC}33m`,
17
17
  };
18
- const paint = (s, color) => `${color}${s}${c.reset}`;
18
+ // NO_COLOR(https://no-color.org)/비TTY 가드 — ansi.js와 동일한 규칙(존재 여부만 체크, 값 무관)이지만
19
+ // 의존성 0 유지를 위해 자체 구현.
20
+ const colorEnabled = () => process.env.NO_COLOR === undefined && !!stdout.isTTY;
21
+ const paint = (s, color, enabled = colorEnabled()) => (enabled ? `${color}${s}${c.reset}` : String(s));
19
22
  const hideCursor = () => stdout.write(`${ESC}?25l`);
20
23
  const showCursor = () => stdout.write(`${ESC}?25h`);
21
24
 
@@ -55,15 +58,22 @@ function keySession(renderFn, onKey) {
55
58
 
56
59
  const cleanup = () => {
57
60
  stdin.removeListener("keypress", handler);
61
+ stdin.removeListener("end", onEnd);
58
62
  if (stdin.isTTY) stdin.setRawMode(wasRaw);
59
63
  stdin.pause();
60
64
  showCursor();
61
65
  };
62
66
 
67
+ // stdin 종료(EOF/Ctrl+D, SSH 연결 끊김 등) — 취소(ESC/Ctrl+C)와 동일하게 처리해 무한 대기를 방지한다.
68
+ const onEnd = () => {
69
+ cleanup();
70
+ resolve(CANCEL);
71
+ };
72
+
63
73
  const handler = (str, key) => {
64
74
  key = key || {};
65
- // 취소: Ctrl+C / ESC
66
- if ((key.ctrl && key.name === "c") || key.name === "escape") {
75
+ // 취소: Ctrl+C / Ctrl+D / ESC (raw mode에서는 Ctrl+D가 stdin "end"가 아니라 일반 keypress로 들어온다)
76
+ if ((key.ctrl && (key.name === "c" || key.name === "d")) || key.name === "escape") {
67
77
  cleanup();
68
78
  resolve(CANCEL);
69
79
  return;
@@ -77,6 +87,7 @@ function keySession(renderFn, onKey) {
77
87
  }
78
88
  };
79
89
  stdin.on("keypress", handler);
90
+ stdin.on("end", onEnd);
80
91
  renderFn(); // 최초 렌더
81
92
  });
82
93
  }
@@ -194,14 +205,22 @@ export async function text({ message, defaultValue = "" }) {
194
205
 
195
206
  const cleanup = () => {
196
207
  stdin.removeListener("keypress", handler);
208
+ stdin.removeListener("end", onEnd);
197
209
  if (stdin.isTTY) stdin.setRawMode(wasRaw);
198
210
  stdin.pause();
199
211
  stdout.write("\n");
200
212
  };
201
213
 
214
+ // stdin 종료(EOF/Ctrl+D) — 취소와 동일하게 처리해 무한 대기를 방지한다.
215
+ const onEnd = () => {
216
+ cleanup();
217
+ resolve(CANCEL);
218
+ };
219
+
202
220
  const handler = (str, key) => {
203
221
  key = key || {};
204
- if ((key.ctrl && key.name === "c") || key.name === "escape") { cleanup(); resolve(CANCEL); return; }
222
+ // 취소: Ctrl+C / Ctrl+D / ESC (raw mode에서는 Ctrl+D가 stdin "end" 아니라 일반 keypress로 들어온다)
223
+ if ((key.ctrl && (key.name === "c" || key.name === "d")) || key.name === "escape") { cleanup(); resolve(CANCEL); return; }
205
224
  if (key.name === "return" || key.name === "enter") {
206
225
  cleanup();
207
226
  resolve(buf.length ? buf : defaultValue);
@@ -212,6 +231,7 @@ export async function text({ message, defaultValue = "" }) {
212
231
  if (str && !key.ctrl && !key.meta && str.length === 1 && str >= " ") { buf += str; prompt(); return; }
213
232
  };
214
233
  stdin.on("keypress", handler);
234
+ stdin.on("end", onEnd);
215
235
  prompt();
216
236
  });
217
237
  }
package/src/ui/summary.js CHANGED
@@ -1,17 +1,15 @@
1
1
  // 완료 요약 출력 (.sh print_summary 등가). 전부 stderr.
2
2
  // ctx: { mode, types:[], version, copiedFiles:[], branches?, gitignoreUpdated? }
3
3
  import { WORKFLOW_PREFIX, WORKFLOW_COMMON_PREFIX } from "../core/paths.js";
4
+ import { paint, A, colorEnabled } from "./ansi.js";
4
5
 
5
6
  const SEPARATOR = "────────────────────────────────────────";
6
7
 
7
8
  export function printSummary(ctx) {
8
9
  const { mode, types = [], version = "", copiedFiles = [], branches = null, gitignoreUpdated = false } = ctx || {};
9
10
  const err = (s = "") => process.stderr.write(`${s}\n`);
10
- // 색상은 TTY일 때만 (.sh YELLOW/CYAN/NC 등가)
11
- const isTty = !!process.stderr.isTTY;
12
- const YELLOW = isTty ? "\x1b[1;33m" : "";
13
- const CYAN = isTty ? "\x1b[0;36m" : "";
14
- const NC = isTty ? "\x1b[0m" : "";
11
+ // 색상은 ansi.js의 공용 가드로 통일 (NO_COLOR + stderr TTY 여부)
12
+ const enabled = colorEnabled(process.stderr);
15
13
 
16
14
  err("");
17
15
  err(SEPARATOR);
@@ -102,7 +100,7 @@ export function printSummary(ctx) {
102
100
  // 필수 작업 안내
103
101
  err(SEPARATOR);
104
102
  err("");
105
- err(`${YELLOW}⚠️ 다음 작업을 확인해주세요:${NC}`);
103
+ err(paint(paint("⚠️ 다음 작업을 확인해주세요:", A.yellow, enabled), A.bold, enabled));
106
104
  err("");
107
105
  err(" 1️⃣ 릴리스 automerge용 PAT (선택 — 없으면 GITHUB_TOKEN 사용)");
108
106
  err(" → Repository Settings > Secrets > Actions");
@@ -114,6 +112,6 @@ export function printSummary(ctx) {
114
112
  err("");
115
113
  err(SEPARATOR);
116
114
  err("");
117
- err(`${CYAN}📖 워크플로우 구성과 릴리스 흐름은 README를 참고하세요.${NC}`);
115
+ err(paint("📖 워크플로우 구성과 릴리스 흐름은 README를 참고하세요.", A.cyan, enabled));
118
116
  err("");
119
117
  }