project-auto-wizard 0.13.1 → 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.
package/README.md CHANGED
@@ -21,7 +21,7 @@ Everything it installs is plain GitHub Actions in your own repository. No API ke
21
21
  </div>
22
22
 
23
23
  <!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
24
- ## Latest Version : v0.13.1 (2026-09-30)
24
+ ## Latest Version : v0.13.2 (2026-09-30)
25
25
 
26
26
  [Full release history](CHANGELOG.md)
27
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-auto-wizard",
3
- "version": "0.13.1",
3
+ "version": "0.13.2",
4
4
  "description": "One command DevOps: npx wizard that installs GitHub-native Release Automation (optional GitHub Copilot AI summaries) into any project",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -53,5 +53,5 @@ metadata:
53
53
  semver_auto: {{OPT_SEMVER_AUTO}}
54
54
  copilot_ai: {{OPT_COPILOT_AI}}
55
55
  deploy_style: "{{OPT_DEPLOY_STYLE}}" # simple | nginx | traefik | none (서버 배포 워크플로우)
56
- {{FLUTTER_OPTIONS}}
56
+ {{TYPE_OPTIONS}}
57
57
  {{DEPLOY}}
package/src/cli/args.js CHANGED
@@ -2,15 +2,17 @@
2
2
  import { VALID_TYPES, VALID_MODES } from "../context.js";
3
3
  import { DEPLOY_STYLES, isDeployStyle, NO_DEPLOY_STYLE } from "../core/deploy-style.js";
4
4
  import { isValidBranchName } from "../core/branches.js";
5
- import {
6
- ENV_MODES, DEPLOY_MODES, STORE_PLATFORMS, NO_STORE, isEnvMode, isDeployMode, parseStoreList,
7
- } from "../core/flutter-options.js";
5
+ import { TYPES } from "../core/types.js";
8
6
  import { CliError } from "../core/errors.js";
9
7
  import { normalizePath, isRepoRelativePath } from "../core/paths.js";
10
8
 
11
9
  // 기존 import 경로(cli/args.js) 호환 — 정의는 core에 있다.
12
10
  export { CliError, normalizePath, isRepoRelativePath };
13
11
 
12
+ // 타입 전용 CLI 플래그(타입 훅 cliFlags) — 플래그 이름 → { field, initial, parse }. 훅은 타입 표 순서대로 모은다.
13
+ export const TYPE_CLI_FLAGS = TYPES.flatMap((t) => t.hooks?.cliFlags ?? []);
14
+ const TYPE_CLI_FLAG_BY_NAME = new Map(TYPE_CLI_FLAGS.map((f) => [f.flag, f]));
15
+
14
16
  // argv(process.argv.slice(2)) → 파싱 결과. 오류 시 throw(호출부에서 exit 1).
