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,16 +1,18 @@
1
- // 워크플로우 복사 엔진 (.sh copy_workflows + _copy_workflows_for_type 등가).
2
- // 실측: template_integrator.sh 3398~3815.
1
+ // 워크플로우 복사 엔진 — common → 타입별 → server-deploy 순으로 분류·복사·치환한다.
3
2
  // 대화형 3지선(기존 파일 충돌)은 copyWorkflowsInteractive(async)가 결정 Map을 만들어
4
3
  // 동기 엔진(copyWorkflows)에 hooks.decisions로 전달한다 — 기존 시그니처·force 동작 무변경.
5
4
  import { join, basename } from "node:path";
6
- import { deployFilter, isDeployWorkflow, activateDeployTrigger, DEFAULT_DEPLOY_STYLE, NO_DEPLOY_STYLE } from "../deploy-style.js";
5
+ import {
6
+ deployFilter, isDeployWorkflow, activateDeployTrigger, payloadWorkflowNames, DEFAULT_DEPLOY_STYLE, NO_DEPLOY_STYLE,
7
+ } from "../deploy-style.js";
7
8
  import { storeWorkflowFilter } from "../flutter-options.js";
8
9
  import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
9
- import { PATHS, PAYLOAD } from "../paths.js";
10
+ import { PATHS, PAYLOAD, typeWorkflowDirs } from "../paths.js";
10
11
  import { exists, writeText, listYamlFiles } from "../fsutil.js";
11
12
  import { substituteEnv } from "../wizard-env.js";
12
13
  import { substitute } from "../branding.js";
13
14
  import { sha256, readBaseline } from "../baseline.js";
15
+ import { parseDeployBlock } from "../version-yml.js";
14
16
  import { log } from "../logger.js";
15
17
 
16
18
  // 원본 텍스트 로더 — context.branches가 있으면 {{MAIN_BRANCH}}/{{DEVELOP_BRANCH}} 치환 적용.
@@ -24,37 +26,49 @@ export function makeSrcText(branches, deployStyle = DEFAULT_DEPLOY_STYLE) {
24
26
  };
25
27
  }
26
28
 
27
- // trunk-based 모드에서 설치하지 않는 common 워크플로우 (DESIGN-SPEC §4 설치 매트릭스).
29
+ // trunk-based 모드에서 설치하지 않는 common 워크플로우.
28
30
  // 릴리스 PR 흐름이 없으므로 RELEASE-PUBLISH 하나가 bump→changelog→tag→Release를 흡수한다.
29
31
  const TRUNK_BASED_EXCLUDED = new Set([
30
32
  "PROJECT-COMMON-VERSION-CONTROL.yaml",
31
33
  "PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml",
32
34
  ]);
33
35
 
34
- // 타입 루트 디렉토리에 거는 파일 필터 — "배포 안 함"의 CD 제외와 Flutter 스토어 대상 선택을 합성한다.
36
+ // version.yml deploy 블록에 저장된 배포 값 — Map<type, Map<key,value>>.
37
+ // 설치·분류·baseline이 같은 값을 기본값으로 써야 재실행 때 가짜 변경이 생기지 않는다.
38
+ export function readSavedDeployValues(targetRoot = ".") {
39
+ const p = join(targetRoot, PATHS.versionFile);
40
+ if (!existsSync(p)) return new Map();
41
+ return parseDeployBlock(readFileSync(p, "utf8"));
42
+ }
43
+
44
+ // 타입 루트 디렉토리에 거는 파일 필터 — 배포 방식 필터와 Flutter 스토어 대상 선택을 합성한다.
35
45
  // copyWorkflowsForType·surveyWorkflows·planWorkflows가 같은 함수를 써야 설치·충돌 조사·status/dry-run이
36
46
  // 서로 다른 파일 집합을 보지 않는다. flutterStore가 배열이 아니면(null=미결정, 비대화형 기본) 스토어 필터는
37
47
  // 걸지 않는다. 필터가 없어도 항상 함수를 돌려준다 — processDir은 null을 받지 못한다.
