project-auto-wizard 0.1.34 → 0.2.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.1.33 (2026-08-10)
20
+ ## 최신 버전 : v0.1.34 (2026-08-10)
21
21
 
22
22
  [전체 버전 기록 보기](CHANGELOG.md)
23
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-auto-wizard",
3
- "version": "0.1.34",
3
+ "version": "0.2.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",
@@ -10,9 +10,10 @@ import { buildVersionYml, parseExisting } from "../core/version-yml.js";
10
10
  import { readVersionYmlTemplate } from "../core/assets.js";
11
11
  import { markerForType } from "../core/detect.js";
12
12
  import { addVersionSectionToReadme } from "../core/copy/readme.js";
13
- import { copyWorkflows } from "../core/copy/workflows.js";
13
+ import { copyWorkflows, computeBaselineEntries, makeSrcText } from "../core/copy/workflows.js";
14
14
  import { copyScripts } from "../core/copy/simple.js";
15
15
  import { ensureGitignore } from "../core/copy/gitignore.js";
16
+ import { readBaseline, writeBaseline } from "../core/baseline.js";
16
17
 
17
18
  // context: { version, types, paths:Map, branch, versionCode, includeNexus, includeSecretBackup,
18
19
  // force, repoName, resolvers, now, today }
@@ -56,5 +57,19 @@ export function runFull(context, payloadRoot, targetRoot = ".", hooks = {}) {
56
57
  const gitignoreUpdated = wfCounters.backupAdded > 0 || wfCounters.templateAdded > 0;
57
58
  if (gitignoreUpdated) ensureGitignore(targetRoot);
58
59
 
60
+ // 6. baseline 기록 (issue #69) — 다음 업데이트에서 "누가 바꿨는지"를 가를 기준점.
61
+ // env 치환까지 전부 끝난 뒤에 해시해야 디스크 내용이 최종형이다. 그래서 copyWorkflows 안이
62
+ // 아니라 여기서 기록한다.
63
+ // 기존 baseline은 병합 대상 — 이번에 건드리지 않은 파일의 기준점을 잃지 않는다.
64
+ writeBaseline(targetRoot, {
65
+ templateVersion,
66
+ installedAt: now || today || "",
67
+ entries: computeBaselineEntries(
68
+ wfCounters.baselineTargets || new Map(),
69
+ join(targetRoot, PATHS.workflowsDir),
70
+ makeSrcText(context.branches || null)),
71
+ previous: readBaseline(targetRoot),
72
+ });
73
+
59
74
  return { workflows: wfCounters, gitignoreUpdated };
60
75
  }
@@ -12,7 +12,7 @@ import { resolveProjectPaths } from "../core/paths-resolve.js";
12
12
  import { resolveBranchConfig, detectRemoteBranches, ensureDevelopBranch } from "../core/branches.js";
13
13
  import { askAllOptionalWorkflows } from "../core/options-ask.js";
14
14
  import { promptEnvPlan } from "../ui/env-plan.js";
15
- import { listWorkflowConflicts } from "../core/copy/workflows.js";
15
+ import { surveyWorkflows } from "../core/copy/workflows.js";
16
16
  import { createContext, VALID_TYPES } from "../context.js";
17
17
  import { runFull } from "./full.js";
18
18
  import { runUninstallFlow } from "./uninstall.js";
@@ -206,17 +206,37 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
206
206
  });
207
207
  ctx.templateVersion = templateVersion;
208
208
 
209
- // 기존 워크플로우 충돌 3지선 타입당 1회 결정을 파일에 캐시 적용 (.sh L3440~3508 UX 등가)
209
+ // 사용자가 답해야 하는 것만 묻는다 (issue #69). baseline 3-way가 자동으로 안전한 경우를
210
+ // 걸러내므로, 여기 오는 것은 (a) 양쪽이 다 바뀐 진짜 충돌과 (b) 사용자가 지운 파일뿐이다.
210
211
  let hooks = {};
