project-auto-wizard 0.12.2 → 0.13.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 (86) hide show
  1. package/README.ja.md +165 -0
  2. package/README.ko.md +165 -0
  3. package/README.md +107 -286
  4. package/README.zh-CN.md +165 -0
  5. package/bin/project-auto-wizard.js +1 -2
  6. package/package.json +7 -3
  7. package/payload/config/breaking-changes.json +24 -0
  8. package/payload/config/wizard-prompts.yml +45 -0
  9. package/payload/scripts/changelog_manager.py +141 -46
  10. package/payload/scripts/issue_helper.py +34 -5
  11. package/payload/scripts/version_manager.py +397 -97
  12. package/payload/version.yml.template +4 -2
  13. package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +39 -9
  14. package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +165 -38
  15. package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +14 -5
  16. package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +220 -6
  17. package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +89 -3
  18. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +78 -16
  19. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +72 -16
  20. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +62 -20
  21. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +51 -13
  22. package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +75 -11
  23. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +52 -3
  24. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +60 -11
  25. package/payload/workflows/go/PROJECT-GO-CI.yaml +10 -1
  26. package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +148 -28
  27. package/payload/workflows/go/PROJECT-GO-SIMPLE-CICD.yaml +45 -4
  28. package/payload/workflows/next/PROJECT-NEXT-CI.yaml +25 -3
  29. package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +83 -9
  30. package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +70 -6
  31. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +148 -28
  32. package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +45 -4
  33. package/payload/workflows/react/PROJECT-REACT-CI.yaml +25 -3
  34. package/payload/workflows/react/PROJECT-REACT-CICD.yaml +84 -10
  35. package/payload/workflows/spring/PROJECT-SPRING-CI.yml +13 -3
  36. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +47 -5
  37. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +47 -5
  38. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +153 -30
  39. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +47 -5
  40. package/src/cli/args.js +33 -24
  41. package/src/cli/help.js +16 -4
  42. package/src/commands/doctor.js +17 -10
  43. package/src/commands/dry-run.js +54 -1
  44. package/src/commands/full.js +127 -50
  45. package/src/commands/interactive-flutter.js +1 -1
  46. package/src/commands/interactive.js +124 -56
  47. package/src/commands/purge.js +59 -17
  48. package/src/commands/status.js +16 -1
  49. package/src/commands/uninstall.js +58 -13
  50. package/src/context.js +5 -5
  51. package/src/core/baseline.js +20 -5
  52. package/src/core/branches.js +19 -1
  53. package/src/core/branding.js +1 -1
  54. package/src/core/breaking-check.js +14 -19
  55. package/src/core/breaking.js +7 -3
  56. package/src/core/copy/gitignore.js +20 -12
  57. package/src/core/copy/readme.js +24 -5
  58. package/src/core/copy/simple.js +52 -13
  59. package/src/core/copy/workflows.js +90 -47
  60. package/src/core/deploy-style.js +83 -11
  61. package/src/core/detect-fs.js +127 -24
  62. package/src/core/detect.js +131 -47
  63. package/src/core/errors.js +3 -0
  64. package/src/core/flutter-options.js +4 -3
  65. package/src/core/fsutil.js +23 -6
  66. package/src/core/logger.js +55 -19
  67. package/src/core/paths-resolve.js +50 -47
  68. package/src/core/paths.js +26 -1
  69. package/src/core/release-options.js +23 -0
  70. package/src/core/removal-plan.js +75 -19
  71. package/src/core/types.js +113 -0
  72. package/src/core/verify.js +85 -19
  73. package/src/core/version-yml.js +42 -8
  74. package/src/core/wizard-env.js +21 -14
  75. package/src/core/wizard-labels.js +34 -25
  76. package/src/index.js +122 -49
  77. package/src/ui/ansi.js +3 -1
  78. package/src/ui/banner.js +12 -8
  79. package/src/ui/env-plan.js +68 -44
  80. package/src/ui/prompts.js +22 -12
  81. package/src/ui/readline-engine.js +109 -22
  82. package/src/ui/status-cards.js +10 -5
  83. package/src/ui/summary.js +71 -33
  84. package/payload/scripts/__pycache__/changelog_manager.cpython-314.pyc +0 -0
  85. package/payload/scripts/__pycache__/issue_helper.cpython-314.pyc +0 -0
  86. package/payload/scripts/__pycache__/version_manager.cpython-314.pyc +0 -0
@@ -1,10 +1,11 @@
1
- // 실 파일시스템 프로젝트 감지 (.sh detect_* 실행부 등가).
1
+ // 실 파일시스템 프로젝트 감지.
2
2
  // detect.js 순수 함수를 fs/git으로 구동한다.
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
6
  import { detectTypesFromMarkers, detectVersionFromFiles, detectBuildNumberFromFiles, detectJdkFromFiles, resolveMarkers } from "./detect.js";
7
7
  import { parseExisting } from "./version-yml.js";
8
+ import { isValidBranchName } from "./branches.js";
8
9
 
9
10
  const hasFile = (root) => (rel) => existsSync(join(root, rel));
