projectops 4.2.15 → 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.14 (2026-07-12)
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.15",
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
 
@@ -89,6 +96,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
89
96
  let changelogProvider = existing?.options?.changelogProvider ?? "github-ai";
90
97
  let changelogBaseUrl = existing?.options?.changelogBaseUrl ?? "";
91
98
  let deployBranch = existing?.options?.deployBranch ?? "develop"; // #456
99
+ let deployBranchReady = null; // #490 — 이번 실행에서 개발 브랜치 존재/생성이 확인됐는지
100
+ let deployBranchCreated = null; // #493 — 마법사가 직접 생성했는지 (가이드 기록용)
92
101
  let intent = existing?.options?.intent ?? null; // #485 프로젝트 성격
93
102
  const showOptional = mode === "full" || mode === "workflows";
94
103
  const realTty = process.stdout.isTTY === true;
@@ -123,6 +132,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
123
132
  changelogProvider = r.changelogProvider;
124
133
  changelogBaseUrl = r.changelogBaseUrl;
125
134
  deployBranch = r.deployBranch;
135
+ deployBranchReady = r.deployBranchReady ?? deployBranchReady; // #490
136
+ deployBranchCreated = r.deployBranchCreated ?? deployBranchCreated; // #493
126
137
  intent = r.intent;
127
138
  }
128
139
 
@@ -186,6 +197,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
186
197
  changelogProvider = r.changelogProvider;
187
198
  changelogBaseUrl = r.changelogBaseUrl;
188
199
  deployBranch = r.deployBranch;
200
+ deployBranchReady = r.deployBranchReady ?? deployBranchReady; // #490
201
+ deployBranchCreated = r.deployBranchCreated ?? deployBranchCreated; // #493
189
202
  intent = r.intent;
190
203
  }
191
204
  }
@@ -249,14 +262,18 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
249
262
  }
250
263
 
251
264
  // 레거시 마이그레이션 (#470) — 워크플로우를 만지는 모드에서만. 대화형은 safe 티어 확인 1회.
265
+ let migrationsResult = null; // #493 — 가이드 기록용
252
266
  if (mode === "full" || mode === "workflows") {
253
- await runMigrations({
267
+ migrationsResult = await runMigrations({
254
268
  targetRoot: cwd,
255
269
  askYesNo: (msg, def) => io.askYesNo(msg, def),
256
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 ?? "" });
257
273
  }
258
274
 
259
275
  // 고아 타입 워크플로우 정리 (#487) — 타입 변경으로 선택에서 빠진 타입의 잔존 워크플로우
276
+ const orphanReport = { cleaned: [], pending: [] }; // #493 — 가이드 기록용
260
277
  if (mode === "full" || mode === "workflows") {
261
278
  const orphans = detectOrphanWorkflows({ tempDir, targetRoot: cwd, selectedTypes: types });
262
279
  if (orphans.length > 0) {
@@ -269,28 +286,48 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
269
286
  const results = applyOrphanCleanup(cwd, orphans);
270
287
  const ok = results.filter((r) => r.action === "bak");
271
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);
272
291
  io.note?.(`✅ 고아 워크플로우 정리: ${ok.length}개${failed.length ? ` (실패 ${failed.length}개)` : ""}`, "정리 완료");
292
+ } else {
293
+ orphanReport.pending = orphans.map((o) => o.filename);
273
294
  }
274
295
  }
275
296
  }
276
297
 
277
298
  let result = null;
278
- if (mode === "full") result = runFull(ctx, tempDir, cwd, hooks);
299
+ if (mode === "full") result = runFull(ctx, tempDir, cwd, { ...hooks, trace });
279
300
  else if (mode === "version") result = runVersion(ctx, tempDir, cwd);
280
- else if (mode === "workflows") result = runWorkflows(ctx, tempDir, cwd, hooks);
301
+ else if (mode === "workflows") result = runWorkflows(ctx, tempDir, cwd, { ...hooks, trace });
281
302
 
