project-auto-wizard 0.12.2 → 0.13.0

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 (86) hide show
  1. package/README.ja.md +165 -0
  2. package/README.ko.md +165 -0
  3. package/README.md +107 -286
  4. package/README.zh-CN.md +165 -0
  5. package/bin/project-auto-wizard.js +1 -2
  6. package/package.json +7 -3
  7. package/payload/config/breaking-changes.json +24 -0
  8. package/payload/config/wizard-prompts.yml +45 -0
  9. package/payload/scripts/changelog_manager.py +141 -46
  10. package/payload/scripts/issue_helper.py +34 -5
  11. package/payload/scripts/version_manager.py +397 -97
  12. package/payload/version.yml.template +4 -2
  13. package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +39 -9
  14. package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +165 -38
  15. package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +14 -5
  16. package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +220 -6
  17. package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +89 -3
  18. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +78 -16
  19. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +72 -16
  20. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +62 -20
  21. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +51 -13
  22. package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +75 -11
  23. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +52 -3
  24. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +60 -11
  25. package/payload/workflows/go/PROJECT-GO-CI.yaml +10 -1
  26. package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +148 -28
  27. package/payload/workflows/go/PROJECT-GO-SIMPLE-CICD.yaml +45 -4
  28. package/payload/workflows/next/PROJECT-NEXT-CI.yaml +25 -3
  29. package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +83 -9
  30. package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +70 -6
  31. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +148 -28
  32. package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +45 -4
  33. package/payload/workflows/react/PROJECT-REACT-CI.yaml +25 -3
  34. package/payload/workflows/react/PROJECT-REACT-CICD.yaml +84 -10
  35. package/payload/workflows/spring/PROJECT-SPRING-CI.yml +13 -3
  36. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +47 -5
  37. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +47 -5
  38. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +153 -30
  39. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +47 -5
  40. package/src/cli/args.js +33 -24
  41. package/src/cli/help.js +16 -4
  42. package/src/commands/doctor.js +17 -10
  43. package/src/commands/dry-run.js +54 -1
  44. package/src/commands/full.js +127 -50
  45. package/src/commands/interactive-flutter.js +1 -1
  46. package/src/commands/interactive.js +124 -56
  47. package/src/commands/purge.js +59 -17
  48. package/src/commands/status.js +16 -1
  49. package/src/commands/uninstall.js +58 -13
  50. package/src/context.js +5 -5
  51. package/src/core/baseline.js +20 -5
  52. package/src/core/branches.js +19 -1
  53. package/src/core/branding.js +1 -1
  54. package/src/core/breaking-check.js +14 -19
  55. package/src/core/breaking.js +7 -3
  56. package/src/core/copy/gitignore.js +20 -12
  57. package/src/core/copy/readme.js +24 -5
  58. package/src/core/copy/simple.js +52 -13
  59. package/src/core/copy/workflows.js +90 -47
  60. package/src/core/deploy-style.js +83 -11
  61. package/src/core/detect-fs.js +127 -24
  62. package/src/core/detect.js +131 -47
  63. package/src/core/errors.js +3 -0
  64. package/src/core/flutter-options.js +4 -3
  65. package/src/core/fsutil.js +23 -6
  66. package/src/core/logger.js +55 -19
  67. package/src/core/paths-resolve.js +50 -47
  68. package/src/core/paths.js +26 -1
  69. package/src/core/release-options.js +23 -0
  70. package/src/core/removal-plan.js +75 -19
  71. package/src/core/types.js +113 -0
  72. package/src/core/verify.js +85 -19
  73. package/src/core/version-yml.js +42 -8
  74. package/src/core/wizard-env.js +21 -14
  75. package/src/core/wizard-labels.js +34 -25
  76. package/src/index.js +122 -49
  77. package/src/ui/ansi.js +3 -1
  78. package/src/ui/banner.js +12 -8
  79. package/src/ui/env-plan.js +68 -44
  80. package/src/ui/prompts.js +22 -12
  81. package/src/ui/readline-engine.js +109 -22
  82. package/src/ui/status-cards.js +10 -5
  83. package/src/ui/summary.js +71 -33
  84. package/payload/scripts/__pycache__/changelog_manager.cpython-314.pyc +0 -0
  85. package/payload/scripts/__pycache__/issue_helper.cpython-314.pyc +0 -0
  86. package/payload/scripts/__pycache__/version_manager.cpython-314.pyc +0 -0
@@ -6,7 +6,7 @@
6
6
  // 왜 로컬 전용인가: 상세도를 제약하지 않기 위해서다. 로그 디렉토리에 .gitignore를 직접
7
7
  // 두어 그 폴더만 추적에서 뺀다 — 루트 .gitignore는 건드리지 않는다.
