project-auto-wizard 0.13.0 → 0.13.2

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.
@@ -0,0 +1,125 @@
1
+ // Flutter 타입 훅 — types.js 레지스트리의 flutter 항목이 가리키는 구현 모음.
2
+ // 공통 코드는 flutter를 직접 알지 못하고 훅 이름으로만 호출한다(각 훅의 계약은 types.js 머리말 참고).
3
+ import { existsSync } from "node:fs";
4
+ import {
5
+ ENV_MODES, DEPLOY_MODES, DEFAULT_ENV_MODE, DEFAULT_DEPLOY_MODE, STORE_PLATFORMS, NO_STORE,
6
+ isEnvMode, isDeployMode, parseStoreList, formatStoreList, deployModeWarning,
7
+ storeWorkflowFilter, cleanupDeselectedStoreWorkflows, resolveFlutterOptions,
8
+ } from "./flutter-options.js";
9
+ import { planFlutterAppFiles, copyFlutterAppFiles } from "./copy/flutter-app.js";
10
+ import { flutterStoreChecks } from "./flutter-doctor.js";
11
+ import { inferInstalledStores } from "./installed-stores.js";
12
+ import { escapeYamlDoubleQuoted } from "./wizard-env.js";
13
+ import { CliError } from "./errors.js";
14
+
15
+ // 저장값이 없으면 그 상태에서 실제로 적용되는 동작을 함께 알린다.
16
+ function statusLabels(options) {
17
+ return [
18
+ ` env_mode=${options.envMode ?? "미설정(dotenv 유지)"}`,
19
+ ` flutter_store=${options.flutterStore ?? "미설정(둘 다 설치)"}`,
20
+ ` android_deploy_mode=${options.androidDeployMode ?? "미설정(store_only)"}`,
21
+ ` ios_deploy_mode=${options.iosDeployMode ?? "미설정(store_only)"}`,
22
+ ].join("");
23
+ }
24
+
25
+ // 스토어 저장값이 없는 기존 Flutter 설치는 설치돼 있는 스토어 워크플로우로 추론한다(대화형과 같은 결론).
26
+ // 추론하지 않으면 미결정이 "둘 다"로 확정 저장되어, 지웠던 플랫폼의 워크플로우·fastlane 파일이 되살아난다.
27
+ // workflowsDir가 없으면(status 등 읽기 전용 경로) 추론하지 않는다.
28
+ function resolveOptions({ opts = {}, existing = null, workflowsDir = null }) {
29
+ const inferredStores = workflowsDir && opts.flutterStore == null && existing?.types?.includes("flutter")
30
+ && existing.options?.flutterStore == null && existsSync(workflowsDir)
31
+ ? inferInstalledStores(workflowsDir) : null;
32
+ return resolveFlutterOptions({
33
+ cli: {
34
+ envMode: opts.flutterEnvMode ?? "", stores: opts.flutterStore ?? inferredStores,
35
+ androidDeployMode: opts.androidDeployMode ?? "", iosDeployMode: opts.iosDeployMode ?? "",
36
+ },
37
+ existing,
38
+ });
39
+ }
40
+
41
+ // version.yml options 아래 Flutter 블록(6칸 들여쓰기). 값이 비었으면 워크플로우 템플릿 기본값과 같은 값으로 채운다 —
42
+ // 저장값과 실제 설치 내용이 어긋나지 않게. stores가 null(미결정)이면 현행 동작대로 둘 다 설치되므로 "android,ios"로 기록한다.
43
+ function versionOptionsBlock({ envMode, stores, androidDeployMode, iosDeployMode } = {}) {
44
+ const quote = (v) => `"${escapeYamlDoubleQuoted(v)}"`;
45
+ return [
46
+ ` env_mode: ${quote(envMode || DEFAULT_ENV_MODE)} # ${ENV_MODES.join(" | ")} (Flutter 환경변수 주입 방식)`,
47
+ ` flutter_store: ${quote(formatStoreList(stores ?? STORE_PLATFORMS))} # android | ios | android,ios | none (스토어 배포 대상)`,
48
+ ` android_deploy_mode: ${quote(androidDeployMode || DEFAULT_DEPLOY_MODE)} # ${DEPLOY_MODES.join(" | ")} (Play Store 배포 모드)`,
49
+ ` ios_deploy_mode: ${quote(iosDeployMode || DEFAULT_DEPLOY_MODE)} # ${DEPLOY_MODES.join(" | ")} (iOS 배포 모드)`,
50
+ ].join("\n");
51
+ }
52
+
53
+ // 값 목록 검증 플래그 하나를 만든다 — 값이 목록 밖이면 CliError.
54
+ const enumFlag = (flag, field, isValid, allowed) => ({
55
+ flag, field, initial: "",
56
+ parse(v) {
57
+ if (!isValid(v)) throw new CliError(`${flag} 값이 올바르지 않습니다: ${v ?? "(없음)"} (${allowed.join(" | ")})`);
58
+ return v;
59
+ },
60
+ });
61
+
62
+ export const flutterHooks = {
63
+ resolveOptions,
64
+ // 확정된 옵션 → 설치 컨텍스트 필드. 기본값은 옵션이 없을 때의 "미결정" 상태다.
65
+ contextDefaults: {
66
+ envMode: "", // "dart-define" | "dotenv". ""=미결정 → 템플릿 기본값(dart-define)
67
+ flutterStore: null, // 스토어 배포 대상 string[] (예: ["android","ios"]). null=미결정 → 둘 다(현행 동작)
68
+ androidDeployMode: "", // store_only | store_prepare | store_submit. ""=미결정 → store_only
69
+ iosDeployMode: "", // 위와 동일 (iOS)
70
+ },
71
+ contextFields: (options) => ({
72
+ envMode: options.envMode,
73
+ flutterStore: options.stores,
74
+ androidDeployMode: options.androidDeployMode,
75
+ iosDeployMode: options.iosDeployMode,
76
+ }),
77
+ optionsFromContext: ({ envMode, flutterStore, androidDeployMode, iosDeployMode }) => ({
78
+ envMode, stores: flutterStore, androidDeployMode, iosDeployMode,
79
+ }),
80
+ versionOptionsBlock,
81
+ // version.yml 저장 키 → 파싱 결과 필드. 값은 원문 문자열로 돌려주고, 유효성 판정은 resolveOptions 몫이다.
82
+ savedOptionKeys: {
83
+ env_mode: "envMode", flutter_store: "flutterStore",
84
+ android_deploy_mode: "androidDeployMode", ios_deploy_mode: "iosDeployMode",
85
+ },
86
+ // CLI 플래그 — 파싱 결과 필드(opts)와 값 검증. initial은 미지정 값이다.
87
+ cliFlags: [
88
+ enumFlag("--flutter-env-mode", "flutterEnvMode", isEnvMode, ENV_MODES),
89
+ {
90
+ flag: "--flutter-store", field: "flutterStore", initial: null,
91
+ parse(v) {
92
+ // 빈 문자열은 parseStoreList가 []로 보지만, 스토어를 안 고르겠다는 뜻은 명시적인 none으로만 받는다.
93
+ const stores = v ? parseStoreList(v) : null;
94
+ if (stores === null) {
95
+ throw new CliError(`--flutter-store 값이 올바르지 않습니다: ${v || "(없음)"} (${[STORE_PLATFORMS.join(","), ...STORE_PLATFORMS, NO_STORE].join(" | ")})`);
96
+ }
97
+ return stores;
98
+ },
99
+ },
100
+ enumFlag("--android-deploy-mode", "androidDeployMode", isDeployMode, DEPLOY_MODES),
101
+ enumFlag("--ios-deploy-mode", "iosDeployMode", isDeployMode, DEPLOY_MODES),
102
+ ],
103
+ // 설치 직후 알릴 경고 — store_submit은 main push마다 심사를 자동 제출하므로 선택하지 않은 스토어에는 뜨면 안 된다.
104
+ installNotices: ({ stores, androidDeployMode, iosDeployMode }) => [
105
+ (stores === null || stores.includes("android")) && deployModeWarning(androidDeployMode),
106
+ (stores === null || stores.includes("ios")) && deployModeWarning(iosDeployMode),
107
+ ],
108
+ // 설치 로그에 남길 선택값 [이름, 값] 목록
109
+ logChoices: (context) => {
110
+ const stores = Array.isArray(context.flutterStore) ? (context.flutterStore.join(",") || "없음") : "미결정(둘 다)";
111
+ return [["flutter",
112
+ `env=${context.envMode || "-"} stores=${stores} android=${context.androidDeployMode || "-"} ios=${context.iosDeployMode || "-"}`]];
113
+ },
114
+ // 스토어 대상이 배열일 때만 스토어 워크플로우 필터를 건다(null=미결정 → 전부 설치).
115
+ workflowFilter: ({ flutterStore }) => (Array.isArray(flutterStore) ? storeWorkflowFilter(flutterStore) : null),
116
+ // 선택 해제된 스토어 워크플로우 정리 — 대상이 미결정(null)이면 아무것도 지우지 않는다.
117
+ cleanupWorkflows: (workflowsDir, installed, context, baseline, opts) => (Array.isArray(context.flutterStore)
118
+ ? cleanupDeselectedStoreWorkflows(workflowsDir, installed, context.flutterStore, baseline, opts)
119
+ : { removed: [], backedUp: [] }),
120
+ appFilesTag: "flutter-app",
121
+ planAppFiles: planFlutterAppFiles,
122
+ copyAppFiles: copyFlutterAppFiles,
123
+ statusLabels,
124
+ doctorChecks: flutterStoreChecks,
125
+ };
@@ -94,6 +94,11 @@ export function storeAppFilesFor(stores) {
94
94
  return platforms.flatMap((p) => STORE_APP_FILES[p]);
95
95
  }
