projectops 4.2.16 → 4.2.17

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 CHANGED
@@ -7,7 +7,7 @@
7
7
  > 이슈 등록부터 커밋, 보고서, 배포까지. 개발자는 코드만 작성하세요.
8
8
 
9
9
  <!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
10
- ## 최신 버전 : v4.2.15 (2026-07-13)
10
+ ## 최신 버전 : v4.2.16 (2026-07-14)
11
11
 
12
12
  [전체 버전 기록 보기](CHANGELOG.md)
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectops",
3
- "version": "4.2.16",
3
+ "version": "4.2.17",
4
4
  "description": "ProjectOps — 완전 자동화 GitHub 프로젝트 관리 템플릿 통합 CLI",
5
5
  "keywords": [
6
6
  "devops",
@@ -14,6 +14,8 @@ 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 } from "../core/options-ask.js";
17
+ import { createRunTrace } from "../core/run-trace.js";
18
+ import { appendGuideEntry } from "../core/migration-guide.js";
17
19
  import { promptEnvPlan } from "../ui/env-plan.js";
18
20
  import { listWorkflowConflicts } from "../core/copy/workflows.js";
19
21
  import { createContext, VALID_TYPES } from "../context.js";
@@ -31,6 +33,9 @@ const isCancel = (v) => v === CANCEL || typeof v === "symbol";
31
33
  // skills = runSkills 주입 지점(테스트가 실제 IDE CLI를 안 건드리게). 기본은 실제 runSkills.
32
34
  export async function runInteractive(baseCtx, { cwd = process.cwd(), source = { type: "git" }, clock, io = prompts, skills = runSkills } = {}) {
33
35
  const tempDir = join(cwd, PATHS.tempDir);
36
+ // 실행 트레이스 (#494) — 실제 CLI(io=prompts)에서만 터미널 미러를 켠다 (테스트 스텁 io는 이벤트만).
37
+ const trace = createRunTrace();
38
+ if (io === prompts) trace.mirrorStart();
34
39
  try {
35
40
  // 템플릿 먼저 획득 — 배너에 실제 템플릿 버전을 표시 (.sh는 원격 version.yml fetch L4270~4280 등가)
36
41
  acquireTemplate({ tempDir, source });
@@ -53,9 +58,11 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
53
58
  if (mode === CANCEL || mode == null) { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
54
59
 
55
60
  // Breaking Changes 게이트 (.sh execute_integration L4415~4420 — 모든 모드 공통, 대화형은 확인 질문)
61
+ let breakingReport = null; // #493 — 통과 구간 항목을 가이드에 조치 방법 전문으로 임베드
56
62
  const proceed = await runBreakingCheck({
57
63
  cwd, tempDir, templateVersion,
58
64
  askYesNo: (msg, def) => io.askYesNo(msg, def),
65
+ onItems: (items) => { breakingReport = items; },
59
66
  });
60
67
  if (!proceed) { io.cancelMessage?.("통합을 안전하게 취소했습니다."); return 0; }
61
68
 
@@ -90,6 +97,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
90
97
  let changelogBaseUrl = existing?.options?.changelogBaseUrl ?? "";
91
98
  let deployBranch = existing?.options?.deployBranch ?? "develop"; // #456
92
99
  let deployBranchReady = null; // #490 — 이번 실행에서 개발 브랜치 존재/생성이 확인됐는지
100
+ let deployBranchCreated = null; // #493 — 마법사가 직접 생성했는지 (가이드 기록용)
93
101
  let intent = existing?.options?.intent ?? null; // #485 프로젝트 성격
94
102
  const showOptional = mode === "full" || mode === "workflows";
95
103
  const realTty = process.stdout.isTTY === true;
@@ -125,6 +133,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
125
133
  changelogBaseUrl = r.changelogBaseUrl;
126
134
  deployBranch = r.deployBranch;
127
135
  deployBranchReady = r.deployBranchReady ?? deployBranchReady; // #490
136
+ deployBranchCreated = r.deployBranchCreated ?? deployBranchCreated; // #493
128
137
  intent = r.intent;
129
138
  }
130
139
 
@@ -189,6 +198,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
189
198
  changelogBaseUrl = r.changelogBaseUrl;
190
199
  deployBranch = r.deployBranch;
191
200
  deployBranchReady = r.deployBranchReady ?? deployBranchReady; // #490
201
+ deployBranchCreated = r.deployBranchCreated ?? deployBranchCreated; // #493
192
202
  intent = r.intent;
193
203
  }
194
204
  }
@@ -252,14 +262,18 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
252
262
  }
253
263
 
254
264
  // 레거시 마이그레이션 (#470) — 워크플로우를 만지는 모드에서만. 대화형은 safe 티어 확인 1회.
