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
|
@@ -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
|
|
|
@@ -75,33 +87,59 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
75
87
|
// values/useDefaults는 치환 경로에서만 의미 (isUnchanged는 내부에서 useDefaults:true 강제 — 가상 비교 무손상)
|
|
76
88
|
const envOptsFor = (type) => ({ type, projectPath: paths.get(type) || ".", repoName, resolvers, values: envValues, useDefaults: envUseDefaults, branches });
|
|
77
89
|
|
|
78
|
-
// (1) common —
|
|
90
|
+
// (1) common — 타입별과 동일한 판정·보호를 받는다 (#560).
|
|
91
|
+
//
|
|
92
|
+
// 종전에는 "unchanged면 스킵, 아니면 무조건 덮어쓰기"였다. 그런데 릴리스 파이프라인처럼
|
|
93
|
+
// 프로젝트마다 뒤에 붙일 일이 다른 워크플로우는 common에 있어도 사용자가 고쳐 쓸 수밖에
|
|
94
|
+
// 없다. 고치지 않고는 쓸 수 없는 파일을, 고치면 백업도 없이 날아가는 규칙으로 관리하고
|
|
95
|
+
// 있었다. common/deploy조차 .bak을 남기는데 본체만 아무것도 남기지 않았다.
|
|
79
96
|
const commonDir = join(projectTypesDir, "common");
|
|
80
97
|
if (exists(commonDir)) {
|
|
98
|
+
const commonEnv = envOptsFor("common");
|
|
99
|
+
const commonClass = classify(commonDir, workflowsDir, commonEnv, baseline);
|
|
81
100
|
for (const filename of listYamlFiles(commonDir)) {
|
|
82
101
|
// #491 — util 동기화 워크플로우는 util 모듈이 있(게 되)는 레포에만 복사
|
|
83
102
|
if (filename === UTIL_VERSION_SYNC && !utilSyncApplies(tempDir, targetRoot, types)) {
|
|
84
103
|
trace?.event("copy", "excluded", filename, { reason: "util-modules-absent" });
|
|
85
104
|
continue;
|
|
86
105
|
}
|
|
87
|
-
|
|
88
|
-
const dst = join(workflowsDir, filename);
|
|
89
|
-
if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOptsFor("common"))) {
|
|
106
|
+
if (commonClass.unchanged.includes(filename)) {
|
|
90
107
|
counters.skipped++;
|
|
91
108
|
trace?.event("copy", "skipped-unchanged", filename, { group: "common" });
|
|
92
109
|
continue;
|
|
93
110
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
111
|
+
// 사용자가 손댄 적 없고 템플릿만 바뀐 파일 — 물어볼 것 없이 최신으로 올린다(종전과 동일).
|
|
112
|
+
if (commonClass.newFiles.includes(filename) || commonClass.upstream.includes(filename)) {
|
|
113
|
+
copyFileSync(join(commonDir, filename), join(workflowsDir, filename));
|
|
114
|
+
counters.copied++;
|
|
115
|
+
counters.copiedFiles.push(filename);
|
|
116
|
+
trace?.event("copy", commonClass.upstream.includes(filename) ? "upstream-updated" : "copied",
|
|
117
|
+
filename, { group: "common" });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
// 여기부터는 changed — "지금 템플릿 렌더 결과 ≠ 설치본"이다. 둘로 갈린다.
|
|
121
|
+
const dst = join(workflowsDir, filename);
|
|
122
|
+
const modified = isUserModified(baseline, filename, readFileSync(dst, "utf8"));
|
|
123
|
+
if (modified === null) {
|
|
124
|
+
// 판정 불가(기준점 없음 = 기존 통합 레포). common의 종전 계약은 "항상 최신으로 갱신"이라
|
|
125
|
+
// 여기서 skip하면 기존 레포가 업데이트를 영영 못 받는다. 계약은 지키되 되돌릴 수단을
|
|
126
|
+
// 남긴다 — 덮어쓰기 전 .bak. (#560: common/deploy조차 .bak을 남기는데 본체만 없었다)
|
|
127
|
+
renameSync(dst, dst + ".bak");
|
|
128
|
+
copyFileSync(join(commonDir, filename), dst);
|
|
129
|
+
counters.copied++;
|
|
130
|
+
counters.copiedFiles.push(filename);
|
|
131
|
+
trace?.event("copy", "replaced-bak", filename, { group: "common", reason: "baseline-absent" });
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
// 사용자가 손댄 것이 확인된 파일 — 결정에 따라 처리(미지정이면 유지).
|
|
135
|
+
applyDecision(decisions.get(filename), commonDir, workflowsDir, filename, counters, trace);
|
|
98
136
|
}
|
|
99
137
|
}
|
|
100
138
|
|
|
101
139
|
// (2~4) 타입별
|
|
102
140
|
for (const type of types) {
|
|
103
141
|
const asks = new Map();
|
|
104
|
-
copyWorkflowsForType(type, projectTypesDir, workflowsDir, { deployTarget, publishTargets, ...context, envOptsFor, collectAsks: asks, decisions, trace }, counters);
|
|
142
|
+
copyWorkflowsForType(type, projectTypesDir, workflowsDir, { deployTarget, publishTargets, ...context, envOptsFor, collectAsks: asks, decisions, trace, baseline }, counters);
|
|
105
143
|
if (asks.size) deployValues.set(type, asks);
|
|
106
144
|
}
|
|
107
145
|
|
|
@@ -155,9 +193,39 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
155
193
|
}
|
|
156
194
|
}
|
|
157
195
|
|
|
196
|
+
// (7) 기준점 기록 (#557) — 다음 업데이트가 "누가 바꿨는지"를 가릴 근거.
|
|
197
|
+
// 이번에 실제로 쓴 파일만 installed를 갱신한다. 유지(skip)한 파일에 우리가 쓴 것처럼
|
|
198
|
+
// 기록하면 다음 업데이트에서 사용자 수정이 조용히 덮인다.
|
|
199
|
+
recordBaseline(workflowsDir, targetRoot, counters, baseline, context.templateVersion, context.now, trace);
|
|
200
|
+
|
|
158
201
|
return counters;
|
|
159
202
|
}
|
|
160
203
|
|
|
204
|
+
// 설치 직후의 디스크 내용을 기준점으로 남긴다. 실패해도 통합을 막지 않는다 —
|
|
205
|
+
// 기준점이 없으면 다음 업데이트가 종전 2-way 판정으로 폴백할 뿐이다.
|
|
206
|
+
function recordBaseline(workflowsDir, targetRoot, counters, previous, templateVersion, now, trace = null) {
|
|
207
|
+
try {
|
|
208
|
+
const entries = new Map();
|
|
209
|
+
for (const f of counters.copiedFiles || []) {
|
|
210
|
+
const p = join(workflowsDir, f);
|
|
211
|
+
if (!existsSync(p)) continue;
|
|
212
|
+
const content = readFileSync(p, "utf8");
|
|
213
|
+
// 치환까지 끝난 최종 디스크 내용이 곧 우리가 쓴 것이자, 이 시점의 렌더 결과다.
|
|
214
|
+
entries.set(f, { installed: sha256(content), rendered: sha256(content) });
|
|
215
|
+
}
|
|
216
|
+
if (entries.size === 0 && previous) return; // 새로 쓴 게 없으면 기존 기준점을 건드리지 않는다
|
|
217
|
+
trace?.event("baseline", "recorded", "", { files: entries.size, hadPrevious: !!previous });
|
|
218
|
+
writeBaseline(targetRoot, {
|
|
219
|
+
templateVersion: templateVersion || "unknown",
|
|
220
|
+
installedAt: now || "",
|
|
221
|
+
entries,
|
|
222
|
+
previous,
|
|
223
|
+
});
|
|
224
|
+
} catch {
|
|
225
|
+
// 기준점 기록 실패는 통합 실패가 아니다
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
161
229
|
// changed(기존에 있고 내용이 바뀐) 파일 1개를 결정에 따라 처리 (.sh 3440~3508 3지선 case 등가).
|
|
162
230
|
// 'skip'(기본): 기존 유지. 'backup': 기존→.bak 후 교체. 'template': 기존 유지 + 새 버전을 .template.yaml로.
|
|
163
231
|
function applyDecision(decision, srcDir, workflowsDir, filename, counters, trace = null) {
|
|
@@ -189,18 +257,34 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters, trace
|
|
|
189
257
|
export function listWorkflowConflicts(context, tempDir, targetRoot = ".") {
|
|
190
258
|
const { types = [], paths = new Map(), deployTarget = "docker-ssh", repoName = "", resolvers = {}, branch = "", deployBranch = "" } = context;
|
|
191
259
|
const workflowsDir = join(targetRoot, PATHS.workflowsDir);
|
|
260
|
+
// 설치 시점 기준점(#557) — 없으면 null이고 classify가 종전 2-way로 폴백한다.
|
|
261
|
+
const baseline = readBaseline(targetRoot);
|
|
192
262
|
const projectTypesDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
|
|
193
|
-
const conflicts = []; // [{ filename, type }] — 엔진 처리 순서와 동일 (타입 순회 →
|
|
263
|
+
const conflicts = []; // [{ filename, type }] — 엔진 처리 순서와 동일 (common → 타입 순회 → server-deploy)
|
|
194
264
|
const branches = { defaultBranch: branch || "main", deployBranch: deployBranch || "develop" }; // #477 — 엔진과 동일 기준
|
|
265
|
+
|
|
266
|
+
// common (#560) — 사용자가 손댄 것이 "확인된" 파일만 질문 대상이다.
|
|
267
|
+
// 기준점이 없어 판정 불가인 파일은 엔진이 .bak을 남기고 덮어쓰므로 질문하지 않는다.
|
|
268
|
+
const commonDir = join(projectTypesDir, "common");
|
|
269
|
+
if (exists(commonDir)) {
|
|
270
|
+
const commonEnv = { type: "common", projectPath: ".", repoName, resolvers, branches };
|
|
271
|
+
for (const f of classify(commonDir, workflowsDir, commonEnv, baseline).changed) {
|
|
272
|
+
const dst = join(workflowsDir, f);
|
|
273
|
+
if (!existsSync(dst)) continue;
|
|
274
|
+
if (isUserModified(baseline, f, readFileSync(dst, "utf8")) !== true) continue;
|
|
275
|
+
conflicts.push({ filename: f, type: "common" });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
195
279
|
for (const type of types) {
|
|
196
280
|
const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers, branches };
|
|
197
281
|
const typeDir = join(projectTypesDir, type);
|
|
198
282
|
if (exists(typeDir)) {
|
|
199
|
-
for (const f of classify(typeDir, workflowsDir, envOpts).changed) conflicts.push({ filename: f, type });
|
|
283
|
+
for (const f of classify(typeDir, workflowsDir, envOpts, baseline).changed) conflicts.push({ filename: f, type });
|
|
200
284
|
}
|
|
201
285
|
const serverDeployDir = join(typeDir, "server-deploy");
|
|
202
286
|
if (exists(serverDeployDir) && (deployTarget || "docker-ssh") === "docker-ssh") {
|
|
203
|
-
for (const f of classify(serverDeployDir, workflowsDir, envOpts).changed) conflicts.push({ filename: f, type });
|
|
287
|
+
for (const f of classify(serverDeployDir, workflowsDir, envOpts, baseline).changed) conflicts.push({ filename: f, type });
|
|
204
288
|
}
|
|
205
289
|
}
|
|
206
290
|
return conflicts;
|
|
@@ -224,17 +308,19 @@ export async function copyWorkflowsInteractive(context, tempDir, targetRoot = ".
|
|
|
224
308
|
const PUBLISH_TARGETS = ["nexus", "npm", "github-packages"];
|
|
225
309
|
|
|
226
310
|
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;
|
|
311
|
+
const { deployTarget = "docker-ssh", publishTargets = [], force = false, paths = new Map(), repoName = "", resolvers = {}, envOptsFor, collectAsks = null, decisions = new Map(), trace = null, baseline = null } = ctx;
|
|
228
312
|
const typeDir = join(projectTypesDir, type);
|
|
229
313
|
const envOpts = envOptsFor(type);
|
|
230
314
|
let unchangedNames = [];
|
|
231
315
|
|
|
232
316
|
// 타입별 워크플로우 (직하위)
|
|
233
317
|
if (exists(typeDir)) {
|
|
234
|
-
const { newFiles, unchanged, changed } = classify(typeDir, workflowsDir, envOpts);
|
|
318
|
+
const { newFiles, unchanged, changed, upstream } = classify(typeDir, workflowsDir, envOpts, baseline);
|
|
235
319
|
unchangedNames = unchanged.slice();
|
|
236
320
|
for (const f of unchanged) { counters.skipped++; trace?.event("copy", "skipped-unchanged", f, { group: type }); }
|
|
237
321
|
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 }); }
|
|
322
|
+
// upstream(#557): 사용자가 손대지 않았고 템플릿만 바뀐 파일 — 물어볼 것 없이 최신으로 올린다.
|
|
323
|
+
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, reason: "baseline-match" }); }
|
|
238
324
|
// changed: 결정 Map에 따라 처리 (미지정=skip → 현행 force 동작과 동일)
|
|
239
325
|
for (const f of changed) applyDecision(decisions.get(f), typeDir, workflowsDir, f, counters, trace);
|
|
240
326
|
}
|
|
@@ -242,9 +328,10 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
242
328
|
// server-deploy — deploy=docker-ssh일 때만 포함 (#439)
|
|
243
329
|
const serverDeployDir = join(typeDir, "server-deploy");
|
|
244
330
|
if (exists(serverDeployDir) && (deployTarget || "docker-ssh") === "docker-ssh") {
|
|
245
|
-
const { newFiles, unchanged, changed } = classify(serverDeployDir, workflowsDir, envOpts);
|
|
331
|
+
const { newFiles, unchanged, changed, upstream } = classify(serverDeployDir, workflowsDir, envOpts, baseline);
|
|
246
332
|
for (const f of unchanged) { counters.skipped++; trace?.event("copy", "skipped-unchanged", f, { group: `${type}/server-deploy` }); }
|
|
247
333
|
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` }); }
|
|
334
|
+
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`, reason: "baseline-match" }); }
|
|
248
335
|
for (const f of changed) applyDecision(decisions.get(f), serverDeployDir, workflowsDir, f, counters, trace);
|
|
249
336
|
}
|
|
250
337
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// 마이그레이션 가이드 (#493) — Layer 1 큐레이션 문서.
|
|
2
|
-
// 마법사(full/workflows) 실행이 끝나면 대상 레포의
|
|
2
|
+
// 마법사(full/workflows) 실행이 끝나면 대상 레포의 .github/.projectops/logs/PROJECTOPS-MIGRATION-GUIDE.md에
|
|
3
3
|
// "고정 헤더(최초 1회) + 실행 엔트리(append-only)"를 남긴다. 사람용 동적 체크리스트와
|
|
4
4
|
// AI Agent용 yaml 메타데이터를 한 엔트리에 담고, run-trace(#494)의 events를 단일 소스로 소비한다.
|
|
5
5
|
import { join } from "node:path";
|
|
@@ -156,7 +156,7 @@ export function renderGuideEntry(report) {
|
|
|
156
156
|
L.push(`template: { from: ${yq(from)}, to: ${yq(to)} }`);
|
|
157
157
|
L.push(`mode: ${r.mode || "full"}`);
|
|
158
158
|
L.push(`types: ${ylist(r.types)}`);
|
|
159
|
-
L.push(`options: { deploy: ${yq(r.options?.deploy ?? "")}, publish: ${ylist(r.options?.publish)}, secret_backup: ${r.options?.secretBackup === true}, coderabbit: ${r.options?.coderabbit === true}, changelog_provider: ${yq(r.options?.changelogProvider ?? "")}, intent: ${yq(r.options?.intent ?? "")}, semver_auto: ${r.options?.semverAuto === true} }`);
|
|
159
|
+
L.push(`options: { deploy: ${yq(r.options?.deploy ?? "")}, publish: ${ylist(r.options?.publish)}, secret_backup: ${r.options?.secretBackup === true}, coderabbit: ${r.options?.coderabbit === true}, changelog_provider: ${yq(r.options?.changelogProvider ?? "")}, intent: ${yq(r.options?.intent ?? "")}, semver_auto: ${r.options?.semverAuto === true}, app_release: ${r.options?.appRelease === true} }`);
|
|
160
160
|
L.push(`branches: { default: ${yq(r.branches?.defaultBranch ?? "main")}, deploy: ${yq(r.branches?.deployBranch ?? "develop")}, deploy_branch_created: ${r.branches?.created === true} }`);
|
|
161
161
|
L.push("workflows:");
|
|
162
162
|
L.push(` added: ${ylist(wf.added)}`);
|
package/src/core/run-trace.js
CHANGED
|
@@ -5,7 +5,18 @@
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { writeText } from "./fsutil.js";
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// 진단 로그 위치 (#561). docs/ 아래(추적 대상)에서 옮겼다 — 이 기록은 사람이 읽는 문서가
|
|
9
|
+
// 아니라 Agent가 "지난 실행에서 무슨 일이 있었나"를 확인하는 자료다.
|
|
10
|
+
// 폴더 안에 .gitignore를 함께 써서 폴더가 자기 규칙을 들고 다닌다(루트 .gitignore 무수정).
|
|
11
|
+
// 형제인 .github/.projectops/baseline.json은 팀원 공유 자산이라 계속 추적된다.
|
|
12
|
+
export const MIGRATION_DIR = ".github/.projectops/logs";
|
|
13
|
+
export const LOGS_GITIGNORE = [
|
|
14
|
+
"# projectops 실행 진단 로그 — 저장소에 추적하지 않습니다.",
|
|
15
|
+
"# 이 폴더는 마법사가 실행할 때마다 기록을 남기며, 커밋 대상이 아닙니다.",
|
|
16
|
+
"*",
|
|
17
|
+
"!.gitignore",
|
|
18
|
+
"",
|
|
19
|
+
].join("\n");
|
|
9
20
|
export const TRACE_SCHEMA = 1;
|
|
10
21
|
|
|
11
22
|
// 민감값 가드 — PAT·토큰·시크릿·비밀번호는 어떤 이벤트에도 남기지 않는다 (#494 안전 규칙).
|
|
@@ -20,6 +31,60 @@ export function scrubDetail(detail) {
|
|
|
20
31
|
return out;
|
|
21
32
|
}
|
|
22
33
|
|
|
34
|
+
// 단계 소요 시간. 시계를 고정 주입한 경우(테스트)는 생략해 결과가 흔들리지 않게 한다.
|
|
35
|
+
function elapsed(t0, clockIso) {
|
|
36
|
+
return clockIso ? {} : { ms: Date.now() - t0 };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 이벤트를 서버 로그 스타일 한 줄로 만든다 (#561).
|
|
40
|
+
// [시각] 레벨 phase/action 대상 key=value ...
|
|
41
|
+
// 레벨은 action에서 유추한다 — 조치가 필요한 것(미치환·취소·실패)만 눈에 띄어야 한다.
|
|
42
|
+
// 레벨은 "무엇을 먼저 봐야 하는가"로 나눈다.
|
|
43
|
+
// ERROR — 실패. 반드시 조치해야 한다.
|
|
44
|
+
// WARN — 그대로 두면 나중에 실패할 수 있는 것, 사용자가 판단해야 하는 것.
|
|
45
|
+
// INFO — 실행의 큰 줄기. 단계 경계와 확정된 판단.
|
|
46
|
+
// DEBUG — 건별 상세(파일 하나, 치환 하나). 기본값은 INFO지만 전부 파일에는 남는다.
|
|
47
|
+
//
|
|
48
|
+
// DEBUG가 파일에 남는 이유: 문제가 터진 뒤에 "그때 DEBUG를 켰더라면"은 소용이 없다.
|
|
49
|
+
// 화면은 조용히 두되 파일에는 전부 적는다.
|
|
50
|
+
const ERROR_ACTIONS = new Set(["error", "failed"]);
|
|
51
|
+
const WARN_ACTIONS = new Set([
|
|
52
|
+
"unresolved", "cancelled", "skipped-conflict", "leftover-old-gen",
|
|
53
|
+
"neutralized", "detected", "replaced-bak",
|
|
54
|
+
]);
|
|
55
|
+
const DEBUG_ACTIONS = new Set([
|
|
56
|
+
"copied", "skipped-unchanged", "excluded", "substituted", "branch-substituted",
|
|
57
|
+
"required-key", "project-path", "upstream-updated", "template-added",
|
|
58
|
+
"scripts", "config", "util", "templates",
|
|
59
|
+
]);
|
|
60
|
+
export function levelOf(action) {
|
|
61
|
+
if (ERROR_ACTIONS.has(action)) return "ERROR";
|
|
62
|
+
if (WARN_ACTIONS.has(action)) return "WARN";
|
|
63
|
+
if (DEBUG_ACTIONS.has(action)) return "DEBUG";
|
|
64
|
+
return "INFO";
|
|
65
|
+
}
|
|
66
|
+
export function formatLogLine(e) {
|
|
67
|
+
const time = String(e.ts || "").replace(/^.*T/, "").replace(/Z$/, "");
|
|
68
|
+
const level = levelOf(e.action).padEnd(5);
|
|
69
|
+
const tag = `${e.phase}/${e.action}`.padEnd(24);
|
|
70
|
+
const parts = [`[${time}] ${level} ${tag} ${e.target || ""}`.trimEnd()];
|
|
71
|
+
if (e.detail && typeof e.detail === "object") {
|
|
72
|
+
const kv = Object.entries(e.detail)
|
|
73
|
+
.filter(([, v]) => v !== null && v !== undefined)
|
|
74
|
+
.map(([k, v]) => `${k}=${Array.isArray(v) ? (v.join("|") || "[]") : v}`);
|
|
75
|
+
if (kv.length) parts.push(` ${kv.join(" ")}`);
|
|
76
|
+
}
|
|
77
|
+
return parts.join("") + "\n";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 색상·커서 제어 시퀀스 제거 (#561). 로그 파일은 에디터·Agent가 읽으므로 이스케이프가
|
|
81
|
+
// 그대로 남으면 판독을 방해한다. 터미널 출력 자체는 건드리지 않는다(사본만 정제).
|
|
82
|
+
// eslint-disable-next-line no-control-regex
|
|
83
|
+
const ANSI_RE = /\u001b\[[0-9;]*[A-Za-z]/g;
|
|
84
|
+
export function stripAnsi(text) {
|
|
85
|
+
return String(text).replace(ANSI_RE, "");
|
|
86
|
+
}
|
|
87
|
+
|
|
23
88
|
// now("YYYY-MM-DD HH:MM:SS") → 파일명 스탬프 "YYYYMMDD_HHMMSS". 형식이 아니면 "run" 폴백(테스트 주입 clock 안전).
|
|
24
89
|
export function stampFromNow(now) {
|
|
25
90
|
const digits = String(now ?? "").replace(/[^0-9]/g, "");
|
|
@@ -32,6 +97,8 @@ export function createRunTrace({ clockIso = null } = {}) {
|
|
|
32
97
|
const events = [];
|
|
33
98
|
const lines = [];
|
|
34
99
|
let restore = null;
|
|
100
|
+
let finalized = false;
|
|
101
|
+
let signalsArmed = false;
|
|
35
102
|
|
|
36
103
|
const nowIso = () => clockIso ?? new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
|
37
104
|
|
|
@@ -40,11 +107,14 @@ export function createRunTrace({ clockIso = null } = {}) {
|
|
|
40
107
|
lines,
|
|
41
108
|
|
|
42
109
|
// 이벤트 1건 기록. detail은 민감키 스크럽 후 저장.
|
|
110
|
+
// 같은 내용을 사람이 읽는 로그 라인으로도 남긴다 (#561) — 터미널에 보이지 않는 내부
|
|
111
|
+
// 동작까지 .log 한 파일에서 시간순으로 따라갈 수 있어야, 문제가 생겼을 때 바로 대응된다.
|
|
43
112
|
event(phase, action, target = "", detail = null) {
|
|
44
113
|
const e = { ts: nowIso(), phase, action, target };
|
|
45
114
|
const d = scrubDetail(detail);
|
|
46
115
|
if (d != null && (typeof d !== "object" || Object.keys(d).length > 0)) e.detail = d;
|
|
47
116
|
events.push(e);
|
|
117
|
+
lines.push(formatLogLine(e));
|
|
48
118
|
return e;
|
|
49
119
|
},
|
|
50
120
|
|
|
@@ -55,7 +125,10 @@ export function createRunTrace({ clockIso = null } = {}) {
|
|
|
55
125
|
const so = process.stdout.write; // 원본 참조 보관 — 복원 시 identity 유지
|
|
56
126
|
const se = process.stderr.write;
|
|
57
127
|
const capture = (chunk) => {
|
|
58
|
-
try {
|
|
128
|
+
try {
|
|
129
|
+
const s = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
130
|
+
lines.push(stripAnsi(s));
|
|
131
|
+
} catch { /* 미러 실패는 실행에 영향 없음 */ }
|
|
59
132
|
};
|
|
60
133
|
process.stdout.write = function (chunk, ...rest) { capture(chunk); return so.apply(process.stdout, [chunk, ...rest]); };
|
|
61
134
|
process.stderr.write = function (chunk, ...rest) { capture(chunk); return se.apply(process.stderr, [chunk, ...rest]); };
|
|
@@ -66,24 +139,106 @@ export function createRunTrace({ clockIso = null } = {}) {
|
|
|
66
139
|
if (restore) { restore(); restore = null; }
|
|
67
140
|
},
|
|
68
141
|
|
|
69
|
-
//
|
|
142
|
+
// 단계 실행 래퍼 (#561) — 서버 로그처럼 "어디에 들어갔다 언제 나왔고 얼마 걸렸는지"를
|
|
143
|
+
// 자동으로 남긴다. 개별 호출부에 start/done을 흩뿌리면 빠뜨리는 자리가 생긴다.
|
|
144
|
+
// 예외가 나면 failed로 기록하고 그대로 던진다 — 삼키지 않는다.
|
|
145
|
+
step(name, fn, detail = null) {
|
|
146
|
+
this.event("step", "start", name, detail);
|
|
147
|
+
const t0 = Date.now();
|
|
148
|
+
try {
|
|
149
|
+
const out = fn();
|
|
150
|
+
this.event("step", "done", name, elapsed(t0, clockIso));
|
|
151
|
+
return out;
|
|
152
|
+
} catch (err) {
|
|
153
|
+
this.event("step", "failed", name, { ...elapsed(t0, clockIso), message: err?.message || String(err) });
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
// async 버전 — 대화형 단계(질문 대기 포함)에 쓴다.
|
|
159
|
+
async stepAsync(name, fn, detail = null) {
|
|
160
|
+
this.event("step", "start", name, detail);
|
|
161
|
+
const t0 = Date.now();
|
|
162
|
+
try {
|
|
163
|
+
const out = await fn();
|
|
164
|
+
this.event("step", "done", name, elapsed(t0, clockIso));
|
|
165
|
+
return out;
|
|
166
|
+
} catch (err) {
|
|
167
|
+
this.event("step", "failed", name, { ...elapsed(t0, clockIso), message: err?.message || String(err) });
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
// 강제 종료(Ctrl+C)에도 기록을 남긴다 (#561).
|
|
173
|
+
// SIGINT 기본 동작으로 프로세스가 즉사하면 finally가 돌지 않는다 — 사용자가 끊었을 때야말로
|
|
174
|
+
// "어디까지 갔는지"가 가장 궁금한 순간이라, 여기서 놓치면 로그의 쓸모가 절반이다.
|
|
175
|
+
// 핸들러는 기록만 하고 원래대로 종료시킨다(동작을 바꾸지 않는다).
|
|
176
|
+
armSignals(opts = {}) {
|
|
177
|
+
if (signalsArmed) return () => {};
|
|
178
|
+
signalsArmed = true;
|
|
179
|
+
const self = this;
|
|
180
|
+
const handlers = [];
|
|
181
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
182
|
+
const h = () => {
|
|
183
|
+
self.event("run", "cancelled", sig, { reason: "사용자 강제 종료(신호 수신)" });
|
|
184
|
+
self.finalize(opts);
|
|
185
|
+
process.removeListener(sig, h);
|
|
186
|
+
process.kill(process.pid, sig); // 원래 종료 동작으로 넘긴다
|
|
187
|
+
};
|
|
188
|
+
process.on(sig, h);
|
|
189
|
+
handlers.push([sig, h]);
|
|
190
|
+
}
|
|
191
|
+
return () => {
|
|
192
|
+
for (const [sig, h] of handlers) process.removeListener(sig, h);
|
|
193
|
+
signalsArmed = false;
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
// 어떤 경로로 끝나든 기록을 남긴다 (#561) — 정상 완주·중간 취소·예외·강제 종료.
|
|
198
|
+
// 사용자가 중간에 끊었을 때야말로 "어디까지 갔는지"가 가장 궁금한 순간이라,
|
|
199
|
+
// 완주했을 때만 남기는 기록은 쓸모가 절반이다.
|
|
200
|
+
//
|
|
201
|
+
// 두 번 불려도 한 번만 쓴다(정상 경로 + finally 중복 호출 대비). 실패는 삼킨다 —
|
|
202
|
+
// 기록 실패가 종료를 막아선 안 된다.
|
|
203
|
+
finalize(opts = {}) {
|
|
204
|
+
if (finalized) return null;
|
|
205
|
+
finalized = true;
|
|
206
|
+
try {
|
|
207
|
+
this.mirrorStop();
|
|
208
|
+
return this.write(opts);
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
// 기록 파일 경로만 계산한다 (쓰지 않음). 완료 화면까지 캡처하려면 write를 화면 출력 뒤로
|
|
215
|
+
// 미뤄야 하는데, 가이드 엔트리는 그 전에 traceFile 경로를 참조해야 해서 둘을 분리했다.
|
|
216
|
+
paths({ fromVersion = "", toVersion = "", now = "" } = {}) {
|
|
217
|
+
const stamp = stampFromNow(now);
|
|
218
|
+
const from = String(fromVersion || "new").replace(/[^0-9a-zA-Z.-]/g, "");
|
|
219
|
+
const to = String(toVersion || "unknown").replace(/[^0-9a-zA-Z.-]/g, "");
|
|
220
|
+
const base = `${stamp}_v${from}_to_v${to}`;
|
|
221
|
+
return { base, traceFile: `${MIGRATION_DIR}/${base}.jsonl`, logFile: `${MIGRATION_DIR}/${base}.log` };
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
// Layer 2/3 파일 기록 — .github/.projectops/logs/{stamp}_v{from}_to_v{to}.{jsonl,log}
|
|
70
225
|
// 반환: { traceFile, logFile } (targetRoot 기준 상대 경로 — 가이드 메타 포인터용).
|
|
71
226
|
// 이벤트가 0건이면 기록하지 않는다(no-op 실행 오염 방지) — null 반환.
|
|
72
227
|
write({ targetRoot = ".", fromVersion = "", toVersion = "", now = "" } = {}) {
|
|
73
228
|
if (events.length === 0) return null;
|
|
74
|
-
const stamp = stampFromNow(now);
|
|
75
229
|
const from = String(fromVersion || "new").replace(/[^0-9a-zA-Z.-]/g, "");
|
|
76
230
|
const to = String(toVersion || "unknown").replace(/[^0-9a-zA-Z.-]/g, "");
|
|
77
|
-
const
|
|
78
|
-
|
|
231
|
+
const planned = this.paths({ fromVersion, toVersion, now });
|
|
232
|
+
// 폴더 규칙을 매번 보장한다 — 사용자가 지웠거나 폴더가 새로 생겨도 추적되지 않게.
|
|
233
|
+
writeText(join(targetRoot, `${MIGRATION_DIR}/.gitignore`), LOGS_GITIGNORE);
|
|
79
234
|
const header = JSON.stringify({ schema: TRACE_SCHEMA, kind: "projectops-migration-trace", from, to, started: events[0]?.ts ?? "" });
|
|
80
|
-
writeText(join(targetRoot, traceFile), [header, ...events.map((e) => JSON.stringify(e))].join("\n") + "\n");
|
|
235
|
+
writeText(join(targetRoot, planned.traceFile), [header, ...events.map((e) => JSON.stringify(e))].join("\n") + "\n");
|
|
81
236
|
let logFile = null;
|
|
82
237
|
if (lines.length > 0) {
|
|
83
|
-
logFile =
|
|
238
|
+
logFile = planned.logFile;
|
|
84
239
|
writeText(join(targetRoot, logFile), lines.join(""));
|
|
85
240
|
}
|
|
86
|
-
return { traceFile, logFile };
|
|
241
|
+
return { traceFile: planned.traceFile, logFile };
|
|
87
242
|
},
|
|
88
243
|
};
|
|
89
244
|
}
|