project-auto-wizard 0.12.2 → 0.13.0

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