8
8
  import { appendFileSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
9
- import { join } from "node:path";
9
+ import { dirname, join } from "node:path";
10
10
 
11
11
  export const LOG_DIR = ".github/.wizard/logs";
12
12
  const KEEP = 20; // 유지할 로그 파일 수
@@ -17,7 +17,7 @@ const GITIGNORE_BODY = "*\n!.gitignore\n";
17
17
  const SECRET_KEY_RE = /(PASSWORD|SECRET|TOKEN|KEY|CREDENTIAL)/i;
18
18
  const MASK = "***";
19
19
 
20
- let state = null; // { file, clock, startedAt, disabled }
20
+ let state = null; // { targetRoot, name, header, file, rel, clock, startedAt, disabled }
21
21
 
22
22
  export function maskValue(key, value) {
23
23
  // SSH_AUTH_METHOD처럼 "방식"만 담는 키는 비밀이 아니다 — 이름에 KEY가 들어가도 마스킹하지 않는다.
@@ -32,8 +32,26 @@ export function stampFrom(now = "") {
32
32
  return `${m[1]}${m[2]}${m[3]}-${m[4]}${m[5]}${m[6]}`;
33
33
  }
34
34
 
35
- export function logFilename(now, action = "install") {
36
- return `${stampFrom(now)}-${action}.log`;
35
+ // 초 단위 이름만 쓰면 같은 초에 연달아 실행할 때 앞 실행의 로그를 덮어쓴다 — 밀리초를 붙인다.
36
+ // 밀리초도 시각 순서라 이름 정렬이 곧 실행 순서라는 전제(rotate)는 그대로 유지된다.
37
+ export function logFilename(now, action = "install", ms = 0) {
38
+ return `${stampFrom(now)}-${String(ms).padStart(3, "0")}-${action}.log`;
39
+ }
40
+
41
+ // 같은 이름이 이미 있으면(다른 프로세스가 같은 밀리초에 연 경우) -2, -3을 붙여 새로 만든다.
42
+ // wx 플래그로 "없을 때만 생성"을 원자적으로 판정해 동시 실행끼리도 서로 덮어쓰지 않는다.
43
+ function createUnique(dir, base, header) {
44
+ const stem = base.replace(/\.log$/, "");
45
+ for (let n = 1; n < 100; n++) {
46
+ const name = n === 1 ? base : `${stem}-${n}.log`;
47
+ try {
48
+ writeFileSync(join(dir, name), header, { flag: "wx" });
49
+ return name;
50
+ } catch (e) {
51
+ if (e.code !== "EEXIST") throw e;
52
+ }
53
+ }
54
+ throw new Error(`로그 파일 이름이 모두 사용 중입니다: ${base}`);
37
55
  }
38
56
 
39
57
  // 최근 KEEP개만 남기고 오래된 것부터 지운다. 파일명이 시각 오름차순이라 이름 정렬로 충분하다.
@@ -45,31 +63,39 @@ function rotate(dir) {
45
63
  }
46
64
  }
47
65
 
66
+ // 파일은 첫 기록 때 만든다 — 읽기 전용 모드(status/doctor)나 인자 검증에서 거부된 실행처럼
67
+ // 아무것도 바꾸지 않은 실행이 대상 레포에 로그 폴더와 헤더만 있는 파일을 남기지 않도록.
48
68
  export function initLogger(targetRoot, opts = {}) {
49
- const { action = "install", now = "", argv = [], templateVersion = "unknown", clock = () => new Date() } = opts;
69
+ const { action = "install", now = "", argv = [], templateVersion = "unknown", clock = () => new Date(),
70
+ ms = new Date().getUTCMilliseconds() } = opts;
71
+ // 시각은 UTC다 — 로컬 시간대로 읽으면 몇 시간 어긋나 보이므로 헤더에 밝혀 둔다.
72
+ const header =
73
+ `=== project-auto-wizard v${templateVersion} | ${action} | ${now} UTC ===\n` +
74
+ `argv : ${["project-auto-wizard", ...argv].join(" ")}\n` +
75
+ `node : ${process.version} | ${process.platform} ${process.arch}\n` +
76
+ `target : ${targetRoot}\n\n`;
77
+ const st = { targetRoot, name: logFilename(now, action, ms), header, file: "", rel: "", clock, startedAt: Date.now(), disabled: false };
78
+ state = st;
79
+ return { get path() { return st.rel; } };
80
+ }
81
+
82
+ // 첫 기록 직전에 로그 파일을 연다. 실패하면 이후 기록을 끈다.
83
+ function open(st) {
50
84
  try {
51
- const dir = join(targetRoot, LOG_DIR);
85
+ const dir = join(st.targetRoot, LOG_DIR);
52
86
  mkdirSync(dir, { recursive: true });
53
87
  // 사용자가 직접 둔 .gitignore가 있으면 존중한다.
54
88
  const gi = join(dir, ".gitignore");
55
89
  if (!existsSync(gi)) writeFileSync(gi, GITIGNORE_BODY);
56
90
  rotate(dir);
57
-
58
- const rel = `${LOG_DIR}/${logFilename(now, action)}`;
59
- const file = join(targetRoot, rel);
60
- const header =
61
- `=== project-auto-wizard v${templateVersion} | ${action} | ${now} ===\n` +
62
- `argv : ${["project-auto-wizard", ...argv].join(" ")}\n` +
63
- `node : ${process.version} | ${process.platform} ${process.arch}\n` +
64
- `target : ${targetRoot}\n\n`;
65
- writeFileSync(file, header);
66
- state = { file, rel, clock, startedAt: Date.now(), disabled: false };
67
- return { path: rel };
91
+ st.rel = `${LOG_DIR}/${createUnique(dir, st.name, st.header)}`;
92
+ st.file = join(st.targetRoot, st.rel);
93
+ return true;
68
94
  } catch (e) {
69
95
  // 로그를 못 남긴 것이 설치를 되돌릴 이유는 아니다 — 다만 조용히 삼키지는 않는다.
96
+ st.disabled = true;
70
97
  process.stderr.write(`[warn] 실행 로그를 시작하지 못했습니다: ${e.message}\n`);
71
- state = null;
72
- return null;
98
+ return false;
73
99
  }
74
100
  }
75
101
 
@@ -108,6 +134,7 @@ function hhmmss(date) {
108
134
 
109
135
  function write(level, scope, action, detail = "") {
110
136
  if (!state || state.disabled) return;
137
+ if (!state.file && !open(state)) return;
111
138
  try {
112
139
  const line = `${hhmmss(state.clock())} ${level} ${String(scope).padEnd(SCOPE_W)} ${String(action).padEnd(ACTION_W)} ${detail}`.trimEnd();
113
140
  appendFileSync(state.file, line + "\n");
@@ -125,6 +152,7 @@ export const log = {
125
152
  // rows: Array<[label, value]> — 라벨 폭을 맞춰 정렬한다.
126
153
  summary(rows = []) {
127
154
  if (!state || state.disabled || !rows.length) return;
155
+ if (!state.file && !open(state)) return;
128
156
  // 한글은 터미널에서 2칸을 차지한다 — 문자 수로 맞추면 눈으로 볼 때 어긋난다.
129
157
  const w = Math.max(...rows.map(([k]) => dispWidth(k)));
130
158
  const body = rows.map(([k, v]) => `${k}${" ".repeat(w - dispWidth(k))} : ${v}`).join("\n");
@@ -136,6 +164,14 @@ export const log = {
136
164
  },
137
165
  };
138
166
 
167
+ // 로그 폴더(.github/.wizard)째 지울 수 있는 삭제 실행용. 지우기 전에 쓰면 기록이 함께 사라지며
168
+ // ENOENT 경고가 나고, 지운 뒤에 쓰면 폴더를 되살려 완전 삭제 뒤에도 흔적이 남는다.
169
+ // 그래서 기록을 모았다가 폴더가 남아 있을 때만 쓴다(지워졌다면 결과는 화면 출력으로 대신한다).
170
+ export function logRemovals(targetRoot, entries = []) {
171
+ if (!existsSync(dirname(join(targetRoot, LOG_DIR)))) return;
172
+ for (const [scope, action, detail] of entries) write("INFO", scope, action, detail);
173
+ }
174
+
139
175
  export function closeLogger() {
140
176
  state = null;
141
177
  }
@@ -1,5 +1,4 @@
1
- // 타입별 프로젝트 경로 감지·확정 (.sh find_type_path_candidates L1249~1311 /
2
- // resolve_project_paths L1362~1589 등가). 모노레포에서 각 타입의 버전 파일이
1
+ // 타입별 프로젝트 경로 감지·확정. 모노레포에서 각 타입의 버전 파일이
3
2
  // 어느 폴더에 있는지 5단계 우선순위로 확정한다.
4
3
  //
5
4
  // io 주입 계약(readline-engine 시그니처 그대로):
@@ -10,24 +9,24 @@
10
9
  import { existsSync, readdirSync } from "node:fs";
11
10
  import { join } from "node:path";
12
11
  import { markerForType as baseMarkerForType, resolveMarker } from "./detect.js";
13
- import { normalizePath, CliError } from "../cli/args.js";
12
+ import { TYPES, typeInfo } from "./types.js";
13
+ import { normalizePath, isRepoRelativePath } from "./paths.js";
14
+ import { CliError } from "./errors.js";
14
15
 
15
- // 취소(ESC/Ctrl+C)는 CANCEL 심볼 — ui를 import하지 않고 심볼 여부로만 판정 (core→ui 역참조 방지)
16
+ // 취소(ESC)는 CANCEL 심볼(Ctrl+C는 엔진이 예외로 중단시킨다) — ui를 import하지 않고 심볼 여부로만 판정 (core→ui 역참조 방지)
16
17
  const isCancel = (v) => typeof v === "symbol";
17
18
 
18
- // 타입의 대표 마커 파일명 (.sh marker_for_type L1220~1229 등가).
19
- // detect.js는 미지 타입에 package.json을 기본 반환하지만 .sh는 빈 문자열 — 등가를 위해 래핑.
20
- const KNOWN_MARKER_TYPES = new Set([
21
- "flutter", "react", "next", "node", "react-native", "react-native-expo", "python", "spring", "go",
22
- ]);
19
+ // 타입의 대표 마커 파일명.
20
+ // detect.js는 미지 타입에 package.json을 기본 반환하지만, 경로 탐색에서는 마커가 없는 타입(basic 등)을 빈 문자열로 구분해야 해서 래핑한다.
21
+ const KNOWN_MARKER_TYPES = new Set(TYPES.filter((t) => t.markers.length).map((t) => t.id));
23
22
  export function markerForType(type) {
24
23
  return KNOWN_MARKER_TYPES.has(type) ? baseMarkerForType(type) : "";
25
24
  }
26
25
 
27
26
  // 디렉토리에 실재하는 마커 파일명 반환 — resolveMarker의 fs 구동판.
28
- // (.sh existing_marker_in_dir L1232~1245: spring build.gradle/.kts/pom.xml, python pyproject/setup.py/requirements.txt)
27
+ // (보조 마커 포함: spring build.gradle/.kts/pom.xml, python pyproject/setup.py/requirements.txt)
29
28
  export function existingMarkerInDir(type, dir) {
30
- if (!markerForType(type)) return ""; // .sh 등가: 미지 타입은 빈 문자열
29
+ if (!markerForType(type)) return ""; // 미지 타입은 빈 문자열
31
30
  return resolveMarker(type, (n) => existsSync(join(dir, n)));
32
31
  }
33
32
 
@@ -42,7 +41,7 @@ function walkFindDirs(root, { prune, match, maxDepth = 3 }) {
42
41
  const childDepth = depth + 1;
43
42
  const childRel = rel ? `${rel}/${e.name}` : e.name;
44
43
  if (e.isDirectory()) {
45
- // prune 폴더는 하위 전체 제외 (.sh find -prune 등가)
44
+ // prune 폴더는 하위 전체 제외
46
45
  if (prune.has(e.name)) continue;
47
46
  // 자식 파일이 depth ≤ maxDepth 안에 들어올 때만 하강
48
47
  if (childDepth < maxDepth) walk(childRel, childDepth);
@@ -54,13 +53,13 @@ function walkFindDirs(root, { prune, match, maxDepth = 3 }) {
54
53
  }
55
54
  };
56
55
  walk("", 0);
57
- return [...new Set(hits)].sort(); // sort -u 등가
56
+ return [...new Set(hits)].sort(); // 중복 제거 + 정렬
58
57
  }
59
58
 
60
- // 타입별 마커 파일 후보 검색 (.sh find_type_path_candidates L1249~1311 등가).
59
+ // 타입별 마커 파일 후보 검색.
61
60
  // 반환: 후보 디렉토리 상대경로 배열 (루트는 ".").
62
61
  export function findTypePathCandidates(root, type) {
63
- // ── Spring 멀티모듈: settings.gradle(.kts) 폴더 = 모듈 루트로 축약 (.sh L1255~1268) ──
62
+ // ── Spring 멀티모듈: settings.gradle(.kts) 폴더 = 모듈 루트로 축약 ──
64
63
  // version_manager가 그 폴더 아래 build.gradle 전부를 갱신하므로 하위 모듈을 펼치지 않는다.
65
64
  // android/ 의 settings.gradle(Flutter/RN)은 spring이 아니므로 prune.
66
65
  if (type === "spring") {
@@ -72,23 +71,15 @@ export function findTypePathCandidates(root, type) {
72
71
  // settings.gradle 없음 → 단일 모듈, 아래 build.gradle 폴백
73
72
  }
74
73
 
75
- const namesByType = {
76
- flutter: ["pubspec.yaml"],
77
- react: ["package.json"], next: ["package.json"], node: ["package.json"],
78
- "react-native": ["package.json"],
79
- "react-native-expo": ["app.json"],
80
- python: ["pyproject.toml", "setup.py", "requirements.txt"],
81
- spring: ["build.gradle", "build.gradle.kts", "pom.xml"],
82
- go: ["go.mod"],
83
- };
84
- const names = namesByType[type];
85
- if (!names) return [];
74
+ // 레지스트리의 마커 순서가 곧 우선순위다 (대표 파일 먼저).
75
+ const names = typeInfo(type)?.markers;
76
+ if (!names?.length) return [];
86
77
 
87
78
  const prune = new Set([
88
79
  "node_modules", ".git", "build", "dist", ".dart_tool", "android", "ios",
89
80
  ".gradle", "venv", ".venv", "__pycache__",
90
81
  ]);
91
- // 우선순위 높은 마커에서 발견되면 그것만 사용 (.sh L1281~1288)
82
+ // 우선순위 높은 마커에서 발견되면 그것만 사용
92
83
  let found = [];
93
84
  for (const n of names) {
94
85
  found = walkFindDirs(root, { prune, match: (name) => name === n });
@@ -97,13 +88,13 @@ export function findTypePathCandidates(root, type) {
97
88
 
98
89
  return found.filter((d) => {
99
90
  if (type === "flutter") {
100
- // example/ 제외 + lib/ 동반 확인 — 오탐 방지 (.sh L1298~1303)
91
+ // example/ 제외 + lib/ 동반 확인 — 오탐 방지
101
92
  if (d.includes("example")) return false;
102
93
  const libDir = d === "." ? join(root, "lib") : join(root, d, "lib");
103
94
  if (!existsSync(libDir)) return false;
104
95
  }
105
96
  if (type === "spring") {
106
- // Flutter/RN의 android/build.gradle 오탐 제외 (.sh L1304~1307)
97
+ // Flutter/RN의 android/build.gradle 오탐 제외
107
98
  if (d.includes("android")) return false;
108
99
  }
109
100
  return true;
@@ -111,7 +102,7 @@ export function findTypePathCandidates(root, type) {
111
102
  }
112
103
 
113
104
  // 선택된 모든 타입의 경로를 감지·확인하여 Map<type,path> 확정
114
- // (.sh resolve_project_paths L1362~1589 등가 — 5단계 우선순위).
105
+ // (5단계 우선순위).
115
106
  // ① paths에 이미 있음(--paths) → 유지
116
107
  // ② 루트에 마커 존재 → "." 자동
117
108
  // ③ existingPaths(version.yml 저장값)
@@ -123,11 +114,11 @@ export async function resolveProjectPaths({
123
114
  }) {
124
115
  const say = io.log || ((m) => process.stderr.write(`${m}\n`));
125
116
  const result = new Map(paths); // --paths 사전값 유지 (호출부 Map은 불변)
126
- const targets = types.filter((t) => t !== "basic"); // basic은 경로 불필요 (.sh L1400)
117
+ const targets = types.filter((t) => t !== "basic"); // basic은 경로 불필요
127
118
  if (targets.length === 0) return result;
128
119
 
129
120
  const total = targets.length;
130
- // ── 도입부 안내 (.sh L1407~1434 — 감지 결과 + 무엇을 할지 설명) ──
121
+ // ── 도입부 안내 (감지 결과 + 무엇을 할지 설명) ──
131
122
  say("");
132
123
  if (total > 1) say(`🔍 멀티타입 프로젝트가 감지되었습니다 — 총 ${total}개 타입`);
133
124
  else say(`🔍 ${targets[0]} 프로젝트가 감지되었습니다 — 총 1개 타입`);
@@ -141,17 +132,22 @@ export async function resolveProjectPaths({
141
132
  idx += 1;
142
133
  const prog = `[${idx}/${total}]`;
143
134
 
144
- // ① --paths 등으로 이미 지정됨 → 최우선 (.sh L1441~1446)
135
+ // ① --paths 등으로 이미 지정됨 → 최우선
145
136
  if (result.get(t)) {
146
137
  const p = result.get(t);
147
138
  if (!existsSync(join(root, p))) {
148
139
  throw new CliError(`--paths로 지정한 경로가 존재하지 않습니다: '${t}=${p}'`);
149
140
  }
150
141
  say(` ${t} → ${p} (--paths 지정)`);
142
+ // 막지는 않는다(마커 없이 쓰는 구성도 있다) — 오타로 엉뚱한 폴더를 준 경우를 알린다.
143
+ const pm = existingMarkerInDir(t, join(root, p));
144
+ if (pm && !existsSync(join(root, p, pm))) {
145
+ say(` ⚠️ ${p}에 ${t} 프로젝트 파일(${pm})이 없습니다 — 경로가 맞는지 확인하세요.`);
146
+ }
151
147
  continue;
152
148
  }
153
149
 
154
- // ② 루트에 마커 존재 → "." 자동 확정 (.sh L1449~1455, 보조 마커 포함)
150
+ // ② 루트에 마커 존재 → "." 자동 확정 (보조 마커 포함)
155
151
  const rootMarker = existingMarkerInDir(t, root);
156
152
  if (rootMarker && existsSync(join(root, rootMarker))) {
157
153
  result.set(t, ".");
@@ -159,14 +155,14 @@ export async function resolveProjectPaths({
159
155
  continue;
160
156
  }
161
157
 
162
- // ③ 기존 version.yml 저장값 → 기본 제안값 (.sh L1458~1466)
158
+ // ③ 기존 version.yml 저장값 → 기본 제안값
163
159
  const existing = existingPaths.get(t) || "";
164
160
 
165
- // ④ 후보 검색 (.sh L1469~1471)
161
+ // ④ 후보 검색
166
162
  const candidates = findTypePathCandidates(root, t);
167
163
  let chosen = "";
168
164
 
169
- // ── ⑤-a 비대화형 (--force 또는 TTY 없음, .sh L1476~1489 — root 폴백 의도적 불포함) ──
165
+ // ── ⑤-a 비대화형 (--force 또는 TTY 없음 — root 폴백 의도적 불포함) ──
170
166
  if (force || !tty) {
171
167
  if (existing) {
172
168
  chosen = existing;
@@ -183,7 +179,7 @@ export async function resolveProjectPaths({
183
179
  continue;
184
180
  }
185
181
 
186
- // ── ⑤-b 대화형: 후보 개수별 분기 (.sh L1492~1525) ──
182
+ // ── ⑤-b 대화형: 후보 개수별 분기 ──
187
183
  if (candidates.length === 1) {
188
184
  const cand = candidates[0];
189
185
  const candMarker = existingMarkerInDir(t, cand === "." ? root : join(root, cand));
@@ -200,21 +196,21 @@ export async function resolveProjectPaths({
200
196
  } else if (candidates.length > 1) {
201
197
  say("");
202
198
  say(` ${prog} 🔍 ${t}: 경로 후보 ${candidates.length}개 발견`);
203
- // 후보들 + '직접 입력' 메뉴 — value 자체를 한국어로 (센티넬 노출 방지, .sh L1508~1521)
199
+ // 후보들 + '직접 입력' 메뉴 — value 자체를 한국어로 (센티넬 노출 방지)
204
200
  const options = candidates.map((c) => ({
205
201
  value: c,
206
202
  label: `${c} (${existingMarkerInDir(t, c === "." ? root : join(root, c))})`,
207
203
  }));
208
204
  options.push({ value: "직접 입력", label: "직접 입력" });
209
205
  const sel = await io.select({ message: ` ${t} 프로젝트 루트를 선택하세요`, options });
210
- // ESC(취소)도 직접 입력으로 폴백 (.sh `|| _sel="직접 입력"`)
206
+ // ESC(취소)도 직접 입력으로 폴백
211
207
  if (!isCancel(sel) && sel != null && sel !== "직접 입력") chosen = sel;
212
208
  } else {
213
209
  say("");
214
210
  say(` ⚠️ ${prog} ${t}: 프로젝트를 찾지 못했습니다 (maxdepth 3).`);
215
211
  }
216
212
 
217
- // ── 직접 입력 루프 (위에서 미확정 시, .sh L1528~1553) ──
213
+ // ── 직접 입력 루프 (위에서 미확정 시) ──
218
214
  while (!chosen) {
219
215
  const hintMarker = existingMarkerInDir(t, root);
220
216
  let prompt = ` ${t} 프로젝트 루트 경로 입력 (${hintMarker} 이 있는 폴더, 예: server, app — 루트면 그냥 Enter`;
@@ -223,16 +219,23 @@ export async function resolveProjectPaths({
223
219
  let input = await io.text({ message: prompt, defaultValue: "" });
224
220
  if (isCancel(input) || input == null) input = ""; // ESC → 빈값 (아래 폴백)
225
221
  input = String(input).trim();
226
- // 빈값 → 기존값 또는 루트 (.sh L1541~1543) — normalizePath 전에 판정
222
+ // 빈값 → 기존값 또는 루트 — normalizePath 전에 판정
227
223
  input = input === "" ? (existing || ".") : normalizePath(input);
228
- // 검증: 입력 경로에 마커 존재 확인 (보조 마커 포함, .sh L1544~1552)
224
+ if (!isRepoRelativePath(input)) {
225
+ say(` ⚠️ '${input}'은(는) 레포 밖 경로입니다 — 레포 루트 기준 상대경로로 입력하세요.`);
226
+ continue;
227
+ }
228
+ // 검증: 입력 경로에 마커 존재 확인 (보조 마커 포함)
229
229
  const m = existingMarkerInDir(t, input === "." ? root : join(root, input));
230
230
  if (m && existsSync(join(root, input === "." ? "" : input, m))) {
231
231
  chosen = input;
232
232
  } else {
233
233
  say(` ⚠️ ${input}/${m} 파일이 없습니다.`);
234
- const forceOk = await io.confirm({ message: " 그래도 이 경로를 사용할까요?", initialValue: false });
235
- if (forceOk === true) chosen = input;
234
+ // 기본(Enter)·ESC는 이 경로를 그대로 쓴다 — 기본을 '아니오'로 두면 마커가 아직 없는 타입
235
+ // (프로젝트 생성 전에 미리 추가한 타입 등)은 Enter만으로는 빠져나갈 수 없었다.
236
+ // 다시 입력하려면 '아니오'를 명시적으로 고른다. 경로는 version.yml project_paths에서 나중에 고칠 수 있다.
237
+ const forceOk = await io.confirm({ message: " 그래도 이 경로를 사용할까요? (아니오 = 다시 입력)", initialValue: true });
238
+ if (forceOk !== false) chosen = input;
236
239
  }
237
240
  }
238
241
 
@@ -240,7 +243,7 @@ export async function resolveProjectPaths({
240
243
  say(` ✅ ${t} → ${chosen}`);
241
244
  }
242
245
 
243
- // ── 요약 + 같은 마커 파일 중복 경고 (.sh L1559~1587) ──
246
+ // ── 요약 + 같은 마커 파일 중복 경고 ──
244
247
  say("");
245
248
  say("📂 타입별 버전 파일 경로 확정:");
246
249
  const fileToTypes = new Map(); // 마커 파일 상대경로 → 그 파일을 쓰는 타입들
@@ -253,7 +256,7 @@ export async function resolveProjectPaths({
253
256
  }
254
257
  for (const [file, ts] of fileToTypes) {
255
258
  if (ts.length > 1) {
256
- // 멱등 동작이라 막지는 않고 경고만 (.sh L1577~1586)
259
+ // 멱등 동작이라 막지는 않고 경고만
257
260
  say(` ⚠️ 같은 파일(${file})을 여러 타입(${ts.join(" ")})이 바라봅니다.`);
258
261
  say(" → sync 때 모두 같은 버전이 기록됩니다. 동작에는 문제없지만 의도한 구성인지 확인하세요.");
259
262
  }
package/src/core/paths.js CHANGED
@@ -1,16 +1,41 @@
1
1
  // 경로 상수 — 설치 대상(사용자 레포) 경로 + payload 내부 레이아웃.
2
+ import { join } from "node:path";
3
+
2
4
  export const PATHS = {
3
5
  versionFile: "version.yml",
4
6
  workflowsDir: ".github/workflows",
5
7
  scriptsDir: ".github/scripts",
6
8
  };
7
9
 
8
- // payload/ 내부 레이아웃 (payload 단일 진실 — DESIGN-SPEC §3)
10
+ // payload/ 내부 레이아웃 (payload 단일 진실)
9
11
  export const PAYLOAD = {
10
12
  workflowsDir: "workflows", // payload/workflows/{common,spring,flutter,...}
11
13
  scriptsDir: "scripts", // payload/scripts/*.py
12
14
  configDir: "config", // payload/config/wizard-prompts.yml 등 (마법사 런타임용)
13
15
  };
14
16
 
17
+ // 타입 하나의 워크플로우 원본 폴더 [타입 직하위, server-deploy 하위] — 존재 여부는 호출부가 확인한다.
18
+ // 설치·충돌 조사·미리보기·배포 방식 판정이 같은 폴더 구성을 봐야 결과가 서로 어긋나지 않는다.
19
+ export function typeWorkflowDirs(payloadRoot, type) {
20
+ const typeDir = join(payloadRoot, PAYLOAD.workflowsDir, type);
21
+ return [typeDir, join(typeDir, "server-deploy")];
22
+ }
23
+
15
24
  export const WORKFLOW_PREFIX = "PROJECT";
16
25
  export const WORKFLOW_COMMON_PREFIX = "PROJECT-COMMON";
26
+
27
+ // 경로 정규화: 앞뒤 공백·\→/·끝 /·앞 ./ 제거, 빈값→"."
28
+ export function normalizePath(p) {
29
+ let s = String(p).trim();
30
+ s = s.replace(/\\/g, "/");
31
+ s = s.replace(/\/+$/, ""); // 끝 /
32
+ s = s.replace(/^\.\//, ""); // 앞 ./
33
+ return s === "" ? "." : s;
34
+ }
35
+
36
+ // 정규화된 경로가 레포 안(상대경로, '..' 없음)인가. 레포 밖을 가리키면 설치 파일(fastlane 등)이
37
+ // 옆 레포에 만들어지고 워크플로우 paths 필터도 동작하지 않는다.
38
+ export function isRepoRelativePath(p) {
39
+ if (/^\//.test(p) || /^[A-Za-z]:/.test(p)) return false;
40
+ return !p.split("/").includes("..");
41
+ }
@@ -0,0 +1,23 @@
1
+ // 릴리스 옵션(semver_auto·copilot_ai) 값 결정 규칙 — 비대화형(index.js)과 대화형(interactive.js)이 공유한다.
2
+ // 우선순위: 명시값(CLI 플래그·질문 답변) → version.yml 저장값 → 기본값. 경로마다 따로 두면
3
+ // 같은 레포가 실행 방식에 따라 다른 설정으로 설치된다.
4
+
5
+ // 명시값과 저장값만 반영한다 — null이면 아직 정해지지 않은 것(대화형은 이 값으로 질문 여부를 가른다).
6
+ export function pickReleaseOptions(explicit = {}, existing = null) {
7
+ return {
8
+ semverAuto: explicit.semverAuto ?? existing?.options?.semverAuto ?? null,
9
+ copilotAi: explicit.copilotAi ?? existing?.options?.copilotAi ?? null,
10
+ };
11
+ }
12
+
13
+ // 최종값 — 정해지지 않은 옵션에 기본값을 채운다.
14
+ // semver_auto: 신규 설치만 true. 기존 version.yml에 키가 없던 설치(기능 추가 이전 설치)가 조용히 켜지면
15
+ // 애매한 커밋 하나로 major가 승격될 수 있어 false로 둔다.
16
+ // copilot_ai: AI Credits를 소비하는 opt-in이라 신규·기존 모두 명시하지 않으면 false.
17
+ export function resolveReleaseOptions(explicit = {}, existing = null) {
18
+ const { semverAuto, copilotAi } = pickReleaseOptions(explicit, existing);
19
+ return {
20
+ includeSemverAuto: semverAuto === null ? !existing : semverAuto !== false,
21
+ includeCopilotAi: copilotAi === true,
22
+ };
23
+ }
@@ -8,30 +8,24 @@
8
8
  // 없는(그러나 파일명은 여전히 현재 payload와 일치하는) 기존 설치 전체를 인식하지 못하는 회귀가
9
9
  // 생긴다 — 그래서 두 방식을 합집합으로 병행한다. 마커는 이 수정 이후 배포되는 payload 템플릿부터
10
10
  // 포함되므로, (b) 경로가 실제로 새로 잡아내는 것은 "이름이 바뀌거나 삭제된, 마커가 있는" 파일뿐이다.
11
+ // 단, 마커는 사용자가 워크플로우를 복사해 이름만 바꾼 파일에도 그대로 따라간다. 그래서 (b)는
12
+ // baseline에 설치 기록이 있는 파일명만 인정하고, baseline이 없는 구버전 설치에서만 PROJECT-* 접두로 대신 판단한다.
11
13
  // 사용자가 직접 만든 워크플로우·version.yml·README·.gitignore는 대상이 아니다
12
14
  // (version.yml은 사용자 버전 데이터 — 제거 대상이 아니라 산출물이다).
13
15
  //
14
16
  // 이 파일은 원래 src/commands/revert.js였다. revert 모드는 uninstall의 부분집합이라 제거됐고
15
17
  //, 판별 로직만 남아 commands가 아닌 core로 옮겨졌다.
16
- import { join } from "node:path";
17
- import { existsSync, readFileSync, readdirSync } from "node:fs";
18
- import { PATHS, PAYLOAD } from "./paths.js";
19
- import { BASELINE_DIR, BASELINE_PATH } from "./baseline.js";
18
+ import { join, isAbsolute } from "node:path";
19
+ import { existsSync, readFileSync, readdirSync, renameSync, rmSync } from "node:fs";
20
+ import { PATHS } from "./paths.js";
21
+ import { BASELINE_DIR, BASELINE_PATH, readBaseline, appFileHash, sha256 } from "./baseline.js";
22
+ import { LOG_DIR } from "./logger.js";
23
+ import { payloadWorkflowNames } from "./deploy-style.js";
24
+ import { SCRIPT_NAMES } from "./copy/simple.js";
20
25
 
21
26
  // payload/workflows/**/*.yaml 첫 줄에 심어둔 고정 마커 — 이 값이 바뀌면 과거 설치분과의 매칭이 끊긴다.
22
27
  export const MANAGED_WORKFLOW_MARKER = "# project-auto-wizard:managed-workflow";
23
28
 
24
- // payload/workflows/** 전체(하위 폴더 포함)의 yaml 파일명 집합.
25
- function payloadWorkflowNames(payloadRoot) {
26
- const names = new Set();
27
- const root = join(payloadRoot, PAYLOAD.workflowsDir);
28
- if (!existsSync(root)) return names;
29
- for (const e of readdirSync(root, { recursive: true, withFileTypes: true })) {
30
- if (e.isFile() && /\.(ya?ml)$/.test(e.name)) names.add(e.name);
31
- }
32
- return names;
33
- }
34
-
35
29
  // 설치된 워크플로우 디렉토리(평면 구조)에서 관리 마커로 시작하는 파일명 집합.
36
30
  // .bak/.template.yaml 백업본도 원본 텍스트를 그대로 복사한 것이라 마커를 그대로 갖고 있어
37
31
  // 확장자나 파일명 유추 없이 동일한 방식으로 인식된다.
@@ -46,11 +40,29 @@ function markedWorkflowNames(wfDir) {
46
40
  return names;
47
41
  }
48
42
 
43
+ // 마커가 있는 파일이 마법사가 설치한 것인지 — .bak/.template.yaml 백업본은 원본 파일명으로 따진다.
44
+ // recorded: baseline의 파일명 집합, baseline이 없으면 null.
45
+ function isInstalledName(name, recorded) {
46
+ if (!recorded) return name.startsWith("PROJECT-");
47
+ const stem = name.endsWith(".bak") ? name.slice(0, -".bak".length) : name;
48
+ const candidates = [name, stem];
49
+ if (stem.endsWith(".template.yaml")) {
50
+ const base = stem.slice(0, -".template.yaml".length);
51
+ candidates.push(base, base + ".yaml");
52
+ }
53
+ return candidates.some((c) => recorded.has(c));
54
+ }
55
+
56
+ // 충돌 처리·정리로 생긴 백업 파생 파일(.bak/.template.yaml) — .gitignore 자동 추가 항목이 가리는 대상이다.
57
+ // 워크플로우를 남기는 삭제에서 이 파일들이 남으면 .gitignore 항목도 남겨야 git 상태에 드러나지 않는다.
58
+ export const backupArtifacts = (names) => names.filter((n) => n.endsWith(".bak") || n.endsWith(".template.yaml"));
59
+
49
60
  // 아무것도 지우지 않는 순수 함수 — uninstall/purge/--dry-run이 공유한다.
50
61
  export function planRemoval(payloadRoot, targetRoot = ".") {
51
62
  const removedWf = new Set();
52
63
  const removedScripts = [];
53
64
  const wfDir = join(targetRoot, PATHS.workflowsDir);
65
+ const baseline = readBaseline(targetRoot);
54
66
  if (existsSync(wfDir)) {
55
67
  // (a) 현재 payload와 파일명이 일치하는 것 — 마커 유무 무관(기존 설치 회귀 방지).
56
68
  for (const name of payloadWorkflowNames(payloadRoot)) {
@@ -61,13 +73,57 @@ export function planRemoval(payloadRoot, targetRoot = ".") {
61
73
  if (existsSync(p + ".bak")) removedWf.add(name + ".bak");
62
74
  }
63
75
  // (b) 관리 마커로 시작하는 것 — payload에서 이름이 바뀌거나 삭제된 파일도 인식.
64
- for (const name of markedWorkflowNames(wfDir)) removedWf.add(name);
76
+ const recorded = baseline ? new Set(Object.keys(baseline.files)) : null;
77
+ for (const name of markedWorkflowNames(wfDir)) {
78
+ if (isInstalledName(name, recorded)) removedWf.add(name);
79
+ }
65
80
  }
66
- for (const s of ["version_manager.py", "changelog_manager.py", "truncate_release_notes.py", "issue_helper.py"]) {
81
+ for (const s of SCRIPT_NAMES) {
67
82
  if (existsSync(join(targetRoot, PATHS.scriptsDir, s))) removedScripts.push(s);
68
83
  }
84
+ // Flutter 앱 파일(Fastfile 등)은 마법사가 새로 만들었고 내용이 그대로인 것만 — 값을 채워 넣은 파일은 사용자 것이다.
85
+ const appFiles = [];
86
+ for (const [rel, hash] of Object.entries(baseline?.appFiles || {})) {
87
+ if (rel.split(/[\\/]/).includes("..") || isAbsolute(rel)) continue; // 레포 밖 경로는 신뢰하지 않는다
88
+ const p = join(targetRoot, rel);
89
+ if (existsSync(p) && appFileHash(readFileSync(p, "utf8")) === hash) appFiles.push(rel);
90
+ }
69
91
  // baseline은 마법사가 만든 내부 상태 파일이다 — 설치물을 지우면 함께 사라져야 한다.
70
92
  // 남겨두면 다음 설치가 "예전에 깔았다가 사용자가 지운 파일"로 오인해 전부 removed로 분류한다.
71
- const baseline = existsSync(join(targetRoot, BASELINE_PATH)) ? [BASELINE_DIR] : [];
72
- return { workflows: [...removedWf], scripts: removedScripts, baseline };
93
+ // 실행 로그(.wizard/logs)만 남은 경우도 같은 폴더째 지운다 — 설치물을 다 지운 뒤 로그 폴더만 남으면
94
+ // "완전 삭제"가 아니다.
95
+ const baselineDirs = existsSync(join(targetRoot, BASELINE_PATH)) || existsSync(join(targetRoot, LOG_DIR)) ? [BASELINE_DIR] : [];
96
+ return { workflows: [...removedWf], scripts: removedScripts, appFiles, baseline: baselineDirs };
97
+ }
98
+
99
+ // 업데이트 때 정리할 옛 워크플로우 — 마법사가 설치했지만(baseline 기록 + 관리 마커) 지금 payload에는 없는 파일.
100
+ // payload에서 이름이 바뀌거나 빠진 파일이라, 남겨두면 옛 트리거·옛 절차로 계속 돈다.
101
+ // baseline에 없는 파일은 대상이 아니다 — 사용자가 마법사 파일을 복사해 만든 워크플로우일 수 있다.
102
+ export function findStaleWorkflows(payloadRoot, targetRoot = ".", baseline = null) {
103
+ if (!baseline?.files) return [];
104
+ const current = payloadWorkflowNames(payloadRoot);
105
+ return [...markedWorkflowNames(join(targetRoot, PATHS.workflowsDir))]
106
+ .filter((n) => /\.ya?ml$/.test(n) && !n.endsWith(".template.yaml") && !current.has(n) && baseline.files[n])
107
+ .sort();
108
+ }
109
+
110
+ // 배포 방식 정리와 같은 규칙 — 손대지 않은 파일(installed 해시 일치)은 삭제, 손댄 파일은 .bak으로 옮겨
111
+ // 내용은 지키고 트리거만 끈다. dryRun이면 판정만 하고 파일은 건드리지 않는다 (--dry-run 미리보기용).
112
+ export function cleanupStaleWorkflows(targetRoot, names, baseline, { dryRun = false } = {}) {
113
+ const wfDir = join(targetRoot, PATHS.workflowsDir);
114
+ const removed = [];
115
+ const backedUp = [];
116
+ for (const name of names) {
117
+ const p = join(wfDir, name);
118
+ if (!existsSync(p)) continue;
119
+ const known = baseline?.files?.[name]?.installed;
120
+ if (known && sha256(readFileSync(p, "utf8")) === known) {
121
+ if (!dryRun) rmSync(p, { force: true });
122
+ removed.push(name);
123
+ } else {
124
+ if (!dryRun) renameSync(p, `${p}.bak`);
125
+ backedUp.push(name);
126
+ }
127
+ }
128
+ return { removed, backedUp };
73
129
  }