project-auto-wizard 0.1.34 → 0.3.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.
Files changed (46) hide show
  1. package/README.md +15 -4
  2. package/package.json +1 -1
  3. package/payload/scripts/__pycache__/changelog_manager.cpython-314.pyc +0 -0
  4. package/payload/scripts/__pycache__/issue_helper.cpython-314.pyc +0 -0
  5. package/payload/scripts/__pycache__/version_manager.cpython-314.pyc +0 -0
  6. package/payload/scripts/issue_helper.py +334 -0
  7. package/payload/scripts/truncate_release_notes.py +89 -0
  8. package/payload/version.yml.template +1 -0
  9. package/payload/workflows/common/PROJECT-COMMON-ISSUE-HELPER.yaml +47 -0
  10. package/payload/workflows/common/secret-backup/PROJECT-COMMON-SECRET-FILE-UPLOAD.yaml +4 -4
  11. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +1 -1
  12. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +1 -1
  13. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +1 -1
  14. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +6 -6
  15. package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +1 -1
  16. package/payload/workflows/spring/{PROJECT-SPRING-GITHUB-PACKAGES-PUBLISH.yml → nexus/PROJECT-SPRING-GITHUB-PACKAGES-PUBLISH.yml} +8 -2
  17. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +8 -6
  18. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +2 -2
  19. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +8 -8
  20. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +2 -2
  21. package/src/cli/args.js +9 -0
  22. package/src/cli/help.js +2 -1
  23. package/src/commands/dry-run.js +7 -16
  24. package/src/commands/full.js +88 -14
  25. package/src/commands/interactive.js +73 -12
  26. package/src/commands/purge.js +2 -0
  27. package/src/commands/status.js +21 -1
  28. package/src/commands/uninstall.js +6 -1
  29. package/src/core/baseline.js +76 -0
  30. package/src/core/copy/simple.js +2 -2
  31. package/src/core/copy/workflows.js +178 -90
  32. package/src/core/deploy-style.js +90 -0
  33. package/src/core/detect-fs.js +36 -7
  34. package/src/core/detect.js +68 -3
  35. package/src/core/install-log.js +182 -0
  36. package/src/core/options-ask.js +5 -2
  37. package/src/core/paths-resolve.js +4 -8
  38. package/src/core/removal-plan.js +6 -2
  39. package/src/core/verify.js +86 -0
  40. package/src/core/version-yml.js +31 -4
  41. package/src/core/wizard-env.js +6 -3
  42. package/src/index.js +15 -2
  43. package/src/ui/env-plan.js +63 -33
  44. package/src/ui/prompts.js +41 -2
  45. package/src/ui/status-cards.js +7 -3
  46. package/src/ui/summary.js +56 -6
@@ -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,9 +6,9 @@ import { chmodSync } from "node:fs";
6
6
  import { PATHS, PAYLOAD } from "../paths.js";
7
7
  import { exists, copyFileSync } from "../fsutil.js";
8
8
 
