projectops 4.4.1 → 4.5.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 +1 -1
- package/package.json +1 -1
- package/src/cli/help.js +4 -1
- package/src/commands/doctor.js +257 -0
- package/src/core/baseline.js +85 -0
- package/src/core/copy/workflows.js +56 -10
- package/src/index.js +7 -0
package/README.md
CHANGED
package/package.json
CHANGED
package/src/cli/help.js
CHANGED
|
@@ -5,7 +5,8 @@ export const HELP_TEXT = `projectops — GitHub 프로젝트 자동화 템플릿
|
|
|
5
5
|
npx projectops [옵션]
|
|
6
6
|
|
|
7
7
|
옵션:
|
|
8
|
-
-m, --mode MODE 통합 모드 (full | version | workflows | issues | skills)
|
|
8
|
+
-m, --mode MODE 통합 모드 (full | version | workflows | issues | skills | doctor)
|
|
9
|
+
doctor: 통합 상태·저장소 설정 진단 (읽기 전용)
|
|
9
10
|
기본: interactive (대화형)
|
|
10
11
|
-t, --type CSV 프로젝트 타입 csv (예: spring,react,python)
|
|
11
12
|
지원: spring flutter react react-native
|
|
@@ -27,4 +28,6 @@ export const HELP_TEXT = `projectops — GitHub 프로젝트 자동화 템플릿
|
|
|
27
28
|
예시:
|
|
28
29
|
npx projectops --mode full --force --type spring,react
|
|
29
30
|
npx projectops --mode workflows --type flutter --paths "flutter=app"
|
|
31
|
+
npx projectops --mode doctor # 설정 진단
|
|
32
|
+
GITHUB_TOKEN=ghp_... npx projectops --mode doctor # 저장소 설정까지 진단
|
|
30
33
|
`;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// doctor 명령 (#558) — 통합 상태·저장소 설정 진단. 읽기 전용, 파일을 건드리지 않는다.
|
|
2
|
+
//
|
|
3
|
+
// 출력 설계:
|
|
4
|
+
// ① 항목 이름에 purpose("무엇을 위한 설정인지")를 병기한다. `actions: write`만 보고는
|
|
5
|
+
// 그게 자기 릴리스 흐름의 무엇을 담당하는지 알 수 없다.
|
|
6
|
+
// ② 도구가 "고쳐야 한다"고 판정하지 않는다. 발견한 사실만 진술하고 그것이 자신에게
|
|
7
|
+
// 문제인지는 사용자가 판단한다 — 쓰지 않는 워크플로우의 Secret은 등록할 이유가 없다.
|
|
8
|
+
// ③ 문제 항목만 `현상 → 영향 → 조치` 로 펼치고, 정상 항목은 한 줄로 압축한다.
|
|
9
|
+
// ④ GitHub 설정 화면에 실제로 표시되는 문자열("Read and write permissions" 등)은
|
|
10
|
+
// 번역하지 않는다. 번역하면 설명은 읽히지만 정작 화면에서 그 항목을 찾지 못한다.
|
|
11
|
+
//
|
|
12
|
+
// 원격 점검은 GITHUB_TOKEN이 있을 때만 한다. 없다고 실패시키지 않는다 — 로컬 점검만으로도
|
|
13
|
+
// 대부분의 흔한 사고(치환 실패·Secret 누락)를 잡을 수 있다.
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
17
|
+
import { PATHS } from "../core/paths.js";
|
|
18
|
+
import { parseExisting } from "../core/version-yml.js";
|
|
19
|
+
import { verifyInstall } from "../core/verify.js";
|
|
20
|
+
import { readBaseline } from "../core/baseline.js";
|
|
21
|
+
import { detectRepoName } from "../core/detect-fs.js";
|
|
22
|
+
|
|
23
|
+
const PERM_PURPOSE = "자동 커밋·태그·후속 워크플로우 실행";
|
|
24
|
+
|
|
25
|
+
// GitHub REST 호출. 실패는 예외가 아니라 {ok:false}로 돌려 진단이 중단되지 않게 한다.
|
|
26
|
+
async function gh(path, token) {
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(`https://api.github.com${path}`, {
|
|
29
|
+
headers: { Authorization: `token ${token}`, Accept: "application/vnd.github+json",
|
|
30
|
+
"User-Agent": "projectops-doctor" },
|
|
31
|
+
});
|
|
32
|
+
if (!res.ok) return { ok: false, status: res.status };
|
|
33
|
+
return { ok: true, data: await res.json() };
|
|
34
|
+
} catch (e) {
|
|
35
|
+
return { ok: false, error: e?.message || String(e) };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// git remote에서 owner/repo 추출. 없으면 null.
|
|
40
|
+
function detectSlug(cwd) {
|
|
41
|
+
try {
|
|
42
|
+
const url = execFileSync("git", ["remote", "get-url", "origin"],
|
|
43
|
+
{ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
44
|
+
const m = url.match(/github\.com[:/]([^/]+)\/([^/.]+)(\.git)?$/);
|
|
45
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── 로컬 점검 ─────────────────────────────────────────────────────────
|
|
52
|
+
export function localChecks(cwd = ".") {
|
|
53
|
+
const rows = [];
|
|
54
|
+
const add = (r) => rows.push(r);
|
|
55
|
+
|
|
56
|
+
const vyPath = join(cwd, PATHS.versionFile);
|
|
57
|
+
const existing = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")) : null;
|
|
58
|
+
|
|
59
|
+
if (!existing) {
|
|
60
|
+
add({ name: "통합 상태", purpose: "이 폴더의 템플릿 설치 여부", status: "INFO",
|
|
61
|
+
value: "version.yml 없음",
|
|
62
|
+
detail: ["이 폴더에는 템플릿이 통합되어 있지 않습니다.",
|
|
63
|
+
"통합하려면: npx projectops"] });
|
|
64
|
+
return rows; // 통합 전이면 나머지 점검이 의미 없다
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
add({ name: "통합 상태", purpose: "이 폴더의 템플릿 설치 여부", status: "OK",
|
|
68
|
+
value: `템플릿 v${existing.templateVersion || "unknown"} / 프로젝트 v${existing.version}` });
|
|
69
|
+
|
|
70
|
+
const wfDir = join(cwd, PATHS.workflowsDir);
|
|
71
|
+
const files = existsSync(wfDir)
|
|
72
|
+
? readdirSync(wfDir).filter((f) => /\.ya?ml$/.test(f))
|
|
73
|
+
: [];
|
|
74
|
+
add({ name: "설치된 워크플로우", purpose: "이 저장소에서 도는 자동화", status: files.length ? "OK" : "WARN",
|
|
75
|
+
value: `${files.length}개`,
|
|
76
|
+
detail: files.length ? null : ["워크플로우가 하나도 없습니다.",
|
|
77
|
+
"통합 모드를 workflows 또는 full로 다시 실행해 보세요."] });
|
|
78
|
+
|
|
79
|
+
// 치환·Secret 판정은 설치 후 검증(#549)과 같은 모듈을 쓴다 — 두 곳에 두면 기준이 갈라진다.
|
|
80
|
+
const v = verifyInstall(cwd);
|
|
81
|
+
add({
|
|
82
|
+
name: "치환되지 않은 값", purpose: "배포 시점에 실패할 자리",
|
|
83
|
+
status: v.unresolved.length === 0 ? "OK" : "WARN",
|
|
84
|
+
value: v.unresolved.length === 0 ? "없음" : `${v.unresolved.length}건`,
|
|
85
|
+
detail: v.unresolved.length === 0 ? null : [
|
|
86
|
+
"아래 위치에 템플릿 값이 그대로 남아 있습니다.",
|
|
87
|
+
...v.unresolved.slice(0, 8).map((u) => ` ${u.filename}:${u.line} ${u.token}`),
|
|
88
|
+
...(v.unresolved.length > 8 ? [` … 외 ${v.unresolved.length - 8}건`] : []),
|
|
89
|
+
"그대로 두면 해당 워크플로우가 배포 단계에서 실패합니다. 직접 값을 채워주세요.",
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const secretNames = [...v.secrets.keys()];
|
|
94
|
+
add({
|
|
95
|
+
name: "필요한 Secret", purpose: "설치된 워크플로우가 요구하는 값",
|
|
96
|
+
status: "INFO", value: secretNames.length ? `${secretNames.length}개` : "없음",
|
|
97
|
+
detail: secretNames.length ? [
|
|
98
|
+
...secretNames.map((n) => ` ${n} ← ${v.secrets.get(n).length}개 워크플로우`),
|
|
99
|
+
"쓰지 않는 워크플로우의 값은 등록하지 않아도 됩니다.",
|
|
100
|
+
] : null,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// 워크플로우 파일의 permissions 선언 점검 (#558).
|
|
104
|
+
// 저장소 설정(Settings > Actions)은 "요청할 수 있는 최대 범위"이고, 워크플로우의
|
|
105
|
+
// permissions 블록은 "실제로 요청한 범위"다. 둘은 다른 층이라 저장소 설정이 정상이어도
|
|
106
|
+
// 선언이 빠지면 API가 403을 준다 — #555가 정확히 그 사고였다.
|
|
107
|
+
const needsActionsWrite = [];
|
|
108
|
+
for (const f of files) {
|
|
109
|
+
const body = readFileSync(join(wfDir, f), "utf8");
|
|
110
|
+
if (!body.includes("dispatch_downstream.py")) continue; // 다른 워크플로우를 깨우는 파일만
|
|
111
|
+
const perm = body.match(/^permissions:\n((?:\s+.*\n)+)/m);
|
|
112
|
+
const hasActionsWrite = perm ? /^\s+actions:\s*write/m.test(perm[1]) : false;
|
|
113
|
+
if (!hasActionsWrite) needsActionsWrite.push(f);
|
|
114
|
+
}
|
|
115
|
+
if (files.length) {
|
|
116
|
+
add({
|
|
117
|
+
name: "워크플로우 권한 선언", purpose: "다른 워크플로우를 실행할 권한",
|
|
118
|
+
status: needsActionsWrite.length ? "WARN" : "OK",
|
|
119
|
+
value: needsActionsWrite.length ? `${needsActionsWrite.length}건 누락` : "정상",
|
|
120
|
+
detail: needsActionsWrite.length ? [
|
|
121
|
+
...needsActionsWrite.map((f) => ` ${f}`),
|
|
122
|
+
"다른 워크플로우를 실행하는데 permissions에 'actions: write'가 없습니다.",
|
|
123
|
+
"저장소 설정이 Read and write여도 이 선언이 없으면 API가 403을 반환합니다.",
|
|
124
|
+
"조치: 해당 파일의 permissions 블록에 'actions: write' 추가",
|
|
125
|
+
] : null,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const baseline = readBaseline(cwd);
|
|
130
|
+
add({
|
|
131
|
+
name: "업데이트 기준점", purpose: "다음 업데이트가 내 수정을 구분할 근거",
|
|
132
|
+
status: baseline ? "OK" : "INFO",
|
|
133
|
+
value: baseline ? `${Object.keys(baseline.files).length}개 파일 기록됨` : "없음",
|
|
134
|
+
detail: baseline ? null : [
|
|
135
|
+
"기준점이 없어 다음 업데이트는 변경된 파일을 모두 물어봅니다.",
|
|
136
|
+
"통합을 한 번 실행하면 기록되고, 그 다음 업데이트부터 내 수정과 템플릿 개선을 구분합니다.",
|
|
137
|
+
],
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return rows;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── 원격 점검 ─────────────────────────────────────────────────────────
|
|
144
|
+
export async function remoteChecks(slug, token, requiredSecrets = []) {
|
|
145
|
+
const rows = [];
|
|
146
|
+
const add = (r) => rows.push(r);
|
|
147
|
+
|
|
148
|
+
const perm = await gh(`/repos/${slug}/actions/permissions/workflow`, token);
|
|
149
|
+
if (!perm.ok) {
|
|
150
|
+
add({ name: "Workflow permissions", purpose: PERM_PURPOSE, status: "INFO",
|
|
151
|
+
value: perm.status === 403 ? "조회 권한 없음" : "조회 실패",
|
|
152
|
+
detail: ["토큰에 저장소 관리 권한이 없어 확인하지 못했습니다.",
|
|
153
|
+
"Settings > Actions > General > Workflow permissions 에서 직접 확인하세요."] });
|
|
154
|
+
} else if (perm.data?.default_workflow_permissions === "write") {
|
|
155
|
+
add({ name: "Workflow permissions", purpose: PERM_PURPOSE, status: "OK",
|
|
156
|
+
value: "Read and write permissions" });
|
|
157
|
+
} else {
|
|
158
|
+
add({
|
|
159
|
+
name: "Workflow permissions", purpose: PERM_PURPOSE, status: "WARN",
|
|
160
|
+
value: "Read repository contents permission (읽기 전용)",
|
|
161
|
+
detail: [
|
|
162
|
+
"워크플로우가 저장소에 쓰거나 다른 워크플로우를 실행할 수 없습니다.",
|
|
163
|
+
"버전 확정 커밋·릴리스 태그·후속 워크플로우 실행이 전부 실패합니다.",
|
|
164
|
+
"조치: Settings > Actions > General > Workflow permissions",
|
|
165
|
+
" → 'Read and write permissions' 선택",
|
|
166
|
+
],
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const repo = await gh(`/repos/${slug}`, token);
|
|
171
|
+
if (repo.ok) {
|
|
172
|
+
const allowed = repo.data?.allow_merge_commit === true;
|
|
173
|
+
add({
|
|
174
|
+
name: "merge commit 허용", purpose: "릴리스 PR 자동 머지 조건",
|
|
175
|
+
status: allowed ? "OK" : "WARN",
|
|
176
|
+
value: allowed ? "허용됨" : "꺼져 있음",
|
|
177
|
+
detail: allowed ? null : [
|
|
178
|
+
"릴리스 PR 자동 머지는 merge commit 방식을 사용합니다.",
|
|
179
|
+
"꺼져 있으면 자동 머지가 실패하고 PR이 열린 채 남습니다.",
|
|
180
|
+
"조치: Settings > General > Pull Requests → 'Allow merge commits' 체크",
|
|
181
|
+
],
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (requiredSecrets.length) {
|
|
186
|
+
const sec = await gh(`/repos/${slug}/actions/secrets?per_page=100`, token);
|
|
187
|
+
if (!sec.ok) {
|
|
188
|
+
add({ name: "Secret 등록 여부", purpose: "배포에 필요한 값", status: "INFO",
|
|
189
|
+
value: "조회 권한 없음",
|
|
190
|
+
detail: ["토큰에 Secret 조회 권한이 없어 확인하지 못했습니다."] });
|
|
191
|
+
} else {
|
|
192
|
+
const have = new Set((sec.data?.secrets || []).map((s) => s.name));
|
|
193
|
+
const missing = requiredSecrets.filter((n) => !have.has(n));
|
|
194
|
+
add({
|
|
195
|
+
name: "Secret 등록 여부", purpose: "배포에 필요한 값",
|
|
196
|
+
status: missing.length ? "WARN" : "OK",
|
|
197
|
+
value: missing.length ? `${missing.length}개 미등록` : "전부 등록됨",
|
|
198
|
+
detail: missing.length ? [
|
|
199
|
+
...missing.map((n) => ` ${n}`),
|
|
200
|
+
"해당 워크플로우를 쓰지 않는다면 등록하지 않아도 됩니다.",
|
|
201
|
+
"조치: Settings > Secrets and variables > Actions > New repository secret",
|
|
202
|
+
] : null,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return rows;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── 렌더 ──────────────────────────────────────────────────────────────
|
|
211
|
+
const ICON = { OK: "✅", WARN: "⚠️", FAIL: "❌", INFO: "ℹ️" };
|
|
212
|
+
|
|
213
|
+
export function renderRows(rows, write = (s) => process.stderr.write(s + "\n")) {
|
|
214
|
+
const head = (r) => `${r.name}${r.purpose ? ` — ${r.purpose}` : ""}`;
|
|
215
|
+
for (const r of rows) {
|
|
216
|
+
write(`${ICON[r.status] || "·"} ${head(r)}: ${r.value}`);
|
|
217
|
+
// 정상 항목은 한 줄로 압축한다. 펼치는 것은 사용자가 무언가 해야 할 때뿐이다.
|
|
218
|
+
if (r.detail && r.status !== "OK") {
|
|
219
|
+
for (const line of r.detail) write(` ${line}`);
|
|
220
|
+
write("");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const warn = rows.filter((r) => r.status === "WARN" || r.status === "FAIL").length;
|
|
224
|
+
write("");
|
|
225
|
+
write(warn === 0
|
|
226
|
+
? "확인된 문제 없음."
|
|
227
|
+
: `살펴볼 항목 ${warn}건. 위 조치를 참고하세요 (쓰지 않는 기능이라면 넘어가도 됩니다).`);
|
|
228
|
+
return warn;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 진입점. 반환: 살펴볼 항목 수 (exit code로 쓰지 않는다 — 진단은 실패가 아니다)
|
|
232
|
+
export async function runDoctor({ cwd = ".", token = process.env.GITHUB_TOKEN || "" } = {}) {
|
|
233
|
+
const write = (s) => process.stderr.write(s + "\n");
|
|
234
|
+
write("");
|
|
235
|
+
write("projectops doctor — 통합 상태 및 저장소 설정 진단");
|
|
236
|
+
write("────────────────────────────────────────");
|
|
237
|
+
write("");
|
|
238
|
+
|
|
239
|
+
const rows = localChecks(cwd);
|
|
240
|
+
const slug = detectSlug(cwd);
|
|
241
|
+
const repoName = detectRepoName(cwd);
|
|
242
|
+
rows.push({ name: "GitHub 원격", purpose: "점검 대상 저장소", status: slug ? "OK" : "INFO",
|
|
243
|
+
value: slug || `origin 없음${repoName ? ` (폴더명: ${repoName})` : ""}`,
|
|
244
|
+
detail: slug ? null : ["원격 저장소를 찾지 못해 저장소 설정은 점검하지 않습니다."] });
|
|
245
|
+
|
|
246
|
+
if (slug && token) {
|
|
247
|
+
const v = verifyInstall(cwd);
|
|
248
|
+
rows.push(...await remoteChecks(slug, token, [...v.secrets.keys()]));
|
|
249
|
+
} else if (slug) {
|
|
250
|
+
rows.push({ name: "저장소 설정 점검", purpose: "권한·머지 방식·Secret", status: "INFO",
|
|
251
|
+
value: "건너뜀 (토큰 없음)",
|
|
252
|
+
detail: ["GITHUB_TOKEN 환경변수를 주면 저장소 설정까지 점검합니다.",
|
|
253
|
+
" GITHUB_TOKEN=ghp_... npx projectops --mode doctor"] });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return renderRows(rows, write);
|
|
257
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// 설치 시점 baseline (#557) — 업데이트에서 "누가 바꿨는지"를 가르는 기준점.
|
|
2
|
+
//
|
|
3
|
+
// 왜 필요한가: isUnchanged()는 템플릿(theirs)과 설치본(ours)을 2-way로 비교한다.
|
|
4
|
+
// base가 없으니 업스트림이 한 글자만 고쳐도 사용자가 손대지 않은 파일이 changed로 떨어지고,
|
|
5
|
+
// 결국 "전부 skip(업데이트 못 받음)" 아니면 "전부 overwrite(사용자 수정 전멸)" 둘 중 하나만
|
|
6
|
+
// 고를 수 있게 된다. 정작 가려내야 할 진짜 충돌이 나머지에 묻힌다.
|
|
7
|
+
//
|
|
8
|
+
// 파일 사본이 아니라 해시만 남긴다 — 분류가 목적이지 자동 병합이 목적이 아니다.
|
|
9
|
+
//
|
|
10
|
+
// 해시를 두 개 두는 이유: env 치환으로 사용자 값이 들어간 파일은 디스크 내용과 "기본값으로
|
|
11
|
+
// 렌더한 결과"가 애초에 다르다. 하나로는 두 질문에 동시에 답할 수 없다.
|
|
12
|
+
// - installed : 설치 시점 우리가 디스크에 쓴 내용 → "사용자가 그 뒤에 손댔는가"
|
|
13
|
+
// - rendered : 그 시점 템플릿을 기본값 치환한 결과 → "업스트림이 그 뒤에 바뀌었는가"
|
|
14
|
+
//
|
|
15
|
+
// installed는 우리가 실제로 쓴 파일에만 채운다. 사용자 수정본을 installed로 기록하면
|
|
16
|
+
// "우리가 쓴 것"이라고 거짓말하는 셈이고, 다음 업데이트에서 그 파일이 조용히 덮인다.
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import { writeText } from "./fsutil.js";
|
|
21
|
+
|
|
22
|
+
export const BASELINE_DIR = ".github/.projectops";
|
|
23
|
+
export const BASELINE_PATH = ".github/.projectops/baseline.json";
|
|
24
|
+
|
|
25
|
+
export function sha256(text) {
|
|
26
|
+
return "sha256:" + createHash("sha256").update(String(text), "utf8").digest("hex");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 없거나 깨졌으면 null — 호출부는 "base 미상"으로 폴백한다(조용히 빈 baseline을 쓰지 않는다.
|
|
30
|
+
// 빈 baseline은 "기록이 없다"가 아니라 "전부 삭제됐다"로 오해될 수 있다).
|
|
31
|
+
export function readBaseline(targetRoot = ".") {
|
|
32
|
+
const p = join(targetRoot, BASELINE_PATH);
|
|
33
|
+
if (!existsSync(p)) return null;
|
|
34
|
+
try {
|
|
35
|
+
const data = JSON.parse(readFileSync(p, "utf8"));
|
|
36
|
+
if (!data || typeof data !== "object" || typeof data.files !== "object" || data.files === null) return null;
|
|
37
|
+
return data;
|
|
38
|
+
} catch {
|
|
39
|
+
return null; // 손상된 baseline은 없는 것으로 취급 — 업데이트를 막지 않는다
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// entries: Map<filename, {installed?:string|null, rendered:string}>
|
|
44
|
+
// 기존 baseline은 병합 대상이다 — 이번 실행에서 건드리지 않은 파일의 기준점을 잃지 않는다.
|
|
45
|
+
export function writeBaseline(targetRoot, { templateVersion, installedAt, entries, previous = null }) {
|
|
46
|
+
const files = { ...(previous?.files || {}) };
|
|
47
|
+
for (const [filename, entry] of entries) {
|
|
48
|
+
const prev = files[filename] || {};
|
|
49
|
+
files[filename] = {
|
|
50
|
+
// installed는 이번에 실제로 쓴 경우에만 갱신. 유지(skip)한 파일은 예전 기준점을 지킨다.
|
|
51
|
+
installed: entry.installed ?? prev.installed ?? null,
|
|
52
|
+
rendered: entry.rendered,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const out = {
|
|
56
|
+
templateVersion: templateVersion || "unknown",
|
|
57
|
+
installedAt: installedAt || "",
|
|
58
|
+
files,
|
|
59
|
+
};
|
|
60
|
+
writeText(join(targetRoot, BASELINE_PATH), JSON.stringify(out, null, 2) + "\n");
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 사용자가 이 파일을 설치 후 손댔는가.
|
|
65
|
+
// baseline이 없거나 그 파일 기록이 없으면 null — "모른다"이지 "안 건드렸다"가 아니다.
|
|
66
|
+
// 호출부는 null을 현행 2-way 판정으로 폴백시켜야 한다.
|
|
67
|
+
export function isUserModified(baseline, filename, installedContent) {
|
|
68
|
+
const rec = baseline?.files?.[filename];
|
|
69
|
+
if (!rec || !rec.installed) return null;
|
|
70
|
+
return rec.installed !== sha256(installedContent);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// baseline에는 있는데 디스크에 없는 파일 = 사용자가 지운 것.
|
|
74
|
+
// 별도의 삭제 이력 파일이 필요 없다는 것이 이 설계의 부산물이다.
|
|
75
|
+
// candidates: 템플릿이 이번에 설치하려는 파일명 목록 (그 밖의 baseline 항목은 관심 없다)
|
|
76
|
+
export function detectRemoved(baseline, candidates, workflowsDir) {
|
|
77
|
+
if (!baseline) return [];
|
|
78
|
+
const removed = [];
|
|
79
|
+
for (const filename of candidates) {
|
|
80
|
+
if (!baseline.files[filename]) continue; // 우리가 설치한 적 없는 파일 — 판단 근거 없음
|
|
81
|
+
if (existsSync(join(workflowsDir, filename))) continue;
|
|
82
|
+
removed.push(filename);
|
|
83
|
+
}
|
|
84
|
+
return removed;
|
|
85
|
+
}
|
|
@@ -7,6 +7,7 @@ import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
|
7
7
|
import { PATHS } from "../paths.js";
|
|
8
8
|
import { exists, copyFileSync, listYamlFiles } from "../fsutil.js";
|
|
9
9
|
import { isUnchanged, substituteEnv } from "../wizard-env.js";
|
|
10
|
+
import { isUserModified, readBaseline, writeBaseline, sha256 } from "../baseline.js";
|
|
10
11
|
import { substituteBranches } from "../branch-sub.js";
|
|
11
12
|
|
|
12
13
|
// 한 파일에 env 치환을 적용해 대상 파일을 갱신 (.sh configure_workflow_env 등가).
|
|
@@ -34,16 +35,25 @@ function utilSyncApplies(tempDir, targetRoot, types) {
|
|
|
34
35
|
return types.some((t) => exists(join(tempDir, ".github", "util", t)));
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
// 4분류 (신규/unchanged/upstream/changed) — 대상 워크플로우 디렉토리 기준.
|
|
39
|
+
//
|
|
40
|
+
// upstream(#557): 사용자가 손대지 않았는데 템플릿만 바뀐 파일. 질문할 이유가 없으므로
|
|
41
|
+
// 그냥 최신으로 올린다. baseline(설치 시점 해시)이 있어야 판정할 수 있다.
|
|
42
|
+
// baseline이 없거나 그 파일 기록이 없으면 판정 불가 → 종전대로 changed로 떨어뜨린다
|
|
43
|
+
// (기존 통합 레포의 동작이 바뀌지 않는다).
|
|
44
|
+
function classify(srcDir, workflowsDir, envOpts, baseline = null) {
|
|
45
|
+
const result = { newFiles: [], unchanged: [], changed: [], upstream: [] };
|
|
40
46
|
for (const filename of listYamlFiles(srcDir)) {
|
|
41
47
|
const src = join(srcDir, filename);
|
|
42
48
|
const dst = join(workflowsDir, filename);
|
|
43
49
|
if (existsSync(dst)) {
|
|
44
50
|
const tpl = readFileSync(src, "utf8");
|
|
45
51
|
const inst = readFileSync(dst, "utf8");
|
|
46
|
-
if (isUnchanged(tpl, inst, envOpts)) result.unchanged.push(filename);
|
|
52
|
+
if (isUnchanged(tpl, inst, envOpts)) { result.unchanged.push(filename); continue; }
|
|
53
|
+
// 여기 왔다는 건 "지금 템플릿 렌더 결과 ≠ 설치본" — 사용자 수정이거나 업스트림 변경이다.
|
|
54
|
+
// baseline이 그 둘을 가른다.
|
|
55
|
+
const modified = isUserModified(baseline, filename, inst);
|
|
56
|
+
if (modified === false) result.upstream.push(filename);
|
|
47
57
|
else result.changed.push(filename);
|
|
48
58
|
} else {
|
|
49
59
|
result.newFiles.push(filename);
|
|
@@ -64,6 +74,8 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
64
74
|
const decisions = hooks.decisions instanceof Map ? hooks.decisions : new Map();
|
|
65
75
|
const trace = hooks.trace ?? null; // #494 — 실행 트레이스 (null-safe: 미주입이면 전 이벤트 no-op)
|
|
66
76
|
const workflowsDir = join(targetRoot, PATHS.workflowsDir);
|
|
77
|
+
// 설치 시점 기준점(#557) — 없으면 null이고 classify가 종전 2-way로 폴백한다.
|
|
78
|
+
const baseline = readBaseline(targetRoot);
|
|
67
79
|
const projectTypesDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
|
|
68
80
|
if (!exists(projectTypesDir)) throw new Error("템플릿 저장소 구조 오류 — project-types 폴더를 찾지 못했습니다.");
|
|
69
81
|
|
|
@@ -101,7 +113,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
101
113
|
// (2~4) 타입별
|
|
102
114
|
for (const type of types) {
|
|
103
115
|
const asks = new Map();
|
|
104
|
-
copyWorkflowsForType(type, projectTypesDir, workflowsDir, { deployTarget, publishTargets, ...context, envOptsFor, collectAsks: asks, decisions, trace }, counters);
|
|
116
|
+
copyWorkflowsForType(type, projectTypesDir, workflowsDir, { deployTarget, publishTargets, ...context, envOptsFor, collectAsks: asks, decisions, trace, baseline }, counters);
|
|
105
117
|
if (asks.size) deployValues.set(type, asks);
|
|
106
118
|
}
|
|
107
119
|
|
|
@@ -155,9 +167,38 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
155
167
|
}
|
|
156
168
|
}
|
|
157
169
|
|
|
170
|
+
// (7) 기준점 기록 (#557) — 다음 업데이트가 "누가 바꿨는지"를 가릴 근거.
|
|
171
|
+
// 이번에 실제로 쓴 파일만 installed를 갱신한다. 유지(skip)한 파일에 우리가 쓴 것처럼
|
|
172
|
+
// 기록하면 다음 업데이트에서 사용자 수정이 조용히 덮인다.
|
|
173
|
+
recordBaseline(workflowsDir, targetRoot, counters, baseline, context.templateVersion, context.now);
|
|
174
|
+
|
|
158
175
|
return counters;
|
|
159
176
|
}
|
|
160
177
|
|
|
178
|
+
// 설치 직후의 디스크 내용을 기준점으로 남긴다. 실패해도 통합을 막지 않는다 —
|
|
179
|
+
// 기준점이 없으면 다음 업데이트가 종전 2-way 판정으로 폴백할 뿐이다.
|
|
180
|
+
function recordBaseline(workflowsDir, targetRoot, counters, previous, templateVersion, now) {
|
|
181
|
+
try {
|
|
182
|
+
const entries = new Map();
|
|
183
|
+
for (const f of counters.copiedFiles || []) {
|
|
184
|
+
const p = join(workflowsDir, f);
|
|
185
|
+
if (!existsSync(p)) continue;
|
|
186
|
+
const content = readFileSync(p, "utf8");
|
|
187
|
+
// 치환까지 끝난 최종 디스크 내용이 곧 우리가 쓴 것이자, 이 시점의 렌더 결과다.
|
|
188
|
+
entries.set(f, { installed: sha256(content), rendered: sha256(content) });
|
|
189
|
+
}
|
|
190
|
+
if (entries.size === 0 && previous) return; // 새로 쓴 게 없으면 기존 기준점을 건드리지 않는다
|
|
191
|
+
writeBaseline(targetRoot, {
|
|
192
|
+
templateVersion: templateVersion || "unknown",
|
|
193
|
+
installedAt: now || "",
|
|
194
|
+
entries,
|
|
195
|
+
previous,
|
|
196
|
+
});
|
|
197
|
+
} catch {
|
|
198
|
+
// 기준점 기록 실패는 통합 실패가 아니다
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
161
202
|
// changed(기존에 있고 내용이 바뀐) 파일 1개를 결정에 따라 처리 (.sh 3440~3508 3지선 case 등가).
|
|
162
203
|
// 'skip'(기본): 기존 유지. 'backup': 기존→.bak 후 교체. 'template': 기존 유지 + 새 버전을 .template.yaml로.
|
|
163
204
|
function applyDecision(decision, srcDir, workflowsDir, filename, counters, trace = null) {
|
|
@@ -189,6 +230,8 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters, trace
|
|
|
189
230
|
export function listWorkflowConflicts(context, tempDir, targetRoot = ".") {
|
|
190
231
|
const { types = [], paths = new Map(), deployTarget = "docker-ssh", repoName = "", resolvers = {}, branch = "", deployBranch = "" } = context;
|
|
191
232
|
const workflowsDir = join(targetRoot, PATHS.workflowsDir);
|
|
233
|
+
// 설치 시점 기준점(#557) — 없으면 null이고 classify가 종전 2-way로 폴백한다.
|
|
234
|
+
const baseline = readBaseline(targetRoot);
|
|
192
235
|
const projectTypesDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
|
|
193
236
|
const conflicts = []; // [{ filename, type }] — 엔진 처리 순서와 동일 (타입 순회 → 직하위 → server-deploy)
|
|
194
237
|
const branches = { defaultBranch: branch || "main", deployBranch: deployBranch || "develop" }; // #477 — 엔진과 동일 기준
|
|
@@ -196,11 +239,11 @@ export function listWorkflowConflicts(context, tempDir, targetRoot = ".") {
|
|
|
196
239
|
const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers, branches };
|
|
197
240
|
const typeDir = join(projectTypesDir, type);
|
|
198
241
|
if (exists(typeDir)) {
|
|
199
|
-
for (const f of classify(typeDir, workflowsDir, envOpts).changed) conflicts.push({ filename: f, type });
|
|
242
|
+
for (const f of classify(typeDir, workflowsDir, envOpts, baseline).changed) conflicts.push({ filename: f, type });
|
|
200
243
|
}
|
|
201
244
|
const serverDeployDir = join(typeDir, "server-deploy");
|
|
202
245
|
if (exists(serverDeployDir) && (deployTarget || "docker-ssh") === "docker-ssh") {
|
|
203
|
-
for (const f of classify(serverDeployDir, workflowsDir, envOpts).changed) conflicts.push({ filename: f, type });
|
|
246
|
+
for (const f of classify(serverDeployDir, workflowsDir, envOpts, baseline).changed) conflicts.push({ filename: f, type });
|
|
204
247
|
}
|
|
205
248
|
}
|
|
206
249
|
return conflicts;
|
|
@@ -224,17 +267,19 @@ export async function copyWorkflowsInteractive(context, tempDir, targetRoot = ".
|
|
|
224
267
|
const PUBLISH_TARGETS = ["nexus", "npm", "github-packages"];
|
|
225
268
|
|
|
226
269
|
function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters) {
|
|
227
|
-
const { deployTarget = "docker-ssh", publishTargets = [], force = false, paths = new Map(), repoName = "", resolvers = {}, envOptsFor, collectAsks = null, decisions = new Map(), trace = null } = ctx;
|
|
270
|
+
const { deployTarget = "docker-ssh", publishTargets = [], force = false, paths = new Map(), repoName = "", resolvers = {}, envOptsFor, collectAsks = null, decisions = new Map(), trace = null, baseline = null } = ctx;
|
|
228
271
|
const typeDir = join(projectTypesDir, type);
|
|
229
272
|
const envOpts = envOptsFor(type);
|
|
230
273
|
let unchangedNames = [];
|
|
231
274
|
|
|
232
275
|
// 타입별 워크플로우 (직하위)
|
|
233
276
|
if (exists(typeDir)) {
|
|
234
|
-
const { newFiles, unchanged, changed } = classify(typeDir, workflowsDir, envOpts);
|
|
277
|
+
const { newFiles, unchanged, changed, upstream } = classify(typeDir, workflowsDir, envOpts, baseline);
|
|
235
278
|
unchangedNames = unchanged.slice();
|
|
236
279
|
for (const f of unchanged) { counters.skipped++; trace?.event("copy", "skipped-unchanged", f, { group: type }); }
|
|
237
280
|
for (const f of newFiles) { copyFileSync(join(typeDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "copied", f, { group: type }); }
|
|
281
|
+
// upstream(#557): 사용자가 손대지 않았고 템플릿만 바뀐 파일 — 물어볼 것 없이 최신으로 올린다.
|
|
282
|
+
for (const f of upstream) { copyFileSync(join(typeDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "upstream-updated", f, { group: type }); }
|
|
238
283
|
// changed: 결정 Map에 따라 처리 (미지정=skip → 현행 force 동작과 동일)
|
|
239
284
|
for (const f of changed) applyDecision(decisions.get(f), typeDir, workflowsDir, f, counters, trace);
|
|
240
285
|
}
|
|
@@ -242,9 +287,10 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
242
287
|
// server-deploy — deploy=docker-ssh일 때만 포함 (#439)
|
|
243
288
|
const serverDeployDir = join(typeDir, "server-deploy");
|
|
244
289
|
if (exists(serverDeployDir) && (deployTarget || "docker-ssh") === "docker-ssh") {
|
|
245
|
-
const { newFiles, unchanged, changed } = classify(serverDeployDir, workflowsDir, envOpts);
|
|
290
|
+
const { newFiles, unchanged, changed, upstream } = classify(serverDeployDir, workflowsDir, envOpts, baseline);
|
|
246
291
|
for (const f of unchanged) { counters.skipped++; trace?.event("copy", "skipped-unchanged", f, { group: `${type}/server-deploy` }); }
|
|
247
292
|
for (const f of newFiles) { copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "copied", f, { group: `${type}/server-deploy` }); }
|
|
293
|
+
for (const f of upstream) { copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "upstream-updated", f, { group: `${type}/server-deploy` }); }
|
|
248
294
|
for (const f of changed) applyDecision(decisions.get(f), serverDeployDir, workflowsDir, f, counters, trace);
|
|
249
295
|
}
|
|
250
296
|
|
package/src/index.js
CHANGED
|
@@ -60,6 +60,13 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
|
|
|
60
60
|
if (opts.showVersion) { console.log(readPkgVersion()); return 0; }
|
|
61
61
|
if (opts.help) { console.log(HELP_TEXT); return 0; }
|
|
62
62
|
|
|
63
|
+
// doctor 모드 (#558) — 읽기 전용 진단. 템플릿을 내려받지 않으므로 네트워크 없이도 동작한다.
|
|
64
|
+
if (opts.mode === "doctor") {
|
|
65
|
+
const { runDoctor } = await import("./commands/doctor.js");
|
|
66
|
+
await runDoctor({ cwd });
|
|
67
|
+
return 0; // 진단은 실패가 아니다 — 살펴볼 항목이 있어도 0으로 끝낸다
|
|
68
|
+
}
|
|
69
|
+
|
|
63
70
|
// skills 모드 — IDE 스킬 설치/업데이트/제거 (템플릿 통합 없음).
|
|
64
71
|
// Cursor 복사용 skills/ 소스가 필요하므로 템플릿을 획득한 뒤 실행한다.
|
|
65
72
|
if (opts.mode === "skills") {
|