265
+ let migrationsResult = null; // #493 — 가이드 기록용
255
266
  if (mode === "full" || mode === "workflows") {
256
- await runMigrations({
267
+ migrationsResult = await runMigrations({
257
268
  targetRoot: cwd,
258
269
  askYesNo: (msg, def) => io.askYesNo(msg, def),
259
270
  });
271
+ for (const a of migrationsResult.applied ?? []) trace.event("legacy", a.action === "error" ? "error" : "neutralized", a.from ?? a.id ?? "", { to: a.to ?? "", id: a.id ?? "" });
272
+ for (const e of migrationsResult.confirmPending ?? []) trace.event("legacy", "leftover-old-gen", e.file, { replacement: e.replacedBy ?? "", reason: e.reason ?? "" });
260
273
  }
261
274
 
262
275
  // 고아 타입 워크플로우 정리 (#487) — 타입 변경으로 선택에서 빠진 타입의 잔존 워크플로우
276
+ const orphanReport = { cleaned: [], pending: [] }; // #493 — 가이드 기록용
263
277
  if (mode === "full" || mode === "workflows") {
264
278
  const orphans = detectOrphanWorkflows({ tempDir, targetRoot: cwd, selectedTypes: types });
265
279
  if (orphans.length > 0) {
@@ -272,28 +286,48 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
272
286
  const results = applyOrphanCleanup(cwd, orphans);
273
287
  const ok = results.filter((r) => r.action === "bak");
274
288
  const failed = results.filter((r) => r.action === "error");
289
+ orphanReport.cleaned = ok.map((r) => r.filename);
290
+ for (const r of ok) trace.event("orphan", "neutralized", r.filename);
275
291
  io.note?.(`✅ 고아 워크플로우 정리: ${ok.length}개${failed.length ? ` (실패 ${failed.length}개)` : ""}`, "정리 완료");
292
+ } else {
293
+ orphanReport.pending = orphans.map((o) => o.filename);
276
294
  }
277
295
  }
278
296
  }
279
297
 
280
298
  let result = null;
281
- if (mode === "full") result = runFull(ctx, tempDir, cwd, hooks);
299
+ if (mode === "full") result = runFull(ctx, tempDir, cwd, { ...hooks, trace });
282
300
  else if (mode === "version") result = runVersion(ctx, tempDir, cwd);
283
- else if (mode === "workflows") result = runWorkflows(ctx, tempDir, cwd, hooks);
301
+ else if (mode === "workflows") result = runWorkflows(ctx, tempDir, cwd, { ...hooks, trace });
284
302
 
285
303
  // 통합 후 IDE 스킬 제안 (.sh L4557 offer_ide_tools_install — 사전 질문 게이트, 기본 N)
286
304
  const wantSkills = await io.askYesNo("AI 에이전트 스킬(Claude·Cursor·Gemini·Codex·PI)도 설치/업데이트할까요?", false);
287
305
  if (wantSkills === true) await skills({ templateVersion, tempDir, interactive: true });
288
306
 
307
+ // 마이그레이션 기록 (#493/#494) — Layer 2/3 트레이스 파일 + Layer 1 가이드 엔트리 (full/workflows만)
308
+ let migrationGuidePath = null;
309
+ if (mode === "full" || mode === "workflows") {
310
+ const files = trace.write({ targetRoot: cwd, fromVersion: existing?.templateVersion || "", toVersion: templateVersion, now });
311
+ migrationGuidePath = appendGuideEntry(cwd, {
312
+ now, mode, types, repoName,
313
+ templateFrom: existing?.templateVersion || "", templateTo: templateVersion,
314
+ options: { deploy: deployTarget, publish: publishTargets, secretBackup: includeSecretBackup, coderabbit: codeReviewCoderabbit, changelogProvider, intent },
315
+ branches: { defaultBranch: branch, deployBranch, ready: deployBranchReady, created: deployBranchCreated },
316
+ breaking: breakingReport, migrations: migrationsResult, orphans: orphanReport,
317
+ events: trace.events, counters: { skipped: result?.workflows?.skipped ?? 0 },
318
+ traceFile: files?.traceFile ?? "", logFile: files?.logFile ?? "",
319
+ }).guidePath;
320
+ }
321
+
289
322
  // 완료 요약 (.sh print_summary L5438)
290
323
  io.summary?.({
291
- mode, types, version, deployBranch, deployBranchReady,
324
+ mode, types, version, deployBranch, deployBranchReady, migrationGuidePath,
292
325
  counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
293
326
  }, cwd);
294
327
  io.outro?.(`통합 완료 — ${mode} 모드로 설치했습니다.`);
295
328
  return 0;
296
329
  } finally {
330
+ trace.mirrorStop();
297
331
  remove(tempDir);
298
332
  }
299
333
  }
@@ -27,7 +27,9 @@ export async function loadBreakingJson(tempDir) {
27
27
  // templateVersion - 설치하려는 템플릿 버전 (.sh의 DEFAULT_VERSION 고정 버그를 실버전으로 교정 — 설계 D2)
28
28
  // askYesNo - async(message, defaultYes)→bool. null이면 비대화형: 경고만 출력 후 진행
29
29
  // loader - 테스트 주입용 (기본 loadBreakingJson)
30
- export async function runBreakingCheck({ cwd, tempDir, templateVersion, askYesNo = null, loader = loadBreakingJson }) {
30
+ // onItems - (#493) 통과 구간 항목 콜백 ({current, target, critical, warnings})
31
+ // 마이그레이션 가이드가 조치 방법 전문을 임베드하는 데 쓴다 (표시 여부와 무관하게 호출)
32
+ export async function runBreakingCheck({ cwd, tempDir, templateVersion, askYesNo = null, loader = loadBreakingJson, onItems = null }) {
31
33
  const vy = join(cwd, "version.yml");
32
34
  if (!existsSync(vy)) return true; // 신규 통합 — 비교 기준 없음
33
35
  const { templateVersion: current } = parseExisting(readFileSync(vy, "utf8"));
@@ -37,6 +39,7 @@ export async function runBreakingCheck({ cwd, tempDir, templateVersion, askYesNo
37
39
  if (!json) return true;
38
40
 
39
41
  const { critical, warnings } = collectBreaking(json, current, templateVersion);
42
+ if (critical.length || warnings.length) onItems?.({ current, target: templateVersion, critical, warnings });
40
43
  if (critical.length === 0 && warnings.length === 0) return true;
41
44
 
42
45
  // 요약 리스트 표시 (#473 — 전문 통덤프는 벽글이 되어 정작 CRITICAL이 안 읽혔고,
@@ -11,11 +11,16 @@ import { substituteBranches } from "../branch-sub.js";
11
11
 
12
12
  // 한 파일에 env 치환을 적용해 대상 파일을 갱신 (.sh configure_workflow_env 등가).
13
13
  // values/useDefaults: env 계획(promptEnvPlan) 결과 — 미지정이면 기본값 경로(현행 force 동작).
14
- function configureEnv(targetPath, { type, projectPath = ".", repoName = "", resolvers = {}, collectAsks = null, values = new Map(), useDefaults = true }) {
14
+ // trace(#494): 치환 1건당 env.substituted 이벤트 (before/after 포함 치환 불일치류 버그의 즉시 발견 근거).
15
+ function configureEnv(targetPath, filename, { type, projectPath = ".", repoName = "", resolvers = {}, collectAsks = null, values = new Map(), useDefaults = true, trace = null }) {
15
16
  const content = readFileSync(targetPath, "utf8");
16
17
  if (!content.includes("@wizard")) return;
17
- const out = substituteEnv(content, { type, useDefaults, values, projectPath, repoName, resolvers, collectAsks });
18
+ const collectSubs = trace ? [] : null;
19
+ const out = substituteEnv(content, { type, useDefaults, values, projectPath, repoName, resolvers, collectAsks, collectSubs });
18
20
  writeFileSync(targetPath, out);
21
+ if (trace && collectSubs) {
22
+ for (const s of collectSubs) trace.event("env", "substituted", filename, { type, ...s });
23
+ }
19
24
  }
20
25
 
21
26
  // util 버전 동기화 워크플로우 (#491) — .github/util/ 모듈(version.json)이 있는 레포에서만 의미가 있다.
@@ -57,6 +62,7 @@ function classify(srcDir, workflowsDir, envOpts) {
57
62
  export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
58
63
  const { types = [], paths = new Map(), deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, repoName = "", resolvers = {}, envValues = new Map(), envUseDefaults = true, branch = "", deployBranch = "" } = context;
59
64
  const decisions = hooks.decisions instanceof Map ? hooks.decisions : new Map();
65
+ const trace = hooks.trace ?? null; // #494 — 실행 트레이스 (null-safe: 미주입이면 전 이벤트 no-op)
60
66
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
61
67
  const projectTypesDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
62
68
  if (!exists(projectTypesDir)) throw new Error("템플릿 저장소 구조 오류 — project-types 폴더를 찾지 못했습니다.");
@@ -74,23 +80,28 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
74
80
  if (exists(commonDir)) {
75
81
  for (const filename of listYamlFiles(commonDir)) {
76
82
  // #491 — util 동기화 워크플로우는 util 모듈이 있(게 되)는 레포에만 복사
77
- if (filename === UTIL_VERSION_SYNC && !utilSyncApplies(tempDir, targetRoot, types)) continue;
83
+ if (filename === UTIL_VERSION_SYNC && !utilSyncApplies(tempDir, targetRoot, types)) {
84
+ trace?.event("copy", "excluded", filename, { reason: "util-modules-absent" });
85
+ continue;
86
+ }
78
87
  const src = join(commonDir, filename);
79
88
  const dst = join(workflowsDir, filename);
80
89
  if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOptsFor("common"))) {
81
90
  counters.skipped++;
91
+ trace?.event("copy", "skipped-unchanged", filename, { group: "common" });
82
92
  continue;
83
93
  }
84
94
  copyFileSync(src, dst);
85
95
  counters.copied++;
86
96
  counters.copiedFiles.push(filename);
97
+ trace?.event("copy", "copied", filename, { group: "common" });
87
98
  }
88
99
  }
89
100
 
90
101
  // (2~4) 타입별
91
102
  for (const type of types) {
92
103
  const asks = new Map();
93
- copyWorkflowsForType(type, projectTypesDir, workflowsDir, { deployTarget, publishTargets, ...context, envOptsFor, collectAsks: asks, decisions }, counters);
104
+ copyWorkflowsForType(type, projectTypesDir, workflowsDir, { deployTarget, publishTargets, ...context, envOptsFor, collectAsks: asks, decisions, trace }, counters);
94
105
  if (asks.size) deployValues.set(type, asks);
95
106
  }
96
107
 
@@ -102,13 +113,16 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
102
113
  const dst = join(workflowsDir, filename);
103
114
  if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOptsFor("common"))) {
104
115
  counters.skipped++;
116
+ trace?.event("copy", "skipped-unchanged", filename, { group: "common-deploy" });
105
117
  continue;
106
118
  }
107
- if (existsSync(dst)) renameSync(dst, dst + ".bak");
119
+ const backedUp = existsSync(dst);
120
+ if (backedUp) renameSync(dst, dst + ".bak");
108
121
  copyFileSync(src, dst);
109
122
  counters.optionalCopied++;
110
123
  counters.copied++;
111
124
  counters.copiedFiles.push(filename);
125
+ trace?.event("copy", backedUp ? "replaced-bak" : "copied", filename, { group: "common-deploy" });
112
126
  }
113
127
  }
114
128
 
@@ -122,6 +136,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
122
136
  counters.optionalCopied++;
123
137
  counters.copied++;
124
138
  counters.copiedFiles.push(filename);
139
+ trace?.event("copy", "copied", filename, { group: "secret-backup" });
125
140
  }