9
- // version_manager.py, changelog_manager.py 무조건 덮어쓰기 (+chmod — Windows에선 무의미하나 무해).
9
+ // version_manager.py, changelog_manager.py, truncate_release_notes.py 무조건 덮어쓰기 (+chmod — Windows에선 무의미하나 무해).
10
10
  export function copyScripts(payloadRoot, targetRoot = ".") {
11
- const scripts = ["version_manager.py", "changelog_manager.py"];
11
+ const scripts = ["version_manager.py", "changelog_manager.py", "truncate_release_notes.py", "issue_helper.py"];
12
12
  let copied = 0;
13
13
  for (const s of scripts) {
14
14
  const src = join(payloadRoot, PAYLOAD.scriptsDir, s);
@@ -3,18 +3,22 @@
3
3
  // 대화형 3지선(기존 파일 충돌)은 copyWorkflowsInteractive(async)가 결정 Map을 만들어
4
4
  // 동기 엔진(copyWorkflows)에 hooks.decisions로 전달한다 — 기존 시그니처·force 동작 무변경.
5
5
  import { join, basename } from "node:path";
6
+ import { deployFilter, isDeployWorkflow, activateDeployTrigger, DEFAULT_DEPLOY_STYLE } from "../deploy-style.js";
6
7
  import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
7
8
  import { PATHS, PAYLOAD } from "../paths.js";
8
9
  import { exists, writeText, listYamlFiles } from "../fsutil.js";
9
- import { isUnchanged, substituteEnv } from "../wizard-env.js";
10
+ import { substituteEnv } from "../wizard-env.js";
10
11
  import { substitute } from "../branding.js";
12
+ import { sha256, readBaseline } from "../baseline.js";
11
13
 
12
14
  // 원본 텍스트 로더 — context.branches가 있으면 {{MAIN_BRANCH}}/{{DEVELOP_BRANCH}} 치환 적용.
13
15
  // classify(unchanged 판정)와 실제 복사가 같은 치환본을 봐야 재실행 시 가짜 충돌이 없다.
14
- function makeSrcText(branches) {
16
+ export function makeSrcText(branches, deployStyle = DEFAULT_DEPLOY_STYLE) {
15
17
  return (p) => {
16
18
  const raw = readFileSync(p, "utf8");
17
- return branches ? substitute(raw, branches) : raw;
19
+ const out = branches ? substitute(raw, branches) : raw;
20
+ // 고른 배포 방식의 CD는 push 트리거를 켜서 설치한다 — 설치했는데 안 도는 상태를 만들지 않는다.
21
+ return isDeployWorkflow(basename(p)) ? activateDeployTrigger(out) : out;
18
22
  };
19
23
  }
20
24
 
@@ -34,34 +38,101 @@ function configureEnv(targetPath, { type, projectPath = ".", repoName = "", reso
34
38
  writeFileSync(targetPath, out);
35
39
  }
36
40
 
37
- // 3분류 (신규/unchanged/changed)대상 워크플로우 디렉토리 기준.
38
- // srcText: 브랜치 치환이 적용된 원본 로더 (makeSrcText).
39
- function classify(srcDir, workflowsDir, envOpts, srcText) {
40
- const result = { newFiles: [], unchanged: [], changed: [] };
41
+ // payload 원본을 "기본값으로 가상 치환한 최종형" isUnchanged가 내부에서 쓰는 것과 같은 값이다.
42
+ // baseline.rendered와 비교해 "업스트림이 바뀌었는가"를 판정하는 쓴다.
43
+ function renderVirtual(templateContent, envOpts) {
44
+ return substituteEnv(templateContent, { ...envOpts, useDefaults: true });
45
+ }
46
+
47
+ // 분류 — 대상 워크플로우 디렉토리 기준. srcText: 브랜치 치환이 적용된 원본 로더 (makeSrcText).
48
+ //
49
+ // baseline이 있으면 3-way로 가른다 (issue #69). base가 없던 시절에는 업스트림이 한 글자만 고쳐도
50
+ // 사용자가 손대지 않은 파일이 changed로 떨어져, "전부 skip" 아니면 "전부 backup" 둘 중 하나만
51
+ // 고를 수 있었다.
52
+ //
53
+ // ours === theirs → unchanged 이미 최신, 할 일 없음
54
+ // sha(ours) === base.installed → upstreamOnly 사용자 미수정 → 질문 없이 교체
55
+ // sha(theirs) === base.rendered → localOnly 업스트림 그대로 → 질문 없이 유지
56
+ // 그 외 → changed 진짜 충돌 → 질문
57
+ // 디스크에 없는데 baseline에 있음 → removed 사용자가 지움 → 되살리기 전에 물어봄
58
+ //
59
+ // baseline이 없는 기존 설치는 base 미상이라 upstreamOnly/localOnly 판정을 할 수 없고,
60
+ // 종전대로 unchanged/changed 2분류로 떨어진다(폴백). 그 실행에서 baseline이 심긴다.
61
+ function classify(srcDir, workflowsDir, envOpts, srcText, baseline = null, filter = null) {
62
+ const result = { newFiles: [], unchanged: [], changed: [], upstreamOnly: [], localOnly: [], removed: [] };
41
63
  for (const filename of listYamlFiles(srcDir)) {
64
+ if (filter && !filter(filename)) continue;
42
65
  const src = join(srcDir, filename);
43
66
  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);
67
+ const base = baseline?.files?.[filename] || null;
68
+
69
+ if (!existsSync(dst)) {
70
+ // baseline에 있는데 디스크에 없다 = 우리가 깔았던 파일을 사용자가 지웠다.
71
+ // 별도의 삭제 이력 파일이 필요 없다는 것이 baseline 설계의 부산물이다.
72
+ if (base) result.removed.push(filename);
73
+ else result.newFiles.push(filename);
74
+ continue;
51
75
  }
76
+
77
+ const tpl = srcText(src);
78
+ const inst = readFileSync(dst, "utf8");
79
+ const theirs = renderVirtual(tpl, envOpts);
80
+ if (theirs === inst) { result.unchanged.push(filename); continue; }
81
+ if (base?.installed && sha256(inst) === base.installed) { result.upstreamOnly.push(filename); continue; }
82
+ if (base?.rendered && sha256(theirs) === base.rendered) { result.localOnly.push(filename); continue; }
83
+ result.changed.push(filename);
52
84
  }
53
85
  return result;
54
86
  }
55
87
 
88
+ // 한 원본 디렉토리를 분류 결과대로 처리한다. common·타입별·server-deploy가 같은 규칙을 쓴다.
89
+ // filter: trunk-based 제외 같은 파일 단위 필터.
90
+ //
91
+ // 자동 처리되는 두 버킷이 이 함수의 핵심이다 (issue #69):
92
+ // upstreamOnly — 사용자가 손대지 않았으니 그냥 최신으로 교체한다. 물어볼 이유가 없다.
93
+ // localOnly — 업스트림이 그대로니 사용자 수정본을 그대로 둔다. 역시 물어볼 이유가 없다.
94
+ function processDir(srcDir, workflowsDir, envOpts, ctx, counters, filter = () => true) {
95
+ const { srcText, baseline, decisions, restoreRemoved, baselineTargets } = ctx;
96
+ const c = classify(srcDir, workflowsDir, envOpts, srcText, baseline);
97
+ const track = (f, wrote) => baselineTargets.set(f, { srcPath: join(srcDir, f), envOpts, wrote });
98
+ const write = (f) => { writeText(join(workflowsDir, f), srcText(join(srcDir, f))); counters.copied++; counters.copiedFiles.push(f); track(f, true); };
99
+
100
+ for (const f of c.unchanged.filter(filter)) { counters.skipped++; track(f, false); }
101
+
102
+ for (const f of c.localOnly.filter(filter)) { counters.skipped++; counters.keptLocal.push(f); track(f, false); }
103
+
104
+ for (const f of c.newFiles.filter(filter)) write(f);
105
+
106
+ for (const f of c.upstreamOnly.filter(filter)) { write(f); counters.autoUpdated.push(f); }
107
+
108
+ // 사용자가 지운 파일은 조용히 되살리지 않는다. 복원 결정이 있을 때만 다시 쓴다.
109
+ // 되살리지 않은 파일은 baselineTargets에 넣지 않는다 — 디스크에 없어 해시할 것이 없고,
110
+ // 기존 baseline 항목은 병합으로 남아 다음 실행에서도 "지운 파일"로 인식된다.
111
+ 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);
114
+ }
115
+
116
+ for (const f of c.changed.filter(filter)) {
117
+ const decision = decisions.get(f);
118
+ applyDecision(decision, srcDir, workflowsDir, f, counters, srcText);
119
+ // 'backup'만 대상 파일 자체를 새로 쓴다. 'template'은 다른 파일명이고 'skip'은 기존 유지.
120
+ track(f, decision === "backup");
121
+ }
122
+ return c;
123
+ }
124
+
56
125
  // copy_workflows 본체 (동기 — 기존 호출부 무변경).
57
126
  // context: { types:[], paths:Map, includeNexus, includeSecretBackup, force, repoName, resolvers,
58
127
  // envValues?:Map<key,value>, envUseDefaults?:boolean } ← env 계획(promptEnvPlan) 결과 주입점
59
- // hooks: { decisions?: Map<filename, 'skip'|'backup'|'template'> } 기존 파일(changed) 충돌 결정.
128
+ // hooks: { decisions?: Map<filename, 'skip'|'backup'|'template'>, 진짜 충돌(changed) 결정
129
+ // restoreRemoved?: Set<filename> } — 사용자가 지운 파일 중 복원할 것
60
130
  // 미지정 파일은 'skip'(현행 force 동작 100% 유지). 대화형 수집은 copyWorkflowsInteractive 참조.
61
- // 반환: {copied, skipped, templateAdded, optionalCopied, backupAdded}
131
+ // 반환: {copied, skipped, templateAdded, optionalCopied, backupAdded, autoUpdated, keptLocal, removedKept, restoredFiles}
62
132
  export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}) {
63
133
  const { types = [], paths = new Map(), includeNexus = false, includeSecretBackup = false, repoName = "", resolvers = {}, envValues = new Map(), envUseDefaults = true } = context;
64
134
  const decisions = hooks.decisions instanceof Map ? hooks.decisions : new Map();
135
+ const restoreRemoved = hooks.restoreRemoved instanceof Set ? hooks.restoreRemoved : new Set();
65
136
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
66
137
  const projectTypesDir = join(payloadRoot, PAYLOAD.workflowsDir);
67
138
  if (!exists(projectTypesDir)) throw new Error("패키지 구조 오류 — payload/workflows 폴더를 찾지 못했습니다.");
@@ -70,31 +141,31 @@ export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}
70
141
  const deployValues = new Map(); // Map<type, Map<key,value>> — deploy 블록용 ask 값
