project-auto-wizard 0.1.6 → 0.1.8

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.
@@ -0,0 +1,129 @@
1
+ // uninstall 모드 — revert(payload 파일명 일치분)보다 넓게, README/.gitignore/version.yml까지 선택적으로 제거.
2
+ // 대화형 체크리스트 또는 --force + --purge-* 로 항목별 opt-in. revert.js는 건드리지 않고 읽기 전용으로만 재사용한다.
3
+ import { join } from "node:path";
4
+ import { existsSync, renameSync } from "node:fs";
5
+ import { PATHS } from "../core/paths.js";
6
+ import { remove } from "../core/fsutil.js";
7
+ import { planRevert } from "./revert.js";
8
+ import { removeVersionSectionFromReadme, hasVersionSection } from "../core/copy/readme.js";
9
+ import { removeAutoAddedEntriesFromGitignore, hasAutoAddedEntries } from "../core/copy/gitignore.js";
10
+ import { CANCEL } from "../ui/prompts.js";
11
+
12
+ // selection: { workflows, scripts, coderabbit, readme, gitignore, versionYml } (모두 boolean).
13
+ // 반환: 위와 동일한 키의 boolean/배열 — 실제로 제거 "대상"인지 여부(순수 함수, 아무것도 지우지 않음).
14
+ export function planUninstall(payloadRoot, targetRoot, selection) {
15
+ const revertPlan = planRevert(payloadRoot, targetRoot);
16
+ return {
17
+ workflows: selection.workflows ? revertPlan.workflows : [],
18
+ scripts: selection.scripts ? revertPlan.scripts : [],
19
+ coderabbit: selection.coderabbit ? revertPlan.coderabbit : false,
20
+ readme: selection.readme ? hasVersionSection(targetRoot) : false,
21
+ gitignore: selection.gitignore ? hasAutoAddedEntries(targetRoot) : false,
22
+ versionYml: selection.versionYml ? existsSync(join(targetRoot, PATHS.versionFile)) : false,
23
+ };
24
+ }
25
+
26
+ // 반환: planUninstall과 동일한 형태 — 실제로 제거된 항목.
27
+ export function runUninstall(context, payloadRoot, targetRoot, selection) {
28
+ const plan = planUninstall(payloadRoot, targetRoot, selection);
29
+ const wfDir = join(targetRoot, PATHS.workflowsDir);
30
+ for (const name of plan.workflows) remove(join(wfDir, name));
31
+ for (const name of plan.scripts) remove(join(targetRoot, PATHS.scriptsDir, name));
32
+ if (plan.coderabbit) {
33
+ const cr = join(targetRoot, ".coderabbit.yaml");
34
+ remove(cr);
35
+ if (existsSync(cr + ".bak")) renameSync(cr + ".bak", cr);
36
+ }
37
+ // removeVersionSectionFromReadme/removeAutoAddedEntriesFromGitignore는 plan이 "제거 대상"으로
38
+ // 판단했더라도 실제로는 안전하게 포기(skip-*)할 수 있다 — 반환 상태를 그대로 신뢰하지 않고
39
+ // 실제 결과로 plan을 덮어써서 호출부(CLI/대화형 요약)가 거짓 성공을 보고하지 않게 한다.
40
+ const readmeRemoved = plan.readme && removeVersionSectionFromReadme(targetRoot) === "removed";
41
+ const gitignoreStatus = plan.gitignore ? removeAutoAddedEntriesFromGitignore(targetRoot) : null;
42
+ const gitignoreRemoved = gitignoreStatus === "removed" || gitignoreStatus === "file-deleted";
43
+ if (plan.versionYml) remove(join(targetRoot, PATHS.versionFile));
44
+ return { ...plan, readme: readmeRemoved, gitignore: gitignoreRemoved };
45
+ }
46
+
47
+ // ── 대화형 체크리스트 흐름 ────────────────────────────────────────────
48
+ const ITEM_DEFS = [
49
+ { key: "workflows", label: "워크플로우 (.github/workflows/PROJECT-*.yaml)" },
50
+ { key: "scripts", label: "스크립트 (.github/scripts/*.py)" },
51
+ { key: "coderabbit", label: ".coderabbit.yaml" },
52
+ { key: "readme", label: "README.md 버전 섹션 (AUTO-VERSION-SECTION)" },
53
+ { key: "gitignore", label: ".gitignore 자동 추가 항목" },
54
+ { key: "versionYml", label: "version.yml (버전/브랜치 설정 전체)" },
55
+ ];
56
+
57
+ // 기본 체크 상태 — 설치 시 옵션(nexus/secret-backup/coderabbit)이 opt-in인 것과 대칭으로,
58
+ // 여기서는 "안전 삭제" 3종만 기본 체크하고 나머지(readme/gitignore/versionYml)는 opt-in.
59
+ export const SAFE_ITEMS = ["workflows", "scripts", "coderabbit"];
60
+
61
+ function detectAvailableItems(payloadRoot, targetRoot) {
62
+ const revertPlan = planRevert(payloadRoot, targetRoot);
63
+ const presence = {
64
+ workflows: revertPlan.workflows.length > 0,
65
+ scripts: revertPlan.scripts.length > 0,
66
+ coderabbit: revertPlan.coderabbit,
67
+ readme: hasVersionSection(targetRoot),
68
+ gitignore: hasAutoAddedEntries(targetRoot),
69
+ versionYml: existsSync(join(targetRoot, PATHS.versionFile)),
70
+ };
71
+ return ITEM_DEFS.filter((d) => presence[d.key]).map((d) => ({ value: d.key, label: d.label }));
72
+ }
73
+
74
+ function toSelection(checkedKeys) {
75
+ const set = new Set(checkedKeys);
76
+ return {
77
+ workflows: set.has("workflows"), scripts: set.has("scripts"), coderabbit: set.has("coderabbit"),
78
+ readme: set.has("readme"), gitignore: set.has("gitignore"), versionYml: set.has("versionYml"),
79
+ };
80
+ }
81
+
82
+ function summarizeSelection(selection) {
83
+ const labelOf = Object.fromEntries(ITEM_DEFS.map((d) => [d.key, d.label]));
84
+ const chosen = Object.keys(selection).filter((k) => selection[k]).map((k) => `- ${labelOf[k]}`);
85
+ return chosen.length ? chosen.join("\n") : "(선택된 항목 없음)";
86
+ }
87
+
88
+ function summarizeResult(result) {
89
+ const lines = [];
90
+ if (result.workflows.length) lines.push(`워크플로우 ${result.workflows.length}개 제거`);
91
+ if (result.scripts.length) lines.push(`스크립트 ${result.scripts.length}개 제거`);
92
+ if (result.coderabbit) lines.push(".coderabbit.yaml 제거");
93
+ if (result.readme) lines.push("README.md 버전 섹션 제거");
94
+ if (result.gitignore) lines.push(".gitignore 자동 추가 항목 제거");
95
+ if (result.versionYml) lines.push("version.yml 제거");
96
+ return lines.length ? lines.join("\n") : "제거된 항목이 없습니다.";
97
+ }
98
+
99
+ // io 계약: engineIo.multiselect({message,options,initialValues}), askYesNo(msg,def),
100
+ // note(text,title)?, cancelMessage(text)? — src/ui/prompts.js가 실물, 테스트는 스텁 주입.
101
+ export async function runUninstallFlow(payloadRoot, targetRoot, io) {
102
+ const available = detectAvailableItems(payloadRoot, targetRoot);
103
+ if (available.length === 0) {
104
+ io.note?.("제거할 항목이 없습니다.", "완전 삭제");
105
+ return null;
106
+ }
107
+
108
+ const checked = await io.engineIo.multiselect({
109
+ message: "삭제할 항목을 선택하세요 (Space 토글, Enter 확정)",
110
+ options: available,
111
+ initialValues: available.map((o) => o.value).filter((v) => SAFE_ITEMS.includes(v)),
112
+ });
113
+ if (checked === CANCEL || !Array.isArray(checked) || checked.length === 0) {
114
+ io.cancelMessage?.("완전 삭제를 취소했습니다.");
115
+ return null;
116
+ }
117
+
118
+ const selection = toSelection(checked);
119
+ io.note?.(summarizeSelection(selection), "삭제 예정 항목");
120
+ const ok = await io.askYesNo("정말 삭제할까요? 되돌릴 수 없습니다.", false);
121
+ if (ok !== true) {
122
+ io.cancelMessage?.("완전 삭제를 취소했습니다.");
123
+ return null;
124
+ }
125
+
126
+ const result = runUninstall({}, payloadRoot, targetRoot, selection);
127
+ io.note?.(summarizeResult(result), "완전 삭제 완료");
128
+ return result;
129
+ }
@@ -14,7 +14,8 @@ import { ensureGitignore } from "../core/copy/gitignore.js";
14
14
  export function runVersion(context, payloadRoot, targetRoot = ".") {
15
15
  const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
16
16
  now, today, templateVersion = "unknown",
17
- includeNexus = false, includeSecretBackup = false, includeCodeRabbit = false } = context;
17
+ includeNexus = false, includeSecretBackup = false, includeCodeRabbit = false,
18
+ includeSemverAuto } = context;
18
19
 
