projectops 4.31.5 → 4.33.1

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 (57) hide show
  1. package/README.md +144 -145
  2. package/package.json +2 -2
  3. package/src/cli/args.js +65 -29
  4. package/src/cli/errors.js +9 -7
  5. package/src/cli/help.js +51 -3
  6. package/src/commands/doctor.js +80 -78
  7. package/src/commands/full.js +17 -3
  8. package/src/commands/interactive.js +78 -50
  9. package/src/commands/issues.js +8 -1
  10. package/src/commands/skills.js +8 -7
  11. package/src/commands/version.js +4 -2
  12. package/src/commands/workflows.js +3 -1
  13. package/src/context.js +4 -0
  14. package/src/core/breaking-check.js +6 -5
  15. package/src/core/copy/simple.js +27 -1
  16. package/src/core/copy/workflows.js +10 -5
  17. package/src/core/exclusions.js +8 -0
  18. package/src/core/ide/adapters/claude.js +18 -17
  19. package/src/core/ide/adapters/codex.js +12 -11
  20. package/src/core/ide/adapters/cursor.js +11 -10
  21. package/src/core/ide/adapters/gemini.js +11 -10
  22. package/src/core/ide/adapters/pi-common.js +2 -1
  23. package/src/core/ide/adapters/pi-harness.js +13 -16
  24. package/src/core/ide/adapters/pi.js +12 -11
  25. package/src/core/label-style.js +78 -0
  26. package/src/core/migration-guide.js +38 -38
  27. package/src/core/migrations/index.js +19 -15
  28. package/src/core/migrations/registry.js +70 -55
  29. package/src/core/options-ask.js +65 -64
  30. package/src/core/orphan-workflows.js +2 -1
  31. package/src/core/paths-resolve.js +31 -30
  32. package/src/core/repo-language.js +57 -0
  33. package/src/core/run-trace.js +10 -0
  34. package/src/core/version-yml.js +68 -36
  35. package/src/i18n/core.en.js +31 -0
  36. package/src/i18n/core.ko.js +31 -0
  37. package/src/i18n/doctor.en.js +89 -0
  38. package/src/i18n/doctor.ko.js +89 -0
  39. package/src/i18n/en.js +142 -0
  40. package/src/i18n/ide.en.js +102 -0
  41. package/src/i18n/ide.ko.js +102 -0
  42. package/src/i18n/index.js +73 -0
  43. package/src/i18n/ko.js +135 -0
  44. package/src/i18n/migrate.en.js +66 -0
  45. package/src/i18n/migrate.ko.js +66 -0
  46. package/src/i18n/options.en.js +104 -0
  47. package/src/i18n/options.ko.js +104 -0
  48. package/src/i18n/summary.en.js +70 -0
  49. package/src/i18n/summary.ko.js +70 -0
  50. package/src/index.js +46 -28
  51. package/src/ui/banner.js +8 -6
  52. package/src/ui/env-plan.js +19 -18
  53. package/src/ui/prompts.js +25 -24
  54. package/src/ui/readline-engine.js +18 -10
  55. package/src/ui/skills-prompts.js +8 -7
  56. package/src/ui/status-cards.js +38 -38
  57. package/src/ui/summary.js +103 -111
@@ -10,12 +10,15 @@ import { markerForType } from "../core/detect.js";
10
10
  import { addVersionSectionToReadme } from "../core/copy/readme.js";
11
11
  import { copyWorkflows } from "../core/copy/workflows.js";
12
12
  import {
13
- copyScripts, copyConfigFolder, copyIssueTemplates,
13
+ copyScripts, copyConfigFolder, copyIssueTemplates, stripIssueTemplateAssignees,
14
14
  copyDiscussionTemplates, copySetupGuide,
15
15
  } from "../core/copy/simple.js";
16
16
  import { copyUtilModules } from "../core/copy/util.js";
17
17
  import { copyCoderabbit } from "../core/copy/coderabbit.js";
18
18
  import { ensureGitignore } from "../core/copy/gitignore.js";
19
+ import { applyLabelStyle } from "../core/label-style.js";
20
+ import { applyRepoLanguage } from "../core/repo-language.js";
21
+ import { t } from "../i18n/index.js";
19
22
  import { verifyInstall } from "../core/verify.js";
20
23
 
21
24
  // context: { version, types, paths:Map, branch, versionCode, deployTarget, publishTargets, includeSecretBackup,
@@ -26,7 +29,7 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
26
29
  force = true, now, today, templateVersion = "unknown",
27
30
  deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, aiPrSummary = true,
28
31
  changelogProvider = "commit", changelogBaseUrl = "", codeReviewCoderabbit = true,
29
- deployBranch = "", intent = null, semverAuto = true , appRelease = null } = context;
32
+ deployBranch = "", intent = null, semverAuto = true , appRelease = null, labelStyle = "en", closeOnRelease = null, projectsSync = null, language = "en" } = context;
30
33
 
31
34
  // project_paths 마커 계산 (.sh existing_marker_in_dir 등가 — 대표 마커명)
32
35
  const pathMarkers = new Map();
@@ -48,7 +51,7 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
48
51
  version, types, paths, pathMarkers, branch, deployBranch, versionCode, now, today,
49
52
  deployValues,
50
53
  templateOptions: { templateVersion, deployTarget, publishTargets, includeSecretBackup, aiPrSummary, optionsDate: today,
51
- changelogProvider, changelogBaseUrl, codeReviewCoderabbit, intent, mode: "full", semverAuto, appRelease },
54
+ changelogProvider, changelogBaseUrl, codeReviewCoderabbit, intent, mode: "full", semverAuto, appRelease, labelStyle, closeOnRelease, projectsSync, language },
52
55
  })), { version, versionCode });
53
56
 
54
57
  // 2. README 버전 섹션
@@ -66,6 +69,14 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
66
69
  // 7. issue / discussion 템플릿
