projectops 4.0.3 → 4.1.1

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.
@@ -0,0 +1,100 @@
1
+ // 선택(opt-in) 워크플로우 포함 여부 질문 (.sh ask_optional_workflow L2651~2702 /
2
+ // ask_all_optional_workflows L2708~2732 등가). Nexus publish + Secret 서버 백업.
3
+ //
4
+ // io 주입 계약(readline-engine 시그니처):
5
+ // io.confirm({message, initialValue}) → bool | CANCEL(symbol)
6
+ // io.log(line) → 안내 출력 (없으면 stderr)
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { listYamlFiles } from "./fsutil.js";
10
+ import { PATHS } from "./paths.js";
11
+ import { parseTemplateOptions } from "./version-yml.js";
12
+
13
+ // 재노출 — 파서 본체는 version-yml.js에 있다 (순환 import 방지: options-ask → version-yml 방향만 허용)
14
+ export { parseTemplateOptions };
15
+
16
+ const isCancel = (v) => typeof v === "symbol";
17
+
18
+ // 옵션 1종 질문 (.sh ask_optional_workflow 등가).
19
+ // 반환: true/false/null(폴더 없음·파일 0개로 질문 자체 생략 → 현재값 유지).
20
+ async function askOptionalWorkflow({ dir, icon, short, desc, current, force, tty, io, forceAsk, say }) {
21
+ // 폴더가 없거나 yaml이 0개면 조용히 건너뜀 (.sh L2664~2669) — 질문 자체가 성립 안 함
22
+ if (!existsSync(dir)) return current;
23
+ const files = listYamlFiles(dir);
24
+ if (files.length === 0) return current;
25
+
26
+ // 이미 값이 설정돼 있고 force-ask 아니면 유지 (CLI/version.yml 우선, .sh L2672~2674)
27
+ if (!forceAsk && (current === true || current === false)) return current;
28
+
29
+ // 비대화형(--force 또는 TTY 없음)이면 기본 제외 (.sh L2677~2679)
30
+ if (force || !tty) return false;
31
+
32
+ say("");
33
+ say(`${icon} ${short} 워크플로우를 발견했습니다. (${files.length}개 파일)`);
34
+ say(` ${desc}`);
35
+ say("");
36
+ say(" 포함되는 워크플로우:");
37
+ for (const f of files) say(` • ${f}`);
38
+ say("");
39
+
40
+ const ans = await io.confirm({ message: `${short} 워크플로우를 포함할까요?`, initialValue: false });
41
+ // ESC(취소)는 '아니오'와 동일 취급 (.sh ask_yes_no 비-0 반환 등가)
42
+ const include = ans === true && !isCancel(ans);
43
+ say(include
44
+ ? `${short} 워크플로우를 포함합니다 — GitHub Actions에 추가됩니다`
45
+ : `${short} 워크플로우를 제외합니다 (나중에 옵션으로 추가 가능)`);
46
+ return include;
47
+ }
48
+
49
+ // 모든 opt-in 워크플로우를 순서대로 질문 (.sh ask_all_optional_workflows 등가).
50
+ // tempDir: 템플릿 다운로드 루트 — project-types는 {tempDir}/.github/workflows/project-types
51
+ // (copyWorkflows와 동일 규약. 테스트 픽스처용으로 {tempDir}/project-types 도 허용.)
52
+ // current: { nexus: bool|null, secretBackup: bool|null } — CLI(--nexus 등) 명시값
53
+ // 반환: { nexus: bool, secretBackup: bool } (미결정 null은 false로 확정)
54
+ export async function askAllOptionalWorkflows({
55
+ tempDir, types = [], current = {}, targetRoot = ".",
56
+ force = false, tty = true, io = {}, forceAsk = false,
57
+ }) {
58
+ const say = io.log || ((m) => process.stderr.write(`${m}\n`));
59
+ let nexus = current.nexus ?? null;
60
+ let secretBackup = current.secretBackup ?? null;
61
+
62
+ // ① --force-ask가 아니면 version.yml 저장값을 먼저 읽어 재질문을 건너뛴다 (.sh L2715~2717).
63
+ // CLI 명시값(current)이 이미 있으면 그쪽이 우선 — 저장값은 빈 자리만 채운다.
64
+ if (!forceAsk) {
65
+ const vy = join(targetRoot, PATHS.versionFile);
66
+ if (existsSync(vy)) {
67
+ const saved = parseTemplateOptions(readFileSync(vy, "utf8"));
68
+ if (nexus === null && saved.nexus !== null) {
69
+ nexus = saved.nexus;
70
+ say(`Nexus 옵션: version.yml 저장값(${nexus}) 유지 — 재질문 생략`);
71
+ }
72
+ if (secretBackup === null && saved.secretBackup !== null) {
73
+ secretBackup = saved.secretBackup;
74
+ say(`Secret 백업 옵션: version.yml 저장값(${secretBackup}) 유지 — 재질문 생략`);
75
+ }
76
+ }
77
+ }
78
+
79
+ // project-types 루트 결정 — 실제 temp 레이아웃 우선, 픽스처(직하위) 폴백
80
+ const real = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
81
+ const ptDir = existsSync(real) ? real : join(tempDir, PATHS.projectTypesDir);
82
+
83
+ // ② Nexus: 각 타입의 nexus/ 폴더 (현재 spring만 존재, .sh L2719~2725)
84
+ for (const t of types) {
85
+ nexus = await askOptionalWorkflow({
86
+ dir: join(ptDir, t, "nexus"), icon: "📦", short: "Nexus 라이브러리 publish",
87
+ desc: "라이브러리/모듈을 Maven 저장소(Nexus)에 배포하는 워크플로우입니다. 일반 서버 배포가 아니라 라이브러리 프로젝트에만 필요합니다.",
88
+ current: nexus, force, tty, io, forceAsk, say,
89
+ });
90
+ }
91
+ // ③ Secret 백업: 공통 폴더 (.sh L2726~2729)
92
+ secretBackup = await askOptionalWorkflow({
93
+ dir: join(ptDir, "common", "secret-backup"), icon: "🔐", short: "Secret 서버 백업",
94
+ desc: "GitHub Secret에 저장한 설정 파일을 SSH로 서버에 업로드·이력관리하는 워크플로우입니다.",
95
+ current: secretBackup, force, tty, io, forceAsk, say,
96
+ });
97
+
98
+ // ④ 미결정(null)은 false로 확정 — .sh에서 빈 INCLUDE_* 가 이후 false 취급되는 것과 동일
99
+ return { nexus: nexus === true, secretBackup: secretBackup === true };
100
+ }
@@ -0,0 +1,261 @@
1
+ // 타입별 프로젝트 경로 감지·확정 (.sh find_type_path_candidates L1249~1311 /
2
+ // resolve_project_paths L1362~1589 등가). 모노레포에서 각 타입의 버전 파일이
3
+ // 어느 폴더에 있는지 5단계 우선순위로 확정한다.
4
+ //
5
+ // io 주입 계약(readline-engine 시그니처 그대로):
6
+ // io.select({message, options:[{value,label}]}) → value | CANCEL(symbol)
7
+ // io.text({message, defaultValue}) → string | CANCEL
8
+ // io.confirm({message, initialValue}) → bool | CANCEL
9
+ // io.log(line) → 안내 출력 (없으면 stderr)
10
+ import { existsSync, readdirSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { markerForType as baseMarkerForType, extraMarkers } from "./detect.js";
13
+ import { normalizePath } from "../cli/args.js";
14
+
15
+ // 취소(ESC/Ctrl+C)는 CANCEL 심볼 — ui를 import하지 않고 심볼 여부로만 판정 (core→ui 역참조 방지)
16
+ const isCancel = (v) => typeof v === "symbol";
17
+
18
+ // 타입의 대표 마커 파일명 (.sh marker_for_type L1220~1229 등가).
19
+ // detect.js는 미지 타입에 package.json을 기본 반환하지만 .sh는 빈 문자열 — 등가를 위해 래핑.
20
+ const KNOWN_MARKER_TYPES = new Set([
21
+ "flutter", "react", "node", "react-native", "react-native-expo", "python", "spring",
22
+ ]);
23
+ export function markerForType(type) {
24
+ return KNOWN_MARKER_TYPES.has(type) ? baseMarkerForType(type) : "";
25
+ }
26
+
27
+ // 디렉토리에 실재하는 마커 파일명 반환 — 보조 마커 포함, 없으면 대표 마커 (표시용).
28
+ // (.sh existing_marker_in_dir L1232~1245: spring build.gradle/.kts/pom.xml, python pyproject/setup.py/requirements.txt)
29
+ export function existingMarkerInDir(type, dir) {
30
+ const primary = markerForType(type);
31
+ const names = primary ? [primary, ...extraMarkers(type)] : [];
32
+ for (const n of names) {
33
+ if (existsSync(join(dir, n))) return n;
34
+ }
35
+ return primary;
36
+ }
37
+
38
+ // maxdepth 3 재귀 파일 탐색 — 매치 파일의 "디렉토리" 상대경로(루트는 ".")를 수집.
39
+ // find의 maxdepth는 파일 경로 컴포넌트 수 기준(./a/b/f = depth 3)이므로 동일하게 계산.
40
+ function walkFindDirs(root, { prune, match, maxDepth = 3 }) {
41
+ const hits = [];
42
+ const walk = (rel, depth) => {
43
+ let entries;
44
+ try { entries = readdirSync(join(root, rel || "."), { withFileTypes: true }); } catch { return; }
45
+ for (const e of entries) {
46
+ const childDepth = depth + 1;
47
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
48
+ if (e.isDirectory()) {
49
+ // prune 폴더는 하위 전체 제외 (.sh find -prune 등가)
50
+ if (prune.has(e.name)) continue;
51
+ // 자식 파일이 depth ≤ maxDepth 안에 들어올 때만 하강
52
+ if (childDepth < maxDepth) walk(childRel, childDepth);
53
+ // childDepth === maxDepth-0 인 디렉토리 내부 파일은 depth maxDepth+1 → find가 안 봄
54
+ else if (childDepth === maxDepth) { /* 파일만 maxDepth까지 — 디렉토리 하강 불필요 */ }
55
+ } else if (childDepth <= maxDepth && match(e.name)) {
56
+ hits.push(rel === "" ? "." : rel);
57
+ }
58
+ }
59
+ };
60
+ walk("", 0);
61
+ return [...new Set(hits)].sort(); // sort -u 등가
62
+ }
63
+
64
+ // 타입별 마커 파일 후보 검색 (.sh find_type_path_candidates L1249~1311 등가).
65
+ // 반환: 후보 디렉토리 상대경로 배열 (루트는 ".").
66
+ export function findTypePathCandidates(root, type) {
67
+ // ── Spring 멀티모듈: settings.gradle(.kts) 폴더 = 모듈 루트로 축약 (.sh L1255~1268) ──
68
+ // version_manager가 그 폴더 아래 build.gradle 전부를 갱신하므로 하위 모듈을 펼치지 않는다.
69
+ // android/ 의 settings.gradle(Flutter/RN)은 spring이 아니므로 prune.
70
+ if (type === "spring") {
71
+ const mm = walkFindDirs(root, {
72
+ prune: new Set(["node_modules", ".git", "build", "dist", ".gradle", "android", "ios"]),
73
+ match: (n) => n === "settings.gradle" || n === "settings.gradle.kts",
74
+ });
75
+ if (mm.length) return mm;
76
+ // settings.gradle 없음 → 단일 모듈, 아래 build.gradle 폴백
77
+ }
78
+
79
+ const namesByType = {
80
+ flutter: ["pubspec.yaml"],
81
+ react: ["package.json"], node: ["package.json"],
82
+ "react-native": ["package.json"],
83
+ "react-native-expo": ["app.json"],
84
+ python: ["pyproject.toml", "setup.py", "requirements.txt"],
85
+ spring: ["build.gradle", "build.gradle.kts", "pom.xml"],
86
+ };
87
+ const names = namesByType[type];
88
+ if (!names) return [];
89
+
90
+ const prune = new Set([
91
+ "node_modules", ".git", "build", "dist", ".dart_tool", "android", "ios",
92
+ ".gradle", "venv", ".venv", "__pycache__",
93
+ ]);
94
+ // 우선순위 높은 마커에서 발견되면 그것만 사용 (.sh L1281~1288)
95
+ let found = [];
96
+ for (const n of names) {
97
+ found = walkFindDirs(root, { prune, match: (name) => name === n });
98
+ if (found.length) break;
99
+ }
100
+
101
+ return found.filter((d) => {
102
+ if (type === "flutter") {
103
+ // example/ 제외 + lib/ 동반 확인 — 오탐 방지 (.sh L1298~1303)
104
+ if (d.includes("example")) return false;
105
+ const libDir = d === "." ? join(root, "lib") : join(root, d, "lib");
106
+ if (!existsSync(libDir)) return false;
107
+ }
108
+ if (type === "spring") {
109
+ // Flutter/RN의 android/build.gradle 오탐 제외 (.sh L1304~1307)
110
+ if (d.includes("android")) return false;
111
+ }
112
+ return true;
113
+ });
114
+ }
115
+
116
+ // 선택된 모든 타입의 경로를 감지·확인하여 Map<type,path> 확정
117
+ // (.sh resolve_project_paths L1362~1589 등가 — 5단계 우선순위).
118
+ // ① paths에 이미 있음(--paths) → 유지
119
+ // ② 루트에 마커 존재 → "." 자동
120
+ // ③ existingPaths(version.yml 저장값)
121
+ // ④ 후보 스캔
122
+ // ⑤ 분기 — 비대화형: 기존값→후보1개→루트 폴백 / 대화형: 확인·선택·직접입력
123
+ export async function resolveProjectPaths({
124
+ root, types = [], paths = new Map(), existingPaths = new Map(),
125
+ force = false, tty = true, io = {},
126
+ }) {
127
+ const say = io.log || ((m) => process.stderr.write(`${m}\n`));
128
+ const result = new Map(paths); // --paths 사전값 유지 (호출부 Map은 불변)
129
+ const targets = types.filter((t) => t !== "basic"); // basic은 경로 불필요 (.sh L1400)
130
+ if (targets.length === 0) return result;
131
+
132
+ const total = targets.length;
133
+ // ── 도입부 안내 (.sh L1407~1434 — 감지 결과 + 무엇을 할지 설명) ──
134
+ say("");
135
+ if (total > 1) say(`🔍 멀티타입 프로젝트가 감지되었습니다 — 총 ${total}개 타입`);
136
+ else say(`🔍 ${targets[0]} 프로젝트가 감지되었습니다 — 총 1개 타입`);
137
+ for (const t of targets) say(` • ${t.padEnd(8)} → ${existingMarkerInDir(t, root)}`);
138
+ say("");
139
+ say("💡 '프로젝트 루트' = 그 타입의 버전 파일이 있는 폴더 (레포 루트 기준 상대경로)");
140
+ say("");
141
+
142
+ let idx = 0;
143
+ for (const t of targets) {
144
+ idx += 1;
145
+ const prog = `[${idx}/${total}]`;
146
+
147
+ // ① --paths 등으로 이미 지정됨 → 최우선 (.sh L1441~1446)
148
+ if (result.get(t)) {
149
+ say(` ${t} → ${result.get(t)} (--paths 지정)`);
150
+ continue;
151
+ }
152
+
153
+ // ② 루트에 마커 존재 → "." 자동 확정 (.sh L1449~1455, 보조 마커 포함)
154
+ const rootMarker = existingMarkerInDir(t, root);
155
+ if (rootMarker && existsSync(join(root, rootMarker))) {
156
+ result.set(t, ".");
157
+ say(` ${t} → . (루트의 ${rootMarker})`);
158
+ continue;
159
+ }
160
+
161
+ // ③ 기존 version.yml 저장값 → 기본 제안값 (.sh L1458~1466)
162
+ const existing = existingPaths.get(t) || "";
163
+
164
+ // ④ 후보 검색 (.sh L1469~1471)
165
+ const candidates = findTypePathCandidates(root, t);
166
+ let chosen = "";
167
+
168
+ // ── ⑤-a 비대화형 (--force 또는 TTY 없음, .sh L1476~1489) ──
169
+ if (force || !tty) {
170
+ if (existing) {
171
+ chosen = existing;
172
+ say(` ${t} → ${chosen} (기존 project_paths 유지)`);
173
+ } else if (candidates.length === 1) {
174
+ chosen = candidates[0];
175
+ say(` ${t} → ${chosen} (자동 감지)`);
176
+ } else {
177
+ chosen = ".";
178
+ say(` ⚠️ ${t} → 후보 ${candidates.length}개로 자동 확정 불가, 루트(.)로 기록 (--paths "${t}=경로"로 지정 가능)`);
179
+ }
180
+ result.set(t, chosen);
181
+ continue;
182
+ }
183
+
184
+ // ── ⑤-b 대화형: 후보 개수별 분기 (.sh L1492~1525) ──
185
+ if (candidates.length === 1) {
186
+ const cand = candidates[0];
187
+ const candMarker = existingMarkerInDir(t, cand === "." ? root : join(root, cand));
188
+ const candFull = cand === "." ? candMarker : `${cand}/${candMarker}`;
189
+ say("");
190
+ say(` ${prog} 🔍 ${t} — ${candMarker} 발견`);
191
+ say(` 위치: <레포루트>/${candFull}`);
192
+ // '아니오'/취소 시 chosen 미설정 → 아래 직접입력 루프로
193
+ const ok = await io.confirm({
194
+ message: ` ${t} 프로젝트 루트를 '${cand}'(으)로 설정할까요? (${candFull} 기준 — 아니오 선택 시 직접 입력)`,
195
+ initialValue: true,
196
+ });
197
+ if (ok === true) chosen = cand;
198
+ } else if (candidates.length > 1) {
199
+ say("");
200
+ say(` ${prog} 🔍 ${t}: 경로 후보 ${candidates.length}개 발견`);
201
+ // 후보들 + '직접 입력' 메뉴 — value 자체를 한국어로 (센티넬 노출 방지, .sh L1508~1521)
202
+ const options = candidates.map((c) => ({
203
+ value: c,
204
+ label: `${c} (${existingMarkerInDir(t, c === "." ? root : join(root, c))})`,
205
+ }));
206
+ options.push({ value: "직접 입력", label: "직접 입력" });
207
+ const sel = await io.select({ message: ` ${t} 프로젝트 루트를 선택하세요`, options });
208
+ // ESC(취소)도 직접 입력으로 폴백 (.sh `|| _sel="직접 입력"`)
209
+ if (!isCancel(sel) && sel != null && sel !== "직접 입력") chosen = sel;
210
+ } else {
211
+ say("");
212
+ say(` ⚠️ ${prog} ${t}: 프로젝트를 찾지 못했습니다 (maxdepth 3).`);
213
+ }
214
+
215
+ // ── 직접 입력 루프 (위에서 미확정 시, .sh L1528~1553) ──
216
+ while (!chosen) {
217
+ const hintMarker = existingMarkerInDir(t, root);
218
+ let prompt = ` ${t} 프로젝트 루트 경로 입력 (${hintMarker} 이 있는 폴더, 예: server, app — 루트면 그냥 Enter`;
219
+ if (existing) prompt += `, 현재값: ${existing}`;
220
+ prompt += "): ";
221
+ let input = await io.text({ message: prompt, defaultValue: "" });
222
+ if (isCancel(input) || input == null) input = ""; // ESC → 빈값 (아래 폴백)
223
+ input = String(input).trim();
224
+ // 빈값 → 기존값 또는 루트 (.sh L1541~1543) — normalizePath 전에 판정
225
+ input = input === "" ? (existing || ".") : normalizePath(input);
226
+ // 검증: 입력 경로에 마커 존재 확인 (보조 마커 포함, .sh L1544~1552)
227
+ const m = existingMarkerInDir(t, input === "." ? root : join(root, input));
228
+ if (m && existsSync(join(root, input === "." ? "" : input, m))) {
229
+ chosen = input;
230
+ } else {
231
+ say(` ⚠️ ${input}/${m} 파일이 없습니다.`);
232
+ const forceOk = await io.confirm({ message: " 그래도 이 경로를 사용할까요?", initialValue: false });
233
+ if (forceOk === true) chosen = input;
234
+ }
235
+ }
236
+
237
+ result.set(t, chosen);
238
+ say(` ✅ ${t} → ${chosen}`);
239
+ }
240
+
241
+ // ── 요약 + 같은 마커 파일 중복 경고 (.sh L1559~1587) ──
242
+ say("");
243
+ say("📂 타입별 버전 파일 경로 확정:");
244
+ const fileToTypes = new Map(); // 마커 파일 상대경로 → 그 파일을 쓰는 타입들
245
+ for (const [pt, pp] of result) {
246
+ const m = existingMarkerInDir(pt, pp === "." ? root : join(root, pp));
247
+ const file = pp === "." ? m : `${pp}/${m}`;
248
+ say(` ${pt} → ${file}`);
249
+ if (!fileToTypes.has(file)) fileToTypes.set(file, []);
250
+ fileToTypes.get(file).push(pt);
251
+ }
252
+ for (const [file, ts] of fileToTypes) {
253
+ if (ts.length > 1) {
254
+ // 멱등 동작이라 막지는 않고 경고만 (.sh L1577~1586)
255
+ say(` ⚠️ 같은 파일(${file})을 여러 타입(${ts.join(" ")})이 바라봅니다.`);
256
+ say(" → sync 때 모두 같은 버전이 기록됩니다. 동작에는 문제없지만 의도한 구성인지 확인하세요.");
257
+ }
258
+ }
259
+ say("");
260
+ return result;
261
+ }
@@ -12,7 +12,7 @@ const HEADER = `# ==============================================================
12
12
  # 사용법:
13
13
  # 1. version: "1.0.0" - 사용자에게 표시되는 버전
14
14
  # 2. version_code: 1 - Play Store/App Store 빌드 번호 (1부터 자동 증가)
15
- # 3. project_type: 프로젝트 타입 지정
15
+ # 3. project_types: 프로젝트 타입 배열 — 첫 항목이 primary
16
16
  # 4. project_paths: 타입별 프로젝트 폴더 (레포 루트 기준 상대경로, 모노레포용)
17
17
  #
18
18
  # 자동 버전 업데이트:
@@ -35,11 +35,48 @@ const HEADER = `# ==============================================================
35
35
  # - .github/workflows/PROJECT-AUTO-CHANGELOG-CONTROL.yaml
36
36
  #
37
37
  # 주의사항:
38
- # - project_type은 최초 설정 후 변경하지 마세요
38
+ # - project_types는 최초 설정 후 변경하지 마세요
39
39
  # - 버전은 항상 높은 버전으로 자동 동기화됩니다
40
40
  # ===================================================================
41
41
  `;
42
42
 
43
+ // metadata.template.options 상태머신 파싱 (.sh read_template_options L2361~2416 등가).
44
+ // 반환: { nexus: bool|null, secretBackup: bool|null } — null=미기재.
45
+ // 구 synology 키 등 다른 키는 어느 분기에도 안 걸려 자연히 무시된다.
46
+ // (options-ask.js가 이 함수를 import한다 — 순환 방지 위해 여기(version-yml)에 정의.)
47
+ export function parseTemplateOptions(content) {
48
+ const out = { nexus: null, secretBackup: null };
49
+ // 값 정규화: 따옴표 제거 + 트림 (.sh tr -d '"' | tr -d "'" | xargs 등가)
50
+ const strip = (s) => String(s).replace(/["']/g, "").trim();
51
+ let inTemplate = false;
52
+ let inOptions = false;
53
+ for (const line of String(content || "").split("\n")) {
54
+ if (/^\s*template:/.test(line)) { inTemplate = true; continue; }
55
+ if (inTemplate && /^\s+options:/.test(line)) { inOptions = true; continue; }
56
+ if (inTemplate && inOptions) {
57
+ let m = line.match(/^\s+nexus:\s*(.+)/);
58
+ if (m) {
59
+ const v = strip(m[1]);
60
+ if (v === "true") out.nexus = true;
61
+ if (v === "false") out.nexus = false;
62
+ continue;
63
+ }
64
+ m = line.match(/^\s+secret_backup:\s*(.+)/);
65
+ if (m) {
66
+ const v = strip(m[1]);
67
+ if (v === "true") out.secretBackup = true;
68
+ if (v === "false") out.secretBackup = false;
69
+ continue;
70
+ }
71
+ // 들여쓰기 0~4칸의 다른 키 → options 섹션 종료 (.sh L2404~2408)
72
+ if (/^\s{0,4}[a-z_]+:/.test(line)) { inOptions = false; inTemplate = false; }
73
+ }
74
+ // 최상위 키 → template 섹션 종료 (.sh L2411~2415)
75
+ if (inTemplate && /^[a-z_]+:/.test(line)) { inTemplate = false; inOptions = false; }
76
+ }
77
+ return out;
78
+ }
79
+
43
80
  // 기존 version.yml에서 값 추출 (.sh grep/sed 등가, 주석 라인 오탐 방지).
44
81
  export function parseExisting(content) {
45
82
  const text = String(content || "");
@@ -82,24 +119,25 @@ export function parseExisting(content) {
82
119
  if (/^\S/.test(l)) break;
83
120
  }
84
121
  }
85
- return { version, versionCode, types, paths, templateVersion };
122
+ // 선택 워크플로우 옵션 (metadata.template.options nexus/secret_backup)
123
+ const options = parseTemplateOptions(text);
124
+ return { version, versionCode, types, paths, templateVersion, options };
86
125
  }
87
126
 
88
127
  // version.yml 전체 생성 (.sh create_version_yml + save_template_options 신규 케이스 등가).
89
- // opts: { version, types:[], primaryType?, paths:Map, pathMarkers?:Map, branch, versionCode, now, today, templateOptions? }
128
+ // opts: { version, types:[], paths:Map, pathMarkers?:Map, branch, versionCode, now, today, templateOptions? }
129
+ // primary 타입은 별도 키 없이 project_types[0]이다 (v4.1.0 SSOT — 단수 project_type 키 제거).
90
130
  // now = "YYYY-MM-DD HH:MM:SS" (UTC) — 결정성 위해 주입
91
131
  // today = "YYYY-MM-DD" (UTC)
92
132
  // pathMarkers = Map<type, markerFilename> (project_paths 주석용)
93
133
  // templateOptions = { templateVersion, includeNexus, includeSecretBackup, optionsDate } (template 블록)
94
- export function buildVersionYml({ version, types = [], primaryType, paths = new Map(), pathMarkers = new Map(), branch = "main", versionCode = 1, now, today, templateOptions = null, deployValues = new Map() }) {
134
+ export function buildVersionYml({ version, types = [], paths = new Map(), pathMarkers = new Map(), branch = "main", versionCode = 1, now, today, templateOptions = null, deployValues = new Map() }) {
95
135
  const typesJson = types.length ? `[${types.map((t) => `"${t}"`).join(",")}]` : `["basic"]`;
96
- const primary = primaryType || types[0] || "basic";
97
136
 
98
137
  let out = HEADER + "\n";
99
138
  out += `version: "${version}"\n`;
100
139
  out += `version_code: ${versionCode} # app build number\n`;
101
140
  out += `project_types: ${typesJson} # 멀티타입 배열 — 첫 항목이 primary, 직접 편집 가능\n`;
102
- out += `project_type: "${primary}" # project_types[0] 자동 미러 — 직접 수정 금지 (spring, flutter, next, react, react-native, react-native-expo, node, python, basic)\n`;
103
141
 
104
142
  // project_paths 블록. pathMarkers: Map<type, markerFilename> (있으면 " type: "path" # path/marker" 주석).
105
143
  if (paths.size) {
@@ -0,0 +1,106 @@
1
+ // wizard-prompts.yml 라벨 메타 파서 (.sh _wf_labels_path/_wf_read_field/wf_field/wf_workflow_name 등가).
2
+ // 실측 기준: template_integrator.sh 2809~2814(_wf_labels_path), 2895~2932(wf_workflow_name),
3
+ // 2934~2960(_wf_read_field), 2962~2970(wf_field).
4
+ // ⚠️ YAML 라이브러리 금지 — .sh와 동일하게 라인 기반 파싱만 한다(외부 의존성 0 + 포맷 관용성 동일).
5
+ import { join } from "node:path";
6
+ import * as nodeFs from "node:fs";
7
+ import { PATHS } from "./paths.js";
8
+
9
+ // wizard-prompts.yml 위치 (레포 루트 기준 상대경로 — .sh LABELS_FILE 상수)
10
+ export const LABELS_FILE = ".github/config/wizard-prompts.yml";
11
+
12
+ // 따옴표 감싸진 값이면 벗기고 trim ("값" → 값). .sh sub(/^"/)/sub(/"$/) 등가.
13
+ function unquote(s) {
14
+ const t = s.trim();
15
+ if (t.startsWith('"') && t.endsWith('"') && t.length >= 2) return t.slice(1, -1);
16
+ return t;
17
+ }
18
+
19
+ // wizard-prompts.yml 텍스트 → 파싱 객체 (순수 함수 — 테스트 직접 사용 가능).
20
+ // 반환: { fields: Map<조회키, {label?,help?,example?}>, workflowNames: [{key,value}] }
21
+ // - 조회키: "PROJECT_NAME" 또는 "flutter.APP_ARTIFACT_NAME" (dotted 타입 오버라이드)
22
+ // - 구형 1줄(KEY: "라벨")은 fields의 label로 흡수 (.sh _wf_read_field 형식1 등가 — label만 의미)
23
+ export function parseWizardPrompts(text) {
24
+ const fields = new Map();
25
+ const workflowNames = [];
26
+ let current = null; // 현재 블록의 fields 엔트리 (들여쓰기 라인 소속처)
27
+ let inWfNames = false; // _workflow_names 블록 내부 여부
28
+
29
+ for (const raw of String(text).split(/\r?\n/)) {
30
+ const line = raw.replace(/\r$/, "");
31
+ if (!line.trim() || line.trim().startsWith("#")) continue; // 빈 줄·주석은 블록을 끊지 않음(.sh awk도 동일하게 무해)
32
+
33
+ if (!/^\s/.test(line)) {
34
+ // 최상위 키 라인 — 이전 블록 종료 (.sh awk `/^[^[:space:]]/ { inblk=0 }` 등가)
35
+ current = null; inWfNames = false;
36
+ const m = line.match(/^([A-Za-z_][A-Za-z0-9_.\-]*):(.*)$/);
37
+ if (!m) continue;
38
+ const key = m[1];
39
+ const rest = m[2].trim();
40
+ if (key === "_workflow_names") { inWfNames = true; continue; }
41
+ const entry = fields.get(key) || {};
42
+ if (rest) {
43
+ // 구형 1줄: KEY: "라벨" (.sh 형식1 — label로만 사용)
44
+ const q = rest.match(/^"([^"]*)"\s*$/);
45
+ if (q) entry.label = q[1];
46
+ }
47
+ fields.set(key, entry);
48
+ current = entry;
49
+ continue;
50
+ }
51
+
52
+ // 들여쓰기 라인 — 현재 블록 소속
53
+ const m = line.match(/^\s+([A-Za-z_][A-Za-z0-9_.\-]*):\s*(.*)$/);
54
+ if (!m) continue;
55
+ if (inWfNames) {
56
+ // " KEY: "값"" 형식만 인정 (.sh _wf_load_workflow_names의 `*:\ \"*\"` case 등가)
57
+ const q = m[2].match(/^"(.*)"\s*$/);
58
+ if (q) workflowNames.push({ key: m[1], value: q[1] });
59
+ continue;
60
+ }
61
+ if (current && (m[1] === "label" || m[1] === "help" || m[1] === "example")) {
62
+ // 블록 내 첫 등장만 채택 (.sh awk `print line; exit` — 첫 매치 사용)
63
+ if (current[m[1]] == null) current[m[1]] = unquote(m[2]);
64
+ }
65
+ }
66
+ return { fields, workflowNames };
67
+ }
68
+
69
+ // wizard-prompts.yml을 찾아 읽고 파싱 (.sh _wf_labels_path 등가).
70
+ // 우선순위: 대상 프로젝트(targetRoot) → 다운로드 원본(tempDir) 폴백 → null.
71
+ // WHY 폴백: 신규 통합에서는 copy_config_folder가 configure_workflow_env보다 늦게 실행되어
72
+ // 대상에 아직 파일이 없다 — 폴백 없으면 label/help/example이 전부 빈값(KEY명만 출력)이 된다.
73
+ // fs 주입 가능(테스트용) — 기본 node:fs.
74
+ export function loadWizardPrompts(targetRoot = ".", tempDir = "", fs = nodeFs) {
75
+ const candidates = [join(targetRoot, LABELS_FILE)];
76
+ if (tempDir) candidates.push(join(tempDir, LABELS_FILE));
77
+ for (const p of candidates) {
78
+ if (fs.existsSync(p)) return parseWizardPrompts(fs.readFileSync(p, "utf8"));
79
+ }
80
+ return null;
81
+ }
82
+
83
+ // 필드 조회 (.sh wf_field 등가). field: "label" | "help" | "example".
84
+ // 우선순위: "{type}.KEY" 블록 → "KEY" 블록(구형 1줄 포함) → 폴백(label이면 KEY명, 아니면 "").
85
+ export function wfField(prompts, type, key, field) {
86
+ if (prompts && prompts.fields) {
87
+ for (const q of [`${type}.${key}`, key]) {
88
+ const v = prompts.fields.get(q)?.[field];
89
+ if (v != null && v !== "") return v;
90
+ }
91
+ }
92
+ return field === "label" ? key : "";
93
+ }
94
+
95
+ // 워크플로우 파일명 → 사람이 읽는 짧은 이름 (.sh wf_workflow_name 등가).
96
+ // _workflow_names에서 "키가 파일명에 포함되면" 그 값 사용 — 최장 키 우선(REACT-CI vs REACT-CICD 구분).
97
+ // 미매칭이면 .yaml/.yml 확장자만 제거해 반환 (.sh `${_base%.y*ml}` 등가).
98
+ export function workflowDisplayName(prompts, filename) {
99
+ const base = String(filename).split("/").pop().split("\\").pop(); // 경로 제거 (.sh `${_file##*/}`)
100
+ let best = null; let bestLen = 0;
101
+ for (const { key, value } of prompts?.workflowNames ?? []) {
102
+ if (base.includes(key) && key.length > bestLen) { best = value; bestLen = key.length; }
103
+ }
104
+ if (best != null) return best;
105
+ return base.replace(/\.ya?ml$/, "");
106
+ }
package/src/index.js CHANGED
@@ -2,14 +2,19 @@
2
2
  // 감지 → 다운로드 → 모드 라우팅 → 통합 실행 → 정리. 비대화형(--force) 우선.
3
3
  import { join, dirname } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { readFileSync } from "node:fs";
5
+ import { readFileSync, existsSync } from "node:fs";
6
6
  import { parseArgs, parsePathsCsv, CliError } from "./cli/args.js";
7
7
  import { HELP_TEXT } from "./cli/help.js";
8
8
  import { createContext } from "./context.js";
9
9
  import { PATHS } from "./core/paths.js";
10
10
  import { remove } from "./core/fsutil.js";
11
11
  import { acquireTemplate, readTemplateVersion } from "./core/assets.js";
12
- import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName } from "./core/detect-fs.js";
12
+ import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName, makeResolvers } from "./core/detect-fs.js";
13
+ import { parseExisting } from "./core/version-yml.js";
14
+ import { runBreakingCheck } from "./core/breaking-check.js";
15
+ import { resolveProjectPaths } from "./core/paths-resolve.js";
16
+ import { printBannerCompact } from "./ui/banner.js";
17
+ import { printSummary } from "./ui/summary.js";
13
18
  import { runFull } from "./commands/full.js";
