project-auto-wizard 0.12.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) 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 +7 -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 +397 -97
  12. package/payload/version.yml.template +4 -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 +17 -10
  43. package/src/commands/dry-run.js +54 -1
  44. package/src/commands/full.js +127 -50
  45. package/src/commands/interactive-flutter.js +1 -1
  46. package/src/commands/interactive.js +124 -56
  47. package/src/commands/purge.js +59 -17
  48. package/src/commands/status.js +16 -1
  49. package/src/commands/uninstall.js +58 -13
  50. package/src/context.js +5 -5
  51. package/src/core/baseline.js +20 -5
  52. package/src/core/branches.js +19 -1
  53. package/src/core/branding.js +1 -1
  54. package/src/core/breaking-check.js +14 -19
  55. package/src/core/breaking.js +7 -3
  56. package/src/core/copy/gitignore.js +20 -12
  57. package/src/core/copy/readme.js +24 -5
  58. package/src/core/copy/simple.js +52 -13
  59. package/src/core/copy/workflows.js +90 -47
  60. package/src/core/deploy-style.js +83 -11
  61. package/src/core/detect-fs.js +127 -24
  62. package/src/core/detect.js +131 -47
  63. package/src/core/errors.js +3 -0
  64. package/src/core/flutter-options.js +4 -3
  65. package/src/core/fsutil.js +23 -6
  66. package/src/core/logger.js +55 -19
  67. package/src/core/paths-resolve.js +50 -47
  68. package/src/core/paths.js +26 -1
  69. package/src/core/release-options.js +23 -0
  70. package/src/core/removal-plan.js +75 -19
  71. package/src/core/types.js +113 -0
  72. package/src/core/verify.js +85 -19
  73. package/src/core/version-yml.js +42 -8
  74. package/src/core/wizard-env.js +21 -14
  75. package/src/core/wizard-labels.js +34 -25
  76. package/src/index.js +122 -49
  77. package/src/ui/ansi.js +3 -1
  78. package/src/ui/banner.js +12 -8
  79. package/src/ui/env-plan.js +68 -44
  80. package/src/ui/prompts.js +22 -12
  81. package/src/ui/readline-engine.js +109 -22
  82. package/src/ui/status-cards.js +10 -5
  83. package/src/ui/summary.js +71 -33
  84. package/payload/scripts/__pycache__/changelog_manager.cpython-314.pyc +0 -0
  85. package/payload/scripts/__pycache__/issue_helper.cpython-314.pyc +0 -0
  86. package/payload/scripts/__pycache__/version_manager.cpython-314.pyc +0 -0
@@ -1,15 +1,46 @@
1
1
  // uninstall 모드 — 마법사가 설치한 파일(planRemoval 판별분)에 더해 README/.gitignore/version.yml까지
2
2
  // 선택적으로 제거한다. 대화형 체크리스트 또는 --force + --purge-* 로 항목별 opt-in.
3
3
  // core/removal-plan.js는 읽기 전용으로만 재사용한다(아무것도 지우지 않는 순수 함수).
4
- import { join } from "node:path";
5
- import { existsSync } from "node:fs";
4
+ import { join, posix } from "node:path";
5
+ import { existsSync, readdirSync, rmdirSync } from "node:fs";
6
6
  import { PATHS } from "../core/paths.js";
7
7
  import { remove } from "../core/fsutil.js";
8
- import { planRemoval } from "../core/removal-plan.js";
8
+ import { planRemoval, backupArtifacts } from "../core/removal-plan.js";
9
9
  import { removeVersionSectionFromReadme, hasVersionSection } from "../core/copy/readme.js";
10
10
  import { removeAutoAddedEntriesFromGitignore, hasAutoAddedEntries } from "../core/copy/gitignore.js";
11
11
  import { CANCEL } from "../ui/prompts.js";
12
- import { log } from "../core/logger.js";
12
+ import { logRemovals } from "../core/logger.js";
13
+
14
+ // 파일을 지운 뒤 비게 된 상위 폴더를 레포 루트 직전까지 정리한다(마법사가 만든 fastlane/ 등).
15
+ // 비어 있지 않은 폴더를 만나면 멈추므로 사용자 파일이 남은 폴더는 건드리지 않는다.
16
+ export function pruneEmptyDirs(targetRoot, relDir) {
17
+ let rel = relDir;
18
+ while (rel && rel !== "." && rel !== "/") {
19
+ const abs = join(targetRoot, rel);
20
+ try {
21
+ if (readdirSync(abs).length) break;
22
+ rmdirSync(abs);
23
+ } catch { break; }
24
+ rel = posix.dirname(rel);
25
+ }
26
+ }
27
+
28
+ // Flutter 앱 파일 제거 — 미수정분만 plan에 들어오므로 그대로 지우고 빈 폴더를 정리한다.
29
+ // 로그는 삭제가 끝난 뒤 한꺼번에 남기므로 기록할 항목을 done에 모은다.
30
+ export function removeAppFiles(targetRoot, appFiles, done = []) {
31
+ for (const rel of appFiles) {
32
+ remove(join(targetRoot, rel));
33
+ pruneEmptyDirs(targetRoot, posix.dirname(rel));
34
+ done.push(["remove", "flutter-app", rel]);
35
+ }
36
+ }
37
+
38
+ // 설치물을 지운 뒤 비게 된 .github/workflows·.github/scripts(와 .github)를 정리한다.
39
+ // 이번에 그 폴더에서 무언가를 지웠을 때만 — 원래 비어 있던 폴더까지 손대지 않는다.
40
+ export function pruneInstallDirs(targetRoot, plan) {
41
+ if (plan.workflows.length || plan.baseline?.length) pruneEmptyDirs(targetRoot, PATHS.workflowsDir);
42
+ if (plan.scripts.length) pruneEmptyDirs(targetRoot, PATHS.scriptsDir);
43
+ }
13
44
 
