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.
Files changed (54) hide show
  1. package/README.md +83 -4
  2. package/package.json +1 -1
  3. package/payload/config/breaking-changes.json +24 -1
  4. package/payload/flutter-app/android/fastlane/Fastfile.playstore +86 -0
  5. package/payload/flutter-app/ios/ExportOptions.plist +31 -0
  6. package/payload/flutter-app/ios/fastlane/Fastfile +117 -0
  7. package/payload/scripts/__pycache__/changelog_manager.cpython-314.pyc +0 -0
  8. package/payload/scripts/__pycache__/issue_helper.cpython-314.pyc +0 -0
  9. package/payload/scripts/__pycache__/version_manager.cpython-314.pyc +0 -0
  10. package/payload/scripts/changelog_manager.py +2 -2
  11. package/payload/scripts/version_manager.py +2 -2
  12. package/payload/version.yml.template +1 -0
  13. package/payload/workflows/common/secret-backup/PROJECT-COMMON-SECRET-FILE-UPLOAD.yaml +12 -12
  14. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +48 -20
  15. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +44 -19
  16. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +28 -36
  17. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +33 -46
  18. package/payload/workflows/flutter/{PROJECT-FLUTTER-SUH-LAB-APP-BUILD-TRIGGER.yaml → PROJECT-FLUTTER-APP-BUILD-TRIGGER.yaml} +38 -38
  19. package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +123 -32
  20. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +57 -42
  21. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +61 -40
  22. package/payload/workflows/go/PROJECT-GO-CI.yaml +47 -0
  23. package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +40 -40
  24. package/payload/workflows/next/PROJECT-NEXT-CI.yaml +47 -0
  25. package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +47 -0
  26. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +40 -40
  27. package/payload/workflows/react/PROJECT-REACT-CI.yaml +47 -0
  28. package/payload/workflows/spring/nexus/PROJECT-SPRING-GITHUB-PACKAGES-PUBLISH.yml +1 -0
  29. package/payload/workflows/spring/nexus/PROJECT-SPRING-NEXUS-CI.yml +47 -0
  30. package/payload/workflows/spring/nexus/PROJECT-SPRING-NEXUS-PUBLISH.yml +1 -0
  31. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +39 -39
  32. package/src/cli/args.js +37 -0
  33. package/src/cli/help.js +4 -0
  34. package/src/commands/doctor.js +55 -2
  35. package/src/commands/dry-run.js +10 -0
  36. package/src/commands/full.js +28 -3
  37. package/src/commands/interactive-flutter.js +95 -0
  38. package/src/commands/interactive.js +51 -5
  39. package/src/commands/status.js +20 -2
  40. package/src/context.js +6 -0
  41. package/src/core/breaking.js +7 -3
  42. package/src/core/copy/flutter-app.js +42 -0
  43. package/src/core/copy/workflows.js +21 -9
  44. package/src/core/detect-fs.js +9 -1
  45. package/src/core/flutter-options.js +118 -0
  46. package/src/core/installed-stores.js +12 -0
  47. package/src/core/verify.js +2 -2
  48. package/src/core/version-yml.js +40 -5
  49. package/src/core/wizard-env.js +26 -4
  50. package/src/index.js +32 -1
  51. package/src/ui/env-plan.js +8 -5
  52. package/src/ui/prompts.js +88 -3
  53. package/src/ui/status-cards.js +13 -1
  54. 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
  }
@@ -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
- const resolvers = makeResolvers(targetRoot, repoName, existing.paths);
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
- lines.push(`옵션 : nexus=${boolLabel(status.options.nexus)} secret_backup=${boolLabel(status.options.secretBackup)} semver_auto=${semverAutoLabel}`);
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: {},
@@ -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, entry] of Object.entries(json || {})) {
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
- const rec = { version: ver, ...entry };
22
- (entry?.severity === "critical" ? critical : warnings).push(rec);
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 === NO_DEPLOY_STYLE ? keepDeploy : null);
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 typeRootFilter = deployStyle === NO_DEPLOY_STYLE ? keepDeploy : undefined;
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 && deployStyle === NO_DEPLOY_STYLE && !keepDeploy(filename)) continue; // 타입 루트 CD도 배제
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
- const typeRootFilter = deployStyle === NO_DEPLOY_STYLE ? deployFilter(deployStyle) : null;
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");
@@ -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
- export function makeResolvers(root, repoName, paths) {
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
+ }
@@ -6,8 +6,8 @@ import { join } from "node:path";
6
6
  import { existsSync, readFileSync } from "node:fs";
7
7
 
8
8
  // 치환 대상이 아닌 토큰 — 워크플로우 스크립트 안의 heredoc 구분자다. 값이 아니라 문법이므로
9
- // 미치환 검사에서 제외한다. (예: cat <<'__SUH_FILE_CONTENT_EOF__')
10
- const SENTINEL_RE = /^__SUH_[A-Z0-9_]*__$/;
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
  // 주석으로 죽어 있는 줄 — 실행되지 않으므로 검사 대상이 아니다.