71
142
  counters.deployValues = deployValues;
72
143
  counters.copiedFiles = []; // 이번 실행에서 실제로 새로 쓰여진 파일명 (issue #19 — printSummary 정확성용)
73
- const srcText = makeSrcText(context.branches || null);
74
- // values/useDefaults는 치환 경로에서만 의미 (isUnchanged는 내부에서 useDefaults:true 강제 가상 비교 무손상)
144
+ counters.autoUpdated = []; // 질문 없이 최신으로 교체된 파일 (사용자 미수정)
145
+ counters.keptLocal = []; // 질문 없이 사용자 수정본을 유지한 파일 (업스트림 무변경)
146
+ counters.removedKept = []; // 사용자가 지웠고 되살리지 않은 파일
147
+ counters.restoredFiles = []; // 사용자가 지웠지만 복원하기로 한 파일
148
+ const deployStyle = context.deployStyle || DEFAULT_DEPLOY_STYLE;
149
+ const srcText = makeSrcText(context.branches || null, deployStyle);
150
+ const baseline = readBaseline(targetRoot);
151
+ const baselineTargets = new Map(); // filename -> { srcPath, envOpts, wrote }
152
+ // values/useDefaults는 치환 경로에서만 의미 (renderVirtual은 useDefaults:true 강제 — 가상 비교 무손상)
75
153
  const envOptsFor = (type) => ({ type, projectPath: paths.get(type) || ".", repoName, resolvers, values: envValues, useDefaults: envUseDefaults });
