project-auto-wizard 0.12.2 → 0.13.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.
Files changed (91) hide show
  1. package/README.ja.md +165 -0
  2. package/README.ko.md +165 -0
  3. package/README.md +107 -286
  4. package/README.zh-CN.md +165 -0
  5. package/bin/project-auto-wizard.js +1 -2
  6. package/package.json +9 -3
  7. package/payload/config/breaking-changes.json +24 -0
  8. package/payload/config/wizard-prompts.yml +45 -0
  9. package/payload/scripts/changelog_manager.py +141 -46
  10. package/payload/scripts/issue_helper.py +34 -5
  11. package/payload/scripts/version_manager.py +482 -107
  12. package/payload/version.yml.template +5 -2
  13. package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +39 -9
  14. package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +165 -38
  15. package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +14 -5
  16. package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +220 -6
  17. package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +89 -3
  18. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +78 -16
  19. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +72 -16
  20. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +62 -20
  21. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +51 -13
  22. package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +75 -11
  23. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +52 -3
  24. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +60 -11
  25. package/payload/workflows/go/PROJECT-GO-CI.yaml +10 -1
  26. package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +148 -28
  27. package/payload/workflows/go/PROJECT-GO-SIMPLE-CICD.yaml +45 -4
  28. package/payload/workflows/next/PROJECT-NEXT-CI.yaml +25 -3
  29. package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +83 -9
  30. package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +70 -6
  31. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +148 -28
  32. package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +45 -4
  33. package/payload/workflows/react/PROJECT-REACT-CI.yaml +25 -3
  34. package/payload/workflows/react/PROJECT-REACT-CICD.yaml +84 -10
  35. package/payload/workflows/spring/PROJECT-SPRING-CI.yml +13 -3
  36. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +47 -5
  37. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +47 -5
  38. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +153 -30
  39. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +47 -5
  40. package/src/cli/args.js +33 -24
  41. package/src/cli/help.js +16 -4
  42. package/src/commands/doctor.js +26 -60
  43. package/src/commands/dry-run.js +59 -5
  44. package/src/commands/full.js +135 -55
  45. package/src/commands/install-settings.js +51 -0
  46. package/src/commands/interactive-flutter.js +1 -1
  47. package/src/commands/interactive.js +135 -69
  48. package/src/commands/purge.js +50 -21
  49. package/src/commands/status.js +18 -12
  50. package/src/commands/uninstall.js +22 -22
  51. package/src/context.js +5 -5
  52. package/src/core/baseline.js +20 -5
  53. package/src/core/branches.js +19 -1
  54. package/src/core/branding.js +1 -1
  55. package/src/core/breaking-check.js +14 -19
  56. package/src/core/breaking.js +7 -3
  57. package/src/core/copy/app-files.js +28 -0
  58. package/src/core/copy/gitignore.js +20 -12
  59. package/src/core/copy/readme.js +24 -5
  60. package/src/core/copy/simple.js +52 -13
  61. package/src/core/copy/workflows.js +133 -111
  62. package/src/core/deploy-style.js +83 -11
  63. package/src/core/detect-fs.js +127 -24
  64. package/src/core/detect.js +177 -47
  65. package/src/core/errors.js +3 -0
  66. package/src/core/flutter-doctor.js +49 -0
  67. package/src/core/flutter-hooks.js +29 -0
  68. package/src/core/flutter-options.js +4 -3
  69. package/src/core/fsutil.js +23 -6
  70. package/src/core/logger.js +55 -19
  71. package/src/core/paths-resolve.js +50 -47
  72. package/src/core/paths.js +26 -1
  73. package/src/core/release-options.js +23 -0
  74. package/src/core/removal-exec.js +67 -0
  75. package/src/core/removal-plan.js +75 -19
  76. package/src/core/types.js +136 -0
  77. package/src/core/verify.js +85 -19
  78. package/src/core/version-yml.js +42 -8
  79. package/src/core/wizard-env.js +21 -14
  80. package/src/core/wizard-labels.js +34 -25
  81. package/src/index.js +128 -60
  82. package/src/ui/ansi.js +3 -1
  83. package/src/ui/banner.js +12 -8
  84. package/src/ui/env-plan.js +68 -44
  85. package/src/ui/prompts.js +22 -12
  86. package/src/ui/readline-engine.js +109 -22
  87. package/src/ui/status-cards.js +10 -5
  88. package/src/ui/summary.js +71 -33
  89. package/payload/scripts/__pycache__/changelog_manager.cpython-314.pyc +0 -0
  90. package/payload/scripts/__pycache__/issue_helper.cpython-314.pyc +0 -0
  91. package/payload/scripts/__pycache__/version_manager.cpython-314.pyc +0 -0
