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
package/src/cli/args.js CHANGED
@@ -2,9 +2,12 @@
2
2
  import { existsSync, statSync, realpathSync } from "node:fs";
3
3
  import { join, relative, isAbsolute } from "node:path";
4
4
  import { VALID_TYPES } from "../context.js";
5
+ import { SUPPORTED_LANGS, t } from "../i18n/index.js";
5
6
 
6
7
  export const DEPLOY_TARGETS = ["docker-ssh", "vercel", "none"];
7
8
  export const PUBLISH_TARGETS = ["nexus", "npm", "github-packages"];
9
+ export const LABEL_STYLE_VALUES = ["en", "ko"];
10
+ export const LANGUAGE_VALUES = ["en", "ko"];
8
11
  export const INTENT_VALUES = ["app", "library", "both", "none", "manual"];
9
12
  const SEMVER_RE = /^\d+\.\d+\.\d+$/;
10
13
  // 유니코드 글자·숫자 허용 (#719) — 공백·셸 메타문자는 문자 클래스에 없어 그대로 거부된다.
@@ -22,11 +25,15 @@ export function parseArgs(argv) {
22
25
  deployTarget: null, // 배포 축 (#439): docker-ssh|vercel|none, null=미설정
23
26
  publishTargets: null, // publish 축 (#439): 타겟 배열, null=미설정
24
27
  deployBranch: "", // 릴리스 PR head 브랜치 (#456): --deploy-branch, 빈 값=미지정
28
+ language: null, // 레포 문구 언어 (#769): --language en|ko (화면 언어 --lang 과 다르다)
29
+ labelStyle: null, // 상태 라벨 표기 (#776): --label-style en|ko
25
30
  intent: null, // 프로젝트 성격 (#485): --intent app|library|both|none|manual, null=미설정(역추론)
26
31
  includeSecretBackup: null,
27
32
  aiPrSummary: null, // #566 — AI 변경 요약 워크플로우 포함 여부
33
+ projectsSync: null, // #716 — Projects 보드 동기화 워크플로우 포함 여부 (null=미지정)
28
34
  pathsCsv: "", // "flutter=app,react=client" 원문 (정규화는 resolve 단계)
29
35
  force: false,
36
+ lang: null, // --lang en|ko (null = decided by src/i18n resolveLang)
30
37
  help: false,
31
38
  showVersion: false, // -v/--version → projectops 패키지 버전 출력 (npm 관례)
32
39
  };
