projectops 4.4.1 → 4.6.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/commands/full.js +32 -15
- package/src/commands/interactive.js +55 -14
- package/src/commands/workflows.js +12 -2
- package/src/core/baseline.js +85 -0
- package/src/core/copy/workflows.js +106 -19
- package/src/core/migration-guide.js +2 -2
- package/src/core/run-trace.js +164 -9
- package/src/index.js +143 -19
- package/src/ui/summary.js +11 -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
|
+
}
|
package/src/commands/full.js
CHANGED
|
@@ -33,46 +33,63 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
33
33
|
|
|
34
34
|
// 3. 워크플로우 복사 (+ env 치환) — deploy 블록에 쓸 ask 값을 수집한다.
|
|
35
35
|
// hooks.decisions: 대화형 충돌 3지선 결정 Map (미지정=skip — 현행 force 동작)
|
|
36
|
-
const
|
|
36
|
+
const step = (name, fn, d) => (hooks.trace ? hooks.trace.step(name, fn, d) : fn());
|
|
37
|
+
const wfCounters = step("copy-workflows", () => copyWorkflows(context, tempDir, targetRoot, hooks),
|
|
38
|
+
{ types, deploy: deployTarget, publish: publishTargets });
|
|
37
39
|
const deployValues = wfCounters.deployValues || new Map(); // Map<type, Map<key,value>>
|
|
38
40
|
|
|
39
41
|
// 1. version.yml 생성 (전체 재생성 — metadata → deploy → template 순, .sh 최종형과 동일)
|
|
40
|
-
writeText(join(targetRoot, PATHS.versionFile),
|
|
42
|
+
step("write-version-yml", () => writeText(join(targetRoot, PATHS.versionFile),
|
|
41
43
|
buildVersionYml({
|
|
42
44
|
version, types, paths, pathMarkers, branch, deployBranch, versionCode, now, today,
|
|
43
45
|
deployValues,
|
|
44
46
|
templateOptions: { templateVersion, deployTarget, publishTargets, includeSecretBackup, optionsDate: today,
|
|
45
47
|
changelogProvider, changelogBaseUrl, codeReviewCoderabbit, intent, mode: "full", semverAuto, appRelease },
|
|
46
|
-
}));
|
|
48
|
+
})), { version, versionCode });
|
|
47
49
|
|
|
48
50
|
// 2. README 버전 섹션
|
|
49
|
-
addVersionSectionToReadme(version, targetRoot);
|
|
51
|
+
step("update-readme", () => addVersionSectionToReadme(version, targetRoot), { version });
|
|
50
52
|
|
|
51
53
|
// 5. scripts / config
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
// 워크플로우 밖 영역도 기록한다 (#561) — 종전에는 "내 스크립트가 갱신됐나"를
|
|
55
|
+
// 로그만 보고 알 수 없었다.
|
|
56
|
+
step("copy-scripts", () => copyScripts(tempDir, targetRoot));
|
|
57
|
+
step("copy-config", () => copyConfigFolder(tempDir, targetRoot));
|
|
54
58
|
|
|
55
59
|
// 6. util (타입별)
|
|
56
|
-
for (const t of types) copyUtilModules(tempDir, t, { force }, targetRoot);
|
|
60
|
+
for (const t of types) step("copy-util", () => copyUtilModules(tempDir, t, { force }, targetRoot), { type: t });
|
|
57
61
|
|
|
58
62
|
// 7. issue / discussion 템플릿
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
step("copy-templates", () => {
|
|
64
|
+
copyIssueTemplates(tempDir, targetRoot);
|
|
65
|
+
copyDiscussionTemplates(tempDir, targetRoot);
|
|
66
|
+
});
|
|
61
67
|
|
|
62
68
|
// 8. coderabbit / gitignore / setup guide
|
|
63
69
|
// CodeRabbit 코드리뷰 미사용 선택(#457)이면 .coderabbit.yaml을 복사하지 않는다.
|
|
64
|
-
copyCoderabbit(tempDir, { force, enabled: codeReviewCoderabbit }, targetRoot)
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
step("copy-coderabbit", () => copyCoderabbit(tempDir, { force, enabled: codeReviewCoderabbit }, targetRoot),
|
|
71
|
+
{ enabled: codeReviewCoderabbit });
|
|
72
|
+
step("ensure-gitignore", () => ensureGitignore(targetRoot));
|
|
73
|
+
step("copy-setup-guide", () => copySetupGuide(tempDir, targetRoot));
|
|
67
74
|
|
|
68
75
|
// 9. 설치 후 검증 (#549) — 디스크에 쓰인 최종 결과물을 다시 읽는다.
|
|
69
76
|
// 치환은 파일 단위로 흩어져 일어나고 auto 토큰은 resolver 결과에 의존하므로,
|
|
70
77
|
// 최종 내용을 보는 것이 실제 배포될 것과 같은 것을 보는 유일한 방법이다.
|
|
71
|
-
const verification = verifyInstall(targetRoot);
|
|
72
|
-
|
|
78
|
+
const verification = step("verify-install", () => verifyInstall(targetRoot));
|
|
79
|
+
// detail 키 이름 주의: run-trace의 민감값 가드가 pat|token|secret|password|credential을
|
|
80
|
+
// 키에서 걸러낸다(#494). 여기서 다루는 값은 비밀이 아니라 "치환 플레이스홀더 이름"과
|
|
81
|
+
// "등록이 필요한 키 이름"이라 가드에 걸리지 않는 이름을 쓴다 — 가드 자체는 우회하지 않는다.
|
|
82
|
+
hooks.trace?.event("verify", "scan", "", {
|
|
73
83
|
unresolved: verification.unresolved.length,
|
|
74
|
-
|
|
84
|
+
requiredKeys: verification.secrets.size,
|
|
75
85
|
});
|
|
86
|
+
// 미치환 값은 배포 시점에 실패할 자리다 — 어느 파일 몇 번째 줄인지 로그에 남긴다.
|
|
87
|
+
for (const u of verification.unresolved) {
|
|
88
|
+
hooks.trace?.event("verify", "unresolved", u.filename, { line: u.line, placeholder: u.token });
|
|
89
|
+
}
|
|
90
|
+
for (const [name, users] of verification.secrets) {
|
|
91
|
+
hooks.trace?.event("verify", "required-key", name, { workflows: users });
|
|
92
|
+
}
|
|
76
93
|
|
|
77
94
|
return { workflows: wfCounters, verification };
|
|
78
95
|
}
|
|
@@ -14,7 +14,7 @@ import { runMigrations } from "../core/migrations/index.js";
|
|
|
14
14
|
import { detectOrphanWorkflows, applyOrphanCleanup } from "../core/orphan-workflows.js";
|
|
15
15
|
import { resolveProjectPaths, filterExcludedTypes } from "../core/paths-resolve.js";
|
|
16
16
|
import { askAllOptionalWorkflows, OPTION_AXES, applicableTargets } from "../core/options-ask.js";
|
|
17
|
-
import { createRunTrace } from "../core/run-trace.js";
|
|
17
|
+
import { createRunTrace, MIGRATION_DIR } from "../core/run-trace.js";
|
|
18
18
|
import { appendGuideEntry } from "../core/migration-guide.js";
|
|
19
19
|
import { promptEnvPlan } from "../ui/env-plan.js";
|
|
20
20
|
import { listWorkflowConflicts } from "../core/copy/workflows.js";
|
|
@@ -36,11 +36,17 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
36
36
|
const tempDir = join(cwd, PATHS.tempDir);
|
|
37
37
|
// 실행 트레이스 (#494) — 실제 CLI(io=prompts)에서만 터미널 미러를 켠다 (테스트 스텁 io는 이벤트만).
|
|
38
38
|
const trace = createRunTrace();
|
|
39
|
+
// Ctrl+C로 끊어도 기록이 남는다 (#561) — 대화형은 사람이 중간에 끊는 일이 잦다.
|
|
40
|
+
const disarmSignals = trace.armSignals({ targetRoot: cwd, now: clock?.now || "" });
|
|
41
|
+
// finally에서 기록할 때 쓰는 값 — try 안에서 확정되기 전에 취소될 수 있으므로 바깥에 둔다 (#561).
|
|
42
|
+
let traceFrom = "";
|
|
43
|
+
let traceTo = "";
|
|
39
44
|
if (io === prompts) trace.mirrorStart();
|
|
40
45
|
try {
|
|
41
46
|
// 템플릿 먼저 획득 — 배너에 실제 템플릿 버전을 표시 (.sh는 원격 version.yml fetch L4270~4280 등가)
|
|
42
|
-
acquireTemplate({ tempDir, source });
|
|
47
|
+
trace.step("acquire-template", () => acquireTemplate({ tempDir, source }), { source: source?.type || "git" });
|
|
43
48
|
const templateVersion = readTemplateVersion(tempDir);
|
|
49
|
+
traceTo = templateVersion;
|
|
44
50
|
|
|
45
51
|
// 층1 — 시작 배너 (#446 확정 시안 A). 스텁엔 banner 없음 → intro 폴백.
|
|
46
52
|
if (io.banner) io.banner({ version: templateVersion, modeLabel: "대화형 통합 마법사" });
|
|
@@ -49,6 +55,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
49
55
|
// 기존 version.yml — version/version_code/paths/옵션 보존의 단일 진실 (.sh SSoT L2208~2239)
|
|
50
56
|
const vyPath = join(cwd, "version.yml");
|
|
51
57
|
const existing = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")) : null;
|
|
58
|
+
traceFrom = existing?.templateVersion || "";
|
|
52
59
|
|
|
53
60
|
// 층4 — IDE Skills 현재 상태 · 층5 — 신규/업데이트 판별 (#446)
|
|
54
61
|
io.ideStatus?.();
|
|
@@ -56,8 +63,11 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
56
63
|
|
|
57
64
|
// 1) 모드 선택 — 기존 통합 레포면 업데이트 항목을 맨 위에 노출 (#502)
|
|
58
65
|
const updateInfo = existing?.templateVersion ? { from: existing.templateVersion, to: templateVersion } : null;
|
|
66
|
+
// 실행 경계(#561) — 대화형은 사람이 무엇을 골랐는지가 핵심 기록이다.
|
|
67
|
+
trace.event("run", "start", "interactive", { templateVersion, isUpdate: !!updateInfo });
|
|
59
68
|
const picked = await io.selectMode(updateInfo ? { update: updateInfo } : {});
|
|
60
|
-
|
|
69
|
+
trace.event("prompt", "mode", String(picked ?? ""), { update: !!updateInfo });
|
|
70
|
+
if (picked === CANCEL || picked == null) { trace.event("run", "cancelled", "mode-select", { reason: "user-cancel" }); io.cancelMessage?.("설치를 취소했습니다."); return 0; }
|
|
61
71
|
// 업데이트 모드(#502): 저장된 통합 범위(templateMode, 없으면 full)를 재실행하고
|
|
62
72
|
// 이하 updateRun 분기가 질문을 최소화한다 ("저장된 설정 그대로 반영"이 계약).
|
|
63
73
|
const updateRun = picked === "update";
|
|
@@ -65,12 +75,12 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
65
75
|
|
|
66
76
|
// Breaking Changes 게이트 (.sh execute_integration L4415~4420 — 모든 모드 공통, 대화형은 확인 질문)
|
|
67
77
|
let breakingReport = null; // #493 — 통과 구간 항목을 가이드에 조치 방법 전문으로 임베드
|
|
68
|
-
const proceed = await runBreakingCheck({
|
|
78
|
+
const proceed = await trace.stepAsync("breaking-check", () => runBreakingCheck({
|
|
69
79
|
cwd, tempDir, templateVersion,
|
|
70
80
|
askYesNo: (msg, def) => io.askYesNo(msg, def),
|
|
71
81
|
onItems: (items) => { breakingReport = items; },
|
|
72
|
-
});
|
|
73
|
-
if (!proceed) { io.cancelMessage?.("통합을 안전하게 취소했습니다."); return 0; }
|
|
82
|
+
}));
|
|
83
|
+
if (!proceed) { trace.event("run", "cancelled", "breaking-gate", { reason: "user-declined" }); io.cancelMessage?.("통합을 안전하게 취소했습니다."); return 0; }
|
|
74
84
|
|
|
75
85
|
// skills 모드 — IDE 스킬 설치 (템플릿 통합 없음). 대화형으로 실행.
|
|
76
86
|
if (mode === "skills") {
|
|
@@ -172,7 +182,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
172
182
|
io.note?.(summarize({ mode, types, version, branch, deployTarget, publishTargets, includeSecretBackup, showOptional, changelogProvider, codeReviewCoderabbit }), "프로젝트 분석 결과");
|
|
173
183
|
}
|
|
174
184
|
const choice = await io.confirmProjectMenu();
|
|
175
|
-
if (choice === "cancel") { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
|
|
185
|
+
if (choice === "cancel") { trace.event("run", "cancelled", "confirm-loop", { reason: "user-cancel" }); io.cancelMessage?.("설치를 취소했습니다."); return 0; }
|
|
176
186
|
if (isCancel(choice) || choice == null) continue; // ESC = 머무르기 (루프 재출력)
|
|
177
187
|
if (choice === "continue") { confirmed = true; break; }
|
|
178
188
|
// edit 루프
|
|
@@ -183,6 +193,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
183
193
|
if (isCancel(what) || what === "done") { editing = false; break; }
|
|
184
194
|
if (what === "type") {
|
|
185
195
|
const t = await io.selectTypes(types);
|
|
196
|
+
trace.event("prompt", "types", (Array.isArray(t) ? t : []).join(",") || "", { before: types });
|
|
186
197
|
if (!isCancel(t) && Array.isArray(t) && t.length) {
|
|
187
198
|
// 타입 집합이 실제로 바뀌면 경로 재해석 대상으로 초기화 (.sh L1984~1992 — 정렬 집합 비교)
|
|
188
199
|
const oldSorted = [...types].sort().join(",");
|
|
@@ -264,6 +275,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
264
275
|
if (conflicts.length) {
|
|
265
276
|
io.note?.(conflicts.map((c) => `• ${c.filename}`).join("\n"), `♻️ 템플릿이 갱신된 워크플로우 ${conflicts.length}개`);
|
|
266
277
|
const yes = await io.askYesNo(`위 ${conflicts.length}개를 .bak 백업 후 새 버전으로 교체할까요? (기존 설정값은 유지됩니다)`, true);
|
|
278
|
+
trace.event("prompt", "conflict-bulk", yes ? "backup" : "skip", { count: conflicts.length, files: conflicts.map((c) => c.filename) });
|
|
267
279
|
const decision = yes === true ? "backup" : "skip";
|
|
268
280
|
updateDecisions = new Map();
|
|
269
281
|
for (const { filename } of conflicts) updateDecisions.set(filename, decision);
|
|
@@ -336,6 +348,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
336
348
|
`🧹 선택되지 않은 타입의 워크플로우 ${orphans.length}개 발견`,
|
|
337
349
|
);
|
|
338
350
|
const yes = await io.askYesNo(`위 ${orphans.length}개를 정리할까요? (.bak 무해화 — 복원 가능)`, true);
|
|
351
|
+
trace.event("prompt", "orphan-cleanup", yes ? "clean" : "keep", { count: orphans.length });
|
|
339
352
|
if (yes === true) {
|
|
340
353
|
const results = applyOrphanCleanup(cwd, orphans);
|
|
341
354
|
const ok = results.filter((r) => r.action === "bak");
|
|
@@ -350,9 +363,9 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
350
363
|
}
|
|
351
364
|
|
|
352
365
|
let result = null;
|
|
353
|
-
if (mode === "full") result = runFull(ctx, tempDir, cwd, { ...hooks, trace });
|
|
354
|
-
else if (mode === "version") result = runVersion(ctx, tempDir, cwd);
|
|
355
|
-
else if (mode === "workflows") result = runWorkflows(ctx, tempDir, cwd, { ...hooks, trace });
|
|
366
|
+
if (mode === "full") result = trace.step("install-full", () => runFull(ctx, tempDir, cwd, { ...hooks, trace }));
|
|
367
|
+
else if (mode === "version") result = trace.step("install-version", () => runVersion(ctx, tempDir, cwd));
|
|
368
|
+
else if (mode === "workflows") result = trace.step("install-workflows", () => runWorkflows(ctx, tempDir, cwd, { ...hooks, trace }));
|
|
356
369
|
|
|
357
370
|
// 통합 후 IDE 스킬 제안 (.sh L4557 offer_ide_tools_install — 사전 질문 게이트, 기본 N)
|
|
358
371
|
// 업데이트 모드(#502): 이미 설치된 IDE 스킬만 질문 없이 최신화 (미설치 IDE는 건드리지 않음)
|
|
@@ -365,12 +378,16 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
365
378
|
|
|
366
379
|
// 마이그레이션 기록 (#493/#494) — Layer 2/3 트레이스 파일 + Layer 1 가이드 엔트리 (full/workflows만)
|
|
367
380
|
let migrationGuidePath = null;
|
|
368
|
-
|
|
369
|
-
|
|
381
|
+
const recordArtifacts = mode === "full" || mode === "workflows";
|
|
382
|
+
// 경로는 먼저 계산하고 실제 쓰기는 완료 화면 뒤로 미룬다 — 요약까지 터미널 미러에 담기 위함 (#561)
|
|
383
|
+
const files = recordArtifacts
|
|
384
|
+
? trace.paths({ fromVersion: existing?.templateVersion || "", toVersion: templateVersion, now })
|
|
385
|
+
: null;
|
|
386
|
+
if (recordArtifacts) {
|
|
370
387
|
migrationGuidePath = appendGuideEntry(cwd, {
|
|
371
388
|
now, mode, types, repoName,
|
|
372
389
|
templateFrom: existing?.templateVersion || "", templateTo: templateVersion,
|
|
373
|
-
options: { deploy: deployTarget, publish: publishTargets, secretBackup: includeSecretBackup, coderabbit: codeReviewCoderabbit, changelogProvider, intent, semverAuto },
|
|
390
|
+
options: { deploy: deployTarget, publish: publishTargets, secretBackup: includeSecretBackup, coderabbit: codeReviewCoderabbit, changelogProvider, intent, semverAuto , appRelease },
|
|
374
391
|
branches: { defaultBranch: branch, deployBranch, ready: deployBranchReady, created: deployBranchCreated },
|
|
375
392
|
breaking: breakingReport, migrations: migrationsResult, orphans: orphanReport,
|
|
376
393
|
events: trace.events, counters: { skipped: result?.workflows?.skipped ?? 0 },
|
|
@@ -382,11 +399,35 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
382
399
|
io.summary?.({
|
|
383
400
|
mode, types, version, deployBranch, deployBranchReady, migrationGuidePath,
|
|
384
401
|
counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
|
|
402
|
+
verification: result?.verification, // #549 설치 검증 결과
|
|
403
|
+
logDir: files ? MIGRATION_DIR : null, // #561 기록 위치 안내
|
|
404
|
+
logFile: files?.logFile ?? null,
|
|
405
|
+
traceFile: files?.traceFile ?? null,
|
|
385
406
|
}, cwd);
|
|
386
407
|
io.outro?.(`통합 완료 — ${mode} 모드로 설치했습니다.`);
|
|
408
|
+
|
|
409
|
+
// 완료 화면까지 캡처한 뒤 종료하고 기록한다 (#561)
|
|
410
|
+
trace.event("run", "end", mode || "", {
|
|
411
|
+
workflowsCopied: result?.workflows?.copied ?? 0,
|
|
412
|
+
workflowsSkipped: result?.workflows?.skipped ?? 0,
|
|
413
|
+
});
|
|
414
|
+
if (recordArtifacts) {
|
|
415
|
+
trace.finalize({ targetRoot: cwd, fromVersion: existing?.templateVersion || "", toVersion: templateVersion, now });
|
|
416
|
+
} else {
|
|
417
|
+
trace.mirrorStop();
|
|
418
|
+
}
|
|
387
419
|
return 0;
|
|
420
|
+
} catch (err) {
|
|
421
|
+
trace.event("run", "error", "interactive", {
|
|
422
|
+
message: err?.message || String(err),
|
|
423
|
+
stack: String(err?.stack || "").split("\n").slice(0, 3).join(" | "),
|
|
424
|
+
});
|
|
425
|
+
throw err;
|
|
388
426
|
} finally {
|
|
389
|
-
|
|
427
|
+
// 어떤 경로로 빠져나가든 기록을 남긴다 (#561) — 중간 취소·예외 포함.
|
|
428
|
+
// finalize는 멱등이라 정상 경로에서 이미 호출됐으면 여기서는 아무 일도 하지 않는다.
|
|
429
|
+
trace.finalize({ targetRoot: cwd, fromVersion: traceFrom, toVersion: traceTo, now: clock?.now || "" });
|
|
430
|
+
disarmSignals();
|
|
390
431
|
remove(tempDir);
|
|
391
432
|
}
|
|
392
433
|
}
|
|
@@ -35,10 +35,20 @@ export function runWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
35
35
|
|
|
36
36
|
// 설치 후 검증 (#549) — 워크플로우만 설치하는 모드라 오히려 더 필요하다.
|
|
37
37
|
const verification = verifyInstall(targetRoot);
|
|
38
|
-
|
|
38
|
+
// detail 키 이름 주의: run-trace의 민감값 가드가 pat|token|secret|password|credential을
|
|
39
|
+
// 키에서 걸러낸다(#494). 여기서 다루는 값은 비밀이 아니라 "치환 플레이스홀더 이름"과
|
|
40
|
+
// "등록이 필요한 키 이름"이라 가드에 걸리지 않는 이름을 쓴다 — 가드 자체는 우회하지 않는다.
|
|
41
|
+
hooks.trace?.event("verify", "scan", "", {
|
|
39
42
|
unresolved: verification.unresolved.length,
|
|
40
|
-
|
|
43
|
+
requiredKeys: verification.secrets.size,
|
|
41
44
|
});
|
|
45
|
+
// 미치환 값은 배포 시점에 실패할 자리다 — 어느 파일 몇 번째 줄인지 로그에 남긴다.
|
|
46
|
+
for (const u of verification.unresolved) {
|
|
47
|
+
hooks.trace?.event("verify", "unresolved", u.filename, { line: u.line, placeholder: u.token });
|
|
48
|
+
}
|
|
49
|
+
for (const [name, users] of verification.secrets) {
|
|
50
|
+
hooks.trace?.event("verify", "required-key", name, { workflows: users });
|
|
51
|
+
}
|
|
42
52
|
|
|
43
53
|
return { workflows: wf, verification };
|
|
44
54
|
}
|
|
@@ -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
|
+
}
|