@@ -46,7 +46,7 @@ async function askUnsetDeployModes(io, state) {
46
46
 
47
47
  // 아직 정해지지 않은 옵션만 묻는다.
48
48
  // envModeDefault — 신규 설치 dart-define / 기존 설치 dotenv (동작 보존)
49
- // inferredStores — 저장값 없는 기존 설치가 이미 쓰던 스토어 (신규 설치는 [])
49
+ // inferredStores — 저장값 없는 기존 설치가 이미 쓰던 스토어 (신규 설치는 CLI 기본값과 같은 전체)
50
50
  export async function askUnsetFlutterOptions(io, state, { envModeDefault, inferredStores }) {
51
51
  const next = { ...state };
52
52
  if (!next.envMode) {
@@ -1,24 +1,28 @@
1
- // 대화형 마법사 (.sh interactive_mode 등가) + #446 UI 층.
1
+ // 대화형 마법사.
2
2
  // io 주입으로 테스트 가능. 실제 실행은 src/ui/prompts.js 함수를 io로 넘긴다.
3
3
  // 새 시각 층(banner/detectionLog/analysisCard/installKind/summary)과 저수준 엔진(engineIo)은
4
4
  // io의 "옵셔널 멤버" — 스텁이 생략하면 해당 층만 건너뛰고 실행 계약은 동일하다.
5
5
  import { join } from "node:path";
6
6
  import { existsSync, readFileSync } from "node:fs";
7
7
  import { resolvePayloadRoot, assertPayload, readTemplateVersion } from "../core/assets.js";
8
- import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName, makeResolvers, detectBuildNumber, detectMarkers } from "../core/detect-fs.js";
8
+ import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName, makeResolvers, detectMarkers } from "../core/detect-fs.js";
9
9
  import { parseExisting } from "../core/version-yml.js";
10
+ import { pickReleaseOptions, resolveReleaseOptions } from "../core/release-options.js";
10
11
  import { runBreakingCheck } from "../core/breaking-check.js";
11
12
  import { resolveProjectPaths } from "../core/paths-resolve.js";
12
- import { resolveBranchConfig, detectRemoteBranches, ensureDevelopBranch, sortBranchesForSelection } from "../core/branches.js";
13
+ import {
14
+ resolveBranchConfig, detectRemoteBranches, ensureDevelopBranch, sortBranchesForSelection, isValidBranchName, developMissingNotice,
15
+ } from "../core/branches.js";
13
16
  import { promptEnvPlan } from "../ui/env-plan.js";
14
17
  import { surveyWorkflows } from "../core/copy/workflows.js";
15
- import { createContext, VALID_TYPES } from "../context.js";
16
- import { isDeployStyle, DEFAULT_DEPLOY_STYLE } from "../core/deploy-style.js";
18
+ import { VALID_TYPES } from "../context.js";
19
+ import { savedDeployStyle, resolveDeployStyle, resolveVersion, resolveVersionCode, buildInstallContext } from "./install-settings.js";
20
+ import { isDeployStyle, DEFAULT_DEPLOY_STYLE, hasServerDeployWorkflows, hasNonstopWorkflows } from "../core/deploy-style.js";
17
21
  import { PATHS } from "../core/paths.js";
18
- import { resolveFlutterOptions, DEFAULT_DEPLOY_MODE } from "../core/flutter-options.js";
22
+ import { resolveFlutterOptions, DEFAULT_DEPLOY_MODE, STORE_PLATFORMS } from "../core/flutter-options.js";
19
23
  import { inferInstalledStores } from "../core/installed-stores.js";
20
24
  import { savedFlutterState, askUnsetFlutterOptions, editFlutterOption, FLUTTER_EDIT_ITEMS } from "./interactive-flutter.js";
21
- import { runFull } from "./full.js";
25
+ import { runFull, postInstallNotices } from "./full.js";
22
26
  import { runUninstallFlow } from "./uninstall.js";
23
27
  import * as prompts from "../ui/prompts.js";
24
28
  import { runStatus, printStatus } from "./status.js";
@@ -26,6 +30,9 @@ import { runDoctor, printDoctorReport } from "./doctor.js";
26
30
  import { currentLogPath, hasLegacyMdLogs } from "../core/logger.js";
27
31
 
28
32
  const CANCEL = prompts.CANCEL;
33
+
34
+ const SEMVER_AUTO_QUESTION = "자동 버전 승격을 사용하시겠습니까? (커밋 타입에 따라 major/minor/patch 자동 결정)";
35
+ const COPILOT_AI_QUESTION = "Copilot으로 AI 요약을 생성하시겠습니까? (GitHub Copilot AI Credits가 소비되며, 사용할 수 없으면 자동으로 규칙 기반 요약으로 전환됩니다)";
29
36
  const isCancel = (v) => v === CANCEL || typeof v === "symbol";
30
37
 
31
38
  // io 기본값 = 실제 prompts. 테스트는 스텁 io 주입.
@@ -37,7 +44,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
37
44
  if (io.banner) io.banner({ version: templateVersion, modeLabel: "대화형 통합 마법사" });
38
45
  else io.intro?.("project-auto-wizard — 대화형 통합 마법사");
39
46
 
40
- // 기존 version.yml — version/version_code/paths/옵션 보존의 단일 진실 (.sh SSoT L2208~2239)
47
+ // 기존 version.yml — version/version_code/paths/옵션 보존의 단일 진실
41
48
  const vyPath = join(cwd, "version.yml");
42
49
  const existing = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")) : null;
43
50
 
@@ -65,7 +72,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
65
72
  return 0;
66
73
  }
67
74
 
