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,25 +1,22 @@
1
- // @wizard env 계획 질문 UI (.sh wf_scope_string/wf_collect_asks/_wf_print_field_card/
2
- // _wf_prefill_all/_wf_prefill_interactive/wf_prompt_env_plan 등가).
3
- // 실측 기준: template_integrator.sh 3059~3085(scope), 3087~3143(collect), 3152~3169(card),
4
- // 3220~3280(prompt 본체 wf_prompt_env_plan).
1
+ // @wizard env 계획 질문 UI — ask 키 수집, 필드 카드 출력, 전부 기본값/일부만 변경 선택.
5
2
  // io 주입식 — 테스트는 {select, multiselect, text} 스텁을 넘긴다. 기본은 readline-engine 실물.
6
3
  import { join } from "node:path";
7
4
  import { readFileSync } from "node:fs";
8
5
  import { stdin, stderr } from "node:process";
9
- import { PAYLOAD } from "../core/paths.js";
6
+ import { PAYLOAD, typeWorkflowDirs } from "../core/paths.js";
10
7
  import { exists, listYamlFiles } from "../core/fsutil.js";
11
8
  import { parseWizardLine, resolveToken, replaceProjectTokens } from "../core/wizard-env.js";
12
9
  import { loadWizardPrompts, wfField, workflowDisplayName } from "../core/wizard-labels.js";
13
- import { deployFilter, NO_DEPLOY_STYLE } from "../core/deploy-style.js";
14
- import { buildTypeRootFilter } from "../core/copy/workflows.js";
10
+ import { deployFilter, payloadWorkflowNames, NO_DEPLOY_STYLE } from "../core/deploy-style.js";
11
+ import { buildTypeRootFilter, readSavedDeployValues } from "../core/copy/workflows.js";
15
12
  import * as engine from "./readline-engine.js";
16
13
 
17
14
  const CANCEL = engine.CANCEL;
18
15
 
19
- // 진행 안내는 stderr로 출력 (.sh print_to_user가 >&2인 것과 동일 — stdout 파이프 오염 방지).
16
+ // 진행 안내는 stderr로 출력 (stdout 파이프 오염 방지).
20
17
  const defaultLog = (s = "") => stderr.write(s + "\n");
21
18
 
22
- // 사용처 문자열 조립 (.sh wf_scope_string 등가).
19
+ // 사용처 문자열 조립.
23
20
  // usages: [{type, workflowName}] — 타입이 여러 개면 타입만 "t1·t2", 하나면 "타입 name1·name2".
