projectops 4.2.11 → 4.2.13

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.10 (2026-07-12)
10
+ ## 최신 버전 : v4.2.12 (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.11",
3
+ "version": "4.2.13",
4
4
  "description": "ProjectOps — 완전 자동화 GitHub 프로젝트 관리 템플릿 통합 CLI",
5
5
  "keywords": [
6
6
  "devops",
@@ -12,7 +12,7 @@ import { parseExisting } from "../core/version-yml.js";
12
12
  import { runBreakingCheck } from "../core/breaking-check.js";
13
13
  import { runMigrations } from "../core/migrations/index.js";
14
14
  import { resolveProjectPaths } from "../core/paths-resolve.js";
15
- import { askAllOptionalWorkflows } from "../core/options-ask.js";
15
+ import { askAllOptionalWorkflows, OPTION_AXES } from "../core/options-ask.js";
16
16
  import { promptEnvPlan } from "../ui/env-plan.js";
17
17
  import { listWorkflowConflicts } from "../core/copy/workflows.js";
18
18
  import { createContext, VALID_TYPES } from "../context.js";
@@ -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,
@@ -159,15 +159,15 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
159
159
  } else if (what === "branch") {
160
160
  const b = await io.askText("기본 브랜치", branch);
161
161
  if (!isCancel(b) && b) branch = b;
162
- } else if (what === "deploy-publish") {
163
- // 배포/publish 축 재질문 (#439 forceAsk)
162
+ } else if (OPTION_AXES.includes(what)) {
163
+ // #483 — 선택한 하나만 재질문 (scope). 나머지 옵션은 현재값 그대로 유지.
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,
169
169
  },
170
- force: false, tty: realTty, forceAsk: true,
170
+ force: false, tty: realTty, forceAsk: true, scope: [what],
171
171
  io: {
172
172
  confirm: ({ message, initialValue }) => io.askYesNo(message, initialValue),
173
173
  select: io.engineIo?.select,
@@ -182,9 +182,6 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
182
182
  changelogProvider = r.changelogProvider;
183
183
  changelogBaseUrl = r.changelogBaseUrl;
184
184
  deployBranch = r.deployBranch;
185
- } else if (what === "secret") {
186
- const y = await io.askYesNo("Secret 백업 워크플로우를 포함할까요?", includeSecretBackup);
187
- if (!isCancel(y)) includeSecretBackup = y === true;
188
185
  }
189
186
  }
190
187
  }
@@ -263,8 +260,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
263
260
 
264
261
  // 완료 요약 (.sh print_summary L5438)
265
262
  io.summary?.({
266
- mode, types, version,
267
- counters: { workflows: result?.workflows?.copied ?? 0, utilModules: 0 },
263
+ mode, types, version, deployBranch,
264
+ counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
268
265
  }, cwd);
269
266
  io.outro?.(`통합 완료 — ${mode} 모드로 설치했습니다.`);
270
267
  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,40 @@ 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
+ // #481 — push 질문에 브랜치명 명시 ("어느 브랜치를 push하는지" 불명확 방지)
39
+ const up = await io.confirm({ message: `원격(origin)에 '${deployBranch}' 브랜치도 push할까요?`, initialValue: true });
40
+ if (up !== true) return { created: true, pushed: false };
41
+ if (pushBranch(targetRoot, deployBranch)) {
42
+ say(`✅ origin/${deployBranch} push 완료`);
43
+ return { created: true, pushed: true };
44
+ }
45
+ say(`⚠️ push 실패(자격/네트워크) — 직접 실행해주세요: git push -u origin ${deployBranch}`);
46
+ return { created: true, pushed: false };
47
+ }
14
48
 
15
49
  // 재노출 — 파서 본체는 version-yml.js에 있다 (순환 import 방지: options-ask → version-yml 방향만 허용)
16
50
  export { parseTemplateOptions };
