project-auto-wizard 0.13.2 → 0.14.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 (112) hide show
  1. package/README.md +1 -1
  2. package/bin/project-auto-wizard.js +18 -4
  3. package/package.json +2 -2
  4. package/payload/config/breaking-changes.json +28 -14
  5. package/payload/config/wizard-prompts.yml +102 -46
  6. package/payload/flutter-app/android/fastlane/Fastfile.playstore +44 -32
  7. package/payload/flutter-app/ios/ExportOptions.plist +8 -8
  8. package/payload/flutter-app/ios/fastlane/Fastfile +57 -43
  9. package/payload/scripts/changelog_manager.py +296 -257
  10. package/payload/scripts/issue_helper.py +54 -50
  11. package/payload/scripts/messages.py +2279 -0
  12. package/payload/scripts/truncate_release_notes.py +7 -6
  13. package/payload/scripts/version_manager.py +64 -61
  14. package/payload/version.yml.template +2 -1
  15. package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +17 -12
  16. package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +59 -34
  17. package/payload/workflows/common/PROJECT-COMMON-ISSUE-HELPER.yaml +17 -15
  18. package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +17 -10
  19. package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +54 -44
  20. package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +24 -17
  21. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +194 -184
  22. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +226 -213
  23. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +46 -42
  24. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +334 -241
  25. package/payload/workflows/flutter/PROJECT-FLUTTER-APP-BUILD-TRIGGER.yaml +152 -128
  26. package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +246 -188
  27. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +353 -238
  28. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +96 -85
  29. package/payload/workflows/go/PROJECT-GO-CI.yaml +47 -36
  30. package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +945 -508
  31. package/payload/workflows/go/PROJECT-GO-SIMPLE-CICD.yaml +155 -133
  32. package/payload/workflows/next/PROJECT-NEXT-CI.yaml +76 -60
  33. package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +97 -77
  34. package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +62 -47
  35. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +942 -505
  36. package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +148 -126
  37. package/payload/workflows/react/PROJECT-REACT-CI.yaml +86 -70
  38. package/payload/workflows/react/PROJECT-REACT-CICD.yaml +112 -92
  39. package/payload/workflows/spring/PROJECT-SPRING-CI.yml +112 -82
  40. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +180 -150
  41. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +161 -131
  42. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +970 -549
  43. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +193 -162
  44. package/src/cli/args.js +98 -52
  45. package/src/cli/help.js +8 -41
  46. package/src/commands/doctor.js +85 -89
  47. package/src/commands/dry-run.js +63 -43
  48. package/src/commands/full.js +79 -77
  49. package/src/commands/install-settings.js +16 -15
  50. package/src/commands/interactive-flutter.js +20 -19
  51. package/src/commands/interactive.js +135 -132
  52. package/src/commands/purge.js +47 -42
  53. package/src/commands/status.js +36 -36
  54. package/src/commands/uninstall.js +43 -41
  55. package/src/context.js +12 -11
  56. package/src/core/assets.js +12 -11
  57. package/src/core/baseline.js +27 -27
  58. package/src/core/branches.js +29 -27
  59. package/src/core/branding.js +7 -6
  60. package/src/core/breaking-check.js +22 -21
  61. package/src/core/breaking.js +15 -8
  62. package/src/core/copy/app-files.js +5 -4
  63. package/src/core/copy/flutter-app.js +8 -8
  64. package/src/core/copy/gitignore.js +58 -50
  65. package/src/core/copy/readme.js +45 -35
  66. package/src/core/copy/simple.js +16 -15
  67. package/src/core/copy/workflows.js +131 -124
  68. package/src/core/deploy-style.js +51 -48
  69. package/src/core/detect-fs.js +57 -57
  70. package/src/core/detect.js +67 -65
  71. package/src/core/errors.js +2 -2
  72. package/src/core/flutter-doctor.js +17 -16
  73. package/src/core/flutter-hooks.js +33 -32
  74. package/src/core/flutter-options.js +34 -33
  75. package/src/core/fsutil.js +12 -12
  76. package/src/core/installed-stores.js +4 -4
  77. package/src/core/logger.js +42 -40
  78. package/src/core/paths-resolve.js +88 -86
  79. package/src/core/paths.js +10 -10
  80. package/src/core/release-options.js +8 -8
  81. package/src/core/removal-exec.js +14 -13
  82. package/src/core/removal-plan.js +37 -37
  83. package/src/core/types.js +47 -47
  84. package/src/core/verify.js +44 -38
  85. package/src/core/version-yml.js +76 -68
  86. package/src/core/wizard-env.js +46 -46
  87. package/src/core/wizard-labels.js +63 -41
  88. package/src/i18n/catalog/en/cli.js +50 -0
  89. package/src/i18n/catalog/en/commands.js +187 -0
  90. package/src/i18n/catalog/en/copy.js +19 -0
  91. package/src/i18n/catalog/en/core.js +74 -0
  92. package/src/i18n/catalog/en/core2.js +15 -0
  93. package/src/i18n/catalog/en/ui.js +221 -0
  94. package/src/i18n/catalog/en.js +12 -0
  95. package/src/i18n/catalog/index.js +5 -0
  96. package/src/i18n/catalog/ko/cli.js +50 -0
  97. package/src/i18n/catalog/ko/commands.js +187 -0
  98. package/src/i18n/catalog/ko/copy.js +19 -0
  99. package/src/i18n/catalog/ko/core.js +74 -0
  100. package/src/i18n/catalog/ko/core2.js +15 -0
  101. package/src/i18n/catalog/ko/ui.js +221 -0
  102. package/src/i18n/catalog/ko.js +10 -0
  103. package/src/i18n/index.js +44 -0
  104. package/src/i18n/languages.js +9 -0
  105. package/src/index.js +128 -94
  106. package/src/ui/ansi.js +7 -7
  107. package/src/ui/banner.js +7 -6
  108. package/src/ui/env-plan.js +82 -81
  109. package/src/ui/prompts.js +97 -122
  110. package/src/ui/readline-engine.js +59 -58
  111. package/src/ui/status-cards.js +37 -31
  112. package/src/ui/summary.js +89 -88