282
303
  // 통합 후 IDE 스킬 제안 (.sh L4557 offer_ide_tools_install — 사전 질문 게이트, 기본 N)
283
304
  const wantSkills = await io.askYesNo("AI 에이전트 스킬(Claude·Cursor·Gemini·Codex·PI)도 설치/업데이트할까요?", false);
284
305
  if (wantSkills === true) await skills({ templateVersion, tempDir, interactive: true });
285
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
+
286
322
  // 완료 요약 (.sh print_summary L5438)
287
323
  io.summary?.({
288
- mode, types, version, deployBranch,
324
+ mode, types, version, deployBranch, deployBranchReady, migrationGuidePath,
289
325
  counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
290
326
  }, cwd);
291
327
  io.outro?.(`통합 완료 — ${mode} 모드로 설치했습니다.`);
292
328
  return 0;
293
329
  } finally {
330
+ trace.mirrorStop();
294
331
  remove(tempDir);
295
332
  }
296
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,27 @@ 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
+ }
24
+ }
25
+
26
+ // util 버전 동기화 워크플로우 (#491) — .github/util/ 모듈(version.json)이 있는 레포에서만 의미가 있다.
27
+ // util이 없는 레포(예: spring 단독)에 복사하면 트리거가 영원히 안 걸리는 no-op 오염이 된다.
28
+ const UTIL_VERSION_SYNC = "PROJECT-COMMON-TEMPLATE-UTIL-VERSION-SYNC.yml";
29
+
30
+ // 이 통합에서 util 모듈이 존재하게 되는가 — 대상에 이미 있거나(업데이트),
31
+ // 선택 타입의 util이 템플릿에 있어 곧 복사될 예정(runFull 6단계)이면 true.
32
+ function utilSyncApplies(tempDir, targetRoot, types) {
33
+ if (exists(join(targetRoot, ".github", "util"))) return true;
34
+ return types.some((t) => exists(join(tempDir, ".github", "util", t)));
19
35
  }
20
36
 
21
37
  // 3분류 (신규/unchanged/changed) — 대상 워크플로우 디렉토리 기준.
@@ -46,6 +62,7 @@ function classify(srcDir, workflowsDir, envOpts) {
46
62
  export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
47
63
  const { types = [], paths = new Map(), deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, repoName = "", resolvers = {}, envValues = new Map(), envUseDefaults = true, branch = "", deployBranch = "" } = context;
48
64
  const decisions = hooks.decisions instanceof Map ? hooks.decisions : new Map();
65
+ const trace = hooks.trace ?? null; // #494 — 실행 트레이스 (null-safe: 미주입이면 전 이벤트 no-op)
49
66
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
50
67
  const projectTypesDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
51
68
  if (!exists(projectTypesDir)) throw new Error("템플릿 저장소 구조 오류 — project-types 폴더를 찾지 못했습니다.");
@@ -62,22 +79,29 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
62
79
  const commonDir = join(projectTypesDir, "common");
63
80
  if (exists(commonDir)) {
64
81
  for (const filename of listYamlFiles(commonDir)) {
82
+ // #491 — util 동기화 워크플로우는 util 모듈이 있(게 되)는 레포에만 복사
83
+ if (filename === UTIL_VERSION_SYNC && !utilSyncApplies(tempDir, targetRoot, types)) {
84
+ trace?.event("copy", "excluded", filename, { reason: "util-modules-absent" });
85
+ continue;
86
+ }
65
87
  const src = join(commonDir, filename);
66
88
  const dst = join(workflowsDir, filename);
67
89
  if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOptsFor("common"))) {
68
90
  counters.skipped++;
91
+ trace?.event("copy", "skipped-unchanged", filename, { group: "common" });
69
92
  continue;
70
93
  }
71
94
  copyFileSync(src, dst);
72
95
  counters.copied++;
73
96
  counters.copiedFiles.push(filename);
97
+ trace?.event("copy", "copied", filename, { group: "common" });
74
98
  }
