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.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  > 이슈 등록부터 커밋, 보고서, 배포까지. 개발자는 코드만 작성하세요.
8
8
 
9
9
  <!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
10
- ## 최신 버전 : v4.0.2 (2026-07-08)
10
+ ## 최신 버전 : v4.0.4 (2026-07-08)
11
11
 
12
12
  [전체 버전 기록 보기](CHANGELOG.md)
13
13
 
@@ -218,8 +218,7 @@ bash <(curl -fsSL "https://raw.githubusercontent.com/Cassiiopeia/projectops/main
218
218
  |------|----------|-------|
219
219
  | `spring` | build.gradle | SSH+Docker 배포, Nexus |
220
220
  | `flutter` | pubspec.yaml | TestFlight, Play Store |
221
- | `react` | package.json | Docker |
222
- | `next` | package.json | Docker |
221
+ | `react` (Next.js 포함) | package.json | Docker |
223
222
  | `node` | package.json | Docker |
224
223
  | `python` | pyproject.toml | SSH+Docker 배포 |
225
224
  | `react-native` | Info.plist + build.gradle | — |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectops",
3
- "version": "4.0.3",
3
+ "version": "4.1.1",
4
4
  "description": "ProjectOps — 완전 자동화 GitHub 프로젝트 관리 템플릿 통합 CLI (구 SUH-DEVOPS-TEMPLATE 마법사)",
5
5
  "keywords": [
6
6
  "devops",
package/src/cli/help.js CHANGED
@@ -8,8 +8,9 @@ export const HELP_TEXT = `projectops — GitHub 프로젝트 자동화 템플릿
8
8
  -m, --mode MODE 통합 모드 (full | version | workflows | issues | skills)
9
9
  기본: interactive (대화형)
10
10
  -t, --type CSV 프로젝트 타입 csv (예: spring,react,python)
11
- 지원: spring flutter next react react-native
11
+ 지원: spring flutter react react-native
12
12
  react-native-expo node python basic
13
+ (next는 react로 흡수됨 — Next.js 프로젝트는 react 사용)
13
14
  --project-version V 통합 대상의 초기 버전 (예: 1.0.0). 미지정 시 자동 감지
14
15
  --paths "t=p,..." 타입별 프로젝트 경로 (모노레포). 예: flutter=app,react=client
15
16
  --nexus / --no-nexus Nexus 라이브러리 publish 워크플로우 포함/제외
@@ -19,7 +19,7 @@ import { ensureGitignore } from "../core/copy/gitignore.js";
19
19
  // context: { version, types, paths:Map, branch, versionCode, includeNexus, includeSecretBackup,
20
20
  // force, repoName, resolvers, now, today }
21
21
  // tempDir: 획득된 템플릿. targetRoot: 통합 대상.
22
- export function runFull(context, tempDir, targetRoot = ".") {
22
+ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
23
23
  const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
24
24
  force = true, now, today, templateVersion = "unknown",
25
25
  includeNexus = false, includeSecretBackup = false } = context;
@@ -29,8 +29,8 @@ export function runFull(context, tempDir, targetRoot = ".") {
29
29
  for (const [t] of paths) pathMarkers.set(t, markerForType(t));
30
30
 
31
31
  // 3. 워크플로우 복사 (+ env 치환) — deploy 블록에 쓸 ask 값을 수집한다.
32
- // (.sh는 copy_workflows update_version_yml_deploy로 deploy 블록을 붙인다.)
33
- const wfCounters = copyWorkflows(context, tempDir, targetRoot);
32
+ // hooks.decisions: 대화형 충돌 3지선 결정 Map (미지정=skip — 현행 force 동작)
33
+ const wfCounters = copyWorkflows(context, tempDir, targetRoot, hooks);
34
34
  const deployValues = wfCounters.deployValues || new Map(); // Map<type, Map<key,value>>
35
35
 
36
36
  // 1. version.yml 생성 (전체 재생성 — metadata → deploy → template 순, .sh 최종형과 동일)
@@ -1,10 +1,19 @@
1
- // 대화형 마법사 (.sh interactive_mode 등가) — template_integrator.sh 4262~4411.
1
+ // 대화형 마법사 (.sh interactive_mode 등가) — template_integrator.sh 4262~4411 + #446 UI 5층.
2
2
  // io 주입으로 테스트 가능. 실제 실행은 src/ui/prompts.js 함수를 io로 넘긴다.
3
+ // 새 시각 층(banner/detectionLog/analysisCard/ideStatus/installKind/summary)과 저수준 엔진(engineIo)은
4
+ // io의 "옵셔널 멤버" — 스텁이 생략하면 해당 층만 건너뛰고 실행 계약은 동일하다.
3
5
  import { join } from "node:path";
6
+ import { existsSync, readFileSync } from "node:fs";
4
7
  import { PATHS } from "../core/paths.js";
5
8
  import { remove } from "../core/fsutil.js";
6
9
  import { acquireTemplate, readTemplateVersion } from "../core/assets.js";
7
- import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName } from "../core/detect-fs.js";
10
+ import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName, makeResolvers } from "../core/detect-fs.js";
11
+ import { parseExisting } from "../core/version-yml.js";
12
+ import { runBreakingCheck } from "../core/breaking-check.js";
13
+ import { resolveProjectPaths } from "../core/paths-resolve.js";
14
+ import { askAllOptionalWorkflows } from "../core/options-ask.js";
15
+ import { promptEnvPlan } from "../ui/env-plan.js";
16
+ import { listWorkflowConflicts } from "../core/copy/workflows.js";
8
17
  import { createContext, VALID_TYPES } from "../context.js";
9
18
  import { runFull } from "./full.js";
10
19
  import { runVersion } from "./version.js";
@@ -14,21 +23,40 @@ import { runSkills } from "./skills.js";
14
23
  import * as prompts from "../ui/prompts.js";
15
24
 
16
25
  const CANCEL = prompts.CANCEL;
26
+ const isCancel = (v) => v === CANCEL || typeof v === "symbol";
17
27
 
18
28
  // io 기본값 = 실제 prompts. 테스트는 스텁 io 주입.
19
29
  // skills = runSkills 주입 지점(테스트가 실제 IDE CLI를 안 건드리게). 기본은 실제 runSkills.
20
30
  export async function runInteractive(baseCtx, { cwd = process.cwd(), source = { type: "git" }, clock, io = prompts, skills = runSkills } = {}) {
21
- io.intro?.("projectops — 대화형 통합 마법사");
22
-
23
- // 1) 모드 선택
24
- const mode = await io.selectMode();
25
- if (mode === CANCEL || mode == null) { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
26
-
27
31
  const tempDir = join(cwd, PATHS.tempDir);
28
32
  try {
33
+ // 템플릿 먼저 획득 — 배너에 실제 템플릿 버전을 표시 (.sh는 원격 version.yml fetch L4270~4280 등가)
29
34
  acquireTemplate({ tempDir, source });
30
35
  const templateVersion = readTemplateVersion(tempDir);
31
36
 
37
+ // 층1 — 시작 배너 (#446 확정 시안 A). 스텁엔 banner 없음 → intro 폴백.
38
+ if (io.banner) io.banner({ version: templateVersion, modeLabel: "대화형 통합 마법사" });
39
+ else io.intro?.("projectops — 대화형 통합 마법사");
40
+
41
+ // 기존 version.yml — version/version_code/paths/옵션 보존의 단일 진실 (.sh SSoT L2208~2239)
42
+ const vyPath = join(cwd, "version.yml");
43
+ const existing = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")) : null;
44
+
45
+ // 층4 — IDE Skills 현재 상태 · 층5 — 신규/업데이트 판별 (#446)
46
+ io.ideStatus?.();
47
+ io.installKind?.({ currentTemplateVersion: existing?.templateVersion || "", templateVersion });
48
+
49
+ // 1) 모드 선택
50
+ const mode = await io.selectMode();
51
+ if (mode === CANCEL || mode == null) { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
52
+
53
+ // Breaking Changes 게이트 (.sh execute_integration L4415~4420 — 모든 모드 공통, 대화형은 확인 질문)
54
+ const proceed = await runBreakingCheck({
55
+ cwd, tempDir, templateVersion,
56
+ askYesNo: (msg, def) => io.askYesNo(msg, def),
57
+ });
58
+ if (!proceed) { io.cancelMessage?.("통합을 안전하게 취소했습니다."); return 0; }
59
+
32
60
  // skills 모드 — IDE 스킬 설치 (템플릿 통합 없음). 대화형으로 실행.
33
61
  if (mode === "skills") {
34
62
  await skills({ templateVersion, tempDir, interactive: true });
@@ -40,68 +68,153 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
40
68
  if (mode === "issues") {
41
69
  const ctx = createContext({ ...baseCtx, mode, force: true });
42
70
  runIssues(ctx, tempDir, cwd);
71
+ io.summary?.({ mode, types: [], version: "", counters: {} }, cwd);
43
72
  io.outro?.("이슈·PR 템플릿을 설치했습니다.");
44
73
  return 0;
45
74
  }
46
75
 
47
- // full/version/workflows — 감지
76
+ // full/version/workflows — 감지 (version은 기존 version.yml 최우선)
48
77
  let types = detectTypes(cwd);
49
- let version = detectVersion(cwd);
78
+ let version = (existing?.version) || detectVersion(cwd);
50
79
  let branch = detectDefaultBranch(cwd);
51
80
  const repoName = detectRepoName(cwd);
52
- let includeNexus = false, includeSecretBackup = false;
81
+ const versionCode = existing?.versionCode ?? 1; // 기존 빌드번호 보존
82
+ // 선택 워크플로우 초기값: version.yml 저장 옵션 (.sh read_template_options L2361 등가)
83
+ let includeNexus = existing?.options?.nexus ?? false;
84
+ let includeSecretBackup = existing?.options?.secretBackup ?? false;
53
85
  const showOptional = mode === "full" || mode === "workflows";
86
+ const realTty = process.stdout.isTTY === true;
87
+
88
+ // 층2 — 감지 로그 (#446)
89
+ io.detectionLog?.({ types, version, branch });
90
+
91
+ // 선택 워크플로우(Nexus/Secret) 질문 (.sh ask_all_optional_workflows L2707 — full/workflows만)
92
+ if (showOptional) {
93
+ const r = await askAllOptionalWorkflows({
94
+ tempDir, types, targetRoot: cwd,
95
+ current: { nexus: existing?.options?.nexus ?? null, secretBackup: existing?.options?.secretBackup ?? null },
96
+ force: false, tty: realTty,
97
+ io: { confirm: ({ message, initialValue }) => io.askYesNo(message, initialValue) },
98
+ });
99
+ includeNexus = r.nexus;
100
+ includeSecretBackup = r.secretBackup;
101
+ }
54
102
 
55
- // 확인/수정 루프
103
+ // 확인/수정 루프 — ESC는 '머무르기' (.sh L1877~1881: 명시적 '아니오'만 종료)
104
+ let paths = new Map();
56
105
  let confirmed = false;
57
106
  while (!confirmed) {
58
- io.note?.(summarize({ mode, types, version, branch, includeNexus, includeSecretBackup, showOptional }), "프로젝트 분석 결과");
107
+ // 층3 프로젝트 분석 개요 카드 (#446). 스텁엔 없음 → note 폴백.
108
+ if (io.analysisCard) {
109
+ io.analysisCard({ mode, modeLabel: modeLabel(mode), types, version, branch, includeNexus, includeSecretBackup, showOptional, paths });
110
+ } else {
111
+ io.note?.(summarize({ mode, types, version, branch, includeNexus, includeSecretBackup, showOptional }), "프로젝트 분석 결과");
112
+ }
59
113
  const choice = await io.confirmProjectMenu();
60
- if (choice === CANCEL || choice === "cancel") { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
114
+ if (choice === "cancel") { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
115
+ if (isCancel(choice) || choice == null) continue; // ESC = 머무르기 (루프 재출력)
61
116
  if (choice === "continue") { confirmed = true; break; }
62
117
  // edit 루프
63
118
  let editing = true;
64
119
  while (editing) {
65
120
  const what = await io.editMenu({ showOptional });
66
- if (what === CANCEL || what === "done") { editing = false; break; }
121
+ if (isCancel(what) || what === "done") { editing = false; break; }
67
122
  if (what === "type") {
68
123
  const t = await io.selectTypes(types);
69
- if (t !== CANCEL && Array.isArray(t) && t.length) types = t;
124
+ if (!isCancel(t) && Array.isArray(t) && t.length) {
125
+ // 타입 집합이 실제로 바뀌면 경로 재해석 대상으로 초기화 (.sh L1984~1992 — 정렬 집합 비교)
126
+ const oldSorted = [...types].sort().join(",");
127
+ types = t.filter((x) => VALID_TYPES.includes(x));
128
+ if ([...types].sort().join(",") !== oldSorted) paths = new Map();
129
+ }
70
130
  } else if (what === "version") {
71
131
  const v = await io.askText("새 버전 (예: 1.0.0)", version);
72
- if (v !== CANCEL) version = v;
132
+ if (!isCancel(v) && v !== version) {
133
+ // semver 형식 검증 (.sh L2010~2015)
134
+ if (/^\d+\.\d+\.\d+$/.test(v)) version = v;
135
+ else io.note?.("버전 형식이 올바르지 않습니다 (x.y.z 형태) — 기존 값을 유지합니다.", "⚠ 버전");
136
+ }
73
137
  } else if (what === "branch") {
74
138
  const b = await io.askText("기본 브랜치", branch);
75
- if (b !== CANCEL) branch = b;
139
+ if (!isCancel(b) && b) branch = b;
76
140
  } else if (what === "nexus") {
77
141
  const y = await io.askYesNo("Nexus publish 워크플로우를 포함할까요?", includeNexus);
78
- if (y !== CANCEL) includeNexus = y;
142
+ if (!isCancel(y)) includeNexus = y === true;
79
143
  } else if (what === "secret") {
80
144
  const y = await io.askYesNo("Secret 백업 워크플로우를 포함할까요?", includeSecretBackup);
81
- if (y !== CANCEL) includeSecretBackup = y;
145
+ if (!isCancel(y)) includeSecretBackup = y === true;
82
146
  }
83
147
  }
84
148
  }
85
149
 
86
- // 경로: 미지정 타입은 루트(basic 제외)
87
- const paths = new Map();
88
- for (const t of types) if (t !== "basic") paths.set(t, ".");
150
+ // 경로 확정 (.sh resolve_project_paths L1362~1589 — full/version만. 저장값·후보 스캔·질문)
151
+ if (mode === "full" || mode === "version") {
152
+ paths = await resolveProjectPaths({
153
+ root: cwd, types, paths, existingPaths: existing?.paths ?? new Map(),
154
+ force: false, tty: realTty, io: io.engineIo ?? {},
155
+ });
156
+ } else {
157
+ for (const t of types) if (t !== "basic" && !paths.has(t)) paths.set(t, existing?.paths.get(t) || ".");
158
+ }
159
+
160
+ // @wizard env 계획 질문 (.sh wf_prompt_env_plan L3220 — full/workflows만)
161
+ const resolvers = makeResolvers(cwd, repoName, paths);
162
+ let envValues = new Map(), envUseDefaults = true;
163
+ if (showOptional) {
164
+ const plan = await promptEnvPlan({
165
+ tempDir, types, io: io.engineIo ?? null, force: false,
166
+ resolvers, includeNexus, targetRoot: cwd, repoName,
167
+ });
168
+ envValues = plan.values;
169
+ envUseDefaults = plan.useDefaults;
170
+ }
89
171
 
90
172
  const { now, today } = clock || utcNow();
91
173
  const ctx = createContext({
92
- mode, force: true, types, version, branch, paths, includeNexus, includeSecretBackup, repoName, templateVersion,
93
- resolvers: {
94
- repo: () => repoName, "spring-app-yml-dir": () => "", "spring-app-yml-path": () => "",
95
- "flutter-root": () => paths.get("flutter") || ".",
96
- },
97
- now, today,
174
+ mode, force: true, types, version, versionCode, branch, paths, includeNexus, includeSecretBackup,
175
+ repoName, templateVersion, resolvers, envValues, envUseDefaults, now, today,
98
176
  });
99
177
  ctx.templateVersion = templateVersion;
100
178
 
101
- if (mode === "full") runFull(ctx, tempDir, cwd);
102
- else if (mode === "version") runVersion(ctx, tempDir, cwd);
103
- else if (mode === "workflows") runWorkflows(ctx, tempDir, cwd);
179
+ // 기존 워크플로우 충돌 3지선 — 타입당 1회 결정을 파일에 캐시 적용 (.sh L3440~3508 UX 등가)
180
+ let hooks = {};
181
+ if (showOptional && io.engineIo?.select) {
182
+ const conflicts = listWorkflowConflicts(ctx, tempDir, cwd);
183
+ if (conflicts.length) {
184
+ const perType = new Map();
185
+ const decisions = new Map();
186
+ for (const { filename, type } of conflicts) {
187
+ if (!perType.has(type)) {
188
+ const sel = await io.engineIo.select({
189
+ message: `기존 워크플로우와 내용이 다른 파일이 있습니다 (${type}) — 어떻게 할까요?`,
190
+ options: [
191
+ { value: "skip", label: "건너뛰기 — 기존 파일 유지 (기본)" },
192
+ { value: "backup", label: ".bak 백업 후 새 버전으로 교체" },
193
+ { value: "template", label: "기존 유지 + 새 버전을 .template.yaml로 참고 추가" },
194
+ ],
195
+ });
196
+ perType.set(type, isCancel(sel) || sel == null ? "skip" : sel); // ESC = 건너뛰기 (.sh L3463)
197
+ }
198
+ decisions.set(filename, perType.get(type));
199
+ }
200
+ hooks = { decisions };
201
+ }
202
+ }
203
+
204
+ let result = null;
205
+ if (mode === "full") result = runFull(ctx, tempDir, cwd, hooks);
206
+ else if (mode === "version") result = runVersion(ctx, tempDir, cwd);
207
+ else if (mode === "workflows") result = runWorkflows(ctx, tempDir, cwd, hooks);
208
+
209
+ // 통합 후 IDE 스킬 제안 (.sh L4557 offer_ide_tools_install — 사전 질문 게이트, 기본 N)
210
+ const wantSkills = await io.askYesNo("AI 에이전트 스킬(Claude·Cursor·Gemini·Codex·PI)도 설치/업데이트할까요?", false);
211
+ if (wantSkills === true) await skills({ templateVersion, tempDir, interactive: true });
104
212
 
213
+ // 완료 요약 (.sh print_summary L5438)
214
+ io.summary?.({
215
+ mode, types, version,
216
+ counters: { workflows: result?.workflows?.copied ?? 0, utilModules: 0 },
217
+ }, cwd);
105
218
  io.outro?.(`통합 완료 — ${mode} 모드로 설치했습니다.`);
106
219
  return 0;
107
220
  } finally {
@@ -8,9 +8,9 @@ import { copyWorkflows } from "../core/copy/workflows.js";
8
8
  import { copyScripts, copyConfigFolder, copySetupGuide } from "../core/copy/simple.js";
9
9
  import { copyUtilModules } from "../core/copy/util.js";
10
10
 
11
- export function runWorkflows(context, tempDir, targetRoot = ".") {
11
+ export function runWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
12
12
  const { types = [], force = true } = context;
13
- const wf = copyWorkflows(context, tempDir, targetRoot);
13
+ const wf = copyWorkflows(context, tempDir, targetRoot, hooks);
14
14
 
15
15
  // update_version_yml_deploy: 기존 version.yml이 있고 ask 값이 있을 때만 deploy 블록 갱신
16
16
  const vy = join(targetRoot, PATHS.versionFile);
package/src/context.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // 마법사 전역 상태를 하나의 객체로 명시화 (bash 전역 변수군 대체)
2
+ // next 타입은 v4.1.0에서 react로 흡수됨 (breaking)
2
3
  export const VALID_TYPES = [
3
- "spring", "flutter", "next", "react",
4
+ "spring", "flutter", "react",
4
5
  "react-native", "react-native-expo", "node", "python", "basic",
5
6
  ];
6
7
 
@@ -0,0 +1,65 @@
1
+ // breaking-changes 확인 흐름 배선 (.sh check_breaking_changes L2506~2616 등가).
2
+ // collectBreaking(순수 비교)은 breaking.js — 이 모듈은 로드(원격→clone본 폴백)·표시·확인 게이트.
3
+ import { readFileSync, existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { collectBreaking } from "./breaking.js";
6
+ import { parseExisting } from "./version-yml.js";
7
+
8
+ const BC_URL = "https://raw.githubusercontent.com/Cassiiopeia/projectops/main/.github/config/breaking-changes.json";
9
+
10
+ // 원격 우선(3초 타임아웃) → clone된 템플릿의 번들본 폴백 → 둘 다 실패 시 null(조용히 스킵 — .sh 등가)
11
+ export async function loadBreakingJson(tempDir) {
12
+ try {
13
+ const res = await fetch(BC_URL, { signal: AbortSignal.timeout(3000) });
14
+ if (res.ok) return await res.json();
15
+ } catch { /* 원격 실패 — 폴백 */ }
16
+ try {
17
+ const p = join(tempDir, ".github", "config", "breaking-changes.json");
18
+ if (existsSync(p)) return JSON.parse(readFileSync(p, "utf8"));
19
+ } catch { /* 폴백 실패 — 스킵 */ }
20
+ return null;
21
+ }
22
+
23
+ // 반환: true=진행, false=사용자 취소.
24
+ // opts:
25
+ // cwd - 통합 대상 루트 (기존 version.yml에서 현재 템플릿 버전 읽음)
26
+ // tempDir - clone된 템플릿 (번들 폴백용)
27
+ // templateVersion - 설치하려는 템플릿 버전 (.sh의 DEFAULT_VERSION 고정 버그를 실버전으로 교정 — 설계 D2)
28
+ // askYesNo - async(message, defaultYes)→bool. null이면 비대화형: 경고만 출력 후 진행
29
+ // loader - 테스트 주입용 (기본 loadBreakingJson)
30
+ export async function runBreakingCheck({ cwd, tempDir, templateVersion, askYesNo = null, loader = loadBreakingJson }) {
31
+ const vy = join(cwd, "version.yml");
32
+ if (!existsSync(vy)) return true; // 신규 통합 — 비교 기준 없음
33
+ const { templateVersion: current } = parseExisting(readFileSync(vy, "utf8"));
34
+ if (!current) return true; // 템플릿 메타 없음(unknown) — .sh 동일하게 스킵
35
+
36
+ const json = await loader(tempDir);
37
+ if (!json) return true;
38
+
39
+ const { critical, warnings } = collectBreaking(json, current, templateVersion);
40
+ if (critical.length === 0 && warnings.length === 0) return true;
41
+
42
+ // 박스 표시 (.sh L2580~2603)
43
+ const e = (s = "") => process.stderr.write(s + "\n");
44
+ e("");
45
+ e("╔══════════════════════════════════════════════════════════════════╗");
46
+ e(`║ ⚠️ BREAKING CHANGES (v${current} → v${templateVersion})`);
47
+ e("╠══════════════════════════════════════════════════════════════════╣");
48
+ for (const c of critical) { e("║"); e(`║ [CRITICAL] ${c.version} - ${c.title || ""}`); e(`║ → ${c.message || ""}`); }
49
+ for (const w of warnings) { e("║"); e(`║ [WARNING] ${w.version} - ${w.title || ""}`); e(`║ → ${w.message || ""}`); }
50
+ e("║");
51
+ e("╚══════════════════════════════════════════════════════════════════╝");
52
+ e("");
53
+
54
+ if (critical.length > 0) {
55
+ if (askYesNo) {
56
+ // 대화형: 명시 확인 없으면 중단 (기본 N — .sh 등가)
57
+ const ok = await askYesNo("위 호환성 변경을 확인했고 계속 진행할까요?", false);
58
+ if (ok !== true) return false;
59
+ } else {
60
+ // 비대화형(--force): 게이트로 CI를 죽이지 않고 경고 후 진행 (.sh와 의도적 차이 — CI 친화)
61
+ e("⚠️ CRITICAL 호환성 변경이 있습니다 — 비대화형 실행이라 계속 진행합니다. 위 내용을 꼭 확인하세요.");
62
+ }
63
+ }
64
+ return true;
65
+ }
@@ -1,17 +1,19 @@
1
1
  // 워크플로우 복사 엔진 (.sh copy_workflows + _copy_workflows_for_type 등가).
2
- // 실측: template_integrator.sh 3398~3815. SP2-B는 --force 경로만 구현.
3
- // (대화형 3지선 메뉴·env 계획 질문은 SP2-C. force/비TTY의 기본 선택은 "건너뛰기"이므로 등가.)
2
+ // 실측: template_integrator.sh 3398~3815.
3
+ // 대화형 3지선(기존 파일 충돌)은 copyWorkflowsInteractive(async)가 결정 Map을 만들어
4
+ // 동기 엔진(copyWorkflows)에 hooks.decisions로 전달한다 — 기존 시그니처·force 동작 무변경.
4
5
  import { join, basename } from "node:path";
5
6
  import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
6
7
  import { PATHS } from "../paths.js";
7
8
  import { exists, copyFileSync, listYamlFiles } from "../fsutil.js";
8
9
  import { isUnchanged, substituteEnv } from "../wizard-env.js";
9
10
 
10
- // 한 파일에 env 치환(기본값)을 적용해 대상 파일을 갱신 (.sh configure_workflow_env 등가).
11
- function configureEnv(targetPath, { type, projectPath = ".", repoName = "", resolvers = {}, collectAsks = null }) {
11
+ // 한 파일에 env 치환을 적용해 대상 파일을 갱신 (.sh configure_workflow_env 등가).
12
+ // values/useDefaults: env 계획(promptEnvPlan) 결과 미지정이면 기본값 경로(현행 force 동작).
13
+ function configureEnv(targetPath, { type, projectPath = ".", repoName = "", resolvers = {}, collectAsks = null, values = new Map(), useDefaults = true }) {
12
14
  const content = readFileSync(targetPath, "utf8");
13
15
  if (!content.includes("@wizard")) return;
14
- const out = substituteEnv(content, { type, useDefaults: true, projectPath, repoName, resolvers, collectAsks });
16
+ const out = substituteEnv(content, { type, useDefaults, values, projectPath, repoName, resolvers, collectAsks });
15
17
  writeFileSync(targetPath, out);
16
18
  }
17
19
 
@@ -33,12 +35,15 @@ function classify(srcDir, workflowsDir, envOpts) {
33
35
  return result;
34
36
  }
35
37
 
36
- // copy_workflows 본체.
37
- // context: { types:[], paths:Map, includeNexus, includeSecretBackup, force, repoName, resolvers }
38
- // tempDir: 다운로드 원본. targetRoot: 통합 대상 루트.
38
+ // copy_workflows 본체 (동기 — 기존 호출부 무변경).
39
+ // context: { types:[], paths:Map, includeNexus, includeSecretBackup, force, repoName, resolvers,
40
+ // envValues?:Map<key,value>, envUseDefaults?:boolean } ← env 계획(promptEnvPlan) 결과 주입점
41
+ // hooks: { decisions?: Map<filename, 'skip'|'backup'|'template'> } — 기존 파일(changed) 충돌 결정.
42
+ // 미지정 파일은 'skip'(현행 force 동작 100% 유지). 대화형 수집은 copyWorkflowsInteractive 참조.
39
43
  // 반환: {copied, skipped, templateAdded, optionalCopied}
40
- export function copyWorkflows(context, tempDir, targetRoot = ".") {
41
- const { types = [], paths = new Map(), includeNexus = false, includeSecretBackup = false, repoName = "", resolvers = {} } = context;
44
+ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
45
+ const { types = [], paths = new Map(), includeNexus = false, includeSecretBackup = false, repoName = "", resolvers = {}, envValues = new Map(), envUseDefaults = true } = context;
46
+ const decisions = hooks.decisions instanceof Map ? hooks.decisions : new Map();
42
47
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
43
48
  const projectTypesDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
44
49
  if (!exists(projectTypesDir)) throw new Error("템플릿 저장소 구조 오류 — project-types 폴더를 찾지 못했습니다.");
@@ -46,7 +51,8 @@ export function copyWorkflows(context, tempDir, targetRoot = ".") {
46
51
  const counters = { copied: 0, skipped: 0, templateAdded: 0, optionalCopied: 0 };
47
52
  const deployValues = new Map(); // Map<type, Map<key,value>> — deploy 블록용 ask 값
48
53
  counters.deployValues = deployValues;
49
- const envOptsFor = (type) => ({ type, projectPath: paths.get(type) || ".", repoName, resolvers });
54
+ // values/useDefaults는 치환 경로에서만 의미 (isUnchanged는 내부에서 useDefaults:true 강제 가상 비교 무손상)
55
+ const envOptsFor = (type) => ({ type, projectPath: paths.get(type) || ".", repoName, resolvers, values: envValues, useDefaults: envUseDefaults });
50
56
 
51
57
  // (1) common — unchanged면 스킵, 아니면 무조건 덮어쓰기
52
58
  const commonDir = join(projectTypesDir, "common");
@@ -66,7 +72,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".") {
66
72
  // (2~4) 타입별
67
73
  for (const type of types) {
68
74
  const asks = new Map();
69
- copyWorkflowsForType(type, projectTypesDir, workflowsDir, { includeNexus, ...context, envOptsFor, collectAsks: asks }, counters);
75
+ copyWorkflowsForType(type, projectTypesDir, workflowsDir, { includeNexus, ...context, envOptsFor, collectAsks: asks, decisions }, counters);
70
76
  if (asks.size) deployValues.set(type, asks);
71
77
  }
72
78
 
@@ -85,8 +91,66 @@ export function copyWorkflows(context, tempDir, targetRoot = ".") {
85
91
  return counters;
86
92
  }
87
93
 
94
+ // changed(기존에 있고 내용이 바뀐) 파일 1개를 결정에 따라 처리 (.sh 3440~3508 3지선 case 등가).
95
+ // 'skip'(기본): 기존 유지. 'backup': 기존→.bak 후 교체. 'template': 기존 유지 + 새 버전을 .template.yaml로.
96
+ function applyDecision(decision, srcDir, workflowsDir, filename, counters) {
97
+ const src = join(srcDir, filename);
98
+ const dst = join(workflowsDir, filename);
99
+ if (decision === "backup") {
100
+ // .sh O) mv → cp: 기존을 .bak으로 백업 후 새 버전으로 교체
101
+ renameSync(dst, dst + ".bak");
102
+ copyFileSync(src, dst);
103
+ counters.copied++;
104
+ return;
105
+ }
106
+ if (decision === "template") {
107
+ // .sh T) `${filename%.yaml}.template.yaml` — .yaml만 strip (.yml은 그대로 뒤에 붙음, .sh 동일)
108
+ const templateName = (filename.endsWith(".yaml") ? filename.slice(0, -".yaml".length) : filename) + ".template.yaml";
109
+ copyFileSync(src, join(workflowsDir, templateName)); // cp가 기존 .template.yaml 덮어씀(.sh rm -f + cp 등가)
110
+ counters.templateAdded++;
111
+ return;
112
+ }
113
+ counters.skipped++; // 'skip'/미지정/ESC → 기존 유지 (.sh S)·force 기본)
114
+ }
115
+
116
+ // 대상 워크플로우 디렉토리에서 changed(충돌) 파일 목록만 뽑는다 — copyWorkflowsInteractive의 사전 조사용.
117
+ // copyWorkflows 본체와 동일한 classify 기준을 써야 결정 Map이 실제 처리 대상과 1:1로 맞는다.
118
+ export function listWorkflowConflicts(context, tempDir, targetRoot = ".") {
119
+ const { types = [], paths = new Map(), includeNexus = false, repoName = "", resolvers = {} } = context;
120
+ const workflowsDir = join(targetRoot, PATHS.workflowsDir);
121
+ const projectTypesDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
122
+ const conflicts = []; // [{ filename, type }] — 엔진 처리 순서와 동일 (타입 순회 → 직하위 → server-deploy)
123
+ for (const type of types) {
124
+ const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers };
125
+ const typeDir = join(projectTypesDir, type);
126
+ if (exists(typeDir)) {
127
+ for (const f of classify(typeDir, workflowsDir, envOpts).changed) conflicts.push({ filename: f, type });
128
+ }
129
+ const serverDeployDir = join(typeDir, "server-deploy");
130
+ if (exists(serverDeployDir) && !includeNexus) {
131
+ for (const f of classify(serverDeployDir, workflowsDir, envOpts).changed) conflicts.push({ filename: f, type });
132
+ }
133
+ }
134
+ return conflicts;
135
+ }
136
+
137
+ // 대화형 진입점 (async) — 충돌마다 onConflict(filename, type)를 await해 결정 Map을 만든 뒤
138
+ // 동기 엔진에 위임한다. WHY 분리: copyWorkflows를 async로 바꾸면 await 없이 호출하는
139
+ // 기존 호출부(runFull/runWorkflows)가 깨진다 — 시그니처 무변경 원칙.
140
+ // onConflict 반환값: 'template' | 'skip' | 'backup' (그 외/미지정 → 'skip').
141
+ export async function copyWorkflowsInteractive(context, tempDir, targetRoot = ".", { onConflict } = {}) {
142
+ const decisions = new Map();
143
+ if (typeof onConflict === "function") {
144
+ for (const { filename, type } of listWorkflowConflicts(context, tempDir, targetRoot)) {
145
+ if (decisions.has(filename)) continue; // 파일명은 PROJECT-{TYPE}- prefix로 타입 간 유일
146
+ decisions.set(filename, await onConflict(filename, type));
147
+ }
148
+ }
149
+ return copyWorkflows(context, tempDir, targetRoot, { decisions });
150
+ }
151
+
88
152
  function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters) {
89
- const { includeNexus, force = false, paths = new Map(), repoName = "", resolvers = {}, envOptsFor, collectAsks = null } = ctx;
153
+ const { includeNexus, force = false, paths = new Map(), repoName = "", resolvers = {}, envOptsFor, collectAsks = null, decisions = new Map() } = ctx;
90
154
  const typeDir = join(projectTypesDir, type);
91
155
  const envOpts = envOptsFor(type);
92
156
  let unchangedNames = [];
@@ -97,9 +161,8 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
97
161
  unchangedNames = unchanged.slice();
98
162
  for (const f of unchanged) counters.skipped++;
99
163
  for (const f of newFiles) { copyFileSync(join(typeDir, f), join(workflowsDir, f)); counters.copied++; }
100
- // changed: force/비TTY 기본은 "건너뛰기"(기존 유지)
101
- if (!force) { /* 대화형 메뉴는 SP2-C */ }
102
- for (const f of changed) counters.skipped++; // force 경로 = skip
164
+ // changed: 결정 Map에 따라 처리 (미지정=skip → 현행 force 동작과 동일)
165
+ for (const f of changed) applyDecision(decisions.get(f), typeDir, workflowsDir, f, counters);
103
166
  }
104
167
 
105
168
  // server-deploy
@@ -111,7 +174,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
111
174
  const { newFiles, unchanged, changed } = classify(serverDeployDir, workflowsDir, envOpts);
112
175
  for (const f of unchanged) counters.skipped++;
113
176
  for (const f of newFiles) { copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; }
114
- for (const f of changed) counters.skipped++; // force = skip
177
+ for (const f of changed) applyDecision(decisions.get(f), serverDeployDir, workflowsDir, f, counters);
115
178
  }
116
179
  }
117
180
 
@@ -139,7 +202,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
139
202
  const target = join(workflowsDir, filename);
140
203
  if (!existsSync(target)) continue; // 건너뛴 파일 제외
141
204
  if (unchangedNames.includes(filename)) continue; // unchanged 제외
142
- configureEnv(target, { type, projectPath: paths.get(type) || ".", repoName, resolvers, collectAsks });
205
+ configureEnv(target, { ...envOpts, collectAsks }); // env 계획 values/useDefaults 포함
143
206
  }
144
207
  }
145
208
  }
@@ -1,6 +1,6 @@
1
1
  // 실 파일시스템 프로젝트 감지 (.sh detect_* 실행부 등가).
2
2
  // SP2-A detect.js 순수 함수를 fs/git으로 구동한다.
3
- import { existsSync, readFileSync } from "node:fs";
3
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
4
4
  import { join, basename } from "node:path";
5
5
  import { execFileSync } from "node:child_process";
6
6
  import { detectTypesFromMarkers, detectVersionFromFiles } from "./detect.js";
@@ -62,3 +62,45 @@ function hasCommand(cmd) {
62
62
  return true;
63
63
  } catch { return false; }
64
64
  }
65
+
66
+ // Spring application*.yml 탐색 (.sh resolve_spring_app_yml_dir/path L2767~2780 등가)
67
+ // find {base} -path "*/src/main/resources/application*.yml" | head -1 의 fs 재귀 구현.
68
+ // 반환: root 기준 상대경로 (예: "server/src/main/resources/application.yml") 또는 "".
69
+ export function findSpringAppYml(root, base = ".") {
70
+ const startRel = base === "." ? "" : base;
71
+ const PRUNE = new Set(["node_modules", ".git", "build", ".gradle", "target", ".idea"]);
72
+ let hit = "";
73
+ const walk = (rel, depth) => {
74
+ if (hit || depth > 8) return; // head -1 등가 — 첫 매치에서 중단
75
+ let entries;
76
+ try { entries = readdirSync(join(root, rel), { withFileTypes: true }); } catch { return; }
77
+ // 정렬로 순회 순서 결정화 (find 순서 플랫폼 편차 제거)
78
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
79
+ if (hit) return;
80
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
81
+ if (e.isDirectory()) {
82
+ if (PRUNE.has(e.name)) continue;
83
+ walk(childRel, depth + 1);
84
+ } else if (/^application.*\.yml$/.test(e.name) && childRel.includes("src/main/resources/")) {
85
+ hit = childRel;
86
+ }
87
+ }
88
+ };
89
+ walk(startRel, 0);
90
+ return hit;
91
+ }
92
+
93
+ // 실 resolver 세트 생성 (.sh resolve_token 4종 등가) — index/interactive 공용.
94
+ // paths: Map<type, path> (모노레포 경로).
95
+ export function makeResolvers(root, repoName, paths) {
96
+ const springBase = (t) => paths.get(t || "spring") || paths.get("spring") || ".";
97
+ return {
98
+ repo: () => repoName,
99
+ "spring-app-yml-dir": (t) => {
100
+ const f = findSpringAppYml(root, springBase(t));
101
+ return f ? f.split("/").slice(0, -1).join("/") : "";
102
+ },
103
+ "spring-app-yml-path": (t) => findSpringAppYml(root, springBase(t)) || "",
104
+ "flutter-root": () => paths.get("flutter") || ".",
105
+ };
106
+ }
@@ -6,8 +6,8 @@ export function classifyPackageText(raw) {
6
6
  if (s.includes("@react-native") || s.includes("react-native")) {
7
7
  return s.includes("expo") ? "react-native-expo" : "react-native";
8
8
  }
9
- if (s.includes('"next"')) return "next";
10
- if (s.includes('"react"')) return "react";
9
+ // next 타입은 v4.1.0에서 react로 흡수 — "next" 의존성이 있어도 react로 판정
10
+ if (s.includes('"react"') || s.includes('"next"')) return "react";
11
11
  return "node";
12
12
  }
13
13