project-auto-wizard 0.1.8 → 0.1.10

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
@@ -16,7 +16,7 @@ npx project-auto-wizard
16
16
  [![node](https://img.shields.io/badge/node-%3E%3D20.12-brightgreen)](package.json)
17
17
 
18
18
  <!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
19
- ## 최신 버전 : v0.1.7 (2026-07-29)
19
+ ## 최신 버전 : v0.1.10 (2026-08-02)
20
20
 
21
21
  [전체 버전 기록 보기](CHANGELOG.md)
22
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-auto-wizard",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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",
package/src/cli/args.js CHANGED
@@ -22,6 +22,16 @@ export function parseArgs(argv) {
22
22
  purgeReadme: false, // --purge-readme: uninstall --force 시 README 버전 섹션도 제거
23
23
  purgeGitignore: false, // --purge-gitignore: uninstall --force 시 .gitignore 자동 추가 항목도 제거
24
24
  purgeVersion: false, // --purge-version: uninstall --force 시 version.yml도 제거
25
+ // purge 전용 플래그 (숨김 모드 — HELP_TEXT에는 노출하지 않는다).
26
+ yes: false, // --yes: purge 실행 확인 (필수, --force로 대체 불가)
27
+ allowDirty: false, // --allow-dirty: git 작업트리 dirty 상태에서도 강행
28
+ deleteDevelopBranch: false, // --delete-develop-branch: 로컬 develop 브랜치까지 삭제
29
+ keepVersionYml: false,
30
+ keepReadme: false,
31
+ keepChangelog: false,
32
+ keepWorkflows: false,
33
+ keepScripts: false,
34
+ keepCoderabbit: false,
25
35
  };
26
36
  const args = [...argv];
27
37
  while (args.length > 0) {
@@ -58,6 +68,15 @@ export function parseArgs(argv) {
58
68
  case "--purge-readme": result.purgeReadme = true; break;
59
69
  case "--purge-gitignore": result.purgeGitignore = true; break;
60
70
  case "--purge-version": result.purgeVersion = true; break;
71
+ case "--yes": result.yes = true; break;
72
+ case "--allow-dirty": result.allowDirty = true; break;
73
+ case "--delete-develop-branch": result.deleteDevelopBranch = true; break;
74
+ case "--keep-version-yml": result.keepVersionYml = true; break;
75
+ case "--keep-readme": result.keepReadme = true; break;
76
+ case "--keep-changelog": result.keepChangelog = true; break;
77
+ case "--keep-workflows": result.keepWorkflows = true; break;
78
+ case "--keep-scripts": result.keepScripts = true; break;
79
+ case "--keep-coderabbit": result.keepCoderabbit = true; break;
61
80
  case "--nexus": result.includeNexus = true; break;
62
81
  case "--no-nexus": result.includeNexus = false; break;
63
82
  case "--secret-backup": result.includeSecretBackup = true; break;
@@ -1,5 +1,6 @@
1
1
  // full 모드 오케스트레이터 (.sh execute_integration full case 등가).
2
- // 복사 순서: workflows(+env 치환) → version.yml → readme → scripts → coderabbit → gitignore
2
+ // 복사 순서: workflows(+env 치환) → version.yml → readme → scripts → coderabbit → gitignore(조건부)
3
+ // gitignore는 충돌 백업 부산물(.bak/.template.yaml)이 이번 실행에서 실제로 생겼을 때만 갱신한다 — issue #7.
3
4
  // (원본의 util/issue/discussion/setup-guide/config 설치는 project-auto-wizard 스코프에서 제외 — DESIGN-SPEC §2)
4
5
  import { join } from "node:path";
5
6
  import { writeText } from "../core/fsutil.js";
@@ -46,9 +47,13 @@ export function runFull(context, payloadRoot, targetRoot = ".", hooks = {}) {
46
47
  // 4. scripts (payload/scripts/*.py → .github/scripts/)
47
48
  copyScripts(payloadRoot, targetRoot);
48
49
 
49
- // 5. coderabbit (opt-in true일 때만 — DESIGN-SPEC §4 질문②) / gitignore
50
- if (includeCodeRabbit === true) copyCoderabbit(payloadRoot, { force }, targetRoot);
51
- ensureGitignore(targetRoot);
50
+ // 5. coderabbit (opt-in true일 때만 — DESIGN-SPEC §4 질문②)
51
+ const coderabbitResult = includeCodeRabbit === true ? copyCoderabbit(payloadRoot, { force }, targetRoot) : null;
52
52
 
53
- return { workflows: wfCounters };
53
+ // 6. gitignore 워크플로우/coderabbit 충돌 처리가 .bak나 .template.yaml을 실제로 만든 경우에만 갱신한다.
54
+ // 충돌 없는 설치(대부분의 최초 설치)는 .gitignore를 전혀 건드리지 않는다 — issue #7.
55
+ const gitignoreUpdated = wfCounters.backupAdded > 0 || wfCounters.templateAdded > 0 || coderabbitResult === "overwritten-backup";
56
+ if (gitignoreUpdated) ensureGitignore(targetRoot);
57
+
58
+ return { workflows: wfCounters, gitignoreUpdated };
54
59
  }
@@ -249,6 +249,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
249
249
  io.summary?.({
250
250
  mode, types, version, branches, includeCodeRabbit,
251
251
  counters: { workflows: result?.workflows?.copied ?? 0 },
252
+ gitignoreUpdated: result?.gitignoreUpdated === true,
252
253
  }, cwd);
253
254
  io.outro?.(`통합 완료 — ${mode} 모드로 설치했습니다.`);
254
255
  return 0;
@@ -0,0 +1,86 @@
1
+ // purge 모드 — revert가 지우는 전부(워크플로우·스크립트·coderabbit) + version.yml·README
2
+ // AUTO-VERSION-SECTION 블록·CHANGELOG를 추가로 제거해 설치 이전 상태로 완전히 되돌린다.
3
+ // 개발·테스트 전용 숨김 모드 — DESIGN-SPEC purge #6.
4
+ // develop 브랜치 삭제는 파일 삭제와 성격이 달라(실행 시점 git 상태 판단 필요) 여기 plan에는
5
+ // 포함하지 않고 index.js의 purge 분기에서 직접 처리한다.
6
+ import { join } from "node:path";
7
+ import { existsSync, renameSync } from "node:fs";
8
+ import { PATHS } from "../core/paths.js";
9
+ import { remove } from "../core/fsutil.js";
10
+ import { planRevert } from "./revert.js";
11
+ import { removeVersionSectionFromReadme, hasVersionSection } from "../core/copy/readme.js";
12
+
13
+ const CHANGELOG_FILES = ["CHANGELOG.json", "CHANGELOG.md"];
14
+
15
+ // keepFlags: { versionYml, readme, changelog, workflows, scripts, coderabbit } — true인 카테고리는 후보에서 제외.
16
+ // 반환: { workflows, scripts, coderabbit, versionYml, readmeSection, changelog } — 아무것도 지우지 않는 순수 함수.
17
+ export function planPurge(payloadRoot, targetRoot = ".", keepFlags = {}) {
18
+ const revertPlan = planRevert(payloadRoot, targetRoot);
19
+ return {
20
+ workflows: keepFlags.workflows ? [] : revertPlan.workflows,
21
+ scripts: keepFlags.scripts ? [] : revertPlan.scripts,
22
+ coderabbit: keepFlags.coderabbit ? false : revertPlan.coderabbit,
23
+ versionYml: !keepFlags.versionYml && existsSync(join(targetRoot, PATHS.versionFile)),
24
+ readmeSection: !keepFlags.readme && hasVersionSection(targetRoot),
25
+ changelog: keepFlags.changelog ? [] : CHANGELOG_FILES.filter((f) => existsSync(join(targetRoot, f))),
26
+ };
27
+ }
28
+
29
+ // 삭제 전 요약 출력 — dry-run 미리보기와 실제 실행 전 요약 양쪽에서 재사용한다.
30
+ export function printPurgePlan(plan, { dryRun = false } = {}) {
31
+ const lines = ["",
32
+ dryRun
33
+ ? "project-auto-wizard --mode purge --dry-run — 미리보기, 실제 파일은 바뀌지 않았습니다"
34
+ : "project-auto-wizard --mode purge — 아래 항목을 제거합니다",
35
+ ""];
36
+ lines.push(`워크플로우 (${plan.workflows.length}개):`);
37
+ for (const f of plan.workflows) lines.push(` - ${f}`);
38
+ lines.push(`스크립트 (${plan.scripts.length}개):`);
39
+ for (const f of plan.scripts) lines.push(` - ${f}`);
40
+ if (plan.coderabbit) lines.push("파일: .coderabbit.yaml");
41
+ if (plan.versionYml) lines.push("파일: version.yml");
42
+ if (plan.readmeSection) lines.push("README.md: AUTO-VERSION-SECTION 블록");
43
+ for (const f of plan.changelog) lines.push(`파일: ${f}`);
44
+ lines.push("");
45
+ console.log(lines.join("\n"));
46
+ }
47
+
48
+ // 실제 삭제 수행 — planPurge()와 동일 shape을 반환하되 실제로 제거된 항목을 반영한다.
49
+ // runRevert()를 통째로 호출하지 않는 이유: runRevert는 항상 전체를 지우므로
50
+ // --keep-* 로 선택적 카테고리만 보존하는 요구사항과 맞지 않는다.
51
+ // H3 (Fable 검토): readmeSection은 plan의 판정을 그대로 되돌려주지 않고
52
+ // removeVersionSectionFromReadme()의 실제 반환값("removed"인지)을 반영한다 — 스펙 §6이
53
+ // "반환값은 실제 삭제 결과를 반영"하라고 명시하기 때문에, plan과 실제 제거 조건이
54
+ // 이론상 어긋나는 경우에도 printPurgeResult가 거짓으로 "제거됨"을 보고하지 않는다.
55
+ export function executePurge(payloadRoot, targetRoot = ".", keepFlags = {}) {
56
+ const plan = planPurge(payloadRoot, targetRoot, keepFlags);
57
+ const wfDir = join(targetRoot, PATHS.workflowsDir);
58
+ for (const name of plan.workflows) remove(join(wfDir, name));
59
+ for (const name of plan.scripts) remove(join(targetRoot, PATHS.scriptsDir, name));
60
+ if (plan.coderabbit) {
61
+ const cr = join(targetRoot, ".coderabbit.yaml");
62
+ remove(cr);
63
+ if (existsSync(cr + ".bak")) renameSync(cr + ".bak", cr);
64
+ }
65
+ if (plan.versionYml) remove(join(targetRoot, PATHS.versionFile));
66
+ const readmeSection = plan.readmeSection && removeVersionSectionFromReadme(targetRoot) === "removed";
67
+ for (const f of plan.changelog) remove(join(targetRoot, f));
68
+ return { ...plan, readmeSection };
69
+ }
70
+
71
+ // 삭제 후 실제 제거된 목록 출력 — printPurgePlan과 완전히 동일한 형태(파일명 나열)로
72
+ // 맞춘다 (M3, Fable 검토: 개수만 출력하면 스펙 §5-6의 "제거된 목록 재출력" 요구를 충족하지 못함).
73
+ export function printPurgeResult(result) {
74
+ const lines = ["", "제거됨:", ""];
75
+ lines.push(`워크플로우 (${result.workflows.length}개):`);
76
+ for (const f of result.workflows) lines.push(` - ${f}`);
77
+ lines.push(`스크립트 (${result.scripts.length}개):`);
78
+ for (const f of result.scripts) lines.push(` - ${f}`);
79
+ if (result.coderabbit) lines.push("파일: .coderabbit.yaml");
80
+ if (result.versionYml) lines.push("파일: version.yml");
81
+ if (result.readmeSection) lines.push("README.md: AUTO-VERSION-SECTION 블록");
82
+ for (const f of result.changelog) lines.push(`파일: ${f}`);
83
+ lines.push("(.gitignore에 추가된 백업 파일 제외 항목(*.bak/*.template.yaml)은 purge 대상에서 제외되어 그대로 보존됩니다)");
84
+ lines.push("");
85
+ console.log(lines.join("\n"));
86
+ }
@@ -1,6 +1,7 @@
1
1
  // version 모드 (.sh execute_integration version case 등가).
2
- // 순서: version.yml → readme → scripts → gitignore.
3
- // (워크플로우 미복사 deploy 블록 없음. util·issue·coderabbit·setup-guide는 스코프 제외.)
2
+ // 순서: version.yml → readme → scripts.
3
+ // (워크플로우/coderabbit을 복사하지 않으므로 충돌 백업 부산물이 생길 수 없다 — gitignore 갱신 대상 없음, issue #7.
4
+ // util·issue·coderabbit·setup-guide는 스코프 제외.)
4
5
  import { join } from "node:path";
5
6
  import { writeText } from "../core/fsutil.js";
6
7
  import { PATHS } from "../core/paths.js";
@@ -9,7 +10,6 @@ import { readVersionYmlTemplate } from "../core/assets.js";
9
10
  import { markerForType } from "../core/detect.js";
10
11
  import { addVersionSectionToReadme } from "../core/copy/readme.js";
11
12
  import { copyScripts } from "../core/copy/simple.js";
12
- import { ensureGitignore } from "../core/copy/gitignore.js";
13
13
 
14
14
  export function runVersion(context, payloadRoot, targetRoot = ".") {
15
15
  const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
@@ -28,5 +28,4 @@ export function runVersion(context, payloadRoot, targetRoot = ".") {
28
28
  }));
29
29
  addVersionSectionToReadme(version, targetRoot);
30
30
  copyScripts(payloadRoot, targetRoot);
31
- ensureGitignore(targetRoot);
32
31
  }
@@ -5,7 +5,7 @@
5
5
  import { execFile } from "node:child_process";
6
6
 
7
7
  // 기본 exec — git 명령 실행. 반환 {code, stdout, stderr}. 테스트는 mock 주입.
8
- function defaultExec(cmd, args, { cwd } = {}) {
8
+ export function defaultExec(cmd, args, { cwd } = {}) {
9
9
  return new Promise((resolve) => {
10
10
  execFile(cmd, args, { cwd, windowsHide: true }, (err, stdout, stderr) => {
11
11
  resolve({ code: err ? (err.code ?? 1) : 0, stdout: String(stdout), stderr: String(stderr) });
@@ -1,8 +1,17 @@
1
- // .gitignore 보장 (.sh ensure_gitignore + normalize/check 등가) template_integrator.sh 3996~4111.
1
+ // .gitignore 보장 마법사 자신이 만드는 충돌 백업 부산물(*.bak, *.template.yaml)만 대상으로 한다.
2
+ // 마법사가 설치하는 것과 무관한 개인 개발환경 설정(IDE 등)은 마법사 책임 범위 밖 — issue #7.
3
+ // 주의(의도된 트레이드오프): 이 변경 이전 버전으로 설치해 /.idea·/.claude/settings.local.json이 이미
4
+ // 배너 블록에 남아있는 레포에서 removeAutoAddedEntriesFromGitignore()를 실행하면, 배너는 제거되지만
5
+ // REQUIRED_ENTRIES가 더 이상 옛 항목과 일치하지 않아 그 두 줄은 지워지지 않고 배너 표시 없이 남는다.
6
+ // 이슈 #7이 "이미 설치된 레포의 .gitignore는 소급 처리하지 않고 사용자 판단에 맡긴다"고 명시하므로
7
+ // 별도 마이그레이션 로직을 추가하지 않는다 — 남은 항목은 무해하며 필요하면 사용자가 직접 지운다.
2
8
  import { join } from "node:path";
3
9
  import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
4
10
 
5
- const REQUIRED_ENTRIES = ["/.idea", "/.claude/settings.local.json"];
11
+ // issue #7: 마법사가 설치하는 것과 무관한 개인 개발환경 항목(/.idea 등)은 마법사 책임 밖이므로 제거.
12
+ // 대신 마법사 자신의 충돌 처리(workflows.js backup/template 결정, coderabbit.js 덮어쓰기 백업)가
13
+ // 실제로 만들어내는 부산물만 gitignore 대상으로 삼는다.
14
+ const REQUIRED_ENTRIES = ["*.bak", "*.template.yaml"];
6
15
 
7
16
  // .sh normalize_gitignore_entry: 주석 제거·트림·앞 / 제거·앞 ./ 제거·뒤 / 제거. 빈값이면 원본.
8
17
  export function normalizeGitignoreEntry(entry) {
@@ -26,11 +35,9 @@ function entryExists(target, content) {
26
35
  }
27
36
 
28
37
  const NEW_FILE_CONTENT =
29
- "# IDE Settings\n" +
30
- "/.idea\n" +
31
- "\n" +
32
- "# Claude AI Settings\n" +
33
- "/.claude/settings.local.json\n";
38
+ "# project-auto-wizard: 충돌 처리 시 생성되는 백업 파일 (안전하게 무시해도 됩니다)\n" +
39
+ "*.bak\n" +
40
+ "*.template.yaml\n";
34
41
 
35
42
  // 반환: {created, added:[...]}
36
43
  export function ensureGitignore(targetRoot = ".") {
@@ -2,7 +2,7 @@
2
2
  import { join } from "node:path";
3
3
  import { existsSync, readFileSync, appendFileSync, writeFileSync } from "node:fs";
4
4
 
5
- const MARKER = "<!-- AUTO-VERSION-SECTION";
5
+ export const MARKER = "<!-- AUTO-VERSION-SECTION";
6
6
  // ## (최신 버전|최신버전|Version|버전) : vX.Y.Z (대소문자 무시)
7
7
  const VERSION_LINE_RE = /##\s*(최신\s*버전|최신버전|Version|버전)\s*:\s*v[0-9]+\.[0-9]+\.[0-9]+/i;
8
8
 
@@ -58,7 +58,7 @@ function classify(srcDir, workflowsDir, envOpts, srcText) {
58
58
  // envValues?:Map<key,value>, envUseDefaults?:boolean } ← env 계획(promptEnvPlan) 결과 주입점
59
59
  // hooks: { decisions?: Map<filename, 'skip'|'backup'|'template'> } — 기존 파일(changed) 충돌 결정.
60
60
  // 미지정 파일은 'skip'(현행 force 동작 100% 유지). 대화형 수집은 copyWorkflowsInteractive 참조.
61
- // 반환: {copied, skipped, templateAdded, optionalCopied}
61
+ // 반환: {copied, skipped, templateAdded, optionalCopied, backupAdded}
62
62
  export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}) {
63
63
  const { types = [], paths = new Map(), includeNexus = false, includeSecretBackup = false, repoName = "", resolvers = {}, envValues = new Map(), envUseDefaults = true } = context;
64
64
  const decisions = hooks.decisions instanceof Map ? hooks.decisions : new Map();
@@ -66,7 +66,7 @@ export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}
66
66
  const projectTypesDir = join(payloadRoot, PAYLOAD.workflowsDir);
67
67
  if (!exists(projectTypesDir)) throw new Error("패키지 구조 오류 — payload/workflows 폴더를 찾지 못했습니다.");
68
68
 
69
- const counters = { copied: 0, skipped: 0, templateAdded: 0, optionalCopied: 0 };
69
+ const counters = { copied: 0, skipped: 0, templateAdded: 0, optionalCopied: 0, backupAdded: 0 };
70
70
  const deployValues = new Map(); // Map<type, Map<key,value>> — deploy 블록용 ask 값
71
71
  counters.deployValues = deployValues;
72
72
  const srcText = makeSrcText(context.branches || null);
@@ -124,6 +124,7 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcTe
124
124
  renameSync(dst, dst + ".bak");
125
125
  writeText(dst, srcText(src));
126
126
  counters.copied++;
127
+ counters.backupAdded++;
127
128
  return;
128
129
  }
129
130
  if (decision === "template") {
@@ -213,7 +214,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
213
214
  counters.skipped++;
214
215
  continue;
215
216
  }
216
- if (existsSync(dst)) renameSync(dst, dst + ".bak");
217
+ if (existsSync(dst)) { renameSync(dst, dst + ".bak"); counters.backupAdded++; }
217
218
  writeText(dst, body);
218
219
  counters.optionalCopied++;
219
220
  counters.copied++;
package/src/index.js CHANGED
@@ -4,6 +4,7 @@
4
4
  import { join, dirname } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { readFileSync, existsSync } from "node:fs";
7
+ import { createInterface } from "node:readline/promises";
7
8
  import { parseArgs, parsePathsCsv, CliError } from "./cli/args.js";
8
9
  import { HELP_TEXT } from "./cli/help.js";
9
10
  import { createContext } from "./context.js";
@@ -12,7 +13,7 @@ import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName, makeRe
12
13
  import { parseExisting } from "./core/version-yml.js";
13
14
  import { runBreakingCheck } from "./core/breaking-check.js";
14
15
  import { resolveProjectPaths } from "./core/paths-resolve.js";
15
- import { resolveBranchConfig, detectRemoteBranches, ensureDevelopBranch } from "./core/branches.js";
16
+ import { resolveBranchConfig, detectRemoteBranches, ensureDevelopBranch, defaultExec } from "./core/branches.js";
16
17
  import { printBannerCompact } from "./ui/banner.js";
17
18
  import { printSummary } from "./ui/summary.js";
18
19
  import { runFull } from "./commands/full.js";
@@ -25,6 +26,7 @@ import { runInteractive } from "./commands/interactive.js";
25
26
  import { runStatus, printStatus } from "./commands/status.js";
26
27
  import { runDoctor, printDoctorReport } from "./commands/doctor.js";
27
28
  import { planDryRun, printDryRun } from "./commands/dry-run.js";
29
+ import { planPurge, executePurge, printPurgePlan, printPurgeResult } from "./commands/purge.js";
28
30
 
29
31
  // 패키지 버전 읽기 (-v/--version 출력용). src/../package.json.
30
32
  function readPkgVersion() {
@@ -45,10 +47,24 @@ function utcNow(date = new Date()) {
45
47
  return { now: `${d} ${t}`, today: d };
46
48
  }
47
49
 
48
- // run(argv, opts) exitCode. opts: { cwd, payloadRoot?, clock? }
50
+ // purge TTY 확인 실제 stdin에서 입력을 받는다 (테스트는 promptRepoName 주입으로 대체).
51
+ async function defaultPromptRepoName(repoName) {
52
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
53
+ try {
54
+ return await rl.question(`purge를 실행하려면 정확히 이 레포명을 입력하세요: ${repoName}\n> `);
55
+ } finally {
56
+ rl.close();
57
+ }
58
+ }
59
+
60
+ // run(argv, opts) → exitCode. opts: { cwd, payloadRoot?, clock?, exec?, promptRepoName? }
49
61
  // payloadRoot: 테스트 픽스처 주입점 (기본: 패키지 동봉 payload/)
50
62
  // clock: {now, today} 주입 (기본 현재 UTC).
51
- export async function run(argv, { cwd = process.cwd(), payloadRoot, clock } = {}) {
63
+ // exec/promptRepoName: purge 모드 안전장치 게이트용 주입점 (기본 실제 구현, 테스트는 mock 주입).
64
+ export async function run(argv, {
65
+ cwd = process.cwd(), payloadRoot, clock,
66
+ exec = defaultExec, promptRepoName = defaultPromptRepoName,
67
+ } = {}) {
52
68
  let opts;
53
69
  try {
54
70
  opts = parseArgs(argv);
@@ -91,6 +107,80 @@ export async function run(argv, { cwd = process.cwd(), payloadRoot, clock } = {}
91
107
  console.error("version.yml·README·.gitignore는 보존됩니다 (사용자 데이터).");
92
108
  return 0;
93
109
  }
110
+ // purge 모드 — 마법사가 만든 모든 산출물을 지워 설치 이전 상태로 완전히 되돌린다.
111
+ // 개발·테스트 전용 숨김 모드 — --help/대화형 메뉴에 노출하지 않는다 (issue #6).
112
+ if (opts.mode === "purge") {
113
+ if (!existsSync(join(cwd, ".git"))) {
114
+ console.error("git 레포가 아닙니다(.git 없음) — purge는 git 레포 안에서만 실행할 수 있습니다.");
115
+ return 1;
116
+ }
117
+ const keepFlags = {
118
+ versionYml: opts.keepVersionYml, readme: opts.keepReadme, changelog: opts.keepChangelog,
119
+ workflows: opts.keepWorkflows, scripts: opts.keepScripts, coderabbit: opts.keepCoderabbit,
120
+ };
121
+ // version.yml은 여기서 미리 읽어둔다 — (a) executePurge()가 version.yml 자체를 지울 수 있어
122
+ // 실행 이후에는 읽을 수 없고, (b) 아래 dry-run 예고 문구도 trunk-based 여부(develop === main)를
123
+ // 알아야 실제 실행 시 삭제를 건너뛸지 미리 알릴 수 있기 때문에, 두 지점보다 앞서 읽어야 한다.
124
+ const vyPath = join(cwd, "version.yml");
125
+ const existing = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")) : null;
126
+ if (opts.dryRun) {
127
+ printPurgePlan(planPurge(payload, cwd, keepFlags), { dryRun: true });
128
+ // M4 (Fable 검토): develop 브랜치 삭제는 plan에 포함되지 않으므로(§6 — git 상태는 실행 시점에만
129
+ // 판단 가능) 별도로 예고하지 않으면 dry-run 미리보기가 유일한 파괴적 동작을 사용자에게 숨기게 된다.
130
+ if (opts.deleteDevelopBranch) {
131
+ const developBranch = existing?.branches?.develop || "develop";
132
+ if (existing?.branches?.main && developBranch === existing.branches.main) {
133
+ console.log("(--delete-develop-branch 지정됨: trunk-based 구성(develop === main)이라 실제 실행 시에도 삭제를 건너뜁니다)");
134
+ } else {
135
+ console.log("(--delete-develop-branch 지정됨: 실제 실행 시 로컬 develop 브랜치도 삭제를 시도합니다)");
136
+ }
137
+ }
138
+ return 0;
139
+ }
140
+ if (!opts.yes) {
141
+ console.error("--yes 없이는 purge를 실행할 수 없습니다 (--force로 대체할 수 없습니다).");
142
+ return 1;
143
+ }
144
+ const st = await exec("git", ["status", "--porcelain"], { cwd });
145
+ if (st.code !== 0) {
146
+ console.error("git 상태를 확인할 수 없습니다 — 안전을 위해 purge를 중단합니다.");
147
+ return 1;
148
+ }
149
+ if (!opts.allowDirty && st.stdout.trim() !== "") {
150
+ console.error("작업트리에 커밋되지 않은 변경 사항이 있습니다 — purge 후 복구할 수 없습니다. 커밋하거나 --allow-dirty를 사용하세요.");
151
+ return 1;
152
+ }
153
+ if (!opts.force) {
154
+ if (!process.stdout.isTTY) {
155
+ console.error("비대화형 환경에서는 --force 옵션이 필요합니다.");
156
+ return 1;
157
+ }
158
+ const repoName = detectRepoName(cwd);
159
+ const typed = await promptRepoName(repoName);
160
+ if (typed !== repoName) {
161
+ console.error("입력한 레포명이 일치하지 않습니다 — purge를 중단합니다.");
162
+ return 1;
163
+ }
164
+ }
165
+ const plan = planPurge(payload, cwd, keepFlags);
166
+ printPurgePlan(plan, { dryRun: false });
167
+ const result = executePurge(payload, cwd, keepFlags);
168
+ printPurgeResult(result);
169
+ if (opts.deleteDevelopBranch) {
170
+ const developBranch = existing?.branches?.develop || "develop";
171
+ if (existing?.branches?.main && developBranch === existing.branches.main) {
172
+ console.error("trunk-based 구성(develop === main)입니다 — 릴리스 브랜치 삭제는 건너뜁니다.");
173
+ } else {
174
+ const br = await exec("git", ["branch", "-d", developBranch], { cwd });
175
+ if (br.code !== 0) {
176
+ console.error(`⚠️ 로컬 '${developBranch}' 브랜치 삭제 실패 (${(br.stderr || "").trim() || "이유 확인 불가"}) — 수동으로 확인하세요.`);
177
+ } else {
178
+ console.error(`로컬 '${developBranch}' 브랜치를 삭제했습니다.`);
179
+ }
180
+ }
181
+ }
182
+ return 0;
183
+ }
94
184
 
95
185
  // uninstall 모드 — revert보다 넓게 README·gitignore·version.yml까지 선택적으로 제거.
96
186
  if (opts.mode === "uninstall") {
@@ -221,6 +311,7 @@ export async function run(argv, { cwd = process.cwd(), payloadRoot, clock } = {}
221
311
  mode: opts.mode, types, version, branches,
222
312
  includeCodeRabbit: context.includeCodeRabbit === true,
223
313
  counters: { workflows: result?.workflows?.copied ?? 0 },
314
+ gitignoreUpdated: result?.gitignoreUpdated === true,
224
315
  }, cwd);
225
316
  return 0;
226
317
  }
package/src/ui/summary.js CHANGED
@@ -8,7 +8,7 @@ import { listYamlFiles } from "../core/fsutil.js";
8
8
  const SEPARATOR = "────────────────────────────────────────";
9
9
 
10
10
  export function printSummary(ctx, targetRoot = ".") {
11
- const { mode, types = [], version = "", counters = {}, branches = null, includeCodeRabbit = false } = ctx || {};
11
+ const { mode, types = [], version = "", counters = {}, branches = null, includeCodeRabbit = false, gitignoreUpdated = false } = ctx || {};
12
12
  const err = (s = "") => process.stderr.write(`${s}\n`);
13
13
  // 색상은 TTY일 때만 (.sh YELLOW/CYAN/NC 등가)
14
14
  const isTty = !!process.stderr.isTTY;
@@ -32,12 +32,11 @@ export function printSummary(ctx, targetRoot = ".") {
32
32
  err(" ✅ 버전 관리 시스템 (version.yml)");
33
33
  err(" ✅ README.md 자동 버전 업데이트");
34
34
  err(" ✅ GitHub Actions 워크플로우 (AI 릴리스 자동화 포함)");
35
- err(" ✅ .gitignore 필수 항목");
35
+ if (gitignoreUpdated) err(" ✅ .gitignore 백업 파일 제외 항목 (*.bak/*.template.yaml)");
36
36
  break;
37
37
  case "version":
38
38
  err(" ✅ 버전 관리 시스템 (version.yml)");
39
39
  err(" ✅ README.md 자동 버전 업데이트");
40
- err(" ✅ .gitignore 필수 항목");
41
40
  break;
42
41
  case "workflows":
43
42
  err(" ✅ GitHub Actions 워크플로우 (AI 릴리스 자동화 포함)");