14
45
  // selection: { workflows, scripts, readme, gitignore, versionYml } (모두 boolean).
15
46
  // 반환: 위와 동일한 키의 boolean/배열 — 실제로 제거 "대상"인지 여부(순수 함수, 아무것도 지우지 않음).
@@ -17,11 +48,16 @@ export function planUninstall(payloadRoot, targetRoot, selection) {
17
48
  const removalPlan = planRemoval(payloadRoot, targetRoot);
18
49
  return {
19
50
  workflows: selection.workflows ? removalPlan.workflows : [],
20
- // baseline은 워크플로우 해시 기록 — 워크플로우를 지우면 함께 사라져야 한다
51
+ // baseline은 워크플로우 해시 기록 — 워크플로우를 지우면 함께 사라져야 한다.
52
+ // Flutter 앱 파일의 생성 기록도 baseline에만 있으므로 같은 항목으로 묶는다.
53
+ appFiles: selection.workflows ? removalPlan.appFiles : [],
21
54
  baseline: selection.workflows ? removalPlan.baseline : [],
22
55
  scripts: selection.scripts ? removalPlan.scripts : [],
23
56
  readme: selection.readme ? hasVersionSection(targetRoot) : false,
24
- gitignore: selection.gitignore ? hasAutoAddedEntries(targetRoot) : false,
57
+ // 워크플로우를 남기면 그 백업 파일(.bak 등)도 남는다 — .gitignore 항목을 지우면 git 상태에 드러나므로 유지한다.
58
+ gitignore: selection.gitignore
59
+ ? hasAutoAddedEntries(targetRoot) && (selection.workflows || backupArtifacts(removalPlan.workflows).length === 0)
60
+ : false,
25
61
  versionYml: selection.versionYml ? existsSync(join(targetRoot, PATHS.versionFile)) : false,
26
62
  };
27
63
  }
