project-auto-wizard 0.10.0 → 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 (46) 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/version.yml.template +1 -0
  11. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +48 -20
  12. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +44 -19
  13. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +28 -36
  14. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +32 -45
  15. package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +123 -32
  16. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +56 -41
  17. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +61 -40
  18. package/payload/workflows/go/PROJECT-GO-CI.yaml +47 -0
  19. package/payload/workflows/next/PROJECT-NEXT-CI.yaml +47 -0
  20. package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +47 -0
  21. package/payload/workflows/react/PROJECT-REACT-CI.yaml +47 -0
  22. package/payload/workflows/spring/nexus/PROJECT-SPRING-GITHUB-PACKAGES-PUBLISH.yml +1 -0
  23. package/payload/workflows/spring/nexus/PROJECT-SPRING-NEXUS-CI.yml +47 -0
  24. package/payload/workflows/spring/nexus/PROJECT-SPRING-NEXUS-PUBLISH.yml +1 -0
  25. package/src/cli/args.js +37 -0
  26. package/src/cli/help.js +4 -0
  27. package/src/commands/doctor.js +55 -2
  28. package/src/commands/dry-run.js +10 -0
  29. package/src/commands/full.js +28 -3
  30. package/src/commands/interactive-flutter.js +95 -0
  31. package/src/commands/interactive.js +51 -5
  32. package/src/commands/status.js +20 -2
  33. package/src/context.js +6 -0
  34. package/src/core/breaking.js +7 -3
  35. package/src/core/copy/flutter-app.js +42 -0
  36. package/src/core/copy/workflows.js +21 -9
  37. package/src/core/detect-fs.js +9 -1
  38. package/src/core/flutter-options.js +118 -0
  39. package/src/core/installed-stores.js +12 -0
  40. package/src/core/version-yml.js +40 -5
  41. package/src/core/wizard-env.js +26 -4
  42. package/src/index.js +32 -1
  43. package/src/ui/env-plan.js +8 -5
  44. package/src/ui/prompts.js +88 -3
  45. package/src/ui/status-cards.js +13 -1
  46. package/src/ui/summary.js +24 -5
@@ -11,9 +11,13 @@
11
11
  // ④ GitHub 설정 화면에 실제로 표시되는 문자열("Read and write permissions" 등)은 번역하지
12
12
  // 않는다. 번역하면 설명은 읽히지만 정작 화면에서 그 항목을 찾지 못한다.
13
13
  import { spawnSync } from "node:child_process";
14
- import { existsSync } from "node:fs";
15
- import { join } from "node:path";
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { join, posix } from "node:path";
16
16
  import { A, paint, colorEnabled, visualWidth } from "../ui/ansi.js";
17
+ import { PATHS } from "../core/paths.js";
18
+ import { parseExisting } from "../core/version-yml.js";
19
+ import { STORE_PLATFORMS, STORE_APP_FILES, parseStoreList, storeAppFilesFor } from "../core/flutter-options.js";
20
+ import { inferInstalledStores } from "../core/installed-stores.js";
17
21
 
18
22
  const defaultExec = (cmd, args) => spawnSync(cmd, args, { encoding: "utf8" });
19
23
 
@@ -23,6 +27,7 @@ const defaultExec = (cmd, args) => spawnSync(cmd, args, { encoding: "utf8" });
23
27
  const REPO_URL = "https://github.com/Twin-Fang/project-auto-wizard";
24
28
  export const DOC = {
25
29
  postInstall: `${REPO_URL}#post-install`,
30
+ flutterStore: `${REPO_URL}#flutter-store`,
26
31
  };
27
32
 
28
33
  export function runDoctor(cwd = process.cwd(), { exec = defaultExec } = {}) {
@@ -39,6 +44,9 @@ export function runDoctor(cwd = process.cwd(), { exec = defaultExec } = {}) {
39
44
  note: ["이 폴더엔 아직 설치되지 않았습니다 — 지금 설치하면 됩니다."],
40
45
  });
41
46
 
47
+ // Flutter 스토어 배포 파일 점검 (이슈 #131) — 로컬 파일만 보므로 gh 조회 결과와 무관하게 수행한다.
48
+ if (installed) for (const item of flutterStoreChecks(cwd)) add(item);
49
+
42
50
  const ghVersion = exec("gh", ["--version"]);