@@ -1,7 +1,7 @@
1
- // 대화형 마법사.
2
- // io 주입으로 테스트 가능. 실제 실행은 src/ui/prompts.js 함수를 io로 넘긴다.
3
- // 새 시각 층(banner/detectionLog/analysisCard/installKind/summary)과 저수준 엔진(engineIo)은
4
- // io의 "옵셔널 멤버" — 스텁이 생략하면 해당 층만 건너뛰고 실행 계약은 동일하다.
1
+ // Interactive wizard.
2
+ // Testable through io injection. The real run passes the src/ui/prompts.js functions as io.
3
+ // The visual layers (banner/detectionLog/analysisCard/installKind/summary) and the low-level engine (engineIo)
4
+ // are "optional members" of io - a stub that omits them just skips that layer; the execution contract is the same.
5
5
  import { join } from "node:path";
6
6
  import { existsSync, readFileSync } from "node:fs";
7
7
  import { resolvePayloadRoot, assertPayload, readTemplateVersion } from "../core/assets.js";
@@ -28,115 +28,117 @@ import * as prompts from "../ui/prompts.js";
28
28
  import { runStatus, printStatus } from "./status.js";
29
29
  import { runDoctor, printDoctorReport } from "./doctor.js";
30
30
  import { currentLogPath, hasLegacyMdLogs } from "../core/logger.js";
31
+ import { t, getLanguage } from "../i18n/index.js";
31
32
 
32
33
  const CANCEL = prompts.CANCEL;
33
34
 
34
- const SEMVER_AUTO_QUESTION = "자동 버전 승격을 사용하시겠습니까? (커밋 타입에 따라 major/minor/patch 자동 결정)";
35
- const COPILOT_AI_QUESTION = "Copilot으로 AI 요약을 생성하시겠습니까? (GitHub Copilot AI Credits가 소비되며, 사용할 수 없으면 자동으로 규칙 기반 요약으로 전환됩니다)";
35
+ // Resolved lazily so the text follows the language chosen at run time.
36
+ const semverAutoQuestion = () => t("interactive.question.semverAuto");
37
+ const copilotAiQuestion = () => t("interactive.question.copilotAi");
36
38
  const isCancel = (v) => v === CANCEL || typeof v === "symbol";
37
39
 
