projectops 4.31.5 → 4.33.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 (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 +11 -2
  8. package/src/commands/interactive.js +71 -49
  9. package/src/commands/issues.js +4 -0
  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 +4 -0
  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 +47 -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 +141 -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 +134 -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,6 +10,7 @@ import { existsSync, readFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  import { listYamlFiles } from "./fsutil.js";
12
12
  import { PATHS } from "./paths.js";
13
+ import { t as msg } from "../i18n/index.js";
13
14
  import { parseTemplateOptions, inferIntent } from "./version-yml.js";
14
15
  import { branchStatus, createBranch, pushBranch } from "./git-branch.js";
15
16
 
@@ -26,28 +27,28 @@ export async function ensureDeployBranch({ targetRoot = ".", deployBranch = "",
26
27
  if (!st.isRepo) return { created: false, pushed: false, ready: null };
27
28
  if (st.local) return { created: false, pushed: false, ready: true };
28
29
  if (st.remote === true) {
29
- say(`ℹ️ '${deployBranch}' 브랜치가 원격에는 있고 로컬에 없습니다 — 필요 시 'git switch ${deployBranch}'로 가져오세요.`);
30
+ say(msg("options.branch.remoteOnly", { branch: deployBranch }));
30
31
  return { created: false, pushed: false, ready: true };
31
32
  }
32
- say(`⚠️ 개발(릴리스 소스) 브랜치 '${deployBranch}'가 없습니다 — 릴리스(${deployBranch}→${defaultBranch || "기본 브랜치"} PR)가 동작하려면 필요합니다.`);
33
- const mk = await io.confirm({ message: `${defaultBranch || "현재"} 브랜치에서 '${deployBranch}' 브랜치를 만들까요?`, initialValue: true });
33
+ say(msg("options.branch.missing", { branch: deployBranch, base: defaultBranch || msg("options.branch.baseFallback") }));
34
+ const mk = await io.confirm({ message: msg("options.branch.createPrompt", { base: defaultBranch || msg("options.branch.currentFallback"), branch: deployBranch }), initialValue: true });
34
35
  if (mk !== true) {
35
- say(`→ 건너뜁니다. 나중에 직접: git checkout -b ${deployBranch} && git push -u origin ${deployBranch}`);
36
+ say(msg("options.branch.skipped", { branch: deployBranch }));
36
37
  return { created: false, pushed: false, ready: false };
37
38
  }
38
39
  if (!createBranch(targetRoot, deployBranch, defaultBranch)) {
39
- say(`⚠️ 브랜치 생성 실패 — 직접 실행해주세요: git branch ${deployBranch}${defaultBranch ? ` ${defaultBranch}` : ""}`);
40
+ say(msg("options.branch.createFailed", { branch: deployBranch, from: defaultBranch ? ` ${defaultBranch}` : "" }));
40
41
  return { created: false, pushed: false, ready: false };
41
42
  }
42
- say(`✅ 로컬 브랜치 '${deployBranch}' 생성 완료 (checkout은 하지 않았습니다)`);
43
+ say(msg("options.branch.created", { branch: deployBranch }));
43
44
  // #481 — push 질문에 브랜치명 명시 ("어느 브랜치를 push하는지" 불명확 방지)
44
- const up = await io.confirm({ message: `원격(origin)에 '${deployBranch}' 브랜치도 push할까요?`, initialValue: true });
45
+ const up = await io.confirm({ message: msg("options.branch.pushPrompt", { branch: deployBranch }), initialValue: true });
45
46
  if (up !== true) return { created: true, pushed: false, ready: true };
46
47
  if (pushBranch(targetRoot, deployBranch)) {
47
- say(`✅ origin/${deployBranch} push 완료`);
48
+ say(msg("options.branch.pushed", { branch: deployBranch }));
48
49
  return { created: true, pushed: true, ready: true };
49
50
  }
50
- say(`⚠️ push 실패(자격/네트워크) — 직접 실행해주세요: git push -u origin ${deployBranch}`);
51
+ say(msg("options.branch.pushFailed", { branch: deployBranch }));
51
52
  return { created: true, pushed: false, ready: true };
52
53
  }
53
54
 
@@ -123,18 +124,18 @@ async function askOptionalWorkflow({ dir, icon, short, desc, current, force, tty
123
124
  if (force || !tty) return false;
124
125
 
125
126
  say("");
126
- say(`${icon} ${short} 워크플로우를 발견했습니다. (${files.length}개 파일)`);
127
+ say(msg("options.optional.found", { icon, name: short, count: files.length }));
127
128
  say(` ${desc}`);
128
129
  say("");
129
- say(" 포함되는 워크플로우:");
130
+ say(msg("options.optional.included"));
130
131
  for (const f of files) say(` • ${f}`);
131
132
  say("");
132
133
 
133
- const ans = await io.confirm({ message: `${short} 워크플로우를 포함할까요?`, initialValue: false });
134
+ const ans = await io.confirm({ message: msg("options.optional.prompt", { name: short }), initialValue: false });
134
135
  const include = ans === true && !isCancel(ans);
135
136
  say(include
136
- ? `${short} 워크플로우를 포함합니다 — GitHub Actions에 추가됩니다`
137
- : `${short} 워크플로우를 제외합니다 (나중에 옵션으로 추가 가능)`);
137
+ ? msg("options.optional.yes", { name: short })
138
+ : msg("options.optional.no", { name: short }));
138
139
  return include;
139
140
  }
140
141
 
@@ -187,15 +188,15 @@ export async function askAllOptionalWorkflows({
187
188
  const saved = parseTemplateOptions(readFileSync(vy, "utf8"));
188
189
  if (deploy === null && saved.deploy !== null) {
189
190
  deploy = saved.deploy;
190
- say(`배포 방식: version.yml 저장값(${deploy}) 유지 — 재질문 생략`);
191
+ say(msg("options.saved.deploy", { value: deploy }));
191
192
  }
192
193
  if (publish === null && saved.publish !== null) {
193
194
  publish = saved.publish;
194
- say(`Publish 타겟: version.yml 저장값(${publish.join(",") || "없음"}) 유지 — 재질문 생략`);
195
+ say(msg("options.saved.publish", { value: publish.join(",") || msg("options.saved.none") }));
195
196
  }
196
197
  if (secretBackup === null && saved.secretBackup !== null) {
197
198
  secretBackup = saved.secretBackup;
198
- say(`Secret 백업 옵션: version.yml 저장값(${secretBackup}) 유지 — 재질문 생략`);
199
+ say(msg("options.saved.secret", { value: secretBackup }));
199
200
  }
200
201
  // #455 changelog/code_review 저장값 재사용
201
202
  if (codeReviewCoderabbit === null && saved.codeReviewCoderabbit !== null) codeReviewCoderabbit = saved.codeReviewCoderabbit;
@@ -235,19 +236,19 @@ export async function askAllOptionalWorkflows({
235
236
  intent = intent ?? inferIntent(deploy, publish) ?? "manual";
236
237
  } else {
237
238
  say("");
238
- say("🧭 이 프로젝트는 어떤 성격인가요? (배포 관련 질문을 여기에 맞춰 좁혀드립니다)");
239
+ say(msg("options.intent.intro"));
239
240
  const ans = await io.select({
240
- message: "프로젝트 성격을 선택하세요",
241
+ message: msg("options.intent.prompt"),
241
242
  options: [
242
- { value: "app", label: "서버/호스팅에 올려 돌리는 앱·서비스 (배포만)" },
243
- { value: "library", label: "남이 가져다 쓰는 라이브러리/패키지 (publish만)" },
244
- { value: "both", label: "둘 다 (서버로도 돌리고 라이브러리로도 냄)" },
245
- { value: "none", label: "배포 안 함 (CI·빌드 검증만)" },
246
- { value: "manual", label: "직접 하나씩 고를게요 (모든 옵션 개별 질문)" },
243
+ { value: "app", label: msg("options.intent.app") },
244
+ { value: "library", label: msg("options.intent.library") },
245
+ { value: "both", label: msg("options.intent.both") },
246
+ { value: "none", label: msg("options.intent.none") },
247
+ { value: "manual", label: msg("options.intent.manual") },
247
248
  ],
248
249
  });
249
250
  intent = (!isCancel(ans) && INTENT_ASKS_DEPLOY[ans] !== undefined) ? ans : (intent ?? "manual");
250
- say(`프로젝트 성격: ${intent}`);
251
+ say(msg("options.intent.chosen", { value: intent }));
251
252
  }
252
253
  // intent 확정 후 유도: 안 묻는 축은 값 확정, 묻는 축은 재질문 대상으로 되돌린다.
253
254
  if (!INTENT_ASKS_DEPLOY[intent]) deploy = "none";
@@ -268,9 +269,9 @@ export async function askAllOptionalWorkflows({
268
269
  // manual(직접 고르기)에서 둘 다 물을 땐 두 축이 별개임을 한 번 안내 (#480 계승).
269
270
  if (intent === "manual" && willAskDeploy && willAskPublish && tty && typeof io.select === "function") {
270
271
  say("");
271
- say("🧭 배포는 두 가지가 따로 있습니다 — 서로 독립이라 각각 답하시면 됩니다:");
272
- say(" 1) 실행물 배포 — 서버/호스팅에 올려 돌리는 것 (Docker, Vercel …)");
273
- say(" 2) 라이브러리 배포(publish) — 남이 가져다 쓰게 레지스트리에 내는 것 (Nexus, npm …)");
272
+ say(msg("options.axes.intro"));
273
+ say(msg("options.axes.one"));
274
+ say(msg("options.axes.two"));
274
275
  }
275
276
  // 비대화형/폴백 기본값 (#498) — docker-ssh가 적용 가능할 때만 기본, 아니면 none.
276
277
  const deployDefault = applicable.deploy.includes("docker-ssh") ? "docker-ssh" : "none";
@@ -279,22 +280,22 @@ export async function askAllOptionalWorkflows({
279
280
  deploy = deploy ?? deployDefault;
280
281
  } else {
281
282
  say("");
282
- say("🚀 (1) 실행물(서버/앱)을 어디에 올리나요?");
283
- say(" 서버·호스팅에 올릴 계획이 있으면 고르고, 없으면 '서버에 올리지 않음'으로 두세요.");
283
+ say(msg("options.deploy.intro"));
284
+ say(msg("options.deploy.hint"));
284
285
  // 선택지는 적용 가능 타겟으로 필터링 (#498) — 'none'은 항상 노출.
285
286
  const deployLabels = [
286
- { value: "docker-ssh", label: "Docker + SSH 서버 배포 (기본)" },
287
+ { value: "docker-ssh", label: msg("options.deploy.dockerSsh") },
287
288
  { value: "vercel", label: "Vercel" },
288
289
  ];
289
290
  const ans = await io.select({
290
- message: "실행물 배포 방식을 선택하세요",
291
+ message: msg("options.deploy.prompt"),
291
292
  options: [
292
293
  ...deployLabels.filter((o) => applicable.deploy.includes(o.value)),
293
- { value: "none", label: "서버에 올리지 않음 (빌드 검증만 · 라이브러리는 다음에서 선택)" },
294
+ { value: "none", label: msg("options.deploy.none") },
294
295
  ],
295
296
  });
296
297
  deploy = (!isCancel(ans) && (ans === "none" || applicable.deploy.includes(ans))) ? ans : (deploy ?? deployDefault);
297
- say(`실행물 배포: ${deploy}`);
298
+ say(msg("options.deploy.chosen", { value: deploy }));
298
299
  }
299
300
  }
300
301
 
@@ -304,16 +305,16 @@ export async function askAllOptionalWorkflows({
304
305
  publish = publish ?? [];
305
306
  } else {
306
307
  say("");
307
- say("📦 (2) 이 프로젝트를 남이 가져다 쓰는 라이브러리로도 배포(publish)하나요?");
308
- say(" (1) 실행물 배포와 별개입니다. 해당되는 걸 고르고, 라이브러리 배포를 안 하면 아무것도 고르지 말고 Enter.");
308
+ say(msg("options.publish.intro"));
309
+ say(msg("options.publish.hint"));
309
310
  // 선택지는 적용 가능 타겟으로 필터링 (#498)
310
311
  const publishLabels = [
311
- { value: "nexus", label: "사내 Maven(Nexus) 라이브러리 배포" },
312
- { value: "npm", label: "공개 npmjs 패키지 배포 (NPM_TOKEN)" },
313
- { value: "github-packages", label: "GitHub Packages 라이브러리 배포" },
312
+ { value: "nexus", label: msg("options.publish.nexus") },
313
+ { value: "npm", label: msg("options.publish.npm") },
314
+ { value: "github-packages", label: msg("options.publish.githubPackages") },
314
315
  ];
315
316
  const ans = await io.multiselect({
316
- message: "라이브러리 배포 타겟 (없으면 선택 없이 Enter = 배포 안 함)",
317
+ message: msg("options.publish.prompt"),
317
318
  options: publishLabels.filter((o) => applicable.publish.includes(o.value)),
318
319
  initialValues: publish ?? [],
319
320
  required: false,
@@ -321,7 +322,7 @@ export async function askAllOptionalWorkflows({
321
322
  publish = (!isCancel(ans) && Array.isArray(ans))
322
323
  ? ans.filter((t) => applicable.publish.includes(t))
323
324
  : (publish ?? []);
324
- say(publish.length ? `라이브러리 배포: ${publish.join(", ")}` : "라이브러리 배포: 안 함");
325
+ say(publish.length ? msg("options.publish.chosen", { value: publish.join(", ") }) : msg("options.publish.chosenNone"));
325
326
  }
326
327
  }
327
328
  }
@@ -338,17 +339,17 @@ export async function askAllOptionalWorkflows({
338
339
  aiPrSummary = aiPrSummary ?? true;
339
340
  } else {
340
341
  say("");
341
- say("💬 PR에 어떤 댓글을 받으시겠어요?");
342
- say(" 두 가지는 하는 일이 다르고 한도도 따로라, 같이 켜도 서로 방해하지 않습니다.");
343
- say(" · 변경 요약 — 무엇이 바뀌었는지 (추가 설정 없이 바로 동작)");
344
- say(" · 코드 리뷰 — 버그·개선점 지적 (CodeRabbit 앱 설치 필요, 공개 저장소 무료)");
342
+ say(msg("options.pr.intro"));
343
+ say(msg("options.pr.hint1"));
344
+ say(msg("options.pr.hint2"));
345
+ say(msg("options.pr.hint3"));
345
346
  const ans = await io.select({
346
- message: "PR 댓글 방식을 선택하세요",
347
+ message: msg("options.pr.prompt"),
347
348
  options: [
348
- { value: "both", label: "둘 다 (추천 · 무료로 쓸 수 있습니다)" },
349
- { value: "summary", label: "변경 요약만 (추가 설정 불필요)" },
350
- { value: "coderabbit", label: "코드 리뷰만 (CodeRabbit)" },
351
- { value: "none", label: "사용 안 함" },
349
+ { value: "both", label: msg("options.pr.both") },
350
+ { value: "summary", label: msg("options.pr.summary") },
351
+ { value: "coderabbit", label: msg("options.pr.coderabbit") },
352
+ { value: "none", label: msg("options.pr.none") },
352
353
  ],
353
354
  });
354
355
  // 취소·미응답은 추천값으로 — 다른 축과 같은 방어 패턴이다.
@@ -356,13 +357,13 @@ export async function askAllOptionalWorkflows({
356
357
  const pick = (!isCancel(ans) && PR_COMMENT_CHOICES.includes(ans)) ? ans : "summary";
357
358
  codeReviewCoderabbit = pick === "coderabbit" || pick === "both";
358
359
  aiPrSummary = pick === "summary" || pick === "both";
359
- say(`PR 댓글: ${{ summary: "AI 변경 요약", coderabbit: "CodeRabbit", both: "둘 다", none: "사용 안 함" }[pick]}`);
360
+ say(msg("options.pr.chosen", { value: msg(`options.pr.label.${pick}`) }));
360
361
  // #481 — "사용"만으로는 안 붙는다. 앱 설치 + 레포 접근 권한이 있어야 실제로 리뷰가 달린다.
361
362
  if (codeReviewCoderabbit) {
362
- say(" ⚠️ CodeRabbit은 추가 설정이 필요합니다:");
363
- say(" 1) https://coderabbit.ai 접속 → GitHub으로 로그인");
364
- say(" 2) CodeRabbit GitHub 앱 설치 → 이 저장소에 접근 권한(grant access) 부여");
365
- say(" (이 단계를 안 하면 워크플로우는 켜져도 PR에 리뷰 댓글이 달리지 않습니다)");
363
+ say(msg("options.pr.crWarn"));
364
+ say(msg("options.pr.crStep1"));
365
+ say(msg("options.pr.crStep2"));
366
+ say(msg("options.pr.crStep3"));
366
367
  }
367
368
  }
368
369
  }
@@ -379,9 +380,9 @@ export async function askAllOptionalWorkflows({
379
380
  if (force || !tty || typeof io.text !== "function") {
380
381
  changelogBaseUrl = changelogBaseUrl ?? "";
381
382
  } else {
382
- const ans = await io.text({ message: "Ollama 서버 base_url (예: https://ai.suhsaechan.kr/v1)" });
383
+ const ans = await io.text({ message: msg("options.ollama.prompt") });
383
384
  changelogBaseUrl = (typeof ans === "string" && !isCancel(ans)) ? ans.trim() : "";
384
- say(`Ollama base_url: ${changelogBaseUrl || "(미지정)"}`);
385
+ say(msg("options.ollama.chosen", { value: changelogBaseUrl || msg("options.ollama.unset") }));
385
386
  }
386
387
  } else if (changelogBaseUrl === null) {
387
388
  changelogBaseUrl = "";
@@ -396,12 +397,12 @@ export async function askAllOptionalWorkflows({
396
397
  } else {
397
398
  const base = defaultBranch || "main"; // git으로 감지된 기본 브랜치 (#481 동적 안내)
398
399
  say("");
399
- say("🌿 개발한 코드를 모아서 배포로 올리는 '개발 브랜치'는 무엇인가요?");
400
- say(` 감지된 기본(배포) 브랜치는 '${base}'입니다. 개발은 보통 그 앞단 브랜치(예: develop)에서 모아 올립니다.`);
401
- say(" 특별한 이유가 없으면 develop 그대로 두세요.");
402
- const ans = await io.text({ message: "개발(릴리스 소스) 브랜치", initialValue: deployBranch ?? "develop" });
400
+ say(msg("options.devBranch.intro"));
401
+ say(msg("options.devBranch.detected", { base }));
402
+ say(msg("options.devBranch.hint"));
403
+ const ans = await io.text({ message: msg("options.devBranch.prompt"), initialValue: deployBranch ?? "develop" });
403
404
  deployBranch = (typeof ans === "string" && !isCancel(ans) && ans.trim()) ? ans.trim() : (deployBranch ?? "develop");
404
- say(`개발(릴리스 소스) 브랜치: ${deployBranch}`);
405
+ say(msg("options.devBranch.chosen", { value: deployBranch }));
405
406
  // 브랜치 존재 확인 + 생성 제안 (#477) — 없으면 릴리스 파이프라인이 조용히 놀게 된다
406
407
  // #490 — 결과(ready)를 완료 요약에 전달해 이미 생성/확인한 브랜치를 재지시하지 않는다
407
408
  const br = await ensureDeployBranch({ targetRoot, deployBranch, defaultBranch, io, say });
@@ -414,8 +415,8 @@ export async function askAllOptionalWorkflows({
414
415
  const real = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
415
416
  const ptDir = existsSync(real) ? real : join(tempDir, PATHS.projectTypesDir);
416
417
  secretBackup = await askOptionalWorkflow({
417
- dir: join(ptDir, "common", "secret-backup"), icon: "🔐", short: "Secret 서버 백업",
418
- desc: "GitHub Secret에 저장한 설정 파일을 SSH로 서버에 업로드·이력관리하는 워크플로우입니다.",
418
+ dir: join(ptDir, "common", "secret-backup"), icon: "🔐", short: msg("options.secret.short"),
419
+ desc: msg("options.secret.desc"),
419
420
  current: secretBackup, force, tty, io, forceAsk: ask("secret"), say,
420
421
  });
421
422
 
@@ -30,12 +30,13 @@ function typeInventory(projectTypesDir, type) {
30
30
  // 축을 끈 사용자에게는 그게 곧 "껐는데 왜 도나"가 된다 — 여기서 고아로 잡는다.
31
31
  // deploy는 타겟별 폴더라 선택된 타겟만 살리고 나머지를 대상으로 삼는다.
32
32
  function commonOptionalOrphans(commonDir, opts) {
33
- const { includeSecretBackup = false, aiPrSummary = true, deployTarget = "docker-ssh" } = opts;
33
+ const { includeSecretBackup = false, aiPrSummary = true, projectsSync = true, deployTarget = "docker-ssh" } = opts;
34
34
  const out = [];
35
35
 
36
36
  const gated = [
37
37
  ["secret-backup", includeSecretBackup, "secret-backup"],
38
38
  ["pr-summary", aiPrSummary, "pr-summary"],
39
+ ["projects-sync", projectsSync, "projects-sync"],
39
40
  ];
40
41
  for (const [dir, enabled, label] of gated) {
41
42
  if (enabled) continue;
@@ -9,6 +9,7 @@
9
9
  // io.log(line) → 안내 출력 (없으면 stderr)
10
10
  import { existsSync, readdirSync } from "node:fs";
11
11
  import { join } from "node:path";
12
+ import { t as msg } from "../i18n/index.js";
12
13
  import { markerForType as baseMarkerForType, extraMarkers } from "./detect.js";
13
14
  import { normalizePath } from "../cli/args.js";
14
15
 
@@ -132,11 +133,11 @@ export async function resolveProjectPaths({
132
133
  const total = targets.length;
133
134
  // ── 도입부 안내 (.sh L1407~1434 — 감지 결과 + 무엇을 할지 설명) ──
134
135
  say("");
135
- if (total > 1) say(`🔍 멀티타입 프로젝트가 감지되었습니다 — 총 ${total}개 타입`);
136
- else say(`🔍 ${targets[0]} 프로젝트가 감지되었습니다 — 총 1개 타입`);
136
+ if (total > 1) say(msg("paths.multi", { total }));
137
+ else say(msg("paths.single", { type: targets[0] }));
137
138
  for (const t of targets) say(` • ${t.padEnd(8)} → ${existingMarkerInDir(t, root)}`);
138
139
  say("");
139
- say("💡 '프로젝트 루트' = 그 타입의 버전 파일이 있는 폴더 (레포 루트 기준 상대경로)");
140
+ say(msg("paths.rootDef"));
140
141
  say("");
141
142
 
142
143
  let idx = 0;
@@ -146,7 +147,7 @@ export async function resolveProjectPaths({
146
147
 
147
148
  // ① --paths 등으로 이미 지정됨 → 최우선 (.sh L1441~1446)
148
149
  if (result.get(t)) {
149
- say(` ${t} → ${result.get(t)} (--paths 지정)`);
150
+ say(msg("paths.byFlag", { type: t, path: result.get(t) }));
150
151
  continue;
151
152
  }
152
153
 
@@ -154,7 +155,7 @@ export async function resolveProjectPaths({
154
155
  const rootMarker = existingMarkerInDir(t, root);
155
156
  if (rootMarker && existsSync(join(root, rootMarker))) {
156
157
  result.set(t, ".");
157
- say(` ${t} → . (루트의 ${rootMarker})`);
158
+ say(msg("paths.atRoot", { type: t, marker: rootMarker }));
158
159
  continue;
159
160
  }
160
161
 
@@ -169,13 +170,13 @@ export async function resolveProjectPaths({
169
170
  if (force || !tty) {
170
171
  if (existing) {
171
172
  chosen = existing;
172
- say(` ${t} → ${chosen} (기존 project_paths 유지)`);
173
+ say(msg("paths.keptSaved", { type: t, path: chosen }));
173
174
  } else if (candidates.length === 1) {
174
175
  chosen = candidates[0];
175
- say(` ${t} → ${chosen} (자동 감지)`);
176
+ say(msg("paths.autoDetected", { type: t, path: chosen }));
176
177
  } else {
177
178
  chosen = ".";
178
- say(` ⚠️ ${t} → 후보 ${candidates.length}개로 자동 확정 불가, 루트(.)로 기록 (--paths "${t}=경로"로 지정 가능)`);
179
+ say(msg("paths.ambiguous", { type: t, count: candidates.length }));
179
180
  }
180
181
  result.set(t, chosen);
181
182
  continue;
@@ -189,14 +190,14 @@ export async function resolveProjectPaths({
189
190
  const candMarker = existingMarkerInDir(t, cand === "." ? root : join(root, cand));
190
191
  const candFull = cand === "." ? candMarker : `${cand}/${candMarker}`;
191
192
  say("");
192
- say(` ${prog} 🔍 ${t} — ${candMarker} 발견`);
193
- say(` 위치: <레포루트>/${candFull}`);
193
+ say(msg("paths.found", { prog, type: t, marker: candMarker }));
194
+ say(msg("paths.location", { file: candFull }));
194
195
  const sel = await io.select({
195
- message: ` ${t} 프로젝트 루트를 '${cand}'(으)로 설정할까요?`,
196
+ message: msg("paths.confirmPrompt", { type: t, cand }),
196
197
  options: [
197
- { value: cand, label: `예 — '${cand}' 사용 (${candFull} 기준)` },
198
- { value: "직접 입력", label: "아니오 — 경로 직접 입력" },
199
- { value: EXCLUDE, label: `이 타입 아님 — ${t} 설치 대상에서 제외` },
198
+ { value: cand, label: msg("paths.confirmYes", { cand, file: candFull }) },
199
+ { value: "직접 입력", label: msg("paths.confirmManual") },
200
+ { value: EXCLUDE, label: msg("paths.exclude", { type: t }) },
200
201
  ],
201
202
  });
202
203
  if (sel === EXCLUDE) excluded = true;
@@ -204,28 +205,28 @@ export async function resolveProjectPaths({
204
205
  // ESC/직접 입력 → 아래 직접입력 루프로
205
206
  } else if (candidates.length > 1) {
206
207
  say("");
207
- say(` ${prog} 🔍 ${t}: 경로 후보 ${candidates.length}개 발견`);
208
+ say(msg("paths.multiFound", { prog, type: t, count: candidates.length }));
208
209
  // 후보들 + '직접 입력'/'이 타입 제외' 메뉴 — value 자체를 한국어로 (센티넬 노출 방지, .sh L1508~1521)
209
210
  const options = candidates.map((c) => ({
210
211
  value: c,
211
212
  label: `${c} (${existingMarkerInDir(t, c === "." ? root : join(root, c))})`,
212
213
  }));
213
- options.push({ value: "직접 입력", label: "직접 입력" });
214
- options.push({ value: EXCLUDE, label: `이 타입 아님 — ${t} 설치 대상에서 제외` });
215
- const sel = await io.select({ message: ` ${t} 프로젝트 루트를 선택하세요`, options });
214
+ options.push({ value: "직접 입력", label: msg("paths.manual") });
215
+ options.push({ value: EXCLUDE, label: msg("paths.exclude", { type: t }) });
216
+ const sel = await io.select({ message: msg("paths.selectPrompt", { type: t }), options });
216
217
  // ESC(취소)도 직접 입력으로 폴백 (.sh `|| _sel="직접 입력"`)
217
218
  if (sel === EXCLUDE) excluded = true;
218
219
  else if (!isCancel(sel) && sel != null && sel !== "직접 입력") chosen = sel;
219
220
  } else {
220
221
  say("");
221
- say(` ⚠️ ${prog} ${t}: 프로젝트를 찾지 못했습니다 (maxdepth 3).`);
222
+ say(msg("paths.notFound", { prog, type: t }));
222
223
  }
223
224
 
224
225
  // ── 직접 입력 루프 (위에서 미확정 시, .sh L1528~1553) — 제외 탈출구 포함 (#487) ──
225
226
  while (!chosen && !excluded) {
226
227
  const hintMarker = existingMarkerInDir(t, root);
227
- let prompt = ` ${t} 프로젝트 루트 경로 입력 (${hintMarker} 이 있는 폴더, 예: server, app — 루트면 그냥 Enter`;
228
- if (existing) prompt += `, 현재값: ${existing}`;
228
+ let prompt = msg("paths.inputPrompt", { type: t, marker: hintMarker });
229
+ if (existing) prompt += msg("paths.inputCurrent", { value: existing });
229
230
  prompt += "): ";
230
231
  let input = await io.text({ message: prompt, defaultValue: "" });
231
232
  if (isCancel(input) || input == null) input = ""; // ESC → 빈값 (아래 폴백)
@@ -237,13 +238,13 @@ export async function resolveProjectPaths({
237
238
  if (m && existsSync(join(root, input === "." ? "" : input, m))) {
238
239
  chosen = input;
239
240
  } else {
240
- say(` ⚠️ ${input}/${m} 파일이 없습니다.`);
241
+ say(msg("paths.missingFile", { path: input, marker: m }));
241
242
  const act = await io.select({
242
- message: " 어떻게 할까요?",
243
+ message: msg("paths.actionPrompt"),
243
244
  options: [
244
- { value: "retry", label: "다시 입력" },
245
- { value: "force", label: `그래도 '${input}' 경로 사용` },
246
- { value: EXCLUDE, label: `이 타입 아님 — ${t} 설치 대상에서 제외` },
245
+ { value: "retry", label: msg("paths.retry") },
246
+ { value: "force", label: msg("paths.force", { path: input }) },
247
+ { value: EXCLUDE, label: msg("paths.exclude", { type: t }) },
247
248
  ],
248
249
  });
249
250
  if (act === "force") chosen = input;
@@ -253,7 +254,7 @@ export async function resolveProjectPaths({
253
254
  }
254
255
 
255
256
  if (excluded) {
256
- say(` ➖ ${t} 제외 — 이 타입은 버전 동기화·워크플로우 설치 대상에서 빠집니다`);
257
+ say(msg("paths.excluded", { type: t }));
257
258
  continue;
258
259
  }
259
260
 
@@ -263,7 +264,7 @@ export async function resolveProjectPaths({
263
264
 
264
265
  // ── 요약 + 같은 마커 파일 중복 경고 (.sh L1559~1587) ──
265
266
  say("");
266
- say("📂 타입별 버전 파일 경로 확정:");
267
+ say(msg("paths.summary"));
267
268
  const fileToTypes = new Map(); // 마커 파일 상대경로 → 그 파일을 쓰는 타입들
268
269
  for (const [pt, pp] of result) {
269
270
  const m = existingMarkerInDir(pt, pp === "." ? root : join(root, pp));
@@ -275,8 +276,8 @@ export async function resolveProjectPaths({
275
276
  for (const [file, ts] of fileToTypes) {
276
277
  if (ts.length > 1) {
277
278
  // 멱등 동작이라 막지는 않고 경고만 (.sh L1577~1586)
278
- say(` ⚠️ 같은 파일(${file})을 여러 타입(${ts.join(" ")})이 바라봅니다.`);
279
- say(" → sync 때 모두 같은 버전이 기록됩니다. 동작에는 문제없지만 의도한 구성인지 확인하세요.");
279
+ say(msg("paths.duplicate", { types: ts.join(" "), file }));
280
+ say(msg("paths.duplicateNote"));
280
281
  }
281
282
  }
282
283
  say("");
@@ -0,0 +1,47 @@
1
+ // 레포 문구 언어(#769): 영문("en")이 정본, 한국어("ko")는 오버레이다.
2
+ // 한글 사본을 파일마다 따로 두면 두 벌이 어긋나므로, 한국어판은 .github/i18n/ko/ 한 곳에만 두고
3
+ // language: ko 일 때 복사 직후 같은 상대경로로 덮는다.
4
+ import { join, relative, dirname } from "node:path";
5
+ import { existsSync, readdirSync, copyFileSync, mkdirSync, statSync } from "node:fs";
6
+
7
+ export const REPO_LANGUAGES = ["en", "ko"];
8
+
9
+ // version.yml 에 저장값이 없을 때의 결정. 신규 설치는 영문, 이미 설치된 레포는 한국어 유지.
10
+ // (label_style 과 같은 원칙 — 업데이트만으로 사용자 레포의 문구가 바뀌면 안 된다.)
11
+ export function resolveRepoLanguage({ flag, stored, existing }) {
12
+ if (REPO_LANGUAGES.includes(flag)) return flag;
13
+ if (REPO_LANGUAGES.includes(stored)) return stored;
14
+ return existing ? "ko" : "en";
15
+ }
16
+
17
+ function walk(dir) {
18
+ const out = [];
19
+ for (const name of readdirSync(dir)) {
20
+ const p = join(dir, name);
21
+ if (statSync(p).isDirectory()) out.push(...walk(p));
22
+ else out.push(p);
23
+ }
24
+ return out;
25
+ }
26
+
27
+ // 한국어 모드: 오버레이의 모든 파일을 targetRoot/.github/ 아래 같은 상대경로로 복사한다.
28
+ // 반환: 복사한 파일의 레포 루트 기준 상대경로(.github/...). en 이거나 오버레이가 없으면 [].
29
+ // 실패해도 예외를 던지지 않는다 — 영문 템플릿이 남을 뿐 설치를 죽일 이유가 아니다.
30
+ export function applyRepoLanguage(tempDir, targetRoot, language) {
31
+ if (language !== "ko") return [];
32
+ const overlay = join(tempDir, ".github", "i18n", "ko");
33
+ if (!existsSync(overlay)) return [];
34
+ const changed = [];
35
+ try {
36
+ for (const file of walk(overlay)) {
37
+ const rel = relative(overlay, file).split("\\").join("/");
38
+ const dst = join(targetRoot, ".github", rel);
39
+ mkdirSync(dirname(dst), { recursive: true });
40
+ copyFileSync(file, dst);
41
+ changed.push(`.github/${rel}`);
42
+ }
43
+ } catch {
44
+ return changed;
45
+ }
46
+ return changed;
47
+ }
@@ -4,6 +4,7 @@
4
4
  // Layer 1(큐레이션 가이드)은 migration-guide.js — 이 모듈의 events를 단일 소스로 소비한다.
5
5
  import { join } from "node:path";
6
6
  import { writeText } from "./fsutil.js";
7
+ import { getLang } from "../i18n/index.js";
7
8
 
8
9
  // 진단 로그 위치 (#561). docs/ 아래(추적 대상)에서 옮겼다 — 이 기록은 사람이 읽는 문서가
9
10
  // 아니라 Agent가 "지난 실행에서 무슨 일이 있었나"를 확인하는 자료다.
@@ -124,7 +125,11 @@ export function createRunTrace({ clockIso = null } = {}) {
124
125
  if (restore) return;
125
126
  const so = process.stdout.write; // 원본 참조 보관 — 복원 시 identity 유지
126
127
  const se = process.stderr.write;
128
+ // The run log is always English (agents read it later, and it must not change with the user's locale).
129
+ // When the screen is in another language the raw terminal text is not copied; the caller appends an
130
+ // English rendering of the completion screen with appendText() instead.
127
131
  const capture = (chunk) => {
132
+ if (getLang() !== "en") return;
128
133
  try {
129
134
  const s = typeof chunk === "string" ? chunk : chunk.toString("utf8");
130
135
  lines.push(stripAnsi(s));
@@ -135,6 +140,11 @@ export function createRunTrace({ clockIso = null } = {}) {
135
140
  restore = () => { process.stdout.write = so; process.stderr.write = se; };
136
141
  },
137
142
 
143
+ // Add already-rendered text (e.g. the English completion screen) to the log.
144
+ appendText(text) {
145
+ lines.push(stripAnsi(String(text)));
146
+ },
147
+
138
148
  mirrorStop() {
139
149
  if (restore) { restore(); restore = null; }
140
150
  },