project-auto-wizard 0.1.5 → 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.
@@ -13,10 +13,16 @@
13
13
  # exits cleanly. Version + changelog were already confirmed in the PR.
14
14
  #
15
15
  # - trunk-based: every direct push to {{MAIN_BRANCH}} is a release.
16
- # This job bumps the version (patch +1 and sync), summarizes the commits
17
- # since the last tag with the ai-summary engine chain (user API ->
18
- # GitHub Models -> rule-based fallback), updates CHANGELOG.json /
19
- # CHANGELOG.md, and commits everything back with [skip ci].
16
+ # This job bumps the version and syncs it, summarizes the commits since
17
+ # the last tag with the ai-summary engine chain (user API -> GitHub
18
+ # Models -> rule-based fallback, diff-stat-enriched prompt), updates
19
+ # CHANGELOG.json / CHANGELOG.md, and commits everything back with
20
+ # [skip ci]. Same as pr-flow's AUTO-CHANGELOG-CONTROL:
21
+ # - If version.yml metadata.template.options.semver_auto is true
22
+ # (default): classifies commits (feat -> minor, `!` marker -> major,
23
+ # else patch; AI-assisted patch->minor upgrade for unclassified
24
+ # commits) and bumps accordingly.
25
+ # - Otherwise: always patch+1 (legacy behavior).
20
26
  #
21
27
  # Common tail (both modes): recovery-aware idempotency guard (tag and
22
28
  # release checked separately so a half-published release can be finished
@@ -102,6 +108,19 @@ jobs:
102
108
  echo "not a release-confirm push in pr-flow mode — skipping (clean exit)"
103
109
  fi
104
110
 
111
+ - name: Read semver_auto option from version.yml
112
+ id: semver_options
113
+ if: >-
114
+ steps.gate.outputs.proceed == 'true' &&
115
+ steps.mode.outputs.mode == 'trunk-based' &&
116
+ github.event_name == 'push'
117
+ run: |
118
+ # scoped to the metadata: -> template: -> options: chain so an
119
+ # unrelated "semver_auto:" key can never hijack the value
120
+ SEMVER_AUTO=$(python3 -c 'import re; t=open("version.yml",encoding="utf-8").read(); m=re.search(r"metadata:.*?template:.*?options:.*?semver_auto:\s*\"?(true|false)", t, re.S); print(m.group(1) if m else "false")' 2>/dev/null || echo "false")
121
+ echo "semver_auto=$SEMVER_AUTO" >> $GITHUB_OUTPUT
122
+ echo "semver_auto option: $SEMVER_AUTO"
123
+
105
124
  - name: Trunk-based version bump + changelog
106
125
  if: >-
107
126
  steps.gate.outputs.proceed == 'true' &&
@@ -123,28 +142,48 @@ jobs:
123
142
  exit 0
124
143
  fi
125
144
 
126
- NEW_VERSION=$(python3 .github/scripts/version_manager.py increment | tail -n 1)
127
- if [ -z "$NEW_VERSION" ]; then
128
- echo "version bump failed"
129
- exit 1
130
- fi
131
- python3 .github/scripts/version_manager.py sync
132
- echo "release version: $NEW_VERSION"
133
-
134
- # Commits since the last tag (fall back to full history on first release)
145
+ # Commits (and diff-stat) since the last tag — this is "what's in
146
+ # this release" for trunk-based pushes. Fall back to the full
147
+ # history / a diff against the empty tree on the first-ever
148
+ # release (no previous tag to diff against).
135
149
  LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
136
150
  if [ -n "$LAST_TAG" ]; then
137
151
  echo "collecting commits since $LAST_TAG"
138
152
  git log --pretty=%s "${LAST_TAG}..HEAD" > commits.txt
153
+ DIFF_BASE="$LAST_TAG"
139
154
  else
140
155
  echo "no previous tag — collecting the full history"
141
156
  git log --pretty=%s > commits.txt
157
+ DIFF_BASE="4b825dc642cb6eb9a060e54bf8d69288fbee4904" # git empty tree hash
142
158
  fi
159
+ git diff --stat "${DIFF_BASE}..HEAD" > _full_diff_stat.txt
160
+ if [ "$(wc -l < _full_diff_stat.txt)" -gt 49 ]; then
161
+ { head -49 _full_diff_stat.txt; tail -1 _full_diff_stat.txt; } > diff_stat.txt
162
+ else
163
+ cp _full_diff_stat.txt diff_stat.txt
164
+ fi
165
+ rm -f _full_diff_stat.txt
166
+
167
+ if [ "${{ steps.semver_options.outputs.semver_auto }}" = "true" ]; then
168
+ BUMP=$(python3 .github/scripts/changelog_manager.py classify-bump --commits-file commits.txt | tail -n 1)
169
+ else
170
+ BUMP="patch"
171
+ fi
172
+ echo "bump level: $BUMP"
173
+
174
+ NEW_VERSION=$(python3 .github/scripts/version_manager.py increment --bump "$BUMP" | tail -n 1)
175
+ if [ -z "$NEW_VERSION" ]; then
176
+ echo "version bump failed"
177
+ exit 1
178
+ fi
179
+ python3 .github/scripts/version_manager.py sync
180
+ echo "release version: $NEW_VERSION ($BUMP)"
143
181
 
144
182
  python3 .github/scripts/changelog_manager.py ai-summary \
145
183
  --commits-file commits.txt \
146
184
  --version "$NEW_VERSION" \
147
- --output summary.md
185
+ --output summary.md \
186
+ --diff-stat-file diff_stat.txt
148
187
 
149
188
  # Same input channel as AUTO-CHANGELOG-CONTROL:
150
189
  # update-from-summary reads ./pr_body.md
@@ -165,7 +204,7 @@ jobs:
165
204
  python3 .github/scripts/changelog_manager.py update-from-summary
166
205
  python3 .github/scripts/changelog_manager.py generate-md
167
206
 
168
- rm -f pr_body.md summary.md commits.txt
207
+ rm -f pr_body.md summary.md commits.txt diff_stat.txt
169
208
 
170
209
  git add -A
171
210
  if git diff --staged --quiet; then
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 = [];