67
70
  step("copy-templates", () => {
68
71
  copyIssueTemplates(tempDir, targetRoot);
72
+ // 템플릿 언어(#769) — 원본은 영문이라 한국어 레포는 복사 직후 오버레이로 덮는다.
73
+ // 라벨 표기 변환(apply-label-style)보다 반드시 먼저여야 한다.
74
+ const { changed: overlaid, failed: overlayFailed } = applyRepoLanguage(tempDir, targetRoot, language);
75
+ hooks.trace?.event("copy", "repo-language", language, { overlaid: overlaid.length, failed: overlayFailed.length });
76
+ for (const f of overlayFailed) hooks.trace?.event("copy", "overlay-failed", f.file, { reason: f.error });
77
+ // 담당자 고정값 제거(#782) — 오버레이가 다시 넣으므로 그 뒤에 한다.
78
+ hooks.trace?.event("copy", "strip-assignees", "", { files: stripIssueTemplateAssignees(targetRoot).length });
79
+ if (overlayFailed.length) console.error(t("language.overlayFailed", { n: overlayFailed.length, files: overlayFailed.map((f) => f.file).join(", ") }));
69
80
  copyDiscussionTemplates(tempDir, targetRoot);
70
81
  });
71
82
 
@@ -76,6 +87,9 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
76
87
  step("ensure-gitignore", () => ensureGitignore(targetRoot));
77
88
  step("copy-setup-guide", () => copySetupGuide(tempDir, targetRoot));
78
89
 
90
+ // 상태 라벨 표기(#776) — 템플릿 원본은 영문이라, 한글 레포는 복사가 끝난 뒤 이름만 되돌린다.
91
+ step("apply-label-style", () => applyLabelStyle(targetRoot, labelStyle), { labelStyle });
92
+
79
93
  // 9. 설치 후 검증 (#549) — 디스크에 쓰인 최종 결과물을 다시 읽는다.
80
94
  // 치환은 파일 단위로 흩어져 일어나고 auto 토큰은 resolver 결과에 의존하므로,
81
95
  // 최종 내용을 보는 것이 실제 배포될 것과 같은 것을 보는 유일한 방법이다.
@@ -26,6 +26,10 @@ import { runWorkflows } from "./workflows.js";
26
26
  import { runIssues } from "./issues.js";
27
27
  import { runSkills } from "./skills.js";
28
28
  import * as prompts from "../ui/prompts.js";
29
+ import { t, getLang, withLang } from "../i18n/index.js";
30
+ import { resolveLabelStyle } from "../core/label-style.js";
31
+ import { resolveRepoLanguage } from "../core/repo-language.js";
32
+ import { printSummary } from "../ui/summary.js";
29
33
 
30
34
  const CANCEL = prompts.CANCEL;
31
35
  const isCancel = (v) => v === CANCEL || typeof v === "symbol";
@@ -54,8 +58,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
54
58
  traceTo = templateVersion;
55
59
 
56
60
  // 층1 — 시작 배너 (#446 확정 시안 A). 스텁엔 banner 없음 → intro 폴백.
57
- if (io.banner) io.banner({ version: templateVersion, modeLabel: "대화형 통합 마법사" });
58
- else io.intro?.("projectops — 대화형 통합 마법사");
61
+ if (io.banner) io.banner({ version: templateVersion, modeLabel: t("banner.interactive") });
62
+ else io.intro?.(`projectops, ${t("banner.interactive")}`);
59
63
 
60
64
  // 기존 version.yml — version/version_code/paths/옵션 보존의 단일 진실 (.sh SSoT L2208~2239)
61
65
  const vyPath = join(cwd, "version.yml");
@@ -63,7 +67,6 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
63
67
  traceFrom = existing?.templateVersion || "";
64
68
 
65
69
  // 층4 — IDE Skills 현재 상태 · 층5 — 신규/업데이트 판별 (#446)
66
- io.ideStatus?.();
67
70
  io.installKind?.({ currentTemplateVersion: existing?.templateVersion || "", templateVersion });
68
71
 
69
72
  // 1) 모드 선택 — 기존 통합 레포면 업데이트 항목을 맨 위에 노출 (#502)
@@ -72,7 +75,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
72
75
  trace.event("run", "start", "interactive", { templateVersion, isUpdate: !!updateInfo });
73
76
  const picked = await io.selectMode(updateInfo ? { update: updateInfo } : {});
74
77
  trace.event("prompt", "mode", String(picked ?? ""), { update: !!updateInfo });
75
- if (picked === CANCEL || picked == null) { trace.event("run", "cancelled", "mode-select", { reason: "user-cancel" }); io.cancelMessage?.("설치를 취소했습니다."); return 0; }
78
+ if (picked === CANCEL || picked == null) { trace.event("run", "cancelled", "mode-select", { reason: "user-cancel" }); io.cancelMessage?.(t("flow.cancelInstall")); return 0; }
76
79
  // 업데이트 모드(#502): 저장된 통합 범위(templateMode, 없으면 full)를 재실행하고
77
80
  // 이하 updateRun 분기가 질문을 최소화한다 ("저장된 설정 그대로 반영"이 계약).
78
81
  const updateRun = picked === "update";
@@ -85,21 +88,28 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
85
88
  askYesNo: (msg, def) => io.askYesNo(msg, def),
86
89
  onItems: (items) => { breakingReport = items; },
87
90
  }));
88
- if (!proceed) { trace.event("run", "cancelled", "breaking-gate", { reason: "user-declined" }); io.cancelMessage?.("통합을 안전하게 취소했습니다."); return 0; }
91
+ if (!proceed) { trace.event("run", "cancelled", "breaking-gate", { reason: "user-declined" }); io.cancelMessage?.(t("flow.cancelBreaking")); return 0; }
89
92
 
90
93
  // skills 모드 — IDE 스킬 설치 (템플릿 통합 없음). 대화형으로 실행.
91
94
  if (mode === "skills") {
95
+ io.ideStatus?.();
92
96
  await skills({ templateVersion, tempDir, interactive: true });
93
- io.outro?.("AI 스킬 설치를 마쳤습니다.");
97
+ io.outro?.(t("flow.doneSkills"));
94
98
  return 0;
95
99
  }
96
100
 
97
101
  // issues 모드는 정보 수집 없이 바로 실행