96
96
 
97
+ // store_submit은 main push마다 심사를 제출하므로 고른 직후 한 줄로 알린다. 다른 모드는 알릴 것이 없어 빈 문자열.
98
+ export function deployModeWarning(mode) {
99
+ return mode === "store_submit" ? "store_submit을 고르면 main push마다 심사가 자동 제출됩니다." : "";
100
+ }
101
+
97
102
  const validOr = (isValid, value, fallback) => (isValid(value) ? value : fallback);
98
103
 
99
104
  // 옵션 최종 결정 — 우선순위: CLI > version.yml 저장값 > 기본값.
@@ -0,0 +1,67 @@
1
+ // uninstall·purge 공통 삭제 실행부 — 두 명령은 옵션을 삭제 계획으로 바꾸기만 하고,
2
+ // 실제 삭제와 기록은 여기서 한 번에 처리한다. 대상 판별은 removal-plan.js(읽기 전용)가 맡는다.
3
+ import { join, posix } from "node:path";
4
+ import { readdirSync, rmdirSync } from "node:fs";
5
+ import { PATHS } from "./paths.js";
6
+ import { remove } from "./fsutil.js";
7
+ import { removeVersionSectionFromReadme } from "./copy/readme.js";
8
+ import { removeAutoAddedEntriesFromGitignore } from "./copy/gitignore.js";
9
+ import { logRemovals } from "./logger.js";
10
+
11
+ // 파일을 지운 뒤 비게 된 상위 폴더를 레포 루트 직전까지 정리한다(마법사가 만든 fastlane/ 등).
12
+ // 비어 있지 않은 폴더를 만나면 멈추므로 사용자 파일이 남은 폴더는 건드리지 않는다.
13
+ export function pruneEmptyDirs(targetRoot, relDir) {
14
+ let rel = relDir;
15
+ while (rel && rel !== "." && rel !== "/") {
16
+ const abs = join(targetRoot, rel);
17
+ try {
18
+ if (readdirSync(abs).length) break;
19
+ rmdirSync(abs);
20
+ } catch { break; }
21
+ rel = posix.dirname(rel);
22
+ }
23
+ }
24
+
25
+ // Flutter 앱 파일 제거 — 미수정분만 plan에 들어오므로 그대로 지우고 빈 폴더를 정리한다.
26
+ // 로그는 삭제가 끝난 뒤 한꺼번에 남기므로 기록할 항목을 done에 모은다.
27
+ export function removeAppFiles(targetRoot, appFiles, done = []) {
28
+ for (const rel of appFiles) {
29
+ remove(join(targetRoot, rel));
30
+ pruneEmptyDirs(targetRoot, posix.dirname(rel));
31
+ done.push(["remove", "flutter-app", rel]);
32
+ }
33
+ }
34
+
35
+ // 설치물을 지운 뒤 비게 된 .github/workflows·.github/scripts(와 .github)를 정리한다.
36
+ // 이번에 그 폴더에서 무언가를 지웠을 때만 — 원래 비어 있던 폴더까지 손대지 않는다.
37
+ export function pruneInstallDirs(targetRoot, plan) {
38
+ if (plan.workflows.length || plan.baseline?.length) pruneEmptyDirs(targetRoot, PATHS.workflowsDir);
39
+ if (plan.scripts.length) pruneEmptyDirs(targetRoot, PATHS.scriptsDir);
40
+ }
41
+
42
+ // plan: { workflows, scripts, appFiles, baseline, versionYml, readme, gitignore, changelog } — 모두 "삭제 대상" 여부/목록.
43
+ // options.logOrder: 뒤쪽 항목(version·readme·gitignore·changelog)의 기록 순서 — 명령마다 기존 순서를 유지한다.
44
+ // options.gitignoreDetail(status): .gitignore 기록 문구. null을 돌려주면 기록하지 않는다.
45
+ // 반환: 실제로 제거된 결과 — readme/gitignore는 plan이 대상으로 봤어도 안전하게 포기(skip-*)될 수 있어 실제 결과로 덮어쓴다.
46
+ export function executeRemoval(targetRoot, plan, { logOrder, gitignoreDetail }) {
47
+ const wfDir = join(targetRoot, PATHS.workflowsDir);
48
+ const done = [];
49
+ for (const name of plan.workflows) { remove(join(wfDir, name)); done.push(["remove", "workflow", name]); }
50
+ for (const name of plan.scripts) { remove(join(targetRoot, PATHS.scriptsDir, name)); done.push(["remove", "script", name]); }
51
+ removeAppFiles(targetRoot, plan.appFiles, done);
52
+ for (const p of plan.baseline || []) { remove(join(targetRoot, p)); done.push(["remove", "metadata", p]); }
53
+ pruneInstallDirs(targetRoot, plan);
54
+
55
+ const entries = { version: [], readme: [], gitignore: [], changelog: [] };
56
+ const readme = !!plan.readme && removeVersionSectionFromReadme(targetRoot) === "removed";
57
+ const gitignoreStatus = plan.gitignore ? removeAutoAddedEntriesFromGitignore(targetRoot) : null;
58
+ const gitignore = gitignoreStatus === "removed" || gitignoreStatus === "file-deleted";
59
+ if (plan.versionYml) { remove(join(targetRoot, PATHS.versionFile)); entries.version.push(["remove", "version", PATHS.versionFile]); }
60
+ for (const f of plan.changelog || []) { remove(join(targetRoot, f)); entries.changelog.push(["remove", "changelog", f]); }
61
+ if (readme) entries.readme.push(["remove", "readme", "README.md 버전 섹션"]);
62
+ const detail = gitignoreStatus && gitignoreDetail(gitignoreStatus);
63
+ if (detail) entries.gitignore.push(["remove", "gitignore", detail]);
64
+ for (const k of logOrder) done.push(...entries[k]);
65
+ logRemovals(targetRoot, done);
66
+ return { readme, gitignore };
67
+ }
package/src/core/types.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { flutterHooks } from "./flutter-hooks.js";
2
+
1
3
  // 프로젝트 타입 레지스트리 — 타입별 지식(감지 마커, 감지 순서, 버전·빌드 번호 파일)을 한 곳에 둔다.