14
19
  import { runVersion } from "./commands/version.js";
15
20
  import { runWorkflows } from "./commands/workflows.js";
@@ -77,40 +82,55 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
77
82
  return 1;
78
83
  }
79
84
 
85
+ // 기존 version.yml 로드 — version/version_code/project_paths 보존의 단일 진실 (.sh L2208~2239 SSoT)
86
+ const vyPath = join(cwd, "version.yml");
87
+ const existing = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")) : null;
88
+
80
89
  // 감지 (CLI 인자 우선, 없으면 자동 감지 — version.yml 우선 규칙은 detectTypes/detectVersion 내부)
81
90
  const types = opts.types.length ? opts.types : detectTypes(cwd);
82
- const version = opts.version || detectVersion(cwd);
91
+ // version: 기존 version.yml 최우선(SSoT — 재실행 시 덮어쓰기 방지) → CLI 지정 → 파일 감지
92
+ const version = (existing?.version) || opts.version || detectVersion(cwd);
93
+ const versionCode = existing?.versionCode ?? 1; // 기존 빌드번호 보존 (.sh L2208~2221)
83
94
  const branch = detectDefaultBranch(cwd);
84
95
  const repoName = detectRepoName(cwd);
85
- const paths = parsePathsCsv(opts.pathsCsv);
86
- // paths 미지정 타입은 루트(".") — basic 제외
87
- for (const t of types) if (t !== "basic" && !paths.has(t)) paths.set(t, ".");
96
+ // 경로 확정 (.sh resolve_project_paths 비대화형 경로 — --paths 우선 → 저장값 → 후보 1개 자동 → 루트 폴백)
97
+ const paths = await resolveProjectPaths({
98
+ root: cwd, types, paths: parsePathsCsv(opts.pathsCsv),
99
+ existingPaths: existing?.paths ?? new Map(), force: true, tty: false, io: {},
100
+ });
88
101
 