@@ -52,15 +86,23 @@ async function askOptionalWorkflow({ dir, icon, short, desc, current, force, tty
52
86
  return include;
53
87
  }
54
88
 
89
+ // 옵션 질문의 축 키 (#483 수정 스코프 단위) — 수정 메뉴가 이 단위로 한 축만 재질문한다.
90
+ export const OPTION_AXES = ["deploy", "publish", "code-review", "changelog", "release-branch", "secret"];
91
+
55
92
  // 배포/publish 축 + Secret 백업을 순서대로 질문 (.sh ask_all_optional_workflows 등가).
56
93
  // tempDir: 템플릿 다운로드 루트 — project-types는 {tempDir}/.github/workflows/project-types
57
94
  // current: { deploy: string|null, publish: string[]|null, secretBackup: bool|null } — CLI 명시값
58
- // 반환: { deploy: string, publish: string[], secretBackup: bool }
95
+ // scope: null이면 축(초기 통합·전체 재질문). Set/배열이면 축만 forceAsk 대상 (#483 수정 메뉴 격리).
96
+ // 스코프 밖 축은 forceAsk여도 current/저장값을 그대로 유지하고 다시 묻지 않는다.
97
+ // 반환: { deploy, publish, secretBackup, codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch }
59
98
  export async function askAllOptionalWorkflows({
60
99
  tempDir, types = [], current = {}, targetRoot = ".",
61
- force = false, tty = true, io = {}, forceAsk = false,
100
+ force = false, tty = true, io = {}, forceAsk = false, defaultBranch = "", scope = null,
62
101
  }) {
63
102
  const say = io.log || ((m) => process.stderr.write(`${m}\n`));
103
+ // 축별 재질문 여부: 전역 forceAsk이고, scope가 없거나 그 축을 포함할 때만 강제 질문.
104
+ const scopeSet = scope == null ? null : new Set(scope);
105
+ const ask = (axis) => forceAsk && (scopeSet === null || scopeSet.has(axis));
64
106
  let deploy = current.deploy ?? null;
65
107
  let publish = current.publish ?? null;
66
108
  let secretBackup = current.secretBackup ?? null;
@@ -106,36 +148,47 @@ export async function askAllOptionalWorkflows({
106
148
  if (deploy === null) deploy = "none";
107
149
  if (publish === null) publish = [];
108
150
  } else {
109
- if (forceAsk || deploy === null) {
151
+ const willAskDeploy = ask("deploy") || deploy === null;
152
+ const willAskPublish = ask("publish") || publish === null;
153
+ // #480 — deploy·publish는 서로 다른 두 축이다. 둘 다 물을 참이면 먼저 큰 그림을 한 번 안내한다.
154
+ // (수정 메뉴로 한 축만 고칠 때는 맥락이 이미 명확하므로 생략)
155
+ if (willAskDeploy && willAskPublish && tty && typeof io.select === "function") {
156
+ say("");
157
+ say("🧭 배포는 두 가지가 따로 있습니다 — 서로 독립이라 각각 답하시면 됩니다:");
158
+ say(" 1) 실행물 배포 — 서버/호스팅에 올려 돌리는 것 (Docker, Vercel …)");
159
+ say(" 2) 라이브러리 배포(publish) — 남이 가져다 쓰게 레지스트리에 내는 것 (Nexus, npm …)");
160
+ say(" 먼저 (1) 실행물 배포부터 물어보고, 이어서 (2) 라이브러리 배포를 물어봅니다.");
161
+ }
162
+ if (willAskDeploy) {
110
163
  if (force || !tty || typeof io.select !== "function") {
111
164
  deploy = deploy ?? "docker-ssh";
112
165
  } else {
113
166
  say("");
114
- say("🚀 프로젝트를 어디에 배포하나요?");
115
- say(" 서버·호스팅에 올릴 계획이 있으면 고르고, 지금 없으면 '배포 '으로 두면 됩니다.");
167
+ say("🚀 (1) 실행물(서버/앱)을 어디에 올리나요?");
168
+ say(" 서버·호스팅에 올릴 계획이 있으면 고르고, 없으면 '서버에 올리지 않음'으로 두세요.");
116
169
  const ans = await io.select({
117
- message: "배포 방식을 선택하세요",
170
+ message: "실행물 배포 방식을 선택하세요",
118
171
  options: [
119
172
  { value: "docker-ssh", label: "Docker + SSH 서버 배포 (기본)" },
120
173
  { value: "vercel", label: "Vercel" },
121
- { value: "none", label: "배포하지 않음 (라이브러리/CI 전용)" },
174
+ { value: "none", label: "서버에 올리지 않음 (빌드 검증만 · 라이브러리는 다음에서 선택)" },
122
175
  ],
123
176
  });
124
177
  deploy = (!isCancel(ans) && DEPLOY_TARGETS.includes(ans)) ? ans : (deploy ?? "docker-ssh");
125
- say(`배포 방식: ${deploy}`);
178
+ say(`실행물 배포: ${deploy}`);
126
179
  }
127
180
  }
128
181
 
129
182
  // ── ③ publish 타겟 (다중 선택) ──
130
- if (forceAsk || publish === null) {
183
+ if (willAskPublish) {
131
184
  if (force || !tty || typeof io.multiselect !== "function") {
132
185
  publish = publish ?? [];
133
186
  } else {
134
187
  say("");
135
- say("📦 라이브러리로 배포(publish)할 계획이 있나요?");
136
- say(" 사내 Nexus·npmjs·GitHub Packages 해당되는 걸 고르세요. 없으면 그냥 Enter.");
188
+ say("📦 (2) 이 프로젝트를 남이 가져다 쓰는 라이브러리로도 배포(publish)하나요?");
189
+ say(" (1) 실행물 배포와 별개입니다. 해당되는 걸 고르고, 라이브러리 배포를 안 하면 아무것도 고르지 말고 Enter.");
137
190
  const ans = await io.multiselect({
138
- message: "publish 타겟을 선택하세요 (Space 토글, Enter 확정)",
191
+ message: "라이브러리 배포 타겟 (없으면 선택 없이 Enter = 배포 안 함)",
139
192
  options: [
140
193
  { value: "nexus", label: "사내 Maven(Nexus) 라이브러리 배포" },
141
194
  { value: "npm", label: "공개 npmjs 패키지 배포 (NPM_TOKEN)" },
@@ -147,13 +200,13 @@ export async function askAllOptionalWorkflows({
147
200
  publish = (!isCancel(ans) && Array.isArray(ans))
148
201
  ? ans.filter((t) => PUBLISH_TARGETS.includes(t))
149
202
  : (publish ?? []);
150
- say(`Publish 타겟: ${publish.join(",") || "없음"}`);
203
+ say(publish.length ? `라이브러리 배포: ${publish.join(", ")}` : "라이브러리 배포: 안 함");
151
204
  }
152
205
  }
153
206
  }
154
207
 
155
208
  // ── code_review: CodeRabbit AI 코드 리뷰 (changelog와 무관 — #455) ──
156
- if (forceAsk || codeReviewCoderabbit === null) {
209
+ if (ask("code-review") || codeReviewCoderabbit === null) {
157
210
  if (force || !tty || typeof io.confirm !== "function") {
158
211
  codeReviewCoderabbit = codeReviewCoderabbit ?? false;
159
212
  } else {
@@ -162,24 +215,33 @@ export async function askAllOptionalWorkflows({
162
215
  const ans = await io.confirm({ message: "CodeRabbit AI 코드 리뷰 사용", initialValue: false });
163
216
  codeReviewCoderabbit = (ans === true && !isCancel(ans));
164
217
  say(`CodeRabbit 코드 리뷰: ${codeReviewCoderabbit ? "사용" : "미사용"}`);
218
+ // #481 — "사용"만으로는 안 붙는다. 앱 설치 + 레포 접근 권한이 있어야 실제로 리뷰가 달린다.
219
+ if (codeReviewCoderabbit) {
220
+ say(" ⚠️ 실제로 리뷰가 붙으려면 추가 설정이 필요합니다:");
221
+ say(" 1) https://coderabbit.ai 접속 → GitHub으로 로그인");
222
+ say(" 2) CodeRabbit GitHub 앱 설치 → 이 저장소에 접근 권한(grant access) 부여");
223
+ say(" (이 단계를 안 하면 워크플로우는 켜져도 PR에 리뷰 댓글이 달리지 않습니다)");
224
+ }
165
225
  }
166
226
  }
167
227
 
168
228
  // ── changelog: 릴리스 노트 생성기 (기본 커서 = github-ai — #455) ──
169
- if (forceAsk || changelogProvider === null) {
229
+ if (ask("changelog") || changelogProvider === null) {
170
230
  if (force || !tty || typeof io.select !== "function") {
171
231
  changelogProvider = changelogProvider ?? "github-ai";
172
232
  } else {
173
233
  say("");
174
- say("📝 릴리스 노트(changelog)는 뭘로 만들까요?");
234
+ say("📝 릴리스 노트(changelog)는 1순위로 뭘로 만들까요?");
175
235
  say(" GitHub AI는 설정 없이 바로 됩니다. 나머지는 나중에 GitHub Secret 등록이 필요할 수 있어요.");
236
+ // #481 — 하나만 골라야 하는 게 아니다. 고른 게 실패하면 자동 폴백하므로 안심하고 고르라고 안내.
237
+ say(" ✅ 고른 방식이 실패해도 자동으로 GitHub AI → 커밋 분석 순으로 폴백하니 릴리스 노트는 항상 생성됩니다.");
176
238
  const ans = await io.select({
177
- message: "changelog 생성기를 선택하세요",
239
+ message: "1순위 changelog 생성기를 선택하세요 (실패 시 자동 폴백)",
178
240
  options: [
179
241
  { value: "github-ai", label: "GitHub AI (추천 · 설정 불필요)" },
180
242
  { value: "coderabbit", label: "CodeRabbit" },
181
243
  { value: "openai", label: "OpenAI 호환 API (키 등록 필요)" },
182
- { value: "commit", label: "커밋 분석만 (AI 없음)" },
244
+ { value: "commit", label: "커밋 분석만 (AI 없음 · 최후 안전망)" },
183
245
  ],
184
246
  });
185
247
  changelogProvider = (!isCancel(ans) && CHANGELOG_PROVIDERS.includes(ans)) ? ans : (changelogProvider ?? "github-ai");
@@ -188,7 +250,7 @@ export async function askAllOptionalWorkflows({
188
250
  }
189
251
 
190
252
  // ollama 선택 시에만 base_url 질문 (나머지 provider는 preset base_url 자동 — #455)
191
- if (changelogProvider === "ollama" && (forceAsk || changelogBaseUrl === null || changelogBaseUrl === "")) {
253
+ if (changelogProvider === "ollama" && (ask("changelog") || changelogBaseUrl === null || changelogBaseUrl === "")) {
192
254
  if (force || !tty || typeof io.text !== "function") {
193
255
  changelogBaseUrl = changelogBaseUrl ?? "";
194
256
  } else {
@@ -200,18 +262,23 @@ export async function askAllOptionalWorkflows({
200
262
  changelogBaseUrl = "";
201
263
  }
202
264
 
203
- // ── deploy_branch: 릴리스 PR의 head 브랜치 (#456 — default_branch와 별개) ──
204
- // 대부분 develop→main 릴리스 구조라 기본값 develop. 다른 head를 쓰는 레포를 위해 물어본다.
205
- if (forceAsk || deployBranch === null) {
265
+ // ── 릴리스 소스(개발) 브랜치 (#456 필드 deploy_branch 이름과 달리 "개발 브랜치"다, #482) ──
266
+ // 값은 릴리스 PR(개발→기본)의 head, 개발한 모아 기본 브랜치로 올리는 브랜치다.
267
+ // "배포 브랜치"가 아니다 배포가 도는 곳은 기본 브랜치(default) 쪽 개념. #482 참조.
268
+ if (ask("release-branch") || deployBranch === null) {
206
269
  if (force || !tty || typeof io.text !== "function") {
207
270
  deployBranch = deployBranch ?? "develop";
208
271
  } else {
272
+ const base = defaultBranch || "main"; // git으로 감지된 기본 브랜치 (#481 동적 안내)
209
273
  say("");
210
- say("🌿 릴리스 배포 브랜치(릴리스 PR의 head)는 무엇인가요?");
211
- say(" develop→main 릴리스 구조면 develop 그대로 두세요. 배포 브랜치가 따로면 이름을 적어주세요.");
212
- const ans = await io.text({ message: "배포 브랜치", initialValue: deployBranch ?? "develop" });
274
+ say("🌿 개발한 코드를 모아서 배포로 올리는 '개발 브랜치'는 무엇인가요?");
275
+ say(` 감지된 기본(배포) 브랜치는 '${base}'입니다. 개발은 보통 앞단 브랜치(예: develop)에서 모아 올립니다.`);
276
+ say(" 특별한 이유가 없으면 develop 그대로 두세요.");
277
+ const ans = await io.text({ message: "개발(릴리스 소스) 브랜치", initialValue: deployBranch ?? "develop" });
213
278
  deployBranch = (typeof ans === "string" && !isCancel(ans) && ans.trim()) ? ans.trim() : (deployBranch ?? "develop");
214
- say(`배포 브랜치: ${deployBranch}`);
279
+ say(`개발(릴리스 소스) 브랜치: ${deployBranch}`);
280
+ // 브랜치 존재 확인 + 생성 제안 (#477) — 없으면 릴리스 파이프라인이 조용히 놀게 된다
281
+ await ensureDeployBranch({ targetRoot, deployBranch, defaultBranch, io, say });
215
282
  }
216
283
  }
217
284
 
@@ -221,7 +288,7 @@ export async function askAllOptionalWorkflows({
221
288
  secretBackup = await askOptionalWorkflow({
222
289
  dir: join(ptDir, "common", "secret-backup"), icon: "🔐", short: "Secret 서버 백업",
223
290
  desc: "GitHub Secret에 저장한 설정 파일을 SSH로 서버에 업로드·이력관리하는 워크플로우입니다.",
224
- current: secretBackup, force, tty, io, forceAsk, say,
291
+ current: secretBackup, force, tty, io, forceAsk: ask("secret"), say,
225
292
  });
226
293
 
227
294
  return {
@@ -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/prompts.js CHANGED
@@ -39,7 +39,12 @@ export async function editMenu({ showOptional = false } = {}) {
39
39
  { value: "branch", label: "기본 브랜치" },
40
40
  ];
41
41
  if (showOptional) {
42
- options.push({ value: "deploy-publish", label: "배포/Publish 방식" });
42
+ // #483 항목별 격리: 한 축만 골라 그 축만 재질문한다 (통짜 "배포/Publish 방식" 분해)
43
+ options.push({ value: "deploy", label: "배포 방식 (서버 실행물)" });
44
+ options.push({ value: "publish", label: "라이브러리 배포(publish) 타겟" });
45
+ options.push({ value: "code-review", label: "CodeRabbit 코드 리뷰" });
46
+ options.push({ value: "changelog", label: "릴리스 노트(changelog) 생성기" });
47
+ options.push({ value: "release-branch", label: "릴리스 소스(개발) 브랜치" });
43
48
  options.push({ value: "secret", label: "Secret 백업 포함 여부" });
44
49
  }
45
50
  options.push({ value: "done", label: "모두 맞음, 계속" });
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,11 +139,11 @@ 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
- err(" 3️⃣ CodeRabbit 활성화");
166
- err(" → https://coderabbit.ai 방문하여 저장소 활성화");
145
+ err(" 3️⃣ CodeRabbit 활성화 (코드 리뷰를 켰다면)");
146
+ err(" → https://coderabbit.ai 로그인 GitHub 앱 설치 → 이 저장소에 접근 권한(grant access) 부여");
167
147
  err("");
168
148
  err(SEPARATOR);
169
149
  err("");