project-auto-wizard 0.1.34 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +15 -4
  2. package/package.json +1 -1
  3. package/payload/scripts/__pycache__/changelog_manager.cpython-314.pyc +0 -0
  4. package/payload/scripts/__pycache__/issue_helper.cpython-314.pyc +0 -0
  5. package/payload/scripts/__pycache__/version_manager.cpython-314.pyc +0 -0
  6. package/payload/scripts/issue_helper.py +334 -0
  7. package/payload/scripts/truncate_release_notes.py +89 -0
  8. package/payload/version.yml.template +1 -0
  9. package/payload/workflows/common/PROJECT-COMMON-ISSUE-HELPER.yaml +47 -0
  10. package/payload/workflows/common/secret-backup/PROJECT-COMMON-SECRET-FILE-UPLOAD.yaml +4 -4
  11. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +1 -1
  12. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +1 -1
  13. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +1 -1
  14. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +6 -6
  15. package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +1 -1
  16. package/payload/workflows/spring/{PROJECT-SPRING-GITHUB-PACKAGES-PUBLISH.yml → nexus/PROJECT-SPRING-GITHUB-PACKAGES-PUBLISH.yml} +8 -2
  17. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +8 -6
  18. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +2 -2
  19. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +8 -8
  20. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +2 -2
  21. package/src/cli/args.js +9 -0
  22. package/src/cli/help.js +2 -1
  23. package/src/commands/dry-run.js +7 -16
  24. package/src/commands/full.js +88 -14
  25. package/src/commands/interactive.js +73 -12
  26. package/src/commands/purge.js +2 -0
  27. package/src/commands/status.js +21 -1
  28. package/src/commands/uninstall.js +6 -1
  29. package/src/core/baseline.js +76 -0
  30. package/src/core/copy/simple.js +2 -2
  31. package/src/core/copy/workflows.js +178 -90
  32. package/src/core/deploy-style.js +90 -0
  33. package/src/core/detect-fs.js +36 -7
  34. package/src/core/detect.js +68 -3
  35. package/src/core/install-log.js +182 -0
  36. package/src/core/options-ask.js +5 -2
  37. package/src/core/paths-resolve.js +4 -8
  38. package/src/core/removal-plan.js +6 -2
  39. package/src/core/verify.js +86 -0
  40. package/src/core/version-yml.js +31 -4
  41. package/src/core/wizard-env.js +6 -3
  42. package/src/index.js +15 -2
  43. package/src/ui/env-plan.js +63 -33
  44. package/src/ui/prompts.js +41 -2
  45. package/src/ui/status-cards.js +7 -3
  46. package/src/ui/summary.js +56 -6
@@ -3,7 +3,7 @@
3
3
  import { existsSync, readFileSync, readdirSync } from "node:fs";
4
4
  import { join, basename } from "node:path";
5
5
  import { execFileSync } from "node:child_process";
6
- import { detectTypesFromMarkers, detectVersionFromFiles, detectBuildNumberFromFiles } from "./detect.js";
6
+ import { detectTypesFromMarkers, detectVersionFromFiles, detectBuildNumberFromFiles, detectJdkFromFiles, resolveMarkers } from "./detect.js";
7
7
  import { parseExisting } from "./version-yml.js";
8
8
 
9
9
  const hasFile = (root) => (rel) => existsSync(join(root, rel));
@@ -28,11 +28,25 @@ export function detectTypes(root) {
28
28
  }
29
29
 
30
30
  // 버전 감지 — .sh detect_version 순서. jq는 package.json 파싱에 쓰인 적이 없어 게이트를 제거했다(이슈 #22 L4).
