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.
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.0 (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.0",
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",
@@ -21,6 +21,8 @@
21
21
  "test": "npm run test:node && npm run test:py",
22
22
  "test:node": "node --test --test-concurrency=1",
23
23
  "test:py": "node scripts/run-py-tests.mjs",
24
+ "generate:workflows": "node scripts/generate-workflows.mjs",
25
+ "generate:workflows:check": "node scripts/generate-workflows.mjs --check",
24
26
  "sync:dogfood": "node scripts/sync-dogfood.mjs",
25
27
  "sync:dogfood:check": "node scripts/sync-dogfood.mjs --check"
26
28
  },
@@ -436,6 +436,64 @@ def sync_maven(path_dir, new_version):
436
436
 
437
437
 
438
438
  _GRADLE_VERSION_RE = re.compile(r"""^([ \t]*version[ \t]*=[ \t]*)(['"])([^'"\n]*)\2""", re.MULTILINE)
439
+ _GRADLE_SHARED_BLOCKS = ("allprojects", "subprojects")
440
+
441
+
442
+ def _gradle_code(line, quote=""):
443
+ """한 줄에서 주석을 떼고 따옴표 안 글자는 공백으로 바꾼다. url 'https://…'의 `//`나
444
+ 문자열 속 중괄호가 블록 깊이 계산을 흐트러뜨리지 않게 한다. detect.js와 같은 규칙.
445
+
446
+ quote는 이 줄이 시작될 때 열려 있던 따옴표다. 삼중따옴표(\"\"\" ''')는 여러 줄에 걸치므로
447
+ (코드, 줄 끝에서 열려 있는 따옴표)를 돌려줘 다음 줄이 이어받게 한다. 한 줄짜리 따옴표는
448
+ 줄 끝에서 닫힌 것으로 본다."""
449
+ out, i = [], 0
450
+ while i < len(line):
451
+ ch = line[i]
452
+ if quote:
453
+ if ch == "\\" and i + 1 < len(line):
454
+ out.append(" "); i += 2; continue
455
+ if line.startswith(quote, i):
456
+ out.append(quote); i += len(quote); quote = ""; continue
457
+ out.append(" ")
458
+ elif line.startswith(('"""', "'''"), i):
459
+ quote = line[i:i + 3]; out.append(quote); i += 3; continue
460
+ elif ch in "'\"":
461
+ quote = ch; out.append(ch)
462
+ elif line.startswith("//", i):
463
+ break
464
+ else:
465
+ out.append(ch)
466
+ i += 1
467
+ return "".join(out), quote if len(quote) == 3 else ""
468
+
469
+
470
+ def _gradle_version_matches(text):
471
+ """프로젝트 버전 줄의 match 목록. 읽기와 동기화가 이 한 곳을 공유해 같은 줄만 다룬다.
472
+
473
+ 들여쓰지 않은 `version =`이 있으면 그것만 쓴다. 없을 때만 allprojects/subprojects 블록 안의
474
+ 들여쓴 줄을 인정한다 — `node { version = '20.11.0' }` 같은 플러그인 설정 블록을
475
+ 프로젝트 버전으로 오인하면 버전이 뛰고 빌드 설정이 깨진다."""
476
+ top, shared = [], []
477
+ stack = [] # 열린 블록 이름. 줄 끝이 `{`로 끝나는 줄의 마지막 단어
478
+ quote = "" # 여러 줄 문자열 안이면 그 따옴표. 문자열 속 줄은 코드가 아니다
479
+ pos = 0
480
+ for line in text.splitlines(keepends=True):
481
+ m = None if quote else _GRADLE_VERSION_RE.match(line)
482
+ if m:
483
+ m = _GRADLE_VERSION_RE.match(text, pos)
484
+ if not m.group(1)[:1].isspace():
485
+ top.append(m)
486
+ elif any(b in _GRADLE_SHARED_BLOCKS for b in stack):
487
+ shared.append(m)
488
+ code, quote = _gradle_code(line, quote)
489
+ name = re.search(r"(\w+)\s*\{[^{}]*$", code)
490
+ for ch in code:
491
+ if ch == "{":
492
+ stack.append(name.group(1) if name else "")
493
+ elif ch == "}" and stack:
494
+ stack.pop()
495
+ pos += len(line)
496
+ return top or shared
439
497
 
440
498
 
441
499
  def sync_spring(path_dir, new_version):
@@ -455,10 +513,13 @@ def sync_spring(path_dir, new_version):
455
513
  text = read_file(gradle_file)
456
514
  # 줄 시작의 `version =`만 프로젝트 버전이다. 앵커가 없으면 kotlin_version 같은
457
515
  # 의존성 버전 변수까지 함께 바뀌어 빌드가 깨진다.