@@ -30,16 +66,22 @@ export function planUninstall(payloadRoot, targetRoot, selection) {
30
66
  export function runUninstall(context, payloadRoot, targetRoot, selection) {
31
67
  const plan = planUninstall(payloadRoot, targetRoot, selection);
32
68
  const wfDir = join(targetRoot, PATHS.workflowsDir);
33
- for (const name of plan.workflows) { remove(join(wfDir, name)); log.info("remove", "workflow", name); }
34
- for (const name of plan.scripts) { remove(join(targetRoot, PATHS.scriptsDir, name)); log.info("remove", "script", name); }
35
- for (const p of plan.baseline || []) { remove(join(targetRoot, p)); log.info("remove", "metadata", p); }
69
+ const done = [];
70
+ for (const name of plan.workflows) { remove(join(wfDir, name)); done.push(["remove", "workflow", name]); }
71
+ for (const name of plan.scripts) { remove(join(targetRoot, PATHS.scriptsDir, name)); done.push(["remove", "script", name]); }
72
+ removeAppFiles(targetRoot, plan.appFiles, done);
73
+ for (const p of plan.baseline || []) { remove(join(targetRoot, p)); done.push(["remove", "metadata", p]); }
74
+ pruneInstallDirs(targetRoot, plan);
36
75
  // removeVersionSectionFromReadme/removeAutoAddedEntriesFromGitignore는 plan이 "제거 대상"으로
37
76
  // 판단했더라도 실제로는 안전하게 포기(skip-*)할 수 있다 — 반환 상태를 그대로 신뢰하지 않고
38
77
  // 실제 결과로 plan을 덮어써서 호출부(CLI/대화형 요약)가 거짓 성공을 보고하지 않게 한다.
39
78
  const readmeRemoved = plan.readme && removeVersionSectionFromReadme(targetRoot) === "removed";
40
79
  const gitignoreStatus = plan.gitignore ? removeAutoAddedEntriesFromGitignore(targetRoot) : null;
41
80
  const gitignoreRemoved = gitignoreStatus === "removed" || gitignoreStatus === "file-deleted";
42
- if (plan.versionYml) { remove(join(targetRoot, PATHS.versionFile)); log.info("remove", "version", PATHS.versionFile); }
81
+ if (readmeRemoved) done.push(["remove", "readme", "README.md 버전 섹션"]);
82
+ if (gitignoreStatus) done.push(["remove", "gitignore", `.gitignore 자동 추가 항목 (${gitignoreStatus})`]);
83
+ if (plan.versionYml) { remove(join(targetRoot, PATHS.versionFile)); done.push(["remove", "version", PATHS.versionFile]); }
84
+ logRemovals(targetRoot, done);
43
85
  return { ...plan, readme: readmeRemoved, gitignore: gitignoreRemoved };
44
86
  }
45
87
 
@@ -47,7 +89,8 @@ export function runUninstall(context, payloadRoot, targetRoot, selection) {
47
89
  const ITEM_DEFS = [
48
90
  // .github/.wizard 에는 baseline.json과 설치 로그(.wizard/logs)가 함께 들어 있다 —
49
91
  // 워크플로우를 지우면 함께 사라지므로 라벨에 명시한다.
50
- { key: "workflows", label: "워크플로우 (.github/workflows/PROJECT-*.yaml) + 설치 기록 (.github/.wizard)" },
92
+ // Flutter 앱 파일은 마법사가 만들고 사용자가 수정하지 않은 것만 함께 지운다.
93
+ { key: "workflows", label: "워크플로우 (.github/workflows/PROJECT-*.yaml) + 설치 기록 (.github/.wizard) + 수정하지 않은 Flutter 앱 파일" },
51
94
  { key: "scripts", label: "스크립트 (.github/scripts/*.py)" },
52
95
  { key: "readme", label: "README.md 버전 섹션 (AUTO-VERSION-SECTION)" },
53
96
  { key: "gitignore", label: ".gitignore 자동 추가 항목" },
@@ -87,6 +130,7 @@ function summarizeResult(result) {
87
130
  const lines = [];
88
131
  if (result.workflows.length) lines.push(`워크플로우 ${result.workflows.length}개 제거`);
89
132
  if (result.scripts.length) lines.push(`스크립트 ${result.scripts.length}개 제거`);
133
+ if (result.appFiles?.length) lines.push(`Flutter 앱 파일 ${result.appFiles.length}개 제거`);
90
134
  if (result.readme) lines.push("README.md 버전 섹션 제거");
91
135
  if (result.gitignore) lines.push(".gitignore 자동 추가 항목 제거");
92
136
  if (result.versionYml) lines.push("version.yml 제거");
@@ -95,7 +139,8 @@ function summarizeResult(result) {
95
139
 
96
140
  // io 계약: engineIo.multiselect({message,options,initialValues}), askYesNo(msg,def),
97
141
  // note(text,title)?, cancelMessage(text)? — src/ui/prompts.js가 실물, 테스트는 스텁 주입.
98
- export async function runUninstallFlow(payloadRoot, targetRoot, io) {
142
+ // preset: CLI의 --purge-* 플래그 { readme, gitignore, versionYml } — 체크리스트 초기 선택에 반영한다.
143
+ export async function runUninstallFlow(payloadRoot, targetRoot, io, preset = {}) {
99
144
  const available = detectAvailableItems(payloadRoot, targetRoot);
100
145
  if (available.length === 0) {
101
146
  io.note?.("제거할 항목이 없습니다.", "완전 삭제");
@@ -105,7 +150,7 @@ export async function runUninstallFlow(payloadRoot, targetRoot, io) {
105
150
  const checked = await io.engineIo.multiselect({
106
151
  message: "삭제할 항목을 선택하세요 (Space 토글, Enter 확정)",
107
152
  options: available,
108
- initialValues: available.map((o) => o.value).filter((v) => SAFE_ITEMS.includes(v)),
153
+ initialValues: available.map((o) => o.value).filter((v) => SAFE_ITEMS.includes(v) || preset[v] === true),
109
154
  });
110
155
  if (checked === CANCEL || !Array.isArray(checked) || checked.length === 0) {
111
156
  io.cancelMessage?.("완전 삭제를 취소했습니다.");
package/src/context.js CHANGED
@@ -1,8 +1,8 @@
1
- // 마법사 전역 상태를 하나의 객체로 명시화 (bash 전역 변수군 대체)
2
- export const VALID_TYPES = [
3
- "spring", "flutter", "next", "react",
4
- "react-native", "react-native-expo", "node", "python", "basic", "go",
5
- ];
1
+ // 마법사 전역 상태를 하나의 객체로 명시화
2
+ import { TYPE_IDS } from "./core/types.js";
3
+
4
+ // --type 화이트리스트 — 타입 레지스트리(core/types.js)의 표시 순서 그대로.
5
+ export const VALID_TYPES = TYPE_IDS;
6
6
 
7
7
  // --mode 화이트리스트 — 알 수 없는 값은 부수효과(브랜치 조회 등) 이전에 즉시 거부해야 한다.
8
8
  // purge는 --help/대화형 메뉴에 노출하지 않는 숨김 모드이지만 검증 대상에는 포함한다.
@@ -7,7 +7,7 @@
7
7
  //
8
8
  // 파일 사본이 아니라 해시만 남긴다 — 분류가 목적이지 자동 병합이 목적이 아니다.
9
9
  //
10
- // 해시를 두 개 두는 이유(이슈 원안은 하나였다): env 치환으로 사용자 값이 들어간 파일은
10
+ // 해시를 두 개 두는 이유: env 치환으로 사용자 값이 들어간 파일은
11
11
  // 디스크 내용과 "기본값으로 렌더한 결과"가 애초에 다르다. 하나로는 두 질문에 동시에 답할 수 없다.
12
12
  // - installed : 설치 시점 우리가 디스크에 쓴 내용 → "사용자가 그 뒤에 손댔는가"
13
13
  // - rendered : 그 시점 payload를 기본값 치환한 결과 → "업스트림이 그 뒤에 바뀌었는가"
@@ -26,6 +26,12 @@ export function sha256(text) {
26
26
  return "sha256:" + createHash("sha256").update(String(text), "utf8").digest("hex");
27
27
  }
28
28
 
29
+ // Flutter 앱 파일(Fastfile·ExportOptions.plist) 해시 — 체크아웃 줄바꿈(autocrlf)만 달라진 파일을
30
+ // "사용자가 수정했다"로 보지 않도록 LF로 맞춘 뒤 해시한다.
31
+ export function appFileHash(text) {
32
+ return sha256(String(text).replace(/\r\n/g, "\n"));
33
+ }
34
+
29
35
  // 없거나 깨졌으면 null — 호출부는 "base 미상"으로 폴백한다(조용히 빈 baseline을 쓰지 않는다.
30
36
  // 빈 baseline은 "기록이 없다"가 아니라 "전부 삭제됐다"로 오해될 수 있다).
31
37
  export function readBaseline(targetRoot = ".") {
@@ -40,22 +46,31 @@ export function readBaseline(targetRoot = ".") {
40
46
  }
41
47
  }
42
48
 
43
- // entries: Map<filename, {installed?:string|null, rendered:string}>
49
+ // entries: Map<filename, {installed?:string|null, rendered?:string|null}>
50
+ // appFiles: Map<레포 기준 상대경로, appFileHash> — 이번에 새로 만든 Flutter 앱 파일. 완전 삭제가
51
+ // "마법사가 만들었고 사용자가 손대지 않은 파일"만 지우는 근거다. files는 워크플로우 파일명 키라 섞지 않는다.
44
52
  // 기존 baseline은 병합 대상이다 — 이번 실행에서 건드리지 않은 파일의 기준점을 잃지 않는다.
45
- export function writeBaseline(targetRoot, { templateVersion, installedAt, entries, previous = null }) {
53
+ export function writeBaseline(targetRoot, { templateVersion, installedAt, entries, previous = null, appFiles = new Map() }) {
46
54
  const files = { ...(previous?.files || {}) };
47
55
  for (const [filename, entry] of entries) {
48
56
  const prev = files[filename] || {};
49
57
  files[filename] = {
50
58
  // installed는 이번에 실제로 쓴 경우에만 갱신. 유지(skip)한 파일은 예전 기준점을 지킨다.
51
59
  installed: entry.installed ?? prev.installed ?? null,
52
- rendered: entry.rendered,
60
+ // rendered가 없으면(충돌로 업스트림 변경을 받지 않은 파일) 예전 기준점을 지킨다.
61
+ rendered: entry.rendered ?? prev.rendered ?? null,
53
62
  };
54
63
  }
64
+ const apps = { ...(previous?.appFiles || {}), ...Object.fromEntries(appFiles) };
65
+ // 기준점이 하나도 바뀌지 않은 재실행은 설치 시각도 그대로 둔다 — 매 실행마다 파일이 바뀌면 멱등이 아니다.
66
+ const unchanged = previous && previous.templateVersion === (templateVersion || "unknown")
67
+ && JSON.stringify(previous.files) === JSON.stringify(files)
68
+ && JSON.stringify(previous.appFiles || {}) === JSON.stringify(apps);
55
69
  const out = {
56
70
  templateVersion: templateVersion || "unknown",
57
- installedAt: installedAt || "",
71
+ installedAt: unchanged ? (previous.installedAt || "") : (installedAt || ""),
58
72
  files,
73
+ ...(Object.keys(apps).length ? { appFiles: apps } : {}),
59
74
  };
60
75
  writeText(join(targetRoot, BASELINE_PATH), JSON.stringify(out, null, 2) + "\n");
61
76
  return out;
@@ -1,4 +1,4 @@
1
- // 브랜치 구성 (DESIGN-SPEC §4 신규 질문 ①).
1
+ // 브랜치 구성 — main/develop 브랜치와 pr-flow·trunk-based 모드.
2
2
  // on: push: branches: 는 YAML 정적 값 — 마법사가 릴리스/개발 브랜치를 물어(또는 플래그로 받아)
3
3
  // {{MAIN_BRANCH}}/{{DEVELOP_BRANCH}} 플레이스홀더를 치환한다 (치환 자체는 branding.js).
4
4
  // main === develop 이면 trunk-based 모드 → RELEASE-PUBLISH 단독 설치.
@@ -26,6 +26,24 @@ export async function detectRemoteBranches(cwd, exec = defaultExec) {
26
26
  .filter((s, i, a) => a.indexOf(s) === i);
27
27
  }
28
28
 
29
+ // 브랜치 이름 검증 — git check-ref-format --branch 규칙에 더해, 워크플로우 YAML·셸 명령에 그대로
30
+ // 치환되는 값이라 공백·괄호·따옴표 같은 셸 특수문자도 거부한다. 빈 원격이 돌려주는 "(unknown)" 같은
31
+ // 감지 실패 값이나 공백만 입력한 값이 트리거에 기록되면 워크플로우가 영영 돌지 않는다.
32
+ export function isValidBranchName(name) {
33
+ if (typeof name !== "string" || name === "") return false;
34
+ if (/[\s~^:?*[\\\x00-\x1f\x7f()"'`$;&|<>!{}]/.test(name)) return false;
35
+ if (name.startsWith("-") || name.startsWith("/") || name.endsWith("/")) return false;
36
+ if (name.endsWith(".") || name.endsWith(".lock") || name === "@") return false;
37
+ if (name.includes("..") || name.includes("//") || name.includes("@{")) return false;
38
+ return !name.split("/").some((part) => part.startsWith("."));
39
+ }
40
+
41
+ // 원격에 브랜치가 하나도 없거나(빈 원격·origin 없음) develop 생성을 건너뛴 경우의 안내.
42
+ // develop이 없으면 pr-flow 워크플로우가 트리거되지 않으므로 조용히 넘어가면 안 된다.
43
+ export function developMissingNotice({ main, develop }) {
44
+ return `원격에 '${develop}' 브랜치가 없습니다 — 설치 파일을 커밋해 '${main}'을 push한 뒤 만들어 주세요: git push origin ${main}:${develop}`;
45
+ }
46
+
29
47
  // 결정 (순수 함수): 플래그/답변 → 최종 구성.
30
48
  // 우선순위: 명시값(mainBranch/developBranch) → 감지 default → 하드 폴백(main/develop).
31
49
  export function resolveBranchConfig({ mainBranch = "", developBranch = "", defaultBranch = "" } = {}) {
@@ -1,4 +1,4 @@
1
- // 브랜치 플레이스홀더 치환 파이프라인 (DESIGN-SPEC §4).
1
+ // 브랜치 플레이스홀더 치환 파이프라인.
2
2
  // payload 워크플로우의 {{MAIN_BRANCH}}/{{DEVELOP_BRANCH}}를 설치 시 실제 브랜치명으로 바꾼다.
3
3
  // ⚠️ GitHub Actions 표현식 `${{ ... }}`은 우리 토큰이 아니다 — `$` 선행 시 제외(lookbehind).
4
4
  // 치환 후 알 수 없는 {{TOKEN}}이 남으면 throw — 복사 무결성 가드 (오타·누락 조기 검출).
@@ -1,45 +1,40 @@
1
- // breaking-changes 확인 흐름 배선 (.sh check_breaking_changes L2506~2616 등가).
2
- // collectBreaking(순수 비교)은 breaking.js — 이 모듈은 로드(원격→clone본 폴백)·표시·확인 게이트.
1
+ // breaking-changes 확인 흐름 배선.
2
+ // collectBreaking(순수 비교)은 breaking.js — 이 모듈은 로드(패키지 번들본)·표시·확인 게이트.
3
3
  import { readFileSync, existsSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { collectBreaking } from "./breaking.js";
6
6
  import { parseExisting } from "./version-yml.js";
7
7
 
8
- const BC_URL = "https://raw.githubusercontent.com/Twin-Fang/project-auto-wizard/main/payload/config/breaking-changes.json";
9
-
10
- // 원격 우선(3초 타임아웃) → 패키지 payload/config 번들본 폴백 → 둘 다 실패 시 null(조용히 스킵 — .sh 등가)
11
- export async function loadBreakingJson(payloadRoot) {
12
- try {
13
- const res = await fetch(BC_URL, { signal: AbortSignal.timeout(3000) });
14
- if (res.ok) return await res.json();
15
- } catch { /* 원격 실패 — 폴백 */ }
8
+ // 패키지에 동봉된 payload/config 번들본만 읽는다. 원격(main)을 읽으면 같은 패키지 버전이라도
9
+ // 실행 시점마다 결과가 달라지고 설치 중 네트워크 요청이 생긴다. 읽기 실패 시 null(조용히 스킵).
10
+ export function loadBreakingJson(payloadRoot) {
16
11
  try {
17
12
  const p = join(payloadRoot, "config", "breaking-changes.json");
18
13
  if (existsSync(p)) return JSON.parse(readFileSync(p, "utf8"));
19
- } catch { /* 폴백 실패 — 스킵 */ }
14
+ } catch { /* 번들 읽기 실패 — 스킵 */ }
20
15
  return null;
21
16
  }
22
17
 
23
18
  // 반환: true=진행, false=사용자 취소.
24
19
  // opts:
25
20
  // cwd - 통합 대상 루트 (기존 version.yml에서 현재 템플릿 버전 읽음)
26
- // payloadRoot - 패키지 payload/ 루트 (번들 폴백용)
27
- // templateVersion - 설치하려는 템플릿 버전 (.sh의 DEFAULT_VERSION 고정 버그를 실버전으로 교정)
21
+ // payloadRoot - 패키지 payload/ 루트 (번들 breaking-changes.json 위치)
22
+ // templateVersion - 설치하려는 템플릿 버전 (고정값이 아니라 실제 패키지 버전)
28
23
  // askYesNo - async(message, defaultYes)→bool. null이면 비대화형: 경고만 출력 후 진행
29
24
  // loader - 테스트 주입용 (기본 loadBreakingJson)
30
25
  export async function runBreakingCheck({ cwd, payloadRoot, templateVersion, askYesNo = null, loader = loadBreakingJson }) {
31
26
  const vy = join(cwd, "version.yml");
32
27
  if (!existsSync(vy)) return true; // 신규 통합 — 비교 기준 없음
33
- const { templateVersion: current } = parseExisting(readFileSync(vy, "utf8"));
34
- if (!current) return true; // 템플릿 메타 없음(unknown) — .sh 동일하게 스킵
28
+ const { templateVersion: current, types } = parseExisting(readFileSync(vy, "utf8"));
29
+ if (!current) return true; // 템플릿 메타 없음(unknown) — 비교 기준이 없어 스킵
35
30
 
36
31
  const json = await loader(payloadRoot);
37
32
  if (!json) return true;
38
33
 
39
- const { critical, warnings } = collectBreaking(json, current, templateVersion);
34
+ const { critical, warnings } = collectBreaking(json, current, templateVersion, types);
40
35
  if (critical.length === 0 && warnings.length === 0) return true;
41
36
 
42
- // 박스 표시 (.sh L2580~2603)
37
+ // 박스 표시
43
38
  const e = (s = "") => process.stderr.write(s + "\n");
44
39
  e("");
45
40
  e("╔══════════════════════════════════════════════════════════════════╗");
@@ -53,11 +48,11 @@ export async function runBreakingCheck({ cwd, payloadRoot, templateVersion, askY
53
48
 
54
49
  if (critical.length > 0) {
55
50
  if (askYesNo) {
56
- // 대화형: 명시 확인 없으면 중단 (기본 N — .sh 등가)
51
+ // 대화형: 명시 확인 없으면 중단 (기본 N)
57
52
  const ok = await askYesNo("위 호환성 변경을 확인했고 계속 진행할까요?", false);
58
53
  if (ok !== true) return false;
59
54
  } else {
60
- // 비대화형(--force): 게이트로 CI를 죽이지 않고 경고 후 진행 (.sh와 의도적 차이 — CI 친화)
55
+ // 비대화형(--force): 게이트로 CI를 죽이지 않고 경고 후 진행 (CI 친화)
61
56
  e("⚠️ CRITICAL 호환성 변경이 있습니다 — 비대화형 실행이라 계속 진행합니다. 위 내용을 꼭 확인하세요.");
62
57
  }
63
58
  }
@@ -1,4 +1,4 @@
1
- // .sh compare_versions 등가: v 접두 제거, 3자리 숫자 비교, 누락 자리=0
1
+ // 버전 비교: v 접두 제거, 3자리 숫자 비교, 누락 자리=0
2
2
  export function compareVersions(a, b) {
3
3
  const parse = (v) => String(v).replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
4
4
  const pa = parse(a), pb = parse(b);
@@ -11,16 +11,20 @@ export function compareVersions(a, b) {
11
11
  }
12
12
 
13
13
  // breaking-changes.json에서 current < ver <= target 범위 항목 수집.
14
- // ⚠️ .sh 버그 수정: target은 하드코딩 1.3.14가 아니라 실제 templateVersion을 넘긴다.
14
+ // target은 고정값이 아니라 실제 templateVersion을 넘긴다 — 그래야 설치하려는 버전까지의 고지가 모두 잡힌다.
15
15
  // _ 로 시작하는 키(메타) 제외. severity critical / 그 외(warning).
16
16
  // 버전 키의 값은 항목 객체 또는 항목 객체의 배열(같은 릴리스에 고지가 여러 건일 때).
17
- export function collectBreaking(json, current, target) {
17
+ // types: 설치된 프로젝트 타입. 항목에 types가 있으면 겹치는 타입이 있을 때만 보여준다 — spring 레포에
18
+ // Flutter 전용 경고가 뜨면 진짜 알려야 할 고지가 묻힌다. types를 모르면(빈 배열) 전부 보여준다.
19
+ export function collectBreaking(json, current, target, types = []) {
18
20
  const critical = [], warnings = [];
21
+ const relevant = (entry) => !Array.isArray(entry?.types) || !types.length || entry.types.some((t) => types.includes(t));
19
22
  for (const [ver, value] of Object.entries(json || {})) {
20
23
  if (ver.startsWith("_")) continue;
21
24
  if (compareVersions(current, ver) < 0 && compareVersions(ver, target) <= 0) {
22
25
  // 한 릴리스에 고지가 여러 건이면 배열로 등록한다 — JSON 키(버전)는 유일해서 객체로는 한 건만 담긴다.
23
26
  for (const entry of Array.isArray(value) ? value : [value]) {
27
+ if (!relevant(entry)) continue;
24
28
  const rec = { version: ver, ...entry };
25
29
  (entry?.severity === "critical" ? critical : warnings).push(rec);
26
30
  }
@@ -14,7 +14,7 @@ import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
14
14
  // 실제로 만들어내는 부산물만 gitignore 대상으로 삼는다.
15
15
  const REQUIRED_ENTRIES = ["*.bak", "*.template.yaml"];
16
16
 
17
- // .sh normalize_gitignore_entry: 주석 제거·트림·앞 / 제거·앞 ./ 제거·뒤 / 제거. 빈값이면 원본.
17
+ // 항목 정규화: 주석 제거·트림·앞 / 제거·앞 ./ 제거·뒤 / 제거. 빈값이면 원본.
18
18
  export function normalizeGitignoreEntry(entry) {
19
19
  let e = String(entry);
20
20
  e = e.replace(/#.*$/, ""); // 주석 제거
@@ -40,23 +40,31 @@ const NEW_FILE_CONTENT =
40
40
  "*.bak\n" +
41
41
  "*.template.yaml\n";
42
42
 
43
+ // 쓰지 않고 판정만 한다 — 실제 갱신과 --dry-run 미리보기가 같은 판정을 쓰게 한다.
44
+ // 반환: {created, added:[...]}
45
+ export function planGitignore(targetRoot = ".") {
46
+ const p = join(targetRoot, ".gitignore");
47
+ if (!existsSync(p)) return { created: true, added: REQUIRED_ENTRIES.slice() };
48
+ const content = readFileSync(p, "utf8");
49
+ return { created: false, added: REQUIRED_ENTRIES.filter((e) => !entryExists(e, content)) };
50
+ }
51
+
43
52
  // 반환: {created, added:[...]}
44
53
  export function ensureGitignore(targetRoot = ".") {
45
54
  const p = join(targetRoot, ".gitignore");
46
- if (!existsSync(p)) {
55
+ const plan = planGitignore(targetRoot);
56
+ if (plan.created) {
47
57
  writeFileSync(p, NEW_FILE_CONTENT);
48
- return { created: true, added: REQUIRED_ENTRIES.slice() };
58
+ return plan;
49
59
  }
60
+ const toAdd = plan.added;
61
+ if (toAdd.length === 0) return plan;
50
62
  let content = readFileSync(p, "utf8");
51
- const toAdd = REQUIRED_ENTRIES.filter((e) => !entryExists(e, content));
52
- if (toAdd.length === 0) return { created: false, added: [] };
53
63
 
54
- // 파일 끝에 개행 없으면 추가 (.sh: tail -c 1 이 non-empty면 echo "")
55
- if (content.length > 0 && !content.endsWith("\n")) content += "\n";
56
- content += "\n";
57
- content += "# ====================================================================\n";
58
- content += "# project-auto-wizard: Auto-added entries\n";
59
- content += "# ====================================================================\n";
64
+ // BANNER는 "\n"으로 시작한다. 파일 끝에 개행이 없으면 그 "\n"이 마지막 줄을 끝내는 역할을 하고,
65
+ // 개행이 있으면 빈 줄 하나가 된다. 어느 쪽이든 제거 시 BANNER 앞까지 자르면 원문 그대로 돌아온다.
66
+ // (개행을 따로 보충하면 제거 후에도 원래 없던 끝 개행이 남는다.)
67
+ content += BANNER;
60
68
  for (const e of toAdd) content += e + "\n";
61
69
  content += BANNER_END;
62
70
  writeFileSync(p, content);
@@ -64,7 +72,7 @@ export function ensureGitignore(targetRoot = ".") {
64
72
  }
65
73
 
66
74
  // ensureGitignore가 기존 파일에 배너 블록을 추가할 때 항상 이 정확한 시퀀스로 시작한다
67
- // (빈 줄 하나 + 3줄 배너), 그리고 REQUIRED_ENTRIES 뒤에 BANNER_END로 끝난다. 배너~BANNER_END
75
+ // ("\n" + 3줄 배너), 그리고 REQUIRED_ENTRIES 뒤에 BANNER_END로 끝난다. 배너~BANNER_END
68
76
  // 범위 안에서 REQUIRED_ENTRIES와 일치하는 줄만 개별 제거하고, 사용자가 그 사이/뒤에 추가한
69
77
  // 줄은 절대 건드리지 않는다.
70
78
  const BANNER =
@@ -1,4 +1,4 @@
1
- // README 버전 섹션 추가 (.sh add_version_section_to_readme 등가) — template_integrator.sh 2145~2181.
1
+ // README 버전 섹션 추가.
2
2
  import { join } from "node:path";
3
3
  import { existsSync, readFileSync, appendFileSync, writeFileSync } from "node:fs";
4
4
 
@@ -6,17 +6,33 @@ export const MARKER = "<!-- AUTO-VERSION-SECTION";
6
6
  // ## (최신 버전|최신버전|Version|버전) : vX.Y.Z (대소문자 무시)
7
7
  const VERSION_LINE_RE = /##\s*(최신\s*버전|최신버전|Version|버전)\s*:\s*v[0-9]+\.[0-9]+\.[0-9]+/i;
8
8
 
9
+ // 실행 로그용 설명 — 상태 코드만 남기면 나중에 로그를 읽는 사람이 의미를 다시 찾아봐야 한다.
10
+ export const README_STATUS_LABEL = {
11
+ added: "README.md 끝에 버전 섹션 추가",
12
+ "skip-no-readme": "README.md가 없어 버전 섹션을 추가하지 않음",
13
+ "skip-marker": "이미 버전 섹션이 있어 그대로 둠",
14
+ "skip-version-line": "이미 버전 줄이 있어 그대로 둠",
15
+ };
16
+
9
17
  // README.md 없으면 스킵. 마커 또는 버전 라인 있으면 스킵. 없으면 파일 끝에 append.
10
18
  // 반환: 'skip-no-readme' | 'skip-marker' | 'skip-version-line' | 'added'
11
- export function addVersionSectionToReadme(version, targetRoot = ".") {
19
+ // 쓰지 않고 판정만 한다 — 실제 추가와 --dry-run 미리보기가 같은 판정을 쓰게 한다.
20
+ export function planVersionSection(targetRoot = ".") {
12
21
  const p = join(targetRoot, "README.md");
13
22
  if (!existsSync(p)) return "skip-no-readme";
14
23
  const content = readFileSync(p, "utf8");
15
24
  if (content.includes(MARKER)) return "skip-marker";
16
25
  if (VERSION_LINE_RE.test(content)) return "skip-version-line";
26
+ return "added";
27
+ }
28
+
29
+ export function addVersionSectionToReadme(version, targetRoot = ".") {
30
+ const status = planVersionSection(targetRoot);
31
+ if (status !== "added") return status;
32
+ const p = join(targetRoot, "README.md");
33
+ const content = readFileSync(p, "utf8");
17
34
 
18
- // .sh: cat >> README.md << EOF — EOF 다음 첫 줄이 빈 줄이므로 append 본문은 "\n---\n..."로 시작.
19
- // (원본 파일이 개행으로 끝난다는 전제는 .sh와 동일 — heredoc은 원본 끝에 그대로 붙는다.)
35
+ // 기존 본문과 구분선 사이에 빈 줄을 두도록 append 본문은 "\n---\n..."로 시작.
20
36
  const section =
21
37
  "\n" +
22
38
  "---\n" +
@@ -25,7 +41,10 @@ export function addVersionSectionToReadme(version, targetRoot = ".") {
25
41
  `## 최신 버전 : v${version}\n` +
26
42
  "\n" +
27
43
  "[전체 버전 기록 보기](CHANGELOG.md)\n";
28
- appendFileSync(p, section);
44
+ // 끝 개행이 없는 README에 그대로 붙이면 마지막 줄 바로 다음 줄에 "---"가 와서
45
+ // 마크다운이 그 줄을 제목(Setext h2)으로 바꿔 버린다 — 줄을 먼저 끝낸다.
46
+ const lead = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
47
+ appendFileSync(p, lead + section);
29
48
  return "added";
30
49
  }
31
50
 
@@ -2,22 +2,61 @@
2
2
  // payload 단일 진실: 스크립트는 payload/scripts/*.py → 사용자 레포 .github/scripts/ 로 설치된다.
3
3
  // 워크플로우 전부가 이 경로(python3 .github/scripts/*.py)를 호출하므로 누락 시 설치물이 런타임에 죽는다.
4
4
  import { join } from "node:path";
5
- import { chmodSync } from "node:fs";
5
+ import { chmodSync, readFileSync, readdirSync, rmSync, rmdirSync } from "node:fs";
6
6
  import { PATHS, PAYLOAD } from "../paths.js";
7
7
  import { exists, copyFileSync } from "../fsutil.js";
8
8
 
9
- // version_manager.py, changelog_manager.py, truncate_release_notes.py 무조건 덮어쓰기 (+chmod — Windows에선 무의미하나 무해).
9
+ export const SCRIPT_NAMES = ["version_manager.py", "changelog_manager.py", "truncate_release_notes.py", "issue_helper.py"];
10
+
11
+ // 파일별로 무엇이 일어날지 계산한다(아무것도 쓰지 않음) — 실제 복사와 --dry-run이 같은 판정을 쓴다.
12
+ // 반환: [{ name, action: "create" | "overwrite" | "unchanged" }]
13
+ export function planScripts(payloadRoot, targetRoot = ".") {
14
+ const out = [];
15
+ for (const name of SCRIPT_NAMES) {
16
+ const src = join(payloadRoot, PAYLOAD.scriptsDir, name);
17
+ if (!exists(src)) continue;
18
+ const dst = join(targetRoot, PATHS.scriptsDir, name);
19
+ const action = !exists(dst) ? "create"
20
+ : readFileSync(dst).equals(readFileSync(src)) ? "unchanged" : "overwrite";
21
+ out.push({ name, action });
22
+ }
23
+ return out;
24
+ }
25
+
26
+ // 스크립트는 항상 payload 버전으로 덮어쓴다 (+chmod — Windows에선 무의미하나 무해).
27
+ // 사용자가 고친 스크립트도 덮이므로, 호출부가 로그에 남길 수 있게 파일별 결과를 돌려준다.
10
28
  export function copyScripts(payloadRoot, targetRoot = ".") {
11
- const scripts = ["version_manager.py", "changelog_manager.py", "truncate_release_notes.py", "issue_helper.py"];
12
- let copied = 0;
13
- for (const s of scripts) {
14
- const src = join(payloadRoot, PAYLOAD.scriptsDir, s);
15
- if (exists(src)) {
16
- const dst = join(targetRoot, PATHS.scriptsDir, s);
17
- copyFileSync(src, dst);
18
- try { chmodSync(dst, 0o755); } catch { /* Windows 등 chmod 무의미 */ }
19
- copied++;
20
- }
29
+ const results = planScripts(payloadRoot, targetRoot);
30
+ for (const { name } of results) {
31
+ const dst = join(targetRoot, PATHS.scriptsDir, name);
32
+ copyFileSync(join(payloadRoot, PAYLOAD.scriptsDir, name), dst);
33
+ try { chmodSync(dst, 0o755); } catch { /* Windows 등 chmod 무의미 */ }
34
+ }
35
+ return results;
36
+ }
37
+
38
+ // 예전 버전의 워크플로우는 스크립트 import가 남긴 바이트코드를 봇 커밋에 섞어 올렸다.
39
+ // 지금은 생성 자체를 막지만 이미 커밋된 pyc는 업데이트로 사라지지 않으므로 여기서 지운다.
40
+ // 범위는 마법사 스크립트 폴더의 pyc와 그 __pycache__뿐이다 — 다른 경로는 건드리지 않는다.
41
+ // 반환: 지울(지운) 파일의 targetRoot 기준 상대 경로 목록 — 삭제는 사용자가 설치 파일과 함께 커밋한다
42
+ export function planScriptBytecode(targetRoot = ".") {
43
+ const base = join(targetRoot, PATHS.scriptsDir);
44
+ const list = (rel) => {
45
+ try {
46
+ return readdirSync(join(base, rel), { withFileTypes: true })
47
+ .filter((e) => e.isFile() && e.name.endsWith(".pyc"))
48
+ .map((e) => [PATHS.scriptsDir, rel, e.name].filter(Boolean).join("/"));
49
+ } catch { return []; }
50
+ };
51
+ return [...list(""), ...list("__pycache__")];
52
+ }
53
+
54
+ export function removeScriptBytecode(targetRoot = ".") {
55
+ const removed = planScriptBytecode(targetRoot);
56
+ for (const rel of removed) rmSync(join(targetRoot, rel), { force: true });
57
+ if (removed.some((rel) => rel.includes("/__pycache__/"))) {
58
+ // 비었을 때만 지운다 — 다른 파일이 있으면 그대로 둔다
59
+ try { rmdirSync(join(targetRoot, PATHS.scriptsDir, "__pycache__")); } catch { /* 비어 있지 않음 */ }
21
60
  }
22
- return copied;
61
+ return removed;
23
62
  }