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
@@ -11,6 +11,7 @@ import { exists, listYamlFiles } from "../core/fsutil.js";
11
11
  import { parseWizardLine, resolveToken, resolveGlobalTokens } from "../core/wizard-env.js";
12
12
  import { loadWizardPrompts, wfField, workflowDisplayName } from "../core/wizard-labels.js";
13
13
  import * as engine from "./readline-engine.js";
14
+ import { t } from "../i18n/index.js";
14
15
 
15
16
  const CANCEL = engine.CANCEL;
16
17
 
@@ -52,7 +53,7 @@ export function collectAsks(tempDir, types = [], opts = {}) {
52
53
  // 복사 엔진과 동일한 폴더 구성: 타입 직하위 + (docker-ssh면) server-deploy + 선택된 publish/<target>
53
54
  const dirs = [typeDir];
54
55
  if ((deployTarget || "docker-ssh") === "docker-ssh") dirs.push(join(typeDir, "server-deploy"));
55
- for (const t of publishTargets) dirs.push(join(typeDir, "publish", t));
56
+ for (const pt of publishTargets) dirs.push(join(typeDir, "publish", pt));
56
57
 
57
58
  for (const dir of dirs) {
58
59
  if (!exists(dir)) continue;
@@ -91,18 +92,18 @@ function firstTypeFor(usages, key) {
91
92
  // KEY 1개를 'label·사용처·설명·예시·기본값' 카드로 출력 (.sh _wf_print_field_card 등가).
92
93
  // info: { default, usages } — idx/tot 있으면 "(i/t)" 진행 표시. log 주입 가능(테스트 무음화).
93
94
  export function printFieldCard(prompts, key, info, idx = null, tot = null, log = defaultLog) {
94
- const t = info.usages?.[0]?.type ?? "";
95
- const label = wfField(prompts, t, key, "label");
96
- const help = wfField(prompts, t, key, "help");
97
- const ex = wfField(prompts, t, key, "example");
95
+ const ty = info.usages?.[0]?.type ?? "";
96
+ const label = wfField(prompts, ty, key, "label");
97
+ const help = wfField(prompts, ty, key, "help");
98
+ const ex = wfField(prompts, ty, key, "example");
98
99
  const scope = scopeString(info.usages || []);
99
100
  const head = idx != null && tot != null
100
101
  ? ` ▸ (${idx}/${tot}) ${label} [${scope}]`
101
102
  : ` ▸ ${label} [${scope}]`;
102
103
  log(head);
103
104
  if (help) log(` ${help}`);
104
- if (ex) log(` 예) ${ex}`);
105
- log(` 기본값: ${info.default ?? ""}`);
105
+ if (ex) log(t("envPlan.example", { ex }));
106
+ log(t("envPlan.default", { value: info.default ?? "" }));
106
107
  log("");
107
108
  }
108
109
 
@@ -112,14 +113,14 @@ async function promptEach(io, prompts, asks, todoKeys, values, log) {
112
113
  const tot = todoKeys.length;
113
114
  if (tot === 0) return;
114
115
  log("");
115
- log(" 값을 입력하세요. 그대로 두려면 아무것도 입력하지 말고 Enter를 누르면 기본값이 적용됩니다.");
116
+ log(t("envPlan.enterValues"));
116
117
  log("");
117
118
  let i = 0;
118
119
  for (const key of todoKeys) {
119
120
  i++;
120
121
  const def = asks.defaults.get(key) ?? "";
121
122
  printFieldCard(prompts, key, { default: def, usages: asks.usages.get(key) || [] }, i, tot, log);
122
- let input = await io.text({ message: `↳ 값 입력 (Enter=기본값 «${def}» 유지):`, defaultValue: def });
123
+ let input = await io.text({ message: t("envPlan.valuePrompt", { def }), defaultValue: def });
123
124
  if (input === CANCEL || input == null || input === "") input = def;
124
125
  values.set(key, input);
125
126
  const label = wfField(prompts, firstTypeFor(asks.usages, key), key, "label");
@@ -159,10 +160,10 @@ export async function promptEnvPlan({
159
160
 
160
161
  // 기본값 미리보기 카드 전체 출력 (.sh 3237~3251)
161
162
  log("");
162
- log("▶ 배포 워크플로우 환경설정을 채웁니다");
163
+ log(t("envPlan.title"));
163
164
  log("");
164
- log(" 설치되는 배포 워크플로우가 사용할 값입니다. 항목마다 '무엇에 쓰이는지·설명·예시'와");
165
- log(" 기본값을 함께 보여드립니다. 그대로 둬도 되고, 원하는 것만 바꿀 수 있습니다.");
165
+ log(t("envPlan.intro1"));
166
+ log(t("envPlan.intro2"));
166
167
  log("");
167
168
  const tot = asks.keys.length;
168
169
  asks.keys.forEach((key, i) => {
@@ -171,11 +172,11 @@ export async function promptEnvPlan({
171
172
  log(" ─────────────────────────────────────────────");
172
173
 
173
174
  const choice = await ui.select({
174
- message: "어떻게 채울까요?",
175
+ message: t("envPlan.how"),
175
176
  options: [
176
- { value: "all", label: "① 위 기본값 그대로 전부 설치 (입력 없이 바로 진행)" },
177
- { value: "each", label: "② 하나씩 직접 입력 (모든 항목을 순서대로)" },
178
- { value: "some", label: "③ 몇 개만 골라서 바꾸기 (고른 것만 입력 · 나머지는 기본값)" },
177
+ { value: "all", label: t("envPlan.optAll") },
178
+ { value: "each", label: t("envPlan.optEach") },
179
+ { value: "some", label: t("envPlan.optSome") },
179
180
  ],
180
181
  });
181
182
  // ESC/취소 → 전부 기본값 (.sh `if [ "$_rc" -ne 0 ]` 등가)
@@ -194,10 +195,10 @@ export async function promptEnvPlan({
194
195
  // some: 바꿀 항목만 멀티선택 → 고른 것만 입력 (.sh 3266~3277)
195
196
  const options = asks.keys.map((key) => ({
196
197
  value: key,
197
- label: `${wfField(prompts, firstTypeFor(asks.usages, key), key, "label")} (기본: ${defaults.get(key)})`,
198
+ label: `${wfField(prompts, firstTypeFor(asks.usages, key), key, "label")} ${t("envPlan.defaultTag", { value: defaults.get(key) })}`,
198
199
  }));
199
200
  const selected = await ui.multiselect({
200
- message: "바꿀 항목을 고르세요 (Space로 선택 · Enter로 확정)",
201
+ message: t("envPlan.pickPrompt"),
201
202
  options,
202
203
  initialValues: [],
203
204
  });
package/src/ui/prompts.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // node:readline 기반 자체 엔진 사용 (@clack/prompts 는 Windows TTY에서 Enter가 멈추는 버그로 제거).
3
3
  // 취소(ESC/Ctrl+C)는 각 함수가 CANCEL 심볼을 반환 → 호출부가 정상 종료(exit 0) 처리.
4
4
  import * as engine from "./readline-engine.js";
5
+ import { t } from "../i18n/index.js";
5
6
 
6
7
  export const CANCEL = engine.CANCEL;
7
8
 
@@ -12,26 +13,26 @@ export async function selectMode({ update = null } = {}) {
12
13
  const options = [];
13
14
  if (update) {
14
15
  const range = update.from ? `v${update.from} → v${update.to}` : `v${update.to}`;
15
- options.push({ value: "update", label: `업데이트 (v${range})` });
16
+ options.push({ value: "update", label: t("mode.update", { range: `v${range}` }) });
16
17
  }
17
18
  options.push(
18
- { value: "full", label: "전체 설치 (버전관리 + 워크플로우 + 템플릿)" },
19
- { value: "version", label: "버전 관리 전용 (자동화 시스템)" },
20
- { value: "workflows", label: "워크플로우 전용 (GitHub Actions 빌드, 배포)" },
21
- { value: "issues", label: "이슈/PR 템플릿 전용" },
22
- { value: "skills", label: "AI 스킬 전용 (Claude, Cursor, Gemini, Codex, PI)" },
19
+ { value: "full", label: t("mode.full") },
20
+ { value: "version", label: t("mode.version") },
21
+ { value: "workflows", label: t("mode.workflows") },
22
+ { value: "issues", label: t("mode.issues") },
23
+ { value: "skills", label: t("mode.skills") },
23
24
  );
24
- return engine.select({ message: "무엇을 설치할까요?", options });
25
+ return engine.select({ message: t("mode.prompt"), options });
25
26
  }
26
27
 
27
28
  // 프로젝트 확인 화면 메뉴 (계속/수정/취소).
28
29
  export async function confirmProjectMenu() {
29
30
  return engine.select({
30
- message: "이 정보로 진행할까요?",
31
+ message: t("confirm.prompt"),
31
32
  options: [
32
- { value: "continue", label: "예, 계속 진행" },
33
- { value: "edit", label: "수정하기" },
34
- { value: "cancel", label: "아니오, 취소" },
33
+ { value: "continue", label: t("confirm.continue") },
34
+ { value: "edit", label: t("confirm.edit") },
35
+ { value: "cancel", label: t("confirm.cancel") },
35
36
  ],
36
37
  });
37
38
  }
@@ -41,32 +42,32 @@ export async function confirmProjectMenu() {
41
42
  // null이면 기존처럼 전부 노출 (테스트 스텁 하위호환).
42
43
  export async function editMenu({ showOptional = false, axes = null } = {}) {
43
44
  const options = [
44
- { value: "type", label: "프로젝트 타입" },
45
- { value: "version", label: "버전" },
46
- { value: "branch", label: "기본 브랜치" },
45
+ { value: "type", label: t("edit.type") },
46
+ { value: "version", label: t("edit.version") },
47
+ { value: "branch", label: t("edit.branch") },
47
48
  ];
48
49
  if (showOptional) {
49
50
  const hasDeploy = axes == null || axes.deploy.length > 0;
50
51
  const hasPublish = axes == null || axes.publish.length > 0;
51
52
  // #485 — 프로젝트 성격(intent): 재선택 시 배포/publish 축을 재유도한다. 축이 하나도 없으면 무의미 → 숨김.
52
- if (hasDeploy || hasPublish) options.push({ value: "intent", label: "프로젝트 성격 (배포 유형)" });
53
+ if (hasDeploy || hasPublish) options.push({ value: "intent", label: t("edit.intent") });
53
54
  // #483 — 항목별 격리: 한 축만 골라 그 축만 재질문한다 (통짜 "배포/Publish 방식" 분해)
54
- if (hasDeploy) options.push({ value: "deploy", label: "배포 방식 (서버 실행물)" });
55
- if (hasPublish) options.push({ value: "publish", label: "라이브러리 배포(publish) 타겟" });
56
- options.push({ value: "code-review", label: "CodeRabbit 코드 리뷰" });
57
- options.push({ value: "changelog", label: "릴리스 노트(changelog) 생성기" });
58
- options.push({ value: "release-branch", label: "릴리스 소스(개발) 브랜치" });
59
- options.push({ value: "secret", label: "Secret 백업 포함 여부" });
55
+ if (hasDeploy) options.push({ value: "deploy", label: t("edit.deploy") });
56
+ if (hasPublish) options.push({ value: "publish", label: t("edit.publish") });
57
+ options.push({ value: "code-review", label: t("edit.codeReview") });
58
+ options.push({ value: "changelog", label: t("edit.changelog") });
59
+ options.push({ value: "release-branch", label: t("edit.releaseBranch") });
60
+ options.push({ value: "secret", label: t("edit.secret") });
60
61
  }
61
- options.push({ value: "done", label: "모두 맞음, 계속" });
62
- return engine.select({ message: "어떤 항목을 수정할까요?", options });
62
+ options.push({ value: "done", label: t("edit.done") });
63
+ return engine.select({ message: t("edit.prompt"), options });
63
64
  }
64
65
 
65
66
  // 타입 멀티선택.
66
67
  export async function selectTypes(current = []) {
67
68
  const all = ["spring", "flutter", "react", "react-native", "react-native-expo", "node", "python", "basic"];
68
69
  return engine.multiselect({
69
- message: "프로젝트 타입을 선택하세요 (Space 토글, Enter 확정)",
70
+ message: t("types.prompt"),
70
71
  options: all.map((t) => ({ value: t, label: t })),
71
72
  initialValues: current.length ? current : ["basic"],
72
73
  required: true,
@@ -7,6 +7,7 @@
7
7
  import { emitKeypressEvents } from "node:readline";
8
8
  import { stdin, stdout } from "node:process";
9
9
  import { visualWidth } from "./ansi.js";
10
+ import { t } from "../i18n/index.js";
10
11
 
11
12
  export const CANCEL = Symbol("cancel");
12
13
 
@@ -114,7 +115,7 @@ export async function select({ message, options, initialIndex = 0 }) {
114
115
  const hint = o.hint && sel ? paint(` (${o.hint})`, c.dim) : "";
115
116
  lines.push(`${S_BAR} ${marker} ${label}${hint}`);
116
117
  });
117
- lines.push(paint(`└ ↑/↓ 이동 · Enter 확정 · ESC 취소`, c.gray));
118
+ lines.push(paint(`└ ${t("engine.hint.select")}`, c.gray));
118
119
  r.render(lines);
119
120
  };
120
121
 
@@ -161,7 +162,7 @@ export async function multiselect({ message, options, initialValues = [], requir
161
162
  lines.push(`${S_BAR} ${pointer} ${box} ${label}${hint}`);
162
163
  });
163
164
  if (warn) lines.push(paint(` ${warn}`, c.yellow));
164
- lines.push(paint(`└ ↑/↓ 이동 · Space 토글 · Enter 확정 · ESC 취소`, c.gray));
165
+ lines.push(paint(`└ ${t("engine.hint.multiselect")}`, c.gray));
165
166
  r.render(lines);
166
167
  };
167
168
 
@@ -171,19 +172,26 @@ export async function multiselect({ message, options, initialValues = [], requir
171
172
  if (key.name === "down" || key.name === "j") { idx = (idx + 1) % options.length; return; }
172
173
  if (key.name === "space" || str === " ") {
173
174
  const o = options[idx];
174
- if (o.disabled) { warn = "선택할 수 없는 항목입니다."; return; }
175
+ if (o.disabled) { warn = t("engine.disabled"); return; }
175
176
  if (chosen.has(o.value)) chosen.delete(o.value); else chosen.add(o.value);
176
177
  return;
177
178
  }
179
+ if (str === "a") {
180
+ // Select all; if everything is already selected, clear instead (like a toggle).
181
+ const selectable = options.filter((o) => !o.disabled).map((o) => o.value);
182
+ const allOn = selectable.every((v) => chosen.has(v));
183
+ for (const v of selectable) { if (allOn) chosen.delete(v); else chosen.add(v); }
184
+ return;
185
+ }
178
186
  if (key.name === "return" || key.name === "enter") {
179
- if (required && chosen.size === 0) { warn = "최소 1개 이상 선택하세요."; return; }
187
+ if (required && chosen.size === 0) { warn = t("engine.required"); return; }
180
188
  return [...chosen];
181
189
  }
182
190
  return;
183
191
  });
184
192
 
185
193
  if (result !== CANCEL) {
186
- const labels = options.filter((o) => chosen.has(o.value)).map((o) => o.label).join(", ") || "(없음)";
194
+ const labels = options.filter((o) => chosen.has(o.value)).map((o) => o.label).join(", ") || t("engine.none");
187
195
  r.render([S_BAR, `${S_DONE} ${paint(message, c.dim)}`, `${S_BAR} ${paint(labels, c.dim)}`]);
188
196
  }
189
197
  return result;
@@ -238,10 +246,10 @@ export async function confirm({ message, initialValue = true }) {
238
246
  let val = initialValue;
239
247
 
240
248
  const draw = () => {
241
- const yes = val ? paint("● 예", c.green) : paint("○ 예", c.dim);
242
- const no = !val ? paint("● 아니오", c.green) : paint("○ 아니오", c.dim);
249
+ const yes = val ? paint(`● ${t("engine.yes")}`, c.green) : paint(`○ ${t("engine.yes")}`, c.dim);
250
+ const no = !val ? paint(`● ${t("engine.no")}`, c.green) : paint(`○ ${t("engine.no")}`, c.dim);
243
251
  r.render([S_BAR, `${S_Q} ${paint(message, c.bold)}`, `${S_BAR} ${yes} ${no}`,
244
- paint(`└ ←/→ 또는 y/n · Enter 확정 · ESC 취소`, c.gray)]);
252
+ paint(`└ ${t("engine.hint.confirm")}`, c.gray)]);
245
253
  };
246
254
 
247
255
  const result = await keySession(draw, (str, key) => {
@@ -253,7 +261,7 @@ export async function confirm({ message, initialValue = true }) {
253
261
  });
254
262
 
255
263
  if (result !== CANCEL) {
256
- r.render([S_BAR, `${S_DONE} ${paint(message, c.dim)}`, `${S_BAR} ${paint(result ? "예" : "아니오", c.dim)}`]);
264
+ r.render([S_BAR, `${S_DONE} ${paint(message, c.dim)}`, `${S_BAR} ${paint(result ? t("engine.yes") : t("engine.no"), c.dim)}`]);
257
265
  }
258
266
  return result;
259
267
  }
@@ -261,7 +269,7 @@ export async function confirm({ message, initialValue = true }) {
261
269
  // ── 출력 헬퍼 (clack intro/outro/note/cancel 대체) ──────────────────
262
270
  export function intro(text) { stdout.write(`\n${paint("┌", c.gray)} ${paint(text, c.bold)}\n`); }
263
271
  export function outro(text) { stdout.write(`${paint("└", c.gray)} ${paint(text, c.green)}\n\n`); }
264
- export function cancelMessage(text = "취소했습니다.") { stdout.write(`${paint("■", c.yellow)} ${paint(text, c.yellow)}\n`); }
272
+ export function cancelMessage(text = t("engine.cancelled")) { stdout.write(`${paint("■", c.yellow)} ${paint(text, c.yellow)}\n`); }
265
273
  export function note(text, title = "") {
266
274
  const lines = String(text).split("\n");
267
275
  stdout.write(`${paint("○", c.cyan)} ${paint(title, c.bold)}\n`);
@@ -1,17 +1,18 @@
1
1
  // IDE Skills 대화형 프롬프트 (.sh 라우터 choose_menu 등가).
2
2
  // node:readline 기반 자체 엔진 사용 (@clack/prompts 는 Windows TTY Enter 버그로 제거).
3
3
  import * as engine from "./readline-engine.js";
4
+ import { t } from "../i18n/index.js";
4
5
 
5
6
  export const CANCEL = engine.CANCEL;
6
7
 
7
8
  // 동작 선택: 설치/업데이트 · 제거 · 그대로. 취소=skip.
8
9
  export async function selectAction() {
9
10
  const v = await engine.select({
10
- message: "AI 스킬을 어떻게 할까요?",
11
+ message: t("skills.action.prompt"),
11
12
  options: [
12
- { value: "apply", label: "설치 / 업데이트 — 최신 상태로 맞추기" },
13
- { value: "remove", label: "제거 — 설치된 스킬 삭제하기" },
14
- { value: "skip", label: "그대로 두기" },
13
+ { value: "apply", label: t("skills.action.apply") },
14
+ { value: "remove", label: t("skills.action.remove") },
15
+ { value: "skip", label: t("skills.action.skip") },
15
16
  ],
16
17
  });
17
18
  return v === CANCEL ? "skip" : v;
@@ -21,12 +22,12 @@ export async function selectAction() {
21
22
  export async function selectTargets(choices, preselect = [], action = "apply") {
22
23
  const options = choices.map((c) => ({
23
24
  value: c.id,
24
- label: c.label + (c.disabled ? " (미감지)" : ""),
25
- hint: c.disabled ? "CLI 없음" : undefined,
25
+ label: c.label + (c.disabled ? t("skills.target.undetected") : ""),
26
+ hint: c.disabled ? t("ide.cliMissing") : undefined,
26
27
  disabled: c.disabled,
27
28
  }));
28
29
  return engine.multiselect({
29
- message: `${action === "apply" ? "설치 / 업데이트" : "제거"}할 IDE를 고르세요 (Space 토글, Enter 확정)`,
30
+ message: t(action === "apply" ? "skills.target.promptApply" : "skills.target.promptRemove"),
30
31
  options,
31
32
  initialValues: preselect,
32
33
  required: false,
@@ -1,94 +1,94 @@
1
- // 첫 화면 상태 표시 층 (#446 층2~5) — 감지 로그 · 분석 카드 · IDE 상태 · 신규/업데이트 판별
2
- // (층5의 Breaking Changes 박스는 core/breaking-check.js가 담당)
1
+ // Status layers shown around the start screen: detection log, summary card, IDE status, install kind.
2
+ // (The Breaking Changes box lives in core/breaking-check.js.)
3
+ // All text goes through t() so it follows the display language; markers and ids stay as-is.
3
4
  import { A, paint, padEndVisual } from "./ansi.js";
4
5
  import { ADAPTERS } from "../core/ide/registry.js";
5
6
  import { markerForType } from "../core/detect.js";
7
+ import { t } from "../i18n/index.js";
6
8
 
7
9
  const GUT = paint("│", A.gray);
8
10
  const HEAD = paint("◆", A.cyan);
9
11
  const OK = paint("✓", A.green);
10
12
 
11
- // 층2 — 감지 로그 (.ps1 감지 진행 표시 등가)
13
+ // Detection log: one line per detected type, then version and branch.
12
14
  export function printDetectionLog({ types = [], version = "", branch = "" }, out = (s) => process.stdout.write(s)) {
13
- out(`${paint("┌", A.gray)} 🔍 프로젝트를 살펴보는 중...\n`);
15
+ out(`${paint("┌", A.gray)} ${t("detect.title")}\n`);
14
16
  if (types.length && !(types.length === 1 && types[0] === "basic")) {
15
- for (const t of types) {
16
- const marker = markerForType(t);
17
- out(`${GUT} ${OK} ${marker ? `${marker} 발견 → ` : ""}${paint(t, A.bold)} 감지\n`);
17
+ for (const type of types) {
18
+ const marker = markerForType(type);
19
+ const name = paint(type, A.bold);
20
+ out(`${GUT} ${OK} ${marker ? t("detect.found", { marker, type: name }) : t("detect.foundNoMarker", { type: name })}\n`);
18
21
  }
19
22
  } else {
20
- out(`${GUT} ${paint("─", A.dim)} 마커 파일 없음 → ${paint("basic", A.bold)} (직접 선택 가능)\n`);
23
+ out(`${GUT} ${paint("-", A.dim)} ${t("detect.none")}\n`);
21
24
  }
22
- out(`${GUT} ${OK} 버전: ${paint(`v${version}`, A.green)} - 브랜치: ${paint(branch, A.green)}\n`);
25
+ out(`${GUT} ${OK} ${t("detect.versionBranch", { version: paint(version, A.green), branch: paint(branch, A.green) })}\n`);
23
26
  out(`${GUT}\n`);
24
27
  }
25
28
 
26
- // 층3 — 프로젝트 분석 개요 카드 (.ps1 Print-ProjectAnalysis 등가+)
29
+ // Summary card shown before the "continue?" menu: what is about to be installed.
27
30
  export function printAnalysisCard({ mode = "", modeLabel = "", types = [], version = "", branch = "",
28
31
  deployTarget = null, publishTargets = null, includeSecretBackup = null, paths = new Map(), showOptional = false },
29
32
  out = (s) => process.stdout.write(s)) {
30
- out(`${HEAD} ${paint("프로젝트 분석 결과", A.bold)}\n`);
31
- // 라벨을 시각 폭(CJK 2칸) 기준으로 패딩 — 한글·영문 혼합 라벨(타입·Publish·Secret백업) 열 정렬.
32
- // 가장 긴 라벨 "Secret백업"(=8칸) 기준 여유 두고 12.
33
- const row = (icon, label, value) => out(`${GUT} ${icon} ${padEndVisual(label, 12)} ${value}\n`);
34
- row("📂", types.length > 1 ? "타입(멀티)" : "타입", paint(types.join(", ") || "basic", A.bold));
35
- row("🌙", "버전", paint(`v${version}`, A.green));
36
- row("🌿", "브랜치", branch);
37
- if (modeLabel || mode) row("💫", "통합 모드", modeLabel || mode);
33
+ out(`${HEAD} ${paint(t("card.title"), A.bold)}\n`);
34
+ // Labels are padded by visual width (CJK counts as 2 columns) so values line up in both languages.
35
+ const row = (label, value) => out(`${GUT} ${padEndVisual(label, 14)} ${value}\n`);
36
+ row(types.length > 1 ? t("card.typeMulti") : t("card.type"), paint(types.join(", ") || "basic", A.bold));
37
+ row(t("card.version"), paint(`v${version}`, A.green));
38
+ row(t("card.branch"), branch);
39
+ if (modeLabel || mode) row(t("card.mode"), modeLabel || mode);
38
40
  if (showOptional) {
39
- row("🚀", "배포", paint(deployTarget || "docker-ssh", A.bold));
41
+ row(t("card.deploy"), paint(deployTarget || "docker-ssh", A.bold));
40
42
  const pub = (publishTargets ?? []).join(",");
41
- row("📦", "Publish", pub ? paint(pub, A.green) : paint("없음", A.dim));
42
- row("🔐", "Secret백업", includeSecretBackup === true ? paint("포함", A.green) : paint("제외", A.dim));
43
+ row(t("card.publish"), pub ? paint(pub, A.green) : paint(t("card.none"), A.dim));
44
+ row(t("card.secret"), includeSecretBackup === true ? paint(t("card.included"), A.green) : paint(t("card.excluded"), A.dim));
43
45
  }
44
- // 모노레포 경로 — 루트가 아닌 항목이 하나라도 있으면 표시
46
+ // Monorepo paths: shown only when at least one type is not at the repository root.
45
47
  const nonRoot = [...paths.entries()].filter(([, p]) => p && p !== ".");
46
48
  if (nonRoot.length) {
47
- row("📁", "경로", [...paths.entries()].map(([t, p]) => `${t}→${p}`).join(", "));
49
+ row(t("card.paths"), [...paths.entries()].map(([type, p]) => `${type}→${p}`).join(", "));
48
50
  }
49
51
  out(`${GUT}\n`);
50
52
  }
51
53
 
52
- // 층4 — IDE Skills 현재 상태 수집 (어댑터 detect 순회 — 예외 없음 계약)
54
+ // IDE skills: ask every adapter what it sees (adapters never throw by contract).
53
55
  export function collectIdeStatuses(io) {
54
56
  return ADAPTERS.map((a) => {
55
57
  let st;
56
- try { st = a.detect(io); } catch { st = { installed: false, version: null, cliMissing: true, note: "감지 실패" }; }
58
+ try { st = a.detect(io); } catch { st = { installed: false, version: null, cliMissing: true, note: t("ide.detectFailed") }; }
57
59
  return { id: a.id, label: a.label, ...st };
58
60
  });
59
61
  }
60
62
 
61
- // 층4 — IDE Skills 현재 상태 표시
62
63
  export function printIdeStatus(statuses, out = (s) => process.stdout.write(s)) {
63
64
  if (!statuses?.length) return;
64
- out(`${HEAD} ${paint("AI 에이전트 스킬 상태", A.bold)}\n`);
65
+ out(`${HEAD} ${paint(t("ide.title"), A.bold)}\n`);
65
66
  for (const s of statuses) {
66
67
  let mark, detail;
67
68
  if (s.installed) {
68
69
  mark = OK;
69
- detail = paint(`설치됨${s.version ? ` v${s.version}` : ""}${s.scope ? ` (${s.scope})` : ""}`, A.green);
70
+ detail = paint(`${t("ide.installed")}${s.version ? ` v${s.version}` : ""}${s.scope ? ` (${s.scope})` : ""}`, A.green);
70
71
  } else if (s.cliMissing) {
71
72
  mark = paint("⚠", A.yellow);
72
- detail = paint(s.note || "CLI 없음", A.yellow);
73
+ detail = paint(s.note || t("ide.cliMissing"), A.yellow);
73
74
  } else {
74
- mark = paint("─", A.dim);
75
- detail = paint("미설치", A.dim);
75
+ mark = paint("-", A.dim);
76
+ detail = paint(t("ide.notInstalled"), A.dim);
76
77
  }
77
78
  out(`${GUT} ${mark} ${s.label.padEnd(14)} ${detail}${s.note && !s.cliMissing ? paint(` ${s.note}`, A.dim) : ""}\n`);
78
79
  }
79
80
  out(`${GUT}\n`);
80
81
  }
81
82
 
82
- // 층5 — 신규 통합 vs 업데이트 판별 라인 (Breaking 박스는 breaking-check.js)
83
- // WHY 판정: version.yml의 metadata.template.version(이전 통합 흔적)이 있으면 업데이트, 없으면 신규.
84
- // 사용자가 "무슨 기준으로 신규/업데이트인지"를 바로 알도록 판정 근거를 화면에 밝힌다.
83
+ // New install vs update: one line plus the reason, so users know why the menu looks the way it does.
84
+ // Rule: version.yml carrying metadata.template.version means a previous install, so it is an update.
85
85
  export function printInstallKind({ currentTemplateVersion = "", templateVersion = "" }, out = (s) => process.stdout.write(s)) {
86
86
  if (currentTemplateVersion) {
87
- out(`${GUT} ♻️ ${paint("업데이트", A.bold)} - 템플릿 ${paint(`v${currentTemplateVersion}`, A.dim)} → ${paint(`v${templateVersion}`, A.green)}\n`);
88
- out(`${GUT} ${paint("version.yml에 이전 통합 기록이 있습니다 - 메뉴 맨 위 '업데이트'가 저장된 설정 그대로 반영합니다", A.dim)}\n`);
87
+ out(`${GUT} ${paint(t("kind.update"), A.bold)}: ${t("kind.updateFrom", { from: paint(currentTemplateVersion, A.dim), to: paint(templateVersion, A.green) })}\n`);
88
+ out(`${GUT} ${paint(t("kind.updateWhy"), A.dim)}\n`);
89
89
  } else {
90
- out(`${GUT} 🆕 ${paint("신규 통합", A.bold)} - 이 프로젝트에 처음 설치합니다 (템플릿 ${paint(`v${templateVersion}`, A.green)})\n`);
91
- out(`${GUT} ${paint("version.yml에 이전 통합 기록이 없어 신규로 봅니다", A.dim)}\n`);
90
+ out(`${GUT} ${paint(t("kind.new"), A.bold)}: ${t("kind.newTemplate", { to: paint(templateVersion, A.green) })}\n`);
91
+ out(`${GUT} ${paint(t("kind.newWhy"), A.dim)}\n`);
92
92
  }
93
93
  out(`${GUT}\n`);
94
94
  }