89
102
  const { now, today } = clock || utcNow();
90
103
  const tempDir = join(cwd, PATHS.tempDir);
91
104
 
92
105
  const context = createContext({
93
- mode: opts.mode, force: true, types, version, branch,
94
- paths, includeNexus: opts.includeNexus === true, includeSecretBackup: opts.includeSecretBackup === true,
106
+ mode: opts.mode, force: true, types, version, versionCode, branch,
107
+ paths,
108
+ // 옵션 워크플로우: CLI 플래그 최우선 → version.yml 저장 옵션(.sh read_template_options 등가) → false
109
+ includeNexus: opts.includeNexus ?? existing?.options?.nexus ?? false,
110
+ includeSecretBackup: opts.includeSecretBackup ?? existing?.options?.secretBackup ?? false,
95
111
  repoName,
96
- resolvers: {
97
- repo: () => repoName,
98
- "spring-app-yml-dir": () => "",
99
- "spring-app-yml-path": () => "",
100
- "flutter-root": () => paths.get("flutter") || ".",
101
- },
112
+ // 실 resolver 4종 (.sh resolve_token 등가 — spring-app-yml 스텁 제거)
113
+ resolvers: makeResolvers(cwd, repoName, paths),
102
114
  now, today,
103
115
  });
104
116
 