75
99
  }
76
100
 
77
101
  // (2~4) 타입별
78
102
  for (const type of types) {
79
103
  const asks = new Map();
80
- 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);
81
105
  if (asks.size) deployValues.set(type, asks);
82
106
  }
83
107
 
@@ -89,13 +113,16 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
89
113
  const dst = join(workflowsDir, filename);
90
114
  if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOptsFor("common"))) {
91
115
  counters.skipped++;
116
+ trace?.event("copy", "skipped-unchanged", filename, { group: "common-deploy" });
92
117
  continue;
93
118
  }
94
- if (existsSync(dst)) renameSync(dst, dst + ".bak");
119
+ const backedUp = existsSync(dst);
120
+ if (backedUp) renameSync(dst, dst + ".bak");
95
121
  copyFileSync(src, dst);
96
122
  counters.optionalCopied++;
97
123
  counters.copied++;
98
124
  counters.copiedFiles.push(filename);
125
+ trace?.event("copy", backedUp ? "replaced-bak" : "copied", filename, { group: "common-deploy" });
99
126
  }
100
127
  }
101
128
 
@@ -109,6 +136,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
109
136
  counters.optionalCopied++;
110
137
  counters.copied++;
111
138
  counters.copiedFiles.push(filename);
139
+ trace?.event("copy", "copied", filename, { group: "secret-backup" });
112
140
  }
113
141
  }
114
142
 
@@ -120,7 +148,10 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
120
148
  if (!existsSync(p)) continue;
121
149
  const before = readFileSync(p, "utf8");
122
150
  const after = substituteBranches(before, branches);
123
- 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
+ }
124
155
  }
125
156
  }
126
157
 
@@ -129,7 +160,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
129
160
 
130
161
  // changed(기존에 있고 내용이 바뀐) 파일 1개를 결정에 따라 처리 (.sh 3440~3508 3지선 case 등가).
131
162
  // 'skip'(기본): 기존 유지. 'backup': 기존→.bak 후 교체. 'template': 기존 유지 + 새 버전을 .template.yaml로.
132
- function applyDecision(decision, srcDir, workflowsDir, filename, counters) {
163
+ function applyDecision(decision, srcDir, workflowsDir, filename, counters, trace = null) {
133
164
  const src = join(srcDir, filename);
134
165
  const dst = join(workflowsDir, filename);
135
166
  if (decision === "backup") {
@@ -138,6 +169,7 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters) {
138
169
  copyFileSync(src, dst);
139
170
  counters.copied++;
140
171
  counters.copiedFiles?.push(filename);
172
+ trace?.event("copy", "replaced-bak", filename, { decision });
141
173
  return;
142
174
  }
143
175
  if (decision === "template") {
@@ -145,9 +177,11 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters) {
145
177
  const templateName = (filename.endsWith(".yaml") ? filename.slice(0, -".yaml".length) : filename) + ".template.yaml";
146
178
  copyFileSync(src, join(workflowsDir, templateName)); // cp가 기존 .template.yaml 덮어씀(.sh rm -f + cp 등가)
147
179
  counters.templateAdded++;
180
+ trace?.event("copy", "template-added", templateName, { original: filename });
148
181
  return;
149
182
  }
150
183
  counters.skipped++; // 'skip'/미지정/ESC → 기존 유지 (.sh S)·force 기본)
184
+ trace?.event("copy", "skipped-conflict", filename, { decision: decision ?? "skip", note: "사용자 수정본 유지 — 병합 검토 후보" });
151
185
  }
152
186
 
153
187
  // 대상 워크플로우 디렉토리에서 changed(충돌) 파일 목록만 뽑는다 — copyWorkflowsInteractive의 사전 조사용.
@@ -190,7 +224,7 @@ export async function copyWorkflowsInteractive(context, tempDir, targetRoot = ".
190
224
  const PUBLISH_TARGETS = ["nexus", "npm", "github-packages"];
191
225
 
192
226
  function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters) {
193
- 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;
194
228
  const typeDir = join(projectTypesDir, type);
195
229
  const envOpts = envOptsFor(type);
196
230
  let unchangedNames = [];
@@ -199,19 +233,19 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
199
233
  if (exists(typeDir)) {
200
234
  const { newFiles, unchanged, changed } = classify(typeDir, workflowsDir, envOpts);
201
235
  unchangedNames = unchanged.slice();
202
- for (const f of unchanged) counters.skipped++;
203
- 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 }); }
204
238
  // changed: 결정 Map에 따라 처리 (미지정=skip → 현행 force 동작과 동일)