154
+ const dirCtx = { srcText, baseline, decisions, restoreRemoved, baselineTargets };
76
155
 
77
- // (1) common — 타입별 워크플로우와 동일한 3지선(README 계약, issue #20 H3): unchanged면 스킵,
78
- // changed면 decisions Map 결정에 따름(미지정 시 skip — 기존 사용자 수정 보존).
156
+ // (1) common — 타입별과 동일 규칙 (README 계약, issue #20 H3).
79
157
  // trunk-based 모드는 VERSION-CONTROL·AUTO-CHANGELOG 미설치 (RELEASE-PUBLISH 단독).
80
158
  const branchMode = context.branches?.mode || "pr-flow";
81
159
  const commonDir = join(projectTypesDir, "common");
82
160
  if (exists(commonDir)) {
83
161
  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);
162
+ processDir(commonDir, workflowsDir, envOptsFor("common"), dirCtx, counters, notExcluded);
92
163
  }
93
164
 
94
165
  // (2~4) 타입별
95
166
  for (const type of types) {
96
167
  const asks = new Map();
97
- copyWorkflowsForType(type, projectTypesDir, workflowsDir, { includeNexus, ...context, envOptsFor, collectAsks: asks, decisions, srcText }, counters);
168
+ copyWorkflowsForType(type, projectTypesDir, workflowsDir, { includeNexus, ...context, deployStyle, envOptsFor, collectAsks: asks, dirCtx }, counters);
98
169
  if (asks.size) deployValues.set(type, asks);
99
170
  }
100
171
 
@@ -105,15 +176,35 @@ export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}
105
176
  const dst = join(workflowsDir, filename);
106
177
  if (existsSync(dst)) continue; // 이미 존재하면 스킵
107
178
  writeText(dst, srcText(join(secretDir, filename)));
179
+ // 이 경로는 타입별 복사 루프 밖이라 env 치환 루프가 닿지 않는다. 여기서 직접 걸어주지
180
+ // 않으면 이 파일의 @wizard 마커가 통째로 무시돼 __PROJECT_NAME__ 같은 값이 그대로 설치된다.
181
+ configureEnv(dst, envOptsFor("common"));
108
182
  counters.optionalCopied++;
109
183
  counters.copied++;
110
184
  counters.copiedFiles.push(filename);
185
+ baselineTargets.set(filename, { srcPath: join(secretDir, filename), envOpts: envOptsFor("common"), wrote: true });
111
186
  }
112
187
  }
113
188
 
189
+ counters.baselineTargets = baselineTargets; // 호출부(runFull)가 env 치환 완료 후 baseline을 기록한다
114
190
  return counters;
115
191
  }
116
192
 
