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.
@@ -0,0 +1,145 @@
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
+ export const SCHEMA_VERSION = 1;
15
+
16
+ export const MODES = [
17
+ { name: "interactive", description: "Default. Asks questions in the terminal. Do not use from an agent or CI." },
18
+ { name: "full", description: "Install everything: version.yml, workflows, scripts, templates, setup guide." },
19
+ { name: "version", description: "Only version.yml, README version section and version scripts." },
20
+ { name: "workflows", description: "Only GitHub Actions workflows (+ scripts, config, util). Existing version.yml is kept." },
21
+ { name: "issues", description: "Only issue and discussion templates." },
22
+ { name: "skills", description: "Install or update agent skills for supported IDEs." },
23
+ { name: "doctor", description: "Read-only diagnosis of the install and repository settings. Safe to run any time." },
24
+ { name: "options", description: "Print this option table (add --json for machine-readable output). Read-only, no network." },
25
+ ];
26
+
27
+ // 프로젝트 타입은 context.js 의 VALID_TYPES 와 같아야 한다 (테스트가 대조).
28
+ export const FLAGS = [
29
+ { flag: "--mode", alias: "-m", value: "MODE", values: MODES.map((m) => m.name), default: "interactive",
30
+ description: "What to do. Agents and CI should always pass an explicit non-interactive mode together with --force." },
31
+ { flag: "--type", alias: "-t", value: "CSV", default: "auto-detected",
32
+ description: "Project types, comma separated. The first one is the primary type.",
33
+ values: ["spring", "flutter", "react", "react-native", "react-native-expo", "node", "python", "basic"] },
34
+ { flag: "--project-version", value: "VERSION", default: "auto-detected",
35
+ description: "Initial version of the target project (x.y.z)." },
36
+ { flag: "--paths", value: "type=path,...", default: "repo root",
37
+ description: "Per-type project folders for monorepos, e.g. flutter=app,react=client.", versionYml: "project_paths" },
38
+ { flag: "--intent", value: "KIND", values: ["app", "library", "both", "none", "manual"], default: "inferred from --deploy/--publish",
39
+ description: "Project kind. Decides whether deploy and publish questions are asked.", versionYml: "metadata.template.options.intent" },
40
+ { flag: "--deploy", value: "TARGET", values: ["docker-ssh", "vercel", "none"], default: "docker-ssh",
41
+ description: "Where the app is deployed. Pick one.", versionYml: "metadata.template.options.deploy" },
42
+ { flag: "--publish", value: "CSV", values: ["nexus", "npm", "github-packages"], default: "none",
43
+ description: "Registries a library is published to. Several allowed.", versionYml: "metadata.template.options.publish" },
44
+ { flag: "--deploy-branch", value: "NAME", default: "develop",
45
+ description: "Development branch: the head of the release PR. Not the repository default branch.", versionYml: "metadata.deploy_branch" },
46
+ { flag: "--language", value: "LANG", values: ["en", "ko"], default: "en for new installs, ko for existing installs",
47
+ description: "Language of issue/PR templates and bot messages written into the repo.", versionYml: "metadata.template.options.language" },
48
+ { flag: "--label-style", value: "STYLE", values: ["en", "ko"], default: "en for new installs, ko for existing installs",
49
+ description: "Status label names: en = 'status: todo', ko = legacy Korean names.", versionYml: "metadata.template.options.label_style" },
50
+ { flag: "--secret-backup", negation: "--no-secret-backup",
51
+ description: "Include / exclude the workflow that uploads GitHub Secrets to a server over SSH.", default: "excluded",
52
+ versionYml: "metadata.template.options.secret_backup" },
53
+ { flag: "--ai-summary", negation: "--no-ai-summary",
54
+ description: "Include / exclude the workflow that comments an AI summary on work PRs.", default: "included",
55
+ versionYml: "metadata.template.options.code_review.ai_summary" },
56
+ { flag: "--projects-sync", negation: "--no-projects-sync",
57
+ description: "Include / exclude the GitHub Projects status sync workflow. Needs a PAT secret and PROJECT_URL variable.",
58
+ default: "excluded for new installs; kept if already installed", versionYml: "metadata.template.options.projects_sync" },
59
+ { flag: "--remove-legacy",
60
+ description: "Rename retired old-generation workflows to .bak. Without it they are only listed in the summary and in doctor.", default: "off" },
61
+ { flag: "--force", alias: "-y", aliases: ["-y", "--yes"], description: "Skip every confirmation and use non-interactive defaults. Required for agents and CI.", default: "off" },
62
+ { flag: "--lang", value: "LANG", values: ["en", "ko"], default: "system language; pinned to en in CI and with --force",
63
+ description: "Language of the CLI screen. Not the language written into the repo (that is --language)." },
64
+ { flag: "--json", description: "With --mode options: print machine-readable JSON instead of a table.", default: "off" },
65
+ { flag: "--version", alias: "-v", description: "Print the projectops package version and exit." },
66
+ { flag: "--help", alias: "-h", description: "Print the human-readable help and exit." },
67
+ // 지원 종료 예정 — 새 설정에는 쓰지 않는다
68
+ { flag: "--nexus", negation: "--no-nexus", deprecated: "Use --publish nexus. Also implies --deploy none." },
69
+ { flag: "--npm-publish", negation: "--no-npm-publish", deprecated: "Use --publish npm." },
70
+ ];
71
+
72
+ // version.yml 의 키. path 는 파일 안의 위치, parserKey 는 parseTemplateOptions 가 돌려주는 이름.
73
+ export const VERSION_YML = [
74
+ { path: "version", type: "string", description: "Project version (x.y.z). The single source of truth; workflows sync it into build files.", edit: "workflow-managed" },
75
+ { 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" },
76
+ { path: "project_types", type: "string[]", description: "Project types; the first is primary. Do not change after setup.", edit: "wizard" },
77
+ { path: "project_paths", type: "map", description: "Per-type project folder relative to the repo root (monorepo).", edit: "wizard (--paths)" },
78
+ { path: "metadata.default_branch", type: "string", default: "main", description: "Repository default (production) branch. Pushes here deploy.", edit: "wizard" },
79
+ { path: "metadata.deploy_branch", parserKey: "deployBranch", type: "string", default: "develop",
80
+ description: "Development branch: head of the release PR. Despite the name it is NOT where deploys run.", edit: "wizard (--deploy-branch)" },
81
+ { path: "metadata.template.options.intent", parserKey: "intent", type: "enum", values: ["app", "library", "both", "none", "manual"],
82
+ description: "Project kind; derives the deploy and publish questions.", edit: "wizard (--intent)" },
83
+ { path: "metadata.template.options.deploy", parserKey: "deploy", type: "enum", values: ["docker-ssh", "vercel", "none"], default: "docker-ssh",
84
+ description: "Where the app is deployed.", edit: "wizard (--deploy)" },
85
+ { path: "metadata.template.options.publish", parserKey: "publish", type: "enum[]", values: ["nexus", "npm", "github-packages"], default: [],
86
+ description: "Library registries.", edit: "wizard (--publish)" },
87
+ { path: "metadata.template.options.secret_backup", parserKey: "secretBackup", type: "boolean", default: false,
88
+ description: "Secret-to-server upload workflow installed.", edit: "wizard (--secret-backup)" },
89
+ { path: "metadata.template.options.semver_auto", parserKey: "semverAuto", type: "boolean",
90
+ default: true,
91
+ 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.",
92
+ edit: "by hand" },
93
+ { path: "metadata.template.options.close_on_release", parserKey: "closeOnRelease", type: "boolean", default: "true for new installs; missing for existing installs",
94
+ description: "A release merge closes issues that carry the done label and are referenced by a release commit.", edit: "by hand" },
95
+ { path: "metadata.template.options.language", parserKey: "language", type: "enum", values: ["en", "ko"], description: "Issue/PR template and bot message language.", edit: "wizard (--language)" },
96
+ { path: "metadata.template.options.label_style", parserKey: "labelStyle", type: "enum", values: ["en", "ko"], description: "Status label names.", edit: "wizard (--label-style)" },
97
+ { path: "metadata.template.options.projects_sync", parserKey: "projectsSync", type: "boolean", description: "Projects status sync workflow installed.", edit: "wizard (--projects-sync)" },
98
+ { path: "metadata.template.options.app_release", parserKey: "appRelease", type: "boolean",
99
+ description: "Releases go through App Store / Play Store review. Release notes get a review warning banner. Never auto-enabled.", edit: "by hand" },
100
+ { path: "metadata.template.options.excluded_workflows", parserKey: "excludedWorkflows", type: "string[]",
101
+ description: "Workflow file names the update must never install. Deleted files are also not restored; a copy is kept in .github/.projectops/incoming/.",
102
+ edit: "by hand" },
103
+ { path: "metadata.template.options.code_review.coderabbit", parserKey: "codeReviewCoderabbit", type: "boolean", description: "CodeRabbit config installed.", edit: "wizard" },
104
+ { path: "metadata.template.options.code_review.ai_summary", parserKey: "aiPrSummary", type: "boolean", default: true, description: "AI PR summary workflow installed.", edit: "wizard (--ai-summary)" },
105
+ { path: "metadata.template.options.changelog.provider", parserKey: "changelogProvider", type: "enum",
106
+ values: ["commit", "openai", "gemini", "claude", "groq", "mistral", "ollama", "copilot", "coderabbit"], default: "commit",
107
+ description: "Release note generator. 'commit' is free and always works; AI keys are picked up automatically when registered as secrets.", edit: "by hand" },
108
+ { path: "metadata.template.options.changelog.base_url", parserKey: "changelogBaseUrl", type: "string", description: "Only for provider ollama.", edit: "by hand" },
109
+ ];
110
+
111
+ export function buildSchema(packageVersion = "") {
112
+ return {
113
+ tool: "projectops",
114
+ packageVersion,
115
+ schemaVersion: SCHEMA_VERSION,
116
+ usage: "npx projectops --mode <mode> [--type csv] [--force] ... (agents: always pass --force and an explicit --mode)",
117
+ modes: MODES,
118
+ flags: FLAGS,
119
+ versionYml: VERSION_YML,
120
+ related: {
121
+ diagnose: "npx projectops --mode doctor",
122
+ optionsJson: "npx projectops --mode options --json",
123
+ docs: "https://cassiiopeia.github.io/projectops/",
124
+ },
125
+ };
126
+ }
127
+
128
+ // 사람이 읽는 표 — JSON 이 아닐 때 출력한다.
129
+ export function renderText(schema) {
130
+ const lines = [`projectops ${schema.packageVersion} — options (schema v${schema.schemaVersion})`, "", schema.usage, "", "MODES"];
131
+ for (const m of schema.modes) lines.push(` ${m.name.padEnd(12)} ${m.description}`);
132
+ lines.push("", "FLAGS");
133
+ for (const f of schema.flags) {
134
+ const name = [...(f.aliases ?? [f.alias]), f.flag + (f.value ? ` ${f.value}` : ""), f.negation].filter(Boolean).join(", ");
135
+ const desc = f.deprecated ? `(deprecated) ${f.deprecated}` : f.description;
136
+ const extra = [f.values ? `values: ${f.values.join("|")}` : "", f.default !== undefined ? `default: ${f.default}` : ""].filter(Boolean).join("; ");
137
+ lines.push(` ${name}`, ` ${desc}${extra ? ` [${extra}]` : ""}`);
138
+ }
139
+ lines.push("", "VERSION.YML KEYS");
140
+ for (const k of schema.versionYml) {
141
+ const extra = [k.type, k.values ? k.values.join("|") : "", k.default !== undefined ? `default: ${JSON.stringify(k.default)}` : ""].filter(Boolean).join("; ");
142
+ lines.push(` ${k.path} [${extra}]`, ` ${k.description}`);
143
+ }
144
+ return lines.join("\n");
145
+ }
@@ -1,8 +1,9 @@
1
1
  // version.yml 파싱·생성 (.sh create_version_yml 등가, 전체 재생성 전략 D4).
2
2
  // ⚠️ YAML 재직렬화 금지 — 주석이 데이터. .sh heredoc과 바이트 동일한 템플릿 문자열.
3
3
  // 실측 기준: template_integrator.sh 2184~2354.
4
+ import { execFileSync } from "node:child_process";
4
5
 
5
- const HEADER = `# ===================================================================
6
+ const HEADER_EN = `# ===================================================================
6
7
  # Project version management file
7
8
  # ===================================================================
8
9
  #
@@ -45,6 +46,98 @@ const HEADER = `# ==============================================================
45
46
  # ===================================================================
46
47
  `;
47
48
 
49
+ // 한국어 레포(options.language: ko)는 주석도 한국어로 쓴다 (#811).
50
+ // 업데이트(--force 는 화면이 en 고정)마다 한국어 주석이 영문으로 바뀌어 version.yml diff 가 86줄이 됐다.
51
+ const HEADER_KO = `# ===================================================================
52
+ # 프로젝트 버전 관리 파일
53
+ # ===================================================================
54
+ #
55
+ # 이 파일은 다양한 프로젝트 타입에서 버전 정보를 중앙 관리하기 위한 파일입니다.
56
+ # GitHub Actions 워크플로우가 이 파일을 읽어 자동으로 버전을 관리합니다.
57
+ #
58
+ # 사용법:
59
+ # 1. version: "1.0.0" - 사용자에게 표시되는 버전
60
+ # 2. version_code: 1 - Play Store/App Store 빌드 번호 (1부터 자동 증가)
61
+ # 3. project_types: 프로젝트 타입 배열 — 첫 항목이 primary
62
+ # 4. project_paths: 타입별 프로젝트 폴더 (레포 루트 기준 상대경로, 모노레포용)
63
+ #
64
+ # 자동 버전 업데이트:
65
+ # - version_code: 매 빌드마다 자동으로 1씩 증가 (아래 판정과 무관)
66
+ # - 버전 승격 폭은 metadata.template.options.semver_auto 가 정합니다
67
+ # semver_auto: true -> 릴리스 구간 커밋 제목으로 판정
68
+ # "제목 : feat! : 내용" 또는 "feat!:" -> major (x+1.0.0)
69
+ # "제목 : feat : 내용" 또는 "feat:" -> minor (x.y+1.0)
70
+ # 그 외(fix/docs/chore/refactor/test) -> patch (x.y.z+1)
71
+ # 키 없음 / false -> 항상 patch +1
72
+ # - 위 판정과 무관하게 version 값을 직접 수정해도 됩니다
73
+ #
74
+ # 프로젝트 타입별 동기화 파일:
75
+ # - spring: build.gradle (version = "x.y.z")
76
+ # - flutter: pubspec.yaml (version: x.y.z+i, buildNumber 포함)
77
+ # - react/node: package.json ("version": "x.y.z")
78
+ # - react-native: iOS Info.plist 또는 Android build.gradle
79
+ # - react-native-expo: app.json (expo.version)
80
+ # - python: pyproject.toml (version = "x.y.z")
81
+ # - basic/기타: version.yml 파일만 사용
82
+ #
83
+ # 연관된 워크플로우:
84
+ # - .github/workflows/PROJECT-VERSION-CONTROL.yaml
85
+ # - .github/workflows/PROJECT-README-VERSION-UPDATE.yaml
86
+ # - .github/workflows/PROJECT-RELEASE-CHANGELOG.yaml
87
+ #
88
+ # 주의사항:
89
+ # - project_types는 최초 설정 후 변경하지 마세요
90
+ # - 버전은 항상 높은 버전으로 자동 동기화됩니다
91
+ # ===================================================================
92
+ `;
93
+
94
+ // 키 옆 한 줄 주석. 언어는 화면 언어가 아니라 레포 문구 언어(options.language)를 따른다.
95
+ const COMMENTS = {
96
+ en: {
97
+ versionCode: "app build number",
98
+ projectTypes: "array of types, first entry is primary, safe to edit by hand",
99
+ projectPaths: "project folder per type (relative to the repo root)",
100
+ mode: "install scope (full/version/workflows), used when re-running update mode",
101
+ intent: "project kind (app/library/both/none/manual), drives the deploy questions",
102
+ semverAuto: "decide the version bump from commit titles (false means always patch)",
103
+ projectsSync: "include the GitHub Projects status sync workflow",
104
+ closeOnRelease: "close issues labelled done when a release is merged (false or missing means do not close)",
105
+ language: "issue/PR template language (en | ko)",
106
+ labelStyle: "status label style (en: status: todo / ko: Korean names)",
107
+ appRelease: "whether releases go through App Store / Play Store review",
108
+ deploy: "deploy settings remembered by the wizard (non-sensitive, safe to edit by hand)",
109
+ excluded: "workflow files the update must never install (safe to edit by hand)",
110
+ },
111
+ ko: {
112
+ versionCode: "app build number",
113
+ projectTypes: "멀티타입 배열 — 첫 항목이 primary, 직접 편집 가능",
114
+ projectPaths: "타입별 프로젝트 폴더 (레포 루트 기준 상대경로)",
115
+ mode: "통합 범위(full/version/workflows) — 업데이트 모드 재실행 기준",
116
+ intent: "프로젝트 성격(app/library/both/none/manual) — 배포 질문 유도 기준",
117
+ semverAuto: "커밋 제목으로 버전 승격 폭 결정 (false면 항상 patch)",
118
+ projectsSync: "GitHub Projects 상태 동기화 워크플로우 포함 여부",
119
+ closeOnRelease: "릴리스 머지 때 완료 라벨 이슈를 닫는다 (false/미기재면 닫지 않음)",
120
+ language: "이슈/PR 템플릿 언어 (en | ko)",
121
+ labelStyle: "상태 라벨 표기 (en: status: todo / ko: 작업전)",
122
+ appRelease: "앱스토어·플레이스토어 심사로 이어지는 배포인가",
123
+ deploy: "마법사가 기억하는 배포 설정 (비민감 / 직접 수정 가능)",
124
+ excluded: "업데이트가 절대 설치하지 않을 워크플로우 파일 (직접 수정 가능)",
125
+ },
126
+ };
127
+ const commentsFor = (lang) => COMMENTS[lang === "ko" ? "ko" : "en"];
128
+
129
+ // last_updated_by 결정 (#811): 실행한 git 사용자 → 기존 값 → template_integrator.
130
+ // 예전엔 업데이트마다 "template_integrator"로 덮어써 누가 바꿨는지가 사라졌다.
131
+ export function resolveUpdatedBy(existingContent = "", cwd = ".") {
132
+ try {
133
+ const name = execFileSync("git", ["config", "user.name"],
134
+ { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
135
+ if (name) return name;
136
+ } catch { /* git 없음·설정 없음(CI) — 기존 값으로 */ }
137
+ const m = String(existingContent || "").match(/^\s+last_updated_by:\s*"([^"]*)"/m);
138
+ return m && m[1] ? m[1] : "template_integrator";
139
+ }
140
+
48
141
  // metadata.template.options 상태머신 파싱 (.sh read_template_options L2361~2416 등가).
49
142
  // 반환(#439 배포/publish 축): { deploy: string|null, publish: string[]|null, secretBackup: bool|null }
50
143
  // deploy: 'docker-ssh'|'vercel'|'none', publish: ['nexus','npm','github-packages'] 부분집합. null=미기재.
@@ -57,7 +150,7 @@ const HEADER = `# ==============================================================
57
150
  export function parseTemplateOptions(content) {
58
151
  const out = { deploy: null, publish: null, secretBackup: null,
59
152
  changelogProvider: null, changelogBaseUrl: null, codeReviewCoderabbit: null, aiPrSummary: null, projectsSync: null,
60
- deployBranch: null, intent: null, semverAuto: null, appRelease: null, labelStyle: null, closeOnRelease: null, language: null };
153
+ deployBranch: null, intent: null, semverAuto: null, appRelease: null, labelStyle: null, closeOnRelease: null, language: null, excludedWorkflows: null };
61
154
  // deploy_branch는 metadata 직속(#456) — template.options 밖이라 별도로 스캔한다.
62
155
  for (const line of String(content || "").split("\n")) {
63
156
  if (line.startsWith("#")) continue;
@@ -159,6 +252,12 @@ export function parseTemplateOptions(content) {
159
252
  out.closeOnRelease = m[1] === "true";
160
253
  continue;
161
254
  }
255
+ // 설치하지 않을 워크플로우 (#810) — 인라인 배열. 업데이트가 지운 파일을 되살리지 않게 하는 명시 수단이다.
256
+ m = line.match(/^\s+excluded_workflows:\s*\[([^\]]*)\]/);
257
+ if (m) {
258
+ out.excludedWorkflows = m[1].split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
259
+ continue;
260
+ }
162
261
  // 프로젝트 성격(#553) — 앱 심사로 이어지는 레포인가. 워크플로우와 스킬이 같은 값을 본다.
163
262
  m = line.match(/^\s+app_release:\s*["']?(true|false)["']?/);
164
263
  if (m) {
@@ -329,17 +428,19 @@ export function mergeDeployValues(existingContent, fresh) {
329
428
  // today = "YYYY-MM-DD" (UTC)
330
429
  // pathMarkers = Map<type, markerFilename> (project_paths 주석용)
331
430
  // templateOptions = { templateVersion, deployTarget, publishTargets, includeSecretBackup, optionsDate } (template 블록)
332
- export function buildVersionYml({ version, types = [], paths = new Map(), pathMarkers = new Map(), branch = "main", deployBranch = "", versionCode = 1, now, today, templateOptions = null, deployValues = new Map() }) {
431
+ export function buildVersionYml({ version, types = [], paths = new Map(), pathMarkers = new Map(), branch = "main", deployBranch = "", versionCode = 1, now, today, templateOptions = null, deployValues = new Map(), updatedBy = "template_integrator" }) {
432
+ const lang = templateOptions?.language === "ko" ? "ko" : "en";
433
+ const C = commentsFor(lang);
333
434
  const typesJson = types.length ? `[${types.map((t) => `"${t}"`).join(",")}]` : `["basic"]`;
334
435
 
335
- let out = HEADER + "\n";
436
+ let out = (lang === "ko" ? HEADER_KO : HEADER_EN) + "\n";
336
437
  out += `version: "${version}"\n`;
337
- out += `version_code: ${versionCode} # app build number\n`;
338
- out += `project_types: ${typesJson} # array of types, first entry is primary, safe to edit by hand\n`;
438
+ out += `version_code: ${versionCode} # ${C.versionCode}\n`;
439
+ out += `project_types: ${typesJson} # ${C.projectTypes}\n`;
339
440
 
340
441
  // project_paths 블록. pathMarkers: Map<type, markerFilename> (있으면 " type: "path" # path/marker" 주석).
341
442
  if (paths.size) {
342
- out += `project_paths: # project folder per type (relative to the repo root)\n`;
443
+ out += `project_paths: # ${C.projectPaths}\n`;
343
444
  for (const [t, p] of paths) {
344
445
  const marker = pathMarkers.get(t) || "";
345
446
  const pf = p === "." ? marker : (marker ? `${p}/${marker}` : p);
@@ -349,7 +450,7 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
349
450
 
350
451
  out += `metadata:\n`;
351
452
  out += ` last_updated: "${now}"\n`;
352
- out += ` last_updated_by: "template_integrator"\n`;
453
+ out += ` last_updated_by: "${String(updatedBy || "template_integrator").replace(/"/g, "")}"\n`;
353
454
  out += ` default_branch: "${branch}"\n`;
354
455
  // deploy_branch: 릴리스 PR의 head 브랜치(#456). default_branch(레포 기본)와 별개 개념.
355
456
  // 지정된 경우에만 출력 — 미지정이면 스킬이 develop로 폴백(하위호환).
@@ -361,7 +462,7 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
361
462
  if (templateOptions) {
362
463
  const { templateVersion = "unknown", deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, optionsDate = today,
363
464
  changelogProvider = "commit", changelogBaseUrl = "", codeReviewCoderabbit = true, aiPrSummary = true, intent = null, mode = null,
364
- semverAuto = true, appRelease = null, labelStyle = null, closeOnRelease = null, projectsSync = null, language = null } = templateOptions;
465
+ semverAuto = true, appRelease = null, labelStyle = null, closeOnRelease = null, projectsSync = null, language = null, excludedWorkflows = null } = templateOptions;
365
466
  const publishJson = `[${publishTargets.map((t) => `"${t}"`).join(",")}]`;
366
467
  // intent(프로젝트 성격, #485) — 미지정이면 deploy/publish에서 역추론해 기록 (재통합 시 진입 질문 생략용)
367
468
  const intentVal = intent || inferIntent(deployTarget, publishTargets) || "manual";
@@ -369,27 +470,29 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
369
470
  out += ` source: "projectops"\n`;
370
471
  out += ` version: "${templateVersion}"\n`;
371
472
  // mode(#502) — 이 레포에 통합된 범위. 업데이트 모드가 같은 범위를 재실행하는 기준.
372
- if (mode) out += ` mode: "${mode}" # install scope (full/version/workflows), used when re-running update mode\n`;
473
+ if (mode) out += ` mode: "${mode}" # ${C.mode}\n`;
373
474
  out += ` integrated_date: "${optionsDate}"\n`;
374
475
  out += ` last_update_date: "${optionsDate}"\n`;
375
476
  out += ` options:\n`;
376
- out += ` intent: "${intentVal}" # project kind (app/library/both/none/manual), drives the deploy questions\n`;
477
+ out += ` intent: "${intentVal}" # ${C.intent}\n`;
377
478
  out += ` deploy: "${deployTarget}"\n`;
378
479
  out += ` publish: ${publishJson}\n`;
379
480
  out += ` secret_backup: ${includeSecretBackup}\n`;
380
481
  // semver 자동 승격(#546) — 릴리스 시 커밋 제목으로 major/minor/patch 결정. false면 항상 patch.
381
- out += ` semver_auto: ${semverAuto} # decide the version bump from commit titles (false means always patch)\n`;
482
+ out += ` semver_auto: ${semverAuto} # ${C.semverAuto}\n`;
382
483
  // Projects 보드 동기화(#716) — 미지정이면 키를 쓰지 않는다.
383
- if (projectsSync !== null) out += ` projects_sync: ${projectsSync} # GitHub Projects 상태 동기화 워크플로우 포함 여부\n`;
484
+ if (projectsSync !== null) out += ` projects_sync: ${projectsSync} # ${C.projectsSync}\n`;
384
485
  // 릴리스 시 완료 이슈 닫기(#771) — 미지정이면 키를 쓰지 않는다(기존 레포는 현행 유지).
385
- if (closeOnRelease !== null) out += ` close_on_release: ${closeOnRelease} # close issues labelled done when a release is merged (false or missing means do not close)\n`;
486
+ if (closeOnRelease !== null) out += ` close_on_release: ${closeOnRelease} # ${C.closeOnRelease}\n`;
386
487
  // 상태 라벨 표기(#776) — 미지정이면 키를 쓰지 않는다(기존 레포 무변화).
387
488
  // 레포 문구 언어(#769) — 미지정이면 키를 쓰지 않는다(기존 레포 무변화).
388
- if (language) out += ` language: ${language} # issue/PR template language (en | ko)\n`;
389
- if (labelStyle) out += ` label_style: ${labelStyle} # status label style (en: status: todo / ko: Korean names)\n`;
489
+ // 설치 제외 목록(#810) — 전체 재생성이라 다시 쓰지 않으면 사용자가 적은 목록이 사라진다.
490
+ if (excludedWorkflows && excludedWorkflows.length) out += ` excluded_workflows: [${excludedWorkflows.map((f) => `"${f}"`).join(", ")}] # ${C.excluded}\n`;
491
+ if (language) out += ` language: ${language} # ${C.language}\n`;
492
+ if (labelStyle) out += ` label_style: ${labelStyle} # ${C.labelStyle}\n`;
390
493
  // 앱 심사 배포 레포 여부(#553) — 미지정이면 키를 쓰지 않는다(기존 레포 무변화).
391
494
  if (appRelease !== null) {
392
- out += ` app_release: ${appRelease} # whether releases go through App Store / Play Store review\n`;
495
+ out += ` app_release: ${appRelease} # ${C.appRelease}\n`;
393
496
  }
394
497
  out += ` code_review:\n`;
395
498
  out += ` coderabbit: ${codeReviewCoderabbit}\n`;
@@ -405,7 +508,7 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
405
508
  const deployTypes = [...deployValues.keys()].filter((t) => deployValues.get(t) && deployValues.get(t).size > 0);
406
509
  if (deployTypes.length) {
407
510
  out += `\n`;
408
- out += `deploy: # deploy settings remembered by the wizard (non-sensitive, safe to edit by hand)\n`;
511
+ out += `deploy: # ${C.deploy}\n`;
409
512
  for (const t of deployTypes) {
410
513
  out += ` ${t}:\n`;
411
514
  for (const [k, v] of deployValues.get(t)) out += ` ${k}: "${v}"\n`;
@@ -47,6 +47,13 @@ export function extractEnvValues(installedContent) {
47
47
  return values;
48
48
  }
49
49
 
50
+ // 예시용 기본값 (#810) — 그대로 배포되면 남의 도메인으로 요청이 간다.
51
+ // 사용자가 값을 고르지 않은 경로(--force·기본값 사용)에서는 채우지 않고 __KEY__ 를 남긴다.
52
+ // 그러면 설치 후 검증(verify.js)이 미치환으로 잡아 완료 화면에 "직접 채울 것"으로 보여준다.
53
+ export function isPlaceholderDefault(value) {
54
+ return /(^|\.)example\.(com|org|net)$/i.test(String(value || "").trim());
55
+ }
56
+
50
57
  // resolver — .sh resolve_token 등가. 값 계산은 주입된 resolvers로 위임(순수성 유지).
51
58
  // resolvers: { repo, "spring-app-yml-dir"(type), "spring-app-yml-path"(type), "flutter-root" }
52
59
  export function resolveToken(name, type, resolvers = {}) {
@@ -89,6 +96,7 @@ export function substituteEnv(content, opts = {}) {
89
96
  let def = p.arg.startsWith("@") ? resolveToken(p.arg.slice(1), type, resolvers) : p.arg;
90
97
  const chosen = values.get(p.key);
91
98
  if (chosen != null && chosen !== "" && !useDefaults) val = chosen;
99
+ else if (isPlaceholderDefault(def)) continue; // 예시값은 쓰지 않는다 — 토큰·마커 그대로 (#810)
92
100
  else val = def;
93
101
  // ask 키만 수집 (.sh wf_deploy_set — auto는 저장 안 함). deploy 블록용.
94
102
  // #489 — 파일 본문은 아래 전역 토큰 치환을 거치므로 수집값도 동일 치환해
@@ -40,6 +40,10 @@ export default {
40
40
  "doctor.pd.l1": "These run other workflows but permissions has no 'actions: write'.",
41
41
  "doctor.pd.l2": "Even if the repository setting is Read and write, the API returns 403 without this declaration.",
42
42
  "doctor.pd.l3": "Fix: add 'actions: write' to the permissions block of each file",
43
+ "doctor.legacy.name": "Retired workflows",
44
+ "doctor.legacy.purpose": "Old-generation files that run next to their replacements",
45
+ "doctor.legacy.l1": "They react to the same events as the new workflows, so every run happens twice. Delete them:",
46
+ "doctor.legacy.l2": "Or run `npx projectops --mode workflows --force --remove-legacy` to rename them to .bak.",
43
47
  "doctor.bl.name": "Update baseline",
44
48
  "doctor.bl.purpose": "Basis for telling your edits apart on the next update",
45
49
  "doctor.bl.recorded": "{n} files recorded",
@@ -40,6 +40,10 @@ export default {
40
40
  "doctor.pd.l1": "다른 워크플로우를 실행하는데 permissions에 'actions: write'가 없습니다.",
41
41
  "doctor.pd.l2": "저장소 설정이 Read and write여도 이 선언이 없으면 API가 403을 반환합니다.",
42
42
  "doctor.pd.l3": "조치: 해당 파일의 permissions 블록에 'actions: write' 추가",
43
+ "doctor.legacy.name": "은퇴한 워크플로우",
44
+ "doctor.legacy.purpose": "신형과 함께 실행되는 구세대 파일",
45
+ "doctor.legacy.l1": "신형과 같은 이벤트에 반응해 모든 실행이 두 번 일어납니다. 지우려면:",
46
+ "doctor.legacy.l2": "또는 `npx projectops --mode workflows --force --remove-legacy` 로 .bak 으로 치웁니다.",
43
47
  "doctor.bl.name": "업데이트 기준점",
44
48
  "doctor.bl.purpose": "다음 업데이트가 내 수정을 구분할 근거",
45
49
  "doctor.bl.recorded": "{n}개 파일 기록됨",
@@ -58,6 +58,9 @@ export default {
58
58
  "migrate.confirm.found": "Found {n} old-generation deploy workflow(s). They may be live deployments, so they are left untouched:",
59
59
  "migrate.confirm.newer": " (new: {name})",
60
60
  "migrate.confirm.hint": " Delete the old files yourself once you have switched to the new workflows.",
61
+ "migrate.confirm.hintForce": " Delete the old files yourself once you have switched, or rerun with --remove-legacy to rename them to .bak.",
62
+ "migrate.confirm.ask": "Rename these {n} old workflow(s) to .bak so they stop running? (restore by removing .bak)",
63
+ "migrate.confirm.done": "✅ Renamed {n} old workflow(s) to .bak.",
61
64
  "breaking.title": "BREAKING CHANGES (v{current} -> v{target}) - CRITICAL {critical}, WARNING {warnings}",
62
65
  "breaking.askDetail": "Show the details (action steps) for each item?",
63
66
  "breaking.seeJson": " Details and action steps: see .github/config/breaking-changes.json",
@@ -58,6 +58,9 @@ export default {
58
58
  "migrate.confirm.found": "⚠️ 구세대 배포 워크플로우 {n}개 발견 — 현역 배포일 수 있어 자동으로 건드리지 않습니다:",
59
59
  "migrate.confirm.newer": " (신형: {name})",
60
60
  "migrate.confirm.hint": " 신형 워크플로우로 전환을 마친 뒤 구 파일을 직접 삭제하세요.",
61
+ "migrate.confirm.hintForce": " 전환을 마친 뒤 구 파일을 직접 삭제하거나, --remove-legacy 로 다시 실행하면 .bak 으로 치웁니다.",
62
+ "migrate.confirm.ask": "구세대 워크플로우 {n}개를 .bak 으로 바꿔 실행을 멈출까요? (.bak 을 지우면 복원)",
63
+ "migrate.confirm.done": "✅ 구세대 워크플로우 {n}개를 .bak 으로 바꿨습니다.",
61
64
  "breaking.title": "⚠️ BREAKING CHANGES (v{current} -> v{target}) - CRITICAL {critical}건, WARNING {warnings}건",
62
65
  "breaking.askDetail": "각 항목의 상세 내용(조치 방법)을 볼까요?",
63
66
  "breaking.seeJson": " 상세 내용, 조치 방법: .github/config/breaking-changes.json 참고",
@@ -23,6 +23,16 @@ export default {
23
23
  "done.keptHint1": "Copies of the new templates are in .github/.projectops/incoming/ (not tracked by git).",
24
24
  "done.keptHint2": "Move only the changes you want into your workflows.",
25
25
  "done.replacedTitle": "Could not tell whether {count} workflows were modified, so they were replaced with the new template and the originals saved as .bak",
26
+ "done.semverTitle": "Automatic version bump (semver_auto) is now ON for this repo",
27
+ "done.semverBody": "The next release picks the bump from commit titles: 'feat:' raises the minor version, 'type!:' the major one, anything else the patch.",
28
+ "done.semverOff": "To keep patch-only bumps, set metadata.template.options.semver_auto: false in version.yml.",
29
+ "done.notInstalledTitle": "{count} template workflow(s) were not installed",
30
+ "done.notInstalled.excluded": "listed in excluded_workflows",
31
+ "done.notInstalled.deleted-by-user": "you deleted it earlier",
32
+ "done.notInstalled.no-dev-branch": "only runs on {branch}, which this repo does not have",
33
+ "done.notInstalledHint": "To install one anyway, copy the file shown above. To stop being told, add it to options.excluded_workflows in version.yml.",
34
+ "done.legacyTitle": "{count} retired workflow(s) are still installed and run alongside their replacements",
35
+ "done.legacyHint": "Delete them once the new workflow works, or rerun with --remove-legacy to rename them to .bak.",
26
36
  "done.replacedHint": "If you had edits, copy them over from the .bak files. (The update baseline is recorded by this run.)",
27
37
  "done.utilTitle": "Utility modules:",
28
38
  "done.spring.title": "Extra setup for Spring projects:",
@@ -23,6 +23,16 @@ export default {
23
23
  "done.keptHint1": "새 템플릿 사본은 저장소에 추적되지 않는 .github/.projectops/incoming/ 에 있습니다.",
24
24
  "done.keptHint2": "필요한 변경만 워크플로에 직접 옮겨 반영하세요.",
25
25
  "done.replacedTitle": "수정 여부를 확인할 수 없어 {count}개 워크플로를 새 템플릿으로 교체하고 원본을 .bak 으로 백업했습니다",
26
+ "done.semverTitle": "이 레포의 자동 버전 승격(semver_auto)이 켜졌습니다",
27
+ "done.semverBody": "다음 릴리스부터 커밋 제목으로 올리는 폭을 정합니다: 'feat:'는 minor, 'type!:'는 major, 그 외는 patch.",
28
+ "done.semverOff": "patch만 올리고 싶으면 version.yml 의 metadata.template.options.semver_auto: false 로 두세요.",
29
+ "done.notInstalledTitle": "템플릿 워크플로우 {count}개를 설치하지 않았습니다",
30
+ "done.notInstalled.excluded": "excluded_workflows 에 적혀 있음",
31
+ "done.notInstalled.deleted-by-user": "예전에 직접 지운 파일",
32
+ "done.notInstalled.no-dev-branch": "{branch} 브랜치에서만 실행되는데 이 레포에는 그 브랜치가 없음",
33
+ "done.notInstalledHint": "그래도 쓰려면 위에 적힌 파일을 복사하세요. 안내를 그만 보려면 version.yml 의 options.excluded_workflows 에 추가하세요.",
34
+ "done.legacyTitle": "은퇴한 구세대 워크플로우 {count}개가 남아 신형과 함께 실행됩니다",
35
+ "done.legacyHint": "신형이 동작하는 걸 확인한 뒤 지우거나, --remove-legacy 로 다시 실행하면 .bak 으로 치웁니다.",
26
36
  "done.replacedHint": "직접 수정한 내용이 있었다면 .bak 에서 옮겨 오세요. (업데이트 기준점은 이번 실행으로 새로 기록됩니다)",
27
37
  "done.utilTitle": "유틸리티 모듈:",
28
38
  "done.spring.title": "Spring 프로젝트 추가 설정:",
package/src/index.js CHANGED
@@ -79,6 +79,14 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
79
79
  if (opts.showVersion) { console.log(readPkgVersion()); return 0; }
80
80
  if (opts.help) { console.log(helpText()); return 0; }
81
81
 
82
+ // options 모드 — 옵션 정본 출력. AI agent 가 문서를 뒤지지 않고 한 번에 읽는다. 읽기 전용·네트워크 없음.
83
+ if (opts.mode === "options") {
84
+ const { buildSchema, renderText } = await import("./core/options-schema.js");
85
+ const schema = buildSchema(readPkgVersion());
86
+ console.log(opts.json ? JSON.stringify(schema, null, 2) : renderText(schema));
87
+ return 0;
88
+ }
89
+
82
90
  // doctor 모드 (#558) — 읽기 전용 진단. 템플릿을 내려받지 않으므로 네트워크 없이도 동작한다.
83
91
  if (opts.mode === "doctor") {
84
92
  const { runDoctor } = await import("./commands/doctor.js");
@@ -247,10 +255,12 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
247
255
  codeReviewCoderabbit: existing?.options?.codeReviewCoderabbit ?? false,
248
256
  // semver 자동 승격(#546): 저장값 → (기존 통합 레포면 false / 신규면 true).
249
257
  // 이미 통합된 레포의 버전이 업데이트만으로 예고 없이 minor로 튀지 않게 하는 안전장치다.
250
- semverAuto: existing?.options?.semverAuto ?? (existing ? false : true),
258
+ // 저장값(명시적 false 포함)은 존중하고, 키가 없으면 기존 레포도 켠다. 켜진 사실은 완료 화면이 알린다.
259
+ semverAuto: existing?.options?.semverAuto ?? true,
251
260
  // 앱 심사 배포 레포 여부(#553): 저장값만 보존한다. 마법사가 묻지 않으므로 새로 켜지 않는다
252
261
  // (사용자가 version.yml에 직접 쓰거나 스킬이 기록한 값을 그대로 유지).
253
262
  appRelease: existing?.options?.appRelease ?? null,
263
+ excludedWorkflows: existing?.options?.excludedWorkflows ?? null, // #810 저장값 보존
254
264
  // 상태 라벨 표기(#776): 플래그 > 저장값 > (신규 en / 기존 ko). 기존 레포의 라벨 이름은 업데이트만으로 바뀌지 않는다.
255
265
  // 릴리스 시 완료 이슈 닫기(#771): 저장값 보존, 없으면 신규만 true. 기존 레포는 키를 만들지 않아 현행 유지.
256
266
  closeOnRelease: existing?.options?.closeOnRelease ?? (existing ? null : true),
@@ -275,7 +285,7 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
275
285
  });
276
286
  trace.event("resolve", "semver-auto", String(context.semverAuto), {
277
287
  reason: existing?.options?.semverAuto != null ? "stored value in version.yml kept"
278
- : (existing ? "existing integrated repo, false to avoid unannounced version jumps"
288
+ : (existing ? "existing integrated repo without the key, turned on by default (announced in the summary)"
279
289
  : "new integration, true"),
280
290
  });
281
291
  trace.event("resolve", "app-release", String(context.appRelease), {
@@ -313,7 +323,7 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
313
323
 
314
324
  // 레거시 마이그레이션 (#470) — 워크플로우를 만지는 모드에서만. 비대화형은 safe 티어 자동 적용.
315
325
  if (recordArtifacts) {
316
- migrationsResult = await trace.stepAsync("legacy-migrations", () => runMigrations({ targetRoot: cwd }));
326
+ migrationsResult = await trace.stepAsync("legacy-migrations", () => runMigrations({ targetRoot: cwd, removeLegacy: opts.removeLegacy === true }));
317
327
  for (const a of migrationsResult.applied ?? []) trace.event("legacy", a.action === "error" ? "error" : "neutralized", a.from ?? a.id ?? "", { to: a.to ?? "", id: a.id ?? "" });
318
328
  for (const e of migrationsResult.confirmPending ?? []) trace.event("legacy", "leftover-old-gen", e.file, { replacement: e.replacedBy ?? "", reason: e.reason ?? "" });
319
329
  }
@@ -352,7 +362,7 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
352
362
  migrationGuidePath = appendGuideEntry(cwd, {
353
363
  now, mode: opts.mode, types, repoName,
354
364
  templateFrom: existing?.templateVersion || "", templateTo: context.templateVersion,
355
- options: { deploy: deployTarget, publish: publishTargets, secretBackup: context.includeSecretBackup, coderabbit: context.codeReviewCoderabbit, changelogProvider: context.changelogProvider, intent, semverAuto: context.semverAuto , appRelease: context.appRelease, language: context.language, labelStyle: context.labelStyle, closeOnRelease: context.closeOnRelease, projectsSync: context.projectsSync },
365
+ options: { deploy: deployTarget, publish: publishTargets, secretBackup: context.includeSecretBackup, coderabbit: context.codeReviewCoderabbit, changelogProvider: context.changelogProvider, intent, semverAuto: context.semverAuto , appRelease: context.appRelease, language: context.language, labelStyle: context.labelStyle, closeOnRelease: context.closeOnRelease, projectsSync: context.projectsSync, excludedWorkflows: context.excludedWorkflows },
356
366
  branches: { defaultBranch: branch, deployBranch: context.deployBranch || "develop", ready: null, created: null },
357
367
  breaking: breakingReport, migrations: migrationsResult, orphans: { cleaned: [], pending: orphanPending },
358
368
  events: trace.events, counters: { skipped: result?.workflows?.skipped ?? 0 },
@@ -366,6 +376,9 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
366
376
  counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
367
377
  skippedConflicts: result?.workflows?.skippedConflicts ?? [], // #654 병합 안내
368
378
  replacedBak: result?.workflows?.replacedBak ?? [], // #673 기준점 없이 교체한 파일 안내
379
+ legacyLeftover: migrationsResult?.confirmPending ?? [], // #809 신·구 세대 동시 실행 안내
380
+ notInstalled: result?.workflows?.notInstalled ?? [], // #810 깔지 않은 워크플로우 안내
381
+ semverAutoNewlyOn: !!existing && existing?.options?.semverAuto == null && context.semverAuto === true, // 업데이트가 켠 사실 안내
369
382
  verification: result?.verification, // #549 설치 후 검증 결과 (full/workflows 모드에서만 존재)
370
383
  // #569 — 고른 것만 안내하려면 선택값이 필요하다
371
384
  aiPrSummary: context.aiPrSummary, codeReviewCoderabbit: context.codeReviewCoderabbit,
package/src/ui/summary.js CHANGED
@@ -120,6 +120,38 @@ export function printSummary(ctx, targetRoot = ".", write = null) {
120
120
  err(` ${t("done.replacedHint")}`);
121
121
  }
122
122
 
123
+ // The update turned on automatic semver for a repo that did not have the key: tell them, because the next release can jump.
124
+ if (ctx?.semverAutoNewlyOn) {
125
+ err("");
126
+ err(` ${YELLOW}⚠️ ${t("done.semverTitle")}${NC}`);
127
+ err(` ${t("done.semverBody")}`);
128
+ err(` ${t("done.semverOff")}`);
129
+ }
130
+
131
+ // Workflows left out on purpose (#810): deleted by the user, listed in excluded_workflows, or needing a branch this repo lacks.
132
+ const skippedNew = ctx?.notInstalled ?? [];
133
+ if (skippedNew.length > 0) {
134
+ err("");
135
+ err(` ${CYAN}ℹ️ ${t("done.notInstalledTitle", { count: skippedNew.length })}${NC}`);
136
+ for (const s of skippedNew) {
137
+ err(` ⏭️ ${s.filename} (${t(`done.notInstalled.${s.reason}`, { branch: ctx?.deployBranch || "develop" })})`);
138
+ if (s.incoming) err(` cp ${s.incoming} .github/workflows/${s.filename}`);
139
+ }
140
+ err(` ${t("done.notInstalledHint")}`);
141
+ }
142
+
143
+ // Retired old-generation workflows still installed: they run next to their replacements (#809).
144
+ const legacy = ctx?.legacyLeftover ?? [];
145
+ if (legacy.length > 0) {
146
+ err("");
147
+ err(` ${YELLOW}⚠️ ${t("done.legacyTitle", { count: legacy.length })}${NC}`);
148
+ for (const e of legacy) {
149
+ err(` 🗑️ ${e.file}${e.replacedBy ? ` → ${e.replacedBy}` : ""}`);
150
+ err(` git rm .github/workflows/${e.file}`);
151
+ }
152
+ err(` ${t("done.legacyHint")}`);
153
+ }
154
+
123
155
  err("");
124
156
  err(" 🔧 .github/scripts/");
125
157
  err(" ├─ version_manager.sh");