project-auto-wizard 0.13.3 → 0.15.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 (115) hide show
  1. package/README.ja.md +2 -0
  2. package/README.ko.md +2 -0
  3. package/README.md +3 -1
  4. package/README.zh-CN.md +2 -0
  5. package/bin/project-auto-wizard.js +18 -4
  6. package/package.json +2 -2
  7. package/payload/config/breaking-changes.json +28 -14
  8. package/payload/config/wizard-prompts.yml +102 -46
  9. package/payload/flutter-app/android/fastlane/Fastfile.playstore +44 -32
  10. package/payload/flutter-app/ios/ExportOptions.plist +8 -8
  11. package/payload/flutter-app/ios/fastlane/Fastfile +57 -43
  12. package/payload/scripts/changelog_manager.py +304 -265
  13. package/payload/scripts/issue_helper.py +54 -50
  14. package/payload/scripts/messages.py +2585 -0
  15. package/payload/scripts/truncate_release_notes.py +7 -6
  16. package/payload/scripts/version_manager.py +110 -107
  17. package/payload/version.yml.template +2 -1
  18. package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +23 -16
  19. package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +106 -63
  20. package/payload/workflows/common/PROJECT-COMMON-ISSUE-HELPER.yaml +17 -15
  21. package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +55 -45
  22. package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +90 -72
  23. package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +59 -45
  24. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +194 -184
  25. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +226 -213
  26. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +46 -42
  27. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +334 -241
  28. package/payload/workflows/flutter/PROJECT-FLUTTER-APP-BUILD-TRIGGER.yaml +152 -128
  29. package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +246 -188
  30. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +353 -238
  31. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +96 -85
  32. package/payload/workflows/go/PROJECT-GO-CI.yaml +47 -36
  33. package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +945 -508
  34. package/payload/workflows/go/PROJECT-GO-SIMPLE-CICD.yaml +155 -133
  35. package/payload/workflows/next/PROJECT-NEXT-CI.yaml +76 -60
  36. package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +97 -77
  37. package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +62 -47
  38. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +942 -505
  39. package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +148 -126
  40. package/payload/workflows/react/PROJECT-REACT-CI.yaml +86 -70
  41. package/payload/workflows/react/PROJECT-REACT-CICD.yaml +112 -92
  42. package/payload/workflows/spring/PROJECT-SPRING-CI.yml +112 -82
  43. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +180 -150
  44. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +161 -131
  45. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +970 -549
  46. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +193 -162
  47. package/src/cli/args.js +98 -52
  48. package/src/cli/help.js +8 -41
  49. package/src/commands/doctor.js +90 -89
  50. package/src/commands/dry-run.js +64 -43
  51. package/src/commands/full.js +79 -77
  52. package/src/commands/install-settings.js +16 -15
  53. package/src/commands/interactive-flutter.js +20 -19
  54. package/src/commands/interactive.js +135 -132
  55. package/src/commands/purge.js +47 -42
  56. package/src/commands/status.js +36 -36
  57. package/src/commands/uninstall.js +43 -41
  58. package/src/context.js +12 -11
  59. package/src/core/assets.js +12 -11
  60. package/src/core/baseline.js +27 -27
  61. package/src/core/branches.js +29 -27
  62. package/src/core/branding.js +7 -6
  63. package/src/core/breaking-check.js +22 -21
  64. package/src/core/breaking.js +15 -8
  65. package/src/core/copy/app-files.js +5 -4
  66. package/src/core/copy/flutter-app.js +8 -8
  67. package/src/core/copy/gitignore.js +58 -50
  68. package/src/core/copy/readme.js +102 -36
  69. package/src/core/copy/simple.js +16 -15
  70. package/src/core/copy/workflows.js +131 -124
  71. package/src/core/deploy-style.js +51 -48
  72. package/src/core/detect-fs.js +57 -57
  73. package/src/core/detect.js +67 -65
  74. package/src/core/errors.js +2 -2
  75. package/src/core/flutter-doctor.js +17 -16
  76. package/src/core/flutter-hooks.js +33 -32
  77. package/src/core/flutter-options.js +34 -33
  78. package/src/core/fsutil.js +12 -12
  79. package/src/core/installed-stores.js +4 -4
  80. package/src/core/logger.js +42 -40
  81. package/src/core/paths-resolve.js +88 -86
  82. package/src/core/paths.js +10 -10
  83. package/src/core/release-options.js +8 -8
  84. package/src/core/removal-exec.js +14 -13
  85. package/src/core/removal-plan.js +37 -37
  86. package/src/core/types.js +47 -47
  87. package/src/core/verify.js +44 -38
  88. package/src/core/version-yml.js +76 -68
  89. package/src/core/wizard-env.js +46 -46
  90. package/src/core/wizard-labels.js +63 -41
  91. package/src/i18n/catalog/en/cli.js +50 -0
  92. package/src/i18n/catalog/en/commands.js +188 -0
  93. package/src/i18n/catalog/en/copy.js +20 -0
  94. package/src/i18n/catalog/en/core.js +74 -0
  95. package/src/i18n/catalog/en/core2.js +15 -0
  96. package/src/i18n/catalog/en/ui.js +221 -0
  97. package/src/i18n/catalog/en.js +12 -0
  98. package/src/i18n/catalog/index.js +5 -0
  99. package/src/i18n/catalog/ko/cli.js +50 -0
  100. package/src/i18n/catalog/ko/commands.js +188 -0
  101. package/src/i18n/catalog/ko/copy.js +20 -0
  102. package/src/i18n/catalog/ko/core.js +74 -0
  103. package/src/i18n/catalog/ko/core2.js +15 -0
  104. package/src/i18n/catalog/ko/ui.js +221 -0
  105. package/src/i18n/catalog/ko.js +10 -0
  106. package/src/i18n/index.js +44 -0
  107. package/src/i18n/languages.js +9 -0
  108. package/src/index.js +132 -97
  109. package/src/ui/ansi.js +7 -7
  110. package/src/ui/banner.js +7 -6
  111. package/src/ui/env-plan.js +82 -81
  112. package/src/ui/prompts.js +97 -122
  113. package/src/ui/readline-engine.js +59 -58
  114. package/src/ui/status-cards.js +37 -31
  115. package/src/ui/summary.js +90 -89