205
- 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);
206
240
  }
207
241
 
208
242
  // server-deploy — deploy=docker-ssh일 때만 포함 (#439)
209
243
  const serverDeployDir = join(typeDir, "server-deploy");
210
244
  if (exists(serverDeployDir) && (deployTarget || "docker-ssh") === "docker-ssh") {
211
245
  const { newFiles, unchanged, changed } = classify(serverDeployDir, workflowsDir, envOpts);
212
- for (const f of unchanged) counters.skipped++;
213
- for (const f of newFiles) { copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); }
214
- 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);
215
249
  }
216
250
 
217
251
  // publish/<target> (opt-in — #439 publish 축. 타입은 파일 위치일 뿐 게이트가 아니다)
@@ -225,13 +259,16 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
225
259
  const dst = join(workflowsDir, filename);
226
260
  if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOpts)) {
227
261
  counters.skipped++;
262
+ trace?.event("copy", "skipped-unchanged", filename, { group: `${type}/publish/${target}` });
228
263
  continue;
229
264
  }
230
- if (existsSync(dst)) renameSync(dst, dst + ".bak");
265
+ const backedUp = existsSync(dst);
266
+ if (backedUp) renameSync(dst, dst + ".bak");
231
267
  copyFileSync(src, dst);
232
268
  counters.optionalCopied++;
233
269
  counters.copied++;
234
270
  counters.copiedFiles.push(filename);
271
+ trace?.event("copy", backedUp ? "replaced-bak" : "copied", filename, { group: `${type}/publish/${target}` });
235
272
  }
236
273
  }
237
274
 
@@ -242,7 +279,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
242
279
  const target = join(workflowsDir, filename);
243
280
  if (!existsSync(target)) continue; // 건너뛴 파일 제외
244
281
  if (unchangedNames.includes(filename)) continue; // unchanged 제외
245
- configureEnv(target, { ...envOpts, collectAsks }); // env 계획 values/useDefaults 포함
282
+ configureEnv(target, filename, { ...envOpts, collectAsks, trace }); // env 계획 values/useDefaults 포함
246
283
  }
247
284
  }
248
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
+ }
@@ -16,34 +16,39 @@ import { branchStatus, createBranch, pushBranch } from "./git-branch.js";
16
16
  // 개발(배포) 브랜치 존재 확인 + 생성 제안 (#477) — 대화형 전용.
17
17
  // 로컬에 없고 원격에도 없(거나 불명이)면 기본 브랜치에서 생성을 제안하고, 생성 후 push 여부도 묻는다.
18
18
  // git 미설치·비레포·질문 불가(io.confirm 없음)면 조용히 통과 — 마법사 진행을 막지 않는다.