@@ -38,7 +45,7 @@ export function parseArgs(argv) {
38
45
  {
39
46
  const v = (args.shift() ?? "").trim();
40
47
  if (!MODE_VALUES.includes(v)) {
41
- throw new CliError(`--mode 값은 ${MODE_VALUES.join(" | ")} 중 하나여야 합니다: '${v}'`);
48
+ throw new CliError(t("args.mode", { values: MODE_VALUES.join(" | "), v }));
42
49
  }
43
50
  result.mode = v;
44
51
  break;
@@ -52,7 +59,7 @@ export function parseArgs(argv) {
52
59
  // 'v1.0.0'은 사용자가 흔히 쓰는 표기라 거부하지 않고 접두사만 떼어 받는다
53
60
  const v = (args.shift() ?? "").trim().replace(/^[vV](?=\d)/, "");
54
61
  if (!SEMVER_RE.test(v)) {
55
- throw new CliError(`--project-version 값은 x.y.z (예: 1.0.0) 형식이어야 합니다: '${v}'`);
62
+ throw new CliError(t("args.projectVersion", { v }));
56
63
  }
57
64
  result.version = v;
58
65
  break;
@@ -61,26 +68,34 @@ export function parseArgs(argv) {
61
68
  const csv = args.shift() ?? "";
62
69
  const seen = new Set();
63
70
  const types = [];
64
- for (let t of csv.split(",")) {
65
- t = t.replace(/\s/g, "");
66
- if (t === "") continue;
67
- if (seen.has(t)) continue; // dedup
68
- if (!VALID_TYPES.includes(t)) {
69
- throw new CliError(`지원하지 않는 타입: '${t}'\n지원 타입: ${VALID_TYPES.join(" ")}`);
71
+ for (let ty of csv.split(",")) {
72
+ ty = ty.replace(/\s/g, "");
73
+ if (ty === "") continue;
74
+ if (seen.has(ty)) continue; // dedup
75
+ if (!VALID_TYPES.includes(ty)) {
76
+ throw new CliError(t("args.unsupportedType", { type: ty, valid: VALID_TYPES.join(" ") }));
70
77
  }
71
- seen.add(t);
72
- types.push(t);
78
+ seen.add(ty);
79
+ types.push(ty);
73
80
  }
74
- if (types.length === 0) throw new CliError("--type 인자가 비어 있습니다");
81
+ if (types.length === 0) throw new CliError(t("args.typeEmpty"));
75
82
  result.types = types;
76
83
  result.primaryType = types[0];
77
84
  break;
78
85
  }
79
- case "--force": result.force = true; break;
86
+ case "--force": case "-y": case "--yes": result.force = true; break;
87
+ case "--lang": {
88
+ const v = (args.shift() ?? "").trim().toLowerCase();
89
+ if (!SUPPORTED_LANGS.includes(v)) {
90
+ throw new CliError(t("cli.langInvalid", { values: SUPPORTED_LANGS.join(" | "), value: v }));
91
+ }
92
+ result.lang = v;
93
+ break;
94
+ }
80
95
  case "--deploy": {
81
96
  const v = args.shift() ?? "";
82
97
  if (!DEPLOY_TARGETS.includes(v)) {
83
- throw new CliError(`--deploy 값은 ${DEPLOY_TARGETS.join(" | ")} 중 하나여야 합니다: '${v}'`);
98
+ throw new CliError(t("args.deploy", { values: DEPLOY_TARGETS.join(" | "), v }));
84
99
  }
85
100
  result.deployTarget = v;
86
101
  break;
@@ -88,13 +103,13 @@ export function parseArgs(argv) {
88
103
  case "--publish": {
89
104
  const csv = args.shift() ?? "";
90
105
  const targets = [];
91
- for (let t of csv.split(",")) {
92
- t = t.replace(/\s/g, "");
93
- if (t === "") continue;
94
- if (!PUBLISH_TARGETS.includes(t)) {
95
- throw new CliError(`--publish 값은 ${PUBLISH_TARGETS.join(" | ")} csv여야 합니다: '${t}'`);
106
+ for (let pt of csv.split(",")) {
107
+ pt = pt.replace(/\s/g, "");
108
+ if (pt === "") continue;
109
+ if (!PUBLISH_TARGETS.includes(pt)) {
110
+ throw new CliError(t("args.publish", { values: PUBLISH_TARGETS.join(" | "), v: pt }));
96
111
  }
97
- if (!targets.includes(t)) targets.push(t);
112
+ if (!targets.includes(pt)) targets.push(pt);
98
113
  }
99
114
  result.publishTargets = targets;
100
115
  break;
@@ -104,7 +119,7 @@ export function parseArgs(argv) {
104
119
  const v = (args.shift() ?? "").trim();
105
120
  // 워크플로우 yaml·셸에 그대로 삽입되므로 안전한 브랜치명만 허용 (#676)
106
121
  if (v && (!BRANCH_RE.test(v) || v.includes("..") || v.endsWith("/") || v.endsWith(".lock"))) {
107
- throw new CliError(`--deploy-branch 값은 글자·숫자·. _ - / 로 이루어진 브랜치명이어야 합니다: '${v}'`);
122
+ throw new CliError(t("args.deployBranch", { v }));
108
123
  }
109
124
  if (v) result.deployBranch = v;
110
125
  break;
@@ -113,14 +128,33 @@ export function parseArgs(argv) {
113
128
  // 프로젝트 성격 (#485). 미지정이면 --deploy/--publish에서 역추론.
114
129
  const v = (args.shift() ?? "").trim();
115
130
  if (!INTENT_VALUES.includes(v)) {
116
- throw new CliError(`--intent 값은 ${INTENT_VALUES.join(" | ")} 중 하나여야 합니다: '${v}'`);
131
+ throw new CliError(t("args.intent", { values: INTENT_VALUES.join(" | "), v }));
117
132
  }
118
133
  result.intent = v;
119
134
  break;
120
135
  }
136
+ case "--language": {
137
+ // 레포 문구 언어 (#769). 미지정이면 version.yml 저장값 → (신규 en / 기존 ko).
138
+ // 화면 언어(--lang)와 다르다: 이쪽은 사용자 레포에 쓰이는 템플릿의 언어다.
139
+ const v = (args.shift() ?? "").trim();
140
+ if (!LANGUAGE_VALUES.includes(v)) {
141
+ throw new CliError(`--language must be one of ${LANGUAGE_VALUES.join(" | ")}: '${v}'`);
142
+ }
143
+ result.language = v;
144
+ break;
145
+ }
146
+ case "--label-style": {
147
+ // 상태 라벨 표기 (#776). 미지정이면 version.yml 저장값 → (신규 en / 기존 ko).
148
+ const v = (args.shift() ?? "").trim();
149
+ if (!LABEL_STYLE_VALUES.includes(v)) {
150
+ throw new CliError(`--label-style must be one of ${LABEL_STYLE_VALUES.join(" | ")}: '${v}'`);
151
+ }
152
+ result.labelStyle = v;
153
+ break;
154
+ }
121
155
  // ── deprecated alias (1 minor 유지 — #439) ──
122
156
  case "--nexus":
123
- process.stderr.write("⚠️ --nexus는 deprecated입니다. --publish nexus --deploy none 을 사용하세요.\n");
157
+ process.stderr.write(t("args.nexusDeprecated") + "\n");
124
158
  result.publishTargets = [...new Set([...(result.publishTargets ?? []), "nexus"])];
125
159
  if (result.deployTarget === null) result.deployTarget = "none";
126
160
  break;
@@ -132,8 +166,10 @@ export function parseArgs(argv) {
132
166
  // #566 — 비대화형에서도 켜고 끌 수 있어야 한다. 없으면 자동화 환경은 선택권이 없다.
133
167
  case "--ai-summary": result.aiPrSummary = true; break;
134
168
  case "--no-ai-summary": result.aiPrSummary = false; break;
169
+ case "--projects-sync": result.projectsSync = true; break;
170
+ case "--no-projects-sync": result.projectsSync = false; break;
135
171
  case "--npm-publish":
136
- process.stderr.write("⚠️ --npm-publish는 deprecated입니다. --publish npm 을 사용하세요.\n");
172
+ process.stderr.write(t("args.npmPublishDeprecated") + "\n");
137
173
  result.publishTargets = [...new Set([...(result.publishTargets ?? []), "npm"])];
138
174
  break;
139
175
  case "--no-npm-publish":
@@ -142,7 +178,7 @@ export function parseArgs(argv) {
142
178
  case "--paths": result.pathsCsv = args.shift() ?? ""; break;
143
179
  case "-h": case "--help": result.help = true; break;
144
180
  default:
145
- throw new CliError(`알 수 없는 옵션: ${a}`);
181
+ throw new CliError(t("args.unknownOption", { opt: a }));
146
182
  }
147
183
  }
148
184
  return result;
@@ -166,18 +202,18 @@ export function normalizePath(p) {
166
202
  export function validatePathsMap(map, { root, types }) {
167
203
  for (const [type, p] of map) {
168
204
  if (!types.includes(type)) {
169
- throw new CliError(`--paths 에 선택하지 않은 타입이 있습니다: '${type}' (선택한 타입: ${types.join(",") || "없음"})`);
205
+ throw new CliError(t("args.paths.unselected", { type, types: types.join(",") || t("args.paths.none") }));
170
206
  }
171
207
  if (p.startsWith("/") || /^[A-Za-z]:/.test(p) || p.split("/").includes("..")) {
172
- throw new CliError(`--paths ${type}=${p} : 저장소 루트 기준 상대 경로여야 합니다 (절대경로, '..' 불가)`);
208
+ throw new CliError(t("args.paths.relative", { type, path: p }));
173
209
  }
174
210
  const abs = join(root, p);
175
211
  if (!existsSync(abs) || !statSync(abs).isDirectory()) {
176
- throw new CliError(`--paths ${type}=${p} : 존재하지 않는 폴더입니다`);
212
+ throw new CliError(t("args.paths.missing", { type, path: p }));
177
213
  }
178
214
  const rel = relative(realpathSync(root), realpathSync(abs));
179
215
  if (rel.startsWith("..") || isAbsolute(rel)) {
180
- throw new CliError(`--paths ${type}=${p} : 저장소 밖을 가리킵니다`);
216
+ throw new CliError(t("args.paths.outside", { type, path: p }));
181
217
  }
182
218
  }
183
219
  }
@@ -192,7 +228,7 @@ export function parsePathsCsv(csv) {
192
228
  const type = (eq >= 0 ? pair.slice(0, eq) : pair).trim();
193
229
  const rawPath = eq >= 0 ? pair.slice(eq + 1) : "";
194
230
  if (!VALID_TYPES.includes(type)) {
195
- throw new CliError(`--paths에 지원하지 않는 타입: '${type}'`);
231
+ throw new CliError(t("args.paths.badType", { type }));
196
232
  }
197
233
  map.set(type, normalizePath(rawPath));
198
234
  }
package/src/cli/errors.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { t } from "../i18n/index.js";
2
+
1
3
  // 예기치 못한 예외를 사용자가 읽을 수 있는 한 줄 오류 + 조치 안내로 바꾼다 (#672).
2
4
  // 스택 트레이스는 사용자가 고칠 수 있는 정보가 아니므로 DEBUG 환경변수가 있을 때만 노출한다.
3
5
 
@@ -8,27 +10,27 @@ export function describeError(err) {
8
10
 
9
11
  // PATH 에 git 이 없음 — spawn 계열이 ENOENT 를 던진다
10
12
  if (code === "ENOENT" && /\bgit\b/.test(`${err?.path ?? ""} ${err?.syscall ?? ""} ${msg}`)) {
11
- return { message: "git 을 실행할 수 없습니다.", hint: "git 이 설치되어 있고 PATH 에 있는지 확인하세요." };
13
+ return { message: t("errors.git.message"), hint: t("errors.git.hint") };
12
14
  }
13
15
  // 템플릿 내려받기 실패 — execFileSync 가 "Command failed: git clone ..." 를 던진다
14
16
  if (/Command failed: git clone/.test(msg)) {
15
17
  return {
16
- message: "템플릿을 내려받지 못했습니다.",
17
- hint: "네트워크·프록시 설정과 GitHub 접근 가능 여부를 확인한 뒤 다시 실행하세요.",
18
+ message: t("errors.clone.message"),
19
+ hint: t("errors.clone.hint"),
18
20
  };
19
21
  }
20
22
  // 권한/읽기 전용 파일시스템
21
23
  if (code === "EACCES" || code === "EPERM" || code === "EROFS") {
22
24
  const where = err?.path ? ` (${err.path})` : "";
23
25
  return {
24
- message: `파일을 쓸 수 없습니다${where}.`,
25
- hint: "프로젝트 폴더와 .github 폴더의 쓰기 권한(읽기 전용 여부)을 확인하세요.",
26
+ message: t("errors.write.message", { where }),
27
+ hint: t("errors.write.hint"),
26
28
  };
27
29
  }
28
30
  if (code === "ENOSPC") {
29
- return { message: "디스크 공간이 부족합니다.", hint: "공간을 확보한 뒤 다시 실행하세요." };
31
+ return { message: t("errors.disk.message"), hint: t("errors.disk.hint") };
30
32
  }
31
- return { message: `예기치 못한 오류: ${msg}`, hint: "자세한 내용은 DEBUG=1 로 다시 실행하면 볼 수 있습니다." };
33
+ return { message: t("errors.unexpected.message", { msg }), hint: t("errors.unexpected.hint") };
32
34
  }
33
35
 
34
36
  // 오류를 stderr 로 출력한다. 호출부는 비0 종료코드를 돌려주면 된다.
package/src/cli/help.js CHANGED
@@ -1,5 +1,45 @@
1
- // --help 텍스트 (.sh show_help 요약 이식).
2
- export const HELP_TEXT = `projectops — GitHub 프로젝트 자동화 템플릿 통합 CLI
1
+ // --help text in each supported language. English is the default (see src/i18n).
2
+ import { getLang } from "../i18n/index.js";
3
+
4
+ const HELP_EN = `projectops — GitHub project automation template installer
5
+
6
+ Usage:
7
+ npx projectops [options]
8
+
9
+ Options:
10
+ -m, --mode MODE Mode (full | version | workflows | issues | skills | doctor)
11
+ doctor: diagnose the installation and repository settings (read-only)
12
+ default: interactive
13
+ -t, --type CSV Project types, comma separated (e.g. spring,react,python)
14
+ supported: spring flutter react react-native
15
+ react-native-expo node python basic
16
+ (next was merged into react: use react for Next.js)
17
+ --project-version V Initial version of the target project (e.g. 1.0.0). Detected if omitted
18
+ --paths "t=p,..." Per-type project paths for monorepos. e.g. flutter=app,react=client
19
+ --intent KIND Project kind: app | library | both | none | manual
20
+ (inferred from --deploy/--publish if omitted)
21
+ --label-style STYLE Status label names: en (status: todo) | ko (legacy 작업전)
22
+ --language LANG Language of the issue and PR templates written into your repo: en | ko
23
+ --deploy TARGET Deployment, pick one: docker-ssh (default) | vercel | none
24
+ --publish CSV Publish targets: nexus,npm,github-packages (default: none)
25
+ --deploy-branch NAME Head branch of the release PR (default: develop). Not the default branch
26
+ --secret-backup / --no-secret-backup Include / exclude the secret backup workflow
27
+ --ai-summary / --no-ai-summary Include / exclude the PR summary workflow
28
+ --projects-sync / --no-projects-sync Include / exclude the GitHub Projects status sync workflow (default: off for new installs)
29
+ --nexus / --npm-publish (deprecated: use --publish nexus / --publish npm)
30
+ --force, -y, --yes Skip every confirmation and use non-interactive defaults
31
+ --lang en|ko Display language (default: system language, pinned to en in CI and with --force)
32
+ -v, --version Print the projectops version
33
+ -h, --help Show this help
34
+
35
+ Examples:
36
+ npx projectops --mode full --force --type spring,react
37
+ npx projectops --mode workflows --type flutter --paths "flutter=app"
38
+ npx projectops --mode doctor # diagnose settings
39
+ GITHUB_TOKEN=ghp_... npx projectops --mode doctor # include repository settings
40
+ `;
41
+
42
+ const HELP_KO = `projectops — GitHub 프로젝트 자동화 템플릿 통합 CLI
3
43
 
4
44
  사용법:
5
45
  npx projectops [옵션]
@@ -16,13 +56,17 @@ export const HELP_TEXT = `projectops — GitHub 프로젝트 자동화 템플릿
16
56
  --paths "t=p,..." 타입별 프로젝트 경로 (모노레포). 예: flutter=app,react=client
17
57
  --intent KIND 프로젝트 성격(#485): app | library | both | none | manual
18
58
  (미지정 시 --deploy/--publish에서 역추론. library/none→deploy 제외, app/none→publish 제외)
59
+ --label-style STYLE 상태 라벨 표기: en (status: todo) | ko (기존 작업전)
60
+ --language LANG 레포에 쓰이는 이슈/PR 템플릿 언어: en | ko
19
61
  --deploy TARGET 배포 방식 택1: docker-ssh(기본) | vercel | none
20
62
  --publish CSV publish 타겟 csv: nexus,npm,github-packages (기본: 없음)
21
63
  --deploy-branch NAME 릴리스 PR head 브랜치 (#456, 기본: develop). default_branch와 별개
22
64
  --secret-backup / --no-secret-backup Secret 백업 워크플로우 포함/제외
23
65
  --ai-summary / --no-ai-summary PR 변경 요약 워크플로우 포함/제외
66
+ --projects-sync / --no-projects-sync GitHub Projects 상태 동기화 워크플로우 포함/제외 (신규 설치 기본 제외)
24
67
  --nexus / --npm-publish (deprecated — --publish nexus / --publish npm 사용)
25
- --force 모든 확인 생략, 비대화형 기본값 사용
68
+ --force, -y, --yes 모든 확인 생략, 비대화형 기본값 사용
69
+ --lang en|ko 화면 언어 (기본: 시스템 언어, CI/--force 는 en 고정)
26
70
  -v, --version projectops 버전 출력
27
71
  -h, --help 이 도움말 표시
28
72
 
@@ -32,3 +76,7 @@ export const HELP_TEXT = `projectops — GitHub 프로젝트 자동화 템플릿
32
76
  npx projectops --mode doctor # 설정 진단
33
77
  GITHUB_TOKEN=ghp_... npx projectops --mode doctor # 저장소 설정까지 진단
34
78
  `;
79
+
80
+ export function helpText(lang = getLang()) {
81
+ return lang === "ko" ? HELP_KO : HELP_EN;
82
+ }
@@ -19,8 +19,10 @@ import { parseExisting } from "../core/version-yml.js";
19
19
  import { verifyInstall } from "../core/verify.js";
20
20
  import { readBaseline } from "../core/baseline.js";
21
21
  import { detectRepoName } from "../core/detect-fs.js";
22
+ import { t } from "../i18n/index.js";
22
23
 
23
- const PERM_PURPOSE = "자동 커밋·태그·후속 워크플로우 실행";
24
+ // 언어 전환(withLang 등) 시점에 평가되도록 함수로 둔다
25
+ const permPurpose = () => t("doctor.perm.purpose");
24
26
 
25
27
  // GitHub REST 호출. 실패는 예외가 아니라 {ok:false}로 돌려 진단이 중단되지 않게 한다.
26
28
  async function gh(path, token) {
@@ -57,46 +59,46 @@ export function localChecks(cwd = ".") {
57
59
  const existing = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")) : null;
58
60
 
59
61
  if (!existing) {
60
- add({ name: "통합 상태", purpose: "이 폴더의 템플릿 설치 여부", status: "INFO",
61
- value: "version.yml 없음",
62
- detail: ["이 폴더에는 템플릿이 통합되어 있지 않습니다.",
63
- "통합하려면: npx projectops"] });
62
+ add({ name: t("doctor.integ.name"), purpose: t("doctor.integ.purpose"), status: "INFO",
63
+ value: t("doctor.integ.noVersionYml"),
64
+ detail: [t("doctor.integ.notIntegrated"),
65
+ t("doctor.integ.howTo")] });
64
66
  return rows; // 통합 전이면 나머지 점검이 의미 없다
65
67
  }
66
68
 
67
- add({ name: "통합 상태", purpose: "이 폴더의 템플릿 설치 여부", status: "OK",
68
- value: `템플릿 v${existing.templateVersion || "unknown"} / 프로젝트 v${existing.version}` });
69
+ add({ name: t("doctor.integ.name"), purpose: t("doctor.integ.purpose"), status: "OK",
70
+ value: t("doctor.integ.versions", { tpl: existing.templateVersion || "unknown", proj: existing.version }) });
69
71
 
70
72
  const wfDir = join(cwd, PATHS.workflowsDir);
71
73
  const files = existsSync(wfDir)
72
74
  ? readdirSync(wfDir).filter((f) => /\.ya?ml$/.test(f))
73
75
  : [];
74
- add({ name: "설치된 워크플로우", purpose: "이 저장소에서 도는 자동화", status: files.length ? "OK" : "WARN",
75
- value: `${files.length}개`,
76
- detail: files.length ? null : ["워크플로우가 하나도 없습니다.",
77
- "통합 모드를 workflows 또는 full로 다시 실행해 보세요."] });
76
+ add({ name: t("doctor.wf.name"), purpose: t("doctor.wf.purpose"), status: files.length ? "OK" : "WARN",
77
+ value: t("doctor.count", { n: files.length }),
78
+ detail: files.length ? null : [t("doctor.wf.none"),
79
+ t("doctor.wf.noneHint")] });
78
80
 
79
81
  // 치환·Secret 판정은 설치 후 검증(#549)과 같은 모듈을 쓴다 — 두 곳에 두면 기준이 갈라진다.
80
82
  const v = verifyInstall(cwd);
81
83
  add({
82
- name: "치환되지 않은 값", purpose: "배포 시점에 실패할 자리",
84
+ name: t("doctor.unres.name"), purpose: t("doctor.unres.purpose"),
83
85
  status: v.unresolved.length === 0 ? "OK" : "WARN",
84
- value: v.unresolved.length === 0 ? "없음" : `${v.unresolved.length}건`,
86
+ value: v.unresolved.length === 0 ? t("doctor.none") : t("doctor.cases", { n: v.unresolved.length }),
85
87
  detail: v.unresolved.length === 0 ? null : [
86
- "아래 위치에 템플릿 값이 그대로 남아 있습니다.",
88
+ t("doctor.unres.intro"),
87
89
  ...v.unresolved.slice(0, 8).map((u) => ` ${u.filename}:${u.line} ${u.token}`),
88
- ...(v.unresolved.length > 8 ? [` … 외 ${v.unresolved.length - 8}건`] : []),
89
- "그대로 두면 해당 워크플로우가 배포 단계에서 실패합니다. 직접 값을 채워주세요.",
90
+ ...(v.unresolved.length > 8 ? [t("doctor.unres.more", { n: v.unresolved.length - 8 })] : []),
91
+ t("doctor.unres.outro"),
90
92
  ],
91
93
  });
92
94
 
93
95
  const secretNames = [...v.secrets.keys()];
94
96
  add({
95
- name: "필요한 Secret", purpose: "설치된 워크플로우가 요구하는 값",
96
- status: "INFO", value: secretNames.length ? `${secretNames.length}개` : "없음",
97
+ name: t("doctor.sec.name"), purpose: t("doctor.sec.purpose"),
98
+ status: "INFO", value: secretNames.length ? t("doctor.count", { n: secretNames.length }) : t("doctor.none"),
97
99
  detail: secretNames.length ? [
98
- ...secretNames.map((n) => ` ${n} ← ${v.secrets.get(n).length}개 워크플로우`),
99
- "쓰지 않는 워크플로우의 값은 등록하지 않아도 됩니다.",
100
+ ...secretNames.map((n) => t("doctor.sec.line", { name: n, n: v.secrets.get(n).length })),
101
+ t("doctor.sec.skipUnused"),
100
102
  ] : null,
101
103
  });
102
104
 
@@ -107,16 +109,16 @@ export function localChecks(cwd = ".") {
107
109
  const hasSummaryWf = files.some((f) => /AI-PR-SUMMARY|RELEASE-CHANGELOG/.test(f));
108
110
  if (hasSummaryWf) {
109
111
  add({
110
- name: "AI 요약 키", purpose: "릴리스 노트를 AI로 다듬을지 (선택)",
111
- status: "INFO", value: "선택 사항",
112
+ name: t("doctor.ai.name"), purpose: t("doctor.ai.purpose"),
113
+ status: "INFO", value: t("doctor.ai.optional"),
112
114
  detail: [
113
- "등록하지 않아도 릴리스 노트는 나옵니다 — 커밋 내용을 분석해 만듭니다.",
114
- "AI가 다듬은 문장을 원하면 아래 중 하나를 저장소 Secret에 등록하세요.",
115
- " GEMINI_API_KEY 무료 · https://aistudio.google.com/apikey",
116
- " GROQ_API_KEY 무료",
117
- " MISTRAL_API_KEY 무료",
118
- " OPENAI_API_KEY / ANTHROPIC_API_KEY 유료",
119
- "등록한 것이 자동으로 쓰입니다. 별도 설정은 필요 없습니다.",
115
+ t("doctor.ai.l1"),
116
+ t("doctor.ai.l2"),
117
+ t("doctor.ai.gemini"),
118
+ t("doctor.ai.groq"),
119
+ t("doctor.ai.mistral"),
120
+ t("doctor.ai.paid"),
121
+ t("doctor.ai.l3"),
120
122
  ],
121
123
  });
122
124
  }
@@ -135,26 +137,26 @@ export function localChecks(cwd = ".") {
135
137
  }
136
138
  if (files.length) {
137
139
  add({
138
- name: "워크플로우 권한 선언", purpose: "다른 워크플로우를 실행할 권한",
140
+ name: t("doctor.pd.name"), purpose: t("doctor.pd.purpose"),
139
141
  status: needsActionsWrite.length ? "WARN" : "OK",
140
- value: needsActionsWrite.length ? `${needsActionsWrite.length}건 누락` : "정상",
142
+ value: needsActionsWrite.length ? t("doctor.pd.missing", { n: needsActionsWrite.length }) : t("doctor.ok"),
141
143
  detail: needsActionsWrite.length ? [
142
144
  ...needsActionsWrite.map((f) => ` ${f}`),
143
- "다른 워크플로우를 실행하는데 permissions에 'actions: write'가 없습니다.",
144
- "저장소 설정이 Read and write여도 이 선언이 없으면 API가 403을 반환합니다.",
145
- "조치: 해당 파일의 permissions 블록에 'actions: write' 추가",
145
+ t("doctor.pd.l1"),
146
+ t("doctor.pd.l2"),
147
+ t("doctor.pd.l3"),
146
148
  ] : null,
147
149
  });
148
150
  }
149
151
 
150
152
  const baseline = readBaseline(cwd);
151
153
  add({
152
- name: "업데이트 기준점", purpose: "다음 업데이트가 내 수정을 구분할 근거",
154
+ name: t("doctor.bl.name"), purpose: t("doctor.bl.purpose"),
153
155
  status: baseline ? "OK" : "INFO",
154
- value: baseline ? `${Object.keys(baseline.files).length}개 파일 기록됨` : "없음",
156
+ value: baseline ? t("doctor.bl.recorded", { n: Object.keys(baseline.files).length }) : t("doctor.none"),
155
157
  detail: baseline ? null : [
156
- "기준점이 없어 다음 업데이트는 변경된 파일을 모두 물어봅니다.",
157
- "통합을 한 번 실행하면 기록되고, 그 다음 업데이트부터 내 수정과 템플릿 개선을 구분합니다.",
158
+ t("doctor.bl.l1"),
159
+ t("doctor.bl.l2"),
158
160
  ],
159
161
  });
160
162
 
@@ -177,27 +179,27 @@ export async function remoteChecks(slug, token, requiredSecrets = [], undeclared
177
179
 
178
180
  const perm = await gh(`/repos/${slug}/actions/permissions/workflow`, token);
179
181
  if (!perm.ok) {
180
- add({ name: "Workflow permissions", purpose: PERM_PURPOSE, status: "INFO",
181
- value: perm.status === 403 ? "조회 권한 없음" : "조회 실패",
182
- detail: ["토큰에 저장소 관리 권한이 없어 확인하지 못했습니다.",
183
- "Settings > Actions > General > Workflow permissions 에서 직접 확인하세요."] });
182
+ add({ name: "Workflow permissions", purpose: permPurpose(), status: "INFO",
183
+ value: perm.status === 403 ? t("doctor.rm.noAccess") : t("doctor.rm.failed"),
184
+ detail: [t("doctor.rm.permNoAdmin"),
185
+ t("doctor.rm.permCheck")] });
184
186
  } else if (perm.data?.default_workflow_permissions === "write") {
185
- add({ name: "Workflow permissions", purpose: PERM_PURPOSE, status: "OK",
187
+ add({ name: "Workflow permissions", purpose: permPurpose(), status: "OK",
186
188
  value: "Read and write permissions" });
187
189
  } else if (undeclared.length === 0) {
188
190
  // 모든 워크플로우가 permissions 를 스스로 선언 → 저장소 기본값(read)은 실제 동작에 영향이 없다
189
- add({ name: "Workflow permissions", purpose: PERM_PURPOSE, status: "INFO",
190
- value: "읽기 전용 (워크플로우가 권한을 직접 선언해 영향 없음)",
191
- detail: ["설치된 워크플로우가 permissions 를 모두 선언하고 있어 기본값이 읽기 전용이어도 정상 동작합니다."] });
191
+ add({ name: "Workflow permissions", purpose: permPurpose(), status: "INFO",
192
+ value: t("doctor.rm.roOk"),
193
+ detail: [t("doctor.rm.roOkDetail")] });
192
194
  } else {
193
195
  add({
194
- name: "Workflow permissions", purpose: PERM_PURPOSE, status: "WARN",
195
- value: "Read repository contents permission (읽기 전용)",
196
+ name: "Workflow permissions", purpose: permPurpose(), status: "WARN",
197
+ value: t("doctor.rm.roWarn"),
196
198
  detail: [
197
- "permissions 선언이 없는 워크플로우는 저장소에 쓰거나 다른 워크플로우를 실행할 수 없습니다.",
199
+ t("doctor.rm.l1"),
198
200
  ...undeclared.map((f) => ` ${f}`),
199
- "조치: 해당 파일에 permissions 블록을 추가하거나",
200
- " Settings > Actions > General > Workflow permissions → 'Read and write permissions' 선택",
201
+ t("doctor.rm.fix1"),
202
+ t("doctor.rm.fix2"),
201
203
  ],
202
204
  });
203
205
  }
@@ -206,13 +208,13 @@ export async function remoteChecks(slug, token, requiredSecrets = [], undeclared
206
208
  if (repo.ok) {
207
209
  const allowed = repo.data?.allow_merge_commit === true;
208
210
  add({
209
- name: "merge commit 허용", purpose: "릴리스 PR 자동 머지 조건",
211
+ name: t("doctor.mc.name"), purpose: t("doctor.mc.purpose"),
210
212
  status: allowed ? "OK" : "WARN",
211
- value: allowed ? "허용됨" : "꺼져 있음",
213
+ value: allowed ? t("doctor.mc.on") : t("doctor.mc.off"),
212
214
  detail: allowed ? null : [
213
- "릴리스 PR 자동 머지는 merge commit 방식을 사용합니다.",
214
- "꺼져 있으면 자동 머지가 실패하고 PR이 열린 채 남습니다.",
215
- "조치: Settings > General > Pull Requests → 'Allow merge commits' 체크",
215
+ t("doctor.mc.l1"),
216
+ t("doctor.mc.l2"),
217
+ t("doctor.mc.fix"),
216
218
  ],
217
219
  });
218
220
  }
@@ -220,35 +222,35 @@ export async function remoteChecks(slug, token, requiredSecrets = [], undeclared
220
222
  if (requiredSecrets.length) {
221
223
  const sec = await gh(`/repos/${slug}/actions/secrets?per_page=100`, token);
222
224
  if (!sec.ok) {
223
- add({ name: "Secret 등록 여부", purpose: "배포에 필요한 값", status: "INFO",
224
- value: "조회 권한 없음",
225
- detail: ["토큰에 Secret 조회 권한이 없어 확인하지 못했습니다."] });
225
+ add({ name: t("doctor.sr.name"), purpose: t("doctor.sr.purpose"), status: "INFO",
226
+ value: t("doctor.rm.noAccess"),
227
+ detail: [t("doctor.sr.noAccess")] });
226
228
  } else {
227
229
  const have = new Set((sec.data?.secrets || []).map((s) => s.name));
228
230
 
229
231
  // AI 요약 키가 실제로 등록돼 있는지 (#569) — 있으면 어느 서비스인지까지 보여준다.
230
232
  const AI_KEYS = { GEMINI_API_KEY: "Gemini", OPENAI_API_KEY: "OpenAI", ANTHROPIC_API_KEY: "Anthropic",
231
- GROQ_API_KEY: "Groq", MISTRAL_API_KEY: "Mistral", MODEL_API_KEY: "구 이름(서비스 자동 추정)" };
233
+ GROQ_API_KEY: "Groq", MISTRAL_API_KEY: "Mistral", MODEL_API_KEY: t("doctor.air.legacy") };
232
234
  const foundAi = Object.entries(AI_KEYS).filter(([n]) => have.has(n));
233
235
  rows.push({
234
- name: "AI 요약 키 등록", purpose: "릴리스 노트를 AI로 다듬을지 (선택)",
236
+ name: t("doctor.air.name"), purpose: t("doctor.ai.purpose"),
235
237
  status: "INFO",
236
- value: foundAi.length ? foundAi.map(([n, label]) => `${n} (${label})`).join(", ") : "없음 — 커밋 분석으로 동작",
238
+ value: foundAi.length ? foundAi.map(([n, label]) => `${n} (${label})`).join(", ") : t("doctor.air.none"),
237
239
  detail: foundAi.length ? null : [
238
- "등록하지 않아도 릴리스 노트는 정상적으로 나옵니다.",
239
- "AI를 쓰려면 GEMINI_API_KEY(무료)를 등록하세요 — https://aistudio.google.com/apikey",
240
+ t("doctor.air.l1"),
241
+ t("doctor.air.l2"),
240
242
  ],
241
243
  });
242
244
  // AI 키는 위에서 따로 안내하는 선택 항목이다 — '배포에 필요한 값' 미등록 목록에 섞으면 필수처럼 읽힌다 (#723)
243
245
  const missing = requiredSecrets.filter((n) => !have.has(n) && !(n in AI_KEYS));
244
246
  add({
245
- name: "Secret 등록 여부", purpose: "배포에 필요한 값",
247
+ name: t("doctor.sr.name"), purpose: t("doctor.sr.purpose"),
246
248
  status: missing.length ? "WARN" : "OK",
247
- value: missing.length ? `${missing.length}개 미등록` : "전부 등록됨",
249
+ value: missing.length ? t("doctor.sr.missing", { n: missing.length }) : t("doctor.sr.all"),
248
250
  detail: missing.length ? [
249
251
  ...missing.map((n) => ` ${n}`),
250
- "해당 워크플로우를 쓰지 않는다면 등록하지 않아도 됩니다.",
251
- "조치: Settings > Secrets and variables > Actions > New repository secret",
252
+ t("doctor.sr.l1"),
253
+ t("doctor.sr.fix"),
252
254
  ] : null,
253
255
  });
254
256
  }
@@ -261,7 +263,7 @@ export async function remoteChecks(slug, token, requiredSecrets = [], undeclared
261
263
  const ICON = { OK: "✅", WARN: "⚠️", FAIL: "❌", INFO: "ℹ️" };
262
264
 
263
265
  export function renderRows(rows, write = (s) => process.stderr.write(s + "\n")) {
264
- const head = (r) => `${r.name}${r.purpose ? ` — ${r.purpose}` : ""}`;
266
+ const head = (r) => `${r.name}${r.purpose ? ` - ${r.purpose}` : ""}`;
265
267
  for (const r of rows) {
266
268
  write(`${ICON[r.status] || "·"} ${head(r)}: ${r.value}`);
267
269
  // 정상 항목은 한 줄로 압축한다. 펼치는 것은 사용자가 무언가 해야 할 때뿐이다.
@@ -273,8 +275,8 @@ export function renderRows(rows, write = (s) => process.stderr.write(s + "\n"))
273
275
  const warn = rows.filter((r) => r.status === "WARN" || r.status === "FAIL").length;
274
276
  write("");
275
277
  write(warn === 0
276
- ? "확인된 문제 없음."
277
- : `살펴볼 항목 ${warn}건. 위 조치를 참고하세요 (쓰지 않는 기능이라면 넘어가도 됩니다).`);
278
+ ? t("doctor.render.clean")
279
+ : t("doctor.render.warn", { n: warn }));
278
280
  return warn;
279
281
  }
280
282
 
@@ -282,24 +284,24 @@ export function renderRows(rows, write = (s) => process.stderr.write(s + "\n"))
282
284
  export async function runDoctor({ cwd = ".", token = process.env.GITHUB_TOKEN || "" } = {}) {
283
285
  const write = (s) => process.stderr.write(s + "\n");
284
286
  write("");
285
- write("projectops doctor — 통합 상태 및 저장소 설정 진단");
287
+ write(t("doctor.title"));
286
288
  write("────────────────────────────────────────");
287
289
  write("");
288
290
 
289
291
  const rows = localChecks(cwd);
290
292
  const slug = detectSlug(cwd);
291
293
  const repoName = detectRepoName(cwd);
292
- rows.push({ name: "GitHub 원격", purpose: "점검 대상 저장소", status: slug ? "OK" : "INFO",
293
- value: slug || `origin 없음${repoName ? ` (폴더명: ${repoName})` : ""}`,
294
- detail: slug ? null : ["원격 저장소를 찾지 못해 저장소 설정은 점검하지 않습니다."] });
294
+ rows.push({ name: t("doctor.gh.name"), purpose: t("doctor.gh.purpose"), status: slug ? "OK" : "INFO",
295
+ value: slug || t("doctor.gh.noOrigin", { folder: repoName ? t("doctor.gh.folder", { name: repoName }) : "" }),
296
+ detail: slug ? null : [t("doctor.gh.noRemote")] });
295
297
 
296
298
  if (slug && token) {
297
299
  const v = verifyInstall(cwd);
298
300
  rows.push(...await remoteChecks(slug, token, [...v.secrets.keys()], workflowsWithoutPermissions(cwd)));
299
301
  } else if (slug) {
300
- rows.push({ name: "저장소 설정 점검", purpose: "권한·머지 방식·Secret", status: "INFO",
301
- value: "건너뜀 (토큰 없음)",
302
- detail: ["GITHUB_TOKEN 환경변수를 주면 저장소 설정까지 점검합니다.",
302
+ rows.push({ name: t("doctor.skip.name"), purpose: t("doctor.skip.purpose"), status: "INFO",
303
+ value: t("doctor.skip.value"),
304
+ detail: [t("doctor.skip.l1"),
303
305
  " GITHUB_TOKEN=ghp_... npx projectops --mode doctor"] });
304
306
  }
305
307