193
+ // copyWorkflows가 끝나고 env 치환까지 마친 뒤에 호출한다 — 그래야 디스크 내용이 최종형이다.
194
+ // entries: Map<filename, {installed:string|null, rendered:string}>
195
+ export function computeBaselineEntries(baselineTargets, workflowsDir, srcText) {
196
+ const entries = new Map();
197
+ for (const [filename, info] of baselineTargets) {
198
+ const dst = join(workflowsDir, filename);
199
+ if (!existsSync(dst)) continue;
200
+ const rendered = sha256(renderVirtual(srcText(info.srcPath), info.envOpts));
201
+ // installed는 이번에 우리가 쓴 파일에만 채운다. 사용자 수정본을 installed로 기록하면
202
+ // "우리가 쓴 것"이라고 거짓말하는 셈이고, 다음 업데이트에서 그 파일이 조용히 덮인다.
203
+ entries.set(filename, { installed: info.wrote ? sha256(readFileSync(dst, "utf8")) : null, rendered });
204
+ }
205
+ return entries;
206
+ }
207
+
117
208
  // changed(기존에 있고 내용이 바뀐) 파일 1개를 결정에 따라 처리 (.sh 3440~3508 3지선 case 등가).
118
209
  // 'skip'(기본): 기존 유지. 'backup': 기존→.bak 후 교체. 'template': 기존 유지 + 새 버전을 .template.yaml로.
119
210
  function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcText) {
@@ -139,38 +230,49 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcTe
139
230
  counters.skipped++; // 'skip'/미지정/ESC → 기존 유지 (.sh S)·force 기본)
140
231
  }
141
232
 
142
- // 대상 워크플로우 디렉토리에서 changed(충돌) 파일 목록만 뽑는다 copyWorkflowsInteractive의 사전 조사용.
233
+ // 대화형 사전 조사 사람이 답해야 하는 것만 뽑는다 (issue #69).
143
234
  // copyWorkflows 본체와 동일한 classify 기준을 써야 결정 Map이 실제 처리 대상과 1:1로 맞는다.
144
235
  // common도 타입별과 동일하게 스캔한다 (issue #20 H3 — 이전에는 common 충돌이 질문조차 되지 않았다).
145
- export function listWorkflowConflicts(context, payloadRoot, targetRoot = ".") {
236
+ // 반환: { conflicts: [{filename,type}], removed: [{filename,type}] }
237
+ // conflicts — 양쪽이 다 바뀐 진짜 충돌. upstreamOnly/localOnly는 자동 처리되므로 여기 없다.
238
+ // removed — 우리가 깔았는데 사용자가 지운 파일. 되살리기 전에 물어봐야 한다.
239
+ export function surveyWorkflows(context, payloadRoot, targetRoot = ".") {
146
240
  const { types = [], paths = new Map(), includeNexus = false, repoName = "", resolvers = {} } = context;
147
241
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
148
242
  const projectTypesDir = join(payloadRoot, PAYLOAD.workflowsDir);
149
- const srcText = makeSrcText(context.branches || null);
243
+ const deployStyle = context.deployStyle || DEFAULT_DEPLOY_STYLE;
244
+ const srcText = makeSrcText(context.branches || null, deployStyle);
245
+ const baseline = readBaseline(targetRoot);
150
246
  const branchMode = context.branches?.mode || "pr-flow";
151
- const conflicts = []; // [{ filename, type }] — 엔진 처리 순서와 동일 (common → 타입 순회 → 직하위 → server-deploy)
247
+ const conflicts = []; // 엔진 처리 순서와 동일 (common → 타입 순회 → 직하위 → server-deploy)
248
+ const removed = [];
249
+
250
+ const keepDeploy = deployFilter(deployStyle);
251
+ const collect = (srcDir, envOpts, type, skipFile = () => false, filter = null) => {
252
+ const c = classify(srcDir, workflowsDir, envOpts, srcText, baseline, filter);
253
+ for (const f of c.changed) { if (!skipFile(f)) conflicts.push({ filename: f, type }); }
254
+ for (const f of c.removed) { if (!skipFile(f)) removed.push({ filename: f, type }); }
255
+ };
152
256
 
153
257
  const commonDir = join(projectTypesDir, "common");
154
258
  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
- }
259
+ collect(commonDir, { type: "common", projectPath: ".", repoName, resolvers }, "common",
260
+ (f) => branchMode === "trunk-based" && TRUNK_BASED_EXCLUDED.has(f));
160
261
  }
161
262
 
162
263
  for (const type of types) {
163
264
  const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers };
164
265
  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
- }
266
+ if (exists(typeDir)) collect(typeDir, envOpts, type);
168
267
  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
- }
268
+ if (exists(serverDeployDir) && !includeNexus) collect(serverDeployDir, envOpts, type, () => false, keepDeploy);
172
269
  }
173
- return conflicts;
270
+ return { conflicts, removed };
271
+ }
272
+
273
+ // 진짜 충돌 목록만 필요할 때 (기존 호출부 호환).
274
+ export function listWorkflowConflicts(context, payloadRoot, targetRoot = ".") {
275
+ return surveyWorkflows(context, payloadRoot, targetRoot).conflicts;
174
276
  }
175
277
 