15
17
  export function parseArgs(argv) {
16
18
  const result = {
@@ -24,10 +26,8 @@ export function parseArgs(argv) {
24
26
  mainBranch: "", // 릴리스 브랜치 (--main-branch). 빈값=감지된 default branch
25
27
  developBranch: "", // 개발 브랜치 (--develop-branch). 빈값=develop
26
28
  deployStyle: "", // 서버 배포 방식 (--deploy-style). 빈값=version.yml 저장값 → simple
27
- flutterEnvMode: "", // Flutter 환경변수 방식 (--flutter-env-mode). 빈값=저장값 → 신규 dart-define/기존 dotenv
28
- flutterStore: null, // Flutter 스토어 배포 대상 string[] (--flutter-store). null=미지정, none → []
29
- androidDeployMode: "", // Play Store 배포 모드 (--android-deploy-mode). 빈값=저장값 → store_only
30
- iosDeployMode: "", // iOS 배포 모드 (--ios-deploy-mode). 빈값=저장값 → store_only
29
+ // 타입 전용 플래그 필드(예: Flutter 환경변수 방식·스토어 대상·배포 모드) — 미지정 값은 타입 훅이 정한다.
30
+ ...Object.fromEntries(TYPE_CLI_FLAGS.map((f) => [f.field, f.initial])),
31
31
  force: false,
32
32
  help: false,
33
33
  showVersion: false, // -v/--version → 패키지 버전 출력 (npm 관례)
@@ -49,6 +49,8 @@ export function parseArgs(argv) {
49
49
  const seenFlags = new Set(); // --semver-auto/--copilot류 상호 모순 플래그 검증용
50
50
  while (args.length > 0) {
51
51
  const a = args.shift();
52
+ const typeFlag = TYPE_CLI_FLAG_BY_NAME.get(a);
53
+ if (typeFlag) { result[typeFlag.field] = typeFlag.parse(args.shift()); continue; }
52
54
  switch (a) {
53
55
  case "-m": case "--mode":
54
56
  result.mode = args.shift() ?? ""; break;
@@ -103,36 +105,6 @@ export function parseArgs(argv) {
103
105
  }
104
106
  result.deployStyle = v; break;
105
107
  }
106
- case "--flutter-env-mode": {
107
- const v = args.shift();
108
- if (!isEnvMode(v)) {
109
- throw new CliError(`--flutter-env-mode 값이 올바르지 않습니다: ${v ?? "(없음)"} (${ENV_MODES.join(" | ")})`);
110
- }
111
- result.flutterEnvMode = v; break;
112
- }
113
- case "--flutter-store": {
114
- const v = args.shift();
115
- // 빈 문자열은 parseStoreList가 []로 보지만, 스토어를 안 고르겠다는 뜻은 명시적인 none으로만 받는다.
116
- const stores = v ? parseStoreList(v) : null;
117
- if (stores === null) {
118
- throw new CliError(`--flutter-store 값이 올바르지 않습니다: ${v || "(없음)"} (${[STORE_PLATFORMS.join(","), ...STORE_PLATFORMS, NO_STORE].join(" | ")})`);
119
- }
120
- result.flutterStore = stores; break;
121
- }
122
- case "--android-deploy-mode": {
123
- const v = args.shift();
124
- if (!isDeployMode(v)) {
125
- throw new CliError(`--android-deploy-mode 값이 올바르지 않습니다: ${v ?? "(없음)"} (${DEPLOY_MODES.join(" | ")})`);
126
- }
127
- result.androidDeployMode = v; break;
128
- }
129
- case "--ios-deploy-mode": {
130
- const v = args.shift();
131
- if (!isDeployMode(v)) {
132
- throw new CliError(`--ios-deploy-mode 값이 올바르지 않습니다: ${v ?? "(없음)"} (${DEPLOY_MODES.join(" | ")})`);
133
- }
134
- result.iosDeployMode = v; break;
135
- }
136
108
  case "--semver-auto":
137
109
  if (seenFlags.has("--no-semver-auto")) throw new CliError("--semver-auto와 --no-semver-auto는 동시에 지정할 수 없습니다");
138
110
  seenFlags.add("--semver-auto"); result.includeSemverAuto = true; break;
@@ -198,16 +198,14 @@ export function cleanupWorkflows(context, payloadRoot, targetRoot, baseline, { j
198
198
  return { cleanup, storeCleanup, staleCleanup };
199
199
  }
200
200
 
201
- // 설치 결과를 가른 선택(배포 방식·자동 승격·Copilot·Flutter 옵션)을 남긴다 — 대화형에서 고른 값도
201
+ // 설치 결과를 가른 선택(배포 방식·자동 승격·Copilot·타입 전용 옵션)을 남긴다 — 대화형에서 고른 값도
202
202
  // 여기로 모이므로, 나중에 "왜 이렇게 설치됐나"를 로그만으로 따라갈 수 있다.
203
203
  function logChoices(context, types) {
204
204
  if (context.deployStyle) log.info("option", "deploy", context.deployStyle);
205
205
  if (context.includeSemverAuto != null) log.info("option", "semver", context.includeSemverAuto ? "on" : "off");
206
206
  if (context.includeCopilotAi != null) log.info("option", "copilot", context.includeCopilotAi ? "on" : "off");
207
- if (types.includes("flutter")) {
208
- const stores = Array.isArray(context.flutterStore) ? (context.flutterStore.join(",") || "없음") : "미결정(둘 다)";
209
- log.info("option", "flutter",
210
- `env=${context.envMode || "-"} stores=${stores} android=${context.androidDeployMode || "-"} ios=${context.iosDeployMode || "-"}`);
207
+ for (const { hook } of hooksFor(types, "logChoices")) {
208
+ for (const [name, value] of hook(context)) log.info("option", name, value);
211
209
  }
212
210
  }
213
211
 
@@ -3,6 +3,7 @@
3
3
  // 설치 컨텍스트로 조립하는 규칙은 한 곳에 둬야 한쪽만 고쳐 두 모드의 결과가 갈라지지 않는다.
4
4
  import { detectVersion, detectBuildNumber } from "../core/detect-fs.js";
5
5
  import { createContext } from "../context.js";
6
+ import { mergeHookResults } from "../core/types.js";
6
7
  import { isDeployStyle, DEFAULT_DEPLOY_STYLE, hasServerDeployWorkflows, effectiveDeployStyle } from "../core/deploy-style.js";
7
8
 
8
9
  // version.yml에 저장된 배포 방식 — 없거나 알 수 없는 값이면 "".
@@ -29,21 +30,18 @@ export function resolveVersionCode({ cwd, existing, types, paths }) {
29
30
 
30
31
  // 확정된 값들을 설치 컨텍스트로 조립한다.
31
32
  // deployStyle: resolveDeployStyle 결과(고른 값). 실제 설치되는 방식으로 바꿔 기록한다.
32
- // flutterOptions: { envMode, stores, androidDeployMode, iosDeployMode } — 두 경로 모두 확정된 값.
33
+ // typeOptions: 타입 훅(resolveOptions)이 확정한 타입 전용 옵션 — 두 경로 모두 확정된 값. 컨텍스트 필드로의 변환도 타입 훅이 한다.
33
34
  // releaseOptions: { includeSemverAuto, includeCopilotAi } — resolveReleaseOptions 결과.
34
35
  // extra: 경로별로만 쓰는 필드(envValues 등).
35
36
  export function buildInstallContext({
36
- payload, existing, templateVersion, types, deployStyle, flutterOptions, releaseOptions, ...rest
37
+ payload, existing, templateVersion, types, deployStyle, typeOptions, releaseOptions, ...rest
37
38
  }) {
38
39
  return createContext({
39
40
  types,
40
41
  ...releaseOptions,
41
42
  // 안내는 고른 값으로 하되, 기록은 실제로 설치되는 방식으로 한다.
42
43
  deployStyle: effectiveDeployStyle(payload, types, deployStyle),
43
- envMode: flutterOptions.envMode,
44
- flutterStore: flutterOptions.stores,
45
- androidDeployMode: flutterOptions.androidDeployMode,
46
- iosDeployMode: flutterOptions.iosDeployMode,
44
+ ...mergeHookResults(types, "contextFields", typeOptions),
47
45
  previousTemplateVersion: existing?.templateVersion || "",
48
46
  templateVersion,
49
47
  ...rest,
@@ -317,7 +317,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
317
317
  payload, existing, templateVersion, types,
318
318
  // 저장값이 무중단이어도 선택한 타입에 그 방식이 없으면 단일 서버 배포가 설치된다 — 설치된 방식을 기록한다.
319
319
  deployStyle: resolveDeployStyle({ payload, types, explicit: deployStyle, existing }),
320
- flutterOptions,
320
+ typeOptions: flutterOptions,
321
321
  releaseOptions: { includeSemverAuto, includeCopilotAi },
322
322
  mode, force: true, version, versionCode, branch, branches, paths,
323
323
  repoName, resolvers, envValues, envUseDefaults, now, today,
@@ -5,11 +5,10 @@ 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";
9
8
  import { isDeployStyle, DEFAULT_DEPLOY_STYLE } from "../core/deploy-style.js";
10
9
  import { findStaleWorkflows } from "../core/removal-plan.js";
11
10
  import { readBaseline } from "../core/baseline.js";
12
- import { hooksFor } from "../core/types.js";
11
+ import { hooksFor, mergeHookResults } from "../core/types.js";
13
12
 
14
13
  // payloadRoot: 패키지 payload/ 루트. targetRoot: 상태를 확인할 대상 레포.
15
14
  export function runStatus(payloadRoot, targetRoot = ".") {
@@ -20,10 +19,8 @@ export function runStatus(payloadRoot, targetRoot = ".") {
20
19
  const repoName = detectRepoName(targetRoot);
21
20
  // 설치 때 워크플로우에 치환된 환경변수 방식·배포 모드와 스토어 선택을 비교 기준에도 똑같이 적용한다 —
22
21
  // 그렇지 않으면 미수정 파일이 드리프트로, 선택 해제한 스토어 워크플로우가 "삭제함"으로 오탐된다.
23
- const flutterOptions = resolveFlutterOptions({
24
- cli: { envMode: "", stores: null, androidDeployMode: "", iosDeployMode: "" }, existing,
25
- });
26
- const resolvers = makeResolvers(targetRoot, repoName, existing.paths, flutterOptions);
22
+ const typeOptions = mergeHookResults(existing.types, "resolveOptions", { existing });
23
+ const resolvers = makeResolvers(targetRoot, repoName, existing.paths, typeOptions);
27
24
  // version.yml에 branches 블록이 없으면(신기능 이전 설치·수기 편집) makeSrcText(null)이
28
25
  // {{MAIN_BRANCH}}/{{DEVELOP_BRANCH}}를 치환하지 못해 모든 워크플로우가 드리프트로 오탐된다 —
29
26
  // 비교용 기본값으로 폴백(실제 저장값은 아니지만 드리프트 비교 목적에는 충분).
@@ -31,7 +28,7 @@ export function runStatus(payloadRoot, targetRoot = ".") {
31
28
  const context = {
32
29
  types: existing.types, paths: existing.paths,
33
30
  repoName, resolvers, branches: branchesForCompare,
34
- flutterStore: flutterOptions.stores,
31
+ ...mergeHookResults(existing.types, "contextFields", typeOptions),
35
32
  // 설치 때 고른 배포 방식으로 비교해야 nginx·traefik CD 수정도 드리프트로 잡힌다.
36
33
  // 빠지면 기본값(simple)으로 걸러 설치된 무중단 CD가 비교 대상에서 사라진다.
37
34
  deployStyle: isDeployStyle(existing.options.deployStyle) ? existing.options.deployStyle : DEFAULT_DEPLOY_STYLE,
package/src/context.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // 마법사 전역 상태를 하나의 객체로 명시화
2
- import { TYPE_IDS } from "./core/types.js";
2
+ import { TYPE_IDS, allHookValues } from "./core/types.js";
3
3
 
4
4
  // --type 화이트리스트 — 타입 레지스트리(core/types.js)의 표시 순서 그대로.
5
5
  export const VALID_TYPES = TYPE_IDS;
@@ -26,12 +26,8 @@ export function createContext(overrides = {}) {
26
26
  paths: new Map(), // type -> path
27
27
  includeSemverAuto: null, // null=미설정(다운스트림에서 true로 해석), true/false=명시
28
28
  includeCopilotAi: null, // null=미설정(다운스트림에서 false로 해석), true/false=명시 — Copilot AI 요약 opt-in
29
- // Flutter 옵션 — Flutter 타입이 없는 프로젝트에서는 전부 무시된다.
30
- // 결정은 src/core/flutter-options.js의 resolveFlutterOptions가 한다.
31
- envMode: "", // "dart-define" | "dotenv". ""=미결정 → 템플릿 기본값(dart-define)
32
- flutterStore: null, // 스토어 배포 대상 string[] (예: ["android","ios"]). null=미결정 → 둘 다(현행 동작)
33
- androidDeployMode: "", // store_only | store_prepare | store_submit. ""=미결정 → store_only
34
- iosDeployMode: "", // 위와 동일 (iOS)
29
+ // 타입 전용 옵션 필드 — 해당 타입이 없는 프로젝트에서는 전부 무시된다. 필드와 기본값은 타입 훅(contextDefaults)이 정한다.
30
+ ...allHookValues("contextDefaults"),
35
31
  templateVersion: "",
36
32
  deployValues: new Map(), // "type.KEY" -> value
37
33
  counters: {},
@@ -1,8 +1,16 @@
1
1
  // Flutter 타입 훅 — types.js 레지스트리의 flutter 항목이 가리키는 구현 모음.
2
2
  // 공통 코드는 flutter를 직접 알지 못하고 훅 이름으로만 호출한다(각 훅의 계약은 types.js 머리말 참고).
3
- import { storeWorkflowFilter, cleanupDeselectedStoreWorkflows } from "./flutter-options.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";
4
9
  import { planFlutterAppFiles, copyFlutterAppFiles } from "./copy/flutter-app.js";
5
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";
6
14
 
7
15
  // 저장값이 없으면 그 상태에서 실제로 적용되는 동작을 함께 알린다.
8
16
  function statusLabels(options) {
@@ -14,7 +22,95 @@ function statusLabels(options) {
14
22
  ].join("");
15
23
  }
16
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
+
17
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
+ },
18
114
  // 스토어 대상이 배열일 때만 스토어 워크플로우 필터를 건다(null=미결정 → 전부 설치).
19
115
  workflowFilter: ({ flutterStore }) => (Array.isArray(flutterStore) ? storeWorkflowFilter(flutterStore) : null),
20
116
  // 선택 해제된 스토어 워크플로우 정리 — 대상이 미결정(null)이면 아무것도 지우지 않는다.
@@ -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 저장값 > 기본값.
package/src/core/types.js CHANGED
@@ -24,7 +24,17 @@ import { flutterHooks } from "./flutter-hooks.js";
24
24
  // appFilesTag 앱 파일 복사 로그의 분류 이름
25
25
  // statusLabels(options) status 옵션 줄에 덧붙일 문자열
26
26
  // doctorChecks(cwd, existing, { docs }) doctor 진단 행 배열
27
- // (타입 전용 옵션 해석·CLI 플래그·version.yml 블록·대화형 질문은 아직 각 호출부에 남아 있다.)
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에 남아 있다.)
28
38
  export const TYPES = [
29
39
  {
30
40
  id: "spring",
@@ -114,6 +124,14 @@ export function hooksFor(types, name) {
114
124
  return found;
115
125
  }
116
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
+
117
135
  // 표시 순서 그대로의 타입 이름 목록
118
136
  // VALID_TYPES·ALL_TYPES가 같은 배열을 공유하므로 한쪽에서 바꿔 다른 쪽이 오염되지 않게 고정한다.
119
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,11 +6,10 @@ 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
11
  import { fallbackStyleTypes } from "./core/deploy-style.js";
12
- import { resolveFlutterOptions } from "./core/flutter-options.js";
13
- import { inferInstalledStores } from "./core/installed-stores.js";
12
+ import { hooksFor, mergeHookResults } from "./core/types.js";
14
13
  import { PATHS } from "./core/paths.js";
15
14
  import { resolvePayloadRoot, assertPayload, readTemplateVersion } from "./core/assets.js";
16
15
  import { detectTypes, detectDefaultBranch, detectRepoName, makeResolvers, detectMarkers } from "./core/detect-fs.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와 함께 쓰세요.`);
@@ -317,20 +315,10 @@ async function runInner(argv, {
317
315
  }
318
316
  }
319
317
 
320
- // Flutter 옵션 — CLI 플래그 → version.yml 저장값 → 기본값(신규 dart-define, 기존 설치 dotenv 보존).
321
- // Flutter 타입이 없는 프로젝트에서는 렌더·치환 단계가 전부 무시한다.
322
- // 스토어 저장값이 없는 기존 Flutter 설치는 설치돼 있는 스토어 워크플로우로 추론한다(대화형과 같은 결론).
323
- // 추론하지 않으면 미결정이 "둘 다"로 확정 저장되어, 지웠던 플랫폼의 워크플로우·fastlane 파일이 되살아난다.
324
- const workflowsDir = join(cwd, PATHS.workflowsDir);
325
- const inferredStores = opts.flutterStore == null && existing?.types?.includes("flutter")
326
- && existing.options?.flutterStore == null && existsSync(workflowsDir)
327
- ? inferInstalledStores(workflowsDir) : null;
328
- const flutterOptions = resolveFlutterOptions({
329
- cli: {
330
- envMode: opts.flutterEnvMode, stores: opts.flutterStore ?? inferredStores,
331
- androidDeployMode: opts.androidDeployMode, iosDeployMode: opts.iosDeployMode,
332
- },
333
- existing,
318
+ // 타입 전용 옵션 — CLI 플래그 → version.yml 저장값 → 기본값을 타입 훅이 정한다. 해당 타입이 없으면 비어 있다.
319
+ // (Flutter는 저장값이 없는 기존 설치의 스토어를 설치된 워크플로우로 추론한다 — 대화형과 같은 결론.)
320
+ const typeOptions = mergeHookResults(types, "resolveOptions", {
321
+ opts, existing, workflowsDir: join(cwd, PATHS.workflowsDir),
334
322
  });
335
323
 
336
324
  // 고른 배포 방식 — 플래그 → 저장값 → 기본값. 실제 설치되는 방식으로의 정리는 컨텍스트 조립에서 한다.
@@ -338,7 +326,7 @@ async function runInner(argv, {
338
326
 
339
327
  const context = buildInstallContext({
340
328
  payload, existing, templateVersion: readTemplateVersion(), types, deployStyle: chosenDeployStyle,
341
- flutterOptions,
329
+ typeOptions,
342
330
  // 옵션: CLI 플래그 최우선 → version.yml 저장 옵션 → 기본값 (대화형과 같은 규칙)
343
331
  releaseOptions: resolveReleaseOptions({ semverAuto: opts.includeSemverAuto, copilotAi: opts.includeCopilotAi }, existing),
344
332
  mode: opts.mode, force: opts.force, version, versionCode, branch,
@@ -346,7 +334,7 @@ async function runInner(argv, {
346
334
  paths,
347
335
  repoName,
348
336
  // @wizard ask/auto 토큰 값을 계산하는 resolver
349
- resolvers: makeResolvers(cwd, repoName, paths, flutterOptions),
337
+ resolvers: makeResolvers(cwd, repoName, paths, typeOptions),
350
338
  now, today,
351
339
  // 설치 로그용 부가 문맥 — 설치 동작 자체는 바꾸지 않는다.
352
340
  markers: detectMarkers(cwd, types), detectWarnings,
@@ -395,18 +383,11 @@ async function runInner(argv, {
395
383
  });
396
384
  for (const n of postInstallNotices(result)) console.error(n.startsWith(" ") ? n : `⚠️ ${n}`);
397
385
  // store_submit 배포 모드는 main push마다 심사를 자동 제출한다 — 비대화형에서도 같은 경고를 보여준다
398
- // (대화형 경로는 ui/prompts.js#deployModeWarning을 선택 시점에 note로 보여준다).
399
- // Flutter 타입이 아니거나 해당 스토어를 선택하지 않은 프로젝트에는 뜨면 안 된다.
400
- const { stores } = flutterOptions;
401
- const warnings = [];
402
- if (types.includes("flutter") && (stores === null || stores.includes("android"))) {
403
- warnings.push(prompts.deployModeWarning(flutterOptions.androidDeployMode));
404
- }
405
- if (types.includes("flutter") && (stores === null || stores.includes("ios"))) {
406
- warnings.push(prompts.deployModeWarning(flutterOptions.iosDeployMode));
407
- }
408
- for (const w of warnings) {
409
- 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
+ }
410
391
  }
411
392
  return 0;
412
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가 됐는데, 그 규칙이 사전에 안내되지 않아