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 +1 -1
- package/package.json +1 -1
- package/src/commands/interactive.js +8 -11
- package/src/core/branch-sub.js +41 -0
- package/src/core/breaking-check.js +23 -8
- package/src/core/copy/simple.js +2 -0
- package/src/core/copy/workflows.js +29 -8
- package/src/core/detect-fs.js +3 -0
- package/src/core/git-branch.js +34 -0
- package/src/core/migrations/index.js +42 -8
- package/src/core/migrations/registry.js +19 -3
- package/src/core/migrations/rules/legacy-dirs.js +36 -0
- package/src/core/migrations/rules/obsolete-workflows.js +10 -1
- package/src/core/migrations/rules/settings-extractors.js +65 -0
- package/src/core/options-ask.js +94 -27
- package/src/core/version-yml.js +13 -2
- package/src/core/wizard-env.js +7 -1
- package/src/index.js +2 -2
- package/src/ui/prompts.js +6 -1
- package/src/ui/summary.js +15 -35
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -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
|
|
163
|
-
//
|
|
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
|
-
//
|
|
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) {
|
package/src/core/copy/simple.js
CHANGED
|
@@ -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
|
|
package/src/core/detect-fs.js
CHANGED
|
@@ -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"
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
};
|
package/src/core/options-ask.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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: "
|
|
174
|
+
{ value: "none", label: "서버에 올리지 않음 (빌드 검증만 · 라이브러리는 다음에서 선택)" },
|
|
122
175
|
],
|
|
123
176
|
});
|
|
124
177
|
deploy = (!isCancel(ans) && DEPLOY_TARGETS.includes(ans)) ? ans : (deploy ?? "docker-ssh");
|
|
125
|
-
say(
|
|
178
|
+
say(`실행물 배포: ${deploy}`);
|
|
126
179
|
}
|
|
127
180
|
}
|
|
128
181
|
|
|
129
182
|
// ── ③ publish 타겟 (다중 선택) ──
|
|
130
|
-
if (
|
|
183
|
+
if (willAskPublish) {
|
|
131
184
|
if (force || !tty || typeof io.multiselect !== "function") {
|
|
132
185
|
publish = publish ?? [];
|
|
133
186
|
} else {
|
|
134
187
|
say("");
|
|
135
|
-
say("📦
|
|
136
|
-
say("
|
|
188
|
+
say("📦 (2) 이 프로젝트를 남이 가져다 쓰는 라이브러리로도 배포(publish)하나요?");
|
|
189
|
+
say(" (1) 실행물 배포와 별개입니다. 해당되는 걸 고르고, 라이브러리 배포를 안 하면 아무것도 고르지 말고 Enter.");
|
|
137
190
|
const ans = await io.multiselect({
|
|
138
|
-
message: "
|
|
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(
|
|
203
|
+
say(publish.length ? `라이브러리 배포: ${publish.join(", ")}` : "라이브러리 배포: 안 함");
|
|
151
204
|
}
|
|
152
205
|
}
|
|
153
206
|
}
|
|
154
207
|
|
|
155
208
|
// ── code_review: CodeRabbit AI 코드 리뷰 (changelog와 무관 — #455) ──
|
|
156
|
-
if (
|
|
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 (
|
|
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" && (
|
|
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
|
-
// ──
|
|
204
|
-
//
|
|
205
|
-
|
|
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("🌿
|
|
211
|
-
say(
|
|
212
|
-
|
|
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(
|
|
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 {
|
package/src/core/version-yml.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
package/src/core/wizard-env.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
// 실제
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
const
|
|
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 (
|
|
101
|
-
err(` 📦 새로
|
|
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
|
-
|
|
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(
|
|
163
|
-
err(
|
|
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("");
|