projectops 4.5.0 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  > 이슈 등록부터 커밋, 보고서, 배포까지. 개발자는 코드만 작성하세요.
8
8
 
9
9
  <!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
10
- ## 최신 버전 : v4.4.1 (2026-09-16)
10
+ ## 최신 버전 : v4.5.0 (2026-09-16)
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.5.0",
3
+ "version": "4.6.0",
4
4
  "description": "ProjectOps — 완전 자동화 GitHub 프로젝트 관리 템플릿 통합 CLI",
5
5
  "keywords": [
6
6
  "devops",
@@ -33,46 +33,63 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
33
33
 
34
34
  // 3. 워크플로우 복사 (+ env 치환) — deploy 블록에 쓸 ask 값을 수집한다.
35
35
  // hooks.decisions: 대화형 충돌 3지선 결정 Map (미지정=skip — 현행 force 동작)
36
- const wfCounters = copyWorkflows(context, tempDir, targetRoot, hooks);
36
+ const step = (name, fn, d) => (hooks.trace ? hooks.trace.step(name, fn, d) : fn());
37
+ const wfCounters = step("copy-workflows", () => copyWorkflows(context, tempDir, targetRoot, hooks),
38
+ { types, deploy: deployTarget, publish: publishTargets });
37
39
  const deployValues = wfCounters.deployValues || new Map(); // Map<type, Map<key,value>>
38
40
 
39
41
  // 1. version.yml 생성 (전체 재생성 — metadata → deploy → template 순, .sh 최종형과 동일)
40
- writeText(join(targetRoot, PATHS.versionFile),
42
+ step("write-version-yml", () => writeText(join(targetRoot, PATHS.versionFile),
41
43
  buildVersionYml({
42
44
  version, types, paths, pathMarkers, branch, deployBranch, versionCode, now, today,
43
45
  deployValues,
44
46
  templateOptions: { templateVersion, deployTarget, publishTargets, includeSecretBackup, optionsDate: today,
45
47
  changelogProvider, changelogBaseUrl, codeReviewCoderabbit, intent, mode: "full", semverAuto, appRelease },
46
- }));
48
+ })), { version, versionCode });
47
49
 
48
50
  // 2. README 버전 섹션
49
- addVersionSectionToReadme(version, targetRoot);
51
+ step("update-readme", () => addVersionSectionToReadme(version, targetRoot), { version });
50
52
 
51
53
  // 5. scripts / config
52
- copyScripts(tempDir, targetRoot);
53
- copyConfigFolder(tempDir, targetRoot);
54
+ // 워크플로우 밖 영역도 기록한다 (#561) — 종전에는 "내 스크립트가 갱신됐나"를
55
+ // 로그만 보고 알 수 없었다.
56
+ step("copy-scripts", () => copyScripts(tempDir, targetRoot));
57
+ step("copy-config", () => copyConfigFolder(tempDir, targetRoot));
54
58
 
55
59
  // 6. util (타입별)
56
- for (const t of types) copyUtilModules(tempDir, t, { force }, targetRoot);
60
+ for (const t of types) step("copy-util", () => copyUtilModules(tempDir, t, { force }, targetRoot), { type: t });
57
61
 
58
62
  // 7. issue / discussion 템플릿
59
- copyIssueTemplates(tempDir, targetRoot);
60
- copyDiscussionTemplates(tempDir, targetRoot);
63
+ step("copy-templates", () => {
64
+ copyIssueTemplates(tempDir, targetRoot);
65
+ copyDiscussionTemplates(tempDir, targetRoot);
66
+ });
61
67
 
62
68
  // 8. coderabbit / gitignore / setup guide
63
69
  // CodeRabbit 코드리뷰 미사용 선택(#457)이면 .coderabbit.yaml을 복사하지 않는다.
64
- copyCoderabbit(tempDir, { force, enabled: codeReviewCoderabbit }, targetRoot);
65
- ensureGitignore(targetRoot);
66
- copySetupGuide(tempDir, targetRoot);
70
+ step("copy-coderabbit", () => copyCoderabbit(tempDir, { force, enabled: codeReviewCoderabbit }, targetRoot),
71
+ { enabled: codeReviewCoderabbit });
72
+ step("ensure-gitignore", () => ensureGitignore(targetRoot));
73
+ step("copy-setup-guide", () => copySetupGuide(tempDir, targetRoot));
67
74
 
68
75
  // 9. 설치 후 검증 (#549) — 디스크에 쓰인 최종 결과물을 다시 읽는다.
69
76
  // 치환은 파일 단위로 흩어져 일어나고 auto 토큰은 resolver 결과에 의존하므로,
70
77
  // 최종 내용을 보는 것이 실제 배포될 것과 같은 것을 보는 유일한 방법이다.
