project-auto-wizard 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -4
- package/package.json +1 -1
- package/payload/config/breaking-changes.json +24 -1
- package/payload/flutter-app/android/fastlane/Fastfile.playstore +86 -0
- package/payload/flutter-app/ios/ExportOptions.plist +31 -0
- package/payload/flutter-app/ios/fastlane/Fastfile +117 -0
- package/payload/scripts/__pycache__/changelog_manager.cpython-314.pyc +0 -0
- package/payload/scripts/__pycache__/issue_helper.cpython-314.pyc +0 -0
- package/payload/scripts/__pycache__/version_manager.cpython-314.pyc +0 -0
- package/payload/scripts/changelog_manager.py +2 -2
- package/payload/scripts/version_manager.py +2 -2
- package/payload/version.yml.template +1 -0
- package/payload/workflows/common/secret-backup/PROJECT-COMMON-SECRET-FILE-UPLOAD.yaml +12 -12
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +48 -20
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +44 -19
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +28 -36
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +33 -46
- package/payload/workflows/flutter/{PROJECT-FLUTTER-SUH-LAB-APP-BUILD-TRIGGER.yaml → PROJECT-FLUTTER-APP-BUILD-TRIGGER.yaml} +38 -38
- package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +123 -32
- package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +57 -42
- package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +61 -40
- package/payload/workflows/go/PROJECT-GO-CI.yaml +47 -0
- package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +40 -40
- package/payload/workflows/next/PROJECT-NEXT-CI.yaml +47 -0
- package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +47 -0
- package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +40 -40
- package/payload/workflows/react/PROJECT-REACT-CI.yaml +47 -0
- package/payload/workflows/spring/nexus/PROJECT-SPRING-GITHUB-PACKAGES-PUBLISH.yml +1 -0
- package/payload/workflows/spring/nexus/PROJECT-SPRING-NEXUS-CI.yml +47 -0
- package/payload/workflows/spring/nexus/PROJECT-SPRING-NEXUS-PUBLISH.yml +1 -0
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +39 -39
- package/src/cli/args.js +37 -0
- package/src/cli/help.js +4 -0
- package/src/commands/doctor.js +55 -2
- package/src/commands/dry-run.js +10 -0
- package/src/commands/full.js +28 -3
- package/src/commands/interactive-flutter.js +95 -0
- package/src/commands/interactive.js +51 -5
- package/src/commands/status.js +20 -2
- package/src/context.js +6 -0
- package/src/core/breaking.js +7 -3
- package/src/core/copy/flutter-app.js +42 -0
- package/src/core/copy/workflows.js +21 -9
- package/src/core/detect-fs.js +9 -1
- package/src/core/flutter-options.js +118 -0
- package/src/core/installed-stores.js +12 -0
- package/src/core/verify.js +2 -2
- package/src/core/version-yml.js +40 -5
- package/src/core/wizard-env.js +26 -4
- package/src/index.js +32 -1
- package/src/ui/env-plan.js +8 -5
- package/src/ui/prompts.js +88 -3
- package/src/ui/status-cards.js +13 -1
- package/src/ui/summary.js +24 -5
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// 대화형 마법사의 Flutter 옵션 질문 (이슈 #131) — 환경변수 방식 · 스토어 배포 대상 · 배포 모드.
|
|
2
|
+
// 상태는 { envMode, stores, androidDeployMode, iosDeployMode } 불변 객체로 주고받는다.
|
|
3
|
+
// envMode·*DeployMode의 ""와 stores의 null = 미결정 (stores의 빈 배열 = "스토어 배포 안 함"으로 이미 결정됨).
|
|
4
|
+
// 이미 결정된 값은 다시 묻지 않는다 (version.yml 저장값 재질문 생략 규약). ESC(취소)는 항상 기본값:
|
|
5
|
+
// 처음 묻는 질문이면 기본값, 수정 중이면 현재값을 유지한다.
|
|
6
|
+
import {
|
|
7
|
+
isEnvMode, isDeployMode, parseStoreList, STORE_PLATFORMS, DEFAULT_DEPLOY_MODE,
|
|
8
|
+
} from "../core/flutter-options.js";
|
|
9
|
+
import { deployModeWarning } from "../ui/prompts.js";
|
|
10
|
+
|
|
11
|
+
export const FLUTTER_EDIT_ITEMS = new Set(["envMode", "flutterStore", "deployMode"]);
|
|
12
|
+
|
|
13
|
+
const DEPLOY_MODE_KEY = { android: "androidDeployMode", ios: "iosDeployMode" };
|
|
14
|
+
|
|
15
|
+
// STORE_PLATFORMS 순서로 정렬하고 모르는 값은 버린다 — 플랫폼별 질문 순서를 고정한다.
|
|
16
|
+
const normalizeStores = (picked) => STORE_PLATFORMS.filter((platform) => picked.includes(platform));
|
|
17
|
+
|
|
18
|
+
// 기존 version.yml의 저장값을 상태로 옮긴다. 잘못된 값은 저장이 없는 것으로 보고 다시 묻는다.
|
|
19
|
+
export function savedFlutterState(existing) {
|
|
20
|
+
const options = existing?.options ?? {};
|
|
21
|
+
return {
|
|
22
|
+
envMode: isEnvMode(options.envMode) ? options.envMode : "",
|
|
23
|
+
stores: options.flutterStore == null ? null : parseStoreList(options.flutterStore),
|
|
24
|
+
androidDeployMode: isDeployMode(options.androidDeployMode) ? options.androidDeployMode : "",
|
|
25
|
+
iosDeployMode: isDeployMode(options.iosDeployMode) ? options.iosDeployMode : "",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function askDeployMode(io, platform, initialValue) {
|
|
30
|
+
const picked = await io.selectDeployMode({ platform, initialValue });
|
|
31
|
+
const mode = isDeployMode(picked) ? picked : initialValue; // ESC = 기본값
|
|
32
|
+
const warning = deployModeWarning(mode);
|
|
33
|
+
if (warning) io.note?.(warning, "배포 모드");
|
|
34
|
+
return mode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 고른 플랫폼 중 배포 모드가 아직 없는 것만 묻는다.
|
|
38
|
+
async function askUnsetDeployModes(io, state) {
|
|
39
|
+
const next = { ...state };
|
|
40
|
+
for (const platform of state.stores ?? []) {
|
|
41
|
+
const key = DEPLOY_MODE_KEY[platform];
|
|
42
|
+
if (!next[key]) next[key] = await askDeployMode(io, platform, DEFAULT_DEPLOY_MODE);
|
|
43
|
+
}
|
|
44
|
+
return next;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 아직 정해지지 않은 옵션만 묻는다.
|
|
48
|
+
// envModeDefault — 신규 설치 dart-define / 기존 설치 dotenv (동작 보존)
|
|
49
|
+
// inferredStores — 저장값 없는 기존 설치가 이미 쓰던 스토어 (신규 설치는 [])
|
|
50
|
+
export async function askUnsetFlutterOptions(io, state, { envModeDefault, inferredStores }) {
|
|
51
|
+
const next = { ...state };
|
|
52
|
+
if (!next.envMode) {
|
|
53
|
+
const picked = await io.selectEnvMode({ initialValue: envModeDefault });
|
|
54
|
+
next.envMode = isEnvMode(picked) ? picked : envModeDefault; // ESC = 기본값
|
|
55
|
+
}
|
|
56
|
+
if (next.stores === null) {
|
|
57
|
+
const picked = await io.selectFlutterStores({ initialValues: inferredStores });
|
|
58
|
+
next.stores = Array.isArray(picked) ? normalizeStores(picked) : inferredStores; // ESC = 초기 선택
|
|
59
|
+
}
|
|
60
|
+
return askUnsetDeployModes(io, next);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 수정하기 메뉴에서 고른 항목 하나를 다시 묻는다. 현재값이 초기 선택이고 ESC는 현재값 유지.
|
|
64
|
+
export async function editFlutterOption(io, what, state, envModeDefault) {
|
|
65
|
+
if (what === "envMode") {
|
|
66
|
+
const picked = await io.selectEnvMode({ initialValue: state.envMode || envModeDefault });
|
|
67
|
+
return isEnvMode(picked) ? { ...state, envMode: picked } : state;
|
|
68
|
+
}
|
|
69
|
+
if (what === "flutterStore") {
|
|
70
|
+
const picked = await io.selectFlutterStores({ initialValues: state.stores ?? [] });
|
|
71
|
+
if (!Array.isArray(picked)) return state;
|
|
72
|
+
const stores = normalizeStores(picked);
|
|
73
|
+
// 해제된 플랫폼의 배포 모드는 초기화한다 — 그대로 두면 재선택 시 옛 값이 남아 다시 묻지 않는다.
|
|
74
|
+
const reset = { ...state, stores };
|
|
75
|
+
for (const platform of STORE_PLATFORMS) {
|
|
76
|
+
if (!stores.includes(platform)) reset[DEPLOY_MODE_KEY[platform]] = "";
|
|
77
|
+
}
|
|
78
|
+
// 새로 추가되거나 방금 초기화된 플랫폼만 배포 모드를 묻는다 — 이미 정한 플랫폼의 모드는 그대로 둔다.
|
|
79
|
+
return askUnsetDeployModes(io, reset);
|
|
80
|
+
}
|
|
81
|
+
if (what === "deployMode") {
|
|
82
|
+
const stores = state.stores ?? [];
|
|
83
|
+
if (!stores.length) {
|
|
84
|
+
io.note?.("스토어 배포 대상을 먼저 선택하세요 ('스토어 배포 대상' 항목).", "배포 모드");
|
|
85
|
+
return state;
|
|
86
|
+
}
|
|
87
|
+
const next = { ...state };
|
|
88
|
+
for (const platform of stores) {
|
|
89
|
+
const key = DEPLOY_MODE_KEY[platform];
|
|
90
|
+
next[key] = await askDeployMode(io, platform, state[key] || DEFAULT_DEPLOY_MODE);
|
|
91
|
+
}
|
|
92
|
+
return next;
|
|
93
|
+
}
|
|
94
|
+
return state;
|
|
95
|
+
}
|
|
@@ -15,6 +15,10 @@ import { promptEnvPlan } from "../ui/env-plan.js";
|
|
|
15
15
|
import { surveyWorkflows } from "../core/copy/workflows.js";
|
|
16
16
|
import { createContext, VALID_TYPES } from "../context.js";
|
|
17
17
|
import { isDeployStyle, DEFAULT_DEPLOY_STYLE } from "../core/deploy-style.js";
|
|
18
|
+
import { PATHS } from "../core/paths.js";
|
|
19
|
+
import { resolveFlutterOptions, DEFAULT_DEPLOY_MODE } from "../core/flutter-options.js";
|
|
20
|
+
import { inferInstalledStores } from "../core/installed-stores.js";
|
|
21
|
+
import { savedFlutterState, askUnsetFlutterOptions, editFlutterOption, FLUTTER_EDIT_ITEMS } from "./interactive-flutter.js";
|
|
18
22
|
import { runFull } from "./full.js";
|
|
19
23
|
import { runUninstallFlow } from "./uninstall.js";
|
|
20
24
|
import * as prompts from "../ui/prompts.js";
|
|
@@ -89,6 +93,21 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
|
|
|
89
93
|
const showOptional = mode === "full";
|
|
90
94
|
const realTty = process.stdout.isTTY === true;
|
|
91
95
|
|
|
96
|
+
// Flutter 옵션 (이슈 #131) — 저장값이 있으면 재질문하지 않는다 (deploy_style과 같은 규약).
|
|
97
|
+
// 저장값 없는 기존 설치는 동작 보존을 위해 dotenv를 초기 선택으로, 스토어는 설치된 워크플로우로 추론한다.
|
|
98
|
+
// 환경변수 기본값 규칙(신규=dart-define, 기존 설치·저장값 없음=dotenv)은 resolveFlutterOptions가 단일 진실이다.
|
|
99
|
+
let flutter = savedFlutterState(existing);
|
|
100
|
+
const flutterAsk = {
|
|
101
|
+
envModeDefault: resolveFlutterOptions({
|
|
102
|
+
cli: { envMode: "", stores: null, androidDeployMode: "", iosDeployMode: "" }, existing,
|
|
103
|
+
}).envMode,
|
|
104
|
+
inferredStores: existing && flutter.stores === null ? inferInstalledStores(join(cwd, PATHS.workflowsDir)) : [],
|
|
105
|
+
};
|
|
106
|
+
// 이미 정해진 값은 건너뛰므로 여러 번 불러도 같은 질문이 반복되지 않는다.
|
|
107
|
+
const askFlutterOptions = async () => {
|
|
108
|
+
if (types.includes("flutter")) flutter = await askUnsetFlutterOptions(io, flutter, flutterAsk);
|
|
109
|
+
};
|
|
110
|
+
|
|
92
111
|
// 층2 — 감지 로그 (#446). markers = 실제로 존재를 확인한 파일 (이슈 #77).
|
|
93
112
|
let markers = detectMarkers(cwd, types);
|
|
94
113
|
io.detectionLog?.({ types, version, branch, markers, warnings: detectWarnings });
|
|
@@ -125,6 +144,9 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
|
|
|
125
144
|
deployStyle = isDeployStyle(picked) ? picked : DEFAULT_DEPLOY_STYLE; // ESC = 기본값
|
|
126
145
|
}
|
|
127
146
|
|
|
147
|
+
// Flutter 옵션 (이슈 #131) — 환경변수 방식 → 스토어 배포 대상 → 플랫폼별 배포 모드.
|
|
148
|
+
await askFlutterOptions();
|
|
149
|
+
|
|
128
150
|
// 신규 질문 — 자동 semver 승격 (기본 ON). 저장값 있으면 재질문 생략.
|
|
129
151
|
// version.yml을 쓰지 않는 workflows 모드에서는 답변이 무의미하므로 full에서만 질문한다.
|
|
130
152
|
if (mode === "full" && includeSemverAuto === null) {
|
|
@@ -143,9 +165,9 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
|
|
|
143
165
|
while (!confirmed) {
|
|
144
166
|
// 층3 — 프로젝트 분석 개요 카드 (#446). 스텁엔 없음 → note 폴백.
|
|
145
167
|
if (io.analysisCard) {
|
|
146
|
-
io.analysisCard({ mode, modeLabel: modeLabel(mode), types, version, branch, includeNexus, includeSecretBackup, showOptional, paths });
|
|
168
|
+
io.analysisCard({ mode, modeLabel: modeLabel(mode), types, version, branch, includeNexus, includeSecretBackup, showOptional, paths, flutter, envModeDefault: flutterAsk.envModeDefault });
|
|
147
169
|
} else {
|
|
148
|
-
io.note?.(summarize({ mode, types, version, branch, includeNexus, includeSecretBackup, showOptional }), "프로젝트 분석 결과");
|
|
170
|
+
io.note?.(summarize({ mode, types, version, branch, includeNexus, includeSecretBackup, showOptional, flutter, envModeDefault: flutterAsk.envModeDefault }), "프로젝트 분석 결과");
|
|
149
171
|
}
|
|
150
172
|
const choice = await io.confirmProjectMenu();
|
|
151
173
|
if (choice === "cancel") { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
|
|
@@ -154,7 +176,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
|
|
|
154
176
|
// edit 루프
|
|
155
177
|
let editing = true;
|
|
156
178
|
while (editing) {
|
|
157
|
-
const what = await io.editMenu({ showOptional });
|
|
179
|
+
const what = await io.editMenu({ showOptional, showFlutter: showOptional && types.includes("flutter") });
|
|
158
180
|
if (isCancel(what) || what === "done") { editing = false; break; }
|
|
159
181
|
if (what === "type") {
|
|
160
182
|
const t = await io.selectTypes(types);
|
|
@@ -180,10 +202,22 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
|
|
|
180
202
|
} else if (what === "secret") {
|
|
181
203
|
const y = await io.askYesNo("Secret 백업 워크플로우를 포함할까요?", includeSecretBackup);
|
|
182
204
|
if (!isCancel(y)) includeSecretBackup = y === true;
|
|
205
|
+
} else if (FLUTTER_EDIT_ITEMS.has(what)) {
|
|
206
|
+
flutter = await editFlutterOption(io, what, flutter, flutterAsk.envModeDefault);
|
|
183
207
|
}
|
|
184
208
|
}
|
|
185
209
|
}
|
|
186
210
|
|
|
211
|
+
// 편집 루프에서 뒤늦게 flutter 타입이 추가된 경우에도 옵션을 확정한다 — 이미 정해진 값은 다시 묻지 않는다.
|
|
212
|
+
if (showOptional) await askFlutterOptions();
|
|
213
|
+
// 질문이 나오지 않은 경우(비 full 모드 등)도 동작 보존 기본값으로 채워 워크플로우 치환이 어긋나지 않게 한다.
|
|
214
|
+
const flutterOptions = {
|
|
215
|
+
envMode: flutter.envMode || flutterAsk.envModeDefault,
|
|
216
|
+
stores: flutter.stores,
|
|
217
|
+
androidDeployMode: flutter.androidDeployMode || DEFAULT_DEPLOY_MODE,
|
|
218
|
+
iosDeployMode: flutter.iosDeployMode || DEFAULT_DEPLOY_MODE,
|
|
219
|
+
};
|
|
220
|
+
|
|
187
221
|
const versionCode = existing?.versionCode ?? detectBuildNumber(cwd, { types }) ?? 1; // 기존 빌드번호 보존, 신규 통합 시 프로젝트 파일에서 감지 (이슈 #41)
|
|
188
222
|
|
|
189
223
|
// 신규 질문 ① — 브랜치 설정 (DESIGN-SPEC §4). full/workflows만 질문, version은 기본값 기록.
|
|
@@ -226,12 +260,13 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
|
|
|
226
260
|
}
|
|
227
261
|
|
|
228
262
|
// @wizard env 계획 질문 (.sh wf_prompt_env_plan L3220 — full/workflows만)
|
|
229
|
-
const resolvers = makeResolvers(cwd, repoName, paths);
|
|
263
|
+
const resolvers = makeResolvers(cwd, repoName, paths, flutterOptions);
|
|
230
264
|
let envValues = new Map(), envUseDefaults = true, envAnswers = [];
|
|
231
265
|
if (showOptional) {
|
|
232
266
|
const plan = await promptEnvPlan({
|
|
233
267
|
payloadRoot: payload, types, io: io.engineIo ?? null, force: false,
|
|
234
268
|
resolvers, includeNexus, includeSecretBackup, deployStyle, targetRoot: cwd, repoName,
|
|
269
|
+
flutterStore: flutterOptions.stores, // 선택 해제된 스토어 워크플로우의 ask 질문은 묻지 않는다 (D2 env-plan)
|
|
235
270
|
});
|
|
236
271
|
envValues = plan.values;
|
|
237
272
|
envUseDefaults = plan.useDefaults;
|
|
@@ -247,6 +282,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
|
|
|
247
282
|
// 설치 로그(#79)·완료 요약(#80)이 쓰는 부가 문맥 — 설치 동작 자체는 바꾸지 않는다.
|
|
248
283
|
markers, envAnswers, detectWarnings,
|
|
249
284
|
deployStyle: deployStyle || DEFAULT_DEPLOY_STYLE,
|
|
285
|
+
envMode: flutterOptions.envMode, flutterStore: flutterOptions.stores,
|
|
286
|
+
androidDeployMode: flutterOptions.androidDeployMode, iosDeployMode: flutterOptions.iosDeployMode,
|
|
250
287
|
previousTemplateVersion: existing?.templateVersion || "",
|
|
251
288
|
});
|
|
252
289
|
ctx.templateVersion = templateVersion;
|
|
@@ -309,6 +346,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
|
|
|
309
346
|
logPath: currentLogPath(),
|
|
310
347
|
legacyMdLogs: hasLegacyMdLogs(cwd),
|
|
311
348
|
cleanup: result?.cleanup ?? null,
|
|
349
|
+
storeCleanup: result?.storeCleanup ?? null,
|
|
350
|
+
flutterApp: result?.flutterApp ?? null,
|
|
312
351
|
});
|
|
313
352
|
io.outro?.(`통합 완료 — ${mode} 모드로 설치했습니다.`);
|
|
314
353
|
return 0;
|
|
@@ -335,7 +374,7 @@ export async function pickBranch(io, message, def, remoteBranches, isCancel) {
|
|
|
335
374
|
return isCancel(v) || !v ? def : v;
|
|
336
375
|
}
|
|
337
376
|
|
|
338
|
-
function summarize({ mode, types, version, branch, includeNexus, includeSecretBackup, showOptional }) {
|
|
377
|
+
function summarize({ mode, types, version, branch, includeNexus, includeSecretBackup, showOptional, flutter, envModeDefault }) {
|
|
339
378
|
const lines = [
|
|
340
379
|
`통합 모드 : ${modeLabel(mode)}`,
|
|
341
380
|
`프로젝트 타입 : ${types.join(", ")}${types.length > 1 ? " (멀티)" : ""}`,
|
|
@@ -345,6 +384,13 @@ function summarize({ mode, types, version, branch, includeNexus, includeSecretBa
|
|
|
345
384
|
if (showOptional) {
|
|
346
385
|
lines.push(`Nexus publish : ${includeNexus ? "포함" : "제외"}`);
|
|
347
386
|
lines.push(`Secret 백업 : ${includeSecretBackup ? "포함" : "제외"}`);
|
|
387
|
+
if (types.includes("flutter")) {
|
|
388
|
+
const stores = flutter.stores ?? [];
|
|
389
|
+
const modeParts = stores.map((p) => `${p}=${(p === "android" ? flutter.androidDeployMode : flutter.iosDeployMode) || DEFAULT_DEPLOY_MODE}`);
|
|
390
|
+
lines.push(`환경변수 방식 : ${flutter.envMode || envModeDefault}`);
|
|
391
|
+
lines.push(`스토어 배포 대상 : ${stores.length ? stores.join(", ") : "없음"}`);
|
|
392
|
+
lines.push(`배포 모드 : ${modeParts.length ? modeParts.join(" ") : "없음"}`);
|
|
393
|
+
}
|
|
348
394
|
}
|
|
349
395
|
return lines.join("\n");
|
|
350
396
|
}
|
package/src/commands/status.js
CHANGED
|
@@ -5,6 +5,7 @@ import { parseExisting } from "../core/version-yml.js";
|
|
|
5
5
|
import { planWorkflows } from "../core/copy/workflows.js";
|
|
6
6
|
import { makeResolvers, detectRepoName, detectDefaultBranch } from "../core/detect-fs.js";
|
|
7
7
|
import { PATHS } from "../core/paths.js";
|
|
8
|
+
import { resolveFlutterOptions } from "../core/flutter-options.js";
|
|
8
9
|
|
|
9
10
|
// payloadRoot: 패키지 payload/ 루트. targetRoot: 상태를 확인할 대상 레포.
|
|
10
11
|
export function runStatus(payloadRoot, targetRoot = ".") {
|
|
@@ -13,7 +14,12 @@ export function runStatus(payloadRoot, targetRoot = ".") {
|
|
|
13
14
|
|
|
14
15
|
const existing = parseExisting(readFileSync(vyPath, "utf8"));
|
|
15
16
|
const repoName = detectRepoName(targetRoot);
|
|
16
|
-
|
|
17
|
+
// 설치 때 워크플로우에 치환된 환경변수 방식·배포 모드와 스토어 선택을 비교 기준에도 똑같이 적용한다 —
|
|
18
|
+
// 그렇지 않으면 미수정 파일이 드리프트로, 선택 해제한 스토어 워크플로우가 "삭제함"으로 오탐된다.
|
|
19
|
+
const flutterOptions = resolveFlutterOptions({
|
|
20
|
+
cli: { envMode: "", stores: null, androidDeployMode: "", iosDeployMode: "" }, existing,
|
|
21
|
+
});
|
|
22
|
+
const resolvers = makeResolvers(targetRoot, repoName, existing.paths, flutterOptions);
|
|
17
23
|
// version.yml에 branches 블록이 없으면(신기능 이전 설치·수기 편집) makeSrcText(null)이
|
|
18
24
|
// {{MAIN_BRANCH}}/{{DEVELOP_BRANCH}}를 치환하지 못해 모든 워크플로우가 드리프트로 오탐된다 —
|
|
19
25
|
// 비교용 기본값으로 폴백(실제 저장값은 아니지만 드리프트 비교 목적에는 충분).
|
|
@@ -23,6 +29,7 @@ export function runStatus(payloadRoot, targetRoot = ".") {
|
|
|
23
29
|
includeNexus: existing.options.nexus === true,
|
|
24
30
|
includeSecretBackup: existing.options.secretBackup === true,
|
|
25
31
|
repoName, resolvers, branches: branchesForCompare,
|
|
32
|
+
flutterStore: flutterOptions.stores,
|
|
26
33
|
};
|
|
27
34
|
const plan = planWorkflows(context, payloadRoot, targetRoot);
|
|
28
35
|
|
|
@@ -62,7 +69,8 @@ export function printStatus(status) {
|
|
|
62
69
|
}
|
|
63
70
|
const boolLabel = (v) => (v === null ? "미설정(기본 false)" : v);
|
|
64
71
|
const semverAutoLabel = status.options.semverAuto === null ? "미설정(기본 false)" : status.options.semverAuto;
|
|
65
|
-
|
|
72
|
+
const flutterLabels = status.types.includes("flutter") ? flutterOptionLabels(status.options) : "";
|
|
73
|
+
lines.push(`옵션 : nexus=${boolLabel(status.options.nexus)} secret_backup=${boolLabel(status.options.secretBackup)} semver_auto=${semverAutoLabel}${flutterLabels}`);
|
|
66
74
|
if (status.modifiedFiles.length) {
|
|
67
75
|
lines.push("", `사용자가 수정한 워크플로우 파일 (${status.modifiedFiles.length}개):`);
|
|
68
76
|
for (const f of status.modifiedFiles) lines.push(` - ${f}`);
|
|
@@ -82,3 +90,13 @@ export function printStatus(status) {
|
|
|
82
90
|
lines.push("");
|
|
83
91
|
console.log(lines.join("\n"));
|
|
84
92
|
}
|
|
93
|
+
|
|
94
|
+
// Flutter 타입일 때만 붙는 옵션 (이슈 #131). 저장값이 없으면 그 상태에서 실제로 적용되는 동작을 함께 알린다.
|
|
95
|
+
function flutterOptionLabels(options) {
|
|
96
|
+
return [
|
|
97
|
+
` env_mode=${options.envMode ?? "미설정(dotenv 유지)"}`,
|
|
98
|
+
` flutter_store=${options.flutterStore ?? "미설정(둘 다 설치)"}`,
|
|
99
|
+
` android_deploy_mode=${options.androidDeployMode ?? "미설정(store_only)"}`,
|
|
100
|
+
` ios_deploy_mode=${options.iosDeployMode ?? "미설정(store_only)"}`,
|
|
101
|
+
].join("");
|
|
102
|
+
}
|
package/src/context.js
CHANGED
|
@@ -27,6 +27,12 @@ export function createContext(overrides = {}) {
|
|
|
27
27
|
includeNexus: null, // null=미설정, true/false=명시
|
|
28
28
|
includeSecretBackup: null,
|
|
29
29
|
includeSemverAuto: null, // null=미설정(다운스트림에서 true로 해석), true/false=명시
|
|
30
|
+
// Flutter 옵션 (이슈 #131) — Flutter 타입이 없는 프로젝트에서는 전부 무시된다.
|
|
31
|
+
// 결정은 src/core/flutter-options.js의 resolveFlutterOptions가 한다.
|
|
32
|
+
envMode: "", // "dart-define" | "dotenv". ""=미결정 → 템플릿 기본값(dart-define)
|
|
33
|
+
flutterStore: null, // 스토어 배포 대상 string[] (예: ["android","ios"]). null=미결정 → 둘 다(현행 동작)
|
|
34
|
+
androidDeployMode: "", // store_only | store_prepare | store_submit. ""=미결정 → store_only
|
|
35
|
+
iosDeployMode: "", // 위와 동일 (iOS)
|
|
30
36
|
templateVersion: "",
|
|
31
37
|
deployValues: new Map(), // "type.KEY" -> value
|
|
32
38
|
counters: {},
|
package/src/core/breaking.js
CHANGED
|
@@ -13,13 +13,17 @@ export function compareVersions(a, b) {
|
|
|
13
13
|
// breaking-changes.json에서 current < ver <= target 범위 항목 수집.
|
|
14
14
|
// ⚠️ .sh 버그 수정: target은 하드코딩 1.3.14가 아니라 실제 templateVersion을 넘긴다 (D2).
|
|
15
15
|
// _ 로 시작하는 키(메타) 제외. severity critical / 그 외(warning).
|
|
16
|
+
// 버전 키의 값은 항목 객체 또는 항목 객체의 배열(같은 릴리스에 고지가 여러 건일 때).
|
|
16
17
|
export function collectBreaking(json, current, target) {
|
|
17
18
|
const critical = [], warnings = [];
|
|
18
|
-
for (const [ver,
|
|
19
|
+
for (const [ver, value] of Object.entries(json || {})) {
|
|
19
20
|
if (ver.startsWith("_")) continue;
|
|
20
21
|
if (compareVersions(current, ver) < 0 && compareVersions(ver, target) <= 0) {
|
|
21
|
-
|
|
22
|
-
(entry
|
|
22
|
+
// 한 릴리스에 고지가 여러 건이면 배열로 등록한다 — JSON 키(버전)는 유일해서 객체로는 한 건만 담긴다.
|
|
23
|
+
for (const entry of Array.isArray(value) ? value : [value]) {
|
|
24
|
+
const rec = { version: ver, ...entry };
|
|
25
|
+
(entry?.severity === "critical" ? critical : warnings).push(rec);
|
|
26
|
+
}
|
|
23
27
|
}
|
|
24
28
|
}
|
|
25
29
|
return { critical, warnings };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Flutter 앱 소유 파일(fastlane·ExportOptions.plist) 설치 (이슈 #131).
|
|
2
|
+
// 워크플로우와 달리 사용자가 값을 채워 넣는 파일이라 "없을 때만 생성"한다 — 덮어쓰지 않고 baseline
|
|
3
|
+
// 3-way도 적용하지 않는다(copyWorkflows의 secret-backup 경로와 같은 선례). 원본 갱신을 기존 사용자에게
|
|
4
|
+
// 전파하는 것은 범위 밖이다.
|
|
5
|
+
import { join, posix } from "node:path";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { copyFileSync } from "../fsutil.js";
|
|
8
|
+
import { storeAppFilesFor } from "../flutter-options.js";
|
|
9
|
+
|
|
10
|
+
const FLUTTER_APP_DIR = "flutter-app"; // payload/flutter-app/
|
|
11
|
+
|
|
12
|
+
// 이번 실행에서 다룰 파일의 (보고용 상대경로, 원본, 목적지) 목록.
|
|
13
|
+
// 대상은 Flutter 타입이 있을 때만이고, 선택된 플랫폼(context.flutterStore, null이면 둘 다)의 파일로 한정한다.
|
|
14
|
+
function flutterAppTargets(context, payloadRoot, targetRoot) {
|
|
15
|
+
const { types = [], paths = new Map(), flutterStore = null } = context;
|
|
16
|
+
if (!types.includes("flutter")) return [];
|
|
17
|
+
const flutterRoot = paths.get("flutter") || ".";
|
|
18
|
+
return storeAppFilesFor(flutterStore).map((rel) => ({
|
|
19
|
+
reported: posix.join(flutterRoot, rel),
|
|
20
|
+
src: join(payloadRoot, FLUTTER_APP_DIR, rel),
|
|
21
|
+
dst: join(targetRoot, flutterRoot, rel),
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 읽기 전용 — status/dry-run이 쓴다. 원본 payload는 읽지 않는다.
|
|
26
|
+
export function planFlutterAppFiles(context, payloadRoot, targetRoot = ".") {
|
|
27
|
+
const result = { created: [], kept: [] };
|
|
28
|
+
for (const { reported, dst } of flutterAppTargets(context, payloadRoot, targetRoot)) {
|
|
29
|
+
(existsSync(dst) ? result.kept : result.created).push(reported);
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function copyFlutterAppFiles(context, payloadRoot, targetRoot = ".") {
|
|
35
|
+
const result = { created: [], kept: [] };
|
|
36
|
+
for (const { reported, src, dst } of flutterAppTargets(context, payloadRoot, targetRoot)) {
|
|
37
|
+
if (existsSync(dst)) { result.kept.push(reported); continue; }
|
|
38
|
+
copyFileSync(src, dst); // 부모 디렉토리 생성 + 바이트 그대로(CRLF 보존)
|
|
39
|
+
result.created.push(reported);
|
|
40
|
+
}
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// 동기 엔진(copyWorkflows)에 hooks.decisions로 전달한다 — 기존 시그니처·force 동작 무변경.
|
|
5
5
|
import { join, basename } from "node:path";
|
|
6
6
|
import { deployFilter, isDeployWorkflow, activateDeployTrigger, DEFAULT_DEPLOY_STYLE, NO_DEPLOY_STYLE } from "../deploy-style.js";
|
|
7
|
+
import { storeWorkflowFilter } from "../flutter-options.js";
|
|
7
8
|
import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
8
9
|
import { PATHS, PAYLOAD } from "../paths.js";
|
|
9
10
|
import { exists, writeText, listYamlFiles } from "../fsutil.js";
|
|
@@ -30,6 +31,17 @@ const TRUNK_BASED_EXCLUDED = new Set([
|
|
|
30
31
|
"PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml",
|
|
31
32
|
]);
|
|
32
33
|
|
|
34
|
+
// 타입 루트 디렉토리에 거는 파일 필터 — "배포 안 함"의 CD 제외와 Flutter 스토어 대상 선택을 합성한다.
|
|
35
|
+
// copyWorkflowsForType·surveyWorkflows·planWorkflows가 같은 함수를 써야 설치·충돌 조사·status/dry-run이
|
|
36
|
+
// 서로 다른 파일 집합을 보지 않는다. flutterStore가 배열이 아니면(null=미결정, 비대화형 기본) 스토어 필터는
|
|
37
|
+
// 걸지 않는다. 필터가 없어도 항상 함수를 돌려준다 — processDir은 null을 받지 못한다.
|
|
38
|
+
export function buildTypeRootFilter(type, deployStyle, flutterStore) {
|
|
39
|
+
const filters = [];
|
|
40
|
+
if (deployStyle === NO_DEPLOY_STYLE) filters.push(deployFilter(deployStyle));
|
|
41
|
+
if (type === "flutter" && Array.isArray(flutterStore)) filters.push(storeWorkflowFilter(flutterStore));
|
|
42
|
+
return (filename) => filters.every((keep) => keep(filename));
|
|
43
|
+
}
|
|
44
|
+
|
|
33
45
|
// 한 파일에 env 치환을 적용해 대상 파일을 갱신 (.sh configure_workflow_env 등가).
|
|
34
46
|
// values/useDefaults: env 계획(promptEnvPlan) 결과 — 미지정이면 기본값 경로(현행 force 동작).
|
|
35
47
|
function configureEnv(targetPath, { type, projectPath = ".", repoName = "", resolvers = {}, collectAsks = null, values = new Map(), useDefaults = true }) {
|
|
@@ -143,6 +155,7 @@ function processDir(srcDir, workflowsDir, envOpts, ctx, counters, filter = () =>
|
|
|
143
155
|
// copy_workflows 본체 (동기 — 기존 호출부 무변경).
|
|
144
156
|
// context: { types:[], paths:Map, includeNexus, includeSecretBackup, force, repoName, resolvers,
|
|
145
157
|
// envValues?:Map<key,value>, envUseDefaults?:boolean } ← env 계획(promptEnvPlan) 결과 주입점
|
|
158
|
+
// flutterStore?:string[]|null } ← Flutter 스토어 대상 (null=필터 없음, 배열=선택된 플랫폼만)
|
|
146
159
|
// hooks: { decisions?: Map<filename, 'skip'|'backup'|'template'>, — 진짜 충돌(changed) 결정
|
|
147
160
|
// restoreRemoved?: Set<filename> } — 사용자가 지운 파일 중 복원할 것
|
|
148
161
|
// 미지정 파일은 'skip'(현행 force 동작 100% 유지). 대화형 수집은 copyWorkflowsInteractive 참조.
|
|
@@ -270,7 +283,7 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters, srcTe
|
|
|
270
283
|
// conflicts — 양쪽이 다 바뀐 진짜 충돌. upstreamOnly/localOnly는 자동 처리되므로 여기 없다.
|
|
271
284
|
// removed — 우리가 깔았는데 사용자가 지운 파일. 되살리기 전에 물어봐야 한다.
|
|
272
285
|
export function surveyWorkflows(context, payloadRoot, targetRoot = ".") {
|
|
273
|
-
const { types = [], paths = new Map(), includeNexus = false, repoName = "", resolvers = {} } = context;
|
|
286
|
+
const { types = [], paths = new Map(), includeNexus = false, repoName = "", resolvers = {}, flutterStore = null } = context;
|
|
274
287
|
const workflowsDir = join(targetRoot, PATHS.workflowsDir);
|
|
275
288
|
const projectTypesDir = join(payloadRoot, PAYLOAD.workflowsDir);
|
|
276
289
|
const deployStyle = context.deployStyle || DEFAULT_DEPLOY_STYLE;
|
|
@@ -297,7 +310,7 @@ export function surveyWorkflows(context, payloadRoot, targetRoot = ".") {
|
|
|
297
310
|
const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers };
|
|
298
311
|
const typeDir = join(projectTypesDir, type);
|
|
299
312
|
if (exists(typeDir)) {
|
|
300
|
-
collect(typeDir, envOpts, type, () => false, deployStyle
|
|
313
|
+
collect(typeDir, envOpts, type, () => false, buildTypeRootFilter(type, deployStyle, flutterStore));
|
|
301
314
|
}
|
|
302
315
|
const serverDeployDir = join(typeDir, "server-deploy");
|
|
303
316
|
if (exists(serverDeployDir) && !includeNexus && deployStyle !== NO_DEPLOY_STYLE) {
|
|
@@ -328,8 +341,9 @@ export async function copyWorkflowsInteractive(context, payloadRoot, targetRoot
|
|
|
328
341
|
}
|
|
329
342
|
|
|
330
343
|
function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters) {
|
|
331
|
-
const { includeNexus, deployStyle = "", envOptsFor, collectAsks = null, dirCtx } = ctx;
|
|
344
|
+
const { includeNexus, deployStyle = "", flutterStore = null, envOptsFor, collectAsks = null, dirCtx } = ctx;
|
|
332
345
|
const keepDeploy = deployFilter(deployStyle);
|
|
346
|
+
const keepTypeRoot = buildTypeRootFilter(type, deployStyle, flutterStore);
|
|
333
347
|
const { srcText, baselineTargets } = dirCtx;
|
|
334
348
|
const typeDir = join(projectTypesDir, type);
|
|
335
349
|
const envOpts = envOptsFor(type);
|
|
@@ -341,8 +355,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
341
355
|
// 바로 있는 타입도 있다 — "배포 안 함"일 때는 타입 루트에서도 CD 파일(SIMPLE-CICD 등)을
|
|
342
356
|
// 걸러야 한다. simple/nginx/traefik은 오늘과 동일하게 필터 없이 전부 복사한다.
|
|
343
357
|
if (exists(typeDir)) {
|
|
344
|
-
const
|
|
345
|
-
const c = processDir(typeDir, workflowsDir, envOpts, dirCtx, counters, typeRootFilter);
|
|
358
|
+
const c = processDir(typeDir, workflowsDir, envOpts, dirCtx, counters, keepTypeRoot);
|
|
346
359
|
untouched.push(...c.unchanged, ...c.localOnly);
|
|
347
360
|
}
|
|
348
361
|
|
|
@@ -374,7 +387,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
374
387
|
for (const filename of listYamlFiles(srcDir)) {
|
|
375
388
|
const target = join(workflowsDir, filename);
|
|
376
389
|
if (srcDir === serverDeployDir && !keepDeploy(filename)) continue; // 안 고른 배포 방식
|
|
377
|
-
if (srcDir === typeDir &&
|
|
390
|
+
if (srcDir === typeDir && !keepTypeRoot(filename)) continue; // 타입 루트: 배제된 CD·안 고른 스토어 워크플로우
|
|
378
391
|
if (!existsSync(target)) continue; // 건너뛴 파일 제외
|
|
379
392
|
if (untouched.includes(filename)) continue; // unchanged/localOnly 제외
|
|
380
393
|
configureEnv(target, { ...envOpts, collectAsks }); // env 계획 values/useDefaults 포함
|
|
@@ -386,7 +399,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
386
399
|
// changed뿐 아니라 newFiles/unchanged까지 전부 반환한다는 점이 listWorkflowConflicts(changed만
|
|
387
400
|
// 반환)와 다르다(읽기 전용 — 실제로 아무 파일도 쓰지 않는다).
|
|
388
401
|
export function planWorkflows(context, payloadRoot, targetRoot = ".") {
|
|
389
|
-
const { types = [], paths = new Map(), includeNexus = false, includeSecretBackup = false, repoName = "", resolvers = {} } = context;
|
|
402
|
+
const { types = [], paths = new Map(), includeNexus = false, includeSecretBackup = false, repoName = "", resolvers = {}, flutterStore = null } = context;
|
|
390
403
|
const workflowsDir = join(targetRoot, PATHS.workflowsDir);
|
|
391
404
|
const projectTypesDir = join(payloadRoot, PAYLOAD.workflowsDir);
|
|
392
405
|
const deployStyle = context.deployStyle || DEFAULT_DEPLOY_STYLE;
|
|
@@ -427,8 +440,7 @@ export function planWorkflows(context, payloadRoot, targetRoot = ".") {
|
|
|
427
440
|
const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers };
|
|
428
441
|
const typeDir = join(projectTypesDir, type);
|
|
429
442
|
if (exists(typeDir)) {
|
|
430
|
-
|
|
431
|
-
merge(classify(typeDir, workflowsDir, envOpts, srcText, baseline, typeRootFilter), type);
|
|
443
|
+
merge(classify(typeDir, workflowsDir, envOpts, srcText, baseline, buildTypeRootFilter(type, deployStyle, flutterStore)), type);
|
|
432
444
|
}
|
|
433
445
|
|
|
434
446
|
const serverDeployDir = join(typeDir, "server-deploy");
|
package/src/core/detect-fs.js
CHANGED
|
@@ -116,7 +116,9 @@ export function findSpringAppYml(root, base = ".") {
|
|
|
116
116
|
|
|
117
117
|
// 실 resolver 세트 생성 (.sh resolve_token 4종 등가) — index/interactive 공용.
|
|
118
118
|
// paths: Map<type, path> (모노레포 경로).
|
|
119
|
-
|
|
119
|
+
// flutterOptions: resolveFlutterOptions 결과 또는 같은 필드를 가진 context (이슈 #131). null이면 Flutter 토큰이
|
|
120
|
+
// 빈 값이라 템플릿 기본값(dart-define, store_only)이 그대로 남는다.
|
|
121
|
+
export function makeResolvers(root, repoName, paths, flutterOptions = null) {
|
|
120
122
|
const springBase = (t) => paths.get(t || "spring") || paths.get("spring") || ".";
|
|
121
123
|
return {
|
|
122
124
|
repo: () => repoName,
|
|
@@ -130,5 +132,11 @@ export function makeResolvers(root, repoName, paths) {
|
|
|
130
132
|
},
|
|
131
133
|
"spring-app-yml-path": (t) => findSpringAppYml(root, springBase(t)) || "",
|
|
132
134
|
"flutter-root": () => paths.get("flutter") || ".",
|
|
135
|
+
// CI changes job의 경로 필터(이슈 #131) — 타입별 프로젝트 루트. 단일 레포·common은 "."(항상 변경됨으로 판정).
|
|
136
|
+
"project-path": (t) => paths.get(t) || ".",
|
|
137
|
+
// 빈 문자열이면 setEnvLine/setFallbackLine이 줄을 건너뛰어 템플릿 기본값이 남는다.
|
|
138
|
+
"flutter-env-mode": () => flutterOptions?.envMode || "",
|
|
139
|
+
"android-deploy-mode": () => flutterOptions?.androidDeployMode || "",
|
|
140
|
+
"ios-deploy-mode": () => flutterOptions?.iosDeployMode || "",
|
|
133
141
|
};
|
|
134
142
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Flutter 옵션 (이슈 #131) — 환경변수 방식·스토어 배포 대상·배포 모드의 단일 진실.
|
|
2
|
+
// 스토어 배포 대상은 deploy-style.js(배포 방식)와 같은 구조 — 값 목록, 파일 필터, 선택 해제 정리 — 를 따른다.
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
5
|
+
import { sha256 } from "./baseline.js";
|
|
6
|
+
|
|
7
|
+
// 환경변수 주입 방식. dart-define은 --dart-define-from-file, dotenv는 flutter_dotenv/envied용 .env 생성.
|
|
8
|
+
// 둘을 동시에 쓰는 both는 만들지 않는다 — 환경변수를 두 곳에서 관리하게 되기 때문.
|
|
9
|
+
export const ENV_MODES = ["dart-define", "dotenv"];
|
|
10
|
+
export const DEFAULT_ENV_MODE = "dart-define"; // 신규 설치 기본
|
|
11
|
+
export const LEGACY_ENV_MODE = "dotenv"; // 기존 설치(version.yml 있음, 저장값 없음)가 조용히 깨지지 않도록 보존
|
|
12
|
+
|
|
13
|
+
export const STORE_PLATFORMS = ["android", "ios"];
|
|
14
|
+
export const DEPLOY_MODES = ["store_only", "store_prepare", "store_submit"];
|
|
15
|
+
export const DEFAULT_DEPLOY_MODE = "store_only";
|
|
16
|
+
export const NO_STORE = "none";
|
|
17
|
+
|
|
18
|
+
export const isEnvMode = (v) => ENV_MODES.includes(v);
|
|
19
|
+
export const isDeployMode = (v) => DEPLOY_MODES.includes(v);
|
|
20
|
+
|
|
21
|
+
// "android,ios" | "android" | "none" | "" → 배열. 항상 STORE_PLATFORMS 순서라 직렬화가 결정적이다.
|
|
22
|
+
// 알 수 없는 토큰이 하나라도 있거나 문자열이 아니면 null — 호출부가 "값 없음/잘못된 값"으로 처리한다.
|
|
23
|
+
export function parseStoreList(csv) {
|
|
24
|
+
if (typeof csv !== "string") return null;
|
|
25
|
+
const tokens = csv.split(",").map((t) => t.trim()).filter((t) => t !== "");
|
|
26
|
+
if (tokens.length === 0 || (tokens.length === 1 && tokens[0] === NO_STORE)) return [];
|
|
27
|
+
if (!tokens.every((t) => STORE_PLATFORMS.includes(t))) return null;
|
|
28
|
+
return STORE_PLATFORMS.filter((p) => tokens.includes(p));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 저장용 직렬화 — 빈 배열은 빈 문자열이 아니라 "none"으로 적어 "선택 안 함"과 "값 없음"을 구분한다.
|
|
32
|
+
export function formatStoreList(stores) {
|
|
33
|
+
const ordered = STORE_PLATFORMS.filter((p) => stores.includes(p));
|
|
34
|
+
return ordered.length ? ordered.join(",") : NO_STORE;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 플랫폼별 스토어 워크플로우 (payload/workflows/flutter/ 기준 파일명).
|
|
38
|
+
// FIREBASE·SELFHOSTED·TEST-APK·APP-BUILD-TRIGGER·CI는 스토어와 무관해 여기 넣지 않는다 — 항상 설치된다.
|
|
39
|
+
export const STORE_WORKFLOWS = {
|
|
40
|
+
android: ["PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml"],
|
|
41
|
+
ios: ["PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml", "PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml"],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const isStoreWorkflow = (filename) => Object.values(STORE_WORKFLOWS).flat().includes(filename);
|
|
45
|
+
|
|
46
|
+
// 파일 필터 — stores가 null이면 미결정이라 현행 동작(전부 설치)이다.
|
|
47
|
+
// 배열이면 스토어 워크플로우는 선택된 플랫폼 것만 통과하고, 그 외 파일은 항상 통과한다.
|
|
48
|
+
export function storeWorkflowFilter(stores) {
|
|
49
|
+
if (stores === null || stores === undefined) return () => true;
|
|
50
|
+
const allowed = new Set(STORE_PLATFORMS.filter((p) => stores.includes(p)).flatMap((p) => STORE_WORKFLOWS[p]));
|
|
51
|
+
return (filename) => !isStoreWorkflow(filename) || allowed.has(filename);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 선택 해제한 스토어 워크플로우 정리 — cleanupOtherDeployWorkflows(deploy-style.js)와 같은 규칙이다.
|
|
55
|
+
// 손대지 않은 것(baseline의 installed 해시와 동일) → 삭제
|
|
56
|
+
// 손댄 것 → .bak으로 옮긴다 (내용 보존, 트리거만 죽인다)
|
|
57
|
+
// 사용자 소유인 Fastfile·ExportOptions.plist는 여기서 다루지 않는다 (워크플로우 파일만 대상).
|
|
58
|
+
// 반환: { removed:[], backedUp:[] }
|
|
59
|
+
export function cleanupDeselectedStoreWorkflows(workflowsDir, installedFilenames, stores, baseline) {
|
|
60
|
+
const keep = storeWorkflowFilter(stores);
|
|
61
|
+
const removed = [];
|
|
62
|
+
const backedUp = [];
|
|
63
|
+
|
|
64
|
+
for (const filename of installedFilenames) {
|
|
65
|
+
if (!isStoreWorkflow(filename) || keep(filename)) continue;
|
|
66
|
+
const p = join(workflowsDir, filename);
|
|
67
|
+
if (!existsSync(p)) continue;
|
|
68
|
+
|
|
69
|
+
const known = baseline?.files?.[filename]?.installed;
|
|
70
|
+
const untouched = known && sha256(readFileSync(p, "utf8")) === known;
|
|
71
|
+
if (untouched) {
|
|
72
|
+
rmSync(p, { force: true });
|
|
73
|
+
removed.push(filename);
|
|
74
|
+
} else {
|
|
75
|
+
renameSync(p, `${p}.bak`);
|
|
76
|
+
backedUp.push(filename);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { removed, backedUp };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 스토어 앱 파일 (payload/flutter-app/ 기준 상대경로) — 플랫폼별 묶음.
|
|
83
|
+
export const STORE_APP_FILES = {
|
|
84
|
+
android: ["android/fastlane/Fastfile.playstore"],
|
|
85
|
+
ios: ["ios/fastlane/Fastfile", "ios/ExportOptions.plist"],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// 선택된 플랫폼의 앱 파일 목록. stores가 null이면 전부 (현행 동작 = 둘 다 설치).
|
|
89
|
+
export function storeAppFilesFor(stores) {
|
|
90
|
+
const platforms = stores === null || stores === undefined
|
|
91
|
+
? STORE_PLATFORMS
|
|
92
|
+
: STORE_PLATFORMS.filter((p) => stores.includes(p));
|
|
93
|
+
return platforms.flatMap((p) => STORE_APP_FILES[p]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const validOr = (isValid, value, fallback) => (isValid(value) ? value : fallback);
|
|
97
|
+
|
|
98
|
+
// 옵션 최종 결정 — 우선순위: CLI > version.yml 저장값 > 기본값.
|
|
99
|
+
// cli { envMode:"", stores:null|string[], androidDeployMode:"", iosDeployMode:"" } (빈값/null = 미지정)
|
|
100
|
+
// existing parseExisting() 결과 또는 null (version.yml이 없으면 신규 설치)
|
|
101
|
+
//
|
|
102
|
+
// - envMode 기본값: 신규 설치와 "Flutter를 새로 추가하는" 기존 설치는 dart-define, 이미 Flutter가
|
|
103
|
+
// 설치돼 있던 프로젝트는 dotenv. 업데이트 한 번에 flutter_dotenv 프로젝트가 조용히 깨지지 않게
|
|
104
|
+
// 하려는 것이라, 판단 기준은 "이 프로젝트에 Flutter가 이미 있었는가"다(단순 existing 존재 여부가
|
|
105
|
+
// 아니다 — Spring 전용 프로젝트에 flutter 타입을 처음 추가하는 경우까지 dotenv로 묶으면 안 된다).
|
|
106
|
+
// - stores: null은 "미결정" — 비대화형은 현행 동작(둘 다 설치), 대화형은 질문한다.
|
|
107
|
+
// - 저장값은 유효할 때만 쓴다. version.yml은 사람이 고칠 수 있는데, 그 값이 워크플로우 표현식
|
|
108
|
+
// (`|| 'store_only'` 폴백 자리)에 그대로 들어가므로 목록 밖 문자열은 걸러야 한다.
|
|
109
|
+
export function resolveFlutterOptions({ cli = {}, existing = null } = {}) {
|
|
110
|
+
const saved = existing?.options ?? {};
|
|
111
|
+
const hadFlutterAlready = Array.isArray(existing?.types) && existing.types.includes("flutter");
|
|
112
|
+
return {
|
|
113
|
+
envMode: cli.envMode || validOr(isEnvMode, saved.envMode, hadFlutterAlready ? LEGACY_ENV_MODE : DEFAULT_ENV_MODE),
|
|
114
|
+
stores: cli.stores ?? parseStoreList(saved.flutterStore),
|
|
115
|
+
androidDeployMode: cli.androidDeployMode || validOr(isDeployMode, saved.androidDeployMode, DEFAULT_DEPLOY_MODE),
|
|
116
|
+
iosDeployMode: cli.iosDeployMode || validOr(isDeployMode, saved.iosDeployMode, DEFAULT_DEPLOY_MODE),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// 이미 설치된 스토어 배포 워크플로우 파일명으로 플랫폼을 추론한다 (이슈 #131).
|
|
2
|
+
// version.yml에 flutter_store 저장값이 없는 기존 설치가 대상이다 — 저장값이 없다고 "선택 없음"으로 보면
|
|
3
|
+
// 잘 쓰던 스토어 워크플로우가 선택 해제 정리 규칙에 의해 삭제된다.
|
|
4
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
5
|
+
import { STORE_PLATFORMS, STORE_WORKFLOWS } from "./flutter-options.js";
|
|
6
|
+
|
|
7
|
+
// 반환: 스토어 워크플로우가 하나라도 설치된 플랫폼 (STORE_PLATFORMS 순서).
|
|
8
|
+
export function inferInstalledStores(workflowsDir) {
|
|
9
|
+
if (!existsSync(workflowsDir)) return [];
|
|
10
|
+
const installed = new Set(readdirSync(workflowsDir));
|
|
11
|
+
return STORE_PLATFORMS.filter((platform) => STORE_WORKFLOWS[platform].some((file) => installed.has(file)));
|
|
12
|
+
}
|
package/src/core/verify.js
CHANGED
|
@@ -6,8 +6,8 @@ import { join } from "node:path";
|
|
|
6
6
|
import { existsSync, readFileSync } from "node:fs";
|
|
7
7
|
|
|
8
8
|
// 치환 대상이 아닌 토큰 — 워크플로우 스크립트 안의 heredoc 구분자다. 값이 아니라 문법이므로
|
|
9
|
-
// 미치환 검사에서 제외한다. (예: cat <<'
|
|
10
|
-
const SENTINEL_RE = /^
|
|
9
|
+
// 미치환 검사에서 제외한다. (예: cat <<'__WIZARD_FILE_CONTENT_EOF__')
|
|
10
|
+
const SENTINEL_RE = /^__WIZARD_[A-Z0-9_]*__$/;
|
|
11
11
|
const PLACEHOLDER_RE = /__[A-Z][A-Z0-9_]*__/g;
|
|
12
12
|
|
|
13
13
|
// 주석으로 죽어 있는 줄 — 실행되지 않으므로 검사 대상이 아니다.
|