98
102
  if (mode === "issues") {
99
- const ctx = createContext({ ...baseCtx, mode, force: true });
103
+ // 기존 레포의 템플릿 언어와 라벨 표기는 저장값을 따른다. 빈 baseCtx 로 만들면 둘 다 null 이 되어
104
+ // runIssues 의 기본값(en)이 적용돼, 키 없는 기존 한국어 레포가 질문 없이 영문으로 바뀐다 (#769).
105
+ const ctx = createContext({
106
+ ...baseCtx, mode, force: true,
107
+ language: resolveRepoLanguage({ flag: null, stored: existing?.options?.language, existing: !!existing }),
108
+ labelStyle: resolveLabelStyle({ flag: null, stored: existing?.options?.labelStyle, existing: !!existing }),
109
+ });
100
110
  runIssues(ctx, tempDir, cwd);
101
111
  io.summary?.({ mode, types: [], version: "", counters: {} }, cwd);
102
- io.outro?.("이슈·PR 템플릿을 설치했습니다.");
112
+ io.outro?.(t("flow.doneIssues"));
103
113
  return 0;
104
114
  }
105
115
 
@@ -130,8 +140,26 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
130
140
  // 저장값이 있으면 보존, 없으면 신규 통합만 ON. 기존 레포는 업데이트만으로 버전이 튀지 않는다.
131
141
  const semverAuto = existing?.options?.semverAuto ?? (existing ? false : true);
132
142
  const appRelease = existing?.options?.appRelease ?? null; // #553 저장값 보존 (묻지 않음)
143
+ // 상태 라벨 표기(#776): 저장값 > (신규 en / 기존 ko). 기존 한글 레포에는 영문 전환을 한 번 제안한다(아래).
144
+ const closeOnRelease = existing?.options?.closeOnRelease ?? (existing ? null : true); // #771 묻지 않는다
145
+ // Projects 보드 동기화(#716): 묻지 않는다. 저장값 > 이미 설치돼 있으면 유지 > 신규는 제외 (index.js 와 같은 규칙).
146
+ const projectsSync = existing?.options?.projectsSync
147
+ ?? existsSync(join(cwd, ".github/workflows/PROJECT-COMMON-PROJECTS-SYNC-MANAGER.yaml"));
148
+ // 레포 문구 언어(#769): 저장값 > (신규 en / 기존 ko). 기존 레포에는 영문 전환을 한 번만 제안한다.
149
+ let language = resolveRepoLanguage({ flag: null, stored: existing?.options?.language, existing: !!existing });
150
+ let labelStyle = resolveLabelStyle({ flag: null, stored: existing?.options?.labelStyle, existing: !!existing });
133
151
  const showOptional = mode === "full" || mode === "workflows";
134
152
  const realTty = process.stdout.isTTY === true;
153
+ // 기존 한글 라벨 레포에는 영문 표준 전환을 한 번만 제안한다(#776). 거절하면 한글을 저장해 다시 묻지 않는다.
154
+ // 라벨 정의의 from_name 이 이슈에 붙은 라벨을 그대로 옮기므로 이슈 이력은 보존된다.
155
+ if (existing && !existing.options?.labelStyle && realTty && showOptional && io.askYesNo) {
156
+ const adopt = await io.askYesNo(t("labelStyle.propose"), false);
157
+ labelStyle = adopt ? "en" : "ko";
158
+ }
159
+ if (existing && !existing.options?.language && realTty && showOptional && io.askYesNo) {
160
+ const adopt = await io.askYesNo(t("language.propose"), false);
161
+ language = adopt ? "en" : "ko";
162
+ }
135
163
 
136
164
  // 층2 — 감지 로그 (#446)
137
165
  io.detectionLog?.({ types, version, branch });
@@ -174,9 +202,9 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
174
202
  // 업데이트 모드 — 카드는 보여주되 확인 루프 생략 (#502: 설정 변경은 기존 모드의 몫)
175
203
  if (updateRun) {
176
204
  if (io.analysisCard) {
177
- io.analysisCard({ mode, modeLabel: `업데이트 (${modeLabel(mode)} 범위)`, types, version, branch, deployTarget, publishTargets, includeSecretBackup, showOptional, paths });
205
+ io.analysisCard({ mode, modeLabel: t("flow.updateScope", { scope: modeLabel(mode) }), types, version, branch, deployTarget, publishTargets, includeSecretBackup, showOptional, paths });
178
206
  } else {
179
- io.note?.(summarize({ mode, types, version, branch, deployTarget, publishTargets, includeSecretBackup, showOptional, changelogProvider, codeReviewCoderabbit }), "업데이트 — 저장된 설정으로 진행");
207
+ io.note?.(summarize({ mode, types, version, branch, deployTarget, publishTargets, includeSecretBackup, showOptional, changelogProvider, codeReviewCoderabbit }), t("flow.title.update"));
180
208
  }
181
209
  confirmed = true;
182
210
  }
@@ -185,10 +213,10 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
185
213
  if (io.analysisCard) {
186
214
  io.analysisCard({ mode, modeLabel: modeLabel(mode), types, version, branch, deployTarget, publishTargets, includeSecretBackup, showOptional, paths });
187
215
  } else {
188
- io.note?.(summarize({ mode, types, version, branch, deployTarget, publishTargets, includeSecretBackup, showOptional, changelogProvider, codeReviewCoderabbit }), "프로젝트 분석 결과");
216
+ io.note?.(summarize({ mode, types, version, branch, deployTarget, publishTargets, includeSecretBackup, showOptional, changelogProvider, codeReviewCoderabbit }), t("card.title"));
189
217
  }
190
218
  const choice = await io.confirmProjectMenu();
191
- if (choice === "cancel") { trace.event("run", "cancelled", "confirm-loop", { reason: "user-cancel" }); io.cancelMessage?.("설치를 취소했습니다."); return 0; }
219
+ if (choice === "cancel") { trace.event("run", "cancelled", "confirm-loop", { reason: "user-cancel" }); io.cancelMessage?.(t("flow.cancelInstall")); return 0; }
192
220
  if (isCancel(choice) || choice == null) continue; // ESC = 머무르기 (루프 재출력)