38
- export function buildTypeRootFilter(type, deployStyle, flutterStore) {
48
+ // go/python·react/next처럼 서버 배포 워크플로우가 타입 루트에 바로 있는 타입도 있어 배포 방식 필터를
49
+ // 항상 건다. available(payload 파일명 집합)이 있어야 고른 방식이 없는 타입을 단일 서버 배포로 대체한다.
50
+ export function buildTypeRootFilter(type, deployStyle, flutterStore, available = null) {
39
51
  const filters = [];
40
- if (deployStyle === NO_DEPLOY_STYLE) filters.push(deployFilter(deployStyle));
52
+ if (deployStyle) filters.push(deployFilter(deployStyle, available));
41
53
  if (type === "flutter" && Array.isArray(flutterStore)) filters.push(storeWorkflowFilter(flutterStore));
42
54
  return (filename) => filters.every((keep) => keep(filename));
43
55
  }
44
56
 
45
- // 한 파일에 env 치환을 적용해 대상 파일을 갱신 (.sh configure_workflow_env 등가).
57
+ // 한 파일에 env 치환을 적용해 대상 파일을 갱신.
46
58
  // values/useDefaults: env 계획(promptEnvPlan) 결과 — 미지정이면 기본값 경로(현행 force 동작).
47
- function configureEnv(targetPath, { type, projectPath = ".", repoName = "", resolvers = {}, collectAsks = null, values = new Map(), useDefaults = true }) {
59
+ function configureEnv(targetPath, { type, projectPath = ".", repoName = "", resolvers = {}, collectAsks = null, values = new Map(), useDefaults = true, savedValues = null }) {
48
60
  const content = readFileSync(targetPath, "utf8");
49
61
  if (!content.includes("@wizard")) return;
50
- const out = substituteEnv(content, { type, useDefaults, values, projectPath, repoName, resolvers, collectAsks });
62
+ const out = substituteEnv(content, { type, useDefaults, values, projectPath, repoName, resolvers, collectAsks, savedValues });
51
63
  writeFileSync(targetPath, out);
52
64
  }
53
65
 
54
- // payload 원본을 "기본값으로 가상 치환한 최종형" — isUnchanged가 내부에서 쓰는 것과 같은 값이다.
66
+ // payload 원본을 "이번 실행이 쓸 값으로 가상 치환한 최종형" — 저장된 deploy 값(없으면 기본값)과
67
+ // 이번에 답한 값을 반영한다. 기본값만 쓰면 기본값이 아닌 값으로 설치한 파일이 재실행마다 변경으로
68
+ // 잡혀, 다시 쓰이면서 답한 값이 기본값으로 되돌아간다.
55
69
  // baseline.rendered와 비교해 "업스트림이 바뀌었는가"를 판정하는 데 쓴다.
56
70
  function renderVirtual(templateContent, envOpts) {
57
- return substituteEnv(templateContent, { ...envOpts, useDefaults: true });
71
+ return substituteEnv(templateContent, { useDefaults: true, ...envOpts, collectAsks: null });
58
72
  }
59
73
 
60
74
  // 분류 — 대상 워크플로우 디렉토리 기준. srcText: 브랜치 치환이 적용된 원본 로더 (makeSrcText).
@@ -107,7 +121,7 @@ function classify(srcDir, workflowsDir, envOpts, srcText, baseline = null, filte
107
121
  function processDir(srcDir, workflowsDir, envOpts, ctx, counters, filter = () => true) {
108
122
  const { srcText, baseline, decisions, restoreRemoved, baselineTargets } = ctx;
109
123
  const c = classify(srcDir, workflowsDir, envOpts, srcText, baseline);
110
- const track = (f, wrote) => baselineTargets.set(f, { srcPath: join(srcDir, f), envOpts, wrote });
124
+ const track = (f, wrote, keepRendered = false) => baselineTargets.set(f, { srcPath: join(srcDir, f), envOpts, wrote, keepRendered });
111
125
  const write = (f) => { writeText(join(workflowsDir, f), srcText(join(srcDir, f))); counters.copied++; counters.copiedFiles.push(f); track(f, true); };
112
126
 
113
127
  for (const f of c.unchanged.filter(filter)) {
@@ -147,12 +161,16 @@ function processDir(srcDir, workflowsDir, envOpts, ctx, counters, filter = () =>
147
161
  const decision = decisions.get(f);
148
162
  applyDecision(decision, srcDir, workflowsDir, f, counters, srcText);
149
163
  // 'backup'만 대상 파일 자체를 새로 쓴다. 'template'은 다른 파일명이고 'skip'은 기존 유지.
150
- track(f, decision === "backup");
164
+ // skip은 업스트림 변경을 받지 않은 것이라 rendered를 예전 값으로 둔다 — 새 값으로 바꾸면 다음 실행에서
165
+ // "업스트림 무변경"으로 분류되어 그 변경을 영영 받을 수 없다.
166
+ const kept = decision !== "backup" && decision !== "template";
167
+ if (kept) counters.conflictKept.push(f);
168
+ track(f, decision === "backup", kept);
151
169
  }
152
170
  return c;
153
171
  }
154
172
 
155
- // copy_workflows 본체 (동기 — 기존 호출부 무변경).
173
+ // 복사 엔진 본체 (동기 — 기존 호출부 무변경).
156
174
  // context: { types:[], paths:Map, force, repoName, resolvers,
157
175
  // envValues?:Map<key,value>, envUseDefaults?:boolean } ← env 계획(promptEnvPlan) 결과 주입점
158
176
  // flutterStore?:string[]|null } ← Flutter 스토어 대상 (null=필터 없음, 배열=선택된 플랫폼만)
@@ -177,13 +195,19 @@ export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}
177
195
  counters.keptLocal = []; // 질문 없이 사용자 수정본을 유지한 파일 (업스트림 무변경)
178
196
  counters.removedKept = []; // 사용자가 지웠고 되살리지 않은 파일
179
197
  counters.restoredFiles = []; // 사용자가 지웠지만 복원하기로 한 파일
198
+ counters.conflictKept = []; // 양쪽이 다 바뀌어 기존 파일을 유지한 파일 (업스트림 변경 미반영)
180
199
  const deployStyle = context.deployStyle || DEFAULT_DEPLOY_STYLE;
181
200
  const srcText = makeSrcText(context.branches || null, deployStyle);
182
201
  const baseline = readBaseline(targetRoot);
183
202
  const baselineTargets = new Map(); // filename -> { srcPath, envOpts, wrote }
184
203
  // values/useDefaults는 치환 경로에서만 의미 (renderVirtual은 useDefaults:true 강제 — 가상 비교 무손상)
185
- const envOptsFor = (type) => ({ type, projectPath: paths.get(type) || ".", repoName, resolvers, values: envValues, useDefaults: envUseDefaults });
186
- const dirCtx = { srcText, baseline, decisions, restoreRemoved, baselineTargets };
204
+ const saved = readSavedDeployValues(targetRoot);
205
+ const envOptsFor = (type) => ({
206
+ type, projectPath: paths.get(type) || ".", repoName, resolvers, values: envValues, useDefaults: envUseDefaults,
207
+ savedValues: saved.get(type) || null,
208
+ });
209
+ const available = payloadWorkflowNames(payloadRoot);
210
+ const dirCtx = { srcText, baseline, decisions, restoreRemoved, baselineTargets, available };
187
211
 
188
212
  // (1) common — 타입별과 동일 규칙 (README 계약).
189
213
  // trunk-based 모드는 VERSION-CONTROL·AUTO-CHANGELOG 미설치 (RELEASE-PUBLISH 단독).
@@ -208,7 +232,7 @@ export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}
208
232
  // (2~4) 타입별
209
233
  for (const type of types) {
210
234
  const asks = new Map();
211
- copyWorkflowsForType(type, projectTypesDir, workflowsDir, { ...context, deployStyle, envOptsFor, collectAsks: asks, dirCtx }, counters);
235
+ copyWorkflowsForType(type, payloadRoot, workflowsDir, { ...context, deployStyle, envOptsFor, collectAsks: asks, dirCtx }, counters);
212
236
  if (asks.size) deployValues.set(type, asks);
213
237
  }
214
238
 
@@ -218,12 +242,16 @@ export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}
218
242
 
219
243
  // copyWorkflows가 끝나고 env 치환까지 마친 뒤에 호출한다 — 그래야 디스크 내용이 최종형이다.
220
244
  // entries: Map<filename, {installed:string|null, rendered:string}>
221
- export function computeBaselineEntries(baselineTargets, workflowsDir, srcText) {
245
+ // savedDeploy: 이번 실행이 version.yml deploy 블록에 쓴 값(Map<type, Map<key,value>>). 다음 실행은 이 값을
246
+ // 기본값으로 분류하므로 rendered도 같은 값으로 계산해야 재실행이 "업스트림 변경"으로 오인되지 않는다.
247
+ export function computeBaselineEntries(baselineTargets, workflowsDir, srcText, savedDeploy = null) {
222
248
  const entries = new Map();
223
249
  for (const [filename, info] of baselineTargets) {
224
250
  const dst = join(workflowsDir, filename);
225
251
  if (!existsSync(dst)) continue;
226
- const rendered = sha256(renderVirtual(srcText(info.srcPath), info.envOpts));
252
+ const envOpts = savedDeploy ? { ...info.envOpts, savedValues: savedDeploy.get(info.envOpts.type) || null } : info.envOpts;
253
+ // keepRendered: 충돌로 기존 파일을 유지함 — 예전 rendered를 그대로 두도록 null로 넘긴다(writeBaseline이 병합).
254
+ const rendered = info.keepRendered ? null : sha256(renderVirtual(srcText(info.srcPath), envOpts));
227
255
  // installed는 이번에 우리가 쓴 파일에만 채운다. 사용자 수정본을 installed로 기록하면
228
256
  // "우리가 쓴 것"이라고 거짓말하는 셈이고, 다음 업데이트에서 그 파일이 조용히 덮인다.
229
257
  entries.set(filename, { installed: info.wrote ? sha256(readFileSync(dst, "utf8")) : null, rendered });
@@ -231,13 +259,13 @@ export function computeBaselineEntries(baselineTargets, workflowsDir, srcText) {
231
259
  return entries;
232
260
  }
233
261
 
234
- // changed(기존에 있고 내용이 바뀐) 파일 1개를 결정에 따라 처리 (.sh 3440~3508 3지선 case 등가).
262
+ // changed(기존에 있고 내용이 바뀐) 파일 1개를 결정에 따라 처리.
235
263
  // 'skip'(기본): 기존 유지. 'backup': 기존→.bak 후 교체. 'template': 기존 유지 + 새 버전을 .template.yaml로.
236
264
  function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcText) {
237
265
  const src = join(srcDir, filename);
238
266
  const dst = join(workflowsDir, filename);
239
267
  if (decision === "backup") {
240
- // .sh O) mv → cp: 기존을 .bak으로 백업 후 새 버전으로 교체
268
+ // 기존을 .bak으로 백업 후 새 버전으로 교체
241
269
  renameSync(dst, dst + ".bak");
242
270
  writeText(dst, srcText(src));
243
271
  counters.copied++;
@@ -247,16 +275,19 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcTe
247
275
  return;
248
276
  }
249
277
  if (decision === "template") {
250
- // .sh T) `${filename%.yaml}.template.yaml` — .yaml만 strip (.yml은 그대로 뒤에 붙음, .sh 동일)
278
+ // `${이름}.template.yaml` — .yaml만 떼고 붙인다 (.yml은 그대로 뒤에 붙는다)
251
279
  const templateName = (filename.endsWith(".yaml") ? filename.slice(0, -".yaml".length) : filename) + ".template.yaml";
252
- writeText(join(workflowsDir, templateName), srcText(src)); // 기존 .template.yaml 덮어씀(.sh rm -f + cp 등가)
280
+ writeText(join(workflowsDir, templateName), srcText(src)); // 기존 .template.yaml 덮어씀
253
281
  counters.templateAdded++;
254
282
  counters.copiedFiles.push(templateName);
255
283
  log.info("copy", "template", `${filename} 유지 + ${templateName} 생성 (사용자 결정)`);
256
284
  return;
257
285
  }
258
- counters.skipped++; // 'skip'/미지정/ESC → 기존 유지 (.sh S)·force 기본)
259
- log.info("copy", "skip", `${filename} (사용자 결정: 기존 유지)`);
286
+ counters.skipped++; // 'skip'/미지정/ESC → 기존 유지 (force 기본)
287
+ // 결정이 없으면 사용자가 고른 게 아니라 --force 기본값이다 — 로그가 사실과 달라지지 않게 구분한다.
288
+ log.info("copy", "skip", decision
289
+ ? `${filename} (사용자 결정: 기존 유지, 업스트림 변경 미반영)`
290
+ : `${filename} (--force 기본값: 기존 유지, 업스트림 변경 미반영)`);
260
291
  }
261
292
 
262
293
  // 대화형 사전 조사 — 사람이 답해야 하는 것만 뽑는다.
@@ -273,10 +304,12 @@ export function surveyWorkflows(context, payloadRoot, targetRoot = ".") {
273
304
  const srcText = makeSrcText(context.branches || null, deployStyle);
274
305
  const baseline = readBaseline(targetRoot);
275
306
  const branchMode = context.branches?.mode || "pr-flow";
307
+ const saved = readSavedDeployValues(targetRoot);
276
308
  const conflicts = []; // 엔진 처리 순서와 동일 (common → 타입 순회 → 직하위 → server-deploy)
277
309
  const removed = [];
278
310
 
279
- const keepDeploy = deployFilter(deployStyle);
311
+ const available = payloadWorkflowNames(payloadRoot);
312
+ const keepDeploy = deployFilter(deployStyle, available);
280
313
  const collect = (srcDir, envOpts, type, skipFile = () => false, filter = null) => {
281
314
  const c = classify(srcDir, workflowsDir, envOpts, srcText, baseline, filter);
282
315
  for (const f of c.changed) { if (!skipFile(f)) conflicts.push({ filename: f, type }); }
@@ -285,17 +318,23 @@ export function surveyWorkflows(context, payloadRoot, targetRoot = ".") {
285
318
 
286
319
  const commonDir = join(projectTypesDir, "common");
287
320
  if (exists(commonDir)) {
288
- collect(commonDir, { type: "common", projectPath: ".", repoName, resolvers }, "common",
321
+ collect(commonDir, {
322
+ type: "common", projectPath: ".", repoName, resolvers,
323
+ values: context.envValues || new Map(), useDefaults: context.envUseDefaults !== false,
324
+ }, "common",
289
325
  (f) => branchMode === "trunk-based" && TRUNK_BASED_EXCLUDED.has(f));
290
326
  }
291
327
 
292
328
  for (const type of types) {
293
- const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers };
294
- const typeDir = join(projectTypesDir, type);
329
+ // 복사 엔진과 같은 값(이번 답변 포함)으로 분류해야 결정 목록이 실제 처리 대상과 맞는다.
330
+ const envOpts = {
331
+ type, projectPath: paths.get(type) || ".", repoName, resolvers, savedValues: saved.get(type) || null,
332
+ values: context.envValues || new Map(), useDefaults: context.envUseDefaults !== false,
333
+ };
334
+ const [typeDir, serverDeployDir] = typeWorkflowDirs(payloadRoot, type);
295
335
  if (exists(typeDir)) {
296
- collect(typeDir, envOpts, type, () => false, buildTypeRootFilter(type, deployStyle, flutterStore));
336
+ collect(typeDir, envOpts, type, () => false, buildTypeRootFilter(type, deployStyle, flutterStore, available));
297
337
  }
298
- const serverDeployDir = join(typeDir, "server-deploy");
299
338
  if (exists(serverDeployDir) && deployStyle !== NO_DEPLOY_STYLE) {
300
339
  collect(serverDeployDir, envOpts, type, () => false, keepDeploy);
301
340
  }
@@ -323,27 +362,25 @@ export async function copyWorkflowsInteractive(context, payloadRoot, targetRoot
323
362
  return copyWorkflows(context, payloadRoot, targetRoot, { decisions });
324
363
  }
325
364
 
326
- function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters) {
365
+ function copyWorkflowsForType(type, payloadRoot, workflowsDir, ctx, counters) {
327
366
  const { deployStyle = "", flutterStore = null, envOptsFor, collectAsks = null, dirCtx } = ctx;
328
- const keepDeploy = deployFilter(deployStyle);
329
- const keepTypeRoot = buildTypeRootFilter(type, deployStyle, flutterStore);
367
+ const keepDeploy = deployFilter(deployStyle, dirCtx.available);
368
+ const keepTypeRoot = buildTypeRootFilter(type, deployStyle, flutterStore, dirCtx.available);
330
369
  const { srcText, baselineTargets } = dirCtx;
331
- const typeDir = join(projectTypesDir, type);
370
+ const [typeDir, serverDeployDir] = typeWorkflowDirs(payloadRoot, type);
332
371
  const envOpts = envOptsFor(type);
333
372
  // env 치환에서 제외할 파일 — 손대지 않기로 한 것들(unchanged/localOnly/유지된 삭제분)에
334
373
  // 치환을 다시 걸면 사용자 수정본을 덮어쓰게 된다.
335
374
  const untouched = [];
336
375
 
337
- // 타입별 워크플로우 (직하위). go/python처럼 CD 워크플로우가 server-deploy 없이 타입 루트에
338
- // 바로 있는 타입도 있다 — "배포 안 함"일 때는 타입 루트에서도 CD 파일(SIMPLE-CICD 등)을
339
- // 걸러야 한다. simple/nginx/traefik은 오늘과 동일하게 필터 없이 전부 복사한다.
376
+ // 타입별 워크플로우 (직하위). go/python·react/next처럼 서버 배포 워크플로우가 server-deploy 없이
377
+ // 타입 루트에 바로 있는 타입도 있어, 타입 루트에도 같은 배포 방식 필터를 건다.
340
378
  if (exists(typeDir)) {
341
379
  const c = processDir(typeDir, workflowsDir, envOpts, dirCtx, counters, keepTypeRoot);
342
380
  untouched.push(...c.unchanged, ...c.localOnly);
343
381
  }
344
382
 
345
383
  // server-deploy
346
- const serverDeployDir = join(typeDir, "server-deploy");
347
384
  // "배포 안 함"이면 폴더째 제외 (복사 안 함)
348
385
  if (exists(serverDeployDir) && deployStyle !== NO_DEPLOY_STYLE) {
349
386
  const c = processDir(serverDeployDir, workflowsDir, envOpts, dirCtx, counters, keepDeploy);
@@ -359,7 +396,12 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
359
396
  if (srcDir === serverDeployDir && !keepDeploy(filename)) continue; // 안 고른 배포 방식
360
397
  if (srcDir === typeDir && !keepTypeRoot(filename)) continue; // 타입 루트: 배제된 CD·안 고른 스토어 워크플로우
361
398
  if (!existsSync(target)) continue; // 건너뛴 파일 제외
362
- if (untouched.includes(filename)) continue; // unchanged/localOnly 제외
399
+ // unchanged/localOnly와 치환 마커가 이미 없는 유지본은 다시 치환하지 않는다. 대신 deploy 블록이
400
+ // 사라지지 않도록 저장값(없으면 기본값)으로 ask 값만 수집한다 — 파일은 건드리지 않는다.
401
+ if (untouched.includes(filename) || !readFileSync(target, "utf8").includes("@wizard")) {
402
+ if (collectAsks) substituteEnv(srcText(join(srcDir, filename)), { ...envOpts, useDefaults: true, collectAsks });
403
+ continue;
404
+ }
363
405
  configureEnv(target, { ...envOpts, collectAsks }); // env 계획 values/useDefaults 포함
364
406
  }
365
407
  }
@@ -375,6 +417,8 @@ export function planWorkflows(context, payloadRoot, targetRoot = ".") {
375
417
  const deployStyle = context.deployStyle || DEFAULT_DEPLOY_STYLE;
376
418
  const srcText = makeSrcText(context.branches || null, deployStyle);
377
419
  const baseline = readBaseline(targetRoot);
420
+ const saved = readSavedDeployValues(targetRoot);
421
+ const available = payloadWorkflowNames(payloadRoot);
378
422
  const branchMode = context.branches?.mode || "pr-flow";
379
423
  // upstreamOnly/localOnly/removed는 baseline이 있을 때만 채워진다.
380
424
  const plan = { newFiles: [], unchanged: [], changed: [], upstreamOnly: [], localOnly: [], removed: [] };
@@ -397,15 +441,14 @@ export function planWorkflows(context, payloadRoot, targetRoot = ".") {
397
441
  }
398
442
 
399
443
  for (const type of types) {
400
- const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers };
401
- const typeDir = join(projectTypesDir, type);
444
+ const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers, savedValues: saved.get(type) || null };
445
+ const [typeDir, serverDeployDir] = typeWorkflowDirs(payloadRoot, type);
402
446
  if (exists(typeDir)) {
403
- merge(classify(typeDir, workflowsDir, envOpts, srcText, baseline, buildTypeRootFilter(type, deployStyle, flutterStore)), type);
447
+ merge(classify(typeDir, workflowsDir, envOpts, srcText, baseline, buildTypeRootFilter(type, deployStyle, flutterStore, available)), type);
404
448
  }
405
449
 
406
- const serverDeployDir = join(typeDir, "server-deploy");
407
450
  if (exists(serverDeployDir) && deployStyle !== NO_DEPLOY_STYLE) {
408
- merge(classify(serverDeployDir, workflowsDir, envOpts, srcText, baseline, deployFilter(deployStyle)), type);
451
+ merge(classify(serverDeployDir, workflowsDir, envOpts, srcText, baseline, deployFilter(deployStyle, available)), type);
409
452
  }
410
453
  }
411
454
 
@@ -2,8 +2,10 @@
2
2
  // Nginx 무중단과 Traefik 무중단을 동시에 쓰는 경우는 없으므로 하나만 설치한다.
3
3
  // 고른 것은 push 트리거까지 켜서 설치한다 — 설치했는데 안 도는 상태를 만들지 않는다.
4
4
  import { join } from "node:path";
5
- import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
5
+ import { existsSync, readFileSync, readdirSync, renameSync, rmSync } from "node:fs";
6
6
  import { sha256 } from "./baseline.js";
7
+ import { PAYLOAD, typeWorkflowDirs } from "./paths.js";
8
+ import { SINGLE_SERVER_CD_FILES } from "./types.js";
7
9
 
8
10
  // 파일명 접미사로 식별한다 — 타입 접두사(PROJECT-SPRING- 등)는 타입마다 다르기 때문.
9
11
  export const DEPLOY_STYLES = [
@@ -25,17 +27,81 @@ export const isDeployStyle = (v) => v === NO_DEPLOY_STYLE || DEPLOY_STYLES.some(
25
27
  // 이 파일이 CD 본체인가 (= 택1 대상인가). PR 프리뷰는 배포 방식과 직교하는 축이라 제외한다.
26
28
  export const isDeployWorkflow = (filename) => DEPLOY_STYLES.some((s) => filename.endsWith(s.suffix));
27
29
 
30
+ const PREVIEW_SUFFIX = "-PR-PREVIEW.yaml";
31
+
32
+ // 서버에 배포하는 워크플로우 전부 — CD 본체, 단일 CD(react·next), PR 프리뷰.
33
+ // "배포 안 함"은 이 전부를 모든 타입에서 똑같이 뺀다. 일부만 빼면 서버 Secret 요구가 남는다.
34
+ export const isServerDeployWorkflow = (filename) =>
35
+ isDeployWorkflow(filename) || SINGLE_SERVER_CD_FILES.has(filename) || filename.endsWith(PREVIEW_SUFFIX);
36
+
37
+ // 선택한 타입 중 서버 배포 워크플로우를 가진 타입이 있는가 — payload 파일로 판정한다.
38
+ // 없으면(node·flutter 등) 배포 방식은 설치 결과에 아무 영향이 없으므로 묻지도 기록하지도 않는다.
39
+ export function hasServerDeployWorkflows(payloadRoot, types = []) {
40
+ return types.some((type) => typeWorkflowDirs(payloadRoot, type)
41
+ .some((dir) => existsSync(dir) && readdirSync(dir).some(isServerDeployWorkflow)));
42
+ }
43
+
44
+ // 선택한 타입 중 무중단(nginx·traefik) 워크플로우가 있는 타입이 있는가 — 없으면 무중단 선택지를 보이지 않는다.
45
+ export function hasNonstopWorkflows(payloadRoot, types = []) {
46
+ const nonstop = DEPLOY_STYLES.filter((s) => s.value !== DEFAULT_DEPLOY_STYLE).map((s) => s.suffix);
47
+ return types.some((type) => typeWorkflowDirs(payloadRoot, type)
48
+ .some((dir) => existsSync(dir) && readdirSync(dir).some((f) => nonstop.some((sfx) => f.endsWith(sfx)))));
49
+ }
50
+
51
+ // payload/workflows/** 의 워크플로우 파일명 전체. 파일명은 PROJECT-{TYPE}- 접두사로 타입 간 유일하다.
52
+ export function payloadWorkflowNames(payloadRoot) {
53
+ const names = new Set();
54
+ const root = join(payloadRoot, PAYLOAD.workflowsDir);
55
+ if (!existsSync(root)) return names;
56
+ for (const e of readdirSync(root, { recursive: true, withFileTypes: true })) {
57
+ if (e.isFile() && /\.ya?ml$/.test(e.name)) names.add(e.name);
58
+ }
59
+ return names;
60
+ }
61
+
28
62
  // 모르는 값은 기본값으로 수렴시킨다. 빈 접미사를 돌려주면 endsWith("")가 항상 참이라
29
63
  // "전부 통과"가 되어, 잘못된 값이 조용히 CD 전부 설치로 새어나간다.
30
64
  const suffixOf = (style) =>
31
65
  (DEPLOY_STYLES.find((s) => s.value === style) ?? DEPLOY_STYLES.find((s) => s.value === DEFAULT_DEPLOY_STYLE)).suffix;
66
+ const SIMPLE_SUFFIX = suffixOf(DEFAULT_DEPLOY_STYLE);
67
+
68
+ // 고른 방식의 CD가 없는 타입의 단일 서버 배포 CD인가 (예: nginx를 골랐는데 python·go에는 NONSTOP이 없다).
69
+ // 그런 타입은 단일 서버 배포로 대신 설치한다 — 설치 직후 지우거나 .bak으로 옮기면 배포가 사라진다.
70
+ function isFallbackSimple(filename, style, available) {
71
+ if (!available || !filename.endsWith(SIMPLE_SUFFIX)) return false;
72
+ const prefix = filename.slice(0, -SIMPLE_SUFFIX.length);
73
+ return !available.has(prefix + suffixOf(style));
74
+ }
32
75
 
33
76
  // 파일 필터 — 고른 방식의 CD만 통과. CD가 아닌 파일(PR 프리뷰·common 등)은 항상 통과.
34
- // "none"은 CD를 하나도 설치하지 않으므로 접미사 매칭 없이 CD 파일 전부를 거른다.
35
- export function deployFilter(style) {
36
- if (style === NO_DEPLOY_STYLE) return (filename) => !isDeployWorkflow(filename);
77
+ // "none"은 서버 배포 워크플로우(CD·단일 CD·PR 프리뷰)를 하나도 설치하지 않는다.
78
+ // available: payload 워크플로우 파일명 집합(payloadWorkflowNames). 주면 고른 방식이 없는 타입은
79
+ // 단일 서버 배포로 대체한다. 없으면 접미사만으로 가른다.
80
+ export function deployFilter(style, available = null) {
81
+ if (style === NO_DEPLOY_STYLE) return (filename) => !isServerDeployWorkflow(filename);
37
82
  const suffix = suffixOf(style);
38
- return (filename) => !isDeployWorkflow(filename) || filename.endsWith(suffix);
83
+ return (filename) => !isDeployWorkflow(filename) || filename.endsWith(suffix)
84
+ || isFallbackSimple(filename, style, available);
85
+ }
86
+
87
+ // 고른 방식(nginx·traefik)의 워크플로우가 없어 단일 서버 배포로 대신 설치하는 타입 목록 — 안내용.
88
+ export function fallbackStyleTypes(payloadRoot, types = [], style) {
89
+ if (!style || style === NO_DEPLOY_STYLE || suffixOf(style) === SIMPLE_SUFFIX) return [];
90
+ return types.filter((type) => {
91
+ const files = typeWorkflowDirs(payloadRoot, type)
92
+ .filter((dir) => existsSync(dir)).flatMap((dir) => readdirSync(dir));
93
+ return files.some(isServerDeployWorkflow) && !files.some((f) => f.endsWith(suffixOf(style)));
94
+ });
95
+ }
96
+
97
+ // 실제로 설치되는 배포 방식 — 고른 무중단 방식의 워크플로우가 선택한 타입 어디에도 없으면 전부 단일 서버
98
+ // 배포로 대신 설치되므로 simple이다. 고른 값을 그대로 기록하면 version.yml·status가 설치 상태와 어긋나고,
99
+ // 다음 실행도 그 값을 기본값으로 이어받는다. 한 타입이라도 그 방식이 있으면 고른 값을 유지한다.
100
+ export function effectiveDeployStyle(payloadRoot, types = [], style) {
101
+ if (!style || style === NO_DEPLOY_STYLE || suffixOf(style) === SIMPLE_SUFFIX) return style;
102
+ const installed = types.some((type) => typeWorkflowDirs(payloadRoot, type)
103
+ .some((dir) => existsSync(dir) && readdirSync(dir).some((f) => f.endsWith(suffixOf(style)))));
104
+ return installed ? style : DEFAULT_DEPLOY_STYLE;
39
105
  }
40
106
 
41
107
  // 무중단 템플릿은 push 트리거가 주석 처리된 채 들어 있다(기본 배포가 단일 서버라서).
@@ -73,24 +139,30 @@ export function activateDeployTrigger(content) {
73
139
  // 손대지 않은 것(baseline의 installed 해시와 동일) → 삭제. 물어볼 이유가 없다.
74
140
  // 손댄 것 → .bak으로 옮긴다. 내용은 지키고 트리거만 죽인다.
75
141
  //
142
+ // opts.available — payload 워크플로우 파일명 집합. 주면 그 안의 파일(마법사가 까는 파일)만 정리하고,
143
+ // 고른 방식이 없는 타입의 단일 서버 배포는 남긴다.
144
+ // opts.justWritten — 이번 실행에서 마법사가 방금 쓴 파일. 첫 설치처럼 baseline이 없어도 수정본으로 오인하지 않는다.
145
+ // opts.dryRun — 판정만 하고 파일은 건드리지 않는다. --dry-run 미리보기가 실제 실행과 같은 판정을 쓰게 한다.
76
146
  // 반환: { removed:[], backedUp:[] } — 완료 화면·설치 기록에 그대로 보고한다.
77
- export function cleanupOtherDeployWorkflows(workflowsDir, installedFilenames, style, baseline) {
78
- const keep = deployFilter(style);
147
+ export function cleanupOtherDeployWorkflows(workflowsDir, installedFilenames, style, baseline, { available = null, justWritten = [], dryRun = false } = {}) {
148
+ const keep = deployFilter(style, available);
149
+ const written = new Set(justWritten);
79
150
  const removed = [];
80
151
  const backedUp = [];
81
152
 
82
153
  for (const filename of installedFilenames) {
83
- if (!isDeployWorkflow(filename) || keep(filename)) continue;
154
+ if (!isServerDeployWorkflow(filename) || keep(filename)) continue;
155
+ if (available && !available.has(filename)) continue; // 사용자가 만든 비슷한 이름의 워크플로우는 건드리지 않는다
84
156
  const p = join(workflowsDir, filename);
85
157
  if (!existsSync(p)) continue;
86
158
 
87
159
  const known = baseline?.files?.[filename]?.installed;
88
- const untouched = known && sha256(readFileSync(p, "utf8")) === known;
160
+ const untouched = written.has(filename) || (known && sha256(readFileSync(p, "utf8")) === known);
89
161
  if (untouched) {
90
- rmSync(p, { force: true });
162
+ if (!dryRun) rmSync(p, { force: true });
91
163
  removed.push(filename);
92
164
  } else {
93
- renameSync(p, `${p}.bak`);
165
+ if (!dryRun) renameSync(p, `${p}.bak`);
94
166
  backedUp.push(filename);
95
167
  }
96
168
  }