126
141
  }
127
142
 
@@ -133,7 +148,10 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
133
148
  if (!existsSync(p)) continue;
134
149
  const before = readFileSync(p, "utf8");
135
150
  const after = substituteBranches(before, branches);
136
- if (after !== before) writeFileSync(p, after);
151
+ if (after !== before) {
152
+ writeFileSync(p, after);
153
+ trace?.event("env", "branch-substituted", f, { defaultBranch: branches.defaultBranch, deployBranch: branches.deployBranch });
154
+ }
137
155
  }
138
156
  }
139
157
 
@@ -142,7 +160,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
142
160
 
143
161
  // changed(기존에 있고 내용이 바뀐) 파일 1개를 결정에 따라 처리 (.sh 3440~3508 3지선 case 등가).
144
162
  // 'skip'(기본): 기존 유지. 'backup': 기존→.bak 후 교체. 'template': 기존 유지 + 새 버전을 .template.yaml로.
145
- function applyDecision(decision, srcDir, workflowsDir, filename, counters) {
163
+ function applyDecision(decision, srcDir, workflowsDir, filename, counters, trace = null) {
146
164
  const src = join(srcDir, filename);
147
165
  const dst = join(workflowsDir, filename);
148
166
  if (decision === "backup") {
@@ -151,6 +169,7 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters) {
151
169
  copyFileSync(src, dst);
152
170
  counters.copied++;
153
171
  counters.copiedFiles?.push(filename);
172
+ trace?.event("copy", "replaced-bak", filename, { decision });
154
173
  return;
155
174
  }
156
175
  if (decision === "template") {
@@ -158,9 +177,11 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters) {
158
177
  const templateName = (filename.endsWith(".yaml") ? filename.slice(0, -".yaml".length) : filename) + ".template.yaml";
159
178
  copyFileSync(src, join(workflowsDir, templateName)); // cp가 기존 .template.yaml 덮어씀(.sh rm -f + cp 등가)
160
179
  counters.templateAdded++;
180
+ trace?.event("copy", "template-added", templateName, { original: filename });
161
181
  return;
162
182
  }
163
183
  counters.skipped++; // 'skip'/미지정/ESC → 기존 유지 (.sh S)·force 기본)
184
+ trace?.event("copy", "skipped-conflict", filename, { decision: decision ?? "skip", note: "사용자 수정본 유지 — 병합 검토 후보" });
164
185
  }
165
186
 
166
187
  // 대상 워크플로우 디렉토리에서 changed(충돌) 파일 목록만 뽑는다 — copyWorkflowsInteractive의 사전 조사용.
@@ -203,7 +224,7 @@ export async function copyWorkflowsInteractive(context, tempDir, targetRoot = ".
203
224
  const PUBLISH_TARGETS = ["nexus", "npm", "github-packages"];
204
225
 
205
226
  function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters) {
206
- const { deployTarget = "docker-ssh", publishTargets = [], force = false, paths = new Map(), repoName = "", resolvers = {}, envOptsFor, collectAsks = null, decisions = new Map() } = ctx;
227
+ const { deployTarget = "docker-ssh", publishTargets = [], force = false, paths = new Map(), repoName = "", resolvers = {}, envOptsFor, collectAsks = null, decisions = new Map(), trace = null } = ctx;
207
228
  const typeDir = join(projectTypesDir, type);
208
229
  const envOpts = envOptsFor(type);
209
230
  let unchangedNames = [];
@@ -212,19 +233,19 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
212
233
  if (exists(typeDir)) {
213
234
  const { newFiles, unchanged, changed } = classify(typeDir, workflowsDir, envOpts);
214
235
  unchangedNames = unchanged.slice();
215
- for (const f of unchanged) counters.skipped++;
216
- for (const f of newFiles) { copyFileSync(join(typeDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); }
236
+ for (const f of unchanged) { counters.skipped++; trace?.event("copy", "skipped-unchanged", f, { group: type }); }
237
+ 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 }); }
217
238
  // changed: 결정 Map에 따라 처리 (미지정=skip → 현행 force 동작과 동일)
218
- for (const f of changed) applyDecision(decisions.get(f), typeDir, workflowsDir, f, counters);
239
+ for (const f of changed) applyDecision(decisions.get(f), typeDir, workflowsDir, f, counters, trace);
219
240
  }
