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,27 +1,29 @@
1
- // .gitignore 보장 — 마법사 자신이 만드는 충돌 백업 부산물(*.bak, *.template.yaml)만 대상으로 한다.
2
- // 마법사가 설치하는 것과 무관한 개인 개발환경 설정(IDE 등)은 마법사 책임 범위 밖.
3
- // 배너 블록은 종료 마커(BANNER_END)로 범위가 명확히 구분되어 있어, 그 안에 사용자가 다른 줄을
4
- // 끼워넣어도 REQUIRED_ENTRIES만 정확히 개별 제거하고 나머지는 보존한다.
5
- // 주의(의도된 트레이드오프): 이 수정 이전 버전으로 설치되어 종료 마커가 없는 배너가 이미 있는
6
- // 레포에서는, 배너 직후 REQUIRED_ENTRIES가 연속으로 이어지는 동안만 제거하는 구 방식으로
7
- // 폴백한다 — 이전 결정이 "이미 설치된 레포의 .gitignore는 소급 처리하지 않고 사용자 판단에
8
- // 맡긴다"고 명시하므로 별도 마이그레이션 로직을 추가하지 않는다.
1
+ // .gitignore guarantee: covers only the conflict backup by-products the wizard itself creates
2
+ // (*.bak, *.template.yaml). Personal dev-environment settings (IDE etc.) unrelated to what the wizard
3
+ // installs are outside its responsibility.
4
+ // The banner block is clearly delimited by the end marker (BANNER_END), so even if the user inserts other
5
+ // lines inside it, only the REQUIRED_ENTRIES are removed individually and everything else is preserved.
6
+ // Note (intended trade-off): in repos installed before this change, where the banner has no end marker,
7
+ // we fall back to the old approach of removing only while REQUIRED_ENTRIES follow the banner consecutively.
8
+ // An earlier decision states that .gitignore of already installed repos is not retroactively processed and is
9
+ // left to the user, so no separate migration logic is added.
9
10
  import { join } from "node:path";
10
11
  import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
12
+ import { t, SUPPORTED_LANGUAGES } from "../../i18n/index.js";
11
13
 
12
- // 마법사가 설치하는 것과 무관한 개인 개발환경 항목(/.idea 등)은 마법사 책임 밖이므로 제거.
13
- // 대신 마법사 자신의 충돌 처리(workflows.js backup/template 결정)가
14
- // 실제로 만들어내는 부산물만 gitignore 대상으로 삼는다.
14
+ // Personal dev-environment entries (/.idea etc.) are outside the wizard's responsibility, so they are excluded.
15
+ // Only the by-products the wizard's own conflict handling (workflows.js backup/template decisions)
16
+ // actually creates are gitignore targets.
15
17
  const REQUIRED_ENTRIES = ["*.bak", "*.template.yaml"];
16
18
 