38
- // io 기본값 = 실제 prompts. 테스트는 스텁 io 주입.
40
+ // io defaults to the real prompts. Tests inject a stub io.
39
41
  export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot, clock, io = prompts } = {}) {
40
42
  const payload = assertPayload(payloadRoot ?? resolvePayloadRoot());
41
43
  const templateVersion = readTemplateVersion();
42
44
 
43
- // 시작 배너. 스텁엔 banner 없음 → intro 폴백.
44
- if (io.banner) io.banner({ version: templateVersion, modeLabel: "대화형 통합 마법사" });
45
- else io.intro?.("project-auto-wizard — 대화형 통합 마법사");
45
+ // Start banner. Stubs have no banner -> fall back to intro.
46
+ if (io.banner) io.banner({ version: templateVersion, modeLabel: t("interactive.banner.modeLabel") });
47
+ else io.intro?.(t("interactive.intro"));
46
48
 
47
- // 기존 version.yml — version/version_code/paths/옵션 보존의 단일 진실
49
+ // Existing version.yml - single source of truth for preserving version/version_code/paths/options
48
50
  const vyPath = join(cwd, "version.yml");
49
51
  const existing = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")) : null;
50
52
 
51
- // 신규/업데이트 판별
53
+ // New install vs update detection
52
54
  io.installKind?.({ currentTemplateVersion: existing?.templateVersion || "", templateVersion });
53
55
 
54
- // 1) 모드 선택
55
- // status/doctor — 읽기 전용이라 감지·breaking 게이트가 필요 없다. 결과를 보여준 뒤 메뉴로
56
- // 돌아온다: 진단의 목적이 설치 준비이므로 확인 → 설치를 한 세션에서 끝내야 한다.
57
- // CLI 경로(index.js의 --mode status/doctor)는 단발 명령이므로 지금처럼 즉시 종료한다.
56
+ // 1) Mode selection
57
+ // status/doctor are read-only, so no detection or breaking gate is needed. After showing the result we
58
+ // return to the menu: the point of diagnosis is install preparation, so check -> install should finish in one session.
59
+ // The CLI path (--mode status/doctor in index.js) is a one-shot command, so it exits immediately as before.
58
60
  let mode;
59
61
  for (let round = 0; ; round++) {
60
62
  mode = await io.selectMode({ again: round > 0 });
61
- if (mode === CANCEL || mode == null) { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
63
+ if (mode === CANCEL || mode == null) { io.cancelMessage?.(t("interactive.cancel.install")); return 0; }
62
64
  if (mode === "status") { printStatus(runStatus(payload, cwd)); continue; }
63
65
  if (mode === "doctor") { printDoctorReport(runDoctor(cwd)); continue; }
64
66
  break;
65
67
  }
66
68
 
67
- // uninstall 모드 — 대화형 체크리스트로 항목별 opt-in 후 삭제. 감지·breaking 게이트 불필요.
68
- // runUninstallFlow는 취소/항목없음 시 null을 반환한다 — 그때는 완료 outro를 찍지 않는다.
69
+ // uninstall mode - delete after per-item opt-in through an interactive checklist. No detection or breaking gate needed.
70
+ // runUninstallFlow returns null on cancel / nothing to remove - then the completion outro is not printed.
69
71
  if (mode === "uninstall") {
70
72
  const result = await runUninstallFlow(payload, cwd, io);
71
- if (result) io.outro?.("완전 삭제를 마쳤습니다.");
73
+ if (result) io.outro?.(t("interactive.outro.uninstall"));
72
74
  return 0;
73
75
  }
74
76
 
75
- // Breaking Changes 게이트 (모든 모드 공통, 대화형은 확인 질문)
77
+ // Breaking Changes gate (common to all modes, a confirmation question in interactive mode)
76
78
  const proceed = await runBreakingCheck({
77
79
  cwd, payloadRoot: payload, templateVersion,
78
80
  askYesNo: (msg, def) => io.askYesNo(msg, def),
79
81
  });
80
- if (!proceed) { io.cancelMessage?.("통합을 안전하게 취소했습니다."); return 0; }
82
+ if (!proceed) { io.cancelMessage?.(t("interactive.cancel.integration")); return 0; }
81
83
 
82
- // full/version/workflows — 감지 (version은 기존 version.yml 최우선)
83
- // 감지 경고는 즉시 찍지 않고 모았다가 감지 박스 안에서 출력한다 — 종전에는 경고가 박스보다
84
- // 먼저 나와 앞선 질문에 대한 경고처럼 보였다. 안내 문구도 대화형용으로 바꾼다.
84
+ // full/version/workflows - detection (the existing version.yml takes top priority for version)
85
+ // Detection warnings are collected and printed inside the detection box instead of immediately - they used to
86
+ // appear before the box and look like warnings about the previous question. The hint text is also adapted for interactive mode.
85
87
  const detectWarnings = [];
86
88
  let types = detectTypes(cwd);
87
89
  let version = resolveVersion({
88
90
  cwd, existing, types,
89
91
  warn: (m) => detectWarnings.push(m),
90
- hint: "다음 화면의 '수정하기 > 버전'에서 바로 고칠 수 있습니다.",
92
+ hint: t("interactive.hint.version"),
91
93
  });
92
- // 경로가 확정된 뒤 모노레포 하위 폴더에서 다시 감지할지 — 저장값·직접 입력한 값은 건드리지 않는다.
94
+ // Whether to re-detect in a monorepo subfolder once paths are settled - saved values and hand-typed values are left alone.
93
95
  let versionAutoDetected = !existing?.version;
94
96
  let branch = detectDefaultBranch(cwd, {
95
97
  warn: (m) => detectWarnings.push(m),
96
- hint: "다르면 뒤의 '릴리스 브랜치' 질문에서 바꿀 수 있습니다.",
98
+ hint: t("interactive.hint.branch"),
97
99
  });
98
100
  const repoName = detectRepoName(cwd);
99
- // 선택 워크플로우 초기값: CLI 플래그(--copilot 등) → version.yml 저장 옵션
100
- // 플래그로 정한 값은 질문을 생략한다 — 비대화형과 같은 우선순위.
101
+ // Initial values of optional workflows: CLI flags (--copilot etc.) -> options saved in version.yml
102
+ // Values set by a flag skip the question - same priority as non-interactive mode.
101
103
  let { semverAuto: includeSemverAuto, copilotAi: includeCopilotAi } = pickReleaseOptions(
102
104
  { semverAuto: baseCtx?.includeSemverAuto, copilotAi: baseCtx?.includeCopilotAi }, existing);
103
- // 서버 배포 방식 — 저장값(version.yml)이 있으면 재질문하지 않는다 (semver_auto와 같은 규약).
105
+ // Server deploy style - not asked again when a saved value (version.yml) exists (same convention as semver_auto).
104
106
  let deployStyle = savedDeployStyle(existing);
105
107
  const showOptional = mode === "full";
106
108
  const realTty = process.stdout.isTTY === true;
107
109
 
108
- // Flutter 옵션 — 저장값이 있으면 재질문하지 않는다 (deploy_style과 같은 규약).
109
- // 저장값 없는 기존 설치는 동작 보존을 위해 dotenv를 초기 선택으로, 스토어는 설치된 워크플로우로 추론한다.
110
- // 환경변수 기본값 규칙(신규=dart-define, 기존 설치·저장값 없음=dotenv)은 resolveFlutterOptions가 단일 진실이다.
110
+ // Flutter options - not asked again when a saved value exists (same convention as deploy_style).
111
+ // Existing installs without saved values start with dotenv to preserve behaviour, and stores are inferred from the installed workflows.
112
+ // The env default rule (new = dart-define, existing install without saved value = dotenv) lives only in resolveFlutterOptions.
111
113
  let flutter = savedFlutterState(existing);
112
114
  const flutterAsk = {
113
115
  envModeDefault: resolveFlutterOptions({
114
116
  cli: { envMode: "", stores: null, androidDeployMode: "", iosDeployMode: "" }, existing,
115
117
  }).envMode,
116
- // 신규 설치의 초기 선택은 CLI 기본값(--flutter-store 미지정 = 둘 다)과 같아야 한다 — 경로에 따라 설치 결과가 달라지면 안 된다.
118
+ // The initial selection of a new install must equal the CLI default (--flutter-store unspecified = both) - the install result must not depend on the path taken.
117
119
  inferredStores: existing && flutter.stores === null ? inferInstalledStores(join(cwd, PATHS.workflowsDir)) : [...STORE_PLATFORMS],
118
120
  };
119
- // 이미 정해진 값은 건너뛰므로 여러 번 불러도 같은 질문이 반복되지 않는다.
121
+ // Already-decided values are skipped, so calling this several times never repeats a question.
120
122
  const askFlutterOptions = async () => {
121
123
  if (types.includes("flutter")) flutter = await askUnsetFlutterOptions(io, flutter, flutterAsk);
122
124
  };
123
- // 서버 배포 방식 — 서버 배포(CD) 워크플로우가 있는 타입(spring·go·python 등)일 때만 묻는다.
124
- // 그 외 타입에는 설치 결과에 영향이 없는 질문이라 묻지도 기록하지도 않는다.
125
+ // Server deploy style - asked only for types that have server-deploy (CD) workflows (spring, go, python, ...).
126
+ // For other types the question does not affect the install result, so it is neither asked nor recorded.
125
127
  const hasServerDeploy = () => hasServerDeployWorkflows(payload, types);
126
128
  const askDeployStyle = async () => {
127
129
  if (isDeployStyle(deployStyle) || !hasServerDeploy()) return;
128
130
  const picked = await io.selectDeployStyle({ nonstop: hasNonstopWorkflows(payload, types) });
129
- deployStyle = isDeployStyle(picked) ? picked : DEFAULT_DEPLOY_STYLE; // ESC = 기본값
131
+ deployStyle = isDeployStyle(picked) ? picked : DEFAULT_DEPLOY_STYLE; // ESC = default
130
132
  };
131
133
 
132
- // 감지 로그. markers = 실제로 존재를 확인한 파일.
134
+ // Detection log. markers = files whose existence was actually confirmed.
133
135
  let markers = detectMarkers(cwd, types);
134
136
  io.detectionLog?.({ types, version, branch, markers, warnings: detectWarnings });
135
137
 
136
- // 타입 확정 — 감지는 추정이므로 다른 질문보다 먼저 확인받는다. 종전에는 확정 UI가
137
- // '수정하기 > 프로젝트 타입' 두 단계 뒤에 숨어 있어, 타입이 틀린 채로 설치가 끝나는 일이 많았다.
138
- // 타입이 뒤에 나올 질문(선택 워크플로우·경로·env)의 범위를 정하므로 순서상 여기가 맞다.
139
- // 저장값이 있는 업데이트 설치와 비대화형에서는 묻지 않는다 — 기존 동작 그대로.
138
+ // Confirm types - detection is a guess, so get confirmation before any other question. The confirmation UI used to be
139
+ // hidden two steps deep in 'Edit > Project type', so installs often finished with the wrong type.
140
+ // Types decide the scope of the later questions (optional workflows, paths, env), so this is the right place in the order.
141
+ // Not asked for update installs with saved values and in non-interactive mode - unchanged behaviour.
140
142
  if (showOptional && !existing?.types?.length) {
141
143
  const picked = await io.confirmTypes({ types, markers });
142
144
  if (!isCancel(picked) && Array.isArray(picked) && picked.length) {
@@ -148,37 +150,37 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
148
150
  }
149
151
  }
150
152
 
151
- // 배포 방식·Flutter 옵션·semver/Copilot 질문 — 선택 항목을 묻는 경우(showOptional)만
153
+ // Deploy style, Flutter options, semver/Copilot questions - only when optional items are asked (showOptional)
152
154
  if (showOptional) {
153
155
  await askDeployStyle();
154
156
 
155
- // Flutter 옵션 — 환경변수 방식 → 스토어 배포 대상 → 플랫폼별 배포 모드.
157
+ // Flutter options - env mode -> store deploy targets -> per-platform deploy mode.
156
158
  await askFlutterOptions();
157
159
 
158
- // 신규 질문 — 자동 semver 승격 (기본 ON). 저장값 있으면 재질문 생략.
159
- // version.yml을 쓰지 않는 workflows 모드에서는 답변이 무의미하므로 full에서만 질문한다.
160
+ // Auto semver bump question (default ON). Skipped when a saved value exists.
161
+ // In workflows mode version.yml is not written and the answer is meaningless, so it is asked only in full.
160
162
  if (mode === "full" && includeSemverAuto === null) {
161
- const y2 = await io.askYesNo(SEMVER_AUTO_QUESTION, true);
163
+ const y2 = await io.askYesNo(semverAutoQuestion(), true);
162
164
  includeSemverAuto = y2 === true;
163
165
  }
164
166
 
165
- // Copilot AI 요약 — AI Credits를 소비하므로 opt-in(기본 No). 저장값 있으면 재질문 생략.
167
+ // Copilot AI summary - consumes AI Credits, so opt-in (default No). Skipped when a saved value exists.
166
168
  if (mode === "full" && includeCopilotAi === null) {
167
- const y3 = await io.askYesNo(COPILOT_AI_QUESTION, false);
169
+ const y3 = await io.askYesNo(copilotAiQuestion(), false);
168
170
  includeCopilotAi = y3 === true;
169
171
  }
170
172
  }
171
- // 질문이 실제로 나온 경우(위 full 모드 질문) 답변을 그대로 존중하고, 안 나온 옵션만 기본값으로 채운다
172
- // — CLI 경로(index.js)와 같은 함수라 같은 규칙이다.
173
+ // When a question was actually asked (the full-mode questions above) its answer is respected, and only options that were not asked
174
+ // are filled with defaults - same function as the CLI path (index.js), hence the same rule.
173
175
  ({ includeSemverAuto, includeCopilotAi } = resolveReleaseOptions(
174
176
  { semverAuto: includeSemverAuto, copilotAi: includeCopilotAi }, existing));
175
177
  const showOptionToggles = mode === "full";
176
178
 
177
- // 확인/수정 루프 — ESC는 '머무르기' (명시적 '아니오'만 종료)
179
+ // Confirm/edit loop - ESC means 'stay' (only an explicit 'No' exits)
178
180
  let paths = new Map();
179
181
  let confirmed = false;
180
182
  while (!confirmed) {
181
- // 프로젝트 분석 개요 카드. 스텁엔 없음 → note 폴백.
183
+ // Project analysis overview card. Stubs have none -> fall back to note.
182
184
  if (io.analysisCard) {
183
185
  io.analysisCard({
184
186
  mode, modeLabel: modeLabel(mode), types, version, branch, showOptional, paths, flutter, envModeDefault: flutterAsk.envModeDefault,
@@ -188,40 +190,40 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
188
190
  io.note?.(summarize({
189
191
  mode, types, version, branch, showOptional, flutter, envModeDefault: flutterAsk.envModeDefault,
190
192
  options: showOptionToggles ? { semverAuto: includeSemverAuto, copilotAi: includeCopilotAi } : null,
191
- }), "프로젝트 분석 결과");
193
+ }), t("interactive.summary.title"));
192
194
  }
193
195
  const choice = await io.confirmProjectMenu();
194
- if (choice === "cancel") { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
195
- if (isCancel(choice) || choice == null) continue; // ESC = 머무르기 (루프 재출력)
196
+ if (choice === "cancel") { io.cancelMessage?.(t("interactive.cancel.install")); return 0; }
197
+ if (isCancel(choice) || choice == null) continue; // ESC = stay (redraw the loop)
196
198
  if (choice === "continue") { confirmed = true; break; }
197
- // edit 루프
199
+ // edit loop
198
200
  let editing = true;
199
201
  while (editing) {
200
202
  const what = await io.editMenu({ showFlutter: showOptional && types.includes("flutter"), showOptions: showOptionToggles });
201
203
  if (isCancel(what) || what === "done") { editing = false; break; }
202
204
  if (what === "type") {
203
- const t = await io.selectTypes(types);
204
- if (!isCancel(t) && Array.isArray(t) && t.length) {
205
- // 타입 집합이 실제로 바뀌면 경로 재해석 대상으로 초기화 (정렬 집합 비교)
205
+ const picked = await io.selectTypes(types);
206
+ if (!isCancel(picked) && Array.isArray(picked) && picked.length) {
207
+ // If the type set really changed, reset it so paths are re-resolved (sorted-set comparison)
206
208
  const oldSorted = [...types].sort().join(",");
207
- types = t.filter((x) => VALID_TYPES.includes(x));
209
+ types = picked.filter((x) => VALID_TYPES.includes(x));
208
210
  if ([...types].sort().join(",") !== oldSorted) paths = new Map();
209
211
  }
210
212
  } else if (what === "version") {
211
- const v = await io.askText("새 버전 (예: 1.0.0)", version);
213
+ const v = await io.askText(t("interactive.edit.versionPrompt"), version);
212
214
  if (!isCancel(v) && v !== version) {
213
- // semver 형식 검증
215
+ // semver format check
214
216
  if (/^\d+\.\d+\.\d+$/.test(v)) { version = v; versionAutoDetected = false; }
215
- else io.note?.("버전 형식이 올바르지 않습니다 (x.y.z 형태) — 기존 값을 유지합니다.", "⚠ 버전");
217
+ else io.note?.(t("interactive.edit.versionInvalid"), t("interactive.edit.versionInvalidTitle"));
216
218
  }
217
219
  } else if (what === "branch") {
218
- branch = await askBranchName(io, "기본 브랜치", branch, isCancel);
220
+ branch = await askBranchName(io, t("interactive.edit.branchPrompt"), branch, isCancel);
219
221
  } else if (what === "semverAuto") {
220
- // 저장값이 있으면 처음 질문을 건너뛰므로, 한 번 정한 값을 바꿀 수 있는 곳은 여기뿐이다.
221
- const y = await io.askYesNo(SEMVER_AUTO_QUESTION, includeSemverAuto);
222
+ // A saved value skips the first question, so this is the only place to change a value once decided.
223
+ const y = await io.askYesNo(semverAutoQuestion(), includeSemverAuto);
222
224
  if (typeof y === "boolean") includeSemverAuto = y;
223
225
  } else if (what === "copilotAi") {
224
- const y = await io.askYesNo(COPILOT_AI_QUESTION, includeCopilotAi);
226
+ const y = await io.askYesNo(copilotAiQuestion(), includeCopilotAi);
225
227
  if (typeof y === "boolean") includeCopilotAi = y;
226
228
  } else if (FLUTTER_EDIT_ITEMS.has(what)) {
227
229
  flutter = await editFlutterOption(io, what, flutter, flutterAsk.envModeDefault);
@@ -229,13 +231,13 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
229
231
  }
230
232
  }
231
233
 
232
- // 편집 루프에서 뒤늦게 flutter 타입이 추가된 경우에도 옵션을 확정한다 — 이미 정해진 값은 다시 묻지 않는다.
233
- // 서버 배포 타입이 뒤늦게 추가된 경우도 같다.
234
+ // Settle the options even when the flutter type was added late in the edit loop - already-decided values are not asked again.
235
+ // The same goes for a server-deploy type added late.
234
236
  if (showOptional) {
235
237
  await askDeployStyle();
236
238
  await askFlutterOptions();
237
239
  }
238
- // 질문이 나오지 않은 경우(비 full 모드 등)도 동작 보존 기본값으로 채워 워크플로우 치환이 어긋나지 않게 한다.
240
+ // Even when no question was asked (non-full modes etc.) fill in behaviour-preserving defaults so workflow substitution stays consistent.
239
241
  const flutterOptions = {
240
242
  envMode: flutter.envMode || flutterAsk.envModeDefault,
241
243
  stores: flutter.stores,
@@ -243,9 +245,9 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
243
245
  iosDeployMode: flutter.iosDeployMode || DEFAULT_DEPLOY_MODE,
244
246
  };
245
247
 
246
- // 브랜치 설정. full/workflows만 질문, version은 기본값 기록.
247
- // 저장값(version.yml metadata.template.branches)이 있으면 재질문 없이 재사용 (업데이트 모드).
248
- // 이전 버전이 저장한 감지 실패 값("(unknown)" 등)이 있으면 저장값이 없는 것으로 보고 다시 묻는다.
248
+ // Branch setup. Only full/workflows ask; version records the defaults.
249
+ // A saved value (version.yml metadata.template.branches) is reused without asking (update mode).
250
+ // A detection-failure value saved by an earlier version ("(unknown)" etc.) counts as not saved and is asked again.
249
251
  const savedBranches = existing?.branches
250
252
  && isValidBranchName(existing.branches.main) && isValidBranchName(existing.branches.develop)
251
253
  ? existing.branches : null;
@@ -255,114 +257,115 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
255
257
  let developMissing = false;
256
258
  if (showOptional && !savedBranches) {
257
259
  const remoteBranches = await detectRemoteBranches(cwd);
258
- // 두 질문에 같은 이름을 입력해야만 trunk-based가 되는 암묵적 규칙 대신,
259
- // 전략을 먼저 명시적으로 고르게 한다. 취소/그 외 값은 기존 기본 동작과 같은 pr-flow로 폴백
260
- // (selectDeployStyle의 "ESC = 기본값" 패턴과 동일).
260
+ // Instead of the implicit rule that trunk-based only happens when the same name is typed for both questions,
261
+ // let the user pick the strategy explicitly first. Cancel / any other value falls back to pr-flow, the existing default
262
+ // (same "ESC = default" pattern as selectDeployStyle).
261
263
  const strategyPick = await io.selectBranchStrategy();
262
264
  const strategy = strategyPick === "trunk-based" ? "trunk-based" : "pr-flow";
263
- const mainB = await pickBranch(io, `릴리스 브랜치를 선택하세요 (기본: ${branch})`, branch, remoteBranches, isCancel);
264
- // trunk-based면 개발 브랜치 질문 자체를 생략 — 유일한 브랜치(main)를 그대로 develop으로 쓴다.
265
+ const mainB = await pickBranch(io, t("interactive.branch.releasePrompt", { branch }), branch, remoteBranches, isCancel);
266
+ // For trunk-based the development-branch question is skipped - the only branch (main) doubles as develop.
265
267
  const devB = strategy === "trunk-based"
266
268
  ? mainB
267
- : await pickBranch(io, "개발 브랜치를 선택하세요 (기본: develop)", "develop", remoteBranches, isCancel);
269
+ : await pickBranch(io, t("interactive.branch.developPrompt"), "develop", remoteBranches, isCancel);
268
270
  branches = resolveBranchConfig({ mainBranch: mainB, developBranch: devB, defaultBranch: branch });
269
271
  if (branches.mode === "trunk-based") {
270
- io.note?.(`릴리스 브랜치(${branches.main}) 하나만 사용하는 trunk-based 모드로 설치합니다 (RELEASE-PUBLISH 단독).`, "브랜치 모드");
272
+ io.note?.(t("interactive.branch.trunkNote", { main: branches.main }), t("interactive.branch.modeTitle"));
271
273
  } else if (!remoteBranches.length) {
272
- // 원격이 없거나 비어 있으면 push할 기준이 없다 — 만들지 않았다는 사실과 방법을 알린다.
274
+ // With no remote or an empty one there is nothing to push against - report that it was not created and how to do it.
273
275
  developMissing = true;
274
- io.note?.(developMissingNotice(branches), "브랜치");
276
+ io.note?.(developMissingNotice(branches), t("interactive.branch.title"));
275
277
  } else if (!remoteBranches.includes(branches.develop)) {
276
278
  const r = await ensureDevelopBranch({
277
279
  develop: branches.develop, remoteBranches, cwd,
278
280
  confirm: (msg) => io.askYesNo(msg, true),
279
- log: (m) => io.note?.(m, "브랜치"),
281
+ log: (m) => io.note?.(m, t("interactive.branch.title")),
280
282
  });
281
283
  developMissing = r.created !== true || r.pushed === false;
282
284
  }
283
285
  }
284
286
 
285
- // 경로 확정 (저장값·후보 스캔·질문)
287
+ // Settle paths (saved values, candidate scan, questions)
286
288
  if (mode === "full") {
287
289
  paths = await resolveProjectPaths({
288
290
  root: cwd, types, paths, existingPaths: existing?.paths ?? new Map(),
289
291
  force: false, tty: realTty, io: io.engineIo ?? {},
290
292
  });
291
293
  } else {
292
- for (const t of types) if (t !== "basic" && !paths.has(t)) paths.set(t, existing?.paths.get(t) || ".");
294
+ for (const ty of types) if (ty !== "basic" && !paths.has(ty)) paths.set(ty, existing?.paths.get(ty) || ".");
293
295
  }
294
296
 
295
- // 모노레포는 버전·빌드 번호 파일이 타입 폴더 안에 있다 — 경로가 확정된 지금 그 폴더에서 감지한다.
297
+ // In a monorepo the version/build-number files live inside the type folder - detect there now that paths are settled.
296
298
  if (versionAutoDetected && [...paths.values()].some((p) => p && p !== ".")) {
297
299
  version = detectVersion(cwd, { types, paths, warn: () => {} });
298
300
  }
299
301
  const versionCode = resolveVersionCode({ cwd, existing, types, paths });
300
302
 
301
- // @wizard env 계획 질문 (full/workflows만)
303
+ // @wizard env plan questions (full/workflows only)
302
304
  const resolvers = makeResolvers(cwd, repoName, paths, flutterOptions);
303
305
  let envValues = new Map(), envUseDefaults = true, envAnswers = [];
304
306
  if (showOptional) {
305
307
  const plan = await promptEnvPlan({
306
308
  payloadRoot: payload, types, io: io.engineIo ?? null, force: false,
307
309
  resolvers, deployStyle, targetRoot: cwd, repoName,
308
- flutterStore: flutterOptions.stores, // 선택 해제된 스토어 워크플로우의 ask 질문은 묻지 않는다
310
+ flutterStore: flutterOptions.stores, // ask questions of deselected store workflows are not asked
309
311
  });
310
312
  envValues = plan.values;
311
313
  envUseDefaults = plan.useDefaults;
312
- envAnswers = plan.answers || []; // 완료 요약·설치 로그가 같은 답변 데이터를 쓴다
314
+ envAnswers = plan.answers || []; // the completion summary and install log share the same answer data
313
315
  }
314
316
 
315
317
  const { now, today } = clock || utcNow();
316
318
  const ctx = buildInstallContext({
317
319
  payload, existing, templateVersion, types,
318
- // 저장값이 무중단이어도 선택한 타입에 그 방식이 없으면 단일 서버 배포가 설치된다 — 설치된 방식을 기록한다.
320
+ // Even if the saved value is zero-downtime, a single-server deploy is installed when the chosen types lack that style - record the style actually installed.
319
321
  deployStyle: resolveDeployStyle({ payload, types, explicit: deployStyle, existing }),
320
322
  typeOptions: flutterOptions,
321
323
  releaseOptions: { includeSemverAuto, includeCopilotAi },
322
324
  mode, force: true, version, versionCode, branch, branches, paths,
323
325
  repoName, resolvers, envValues, envUseDefaults, now, today,
324
- // 설치 로그·완료 요약이 쓰는 부가 문맥 — 설치 동작 자체는 바꾸지 않는다.
326
+ language: baseCtx.language ?? existing?.language ?? getLanguage(),
327
+ // Extra context used by the install log and completion summary - does not change the install behaviour itself.
325
328
  markers, envAnswers, detectWarnings,
326
329
  });
327
330
 
328
- // 사용자가 답해야 하는 것만 묻는다. baseline 3-way가 자동으로 안전한 경우를
329
- // 걸러내므로, 여기 오는 것은 (a) 양쪽이 다 바뀐 진짜 충돌과 (b) 사용자가 지운 파일뿐이다.
331
+ // Ask only what the user has to answer. The baseline 3-way merge filters out the automatically
332
+ // safe cases, so what reaches here is (a) real conflicts where both sides changed and (b) files the user deleted.
330
333
  let hooks = {};
331
334
  if (showOptional && io.engineIo?.select) {
332
335
  const { conflicts, removed } = surveyWorkflows(ctx, payload, cwd);
333
336
 
334
- // (b) 지운 파일 — 조용히 되살리지 않는다. 되살리는 쪽이 기본이 아니라는 점이 핵심이다.
337
+ // (b) Deleted files - never restored silently. The key point is that restoring is not the default.
335
338
  const restoreRemoved = new Set();
336
339
  if (removed.length) {
337
340
  io.note?.(removed.map((r) => ` - ${r.filename}`).join("\n"),
338
- `이전 설치에는 있었지만 지금 없는 워크플로우 (${removed.length}개)`);
341
+ t("interactive.removed.title", { count: removed.length }));
339
342
  for (const { filename } of removed) {
340
343
  const sel = await io.engineIo.select({
341
- message: `${filename} — 직접 지우신 것으로 보입니다. 다시 추가할까요?`,
344
+ message: t("interactive.removed.message", { filename }),
342
345
  options: [
343
- { value: "keep", label: "추가하지 않음 — 지운 상태 유지 (기본)" },
344
- { value: "restore", label: "다시 추가 — 최신 버전으로 설치" },
346
+ { value: "keep", label: t("interactive.removed.keep") },
347
+ { value: "restore", label: t("interactive.removed.restore") },
345
348
  ],
346
349
  });
347
- if (!isCancel(sel) && sel === "restore") restoreRemoved.add(filename); // ESC = 유지
350
+ if (!isCancel(sel) && sel === "restore") restoreRemoved.add(filename); // ESC = keep
348
351
  }
349
352
  }
350
353
 
351
- // (a) 진짜 충돌 3지선 — 타입당 1회 결정을 파일에 캐시 적용
354
+ // (a) Real conflicts, three choices - one decision per type, applied to its files via a cache
352
355
  const decisions = new Map();
353
356
  if (conflicts.length) {
354
357
  const perType = new Map();
355
358
  for (const { filename, type } of conflicts) {
356
359
  if (!perType.has(type)) {
357
360
  const sel = await io.engineIo.select({
358
- message: `내 수정과 업스트림 변경이 겹칩니다 (${type}) — 어떻게 할까요?`,
361
+ message: t("interactive.conflict.message", { type }),
359
362
  options: [
360
- { value: "skip", label: "건너뛰기 — 기존 파일 유지 (기본)" },
361
- { value: "backup", label: ".bak 백업 후 새 버전으로 교체" },
362
- { value: "template", label: "기존 유지 + 새 버전을 .template.yaml로 참고 추가" },
363
+ { value: "skip", label: t("interactive.conflict.skip") },
364
+ { value: "backup", label: t("interactive.conflict.backup") },
365
+ { value: "template", label: t("interactive.conflict.template") },
363
366
  ],
364
367
  });
365
- perType.set(type, isCancel(sel) || sel == null ? "skip" : sel); // ESC = 건너뛰기
368
+ perType.set(type, isCancel(sel) || sel == null ? "skip" : sel); // ESC = skip
366
369
  }
367
370
  decisions.set(filename, perType.get(type));
368
371
  }
@@ -372,7 +375,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
372
375
 
373
376
  const result = runFull(ctx, payload, cwd, hooks);
374
377
 
375
- // 완료 요약
378
+ // Completion summary
376
379
  io.summary?.({
377
380
  mode, types, version, versionCode, branches, developMissing,
378
381
  copiedFiles: result?.workflows?.copiedFiles ?? [],
@@ -391,30 +394,30 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
391
394
  scripts: result?.scripts ?? null,
392
395
  });
393
396
  const notices = postInstallNotices(result, { interactive: true });
394
- if (notices.length) io.note?.(notices.join("\n"), "이전 워크플로우 정리");
395
- io.outro?.(`통합 완료 — ${mode} 모드로 설치했습니다.`);
397
+ if (notices.length) io.note?.(notices.join("\n"), t("interactive.notices.title"));
398
+ io.outro?.(t("interactive.outro.done", { mode }));
396
399
  return 0;
397
400
  }
398
401
 
399
- // 브랜치 선택 — 원격 목록이 있으면 select(+직접 입력), 없으면 텍스트 입력. ESC/빈값 = 기본값.
400
- // def를 최우선으로, main/develop을 그다음으로 정렬하고 커서를 def에 고정한다.
402
+ // Branch selection - select (+ manual entry) when a remote list exists, otherwise text input. ESC/empty = default.
403
+ // def sorts first, main/develop next, and the cursor is pinned to def.
401
404
  export async function pickBranch(io, message, def, remoteBranches, isCancel) {
402
405
  if (io.engineIo?.select && remoteBranches.length) {
403
406
  const sorted = sortBranchesForSelection(remoteBranches, def);
404
407
  const options = [];
405
- if (!sorted.includes(def)) options.push({ value: def, label: `${def} (기본값 — 없으면 새로 생성)` });
406
- for (const b of sorted) options.push({ value: b, label: b === def ? `${b} (기본값)` : b });
407
- options.push({ value: "__custom__", label: "직접 입력..." });
408
+ if (!sorted.includes(def)) options.push({ value: def, label: t("interactive.branch.defaultNew", { name: def }) });
409
+ for (const b of sorted) options.push({ value: b, label: b === def ? t("interactive.branch.defaultMark", { name: b }) : b });
410
+ options.push({ value: "__custom__", label: t("interactive.branch.custom") });
408
411
  const initialIndex = Math.max(0, options.findIndex((o) => o.value === def));
409
412
  const sel = await io.engineIo.select({ message, options, initialIndex });
410
- if (sel === "__custom__") return askBranchName(io, "브랜치 이름", def, isCancel);
413
+ if (sel === "__custom__") return askBranchName(io, t("interactive.branch.namePrompt"), def, isCancel);
411
414
  return isCancel(sel) || sel == null ? def : sel;
412
415
  }
413
416
  return askBranchName(io, message, def, isCancel);
414
417
  }
415
418
 
416
- // 브랜치 이름 텍스트 입력 — 앞뒤 공백을 떼고, 비었거나 ESC면 기본값, 쓸 수 없는 이름이면 다시 묻는다.
417
- // 입력값이 워크플로우 트리거에 그대로 들어가므로 공백 포함 이름 등은 워크플로우가 영영 돌지 않는다.
419
+ // Branch name text input - trim whitespace, use the default when empty or ESC, ask again for unusable names.
420
+ // The value goes into the workflow trigger as is, so names with spaces etc. would keep the workflow from ever running.
418
421
  async function askBranchName(io, message, def, isCancel) {
419
422
  for (;;) {
420
423
  const v = await io.askText(message, def);
@@ -422,40 +425,40 @@ async function askBranchName(io, message, def, isCancel) {
422
425
  const name = String(v ?? "").trim();
423
426
  if (!name) return def;
424
427
  if (isValidBranchName(name)) return name;
425
- io.note?.(`'${name}'은(는) 브랜치 이름으로 쓸 수 없습니다 (공백·특수문자·'..' 등 불가) — 다시 입력하세요.`, "⚠ 브랜치");
428
+ io.note?.(t("interactive.branch.invalid", { name }), t("interactive.branch.invalidTitle"));
426
429
  }
427
430
  }
428
431
 
429
432
  function summarize({ mode, types, version, branch, showOptional, flutter, envModeDefault, options = null }) {
430
433
  const lines = [
431
- `통합 모드 : ${modeLabel(mode)}`,
432
- `프로젝트 타입 : ${types.join(", ")}${types.length > 1 ? " (멀티)" : ""}`,
433
- `버전 : ${version}`,
434
- `기본 브랜치 : ${branch}`,
434
+ t("interactive.summary.mode", { value: modeLabel(mode) }),
435
+ t("interactive.summary.types", { value: `${types.join(", ")}${types.length > 1 ? t("interactive.summary.multi") : ""}` }),
436
+ t("interactive.summary.version", { value: version }),
437
+ t("interactive.summary.branch", { value: branch }),
435
438
  ];
436
439
  if (showOptional) {
437
440
  if (types.includes("flutter")) {
438
441
  const stores = flutter.stores ?? [];
439
442
  const modeParts = stores.map((p) => `${p}=${(p === "android" ? flutter.androidDeployMode : flutter.iosDeployMode) || DEFAULT_DEPLOY_MODE}`);
440
- lines.push(`환경변수 방식 : ${flutter.envMode || envModeDefault}`);
441
- lines.push(`스토어 배포 대상 : ${stores.length ? stores.join(", ") : "없음"}`);
442
- lines.push(`배포 모드 : ${modeParts.length ? modeParts.join(" ") : "없음"}`);
443
+ lines.push(t("interactive.summary.envMode", { value: flutter.envMode || envModeDefault }));
444
+ lines.push(t("interactive.summary.stores", { value: stores.length ? stores.join(", ") : t("interactive.summary.none") }));
445
+ lines.push(t("interactive.summary.deployMode", { value: modeParts.length ? modeParts.join(" ") : t("interactive.summary.none") }));
443
446
  }
444
447
  }
445
448
  if (options) {
446
- lines.push(`자동 버전 승격 : ${options.semverAuto ? "켜짐" : "꺼짐"}`);
447
- lines.push(`Copilot AI 요약 : ${options.copilotAi ? "켜짐" : "꺼짐"}`);
449
+ lines.push(t("interactive.summary.semverAuto", { value: options.semverAuto ? t("interactive.summary.on") : t("interactive.summary.off") }));
450
+ lines.push(t("interactive.summary.copilotAi", { value: options.copilotAi ? t("interactive.summary.on") : t("interactive.summary.off") }));
448
451
  }
449
452
  return lines.join("\n");
450
453
  }
451
454
 
452
455
  function modeLabel(m) {
453
- return { full: "전체 설치", version: "버전 관리만", workflows: "워크플로우만" }[m] || m;
456
+ return { full: t("interactive.modeLabel.full"), version: t("interactive.modeLabel.version"), workflows: t("interactive.modeLabel.workflows") }[m] || m;
454
457
  }
455
458
 
456
459
  function utcNow(date = new Date()) {
457
460
  const p = (n) => String(n).padStart(2, "0");
458
461
  const d = `${date.getUTCFullYear()}-${p(date.getUTCMonth() + 1)}-${p(date.getUTCDate())}`;
459
- const t = `${p(date.getUTCHours())}:${p(date.getUTCMinutes())}:${p(date.getUTCSeconds())}`;
460
- return { now: `${d} ${t}`, today: d };
462
+ const tm = `${p(date.getUTCHours())}:${p(date.getUTCMinutes())}:${p(date.getUTCSeconds())}`;
463
+ return { now: `${d} ${tm}`, today: d };
461
464
  }