43
51
  if (ghVersion.error || ghVersion.status !== 0) {
44
52
  add({
@@ -163,6 +171,51 @@ export function runDoctor(cwd = process.cwd(), { exec = defaultExec } = {}) {
163
171
  return results;
164
172
  }
165
173
 
174
+ const PLATFORM_ROW_NAME = { android: "Flutter Android 배포 파일", ios: "Flutter iOS 배포 파일" };
175
+ const PLACEHOLDER_RE = /__[A-Z][A-Z0-9_]*__/g; // 감지 규칙은 ExportOptions.plist 템플릿·IOS-TESTFLIGHT 검증과 동일
176
+ const EXPORT_OPTIONS_REL = STORE_APP_FILES.ios.find((rel) => rel.endsWith("ExportOptions.plist"));
177
+
178
+ // Flutter 스토어 배포 진단 — 선택한 플랫폼의 필수 파일과 ExportOptions.plist 플레이스홀더 (이슈 #131).
179
+ // 스토어 시크릿 등록 여부는 이번 범위 밖이다. 반환: doctor 결과 행 배열.
180
+ function flutterStoreChecks(cwd) {
181
+ const existing = parseExisting(readFileSync(join(cwd, PATHS.versionFile), "utf8"));
182
+ if (!existing.types.includes("flutter")) return [];
183
+ const saved = existing.options.flutterStore == null ? null : parseStoreList(existing.options.flutterStore);
184
+ // 저장값 없는 기존 설치는 설치된 스토어 워크플로우로 추론한다 (interactive와 같은 규칙).
185
+ const stores = saved ?? inferInstalledStores(join(cwd, PATHS.workflowsDir));
186
+ const flutterRoot = existing.paths.get("flutter") || ".";
187
+ const rows = [];
188
+
189
+ for (const platform of STORE_PLATFORMS.filter((p) => stores.includes(p))) {
190
+ const files = storeAppFilesFor([platform]).map((rel) => posix.join(flutterRoot, rel));
191
+ const missing = files.filter((file) => !existsSync(join(cwd, file)));
192
+ const head = { name: PLATFORM_ROW_NAME[platform], purpose: "fastlane 스토어 업로드에 필요한 파일" };
193
+ rows.push(missing.length
194
+ ? {
195
+ ...head, status: "WARN", value: `없는 파일: ${missing.join(", ")}`,
196
+ impact: ["이 파일이 없으면 스토어 배포 워크플로우가 fastlane 단계에서 실패합니다."],
197
+ actions: ["마법사를 다시 실행하면 없는 파일만 새로 만들어 줍니다 (이미 있는 파일은 덮어쓰지 않음)"],
198
+ doc: DOC.flutterStore,
199
+ }
200
+ : { ...head, status: "OK", value: `${files.length}개 있음` });
201
+ }
202
+
203
+ const plistPath = posix.join(flutterRoot, EXPORT_OPTIONS_REL);
204
+ if (stores.includes("ios") && existsSync(join(cwd, plistPath))) {
205
+ const head = { name: "ExportOptions.plist", purpose: "iOS 서명·내보내기 설정" };
206
+ const left = [...new Set(readFileSync(join(cwd, plistPath), "utf8").match(PLACEHOLDER_RE) ?? [])];
207
+ rows.push(left.length
208
+ ? {
209
+ ...head, status: "WARN", value: `채워지지 않은 값: ${left.join(", ")}`,
210
+ impact: ["채우지 않으면 IOS-TESTFLIGHT 워크플로우가 ExportOptions.plist 검증 단계에서 중단됩니다."],
211
+ actions: [`${plistPath} 의 플레이스홀더를 실제 값(Team ID · 번들 ID · 프로비저닝 프로파일 이름)으로 바꾸세요`],
212
+ doc: DOC.flutterStore,
213
+ }
214
+ : { ...head, status: "OK", value: "플레이스홀더 없음" });
215
+ }
216
+ return rows;
217
+ }
218
+
166
219
  const asLines = (v) => (Array.isArray(v) ? v : v ? [String(v)] : []);
167
220
  const headOf = (r) => `${r.label || r.name}${r.purpose ? ` — ${r.purpose}` : ""}`;
168
221
 
@@ -4,6 +4,7 @@ import { existsSync, readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { PATHS } from "../core/paths.js";
6
6
  import { planWorkflows } from "../core/copy/workflows.js";
7
+ import { planFlutterAppFiles } from "../core/copy/flutter-app.js";
7
8
  import { planUninstall } from "./uninstall.js";
8
9
  import { renderVersionYml, parseExisting } from "../core/version-yml.js";
9
10
  import { readVersionYmlTemplate } from "../core/assets.js";
@@ -33,6 +34,8 @@ export function planDryRun(mode, context, payloadRoot, targetRoot = ".") {
33
34
  return {
34
35
  mode,
35
36
  workflows: planWorkflows(context, payloadRoot, targetRoot),
37
+ // Flutter 스토어 배포 파일(Fastfile·ExportOptions.plist) — 이미 있는 파일은 덮어쓰지 않고 유지한다.
38
+ flutterApp: planFlutterAppFiles(context, payloadRoot, targetRoot),
36
39
  versionYml: versionYmlPreview(context, payloadRoot, targetRoot),
37
40
  };
38
41
  }
@@ -57,6 +60,13 @@ export function printDryRun(plan) {
57
60
  for (const f of w.changed) lines.push(` ~ ${f.filename} [${f.type}]`);
58
61
  lines.push(`동일한 파일 (${w.unchanged.length}개, 변경 없음)`);
59
62
  }
63
+ if (plan.flutterApp && (plan.flutterApp.created.length || plan.flutterApp.kept.length)) {
64
+ const { created, kept } = plan.flutterApp;
65
+ lines.push(`Flutter 스토어 배포 파일 — 신규 (${created.length}개):`);
66
+ for (const f of created) lines.push(` + ${f}`);
67
+ lines.push(`Flutter 스토어 배포 파일 — 기존 파일 유지 (${kept.length}개, 덮어쓰지 않음):`);
68
+ for (const f of kept) lines.push(` = ${f} (기존 파일 유지)`);
69
+ }
60
70
  if (plan.versionYml) {
61
71
  lines.push(plan.versionYml.existed
62
72
  ? (plan.versionYml.changed ? "version.yml: 갱신될 예정" : "version.yml: 변경 없음")
@@ -1,5 +1,5 @@
1
1
  // full 모드 오케스트레이터 (.sh execute_integration full case 등가).
2
- // 복사 순서: workflows(+env 치환) → version.yml → readme → scripts → gitignore(조건부)
2
+ // 복사 순서: workflows(+env 치환) → flutter 앱 파일 → version.yml → readme → scripts → gitignore(조건부)
3
3
  // gitignore는 충돌 백업 부산물(.bak/.template.yaml)이 이번 실행에서 실제로 생겼을 때만 갱신한다 — issue #7.
4
4
  // (원본의 util/issue/discussion/setup-guide/config 설치는 project-auto-wizard 스코프에서 제외 — DESIGN-SPEC §2)
5
5
  import { join } from "node:path";
@@ -12,10 +12,12 @@ import { existingMarkerInDir } from "../core/paths-resolve.js";
12
12
  import { addVersionSectionToReadme } from "../core/copy/readme.js";
13
13
  import { copyWorkflows, computeBaselineEntries, makeSrcText } from "../core/copy/workflows.js";
14
14
  import { copyScripts } from "../core/copy/simple.js";
15
+ import { copyFlutterAppFiles } from "../core/copy/flutter-app.js";
15
16
  import { ensureGitignore } from "../core/copy/gitignore.js";
16
17
  import { readBaseline, writeBaseline } from "../core/baseline.js";
17
18
  import { scanUnsubstituted, collectRequiredSecrets, narrowSecretsBySshAuth } from "../core/verify.js";
18
19
  import { cleanupOtherDeployWorkflows, DEFAULT_DEPLOY_STYLE } from "../core/deploy-style.js";
20
+ import { cleanupDeselectedStoreWorkflows } from "../core/flutter-options.js";
19
21
  import { log, maskValue } from "../core/logger.js";
20
22
 
21
23
  // context: { version, types, paths:Map, branch, versionCode, includeNexus, includeSecretBackup,
@@ -47,6 +49,12 @@ export function runFull(context, payloadRoot, targetRoot = ".", hooks = {}) {
47
49
  const wfCounters = copyWorkflows(context, payloadRoot, targetRoot, hooks);
48
50
  const deployValues = wfCounters.deployValues || new Map(); // Map<type, Map<key,value>>
49
51
 
52
+ // 1-1. Flutter 앱 파일(fastlane·ExportOptions) — 사용자가 값을 채워 쓰는 파일이라 없을 때만 만든다.
53
+ // 워크플로우 복사가 끝난 뒤에 실행한다(워크플로우가 이 파일들을 전제로 돈다).
54
+ const flutterApp = copyFlutterAppFiles(context, payloadRoot, targetRoot);
55
+ for (const f of flutterApp.created) log.info("flutter-app", "create", f);
56
+ for (const f of flutterApp.kept) log.info("flutter-app", "keep", `${f} (기존 파일 유지)`);
57
+
50
58
  // 기존 version.yml의 알려지지 않은 최상위 필드를 재생성 시 보존한다 (issue #20 M8).
51
59
  const vyPath = join(targetRoot, PATHS.versionFile);
52
60
  const extraTopLevel = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")).extraTopLevel : [];
@@ -80,7 +88,24 @@ export function runFull(context, payloadRoot, targetRoot = ".", hooks = {}) {
80
88
 
81
89
  for (const f of cleanup.removed || []) log.info("cleanup", "remove", `${f} (이전 배포 방식 정리)`);
82
90
  for (const f of cleanup.backedUp || []) log.info("cleanup", "backup", `${f} → ${f}.bak`);
83
- const gitignoreUpdated = gitignoreUpdated0 || cleanup.backedUp.length > 0;
91
+
92
+ // 6-1. 선택 해제된 스토어 워크플로우 정리 — 스토어 대상을 줄여 재설치하면 이전 워크플로우가 남아
93
+ // main push마다 계속 도는 것을 막는다. 규칙은 6과 같다(미수정 삭제, 수정본 .bak).
94
+ // 선택이 미결정(null)이거나 Flutter가 없으면 현행 동작 그대로 아무것도 지우지 않는다.
95
+ // Fastfile·ExportOptions는 사용자 소유라 여기서 다루지 않는다.
96
+ const workflowsDir = join(targetRoot, PATHS.workflowsDir);
97
+ const storeCleanup = Array.isArray(context.flutterStore) && types.includes("flutter")
98
+ ? cleanupDeselectedStoreWorkflows(
99
+ workflowsDir,
100
+ existsSync(workflowsDir) ? readdirSync(workflowsDir) : [],
101
+ context.flutterStore,
102
+ previousBaseline)
103
+ : { removed: [], backedUp: [] };
104
+ for (const f of [...storeCleanup.removed, ...storeCleanup.backedUp]) delete previousBaseline?.files?.[f];
105
+ for (const f of storeCleanup.removed) log.info("cleanup", "remove", `${f} (선택 해제된 스토어 워크플로우 정리)`);
106
+ for (const f of storeCleanup.backedUp) log.info("cleanup", "backup", `${f} → ${f}.bak`);
107
+
108
+ const gitignoreUpdated = gitignoreUpdated0 || cleanup.backedUp.length > 0 || storeCleanup.backedUp.length > 0;
84
109
  if (gitignoreUpdated) ensureGitignore(targetRoot);
85
110
 
86
111
  // 7. baseline 기록 (issue #69) — 다음 업데이트에서 "누가 바꿨는지"를 가를 기준점.
@@ -122,7 +147,7 @@ export function runFull(context, payloadRoot, targetRoot = ".", hooks = {}) {
122
147
  ["결과", unresolved.length ? `주의 (미치환 ${unresolved.length}건)` : "OK"],
123
148
  ]);
124
149
 
125
- return { workflows: wfCounters, gitignoreUpdated, unresolved, secrets, cleanup };
150
+ return { workflows: wfCounters, gitignoreUpdated, unresolved, secrets, cleanup, storeCleanup, flutterApp };
126
151
  }
127
152
 
128
153
  // deployValues는 Map<type, Map<key,value>> — 타입 구분 없이 첫 값만 필요할 때 쓴다.
@@ -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
+ }