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 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.3 (2026-10-07)
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/SKILLS.md](docs/SKILLS.md)** (Korean)
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/SKILLS.md).
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/SKILLS.md) for details.
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/PR-PREVIEW.md) | [Flutter builds](docs/FLUTTER-TEST-BUILD-TRIGGER.md) | [Issue automation](docs/ISSUE-AUTOMATION.md)
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/)**. Most guides are currently Korean only.
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 (Korean)](https://cassiiopeia.github.io/projectops/SKILLS)
315
- - [Versioning (Korean)](https://cassiiopeia.github.io/projectops/VERSION-CONTROL)
316
- - [Changelog automation (Korean)](https://cassiiopeia.github.io/projectops/CHANGELOG-AUTOMATION)
317
- - [PR Preview (Korean)](https://cassiiopeia.github.io/projectops/PR-PREVIEW)
318
- - [Issue automation (Korean)](https://cassiiopeia.github.io/projectops/ISSUE-AUTOMATION)
319
- - [SSH + Docker deploy (Korean)](https://cassiiopeia.github.io/projectops/SSH-DOCKER-DEPLOYMENT-GUIDE)
320
- - [Flutter CI/CD (Korean)](https://cassiiopeia.github.io/projectops/FLUTTER-CICD-OVERVIEW)
321
- - [Troubleshooting (Korean)](https://cassiiopeia.github.io/projectops/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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectops",
3
- "version": "4.36.4",
3
+ "version": "4.37.0",
4
4
  "description": "ProjectOps: installer CLI for a fully automated GitHub project management template (versioning, CI/CD, release notes, agent skills)",
5
5
  "keywords": [
6
6
  "devops",
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 버전 출력
@@ -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"),
@@ -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 { buildVersionYml, mergeDeployValues } from "../core/version-yml.js";
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
- // 저장값이 있으면 보존, 없으면 신규 통합만 ON. 기존 레포는 업데이트만으로 버전이 튀지 않는다.
141
- const semverAuto = existing?.options?.semverAuto ?? (existing ? false : true);
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 기록 위치 안내
@@ -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 { buildVersionYml, mergeDeployValues } from "../core/version-yml.js";
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
+ }
@@ -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
- if (exists(srcIssue)) copyDirSync(srcIssue, join(targetRoot, ".github", "ISSUE_TEMPLATE"));
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) { copyFileSync(join(typeDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "upstream-updated", f, { group: type, reason: "baseline-match" }); }
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) { copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "upstream-updated", f, { group: `${type}/server-deploy`, reason: "baseline-match" }); }
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
- configureEnv(target, filename, { ...envOpts, collectAsks, trace }); // env 계획 values/useDefaults 포함
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
  }
@@ -9,6 +9,7 @@ export const DOCS_TO_REMOVE = [
9
9
  "CLAUDE.md",
10
10
  "AGENTS.md",
11
11
  "GEMINI.md",
12
+ "llms.txt",
12
13
  "gemini-extension.json",
13
14
  // 릴리스마다 changelog-deploy 파이프라인이 재생성하는 이 레포 전용 작업 산출물.
14
15
  // 사용자 프로젝트에는 의미가 없으므로 복사하지 않는다.
@@ -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
- log(t("migrate.confirm.hint"));
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 };