458
- new_text, count = _GRADLE_VERSION_RE.subn(
459
- lambda m: f"{m.group(1)}{m.group(2)}{_keep_snapshot(m.group(3), new_version)}{m.group(2)}", text,
460
- )
461
- if count == 0:
516
+ matches = _gradle_version_matches(text)
517
+ new_text = text
518
+ # 뒤에서부터 바꿔 앞쪽 span이 밀리지 않게 한다.
519
+ for m in reversed(matches):
520
+ replaced = f"{m.group(1)}{m.group(2)}{_keep_snapshot(m.group(3), new_version)}{m.group(2)}"
521
+ new_text = new_text[:m.start()] + replaced + new_text[m.end():]
522
+ if not matches:
462
523
  log(f"WARNING: spring: no `version = '...'` line in {gradle_file} — skipping")
463
524
  continue
464
525
  write_file(gradle_file, new_text)
@@ -586,21 +647,36 @@ def sync_python(path_dir, new_version):
586
647
  log(f"updated: {target}")
587
648
 
588
649
 
650
+ _PLIST_VERSION_RE = re.compile(r'(<key>CFBundleShortVersionString</key>\s*<string>)([^<]*)(</string>)')
651
+
652
+
653
+ def _rn_app_plists(ios_dir):
654
+ """앱 타깃의 Info.plist만 이름순으로. Pods·빌드 산출물·테스트 타깃은 우리 버전이 아니다.
655
+ 읽기와 동기화가 같은 파일 집합을 보도록 한 곳에서 고른다."""
656
+ skip = {"Pods", "build"}
657
+ return [
658
+ p for p in sorted(ios_dir.glob("*/Info.plist"))
659
+ if p.parent.name not in skip and not p.parent.name.endswith("Tests")
660
+ ]
661
+
662
+
589
663
  def sync_react_native(path_dir, new_version):
590
664
  ios_dir = Path(path_dir) / "ios"
591
665
  found_plist = False
592
666
  if ios_dir.is_dir():
593
- for plist_file in ios_dir.rglob("Info.plist"):
667
+ for plist_file in _rn_app_plists(ios_dir):
594
668
  text = read_file(plist_file)
595
- if "CFBundleShortVersionString" in text:
596
- new_text = re.sub(
597
- r'(<key>CFBundleShortVersionString</key>\s*<string>)[^<]*(</string>)',
598
- r'\g<1>' + new_version + r'\g<2>',
599
- text,
600
- )
601
- write_file(plist_file, new_text)
602
- log(f"updated: {plist_file}")
603
- found_plist = True
669
+ m = _PLIST_VERSION_RE.search(text)
670
+ if not m:
671
+ continue
672
+ # $(MARKETING_VERSION) 같은 빌드 변수 참조는 Xcode 설정이 원본이라 덮어쓰지 않는다.
673
+ if m.group(2).strip().startswith("$("):
674
+ log(f"skipped: {plist_file} — CFBundleShortVersionString references a build variable")
675
+ continue
676
+ new_text = _PLIST_VERSION_RE.sub(lambda mm: mm.group(1) + new_version + mm.group(3), text)
677
+ write_file(plist_file, new_text)
678
+ log(f"updated: {plist_file}")
679
+ found_plist = True
604
680
  else:
605
681
  log(f"WARNING: react-native: {ios_dir} not found — skipping")
606
682
 
@@ -691,7 +767,7 @@ def _read_spring(path_dir):
691
767
  for name in ("build.gradle", "build.gradle.kts"):
692
768
  p = Path(path_dir) / name
693
769
  if p.is_file():
694
- for m in _GRADLE_VERSION_RE.finditer(read_file(p)):
770
+ for m in _gradle_version_matches(read_file(p)):
695
771
  version = core_version(m.group(3))
696
772
  if version:
697
773
  return version