176
278
  // 대화형 진입점 (async) — 충돌마다 onConflict(filename, type)를 await해 결정 Map을 만든 뒤
@@ -189,23 +291,19 @@ export async function copyWorkflowsInteractive(context, payloadRoot, targetRoot
189
291
  }
190
292
 
191
293
  function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters) {
192
- const { includeNexus, envOptsFor, collectAsks = null, decisions = new Map(), srcText } = ctx;
294
+ const { includeNexus, deployStyle = "", envOptsFor, collectAsks = null, dirCtx } = ctx;
295
+ const keepDeploy = deployFilter(deployStyle);
296
+ const { srcText, baselineTargets } = dirCtx;
193
297
  const typeDir = join(projectTypesDir, type);
194
298
  const envOpts = envOptsFor(type);
195
- let unchangedNames = [];
299
+ // env 치환에서 제외할 파일 — 손대지 않기로 한 것들(unchanged/localOnly/유지된 삭제분)에
300
+ // 치환을 다시 걸면 사용자 수정본을 덮어쓰게 된다.
301
+ const untouched = [];
196
302
 
197
303
  // 타입별 워크플로우 (직하위)
198
304
  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);
305
+ const c = processDir(typeDir, workflowsDir, envOpts, dirCtx, counters);
306
+ untouched.push(...c.unchanged, ...c.localOnly);
209
307
  }
210
308
 
211
309
  // server-deploy
@@ -214,43 +312,29 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
214
312
  if (includeNexus) {
215
313
  // Nexus 프로젝트 → 폴더째 제외 (복사 안 함)
216
314
  } 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);
315
+ const c = processDir(serverDeployDir, workflowsDir, envOpts, dirCtx, counters, keepDeploy);
316
+ untouched.push(...c.unchanged, ...c.localOnly);
225
317
  }
226
318
  }
227
319
 
228
- // nexus (opt-in)
320
+ // nexus (opt-in) — 라이브러리 publish 계열(Nexus + GitHub Packages).
321
+ // 다른 폴더와 같은 processDir을 쓴다. 종전에는 이 경로만 별도 로직으로 "존재하면 무조건
322
+ // .bak 후 교체"였는데, 그러면 사용자가 손댄 publish 워크플로우가 재실행마다 묻지도 않고
323
+ // 밀린다. baseline 3-way·충돌 3지선을 다른 워크플로우와 동일하게 적용한다.
229
324
  const nexusDir = join(typeDir, "nexus");
230
325
  if (exists(nexusDir) && includeNexus) {
231
- for (const filename of listYamlFiles(nexusDir)) {
232
- const src = join(nexusDir, filename);
233
- const dst = join(workflowsDir, filename);
234
- const body = srcText(src);
235
- if (existsSync(dst) && isUnchanged(body, readFileSync(dst, "utf8"), envOpts)) {
236
- counters.skipped++;
237
- continue;
238
- }
239
- if (existsSync(dst)) { renameSync(dst, dst + ".bak"); counters.backupAdded++; }
240
- writeText(dst, body);
241
- counters.optionalCopied++;
242
- counters.copied++;
243
- counters.copiedFiles.push(filename);
244
- }
326
+ const c = processDir(nexusDir, workflowsDir, envOpts, dirCtx, counters);
327
+ untouched.push(...c.unchanged, ...c.localOnly);
245
328
  }
246
329
 
247
- // env 치환 — 이 타입의 원본 디렉토리들에서 복사돼 존재하고 unchanged 아닌 파일만
330
+ // env 치환 — 이 타입의 원본 디렉토리들에서 복사돼 존재하고, 손대지 않기로 한 것이 아닌 파일만
248
331
  for (const srcDir of [typeDir, serverDeployDir, nexusDir]) {
249
332
  if (!exists(srcDir)) continue;
250
333
  for (const filename of listYamlFiles(srcDir)) {
251
334
  const target = join(workflowsDir, filename);
252
- if (!existsSync(target)) continue; // 건너뛴 파일 제외
253
- if (unchangedNames.includes(filename)) continue; // unchanged 제외
335
+ if (srcDir === serverDeployDir && !keepDeploy(filename)) continue; // 고른 배포 방식
336
+ if (!existsSync(target)) continue; // 건너뛴 파일 제외
337
+ if (untouched.includes(filename)) continue; // unchanged/localOnly 제외
254
338
  configureEnv(target, { ...envOpts, collectAsks }); // env 계획 values/useDefaults 포함
255
339
  }
256
340
  }
