projectops 4.0.3 → 4.0.4
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/README.md +1 -1
- package/package.json +1 -1
- package/src/commands/full.js +3 -3
- package/src/commands/interactive.js +145 -32
- package/src/commands/workflows.js +2 -2
- package/src/core/breaking-check.js +65 -0
- package/src/core/copy/workflows.js +81 -18
- package/src/core/detect-fs.js +43 -1
- package/src/core/options-ask.js +100 -0
- package/src/core/paths-resolve.js +261 -0
- package/src/core/version-yml.js +40 -1
- package/src/core/wizard-labels.js +106 -0
- package/src/index.js +44 -18
- package/src/ui/ansi.js +26 -0
- package/src/ui/banner.js +30 -0
- package/src/ui/env-plan.js +207 -0
- package/src/ui/prompts.js +26 -0
- package/src/ui/status-cards.js +86 -0
- package/src/ui/summary.js +173 -0
package/README.md
CHANGED
package/package.json
CHANGED
package/src/commands/full.js
CHANGED
|
@@ -19,7 +19,7 @@ import { ensureGitignore } from "../core/copy/gitignore.js";
|
|
|
19
19
|
// context: { version, types, paths:Map, branch, versionCode, includeNexus, includeSecretBackup,
|
|
20
20
|
// force, repoName, resolvers, now, today }
|
|
21
21
|
// tempDir: 획득된 템플릿. targetRoot: 통합 대상.
|
|
22
|
-
export function runFull(context, tempDir, targetRoot = ".") {
|
|
22
|
+
export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
23
23
|
const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
|
|
24
24
|
force = true, now, today, templateVersion = "unknown",
|
|
25
25
|
includeNexus = false, includeSecretBackup = false } = context;
|
|
@@ -29,8 +29,8 @@ export function runFull(context, tempDir, targetRoot = ".") {
|
|
|
29
29
|
for (const [t] of paths) pathMarkers.set(t, markerForType(t));
|
|
30
30
|
|
|
31
31
|
// 3. 워크플로우 복사 (+ env 치환) — deploy 블록에 쓸 ask 값을 수집한다.
|
|
32
|
-
//
|
|
33
|
-
const wfCounters = copyWorkflows(context, tempDir, targetRoot);
|
|
32
|
+
// hooks.decisions: 대화형 충돌 3지선 결정 Map (미지정=skip — 현행 force 동작)
|
|
33
|
+
const wfCounters = copyWorkflows(context, tempDir, targetRoot, hooks);
|
|
34
34
|
const deployValues = wfCounters.deployValues || new Map(); // Map<type, Map<key,value>>
|
|
35
35
|
|
|
36
36
|
// 1. version.yml 생성 (전체 재생성 — metadata → deploy → template 순, .sh 최종형과 동일)
|
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
// 대화형 마법사 (.sh interactive_mode 등가) — template_integrator.sh 4262~4411
|
|
1
|
+
// 대화형 마법사 (.sh interactive_mode 등가) — template_integrator.sh 4262~4411 + #446 UI 5층.
|
|
2
2
|
// io 주입으로 테스트 가능. 실제 실행은 src/ui/prompts.js 함수를 io로 넘긴다.
|
|
3
|
+
// 새 시각 층(banner/detectionLog/analysisCard/ideStatus/installKind/summary)과 저수준 엔진(engineIo)은
|
|
4
|
+
// io의 "옵셔널 멤버" — 스텁이 생략하면 해당 층만 건너뛰고 실행 계약은 동일하다.
|
|
3
5
|
import { join } from "node:path";
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
7
|
import { PATHS } from "../core/paths.js";
|
|
5
8
|
import { remove } from "../core/fsutil.js";
|
|
6
9
|
import { acquireTemplate, readTemplateVersion } from "../core/assets.js";
|
|
7
|
-
import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName } from "../core/detect-fs.js";
|
|
10
|
+
import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName, makeResolvers } from "../core/detect-fs.js";
|
|
11
|
+
import { parseExisting } from "../core/version-yml.js";
|
|
12
|
+
import { runBreakingCheck } from "../core/breaking-check.js";
|
|
13
|
+
import { resolveProjectPaths } from "../core/paths-resolve.js";
|
|
14
|
+
import { askAllOptionalWorkflows } from "../core/options-ask.js";
|
|
15
|
+
import { promptEnvPlan } from "../ui/env-plan.js";
|
|
16
|
+
import { listWorkflowConflicts } from "../core/copy/workflows.js";
|
|
8
17
|
import { createContext, VALID_TYPES } from "../context.js";
|
|
9
18
|
import { runFull } from "./full.js";
|
|
10
19
|
import { runVersion } from "./version.js";
|
|
@@ -14,21 +23,40 @@ import { runSkills } from "./skills.js";
|
|
|
14
23
|
import * as prompts from "../ui/prompts.js";
|
|
15
24
|
|
|
16
25
|
const CANCEL = prompts.CANCEL;
|
|
26
|
+
const isCancel = (v) => v === CANCEL || typeof v === "symbol";
|
|
17
27
|
|
|
18
28
|
// io 기본값 = 실제 prompts. 테스트는 스텁 io 주입.
|
|
19
29
|
// skills = runSkills 주입 지점(테스트가 실제 IDE CLI를 안 건드리게). 기본은 실제 runSkills.
|
|
20
30
|
export async function runInteractive(baseCtx, { cwd = process.cwd(), source = { type: "git" }, clock, io = prompts, skills = runSkills } = {}) {
|
|
21
|
-
io.intro?.("projectops — 대화형 통합 마법사");
|
|
22
|
-
|
|
23
|
-
// 1) 모드 선택
|
|
24
|
-
const mode = await io.selectMode();
|
|
25
|
-
if (mode === CANCEL || mode == null) { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
|
|
26
|
-
|
|
27
31
|
const tempDir = join(cwd, PATHS.tempDir);
|
|
28
32
|
try {
|
|
33
|
+
// 템플릿 먼저 획득 — 배너에 실제 템플릿 버전을 표시 (.sh는 원격 version.yml fetch L4270~4280 등가)
|
|
29
34
|
acquireTemplate({ tempDir, source });
|
|
30
35
|
const templateVersion = readTemplateVersion(tempDir);
|
|
31
36
|
|
|
37
|
+
// 층1 — 시작 배너 (#446 확정 시안 A). 스텁엔 banner 없음 → intro 폴백.
|
|
38
|
+
if (io.banner) io.banner({ version: templateVersion, modeLabel: "대화형 통합 마법사" });
|
|
39
|
+
else io.intro?.("projectops — 대화형 통합 마법사");
|
|
40
|
+
|
|
41
|
+
// 기존 version.yml — version/version_code/paths/옵션 보존의 단일 진실 (.sh SSoT L2208~2239)
|
|
42
|
+
const vyPath = join(cwd, "version.yml");
|
|
43
|
+
const existing = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")) : null;
|
|
44
|
+
|
|
45
|
+
// 층4 — IDE Skills 현재 상태 · 층5 — 신규/업데이트 판별 (#446)
|
|
46
|
+
io.ideStatus?.();
|
|
47
|
+
io.installKind?.({ currentTemplateVersion: existing?.templateVersion || "", templateVersion });
|
|
48
|
+
|
|
49
|
+
// 1) 모드 선택
|
|
50
|
+
const mode = await io.selectMode();
|
|
51
|
+
if (mode === CANCEL || mode == null) { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
|
|
52
|
+
|
|
53
|
+
// Breaking Changes 게이트 (.sh execute_integration L4415~4420 — 모든 모드 공통, 대화형은 확인 질문)
|
|
54
|
+
const proceed = await runBreakingCheck({
|
|
55
|
+
cwd, tempDir, templateVersion,
|
|
56
|
+
askYesNo: (msg, def) => io.askYesNo(msg, def),
|
|
57
|
+
});
|
|
58
|
+
if (!proceed) { io.cancelMessage?.("통합을 안전하게 취소했습니다."); return 0; }
|
|
59
|
+
|
|
32
60
|
// skills 모드 — IDE 스킬 설치 (템플릿 통합 없음). 대화형으로 실행.
|
|
33
61
|
if (mode === "skills") {
|
|
34
62
|
await skills({ templateVersion, tempDir, interactive: true });
|
|
@@ -40,68 +68,153 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
40
68
|
if (mode === "issues") {
|
|
41
69
|
const ctx = createContext({ ...baseCtx, mode, force: true });
|
|
42
70
|
runIssues(ctx, tempDir, cwd);
|
|
71
|
+
io.summary?.({ mode, types: [], version: "", counters: {} }, cwd);
|
|
43
72
|
io.outro?.("이슈·PR 템플릿을 설치했습니다.");
|
|
44
73
|
return 0;
|
|
45
74
|
}
|
|
46
75
|
|
|
47
|
-
// full/version/workflows — 감지
|
|
76
|
+
// full/version/workflows — 감지 (version은 기존 version.yml 최우선)
|
|
48
77
|
let types = detectTypes(cwd);
|
|
49
|
-
let version = detectVersion(cwd);
|
|
78
|
+
let version = (existing?.version) || detectVersion(cwd);
|
|
50
79
|
let branch = detectDefaultBranch(cwd);
|
|
51
80
|
const repoName = detectRepoName(cwd);
|
|
52
|
-
|
|
81
|
+
const versionCode = existing?.versionCode ?? 1; // 기존 빌드번호 보존
|
|
82
|
+
// 선택 워크플로우 초기값: version.yml 저장 옵션 (.sh read_template_options L2361 등가)
|
|
83
|
+
let includeNexus = existing?.options?.nexus ?? false;
|
|
84
|
+
let includeSecretBackup = existing?.options?.secretBackup ?? false;
|
|
53
85
|
const showOptional = mode === "full" || mode === "workflows";
|
|
86
|
+
const realTty = process.stdout.isTTY === true;
|
|
87
|
+
|
|
88
|
+
// 층2 — 감지 로그 (#446)
|
|
89
|
+
io.detectionLog?.({ types, version, branch });
|
|
90
|
+
|
|
91
|
+
// 선택 워크플로우(Nexus/Secret) 질문 (.sh ask_all_optional_workflows L2707 — full/workflows만)
|
|
92
|
+
if (showOptional) {
|
|
93
|
+
const r = await askAllOptionalWorkflows({
|
|
94
|
+
tempDir, types, targetRoot: cwd,
|
|
95
|
+
current: { nexus: existing?.options?.nexus ?? null, secretBackup: existing?.options?.secretBackup ?? null },
|
|
96
|
+
force: false, tty: realTty,
|
|
97
|
+
io: { confirm: ({ message, initialValue }) => io.askYesNo(message, initialValue) },
|
|
98
|
+
});
|
|
99
|
+
includeNexus = r.nexus;
|
|
100
|
+
includeSecretBackup = r.secretBackup;
|
|
101
|
+
}
|
|
54
102
|
|
|
55
|
-
// 확인/수정 루프
|
|
103
|
+
// 확인/수정 루프 — ESC는 '머무르기' (.sh L1877~1881: 명시적 '아니오'만 종료)
|
|
104
|
+
let paths = new Map();
|
|
56
105
|
let confirmed = false;
|
|
57
106
|
while (!confirmed) {
|
|
58
|
-
|
|
107
|
+
// 층3 — 프로젝트 분석 개요 카드 (#446). 스텁엔 없음 → note 폴백.
|
|
108
|
+
if (io.analysisCard) {
|
|
109
|
+
io.analysisCard({ mode, modeLabel: modeLabel(mode), types, version, branch, includeNexus, includeSecretBackup, showOptional, paths });
|
|
110
|
+
} else {
|
|
111
|
+
io.note?.(summarize({ mode, types, version, branch, includeNexus, includeSecretBackup, showOptional }), "프로젝트 분석 결과");
|
|
112
|
+
}
|
|
59
113
|
const choice = await io.confirmProjectMenu();
|
|
60
|
-
if (choice ===
|
|
114
|
+
if (choice === "cancel") { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
|
|
115
|
+
if (isCancel(choice) || choice == null) continue; // ESC = 머무르기 (루프 재출력)
|
|
61
116
|
if (choice === "continue") { confirmed = true; break; }
|
|
62
117
|
// edit 루프
|
|
63
118
|
let editing = true;
|
|
64
119
|
while (editing) {
|
|
65
120
|
const what = await io.editMenu({ showOptional });
|
|
66
|
-
if (what
|
|
121
|
+
if (isCancel(what) || what === "done") { editing = false; break; }
|
|
67
122
|
if (what === "type") {
|
|
68
123
|
const t = await io.selectTypes(types);
|
|
69
|
-
if (t
|
|
124
|
+
if (!isCancel(t) && Array.isArray(t) && t.length) {
|
|
125
|
+
// 타입 집합이 실제로 바뀌면 경로 재해석 대상으로 초기화 (.sh L1984~1992 — 정렬 집합 비교)
|
|
126
|
+
const oldSorted = [...types].sort().join(",");
|
|
127
|
+
types = t.filter((x) => VALID_TYPES.includes(x));
|
|
128
|
+
if ([...types].sort().join(",") !== oldSorted) paths = new Map();
|
|
129
|
+
}
|
|
70
130
|
} else if (what === "version") {
|
|
71
131
|
const v = await io.askText("새 버전 (예: 1.0.0)", version);
|
|
72
|
-
if (v !==
|
|
132
|
+
if (!isCancel(v) && v !== version) {
|
|
133
|
+
// semver 형식 검증 (.sh L2010~2015)
|
|
134
|
+
if (/^\d+\.\d+\.\d+$/.test(v)) version = v;
|
|
135
|
+
else io.note?.("버전 형식이 올바르지 않습니다 (x.y.z 형태) — 기존 값을 유지합니다.", "⚠ 버전");
|
|
136
|
+
}
|
|
73
137
|
} else if (what === "branch") {
|
|
74
138
|
const b = await io.askText("기본 브랜치", branch);
|
|
75
|
-
if (b
|
|
139
|
+
if (!isCancel(b) && b) branch = b;
|
|
76
140
|
} else if (what === "nexus") {
|
|
77
141
|
const y = await io.askYesNo("Nexus publish 워크플로우를 포함할까요?", includeNexus);
|
|
78
|
-
if (y
|
|
142
|
+
if (!isCancel(y)) includeNexus = y === true;
|
|
79
143
|
} else if (what === "secret") {
|
|
80
144
|
const y = await io.askYesNo("Secret 백업 워크플로우를 포함할까요?", includeSecretBackup);
|
|
81
|
-
if (y
|
|
145
|
+
if (!isCancel(y)) includeSecretBackup = y === true;
|
|
82
146
|
}
|
|
83
147
|
}
|
|
84
148
|
}
|
|
85
149
|
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
150
|
+
// 경로 확정 (.sh resolve_project_paths L1362~1589 — full/version만. 저장값·후보 스캔·질문)
|
|
151
|
+
if (mode === "full" || mode === "version") {
|
|
152
|
+
paths = await resolveProjectPaths({
|
|
153
|
+
root: cwd, types, paths, existingPaths: existing?.paths ?? new Map(),
|
|
154
|
+
force: false, tty: realTty, io: io.engineIo ?? {},
|
|
155
|
+
});
|
|
156
|
+
} else {
|
|
157
|
+
for (const t of types) if (t !== "basic" && !paths.has(t)) paths.set(t, existing?.paths.get(t) || ".");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// @wizard env 계획 질문 (.sh wf_prompt_env_plan L3220 — full/workflows만)
|
|
161
|
+
const resolvers = makeResolvers(cwd, repoName, paths);
|
|
162
|
+
let envValues = new Map(), envUseDefaults = true;
|
|
163
|
+
if (showOptional) {
|
|
164
|
+
const plan = await promptEnvPlan({
|
|
165
|
+
tempDir, types, io: io.engineIo ?? null, force: false,
|
|
166
|
+
resolvers, includeNexus, targetRoot: cwd, repoName,
|
|
167
|
+
});
|
|
168
|
+
envValues = plan.values;
|
|
169
|
+
envUseDefaults = plan.useDefaults;
|
|
170
|
+
}
|
|
89
171
|
|
|
90
172
|
const { now, today } = clock || utcNow();
|
|
91
173
|
const ctx = createContext({
|
|
92
|
-
mode, force: true, types, version, branch, paths, includeNexus, includeSecretBackup,
|
|
93
|
-
resolvers
|
|
94
|
-
repo: () => repoName, "spring-app-yml-dir": () => "", "spring-app-yml-path": () => "",
|
|
95
|
-
"flutter-root": () => paths.get("flutter") || ".",
|
|
96
|
-
},
|
|
97
|
-
now, today,
|
|
174
|
+
mode, force: true, types, version, versionCode, branch, paths, includeNexus, includeSecretBackup,
|
|
175
|
+
repoName, templateVersion, resolvers, envValues, envUseDefaults, now, today,
|
|
98
176
|
});
|
|
99
177
|
ctx.templateVersion = templateVersion;
|
|
100
178
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
179
|
+
// 기존 워크플로우 충돌 3지선 — 타입당 1회 결정을 파일에 캐시 적용 (.sh L3440~3508 UX 등가)
|
|
180
|
+
let hooks = {};
|
|
181
|
+
if (showOptional && io.engineIo?.select) {
|
|
182
|
+
const conflicts = listWorkflowConflicts(ctx, tempDir, cwd);
|
|
183
|
+
if (conflicts.length) {
|
|
184
|
+
const perType = new Map();
|
|
185
|
+
const decisions = new Map();
|
|
186
|
+
for (const { filename, type } of conflicts) {
|
|
187
|
+
if (!perType.has(type)) {
|
|
188
|
+
const sel = await io.engineIo.select({
|
|
189
|
+
message: `기존 워크플로우와 내용이 다른 파일이 있습니다 (${type}) — 어떻게 할까요?`,
|
|
190
|
+
options: [
|
|
191
|
+
{ value: "skip", label: "건너뛰기 — 기존 파일 유지 (기본)" },
|
|
192
|
+
{ value: "backup", label: ".bak 백업 후 새 버전으로 교체" },
|
|
193
|
+
{ value: "template", label: "기존 유지 + 새 버전을 .template.yaml로 참고 추가" },
|
|
194
|
+
],
|
|
195
|
+
});
|
|
196
|
+
perType.set(type, isCancel(sel) || sel == null ? "skip" : sel); // ESC = 건너뛰기 (.sh L3463)
|
|
197
|
+
}
|
|
198
|
+
decisions.set(filename, perType.get(type));
|
|
199
|
+
}
|
|
200
|
+
hooks = { decisions };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let result = null;
|
|
205
|
+
if (mode === "full") result = runFull(ctx, tempDir, cwd, hooks);
|
|
206
|
+
else if (mode === "version") result = runVersion(ctx, tempDir, cwd);
|
|
207
|
+
else if (mode === "workflows") result = runWorkflows(ctx, tempDir, cwd, hooks);
|
|
208
|
+
|
|
209
|
+
// 통합 후 IDE 스킬 제안 (.sh L4557 offer_ide_tools_install — 사전 질문 게이트, 기본 N)
|
|
210
|
+
const wantSkills = await io.askYesNo("AI 에이전트 스킬(Claude·Cursor·Gemini·Codex·PI)도 설치/업데이트할까요?", false);
|
|
211
|
+
if (wantSkills === true) await skills({ templateVersion, tempDir, interactive: true });
|
|
104
212
|
|
|
213
|
+
// 완료 요약 (.sh print_summary L5438)
|
|
214
|
+
io.summary?.({
|
|
215
|
+
mode, types, version,
|
|
216
|
+
counters: { workflows: result?.workflows?.copied ?? 0, utilModules: 0 },
|
|
217
|
+
}, cwd);
|
|
105
218
|
io.outro?.(`통합 완료 — ${mode} 모드로 설치했습니다.`);
|
|
106
219
|
return 0;
|
|
107
220
|
} finally {
|
|
@@ -8,9 +8,9 @@ import { copyWorkflows } from "../core/copy/workflows.js";
|
|
|
8
8
|
import { copyScripts, copyConfigFolder, copySetupGuide } from "../core/copy/simple.js";
|
|
9
9
|
import { copyUtilModules } from "../core/copy/util.js";
|
|
10
10
|
|
|
11
|
-
export function runWorkflows(context, tempDir, targetRoot = ".") {
|
|
11
|
+
export function runWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
12
12
|
const { types = [], force = true } = context;
|
|
13
|
-
const wf = copyWorkflows(context, tempDir, targetRoot);
|
|
13
|
+
const wf = copyWorkflows(context, tempDir, targetRoot, hooks);
|
|
14
14
|
|
|
15
15
|
// update_version_yml_deploy: 기존 version.yml이 있고 ask 값이 있을 때만 deploy 블록 갱신
|
|
16
16
|
const vy = join(targetRoot, PATHS.versionFile);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// breaking-changes 확인 흐름 배선 (.sh check_breaking_changes L2506~2616 등가).
|
|
2
|
+
// collectBreaking(순수 비교)은 breaking.js — 이 모듈은 로드(원격→clone본 폴백)·표시·확인 게이트.
|
|
3
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { collectBreaking } from "./breaking.js";
|
|
6
|
+
import { parseExisting } from "./version-yml.js";
|
|
7
|
+
|
|
8
|
+
const BC_URL = "https://raw.githubusercontent.com/Cassiiopeia/projectops/main/.github/config/breaking-changes.json";
|
|
9
|
+
|
|
10
|
+
// 원격 우선(3초 타임아웃) → clone된 템플릿의 번들본 폴백 → 둘 다 실패 시 null(조용히 스킵 — .sh 등가)
|
|
11
|
+
export async function loadBreakingJson(tempDir) {
|
|
12
|
+
try {
|
|
13
|
+
const res = await fetch(BC_URL, { signal: AbortSignal.timeout(3000) });
|
|
14
|
+
if (res.ok) return await res.json();
|
|
15
|
+
} catch { /* 원격 실패 — 폴백 */ }
|
|
16
|
+
try {
|
|
17
|
+
const p = join(tempDir, ".github", "config", "breaking-changes.json");
|
|
18
|
+
if (existsSync(p)) return JSON.parse(readFileSync(p, "utf8"));
|
|
19
|
+
} catch { /* 폴백 실패 — 스킵 */ }
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 반환: true=진행, false=사용자 취소.
|
|
24
|
+
// opts:
|
|
25
|
+
// cwd - 통합 대상 루트 (기존 version.yml에서 현재 템플릿 버전 읽음)
|
|
26
|
+
// tempDir - clone된 템플릿 (번들 폴백용)
|
|
27
|
+
// templateVersion - 설치하려는 템플릿 버전 (.sh의 DEFAULT_VERSION 고정 버그를 실버전으로 교정 — 설계 D2)
|
|
28
|
+
// askYesNo - async(message, defaultYes)→bool. null이면 비대화형: 경고만 출력 후 진행
|
|
29
|
+
// loader - 테스트 주입용 (기본 loadBreakingJson)
|
|
30
|
+
export async function runBreakingCheck({ cwd, tempDir, templateVersion, askYesNo = null, loader = loadBreakingJson }) {
|
|
31
|
+
const vy = join(cwd, "version.yml");
|
|
32
|
+
if (!existsSync(vy)) return true; // 신규 통합 — 비교 기준 없음
|
|
33
|
+
const { templateVersion: current } = parseExisting(readFileSync(vy, "utf8"));
|
|
34
|
+
if (!current) return true; // 템플릿 메타 없음(unknown) — .sh 동일하게 스킵
|
|
35
|
+
|
|
36
|
+
const json = await loader(tempDir);
|
|
37
|
+
if (!json) return true;
|
|
38
|
+
|
|
39
|
+
const { critical, warnings } = collectBreaking(json, current, templateVersion);
|
|
40
|
+
if (critical.length === 0 && warnings.length === 0) return true;
|
|
41
|
+
|
|
42
|
+
// 박스 표시 (.sh L2580~2603)
|
|
43
|
+
const e = (s = "") => process.stderr.write(s + "\n");
|
|
44
|
+
e("");
|
|
45
|
+
e("╔══════════════════════════════════════════════════════════════════╗");
|
|
46
|
+
e(`║ ⚠️ BREAKING CHANGES (v${current} → v${templateVersion})`);
|
|
47
|
+
e("╠══════════════════════════════════════════════════════════════════╣");
|
|
48
|
+
for (const c of critical) { e("║"); e(`║ [CRITICAL] ${c.version} - ${c.title || ""}`); e(`║ → ${c.message || ""}`); }
|
|
49
|
+
for (const w of warnings) { e("║"); e(`║ [WARNING] ${w.version} - ${w.title || ""}`); e(`║ → ${w.message || ""}`); }
|
|
50
|
+
e("║");
|
|
51
|
+
e("╚══════════════════════════════════════════════════════════════════╝");
|
|
52
|
+
e("");
|
|
53
|
+
|
|
54
|
+
if (critical.length > 0) {
|
|
55
|
+
if (askYesNo) {
|
|
56
|
+
// 대화형: 명시 확인 없으면 중단 (기본 N — .sh 등가)
|
|
57
|
+
const ok = await askYesNo("위 호환성 변경을 확인했고 계속 진행할까요?", false);
|
|
58
|
+
if (ok !== true) return false;
|
|
59
|
+
} else {
|
|
60
|
+
// 비대화형(--force): 게이트로 CI를 죽이지 않고 경고 후 진행 (.sh와 의도적 차이 — CI 친화)
|
|
61
|
+
e("⚠️ CRITICAL 호환성 변경이 있습니다 — 비대화형 실행이라 계속 진행합니다. 위 내용을 꼭 확인하세요.");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
// 워크플로우 복사 엔진 (.sh copy_workflows + _copy_workflows_for_type 등가).
|
|
2
|
-
// 실측: template_integrator.sh 3398~3815.
|
|
3
|
-
//
|
|
2
|
+
// 실측: template_integrator.sh 3398~3815.
|
|
3
|
+
// 대화형 3지선(기존 파일 충돌)은 copyWorkflowsInteractive(async)가 결정 Map을 만들어
|
|
4
|
+
// 동기 엔진(copyWorkflows)에 hooks.decisions로 전달한다 — 기존 시그니처·force 동작 무변경.
|
|
4
5
|
import { join, basename } from "node:path";
|
|
5
6
|
import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
6
7
|
import { PATHS } from "../paths.js";
|
|
7
8
|
import { exists, copyFileSync, listYamlFiles } from "../fsutil.js";
|
|
8
9
|
import { isUnchanged, substituteEnv } from "../wizard-env.js";
|
|
9
10
|
|
|
10
|
-
// 한 파일에 env
|
|
11
|
-
|
|
11
|
+
// 한 파일에 env 치환을 적용해 대상 파일을 갱신 (.sh configure_workflow_env 등가).
|
|
12
|
+
// values/useDefaults: env 계획(promptEnvPlan) 결과 — 미지정이면 기본값 경로(현행 force 동작).
|
|
13
|
+
function configureEnv(targetPath, { type, projectPath = ".", repoName = "", resolvers = {}, collectAsks = null, values = new Map(), useDefaults = true }) {
|
|
12
14
|
const content = readFileSync(targetPath, "utf8");
|
|
13
15
|
if (!content.includes("@wizard")) return;
|
|
14
|
-
const out = substituteEnv(content, { type, useDefaults
|
|
16
|
+
const out = substituteEnv(content, { type, useDefaults, values, projectPath, repoName, resolvers, collectAsks });
|
|
15
17
|
writeFileSync(targetPath, out);
|
|
16
18
|
}
|
|
17
19
|
|
|
@@ -33,12 +35,15 @@ function classify(srcDir, workflowsDir, envOpts) {
|
|
|
33
35
|
return result;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
// copy_workflows
|
|
37
|
-
// context: { types:[], paths:Map, includeNexus, includeSecretBackup, force, repoName, resolvers
|
|
38
|
-
//
|
|
38
|
+
// copy_workflows 본체 (동기 — 기존 호출부 무변경).
|
|
39
|
+
// context: { types:[], paths:Map, includeNexus, includeSecretBackup, force, repoName, resolvers,
|
|
40
|
+
// envValues?:Map<key,value>, envUseDefaults?:boolean } ← env 계획(promptEnvPlan) 결과 주입점
|
|
41
|
+
// hooks: { decisions?: Map<filename, 'skip'|'backup'|'template'> } — 기존 파일(changed) 충돌 결정.
|
|
42
|
+
// 미지정 파일은 'skip'(현행 force 동작 100% 유지). 대화형 수집은 copyWorkflowsInteractive 참조.
|
|
39
43
|
// 반환: {copied, skipped, templateAdded, optionalCopied}
|
|
40
|
-
export function copyWorkflows(context, tempDir, targetRoot = ".") {
|
|
41
|
-
const { types = [], paths = new Map(), includeNexus = false, includeSecretBackup = false, repoName = "", resolvers = {} } = context;
|
|
44
|
+
export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
45
|
+
const { types = [], paths = new Map(), includeNexus = false, includeSecretBackup = false, repoName = "", resolvers = {}, envValues = new Map(), envUseDefaults = true } = context;
|
|
46
|
+
const decisions = hooks.decisions instanceof Map ? hooks.decisions : new Map();
|
|
42
47
|
const workflowsDir = join(targetRoot, PATHS.workflowsDir);
|
|
43
48
|
const projectTypesDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
|
|
44
49
|
if (!exists(projectTypesDir)) throw new Error("템플릿 저장소 구조 오류 — project-types 폴더를 찾지 못했습니다.");
|
|
@@ -46,7 +51,8 @@ export function copyWorkflows(context, tempDir, targetRoot = ".") {
|
|
|
46
51
|
const counters = { copied: 0, skipped: 0, templateAdded: 0, optionalCopied: 0 };
|
|
47
52
|
const deployValues = new Map(); // Map<type, Map<key,value>> — deploy 블록용 ask 값
|
|
48
53
|
counters.deployValues = deployValues;
|
|
49
|
-
|
|
54
|
+
// values/useDefaults는 치환 경로에서만 의미 (isUnchanged는 내부에서 useDefaults:true 강제 — 가상 비교 무손상)
|
|
55
|
+
const envOptsFor = (type) => ({ type, projectPath: paths.get(type) || ".", repoName, resolvers, values: envValues, useDefaults: envUseDefaults });
|
|
50
56
|
|
|
51
57
|
// (1) common — unchanged면 스킵, 아니면 무조건 덮어쓰기
|
|
52
58
|
const commonDir = join(projectTypesDir, "common");
|
|
@@ -66,7 +72,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".") {
|
|
|
66
72
|
// (2~4) 타입별
|
|
67
73
|
for (const type of types) {
|
|
68
74
|
const asks = new Map();
|
|
69
|
-
copyWorkflowsForType(type, projectTypesDir, workflowsDir, { includeNexus, ...context, envOptsFor, collectAsks: asks }, counters);
|
|
75
|
+
copyWorkflowsForType(type, projectTypesDir, workflowsDir, { includeNexus, ...context, envOptsFor, collectAsks: asks, decisions }, counters);
|
|
70
76
|
if (asks.size) deployValues.set(type, asks);
|
|
71
77
|
}
|
|
72
78
|
|
|
@@ -85,8 +91,66 @@ export function copyWorkflows(context, tempDir, targetRoot = ".") {
|
|
|
85
91
|
return counters;
|
|
86
92
|
}
|
|
87
93
|
|
|
94
|
+
// changed(기존에 있고 내용이 바뀐) 파일 1개를 결정에 따라 처리 (.sh 3440~3508 3지선 case 등가).
|
|
95
|
+
// 'skip'(기본): 기존 유지. 'backup': 기존→.bak 후 교체. 'template': 기존 유지 + 새 버전을 .template.yaml로.
|
|
96
|
+
function applyDecision(decision, srcDir, workflowsDir, filename, counters) {
|
|
97
|
+
const src = join(srcDir, filename);
|
|
98
|
+
const dst = join(workflowsDir, filename);
|
|
99
|
+
if (decision === "backup") {
|
|
100
|
+
// .sh O) mv → cp: 기존을 .bak으로 백업 후 새 버전으로 교체
|
|
101
|
+
renameSync(dst, dst + ".bak");
|
|
102
|
+
copyFileSync(src, dst);
|
|
103
|
+
counters.copied++;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (decision === "template") {
|
|
107
|
+
// .sh T) `${filename%.yaml}.template.yaml` — .yaml만 strip (.yml은 그대로 뒤에 붙음, .sh 동일)
|
|
108
|
+
const templateName = (filename.endsWith(".yaml") ? filename.slice(0, -".yaml".length) : filename) + ".template.yaml";
|
|
109
|
+
copyFileSync(src, join(workflowsDir, templateName)); // cp가 기존 .template.yaml 덮어씀(.sh rm -f + cp 등가)
|
|
110
|
+
counters.templateAdded++;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
counters.skipped++; // 'skip'/미지정/ESC → 기존 유지 (.sh S)·force 기본)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 대상 워크플로우 디렉토리에서 changed(충돌) 파일 목록만 뽑는다 — copyWorkflowsInteractive의 사전 조사용.
|
|
117
|
+
// copyWorkflows 본체와 동일한 classify 기준을 써야 결정 Map이 실제 처리 대상과 1:1로 맞는다.
|
|
118
|
+
export function listWorkflowConflicts(context, tempDir, targetRoot = ".") {
|
|
119
|
+
const { types = [], paths = new Map(), includeNexus = false, repoName = "", resolvers = {} } = context;
|
|
120
|
+
const workflowsDir = join(targetRoot, PATHS.workflowsDir);
|
|
121
|
+
const projectTypesDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
|
|
122
|
+
const conflicts = []; // [{ filename, type }] — 엔진 처리 순서와 동일 (타입 순회 → 직하위 → server-deploy)
|
|
123
|
+
for (const type of types) {
|
|
124
|
+
const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers };
|
|
125
|
+
const typeDir = join(projectTypesDir, type);
|
|
126
|
+
if (exists(typeDir)) {
|
|
127
|
+
for (const f of classify(typeDir, workflowsDir, envOpts).changed) conflicts.push({ filename: f, type });
|
|
128
|
+
}
|
|
129
|
+
const serverDeployDir = join(typeDir, "server-deploy");
|
|
130
|
+
if (exists(serverDeployDir) && !includeNexus) {
|
|
131
|
+
for (const f of classify(serverDeployDir, workflowsDir, envOpts).changed) conflicts.push({ filename: f, type });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return conflicts;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 대화형 진입점 (async) — 충돌마다 onConflict(filename, type)를 await해 결정 Map을 만든 뒤
|
|
138
|
+
// 동기 엔진에 위임한다. WHY 분리: copyWorkflows를 async로 바꾸면 await 없이 호출하는
|
|
139
|
+
// 기존 호출부(runFull/runWorkflows)가 깨진다 — 시그니처 무변경 원칙.
|
|
140
|
+
// onConflict 반환값: 'template' | 'skip' | 'backup' (그 외/미지정 → 'skip').
|
|
141
|
+
export async function copyWorkflowsInteractive(context, tempDir, targetRoot = ".", { onConflict } = {}) {
|
|
142
|
+
const decisions = new Map();
|
|
143
|
+
if (typeof onConflict === "function") {
|
|
144
|
+
for (const { filename, type } of listWorkflowConflicts(context, tempDir, targetRoot)) {
|
|
145
|
+
if (decisions.has(filename)) continue; // 파일명은 PROJECT-{TYPE}- prefix로 타입 간 유일
|
|
146
|
+
decisions.set(filename, await onConflict(filename, type));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return copyWorkflows(context, tempDir, targetRoot, { decisions });
|
|
150
|
+
}
|
|
151
|
+
|
|
88
152
|
function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters) {
|
|
89
|
-
const { includeNexus, force = false, paths = new Map(), repoName = "", resolvers = {}, envOptsFor, collectAsks = null } = ctx;
|
|
153
|
+
const { includeNexus, force = false, paths = new Map(), repoName = "", resolvers = {}, envOptsFor, collectAsks = null, decisions = new Map() } = ctx;
|
|
90
154
|
const typeDir = join(projectTypesDir, type);
|
|
91
155
|
const envOpts = envOptsFor(type);
|
|
92
156
|
let unchangedNames = [];
|
|
@@ -97,9 +161,8 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
97
161
|
unchangedNames = unchanged.slice();
|
|
98
162
|
for (const f of unchanged) counters.skipped++;
|
|
99
163
|
for (const f of newFiles) { copyFileSync(join(typeDir, f), join(workflowsDir, f)); counters.copied++; }
|
|
100
|
-
// changed:
|
|
101
|
-
|
|
102
|
-
for (const f of changed) counters.skipped++; // force 경로 = skip
|
|
164
|
+
// changed: 결정 Map에 따라 처리 (미지정=skip → 현행 force 동작과 동일)
|
|
165
|
+
for (const f of changed) applyDecision(decisions.get(f), typeDir, workflowsDir, f, counters);
|
|
103
166
|
}
|
|
104
167
|
|
|
105
168
|
// server-deploy
|
|
@@ -111,7 +174,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
111
174
|
const { newFiles, unchanged, changed } = classify(serverDeployDir, workflowsDir, envOpts);
|
|
112
175
|
for (const f of unchanged) counters.skipped++;
|
|
113
176
|
for (const f of newFiles) { copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; }
|
|
114
|
-
for (const f of changed)
|
|
177
|
+
for (const f of changed) applyDecision(decisions.get(f), serverDeployDir, workflowsDir, f, counters);
|
|
115
178
|
}
|
|
116
179
|
}
|
|
117
180
|
|
|
@@ -139,7 +202,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
139
202
|
const target = join(workflowsDir, filename);
|
|
140
203
|
if (!existsSync(target)) continue; // 건너뛴 파일 제외
|
|
141
204
|
if (unchangedNames.includes(filename)) continue; // unchanged 제외
|
|
142
|
-
configureEnv(target, {
|
|
205
|
+
configureEnv(target, { ...envOpts, collectAsks }); // env 계획 values/useDefaults 포함
|
|
143
206
|
}
|
|
144
207
|
}
|
|
145
208
|
}
|
package/src/core/detect-fs.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// 실 파일시스템 프로젝트 감지 (.sh detect_* 실행부 등가).
|
|
2
2
|
// SP2-A detect.js 순수 함수를 fs/git으로 구동한다.
|
|
3
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
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 } from "./detect.js";
|
|
@@ -62,3 +62,45 @@ function hasCommand(cmd) {
|
|
|
62
62
|
return true;
|
|
63
63
|
} catch { return false; }
|
|
64
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
|
+
}
|