project-auto-wizard 0.13.3 → 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,14 +1,14 @@
1
- // doctor 명령 — 로컬 환경 진단(읽기 전용, 규칙 기반). gh CLI에 위임해 원격 상태를 점검한다.
1
+ // doctor command: local environment diagnosis (read-only, rule-based). Delegates to the gh CLI to check remote state.
2
2
  //
3
- // 출력 설계 — `flutter doctor` 패턴을 차용한다.
4
- // ① 항목 라벨에 purpose("무엇을 위한 설정인지")를 병기한다. `WORKFLOW_PAT`만 보고는 그게
5
- // 자기 릴리스 흐름의 무엇을 담당하는지 알 수 없다 — flutter의
6
- // `Android toolchain - develop for Android devices`와 같은 이유다.
7
- // ② 도구가 "설치해도 된다/안 된다"를 판정하지 않는다. 발견한 사실(문제 N개)만 진술하고
8
- // 그게 자신에게 문제인지는 사용자가 판단한다.
9
- // ③ 문제 항목만 `현상 → 영향 → 조치 → 문서` 4단으로 펼치고, 정상 항목은 한 줄로 압축한다.
10
- // ④ GitHub 설정 화면에 실제로 표시되는 문자열("Read and write permissions" 등)은 번역하지
11
- // 않는다. 번역하면 설명은 읽히지만 정작 화면에서 그 항목을 찾지 못한다.
3
+ // Output design, borrowed from the `flutter doctor` pattern.
4
+ // 1. Each item label carries its purpose ("what this setting is for"). `WORKFLOW_PAT` alone does not tell
5
+ // what it does in the release flow, the same reason flutter prints
6
+ // `Android toolchain - develop for Android devices`.
7
+ // 2. The tool never judges "OK to install / not OK". It only states the facts found (N problems) and
8
+ // leaves it to the user whether they matter.
9
+ // 3. Only problem items unfold into 4 levels (symptom -> impact -> action -> docs); healthy items collapse to one line.
10
+ // 4. Strings actually shown in the GitHub settings UI ("Read and write permissions", etc.) are not
11
+ // translated: a translation reads well but then the item cannot be found on the real screen.
12
12
  import { spawnSync } from "node:child_process";
13
13
  import { existsSync, readFileSync } from "node:fs";
14
14
  import { join } from "node:path";
@@ -16,33 +16,32 @@ import { A, paint, colorEnabled, visualWidth } from "../ui/ansi.js";
16
16
  import { PATHS } from "../core/paths.js";
17
17
  import { parseExisting } from "../core/version-yml.js";
18
18
  import { hooksFor } from "../core/types.js";
19
+ import { t } from "../i18n/index.js";
19
20
 
20
21
  const defaultExec = (cmd, args) => spawnSync(cmd, args, { encoding: "utf8" });
21
22
 
22
- // 해결 가이드 링크. 문서 사이트 페이지 안의 영문 HTML 앵커를 가리킨다 — 한글 헤딩
23
- // 자동 앵커는 URL 인코딩되어 터미널에서 알아볼 수 없게 깨진다.
24
- // 진단 출력이 한국어이므로 한국어 페이지(/ko/)로 연결한다.
23
+ // Resolution guide links. They point at English HTML anchors inside the docs site pages: auto-generated
24
+ // anchors from non-Latin headings get URL-encoded and become unreadable in a terminal.
25
+ // Getters so the link follows the output language (the localized page path comes from the catalog).
25
26
  export const DOCS_SITE_URL = "https://twin-fang.github.io/project-auto-wizard";
26
27
  export const DOC = {
27
- postInstall: `${DOCS_SITE_URL}/ko/start/quickstart/#post-install`,
28
- flutterStore: `${DOCS_SITE_URL}/ko/project-types/flutter/#flutter-store`,
28
+ get postInstall() { return `${DOCS_SITE_URL}${t("cmd.doctor.docPath.postInstall")}`; },
29
+ get flutterStore() { return `${DOCS_SITE_URL}${t("cmd.doctor.docPath.flutterStore")}`; },
29
30
  };
30
31
 
31
32
  export function runDoctor(cwd = process.cwd(), { exec = defaultExec } = {}) {
32
33
  const results = [];
33
- // name은 항목 식별자(테스트·프로그램 참조용), label은 화면 표기용. label 생략 시 name을 쓴다.
34
+ // name is the item identifier (for tests/programmatic reference), label is for display. Falls back to name when label is omitted.
34
35
  const add = (item) => { results.push({ actions: [], ...item }); return results; };
35
36
 
36
37
  const installed = existsSync(join(cwd, "version.yml"));
37
- // 미설치는 문제가 아니다 — doctor는 설치 "전에" 돌려보는 것이 정상 사용 경로다.
38
+ // Not installed is not a problem: running doctor "before" installing is the normal usage path.
39
+ const installItem = { name: t("cmd.doctor.install.name"), label: t("cmd.doctor.install.label"), purpose: t("cmd.doctor.install.purpose") };
38
40
  add(installed
39
- ? { name: "설치 여부", label: "설치 상태", purpose: "이 폴더의 마법사 설치 여부", status: "OK", value: "version.yml 있음" }
40
- : {
41
- name: "설치 여부", label: "설치 상태", purpose: "이 폴더의 마법사 설치 여부", status: "INFO",
42
- note: ["이 폴더엔 아직 설치되지 않았습니다 — 지금 설치하면 됩니다."],
43
- });
41
+ ? { ...installItem, status: "OK", value: t("cmd.doctor.install.value") }
42
+ : { ...installItem, status: "INFO", note: [t("cmd.doctor.install.note")] });
44
43
 