220
241
 
221
242
  // server-deploy — deploy=docker-ssh일 때만 포함 (#439)
222
243
  const serverDeployDir = join(typeDir, "server-deploy");
223
244
  if (exists(serverDeployDir) && (deployTarget || "docker-ssh") === "docker-ssh") {
224
245
  const { newFiles, unchanged, changed } = classify(serverDeployDir, workflowsDir, envOpts);
225
- for (const f of unchanged) counters.skipped++;
226
- for (const f of newFiles) { copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); }
227
- for (const f of changed) applyDecision(decisions.get(f), serverDeployDir, workflowsDir, f, counters);
246
+ for (const f of unchanged) { counters.skipped++; trace?.event("copy", "skipped-unchanged", f, { group: `${type}/server-deploy` }); }
247
+ 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` }); }
248
+ for (const f of changed) applyDecision(decisions.get(f), serverDeployDir, workflowsDir, f, counters, trace);
228
249
  }
229
250
 
230
251
  // publish/<target> (opt-in — #439 publish 축. 타입은 파일 위치일 뿐 게이트가 아니다)
@@ -238,13 +259,16 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
238
259
  const dst = join(workflowsDir, filename);
239
260
  if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOpts)) {
240
261
  counters.skipped++;
262
+ trace?.event("copy", "skipped-unchanged", filename, { group: `${type}/publish/${target}` });
241
263
  continue;
242
264
  }
243
- if (existsSync(dst)) renameSync(dst, dst + ".bak");
265
+ const backedUp = existsSync(dst);
266
+ if (backedUp) renameSync(dst, dst + ".bak");
244
267
  copyFileSync(src, dst);
245
268
  counters.optionalCopied++;
246
269
  counters.copied++;
247
270
  counters.copiedFiles.push(filename);
271
+ trace?.event("copy", backedUp ? "replaced-bak" : "copied", filename, { group: `${type}/publish/${target}` });
248
272
  }
249
273
  }
250
274
 
@@ -255,7 +279,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
255
279
  const target = join(workflowsDir, filename);
256
280
  if (!existsSync(target)) continue; // 건너뛴 파일 제외
257
281
  if (unchangedNames.includes(filename)) continue; // unchanged 제외
258
- configureEnv(target, { ...envOpts, collectAsks }); // env 계획 values/useDefaults 포함
282
+ configureEnv(target, filename, { ...envOpts, collectAsks, trace }); // env 계획 values/useDefaults 포함
259
283
  }
260
284
  }
261
285
  }
@@ -0,0 +1,224 @@
1
+ // 마이그레이션 가이드 (#493) — Layer 1 큐레이션 문서.
2
+ // 마법사(full/workflows) 실행이 끝나면 대상 레포의 docs/projectops/migration/PROJECTOPS-MIGRATION-GUIDE.md에
3
+ // "고정 헤더(최초 1회) + 실행 엔트리(append-only)"를 남긴다. 사람용 동적 체크리스트와
4
+ // AI Agent용 yaml 메타데이터를 한 엔트리에 담고, run-trace(#494)의 events를 단일 소스로 소비한다.
5
+ import { join } from "node:path";
6
+ import { existsSync, readFileSync, appendFileSync } from "node:fs";
7
+ import { writeText } from "./fsutil.js";
8
+ import { MIGRATION_DIR } from "./run-trace.js";
9
+
10
+ export const GUIDE_FILE = `${MIGRATION_DIR}/PROJECTOPS-MIGRATION-GUIDE.md`;
11
+ export const GUIDE_SCHEMA = 1;
12
+
13
+ // 고정 헤더 — 문서 목적 + AI Agent 해석 가이드라인. 최초 생성 시 1회만 쓴다 (엔트리와 분리).
14
+ const HEADER = `# ProjectOps 마이그레이션 가이드
15
+
16
+ 이 문서는 projectops 마법사(\`npx projectops\`)가 **실행을 마칠 때마다 자동으로 기록**하는
17
+ 마이그레이션 이력입니다. 실행 엔트리는 아래에 시간순으로 append되며, 기존 엔트리는 수정되지 않습니다.
18
+
19
+ ## 사람이 읽는 법
20
+
21
+ - 각 엔트리의 **확인 체크리스트**가 그 실행 이후 사람이 마무리해야 하는 수동 작업입니다.
22
+ - 체크리스트는 실행에서 실제 발생한 것만 나옵니다 — 항목이 없으면 할 일이 없다는 뜻입니다.
23
+ - 상세 실행 기록(파일별 결정·치환 전후값)은 엔트리 메타의 \`trace_file\`(JSONL)·\`log_file\`(터미널 원문)에 있습니다.
24
+
25
+ ## AI Agent 해석 가이드라인
26
+
27
+ 각 엔트리의 \`\`\`yaml 메타 블록(\`# projectops-migration\`)을 파싱해 아래 규칙으로 판단·조치한다.
28
+
29
+ | 필드 | 의미 | 권장 조치 |
30
+ |------|------|----------|
31
+ | \`workflows.leftover_old_gen\` | 구세대 워크플로우가 현역 가능성으로 보존됨 | 신형과 트리거 중복 여부 검사 → 전환 확인 후 구 파일 삭제 제안 |
32
+ | \`workflows.skipped_conflict\` | 사용자 수정본이 유지됨 (신형 미적용) | 사용자본과 신형 템플릿 diff → 병합 제안 |
33
+ | \`workflows.replaced_bak\` | .bak 백업 후 신형으로 교체됨 | .bak과 신형 비교 → 사용자 커스텀 유실분 복원 검토 후 .bak 정리 |
34
+ | \`env_applied\` | 워크플로우에 적용된 환경값 | 실제 워크플로우 env와 대조 → 드리프트 발견 시 경고 |
35
+ | \`breaking_traversed\` | 이 실행이 통과한 호환성 변경 (조치 방법 전문은 사람용 섹션) | \`action_required: true\` 항목의 조치 완료 여부 확인 |
36
+ | \`manual_actions_pending\` | 남은 수동 작업 코드 목록 | 비어 있지 않으면 사용자에게 상기 |
37
+ | \`trace_file\` | 파일별 결정·치환 전후값 JSONL (Layer 2) | "왜 이 파일이 이렇게 됐나"는 파일명으로 grep |
38
+ | \`log_file\` | 터미널 출력 원문 (Layer 3) | 실행 재현·포렌식 디버깅용 |
39
+
40
+ - 스키마는 \`schema\` 필드로 버저닝된다. 모르는 필드는 무시하고, 아는 필드만 사용한다.
41
+ - 여러 엔트리가 있으면 **가장 최근 엔트리**가 현재 상태의 기준이다. 과거 엔트리는 이력 참고용.
42
+ `;
43
+
44
+ // ── yaml 렌더 헬퍼 (외부 의존성 없이 수동 직렬화 — version-yml.js와 동일 원칙) ──
45
+ const yq = (s) => `"${String(s ?? "").replaceAll('"', '\\"')}"`;
46
+ const ylist = (arr) => (arr && arr.length ? `[${arr.map(yq).join(", ")}]` : "[]");
47
+
48
+ // trace events → 가이드용 파일 목록 파생 (단일 소스 — 이벤트에서 유도).
49
+ export function deriveWorkflowLists(events = []) {
50
+ const pick = (action) => events.filter((e) => e.phase === "copy" && e.action === action).map((e) => e.target);
51
+ return {
52
+ added: pick("copied"),
53
+ replacedBak: pick("replaced-bak"),
54
+ skippedConflict: pick("skipped-conflict"),
55
+ templateAdded: pick("template-added"),
56
+ excluded: pick("excluded"),
57
+ };
58
+ }
59
+
60
+ // trace events → 타입별 적용 env 값 파생.
61
+ export function deriveEnvApplied(events = []) {
62
+ const byType = new Map();
63
+ for (const e of events) {
64
+ if (e.phase !== "env" || e.action !== "substituted") continue;
65
+ const t = e.detail?.type ?? "";
66
+ if (!byType.has(t)) byType.set(t, new Map());
67
+ byType.get(t).set(e.detail?.key ?? "", e.detail?.after ?? "");
68
+ }
69
+ return byType;
70
+ }
71
+
72
+ // 실행 엔트리 렌더링. report:
73
+ // { now, mode, types, repoName, templateFrom, templateTo,
74
+ // options: {deploy, publish, secretBackup, coderabbit, changelogProvider, intent},
75
+ // branches: {defaultBranch, deployBranch, ready, created},
76
+ // breaking: {current, target, critical:[], warnings:[]} | null,
77
+ // migrations: {applied:[], confirmPending:[], askPending:[]} | null,
78
+ // orphans: {cleaned:[], pending:[]} | null,
79
+ // events: [], counters: {}, traceFile, logFile }
80
+ export function renderGuideEntry(report) {
81
+ const r = report ?? {};
82
+ const from = r.templateFrom || "new";
83
+ const to = r.templateTo || "unknown";
84
+ const wf = deriveWorkflowLists(r.events);
85
+ const envByType = deriveEnvApplied(r.events);
86
+ const breaking = r.breaking ?? null;
87
+ const breakingAll = breaking ? [...(breaking.critical ?? []), ...(breaking.warnings ?? [])] : [];
88
+ const mig = r.migrations ?? null;
89
+ const leftoverOldGen = (mig?.confirmPending ?? []).map((e) => ({ file: e.file, replacement: e.replacedBy || "", reason: e.reason || "" }));
90
+ const legacyNeutralized = (mig?.applied ?? []).filter((a) => a.action !== "error");
91
+ const orphanCleaned = (r.orphans?.cleaned ?? []);
92
+
93
+ const L = [];
94
+ L.push("---");
95
+ L.push("");
96
+ L.push(`## ${r.now || ""} — v${from} → v${to} (${r.mode || "full"})`);
97
+ L.push("");
98
+ L.push(`- 타입: ${(r.types ?? []).join(", ") || "-"} · 배포: ${r.options?.deploy ?? "-"} · publish: ${(r.options?.publish ?? []).join(",") || "없음"}`);
99
+ L.push(`- 워크플로우: 신규/갱신 ${wf.added.length + wf.replacedBak.length}개 · 유지(unchanged/충돌스킵) ${(r.counters?.skipped ?? 0)}개`);
100
+ L.push("");
101
+
102
+ // ── 확인 체크리스트 (동적 — 실제 발생분만) ──
103
+ const checklist = [];
104
+ if (leftoverOldGen.length) {
105
+ checklist.push(`- [ ] **구세대 배포 워크플로우 ${leftoverOldGen.length}개 전환 후 삭제** — 현역 배포일 수 있어 마법사가 건드리지 않았습니다:`);
106
+ for (const o of leftoverOldGen) checklist.push(` - \`${o.file}\`${o.replacement ? ` → 신형 \`${o.replacement}\`` : ""}`);
107
+ }
108
+ if (wf.replacedBak.length || legacyNeutralized.length) {
109
+ checklist.push(`- [ ] **.bak 백업 파일 확인 후 정리** — 커스텀 유실분이 없는지 신형과 비교하세요:`);
110
+ for (const f of wf.replacedBak) checklist.push(` - \`${f}.bak\` (충돌 교체 백업)`);
111
+ for (const a of legacyNeutralized) if (a.to && String(a.to).endsWith(".bak")) checklist.push(` - \`${a.to}\` (레거시 무해화)`);
112
+ }
113
+ if (wf.skippedConflict.length) {
114
+ checklist.push(`- [ ] **기존 수정본 유지 ${wf.skippedConflict.length}개 — 신형과 병합 검토**: ${wf.skippedConflict.map((f) => `\`${f}\``).join(", ")}`);
115
+ }
116
+ if (wf.added.length || wf.replacedBak.length) {
117
+ checklist.push(`- [ ] **새/갱신 CICD가 요구하는 GitHub Secrets 등록 확인** (Settings → Secrets → Actions, \`_GITHUB_PAT_TOKEN\` 포함)`);
118
+ }
119
+ if (envByType.size) {
120
+ checklist.push(`- [ ] **적용된 배포 환경값 검증** — 실제 환경과 다르면 워크플로우 env를 직접 수정:`);
121
+ for (const [t, kv] of envByType) {
122
+ for (const [k, v] of kv) checklist.push(` - ${t} · \`${k}\` = \`${v}\``);
123
+ }
124
+ }
125
+ if (r.branches?.created === true) {
126
+ checklist.push(`- [x] 개발(릴리스 소스) 브랜치 \`${r.branches?.deployBranch ?? "develop"}\` — 마법사가 생성·확인 완료`);
127
+ } else if (r.branches?.ready === false) {
128
+ checklist.push(`- [ ] 개발(릴리스 소스) 브랜치 \`${r.branches?.deployBranch ?? "develop"}\` 생성 — 릴리스 PR이 동작하려면 필요합니다`);
129
+ }
130
+ if (checklist.length) {
131
+ L.push("### 확인 체크리스트");
132
+ L.push("");
133
+ L.push(...checklist);
134
+ L.push("");
135
+ }
136
+
137
+ // ── 버전 점프에서 통과한 호환성 변경 (조치 방법 전문 — 터미널에서 스킵해도 여기 남는다) ──
138
+ if (breakingAll.length) {
139
+ L.push(`### 통과한 호환성 변경 (v${breaking.current} → v${breaking.target})`);
140
+ L.push("");
141
+ for (const it of breakingAll) {
142
+ const sev = (breaking.critical ?? []).includes(it) ? "CRITICAL" : "WARNING";
143
+ L.push(`#### ${sev === "CRITICAL" ? "❗" : "⚠️"} [${sev}] ${it.version} — ${it.title || ""}`);
144
+ if (it.message) L.push(`${it.message}`);
145
+ L.push("");
146
+ }
147
+ }
148
+
149
+ // ── AI 메타데이터 ──
150
+ L.push("### AI 메타데이터");
151
+ L.push("");
152
+ L.push("```yaml");
153
+ L.push("# projectops-migration (machine-readable)");
154
+ L.push(`schema: ${GUIDE_SCHEMA}`);
155
+ L.push(`run_at: ${yq(r.now || "")}`);
156
+ L.push(`template: { from: ${yq(from)}, to: ${yq(to)} }`);
157
+ L.push(`mode: ${r.mode || "full"}`);
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 ?? "")} }`);
160
+ L.push(`branches: { default: ${yq(r.branches?.defaultBranch ?? "main")}, deploy: ${yq(r.branches?.deployBranch ?? "develop")}, deploy_branch_created: ${r.branches?.created === true} }`);
161
+ L.push("workflows:");
162
+ L.push(` added: ${ylist(wf.added)}`);
163
+ L.push(` replaced_bak: ${ylist(wf.replacedBak)}`);
164
+ L.push(` skipped_conflict: ${ylist(wf.skippedConflict)}`);
165
+ L.push(` template_added: ${ylist(wf.templateAdded)}`);
166
+ if (legacyNeutralized.length) {
167
+ L.push(" legacy_neutralized:");
168
+ for (const a of legacyNeutralized) L.push(` - { file: ${yq(a.from ?? a.id ?? "")}, to: ${yq(a.to ?? "")}, action: ${yq(a.action ?? "")} }`);
169
+ } else {
170
+ L.push(" legacy_neutralized: []");
171
+ }
172
+ if (leftoverOldGen.length) {
173
+ L.push(" leftover_old_gen:");
174
+ for (const o of leftoverOldGen) L.push(` - { file: ${yq(o.file)}, replacement: ${yq(o.replacement)} }`);
175
+ } else {
176
+ L.push(" leftover_old_gen: []");
177
+ }
178
+ if (orphanCleaned.length) L.push(` orphan_neutralized: ${ylist(orphanCleaned)}`);
179
+ if (envByType.size) {
180
+ L.push("env_applied:");
181
+ for (const [t, kv] of envByType) {
182
+ L.push(` ${t}:`);
183
+ for (const [k, v] of kv) L.push(` ${k}: ${yq(v)}`);
184
+ }
185
+ } else {
186
+ L.push("env_applied: {}");
187
+ }
188
+ if (breakingAll.length) {
189
+ L.push("breaking_traversed:");
190
+ for (const it of breakingAll) {
191
+ const sev = (breaking.critical ?? []).includes(it) ? "critical" : "warning";
192
+ L.push(` - { version: ${yq(it.version)}, severity: ${sev}, title: ${yq(it.title ?? "")}, action_required: ${sev === "critical"} }`);
193
+ }
194
+ } else {
195
+ L.push("breaking_traversed: []");
196
+ }
197
+ const pending = [];
198
+ if (leftoverOldGen.length) pending.push("delete-old-gen-workflows");
199
+ if (wf.replacedBak.length || legacyNeutralized.some((a) => a.to && String(a.to).endsWith(".bak"))) pending.push("review-bak-files");
200
+ if (wf.skippedConflict.length) pending.push("merge-skipped-conflicts");
201
+ if (wf.added.length || wf.replacedBak.length) pending.push("register-secrets");
202
+ if (r.branches?.ready === false) pending.push("create-deploy-branch");
203
+ L.push(`manual_actions_pending: ${ylist(pending)}`);
204
+ L.push(`trace_file: ${yq(r.traceFile ?? "")}`);
205
+ L.push(`log_file: ${yq(r.logFile ?? "")}`);
206
+ L.push("```");
207
+ L.push("");
208
+ return L.join("\n");
209
+ }
210
+
211
+ // 가이드 파일에 엔트리 append (파일 없으면 헤더부터 생성). 반환: { guidePath, created }.
212
+ export function appendGuideEntry(targetRoot, report) {
213
+ const guidePath = join(targetRoot, GUIDE_FILE);
214
+ const entry = renderGuideEntry(report);
215
+ const created = !existsSync(guidePath);
216
+ if (created) {
217
+ writeText(guidePath, HEADER + "\n" + entry);
218
+ } else {
219
+ // append-only — 기존 엔트리 불변 (이력 보존 계약)
220
+ const prev = readFileSync(guidePath, "utf8");
221
+ appendFileSync(guidePath, (prev.endsWith("\n") ? "" : "\n") + entry);
222
+ }
223
+ return { guidePath: GUIDE_FILE, created };
224
+ }
@@ -121,6 +121,7 @@ export async function askAllOptionalWorkflows({
121
121
  let changelogBaseUrl = current.changelogBaseUrl ?? null;
122
122
  let deployBranch = current.deployBranch ?? null; // #456 릴리스 PR head 브랜치
123
123
  let deployBranchReady = null; // #490 — 이번 실행에서 브랜치 존재/생성이 확인됐는지 (null=확인 안 함)
124
+ let deployBranchCreated = null; // #493 — 이번 실행에서 마법사가 직접 생성했는지 (가이드 기록용)
124
125
  let intent = current.intent ?? null; // #485 프로젝트 성격 (app/library/both/none/manual)
125
126
 
126
127
  // basic 단독 타입은 서버 배포도 라이브러리 publish도 개념상 성립하지 않는다.
@@ -337,6 +338,7 @@ export async function askAllOptionalWorkflows({
337
338
  // #490 — 결과(ready)를 완료 요약에 전달해 이미 생성/확인한 브랜치를 재지시하지 않는다
338
339
  const br = await ensureDeployBranch({ targetRoot, deployBranch, defaultBranch, io, say });
339
340
  deployBranchReady = br.ready;
341
+ deployBranchCreated = br.created;
340
342
  }
341
343
  }
342
344
 
@@ -358,6 +360,7 @@ export async function askAllOptionalWorkflows({
358
360
  changelogBaseUrl: changelogBaseUrl ?? "",
359
361
  deployBranch: deployBranch ?? "develop",
360
362
  deployBranchReady, // #490 — true=존재/생성 확인됨, false=거절/실패, null=확인 안 함
363
+ deployBranchCreated, // #493 — true=이번 실행에서 마법사가 생성, null=확인 안 함
361
364
 
362
365
  // #485 intent — 확정값 우선, 없으면 최종 deploy/publish에서 역추론(basic·비대화형 경로 보정)
363
366
  intent: intent ?? inferIntent(finalDeploy, finalPublish) ?? "manual",
@@ -0,0 +1,89 @@
1
+ // 마법사 실행 트레이스 (#494) — 3계층 기록의 Layer 2(JSONL 이벤트) + Layer 3(터미널 미러).
2
+ // 마법사의 모든 결정·행동을 이벤트 한 줄씩 남겨, 나중에 사람·AI Agent가 파일명 grep 한 번으로
3
+ // "이 파일에 마법사가 한 모든 일"을 시간순 추적할 수 있게 한다 (v4.2.15 수동 로그 복사 진단의 자동화).
4
+ // Layer 1(큐레이션 가이드)은 migration-guide.js — 이 모듈의 events를 단일 소스로 소비한다.
5
+ import { join } from "node:path";
6
+ import { writeText } from "./fsutil.js";
7
+
8
+ export const MIGRATION_DIR = "docs/projectops/migration";
9
+ export const TRACE_SCHEMA = 1;
10
+
11
+ // 민감값 가드 — PAT·토큰·시크릿·비밀번호는 어떤 이벤트에도 남기지 않는다 (#494 안전 규칙).
12
+ const SENSITIVE_KEY_RE = /pat|token|secret|password|credential/i;
13
+ export function scrubDetail(detail) {
14
+ if (detail == null || typeof detail !== "object" || Array.isArray(detail)) return detail;
15
+ const out = {};
16
+ for (const [k, v] of Object.entries(detail)) {
17
+ if (SENSITIVE_KEY_RE.test(k)) continue;
18
+ out[k] = (v != null && typeof v === "object" && !Array.isArray(v)) ? scrubDetail(v) : v;
19
+ }
20
+ return out;
21
+ }
22
+
23
+ // now("YYYY-MM-DD HH:MM:SS") → 파일명 스탬프 "YYYYMMDD_HHMMSS". 형식이 아니면 "run" 폴백(테스트 주입 clock 안전).
24
+ export function stampFromNow(now) {
25
+ const digits = String(now ?? "").replace(/[^0-9]/g, "");
26
+ if (digits.length < 14) return "run";
27
+ return `${digits.slice(0, 8)}_${digits.slice(8, 14)}`;
28
+ }
29
+
30
+ // 트레이스 팩토리. clockIso 주입 가능(테스트 결정성) — 기본은 실제 UTC.
31
+ export function createRunTrace({ clockIso = null } = {}) {
32
+ const events = [];
33
+ const lines = [];
34
+ let restore = null;
35
+
36
+ const nowIso = () => clockIso ?? new Date().toISOString().replace(/\.\d+Z$/, "Z");
37
+
38
+ return {
39
+ events,
40
+ lines,
41
+
42
+ // 이벤트 1건 기록. detail은 민감키 스크럽 후 저장.
43
+ event(phase, action, target = "", detail = null) {
44
+ const e = { ts: nowIso(), phase, action, target };
45
+ const d = scrubDetail(detail);
46
+ if (d != null && (typeof d !== "object" || Object.keys(d).length > 0)) e.detail = d;
47
+ events.push(e);
48
+ return e;
49
+ },
50
+
51
+ // 터미널 출력 미러 시작 — stdout/stderr write를 감싸 사본만 수집(출력 자체는 그대로 통과).
52
+ // 실제 CLI 실행에서만 켠다 (테스트 스텁 io 경로는 호출하지 않음).
53
+ mirrorStart() {
54
+ if (restore) return;
55
+ const so = process.stdout.write; // 원본 참조 보관 — 복원 시 identity 유지
56
+ const se = process.stderr.write;
57
+ const capture = (chunk) => {
58
+ try { lines.push(typeof chunk === "string" ? chunk : chunk.toString("utf8")); } catch { /* 미러 실패는 실행에 영향 없음 */ }
59
+ };
60
+ process.stdout.write = function (chunk, ...rest) { capture(chunk); return so.apply(process.stdout, [chunk, ...rest]); };
61
+ process.stderr.write = function (chunk, ...rest) { capture(chunk); return se.apply(process.stderr, [chunk, ...rest]); };
62
+ restore = () => { process.stdout.write = so; process.stderr.write = se; };
63
+ },
64
+
65
+ mirrorStop() {
66
+ if (restore) { restore(); restore = null; }
67
+ },
68
+
69
+ // Layer 2/3 파일 기록 — docs/projectops/migration/{stamp}_v{from}_to_v{to}.{jsonl,log}
70
+ // 반환: { traceFile, logFile } (targetRoot 기준 상대 경로 — 가이드 메타 포인터용).
71
+ // 이벤트가 0건이면 기록하지 않는다(no-op 실행 오염 방지) — null 반환.
72
+ write({ targetRoot = ".", fromVersion = "", toVersion = "", now = "" } = {}) {
73
+ if (events.length === 0) return null;
74
+ const stamp = stampFromNow(now);
75
+ const from = String(fromVersion || "new").replace(/[^0-9a-zA-Z.-]/g, "");
76
+ 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`;
79
+ 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");
81
+ let logFile = null;
82
+ if (lines.length > 0) {
83
+ logFile = `${MIGRATION_DIR}/${base}.log`;
84
+ writeText(join(targetRoot, logFile), lines.join(""));
85
+ }
86
+ return { traceFile, logFile };
87
+ },
88
+ };
89
+ }
@@ -57,8 +57,9 @@ export function resolveGlobalTokens(s, repoName = "") {
57
57
  // resolvers - resolveToken용
58
58
  // repoName - __PROJECT_NAME__/__APP_ARTIFACT_NAME__ 치환값
59
59
  // projectPath - paths-anchor 치환용 ('.'이면 anchor 미변경)
60
+ // collectSubs - 배열이면 치환 1건당 {key, action, before, after}를 push (#494 트레이스용)
60
61
  export function substituteEnv(content, opts = {}) {
61
- const { type = "", values = new Map(), useDefaults = true, resolvers = {}, repoName = "", projectPath = ".", collectAsks = null } = opts;
62
+ const { type = "", values = new Map(), useDefaults = true, resolvers = {}, repoName = "", projectPath = ".", collectAsks = null, collectSubs = null } = opts;
62
63
  if (!content.includes("@wizard")) return content;
63
64
 
64
65
  // CRLF 안전: EOL을 분리해 LF 기준으로 파싱·치환하고, 원래 EOL 스타일을 복원한다.
@@ -81,6 +82,11 @@ export function substituteEnv(content, opts = {}) {
81
82
  // version.yml deploy 블록이 설치본과 항상 바이트 일치하게 한다.
82
83
  if (collectAsks) collectAsks.set(p.key, resolveGlobalTokens(val, repoName));
83
84
  }
85
+ // #494 트레이스 — 치환 전후값 기록 (after는 파일 최종형과 동일하게 전역 토큰까지 해석)
86
+ if (Array.isArray(collectSubs) && val !== "" && val != null) {
87
+ const before = (lines[i].match(/:\s*"([^"]*)"/) || [])[1] ?? "";
88
+ collectSubs.push({ key: p.key, action: p.action, before, after: resolveGlobalTokens(val, repoName) });
89
+ }
84
90
  lines[i] = setEnvLine(lines[i], p.key, val);
85
91
  }
86
92
  let out = lines.join(usesCRLF ? "\r\n" : "\n");
package/src/index.js CHANGED
@@ -14,6 +14,8 @@ import { parseExisting } from "./core/version-yml.js";
14
14
  import { runBreakingCheck } from "./core/breaking-check.js";
15
15
  import { runMigrations } from "./core/migrations/index.js";
16
16
  import { detectOrphanWorkflows } from "./core/orphan-workflows.js";
17
+ import { createRunTrace } from "./core/run-trace.js";
18
+ import { appendGuideEntry } from "./core/migration-guide.js";
17
19
  import { resolveProjectPaths } from "./core/paths-resolve.js";
18
20
  import { printBannerCompact } from "./ui/banner.js";
19
21
  import { printSummary } from "./ui/summary.js";
@@ -135,6 +137,13 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
135
137
  });
136
138
 
137
139
  let result = null;
140
+ // 실행 트레이스 (#494) — 비대화형도 이벤트 기록 (터미널 미러는 CLI 실행에서만 유의미하므로 함께 켠다)
141
+ const trace = createRunTrace();
142
+ const recordArtifacts = opts.mode === "full" || opts.mode === "workflows";
143
+ let breakingReport = null;
144
+ let migrationsResult = null;
145
+ let orphanPending = [];
146
+ if (recordArtifacts) trace.mirrorStart();
138
147
  try {
139
148
  acquireTemplate({ tempDir, source });
140
149
  context.templateVersion = readTemplateVersion(tempDir);
@@ -143,38 +152,60 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
143
152
  printBannerCompact({ version: context.templateVersion, mode: opts.mode });
144
153
 
145
154
  // Breaking Changes 게이트 (.sh execute_integration L4415~4420 등가 — 비대화형은 경고 후 진행)
146
- const proceed = await runBreakingCheck({ cwd, tempDir, templateVersion: context.templateVersion });
155
+ const proceed = await runBreakingCheck({
156
+ cwd, tempDir, templateVersion: context.templateVersion,
157
+ onItems: (items) => { breakingReport = items; },
158
+ });
147
159
  if (!proceed) return 0;
148
160
 
149
161
  // 레거시 마이그레이션 (#470) — 워크플로우를 만지는 모드에서만. 비대화형은 safe 티어 자동 적용.
150
- if (opts.mode === "full" || opts.mode === "workflows") {
151
- await runMigrations({ targetRoot: cwd });
162
+ if (recordArtifacts) {
163
+ migrationsResult = await runMigrations({ targetRoot: cwd });
164
+ for (const a of migrationsResult.applied ?? []) trace.event("legacy", a.action === "error" ? "error" : "neutralized", a.from ?? a.id ?? "", { to: a.to ?? "", id: a.id ?? "" });
165
+ for (const e of migrationsResult.confirmPending ?? []) trace.event("legacy", "leftover-old-gen", e.file, { replacement: e.replacedBy ?? "", reason: e.reason ?? "" });
152
166
  }
153
167
 
154
168
  // 고아 타입 워크플로우 안내 (#487) — 비대화형은 자동 무해화 금지(배포 파이프라인일 수 있음), 안내만
155
- if (opts.mode === "full" || opts.mode === "workflows") {
169
+ if (recordArtifacts) {
156
170
  const orphans = detectOrphanWorkflows({ tempDir, targetRoot: cwd, selectedTypes: types });
171
+ orphanPending = orphans.map((o) => o.filename);
157
172
  for (const o of orphans) {
158
173
  console.error(`⚠️ 선택되지 않은 타입(${o.type})의 워크플로우가 남아있습니다: ${o.filename} — 대화형 마법사(npx projectops)에서 정리할 수 있습니다.`);
159
174
  }
160
175
  }
161
176
 
162
177
  switch (opts.mode) {
163
- case "full": result = runFull(context, tempDir, cwd); break;
178
+ case "full": result = runFull(context, tempDir, cwd, { trace }); break;
164
179
  case "version": result = runVersion(context, tempDir, cwd); break;
165
- case "workflows": result = runWorkflows(context, tempDir, cwd); break;
180
+ case "workflows": result = runWorkflows(context, tempDir, cwd, { trace }); break;
166
181
  case "issues": result = runIssues(context, tempDir, cwd); break;
167
182
  default:
168
183
  // 알 수 없는 모드 → .sh와 동일하게 복사 0건, 에러 아님
169
184
  break;
170
185
  }
171
186
  } finally {
187
+ trace.mirrorStop();
172
188
  remove(tempDir);
173
189
  }
174
190
 
191
+ // 마이그레이션 기록 (#493/#494) — Layer 2/3 트레이스 파일 + Layer 1 가이드 엔트리
192
+ let migrationGuidePath = null;
193
+ if (recordArtifacts) {
194
+ const files = trace.write({ targetRoot: cwd, fromVersion: existing?.templateVersion || "", toVersion: context.templateVersion, now });
195
+ migrationGuidePath = appendGuideEntry(cwd, {
196
+ now, mode: opts.mode, types, repoName,
197
+ templateFrom: existing?.templateVersion || "", templateTo: context.templateVersion,
198
+ options: { deploy: deployTarget, publish: publishTargets, secretBackup: context.includeSecretBackup, coderabbit: context.codeReviewCoderabbit, changelogProvider: context.changelogProvider, intent },
199
+ branches: { defaultBranch: branch, deployBranch: context.deployBranch || "develop", ready: null, created: null },
200
+ breaking: breakingReport, migrations: migrationsResult, orphans: { cleaned: [], pending: orphanPending },
201
+ events: trace.events, counters: { skipped: result?.workflows?.skipped ?? 0 },
202
+ traceFile: files?.traceFile ?? "", logFile: files?.logFile ?? "",
203
+ }).guidePath;
204
+ }
205
+
175
206
  // 완료 요약 (.sh print_summary — CLI 모드에서도 출력)
176
207
  printSummary({
177
- mode: opts.mode, types, version, deployBranch: context.deployBranch,
208
+ mode: opts.mode, types, version, deployBranch: context.deployBranch, migrationGuidePath,
178
209
  counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
179
210
  }, cwd);
180
211
  return 0;
package/src/ui/summary.js CHANGED
@@ -128,6 +128,11 @@ export function printSummary(ctx, targetRoot = ".") {
128
128
 
129
129
  err(" 📖 TEMPLATE REPO: https://github.com/Cassiiopeia/projectops");
130
130
  err(" 📚 워크플로우 가이드: .github/workflows/project-types/README.md");
131
+ // #493 — 이번 실행의 마이그레이션 기록 (수동 체크리스트·breaking 조치·AI 메타데이터)
132
+ if (ctx?.migrationGuidePath) {
133
+ err(` 🧭 마이그레이션 가이드: ${ctx.migrationGuidePath}`);
134
+ err(" (이번 실행의 변경 내역·수동 확인 체크리스트·AI Agent용 메타데이터가 기록됐습니다)");
135
+ }
131
136
  err("");
132
137
 
133
138
  // 필수 3가지 작업 안내 (.sh L5605~5625 — 원문 유지)