19
20
  const pathMarkers = new Map();
20
21
  for (const [t] of paths) pathMarkers.set(t, markerForType(t));
@@ -23,7 +24,7 @@ export function runVersion(context, payloadRoot, targetRoot = ".") {
23
24
  buildVersionYml({
24
25
  templateText: readVersionYmlTemplate(payloadRoot),
25
26
  version, types, paths, pathMarkers, branch, branches: context.branches, versionCode, now, today,
26
- templateOptions: { templateVersion, includeNexus, includeSecretBackup, includeCodeRabbit: includeCodeRabbit === true, optionsDate: today },
27
+ templateOptions: { templateVersion, includeNexus, includeSecretBackup, includeCodeRabbit: includeCodeRabbit === true, includeSemverAuto: includeSemverAuto !== false, optionsDate: today },
27
28
  }));
28
29
  addVersionSectionToReadme(version, targetRoot);
29
30
  copyScripts(payloadRoot, targetRoot);
package/src/context.js CHANGED
@@ -18,6 +18,7 @@ export function createContext(overrides = {}) {
18
18
  includeNexus: null, // null=미설정, true/false=명시
19
19
  includeSecretBackup: null,
20
20
  includeCodeRabbit: null, // CodeRabbit opt-in (기본 false — version.yml options.coderabbit에 기록)
21
+ includeSemverAuto: null, // null=미설정(다운스트림에서 true로 해석), true/false=명시
21
22
  templateVersion: "",
22
23
  deployValues: new Map(), // "type.KEY" -> value
23
24
  counters: {},
@@ -1,6 +1,6 @@
1
1
  // .gitignore 보장 (.sh ensure_gitignore + normalize/check 등가) — template_integrator.sh 3996~4111.
2
2
  import { join } from "node:path";
3
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
4
4
 
5
5
  const REQUIRED_ENTRIES = ["/.idea", "/.claude/settings.local.json"];
6
6
 
@@ -53,3 +53,55 @@ export function ensureGitignore(targetRoot = ".") {
53
53
  writeFileSync(p, content);
54
54
  return { created: false, added: toAdd };
55
55
  }
56
+
57
+ // ensureGitignore가 기존 파일에 배너 블록을 추가할 때 항상 이 정확한 시퀀스로 시작한다
58
+ // (빈 줄 하나 + 3줄 배너). 배너 직후, REQUIRED_ENTRIES와 일치하는 라인이 연속되는 동안만
59
+ // "마법사가 추가한 항목"이다 — 그 뒤에 사용자가 나중에 추가한 항목은 절대 건드리지 않는다.
60
+ const BANNER =
61
+ "\n" +
62
+ "# ====================================================================\n" +
63
+ "# project-auto-wizard: Auto-added entries\n" +
64
+ "# ====================================================================\n";
65
+
66
+ // .gitignore에 마법사가 추가한 항목이 있는지 확인 (체크리스트 노출 판단용).
67
+ // 두 케이스: (1) 원래 없던 파일을 통째로(또는 그 뒤에 사용자가 이어 쓴 형태로) 새로 만든 경우
68
+ // (2) 기존 파일에 배너 블록을 붙인 경우.
69
+ export function hasAutoAddedEntries(targetRoot = ".") {
70
+ const p = join(targetRoot, ".gitignore");
71
+ if (!existsSync(p)) return false;
72
+ const content = readFileSync(p, "utf8");
73
+ return content.startsWith(NEW_FILE_CONTENT) || content.includes(BANNER);
74
+ }
75
+
76
+ // 반환: 'removed' | 'file-deleted' | 'skip-no-gitignore' | 'skip-not-found'
77
+ export function removeAutoAddedEntriesFromGitignore(targetRoot = ".") {
78
+ const p = join(targetRoot, ".gitignore");
79
+ if (!existsSync(p)) return "skip-no-gitignore";
80
+ const content = readFileSync(p, "utf8");
81
+
82
+ // 원래 파일이 없었는데 마법사가 통째로 만든 경우 — 그 뒤에 사용자가 이어서 추가한 내용만 보존.
83
+ if (content.startsWith(NEW_FILE_CONTENT)) {
84
+ const remainder = content.slice(NEW_FILE_CONTENT.length);
85
+ if (remainder === "") {
86
+ rmSync(p);
87
+ return "file-deleted";
88
+ }
89
+ writeFileSync(p, remainder);
90
+ return "removed";
91
+ }
92
+
93
+ // 기존 파일에 배너 블록이 붙은 경우 — 배너 직후 REQUIRED_ENTRIES와 일치하는 라인이 연속되는
94
+ // 동안만 제거하고, 그 뒤(사용자가 나중에 추가한 항목)는 그대로 둔다.
95
+ const idx = content.indexOf(BANNER);
96
+ if (idx === -1) return "skip-not-found";
97
+ const afterBanner = idx + BANNER.length;
98
+ const lines = content.slice(afterBanner).split("\n");
99
+ let consumed = 0;
100
+ for (const line of lines) {
101
+ const isKnownEntry = REQUIRED_ENTRIES.some((e) => normalizeGitignoreEntry(line) === normalizeGitignoreEntry(e));
102
+ if (!isKnownEntry) break;
103
+ consumed += line.length + 1; // +1: split이 삼킨 "\n"
104
+ }
105
+ writeFileSync(p, content.slice(0, idx) + content.slice(afterBanner + consumed));
106
+ return "removed";
107
+ }
@@ -1,6 +1,6 @@
1
1
  // README 버전 섹션 추가 (.sh add_version_section_to_readme 등가) — template_integrator.sh 2145~2181.
2
2
  import { join } from "node:path";
3
- import { existsSync, readFileSync, appendFileSync } from "node:fs";
3
+ import { existsSync, readFileSync, appendFileSync, writeFileSync } from "node:fs";
4
4
 
5
5
  const MARKER = "<!-- AUTO-VERSION-SECTION";
6
6
  // ## (최신 버전|최신버전|Version|버전) : vX.Y.Z (대소문자 무시)
@@ -28,3 +28,54 @@ export function addVersionSectionToReadme(version, targetRoot = ".") {
28
28
  appendFileSync(p, section);
29
29
  return "added";
30
30
  }
31
+
32
+ // addVersionSectionToReadme가 항상 파일 맨 끝에 붙이는 접두/접미 시퀀스.
33
+ // 접두(SECTION_PREFIX)가 있으면 마법사가 append한 "블록"이다 — 이 블록은 항상 SECTION_TAIL
34
+ // 라인으로 끝나므로, 그 지점까지만 잘라내야 그 뒤에 사용자가 나중에 덧붙인 내용(라이선스 절 등)을
35
+ // 지우지 않는다("파일 끝까지" 자르면 사용자 콘텐츠가 소실된다).
36
+ const SECTION_PREFIX = "\n---\n\n" + MARKER;
37
+ const SECTION_TAIL = "[전체 버전 기록 보기](CHANGELOG.md)\n";
38
+ // PROJECT-COMMON-README-VERSION-UPDATE.yaml(설치되는 CI)은 설치 시점에 이미 사용자가 자기 버전
39
+ // 라인을 갖고 있던 README(addVersionSectionToReadme가 'skip-version-line'으로 건너뛴 경우)에는
40
+ // '---' 구분자 없이 마커 주석 한 줄만 그 버전 라인 위에 끼워넣는다. 이 경우 버전 라인 자체는
41
+ // 사용자 소유이므로 지우지 않고 마커 주석 한 줄만 제거한다.
42
+ const MARKER_LINE = "<!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->\n";
43
+
44
+ // README.md에 마법사(또는 설치된 CI)가 추가한 흔적이 있는지 확인 (체크리스트 노출 판단용).
45
+ export function hasVersionSection(targetRoot = ".") {
46
+ const p = join(targetRoot, "README.md");
47
+ if (!existsSync(p)) return false;
48
+ const content = readFileSync(p, "utf8");
49
+ return content.includes(SECTION_PREFIX) || content.includes(MARKER_LINE);
50
+ }
51
+
52
+ // 반환: 'removed' | 'skip-no-readme' | 'skip-no-marker' | 'skip-unexpected-format'
53
+ export function removeVersionSectionFromReadme(targetRoot = ".") {
54
+ const p = join(targetRoot, "README.md");
55
+ if (!existsSync(p)) return "skip-no-readme";
56
+ const content = readFileSync(p, "utf8");
57
+
58
+ const idx = content.indexOf(SECTION_PREFIX);
59
+ if (idx !== -1) {
60
+ // 마법사가 append한 전체 블록 케이스 — SECTION_TAIL 라인까지만 잘라내고 그 이후는 보존한다.
61
+ // tail을 못 찾거나(사용자가 그 줄을 지웠다면), 위저드 블록치고 너무 먼 곳에서 발견되면
62
+ // (사용자가 같은 문구를 자기 문서 어딘가로 옮겨 적은 경우) 그 사이 사용자 콘텐츠까지
63
+ // 지워버릴 수 있으므로 어디까지가 "마법사 구간"인지 확신할 수 없어 안전하게 포기한다.
64
+ // 위저드 블록은 버전 문자열이 길어져도 수백 바이트를 넘지 않는다.
65
+ const MAX_SECTION_LENGTH = 300;
66
+ const tailIdx = content.indexOf(SECTION_TAIL, idx);
67
+ if (tailIdx === -1 || tailIdx > idx + MAX_SECTION_LENGTH) return "skip-unexpected-format";
68
+ const cutEnd = tailIdx + SECTION_TAIL.length;
69
+ writeFileSync(p, content.slice(0, idx) + content.slice(cutEnd));
70
+ return "removed";
71
+ }
72
+
73
+ const markerIdx = content.indexOf(MARKER_LINE);
74
+ if (markerIdx !== -1) {
75
+ // CI가 사용자의 기존 버전 라인 위에 마커만 끼워넣은 케이스 — 버전 라인은 건드리지 않는다.
76
+ writeFileSync(p, content.slice(0, markerIdx) + content.slice(markerIdx + MARKER_LINE.length));
77
+ return "removed";
78
+ }
79
+
80
+ return "skip-no-marker";
81
+ }
@@ -231,3 +231,59 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
231
231
  }
