projectops 4.2.10 → 4.2.12

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.9 (2026-07-12)
10
+ ## 최신 버전 : v4.2.11 (2026-07-12)
11
11
 
12
12
  [전체 버전 기록 보기](CHANGELOG.md)
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectops",
3
- "version": "4.2.10",
3
+ "version": "4.2.12",
4
4
  "description": "ProjectOps — 완전 자동화 GitHub 프로젝트 관리 템플릿 통합 CLI",
5
5
  "keywords": [
6
6
  "devops",
@@ -97,7 +97,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
97
97
  // 배포/publish 축 + Secret 백업 질문 (#439 — full/workflows만)
98
98
  if (showOptional) {
99
99
  const r = await askAllOptionalWorkflows({
100
- tempDir, types, targetRoot: cwd,
100
+ tempDir, types, targetRoot: cwd, defaultBranch: branch,
101
101
  current: {
102
102
  deploy: existing?.options?.deploy ?? null, publish: existing?.options?.publish ?? null, secretBackup: existing?.options?.secretBackup ?? null,
103
103
  codeReviewCoderabbit: existing?.options?.codeReviewCoderabbit ?? null,
@@ -162,7 +162,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
162
162
  } else if (what === "deploy-publish") {
163
163
  // 배포/publish 축 재질문 (#439 — forceAsk)
164
164
  const r = await askAllOptionalWorkflows({
165
- tempDir, types, targetRoot: cwd,
165
+ tempDir, types, targetRoot: cwd, defaultBranch: branch,
166
166
  current: {
167
167
  deploy: deployTarget, publish: publishTargets, secretBackup: includeSecretBackup,
168
168
  codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch,
@@ -263,8 +263,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
263
263
 
264
264
  // 완료 요약 (.sh print_summary L5438)
265
265
  io.summary?.({
266
- mode, types, version,
267
- counters: { workflows: result?.workflows?.copied ?? 0, utilModules: 0 },
266
+ mode, types, version, deployBranch,
267
+ counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
268
268
  }, cwd);
269
269
  io.outro?.(`통합 완료 — ${mode} 모드로 설치했습니다.`);
270
270
  return 0;
@@ -0,0 +1,41 @@
1
+ // 워크플로우 브랜치 치환 (#477) — 대상 레포의 브랜치 전략이 표준(main/develop)과 다를 때
2
+ // 복사된 워크플로우의 브랜치 리터럴을 설정값으로 교체한다.
3
+ // 표준값이면 입력을 그대로 반환(no-op) — 템플릿 원본과 바이트 동일이 유지되어야
4
+ // isUnchanged 스킵 로직과 "표준 레포 무변화" 계약이 깨지지 않는다.
5
+ //
6
+ // 치환 대상은 실측 전수조사(#477)된 패턴만 다룬다 (글롭·전면 replace 금지 — 오살 방지):
7
+ // 1) 트리거 인라인: branches: ["main"] / [develop] / [ develop ] / ["develop"]
8
+ // 2) 트리거 멀티라인: " - main" / " - develop" 단독 라인 (뒤 주석 허용)
9
+ // 3) 릴리스 가드: == 'develop' (RELEASE-CHANGELOG automerge head 가드)
10
+ // 4) step 내부: git fetch origin main (RELEASE-CHANGELOG 커밋 분석)
11
+
12
+ // main(기본 브랜치) 계열 치환
13
+ function subDefault(s, db) {
14
+ s = s.replace(/^(\s*branches:\s*\[\s*["']?)main(["']?\s*\])/gm, `$1${db}$2`);
15
+ s = s.replace(/^(\s*-\s*)main(\s*(?:#.*)?)$/gm, `$1${db}$2`);
16
+ s = s.replace(/\bgit fetch origin main\b/g, `git fetch origin ${db}`);
17
+ return s;
18
+ }
19
+
20
+ // develop(개발/릴리스 head 브랜치) 계열 치환
21
+ function subDeploy(s, dev) {
22
+ s = s.replace(/^(\s*branches:\s*\[\s*["']?)develop(["']?\s*\])/gm, `$1${dev}$2`);
23
+ s = s.replace(/^(\s*-\s*)develop(\s*(?:#.*)?)$/gm, `$1${dev}$2`);
24
+ s = s.replace(/== 'develop'/g, `== '${dev}'`);
25
+ return s;
26
+ }
27
+
28
+ // content에 브랜치 설정을 적용한 결과를 반환. branches 미지정/표준값이면 원본 그대로.
29
+ // 플레이스홀더 2단 치환: 값 충돌 방지 (예: 기본 브랜치=develop인 레포에서 main→develop 치환 결과가
30
+ // 곧이어 develop→X 치환에 다시 걸리는 연쇄 오염을 차단).
31
+ export function substituteBranches(content, branches = null) {
32
+ if (!branches) return content;
33
+ const db = branches.defaultBranch || "main";
34
+ const dev = branches.deployBranch || "develop";
35
+ let s = String(content);
36
+ const T_DB = "__PJOPS_DEFAULT__";
37
+ const T_DEV = "__PJOPS_DEPLOY__";
38
+ if (dev !== "develop") s = subDeploy(s, T_DEV);
39
+ if (db !== "main") s = subDefault(s, T_DB);
40
+ return s.replaceAll(T_DEV, dev).replaceAll(T_DB, db);
41
+ }
@@ -39,17 +39,32 @@ export async function runBreakingCheck({ cwd, tempDir, templateVersion, askYesNo
39
39
  const { critical, warnings } = collectBreaking(json, current, templateVersion);
40
40
  if (critical.length === 0 && warnings.length === 0) return true;
41
41
 
42
- // 박스 표시 (.sh L2580~2603)
42
+ // 요약 리스트 표시 (#473 — 전문 통덤프는 벽글이 되어 정작 CRITICAL이 안 읽혔고,
43
+ // 긴 본문 래핑이 ║ 박스 경계를 붕괴시켰다. 버전·제목만 한 줄씩, 전문은 선택 열람.)
43
44
  const e = (s = "") => process.stderr.write(s + "\n");
44
45
  e("");
45
- e("╔══════════════════════════════════════════════════════════════════╗");
46
- e(`║ ⚠️ BREAKING CHANGES (v${current} → v${templateVersion})`);
47
- e("╠══════════════════════════════════════════════════════════════════╣");
48
- for (const c of critical) { e("║"); e(`║ [CRITICAL] ${c.version} - ${c.title || ""}`); e(`║ → ${c.message || ""}`); }
49
- for (const w of warnings) { e("║"); e(`║ [WARNING] ${w.version} - ${w.title || ""}`); e(`║ → ${w.message || ""}`); }
50
- e("║");
51
- e("╚══════════════════════════════════════════════════════════════════╝");
46
+ e(`⚠️ BREAKING CHANGES (v${current} → v${templateVersion}) — CRITICAL ${critical.length}건 · WARNING ${warnings.length}건`);
52
47
  e("");
48
+ for (const c of critical) e(` ❗ [CRITICAL] ${c.version} — ${c.title || ""}`);
49
+ for (const w of warnings) e(` ⚠️ [WARNING] ${w.version} — ${w.title || ""}`);
50
+ e("");
51
+
52
+ if (askYesNo) {
53
+ // 대화형: 전문(조치 방법)은 원할 때만 펼친다
54
+ const detail = await askYesNo("각 항목의 상세 내용(조치 방법)을 볼까요?", false);
55
+ if (detail === true) {
56
+ for (const it of [...critical, ...warnings]) {
57
+ e("");
58
+ e(`■ ${it.version} — ${it.title || ""}`);
59
+ e(` ${it.message || ""}`);
60
+ }
61
+ e("");
62
+ }
63
+ } else {
64
+ e(" 상세 내용·조치 방법: .github/config/breaking-changes.json 참고");
65
+ e(` (${BC_URL})`);
66
+ e("");
67
+ }
53
68
 
54
69
  if (critical.length > 0) {
55
70
  if (askYesNo) {
@@ -9,11 +9,13 @@ import { exists, copyFileSync, copyDirSync } from "../fsutil.js";
9
9
  // version_manager는 .sh(위임 shim) + .py(실 로직) 한 쌍 — 둘 다 복사해야 동작 (#448).
10
10
  // changelog provider 사다리(.py 5종, #455)는 RELEASE-CHANGELOG 워크플로우 fallback-summary가
11
11
  // 호출하므로 함께 복사해야 사용자 프로젝트에서 릴리스 노트 생성이 동작한다.
12
+ // issue_helper.py는 SUH-ISSUE-HELPER 워크플로우가 호출 — 함께 복사 필수 (#478).
12
13
  export function copyScripts(tempDir, targetRoot = ".") {
13
14
  const scripts = [
14
15
  "version_manager.sh", "version_manager.py",
15
16
  "changelog_manager.py",
16
17
  "truncate_release_notes.sh", "truncate_release_notes.py",
18
+ "issue_helper.py",
17
19
  "changelog_providers/_common.py", "changelog_providers/ladder.py",
18
20
  "changelog_providers/commit.py", "changelog_providers/github_ai.py",
19
21
  "changelog_providers/openai_compatible.py",
@@ -7,6 +7,7 @@ import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
7
7
  import { PATHS } from "../paths.js";
8
8
  import { exists, copyFileSync, listYamlFiles } from "../fsutil.js";
9
9
  import { isUnchanged, substituteEnv } from "../wizard-env.js";
10
+ import { substituteBranches } from "../branch-sub.js";
10
11
 
11
12
  // 한 파일에 env 치환을 적용해 대상 파일을 갱신 (.sh configure_workflow_env 등가).
12
13
  // values/useDefaults: env 계획(promptEnvPlan) 결과 — 미지정이면 기본값 경로(현행 force 동작).
@@ -41,19 +42,21 @@ function classify(srcDir, workflowsDir, envOpts) {
41
42
  // deployTarget(#439 택1): 'docker-ssh'(기본) | 'vercel' | 'none' — server-deploy는 docker-ssh일 때만,
42
43
  // common/deploy/<target>/은 해당 타겟일 때 복사. publishTargets(#439 다중): 'nexus'|'npm'|'github-packages'.
43
44
  // hooks: { decisions?: Map<filename, 'skip'|'backup'|'template'> } — 기존 파일(changed) 충돌 결정.
44
- // 반환: {copied, skipped, templateAdded, optionalCopied}
45
+ // 반환: {copied, skipped, templateAdded, optionalCopied, copiedFiles[]} — copiedFiles는 실제 복사·교체된 파일명 (#473 요약용)
45
46
  export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
46
- const { types = [], paths = new Map(), deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, repoName = "", resolvers = {}, envValues = new Map(), envUseDefaults = true } = context;
47
+ const { types = [], paths = new Map(), deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, repoName = "", resolvers = {}, envValues = new Map(), envUseDefaults = true, branch = "", deployBranch = "" } = context;
47
48
  const decisions = hooks.decisions instanceof Map ? hooks.decisions : new Map();
48
49
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
49
50
  const projectTypesDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
50
51
  if (!exists(projectTypesDir)) throw new Error("템플릿 저장소 구조 오류 — project-types 폴더를 찾지 못했습니다.");
51
52
 
52
- const counters = { copied: 0, skipped: 0, templateAdded: 0, optionalCopied: 0 };
53
+ const counters = { copied: 0, skipped: 0, templateAdded: 0, optionalCopied: 0, copiedFiles: [] };
53
54
  const deployValues = new Map(); // Map<type, Map<key,value>> — deploy 블록용 ask 값
54
55
  counters.deployValues = deployValues;
56
+ // 브랜치 전략 (#477) — 표준(main/develop)이면 치환·가상비교 모두 no-op
57
+ const branches = { defaultBranch: branch || "main", deployBranch: deployBranch || "develop" };
55
58
  // values/useDefaults는 치환 경로에서만 의미 (isUnchanged는 내부에서 useDefaults:true 강제 — 가상 비교 무손상)
56
- const envOptsFor = (type) => ({ type, projectPath: paths.get(type) || ".", repoName, resolvers, values: envValues, useDefaults: envUseDefaults });
59
+ const envOptsFor = (type) => ({ type, projectPath: paths.get(type) || ".", repoName, resolvers, values: envValues, useDefaults: envUseDefaults, branches });
57
60
 
58
61
  // (1) common — unchanged면 스킵, 아니면 무조건 덮어쓰기
59
62
  const commonDir = join(projectTypesDir, "common");
@@ -67,6 +70,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
67
70
  }
68
71
  copyFileSync(src, dst);
69
72
  counters.copied++;
73
+ counters.copiedFiles.push(filename);
70
74
  }
71
75
  }
72
76
 
@@ -91,6 +95,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
91
95
  copyFileSync(src, dst);
92
96
  counters.optionalCopied++;
93
97
  counters.copied++;
98
+ counters.copiedFiles.push(filename);
94
99
  }
95
100
  }
96
101
 
@@ -103,6 +108,19 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
103
108
  copyFileSync(join(secretDir, filename), dst);
104
109
  counters.optionalCopied++;
105
110
  counters.copied++;
111
+ counters.copiedFiles.push(filename);
112
+ }
113
+ }
114
+
115
+ // (6) 브랜치 치환 post-pass (#477) — 표준과 다른 브랜치 전략일 때만 복사된 파일에 적용.
116
+ // isUnchanged 가상 비교에도 같은 branches가 들어가므로 다음 업데이트에서 재복사 churn이 없다.
117
+ if (branches.defaultBranch !== "main" || branches.deployBranch !== "develop") {
118
+ for (const f of counters.copiedFiles) {
119
+ const p = join(workflowsDir, f);
120
+ if (!existsSync(p)) continue;
121
+ const before = readFileSync(p, "utf8");
122
+ const after = substituteBranches(before, branches);
123
+ if (after !== before) writeFileSync(p, after);
106
124
  }
107
125
  }
108
126
 
@@ -119,6 +137,7 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters) {
119
137
  renameSync(dst, dst + ".bak");
120
138
  copyFileSync(src, dst);
121
139
  counters.copied++;
140
+ counters.copiedFiles?.push(filename);
122
141
  return;
123
142
  }
124
143
  if (decision === "template") {
@@ -134,12 +153,13 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters) {
134
153
  // 대상 워크플로우 디렉토리에서 changed(충돌) 파일 목록만 뽑는다 — copyWorkflowsInteractive의 사전 조사용.
135
154
  // copyWorkflows 본체와 동일한 classify 기준을 써야 결정 Map이 실제 처리 대상과 1:1로 맞는다.
136
155
  export function listWorkflowConflicts(context, tempDir, targetRoot = ".") {
137
- const { types = [], paths = new Map(), deployTarget = "docker-ssh", repoName = "", resolvers = {} } = context;
156
+ const { types = [], paths = new Map(), deployTarget = "docker-ssh", repoName = "", resolvers = {}, branch = "", deployBranch = "" } = context;
138
157
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
139
158
  const projectTypesDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
140
159
  const conflicts = []; // [{ filename, type }] — 엔진 처리 순서와 동일 (타입 순회 → 직하위 → server-deploy)
160
+ const branches = { defaultBranch: branch || "main", deployBranch: deployBranch || "develop" }; // #477 — 엔진과 동일 기준
141
161
  for (const type of types) {
142
- const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers };
162
+ const envOpts = { type, projectPath: paths.get(type) || ".", repoName, resolvers, branches };
143
163
  const typeDir = join(projectTypesDir, type);
144
164
  if (exists(typeDir)) {
145
165
  for (const f of classify(typeDir, workflowsDir, envOpts).changed) conflicts.push({ filename: f, type });
@@ -180,7 +200,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
180
200
  const { newFiles, unchanged, changed } = classify(typeDir, workflowsDir, envOpts);
181
201
  unchangedNames = unchanged.slice();
182
202
  for (const f of unchanged) counters.skipped++;
183
- for (const f of newFiles) { copyFileSync(join(typeDir, f), join(workflowsDir, f)); counters.copied++; }
203
+ for (const f of newFiles) { copyFileSync(join(typeDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); }
184
204
  // changed: 결정 Map에 따라 처리 (미지정=skip → 현행 force 동작과 동일)
185
205
  for (const f of changed) applyDecision(decisions.get(f), typeDir, workflowsDir, f, counters);
186
206
  }
@@ -190,7 +210,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
190
210
  if (exists(serverDeployDir) && (deployTarget || "docker-ssh") === "docker-ssh") {
191
211
  const { newFiles, unchanged, changed } = classify(serverDeployDir, workflowsDir, envOpts);
192
212
  for (const f of unchanged) counters.skipped++;
193
- for (const f of newFiles) { copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; }
213
+ for (const f of newFiles) { copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); }
194
214
  for (const f of changed) applyDecision(decisions.get(f), serverDeployDir, workflowsDir, f, counters);
195
215
  }
196
216
 
@@ -211,6 +231,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
211
231
  copyFileSync(src, dst);
212
232
  counters.optionalCopied++;
213
233
  counters.copied++;
234
+ counters.copiedFiles.push(filename);
214
235
  }
215
236
  }
216
237
 
@@ -73,6 +73,9 @@ export function detectDefaultBranch(root) {
73
73
  const show = gitOut(root, ["remote", "show", "origin"]);
74
74
  const m = show.match(/HEAD branch:\s*(\S+)/);
75
75
  if (m) return m[1];
76
+ // origin 없는 로컬 레포(#477) — 현재 HEAD 브랜치가 최선의 추정 (대화형 수정 메뉴로 교정 가능)
77
+ const head = gitOut(root, ["branch", "--show-current"]);
78
+ if (head) return head;
76
79
  return "main";
77
80
  }
78
81
 
@@ -0,0 +1,34 @@
1
+ // 브랜치 존재 확인·생성 헬퍼 (#477) — 마법사의 개발 브랜치 생성 제안에 사용.
2
+ // 모든 함수는 실패 시 조용히 false/null을 반환한다 (git 미설치·비레포·원격 없음에서 마법사가 죽지 않게).
3
+ import { execFileSync } from "node:child_process";
4
+
5
+ function git(root, args) {
6
+ try {
7
+ return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
8
+ } catch { return null; }
9
+ }
10
+
11
+ // 브랜치 존재 상태. remote는 origin 조회 실패(원격 없음·네트워크) 시 null(불명).
12
+ export function branchStatus(root, name) {
13
+ const isRepo = git(root, ["rev-parse", "--git-dir"]) !== null;
14
+ if (!isRepo || !name) return { isRepo, local: false, remote: null };
15
+ const local = git(root, ["show-ref", "--verify", `refs/heads/${name}`]) !== null;
16
+ const ls = git(root, ["ls-remote", "--heads", "origin", name]);
17
+ const remote = ls === null ? null : ls.length > 0;
18
+ return { isRepo, local, remote };
19
+ }
20
+
21
+ // fromBranch(기본 브랜치)에서 로컬 브랜치 생성 — checkout하지 않는다.
22
+ // 로컬에 fromBranch가 없으면 origin/fromBranch → HEAD 순으로 폴백.
23
+ export function createBranch(root, name, fromBranch = "") {
24
+ if (fromBranch) {
25
+ if (git(root, ["branch", name, fromBranch]) !== null) return true;
26
+ if (git(root, ["branch", name, `origin/${fromBranch}`]) !== null) return true;
27
+ }
28
+ return git(root, ["branch", name]) !== null;
29
+ }
30
+
31
+ // 원격(origin)에 브랜치 push (-u). 자격/네트워크 실패 시 false.
32
+ export function pushBranch(root, name) {
33
+ return git(root, ["push", "-u", "origin", name]) !== null;
34
+ }
@@ -4,25 +4,30 @@
4
4
  import { MIGRATIONS } from "./registry.js";
5
5
  import * as workflowRule from "./rules/obsolete-workflows.js";
6
6
  import * as rootFileRule from "./rules/root-files.js";
7
+ import * as legacyDirRule from "./rules/legacy-dirs.js";
7
8
 
8
9
  const RULES = {
9
10
  workflow: workflowRule,
10
11
  "root-file": rootFileRule,
12
+ "legacy-dir": legacyDirRule,
11
13
  };
12
14
 
13
- // 대상 레포에서 레거시 잔재 감지. 반환: { safe: [entry], confirm: [entry] }
15
+ // 대상 레포에서 레거시 잔재 감지. 반환: { safe: [entry], confirm: [entry], ask: [entry] }
14
16
  export function detectMigrations(targetRoot = ".") {
15
17
  const safe = [];
16
18
  const confirm = [];
19
+ const ask = [];
17
20
  for (const entry of MIGRATIONS) {
18
21
  const rule = RULES[entry.category];
19
22
  if (!rule || !rule.detect(targetRoot, entry)) continue;
20
- (entry.tier === "safe" ? safe : confirm).push(entry);
23
+ if (entry.tier === "safe") safe.push(entry);
24
+ else if (entry.tier === "ask") ask.push(entry);
25
+ else confirm.push(entry);
21
26
  }
22
- return { safe, confirm };
27
+ return { safe, confirm, ask };
23
28
  }
24
29
 
25
- // safe 티어 적용. 실패해도 나머지는 계속(부분 실패 허용 — 멱등이라 재실행으로 복구).
30
+ // 항목 일괄 적용 실행기 (safe·ask 티어 공용). 실패해도 나머지는 계속(부분 실패 허용 — 멱등이라 재실행으로 복구).
26
31
  export function applySafeMigrations(targetRoot, entries) {
27
32
  const results = [];
28
33
  for (const entry of entries) {
@@ -38,10 +43,12 @@ export function applySafeMigrations(targetRoot, entries) {
38
43
  // 마법사 배선 진입점.
39
44
  // askYesNo - async(msg, defaultYes)→bool. null이면 비대화형(--force): safe 자동 적용
40
45
  // log - 한 줄 출력 함수 (기본 console.log)
41
- // 반환: { applied: [결과], confirmPending: [entry] }
46
+ // 반환: { applied: [결과], confirmPending: [entry], askPending: [entry] }
42
47
  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: [] };
48
+ const { safe, confirm, ask } = detectMigrations(targetRoot);
49
+ if (safe.length === 0 && confirm.length === 0 && ask.length === 0) {
50
+ return { applied: [], confirmPending: [], askPending: [] };
51
+ }
45
52
 
46
53
  let applied = [];
47
54
  if (safe.length > 0) {
@@ -65,6 +72,33 @@ export async function runMigrations({ targetRoot = ".", askYesNo = null, log = c
65
72
  }
66
73
  }
67
74
 
75
+ // ask 티어 (#476) — 사용자 문서가 담긴 구명칭 폴더: 대화형은 확인 후 이동, 비대화형은 안내만
76
+ let askPending = [];
77
+ if (ask.length > 0) {
78
+ log(`📁 구명칭 산출물 폴더 ${ask.length}개 감지 — 사용자 문서가 들어 있어 확인 후 이동합니다:`);
79
+ for (const e of ask) {
80
+ log(` • ${e.file}/ → ${e.replacedBy}/`);
81
+ log(` (${e.reason})`);
82
+ }
83
+ if (askYesNo) {
84
+ const yes = await askYesNo("위 폴더를 새 이름으로 이동할까요? (파일 손실 없음 — 충돌 시 원본 유지)", true);
85
+ if (yes === true) {
86
+ const results = applySafeMigrations(targetRoot, ask);
87
+ applied = applied.concat(results);
88
+ for (const r of results) {
89
+ if (r.action === "error") log(` ⚠️ ${r.from}: ${r.error}`);
90
+ else log(` ✅ ${r.from}/ → ${r.to}/ (이동 ${r.moved ?? 0}개${r.skipped ? `, 충돌 유지 ${r.skipped}개` : ""})`);
91
+ }
92
+ } else {
93
+ askPending = ask;
94
+ log("→ 폴더 이동을 건너뜁니다 (다음 업데이트 때 다시 안내)");
95
+ }
96
+ } else {
97
+ askPending = ask;
98
+ log(" 비대화형 실행이라 자동으로 이동하지 않습니다 — 대화형 마법사(npx projectops)에서 확인 후 이동하세요.");
99
+ }
100
+ }
101
+
68
102
  if (confirm.length > 0) {
69
103
  log(`⚠️ 구세대 배포 워크플로우 ${confirm.length}개 발견 — 현역 배포일 수 있어 자동으로 건드리지 않습니다:`);
70
104
  for (const e of confirm) {
@@ -74,5 +108,5 @@ export async function runMigrations({ targetRoot = ".", askYesNo = null, log = c
74
108
  log(" 신형 워크플로우로 전환을 마친 뒤 구 파일을 직접 삭제하세요.");
75
109
  }
76
110
 
77
- return { applied, confirmPending: confirm };
111
+ return { applied, confirmPending: confirm, askPending };
78
112
  }
@@ -5,17 +5,21 @@
5
5
  //
6
6
  // 항목 스키마:
7
7
  // id - 고유 식별자 (kebab-case)
8
- // category - "workflow" | "root-file" (rules/ 폴더의 구현이 detect/apply 담당)
8
+ // category - "workflow" | "root-file" | "legacy-dir" (rules/ 폴더의 구현이 detect/apply 담당)
9
9
  // tier - "safe" : 신형이 같은 기능을 대체(순수 리네임). 공존 시 이중 트리거 실해
10
10
  // → 확인 1회 후 자동 무해화(.bak) / 삭제
11
11
  // "confirm" : 배포 파이프라인일 수 있음(그 레포의 유일한 현역 배포 가능성)
12
12
  // → 자동으로 건드리지 않고 안내만. --force에서도 불변
13
+ // "ask" : 사용자 콘텐츠가 담긴 폴더 등 — 손실 없는 이동이지만 사용자 소유물 (#476)
14
+ // → 대화형: 확인 후 이동 / 비대화형: 자동 조치 없이 안내만
13
15
  // file - 정확한 파일명 (글롭 금지 — 사용자 커스텀 보호의 핵심)
14
16
  // replacedBy - 대체 신형 파일명 (없으면 null)
15
17
  // since - 구 파일이 폐기된 템플릿 버전(참고용)
16
18
  // reason - 계획 카드에 표시할 사유
17
19
  // contentMarker - (선택) 파일명이 범용적일 때 오탐 방지용 내용 마커 — 이 문자열이
18
20
  // 파일 내용에 있을 때만 템플릿 소유로 판정
21
+ // settingsExtractor - (선택) 무해화 직전 실행할 설정 이관기 이름
22
+ // (rules/settings-extractors.js의 EXTRACTORS 키)
19
23
  //
20
24
  // 데이터 출처: git 히스토리 삭제/리네임 전수 + 실제 통합 레포 14개 스캔 (2026-07-11, 설계 문서 참조)
21
25
  export const MIGRATIONS = [
@@ -33,11 +37,18 @@ export const MIGRATIONS = [
33
37
  file: "sync-issue-labels.yaml", replacedBy: "PROJECT-COMMON-SYNC-ISSUE-LABELS.yaml",
34
38
  since: "2.x", reason: "0세대 리네임 — 공존 시 라벨 동기화 중복" },
35
39
  { id: "wf-issue-comment-v1", category: "workflow", tier: "safe",
36
- file: "PROJECT-ISSUE-COMMENT.yaml", replacedBy: "PROJECT-COMMON-SUH-ISSUE-HELPER-MODULE.yml",
40
+ file: "PROJECT-ISSUE-COMMENT.yaml", replacedBy: "PROJECT-COMMON-SUH-ISSUE-HELPER.yaml",
37
41
  since: "2.x", reason: "이슈 헬퍼 1세대 — 공존 시 이슈 댓글 중복" },
38
42
  { id: "wf-issue-comment-v2", category: "workflow", tier: "safe",
39
- file: "PROJECT-COMMON-ISSUE-COMMENT.yaml", replacedBy: "PROJECT-COMMON-SUH-ISSUE-HELPER-API.yaml",
43
+ file: "PROJECT-COMMON-ISSUE-COMMENT.yaml", replacedBy: "PROJECT-COMMON-SUH-ISSUE-HELPER.yaml",
40
44
  since: "2.x", reason: "이슈 헬퍼 2세대 — 공존 시 이슈 댓글 중복" },
45
+ { id: "wf-issue-helper-api", category: "workflow", tier: "safe",
46
+ file: "PROJECT-COMMON-SUH-ISSUE-HELPER-API.yaml", replacedBy: "PROJECT-COMMON-SUH-ISSUE-HELPER.yaml",
47
+ since: "4.3.0", reason: "이슈 헬퍼 API 버전 폐기 (dispatch 전용 비활성 잔재)" },
48
+ { id: "wf-issue-helper-module", category: "workflow", tier: "safe",
49
+ file: "PROJECT-COMMON-SUH-ISSUE-HELPER-MODULE.yml", replacedBy: "PROJECT-COMMON-SUH-ISSUE-HELPER.yaml",
50
+ since: "4.3.0", reason: "외부 액션 내재화(#478) — 공존 시 이슈 댓글 중복",
51
+ settingsExtractor: "suh-issue-helper-module" }, // 무해화 전 with: 커스텀 값을 version.yml로 이관
41
52
  { id: "wf-auto-changelog-v1", category: "workflow", tier: "safe",
42
53
  file: "PROJECT-AUTO-CHANGELOG-CONTROL.yaml", replacedBy: "PROJECT-COMMON-RELEASE-CHANGELOG.yaml",
43
54
  since: "2.x", reason: "릴리스 워크플로우 1세대 — 공존 시 릴리스 PR 이중 처리" },
@@ -145,4 +156,9 @@ export const MIGRATIONS = [
145
156
  file: "SETUP-GUIDE.md", replacedBy: "PROJECTOPS-SETUP-GUIDE.md",
146
157
  since: "2.x", reason: "구 가이드 문서 — 잔재",
147
158
  contentMarker: "SUH" }, // 범용 파일명이라 내용에 템플릿 마커가 있을 때만 소유 판정
159
+
160
+ // ── legacy-dir / ask — 구명칭 산출물 폴더 (사용자 문서 보존 이동, #476) ────────
161
+ { id: "dir-docs-suh-template", category: "legacy-dir", tier: "ask",
162
+ file: "docs/suh-template", replacedBy: "docs/projectops",
163
+ since: "4.2.9", reason: "리브랜딩 — 스킬 산출물(이슈·보고서) 폴더 구명칭. 신 스킬은 docs/projectops/에 저장" },
148
164
  ];
@@ -0,0 +1,36 @@
1
+ // legacy-dir 카테고리 규칙 (#476) — 구명칭 산출물 폴더를 신명칭으로 이동.
2
+ // 폴더 안은 사용자가 작성한 문서(이슈·보고서)라 삭제·무해화 대상이 아니다 — 손실 없는 이동만 한다.
3
+ // 대상 폴더가 이미 있으면 재귀 병합하되 충돌 파일은 원위치에 남긴다(사용자 판단 존중).
4
+ import { existsSync, statSync, readdirSync, renameSync, mkdirSync, rmdirSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ export function detect(targetRoot, entry) {
8
+ const p = join(targetRoot, entry.file);
9
+ try { return existsSync(p) && statSync(p).isDirectory(); }
10
+ catch { return false; }
11
+ }
12
+
13
+ // 재귀 병합: 파일은 대상에 없을 때만 이동, 하위 폴더는 재귀. 다 비운 원본 폴더는 제거.
14
+ function mergeDir(from, to) {
15
+ mkdirSync(to, { recursive: true });
16
+ let moved = 0, skipped = 0;
17
+ for (const e of readdirSync(from, { withFileTypes: true })) {
18
+ const s = join(from, e.name);
19
+ const d = join(to, e.name);
20
+ if (e.isDirectory()) {
21
+ const r = mergeDir(s, d);
22
+ moved += r.moved; skipped += r.skipped;
23
+ } else if (existsSync(d)) {
24
+ skipped++; // 동명 파일 충돌 — 원위치에 남긴다
25
+ } else {
26
+ renameSync(s, d); moved++;
27
+ }
28
+ }
29
+ try { rmdirSync(from); } catch { /* 충돌 잔여로 비어있지 않으면 남긴다 */ }
30
+ return { moved, skipped };
31
+ }
32
+
33
+ export function apply(targetRoot, entry) {
34
+ const { moved, skipped } = mergeDir(join(targetRoot, entry.file), join(targetRoot, entry.replacedBy));
35
+ return { action: "moved", from: entry.file, to: entry.replacedBy, moved, skipped };
36
+ }
@@ -2,6 +2,7 @@
2
2
  // GitHub Actions는 .yaml/.yml만 실행하므로 .bak 리네임 = 즉시 무해화 + 복원 가능.
3
3
  import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
4
4
  import { join } from "node:path";
5
+ import { EXTRACTORS } from "./settings-extractors.js";
5
6
 
6
7
  const WF_DIR = join(".github", "workflows");
7
8
 
@@ -20,9 +21,17 @@ export function detect(targetRoot, entry) {
20
21
  }
21
22
 
22
23
  export function apply(targetRoot, entry) {
24
+ // 무해화 전 설정 이관 (실패해도 무해화는 진행 — 부분 실패 허용 원칙)
25
+ let carried = [];
26
+ if (entry.settingsExtractor && EXTRACTORS[entry.settingsExtractor]) {
27
+ try { carried = EXTRACTORS[entry.settingsExtractor](targetRoot, entry).carried; }
28
+ catch { carried = []; }
29
+ }
23
30
  const src = target(targetRoot, entry);
24
31
  const bak = `${src}.bak`;
25
32
  if (existsSync(bak)) rmSync(bak, { force: true }); // Windows rename은 대상 존재 시 실패
26
33
  renameSync(src, bak);
27
- return { action: "bak", from: entry.file, to: `${entry.file}.bak` };
34
+ const result = { action: "bak", from: entry.file, to: `${entry.file}.bak` };
35
+ if (carried.length > 0) result.carried = carried;
36
+ return result;
28
37
  }
@@ -0,0 +1,65 @@
1
+ // 설정 이관기 (#470 확장, #478) — 구 워크플로우의 커스텀 설정을 무해화 전에 version.yml로 이관.
2
+ // 원칙: 기본값과 다른 값만 이관 / issue_helper 섹션이 이미 있으면 불변(신형 우선·멱등)
3
+ // / version.yml 없으면 skip / 실패해도 무해화를 막지 않는다(호출부에서 try-catch).
4
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ // 구 MODULE 워크플로우 배포본의 with: 기본값 — 이 값 그대로면 사용자 커스텀이 아니다
8
+ const OLD_DEFAULTS = {
9
+ branch_prefix: "",
10
+ max_branch_length: "100",
11
+ commit_template: "${issueTitle} : feat : {변경 사항에 대한 설명} ${issueUrl}",
12
+ comment_marker: "<!-- 이 댓글은 SUH-ISSUE-HELPER 에 의해 자동으로 생성되었습니다. - https://github.com/Cassiiopeia/github-issue-helper -->",
13
+ };
14
+
15
+ const KEYS = Object.keys(OLD_DEFAULTS);
16
+
17
+ function unquote(v) { return v.trim().replace(/^["']|["']$/g, ""); }
18
+
19
+ function parseWithValues(content) {
20
+ const out = {};
21
+ for (const key of KEYS) {
22
+ const m = content.match(new RegExp(`^\\s*${key}:\\s*(.+)$`, "m"));
23
+ if (m) out[key] = unquote(m[1].replace(/\s+#.*$/, ""));
24
+ }
25
+ return out;
26
+ }
27
+
28
+ // version.yml에 issue_helper 블록 삽입. 계층(metadata/template/options)이 없으면 만든다.
29
+ function insertIssueHelperBlock(vyText, carried) {
30
+ const lines = Object.entries(carried)
31
+ .map(([k, v]) => ` ${k}: "${v.replace(/"/g, '\\"')}"`);
32
+ const block = [" issue_helper:", ...lines].join("\n");
33
+
34
+ if (/^\s{4}options:\s*$/m.test(vyText))
35
+ return vyText.replace(/^(\s{4}options:\s*)$/m, `$1\n${block}`);
36
+ if (/^\s{2}template:\s*$/m.test(vyText))
37
+ return vyText.replace(/^(\s{2}template:\s*)$/m, `$1\n options:\n${block}`);
38
+ if (/^metadata:\s*$/m.test(vyText))
39
+ return vyText.replace(/^(metadata:\s*)$/m, `$1\n template:\n options:\n${block}`);
40
+ return `${vyText.replace(/\n*$/, "\n")}metadata:\n template:\n options:\n${block}\n`;
41
+ }
42
+
43
+ // 구 SUH-ISSUE-HELPER-MODULE의 with: → options.issue_helper 이관. 반환: { carried: [키...] }
44
+ export function extractIssueHelperModule(targetRoot, entry) {
45
+ const wf = join(targetRoot, ".github", "workflows", entry.file);
46
+ const vy = join(targetRoot, "version.yml");
47
+ if (!existsSync(wf) || !existsSync(vy)) return { carried: [] };
48
+
49
+ const vyText = readFileSync(vy, "utf8");
50
+ if (/^\s*issue_helper:/m.test(vyText)) return { carried: [] }; // 신형 설정 우선
51
+
52
+ const vals = parseWithValues(readFileSync(wf, "utf8"));
53
+ const carried = {};
54
+ for (const [k, v] of Object.entries(vals)) {
55
+ if (v !== OLD_DEFAULTS[k]) carried[k] = v; // 기본값과 다른 것만
56
+ }
57
+ if (Object.keys(carried).length === 0) return { carried: [] };
58
+
59
+ writeFileSync(vy, insertIssueHelperBlock(vyText, carried));
60
+ return { carried: Object.keys(carried) };
61
+ }
62
+
63
+ export const EXTRACTORS = {
64
+ "suh-issue-helper-module": extractIssueHelperModule,
65
+ };
@@ -11,6 +11,39 @@ import { join } from "node:path";
11
11
  import { listYamlFiles } from "./fsutil.js";
12
12
  import { PATHS } from "./paths.js";
13
13
  import { parseTemplateOptions } from "./version-yml.js";
14
+ import { branchStatus, createBranch, pushBranch } from "./git-branch.js";
15
+
16
+ // 개발(배포) 브랜치 존재 확인 + 생성 제안 (#477) — 대화형 전용.
17
+ // 로컬에 없고 원격에도 없(거나 불명이)면 기본 브랜치에서 생성을 제안하고, 생성 후 push 여부도 묻는다.
18
+ // git 미설치·비레포·질문 불가(io.confirm 없음)면 조용히 통과 — 마법사 진행을 막지 않는다.
19
+ export async function ensureDeployBranch({ targetRoot = ".", deployBranch = "", defaultBranch = "", io = {}, say = () => {} }) {
20
+ if (!deployBranch || typeof io.confirm !== "function") return { created: false, pushed: false };
21
+ const st = branchStatus(targetRoot, deployBranch);
22
+ if (!st.isRepo || st.local) return { created: false, pushed: false };
23
+ if (st.remote === true) {
24
+ say(`ℹ️ '${deployBranch}' 브랜치가 원격에는 있고 로컬에 없습니다 — 필요 시 'git switch ${deployBranch}'로 가져오세요.`);
25
+ return { created: false, pushed: false };
26
+ }
27
+ say(`⚠️ 배포 브랜치 '${deployBranch}'가 없습니다 — 릴리스 파이프라인(${deployBranch}→${defaultBranch || "기본 브랜치"} PR)이 동작하려면 필요합니다.`);
28
+ const mk = await io.confirm({ message: `${defaultBranch || "현재"} 브랜치에서 '${deployBranch}' 브랜치를 만들까요?`, initialValue: true });
29
+ if (mk !== true) {
30
+ say(`→ 건너뜁니다. 나중에 직접: git checkout -b ${deployBranch} && git push -u origin ${deployBranch}`);
31
+ return { created: false, pushed: false };
32
+ }
33
+ if (!createBranch(targetRoot, deployBranch, defaultBranch)) {
34
+ say(`⚠️ 브랜치 생성 실패 — 직접 실행해주세요: git branch ${deployBranch}${defaultBranch ? ` ${defaultBranch}` : ""}`);
35
+ return { created: false, pushed: false };
36
+ }
37
+ say(`✅ 로컬 브랜치 '${deployBranch}' 생성 완료 (checkout은 하지 않았습니다)`);
38
+ const up = await io.confirm({ message: "원격(origin)에도 push할까요?", initialValue: true });
39
+ if (up !== true) return { created: true, pushed: false };
40
+ if (pushBranch(targetRoot, deployBranch)) {
41
+ say(`✅ origin/${deployBranch} push 완료`);
42
+ return { created: true, pushed: true };
43
+ }
44
+ say(`⚠️ push 실패(자격/네트워크) — 직접 실행해주세요: git push -u origin ${deployBranch}`);
45
+ return { created: true, pushed: false };
46
+ }
14
47
 
15
48
  // 재노출 — 파서 본체는 version-yml.js에 있다 (순환 import 방지: options-ask → version-yml 방향만 허용)
16
49
  export { parseTemplateOptions };
@@ -58,7 +91,7 @@ async function askOptionalWorkflow({ dir, icon, short, desc, current, force, tty
58
91
  // 반환: { deploy: string, publish: string[], secretBackup: bool }
59
92
  export async function askAllOptionalWorkflows({
60
93
  tempDir, types = [], current = {}, targetRoot = ".",
61
- force = false, tty = true, io = {}, forceAsk = false,
94
+ force = false, tty = true, io = {}, forceAsk = false, defaultBranch = "",
62
95
  }) {
63
96
  const say = io.log || ((m) => process.stderr.write(`${m}\n`));
64
97
  let deploy = current.deploy ?? null;
@@ -212,6 +245,8 @@ export async function askAllOptionalWorkflows({
212
245
  const ans = await io.text({ message: "배포 브랜치", initialValue: deployBranch ?? "develop" });
213
246
  deployBranch = (typeof ans === "string" && !isCancel(ans) && ans.trim()) ? ans.trim() : (deployBranch ?? "develop");
214
247
  say(`배포 브랜치: ${deployBranch}`);
248
+ // 브랜치 존재 확인 + 생성 제안 (#477) — 없으면 릴리스 파이프라인이 조용히 놀게 된다
249
+ await ensureDeployBranch({ targetRoot, deployBranch, defaultBranch, io, say });
215
250
  }
216
251
  }
217
252
 
@@ -43,10 +43,11 @@ const HEADER = `# ==============================================================
43
43
  // metadata.template.options 상태머신 파싱 (.sh read_template_options L2361~2416 등가).
44
44
  // 반환(#439 배포/publish 축): { deploy: string|null, publish: string[]|null, secretBackup: bool|null }
45
45
  // deploy: 'docker-ssh'|'vercel'|'none', publish: ['nexus','npm','github-packages'] 부분집합. null=미기재.
46
- // 구 키(nexus/npm_publish)는 신 축으로 자동 마이그레이션해 읽는다 (v4.2.0 이전 파일 호환):
46
+ // 구 키(nexus/npm_publish/synology)는 신 축으로 자동 마이그레이션해 읽는다 (v4.2.0 이전 파일 호환):
47
47
  // nexus:true → publish에 'nexus' + deploy 미기재면 'none' (구 동작: nexus면 서버 배포 제외)
48
48
  // npm_publish:true → publish에 'npm'
49
- // synology 다른 키는 어느 분기에도 걸려 자연히 무시된다.
49
+ // synology:true secret_backup 미기재면 true (#473 Synology 옵션은 Secret 업로드를 포함했으므로
50
+ // 승계하지 않으면 비대화형 업데이트에서 Secret 백업 워크플로우를 조용히 잃는다)
50
51
  // (options-ask.js가 이 함수를 import한다 — 순환 방지 위해 여기(version-yml)에 정의.)
51
52
  export function parseTemplateOptions(content) {
52
53
  const out = { deploy: null, publish: null, secretBackup: null,
@@ -60,6 +61,7 @@ export function parseTemplateOptions(content) {
60
61
  }
61
62
  let legacyNexus = null;
62
63
  let legacyNpm = null;
64
+ let legacySynology = null;
63
65
  // 값 정규화: 따옴표 제거 + 트림 (.sh tr -d '"' | tr -d "'" | xargs 등가)
64
66
  const strip = (s) => String(s).replace(/["']/g, "").trim();
65
67
  let inTemplate = false;
@@ -115,6 +117,13 @@ export function parseTemplateOptions(content) {
115
117
  if (v === "false") legacyNpm = false;
116
118
  continue;
117
119
  }
120
+ m = line.match(/^\s+synology:\s*(.+)/);
121
+ if (m) {
122
+ const v = strip(m[1]);
123
+ if (v === "true") legacySynology = true;
124
+ if (v === "false") legacySynology = false;
125
+ continue;
126
+ }
118
127
  // 들여쓰기 0~4칸의 다른 키 → options 섹션 종료 (.sh L2404~2408)
119
128
  if (/^\s{0,4}[a-z_]+:/.test(line)) { inOptions = false; inTemplate = false; }
120
129
  }
@@ -130,6 +139,8 @@ export function parseTemplateOptions(content) {
130
139
  }
131
140
  if (legacyNpm === true) out.publish.push("npm");
132
141
  }
142
+ // 구 synology 키 → secret_backup 승계 (#473) — 신 키가 명시된 경우는 신 키 우선
143
+ if (out.secretBackup === null && legacySynology === true) out.secretBackup = true;
133
144
  return out;
134
145
  }
135
146
 
@@ -1,6 +1,7 @@
1
1
  // @wizard env 토큰 엔진 (.sh configure_workflow_env / _wf_set_env / _wf_is_unchanged 등가).
2
2
  // ⚠️ YAML 파싱/재직렬화 금지 — 라인 단위 문자열 처리 (포맷·주석 보존이 unchanged 판정 전제).
3
3
  // 실측 기준: template_integrator.sh 3282~3360, 3003~3012.
4
+ import { substituteBranches } from "./branch-sub.js";
4
5
 
5
6
  // KEY 정규식: .sh는 [A-Z_]+ (대문자+언더스코어만). ask/auto 마커가 있는 라인만 대상.
6
7
  const MARKER_RE = /#\s*@wizard\s+(ask|auto):(.*)$/;
@@ -96,6 +97,11 @@ export function substituteEnv(content, opts = {}) {
96
97
 
97
98
  // .sh _wf_is_unchanged 등가: 원본을 "기본값으로 가상 치환한 최종형"과 설치본을 바이트 비교.
98
99
  export function isUnchanged(templateContent, installedContent, opts = {}) {
99
- const virtual = substituteEnv(templateContent, { ...opts, useDefaults: true });
100
+ // 브랜치 치환(#477)도 가상 비교에 포함 치환 설치본이 "변경됨"으로 오판되어 매 업데이트마다
101
+ // 재복사(.bak churn)되는 것을 막는다. opts.branches 미지정/표준값이면 no-op.
102
+ const virtual = substituteBranches(
103
+ substituteEnv(templateContent, { ...opts, useDefaults: true }),
104
+ opts.branches ?? null,
105
+ );
100
106
  return virtual === installedContent;
101
107
  }
package/src/index.js CHANGED
@@ -154,8 +154,8 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
154
154
 
155
155
  // 완료 요약 (.sh print_summary — CLI 모드에서도 출력)
156
156
  printSummary({
157
- mode: opts.mode, types, version,
158
- counters: { workflows: result?.workflows?.copied ?? 0, utilModules: 0 },
157
+ mode: opts.mode, types, version, deployBranch: context.deployBranch,
158
+ counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
159
159
  }, cwd);
160
160
  return 0;
161
161
  }
package/src/ui/summary.js CHANGED
@@ -2,15 +2,13 @@
2
2
  // ctx: { mode, types:[], version, counters:{ workflows, utilModules } }
3
3
  import { existsSync, readdirSync } from "node:fs";
4
4
  import { join } from "node:path";
5
- import {
6
- PATHS, WORKFLOW_PREFIX, WORKFLOW_COMMON_PREFIX, WORKFLOW_TEMPLATE_INIT,
7
- } from "../core/paths.js";
8
- import { listYamlFiles } from "../core/fsutil.js";
5
+ import { WORKFLOW_COMMON_PREFIX } from "../core/paths.js";
9
6
 
10
7
  const SEPARATOR = "────────────────────────────────────────";
11
8
 
12
9
  export function printSummary(ctx, targetRoot = ".") {
13
10
  const { mode, types = [], version = "", counters = {} } = ctx || {};
11
+ const deployBranchName = ctx?.deployBranch || "develop"; // #477 — 설정된 배포 브랜치명으로 안내
14
12
  const err = (s = "") => process.stderr.write(`${s}\n`);
15
13
  // 색상은 TTY일 때만 (.sh YELLOW/CYAN/NC 등가)
16
14
  const isTty = !!process.stderr.isTTY;
@@ -18,7 +16,6 @@ export function printSummary(ctx, targetRoot = ".") {
18
16
  const CYAN = isTty ? "\x1b[0;36m" : "";
19
17
  const NC = isTty ? "\x1b[0m" : "";
20
18
  const utilModulesCopied = counters.utilModules ?? 0;
21
- const workflowsCopied = counters.workflows ?? 0;
22
19
 
23
20
  err("");
24
21
  err(SEPARATOR);
@@ -77,35 +74,18 @@ export function printSummary(ctx, targetRoot = ".") {
77
74
  err("");
78
75
  err("추가된 워크플로우:");
79
76
 
80
- // 실제 복사된 워크플로우와 기존 파일 구분 (.sh L5505~5534)
81
- const commonWorkflows = [];
82
- const typeWorkflows = [];
83
- const existingWorkflows = [];
84
- const workflowsDir = join(targetRoot, PATHS.workflowsDir);
85
- if (existsSync(workflowsDir)) {
86
- const typePrefixes = types.map((t) => `${WORKFLOW_PREFIX}-${t.toUpperCase()}-`);
87
- for (const filename of listYamlFiles(workflowsDir)) {
88
- if (!filename.startsWith(`${WORKFLOW_PREFIX}-`)) continue; // PROJECT-*.{yaml,yml}만
89
- if (filename === WORKFLOW_TEMPLATE_INIT) {
90
- // TEMPLATE-INITIALIZER는 템플릿 전용 기존 파일로 분류
91
- existingWorkflows.push(filename);
92
- } else if (filename.startsWith(`${WORKFLOW_COMMON_PREFIX}-`)) {
93
- commonWorkflows.push(filename);
94
- } else if (typePrefixes.some((p) => filename.startsWith(p))) {
95
- typeWorkflows.push(filename);
96
- }
97
- }
98
- }
77
+ // 실제 복사·교체된 파일 목록만 출력 (#473 — 디렉토리 스캔은 존재 파일 전부를 "새로 설치됨"으로
78
+ // 오표기했고 카운터와 소스가 달라 (0개) 불일치가 났다. 복사 엔진의 copiedFiles가 유일한 소스.)
79
+ const copiedFiles = counters.workflowFiles ?? [];
80
+ const commonWorkflows = copiedFiles.filter((f) => f.startsWith(`${WORKFLOW_COMMON_PREFIX}-`));
81
+ const typeWorkflows = copiedFiles.filter((f) => !f.startsWith(`${WORKFLOW_COMMON_PREFIX}-`));
99
82
 
100
- if (commonWorkflows.length > 0 || typeWorkflows.length > 0) {
101
- err(` 📦 새로 설치됨 (${workflowsCopied}개):`);
83
+ if (copiedFiles.length > 0) {
84
+ err(` 📦 새로 설치·갱신됨 (${copiedFiles.length}개):`);
102
85
  for (const wf of commonWorkflows) err(` 📌 ${wf}`);
103
86
  for (const wf of typeWorkflows) err(` 🎯 ${wf}`);
104
- }
105
- if (existingWorkflows.length > 0) {
106
- err("");
107
- err(" 🔧 기존 파일 유지됨:");
108
- for (const wf of existingWorkflows) err(` 📌 ${wf} (템플릿 전용)`);
87
+ } else {
88
+ err(" 📦 새로 설치·갱신된 워크플로우 없음 — 모두 최신 상태");
109
89
  }
110
90
 
111
91
  err("");
@@ -159,8 +139,8 @@ export function printSummary(ctx, targetRoot = ".") {
159
139
  err(" → Secret Name: _GITHUB_PAT_TOKEN");
160
140
  err(" → Scopes: repo, workflow");
161
141
  err("");
162
- err(" 2️⃣ develop 브랜치 생성");
163
- err(" → git checkout -b develop && git push -u origin develop");
142
+ err(` 2️⃣ ${deployBranchName} 브랜치 생성 (아직 없다면)`);
143
+ err(` → git checkout -b ${deployBranchName} && git push -u origin ${deployBranchName}`);
164
144
  err("");
165
145
  err(" 3️⃣ CodeRabbit 활성화");
166
146
  err(" → https://coderabbit.ai 방문하여 저장소 활성화");