19
+ // 반환 ready (#490 — 완료 요약이 "브랜치 만들어라" 재지시를 접을지 판단):
20
+ // true = 브랜치가 이미 있거나 이번 실행에서 생성함 → 요약에서 생성 안내 불필요
21
+ // false = 없는데 사용자가 거절/생성 실패 → 안내 유지
22
+ // null = 확인 자체를 못 함(비레포·질문 불가) → 안내 유지(보수적)
19
23
  export async function ensureDeployBranch({ targetRoot = ".", deployBranch = "", defaultBranch = "", io = {}, say = () => {} }) {
20
- if (!deployBranch || typeof io.confirm !== "function") return { created: false, pushed: false };
24
+ if (!deployBranch || typeof io.confirm !== "function") return { created: false, pushed: false, ready: null };
21
25
  const st = branchStatus(targetRoot, deployBranch);
22
- if (!st.isRepo || st.local) return { created: false, pushed: false };
26
+ if (!st.isRepo) return { created: false, pushed: false, ready: null };
27
+ if (st.local) return { created: false, pushed: false, ready: true };
23
28
  if (st.remote === true) {
24
29
  say(`ℹ️ '${deployBranch}' 브랜치가 원격에는 있고 로컬에 없습니다 — 필요 시 'git switch ${deployBranch}'로 가져오세요.`);
25
- return { created: false, pushed: false };
30
+ return { created: false, pushed: false, ready: true };
26
31
  }
27
32
  say(`⚠️ 개발(릴리스 소스) 브랜치 '${deployBranch}'가 없습니다 — 릴리스(${deployBranch}→${defaultBranch || "기본 브랜치"} PR)가 동작하려면 필요합니다.`);
28
33
  const mk = await io.confirm({ message: `${defaultBranch || "현재"} 브랜치에서 '${deployBranch}' 브랜치를 만들까요?`, initialValue: true });
29
34
  if (mk !== true) {
30
35
  say(`→ 건너뜁니다. 나중에 직접: git checkout -b ${deployBranch} && git push -u origin ${deployBranch}`);
31
- return { created: false, pushed: false };
36
+ return { created: false, pushed: false, ready: false };
32
37
  }
33
38
  if (!createBranch(targetRoot, deployBranch, defaultBranch)) {
34
39
  say(`⚠️ 브랜치 생성 실패 — 직접 실행해주세요: git branch ${deployBranch}${defaultBranch ? ` ${defaultBranch}` : ""}`);
35
- return { created: false, pushed: false };
40
+ return { created: false, pushed: false, ready: false };
36
41
  }
37
42
  say(`✅ 로컬 브랜치 '${deployBranch}' 생성 완료 (checkout은 하지 않았습니다)`);
38
43
  // #481 — push 질문에 브랜치명 명시 ("어느 브랜치를 push하는지" 불명확 방지)
39
44
  const up = await io.confirm({ message: `원격(origin)에 '${deployBranch}' 브랜치도 push할까요?`, initialValue: true });
40
- if (up !== true) return { created: true, pushed: false };
45
+ if (up !== true) return { created: true, pushed: false, ready: true };
41
46
  if (pushBranch(targetRoot, deployBranch)) {
42
47
  say(`✅ origin/${deployBranch} push 완료`);
43
- return { created: true, pushed: true };
48
+ return { created: true, pushed: true, ready: true };
44
49
  }
45
50
  say(`⚠️ push 실패(자격/네트워크) — 직접 실행해주세요: git push -u origin ${deployBranch}`);
46
- return { created: true, pushed: false };
51
+ return { created: true, pushed: false, ready: true };
47
52
  }
48
53
 
49
54
  // 재노출 — 파서 본체는 version-yml.js에 있다 (순환 import 방지: options-ask → version-yml 방향만 허용)