232
232
  }
233
233
  }
234
+
235
+ // 전체 워크플로우 분류(common + 타입별 + server-deploy + nexus opt-in) — status/dry-run 공용.
236
+ // listWorkflowConflicts와 달리 common 디렉토리도 포함하고, changed뿐 아니라
237
+ // newFiles/unchanged까지 전부 반환한다(읽기 전용 — 실제로 아무 파일도 쓰지 않는다).
238
+ export function planWorkflows(context, payloadRoot, targetRoot = ".") {
239
+ const { types = [], paths = new Map(), includeNexus = false, includeSecretBackup = false, repoName = "", resolvers = {} } = context;
240
+ const workflowsDir = join(targetRoot, PATHS.workflowsDir);
241
+ const projectTypesDir = join(payloadRoot, PAYLOAD.workflowsDir);
242
+ const srcText = makeSrcText(context.branches || null);
243
+ const branchMode = context.branches?.mode || "pr-flow";
244
+ const plan = { newFiles: [], unchanged: [], changed: [] };
245
+
246
+ const merge = (result, type, excluded = null) => {
247
+ for (const bucket of ["newFiles", "unchanged", "changed"]) {
248
+ for (const filename of result[bucket]) {
249
+ if (excluded && excluded.has(filename)) continue;
250
+ plan[bucket].push({ filename, type });
251
+ }
252
+ }
253
+ };
254
+
255
+ const commonDir = join(projectTypesDir, "common");
256
+ if (exists(commonDir)) {
257
+ const envOpts = { type: "common", projectPath: ".", repoName, resolvers };
258
+ merge(classify(commonDir, workflowsDir, envOpts, srcText), "common",
259
+ branchMode === "trunk-based" ? TRUNK_BASED_EXCLUDED : null);
260
+ }
261
+
262
+ // secret-backup은 copyWorkflows처럼 신규 파일만 대상(기존 파일은 절대 덮어쓰지 않는 규약) —
263
+ // classify()의 changed 판정과 무관하게, 여기서도 존재 여부만으로 new/unchanged를 가른다.
264
+ const secretDir = join(commonDir, "secret-backup");
265
+ if (exists(secretDir) && includeSecretBackup) {
266
+ for (const filename of listYamlFiles(secretDir)) {
267
+ const dst = join(workflowsDir, filename);
268
+ plan[existsSync(dst) ? "unchanged" : "newFiles"].push({ filename, type: "common" });
269
+ }
270
+ }
271
+
272
+ for (const type of types) {
273
+ const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers };
274
+ const typeDir = join(projectTypesDir, type);
275
+ if (exists(typeDir)) merge(classify(typeDir, workflowsDir, envOpts, srcText), type);
276
+
277
+ const serverDeployDir = join(typeDir, "server-deploy");
278
+ if (exists(serverDeployDir) && !includeNexus) {
279
+ merge(classify(serverDeployDir, workflowsDir, envOpts, srcText), type);
280
+ }
281
+
282
+ const nexusDir = join(typeDir, "nexus");
283
+ if (exists(nexusDir) && includeNexus) {
284
+ merge(classify(nexusDir, workflowsDir, envOpts, srcText), type);
285
+ }
286
+ }
287
+
288
+ return plan;
289
+ }
@@ -7,7 +7,7 @@
7
7
  // 구 synology 키 등 다른 키는 어느 분기에도 안 걸려 자연히 무시된다.