211
212
  if (showOptional && io.engineIo?.select) {
212
- const conflicts = listWorkflowConflicts(ctx, payload, cwd);
213
+ const { conflicts, removed } = surveyWorkflows(ctx, payload, cwd);
214
+
215
+ // (b) 지운 파일 — 조용히 되살리지 않는다. 되살리는 쪽이 기본이 아니라는 점이 핵심이다.
216
+ const restoreRemoved = new Set();
217
+ if (removed.length) {
218
+ io.note?.(removed.map((r) => ` - ${r.filename}`).join("\n"),
219
+ `이전 설치에는 있었지만 지금 없는 워크플로우 (${removed.length}개)`);
220
+ for (const { filename } of removed) {
221
+ const sel = await io.engineIo.select({
222
+ message: `${filename} — 직접 지우신 것으로 보입니다. 다시 추가할까요?`,
223
+ options: [
224
+ { value: "keep", label: "추가하지 않음 — 지운 상태 유지 (기본)" },
225
+ { value: "restore", label: "다시 추가 — 최신 버전으로 설치" },
226
+ ],
227
+ });
228
+ if (!isCancel(sel) && sel === "restore") restoreRemoved.add(filename); // ESC = 유지
229
+ }
230
+ }
231
+
232
+ // (a) 진짜 충돌 3지선 — 타입당 1회 결정을 파일에 캐시 적용 (.sh L3440~3508 UX 등가)
233
+ const decisions = new Map();
213
234
  if (conflicts.length) {
214
235
  const perType = new Map();
215
- const decisions = new Map();
216
236
  for (const { filename, type } of conflicts) {
217
237
  if (!perType.has(type)) {
218
238
  const sel = await io.engineIo.select({
219
- message: `기존 워크플로우와 내용이 다른 파일이 있습니다 (${type}) — 어떻게 할까요?`,
239
+ message: `내 수정과 업스트림 변경이 겹칩니다 (${type}) — 어떻게 할까요?`,
220
240
  options: [
221
241
  { value: "skip", label: "건너뛰기 — 기존 파일 유지 (기본)" },
222
242
  { value: "backup", label: ".bak 백업 후 새 버전으로 교체" },
@@ -227,8 +247,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
227
247
  }
228
248
  decisions.set(filename, perType.get(type));
229
249
  }
230
- hooks = { decisions };
231
250
  }
251
+ hooks = { decisions, restoreRemoved };
232
252
  }
233
253
 
234
254
  const result = runFull(ctx, payload, cwd, hooks);
@@ -18,6 +18,7 @@ export function planPurge(payloadRoot, targetRoot = ".", keepFlags = {}) {
18
18
  const removalPlan = planRemoval(payloadRoot, targetRoot);
19
19
  return {
20
20
  workflows: keepFlags.workflows ? [] : removalPlan.workflows,
21
+ baseline: keepFlags.workflows ? [] : removalPlan.baseline,
21
22
  scripts: keepFlags.scripts ? [] : removalPlan.scripts,
22
23
  versionYml: !keepFlags.versionYml && existsSync(join(targetRoot, PATHS.versionFile)),
23
24
  readmeSection: !keepFlags.readme && hasVersionSection(targetRoot),
@@ -55,6 +56,7 @@ export function executePurge(payloadRoot, targetRoot = ".", keepFlags = {}) {
55
56
  const wfDir = join(targetRoot, PATHS.workflowsDir);
56
57
  for (const name of plan.workflows) remove(join(wfDir, name));
57
58
  for (const name of plan.scripts) remove(join(targetRoot, PATHS.scriptsDir, name));
59
+ for (const p of plan.baseline || []) remove(join(targetRoot, p));
58
60
  if (plan.versionYml) remove(join(targetRoot, PATHS.versionFile));
59
61
  const readmeSection = plan.readmeSection && removeVersionSectionFromReadme(targetRoot) === "removed";
60
62
  for (const f of plan.changelog) remove(join(targetRoot, f));
@@ -33,7 +33,17 @@ export function runStatus(payloadRoot, targetRoot = ".") {
33
33
  types: existing.types,
34
34
  branches: existing.branches,
35
35
  options: existing.options,
36
- modifiedFiles: plan.changed.map((f) => f.filename),
36
+ // 사용자가 손댄 파일 = 진짜 충돌(changed) + 업스트림은 그대로인데 내가 고친 것(localOnly).
37
+ // baseline이 없으면 localOnly는 항상 비어 있어 종전과 동일하게 동작한다 (issue #69).
38
+ modifiedFiles: [...plan.changed, ...plan.localOnly].map((f) => f.filename),
39
+ // 업데이트 시 무슨 일이 일어날지 미리 보여주는 버킷들 — 판단 재료가 없어 사용자가
40
+ // 직접 git diff를 떠야 했던 문제를 없앤다.
41
+ buckets: {
42
+ autoUpdatable: plan.upstreamOnly.map((f) => f.filename), // 질문 없이 최신으로 교체됨
43
+ localKept: plan.localOnly.map((f) => f.filename), // 질문 없이 내 수정본 유지됨
44
+ conflicts: plan.changed.map((f) => f.filename), // 양쪽 변경 — 검토 필요
45
+ removed: plan.removed.map((f) => f.filename), // 내가 지웠고 복원하지 않음
46
+ },
37
47
  };
38
48
  }
39
49
 
@@ -59,6 +69,16 @@ export function printStatus(status) {
59
69
  } else {
60
70
  lines.push("", "모든 워크플로우 파일이 설치 시점 기본값과 동일합니다 (수정 없음).");
61
71
  }
72
+
73
+ // 업데이트하면 무슨 일이 일어나는지 (issue #69). baseline이 없는 설치는 전부 0이라 출력하지 않는다.
74
+ const b = status.buckets || { autoUpdatable: [], localKept: [], conflicts: [], removed: [] };
75
+ if (b.autoUpdatable.length || b.localKept.length || b.conflicts.length || b.removed.length) {
76
+ lines.push("", "지금 업데이트하면:");
77
+ lines.push(` 업스트림 변경 — 자동 적용 가능 ${String(b.autoUpdatable.length).padStart(3)}`);
78
+ lines.push(` 내가 수정 · 업스트림 그대로 ${String(b.localKept.length).padStart(3)} (유지)`);
79
+ lines.push(` 양쪽 변경 — 검토 필요 ${String(b.conflicts.length).padStart(3)}`);
80
+ lines.push(` 내가 삭제함 — 복원 안 함 ${String(b.removed.length).padStart(3)}`);
81
+ }
62
82
  lines.push("");
63
83
  console.log(lines.join("\n"));
64
84
  }
@@ -16,6 +16,8 @@ export function planUninstall(payloadRoot, targetRoot, selection) {
16
16
  const removalPlan = planRemoval(payloadRoot, targetRoot);
17
17
  return {
18
18
  workflows: selection.workflows ? removalPlan.workflows : [],
19
+ // baseline은 워크플로우 해시 기록 — 워크플로우를 지우면 함께 사라져야 한다 (issue #69)
20
+ baseline: selection.workflows ? removalPlan.baseline : [],
19
21
  scripts: selection.scripts ? removalPlan.scripts : [],
20
22
  readme: selection.readme ? hasVersionSection(targetRoot) : false,
21
23
  gitignore: selection.gitignore ? hasAutoAddedEntries(targetRoot) : false,
@@ -29,6 +31,7 @@ export function runUninstall(context, payloadRoot, targetRoot, selection) {
29
31
  const wfDir = join(targetRoot, PATHS.workflowsDir);
30
32
  for (const name of plan.workflows) remove(join(wfDir, name));
31
33
  for (const name of plan.scripts) remove(join(targetRoot, PATHS.scriptsDir, name));
34
+ for (const p of plan.baseline || []) remove(join(targetRoot, p));
32
35
  // removeVersionSectionFromReadme/removeAutoAddedEntriesFromGitignore는 plan이 "제거 대상"으로
33
36
  // 판단했더라도 실제로는 안전하게 포기(skip-*)할 수 있다 — 반환 상태를 그대로 신뢰하지 않고
34
37
  // 실제 결과로 plan을 덮어써서 호출부(CLI/대화형 요약)가 거짓 성공을 보고하지 않게 한다.
@@ -0,0 +1,76 @@
1
+ // 설치 시점 baseline (issue #69) — 업데이트에서 "누가 바꿨는지"를 가르는 기준점.
2
+ //
3
+ // 왜 필요한가: isUnchanged()는 payload(theirs)와 설치본(ours)을 2-way로 비교한다.
4
+ // base가 없으니 업스트림이 한 글자만 고쳐도 사용자가 손대지 않은 파일이 changed로 떨어지고,
5
+ // 결국 "전부 skip(업데이트 못 받음)" 아니면 "전부 backup(사용자 수정 전멸)" 둘 중 하나만
6
+ // 고를 수 있게 된다.
7
+ //
8
+ // 파일 사본이 아니라 해시만 남긴다 — 분류가 목적이지 자동 병합이 목적이 아니다.
9
+ //
10
+ // 해시를 두 개 두는 이유(이슈 원안은 하나였다): env 치환으로 사용자 값이 들어간 파일은
11
+ // 디스크 내용과 "기본값으로 렌더한 결과"가 애초에 다르다. 하나로는 두 질문에 동시에 답할 수 없다.
12
+ // - installed : 설치 시점 우리가 디스크에 쓴 내용 → "사용자가 그 뒤에 손댔는가"
13
+ // - rendered : 그 시점 payload를 기본값 치환한 결과 → "업스트림이 그 뒤에 바뀌었는가"
14
+ //
15
+ // installed는 우리가 실제로 쓴 파일에만 채운다. 사용자 수정본을 installed로 기록하면
16
+ // "우리가 쓴 것"이라고 거짓말하는 셈이고, 다음 업데이트에서 그 파일이 조용히 덮인다.
17
+ import { createHash } from "node:crypto";
18
+ import { join } from "node:path";
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { writeText } from "./fsutil.js";
21
+
22
+ export const BASELINE_DIR = ".github/.wizard";
23
+ export const BASELINE_PATH = ".github/.wizard/baseline.json";
24
+
25
+ export function sha256(text) {
26
+ return "sha256:" + createHash("sha256").update(String(text), "utf8").digest("hex");
27
+ }
28
+
29
+ // 없거나 깨졌으면 null — 호출부는 "base 미상"으로 폴백한다(조용히 빈 baseline을 쓰지 않는다.
30
+ // 빈 baseline은 "기록이 없다"가 아니라 "전부 삭제됐다"로 오해될 수 있다).
31
+ export function readBaseline(targetRoot = ".") {
32
+ const p = join(targetRoot, BASELINE_PATH);
33
+ if (!existsSync(p)) return null;
34
+ try {
35
+ const data = JSON.parse(readFileSync(p, "utf8"));
36
+ if (!data || typeof data !== "object" || typeof data.files !== "object" || data.files === null) return null;
37
+ return data;
38
+ } catch {
39
+ return null; // 손상된 baseline은 없는 것으로 취급 — 업데이트를 막지 않는다
40
+ }
41
+ }
42
+
43
+ // entries: Map<filename, {installed?:string|null, rendered:string}>
44
+ // 기존 baseline은 병합 대상이다 — 이번 실행에서 건드리지 않은 파일의 기준점을 잃지 않는다.
45
+ export function writeBaseline(targetRoot, { templateVersion, installedAt, entries, previous = null }) {
46
+ const files = { ...(previous?.files || {}) };
47
+ for (const [filename, entry] of entries) {
48
+ const prev = files[filename] || {};
49
+ files[filename] = {
50
+ // installed는 이번에 실제로 쓴 경우에만 갱신. 유지(skip)한 파일은 예전 기준점을 지킨다.
51
+ installed: entry.installed ?? prev.installed ?? null,
52
+ rendered: entry.rendered,
53
+ };
54
+ }
55
+ const out = {
56
+ templateVersion: templateVersion || "unknown",
57
+ installedAt: installedAt || "",
58
+ files,
59
+ };
60
+ writeText(join(targetRoot, BASELINE_PATH), JSON.stringify(out, null, 2) + "\n");
61
+ return out;
62
+ }
63
+
64
+ // baseline에는 있는데 디스크에 없는 파일 = 사용자가 지운 것.
65
+ // 별도의 삭제 이력 파일이 필요 없다는 것이 이 설계의 부산물이다.
66
+ // candidates: payload가 이번에 설치하려는 파일명 목록 (그 밖의 baseline 항목은 관심 없다)
67
+ export function detectRemoved(baseline, candidates, workflowsDir) {
68
+ if (!baseline) return [];
69
+ const removed = [];
70
+ for (const filename of candidates) {
71
+ if (!baseline.files[filename]) continue; // 우리가 설치한 적 없는 파일 — 판단 근거 없음
72
+ if (existsSync(join(workflowsDir, filename))) continue;
73
+ removed.push(filename);
74
+ }
75
+ return removed;
76
+ }
@@ -6,12 +6,13 @@ import { join, basename } from "node:path";
6
6
  import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
7
7
  import { PATHS, PAYLOAD } from "../paths.js";
8
8
  import { exists, writeText, listYamlFiles } from "../fsutil.js";
9
- import { isUnchanged, substituteEnv } from "../wizard-env.js";
9
+ import { substituteEnv } from "../wizard-env.js";
10
10
  import { substitute } from "../branding.js";
11
+ import { sha256, readBaseline } from "../baseline.js";
11
12
 
12
13
  // 원본 텍스트 로더 — context.branches가 있으면 {{MAIN_BRANCH}}/{{DEVELOP_BRANCH}} 치환 적용.
13
14
  // classify(unchanged 판정)와 실제 복사가 같은 치환본을 봐야 재실행 시 가짜 충돌이 없다.
14
- function makeSrcText(branches) {
15
+ export function makeSrcText(branches) {
15
16
  return (p) => {
16
17
  const raw = readFileSync(p, "utf8");
17
18
  return branches ? substitute(raw, branches) : raw;
@@ -34,34 +35,100 @@ function configureEnv(targetPath, { type, projectPath = ".", repoName = "", reso
34
35
  writeFileSync(targetPath, out);
35
36
  }
36
37
 
37
- // 3분류 (신규/unchanged/changed)대상 워크플로우 디렉토리 기준.
38
- // srcText: 브랜치 치환이 적용된 원본 로더 (makeSrcText).
39
- function classify(srcDir, workflowsDir, envOpts, srcText) {
40
- const result = { newFiles: [], unchanged: [], changed: [] };
38
+ // payload 원본을 "기본값으로 가상 치환한 최종형" isUnchanged가 내부에서 쓰는 것과 같은 값이다.
39
+ // baseline.rendered와 비교해 "업스트림이 바뀌었는가"를 판정하는 쓴다.
40
+ function renderVirtual(templateContent, envOpts) {
41
+ return substituteEnv(templateContent, { ...envOpts, useDefaults: true });
42
+ }
43
+
44
+ // 분류 — 대상 워크플로우 디렉토리 기준. srcText: 브랜치 치환이 적용된 원본 로더 (makeSrcText).
45
+ //
46
+ // baseline이 있으면 3-way로 가른다 (issue #69). base가 없던 시절에는 업스트림이 한 글자만 고쳐도
47
+ // 사용자가 손대지 않은 파일이 changed로 떨어져, "전부 skip" 아니면 "전부 backup" 둘 중 하나만
48
+ // 고를 수 있었다.
49
+ //
50
+ // ours === theirs → unchanged 이미 최신, 할 일 없음
51
+ // sha(ours) === base.installed → upstreamOnly 사용자 미수정 → 질문 없이 교체
52
+ // sha(theirs) === base.rendered → localOnly 업스트림 그대로 → 질문 없이 유지
53
+ // 그 외 → changed 진짜 충돌 → 질문
54
+ // 디스크에 없는데 baseline에 있음 → removed 사용자가 지움 → 되살리기 전에 물어봄
55
+ //
56
+ // baseline이 없는 기존 설치는 base 미상이라 upstreamOnly/localOnly 판정을 할 수 없고,
57
+ // 종전대로 unchanged/changed 2분류로 떨어진다(폴백). 그 실행에서 baseline이 심긴다.
58
+ function classify(srcDir, workflowsDir, envOpts, srcText, baseline = null) {
59
+ const result = { newFiles: [], unchanged: [], changed: [], upstreamOnly: [], localOnly: [], removed: [] };
41
60
  for (const filename of listYamlFiles(srcDir)) {
42
61
  const src = join(srcDir, filename);
43
62
  const dst = join(workflowsDir, filename);
44
- if (existsSync(dst)) {
45
- const tpl = srcText(src);
46
- const inst = readFileSync(dst, "utf8");
47
- if (isUnchanged(tpl, inst, envOpts)) result.unchanged.push(filename);
48
- else result.changed.push(filename);
49
- } else {
50
- result.newFiles.push(filename);
63
+ const base = baseline?.files?.[filename] || null;
64
+
65
+ if (!existsSync(dst)) {
66
+ // baseline에 있는데 디스크에 없다 = 우리가 깔았던 파일을 사용자가 지웠다.
67
+ // 별도의 삭제 이력 파일이 필요 없다는 것이 baseline 설계의 부산물이다.
68
+ if (base) result.removed.push(filename);
69
+ else result.newFiles.push(filename);
70
+ continue;
51
71
  }
72
+
73
+ const tpl = srcText(src);
74
+ const inst = readFileSync(dst, "utf8");
75
+ const theirs = renderVirtual(tpl, envOpts);
76
+ if (theirs === inst) { result.unchanged.push(filename); continue; }
77
+ if (base?.installed && sha256(inst) === base.installed) { result.upstreamOnly.push(filename); continue; }
78
+ if (base?.rendered && sha256(theirs) === base.rendered) { result.localOnly.push(filename); continue; }
79
+ result.changed.push(filename);
52
80
  }
53
81
  return result;
54
82
  }
55
83
 
84
+ // 한 원본 디렉토리를 분류 결과대로 처리한다. common·타입별·server-deploy가 같은 규칙을 쓴다.
85
+ // filter: trunk-based 제외 같은 파일 단위 필터.
86
+ //
87
+ // 자동 처리되는 두 버킷이 이 함수의 핵심이다 (issue #69):
88
+ // upstreamOnly — 사용자가 손대지 않았으니 그냥 최신으로 교체한다. 물어볼 이유가 없다.
89
+ // localOnly — 업스트림이 그대로니 사용자 수정본을 그대로 둔다. 역시 물어볼 이유가 없다.
90
+ function processDir(srcDir, workflowsDir, envOpts, ctx, counters, filter = () => true) {
91
+ const { srcText, baseline, decisions, restoreRemoved, baselineTargets } = ctx;
92
+ const c = classify(srcDir, workflowsDir, envOpts, srcText, baseline);
93
+ const track = (f, wrote) => baselineTargets.set(f, { srcPath: join(srcDir, f), envOpts, wrote });
94
+ const write = (f) => { writeText(join(workflowsDir, f), srcText(join(srcDir, f))); counters.copied++; counters.copiedFiles.push(f); track(f, true); };
95
+
96
+ for (const f of c.unchanged.filter(filter)) { counters.skipped++; track(f, false); }
97
+
98
+ for (const f of c.localOnly.filter(filter)) { counters.skipped++; counters.keptLocal.push(f); track(f, false); }
99
+
100
+ for (const f of c.newFiles.filter(filter)) write(f);
101
+
102
+ for (const f of c.upstreamOnly.filter(filter)) { write(f); counters.autoUpdated.push(f); }
103
+
104
+ // 사용자가 지운 파일은 조용히 되살리지 않는다. 복원 결정이 있을 때만 다시 쓴다.
105
+ // 되살리지 않은 파일은 baselineTargets에 넣지 않는다 — 디스크에 없어 해시할 것이 없고,
106
+ // 기존 baseline 항목은 병합으로 남아 다음 실행에서도 "지운 파일"로 인식된다.
107
+ for (const f of c.removed.filter(filter)) {
108
+ if (restoreRemoved.has(f)) { write(f); counters.restoredFiles.push(f); }
109
+ else counters.removedKept.push(f);
110
+ }
111
+
112
+ for (const f of c.changed.filter(filter)) {
113
+ const decision = decisions.get(f);
114
+ applyDecision(decision, srcDir, workflowsDir, f, counters, srcText);
115
+ // 'backup'만 대상 파일 자체를 새로 쓴다. 'template'은 다른 파일명이고 'skip'은 기존 유지.
116
+ track(f, decision === "backup");
117
+ }
118
+ return c;
119
+ }
120
+
56
121
  // copy_workflows 본체 (동기 — 기존 호출부 무변경).
57
122
  // context: { types:[], paths:Map, includeNexus, includeSecretBackup, force, repoName, resolvers,
58
123
  // envValues?:Map<key,value>, envUseDefaults?:boolean } ← env 계획(promptEnvPlan) 결과 주입점
59
- // hooks: { decisions?: Map<filename, 'skip'|'backup'|'template'> } 기존 파일(changed) 충돌 결정.
124
+ // hooks: { decisions?: Map<filename, 'skip'|'backup'|'template'>, 진짜 충돌(changed) 결정
125
+ // restoreRemoved?: Set<filename> } — 사용자가 지운 파일 중 복원할 것
60
126
  // 미지정 파일은 'skip'(현행 force 동작 100% 유지). 대화형 수집은 copyWorkflowsInteractive 참조.
61
- // 반환: {copied, skipped, templateAdded, optionalCopied, backupAdded}
127
+ // 반환: {copied, skipped, templateAdded, optionalCopied, backupAdded, autoUpdated, keptLocal, removedKept, restoredFiles}
62
128
  export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}) {
63
129
  const { types = [], paths = new Map(), includeNexus = false, includeSecretBackup = false, repoName = "", resolvers = {}, envValues = new Map(), envUseDefaults = true } = context;
64
130
  const decisions = hooks.decisions instanceof Map ? hooks.decisions : new Map();
131
+ const restoreRemoved = hooks.restoreRemoved instanceof Set ? hooks.restoreRemoved : new Set();
65
132
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
66
133
  const projectTypesDir = join(payloadRoot, PAYLOAD.workflowsDir);
67
134
  if (!exists(projectTypesDir)) throw new Error("패키지 구조 오류 — payload/workflows 폴더를 찾지 못했습니다.");
@@ -70,31 +137,30 @@ export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}
70
137
  const deployValues = new Map(); // Map<type, Map<key,value>> — deploy 블록용 ask 값
71
138
  counters.deployValues = deployValues;
72
139
  counters.copiedFiles = []; // 이번 실행에서 실제로 새로 쓰여진 파일명 (issue #19 — printSummary 정확성용)
140
+ counters.autoUpdated = []; // 질문 없이 최신으로 교체된 파일 (사용자 미수정)
141
+ counters.keptLocal = []; // 질문 없이 사용자 수정본을 유지한 파일 (업스트림 무변경)
142
+ counters.removedKept = []; // 사용자가 지웠고 되살리지 않은 파일
143
+ counters.restoredFiles = []; // 사용자가 지웠지만 복원하기로 한 파일
73
144
  const srcText = makeSrcText(context.branches || null);
74
- // values/useDefaults는 치환 경로에서만 의미 (isUnchanged는 내부에서 useDefaults:true 강제 — 가상 비교 무손상)
145
+ const baseline = readBaseline(targetRoot);
146
+ const baselineTargets = new Map(); // filename -> { srcPath, envOpts, wrote }
147
+ // values/useDefaults는 치환 경로에서만 의미 (renderVirtual은 useDefaults:true 강제 — 가상 비교 무손상)
75
148
  const envOptsFor = (type) => ({ type, projectPath: paths.get(type) || ".", repoName, resolvers, values: envValues, useDefaults: envUseDefaults });
149
+ const dirCtx = { srcText, baseline, decisions, restoreRemoved, baselineTargets };
76
150
 
77
- // (1) common — 타입별 워크플로우와 동일한 3지선(README 계약, issue #20 H3): unchanged면 스킵,
78
- // changed면 decisions Map 결정에 따름(미지정 시 skip — 기존 사용자 수정 보존).
151
+ // (1) common — 타입별과 동일 규칙 (README 계약, issue #20 H3).
79
152
  // trunk-based 모드는 VERSION-CONTROL·AUTO-CHANGELOG 미설치 (RELEASE-PUBLISH 단독).
80
153
  const branchMode = context.branches?.mode || "pr-flow";
81
154
  const commonDir = join(projectTypesDir, "common");
82
155
  if (exists(commonDir)) {
83
156
  const notExcluded = (filename) => !(branchMode === "trunk-based" && TRUNK_BASED_EXCLUDED.has(filename));
84
- const { newFiles, unchanged, changed } = classify(commonDir, workflowsDir, envOptsFor("common"), srcText);
85
- counters.skipped += unchanged.filter(notExcluded).length;
86
- for (const f of newFiles.filter(notExcluded)) {
87
- writeText(join(workflowsDir, f), srcText(join(commonDir, f)));
88
- counters.copied++;
89
- counters.copiedFiles.push(f);
90
- }
91
- for (const f of changed.filter(notExcluded)) applyDecision(decisions.get(f), commonDir, workflowsDir, f, counters, srcText);
157
+ processDir(commonDir, workflowsDir, envOptsFor("common"), dirCtx, counters, notExcluded);
92
158
  }
93
159
 
94
160
  // (2~4) 타입별
95
161
  for (const type of types) {
96
162
  const asks = new Map();
97
- copyWorkflowsForType(type, projectTypesDir, workflowsDir, { includeNexus, ...context, envOptsFor, collectAsks: asks, decisions, srcText }, counters);
163
+ copyWorkflowsForType(type, projectTypesDir, workflowsDir, { includeNexus, ...context, envOptsFor, collectAsks: asks, dirCtx }, counters);
98
164
  if (asks.size) deployValues.set(type, asks);
99
165
  }
100
166
 
@@ -108,12 +174,29 @@ export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}
108
174
  counters.optionalCopied++;
109
175
  counters.copied++;
110
176
  counters.copiedFiles.push(filename);
177
+ baselineTargets.set(filename, { srcPath: join(secretDir, filename), envOpts: envOptsFor("common"), wrote: true });
111
178
  }
112
179
  }
113
180
 
181
+ counters.baselineTargets = baselineTargets; // 호출부(runFull)가 env 치환 완료 후 baseline을 기록한다
114
182
  return counters;
115
183
  }
116
184
 
185
+ // copyWorkflows가 끝나고 env 치환까지 마친 뒤에 호출한다 — 그래야 디스크 내용이 최종형이다.
186
+ // entries: Map<filename, {installed:string|null, rendered:string}>
187
+ export function computeBaselineEntries(baselineTargets, workflowsDir, srcText) {
188
+ const entries = new Map();
189
+ for (const [filename, info] of baselineTargets) {
190
+ const dst = join(workflowsDir, filename);
191
+ if (!existsSync(dst)) continue;
192
+ const rendered = sha256(renderVirtual(srcText(info.srcPath), info.envOpts));
193
+ // installed는 이번에 우리가 쓴 파일에만 채운다. 사용자 수정본을 installed로 기록하면
194
+ // "우리가 쓴 것"이라고 거짓말하는 셈이고, 다음 업데이트에서 그 파일이 조용히 덮인다.
195
+ entries.set(filename, { installed: info.wrote ? sha256(readFileSync(dst, "utf8")) : null, rendered });
196
+ }
197
+ return entries;
198
+ }
199
+
117
200
  // changed(기존에 있고 내용이 바뀐) 파일 1개를 결정에 따라 처리 (.sh 3440~3508 3지선 case 등가).
118
201
  // 'skip'(기본): 기존 유지. 'backup': 기존→.bak 후 교체. 'template': 기존 유지 + 새 버전을 .template.yaml로.
119
202
  function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcText) {
@@ -139,38 +222,47 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcTe
139
222
  counters.skipped++; // 'skip'/미지정/ESC → 기존 유지 (.sh S)·force 기본)
140
223
  }
141
224
 
142
- // 대상 워크플로우 디렉토리에서 changed(충돌) 파일 목록만 뽑는다 copyWorkflowsInteractive의 사전 조사용.
225
+ // 대화형 사전 조사 사람이 답해야 하는 것만 뽑는다 (issue #69).
143
226
  // copyWorkflows 본체와 동일한 classify 기준을 써야 결정 Map이 실제 처리 대상과 1:1로 맞는다.
144
227
  // common도 타입별과 동일하게 스캔한다 (issue #20 H3 — 이전에는 common 충돌이 질문조차 되지 않았다).
145
- export function listWorkflowConflicts(context, payloadRoot, targetRoot = ".") {
228
+ // 반환: { conflicts: [{filename,type}], removed: [{filename,type}] }
229
+ // conflicts — 양쪽이 다 바뀐 진짜 충돌. upstreamOnly/localOnly는 자동 처리되므로 여기 없다.
230
+ // removed — 우리가 깔았는데 사용자가 지운 파일. 되살리기 전에 물어봐야 한다.
231
+ export function surveyWorkflows(context, payloadRoot, targetRoot = ".") {
146
232
  const { types = [], paths = new Map(), includeNexus = false, repoName = "", resolvers = {} } = context;
147
233
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
148
234
  const projectTypesDir = join(payloadRoot, PAYLOAD.workflowsDir);
149
235
  const srcText = makeSrcText(context.branches || null);
236
+ const baseline = readBaseline(targetRoot);
150
237
  const branchMode = context.branches?.mode || "pr-flow";
151
- const conflicts = []; // [{ filename, type }] — 엔진 처리 순서와 동일 (common → 타입 순회 → 직하위 → server-deploy)
238
+ const conflicts = []; // 엔진 처리 순서와 동일 (common → 타입 순회 → 직하위 → server-deploy)
239
+ const removed = [];
240
+
241
+ const collect = (srcDir, envOpts, type, skipFile = () => false) => {
242
+ const c = classify(srcDir, workflowsDir, envOpts, srcText, baseline);
243
+ for (const f of c.changed) { if (!skipFile(f)) conflicts.push({ filename: f, type }); }
244
+ for (const f of c.removed) { if (!skipFile(f)) removed.push({ filename: f, type }); }
245
+ };
152
246
 
153
247
  const commonDir = join(projectTypesDir, "common");
154
248
  if (exists(commonDir)) {
155
- const envOpts = { type: "common", projectPath: ".", repoName, resolvers };
156
- for (const f of classify(commonDir, workflowsDir, envOpts, srcText).changed) {
157
- if (branchMode === "trunk-based" && TRUNK_BASED_EXCLUDED.has(f)) continue;
158
- conflicts.push({ filename: f, type: "common" });
159
- }
249
+ collect(commonDir, { type: "common", projectPath: ".", repoName, resolvers }, "common",
250
+ (f) => branchMode === "trunk-based" && TRUNK_BASED_EXCLUDED.has(f));
160
251
  }
161
252
 
162
253
  for (const type of types) {
163
254
  const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers };
164
255
  const typeDir = join(projectTypesDir, type);
165
- if (exists(typeDir)) {
166
- for (const f of classify(typeDir, workflowsDir, envOpts, srcText).changed) conflicts.push({ filename: f, type });
167
- }
256
+ if (exists(typeDir)) collect(typeDir, envOpts, type);
168
257
  const serverDeployDir = join(typeDir, "server-deploy");
169
- if (exists(serverDeployDir) && !includeNexus) {
170
- for (const f of classify(serverDeployDir, workflowsDir, envOpts, srcText).changed) conflicts.push({ filename: f, type });
171
- }
258
+ if (exists(serverDeployDir) && !includeNexus) collect(serverDeployDir, envOpts, type);
172
259
  }
173
- return conflicts;
260
+ return { conflicts, removed };
261
+ }
262
+
263
+ // 진짜 충돌 목록만 필요할 때 (기존 호출부 호환).
264
+ export function listWorkflowConflicts(context, payloadRoot, targetRoot = ".") {
265
+ return surveyWorkflows(context, payloadRoot, targetRoot).conflicts;
174
266
  }
175
267
 
176
268
  // 대화형 진입점 (async) — 충돌마다 onConflict(filename, type)를 await해 결정 Map을 만든 뒤
@@ -189,23 +281,18 @@ export async function copyWorkflowsInteractive(context, payloadRoot, targetRoot
189
281
  }
190
282
 
191
283
  function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters) {
192
- const { includeNexus, envOptsFor, collectAsks = null, decisions = new Map(), srcText } = ctx;
284
+ const { includeNexus, envOptsFor, collectAsks = null, dirCtx } = ctx;
285
+ const { srcText, baselineTargets } = dirCtx;
193
286
  const typeDir = join(projectTypesDir, type);
194
287
  const envOpts = envOptsFor(type);
195
- let unchangedNames = [];
288
+ // env 치환에서 제외할 파일 — 손대지 않기로 한 것들(unchanged/localOnly/유지된 삭제분)에
289
+ // 치환을 다시 걸면 사용자 수정본을 덮어쓰게 된다.
290
+ const untouched = [];
196
291
 
197
292
  // 타입별 워크플로우 (직하위)
198
293
  if (exists(typeDir)) {
199
- const { newFiles, unchanged, changed } = classify(typeDir, workflowsDir, envOpts, srcText);
200
- unchangedNames = unchanged.slice();
201
- for (const f of unchanged) counters.skipped++;
202
- for (const f of newFiles) {
203
- writeText(join(workflowsDir, f), srcText(join(typeDir, f)));
204
- counters.copied++;
205
- counters.copiedFiles.push(f);
206
- }
207
- // changed: 결정 Map에 따라 처리 (미지정=skip → 현행 force 동작과 동일)
208
- for (const f of changed) applyDecision(decisions.get(f), typeDir, workflowsDir, f, counters, srcText);
294
+ const c = processDir(typeDir, workflowsDir, envOpts, dirCtx, counters);
295
+ untouched.push(...c.unchanged, ...c.localOnly);
209
296
  }
210
297
 
211
298
  // server-deploy
@@ -214,14 +301,8 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
214
301
  if (includeNexus) {
215
302
  // Nexus 프로젝트 → 폴더째 제외 (복사 안 함)
216
303
  } else {
217
- const { newFiles, unchanged, changed } = classify(serverDeployDir, workflowsDir, envOpts, srcText);
218
- for (const f of unchanged) counters.skipped++;
219
- for (const f of newFiles) {
220
- writeText(join(workflowsDir, f), srcText(join(serverDeployDir, f)));
221
- counters.copied++;
222
- counters.copiedFiles.push(f);
223
- }
224
- for (const f of changed) applyDecision(decisions.get(f), serverDeployDir, workflowsDir, f, counters, srcText);
304
+ const c = processDir(serverDeployDir, workflowsDir, envOpts, dirCtx, counters);
305
+ untouched.push(...c.unchanged, ...c.localOnly);
225
306
  }
226
307
  }
227
308
 
@@ -232,7 +313,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
232
313
  const src = join(nexusDir, filename);
233
314
  const dst = join(workflowsDir, filename);
234
315
  const body = srcText(src);
235
- if (existsSync(dst) && isUnchanged(body, readFileSync(dst, "utf8"), envOpts)) {
316
+ if (existsSync(dst) && renderVirtual(body, envOpts) === readFileSync(dst, "utf8")) {
236
317
  counters.skipped++;
237
318
  continue;
238
319
  }
@@ -241,16 +322,17 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
241
322
  counters.optionalCopied++;
242
323
  counters.copied++;
243
324
  counters.copiedFiles.push(filename);
325
+ baselineTargets.set(filename, { srcPath: src, envOpts, wrote: true });
244
326
  }
245
327
  }
246
328
 
247
- // env 치환 — 이 타입의 원본 디렉토리들에서 복사돼 존재하고 unchanged 아닌 파일만
329
+ // env 치환 — 이 타입의 원본 디렉토리들에서 복사돼 존재하고, 손대지 않기로 한 것이 아닌 파일만
248
330
  for (const srcDir of [typeDir, serverDeployDir, nexusDir]) {
249
331
  if (!exists(srcDir)) continue;
250
332
  for (const filename of listYamlFiles(srcDir)) {
251
333
  const target = join(workflowsDir, filename);
252
- if (!existsSync(target)) continue; // 건너뛴 파일 제외
253
- if (unchangedNames.includes(filename)) continue; // unchanged 제외
334
+ if (!existsSync(target)) continue; // 건너뛴 파일 제외
335
+ if (untouched.includes(filename)) continue; // unchanged/localOnly 제외
254
336
  configureEnv(target, { ...envOpts, collectAsks }); // env 계획 values/useDefaults 포함
255
337
  }
256
338
  }
@@ -264,11 +346,14 @@ export function planWorkflows(context, payloadRoot, targetRoot = ".") {
264
346
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
265
347
  const projectTypesDir = join(payloadRoot, PAYLOAD.workflowsDir);
266
348
  const srcText = makeSrcText(context.branches || null);
349
+ const baseline = readBaseline(targetRoot);
267
350
  const branchMode = context.branches?.mode || "pr-flow";
268
- const plan = { newFiles: [], unchanged: [], changed: [] };
351
+ // upstreamOnly/localOnly/removed는 baseline이 있을 때만 채워진다 (issue #69).
352
+ const plan = { newFiles: [], unchanged: [], changed: [], upstreamOnly: [], localOnly: [], removed: [] };
353
+ const BUCKETS = ["newFiles", "unchanged", "changed", "upstreamOnly", "localOnly", "removed"];
269
354
 
270
355
  const merge = (result, type, excluded = null) => {
271
- for (const bucket of ["newFiles", "unchanged", "changed"]) {
356
+ for (const bucket of BUCKETS) {
272
357
  for (const filename of result[bucket]) {
273
358
  if (excluded && excluded.has(filename)) continue;
274
359
  plan[bucket].push({ filename, type });
@@ -279,7 +364,7 @@ export function planWorkflows(context, payloadRoot, targetRoot = ".") {
279
364
  const commonDir = join(projectTypesDir, "common");
280
365
  if (exists(commonDir)) {
281
366
  const envOpts = { type: "common", projectPath: ".", repoName, resolvers };
282
- merge(classify(commonDir, workflowsDir, envOpts, srcText), "common",
367
+ merge(classify(commonDir, workflowsDir, envOpts, srcText, baseline), "common",
283
368
  branchMode === "trunk-based" ? TRUNK_BASED_EXCLUDED : null);
284
369
  }
285
370
 
@@ -296,16 +381,16 @@ export function planWorkflows(context, payloadRoot, targetRoot = ".") {
296
381
  for (const type of types) {
297
382
  const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers };
298
383
  const typeDir = join(projectTypesDir, type);
299
- if (exists(typeDir)) merge(classify(typeDir, workflowsDir, envOpts, srcText), type);
384
+ if (exists(typeDir)) merge(classify(typeDir, workflowsDir, envOpts, srcText, baseline), type);
300
385
 
301
386
  const serverDeployDir = join(typeDir, "server-deploy");
302
387
  if (exists(serverDeployDir) && !includeNexus) {
303
- merge(classify(serverDeployDir, workflowsDir, envOpts, srcText), type);
388
+ merge(classify(serverDeployDir, workflowsDir, envOpts, srcText, baseline), type);
304
389
  }
305
390
 
306
391
  const nexusDir = join(typeDir, "nexus");
307
392
  if (exists(nexusDir) && includeNexus) {
308
- merge(classify(nexusDir, workflowsDir, envOpts, srcText), type);
393
+ merge(classify(nexusDir, workflowsDir, envOpts, srcText, baseline), type);
309
394
  }
310
395
  }
311
396
 
@@ -16,6 +16,7 @@
16
16
  import { join } from "node:path";
17
17
  import { existsSync, readFileSync, readdirSync } from "node:fs";
18
18
  import { PATHS, PAYLOAD } from "./paths.js";
19
+ import { BASELINE_DIR, BASELINE_PATH } from "./baseline.js";
19
20
 
20
21
  // payload/workflows/**/*.yaml 첫 줄에 심어둔 고정 마커 — 이 값이 바뀌면 과거 설치분과의 매칭이 끊긴다.
21
22
  export const MANAGED_WORKFLOW_MARKER = "# project-auto-wizard:managed-workflow";
@@ -65,5 +66,8 @@ export function planRemoval(payloadRoot, targetRoot = ".") {
65
66
  for (const s of ["version_manager.py", "changelog_manager.py"]) {
66
67
  if (existsSync(join(targetRoot, PATHS.scriptsDir, s))) removedScripts.push(s);
67
68
  }
68
- return { workflows: [...removedWf], scripts: removedScripts };
69
+ // baseline은 마법사가 만든 내부 상태 파일이다 — 설치물을 지우면 함께 사라져야 한다.
70
+ // 남겨두면 다음 설치가 "예전에 깔았다가 사용자가 지운 파일"로 오인해 전부 removed로 분류한다.
71
+ const baseline = existsSync(join(targetRoot, BASELINE_PATH)) ? [BASELINE_DIR] : [];
72
+ return { workflows: [...removedWf], scripts: removedScripts, baseline };
69
73
  }