31
- export function detectVersion(root, { warn = (m) => console.error(m) } = {}) {
31
+ // hint: 폴백 경고에 붙일 해결 방법 안내 (대화형/CLI가 다르다 이슈 #80).
32
+ export function detectVersion(root, { warn = (m) => console.error(m), hint } = {}) {
32
33
  const read = readFile(root);
33
34
  const readJson = (rel) => { const c = read(rel); try { return c ? JSON.parse(c) : null; } catch { return null; } };
34
35
  const gitTag = gitOut(root, ["describe", "--tags", "--abbrev=0"]);
35
- return detectVersionFromFiles({ read, readJson, gitTag, warn });
36
+ return detectVersionFromFiles({ read, readJson, gitTag, warn, hint });
37
+ }
38
+
39
+ // 타입별 실제 마커 파일 (이슈 #77) — 감지 로그·설치 로그가 같은 근거 파일을 인용하도록.
40
+ export function detectMarkers(root, types = []) {
41
+ return resolveMarkers(types, hasFile(root));
42
+ }
43
+
44
+ // 빌드 JDK 감지 (이슈 #82) — 배포 워크플로우 JAVA_VERSION 기본값에 실측값을 쓰기 위해.
45
+ // base: 모노레포에서 spring 프로젝트 루트 (레포 루트 기준 상대경로).
46
+ export function detectJdk(root, base = ".") {
47
+ const rel = base && base !== "." ? (r) => `${base}/${r}` : (r) => r;
48
+ const read = readFile(root);
49
+ return detectJdkFromFiles({ read: (r) => read(rel(r)) });
36
50
  }
37
51
 
38
52
  // 빌드 번호 감지 — 신규 통합 시 pubspec.yaml/build.gradle/app.json에서 실제 빌드 번호를 읽는다 (이슈 #41).
@@ -65,23 +79,34 @@ export function detectRepoName(root) {
65
79
  // Spring application*.yml 탐색 (.sh resolve_spring_app_yml_dir/path L2767~2780 등가)
66
80
  // find {base} -path "*/src/main/resources/application*.yml" | head -1 의 fs 재귀 구현.
67
81
  // 반환: root 기준 상대경로 (예: "server/src/main/resources/application.yml") 또는 "".
82
+ //
83
+ // .yaml도 인정한다 (이슈 #81). Spring은 .yml/.yaml을 모두 공식 지원하는데 종전 정규식이
84
+ // .yml만 봐서, application.yaml을 쓰는 프로젝트는 이 값이 빈 문자열이 되고 그 결과
85
+ // __APPLICATION_YML_DIR__ 가 치환되지 않은 채 설치됐다.
86
+ //
87
+ // 같은 디렉토리에서는 프로파일 없는 기본 파일(application.yml/.yaml)을 우선한다. 파일명 정렬만
88
+ // 쓰면 'application-dev.yml'이 'application.yml'보다 앞서(`-` < `.`) 프로파일 파일이 잡힌다.
68
89
  export function findSpringAppYml(root, base = ".") {
69
90
  const startRel = base === "." ? "" : base;
70
91
  const PRUNE = new Set(["node_modules", ".git", "build", ".gradle", "target", ".idea"]);
92
+ const APP_YML = /^application(-[^/]*)?\.ya?ml$/;
71
93
  let hit = "";
94
+ let hitIsBase = false;
72
95
  const walk = (rel, depth) => {
73
- if (hit || depth > 8) return; // head -1 등가 매치에서 중단
96
+ if (hitIsBase || depth > 8) return; // 기본 파일을 찾았으면 필요가 없다
74
97
  let entries;
75
98
  try { entries = readdirSync(join(root, rel), { withFileTypes: true }); } catch { return; }
76
99
  // 정렬로 순회 순서 결정화 (find 순서 플랫폼 편차 제거)
77
100
  for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
78
- if (hit) return;
101
+ if (hitIsBase) return;
79
102
  const childRel = rel ? `${rel}/${e.name}` : e.name;
80
103
  if (e.isDirectory()) {
81
104
  if (PRUNE.has(e.name)) continue;
82
105
  walk(childRel, depth + 1);
83
- } else if (/^application.*\.yml$/.test(e.name) && childRel.includes("src/main/resources/")) {
84
- hit = childRel;
106
+ } else if (APP_YML.test(e.name) && childRel.includes("src/main/resources/")) {
107
+ const isBase = /^application\.ya?ml$/.test(e.name);
108
+ // 첫 매치는 일단 채택하고, 이후 기본 파일이 나오면 그걸로 승격한다.
109
+ if (!hit || isBase) { hit = childRel; hitIsBase = isBase; }
85
110
  }
86
111
  }
87
112
  };
@@ -95,6 +120,10 @@ export function makeResolvers(root, repoName, paths) {
95
120
  const springBase = (t) => paths.get(t || "spring") || paths.get("spring") || ".";
96
121
  return {
97
122
  repo: () => repoName,
123
+ // 빌드 JDK (이슈 #82) — 배포 워크플로우 JAVA_VERSION의 기본값. 프로젝트 툴체인을 실측한다.
124
+ // ⚠️ 빈 문자열을 돌려주면 setEnvLine이 그 줄을 건너뛰어 __JAVA_VERSION__이 그대로 남는다
125
+ // (이슈 #81과 같은 실패 형태). 감지 실패 시 반드시 종전 기본값 21로 폴백한다.
126
+ jdk: (t) => detectJdk(root, springBase(t)) || "21",
98
127
  "spring-app-yml-dir": (t) => {
99
128
  const f = findSpringAppYml(root, springBase(t));
100
129
  return f ? f.split("/").slice(0, -1).join("/") : "";
@@ -36,7 +36,9 @@ const VERSION_RE = /^\d+\.\d+\.\d+$/;
36
36
 
37
37
  // 버전 감지 (동작명세 §3.3) — 순서대로 첫 성공. read(relpath)=>string|null 주입.
38
38
  // package.json은 이미 Node JSON.parse로 파싱을 마친 값이므로 jq 설치 여부와 무관하게 항상 사용한다(이슈 #22 L4).
39
- export function detectVersionFromFiles({ read, readJson, gitTag, warn }) {
39
+ // hint: 폴백 경고 뒤에 붙일 "그럼 어떻게 고치나" 한 줄. 대화형과 CLI가 서로 다른 방법을
40
+ // 안내해야 하므로(이슈 #80) 호출부가 정한다. 미지정 시 CLI 문구를 쓴다.
41
+ export function detectVersionFromFiles({ read, readJson, gitTag, warn, hint }) {
40
42
  const pkg = readJson?.("package.json");
41
43
  if (pkg?.version && VERSION_RE.test(pkg.version)) return pkg.version;
42
44
  const grab = (content, re) => {
@@ -47,14 +49,29 @@ export function detectVersionFromFiles({ read, readJson, gitTag, warn }) {
47
49
  return null;
48
50
  };
49
51
  let v;
50
- if ((v = grab(read("build.gradle"), /version\s*=\s*["']?(\d+\.\d+\.\d+)/))) return v;
52
+ const gradleRe = /version\s*=\s*["']?(\d+\.\d+\.\d+)/;
53
+ // Groovy DSL과 Kotlin DSL은 같은 문법(`version = "x.y.z"`)이라 정규식을 공유한다.
54
+ // .kts를 빼먹으면 Kotlin DSL Spring 프로젝트가 전부 0.0.1로 초기화된다 (이슈 #77).
55
+ if ((v = grab(read("build.gradle"), gradleRe))) return v;
56
+ if ((v = grab(read("build.gradle.kts"), gradleRe))) return v;
57
+ if ((v = versionFromPom(read("pom.xml")))) return v;
51
58
  if ((v = grab(read("pubspec.yaml"), /^version:\s*(\d+\.\d+\.\d+)/))) return v;
52
59
  if ((v = grab(read("pyproject.toml"), /version\s*=\s*["']?(\d+\.\d+\.\d+)/))) return v;
53
60
  if (gitTag) { const t = String(gitTag).replace(/^v/, ""); if (VERSION_RE.test(t)) return t; }
54
- warn?.("⚠️ 버전을 자동 감지하지 못해 기본값 0.0.1을 사용합니다 — --project-version으로 직접 지정하거나 version.yml을 확인하세요.");
61
+ const tail = hint ?? "--project-version으로 직접 지정하거나 version.yml을 확인하세요.";
62
+ warn?.(`⚠️ 버전을 자동 감지하지 못해 기본값 0.0.1을 사용합니다 — ${tail}`);
55
63
  return "0.0.1";
56
64
  }
57
65
 
66
+ // Maven pom.xml의 프로젝트 버전 (이슈 #77). <parent> 블록 안의 버전은 스프링 부트 BOM 버전이라
67
+ // 프로젝트 버전이 아니다 — 그 구간을 지운 뒤 첫 <version>을 읽는다.
68
+ export function versionFromPom(content) {
69
+ if (!content) return null;
70
+ const body = String(content).replace(/<parent>[\s\S]*?<\/parent>/g, "");
71
+ const m = body.match(/<version>\s*(\d+\.\d+\.\d+)[^<]*<\/version>/);
72
+ return m ? m[1] : null;
73
+ }
74
+
58
75
  export function markerForType(type) {
59
76
  return { flutter: "pubspec.yaml", "react-native-expo": "app.json", python: "pyproject.toml", spring: "build.gradle" }[type] || "package.json";
60
77
  }
@@ -63,6 +80,54 @@ export function extraMarkers(type) {
63
80
  return { python: ["setup.py", "requirements.txt"], spring: ["build.gradle.kts", "pom.xml"] }[type] || [];
64
81
  }
65
82
 
83
+ // 그 타입을 감지하는 데 실제로 쓰인 파일 (이슈 #77). markerForType은 타입당 대표 파일 하나를
84
+ // 고정 반환하므로, build.gradle.kts만 있는 레포에서도 "build.gradle 발견"이라고 출력돼
85
+ // 같은 설치 로그 안에서 경로 확정 화면과 파일명이 어긋났다. has()로 실재하는 것을 고른다.
86
+ // 실재하는 후보가 없으면(감지 전 화면 등) 대표 파일을 쓴다.
87
+ export function resolveMarker(type, has) {
88
+ const candidates = [markerForType(type), ...extraMarkers(type)];
89
+ return candidates.find(has) ?? candidates[0];
90
+ }
91
+
92
+ // 빌드 JDK 감지 (이슈 #82) — 배포 워크플로우의 JAVA_VERSION 기본값이 21로 고정돼 있어
93
+ // toolchain이 다른 프로젝트(예: 25)는 그대로 Enter를 누르면 러너 JDK와 어긋나 빌드가 깨진다.
94
+ // 빌드 번호를 프로젝트 파일에서 읽는 detectBuildNumberFromFiles와 같은 방식으로 실측한다.
95
+ // 반환: "21" 같은 메이저 버전 문자열, 못 찾으면 null.
96
+ export function detectJdkFromFiles({ read }) {
97
+ const pick = (content, patterns) => {
98
+ if (!content) return null;
99
+ for (const re of patterns) {
100
+ const m = String(content).match(re);
101
+ // JavaVersion.VERSION_1_8 처럼 1_8 표기는 8로 정규화한다.
102
+ if (m) return m[1] === "1_8" ? "8" : m[1].replace("1_", "");
103
+ }
104
+ return null;
105
+ };
106
+ const gradlePatterns = [
107
+ /JavaLanguageVersion\.of\((\d+)\)/, // toolchain (Gradle 권장 표기)
108
+ /JavaVersion\.VERSION_(\d+(?:_\d+)?)/, // sourceCompatibility = JavaVersion.VERSION_21
109
+ /(?:source|target)Compatibility\s*=?\s*["'](\d+)["']/, // sourceCompatibility = '17'
110
+ ];
111
+ let v;
112
+ if ((v = pick(read("build.gradle.kts"), gradlePatterns))) return v;
113
+ if ((v = pick(read("build.gradle"), gradlePatterns))) return v;
114
+ if ((v = pick(read("pom.xml"), [
115
+ /<java\.version>\s*(\d+(?:\.\d+)?)\s*<\/java\.version>/,
116
+ /<maven\.compiler\.(?:source|release)>\s*(\d+(?:\.\d+)?)\s*<\//,
117
+ ]))) return v.replace(/^1\./, "");
118
+ return null;
119
+ }
120
+
121
+ // 타입별 실제 마커 파일 맵 — 감지 로그·설치 로그가 같은 근거를 쓰도록 한 곳에서 만든다.
122
+ export function resolveMarkers(types = [], has) {
123
+ const out = new Map();
124
+ for (const t of types) {
125
+ if (t === "basic") continue;
126
+ out.set(t, resolveMarker(t, has));
127
+ }
128
+ return out;
129
+ }
130
+
66
131
  // 빌드 번호 감지 (이슈 #41) — 신규 통합 시 pubspec.yaml/build.gradle/app.json에 이미 기록된
67
132
  // 빌드 번호를 읽어 version_code가 항상 1로 초기화되는 걸 막는다. types 배열에서 먼저 매칭되는
68
133
  // 첫 타입만 사용한다(다른 감지 로직의 types[0]=primary 관례와 동일). read(rel)=>string|null,
@@ -0,0 +1,182 @@
1
+ // 설치 로그 (이슈 #79) — 실행마다 "무엇을 어떤 값으로 설치했는지"를 레포에 한 건 남긴다.
2
+ //
3
+ // 왜 필요한가: version.yml에는 버전·타입·경로·브랜치·옵션만 남는다. 감지 근거, 질문별 답변,
4
+ // 특히 환경설정 답변(도메인·포트·볼륨 경로)은 워크플로우 YAML 안으로 흩어질 뿐 한 곳에
5
+ // 기록되지 않아, 배포가 실패했을 때 "설치할 때 뭘로 답했더라"를 역추적할 방법이 없었다.
6
+ // 터미널 스크롤백은 에이전트가 볼 수 없고 다른 클론에도 남지 않는다.
7
+ //
8
+ // 위치는 .github/.wizard/ 아래 — 마법사가 이미 baseline.json을 두는 자기 메타데이터 폴더다.
9
+ // 새 최상위 폴더를 만들 이유가 없고, 삭제 모드에서도 경계가 이 폴더 하나로 끝난다.
10
+ import { join } from "node:path";
11
+ import { writeText } from "./fsutil.js";
12
+
13
+ export const LOG_DIR = ".github/.wizard/logs";
14
+
15
+ // 값에 비밀이 들어갈 수 있는 키 — 현재 질문 항목에는 없지만(도메인·경로·포트·인증 '방식'),
16
+ // 앞으로 추가될 때 그냥 평문으로 커밋되지 않도록 처음부터 걸어둔다. 이 파일은 커밋 대상이다.
17
+ const SECRET_KEY_RE = /(PASSWORD|SECRET|TOKEN|KEY|CREDENTIAL)/i;
18
+ const MASK = "***";
19
+
20
+ export function maskValue(key, value) {
21
+ // SSH_AUTH_METHOD처럼 "방식"만 담는 키는 비밀이 아니다 — 이름에 KEY가 들어가도 마스킹하지 않는다.
22
+ if (key === "SSH_AUTH_METHOD") return value;
23
+ return SECRET_KEY_RE.test(key) ? MASK : value;
24
+ }
25
+
26
+ // "2026-08-12 18:15:30" → "20260812-181530". 파일명이 곧 정렬 키가 되도록.
27
+ export function stampFrom(now = "") {
28
+ const m = String(now).match(/(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/);
29
+ if (!m) return "unknown";
30
+ return `${m[1]}${m[2]}${m[3]}-${m[4]}${m[5]}${m[6]}`;
31
+ }
32
+
33
+ export function logFilename(now, action = "install") {
34
+ return `${stampFrom(now)}-${action}.md`;
35
+ }
36
+
37
+ const yamlStr = (v) => `"${String(v ?? "").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
38
+ const yamlList = (arr) => `[${(arr || []).map(yamlStr).join(", ")}]`;
39
+
40
+ // 마크다운 렌더 — 상단은 기계가 읽는 front matter, 아래는 사람이 읽는 본문.
41
+ // 에이전트가 파싱만 해도 핵심을 얻고, 사람이 열면 그대로 읽힌다.
42
+ export function renderInstallLog(d = {}) {
43
+ const {
44
+ action = "install", at = "", templateVersion = "",
45
+ previousTemplateVersion = "", mode = "", types = [], markers = new Map(),
46
+ version = "", branch = "", branches = null, paths = new Map(),
47
+ options = {}, answers = [], result = {}, unresolved = [], secrets = new Map(),
48
+ warnings = [], cleanup = null,
49
+ } = d;
50
+
51
+ const L = [];
52
+ L.push("---");
53
+ L.push(`action: ${yamlStr(action)}`);
54
+ L.push(`at: ${yamlStr(at)}`);
55
+ L.push(`template_version: ${yamlStr(templateVersion)}`);
56
+ if (previousTemplateVersion) L.push(`previous_template_version: ${yamlStr(previousTemplateVersion)}`);
57
+ L.push(`mode: ${yamlStr(mode)}`);
58
+ L.push(`project_types: ${yamlList(types)}`);
59
+ L.push(`version: ${yamlStr(version)}`);
60
+ L.push(`default_branch: ${yamlStr(branch)}`);
61
+ if (branches) {
62
+ L.push(`branches: { main: ${yamlStr(branches.main)}, develop: ${yamlStr(branches.develop)}, mode: ${yamlStr(branches.mode)} }`);
63
+ }
64
+ L.push(`unresolved_count: ${unresolved.length}`);
65
+ L.push(`required_secrets: ${yamlList([...secrets.keys()])}`);
66
+ L.push("---");
67
+ L.push("");
68
+ L.push(`# 설치 로그 — ${at}`);
69
+ L.push("");
70
+ L.push("project-auto-wizard가 이 레포에 무엇을 설치했는지 남긴 기록입니다. 직접 편집하지 마세요.");
71
+ L.push("");
72
+
73
+ L.push("## 실행");
74
+ L.push("");
75
+ L.push("| 항목 | 값 |");
76
+ L.push("|---|---|");
77
+ L.push(`| 동작 | ${action === "install" ? "신규 설치" : action === "update" ? "업데이트" : action} |`);
78
+ L.push(`| 템플릿 버전 | ${previousTemplateVersion ? `${previousTemplateVersion} → ${templateVersion}` : templateVersion || "-"} |`);
79
+ L.push(`| 설치 모드 | ${mode || "-"} |`);
80
+ L.push("");
81
+
82
+ L.push("## 감지 결과");
83
+ L.push("");
84
+ L.push("| 항목 | 값 | 근거 |");
85
+ L.push("|---|---|---|");
86
+ for (const t of types) {
87
+ L.push(`| 타입 | ${t} | ${markers?.get?.(t) || "직접 선택"} |`);
88
+ }
89
+ L.push(`| 버전 | ${version} | ${d.versionSource || "자동 감지"} |`);
90
+ L.push(`| 브랜치 | ${branch} | git |`);
91
+ for (const [t, p] of paths) L.push(`| 경로 (${t}) | ${p} | |`);
92
+ L.push("");
93
+ if (warnings.length) {
94
+ L.push("감지 중 경고:");
95
+ L.push("");
96
+ for (const w of warnings) L.push(`- ${w}`);
97
+ L.push("");
98
+ }
99
+
100
+ L.push("## 선택 항목");
101
+ L.push("");
102
+ L.push("| 항목 | 값 |");
103
+ L.push("|---|---|");
104
+ L.push(`| 라이브러리 publish (Nexus·GitHub Packages) | ${options.nexus ? "포함" : "제외"} |`);
105
+ L.push(`| Secret 서버 백업 | ${options.secretBackup ? "포함" : "제외"} |`);
106
+ L.push(`| 자동 버전 승격 | ${options.semverAuto === false ? "사용 안 함" : "사용"} |`);
107
+ L.push(`| 서버 배포 방식 | ${options.deployStyle || "-"} |`);
108
+ L.push("");
109
+
110
+ L.push("## 환경설정 답변");
111
+ L.push("");
112
+ if (!answers.length) {
113
+ L.push("이 설치에서는 환경설정 질문이 없었습니다.");
114
+ } else {
115
+ L.push("`기본값` 열이 `예`면 질문에서 그대로 Enter를 누른 값입니다. 배포가 예상과 다르게 동작하면 여기부터 확인하세요.");
116
+ L.push("");
117
+ L.push("| 키 | 항목 | 값 | 기본값 | 사용처 |");
118
+ L.push("|---|---|---|---|---|");
119
+ for (const a of answers) {
120
+ L.push(`| \`${a.key}\` | ${a.label} | \`${maskValue(a.key, a.value)}\` | ${a.isDefault ? "예" : "아니오"} | ${a.scope || ""} |`);
121
+ }
122
+ }
123
+ L.push("");
124
+
125
+ L.push("## 설치 결과");
126
+ L.push("");
127
+ const sec = (title, items, fmt = (x) => `- \`${x}\``) => {
128
+ if (!items || !items.length) return;
129
+ L.push(`### ${title} (${items.length})`);
130
+ L.push("");
131
+ for (const it of items) L.push(fmt(it));
132
+ L.push("");
133
+ };
134
+ sec("새로 설치된 파일", result.copiedFiles);
135
+ sec("이전 배포 방식 정리 — 삭제", cleanup?.removed);
136
+ sec("이전 배포 방식 정리 — .bak 백업 (수정 내용 보존)", cleanup?.backedUp);
137
+ sec("건너뛴 파일", result.skippedFiles);
138
+ sec("백업 후 교체한 파일", result.backupFiles);
139
+ if (result.gitignoreUpdated) { L.push("`.gitignore`를 갱신했습니다 (충돌 백업 파일 무시 항목 추가)."); L.push(""); }
140
+
141
+ L.push("## 남은 할 일");
142
+ L.push("");
143
+ if (unresolved.length) {
144
+ L.push("### ⚠️ 값이 채워지지 않은 항목");
145
+ L.push("");
146
+ L.push("마법사가 값을 계산하지 못해 플레이스홀더가 그대로 남았습니다. **이 상태로는 해당 워크플로우가 정상 동작하지 않습니다.** 직접 채워 주세요.");
147
+ L.push("");
148
+ L.push("| 파일 | 줄 | 토큰 |");
149
+ L.push("|---|---|---|");
150
+ for (const u of unresolved) L.push(`| \`${u.filename}\` | ${u.line} | \`${u.token}\` |`);
151
+ L.push("");
152
+ }
153
+ if (secrets.size) {
154
+ L.push("### 등록해야 하는 GitHub Secret");
155
+ L.push("");
156
+ L.push("Settings > Secrets and variables > Actions 에서 등록합니다. 등록 전에는 해당 워크플로우가 실패합니다.");
157
+ L.push("");
158
+ L.push("| Secret | 사용하는 워크플로우 |");
159
+ L.push("|---|---|");
160
+ for (const [name, users] of secrets) L.push(`| \`${name}\` | ${users.map((u) => `\`${u}\``).join(", ")} |`);
161
+ L.push("");
162
+ }
163
+ if (!unresolved.length && !secrets.size) {
164
+ L.push("추가로 조치할 항목이 없습니다.");
165
+ L.push("");
166
+ }
167
+
168
+ return L.join("\n") + "\n";
169
+ }
170
+
171
+ // 로그 기록. 실패해도 설치 자체는 성공으로 끝나야 하므로 예외를 삼키고 null을 돌려준다 —
172
+ // 로그를 못 남긴 것이 설치를 되돌릴 이유는 아니다.
173
+ export function writeInstallLog(targetRoot, data = {}) {
174
+ try {
175
+ const filename = logFilename(data.at, data.action || "install");
176
+ const rel = `${LOG_DIR}/${filename}`;
177
+ writeText(join(targetRoot, rel), renderInstallLog(data));
178
+ return { path: rel };
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
@@ -82,8 +82,11 @@ export async function askAllOptionalWorkflows({
82
82
  // ② Nexus: 각 타입의 nexus/ 폴더 (현재 spring만 존재, .sh L2719~2725)
83
83
  for (const t of types) {
84
84
  nexus = await askOptionalWorkflow({
85
- dir: join(ptDir, t, "nexus"), icon: "📦", short: "Nexus 라이브러리 publish",
86
- desc: "라이브러리/모듈을 Maven 저장소(Nexus)에 배포하는 워크플로우입니다. 일반 서버 배포가 아니라 라이브러리 프로젝트에만 필요합니다.",
85
+ // GitHub Packages publish도 같은 '라이브러리 배포' 계열이라 질문이 함께 관장한다 (이슈 #80).
86
+ // 종전에는 Nexus 묻고 GitHub Packages는 무조건 설치돼, "라이브러리 배포 필요 없다"
87
+ // 답한 사용자에게 라이브러리 배포 워크플로우가 깔렸다.
88
+ dir: join(ptDir, t, "nexus"), icon: "📦", short: "라이브러리 publish (Nexus · GitHub Packages)",
89
+ desc: "라이브러리/모듈을 Maven 저장소(Nexus)나 GitHub Packages에 배포하는 워크플로우입니다. 일반 서버 배포가 아니라 라이브러리 프로젝트에만 필요합니다. 포함하면 서버 배포 워크플로우는 설치되지 않습니다.",
87
90
  current: nexus, force, tty, io, forceAsk, say,
88
91
  });
89
92
  }
@@ -9,7 +9,7 @@
9
9
  // io.log(line) → 안내 출력 (없으면 stderr)
10
10
  import { existsSync, readdirSync } from "node:fs";
11
11
  import { join } from "node:path";
12
- import { markerForType as baseMarkerForType, extraMarkers } from "./detect.js";
12
+ import { markerForType as baseMarkerForType, resolveMarker } from "./detect.js";
13
13
  import { normalizePath, CliError } from "../cli/args.js";
14
14
 
15
15
  // 취소(ESC/Ctrl+C)는 CANCEL 심볼 — ui를 import하지 않고 심볼 여부로만 판정 (core→ui 역참조 방지)
@@ -24,15 +24,11 @@ export function markerForType(type) {
24
24
  return KNOWN_MARKER_TYPES.has(type) ? baseMarkerForType(type) : "";
25
25
  }
26
26
 
27
- // 디렉토리에 실재하는 마커 파일명 반환 — 보조 마커 포함, 없으면 대표 마커 (표시용).
27
+ // 디렉토리에 실재하는 마커 파일명 반환 — resolveMarker의 fs 구동판.
28
28
  // (.sh existing_marker_in_dir L1232~1245: spring build.gradle/.kts/pom.xml, python pyproject/setup.py/requirements.txt)
29
29
  export function existingMarkerInDir(type, dir) {
30
- const primary = markerForType(type);
31
- const names = primary ? [primary, ...extraMarkers(type)] : [];
32
- for (const n of names) {
33
- if (existsSync(join(dir, n))) return n;
34
- }
35
- return primary;
30
+ if (!markerForType(type)) return ""; // .sh 등가: 미지 타입은 빈 문자열
31
+ return resolveMarker(type, (n) => existsSync(join(dir, n)));
36
32
  }
37
33
 
38
34
  // maxdepth 3 재귀 파일 탐색 — 매치 파일의 "디렉토리" 상대경로(루트는 ".")를 수집.
@@ -16,6 +16,7 @@
16
16
  import { join } from "node:path";
17
17
  import { existsSync, readFileSync, readdirSync } from "node:fs";
18
18
  import { PATHS, PAYLOAD } from "./paths.js";
19
+ import { BASELINE_DIR, BASELINE_PATH } from "./baseline.js";
19
20
 
20
21
  // payload/workflows/**/*.yaml 첫 줄에 심어둔 고정 마커 — 이 값이 바뀌면 과거 설치분과의 매칭이 끊긴다.
21
22
  export const MANAGED_WORKFLOW_MARKER = "# project-auto-wizard:managed-workflow";
@@ -62,8 +63,11 @@ export function planRemoval(payloadRoot, targetRoot = ".") {
62
63
  // (b) 관리 마커로 시작하는 것 — payload에서 이름이 바뀌거나 삭제된 파일도 인식 (issue #20 L12).
63
64
  for (const name of markedWorkflowNames(wfDir)) removedWf.add(name);
64
65
  }
65
- for (const s of ["version_manager.py", "changelog_manager.py"]) {
66
+ for (const s of ["version_manager.py", "changelog_manager.py", "truncate_release_notes.py", "issue_helper.py"]) {
66
67
  if (existsSync(join(targetRoot, PATHS.scriptsDir, s))) removedScripts.push(s);
67
68
  }
68
- return { workflows: [...removedWf], scripts: removedScripts };
69
+ // baseline은 마법사가 만든 내부 상태 파일이다 — 설치물을 지우면 함께 사라져야 한다.
70
+ // 남겨두면 다음 설치가 "예전에 깔았다가 사용자가 지운 파일"로 오인해 전부 removed로 분류한다.
71
+ const baseline = existsSync(join(targetRoot, BASELINE_PATH)) ? [BASELINE_DIR] : [];
72
+ return { workflows: [...removedWf], scripts: removedScripts, baseline };
69
73
  }
@@ -0,0 +1,86 @@
1
+ // 설치 후 검증 (이슈 #81, #80) — 설치된 워크플로우를 다시 읽어 "이대로 돌아가는가"를 본다.
2
+ //
3
+ // 왜 설치 전이 아니라 후인가: 치환은 파일 단위로 흩어져 일어나고 auto 토큰은 resolver 결과에
4
+ // 의존한다. 최종 디스크 내용을 보는 것이 실제로 배포될 것과 같은 것을 보는 유일한 방법이다.
5
+ import { join } from "node:path";
6
+ import { existsSync, readFileSync } from "node:fs";
7
+
8
+ // 치환 대상이 아닌 토큰 — 워크플로우 스크립트 안의 heredoc 구분자다. 값이 아니라 문법이므로
9
+ // 미치환 검사에서 제외한다. (예: cat <<'__SUH_FILE_CONTENT_EOF__')
10
+ const SENTINEL_RE = /^__SUH_[A-Z0-9_]*__$/;
11
+ const PLACEHOLDER_RE = /__[A-Z][A-Z0-9_]*__/g;
12
+
13
+ // 주석으로 죽어 있는 줄 — 실행되지 않으므로 검사 대상이 아니다.
14
+ // 템플릿에는 "[선택] ..." 예시 스텝이 통째로 주석 처리돼 들어 있는데, 이걸 세면
15
+ // 쓰지도 않는 Secret을 "등록하세요"라고 안내하게 된다.
16
+ const isCommented = (line) => /^\s*#/.test(line);
17
+
18
+ // 미치환 플레이스홀더 스캔 (이슈 #81).
19
+ // 종전에는 auto 토큰 계산이 실패해도(예: application.yaml을 못 찾아 경로가 빈 문자열) 그 줄을
20
+ // 건드리지 않고 넘어가, __APPLICATION_YML_DIR__ 이 그대로 남은 워크플로우가 "설치 성공"으로
21
+ // 끝났다. 배포 시점에야 그 이름의 디렉토리가 만들어지며 문제가 드러난다.
22
+ //
23
+ // 반환: [{ filename, line, token, text }] — 사람이 바로 고칠 수 있게 줄 번호까지 준다.
24
+ export function scanUnsubstituted(workflowsDir, filenames = []) {
25
+ const found = [];
26
+ for (const filename of filenames) {
27
+ const p = join(workflowsDir, filename);
28
+ if (!existsSync(p)) continue;
29
+ let content;
30
+ try { content = readFileSync(p, "utf8"); } catch { continue; }
31
+ content.split(/\r?\n/).forEach((text, i) => {
32
+ if (isCommented(text)) return;
33
+ for (const token of text.match(PLACEHOLDER_RE) || []) {
34
+ if (SENTINEL_RE.test(token)) continue;
35
+ found.push({ filename, line: i + 1, token, text: text.trim() });
36
+ }
37
+ });
38
+ }
39
+ return found;
40
+ }
41
+
42
+ // GITHUB_TOKEN은 Actions가 자동 주입하므로 사용자가 등록할 대상이 아니다.
43
+ const AUTO_SECRETS = new Set(["GITHUB_TOKEN"]);
44
+ // 없어도 워크플로우가 도는 secret — 폴백이 문서화돼 있다. 필수와 섞어 "등록해야 동작합니다"라고
45
+ // 하면 안내 자체를 못 믿게 되므로 분리한다.
46
+ // AI_API_KEY → 없으면 GitHub Models(무료) → 규칙 fallback
47
+ // WORKFLOW_PAT → 없으면 GITHUB_TOKEN
48
+ export const OPTIONAL_SECRETS = new Set(["AI_API_KEY", "WORKFLOW_PAT"]);
49
+ const SECRET_RE = /secrets\.([A-Z][A-Z0-9_]*)/g;
50
+
51
+ // 설치된 워크플로우가 요구하는 GitHub Secret 목록 (이슈 #80).
52
+ // 완료 화면이 WORKFLOW_PAT와 권한만 안내하는 바람에, 배포 워크플로우가 실제로 필요로 하는
53
+ // SERVER_HOST·SSH_KEY 같은 값이 하나도 안내되지 않았다. 설치 직후 상태로는 배포가 돌지 않는데
54
+ // 그 사실이 어디에도 드러나지 않는다.
55
+ //
56
+ // 반환: Map<secretName, string[] 그 secret을 쓰는 파일명>
57
+ export function collectRequiredSecrets(workflowsDir, filenames = []) {
58
+ const out = new Map();
59
+ for (const filename of filenames) {
60
+ const p = join(workflowsDir, filename);
61
+ if (!existsSync(p)) continue;
62
+ let content;
63
+ try { content = readFileSync(p, "utf8"); } catch { continue; }
64
+ for (const line of content.split(/\r?\n/)) {
65
+ if (isCommented(line)) continue;
66
+ for (const m of line.matchAll(SECRET_RE)) {
67
+ const name = m[1];
68
+ if (AUTO_SECRETS.has(name) || OPTIONAL_SECRETS.has(name)) continue;
69
+ if (!out.has(name)) out.set(name, []);
70
+ const users = out.get(name);
71
+ if (!users.includes(filename)) users.push(filename);
72
+ }
73
+ }
74
+ }
75
+ return new Map([...out.entries()].sort(([a], [b]) => a.localeCompare(b)));
76
+ }
77
+
78
+ // SSH 인증 방식에 따라 둘 중 하나만 필요한 secret — 사용자가 이미 답한 값으로 목록을 좁힌다.
79
+ // 안 쓸 secret까지 "등록하세요"라고 하면 안내 자체를 신뢰하지 않게 된다.
80
+ export function narrowSecretsBySshAuth(secrets, sshAuthMethod) {
81
+ if (!sshAuthMethod) return secrets;
82
+ const drop = sshAuthMethod === "key" ? "SERVER_PASSWORD" : "SSH_KEY";
83
+ const out = new Map(secrets);
84
+ out.delete(drop);
85
+ return out;
86
+ }
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_DEPLOY_STYLE } from "./deploy-style.js";
1
2
  import { escapeYamlDoubleQuoted } from "./wizard-env.js";
2
3
 
3
4
  // version.yml 파싱·생성 (.sh create_version_yml 등가, 전체 재생성 전략 D4).
@@ -38,9 +39,11 @@ export function parseExtraTopLevel(content) {
38
39
  // 구 synology·coderabbit 키 등 다른 키는 어느 분기에도 안 걸려 자연히 무시된다(파싱 에러 없음).
39
40
  // (options-ask.js가 이 함수를 import한다 — 순환 방지 위해 여기(version-yml)에 정의.)
40
41
  export function parseTemplateOptions(content) {
41
- const out = { nexus: null, secretBackup: null, semverAuto: null };
42
+ const out = { nexus: null, secretBackup: null, semverAuto: null, deployStyle: null };
42
43
  // 값 정규화: 따옴표 제거 + 트림 (.sh tr -d '"' | tr -d "'" | xargs 등가)
43
- const strip = (s) => String(s).replace(/["']/g, "").trim();
44
+ // 인라인 주석(` # ...`) 먼저 떼고 따옴표·공백을 정리한다. 문자열 값을 받는 키(deploy_style)
45
+ // 주석을 안 떼면 "simple # simple | nginx ..." 가 통째로 값이 된다.
46
+ const strip = (s) => String(s).replace(/\s+#.*$/, "").replace(/["']/g, "").trim();
44
47
  let inTemplate = false;
45
48
  let inOptions = false;
46
49
  for (const line of String(content || "").split("\n")) {
@@ -61,6 +64,8 @@ export function parseTemplateOptions(content) {
61
64
  if (v === "false") out.secretBackup = false;
62
65
  continue;
63
66
  }
67
+ m = line.match(/^\s+deploy_style:\s*(.+)/);
68
+ if (m) { const v = strip(m[1]); if (v) out.deployStyle = v; continue; }
64
69
  m = line.match(/^\s+semver_auto:\s*(.+)/);
65
70
  if (m) {
66
71
  const v = strip(m[1]);
@@ -128,7 +133,9 @@ export function parseExisting(content) {
128
133
 
129
134
  // metadata.template.branches 블록 파싱. 셋 다 있어야 유효 — 아니면 null.
130
135
  export function parseTemplateBranches(content) {
131
- const strip = (s) => String(s).replace(/["']/g, "").trim();
136
+ // 인라인 주석(` # ...`) 먼저 떼고 따옴표·공백을 정리한다. 문자열 값을 받는 키(deploy_style)
137
+ // 주석을 안 떼면 "simple # simple | nginx ..." 가 통째로 값이 된다.
138
+ const strip = (s) => String(s).replace(/\s+#.*$/, "").replace(/["']/g, "").trim();
132
139
  let inTemplate = false;
133
140
  let inBranches = false;
134
141
  const out = { main: "", develop: "", mode: "" };
@@ -168,7 +175,7 @@ export function buildVersionYml({
168
175
  const b = branches || { main: branch || "main", develop: "develop", mode: "pr-flow" };
169
176
  const {
170
177
  templateVersion = "unknown", includeNexus = false, includeSecretBackup = false,
171
- includeSemverAuto = true, optionsDate = today,
178
+ includeSemverAuto = true, deployStyle = "", optionsDate = today,
172
179
  } = templateOptions || {};
173
180
 
174
181
  // project_paths 블록 (full-line 토큰 {{PROJECT_PATHS}} — 없으면 라인 제거)
@@ -205,6 +212,7 @@ export function buildVersionYml({
205
212
  MAIN_BRANCH: b.main, DEVELOP_BRANCH: b.develop, BRANCH_MODE: b.mode,
206
213
  OPT_NEXUS: String(includeNexus), OPT_SECRET_BACKUP: String(includeSecretBackup),
207
214
  OPT_SEMVER_AUTO: String(includeSemverAuto),
215
+ OPT_DEPLOY_STYLE: String(deployStyle || ""),
208
216
  };
209
217
 
210
218
  const out = [];
@@ -225,3 +233,22 @@ export function buildVersionYml({
225
233
  if (!text.endsWith("\n")) text += "\n";
226
234
  return text.replace(/\n{3,}$/, "\n"); // 말미 과잉 빈 줄 정리
227
235
  }
236
+
237
+ // context 하나로 version.yml 최종형을 만든다 — 실제 설치(full)와 미리보기(dry-run)가
238
+ // 같은 함수를 쓰게 해서 "미리보기와 결과가 다른" 상황을 구조적으로 막는다.
239
+ // deployValues는 실제 설치에서만 존재한다(미리보기는 치환을 수행하지 않으므로 빈 Map).
240
+ export function renderVersionYml(context, templateText, { pathMarkers, deployValues = new Map(), extraTopLevel = [] }) {
241
+ const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
242
+ now, today, templateVersion = "unknown", branches = null,
243
+ includeNexus = false, includeSecretBackup = false, includeSemverAuto, deployStyle } = context;
244
+ return buildVersionYml({
245
+ templateText, version, types, paths, pathMarkers, branch, branches, versionCode, now, today,
246
+ deployValues, extraTopLevel,
247
+ templateOptions: {
248
+ templateVersion, includeNexus, includeSecretBackup,
249
+ includeSemverAuto: includeSemverAuto !== false,
250
+ deployStyle: deployStyle || DEFAULT_DEPLOY_STYLE,
251
+ optionsDate: today,
252
+ },
253
+ });
254
+ }
@@ -31,11 +31,14 @@ export function setEnvLine(line, key, value) {
31
31
  // CRLF 안전: 라인 끝 \r을 분리해 처리 후 복원 (autocrlf 프로젝트 대응)
32
32
  const cr = line.endsWith("\r") ? "\r" : "";
33
33
  const body = cr ? line.slice(0, -1) : line;
34
- const escaped = escapeYamlDoubleQuoted(value);
35
34
  // 값 치환: KEY: "기존값" → KEY: "value"
35
+ // 홑따옴표(KEY: '기존값')도 받는다 — 템플릿에 두 표기가 섞여 있는데 겹따옴표만 보면
36
+ // 홑따옴표 줄의 @wizard 마커가 아무 경고 없이 무시된다(이슈 #81과 같은 실패 형태).
37
+ // 치환 결과는 겹따옴표로 통일하고, 값은 그에 맞게 이스케이프한다.
38
+ const escaped = escapeYamlDoubleQuoted(value);
36
39
  let out = body.replace(
37
- new RegExp(`^(\\s*${key}:\\s*")[^"]*(")`),
38
- (_m, p1, p2) => `${p1}${escaped}${p2}`,
40
+ new RegExp(`^(\\s*${key}:\\s*)(["'])(?:(?!\\2).)*\\2`),
41
+ (_m, head) => `${head}"${escaped}"`,
39
42
  );
40
43
  // 그 줄 끝 # @wizard ... 주석 제거 (앞 공백째)
41
44
  out = out.replace(/(\S)[^\S\r\n]*#[^\S\r\n]*@wizard[^\S\r\n].*$/, "$1");