24
21
  export function scopeString(usages = []) {
25
22
  const types = []; const names = [];
@@ -31,18 +28,21 @@ export function scopeString(usages = []) {
31
28
  return `${types.join("·")} ${names.join("·")}`.trim();
32
29
  }
33
30
 
34
- // ask KEY 수집 (.sh wf_collect_asks 등가) — 실제 설치되는 워크플로우와 같은 소스를 스캔한다.
31
+ // ask KEY 수집 — 실제 설치되는 워크플로우와 같은 소스를 스캔한다.
35
32
  // payloadRoot: 패키지 payload/ 루트. types: 설치 대상 타입 목록.
36
33
  // opts:
37
- // resolvers - @접두 기본값(@repo 등) 해석용 (.sh는 수집 시점에 resolve_token — 동일)
34
+ // resolvers - @접두 기본값(@repo 등) 해석용 (수집 시점에 해석)
38
35
  // flutterStore - Flutter 스토어 대상(string[]|null). 배열이면 선택 해제된 스토어 워크플로우는 스캔에서 제외 (null=현행)
39
36
  // prompts - wizard-labels 파싱 객체 (워크플로우 표시명용, null이면 확장자 제거 폴백)
37
+ // saved - Map<type, Map<key,value>>: version.yml deploy 블록 저장값. 있으면 그 값을 기본값으로 보여준다
38
+ // (업데이트 때 "기본값 그대로"를 고르면 설치 때 답한 값이 유지되어야 한다)
40
39
  // 반환: { keys:[], defaults:Map<key,default>, typeDefaults:Map<"type|key",default>,
41
40
  // usages:Map<key,[{type,workflowName}]> }
42
41
  export function collectAsks(payloadRoot, types = [], opts = {}) {
43
- const { resolvers = {}, deployStyle = "", flutterStore = null, prompts = null } = opts;
42
+ const { resolvers = {}, deployStyle = "", flutterStore = null, prompts = null, saved = new Map() } = opts;
44
43
  // 설치하지 않을 배포 워크플로우의 질문까지 묻지 않는다 — 질문 수는 설치 범위를 따라간다.
45
- const keepDeploy = deployFilter(deployStyle);
44
+ const available = payloadWorkflowNames(payloadRoot);
45
+ const keepDeploy = deployFilter(deployStyle, available);
46
46
  const baseDir = join(payloadRoot, PAYLOAD.workflowsDir);
47
47
  const keys = [];
48
48
  const defaults = new Map();
@@ -55,19 +55,15 @@ export function collectAsks(payloadRoot, types = [], opts = {}) {
55
55
  const commonDir = join(baseDir, "common");
56
56
  if (exists(commonDir)) units.push(["common", commonDir, null]);
57
57
  for (const type of types) {
58
- const typeDir = join(baseDir, type);
58
+ const [typeDir, serverDeployDir] = typeWorkflowDirs(payloadRoot, type);
59
59
  if (!exists(typeDir)) continue;
60
- // 복사 엔진과 동일한 폴더 구성: 타입 직하위 + ("배포 안 함"이
61
- // 아닐 때만) server-deploy. server-deploy가 있는 타입은 "none"일 때 그 폴더(PR 프리뷰
62
- // 포함) 자체를 스캔에서 뺀다.
63
- // go/python처럼 CD가 server-deploy 없이 타입 루트에 바로 있는 타입은, "배포 안 함"일 때
64
- // 타입 루트 스캔에도 keepDeploy를 걸어야 CD 전용 ask 키(예: DEPLOY_PORT)가 걸러진다.
65
- // PR 프리뷰 자체의 ask 키(SSH_AUTH_METHOD 등)는 이 필터로는 걸러지지 않는다 — PR 프리뷰는
66
- // 배포 방식과 무관하게 항상 설치되는 별도 축이라 의도된 잔여 범위다.
60
+ // 복사 엔진과 동일한 폴더 구성: 타입 직하위 + ("배포 안 함"이 아닐 때만) server-deploy.
61
+ // go/python·react/next처럼 서버 배포 워크플로우가 타입 루트에 바로 있는 타입도 같은 배포 방식
62
+ // 필터로 거른다 — "배포 안 함"이면 CD·PR 프리뷰 전용 ask 키(DEPLOY_PORT, SSH_AUTH_METHOD 등)를 묻지 않는다.
67
63
  // Flutter는 선택 해제된 스토어 워크플로우(PLAYSTORE·TESTFLIGHT)도 같은 필터로 걸러 질문 범위가 설치 범위와 같다.
68
- units.push([type, typeDir, buildTypeRootFilter(type, deployStyle, flutterStore)]);
64
+ units.push([type, typeDir, buildTypeRootFilter(type, deployStyle, flutterStore, available)]);
69
65
  if (deployStyle !== NO_DEPLOY_STYLE) {
70
- units.push([type, join(typeDir, "server-deploy"), keepDeploy]);
66
+ units.push([type, serverDeployDir, keepDeploy]);
71
67
  }
72
68
  }
73
69
 
@@ -79,16 +75,19 @@ export function collectAsks(payloadRoot, types = [], opts = {}) {
79
75
  if (!content.includes("@wizard")) continue;
80
76
  const workflowName = workflowDisplayName(prompts, filename);
81
77
  for (const line of content.split(/\r?\n/)) {
82
- const p = parseWizardLine(line); // KEY 정규식 [A-Z_]+ (.sh와 동일)
78
+ const p = parseWizardLine(line);
83
79
  if (!p || p.action !== "ask") continue;
84
- // 타입별 기본값: @접두면 resolver 해석, 아니면 리터럴 (.sh _type_default 등가)
80
+ // 타입별 기본값: @접두면 resolver 해석, 아니면 리터럴
85
81
  const rawDefault = p.arg.startsWith("@")
86
82
  ? resolveToken(p.arg.slice(1), type, resolvers)
87
83
  : p.arg;
88
84
  // 리터럴 기본값 안에 __PROJECT_NAME__ 등이 박혀 있으면실제 repoName으로
89
85
  // 풀어준다 — substituteEnv()가 설치 파일에 적용하는 것과 동일한 치환이라야 마법사
90
86
  // 화면 표시와 실제 설치 결과가 어긋나지 않는다.
91
- const typeDefault = replaceProjectTokens(rawDefault, resolveToken("repo", type, resolvers));
87
+ const savedValue = saved.get(type)?.get(p.key);
88
+ const typeDefault = savedValue != null && savedValue !== ""
89
+ ? savedValue
90
+ : replaceProjectTokens(rawDefault, resolveToken("repo", type, resolvers));
92
91
  typeDefaults.set(`${type}|${p.key}`, typeDefault);
93
92
  if (!defaults.has(p.key)) { keys.push(p.key); defaults.set(p.key, typeDefault); }
94
93
  const list = usages.get(p.key) || [];
@@ -100,7 +99,7 @@ export function collectAsks(payloadRoot, types = [], opts = {}) {
100
99
  return { keys, defaults, typeDefaults, usages };
101
100
  }
102
101
 
103
- // KEY가 처음 등장한 type (.sh _wf_first_type_for — 라벨 조회 시 타입 오버라이드 우선순위용)
102
+ // KEY가 처음 등장한 type — 라벨 조회 시 타입 오버라이드 우선순위용
104
103
  function firstTypeFor(usages, key) {
105
104
  return usages.get(key)?.[0]?.type ?? "";
106
105
  }
@@ -122,7 +121,7 @@ function buildAnswers(prompts, asks, values, useDefaults) {
122
121
  });
123
122
  }
124
123
 
125
- // KEY 1개를 'label·사용처·설명·예시·기본값' 카드로 출력 (.sh _wf_print_field_card 등가).
124
+ // KEY 1개를 'label·사용처·설명·예시·기본값' 카드로 출력.
126
125
  // info: { default, usages } — idx/tot 있으면 "(i/t)" 진행 표시. log 주입 가능(테스트 무음화).
127
126
  export function printFieldCard(prompts, key, info, idx = null, tot = null, log = defaultLog) {
128
127
  const t = info.usages?.[0]?.type ?? "";
@@ -146,8 +145,25 @@ function isBooleanDefault(value) {
146
145
  return value === "true" || value === "false";
147
146
  }
148
147
 
149
- // 지정 KEY들을 하나씩 입력받아 values에 기록 (.sh _wf_prefill_interactive 등가).
150
- // 빈 입력(Enter)/ESC → KEY 공통 기본값 유지 (.sh safe_read || _in="" 등가).
148
+ // 형식이 정해진 ask 값 검증 — 잘못된 값은 설치는 통과하고 배포 단계에서야 실패하므로 입력 시점에 다시 묻는다.
149
+ // 반환: 오류 문구(문제 없으면 "").
150
+ export function validateAskValue(key, value) {
151
+ const v = String(value);
152
+ if (/_PORT$/.test(key)) {
153
+ const n = Number(v);
154
+ return /^\d+$/.test(v) && n >= 1 && n <= 65535 ? "" : "1~65535 사이의 숫자로 입력하세요.";
155
+ }
156
+ if (key === "SSH_AUTH_METHOD") {
157
+ return v === "password" || v === "key" ? "" : "password 또는 key 중 하나로 입력하세요.";
158
+ }
159
+ if (key === "JAVA_VERSION") {
160
+ return /^\d+(\.\d+)*$/.test(v) ? "" : "JDK 버전 숫자로 입력하세요 (예: 21, 17).";
161
+ }
162
+ return "";
163
+ }
164
+
165
+ // 지정 KEY들을 하나씩 입력받아 values에 기록.
166
+ // 빈 입력(Enter)/ESC → KEY 공통 기본값 유지.
151
167
  async function promptEach(io, prompts, asks, todoKeys, values, log) {
152
168
  const tot = todoKeys.length;
153
169
  if (tot === 0) return;
@@ -165,8 +181,15 @@ async function promptEach(io, prompts, asks, todoKeys, values, log) {
165
181
  const answer = await io.confirm({ message: `↳ ${label} — 활성화할까요?`, initialValue: def === "true" });
166
182
  input = answer === CANCEL ? def : (answer ? "true" : "false");
167
183
  } else {
168
- input = await io.text({ message: `↳ 값 입력 (Enter=기본값 «${def}» 유지):`, defaultValue: def });
169
- if (input === CANCEL || input == null || input === "") input = def;
184
+ for (;;) {
185
+ input = await io.text({ message: `↳ 값 입력 (Enter=기본값 «${def}» 유지):`, defaultValue: def });
186
+ if (input === CANCEL || input == null) { input = def; break; }
187
+ input = String(input).trim();
188
+ if (input === "") { input = def; break; }
189
+ const problem = validateAskValue(key, input);
190
+ if (!problem) break;
191
+ log(` ⚠️ '${input}' — ${problem}`);
192
+ }
170
193
  }
171
194
  values.set(key, input);
172
195
  log(` → ${label} = ${input}`);
@@ -174,15 +197,15 @@ async function promptEach(io, prompts, asks, todoKeys, values, log) {
174
197
  }
175
198
  }
176
199
 
177
- // 배포 env 설정 계획 (.sh wf_prompt_env_plan 등가).
200
+ // 배포 env 설정 계획.
178
201
  // 반환: { values: Map<key,value>, useDefaults: boolean, answers: [{key,label,value,isDefault,scope}] }
179
- // - useDefaults=true → 호출부는 substituteEnv에 그대로 넘기면 타입별 기본값 경로(.sh _wf_prefill_all 등가)
202
+ // - useDefaults=true → 호출부는 substituteEnv에 그대로 넘기면 타입별 기본값 경로
180
203
  // - useDefaults=false → values에 담긴 키만 사용자 확정값으로 치환, 나머지는 기본값
181
204
  // (⚠️ substituteEnv는 useDefaults=false일 때만 values를 참조하므로 이 플래그를 반드시 함께 전달)
182
205
  // 인자:
183
206
  // payloadRoot/types/resolvers/flutterStore — collectAsks와 동일 의미
184
207
  // targetRoot — wizard-prompts.yml 1차 탐색 위치(기본 ".")
185
- // force — true면 질문 없이 전부 기본값 (.sh FORCE_MODE 등가)
208
+ // force — true면 질문 없이 전부 기본값
186
209
  // io — {select, multiselect, text} 주입 (기본 readline-engine). 테스트 스텁 지점.
187
210
  // log — 카드·안내 출력 함수 주입 (기본 stderr)
188
211
  export async function promptEnvPlan({
@@ -190,13 +213,14 @@ export async function promptEnvPlan({
190
213
  deployStyle = "", flutterStore = null, targetRoot = ".", repoName = "", log = defaultLog,
191
214
  } = {}) {
192
215
  const prompts = loadWizardPrompts(targetRoot, payloadRoot);
193
- const asks = collectAsks(payloadRoot, types, { resolvers, deployStyle, flutterStore, prompts });
216
+ const saved = readSavedDeployValues(targetRoot);
217
+ const asks = collectAsks(payloadRoot, types, { resolvers, deployStyle, flutterStore, prompts, saved });
194
218
  const defaults = asks.defaults;
195
219
 
196
- // 수집 키 0개 → 질문 자체가 없음 (.sh `[ ${#WF_ASK_KEYS[@]} -eq 0 ]` 등가)
220
+ // 수집 키 0개 → 질문 자체가 없음
197
221
  if (asks.keys.length === 0) return { values: new Map(), useDefaults: true, answers: [] };
198
222
 
199
- // 비대화형: force 또는 (io 미주입 && 비TTY) → 전부 기본값 (.sh FORCE_MODE/TTY_AVAILABLE 분기 등가)
223
+ // 비대화형: force 또는 (io 미주입 && 비TTY) → 전부 기본값
200
224
  // io가 주입돼 있으면(테스트/상위 마법사) TTY 여부와 무관하게 대화형으로 진행한다.
201
225
  const interactive = !force && (io != null || stdin.isTTY);
202
226
  if (!interactive) {
@@ -206,7 +230,7 @@ export async function promptEnvPlan({
206
230
 
207
231
  const ui = io ?? engine;
208
232
 
209
- // 기본값 미리보기 카드 전체 출력 (.sh 3237~3251)
233
+ // 기본값 미리보기 카드 전체 출력
210
234
  log("");
211
235
  log("▶ 워크플로우 환경설정을 채웁니다");
212
236
  log("");
@@ -227,21 +251,21 @@ export async function promptEnvPlan({
227
251
  { value: "some", label: "③ 몇 개만 골라서 바꾸기 (고른 것만 입력 · 나머지는 기본값)" },
228
252
  ],
229
253
  });
230
- // ESC/취소 → 전부 기본값 (.sh `if [ "$_rc" -ne 0 ]` 등가)
254
+ // ESC/취소 → 전부 기본값
231
255
  if (choice === CANCEL || choice == null || choice === "all") {
232
256
  const values = new Map(defaults);
233
257
  return { values, useDefaults: true, answers: buildAnswers(prompts, asks, values, true) };
234
258
  }
235
259
 
236
260
  // 사용자가 확정한 키만 values에 담는다 — substituteEnv(useDefaults:false)가
237
- // values에 없는 키는 타입별 기본값으로 채우므로 .sh(_wf_prefill_all 후 덮어쓰기)와 등가.
261
+ // values에 없는 키는 타입별 기본값으로 채운다(기본값 위에 답한 값만 덮어쓰는 것과 같다).
238
262
  const values = new Map();
239
263
  if (choice === "each") {
240
264
  await promptEach(ui, prompts, asks, asks.keys, values, log);
241
265
  return { values, useDefaults: false, answers: buildAnswers(prompts, asks, values, false) };
242
266
  }
243
267
 
244
- // some: 바꿀 항목만 멀티선택 → 고른 것만 입력 (.sh 3266~3277)
268
+ // some: 바꿀 항목만 멀티선택 → 고른 것만 입력
245
269
  const options = asks.keys.map((key) => ({
246
270
  value: key,
247
271
  label: `${wfField(prompts, firstTypeFor(asks.usages, key), key, "label")} (기본: ${defaults.get(key)})`,
@@ -251,12 +275,12 @@ export async function promptEnvPlan({
251
275
  options,
252
276
  initialValues: [],
253
277
  });
254
- // ESC/빈 선택 → 전부 기본값 (.sh: _wf_prefill_all만 수행)
278
+ // ESC/빈 선택 → 전부 기본값
255
279
  if (selected === CANCEL || !Array.isArray(selected) || selected.length === 0) {
256
280
  const values = new Map(defaults);
257
281
  return { values, useDefaults: true, answers: buildAnswers(prompts, asks, values, true) };
258
282
  }
259
- // 수집 키 순서 유지 + WF_ASK_KEYS 멤버만 인정 (.sh _wf_prefill_interactive 필터 등가)
283
+ // 수집 키 순서 유지 + 수집된 키만 인정
260
284
  const todo = asks.keys.filter((k) => selected.includes(k));
261
285
  await promptEach(ui, prompts, asks, todo, values, log);
262
286
  return { values, useDefaults: false, answers: buildAnswers(prompts, asks, values, false) };
package/src/ui/prompts.js CHANGED
@@ -1,8 +1,10 @@
1
- // 대화형 프롬프트 래핑 (.sh interactive_menu/choose_menu/ask_* 등가).
1
+ // 대화형 프롬프트 래핑.
2
2
  // node:readline 기반 자체 엔진 사용 (@clack/prompts 는 Windows TTY에서 Enter가 멈추는 버그로 제거).
3
- // 취소(ESC/Ctrl+C)는 각 함수가 CANCEL 심볼을 반환 → 호출부가 정상 종료(exit 0) 처리.
3
+ // ESC는 각 함수가 CANCEL 심볼을 반환 → 호출부가 기본값/머무르기로 해석한다.
4
+ // Ctrl+C·Ctrl+D는 엔진이 PromptAbortError로 reject → run()이 잡아 종료코드 130으로 끝낸다.
4
5
  import * as engine from "./readline-engine.js";
5
6
  import { DEPLOY_STYLES, NO_DEPLOY_STYLE } from "../core/deploy-style.js";
7
+ import { TYPE_IDS } from "../core/types.js";
6
8
  import { ENV_MODES, DEFAULT_ENV_MODE, STORE_PLATFORMS, DEPLOY_MODES, DEFAULT_DEPLOY_MODE } from "../core/flutter-options.js";
7
9
 
8
10
  export const CANCEL = engine.CANCEL;
@@ -36,12 +38,17 @@ export async function confirmProjectMenu() {
36
38
 
37
39
  // 수정 메뉴 항목 — showFlutter=Flutter 타입일 때만 환경변수 방식/스토어 배포 대상/배포 모드 노출.
38
40
  // 라벨·순서를 테스트할 수 있도록 순수 함수로 분리했다.
39
- export function editMenuOptions({ showFlutter = false } = {}) {
41
+ // showOptions=선택 워크플로우 토글(자동 버전 승격·Copilot) 노출 — 저장값이 있으면 처음 질문을 건너뛰므로 여기서 바꾼다.
42
+ export function editMenuOptions({ showFlutter = false, showOptions = false } = {}) {
40
43
  const options = [
41
44
  { value: "type", label: "프로젝트 타입" },
42
45
  { value: "version", label: "버전" },
43
46
  { value: "branch", label: "기본 브랜치" },
44
47
  ];
48
+ if (showOptions) {
49
+ options.push({ value: "semverAuto", label: "자동 버전 승격" });
50
+ options.push({ value: "copilotAi", label: "Copilot AI 요약" });
51
+ }
45
52
  if (showFlutter) {
46
53
  options.push({ value: "envMode", label: "환경변수 방식" });
47
54
  options.push({ value: "flutterStore", label: "스토어 배포 대상" });
@@ -52,11 +59,12 @@ export function editMenuOptions({ showFlutter = false } = {}) {
52
59
  }
53
60
 
54
61
  // 수정 메뉴 — 어떤 항목을 고칠지.
55
- export async function editMenu({ showFlutter = false } = {}) {
56
- return engine.select({ message: "어떤 항목을 수정할까요?", options: editMenuOptions({ showFlutter }) });
62
+ export async function editMenu({ showFlutter = false, showOptions = false } = {}) {
63
+ return engine.select({ message: "어떤 항목을 수정할까요?", options: editMenuOptions({ showFlutter, showOptions }) });
57
64
  }
58
65
 
59
- const ALL_TYPES = ["spring", "flutter", "next", "react", "react-native", "react-native-expo", "node", "python", "basic", "go"];
66
+ // 대화형 타입 선택지 — CLI 검증 목록(VALID_TYPES)과 같은 레지스트리에서 만든다.
67
+ export const ALL_TYPES = TYPE_IDS;
60
68
 
61
69
  // 타입 멀티선택.
62
70
  export async function selectTypes(current = []) {
@@ -93,20 +101,22 @@ export async function confirmTypes({ types = [], markers = null } = {}) {
93
101
  // 배포 방식 선택. 서버 배포 CD 워크플로우는 서로 대체재라 하나만 쓴다.
94
102
  // 고른 것만 설치하고 push 트리거까지 켜준다 — 종전에는 넷을 다 깔고 SIMPLE만 켜져 있어,
95
103
  // 무중단을 원한 사람은 설치 후 YAML을 직접 고쳐야 했다.
96
- // "서버 배포 안 함"은 server-deploy 폴더 자체(PR 프리뷰 포함)를 제외한다 — 서버 배포를
104
+ // "서버 배포 안 함"은 모든 타입에서 서버 배포 워크플로우(CD·PR 프리뷰)를 제외한다 — 서버 배포를
97
105
  // 하지 않는 프로젝트(프론트엔드 전용, 라이브러리 등)를 위한 선택지다.
98
- export async function selectDeployStyle() {
106
+ // nonstop=false면(선택한 타입 어디에도 무중단 워크플로우가 없음) 고를 수 없는 무중단 선택지를 빼고 묻는다.
107
+ export async function selectDeployStyle({ nonstop = true } = {}) {
99
108
  engine.note(
100
109
  "서버 배포 워크플로우는 서로 대체재입니다 (Nginx와 Traefik을 동시에 쓰지 않습니다).\n" +
101
- "고른 방식만 설치하고 자동 실행(push 트리거)까지 켭니다. PR 프리뷰는 선택과 무관하게 함께 설치됩니다\n" +
102
- "(단, server-deploy 폴더가 있는 타입(spring)은 \"서버 배포 안 함\"을 고르면 PR 프리뷰도 함께 제외됩니다).",
110
+ "고른 방식만 설치하고 자동 실행(push 트리거)까지 켭니다. PR 프리뷰는 배포 방식과 함께 설치되고,\n" +
111
+ "\"서버 배포 안 함\"을 고르면 모든 타입에서 CD와 PR 프리뷰를 함께 제외합니다.\n" +
112
+ "무중단 배포는 spring에만 있습니다 — 다른 타입(python·go·react·next)은 단일 서버 배포로 설치됩니다.",
103
113
  "배포 방식",
104
114
  );
105
115
  return engine.select({
106
116
  message: "서버 배포는 어떤 방식으로 할까요?",
107
117
  options: [
108
- ...DEPLOY_STYLES.map((s) => ({ value: s.value, label: s.label })),
109
- { value: NO_DEPLOY_STYLE, label: "서버 배포 안 함 — CD 워크플로우/배포 설정을 생성하지 않음" },
118
+ ...DEPLOY_STYLES.filter((s) => nonstop || s.value === "simple").map((s) => ({ value: s.value, label: s.label })),
119
+ { value: NO_DEPLOY_STYLE, label: "서버 배포 안 함 — CD·PR 프리뷰 워크플로우와 배포 설정을 생성하지 않음" },
110
120
  ],
111
121
  });
112
122
  }
@@ -1,14 +1,40 @@
1
1
  // node:readline 기반 대화형 프롬프트 엔진 (@clack/prompts 대체).
2
2
  // WHY: @clack/prompts 1.7.0 이 Windows TTY 콘솔에서 Enter(return) 키를 처리하지 못하고
3
- // 멈추는 버그가 있다(실측 확정). node:readline 의 keypress 이벤트는 Windows에서 정상 동작한다.
4
- // .sh/.ps1 이 자체 메뉴를 구현한 것과 동일한 접근. 외부 의존성 0 → 내부망에서도 안전.
3
+ // 멈추는 버그가 있다. node:readline 의 keypress 이벤트는 Windows에서 정상 동작한다.
4
+ // 메뉴를 직접 구현하면 외부 의존성 0 → 내부망에서도 안전.
5
5
  //
6
- // 계약: 취소(ESC/Ctrl+C)는 CANCEL 심볼 반환. 각 함수 async.
6
+ // 계약: ESC는 CANCEL 심볼 반환(호출부가 "기본값/머무르기"로 해석). 각 함수 async.
7
+ // Ctrl+C·Ctrl+D·stdin 종료는 중단 — PromptAbortError로 reject해 어느 질문에서든 즉시 빠져나간다.
7
8
  import { emitKeypressEvents } from "node:readline";
8
9
  import { stdin, stdout } from "node:process";
10
+ import { visualWidth } from "./ansi.js";
9
11
 
10
12
  export const CANCEL = Symbol("cancel");
11
13
 
14
+ // 사용자 중단(Ctrl+C 등). ESC와 달리 기본값으로 진행하면 안 되므로 반환값이 아니라 예외로 전파한다 —
15
+ // 호출부마다 CANCEL 해석이 달라 일부는 기본값으로 설치를 강행하거나 같은 화면을 무한히 다시 그렸다.
16
+ // signal: 외부 신호로 중단된 경우 그 신호명("SIGINT"·"SIGTERM") — 종료 코드를 셸 관례(128+번호)에 맞춘다.
17
+ export class PromptAbortError extends Error {
18
+ constructor(signal = null) {
19
+ super("사용자가 중단했습니다.");
20
+ this.name = "PromptAbortError";
21
+ this.signal = signal;
22
+ }
23
+ }
24
+ export const isPromptAbort = (e) => e instanceof PromptAbortError;
25
+
26
+ // raw mode에서는 Ctrl+C가 SIGINT가 아니라 keypress로 들어온다. Ctrl+D도 raw mode에선 EOF가 아니다.
27
+ const isAbortKey = (key) => key.ctrl && (key.name === "c" || key.name === "d");
28
+
29
+ // 외부 신호(kill -INT·kill -TERM)는 keypress가 아니라 프로세스 신호로 온다. 리스너가 없으면 Node가 곧바로
30
+ // 종료해 커서 숨김과 raw mode가 복구되지 않는다 — 입력 대기 중에는 키보드 Ctrl+C와 같은 중단 경로로 돌린다.
31
+ const TERM_SIGNALS = ["SIGINT", "SIGTERM"];
32
+ function onTermSignal(fn) {
33
+ const handlers = TERM_SIGNALS.map((sig) => [sig, () => fn(sig)]);
34
+ for (const [sig, h] of handlers) process.on(sig, h);
35
+ return () => { for (const [sig, h] of handlers) process.removeListener(sig, h); };
36
+ }
37
+
12
38
  // ── ANSI 헬퍼 (picocolors 대체 — 의존성 0) ───────────────────────────
13
39
  const ESC = "\x1b[";
14
40
  const c = {
@@ -17,10 +43,27 @@ const c = {
17
43
  };
18
44
  // NO_COLOR(https://no-color.org)/비TTY 가드 — ansi.js와 동일한 규칙(존재 여부만 체크, 값 무관)이지만
19
45
  // 의존성 0 유지를 위해 자체 구현.
20
- const colorEnabled = () => process.env.NO_COLOR === undefined && !!stdout.isTTY;
46
+ // TERM=dumb은 색상뿐 아니라 커서 이동·지우기 시퀀스도 해석하지 못한다 — 그때는 다시 그리지 않고 이어서 출력한다.
47
+ const isDumb = () => process.env.TERM === "dumb";
48
+ const colorEnabled = () => process.env.NO_COLOR === undefined && !!stdout.isTTY && !isDumb();
21
49
  const paint = (s, color, enabled = colorEnabled()) => (enabled ? `${color}${s}${c.reset}` : String(s));
22
- const hideCursor = () => stdout.write(`${ESC}?25l`);
23
- const showCursor = () => stdout.write(`${ESC}?25h`);
50
+ // 커서를 숨긴 채 프로세스가 끝나면(예외·process.exit 등) 터미널 커서가 사라진 채로 남는다 — 종료 시점에 한 번 더 복구한다.
51
+ let cursorHidden = false;
52
+ let exitGuard = false;
53
+ const hideCursor = () => {
54
+ if (isDumb()) return;
55
+ stdout.write(`${ESC}?25l`);
56
+ cursorHidden = true;
57
+ if (!exitGuard) {
58
+ exitGuard = true;
59
+ process.once("exit", () => { if (cursorHidden) stdout.write(`${ESC}?25h`); });
60
+ }
61
+ };
62
+ const showCursor = () => {
63
+ if (isDumb()) return;
64
+ stdout.write(`${ESC}?25h`);
65
+ cursorHidden = false;
66
+ };
24
67
 
25
68
  // 심볼 (clack 톤 유지)
26
69
  const S_ACTIVE = paint("●", c.green);
@@ -31,16 +74,24 @@ const S_BAR = paint("│", c.gray);
31
74
  const S_Q = paint("◆", c.cyan);
32
75
  const S_DONE = paint("◇", c.green);
33
76
 
77
+ // 한 줄이 터미널에서 실제로 차지하는 행 수 — 폭을 넘는 줄은 터미널이 접어서 여러 행이 된다.
78
+ // 논리 줄 수만큼만 올라가면 접힌 윗부분이 지워지지 않고 화면에 사본이 쌓인다.
79
+ export function physicalRows(line, columns = stdout.columns) {
80
+ if (!columns) return 1;
81
+ return Math.max(1, Math.ceil(visualWidth(line) / columns));
82
+ }
83
+
34
84
  // 여러 줄 지운 뒤 커서를 블록 시작으로 되돌리는 렌더러.
35
- // prevLines 만큼 위로 올라가 지우고 새로 그린다.
85
+ // 직전에 그린 물리 행 수만큼 위로 올라가 지우고 새로 그린다.
36
86
  function makeRenderer() {
37
87
  let prevLines = 0;
38
88
  return {
39
89
  render(lines) {
90
+ if (isDumb()) { stdout.write(lines.join("\n") + "\n"); return; }
40
91
  if (prevLines > 0) stdout.write(`${ESC}${prevLines}A`); // 위로
41
92
  stdout.write(`${ESC}0J`); // 커서 아래 전부 지우기
42
93
  stdout.write(lines.join("\n") + "\n");
43
- prevLines = lines.length;
94
+ prevLines = lines.reduce((n, l) => n + physicalRows(l), 0);
44
95
  },
45
96
  reset() { prevLines = 0; },
46
97
  };
@@ -49,7 +100,7 @@ function makeRenderer() {
49
100
  // raw keypress 세션 공통 래퍼. onKey(str,key) → true 반환 시 종료.
50
101
  // 반환값은 finalize()가 만든다. 취소 시 CANCEL.
51
102
  function keySession(renderFn, onKey) {
52
- return new Promise((resolve) => {
103
+ return new Promise((resolve, reject) => {
53
104
  const wasRaw = stdin.isTTY ? stdin.isRaw : false;
54
105
  emitKeypressEvents(stdin);
55
106
  if (stdin.isTTY) stdin.setRawMode(true);
@@ -59,21 +110,33 @@ function keySession(renderFn, onKey) {
59
110
  const cleanup = () => {
60
111
  stdin.removeListener("keypress", handler);
61
112
  stdin.removeListener("end", onEnd);
113
+ offSignal();
62
114
  if (stdin.isTTY) stdin.setRawMode(wasRaw);
63
115
  stdin.pause();
64
116
  showCursor();
65
117
  };
118
+ const offSignal = onTermSignal((sig) => {
119
+ cleanup();
120
+ stdout.write("\n");
121
+ reject(new PromptAbortError(sig));
122
+ });
66
123
 
67
- // stdin 종료(EOF/Ctrl+D, SSH 연결 끊김 등) — 취소(ESC/Ctrl+C)와 동일하게 처리해 무한 대기를 방지한다.
124
+ // stdin 종료(EOF, SSH 연결 끊김 등) — 더 이상 입력이 올 수 없으므로 중단한다.
125
+ // CANCEL로 돌려주면 "머무르기"로 해석하는 화면에서 다시 묻다가 영원히 대기한다.
68
126
  const onEnd = () => {
69
127
  cleanup();
70
- resolve(CANCEL);
128
+ reject(new PromptAbortError());
71
129
  };
72
130
 
73
131
  const handler = (str, key) => {
74
132
  key = key || {};
75
- // 취소: Ctrl+C / Ctrl+D / ESC (raw mode에서는 Ctrl+D가 stdin "end"가 아니라 일반 keypress로 들어온다)
76
- if ((key.ctrl && (key.name === "c" || key.name === "d")) || key.name === "escape") {
133
+ if (isAbortKey(key)) {
134
+ cleanup();
135
+ stdout.write("\n");
136
+ reject(new PromptAbortError());
137
+ return;
138
+ }
139
+ if (key.name === "escape") {
77
140
  cleanup();
78
141
  resolve(CANCEL);
79
142
  return;
@@ -190,45 +253,69 @@ export async function multiselect({ message, options, initialValues = [], requir
190
253
  // 반환: 입력 문자열(빈 입력 시 defaultValue) 또는 CANCEL.
191
254
  export async function text({ message, defaultValue = "" }) {
192
255
  if (!stdin.isTTY) return defaultValue;
193
- return new Promise((resolve) => {
256
+ return new Promise((resolve, reject) => {
194
257
  const wasRaw = stdin.isTTY ? stdin.isRaw : false;
195
258
  emitKeypressEvents(stdin);
196
259
  if (stdin.isTTY) stdin.setRawMode(true);
197
260
  stdin.resume();
198
261
  let buf = "";
199
262
 
263
+ // dumb 터미널은 줄을 지울 수 없으므로 프롬프트는 한 번만 쓰고 입력 글자만 이어서 출력한다.
264
+ const dumb = isDumb();
265
+ let prevRows = 0; // 직전 프롬프트가 차지한 물리 행 수 — 긴 프롬프트는 여러 행으로 접힌다
200
266
  const prompt = () => {
201
- stdout.write(`\r${ESC}0K`); // 줄 초기화
267
+ if (dumb) {
268
+ stdout.write(`${S_Q} ${message} ${defaultValue ? `[${defaultValue}] ` : ""}`);
269
+ return;
270
+ }
271
+ // 커서는 접힌 마지막 행에 있으므로 첫 행까지 올라간 뒤 아래를 전부 지운다
272
+ stdout.write(prevRows > 1 ? `\r${ESC}${prevRows - 1}A${ESC}0J` : `\r${ESC}0J`);
202
273
  const shown = buf.length ? buf : paint(defaultValue || "", c.dim);
203
- stdout.write(`${S_Q} ${paint(message, c.bold)} ${shown}`);
274
+ const line = `${S_Q} ${paint(message, c.bold)} ${shown}`;
275
+ stdout.write(line);
276
+ prevRows = physicalRows(line);
204
277
  };
205
278
 
206
279
  const cleanup = () => {
207
280
  stdin.removeListener("keypress", handler);
208
281
  stdin.removeListener("end", onEnd);
282
+ offSignal();
209
283
  if (stdin.isTTY) stdin.setRawMode(wasRaw);
210
284
  stdin.pause();
211
285
  stdout.write("\n");
212
286
  };
287
+ const offSignal = onTermSignal((sig) => {
288
+ cleanup();
289
+ reject(new PromptAbortError(sig));
290
+ });
213
291
 
214
- // stdin 종료(EOF/Ctrl+D) — 취소와 동일하게 처리해 무한 대기를 방지한다.
292
+ // stdin 종료(EOF) — 더 이상 입력이 올 수 없으므로 중단한다.
215
293
  const onEnd = () => {
216
294
  cleanup();
217
- resolve(CANCEL);
295
+ reject(new PromptAbortError());
218
296
  };
219
297
 
220
298
  const handler = (str, key) => {
221
299
  key = key || {};
222
- // 취소: Ctrl+C / Ctrl+D / ESC (raw mode에서는 Ctrl+D가 stdin "end"가 아니라 일반 keypress로 들어온다)
223
- if ((key.ctrl && (key.name === "c" || key.name === "d")) || key.name === "escape") { cleanup(); resolve(CANCEL); return; }
300
+ if (isAbortKey(key)) { cleanup(); reject(new PromptAbortError()); return; }
301
+ if (key.name === "escape") { cleanup(); resolve(CANCEL); return; }
224
302
  if (key.name === "return" || key.name === "enter") {
225
303
  cleanup();
226
304
  resolve(buf.length ? buf : defaultValue);
227
305
  return;
228
306
  }
229
- if (key.name === "backspace") { buf = buf.slice(0, -1); prompt(); return; }
307
+ if (key.name === "backspace") {
308
+ if (dumb && buf.length) stdout.write("\b \b");
309
+ buf = buf.slice(0, -1);
310
+ if (!dumb) prompt();
311
+ return;
312
+ }
230
313
  // 일반 문자 (제어문자 제외)
231
- if (str && !key.ctrl && !key.meta && str.length === 1 && str >= " ") { buf += str; prompt(); return; }
314
+ if (str && !key.ctrl && !key.meta && str.length === 1 && str >= " ") {
315
+ buf += str;
316
+ if (dumb) stdout.write(str); else prompt();
317
+ return;
318
+ }
232
319
  };
233
320
  stdin.on("keypress", handler);
234
321
  stdin.on("end", onEnd);
@@ -1,6 +1,5 @@
1
1
  // 첫 화면 상태 표시 층 — 감지 로그 · 분석 카드 · 신규/업데이트 판별
2
- // (Breaking Changes 박스는 core/breaking-check.js가 담당.
3
- // 원본의 IDE Skills 상태는 project-auto-wizard 스코프 제외 — Agent Skills 미포함)
2
+ // (Breaking Changes 박스는 core/breaking-check.js가 담당)
4
3
  import { A, paint } from "./ansi.js";
5
4
  import { DEFAULT_DEPLOY_MODE } from "../core/flutter-options.js";
6
5
 
@@ -8,7 +7,7 @@ const GUT = paint("│", A.gray);
8
7
  const HEAD = paint("◆", A.cyan);
9
8
  const OK = paint("✓", A.green);
10
9
 
11
- // 감지 로그 (.ps1 감지 진행 표시 등가)
10
+ // 감지 로그 — 무엇을 근거로 어떤 타입을 감지했는지
12
11
  // markers: Map<type, 실제 발견 파일>.
13
12
  // warnings: 감지 도중 나온 경고. 감지 함수를 먼저 호출한 뒤 박스를 그리는 구조라 경고가
14
13
  // 박스 위로 새어나가 앞선 질문에 대한 경고처럼 보였다 — 박스 안에서 출력한다.
@@ -28,10 +27,10 @@ export function printDetectionLog({ types = [], version = "", branch = "", marke
28
27
  out(`${GUT}\n`);
29
28
  }
30
29
 
31
- // 프로젝트 분석 개요 카드 (.ps1 Print-ProjectAnalysis 등가+)
30
+ // 프로젝트 분석 개요 카드
32
31
  export function printAnalysisCard({ mode = "", modeLabel = "", types = [], version = "", branch = "",
33
32
  paths = new Map(), showOptional = false,
34
- flutter = null, envModeDefault = "" },
33
+ flutter = null, envModeDefault = "", options = null },
35
34
  out = (s) => process.stderr.write(s)) {
36
35
  out(`${HEAD} ${paint("프로젝트 분석 결과", A.bold)}\n`);
37
36
  const row = (icon, label, value) => out(`${GUT} ${icon} ${label.padEnd(10)} ${value}\n`);
@@ -51,6 +50,12 @@ export function printAnalysisCard({ mode = "", modeLabel = "", types = [], versi
51
50
  row("🚀", "배포모드", modeParts.length ? modeParts.join(" ") : "없음");
52
51
  }
53
52
  }
53
+ // 선택 워크플로우 — 저장값이 있으면 질문 없이 쓰이므로 확정 전에 현재값을 보여준다.
54
+ if (options) {
55
+ const onOff = (v) => (v ? paint("켜짐", A.green) : paint("꺼짐", A.dim));
56
+ row("🔢", "자동승격", onOff(options.semverAuto));
57
+ row("🤖", "Copilot", onOff(options.copilotAi));
58
+ }
54
59
  // 모노레포 경로 — 루트가 아닌 항목이 하나라도 있으면 표시
55
60
  const nonRoot = [...paths.entries()].filter(([, p]) => p && p !== ".");
56
61
  if (nonRoot.length) {