45
- // 타입별 로컬 파일 점검(예: Flutter 스토어 배포 파일) — 로컬 파일만 보므로 gh 조회 결과와 무관하게 수행한다.
44
+ // Per-type local file checks (e.g. Flutter store deploy files): only local files are read, so this runs regardless of the gh lookup results.
46
45
  if (installed) {
47
46
  const existing = parseExisting(readFileSync(join(cwd, PATHS.versionFile), "utf8"));
48
47
  for (const { hook } of hooksFor(existing.types, "doctorChecks")) {
@@ -53,41 +52,41 @@ export function runDoctor(cwd = process.cwd(), { exec = defaultExec } = {}) {
53
52
  const ghVersion = exec("gh", ["--version"]);
54
53
  if (ghVersion.error || ghVersion.status !== 0) {
55
54
  add({
56
- name: "gh CLI", purpose: "레포 설정 조회용", status: "WARN",
57
- value: "gh CLI를 찾을 수 없습니다.",
58
- impact: ["레포 권한·secret 설정을 조회할 수 없어 아래 원격 점검을 모두 건너뜁니다."],
59
- actions: ["https://cli.github.com/ 에서 gh CLI를 설치한 뒤 다시 실행하세요"],
55
+ name: "gh CLI", purpose: t("cmd.doctor.gh.purpose"), status: "WARN",
56
+ value: t("cmd.doctor.gh.notFound"),
57
+ impact: [t("cmd.doctor.gh.notFoundImpact")],
58
+ actions: [t("cmd.doctor.gh.notFoundAction")],
60
59
  });
61
60
  return results;
62
61
  }
63
62
  add({
64
- name: "gh CLI", purpose: "레포 설정 조회용", status: "OK",
65
- value: (ghVersion.stdout || "").split("\n")[0] || "설치됨",
63
+ name: "gh CLI", purpose: t("cmd.doctor.gh.purpose"), status: "OK",
64
+ value: (ghVersion.stdout || "").split("\n")[0] || t("cmd.doctor.gh.installed"),
66
65
  });
67
66
 
68
67
  const auth = exec("gh", ["auth", "status"]);
69
68
  const authOk = !auth.error && auth.status === 0;
70
69
  if (!authOk) {
71
70
  add({
72
- name: "gh 인증", label: "GitHub 로그인", purpose: "레포 설정 조회 권한", status: "FAIL",
73
- value: "로그인되어 있지 않습니다.",
74
- impact: ["레포 설정을 읽을 수 없어 아래 원격 점검을 모두 건너뜁니다."],
75
- actions: ["터미널에서 `gh auth login` 실행"],
71
+ name: t("cmd.doctor.auth.name"), label: t("cmd.doctor.auth.label"), purpose: t("cmd.doctor.auth.purpose"), status: "FAIL",
72
+ value: t("cmd.doctor.auth.notLoggedIn"),
73
+ impact: [t("cmd.doctor.auth.impact")],
74
+ actions: [t("cmd.doctor.auth.action")],
76
75
  });
77
76
  return results;
78
77
  }
79
- add({ name: "gh 인증", label: "GitHub 로그인", purpose: "레포 설정 조회 권한", status: "OK", value: "인증됨" });
78
+ add({ name: t("cmd.doctor.auth.name"), label: t("cmd.doctor.auth.label"), purpose: t("cmd.doctor.auth.purpose"), status: "OK", value: t("cmd.doctor.auth.ok") });
80
79
 
81
80
  const remote = exec("git", ["-C", cwd, "remote", "get-url", "origin"]);
82
81
  const url = remote.status === 0 ? (remote.stdout || "").trim() : "";
83
- // 레포 이름에는 점이 올 수 있다(user.github.io, next.js) — 끝의 .git만 떼어낸다.
82
+ // Repo names may contain dots (user.github.io, next.js); strip only the trailing .git.
84
83
  const match = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(\.git)?\/?$/);
85
84
  if (!match) {
86
85
  add({
87
- name: "GitHub 원격", purpose: "점검 대상 레포 식별", status: "WARN",
88
- value: "origin 리모트에서 GitHub owner/repo를 찾지 못했습니다.",
89
- impact: ["어느 레포를 점검해야 할지 알 수 없어 아래 원격 점검을 모두 건너뜁니다."],
90
- actions: ["`git remote -v`로 origin이 GitHub 주소를 가리키는지 확인하세요"],
86
+ name: t("cmd.doctor.remote.name"), purpose: t("cmd.doctor.remote.purpose"), status: "WARN",
87
+ value: t("cmd.doctor.remote.notFound"),
88
+ impact: [t("cmd.doctor.remote.impact")],
89
+ actions: [t("cmd.doctor.remote.action")],
91
90
  });
92
91
  return results;
93
92
  }
@@ -95,30 +94,30 @@ export function runDoctor(cwd = process.cwd(), { exec = defaultExec } = {}) {
95
94
 
96
95
  const perm = exec("gh", ["api", `repos/${owner}/${repo}/actions/permissions/workflow`, "--jq", ".default_workflow_permissions"]);
97
96
  const permValue = (perm.stdout || "").trim();
98
- // 이 값은 "워크플로우가 permissions를 생략했을 때 적용되는 기본값"이지 상한이 아니다.
99
- // 마법사가 설치하는 워크플로우는 전부 자체 permissions를 선언하므로(회귀 가드:
100
- // tests/node/payload-workflow-permissions.test.js) read여도 정상 동작한다 — 이 레포 자체가
101
- // read인데 VERSION-CONTROL이 버전 커밋 push에 성공하는 것이 그 증거다.
102
- const PERM_PURPOSE = "직접 추가한 워크플로우의 기본 권한";
97
+ // This value is the "default applied when a workflow omits permissions", not an upper bound.
98
+ // Every workflow the wizard installs declares its own permissions (regression guard:
99
+ // tests/node/payload-workflow-permissions.test.js), so it works even with read; this repo itself is
100
+ // read and VERSION-CONTROL still succeeds in pushing the version commit, which is the proof.
101
+ const PERM_PURPOSE = t("cmd.doctor.perm.purpose");
103
102
  if (perm.status !== 0) {
104
- // 조회 실패는 오진이 아니라 실제로 정보를 얻지 못한 상태다 — 관리자 확인이라는 조치가 있다.
103
+ // A failed lookup is not a misdiagnosis but a real lack of information; the action is to ask an admin.
105
104
  add({
106
105
  name: "Workflow permissions", purpose: PERM_PURPOSE, status: "WARN",
107
- value: "설정을 조회하지 못했습니다.",
108
- impact: ["레포 관리자 권한이 없으면 이 설정은 조회되지 않습니다 — 값 자체는 정상일 수 있습니다."],
109
- actions: ["레포 관리자에게 Settings → Actions → General → Workflow permissions 값을 확인하세요"],
106
+ value: t("cmd.doctor.perm.lookupFailed"),
107
+ impact: [t("cmd.doctor.adminImpact")],
108
+ actions: [t("cmd.doctor.perm.action")],
110
109
  doc: DOC.postInstall,
111
110
  });
112
111
  } else if (permValue === "write") {
113
112
  add({ name: "Workflow permissions", purpose: PERM_PURPOSE, status: "OK", value: "Read and write" });
114
113
  } else {
115
- // 조치가 필요 없으므로 WARN이 아니라 INFO다. 경고로 띄우면 없는 장애를 알리고
116
- // 불필요한 권한 상향을 유도해 최소 권한 원칙에 역행한다.
114
+ // No action is needed, so this is INFO rather than WARN. A warning would announce a nonexistent problem
115
+ // and push users toward an unnecessary permission increase, against the least-privilege principle.
117
116
  add({
118
117
  name: "Workflow permissions", purpose: PERM_PURPOSE, status: "INFO",
119
118
  note: [
120
- `현재 ${permValue || "확인불가"} 입니다 — 마법사가 설치한 워크플로우는 각자 권한을 선언하므로 그대로 동작합니다.`,
121
- "직접 추가한 워크플로우에서 permissions를 생략했다면 이 기본값을 따르므로, 그때만 Read and write로 올리세요.",
119
+ t("cmd.doctor.perm.infoCurrent", { value: permValue || t("cmd.doctor.perm.unknown") }),
120
+ t("cmd.doctor.perm.infoAdvice"),
122
121
  ],
123
122
  });
124
123
  }
@@ -126,57 +125,54 @@ export function runDoctor(cwd = process.cwd(), { exec = defaultExec } = {}) {
126
125
  const secrets = exec("gh", ["secret", "list", "--repo", `${owner}/${repo}`]);
127
126
  const hasPat = secrets.status === 0 && (secrets.stdout || "").split("\n").some((l) => l.startsWith("WORKFLOW_PAT"));
128
127
  add(hasPat
129
- ? { name: "WORKFLOW_PAT secret", label: "WORKFLOW_PAT", purpose: "자동 태그·Release 발행", status: "OK", value: "등록됨" }
128
+ ? { name: "WORKFLOW_PAT secret", label: "WORKFLOW_PAT", purpose: t("cmd.doctor.pat.purpose"), status: "OK", value: t("cmd.doctor.pat.registered") }
130
129
  : {
131
- // 조치가 필요 없으므로 WARN이 아니라 INFO다 — 위 Workflow permissions 항목과 동일 이유:
132
- // 폴백(wait-for-merge-and-trigger-release / Trigger NPM-PUBLISH)이 GITHUB_TOKEN만으로
133
- // 파이프라인을 끝까지 이어가므로, 없는 장애를 경고로 띄우지 않는다.
134
- name: "WORKFLOW_PAT secret", label: "WORKFLOW_PAT", purpose: "자동 태그·Release 발행", status: "INFO",
135
- note: [
136
- "secret이 없어도 폴백이 자동으로 이어받아 태그·Release 발행과 main 배포 워크플로우 실행까지 진행됩니다 — 실제 병합 후 최대 ~20초 정도 더 걸릴 뿐입니다.",
137
- "속도를 더 원한다면 PAT을 등록할 수 있습니다 — 반드시 개인 계정이 아닌 조직 bot/machine 계정으로 발급하세요 (scopes: repo, workflow).",
138
- "등록: 레포 Settings → Secrets and variables → Actions → New repository secret · 이름은 WORKFLOW_PAT",
139
- ],
130
+ // No action is needed, so this is INFO rather than WARN, for the same reason as the Workflow permissions
131
+ // item above: the fallback (wait-for-merge-and-trigger-release / Trigger NPM-PUBLISH) carries the
132
+ // pipeline through to the end with GITHUB_TOKEN alone, so a nonexistent problem is not raised as a warning.
133
+ name: "WORKFLOW_PAT secret", label: "WORKFLOW_PAT", purpose: t("cmd.doctor.pat.purpose"), status: "INFO",
134
+ note: [t("cmd.doctor.pat.note1"), t("cmd.doctor.pat.note2"), t("cmd.doctor.pat.note3")],
140
135
  });
141
136
 
142
137
  const mergeSettings = exec("gh", ["api", `repos/${owner}/${repo}`, "--jq", ".allow_merge_commit"]);
143
138
  const automergeOk = mergeSettings.status === 0 && mergeSettings.stdout.trim() === "true";
139
+ const mergeItem = { name: t("cmd.doctor.merge.name"), label: t("cmd.doctor.merge.label"), purpose: t("cmd.doctor.merge.purpose") };
144
140
  if (mergeSettings.status !== 0) {
145
141
  add({
146
- name: "automerge 호환성(merge commit 허용)", label: "merge commit 허용", purpose: "릴리스 PR 자동 머지 조건", status: "WARN",
147
- value: "설정을 조회하지 못했습니다.",
148
- impact: ["레포 관리자 권한이 없으면 이 설정은 조회되지 않습니다 — 값 자체는 정상일 수 있습니다."],
149
- actions: ["레포 관리자에게 Settings → General → Pull Requests 설정을 확인하세요"],
142
+ ...mergeItem, status: "WARN",
143
+ value: t("cmd.doctor.merge.lookupFailed"),
144
+ impact: [t("cmd.doctor.adminImpact")],
145
+ actions: [t("cmd.doctor.merge.lookupAction")],
150
146
  doc: DOC.postInstall,
151
147
  });
152
148
  } else if (automergeOk) {
153
- add({ name: "automerge 호환성(merge commit 허용)", label: "merge commit 허용", purpose: "릴리스 PR 자동 머지 조건", status: "OK", value: "허용됨" });
149
+ add({ ...mergeItem, status: "OK", value: t("cmd.doctor.merge.allowed") });
154
150
  } else {
155
151
  add({
156
- name: "automerge 호환성(merge commit 허용)", label: "merge commit 허용", purpose: "릴리스 PR 자동 머지 조건", status: "WARN",
157
- value: "이 레포는 merge commit이 꺼져 있습니다.",
158
- impact: ["RELEASE-PUBLISH가 머지 커밋 제목으로 릴리스를 감지하므로 자동 머지 흐름이 어긋날 수 있습니다."],
152
+ ...mergeItem, status: "WARN",
153
+ value: t("cmd.doctor.merge.disabled"),
154
+ impact: [t("cmd.doctor.merge.impact")],
159
155
  actions: [
160
- "레포 Settings → General → Pull Requests",
161
- "Allow merge commits 체크",
156
+ t("cmd.doctor.merge.action1"),
157
+ t("cmd.doctor.merge.action2"),
162
158
  ],
163
159
  doc: DOC.postInstall,
164
160
  });
165
161
  }
166
162
 
167
- // 실제 설정값을 보여준다 — 켜 둔 사용자에게 "꺼져 있다"고 안내하면 현재 상태를 오해하게 된다.
163
+ // Show the actual setting: telling a user who turned it on that it is off would misrepresent the current state.
168
164
  const copilotAi = installed ? parseExisting(readFileSync(join(cwd, "version.yml"), "utf8")).options.copilotAi : null;
169
165
  const copilotState = copilotAi === true
170
- ? "켜져 있습니다 (version.yml의 copilot_ai: true)."
166
+ ? t("cmd.doctor.copilot.on")
171
167
  : copilotAi === false
172
- ? "꺼져 있습니다 (version.yml의 copilot_ai: false) — 켜려면 --copilot으로 다시 설치하거나 대화형 '수정하기 > Copilot AI 요약'을 쓰세요."
173
- : "기본은 꺼져 있습니다 (version.yml의 copilot_ai: false).";
168
+ ? t("cmd.doctor.copilot.off")
169
+ : t("cmd.doctor.copilot.default");
174
170
  add({
175
- name: "Copilot AI 요약", label: "Copilot AI 요약", purpose: "AI 릴리스 노트 생성(선택)", status: "INFO",
171
+ name: t("cmd.doctor.copilot.name"), label: t("cmd.doctor.copilot.name"), purpose: t("cmd.doctor.copilot.purpose"), status: "INFO",
176
172
  note: [
177
173
  copilotState,
178
- `${copilotAi === true ? "" : "켜면 "}GitHub Copilot AI Credits가 소비됩니다 — 조직은 'Allow use of Copilot CLI billed to the organization' 정책이 필요합니다.`,
179
- "꺼져 있거나 사용할 수 없으면 규칙 기반 요약으로 자동 전환되므로 그대로 두셔도 됩니다.",
174
+ t(copilotAi === true ? "cmd.doctor.copilot.creditsOn" : "cmd.doctor.copilot.creditsOff"),
175
+ t("cmd.doctor.copilot.fallback"),
180
176
  ],
181
177
  });
182
178
 
@@ -192,9 +188,9 @@ export function printDoctorReport(results, { out = (s) => console.log(s), color
192
188
  const problems = results.filter((r) => r.status === "WARN" || r.status === "FAIL");
193
189
  const infos = results.filter((r) => r.status === "INFO");
194
190
 
195
- const lines = ["", `${p("◆", A.cyan)} ${p("환경 진단", A.bold)} ${p("— project-auto-wizard doctor", A.dim)}`, ""];
191
+ const lines = ["", `${p("◆", A.cyan)} ${p(t("cmd.doctor.heading"), A.bold)} ${p("— project-auto-wizard doctor", A.dim)}`, ""];
196
192
 
197
- // 정상 — 한 줄씩. 값은 라벨 폭에 맞춰 우측으로 정렬한다(CJK 2칸 계산은 visualWidth에 위임).
193
+ // Healthy items: one line each. Values are aligned to the label width (double-width CJK counting is delegated to visualWidth).
198
194
  if (oks.length) {
199
195
  const width = Math.max(...oks.map((r) => visualWidth(headOf(r))));
200
196
  for (const r of oks) {
@@ -205,18 +201,18 @@ export function printDoctorReport(results, { out = (s) => console.log(s), color
205
201
  lines.push("");
206
202
  }
207
203
 
208
- // 문제 — 현상 → 영향 → 조치 → 문서 순으로 펼친다.
204
+ // Problems: unfold as symptom -> impact -> action -> docs.
209
205
  for (const r of problems) {
210
206
  const fail = r.status === "FAIL";
211
207
  lines.push(` ${p(fail ? "[✗]" : "[!]", fail ? A.red : A.yellow)} ${p(headOf(r), A.bold)}`);
212
208
  if (r.value) lines.push(` ${p(fail ? "✗" : "✗", fail ? A.red : A.yellow)} ${r.value}`);
213
209
  for (const l of asLines(r.impact)) lines.push(` ${p(l, A.dim)}`);
214
210
  for (const a of asLines(r.actions)) lines.push(` ${p("→", A.cyan)} ${a}`);
215
- if (r.doc) lines.push(` ${p("→", A.cyan)} 자세히: ${p(r.doc, A.dim)}`);
211
+ if (r.doc) lines.push(` ${p("→", A.cyan)} ${t("cmd.doctor.details")}${p(r.doc, A.dim)}`);
216
212
  lines.push("");
217
213
  }
218
214
 
219
- // 참고 — 조치가 필요 없는 안내.
215
+ // Notes: guidance that needs no action.
220
216
  for (const r of infos) {
221
217
  lines.push(` ${p("[i]", A.gray)} ${headOf(r)}`);
222
218
  for (const l of asLines(r.note)) lines.push(` ${p(l, A.dim)}`);
@@ -227,15 +223,15 @@ export function printDoctorReport(results, { out = (s) => console.log(s), color
227
223
  out(lines.join("\n"));
228
224
  }
229
225
 
230
- // 요약 — "설치해도 된다"는 판정 대신 발견한 사실만 말한다(flutter doctor의 마지막 줄과 같은 태도).
226
+ // Summary: states only the facts found instead of an "OK to install" verdict (same stance as flutter doctor's last line).
231
227
  function summaryLine(problems, p) {
232
- if (!problems.length) return ` ${p("✓", A.green)} 문제를 찾지 못했습니다.`;
228
+ if (!problems.length) return ` ${p("✓", A.green)} ${t("cmd.doctor.summaryNone")}`;
233
229
  const n = problems.length;
234
230
  if (problems.some((r) => r.status === "FAIL")) {
235
- return ` ${p("✗", A.red)} ${n}개 항목에서 문제를 찾았습니다 — 일부 점검은 실행하지 못했습니다.`;
231
+ return ` ${p("✗", A.red)} ${t("cmd.doctor.summaryFail", { n })}`;
236
232
  }
237
233
  return [
238
- ` ${p("!", A.yellow)} ${n}개 항목에서 문제를 찾았습니다.`,
239
- ` 설치 자체는 지금 진행할 수 있고, 위 ${n}개는 나중에 설정해도 됩니다.`,
234
+ ` ${p("!", A.yellow)} ${t("cmd.doctor.summaryWarn", { n })}`,
235
+ ` ${t("cmd.doctor.summaryWarnNote", { n })}`,
240
236
  ].join("\n");
241
237
  }
@@ -1,5 +1,5 @@
1
- // --dry-run 미리보기 — 실제 파일을 쓰지 않고 무엇이 바뀔지 계산한다.
2
- // full/uninstall 두 모드 지원 (부분 설치·되돌리기 모드 제거).
1
+ // --dry-run preview: computes what would change without writing any file.
2
+ // Supports the full/uninstall modes (partial-install and revert modes were removed).
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { PATHS } from "../core/paths.js";
@@ -14,6 +14,7 @@ import { planVersionSection } from "../core/copy/readme.js";
14
14
  import { BASELINE_PATH, readBaseline } from "../core/baseline.js";
15
15
  import { cleanupWorkflows } from "./full.js";
16
16
  import { planGitignore } from "../core/copy/gitignore.js";
17
+ import { t } from "../i18n/index.js";
17
18
 
18
19
  function versionYmlPreview(context, payloadRoot, targetRoot) {
19
20
  const { paths = new Map() } = context;
@@ -24,35 +25,36 @@ function versionYmlPreview(context, payloadRoot, targetRoot) {
24
25
  const existingRaw = existsSync(vyPath) ? readFileSync(vyPath, "utf8") : null;
25
26
  const extraTopLevel = existingRaw !== null ? parseExisting(existingRaw).extraTopLevel : [];
26
27
 
27
- // 실제 설치와 같은 렌더 함수를 쓴다 — 각자 조립하면 옵션이 늘 때마다 미리보기가 어긋난다.
28
+ // Use the same render function as the real install; assembling separately drifts the preview whenever an option is added.
28
29
  const wouldBe = renderVersionYml(context, readVersionYmlTemplate(payloadRoot), {
29
30
  pathMarkers, extraTopLevel,
30
31
  });
31
- // 실제 설치와 같은 기준으로 비교한다 — 시각 줄만 다르면 설치도 파일을 다시 쓰지 않는다.
32
+ // Compare by the same rule as the real install: if only the timestamp lines differ, the install does not rewrite the file either.
32
33
  return { existed: existingRaw !== null, changed: existingRaw === null || !sameIgnoringTimestamps(existingRaw, wouldBe) };
33
34
  }
34
35
 
35
- // mode: "full" | "uninstall". 읽기 전용 — 아무 파일도 쓰지 않는다.
36
+ // mode: "full" | "uninstall". Read-only: writes no file.
36
37
  export function planDryRun(mode, context, payloadRoot, targetRoot = ".") {
37
38
  if (mode === "uninstall") {
38
39
  return { mode, uninstall: planUninstall(payloadRoot, targetRoot, context.uninstallSelection) };
39
40
  }
40
41
  const workflows = planWorkflows(context, payloadRoot, targetRoot);
41
- // 실제 실행과 같은 정리 판정을 파일을 건드리지 않고 돌린다 — 배포 방식 변경·스토어 해제로 지워지거나
42
- // .bak으로 옮겨질 파일이 미리보기에서 빠지면 안 된다. 비대화형 실행이 새로 쓰는 파일은 신규·자동 갱신분이다.
42
+ // Run the same cleanup decision as the real run without touching files: files deleted or moved to .bak by a
43
+ // deploy style change or a store deselection must not be missing from the preview. Files a non-interactive
44
+ // run writes anew are the new and auto-updated ones.
43
45
  const justWritten = [...workflows.newFiles, ...workflows.upstreamOnly].map((f) => f.filename);
44
46
  const cleanup = cleanupWorkflows(context, payloadRoot, targetRoot, readBaseline(targetRoot), { justWritten, dryRun: true });
45
- // .bak이 생기면 실제 실행은 .gitignore에 백업 파일 항목을 보장한다. 비대화형 충돌은 기존 유지라 .bak을 만들지 않는다.
47
+ // When a .bak appears, the real run ensures a backup-file entry in .gitignore. Non-interactive conflicts keep the existing file, so no .bak is made.
46
48
  const backedUp = Object.values(cleanup).some((r) => r.backedUp.length > 0);
47
49
  return {
48
50
  mode,
49
51
  workflows,
50
52
  cleanup,
51
53
  gitignore: backedUp ? planGitignore(targetRoot) : null,
52
- // Flutter 스토어 배포 파일(Fastfile·ExportOptions.plist) — 이미 있는 파일은 덮어쓰지 않고 유지한다.
54
+ // Flutter store deploy files (Fastfile, ExportOptions.plist): existing files are kept, not overwritten.
53
55
  flutterApp: planTypeAppFiles(context, payloadRoot, targetRoot),
54
56
  versionYml: versionYmlPreview(context, payloadRoot, targetRoot),
55
- // 실제 설치가 함께 바꾸는 파일 — 특히 기존 파일을 덮어쓰는 스크립트는 미리 보여줘야 한다.
57
+ // Files the real install also changes; scripts that overwrite existing files in particular must be shown in advance.
56
58
  scripts: planScripts(payloadRoot, targetRoot),
57
59
  readme: planVersionSection(targetRoot),
58
60
  baselineExists: existsSync(join(targetRoot, BASELINE_PATH)),
@@ -60,74 +62,92 @@ export function planDryRun(mode, context, payloadRoot, targetRoot = ".") {
60
62
  }
61
63
 
62
64
  export function printDryRun(plan) {
63
- const lines = ["", `project-auto-wizard --dry-run (mode: ${plan.mode}) — 미리보기, 실제 파일은 바뀌지 않았습니다`, ""];
65
+ const lines = ["", t("cmd.dryRun.header", { mode: plan.mode }), ""];
64
66
  if (plan.mode === "uninstall") {
65
67
  const u = plan.uninstall;
66
- lines.push(`제거될 워크플로우 (${u.workflows.length}개):`);
68
+ lines.push(t("cmd.dryRun.uninstall.workflows", { n: u.workflows.length }));
67
69
  for (const f of u.workflows) lines.push(` - ${f}`);
68
- lines.push(`제거될 스크립트 (${u.scripts.length}개):`);
70
+ lines.push(t("cmd.dryRun.uninstall.scripts", { n: u.scripts.length }));
69
71
  for (const f of u.scripts) lines.push(` - ${f}`);
70
- for (const f of u.appFiles || []) lines.push(`제거될 Flutter 앱 파일: ${f}`);
71
- if (u.readme) lines.push("제거될 항목: README.md 버전 섹션 (AUTO-VERSION-SECTION)");
72
- if (u.gitignore) lines.push("제거될 항목: .gitignore 자동 추가 항목");
73
- if (u.versionYml) lines.push("제거될 파일: version.yml");
72
+ for (const f of u.appFiles || []) lines.push(t("cmd.dryRun.uninstall.appFile", { file: f }));
73
+ if (u.readme) lines.push(t("cmd.dryRun.uninstall.readme"));
74
+ if (u.gitignore) lines.push(t("cmd.dryRun.uninstall.gitignore"));
75
+ if (u.versionYml) lines.push(t("cmd.dryRun.uninstall.versionYml"));
74
76
  } else {
75
77
  if (plan.workflows) {
76
78
  const w = plan.workflows;
77
- lines.push(`신규 파일 (${w.newFiles.length}개):`);
79
+ lines.push(t("cmd.dryRun.full.newFiles", { n: w.newFiles.length }));
78
80
  for (const f of w.newFiles) lines.push(` + ${f.filename} [${f.type}]`);
79
- lines.push(`변경될 파일 (${w.changed.length}개, 기존 설치가 사용자 수정본이면 충돌):`);
81
+ lines.push(t("cmd.dryRun.full.changed", { n: w.changed.length }));
80
82
  for (const f of w.changed) lines.push(` ~ ${f.filename} [${f.type}]`);
81
- lines.push(`동일한 파일 (${w.unchanged.length}개, 변경 없음)`);
83
+ lines.push(t("cmd.dryRun.full.unchanged", { n: w.unchanged.length }));
84
+ // Buckets that exist only with a baseline. The real run acts on each of them, so the preview lists them too
85
+ // (only when non-empty, so installs without a baseline print exactly as before).
86
+ if (w.upstreamOnly?.length) {
87
+ lines.push(t("cmd.dryRun.full.autoUpdated", { n: w.upstreamOnly.length }));
88
+ for (const f of w.upstreamOnly) lines.push(` ~ ${f.filename} [${f.type}]`);
89
+ }
90
+ if (w.localOnly?.length) {
91
+ lines.push(t("cmd.dryRun.full.localKept", { n: w.localOnly.length }));
92
+ for (const f of w.localOnly) lines.push(` = ${f.filename} [${f.type}]`);
93
+ }
94
+ if (w.removed?.length) {
95
+ lines.push(t("cmd.dryRun.full.removed", { n: w.removed.length }));
96
+ for (const f of w.removed) lines.push(` - ${f.filename} [${f.type}]`);
97
+ }
82
98
  }
83
99
  if (plan.cleanup) {
84
- const reasons = { cleanup: "이전 배포 방식 정리", storeCleanup: "선택 해제된 스토어 워크플로우 정리", staleCleanup: "현재 버전에 없는 이전 워크플로우 정리" };
100
+ const reasons = {
101
+ cleanup: t("cmd.dryRun.reason.cleanup"), storeCleanup: t("cmd.dryRun.reason.storeCleanup"), staleCleanup: t("cmd.dryRun.reason.staleCleanup"),
102
+ };
85
103
  const removed = [];
86
104
  const backedUp = [];
87
105
  for (const [key, reason] of Object.entries(reasons)) {
88
- for (const f of plan.cleanup[key]?.removed || []) removed.push(` - ${f} (${reason})`);
89
- for (const f of plan.cleanup[key]?.backedUp || []) backedUp.push(` > ${f} → ${f}.bak (${reason}, 수정본이라 백업)`);
106
+ for (const f of plan.cleanup[key]?.removed || []) removed.push(t("cmd.dryRun.cleanup.removedLine", { file: f, reason }));
107
+ for (const f of plan.cleanup[key]?.backedUp || []) backedUp.push(t("cmd.dryRun.cleanup.backedUpLine", { file: f, reason }));
90
108
  }
91
- if (removed.length) lines.push(`삭제될 파일 (${removed.length}개):`, ...removed);
92
- if (backedUp.length) lines.push(`.bak으로 옮겨질 파일 (${backedUp.length}개):`, ...backedUp);
109
+ if (removed.length) lines.push(t("cmd.dryRun.cleanup.removedHeading", { n: removed.length }), ...removed);
110
+ if (backedUp.length) lines.push(t("cmd.dryRun.cleanup.backedUpHeading", { n: backedUp.length }), ...backedUp);
93
111
  }
94
112
  if (plan.gitignore) {
95
113
  const g = plan.gitignore;
96
114
  lines.push(g.created
97
- ? `.gitignore: 새로 생성될 예정 (${g.added.join(", ")})`
98
- : g.added.length ? `.gitignore: ${g.added.join(", ")} 추가될 예정` : ".gitignore: 변경 없음 (이미 있는 항목)");
115
+ ? t("cmd.dryRun.gitignore.created", { added: g.added.join(", ") })
116
+ : g.added.length ? t("cmd.dryRun.gitignore.append", { added: g.added.join(", ") }) : t("cmd.dryRun.gitignore.unchanged"));
99
117
  }
100
118
  if (plan.flutterApp && (plan.flutterApp.created.length || plan.flutterApp.kept.length)) {
101
119
  const { created, kept } = plan.flutterApp;
102
- lines.push(`Flutter 스토어 배포 파일 — 신규 (${created.length}개):`);
120
+ lines.push(t("cmd.dryRun.flutter.created", { n: created.length }));
103
121
  for (const f of created) lines.push(` + ${f}`);
104
- lines.push(`Flutter 스토어 배포 파일 — 기존 파일 유지 (${kept.length}개, 덮어쓰지 않음):`);
105
- for (const f of kept) lines.push(` = ${f} (기존 파일 유지)`);
122
+ lines.push(t("cmd.dryRun.flutter.kept", { n: kept.length }));
123
+ for (const f of kept) lines.push(t("cmd.dryRun.flutter.keptLine", { file: f }));
106
124
  }
107
125
  if (plan.versionYml) {
108
126
  lines.push(plan.versionYml.existed
109
- ? (plan.versionYml.changed ? "version.yml: 갱신될 예정" : "version.yml: 변경 없음")
110
- : "version.yml: 새로 생성될 예정");
111
- // dry-run은 프롬프트 없이 읽기 전용으로 동작하므로 @wizard ask 배포 설정 값을 계산할 수 없다.
112
- // spring 등 deploy 블록이 있는 타입은 실제 설치 결과와 미리보기가 다를 수 있음을 안내한다.
113
- lines.push(" (참고: 배포 설정 질문이 있는 타입(spring 등)은 deploy: 블록이 미리보기에 반영되지 않아 실제 설치와 다르게 보일 수 있습니다.)");
127
+ ? (plan.versionYml.changed ? t("cmd.dryRun.versionYml.update") : t("cmd.dryRun.versionYml.unchanged"))
128
+ : t("cmd.dryRun.versionYml.create"));
129
+ // dry-run runs read-only without prompts, so it cannot compute @wizard ask deploy settings.
130
+ // For types with a deploy block (spring etc.) say the preview may differ from the real install.
131
+ lines.push(t("cmd.dryRun.versionYml.note"));
114
132
  }
115
133
  if (plan.scripts) {
116
134
  const mark = { create: "+", overwrite: "~", unchanged: "=" };
117
- const note = { create: "새로 생성", overwrite: "기존 파일을 새 버전으로 덮어씀 — 직접 고친 내용은 사라집니다", unchanged: "변경 없음" };
118
- lines.push(`스크립트 (${PATHS.scriptsDir}/, ${plan.scripts.length}개 — 항상 새 버전으로 교체):`);
135
+ const note = {
136
+ create: t("cmd.dryRun.scripts.create"), overwrite: t("cmd.dryRun.scripts.overwrite"), unchanged: t("cmd.dryRun.scripts.unchanged"),
137
+ };
138
+ lines.push(t("cmd.dryRun.scripts.heading", { dir: PATHS.scriptsDir, n: plan.scripts.length }));
119
139
  for (const s of plan.scripts) lines.push(` ${mark[s.action]} ${s.name} (${note[s.action]})`);
120
140
  }
121
141
  if (plan.readme) {
122
142
  lines.push({
123
- added: "README.md: 끝에 버전 섹션이 추가될 예정",
124
- "skip-no-readme": "README.md: 파일이 없어 버전 섹션을 추가하지 않음",
125
- "skip-marker": "README.md: 이미 버전 섹션이 있어 변경 없음",
126
- "skip-version-line": "README.md: 이미 버전 줄이 있어 변경 없음",
127
- }[plan.readme] || `README.md: ${plan.readme}`);
143
+ added: t("cmd.dryRun.readme.added"),
144
+ "skip-no-readme": t("cmd.dryRun.readme.skipNoReadme"),
145
+ "skip-marker": t("cmd.dryRun.readme.skipMarker"),
146
+ "skip-version-line": t("cmd.dryRun.readme.skipVersionLine"),
147
+ }[plan.readme] || t("cmd.dryRun.readme.other", { status: plan.readme }));
128
148
  }
129
149
  if (plan.baselineExists !== undefined) {
130
- lines.push(`${BASELINE_PATH}: ${plan.baselineExists ? "갱신될 예정" : "새로 생성될 예정"} (다음 업데이트의 비교 기준)`);
150
+ lines.push(t(plan.baselineExists ? "cmd.dryRun.baseline.update" : "cmd.dryRun.baseline.create", { path: BASELINE_PATH }));
131
151
  }
132
152
  }
133
153
  lines.push("");