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.
- package/README.md +144 -145
- package/package.json +2 -2
- package/src/cli/args.js +65 -29
- package/src/cli/errors.js +9 -7
- package/src/cli/help.js +51 -3
- package/src/commands/doctor.js +80 -78
- package/src/commands/full.js +11 -2
- package/src/commands/interactive.js +71 -49
- package/src/commands/issues.js +4 -0
- package/src/commands/skills.js +8 -7
- package/src/commands/version.js +4 -2
- package/src/commands/workflows.js +3 -1
- package/src/context.js +4 -0
- package/src/core/breaking-check.js +6 -5
- package/src/core/copy/simple.js +4 -0
- package/src/core/copy/workflows.js +10 -5
- package/src/core/exclusions.js +8 -0
- package/src/core/ide/adapters/claude.js +18 -17
- package/src/core/ide/adapters/codex.js +12 -11
- package/src/core/ide/adapters/cursor.js +11 -10
- package/src/core/ide/adapters/gemini.js +11 -10
- package/src/core/ide/adapters/pi-common.js +2 -1
- package/src/core/ide/adapters/pi-harness.js +13 -16
- package/src/core/ide/adapters/pi.js +12 -11
- package/src/core/label-style.js +78 -0
- package/src/core/migration-guide.js +38 -38
- package/src/core/migrations/index.js +19 -15
- package/src/core/migrations/registry.js +70 -55
- package/src/core/options-ask.js +65 -64
- package/src/core/orphan-workflows.js +2 -1
- package/src/core/paths-resolve.js +31 -30
- package/src/core/repo-language.js +47 -0
- package/src/core/run-trace.js +10 -0
- package/src/core/version-yml.js +68 -36
- package/src/i18n/core.en.js +31 -0
- package/src/i18n/core.ko.js +31 -0
- package/src/i18n/doctor.en.js +89 -0
- package/src/i18n/doctor.ko.js +89 -0
- package/src/i18n/en.js +141 -0
- package/src/i18n/ide.en.js +102 -0
- package/src/i18n/ide.ko.js +102 -0
- package/src/i18n/index.js +73 -0
- package/src/i18n/ko.js +134 -0
- package/src/i18n/migrate.en.js +66 -0
- package/src/i18n/migrate.ko.js +66 -0
- package/src/i18n/options.en.js +104 -0
- package/src/i18n/options.ko.js +104 -0
- package/src/i18n/summary.en.js +70 -0
- package/src/i18n/summary.ko.js +70 -0
- package/src/index.js +46 -28
- package/src/ui/banner.js +8 -6
- package/src/ui/env-plan.js +19 -18
- package/src/ui/prompts.js +25 -24
- package/src/ui/readline-engine.js +18 -10
- package/src/ui/skills-prompts.js +8 -7
- package/src/ui/status-cards.js +38 -38
- 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(
|
|
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(
|
|
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
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
-
if (seen.has(
|
|
68
|
-
if (!VALID_TYPES.includes(
|
|
69
|
-
throw new CliError(
|
|
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(
|
|
72
|
-
types.push(
|
|
78
|
+
seen.add(ty);
|
|
79
|
+
types.push(ty);
|
|
73
80
|
}
|
|
74
|
-
if (types.length === 0) throw new CliError("
|
|
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(
|
|
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
|
|
92
|
-
|
|
93
|
-
if (
|
|
94
|
-
if (!PUBLISH_TARGETS.includes(
|
|
95
|
-
throw new CliError(
|
|
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(
|
|
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(
|
|
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(
|
|
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("
|
|
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("
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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: "
|
|
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:
|
|
25
|
-
hint: "
|
|
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: "
|
|
31
|
+
return { message: t("errors.disk.message"), hint: t("errors.disk.hint") };
|
|
30
32
|
}
|
|
31
|
-
return { message:
|
|
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
|
|
2
|
-
|
|
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
|
+
}
|
package/src/commands/doctor.js
CHANGED
|
@@ -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
|
-
|
|
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: "
|
|
61
|
-
value: "
|
|
62
|
-
detail: ["
|
|
63
|
-
"
|
|
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: "
|
|
68
|
-
value:
|
|
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: "
|
|
75
|
-
value:
|
|
76
|
-
detail: files.length ? null : ["
|
|
77
|
-
"
|
|
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: "
|
|
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 ? "
|
|
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 ? [
|
|
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: "
|
|
96
|
-
status: "INFO", value: 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) =>
|
|
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: "
|
|
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
|
-
"
|
|
115
|
-
"
|
|
116
|
-
"
|
|
117
|
-
"
|
|
118
|
-
"
|
|
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: "
|
|
140
|
+
name: t("doctor.pd.name"), purpose: t("doctor.pd.purpose"),
|
|
139
141
|
status: needsActionsWrite.length ? "WARN" : "OK",
|
|
140
|
-
value: 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
|
-
"
|
|
144
|
-
"
|
|
145
|
-
"
|
|
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: "
|
|
154
|
+
name: t("doctor.bl.name"), purpose: t("doctor.bl.purpose"),
|
|
153
155
|
status: baseline ? "OK" : "INFO",
|
|
154
|
-
value: baseline ?
|
|
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:
|
|
181
|
-
value: perm.status === 403 ? "
|
|
182
|
-
detail: ["
|
|
183
|
-
"
|
|
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:
|
|
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:
|
|
190
|
-
value: "
|
|
191
|
-
detail: ["
|
|
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:
|
|
195
|
-
value: "
|
|
196
|
+
name: "Workflow permissions", purpose: permPurpose(), status: "WARN",
|
|
197
|
+
value: t("doctor.rm.roWarn"),
|
|
196
198
|
detail: [
|
|
197
|
-
"
|
|
199
|
+
t("doctor.rm.l1"),
|
|
198
200
|
...undeclared.map((f) => ` ${f}`),
|
|
199
|
-
"
|
|
200
|
-
"
|
|
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: "
|
|
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
|
-
"
|
|
214
|
-
"
|
|
215
|
-
"
|
|
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: "
|
|
224
|
-
value: "
|
|
225
|
-
detail: ["
|
|
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: "
|
|
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
|
-
"
|
|
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: "
|
|
247
|
+
name: t("doctor.sr.name"), purpose: t("doctor.sr.purpose"),
|
|
246
248
|
status: missing.length ? "WARN" : "OK",
|
|
247
|
-
value: 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
|
-
"
|
|
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 ? `
|
|
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
|
-
:
|
|
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("
|
|
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: "
|
|
293
|
-
value: slug ||
|
|
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: "
|
|
301
|
-
value: "
|
|
302
|
-
detail: ["
|
|
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
|
|