193
221
  if (choice === "continue") { confirmed = true; break; }
194
222
  // edit 루프
@@ -207,14 +235,14 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
207
235
  if ([...types].sort().join(",") !== oldSorted) paths = new Map();
208
236
  }
209
237
  } else if (what === "version") {
210
- const v = await io.askText("새 버전 (예: 1.0.0)", version);
238
+ const v = await io.askText(t("flow.askVersion"), version);
211
239
  if (!isCancel(v) && v !== version) {
212
240
  // semver 형식 검증 (.sh L2010~2015)
213
241
  if (/^\d+\.\d+\.\d+$/.test(v)) version = v;
214
- else io.note?.("버전 형식이 올바르지 않습니다 (x.y.z 형태) — 기존 값을 유지합니다.", "⚠ 버전");
242
+ else io.note?.(t("flow.badVersion"), t("flow.badVersionTitle"));
215
243
  }
216
244
  } else if (what === "branch") {
217
- const b = await io.askText("기본 브랜치", branch);
245
+ const b = await io.askText(t("flow.askBranch"), branch);
218
246
  if (!isCancel(b) && b) branch = b;
219
247
  } else if (OPTION_AXES.includes(what)) {
220
248
  // #483 — 선택한 축 하나만 재질문 (scope). 나머지 옵션은 현재값 그대로 유지.
@@ -279,8 +307,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
279
307
  const liteCtx = { types, paths, deployTarget, publishTargets, repoName, resolvers, branch, deployBranch };
280
308
  const conflicts = listWorkflowConflicts(liteCtx, tempDir, cwd);
281
309
  if (conflicts.length) {
282
- io.note?.(conflicts.map((c) => `• ${c.filename}`).join("\n"), `♻️ 템플릿이 갱신된 워크플로우 ${conflicts.length}개`);
283
- const yes = await io.askYesNo(`위 ${conflicts.length}개를 .bak 백업 후 새 버전으로 교체할까요? (기존 설정값은 유지됩니다)`, true);
310
+ io.note?.(conflicts.map((c) => `• ${c.filename}`).join("\n"), t("flow.conflictsTitle", { count: conflicts.length }));
311
+ const yes = await io.askYesNo(t("flow.conflictsAsk", { count: conflicts.length }), true);
284
312
  trace.event("prompt", "conflict-bulk", yes ? "backup" : "skip", { count: conflicts.length, files: conflicts.map((c) => c.filename) });
285
313
  const decision = yes === true ? "backup" : "skip";
286
314
  updateDecisions = new Map();
@@ -300,7 +328,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
300
328
  const { now, today } = clock || utcNow();
301
329
  const ctx = createContext({
302
330
  mode, force: true, types, version, versionCode, branch, paths, deployTarget, publishTargets, includeSecretBackup,
303
- codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch, intent, semverAuto, appRelease,
331
+ codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch, intent, semverAuto, appRelease, labelStyle, closeOnRelease, projectsSync, language,
304
332
  repoName, templateVersion, resolvers, envValues, envUseDefaults, now, today,
305
333
  // #502 — version 모드가 기존 full 기록을 강등하지 않도록 (full이 우세)
306
334
  recordMode: existing?.templateMode === "full" ? "full" : "version",
@@ -318,11 +346,11 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
318
346
  for (const { filename, type } of conflicts) {
319
347
  if (!perType.has(type)) {
320
348
  const sel = await io.engineIo.select({
321
- message: `기존 워크플로우와 내용이 다른 파일이 있습니다 (${type}) — 어떻게 할까요?`,
349
+ message: t("flow.conflictType", { type }),
322
350
  options: [
323
- { value: "skip", label: "건너뛰기 — 기존 파일 유지 (기본)" },
324
- { value: "backup", label: ".bak 백업 후 새 버전으로 교체" },
325
- { value: "template", label: "기존 유지 + 새 버전을 .template.yaml로 참고 추가" },
351
+ { value: "skip", label: t("flow.opt.skip") },
352
+ { value: "backup", label: t("flow.opt.backup") },
353
+ { value: "template", label: t("flow.opt.template") },
326
354
  ],
327
355
  });
328
356
  perType.set(type, isCancel(sel) || sel == null ? "skip" : sel); // ESC = 건너뛰기 (.sh L3463)
@@ -349,13 +377,13 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
349
377
  if (mode === "full" || mode === "workflows") {
350
378
  const orphans = detectOrphanWorkflows({ tempDir, targetRoot: cwd, selectedTypes: types,
351
379
  // 껐는데 남아 계속 도는 워크플로우도 함께 잡는다 (#566)
352
- options: { includeSecretBackup, aiPrSummary: aiPrSummary !== false, deployTarget } });
380
+ options: { includeSecretBackup, aiPrSummary: aiPrSummary !== false, projectsSync, deployTarget } });
353
381
  if (orphans.length > 0) {
354
382
  io.note?.(
355
- orphans.map((o) => `• ${o.filename} (${o.type} 타입 — 현재 미선택)`).join("\n"),
356
- `🧹 선택되지 않은 타입의 워크플로우 ${orphans.length}개 발견`,
383
+ orphans.map((o) => `• ${t("flow.orphanLine", { file: o.filename, type: o.type })}`).join("\n"),
384
+ t("flow.orphanTitle", { count: orphans.length }),
357
385
  );
358
- const yes = await io.askYesNo(`위 ${orphans.length}개를 정리할까요? (.bak 무해화 — 복원 가능)`, true);
386
+ const yes = await io.askYesNo(t("flow.orphanAsk", { count: orphans.length }), true);
359
387
  trace.event("prompt", "orphan-cleanup", yes ? "clean" : "keep", { count: orphans.length });
360
388
  if (yes === true) {
361
389
  const results = applyOrphanCleanup(cwd, orphans);
@@ -363,7 +391,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
363
391
  const failed = results.filter((r) => r.action === "error");
364
392
  orphanReport.cleaned = ok.map((r) => r.filename);
365
393
  for (const r of ok) trace.event("orphan", "neutralized", r.filename);
366
- io.note?.(`✅ 고아 워크플로우 정리: ${ok.length}개${failed.length ? ` (실패 ${failed.length}개)` : ""}`, "정리 완료");
394
+ io.note?.(t("flow.orphanDone", { ok: ok.length, failed: failed.length ? t("flow.orphanFailed", { count: failed.length }) : "" }), t("flow.orphanDoneTitle"));
367
395
  } else {
368
396
  orphanReport.pending = orphans.map((o) => o.filename);
369
397
  }
@@ -380,8 +408,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
380
408
  if (updateRun) {
381
409
  await skills({ templateVersion, tempDir, interactive: false, installedOnly: true });
382
410
  } else {
383
- const wantSkills = await io.askYesNo("AI 에이전트 스킬(Claude·Cursor·Gemini·Codex·PI)도 설치/업데이트할까요?", false);
384
- if (wantSkills === true) await skills({ templateVersion, tempDir, interactive: true });
411
+ const wantSkills = await io.askYesNo(t("flow.askSkills"), false);
412
+ if (wantSkills === true) { io.ideStatus?.(); await skills({ templateVersion, tempDir, interactive: true }); }
385
413
  }
386
414
 
387
415
  // 마이그레이션 기록 (#493/#494) — Layer 2/3 트레이스 파일 + Layer 1 가이드 엔트리 (full/workflows만)
@@ -395,7 +423,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
395
423
  migrationGuidePath = appendGuideEntry(cwd, {
396
424
  now, mode, types, repoName,
397
425
  templateFrom: existing?.templateVersion || "", templateTo: templateVersion,
398
- options: { deploy: deployTarget, publish: publishTargets, secretBackup: includeSecretBackup, coderabbit: codeReviewCoderabbit, changelogProvider, intent, semverAuto , appRelease },
426
+ options: { deploy: deployTarget, publish: publishTargets, secretBackup: includeSecretBackup, coderabbit: codeReviewCoderabbit, changelogProvider, intent, semverAuto , appRelease, labelStyle, closeOnRelease, projectsSync, language },
399
427
  branches: { defaultBranch: branch, deployBranch, ready: deployBranchReady, created: deployBranchCreated },
400
428
  breaking: breakingReport, migrations: migrationsResult, orphans: orphanReport,
401
429
  events: trace.events, counters: { skipped: result?.workflows?.skipped ?? 0 },
@@ -404,7 +432,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
404
432
  }
405
433
 
406
434
  // 완료 요약 (.sh print_summary L5438)
407
- io.summary?.({
435
+ const summaryCtx = {
408
436
  mode, types, version, deployBranch, deployBranchReady, migrationGuidePath,
409
437
  counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
410
438
  skippedConflicts: result?.workflows?.skippedConflicts ?? [], // #654 병합 안내
@@ -414,8 +442,15 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
414
442
  logDir: files ? MIGRATION_DIR : null, // #561 기록 위치 안내
415
443
  logFile: files?.logFile ?? null,
416
444
  traceFile: files?.traceFile ?? null,
417
- }, cwd);
418
- io.outro?.(`통합 완료 — ${mode} 모드로 설치했습니다.`);
445
+ };
446
+ io.summary?.(summaryCtx, cwd);
447
+ // The run log is English only: when the screen is in another language, add an English copy of this screen.
448
+ if (getLang() !== "en") {
449
+ const buf = [];
450
+ withLang("en", () => printSummary(summaryCtx, cwd, (x) => buf.push(x)));
451
+ trace.appendText(buf.join(""));
452
+ }
453
+ io.outro?.(t("flow.done", { mode }));
419
454
 
420
455
  // 완료 화면까지 출력한 뒤 finally에서 닫는다 (#561) — 여기서 닫으면 이 아래 화면이 안 담긴다.
421
456
  trace.event("run", "end", mode || "", {
@@ -442,30 +477,23 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
442
477
 
443
478
  function summarize({ mode, types, version, branch, deployTarget, publishTargets, includeSecretBackup, showOptional, changelogProvider, codeReviewCoderabbit }) {
444
479
  const lines = [
445
- `통합 모드 : ${modeLabel(mode)}`,
446
- `프로젝트 타입 : ${types.join(", ")}${types.length > 1 ? " (멀티)" : ""}`,
447
- `버전 : ${version}`,
448
- `기본 브랜치 : ${branch}`,
480
+ `${t("sum.mode")}: ${modeLabel(mode)}`,
481
+ `${t("sum.types")}: ${types.join(", ")}${types.length > 1 ? t("sum.multi") : ""}`,
482
+ `${t("card.version")}: ${version}`,
483
+ `${t("edit.branch")}: ${branch}`,
449
484
  ];
450
485
  if (showOptional) {
451
- lines.push(`배포 방식 : ${deployTarget || "docker-ssh"}`);
452
- lines.push(`Publish : ${(publishTargets ?? []).join(",") || "없음"}`);
453
- lines.push(`Secret 백업 : ${includeSecretBackup ? "포함" : "제외"}`);
454
- lines.push(`Changelog : ${changelogProvider || "commit"}`);
455
- lines.push(`CodeRabbit 리뷰 : ${codeReviewCoderabbit ? "사용" : "미사용"}`);
486
+ lines.push(`${t("sum.deploy")}: ${deployTarget || "docker-ssh"}`);
487
+ lines.push(`${t("sum.publish")}: ${(publishTargets ?? []).join(",") || t("card.none")}`);
488
+ lines.push(`${t("sum.secret")}: ${includeSecretBackup ? t("card.included") : t("card.excluded")}`);
489
+ lines.push(`${t("sum.changelog")}: ${changelogProvider || "commit"}`);
490
+ lines.push(`${t("sum.coderabbit")}: ${codeReviewCoderabbit ? t("sum.on") : t("sum.off")}`);
456
491
  }
457
492
  return lines.join("\n");
458
493
  }
459
494
 
460
495
  function modeLabel(m) {
461
- return {
462
- full: "전체 설치 (버전관리 + 워크플로우 + 템플릿)",
463
- version: "버전 관리 전용 (자동화 시스템)",
464
- workflows: "워크플로우 전용 (GitHub Actions 빌드, 배포)",
465
- issues: "이슈/PR 템플릿 전용",
466
- skills: "AI 스킬 전용 (Claude, Cursor, Gemini, Codex, PI)",
467
- update: "업데이트"
468
- }[m] || m;
496
+ return m === "update" ? t("mode.update.short") : (t(`mode.${m}`) === `mode.${m}` ? m : t(`mode.${m}`));
469
497
  }
470
498
 
471
499
  function utcNow(date = new Date()) {
@@ -1,8 +1,15 @@
1
1
  // issues 모드 (.sh execute_integration issues case 등가) — template_integrator.sh 4532~4535.
2
2
  // 이슈/PR 템플릿 + Discussion 템플릿만 복사.
3
- import { copyIssueTemplates, copyDiscussionTemplates } from "../core/copy/simple.js";
3
+ import { applyLabelStyle } from "../core/label-style.js";
4
+ import { applyRepoLanguage } from "../core/repo-language.js";
5
+ import { t } from "../i18n/index.js";
6
+ import { copyIssueTemplates, copyDiscussionTemplates, stripIssueTemplateAssignees } from "../core/copy/simple.js";
4
7
 
5
8
  export function runIssues(context, tempDir, targetRoot = ".") {
6
9
  copyIssueTemplates(tempDir, targetRoot);
10
+ const { failed } = applyRepoLanguage(tempDir, targetRoot, context.language ?? "en"); // #769 — 라벨 변환보다 먼저
11
+ if (failed.length) console.error(t("language.overlayFailed", { n: failed.length, files: failed.map((f) => f.file).join(", ") }));
12
+ stripIssueTemplateAssignees(targetRoot); // #782 — 오버레이가 다시 넣으므로 그 뒤에
13
+ applyLabelStyle(targetRoot, context.labelStyle ?? "en"); // #776
7
14
  copyDiscussionTemplates(tempDir, targetRoot);
8
15
  }
@@ -5,6 +5,7 @@ import { ADAPTERS } from "../core/ide/registry.js";
5
5
  import { versionTag } from "../core/ide/util.js";
6
6
  import { defaultIo } from "../core/ide/runner.js";
7
7
  import * as skillsPrompts from "../ui/skills-prompts.js";
8
+ import { t } from "../i18n/index.js";
8
9
 
9
10
  // 상태 수집 → [{adapter, status}]. optional 어댑터는 감지 불가(cliMissing)면 목록에서 제외.
10
11
  export function collectStatuses(io) {
@@ -21,12 +22,12 @@ export function collectStatuses(io) {
21
22
  export function formatStatuses(rows, templateVersion) {
22
23
  return rows.map(({ adapter, status }) => {
23
24
  const label = adapter.label.padEnd(12);
24
- if (status.cliMissing) return `${label}: ${status.note || "skill 미설치 (CLI 없음)"}`;
25
+ if (status.cliMissing) return `${label}: ${status.note || t("skills.status.noCli")}`;
25
26
  if (status.installed) {
26
27
  const v = status.version ? ` (v${status.version})` : "";
27
- return `${label}: skill 설치됨${v}${versionTag(status.version, templateVersion)}`;
28
+ return `${label}: ${t("skills.status.installed")}${v}${versionTag(status.version, templateVersion)}`;
28
29
  }
29
- return `${label}: ${status.note || "skill 미설치"}`;
30
+ return `${label}: ${status.note || t("skills.status.notInstalled")}`;
30
31
  });
31
32
  }
32
33
 
@@ -40,7 +41,7 @@ export async function runSkills(opts = {}) {
40
41
 
41
42
  const rows = collectStatuses(io);
42
43
  io.log("");
43
- io.log("── IDE Skills 현재 상태 ──");
44
+ io.log(t("skills.statusTitle"));
44
45
  for (const line of formatStatuses(rows, ctx.templateVersion)) io.log(line);
45
46
  io.log("");
46
47
 
@@ -57,7 +58,7 @@ export async function runSkills(opts = {}) {
57
58
  // 대화형: 동작 선택 → IDE 멀티셀렉트 → 실행.
58
59
  const action = await ui.selectAction();
59
60
  if (action == null || action === skillsPrompts.CANCEL || action === "skip") {
60
- io.log("IDE Skills는 변경하지 않고 넘어갑니다.");
61
+ io.log(t("skills.unchanged"));
61
62
  return 0;
62
63
  }
63
64
 
@@ -71,7 +72,7 @@ export async function runSkills(opts = {}) {
71
72
 
72
73
  const targets = await ui.selectTargets(choices, preselect, action);
73
74
  if (targets == null || targets === skillsPrompts.CANCEL || !targets.length) {
74
- io.log("선택한 IDE가 없어 건너뜁니다 (원할 때 다시 실행하세요).");
75
+ io.log(t("skills.noneSelected"));
75
76
  return 0;
76
77
  }
77
78
 
@@ -79,7 +80,7 @@ export async function runSkills(opts = {}) {
79
80
  for (const { adapter } of rows) {
80
81
  if (!selected.has(adapter.id)) continue;
81
82
  io.log("");
82
- io.log(`[ ${adapter.label} ${action === "apply" ? "설치/업데이트" : "제거"} ]`);
83
+ io.log(t(action === "apply" ? "skills.header.apply" : "skills.header.remove", { label: adapter.label }));
83
84
  safe(() => (action === "apply" ? adapter.apply(io, ctx) : adapter.remove(io, ctx)), false);
84
85
  }
85
86
  return 0;
@@ -8,6 +8,7 @@ import { PATHS } from "../core/paths.js";
8
8
  import { buildVersionYml, mergeDeployValues } from "../core/version-yml.js";
9
9
  import { markerForType } from "../core/detect.js";
10
10
  import { addVersionSectionToReadme } from "../core/copy/readme.js";
11
+ import { applyLabelStyle } from "../core/label-style.js";
11
12
  import { copyScripts, copyConfigFolder, copySetupGuide } from "../core/copy/simple.js";
12
13
  import { ensureGitignore } from "../core/copy/gitignore.js";
13
14
 
@@ -15,7 +16,7 @@ export function runVersion(context, tempDir, targetRoot = ".") {
15
16
  const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
16
17
  now, today, templateVersion = "unknown", deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false,
17
18
  changelogProvider = "commit", changelogBaseUrl = "", codeReviewCoderabbit = true,
18
- deployBranch = "", recordMode = "version", semverAuto = true , appRelease = null } = context;
19
+ deployBranch = "", recordMode = "version", semverAuto = true , appRelease = null, labelStyle = "en", closeOnRelease = null, projectsSync = null, language = null } = context;
19
20
 
20
21
  const pathMarkers = new Map();
21
22
  for (const [t] of paths) pathMarkers.set(t, markerForType(t));
@@ -31,11 +32,12 @@ export function runVersion(context, tempDir, targetRoot = ".") {
31
32
  // mode(#502): version 모드가 기존 full 통합 기록을 "version"으로 강등하지 않도록
32
33
  // 호출부가 recordMode로 기존 값을 넘긴다 (full이 우세 — 업데이트 재실행 범위 축소 방지).
33
34
  templateOptions: { templateVersion, deployTarget, publishTargets, includeSecretBackup, optionsDate: today,
34
- changelogProvider, changelogBaseUrl, codeReviewCoderabbit, mode: recordMode, semverAuto, appRelease },
35
+ changelogProvider, changelogBaseUrl, codeReviewCoderabbit, mode: recordMode, semverAuto, appRelease, labelStyle, closeOnRelease, projectsSync, language },
35
36
  }));
36
37
  addVersionSectionToReadme(version, targetRoot);
37
38
  copyScripts(tempDir, targetRoot);
38
39
  copyConfigFolder(tempDir, targetRoot);
40
+ applyLabelStyle(targetRoot, labelStyle); // #776
39
41
  ensureGitignore(targetRoot);
40
42
  copySetupGuide(tempDir, targetRoot);
41
43
  }
@@ -5,13 +5,14 @@ import { join } from "node:path";
5
5
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { PATHS } from "../core/paths.js";
7
7
  import { copyWorkflows } from "../core/copy/workflows.js";
8
+ import { applyLabelStyle } from "../core/label-style.js";
8
9
  import { copyScripts, copyConfigFolder, copySetupGuide } from "../core/copy/simple.js";
9
10
  import { copyUtilModules } from "../core/copy/util.js";
10
11
  import { convertLegacySingularType, mergeDeployValues } from "../core/version-yml.js";
11
12
  import { verifyInstall } from "../core/verify.js";
12
13
 
13
14
  export function runWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
14
- const { types = [], force = true } = context;
15
+ const { types = [], force = true, labelStyle = "en" } = context;
15
16
  const wf = copyWorkflows(context, tempDir, targetRoot, hooks);
16
17
 
17
18
  const vy = join(targetRoot, PATHS.versionFile);
@@ -31,6 +32,7 @@ export function runWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
31
32
 
32
33
  copyScripts(tempDir, targetRoot);
33
34
  copyConfigFolder(tempDir, targetRoot);
35
+ applyLabelStyle(targetRoot, labelStyle); // #776 — 워크플로우(QA 봇)와 라벨 정의를 같은 표기로 맞춘다
34
36
  for (const t of types) copyUtilModules(tempDir, t, { force }, targetRoot);
35
37
  copySetupGuide(tempDir, targetRoot);
36
38
 
package/src/context.js CHANGED
@@ -19,6 +19,7 @@ export function createContext(overrides = {}) {
19
19
  deployTarget: null, // 'docker-ssh'(기본) | 'vercel' | 'none'
20
20
  publishTargets: null, // ['nexus','npm','github-packages'] 부분집합
21
21
  includeSecretBackup: null,
22
+ projectsSync: null, // Projects 보드 동기화 포함 여부 (#716). null=미설정(신규 false / 이미 설치된 레포는 유지)
22
23
  aiPrSummary: null, // #566 — AI 변경 요약 워크플로우 포함 여부
23
24
  // changelog provider 축 (#455 — null=미설정)
24
25
  changelogProvider: null, // 'commit'(기본) | 'coderabbit' | 'openai' | 'gemini' | 'claude' | 'ollama' | 'commit'
@@ -27,6 +28,9 @@ export function createContext(overrides = {}) {
27
28
  deployBranch: "", // 릴리스 PR head 브랜치 (#456). 빈 값=metadata.deploy_branch 미출력
28
29
  intent: null, // 프로젝트 성격 (#485 — app/library/both/none/manual). null=미설정(역추론)
29
30
  semverAuto: null, // semver 자동 승격 (#546). null=미설정(신규 통합 true / 기존 레포 false)
31
+ closeOnRelease: null, // 릴리스 시 완료 이슈 닫기 (#771). null=미설정(신규 true / 기존 레포 키 없음)
32
+ language: null, // 레포 문구 언어 (#769). null=미설정(신규 en / 기존 레포 ko)
33
+ labelStyle: null, // 상태 라벨 표기 (#776). null=미설정(신규 en / 기존 레포 ko)
30
34
  appRelease: null, // 앱 심사 배포 레포인가 (#553). null=미설정(키 기록 안 함)
31
35
  templateVersion: "",
32
36
  tempDir: "",
@@ -4,6 +4,7 @@ import { readFileSync, existsSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { collectBreaking } from "./breaking.js";
6
6
  import { parseExisting } from "./version-yml.js";
7
+ import { t } from "../i18n/index.js";
7
8
 
8
9
  const BC_URL = "https://raw.githubusercontent.com/Cassiiopeia/projectops/main/.github/config/breaking-changes.json";
9
10
 
@@ -46,7 +47,7 @@ export async function runBreakingCheck({ cwd, tempDir, templateVersion, askYesNo
46
47
  // 긴 본문 래핑이 ║ 박스 경계를 붕괴시켰다. 버전·제목만 한 줄씩, 전문은 선택 열람.)
47
48
  const e = (s = "") => process.stderr.write(s + "\n");
48
49
  e("");
49
- e(`⚠️ BREAKING CHANGES (v${current} -> v${templateVersion}) - CRITICAL ${critical.length}건, WARNING ${warnings.length}건`);
50
+ e(t("breaking.title", { current, target: templateVersion, critical: critical.length, warnings: warnings.length }));
50
51
  e("");
51
52
  for (const c of critical) e(` ❗ [CRITICAL] ${c.version} - ${c.title || ""}`);
52
53
  for (const w of warnings) e(` ⚠️ [WARNING] ${w.version} - ${w.title || ""}`);
@@ -54,7 +55,7 @@ export async function runBreakingCheck({ cwd, tempDir, templateVersion, askYesNo
54
55
 
55
56
  if (askYesNo) {
56
57
  // 대화형: 전문(조치 방법)은 원할 때만 펼친다
57
- const detail = await askYesNo("각 항목의 상세 내용(조치 방법)을 볼까요?", false);
58
+ const detail = await askYesNo(t("breaking.askDetail"), false);
58
59
  if (detail === true) {
59
60
  for (const it of [...critical, ...warnings]) {
60
61
  e("");
@@ -64,7 +65,7 @@ export async function runBreakingCheck({ cwd, tempDir, templateVersion, askYesNo
64
65
  e("");
65
66
  }
66
67
  } else {
67
- e(" 상세 내용, 조치 방법: .github/config/breaking-changes.json 참고");
68
+ e(t("breaking.seeJson"));
68
69
  e(` (${BC_URL})`);
69
70
  e("");
70
71
  }
@@ -72,11 +73,11 @@ export async function runBreakingCheck({ cwd, tempDir, templateVersion, askYesNo
72
73
  if (critical.length > 0) {
73
74
  if (askYesNo) {
74
75
  // 대화형: 명시 확인 없으면 중단 (기본 N — .sh 등가)
75
- const ok = await askYesNo("위 호환성 변경을 확인했고 계속 진행할까요?", false);
76
+ const ok = await askYesNo(t("breaking.askContinue"), false);
76
77
  if (ok !== true) return false;
77
78
  } else {
78
79
  // 비대화형(--force): 게이트로 CI를 죽이지 않고 경고 후 진행 (.sh와 의도적 차이 — CI 친화)
79
- e("⚠️ CRITICAL 호환성 변경이 있습니다 — 비대화형 실행이라 계속 진행합니다. 위 내용을 꼭 확인하세요.");
80
+ e(t("breaking.nonInteractive"));
80
81
  }
81
82
  }
82
83
  return true;
@@ -1,7 +1,7 @@
1
1
  // 단순 복사 함수 (무조건 덮어쓰기류) — .sh copy_scripts/config/issue/discussion/setup_guide 등가.
2
2
  // 실측: template_integrator.sh 3818, 3845, 3872, 3895, 4114.
3
3
  import { join } from "node:path";
4
- import { chmodSync } from "node:fs";
4
+ import { chmodSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { PATHS } from "../paths.js";
6
6
  import { exists, copyFileSync, copyDirSync } from "../fsutil.js";
7
7
 
@@ -31,6 +31,10 @@ export function copyScripts(tempDir, targetRoot = ".") {
31
31
  "asc_client.py",
32
32
  // iOS 버전 사전 점검, 업로드 거부 분류, 아카이브부터 업로드까지 재시도 (#643).
33
33
  "ios_release.py",
34
+ // 릴리스 머지 때 완료 라벨이 붙은 이슈를 닫는다 (#771). close_on_release 옵션이 켜진 레포에서만 동작한다.
35
+ "close_issues_on_release.py",
36
+ // Play Store/TestFlight 배포 모드를 코드 설정(.github/config/store-deploy.json)에서 읽는다 (#767).
37
+ "store_deploy_config.py",
34
38
  "changelog_providers/_common.py", "changelog_providers/ladder.py",
35
39
  "changelog_providers/commit.py", "changelog_providers/copilot.py",
36
40
  "changelog_providers/openai_compatible.py",
@@ -56,6 +60,28 @@ export function copyConfigFolder(tempDir, targetRoot = ".") {
56
60
  return true;
57
61
  }
58
62
 
63
+ // 사용자 레포로 복사된 이슈 템플릿에서 담당자(assignees) 줄을 지운다 (#782).
64
+ // 원본에는 이 저장소 소유자가 박혀 있는데, 남의 레포에서 새 이슈를 만들 때 그 사람이 담당자로
65
+ // 지정되면 안 된다. 담당자는 이슈 헬퍼와 QA 봇이 version.yml 설정으로 정한다.
66
+ // frontmatter(첫 --- 와 둘째 --- 사이)의 줄만 대상이다 — 본문에 같은 문구가 있어도 건드리지 않는다.
67
+ // 반환: 바뀐 파일의 레포 루트 기준 상대경로. 오버레이 적용 뒤에 불러야 한다(오버레이가 다시 넣는다).
68
+ export function stripIssueTemplateAssignees(targetRoot = ".") {
69
+ const dir = join(targetRoot, ".github", "ISSUE_TEMPLATE");
70
+ if (!exists(dir)) return [];
71
+ const changed = [];
72
+ for (const name of readdirSync(dir).filter((f) => f.endsWith(".md"))) {
73
+ const file = join(dir, name);
74
+ const text = readFileSync(file, "utf8");
75
+ const m = text.match(/^(---\r?\n)([\s\S]*?)(\r?\n---)/);
76
+ if (!m) continue;
77
+ const front = m[2].split(/\r?\n/).filter((l) => !/^assignees:/.test(l)).join("\n");
78
+ if (front === m[2]) continue;
79
+ writeFileSync(file, text.replace(m[0], `${m[1]}${front}${m[3]}`));
80
+ changed.push(`.github/ISSUE_TEMPLATE/${name}`);
81
+ }
82
+ return changed;
83
+ }
84
+
59
85
  // .github/ISSUE_TEMPLATE/ 전체 + PULL_REQUEST_TEMPLATE.md 덮어쓰기.
60
86
  export function copyIssueTemplates(tempDir, targetRoot = ".") {
61
87
  const srcIssue = join(tempDir, ".github", "ISSUE_TEMPLATE");