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,26 @@
|
|
|
1
|
+
// .sh compare_versions 등가: v 접두 제거, 3자리 숫자 비교, 누락 자리=0
|
|
2
|
+
export function compareVersions(a, b) {
|
|
3
|
+
const parse = (v) => String(v).replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
|
|
4
|
+
const pa = parse(a), pb = parse(b);
|
|
5
|
+
for (let i = 0; i < 3; i++) {
|
|
6
|
+
const x = pa[i] ?? 0, y = pb[i] ?? 0;
|
|
7
|
+
if (x > y) return 1;
|
|
8
|
+
if (x < y) return -1;
|
|
9
|
+
}
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// breaking-changes.json에서 current < ver <= target 범위 항목 수집.
|
|
14
|
+
// ⚠️ .sh 버그 수정: target은 하드코딩 1.3.14가 아니라 실제 templateVersion을 넘긴다 (D2).
|
|
15
|
+
// _ 로 시작하는 키(메타) 제외. severity critical / 그 외(warning).
|
|
16
|
+
export function collectBreaking(json, current, target) {
|
|
17
|
+
const critical = [], warnings = [];
|
|
18
|
+
for (const [ver, entry] of Object.entries(json || {})) {
|
|
19
|
+
if (ver.startsWith("_")) continue;
|
|
20
|
+
if (compareVersions(current, ver) < 0 && compareVersions(ver, target) <= 0) {
|
|
21
|
+
const rec = { version: ver, ...entry };
|
|
22
|
+
(entry?.severity === "critical" ? critical : warnings).push(rec);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { critical, warnings };
|
|
26
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// .coderabbit.yaml 복사 (.sh copy_coderabbit_config 등가, force 경로).
|
|
2
|
+
// 소스는 payload/coderabbit.yaml (npm 패키징 안전을 위해 무점 파일명으로 동봉 → 대상엔 .coderabbit.yaml).
|
|
3
|
+
// Task 15에서 coderabbit opt-in true일 때만 호출하도록 재배선 예정.
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { exists, copyFileSync } from "../fsutil.js";
|
|
6
|
+
|
|
7
|
+
// 반환: 'skip-no-src' | 'copied-new' | 'overwritten-backup' | 'skip-non-tty'
|
|
8
|
+
// opts: { force, tty }
|
|
9
|
+
export function copyCoderabbit(payloadRoot, { force = false, tty = false } = {}, targetRoot = ".") {
|
|
10
|
+
const src = join(payloadRoot, "coderabbit.yaml");
|
|
11
|
+
if (!exists(src)) return "skip-no-src";
|
|
12
|
+
const dst = join(targetRoot, ".coderabbit.yaml");
|
|
13
|
+
|
|
14
|
+
if (exists(dst)) {
|
|
15
|
+
if (force) {
|
|
16
|
+
copyFileSync(dst, dst + ".bak"); // 백업 후 덮어쓰기
|
|
17
|
+
copyFileSync(src, dst);
|
|
18
|
+
return "overwritten-backup";
|
|
19
|
+
}
|
|
20
|
+
if (!tty) return "skip-non-tty"; // 비TTY & !force → 유지
|
|
21
|
+
// 대화형 메뉴는 SP2-C — 여기서는 force가 아니면 유지
|
|
22
|
+
return "skip-non-tty";
|
|
23
|
+
}
|
|
24
|
+
copyFileSync(src, dst); // 신규
|
|
25
|
+
return "copied-new";
|
|
26
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// .gitignore 보장 (.sh ensure_gitignore + normalize/check 등가) — template_integrator.sh 3996~4111.
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
const REQUIRED_ENTRIES = ["/.idea", "/.claude/settings.local.json"];
|
|
6
|
+
|
|
7
|
+
// .sh normalize_gitignore_entry: 주석 제거·트림·앞 / 제거·앞 ./ 제거·뒤 / 제거. 빈값이면 원본.
|
|
8
|
+
export function normalizeGitignoreEntry(entry) {
|
|
9
|
+
let e = String(entry);
|
|
10
|
+
e = e.replace(/#.*$/, ""); // 주석 제거
|
|
11
|
+
e = e.trim(); // 앞뒤 공백
|
|
12
|
+
e = e.replace(/^\//, ""); // 앞 /
|
|
13
|
+
e = e.replace(/^\.\//, ""); // 앞 ./
|
|
14
|
+
e = e.replace(/\/$/, ""); // 뒤 /
|
|
15
|
+
return e === "" ? String(entry) : e;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function entryExists(target, content) {
|
|
19
|
+
const nt = normalizeGitignoreEntry(target);
|
|
20
|
+
for (const line of content.split("\n")) {
|
|
21
|
+
if (/^\s*#/.test(line)) continue;
|
|
22
|
+
if (/^\s*$/.test(line)) continue;
|
|
23
|
+
if (normalizeGitignoreEntry(line) === nt) return true;
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const NEW_FILE_CONTENT =
|
|
29
|
+
"# IDE Settings\n" +
|
|
30
|
+
"/.idea\n" +
|
|
31
|
+
"\n" +
|
|
32
|
+
"# Claude AI Settings\n" +
|
|
33
|
+
"/.claude/settings.local.json\n";
|
|
34
|
+
|
|
35
|
+
// 반환: {created, added:[...]}
|
|
36
|
+
export function ensureGitignore(targetRoot = ".") {
|
|
37
|
+
const p = join(targetRoot, ".gitignore");
|
|
38
|
+
if (!existsSync(p)) {
|
|
39
|
+
writeFileSync(p, NEW_FILE_CONTENT);
|
|
40
|
+
return { created: true, added: REQUIRED_ENTRIES.slice() };
|
|
41
|
+
}
|
|
42
|
+
let content = readFileSync(p, "utf8");
|
|
43
|
+
const toAdd = REQUIRED_ENTRIES.filter((e) => !entryExists(e, content));
|
|
44
|
+
if (toAdd.length === 0) return { created: false, added: [] };
|
|
45
|
+
|
|
46
|
+
// 파일 끝에 개행 없으면 추가 (.sh: tail -c 1 이 non-empty면 echo "")
|
|
47
|
+
if (content.length > 0 && !content.endsWith("\n")) content += "\n";
|
|
48
|
+
content += "\n";
|
|
49
|
+
content += "# ====================================================================\n";
|
|
50
|
+
content += "# project-auto-wizard: Auto-added entries\n";
|
|
51
|
+
content += "# ====================================================================\n";
|
|
52
|
+
for (const e of toAdd) content += e + "\n";
|
|
53
|
+
writeFileSync(p, content);
|
|
54
|
+
return { created: false, added: toAdd };
|
|
55
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// README 버전 섹션 추가 (.sh add_version_section_to_readme 등가) — template_integrator.sh 2145~2181.
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { existsSync, readFileSync, appendFileSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
const MARKER = "<!-- AUTO-VERSION-SECTION";
|
|
6
|
+
// ## (최신 버전|최신버전|Version|버전) : vX.Y.Z (대소문자 무시)
|
|
7
|
+
const VERSION_LINE_RE = /##\s*(최신\s*버전|최신버전|Version|버전)\s*:\s*v[0-9]+\.[0-9]+\.[0-9]+/i;
|
|
8
|
+
|
|
9
|
+
// README.md 없으면 스킵. 마커 또는 버전 라인 있으면 스킵. 없으면 파일 끝에 append.
|
|
10
|
+
// 반환: 'skip-no-readme' | 'skip-marker' | 'skip-version-line' | 'added'
|
|
11
|
+
export function addVersionSectionToReadme(version, targetRoot = ".") {
|
|
12
|
+
const p = join(targetRoot, "README.md");
|
|
13
|
+
if (!existsSync(p)) return "skip-no-readme";
|
|
14
|
+
const content = readFileSync(p, "utf8");
|
|
15
|
+
if (content.includes(MARKER)) return "skip-marker";
|
|
16
|
+
if (VERSION_LINE_RE.test(content)) return "skip-version-line";
|
|
17
|
+
|
|
18
|
+
// .sh: cat >> README.md << EOF — EOF 다음 첫 줄이 빈 줄이므로 append 본문은 "\n---\n..."로 시작.
|
|
19
|
+
// (원본 파일이 개행으로 끝난다는 전제는 .sh와 동일 — heredoc은 원본 끝에 그대로 붙는다.)
|
|
20
|
+
const section =
|
|
21
|
+
"\n" +
|
|
22
|
+
"---\n" +
|
|
23
|
+
"\n" +
|
|
24
|
+
"<!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->\n" +
|
|
25
|
+
`## 최신 버전 : v${version}\n` +
|
|
26
|
+
"\n" +
|
|
27
|
+
"[전체 버전 기록 보기](CHANGELOG.md)\n";
|
|
28
|
+
appendFileSync(p, section);
|
|
29
|
+
return "added";
|
|
30
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// 단순 복사 함수 (무조건 덮어쓰기류).
|
|
2
|
+
// payload 단일 진실: 스크립트는 payload/scripts/*.py → 사용자 레포 .github/scripts/ 로 설치된다.
|
|
3
|
+
// 워크플로우 전부가 이 경로(python3 .github/scripts/*.py)를 호출하므로 누락 시 설치물이 런타임에 죽는다.
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { chmodSync } from "node:fs";
|
|
6
|
+
import { PATHS, PAYLOAD } from "../paths.js";
|
|
7
|
+
import { exists, copyFileSync } from "../fsutil.js";
|
|
8
|
+
|
|
9
|
+
// version_manager.py, changelog_manager.py 무조건 덮어쓰기 (+chmod — Windows에선 무의미하나 무해).
|
|
10
|
+
export function copyScripts(payloadRoot, targetRoot = ".") {
|
|
11
|
+
const scripts = ["version_manager.py", "changelog_manager.py"];
|
|
12
|
+
let copied = 0;
|
|
13
|
+
for (const s of scripts) {
|
|
14
|
+
const src = join(payloadRoot, PAYLOAD.scriptsDir, s);
|
|
15
|
+
if (exists(src)) {
|
|
16
|
+
const dst = join(targetRoot, PATHS.scriptsDir, s);
|
|
17
|
+
copyFileSync(src, dst);
|
|
18
|
+
try { chmodSync(dst, 0o755); } catch { /* Windows 등 chmod 무의미 */ }
|
|
19
|
+
copied++;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return copied;
|
|
23
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// 워크플로우 복사 엔진 (.sh copy_workflows + _copy_workflows_for_type 등가).
|
|
2
|
+
// 실측: template_integrator.sh 3398~3815.
|
|
3
|
+
// 대화형 3지선(기존 파일 충돌)은 copyWorkflowsInteractive(async)가 결정 Map을 만들어
|
|
4
|
+
// 동기 엔진(copyWorkflows)에 hooks.decisions로 전달한다 — 기존 시그니처·force 동작 무변경.
|
|
5
|
+
import { join, basename } from "node:path";
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
7
|
+
import { PATHS, PAYLOAD } from "../paths.js";
|
|
8
|
+
import { exists, writeText, listYamlFiles } from "../fsutil.js";
|
|
9
|
+
import { isUnchanged, substituteEnv } from "../wizard-env.js";
|
|
10
|
+
import { substitute } from "../branding.js";
|
|
11
|
+
|
|
12
|
+
// 원본 텍스트 로더 — context.branches가 있으면 {{MAIN_BRANCH}}/{{DEVELOP_BRANCH}} 치환 적용.
|
|
13
|
+
// classify(unchanged 판정)와 실제 복사가 같은 치환본을 봐야 재실행 시 가짜 충돌이 없다.
|
|
14
|
+
function makeSrcText(branches) {
|
|
15
|
+
return (p) => {
|
|
16
|
+
const raw = readFileSync(p, "utf8");
|
|
17
|
+
return branches ? substitute(raw, branches) : raw;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// trunk-based 모드에서 설치하지 않는 common 워크플로우 (DESIGN-SPEC §4 설치 매트릭스).
|
|
22
|
+
// 릴리스 PR 흐름이 없으므로 RELEASE-PUBLISH 하나가 bump→changelog→tag→Release를 흡수한다.
|
|
23
|
+
const TRUNK_BASED_EXCLUDED = new Set([
|
|
24
|
+
"PROJECT-COMMON-VERSION-CONTROL.yaml",
|
|
25
|
+
"PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// 한 파일에 env 치환을 적용해 대상 파일을 갱신 (.sh configure_workflow_env 등가).
|
|
29
|
+
// values/useDefaults: env 계획(promptEnvPlan) 결과 — 미지정이면 기본값 경로(현행 force 동작).
|
|
30
|
+
function configureEnv(targetPath, { type, projectPath = ".", repoName = "", resolvers = {}, collectAsks = null, values = new Map(), useDefaults = true }) {
|
|
31
|
+
const content = readFileSync(targetPath, "utf8");
|
|
32
|
+
if (!content.includes("@wizard")) return;
|
|
33
|
+
const out = substituteEnv(content, { type, useDefaults, values, projectPath, repoName, resolvers, collectAsks });
|
|
34
|
+
writeFileSync(targetPath, out);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 3분류 (신규/unchanged/changed) — 대상 워크플로우 디렉토리 기준.
|
|
38
|
+
// srcText: 브랜치 치환이 적용된 원본 로더 (makeSrcText).
|
|
39
|
+
function classify(srcDir, workflowsDir, envOpts, srcText) {
|
|
40
|
+
const result = { newFiles: [], unchanged: [], changed: [] };
|
|
41
|
+
for (const filename of listYamlFiles(srcDir)) {
|
|
42
|
+
const src = join(srcDir, filename);
|
|
43
|
+
const dst = join(workflowsDir, filename);
|
|
44
|
+
if (existsSync(dst)) {
|
|
45
|
+
const tpl = srcText(src);
|
|
46
|
+
const inst = readFileSync(dst, "utf8");
|
|
47
|
+
if (isUnchanged(tpl, inst, envOpts)) result.unchanged.push(filename);
|
|
48
|
+
else result.changed.push(filename);
|
|
49
|
+
} else {
|
|
50
|
+
result.newFiles.push(filename);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// copy_workflows 본체 (동기 — 기존 호출부 무변경).
|
|
57
|
+
// context: { types:[], paths:Map, includeNexus, includeSecretBackup, force, repoName, resolvers,
|
|
58
|
+
// envValues?:Map<key,value>, envUseDefaults?:boolean } ← env 계획(promptEnvPlan) 결과 주입점
|
|
59
|
+
// hooks: { decisions?: Map<filename, 'skip'|'backup'|'template'> } — 기존 파일(changed) 충돌 결정.
|
|
60
|
+
// 미지정 파일은 'skip'(현행 force 동작 100% 유지). 대화형 수집은 copyWorkflowsInteractive 참조.
|
|
61
|
+
// 반환: {copied, skipped, templateAdded, optionalCopied}
|
|
62
|
+
export function copyWorkflows(context, payloadRoot, targetRoot = ".", hooks = {}) {
|
|
63
|
+
const { types = [], paths = new Map(), includeNexus = false, includeSecretBackup = false, repoName = "", resolvers = {}, envValues = new Map(), envUseDefaults = true } = context;
|
|
64
|
+
const decisions = hooks.decisions instanceof Map ? hooks.decisions : new Map();
|
|
65
|
+
const workflowsDir = join(targetRoot, PATHS.workflowsDir);
|
|
66
|
+
const projectTypesDir = join(payloadRoot, PAYLOAD.workflowsDir);
|
|
67
|
+
if (!exists(projectTypesDir)) throw new Error("패키지 구조 오류 — payload/workflows 폴더를 찾지 못했습니다.");
|
|
68
|
+
|
|
69
|
+
const counters = { copied: 0, skipped: 0, templateAdded: 0, optionalCopied: 0 };
|
|
70
|
+
const deployValues = new Map(); // Map<type, Map<key,value>> — deploy 블록용 ask 값
|
|
71
|
+
counters.deployValues = deployValues;
|
|
72
|
+
const srcText = makeSrcText(context.branches || null);
|
|
73
|
+
// values/useDefaults는 치환 경로에서만 의미 (isUnchanged는 내부에서 useDefaults:true 강제 — 가상 비교 무손상)
|
|
74
|
+
const envOptsFor = (type) => ({ type, projectPath: paths.get(type) || ".", repoName, resolvers, values: envValues, useDefaults: envUseDefaults });
|
|
75
|
+
|
|
76
|
+
// (1) common — unchanged면 스킵, 아니면 무조건 덮어쓰기.
|
|
77
|
+
// trunk-based 모드는 VERSION-CONTROL·AUTO-CHANGELOG 미설치 (RELEASE-PUBLISH 단독).
|
|
78
|
+
const branchMode = context.branches?.mode || "pr-flow";
|
|
79
|
+
const commonDir = join(projectTypesDir, "common");
|
|
80
|
+
if (exists(commonDir)) {
|
|
81
|
+
for (const filename of listYamlFiles(commonDir)) {
|
|
82
|
+
if (branchMode === "trunk-based" && TRUNK_BASED_EXCLUDED.has(filename)) continue;
|
|
83
|
+
const src = join(commonDir, filename);
|
|
84
|
+
const dst = join(workflowsDir, filename);
|
|
85
|
+
const body = srcText(src);
|
|
86
|
+
if (existsSync(dst) && isUnchanged(body, readFileSync(dst, "utf8"), envOptsFor("common"))) {
|
|
87
|
+
counters.skipped++;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
writeText(dst, body);
|
|
91
|
+
counters.copied++;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// (2~4) 타입별
|
|
96
|
+
for (const type of types) {
|
|
97
|
+
const asks = new Map();
|
|
98
|
+
copyWorkflowsForType(type, projectTypesDir, workflowsDir, { includeNexus, ...context, envOptsFor, collectAsks: asks, decisions, srcText }, counters);
|
|
99
|
+
if (asks.size) deployValues.set(type, asks);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// (5) common/secret-backup — 있으면 무조건 스킵/신규만 복사
|
|
103
|
+
const secretDir = join(commonDir, "secret-backup");
|
|
104
|
+
if (exists(secretDir) && includeSecretBackup) {
|
|
105
|
+
for (const filename of listYamlFiles(secretDir)) {
|
|
106
|
+
const dst = join(workflowsDir, filename);
|
|
107
|
+
if (existsSync(dst)) continue; // 이미 존재하면 스킵
|
|
108
|
+
writeText(dst, srcText(join(secretDir, filename)));
|
|
109
|
+
counters.optionalCopied++;
|
|
110
|
+
counters.copied++;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return counters;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// changed(기존에 있고 내용이 바뀐) 파일 1개를 결정에 따라 처리 (.sh 3440~3508 3지선 case 등가).
|
|
118
|
+
// 'skip'(기본): 기존 유지. 'backup': 기존→.bak 후 교체. 'template': 기존 유지 + 새 버전을 .template.yaml로.
|
|
119
|
+
function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcText) {
|
|
120
|
+
const src = join(srcDir, filename);
|
|
121
|
+
const dst = join(workflowsDir, filename);
|
|
122
|
+
if (decision === "backup") {
|
|
123
|
+
// .sh O) mv → cp: 기존을 .bak으로 백업 후 새 버전으로 교체
|
|
124
|
+
renameSync(dst, dst + ".bak");
|
|
125
|
+
writeText(dst, srcText(src));
|
|
126
|
+
counters.copied++;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (decision === "template") {
|
|
130
|
+
// .sh T) `${filename%.yaml}.template.yaml` — .yaml만 strip (.yml은 그대로 뒤에 붙음, .sh 동일)
|
|
131
|
+
const templateName = (filename.endsWith(".yaml") ? filename.slice(0, -".yaml".length) : filename) + ".template.yaml";
|
|
132
|
+
writeText(join(workflowsDir, templateName), srcText(src)); // 기존 .template.yaml 덮어씀(.sh rm -f + cp 등가)
|
|
133
|
+
counters.templateAdded++;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
counters.skipped++; // 'skip'/미지정/ESC → 기존 유지 (.sh S)·force 기본)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 대상 워크플로우 디렉토리에서 changed(충돌) 파일 목록만 뽑는다 — copyWorkflowsInteractive의 사전 조사용.
|
|
140
|
+
// copyWorkflows 본체와 동일한 classify 기준을 써야 결정 Map이 실제 처리 대상과 1:1로 맞는다.
|
|
141
|
+
export function listWorkflowConflicts(context, payloadRoot, targetRoot = ".") {
|
|
142
|
+
const { types = [], paths = new Map(), includeNexus = false, repoName = "", resolvers = {} } = context;
|
|
143
|
+
const workflowsDir = join(targetRoot, PATHS.workflowsDir);
|
|
144
|
+
const projectTypesDir = join(payloadRoot, PAYLOAD.workflowsDir);
|
|
145
|
+
const srcText = makeSrcText(context.branches || null);
|
|
146
|
+
const conflicts = []; // [{ filename, type }] — 엔진 처리 순서와 동일 (타입 순회 → 직하위 → server-deploy)
|
|
147
|
+
for (const type of types) {
|
|
148
|
+
const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers };
|
|
149
|
+
const typeDir = join(projectTypesDir, type);
|
|
150
|
+
if (exists(typeDir)) {
|
|
151
|
+
for (const f of classify(typeDir, workflowsDir, envOpts, srcText).changed) conflicts.push({ filename: f, type });
|
|
152
|
+
}
|
|
153
|
+
const serverDeployDir = join(typeDir, "server-deploy");
|
|
154
|
+
if (exists(serverDeployDir) && !includeNexus) {
|
|
155
|
+
for (const f of classify(serverDeployDir, workflowsDir, envOpts, srcText).changed) conflicts.push({ filename: f, type });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return conflicts;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 대화형 진입점 (async) — 충돌마다 onConflict(filename, type)를 await해 결정 Map을 만든 뒤
|
|
162
|
+
// 동기 엔진에 위임한다. WHY 분리: copyWorkflows를 async로 바꾸면 await 없이 호출하는
|
|
163
|
+
// 기존 호출부(runFull/runWorkflows)가 깨진다 — 시그니처 무변경 원칙.
|
|
164
|
+
// onConflict 반환값: 'template' | 'skip' | 'backup' (그 외/미지정 → 'skip').
|
|
165
|
+
export async function copyWorkflowsInteractive(context, payloadRoot, targetRoot = ".", { onConflict } = {}) {
|
|
166
|
+
const decisions = new Map();
|
|
167
|
+
if (typeof onConflict === "function") {
|
|
168
|
+
for (const { filename, type } of listWorkflowConflicts(context, payloadRoot, targetRoot)) {
|
|
169
|
+
if (decisions.has(filename)) continue; // 파일명은 PROJECT-{TYPE}- prefix로 타입 간 유일
|
|
170
|
+
decisions.set(filename, await onConflict(filename, type));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return copyWorkflows(context, payloadRoot, targetRoot, { decisions });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters) {
|
|
177
|
+
const { includeNexus, envOptsFor, collectAsks = null, decisions = new Map(), srcText } = ctx;
|
|
178
|
+
const typeDir = join(projectTypesDir, type);
|
|
179
|
+
const envOpts = envOptsFor(type);
|
|
180
|
+
let unchangedNames = [];
|
|
181
|
+
|
|
182
|
+
// 타입별 워크플로우 (직하위)
|
|
183
|
+
if (exists(typeDir)) {
|
|
184
|
+
const { newFiles, unchanged, changed } = classify(typeDir, workflowsDir, envOpts, srcText);
|
|
185
|
+
unchangedNames = unchanged.slice();
|
|
186
|
+
for (const f of unchanged) counters.skipped++;
|
|
187
|
+
for (const f of newFiles) { writeText(join(workflowsDir, f), srcText(join(typeDir, f))); counters.copied++; }
|
|
188
|
+
// changed: 결정 Map에 따라 처리 (미지정=skip → 현행 force 동작과 동일)
|
|
189
|
+
for (const f of changed) applyDecision(decisions.get(f), typeDir, workflowsDir, f, counters, srcText);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// server-deploy
|
|
193
|
+
const serverDeployDir = join(typeDir, "server-deploy");
|
|
194
|
+
if (exists(serverDeployDir)) {
|
|
195
|
+
if (includeNexus) {
|
|
196
|
+
// Nexus 프로젝트 → 폴더째 제외 (복사 안 함)
|
|
197
|
+
} else {
|
|
198
|
+
const { newFiles, unchanged, changed } = classify(serverDeployDir, workflowsDir, envOpts, srcText);
|
|
199
|
+
for (const f of unchanged) counters.skipped++;
|
|
200
|
+
for (const f of newFiles) { writeText(join(workflowsDir, f), srcText(join(serverDeployDir, f))); counters.copied++; }
|
|
201
|
+
for (const f of changed) applyDecision(decisions.get(f), serverDeployDir, workflowsDir, f, counters, srcText);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// nexus (opt-in)
|
|
206
|
+
const nexusDir = join(typeDir, "nexus");
|
|
207
|
+
if (exists(nexusDir) && includeNexus) {
|
|
208
|
+
for (const filename of listYamlFiles(nexusDir)) {
|
|
209
|
+
const src = join(nexusDir, filename);
|
|
210
|
+
const dst = join(workflowsDir, filename);
|
|
211
|
+
const body = srcText(src);
|
|
212
|
+
if (existsSync(dst) && isUnchanged(body, readFileSync(dst, "utf8"), envOpts)) {
|
|
213
|
+
counters.skipped++;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (existsSync(dst)) renameSync(dst, dst + ".bak");
|
|
217
|
+
writeText(dst, body);
|
|
218
|
+
counters.optionalCopied++;
|
|
219
|
+
counters.copied++;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// env 치환 — 이 타입의 원본 디렉토리들에서 복사돼 존재하고 unchanged 아닌 파일만
|
|
224
|
+
for (const srcDir of [typeDir, serverDeployDir, nexusDir]) {
|
|
225
|
+
if (!exists(srcDir)) continue;
|
|
226
|
+
for (const filename of listYamlFiles(srcDir)) {
|
|
227
|
+
const target = join(workflowsDir, filename);
|
|
228
|
+
if (!existsSync(target)) continue; // 건너뛴 파일 제외
|
|
229
|
+
if (unchangedNames.includes(filename)) continue; // unchanged 제외
|
|
230
|
+
configureEnv(target, { ...envOpts, collectAsks }); // env 계획 values/useDefaults 포함
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// 실 파일시스템 프로젝트 감지 (.sh detect_* 실행부 등가).
|
|
2
|
+
// SP2-A detect.js 순수 함수를 fs/git으로 구동한다.
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
4
|
+
import { join, basename } from "node:path";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { detectTypesFromMarkers, detectVersionFromFiles } from "./detect.js";
|
|
7
|
+
import { parseExisting } from "./version-yml.js";
|
|
8
|
+
|
|
9
|
+
const hasFile = (root) => (rel) => existsSync(join(root, rel));
|
|
10
|
+
const readFile = (root) => (rel) => {
|
|
11
|
+
try { return readFileSync(join(root, rel), "utf8"); } catch { return null; }
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function gitOut(root, args) {
|
|
15
|
+
try {
|
|
16
|
+
return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
17
|
+
} catch { return ""; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 타입 감지 — version.yml의 project_types 최우선(source of truth), 없으면 마커 스캔.
|
|
21
|
+
export function detectTypes(root) {
|
|
22
|
+
const vy = join(root, "version.yml");
|
|
23
|
+
if (existsSync(vy)) {
|
|
24
|
+
const { types } = parseExisting(readFileSync(vy, "utf8"));
|
|
25
|
+
if (types.length) return types; // basic 포함, 명시돼 있으면 그대로
|
|
26
|
+
}
|
|
27
|
+
return detectTypesFromMarkers({ has: hasFile(root), read: readFile(root) });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 버전 감지 — .sh detect_version 순서. jq 유무는 command 존재로 판정.
|
|
31
|
+
export function detectVersion(root, { hasJq } = {}) {
|
|
32
|
+
const read = readFile(root);
|
|
33
|
+
const readJson = (rel) => { const c = read(rel); try { return c ? JSON.parse(c) : null; } catch { return null; } };
|
|
34
|
+
const jq = hasJq ?? hasCommand("jq");
|
|
35
|
+
const gitTag = gitOut(root, ["describe", "--tags", "--abbrev=0"]);
|
|
36
|
+
return detectVersionFromFiles({ read, readJson, hasJq: jq, gitTag });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 기본 브랜치 감지 — symbolic-ref → remote show → main.
|
|
40
|
+
export function detectDefaultBranch(root) {
|
|
41
|
+
let b = gitOut(root, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
|
|
42
|
+
if (b) return b.replace(/^refs\/remotes\/origin\//, "");
|
|
43
|
+
const show = gitOut(root, ["remote", "show", "origin"]);
|
|
44
|
+
const m = show.match(/HEAD branch:\s*(\S+)/);
|
|
45
|
+
if (m) return m[1];
|
|
46
|
+
return "main";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 레포명 — git remote get-url origin 마지막 세그먼트, 실패 시 폴더명.
|
|
50
|
+
export function detectRepoName(root) {
|
|
51
|
+
const url = gitOut(root, ["remote", "get-url", "origin"]);
|
|
52
|
+
if (url) {
|
|
53
|
+
const seg = url.replace(/\.git$/, "").split(/[/:]/).pop();
|
|
54
|
+
if (seg) return seg;
|
|
55
|
+
}
|
|
56
|
+
return basename(root);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function hasCommand(cmd) {
|
|
60
|
+
try {
|
|
61
|
+
execFileSync(process.platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore" });
|
|
62
|
+
return true;
|
|
63
|
+
} catch { return false; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Spring application*.yml 탐색 (.sh resolve_spring_app_yml_dir/path L2767~2780 등가)
|
|
67
|
+
// find {base} -path "*/src/main/resources/application*.yml" | head -1 의 fs 재귀 구현.
|
|
68
|
+
// 반환: root 기준 상대경로 (예: "server/src/main/resources/application.yml") 또는 "".
|
|
69
|
+
export function findSpringAppYml(root, base = ".") {
|
|
70
|
+
const startRel = base === "." ? "" : base;
|
|
71
|
+
const PRUNE = new Set(["node_modules", ".git", "build", ".gradle", "target", ".idea"]);
|
|
72
|
+
let hit = "";
|
|
73
|
+
const walk = (rel, depth) => {
|
|
74
|
+
if (hit || depth > 8) return; // head -1 등가 — 첫 매치에서 중단
|
|
75
|
+
let entries;
|
|
76
|
+
try { entries = readdirSync(join(root, rel), { withFileTypes: true }); } catch { return; }
|
|
77
|
+
// 정렬로 순회 순서 결정화 (find 순서 플랫폼 편차 제거)
|
|
78
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
79
|
+
if (hit) return;
|
|
80
|
+
const childRel = rel ? `${rel}/${e.name}` : e.name;
|
|
81
|
+
if (e.isDirectory()) {
|
|
82
|
+
if (PRUNE.has(e.name)) continue;
|
|
83
|
+
walk(childRel, depth + 1);
|
|
84
|
+
} else if (/^application.*\.yml$/.test(e.name) && childRel.includes("src/main/resources/")) {
|
|
85
|
+
hit = childRel;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
walk(startRel, 0);
|
|
90
|
+
return hit;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 실 resolver 세트 생성 (.sh resolve_token 4종 등가) — index/interactive 공용.
|
|
94
|
+
// paths: Map<type, path> (모노레포 경로).
|
|
95
|
+
export function makeResolvers(root, repoName, paths) {
|
|
96
|
+
const springBase = (t) => paths.get(t || "spring") || paths.get("spring") || ".";
|
|
97
|
+
return {
|
|
98
|
+
repo: () => repoName,
|
|
99
|
+
"spring-app-yml-dir": (t) => {
|
|
100
|
+
const f = findSpringAppYml(root, springBase(t));
|
|
101
|
+
return f ? f.split("/").slice(0, -1).join("/") : "";
|
|
102
|
+
},
|
|
103
|
+
"spring-app-yml-path": (t) => findSpringAppYml(root, springBase(t)) || "",
|
|
104
|
+
"flutter-root": () => paths.get("flutter") || ".",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// package.json 내용 분류 (.sh classify_package_json 등가) — 원본 파일 텍스트에 대한
|
|
2
|
+
// grep 부분문자열 매칭. dependencies 파싱이 아니라 raw 텍스트 검사여야 등가.
|
|
3
|
+
// 입력은 package.json의 원문 문자열(raw). 순서 중요.
|
|
4
|
+
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";
|
|
8
|
+
}
|
|
9
|
+
if (s.includes('"next"')) return "next";
|
|
10
|
+
if (s.includes('"react"')) return "react";
|
|
11
|
+
return "node";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// 편의: 파싱된 객체를 받는 경우 원문으로 재직렬화해 위 규칙 적용
|
|
15
|
+
export function classifyPackageJson(pkgOrRaw) {
|
|
16
|
+
const raw = typeof pkgOrRaw === "string" ? pkgOrRaw : JSON.stringify(pkgOrRaw || {});
|
|
17
|
+
return classifyPackageText(raw);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 마커 스캔 (동작명세 §3.1). has(relpath)=>bool 주입. node는 다른 타입 있으면 미추가.
|
|
21
|
+
// read(relpath)=>string|null 로 package.json 원문을 받아 classifyPackageText에 넘긴다.
|
|
22
|
+
export function detectTypesFromMarkers({ has, read }) {
|
|
23
|
+
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("package.json")) {
|
|
28
|
+
const cls = classifyPackageText(read ? read("package.json") : "");
|
|
29
|
+
if (cls === "node") { if (types.length === 0) types.push("node"); }
|
|
30
|
+
else types.push(cls);
|
|
31
|
+
}
|
|
32
|
+
return types.length ? [...new Set(types)] : ["basic"];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const VERSION_RE = /^\d+\.\d+\.\d+$/;
|
|
36
|
+
|
|
37
|
+
// 버전 감지 (동작명세 §3.3) — 순서대로 첫 성공. read(relpath)=>string|null 주입.
|
|
38
|
+
export function detectVersionFromFiles({ read, readJson, hasJq, gitTag }) {
|
|
39
|
+
const pkg = readJson?.("package.json");
|
|
40
|
+
if (hasJq && pkg?.version && VERSION_RE.test(pkg.version)) return pkg.version;
|
|
41
|
+
const grab = (content, re) => {
|
|
42
|
+
for (const line of (content || "").split("\n")) {
|
|
43
|
+
const m = line.match(re);
|
|
44
|
+
if (m && VERSION_RE.test(m[1])) return m[1];
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
};
|
|
48
|
+
let v;
|
|
49
|
+
if ((v = grab(read("build.gradle"), /version\s*=\s*["']?(\d+\.\d+\.\d+)/))) return v;
|
|
50
|
+
if ((v = grab(read("pubspec.yaml"), /^version:\s*(\d+\.\d+\.\d+)/))) return v;
|
|
51
|
+
if ((v = grab(read("pyproject.toml"), /version\s*=\s*["']?(\d+\.\d+\.\d+)/))) return v;
|
|
52
|
+
if (gitTag) { const t = String(gitTag).replace(/^v/, ""); if (VERSION_RE.test(t)) return t; }
|
|
53
|
+
return "0.0.1";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function markerForType(type) {
|
|
57
|
+
return { flutter: "pubspec.yaml", "react-native-expo": "app.json", python: "pyproject.toml", spring: "build.gradle" }[type] || "package.json";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function extraMarkers(type) {
|
|
61
|
+
return { python: ["setup.py", "requirements.txt"], spring: ["build.gradle.kts", "pom.xml"] }[type] || [];
|
|
62
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// 파일시스템 공용 유틸 (LF 보존 바이트 복사). 텍스트는 그대로 복사해 원본 줄바꿈 유지.
|
|
2
|
+
import {
|
|
3
|
+
cpSync, existsSync, readFileSync, writeFileSync, mkdirSync,
|
|
4
|
+
readdirSync, rmSync,
|
|
5
|
+
} from "node:fs";
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
|
|
8
|
+
export const exists = (p) => existsSync(p);
|
|
9
|
+
export const readText = (p) => readFileSync(p, "utf8");
|
|
10
|
+
|
|
11
|
+
export function writeText(p, s) {
|
|
12
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
13
|
+
writeFileSync(p, s);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 단일 파일 복사 (부모 디렉토리 자동 생성, 바이트 그대로)
|
|
17
|
+
export function copyFileSync(src, dst) {
|
|
18
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
19
|
+
cpSync(src, dst);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 디렉토리 재귀 복사 (내용을 dst 하위로)
|
|
23
|
+
export function copyDirSync(src, dst) {
|
|
24
|
+
mkdirSync(dst, { recursive: true });
|
|
25
|
+
cpSync(src, dst, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 파일/폴더 삭제 (없어도 무해)
|
|
29
|
+
export function remove(p) {
|
|
30
|
+
rmSync(p, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 디렉토리 직하위 .yaml/.yml 파일명 목록. 하위 폴더 제외.
|
|
34
|
+
// 정렬 순서는 .sh의 glob `"$_dir"/*.yaml "$_dir"/*.yml` 와 일치시킨다:
|
|
35
|
+
// 확장자로 1차 그룹(.yaml 먼저 → .yml 나중), 각 그룹 안에서 알파벳순.
|
|
36
|
+
// (단순 .sort()는 확장자를 섞어 정렬해 .sh와 파일 순회 순서가 갈리고,
|
|
37
|
+
// 그 결과 version.yml deploy 블록의 키 순서까지 달라진다. 확장자 그룹핑으로 바이트 등가 확보.)
|
|
38
|
+
export function listYamlFiles(dir) {
|
|
39
|
+
if (!existsSync(dir)) return [];
|
|
40
|
+
const names = readdirSync(dir, { withFileTypes: true })
|
|
41
|
+
.filter((e) => e.isFile() && /\.(ya?ml)$/.test(e.name))
|
|
42
|
+
.map((e) => e.name);
|
|
43
|
+
const yaml = names.filter((n) => n.endsWith(".yaml")).sort();
|
|
44
|
+
const yml = names.filter((n) => n.endsWith(".yml")).sort();
|
|
45
|
+
return [...yaml, ...yml];
|
|
46
|
+
}
|