projectops 4.36.4 → 4.37.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 +14 -14
- package/package.json +1 -1
- package/src/cli/args.js +5 -1
- 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 +1 -0
- package/src/core/agent-guide.js +44 -0
- package/src/core/copy/simple.js +3 -2
- package/src/core/copy/workflows.js +84 -6
- package/src/core/exclusions.js +1 -0
- package/src/core/fsutil.js +2 -2
- package/src/core/migrations/index.js +17 -2
- package/src/core/options-schema.js +145 -0
- package/src/core/version-yml.js +121 -18
- 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 +17 -4
- package/src/ui/summary.js +32 -0
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
**Status: actively maintained.** New versions ship often, sometimes several a day. To stay on a version you have tested, run `npx projectops@<version>` ([releases](https://github.com/Cassiiopeia/projectops/releases)).
|
|
10
10
|
|
|
11
11
|
<!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
|
|
12
|
-
## Latest version : v4.36.
|
|
12
|
+
## Latest version : v4.36.4 (2026-10-07)
|
|
13
13
|
|
|
14
14
|
[View full version history](CHANGELOG.md)
|
|
15
15
|
|
|
@@ -178,7 +178,7 @@ flowchart TD
|
|
|
178
178
|
K --> L["/pro-changelog-deploy<br/>Release PR + automerge"]
|
|
179
179
|
```
|
|
180
180
|
|
|
181
|
-
> Full list of Skills and usage: **[docs/
|
|
181
|
+
> Full list of Skills and usage: **[docs/en/skills.md](docs/en/skills.md)**
|
|
182
182
|
|
|
183
183
|
### GitHub Actions pipeline
|
|
184
184
|
|
|
@@ -215,14 +215,14 @@ codex plugin marketplace add Cassiiopeia/projectops
|
|
|
215
215
|
|
|
216
216
|
The `--mode skills` wizard registers the Codex marketplace and also prepares the native skills fallback. Use `/plugins` only to check or manage the install.
|
|
217
217
|
|
|
218
|
-
Where the Codex plugin marketplace is unavailable, use the fallback install described in the [Skills guide](docs/
|
|
218
|
+
Where the Codex plugin marketplace is unavailable, use the fallback install described in the [Skills guide](docs/en/skills.md).
|
|
219
219
|
|
|
220
220
|
```bash
|
|
221
221
|
# Cursor / the full Agent Skills install menu (recommended: npx)
|
|
222
222
|
npx projectops --mode skills
|
|
223
223
|
```
|
|
224
224
|
|
|
225
|
-
> Claude Code prefers `/pro-` autocomplete, Gemini prefers its extension, and Codex prefers its plugin marketplace. See the [Skills guide](docs/
|
|
225
|
+
> Claude Code prefers `/pro-` autocomplete, Gemini prefers its extension, and Codex prefers its plugin marketplace. See the [Skills guide](docs/en/skills.md) for details.
|
|
226
226
|
|
|
227
227
|
</details>
|
|
228
228
|
|
|
@@ -257,7 +257,7 @@ Run automation by commenting on an issue or PR.
|
|
|
257
257
|
| `@projectops ios build` | Build iOS only | Flutter |
|
|
258
258
|
| `@projectops create qa` | Create a QA issue automatically | All projects |
|
|
259
259
|
|
|
260
|
-
> Details: [PR Preview](docs/
|
|
260
|
+
> Details: [PR Preview](docs/en/pr-preview.md) | [Flutter builds](docs/FLUTTER-TEST-BUILD-TRIGGER.md) | [Issue automation](docs/en/issue-automation.md)
|
|
261
261
|
|
|
262
262
|
---
|
|
263
263
|
|
|
@@ -307,18 +307,18 @@ We would rather tell you up front when this is not a fit.
|
|
|
307
307
|
|
|
308
308
|
## Documentation
|
|
309
309
|
|
|
310
|
-
Browse and search everything on the **[docs site](https://cassiiopeia.github.io/projectops/en/)**.
|
|
310
|
+
Browse and search everything on the **[docs site](https://cassiiopeia.github.io/projectops/en/)**. The main guides are in English. A few wizard pages are still Korean only (translations are welcome, see [#799](https://github.com/Cassiiopeia/projectops/issues/799)).
|
|
311
311
|
|
|
312
312
|
- [Getting started](https://cassiiopeia.github.io/projectops/en/getting-started)
|
|
313
313
|
- [CLI reference](https://cassiiopeia.github.io/projectops/en/cli)
|
|
314
|
-
- [Agent Skills guide
|
|
315
|
-
- [Versioning
|
|
316
|
-
- [Changelog automation
|
|
317
|
-
- [PR Preview
|
|
318
|
-
- [Issue automation
|
|
319
|
-
- [SSH + Docker deploy
|
|
320
|
-
- [Flutter CI/CD
|
|
321
|
-
- [Troubleshooting
|
|
314
|
+
- [Agent Skills guide](https://cassiiopeia.github.io/projectops/en/skills)
|
|
315
|
+
- [Versioning](https://cassiiopeia.github.io/projectops/en/version-control)
|
|
316
|
+
- [Changelog automation](https://cassiiopeia.github.io/projectops/en/changelog-automation)
|
|
317
|
+
- [PR Preview](https://cassiiopeia.github.io/projectops/en/pr-preview)
|
|
318
|
+
- [Issue automation](https://cassiiopeia.github.io/projectops/en/issue-automation)
|
|
319
|
+
- [SSH + Docker deploy](https://cassiiopeia.github.io/projectops/en/ssh-docker-deployment-guide)
|
|
320
|
+
- [Flutter CI/CD](https://cassiiopeia.github.io/projectops/en/flutter-cicd-overview)
|
|
321
|
+
- [Troubleshooting](https://cassiiopeia.github.io/projectops/en/troubleshooting)
|
|
322
322
|
|
|
323
323
|
---
|
|
324
324
|
|
package/package.json
CHANGED
package/src/cli/args.js
CHANGED
|
@@ -15,7 +15,7 @@ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
|
15
15
|
// 유니코드 글자·숫자 허용 (#719) — 공백·셸 메타문자는 문자 클래스에 없어 그대로 거부된다.
|
|
16
16
|
const BRANCH_RE = /^[\p{L}\p{N}][\p{L}\p{N}._/-]*$/u;
|
|
17
17
|
// 허용 실행 모드 (#665) — 오타가 조용히 "복사 0건 성공"으로 끝나지 않게 한다.
|
|
18
|
-
export const MODE_VALUES = ["full", "version", "workflows", "issues", "skills", "doctor", "interactive"];
|
|
18
|
+
export const MODE_VALUES = ["full", "version", "workflows", "issues", "skills", "doctor", "options", "interactive"];
|
|
19
19
|
|
|
20
20
|
// argv(process.argv.slice(2)) → 파싱 결과. 오류 시 throw(호출부에서 exit 1).
|
|
21
21
|
export function parseArgs(argv) {
|
|
@@ -33,6 +33,8 @@ export function parseArgs(argv) {
|
|
|
33
33
|
includeSecretBackup: null,
|
|
34
34
|
aiPrSummary: null, // #566 — AI 변경 요약 워크플로우 포함 여부
|
|
35
35
|
projectsSync: null, // #716 — Projects 보드 동기화 워크플로우 포함 여부 (null=미지정)
|
|
36
|
+
json: false, // --json: 기계가 읽는 출력 (--mode options)
|
|
37
|
+
removeLegacy: false, // #809 — 구세대 배포 워크플로우(confirm 티어)도 .bak 으로 치운다
|
|
36
38
|
pathsCsv: "", // "flutter=app,react=client" 원문 (정규화는 resolve 단계)
|
|
37
39
|
force: false,
|
|
38
40
|
lang: null, // --lang en|ko (null = decided by src/i18n resolveLang)
|
|
@@ -170,6 +172,8 @@ export function parseArgs(argv) {
|
|
|
170
172
|
case "--no-ai-summary": result.aiPrSummary = false; break;
|
|
171
173
|
case "--projects-sync": result.projectsSync = true; break;
|
|
172
174
|
case "--no-projects-sync": result.projectsSync = false; break;
|
|
175
|
+
case "--remove-legacy": result.removeLegacy = true; break;
|
|
176
|
+
case "--json": result.json = true; break;
|
|
173
177
|
case "--npm-publish":
|
|
174
178
|
process.stderr.write(t("args.npmPublishDeprecated") + "\n");
|
|
175
179
|
result.publishTargets = [...new Set([...(result.publishTargets ?? []), "npm"])];
|
package/src/cli/help.js
CHANGED
|
@@ -7,8 +7,9 @@ Usage:
|
|
|
7
7
|
npx projectops [options]
|
|
8
8
|
|
|
9
9
|
Options:
|
|
10
|
-
-m, --mode MODE Mode (full | version | workflows | issues | skills | doctor)
|
|
10
|
+
-m, --mode MODE Mode (full | version | workflows | issues | skills | doctor | options)
|
|
11
11
|
doctor: diagnose the installation and repository settings (read-only)
|
|
12
|
+
options: print every flag and version.yml key; add --json for agents
|
|
12
13
|
default: interactive
|
|
13
14
|
-t, --type CSV Project types, comma separated (e.g. spring,react,python)
|
|
14
15
|
supported: spring flutter react react-native
|
|
@@ -26,7 +27,9 @@ Options:
|
|
|
26
27
|
--secret-backup / --no-secret-backup Include / exclude the secret backup workflow
|
|
27
28
|
--ai-summary / --no-ai-summary Include / exclude the PR summary workflow
|
|
28
29
|
--projects-sync / --no-projects-sync Include / exclude the GitHub Projects status sync workflow (default: off for new installs)
|
|
30
|
+
--remove-legacy Rename retired old-generation workflows to .bak (otherwise only listed)
|
|
29
31
|
--nexus / --npm-publish (deprecated: use --publish nexus / --publish npm)
|
|
32
|
+
--json With --mode options: machine-readable output
|
|
30
33
|
--force, -y, --yes Skip every confirmation and use non-interactive defaults
|
|
31
34
|
--lang en|ko Display language (default: system language, pinned to en in CI and with --force)
|
|
32
35
|
-v, --version Print the projectops version
|
|
@@ -45,8 +48,9 @@ const HELP_KO = `projectops — GitHub 프로젝트 자동화 템플릿 통합 C
|
|
|
45
48
|
npx projectops [옵션]
|
|
46
49
|
|
|
47
50
|
옵션:
|
|
48
|
-
-m, --mode MODE 통합 모드 (full | version | workflows | issues | skills | doctor)
|
|
51
|
+
-m, --mode MODE 통합 모드 (full | version | workflows | issues | skills | doctor | options)
|
|
49
52
|
doctor: 통합 상태·저장소 설정 진단 (읽기 전용)
|
|
53
|
+
options: 모든 플래그·version.yml 키 출력 (--json 이면 agent용 JSON)
|
|
50
54
|
기본: interactive (대화형)
|
|
51
55
|
-t, --type CSV 프로젝트 타입 csv (예: spring,react,python)
|
|
52
56
|
지원: spring flutter react react-native
|
|
@@ -64,7 +68,9 @@ const HELP_KO = `projectops — GitHub 프로젝트 자동화 템플릿 통합 C
|
|
|
64
68
|
--secret-backup / --no-secret-backup Secret 백업 워크플로우 포함/제외
|
|
65
69
|
--ai-summary / --no-ai-summary PR 변경 요약 워크플로우 포함/제외
|
|
66
70
|
--projects-sync / --no-projects-sync GitHub Projects 상태 동기화 워크플로우 포함/제외 (신규 설치 기본 제외)
|
|
71
|
+
--remove-legacy 은퇴한 구세대 워크플로우를 .bak 으로 치움 (없으면 목록만 안내)
|
|
67
72
|
--nexus / --npm-publish (deprecated — --publish nexus / --publish npm 사용)
|
|
73
|
+
--json --mode options 와 함께: 기계가 읽는 JSON 출력
|
|
68
74
|
--force, -y, --yes 모든 확인 생략, 비대화형 기본값 사용
|
|
69
75
|
--lang en|ko 화면 언어 (기본: 시스템 언어, CI/--force 는 en 고정)
|
|
70
76
|
-v, --version projectops 버전 출력
|
package/src/commands/doctor.js
CHANGED
|
@@ -19,6 +19,7 @@ import { parseExisting } from "../core/version-yml.js";
|
|
|
19
19
|
import { verifyInstall } from "../core/verify.js";
|
|
20
20
|
import { readBaseline } from "../core/baseline.js";
|
|
21
21
|
import { detectRepoName } from "../core/detect-fs.js";
|
|
22
|
+
import { detectMigrations } from "../core/migrations/index.js";
|
|
22
23
|
import { t } from "../i18n/index.js";
|
|
23
24
|
|
|
24
25
|
// 언어 전환(withLang 등) 시점에 평가되도록 함수로 둔다
|
|
@@ -149,6 +150,21 @@ export function localChecks(cwd = ".") {
|
|
|
149
150
|
});
|
|
150
151
|
}
|
|
151
152
|
|
|
153
|
+
// 은퇴한 구세대 워크플로우 (#809) — 업데이트는 감지만 하고 남겨 두므로 신형과 함께 돈다.
|
|
154
|
+
// 예전엔 로그에만 남아 doctor 가 "문제 없음"이라 했다.
|
|
155
|
+
const legacy = detectMigrations(cwd).confirm;
|
|
156
|
+
add({
|
|
157
|
+
name: t("doctor.legacy.name"), purpose: t("doctor.legacy.purpose"),
|
|
158
|
+
status: legacy.length ? "WARN" : "OK",
|
|
159
|
+
value: legacy.length ? t("doctor.count", { n: legacy.length }) : t("doctor.none"),
|
|
160
|
+
detail: legacy.length ? [
|
|
161
|
+
...legacy.map((e) => ` ${e.file}${e.replacedBy ? ` → ${e.replacedBy}` : ""}`),
|
|
162
|
+
t("doctor.legacy.l1"),
|
|
163
|
+
...legacy.map((e) => ` git rm .github/workflows/${e.file}`),
|
|
164
|
+
t("doctor.legacy.l2"),
|
|
165
|
+
] : null,
|
|
166
|
+
});
|
|
167
|
+
|
|
152
168
|
const baseline = readBaseline(cwd);
|
|
153
169
|
add({
|
|
154
170
|
name: t("doctor.bl.name"), purpose: t("doctor.bl.purpose"),
|
package/src/commands/full.js
CHANGED
|
@@ -5,7 +5,8 @@ import { join } from "node:path";
|
|
|
5
5
|
import { existsSync, readFileSync } from "node:fs";
|
|
6
6
|
import { writeText } from "../core/fsutil.js";
|
|
7
7
|
import { PATHS } from "../core/paths.js";
|
|
8
|
-
import {
|
|
8
|
+
import { installAgentGuide } from "../core/agent-guide.js";
|
|
9
|
+
import { buildVersionYml, mergeDeployValues, resolveUpdatedBy } from "../core/version-yml.js";
|
|
9
10
|
import { markerForType } from "../core/detect.js";
|
|
10
11
|
import { addVersionSectionToReadme } from "../core/copy/readme.js";
|
|
11
12
|
import { copyWorkflows } from "../core/copy/workflows.js";
|
|
@@ -29,7 +30,7 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
29
30
|
force = true, now, today, templateVersion = "unknown",
|
|
30
31
|
deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, aiPrSummary = true,
|
|
31
32
|
changelogProvider = "commit", changelogBaseUrl = "", codeReviewCoderabbit = true,
|
|
32
|
-
deployBranch = "", intent = null, semverAuto = true , appRelease = null, labelStyle = "en", closeOnRelease = null, projectsSync = null, language = "en" } = context;
|
|
33
|
+
deployBranch = "", intent = null, semverAuto = true , appRelease = null, labelStyle = "en", closeOnRelease = null, projectsSync = null, language = "en", excludedWorkflows = null } = context;
|
|
33
34
|
|
|
34
35
|
// project_paths 마커 계산 (.sh existing_marker_in_dir 등가 — 대표 마커명)
|
|
35
36
|
const pathMarkers = new Map();
|
|
@@ -50,8 +51,9 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
50
51
|
buildVersionYml({
|
|
51
52
|
version, types, paths, pathMarkers, branch, deployBranch, versionCode, now, today,
|
|
52
53
|
deployValues,
|
|
54
|
+
updatedBy: resolveUpdatedBy(existsSync(vyFile) ? readFileSync(vyFile, "utf8") : "", targetRoot), // #811
|
|
53
55
|
templateOptions: { templateVersion, deployTarget, publishTargets, includeSecretBackup, aiPrSummary, optionsDate: today,
|
|
54
|
-
changelogProvider, changelogBaseUrl, codeReviewCoderabbit, intent, mode: "full", semverAuto, appRelease, labelStyle, closeOnRelease, projectsSync, language },
|
|
56
|
+
changelogProvider, changelogBaseUrl, codeReviewCoderabbit, intent, mode: "full", semverAuto, appRelease, labelStyle, closeOnRelease, projectsSync, language, excludedWorkflows },
|
|
55
57
|
})), { version, versionCode });
|
|
56
58
|
|
|
57
59
|
// 2. README 버전 섹션
|
|
@@ -86,6 +88,7 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
86
88
|
{ enabled: codeReviewCoderabbit });
|
|
87
89
|
step("ensure-gitignore", () => ensureGitignore(targetRoot));
|
|
88
90
|
step("copy-setup-guide", () => copySetupGuide(tempDir, targetRoot));
|
|
91
|
+
step("install-agent-guide", () => installAgentGuide(targetRoot, templateVersion)); // agent 가 옵션을 확인하는 법 안내
|
|
89
92
|
|
|
90
93
|
// 상태 라벨 표기(#776) — 템플릿 원본은 영문이라, 한글 레포는 복사가 끝난 뒤 이름만 되돌린다.
|
|
91
94
|
step("apply-label-style", () => applyLabelStyle(targetRoot, labelStyle), { labelStyle });
|
|
@@ -137,11 +137,13 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
137
137
|
let deployBranchCreated = null; // #493 — 마법사가 직접 생성했는지 (가이드 기록용)
|
|
138
138
|
let intent = existing?.options?.intent ?? null; // #485 프로젝트 성격
|
|
139
139
|
// semver 자동 승격(#546) — 질문하지 않는다(#485 질문 부담 축소 방향 유지).
|
|
140
|
-
//
|
|
141
|
-
const semverAuto = existing?.options?.semverAuto ??
|
|
140
|
+
// 저장값(명시적 false 포함)은 보존하고, 키가 없으면 기존 레포도 ON. 켜진 사실은 완료 화면이 알린다.
|
|
141
|
+
const semverAuto = existing?.options?.semverAuto ?? true;
|
|
142
|
+
const semverAutoNewlyOn = !!existing && existing?.options?.semverAuto == null;
|
|
142
143
|
const appRelease = existing?.options?.appRelease ?? null; // #553 저장값 보존 (묻지 않음)
|
|
143
144
|
// 상태 라벨 표기(#776): 저장값 > (신규 en / 기존 ko). 기존 한글 레포에는 영문 전환을 한 번 제안한다(아래).
|
|
144
145
|
const closeOnRelease = existing?.options?.closeOnRelease ?? (existing ? null : true); // #771 묻지 않는다
|
|
146
|
+
const excludedWorkflows = existing?.options?.excludedWorkflows ?? null; // #810 저장값 보존
|
|
145
147
|
// Projects 보드 동기화(#716): 묻지 않는다. 저장값 > 이미 설치돼 있으면 유지 > 신규는 제외 (index.js 와 같은 규칙).
|
|
146
148
|
const projectsSync = existing?.options?.projectsSync
|
|
147
149
|
?? existsSync(join(cwd, ".github/workflows/PROJECT-COMMON-PROJECTS-SYNC-MANAGER.yaml"));
|
|
@@ -328,7 +330,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
328
330
|
const { now, today } = clock || utcNow();
|
|
329
331
|
const ctx = createContext({
|
|
330
332
|
mode, force: true, types, version, versionCode, branch, paths, deployTarget, publishTargets, includeSecretBackup,
|
|
331
|
-
codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch, intent, semverAuto, appRelease, labelStyle, closeOnRelease, projectsSync, language,
|
|
333
|
+
codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch, intent, semverAuto, appRelease, labelStyle, closeOnRelease, projectsSync, language, excludedWorkflows,
|
|
332
334
|
repoName, templateVersion, resolvers, envValues, envUseDefaults, now, today,
|
|
333
335
|
// #502 — version 모드가 기존 full 기록을 강등하지 않도록 (full이 우세)
|
|
334
336
|
recordMode: existing?.templateMode === "full" ? "full" : "version",
|
|
@@ -423,7 +425,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
423
425
|
migrationGuidePath = appendGuideEntry(cwd, {
|
|
424
426
|
now, mode, types, repoName,
|
|
425
427
|
templateFrom: existing?.templateVersion || "", templateTo: templateVersion,
|
|
426
|
-
options: { deploy: deployTarget, publish: publishTargets, secretBackup: includeSecretBackup, coderabbit: codeReviewCoderabbit, changelogProvider, intent, semverAuto , appRelease, labelStyle, closeOnRelease, projectsSync, language },
|
|
428
|
+
options: { deploy: deployTarget, publish: publishTargets, secretBackup: includeSecretBackup, coderabbit: codeReviewCoderabbit, changelogProvider, intent, semverAuto , appRelease, labelStyle, closeOnRelease, projectsSync, language, excludedWorkflows },
|
|
427
429
|
branches: { defaultBranch: branch, deployBranch, ready: deployBranchReady, created: deployBranchCreated },
|
|
428
430
|
breaking: breakingReport, migrations: migrationsResult, orphans: orphanReport,
|
|
429
431
|
events: trace.events, counters: { skipped: result?.workflows?.skipped ?? 0 },
|
|
@@ -437,6 +439,9 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
437
439
|
counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
|
|
438
440
|
skippedConflicts: result?.workflows?.skippedConflicts ?? [], // #654 병합 안내
|
|
439
441
|
replacedBak: result?.workflows?.replacedBak ?? [], // #673 기준점 없이 교체한 파일 안내
|
|
442
|
+
legacyLeftover: migrationsResult?.confirmPending ?? [], // #809 신·구 세대 동시 실행 안내
|
|
443
|
+
notInstalled: result?.workflows?.notInstalled ?? [], // #810 깔지 않은 워크플로우 안내
|
|
444
|
+
semverAutoNewlyOn,
|
|
440
445
|
verification: result?.verification, // #549 설치 검증 결과
|
|
441
446
|
aiPrSummary, codeReviewCoderabbit, // #569 — 고른 것만 안내
|
|
442
447
|
logDir: files ? MIGRATION_DIR : null, // #561 기록 위치 안내
|
package/src/commands/version.js
CHANGED
|
@@ -5,7 +5,8 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { writeText } from "../core/fsutil.js";
|
|
7
7
|
import { PATHS } from "../core/paths.js";
|
|
8
|
-
import {
|
|
8
|
+
import { installAgentGuide } from "../core/agent-guide.js";
|
|
9
|
+
import { buildVersionYml, mergeDeployValues, resolveUpdatedBy } from "../core/version-yml.js";
|
|
9
10
|
import { markerForType } from "../core/detect.js";
|
|
10
11
|
import { addVersionSectionToReadme } from "../core/copy/readme.js";
|
|
11
12
|
import { applyLabelStyle } from "../core/label-style.js";
|
|
@@ -16,7 +17,7 @@ export function runVersion(context, tempDir, targetRoot = ".") {
|
|
|
16
17
|
const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
|
|
17
18
|
now, today, templateVersion = "unknown", deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false,
|
|
18
19
|
changelogProvider = "commit", changelogBaseUrl = "", codeReviewCoderabbit = true,
|
|
19
|
-
deployBranch = "", recordMode = "version", semverAuto = true , appRelease = null, labelStyle = "en", closeOnRelease = null, projectsSync = null, language = null } = context;
|
|
20
|
+
deployBranch = "", recordMode = "version", semverAuto = true , appRelease = null, labelStyle = "en", closeOnRelease = null, projectsSync = null, language = null, excludedWorkflows = null } = context;
|
|
20
21
|
|
|
21
22
|
const pathMarkers = new Map();
|
|
22
23
|
for (const [t] of paths) pathMarkers.set(t, markerForType(t));
|
|
@@ -29,10 +30,11 @@ export function runVersion(context, tempDir, targetRoot = ".") {
|
|
|
29
30
|
buildVersionYml({
|
|
30
31
|
version, types, paths, pathMarkers, branch, deployBranch, versionCode, now, today,
|
|
31
32
|
deployValues,
|
|
33
|
+
updatedBy: resolveUpdatedBy(existsSync(vyFile) ? readFileSync(vyFile, "utf8") : "", targetRoot), // #811
|
|
32
34
|
// mode(#502): version 모드가 기존 full 통합 기록을 "version"으로 강등하지 않도록
|
|
33
35
|
// 호출부가 recordMode로 기존 값을 넘긴다 (full이 우세 — 업데이트 재실행 범위 축소 방지).
|
|
34
36
|
templateOptions: { templateVersion, deployTarget, publishTargets, includeSecretBackup, optionsDate: today,
|
|
35
|
-
changelogProvider, changelogBaseUrl, codeReviewCoderabbit, mode: recordMode, semverAuto, appRelease, labelStyle, closeOnRelease, projectsSync, language },
|
|
37
|
+
changelogProvider, changelogBaseUrl, codeReviewCoderabbit, mode: recordMode, semverAuto, appRelease, labelStyle, closeOnRelease, projectsSync, language, excludedWorkflows },
|
|
36
38
|
}));
|
|
37
39
|
addVersionSectionToReadme(version, targetRoot);
|
|
38
40
|
copyScripts(tempDir, targetRoot);
|
|
@@ -40,4 +42,5 @@ export function runVersion(context, tempDir, targetRoot = ".") {
|
|
|
40
42
|
applyLabelStyle(targetRoot, labelStyle); // #776
|
|
41
43
|
ensureGitignore(targetRoot);
|
|
42
44
|
copySetupGuide(tempDir, targetRoot);
|
|
45
|
+
installAgentGuide(targetRoot, templateVersion);
|
|
43
46
|
}
|
|
@@ -9,6 +9,7 @@ import { applyLabelStyle } from "../core/label-style.js";
|
|
|
9
9
|
import { copyScripts, copyConfigFolder, copySetupGuide } from "../core/copy/simple.js";
|
|
10
10
|
import { copyUtilModules } from "../core/copy/util.js";
|
|
11
11
|
import { convertLegacySingularType, mergeDeployValues } from "../core/version-yml.js";
|
|
12
|
+
import { installAgentGuide } from "../core/agent-guide.js";
|
|
12
13
|
import { verifyInstall } from "../core/verify.js";
|
|
13
14
|
|
|
14
15
|
export function runWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
@@ -35,6 +36,7 @@ export function runWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
35
36
|
applyLabelStyle(targetRoot, labelStyle); // #776 — 워크플로우(QA 봇)와 라벨 정의를 같은 표기로 맞춘다
|
|
36
37
|
for (const t of types) copyUtilModules(tempDir, t, { force }, targetRoot);
|
|
37
38
|
copySetupGuide(tempDir, targetRoot);
|
|
39
|
+
installAgentGuide(targetRoot, context.templateVersion);
|
|
38
40
|
|
|
39
41
|
// 설치 후 검증 (#549) — 워크플로우만 설치하는 모드라 오히려 더 필요하다.
|
|
40
42
|
const verification = verifyInstall(targetRoot);
|
package/src/context.js
CHANGED
|
@@ -31,6 +31,7 @@ export function createContext(overrides = {}) {
|
|
|
31
31
|
closeOnRelease: null, // 릴리스 시 완료 이슈 닫기 (#771). null=미설정(신규 true / 기존 레포 키 없음)
|
|
32
32
|
language: null, // 레포 문구 언어 (#769). null=미설정(신규 en / 기존 레포 ko)
|
|
33
33
|
labelStyle: null, // 상태 라벨 표기 (#776). null=미설정(신규 en / 기존 레포 ko)
|
|
34
|
+
excludedWorkflows: null, // 설치하지 않을 워크플로우 파일명 (#810). null=없음
|
|
34
35
|
appRelease: null, // 앱 심사 배포 레포인가 (#553). null=미설정(키 기록 안 함)
|
|
35
36
|
templateVersion: "",
|
|
36
37
|
tempDir: "",
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// 사용자 레포에 설치하는 AI agent 안내문 (.github/.projectops/AGENT-GUIDE.md).
|
|
2
|
+
//
|
|
3
|
+
// 왜 파일인가: 이 레포의 AGENTS.md 는 템플릿 전용이라 사용자 프로젝트로 가지 않는다. 그래서 projectops 가
|
|
4
|
+
// 깔린 레포에서 일하는 agent 는 "이 레포가 무엇으로 관리되는지, 옵션을 어디서 확인하는지"를 알 길이 없었다.
|
|
5
|
+
// 레포 루트의 AGENTS.md·CLAUDE.md 는 사용자 것이라 건드리지 않고, 폴더 안에 두고 한 줄 포인터만 안내한다.
|
|
6
|
+
//
|
|
7
|
+
// 내용에 옵션 표를 박지 않는다 — 박으면 곧 낡는다. 표는 항상 `npx projectops --mode options --json` 이 정본이다.
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { writeText } from "./fsutil.js";
|
|
10
|
+
|
|
11
|
+
export const AGENT_GUIDE_PATH = ".github/.projectops/AGENT-GUIDE.md";
|
|
12
|
+
|
|
13
|
+
export function agentGuideText(templateVersion = "") {
|
|
14
|
+
return `# projectops — guide for AI agents
|
|
15
|
+
|
|
16
|
+
This repository is managed with [projectops](https://github.com/Cassiiopeia/projectops)${templateVersion ? ` (installed template ${templateVersion})` : ""}.
|
|
17
|
+
GitHub Actions workflows in \`.github/workflows/PROJECT-*\` handle versioning, release notes, CI/CD and issue automation.
|
|
18
|
+
This file is generated by the installer and overwritten on update. Do not edit it.
|
|
19
|
+
|
|
20
|
+
## Ask the tool, do not guess
|
|
21
|
+
|
|
22
|
+
\`\`\`bash
|
|
23
|
+
npx projectops --mode options --json # every CLI flag and every version.yml key, with allowed values and defaults
|
|
24
|
+
npx projectops --mode doctor # read-only diagnosis of this install and the repository settings
|
|
25
|
+
\`\`\`
|
|
26
|
+
|
|
27
|
+
Both are read-only and need no network.
|
|
28
|
+
|
|
29
|
+
## Rules
|
|
30
|
+
|
|
31
|
+
- Settings live in \`version.yml\` under \`metadata.template.options\`. The options table says which keys are safe to edit by hand.
|
|
32
|
+
- \`version\` in \`version.yml\` is the single source of the project version. Workflows bump it on release; do not bump it by hand unless asked.
|
|
33
|
+
- Commit titles decide the release bump when \`semver_auto\` is on: \`feat:\` = minor, \`feat!:\` = major, anything else = patch.
|
|
34
|
+
- Running the installer again updates workflows. Files you changed are kept (a copy of the new version is saved in \`.github/.projectops/incoming/\`). Files you deleted are not restored.
|
|
35
|
+
- To stop a template workflow from ever being installed, list its file name in \`options.excluded_workflows\`.
|
|
36
|
+
- Run the installer from an agent or CI only with an explicit mode and \`--force\`, e.g. \`npx projectops --mode workflows --force\`. Without \`--force\` it asks questions and blocks.
|
|
37
|
+
- Do not edit \`.github/.projectops/logs\`, \`baseline.json\` or \`incoming/\`. They are diagnostics for the next update.
|
|
38
|
+
`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function installAgentGuide(targetRoot = ".", templateVersion = "") {
|
|
42
|
+
writeText(join(targetRoot, AGENT_GUIDE_PATH), agentGuideText(templateVersion));
|
|
43
|
+
return AGENT_GUIDE_PATH;
|
|
44
|
+
}
|
package/src/core/copy/simple.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// 단순 복사 함수 (무조건 덮어쓰기류) — .sh copy_scripts/config/issue/discussion/setup_guide 등가.
|
|
2
2
|
// 실측: template_integrator.sh 3818, 3845, 3872, 3895, 4114.
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { join, basename } from "node:path";
|
|
4
4
|
import { chmodSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { PATHS } from "../paths.js";
|
|
6
6
|
import { exists, copyFileSync, copyDirSync } from "../fsutil.js";
|
|
@@ -97,7 +97,8 @@ export function stripIssueTemplateAssignees(targetRoot = ".") {
|
|
|
97
97
|
// .github/ISSUE_TEMPLATE/ 전체 + PULL_REQUEST_TEMPLATE.md 덮어쓰기.
|
|
98
98
|
export function copyIssueTemplates(tempDir, targetRoot = ".") {
|
|
99
99
|
const srcIssue = join(tempDir, ".github", "ISSUE_TEMPLATE");
|
|
100
|
-
|
|
100
|
+
// `projectops-` 로 시작하는 파일은 이 저장소(projectops 자체)의 이슈 양식이라 사용자 레포로 복사하지 않는다 (#797).
|
|
101
|
+
if (exists(srcIssue)) copyDirSync(srcIssue, join(targetRoot, ".github", "ISSUE_TEMPLATE"), { filter: (src) => !basename(src).startsWith("projectops-") });
|
|
101
102
|
const srcPr = join(tempDir, ".github", "PULL_REQUEST_TEMPLATE.md");
|
|
102
103
|
if (exists(srcPr)) copyFileSync(srcPr, join(targetRoot, ".github", "PULL_REQUEST_TEMPLATE.md"));
|
|
103
104
|
}
|
|
@@ -6,10 +6,29 @@ 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
|
+
|
|
15
|
+
// on: 블록의 브랜치 필터가 전부 develop 뿐인가 (#810).
|
|
16
|
+
// 개발 브랜치가 없는 레포에 깔면 영영 실행되지 않는 워크플로우를 가린다.
|
|
17
|
+
// workflow_dispatch 외의 다른 이벤트(schedule·issues 등)가 있으면 그것만으로도 돌 수 있으니 false.
|
|
18
|
+
export function onlyTriggersOnDevelop(content) {
|
|
19
|
+
const m = String(content).match(/^on:\s*\n((?:[ \t]+.*\n|\s*\n)+)/m);
|
|
20
|
+
if (!m) return false;
|
|
21
|
+
const block = m[1];
|
|
22
|
+
const events = [...block.matchAll(/^ {2}([a-z_]+):/gm)].map((x) => x[1]);
|
|
23
|
+
if (!events.length || events.some((e) => !["push", "pull_request", "workflow_dispatch"].includes(e))) return false;
|
|
24
|
+
const branches = [];
|
|
25
|
+
for (const b of block.matchAll(/^\s+branches:\s*\[([^\]]*)\]/gm)) branches.push(...b[1].split(",").map((s) => s.trim().replace(/["']/g, "")).filter(Boolean));
|
|
26
|
+
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]));
|
|
27
|
+
// push/pull_request 마다 branches 필터가 있어야 한다 — 필터 없는 push 는 모든 브랜치에서 돈다
|
|
28
|
+
const filtered = (block.match(/^\s+branches:/gm) || []).length;
|
|
29
|
+
const needFilter = events.filter((e) => e !== "workflow_dispatch").length;
|
|
30
|
+
return branches.length > 0 && filtered >= needFilter && branches.every((b) => b === "develop");
|
|
31
|
+
}
|
|
13
32
|
|
|
14
33
|
// 한 파일에 env 치환을 적용해 대상 파일을 갱신 (.sh configure_workflow_env 등가).
|
|
15
34
|
// values/useDefaults: env 계획(promptEnvPlan) 결과 — 미지정이면 기본값 경로(현행 force 동작).
|
|
@@ -90,6 +109,43 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
90
109
|
// values/useDefaults는 치환 경로에서만 의미 (isUnchanged는 내부에서 useDefaults:true 강제 — 가상 비교 무손상)
|
|
91
110
|
const envOptsFor = (type) => ({ type, projectPath: paths.get(type) || ".", repoName, resolvers, values: envValues, useDefaults: envUseDefaults, branches });
|
|
92
111
|
|
|
112
|
+
// 설치하지 않을 새 파일 (#810) — 깔 때마다 다시 생기던 것들.
|
|
113
|
+
// excluded : version.yml options.excluded_workflows 에 사용자가 적은 파일
|
|
114
|
+
// deleted-by-user : 우리가 깐 기록(baseline)이 있는데 지금 없다 = 사용자가 지웠다 (.bak 은 우리가 치운 것이라 제외)
|
|
115
|
+
// no-dev-branch : 개발 브랜치가 원격에 확실히 없는데 그 브랜치에서만 도는 워크플로우
|
|
116
|
+
const notInstalled = [];
|
|
117
|
+
Object.defineProperty(counters, "notInstalled", { value: notInstalled, enumerable: false });
|
|
118
|
+
const excludedSet = new Set(context.excludedWorkflows || []);
|
|
119
|
+
const devName = branches.deployBranch;
|
|
120
|
+
let devMissing = null; // 필요할 때 한 번만 원격을 조회한다
|
|
121
|
+
const isDevMissing = () => {
|
|
122
|
+
if (devMissing === null) {
|
|
123
|
+
const st = devName === branches.defaultBranch ? null : branchStatus(targetRoot, devName);
|
|
124
|
+
devMissing = !!(st && st.isRepo && !st.local && st.remote === false);
|
|
125
|
+
}
|
|
126
|
+
return devMissing;
|
|
127
|
+
};
|
|
128
|
+
const skipNew = (srcDir, filename, group) => {
|
|
129
|
+
const dst = join(workflowsDir, filename);
|
|
130
|
+
if (existsSync(dst)) return false;
|
|
131
|
+
let reason = null;
|
|
132
|
+
if (excludedSet.has(filename)) reason = "excluded";
|
|
133
|
+
else if (baseline?.files?.[filename] && !existsSync(dst + ".bak")) reason = "deleted-by-user";
|
|
134
|
+
else if (onlyTriggersOnDevelop(readFileSync(join(srcDir, filename), "utf8")) && isDevMissing()) reason = "no-dev-branch";
|
|
135
|
+
if (!reason) return false;
|
|
136
|
+
const info = { filename, reason, incoming: "" };
|
|
137
|
+
try {
|
|
138
|
+
// 마음이 바뀌면 cp 한 번으로 되살릴 수 있게 렌더본을 incoming 에 남긴다
|
|
139
|
+
const rendered = substituteBranches(substituteEnv(readFileSync(join(srcDir, filename), "utf8"),
|
|
140
|
+
{ ...envOptsFor(group.split("/")[0]), useDefaults: true }), branches);
|
|
141
|
+
info.incoming = saveIncoming(targetRoot, filename, rendered);
|
|
142
|
+
} catch { /* 안내만 빠진다 */ }
|
|
143
|
+
notInstalled.push(info);
|
|
144
|
+
trace?.event("copy", "not-installed", filename, { group, reason });
|
|
145
|
+
return true;
|
|
146
|
+
};
|
|
147
|
+
Object.defineProperty(counters, "skipNew", { value: skipNew, enumerable: false });
|
|
148
|
+
|
|
93
149
|
// 사용자 수정본을 유지할 때 새 템플릿 사본을 incoming에 남긴다 (#654).
|
|
94
150
|
// 실패는 경고 한 줄로 흡수한다 — 병합 안내 때문에 복사 전체가 죽으면 안 된다.
|
|
95
151
|
const onSkip = (srcDir, filename, type) => {
|
|
@@ -133,6 +189,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
133
189
|
continue;
|
|
134
190
|
}
|
|
135
191
|
// 사용자가 손댄 적 없고 템플릿만 바뀐 파일 — 물어볼 것 없이 최신으로 올린다(종전과 동일).
|
|
192
|
+
if (commonClass.newFiles.includes(filename) && skipNew(commonDir, filename, "common")) continue;
|
|
136
193
|
if (commonClass.newFiles.includes(filename) || commonClass.upstream.includes(filename)) {
|
|
137
194
|
copyFileSync(join(commonDir, filename), join(workflowsDir, filename));
|
|
138
195
|
counters.copied++;
|
|
@@ -179,6 +236,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
179
236
|
trace?.event("copy", "skipped-unchanged", filename, { group: "common-deploy" });
|
|
180
237
|
continue;
|
|
181
238
|
}
|
|
239
|
+
if (skipNew(commonDeployDir, filename, "common-deploy")) continue;
|
|
182
240
|
const backedUp = existsSync(dst);
|
|
183
241
|
if (backedUp) renameSync(dst, dst + ".bak");
|
|
184
242
|
copyFileSync(src, dst);
|
|
@@ -207,6 +265,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
207
265
|
trace?.event("copy", "skipped-unchanged", filename, { group: groupName });
|
|
208
266
|
continue;
|
|
209
267
|
}
|
|
268
|
+
if (skipNew(prSummaryDir, filename, groupName)) continue;
|
|
210
269
|
const backedUp = existsSync(dst);
|
|
211
270
|
if (backedUp) renameSync(dst, dst + ".bak");
|
|
212
271
|
copyFileSync(src, dst);
|
|
@@ -225,6 +284,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
225
284
|
for (const filename of listYamlFiles(secretDir)) {
|
|
226
285
|
const dst = join(workflowsDir, filename);
|
|
227
286
|
if (existsSync(dst)) continue; // 이미 존재하면 스킵
|
|
287
|
+
if (skipNew(secretDir, filename, "secret-backup")) continue;
|
|
228
288
|
copyFileSync(join(secretDir, filename), dst);
|
|
229
289
|
// PROJECT_NAME 이 고정값이면 모든 프로젝트가 서버의 같은 폴더에 Secret 을 덮어쓴다 (#729) —
|
|
230
290
|
// 다른 배포 워크플로처럼 레포 이름으로 채운다. 이름을 알 수 없으면 기존 기본값을 쓴다.
|
|
@@ -377,15 +437,25 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
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
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 };
|