117
+ let result = null;
105
118
  try {
106
119
  acquireTemplate({ tempDir, source });
107
120
  context.templateVersion = readTemplateVersion(tempDir);
108
121
 
122
+ // 비대화형 축약 배너 (#446 확정 — 1줄, 로그 오염 최소)
123
+ printBannerCompact({ version: context.templateVersion, mode: opts.mode });
124
+
125
+ // Breaking Changes 게이트 (.sh execute_integration L4415~4420 등가 — 비대화형은 경고 후 진행)
126
+ const proceed = await runBreakingCheck({ cwd, tempDir, templateVersion: context.templateVersion });
127
+ if (!proceed) return 0;
128
+
109
129
  switch (opts.mode) {
110
- case "full": runFull(context, tempDir, cwd); break;
111
- case "version": runVersion(context, tempDir, cwd); break;
112
- case "workflows": runWorkflows(context, tempDir, cwd); break;
113
- case "issues": runIssues(context, tempDir, cwd); break;
130
+ case "full": result = runFull(context, tempDir, cwd); break;
131
+ case "version": result = runVersion(context, tempDir, cwd); break;
132
+ case "workflows": result = runWorkflows(context, tempDir, cwd); break;
133
+ case "issues": result = runIssues(context, tempDir, cwd); break;
114
134
  default:
115
135
  // 알 수 없는 모드 → .sh와 동일하게 복사 0건, 에러 아님
116
136
  break;
@@ -118,5 +138,11 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
118
138
  } finally {
119
139
  remove(tempDir);
120
140
  }
141
+
142
+ // 완료 요약 (.sh print_summary — CLI 모드에서도 출력)
143
+ printSummary({
144
+ mode: opts.mode, types, version,
145
+ counters: { workflows: result?.workflows?.copied ?? 0, utilModules: 0 },
146
+ }, cwd);
121
147
  return 0;
122
148
  }