projectops 4.2.13 → 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/cli/args.js +11 -0
- package/src/cli/help.js +2 -0
- package/src/commands/full.js +2 -2
- package/src/commands/interactive.js +28 -3
- package/src/context.js +1 -0
- package/src/core/options-ask.js +64 -10
- package/src/core/orphan-workflows.js +64 -0
- package/src/core/paths-resolve.js +42 -12
- package/src/core/version-yml.js +29 -2
- package/src/index.js +23 -3
- package/src/ui/prompts.js +2 -0
package/README.md
CHANGED
package/package.json
CHANGED
package/src/cli/args.js
CHANGED
|
@@ -3,6 +3,7 @@ import { VALID_TYPES } from "../context.js";
|
|
|
3
3
|
|
|
4
4
|
export const DEPLOY_TARGETS = ["docker-ssh", "vercel", "none"];
|
|
5
5
|
export const PUBLISH_TARGETS = ["nexus", "npm", "github-packages"];
|
|
6
|
+
export const INTENT_VALUES = ["app", "library", "both", "none", "manual"];
|
|
6
7
|
|
|
7
8
|
// argv(process.argv.slice(2)) → 파싱 결과. 오류 시 throw(호출부에서 exit 1).
|
|
8
9
|
export function parseArgs(argv) {
|
|
@@ -14,6 +15,7 @@ export function parseArgs(argv) {
|
|
|
14
15
|
deployTarget: null, // 배포 축 (#439): docker-ssh|vercel|none, null=미설정
|
|
15
16
|
publishTargets: null, // publish 축 (#439): 타겟 배열, null=미설정
|
|
16
17
|
deployBranch: "", // 릴리스 PR head 브랜치 (#456): --deploy-branch, 빈 값=미지정
|
|
18
|
+
intent: null, // 프로젝트 성격 (#485): --intent app|library|both|none|manual, null=미설정(역추론)
|
|
17
19
|
includeSecretBackup: null,
|
|
18
20
|
pathsCsv: "", // "flutter=app,react=client" 원문 (정규화는 resolve 단계)
|
|
19
21
|
force: false,
|
|
@@ -79,6 +81,15 @@ export function parseArgs(argv) {
|
|
|
79
81
|
if (v) result.deployBranch = v;
|
|
80
82
|
break;
|
|
81
83
|
}
|
|
84
|
+
case "--intent": {
|
|
85
|
+
// 프로젝트 성격 (#485). 미지정이면 --deploy/--publish에서 역추론.
|
|
86
|
+
const v = (args.shift() ?? "").trim();
|
|
87
|
+
if (!INTENT_VALUES.includes(v)) {
|
|
88
|
+
throw new CliError(`--intent 값은 ${INTENT_VALUES.join(" | ")} 중 하나여야 합니다: '${v}'`);
|
|
89
|
+
}
|
|
90
|
+
result.intent = v;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
82
93
|
// ── deprecated alias (1 minor 유지 — #439) ──
|
|
83
94
|
case "--nexus":
|
|
84
95
|
process.stderr.write("⚠️ --nexus는 deprecated입니다. --publish nexus --deploy none 을 사용하세요.\n");
|
package/src/cli/help.js
CHANGED
|
@@ -13,6 +13,8 @@ export const HELP_TEXT = `projectops — GitHub 프로젝트 자동화 템플릿
|
|
|
13
13
|
(next는 react로 흡수됨 — Next.js 프로젝트는 react 사용)
|
|
14
14
|
--project-version V 통합 대상의 초기 버전 (예: 1.0.0). 미지정 시 자동 감지
|
|
15
15
|
--paths "t=p,..." 타입별 프로젝트 경로 (모노레포). 예: flutter=app,react=client
|
|
16
|
+
--intent KIND 프로젝트 성격(#485): app | library | both | none | manual
|
|
17
|
+
(미지정 시 --deploy/--publish에서 역추론. library/none→deploy 제외, app/none→publish 제외)
|
|
16
18
|
--deploy TARGET 배포 방식 택1: docker-ssh(기본) | vercel | none
|
|
17
19
|
--publish CSV publish 타겟 csv: nexus,npm,github-packages (기본: 없음)
|
|
18
20
|
--deploy-branch NAME 릴리스 PR head 브랜치 (#456, 기본: develop). default_branch와 별개
|
package/src/commands/full.js
CHANGED
|
@@ -24,7 +24,7 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
24
24
|
force = true, now, today, templateVersion = "unknown",
|
|
25
25
|
deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false,
|
|
26
26
|
changelogProvider = "github-ai", changelogBaseUrl = "", codeReviewCoderabbit = true,
|
|
27
|
-
deployBranch = "" } = context;
|
|
27
|
+
deployBranch = "", intent = null } = context;
|
|
28
28
|
|
|
29
29
|
// project_paths 마커 계산 (.sh existing_marker_in_dir 등가 — 대표 마커명)
|
|
30
30
|
const pathMarkers = new Map();
|
|
@@ -41,7 +41,7 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
41
41
|
version, types, paths, pathMarkers, branch, deployBranch, versionCode, now, today,
|
|
42
42
|
deployValues,
|
|
43
43
|
templateOptions: { templateVersion, deployTarget, publishTargets, includeSecretBackup, optionsDate: today,
|
|
44
|
-
changelogProvider, changelogBaseUrl, codeReviewCoderabbit },
|
|
44
|
+
changelogProvider, changelogBaseUrl, codeReviewCoderabbit, intent },
|
|
45
45
|
}));
|
|
46
46
|
|
|
47
47
|
// 2. README 버전 섹션
|
|
@@ -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 intent = existing?.options?.intent ?? null; // #485 프로젝트 성격
|
|
91
93
|
const showOptional = mode === "full" || mode === "workflows";
|
|
92
94
|
const realTty = process.stdout.isTTY === true;
|
|
93
95
|
|
|
@@ -104,6 +106,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
104
106
|
changelogProvider: existing?.options?.changelogProvider ?? null,
|
|
105
107
|
changelogBaseUrl: existing?.options?.changelogBaseUrl ?? null,
|
|
106
108
|
deployBranch: existing?.options?.deployBranch ?? null,
|
|
109
|
+
intent: existing?.options?.intent ?? null,
|
|
107
110
|
},
|
|
108
111
|
force: false, tty: realTty,
|
|
109
112
|
io: {
|
|
@@ -120,6 +123,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
120
123
|
changelogProvider = r.changelogProvider;
|
|
121
124
|
changelogBaseUrl = r.changelogBaseUrl;
|
|
122
125
|
deployBranch = r.deployBranch;
|
|
126
|
+
intent = r.intent;
|
|
123
127
|
}
|
|
124
128
|
|
|
125
129
|
// 확인/수정 루프 — ESC는 '머무르기' (.sh L1877~1881: 명시적 '아니오'만 종료)
|
|
@@ -165,7 +169,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
165
169
|
tempDir, types, targetRoot: cwd, defaultBranch: branch,
|
|
166
170
|
current: {
|
|
167
171
|
deploy: deployTarget, publish: publishTargets, secretBackup: includeSecretBackup,
|
|
168
|
-
codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch,
|
|
172
|
+
codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch, intent,
|
|
169
173
|
},
|
|
170
174
|
force: false, tty: realTty, forceAsk: true, scope: [what],
|
|
171
175
|
io: {
|
|
@@ -182,6 +186,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
182
186
|
changelogProvider = r.changelogProvider;
|
|
183
187
|
changelogBaseUrl = r.changelogBaseUrl;
|
|
184
188
|
deployBranch = r.deployBranch;
|
|
189
|
+
intent = r.intent;
|
|
185
190
|
}
|
|
186
191
|
}
|
|
187
192
|
}
|
|
@@ -192,6 +197,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
192
197
|
root: cwd, types, paths, existingPaths: existing?.paths ?? new Map(),
|
|
193
198
|
force: false, tty: realTty, io: io.engineIo ?? {},
|
|
194
199
|
});
|
|
200
|
+
// 타입 탈출구 (#487) — 경로 단계에서 제외된 타입은 version.yml·복사·env 전 단계에서 뺀다
|
|
201
|
+
types = filterExcludedTypes(types, paths);
|
|
195
202
|
} else {
|
|
196
203
|
for (const t of types) if (t !== "basic" && !paths.has(t)) paths.set(t, existing?.paths.get(t) || ".");
|
|
197
204
|
}
|
|
@@ -211,7 +218,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
211
218
|
const { now, today } = clock || utcNow();
|
|
212
219
|
const ctx = createContext({
|
|
213
220
|
mode, force: true, types, version, versionCode, branch, paths, deployTarget, publishTargets, includeSecretBackup,
|
|
214
|
-
codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch,
|
|
221
|
+
codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch, intent,
|
|
215
222
|
repoName, templateVersion, resolvers, envValues, envUseDefaults, now, today,
|
|
216
223
|
});
|
|
217
224
|
ctx.templateVersion = templateVersion;
|
|
@@ -249,6 +256,24 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
249
256
|
});
|
|
250
257
|
}
|
|
251
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
|
+
|
|
252
277
|
let result = null;
|
|
253
278
|
if (mode === "full") result = runFull(ctx, tempDir, cwd, hooks);
|
|
254
279
|
else if (mode === "version") result = runVersion(ctx, tempDir, cwd);
|
package/src/context.js
CHANGED
|
@@ -24,6 +24,7 @@ export function createContext(overrides = {}) {
|
|
|
24
24
|
changelogBaseUrl: null, // ollama일 때만 값
|
|
25
25
|
codeReviewCoderabbit: null,
|
|
26
26
|
deployBranch: "", // 릴리스 PR head 브랜치 (#456). 빈 값=metadata.deploy_branch 미출력
|
|
27
|
+
intent: null, // 프로젝트 성격 (#485 — app/library/both/none/manual). null=미설정(역추론)
|
|
27
28
|
templateVersion: "",
|
|
28
29
|
tempDir: "",
|
|
29
30
|
deployValues: new Map(), // "type.KEY" -> value
|
package/src/core/options-ask.js
CHANGED
|
@@ -10,7 +10,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { listYamlFiles } from "./fsutil.js";
|
|
12
12
|
import { PATHS } from "./paths.js";
|
|
13
|
-
import { parseTemplateOptions } from "./version-yml.js";
|
|
13
|
+
import { parseTemplateOptions, inferIntent } from "./version-yml.js";
|
|
14
14
|
import { branchStatus, createBranch, pushBranch } from "./git-branch.js";
|
|
15
15
|
|
|
16
16
|
// 개발(배포) 브랜치 존재 확인 + 생성 제안 (#477) — 대화형 전용.
|
|
@@ -87,7 +87,12 @@ async function askOptionalWorkflow({ dir, icon, short, desc, current, force, tty
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
// 옵션 질문의 축 키 (#483 수정 스코프 단위) — 수정 메뉴가 이 단위로 한 축만 재질문한다.
|
|
90
|
-
|
|
90
|
+
// intent(#485)는 프로젝트 성격 — 재질문 시 deploy/publish 축을 재유도한다.
|
|
91
|
+
export const OPTION_AXES = ["intent", "deploy", "publish", "code-review", "changelog", "release-branch", "secret"];
|
|
92
|
+
|
|
93
|
+
// intent → 어떤 배포 축을 물을지 (#485). manual은 둘 다(기존 순차 질문), 나머지는 유도.
|
|
94
|
+
const INTENT_ASKS_DEPLOY = { app: true, both: true, manual: true, library: false, none: false };
|
|
95
|
+
const INTENT_ASKS_PUBLISH = { library: true, both: true, manual: true, app: false, none: false };
|
|
91
96
|
|
|
92
97
|
// 배포/publish 축 + Secret 백업을 순서대로 질문 (.sh ask_all_optional_workflows 등가).
|
|
93
98
|
// tempDir: 템플릿 다운로드 루트 — project-types는 {tempDir}/.github/workflows/project-types
|
|
@@ -110,6 +115,7 @@ export async function askAllOptionalWorkflows({
|
|
|
110
115
|
let changelogProvider = current.changelogProvider ?? null;
|
|
111
116
|
let changelogBaseUrl = current.changelogBaseUrl ?? null;
|
|
112
117
|
let deployBranch = current.deployBranch ?? null; // #456 릴리스 PR head 브랜치
|
|
118
|
+
let intent = current.intent ?? null; // #485 프로젝트 성격 (app/library/both/none/manual)
|
|
113
119
|
|
|
114
120
|
// basic 단독 타입은 서버 배포도 라이브러리 publish도 개념상 성립하지 않는다.
|
|
115
121
|
// 배포/publish 질문을 건너뛰고 none·[]로 조용히 확정한다 (타입 변경 시 재질문됨).
|
|
@@ -140,24 +146,68 @@ export async function askAllOptionalWorkflows({
|
|
|
140
146
|
if (changelogBaseUrl === null && saved.changelogBaseUrl !== null) changelogBaseUrl = saved.changelogBaseUrl;
|
|
141
147
|
// #456 deploy_branch 저장값 재사용
|
|
142
148
|
if (deployBranch === null && saved.deployBranch != null) deployBranch = saved.deployBranch;
|
|
149
|
+
// #485 intent 저장값 재사용 — 없으면 저장된 deploy/publish에서 역추론(구 version.yml 하위호환)
|
|
150
|
+
if (intent === null) intent = saved.intent ?? inferIntent(saved.deploy, saved.publish);
|
|
143
151
|
}
|
|
144
152
|
}
|
|
145
153
|
|
|
146
|
-
// ── ②
|
|
154
|
+
// ── ② intent(프로젝트 성격) 우선 분기 → 배포 축 유도 (#485) ──
|
|
155
|
+
// basic 단독이면 intent 개념 자체가 없어 질문 스킵, none/[]로 확정.
|
|
147
156
|
if (isBasicOnly) {
|
|
148
157
|
if (deploy === null) deploy = "none";
|
|
149
158
|
if (publish === null) publish = [];
|
|
159
|
+
intent = "none";
|
|
150
160
|
} else {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
161
|
+
// 수정 메뉴에서 deploy/publish 축만 콕 집어 고칠 때(scope에 그 축이 있고 intent는 없음)는
|
|
162
|
+
// intent 질문·게이팅을 건너뛰고 그 축만 바로 편집한다 (#483 격리 존중). intent는 값 유지·역추론.
|
|
163
|
+
const scopedAxisOnly = scopeSet !== null && !scopeSet.has("intent");
|
|
164
|
+
if (scopedAxisOnly) {
|
|
165
|
+
if (intent === null) intent = inferIntent(deploy, publish) ?? "manual";
|
|
166
|
+
} else {
|
|
167
|
+
// intent 질문: 미확정이거나(초기 통합) intent 축을 강제 재질문(수정 메뉴 "프로젝트 성격")일 때.
|
|
168
|
+
const askIntent = ask("intent") || intent === null;
|
|
169
|
+
if (askIntent) {
|
|
170
|
+
if (force || !tty || typeof io.select !== "function") {
|
|
171
|
+
// 비대화형: CLI intent 미지정이면 deploy/publish에서 역추론, 그래도 없으면 manual(기존 동작)
|
|
172
|
+
intent = intent ?? inferIntent(deploy, publish) ?? "manual";
|
|
173
|
+
} else {
|
|
174
|
+
say("");
|
|
175
|
+
say("🧭 이 프로젝트는 어떤 성격인가요? (배포 관련 질문을 여기에 맞춰 좁혀드립니다)");
|
|
176
|
+
const ans = await io.select({
|
|
177
|
+
message: "프로젝트 성격을 선택하세요",
|
|
178
|
+
options: [
|
|
179
|
+
{ value: "app", label: "서버/호스팅에 올려 돌리는 앱·서비스 (배포만)" },
|
|
180
|
+
{ value: "library", label: "남이 가져다 쓰는 라이브러리/패키지 (publish만)" },
|
|
181
|
+
{ value: "both", label: "둘 다 (서버로도 돌리고 라이브러리로도 냄)" },
|
|
182
|
+
{ value: "none", label: "배포 안 함 (CI·빌드 검증만)" },
|
|
183
|
+
{ value: "manual", label: "직접 하나씩 고를게요 (모든 옵션 개별 질문)" },
|
|
184
|
+
],
|
|
185
|
+
});
|
|
186
|
+
intent = (!isCancel(ans) && INTENT_ASKS_DEPLOY[ans] !== undefined) ? ans : (intent ?? "manual");
|
|
187
|
+
say(`프로젝트 성격: ${intent}`);
|
|
188
|
+
}
|
|
189
|
+
// intent 확정 후 유도: 안 묻는 축은 값 확정, 묻는 축은 재질문 대상으로 되돌린다.
|
|
190
|
+
if (!INTENT_ASKS_DEPLOY[intent]) deploy = "none";
|
|
191
|
+
if (!INTENT_ASKS_PUBLISH[intent]) publish = [];
|
|
192
|
+
if (INTENT_ASKS_DEPLOY[intent] && ask("intent")) deploy = null;
|
|
193
|
+
if (INTENT_ASKS_PUBLISH[intent] && ask("intent")) publish = null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// intent가 정해진 뒤, 유도 규칙에 따라 각 축을 물을지 결정 (#485).
|
|
197
|
+
// scopedAxisOnly(수정 메뉴 단일 축)면 intent 게이트를 무시하고 그 축을 편집하게 한다.
|
|
198
|
+
const deployGate = scopedAxisOnly ? true : INTENT_ASKS_DEPLOY[intent];
|
|
199
|
+
const publishGate = scopedAxisOnly ? true : INTENT_ASKS_PUBLISH[intent];
|
|
200
|
+
const willAskDeploy = deployGate && (ask("deploy") || deploy === null);
|
|
201
|
+
const willAskPublish = publishGate && (ask("publish") || publish === null);
|
|
202
|
+
// 유도로 안 묻는 축은 기본값 확정.
|
|
203
|
+
if (!deployGate && deploy === null) deploy = "none";
|
|
204
|
+
if (!publishGate && publish === null) publish = [];
|
|
205
|
+
// manual(직접 고르기)에서 둘 다 물을 땐 두 축이 별개임을 한 번 안내 (#480 계승).
|
|
206
|
+
if (intent === "manual" && willAskDeploy && willAskPublish && tty && typeof io.select === "function") {
|
|
156
207
|
say("");
|
|
157
208
|
say("🧭 배포는 두 가지가 따로 있습니다 — 서로 독립이라 각각 답하시면 됩니다:");
|
|
158
209
|
say(" 1) 실행물 배포 — 서버/호스팅에 올려 돌리는 것 (Docker, Vercel …)");
|
|
159
210
|
say(" 2) 라이브러리 배포(publish) — 남이 가져다 쓰게 레지스트리에 내는 것 (Nexus, npm …)");
|
|
160
|
-
say(" 먼저 (1) 실행물 배포부터 물어보고, 이어서 (2) 라이브러리 배포를 물어봅니다.");
|
|
161
211
|
}
|
|
162
212
|
if (willAskDeploy) {
|
|
163
213
|
if (force || !tty || typeof io.select !== "function") {
|
|
@@ -291,11 +341,15 @@ export async function askAllOptionalWorkflows({
|
|
|
291
341
|
current: secretBackup, force, tty, io, forceAsk: ask("secret"), say,
|
|
292
342
|
});
|
|
293
343
|
|
|
344
|
+
const finalDeploy = deploy ?? "docker-ssh";
|
|
345
|
+
const finalPublish = publish ?? [];
|
|
294
346
|
return {
|
|
295
|
-
deploy:
|
|
347
|
+
deploy: finalDeploy, publish: finalPublish, secretBackup: secretBackup === true,
|
|
296
348
|
codeReviewCoderabbit: codeReviewCoderabbit === true,
|
|
297
349
|
changelogProvider: changelogProvider ?? "github-ai",
|
|
298
350
|
changelogBaseUrl: changelogBaseUrl ?? "",
|
|
299
351
|
deployBranch: deployBranch ?? "develop",
|
|
352
|
+
// #485 intent — 확정값 우선, 없으면 최종 deploy/publish에서 역추론(basic·비대화형 경로 보정)
|
|
353
|
+
intent: intent ?? inferIntent(finalDeploy, finalPublish) ?? "manual",
|
|
300
354
|
};
|
|
301
355
|
}
|
|
@@ -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/version-yml.js
CHANGED
|
@@ -52,7 +52,7 @@ const HEADER = `# ==============================================================
|
|
|
52
52
|
export function parseTemplateOptions(content) {
|
|
53
53
|
const out = { deploy: null, publish: null, secretBackup: null,
|
|
54
54
|
changelogProvider: null, changelogBaseUrl: null, codeReviewCoderabbit: null,
|
|
55
|
-
deployBranch: null };
|
|
55
|
+
deployBranch: null, intent: null };
|
|
56
56
|
// deploy_branch는 metadata 직속(#456) — template.options 밖이라 별도로 스캔한다.
|
|
57
57
|
for (const line of String(content || "").split("\n")) {
|
|
58
58
|
if (line.startsWith("#")) continue;
|
|
@@ -85,6 +85,14 @@ export function parseTemplateOptions(content) {
|
|
|
85
85
|
const bm = line.match(/^\s+base_url:\s*(.*)/);
|
|
86
86
|
if (bm) { out.changelogBaseUrl = strip(bm[1]); continue; }
|
|
87
87
|
}
|
|
88
|
+
// intent(프로젝트 성격, #485) — deploy 라인보다 먼저 매칭 (deploy: 와 intent: 구분).
|
|
89
|
+
// 값 뒤 인라인 주석(# ...)이 붙으므로 따옴표 안 or 첫 토큰만 취한다.
|
|
90
|
+
let im = line.match(/^\s+intent:\s*["']?([a-z]+)["']?/);
|
|
91
|
+
if (im) {
|
|
92
|
+
const v = im[1];
|
|
93
|
+
if (["app", "library", "both", "none", "manual"].includes(v)) out.intent = v;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
88
96
|
let m = line.match(/^\s+deploy:\s*(.+)/);
|
|
89
97
|
if (m) {
|
|
90
98
|
const v = strip(m[1]);
|
|
@@ -144,6 +152,22 @@ export function parseTemplateOptions(content) {
|
|
|
144
152
|
return out;
|
|
145
153
|
}
|
|
146
154
|
|
|
155
|
+
// deploy/publish 저장값에서 intent(프로젝트 성격) 역추론 (#485 — intent 키 없는 구 version.yml 하위호환).
|
|
156
|
+
// deploy≠none & publish=[] → app (서버/앱만)
|
|
157
|
+
// deploy=none & publish≠[] → library (라이브러리만)
|
|
158
|
+
// deploy≠none & publish≠[] → both
|
|
159
|
+
// deploy=none & publish=[] → none
|
|
160
|
+
// deploy/publish가 아직 null(미설정)이면 intent도 추론 불가 → null 반환(진입 질문을 하도록).
|
|
161
|
+
export function inferIntent(deploy, publish) {
|
|
162
|
+
if (deploy == null && (publish == null)) return null;
|
|
163
|
+
const hasServer = deploy != null && deploy !== "none";
|
|
164
|
+
const hasLib = Array.isArray(publish) && publish.length > 0;
|
|
165
|
+
if (hasServer && hasLib) return "both";
|
|
166
|
+
if (hasServer) return "app";
|
|
167
|
+
if (hasLib) return "library";
|
|
168
|
+
return "none";
|
|
169
|
+
}
|
|
170
|
+
|
|
147
171
|
// v4.1.0 이전 단수 project_type 키 → project_types 배열 최소 변환 (#471).
|
|
148
172
|
// version.yml을 재생성하지 않는 경로(workflows 모드)에서 신형 version_manager(단수 키 명시 거부)와의
|
|
149
173
|
// 정합을 맞춘다 — 해당 라인만 교체하고 나머지 내용은 손대지 않는다.
|
|
@@ -267,14 +291,17 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
|
|
|
267
291
|
// template 옵션 블록 (.sh save_template_options 신규 추가 케이스). templateOptions 지정 시.
|
|
268
292
|
if (templateOptions) {
|
|
269
293
|
const { templateVersion = "unknown", deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, optionsDate = today,
|
|
270
|
-
changelogProvider = "github-ai", changelogBaseUrl = "", codeReviewCoderabbit = true } = templateOptions;
|
|
294
|
+
changelogProvider = "github-ai", changelogBaseUrl = "", codeReviewCoderabbit = true, intent = null } = templateOptions;
|
|
271
295
|
const publishJson = `[${publishTargets.map((t) => `"${t}"`).join(",")}]`;
|
|
296
|
+
// intent(프로젝트 성격, #485) — 미지정이면 deploy/publish에서 역추론해 기록 (재통합 시 진입 질문 생략용)
|
|
297
|
+
const intentVal = intent || inferIntent(deployTarget, publishTargets) || "manual";
|
|
272
298
|
out += ` template:\n`;
|
|
273
299
|
out += ` source: "projectops"\n`;
|
|
274
300
|
out += ` version: "${templateVersion}"\n`;
|
|
275
301
|
out += ` integrated_date: "${optionsDate}"\n`;
|
|
276
302
|
out += ` last_update_date: "${optionsDate}"\n`;
|
|
277
303
|
out += ` options:\n`;
|
|
304
|
+
out += ` intent: "${intentVal}" # 프로젝트 성격(app/library/both/none/manual) — 배포 질문 유도 기준\n`;
|
|
278
305
|
out += ` deploy: "${deployTarget}"\n`;
|
|
279
306
|
out += ` publish: ${publishJson}\n`;
|
|
280
307
|
out += ` secret_backup: ${includeSecretBackup}\n`;
|
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";
|
|
@@ -103,15 +104,26 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
|
|
|
103
104
|
const { now, today } = clock || utcNow();
|
|
104
105
|
const tempDir = join(cwd, PATHS.tempDir);
|
|
105
106
|
|
|
107
|
+
// 프로젝트 성격(#485): CLI --intent → version.yml 저장값. deploy/publish 유도의 기준.
|
|
108
|
+
const intent = opts.intent ?? existing?.options?.intent ?? null;
|
|
109
|
+
// 배포/publish 축(#439): CLI 플래그 최우선 → 저장값 → 기본값. 단 --intent가 명시됐고 해당 축 플래그가
|
|
110
|
+
// 없으면 intent가 유도한다 (#485 비대화형): library/none이면 deploy=none, app/none이면 publish=[].
|
|
111
|
+
let deployTarget = opts.deployTarget ?? existing?.options?.deploy ?? "docker-ssh";
|
|
112
|
+
let publishTargets = opts.publishTargets ?? existing?.options?.publish ?? [];
|
|
113
|
+
if (opts.intent != null) {
|
|
114
|
+
if ((intent === "library" || intent === "none") && opts.deployTarget == null) deployTarget = "none";
|
|
115
|
+
if ((intent === "app" || intent === "none") && opts.publishTargets == null) publishTargets = [];
|
|
116
|
+
}
|
|
117
|
+
|
|
106
118
|
const context = createContext({
|
|
107
119
|
mode: opts.mode, force: true, types, version, versionCode, branch,
|
|
108
120
|
paths,
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
publishTargets: opts.publishTargets ?? existing?.options?.publish ?? [],
|
|
121
|
+
deployTarget,
|
|
122
|
+
publishTargets,
|
|
112
123
|
includeSecretBackup: opts.includeSecretBackup ?? existing?.options?.secretBackup ?? false,
|
|
113
124
|
// 릴리스 배포 브랜치(#456): CLI 플래그 → version.yml 저장값 → 빈 값(미출력, 스킬이 develop 폴백)
|
|
114
125
|
deployBranch: opts.deployBranch || existing?.options?.deployBranch || "",
|
|
126
|
+
intent,
|
|
115
127
|
// changelog/code_review 축(#455): 비대화형은 저장값 → 기본값. null이 흘러 provider:"null"로 기록되던 버그 수정.
|
|
116
128
|
changelogProvider: existing?.options?.changelogProvider ?? "github-ai",
|
|
117
129
|
changelogBaseUrl: existing?.options?.changelogBaseUrl ?? "",
|
|
@@ -139,6 +151,14 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
|
|
|
139
151
|
await runMigrations({ targetRoot: cwd });
|
|
140
152
|
}
|
|
141
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
|
+
|
|
142
162
|
switch (opts.mode) {
|
|
143
163
|
case "full": result = runFull(context, tempDir, cwd); break;
|
|
144
164
|
case "version": result = runVersion(context, tempDir, cwd); break;
|
package/src/ui/prompts.js
CHANGED
|
@@ -39,6 +39,8 @@ export async function editMenu({ showOptional = false } = {}) {
|
|
|
39
39
|
{ value: "branch", label: "기본 브랜치" },
|
|
40
40
|
];
|
|
41
41
|
if (showOptional) {
|
|
42
|
+
// #485 — 프로젝트 성격(intent): 재선택 시 배포/publish 축을 재유도한다.
|
|
43
|
+
options.push({ value: "intent", label: "프로젝트 성격 (배포 유형)" });
|
|
42
44
|
// #483 — 항목별 격리: 한 축만 골라 그 축만 재질문한다 (통짜 "배포/Publish 방식" 분해)
|
|
43
45
|
options.push({ value: "deploy", label: "배포 방식 (서버 실행물)" });
|
|
44
46
|
options.push({ value: "publish", label: "라이브러리 배포(publish) 타겟" });
|