projectops 4.36.4 → 4.38.0
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 +17 -16
- package/package.json +1 -1
- package/src/cli/args.js +10 -5
- package/src/cli/help.js +8 -2
- package/src/commands/doctor.js +16 -0
- package/src/commands/full.js +6 -3
- package/src/commands/interactive.js +9 -4
- package/src/commands/version.js +6 -3
- package/src/commands/workflows.js +2 -0
- package/src/context.js +2 -1
- package/src/core/agent-guide.js +44 -0
- package/src/core/constants.js +28 -0
- package/src/core/copy/simple.js +3 -2
- package/src/core/copy/workflows.js +85 -7
- package/src/core/exclusions.js +2 -0
- package/src/core/fsutil.js +2 -2
- package/src/core/migrations/index.js +17 -2
- package/src/core/options-ask.js +5 -4
- package/src/core/options-schema.js +150 -0
- package/src/core/version-yml.js +135 -24
- package/src/core/wizard-env.js +8 -0
- package/src/i18n/doctor.en.js +4 -0
- package/src/i18n/doctor.ko.js +4 -0
- package/src/i18n/migrate.en.js +3 -0
- package/src/i18n/migrate.ko.js +3 -0
- package/src/i18n/summary.en.js +10 -0
- package/src/i18n/summary.ko.js +10 -0
- package/src/index.js +18 -6
- package/src/ui/summary.js +32 -0
|
@@ -6,10 +6,30 @@ import { join, basename } from "node:path";
|
|
|
6
6
|
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
|
-
import { isUnchanged, substituteEnv, resolveGlobalTokens } from "../wizard-env.js";
|
|
9
|
+
import { isUnchanged, substituteEnv, resolveGlobalTokens, extractEnvValues } from "../wizard-env.js";
|
|
10
10
|
import { isUserModified, readBaseline, writeBaseline, sha256 } from "../baseline.js";
|
|
11
11
|
import { substituteBranches } from "../branch-sub.js";
|
|
12
12
|
import { saveIncoming, lineDiffCounts } from "../incoming.js";
|
|
13
|
+
import { branchStatus } from "../git-branch.js";
|
|
14
|
+
import { PUBLISH_TARGETS } from "../constants.js";
|
|
15
|
+
|
|
16
|
+
// on: 블록의 브랜치 필터가 전부 develop 뿐인가 (#810).
|
|
17
|
+
// 개발 브랜치가 없는 레포에 깔면 영영 실행되지 않는 워크플로우를 가린다.
|
|
18
|
+
// workflow_dispatch 외의 다른 이벤트(schedule·issues 등)가 있으면 그것만으로도 돌 수 있으니 false.
|
|
19
|
+
export function onlyTriggersOnDevelop(content) {
|
|
20
|
+
const m = String(content).match(/^on:\s*\n((?:[ \t]+.*\n|\s*\n)+)/m);
|
|
21
|
+
if (!m) return false;
|
|
22
|
+
const block = m[1];
|
|
23
|
+
const events = [...block.matchAll(/^ {2}([a-z_]+):/gm)].map((x) => x[1]);
|
|
24
|
+
if (!events.length || events.some((e) => !["push", "pull_request", "workflow_dispatch"].includes(e))) return false;
|
|
25
|
+
const branches = [];
|
|
26
|
+
for (const b of block.matchAll(/^\s+branches:\s*\[([^\]]*)\]/gm)) branches.push(...b[1].split(",").map((s) => s.trim().replace(/["']/g, "")).filter(Boolean));
|
|
27
|
+
for (const b of block.matchAll(/^\s+branches:\s*\n((?:\s+-\s*.*\n)+)/gm)) branches.push(...[...b[1].matchAll(/-\s*["']?([^"'\s#]+)/g)].map((x) => x[1]));
|
|
28
|
+
// push/pull_request 마다 branches 필터가 있어야 한다 — 필터 없는 push 는 모든 브랜치에서 돈다
|
|
29
|
+
const filtered = (block.match(/^\s+branches:/gm) || []).length;
|
|
30
|
+
const needFilter = events.filter((e) => e !== "workflow_dispatch").length;
|
|
31
|
+
return branches.length > 0 && filtered >= needFilter && branches.every((b) => b === "develop");
|
|
32
|
+
}
|
|
13
33
|
|
|
14
34
|
// 한 파일에 env 치환을 적용해 대상 파일을 갱신 (.sh configure_workflow_env 등가).
|
|
15
35
|
// values/useDefaults: env 계획(promptEnvPlan) 결과 — 미지정이면 기본값 경로(현행 force 동작).
|
|
@@ -90,6 +110,43 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
90
110
|
// values/useDefaults는 치환 경로에서만 의미 (isUnchanged는 내부에서 useDefaults:true 강제 — 가상 비교 무손상)
|
|
91
111
|
const envOptsFor = (type) => ({ type, projectPath: paths.get(type) || ".", repoName, resolvers, values: envValues, useDefaults: envUseDefaults, branches });
|
|
92
112
|
|
|
113
|
+
// 설치하지 않을 새 파일 (#810) — 깔 때마다 다시 생기던 것들.
|
|
114
|
+
// excluded : version.yml options.excluded_workflows 에 사용자가 적은 파일
|
|
115
|
+
// deleted-by-user : 우리가 깐 기록(baseline)이 있는데 지금 없다 = 사용자가 지웠다 (.bak 은 우리가 치운 것이라 제외)
|
|
116
|
+
// no-dev-branch : 개발 브랜치가 원격에 확실히 없는데 그 브랜치에서만 도는 워크플로우
|
|
117
|
+
const notInstalled = [];
|
|
118
|
+
Object.defineProperty(counters, "notInstalled", { value: notInstalled, enumerable: false });
|
|
119
|
+
const excludedSet = new Set(context.excludedWorkflows || []);
|
|
120
|
+
const devName = branches.deployBranch;
|
|
121
|
+
let devMissing = null; // 필요할 때 한 번만 원격을 조회한다
|
|
122
|
+
const isDevMissing = () => {
|
|
123
|
+
if (devMissing === null) {
|
|
124
|
+
const st = devName === branches.defaultBranch ? null : branchStatus(targetRoot, devName);
|
|
125
|
+
devMissing = !!(st && st.isRepo && !st.local && st.remote === false);
|
|
126
|
+
}
|
|
127
|
+
return devMissing;
|
|
128
|
+
};
|
|
129
|
+
const skipNew = (srcDir, filename, group) => {
|
|
130
|
+
const dst = join(workflowsDir, filename);
|
|
131
|
+
if (existsSync(dst)) return false;
|
|
132
|
+
let reason = null;
|
|
133
|
+
if (excludedSet.has(filename)) reason = "excluded";
|
|
134
|
+
else if (baseline?.files?.[filename] && !existsSync(dst + ".bak")) reason = "deleted-by-user";
|
|
135
|
+
else if (onlyTriggersOnDevelop(readFileSync(join(srcDir, filename), "utf8")) && isDevMissing()) reason = "no-dev-branch";
|
|
136
|
+
if (!reason) return false;
|
|
137
|
+
const info = { filename, reason, incoming: "" };
|
|
138
|
+
try {
|
|
139
|
+
// 마음이 바뀌면 cp 한 번으로 되살릴 수 있게 렌더본을 incoming 에 남긴다
|
|
140
|
+
const rendered = substituteBranches(substituteEnv(readFileSync(join(srcDir, filename), "utf8"),
|
|
141
|
+
{ ...envOptsFor(group.split("/")[0]), useDefaults: true }), branches);
|
|
142
|
+
info.incoming = saveIncoming(targetRoot, filename, rendered);
|
|
143
|
+
} catch { /* 안내만 빠진다 */ }
|
|
144
|
+
notInstalled.push(info);
|
|
145
|
+
trace?.event("copy", "not-installed", filename, { group, reason });
|
|
146
|
+
return true;
|
|
147
|
+
};
|
|
148
|
+
Object.defineProperty(counters, "skipNew", { value: skipNew, enumerable: false });
|
|
149
|
+
|
|
93
150
|
// 사용자 수정본을 유지할 때 새 템플릿 사본을 incoming에 남긴다 (#654).
|
|
94
151
|
// 실패는 경고 한 줄로 흡수한다 — 병합 안내 때문에 복사 전체가 죽으면 안 된다.
|
|
95
152
|
const onSkip = (srcDir, filename, type) => {
|
|
@@ -133,6 +190,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
133
190
|
continue;
|
|
134
191
|
}
|
|
135
192
|
// 사용자가 손댄 적 없고 템플릿만 바뀐 파일 — 물어볼 것 없이 최신으로 올린다(종전과 동일).
|
|
193
|
+
if (commonClass.newFiles.includes(filename) && skipNew(commonDir, filename, "common")) continue;
|
|
136
194
|
if (commonClass.newFiles.includes(filename) || commonClass.upstream.includes(filename)) {
|
|
137
195
|
copyFileSync(join(commonDir, filename), join(workflowsDir, filename));
|
|
138
196
|
counters.copied++;
|
|
@@ -179,6 +237,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
179
237
|
trace?.event("copy", "skipped-unchanged", filename, { group: "common-deploy" });
|
|
180
238
|
continue;
|
|
181
239
|
}
|
|
240
|
+
if (skipNew(commonDeployDir, filename, "common-deploy")) continue;
|
|
182
241
|
const backedUp = existsSync(dst);
|
|
183
242
|
if (backedUp) renameSync(dst, dst + ".bak");
|
|
184
243
|
copyFileSync(src, dst);
|
|
@@ -207,6 +266,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
207
266
|
trace?.event("copy", "skipped-unchanged", filename, { group: groupName });
|
|
208
267
|
continue;
|
|
209
268
|
}
|
|
269
|
+
if (skipNew(prSummaryDir, filename, groupName)) continue;
|
|
210
270
|
const backedUp = existsSync(dst);
|
|
211
271
|
if (backedUp) renameSync(dst, dst + ".bak");
|
|
212
272
|
copyFileSync(src, dst);
|
|
@@ -225,6 +285,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
225
285
|
for (const filename of listYamlFiles(secretDir)) {
|
|
226
286
|
const dst = join(workflowsDir, filename);
|
|
227
287
|
if (existsSync(dst)) continue; // 이미 존재하면 스킵
|
|
288
|
+
if (skipNew(secretDir, filename, "secret-backup")) continue;
|
|
228
289
|
copyFileSync(join(secretDir, filename), dst);
|
|
229
290
|
// PROJECT_NAME 이 고정값이면 모든 프로젝트가 서버의 같은 폴더에 Secret 을 덮어쓴다 (#729) —
|
|
230
291
|
// 다른 배포 워크플로처럼 레포 이름으로 채운다. 이름을 알 수 없으면 기존 기본값을 쓴다.
|
|
@@ -370,22 +431,31 @@ export async function copyWorkflowsInteractive(context, tempDir, targetRoot = ".
|
|
|
370
431
|
return copyWorkflows(context, tempDir, targetRoot, { decisions });
|
|
371
432
|
}
|
|
372
433
|
|
|
373
|
-
const PUBLISH_TARGETS = ["nexus", "npm", "github-packages"];
|
|
374
434
|
|
|
375
435
|
function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters) {
|
|
376
436
|
const { deployTarget = "docker-ssh", publishTargets = [], force = false, paths = new Map(), repoName = "", resolvers = {}, envOptsFor, collectAsks = null, decisions = new Map(), trace = null, baseline = null } = ctx;
|
|
377
437
|
const typeDir = join(projectTypesDir, type);
|
|
378
438
|
const envOpts = envOptsFor(type);
|
|
379
439
|
let unchangedNames = [];
|
|
440
|
+
// upstream 갱신 전 설치본의 env 실값 (#810 조사 중 발견). 템플릿만 바뀐 파일을 새로 깔 때
|
|
441
|
+
// 기본값으로 다시 치환해, 사용자가 고른 값(JAVA_VERSION 17 등)이 조용히 21로 돌아갔다.
|
|
442
|
+
const carried = new Map(); // Map<filename, Map<key,value>>
|
|
443
|
+
const upstreamCopy = (srcDir, f, group) => {
|
|
444
|
+
const dst = join(workflowsDir, f);
|
|
445
|
+
try { carried.set(f, extractEnvValues(readFileSync(dst, "utf8"))); } catch { /* 읽기 실패 — 기본값으로 */ }
|
|
446
|
+
copyFileSync(join(srcDir, f), dst);
|
|
447
|
+
counters.copied++; counters.copiedFiles.push(f);
|
|
448
|
+
trace?.event("copy", "upstream-updated", f, { group, reason: "baseline-match", carried: carried.get(f)?.size ?? 0 });
|
|
449
|
+
};
|
|
380
450
|
|
|
381
451
|
// 타입별 워크플로우 (직하위)
|
|
382
452
|
if (exists(typeDir)) {
|
|
383
453
|
const { newFiles, unchanged, changed, upstream } = classify(typeDir, workflowsDir, envOpts, baseline);
|
|
384
454
|
unchangedNames = unchanged.slice();
|
|
385
455
|
for (const f of unchanged) { counters.skipped++; trace?.event("copy", "skipped-unchanged", f, { group: type }); }
|
|
386
|
-
for (const f of newFiles) { copyFileSync(join(typeDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "copied", f, { group: type }); }
|
|
456
|
+
for (const f of newFiles) { if (counters.skipNew(typeDir, f, type)) continue; copyFileSync(join(typeDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "copied", f, { group: type }); }
|
|
387
457
|
// upstream(#557): 사용자가 손대지 않았고 템플릿만 바뀐 파일 — 물어볼 것 없이 최신으로 올린다.
|
|
388
|
-
for (const f of upstream)
|
|
458
|
+
for (const f of upstream) upstreamCopy(typeDir, f, type);
|
|
389
459
|
// changed: 결정 Map에 따라 처리 (미지정=skip → 현행 force 동작과 동일)
|
|
390
460
|
for (const f of changed) applyDecision(decisions.get(f), typeDir, workflowsDir, f, counters, trace, type);
|
|
391
461
|
}
|
|
@@ -395,8 +465,8 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
395
465
|
if (exists(serverDeployDir) && (deployTarget || "docker-ssh") === "docker-ssh") {
|
|
396
466
|
const { newFiles, unchanged, changed, upstream } = classify(serverDeployDir, workflowsDir, envOpts, baseline);
|
|
397
467
|
for (const f of unchanged) { counters.skipped++; trace?.event("copy", "skipped-unchanged", f, { group: `${type}/server-deploy` }); }
|
|
398
|
-
for (const f of newFiles) { copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "copied", f, { group: `${type}/server-deploy` }); }
|
|
399
|
-
for (const f of upstream)
|
|
468
|
+
for (const f of newFiles) { if (counters.skipNew(serverDeployDir, f, `${type}/server-deploy`)) continue; copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "copied", f, { group: `${type}/server-deploy` }); }
|
|
469
|
+
for (const f of upstream) upstreamCopy(serverDeployDir, f, `${type}/server-deploy`);
|
|
400
470
|
for (const f of changed) applyDecision(decisions.get(f), serverDeployDir, workflowsDir, f, counters, trace, type);
|
|
401
471
|
}
|
|
402
472
|
|
|
@@ -414,6 +484,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
414
484
|
trace?.event("copy", "skipped-unchanged", filename, { group: `${type}/publish/${target}` });
|
|
415
485
|
continue;
|
|
416
486
|
}
|
|
487
|
+
if (counters.skipNew(pubDir, filename, `${type}/publish/${target}`)) continue;
|
|
417
488
|
const backedUp = existsSync(dst);
|
|
418
489
|
if (backedUp) renameSync(dst, dst + ".bak");
|
|
419
490
|
copyFileSync(src, dst);
|
|
@@ -432,7 +503,14 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
432
503
|
const target = join(workflowsDir, filename);
|
|
433
504
|
if (!existsSync(target)) continue; // 건너뛴 파일 제외
|
|
434
505
|
if (unchangedNames.includes(filename)) continue; // unchanged 제외
|
|
435
|
-
|
|
506
|
+
const prev = carried.get(filename);
|
|
507
|
+
if (prev && prev.size) {
|
|
508
|
+
// 이월값 위에 이번 실행에서 명시로 고른 값을 덮는다 (명시 선택이 이긴다)
|
|
509
|
+
const values = new Map([...prev, ...(envOpts.useDefaults ? [] : envOpts.values)]);
|
|
510
|
+
configureEnv(target, filename, { ...envOpts, values, useDefaults: false, collectAsks, trace });
|
|
511
|
+
} else {
|
|
512
|
+
configureEnv(target, filename, { ...envOpts, collectAsks, trace }); // env 계획 values/useDefaults 포함
|
|
513
|
+
}
|
|
436
514
|
}
|
|
437
515
|
}
|
|
438
516
|
}
|
package/src/core/exclusions.js
CHANGED
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
export const DOCS_TO_REMOVE = [
|
|
6
6
|
"CONTRIBUTING.md",
|
|
7
7
|
"SECURITY.md",
|
|
8
|
+
"CODE_OF_CONDUCT.md",
|
|
8
9
|
"ARCHITECTURE.md",
|
|
9
10
|
"CLAUDE.md",
|
|
10
11
|
"AGENTS.md",
|
|
11
12
|
"GEMINI.md",
|
|
13
|
+
"llms.txt",
|
|
12
14
|
"gemini-extension.json",
|
|
13
15
|
// 릴리스마다 changelog-deploy 파이프라인이 재생성하는 이 레포 전용 작업 산출물.
|
|
14
16
|
// 사용자 프로젝트에는 의미가 없으므로 복사하지 않는다.
|
package/src/core/fsutil.js
CHANGED
|
@@ -20,9 +20,9 @@ export function copyFileSync(src, dst) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
// 디렉토리 재귀 복사 (내용을 dst 하위로)
|
|
23
|
-
export function copyDirSync(src, dst) {
|
|
23
|
+
export function copyDirSync(src, dst, { filter } = {}) {
|
|
24
24
|
mkdirSync(dst, { recursive: true });
|
|
25
|
-
cpSync(src, dst, { recursive: true });
|
|
25
|
+
cpSync(src, dst, { recursive: true, ...(filter ? { filter } : {}) });
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
// 파일/폴더 삭제 (없어도 무해)
|
|
@@ -48,8 +48,9 @@ export function applySafeMigrations(targetRoot, entries) {
|
|
|
48
48
|
// 마법사 배선 진입점.
|
|
49
49
|
// askYesNo - async(msg, defaultYes)→bool. null이면 비대화형(--force): safe 자동 적용
|
|
50
50
|
// log - 한 줄 출력 함수 (기본 console.log)
|
|
51
|
+
// removeLegacy - true면 confirm 티어(구세대 배포 워크플로우)도 .bak 무해화 (--remove-legacy, #809)
|
|
51
52
|
// 반환: { applied: [결과], confirmPending: [entry], askPending: [entry] }
|
|
52
|
-
export async function runMigrations({ targetRoot = ".", askYesNo = null, log = console.log } = {}) {
|
|
53
|
+
export async function runMigrations({ targetRoot = ".", askYesNo = null, log = console.log, removeLegacy = false } = {}) {
|
|
53
54
|
const { safe, confirm, ask } = detectMigrations(targetRoot);
|
|
54
55
|
if (safe.length === 0 && confirm.length === 0 && ask.length === 0) {
|
|
55
56
|
return { applied: [], confirmPending: [], askPending: [] };
|
|
@@ -110,7 +111,21 @@ export async function runMigrations({ targetRoot = ".", askYesNo = null, log = c
|
|
|
110
111
|
const arrow = e.replacedBy ? t("migrate.confirm.newer", { name: e.replacedBy }) : "";
|
|
111
112
|
log(` • ${e.file}${arrow}: ${reasonOf(e)}`);
|
|
112
113
|
}
|
|
113
|
-
|
|
114
|
+
// 신·구 세대가 함께 돌면 같은 이벤트에 두 번 반응한다 (#809 — PR 닫힘마다 PREVIEW 두 벌).
|
|
115
|
+
// 현역 배포일 수 있어 기본은 손대지 않되, 명시 플래그나 대화형 확인이 있으면 .bak 으로 치운다(복원 가능).
|
|
116
|
+
const yes = removeLegacy
|
|
117
|
+
? true
|
|
118
|
+
: askYesNo ? await askYesNo(t("migrate.confirm.ask", { n: confirm.length }), false) : false;
|
|
119
|
+
if (yes === true) {
|
|
120
|
+
const results = applySafeMigrations(targetRoot, confirm);
|
|
121
|
+
applied = applied.concat(results);
|
|
122
|
+
const failed = results.filter((r) => r.action === "error");
|
|
123
|
+
log(t("migrate.confirm.done", { n: results.length - failed.length }));
|
|
124
|
+
for (const f of failed) log(` ⚠️ ${f.from}: ${f.error}`);
|
|
125
|
+
const failedIds = new Set(failed.map((f) => f.id));
|
|
126
|
+
return { applied, confirmPending: confirm.filter((e) => failedIds.has(e.id)), askPending };
|
|
127
|
+
}
|
|
128
|
+
log(t(askYesNo ? "migrate.confirm.hint" : "migrate.confirm.hintForce"));
|
|
114
129
|
}
|
|
115
130
|
|
|
116
131
|
return { applied, confirmPending: confirm, askPending };
|
package/src/core/options-ask.js
CHANGED
|
@@ -13,6 +13,7 @@ import { PATHS } from "./paths.js";
|
|
|
13
13
|
import { t as msg } from "../i18n/index.js";
|
|
14
14
|
import { parseTemplateOptions, inferIntent } from "./version-yml.js";
|
|
15
15
|
import { branchStatus, createBranch, pushBranch } from "./git-branch.js";
|
|
16
|
+
import { DEPLOY_TARGETS, PUBLISH_TARGETS, CHANGELOG_PROVIDERS } from "./constants.js";
|
|
16
17
|
|
|
17
18
|
// 개발(배포) 브랜치 존재 확인 + 생성 제안 (#477) — 대화형 전용.
|
|
18
19
|
// 로컬에 없고 원격에도 없(거나 불명이)면 기본 브랜치에서 생성을 제안하고, 생성 후 push 여부도 묻는다.
|
|
@@ -55,8 +56,8 @@ export async function ensureDeployBranch({ targetRoot = ".", deployBranch = "",
|
|
|
55
56
|
// 재노출 — 파서 본체는 version-yml.js에 있다 (순환 import 방지: options-ask → version-yml 방향만 허용)
|
|
56
57
|
export { parseTemplateOptions };
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
export
|
|
59
|
+
// 값 목록은 core/constants.js 가 정본이다. 기존 import 경로를 깨지 않도록 다시 내보낸다.
|
|
60
|
+
export { DEPLOY_TARGETS, PUBLISH_TARGETS, CHANGELOG_PROVIDERS };
|
|
60
61
|
|
|
61
62
|
// #498 — 타입별 적용 가능 deploy/publish 타겟 선언.
|
|
62
63
|
// 빈 배열 = 그 축이 개념상 성립하지 않는 타입. 모바일 앱(flutter/react-native/expo)은 스토어 배포
|
|
@@ -95,9 +96,9 @@ export function applicableTargets(types = []) {
|
|
|
95
96
|
}
|
|
96
97
|
// changelog 생성기 provider (#455, #566에서 재정비).
|
|
97
98
|
// 기본값은 commit — 외부 의존이 없어 어떤 환경에서도 결과가 나온다. AI를 쓰려면
|
|
98
|
-
//
|
|
99
|
+
// 서비스별 키(GEMINI_API_KEY 등)를 등록하면 사다리가 자동으로 집어 쓴다(마법사는 묻지 않는다).
|
|
100
|
+
// MODEL_API_KEY 는 구 이름이라 하위호환으로만 읽는다.
|
|
99
101
|
// github-ai는 GitHub Models 종료(2026-07-30)로 제외됐다.
|
|
100
|
-
export const CHANGELOG_PROVIDERS = ["copilot", "coderabbit", "openai", "gemini", "claude", "groq", "mistral", "ollama", "commit"];
|
|
101
102
|
|
|
102
103
|
// 더 이상 동작하지 않는 저장값을 살아있는 값으로 옮긴다 (#566).
|
|
103
104
|
// 기존 저장소가 업데이트만 돌려도 죽은 설정에서 벗어나게 하는 유일한 경로다.
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// projectops 옵션 정본 (AI agent·사람·문서가 같은 표를 본다).
|
|
2
|
+
//
|
|
3
|
+
// 왜 필요한가: 옵션 설명이 문서 6곳(NPX-WIZARD, VERSION-CONTROL, ISSUE-AUTOMATION ...)에 흩어져 있어
|
|
4
|
+
// agent가 "이 옵션이 있나, 기본값이 뭔가"를 확인하려면 문서를 뒤져야 했다. 이제 한 호출로 읽는다:
|
|
5
|
+
// npx projectops --mode options --json
|
|
6
|
+
//
|
|
7
|
+
// 이 표는 손으로 쓰되, test/options-schema.test.js 가 실제 코드와 대조한다.
|
|
8
|
+
// - args.js 의 모든 CLI 플래그가 flags 에 있어야 한다
|
|
9
|
+
// - parseTemplateOptions 가 읽는 모든 키가 versionYml 에 있어야 한다
|
|
10
|
+
// 새 플래그·옵션을 추가하고 여기에 안 쓰면 테스트가 실패한다.
|
|
11
|
+
//
|
|
12
|
+
// 설명은 영문이다 — 기계가 읽는 표이고, 화면 언어(i18n)와 무관하게 같아야 한다.
|
|
13
|
+
|
|
14
|
+
import { DEPLOY_TARGETS, PUBLISH_TARGETS, INTENT_VALUES, LABEL_STYLES, CHANGELOG_PROVIDERS } from "./constants.js";
|
|
15
|
+
import { REPO_LANGUAGES } from "./repo-language.js";
|
|
16
|
+
import { VALID_TYPES } from "../context.js";
|
|
17
|
+
import { SUPPORTED_LANGS } from "../i18n/index.js";
|
|
18
|
+
|
|
19
|
+
export const SCHEMA_VERSION = 1;
|
|
20
|
+
|
|
21
|
+
export const MODES = [
|
|
22
|
+
{ name: "interactive", description: "Default. Asks questions in the terminal. Do not use from an agent or CI." },
|
|
23
|
+
{ name: "full", description: "Install everything: version.yml, workflows, scripts, templates, setup guide." },
|
|
24
|
+
{ name: "version", description: "Only version.yml, README version section and version scripts." },
|
|
25
|
+
{ name: "workflows", description: "Only GitHub Actions workflows (+ scripts, config, util). Existing version.yml is kept." },
|
|
26
|
+
{ name: "issues", description: "Only issue and discussion templates." },
|
|
27
|
+
{ name: "skills", description: "Install or update agent skills for supported IDEs." },
|
|
28
|
+
{ name: "doctor", description: "Read-only diagnosis of the install and repository settings. Safe to run any time." },
|
|
29
|
+
{ name: "options", description: "Print this option table (add --json for machine-readable output). Read-only, no network." },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// 프로젝트 타입은 context.js 의 VALID_TYPES 와 같아야 한다 (테스트가 대조).
|
|
33
|
+
export const FLAGS = [
|
|
34
|
+
{ flag: "--mode", alias: "-m", value: "MODE", values: MODES.map((m) => m.name), default: "interactive",
|
|
35
|
+
description: "What to do. Agents and CI should always pass an explicit non-interactive mode together with --force." },
|
|
36
|
+
{ flag: "--type", alias: "-t", value: "CSV", default: "auto-detected",
|
|
37
|
+
description: "Project types, comma separated. The first one is the primary type.",
|
|
38
|
+
values: [...VALID_TYPES] },
|
|
39
|
+
{ flag: "--project-version", value: "VERSION", default: "auto-detected",
|
|
40
|
+
description: "Initial version of the target project (x.y.z)." },
|
|
41
|
+
{ flag: "--paths", value: "type=path,...", default: "repo root",
|
|
42
|
+
description: "Per-type project folders for monorepos, e.g. flutter=app,react=client.", versionYml: "project_paths" },
|
|
43
|
+
{ flag: "--intent", value: "KIND", values: [...INTENT_VALUES], default: "inferred from --deploy/--publish",
|
|
44
|
+
description: "Project kind. Decides whether deploy and publish questions are asked.", versionYml: "metadata.template.options.intent" },
|
|
45
|
+
{ flag: "--deploy", value: "TARGET", values: [...DEPLOY_TARGETS], default: "docker-ssh",
|
|
46
|
+
description: "Where the app is deployed. Pick one.", versionYml: "metadata.template.options.deploy" },
|
|
47
|
+
{ flag: "--publish", value: "CSV", values: [...PUBLISH_TARGETS], default: "none",
|
|
48
|
+
description: "Registries a library is published to. Several allowed.", versionYml: "metadata.template.options.publish" },
|
|
49
|
+
{ flag: "--deploy-branch", value: "NAME", default: "develop",
|
|
50
|
+
description: "Development branch: the head of the release PR. Not the repository default branch.", versionYml: "metadata.deploy_branch" },
|
|
51
|
+
{ flag: "--language", value: "LANG", values: [...REPO_LANGUAGES], default: "en for new installs, ko for existing installs",
|
|
52
|
+
description: "Language of issue/PR templates and bot messages written into the repo.", versionYml: "metadata.template.options.language" },
|
|
53
|
+
{ flag: "--label-style", value: "STYLE", values: [...LABEL_STYLES], default: "en for new installs, ko for existing installs",
|
|
54
|
+
description: "Status label names: en = 'status: todo', ko = legacy Korean names.", versionYml: "metadata.template.options.label_style" },
|
|
55
|
+
{ flag: "--secret-backup", negation: "--no-secret-backup",
|
|
56
|
+
description: "Include / exclude the workflow that uploads GitHub Secrets to a server over SSH.", default: "excluded",
|
|
57
|
+
versionYml: "metadata.template.options.secret_backup" },
|
|
58
|
+
{ flag: "--ai-summary", negation: "--no-ai-summary",
|
|
59
|
+
description: "Include / exclude the workflow that comments an AI summary on work PRs.", default: "included",
|
|
60
|
+
versionYml: "metadata.template.options.code_review.ai_summary" },
|
|
61
|
+
{ flag: "--projects-sync", negation: "--no-projects-sync",
|
|
62
|
+
description: "Include / exclude the GitHub Projects status sync workflow. Needs a PAT secret and PROJECT_URL variable.",
|
|
63
|
+
default: "excluded for new installs; kept if already installed", versionYml: "metadata.template.options.projects_sync" },
|
|
64
|
+
{ flag: "--remove-legacy",
|
|
65
|
+
description: "Rename retired old-generation workflows to .bak. Without it they are only listed in the summary and in doctor.", default: "off" },
|
|
66
|
+
{ flag: "--force", alias: "-y", aliases: ["-y", "--yes"], description: "Skip every confirmation and use non-interactive defaults. Required for agents and CI.", default: "off" },
|
|
67
|
+
{ flag: "--lang", value: "LANG", values: [...SUPPORTED_LANGS], default: "system language; pinned to en in CI and with --force",
|
|
68
|
+
description: "Language of the CLI screen. Not the language written into the repo (that is --language)." },
|
|
69
|
+
{ flag: "--json", description: "With --mode options: print machine-readable JSON instead of a table.", default: "off" },
|
|
70
|
+
{ flag: "--version", alias: "-v", description: "Print the projectops package version and exit." },
|
|
71
|
+
{ flag: "--help", alias: "-h", description: "Print the human-readable help and exit." },
|
|
72
|
+
// 지원 종료 예정 — 새 설정에는 쓰지 않는다
|
|
73
|
+
{ flag: "--nexus", negation: "--no-nexus", deprecated: "Use --publish nexus. Also implies --deploy none." },
|
|
74
|
+
{ flag: "--npm-publish", negation: "--no-npm-publish", deprecated: "Use --publish npm." },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
// version.yml 의 키. path 는 파일 안의 위치, parserKey 는 parseTemplateOptions 가 돌려주는 이름.
|
|
78
|
+
export const VERSION_YML = [
|
|
79
|
+
{ path: "version", type: "string", description: "Project version (x.y.z). The single source of truth; workflows sync it into build files.", edit: "workflow-managed" },
|
|
80
|
+
{ path: "version_code", type: "integer", description: "App build number. +1 on every release. Android release only; iOS and test builds use build_number.py.", edit: "workflow-managed" },
|
|
81
|
+
{ path: "project_types", type: "string[]", description: "Project types; the first is primary. Do not change after setup.", edit: "wizard" },
|
|
82
|
+
{ path: "project_paths", type: "map", description: "Per-type project folder relative to the repo root (monorepo).", edit: "wizard (--paths)" },
|
|
83
|
+
{ path: "metadata.default_branch", type: "string", default: "main", description: "Repository default (production) branch. Pushes here deploy.", edit: "wizard" },
|
|
84
|
+
{ path: "metadata.deploy_branch", parserKey: "deployBranch", type: "string", default: "develop",
|
|
85
|
+
description: "Development branch: head of the release PR. Despite the name it is NOT where deploys run.", edit: "wizard (--deploy-branch)" },
|
|
86
|
+
{ path: "metadata.template.options.intent", parserKey: "intent", type: "enum", values: [...INTENT_VALUES],
|
|
87
|
+
description: "Project kind; derives the deploy and publish questions.", edit: "wizard (--intent)" },
|
|
88
|
+
{ path: "metadata.template.options.deploy", parserKey: "deploy", type: "enum", values: [...DEPLOY_TARGETS], default: "docker-ssh",
|
|
89
|
+
description: "Where the app is deployed.", edit: "wizard (--deploy)" },
|
|
90
|
+
{ path: "metadata.template.options.publish", parserKey: "publish", type: "enum[]", values: [...PUBLISH_TARGETS], default: [],
|
|
91
|
+
description: "Library registries.", edit: "wizard (--publish)" },
|
|
92
|
+
{ path: "metadata.template.options.secret_backup", parserKey: "secretBackup", type: "boolean", default: false,
|
|
93
|
+
description: "Secret-to-server upload workflow installed.", edit: "wizard (--secret-backup)" },
|
|
94
|
+
{ path: "metadata.template.options.semver_auto", parserKey: "semverAuto", type: "boolean",
|
|
95
|
+
default: true,
|
|
96
|
+
description: "Decide the release bump from commit titles: 'type!:' = major, 'feat:' = minor, anything else = patch. Applies both to the release PR and to direct pushes to the default branch.",
|
|
97
|
+
edit: "by hand" },
|
|
98
|
+
{ path: "metadata.template.options.close_on_release", parserKey: "closeOnRelease", type: "boolean", default: "true for new installs; missing for existing installs",
|
|
99
|
+
description: "A release merge closes issues that carry the done label and are referenced by a release commit.", edit: "by hand" },
|
|
100
|
+
{ path: "metadata.template.options.language", parserKey: "language", type: "enum", values: [...REPO_LANGUAGES], description: "Issue/PR template and bot message language.", edit: "wizard (--language)" },
|
|
101
|
+
{ path: "metadata.template.options.label_style", parserKey: "labelStyle", type: "enum", values: [...LABEL_STYLES], description: "Status label names.", edit: "wizard (--label-style)" },
|
|
102
|
+
{ path: "metadata.template.options.projects_sync", parserKey: "projectsSync", type: "boolean", description: "Projects status sync workflow installed.", edit: "wizard (--projects-sync)" },
|
|
103
|
+
{ path: "metadata.template.options.app_release", parserKey: "appRelease", type: "boolean",
|
|
104
|
+
description: "Releases go through App Store / Play Store review. Release notes get a review warning banner. Never auto-enabled.", edit: "by hand" },
|
|
105
|
+
{ path: "metadata.template.options.excluded_workflows", parserKey: "excludedWorkflows", type: "string[]",
|
|
106
|
+
description: "Workflow file names the update must never install. Deleted files are also not restored; a copy is kept in .github/.projectops/incoming/.",
|
|
107
|
+
edit: "by hand" },
|
|
108
|
+
{ path: "metadata.template.options.code_review.coderabbit", parserKey: "codeReviewCoderabbit", type: "boolean", description: "CodeRabbit config installed.", edit: "wizard" },
|
|
109
|
+
{ path: "metadata.template.options.code_review.ai_summary", parserKey: "aiPrSummary", type: "boolean", default: true, description: "AI PR summary workflow installed.", edit: "wizard (--ai-summary)" },
|
|
110
|
+
{ path: "metadata.template.options.changelog.provider", parserKey: "changelogProvider", type: "enum",
|
|
111
|
+
values: [...CHANGELOG_PROVIDERS], default: "commit",
|
|
112
|
+
description: "Release note generator. 'commit' is free and always works; AI keys are picked up automatically when registered as secrets.", edit: "by hand" },
|
|
113
|
+
{ path: "metadata.template.options.changelog.base_url", parserKey: "changelogBaseUrl", type: "string", description: "Only for provider ollama.", edit: "by hand" },
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
export function buildSchema(packageVersion = "") {
|
|
117
|
+
return {
|
|
118
|
+
tool: "projectops",
|
|
119
|
+
packageVersion,
|
|
120
|
+
schemaVersion: SCHEMA_VERSION,
|
|
121
|
+
usage: "npx projectops --mode <mode> [--type csv] [--force] ... (agents: always pass --force and an explicit --mode)",
|
|
122
|
+
modes: MODES,
|
|
123
|
+
flags: FLAGS,
|
|
124
|
+
versionYml: VERSION_YML,
|
|
125
|
+
related: {
|
|
126
|
+
diagnose: "npx projectops --mode doctor",
|
|
127
|
+
optionsJson: "npx projectops --mode options --json",
|
|
128
|
+
docs: "https://cassiiopeia.github.io/projectops/",
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 사람이 읽는 표 — JSON 이 아닐 때 출력한다.
|
|
134
|
+
export function renderText(schema) {
|
|
135
|
+
const lines = [`projectops ${schema.packageVersion} — options (schema v${schema.schemaVersion})`, "", schema.usage, "", "MODES"];
|
|
136
|
+
for (const m of schema.modes) lines.push(` ${m.name.padEnd(12)} ${m.description}`);
|
|
137
|
+
lines.push("", "FLAGS");
|
|
138
|
+
for (const f of schema.flags) {
|
|
139
|
+
const name = [...(f.aliases ?? [f.alias]), f.flag + (f.value ? ` ${f.value}` : ""), f.negation].filter(Boolean).join(", ");
|
|
140
|
+
const desc = f.deprecated ? `(deprecated) ${f.deprecated}` : f.description;
|
|
141
|
+
const extra = [f.values ? `values: ${f.values.join("|")}` : "", f.default !== undefined ? `default: ${f.default}` : ""].filter(Boolean).join("; ");
|
|
142
|
+
lines.push(` ${name}`, ` ${desc}${extra ? ` [${extra}]` : ""}`);
|
|
143
|
+
}
|
|
144
|
+
lines.push("", "VERSION.YML KEYS");
|
|
145
|
+
for (const k of schema.versionYml) {
|
|
146
|
+
const extra = [k.type, k.values ? k.values.join("|") : "", k.default !== undefined ? `default: ${JSON.stringify(k.default)}` : ""].filter(Boolean).join("; ");
|
|
147
|
+
lines.push(` ${k.path} [${extra}]`, ` ${k.description}`);
|
|
148
|
+
}
|
|
149
|
+
return lines.join("\n");
|
|
150
|
+
}
|