project-auto-wizard 0.12.2 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.ja.md +165 -0
  2. package/README.ko.md +165 -0
  3. package/README.md +107 -286
  4. package/README.zh-CN.md +165 -0
  5. package/bin/project-auto-wizard.js +1 -2
  6. package/package.json +9 -3
  7. package/payload/config/breaking-changes.json +24 -0
  8. package/payload/config/wizard-prompts.yml +45 -0
  9. package/payload/scripts/changelog_manager.py +141 -46
  10. package/payload/scripts/issue_helper.py +34 -5
  11. package/payload/scripts/version_manager.py +482 -107
  12. package/payload/version.yml.template +5 -2
  13. package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +39 -9
  14. package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +165 -38
  15. package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +14 -5
  16. package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +220 -6
  17. package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +89 -3
  18. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +78 -16
  19. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +72 -16
  20. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +62 -20
  21. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +51 -13
  22. package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +75 -11
  23. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +52 -3
  24. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +60 -11
  25. package/payload/workflows/go/PROJECT-GO-CI.yaml +10 -1
  26. package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +148 -28
  27. package/payload/workflows/go/PROJECT-GO-SIMPLE-CICD.yaml +45 -4
  28. package/payload/workflows/next/PROJECT-NEXT-CI.yaml +25 -3
  29. package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +83 -9
  30. package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +70 -6
  31. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +148 -28
  32. package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +45 -4
  33. package/payload/workflows/react/PROJECT-REACT-CI.yaml +25 -3
  34. package/payload/workflows/react/PROJECT-REACT-CICD.yaml +84 -10
  35. package/payload/workflows/spring/PROJECT-SPRING-CI.yml +13 -3
  36. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +47 -5
  37. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +47 -5
  38. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +153 -30
  39. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +47 -5
  40. package/src/cli/args.js +33 -24
  41. package/src/cli/help.js +16 -4
  42. package/src/commands/doctor.js +26 -60
  43. package/src/commands/dry-run.js +59 -5
  44. package/src/commands/full.js +135 -55
  45. package/src/commands/install-settings.js +51 -0
  46. package/src/commands/interactive-flutter.js +1 -1
  47. package/src/commands/interactive.js +135 -69
  48. package/src/commands/purge.js +50 -21
  49. package/src/commands/status.js +18 -12
  50. package/src/commands/uninstall.js +22 -22
  51. package/src/context.js +5 -5
  52. package/src/core/baseline.js +20 -5
  53. package/src/core/branches.js +19 -1
  54. package/src/core/branding.js +1 -1
  55. package/src/core/breaking-check.js +14 -19
  56. package/src/core/breaking.js +7 -3
  57. package/src/core/copy/app-files.js +28 -0
  58. package/src/core/copy/gitignore.js +20 -12
  59. package/src/core/copy/readme.js +24 -5
  60. package/src/core/copy/simple.js +52 -13
  61. package/src/core/copy/workflows.js +133 -111
  62. package/src/core/deploy-style.js +83 -11
  63. package/src/core/detect-fs.js +127 -24
  64. package/src/core/detect.js +177 -47
  65. package/src/core/errors.js +3 -0
  66. package/src/core/flutter-doctor.js +49 -0
  67. package/src/core/flutter-hooks.js +29 -0
  68. package/src/core/flutter-options.js +4 -3
  69. package/src/core/fsutil.js +23 -6
  70. package/src/core/logger.js +55 -19
  71. package/src/core/paths-resolve.js +50 -47
  72. package/src/core/paths.js +26 -1
  73. package/src/core/release-options.js +23 -0
  74. package/src/core/removal-exec.js +67 -0
  75. package/src/core/removal-plan.js +75 -19
  76. package/src/core/types.js +136 -0
  77. package/src/core/verify.js +85 -19
  78. package/src/core/version-yml.js +42 -8
  79. package/src/core/wizard-env.js +21 -14
  80. package/src/core/wizard-labels.js +34 -25
  81. package/src/index.js +128 -60
  82. package/src/ui/ansi.js +3 -1
  83. package/src/ui/banner.js +12 -8
  84. package/src/ui/env-plan.js +68 -44
  85. package/src/ui/prompts.js +22 -12
  86. package/src/ui/readline-engine.js +109 -22
  87. package/src/ui/status-cards.js +10 -5
  88. package/src/ui/summary.js +71 -33
  89. package/payload/scripts/__pycache__/changelog_manager.cpython-314.pyc +0 -0
  90. package/payload/scripts/__pycache__/issue_helper.cpython-314.pyc +0 -0
  91. package/payload/scripts/__pycache__/version_manager.cpython-314.pyc +0 -0
@@ -1,7 +1,5 @@
1
- // wizard-prompts.yml 라벨 메타 파서 (.sh _wf_labels_path/_wf_read_field/wf_field/wf_workflow_name 등가).
2
- // 실측 기준: template_integrator.sh 2809~2814(_wf_labels_path), 2895~2932(wf_workflow_name),
3
- // 2934~2960(_wf_read_field), 2962~2970(wf_field).
4
- // ⚠️ YAML 라이브러리 금지 — .sh와 동일하게 라인 기반 파싱만 한다(외부 의존성 0 + 포맷 관용성 동일).
1
+ // wizard-prompts.yml 라벨 메타 파서.
2
+ // ⚠️ YAML 라이브러리 금지 — 라인 기반 파싱만 한다(외부 의존성 0, 형식이 조금 어긋난 사용자 파일도 읽힌다).
5
3
  import { join } from "node:path";