@@ -115,6 +120,8 @@ export async function askAllOptionalWorkflows({
115
120
  let changelogProvider = current.changelogProvider ?? null;
116
121
  let changelogBaseUrl = current.changelogBaseUrl ?? null;
117
122
  let deployBranch = current.deployBranch ?? null; // #456 릴리스 PR head 브랜치
123
+ let deployBranchReady = null; // #490 — 이번 실행에서 브랜치 존재/생성이 확인됐는지 (null=확인 안 함)
124
+ let deployBranchCreated = null; // #493 — 이번 실행에서 마법사가 직접 생성했는지 (가이드 기록용)
118
125
  let intent = current.intent ?? null; // #485 프로젝트 성격 (app/library/both/none/manual)
119
126
 
120
127
  // basic 단독 타입은 서버 배포도 라이브러리 publish도 개념상 성립하지 않는다.
@@ -328,7 +335,10 @@ export async function askAllOptionalWorkflows({
328
335
  deployBranch = (typeof ans === "string" && !isCancel(ans) && ans.trim()) ? ans.trim() : (deployBranch ?? "develop");
329
336
  say(`개발(릴리스 소스) 브랜치: ${deployBranch}`);
330
337
  // 브랜치 존재 확인 + 생성 제안 (#477) — 없으면 릴리스 파이프라인이 조용히 놀게 된다
331
- await ensureDeployBranch({ targetRoot, deployBranch, defaultBranch, io, say });
338
+ // #490 — 결과(ready)를 완료 요약에 전달해 이미 생성/확인한 브랜치를 재지시하지 않는다
339
+ const br = await ensureDeployBranch({ targetRoot, deployBranch, defaultBranch, io, say });
340
+ deployBranchReady = br.ready;
341
+ deployBranchCreated = br.created;
332
342
  }
333
343
  }
334
344
 
@@ -349,6 +359,9 @@ export async function askAllOptionalWorkflows({
349
359
  changelogProvider: changelogProvider ?? "github-ai",
350
360
  changelogBaseUrl: changelogBaseUrl ?? "",
351
361
  deployBranch: deployBranch ?? "develop",
362
+ deployBranchReady, // #490 — true=존재/생성 확인됨, false=거절/실패, null=확인 안 함
363
+ deployBranchCreated, // #493 — true=이번 실행에서 마법사가 생성, null=확인 안 함
364
+
352
365
  // #485 intent — 확정값 우선, 없으면 최종 deploy/publish에서 역추론(basic·비대화형 경로 보정)
353
366
  intent: intent ?? inferIntent(finalDeploy, finalPublish) ?? "manual",
354
367
  };
@@ -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
+ }
@@ -41,6 +41,13 @@ export function resolveToken(name, type, resolvers = {}) {
41
41
  return typeof fn === "function" ? (fn(type) ?? "") : "";
42
42
  }
43
43
 
44
+ // 잔여 전역 토큰 치환 (.sh 3347~3351) — 파일 본문·수집값(#489)·카드 표시가 전부 같은 규칙을 쓴다.
45
+ // 파일에 실제 써지는 값과 version.yml deploy 블록 기억값이 어긋나지 않게 하는 단일 지점.
46
+ export function resolveGlobalTokens(s, repoName = "") {
47
+ if (typeof s !== "string" || (!s.includes("__PROJECT_NAME__") && !s.includes("__APP_ARTIFACT_NAME__"))) return s;
48
+ return s.replaceAll("__PROJECT_NAME__", repoName).replaceAll("__APP_ARTIFACT_NAME__", repoName);
49
+ }
50
+
44
51
  // 파일 전체 치환 (configure_workflow_env 등가).
45
52
  // content: 원본 워크플로우 텍스트. 반환: 치환된 텍스트.
46
53
  // opts:
@@ -50,8 +57,9 @@ export function resolveToken(name, type, resolvers = {}) {
50
57
  // resolvers - resolveToken용
51
58
  // repoName - __PROJECT_NAME__/__APP_ARTIFACT_NAME__ 치환값
52
59
  // projectPath - paths-anchor 치환용 ('.'이면 anchor 미변경)
60
+ // collectSubs - 배열이면 치환 1건당 {key, action, before, after}를 push (#494 트레이스용)
53
61
  export function substituteEnv(content, opts = {}) {
54
- 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;
55
63
  if (!content.includes("@wizard")) return content;
56
64
 
57
65
  // CRLF 안전: EOL을 분리해 LF 기준으로 파싱·치환하고, 원래 EOL 스타일을 복원한다.
@@ -70,16 +78,21 @@ export function substituteEnv(content, opts = {}) {
70
78
  if (chosen != null && chosen !== "" && !useDefaults) val = chosen;
71
79
  else val = def;
72
80
  // ask 키만 수집 (.sh wf_deploy_set — auto는 저장 안 함). deploy 블록용.
73
- if (collectAsks) collectAsks.set(p.key, val);
81
+ // #489 파일 본문은 아래 전역 토큰 치환을 거치므로 수집값도 동일 치환해
82
+ // version.yml deploy 블록이 설치본과 항상 바이트 일치하게 한다.
83
+ if (collectAsks) collectAsks.set(p.key, resolveGlobalTokens(val, repoName));
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) });
74
89
  }
75
90
  lines[i] = setEnvLine(lines[i], p.key, val);
76
91
  }
