project-auto-wizard 0.1.5

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 (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/bin/project-auto-wizard.js +19 -0
  4. package/package.json +36 -0
  5. package/payload/coderabbit.yaml +19 -0
  6. package/payload/config/breaking-changes.json +1 -0
  7. package/payload/config/wizard-prompts.yml +62 -0
  8. package/payload/scripts/changelog_manager.py +726 -0
  9. package/payload/scripts/version_manager.py +617 -0
  10. package/payload/version.yml.template +48 -0
  11. package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +303 -0
  12. package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +293 -0
  13. package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +289 -0
  14. package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +192 -0
  15. package/payload/workflows/common/secret-backup/PROJECT-COMMON-SECRET-FILE-UPLOAD.yaml +209 -0
  16. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +591 -0
  17. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +700 -0
  18. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +308 -0
  19. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +992 -0
  20. package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +689 -0
  21. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +987 -0
  22. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +471 -0
  23. package/payload/workflows/flutter/PROJECT-FLUTTER-SUH-LAB-APP-BUILD-TRIGGER.yaml +500 -0
  24. package/payload/workflows/next/PROJECT-NEXT-CI.yaml +185 -0
  25. package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +271 -0
  26. package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +81 -0
  27. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +2191 -0
  28. package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +386 -0
  29. package/payload/workflows/react/PROJECT-REACT-CI.yaml +194 -0
  30. package/payload/workflows/react/PROJECT-REACT-CICD.yaml +255 -0
  31. package/payload/workflows/spring/PROJECT-SPRING-GITHUB-PACKAGES-PUBLISH.yml +84 -0
  32. package/payload/workflows/spring/nexus/PROJECT-SPRING-NEXUS-CI.yml +316 -0
  33. package/payload/workflows/spring/nexus/PROJECT-SPRING-NEXUS-PUBLISH.yml +58 -0
  34. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +535 -0
  35. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +437 -0
  36. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +2277 -0
  37. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +425 -0
  38. package/src/cli/args.js +95 -0
  39. package/src/cli/help.js +27 -0
  40. package/src/commands/full.js +53 -0
  41. package/src/commands/interactive.js +276 -0
  42. package/src/commands/revert.js +60 -0
  43. package/src/commands/version.js +31 -0
  44. package/src/commands/workflows.js +49 -0
  45. package/src/context.js +26 -0
  46. package/src/core/assets.js +47 -0
  47. package/src/core/branches.js +60 -0
  48. package/src/core/branding.js +15 -0
  49. package/src/core/breaking-check.js +65 -0
  50. package/src/core/breaking.js +26 -0
  51. package/src/core/copy/coderabbit.js +26 -0
  52. package/src/core/copy/gitignore.js +55 -0
  53. package/src/core/copy/readme.js +30 -0
  54. package/src/core/copy/simple.js +23 -0
  55. package/src/core/copy/workflows.js +233 -0
  56. package/src/core/detect-fs.js +106 -0
  57. package/src/core/detect.js +62 -0
  58. package/src/core/fsutil.js +46 -0
  59. package/src/core/options-ask.js +99 -0
  60. package/src/core/paths-resolve.js +261 -0
  61. package/src/core/paths.js +16 -0
  62. package/src/core/version-yml.js +190 -0
  63. package/src/core/wizard-env.js +101 -0
  64. package/src/core/wizard-labels.js +107 -0
  65. package/src/index.js +162 -0
  66. package/src/ui/ansi.js +26 -0
  67. package/src/ui/banner.js +29 -0
  68. package/src/ui/env-plan.js +207 -0
  69. package/src/ui/prompts.js +99 -0
  70. package/src/ui/readline-engine.js +257 -0
  71. package/src/ui/status-cards.js +56 -0
  72. package/src/ui/summary.js +127 -0
@@ -0,0 +1,276 @@
1
+ // 대화형 마법사 (.sh interactive_mode 등가) + #446 UI 층.
2
+ // io 주입으로 테스트 가능. 실제 실행은 src/ui/prompts.js 함수를 io로 넘긴다.
3
+ // 새 시각 층(banner/detectionLog/analysisCard/installKind/summary)과 저수준 엔진(engineIo)은
4
+ // io의 "옵셔널 멤버" — 스텁이 생략하면 해당 층만 건너뛰고 실행 계약은 동일하다.
5
+ import { join } from "node:path";
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { resolvePayloadRoot, assertPayload, readTemplateVersion } from "../core/assets.js";
8
+ import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName, makeResolvers } from "../core/detect-fs.js";
9
+ import { parseExisting } from "../core/version-yml.js";
10
+ import { runBreakingCheck } from "../core/breaking-check.js";
11
+ import { resolveProjectPaths } from "../core/paths-resolve.js";
12
+ import { resolveBranchConfig, detectRemoteBranches, ensureDevelopBranch } from "../core/branches.js";
13
+ import { askAllOptionalWorkflows } from "../core/options-ask.js";
14
+ import { promptEnvPlan } from "../ui/env-plan.js";
15
+ import { listWorkflowConflicts } from "../core/copy/workflows.js";
16
+ import { createContext, VALID_TYPES } from "../context.js";
17
+ import { runFull } from "./full.js";
18
+ import { runVersion } from "./version.js";
19
+ import { runWorkflows } from "./workflows.js";
20
+ import { runRevert } from "./revert.js";
21
+ import * as prompts from "../ui/prompts.js";
22
+
23
+ const CANCEL = prompts.CANCEL;
24
+ const isCancel = (v) => v === CANCEL || typeof v === "symbol";
25
+
26
+ // io 기본값 = 실제 prompts. 테스트는 스텁 io 주입.
27
+ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot, clock, io = prompts } = {}) {
28
+ const payload = assertPayload(payloadRoot ?? resolvePayloadRoot());
29
+ const templateVersion = readTemplateVersion();
30
+
31
+ // 층1 — 시작 배너 (#446 확정 시안 A). 스텁엔 banner 없음 → intro 폴백.
32
+ if (io.banner) io.banner({ version: templateVersion, modeLabel: "대화형 통합 마법사" });
33
+ else io.intro?.("project-auto-wizard — 대화형 통합 마법사");
34
+
35
+ // 기존 version.yml — version/version_code/paths/옵션 보존의 단일 진실 (.sh SSoT L2208~2239)
36
+ const vyPath = join(cwd, "version.yml");
37
+ const existing = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")) : null;
38
+
39
+ // 층5 — 신규/업데이트 판별 (#446)
40
+ io.installKind?.({ currentTemplateVersion: existing?.templateVersion || "", templateVersion });
41
+
42
+ // 1) 모드 선택
43
+ const mode = await io.selectMode();
44
+ if (mode === CANCEL || mode == null) { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
45
+
46
+ // revert 모드 — 확인 질문(기본 아니오) 후 payload 유래 파일 제거. 감지·breaking 게이트 불필요.
47
+ if (mode === "revert") {
48
+ const ok = await io.askYesNo("마법사가 설치한 워크플로우·스크립트를 제거할까요? (version.yml·README는 보존)", false);
49
+ if (ok !== true) { io.cancelMessage?.("되돌리기를 취소했습니다."); return 0; }
50
+ const r = runRevert({}, payload, cwd);
51
+ io.note?.(`워크플로우 ${r.workflows.length}개, 스크립트 ${r.scripts.length}개 제거${r.coderabbit ? " + .coderabbit.yaml" : ""}`, "되돌리기 완료");
52
+ io.outro?.("되돌리기를 마쳤습니다.");
53
+ return 0;
54
+ }
55
+
56
+ // Breaking Changes 게이트 (.sh execute_integration L4415~4420 — 모든 모드 공통, 대화형은 확인 질문)
57
+ const proceed = await runBreakingCheck({
58
+ cwd, payloadRoot: payload, templateVersion,
59
+ askYesNo: (msg, def) => io.askYesNo(msg, def),
60
+ });
61
+ if (!proceed) { io.cancelMessage?.("통합을 안전하게 취소했습니다."); return 0; }
62
+
63
+ // full/version/workflows — 감지 (version은 기존 version.yml 최우선)
64
+ let types = detectTypes(cwd);
65
+ let version = (existing?.version) || detectVersion(cwd);
66
+ let branch = detectDefaultBranch(cwd);
67
+ const repoName = detectRepoName(cwd);
68
+ const versionCode = existing?.versionCode ?? 1; // 기존 빌드번호 보존
69
+ // 선택 워크플로우 초기값: version.yml 저장 옵션 (.sh read_template_options L2361 등가)
70
+ let includeNexus = existing?.options?.nexus ?? false;
71
+ let includeSecretBackup = existing?.options?.secretBackup ?? false;
72
+ let includeCodeRabbit = existing?.options?.coderabbit ?? null;
73
+ const showOptional = mode === "full" || mode === "workflows";
74
+ const realTty = process.stdout.isTTY === true;
75
+
76
+ // 층2 — 감지 로그 (#446)
77
+ io.detectionLog?.({ types, version, branch });
78
+
79
+ // 선택 워크플로우(Nexus/Secret) 질문 (.sh ask_all_optional_workflows L2707 — full/workflows만)
80
+ if (showOptional) {
81
+ const r = await askAllOptionalWorkflows({
82
+ payloadRoot: payload, types, targetRoot: cwd,
83
+ current: { nexus: existing?.options?.nexus ?? null, secretBackup: existing?.options?.secretBackup ?? null },
84
+ force: false, tty: realTty,
85
+ io: { confirm: ({ message, initialValue }) => io.askYesNo(message, initialValue) },
86
+ });
87
+ includeNexus = r.nexus;
88
+ includeSecretBackup = r.secretBackup;
89
+
90
+ // 신규 질문 ② — CodeRabbit opt-in (DESIGN-SPEC §4, 기본 아니오). 저장값 있으면 재질문 생략.
91
+ if (includeCodeRabbit === null) {
92
+ const y = await io.askYesNo("CodeRabbit을 사용합니까? (PR AI 리뷰·요약을 릴리스 노트 1순위로 사용)", false);
93
+ includeCodeRabbit = y === true;
94
+ }
95
+ }
96
+ includeCodeRabbit = includeCodeRabbit === true;
97
+
98
+ // 확인/수정 루프 — ESC는 '머무르기' (.sh L1877~1881: 명시적 '아니오'만 종료)
99
+ let paths = new Map();
100
+ let confirmed = false;
101
+ while (!confirmed) {
102
+ // 층3 — 프로젝트 분석 개요 카드 (#446). 스텁엔 없음 → note 폴백.
103
+ if (io.analysisCard) {
104
+ io.analysisCard({ mode, modeLabel: modeLabel(mode), types, version, branch, includeNexus, includeSecretBackup, showOptional, paths });
105
+ } else {
106
+ io.note?.(summarize({ mode, types, version, branch, includeNexus, includeSecretBackup, showOptional }), "프로젝트 분석 결과");
107
+ }
108
+ const choice = await io.confirmProjectMenu();
109
+ if (choice === "cancel") { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
110
+ if (isCancel(choice) || choice == null) continue; // ESC = 머무르기 (루프 재출력)
111
+ if (choice === "continue") { confirmed = true; break; }
112
+ // edit 루프
113
+ let editing = true;
114
+ while (editing) {
115
+ const what = await io.editMenu({ showOptional });
116
+ if (isCancel(what) || what === "done") { editing = false; break; }
117
+ if (what === "type") {
118
+ const t = await io.selectTypes(types);
119
+ if (!isCancel(t) && Array.isArray(t) && t.length) {
120
+ // 타입 집합이 실제로 바뀌면 경로 재해석 대상으로 초기화 (.sh L1984~1992 — 정렬 집합 비교)
121
+ const oldSorted = [...types].sort().join(",");
122
+ types = t.filter((x) => VALID_TYPES.includes(x));
123
+ if ([...types].sort().join(",") !== oldSorted) paths = new Map();
124
+ }
125
+ } else if (what === "version") {
126
+ const v = await io.askText("새 버전 (예: 1.0.0)", version);
127
+ if (!isCancel(v) && v !== version) {
128
+ // semver 형식 검증 (.sh L2010~2015)
129
+ if (/^\d+\.\d+\.\d+$/.test(v)) version = v;
130
+ else io.note?.("버전 형식이 올바르지 않습니다 (x.y.z 형태) — 기존 값을 유지합니다.", "⚠ 버전");
131
+ }
132
+ } else if (what === "branch") {
133
+ const b = await io.askText("기본 브랜치", branch);
134
+ if (!isCancel(b) && b) branch = b;
135
+ } else if (what === "nexus") {
136
+ const y = await io.askYesNo("Nexus publish 워크플로우를 포함할까요?", includeNexus);
137
+ if (!isCancel(y)) includeNexus = y === true;
138
+ } else if (what === "secret") {
139
+ const y = await io.askYesNo("Secret 백업 워크플로우를 포함할까요?", includeSecretBackup);
140
+ if (!isCancel(y)) includeSecretBackup = y === true;
141
+ }
142
+ }
143
+ }
144
+
145
+ // 신규 질문 ① — 브랜치 설정 (DESIGN-SPEC §4). full/workflows만 질문, version은 기본값 기록.
146
+ // 저장값(version.yml metadata.template.branches)이 있으면 재질문 없이 재사용 (업데이트 모드).
147
+ let branches = existing?.branches
148
+ ? resolveBranchConfig({ mainBranch: existing.branches.main, developBranch: existing.branches.develop, defaultBranch: branch })
149
+ : resolveBranchConfig({ defaultBranch: branch });
150
+ if (showOptional && !existing?.branches) {
151
+ const remoteBranches = await detectRemoteBranches(cwd);
152
+ const mainB = await pickBranch(io, `릴리스 브랜치를 선택하세요 (기본: ${branch})`, branch, remoteBranches, isCancel);
153
+ const devB = await pickBranch(io, "개발 브랜치를 선택하세요 (기본: develop)", "develop", remoteBranches, isCancel);
154
+ branches = resolveBranchConfig({ mainBranch: mainB, developBranch: devB, defaultBranch: branch });
155
+ if (branches.mode === "trunk-based") {
156
+ io.note?.("릴리스 브랜치 = 개발 브랜치 → trunk-based 모드로 설치합니다 (RELEASE-PUBLISH 단독).", "브랜치 모드");
157
+ } else if (remoteBranches.length && !remoteBranches.includes(branches.develop)) {
158
+ await ensureDevelopBranch({
159
+ develop: branches.develop, remoteBranches, cwd,
160
+ confirm: (msg) => io.askYesNo(msg, true),
161
+ log: (m) => io.note?.(m, "브랜치"),
162
+ });
163
+ }
164
+ }
165
+
166
+ // 경로 확정 (.sh resolve_project_paths L1362~1589 — full/version만. 저장값·후보 스캔·질문)
167
+ if (mode === "full" || mode === "version") {
168
+ paths = await resolveProjectPaths({
169
+ root: cwd, types, paths, existingPaths: existing?.paths ?? new Map(),
170
+ force: false, tty: realTty, io: io.engineIo ?? {},
171
+ });
172
+ } else {
173
+ for (const t of types) if (t !== "basic" && !paths.has(t)) paths.set(t, existing?.paths.get(t) || ".");
174
+ }
175
+
176
+ // @wizard env 계획 질문 (.sh wf_prompt_env_plan L3220 — full/workflows만)
177
+ const resolvers = makeResolvers(cwd, repoName, paths);
178
+ let envValues = new Map(), envUseDefaults = true;
179
+ if (showOptional) {
180
+ const plan = await promptEnvPlan({
181
+ payloadRoot: payload, types, io: io.engineIo ?? null, force: false,
182
+ resolvers, includeNexus, targetRoot: cwd, repoName,
183
+ });
184
+ envValues = plan.values;
185
+ envUseDefaults = plan.useDefaults;
186
+ }
187
+
188
+ const { now, today } = clock || utcNow();
189
+ const ctx = createContext({
190
+ mode, force: true, types, version, versionCode, branch, branches, paths,
191
+ includeNexus, includeSecretBackup, includeCodeRabbit,
192
+ repoName, templateVersion, resolvers, envValues, envUseDefaults, now, today,
193
+ });
194
+ ctx.templateVersion = templateVersion;
195
+
196
+ // 기존 워크플로우 충돌 3지선 — 타입당 1회 결정을 파일에 캐시 적용 (.sh L3440~3508 UX 등가)
197
+ let hooks = {};
198
+ if (showOptional && io.engineIo?.select) {
199
+ const conflicts = listWorkflowConflicts(ctx, payload, cwd);
200
+ if (conflicts.length) {
201
+ const perType = new Map();
202
+ const decisions = new Map();
203
+ for (const { filename, type } of conflicts) {
204
+ if (!perType.has(type)) {
205
+ const sel = await io.engineIo.select({
206
+ message: `기존 워크플로우와 내용이 다른 파일이 있습니다 (${type}) — 어떻게 할까요?`,
207
+ options: [
208
+ { value: "skip", label: "건너뛰기 — 기존 파일 유지 (기본)" },
209
+ { value: "backup", label: ".bak 백업 후 새 버전으로 교체" },
210
+ { value: "template", label: "기존 유지 + 새 버전을 .template.yaml로 참고 추가" },
211
+ ],
212
+ });
213
+ perType.set(type, isCancel(sel) || sel == null ? "skip" : sel); // ESC = 건너뛰기 (.sh L3463)
214
+ }
215
+ decisions.set(filename, perType.get(type));
216
+ }
217
+ hooks = { decisions };
218
+ }
219
+ }
220
+
221
+ let result = null;
222
+ if (mode === "full") result = runFull(ctx, payload, cwd, hooks);
223
+ else if (mode === "version") result = runVersion(ctx, payload, cwd);
224
+ else if (mode === "workflows") result = runWorkflows(ctx, payload, cwd, hooks);
225
+
226
+ // 완료 요약 (.sh print_summary L5438)
227
+ io.summary?.({
228
+ mode, types, version, branches, includeCodeRabbit,
229
+ counters: { workflows: result?.workflows?.copied ?? 0 },
230
+ }, cwd);
231
+ io.outro?.(`통합 완료 — ${mode} 모드로 설치했습니다.`);
232
+ return 0;
233
+ }
234
+
235
+ // 브랜치 선택 — 원격 목록이 있으면 select(+직접 입력), 없으면 텍스트 입력. ESC/빈값 = 기본값.
236
+ async function pickBranch(io, message, def, remoteBranches, isCancel) {
237
+ if (io.engineIo?.select && remoteBranches.length) {
238
+ const options = [];
239
+ if (!remoteBranches.includes(def)) options.push({ value: def, label: `${def} (기본값 — 없으면 새로 생성)` });
240
+ for (const b of remoteBranches) options.push({ value: b, label: b === def ? `${b} (기본값)` : b });
241
+ options.push({ value: "__custom__", label: "직접 입력..." });
242
+ const sel = await io.engineIo.select({ message, options });
243
+ if (sel === "__custom__") {
244
+ const v = await io.askText("브랜치 이름", def);
245
+ return isCancel(v) || !v ? def : v;
246
+ }
247
+ return isCancel(sel) || sel == null ? def : sel;
248
+ }
249
+ const v = await io.askText(message, def);
250
+ return isCancel(v) || !v ? def : v;
251
+ }
252
+
253
+ function summarize({ mode, types, version, branch, includeNexus, includeSecretBackup, showOptional }) {
254
+ const lines = [
255
+ `통합 모드 : ${modeLabel(mode)}`,
256
+ `프로젝트 타입 : ${types.join(", ")}${types.length > 1 ? " (멀티)" : ""}`,
257
+ `버전 : ${version}`,
258
+ `기본 브랜치 : ${branch}`,
259
+ ];
260
+ if (showOptional) {
261
+ lines.push(`Nexus publish : ${includeNexus ? "포함" : "제외"}`);
262
+ lines.push(`Secret 백업 : ${includeSecretBackup ? "포함" : "제외"}`);
263
+ }
264
+ return lines.join("\n");
265
+ }
266
+
267
+ function modeLabel(m) {
268
+ return { full: "전체 설치", version: "버전 관리만", workflows: "워크플로우만" }[m] || m;
269
+ }
270
+
271
+ function utcNow(date = new Date()) {
272
+ const p = (n) => String(n).padStart(2, "0");
273
+ const d = `${date.getUTCFullYear()}-${p(date.getUTCMonth() + 1)}-${p(date.getUTCDate())}`;
274
+ const t = `${p(date.getUTCHours())}:${p(date.getUTCMinutes())}:${p(date.getUTCSeconds())}`;
275
+ return { now: `${d} ${t}`, today: d };
276
+ }
@@ -0,0 +1,60 @@
1
+ // revert 모드 — payload 유래 파일만 제거 (DESIGN-SPEC §4 되돌리기).
2
+ // 원칙: payload에 존재하는 파일명과 정확히 일치하는 것만 지운다.
3
+ // 사용자가 직접 만든 워크플로우·version.yml·README·.gitignore는 건드리지 않는다
4
+ // (version.yml은 사용자 버전 데이터 — 제거 대상이 아니라 산출물이다).
5
+ import { join } from "node:path";
6
+ import { existsSync, readdirSync, readFileSync, renameSync } from "node:fs";
7
+ import { PATHS, PAYLOAD } from "../core/paths.js";
8
+ import { remove } from "../core/fsutil.js";
9
+
10
+ // payload/workflows/** 전체(하위 폴더 포함)의 yaml 파일명 집합.
11
+ function payloadWorkflowNames(payloadRoot) {
12
+ const names = new Set();
13
+ const root = join(payloadRoot, PAYLOAD.workflowsDir);
14
+ if (!existsSync(root)) return names;
15
+ for (const e of readdirSync(root, { recursive: true, withFileTypes: true })) {
16
+ if (e.isFile() && /\.(ya?ml)$/.test(e.name)) names.add(e.name);
17
+ }
18
+ return names;
19
+ }
20
+
21
+ // 반환: { workflows: [...제거된 파일명], scripts: [...], coderabbit: bool }
22
+ export function runRevert(context, payloadRoot, targetRoot = ".") {
23
+ const removedWf = [];
24
+ const removedScripts = [];
25
+
26
+ // 1. 워크플로우 — payload 파일명 일치분 + 마법사가 만든 .template.yaml/.bak 파생본
27
+ const wfDir = join(targetRoot, PATHS.workflowsDir);
28
+ const names = payloadWorkflowNames(payloadRoot);
29
+ if (existsSync(wfDir)) {
30
+ for (const name of names) {
31
+ const p = join(wfDir, name);
32
+ if (existsSync(p)) { remove(p); removedWf.push(name); }
33
+ const templateName = (name.endsWith(".yaml") ? name.slice(0, -".yaml".length) : name) + ".template.yaml";
34
+ const tp = join(wfDir, templateName);
35
+ if (existsSync(tp)) { remove(tp); removedWf.push(templateName); }
36
+ const bp = p + ".bak";
37
+ if (existsSync(bp)) { remove(bp); removedWf.push(name + ".bak"); }
38
+ }
39
+ }
40
+
41
+ // 2. 스크립트 — payload가 설치한 2종만
42
+ for (const s of ["version_manager.py", "changelog_manager.py"]) {
43
+ const p = join(targetRoot, PATHS.scriptsDir, s);
44
+ if (existsSync(p)) { remove(p); removedScripts.push(s); }
45
+ }
46
+
47
+ // 3. .coderabbit.yaml — payload 원본과 바이트 일치할 때만 제거 (사용자 자체 파일 보호).
48
+ // 설치 시 백업(.bak)이 있으면 복원한다.
49
+ let coderabbit = false;
50
+ const cr = join(targetRoot, ".coderabbit.yaml");
51
+ const crSrc = join(payloadRoot, "coderabbit.yaml");
52
+ if (existsSync(cr) && existsSync(crSrc)
53
+ && readFileSync(cr, "utf8") === readFileSync(crSrc, "utf8")) {
54
+ remove(cr);
55
+ coderabbit = true;
56
+ if (existsSync(cr + ".bak")) renameSync(cr + ".bak", cr);
57
+ }
58
+
59
+ return { workflows: removedWf, scripts: removedScripts, coderabbit };
60
+ }
@@ -0,0 +1,31 @@
1
+ // version 모드 (.sh execute_integration version case 등가).
2
+ // 순서: version.yml → readme → scripts → gitignore.
3
+ // (워크플로우 미복사 → deploy 블록 없음. util·issue·coderabbit·setup-guide는 스코프 제외.)
4
+ import { join } from "node:path";
5
+ import { writeText } from "../core/fsutil.js";
6
+ import { PATHS } from "../core/paths.js";
7
+ import { buildVersionYml } from "../core/version-yml.js";
8
+ import { readVersionYmlTemplate } from "../core/assets.js";
9
+ import { markerForType } from "../core/detect.js";
10
+ import { addVersionSectionToReadme } from "../core/copy/readme.js";
11
+ import { copyScripts } from "../core/copy/simple.js";
12
+ import { ensureGitignore } from "../core/copy/gitignore.js";
13
+
14
+ export function runVersion(context, payloadRoot, targetRoot = ".") {
15
+ const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
16
+ now, today, templateVersion = "unknown",
17
+ includeNexus = false, includeSecretBackup = false, includeCodeRabbit = false } = context;
18
+
19
+ const pathMarkers = new Map();
20
+ for (const [t] of paths) pathMarkers.set(t, markerForType(t));
21
+
22
+ writeText(join(targetRoot, PATHS.versionFile),
23
+ buildVersionYml({
24
+ templateText: readVersionYmlTemplate(payloadRoot),
25
+ version, types, paths, pathMarkers, branch, branches: context.branches, versionCode, now, today,
26
+ templateOptions: { templateVersion, includeNexus, includeSecretBackup, includeCodeRabbit: includeCodeRabbit === true, optionsDate: today },
27
+ }));
28
+ addVersionSectionToReadme(version, targetRoot);
29
+ copyScripts(payloadRoot, targetRoot);
30
+ ensureGitignore(targetRoot);
31
+ }
@@ -0,0 +1,49 @@
1
+ // workflows 모드 (.sh execute_integration workflows case 등가).
2
+ // 순서: copy_workflows → update_version_yml_deploy(version.yml 있을 때만) → scripts.
3
+ // (version.yml 생성 안 함 — 기존 version.yml이 있을 때만 deploy 블록 추가.
4
+ // util/config/setup-guide 설치는 project-auto-wizard 스코프에서 제외 — DESIGN-SPEC §2)
5
+ import { join } from "node:path";
6
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { PATHS } from "../core/paths.js";
8
+ import { copyWorkflows } from "../core/copy/workflows.js";
9
+ import { copyScripts } from "../core/copy/simple.js";
10
+
11
+ export function runWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}) {
12
+ const wf = copyWorkflows(context, payloadRoot, targetRoot, hooks);
13
+
14
+ // update_version_yml_deploy: 기존 version.yml이 있고 ask 값이 있을 때만 deploy 블록 갱신
15
+ const vy = join(targetRoot, PATHS.versionFile);
16
+ if (existsSync(vy) && wf.deployValues && wf.deployValues.size) {
17
+ writeFileSync(vy, upsertDeployBlock(readFileSync(vy, "utf8"), wf.deployValues));
18
+ }
19
+
20
+ copyScripts(payloadRoot, targetRoot);
21
+ return { workflows: wf };
22
+ }
23
+
24
+ // 기존 version.yml에서 deploy: 블록을 제거하고 새로 append (.sh update_version_yml_deploy 멱등).
25
+ export function upsertDeployBlock(content, deployValues) {
26
+ // 기존 deploy: 블록 제거 (deploy: 라인 ~ 다음 최상위 키 전까지)
27
+ const lines = content.split(/\r?\n/);
28
+ const out = [];
29
+ let inDeploy = false;
30
+ for (const line of lines) {
31
+ if (/^deploy:/.test(line)) { inDeploy = true; continue; }
32
+ if (inDeploy) {
33
+ if (/^\s/.test(line) || line === "") continue; // 들여쓰기/빈줄 = deploy 내부
34
+ inDeploy = false;
35
+ }
36
+ out.push(line);
37
+ }
38
+ let text = out.join("\n").replace(/\n+$/, "\n");
39
+ // 새 deploy 블록 append
40
+ const deployTypes = [...deployValues.keys()].filter((t) => deployValues.get(t)?.size);
41
+ if (deployTypes.length) {
42
+ text += `\ndeploy: # 마법사가 기억하는 배포 설정 (비민감 / 직접 수정 가능)\n`;
43
+ for (const t of deployTypes) {
44
+ text += ` ${t}:\n`;
45
+ for (const [k, v] of deployValues.get(t)) text += ` ${k}: "${v}"\n`;
46
+ }
47
+ }
48
+ return text;
49
+ }
package/src/context.js ADDED
@@ -0,0 +1,26 @@
1
+ // 마법사 전역 상태를 하나의 객체로 명시화 (bash 전역 변수군 대체)
2
+ export const VALID_TYPES = [
3
+ "spring", "flutter", "next", "react",
4
+ "react-native", "react-native-expo", "node", "python", "basic",
5
+ ];
6
+
7
+ export const DEFAULT_VERSION = "0.0.0"; // 패키지 버전 읽기 실패 시 폴백 (배너용 — breaking 비교엔 안 씀)
8
+
9
+ export function createContext(overrides = {}) {
10
+ return {
11
+ mode: "interactive",
12
+ force: false,
13
+ types: [],
14
+ version: "",
15
+ branch: "",
16
+ branches: null, // { main, develop, mode: "pr-flow"|"trunk-based" } — resolveBranchConfig 결과
17
+ paths: new Map(), // type -> path
18
+ includeNexus: null, // null=미설정, true/false=명시
19
+ includeSecretBackup: null,
20
+ includeCodeRabbit: null, // CodeRabbit opt-in (기본 false — version.yml options.coderabbit에 기록)
21
+ templateVersion: "",
22
+ deployValues: new Map(), // "type.KEY" -> value
23
+ counters: {},
24
+ ...overrides,
25
+ };
26
+ }
@@ -0,0 +1,47 @@
1
+ // payload 자산 해석 — 네트워크 접근 0 (구 acquireTemplate/git clone 대체).
2
+ // 마법사가 설치하는 모든 자산은 npm 패키지에 동봉된 payload/ 가 단일 진실이다.
3
+ // npx 글로벌 캐시에서 실행돼도 import.meta.url 기준으로 패키지 내 payload를 정확히 가리킨다.
4
+ import { fileURLToPath } from "node:url";
5
+ import { join } from "node:path";
6
+ import { readFileSync } from "node:fs";
7
+ import { exists, readText, listYamlFiles } from "./fsutil.js";
8
+ import { DEFAULT_VERSION } from "../context.js";
9
+
10
+ // 패키지 내 payload/ 절대경로. 테스트·픽스처는 인자 주입으로 대체 가능.
11
+ export function resolvePayloadRoot() {
12
+ return fileURLToPath(new URL("../../payload/", import.meta.url));
13
+ }
14
+
15
+ // 마법사(=템플릿) 버전 — 패키지 package.json의 version.
16
+ // 구 readTemplateVersion(tempDir/version.yml)과 동일한 소비처(배너·breaking 비교·version.yml 기록)를 채운다.
17
+ export function readTemplateVersion() {
18
+ try {
19
+ const pkgPath = fileURLToPath(new URL("../../package.json", import.meta.url));
20
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
21
+ return pkg.version || DEFAULT_VERSION;
22
+ } catch {
23
+ return DEFAULT_VERSION;
24
+ }
25
+ }
26
+
27
+ // payload/workflows/common 직하위 yaml 목록 (secret-backup 하위 폴더 제외 — 복사 엔진과 동일 규약).
28
+ export function listCommonWorkflows(payloadRoot = resolvePayloadRoot()) {
29
+ return listYamlFiles(join(payloadRoot, "workflows", "common"));
30
+ }
31
+
32
+ // payload 구조 자가 점검 — 필수 폴더 누락 시 명확히 실패 (배포 패키징 오류 조기 발견).
33
+ export function assertPayload(payloadRoot = resolvePayloadRoot()) {
34
+ if (!exists(join(payloadRoot, "workflows"))) {
35
+ throw new Error("패키지 구조 오류 — payload/workflows 폴더를 찾지 못했습니다.");
36
+ }
37
+ if (!exists(join(payloadRoot, "scripts"))) {
38
+ throw new Error("패키지 구조 오류 — payload/scripts 폴더를 찾지 못했습니다.");
39
+ }
40
+ return payloadRoot;
41
+ }
42
+
43
+ // payload/version.yml.template 원문 (Task 14 치환 파이프라인 소비).
44
+ export function readVersionYmlTemplate(payloadRoot = resolvePayloadRoot()) {
45
+ const p = join(payloadRoot, "version.yml.template");
46
+ return exists(p) ? readText(p) : null;
47
+ }
@@ -0,0 +1,60 @@
1
+ // 브랜치 구성 (DESIGN-SPEC §4 신규 질문 ①).
2
+ // on: push: branches: 는 YAML 정적 값 — 마법사가 릴리스/개발 브랜치를 물어(또는 플래그로 받아)
3
+ // {{MAIN_BRANCH}}/{{DEVELOP_BRANCH}} 플레이스홀더를 치환한다 (치환 자체는 branding.js — Task 14).
4
+ // main === develop 이면 trunk-based 모드 → RELEASE-PUBLISH 단독 설치 (설치 매트릭스는 Task 16).
5
+ import { execFile } from "node:child_process";
6
+
7
+ // 기본 exec — git 명령 실행. 반환 {code, stdout, stderr}. 테스트는 mock 주입.
8
+ function defaultExec(cmd, args, { cwd } = {}) {
9
+ return new Promise((resolve) => {
10
+ execFile(cmd, args, { cwd, windowsHide: true }, (err, stdout, stderr) => {
11
+ resolve({ code: err ? (err.code ?? 1) : 0, stdout: String(stdout), stderr: String(stderr) });
12
+ });
13
+ });
14
+ }
15
+
16
+ // 감지: 로컬 git에서 원격 브랜치 목록 (네트워크 없이 — 로컬이 아는 origin/* 기준).
17
+ // git이 없거나 레포가 아니면 빈 목록 (질문 기본값 경로로 폴백).
18
+ export async function detectRemoteBranches(cwd, exec = defaultExec) {
19
+ const r = await exec("git", ["branch", "-r", "--format=%(refname:short)"], { cwd });
20
+ if (r.code !== 0) return [];
21
+ return r.stdout
22
+ .split(/\r?\n/)
23
+ .map((s) => s.trim())
24
+ .filter((s) => s && !s.includes("->")) // "origin/HEAD -> origin/main" 제외
25
+ .map((s) => s.replace(/^origin\//, ""))
26
+ .filter((s, i, a) => a.indexOf(s) === i);
27
+ }
28
+
29
+ // 결정 (순수 함수): 플래그/답변 → 최종 구성.
30
+ // 우선순위: 명시값(mainBranch/developBranch) → 감지 default → 하드 폴백(main/develop).
31
+ export function resolveBranchConfig({ mainBranch = "", developBranch = "", defaultBranch = "" } = {}) {
32
+ const main = mainBranch || defaultBranch || "main";
33
+ const develop = developBranch || "develop";
34
+ return { main, develop, mode: main === develop ? "trunk-based" : "pr-flow" };
35
+ }
36
+
37
+ // 생성: develop이 원격에 없으면 현 HEAD 기준으로 생성 + push.
38
+ // confirm: async(message)→bool — 대화형 확인 질문. null이면(--force) 질문 없이 자동 생성.
39
+ // exec 주입 가능 (테스트 mock). 반환 { created, pushed?, skipped? }.
40
+ export async function ensureDevelopBranch({ develop, remoteBranches = [], confirm = null, cwd, exec = defaultExec, log = null }) {
41
+ if (remoteBranches.includes(develop)) return { created: false };
42
+
43
+ if (confirm) {
44
+ const ok = await confirm(`원격에 '${develop}' 브랜치가 없습니다. 현재 HEAD 기준으로 생성하고 push할까요?`);
45
+ if (ok !== true) return { created: false, skipped: true };
46
+ }
47
+
48
+ const br = await exec("git", ["branch", develop], { cwd });
49
+ if (br.code !== 0) {
50
+ // 이미 로컬에 존재하는 경우 등 — push만 시도
51
+ log?.(`'${develop}' 로컬 브랜치 생성 생략 (${(br.stderr || "").trim() || "이미 존재"})`);
52
+ }
53
+ const push = await exec("git", ["push", "-u", "origin", develop], { cwd });
54
+ if (push.code !== 0) {
55
+ log?.(`⚠️ '${develop}' 브랜치 push 실패 — 원격 설정을 확인한 뒤 수동으로 push하세요 (git push -u origin ${develop})`);
56
+ return { created: true, pushed: false };
57
+ }
58
+ log?.(`'${develop}' 브랜치를 생성하고 push했습니다.`);
59
+ return { created: true, pushed: true };
60
+ }
@@ -0,0 +1,15 @@
1
+ // 브랜치 플레이스홀더 치환 파이프라인 (DESIGN-SPEC §4).
2
+ // payload 워크플로우의 {{MAIN_BRANCH}}/{{DEVELOP_BRANCH}}를 설치 시 실제 브랜치명으로 바꾼다.
3
+ // ⚠️ GitHub Actions 표현식 `${{ ... }}`은 우리 토큰이 아니다 — `$` 선행 시 제외(lookbehind).
4
+ // 치환 후 알 수 없는 {{TOKEN}}이 남으면 throw — 복사 무결성 가드 (오타·누락 조기 검출).
5
+
6
+ const TOKEN_RE = /(?<!\$)\{\{([A-Z][A-Z0-9_]*)\}\}/g;
7
+
8
+ // substitute(text, {main, develop}) — 브랜치 토큰 치환. 미지 토큰 발견 시 throw.
9
+ export function substitute(text, { main, develop }) {
10
+ const map = { MAIN_BRANCH: main, DEVELOP_BRANCH: develop };
11
+ return String(text).replace(TOKEN_RE, (_, name) => {
12
+ if (name in map) return map[name];
13
+ throw new Error(`알 수 없는 플레이스홀더가 남았습니다: {{${name}}}`);
14
+ });
15
+ }
@@ -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/Twin-Fang/project-auto-wizard/main/payload/config/breaking-changes.json";
9
+
10
+ // 원격 우선(3초 타임아웃) → 패키지 payload/config 번들본 폴백 → 둘 다 실패 시 null(조용히 스킵 — .sh 등가)
11
+ export async function loadBreakingJson(payloadRoot) {
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(payloadRoot, "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
+ // payloadRoot - 패키지 payload/ 루트 (번들 폴백용)
27
+ // templateVersion - 설치하려는 템플릿 버전 (.sh의 DEFAULT_VERSION 고정 버그를 실버전으로 교정 — 설계 D2)
28
+ // askYesNo - async(message, defaultYes)→bool. null이면 비대화형: 경고만 출력 후 진행
29
+ // loader - 테스트 주입용 (기본 loadBreakingJson)
30
+ export async function runBreakingCheck({ cwd, payloadRoot, 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(payloadRoot);
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
+ }