6
4
  import * as nodeFs from "node:fs";
7
5
  import { PAYLOAD } from "./paths.js";
@@ -9,7 +7,7 @@ import { PAYLOAD } from "./paths.js";
9
7
  // wizard-prompts.yml 위치 — 사용자 레포(커스텀 오버라이드용) 기준 상대경로
10
8
  export const LABELS_FILE = ".github/config/wizard-prompts.yml";
11
9
 
12
- // 따옴표 감싸진 값이면 벗기고 trim ("값" → 값). .sh sub(/^"/)/sub(/"$/) 등가.
10
+ // 따옴표 감싸진 값이면 벗기고 trim ("값" → 값).
13
11
  function unquote(s) {
14
12
  const t = s.trim();
15
13
  if (t.startsWith('"') && t.endsWith('"') && t.length >= 2) return t.slice(1, -1);
@@ -19,7 +17,7 @@ function unquote(s) {
19
17
  // wizard-prompts.yml 텍스트 → 파싱 객체 (순수 함수 — 테스트 직접 사용 가능).
20
18
  // 반환: { fields: Map<조회키, {label?,help?,example?}>, workflowNames: [{key,value}] }
21
19
  // - 조회키: "PROJECT_NAME" 또는 "flutter.APP_ARTIFACT_NAME" (dotted 타입 오버라이드)
22
- // - 구형 1줄(KEY: "라벨")은 fields의 label로 흡수 (.sh _wf_read_field 형식1 등가 — label만 의미)
20
+ // - 구형 1줄(KEY: "라벨")은 fields의 label로 흡수 (label만 의미)
23
21
  export function parseWizardPrompts(text) {
24
22
  const fields = new Map();
25
23
  const workflowNames = [];
@@ -28,10 +26,10 @@ export function parseWizardPrompts(text) {
28
26
 
29
27
  for (const raw of String(text).split(/\r?\n/)) {
30
28
  const line = raw.replace(/\r$/, "");
31
- if (!line.trim() || line.trim().startsWith("#")) continue; // 빈 줄·주석은 블록을 끊지 않음(.sh awk도 동일하게 무해)
29
+ if (!line.trim() || line.trim().startsWith("#")) continue; // 빈 줄·주석은 블록을 끊지 않음
32
30
 
33
31
  if (!/^\s/.test(line)) {
34
- // 최상위 키 라인 — 이전 블록 종료 (.sh awk `/^[^[:space:]]/ { inblk=0 }` 등가)
32
+ // 최상위 키 라인 — 이전 블록 종료
35
33
  current = null; inWfNames = false;
36
34
  const m = line.match(/^([A-Za-z_][A-Za-z0-9_.\-]*):(.*)$/);
37
35
  if (!m) continue;
@@ -40,7 +38,7 @@ export function parseWizardPrompts(text) {
40
38
  if (key === "_workflow_names") { inWfNames = true; continue; }
41
39
  const entry = fields.get(key) || {};
42
40
  if (rest) {
43
- // 구형 1줄: KEY: "라벨" (.sh 형식1 — label로만 사용)
41
+ // 구형 1줄: KEY: "라벨" (label로만 사용)
44
42
  const q = rest.match(/^"([^"]*)"\s*$/);
45
43
  if (q) entry.label = q[1];
46
44
  }
@@ -53,35 +51,46 @@ export function parseWizardPrompts(text) {
53
51
  const m = line.match(/^\s+([A-Za-z_][A-Za-z0-9_.\-]*):\s*(.*)$/);
54
52
  if (!m) continue;
55
53
  if (inWfNames) {
56
- // " KEY: "값"" 형식만 인정 (.sh _wf_load_workflow_names의 `*:\ \"*\"` case 등가)
54
+ // " KEY: "값"" 형식만 인정
57
55
  const q = m[2].match(/^"(.*)"\s*$/);
58
56
  if (q) workflowNames.push({ key: m[1], value: q[1] });
59
57
  continue;
60
58
  }
61
59
  if (current && (m[1] === "label" || m[1] === "help" || m[1] === "example")) {
62
- // 블록 내 첫 등장만 채택 (.sh awk `print line; exit` — 첫 매치 사용)
60
+ // 블록 내 첫 등장만 채택
63
61
  if (current[m[1]] == null) current[m[1]] = unquote(m[2]);
64
62
  }
65
63
  }
66
64
  return { fields, workflowNames };
67
65
  }
68
66
 
69
- // wizard-prompts.yml을 찾아 읽고 파싱 (.sh _wf_labels_path 등가).
70
- // 우선순위: 대상 프로젝트(targetRoot — 사용자 커스텀) → 패키지 payload/config 번들본 → null.
71
- // WHY 폴백: 사용자 레포에는 이 파일을 설치하지 않는다(payload 단일 진실) —
72
- // 번들본 폴백이 없으면 label/help/example이 전부 빈값(KEY명만 출력)이 된다.
67
+ // wizard-prompts.yml을 찾아 읽고 파싱.
68
+ // 번들본(payload/config)을 바탕으로 대상 프로젝트의 사용자 파일(커스텀 오버라이드)을 키 단위로 덮어쓴다.
69
+ // WHY 병합: 사용자 파일에는 바꾸고 싶은 키만 적는 것이 자연스럽다 — 파일째 대체하면 적지 않은 나머지
70
+ // 질문의 label/help/example과 워크플로우 표시명이 전부 사라져 KEY명만 보인다.
73
71
  // fs 주입 가능(테스트용) — 기본 node:fs.
74
72
  export function loadWizardPrompts(targetRoot = ".", payloadRoot = "", fs = nodeFs) {
75
- // 우선순위: 대상 프로젝트(사용자 커스텀 오버라이드) → 패키지 payload/config 번들본 → null.
76
- const candidates = [join(targetRoot, LABELS_FILE)];
77
- if (payloadRoot) candidates.push(join(payloadRoot, PAYLOAD.configDir, "wizard-prompts.yml"));
78
- for (const p of candidates) {
79
- if (fs.existsSync(p)) return parseWizardPrompts(fs.readFileSync(p, "utf8"));
73
+ const read = (p) => (fs.existsSync(p) ? parseWizardPrompts(fs.readFileSync(p, "utf8")) : null);
74
+ const bundled = payloadRoot ? read(join(payloadRoot, PAYLOAD.configDir, "wizard-prompts.yml")) : null;
75
+ const user = read(join(targetRoot, LABELS_FILE));
76
+ if (!bundled || !user) return user || bundled;
77
+ return mergeWizardPrompts(bundled, user);
78
+ }
79
+
80
+ // 번들 + 사용자 파싱 결과 병합. 같은 키는 사용자가 적은 필드만 덮어쓴다(label만 바꾸면 help/example은 번들 유지).
81
+ export function mergeWizardPrompts(base, override) {
82
+ const fields = new Map([...base.fields].map(([k, v]) => [k, { ...v }]));
83
+ for (const [key, entry] of override.fields) {
84
+ const merged = { ...(fields.get(key) || {}) };
85
+ for (const [f, v] of Object.entries(entry)) if (v != null && v !== "") merged[f] = v;
86
+ fields.set(key, merged);
80
87
  }
81
- return null;
88
+ const overridden = new Set(override.workflowNames.map((w) => w.key));
89
+ const workflowNames = [...base.workflowNames.filter((w) => !overridden.has(w.key)), ...override.workflowNames];
90
+ return { fields, workflowNames };
82
91
  }
83
92
 
84
- // 필드 조회 (.sh wf_field 등가). field: "label" | "help" | "example".
93
+ // 필드 조회. field: "label" | "help" | "example".
85
94
  // 우선순위: "{type}.KEY" 블록 → "KEY" 블록(구형 1줄 포함) → 폴백(label이면 KEY명, 아니면 "").
86
95
  export function wfField(prompts, type, key, field) {
87
96
  if (prompts && prompts.fields) {
@@ -93,11 +102,11 @@ export function wfField(prompts, type, key, field) {
93
102
  return field === "label" ? key : "";
94
103
  }
95
104
 
96
- // 워크플로우 파일명 → 사람이 읽는 짧은 이름 (.sh wf_workflow_name 등가).
105
+ // 워크플로우 파일명 → 사람이 읽는 짧은 이름.
97
106
  // _workflow_names에서 "키가 파일명에 포함되면" 그 값 사용 — 최장 키 우선(REACT-CI vs REACT-CICD 구분).
98
- // 미매칭이면 .yaml/.yml 확장자만 제거해 반환 (.sh `${_base%.y*ml}` 등가).
107
+ // 미매칭이면 .yaml/.yml 확장자만 제거해 반환.
99
108
  export function workflowDisplayName(prompts, filename) {
100
- const base = String(filename).split("/").pop().split("\\").pop(); // 경로 제거 (.sh `${_file##*/}`)
109
+ const base = String(filename).split("/").pop().split("\\").pop(); // 경로 제거
101
110
  let best = null; let bestLen = 0;
102
111
  for (const { key, value } of prompts?.workflowNames ?? []) {
103
112
  if (base.includes(key) && key.length > bestLen) { best = value; bestLen = key.length; }
package/src/index.js CHANGED
@@ -1,28 +1,35 @@
1
- // project-auto-wizard CLI 진입 파이프라인 (.sh main + execute_integration 등가).
1
+ // project-auto-wizard CLI 진입 파이프라인.
2
2
  // 감지 → payload 해석 → 모드 라우팅 → 통합 실행. 비대화형(--force) 우선.
3
- // 네트워크 접근 0 — 설치 자산은 전부 npm 패키지 동봉 payload/ (단일 진실).
3
+ // 설치 자산은 전부 npm 패키지 동봉 payload/ (단일 진실). 자체 네트워크 요청은 없고,
4
+ // 기본 브랜치 감지용 git 명령만 사용자 레포의 origin에 접속할 수 있다.
4
5
  import { join, dirname } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { readFileSync, existsSync } from "node:fs";
7
8
  import { createInterface } from "node:readline/promises";
8
9
  import { parseArgs, parsePathsCsv, CliError } from "./cli/args.js";
9
10
  import { HELP_TEXT } from "./cli/help.js";
10
- import { createContext } from "./context.js";
11
- import { DEFAULT_DEPLOY_STYLE, isDeployStyle } from "./core/deploy-style.js";
11
+ import { fallbackStyleTypes } from "./core/deploy-style.js";
12
12
  import { resolveFlutterOptions } from "./core/flutter-options.js";
13
+ import { inferInstalledStores } from "./core/installed-stores.js";
14
+ import { PATHS } from "./core/paths.js";
13
15
  import { resolvePayloadRoot, assertPayload, readTemplateVersion } from "./core/assets.js";
14
- import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName, makeResolvers, detectBuildNumber, detectMarkers } from "./core/detect-fs.js";
16
+ import { detectTypes, detectDefaultBranch, detectRepoName, makeResolvers, detectMarkers } from "./core/detect-fs.js";
15
17
  import { parseExisting } from "./core/version-yml.js";
18
+ import { resolveReleaseOptions } from "./core/release-options.js";
16
19
  import { runBreakingCheck } from "./core/breaking-check.js";
17
20
  import { resolveProjectPaths } from "./core/paths-resolve.js";
18
- import { resolveBranchConfig, detectRemoteBranches, ensureDevelopBranch, defaultExec } from "./core/branches.js";
21
+ import {
22
+ resolveBranchConfig, detectRemoteBranches, ensureDevelopBranch, defaultExec, isValidBranchName, developMissingNotice,
23
+ } from "./core/branches.js";
19
24
  import { printBannerCompact } from "./ui/banner.js";
20
25
  import { printSummary } from "./ui/summary.js";
21
- import { runFull } from "./commands/full.js";
26
+ import { runFull, postInstallNotices } from "./commands/full.js";
22
27
  import { runUninstall, runUninstallFlow } from "./commands/uninstall.js";
23
28
  import * as prompts from "./ui/prompts.js";
29
+ import { isPromptAbort } from "./ui/readline-engine.js";
24
30
  import { runInteractive } from "./commands/interactive.js";
25
- import { initLogger, closeLogger, currentLogPath, hasLegacyMdLogs } from "./core/logger.js";
31
+ import { resolveDeployStyle, resolveVersion, resolveVersionCode, buildInstallContext } from "./commands/install-settings.js";
32
+ import { initLogger, closeLogger, currentLogPath, hasLegacyMdLogs, log } from "./core/logger.js";
26
33
  import { runStatus, printStatus } from "./commands/status.js";
27
34
  import { runDoctor, printDoctorReport } from "./commands/doctor.js";
28
35
  import { planDryRun, printDryRun } from "./commands/dry-run.js";
@@ -44,7 +51,8 @@ function utcNow(date = new Date()) {
44
51
  const p = (n) => String(n).padStart(2, "0");
45
52
  const d = `${date.getUTCFullYear()}-${p(date.getUTCMonth() + 1)}-${p(date.getUTCDate())}`;
46
53
  const t = `${p(date.getUTCHours())}:${p(date.getUTCMinutes())}:${p(date.getUTCSeconds())}`;
47
- return { now: `${d} ${t}`, today: d };
54
+ // ms: 로그 파일명이 같은 초의 연속 실행끼리 겹치지 않도록 쓴다 (now와 같은 시각에서 뽑는다).
55
+ return { now: `${d} ${t}`, today: d, ms: date.getUTCMilliseconds() };
48
56
  }
49
57
 
50
58
  // purge TTY 확인 — 실제 stdin에서 한 줄 입력을 받는다 (테스트는 promptRepoName 주입으로 대체).
@@ -78,17 +86,11 @@ async function runInner(argv, {
78
86
  const payload = assertPayload(payloadRoot ?? resolvePayloadRoot());
79
87
 
80
88
  // 시각은 여기서 한 번만 계산한다 — 로그 파일명과 설치 기록이 같은 값을 쓰도록.
81
- const { now, today } = clock || utcNow();
89
+ const { now, today, ms } = clock || utcNow();
82
90
 
83
- // dry-run은 "파일을 바꾸지 않는다"가 계약이므로 로그도 남기지 않는다.
84
- // --version/--help는 이 지점 이전에 이미 반환되므로 자연히 제외된다.
85
- const loggedAction = opts.dryRun ? null
86
- : opts.mode === "uninstall" ? "uninstall"
87
- : opts.mode === "purge" ? "purge"
88
- : "install";
89
- if (loggedAction) {
90
- initLogger(cwd, { action: loggedAction, now, argv, templateVersion: readTemplateVersion() });
91
- }
91
+ // 로그는 인자 검증과 모드별 게이트를 모두 통과한 "실제로 파일을 바꾸는" 실행만 남긴다.
92
+ // status/doctor(읽기 전용)·dry-run·거부된 실행은 대상 레포에 아무 파일도 만들지 않아야 한다.
93
+ const startLog = (action) => initLogger(cwd, { action, now, ms, argv, templateVersion: readTemplateVersion() });
92
94
 
93
95
  // 대화형 모드 — 인자 없이 실행 or --mode interactive
94
96
  if (opts.mode === "interactive") {
@@ -101,7 +103,23 @@ async function runInner(argv, {
101
103
  console.error("대화형 입력이 불가능한 환경입니다. --mode <full|uninstall> 와 --force 를 지정하세요.");
102
104
  return 1;
103
105
  }
104
- return await runInteractive({}, { cwd, payloadRoot: payload, clock });
106
+ // 켜고 끄기 옵션은 대화형에서도 반영한다(해당 질문 생략). 나머지 설치 플래그는 대화형 질문이 정하므로
107
+ // 조용히 무시하지 않고 알린다.
108
+ const ignored = [
109
+ [opts.types.length, "--type"], [opts.version, "--project-version"], [opts.pathsCsv, "--paths"],
110
+ [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"],
113
+ ].filter(([v]) => v).map(([, flag]) => flag);
114
+ if (ignored.length) {
115
+ console.error(`⚠️ 대화형 모드에서는 ${ignored.join(", ")}를 사용하지 않습니다 — 질문에서 고르거나 --mode full --force와 함께 쓰세요.`);
116
+ }
117
+ // 로그 파일은 첫 기록 때 생기므로 메뉴에서 status/doctor만 보고 나가면 아무것도 남지 않는다.
118
+ startLog("install");
119
+ return await runInteractive(
120
+ { includeSemverAuto: opts.includeSemverAuto, includeCopilotAi: opts.includeCopilotAi },
121
+ { cwd, payloadRoot: payload, clock },
122
+ );
105
123
  }
106
124
 
107
125
  // purge 모드 — 마법사가 만든 모든 산출물을 지워 설치 이전 상태로 완전히 되돌린다.
@@ -122,7 +140,7 @@ async function runInner(argv, {
122
140
  const existing = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")) : null;
123
141
  if (opts.dryRun) {
124
142
  printPurgePlan(planPurge(payload, cwd, keepFlags), { dryRun: true });
125
- // develop 브랜치 삭제는 plan에 포함되지 않으므로(§6 — git 상태는 실행 시점에만
143
+ // develop 브랜치 삭제는 plan에 포함되지 않으므로(git 상태는 실행 시점에만
126
144
  // 판단 가능) 별도로 예고하지 않으면 dry-run 미리보기가 유일한 파괴적 동작을 사용자에게 숨기게 된다.
127
145
  if (opts.deleteDevelopBranch) {
128
146
  const developBranch = existing?.branches?.develop || "develop";
@@ -159,6 +177,7 @@ async function runInner(argv, {
159
177
  return 1;
160
178
  }
161
179
  }
180
+ startLog("purge");
162
181
  const plan = planPurge(payload, cwd, keepFlags);
163
182
  printPurgePlan(plan, { dryRun: false });
164
183
  const result = executePurge(payload, cwd, keepFlags);
@@ -190,9 +209,11 @@ async function runInner(argv, {
190
209
  return 0;
191
210
  }
192
211
  if (opts.force) {
212
+ startLog("uninstall");
193
213
  const r = runUninstall({}, payload, cwd, safeSelection);
194
214
  const removed = [
195
215
  `워크플로우 ${r.workflows.length}개`, `스크립트 ${r.scripts.length}개`,
216
+ r.appFiles.length > 0 && `Flutter 앱 파일 ${r.appFiles.length}개`,
196
217
  r.readme && "README 버전 섹션",
197
218
  r.gitignore && ".gitignore 자동 추가 항목", r.versionYml && "version.yml",
198
219
  ].filter(Boolean).join(", ");
@@ -203,7 +224,11 @@ async function runInner(argv, {
203
224
  console.error("비대화형 환경에서는 --force 옵션이 필요합니다.");
204
225
  return 1;
205
226
  }
206
- await runUninstallFlow(payload, cwd, prompts);
227
+ startLog("uninstall");
228
+ // --purge-* 플래그는 체크리스트 초기 선택으로 반영한다(조용히 무시하지 않는다).
229
+ await runUninstallFlow(payload, cwd, prompts, {
230
+ readme: opts.purgeReadme, gitignore: opts.purgeGitignore, versionYml: opts.purgeVersion,
231
+ });
207
232
  return 0;
208
233
  }
209
234
 
@@ -217,7 +242,7 @@ async function runInner(argv, {
217
242
  printDoctorReport(runDoctor(cwd));
218
243
  return 0;
219
244
  }
220
- // 명시 모드(full/version/workflows)인데 --force 없으면 TTY 여부와 무관하게 즉시 거부한다
245
+ // 명시 모드(full)인데 --force 없으면 TTY 여부와 무관하게 즉시 거부한다
221
246
  // (TTY에서 확인 없이 즉시 설치되던 결함 수정).
222
247
  // --dry-run은 파일을 쓰지 않으므로 --force 게이트를 우회한다 (status/doctor와 동일한 안전성).
223
248
  if (!opts.force && !opts.dryRun) {
@@ -225,25 +250,32 @@ async function runInner(argv, {
225
250
  return 1;
226
251
  }
227
252
 
228
- // 기존 version.yml 로드 — version/version_code/project_paths 보존의 단일 진실 (.sh SSoT)
253
+ // 기존 version.yml 로드 — version/version_code/project_paths 보존의 단일 진실
229
254
  const vyPath = join(cwd, "version.yml");
230
255
  const existing = existsSync(vyPath) ? parseExisting(readFileSync(vyPath, "utf8")) : null;
231
256
 
232
257
  // 감지 (CLI 인자 우선, 없으면 자동 감지 — version.yml 우선 규칙은 detectTypes/detectVersion 내부)
233
- const types = opts.types.length ? opts.types : detectTypes(cwd);
234
- // version: 기존 version.yml 최우선(SSoT — 재실행 시 덮어쓰기 방지) → CLI 지정 → 파일 감지
235
- // 비대화형이므로 폴백 안내는 CLI 문구(--project-version)를 그대로 쓴다.
258
+ // --paths만 주고 --type을 생략한 모노레포도 그 타입으로 설치되도록 감지에 넘긴다.
259
+ let cliPaths;
260
+ try {
261
+ cliPaths = parsePathsCsv(opts.pathsCsv);
262
+ } catch (e) {
263
+ if (e instanceof CliError) { console.error(e.message); return 1; }
264
+ throw e;
265
+ }
266
+ const types = opts.types.length ? opts.types
267
+ : detectTypes(cwd, { paths: cliPaths, warn: (m) => console.error(m) });
236
268
  const detectWarnings = [];
237
- const version = (existing?.version) || opts.version
238
- || detectVersion(cwd, { warn: (m) => { detectWarnings.push(m); console.error(m); } });
239
- const versionCode = existing?.versionCode ?? detectBuildNumber(cwd, { types }) ?? 1; // 기존 빌드번호 보존, 신규 통합 시 프로젝트 파일에서 감지 (.sh L2208~2221)
240
- const branch = detectDefaultBranch(cwd);
269
+ const branch = detectDefaultBranch(cwd, {
270
+ warn: (m) => { detectWarnings.push(m); console.error(m); },
271
+ hint: "다르면 --main-branch로 지정하세요.",
272
+ });
241
273
  const repoName = detectRepoName(cwd);
242
- // 경로 확정 (.sh resolve_project_paths 비대화형 경로 — --paths 우선 → 저장값 → 후보 1개 자동 → 에러)
274
+ // 경로 확정 (비대화형 — --paths 우선 → 저장값 → 후보 1개 자동 → 에러)
243
275
  let paths;
244
276
  try {
245
277
  paths = await resolveProjectPaths({
246
- root: cwd, types, paths: parsePathsCsv(opts.pathsCsv),
278
+ root: cwd, types, paths: cliPaths,
247
279
  existingPaths: existing?.paths ?? new Map(), force: true, tty: false, io: {},
248
280
  });
249
281
  } catch (e) {
@@ -251,66 +283,85 @@ async function runInner(argv, {
251
283
  throw e;
252
284
  }
253
285
 
286
+ // version: 기존 version.yml 최우선(SSoT — 재실행 시 덮어쓰기 방지) → CLI 지정 → 파일 감지
287
+ // 비대화형이므로 폴백 안내는 CLI 문구(--project-version)를 그대로 쓴다.
288
+ // 경로 확정 뒤에 감지해야 모노레포 하위 폴더의 버전·빌드 번호를 읽는다.
289
+ const version = resolveVersion({
290
+ cwd, existing, explicit: opts.version,
291
+ types, paths, warn: (m) => { detectWarnings.push(m); console.error(m); },
292
+ });
293
+ const versionCode = resolveVersionCode({ cwd, existing, types, paths });
294
+
254
295
  // 브랜치 구성 (--main-branch/--develop-branch → version.yml 저장값 → 감지 default → main/develop)
296
+ // 이전 버전이 저장한 감지 실패 값("(unknown)" 등)은 저장값으로 인정하지 않는다 — 그대로 두면 재실행해도 복구되지 않는다.
297
+ const savedBranch = (b) => (isValidBranchName(b) ? b : "");
255
298
  const branches = resolveBranchConfig({
256
- mainBranch: opts.mainBranch || existing?.branches?.main || "",
257
- developBranch: opts.developBranch || existing?.branches?.develop || "",
299
+ mainBranch: opts.mainBranch || savedBranch(existing?.branches?.main),
300
+ developBranch: opts.developBranch || savedBranch(existing?.branches?.develop),
258
301
  defaultBranch: branch,
259
302
  });
260
303
  // pr-flow에서 develop이 원격에 없으면 자동 생성+push (--force 비대화형 — 질문 없음).
261
- // 원격 목록을 못 읽는 환경(git 없음·origin 없음)은 remoteBranches=[]지만 push 실패를 조용히 보고.
304
+ // 원격에 브랜치가 하나도 없으면(빈 원격·origin 없음) push할 기준이 없으므로 만들지 않고 안내한다.
305
+ let developMissing = false;
262
306
  if (branches.mode === "pr-flow" && !opts.dryRun) {
263
307
  const remoteBranches = await detectRemoteBranches(cwd);
264
- if (remoteBranches.length && !remoteBranches.includes(branches.develop)) {
265
- await ensureDevelopBranch({
308
+ if (!remoteBranches.length) {
309
+ developMissing = true;
310
+ console.error(`⚠️ ${developMissingNotice(branches)}`);
311
+ } else if (!remoteBranches.includes(branches.develop)) {
312
+ const r = await ensureDevelopBranch({
266
313
  develop: branches.develop, remoteBranches, confirm: null, cwd,
267
314
  log: (m) => console.error(m),
268
315
  });
316
+ developMissing = r.pushed === false;
269
317
  }
270
318
  }
271
319
 
272
320
  // Flutter 옵션 — CLI 플래그 → version.yml 저장값 → 기본값(신규 dart-define, 기존 설치 dotenv 보존).
273
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;
274
328
  const flutterOptions = resolveFlutterOptions({
275
329
  cli: {
276
- envMode: opts.flutterEnvMode, stores: opts.flutterStore,
330
+ envMode: opts.flutterEnvMode, stores: opts.flutterStore ?? inferredStores,
277
331
  androidDeployMode: opts.androidDeployMode, iosDeployMode: opts.iosDeployMode,
278
332
  },
279
333
  existing,
280
334
  });
281
335
 
282
- const context = createContext({
283
- mode: opts.mode, force: opts.force, types, version, versionCode, branch,
336
+ // 고른 배포 방식 — 플래그 → 저장값 → 기본값. 실제 설치되는 방식으로의 정리는 컨텍스트 조립에서 한다.
337
+ const chosenDeployStyle = resolveDeployStyle({ payload, types, explicit: opts.deployStyle, existing });
338
+
339
+ const context = buildInstallContext({
340
+ payload, existing, templateVersion: readTemplateVersion(), types, deployStyle: chosenDeployStyle,
341
+ flutterOptions,
342
+ // 옵션: CLI 플래그 최우선 → version.yml 저장 옵션 → 기본값 (대화형과 같은 규칙)
343
+ releaseOptions: resolveReleaseOptions({ semverAuto: opts.includeSemverAuto, copilotAi: opts.includeCopilotAi }, existing),
344
+ mode: opts.mode, force: opts.force, version, versionCode, branch,
284
345
  branches,
285
346
  paths,
286
- // 옵션: CLI 플래그 최우선 → version.yml 저장 옵션 → 기본값
287
- // 기존 version.yml이 있는데 semver_auto 키가 아예 없었던 경우(신규 기능 추가 이전 설치·
288
- // workflows-only 재실행) 조용히 true로 켜지면 애매한 커밋 하나로 major가 승격될 위험이 있다 —
289
- // 기존 설치는 false로 안전하게 폴백, 완전 신규 설치만 true(기존 설계) 유지.
290
- includeSemverAuto: opts.includeSemverAuto ?? existing?.options?.semverAuto ?? (existing ? false : true),
291
- // Copilot AI 요약은 AI Credits를 소비하는 opt-in — 신규·기존 설치 모두 명시하지 않으면 false다.
292
- includeCopilotAi: opts.includeCopilotAi ?? existing?.options?.copilotAi ?? false,
293
347
  repoName,
294
- // 실 resolver 4종 (.sh resolve_token 등가)
348
+ // @wizard ask/auto 토큰 값을 계산하는 resolver
295
349
  resolvers: makeResolvers(cwd, repoName, paths, flutterOptions),
296
350
  now, today,
297
351
  // 설치 로그용 부가 문맥 — 설치 동작 자체는 바꾸지 않는다.
298
352
  markers: detectMarkers(cwd, types), detectWarnings,
299
- deployStyle: opts.deployStyle
300
- || (isDeployStyle(existing?.options?.deployStyle) ? existing.options.deployStyle : DEFAULT_DEPLOY_STYLE),
301
- previousTemplateVersion: existing?.templateVersion || "",
302
- envMode: flutterOptions.envMode,
303
- flutterStore: flutterOptions.stores,
304
- androidDeployMode: flutterOptions.androidDeployMode,
305
- iosDeployMode: flutterOptions.iosDeployMode,
306
353
  });
307
354
 
308
- context.templateVersion = readTemplateVersion();
355
+ // 무중단(nginx·traefik) 워크플로우가 없는 타입은 단일 서버 배포로 설치한다 — 조용히 넘어가지 않게 알린다.
356
+ const fallbackTypes = fallbackStyleTypes(payload, types, chosenDeployStyle);
357
+ if (fallbackTypes.length) {
358
+ console.error(`⚠️ ${fallbackTypes.join(", ")}에는 ${chosenDeployStyle} 무중단 배포 워크플로우가 없어 단일 서버 배포(simple)로 설치합니다.`);
359
+ }
309
360
 
310
361
  // 비대화형 축약 배너 (1줄, 로그 오염 최소)
311
362
  printBannerCompact({ version: context.templateVersion, mode: opts.mode });
312
363
 
313
- // Breaking Changes 게이트 (.sh execute_integration L4415~4420 등가 — 비대화형은 경고 후 진행)
364
+ // Breaking Changes 게이트 (비대화형은 경고 후 진행)
314
365
  const proceed = await runBreakingCheck({ cwd, payloadRoot: payload, templateVersion: context.templateVersion });
315
366
  if (!proceed) return 0;
316
367
 
@@ -319,24 +370,30 @@ async function runInner(argv, {
319
370
  return 0;
320
371
  }
321
372
 
373
+ startLog("install");
322
374
  // opts.mode는 parseArgs()에서 화이트리스트 검증을 통과했고, interactive/purge/uninstall/status/doctor는
323
375
  // 전부 위에서 조기 반환했으므로 이 시점엔 full 하나로 보장된다 (default 분기 제거,
324
376
  // 부분 설치 모드 제거로 분기 자체가 사라졌다).
325
377
  const result = runFull(context, payload, cwd);
326
378
 
327
- // 완료 요약 (.sh print_summary — CLI 모드에서도 출력)
379
+ // 완료 요약 (CLI 모드에서도 출력)
328
380
  printSummary({
329
- mode: opts.mode, types, version, versionCode, branches,
381
+ mode: opts.mode, types, version, versionCode, branches, developMissing,
330
382
  copiedFiles: result?.workflows?.copiedFiles ?? [],
383
+ autoUpdated: result?.workflows?.autoUpdated ?? [],
331
384
  gitignoreUpdated: result?.gitignoreUpdated === true,
332
385
  unresolved: result?.unresolved ?? [],
333
386
  secrets: result?.secrets ?? new Map(),
387
+ optionalSecrets: result?.optionalSecrets ?? new Map(),
334
388
  logPath: currentLogPath(),
335
389
  legacyMdLogs: hasLegacyMdLogs(cwd),
336
390
  cleanup: result?.cleanup ?? null,
337
391
  storeCleanup: result?.storeCleanup ?? null,
338
392
  flutterApp: result?.flutterApp ?? null,
393
+ readme: result?.readme ?? null,
394
+ scripts: result?.scripts ?? null,
339
395
  });
396
+ for (const n of postInstallNotices(result)) console.error(n.startsWith(" ") ? n : `⚠️ ${n}`);
340
397
  // store_submit 배포 모드는 main push마다 심사를 자동 제출한다 — 비대화형에서도 같은 경고를 보여준다
341
398
  // (대화형 경로는 ui/prompts.js#deployModeWarning을 선택 시점에 note로 보여준다).
342
399
  // Flutter 타입이 아니거나 해당 스토어를 선택하지 않은 프로젝트에는 뜨면 안 된다.
@@ -359,6 +416,17 @@ async function runInner(argv, {
359
416
  export async function run(argv, opts = {}) {
360
417
  try {
361
418
  return await runInner(argv, opts);
419
+ } catch (e) {
420
+ // Ctrl+C/EOF는 어느 질문에서든 즉시 중단한다 — 설치 파일을 쓰기 전에 빠져나오고, 셸 관례대로 130을 돌려준다.
421
+ if (isPromptAbort(e)) {
422
+ prompts.cancelMessage("중단했습니다 — 변경 없이 종료합니다.");
423
+ return e.signal === "SIGTERM" ? 143 : 130;
424
+ }
425
+ // 이미 기록을 시작한 실행이 도중에 죽으면 헤더만 남은 로그로는 원인을 알 수 없다 — 사유를 남긴다.
426
+ if (currentLogPath()) log.fail("run", "error", e?.message || String(e));
427
+ // 사용자가 고칠 수 있는 실패(권한 등)는 스택트레이스 대신 읽을 수 있는 문구로 끝낸다.
428
+ if (e instanceof CliError) { console.error(e.message); return 1; }
429
+ throw e;
362
430
  } finally {
363
431
  closeLogger();
364
432
  }
package/src/ui/ansi.js CHANGED
@@ -15,8 +15,10 @@ export const A = {
15
15
  // NO_COLOR(https://no-color.org) 환경변수 또는 대상 스트림이 TTY가 아니면 색상을 끈다.
16
16
  // no-color.org 규격상 NO_COLOR는 "값과 무관하게 존재 여부"만 본다 — NO_COLOR=""(빈 문자열)도
17
17
  // "설정됨"으로 취급해야 하므로 truthy 체크(`!process.env.NO_COLOR`)가 아니라 존재 체크를 쓴다.
18
+ // TERM=dumb(Emacs shell·일부 CI 의사 TTY 등)은 ANSI 시퀀스를 해석하지 못해 그대로 찍힌다 — 색상도 끈다.
19
+ export const isDumbTerminal = () => process.env.TERM === "dumb";
18
20
  export function colorEnabled(stream = process.stdout) {
19
- return process.env.NO_COLOR === undefined && !!stream.isTTY;
21
+ return process.env.NO_COLOR === undefined && !!stream.isTTY && !isDumbTerminal();
20
22
  }
21
23
 
22
24
  export const paint = (s, color, enabled = colorEnabled()) => (enabled ? `${color}${s}${A.reset}` : String(s));
package/src/ui/banner.js CHANGED
@@ -1,5 +1,4 @@
1
1
  // 첫 화면 배너 — 클래식 박스형
2
- // .ps1 Print-Banner 계승 + 브랜딩을 project-auto-wizard로 갱신.
3
2
  import { A, paint, visualWidth } from "./ansi.js";
4
3
 
5
4
  const INNER = 56; // 박스 내부 폭
@@ -9,14 +8,19 @@ function boxLine(out, content = "") {
9
8
  out(paint("║", A.cyan) + content + " ".repeat(pad) + paint("║", A.cyan) + "\n");
10
9
  }
11
10
 
12
- // 대화형 첫 화면 배너 — 박스 타이틀 + 메타 4줄 (.ps1 Print-Banner 등가)
13
- export function printBanner({ version, modeLabel }, out = (s) => process.stderr.write(s)) {
11
+ // 대화형 첫 화면 배너 — 박스 타이틀 + 메타 4줄
12
+ export function printBanner({ version, modeLabel }, out = (s) => process.stderr.write(s), columns = process.stderr.columns) {
14
13
  out("\n");
15
- out(paint(`╔${"═".repeat(INNER)}╗`, A.cyan) + "\n");
16
- boxLine(out);
17
- boxLine(out, ` ${paint("✦", A.yellow)} ${paint("P R O J E C T · A U T O · W I Z A R D", A.bold)} ${paint("✦", A.yellow)}`);
18
- boxLine(out);
19
- out(paint(`╚${"═".repeat(INNER)}╝`, A.cyan) + "\n");
14
+ // 상자보다 좁은 터미널에서는 테두리가 접혀 모양이 무너지므로 상자 없이 제목만 찍는다.
15
+ if (columns && columns < INNER + 2) {
16
+ out(`${paint("✦", A.yellow)} ${paint("PROJECT AUTO WIZARD", A.bold)}\n`);
17
+ } else {
18
+ out(paint(`╔${"═".repeat(INNER)}╗`, A.cyan) + "\n");
19
+ boxLine(out);
20
+ boxLine(out, ` ${paint("✦", A.yellow)} ${paint("P R O J E C T · A U T O · W I Z A R D", A.bold)} ${paint("✦", A.yellow)}`);
21
+ boxLine(out);
22
+ out(paint(`╚${"═".repeat(INNER)}╝`, A.cyan) + "\n");
23
+ }
20
24
  out(` 🌙 Version : ${paint(`v${version}`, A.green)}\n`);
21
25
  out(` 🪐 Mode : ${modeLabel}\n`);
22
26
  out(` 📦 Repo : ${paint("github.com/Twin-Fang/project-auto-wizard", A.dim)}\n`);