17
- // 항목 정규화: 주석 제거·트림·앞 / 제거·앞 ./ 제거·뒤 / 제거. 빈값이면 원본.
19
+ // Normalizes an entry: strip comment, trim, strip leading /, leading ./ and trailing /. Returns the original if empty.
18
20
  export function normalizeGitignoreEntry(entry) {
19
21
  let e = String(entry);
20
- e = e.replace(/#.*$/, ""); // 주석 제거
21
- e = e.trim(); // 앞뒤 공백
22
- e = e.replace(/^\//, ""); // 앞 /
23
- e = e.replace(/^\.\//, ""); // 앞 ./
24
- e = e.replace(/\/$/, ""); // 뒤 /
22
+ e = e.replace(/#.*$/, ""); // strip comment
23
+ e = e.trim(); // surrounding whitespace
24
+ e = e.replace(/^\//, ""); // leading /
25
+ e = e.replace(/^\.\//, ""); // leading ./
26
+ e = e.replace(/\/$/, ""); // trailing /
25
27
  return e === "" ? String(entry) : e;
26
28
  }
27
29
 
@@ -35,13 +37,18 @@ function entryExists(target, content) {
35
37
  return false;
36
38
  }
37
39
 
38
- const NEW_FILE_CONTENT =
39
- "# project-auto-wizard: 충돌 처리 시 생성되는 백업 파일 (안전하게 무시해도 됩니다)\n" +
40
+ const newFileContent = (lang) =>
41
+ t("copy.gitignore.newFileHeader", {}, lang) + "\n" +
40
42
  "*.bak\n" +
41
43
  "*.template.yaml\n";
44
+ // The header comment depends on the language it was written in, so a file created in one language must
45
+ // still be recognized under another: check every supported language's variant.
46
+ const NEW_FILE_CONTENT = () => newFileContent(undefined);
47
+ const matchingNewFileContent = (content) =>
48
+ [NEW_FILE_CONTENT(), ...SUPPORTED_LANGUAGES.map(newFileContent)].find((c) => content.startsWith(c)) ?? null;
42
49
 
43
- // 쓰지 않고 판정만 한다 — 실제 갱신과 --dry-run 미리보기가 같은 판정을 쓰게 한다.
44
- // 반환: {created, added:[...]}
50
+ // Only decides without writing, so the real update and the --dry-run preview share one decision.
51
+ // Returns: {created, added:[...]}
45
52
  export function planGitignore(targetRoot = ".") {
46
53
  const p = join(targetRoot, ".gitignore");
47
54
  if (!existsSync(p)) return { created: true, added: REQUIRED_ENTRIES.slice() };
@@ -49,21 +56,21 @@ export function planGitignore(targetRoot = ".") {
49
56
  return { created: false, added: REQUIRED_ENTRIES.filter((e) => !entryExists(e, content)) };
50
57
  }
51
58
 
52
- // 반환: {created, added:[...]}
59
+ // Returns: {created, added:[...]}
53
60
  export function ensureGitignore(targetRoot = ".") {
54
61
  const p = join(targetRoot, ".gitignore");
55
62
  const plan = planGitignore(targetRoot);
56
63
  if (plan.created) {
57
- writeFileSync(p, NEW_FILE_CONTENT);
64
+ writeFileSync(p, NEW_FILE_CONTENT());
58
65
  return plan;
59
66
  }
60
67
  const toAdd = plan.added;
61
68
  if (toAdd.length === 0) return plan;
62
69
  let content = readFileSync(p, "utf8");
63
70
 
64
- // BANNER는 "\n"으로 시작한다. 파일 끝에 개행이 없으면 그 "\n"이 마지막 줄을 끝내는 역할을 하고,
65
- // 개행이 있으면 빈 줄 하나가 된다. 어느 쪽이든 제거 시 BANNER 앞까지 자르면 원문 그대로 돌아온다.
66
- // (개행을 따로 보충하면 제거 후에도 원래 없던 끝 개행이 남는다.)
71
+ // BANNER starts with "\n". If the file lacks a trailing newline that "\n" terminates the last line;
72
+ // otherwise it becomes one blank line. Either way, cutting up to BANNER on removal restores the original text.
73
+ // (Adding a newline separately would leave a trailing newline that was not there originally.)
67
74
  content += BANNER;
68
75
  for (const e of toAdd) content += e + "\n";
69
76
  content += BANNER_END;
@@ -71,42 +78,44 @@ export function ensureGitignore(targetRoot = ".") {
71
78
  return { created: false, added: toAdd };
72
79
  }
73
80
 
74
- // ensureGitignore가 기존 파일에 배너 블록을 추가할 때 항상 이 정확한 시퀀스로 시작한다
75
- // ("\n" + 3줄 배너), 그리고 REQUIRED_ENTRIES 뒤에 BANNER_END로 끝난다. 배너~BANNER_END
76
- // 범위 안에서 REQUIRED_ENTRIES와 일치하는 줄만 개별 제거하고, 사용자가 그 사이/뒤에 추가한
77
- // 줄은 절대 건드리지 않는다.
81
+ // When ensureGitignore adds a banner block to an existing file it always starts with this exact sequence
82
+ // ("\n" + 3-line banner) and ends with BANNER_END after REQUIRED_ENTRIES. Only lines matching
83
+ // REQUIRED_ENTRIES inside the banner..BANNER_END range are removed individually; lines the user added
84
+ // between or after them are never touched.
85
+ // The banner text is English in every language, so detection needs no language handling.
78
86
  const BANNER =
79
87
  "\n" +
80
88
  "# ====================================================================\n" +
81
89
  "# project-auto-wizard: Auto-added entries\n" +
82
90
  "# ====================================================================\n";
83
91
 
84
- // REQUIRED_ENTRIES 뒤에 오는 종료 마커 — 배너 블록의 "끝"을 명확히 구분해, 그 사이에 사용자가
85
- // 다른 줄을 끼워넣어도 removeAutoAddedEntriesFromGitignore가 정확한 범위 안에서 개별 제거할 수
86
- // 있게 한다. ensureGitignore가 기존 파일에 배너를 추가할 때만 함께 쓴다
87
- // (신규 파일 생성 케이스는 NEW_FILE_CONTENT를 통째로 쓰고 종료 마커는 쓰지 않는다 —
88
- // startsWith 전체 prefix 매칭이라 별도 마커가 필요 없다).
92
+ // End marker after REQUIRED_ENTRIES: it clearly delimits the "end" of the banner block so that
93
+ // removeAutoAddedEntriesFromGitignore can remove entries individually within the exact range even if the user
94
+ // inserted other lines between them. Used only when ensureGitignore adds a banner to an existing file
95
+ // (the new-file case writes NEW_FILE_CONTENT as a whole and no end marker; it is matched
96
+ // by the full startsWith prefix, so no separate marker is needed).
89
97
  const BANNER_END = "# ==== project-auto-wizard: end of auto-added entries ====\n";
90
98
 
91
- // .gitignore에 마법사가 추가한 항목이 있는지 확인 (체크리스트 노출 판단용).
92
- // 두 케이스: (1) 원래 없던 파일을 통째로(또는 그 뒤에 사용자가 이어 쓴 형태로) 새로 만든 경우
93
- // (2) 기존 파일에 배너 블록을 붙인 경우.
99
+ // Checks whether the wizard added entries to .gitignore (to decide on showing the checklist).
100
+ // Two cases: (1) a file that did not exist was created as a whole (or with user content appended after it)
101
+ // (2) a banner block was attached to an existing file.
94
102
  export function hasAutoAddedEntries(targetRoot = ".") {
95
103
  const p = join(targetRoot, ".gitignore");
96
104
  if (!existsSync(p)) return false;
97
105
  const content = readFileSync(p, "utf8");
98
- return content.startsWith(NEW_FILE_CONTENT) || content.includes(BANNER);
106
+ return matchingNewFileContent(content) !== null || content.includes(BANNER);
99
107
  }
100
108
 
101
- // 반환: 'removed' | 'file-deleted' | 'skip-no-gitignore' | 'skip-not-found'
109
+ // Returns: 'removed' | 'file-deleted' | 'skip-no-gitignore' | 'skip-not-found'
102
110
  export function removeAutoAddedEntriesFromGitignore(targetRoot = ".") {
103
111
  const p = join(targetRoot, ".gitignore");
104
112
  if (!existsSync(p)) return "skip-no-gitignore";
105
113
  const content = readFileSync(p, "utf8");
106
114
 
107
- // 원래 파일이 없었는데 마법사가 통째로 만든 경우 — 그 뒤에 사용자가 이어서 추가한 내용만 보존.
108
- if (content.startsWith(NEW_FILE_CONTENT)) {
109
- const remainder = content.slice(NEW_FILE_CONTENT.length);
115
+ // The file did not exist and the wizard created it as a whole: keep only what the user appended after it.
116
+ const created = matchingNewFileContent(content);
117
+ if (created !== null) {
118
+ const remainder = content.slice(created.length);
110
119
  if (remainder === "") {
111
120
  rmSync(p);
112
121
  return "file-deleted";
@@ -115,14 +124,13 @@ export function removeAutoAddedEntriesFromGitignore(targetRoot = ".") {
115
124
  return "removed";
116
125
  }
117
126
 
118
- // 기존 파일에 배너 블록이 붙은 경우.
127
+ // A banner block was attached to an existing file.
119
128
  const idx = content.indexOf(BANNER);
120
129
  if (idx === -1) return "skip-not-found";
121
130
  const afterBanner = idx + BANNER.length;
122
131
 
123
- // 종료 마커가 있으면(이 수정 이후 설치분) 그 범위 안에서 REQUIRED_ENTRIES만 개별적으로
124
- // 제거하고, 사용자가 그 사이에 끼워넣은 줄은 순서·연속 여부와 무관하게 그대로 보존한다
125
- //.
132
+ // If the end marker exists (installed after this change), remove only REQUIRED_ENTRIES individually
133
+ // within that range and keep any lines the user inserted between them, regardless of order or adjacency.
126
134
  const endIdx = content.indexOf(BANNER_END, afterBanner);
127
135
  if (endIdx !== -1) {
128
136
  const region = content.slice(afterBanner, endIdx).split("\n");
@@ -133,14 +141,14 @@ export function removeAutoAddedEntriesFromGitignore(targetRoot = ".") {
133
141
  return "removed";
134
142
  }
135
143
 
136
- // 종료 마커가 없는 구버전 설치(이 수정 이전) — 배너 직후 REQUIRED_ENTRIES가 연속으로
137
- // 이어지는 동안만 제거하는 기존 방식으로 폴백한다 (동일한 소급 미처리 원칙).
144
+ // Old install without an end marker (before this change): fall back to the old approach of removing only
145
+ // while REQUIRED_ENTRIES follow the banner consecutively (same no-retroactive-processing principle).
138
146
  const lines = content.slice(afterBanner).split("\n");
139
147
  let consumed = 0;
140
148
  for (const line of lines) {
141
149
  const isKnownEntry = REQUIRED_ENTRIES.some((e) => normalizeGitignoreEntry(line) === normalizeGitignoreEntry(e));
142
150
  if (!isKnownEntry) break;
143
- consumed += line.length + 1; // +1: split이 삼킨 "\n"
151
+ consumed += line.length + 1; // +1: the "\n" swallowed by split
144
152
  }
145
153
  writeFileSync(p, content.slice(0, idx) + content.slice(afterBanner + consumed));
146
154
  return "removed";
@@ -1,26 +1,80 @@
1
- // README 버전 섹션 추가.
1
+ // Appends the README version section.
2
2
  import { join } from "node:path";
3
3
  import { existsSync, readFileSync, appendFileSync, writeFileSync } from "node:fs";
4
+ import { t, SUPPORTED_LANGUAGES } from "../../i18n/index.js";
4
5
 
5
6
  export const MARKER = "<!-- AUTO-VERSION-SECTION";
6
- // ## (최신 버전|최신버전|Version|버전) : vX.Y.Z (대소문자 무시)
7
- const VERSION_LINE_RE = /##\s*(최신\s*버전|최신버전|Version|버전)\s*:\s*v[0-9]+\.[0-9]+\.[0-9]+/i;
7
+ // "## <latest version|latest-version|current/recent version|Version|version> : vX.Y.Z", where the Korean words are written as
8
+ // \u escapes so this file stays free of Hangul (case-insensitive)
9
+ const VERSION_LINE_RE = /##\s*(\uCD5C\uC2E0\s*\uBC84\uC804|\uCD5C\uC2E0\uBC84\uC804|(?:latest|current|recent)[\s-]*version|Version|\uBC84\uC804)\s*:\s*v[0-9]+\.[0-9]+\.[0-9]+/i;
8
10
 
9
- // 실행 로그용 설명 — 상태 코드만 남기면 나중에 로그를 읽는 사람이 의미를 다시 찾아봐야 한다.
11
+ // Descriptions for the run log: a bare status code forces later readers to look up its meaning.
12
+ // Getters keep the text lazy so it follows the language resolved at runtime.
10
13
  export const README_STATUS_LABEL = {
11
- added: "README.md 끝에 버전 섹션 추가",
12
- "skip-no-readme": "README.md가 없어 버전 섹션을 추가하지 않음",
13
- "skip-marker": "이미 버전 섹션이 있어 그대로 둠",
14
- "skip-version-line": "이미 버전 줄이 있어 그대로 둠",
14
+ get added() { return t("copy.readme.status.added"); },
15
+ get "skip-no-readme"() { return t("copy.readme.status.skipNoReadme"); },
16
+ get "skip-marker"() { return t("copy.readme.status.skipMarker"); },
17
+ get "skip-version-line"() { return t("copy.readme.status.skipVersionLine"); },
18
+ get "heading-updated"() { return t("copy.readme.status.headingUpdated"); },
15
19
  };
16
20
 
17
- // README.md 없으면 스킵. 마커 또는 버전 라인 있으면 스킵. 없으면 파일 끝에 append.
18
- // 반환: 'skip-no-readme' | 'skip-marker' | 'skip-version-line' | 'added'
19
- // 쓰지 않고 판정만 한다 — 실제 추가와 --dry-run 미리보기가 같은 판정을 쓰게 한다.
21
+ // Bundled default heading text ("Latest Version", ...) without the "## " prefix and version part, per language.
22
+ const defaultHeading = (lang) => t("copy.readme.versionHeading", { version: "0" }, lang).replace(/^##\s*/, "").replace(/\s*:\s*v0$/, "");
23
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
24
+
25
+ // Text of the history link line in a given language (without the trailing newline).
26
+ const historyLink = (lang) => t("copy.readme.historyLink", {}, lang);
27
+
28
+ // Rewrites the wizard-made block (marker line, heading line, blank line, history link line) that was written
29
+ // in another language so it matches the current one. Only text that exactly equals another language's bundled
30
+ // default is replaced: a heading or link the user edited is left alone, and so is anything not directly
31
+ // under the marker (code-block examples, a README without the marker).
32
+ // Returns the new content, or null when nothing needs to change.
33
+ function refreshBlockLanguage(content) {
34
+ const currentHeading = defaultHeading();
35
+ const currentLink = historyLink();
36
+ const markerRe = new RegExp("^" + escapeRe(MARKER_LINE.trimEnd()) + "[ \\t]*\\r?\\n", "gm");
37
+ let out = "";
38
+ let last = 0;
39
+ let changed = false;
40
+ for (let m = markerRe.exec(content); m; m = markerRe.exec(content)) {
41
+ // An odd number of fence lines before the marker means it sits inside a code block (an example).
42
+ if ((content.slice(0, m.index).match(/^[ \t]*(```|~~~)/gm) || []).length % 2 === 1) continue;
43
+ const start = m.index + m[0].length;
44
+ let rest = content.slice(start);
45
+ let head = "";
46
+ // Heading line directly under the marker.
47
+ for (const lang of SUPPORTED_LANGUAGES) {
48
+ const heading = defaultHeading(lang);
49
+ if (heading === currentHeading) continue;
50
+ const hm = new RegExp("^## " + escapeRe(heading) + " : (?=v[0-9])").exec(rest);
51
+ if (hm) { head = "## " + currentHeading + " : "; rest = rest.slice(hm[0].length); changed = true; break; }
52
+ }
53
+ out += content.slice(last, start) + head;
54
+ last = start + (content.slice(start).length - rest.length);
55
+ // History link line: heading line, one blank line, then the link.
56
+ const lm = /^([^\r\n]*\r?\n\r?\n)([^\r\n]*)/.exec(rest);
57
+ if (lm) {
58
+ const linkStart = last + lm[1].length;
59
+ const isStale = SUPPORTED_LANGUAGES.some((lang) => historyLink(lang) !== currentLink && lm[2] === historyLink(lang));
60
+ if (isStale) {
61
+ out += content.slice(last, linkStart) + currentLink;
62
+ last = linkStart + lm[2].length;
63
+ changed = true;
64
+ }
65
+ }
66
+ }
67
+ return changed ? out + content.slice(last) : null;
68
+ }
69
+
70
+ // Skip when README.md is missing, or when a marker or version line exists. Otherwise append to the end.
71
+ // Returns: 'skip-no-readme' | 'heading-updated' | 'skip-marker' | 'skip-version-line' | 'added'
72
+ // Only decides without writing, so the real append and the --dry-run preview share one decision.
20
73
  export function planVersionSection(targetRoot = ".") {
21
74
  const p = join(targetRoot, "README.md");
22
75
  if (!existsSync(p)) return "skip-no-readme";
23
76
  const content = readFileSync(p, "utf8");
77
+ if (refreshBlockLanguage(content) !== null) return "heading-updated";
24
78
  if (content.includes(MARKER)) return "skip-marker";
25
79
  if (VERSION_LINE_RE.test(content)) return "skip-version-line";
26
80
  return "added";
@@ -28,39 +82,46 @@ export function planVersionSection(targetRoot = ".") {
28
82
 
29
83
  export function addVersionSectionToReadme(version, targetRoot = ".") {
30
84
  const status = planVersionSection(targetRoot);
31
- if (status !== "added") return status;
32
85
  const p = join(targetRoot, "README.md");
86
+ if (status === "heading-updated") {
87
+ // Swap only the bundled default wording; version text after the colon stays as the workflow wrote it.
88
+ writeFileSync(p, refreshBlockLanguage(readFileSync(p, "utf8")));
89
+ return status;
90
+ }
91
+ if (status !== "added") return status;
33
92
  const content = readFileSync(p, "utf8");
34
93
 
35
- // 기존 본문과 구분선 사이에 빈 줄을 두도록 append 본문은 "\n---\n..."로 시작.
94
+ // The appended body starts with "\n---\n..." to leave a blank line between the existing text and the rule.
36
95
  const section =
37
96
  "\n" +
38
97
  "---\n" +
39
98
  "\n" +
40
99
  "<!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->\n" +
41
- `## 최신 버전 : v${version}\n` +
100
+ t("copy.readme.versionHeading", { version }) + "\n" +
42
101
  "\n" +
43
- "[전체 버전 기록 보기](CHANGELOG.md)\n";
44
- // 끝 개행이 없는 README에 그대로 붙이면 마지막 줄 바로 다음 줄에 "---"가 와서
45
- // 마크다운이 그 줄을 제목(Setext h2)으로 바꿔 버린다 — 줄을 먼저 끝낸다.
102
+ t("copy.readme.historyLink") + "\n";
103
+ // Appending directly to a README without a trailing newline puts "---" right after the last line,
104
+ // and Markdown turns that line into a heading (Setext h2), so terminate the line first.
46
105
  const lead = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
47
106
  appendFileSync(p, lead + section);
48
107
  return "added";
49
108
  }
50
109
 
51
- // addVersionSectionToReadme가 항상 파일 맨 끝에 붙이는 접두/접미 시퀀스.
52
- // 접두(SECTION_PREFIX)가 있으면 마법사가 append한 "블록"이다 — 이 블록은 항상 SECTION_TAIL
53
- // 라인으로 끝나므로, 그 지점까지만 잘라내야 그 뒤에 사용자가 나중에 덧붙인 내용(라이선스 절 등)을
54
- // 지우지 않는다("파일 끝까지" 자르면 사용자 콘텐츠가 소실된다).
110
+ // Prefix/suffix sequence that addVersionSectionToReadme always appends at the very end of the file.
111
+ // If the prefix (SECTION_PREFIX) is present it is a "block" appended by the wizard. The block always ends
112
+ // with the SECTION_TAIL line, so cut only up to that point; cutting "to the end of the file" would delete
113
+ // content the user appended later (a license section etc.).
55
114
  const SECTION_PREFIX = "\n---\n\n" + MARKER;
56
- const SECTION_TAIL = "[전체 버전 기록 보기](CHANGELOG.md)\n";
57
- // PROJECT-COMMON-README-VERSION-UPDATE.yaml(설치되는 CI)은 설치 시점에 이미 사용자가 자기 버전
58
- // 라인을 갖고 있던 README(addVersionSectionToReadme가 'skip-version-line'으로 건너뛴 경우)에는
59
- // '---' 구분자 없이 마커 주석 한 줄만 그 버전 라인 위에 끼워넣는다. 이 경우 버전 라인 자체는
60
- // 사용자 소유이므로 지우지 않고 마커 주석 한 줄만 제거한다.
115
+ // The tail text depends on the language it was installed in, so a section written in one language must
116
+ // still be removable under another: candidates from every supported language are matched.
117
+ const sectionTails = () => [...new Set(SUPPORTED_LANGUAGES.map((l) => t("copy.readme.historyLink", {}, l) + "\n"))];
118
+ // For a README that already had the user's own version line at install time (addVersionSectionToReadme
119
+ // skipped it as 'skip-version-line'), PROJECT-COMMON-README-VERSION-UPDATE.yaml (the installed CI) inserts
120
+ // only a marker comment line above that version line, without the '---' separator. The version line itself
121
+ // is user-owned, so only the marker comment line is removed in that case.
61
122
  const MARKER_LINE = "<!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->\n";
62
123
 
63
- // README.md에 마법사(또는 설치된 CI)가 추가한 흔적이 있는지 확인 (체크리스트 노출 판단용).
124
+ // Checks whether the wizard (or the installed CI) left traces in README.md (to decide on showing the checklist).
64
125
  export function hasVersionSection(targetRoot = ".") {
65
126
  const p = join(targetRoot, "README.md");
66
127
  if (!existsSync(p)) return false;
@@ -68,7 +129,7 @@ export function hasVersionSection(targetRoot = ".") {
68
129
  return content.includes(SECTION_PREFIX) || content.includes(MARKER_LINE);
69
130
  }
70
131
 
71
- // 반환: 'removed' | 'skip-no-readme' | 'skip-no-marker' | 'skip-unexpected-format'
132
+ // Returns: 'removed' | 'skip-no-readme' | 'skip-no-marker' | 'skip-unexpected-format'
72
133
  export function removeVersionSectionFromReadme(targetRoot = ".") {
73
134
  const p = join(targetRoot, "README.md");
74
135
  if (!existsSync(p)) return "skip-no-readme";
@@ -76,22 +137,27 @@ export function removeVersionSectionFromReadme(targetRoot = ".") {
76
137
 
77
138
  const idx = content.indexOf(SECTION_PREFIX);
78
139
  if (idx !== -1) {
79
- // 마법사가 append한 전체 블록 케이스 — SECTION_TAIL 라인까지만 잘라내고 그 이후는 보존한다.
80
- // tail을 못 찾거나(사용자가 그 줄을 지웠다면), 위저드 블록치고 너무 먼 곳에서 발견되면
81
- // (사용자가 같은 문구를 자기 문서 어딘가로 옮겨 적은 경우) 그 사이 사용자 콘텐츠까지
82
- // 지워버릴 수 있으므로 어디까지가 "마법사 구간"인지 확신할 수 없어 안전하게 포기한다.
83
- // 위저드 블록은 버전 문자열이 길어져도 수백 바이트를 넘지 않는다.
140
+ // Whole block appended by the wizard: cut only up to the SECTION_TAIL line and keep everything after it.
141
+ // If the tail is missing (the user deleted that line), or is found too far away for a wizard block
142
+ // (the user copied the same text elsewhere in the document), user content in between could be deleted,
143
+ // and we cannot be sure where the "wizard section" ends, so give up safely.
144
+ // The wizard block stays within a few hundred bytes even with a long version string.
84
145
  const MAX_SECTION_LENGTH = 300;
85
- const tailIdx = content.indexOf(SECTION_TAIL, idx);
146
+ let tailIdx = -1;
147
+ let tailLen = 0;
148
+ for (const tail of sectionTails()) {
149
+ const i = content.indexOf(tail, idx);
150
+ if (i !== -1 && (tailIdx === -1 || i < tailIdx)) { tailIdx = i; tailLen = tail.length; }
151
+ }
86
152
  if (tailIdx === -1 || tailIdx > idx + MAX_SECTION_LENGTH) return "skip-unexpected-format";
87
- const cutEnd = tailIdx + SECTION_TAIL.length;
153
+ const cutEnd = tailIdx + tailLen;
88
154
  writeFileSync(p, content.slice(0, idx) + content.slice(cutEnd));
89
155
  return "removed";
90
156
  }
91
157
 
92
158
  const markerIdx = content.indexOf(MARKER_LINE);
93
159
  if (markerIdx !== -1) {
94
- // CI가 사용자의 기존 버전 라인 위에 마커만 끼워넣은 케이스 — 버전 라인은 건드리지 않는다.
160
+ // The CI inserted only the marker above the user's existing version line; leave the version line alone.
95
161
  writeFileSync(p, content.slice(0, markerIdx) + content.slice(markerIdx + MARKER_LINE.length));
96
162
  return "removed";
97
163
  }
@@ -1,15 +1,16 @@
1
- // 단순 복사 함수 (무조건 덮어쓰기류).
2
- // payload 단일 진실: 스크립트는 payload/scripts/*.py → 사용자 레포 .github/scripts/ 로 설치된다.
3
- // 워크플로우 전부가 이 경로(python3 .github/scripts/*.py)를 호출하므로 누락 시 설치물이 런타임에 죽는다.
1
+ // Simple copy functions (unconditional overwrite).
2
+ // Single source of truth: scripts in payload/scripts/*.py are installed to .github/scripts/ in the user repo.
3
+ // All workflows call this path (python3 .github/scripts/*.py), so a missing script breaks the install at runtime.
4
4
  import { join } from "node:path";
5
5
  import { chmodSync, readFileSync, readdirSync, rmSync, rmdirSync } from "node:fs";
6
6
  import { PATHS, PAYLOAD } from "../paths.js";
7
7
  import { exists, copyFileSync } from "../fsutil.js";
8
8
 
9
- export const SCRIPT_NAMES = ["version_manager.py", "changelog_manager.py", "truncate_release_notes.py", "issue_helper.py"];
9
+ // messages.py is the shared message catalog the other scripts and the workflows import/call.
10
+ export const SCRIPT_NAMES = ["version_manager.py", "changelog_manager.py", "truncate_release_notes.py", "issue_helper.py", "messages.py"];
10
11
 
11
- // 파일별로 무엇이 일어날지 계산한다(아무것도 쓰지 않음) — 실제 복사와 --dry-run이 같은 판정을 쓴다.
12
- // 반환: [{ name, action: "create" | "overwrite" | "unchanged" }]
12
+ // Computes what will happen per file (writes nothing), so the real copy and --dry-run share one decision.
13
+ // Returns: [{ name, action: "create" | "overwrite" | "unchanged" }]
13
14
  export function planScripts(payloadRoot, targetRoot = ".") {
14
15
  const out = [];
15
16
  for (const name of SCRIPT_NAMES) {
@@ -23,22 +24,22 @@ export function planScripts(payloadRoot, targetRoot = ".") {
23
24
  return out;
24
25
  }
25
26
 
26
- // 스크립트는 항상 payload 버전으로 덮어쓴다 (+chmod — Windows에선 무의미하나 무해).
27
- // 사용자가 고친 스크립트도 덮이므로, 호출부가 로그에 남길 수 있게 파일별 결과를 돌려준다.
27
+ // Scripts are always overwritten with the payload version (+chmod, meaningless but harmless on Windows).
28
+ // User-edited scripts are overwritten too, so per-file results are returned for the caller to log.
28
29
  export function copyScripts(payloadRoot, targetRoot = ".") {
29
30
  const results = planScripts(payloadRoot, targetRoot);
30
31
  for (const { name } of results) {
31
32
  const dst = join(targetRoot, PATHS.scriptsDir, name);
32
33
  copyFileSync(join(payloadRoot, PAYLOAD.scriptsDir, name), dst);
33
- try { chmodSync(dst, 0o755); } catch { /* Windows 등 chmod 무의미 */ }
34
+ try { chmodSync(dst, 0o755); } catch { /* chmod is meaningless on Windows etc. */ }
34
35
  }
35
36
  return results;
36
37
  }
37
38
 
38
- // 예전 버전의 워크플로우는 스크립트 import가 남긴 바이트코드를 봇 커밋에 섞어 올렸다.
39
- // 지금은 생성 자체를 막지만 이미 커밋된 pyc는 업데이트로 사라지지 않으므로 여기서 지운다.
40
- // 범위는 마법사 스크립트 폴더의 pyc와 그 __pycache__뿐이다 — 다른 경로는 건드리지 않는다.
41
- // 반환: 지울(지운) 파일의 targetRoot 기준 상대 경로 목록 — 삭제는 사용자가 설치 파일과 함께 커밋한다
39
+ // Older workflow versions committed bytecode left by script imports into bot commits.
40
+ // Generation is now prevented, but already committed pyc files do not vanish on update, so they are removed here.
41
+ // Scope is only the pyc files in the wizard scripts folder and its __pycache__; no other path is touched.
42
+ // Returns paths (relative to targetRoot) of files to remove (removed); the user commits the deletion with the installed files.
42
43
  export function planScriptBytecode(targetRoot = ".") {
43
44
  const base = join(targetRoot, PATHS.scriptsDir);
44
45
  const list = (rel) => {
@@ -55,8 +56,8 @@ export function removeScriptBytecode(targetRoot = ".") {
55
56
  const removed = planScriptBytecode(targetRoot);
56
57
  for (const rel of removed) rmSync(join(targetRoot, rel), { force: true });
57
58
  if (removed.some((rel) => rel.includes("/__pycache__/"))) {
58
- // 비었을 때만 지운다 — 다른 파일이 있으면 그대로 둔다
59
- try { rmdirSync(join(targetRoot, PATHS.scriptsDir, "__pycache__")); } catch { /* 비어 있지 않음 */ }
59
+ // remove only when empty; leave it if other files exist
60
+ try { rmdirSync(join(targetRoot, PATHS.scriptsDir, "__pycache__")); } catch { /* not empty */ }
60
61
  }
61
62
  return removed;
62
63
  }