2
4
  // 타입 목록·마커 맵·감지 체인은 모두 이 표에서 만들어지므로, 새 타입은 여기에 한 줄을 더하는 것에서 시작한다.
3
5
  // (릴리스 시점의 버전 파일 쓰기는 payload/scripts/version_manager.py가 따로 담당한다.)
@@ -13,6 +15,26 @@
13
15
  // versionSources 설치 시 버전을 읽을 파일 키(detect.js의 sources) — 주 타입일 때 이 순서로 먼저 읽는다.
14
16
  // buildNumberSource 모바일 빌드 번호를 읽을 파일 키(detect.js의 detectBuildNumberFromFiles).
15
17
  // singleServerCd 배포 방식 변형 없이 서버 배포 CD가 하나뿐일 때 그 워크플로우 파일명.
18
+ // hooks 타입 전용 동작. 없는 훅은 "할 일 없음"이라 공통 코드는 hooksFor()로 있는 것만 호출한다.
19
+ // workflowFilter(context) 타입 루트 워크플로우 파일 필터(filename → bool) 또는 null
20
+ // cleanupWorkflows(dir, installed, context, baseline, opts)
21
+ // 선택에서 빠진 워크플로우 정리 → { removed, backedUp }
22
+ // planAppFiles / copyAppFiles(context, payloadRoot, targetRoot)
23
+ // 사용자 소유 앱 파일(없을 때만 생성) 계획/복사 → { created, kept }
24
+ // appFilesTag 앱 파일 복사 로그의 분류 이름
25
+ // statusLabels(options) status 옵션 줄에 덧붙일 문자열
26
+ // doctorChecks(cwd, existing, { docs }) doctor 진단 행 배열
27
+ // resolveOptions({ opts, existing, workflowsDir }) CLI 옵션 → 저장값 → 기본값 순으로 확정한 타입 옵션 객체
28
+ // (workflowsDir는 설치된 워크플로우로 추론할 때만 넘긴다)
29
+ // contextDefaults createContext의 미결정 기본 필드
30
+ // contextFields(options) / optionsFromContext(context)
31
+ // 옵션 객체 ↔ 설치 컨텍스트 필드 변환
32
+ // versionOptionsBlock(options) version.yml options 아래에 덧붙일 블록(6칸 들여쓰기)
33
+ // savedOptionKeys version.yml 저장 키 → parseExisting 옵션 필드
34
+ // cliFlags[{ flag, field, initial, parse(v) }] 타입 전용 CLI 플래그(파싱 결과 opts[field]에 담긴다)
35
+ // installNotices(options) 설치 직후 알릴 경고 배열(빈 값은 무시)
36
+ // logChoices(context) 설치 로그에 남길 선택값 [이름, 값] 배열
37
+ // (타입 전용 대화형 질문 흐름은 아직 interactive.js에 남아 있다.)
16
38
  export const TYPES = [
17
39
  {
18
40
  id: "spring",
@@ -26,6 +48,7 @@ export const TYPES = [
26
48
  detectBy: "markers", detectOrder: 1,
27
49
  versionSources: ["pubspec"],
28
50
  buildNumberSource: "pubspec",
51
+ hooks: flutterHooks,
29
52
  },
30
53
  {
31
54
  id: "next",
@@ -91,6 +114,24 @@ const BY_ID = new Map(TYPES.map((t) => [t.id, t]));
91
114
  // 알 수 없는 타입은 undefined — 호출부가 각자의 기본값을 정한다.
92
115
  export const typeInfo = (id) => BY_ID.get(id);
93
116
 
117
+ // types 중 훅 name을 가진 것만 [{ id, hook }]으로 — 주어진 타입 순서를 유지한다.
118
+ export function hooksFor(types, name) {
119
+ const found = [];
120
+ for (const id of types) {
121
+ const hook = BY_ID.get(id)?.hooks?.[name];
122
+ if (hook !== undefined) found.push({ id, hook });
123
+ }
124
+ return found;
125
+ }
126
+
127
+ // types 중 훅 name을 가진 타입들의 반환값(객체)을 하나로 합친다 — 옵션·컨텍스트 필드처럼 타입마다 다른 키를 낼 때 쓴다.
128
+ export function mergeHookResults(types, name, ...args) {
129
+ return Object.assign({}, ...hooksFor(types, name).map(({ hook }) => hook(...args)));
130
+ }
131
+
132
+ // 모든 타입이 선언한 훅 상수(contextDefaults·savedOptionKeys 등)를 하나로 합친다 — 타입이 정해지기 전에 필요한 기본 형태용.
133
+ export const allHookValues = (name) => Object.assign({}, ...TYPES.map((t) => t.hooks?.[name]));
134
+
94
135
  // 표시 순서 그대로의 타입 이름 목록
95
136
  // VALID_TYPES·ALL_TYPES가 같은 배열을 공유하므로 한쪽에서 바꿔 다른 쪽이 오염되지 않게 고정한다.
96
137
  export const TYPE_IDS = Object.freeze(TYPES.map((t) => t.id));
@@ -1,8 +1,6 @@
1
1
  import { DEFAULT_DEPLOY_STYLE } from "./deploy-style.js";
2
2
  import { escapeYamlDoubleQuoted } from "./wizard-env.js";
3
- import {
4
- ENV_MODES, DEPLOY_MODES, DEFAULT_ENV_MODE, DEFAULT_DEPLOY_MODE, STORE_PLATFORMS, formatStoreList,
5
- } from "./flutter-options.js";
3
+ import { hooksFor, mergeHookResults, allHookValues } from "./types.js";
6
4
 
7
5
  // version.yml 파싱·생성 (전체 재생성 전략).
8
6
  // ⚠️ YAML 재직렬화 금지 — 주석이 데이터.
@@ -37,20 +35,18 @@ export function parseExtraTopLevel(content) {
37
35
  return blocks;
38
36
  }
39
37
 
40
- // Flutter 옵션 키 → 반환 필드. 값은 원문 문자열로 돌려주고, 유효성 판정은 resolveFlutterOptions 몫이다.
41
- const FLUTTER_OPTION_KEYS = {
42
- env_mode: "envMode", flutter_store: "flutterStore",
43
- android_deploy_mode: "androidDeployMode", ios_deploy_mode: "iosDeployMode",
44
- };
38
+ // 타입 전용 옵션 키 → 반환 필드(타입 훅 savedOptionKeys). 값은 원문 문자열로 돌려주고, 유효성 판정은 타입 훅의 resolveOptions 몫이다.
39
+ const TYPE_OPTION_KEYS = allHookValues("savedOptionKeys");
40
+ const TYPE_OPTION_LINE = new RegExp(`^\\s+(${Object.keys(TYPE_OPTION_KEYS).join("|")}):\\s*(.+)`);
45
41
 
46
42
  // metadata.template.options 상태머신 파싱.
47
43
  // 반환: { semverAuto: bool|null, copilotAi: bool|null, deployStyle: string|null,
48
- // envMode/flutterStore/androidDeployMode/iosDeployMode: string|null } — null=미기재.
44
+ // 타입 전용 옵션 필드(예: envMode/flutterStore/androidDeployMode/iosDeployMode): string|null } — null=미기재.
49
45
  // 구 synology·coderabbit 키 등 다른 키는 어느 분기에도 안 걸려 자연히 무시된다(파싱 에러 없음).
50
46
  export function parseTemplateOptions(content) {
51
47
  const out = {
52
48
  semverAuto: null, copilotAi: null, deployStyle: null,
53
- envMode: null, flutterStore: null, androidDeployMode: null, iosDeployMode: null,
49
+ ...Object.fromEntries(Object.values(TYPE_OPTION_KEYS).map((field) => [field, null])),
54
50
  };
55
51
  // 값 정규화: 따옴표 제거 + 트림
56
52
  // 인라인 주석(` # ...`)을 먼저 떼고 따옴표·공백을 정리한다. 문자열 값을 받는 키(deploy_style)는
@@ -64,8 +60,8 @@ export function parseTemplateOptions(content) {
64
60
  if (inTemplate && inOptions) {
65
61
  let m = line.match(/^\s+deploy_style:\s*(.+)/);
66
62
  if (m) { const v = strip(m[1]); if (v) out.deployStyle = v; continue; }
67
- m = line.match(/^\s+(env_mode|flutter_store|android_deploy_mode|ios_deploy_mode):\s*(.+)/);
68
- if (m) { const v = strip(m[2]); if (v) out[FLUTTER_OPTION_KEYS[m[1]]] = v; continue; }
63
+ m = line.match(TYPE_OPTION_LINE);
64
+ if (m) { const v = strip(m[2]); if (v) out[TYPE_OPTION_KEYS[m[1]]] = v; continue; }
69
65
  m = line.match(/^\s+semver_auto:\s*(.+)/);
70
66
  if (m) {
71
67
  const v = strip(m[1]);
@@ -187,24 +183,11 @@ export function parseTemplateBranches(content) {
187
183
  return out.main && out.develop && out.mode ? out : null;
188
184
  }
189
185
 
190
- // Flutter 옵션 블록 (전체 줄 토큰 {{FLUTTER_OPTIONS}} — Flutter 타입일 때만). options 아래 6칸 들여쓰기.
191
- // 값이 비었으면 워크플로우 템플릿의 기본값과 같은 값으로 채운다 — 저장값과 실제 설치 내용이 어긋나지 않게.
192
- // stores가 null(미결정)이면 현행 동작대로 둘 다 설치되므로 "android,ios"로 기록한다.
193
- function buildFlutterOptionsBlock({ envMode, stores, androidDeployMode, iosDeployMode } = {}) {
194
- const quote = (v) => `"${escapeYamlDoubleQuoted(v)}"`;
195
- return [
196
- ` env_mode: ${quote(envMode || DEFAULT_ENV_MODE)} # ${ENV_MODES.join(" | ")} (Flutter 환경변수 주입 방식)`,
197
- ` flutter_store: ${quote(formatStoreList(stores ?? STORE_PLATFORMS))} # android | ios | android,ios | none (스토어 배포 대상)`,
198
- ` android_deploy_mode: ${quote(androidDeployMode || DEFAULT_DEPLOY_MODE)} # ${DEPLOY_MODES.join(" | ")} (Play Store 배포 모드)`,
199
- ` ios_deploy_mode: ${quote(iosDeployMode || DEFAULT_DEPLOY_MODE)} # ${DEPLOY_MODES.join(" | ")} (iOS 배포 모드)`,
200
- ].join("\n");
201
- }
202
-
203
186
  // version.yml 전체 생성 — payload/version.yml.template 렌더링.
204
187
  // opts: { templateText, version, types:[], paths:Map, pathMarkers?:Map,
205
188
  // branch, branches?, versionCode, now, today, templateOptions?, deployValues?,
206
189
  // extraTopLevel?:string[], ← 기존 version.yml의 알려지지 않은 최상위 필드 보존
207
- // flutterOptions?:{ envMode, stores, androidDeployMode, iosDeployMode } } ← Flutter 타입일 때만 렌더
190
+ // typeOptions?:object } ← 타입 훅(versionOptionsBlock)이 해당 타입일 때만 렌더하는 옵션 블록의 입력
208
191
  // templateText = payload/version.yml.template 원문 (readVersionYmlTemplate — 필수)
209
192
  // now = "YYYY-MM-DD HH:MM:SS" (UTC) — 결정성 위해 주입 / today = "YYYY-MM-DD"
210
193
  // branches = { main, develop, mode } (resolveBranchConfig 결과. 없으면 branch 기반 기본값)
@@ -213,7 +196,7 @@ function buildFlutterOptionsBlock({ envMode, stores, androidDeployMode, iosDeplo
213
196
  export function buildVersionYml({
214
197
  templateText, version, types = [], paths = new Map(), pathMarkers = new Map(),
215
198
  branch = "main", branches = null, versionCode = 1, now, today,
216
- templateOptions = null, deployValues = new Map(), extraTopLevel = [], flutterOptions = {},
199
+ templateOptions = null, deployValues = new Map(), extraTopLevel = [], typeOptions = {},
217
200
  }) {
218
201
  if (!templateText) throw new Error("version.yml.template 원문이 필요합니다 (payload/version.yml.template 누락?)");
219
202
  const typesJson = types.length ? `[${types.map((t) => `"${t}"`).join(", ")}]` : `["basic"]`;
@@ -249,8 +232,8 @@ export function buildVersionYml({
249
232
  deployBlock = rows.join("\n");
250
233
  }
251
234
 
252
- // Flutter 옵션 블록 (full-line 토큰 {{FLUTTER_OPTIONS}} — Flutter 타입일 때만, 아니면 줄 제거)
253
- const flutterBlock = types.includes("flutter") ? buildFlutterOptionsBlock(flutterOptions) : "";
235
+ // 타입 옵션 블록 (full-line 토큰 {{TYPE_OPTIONS}} — 블록을 내는 타입이 있을 때만, 아니면 줄 제거)
236
+ const typeOptionsBlock = hooksFor(types, "versionOptionsBlock").map(({ hook }) => hook(typeOptions)).join("\n");
254
237
 
255
238
  const scalars = {
256
239
  VERSION: version, VERSION_CODE: String(versionCode),
@@ -267,7 +250,7 @@ export function buildVersionYml({
267
250
  for (const line of String(templateText).split("\n")) {
268
251
  const t = line.trim();
269
252
  if (t === "{{PROJECT_PATHS}}") { if (pathsBlock) out.push(pathsBlock); continue; }
270
- if (t === "{{FLUTTER_OPTIONS}}") { if (flutterBlock) out.push(flutterBlock); continue; }
253
+ if (t === "{{TYPE_OPTIONS}}") { if (typeOptionsBlock) out.push(typeOptionsBlock); continue; }
271
254
  if (t === "{{DEPLOY}}") { if (deployBlock) out.push(deployBlock); continue; }
272
255
  if (t.startsWith("deploy_style:") && deployStyle === null) continue; // 서버 배포가 없는 타입은 기록하지 않는다
273
256
  out.push(line.replace(/\{\{([A-Z][A-Z0-9_]*)\}\}/g, (_, name) => {
@@ -298,12 +281,11 @@ export function sameIgnoringTimestamps(a, b) {
298
281
  export function renderVersionYml(context, templateText, { pathMarkers, deployValues = new Map(), extraTopLevel = [] }) {
299
282
  const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
300
283
  now, today, templateVersion = "unknown", branches = null,
301
- includeSemverAuto, includeCopilotAi, deployStyle,
302
- envMode, flutterStore, androidDeployMode, iosDeployMode } = context;
284
+ includeSemverAuto, includeCopilotAi, deployStyle } = context;
303
285
  return buildVersionYml({
304
286
  templateText, version, types, paths, pathMarkers, branch, branches, versionCode, now, today,
305
287
  deployValues, extraTopLevel,
306
- flutterOptions: { envMode, stores: flutterStore, androidDeployMode, iosDeployMode },
288
+ typeOptions: mergeHookResults(types, "optionsFromContext", context),
307
289
  templateOptions: {
308
290
  templateVersion,
309
291
  includeSemverAuto: includeSemverAuto !== false,
package/src/index.js CHANGED
@@ -6,15 +6,13 @@ import { join, dirname } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { readFileSync, existsSync } from "node:fs";
8
8
  import { createInterface } from "node:readline/promises";
9
- import { parseArgs, parsePathsCsv, CliError } from "./cli/args.js";
9
+ import { parseArgs, parsePathsCsv, CliError, TYPE_CLI_FLAGS } from "./cli/args.js";
10
10
  import { HELP_TEXT } from "./cli/help.js";
11
- import { createContext } from "./context.js";
12
- import { DEFAULT_DEPLOY_STYLE, isDeployStyle, hasServerDeployWorkflows, fallbackStyleTypes, effectiveDeployStyle } from "./core/deploy-style.js";
13
- import { resolveFlutterOptions } from "./core/flutter-options.js";
14
- import { inferInstalledStores } from "./core/installed-stores.js";
11
+ import { fallbackStyleTypes } from "./core/deploy-style.js";
12
+ import { hooksFor, mergeHookResults } from "./core/types.js";
15
13
  import { PATHS } from "./core/paths.js";
16
14
  import { resolvePayloadRoot, assertPayload, readTemplateVersion } from "./core/assets.js";
17
- import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName, makeResolvers, detectBuildNumber, detectMarkers } from "./core/detect-fs.js";
15
+ import { detectTypes, detectDefaultBranch, detectRepoName, makeResolvers, detectMarkers } from "./core/detect-fs.js";
18
16
  import { parseExisting } from "./core/version-yml.js";
19
17
  import { resolveReleaseOptions } from "./core/release-options.js";
20
18
  import { runBreakingCheck } from "./core/breaking-check.js";
@@ -29,6 +27,7 @@ import { runUninstall, runUninstallFlow } from "./commands/uninstall.js";
29
27
  import * as prompts from "./ui/prompts.js";
30
28
  import { isPromptAbort } from "./ui/readline-engine.js";
31
29
  import { runInteractive } from "./commands/interactive.js";
30
+ import { resolveDeployStyle, resolveVersion, resolveVersionCode, buildInstallContext } from "./commands/install-settings.js";
32
31
  import { initLogger, closeLogger, currentLogPath, hasLegacyMdLogs, log } from "./core/logger.js";
33
32
  import { runStatus, printStatus } from "./commands/status.js";
34
33
  import { runDoctor, printDoctorReport } from "./commands/doctor.js";
@@ -108,8 +107,7 @@ async function runInner(argv, {
108
107
  const ignored = [
109
108
  [opts.types.length, "--type"], [opts.version, "--project-version"], [opts.pathsCsv, "--paths"],
110
109
  [opts.mainBranch, "--main-branch"], [opts.developBranch, "--develop-branch"], [opts.deployStyle, "--deploy-style"],
111
- [opts.flutterEnvMode, "--flutter-env-mode"], [opts.flutterStore, "--flutter-store"],
112
- [opts.androidDeployMode, "--android-deploy-mode"], [opts.iosDeployMode, "--ios-deploy-mode"],
110
+ ...TYPE_CLI_FLAGS.map((f) => [opts[f.field], f.flag]),
113
111
  ].filter(([v]) => v).map(([, flag]) => flag);
114
112
  if (ignored.length) {
115
113
  console.error(`⚠️ 대화형 모드에서는 ${ignored.join(", ")}를 사용하지 않습니다 — 질문에서 고르거나 --mode full --force와 함께 쓰세요.`);
@@ -286,9 +284,11 @@ async function runInner(argv, {
286
284
  // version: 기존 version.yml 최우선(SSoT — 재실행 시 덮어쓰기 방지) → CLI 지정 → 파일 감지
287
285
  // 비대화형이므로 폴백 안내는 CLI 문구(--project-version)를 그대로 쓴다.
288
286
  // 경로 확정 뒤에 감지해야 모노레포 하위 폴더의 버전·빌드 번호를 읽는다.
289
- const version = (existing?.version) || opts.version
290
- || detectVersion(cwd, { types, paths, warn: (m) => { detectWarnings.push(m); console.error(m); } });
291
- const versionCode = existing?.versionCode ?? detectBuildNumber(cwd, { types, paths }) ?? 1; // 기존 빌드번호 보존, 신규 통합 시 프로젝트 파일에서 감지
287
+ const version = resolveVersion({
288
+ cwd, existing, explicit: opts.version,
289
+ types, paths, warn: (m) => { detectWarnings.push(m); console.error(m); },
290
+ });
291
+ const versionCode = resolveVersionCode({ cwd, existing, types, paths });
292
292
 
293
293
  // 브랜치 구성 (--main-branch/--develop-branch → version.yml 저장값 → 감지 default → main/develop)
294
294
  // 이전 버전이 저장한 감지 실패 값("(unknown)" 등)은 저장값으로 인정하지 않는다 — 그대로 두면 재실행해도 복구되지 않는다.
@@ -315,53 +315,36 @@ async function runInner(argv, {
315
315
  }
316
316
  }
317
317
 
318
- // Flutter 옵션 — CLI 플래그 → version.yml 저장값 → 기본값(신규 dart-define, 기존 설치 dotenv 보존).
319
- // Flutter 타입이 없는 프로젝트에서는 렌더·치환 단계가 전부 무시한다.
320
- // 스토어 저장값이 없는 기존 Flutter 설치는 설치돼 있는 스토어 워크플로우로 추론한다(대화형과 같은 결론).
321
- // 추론하지 않으면 미결정이 "둘 다"로 확정 저장되어, 지웠던 플랫폼의 워크플로우·fastlane 파일이 되살아난다.
322
- const workflowsDir = join(cwd, PATHS.workflowsDir);
323
- const inferredStores = opts.flutterStore == null && existing?.types?.includes("flutter")
324
- && existing.options?.flutterStore == null && existsSync(workflowsDir)
325
- ? inferInstalledStores(workflowsDir) : null;
326
- const flutterOptions = resolveFlutterOptions({
327
- cli: {
328
- envMode: opts.flutterEnvMode, stores: opts.flutterStore ?? inferredStores,
329
- androidDeployMode: opts.androidDeployMode, iosDeployMode: opts.iosDeployMode,
330
- },
331
- existing,
318
+ // 타입 전용 옵션 — CLI 플래그 → version.yml 저장값 → 기본값을 타입 훅이 정한다. 해당 타입이 없으면 비어 있다.
319
+ // (Flutter는 저장값이 없는 기존 설치의 스토어를 설치된 워크플로우로 추론한다 — 대화형과 같은 결론.)
320
+ const typeOptions = mergeHookResults(types, "resolveOptions", {
321
+ opts, existing, workflowsDir: join(cwd, PATHS.workflowsDir),
332
322
  });
333
323
 
334
- const context = createContext({
335
- mode: opts.mode, force: opts.force, types, version, versionCode, branch,
324
+ // 고른 배포 방식 — 플래그 → 저장값 → 기본값. 실제 설치되는 방식으로의 정리는 컨텍스트 조립에서 한다.
325
+ const chosenDeployStyle = resolveDeployStyle({ payload, types, explicit: opts.deployStyle, existing });
326
+
327
+ const context = buildInstallContext({
328
+ payload, existing, templateVersion: readTemplateVersion(), types, deployStyle: chosenDeployStyle,
329
+ typeOptions,
330
+ // 옵션: CLI 플래그 최우선 → version.yml 저장 옵션 → 기본값 (대화형과 같은 규칙)
331
+ releaseOptions: resolveReleaseOptions({ semverAuto: opts.includeSemverAuto, copilotAi: opts.includeCopilotAi }, existing),
332
+ mode: opts.mode, force: opts.force, version, versionCode, branch,
336
333
  branches,
337
334
  paths,
338
- // 옵션: CLI 플래그 최우선 → version.yml 저장 옵션 → 기본값 (대화형과 같은 규칙)
339
- ...resolveReleaseOptions({ semverAuto: opts.includeSemverAuto, copilotAi: opts.includeCopilotAi }, existing),
340
335
  repoName,
341
336
  // @wizard ask/auto 토큰 값을 계산하는 resolver
342
- resolvers: makeResolvers(cwd, repoName, paths, flutterOptions),
337
+ resolvers: makeResolvers(cwd, repoName, paths, typeOptions),
343
338
  now, today,
344
339
  // 설치 로그용 부가 문맥 — 설치 동작 자체는 바꾸지 않는다.
345
340
  markers: detectMarkers(cwd, types), detectWarnings,
346
- // 서버 배포 워크플로우가 없는 타입은 배포 방식이 설치에 영향이 없으므로 기록하지 않는다(null).
347
- deployStyle: !hasServerDeployWorkflows(payload, types) ? null : opts.deployStyle
348
- || (isDeployStyle(existing?.options?.deployStyle) ? existing.options.deployStyle : DEFAULT_DEPLOY_STYLE),
349
- previousTemplateVersion: existing?.templateVersion || "",
350
- envMode: flutterOptions.envMode,
351
- flutterStore: flutterOptions.stores,
352
- androidDeployMode: flutterOptions.androidDeployMode,
353
- iosDeployMode: flutterOptions.iosDeployMode,
354
341
  });
355
342
 
356
- context.templateVersion = readTemplateVersion();
357
-
358
343
  // 무중단(nginx·traefik) 워크플로우가 없는 타입은 단일 서버 배포로 설치한다 — 조용히 넘어가지 않게 알린다.
359
- const fallbackTypes = fallbackStyleTypes(payload, types, context.deployStyle);
344
+ const fallbackTypes = fallbackStyleTypes(payload, types, chosenDeployStyle);
360
345
  if (fallbackTypes.length) {
361
- console.error(`⚠️ ${fallbackTypes.join(", ")}에는 ${context.deployStyle} 무중단 배포 워크플로우가 없어 단일 서버 배포(simple)로 설치합니다.`);
346
+ console.error(`⚠️ ${fallbackTypes.join(", ")}에는 ${chosenDeployStyle} 무중단 배포 워크플로우가 없어 단일 서버 배포(simple)로 설치합니다.`);
362
347
  }
363
- // 안내는 고른 값으로 하되, 기록은 실제로 설치되는 방식으로 한다.
364
- context.deployStyle = effectiveDeployStyle(payload, types, context.deployStyle);
365
348
 
366
349
  // 비대화형 축약 배너 (1줄, 로그 오염 최소)
367
350
  printBannerCompact({ version: context.templateVersion, mode: opts.mode });
@@ -400,18 +383,11 @@ async function runInner(argv, {
400
383
  });
401
384
  for (const n of postInstallNotices(result)) console.error(n.startsWith(" ") ? n : `⚠️ ${n}`);
402
385
  // store_submit 배포 모드는 main push마다 심사를 자동 제출한다 — 비대화형에서도 같은 경고를 보여준다
403
- // (대화형 경로는 ui/prompts.js#deployModeWarning을 선택 시점에 note로 보여준다).
404
- // Flutter 타입이 아니거나 해당 스토어를 선택하지 않은 프로젝트에는 뜨면 안 된다.
405
- const { stores } = flutterOptions;
406
- const warnings = [];
407
- if (types.includes("flutter") && (stores === null || stores.includes("android"))) {
408
- warnings.push(prompts.deployModeWarning(flutterOptions.androidDeployMode));
409
- }
410
- if (types.includes("flutter") && (stores === null || stores.includes("ios"))) {
411
- warnings.push(prompts.deployModeWarning(flutterOptions.iosDeployMode));
412
- }
413
- for (const w of warnings) {
414
- if (w) console.error(`⚠️ ${w}`);
386
+ // (대화형 경로는 ui/prompts.js#deployModeWarning을 선택 시점에 note로 보여준다). 타입 훅이 해당 타입에만 낸다.
387
+ for (const { hook } of hooksFor(types, "installNotices")) {
388
+ for (const w of hook(typeOptions)) {
389
+ if (w) console.error(`⚠️ ${w}`);
390
+ }
415
391
  }
416
392
  return 0;
417
393
  }
package/src/ui/prompts.js CHANGED
@@ -5,7 +5,7 @@
5
5
  import * as engine from "./readline-engine.js";
6
6
  import { DEPLOY_STYLES, NO_DEPLOY_STYLE } from "../core/deploy-style.js";
7
7
  import { TYPE_IDS } from "../core/types.js";
8
- import { ENV_MODES, DEFAULT_ENV_MODE, STORE_PLATFORMS, DEPLOY_MODES, DEFAULT_DEPLOY_MODE } from "../core/flutter-options.js";
8
+ import { ENV_MODES, DEFAULT_ENV_MODE, STORE_PLATFORMS, DEPLOY_MODES, DEFAULT_DEPLOY_MODE, deployModeWarning } from "../core/flutter-options.js";
9
9
 
10
10
  export const CANCEL = engine.CANCEL;
11
11
 
@@ -188,10 +188,8 @@ export async function selectDeployMode({ platform, initialValue = DEFAULT_DEPLOY
188
188
  });
189
189
  }
190
190
 
191
- // store_submit은 main push마다 심사를 제출하므로 고른 직후 한 줄로 알린다. 호출부(interactive)가 note로 출력한다.
192
- export function deployModeWarning(mode) {
193
- return mode === "store_submit" ? "store_submit을 고르면 main push마다 심사가 자동 제출됩니다." : "";
194
- }
191
+ // store_submit 경고 문구 — 정의는 core에 있고, 호출부(interactive)가 note로 출력한다.
192
+ export { deployModeWarning };
195
193
 
196
194
  // 브랜치 전략 선택. 종전에는 "릴리스 브랜치"/"개발 브랜치" 두 질문에
197
195
  // 같은 이름을 입력해야만 trunk-based가 됐는데, 그 규칙이 사전에 안내되지 않아