10
11
  const readFile = (root) => (rel) => {
@@ -18,22 +19,101 @@ function gitOut(root, args) {
18
19
  }
19
20
 
20
21
  // 타입 감지 — version.yml의 project_types 최우선(source of truth), 없으면 마커 스캔.
21
- export function detectTypes(root) {
22
+ // paths: --paths로 받은 Map<type,path>. 모노레포는 루트에 마커가 없으므로 사용자가 적은 타입을 쓴다.
23
+ // warn: 루트에 마커가 없어 basic으로 떨어질 때 하위 폴더에서 찾은 프로젝트를 알린다.
24
+ export function detectTypes(root, { paths = new Map(), warn } = {}) {
22
25
  const vy = join(root, "version.yml");
23
26
  if (existsSync(vy)) {
24
27
  const { types } = parseExisting(readFileSync(vy, "utf8"));
25
28
  if (types.length) return types; // basic 포함, 명시돼 있으면 그대로
26
29
  }
27
- return detectTypesFromMarkers({ has: hasFile(root), read: readFile(root) });
30
+ const fromRoot = detectTypesFromMarkers({ has: hasFile(root), read: readFile(root) });
31
+ if (paths.size) {
32
+ // --paths 순서가 주 타입을 정한다. 루트 package.json의 node는 다른 타입이 있으면 빼는
33
+ // 마커 스캔 규칙과 맞춘다.
34
+ const merged = [...new Set([...paths.keys(), ...fromRoot])].filter((t) => t !== "basic");
35
+ const types = merged.length > 1 ? merged.filter((t) => t !== "node" || paths.has("node")) : merged;
36
+ return types.length ? types : ["basic"];
37
+ }
38
+ if (fromRoot.length === 1 && fromRoot[0] === "basic" && warn) {
39
+ const found = findSubdirProjects(root);
40
+ if (found.length) {
41
+ const firstDir = new Map();
42
+ for (const { dir, types } of found) for (const t of types) if (!firstDir.has(t)) firstDir.set(t, dir);
43
+ const list = found.map(({ dir, types }) => `${dir}(${types.join(", ")})`).join(", ");
44
+ const hint = [...firstDir].map(([t, d]) => `${t}=${d}`).join(",");
45
+ warn(`⚠️ 루트에서 프로젝트 파일을 찾지 못해 basic으로 설치합니다. 하위 폴더에서 발견: ${list}\n` +
46
+ ` 모노레포라면 --paths "${hint}"로 다시 실행하세요.`);
47
+ }
48
+ }
49
+ return fromRoot;
28
50
  }
29
51
 
30
- // 버전 감지 — .sh detect_version 순서. jq는 package.json 파싱에 쓰인 적이 없어 게이트를 제거했다.
52
+ // 루트 아래 2단계까지 프로젝트 마커가 있는 폴더를 찾는다 (모노레포 안내용).
53
+ // 빌드 산출물·의존성·네이티브 폴더는 오탐만 늘리므로 들어가지 않고, 찾은 폴더의 하위도 보지 않는다.
54
+ const SUBDIR_PRUNE = new Set(["node_modules", "build", "dist", "android", "ios", "venv", "__pycache__"]);
55
+ function findSubdirProjects(root, maxDepth = 2) {
56
+ const found = [];
57
+ const walk = (rel, depth) => {
58
+ let entries;
59
+ try { entries = readdirSync(join(root, rel), { withFileTypes: true }); } catch { return; }
60
+ for (const e of entries) {
61
+ if (!e.isDirectory() || e.name.startsWith(".") || SUBDIR_PRUNE.has(e.name)) continue;
62
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
63
+ const dir = join(root, childRel);
64
+ const types = detectTypesFromMarkers({ has: hasFile(dir), read: readFile(dir) });
65
+ if (types[0] !== "basic") found.push({ dir: childRel, types });
66
+ else if (depth + 1 < maxDepth) walk(childRel, depth + 1);
67
+ }
68
+ };
69
+ walk("", 0);
70
+ return found.sort((a, b) => a.dir.localeCompare(b.dir));
71
+ }
72
+
73
+ // 버전 감지 — 타입별 버전 파일을 순서대로 읽는다.
31
74
  // hint: 폴백 경고에 붙일 해결 방법 안내 (대화형/CLI가 다르다).
32
- export function detectVersion(root, { warn = (m) => console.error(m), hint } = {}) {
33
- const read = readFile(root);
75
+ // 모노레포(--paths)는 버전 파일이 타입 폴더 안에 있다 — 루트만 보면 0.0.1/1로 초기화된다.
76
+ // 주 타입 폴더 → 나머지 타입 폴더 → 루트 순으로 찾는다.
77
+ function projectBases(types = [], paths = null) {
78
+ const bases = [];
79
+ for (const t of types) {
80
+ const p = paths?.get?.(t);
81
+ if (p && p !== "." && !bases.includes(p)) bases.push(p);
82
+ }
83
+ return bases.length ? [...bases, "."] : ["."];
84
+ }
85
+
86
+ // 첫 번째로 결과가 있는 기준 폴더의 값을 쓴다 — read와 list가 같은 폴더 우선순위를 따른다.
87
+ function firstFromBases(bases, fn) {
88
+ return (rel) => {
89
+ for (const b of bases) {
90
+ const c = fn(b === "." ? rel : `${b}/${rel}`);
91
+ if (c != null) return c;
92
+ }
93
+ return null;
94
+ };
95
+ }
96
+
97
+ function readFromProject(root, types = [], paths = null) {
98
+ return firstFromBases(projectBases(types, paths), readFile(root));
99
+ }
100
+
101
+ // 하위 폴더 이름 목록 (React Native의 ios/<앱>/Info.plist 탐색용). 폴더가 없으면 null.
102
+ function listFromProject(root, types = [], paths = null) {
103
+ const listDirs = (rel) => {
104
+ try {
105
+ return readdirSync(join(root, rel), { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
106
+ } catch { return null; }
107
+ };
108
+ return firstFromBases(projectBases(types, paths), listDirs);
109
+ }
110
+
111
+ export function detectVersion(root, { warn = (m) => console.error(m), hint, types = [], paths = null } = {}) {
112
+ const read = readFromProject(root, types, paths);
34
113
  const readJson = (rel) => { const c = read(rel); try { return c ? JSON.parse(c) : null; } catch { return null; } };
114
+ const list = listFromProject(root, types, paths);
35
115
  const gitTag = gitOut(root, ["describe", "--tags", "--abbrev=0"]);
36
- return detectVersionFromFiles({ read, readJson, gitTag, warn, hint });
116
+ return detectVersionFromFiles({ read, readJson, list, gitTag, warn, hint, types });
37
117
  }
38
118
 
39
119
  // 타입별 실제 마커 파일 — 감지 로그·설치 로그가 같은 근거 파일을 인용하도록.
@@ -50,20 +130,28 @@ export function detectJdk(root, base = ".") {
50
130
  }
51
131
 
52
132
  // 빌드 번호 감지 — 신규 통합 시 pubspec.yaml/build.gradle/app.json에서 실제 빌드 번호를 읽는다.
53
- export function detectBuildNumber(root, { types = [], warn = (m) => console.error(m) } = {}) {
54
- const read = readFile(root);
133
+ export function detectBuildNumber(root, { types = [], paths = null, warn = (m) => console.error(m) } = {}) {
134
+ const read = readFromProject(root, types, paths);
55
135
  const readJson = (rel) => { const c = read(rel); try { return c ? JSON.parse(c) : null; } catch { return null; } };
56
136
  return detectBuildNumberFromFiles({ types, read, readJson, warn });
57
137
  }
58
138
 
59
139
  // 기본 브랜치 감지 — symbolic-ref → remote show → main.
60
- export function detectDefaultBranch(root) {
61
- let b = gitOut(root, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
62
- if (b) return b.replace(/^refs\/remotes\/origin\//, "");
140
+ // 빈 원격(remote add만 하고 push 전)은 remote show가 "HEAD branch: (unknown)"을 돌려준다. 이 값이
141
+ // 워크플로우 트리거에 기록되면 릴리스 자동화가 조용히 멈추므로, 유효한 브랜치 이름만 인정하고
142
+ // 아니면 로컬 현재 브랜치(첫 push 대상) → main 순으로 폴백하며 경고한다.
143
+ // hint: 경고에 붙일 해결 방법 안내 (대화형/CLI가 다르다).
144
+ export function detectDefaultBranch(root, { warn = null, hint = "" } = {}) {
145
+ const b = gitOut(root, ["symbolic-ref", "refs/remotes/origin/HEAD"]).replace(/^refs\/remotes\/origin\//, "");
146
+ if (isValidBranchName(b)) return b;
63
147
  const show = gitOut(root, ["remote", "show", "origin"]);
64
148
  const m = show.match(/HEAD branch:\s*(\S+)/);
65
- if (m) return m[1];
66
- return "main";
149
+ if (m && isValidBranchName(m[1])) return m[1];
150
+ if (!m) return "main"; // origin 없음 — 기존 규칙 그대로
151
+ const local = gitOut(root, ["symbolic-ref", "--short", "HEAD"]);
152
+ const fallback = isValidBranchName(local) ? local : "main";
153
+ warn?.(`⚠️ 원격 기본 브랜치를 확인할 수 없어(빈 원격 레포 등) 릴리스 브랜치를 '${fallback}'(으)로 가정합니다.${hint ? ` ${hint}` : ""}`);
154
+ return fallback;
67
155
  }
68
156
 
69
157
  // 레포명 — git remote get-url origin 마지막 세그먼트, 실패 시 폴더명.
@@ -76,7 +164,7 @@ export function detectRepoName(root) {
76
164
  return basename(root);
77
165
  }
78
166
 
79
- // Spring application*.yml 탐색 (.sh resolve_spring_app_yml_dir/path L2767~2780 등가)
167
+ // Spring application*.yml 탐색
80
168
  // find {base} -path "*/src/main/resources/application*.yml" | head -1 의 fs 재귀 구현.
81
169
  // 반환: root 기준 상대경로 (예: "server/src/main/resources/application.yml") 또는 "".
82
170
  //
@@ -87,9 +175,14 @@ export function detectRepoName(root) {
87
175
  // 같은 디렉토리에서는 프로파일 없는 기본 파일(application.yml/.yaml)을 우선한다. 파일명 정렬만
88
176
  // 쓰면 'application-dev.yml'이 'application.yml'보다 앞서(`-` < `.`) 프로파일 파일이 잡힌다.
89
177
  export function findSpringAppYml(root, base = ".") {
178
+ return findSpringConfig(root, base, /^application(-[^/]*)?\.ya?ml$/, /^application\.ya?ml$/);
179
+ }
180
+
181
+ // src/main/resources 아래에서 pattern에 맞는 설정 파일을 찾는다. basePattern(프로파일 없는 기본 파일)이
182
+ // 나오면 그걸로 확정한다.
183
+ function findSpringConfig(root, base, pattern, basePattern) {
90
184
  const startRel = base === "." ? "" : base;
91
185
  const PRUNE = new Set(["node_modules", ".git", "build", ".gradle", "target", ".idea"]);
92
- const APP_YML = /^application(-[^/]*)?\.ya?ml$/;
93
186
  let hit = "";
94
187
  let hitIsBase = false;
95
188
  const walk = (rel, depth) => {
@@ -103,8 +196,8 @@ export function findSpringAppYml(root, base = ".") {
103
196
  if (e.isDirectory()) {
104
197
  if (PRUNE.has(e.name)) continue;
105
198
  walk(childRel, depth + 1);
106
- } else if (APP_YML.test(e.name) && childRel.includes("src/main/resources/")) {
107
- const isBase = /^application\.ya?ml$/.test(e.name);
199
+ } else if (pattern.test(e.name) && childRel.includes("src/main/resources/")) {
200
+ const isBase = basePattern.test(e.name);
108
201
  // 첫 매치는 일단 채택하고, 이후 기본 파일이 나오면 그걸로 승격한다.
109
202
  if (!hit || isBase) { hit = childRel; hitIsBase = isBase; }
110
203
  }
@@ -114,7 +207,17 @@ export function findSpringAppYml(root, base = ".") {
114
207
  return hit;
115
208
  }
116
209
 
117
- // 실 resolver 세트 생성 (.sh resolve_token 4종 등가) — index/interactive 공용.
210
+ // 배포 워크플로우가 application-prod.yml을 만들 리소스 폴더.
211
+ // Spring Initializr 기본 산출물은 application.properties라 yml만 찾으면 빈 값이 되어
212
+ // __APPLICATION_YML_DIR__가 치환되지 않은 채 설치된다. yml → properties → 표준 경로 순으로 정한다.
213
+ export function findSpringResourcesDir(root, base = ".") {
214
+ const f = findSpringAppYml(root, base)
215
+ || findSpringConfig(root, base, /^application(-[^/]*)?\.properties$/, /^application\.properties$/);
216
+ if (f) return f.split("/").slice(0, -1).join("/");
217
+ return base === "." ? "src/main/resources" : `${base}/src/main/resources`;
218
+ }
219
+
220
+ // @wizard 토큰 resolver 세트 생성 — index/interactive 공용.
118
221
  // paths: Map<type, path> (모노레포 경로).
119
222
  // flutterOptions: resolveFlutterOptions 결과 또는 같은 필드를 가진 context. null이면 Flutter 토큰이
120
223
  // 빈 값이라 템플릿 기본값(dart-define, store_only)이 그대로 남는다.
@@ -126,11 +229,11 @@ export function makeResolvers(root, repoName, paths, flutterOptions = null) {
126
229
  // ⚠️ 빈 문자열을 돌려주면 setEnvLine이 그 줄을 건너뛰어 __JAVA_VERSION__이 그대로 남는다
127
230
  // (같은 실패 형태). 감지 실패 시 반드시 종전 기본값 21로 폴백한다.
128
231
  jdk: (t) => detectJdk(root, springBase(t)) || "21",
129
- "spring-app-yml-dir": (t) => {
130
- const f = findSpringAppYml(root, springBase(t));
131
- return f ? f.split("/").slice(0, -1).join("/") : "";
132
- },
133
- "spring-app-yml-path": (t) => findSpringAppYml(root, springBase(t)) || "",
232
+ "spring-app-yml-dir": (t) => findSpringResourcesDir(root, springBase(t)),
233
+ // yml이 없는 프로젝트(properties 전용)는 리소스 폴더의 application.yml로 만든다 —
234
+ // properties 파일 자리에 YAML 내용을 쓰면 설정이 깨진다.
235
+ "spring-app-yml-path": (t) => findSpringAppYml(root, springBase(t))
236
+ || `${findSpringResourcesDir(root, springBase(t))}/application.yml`,
134
237
  "flutter-root": () => paths.get("flutter") || ".",
135
238
  // CI changes job의 경로 필터 — 타입별 프로젝트 루트. 단일 레포·common은 "."(항상 변경됨으로 판정).
136
239
  "project-path": (t) => paths.get(t) || ".",
@@ -1,14 +1,20 @@
1
- // package.json 내용 분류 (.sh classify_package_json 등가) — 원본 파일 텍스트에 대한
2
- // grep 부분문자열 매칭. dependencies 파싱이 아니라 raw 텍스트 검사여야 등가.
3
- // 입력은 package.json의 원문 문자열(raw). 순서 중요.
1
+ import {
2
+ typeInfo, FALLBACK_TYPE, MARKER_DETECTED_TYPES, PACKAGE_DETECTED_TYPES, PACKAGE_FALLBACK_TYPE,
3
+ } from "./types.js";
4
+
5
+ // package.json 분류 — 의존성 "키"를 정확히 비교한다. 원문 부분문자열로 보면 export 스크립트나
6
+ // exponential-backoff가 expo로, react-native-web을 쓰는 웹앱이 react-native로, keywords의 "next"가
7
+ // next로 오감지된다. 입력은 package.json 원문 문자열(raw). 판정 순서는 레지스트리의 detectOrder.
4
8
  export function classifyPackageText(raw) {
5
- const s = String(raw || "");
6
- if (s.includes("@react-native") || s.includes("react-native")) {
7
- return s.includes("expo") ? "react-native-expo" : "react-native";
9
+ let pkg;
10
+ try { pkg = JSON.parse(String(raw || "")); } catch { return PACKAGE_FALLBACK_TYPE; }
11
+ if (!pkg || typeof pkg !== "object") return PACKAGE_FALLBACK_TYPE;
12
+ const deps = new Set();
13
+ for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
14
+ const d = pkg[field];
15
+ if (d && typeof d === "object") for (const k of Object.keys(d)) deps.add(k);
8
16
  }
9
- if (s.includes('"next"')) return "next";
10
- if (s.includes('"react"')) return "react";
11
- return "node";
17
+ return PACKAGE_DETECTED_TYPES.find((t) => deps.has(t.packageDep))?.id ?? PACKAGE_FALLBACK_TYPE;
12
18
  }
13
19
 
14
20
  // 편의: 파싱된 객체를 받는 경우 원문으로 재직렬화해 위 규칙 적용
@@ -17,77 +23,152 @@ export function classifyPackageJson(pkgOrRaw) {
17
23
  return classifyPackageText(raw);
18
24
  }
19
25
 
20
- // 마커 스캔 (동작명세 §3.1). has(relpath)=>bool 주입. node는 다른 타입 있으면 미추가.
26
+ // 마커 스캔. has(relpath)=>bool 주입. node는 다른 타입 있으면 미추가.
21
27
  // read(relpath)=>string|null 로 package.json 원문을 받아 classifyPackageText에 넘긴다.
22
28
  export function detectTypesFromMarkers({ has, read }) {
23
29
  const types = [];
24
- if (has("pubspec.yaml")) types.push("flutter");
25
- if (has("build.gradle") || has("build.gradle.kts") || has("pom.xml")) types.push("spring");
26
- if (has("pyproject.toml") || has("setup.py") || has("requirements.txt")) types.push("python");
27
- if (has("go.mod")) types.push("go");
30
+ for (const t of MARKER_DETECTED_TYPES) if (t.markers.some(has)) types.push(t.id);
28
31
  if (has("package.json")) {
29
32
  const cls = classifyPackageText(read ? read("package.json") : "");
30
- if (cls === "node") { if (types.length === 0) types.push("node"); }
33
+ if (cls === PACKAGE_FALLBACK_TYPE) { if (types.length === 0) types.push(cls); }
31
34
  else types.push(cls);
32
35
  }
33
- return types.length ? [...new Set(types)] : ["basic"];
36
+ return types.length ? [...new Set(types)] : [FALLBACK_TYPE];
37
+ }
38
+
39
+ // 1.2.3-rc.1·1.2.3+7·1.2.0-SNAPSHOT 같은 prerelease/빌드 메타데이터는 x.y.z 코어만 쓴다.
40
+ // version.yml은 x.y.z만 받으므로 감지 실패(0.0.1)로 떨어지는 것보다 코어가 정확하다.
41
+ // 릴리스 시 읽기(payload/scripts/version_manager.py core_version)와 같은 규칙이어야 설치 직후 값이 유지된다.
42
+ function coreVersion(v) {
43
+ const m = String(v ?? "").trim().match(/^v?(\d+\.\d+\.\d+)(?:[-+][0-9A-Za-z.+-]*)?$/);
44
+ return m ? m[1] : null;
34
45
  }
35
46
 
36
- const VERSION_RE = /^\d+\.\d+\.\d+$/;
47
+ // setup.py의 setup(version="x.y.z"). python_version 같은 다른 키는 단어 경계로 거른다.
48
+ export function versionFromSetupPy(content) {
49
+ if (!content) return null;
50
+ const m = String(content).match(/(?<![\w.])version\s*=\s*["']([^"']+)["']/);
51
+ return m ? coreVersion(m[1]) : null;
52
+ }
37
53
 
38
- // 버전 감지 (동작명세 §3.3) — 순서대로 첫 성공. read(relpath)=>string|null 주입.
54
+ // React Native 앱 버전 — 릴리스 때 version_manager가 쓰는 파일(ios/<앱>/Info.plist,
55
+ // android/app/build.gradle)에서 읽는다. package.json version은 동기화 대상이 아니라 기준이 되면 어긋난다.
56
+ // $(MARKETING_VERSION) 참조나 템플릿 기본값 "1.0"처럼 x.y.z가 아니면 건너뛴다.
57
+ // list(relDir)=>string[]|null 로 ios 아래 앱 폴더 이름을 받는다(Pods 등 깊은 plist는 보지 않는다).
58
+ export function versionFromReactNative({ read, list }) {
59
+ for (const dir of [...(list?.("ios") || [])].sort()) {
60
+ const m = String(read(`ios/${dir}/Info.plist`) || "").match(/<key>CFBundleShortVersionString<\/key>\s*<string>([^<]*)<\/string>/);
61
+ const v = m && coreVersion(m[1]);
62
+ if (v) return v;
63
+ }
64
+ const m = String(read("android/app/build.gradle") || "").match(/versionName\s+"([^"]+)"/);
65
+ return m ? coreVersion(m[1]) : null;
66
+ }
67
+
68
+ // 버전 감지 — 순서대로 첫 성공. read(relpath)=>string|null 주입.
39
69
  // package.json은 이미 Node JSON.parse로 파싱을 마친 값이므로 jq 설치 여부와 무관하게 항상 사용한다.
40
70
  // hint: 폴백 경고 뒤에 붙일 "그럼 어떻게 고치나" 한 줄. 대화형과 CLI가 서로 다른 방법을
41
71
  // 안내해야 하므로 호출부가 정한다. 미지정 시 CLI 문구를 쓴다.
42
- export function detectVersionFromFiles({ read, readJson, gitTag, warn, hint }) {
43
- const pkg = readJson?.("package.json");
44
- if (pkg?.version && VERSION_RE.test(pkg.version)) return pkg.version;
72
+ // types: 주 타입(첫 항목)의 버전 파일을 먼저 읽는다. 릴리스 때 version_manager가 주 타입
73
+ // 파일과 version.yml을 비교하므로, 다른 타입 버전을 잡으면 첫 릴리스에서 버전이 뛴다.
74
+ export function detectVersionFromFiles({ read, readJson, list, gitTag, warn, hint, types = [] }) {
45
75
  const grab = (content, re) => {
46
76
  for (const line of (content || "").split("\n")) {
47
77
  const m = line.match(re);
48
- if (m && VERSION_RE.test(m[1])) return m[1];
78
+ if (m) { const v = coreVersion(m[m.length - 1]); if (v) return v; }
49
79
  }
50
80
  return null;
51
81
  };
52
- let v;
53
- const gradleRe = /version\s*=\s*["']?(\d+\.\d+\.\d+)/;
54
- // Groovy DSL과 Kotlin DSL은 같은 문법(`version = "x.y.z"`)이라 정규식을 공유한다.
55
- // .kts를 빼먹으면 Kotlin DSL Spring 프로젝트가 전부 0.0.1로 초기화된다.
56
- if ((v = grab(read("build.gradle"), gradleRe))) return v;
57
- if ((v = grab(read("build.gradle.kts"), gradleRe))) return v;
58
- if ((v = versionFromPom(read("pom.xml")))) return v;
59
- if ((v = grab(read("pubspec.yaml"), /^version:\s*(\d+\.\d+\.\d+)/))) return v;
60
- if ((v = grab(read("pyproject.toml"), /version\s*=\s*["']?(\d+\.\d+\.\d+)/))) return v;
61
- if (gitTag) { const t = String(gitTag).replace(/^v/, ""); if (VERSION_RE.test(t)) return t; }
82
+ // 줄 시작 앵커가 없으면 ext.kotlin_version 같은 의존성 버전 변수가 먼저 걸린다.
83
+ // 따옴표로 감싼 값만 본다 — 릴리스 때 동기화가 고칠 수 있는 형태가 이것뿐이다.
84
+ const gradleRe = /^\s*version\s*=\s*(["'])([^"'\n]*)\1/;
85
+ const sources = {
86
+ packageJson: () => coreVersion(readJson?.("package.json")?.version),
87
+ appJson: () => coreVersion(readJson?.("app.json")?.expo?.version),
88
+ // Groovy DSL과 Kotlin DSL은 같은 문법(`version = "x.y.z"`)이라 정규식을 공유한다.
89
+ // .kts를 빼먹으면 Kotlin DSL Spring 프로젝트가 전부 0.0.1로 초기화된다.
90
+ gradle: () => grab(read("build.gradle"), gradleRe),
91
+ gradleKts: () => grab(read("build.gradle.kts"), gradleRe),
92
+ pom: () => versionFromPom(read("pom.xml")),
93
+ pubspec: () => grab(read("pubspec.yaml"), /^version:\s*([^\s#]+)/),
94
+ pyproject: () => versionFromPyproject(read("pyproject.toml")),
95
+ setupPy: () => versionFromSetupPy(read("setup.py")),
96
+ reactNative: () => versionFromReactNative({ read, list }),
97
+ };
98
+ const order = [
99
+ ...(typeInfo(types[0])?.versionSources || []),
100
+ "packageJson", "gradle", "gradleKts", "pom", "pubspec", "pyproject", "setupPy",
101
+ ];
102
+ for (const key of new Set(order)) {
103
+ const v = sources[key]();
104
+ if (v) return v;
105
+ }
106
+ if (gitTag) { const t = coreVersion(gitTag); if (t) return t; }
62
107
  const tail = hint ?? "--project-version으로 직접 지정하거나 version.yml을 확인하세요.";
63
108
  warn?.(`⚠️ 버전을 자동 감지하지 못해 기본값 0.0.1을 사용합니다 — ${tail}`);
64
109
  return "0.0.1";
65
110
  }
66
111
 
67
- // Maven pom.xml의 프로젝트 버전. <parent> 블록 안의 버전은 스프링 부트 BOM 버전이라
68
- // 프로젝트 버전이 아니다 — 그 구간을 지운 뒤 첫 <version>을 읽는다.
112
+ // pyproject.toml의 패키지 버전. [tool.*] 등 다른 섹션의 `version =`은 도구 설정이라
113
+ // [project]·[tool.poetry] 섹션 안에서만 읽는다.
114
+ export function versionFromPyproject(content) {
115
+ if (!content) return null;
116
+ let section = "";
117
+ for (const line of String(content).split(/\r?\n/)) {
118
+ const h = line.match(/^\s*\[+\s*([^\]]+?)\s*\]+\s*(?:#.*)?$/);
119
+ if (h) { section = h[1]; continue; }
120
+ if (section !== "project" && section !== "tool.poetry") continue;
121
+ const m = line.match(/^\s*version\s*=\s*["']([^"']+)["']/);
122
+ if (m) return coreVersion(m[1]);
123
+ }
124
+ return null;
125
+ }
126
+
127
+ // Maven pom.xml의 프로젝트 버전 — <project> 바로 아래의 <version>만 본다.
128
+ // <parent>(스프링 부트 BOM)나 <dependencies> 안의 버전은 프로젝트 버전이 아니므로 깊이로 구분한다.
129
+ // 프로젝트 버전이 없으면(부모에서 상속) null — 의존성 버전을 대신 고르지 않는다.
69
130
  export function versionFromPom(content) {
70
131
  if (!content) return null;
71
- const body = String(content).replace(/<parent>[\s\S]*?<\/parent>/g, "");
72
- const m = body.match(/<version>\s*(\d+\.\d+\.\d+)[^<]*<\/version>/);
73
- return m ? m[1] : null;
132
+ const text = String(content);
133
+ const tokenRe = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<\?[\s\S]*?\?>|<![^>]*>|<(\/?)([A-Za-z_][\w.:-]*)[^>]*?(\/?)>/g;
134
+ const stack = [];
135
+ let start = -1;
136
+ let m;
137
+ while ((m = tokenRe.exec(text))) {
138
+ const name = m[2];
139
+ if (!name) continue;
140
+ if (m[1]) {
141
+ if (start >= 0 && stack.length === 2 && stack[1] === "version") {
142
+ return coreVersion(text.slice(start, m.index));
143
+ }
144
+ stack.pop();
145
+ continue;
146
+ }
147
+ if (m[3]) continue;
148
+ stack.push(name);
149
+ if (stack.length === 2 && name === "version") start = tokenRe.lastIndex;
150
+ }
151
+ return null;
74
152
  }
75
153
 
154
+ // 타입의 대표 마커 파일. 마커가 없는 타입(basic)·미지 타입은 package.json을 돌려준다.
76
155
  export function markerForType(type) {
77
- return { flutter: "pubspec.yaml", "react-native-expo": "app.json", python: "pyproject.toml", spring: "build.gradle", go: "go.mod" }[type] || "package.json";
156
+ return typeInfo(type)?.markers[0] || "package.json";
78
157
  }
79
158
 
159
+ // 대표 파일 외의 보조 마커 (예: Spring의 build.gradle.kts·pom.xml).
80
160
  export function extraMarkers(type) {
81
- return { python: ["setup.py", "requirements.txt"], spring: ["build.gradle.kts", "pom.xml"] }[type] || [];
161
+ return typeInfo(type)?.markers.slice(1) || [];
82
162
  }
83
163
 
84
164
  // 그 타입을 감지하는 데 실제로 쓰인 파일. markerForType은 타입당 대표 파일 하나를
85
165
  // 고정 반환하므로, build.gradle.kts만 있는 레포에서도 "build.gradle 발견"이라고 출력돼
86
166
  // 같은 설치 로그 안에서 경로 확정 화면과 파일명이 어긋났다. has()로 실재하는 것을 고른다.
87
- // 실재하는 후보가 없으면(감지 전 화면 등) 대표 파일을 쓴다.
88
- export function resolveMarker(type, has) {
167
+ // 실재하는 후보가 없으면(감지 전 화면 등) 대표 파일을 쓴다. 단 "근거"로 보여줄 때는(fallback:false)
168
+ // 빈 문자열을 돌려준다 — 직접 고른 타입에 없는 파일을 근거로 붙이면 감지된 것처럼 보인다.
169
+ export function resolveMarker(type, has, { fallback = true } = {}) {
89
170
  const candidates = [markerForType(type), ...extraMarkers(type)];
90
- return candidates.find(has) ?? candidates[0];
171
+ return candidates.find(has) ?? (fallback ? candidates[0] : "");
91
172
  }
92
173
 
93
174
  // 빌드 JDK 감지 — 배포 워크플로우의 JAVA_VERSION 기본값이 21로 고정돼 있어
@@ -124,7 +205,9 @@ export function resolveMarkers(types = [], has) {
124
205
  const out = new Map();
125
206
  for (const t of types) {
126
207
  if (t === "basic") continue;
127
- out.set(t, resolveMarker(t, has));
208
+ // 실제로 있는 파일만 근거로 삼는다 — 없으면 맵에서 빠져 화면·로그가 "직접 선택"으로 다룬다.
209
+ const found = resolveMarker(t, has, { fallback: false });
210
+ if (found) out.set(t, found);
128
211
  }
129
212
  return out;
130
213
  }
@@ -137,7 +220,8 @@ export function detectBuildNumberFromFiles({ types = [], read, readJson, warn })
137
220
  const tryFlutter = () => {
138
221
  const content = read("pubspec.yaml");
139
222
  if (content == null) return null;
140
- const m = content.match(/^version:\s*\d+\.\d+\.\d+\+(\d+)/m);
223
+ // 1.2.3-rc.1+4처럼 prerelease가 있어도 +N은 빌드 번호다(릴리스 시 읽기와 같은 규칙).
224
+ const m = content.match(/^version:\s*\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\+(\d+)/m);
141
225
  if (m) return parseInt(m[1], 10);
142
226
  warn?.("⚠️ pubspec.yaml에 빌드 번호(+N)가 없어 version_code를 감지하지 못했습니다 — 기본값 1을 사용합니다. 실제 빌드 번호를 확인하세요.");
143
227
  return null;
@@ -160,10 +244,10 @@ export function detectBuildNumberFromFiles({ types = [], read, readJson, warn })
160
244
  warn?.("⚠️ app.json의 expo.android.versionCode가 없어 version_code를 감지하지 못했습니다 — 기본값 1을 사용합니다. 실제 빌드 번호를 확인하세요.");
161
245
  return null;
162
246
  };
247
+ const readers = { pubspec: tryFlutter, androidGradle: tryReactNative, expoAppJson: tryExpo };
163
248
  for (const t of types) {
164
- if (t === "flutter") return tryFlutter();
165
- if (t === "react-native") return tryReactNative();
166
- if (t === "react-native-expo") return tryExpo();
249
+ const source = typeInfo(t)?.buildNumberSource;
250
+ if (source) return readers[source]();
167
251
  }
168
252
  return null;
169
253
  }
@@ -0,0 +1,3 @@
1
+ // 사용자 입력 오류 — 호출부(index.js)가 스택 없이 메시지만 출력하고 exit 1로 끝낸다.
2
+ // core 모듈도 던지므로 cli가 아닌 core에 둔다(core → cli 역참조 방지).
3
+ export class CliError extends Error {}
@@ -55,8 +55,9 @@ export function storeWorkflowFilter(stores) {
55
55
  // 손대지 않은 것(baseline의 installed 해시와 동일) → 삭제
56
56
  // 손댄 것 → .bak으로 옮긴다 (내용 보존, 트리거만 죽인다)
57
57
  // 사용자 소유인 Fastfile·ExportOptions.plist는 여기서 다루지 않는다 (워크플로우 파일만 대상).
58
+ // dryRun이면 판정만 하고 파일은 건드리지 않는다 (--dry-run 미리보기용).
58
59
  // 반환: { removed:[], backedUp:[] }
59
- export function cleanupDeselectedStoreWorkflows(workflowsDir, installedFilenames, stores, baseline) {
60
+ export function cleanupDeselectedStoreWorkflows(workflowsDir, installedFilenames, stores, baseline, { dryRun = false } = {}) {
60
61
  const keep = storeWorkflowFilter(stores);
61
62
  const removed = [];
62
63
  const backedUp = [];
@@ -69,10 +70,10 @@ export function cleanupDeselectedStoreWorkflows(workflowsDir, installedFilenames
69
70
  const known = baseline?.files?.[filename]?.installed;
70
71
  const untouched = known && sha256(readFileSync(p, "utf8")) === known;
71
72
  if (untouched) {
72
- rmSync(p, { force: true });
73
+ if (!dryRun) rmSync(p, { force: true });
73
74
  removed.push(filename);
74
75
  } else {
75
- renameSync(p, `${p}.bak`);
76
+ if (!dryRun) renameSync(p, `${p}.bak`);
76
77
  backedUp.push(filename);
77
78
  }
78
79
  }
@@ -1,9 +1,9 @@
1
1
  // 파일시스템 공용 유틸 (LF 보존 바이트 복사). 텍스트는 그대로 복사해 원본 줄바꿈 유지.
2
2
  import {
3
3
  cpSync, existsSync, readFileSync, writeFileSync, mkdirSync,
4
- readdirSync, rmSync,
4
+ readdirSync, rmSync, accessSync, constants,
5
5
  } from "node:fs";
6
- import { dirname } from "node:path";
6
+ import { dirname, join } from "node:path";
7
7
 
8
8
  export const exists = (p) => existsSync(p);
9
9
  export const readText = (p) => readFileSync(p, "utf8");
@@ -31,10 +31,9 @@ export function remove(p) {
31
31
  }
32
32
 
33
33
  // 디렉토리 직하위 .yaml/.yml 파일명 목록. 하위 폴더 제외.
34
- // 정렬 순서는 .sh의 glob `"$_dir"/*.yaml "$_dir"/*.yml` 와 일치시킨다:
35
- // 확장자로 1차 그룹(.yaml 먼저 → .yml 나중), 각 그룹 안에서 알파벳순.
36
- // (단순 .sort()는 확장자를 섞어 정렬해 .sh와 파일 순회 순서가 갈리고,
37
- // 그 결과 version.yml deploy 블록의 키 순서까지 달라진다. 확장자 그룹핑으로 바이트 등가 확보.)
34
+ // 정렬 순서는 고정한다: 확장자로 1차 그룹(.yaml 먼저 → .yml 나중), 각 그룹 안에서 알파벳순.
35
+ // (단순 .sort()는 확장자를 섞어 정렬해 기존 설치와 파일 순회 순서가 갈리고,
36
+ // 그 결과 version.yml deploy 블록의 키 순서까지 달라진다. 확장자 그룹핑으로 재실행 결과를 바이트 단위로 유지.)
38
37
  export function listYamlFiles(dir) {
39
38
  if (!existsSync(dir)) return [];
40
39
  const names = readdirSync(dir, { withFileTypes: true })
@@ -44,3 +43,21 @@ export function listYamlFiles(dir) {
44
43
  const yml = names.filter((n) => n.endsWith(".yml")).sort();
45
44
  return [...yaml, ...yml];
46
45
  }
46
+
47
+ // 쓰기 전에 권한을 미리 확인한다 — 중간에 EACCES로 멈추면 일부만 설치된 상태가 남고,
48
+ // 다음 실행에서는 기준점(baseline)이 없어 이미 쓴 파일이 전부 충돌로 분류된다.
49
+ // dirs: 파일을 만들 폴더(없으면 만들어질 가장 가까운 상위 폴더를 본다), files: 이미 있으면 덮어쓸 파일.
50
+ // 반환: 쓸 수 없는 경로(root 기준 상대경로) 목록.
51
+ export function findUnwritable(root, dirs = [], files = []) {
52
+ const writable = (p) => { try { accessSync(p, constants.W_OK); return true; } catch { return false; } };
53
+ const blocked = new Set();
54
+ for (const rel of dirs) {
55
+ let cur = rel;
56
+ while (cur !== "." && !existsSync(join(root, cur))) cur = dirname(cur);
57
+ if (!writable(join(root, cur))) blocked.add(cur);
58
+ }
59
+ for (const rel of files) {
60
+ if (existsSync(join(root, rel)) && !writable(join(root, rel))) blocked.add(rel);
61
+ }
62
+ return [...blocked];
63
+ }