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.
@@ -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
- // 3분류 (신규/unchanged/changed) — 대상 워크플로우 디렉토리 기준.
38
- function classify(srcDir, workflowsDir, envOpts) {
39
- const result = { newFiles: [], unchanged: [], changed: [] };
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 — unchanged면 스킵, 아니면 무조건 덮어쓰기
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
- const src = join(commonDir, filename);
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
- copyFileSync(src, dst);
95
- counters.copied++;
96
- counters.copiedFiles.push(filename);
97
- trace?.event("copy", "copied", filename, { group: "common" });
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 }] — 엔진 처리 순서와 동일 (타입 순회 → 직하위 → server-deploy)
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) 실행이 끝나면 대상 레포의 docs/projectops/migration/PROJECTOPS-MIGRATION-GUIDE.md에
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)}`);
@@ -5,7 +5,18 @@
5
5
  import { join } from "node:path";
6
6
  import { writeText } from "./fsutil.js";
7
7
 
8
- export const MIGRATION_DIR = "docs/projectops/migration";
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 { lines.push(typeof chunk === "string" ? chunk : chunk.toString("utf8")); } catch { /* 미러 실패는 실행에 영향 없음 */ }
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
- // Layer 2/3 파일 기록docs/projectops/migration/{stamp}_v{from}_to_v{to}.{jsonl,log}
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 base = `${stamp}_v${from}_to_v${to}`;
78
- const traceFile = `${MIGRATION_DIR}/${base}.jsonl`;
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 = `${MIGRATION_DIR}/${base}.log`;
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
  }