@@ -725,10 +801,9 @@ def _read_react_native(path_dir):
725
801
  $(MARKETING_VERSION) 참조나 템플릿 기본값 "1.0"처럼 x.y.z가 아닌 값은 건너뛴다."""
726
802
  ios_dir = Path(path_dir) / "ios"
727
803
  if ios_dir.is_dir():
728
- # rglob 첫 결과는 파일시스템 순서라 Pods·테스트 타깃 plist가 걸릴 수 있다 — 앱 폴더 한 단계만, 이름순.
729
- for plist in sorted(ios_dir.glob("*/Info.plist")):
730
- m = re.search(r'<key>CFBundleShortVersionString</key>\s*<string>([^<]*)</string>', read_file(plist))
731
- version = core_version(m.group(1)) if m else None
804
+ for plist in _rn_app_plists(ios_dir):
805
+ m = _PLIST_VERSION_RE.search(read_file(plist))
806
+ version = core_version(m.group(2)) if m else None
732
807
  if version:
733
808
  return version
734
809
  gradle_file = Path(path_dir) / "android" / "app" / "build.gradle"
@@ -27,6 +27,7 @@
27
27
  # - go: (none — git tag based, go.mod has no version field)
28
28
  # - basic: version.yml only
29
29
  # A file version like 1.2.0-rc.1 or 1.2.0+4 is read as 1.2.0 (version below is x.y.z only).
30
+ # Synced files are rewritten to x.y.z as well, so even `get` turns 2.0.0-beta.1 in package.json into 2.0.0.
30
31
  # Gradle/Maven -SNAPSHOT is kept when the version is bumped (1.2.0-SNAPSHOT -> 1.2.1-SNAPSHOT).
31
32
  # ===
32
33
 
@@ -52,5 +53,5 @@ metadata:
52
53
  semver_auto: {{OPT_SEMVER_AUTO}}
53
54
  copilot_ai: {{OPT_COPILOT_AI}}
54
55
  deploy_style: "{{OPT_DEPLOY_STYLE}}" # simple | nginx | traefik | none (서버 배포 워크플로우)
55
- {{FLUTTER_OPTIONS}}
56
+ {{TYPE_OPTIONS}}
56
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;
@@ -11,12 +11,11 @@
11
11
  // 않는다. 번역하면 설명은 읽히지만 정작 화면에서 그 항목을 찾지 못한다.
12
12
  import { spawnSync } from "node:child_process";
13
13
  import { existsSync, readFileSync } from "node:fs";
14
- import { join, posix } from "node:path";
14
+ import { join } from "node:path";
15
15
  import { A, paint, colorEnabled, visualWidth } from "../ui/ansi.js";
16
16
  import { PATHS } from "../core/paths.js";
17
17
  import { parseExisting } from "../core/version-yml.js";
18
- import { STORE_PLATFORMS, STORE_APP_FILES, parseStoreList, storeAppFilesFor } from "../core/flutter-options.js";
19
- import { inferInstalledStores } from "../core/installed-stores.js";
18
+ import { hooksFor } from "../core/types.js";
20
19
 
21
20
  const defaultExec = (cmd, args) => spawnSync(cmd, args, { encoding: "utf8" });
22
21
 
@@ -43,8 +42,13 @@ export function runDoctor(cwd = process.cwd(), { exec = defaultExec } = {}) {
43
42
  note: ["이 폴더엔 아직 설치되지 않았습니다 — 지금 설치하면 됩니다."],
44
43
  });
45
44
 
46
- // Flutter 스토어 배포 파일 점검 — 로컬 파일만 보므로 gh 조회 결과와 무관하게 수행한다.
47
- if (installed) for (const item of flutterStoreChecks(cwd)) add(item);
45
+ // 타입별 로컬 파일 점검(예: Flutter 스토어 배포 파일) — 로컬 파일만 보므로 gh 조회 결과와 무관하게 수행한다.
46
+ if (installed) {
47
+ const existing = parseExisting(readFileSync(join(cwd, PATHS.versionFile), "utf8"));
48
+ for (const { hook } of hooksFor(existing.types, "doctorChecks")) {
49
+ for (const item of hook(cwd, existing, { docs: DOC })) add(item);
50
+ }
51
+ }
48
52
 
49
53
  const ghVersion = exec("gh", ["--version"]);
50
54
  if (ghVersion.error || ghVersion.status !== 0) {
@@ -179,51 +183,6 @@ export function runDoctor(cwd = process.cwd(), { exec = defaultExec } = {}) {
179
183
  return results;
180
184
  }
181
185
 
182
- const PLATFORM_ROW_NAME = { android: "Flutter Android 배포 파일", ios: "Flutter iOS 배포 파일" };
183
- const PLACEHOLDER_RE = /__[A-Z][A-Z0-9_]*__/g; // 감지 규칙은 ExportOptions.plist 템플릿·IOS-TESTFLIGHT 검증과 동일
184
- const EXPORT_OPTIONS_REL = STORE_APP_FILES.ios.find((rel) => rel.endsWith("ExportOptions.plist"));
185
-
186
- // Flutter 스토어 배포 진단 — 선택한 플랫폼의 필수 파일과 ExportOptions.plist 플레이스홀더.
187
- // 스토어 시크릿 등록 여부는 이번 범위 밖이다. 반환: doctor 결과 행 배열.
188
- function flutterStoreChecks(cwd) {
189
- const existing = parseExisting(readFileSync(join(cwd, PATHS.versionFile), "utf8"));
190
- if (!existing.types.includes("flutter")) return [];
191
- const saved = existing.options.flutterStore == null ? null : parseStoreList(existing.options.flutterStore);
192
- // 저장값 없는 기존 설치는 설치된 스토어 워크플로우로 추론한다 (interactive와 같은 규칙).
193
- const stores = saved ?? inferInstalledStores(join(cwd, PATHS.workflowsDir));
194
- const flutterRoot = existing.paths.get("flutter") || ".";
195
- const rows = [];
196
-
197
- for (const platform of STORE_PLATFORMS.filter((p) => stores.includes(p))) {
198
- const files = storeAppFilesFor([platform]).map((rel) => posix.join(flutterRoot, rel));
199
- const missing = files.filter((file) => !existsSync(join(cwd, file)));
200
- const head = { name: PLATFORM_ROW_NAME[platform], purpose: "fastlane 스토어 업로드에 필요한 파일" };
201
- rows.push(missing.length
202
- ? {
203
- ...head, status: "WARN", value: `없는 파일: ${missing.join(", ")}`,
204
- impact: ["이 파일이 없으면 스토어 배포 워크플로우가 fastlane 단계에서 실패합니다."],
205
- actions: ["마법사를 다시 실행하면 없는 파일만 새로 만들어 줍니다 (이미 있는 파일은 덮어쓰지 않음)"],
206
- doc: DOC.flutterStore,
207
- }
208
- : { ...head, status: "OK", value: `${files.length}개 있음` });
209
- }
210
-
211
- const plistPath = posix.join(flutterRoot, EXPORT_OPTIONS_REL);
212
- if (stores.includes("ios") && existsSync(join(cwd, plistPath))) {
213
- const head = { name: "ExportOptions.plist", purpose: "iOS 서명·내보내기 설정" };
214
- const left = [...new Set(readFileSync(join(cwd, plistPath), "utf8").match(PLACEHOLDER_RE) ?? [])];
215
- rows.push(left.length
216
- ? {
217
- ...head, status: "WARN", value: `채워지지 않은 값: ${left.join(", ")}`,
218
- impact: ["채우지 않으면 IOS-TESTFLIGHT 워크플로우가 ExportOptions.plist 검증 단계에서 중단됩니다."],
219
- actions: [`${plistPath} 의 플레이스홀더를 실제 값(Team ID · 번들 ID · 프로비저닝 프로파일 이름)으로 바꾸세요`],
220
- doc: DOC.flutterStore,
221
- }
222
- : { ...head, status: "OK", value: "플레이스홀더 없음" });
223
- }
224
- return rows;
225
- }
226
-
227
186
  const asLines = (v) => (Array.isArray(v) ? v : v ? [String(v)] : []);
228
187
  const headOf = (r) => `${r.label || r.name}${r.purpose ? ` — ${r.purpose}` : ""}`;
229
188
 
@@ -4,9 +4,9 @@ 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
+ import { planTypeAppFiles } from "../core/copy/app-files.js";
8
8
  import { planUninstall } from "./uninstall.js";
9
- import { renderVersionYml, parseExisting } from "../core/version-yml.js";
9
+ import { renderVersionYml, parseExisting, sameIgnoringTimestamps } from "../core/version-yml.js";
10
10
  import { readVersionYmlTemplate } from "../core/assets.js";
11
11
  import { existingMarkerInDir } from "../core/paths-resolve.js";
12
12
  import { planScripts } from "../core/copy/simple.js";
@@ -28,7 +28,8 @@ function versionYmlPreview(context, payloadRoot, targetRoot) {
28
28
  const wouldBe = renderVersionYml(context, readVersionYmlTemplate(payloadRoot), {
29
29
  pathMarkers, extraTopLevel,
30
30
  });
31
- return { existed: existingRaw !== null, changed: existingRaw !== wouldBe };
31
+ // 실제 설치와 같은 기준으로 비교한다 — 시각 줄만 다르면 설치도 파일을 다시 쓰지 않는다.
32
+ return { existed: existingRaw !== null, changed: existingRaw === null || !sameIgnoringTimestamps(existingRaw, wouldBe) };
32
33
  }
33
34
 
34
35
  // mode: "full" | "uninstall". 읽기 전용 — 아무 파일도 쓰지 않는다.
@@ -49,7 +50,7 @@ export function planDryRun(mode, context, payloadRoot, targetRoot = ".") {
49
50
  cleanup,
50
51
  gitignore: backedUp ? planGitignore(targetRoot) : null,
51
52
  // Flutter 스토어 배포 파일(Fastfile·ExportOptions.plist) — 이미 있는 파일은 덮어쓰지 않고 유지한다.
52
- flutterApp: planFlutterAppFiles(context, payloadRoot, targetRoot),
53
+ flutterApp: planTypeAppFiles(context, payloadRoot, targetRoot),
53
54
  versionYml: versionYmlPreview(context, payloadRoot, targetRoot),
54
55
  // 실제 설치가 함께 바꾸는 파일 — 특히 기존 파일을 덮어쓰는 스크립트는 미리 보여줘야 한다.
55
56
  scripts: planScripts(payloadRoot, targetRoot),
@@ -12,12 +12,12 @@ import { existingMarkerInDir } from "../core/paths-resolve.js";
12
12
  import { addVersionSectionToReadme, README_STATUS_LABEL } from "../core/copy/readme.js";
13
13
  import { copyWorkflows, computeBaselineEntries, makeSrcText } from "../core/copy/workflows.js";
14
14
  import { copyScripts, removeScriptBytecode } from "../core/copy/simple.js";
15
- import { copyFlutterAppFiles } from "../core/copy/flutter-app.js";
15
+ import { copyTypeAppFiles } from "../core/copy/app-files.js";
16
16
  import { ensureGitignore } from "../core/copy/gitignore.js";
17
17
  import { readBaseline, writeBaseline, appFileHash } from "../core/baseline.js";
18
18
  import { scanUnsubstituted, classifySecrets, narrowSecretsBySshAuth } from "../core/verify.js";
19
19
  import { cleanupOtherDeployWorkflows, payloadWorkflowNames, DEFAULT_DEPLOY_STYLE } from "../core/deploy-style.js";
20
- import { cleanupDeselectedStoreWorkflows } from "../core/flutter-options.js";
20
+ import { hooksFor } from "../core/types.js";
21
21
  import { findStaleWorkflows, cleanupStaleWorkflows } from "../core/removal-plan.js";
22
22
  import { log, maskValue } from "../core/logger.js";
23
23
 
@@ -62,9 +62,9 @@ export function runFull(context, payloadRoot, targetRoot = ".", hooks = {}) {
62
62
 
63
63
  // 1-1. Flutter 앱 파일(fastlane·ExportOptions) — 사용자가 값을 채워 쓰는 파일이라 없을 때만 만든다.
64
64
  // 워크플로우 복사가 끝난 뒤에 실행한다(워크플로우가 이 파일들을 전제로 돈다).
65
- const flutterApp = copyFlutterAppFiles(context, payloadRoot, targetRoot);
66
- for (const f of flutterApp.created) log.info("flutter-app", "create", f);
67
- for (const f of flutterApp.kept) log.info("flutter-app", "keep", `${f} (기존 파일 유지)`);
65
+ const flutterApp = copyTypeAppFiles(context, payloadRoot, targetRoot, (tag, action, f) => {
66
+ log.info(tag, action, action === "keep" ? `${f} (기존 파일 유지)` : f);
67
+ });
68
68
 
69
69
  // 기존 version.yml의 알려지지 않은 최상위 필드를 재생성 시 보존한다.
70
70
  const vyPath = join(targetRoot, PATHS.versionFile);
@@ -188,23 +188,24 @@ export function cleanupWorkflows(context, payloadRoot, targetRoot, baseline, { j
188
188
  const cleanup = cleanupOtherDeployWorkflows(workflowsDir, listDir(),
189
189
  context.deployStyle || DEFAULT_DEPLOY_STYLE, baseline,
190
190
  { available: payloadWorkflowNames(payloadRoot), justWritten, dryRun });
191
- const storeCleanup = Array.isArray(context.flutterStore) && (context.types || []).includes("flutter")
192
- ? cleanupDeselectedStoreWorkflows(workflowsDir, listDir(), context.flutterStore, baseline, { dryRun })
193
- : { removed: [], backedUp: [] };
191
+ const storeCleanup = { removed: [], backedUp: [] };
192
+ for (const { hook } of hooksFor(context.types || [], "cleanupWorkflows")) {
193
+ const r = hook(workflowsDir, listDir(), context, baseline, { dryRun });
194
+ storeCleanup.removed.push(...r.removed);
195
+ storeCleanup.backedUp.push(...r.backedUp);
196
+ }
194
197
  const staleCleanup = cleanupStaleWorkflows(targetRoot, findStaleWorkflows(payloadRoot, targetRoot, baseline), baseline, { dryRun });
195
198
  return { cleanup, storeCleanup, staleCleanup };
196
199
  }
197
200
 
198
- // 설치 결과를 가른 선택(배포 방식·자동 승격·Copilot·Flutter 옵션)을 남긴다 — 대화형에서 고른 값도
201
+ // 설치 결과를 가른 선택(배포 방식·자동 승격·Copilot·타입 전용 옵션)을 남긴다 — 대화형에서 고른 값도
199
202
  // 여기로 모이므로, 나중에 "왜 이렇게 설치됐나"를 로그만으로 따라갈 수 있다.
200
203
  function logChoices(context, types) {
201
204
  if (context.deployStyle) log.info("option", "deploy", context.deployStyle);
202
205
  if (context.includeSemverAuto != null) log.info("option", "semver", context.includeSemverAuto ? "on" : "off");
203
206
  if (context.includeCopilotAi != null) log.info("option", "copilot", context.includeCopilotAi ? "on" : "off");
204
- if (types.includes("flutter")) {
205
- const stores = Array.isArray(context.flutterStore) ? (context.flutterStore.join(",") || "없음") : "미결정(둘 다)";
206
- log.info("option", "flutter",
207
- `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);
208
209
  }
209
210
  }
210
211
 
@@ -0,0 +1,49 @@
1
+ // 설치 설정 해석 단계 — 비대화형(index.js)과 대화형(interactive.js)이 공유한다.
2
+ // 값을 채우는 방법(플래그·질문)은 경로마다 다르지만, 타입·버전·배포 방식·옵션을 확정하고
3
+ // 설치 컨텍스트로 조립하는 규칙은 한 곳에 둬야 한쪽만 고쳐 두 모드의 결과가 갈라지지 않는다.
4
+ import { detectVersion, detectBuildNumber } from "../core/detect-fs.js";
5
+ import { createContext } from "../context.js";
6
+ import { mergeHookResults } from "../core/types.js";
7
+ import { isDeployStyle, DEFAULT_DEPLOY_STYLE, hasServerDeployWorkflows, effectiveDeployStyle } from "../core/deploy-style.js";
8
+
9
+ // version.yml에 저장된 배포 방식 — 없거나 알 수 없는 값이면 "".
10
+ export const savedDeployStyle = (existing) =>
11
+ isDeployStyle(existing?.options?.deployStyle) ? existing.options.deployStyle : "";
12
+
13
+ // 배포 방식 — 명시값(플래그·질문 답변) → 저장값 → 기본값.
14
+ // 서버 배포 워크플로우가 없는 타입은 설치 결과에 영향이 없으므로 기록하지 않는다(null).
15
+ export function resolveDeployStyle({ payload, types, explicit = "", existing }) {
16
+ if (!hasServerDeployWorkflows(payload, types)) return null;
17
+ return explicit || savedDeployStyle(existing) || DEFAULT_DEPLOY_STYLE;
18
+ }
19
+
20
+ // 버전 — 기존 version.yml 최우선(재실행 시 덮어쓰기 방지) → 명시값 → 파일 감지.
21
+ // detectOpts는 감지 함수로 그대로 넘긴다(types·paths·warn·hint).
22
+ export function resolveVersion({ cwd, existing, explicit = "", ...detectOpts }) {
23
+ return existing?.version || explicit || detectVersion(cwd, detectOpts);
24
+ }
25
+
26
+ // 빌드 번호 — 기존 값 보존, 신규 통합이면 프로젝트 파일에서 감지, 못 찾으면 1.
27
+ export function resolveVersionCode({ cwd, existing, types, paths }) {
28
+ return existing?.versionCode ?? detectBuildNumber(cwd, { types, paths }) ?? 1;
29
+ }
30
+
31
+ // 확정된 값들을 설치 컨텍스트로 조립한다.
32
+ // deployStyle: resolveDeployStyle 결과(고른 값). 실제 설치되는 방식으로 바꿔 기록한다.
33
+ // typeOptions: 타입 훅(resolveOptions)이 확정한 타입 전용 옵션 — 두 경로 모두 확정된 값. 컨텍스트 필드로의 변환도 타입 훅이 한다.
34
+ // releaseOptions: { includeSemverAuto, includeCopilotAi } — resolveReleaseOptions 결과.
35
+ // extra: 경로별로만 쓰는 필드(envValues 등).
36
+ export function buildInstallContext({
37
+ payload, existing, templateVersion, types, deployStyle, typeOptions, releaseOptions, ...rest
38
+ }) {
39
+ return createContext({
40
+ types,
41
+ ...releaseOptions,
42
+ // 안내는 고른 값으로 하되, 기록은 실제로 설치되는 방식으로 한다.
43
+ deployStyle: effectiveDeployStyle(payload, types, deployStyle),
44
+ ...mergeHookResults(types, "contextFields", typeOptions),
45
+ previousTemplateVersion: existing?.templateVersion || "",
46
+ templateVersion,
47
+ ...rest,
48
+ });
49
+ }
@@ -5,7 +5,7 @@
5
5
  import { join } from "node:path";
6
6
  import { existsSync, readFileSync } from "node:fs";
7
7
  import { resolvePayloadRoot, assertPayload, readTemplateVersion } from "../core/assets.js";
8
- import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName, makeResolvers, detectBuildNumber, detectMarkers } from "../core/detect-fs.js";
8
+ import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName, makeResolvers, detectMarkers } from "../core/detect-fs.js";
9
9
  import { parseExisting } from "../core/version-yml.js";
10
10
  import { pickReleaseOptions, resolveReleaseOptions } from "../core/release-options.js";
11
11
  import { runBreakingCheck } from "../core/breaking-check.js";
@@ -15,8 +15,9 @@ import {
15
15
  } from "../core/branches.js";
16
16
  import { promptEnvPlan } from "../ui/env-plan.js";
17
17
  import { surveyWorkflows } from "../core/copy/workflows.js";
18
- import { createContext, VALID_TYPES } from "../context.js";
19
- import { isDeployStyle, DEFAULT_DEPLOY_STYLE, hasServerDeployWorkflows, hasNonstopWorkflows, effectiveDeployStyle } from "../core/deploy-style.js";
18
+ import { VALID_TYPES } from "../context.js";
19
+ import { savedDeployStyle, resolveDeployStyle, resolveVersion, resolveVersionCode, buildInstallContext } from "./install-settings.js";
20
+ import { isDeployStyle, DEFAULT_DEPLOY_STYLE, hasServerDeployWorkflows, hasNonstopWorkflows } from "../core/deploy-style.js";
20
21
  import { PATHS } from "../core/paths.js";
21
22
  import { resolveFlutterOptions, DEFAULT_DEPLOY_MODE, STORE_PLATFORMS } from "../core/flutter-options.js";
22
23
  import { inferInstalledStores } from "../core/installed-stores.js";
@@ -83,8 +84,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
83
84
  // 먼저 나와 앞선 질문에 대한 경고처럼 보였다. 안내 문구도 대화형용으로 바꾼다.
84
85
  const detectWarnings = [];
85
86
  let types = detectTypes(cwd);
86
- let version = (existing?.version) || detectVersion(cwd, {
87
- types,
87
+ let version = resolveVersion({
88
+ cwd, existing, types,
88
89
  warn: (m) => detectWarnings.push(m),
89
90
  hint: "다음 화면의 '수정하기 > 버전'에서 바로 고칠 수 있습니다.",
90
91
  });
@@ -100,7 +101,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
100
101
  let { semverAuto: includeSemverAuto, copilotAi: includeCopilotAi } = pickReleaseOptions(
101
102
  { semverAuto: baseCtx?.includeSemverAuto, copilotAi: baseCtx?.includeCopilotAi }, existing);
102
103
  // 서버 배포 방식 — 저장값(version.yml)이 있으면 재질문하지 않는다 (semver_auto와 같은 규약).
103
- let deployStyle = isDeployStyle(existing?.options?.deployStyle) ? existing.options.deployStyle : "";
104
+ let deployStyle = savedDeployStyle(existing);
104
105
  const showOptional = mode === "full";
105
106
  const realTty = process.stdout.isTTY === true;
106
107
 
@@ -295,7 +296,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
295
296
  if (versionAutoDetected && [...paths.values()].some((p) => p && p !== ".")) {
296
297
  version = detectVersion(cwd, { types, paths, warn: () => {} });
297
298
  }
298
- const versionCode = existing?.versionCode ?? detectBuildNumber(cwd, { types, paths }) ?? 1; // 기존 빌드번호 보존, 신규 통합 시 프로젝트 파일에서 감지
299
+ const versionCode = resolveVersionCode({ cwd, existing, types, paths });
299
300
 
300
301
  // @wizard env 계획 질문 (full/workflows만)
301
302
  const resolvers = makeResolvers(cwd, repoName, paths, flutterOptions);
@@ -312,20 +313,17 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), payloadRoot
312
313
  }
313
314
 
314
315
  const { now, today } = clock || utcNow();
315
- const ctx = createContext({
316
- mode, force: true, types, version, versionCode, branch, branches, paths,
317
- includeSemverAuto,
318
- includeCopilotAi,
319
- repoName, templateVersion, resolvers, envValues, envUseDefaults, now, today,
316
+ const ctx = buildInstallContext({
317
+ payload, existing, templateVersion, types,
318
+ // 저장값이 무중단이어도 선택한 타입에 그 방식이 없으면 단일 서버 배포가 설치된다 — 설치된 방식을 기록한다.
319
+ deployStyle: resolveDeployStyle({ payload, types, explicit: deployStyle, existing }),
320
+ typeOptions: flutterOptions,
321
+ releaseOptions: { includeSemverAuto, includeCopilotAi },
322
+ mode, force: true, version, versionCode, branch, branches, paths,
323
+ repoName, resolvers, envValues, envUseDefaults, now, today,
320
324
  // 설치 로그·완료 요약이 쓰는 부가 문맥 — 설치 동작 자체는 바꾸지 않는다.
321
325
  markers, envAnswers, detectWarnings,
322
- // 저장값이 무중단이어도 선택한 타입에 그 방식이 없으면 단일 서버 배포가 설치된다 — 설치된 방식을 기록한다.
323
- deployStyle: hasServerDeploy() ? effectiveDeployStyle(payload, types, deployStyle || DEFAULT_DEPLOY_STYLE) : null,
324
- envMode: flutterOptions.envMode, flutterStore: flutterOptions.stores,
325
- androidDeployMode: flutterOptions.androidDeployMode, iosDeployMode: flutterOptions.iosDeployMode,
326
- previousTemplateVersion: existing?.templateVersion || "",
327
326
  });
328
- ctx.templateVersion = templateVersion;
329
327
 
330
328
  // 사용자가 답해야 하는 것만 묻는다. baseline 3-way가 자동으로 안전한 경우를
331
329
  // 걸러내므로, 여기 오는 것은 (a) 양쪽이 다 바뀐 진짜 충돌과 (b) 사용자가 지운 파일뿐이다.
@@ -7,12 +7,10 @@
7
7
  import { join } from "node:path";
8
8
  import { existsSync, readFileSync } from "node:fs";
9
9
  import { PATHS } from "../core/paths.js";
10
- import { remove } from "../core/fsutil.js";
11
10
  import { planRemoval, backupArtifacts } from "../core/removal-plan.js";
12
- import { removeAppFiles, pruneInstallDirs } from "./uninstall.js";
13
- import { removeVersionSectionFromReadme, hasVersionSection } from "../core/copy/readme.js";
14
- import { removeAutoAddedEntriesFromGitignore, hasAutoAddedEntries } from "../core/copy/gitignore.js";
15
- import { logRemovals } from "../core/logger.js";
11
+ import { executeRemoval } from "../core/removal-exec.js";
12
+ import { hasVersionSection } from "../core/copy/readme.js";
13
+ import { hasAutoAddedEntries } from "../core/copy/gitignore.js";
16
14
 
17
15
  const CHANGELOG_FILES = ["CHANGELOG.json", "CHANGELOG.md"];
18
16
 
@@ -87,22 +85,11 @@ export function printPurgePlan(plan, { dryRun = false } = {}) {
87
85
  // 이론상 어긋나는 경우에도 printPurgeResult가 거짓으로 "제거됨"을 보고하지 않는다.
88
86
  export function executePurge(payloadRoot, targetRoot = ".", keepFlags = {}) {
89
87
  const plan = planPurge(payloadRoot, targetRoot, keepFlags);
90
- const wfDir = join(targetRoot, PATHS.workflowsDir);
91
- const done = [];
92
- for (const name of plan.workflows) { remove(join(wfDir, name)); done.push(["remove", "workflow", name]); }
93
- for (const name of plan.scripts) { remove(join(targetRoot, PATHS.scriptsDir, name)); done.push(["remove", "script", name]); }
94
- removeAppFiles(targetRoot, plan.appFiles, done);
95
- for (const p of plan.baseline || []) { remove(join(targetRoot, p)); done.push(["remove", "metadata", p]); }
96
- pruneInstallDirs(targetRoot, plan);
97
- if (plan.versionYml) { remove(join(targetRoot, PATHS.versionFile)); done.push(["remove", "version", PATHS.versionFile]); }
98
- const readmeSection = plan.readmeSection && removeVersionSectionFromReadme(targetRoot) === "removed";
99
- const gitignoreStatus = plan.gitignore ? removeAutoAddedEntriesFromGitignore(targetRoot) : null;
100
- const gitignore = gitignoreStatus === "removed" || gitignoreStatus === "file-deleted";
101
- if (gitignore) done.push(["remove", "gitignore", ".gitignore 자동 추가 항목"]);
102
- for (const f of plan.changelog) { remove(join(targetRoot, f)); done.push(["remove", "changelog", f]); }
103
- if (readmeSection) done.push(["remove", "readme", "README.md 버전 섹션"]);
104
- logRemovals(targetRoot, done);
105
- return { ...plan, readmeSection, gitignore };
88
+ const { readme, gitignore } = executeRemoval(targetRoot, { ...plan, readme: plan.readmeSection }, {
89
+ logOrder: ["version", "gitignore", "changelog", "readme"],
90
+ gitignoreDetail: (status) => (status === "removed" || status === "file-deleted" ? ".gitignore 자동 추가 항목" : null),
91
+ });
92
+ return { ...plan, readmeSection: readme, gitignore };
106
93
  }
107
94
 
108
95
  // 삭제 후 실제 제거된 목록 출력 — printPurgePlan과 완전히 동일한 형태(파일명 나열)로