projectops 4.2.7 → 4.2.9

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
@@ -7,7 +7,7 @@
7
7
  > 이슈 등록부터 커밋, 보고서, 배포까지. 개발자는 코드만 작성하세요.
8
8
 
9
9
  <!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
10
- ## 최신 버전 : v4.2.6 (2026-07-10)
10
+ ## 최신 버전 : v4.2.8 (2026-07-10)
11
11
 
12
12
  [전체 버전 기록 보기](CHANGELOG.md)
13
13
 
@@ -93,22 +93,7 @@ npx projectops
93
93
 
94
94
  > Node.js 20.12+ 만 있으면 별도 설치 없이 대화형 마법사가 실행됩니다. 비대화형: `npx projectops --mode full --type spring,react --force`
95
95
 
96
- <details>
97
- <summary>대안 — 스크립트 직접 실행 (Node 없이, <b>deprecated</b>)</summary>
98
-
99
- > ⚠️ **`template_integrator.sh` / `.ps1`은 deprecated입니다 (#458).** 실행하면 `npx projectops`로 안내하며, 다음 minor에서 제거될 수 있습니다. Node 20.12+를 쓸 수 있으면 위의 `npx projectops`를 사용하세요.
100
-
101
- ```bash
102
- # macOS / Linux
103
- bash <(curl -fsSL "https://raw.githubusercontent.com/Cassiiopeia/projectops/main/template_integrator.sh")
104
- ```
105
-
106
- ```powershell
107
- # Windows PowerShell
108
- $wc=New-Object Net.WebClient;$wc.Encoding=[Text.Encoding]::UTF8;iex $wc.DownloadString("https://raw.githubusercontent.com/Cassiiopeia/projectops/main/template_integrator.ps1")
109
- ```
110
-
111
- </details>
96
+ > ⚠️ 구 `template_integrator.sh` / `.ps1`은 **지원 종료(EOF)** 되었습니다 (#458). 실행하면 `npx projectops` 안내만 출력하며, 다음 minor에서 파일이 제거됩니다.
112
97
 
113
98
  ### Agent Skills만 설치
114
99
 
@@ -137,14 +122,7 @@ Codex plugin marketplace를 사용할 수 없는 환경에서는 [Skills 가이
137
122
  npx projectops --mode skills
138
123
  ```
139
124
 
140
- <details>
141
- <summary>대안 — 스크립트 직접 실행 (Node 없이)</summary>
142
-
143
- ```bash
144
- bash <(curl -fsSL "https://raw.githubusercontent.com/Cassiiopeia/projectops/main/template_integrator.sh") --mode skills
145
- ```
146
125
 
147
- </details>
148
126
 
149
127
  > Claude Code는 `/projectops:` 자동완성, Gemini는 extension, Codex는 plugin marketplace를 우선 사용합니다. 자세한 설치 방식은 [Skills 가이드](docs/SKILLS.md)를 확인하세요.
150
128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectops",
3
- "version": "4.2.7",
3
+ "version": "4.2.9",
4
4
  "description": "ProjectOps — 완전 자동화 GitHub 프로젝트 관리 템플릿 통합 CLI",
5
5
  "keywords": [
6
6
  "devops",
@@ -10,6 +10,7 @@ import { acquireTemplate, readTemplateVersion } from "../core/assets.js";
10
10
  import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName, makeResolvers } from "../core/detect-fs.js";
11
11
  import { parseExisting } from "../core/version-yml.js";
12
12
  import { runBreakingCheck } from "../core/breaking-check.js";
13
+ import { runMigrations } from "../core/migrations/index.js";
13
14
  import { resolveProjectPaths } from "../core/paths-resolve.js";
14
15
  import { askAllOptionalWorkflows } from "../core/options-ask.js";
15
16
  import { promptEnvPlan } from "../ui/env-plan.js";
@@ -243,6 +244,14 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
243
244
  }
244
245
  }
245
246
 
247
+ // 레거시 마이그레이션 (#470) — 워크플로우를 만지는 모드에서만. 대화형은 safe 티어 확인 1회.
248
+ if (mode === "full" || mode === "workflows") {
249
+ await runMigrations({
250
+ targetRoot: cwd,
251
+ askYesNo: (msg, def) => io.askYesNo(msg, def),
252
+ });
253
+ }
254
+
246
255
  let result = null;
247
256
  if (mode === "full") result = runFull(ctx, tempDir, cwd, hooks);
248
257
  else if (mode === "version") result = runVersion(ctx, tempDir, cwd);
@@ -7,13 +7,22 @@ import { PATHS } from "../core/paths.js";
7
7
  import { copyWorkflows } from "../core/copy/workflows.js";
8
8
  import { copyScripts, copyConfigFolder, copySetupGuide } from "../core/copy/simple.js";
9
9
  import { copyUtilModules } from "../core/copy/util.js";
10
+ import { convertLegacySingularType } from "../core/version-yml.js";
10
11
 
11
12
  export function runWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
12
13
  const { types = [], force = true } = context;
13
14
  const wf = copyWorkflows(context, tempDir, targetRoot, hooks);
14
15
 
15
- // update_version_yml_deploy: 기존 version.yml이 있고 ask 값이 있을 때만 deploy 블록 갱신
16
16
  const vy = join(targetRoot, PATHS.versionFile);
17
+
18
+ // v4.1.0 이전 단수 project_type 최소 변환 (#471) — 여기서 복사되는 신형 version_manager가
19
+ // 단수 키를 거부하므로, 변환 없이 두면 버전 워크플로우가 전부 실패하는 깨진 상태가 된다.
20
+ if (existsSync(vy)) {
21
+ const converted = convertLegacySingularType(readFileSync(vy, "utf8"));
22
+ if (converted !== null) writeFileSync(vy, converted);
23
+ }
24
+
25
+ // update_version_yml_deploy: 기존 version.yml이 있고 ask 값이 있을 때만 deploy 블록 갱신
17
26
  if (existsSync(vy) && wf.deployValues && wf.deployValues.size) {
18
27
  writeFileSync(vy, upsertDeployBlock(readFileSync(vy, "utf8"), wf.deployValues));
19
28
  }
@@ -7,11 +7,16 @@ import { exists, copyFileSync, copyDirSync } from "../fsutil.js";
7
7
 
8
8
  // 버전관리/릴리스노트 스크립트만 무조건 덮어쓰기 + chmod +x.
9
9
  // version_manager는 .sh(위임 shim) + .py(실 로직) 한 쌍 — 둘 다 복사해야 동작 (#448).
10
+ // changelog provider 사다리(.py 5종, #455)는 RELEASE-CHANGELOG 워크플로우 fallback-summary가
11
+ // 호출하므로 함께 복사해야 사용자 프로젝트에서 릴리스 노트 생성이 동작한다.
10
12
  export function copyScripts(tempDir, targetRoot = ".") {
11
13
  const scripts = [
12
14
  "version_manager.sh", "version_manager.py",
13
15
  "changelog_manager.py",
14
16
  "truncate_release_notes.sh", "truncate_release_notes.py",
17
+ "changelog_providers/_common.py", "changelog_providers/ladder.py",
18
+ "changelog_providers/commit.py", "changelog_providers/github_ai.py",
19
+ "changelog_providers/openai_compatible.py",
15
20
  ];
16
21
  let copied = 0;
17
22
  for (const s of scripts) {
@@ -3,7 +3,7 @@
3
3
  import { existsSync, readFileSync, readdirSync } from "node:fs";
4
4
  import { join, basename } from "node:path";
5
5
  import { execFileSync } from "node:child_process";
6
- import { detectTypesFromMarkers, detectVersionFromFiles } from "./detect.js";
6
+ import { detectTypesFromMarkers, detectVersionFromFiles, suggestTypesByExtScan } from "./detect.js";
7
7
  import { parseExisting } from "./version-yml.js";
8
8
 
9
9
  const hasFile = (root) => (rel) => existsSync(join(root, rel));
@@ -17,14 +17,44 @@ function gitOut(root, args) {
17
17
  } catch { return ""; }
18
18
  }
19
19
 
20
+ // 확장자 스캔용 파일 목록 (.sh suggest_types_by_scan find 등가 — depth≤3, vendor 폴더 프루닝).
21
+ const SCAN_PRUNE = new Set([
22
+ "node_modules", ".git", "build", "dist", ".dart_tool",
23
+ "android", "ios", ".gradle", "venv", ".venv", "__pycache__",
24
+ ]);
25
+ export function listScanFiles(root, maxDepth = 3) {
26
+ const files = [];
27
+ const walk = (rel, depth) => {
28
+ if (depth > maxDepth) return;
29
+ let entries;
30
+ try { entries = readdirSync(join(root, rel), { withFileTypes: true }); } catch { return; }
31
+ for (const e of entries) {
32
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
33
+ if (e.isDirectory()) {
34
+ if (!SCAN_PRUNE.has(e.name)) walk(childRel, depth + 1);
35
+ } else {
36
+ files.push(childRel);
37
+ }
38
+ }
39
+ };
40
+ walk("", 1);
41
+ return files;
42
+ }
43
+
20
44
  // 타입 감지 — version.yml의 project_types 최우선(source of truth), 없으면 마커 스캔.
45
+ // 마커도 전혀 없으면(basic 폴백) 확장자 빈도 스캔으로 2차 추천 (.sh suggest_types_by_scan 등가, #458).
21
46
  export function detectTypes(root) {
22
47
  const vy = join(root, "version.yml");
23
48
  if (existsSync(vy)) {
24
49
  const { types } = parseExisting(readFileSync(vy, "utf8"));
25
50
  if (types.length) return types; // basic 포함, 명시돼 있으면 그대로
26
51
  }
27
- return detectTypesFromMarkers({ has: hasFile(root), read: readFile(root) });
52
+ const byMarkers = detectTypesFromMarkers({ has: hasFile(root), read: readFile(root) });
53
+ if (byMarkers.length === 1 && byMarkers[0] === "basic") {
54
+ const scanned = suggestTypesByExtScan(listScanFiles(root));
55
+ if (scanned.length) return scanned; // 추천값 — 마법사 확인/수정 루프에서 사용자가 확정
56
+ }
57
+ return byMarkers;
28
58
  }
29
59
 
30
60
  // 버전 감지 — .sh detect_version 순서. jq 유무는 command 존재로 판정.
@@ -60,3 +60,19 @@ export function markerForType(type) {
60
60
  export function extraMarkers(type) {
61
61
  return { python: ["setup.py", "requirements.txt"], spring: ["build.gradle.kts", "pom.xml"] }[type] || [];
62
62
  }
63
+
64
+ // 확장자 빈도 스캔 추천 (.sh suggest_types_by_scan 2단계 등가, #458 npx 이식).
65
+ // 마커가 전혀 없을 때(=basic 폴백)만 쓰는 2차 추천. files = 프루닝된 상대경로 목록.
66
+ // 임계: dart≥1→flutter, java+kt+gradle≥3→spring, tsx+jsx≥3→react, py≥3→python,
67
+ // 아무것도 없고 ts+js≥3→node. 반환은 메뉴 정의 순서로 정렬.
68
+ export function suggestTypesByExtScan(files) {
69
+ const count = (re) => files.filter((f) => re.test(f)).length;
70
+ const found = new Set();
71
+ if (count(/\.dart$/) >= 1) found.add("flutter");
72
+ if (count(/\.java$/) + count(/\.kt$/) + count(/\.gradle$/) >= 3) found.add("spring");
73
+ if (count(/\.tsx$/) + count(/\.jsx$/) >= 3) found.add("react");
74
+ if (count(/\.py$/) >= 3) found.add("python");
75
+ if (found.size === 0 && count(/\.ts$/) + count(/\.js$/) >= 3) found.add("node");
76
+ const order = ["spring", "flutter", "react", "react-native", "react-native-expo", "node", "python"];
77
+ return order.filter((t) => found.has(t));
78
+ }
@@ -1,6 +1,7 @@
1
- // 템플릿 다운로드 후 제거하는 항목 .sh download_template 내부 배열과 동기화
2
- // ⚠️ CLAUDE.md "3곳 동시 수정" 규칙의 4번째 동기화 지점:
3
- // template_initializer.sh / template_integrator.sh / .ps1 / 이 파일
1
+ // 템플릿 다운로드 후 제거하는 항목 (복사 제외 목록).
2
+ // ⚠️ CLAUDE.md "템플릿 전용 파일 추가" 규칙의 동기화 지점:
3
+ // template_initializer.sh(삭제) / 이 파일(복사 제외)
4
+ // (구 template_integrator.sh/.ps1의 배열은 #458 EOF로 소멸 — 여기가 유일한 복사 제외 지점)
4
5
  export const DOCS_TO_REMOVE = [
5
6
  "CONTRIBUTING.md",
6
7
  "CLAUDE.md",
@@ -0,0 +1,78 @@
1
+ // 레거시 마이그레이션 진입점 (#470) — 신호 기반·멱등.
2
+ // 감지(registry × rules) → 계획 표시 → safe 티어 확인 1회 → 적용 → confirm 티어 안내.
3
+ // 버전 번호를 믿지 않는다: 절반의 레거시 레포는 version.yml에 template 메타가 없다(실측).
4
+ import { MIGRATIONS } from "./registry.js";
5
+ import * as workflowRule from "./rules/obsolete-workflows.js";
6
+ import * as rootFileRule from "./rules/root-files.js";
7
+
8
+ const RULES = {
9
+ workflow: workflowRule,
10
+ "root-file": rootFileRule,
11
+ };
12
+
13
+ // 대상 레포에서 레거시 잔재 감지. 반환: { safe: [entry], confirm: [entry] }
14
+ export function detectMigrations(targetRoot = ".") {
15
+ const safe = [];
16
+ const confirm = [];
17
+ for (const entry of MIGRATIONS) {
18
+ const rule = RULES[entry.category];
19
+ if (!rule || !rule.detect(targetRoot, entry)) continue;
20
+ (entry.tier === "safe" ? safe : confirm).push(entry);
21
+ }
22
+ return { safe, confirm };
23
+ }
24
+
25
+ // safe 티어 적용. 실패해도 나머지는 계속(부분 실패 허용 — 멱등이라 재실행으로 복구).
26
+ export function applySafeMigrations(targetRoot, entries) {
27
+ const results = [];
28
+ for (const entry of entries) {
29
+ try {
30
+ results.push({ id: entry.id, ...RULES[entry.category].apply(targetRoot, entry) });
31
+ } catch (e) {
32
+ results.push({ id: entry.id, action: "error", from: entry.file, error: e.message });
33
+ }
34
+ }
35
+ return results;
36
+ }
37
+
38
+ // 마법사 배선 진입점.
39
+ // askYesNo - async(msg, defaultYes)→bool. null이면 비대화형(--force): safe 자동 적용
40
+ // log - 한 줄 출력 함수 (기본 console.log)
41
+ // 반환: { applied: [결과], confirmPending: [entry] }
42
+ export async function runMigrations({ targetRoot = ".", askYesNo = null, log = console.log } = {}) {
43
+ const { safe, confirm } = detectMigrations(targetRoot);
44
+ if (safe.length === 0 && confirm.length === 0) return { applied: [], confirmPending: [] };
45
+
46
+ let applied = [];
47
+ if (safe.length > 0) {
48
+ log(`🧹 레거시 템플릿 파일 ${safe.length}개 감지 — 신형으로 대체되어 공존 시 중복 실행 위험:`);
49
+ for (const e of safe) {
50
+ const arrow = e.replacedBy ? ` → ${e.replacedBy}` : "";
51
+ log(` • ${e.file}${arrow}`);
52
+ log(` (${e.reason}, ${e.since}부터 폐기)`);
53
+ }
54
+ const yes = askYesNo
55
+ ? await askYesNo(`위 ${safe.length}개를 정리할까요? (워크플로우는 .bak 무해화 — 복원 가능)`, true)
56
+ : true;
57
+ if (yes) {
58
+ applied = applySafeMigrations(targetRoot, safe);
59
+ const ok = applied.filter((r) => r.action !== "error");
60
+ const failed = applied.filter((r) => r.action === "error");
61
+ log(`✅ 레거시 정리 완료: ${ok.length}개${failed.length ? ` (실패 ${failed.length}개)` : ""}`);
62
+ for (const f of failed) log(` ⚠️ ${f.from}: ${f.error}`);
63
+ } else {
64
+ log("→ 레거시 정리를 건너뜁니다 (다음 업데이트 때 다시 안내)");
65
+ }
66
+ }
67
+
68
+ if (confirm.length > 0) {
69
+ log(`⚠️ 구세대 배포 워크플로우 ${confirm.length}개 발견 — 현역 배포일 수 있어 자동으로 건드리지 않습니다:`);
70
+ for (const e of confirm) {
71
+ const arrow = e.replacedBy ? ` (신형: ${e.replacedBy})` : "";
72
+ log(` • ${e.file}${arrow} — ${e.reason}`);
73
+ }
74
+ log(" 신형 워크플로우로 전환을 마친 뒤 구 파일을 직접 삭제하세요.");
75
+ }
76
+
77
+ return { applied, confirmPending: confirm };
78
+ }
@@ -0,0 +1,148 @@
1
+ // 레거시 마이그레이션 단일 레지스트리 (#470) — 유일한 관리 지점.
2
+ //
3
+ // ⚠️ 템플릿에서 워크플로우/루트 파일을 리네임·폐기하면 반드시 여기에 구 이름을 한 줄 추가한다.
4
+ // (test/migrations.test.js가 "레지스트리 항목이 현행 배포 세트와 겹치지 않는지" 자동 검증)
5
+ //
6
+ // 항목 스키마:
7
+ // id - 고유 식별자 (kebab-case)
8
+ // category - "workflow" | "root-file" (rules/ 폴더의 구현이 detect/apply 담당)
9
+ // tier - "safe" : 신형이 같은 기능을 대체(순수 리네임). 공존 시 이중 트리거 실해
10
+ // → 확인 1회 후 자동 무해화(.bak) / 삭제
11
+ // "confirm" : 배포 파이프라인일 수 있음(그 레포의 유일한 현역 배포 가능성)
12
+ // → 자동으로 건드리지 않고 안내만. --force에서도 불변
13
+ // file - 정확한 파일명 (글롭 금지 — 사용자 커스텀 보호의 핵심)
14
+ // replacedBy - 대체 신형 파일명 (없으면 null)
15
+ // since - 구 파일이 폐기된 템플릿 버전(참고용)
16
+ // reason - 계획 카드에 표시할 사유
17
+ // contentMarker - (선택) 파일명이 범용적일 때 오탐 방지용 내용 마커 — 이 문자열이
18
+ // 파일 내용에 있을 때만 템플릿 소유로 판정
19
+ //
20
+ // 데이터 출처: git 히스토리 삭제/리네임 전수 + 실제 통합 레포 14개 스캔 (2026-07-11, 설계 문서 참조)
21
+ export const MIGRATIONS = [
22
+ // ── workflow / safe — 순수 리네임·대체 (공존 시 중복 실행 실해) ──────────────
23
+ { id: "wf-version-control", category: "workflow", tier: "safe",
24
+ file: "PROJECT-VERSION-CONTROL.yaml", replacedBy: "PROJECT-COMMON-VERSION-CONTROL.yaml",
25
+ since: "2.x", reason: "1세대 리네임 — 공존 시 버전 이중 증가" },
26
+ { id: "wf-readme-version-update", category: "workflow", tier: "safe",
27
+ file: "PROJECT-README-VERSION-UPDATE.yaml", replacedBy: "PROJECT-COMMON-README-VERSION-UPDATE.yaml",
28
+ since: "2.x", reason: "1세대 리네임 — 공존 시 README 이중 커밋" },
29
+ { id: "wf-sync-issue-labels-v1", category: "workflow", tier: "safe",
30
+ file: "PROJECT-SYNC-ISSUE-LABELS.yaml", replacedBy: "PROJECT-COMMON-SYNC-ISSUE-LABELS.yaml",
31
+ since: "2.x", reason: "1세대 리네임 — 공존 시 라벨 동기화 중복" },
32
+ { id: "wf-sync-issue-labels-v0", category: "workflow", tier: "safe",
33
+ file: "sync-issue-labels.yaml", replacedBy: "PROJECT-COMMON-SYNC-ISSUE-LABELS.yaml",
34
+ since: "2.x", reason: "0세대 리네임 — 공존 시 라벨 동기화 중복" },
35
+ { id: "wf-issue-comment-v1", category: "workflow", tier: "safe",
36
+ file: "PROJECT-ISSUE-COMMENT.yaml", replacedBy: "PROJECT-COMMON-SUH-ISSUE-HELPER-MODULE.yml",
37
+ since: "2.x", reason: "이슈 헬퍼 1세대 — 공존 시 이슈 댓글 중복" },
38
+ { id: "wf-issue-comment-v2", category: "workflow", tier: "safe",
39
+ file: "PROJECT-COMMON-ISSUE-COMMENT.yaml", replacedBy: "PROJECT-COMMON-SUH-ISSUE-HELPER-API.yaml",
40
+ since: "2.x", reason: "이슈 헬퍼 2세대 — 공존 시 이슈 댓글 중복" },
41
+ { id: "wf-auto-changelog-v1", category: "workflow", tier: "safe",
42
+ file: "PROJECT-AUTO-CHANGELOG-CONTROL.yaml", replacedBy: "PROJECT-COMMON-RELEASE-CHANGELOG.yaml",
43
+ since: "2.x", reason: "릴리스 워크플로우 1세대 — 공존 시 릴리스 PR 이중 처리" },
44
+ { id: "wf-auto-changelog-v2", category: "workflow", tier: "safe",
45
+ file: "PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml", replacedBy: "PROJECT-COMMON-RELEASE-CHANGELOG.yaml",
46
+ since: "4.3.0", reason: "RELEASE-CHANGELOG로 리네임 — 공존 시 릴리스 PR 이중 처리" },
47
+ { id: "wf-comqa-bot", category: "workflow", tier: "safe",
48
+ file: "COMQA-ISSUE-CREATION-BOT.yaml", replacedBy: "PROJECT-COMMON-QA-ISSUE-CREATION-BOT.yaml",
49
+ since: "2.x", reason: "QA 봇 구명칭 — 공존 시 QA 이슈 중복 생성" },
50
+ { id: "wf-template-util-sync", category: "workflow", tier: "safe",
51
+ file: "TEMPLATE-UTIL-VERSION-SYNC.yml", replacedBy: "PROJECT-COMMON-TEMPLATE-UTIL-VERSION-SYNC.yml",
52
+ since: "2.x", reason: "구명칭 — 공존 시 util 버전 동기화 중복" },
53
+ { id: "wf-backlog-manager", category: "workflow", tier: "safe",
54
+ file: "PROJECT-COMMON-PROJECT-BACKLOG-MANAGER.yaml", replacedBy: "PROJECT-COMMON-PROJECTS-SYNC-MANAGER.yaml",
55
+ since: "3.x", reason: "Projects 동기화로 대체 — 공존 시 보드 상태 중복 갱신" },
56
+ { id: "wf-suh-lab-build-trigger", category: "workflow", tier: "safe",
57
+ file: "PROJECT-FLUTTER-SUH-LAB-APP-BUILD-TRIGGER.yaml", replacedBy: "PROJECT-FLUTTER-PROJECTOPS-APP-BUILD-TRIGGER.yaml",
58
+ since: "4.3.0", reason: "리브랜딩 리네임 — 공존 시 빌드 트리거 중복" },
59
+ { id: "wf-next-ci", category: "workflow", tier: "safe",
60
+ file: "PROJECT-NEXT-CI.yaml", replacedBy: "PROJECT-REACT-CI.yaml",
61
+ since: "4.1.0", reason: "next 타입 폐지 (react 흡수)" },
62
+ { id: "wf-next-cicd", category: "workflow", tier: "safe",
63
+ file: "PROJECT-NEXT-CICD.yaml", replacedBy: "PROJECT-REACT-CICD.yaml",
64
+ since: "4.1.0", reason: "next 타입 폐지 (react 흡수)" },
65
+ { id: "wf-flutter-ci-yml-ext", category: "workflow", tier: "safe",
66
+ file: "PROJECT-FLUTTER-CI.yml", replacedBy: "PROJECT-FLUTTER-CI.yaml",
67
+ since: "2.x", reason: "구 확장자(.yml) — 현행 .yaml과 공존 시 CI 중복 실행" },
68
+ { id: "wf-flutter-android-pr-ci", category: "workflow", tier: "safe",
69
+ file: "PROJECT-FLUTTER-ANDROID-PR-CI.yaml", replacedBy: "PROJECT-FLUTTER-CI.yaml",
70
+ since: "3.x", reason: "통합 CI로 흡수 — 공존 시 PR CI 중복 실행" },
71
+
72
+ // ── workflow / confirm — 배포/업로드 계열 (현역 배포일 수 있어 자동 조치 금지) ──
73
+ { id: "wf-syn-spring-simple", category: "workflow", tier: "confirm",
74
+ file: "PROJECT-SPRING-SYNOLOGY-SIMPLE-CICD.yaml", replacedBy: "PROJECT-SPRING-SIMPLE-CICD.yaml",
75
+ since: "3.0.137", reason: "SYNOLOGY 폐기 — SSH+Docker 엔진으로 대체됨" },
76
+ { id: "wf-syn-spring-nonstop", category: "workflow", tier: "confirm",
77
+ file: "PROJECT-SPRING-SYNOLOGY-NONSTOP-CICD.yaml", replacedBy: "PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml",
78
+ since: "3.0.137", reason: "SYNOLOGY 폐기 — 무중단 배포는 NGINX/TRAEFIK 버전으로 대체됨" },
79
+ { id: "wf-syn-spring-preview", category: "workflow", tier: "confirm",
80
+ file: "PROJECT-SPRING-SYNOLOGY-PR-PREVIEW.yaml", replacedBy: "PROJECT-SPRING-PR-PREVIEW.yaml",
81
+ since: "3.0.137", reason: "SYNOLOGY 폐기" },
82
+ { id: "wf-syn-flutter-android", category: "workflow", tier: "confirm",
83
+ file: "PROJECT-FLUTTER-ANDROID-SYNOLOGY-CICD.yaml", replacedBy: "PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml",
84
+ since: "3.0.137", reason: "SYNOLOGY 폐기 — SELFHOSTED(SMB)로 대체됨" },
85
+ { id: "wf-syn-python-cicd", category: "workflow", tier: "confirm",
86
+ file: "PROJECT-PYTHON-SYNOLOGY-CICD.yaml", replacedBy: "PROJECT-PYTHON-SIMPLE-CICD.yaml",
87
+ since: "3.0.137", reason: "SYNOLOGY 폐기" },
88
+ { id: "wf-syn-python-preview", category: "workflow", tier: "confirm",
89
+ file: "PROJECT-PYTHON-SYNOLOGY-PR-PREVIEW.yaml", replacedBy: "PROJECT-PYTHON-PR-PREVIEW.yaml",
90
+ since: "3.0.137", reason: "SYNOLOGY 폐기" },
91
+ { id: "wf-syn-secret-upload", category: "workflow", tier: "confirm",
92
+ file: "PROJECT-COMMON-SYNOLOGY-SECRET-FILE-UPLOAD.yaml", replacedBy: "PROJECT-COMMON-SECRET-FILE-UPLOAD.yaml",
93
+ since: "3.0.137", reason: "SYNOLOGY 폐기 — 공통 Secret 업로드로 대체됨" },
94
+ { id: "wf-spring-ci-v1", category: "workflow", tier: "confirm",
95
+ file: "PROJECT-SPRING-CI.yaml", replacedBy: null,
96
+ since: "2.x", reason: "1세대 Spring CI — 현행 대체본 없음(CICD에 통합)" },
97
+ { id: "wf-spring-cicd-v1", category: "workflow", tier: "confirm",
98
+ file: "PROJECT-SPRING-CICD.yaml", replacedBy: "PROJECT-SPRING-SIMPLE-CICD.yaml",
99
+ since: "2.x", reason: "1세대 Spring 배포 — 현역 배포일 수 있음" },
100
+ { id: "wf-python-cicd-v1", category: "workflow", tier: "confirm",
101
+ file: "PROJECT-PYTHON-CICD.yaml", replacedBy: "PROJECT-PYTHON-SIMPLE-CICD.yaml",
102
+ since: "3.x", reason: "구 Python 배포 — 현역 배포일 수 있음" },
103
+ { id: "wf-android-cicd-v1", category: "workflow", tier: "confirm",
104
+ file: "PROJECT-ANDROID-CICD.yaml", replacedBy: "PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml",
105
+ since: "2.x", reason: "1세대 Android 배포 — 현역 배포일 수 있음" },
106
+ { id: "wf-ios-testflight-v1", category: "workflow", tier: "confirm",
107
+ file: "PROJECT-IOS-TESTFLIGHT-CICD.yml", replacedBy: "PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml",
108
+ since: "2.x", reason: "1세대 iOS 배포 — 현역 배포일 수 있음" },
109
+ { id: "wf-flutter-ios-cicd", category: "workflow", tier: "confirm",
110
+ file: "PROJECT-FLUTTER-IOS-CICD.yaml", replacedBy: "PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml",
111
+ since: "3.x", reason: "TESTFLIGHT 워크플로우로 대체 — 현역 배포일 수 있음" },
112
+ { id: "wf-file-upload-spring", category: "workflow", tier: "confirm",
113
+ file: "PROJECT-SPRING-AUTO-FILE-UPLOAD.yaml", replacedBy: "PROJECT-COMMON-SECRET-FILE-UPLOAD.yaml",
114
+ since: "3.x", reason: "공통 Secret 업로드로 대체 — 운영 업로드 경로일 수 있음" },
115
+ { id: "wf-file-upload-flutter", category: "workflow", tier: "confirm",
116
+ file: "PROJECT-FLUTTER-AUTO-FILE-UPLOAD.yaml", replacedBy: "PROJECT-COMMON-SECRET-FILE-UPLOAD.yaml",
117
+ since: "3.x", reason: "공통 Secret 업로드로 대체 — 운영 업로드 경로일 수 있음" },
118
+ { id: "wf-file-upload-python", category: "workflow", tier: "confirm",
119
+ file: "PROJECT-PYTHON-AUTO-FILE-UPLOAD.yaml", replacedBy: "PROJECT-COMMON-SECRET-FILE-UPLOAD.yaml",
120
+ since: "3.x", reason: "공통 Secret 업로드로 대체 — 운영 업로드 경로일 수 있음" },
121
+ { id: "wf-file-upload-v1", category: "workflow", tier: "confirm",
122
+ file: "PROJECT-FILE-AUTO-UPLOAD.yaml", replacedBy: "PROJECT-COMMON-SECRET-FILE-UPLOAD.yaml",
123
+ since: "2.x", reason: "공통 Secret 업로드로 대체 — 운영 업로드 경로일 수 있음" },
124
+ { id: "wf-nexus-publish-v1", category: "workflow", tier: "confirm",
125
+ file: "PROJECT-NEXUS-PUBLISH.yml", replacedBy: "PROJECT-SPRING-NEXUS-PUBLISH.yml",
126
+ since: "2.x", reason: "구 Nexus 배포 — 현역 라이브러리 배포일 수 있음" },
127
+ { id: "wf-nexus-ci-v1", category: "workflow", tier: "confirm",
128
+ file: "PROJECT-NEXUS-MODULE-CI-BUILD-CHECK.yml", replacedBy: "PROJECT-SPRING-NEXUS-CI.yml",
129
+ since: "2.x", reason: "구 Nexus CI" },
130
+ { id: "wf-ci-build-check-v0", category: "workflow", tier: "confirm",
131
+ file: "PROJECT-CI-BUILD-CHECK.yml", replacedBy: "PROJECT-SPRING-NEXUS-CI.yml",
132
+ since: "2.x", reason: "0세대 라이브러리 CI" },
133
+ { id: "wf-publish-v0", category: "workflow", tier: "confirm",
134
+ file: "PROJECT-PUBLISH.yml", replacedBy: "PROJECT-SPRING-NEXUS-PUBLISH.yml",
135
+ since: "2.x", reason: "0세대 라이브러리 배포 — 현역 배포일 수 있음" },
136
+ { id: "wf-deploy-trigger", category: "workflow", tier: "confirm",
137
+ file: "PROJECT-DEPLOY-TRIGGER.yaml", replacedBy: null,
138
+ since: "3.x", reason: "폐기된 배포 트리거" },
139
+
140
+ // ── root-file / safe — 구 설치 가이드 (사용자가 매번 수동 삭제하던 것) ─────────
141
+ { id: "root-setup-guide-v2", category: "root-file", tier: "safe",
142
+ file: "SUH-DEVOPS-TEMPLATE-SETUP-GUIDE.md", replacedBy: "PROJECTOPS-SETUP-GUIDE.md",
143
+ since: "4.3.0", reason: "리브랜딩으로 구 가이드 대체 — 잔재 문서" },
144
+ { id: "root-setup-guide-v1", category: "root-file", tier: "safe",
145
+ file: "SETUP-GUIDE.md", replacedBy: "PROJECTOPS-SETUP-GUIDE.md",
146
+ since: "2.x", reason: "구 가이드 문서 — 잔재",
147
+ contentMarker: "SUH" }, // 범용 파일명이라 내용에 템플릿 마커가 있을 때만 소유 판정
148
+ ];
@@ -0,0 +1,28 @@
1
+ // workflow 카테고리 규칙 (#470) — .github/workflows/의 구 템플릿 워크플로우를 .bak으로 무해화.
2
+ // GitHub Actions는 .yaml/.yml만 실행하므로 .bak 리네임 = 즉시 무해화 + 복원 가능.
3
+ import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ const WF_DIR = join(".github", "workflows");
7
+
8
+ function target(targetRoot, entry) {
9
+ return join(targetRoot, WF_DIR, entry.file);
10
+ }
11
+
12
+ export function detect(targetRoot, entry) {
13
+ const p = target(targetRoot, entry);
14
+ if (!existsSync(p)) return false;
15
+ if (entry.contentMarker) {
16
+ try { return readFileSync(p, "utf8").includes(entry.contentMarker); }
17
+ catch { return false; }
18
+ }
19
+ return true;
20
+ }
21
+
22
+ export function apply(targetRoot, entry) {
23
+ const src = target(targetRoot, entry);
24
+ const bak = `${src}.bak`;
25
+ if (existsSync(bak)) rmSync(bak, { force: true }); // Windows rename은 대상 존재 시 실패
26
+ renameSync(src, bak);
27
+ return { action: "bak", from: entry.file, to: `${entry.file}.bak` };
28
+ }
@@ -0,0 +1,24 @@
1
+ // root-file 카테고리 규칙 (#470) — 레포 루트의 구 템플릿 문서를 삭제.
2
+ // 문서라 무해화가 필요 없고, 사용자가 매번 수동 삭제하던 잔재라 delete가 기대 동작.
3
+ import { existsSync, readFileSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ function target(targetRoot, entry) {
7
+ return join(targetRoot, entry.file);
8
+ }
9
+
10
+ export function detect(targetRoot, entry) {
11
+ const p = target(targetRoot, entry);
12
+ if (!existsSync(p)) return false;
13
+ // 범용 파일명 오탐 방지 — contentMarker가 있으면 내용까지 확인해야 템플릿 소유로 판정
14
+ if (entry.contentMarker) {
15
+ try { return readFileSync(p, "utf8").includes(entry.contentMarker); }
16
+ catch { return false; }
17
+ }
18
+ return true;
19
+ }
20
+
21
+ export function apply(targetRoot, entry) {
22
+ rmSync(target(targetRoot, entry), { force: true });
23
+ return { action: "deleted", from: entry.file, to: null };
24
+ }
@@ -133,6 +133,29 @@ export function parseTemplateOptions(content) {
133
133
  return out;
134
134
  }
135
135
 
136
+ // v4.1.0 이전 단수 project_type 키 → project_types 배열 최소 변환 (#471).
137
+ // version.yml을 재생성하지 않는 경로(workflows 모드)에서 신형 version_manager(단수 키 명시 거부)와의
138
+ // 정합을 맞춘다 — 해당 라인만 교체하고 나머지 내용은 손대지 않는다.
139
+ // 배열 키가 이미 있으면 남은 단수 키만 제거한다(4.1.0: 공존 시 단수 무시 — 잔재 정리).
140
+ // 반환: 변환된 전체 내용 문자열, 변환할 것이 없으면 null.
141
+ export function convertLegacySingularType(content) {
142
+ const lines = String(content || "").split(/\r?\n/);
143
+ let hasArray = false, idx = -1, value = "";
144
+ lines.forEach((l, i) => {
145
+ if (/^project_types:/.test(l)) hasArray = true;
146
+ const m = l.match(/^project_type:\s*["']?([a-z-]+)["']?/);
147
+ if (m && idx < 0) { idx = i; value = m[1]; }
148
+ });
149
+ if (idx < 0) return null;
150
+ if (hasArray) {
151
+ lines.splice(idx, 1);
152
+ return lines.join("\n");
153
+ }
154
+ const type = value === "next" ? "react" : value; // next 타입은 4.1.0에서 react로 흡수
155
+ lines[idx] = `project_types: ["${type}"] # 멀티타입 배열 — 첫 항목이 primary, 직접 편집 가능`;
156
+ return lines.join("\n");
157
+ }
158
+
136
159
  // 기존 version.yml에서 값 추출 (.sh grep/sed 등가, 주석 라인 오탐 방지).
137
160
  export function parseExisting(content) {
138
161
  const text = String(content || "");
package/src/index.js CHANGED
@@ -12,6 +12,7 @@ import { acquireTemplate, readTemplateVersion } from "./core/assets.js";
12
12
  import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName, makeResolvers } from "./core/detect-fs.js";
13
13
  import { parseExisting } from "./core/version-yml.js";
14
14
  import { runBreakingCheck } from "./core/breaking-check.js";
15
+ import { runMigrations } from "./core/migrations/index.js";
15
16
  import { resolveProjectPaths } from "./core/paths-resolve.js";
16
17
  import { printBannerCompact } from "./ui/banner.js";
17
18
  import { printSummary } from "./ui/summary.js";
@@ -133,6 +134,11 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
133
134
  const proceed = await runBreakingCheck({ cwd, tempDir, templateVersion: context.templateVersion });
134
135
  if (!proceed) return 0;
135
136
 
137
+ // 레거시 마이그레이션 (#470) — 워크플로우를 만지는 모드에서만. 비대화형은 safe 티어 자동 적용.
138
+ if (opts.mode === "full" || opts.mode === "workflows") {
139
+ await runMigrations({ targetRoot: cwd });
140
+ }
141
+
136
142
  switch (opts.mode) {
137
143
  case "full": result = runFull(context, tempDir, cwd); break;
138
144
  case "version": result = runVersion(context, tempDir, cwd); break;