8
8
  // (options-ask.js가 이 함수를 import한다 — 순환 방지 위해 여기(version-yml)에 정의.)
9
9
  export function parseTemplateOptions(content) {
10
- const out = { nexus: null, secretBackup: null, coderabbit: null };
10
+ const out = { nexus: null, secretBackup: null, coderabbit: null, semverAuto: null };
11
11
  // 값 정규화: 따옴표 제거 + 트림 (.sh tr -d '"' | tr -d "'" | xargs 등가)
12
12
  const strip = (s) => String(s).replace(/["']/g, "").trim();
13
13
  let inTemplate = false;
@@ -37,6 +37,13 @@ export function parseTemplateOptions(content) {
37
37
  if (v === "false") out.coderabbit = false;
38
38
  continue;
39
39
  }
40
+ m = line.match(/^\s+semver_auto:\s*(.+)/);
41
+ if (m) {
42
+ const v = strip(m[1]);
43
+ if (v === "true") out.semverAuto = true;
44
+ if (v === "false") out.semverAuto = false;
45
+ continue;
46
+ }
40
47
  // 들여쓰기 0~4칸의 다른 키 → options 섹션 종료 (.sh L2404~2408)
41
48
  if (/^\s{0,4}[a-z_]+:/.test(line)) { inOptions = false; inTemplate = false; }
42
49
  }
@@ -137,7 +144,7 @@ export function buildVersionYml({
137
144
  const b = branches || { main: branch || "main", develop: "develop", mode: "pr-flow" };
138
145
  const {
139
146
  templateVersion = "unknown", includeNexus = false, includeSecretBackup = false,
140
- includeCodeRabbit = false, optionsDate = today,
147
+ includeCodeRabbit = false, includeSemverAuto = true, optionsDate = today,
141
148
  } = templateOptions || {};
142
149
 
143
150
  // project_paths 블록 (full-line 토큰 {{PROJECT_PATHS}} — 없으면 라인 제거)
@@ -171,7 +178,7 @@ export function buildVersionYml({
171
178
  TEMPLATE_VERSION: templateVersion,
172
179
  MAIN_BRANCH: b.main, DEVELOP_BRANCH: b.develop, BRANCH_MODE: b.mode,
173
180
  OPT_NEXUS: String(includeNexus), OPT_SECRET_BACKUP: String(includeSecretBackup),
174
- OPT_CODERABBIT: String(includeCodeRabbit),
181
+ OPT_CODERABBIT: String(includeCodeRabbit), OPT_SEMVER_AUTO: String(includeSemverAuto),
175
182
  };
176
183
 
177
184
  const out = [];
package/src/index.js CHANGED
@@ -19,7 +19,12 @@ import { runFull } from "./commands/full.js";
19
19
  import { runVersion } from "./commands/version.js";
20
20
  import { runWorkflows } from "./commands/workflows.js";
21
21
  import { runRevert } from "./commands/revert.js";
22
+ import { runUninstall, runUninstallFlow } from "./commands/uninstall.js";
23
+ import * as prompts from "./ui/prompts.js";
22
24
  import { runInteractive } from "./commands/interactive.js";
25
+ import { runStatus, printStatus } from "./commands/status.js";
26
+ import { runDoctor, printDoctorReport } from "./commands/doctor.js";
27
+ import { planDryRun, printDryRun } from "./commands/dry-run.js";
23
28
 
24
29
  // 패키지 버전 읽기 (-v/--version 출력용). src/../package.json.
25
30
  function readPkgVersion() {
@@ -58,8 +63,13 @@ export async function run(argv, { cwd = process.cwd(), payloadRoot, clock } = {}
58
63
 
59
64
  // 대화형 모드 — 인자 없이 실행 or --mode interactive
60
65
  if (opts.mode === "interactive") {
66
+ // --dry-run은 대화형 모드에서 조용히 무시되면 안 됨(실제 설치가 진행돼버림) — 명시 에러로 차단.
67
+ if (opts.dryRun) {
68
+ console.error("--dry-run은 --mode <full|version|workflows|revert|uninstall>와 함께 사용하세요 (대화형 모드에서는 지원하지 않습니다).");
69
+ return 1;
70
+ }
61
71
  if (!process.stdout.isTTY) {
62
- console.error("대화형 입력이 불가능한 환경입니다. --mode <full|version|workflows|revert> 와 --force 를 지정하세요.");
72
+ console.error("대화형 입력이 불가능한 환경입니다. --mode <full|version|workflows|revert|uninstall> 와 --force 를 지정하세요.");
63
73
  return 1;
64
74
  }
65
75
  return await runInteractive({}, { cwd, payloadRoot: payload, clock });
@@ -67,17 +77,62 @@ export async function run(argv, { cwd = process.cwd(), payloadRoot, clock } = {}
67
77
 
68
78
  // revert 모드 — payload 유래 파일 제거 (감지·질문 불필요, --force 게이트만)
69
79
  if (opts.mode === "revert") {
70
- if (!opts.force && !process.stdout.isTTY) {
80
+ // --dry-run은 파일을 쓰지 않으므로 --force 게이트를 우회한다 (status/doctor와 동일한 안전성).
81
+ if (!opts.force && !opts.dryRun && !process.stdout.isTTY) {
71
82
  console.error("비대화형 환경에서는 --force 옵션이 필요합니다.");
72
83
  return 1;
73
84
  }
85
+ if (opts.dryRun) {
86
+ printDryRun(planDryRun("revert", {}, payload, cwd));
87
+ return 0;
88
+ }
74
89
  const r = runRevert({}, payload, cwd);
75
90
  console.error(`제거됨 — 워크플로우 ${r.workflows.length}개, 스크립트 ${r.scripts.length}개${r.coderabbit ? ", .coderabbit.yaml" : ""}`);
76
91
  console.error("version.yml·README·.gitignore는 보존됩니다 (사용자 데이터).");
77
92
  return 0;
78
93
  }
94
+
95
+ // uninstall 모드 — revert보다 넓게 README·gitignore·version.yml까지 선택적으로 제거.
96
+ if (opts.mode === "uninstall") {
97
+ const safeSelection = {
98
+ workflows: true, scripts: true, coderabbit: true,
99
+ readme: opts.purgeReadme, gitignore: opts.purgeGitignore, versionYml: opts.purgeVersion,
100
+ };
101
+ if (opts.dryRun) {
102
+ printDryRun(planDryRun("uninstall", { uninstallSelection: safeSelection }, payload, cwd));
103
+ return 0;
104
+ }
105
+ if (opts.force) {
106
+ const r = runUninstall({}, payload, cwd, safeSelection);
107
+ const removed = [
108
+ `워크플로우 ${r.workflows.length}개`, `스크립트 ${r.scripts.length}개`,
109
+ r.coderabbit && ".coderabbit.yaml", r.readme && "README 버전 섹션",
110
+ r.gitignore && ".gitignore 자동 추가 항목", r.versionYml && "version.yml",
111
+ ].filter(Boolean).join(", ");
112
+ console.error(`제거됨 — ${removed}`);
113
+ return 0;
114
+ }
115
+ if (!process.stdout.isTTY) {
116
+ console.error("비대화형 환경에서는 --force 옵션이 필요합니다.");
117
+ return 1;
118
+ }
119
+ await runUninstallFlow(payload, cwd, prompts);
120
+ return 0;
121
+ }
122
+
123
+ // status 모드 — 읽기 전용, TTY/--force 무관하게 항상 동작
124
+ if (opts.mode === "status") {
125
+ printStatus(runStatus(payload, cwd));
126
+ return 0;
127
+ }
128
+ // doctor 모드 — 읽기 전용, TTY/--force 무관하게 항상 동작
129
+ if (opts.mode === "doctor") {
130
+ printDoctorReport(runDoctor(cwd));
131
+ return 0;
132
+ }
79
133
  // 명시 모드인데 --force 없으면 (비대화형 CLI는 --force 필요)
80
- if (!opts.force && !process.stdout.isTTY) {
134
+ // --dry-run은 파일을 쓰지 않으므로 --force 게이트를 우회한다 (status/doctor와 동일한 안전성).
135
+ if (!opts.force && !opts.dryRun && !process.stdout.isTTY) {
81
136
  console.error("비대화형 환경에서는 --force 옵션이 필요합니다.");
82
137
  return 1;
83
138
  }
@@ -107,7 +162,7 @@ export async function run(argv, { cwd = process.cwd(), payloadRoot, clock } = {}
107
162
  });
108
163
  // pr-flow에서 develop이 원격에 없으면 자동 생성+push (--force 비대화형 — 질문 없음).
109
164
  // 원격 목록을 못 읽는 환경(git 없음·origin 없음)은 remoteBranches=[]지만 push 실패를 조용히 보고.
110
- if (branches.mode === "pr-flow") {
165
+ if (branches.mode === "pr-flow" && !opts.dryRun) {
111
166
  const remoteBranches = await detectRemoteBranches(cwd);
112
167
  if (remoteBranches.length && !remoteBranches.includes(branches.develop)) {
113
168
  await ensureDevelopBranch({
@@ -127,6 +182,10 @@ export async function run(argv, { cwd = process.cwd(), payloadRoot, clock } = {}
127
182
  includeNexus: opts.includeNexus ?? existing?.options?.nexus ?? false,
128
183
  includeSecretBackup: opts.includeSecretBackup ?? existing?.options?.secretBackup ?? false,
129
184
  includeCodeRabbit: opts.includeCodeRabbit ?? existing?.options?.coderabbit ?? false,
185
+ // 기존 version.yml이 있는데 semver_auto 키가 아예 없었던 경우(신규 기능 추가 이전 설치·
186
+ // workflows-only 재실행) 조용히 true로 켜지면 애매한 커밋 하나로 major가 승격될 위험이 있다 —
187
+ // 기존 설치는 false로 안전하게 폴백, 완전 신규 설치만 true(기존 설계) 유지.
188
+ includeSemverAuto: opts.includeSemverAuto ?? existing?.options?.semverAuto ?? (existing ? false : true),
130
189
  repoName,
131
190
  // 실 resolver 4종 (.sh resolve_token 등가)
132
191
  resolvers: makeResolvers(cwd, repoName, paths),
@@ -142,6 +201,11 @@ export async function run(argv, { cwd = process.cwd(), payloadRoot, clock } = {}
142
201
  const proceed = await runBreakingCheck({ cwd, payloadRoot: payload, templateVersion: context.templateVersion });
143
202
  if (!proceed) return 0;
144
203
 
204
+ if (opts.dryRun) {
205
+ printDryRun(planDryRun(opts.mode, context, payload, cwd));
206
+ return 0;
207
+ }
208
+
145
209
  let result = null;
146
210
  switch (opts.mode) {
147
211
  case "full": result = runFull(context, payload, cwd); break;
package/src/ui/prompts.js CHANGED
@@ -14,6 +14,7 @@ export async function selectMode() {
14
14
  { value: "version", label: "버전 관리만 — 버전 자동 증가·동기화 시스템만 설치" },
15
15
  { value: "workflows", label: "워크플로우만 — 빌드·배포 GitHub Actions만 설치" },
16
16
  { value: "revert", label: "되돌리기 — 마법사가 설치한 워크플로우·스크립트 제거" },
17
+ { value: "uninstall", label: "완전 삭제 — 마법사가 설치·수정한 모든 항목 제거(확인 후, README·gitignore·version.yml 포함)" },
17
18
  ],
18
19
  });
19
20
  }