@@ -263,12 +347,16 @@ export function planWorkflows(context, payloadRoot, targetRoot = ".") {
263
347
  const { types = [], paths = new Map(), includeNexus = false, includeSecretBackup = false, repoName = "", resolvers = {} } = context;
264
348
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
265
349
  const projectTypesDir = join(payloadRoot, PAYLOAD.workflowsDir);
266
- const srcText = makeSrcText(context.branches || null);
350
+ const deployStyle = context.deployStyle || DEFAULT_DEPLOY_STYLE;
351
+ const srcText = makeSrcText(context.branches || null, deployStyle);
352
+ const baseline = readBaseline(targetRoot);
267
353
  const branchMode = context.branches?.mode || "pr-flow";
268
- const plan = { newFiles: [], unchanged: [], changed: [] };
354
+ // upstreamOnly/localOnly/removed는 baseline이 있을 때만 채워진다 (issue #69).
355
+ const plan = { newFiles: [], unchanged: [], changed: [], upstreamOnly: [], localOnly: [], removed: [] };
356
+ const BUCKETS = ["newFiles", "unchanged", "changed", "upstreamOnly", "localOnly", "removed"];
269
357
 
270
358
  const merge = (result, type, excluded = null) => {
271
- for (const bucket of ["newFiles", "unchanged", "changed"]) {
359
+ for (const bucket of BUCKETS) {
272
360
  for (const filename of result[bucket]) {
273
361
  if (excluded && excluded.has(filename)) continue;
274
362
  plan[bucket].push({ filename, type });
@@ -279,7 +367,7 @@ export function planWorkflows(context, payloadRoot, targetRoot = ".") {
279
367
  const commonDir = join(projectTypesDir, "common");
280
368
  if (exists(commonDir)) {
281
369
  const envOpts = { type: "common", projectPath: ".", repoName, resolvers };
282
- merge(classify(commonDir, workflowsDir, envOpts, srcText), "common",
370
+ merge(classify(commonDir, workflowsDir, envOpts, srcText, baseline), "common",
283
371
  branchMode === "trunk-based" ? TRUNK_BASED_EXCLUDED : null);
284
372
  }
285
373
 
@@ -296,16 +384,16 @@ export function planWorkflows(context, payloadRoot, targetRoot = ".") {
296
384
  for (const type of types) {
297
385
  const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers };
298
386
  const typeDir = join(projectTypesDir, type);
299
- if (exists(typeDir)) merge(classify(typeDir, workflowsDir, envOpts, srcText), type);
387
+ if (exists(typeDir)) merge(classify(typeDir, workflowsDir, envOpts, srcText, baseline), type);
300
388
 
301
389
  const serverDeployDir = join(typeDir, "server-deploy");
302
390
  if (exists(serverDeployDir) && !includeNexus) {
303
- merge(classify(serverDeployDir, workflowsDir, envOpts, srcText), type);
391
+ merge(classify(serverDeployDir, workflowsDir, envOpts, srcText, baseline, deployFilter(deployStyle)), type);
304
392
  }
305
393
 
306
394
  const nexusDir = join(typeDir, "nexus");
307
395
  if (exists(nexusDir) && includeNexus) {
308
- merge(classify(nexusDir, workflowsDir, envOpts, srcText), type);
396
+ merge(classify(nexusDir, workflowsDir, envOpts, srcText, baseline), type);
309
397
  }
310
398
  }
311
399
 
@@ -0,0 +1,90 @@
1
+ // 배포 방식 (이슈 #80) — 서버 배포 CD 워크플로우는 서로 대체재다.
2
+ // Nginx 무중단과 Traefik 무중단을 동시에 쓰는 경우는 없으므로 하나만 설치한다.
3
+ // 고른 것은 push 트리거까지 켜서 설치한다 — 설치했는데 안 도는 상태를 만들지 않는다.
4
+ import { join } from "node:path";
5
+ import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
6
+ import { sha256 } from "./baseline.js";
7
+
8
+ // 파일명 접미사로 식별한다 — 타입 접두사(PROJECT-SPRING- 등)는 타입마다 다르기 때문.
9
+ export const DEPLOY_STYLES = [
10
+ { value: "simple", suffix: "-SIMPLE-CICD.yaml", label: "단일 서버 배포 — 컨테이너를 내렸다 올린다 (가장 단순, 짧은 다운타임)" },
11
+ { value: "nginx", suffix: "-NONSTOP-NGINX-CICD.yaml", label: "무중단 배포 (Nginx) — nginx config의 proxy_pass 포트를 Blue/Green으로 토글" },
12
+ { value: "traefik", suffix: "-NONSTOP-TRAEFIK-CICD.yaml", label: "무중단 배포 (Traefik) — Traefik 라우팅으로 Blue/Green 전환" },
13
+ ];
14
+
15
+ export const DEFAULT_DEPLOY_STYLE = "simple";
16
+
17
+ export const isDeployStyle = (v) => DEPLOY_STYLES.some((s) => s.value === v);
18
+
19
+ // 이 파일이 CD 본체인가 (= 택1 대상인가). PR 프리뷰는 배포 방식과 직교하는 축이라 제외한다.
20
+ export const isDeployWorkflow = (filename) => DEPLOY_STYLES.some((s) => filename.endsWith(s.suffix));
21
+
22
+ // 모르는 값은 기본값으로 수렴시킨다. 빈 접미사를 돌려주면 endsWith("")가 항상 참이라
23
+ // "전부 통과"가 되어, 잘못된 값이 조용히 CD 전부 설치로 새어나간다.
24
+ const suffixOf = (style) =>
25
+ (DEPLOY_STYLES.find((s) => s.value === style) ?? DEPLOY_STYLES.find((s) => s.value === DEFAULT_DEPLOY_STYLE)).suffix;
26
+
27
+ // 파일 필터 — 고른 방식의 CD만 통과. CD가 아닌 파일(PR 프리뷰·common 등)은 항상 통과.
28
+ export function deployFilter(style) {
29
+ const suffix = suffixOf(style);
30
+ return (filename) => !isDeployWorkflow(filename) || filename.endsWith(suffix);
31
+ }
32
+
33
+ // 무중단 템플릿은 push 트리거가 주석 처리된 채 들어 있다(기본 배포가 단일 서버라서).
34
+ // 사용자가 그 방식을 고른 이상 트리거는 켜져 있어야 한다 — 안 그러면 설치해도 아무 일이
35
+ // 일어나지 않고 사용자가 YAML을 직접 고쳐야 한다.
36
+ //
37
+ // 첫 `on:` 블록 안에서 `# ` 두 글자만 떼므로 안쪽 들여쓰기 계층이 그대로 보존된다.
38
+ // 설명문 주석은 벗겨낸 내용이 push/branches/- 로 시작하지 않아 건드리지 않는다.
39
+ const TRIGGER_CONTENT = /^\s*(push:|branches:|- )/;
40
+
41
+ export function activateDeployTrigger(content) {
42
+ const eol = content.includes("\r\n") ? "\r\n" : "\n";
43
+ const lines = content.split(/\r?\n/);
44
+ let inOn = false;
45
+ let changed = false;
46
+ for (let i = 0; i < lines.length; i++) {
47
+ const line = lines[i];
48
+ if (/^on:\s*$/.test(line)) { inOn = true; continue; }
49
+ if (!inOn) continue;
50
+ if (/^\S/.test(line)) break; // 최상위 키를 다시 만나면 on 블록 종료
51
+ const m = line.match(/^(\s*)# ?(.*)$/);
52
+ if (!m || !TRIGGER_CONTENT.test(m[2])) continue;
53
+ lines[i] = `${m[1]}${m[2]}`;
54
+ changed = true;
55
+ }
56
+ return changed ? lines.join(eol) : content;
57
+ }
58
+
59
+ // 방식을 바꿔 재설치했을 때 이전 CD를 정리한다.
60
+ //
61
+ // 남겨두면 이전 방식의 push 트리거가 살아 있어 배포가 두 번 돈다. 그렇다고 사용자에게
62
+ // "직접 지우세요"라고 떠넘기면 설치가 끝나도 레포가 정상이 아닌 상태로 남는다. 마법사가
63
+ // 깐 파일은 마법사가 정리한다.
64
+ //
65
+ // 손대지 않은 것(baseline의 installed 해시와 동일) → 삭제. 물어볼 이유가 없다.
66
+ // 손댄 것 → .bak으로 옮긴다. 내용은 지키고 트리거만 죽인다.
67
+ //
68
+ // 반환: { removed:[], backedUp:[] } — 완료 화면·설치 기록에 그대로 보고한다.
69
+ export function cleanupOtherDeployWorkflows(workflowsDir, installedFilenames, style, baseline) {
70
+ const keep = deployFilter(style);
71
+ const removed = [];
72
+ const backedUp = [];
73
+
74
+ for (const filename of installedFilenames) {
75
+ if (!isDeployWorkflow(filename) || keep(filename)) continue;
76
+ const p = join(workflowsDir, filename);
77
+ if (!existsSync(p)) continue;
78
+
79
+ const known = baseline?.files?.[filename]?.installed;
80
+ const untouched = known && sha256(readFileSync(p, "utf8")) === known;
81
+ if (untouched) {
82
+ rmSync(p, { force: true });
83
+ removed.push(filename);
84
+ } else {
85
+ renameSync(p, `${p}.bak`);
86
+ backedUp.push(filename);
87
+ }
88
+ }
89
+ return { removed, backedUp };
90
+ }