projectops 4.2.14 → 4.2.16
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 +26 -2
- package/src/core/copy/workflows.js +13 -0
- package/src/core/options-ask.js +19 -9
- package/src/core/orphan-workflows.js +64 -0
- package/src/core/paths-resolve.js +42 -12
- package/src/core/wizard-env.js +11 -4
- package/src/index.js +9 -0
- package/src/ui/env-plan.js +10 -6
- package/src/ui/summary.js +8 -2
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -11,7 +11,8 @@ import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName, makeRe
|
|
|
11
11
|
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
|
-
import {
|
|
14
|
+
import { detectOrphanWorkflows, applyOrphanCleanup } from "../core/orphan-workflows.js";
|
|
15
|
+
import { resolveProjectPaths, filterExcludedTypes } from "../core/paths-resolve.js";
|
|
15
16
|
import { askAllOptionalWorkflows, OPTION_AXES } from "../core/options-ask.js";
|
|
16
17
|
import { promptEnvPlan } from "../ui/env-plan.js";
|
|
17
18
|
import { listWorkflowConflicts } from "../core/copy/workflows.js";
|
|
@@ -88,6 +89,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
88
89
|
let changelogProvider = existing?.options?.changelogProvider ?? "github-ai";
|
|
89
90
|
let changelogBaseUrl = existing?.options?.changelogBaseUrl ?? "";
|
|
90
91
|
let deployBranch = existing?.options?.deployBranch ?? "develop"; // #456
|
|
92
|
+
let deployBranchReady = null; // #490 — 이번 실행에서 개발 브랜치 존재/생성이 확인됐는지
|
|
91
93
|
let intent = existing?.options?.intent ?? null; // #485 프로젝트 성격
|
|
92
94
|
const showOptional = mode === "full" || mode === "workflows";
|
|
93
95
|
const realTty = process.stdout.isTTY === true;
|
|
@@ -122,6 +124,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
122
124
|
changelogProvider = r.changelogProvider;
|
|
123
125
|
changelogBaseUrl = r.changelogBaseUrl;
|
|
124
126
|
deployBranch = r.deployBranch;
|
|
127
|
+
deployBranchReady = r.deployBranchReady ?? deployBranchReady; // #490
|
|
125
128
|
intent = r.intent;
|
|
126
129
|
}
|
|
127
130
|
|
|
@@ -185,6 +188,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
185
188
|
changelogProvider = r.changelogProvider;
|
|
186
189
|
changelogBaseUrl = r.changelogBaseUrl;
|
|
187
190
|
deployBranch = r.deployBranch;
|
|
191
|
+
deployBranchReady = r.deployBranchReady ?? deployBranchReady; // #490
|
|
188
192
|
intent = r.intent;
|
|
189
193
|
}
|
|
190
194
|
}
|
|
@@ -196,6 +200,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
196
200
|
root: cwd, types, paths, existingPaths: existing?.paths ?? new Map(),
|
|
197
201
|
force: false, tty: realTty, io: io.engineIo ?? {},
|
|
198
202
|
});
|
|
203
|
+
// 타입 탈출구 (#487) — 경로 단계에서 제외된 타입은 version.yml·복사·env 전 단계에서 뺀다
|
|
204
|
+
types = filterExcludedTypes(types, paths);
|
|
199
205
|
} else {
|
|
200
206
|
for (const t of types) if (t !== "basic" && !paths.has(t)) paths.set(t, existing?.paths.get(t) || ".");
|
|
201
207
|
}
|
|
@@ -253,6 +259,24 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
253
259
|
});
|
|
254
260
|
}
|
|
255
261
|
|
|
262
|
+
// 고아 타입 워크플로우 정리 (#487) — 타입 변경으로 선택에서 빠진 타입의 잔존 워크플로우
|
|
263
|
+
if (mode === "full" || mode === "workflows") {
|
|
264
|
+
const orphans = detectOrphanWorkflows({ tempDir, targetRoot: cwd, selectedTypes: types });
|
|
265
|
+
if (orphans.length > 0) {
|
|
266
|
+
io.note?.(
|
|
267
|
+
orphans.map((o) => `• ${o.filename} (${o.type} 타입 — 현재 미선택)`).join("\n"),
|
|
268
|
+
`🧹 선택되지 않은 타입의 워크플로우 ${orphans.length}개 발견`,
|
|
269
|
+
);
|
|
270
|
+
const yes = await io.askYesNo(`위 ${orphans.length}개를 정리할까요? (.bak 무해화 — 복원 가능)`, true);
|
|
271
|
+
if (yes === true) {
|
|
272
|
+
const results = applyOrphanCleanup(cwd, orphans);
|
|
273
|
+
const ok = results.filter((r) => r.action === "bak");
|
|
274
|
+
const failed = results.filter((r) => r.action === "error");
|
|
275
|
+
io.note?.(`✅ 고아 워크플로우 정리: ${ok.length}개${failed.length ? ` (실패 ${failed.length}개)` : ""}`, "정리 완료");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
256
280
|
let result = null;
|
|
257
281
|
if (mode === "full") result = runFull(ctx, tempDir, cwd, hooks);
|
|
258
282
|
else if (mode === "version") result = runVersion(ctx, tempDir, cwd);
|
|
@@ -264,7 +288,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
264
288
|
|
|
265
289
|
// 완료 요약 (.sh print_summary L5438)
|
|
266
290
|
io.summary?.({
|
|
267
|
-
mode, types, version, deployBranch,
|
|
291
|
+
mode, types, version, deployBranch, deployBranchReady,
|
|
268
292
|
counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
|
|
269
293
|
}, cwd);
|
|
270
294
|
io.outro?.(`통합 완료 — ${mode} 모드로 설치했습니다.`);
|
|
@@ -18,6 +18,17 @@ function configureEnv(targetPath, { type, projectPath = ".", repoName = "", reso
|
|
|
18
18
|
writeFileSync(targetPath, out);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// util 버전 동기화 워크플로우 (#491) — .github/util/ 모듈(version.json)이 있는 레포에서만 의미가 있다.
|
|
22
|
+
// util이 없는 레포(예: spring 단독)에 복사하면 트리거가 영원히 안 걸리는 no-op 오염이 된다.
|
|
23
|
+
const UTIL_VERSION_SYNC = "PROJECT-COMMON-TEMPLATE-UTIL-VERSION-SYNC.yml";
|
|
24
|
+
|
|
25
|
+
// 이 통합에서 util 모듈이 존재하게 되는가 — 대상에 이미 있거나(업데이트),
|
|
26
|
+
// 선택 타입의 util이 템플릿에 있어 곧 복사될 예정(runFull 6단계)이면 true.
|
|
27
|
+
function utilSyncApplies(tempDir, targetRoot, types) {
|
|
28
|
+
if (exists(join(targetRoot, ".github", "util"))) return true;
|
|
29
|
+
return types.some((t) => exists(join(tempDir, ".github", "util", t)));
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
// 3분류 (신규/unchanged/changed) — 대상 워크플로우 디렉토리 기준.
|
|
22
33
|
function classify(srcDir, workflowsDir, envOpts) {
|
|
23
34
|
const result = { newFiles: [], unchanged: [], changed: [] };
|
|
@@ -62,6 +73,8 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
62
73
|
const commonDir = join(projectTypesDir, "common");
|
|
63
74
|
if (exists(commonDir)) {
|
|
64
75
|
for (const filename of listYamlFiles(commonDir)) {
|
|
76
|
+
// #491 — util 동기화 워크플로우는 util 모듈이 있(게 되)는 레포에만 복사
|
|
77
|
+
if (filename === UTIL_VERSION_SYNC && !utilSyncApplies(tempDir, targetRoot, types)) continue;
|
|
65
78
|
const src = join(commonDir, filename);
|
|
66
79
|
const dst = join(workflowsDir, filename);
|
|
67
80
|
if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOptsFor("common"))) {
|
package/src/core/options-ask.js
CHANGED
|
@@ -16,34 +16,39 @@ import { branchStatus, createBranch, pushBranch } from "./git-branch.js";
|
|
|
16
16
|
// 개발(배포) 브랜치 존재 확인 + 생성 제안 (#477) — 대화형 전용.
|
|
17
17
|
// 로컬에 없고 원격에도 없(거나 불명이)면 기본 브랜치에서 생성을 제안하고, 생성 후 push 여부도 묻는다.
|
|
18
18
|
// git 미설치·비레포·질문 불가(io.confirm 없음)면 조용히 통과 — 마법사 진행을 막지 않는다.
|
|
19
|
+
// 반환 ready (#490 — 완료 요약이 "브랜치 만들어라" 재지시를 접을지 판단):
|
|
20
|
+
// true = 브랜치가 이미 있거나 이번 실행에서 생성함 → 요약에서 생성 안내 불필요
|
|
21
|
+
// false = 없는데 사용자가 거절/생성 실패 → 안내 유지
|
|
22
|
+
// null = 확인 자체를 못 함(비레포·질문 불가) → 안내 유지(보수적)
|
|
19
23
|
export async function ensureDeployBranch({ targetRoot = ".", deployBranch = "", defaultBranch = "", io = {}, say = () => {} }) {
|
|
20
|
-
if (!deployBranch || typeof io.confirm !== "function") return { created: false, pushed: false };
|
|
24
|
+
if (!deployBranch || typeof io.confirm !== "function") return { created: false, pushed: false, ready: null };
|
|
21
25
|
const st = branchStatus(targetRoot, deployBranch);
|
|
22
|
-
if (!st.isRepo
|
|
26
|
+
if (!st.isRepo) return { created: false, pushed: false, ready: null };
|
|
27
|
+
if (st.local) return { created: false, pushed: false, ready: true };
|
|
23
28
|
if (st.remote === true) {
|
|
24
29
|
say(`ℹ️ '${deployBranch}' 브랜치가 원격에는 있고 로컬에 없습니다 — 필요 시 'git switch ${deployBranch}'로 가져오세요.`);
|
|
25
|
-
return { created: false, pushed: false };
|
|
30
|
+
return { created: false, pushed: false, ready: true };
|
|
26
31
|
}
|
|
27
32
|
say(`⚠️ 개발(릴리스 소스) 브랜치 '${deployBranch}'가 없습니다 — 릴리스(${deployBranch}→${defaultBranch || "기본 브랜치"} PR)가 동작하려면 필요합니다.`);
|
|
28
33
|
const mk = await io.confirm({ message: `${defaultBranch || "현재"} 브랜치에서 '${deployBranch}' 브랜치를 만들까요?`, initialValue: true });
|
|
29
34
|
if (mk !== true) {
|
|
30
35
|
say(`→ 건너뜁니다. 나중에 직접: git checkout -b ${deployBranch} && git push -u origin ${deployBranch}`);
|
|
31
|
-
return { created: false, pushed: false };
|
|
36
|
+
return { created: false, pushed: false, ready: false };
|
|
32
37
|
}
|
|
33
38
|
if (!createBranch(targetRoot, deployBranch, defaultBranch)) {
|
|
34
39
|
say(`⚠️ 브랜치 생성 실패 — 직접 실행해주세요: git branch ${deployBranch}${defaultBranch ? ` ${defaultBranch}` : ""}`);
|
|
35
|
-
return { created: false, pushed: false };
|
|
40
|
+
return { created: false, pushed: false, ready: false };
|
|
36
41
|
}
|
|
37
42
|
say(`✅ 로컬 브랜치 '${deployBranch}' 생성 완료 (checkout은 하지 않았습니다)`);
|
|
38
43
|
// #481 — push 질문에 브랜치명 명시 ("어느 브랜치를 push하는지" 불명확 방지)
|
|
39
44
|
const up = await io.confirm({ message: `원격(origin)에 '${deployBranch}' 브랜치도 push할까요?`, initialValue: true });
|
|
40
|
-
if (up !== true) return { created: true, pushed: false };
|
|
45
|
+
if (up !== true) return { created: true, pushed: false, ready: true };
|
|
41
46
|
if (pushBranch(targetRoot, deployBranch)) {
|
|
42
47
|
say(`✅ origin/${deployBranch} push 완료`);
|
|
43
|
-
return { created: true, pushed: true };
|
|
48
|
+
return { created: true, pushed: true, ready: true };
|
|
44
49
|
}
|
|
45
50
|
say(`⚠️ push 실패(자격/네트워크) — 직접 실행해주세요: git push -u origin ${deployBranch}`);
|
|
46
|
-
return { created: true, pushed: false };
|
|
51
|
+
return { created: true, pushed: false, ready: true };
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
// 재노출 — 파서 본체는 version-yml.js에 있다 (순환 import 방지: options-ask → version-yml 방향만 허용)
|
|
@@ -115,6 +120,7 @@ export async function askAllOptionalWorkflows({
|
|
|
115
120
|
let changelogProvider = current.changelogProvider ?? null;
|
|
116
121
|
let changelogBaseUrl = current.changelogBaseUrl ?? null;
|
|
117
122
|
let deployBranch = current.deployBranch ?? null; // #456 릴리스 PR head 브랜치
|
|
123
|
+
let deployBranchReady = null; // #490 — 이번 실행에서 브랜치 존재/생성이 확인됐는지 (null=확인 안 함)
|
|
118
124
|
let intent = current.intent ?? null; // #485 프로젝트 성격 (app/library/both/none/manual)
|
|
119
125
|
|
|
120
126
|
// basic 단독 타입은 서버 배포도 라이브러리 publish도 개념상 성립하지 않는다.
|
|
@@ -328,7 +334,9 @@ export async function askAllOptionalWorkflows({
|
|
|
328
334
|
deployBranch = (typeof ans === "string" && !isCancel(ans) && ans.trim()) ? ans.trim() : (deployBranch ?? "develop");
|
|
329
335
|
say(`개발(릴리스 소스) 브랜치: ${deployBranch}`);
|
|
330
336
|
// 브랜치 존재 확인 + 생성 제안 (#477) — 없으면 릴리스 파이프라인이 조용히 놀게 된다
|
|
331
|
-
|
|
337
|
+
// #490 — 결과(ready)를 완료 요약에 전달해 이미 생성/확인한 브랜치를 재지시하지 않는다
|
|
338
|
+
const br = await ensureDeployBranch({ targetRoot, deployBranch, defaultBranch, io, say });
|
|
339
|
+
deployBranchReady = br.ready;
|
|
332
340
|
}
|
|
333
341
|
}
|
|
334
342
|
|
|
@@ -349,6 +357,8 @@ export async function askAllOptionalWorkflows({
|
|
|
349
357
|
changelogProvider: changelogProvider ?? "github-ai",
|
|
350
358
|
changelogBaseUrl: changelogBaseUrl ?? "",
|
|
351
359
|
deployBranch: deployBranch ?? "develop",
|
|
360
|
+
deployBranchReady, // #490 — true=존재/생성 확인됨, false=거절/실패, null=확인 안 함
|
|
361
|
+
|
|
352
362
|
// #485 intent — 확정값 우선, 없으면 최종 deploy/publish에서 역추론(basic·비대화형 경로 보정)
|
|
353
363
|
intent: intent ?? inferIntent(finalDeploy, finalPublish) ?? "manual",
|
|
354
364
|
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// 고아 타입 워크플로우 감지·정리 (#487) — 타입 변경으로 선택에서 빠진 타입의
|
|
2
|
+
// 템플릿 워크플로우를 감지해 .bak 무해화한다 (레거시 마이그레이션과 동일 방식).
|
|
3
|
+
// 안전 원칙: 템플릿 인벤토리와 "정확한 파일명 일치"만 대상 — prefix 매칭 금지
|
|
4
|
+
// (사용자 커스텀 워크플로우 오살 방지). common/은 타입이 아니므로 순회에서 제외.
|
|
5
|
+
import { existsSync, renameSync, rmSync, readdirSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { PATHS } from "./paths.js";
|
|
8
|
+
import { exists, listYamlFiles } from "./fsutil.js";
|
|
9
|
+
|
|
10
|
+
// 템플릿의 project-types/<type>/ 인벤토리 — 직하위 + server-deploy/ + publish/*/ (copy 엔진과 동일 범위)
|
|
11
|
+
function typeInventory(projectTypesDir, type) {
|
|
12
|
+
const typeDir = join(projectTypesDir, type);
|
|
13
|
+
const dirs = [typeDir, join(typeDir, "server-deploy")];
|
|
14
|
+
const pubRoot = join(typeDir, "publish");
|
|
15
|
+
if (exists(pubRoot)) {
|
|
16
|
+
for (const e of readdirSync(pubRoot, { withFileTypes: true })) {
|
|
17
|
+
if (e.isDirectory()) dirs.push(join(pubRoot, e.name));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const files = new Set();
|
|
21
|
+
for (const d of dirs) {
|
|
22
|
+
if (!exists(d)) continue;
|
|
23
|
+
for (const f of listYamlFiles(d)) files.add(f);
|
|
24
|
+
}
|
|
25
|
+
return files;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 선택 안 된 타입의 템플릿 워크플로우가 대상 레포에 실재하면 고아로 반환.
|
|
29
|
+
export function detectOrphanWorkflows({ tempDir, targetRoot = ".", selectedTypes = [] }) {
|
|
30
|
+
const projectTypesDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
|
|
31
|
+
if (!exists(projectTypesDir)) return [];
|
|
32
|
+
const selected = new Set(selectedTypes);
|
|
33
|
+
// 선택된 타입이 쓰는 파일명 집합 — 교차 방어 (파일명은 타입 prefix로 유일하지만 안전망)
|
|
34
|
+
const keep = new Set();
|
|
35
|
+
for (const t of selected) for (const f of typeInventory(projectTypesDir, t)) keep.add(f);
|
|
36
|
+
const workflowsDir = join(targetRoot, PATHS.workflowsDir);
|
|
37
|
+
const orphans = [];
|
|
38
|
+
for (const e of readdirSync(projectTypesDir, { withFileTypes: true })) {
|
|
39
|
+
if (!e.isDirectory() || e.name === "common" || selected.has(e.name)) continue;
|
|
40
|
+
for (const f of typeInventory(projectTypesDir, e.name)) {
|
|
41
|
+
if (keep.has(f)) continue;
|
|
42
|
+
if (existsSync(join(workflowsDir, f))) orphans.push({ filename: f, type: e.name });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return orphans.sort((a, b) => a.filename.localeCompare(b.filename));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// .bak 무해화 실행기 — 부분 실패 허용 (migrations applySafeMigrations와 동일 원칙).
|
|
49
|
+
export function applyOrphanCleanup(targetRoot, orphans) {
|
|
50
|
+
const workflowsDir = join(targetRoot, PATHS.workflowsDir);
|
|
51
|
+
const results = [];
|
|
52
|
+
for (const { filename } of orphans) {
|
|
53
|
+
try {
|
|
54
|
+
const src = join(workflowsDir, filename);
|
|
55
|
+
const bak = `${src}.bak`;
|
|
56
|
+
if (existsSync(bak)) rmSync(bak, { force: true }); // Windows rename은 대상 존재 시 실패
|
|
57
|
+
renameSync(src, bak);
|
|
58
|
+
results.push({ filename, action: "bak" });
|
|
59
|
+
} catch (err) {
|
|
60
|
+
results.push({ filename, action: "error", error: err.message });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return results;
|
|
64
|
+
}
|
|
@@ -181,7 +181,9 @@ export async function resolveProjectPaths({
|
|
|
181
181
|
continue;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
// ── ⑤-b 대화형: 후보 개수별 분기 (.sh L1492~1525) ──
|
|
184
|
+
// ── ⑤-b 대화형: 후보 개수별 분기 (.sh L1492~1525) + 타입 탈출구 (#487) ──
|
|
185
|
+
const EXCLUDE = "이 타입 제외"; // 센티넬 — 한국어 value로 노출 (기존 "직접 입력" 패턴)
|
|
186
|
+
let excluded = false;
|
|
185
187
|
if (candidates.length === 1) {
|
|
186
188
|
const cand = candidates[0];
|
|
187
189
|
const candMarker = existingMarkerInDir(t, cand === "." ? root : join(root, cand));
|
|
@@ -189,31 +191,38 @@ export async function resolveProjectPaths({
|
|
|
189
191
|
say("");
|
|
190
192
|
say(` ${prog} 🔍 ${t} — ${candMarker} 발견`);
|
|
191
193
|
say(` 위치: <레포루트>/${candFull}`);
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
const sel = await io.select({
|
|
195
|
+
message: ` ${t} 프로젝트 루트를 '${cand}'(으)로 설정할까요?`,
|
|
196
|
+
options: [
|
|
197
|
+
{ value: cand, label: `예 — '${cand}' 사용 (${candFull} 기준)` },
|
|
198
|
+
{ value: "직접 입력", label: "아니오 — 경로 직접 입력" },
|
|
199
|
+
{ value: EXCLUDE, label: `이 타입 아님 — ${t} 설치 대상에서 제외` },
|
|
200
|
+
],
|
|
196
201
|
});
|
|
197
|
-
if (
|
|
202
|
+
if (sel === EXCLUDE) excluded = true;
|
|
203
|
+
else if (!isCancel(sel) && sel != null && sel !== "직접 입력") chosen = sel;
|
|
204
|
+
// ESC/직접 입력 → 아래 직접입력 루프로
|
|
198
205
|
} else if (candidates.length > 1) {
|
|
199
206
|
say("");
|
|
200
207
|
say(` ${prog} 🔍 ${t}: 경로 후보 ${candidates.length}개 발견`);
|
|
201
|
-
// 후보들 + '직접 입력' 메뉴 — value 자체를 한국어로 (센티넬 노출 방지, .sh L1508~1521)
|
|
208
|
+
// 후보들 + '직접 입력'/'이 타입 제외' 메뉴 — value 자체를 한국어로 (센티넬 노출 방지, .sh L1508~1521)
|
|
202
209
|
const options = candidates.map((c) => ({
|
|
203
210
|
value: c,
|
|
204
211
|
label: `${c} (${existingMarkerInDir(t, c === "." ? root : join(root, c))})`,
|
|
205
212
|
}));
|
|
206
213
|
options.push({ value: "직접 입력", label: "직접 입력" });
|
|
214
|
+
options.push({ value: EXCLUDE, label: `이 타입 아님 — ${t} 설치 대상에서 제외` });
|
|
207
215
|
const sel = await io.select({ message: ` ${t} 프로젝트 루트를 선택하세요`, options });
|
|
208
216
|
// ESC(취소)도 직접 입력으로 폴백 (.sh `|| _sel="직접 입력"`)
|
|
209
|
-
if (
|
|
217
|
+
if (sel === EXCLUDE) excluded = true;
|
|
218
|
+
else if (!isCancel(sel) && sel != null && sel !== "직접 입력") chosen = sel;
|
|
210
219
|
} else {
|
|
211
220
|
say("");
|
|
212
221
|
say(` ⚠️ ${prog} ${t}: 프로젝트를 찾지 못했습니다 (maxdepth 3).`);
|
|
213
222
|
}
|
|
214
223
|
|
|
215
|
-
// ── 직접 입력 루프 (위에서 미확정 시, .sh L1528~1553) ──
|
|
216
|
-
while (!chosen) {
|
|
224
|
+
// ── 직접 입력 루프 (위에서 미확정 시, .sh L1528~1553) — 제외 탈출구 포함 (#487) ──
|
|
225
|
+
while (!chosen && !excluded) {
|
|
217
226
|
const hintMarker = existingMarkerInDir(t, root);
|
|
218
227
|
let prompt = ` ${t} 프로젝트 루트 경로 입력 (${hintMarker} 이 있는 폴더, 예: server, app — 루트면 그냥 Enter`;
|
|
219
228
|
if (existing) prompt += `, 현재값: ${existing}`;
|
|
@@ -229,11 +238,25 @@ export async function resolveProjectPaths({
|
|
|
229
238
|
chosen = input;
|
|
230
239
|
} else {
|
|
231
240
|
say(` ⚠️ ${input}/${m} 파일이 없습니다.`);
|
|
232
|
-
const
|
|
233
|
-
|
|
241
|
+
const act = await io.select({
|
|
242
|
+
message: " 어떻게 할까요?",
|
|
243
|
+
options: [
|
|
244
|
+
{ value: "retry", label: "다시 입력" },
|
|
245
|
+
{ value: "force", label: `그래도 '${input}' 경로 사용` },
|
|
246
|
+
{ value: EXCLUDE, label: `이 타입 아님 — ${t} 설치 대상에서 제외` },
|
|
247
|
+
],
|
|
248
|
+
});
|
|
249
|
+
if (act === "force") chosen = input;
|
|
250
|
+
else if (act === EXCLUDE) excluded = true;
|
|
251
|
+
// retry/ESC → 루프 계속
|
|
234
252
|
}
|
|
235
253
|
}
|
|
236
254
|
|
|
255
|
+
if (excluded) {
|
|
256
|
+
say(` ➖ ${t} 제외 — 이 타입은 버전 동기화·워크플로우 설치 대상에서 빠집니다`);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
237
260
|
result.set(t, chosen);
|
|
238
261
|
say(` ✅ ${t} → ${chosen}`);
|
|
239
262
|
}
|
|
@@ -259,3 +282,10 @@ export async function resolveProjectPaths({
|
|
|
259
282
|
say("");
|
|
260
283
|
return result;
|
|
261
284
|
}
|
|
285
|
+
|
|
286
|
+
// 경로 단계에서 제외된 타입 반영 (#487) — basic이 아니면서 paths에 없는 타입 제거.
|
|
287
|
+
// 전부 제외되면 basic 폴백 (타입 0개 상태 금지). 비대화형은 제외가 불가능하므로 no-op.
|
|
288
|
+
export function filterExcludedTypes(types, paths) {
|
|
289
|
+
const kept = types.filter((t) => t === "basic" || paths.has(t));
|
|
290
|
+
return kept.length ? kept : ["basic"];
|
|
291
|
+
}
|
package/src/core/wizard-env.js
CHANGED
|
@@ -41,6 +41,13 @@ export function resolveToken(name, type, resolvers = {}) {
|
|
|
41
41
|
return typeof fn === "function" ? (fn(type) ?? "") : "";
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// 잔여 전역 토큰 치환 (.sh 3347~3351) — 파일 본문·수집값(#489)·카드 표시가 전부 같은 규칙을 쓴다.
|
|
45
|
+
// 파일에 실제 써지는 값과 version.yml deploy 블록 기억값이 어긋나지 않게 하는 단일 지점.
|
|
46
|
+
export function resolveGlobalTokens(s, repoName = "") {
|
|
47
|
+
if (typeof s !== "string" || (!s.includes("__PROJECT_NAME__") && !s.includes("__APP_ARTIFACT_NAME__"))) return s;
|
|
48
|
+
return s.replaceAll("__PROJECT_NAME__", repoName).replaceAll("__APP_ARTIFACT_NAME__", repoName);
|
|
49
|
+
}
|
|
50
|
+
|
|
44
51
|
// 파일 전체 치환 (configure_workflow_env 등가).
|
|
45
52
|
// content: 원본 워크플로우 텍스트. 반환: 치환된 텍스트.
|
|
46
53
|
// opts:
|
|
@@ -70,16 +77,16 @@ export function substituteEnv(content, opts = {}) {
|
|
|
70
77
|
if (chosen != null && chosen !== "" && !useDefaults) val = chosen;
|
|
71
78
|
else val = def;
|
|
72
79
|
// ask 키만 수집 (.sh wf_deploy_set — auto는 저장 안 함). deploy 블록용.
|
|
73
|
-
|
|
80
|
+
// #489 — 파일 본문은 아래 전역 토큰 치환을 거치므로 수집값도 동일 치환해
|
|
81
|
+
// version.yml deploy 블록이 설치본과 항상 바이트 일치하게 한다.
|
|
82
|
+
if (collectAsks) collectAsks.set(p.key, resolveGlobalTokens(val, repoName));
|
|
74
83
|
}
|
|
75
84
|
lines[i] = setEnvLine(lines[i], p.key, val);
|
|
76
85
|
}
|
|
77
86
|
let out = lines.join(usesCRLF ? "\r\n" : "\n");
|
|
78
87
|
|
|
79
88
|
// 잔여 전역 토큰 (.sh 3347~3351)
|
|
80
|
-
|
|
81
|
-
out = out.replaceAll("__PROJECT_NAME__", repoName).replaceAll("__APP_ARTIFACT_NAME__", repoName);
|
|
82
|
-
}
|
|
89
|
+
out = resolveGlobalTokens(out, repoName);
|
|
83
90
|
|
|
84
91
|
// paths-anchor (.sh 3353~3360): 경로가 '.'이 아니면 주석 라인 전체를 paths 라인으로 교체
|
|
85
92
|
if (PATHS_ANCHOR_RE.test(out) && projectPath && projectPath !== ".") {
|
package/src/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { detectTypes, detectVersion, detectDefaultBranch, detectRepoName, makeRe
|
|
|
13
13
|
import { parseExisting } from "./core/version-yml.js";
|
|
14
14
|
import { runBreakingCheck } from "./core/breaking-check.js";
|
|
15
15
|
import { runMigrations } from "./core/migrations/index.js";
|
|
16
|
+
import { detectOrphanWorkflows } from "./core/orphan-workflows.js";
|
|
16
17
|
import { resolveProjectPaths } from "./core/paths-resolve.js";
|
|
17
18
|
import { printBannerCompact } from "./ui/banner.js";
|
|
18
19
|
import { printSummary } from "./ui/summary.js";
|
|
@@ -150,6 +151,14 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
|
|
|
150
151
|
await runMigrations({ targetRoot: cwd });
|
|
151
152
|
}
|
|
152
153
|
|
|
154
|
+
// 고아 타입 워크플로우 안내 (#487) — 비대화형은 자동 무해화 금지(배포 파이프라인일 수 있음), 안내만
|
|
155
|
+
if (opts.mode === "full" || opts.mode === "workflows") {
|
|
156
|
+
const orphans = detectOrphanWorkflows({ tempDir, targetRoot: cwd, selectedTypes: types });
|
|
157
|
+
for (const o of orphans) {
|
|
158
|
+
console.error(`⚠️ 선택되지 않은 타입(${o.type})의 워크플로우가 남아있습니다: ${o.filename} — 대화형 마법사(npx projectops)에서 정리할 수 있습니다.`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
153
162
|
switch (opts.mode) {
|
|
154
163
|
case "full": result = runFull(context, tempDir, cwd); break;
|
|
155
164
|
case "version": result = runVersion(context, tempDir, cwd); break;
|
package/src/ui/env-plan.js
CHANGED
|
@@ -8,7 +8,7 @@ import { readFileSync } from "node:fs";
|
|
|
8
8
|
import { stdin, stderr } from "node:process";
|
|
9
9
|
import { PATHS } from "../core/paths.js";
|
|
10
10
|
import { exists, listYamlFiles } from "../core/fsutil.js";
|
|
11
|
-
import { parseWizardLine, resolveToken } from "../core/wizard-env.js";
|
|
11
|
+
import { parseWizardLine, resolveToken, resolveGlobalTokens } from "../core/wizard-env.js";
|
|
12
12
|
import { loadWizardPrompts, wfField, workflowDisplayName } from "../core/wizard-labels.js";
|
|
13
13
|
import * as engine from "./readline-engine.js";
|
|
14
14
|
|
|
@@ -39,7 +39,7 @@ export function scopeString(usages = []) {
|
|
|
39
39
|
// 반환: { keys:[], defaults:Map<key,default>, typeDefaults:Map<"type|key",default>,
|
|
40
40
|
// usages:Map<key,[{type,workflowName}]> }
|
|
41
41
|
export function collectAsks(tempDir, types = [], opts = {}) {
|
|
42
|
-
const { resolvers = {}, deployTarget = "docker-ssh", publishTargets = [], prompts = null } = opts;
|
|
42
|
+
const { resolvers = {}, deployTarget = "docker-ssh", publishTargets = [], prompts = null, repoName = "" } = opts;
|
|
43
43
|
const baseDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
|
|
44
44
|
const keys = [];
|
|
45
45
|
const defaults = new Map();
|
|
@@ -64,9 +64,13 @@ export function collectAsks(tempDir, types = [], opts = {}) {
|
|
|
64
64
|
const p = parseWizardLine(line); // KEY 정규식 [A-Z_]+ (.sh와 동일)
|
|
65
65
|
if (!p || p.action !== "ask") continue;
|
|
66
66
|
// 타입별 기본값: @접두면 resolver 해석, 아니면 리터럴 (.sh _type_default 등가)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
// #489 — __PROJECT_NAME__ 등 전역 토큰은 카드 표시 전에 미리 해석한다.
|
|
68
|
+
// 설치본(파일 복사 시 전역 치환)과 동일한 값을 보여주지 않으면
|
|
69
|
+
// 사용자가 "리터럴이 그대로 박히나?" 오해하게 된다.
|
|
70
|
+
const typeDefault = resolveGlobalTokens(
|
|
71
|
+
p.arg.startsWith("@") ? resolveToken(p.arg.slice(1), type, resolvers) : p.arg,
|
|
72
|
+
repoName,
|
|
73
|
+
);
|
|
70
74
|
typeDefaults.set(`${type}|${p.key}`, typeDefault);
|
|
71
75
|
if (!defaults.has(p.key)) { keys.push(p.key); defaults.set(p.key, typeDefault); }
|
|
72
76
|
const list = usages.get(p.key) || [];
|
|
@@ -140,7 +144,7 @@ export async function promptEnvPlan({
|
|
|
140
144
|
deployTarget = "docker-ssh", publishTargets = [], targetRoot = ".", repoName = "", log = defaultLog,
|
|
141
145
|
} = {}) {
|
|
142
146
|
const prompts = loadWizardPrompts(targetRoot, tempDir);
|
|
143
|
-
const asks = collectAsks(tempDir, types, { resolvers, deployTarget, publishTargets, prompts });
|
|
147
|
+
const asks = collectAsks(tempDir, types, { resolvers, deployTarget, publishTargets, prompts, repoName });
|
|
144
148
|
const defaults = asks.defaults;
|
|
145
149
|
|
|
146
150
|
// 수집 키 0개 → 질문 자체가 없음 (.sh `[ ${#WF_ASK_KEYS[@]} -eq 0 ]` 등가)
|
package/src/ui/summary.js
CHANGED
|
@@ -9,6 +9,7 @@ const SEPARATOR = "────────────────────
|
|
|
9
9
|
export function printSummary(ctx, targetRoot = ".") {
|
|
10
10
|
const { mode, types = [], version = "", counters = {} } = ctx || {};
|
|
11
11
|
const deployBranchName = ctx?.deployBranch || "develop"; // #477 — 설정된 배포 브랜치명으로 안내
|
|
12
|
+
const deployBranchReady = ctx?.deployBranchReady === true; // #490 — 마법사가 이번 실행에서 존재/생성 확인함
|
|
12
13
|
const err = (s = "") => process.stderr.write(`${s}\n`);
|
|
13
14
|
// 색상은 TTY일 때만 (.sh YELLOW/CYAN/NC 등가)
|
|
14
15
|
const isTty = !!process.stderr.isTTY;
|
|
@@ -139,8 +140,13 @@ export function printSummary(ctx, targetRoot = ".") {
|
|
|
139
140
|
err(" → Secret Name: _GITHUB_PAT_TOKEN");
|
|
140
141
|
err(" → Scopes: repo, workflow");
|
|
141
142
|
err("");
|
|
142
|
-
|
|
143
|
-
|
|
143
|
+
// #490 — 마법사가 브랜치를 직접 생성(또는 존재 확인)했으면 같은 작업을 재지시하지 않는다
|
|
144
|
+
if (deployBranchReady) {
|
|
145
|
+
err(` 2️⃣ ✅ ${deployBranchName} 브랜치 준비 완료 — 마법사가 확인·생성했습니다 (추가 작업 불필요)`);
|
|
146
|
+
} else {
|
|
147
|
+
err(` 2️⃣ ${deployBranchName} 브랜치 생성 (아직 없다면)`);
|
|
148
|
+
err(` → git checkout -b ${deployBranchName} && git push -u origin ${deployBranchName}`);
|
|
149
|
+
}
|
|
144
150
|
err("");
|
|
145
151
|
err(" 3️⃣ CodeRabbit 활성화 (코드 리뷰를 켰다면)");
|
|
146
152
|
err(" → https://coderabbit.ai 로그인 → GitHub 앱 설치 → 이 저장소에 접근 권한(grant access) 부여");
|