68
- // Breaking Changes 게이트 (.sh execute_integration L4415~4420 — 모든 모드 공통, 대화형은 확인 질문)
75
+ // Breaking Changes 게이트 (모든 모드 공통, 대화형은 확인 질문)
69
76
  const proceed = await runBreakingCheck({
70
77
  cwd, payloadRoot: payload, templateVersion,
71
78
  askYesNo: (msg, def) => io.askYesNo(msg, def),
@@ -77,17 +84,24 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
77
84
  // 먼저 나와 앞선 질문에 대한 경고처럼 보였다. 안내 문구도 대화형용으로 바꾼다.
78
85
  const detectWarnings = [];
79
86
  let types = detectTypes(cwd);
80
- let version = (existing?.version) || detectVersion(cwd, {
87
+ let version = resolveVersion({
88
+ cwd, existing, types,
81
89
  warn: (m) => detectWarnings.push(m),
82
90
  hint: "다음 화면의 '수정하기 > 버전'에서 바로 고칠 수 있습니다.",
83
91
  });
84
- let branch = detectDefaultBranch(cwd);
92
+ // 경로가 확정된 뒤 모노레포 하위 폴더에서 다시 감지할지 — 저장값·직접 입력한 값은 건드리지 않는다.
93
+ let versionAutoDetected = !existing?.version;
94
+ let branch = detectDefaultBranch(cwd, {
95
+ warn: (m) => detectWarnings.push(m),
96
+ hint: "다르면 뒤의 '릴리스 브랜치' 질문에서 바꿀 수 있습니다.",
97
+ });
85
98
  const repoName = detectRepoName(cwd);
86
- // 선택 워크플로우 초기값: version.yml 저장 옵션 (.sh read_template_options L2361 등가)
87
- let includeSemverAuto = existing?.options?.semverAuto ?? null;
88
- let includeCopilotAi = existing?.options?.copilotAi ?? null;
99
+ // 선택 워크플로우 초기값: CLI 플래그(--copilot 등) → version.yml 저장 옵션
100
+ // 플래그로 정한 값은 질문을 생략한다 — 비대화형과 같은 우선순위.
101
+ let { semverAuto: includeSemverAuto, copilotAi: includeCopilotAi } = pickReleaseOptions(
102
+ { semverAuto: baseCtx?.includeSemverAuto, copilotAi: baseCtx?.includeCopilotAi }, existing);
89
103
  // 서버 배포 방식 — 저장값(version.yml)이 있으면 재질문하지 않는다 (semver_auto와 같은 규약).
90
- let deployStyle = isDeployStyle(existing?.options?.deployStyle) ? existing.options.deployStyle : "";
104
+ let deployStyle = savedDeployStyle(existing);
91
105
  const showOptional = mode === "full";
92
106
  const realTty = process.stdout.isTTY === true;
93
107
 
@@ -99,12 +113,21 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
99
113
  envModeDefault: resolveFlutterOptions({
100
114
  cli: { envMode: "", stores: null, androidDeployMode: "", iosDeployMode: "" }, existing,
101
115
  }).envMode,
102
- inferredStores: existing && flutter.stores === null ? inferInstalledStores(join(cwd, PATHS.workflowsDir)) : [],
116
+ // 신규 설치의 초기 선택은 CLI 기본값(--flutter-store 미지정 = 둘 다)과 같아야 한다 — 경로에 따라 설치 결과가 달라지면 안 된다.
117
+ inferredStores: existing && flutter.stores === null ? inferInstalledStores(join(cwd, PATHS.workflowsDir)) : [...STORE_PLATFORMS],
103
118
  };
104
119
  // 이미 정해진 값은 건너뛰므로 여러 번 불러도 같은 질문이 반복되지 않는다.
105
120
  const askFlutterOptions = async () => {
106
121
  if (types.includes("flutter")) flutter = await askUnsetFlutterOptions(io, flutter, flutterAsk);
107
122
  };
123
+ // 서버 배포 방식 — 서버 배포(CD) 워크플로우가 있는 타입(spring·go·python 등)일 때만 묻는다.
124
+ // 그 외 타입에는 설치 결과에 영향이 없는 질문이라 묻지도 기록하지도 않는다.
125
+ const hasServerDeploy = () => hasServerDeployWorkflows(payload, types);
126
+ const askDeployStyle = async () => {
127
+ if (isDeployStyle(deployStyle) || !hasServerDeploy()) return;
128
+ const picked = await io.selectDeployStyle({ nonstop: hasNonstopWorkflows(payload, types) });
129
+ deployStyle = isDeployStyle(picked) ? picked : DEFAULT_DEPLOY_STYLE; // ESC = 기본값
130
+ };
108
131
 
109
132
  // 감지 로그. markers = 실제로 존재를 확인한 파일.
110
133
  let markers = detectMarkers(cwd, types);
@@ -127,11 +150,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
127
150
 
128
151
  // 배포 방식·Flutter 옵션·semver/Copilot 질문 — 선택 항목을 묻는 경우(showOptional)만
129
152
  if (showOptional) {
130
- // 서버 배포 방식.
131
- if (!isDeployStyle(deployStyle)) {
132
- const picked = await io.selectDeployStyle();
133
- deployStyle = isDeployStyle(picked) ? picked : DEFAULT_DEPLOY_STYLE; // ESC = 기본값
134
- }
153
+ await askDeployStyle();
135
154
 
136
155
  // Flutter 옵션 — 환경변수 방식 → 스토어 배포 대상 → 플랫폼별 배포 모드.
137
156
  await askFlutterOptions();
@@ -139,31 +158,37 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
139
158
  // 신규 질문 — 자동 semver 승격 (기본 ON). 저장값 있으면 재질문 생략.
140
159
  // version.yml을 쓰지 않는 workflows 모드에서는 답변이 무의미하므로 full에서만 질문한다.
141
160
  if (mode === "full" && includeSemverAuto === null) {
142
- const y2 = await io.askYesNo("자동 버전 승격을 사용하시겠습니까? (커밋 타입에 따라 major/minor/patch 자동 결정)", true);
161
+ const y2 = await io.askYesNo(SEMVER_AUTO_QUESTION, true);
143
162
  includeSemverAuto = y2 === true;
144
163
  }
145
164
 
146
165
  // Copilot AI 요약 — AI Credits를 소비하므로 opt-in(기본 No). 저장값 있으면 재질문 생략.
147
166
  if (mode === "full" && includeCopilotAi === null) {
148
- const y3 = await io.askYesNo("Copilot으로 AI 요약을 생성하시겠습니까? (GitHub Copilot AI Credits가 소비되며, 사용할 수 없으면 자동으로 규칙 기반 요약으로 전환됩니다)", false);
167
+ const y3 = await io.askYesNo(COPILOT_AI_QUESTION, false);
149
168
  includeCopilotAi = y3 === true;
150
169
  }
151
170
  }
152
- // 질문이 실제로 나온 경우(위 full 모드 질문) 답변을 그대로 존중.
153
- // 질문이 안 나온 경우(version/workflows 모드) — 기존 설치는 안전하게 false로 폴백,
154
- // 완전 신규 설치만 true(기존 설계) 유지 — CLI 경로(index.js)와 동일한 안전 정책.
155
- includeSemverAuto = includeSemverAuto === null ? (existing ? false : true) : includeSemverAuto !== false;
156
- includeCopilotAi = includeCopilotAi === true;
171
+ // 질문이 실제로 나온 경우(위 full 모드 질문) 답변을 그대로 존중하고, 안 나온 옵션만 기본값으로 채운다
172
+ // — CLI 경로(index.js)와 같은 함수라 같은 규칙이다.
173
+ ({ includeSemverAuto, includeCopilotAi } = resolveReleaseOptions(
174
+ { semverAuto: includeSemverAuto, copilotAi: includeCopilotAi }, existing));
175
+ const showOptionToggles = mode === "full";
157
176
 
158
- // 확인/수정 루프 — ESC는 '머무르기' (.sh L1877~1881: 명시적 '아니오'만 종료)
177
+ // 확인/수정 루프 — ESC는 '머무르기' (명시적 '아니오'만 종료)
159
178
  let paths = new Map();
160
179
  let confirmed = false;
161
180
  while (!confirmed) {
162
181
  // 프로젝트 분석 개요 카드. 스텁엔 없음 → note 폴백.
163
182
  if (io.analysisCard) {
164
- io.analysisCard({ mode, modeLabel: modeLabel(mode), types, version, branch, showOptional, paths, flutter, envModeDefault: flutterAsk.envModeDefault });
183
+ io.analysisCard({
184
+ mode, modeLabel: modeLabel(mode), types, version, branch, showOptional, paths, flutter, envModeDefault: flutterAsk.envModeDefault,
185
+ options: showOptionToggles ? { semverAuto: includeSemverAuto, copilotAi: includeCopilotAi } : null,
186
+ });
165
187
  } else {
166
- io.note?.(summarize({ mode, types, version, branch, showOptional, flutter, envModeDefault: flutterAsk.envModeDefault }), "프로젝트 분석 결과");
188
+ io.note?.(summarize({
189
+ mode, types, version, branch, showOptional, flutter, envModeDefault: flutterAsk.envModeDefault,
190
+ options: showOptionToggles ? { semverAuto: includeSemverAuto, copilotAi: includeCopilotAi } : null,
191
+ }), "프로젝트 분석 결과");
167
192
  }
168
193
  const choice = await io.confirmProjectMenu();
169
194
  if (choice === "cancel") { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
@@ -172,12 +197,12 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
172
197
  // edit 루프
173
198
  let editing = true;
174
199
  while (editing) {
175
- const what = await io.editMenu({ showFlutter: showOptional && types.includes("flutter") });
200
+ const what = await io.editMenu({ showFlutter: showOptional && types.includes("flutter"), showOptions: showOptionToggles });
176
201
  if (isCancel(what) || what === "done") { editing = false; break; }
177
202
  if (what === "type") {
178
203
  const t = await io.selectTypes(types);
179
204
  if (!isCancel(t) && Array.isArray(t) && t.length) {
180
- // 타입 집합이 실제로 바뀌면 경로 재해석 대상으로 초기화 (.sh L1984~1992 — 정렬 집합 비교)
205
+ // 타입 집합이 실제로 바뀌면 경로 재해석 대상으로 초기화 (정렬 집합 비교)
181
206
  const oldSorted = [...types].sort().join(",");
182
207
  types = t.filter((x) => VALID_TYPES.includes(x));
183
208
  if ([...types].sort().join(",") !== oldSorted) paths = new Map();
@@ -185,13 +210,19 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
185
210
  } else if (what === "version") {
186
211
  const v = await io.askText("새 버전 (예: 1.0.0)", version);
187
212
  if (!isCancel(v) && v !== version) {
188
- // semver 형식 검증 (.sh L2010~2015)
189
- if (/^\d+\.\d+\.\d+$/.test(v)) version = v;
213
+ // semver 형식 검증
214
+ if (/^\d+\.\d+\.\d+$/.test(v)) { version = v; versionAutoDetected = false; }
190
215
  else io.note?.("버전 형식이 올바르지 않습니다 (x.y.z 형태) — 기존 값을 유지합니다.", "⚠ 버전");
191
216
  }
192
217
  } else if (what === "branch") {
193
- const b = await io.askText("기본 브랜치", branch);
194
- if (!isCancel(b) && b) branch = b;
218
+ branch = await askBranchName(io, "기본 브랜치", branch, isCancel);
219
+ } else if (what === "semverAuto") {
220
+ // 저장값이 있으면 처음 질문을 건너뛰므로, 한 번 정한 값을 바꿀 수 있는 곳은 여기뿐이다.
221
+ const y = await io.askYesNo(SEMVER_AUTO_QUESTION, includeSemverAuto);
222
+ if (typeof y === "boolean") includeSemverAuto = y;
223
+ } else if (what === "copilotAi") {
224
+ const y = await io.askYesNo(COPILOT_AI_QUESTION, includeCopilotAi);
225
+ if (typeof y === "boolean") includeCopilotAi = y;
195
226
  } else if (FLUTTER_EDIT_ITEMS.has(what)) {
196
227
  flutter = await editFlutterOption(io, what, flutter, flutterAsk.envModeDefault);
197
228
  }
@@ -199,7 +230,11 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
199
230
  }
200
231
 
201
232
  // 편집 루프에서 뒤늦게 flutter 타입이 추가된 경우에도 옵션을 확정한다 — 이미 정해진 값은 다시 묻지 않는다.
202
- if (showOptional) await askFlutterOptions();
233
+ // 서버 배포 타입이 뒤늦게 추가된 경우도 같다.
234
+ if (showOptional) {
235
+ await askDeployStyle();
236
+ await askFlutterOptions();
237
+ }
203
238
  // 질문이 나오지 않은 경우(비 full 모드 등)도 동작 보존 기본값으로 채워 워크플로우 치환이 어긋나지 않게 한다.
204
239
  const flutterOptions = {
205
240
  envMode: flutter.envMode || flutterAsk.envModeDefault,
@@ -208,14 +243,17 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
208
243
  iosDeployMode: flutter.iosDeployMode || DEFAULT_DEPLOY_MODE,
209
244
  };
210
245
 
211
- const versionCode = existing?.versionCode ?? detectBuildNumber(cwd, { types }) ?? 1; // 기존 빌드번호 보존, 신규 통합 시 프로젝트 파일에서 감지
212
-
213
- // 신규 질문 ① — 브랜치 설정 (DESIGN-SPEC §4). full/workflows만 질문, version은 기본값 기록.
246
+ // 브랜치 설정. full/workflows만 질문, version은 기본값 기록.
214
247
  // 저장값(version.yml metadata.template.branches)이 있으면 재질문 없이 재사용 (업데이트 모드).
215
- let branches = existing?.branches
216
- ? resolveBranchConfig({ mainBranch: existing.branches.main, developBranch: existing.branches.develop, defaultBranch: branch })
248
+ // 이전 버전이 저장한 감지 실패 값("(unknown)" 등)이 있으면 저장값이 없는 것으로 보고 다시 묻는다.
249
+ const savedBranches = existing?.branches
250
+ && isValidBranchName(existing.branches.main) && isValidBranchName(existing.branches.develop)
251
+ ? existing.branches : null;
252
+ let branches = savedBranches
253
+ ? resolveBranchConfig({ mainBranch: savedBranches.main, developBranch: savedBranches.develop, defaultBranch: branch })
217
254
  : resolveBranchConfig({ defaultBranch: branch });
218
- if (showOptional && !existing?.branches) {
255
+ let developMissing = false;
256
+ if (showOptional && !savedBranches) {
219
257
  const remoteBranches = await detectRemoteBranches(cwd);
220
258
  // 두 질문에 같은 이름을 입력해야만 trunk-based가 되는 암묵적 규칙 대신,
221
259
  // 전략을 먼저 명시적으로 고르게 한다. 취소/그 외 값은 기존 기본 동작과 같은 pr-flow로 폴백
@@ -230,16 +268,21 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
230
268
  branches = resolveBranchConfig({ mainBranch: mainB, developBranch: devB, defaultBranch: branch });
231
269
  if (branches.mode === "trunk-based") {
232
270
  io.note?.(`릴리스 브랜치(${branches.main}) 하나만 사용하는 trunk-based 모드로 설치합니다 (RELEASE-PUBLISH 단독).`, "브랜치 모드");
233
- } else if (remoteBranches.length && !remoteBranches.includes(branches.develop)) {
234
- await ensureDevelopBranch({
271
+ } else if (!remoteBranches.length) {
272
+ // 원격이 없거나 비어 있으면 push할 기준이 없다 — 만들지 않았다는 사실과 방법을 알린다.
273
+ developMissing = true;
274
+ io.note?.(developMissingNotice(branches), "브랜치");
275
+ } else if (!remoteBranches.includes(branches.develop)) {
276
+ const r = await ensureDevelopBranch({
235
277
  develop: branches.develop, remoteBranches, cwd,
236
278
  confirm: (msg) => io.askYesNo(msg, true),
237
279
  log: (m) => io.note?.(m, "브랜치"),
238
280
  });
281
+ developMissing = r.created !== true || r.pushed === false;
239
282
  }
240
283
  }
241
284
 
242
- // 경로 확정 (.sh resolve_project_paths L1362~1589 — 저장값·후보 스캔·질문)
285
+ // 경로 확정 (저장값·후보 스캔·질문)
243
286
  if (mode === "full") {
244
287
  paths = await resolveProjectPaths({
245
288
  root: cwd, types, paths, existingPaths: existing?.paths ?? new Map(),
@@ -249,7 +292,13 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
249
292
  for (const t of types) if (t !== "basic" && !paths.has(t)) paths.set(t, existing?.paths.get(t) || ".");
250
293
  }
251
294
 
252
- // @wizard env 계획 질문 (.sh wf_prompt_env_plan L3220 — full/workflows만)
295
+ // 모노레포는 버전·빌드 번호 파일이 타입 폴더 안에 있다 — 경로가 확정된 지금 그 폴더에서 감지한다.
296
+ if (versionAutoDetected && [...paths.values()].some((p) => p && p !== ".")) {
297
+ version = detectVersion(cwd, { types, paths, warn: () => {} });
298
+ }
299
+ const versionCode = resolveVersionCode({ cwd, existing, types, paths });
300
+
301
+ // @wizard env 계획 질문 (full/workflows만)
253
302
  const resolvers = makeResolvers(cwd, repoName, paths, flutterOptions);
254
303
  let envValues = new Map(), envUseDefaults = true, envAnswers = [];
255
304
  if (showOptional) {
@@ -264,19 +313,17 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
264
313
  }
265
314
 
266
315
  const { now, today } = clock || utcNow();
267
- const ctx = createContext({
268
- mode, force: true, types, version, versionCode, branch, branches, paths,
269
- includeSemverAuto,
270
- includeCopilotAi,
271
- repoName, templateVersion, resolvers, envValues, envUseDefaults, now, today,
316
+ const ctx = buildInstallContext({
317
+ payload, existing, templateVersion, types,
318
+ // 저장값이 무중단이어도 선택한 타입에 그 방식이 없으면 단일 서버 배포가 설치된다 — 설치된 방식을 기록한다.
319
+ deployStyle: resolveDeployStyle({ payload, types, explicit: deployStyle, existing }),
320
+ flutterOptions,
321
+ releaseOptions: { includeSemverAuto, includeCopilotAi },
322
+ mode, force: true, version, versionCode, branch, branches, paths,
323
+ repoName, resolvers, envValues, envUseDefaults, now, today,
272
324
  // 설치 로그·완료 요약이 쓰는 부가 문맥 — 설치 동작 자체는 바꾸지 않는다.
273
325
  markers, envAnswers, detectWarnings,
274
- deployStyle: deployStyle || DEFAULT_DEPLOY_STYLE,
275
- envMode: flutterOptions.envMode, flutterStore: flutterOptions.stores,
276
- androidDeployMode: flutterOptions.androidDeployMode, iosDeployMode: flutterOptions.iosDeployMode,
277
- previousTemplateVersion: existing?.templateVersion || "",
278
326
  });
279
- ctx.templateVersion = templateVersion;
280
327
 
281
328
  // 사용자가 답해야 하는 것만 묻는다. baseline 3-way가 자동으로 안전한 경우를
282
329
  // 걸러내므로, 여기 오는 것은 (a) 양쪽이 다 바뀐 진짜 충돌과 (b) 사용자가 지운 파일뿐이다.
@@ -301,7 +348,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
301
348
  }
302
349
  }
303
350
 
304
- // (a) 진짜 충돌 3지선 — 타입당 1회 결정을 파일에 캐시 적용 (.sh L3440~3508 UX 등가)
351
+ // (a) 진짜 충돌 3지선 — 타입당 1회 결정을 파일에 캐시 적용
305
352
  const decisions = new Map();
306
353
  if (conflicts.length) {
307
354
  const perType = new Map();
@@ -315,7 +362,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
315
362
  { value: "template", label: "기존 유지 + 새 버전을 .template.yaml로 참고 추가" },
316
363
  ],
317
364
  });
318
- perType.set(type, isCancel(sel) || sel == null ? "skip" : sel); // ESC = 건너뛰기 (.sh L3463)
365
+ perType.set(type, isCancel(sel) || sel == null ? "skip" : sel); // ESC = 건너뛰기
319
366
  }
320
367
  decisions.set(filename, perType.get(type));
321
368
  }
@@ -325,20 +372,26 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
325
372
 
326
373
  const result = runFull(ctx, payload, cwd, hooks);
327
374
 
328
- // 완료 요약 (.sh print_summary L5438)
375
+ // 완료 요약
329
376
  io.summary?.({
330
- mode, types, version, versionCode, branches,
377
+ mode, types, version, versionCode, branches, developMissing,
331
378
  copiedFiles: result?.workflows?.copiedFiles ?? [],
379
+ autoUpdated: result?.workflows?.autoUpdated ?? [],
332
380
  gitignoreUpdated: result?.gitignoreUpdated === true,
333
381
  answers: envAnswers,
334
382
  unresolved: result?.unresolved ?? [],
335
383
  secrets: result?.secrets ?? new Map(),
384
+ optionalSecrets: result?.optionalSecrets ?? new Map(),
336
385
  logPath: currentLogPath(),
337
386
  legacyMdLogs: hasLegacyMdLogs(cwd),
338
387
  cleanup: result?.cleanup ?? null,
339
388
  storeCleanup: result?.storeCleanup ?? null,
340
389
  flutterApp: result?.flutterApp ?? null,
390
+ readme: result?.readme ?? null,
391
+ scripts: result?.scripts ?? null,
341
392
  });
393
+ const notices = postInstallNotices(result, { interactive: true });
394
+ if (notices.length) io.note?.(notices.join("\n"), "이전 워크플로우 정리");
342
395
  io.outro?.(`통합 완료 — ${mode} 모드로 설치했습니다.`);
343
396
  return 0;
344
397
  }
@@ -354,17 +407,26 @@ export async function pickBranch(io, message, def, remoteBranches, isCancel) {
354
407
  options.push({ value: "__custom__", label: "직접 입력..." });
355
408
  const initialIndex = Math.max(0, options.findIndex((o) => o.value === def));
356
409
  const sel = await io.engineIo.select({ message, options, initialIndex });
357
- if (sel === "__custom__") {
358
- const v = await io.askText("브랜치 이름", def);
359
- return isCancel(v) || !v ? def : v;
360
- }
410
+ if (sel === "__custom__") return askBranchName(io, "브랜치 이름", def, isCancel);
361
411
  return isCancel(sel) || sel == null ? def : sel;
362
412
  }
363
- const v = await io.askText(message, def);
364
- return isCancel(v) || !v ? def : v;
413
+ return askBranchName(io, message, def, isCancel);
365
414
  }
366
415
 
367
- function summarize({ mode, types, version, branch, showOptional, flutter, envModeDefault }) {
416
+ // 브랜치 이름 텍스트 입력 — 앞뒤 공백을 떼고, 비었거나 ESC면 기본값, 쓸 수 없는 이름이면 다시 묻는다.
417
+ // 입력값이 워크플로우 트리거에 그대로 들어가므로 공백 포함 이름 등은 워크플로우가 영영 돌지 않는다.
418
+ async function askBranchName(io, message, def, isCancel) {
419
+ for (;;) {
420
+ const v = await io.askText(message, def);
421
+ if (isCancel(v)) return def;
422
+ const name = String(v ?? "").trim();
423
+ if (!name) return def;
424
+ if (isValidBranchName(name)) return name;
425
+ io.note?.(`'${name}'은(는) 브랜치 이름으로 쓸 수 없습니다 (공백·특수문자·'..' 등 불가) — 다시 입력하세요.`, "⚠ 브랜치");
426
+ }
427
+ }
428
+
429
+ function summarize({ mode, types, version, branch, showOptional, flutter, envModeDefault, options = null }) {
368
430
  const lines = [
369
431
  `통합 모드 : ${modeLabel(mode)}`,
370
432
  `프로젝트 타입 : ${types.join(", ")}${types.length > 1 ? " (멀티)" : ""}`,
@@ -380,6 +442,10 @@ function summarize({ mode, types, version, branch, showOptional, flutter, envMod
380
442
  lines.push(`배포 모드 : ${modeParts.length ? modeParts.join(" ") : "없음"}`);
381
443
  }
382
444
  }
445
+ if (options) {
446
+ lines.push(`자동 버전 승격 : ${options.semverAuto ? "켜짐" : "꺼짐"}`);
447
+ lines.push(`Copilot AI 요약 : ${options.copilotAi ? "켜짐" : "꺼짐"}`);
448
+ }
383
449
  return lines.join("\n");
384
450
  }
385
451
 
@@ -1,29 +1,58 @@
1
1
  // purge 모드 — planRemoval이 판별하는 전부(워크플로우·스크립트) + version.yml·README
2
- // AUTO-VERSION-SECTION 블록·CHANGELOG를 추가로 제거해 설치 이전 상태로 완전히 되돌린다.
3
- // 개발·테스트 전용 숨김 모드 — DESIGN-SPEC purge #6.
2
+ // AUTO-VERSION-SECTION 블록·.gitignore 자동 추가 항목·릴리스 워크플로우가 만든 CHANGELOG를
3
+ // 추가로 제거해 설치 이전 상태로 완전히 되돌린다. 마법사가 만들지 않은 파일은 지우지 않는다.
4
+ // 개발·테스트 전용 숨김 모드.
4
5
  // develop 브랜치 삭제는 파일 삭제와 성격이 달라(실행 시점 git 상태 판단 필요) 여기 plan에는
5
6
  // 포함하지 않고 index.js의 purge 분기에서 직접 처리한다.
6
7
  import { join } from "node:path";
7
- import { existsSync } from "node:fs";
8
+ import { existsSync, readFileSync } from "node:fs";
8
9
  import { PATHS } from "../core/paths.js";
9
- import { remove } from "../core/fsutil.js";
10
- import { planRemoval } from "../core/removal-plan.js";
11
- import { removeVersionSectionFromReadme, hasVersionSection } from "../core/copy/readme.js";
12
- import { log } from "../core/logger.js";
10
+ import { planRemoval, backupArtifacts } from "../core/removal-plan.js";
11
+ import { executeRemoval } from "../core/removal-exec.js";
12
+ import { hasVersionSection } from "../core/copy/readme.js";
13
+ import { hasAutoAddedEntries } from "../core/copy/gitignore.js";
13
14
 
14
15
  const CHANGELOG_FILES = ["CHANGELOG.json", "CHANGELOG.md"];
15
16
 
17
+ // changelog_manager.py가 만든 CHANGELOG인지 — 사용자가 원래 쓰던 CHANGELOG.md를 지우지 않기 위해 내용으로 가린다.
18
+ // CHANGELOG.json은 metadata·releases 구조로, CHANGELOG.md는 generate-md가 쓰는 머리말로 판단한다.
19
+ // JSON이 우리 것이면 MD는 릴리스마다 JSON에서 통째로 재생성되므로 함께 우리 것이다.
20
+ function readText(targetRoot, f) {
21
+ try { return readFileSync(join(targetRoot, f), "utf8").replace(/\r\n/g, "\n"); } catch { return null; }
22
+ }
23
+ function isGeneratedChangelogJson(targetRoot) {
24
+ const text = readText(targetRoot, "CHANGELOG.json");
25
+ if (text === null) return false;
26
+ try {
27
+ const data = JSON.parse(text);
28
+ return !!data && typeof data.metadata === "object" && Array.isArray(data.releases);
29
+ } catch { return false; }
30
+ }
31
+ function generatedChangelogFiles(targetRoot) {
32
+ const jsonOurs = isGeneratedChangelogJson(targetRoot);
33
+ const md = readText(targetRoot, "CHANGELOG.md");
34
+ const mdOurs = md !== null && (jsonOurs || md.startsWith("# Changelog\n\n**현재 버전:**"));
35
+ return CHANGELOG_FILES.filter((f) => (f === "CHANGELOG.json" ? jsonOurs : mdOurs));
36
+ }
37
+
16
38
  // keepFlags: { versionYml, readme, changelog, workflows, scripts } — true인 카테고리는 후보에서 제외.
17
- // 반환: { workflows, scripts, versionYml, readmeSection, changelog } — 아무것도 지우지 않는 순수 함수.
39
+ // 반환: { workflows, scripts, versionYml, readmeSection, gitignore, changelog } — 아무것도 지우지 않는 순수 함수.
18
40
  export function planPurge(payloadRoot, targetRoot = ".", keepFlags = {}) {
19
41
  const removalPlan = planRemoval(payloadRoot, targetRoot);
42
+ // 워크플로우를 남기면 그 .bak/.template.yaml도 남는다 — .gitignore 항목까지 지우면 백업 파일이 git 상태에 드러난다.
43
+ const gitignoreKept = hasAutoAddedEntries(targetRoot) && keepFlags.workflows === true
44
+ && backupArtifacts(removalPlan.workflows).length > 0;
20
45
  return {
21
46
  workflows: keepFlags.workflows ? [] : removalPlan.workflows,
47
+ // Flutter 앱 파일의 생성 기록은 baseline에 있으므로 baseline과 같은 플래그를 따른다.
48
+ appFiles: keepFlags.workflows ? [] : removalPlan.appFiles,
22
49
  baseline: keepFlags.workflows ? [] : removalPlan.baseline,
23
50
  scripts: keepFlags.scripts ? [] : removalPlan.scripts,
24
51
  versionYml: !keepFlags.versionYml && existsSync(join(targetRoot, PATHS.versionFile)),
25
52
  readmeSection: !keepFlags.readme && hasVersionSection(targetRoot),
26
- changelog: keepFlags.changelog ? [] : CHANGELOG_FILES.filter((f) => existsSync(join(targetRoot, f))),
53
+ gitignore: hasAutoAddedEntries(targetRoot) && !gitignoreKept,
54
+ gitignoreKept,
55
+ changelog: keepFlags.changelog ? [] : generatedChangelogFiles(targetRoot),
27
56
  };
28
57
  }
29
58
 
@@ -38,8 +67,11 @@ export function printPurgePlan(plan, { dryRun = false } = {}) {
38
67
  for (const f of plan.workflows) lines.push(` - ${f}`);
39
68
  lines.push(`스크립트 (${plan.scripts.length}개):`);
40
69
  for (const f of plan.scripts) lines.push(` - ${f}`);
70
+ for (const f of plan.appFiles || []) lines.push(`Flutter 앱 파일: ${f}`);
41
71
  if (plan.versionYml) lines.push("파일: version.yml");
42
72
  if (plan.readmeSection) lines.push("README.md: AUTO-VERSION-SECTION 블록");
73
+ if (plan.gitignore) lines.push(".gitignore: 자동 추가 항목(*.bak, *.template.yaml)");
74
+ if (plan.gitignoreKept) lines.push(".gitignore: 자동 추가 항목 유지 (남겨 둔 워크플로우의 백업 파일을 가리는 항목)");
43
75
  for (const f of plan.changelog) lines.push(`파일: ${f}`);
44
76
  lines.push("");
45
77
  console.log(lines.join("\n"));
@@ -49,33 +81,30 @@ export function printPurgePlan(plan, { dryRun = false } = {}) {
49
81
  // planRemoval 결과를 그대로 지우지 않는 이유: 그 목록은 항상 전체라서
50
82
  // --keep-* 로 선택적 카테고리만 보존하는 요구사항과 맞지 않는다.
51
83
  // readmeSection은 plan의 판정을 그대로 되돌려주지 않고
52
- // removeVersionSectionFromReadme()의 실제 반환값("removed"인지)을 반영한다 — 스펙 §6이
53
- // "반환값은 실제 삭제 결과를 반영"하라고 명시하기 때문에, plan과 실제 제거 조건이
84
+ // removeVersionSectionFromReadme()의 실제 반환값("removed"인지)을 반영한다 — plan과 실제 제거 조건이
54
85
  // 이론상 어긋나는 경우에도 printPurgeResult가 거짓으로 "제거됨"을 보고하지 않는다.
55
86
  export function executePurge(payloadRoot, targetRoot = ".", keepFlags = {}) {
56
87
  const plan = planPurge(payloadRoot, targetRoot, keepFlags);
57
- const wfDir = join(targetRoot, PATHS.workflowsDir);
58
- for (const name of plan.workflows) { remove(join(wfDir, name)); log.info("remove", "workflow", name); }
59
- for (const name of plan.scripts) { remove(join(targetRoot, PATHS.scriptsDir, name)); log.info("remove", "script", name); }
60
- for (const p of plan.baseline || []) { remove(join(targetRoot, p)); log.info("remove", "metadata", p); }
61
- if (plan.versionYml) { remove(join(targetRoot, PATHS.versionFile)); log.info("remove", "version", PATHS.versionFile); }
62
- const readmeSection = plan.readmeSection && removeVersionSectionFromReadme(targetRoot) === "removed";
63
- for (const f of plan.changelog) { remove(join(targetRoot, f)); log.info("remove", "changelog", f); }
64
- return { ...plan, readmeSection };
88
+ const { readme, gitignore } = executeRemoval(targetRoot, { ...plan, readme: plan.readmeSection }, {
89
+ logOrder: ["version", "gitignore", "changelog", "readme"],
90
+ gitignoreDetail: (status) => (status === "removed" || status === "file-deleted" ? ".gitignore 자동 추가 항목" : null),
91
+ });
92
+ return { ...plan, readmeSection: readme, gitignore };
65
93
  }
66
94
 
67
95
  // 삭제 후 실제 제거된 목록 출력 — printPurgePlan과 완전히 동일한 형태(파일명 나열)로
68
- // 맞춘다 (개수만 출력하면 스펙 §5-6의 "제거된 목록 재출력" 요구를 충족하지 못함).
96
+ // 맞춘다 (개수만 출력하면 무엇이 지워졌는지 확인할 수 없다).
69
97
  export function printPurgeResult(result) {
70
98
  const lines = ["", "제거됨:", ""];
71
99
  lines.push(`워크플로우 (${result.workflows.length}개):`);
72
100
  for (const f of result.workflows) lines.push(` - ${f}`);
73
101
  lines.push(`스크립트 (${result.scripts.length}개):`);
74
102
  for (const f of result.scripts) lines.push(` - ${f}`);
103
+ for (const f of result.appFiles || []) lines.push(`Flutter 앱 파일: ${f}`);
75
104
  if (result.versionYml) lines.push("파일: version.yml");
76
105
  if (result.readmeSection) lines.push("README.md: AUTO-VERSION-SECTION 블록");
106
+ if (result.gitignore) lines.push(".gitignore: 자동 추가 항목(*.bak, *.template.yaml)");
77
107
  for (const f of result.changelog) lines.push(`파일: ${f}`);
78
- lines.push("(.gitignore에 추가된 백업 파일 제외 항목(*.bak/*.template.yaml)은 purge 대상에서 제외되어 그대로 보존됩니다)");
79
108
  lines.push("");
80
109
  console.log(lines.join("\n"));
81
110
  }
@@ -6,6 +6,10 @@ import { planWorkflows } from "../core/copy/workflows.js";
6
6
  import { makeResolvers, detectRepoName, detectDefaultBranch } from "../core/detect-fs.js";
7
7
  import { PATHS } from "../core/paths.js";
8
8
  import { resolveFlutterOptions } from "../core/flutter-options.js";
9
+ import { isDeployStyle, DEFAULT_DEPLOY_STYLE } from "../core/deploy-style.js";
10
+ import { findStaleWorkflows } from "../core/removal-plan.js";
11
+ import { readBaseline } from "../core/baseline.js";
12
+ import { hooksFor } from "../core/types.js";
9
13
 
10
14
  // payloadRoot: 패키지 payload/ 루트. targetRoot: 상태를 확인할 대상 레포.
11
15
  export function runStatus(payloadRoot, targetRoot = ".") {
@@ -28,8 +32,13 @@ export function runStatus(payloadRoot, targetRoot = ".") {
28
32
  types: existing.types, paths: existing.paths,
29
33
  repoName, resolvers, branches: branchesForCompare,
30
34
  flutterStore: flutterOptions.stores,
35
+ // 설치 때 고른 배포 방식으로 비교해야 nginx·traefik CD 수정도 드리프트로 잡힌다.
36
+ // 빠지면 기본값(simple)으로 걸러 설치된 무중단 CD가 비교 대상에서 사라진다.
37
+ deployStyle: isDeployStyle(existing.options.deployStyle) ? existing.options.deployStyle : DEFAULT_DEPLOY_STYLE,
31
38
  };
32
39
  const plan = planWorkflows(context, payloadRoot, targetRoot);
40
+ // 현재 버전에 없는 옛 워크플로우 — 다음 업데이트에서 정리된다. 그 전까지는 옛 트리거로 계속 돈다.
41
+ const stale = findStaleWorkflows(payloadRoot, targetRoot, readBaseline(targetRoot));
33
42
 
34
43
  return {
35
44
  installed: true,
@@ -49,6 +58,7 @@ export function runStatus(payloadRoot, targetRoot = ".") {
49
58
  conflicts: plan.changed.map((f) => f.filename), // 양쪽 변경 — 검토 필요
50
59
  removed: plan.removed.map((f) => f.filename), // 내가 지웠고 복원하지 않음
51
60
  },
61
+ staleFiles: stale,
52
62
  };
53
63
  }
54
64
 
@@ -68,8 +78,9 @@ export function printStatus(status) {
68
78
  const boolLabel = (v) => (v === null ? "미설정(기본 false)" : v);
69
79
  const semverAutoLabel = status.options.semverAuto === null ? "미설정(기본 false)" : status.options.semverAuto;
70
80
  const copilotAiLabel = boolLabel(status.options.copilotAi ?? null);
71
- const flutterLabels = status.types.includes("flutter") ? flutterOptionLabels(status.options) : "";
72
- lines.push(`옵션 : semver_auto=${semverAutoLabel} copilot_ai=${copilotAiLabel}${flutterLabels}`);
81
+ const typeLabels = hooksFor(status.types, "statusLabels").map(({ hook }) => hook(status.options)).join("");
82
+ const deployLabel = status.options.deployStyle ? ` deploy_style=${status.options.deployStyle}` : "";
83
+ lines.push(`옵션 : semver_auto=${semverAutoLabel} copilot_ai=${copilotAiLabel}${deployLabel}${typeLabels}`);
73
84
  if (status.modifiedFiles.length) {
74
85
  lines.push("", `사용자가 수정한 워크플로우 파일 (${status.modifiedFiles.length}개):`);
75
86
  for (const f of status.modifiedFiles) lines.push(` - ${f}`);
@@ -77,6 +88,11 @@ export function printStatus(status) {
77
88
  lines.push("", "모든 워크플로우 파일이 설치 시점 기본값과 동일합니다 (수정 없음).");
78
89
  }
79
90
 
91
+ if (status.staleFiles?.length) {
92
+ lines.push("", `현재 버전에 없는 이전 워크플로우 (${status.staleFiles.length}개 — 다음 업데이트에서 정리됨):`);
93
+ for (const f of status.staleFiles) lines.push(` - ${f}`);
94
+ }
95
+
80
96
  // 업데이트하면 무슨 일이 일어나는지. baseline이 없는 설치는 전부 0이라 출력하지 않는다.
81
97
  const b = status.buckets || { autoUpdatable: [], localKept: [], conflicts: [], removed: [] };
82
98
  if (b.autoUpdatable.length || b.localKept.length || b.conflicts.length || b.removed.length) {
@@ -89,13 +105,3 @@ export function printStatus(status) {
89
105
  lines.push("");
90
106
  console.log(lines.join("\n"));
91
107
  }
92
-
93
- // Flutter 타입일 때만 붙는 옵션. 저장값이 없으면 그 상태에서 실제로 적용되는 동작을 함께 알린다.
94
- function flutterOptionLabels(options) {
95
- return [
96
- ` env_mode=${options.envMode ?? "미설정(dotenv 유지)"}`,
97
- ` flutter_store=${options.flutterStore ?? "미설정(둘 다 설치)"}`,
98
- ` android_deploy_mode=${options.androidDeployMode ?? "미설정(store_only)"}`,
99
- ` ios_deploy_mode=${options.iosDeployMode ?? "미설정(store_only)"}`,
100
- ].join("");
101
- }