project-auto-wizard 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/bin/project-auto-wizard.js +19 -0
- package/package.json +36 -0
- package/payload/coderabbit.yaml +19 -0
- package/payload/config/breaking-changes.json +1 -0
- package/payload/config/wizard-prompts.yml +62 -0
- package/payload/scripts/changelog_manager.py +726 -0
- package/payload/scripts/version_manager.py +617 -0
- package/payload/version.yml.template +48 -0
- package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +303 -0
- package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +293 -0
- package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +289 -0
- package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +192 -0
- package/payload/workflows/common/secret-backup/PROJECT-COMMON-SECRET-FILE-UPLOAD.yaml +209 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +591 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +700 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +308 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +992 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +689 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +987 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +471 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-SUH-LAB-APP-BUILD-TRIGGER.yaml +500 -0
- package/payload/workflows/next/PROJECT-NEXT-CI.yaml +185 -0
- package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +271 -0
- package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +81 -0
- package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +2191 -0
- package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +386 -0
- package/payload/workflows/react/PROJECT-REACT-CI.yaml +194 -0
- package/payload/workflows/react/PROJECT-REACT-CICD.yaml +255 -0
- package/payload/workflows/spring/PROJECT-SPRING-GITHUB-PACKAGES-PUBLISH.yml +84 -0
- package/payload/workflows/spring/nexus/PROJECT-SPRING-NEXUS-CI.yml +316 -0
- package/payload/workflows/spring/nexus/PROJECT-SPRING-NEXUS-PUBLISH.yml +58 -0
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +535 -0
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +437 -0
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +2277 -0
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +425 -0
- package/src/cli/args.js +95 -0
- package/src/cli/help.js +27 -0
- package/src/commands/full.js +53 -0
- package/src/commands/interactive.js +276 -0
- package/src/commands/revert.js +60 -0
- package/src/commands/version.js +31 -0
- package/src/commands/workflows.js +49 -0
- package/src/context.js +26 -0
- package/src/core/assets.js +47 -0
- package/src/core/branches.js +60 -0
- package/src/core/branding.js +15 -0
- package/src/core/breaking-check.js +65 -0
- package/src/core/breaking.js +26 -0
- package/src/core/copy/coderabbit.js +26 -0
- package/src/core/copy/gitignore.js +55 -0
- package/src/core/copy/readme.js +30 -0
- package/src/core/copy/simple.js +23 -0
- package/src/core/copy/workflows.js +233 -0
- package/src/core/detect-fs.js +106 -0
- package/src/core/detect.js +62 -0
- package/src/core/fsutil.js +46 -0
- package/src/core/options-ask.js +99 -0
- package/src/core/paths-resolve.js +261 -0
- package/src/core/paths.js +16 -0
- package/src/core/version-yml.js +190 -0
- package/src/core/wizard-env.js +101 -0
- package/src/core/wizard-labels.js +107 -0
- package/src/index.js +162 -0
- package/src/ui/ansi.js +26 -0
- package/src/ui/banner.js +29 -0
- package/src/ui/env-plan.js +207 -0
- package/src/ui/prompts.js +99 -0
- package/src/ui/readline-engine.js +257 -0
- package/src/ui/status-cards.js +56 -0
- package/src/ui/summary.js +127 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// 선택(opt-in) 워크플로우 포함 여부 질문 (.sh ask_optional_workflow L2651~2702 /
|
|
2
|
+
// ask_all_optional_workflows L2708~2732 등가). Nexus publish + Secret 서버 백업.
|
|
3
|
+
//
|
|
4
|
+
// io 주입 계약(readline-engine 시그니처):
|
|
5
|
+
// io.confirm({message, initialValue}) → bool | CANCEL(symbol)
|
|
6
|
+
// io.log(line) → 안내 출력 (없으면 stderr)
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { listYamlFiles } from "./fsutil.js";
|
|
10
|
+
import { PATHS, PAYLOAD } from "./paths.js";
|
|
11
|
+
import { parseTemplateOptions } from "./version-yml.js";
|
|
12
|
+
|
|
13
|
+
// 재노출 — 파서 본체는 version-yml.js에 있다 (순환 import 방지: options-ask → version-yml 방향만 허용)
|
|
14
|
+
export { parseTemplateOptions };
|
|
15
|
+
|
|
16
|
+
const isCancel = (v) => typeof v === "symbol";
|
|
17
|
+
|
|
18
|
+
// 옵션 1종 질문 (.sh ask_optional_workflow 등가).
|
|
19
|
+
// 반환: true/false/null(폴더 없음·파일 0개로 질문 자체 생략 → 현재값 유지).
|
|
20
|
+
async function askOptionalWorkflow({ dir, icon, short, desc, current, force, tty, io, forceAsk, say }) {
|
|
21
|
+
// 폴더가 없거나 yaml이 0개면 조용히 건너뜀 (.sh L2664~2669) — 질문 자체가 성립 안 함
|
|
22
|
+
if (!existsSync(dir)) return current;
|
|
23
|
+
const files = listYamlFiles(dir);
|
|
24
|
+
if (files.length === 0) return current;
|
|
25
|
+
|
|
26
|
+
// 이미 값이 설정돼 있고 force-ask 아니면 유지 (CLI/version.yml 우선, .sh L2672~2674)
|
|
27
|
+
if (!forceAsk && (current === true || current === false)) return current;
|
|
28
|
+
|
|
29
|
+
// 비대화형(--force 또는 TTY 없음)이면 기본 제외 (.sh L2677~2679)
|
|
30
|
+
if (force || !tty) return false;
|
|
31
|
+
|
|
32
|
+
say("");
|
|
33
|
+
say(`${icon} ${short} 워크플로우를 발견했습니다. (${files.length}개 파일)`);
|
|
34
|
+
say(` ${desc}`);
|
|
35
|
+
say("");
|
|
36
|
+
say(" 포함되는 워크플로우:");
|
|
37
|
+
for (const f of files) say(` • ${f}`);
|
|
38
|
+
say("");
|
|
39
|
+
|
|
40
|
+
const ans = await io.confirm({ message: `${short} 워크플로우를 포함할까요?`, initialValue: false });
|
|
41
|
+
// ESC(취소)는 '아니오'와 동일 취급 (.sh ask_yes_no 비-0 반환 등가)
|
|
42
|
+
const include = ans === true && !isCancel(ans);
|
|
43
|
+
say(include
|
|
44
|
+
? `${short} 워크플로우를 포함합니다 — GitHub Actions에 추가됩니다`
|
|
45
|
+
: `${short} 워크플로우를 제외합니다 (나중에 옵션으로 추가 가능)`);
|
|
46
|
+
return include;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 모든 opt-in 워크플로우를 순서대로 질문 (.sh ask_all_optional_workflows 등가).
|
|
50
|
+
// payloadRoot: 패키지 payload/ 루트 — 타입 폴더는 {payloadRoot}/workflows/<type>
|
|
51
|
+
// (copyWorkflows와 동일 규약)
|
|
52
|
+
// current: { nexus: bool|null, secretBackup: bool|null } — CLI(--nexus 등) 명시값
|
|
53
|
+
// 반환: { nexus: bool, secretBackup: bool } (미결정 null은 false로 확정)
|
|
54
|
+
export async function askAllOptionalWorkflows({
|
|
55
|
+
payloadRoot, types = [], current = {}, targetRoot = ".",
|
|
56
|
+
force = false, tty = true, io = {}, forceAsk = false,
|
|
57
|
+
}) {
|
|
58
|
+
const say = io.log || ((m) => process.stderr.write(`${m}\n`));
|
|
59
|
+
let nexus = current.nexus ?? null;
|
|
60
|
+
let secretBackup = current.secretBackup ?? null;
|
|
61
|
+
|
|
62
|
+
// ① --force-ask가 아니면 version.yml 저장값을 먼저 읽어 재질문을 건너뛴다 (.sh L2715~2717).
|
|
63
|
+
// CLI 명시값(current)이 이미 있으면 그쪽이 우선 — 저장값은 빈 자리만 채운다.
|
|
64
|
+
if (!forceAsk) {
|
|
65
|
+
const vy = join(targetRoot, PATHS.versionFile);
|
|
66
|
+
if (existsSync(vy)) {
|
|
67
|
+
const saved = parseTemplateOptions(readFileSync(vy, "utf8"));
|
|
68
|
+
if (nexus === null && saved.nexus !== null) {
|
|
69
|
+
nexus = saved.nexus;
|
|
70
|
+
say(`Nexus 옵션: version.yml 저장값(${nexus}) 유지 — 재질문 생략`);
|
|
71
|
+
}
|
|
72
|
+
if (secretBackup === null && saved.secretBackup !== null) {
|
|
73
|
+
secretBackup = saved.secretBackup;
|
|
74
|
+
say(`Secret 백업 옵션: version.yml 저장값(${secretBackup}) 유지 — 재질문 생략`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 타입 폴더 루트 — payload/workflows (copyWorkflows와 동일 규약)
|
|
80
|
+
const ptDir = join(payloadRoot, PAYLOAD.workflowsDir);
|
|
81
|
+
|
|
82
|
+
// ② Nexus: 각 타입의 nexus/ 폴더 (현재 spring만 존재, .sh L2719~2725)
|
|
83
|
+
for (const t of types) {
|
|
84
|
+
nexus = await askOptionalWorkflow({
|
|
85
|
+
dir: join(ptDir, t, "nexus"), icon: "📦", short: "Nexus 라이브러리 publish",
|
|
86
|
+
desc: "라이브러리/모듈을 Maven 저장소(Nexus)에 배포하는 워크플로우입니다. 일반 서버 배포가 아니라 라이브러리 프로젝트에만 필요합니다.",
|
|
87
|
+
current: nexus, force, tty, io, forceAsk, say,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
// ③ Secret 백업: 공통 폴더 (.sh L2726~2729)
|
|
91
|
+
secretBackup = await askOptionalWorkflow({
|
|
92
|
+
dir: join(ptDir, "common", "secret-backup"), icon: "🔐", short: "Secret 서버 백업",
|
|
93
|
+
desc: "GitHub Secret에 저장한 설정 파일을 SSH로 서버에 업로드·이력관리하는 워크플로우입니다.",
|
|
94
|
+
current: secretBackup, force, tty, io, forceAsk, say,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ④ 미결정(null)은 false로 확정 — .sh에서 빈 INCLUDE_* 가 이후 false 취급되는 것과 동일
|
|
98
|
+
return { nexus: nexus === true, secretBackup: secretBackup === true };
|
|
99
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// 타입별 프로젝트 경로 감지·확정 (.sh find_type_path_candidates L1249~1311 /
|
|
2
|
+
// resolve_project_paths L1362~1589 등가). 모노레포에서 각 타입의 버전 파일이
|
|
3
|
+
// 어느 폴더에 있는지 5단계 우선순위로 확정한다.
|
|
4
|
+
//
|
|
5
|
+
// io 주입 계약(readline-engine 시그니처 그대로):
|
|
6
|
+
// io.select({message, options:[{value,label}]}) → value | CANCEL(symbol)
|
|
7
|
+
// io.text({message, defaultValue}) → string | CANCEL
|
|
8
|
+
// io.confirm({message, initialValue}) → bool | CANCEL
|
|
9
|
+
// io.log(line) → 안내 출력 (없으면 stderr)
|
|
10
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { markerForType as baseMarkerForType, extraMarkers } from "./detect.js";
|
|
13
|
+
import { normalizePath } from "../cli/args.js";
|
|
14
|
+
|
|
15
|
+
// 취소(ESC/Ctrl+C)는 CANCEL 심볼 — ui를 import하지 않고 심볼 여부로만 판정 (core→ui 역참조 방지)
|
|
16
|
+
const isCancel = (v) => typeof v === "symbol";
|
|
17
|
+
|
|
18
|
+
// 타입의 대표 마커 파일명 (.sh marker_for_type L1220~1229 등가).
|
|
19
|
+
// detect.js는 미지 타입에 package.json을 기본 반환하지만 .sh는 빈 문자열 — 등가를 위해 래핑.
|
|
20
|
+
const KNOWN_MARKER_TYPES = new Set([
|
|
21
|
+
"flutter", "react", "next", "node", "react-native", "react-native-expo", "python", "spring",
|
|
22
|
+
]);
|
|
23
|
+
export function markerForType(type) {
|
|
24
|
+
return KNOWN_MARKER_TYPES.has(type) ? baseMarkerForType(type) : "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 디렉토리에 실재하는 마커 파일명 반환 — 보조 마커 포함, 없으면 대표 마커 (표시용).
|
|
28
|
+
// (.sh existing_marker_in_dir L1232~1245: spring build.gradle/.kts/pom.xml, python pyproject/setup.py/requirements.txt)
|
|
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;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// maxdepth 3 재귀 파일 탐색 — 매치 파일의 "디렉토리" 상대경로(루트는 ".")를 수집.
|
|
39
|
+
// find의 maxdepth는 파일 경로 컴포넌트 수 기준(./a/b/f = depth 3)이므로 동일하게 계산.
|
|
40
|
+
function walkFindDirs(root, { prune, match, maxDepth = 3 }) {
|
|
41
|
+
const hits = [];
|
|
42
|
+
const walk = (rel, depth) => {
|
|
43
|
+
let entries;
|
|
44
|
+
try { entries = readdirSync(join(root, rel || "."), { withFileTypes: true }); } catch { return; }
|
|
45
|
+
for (const e of entries) {
|
|
46
|
+
const childDepth = depth + 1;
|
|
47
|
+
const childRel = rel ? `${rel}/${e.name}` : e.name;
|
|
48
|
+
if (e.isDirectory()) {
|
|
49
|
+
// prune 폴더는 하위 전체 제외 (.sh find -prune 등가)
|
|
50
|
+
if (prune.has(e.name)) continue;
|
|
51
|
+
// 자식 파일이 depth ≤ maxDepth 안에 들어올 때만 하강
|
|
52
|
+
if (childDepth < maxDepth) walk(childRel, childDepth);
|
|
53
|
+
// childDepth === maxDepth-0 인 디렉토리 내부 파일은 depth maxDepth+1 → find가 안 봄
|
|
54
|
+
else if (childDepth === maxDepth) { /* 파일만 maxDepth까지 — 디렉토리 하강 불필요 */ }
|
|
55
|
+
} else if (childDepth <= maxDepth && match(e.name)) {
|
|
56
|
+
hits.push(rel === "" ? "." : rel);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
walk("", 0);
|
|
61
|
+
return [...new Set(hits)].sort(); // sort -u 등가
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 타입별 마커 파일 후보 검색 (.sh find_type_path_candidates L1249~1311 등가).
|
|
65
|
+
// 반환: 후보 디렉토리 상대경로 배열 (루트는 ".").
|
|
66
|
+
export function findTypePathCandidates(root, type) {
|
|
67
|
+
// ── Spring 멀티모듈: settings.gradle(.kts) 폴더 = 모듈 루트로 축약 (.sh L1255~1268) ──
|
|
68
|
+
// version_manager가 그 폴더 아래 build.gradle 전부를 갱신하므로 하위 모듈을 펼치지 않는다.
|
|
69
|
+
// android/ 의 settings.gradle(Flutter/RN)은 spring이 아니므로 prune.
|
|
70
|
+
if (type === "spring") {
|
|
71
|
+
const mm = walkFindDirs(root, {
|
|
72
|
+
prune: new Set(["node_modules", ".git", "build", "dist", ".gradle", "android", "ios"]),
|
|
73
|
+
match: (n) => n === "settings.gradle" || n === "settings.gradle.kts",
|
|
74
|
+
});
|
|
75
|
+
if (mm.length) return mm;
|
|
76
|
+
// settings.gradle 없음 → 단일 모듈, 아래 build.gradle 폴백
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const namesByType = {
|
|
80
|
+
flutter: ["pubspec.yaml"],
|
|
81
|
+
react: ["package.json"], next: ["package.json"], node: ["package.json"],
|
|
82
|
+
"react-native": ["package.json"],
|
|
83
|
+
"react-native-expo": ["app.json"],
|
|
84
|
+
python: ["pyproject.toml", "setup.py", "requirements.txt"],
|
|
85
|
+
spring: ["build.gradle", "build.gradle.kts", "pom.xml"],
|
|
86
|
+
};
|
|
87
|
+
const names = namesByType[type];
|
|
88
|
+
if (!names) return [];
|
|
89
|
+
|
|
90
|
+
const prune = new Set([
|
|
91
|
+
"node_modules", ".git", "build", "dist", ".dart_tool", "android", "ios",
|
|
92
|
+
".gradle", "venv", ".venv", "__pycache__",
|
|
93
|
+
]);
|
|
94
|
+
// 우선순위 높은 마커에서 발견되면 그것만 사용 (.sh L1281~1288)
|
|
95
|
+
let found = [];
|
|
96
|
+
for (const n of names) {
|
|
97
|
+
found = walkFindDirs(root, { prune, match: (name) => name === n });
|
|
98
|
+
if (found.length) break;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return found.filter((d) => {
|
|
102
|
+
if (type === "flutter") {
|
|
103
|
+
// example/ 제외 + lib/ 동반 확인 — 오탐 방지 (.sh L1298~1303)
|
|
104
|
+
if (d.includes("example")) return false;
|
|
105
|
+
const libDir = d === "." ? join(root, "lib") : join(root, d, "lib");
|
|
106
|
+
if (!existsSync(libDir)) return false;
|
|
107
|
+
}
|
|
108
|
+
if (type === "spring") {
|
|
109
|
+
// Flutter/RN의 android/build.gradle 오탐 제외 (.sh L1304~1307)
|
|
110
|
+
if (d.includes("android")) return false;
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 선택된 모든 타입의 경로를 감지·확인하여 Map<type,path> 확정
|
|
117
|
+
// (.sh resolve_project_paths L1362~1589 등가 — 5단계 우선순위).
|
|
118
|
+
// ① paths에 이미 있음(--paths) → 유지
|
|
119
|
+
// ② 루트에 마커 존재 → "." 자동
|
|
120
|
+
// ③ existingPaths(version.yml 저장값)
|
|
121
|
+
// ④ 후보 스캔
|
|
122
|
+
// ⑤ 분기 — 비대화형: 기존값→후보1개→루트 폴백 / 대화형: 확인·선택·직접입력
|
|
123
|
+
export async function resolveProjectPaths({
|
|
124
|
+
root, types = [], paths = new Map(), existingPaths = new Map(),
|
|
125
|
+
force = false, tty = true, io = {},
|
|
126
|
+
}) {
|
|
127
|
+
const say = io.log || ((m) => process.stderr.write(`${m}\n`));
|
|
128
|
+
const result = new Map(paths); // --paths 사전값 유지 (호출부 Map은 불변)
|
|
129
|
+
const targets = types.filter((t) => t !== "basic"); // basic은 경로 불필요 (.sh L1400)
|
|
130
|
+
if (targets.length === 0) return result;
|
|
131
|
+
|
|
132
|
+
const total = targets.length;
|
|
133
|
+
// ── 도입부 안내 (.sh L1407~1434 — 감지 결과 + 무엇을 할지 설명) ──
|
|
134
|
+
say("");
|
|
135
|
+
if (total > 1) say(`🔍 멀티타입 프로젝트가 감지되었습니다 — 총 ${total}개 타입`);
|
|
136
|
+
else say(`🔍 ${targets[0]} 프로젝트가 감지되었습니다 — 총 1개 타입`);
|
|
137
|
+
for (const t of targets) say(` • ${t.padEnd(8)} → ${existingMarkerInDir(t, root)}`);
|
|
138
|
+
say("");
|
|
139
|
+
say("💡 '프로젝트 루트' = 그 타입의 버전 파일이 있는 폴더 (레포 루트 기준 상대경로)");
|
|
140
|
+
say("");
|
|
141
|
+
|
|
142
|
+
let idx = 0;
|
|
143
|
+
for (const t of targets) {
|
|
144
|
+
idx += 1;
|
|
145
|
+
const prog = `[${idx}/${total}]`;
|
|
146
|
+
|
|
147
|
+
// ① --paths 등으로 이미 지정됨 → 최우선 (.sh L1441~1446)
|
|
148
|
+
if (result.get(t)) {
|
|
149
|
+
say(` ${t} → ${result.get(t)} (--paths 지정)`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ② 루트에 마커 존재 → "." 자동 확정 (.sh L1449~1455, 보조 마커 포함)
|
|
154
|
+
const rootMarker = existingMarkerInDir(t, root);
|
|
155
|
+
if (rootMarker && existsSync(join(root, rootMarker))) {
|
|
156
|
+
result.set(t, ".");
|
|
157
|
+
say(` ${t} → . (루트의 ${rootMarker})`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ③ 기존 version.yml 저장값 → 기본 제안값 (.sh L1458~1466)
|
|
162
|
+
const existing = existingPaths.get(t) || "";
|
|
163
|
+
|
|
164
|
+
// ④ 후보 검색 (.sh L1469~1471)
|
|
165
|
+
const candidates = findTypePathCandidates(root, t);
|
|
166
|
+
let chosen = "";
|
|
167
|
+
|
|
168
|
+
// ── ⑤-a 비대화형 (--force 또는 TTY 없음, .sh L1476~1489) ──
|
|
169
|
+
if (force || !tty) {
|
|
170
|
+
if (existing) {
|
|
171
|
+
chosen = existing;
|
|
172
|
+
say(` ${t} → ${chosen} (기존 project_paths 유지)`);
|
|
173
|
+
} else if (candidates.length === 1) {
|
|
174
|
+
chosen = candidates[0];
|
|
175
|
+
say(` ${t} → ${chosen} (자동 감지)`);
|
|
176
|
+
} else {
|
|
177
|
+
chosen = ".";
|
|
178
|
+
say(` ⚠️ ${t} → 후보 ${candidates.length}개로 자동 확정 불가, 루트(.)로 기록 (--paths "${t}=경로"로 지정 가능)`);
|
|
179
|
+
}
|
|
180
|
+
result.set(t, chosen);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── ⑤-b 대화형: 후보 개수별 분기 (.sh L1492~1525) ──
|
|
185
|
+
if (candidates.length === 1) {
|
|
186
|
+
const cand = candidates[0];
|
|
187
|
+
const candMarker = existingMarkerInDir(t, cand === "." ? root : join(root, cand));
|
|
188
|
+
const candFull = cand === "." ? candMarker : `${cand}/${candMarker}`;
|
|
189
|
+
say("");
|
|
190
|
+
say(` ${prog} 🔍 ${t} — ${candMarker} 발견`);
|
|
191
|
+
say(` 위치: <레포루트>/${candFull}`);
|
|
192
|
+
// '아니오'/취소 시 chosen 미설정 → 아래 직접입력 루프로
|
|
193
|
+
const ok = await io.confirm({
|
|
194
|
+
message: ` ${t} 프로젝트 루트를 '${cand}'(으)로 설정할까요? (${candFull} 기준 — 아니오 선택 시 직접 입력)`,
|
|
195
|
+
initialValue: true,
|
|
196
|
+
});
|
|
197
|
+
if (ok === true) chosen = cand;
|
|
198
|
+
} else if (candidates.length > 1) {
|
|
199
|
+
say("");
|
|
200
|
+
say(` ${prog} 🔍 ${t}: 경로 후보 ${candidates.length}개 발견`);
|
|
201
|
+
// 후보들 + '직접 입력' 메뉴 — value 자체를 한국어로 (센티넬 노출 방지, .sh L1508~1521)
|
|
202
|
+
const options = candidates.map((c) => ({
|
|
203
|
+
value: c,
|
|
204
|
+
label: `${c} (${existingMarkerInDir(t, c === "." ? root : join(root, c))})`,
|
|
205
|
+
}));
|
|
206
|
+
options.push({ value: "직접 입력", label: "직접 입력" });
|
|
207
|
+
const sel = await io.select({ message: ` ${t} 프로젝트 루트를 선택하세요`, options });
|
|
208
|
+
// ESC(취소)도 직접 입력으로 폴백 (.sh `|| _sel="직접 입력"`)
|
|
209
|
+
if (!isCancel(sel) && sel != null && sel !== "직접 입력") chosen = sel;
|
|
210
|
+
} else {
|
|
211
|
+
say("");
|
|
212
|
+
say(` ⚠️ ${prog} ${t}: 프로젝트를 찾지 못했습니다 (maxdepth 3).`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── 직접 입력 루프 (위에서 미확정 시, .sh L1528~1553) ──
|
|
216
|
+
while (!chosen) {
|
|
217
|
+
const hintMarker = existingMarkerInDir(t, root);
|
|
218
|
+
let prompt = ` ${t} 프로젝트 루트 경로 입력 (${hintMarker} 이 있는 폴더, 예: server, app — 루트면 그냥 Enter`;
|
|
219
|
+
if (existing) prompt += `, 현재값: ${existing}`;
|
|
220
|
+
prompt += "): ";
|
|
221
|
+
let input = await io.text({ message: prompt, defaultValue: "" });
|
|
222
|
+
if (isCancel(input) || input == null) input = ""; // ESC → 빈값 (아래 폴백)
|
|
223
|
+
input = String(input).trim();
|
|
224
|
+
// 빈값 → 기존값 또는 루트 (.sh L1541~1543) — normalizePath 전에 판정
|
|
225
|
+
input = input === "" ? (existing || ".") : normalizePath(input);
|
|
226
|
+
// 검증: 입력 경로에 마커 존재 확인 (보조 마커 포함, .sh L1544~1552)
|
|
227
|
+
const m = existingMarkerInDir(t, input === "." ? root : join(root, input));
|
|
228
|
+
if (m && existsSync(join(root, input === "." ? "" : input, m))) {
|
|
229
|
+
chosen = input;
|
|
230
|
+
} else {
|
|
231
|
+
say(` ⚠️ ${input}/${m} 파일이 없습니다.`);
|
|
232
|
+
const forceOk = await io.confirm({ message: " 그래도 이 경로를 사용할까요?", initialValue: false });
|
|
233
|
+
if (forceOk === true) chosen = input;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
result.set(t, chosen);
|
|
238
|
+
say(` ✅ ${t} → ${chosen}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── 요약 + 같은 마커 파일 중복 경고 (.sh L1559~1587) ──
|
|
242
|
+
say("");
|
|
243
|
+
say("📂 타입별 버전 파일 경로 확정:");
|
|
244
|
+
const fileToTypes = new Map(); // 마커 파일 상대경로 → 그 파일을 쓰는 타입들
|
|
245
|
+
for (const [pt, pp] of result) {
|
|
246
|
+
const m = existingMarkerInDir(pt, pp === "." ? root : join(root, pp));
|
|
247
|
+
const file = pp === "." ? m : `${pp}/${m}`;
|
|
248
|
+
say(` ${pt} → ${file}`);
|
|
249
|
+
if (!fileToTypes.has(file)) fileToTypes.set(file, []);
|
|
250
|
+
fileToTypes.get(file).push(pt);
|
|
251
|
+
}
|
|
252
|
+
for (const [file, ts] of fileToTypes) {
|
|
253
|
+
if (ts.length > 1) {
|
|
254
|
+
// 멱등 동작이라 막지는 않고 경고만 (.sh L1577~1586)
|
|
255
|
+
say(` ⚠️ 같은 파일(${file})을 여러 타입(${ts.join(" ")})이 바라봅니다.`);
|
|
256
|
+
say(" → sync 때 모두 같은 버전이 기록됩니다. 동작에는 문제없지만 의도한 구성인지 확인하세요.");
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
say("");
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// 경로 상수 — 설치 대상(사용자 레포) 경로 + payload 내부 레이아웃.
|
|
2
|
+
export const PATHS = {
|
|
3
|
+
versionFile: "version.yml",
|
|
4
|
+
workflowsDir: ".github/workflows",
|
|
5
|
+
scriptsDir: ".github/scripts",
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
// payload/ 내부 레이아웃 (payload 단일 진실 — DESIGN-SPEC §3)
|
|
9
|
+
export const PAYLOAD = {
|
|
10
|
+
workflowsDir: "workflows", // payload/workflows/{common,spring,flutter,...}
|
|
11
|
+
scriptsDir: "scripts", // payload/scripts/*.py
|
|
12
|
+
configDir: "config", // payload/config/wizard-prompts.yml 등 (마법사 런타임용)
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const WORKFLOW_PREFIX = "PROJECT";
|
|
16
|
+
export const WORKFLOW_COMMON_PREFIX = "PROJECT-COMMON";
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// version.yml 파싱·생성 (.sh create_version_yml 등가, 전체 재생성 전략 D4).
|
|
2
|
+
// ⚠️ YAML 재직렬화 금지 — 주석이 데이터.
|
|
3
|
+
// 레이아웃 단일 진실 = payload/version.yml.template (호출부가 templateText로 주입).
|
|
4
|
+
|
|
5
|
+
// metadata.template.options 상태머신 파싱 (.sh read_template_options L2361~2416 등가).
|
|
6
|
+
// 반환: { nexus: bool|null, secretBackup: bool|null } — null=미기재.
|
|
7
|
+
// 구 synology 키 등 다른 키는 어느 분기에도 안 걸려 자연히 무시된다.
|
|
8
|
+
// (options-ask.js가 이 함수를 import한다 — 순환 방지 위해 여기(version-yml)에 정의.)
|
|
9
|
+
export function parseTemplateOptions(content) {
|
|
10
|
+
const out = { nexus: null, secretBackup: null, coderabbit: null };
|
|
11
|
+
// 값 정규화: 따옴표 제거 + 트림 (.sh tr -d '"' | tr -d "'" | xargs 등가)
|
|
12
|
+
const strip = (s) => String(s).replace(/["']/g, "").trim();
|
|
13
|
+
let inTemplate = false;
|
|
14
|
+
let inOptions = false;
|
|
15
|
+
for (const line of String(content || "").split("\n")) {
|
|
16
|
+
if (/^\s*template:/.test(line)) { inTemplate = true; continue; }
|
|
17
|
+
if (inTemplate && /^\s+options:/.test(line)) { inOptions = true; continue; }
|
|
18
|
+
if (inTemplate && inOptions) {
|
|
19
|
+
let m = line.match(/^\s+nexus:\s*(.+)/);
|
|
20
|
+
if (m) {
|
|
21
|
+
const v = strip(m[1]);
|
|
22
|
+
if (v === "true") out.nexus = true;
|
|
23
|
+
if (v === "false") out.nexus = false;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
m = line.match(/^\s+secret_backup:\s*(.+)/);
|
|
27
|
+
if (m) {
|
|
28
|
+
const v = strip(m[1]);
|
|
29
|
+
if (v === "true") out.secretBackup = true;
|
|
30
|
+
if (v === "false") out.secretBackup = false;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
m = line.match(/^\s+coderabbit:\s*(.+)/);
|
|
34
|
+
if (m) {
|
|
35
|
+
const v = strip(m[1]);
|
|
36
|
+
if (v === "true") out.coderabbit = true;
|
|
37
|
+
if (v === "false") out.coderabbit = false;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
// 들여쓰기 0~4칸의 다른 키 → options 섹션 종료 (.sh L2404~2408)
|
|
41
|
+
if (/^\s{0,4}[a-z_]+:/.test(line)) { inOptions = false; inTemplate = false; }
|
|
42
|
+
}
|
|
43
|
+
// 최상위 키 → template 섹션 종료 (.sh L2411~2415)
|
|
44
|
+
if (inTemplate && /^[a-z_]+:/.test(line)) { inTemplate = false; inOptions = false; }
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 기존 version.yml에서 값 추출 (.sh grep/sed 등가, 주석 라인 오탐 방지).
|
|
50
|
+
export function parseExisting(content) {
|
|
51
|
+
const text = String(content || "");
|
|
52
|
+
const line = (re) => {
|
|
53
|
+
for (const l of text.split("\n")) {
|
|
54
|
+
if (l.startsWith("#")) continue; // 주석 제외
|
|
55
|
+
const m = l.match(re);
|
|
56
|
+
if (m) return m[1];
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
};
|
|
60
|
+
// version: "x.y.z" (숫자.숫자.숫자 형태만)
|
|
61
|
+
const version = line(/^version:\s*["']?([0-9][0-9.]*)["']?/) || "";
|
|
62
|
+
// version_code: N (양의 정수, 아니면 1)
|
|
63
|
+
let versionCode = parseInt(line(/^version_code:\s*([0-9]+)/) || "", 10);
|
|
64
|
+
if (!Number.isInteger(versionCode) || versionCode <= 0) versionCode = 1;
|
|
65
|
+
// project_types: ["a","b"]
|
|
66
|
+
const typesRaw = line(/^project_types:\s*(\[[^\]]*\])/);
|
|
67
|
+
let types = [];
|
|
68
|
+
if (typesRaw) types = [...typesRaw.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
|
|
69
|
+
// project_paths 블록: " type: "path""
|
|
70
|
+
const paths = new Map();
|
|
71
|
+
let inPaths = false;
|
|
72
|
+
for (const l of text.split("\n")) {
|
|
73
|
+
if (/^project_paths:/.test(l)) { inPaths = true; continue; }
|
|
74
|
+
if (inPaths) {
|
|
75
|
+
const m = l.match(/^\s+([a-z-]+):\s*"([^"]*)"/);
|
|
76
|
+
if (m) paths.set(m[1], m[2]);
|
|
77
|
+
else if (/^\S/.test(l)) inPaths = false; // 들여쓰기 끝 → 블록 종료
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// template: 블록 내 version
|
|
81
|
+
let templateVersion = "";
|
|
82
|
+
let inTemplate = false;
|
|
83
|
+
for (const l of text.split("\n")) {
|
|
84
|
+
if (/^\s*template:/.test(l)) { inTemplate = true; continue; }
|
|
85
|
+
if (inTemplate) {
|
|
86
|
+
const m = l.match(/^\s*version:\s*"([0-9][0-9.]*)"/);
|
|
87
|
+
if (m) { templateVersion = m[1]; break; }
|
|
88
|
+
if (/^\S/.test(l)) break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// 선택 워크플로우 옵션 (metadata.template.options — nexus/secret_backup)
|
|
92
|
+
const options = parseTemplateOptions(text);
|
|
93
|
+
// metadata.template.branches — main/develop/mode (업데이트 모드 재질문 생략용)
|
|
94
|
+
const branches = parseTemplateBranches(text);
|
|
95
|
+
return { version, versionCode, types, paths, templateVersion, options, branches };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// metadata.template.branches 블록 파싱. 셋 다 있어야 유효 — 아니면 null.
|
|
99
|
+
export function parseTemplateBranches(content) {
|
|
100
|
+
const strip = (s) => String(s).replace(/["']/g, "").trim();
|
|
101
|
+
let inTemplate = false;
|
|
102
|
+
let inBranches = false;
|
|
103
|
+
const out = { main: "", develop: "", mode: "" };
|
|
104
|
+
for (const line of String(content || "").split("\n")) {
|
|
105
|
+
if (/^\s*template:/.test(line)) { inTemplate = true; continue; }
|
|
106
|
+
if (inTemplate && /^\s+branches:/.test(line)) { inBranches = true; continue; }
|
|
107
|
+
if (inTemplate && inBranches) {
|
|
108
|
+
let m = line.match(/^\s+main:\s*(.+)/);
|
|
109
|
+
if (m) { out.main = strip(m[1]); continue; }
|
|
110
|
+
m = line.match(/^\s+develop:\s*(.+)/);
|
|
111
|
+
if (m) { out.develop = strip(m[1]); continue; }
|
|
112
|
+
m = line.match(/^\s+mode:\s*(.+)/);
|
|
113
|
+
if (m) { out.mode = strip(m[1].split("#")[0]); continue; }
|
|
114
|
+
if (/^\s{0,4}[a-z_]+:/.test(line)) { inBranches = false; }
|
|
115
|
+
}
|
|
116
|
+
if (inTemplate && /^[a-z_]+:/.test(line)) { inTemplate = false; inBranches = false; }
|
|
117
|
+
}
|
|
118
|
+
return out.main && out.develop && out.mode ? out : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// version.yml 전체 생성 — payload/version.yml.template 렌더링.
|
|
122
|
+
// opts: { templateText, version, types:[], primaryType?, paths:Map, pathMarkers?:Map,
|
|
123
|
+
// branch, branches?, versionCode, now, today, templateOptions?, deployValues? }
|
|
124
|
+
// templateText = payload/version.yml.template 원문 (readVersionYmlTemplate — 필수)
|
|
125
|
+
// now = "YYYY-MM-DD HH:MM:SS" (UTC) — 결정성 위해 주입 / today = "YYYY-MM-DD"
|
|
126
|
+
// branches = { main, develop, mode } (resolveBranchConfig 결과. 없으면 branch 기반 기본값)
|
|
127
|
+
// pathMarkers = Map<type, markerFilename> (project_paths 주석용)
|
|
128
|
+
// templateOptions = { templateVersion, includeNexus, includeSecretBackup, includeCodeRabbit?, optionsDate }
|
|
129
|
+
export function buildVersionYml({
|
|
130
|
+
templateText, version, types = [], primaryType, paths = new Map(), pathMarkers = new Map(),
|
|
131
|
+
branch = "main", branches = null, versionCode = 1, now, today,
|
|
132
|
+
templateOptions = null, deployValues = new Map(),
|
|
133
|
+
}) {
|
|
134
|
+
if (!templateText) throw new Error("version.yml.template 원문이 필요합니다 (payload/version.yml.template 누락?)");
|
|
135
|
+
const typesJson = types.length ? `[${types.map((t) => `"${t}"`).join(", ")}]` : `["basic"]`;
|
|
136
|
+
const primary = primaryType || types[0] || "basic";
|
|
137
|
+
const b = branches || { main: branch || "main", develop: "develop", mode: "pr-flow" };
|
|
138
|
+
const {
|
|
139
|
+
templateVersion = "unknown", includeNexus = false, includeSecretBackup = false,
|
|
140
|
+
includeCodeRabbit = false, optionsDate = today,
|
|
141
|
+
} = templateOptions || {};
|
|
142
|
+
|
|
143
|
+
// project_paths 블록 (full-line 토큰 {{PROJECT_PATHS}} — 없으면 라인 제거)
|
|
144
|
+
let pathsBlock = "";
|
|
145
|
+
if (paths.size) {
|
|
146
|
+
const rows = [`project_paths: # 타입별 프로젝트 폴더 (레포 루트 기준 상대경로)`];
|
|
147
|
+
for (const [t, p] of paths) {
|
|
148
|
+
const marker = pathMarkers.get(t) || "";
|
|
149
|
+
const pf = p === "." ? marker : (marker ? `${p}/${marker}` : p);
|
|
150
|
+
rows.push(marker ? ` ${t}: "${p}" # ${pf}` : ` ${t}: "${p}"`);
|
|
151
|
+
}
|
|
152
|
+
pathsBlock = rows.join("\n");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// deploy 블록 (full-line 토큰 {{DEPLOY}} — WF ask 값이 있는 타입만, 앞에 빈 줄 1개)
|
|
156
|
+
let deployBlock = "";
|
|
157
|
+
const deployTypes = [...deployValues.keys()].filter((t) => deployValues.get(t) && deployValues.get(t).size > 0);
|
|
158
|
+
if (deployTypes.length) {
|
|
159
|
+
const rows = ["", "deploy: # 마법사가 기억하는 배포 설정 (비민감 / 직접 수정 가능)"];
|
|
160
|
+
for (const t of deployTypes) {
|
|
161
|
+
rows.push(` ${t}:`);
|
|
162
|
+
for (const [k, v] of deployValues.get(t)) rows.push(` ${k}: "${v}"`);
|
|
163
|
+
}
|
|
164
|
+
deployBlock = rows.join("\n");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const scalars = {
|
|
168
|
+
VERSION: version, VERSION_CODE: String(versionCode),
|
|
169
|
+
PROJECT_TYPES: typesJson, PROJECT_TYPE: primary,
|
|
170
|
+
NOW: now, TODAY: today || optionsDate, DEFAULT_BRANCH: branch,
|
|
171
|
+
TEMPLATE_VERSION: templateVersion,
|
|
172
|
+
MAIN_BRANCH: b.main, DEVELOP_BRANCH: b.develop, BRANCH_MODE: b.mode,
|
|
173
|
+
OPT_NEXUS: String(includeNexus), OPT_SECRET_BACKUP: String(includeSecretBackup),
|
|
174
|
+
OPT_CODERABBIT: String(includeCodeRabbit),
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const out = [];
|
|
178
|
+
for (const line of String(templateText).split("\n")) {
|
|
179
|
+
const t = line.trim();
|
|
180
|
+
if (t === "{{PROJECT_PATHS}}") { if (pathsBlock) out.push(pathsBlock); continue; }
|
|
181
|
+
if (t === "{{DEPLOY}}") { if (deployBlock) out.push(deployBlock); continue; }
|
|
182
|
+
out.push(line.replace(/\{\{([A-Z][A-Z0-9_]*)\}\}/g, (_, name) => {
|
|
183
|
+
if (name in scalars) return scalars[name];
|
|
184
|
+
throw new Error(`version.yml.template에 알 수 없는 플레이스홀더: {{${name}}}`);
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
let text = out.join("\n");
|
|
188
|
+
if (!text.endsWith("\n")) text += "\n";
|
|
189
|
+
return text.replace(/\n{3,}$/, "\n"); // 말미 과잉 빈 줄 정리
|
|
190
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// @wizard env 토큰 엔진 (.sh configure_workflow_env / _wf_set_env / _wf_is_unchanged 등가).
|
|
2
|
+
// ⚠️ YAML 파싱/재직렬화 금지 — 라인 단위 문자열 처리 (포맷·주석 보존이 unchanged 판정 전제).
|
|
3
|
+
// 실측 기준: template_integrator.sh 3282~3360, 3003~3012.
|
|
4
|
+
|
|
5
|
+
// KEY 정규식: .sh는 [A-Z_]+ (대문자+언더스코어만). ask/auto 마커가 있는 라인만 대상.
|
|
6
|
+
const MARKER_RE = /#\s*@wizard\s+(ask|auto):(.*)$/;
|
|
7
|
+
const KEY_RE = /^(\s*)([A-Z_]+):/;
|
|
8
|
+
const PATHS_ANCHOR_RE = /#\s*@wizard\s+paths-anchor/;
|
|
9
|
+
|
|
10
|
+
// 한 라인을 파싱해 {indent,key,action,arg} 반환. ask/auto 마커 없으면 null.
|
|
11
|
+
export function parseWizardLine(line) {
|
|
12
|
+
const marker = line.match(MARKER_RE);
|
|
13
|
+
if (!marker) return null;
|
|
14
|
+
const km = line.match(KEY_RE);
|
|
15
|
+
if (!km) return null; // KEY: 형식 아니면 (예: paths-anchor 주석) 무시
|
|
16
|
+
return { indent: km[1], key: km[2], action: marker[1], arg: marker[2].trim() };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// .sh _wf_set_env 등가: `KEY: "..."` 따옴표 안 값 치환 + 그 줄 끝 `# @wizard ...` 주석 제거.
|
|
20
|
+
// 라인 하나에 대해 수행. value가 빈문자면 (.sh는 [ -n "$_val" ] 가드) 치환 스킵.
|
|
21
|
+
export function setEnvLine(line, key, value) {
|
|
22
|
+
if (value === "" || value == null) return line;
|
|
23
|
+
// CRLF 안전: 라인 끝 \r을 분리해 처리 후 복원 (autocrlf 프로젝트 대응)
|
|
24
|
+
const cr = line.endsWith("\r") ? "\r" : "";
|
|
25
|
+
const body = cr ? line.slice(0, -1) : line;
|
|
26
|
+
// 값 치환: KEY: "기존값" → KEY: "value"
|
|
27
|
+
let out = body.replace(
|
|
28
|
+
new RegExp(`^(\\s*${key}:\\s*")[^"]*(")`),
|
|
29
|
+
(_m, p1, p2) => `${p1}${value}${p2}`,
|
|
30
|
+
);
|
|
31
|
+
// 그 줄 끝 # @wizard ... 주석 제거 (앞 공백째)
|
|
32
|
+
out = out.replace(/(\S)[^\S\r\n]*#[^\S\r\n]*@wizard[^\S\r\n].*$/, "$1");
|
|
33
|
+
return out + cr;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// resolver — .sh resolve_token 등가. 값 계산은 주입된 resolvers로 위임(순수성 유지).
|
|
37
|
+
// resolvers: { repo, "spring-app-yml-dir"(type), "spring-app-yml-path"(type), "flutter-root" }
|
|
38
|
+
export function resolveToken(name, type, resolvers = {}) {
|
|
39
|
+
const fn = resolvers[name];
|
|
40
|
+
return typeof fn === "function" ? (fn(type) ?? "") : "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 파일 전체 치환 (configure_workflow_env 등가).
|
|
44
|
+
// content: 원본 워크플로우 텍스트. 반환: 치환된 텍스트.
|
|
45
|
+
// opts:
|
|
46
|
+
// type - 프로젝트 타입 (resolver·값 조회용)
|
|
47
|
+
// values - Map<key,value>: ask 키의 사용자 선택값 (없으면 기본값=arg 또는 resolver)
|
|
48
|
+
// useDefaults - true면 ask도 기본값 사용 (WF_USE_DEFAULTS=true, unchanged 비교의 전제)
|
|
49
|
+
// resolvers - resolveToken용
|
|
50
|
+
// repoName - __PROJECT_NAME__/__APP_ARTIFACT_NAME__ 치환값
|
|
51
|
+
// projectPath - paths-anchor 치환용 ('.'이면 anchor 미변경)
|
|
52
|
+
export function substituteEnv(content, opts = {}) {
|
|
53
|
+
const { type = "", values = new Map(), useDefaults = true, resolvers = {}, repoName = "", projectPath = ".", collectAsks = null } = opts;
|
|
54
|
+
if (!content.includes("@wizard")) return content;
|
|
55
|
+
|
|
56
|
+
// CRLF 안전: EOL을 분리해 LF 기준으로 파싱·치환하고, 원래 EOL 스타일을 복원한다.
|
|
57
|
+
// (JS 정규식의 `.`은 \r을 매칭하지 않아 `(.*)$` 마커 파싱이 CRLF에서 실패하기 때문.)
|
|
58
|
+
const usesCRLF = content.includes("\r\n");
|
|
59
|
+
const lines = content.split(/\r?\n/);
|
|
60
|
+
for (let i = 0; i < lines.length; i++) {
|
|
61
|
+
const p = parseWizardLine(lines[i]); // 이미 \r 제거된 라인
|
|
62
|
+
if (!p) continue;
|
|
63
|
+
let val = "";
|
|
64
|
+
if (p.action === "auto") {
|
|
65
|
+
val = resolveToken(p.arg, type, resolvers);
|
|
66
|
+
} else { // ask
|
|
67
|
+
let def = p.arg.startsWith("@") ? resolveToken(p.arg.slice(1), type, resolvers) : p.arg;
|
|
68
|
+
const chosen = values.get(p.key);
|
|
69
|
+
if (chosen != null && chosen !== "" && !useDefaults) val = chosen;
|
|
70
|
+
else val = def;
|
|
71
|
+
// ask 키만 수집 (.sh wf_deploy_set — auto는 저장 안 함). deploy 블록용.
|
|
72
|
+
if (collectAsks) collectAsks.set(p.key, val);
|
|
73
|
+
}
|
|
74
|
+
lines[i] = setEnvLine(lines[i], p.key, val);
|
|
75
|
+
}
|
|
76
|
+
let out = lines.join(usesCRLF ? "\r\n" : "\n");
|
|
77
|
+
|
|
78
|
+
// 잔여 전역 토큰 (.sh 3347~3351)
|
|
79
|
+
if (out.includes("__PROJECT_NAME__") || out.includes("__APP_ARTIFACT_NAME__")) {
|
|
80
|
+
out = out.replaceAll("__PROJECT_NAME__", repoName).replaceAll("__APP_ARTIFACT_NAME__", repoName);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// paths-anchor (.sh 3353~3360): 경로가 '.'이 아니면 주석 라인 전체를 paths 라인으로 교체
|
|
84
|
+
if (PATHS_ANCHOR_RE.test(out) && projectPath && projectPath !== ".") {
|
|
85
|
+
const eol = out.includes("\r\n") ? "\r\n" : "\n";
|
|
86
|
+
out = out.split(/\r?\n/).map((line) => {
|
|
87
|
+
if (PATHS_ANCHOR_RE.test(line)) {
|
|
88
|
+
const indent = (line.match(/^(\s*)/) || ["", ""])[1];
|
|
89
|
+
return `${indent}paths: ['${projectPath}/**']`;
|
|
90
|
+
}
|
|
91
|
+
return line;
|
|
92
|
+
}).join(eol);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// .sh _wf_is_unchanged 등가: 원본을 "기본값으로 가상 치환한 최종형"과 설치본을 바이트 비교.
|
|
98
|
+
export function isUnchanged(templateContent, installedContent, opts = {}) {
|
|
99
|
+
const virtual = substituteEnv(templateContent, { ...opts, useDefaults: true });
|
|
100
|
+
return virtual === installedContent;
|
|
101
|
+
}
|