77
92
  let out = lines.join(usesCRLF ? "\r\n" : "\n");
78
93
 
79
94
  // 잔여 전역 토큰 (.sh 3347~3351)
80
- if (out.includes("__PROJECT_NAME__") || out.includes("__APP_ARTIFACT_NAME__")) {
81
- out = out.replaceAll("__PROJECT_NAME__", repoName).replaceAll("__APP_ARTIFACT_NAME__", repoName);
82
- }
95
+ out = resolveGlobalTokens(out, repoName);
83
96
 
84
97
  // paths-anchor (.sh 3353~3360): 경로가 '.'이 아니면 주석 라인 전체를 paths 라인으로 교체
85
98
  if (PATHS_ANCHOR_RE.test(out) && projectPath && projectPath !== ".") {
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;
@@ -8,7 +8,7 @@ import { readFileSync } from "node:fs";
8
8
  import { stdin, stderr } from "node:process";
9
9
  import { PATHS } from "../core/paths.js";
10
10
  import { exists, listYamlFiles } from "../core/fsutil.js";
11
- import { parseWizardLine, resolveToken } from "../core/wizard-env.js";
11
+ import { parseWizardLine, resolveToken, resolveGlobalTokens } from "../core/wizard-env.js";
12
12
  import { loadWizardPrompts, wfField, workflowDisplayName } from "../core/wizard-labels.js";
13
13
  import * as engine from "./readline-engine.js";
14
14
 
@@ -39,7 +39,7 @@ export function scopeString(usages = []) {
39
39
  // 반환: { keys:[], defaults:Map<key,default>, typeDefaults:Map<"type|key",default>,
40
40
  // usages:Map<key,[{type,workflowName}]> }
41
41
  export function collectAsks(tempDir, types = [], opts = {}) {
42
- const { resolvers = {}, deployTarget = "docker-ssh", publishTargets = [], prompts = null } = opts;
42
+ const { resolvers = {}, deployTarget = "docker-ssh", publishTargets = [], prompts = null, repoName = "" } = opts;
43
43
  const baseDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
44
44
  const keys = [];
45
45
  const defaults = new Map();
@@ -64,9 +64,13 @@ export function collectAsks(tempDir, types = [], opts = {}) {
64
64
  const p = parseWizardLine(line); // KEY 정규식 [A-Z_]+ (.sh와 동일)
65
65
  if (!p || p.action !== "ask") continue;
66
66
  // 타입별 기본값: @접두면 resolver 해석, 아니면 리터럴 (.sh _type_default 등가)
67
- const typeDefault = p.arg.startsWith("@")
68
- ? resolveToken(p.arg.slice(1), type, resolvers)
69
- : p.arg;
67
+ // #489 __PROJECT_NAME__ 등 전역 토큰은 카드 표시 전에 미리 해석한다.
68
+ // 설치본(파일 복사 시 전역 치환) 동일한 값을 보여주지 않으면
69
+ // 사용자가 "리터럴이 그대로 박히나?" 오해하게 된다.
70
+ const typeDefault = resolveGlobalTokens(
71
+ p.arg.startsWith("@") ? resolveToken(p.arg.slice(1), type, resolvers) : p.arg,
72
+ repoName,
73
+ );
70
74
  typeDefaults.set(`${type}|${p.key}`, typeDefault);
71
75
  if (!defaults.has(p.key)) { keys.push(p.key); defaults.set(p.key, typeDefault); }
72
76
  const list = usages.get(p.key) || [];
@@ -140,7 +144,7 @@ export async function promptEnvPlan({
140
144
  deployTarget = "docker-ssh", publishTargets = [], targetRoot = ".", repoName = "", log = defaultLog,
141
145
  } = {}) {
142
146
  const prompts = loadWizardPrompts(targetRoot, tempDir);
143
- const asks = collectAsks(tempDir, types, { resolvers, deployTarget, publishTargets, prompts });
147
+ const asks = collectAsks(tempDir, types, { resolvers, deployTarget, publishTargets, prompts, repoName });
144
148
  const defaults = asks.defaults;
145
149
 
146
150
  // 수집 키 0개 → 질문 자체가 없음 (.sh `[ ${#WF_ASK_KEYS[@]} -eq 0 ]` 등가)
package/src/ui/summary.js CHANGED
@@ -9,6 +9,7 @@ const SEPARATOR = "────────────────────
9
9
  export function printSummary(ctx, targetRoot = ".") {
10
10
  const { mode, types = [], version = "", counters = {} } = ctx || {};
11
11
  const deployBranchName = ctx?.deployBranch || "develop"; // #477 — 설정된 배포 브랜치명으로 안내
12
+ const deployBranchReady = ctx?.deployBranchReady === true; // #490 — 마법사가 이번 실행에서 존재/생성 확인함
12
13
  const err = (s = "") => process.stderr.write(`${s}\n`);
13
14
  // 색상은 TTY일 때만 (.sh YELLOW/CYAN/NC 등가)
14
15
  const isTty = !!process.stderr.isTTY;
@@ -127,6 +128,11 @@ export function printSummary(ctx, targetRoot = ".") {
127
128
 
128
129
  err(" 📖 TEMPLATE REPO: https://github.com/Cassiiopeia/projectops");
129
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
+ }
130
136
  err("");
131
137
 
132
138
  // 필수 3가지 작업 안내 (.sh L5605~5625 — 원문 유지)
@@ -139,8 +145,13 @@ export function printSummary(ctx, targetRoot = ".") {
139
145
  err(" → Secret Name: _GITHUB_PAT_TOKEN");
140
146
  err(" → Scopes: repo, workflow");
141
147
  err("");
142
- err(` 2️⃣ ${deployBranchName} 브랜치 생성 (아직 없다면)`);
143
- err(` → git checkout -b ${deployBranchName} && git push -u origin ${deployBranchName}`);
148
+ // #490 — 마법사가 브랜치를 직접 생성(또는 존재 확인)했으면 같은 작업을 재지시하지 않는다
149
+ if (deployBranchReady) {
150
+ err(` 2️⃣ ✅ ${deployBranchName} 브랜치 준비 완료 — 마법사가 확인·생성했습니다 (추가 작업 불필요)`);
151
+ } else {
152
+ err(` 2️⃣ ${deployBranchName} 브랜치 생성 (아직 없다면)`);
153
+ err(` → git checkout -b ${deployBranchName} && git push -u origin ${deployBranchName}`);
154
+ }
144
155
  err("");
145
156
  err(" 3️⃣ CodeRabbit 활성화 (코드 리뷰를 켰다면)");
146
157
  err(" → https://coderabbit.ai 로그인 → GitHub 앱 설치 → 이 저장소에 접근 권한(grant access) 부여");