project-auto-wizard 0.8.2 → 0.9.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
@@ -17,7 +17,7 @@ npx project-auto-wizard
17
17
  [![node](https://img.shields.io/badge/node-%3E%3D20.12-brightgreen)](package.json)
18
18
 
19
19
  <!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
20
- ## 최신 버전 : v0.8.2 (2026-08-26)
20
+ ## 최신 버전 : v0.9.0 (2026-08-29)
21
21
 
22
22
  [전체 버전 기록 보기](CHANGELOG.md)
23
23
 
@@ -75,11 +75,20 @@ flutter.APP_ARTIFACT_NAME:
75
75
  - **python**: CI / PR 프리뷰 / SimpleCICD
76
76
  - **go**: CI(Dockerfile 불필요, go test/vet/build/lint) / PR 프리뷰 / SimpleCICD(Dockerfile 있는 프로젝트만 해당)
77
77
 
78
- ### 설치 기록 (`.github/.wizard/logs/`)
78
+ ### 실행 로그 (`.github/.wizard/logs/`)
79
79
 
80
- 설치가 끝나면 실행에서 무엇을 어떤 값으로 설치했는지 `.github/.wizard/logs/<시각>-install.md`에 남깁니다. 감지된 타입과 근거 파일, 버전, 브랜치, 선택 워크플로우, **환경설정 질문별 답변(기본값 그대로인지 여부 포함)**, 설치된 파일, 값이 채워지지 않은 항목, 등록해야 하는 GitHub Secret이 담깁니다.
80
+ 설치·업데이트·삭제를 실행할 때마다 `.github/.wizard/logs/<시각>-<동작>.log`에 실행 추적이 남습니다. 감지 근거, **파일별 처리 결정과 사유**, 치환된 값, 미치환 항목, 등록해야 하는 GitHub Secret이 시간순으로 기록되고, 파일 끝에 결과 요약이 붙습니다.
81
81
 
82
- 상단은 기계가 읽는 YAML front matter, 아래는 사람이 읽는 마크다운입니다. 배포가 예상과 다르게 동작할 때 "설치할 때 뭘로 답했더라"를 여기서 확인할 수 있고, 레포에 커밋되므로 AI 에이전트나 다른 팀원도 참고할 수 있습니다. 로그 기록에 실패해도 설치 자체는 정상 완료됩니다.
82
+ ```
83
+ 07:46:01 INFO detect type spring (근거: build.gradle)
84
+ 07:46:01 INFO copy write PROJECT-SPRING-SIMPLE-CICD.yaml (new)
85
+ 07:46:01 INFO copy keep-local PROJECT-COMMON-VERSION-CONTROL.yaml (업스트림 무변경, 사용자 수정본 유지)
86
+ 07:46:01 WARN verify unresolved PROJECT-SPRING-PR-PREVIEW.yaml:43 __APPLICATION_YML_PATH__
87
+ ```
88
+
89
+ 업데이트에서 "내가 고친 워크플로우가 유지됐는지 덮였는지"를 이 로그로 확인할 수 있습니다. 한 줄씩 즉시 기록하므로 도중에 중단되어도 직전까지의 흐름이 남습니다. 로그 기록에 실패해도 설치 자체는 정상 완료됩니다.
90
+
91
+ 이 폴더에는 자체 `.gitignore`(`*`, `!.gitignore`)가 함께 생성되어 **로그가 git에 올라가지 않습니다**. 최근 20개만 보관하고 오래된 것부터 정리합니다. `--dry-run`은 파일을 만들지 않는 것이 계약이므로 로그도 남기지 않습니다.
83
92
 
84
93
  `.github/.wizard/`에는 업데이트 3-way 판정에 쓰는 `baseline.json`도 함께 들어 있어, 완전 삭제 시 워크플로우와 함께 제거됩니다.
85
94
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-auto-wizard",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "One command DevOps: npx wizard that installs GitHub-native AI Release Automation into any project",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -61,11 +61,15 @@ def extract_issue_number(issue_url):
61
61
  return parts[-1] if parts and parts[-1] else ""
62
62
 
63
63
 
64
- _BRANCH_ISSUE_RE = re.compile(r"#(\d+)")
64
+ _BRANCH_ISSUE_HASH_RE = re.compile(r"#(\d+)")
65
+ _BRANCH_ISSUE_WORD_RE = re.compile(r"issues?[-_/](\d+)", re.IGNORECASE)
65
66
 
66
67
 
67
68
  def extract_issue_number_from_branch(branch_name):
68
- match = _BRANCH_ISSUE_RE.search(branch_name)
69
+ match = _BRANCH_ISSUE_HASH_RE.search(branch_name)
70
+ if match:
71
+ return match.group(1)
72
+ match = _BRANCH_ISSUE_WORD_RE.search(branch_name)
69
73
  return match.group(1) if match else None
70
74
 
71
75
 
@@ -16,7 +16,7 @@ import { ensureGitignore } from "../core/copy/gitignore.js";
16
16
  import { readBaseline, writeBaseline } from "../core/baseline.js";
17
17
  import { scanUnsubstituted, collectRequiredSecrets, narrowSecretsBySshAuth } from "../core/verify.js";
18
18
  import { cleanupOtherDeployWorkflows, DEFAULT_DEPLOY_STYLE } from "../core/deploy-style.js";
19
- import { writeInstallLog } from "../core/install-log.js";
19
+ import { log, maskValue } from "../core/logger.js";
20
20
 
21
21
  // context: { version, types, paths:Map, branch, versionCode, includeNexus, includeSecretBackup,
22
22
  // force, repoName, resolvers, now, today }
@@ -31,7 +31,16 @@ export function runFull(context, payloadRoot, targetRoot = ".", hooks = {}) {
31
31
  // 대표 마커명이 아니라 그 폴더에 실제로 있는 파일을 쓴다 — build.gradle.kts만 있는 레포의
32
32
  // version.yml에 "# build.gradle"이라고 적히면 감지 로그와 같은 종류의 거짓말이 된다 (이슈 #77).
33
33
  const pathMarkers = new Map();
34
- for (const [t, p] of paths) pathMarkers.set(t, existingMarkerInDir(t, join(targetRoot, p || ".")));
34
+ for (const [t, p] of paths) {
35
+ const marker = existingMarkerInDir(t, join(targetRoot, p || "."));
36
+ pathMarkers.set(t, marker);
37
+ log.info("detect", "type", `${t} (근거: ${marker || "직접 선택"})`);
38
+ }
39
+ log.info("detect", "version", `${version}${context.versionSource ? ` (${context.versionSource})` : ""}`);
40
+ log.info("detect", "branch", `${branch}${context.branches ? ` | main=${context.branches.main} develop=${context.branches.develop} mode=${context.branches.mode}` : ""}`);
41
+ for (const a of context.envAnswers || []) {
42
+ log.info("prompt", a.isDefault ? "default" : "answer", `${a.key}=${maskValue(a.key, a.value)}`);
43
+ }
35
44
 
36
45
  // 1. 워크플로우 복사 (+ env 치환) — deploy 블록에 쓸 ask 값을 수집한다.
37
46
  // hooks.decisions: 대화형 충돌 3지선 결정 Map (미지정=skip — 현행 force 동작)
@@ -46,6 +55,8 @@ export function runFull(context, payloadRoot, targetRoot = ".", hooks = {}) {
46
55
  writeText(join(targetRoot, PATHS.versionFile),
47
56
  renderVersionYml(context, readVersionYmlTemplate(payloadRoot), { pathMarkers, deployValues, extraTopLevel }));
48
57
 
58
+ log.info("version", "write", `version.yml (v${version}, code=${versionCode})`);
59
+
49
60
  // 3. README 버전 섹션
50
61
  addVersionSectionToReadme(version, targetRoot);
51
62
 
@@ -67,6 +78,8 @@ export function runFull(context, payloadRoot, targetRoot = ".", hooks = {}) {
67
78
  // 지운 파일의 기준점은 baseline에서도 빼야 다음 실행에서 "사용자가 지웠다"로 오인하지 않는다.
68
79
  for (const f of [...cleanup.removed, ...cleanup.backedUp]) delete previousBaseline?.files?.[f];
69
80
 
81
+ for (const f of cleanup.removed || []) log.info("cleanup", "remove", `${f} (이전 배포 방식 정리)`);
82
+ for (const f of cleanup.backedUp || []) log.info("cleanup", "backup", `${f} → ${f}.bak`);
70
83
  const gitignoreUpdated = gitignoreUpdated0 || cleanup.backedUp.length > 0;
71
84
  if (gitignoreUpdated) ensureGitignore(targetRoot);
72
85
 
@@ -95,33 +108,21 @@ export function runFull(context, payloadRoot, targetRoot = ".", hooks = {}) {
95
108
  firstDeployValue(deployValues, "SSH_AUTH_METHOD"),
96
109
  );
97
110
 
98
- // 9. 설치 로그 (이슈 #79) 실행에서 무엇을 어떤 값으로 설치했는지 레포에 남긴다.
99
- // 실패해도 설치는 성공으로 끝난다.
100
- const installLog = writeInstallLog(targetRoot, {
101
- action: context.previousTemplateVersion ? "update" : "install",
102
- at: now || today || "",
103
- templateVersion,
104
- previousTemplateVersion: context.previousTemplateVersion || "",
105
- mode: context.mode || "full",
106
- types, markers: context.markers || new Map(), version,
107
- versionSource: context.versionSource || "",
108
- branch, branches: context.branches, paths,
109
- options: {
110
- nexus: includeNexus,
111
- secretBackup: includeSecretBackup,
112
- semverAuto: includeSemverAuto !== false,
113
- deployStyle: context.deployStyle || "",
114
- },
115
- answers: context.envAnswers || [],
116
- warnings: context.detectWarnings || [],
117
- result: {
118
- copiedFiles: wfCounters.copiedFiles || [],
119
- gitignoreUpdated,
120
- },
121
- unresolved, secrets, cleanup,
122
- });
111
+ // 9. 요약 파일 끝에 결과 블록을 붙인다. tail만 봐도 결과가 보이도록.
112
+ for (const u of unresolved) log.warn("verify", "unresolved", `${u.filename}:${u.line} ${u.token}`);
113
+ for (const [name, users] of secrets) log.info("verify", "secret", `${name} ← ${users.join(", ")}`);
114
+ log.summary([
115
+ ["설치", `${(wfCounters.copiedFiles || []).length}개 파일`],
116
+ ["자동 갱신", `${(wfCounters.autoUpdated || []).length}개 (사용자 미수정)`],
117
+ ["유지", `${(wfCounters.keptLocal || []).length}개 (사용자 수정본)`],
118
+ ["변경 없음", `${(wfCounters.unchangedFiles || []).length}개`],
119
+ ["백업 교체", `${wfCounters.backupAdded || 0}개 (.bak 생성)`],
120
+ ["미치환", `${unresolved.length}건${unresolved.length ? " ← 조치 필요" : ""}`],
121
+ ["필요 Secret", `${secrets.size}개`],
122
+ ["결과", unresolved.length ? `주의 (미치환 ${unresolved.length}건)` : "OK"],
123
+ ]);
123
124
 
124
- return { workflows: wfCounters, gitignoreUpdated, unresolved, secrets, installLog, cleanup };
125
+ return { workflows: wfCounters, gitignoreUpdated, unresolved, secrets, cleanup };
125
126
  }
126
127
 
127
128
  // deployValues는 Map<type, Map<key,value>> — 타입 구분 없이 첫 값만 필요할 때 쓴다.
@@ -20,6 +20,7 @@ import { runUninstallFlow } from "./uninstall.js";
20
20
  import * as prompts from "../ui/prompts.js";
21
21
  import { runStatus, printStatus } from "./status.js";
22
22
  import { runDoctor, printDoctorReport } from "./doctor.js";
23
+ import { currentLogPath, hasLegacyMdLogs } from "../core/logger.js";
23
24
 
24
25
  const CANCEL = prompts.CANCEL;
25
26
  const isCancel = (v) => v === CANCEL || typeof v === "symbol";
@@ -305,7 +306,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
305
306
  answers: envAnswers,
306
307
  unresolved: result?.unresolved ?? [],
307
308
  secrets: result?.secrets ?? new Map(),
308
- installLogPath: result?.installLog?.path ?? "",
309
+ logPath: currentLogPath(),
310
+ legacyMdLogs: hasLegacyMdLogs(cwd),
309
311
  cleanup: result?.cleanup ?? null,
310
312
  });
311
313
  io.outro?.(`통합 완료 — ${mode} 모드로 설치했습니다.`);
@@ -9,6 +9,7 @@ import { PATHS } from "../core/paths.js";
9
9
  import { remove } from "../core/fsutil.js";
10
10
  import { planRemoval } from "../core/removal-plan.js";
11
11
  import { removeVersionSectionFromReadme, hasVersionSection } from "../core/copy/readme.js";
12
+ import { log } from "../core/logger.js";
12
13
 
13
14
  const CHANGELOG_FILES = ["CHANGELOG.json", "CHANGELOG.md"];
14
15
 
@@ -54,12 +55,12 @@ export function printPurgePlan(plan, { dryRun = false } = {}) {
54
55
  export function executePurge(payloadRoot, targetRoot = ".", keepFlags = {}) {
55
56
  const plan = planPurge(payloadRoot, targetRoot, keepFlags);
56
57
  const wfDir = join(targetRoot, PATHS.workflowsDir);
57
- for (const name of plan.workflows) remove(join(wfDir, name));
58
- for (const name of plan.scripts) remove(join(targetRoot, PATHS.scriptsDir, name));
59
- for (const p of plan.baseline || []) remove(join(targetRoot, p));
60
- if (plan.versionYml) remove(join(targetRoot, PATHS.versionFile));
58
+ for (const name of plan.workflows) { remove(join(wfDir, name)); log.info("remove", "workflow", name); }
59
+ for (const name of plan.scripts) { remove(join(targetRoot, PATHS.scriptsDir, name)); log.info("remove", "script", name); }
60
+ for (const p of plan.baseline || []) { remove(join(targetRoot, p)); log.info("remove", "metadata", p); }
61
+ if (plan.versionYml) { remove(join(targetRoot, PATHS.versionFile)); log.info("remove", "version", PATHS.versionFile); }
61
62
  const readmeSection = plan.readmeSection && removeVersionSectionFromReadme(targetRoot) === "removed";
62
- for (const f of plan.changelog) remove(join(targetRoot, f));
63
+ for (const f of plan.changelog) { remove(join(targetRoot, f)); log.info("remove", "changelog", f); }
63
64
  return { ...plan, readmeSection };
64
65
  }
65
66
 
@@ -9,6 +9,7 @@ import { planRemoval } from "../core/removal-plan.js";
9
9
  import { removeVersionSectionFromReadme, hasVersionSection } from "../core/copy/readme.js";
10
10
  import { removeAutoAddedEntriesFromGitignore, hasAutoAddedEntries } from "../core/copy/gitignore.js";
11
11
  import { CANCEL } from "../ui/prompts.js";
12
+ import { log } from "../core/logger.js";
12
13
 
13
14
  // selection: { workflows, scripts, readme, gitignore, versionYml } (모두 boolean).
14
15
  // 반환: 위와 동일한 키의 boolean/배열 — 실제로 제거 "대상"인지 여부(순수 함수, 아무것도 지우지 않음).
@@ -29,16 +30,16 @@ export function planUninstall(payloadRoot, targetRoot, selection) {
29
30
  export function runUninstall(context, payloadRoot, targetRoot, selection) {
30
31
  const plan = planUninstall(payloadRoot, targetRoot, selection);
31
32
  const wfDir = join(targetRoot, PATHS.workflowsDir);
32
- for (const name of plan.workflows) remove(join(wfDir, name));
33
- for (const name of plan.scripts) remove(join(targetRoot, PATHS.scriptsDir, name));
34
- for (const p of plan.baseline || []) remove(join(targetRoot, p));
33
+ for (const name of plan.workflows) { remove(join(wfDir, name)); log.info("remove", "workflow", name); }
34
+ for (const name of plan.scripts) { remove(join(targetRoot, PATHS.scriptsDir, name)); log.info("remove", "script", name); }
35
+ for (const p of plan.baseline || []) { remove(join(targetRoot, p)); log.info("remove", "metadata", p); }
35
36
  // removeVersionSectionFromReadme/removeAutoAddedEntriesFromGitignore는 plan이 "제거 대상"으로
36
37
  // 판단했더라도 실제로는 안전하게 포기(skip-*)할 수 있다 — 반환 상태를 그대로 신뢰하지 않고
37
38
  // 실제 결과로 plan을 덮어써서 호출부(CLI/대화형 요약)가 거짓 성공을 보고하지 않게 한다.
38
39
  const readmeRemoved = plan.readme && removeVersionSectionFromReadme(targetRoot) === "removed";
39
40
  const gitignoreStatus = plan.gitignore ? removeAutoAddedEntriesFromGitignore(targetRoot) : null;
40
41
  const gitignoreRemoved = gitignoreStatus === "removed" || gitignoreStatus === "file-deleted";
41
- if (plan.versionYml) remove(join(targetRoot, PATHS.versionFile));
42
+ if (plan.versionYml) { remove(join(targetRoot, PATHS.versionFile)); log.info("remove", "version", PATHS.versionFile); }
42
43
  return { ...plan, readme: readmeRemoved, gitignore: gitignoreRemoved };
43
44
  }
44
45
 
@@ -10,6 +10,7 @@ import { exists, writeText, listYamlFiles } from "../fsutil.js";
10
10
  import { substituteEnv } from "../wizard-env.js";
11
11
  import { substitute } from "../branding.js";
12
12
  import { sha256, readBaseline } from "../baseline.js";
13
+ import { log } from "../logger.js";
13
14
 
14
15
  // 원본 텍스트 로더 — context.branches가 있으면 {{MAIN_BRANCH}}/{{DEVELOP_BRANCH}} 치환 적용.
15
16
  // classify(unchanged 판정)와 실제 복사가 같은 치환본을 봐야 재실행 시 가짜 충돌이 없다.
@@ -97,20 +98,37 @@ function processDir(srcDir, workflowsDir, envOpts, ctx, counters, filter = () =>
97
98
  const track = (f, wrote) => baselineTargets.set(f, { srcPath: join(srcDir, f), envOpts, wrote });
98
99
  const write = (f) => { writeText(join(workflowsDir, f), srcText(join(srcDir, f))); counters.copied++; counters.copiedFiles.push(f); track(f, true); };
99
100
 
100
- for (const f of c.unchanged.filter(filter)) { counters.skipped++; track(f, false); }
101
+ for (const f of c.unchanged.filter(filter)) {
102
+ counters.skipped++; counters.unchangedFiles.push(f); track(f, false);
103
+ log.info("copy", "skip", `${f} (unchanged)`);
104
+ }
101
105
 
102
- for (const f of c.localOnly.filter(filter)) { counters.skipped++; counters.keptLocal.push(f); track(f, false); }
106
+ for (const f of c.localOnly.filter(filter)) {
107
+ counters.skipped++; counters.keptLocal.push(f); track(f, false);
108
+ log.info("copy", "keep-local", `${f} (업스트림 무변경, 사용자 수정본 유지)`);
109
+ }
103
110
 
104
- for (const f of c.newFiles.filter(filter)) write(f);
111
+ for (const f of c.newFiles.filter(filter)) {
112
+ write(f);
113
+ log.info("copy", "write", `${f} (new)`);
114
+ }
105
115
 
106
- for (const f of c.upstreamOnly.filter(filter)) { write(f); counters.autoUpdated.push(f); }
116
+ for (const f of c.upstreamOnly.filter(filter)) {
117
+ write(f); counters.autoUpdated.push(f);
118
+ log.info("copy", "auto-update", `${f} (사용자 미수정, 최신으로 교체)`);
119
+ }
107
120
 
108
121
  // 사용자가 지운 파일은 조용히 되살리지 않는다. 복원 결정이 있을 때만 다시 쓴다.
109
122
  // 되살리지 않은 파일은 baselineTargets에 넣지 않는다 — 디스크에 없어 해시할 것이 없고,
110
123
  // 기존 baseline 항목은 병합으로 남아 다음 실행에서도 "지운 파일"로 인식된다.
111
124
  for (const f of c.removed.filter(filter)) {
112
- if (restoreRemoved.has(f)) { write(f); counters.restoredFiles.push(f); }
113
- else counters.removedKept.push(f);
125
+ if (restoreRemoved.has(f)) {
126
+ write(f); counters.restoredFiles.push(f);
127
+ log.info("copy", "restore", `${f} (사용자가 지웠지만 복원 결정)`);
128
+ } else {
129
+ counters.removedKept.push(f);
130
+ log.info("copy", "removed-kept", `${f} (사용자가 지움, 되살리지 않음)`);
131
+ }
114
132
  }
115
133
 
116
134
  for (const f of c.changed.filter(filter)) {
@@ -141,6 +159,7 @@ export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}
141
159
  const deployValues = new Map(); // Map<type, Map<key,value>> — deploy 블록용 ask 값
142
160
  counters.deployValues = deployValues;
143
161
  counters.copiedFiles = []; // 이번 실행에서 실제로 새로 쓰여진 파일명 (issue #19 — printSummary 정확성용)
162
+ counters.unchangedFiles = []; // skip(unchanged) 대상 — 로그에서 "왜 안 바뀌었나"의 근거
144
163
  counters.autoUpdated = []; // 질문 없이 최신으로 교체된 파일 (사용자 미수정)
145
164
  counters.keptLocal = []; // 질문 없이 사용자 수정본을 유지한 파일 (업스트림 무변경)
146
165
  counters.removedKept = []; // 사용자가 지웠고 되살리지 않은 파일
@@ -228,6 +247,7 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcTe
228
247
  counters.copied++;
229
248
  counters.backupAdded++;
230
249
  counters.copiedFiles.push(filename);
250
+ log.info("copy", "backup", `${filename} → ${filename}.bak (사용자 결정, 새 버전으로 교체)`);
231
251
  return;
232
252
  }
233
253
  if (decision === "template") {
@@ -236,9 +256,11 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcTe
236
256
  writeText(join(workflowsDir, templateName), srcText(src)); // 기존 .template.yaml 덮어씀(.sh rm -f + cp 등가)
237
257
  counters.templateAdded++;
238
258
  counters.copiedFiles.push(templateName);
259
+ log.info("copy", "template", `${filename} 유지 + ${templateName} 생성 (사용자 결정)`);
239
260
  return;
240
261
  }
241
262
  counters.skipped++; // 'skip'/미지정/ESC → 기존 유지 (.sh S)·force 기본)
263
+ log.info("copy", "skip", `${filename} (사용자 결정: 기존 유지)`);
242
264
  }
243
265
 
244
266
  // 대화형 사전 조사 — 사람이 답해야 하는 것만 뽑는다 (issue #69).
@@ -0,0 +1,141 @@
1
+ // 실행 추적 로그 — "무엇을 어떤 순서로 왜 그렇게 했는지"를 시간순으로 남긴다.
2
+ //
3
+ // 왜 즉시 append인가: 디버깅에서 가장 알고 싶은 순간은 크래시 직전이다. 끝나고 한 번에
4
+ // 쓰는 구조는 예외가 나면 아무것도 남기지 못한다(구 install-log.js가 그랬다).
5
+ //
6
+ // 왜 로컬 전용인가: 상세도를 제약하지 않기 위해서다. 로그 디렉토리에 .gitignore를 직접
7
+ // 두어 그 폴더만 추적에서 뺀다 — 루트 .gitignore는 건드리지 않는다(이슈 #7 원칙).
8
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ export const LOG_DIR = ".github/.wizard/logs";
12
+ const KEEP = 20; // 유지할 로그 파일 수
13
+ const GITIGNORE_BODY = "*\n!.gitignore\n";
14
+
15
+ // 값에 비밀이 들어갈 수 있는 키 — 현재 질문 항목에는 없지만(도메인·경로·포트·인증 '방식'),
16
+ // 앞으로 추가될 때 그냥 평문으로 남지 않도록 처음부터 걸어둔다.
17
+ const SECRET_KEY_RE = /(PASSWORD|SECRET|TOKEN|KEY|CREDENTIAL)/i;
18
+ const MASK = "***";
19
+
20
+ let state = null; // { file, clock, startedAt, disabled }
21
+
22
+ export function maskValue(key, value) {
23
+ // SSH_AUTH_METHOD처럼 "방식"만 담는 키는 비밀이 아니다 — 이름에 KEY가 들어가도 마스킹하지 않는다.
24
+ if (key === "SSH_AUTH_METHOD") return value;
25
+ return SECRET_KEY_RE.test(key) ? MASK : value;
26
+ }
27
+
28
+ // "2026-08-26 12:03:41" → "20260826-120341". 파일명이 곧 정렬 키가 되도록.
29
+ export function stampFrom(now = "") {
30
+ const m = String(now).match(/(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/);
31
+ if (!m) return "unknown";
32
+ return `${m[1]}${m[2]}${m[3]}-${m[4]}${m[5]}${m[6]}`;
33
+ }
34
+
35
+ export function logFilename(now, action = "install") {
36
+ return `${stampFrom(now)}-${action}.log`;
37
+ }
38
+
39
+ // 최근 KEEP개만 남기고 오래된 것부터 지운다. 파일명이 시각 오름차순이라 이름 정렬로 충분하다.
40
+ // 새 파일이 곧 하나 추가되므로 KEEP-1개까지 줄인다.
41
+ function rotate(dir) {
42
+ const logs = readdirSync(dir).filter((f) => f.endsWith(".log")).sort();
43
+ for (const f of logs.slice(0, Math.max(0, logs.length - (KEEP - 1)))) {
44
+ rmSync(join(dir, f), { force: true });
45
+ }
46
+ }
47
+
48
+ export function initLogger(targetRoot, opts = {}) {
49
+ const { action = "install", now = "", argv = [], templateVersion = "unknown", clock = () => new Date() } = opts;
50
+ try {
51
+ const dir = join(targetRoot, LOG_DIR);
52
+ mkdirSync(dir, { recursive: true });
53
+ // 사용자가 직접 둔 .gitignore가 있으면 존중한다.
54
+ const gi = join(dir, ".gitignore");
55
+ if (!existsSync(gi)) writeFileSync(gi, GITIGNORE_BODY);
56
+ rotate(dir);
57
+
58
+ const rel = `${LOG_DIR}/${logFilename(now, action)}`;
59
+ const file = join(targetRoot, rel);
60
+ const header =
61
+ `=== project-auto-wizard v${templateVersion} | ${action} | ${now} ===\n` +
62
+ `argv : ${["project-auto-wizard", ...argv].join(" ")}\n` +
63
+ `node : ${process.version} | ${process.platform} ${process.arch}\n` +
64
+ `target : ${targetRoot}\n\n`;
65
+ writeFileSync(file, header);
66
+ state = { file, rel, clock, startedAt: Date.now(), disabled: false };
67
+ return { path: rel };
68
+ } catch (e) {
69
+ // 로그를 못 남긴 것이 설치를 되돌릴 이유는 아니다 — 다만 조용히 삼키지는 않는다.
70
+ process.stderr.write(`[warn] 실행 로그를 시작하지 못했습니다: ${e.message}\n`);
71
+ state = null;
72
+ return null;
73
+ }
74
+ }
75
+
76
+ export function resetLogger() {
77
+ state = null;
78
+ }
79
+
80
+ // 이번 실행의 로그 경로(레포 상대). 설치 요약 화면이 사용자에게 안내할 때 쓴다.
81
+ export function currentLogPath() {
82
+ return state && !state.disabled ? state.rel : "";
83
+ }
84
+
85
+ // 구버전(.md) 설치 기록이 남아 있는지 — .gitignore는 이미 git이 추적 중인 파일에는
86
+ // 영향이 없으므로, 있으면 사용자가 직접 추적을 끊도록 안내해야 한다.
87
+ export function hasLegacyMdLogs(targetRoot) {
88
+ try {
89
+ const dir = join(targetRoot, LOG_DIR);
90
+ return existsSync(dir) && readdirSync(dir).some((f) => f.endsWith(".md"));
91
+ } catch { return false; }
92
+ }
93
+
94
+ // 열 너비 — 사람이 훑을 때 컬럼이 맞고, 에이전트가 컬럼 단위로 끊어 읽을 수 있게 고정한다.
95
+ const SCOPE_W = 8; // 가장 긴 scope('baseline')에 맞춘다 — 컬럼이 밀리면 훑기가 나빠진다
96
+ const ACTION_W = 10;
97
+
98
+ // 동아시아 전각 문자는 폭 2로 센다 (요약 블록 정렬용).
99
+ const WIDE_RE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE6F\uFF00-\uFF60\uFFE0-\uFFE6]/;
100
+ const dispWidth = (s) => [...String(s)].reduce((n, c) => n + (WIDE_RE.test(c) ? 2 : 1), 0);
101
+
102
+ // 헤더의 실행 시각과 파일명이 UTC 기준(utcNow)이므로 라인 시각도 UTC로 맞춘다 —
103
+ // 로컬 시간을 쓰면 같은 파일 안에서 헤더와 라인이 시간대만큼 어긋난다.
104
+ function hhmmss(date) {
105
+ const p = (n, w = 2) => String(n).padStart(w, "0");
106
+ return `${p(date.getUTCHours())}:${p(date.getUTCMinutes())}:${p(date.getUTCSeconds())}.${p(date.getUTCMilliseconds(), 3)}`;
107
+ }
108
+
109
+ function write(level, scope, action, detail = "") {
110
+ if (!state || state.disabled) return;
111
+ try {
112
+ const line = `${hhmmss(state.clock())} ${level} ${String(scope).padEnd(SCOPE_W)} ${String(action).padEnd(ACTION_W)} ${detail}`.trimEnd();
113
+ appendFileSync(state.file, line + "\n");
114
+ } catch (e) {
115
+ // 첫 실패에서 한 번만 알리고 이후는 조용히 끈다 — 매 줄 경고를 뱉으면 설치 화면이 무너진다.
116
+ state.disabled = true;
117
+ process.stderr.write(`[warn] 실행 로그 기록을 중단합니다: ${e.message}\n`);
118
+ }
119
+ }
120
+
121
+ export const log = {
122
+ info: (scope, action, detail) => write("INFO", scope, action, detail),
123
+ warn: (scope, action, detail) => write("WARN", scope, action, detail),
124
+ fail: (scope, action, detail) => write("FAIL", scope, action, detail),
125
+ // rows: Array<[label, value]> — 라벨 폭을 맞춰 정렬한다.
126
+ summary(rows = []) {
127
+ if (!state || state.disabled || !rows.length) return;
128
+ // 한글은 터미널에서 2칸을 차지한다 — 문자 수로 맞추면 눈으로 볼 때 어긋난다.
129
+ const w = Math.max(...rows.map(([k]) => dispWidth(k)));
130
+ const body = rows.map(([k, v]) => `${k}${" ".repeat(w - dispWidth(k))} : ${v}`).join("\n");
131
+ try {
132
+ appendFileSync(state.file, `\n=== 요약 ===\n${body}\n`);
133
+ } catch {
134
+ state.disabled = true;
135
+ }
136
+ },
137
+ };
138
+
139
+ export function closeLogger() {
140
+ state = null;
141
+ }
package/src/index.js CHANGED
@@ -21,6 +21,7 @@ import { runFull } from "./commands/full.js";
21
21
  import { runUninstall, runUninstallFlow } from "./commands/uninstall.js";
22
22
  import * as prompts from "./ui/prompts.js";
23
23
  import { runInteractive } from "./commands/interactive.js";
24
+ import { initLogger, closeLogger, currentLogPath, hasLegacyMdLogs } from "./core/logger.js";
24
25
  import { runStatus, printStatus } from "./commands/status.js";
25
26
  import { runDoctor, printDoctorReport } from "./commands/doctor.js";
26
27
  import { planDryRun, printDryRun } from "./commands/dry-run.js";
@@ -59,7 +60,7 @@ async function defaultPromptRepoName(repoName) {
59
60
  // payloadRoot: 테스트 픽스처 주입점 (기본: 패키지 동봉 payload/)
60
61
  // clock: {now, today} 주입 (기본 현재 UTC).
61
62
  // exec/promptRepoName: purge 모드 안전장치 게이트용 주입점 (기본 실제 구현, 테스트는 mock 주입).
62
- export async function run(argv, {
63
+ async function runInner(argv, {
63
64
  cwd = process.cwd(), payloadRoot, clock,
64
65
  exec = defaultExec, promptRepoName = defaultPromptRepoName,
65
66
  } = {}) {
@@ -75,6 +76,19 @@ export async function run(argv, {
75
76
 
76
77
  const payload = assertPayload(payloadRoot ?? resolvePayloadRoot());
77
78
 
79
+ // 시각은 여기서 한 번만 계산한다 — 로그 파일명과 설치 기록이 같은 값을 쓰도록.
80
+ const { now, today } = clock || utcNow();
81
+
82
+ // dry-run은 "파일을 바꾸지 않는다"가 계약이므로 로그도 남기지 않는다.
83
+ // --version/--help는 이 지점 이전에 이미 반환되므로 자연히 제외된다.
84
+ const loggedAction = opts.dryRun ? null
85
+ : opts.mode === "uninstall" ? "uninstall"
86
+ : opts.mode === "purge" ? "purge"
87
+ : "install";
88
+ if (loggedAction) {
89
+ initLogger(cwd, { action: loggedAction, now, argv, templateVersion: readTemplateVersion() });
90
+ }
91
+
78
92
  // 대화형 모드 — 인자 없이 실행 or --mode interactive
79
93
  if (opts.mode === "interactive") {
80
94
  // --dry-run은 대화형 모드에서 조용히 무시되면 안 됨(실제 설치가 진행돼버림) — 명시 에러로 차단.
@@ -254,8 +268,6 @@ export async function run(argv, {
254
268
  }
255
269
  }
256
270
 
257
- const { now, today } = clock || utcNow();
258
-
259
271
  const context = createContext({
260
272
  mode: opts.mode, force: opts.force, types, version, versionCode, branch,
261
273
  branches,
@@ -304,8 +316,19 @@ export async function run(argv, {
304
316
  gitignoreUpdated: result?.gitignoreUpdated === true,
305
317
  unresolved: result?.unresolved ?? [],
306
318
  secrets: result?.secrets ?? new Map(),
307
- installLogPath: result?.installLog?.path ?? "",
319
+ logPath: currentLogPath(),
320
+ legacyMdLogs: hasLegacyMdLogs(cwd),
308
321
  cleanup: result?.cleanup ?? null,
309
322
  });
310
323
  return 0;
311
324
  }
325
+
326
+ // 공개 진입점 — 어떤 경로로 끝나든(정상 반환·CliError·예외) 로거를 닫는다.
327
+ // 본문을 통째로 try로 감싸면 들여쓰기가 전부 바뀌므로 얇은 래퍼로 분리했다.
328
+ export async function run(argv, opts = {}) {
329
+ try {
330
+ return await runInner(argv, opts);
331
+ } finally {
332
+ closeLogger();
333
+ }
334
+ }
package/src/ui/summary.js CHANGED
@@ -8,7 +8,7 @@ const SEPARATOR = "────────────────────
8
8
  export function printSummary(ctx) {
9
9
  const { mode, types = [], version = "", versionCode = null, copiedFiles = [], branches = null, gitignoreUpdated = false,
10
10
  // 설치 후 검증·기록 (#79, #80, #81)
11
- answers = [], unresolved = [], secrets = new Map(), installLogPath = "", cleanup = null } = ctx || {};
11
+ answers = [], unresolved = [], secrets = new Map(), logPath = "", legacyMdLogs = false, cleanup = null } = ctx || {};
12
12
  const err = (s = "") => process.stderr.write(`${s}\n`);
13
13
  // 색상은 ansi.js의 공용 가드로 통일 (NO_COLOR + stderr TTY 여부)
14
14
  const enabled = colorEnabled(process.stderr);
@@ -111,9 +111,14 @@ export function printSummary(ctx) {
111
111
  for (const f of cleanup.backedUp || []) err(` • ${f} → ${f}.bak ${paint("수정하신 내용이 있어 백업", A.dim, enabled)}`);
112
112
  err("");
113
113
  }
114
- if (installLogPath) {
115
- err(` 📋 설치 기록: ${installLogPath}`);
116
- err(" → 나중에 '무엇을 어떤 값으로 설치했는지' 확인할 파일을 보세요");
114
+ if (logPath) {
115
+ err(` 📋 실행 로그: ${logPath}`);
116
+ err(" → 무엇을 어떤 값으로 설치했는지 시간순으로 남아 있습니다 (git에 올라가지 않습니다)");
117
+ err("");
118
+ }
119
+ if (legacyMdLogs) {
120
+ err(" ℹ️ 이전 버전의 설치 기록(.md)이 git에 추적 중입니다:");
121
+ err(" git rm -r --cached .github/.wizard/logs");
117
122
  err("");
118
123
  }
119
124
 
@@ -1,182 +0,0 @@
1
- // 설치 로그 (이슈 #79) — 실행마다 "무엇을 어떤 값으로 설치했는지"를 레포에 한 건 남긴다.
2
- //
3
- // 왜 필요한가: version.yml에는 버전·타입·경로·브랜치·옵션만 남는다. 감지 근거, 질문별 답변,
4
- // 특히 환경설정 답변(도메인·포트·볼륨 경로)은 워크플로우 YAML 안으로 흩어질 뿐 한 곳에
5
- // 기록되지 않아, 배포가 실패했을 때 "설치할 때 뭘로 답했더라"를 역추적할 방법이 없었다.
6
- // 터미널 스크롤백은 에이전트가 볼 수 없고 다른 클론에도 남지 않는다.
7
- //
8
- // 위치는 .github/.wizard/ 아래 — 마법사가 이미 baseline.json을 두는 자기 메타데이터 폴더다.
9
- // 새 최상위 폴더를 만들 이유가 없고, 삭제 모드에서도 경계가 이 폴더 하나로 끝난다.
10
- import { join } from "node:path";
11
- import { writeText } from "./fsutil.js";
12
-
13
- export const LOG_DIR = ".github/.wizard/logs";
14
-
15
- // 값에 비밀이 들어갈 수 있는 키 — 현재 질문 항목에는 없지만(도메인·경로·포트·인증 '방식'),
16
- // 앞으로 추가될 때 그냥 평문으로 커밋되지 않도록 처음부터 걸어둔다. 이 파일은 커밋 대상이다.
17
- const SECRET_KEY_RE = /(PASSWORD|SECRET|TOKEN|KEY|CREDENTIAL)/i;
18
- const MASK = "***";
19
-
20
- export function maskValue(key, value) {
21
- // SSH_AUTH_METHOD처럼 "방식"만 담는 키는 비밀이 아니다 — 이름에 KEY가 들어가도 마스킹하지 않는다.
22
- if (key === "SSH_AUTH_METHOD") return value;
23
- return SECRET_KEY_RE.test(key) ? MASK : value;
24
- }
25
-
26
- // "2026-08-12 18:15:30" → "20260812-181530". 파일명이 곧 정렬 키가 되도록.
27
- export function stampFrom(now = "") {
28
- const m = String(now).match(/(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/);
29
- if (!m) return "unknown";
30
- return `${m[1]}${m[2]}${m[3]}-${m[4]}${m[5]}${m[6]}`;
31
- }
32
-
33
- export function logFilename(now, action = "install") {
34
- return `${stampFrom(now)}-${action}.md`;
35
- }
36
-
37
- const yamlStr = (v) => `"${String(v ?? "").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
38
- const yamlList = (arr) => `[${(arr || []).map(yamlStr).join(", ")}]`;
39
-
40
- // 마크다운 렌더 — 상단은 기계가 읽는 front matter, 아래는 사람이 읽는 본문.
41
- // 에이전트가 파싱만 해도 핵심을 얻고, 사람이 열면 그대로 읽힌다.
42
- export function renderInstallLog(d = {}) {
43
- const {
44
- action = "install", at = "", templateVersion = "",
45
- previousTemplateVersion = "", mode = "", types = [], markers = new Map(),
46
- version = "", branch = "", branches = null, paths = new Map(),
47
- options = {}, answers = [], result = {}, unresolved = [], secrets = new Map(),
48
- warnings = [], cleanup = null,
49
- } = d;
50
-
51
- const L = [];
52
- L.push("---");
53
- L.push(`action: ${yamlStr(action)}`);
54
- L.push(`at: ${yamlStr(at)}`);
55
- L.push(`template_version: ${yamlStr(templateVersion)}`);
56
- if (previousTemplateVersion) L.push(`previous_template_version: ${yamlStr(previousTemplateVersion)}`);
57
- L.push(`mode: ${yamlStr(mode)}`);
58
- L.push(`project_types: ${yamlList(types)}`);
59
- L.push(`version: ${yamlStr(version)}`);
60
- L.push(`default_branch: ${yamlStr(branch)}`);
61
- if (branches) {
62
- L.push(`branches: { main: ${yamlStr(branches.main)}, develop: ${yamlStr(branches.develop)}, mode: ${yamlStr(branches.mode)} }`);
63
- }
64
- L.push(`unresolved_count: ${unresolved.length}`);
65
- L.push(`required_secrets: ${yamlList([...secrets.keys()])}`);
66
- L.push("---");
67
- L.push("");
68
- L.push(`# 설치 로그 — ${at}`);
69
- L.push("");
70
- L.push("project-auto-wizard가 이 레포에 무엇을 설치했는지 남긴 기록입니다. 직접 편집하지 마세요.");
71
- L.push("");
72
-
73
- L.push("## 실행");
74
- L.push("");
75
- L.push("| 항목 | 값 |");
76
- L.push("|---|---|");
77
- L.push(`| 동작 | ${action === "install" ? "신규 설치" : action === "update" ? "업데이트" : action} |`);
78
- L.push(`| 템플릿 버전 | ${previousTemplateVersion ? `${previousTemplateVersion} → ${templateVersion}` : templateVersion || "-"} |`);
79
- L.push(`| 설치 모드 | ${mode || "-"} |`);
80
- L.push("");
81
-
82
- L.push("## 감지 결과");
83
- L.push("");
84
- L.push("| 항목 | 값 | 근거 |");
85
- L.push("|---|---|---|");
86
- for (const t of types) {
87
- L.push(`| 타입 | ${t} | ${markers?.get?.(t) || "직접 선택"} |`);
88
- }
89
- L.push(`| 버전 | ${version} | ${d.versionSource || "자동 감지"} |`);
90
- L.push(`| 브랜치 | ${branch} | git |`);
91
- for (const [t, p] of paths) L.push(`| 경로 (${t}) | ${p} | |`);
92
- L.push("");
93
- if (warnings.length) {
94
- L.push("감지 중 경고:");
95
- L.push("");
96
- for (const w of warnings) L.push(`- ${w}`);
97
- L.push("");
98
- }
99
-
100
- L.push("## 선택 항목");
101
- L.push("");
102
- L.push("| 항목 | 값 |");
103
- L.push("|---|---|");
104
- L.push(`| 라이브러리 publish (Nexus·GitHub Packages) | ${options.nexus ? "포함" : "제외"} |`);
105
- L.push(`| Secret 서버 백업 | ${options.secretBackup ? "포함" : "제외"} |`);
106
- L.push(`| 자동 버전 승격 | ${options.semverAuto === false ? "사용 안 함" : "사용"} |`);
107
- L.push(`| 서버 배포 방식 | ${options.deployStyle || "-"} |`);
108
- L.push("");
109
-
110
- L.push("## 환경설정 답변");
111
- L.push("");
112
- if (!answers.length) {
113
- L.push("이 설치에서는 환경설정 질문이 없었습니다.");
114
- } else {
115
- L.push("`기본값` 열이 `예`면 질문에서 그대로 Enter를 누른 값입니다. 배포가 예상과 다르게 동작하면 여기부터 확인하세요.");
116
- L.push("");
117
- L.push("| 키 | 항목 | 값 | 기본값 | 사용처 |");
118
- L.push("|---|---|---|---|---|");
119
- for (const a of answers) {
120
- L.push(`| \`${a.key}\` | ${a.label} | \`${maskValue(a.key, a.value)}\` | ${a.isDefault ? "예" : "아니오"} | ${a.scope || ""} |`);
121
- }
122
- }
123
- L.push("");
124
-
125
- L.push("## 설치 결과");
126
- L.push("");
127
- const sec = (title, items, fmt = (x) => `- \`${x}\``) => {
128
- if (!items || !items.length) return;
129
- L.push(`### ${title} (${items.length})`);
130
- L.push("");
131
- for (const it of items) L.push(fmt(it));
132
- L.push("");
133
- };
134
- sec("새로 설치된 파일", result.copiedFiles);
135
- sec("이전 배포 방식 정리 — 삭제", cleanup?.removed);
136
- sec("이전 배포 방식 정리 — .bak 백업 (수정 내용 보존)", cleanup?.backedUp);
137
- sec("건너뛴 파일", result.skippedFiles);
138
- sec("백업 후 교체한 파일", result.backupFiles);
139
- if (result.gitignoreUpdated) { L.push("`.gitignore`를 갱신했습니다 (충돌 백업 파일 무시 항목 추가)."); L.push(""); }
140
-
141
- L.push("## 남은 할 일");
142
- L.push("");
143
- if (unresolved.length) {
144
- L.push("### ⚠️ 값이 채워지지 않은 항목");
145
- L.push("");
146
- L.push("마법사가 값을 계산하지 못해 플레이스홀더가 그대로 남았습니다. **이 상태로는 해당 워크플로우가 정상 동작하지 않습니다.** 직접 채워 주세요.");
147
- L.push("");
148
- L.push("| 파일 | 줄 | 토큰 |");
149
- L.push("|---|---|---|");
150
- for (const u of unresolved) L.push(`| \`${u.filename}\` | ${u.line} | \`${u.token}\` |`);
151
- L.push("");
152
- }
153
- if (secrets.size) {
154
- L.push("### 등록해야 하는 GitHub Secret");
155
- L.push("");
156
- L.push("Settings > Secrets and variables > Actions 에서 등록합니다. 등록 전에는 해당 워크플로우가 실패합니다.");
157
- L.push("");
158
- L.push("| Secret | 사용하는 워크플로우 |");
159
- L.push("|---|---|");
160
- for (const [name, users] of secrets) L.push(`| \`${name}\` | ${users.map((u) => `\`${u}\``).join(", ")} |`);
161
- L.push("");
162
- }
163
- if (!unresolved.length && !secrets.size) {
164
- L.push("추가로 조치할 항목이 없습니다.");
165
- L.push("");
166
- }
167
-
168
- return L.join("\n") + "\n";
169
- }
170
-
171
- // 로그 기록. 실패해도 설치 자체는 성공으로 끝나야 하므로 예외를 삼키고 null을 돌려준다 —
172
- // 로그를 못 남긴 것이 설치를 되돌릴 이유는 아니다.
173
- export function writeInstallLog(targetRoot, data = {}) {
174
- try {
175
- const filename = logFilename(data.at, data.action || "install");
176
- const rel = `${LOG_DIR}/${filename}`;
177
- writeText(join(targetRoot, rel), renderInstallLog(data));
178
- return { path: rel };
179
- } catch {
180
- return null;
181
- }
182
- }