71
- const verification = verifyInstall(targetRoot);
72
- hooks.trace?.emit?.("verify", "scan", {
78
+ const verification = step("verify-install", () => verifyInstall(targetRoot));
79
+ // detail 키 이름 주의: run-trace 민감값 가드가 pat|token|secret|password|credential을
80
+ // 키에서 걸러낸다(#494). 여기서 다루는 값은 비밀이 아니라 "치환 플레이스홀더 이름"과
81
+ // "등록이 필요한 키 이름"이라 가드에 걸리지 않는 이름을 쓴다 — 가드 자체는 우회하지 않는다.
82
+ hooks.trace?.event("verify", "scan", "", {
73
83
  unresolved: verification.unresolved.length,
74
- secrets: verification.secrets.size,
84
+ requiredKeys: verification.secrets.size,
75
85
  });
86
+ // 미치환 값은 배포 시점에 실패할 자리다 — 어느 파일 몇 번째 줄인지 로그에 남긴다.
87
+ for (const u of verification.unresolved) {
88
+ hooks.trace?.event("verify", "unresolved", u.filename, { line: u.line, placeholder: u.token });
89
+ }
90
+ for (const [name, users] of verification.secrets) {
91
+ hooks.trace?.event("verify", "required-key", name, { workflows: users });
92
+ }
76
93
 
77
94
  return { workflows: wfCounters, verification };
78
95
  }
@@ -14,7 +14,7 @@ import { runMigrations } from "../core/migrations/index.js";
14
14
  import { detectOrphanWorkflows, applyOrphanCleanup } from "../core/orphan-workflows.js";
15
15
  import { resolveProjectPaths, filterExcludedTypes } from "../core/paths-resolve.js";
16
16
  import { askAllOptionalWorkflows, OPTION_AXES, applicableTargets } from "../core/options-ask.js";
17
- import { createRunTrace } from "../core/run-trace.js";
17
+ import { createRunTrace, MIGRATION_DIR } from "../core/run-trace.js";
18
18
  import { appendGuideEntry } from "../core/migration-guide.js";
19
19
  import { promptEnvPlan } from "../ui/env-plan.js";
20
20
  import { listWorkflowConflicts } from "../core/copy/workflows.js";
@@ -36,11 +36,17 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
36
36
  const tempDir = join(cwd, PATHS.tempDir);
37
37
  // 실행 트레이스 (#494) — 실제 CLI(io=prompts)에서만 터미널 미러를 켠다 (테스트 스텁 io는 이벤트만).
38
38
  const trace = createRunTrace();
39
+ // Ctrl+C로 끊어도 기록이 남는다 (#561) — 대화형은 사람이 중간에 끊는 일이 잦다.
40
+ const disarmSignals = trace.armSignals({ targetRoot: cwd, now: clock?.now || "" });
41
+ // finally에서 기록할 때 쓰는 값 — try 안에서 확정되기 전에 취소될 수 있으므로 바깥에 둔다 (#561).
42
+ let traceFrom = "";
43
+ let traceTo = "";
39
44
  if (io === prompts) trace.mirrorStart();
40
45
  try {
41
46
  // 템플릿 먼저 획득 — 배너에 실제 템플릿 버전을 표시 (.sh는 원격 version.yml fetch L4270~4280 등가)
42
- acquireTemplate({ tempDir, source });
47
+ trace.step("acquire-template", () => acquireTemplate({ tempDir, source }), { source: source?.type || "git" });
43
48
  const templateVersion = readTemplateVersion(tempDir);
49
+ traceTo = templateVersion;
44
50
 
45
51
  // 층1 — 시작 배너 (#446 확정 시안 A). 스텁엔 banner 없음 → intro 폴백.
46
52
  if (io.banner) io.banner({ version: templateVersion, modeLabel: "대화형 통합 마법사" });
@@ -49,6 +55,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
49
55
  // 기존 version.yml — version/version_code/paths/옵션 보존의 단일 진실 (.sh SSoT L2208~2239)
50
56
  const vyPath = join(cwd, "version.yml");
51
57
  const existing = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")) : null;
58
+ traceFrom = existing?.templateVersion || "";
52
59
 
53
60
  // 층4 — IDE Skills 현재 상태 · 층5 — 신규/업데이트 판별 (#446)
54
61
  io.ideStatus?.();
@@ -56,8 +63,11 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
56
63
 
57
64
  // 1) 모드 선택 — 기존 통합 레포면 업데이트 항목을 맨 위에 노출 (#502)
58
65
  const updateInfo = existing?.templateVersion ? { from: existing.templateVersion, to: templateVersion } : null;
66
+ // 실행 경계(#561) — 대화형은 사람이 무엇을 골랐는지가 핵심 기록이다.
67
+ trace.event("run", "start", "interactive", { templateVersion, isUpdate: !!updateInfo });
59
68
  const picked = await io.selectMode(updateInfo ? { update: updateInfo } : {});
60
- if (picked === CANCEL || picked == null) { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
69
+ trace.event("prompt", "mode", String(picked ?? ""), { update: !!updateInfo });
70
+ if (picked === CANCEL || picked == null) { trace.event("run", "cancelled", "mode-select", { reason: "user-cancel" }); io.cancelMessage?.("설치를 취소했습니다."); return 0; }
61
71
  // 업데이트 모드(#502): 저장된 통합 범위(templateMode, 없으면 full)를 재실행하고
62
72
  // 이하 updateRun 분기가 질문을 최소화한다 ("저장된 설정 그대로 반영"이 계약).
63
73
  const updateRun = picked === "update";
@@ -65,12 +75,12 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
65
75
 
66
76
  // Breaking Changes 게이트 (.sh execute_integration L4415~4420 — 모든 모드 공통, 대화형은 확인 질문)
67
77
  let breakingReport = null; // #493 — 통과 구간 항목을 가이드에 조치 방법 전문으로 임베드
68
- const proceed = await runBreakingCheck({
78
+ const proceed = await trace.stepAsync("breaking-check", () => runBreakingCheck({
69
79
  cwd, tempDir, templateVersion,
70
80
  askYesNo: (msg, def) => io.askYesNo(msg, def),
71
81
  onItems: (items) => { breakingReport = items; },
72
- });
73
- if (!proceed) { io.cancelMessage?.("통합을 안전하게 취소했습니다."); return 0; }
82
+ }));
83
+ if (!proceed) { trace.event("run", "cancelled", "breaking-gate", { reason: "user-declined" }); io.cancelMessage?.("통합을 안전하게 취소했습니다."); return 0; }
74
84
 
75
85
  // skills 모드 — IDE 스킬 설치 (템플릿 통합 없음). 대화형으로 실행.
76
86
  if (mode === "skills") {
@@ -172,7 +182,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
172
182
  io.note?.(summarize({ mode, types, version, branch, deployTarget, publishTargets, includeSecretBackup, showOptional, changelogProvider, codeReviewCoderabbit }), "프로젝트 분석 결과");
173
183
  }
174
184
  const choice = await io.confirmProjectMenu();
175
- if (choice === "cancel") { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
185
+ if (choice === "cancel") { trace.event("run", "cancelled", "confirm-loop", { reason: "user-cancel" }); io.cancelMessage?.("설치를 취소했습니다."); return 0; }
176
186
  if (isCancel(choice) || choice == null) continue; // ESC = 머무르기 (루프 재출력)
177
187
  if (choice === "continue") { confirmed = true; break; }
178
188
  // edit 루프
@@ -183,6 +193,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
183
193
  if (isCancel(what) || what === "done") { editing = false; break; }
184
194
  if (what === "type") {
185
195
  const t = await io.selectTypes(types);
196
+ trace.event("prompt", "types", (Array.isArray(t) ? t : []).join(",") || "", { before: types });
186
197
  if (!isCancel(t) && Array.isArray(t) && t.length) {
187
198
  // 타입 집합이 실제로 바뀌면 경로 재해석 대상으로 초기화 (.sh L1984~1992 — 정렬 집합 비교)
188
199
  const oldSorted = [...types].sort().join(",");
@@ -264,6 +275,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
264
275
  if (conflicts.length) {
265
276
  io.note?.(conflicts.map((c) => `• ${c.filename}`).join("\n"), `♻️ 템플릿이 갱신된 워크플로우 ${conflicts.length}개`);
266
277
  const yes = await io.askYesNo(`위 ${conflicts.length}개를 .bak 백업 후 새 버전으로 교체할까요? (기존 설정값은 유지됩니다)`, true);
278
+ trace.event("prompt", "conflict-bulk", yes ? "backup" : "skip", { count: conflicts.length, files: conflicts.map((c) => c.filename) });
267
279
  const decision = yes === true ? "backup" : "skip";
268
280
  updateDecisions = new Map();
269
281
  for (const { filename } of conflicts) updateDecisions.set(filename, decision);
@@ -336,6 +348,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
336
348
  `🧹 선택되지 않은 타입의 워크플로우 ${orphans.length}개 발견`,
337
349
  );
338
350
  const yes = await io.askYesNo(`위 ${orphans.length}개를 정리할까요? (.bak 무해화 — 복원 가능)`, true);
351
+ trace.event("prompt", "orphan-cleanup", yes ? "clean" : "keep", { count: orphans.length });
339
352
  if (yes === true) {
340
353
  const results = applyOrphanCleanup(cwd, orphans);
341
354
  const ok = results.filter((r) => r.action === "bak");
@@ -350,9 +363,9 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
350
363
  }
351
364
 
352
365
  let result = null;
353
- if (mode === "full") result = runFull(ctx, tempDir, cwd, { ...hooks, trace });
354
- else if (mode === "version") result = runVersion(ctx, tempDir, cwd);
355
- else if (mode === "workflows") result = runWorkflows(ctx, tempDir, cwd, { ...hooks, trace });
366
+ if (mode === "full") result = trace.step("install-full", () => runFull(ctx, tempDir, cwd, { ...hooks, trace }));
367
+ else if (mode === "version") result = trace.step("install-version", () => runVersion(ctx, tempDir, cwd));
368
+ else if (mode === "workflows") result = trace.step("install-workflows", () => runWorkflows(ctx, tempDir, cwd, { ...hooks, trace }));
356
369
 
357
370
  // 통합 후 IDE 스킬 제안 (.sh L4557 offer_ide_tools_install — 사전 질문 게이트, 기본 N)
358
371
  // 업데이트 모드(#502): 이미 설치된 IDE 스킬만 질문 없이 최신화 (미설치 IDE는 건드리지 않음)
@@ -365,12 +378,16 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
365
378
 
366
379
  // 마이그레이션 기록 (#493/#494) — Layer 2/3 트레이스 파일 + Layer 1 가이드 엔트리 (full/workflows만)
367
380
  let migrationGuidePath = null;
368
- if (mode === "full" || mode === "workflows") {
369
- const files = trace.write({ targetRoot: cwd, fromVersion: existing?.templateVersion || "", toVersion: templateVersion, now });
381
+ const recordArtifacts = mode === "full" || mode === "workflows";
382
+ // 경로는 먼저 계산하고 실제 쓰기는 완료 화면 뒤로 미룬다 요약까지 터미널 미러에 담기 위함 (#561)
383
+ const files = recordArtifacts
384
+ ? trace.paths({ fromVersion: existing?.templateVersion || "", toVersion: templateVersion, now })
385
+ : null;
386
+ if (recordArtifacts) {
370
387
  migrationGuidePath = appendGuideEntry(cwd, {
371
388
  now, mode, types, repoName,
372
389
  templateFrom: existing?.templateVersion || "", templateTo: templateVersion,
373
- options: { deploy: deployTarget, publish: publishTargets, secretBackup: includeSecretBackup, coderabbit: codeReviewCoderabbit, changelogProvider, intent, semverAuto },
390
+ options: { deploy: deployTarget, publish: publishTargets, secretBackup: includeSecretBackup, coderabbit: codeReviewCoderabbit, changelogProvider, intent, semverAuto , appRelease },
374
391
  branches: { defaultBranch: branch, deployBranch, ready: deployBranchReady, created: deployBranchCreated },
375
392
  breaking: breakingReport, migrations: migrationsResult, orphans: orphanReport,
376
393
  events: trace.events, counters: { skipped: result?.workflows?.skipped ?? 0 },
@@ -382,11 +399,35 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
382
399
  io.summary?.({
383
400
  mode, types, version, deployBranch, deployBranchReady, migrationGuidePath,
384
401
  counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
402
+ verification: result?.verification, // #549 설치 검증 결과
403
+ logDir: files ? MIGRATION_DIR : null, // #561 기록 위치 안내
404
+ logFile: files?.logFile ?? null,
405
+ traceFile: files?.traceFile ?? null,
385
406
  }, cwd);
386
407
  io.outro?.(`통합 완료 — ${mode} 모드로 설치했습니다.`);
408
+
409
+ // 완료 화면까지 캡처한 뒤 종료하고 기록한다 (#561)
410
+ trace.event("run", "end", mode || "", {
411
+ workflowsCopied: result?.workflows?.copied ?? 0,
412
+ workflowsSkipped: result?.workflows?.skipped ?? 0,
413
+ });
414
+ if (recordArtifacts) {
415
+ trace.finalize({ targetRoot: cwd, fromVersion: existing?.templateVersion || "", toVersion: templateVersion, now });
416
+ } else {
417
+ trace.mirrorStop();
418
+ }
387
419
  return 0;
420
+ } catch (err) {
421
+ trace.event("run", "error", "interactive", {
422
+ message: err?.message || String(err),
423
+ stack: String(err?.stack || "").split("\n").slice(0, 3).join(" | "),
424
+ });
425
+ throw err;
388
426
  } finally {
389
- trace.mirrorStop();
427
+ // 어떤 경로로 빠져나가든 기록을 남긴다 (#561) — 중간 취소·예외 포함.
428
+ // finalize는 멱등이라 정상 경로에서 이미 호출됐으면 여기서는 아무 일도 하지 않는다.
429
+ trace.finalize({ targetRoot: cwd, fromVersion: traceFrom, toVersion: traceTo, now: clock?.now || "" });
430
+ disarmSignals();
390
431
  remove(tempDir);
391
432
  }
392
433
  }
@@ -35,10 +35,20 @@ export function runWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
35
35
 
36
36
  // 설치 후 검증 (#549) — 워크플로우만 설치하는 모드라 오히려 더 필요하다.
37
37
  const verification = verifyInstall(targetRoot);
38
- hooks.trace?.emit?.("verify", "scan", {
38
+ // detail 키 이름 주의: run-trace 민감값 가드가 pat|token|secret|password|credential을
39
+ // 키에서 걸러낸다(#494). 여기서 다루는 값은 비밀이 아니라 "치환 플레이스홀더 이름"과
40
+ // "등록이 필요한 키 이름"이라 가드에 걸리지 않는 이름을 쓴다 — 가드 자체는 우회하지 않는다.
41
+ hooks.trace?.event("verify", "scan", "", {
39
42
  unresolved: verification.unresolved.length,
40
- secrets: verification.secrets.size,
43
+ requiredKeys: verification.secrets.size,
41
44
  });
45
+ // 미치환 값은 배포 시점에 실패할 자리다 — 어느 파일 몇 번째 줄인지 로그에 남긴다.
46
+ for (const u of verification.unresolved) {
47
+ hooks.trace?.event("verify", "unresolved", u.filename, { line: u.line, placeholder: u.token });
48
+ }
49
+ for (const [name, users] of verification.secrets) {
50
+ hooks.trace?.event("verify", "required-key", name, { workflows: users });
51
+ }
42
52
 
43
53
  return { workflows: wf, verification };
44
54
  }
@@ -87,26 +87,52 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
87
87
  // values/useDefaults는 치환 경로에서만 의미 (isUnchanged는 내부에서 useDefaults:true 강제 — 가상 비교 무손상)
88
88
  const envOptsFor = (type) => ({ type, projectPath: paths.get(type) || ".", repoName, resolvers, values: envValues, useDefaults: envUseDefaults, branches });
89
89
 
90
- // (1) common — unchanged면 스킵, 아니면 무조건 덮어쓰기
90
+ // (1) common — 타입별과 동일한 판정·보호를 받는다 (#560).
91
+ //
92
+ // 종전에는 "unchanged면 스킵, 아니면 무조건 덮어쓰기"였다. 그런데 릴리스 파이프라인처럼
93
+ // 프로젝트마다 뒤에 붙일 일이 다른 워크플로우는 common에 있어도 사용자가 고쳐 쓸 수밖에
94
+ // 없다. 고치지 않고는 쓸 수 없는 파일을, 고치면 백업도 없이 날아가는 규칙으로 관리하고
95
+ // 있었다. common/deploy조차 .bak을 남기는데 본체만 아무것도 남기지 않았다.
91
96
  const commonDir = join(projectTypesDir, "common");
92
97
  if (exists(commonDir)) {
98
+ const commonEnv = envOptsFor("common");
99
+ const commonClass = classify(commonDir, workflowsDir, commonEnv, baseline);
93
100
  for (const filename of listYamlFiles(commonDir)) {
94
101
  // #491 — util 동기화 워크플로우는 util 모듈이 있(게 되)는 레포에만 복사
95
102
  if (filename === UTIL_VERSION_SYNC && !utilSyncApplies(tempDir, targetRoot, types)) {
96
103
  trace?.event("copy", "excluded", filename, { reason: "util-modules-absent" });
97
104
  continue;
98
105
  }
99
- const src = join(commonDir, filename);
100
- const dst = join(workflowsDir, filename);
101
- if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOptsFor("common"))) {
106
+ if (commonClass.unchanged.includes(filename)) {
102
107
  counters.skipped++;
103
108
  trace?.event("copy", "skipped-unchanged", filename, { group: "common" });
104
109
  continue;
105
110
  }
106
- copyFileSync(src, dst);
107
- counters.copied++;
108
- counters.copiedFiles.push(filename);
109
- trace?.event("copy", "copied", filename, { group: "common" });
111
+ // 사용자가 손댄 적 없고 템플릿만 바뀐 파일 — 물어볼 것 없이 최신으로 올린다(종전과 동일).
112
+ if (commonClass.newFiles.includes(filename) || commonClass.upstream.includes(filename)) {
113
+ copyFileSync(join(commonDir, filename), join(workflowsDir, filename));
114
+ counters.copied++;
115
+ counters.copiedFiles.push(filename);
116
+ trace?.event("copy", commonClass.upstream.includes(filename) ? "upstream-updated" : "copied",
117
+ filename, { group: "common" });
118
+ continue;
119
+ }
120
+ // 여기부터는 changed — "지금 템플릿 렌더 결과 ≠ 설치본"이다. 둘로 갈린다.
121
+ const dst = join(workflowsDir, filename);
122
+ const modified = isUserModified(baseline, filename, readFileSync(dst, "utf8"));
123
+ if (modified === null) {
124
+ // 판정 불가(기준점 없음 = 기존 통합 레포). common의 종전 계약은 "항상 최신으로 갱신"이라
125
+ // 여기서 skip하면 기존 레포가 업데이트를 영영 못 받는다. 계약은 지키되 되돌릴 수단을
126
+ // 남긴다 — 덮어쓰기 전 .bak. (#560: common/deploy조차 .bak을 남기는데 본체만 없었다)
127
+ renameSync(dst, dst + ".bak");
128
+ copyFileSync(join(commonDir, filename), dst);
129
+ counters.copied++;
130
+ counters.copiedFiles.push(filename);
131
+ trace?.event("copy", "replaced-bak", filename, { group: "common", reason: "baseline-absent" });
132
+ continue;
133
+ }
134
+ // 사용자가 손댄 것이 확인된 파일 — 결정에 따라 처리(미지정이면 유지).
135
+ applyDecision(decisions.get(filename), commonDir, workflowsDir, filename, counters, trace);
110
136
  }
111
137
  }
112
138
 
@@ -170,14 +196,14 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
170
196
  // (7) 기준점 기록 (#557) — 다음 업데이트가 "누가 바꿨는지"를 가릴 근거.
171
197
  // 이번에 실제로 쓴 파일만 installed를 갱신한다. 유지(skip)한 파일에 우리가 쓴 것처럼
172
198
  // 기록하면 다음 업데이트에서 사용자 수정이 조용히 덮인다.
173
- recordBaseline(workflowsDir, targetRoot, counters, baseline, context.templateVersion, context.now);
199
+ recordBaseline(workflowsDir, targetRoot, counters, baseline, context.templateVersion, context.now, trace);
174
200
 
175
201
  return counters;
176
202
  }
177
203
 
178
204
  // 설치 직후의 디스크 내용을 기준점으로 남긴다. 실패해도 통합을 막지 않는다 —
179
205
  // 기준점이 없으면 다음 업데이트가 종전 2-way 판정으로 폴백할 뿐이다.
180
- function recordBaseline(workflowsDir, targetRoot, counters, previous, templateVersion, now) {
206
+ function recordBaseline(workflowsDir, targetRoot, counters, previous, templateVersion, now, trace = null) {
181
207
  try {
182
208
  const entries = new Map();
183
209
  for (const f of counters.copiedFiles || []) {
@@ -188,6 +214,7 @@ function recordBaseline(workflowsDir, targetRoot, counters, previous, templateVe
188
214
  entries.set(f, { installed: sha256(content), rendered: sha256(content) });
189
215
  }
190
216
  if (entries.size === 0 && previous) return; // 새로 쓴 게 없으면 기존 기준점을 건드리지 않는다
217
+ trace?.event("baseline", "recorded", "", { files: entries.size, hadPrevious: !!previous });
191
218
  writeBaseline(targetRoot, {
192
219
  templateVersion: templateVersion || "unknown",
193
220
  installedAt: now || "",
@@ -233,8 +260,22 @@ export function listWorkflowConflicts(context, tempDir, targetRoot = ".") {
233
260
  // 설치 시점 기준점(#557) — 없으면 null이고 classify가 종전 2-way로 폴백한다.
234
261
  const baseline = readBaseline(targetRoot);
235
262
  const projectTypesDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
236
- const conflicts = []; // [{ filename, type }] — 엔진 처리 순서와 동일 (타입 순회 → 직하위 → server-deploy)
263
+ const conflicts = []; // [{ filename, type }] — 엔진 처리 순서와 동일 (common → 타입 순회 → server-deploy)
237
264
  const branches = { defaultBranch: branch || "main", deployBranch: deployBranch || "develop" }; // #477 — 엔진과 동일 기준
265
+
266
+ // common (#560) — 사용자가 손댄 것이 "확인된" 파일만 질문 대상이다.
267
+ // 기준점이 없어 판정 불가인 파일은 엔진이 .bak을 남기고 덮어쓰므로 질문하지 않는다.
268
+ const commonDir = join(projectTypesDir, "common");
269
+ if (exists(commonDir)) {
270
+ const commonEnv = { type: "common", projectPath: ".", repoName, resolvers, branches };
271
+ for (const f of classify(commonDir, workflowsDir, commonEnv, baseline).changed) {
272
+ const dst = join(workflowsDir, f);
273
+ if (!existsSync(dst)) continue;
274
+ if (isUserModified(baseline, f, readFileSync(dst, "utf8")) !== true) continue;
275
+ conflicts.push({ filename: f, type: "common" });
276
+ }
277
+ }
278
+
238
279
  for (const type of types) {
239
280
  const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers, branches };
240
281
  const typeDir = join(projectTypesDir, type);
@@ -279,7 +320,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
279
320
  for (const f of unchanged) { counters.skipped++; trace?.event("copy", "skipped-unchanged", f, { group: type }); }
280
321
  for (const f of newFiles) { copyFileSync(join(typeDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "copied", f, { group: type }); }
281
322
  // upstream(#557): 사용자가 손대지 않았고 템플릿만 바뀐 파일 — 물어볼 것 없이 최신으로 올린다.
282
- for (const f of upstream) { copyFileSync(join(typeDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "upstream-updated", f, { group: type }); }
323
+ for (const f of upstream) { copyFileSync(join(typeDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "upstream-updated", f, { group: type, reason: "baseline-match" }); }
283
324
  // changed: 결정 Map에 따라 처리 (미지정=skip → 현행 force 동작과 동일)
284
325
  for (const f of changed) applyDecision(decisions.get(f), typeDir, workflowsDir, f, counters, trace);
285
326
  }
@@ -290,7 +331,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
290
331
  const { newFiles, unchanged, changed, upstream } = classify(serverDeployDir, workflowsDir, envOpts, baseline);
291
332
  for (const f of unchanged) { counters.skipped++; trace?.event("copy", "skipped-unchanged", f, { group: `${type}/server-deploy` }); }
292
333
  for (const f of newFiles) { copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "copied", f, { group: `${type}/server-deploy` }); }
293
- for (const f of upstream) { copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "upstream-updated", f, { group: `${type}/server-deploy` }); }
334
+ for (const f of upstream) { copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "upstream-updated", f, { group: `${type}/server-deploy`, reason: "baseline-match" }); }
294
335
  for (const f of changed) applyDecision(decisions.get(f), serverDeployDir, workflowsDir, f, counters, trace);
295
336
  }
296
337
 
@@ -1,5 +1,5 @@
1
1
  // 마이그레이션 가이드 (#493) — Layer 1 큐레이션 문서.
2
- // 마법사(full/workflows) 실행이 끝나면 대상 레포의 docs/projectops/migration/PROJECTOPS-MIGRATION-GUIDE.md에
2
+ // 마법사(full/workflows) 실행이 끝나면 대상 레포의 .github/.projectops/logs/PROJECTOPS-MIGRATION-GUIDE.md에
3
3
  // "고정 헤더(최초 1회) + 실행 엔트리(append-only)"를 남긴다. 사람용 동적 체크리스트와
4
4
  // AI Agent용 yaml 메타데이터를 한 엔트리에 담고, run-trace(#494)의 events를 단일 소스로 소비한다.
5
5
  import { join } from "node:path";
@@ -156,7 +156,7 @@ export function renderGuideEntry(report) {
156
156
  L.push(`template: { from: ${yq(from)}, to: ${yq(to)} }`);
157
157
  L.push(`mode: ${r.mode || "full"}`);
158
158
  L.push(`types: ${ylist(r.types)}`);
159
- L.push(`options: { deploy: ${yq(r.options?.deploy ?? "")}, publish: ${ylist(r.options?.publish)}, secret_backup: ${r.options?.secretBackup === true}, coderabbit: ${r.options?.coderabbit === true}, changelog_provider: ${yq(r.options?.changelogProvider ?? "")}, intent: ${yq(r.options?.intent ?? "")}, semver_auto: ${r.options?.semverAuto === true} }`);
159
+ L.push(`options: { deploy: ${yq(r.options?.deploy ?? "")}, publish: ${ylist(r.options?.publish)}, secret_backup: ${r.options?.secretBackup === true}, coderabbit: ${r.options?.coderabbit === true}, changelog_provider: ${yq(r.options?.changelogProvider ?? "")}, intent: ${yq(r.options?.intent ?? "")}, semver_auto: ${r.options?.semverAuto === true}, app_release: ${r.options?.appRelease === true} }`);
160
160
  L.push(`branches: { default: ${yq(r.branches?.defaultBranch ?? "main")}, deploy: ${yq(r.branches?.deployBranch ?? "develop")}, deploy_branch_created: ${r.branches?.created === true} }`);
161
161
  L.push("workflows:");
162
162
  L.push(` added: ${ylist(wf.added)}`);
@@ -5,7 +5,18 @@
5
5
  import { join } from "node:path";
6
6
  import { writeText } from "./fsutil.js";
7
7
 
8
- export const MIGRATION_DIR = "docs/projectops/migration";
8
+ // 진단 로그 위치 (#561). docs/ 아래(추적 대상)에서 옮겼다 — 이 기록은 사람이 읽는 문서가
9
+ // 아니라 Agent가 "지난 실행에서 무슨 일이 있었나"를 확인하는 자료다.
10
+ // 폴더 안에 .gitignore를 함께 써서 폴더가 자기 규칙을 들고 다닌다(루트 .gitignore 무수정).
11
+ // 형제인 .github/.projectops/baseline.json은 팀원 공유 자산이라 계속 추적된다.
12
+ export const MIGRATION_DIR = ".github/.projectops/logs";
13
+ export const LOGS_GITIGNORE = [
14
+ "# projectops 실행 진단 로그 — 저장소에 추적하지 않습니다.",
15
+ "# 이 폴더는 마법사가 실행할 때마다 기록을 남기며, 커밋 대상이 아닙니다.",
16
+ "*",
17
+ "!.gitignore",
18
+ "",
19
+ ].join("\n");
9
20
  export const TRACE_SCHEMA = 1;
10
21
 
11
22
  // 민감값 가드 — PAT·토큰·시크릿·비밀번호는 어떤 이벤트에도 남기지 않는다 (#494 안전 규칙).
@@ -20,6 +31,60 @@ export function scrubDetail(detail) {
20
31
  return out;
21
32
  }
22
33
 
34
+ // 단계 소요 시간. 시계를 고정 주입한 경우(테스트)는 생략해 결과가 흔들리지 않게 한다.
35
+ function elapsed(t0, clockIso) {
36
+ return clockIso ? {} : { ms: Date.now() - t0 };
37
+ }
38
+
39
+ // 이벤트를 서버 로그 스타일 한 줄로 만든다 (#561).
40
+ // [시각] 레벨 phase/action 대상 key=value ...
41
+ // 레벨은 action에서 유추한다 — 조치가 필요한 것(미치환·취소·실패)만 눈에 띄어야 한다.
42
+ // 레벨은 "무엇을 먼저 봐야 하는가"로 나눈다.
43
+ // ERROR — 실패. 반드시 조치해야 한다.
44
+ // WARN — 그대로 두면 나중에 실패할 수 있는 것, 사용자가 판단해야 하는 것.
45
+ // INFO — 실행의 큰 줄기. 단계 경계와 확정된 판단.
46
+ // DEBUG — 건별 상세(파일 하나, 치환 하나). 기본값은 INFO지만 전부 파일에는 남는다.
47
+ //
48
+ // DEBUG가 파일에 남는 이유: 문제가 터진 뒤에 "그때 DEBUG를 켰더라면"은 소용이 없다.
49
+ // 화면은 조용히 두되 파일에는 전부 적는다.
50
+ const ERROR_ACTIONS = new Set(["error", "failed"]);
51
+ const WARN_ACTIONS = new Set([
52
+ "unresolved", "cancelled", "skipped-conflict", "leftover-old-gen",
53
+ "neutralized", "detected", "replaced-bak",
54
+ ]);
55
+ const DEBUG_ACTIONS = new Set([
56
+ "copied", "skipped-unchanged", "excluded", "substituted", "branch-substituted",
57
+ "required-key", "project-path", "upstream-updated", "template-added",
58
+ "scripts", "config", "util", "templates",
59
+ ]);
60
+ export function levelOf(action) {
61
+ if (ERROR_ACTIONS.has(action)) return "ERROR";
62
+ if (WARN_ACTIONS.has(action)) return "WARN";
63
+ if (DEBUG_ACTIONS.has(action)) return "DEBUG";
64
+ return "INFO";
65
+ }
66
+ export function formatLogLine(e) {
67
+ const time = String(e.ts || "").replace(/^.*T/, "").replace(/Z$/, "");
68
+ const level = levelOf(e.action).padEnd(5);
69
+ const tag = `${e.phase}/${e.action}`.padEnd(24);
70
+ const parts = [`[${time}] ${level} ${tag} ${e.target || ""}`.trimEnd()];
71
+ if (e.detail && typeof e.detail === "object") {
72
+ const kv = Object.entries(e.detail)
73
+ .filter(([, v]) => v !== null && v !== undefined)
74
+ .map(([k, v]) => `${k}=${Array.isArray(v) ? (v.join("|") || "[]") : v}`);
75
+ if (kv.length) parts.push(` ${kv.join(" ")}`);
76
+ }
77
+ return parts.join("") + "\n";
78
+ }
79
+
80
+ // 색상·커서 제어 시퀀스 제거 (#561). 로그 파일은 에디터·Agent가 읽으므로 이스케이프가
81
+ // 그대로 남으면 판독을 방해한다. 터미널 출력 자체는 건드리지 않는다(사본만 정제).
82
+ // eslint-disable-next-line no-control-regex
83
+ const ANSI_RE = /\u001b\[[0-9;]*[A-Za-z]/g;
84
+ export function stripAnsi(text) {
85
+ return String(text).replace(ANSI_RE, "");
86
+ }
87
+
23
88
  // now("YYYY-MM-DD HH:MM:SS") → 파일명 스탬프 "YYYYMMDD_HHMMSS". 형식이 아니면 "run" 폴백(테스트 주입 clock 안전).
24
89
  export function stampFromNow(now) {
25
90
  const digits = String(now ?? "").replace(/[^0-9]/g, "");
@@ -32,6 +97,8 @@ export function createRunTrace({ clockIso = null } = {}) {
32
97
  const events = [];
33
98
  const lines = [];
34
99
  let restore = null;
100
+ let finalized = false;
101
+ let signalsArmed = false;
35
102
 
36
103
  const nowIso = () => clockIso ?? new Date().toISOString().replace(/\.\d+Z$/, "Z");
37
104
 
@@ -40,11 +107,14 @@ export function createRunTrace({ clockIso = null } = {}) {
40
107
  lines,
41
108
 
42
109
  // 이벤트 1건 기록. detail은 민감키 스크럽 후 저장.
110
+ // 같은 내용을 사람이 읽는 로그 라인으로도 남긴다 (#561) — 터미널에 보이지 않는 내부
111
+ // 동작까지 .log 한 파일에서 시간순으로 따라갈 수 있어야, 문제가 생겼을 때 바로 대응된다.
43
112
  event(phase, action, target = "", detail = null) {
44
113
  const e = { ts: nowIso(), phase, action, target };
45
114
  const d = scrubDetail(detail);
46
115
  if (d != null && (typeof d !== "object" || Object.keys(d).length > 0)) e.detail = d;
47
116
  events.push(e);
117
+ lines.push(formatLogLine(e));
48
118
  return e;
49
119
  },
50
120
 
@@ -55,7 +125,10 @@ export function createRunTrace({ clockIso = null } = {}) {
55
125
  const so = process.stdout.write; // 원본 참조 보관 — 복원 시 identity 유지
56
126
  const se = process.stderr.write;
57
127
  const capture = (chunk) => {
58
- try { lines.push(typeof chunk === "string" ? chunk : chunk.toString("utf8")); } catch { /* 미러 실패는 실행에 영향 없음 */ }
128
+ try {
129
+ const s = typeof chunk === "string" ? chunk : chunk.toString("utf8");
130
+ lines.push(stripAnsi(s));
131
+ } catch { /* 미러 실패는 실행에 영향 없음 */ }
59
132
  };
60
133
  process.stdout.write = function (chunk, ...rest) { capture(chunk); return so.apply(process.stdout, [chunk, ...rest]); };
61
134
  process.stderr.write = function (chunk, ...rest) { capture(chunk); return se.apply(process.stderr, [chunk, ...rest]); };
@@ -66,24 +139,106 @@ export function createRunTrace({ clockIso = null } = {}) {
66
139
  if (restore) { restore(); restore = null; }
67
140
  },
68
141
 
69
- // Layer 2/3 파일 기록docs/projectops/migration/{stamp}_v{from}_to_v{to}.{jsonl,log}
142
+ // 단계 실행 래퍼 (#561)서버 로그처럼 "어디에 들어갔다 언제 나왔고 얼마 걸렸는지"를
143
+ // 자동으로 남긴다. 개별 호출부에 start/done을 흩뿌리면 빠뜨리는 자리가 생긴다.
144
+ // 예외가 나면 failed로 기록하고 그대로 던진다 — 삼키지 않는다.
145
+ step(name, fn, detail = null) {
146
+ this.event("step", "start", name, detail);
147
+ const t0 = Date.now();
148
+ try {
149
+ const out = fn();
150
+ this.event("step", "done", name, elapsed(t0, clockIso));
151
+ return out;
152
+ } catch (err) {
153
+ this.event("step", "failed", name, { ...elapsed(t0, clockIso), message: err?.message || String(err) });
154
+ throw err;
155
+ }
156
+ },
157
+
158
+ // async 버전 — 대화형 단계(질문 대기 포함)에 쓴다.
159
+ async stepAsync(name, fn, detail = null) {
160
+ this.event("step", "start", name, detail);
161
+ const t0 = Date.now();
162
+ try {
163
+ const out = await fn();
164
+ this.event("step", "done", name, elapsed(t0, clockIso));
165
+ return out;
166
+ } catch (err) {
167
+ this.event("step", "failed", name, { ...elapsed(t0, clockIso), message: err?.message || String(err) });
168
+ throw err;
169
+ }
170
+ },
171
+
172
+ // 강제 종료(Ctrl+C)에도 기록을 남긴다 (#561).
173
+ // SIGINT 기본 동작으로 프로세스가 즉사하면 finally가 돌지 않는다 — 사용자가 끊었을 때야말로
174
+ // "어디까지 갔는지"가 가장 궁금한 순간이라, 여기서 놓치면 로그의 쓸모가 절반이다.
175
+ // 핸들러는 기록만 하고 원래대로 종료시킨다(동작을 바꾸지 않는다).
176
+ armSignals(opts = {}) {
177
+ if (signalsArmed) return () => {};
178
+ signalsArmed = true;
179
+ const self = this;
180
+ const handlers = [];
181
+ for (const sig of ["SIGINT", "SIGTERM"]) {
182
+ const h = () => {
183
+ self.event("run", "cancelled", sig, { reason: "사용자 강제 종료(신호 수신)" });
184
+ self.finalize(opts);
185
+ process.removeListener(sig, h);
186
+ process.kill(process.pid, sig); // 원래 종료 동작으로 넘긴다
187
+ };
188
+ process.on(sig, h);
189
+ handlers.push([sig, h]);
190
+ }
191
+ return () => {
192
+ for (const [sig, h] of handlers) process.removeListener(sig, h);
193
+ signalsArmed = false;
194
+ };
195
+ },
196
+
197
+ // 어떤 경로로 끝나든 기록을 남긴다 (#561) — 정상 완주·중간 취소·예외·강제 종료.
198
+ // 사용자가 중간에 끊었을 때야말로 "어디까지 갔는지"가 가장 궁금한 순간이라,
199
+ // 완주했을 때만 남기는 기록은 쓸모가 절반이다.
200
+ //
201
+ // 두 번 불려도 한 번만 쓴다(정상 경로 + finally 중복 호출 대비). 실패는 삼킨다 —
202
+ // 기록 실패가 종료를 막아선 안 된다.
203
+ finalize(opts = {}) {
204
+ if (finalized) return null;
205
+ finalized = true;
206
+ try {
207
+ this.mirrorStop();
208
+ return this.write(opts);
209
+ } catch {
210
+ return null;
211
+ }
212
+ },
213
+
214
+ // 기록 파일 경로만 계산한다 (쓰지 않음). 완료 화면까지 캡처하려면 write를 화면 출력 뒤로
215
+ // 미뤄야 하는데, 가이드 엔트리는 그 전에 traceFile 경로를 참조해야 해서 둘을 분리했다.
216
+ paths({ fromVersion = "", toVersion = "", now = "" } = {}) {
217
+ const stamp = stampFromNow(now);
218
+ const from = String(fromVersion || "new").replace(/[^0-9a-zA-Z.-]/g, "");
219
+ const to = String(toVersion || "unknown").replace(/[^0-9a-zA-Z.-]/g, "");
220
+ const base = `${stamp}_v${from}_to_v${to}`;
221
+ return { base, traceFile: `${MIGRATION_DIR}/${base}.jsonl`, logFile: `${MIGRATION_DIR}/${base}.log` };
222
+ },
223
+
224
+ // Layer 2/3 파일 기록 — .github/.projectops/logs/{stamp}_v{from}_to_v{to}.{jsonl,log}
70
225
  // 반환: { traceFile, logFile } (targetRoot 기준 상대 경로 — 가이드 메타 포인터용).
71
226
  // 이벤트가 0건이면 기록하지 않는다(no-op 실행 오염 방지) — null 반환.
72
227
  write({ targetRoot = ".", fromVersion = "", toVersion = "", now = "" } = {}) {
73
228
  if (events.length === 0) return null;
74
- const stamp = stampFromNow(now);
75
229
  const from = String(fromVersion || "new").replace(/[^0-9a-zA-Z.-]/g, "");
76
230
  const to = String(toVersion || "unknown").replace(/[^0-9a-zA-Z.-]/g, "");
77
- const base = `${stamp}_v${from}_to_v${to}`;
78
- const traceFile = `${MIGRATION_DIR}/${base}.jsonl`;
231
+ const planned = this.paths({ fromVersion, toVersion, now });
232
+ // 폴더 규칙을 매번 보장한다 — 사용자가 지웠거나 폴더가 새로 생겨도 추적되지 않게.
233
+ writeText(join(targetRoot, `${MIGRATION_DIR}/.gitignore`), LOGS_GITIGNORE);
79
234
  const header = JSON.stringify({ schema: TRACE_SCHEMA, kind: "projectops-migration-trace", from, to, started: events[0]?.ts ?? "" });
80
- writeText(join(targetRoot, traceFile), [header, ...events.map((e) => JSON.stringify(e))].join("\n") + "\n");
235
+ writeText(join(targetRoot, planned.traceFile), [header, ...events.map((e) => JSON.stringify(e))].join("\n") + "\n");
81
236
  let logFile = null;
82
237
  if (lines.length > 0) {
83
- logFile = `${MIGRATION_DIR}/${base}.log`;
238
+ logFile = planned.logFile;
84
239
  writeText(join(targetRoot, logFile), lines.join(""));
85
240
  }
86
- return { traceFile, logFile };
241
+ return { traceFile: planned.traceFile, logFile };
87
242
  },
88
243
  };
89
244
  }
package/src/index.js CHANGED
@@ -14,9 +14,9 @@ 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";
17
+ import { createRunTrace, MIGRATION_DIR } from "./core/run-trace.js";
18
18
  import { appendGuideEntry } from "./core/migration-guide.js";
19
- import { resolveProjectPaths } from "./core/paths-resolve.js";
19
+ import { resolveProjectPaths, markerForType } from "./core/paths-resolve.js";
20
20
  import { applicableTargets } from "./core/options-ask.js";
21
21
  import { printBannerCompact } from "./ui/banner.js";
22
22
  import { printSummary } from "./ui/summary.js";
@@ -94,23 +94,59 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
94
94
  return 1;
95
95
  }
96
96
 
97
+ // 실행 트레이스 (#494/#561) — 감지·판단 단계부터 기록해야 "왜 이렇게 정해졌는지"가 남는다.
98
+ const trace = createRunTrace();
99
+ const recordArtifacts = opts.mode === "full" || opts.mode === "workflows";
100
+ let disarmSignals = () => {};
101
+ if (recordArtifacts) {
102
+ trace.mirrorStart();
103
+ // Ctrl+C로 끊어도 여기까지의 기록이 남는다 (#561). 경로는 아래에서 확정되지만
104
+ // 신호는 언제든 올 수 있으므로 targetRoot만으로 먼저 무장한다.
105
+ disarmSignals = trace.armSignals({ targetRoot: cwd, now: "" });
106
+ }
107
+
97
108
  // 기존 version.yml 로드 — version/version_code/project_paths 보존의 단일 진실 (.sh L2208~2239 SSoT)
98
109
  const vyPath = join(cwd, "version.yml");
99
110
  const existing = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")) : null;
111
+ trace.event("detect", "existing-install", existing ? "found" : "none", {
112
+ templateVersion: existing?.templateVersion || null,
113
+ version: existing?.version || null,
114
+ types: existing?.types || null,
115
+ });
100
116
 
101
117
  // 감지 (CLI 인자 우선, 없으면 자동 감지 — version.yml 우선 규칙은 detectTypes/detectVersion 내부)
102
118
  const types = opts.types.length ? opts.types : detectTypes(cwd);
119
+ trace.event("detect", "types", types.join(",") || "(없음)", {
120
+ source: opts.types.length ? "cli-flag(--type)" : "auto-detect(마커 파일 스캔)",
121
+ });
103
122
  // version: 기존 version.yml 최우선(SSoT — 재실행 시 덮어쓰기 방지) → CLI 지정 → 파일 감지
104
123
  const version = (existing?.version) || opts.version || detectVersion(cwd);
124
+ trace.event("detect", "version", version, {
125
+ source: existing?.version ? "version.yml(기존값 보존)"
126
+ : (opts.version ? "cli-flag(--project-version)" : "프로젝트 파일 감지"),
127
+ });
105
128
  const versionCode = existing?.versionCode ?? 1; // 기존 빌드번호 보존 (.sh L2208~2221)
106
129
  const branch = detectDefaultBranch(cwd);
107
130
  const repoName = detectRepoName(cwd);
131
+ trace.event("detect", "repo", repoName || "(미상)", { defaultBranch: branch, versionCode });
108
132
  // 경로 확정 (.sh resolve_project_paths 비대화형 경로 — --paths 우선 → 저장값 → 후보 1개 자동 → 루트 폴백)
109
133
  const paths = await resolveProjectPaths({
110
134
  root: cwd, types, paths: parsePathsCsv(opts.pathsCsv),
111
135
  existingPaths: existing?.paths ?? new Map(), force: true, tty: false, io: {},
112
136
  });
113
137
 
138
+ for (const [ty, pth] of paths) {
139
+ // 근거로 "무엇을 보고 그 경로로 정했는지"까지 남긴다 — 경로가 틀렸을 때 추적의 시작점이다.
140
+ const marker = markerForType(ty);
141
+ const at = pth === "." ? marker : `${pth}/${marker}`;
142
+ trace.event("detect", "project-path", ty, {
143
+ path: pth,
144
+ marker: existsSync(join(cwd, at)) ? at : `${at} (없음)`,
145
+ source: parsePathsCsv(opts.pathsCsv).has(ty) ? "cli-flag(--paths)"
146
+ : (existing?.paths?.has(ty) ? "version.yml(저장값)" : "마커 파일 탐색"),
147
+ });
148
+ }
149
+
114
150
  const { now, today } = clock || utcNow();
115
151
  const tempDir = join(cwd, PATHS.tempDir);
116
152
 
@@ -129,10 +165,32 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
129
165
  }
130
166
  // 적용 불가 타겟 조용한 정리 (#498) — 대화형과 동일 규칙. 타입에 성립하지 않는 축 값은
131
167
  // 복사 결과가 동일하므로 경고 없이 none/교집합으로 정리한다 (모바일 앱/basic 단독 등).
168
+ const beforeCleanup = { deploy: deployTarget, publish: [...publishTargets] };
132
169
  if (deployTarget !== "none" && !applicable.deploy.includes(deployTarget)) deployTarget = "none";
133
170
  publishTargets = publishTargets.filter((t) => applicable.publish.includes(t));
134
171
  if (types.length > 0 && applicable.deploy.length === 0 && applicable.publish.length === 0) intent = "none";
135
172
 
173
+ // 축 확정 근거 (#561) — "왜 이 값인가"가 가장 헷갈리는 자리다.
174
+ // CLI 플래그 / 저장값 / intent 유도 / 타입 적용성 정리 중 무엇이 이겼는지 남긴다.
175
+ trace.event("resolve", "intent", String(intent ?? "(미설정)"), {
176
+ source: opts.intent != null ? "cli-flag(--intent)"
177
+ : (existing?.options?.intent ? "version.yml(저장값)" : "미지정 → deploy/publish에서 역추론"),
178
+ });
179
+ trace.event("resolve", "deploy", deployTarget, {
180
+ source: opts.deployTarget != null ? "cli-flag(--deploy)"
181
+ : (existing?.options?.deploy ? "version.yml(저장값)" : "기본값"),
182
+ applicableForTypes: applicable.deploy,
183
+ adjusted: beforeCleanup.deploy !== deployTarget
184
+ ? `${beforeCleanup.deploy} → ${deployTarget} (선택 타입에 적용 불가)` : null,
185
+ });
186
+ trace.event("resolve", "publish", publishTargets.join(",") || "(없음)", {
187
+ source: opts.publishTargets != null ? "cli-flag(--publish)"
188
+ : (existing?.options?.publish ? "version.yml(저장값)" : "기본값"),
189
+ applicableForTypes: applicable.publish,
190
+ adjusted: beforeCleanup.publish.join(",") !== publishTargets.join(",")
191
+ ? `${beforeCleanup.publish.join(",") || "(없음)"} → ${publishTargets.join(",") || "(없음)"} (적용 불가 정리)` : null,
192
+ });
193
+
136
194
  const context = createContext({
137
195
  mode: opts.mode, force: true, types, version, versionCode, branch,
138
196
  paths,
@@ -160,66 +218,111 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
160
218
  now, today,
161
219
  });
162
220
 
221
+ // 최종 확정값 스냅샷 (#561) — 이 한 줄로 "무엇이 어떻게 설치될 것인지"가 고정된다.
222
+ trace.event("resolve", "context", opts.mode || "", {
223
+ types, version, versionCode, branch,
224
+ deploy: deployTarget, publish: publishTargets, intent,
225
+ secretBackup: context.includeSecretBackup,
226
+ changelogProvider: context.changelogProvider,
227
+ coderabbit: context.codeReviewCoderabbit,
228
+ deployBranch: context.deployBranch || "(미지정 → develop 폴백)",
229
+ recordMode: context.recordMode,
230
+ });
231
+ trace.event("resolve", "semver-auto", String(context.semverAuto), {
232
+ reason: existing?.options?.semverAuto != null ? "version.yml 저장값 보존"
233
+ : (existing ? "기존 통합 레포 → 예고 없는 버전 상승 방지를 위해 false"
234
+ : "신규 통합 → true"),
235
+ });
236
+ trace.event("resolve", "app-release", String(context.appRelease), {
237
+ reason: existing?.options?.appRelease != null ? "version.yml 저장값 보존" : "미설정(키를 쓰지 않음)",
238
+ });
239
+
163
240
  let result = null;
164
- // 실행 트레이스 (#494) 비대화형도 이벤트 기록 (터미널 미러는 CLI 실행에서만 유의미하므로 함께 켠다)
165
- const trace = createRunTrace();
166
- const recordArtifacts = opts.mode === "full" || opts.mode === "workflows";
241
+ // trace/recordArtifacts는 감지 단계 기록을 위해 위에서 이미 생성했다 (#561)
167
242
  let breakingReport = null;
168
243
  let migrationsResult = null;
169
244
  let orphanPending = [];
170
- if (recordArtifacts) trace.mirrorStart();
245
+ // 실행 경계(#561) — 로그만 보고 "무엇을 어떤 인자로 돌렸는지"를 알 수 있어야 한다.
246
+ trace.event("run", "start", opts.mode || "", {
247
+ cli: "non-interactive", types, version, branch,
248
+ force: true, deploy: deployTarget, publish: publishTargets, intent,
249
+ });
171
250
  try {
172
- acquireTemplate({ tempDir, source });
251
+ trace.step("acquire-template", () => acquireTemplate({ tempDir, source }), { source: source?.type || "git" });
173
252
  context.templateVersion = readTemplateVersion(tempDir);
253
+ trace.event("detect", "template-version", context.templateVersion, { tempDir: PATHS.tempDir });
174
254
 
175
255
  // 비대화형 축약 배너 (#446 확정 — 1줄, 로그 오염 최소)
176
256
  printBannerCompact({ version: context.templateVersion, mode: opts.mode });
177
257
 
178
258
  // Breaking Changes 게이트 (.sh execute_integration L4415~4420 등가 — 비대화형은 경고 후 진행)
179
- const proceed = await runBreakingCheck({
259
+ const proceed = await trace.stepAsync("breaking-check", () => runBreakingCheck({
180
260
  cwd, tempDir, templateVersion: context.templateVersion,
181
261
  onItems: (items) => { breakingReport = items; },
182
- });
183
- if (!proceed) return 0;
262
+ }));
263
+ trace.event("breaking", "result", proceed ? "proceed" : "halt", { items: (breakingReport ?? []).length });
264
+ if (!proceed) {
265
+ trace.event("run", "cancelled", "breaking-gate", { reason: "호환성 경고로 중단" });
266
+ return 0;
267
+ }
184
268
 
185
269
  // 레거시 마이그레이션 (#470) — 워크플로우를 만지는 모드에서만. 비대화형은 safe 티어 자동 적용.
186
270
  if (recordArtifacts) {
187
- migrationsResult = await runMigrations({ targetRoot: cwd });
271
+ migrationsResult = await trace.stepAsync("legacy-migrations", () => runMigrations({ targetRoot: cwd }));
188
272
  for (const a of migrationsResult.applied ?? []) trace.event("legacy", a.action === "error" ? "error" : "neutralized", a.from ?? a.id ?? "", { to: a.to ?? "", id: a.id ?? "" });
189
273
  for (const e of migrationsResult.confirmPending ?? []) trace.event("legacy", "leftover-old-gen", e.file, { replacement: e.replacedBy ?? "", reason: e.reason ?? "" });
190
274
  }
191
275
 
192
276
  // 고아 타입 워크플로우 안내 (#487) — 비대화형은 자동 무해화 금지(배포 파이프라인일 수 있음), 안내만
193
277
  if (recordArtifacts) {
194
- const orphans = detectOrphanWorkflows({ tempDir, targetRoot: cwd, selectedTypes: types });
278
+ const orphans = trace.step("orphan-scan",
279
+ () => detectOrphanWorkflows({ tempDir, targetRoot: cwd, selectedTypes: types }),
280
+ { selectedTypes: types });
195
281
  orphanPending = orphans.map((o) => o.filename);
282
+ for (const o of orphans) trace.event("orphan", "detected", o.filename, { type: o.type, action: "안내만(비대화형)" });
196
283
  for (const o of orphans) {
197
284
  console.error(`⚠️ 선택되지 않은 타입(${o.type})의 워크플로우가 남아있습니다: ${o.filename} — 대화형 마법사(npx projectops)에서 정리할 수 있습니다.`);
198
285
  }
199
286
  }
200
287
 
201
288
  switch (opts.mode) {
202
- case "full": result = runFull(context, tempDir, cwd, { trace }); break;
203
- case "version": result = runVersion(context, tempDir, cwd); break;
204
- case "workflows": result = runWorkflows(context, tempDir, cwd, { trace }); break;
205
- case "issues": result = runIssues(context, tempDir, cwd); break;
289
+ case "full": result = trace.step("install-full", () => runFull(context, tempDir, cwd, { trace })); break;
290
+ case "version": result = trace.step("install-version", () => runVersion(context, tempDir, cwd)); break;
291
+ case "workflows": result = trace.step("install-workflows", () => runWorkflows(context, tempDir, cwd, { trace })); break;
292
+ case "issues": result = trace.step("install-issues", () => runIssues(context, tempDir, cwd)); break;
206
293
  default:
207
294
  // 알 수 없는 모드 → .sh와 동일하게 복사 0건, 에러 아님
208
295
  break;
209
296
  }
297
+ } catch (err) {
298
+ // 실패 원인을 로그에 남긴다 (#561) — 서버 로그처럼 사후에 바로 짚을 수 있어야 한다.
299
+ trace.event("run", "error", opts.mode || "", {
300
+ message: err?.message || String(err),
301
+ stack: String(err?.stack || "").split("\n").slice(0, 3).join(" | "),
302
+ });
303
+ throw err;
210
304
  } finally {
211
- trace.mirrorStop();
305
+ // 예외로 빠져나가도 기록을 남긴다 (#561). finalize는 멱등 — 정상 경로에서 이미
306
+ // 호출됐으면 여기서는 아무 일도 하지 않는다.
307
+ if (recordArtifacts) {
308
+ trace.finalize({ targetRoot: cwd, fromVersion: existing?.templateVersion || "", toVersion: context.templateVersion, now });
309
+ }
310
+ disarmSignals();
212
311
  remove(tempDir);
213
312
  }
214
313
 
215
314
  // 마이그레이션 기록 (#493/#494) — Layer 2/3 트레이스 파일 + Layer 1 가이드 엔트리
216
315
  let migrationGuidePath = null;
316
+ // 기록 파일 경로는 먼저 계산하고(가이드가 참조), 실제 쓰기는 완료 화면 출력 뒤로 미룬다 —
317
+ // 그래야 터미널 미러에 완료 화면까지 담긴다 (#561).
318
+ const files = recordArtifacts
319
+ ? trace.paths({ fromVersion: existing?.templateVersion || "", toVersion: context.templateVersion, now })
320
+ : null;
217
321
  if (recordArtifacts) {
218
- const files = trace.write({ targetRoot: cwd, fromVersion: existing?.templateVersion || "", toVersion: context.templateVersion, now });
219
322
  migrationGuidePath = appendGuideEntry(cwd, {
220
323
  now, mode: opts.mode, types, repoName,
221
324
  templateFrom: existing?.templateVersion || "", templateTo: context.templateVersion,
222
- options: { deploy: deployTarget, publish: publishTargets, secretBackup: context.includeSecretBackup, coderabbit: context.codeReviewCoderabbit, changelogProvider: context.changelogProvider, intent, semverAuto: context.semverAuto },
325
+ options: { deploy: deployTarget, publish: publishTargets, secretBackup: context.includeSecretBackup, coderabbit: context.codeReviewCoderabbit, changelogProvider: context.changelogProvider, intent, semverAuto: context.semverAuto , appRelease: context.appRelease },
223
326
  branches: { defaultBranch: branch, deployBranch: context.deployBranch || "develop", ready: null, created: null },
224
327
  breaking: breakingReport, migrations: migrationsResult, orphans: { cleaned: [], pending: orphanPending },
225
328
  events: trace.events, counters: { skipped: result?.workflows?.skipped ?? 0 },
@@ -232,6 +335,20 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
232
335
  mode: opts.mode, types, version, deployBranch: context.deployBranch, migrationGuidePath,
233
336
  counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
234
337
  verification: result?.verification, // #549 설치 후 검증 결과 (full/workflows 모드에서만 존재)
338
+ logDir: files ? MIGRATION_DIR : null, // #561 기록 위치 안내
339
+ logFile: files?.logFile ?? null,
340
+ traceFile: files?.traceFile ?? null,
235
341
  }, cwd);
342
+
343
+ // 완료 화면까지 캡처한 뒤 종료하고 기록한다 (#561)
344
+ trace.event("run", "end", opts.mode || "", {
345
+ workflowsCopied: result?.workflows?.copied ?? 0,
346
+ workflowsSkipped: result?.workflows?.skipped ?? 0,
347
+ });
348
+ if (recordArtifacts) {
349
+ trace.finalize({ targetRoot: cwd, fromVersion: existing?.templateVersion || "", toVersion: context.templateVersion, now });
350
+ } else {
351
+ trace.mirrorStop();
352
+ }
236
353
  return 0;
237
354
  }
package/src/ui/summary.js CHANGED
@@ -130,6 +130,17 @@ export function printSummary(ctx, targetRoot = ".") {
130
130
  err(" 📖 TEMPLATE REPO: https://github.com/Cassiiopeia/projectops");
131
131
  err(" 📚 워크플로우 가이드: .github/workflows/project-types/README.md");
132
132
  // #493 — 이번 실행의 마이그레이션 기록. "뭐가 남았고 AI에게 어떻게 시키는지"가 바로 보이게 행동 유도형으로 안내.
133
+ // 실행 기록 위치 (#561) — 무슨 일이 있었는지 나중에 확인할 자리를 알린다.
134
+ // 폴더가 아니라 이번 실행의 파일을 정확히 짚어준다 — 실행이 쌓이면 어느 것이 이번 건인지
135
+ // 모른다. 붙여넣어 바로 열 수 있는 경로가 목적이다.
136
+ if (ctx?.logFile || ctx?.logDir) {
137
+ err(` 📁 실행 로그: ${ctx.logFile || `${ctx.logDir}/`}`);
138
+ if (ctx.traceFile) err(` 이벤트(JSONL): ${ctx.traceFile}`);
139
+ err(" 이번 실행에서 무엇을 어떤 근거로 정했는지 전부 기록돼 있습니다.");
140
+ err(" 문제가 생기면 이 파일을 확인하세요 (저장소에 추적되지 않습니다).");
141
+ err(" 💡 AI Agent에게 \"실행 로그 확인해줘\"라고 요청하면 원인을 짚어줍니다.");
142
+ err("");
143
+ }
133
144
  if (ctx?.migrationGuidePath) {
134
145
  err(` 🧭 마이그레이션 가이드: ${ctx.migrationGuidePath}`);
135
146
  err(" 이번 설치에서 바뀐 내용과 직접 확인해야 할 작업이 이 파일에 정리되어 있습니다.");