@@ -1,37 +1,38 @@
1
- // 타입별 프로젝트 경로 감지·확정. 모노레포에서 각 타입의 버전 파일이
2
- // 어느 폴더에 있는지 5단계 우선순위로 확정한다.
1
+ // Per-type project path detection and confirmation. In a monorepo, decides which folder holds each
2
+ // type's version file using a 5-step priority.
3
3
  //
4
- // io 주입 계약(readline-engine 시그니처 그대로):
4
+ // io injection contract (same as the readline-engine signatures):
5
5
  // io.select({message, options:[{value,label}]}) → value | CANCEL(symbol)
6
6
  // io.text({message, defaultValue}) → string | CANCEL
7
7
  // io.confirm({message, initialValue}) → bool | CANCEL
8
- // io.log(line) → 안내 출력 (없으면 stderr)
8
+ // io.log(line) → notice output (stderr when absent)
9
9
  import { existsSync, readdirSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  import { markerForType as baseMarkerForType, resolveMarker } from "./detect.js";
12
12
  import { TYPES, typeInfo } from "./types.js";
13
13
  import { normalizePath, isRepoRelativePath } from "./paths.js";
14
14
  import { CliError } from "./errors.js";
15
+ import { t as tr } from "../i18n/index.js";
15
16
 
16
- // 취소(ESC)는 CANCEL 심볼(Ctrl+C는 엔진이 예외로 중단시킨다) — ui를 import하지 않고 심볼 여부로만 판정 (core→ui 역참조 방지)
17
+ // Cancel (ESC) is the CANCEL symbol (the engine aborts on Ctrl+C with an exception); judged only by being a symbol, without importing ui (avoids a core-to-ui back reference).
17
18
  const isCancel = (v) => typeof v === "symbol";
18
19
 
19
- // 타입의 대표 마커 파일명.
20
- // detect.js는 미지 타입에 package.json을 기본 반환하지만, 경로 탐색에서는 마커가 없는 타입(basic 등)을 빈 문자열로 구분해야 해서 래핑한다.
20
+ // A type's representative marker file name.
21
+ // detect.js returns package.json by default for unknown types, but path search must tell marker-less types (basic etc.) apart with an empty string, hence the wrapper.
21
22
  const KNOWN_MARKER_TYPES = new Set(TYPES.filter((t) => t.markers.length).map((t) => t.id));
22
23
  export function markerForType(type) {
23
24
  return KNOWN_MARKER_TYPES.has(type) ? baseMarkerForType(type) : "";
24
25
  }
25
26
 
26
- // 디렉토리에 실재하는 마커 파일명 반환 — resolveMarker의 fs 구동판.
27
- // (보조 마커 포함: spring build.gradle/.kts/pom.xml, python pyproject/setup.py/requirements.txt)
27
+ // Returns the marker file name that really exists in a directory: the fs-driven version of resolveMarker.
28
+ // (including secondary markers: spring build.gradle/.kts/pom.xml, python pyproject/setup.py/requirements.txt)
28
29
  export function existingMarkerInDir(type, dir) {
29
- if (!markerForType(type)) return ""; // 미지 타입은 빈 문자열
30
+ if (!markerForType(type)) return ""; // empty string for unknown types
30
31
  return resolveMarker(type, (n) => existsSync(join(dir, n)));
31
32
  }
32
33
 
33
- // maxdepth 3 재귀 파일 탐색 — 매치 파일의 "디렉토리" 상대경로(루트는 ".")를 수집.
34
- // find의 maxdepth는 파일 경로 컴포넌트 수 기준(./a/b/f = depth 3)이므로 동일하게 계산.
34
+ // Recursive file search with maxdepth 3: collects the relative "directory" path of matching files (root is ".").
35
+ // find's maxdepth counts file path components (./a/b/f = depth 3), so it is computed the same way.
35
36
  function walkFindDirs(root, { prune, match, maxDepth = 3 }) {
36
37
  const hits = [];
37
38
  const walk = (rel, depth) => {
@@ -41,37 +42,37 @@ function walkFindDirs(root, { prune, match, maxDepth = 3 }) {
41
42
  const childDepth = depth + 1;
42
43
  const childRel = rel ? `${rel}/${e.name}` : e.name;
43
44
  if (e.isDirectory()) {
44
- // prune 폴더는 하위 전체 제외
45
+ // prune folders are excluded along with everything below them
45
46
  if (prune.has(e.name)) continue;
46
- // 자식 파일이 depth ≤ maxDepth 안에 들어올 때만 하강
47
+ // descend only when child files fall within depth <= maxDepth
47
48
  if (childDepth < maxDepth) walk(childRel, childDepth);
48
- // childDepth === maxDepth-0 인 디렉토리 내부 파일은 depth maxDepth+1 → find가 안 봄
49
- else if (childDepth === maxDepth) { /* 파일만 maxDepth까지 — 디렉토리 하강 불필요 */ }
49
+ // files inside a directory at childDepth === maxDepth are at depth maxDepth+1, which find does not see
50
+ else if (childDepth === maxDepth) { /* files count only up to maxDepth; no need to descend into directories */ }
50
51
  } else if (childDepth <= maxDepth && match(e.name)) {
51
52
  hits.push(rel === "" ? "." : rel);
52
53
  }
53
54
  }
54
55
  };
55
56
  walk("", 0);
56
- return [...new Set(hits)].sort(); // 중복 제거 + 정렬
57
+ return [...new Set(hits)].sort(); // dedupe + sort
57
58
  }
58
59
 
59
- // 타입별 마커 파일 후보 검색.
60
- // 반환: 후보 디렉토리 상대경로 배열 (루트는 ".").
60
+ // Marker-file candidate search per type.
61
+ // Returns an array of candidate directory relative paths (root is ".").
61
62
  export function findTypePathCandidates(root, type) {
62
- // ── Spring 멀티모듈: settings.gradle(.kts) 폴더 = 모듈 루트로 축약 ──
63
- // version_manager가 그 폴더 아래 build.gradle 전부를 갱신하므로 하위 모듈을 펼치지 않는다.
64
- // android/ 의 settings.gradle(Flutter/RN)은 spring이 아니므로 prune.
63
+ // -- Spring multi-module: the settings.gradle(.kts) folder collapses to the module root --
64
+ // version_manager updates every build.gradle beneath that folder, so submodules are not expanded.
65
+ // The settings.gradle under android/ (Flutter/RN) is not spring, so it is pruned.
65
66
  if (type === "spring") {
66
67
  const mm = walkFindDirs(root, {
67
68
  prune: new Set(["node_modules", ".git", "build", "dist", ".gradle", "android", "ios"]),
68
69
  match: (n) => n === "settings.gradle" || n === "settings.gradle.kts",
69
70
  });
70
71
  if (mm.length) return mm;
71
- // settings.gradle 없음 → 단일 모듈, 아래 build.gradle 폴백
72
+ // no settings.gradle: single module, fall back to build.gradle below
72
73
  }
73
74
 
74
- // 레지스트리의 마커 순서가 곧 우선순위다 (대표 파일 먼저).
75
+ // The marker order in the registry is the priority (representative file first).
75
76
  const names = typeInfo(type)?.markers;
76
77
  if (!names?.length) return [];
77
78
 
@@ -79,7 +80,7 @@ export function findTypePathCandidates(root, type) {
79
80
  "node_modules", ".git", "build", "dist", ".dart_tool", "android", "ios",
80
81
  ".gradle", "venv", ".venv", "__pycache__",
81
82
  ]);
82
- // 우선순위 높은 마커에서 발견되면 그것만 사용
83
+ // If found with a higher-priority marker, use only that
83
84
  let found = [];
84
85
  for (const n of names) {
85
86
  found = walkFindDirs(root, { prune, match: (name) => name === n });
@@ -88,43 +89,43 @@ export function findTypePathCandidates(root, type) {
88
89
 
89
90
  return found.filter((d) => {
90
91
  if (type === "flutter") {
91
- // example/ 제외 + lib/ 동반 확인 — 오탐 방지
92
+ // exclude example/ and require a sibling lib/ to avoid false positives
92
93
  if (d.includes("example")) return false;
93
94
  const libDir = d === "." ? join(root, "lib") : join(root, d, "lib");
94
95
  if (!existsSync(libDir)) return false;
95
96
  }
96
97
  if (type === "spring") {
97
- // Flutter/RN의 android/build.gradle 오탐 제외
98
+ // exclude the false positive of Flutter/RN's android/build.gradle
98
99
  if (d.includes("android")) return false;
99
100
  }
100
101
  return true;
101
102
  });
102
103
  }
103
104
 
104
- // 선택된 모든 타입의 경로를 감지·확인하여 Map<type,path> 확정
105
- // (5단계 우선순위).
106
- // ① paths에 이미 있음(--paths) → 유지
107
- // ② 루트에 마커 존재 → "." 자동
108
- // ③ existingPaths(version.yml 저장값)
109
- // ④ 후보 스캔
110
- // ⑤ 분기 — 비대화형: 기존값→후보1개→에러 / 대화형: 확인·선택·직접입력
105
+ // Detects and confirms the paths of all selected types into a Map<type,path>
106
+ // (5-step priority).
107
+ // 1. already in paths (--paths): kept
108
+ // 2. marker exists at the root: "." automatically
109
+ // 3. existingPaths (value saved in version.yml)
110
+ // 4. candidate scan
111
+ // 5. branch - non-interactive: existing value, then a single candidate, then error / interactive: confirm, select, enter manually
111
112
  export async function resolveProjectPaths({
112
113
  root, types = [], paths = new Map(), existingPaths = new Map(),
113
114
  force = false, tty = true, io = {},
114
115
  }) {
115
116
  const say = io.log || ((m) => process.stderr.write(`${m}\n`));
116
- const result = new Map(paths); // --paths 사전값 유지 (호출부 Map은 불변)
117
- const targets = types.filter((t) => t !== "basic"); // basic은 경로 불필요
117
+ const result = new Map(paths); // keep the pre-set --paths values (the caller's Map is left unchanged)
118
+ const targets = types.filter((t) => t !== "basic"); // basic needs no path
118
119
  if (targets.length === 0) return result;
119
120
 
120
121
  const total = targets.length;
121
- // ── 도입부 안내 (감지 결과 + 무엇을 할지 설명) ──
122
+ // -- Intro notice (detection result + what will happen) --
122
123
  say("");
123
- if (total > 1) say(`🔍 멀티타입 프로젝트가 감지되었습니다 — 총 ${total}개 타입`);
124
- else say(`🔍 ${targets[0]} 프로젝트가 감지되었습니다 — 총 1개 타입`);
124
+ if (total > 1) say(tr("core.paths.intro.multi", { total }));
125
+ else say(tr("core.paths.intro.single", { type: targets[0] }));
125
126
  for (const t of targets) say(` • ${t.padEnd(8)} → ${existingMarkerInDir(t, root)}`);
126
127
  say("");
127
- say("💡 '프로젝트 루트' = 그 타입의 버전 파일이 있는 폴더 (레포 루트 기준 상대경로)");
128
+ say(tr("core.paths.intro.rootDef"));
128
129
  say("");
129
130
 
130
131
  let idx = 0;
@@ -132,133 +133,134 @@ export async function resolveProjectPaths({
132
133
  idx += 1;
133
134
  const prog = `[${idx}/${total}]`;
134
135
 
135
- // ① --paths 등으로 이미 지정됨 → 최우선
136
+ // 1. already given via --paths etc.: highest priority
136
137
  if (result.get(t)) {
137
138
  const p = result.get(t);
138
139
  if (!existsSync(join(root, p))) {
139
- throw new CliError(`--paths로 지정한 경로가 존재하지 않습니다: '${t}=${p}'`);
140
+ throw new CliError(tr("core.paths.err.pathMissing", { type: t, path: p }));
140
141
  }
141
- say(` ${t} → ${p} (--paths 지정)`);
142
- // 막지는 않는다(마커 없이 쓰는 구성도 있다) — 오타로 엉뚱한 폴더를 준 경우를 알린다.
142
+ say(tr("core.paths.say.explicit", { type: t, path: p }));
143
+ // Not blocking (some setups work without the marker); this only flags a typo that pointed to the wrong folder.
143
144
  const pm = existingMarkerInDir(t, join(root, p));
144
145
  if (pm && !existsSync(join(root, p, pm))) {
145
- say(` ⚠️ ${p}에 ${t} 프로젝트 파일(${pm})이 없습니다 — 경로가 맞는지 확인하세요.`);
146
+ say(tr("core.paths.warn.noMarker", { path: p, type: t, marker: pm }));
146
147
  }
147
148
  continue;
148
149
  }
149
150
 
150
- // ② 루트에 마커 존재 → "." 자동 확정 (보조 마커 포함)
151
+ // 2. marker exists at the root: "." confirmed automatically (including secondary markers)
151
152
  const rootMarker = existingMarkerInDir(t, root);
152
153
  if (rootMarker && existsSync(join(root, rootMarker))) {
153
154
  result.set(t, ".");
154
- say(` ${t} → . (루트의 ${rootMarker})`);
155
+ say(tr("core.paths.say.rootMarker", { type: t, marker: rootMarker }));
155
156
  continue;
156
157
  }
157
158
 
158
- // ③ 기존 version.yml 저장값 → 기본 제안값
159
+ // 3. value saved in the existing version.yml: default suggestion
159
160
  const existing = existingPaths.get(t) || "";
160
161
 
161
- // ④ 후보 검색
162
+ // 4. candidate search
162
163
  const candidates = findTypePathCandidates(root, t);
163
164
  let chosen = "";
164
165
 
165
- // ── ⑤-a 비대화형 (--force 또는 TTY 없음 — root 폴백 의도적 불포함) ──
166
+ // -- 5a. non-interactive (--force or no TTY; root fallback intentionally excluded) --
166
167
  if (force || !tty) {
167
168
  if (existing) {
168
169
  chosen = existing;
169
- say(` ${t} → ${chosen} (기존 project_paths 유지)`);
170
+ say(tr("core.paths.say.keepExisting", { type: t, path: chosen }));
170
171
  } else if (candidates.length === 1) {
171
172
  chosen = candidates[0];
172
- say(` ${t} → ${chosen} (자동 감지)`);
173
+ say(tr("core.paths.say.auto", { type: t, path: chosen }));
173
174
  } else if (candidates.length === 0) {
174
- throw new CliError(`${t}: 프로젝트 경로를 찾지 못했습니다. --paths "${t}=경로"로 직접 지정하세요.`);
175
+ throw new CliError(tr("core.paths.err.none", { type: t }));
175
176
  } else {
176
- throw new CliError(`${t}: 경로 후보가 ${candidates.length}개로 모호합니다(${candidates.join(", ")}). --paths "${t}=경로"로 직접 지정하세요.`);
177
+ throw new CliError(tr("core.paths.err.ambiguous", { type: t, count: candidates.length, list: candidates.join(", ") }));
177
178
  }
178
179
  result.set(t, chosen);
179
180
  continue;
180
181
  }
181
182
 
182
- // ── ⑤-b 대화형: 후보 개수별 분기 ──
183
+ // -- 5b. interactive: branch by candidate count --
183
184
  if (candidates.length === 1) {
184
185
  const cand = candidates[0];
185
186
  const candMarker = existingMarkerInDir(t, cand === "." ? root : join(root, cand));
186
187
  const candFull = cand === "." ? candMarker : `${cand}/${candMarker}`;
187
188
  say("");
188
- say(` ${prog} 🔍 ${t} — ${candMarker} 발견`);
189
- say(` 위치: <레포루트>/${candFull}`);
190
- // '아니오'/취소 시 chosen 미설정 → 아래 직접입력 루프로
189
+ say(tr("core.paths.found.one", { prog, type: t, marker: candMarker }));
190
+ say(tr("core.paths.found.location", { file: candFull }));
191
+ // On 'No'/cancel chosen stays unset, falling through to the manual-entry loop below
191
192
  const ok = await io.confirm({
192
- message: ` ${t} 프로젝트 루트를 '${cand}'(으)로 설정할까요? (${candFull} 기준 — 아니오 선택 시 직접 입력)`,
193
+ message: tr("core.paths.confirm.candidate", { type: t, cand, file: candFull }),
193
194
  initialValue: true,
194
195
  });
195
196
  if (ok === true) chosen = cand;
196
197
  } else if (candidates.length > 1) {
197
198
  say("");
198
- say(` ${prog} 🔍 ${t}: 경로 후보 ${candidates.length}개 발견`);
199
- // 후보들 + '직접 입력' 메뉴 — value 자체를 한국어로 (센티넬 노출 방지)
199
+ say(tr("core.paths.found.many", { prog, type: t, count: candidates.length }));
200
+ // Candidates + a 'manual entry' menu: the value itself is the display text (avoids exposing a sentinel)
201
+ const manual = tr("core.paths.manualInput");
200
202
  const options = candidates.map((c) => ({
201
203
  value: c,
202
204
  label: `${c} (${existingMarkerInDir(t, c === "." ? root : join(root, c))})`,
203
205
  }));
204
- options.push({ value: "직접 입력", label: "직접 입력" });
205
- const sel = await io.select({ message: ` ${t} 프로젝트 루트를 선택하세요`, options });
206
- // ESC(취소)도 직접 입력으로 폴백
207
- if (!isCancel(sel) && sel != null && sel !== "직접 입력") chosen = sel;
206
+ options.push({ value: manual, label: manual });
207
+ const sel = await io.select({ message: tr("core.paths.select.root", { type: t }), options });
208
+ // ESC (cancel) also falls back to manual entry
209
+ if (!isCancel(sel) && sel != null && sel !== manual) chosen = sel;
208
210
  } else {
209
211
  say("");
210
- say(` ⚠️ ${prog} ${t}: 프로젝트를 찾지 못했습니다 (maxdepth 3).`);
212
+ say(tr("core.paths.found.none", { prog, type: t }));
211
213
  }
212
214
 
213
- // ── 직접 입력 루프 (위에서 미확정 시) ──
215
+ // -- Manual-entry loop (when still undecided above) --
214
216
  while (!chosen) {
215
217
  const hintMarker = existingMarkerInDir(t, root);
216
- let prompt = ` ${t} 프로젝트 루트 경로 입력 (${hintMarker} 이 있는 폴더, 예: server, app — 루트면 그냥 Enter`;
217
- if (existing) prompt += `, 현재값: ${existing}`;
218
+ let prompt = tr("core.paths.prompt.base", { type: t, marker: hintMarker });
219
+ if (existing) prompt += tr("core.paths.prompt.current", { existing });
218
220
  prompt += "): ";
219
221
  let input = await io.text({ message: prompt, defaultValue: "" });
220
- if (isCancel(input) || input == null) input = ""; // ESC → 빈값 (아래 폴백)
222
+ if (isCancel(input) || input == null) input = ""; // ESC becomes empty (falls back below)
221
223
  input = String(input).trim();
222
- // 빈값 → 기존값 또는 루트 — normalizePath 전에 판정
224
+ // Empty means the existing value or the root; decided before normalizePath
223
225
  input = input === "" ? (existing || ".") : normalizePath(input);
224
226
  if (!isRepoRelativePath(input)) {
225
- say(` ⚠️ '${input}'은(는) 레포 밖 경로입니다 — 레포 루트 기준 상대경로로 입력하세요.`);
227
+ say(tr("core.paths.warn.outsideRepo", { input }));
226
228
  continue;
227
229
  }
228
- // 검증: 입력 경로에 마커 존재 확인 (보조 마커 포함)
230
+ // Validation: confirm a marker exists at the entered path (including secondary markers)
229
231
  const m = existingMarkerInDir(t, input === "." ? root : join(root, input));
230
232
  if (m && existsSync(join(root, input === "." ? "" : input, m))) {
231
233
  chosen = input;
232
234
  } else {
233
- say(` ⚠️ ${input}/${m} 파일이 없습니다.`);
234
- // 기본(Enter)·ESC는 이 경로를 그대로 쓴다 — 기본을 '아니오'로 두면 마커가 아직 없는 타입
235
- // (프로젝트 생성 전에 미리 추가한 타입 등)은 Enter만으로는 빠져나갈 수 없었다.
236
- // 다시 입력하려면 '아니오'를 명시적으로 고른다. 경로는 version.yml project_paths에서 나중에 고칠 수 있다.
237
- const forceOk = await io.confirm({ message: " 그래도 이 경로를 사용할까요? (아니오 = 다시 입력)", initialValue: true });
235
+ say(tr("core.paths.warn.noFile", { input, marker: m }));
236
+ // Default (Enter) and ESC keep this path as is. With 'No' as the default, a type whose marker does not exist yet
237
+ // (e.g. added before the project is created) could not be exited with Enter alone.
238
+ // To enter it again, explicitly choose 'No'. The path can be fixed later in version.yml project_paths.
239
+ const forceOk = await io.confirm({ message: tr("core.paths.confirm.useAnyway"), initialValue: true });
238
240
  if (forceOk !== false) chosen = input;
239
241
  }
240
242
  }
241
243
 
242
244
  result.set(t, chosen);
243
- say(` ✅ ${t} → ${chosen}`);
245
+ say(tr("core.paths.say.chosen", { type: t, chosen }));
244
246
  }
245
247
 
246
- // ── 요약 + 같은 마커 파일 중복 경고 ──
248
+ // -- Summary + duplicate-marker-file warning --
247
249
  say("");
248
- say("📂 타입별 버전 파일 경로 확정:");
249
- const fileToTypes = new Map(); // 마커 파일 상대경로 → 그 파일을 쓰는 타입들
250
+ say(tr("core.paths.summary.title"));
251
+ const fileToTypes = new Map(); // marker file relative path to the types using that file
250
252
  for (const [pt, pp] of result) {
251
253
  const m = existingMarkerInDir(pt, pp === "." ? root : join(root, pp));
252
254
  const file = pp === "." ? m : `${pp}/${m}`;
253
- say(` ${pt} → ${file}`);
255
+ say(tr("core.paths.summary.row", { type: pt, file }));
254
256
  if (!fileToTypes.has(file)) fileToTypes.set(file, []);
255
257
  fileToTypes.get(file).push(pt);
256
258
  }
257
259
  for (const [file, ts] of fileToTypes) {
258
260
  if (ts.length > 1) {
259
- // 멱등 동작이라 막지는 않고 경고만
260
- say(` ⚠️ 같은 파일(${file})을 여러 타입(${ts.join(" ")})이 바라봅니다.`);
261
- say(" → sync 때 모두 같은 버전이 기록됩니다. 동작에는 문제없지만 의도한 구성인지 확인하세요.");
261
+ // Idempotent behavior, so only a warning rather than a block
262
+ say(tr("core.paths.dup.warn", { file, types: ts.join(" ") }));
263
+ say(tr("core.paths.dup.note"));
262
264
  }
263
265
  }
264
266
  say("");
package/src/core/paths.js CHANGED
@@ -1,4 +1,4 @@
1
- // 경로 상수 — 설치 대상(사용자 레포) 경로 + payload 내부 레이아웃.
1
+ // Path constants: install target (user repo) paths plus the payload's internal layout.
2
2
  import { join } from "node:path";
3
3
 
4
4
  export const PATHS = {
@@ -7,15 +7,15 @@ export const PATHS = {
7
7
  scriptsDir: ".github/scripts",
8
8
  };
9
9
 
10
- // payload/ 내부 레이아웃 (payload 단일 진실)
10
+ // Layout inside payload/ (payload is the single source of truth)
11
11
  export const PAYLOAD = {
12
12
  workflowsDir: "workflows", // payload/workflows/{common,spring,flutter,...}
13
13
  scriptsDir: "scripts", // payload/scripts/*.py
14
- configDir: "config", // payload/config/wizard-prompts.yml 등 (마법사 런타임용)
14
+ configDir: "config", // payload/config/wizard-prompts.yml etc. (for the wizard runtime)
15
15
  };
16
16
 
17
- // 타입 하나의 워크플로우 원본 폴더 [타입 직하위, server-deploy 하위] — 존재 여부는 호출부가 확인한다.
18
- // 설치·충돌 조사·미리보기·배포 방식 판정이 같은 폴더 구성을 봐야 결과가 서로 어긋나지 않는다.
17
+ // Source workflow folders of one type [directly under the type, under server-deploy]; callers check existence.
18
+ // Install, conflict scan, preview and deploy-style detection must all see the same folder layout so their results do not diverge.
19
19
  export function typeWorkflowDirs(payloadRoot, type) {
20
20
  const typeDir = join(payloadRoot, PAYLOAD.workflowsDir, type);
21
21
  return [typeDir, join(typeDir, "server-deploy")];
@@ -24,17 +24,17 @@ export function typeWorkflowDirs(payloadRoot, type) {
24
24
  export const WORKFLOW_PREFIX = "PROJECT";
25
25
  export const WORKFLOW_COMMON_PREFIX = "PROJECT-COMMON";
26
26
 
27
- // 경로 정규화: 앞뒤 공백·\→/·끝 /·앞 ./ 제거, 빈값→"."
27
+ // Path normalization: strips surrounding whitespace, converts \ to /, removes a trailing / and a leading ./, empty becomes "."
28
28
  export function normalizePath(p) {
29
29
  let s = String(p).trim();
30
30
  s = s.replace(/\\/g, "/");
31
- s = s.replace(/\/+$/, ""); // 끝 /
32
- s = s.replace(/^\.\//, ""); // 앞 ./
31
+ s = s.replace(/\/+$/, ""); // trailing /
32
+ s = s.replace(/^\.\//, ""); // leading ./
33
33
  return s === "" ? "." : s;
34
34
  }
35
35
 
36
- // 정규화된 경로가 레포 안(상대경로, '..' 없음)인가. 레포 밖을 가리키면 설치 파일(fastlane 등)이
37
- // 옆 레포에 만들어지고 워크플로우 paths 필터도 동작하지 않는다.
36
+ // Whether a normalized path stays inside the repo (relative, no '..'). A path pointing outside would create the
37
+ // installed files (fastlane etc.) in a neighboring repo, and the workflow paths filter would not work either.
38
38
  export function isRepoRelativePath(p) {
39
39
  if (/^\//.test(p) || /^[A-Za-z]:/.test(p)) return false;
40
40
  return !p.split("/").includes("..");
@@ -1,8 +1,8 @@
1
- // 릴리스 옵션(semver_auto·copilot_ai) 값 결정 규칙 — 비대화형(index.js)과 대화형(interactive.js)이 공유한다.
2
- // 우선순위: 명시값(CLI 플래그·질문 답변) → version.yml 저장값 → 기본값. 경로마다 따로 두면
3
- // 같은 레포가 실행 방식에 따라 다른 설정으로 설치된다.
1
+ // Value-decision rules for the release options (semver_auto, copilot_ai), shared by non-interactive (index.js) and interactive (interactive.js) modes.
2
+ // Priority: explicit value (CLI flag, prompt answer), then the value saved in version.yml, then the default.
3
+ // Keeping separate copies per path would install the same repo with different settings depending on how it was run.
4
4
 
5
- // 명시값과 저장값만 반영한다 — null이면 아직 정해지지 않은 것(대화형은 이 값으로 질문 여부를 가른다).
5
+ // Reflects only explicit and saved values. null means not decided yet (interactive mode uses it to decide whether to ask).
6
6
  export function pickReleaseOptions(explicit = {}, existing = null) {
7
7
  return {
8
8
  semverAuto: explicit.semverAuto ?? existing?.options?.semverAuto ?? null,
@@ -10,10 +10,10 @@ export function pickReleaseOptions(explicit = {}, existing = null) {
10
10
  };
11
11
  }
12
12
 
13
- // 최종값 — 정해지지 않은 옵션에 기본값을 채운다.
14
- // semver_auto: 신규 설치만 true. 기존 version.yml에 키가 없던 설치(기능 추가 이전 설치)가 조용히 켜지면
15
- // 애매한 커밋 하나로 major가 승격될 수 있어 false로 둔다.
16
- // copilot_ai: AI Credits를 소비하는 opt-in이라 신규·기존 모두 명시하지 않으면 false.
13
+ // Final values: fills undecided options with defaults.
14
+ // semver_auto: true only for fresh installs. If an existing install whose version.yml lacked the key (installed
15
+ // before the feature existed) were switched on silently, one ambiguous commit could promote a major bump, so it stays false.
16
+ // copilot_ai: an opt-in that consumes AI credits, so it is false for both new and existing installs unless stated.
17
17
  export function resolveReleaseOptions(explicit = {}, existing = null) {
18
18
  const { semverAuto, copilotAi } = pickReleaseOptions(explicit, existing);
19
19
  return {
@@ -1,5 +1,5 @@
1
- // uninstall·purge 공통 삭제 실행부 — 두 명령은 옵션을 삭제 계획으로 바꾸기만 하고,
2
- // 실제 삭제와 기록은 여기서 한 번에 처리한다. 대상 판별은 removal-plan.js(읽기 전용)가 맡는다.
1
+ // Shared removal executor for uninstall and purge - the two commands only turn options into a removal plan,
2
+ // and the actual deletion and logging happen here in one place. Target detection is done by removal-plan.js (read-only).
3
3
  import { join, posix } from "node:path";
4
4
  import { readdirSync, rmdirSync } from "node:fs";
5
5
  import { PATHS } from "./paths.js";
@@ -7,9 +7,10 @@ import { remove } from "./fsutil.js";
7
7
  import { removeVersionSectionFromReadme } from "./copy/readme.js";
8
8
  import { removeAutoAddedEntriesFromGitignore } from "./copy/gitignore.js";
9
9
  import { logRemovals } from "./logger.js";
10
+ import { t } from "../i18n/index.js";
10
11
 
11
- // 파일을 지운 뒤 비게 된 상위 폴더를 레포 루트 직전까지 정리한다(마법사가 만든 fastlane/ 등).
12
- // 비어 있지 않은 폴더를 만나면 멈추므로 사용자 파일이 남은 폴더는 건드리지 않는다.
12
+ // After deleting files, clean up the parent folders that became empty, up to just below the repo root (fastlane/ etc. made by the wizard).
13
+ // It stops at the first non-empty folder, so folders still holding user files are left alone.
13
14
  export function pruneEmptyDirs(targetRoot, relDir) {
14
15
  let rel = relDir;
15
16
  while (rel && rel !== "." && rel !== "/") {
@@ -22,8 +23,8 @@ export function pruneEmptyDirs(targetRoot, relDir) {
22
23
  }
23
24
  }
24
25
 
25
- // Flutter 앱 파일 제거 — 미수정분만 plan에 들어오므로 그대로 지우고 빈 폴더를 정리한다.
26
- // 로그는 삭제가 끝난 뒤 한꺼번에 남기므로 기록할 항목을 done에 모은다.
26
+ // Remove Flutter app files - only unmodified ones enter the plan, so delete them as-is and tidy empty folders.
27
+ // Logs are written all at once after deletion finishes, so entries to record are collected in done.
27
28
  export function removeAppFiles(targetRoot, appFiles, done = []) {
28
29
  for (const rel of appFiles) {
29
30
  remove(join(targetRoot, rel));
@@ -32,17 +33,17 @@ export function removeAppFiles(targetRoot, appFiles, done = []) {
32
33
  }
33
34
  }
34
35
 
35
- // 설치물을 지운 뒤 비게 된 .github/workflows·.github/scripts(와 .github)를 정리한다.
36
- // 이번에 그 폴더에서 무언가를 지웠을 때만 — 원래 비어 있던 폴더까지 손대지 않는다.
36
+ // Clean up .github/workflows and .github/scripts (and .github) that became empty after removing installed files.
37
+ // Only when something was deleted from that folder this run - folders that were empty to begin with are not touched.
37
38
  export function pruneInstallDirs(targetRoot, plan) {
38
39
  if (plan.workflows.length || plan.baseline?.length) pruneEmptyDirs(targetRoot, PATHS.workflowsDir);
39
40
  if (plan.scripts.length) pruneEmptyDirs(targetRoot, PATHS.scriptsDir);
40
41
  }
41
42
 
42
- // plan: { workflows, scripts, appFiles, baseline, versionYml, readme, gitignore, changelog } — 모두 "삭제 대상" 여부/목록.
43
- // options.logOrder: 뒤쪽 항목(version·readme·gitignore·changelog)의 기록 순서 — 명령마다 기존 순서를 유지한다.
44
- // options.gitignoreDetail(status): .gitignore 기록 문구. null을 돌려주면 기록하지 않는다.
45
- // 반환: 실제로 제거된 결과 — readme/gitignore는 plan이 대상으로 봤어도 안전하게 포기(skip-*)될 수 있어 실제 결과로 덮어쓴다.
43
+ // plan: { workflows, scripts, appFiles, baseline, versionYml, readme, gitignore, changelog } - each is a "to delete" flag/list.
44
+ // options.logOrder: record order of the trailing entries (version, readme, gitignore, changelog) - each command keeps its existing order.
45
+ // options.gitignoreDetail(status): the .gitignore log text. Returning null means nothing is recorded.
46
+ // Returns: what was actually removed - readme/gitignore may be safely given up (skip-*) even if the plan targeted them, so the actual result overrides.
46
47
  export function executeRemoval(targetRoot, plan, { logOrder, gitignoreDetail }) {
47
48
  const wfDir = join(targetRoot, PATHS.workflowsDir);
48
49
  const done = [];
@@ -58,7 +59,7 @@ export function executeRemoval(targetRoot, plan, { logOrder, gitignoreDetail })
58
59
  const gitignore = gitignoreStatus === "removed" || gitignoreStatus === "file-deleted";
59
60
  if (plan.versionYml) { remove(join(targetRoot, PATHS.versionFile)); entries.version.push(["remove", "version", PATHS.versionFile]); }
60
61
  for (const f of plan.changelog || []) { remove(join(targetRoot, f)); entries.changelog.push(["remove", "changelog", f]); }
61
- if (readme) entries.readme.push(["remove", "readme", "README.md 버전 섹션"]);
62
+ if (readme) entries.readme.push(["remove", "readme", t("core.removalExec.readmeSection")]);
62
63
  const detail = gitignoreStatus && gitignoreDetail(gitignoreStatus);
63
64
  if (detail) entries.gitignore.push(["remove", "gitignore", detail]);
64
65
  for (const k of logOrder) done.push(...entries[k]);