project-auto-wizard 0.1.6 → 0.1.7

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/src/cli/args.js CHANGED
@@ -11,12 +11,14 @@ export function parseArgs(argv) {
11
11
  includeNexus: null, // null=미설정
12
12
  includeSecretBackup: null,
13
13
  includeCodeRabbit: null, // --coderabbit / --no-coderabbit (기본 false — DESIGN-SPEC §4 질문②)
14
+ includeSemverAuto: null, // --semver-auto / --no-semver-auto (기본 true — 미지정 시 다운스트림에서 해석)
14
15
  pathsCsv: "", // "flutter=app,react=client" 원문 (정규화는 resolve 단계)
15
16
  mainBranch: "", // 릴리스 브랜치 (--main-branch). 빈값=감지된 default branch
16
17
  developBranch: "", // 개발 브랜치 (--develop-branch). 빈값=develop
17
18
  force: false,
18
19
  help: false,
19
20
  showVersion: false, // -v/--version → 패키지 버전 출력 (npm 관례)
21
+ dryRun: false, // --dry-run: 실제 변경 없이 미리보기만 (full/version/workflows/revert 공통)
20
22
  };
21
23
  const args = [...argv];
22
24
  while (args.length > 0) {
@@ -49,12 +51,15 @@ export function parseArgs(argv) {
49
51
  break;
50
52
  }
51
53
  case "--force": result.force = true; break;
54
+ case "--dry-run": result.dryRun = true; break;
52
55
  case "--nexus": result.includeNexus = true; break;
53
56
  case "--no-nexus": result.includeNexus = false; break;
54
57
  case "--secret-backup": result.includeSecretBackup = true; break;
55
58
  case "--no-secret-backup": result.includeSecretBackup = false; break;
56
59
  case "--coderabbit": result.includeCodeRabbit = true; break;
57
60
  case "--no-coderabbit": result.includeCodeRabbit = false; break;
61
+ case "--semver-auto": result.includeSemverAuto = true; break;
62
+ case "--no-semver-auto": result.includeSemverAuto = false; break;
58
63
  case "--paths": result.pathsCsv = args.shift() ?? ""; break;
59
64
  case "--main-branch": result.mainBranch = args.shift() ?? ""; break;
60
65
  case "--develop-branch": result.developBranch = args.shift() ?? ""; break;
package/src/cli/help.js CHANGED
@@ -5,8 +5,9 @@ export const HELP_TEXT = `project-auto-wizard — One command DevOps: GitHub-nat
5
5
  npx project-auto-wizard [옵션]
6
6
 
7
7
  옵션:
8
- -m, --mode MODE 통합 모드 (full | version | workflows | revert)
8
+ -m, --mode MODE 통합 모드 (full | version | workflows | revert | status | doctor)
9
9
  기본: interactive (대화형). revert = 설치물 제거(되돌리기)
10
+ status = 설치 상태·드리프트 확인(읽기 전용). doctor = 환경 진단(읽기 전용)
10
11
  -t, --type CSV 프로젝트 타입 csv (예: spring,react,python)
11
12
  지원: spring flutter next react react-native
12
13
  react-native-expo node python basic
@@ -17,11 +18,16 @@ export const HELP_TEXT = `project-auto-wizard — One command DevOps: GitHub-nat
17
18
  --nexus / --no-nexus Nexus 라이브러리 publish 워크플로우 포함/제외
18
19
  --secret-backup / --no-secret-backup Secret 백업 워크플로우 포함/제외
19
20
  --coderabbit / --no-coderabbit CodeRabbit PR 요약을 릴리스 노트 1순위로 사용 (기본: 사용 안 함)
21
+ --semver-auto / --no-semver-auto 커밋 타입 기반 자동 major/minor/patch 승격 (기본: 사용함)
20
22
  --force 모든 확인 생략, 비대화형 기본값 사용
23
+ --dry-run 실제 파일 변경 없이 무엇이 바뀔지만 미리 보여줌 (full/version/workflows/revert 전체 지원)
21
24
  -v, --version project-auto-wizard 버전 출력
22
25
  -h, --help 이 도움말 표시
23
26
 
24
27
  예시:
25
28
  npx project-auto-wizard --mode full --force --type spring,react
26
29
  npx project-auto-wizard --mode workflows --type flutter --paths "flutter=app"
30
+ npx project-auto-wizard --mode status
31
+ npx project-auto-wizard --mode doctor
32
+ npx project-auto-wizard --mode full --force --type node --dry-run
27
33
  `;
@@ -0,0 +1,73 @@
1
+ // doctor 명령 — 로컬 환경 진단(읽기 전용, 규칙 기반). gh CLI에 위임해 원격 상태를 점검한다.
2
+ // AI 진단은 포함하지 않는다(스펙 §4에서 검토 후 기각 — 복잡도 대비 이득 낮음).
3
+ import { spawnSync } from "node:child_process";
4
+ import { existsSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ const defaultExec = (cmd, args) => spawnSync(cmd, args, { encoding: "utf8" });
8
+
9
+ export function runDoctor(cwd = process.cwd(), { exec = defaultExec } = {}) {
10
+ const results = [];
11
+ const add = (name, status, detail) => { results.push({ name, status, detail }); return results; };
12
+
13
+ const installed = existsSync(join(cwd, "version.yml"));
14
+ add("설치 여부", installed ? "OK" : "WARN",
15
+ installed ? "version.yml 발견" : "이 디렉터리에 project-auto-wizard가 설치되어 있지 않습니다 (version.yml 없음)");
16
+
17
+ const ghVersion = exec("gh", ["--version"]);
18
+ if (ghVersion.error || ghVersion.status !== 0) {
19
+ add("gh CLI", "WARN", "gh CLI를 찾을 수 없습니다 — 원격 점검을 건너뜁니다 (https://cli.github.com/ 설치 권장)");
20
+ return results;
21
+ }
22
+ add("gh CLI", "OK", (ghVersion.stdout || "").split("\n")[0] || "설치됨");
23
+
24
+ const auth = exec("gh", ["auth", "status"]);
25
+ const authOk = !auth.error && auth.status === 0;
26
+ add("gh 인증", authOk ? "OK" : "FAIL", authOk ? "인증됨" : "`gh auth login`이 필요합니다");
27
+ if (!authOk) return results;
28
+
29
+ const remote = exec("git", ["-C", cwd, "remote", "get-url", "origin"]);
30
+ const url = remote.status === 0 ? (remote.stdout || "").trim() : "";
31
+ const match = url.match(/github\.com[:/]([^/]+)\/([^/.]+?)(\.git)?$/);
32
+ if (!match) {
33
+ add("GitHub 원격", "WARN", "origin 리모트에서 GitHub owner/repo를 확인하지 못했습니다");
34
+ return results;
35
+ }
36
+ const [, owner, repo] = match;
37
+
38
+ const perm = exec("gh", ["api", `repos/${owner}/${repo}/actions/permissions/workflow`, "--jq", ".default_workflow_permissions"]);
39
+ const permValue = (perm.stdout || "").trim();
40
+ add("Workflow permissions", perm.status === 0 && permValue === "write" ? "OK" : "WARN",
41
+ perm.status === 0
42
+ ? `현재값: ${permValue || "확인불가"} (Settings → Actions → General → Workflow permissions: Read and write 권장)`
43
+ : "조회 실패 — repo 관리자 권한이 필요할 수 있습니다");
44
+
45
+ const secrets = exec("gh", ["secret", "list", "--repo", `${owner}/${repo}`]);
46
+ const hasPat = secrets.status === 0 && (secrets.stdout || "").split("\n").some((l) => l.startsWith("WORKFLOW_PAT"));
47
+ add("WORKFLOW_PAT secret", hasPat ? "OK" : "WARN",
48
+ hasPat
49
+ ? "등록됨"
50
+ : "미등록 — automerge 후 후속 워크플로우(tag/Release)가 트리거되지 않을 수 있습니다. Settings → Secrets → Actions에 등록 (scopes: repo, workflow)");
51
+
52
+ const mergeSettings = exec("gh", ["api", `repos/${owner}/${repo}`, "--jq", ".allow_merge_commit"]);
53
+ const automergeOk = mergeSettings.status === 0 && mergeSettings.stdout.trim() === "true";
54
+ add("automerge 호환성(merge commit 허용)", automergeOk ? "OK" : "WARN",
55
+ mergeSettings.status === 0
56
+ ? (automergeOk
57
+ ? "머지 커밋 허용됨"
58
+ : "이 레포는 merge commit이 비활성화되어 있습니다 — automerge/RELEASE-PUBLISH가 머지 커밋 subject를 감지하는 방식과 충돌할 수 있습니다. Settings → General → Pull Requests → Allow merge commits 활성화 권장")
59
+ : "조회 실패 — repo 관리자 권한이 필요할 수 있습니다");
60
+
61
+ add("GitHub Models 활성화", "INFO",
62
+ "자동 확인 불가 — Settings → Models에서 조직 정책으로 차단되지 않았는지 직접 확인하세요 (차단 시 규칙 기반 fallback으로 자동 전환됩니다)");
63
+
64
+ return results;
65
+ }
66
+
67
+ export function printDoctorReport(results) {
68
+ const icon = { OK: "✅", WARN: "⚠️ ", FAIL: "❌", INFO: "ℹ️ " };
69
+ const lines = ["", "project-auto-wizard doctor — 환경 진단 결과", ""];
70
+ for (const r of results) lines.push(`${icon[r.status] || " "} [${r.status}] ${r.name} — ${r.detail}`);
71
+ lines.push("");
72
+ console.log(lines.join("\n"));
73
+ }
@@ -0,0 +1,77 @@
1
+ // --dry-run 미리보기 — 실제 파일을 쓰지 않고 무엇이 바뀔지 계산한다.
2
+ // full/version/workflows/revert 4개 모드 전체 지원.
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { PATHS } from "../core/paths.js";
6
+ import { planWorkflows } from "../core/copy/workflows.js";
7
+ import { planRevert } from "./revert.js";
8
+ import { buildVersionYml } from "../core/version-yml.js";
9
+ import { readVersionYmlTemplate } from "../core/assets.js";
10
+ import { markerForType } from "../core/detect.js";
11
+
12
+ function versionYmlPreview(context, payloadRoot, targetRoot) {
13
+ const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
14
+ now, today, templateVersion = "unknown",
15
+ includeNexus = false, includeSecretBackup = false, includeCodeRabbit = false,
16
+ includeSemverAuto } = context;
17
+ const pathMarkers = new Map();
18
+ for (const [t] of paths) pathMarkers.set(t, markerForType(t));
19
+ const wouldBe = buildVersionYml({
20
+ templateText: readVersionYmlTemplate(payloadRoot),
21
+ version, types, paths, pathMarkers, branch, branches: context.branches, versionCode, now, today,
22
+ templateOptions: {
23
+ templateVersion, includeNexus, includeSecretBackup,
24
+ includeCodeRabbit: includeCodeRabbit === true,
25
+ includeSemverAuto: includeSemverAuto !== false,
26
+ optionsDate: today,
27
+ },
28
+ });
29
+ const vyPath = join(targetRoot, PATHS.versionFile);
30
+ const existing = existsSync(vyPath) ? readFileSync(vyPath, "utf8") : null;
31
+ return { existed: existing !== null, changed: existing !== wouldBe };
32
+ }
33
+
34
+ // mode: "full" | "version" | "workflows" | "revert". 읽기 전용 — 아무 파일도 쓰지 않는다.
35
+ export function planDryRun(mode, context, payloadRoot, targetRoot = ".") {
36
+ if (mode === "revert") return { mode, revert: planRevert(payloadRoot, targetRoot) };
37
+
38
+ const result = { mode };
39
+ if (mode === "full" || mode === "workflows") {
40
+ result.workflows = planWorkflows(context, payloadRoot, targetRoot);
41
+ }
42
+ if (mode === "full" || mode === "version") {
43
+ result.versionYml = versionYmlPreview(context, payloadRoot, targetRoot);
44
+ }
45
+ return result;
46
+ }
47
+
48
+ export function printDryRun(plan) {
49
+ const lines = ["", `project-auto-wizard --dry-run (mode: ${plan.mode}) — 미리보기, 실제 파일은 바뀌지 않았습니다`, ""];
50
+ if (plan.mode === "revert") {
51
+ const r = plan.revert;
52
+ lines.push(`제거될 워크플로우 (${r.workflows.length}개):`);
53
+ for (const f of r.workflows) lines.push(` - ${f}`);
54
+ lines.push(`제거될 스크립트 (${r.scripts.length}개):`);
55
+ for (const f of r.scripts) lines.push(` - ${f}`);
56
+ if (r.coderabbit) lines.push("제거될 파일: .coderabbit.yaml");
57
+ } else {
58
+ if (plan.workflows) {
59
+ const w = plan.workflows;
60
+ lines.push(`신규 파일 (${w.newFiles.length}개):`);
61
+ for (const f of w.newFiles) lines.push(` + ${f.filename} [${f.type}]`);
62
+ lines.push(`변경될 파일 (${w.changed.length}개, 기존 설치가 사용자 수정본이면 충돌):`);
63
+ for (const f of w.changed) lines.push(` ~ ${f.filename} [${f.type}]`);
64
+ lines.push(`동일한 파일 (${w.unchanged.length}개, 변경 없음)`);
65
+ }
66
+ if (plan.versionYml) {
67
+ lines.push(plan.versionYml.existed
68
+ ? (plan.versionYml.changed ? "version.yml: 갱신될 예정" : "version.yml: 변경 없음")
69
+ : "version.yml: 새로 생성될 예정");
70
+ // dry-run은 프롬프트 없이 읽기 전용으로 동작하므로 @wizard ask 배포 설정 값을 계산할 수 없다.
71
+ // spring 등 deploy 블록이 있는 타입은 실제 설치 결과와 미리보기가 다를 수 있음을 안내한다.
72
+ lines.push(" (참고: 배포 설정 질문이 있는 타입(spring 등)은 deploy: 블록이 미리보기에 반영되지 않아 실제 설치와 다르게 보일 수 있습니다.)");
73
+ }
74
+ }
75
+ lines.push("");
76
+ console.log(lines.join("\n"));
77
+ }
@@ -19,7 +19,8 @@ import { ensureGitignore } from "../core/copy/gitignore.js";
19
19
  export function runFull(context, payloadRoot, targetRoot = ".", hooks = {}) {
20
20
  const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
21
21
  force = true, now, today, templateVersion = "unknown",
22
- includeNexus = false, includeSecretBackup = false, includeCodeRabbit = false } = context;
22
+ includeNexus = false, includeSecretBackup = false, includeCodeRabbit = false,
23
+ includeSemverAuto } = context;
23
24
 
24
25
  // project_paths 마커 계산 (.sh existing_marker_in_dir 등가 — 대표 마커명)
25
26
  const pathMarkers = new Map();
@@ -36,7 +37,7 @@ export function runFull(context, payloadRoot, targetRoot = ".", hooks = {}) {
36
37
  templateText: readVersionYmlTemplate(payloadRoot),
37
38
  version, types, paths, pathMarkers, branch, branches: context.branches, versionCode, now, today,
38
39
  deployValues,
39
- templateOptions: { templateVersion, includeNexus, includeSecretBackup, includeCodeRabbit: includeCodeRabbit === true, optionsDate: today },
40
+ templateOptions: { templateVersion, includeNexus, includeSecretBackup, includeCodeRabbit: includeCodeRabbit === true, includeSemverAuto: includeSemverAuto !== false, optionsDate: today },
40
41
  }));
41
42
 
42
43
  // 3. README 버전 섹션
@@ -70,6 +70,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
70
70
  let includeNexus = existing?.options?.nexus ?? false;
71
71
  let includeSecretBackup = existing?.options?.secretBackup ?? false;
72
72
  let includeCodeRabbit = existing?.options?.coderabbit ?? null;
73
+ let includeSemverAuto = existing?.options?.semverAuto ?? null;
73
74
  const showOptional = mode === "full" || mode === "workflows";
74
75
  const realTty = process.stdout.isTTY === true;
75
76
 
@@ -92,8 +93,19 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
92
93
  const y = await io.askYesNo("CodeRabbit을 사용합니까? (PR AI 리뷰·요약을 릴리스 노트 1순위로 사용)", false);
93
94
  includeCodeRabbit = y === true;
94
95
  }
96
+
97
+ // 신규 질문 — 자동 semver 승격 (기본 ON). 저장값 있으면 재질문 생략.
98
+ // version.yml을 쓰지 않는 workflows 모드에서는 답변이 무의미하므로 full에서만 질문한다.
99
+ if (mode === "full" && includeSemverAuto === null) {
100
+ const y2 = await io.askYesNo("자동 버전 승격을 사용하시겠습니까? (커밋 타입에 따라 major/minor/patch 자동 결정)", true);
101
+ includeSemverAuto = y2 === true;
102
+ }
95
103
  }
96
104
  includeCodeRabbit = includeCodeRabbit === true;
105
+ // 질문이 실제로 나온 경우(위 full 모드 질문) 답변을 그대로 존중.
106
+ // 질문이 안 나온 경우(version/workflows 모드) — 기존 설치는 안전하게 false로 폴백,
107
+ // 완전 신규 설치만 true(기존 설계) 유지 — CLI 경로(index.js)와 동일한 안전 정책.
108
+ includeSemverAuto = includeSemverAuto === null ? (existing ? false : true) : includeSemverAuto !== false;
97
109
 
98
110
  // 확인/수정 루프 — ESC는 '머무르기' (.sh L1877~1881: 명시적 '아니오'만 종료)
99
111
  let paths = new Map();
@@ -189,6 +201,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
189
201
  const ctx = createContext({
190
202
  mode, force: true, types, version, versionCode, branch, branches, paths,
191
203
  includeNexus, includeSecretBackup, includeCodeRabbit,
204
+ includeSemverAuto,
192
205
  repoName, templateVersion, resolvers, envValues, envUseDefaults, now, today,
193
206
  });
194
207
  ctx.templateVersion = templateVersion;
@@ -18,43 +18,44 @@ function payloadWorkflowNames(payloadRoot) {
18
18
  return names;
19
19
  }
20
20
 
21
- // 반환: { workflows: [...제거된 파일명], scripts: [...], coderabbit: bool }
22
- export function runRevert(context, payloadRoot, targetRoot = ".") {
21
+ // payload에 존재하는 파일명과 정확히 일치하는 것만 제거 대상으로 계획한다.
22
+ // 아무것도 지우지 않는 순수 함수 --dry-run과 status류 기능에서 재사용.
23
+ export function planRevert(payloadRoot, targetRoot = ".") {
23
24
  const removedWf = [];
24
25
  const removedScripts = [];
25
-
26
- // 1. 워크플로우 — payload 파일명 일치분 + 마법사가 만든 .template.yaml/.bak 파생본
27
26
  const wfDir = join(targetRoot, PATHS.workflowsDir);
28
27
  const names = payloadWorkflowNames(payloadRoot);
29
28
  if (existsSync(wfDir)) {
30
29
  for (const name of names) {
31
30
  const p = join(wfDir, name);
32
- if (existsSync(p)) { remove(p); removedWf.push(name); }
31
+ if (existsSync(p)) removedWf.push(name);
33
32
  const templateName = (name.endsWith(".yaml") ? name.slice(0, -".yaml".length) : name) + ".template.yaml";
34
- const tp = join(wfDir, templateName);
35
- if (existsSync(tp)) { remove(tp); removedWf.push(templateName); }
36
- const bp = p + ".bak";
37
- if (existsSync(bp)) { remove(bp); removedWf.push(name + ".bak"); }
33
+ if (existsSync(join(wfDir, templateName))) removedWf.push(templateName);
34
+ if (existsSync(p + ".bak")) removedWf.push(name + ".bak");
38
35
  }
39
36
  }
40
-
41
- // 2. 스크립트 — payload가 설치한 2종만
42
37
  for (const s of ["version_manager.py", "changelog_manager.py"]) {
43
- const p = join(targetRoot, PATHS.scriptsDir, s);
44
- if (existsSync(p)) { remove(p); removedScripts.push(s); }
38
+ if (existsSync(join(targetRoot, PATHS.scriptsDir, s))) removedScripts.push(s);
45
39
  }
46
-
47
- // 3. .coderabbit.yaml — payload 원본과 바이트 일치할 때만 제거 (사용자 자체 파일 보호).
48
- // 설치 시 백업(.bak)이 있으면 복원한다.
49
40
  let coderabbit = false;
50
41
  const cr = join(targetRoot, ".coderabbit.yaml");
51
42
  const crSrc = join(payloadRoot, "coderabbit.yaml");
52
- if (existsSync(cr) && existsSync(crSrc)
53
- && readFileSync(cr, "utf8") === readFileSync(crSrc, "utf8")) {
54
- remove(cr);
43
+ if (existsSync(cr) && existsSync(crSrc) && readFileSync(cr, "utf8") === readFileSync(crSrc, "utf8")) {
55
44
  coderabbit = true;
56
- if (existsSync(cr + ".bak")) renameSync(cr + ".bak", cr);
57
45
  }
58
-
59
46
  return { workflows: removedWf, scripts: removedScripts, coderabbit };
60
47
  }
48
+
49
+ // 반환: { workflows: [...제거된 파일명], scripts: [...], coderabbit: bool } — planRevert와 동일한 형태.
50
+ export function runRevert(context, payloadRoot, targetRoot = ".") {
51
+ const plan = planRevert(payloadRoot, targetRoot);
52
+ const wfDir = join(targetRoot, PATHS.workflowsDir);
53
+ for (const name of plan.workflows) remove(join(wfDir, name));
54
+ for (const name of plan.scripts) remove(join(targetRoot, PATHS.scriptsDir, name));
55
+ if (plan.coderabbit) {
56
+ const cr = join(targetRoot, ".coderabbit.yaml");
57
+ remove(cr);
58
+ if (existsSync(cr + ".bak")) renameSync(cr + ".bak", cr);
59
+ }
60
+ return plan;
61
+ }
@@ -0,0 +1,64 @@
1
+ // status 명령 — 읽기 전용 설치 상태 확인. 네트워크 접근 없음(로컬 파일 비교만).
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { parseExisting } from "../core/version-yml.js";
5
+ import { planWorkflows } from "../core/copy/workflows.js";
6
+ import { makeResolvers, detectRepoName, detectDefaultBranch } from "../core/detect-fs.js";
7
+ import { PATHS } from "../core/paths.js";
8
+
9
+ // payloadRoot: 패키지 payload/ 루트. targetRoot: 상태를 확인할 대상 레포.
10
+ export function runStatus(payloadRoot, targetRoot = ".") {
11
+ const vyPath = join(targetRoot, PATHS.versionFile);
12
+ if (!existsSync(vyPath)) return { installed: false };
13
+
14
+ const existing = parseExisting(readFileSync(vyPath, "utf8"));
15
+ const repoName = detectRepoName(targetRoot);
16
+ const resolvers = makeResolvers(targetRoot, repoName, existing.paths);
17
+ // version.yml에 branches 블록이 없으면(신기능 이전 설치·수기 편집) makeSrcText(null)이
18
+ // {{MAIN_BRANCH}}/{{DEVELOP_BRANCH}}를 치환하지 못해 모든 워크플로우가 드리프트로 오탐된다 —
19
+ // 비교용 기본값으로 폴백(실제 저장값은 아니지만 드리프트 비교 목적에는 충분).
20
+ const branchesForCompare = existing.branches || { main: detectDefaultBranch(targetRoot) || "main", develop: "develop", mode: "pr-flow" };
21
+ const context = {
22
+ types: existing.types, paths: existing.paths,
23
+ includeNexus: existing.options.nexus === true,
24
+ includeSecretBackup: existing.options.secretBackup === true,
25
+ repoName, resolvers, branches: branchesForCompare,
26
+ };
27
+ const plan = planWorkflows(context, payloadRoot, targetRoot);
28
+
29
+ return {
30
+ installed: true,
31
+ version: existing.version,
32
+ templateVersion: existing.templateVersion,
33
+ types: existing.types,
34
+ branches: existing.branches,
35
+ options: existing.options,
36
+ modifiedFiles: plan.changed.map((f) => f.filename),
37
+ };
38
+ }
39
+
40
+ export function printStatus(status) {
41
+ const lines = ["", "project-auto-wizard status — 설치 상태", ""];
42
+ if (!status.installed) {
43
+ lines.push("이 디렉터리에 project-auto-wizard가 설치되어 있지 않습니다 (version.yml 없음).", "");
44
+ console.log(lines.join("\n"));
45
+ return;
46
+ }
47
+ lines.push(`버전 : ${status.version}`);
48
+ lines.push(`템플릿 버전 : ${status.templateVersion}`);
49
+ lines.push(`프로젝트 타입 : ${status.types.join(", ") || "(없음)"}`);
50
+ if (status.branches) {
51
+ lines.push(`브랜치 모드 : ${status.branches.mode} (${status.branches.main} / ${status.branches.develop})`);
52
+ }
53
+ const boolLabel = (v) => (v === null ? "미설정(기본 false)" : v);
54
+ const semverAutoLabel = status.options.semverAuto === null ? "미설정(기본 false)" : status.options.semverAuto;
55
+ lines.push(`옵션 : nexus=${boolLabel(status.options.nexus)} secret_backup=${boolLabel(status.options.secretBackup)} coderabbit=${boolLabel(status.options.coderabbit)} semver_auto=${semverAutoLabel}`);
56
+ if (status.modifiedFiles.length) {
57
+ lines.push("", `사용자가 수정한 워크플로우 파일 (${status.modifiedFiles.length}개):`);
58
+ for (const f of status.modifiedFiles) lines.push(` - ${f}`);
59
+ } else {
60
+ lines.push("", "모든 워크플로우 파일이 설치 시점 기본값과 동일합니다 (수정 없음).");
61
+ }
62
+ lines.push("");
63
+ console.log(lines.join("\n"));
64
+ }
@@ -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: {},
@@ -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
@@ -20,6 +20,9 @@ import { runVersion } from "./commands/version.js";
20
20
  import { runWorkflows } from "./commands/workflows.js";
21
21
  import { runRevert } from "./commands/revert.js";
22
22
  import { runInteractive } from "./commands/interactive.js";
23
+ import { runStatus, printStatus } from "./commands/status.js";
24
+ import { runDoctor, printDoctorReport } from "./commands/doctor.js";
25
+ import { planDryRun, printDryRun } from "./commands/dry-run.js";
23
26
 
24
27
  // 패키지 버전 읽기 (-v/--version 출력용). src/../package.json.
25
28
  function readPkgVersion() {
@@ -58,6 +61,11 @@ export async function run(argv, { cwd = process.cwd(), payloadRoot, clock } = {}
58
61
 
59
62
  // 대화형 모드 — 인자 없이 실행 or --mode interactive
60
63
  if (opts.mode === "interactive") {
64
+ // --dry-run은 대화형 모드에서 조용히 무시되면 안 됨(실제 설치가 진행돼버림) — 명시 에러로 차단.
65
+ if (opts.dryRun) {
66
+ console.error("--dry-run은 --mode <full|version|workflows|revert>와 함께 사용하세요 (대화형 모드에서는 지원하지 않습니다).");
67
+ return 1;
68
+ }
61
69
  if (!process.stdout.isTTY) {
62
70
  console.error("대화형 입력이 불가능한 환경입니다. --mode <full|version|workflows|revert> 와 --force 를 지정하세요.");
63
71
  return 1;
@@ -67,17 +75,33 @@ export async function run(argv, { cwd = process.cwd(), payloadRoot, clock } = {}
67
75
 
68
76
  // revert 모드 — payload 유래 파일 제거 (감지·질문 불필요, --force 게이트만)
69
77
  if (opts.mode === "revert") {
70
- if (!opts.force && !process.stdout.isTTY) {
78
+ // --dry-run은 파일을 쓰지 않으므로 --force 게이트를 우회한다 (status/doctor와 동일한 안전성).
79
+ if (!opts.force && !opts.dryRun && !process.stdout.isTTY) {
71
80
  console.error("비대화형 환경에서는 --force 옵션이 필요합니다.");
72
81
  return 1;
73
82
  }
83
+ if (opts.dryRun) {
84
+ printDryRun(planDryRun("revert", {}, payload, cwd));
85
+ return 0;
86
+ }
74
87
  const r = runRevert({}, payload, cwd);
75
88
  console.error(`제거됨 — 워크플로우 ${r.workflows.length}개, 스크립트 ${r.scripts.length}개${r.coderabbit ? ", .coderabbit.yaml" : ""}`);
76
89
  console.error("version.yml·README·.gitignore는 보존됩니다 (사용자 데이터).");
77
90
  return 0;
78
91
  }
92
+ // status 모드 — 읽기 전용, TTY/--force 무관하게 항상 동작
93
+ if (opts.mode === "status") {
94
+ printStatus(runStatus(payload, cwd));
95
+ return 0;
96
+ }
97
+ // doctor 모드 — 읽기 전용, TTY/--force 무관하게 항상 동작
98
+ if (opts.mode === "doctor") {
99
+ printDoctorReport(runDoctor(cwd));
100
+ return 0;
101
+ }
79
102
  // 명시 모드인데 --force 없으면 (비대화형 CLI는 --force 필요)
80
- if (!opts.force && !process.stdout.isTTY) {
103
+ // --dry-run은 파일을 쓰지 않으므로 --force 게이트를 우회한다 (status/doctor와 동일한 안전성).
104
+ if (!opts.force && !opts.dryRun && !process.stdout.isTTY) {
81
105
  console.error("비대화형 환경에서는 --force 옵션이 필요합니다.");
82
106
  return 1;
83
107
  }
@@ -107,7 +131,7 @@ export async function run(argv, { cwd = process.cwd(), payloadRoot, clock } = {}
107
131
  });
108
132
  // pr-flow에서 develop이 원격에 없으면 자동 생성+push (--force 비대화형 — 질문 없음).
109
133
  // 원격 목록을 못 읽는 환경(git 없음·origin 없음)은 remoteBranches=[]지만 push 실패를 조용히 보고.
110
- if (branches.mode === "pr-flow") {
134
+ if (branches.mode === "pr-flow" && !opts.dryRun) {
111
135
  const remoteBranches = await detectRemoteBranches(cwd);
112
136
  if (remoteBranches.length && !remoteBranches.includes(branches.develop)) {
113
137
  await ensureDevelopBranch({
@@ -127,6 +151,10 @@ export async function run(argv, { cwd = process.cwd(), payloadRoot, clock } = {}
127
151
  includeNexus: opts.includeNexus ?? existing?.options?.nexus ?? false,
128
152
  includeSecretBackup: opts.includeSecretBackup ?? existing?.options?.secretBackup ?? false,
129
153
  includeCodeRabbit: opts.includeCodeRabbit ?? existing?.options?.coderabbit ?? false,
154
+ // 기존 version.yml이 있는데 semver_auto 키가 아예 없었던 경우(신규 기능 추가 이전 설치·
155
+ // workflows-only 재실행) 조용히 true로 켜지면 애매한 커밋 하나로 major가 승격될 위험이 있다 —
156
+ // 기존 설치는 false로 안전하게 폴백, 완전 신규 설치만 true(기존 설계) 유지.
157
+ includeSemverAuto: opts.includeSemverAuto ?? existing?.options?.semverAuto ?? (existing ? false : true),
130
158
  repoName,
131
159
  // 실 resolver 4종 (.sh resolve_token 등가)
132
160
  resolvers: makeResolvers(cwd, repoName, paths),
@@ -142,6 +170,11 @@ export async function run(argv, { cwd = process.cwd(), payloadRoot, clock } = {}
142
170
  const proceed = await runBreakingCheck({ cwd, payloadRoot: payload, templateVersion: context.templateVersion });
143
171
  if (!proceed) return 0;
144
172
 
173
+ if (opts.dryRun) {
174
+ printDryRun(planDryRun(opts.mode, context, payload, cwd));
175
+ return 0;
176
+ }
177
+
145
178
  let result = null;
146
179
  switch (opts.mode) {
147
180
  case "full": result = runFull(context, payload, cwd); break;