projectops 4.2.14 → 4.2.15
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 +22 -1
- package/src/core/orphan-workflows.js +64 -0
- package/src/core/paths-resolve.js +42 -12
- package/src/index.js +9 -0
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";
|
|
@@ -196,6 +197,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
196
197
|
root: cwd, types, paths, existingPaths: existing?.paths ?? new Map(),
|
|
197
198
|
force: false, tty: realTty, io: io.engineIo ?? {},
|
|
198
199
|
});
|
|
200
|
+
// 타입 탈출구 (#487) — 경로 단계에서 제외된 타입은 version.yml·복사·env 전 단계에서 뺀다
|
|
201
|
+
types = filterExcludedTypes(types, paths);
|
|
199
202
|
} else {
|
|
200
203
|
for (const t of types) if (t !== "basic" && !paths.has(t)) paths.set(t, existing?.paths.get(t) || ".");
|
|
201
204
|
}
|
|
@@ -253,6 +256,24 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
253
256
|
});
|
|
254
257
|
}
|
|
255
258
|
|
|
259
|
+
// 고아 타입 워크플로우 정리 (#487) — 타입 변경으로 선택에서 빠진 타입의 잔존 워크플로우
|
|
260
|
+
if (mode === "full" || mode === "workflows") {
|
|
261
|
+
const orphans = detectOrphanWorkflows({ tempDir, targetRoot: cwd, selectedTypes: types });
|
|
262
|
+
if (orphans.length > 0) {
|
|
263
|
+
io.note?.(
|
|
264
|
+
orphans.map((o) => `• ${o.filename} (${o.type} 타입 — 현재 미선택)`).join("\n"),
|
|
265
|
+
`🧹 선택되지 않은 타입의 워크플로우 ${orphans.length}개 발견`,
|
|
266
|
+
);
|
|
267
|
+
const yes = await io.askYesNo(`위 ${orphans.length}개를 정리할까요? (.bak 무해화 — 복원 가능)`, true);
|
|
268
|
+
if (yes === true) {
|
|
269
|
+
const results = applyOrphanCleanup(cwd, orphans);
|
|
270
|
+
const ok = results.filter((r) => r.action === "bak");
|
|
271
|
+
const failed = results.filter((r) => r.action === "error");
|
|
272
|
+
io.note?.(`✅ 고아 워크플로우 정리: ${ok.length}개${failed.length ? ` (실패 ${failed.length}개)` : ""}`, "정리 완료");
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
256
277
|
let result = null;
|
|
257
278
|
if (mode === "full") result = runFull(ctx, tempDir, cwd, hooks);
|
|
258
279
|
else if (mode === "version") result = runVersion(ctx, tempDir, cwd);
|
|
@@ -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/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;
|