projectops 4.71.3 → 4.73.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,147 @@
1
+ // 스킬 폴더 복사 공용 (Cursor·Copilot): skills/ + 공용 파이썬 scripts/ > 홈 설치 루트, 테스트 제외, 폐기 정리, 메타 기록
2
+ // 마켓플레이스 없이 SKILL.md 폴더를 그대로 읽는 IDE 가 같은 규칙을 쓰도록 한 곳에 둔다.
3
+ // 설치 모양은 플러그인 캐시와 같다: <설치 루트>/skills/pro-*/scripts/x_cli.py + <설치 루트>/scripts/common
4
+ // (스킬 CLI bootstrap 이 x_cli.py 기준 3단계 위의 scripts/ 를 import 경로로 쓴다)
5
+ import { dirname, join, relative } from "node:path";
6
+ import { existsSync, readFileSync, mkdirSync, cpSync, rmSync, writeFileSync, readdirSync } from "node:fs";
7
+ import { TEMPLATE_REPO_WEB_URL } from "../paths.js";
8
+
9
+ // 스킬을 쓰는 사람에게 필요 없는 것. projectops 자신을 검사하는 테스트다.
10
+ // skills/ 는 통째로 복사되므로 여기서 걸러내지 않으면 사용자 홈에 그대로 쌓인다.
11
+ export const SKIP_IN_COPY = new Set(["conftest.py", "pytest.ini"]);
12
+ export const TEST_PATH = /(^|[\\/])(tests?|__pycache__)([\\/]|$)/;
13
+ // 우리 스킬 접두사: pro- 현행, suh- 옛 이름
14
+ const OWNED_SKILL_NAME = /^(pro|suh)-/;
15
+ // 공용 파이썬 패키지 표식: scripts/common/__init__.py
16
+ const SHARED_SCRIPTS_MARKER = join("common", "__init__.py");
17
+ const SHARED_SCRIPTS_DIR_NAME = "scripts";
18
+ const PYTHON_CACHE_DIR_NAME = "__pycache__";
19
+
20
+ // 스킬 소스: 다운로드된 템플릿(TEMP)/skills 우선, 없으면 로컬 skills/
21
+ export function resolveSkillsSrc(ctx = {}) {
22
+ const cands = [ctx.sourceSkillsDir, ctx.tempDir && join(ctx.tempDir, "skills"), "skills"].filter(Boolean);
23
+ for (const c of cands) if (existsSync(c)) return c;
24
+ return "";
25
+ }
26
+
27
+ // 공용 파이썬 소스: 직접 지정 > 템플릿(TEMP)/scripts > 스킬 소스 옆 scripts/ (같은 레포)
28
+ export function resolveScriptsSrc(ctx = {}, skillsSrc = "") {
29
+ const cands = [ctx.sourceScriptsDir, ctx.tempDir && join(ctx.tempDir, SHARED_SCRIPTS_DIR_NAME),
30
+ skillsSrc && join(dirname(skillsSrc), SHARED_SCRIPTS_DIR_NAME)].filter(Boolean);
31
+ for (const c of cands) if (existsSync(join(c, SHARED_SCRIPTS_MARKER))) return c;
32
+ return "";
33
+ }
34
+
35
+ // 메타 파일 > {installed, version, sharedScripts} (손상된 메타는 설치됐다고 봄)
36
+ export function readSkillsMeta(metaFile) {
37
+ if (!existsSync(metaFile)) return { installed: false, version: null, sharedScripts: [] };
38
+ try {
39
+ const meta = JSON.parse(readFileSync(metaFile, "utf8"));
40
+ return { installed: true, version: meta.version || null, sharedScripts: Array.isArray(meta.sharedScripts) ? meta.sharedScripts : [] };
41
+ } catch { return { installed: true, version: null, sharedScripts: [] }; }
42
+ }
43
+
44
+ // 소스 최상위 항목 복사 (테스트·conftest 제외). 필터는 소스 기준 상대경로로 본다 —
45
+ // 절대경로로 보면 소스가 ~/test/... 아래에 있을 때 전부 걸러진다.
46
+ function copyEntriesWithoutTests(src, dest) {
47
+ mkdirSync(dest, { recursive: true });
48
+ const copiedNames = [];
49
+ for (const e of readdirSync(src, { withFileTypes: true })) {
50
+ if (SKIP_IN_COPY.has(e.name) || TEST_PATH.test(e.name)) continue;
51
+ cpSync(join(src, e.name), join(dest, e.name), {
52
+ recursive: true,
53
+ // 이 저장소 자기 코드를 검사하는 테스트는 남의 컴퓨터로 나갈 이유가 없다 (#591).
54
+ filter: (p) => !TEST_PATH.test(relative(src, p)),
55
+ });
56
+ copiedNames.push(e.name);
57
+ }
58
+ return copiedNames;
59
+ }
60
+
61
+ /**
62
+ * 스킬 복사: 소스 > dest (테스트 제외) > 소스에서 사라진 우리 스킬 제거
63
+ * 폐기한 스킬 이름 목록 반환. 복사 실패는 예외로 올린다 (호출부가 로그·false 처리).
64
+ */
65
+ export function copySkillsTree(src, dest, metaFileName) {
66
+ copyEntriesWithoutTests(src, dest);
67
+ // 복사는 overlay 라 덮어쓰기만 하면 **폐기한 스킬이 남의 컴퓨터에 영영 남아** 계속 잡힌다 (#500).
68
+ // 남의 스킬은 접두사로 걸러 건드리지 않는다.
69
+ const srcNames = new Set(readdirSync(src));
70
+ const obsoleteNames = [];
71
+ for (const name of readdirSync(dest)) {
72
+ if (srcNames.has(name) || name === metaFileName) continue;
73
+ if (!OWNED_SKILL_NAME.test(name)) continue;
74
+ try {
75
+ rmSync(join(dest, name), { recursive: true, force: true });
76
+ obsoleteNames.push(name);
77
+ } catch { /* 무시 — 지우지 못해도 설치는 계속한다 */ }
78
+ }
79
+ return obsoleteNames;
80
+ }
81
+
82
+ // 공용 파이썬 교체 복사: 지난 설치 항목 제거 > 소스 전체 복사 (폐기 모듈이 남지 않게). 복사한 항목 이름 반환
83
+ function replaceSharedScripts(scriptsSrc, scriptsDest, previousNames) {
84
+ removeNamedEntries(scriptsDest, previousNames);
85
+ return copyEntriesWithoutTests(scriptsSrc, scriptsDest);
86
+ }
87
+
88
+ // 이름 목록 + 파이썬 실행이 만든 __pycache__ 삭제 > 폴더가 비면 폴더도 삭제
89
+ function removeNamedEntries(dir, names) {
90
+ for (const name of [...names, PYTHON_CACHE_DIR_NAME]) {
91
+ // 메타에서 읽은 이름이라 경로 이동(../) 차단
92
+ if (!name || name.includes("/") || name.includes("\\") || name === "..") continue;
93
+ rmSync(join(dir, name), { recursive: true, force: true });
94
+ }
95
+ if (existsSync(dir) && readdirSync(dir).length === 0) rmSync(dir, { recursive: true, force: true });
96
+ }
97
+
98
+ /**
99
+ * 홈 설치 1회: 스킬 복사 > 공용 파이썬 교체 복사 > 메타 기록
100
+ * {obsoleteNames, hasSharedScripts} 반환. 실패는 예외로 올린다.
101
+ */
102
+ export function installHomeSkills({ skillsSrc, scriptsSrc, skillsDest, metaFileName, templateVersion }) {
103
+ const metaFile = join(skillsDest, metaFileName);
104
+ const previousSharedScripts = readSkillsMeta(metaFile).sharedScripts;
105
+ const obsoleteNames = copySkillsTree(skillsSrc, skillsDest, metaFileName);
106
+ const scriptsDest = join(dirname(skillsDest), SHARED_SCRIPTS_DIR_NAME);
107
+ const sharedScripts = scriptsSrc ? replaceSharedScripts(scriptsSrc, scriptsDest, previousSharedScripts) : previousSharedScripts;
108
+ writeSkillsMeta(skillsDest, metaFileName, templateVersion, { sharedScripts });
109
+ return { obsoleteNames, hasSharedScripts: !!scriptsSrc };
110
+ }
111
+
112
+ // projectops 가 이 폴더에 설치했다고 볼 항목: 소스 폴더명 ∪ pro-/suh- 접두사 ∪ 메타 파일
113
+ // 접두어 없는 폴더(somansa-tools 등 남의 스킬)는 제외 → 보존
114
+ export function ownedEntries(dir, ctx, metaFileName) {
115
+ if (!existsSync(dir)) return [];
116
+ let srcNames = new Set();
117
+ const src = resolveSkillsSrc(ctx);
118
+ if (src && existsSync(src)) srcNames = new Set(readdirSync(src));
119
+ // 폐기한 스킬은 소스에 없으므로 srcNames 로는 잡히지 않는다 (#619).
120
+ return readdirSync(dir).filter((name) => srcNames.has(name) || OWNED_SKILL_NAME.test(name) || name === metaFileName);
121
+ }
122
+
123
+ // 우리 항목만 삭제 (메타에 적힌 공용 파이썬 포함) > 폴더가 비면 폴더도 삭제. 실패는 예외로 올린다.
124
+ export function removeOwnedEntries(dir, ctx, metaFileName) {
125
+ const { sharedScripts } = readSkillsMeta(join(dir, metaFileName));
126
+ for (const name of ownedEntries(dir, ctx, metaFileName)) rmSync(join(dir, name), { recursive: true, force: true });
127
+ if (existsSync(dir) && readdirSync(dir).length === 0) rmSync(dir, { recursive: true, force: true });
128
+ const scriptsDest = join(dirname(dir), SHARED_SCRIPTS_DIR_NAME);
129
+ if (sharedScripts.length && existsSync(scriptsDest)) removeNamedEntries(scriptsDest, sharedScripts);
130
+ }
131
+
132
+ // 메타 기록: 버전 추적용 (installedAt 은 첫 설치 시각 유지). extraFields: sharedScripts 등
133
+ export function writeSkillsMeta(destDir, metaFileName, templateVersion, extraFields = {}) {
134
+ const version = templateVersion || "unknown";
135
+ const now = new Date().toISOString().replace(/\.\d+Z$/, "Z");
136
+ const file = join(destDir, metaFileName);
137
+ let installedAt = now;
138
+ if (existsSync(file)) { try { installedAt = JSON.parse(readFileSync(file, "utf8")).installedAt || now; } catch { /* 무해 */ } }
139
+ const meta = {
140
+ name: "projectops", version, scope: "user",
141
+ source: TEMPLATE_REPO_WEB_URL,
142
+ installPath: destDir, installedAt, lastUpdated: now,
143
+ ...extraFields,
144
+ };
145
+ mkdirSync(destDir, { recursive: true });
146
+ writeFileSync(file, JSON.stringify(meta, null, 2) + "\n");
147
+ }
@@ -0,0 +1,51 @@
1
+ // 고정 진입점 설치: ~/.projectops/bin/{projectops_cli.py, launch_cli.py} (#942·#897)
2
+ // SKILL.md 는 이 진입점을 먼저 부른다. 파이썬 쪽은 CLI 를 한 번 실행해야 진입점을 깔므로,
3
+ // 홈 복사 설치(Cursor·Copilot)는 첫 호출부터 길을 찾도록 설치 때 깐다 (#200).
4
+ // 규칙은 파이썬 refresh_stable_entrypoint 와 같다: 소스 RESOLVER_REVISION 이 깔린 것보다 높을 때만 덮는다.
5
+ import { join } from "node:path";
6
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, chmodSync, renameSync } from "node:fs";
7
+
8
+ const REVISION_PATTERN = /^RESOLVER_REVISION = (\d+)/m;
9
+ // 진입점 권한: 직접 실행 가능 (rwxr-xr-x)
10
+ const STABLE_ENTRYPOINT_MODE = 0o755;
11
+ // 진입점 2종: (설치 이름, 소스 경로 계산)
12
+ const STABLE_ENTRYPOINTS = [
13
+ { name: "projectops_cli.py", source: ({ scriptsSrc }) => scriptsSrc && join(scriptsSrc, "projectops_cli.py") },
14
+ { name: "launch_cli.py", source: ({ skillsSrc }) => skillsSrc && join(skillsSrc, "pro-launch", "scripts", "launch_resolver.py") },
15
+ ];
16
+
17
+ // 파일 > 내용 번호 (없거나 못 읽으면 -1)
18
+ function readRevision(file) {
19
+ try {
20
+ const match = REVISION_PATTERN.exec(readFileSync(file, "utf8"));
21
+ return match ? Number(match[1]) : -1;
22
+ } catch { return -1; }
23
+ }
24
+
25
+ /**
26
+ * 진입점 갱신: 소스 번호 > 깔린 번호일 때만 임시 파일 > 권한 > 교체
27
+ * 바꾼 진입점 이름 목록 반환. 실패는 예외로 올린다.
28
+ */
29
+ export function refreshStableEntrypoints(projectopsHome, sources) {
30
+ const binDir = join(projectopsHome, "bin");
31
+ const updatedNames = [];
32
+ for (const entry of STABLE_ENTRYPOINTS) {
33
+ const sourceFile = entry.source(sources);
34
+ if (!sourceFile || !existsSync(sourceFile)) continue;
35
+ const target = join(binDir, entry.name);
36
+ if (readRevision(sourceFile) <= readRevision(target)) continue;
37
+ mkdirSync(binDir, { recursive: true });
38
+ // 다른 세션이 동시에 읽어도 반쯤 쓴 파일을 보지 않게
39
+ const temporary = `${target}.${process.pid}.tmp`;
40
+ writeFileSync(temporary, readFileSync(sourceFile, "utf8"));
41
+ chmodSync(temporary, STABLE_ENTRYPOINT_MODE);
42
+ renameSync(temporary, target);
43
+ updatedNames.push(entry.name);
44
+ }
45
+ return updatedNames;
46
+ }
47
+
48
+ // projectops 홈: io 가 주면 그 값 (테스트 격리), 아니면 ~/.projectops
49
+ export function projectopsHomeOf(io) {
50
+ return typeof io.projectopsHome === "function" ? io.projectopsHome() : join(io.home(), ".projectops");
51
+ }
@@ -75,7 +75,7 @@ export function deriveEnvApplied(events = []) {
75
75
  // 실행 엔트리 렌더링. report:
76
76
  // { now, mode, types, repoName, templateFrom, templateTo,
77
77
  // options: {deploy, publish, secretBackup, coderabbit, changelogProvider, intent},
78
- // branches: {defaultBranch, deployBranch, ready, created},
78
+ // branches: {defaultBranch, deployBranch, ready, created, result}, // result: #715 비대화형 생성 결과 | null
79
79
  // breaking: {current, target, critical:[], warnings:[]} | null,
80
80
  // migrations: {applied:[], confirmPending:[], askPending:[]} | null,
81
81
  // orphans: {cleaned:[], pending:[]} | null,
@@ -130,7 +130,15 @@ export function renderGuideEntry(report) {
130
130
  for (const [k, v] of kv) checklist.push(` - ${t} \`${k}\` = \`${v}\``);
131
131
  }
132
132
  }
133
- if (r.branches?.created === true) {
133
+ const devBranch = r.branches?.deployBranch ?? "develop";
134
+ const devBranchResult = r.branches?.result ?? null; // #715 --create-development-branch 결과
135
+ if (devBranchResult?.status === "created") {
136
+ checklist.push(`- [x] Development (release source) branch \`${devBranch}\`: created from \`${devBranchResult.base || "HEAD"}\` and pushed (--create-development-branch)`);
137
+ } else if (devBranchResult?.status === "skipped") {
138
+ checklist.push(`- [x] Development (release source) branch \`${devBranch}\`: already exists, nothing changed (--create-development-branch)`);
139
+ } else if (devBranchResult?.status === "failed") {
140
+ checklist.push(`- [ ] **Create the development (release source) branch \`${devBranch}\`**: automatic creation failed (${devBranchResult.reason}). Run: \`${devBranchResult.manualCommand}\``);
141
+ } else if (r.branches?.created === true) {
134
142
  checklist.push(`- [x] Development (release source) branch \`${r.branches?.deployBranch ?? "develop"}\`: created and verified by the wizard`);
135
143
  } else if (r.branches?.ready === false) {
136
144
  checklist.push(`- [ ] Create the development (release source) branch \`${r.branches?.deployBranch ?? "develop"}\` (required for the release PR to work)`);
@@ -165,7 +173,7 @@ export function renderGuideEntry(report) {
165
173
  L.push(`mode: ${r.mode || "full"}`);
166
174
  L.push(`types: ${ylist(r.types)}`);
167
175
  L.push(`options: { deploy: ${yq(r.options?.deploy ?? "")}, publish: ${ylist(r.options?.publish)}, secret_backup: ${r.options?.secretBackup === true}, coderabbit: ${r.options?.coderabbit === true}, changelog_provider: ${yq(r.options?.changelogProvider ?? "")}, intent: ${yq(r.options?.intent ?? "")}, semver_auto: ${r.options?.semverAuto === true}, language: ${yq(r.options?.language ?? "")}, label_style: ${yq(r.options?.labelStyle ?? "")}, close_on_release: ${r.options?.closeOnRelease === true}, app_release: ${r.options?.appRelease === true} }`);
168
- L.push(`branches: { default: ${yq(r.branches?.defaultBranch ?? "main")}, deploy: ${yq(r.branches?.deployBranch ?? "develop")}, deploy_branch_created: ${r.branches?.created === true} }`);
176
+ L.push(`branches: { default: ${yq(r.branches?.defaultBranch ?? "main")}, deploy: ${yq(r.branches?.deployBranch ?? "develop")}, deploy_branch_created: ${r.branches?.created === true}, create_result: ${devBranchResult ? yq(devBranchResult.status) : "null"} }`);
169
177
  L.push("workflows:");
170
178
  L.push(` added: ${ylist(wf.added)}`);
171
179
  L.push(` replaced_bak: ${ylist(wf.replacedBak)}`);
@@ -21,35 +21,37 @@ import { DEPLOY_TARGETS, PUBLISH_TARGETS, CHANGELOG_PROVIDERS, DEFAULT_CODE_REVI
21
21
  // true = 브랜치가 이미 있거나 이번 실행에서 생성함 → 요약에서 생성 안내 불필요
22
22
  // false = 없는데 사용자가 거절/생성 실패 → 안내 유지
23
23
  // null = 확인 자체를 못 함(비레포·질문 불가) → 안내 유지(보수적)
24
+ // 반환 state (#715 — 비대화형 결과 기록용): not-checked · not-a-repo · exists-local · exists-remote ·
25
+ // declined · create-failed · push-declined · pushed · push-failed
24
26
  export async function ensureDeployBranch({ targetRoot = ".", deployBranch = "", defaultBranch = "", io = {}, say = () => {} }) {
25
- if (!deployBranch || typeof io.confirm !== "function") return { created: false, pushed: false, ready: null };
27
+ if (!deployBranch || typeof io.confirm !== "function") return { created: false, pushed: false, ready: null, state: "not-checked" };
26
28
  const st = branchStatus(targetRoot, deployBranch);
27
- if (!st.isRepo) return { created: false, pushed: false, ready: null };
28
- if (st.local) return { created: false, pushed: false, ready: true };
29
+ if (!st.isRepo) return { created: false, pushed: false, ready: null, state: "not-a-repo" };
30
+ if (st.local) return { created: false, pushed: false, ready: true, state: "exists-local" };
29
31
  if (st.remote === true) {
30
32
  say(msg("options.branch.remoteOnly", { branch: deployBranch }));
31
- return { created: false, pushed: false, ready: true };
33
+ return { created: false, pushed: false, ready: true, state: "exists-remote" };
32
34
  }
33
35
  say(msg("options.branch.missing", { branch: deployBranch, base: defaultBranch || msg("options.branch.baseFallback") }));
34
36
  const mk = await io.confirm({ message: msg("options.branch.createPrompt", { base: defaultBranch || msg("options.branch.currentFallback"), branch: deployBranch }), initialValue: true });
35
37
  if (mk !== true) {
36
38
  say(msg("options.branch.skipped", { branch: deployBranch }));
37
- return { created: false, pushed: false, ready: false };
39
+ return { created: false, pushed: false, ready: false, state: "declined" };
38
40
  }
39
41
  if (!createBranch(targetRoot, deployBranch, defaultBranch)) {
40
42
  say(msg("options.branch.createFailed", { branch: deployBranch, from: defaultBranch ? ` ${defaultBranch}` : "" }));
41
- return { created: false, pushed: false, ready: false };
43
+ return { created: false, pushed: false, ready: false, state: "create-failed" };
42
44
  }
43
45
  say(msg("options.branch.created", { branch: deployBranch }));
44
46
  // #481 — push 질문에 브랜치명 명시 ("어느 브랜치를 push하는지" 불명확 방지)
45
47
  const up = await io.confirm({ message: msg("options.branch.pushPrompt", { branch: deployBranch }), initialValue: true });
46
- if (up !== true) return { created: true, pushed: false, ready: true };
48
+ if (up !== true) return { created: true, pushed: false, ready: true, state: "push-declined" };
47
49
  if (pushBranch(targetRoot, deployBranch)) {
48
50
  say(msg("options.branch.pushed", { branch: deployBranch }));
49
- return { created: true, pushed: true, ready: true };
51
+ return { created: true, pushed: true, ready: true, state: "pushed" };
50
52
  }
51
53
  say(msg("options.branch.pushFailed", { branch: deployBranch }));
52
- return { created: true, pushed: false, ready: true };
54
+ return { created: true, pushed: false, ready: true, state: "push-failed" };
53
55
  }
54
56
 
55
57
  // 재노출 — 파서 본체는 version-yml.js에 있다 (순환 import 방지: options-ask → version-yml 방향만 허용)
@@ -48,6 +48,8 @@ export const FLAGS = [
48
48
  description: "Registries a library is published to. Several allowed.", versionYml: "metadata.template.options.publish" },
49
49
  { flag: "--deploy-branch", value: "NAME", default: "develop",
50
50
  description: "Development branch: the head of the release PR. Not the repository default branch.", versionYml: "metadata.development_branch" },
51
+ { flag: "--create-development-branch", default: "off",
52
+ description: "Non-interactive runs: create the development branch from the default branch and push it to origin when it does not exist." },
51
53
  { flag: "--language", value: "LANG", values: [...REPO_LANGUAGES], default: "en for new installs, ko for existing installs",
52
54
  description: "Language of issue/PR templates and bot messages written into the repo.", versionYml: "metadata.template.options.language" },
53
55
  { flag: "--label-style", value: "STYLE", values: [...LABEL_STYLES], default: "en for new installs, ko for existing installs",
@@ -183,11 +185,18 @@ export const FLAG_DETAILS = {
183
185
  },
184
186
  "--deploy-branch": {
185
187
  when: "Only if your development branch is not named develop.",
186
- effect: "Writes metadata.development_branch (and the old deploy_branch with the same value) and rewrites the branch name inside the installed workflows (release PR head, CI triggers, PR summary base). The branch is NOT created by this flag.",
188
+ effect: "Writes metadata.development_branch (and the old deploy_branch with the same value) and rewrites the branch name inside the installed workflows (release PR head, CI triggers, PR summary base). The branch is NOT created by this flag (use --create-development-branch).",
187
189
  omitted: "develop.",
188
190
  example: "npx projectops --mode full --force --deploy-branch dev",
189
191
  caution: "Despite the name this is the development branch (head of the release PR), not the branch that deploys. The deploying branch is the repository default branch.",
190
192
  },
193
+ "--create-development-branch": {
194
+ when: "Agents and CI integrating a repo that has no development branch yet. Interactive runs ask instead.",
195
+ effect: "After the install, checks the development branch (--deploy-branch, default develop). Exists locally or on origin: nothing is done. Missing: creates it from the repository default branch (no checkout) and pushes it with git push -u origin <branch>. The result (created, skipped or failed) is printed in the completion summary and recorded in .github/.projectops/logs/ and the migration guide checklist.",
196
+ omitted: "The branch is never created or pushed; the completion summary lists it as a manual step with the command to run.",
197
+ example: "npx projectops --mode full --force --create-development-branch",
198
+ caution: "It pushes to the remote. A failed push (credentials, network, no origin) does not fail the run: the exit code stays 0 and the summary prints the command to run yourself.",
199
+ },
191
200
  "--language": {
192
201
  when: "Set it only to change the language of an existing repo or to start a new repo in Korean.",
193
202
  effect: "Chooses the language of the issue/PR templates and bot comments written into the repo, and of the comments inside version.yml.",
@@ -294,6 +303,7 @@ export const RECIPES = [
294
303
  { goal: "Never install a template workflow again", command: "Add its file name to metadata.template.options.excluded_workflows in version.yml", note: "Delete the installed file too; the update will not bring it back." },
295
304
  { goal: "Stop retired workflows from running next to their replacements", command: "npx projectops --mode workflows --force --remove-legacy", note: "Run only after the new workflow is working." },
296
305
  { goal: "Keep every release a patch bump", command: "Set metadata.template.options.semver_auto: false in version.yml" },
306
+ { goal: "Install everything and create the development branch (develop) in one run", command: "npx projectops --mode full --force --create-development-branch", note: "Pushes the new branch to origin. Does nothing if it already exists." },
297
307
  { goal: "Install a library publish workflow", command: "npx projectops --mode workflows --force --type node --publish npm", note: "Needs the NPM_TOKEN secret." },
298
308
  ];
299
309
 
@@ -0,0 +1,80 @@
1
+ // 자동 트리거 + 예시 값이 함께 남은 워크플로우 감지 (#662)
2
+ // 예시 값(your-project 등)은 미치환 토큰(__X__) 검사에 걸리지 않아, 푸시·라벨마다 실패 런이 쌓여도 doctor 가 통과시켰다.
3
+ // ⚠️ YAML 파서 없이 라인 단위로 본다 (의존성 0 — 설치 CLI 는 dependencies 가 비어 있다).
4
+ import { join } from "node:path";
5
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
6
+
7
+ // 사람이 직접 눌러야 도는 트리거 3개 — 나머지는 전부 자동 트리거로 본다
8
+ const MANUAL_EVENTS = new Set(["workflow_dispatch", "workflow_call", "repository_dispatch"]);
9
+
10
+ // 예시 값 판정: your-/your_ 접두 단어, example.com 류 도메인, 미치환 토큰 (heredoc 구분자 __SUH_*__ 제외)
11
+ const PLACEHOLDER_VALUE_RES = [
12
+ /(^|[^a-z0-9])your[-_][a-z0-9]/i,
13
+ /(^|[.@/])example\.(com|org|net)\b/i,
14
+ /__(?!SUH_)[A-Z][A-Z0-9_]*__/,
15
+ ];
16
+
17
+ // 환경변수형 줄만 본다: ` KEY: "값"` (run 스크립트 안 URL·문구 오탐 방지)
18
+ const ENV_LINE_RE = /^\s*([A-Z][A-Z0-9_]*):\s*(?:"([^"]*)"|'([^']*)'|([^\s#][^#]*?))\s*(?:#.*)?$/;
19
+
20
+ const isCommented = (line) => /^\s*#/.test(line);
21
+
22
+ /**
23
+ * on: 블록 > 이벤트 이름 목록 (인라인 `on: push`, `on: [a, b]`, 블록 형식 3가지)
24
+ */
25
+ export function parseTriggerEvents(text) {
26
+ const lines = String(text || "").split(/\r?\n/);
27
+ const start = lines.findIndex((l) => /^(on|"on"|'on'):/.test(l));
28
+ if (start < 0) return [];
29
+ const inline = lines[start].replace(/^[^:]*:\s*/, "").replace(/#.*$/, "").trim();
30
+ if (inline) return inline.replace(/^\[|\]$/g, "").split(",").map((s) => s.trim()).filter(Boolean);
31
+
32
+ // 블록: 첫 하위 줄의 들여쓰기 깊이에 있는 키만 이벤트로 센다
33
+ const events = [];
34
+ let depth = null;
35
+ for (const line of lines.slice(start + 1)) {
36
+ if (!line.trim() || isCommented(line)) continue;
37
+ const indent = line.match(/^\s*/)[0].length;
38
+ if (indent === 0) break;
39
+ if (depth === null) depth = indent;
40
+ if (indent !== depth) continue;
41
+ const key = line.trim().match(/^([A-Za-z_]+):/);
42
+ if (key) events.push(key[1]);
43
+ }
44
+ return events;
45
+ }
46
+
47
+ /**
48
+ * 워크플로우 본문 > 예시 값이 남은 환경변수 줄 [{ key, value, line }]
49
+ */
50
+ export function findPlaceholderValues(text) {
51
+ const found = [];
52
+ String(text || "").split(/\r?\n/).forEach((line, index) => {
53
+ if (isCommented(line)) return;
54
+ const match = line.match(ENV_LINE_RE);
55
+ if (!match) return;
56
+ const value = match[2] ?? match[3] ?? match[4] ?? "";
57
+ if (PLACEHOLDER_VALUE_RES.some((re) => re.test(value))) {
58
+ found.push({ key: match[1], value, line: index + 1 });
59
+ }
60
+ });
61
+ return found;
62
+ }
63
+
64
+ /**
65
+ * 설치된 워크플로우 폴더 > 자동 트리거와 예시 값을 함께 가진 파일 [{ file, triggers, values }]
66
+ */
67
+ export function scanPlaceholderTriggers(workflowsDir) {
68
+ if (!existsSync(workflowsDir)) return [];
69
+ const files = readdirSync(workflowsDir).filter((f) => /\.ya?ml$/.test(f)).sort();
70
+ const result = [];
71
+ for (const file of files) {
72
+ let text;
73
+ try { text = readFileSync(join(workflowsDir, file), "utf8"); } catch { continue; }
74
+ const triggers = parseTriggerEvents(text).filter((e) => !MANUAL_EVENTS.has(e));
75
+ if (!triggers.length) continue;
76
+ const values = findPlaceholderValues(text);
77
+ if (values.length) result.push({ file, triggers, values });
78
+ }
79
+ return result;
80
+ }
@@ -20,6 +20,10 @@ export default {
20
20
  "doctor.unres.intro": "Template values are still present at the locations below.",
21
21
  "doctor.unres.more": " ... and {n} more",
22
22
  "doctor.unres.outro": "If left as is, those workflows fail at the deploy step. Fill in the values yourself.",
23
+ "doctor.ph.name": "Example values with automatic triggers",
24
+ "doctor.ph.purpose": "Workflows that fail on every trigger until configured",
25
+ "doctor.ph.l1": "These workflows run automatically (push, labels and so on) but still hold example values, so each trigger can create a failed run.",
26
+ "doctor.ph.fix": "Fix: fill in the real values, or delete the workflows you do not use.",
23
27
  "doctor.sec.name": "Required secrets",
24
28
  "doctor.sec.purpose": "Values the installed workflows require",
25
29
  "doctor.sec.line": " {name} <- {n} workflow(s)",
@@ -20,6 +20,10 @@ export default {
20
20
  "doctor.unres.intro": "아래 위치에 템플릿 값이 그대로 남아 있습니다.",
21
21
  "doctor.unres.more": " … 외 {n}건",
22
22
  "doctor.unres.outro": "그대로 두면 해당 워크플로우가 배포 단계에서 실패합니다. 직접 값을 채워주세요.",
23
+ "doctor.ph.name": "자동 실행되는 예시 값",
24
+ "doctor.ph.purpose": "설정 전까지 실행될 때마다 실패하는 워크플로우",
25
+ "doctor.ph.l1": "아래 워크플로우는 자동으로 실행되는데(push, 라벨 등) 예시 값이 그대로 남아 있어, 실행될 때마다 실패 런이 생길 수 있습니다.",
26
+ "doctor.ph.fix": "조치: 실제 값을 채우거나, 쓰지 않는 워크플로우는 삭제하세요.",
23
27
  "doctor.sec.name": "필요한 Secret",
24
28
  "doctor.sec.purpose": "설치된 워크플로우가 요구하는 값",
25
29
  "doctor.sec.line": " {name} ← {n}개 워크플로우",
package/src/i18n/en.js CHANGED
@@ -31,7 +31,7 @@ export default {
31
31
  "mode.version": "Versioning only (automation system)",
32
32
  "mode.workflows": "Workflows only (GitHub Actions build and deploy)",
33
33
  "mode.issues": "Issue/PR templates only",
34
- "mode.skills": "AI skills only (Claude, Cursor, Gemini, Codex, PI)",
34
+ "mode.skills": "AI skills only (Claude, Cursor, Gemini, Codex, Copilot, PI)",
35
35
 
36
36
  // confirm and edit menus
37
37
  "confirm.prompt": "Continue with these settings?",
@@ -113,7 +113,7 @@ export default {
113
113
  "flow.askBranch": "Default branch",
114
114
  "flow.badVersion": "Invalid version format (use x.y.z). Keeping the current value.",
115
115
  "flow.badVersionTitle": "Version",
116
- "flow.askSkills": "Also install or update AI agent skills (Claude, Cursor, Gemini, Codex, PI)?",
116
+ "flow.askSkills": "Also install or update AI agent skills (Claude, Cursor, Gemini, Codex, Copilot, PI)?",
117
117
  "flow.done": "Done. Installed in {mode} mode.",
118
118
  "flow.updateScope": "Update ({scope} scope)",
119
119
  "flow.conflictsTitle": "{count} workflows changed in the new template",
@@ -30,14 +30,27 @@ export default {
30
30
  "ide.codex.legacy": " Removed legacy Codex skills: {name}",
31
31
  "ide.cursor.noSource": " Could not find the skills source (a skills/ folder is required).",
32
32
  "ide.cursor.copying": "Copying Cursor Skills...",
33
- "ide.cursor.obsolete": " Removed obsolete skill: {name}",
34
33
  "ide.cursor.installed": " Cursor Skills installed ({dest}/, v{version})",
35
34
  "ide.cursor.copyFailed": " Cursor Skills copy failed. Check the skills/ folder.",
36
35
  "ide.cursor.removeNone": " No Cursor Skills are installed, skipping",
37
36
  "ide.cursor.removed": " Cursor Skills removed (projectops-owned only, {dir}/)",
38
37
  "ide.cursor.removeFailed": " Cursor Skills removal failed. Check manually: {dir}",
39
38
  "ide.cursor.legacy": " Cleaned up legacy Cursor Skills (selective): name={name}, v={version}, reinstalling",
40
- "ide.cursor.manual": " Tip for Cursor: copy the skills/ folder to ~/.cursor/skills/.",
39
+ "ide.cursor.manual": " Tip for Cursor: copy skills/ to ~/.cursor/skills/ and scripts/ to ~/.cursor/scripts/ (the skill CLIs import scripts/common).",
40
+ "ide.skillsCopy.obsolete": " Removed obsolete skill: {name}",
41
+ "ide.skillsCopy.noScripts": " Shared Python scripts (scripts/common) not found: the skills' Python CLIs will not run until reinstalled from the full template.",
42
+ "ide.skillsCopy.entrypoint": " Stable entry point installed: {path}",
43
+ "ide.skillsCopy.entrypointFailed": " Could not install the stable entry point under ~/.projectops/bin (the first CLI run retries).",
44
+ "ide.copilot.detected": "Installable (Copilot detected)",
45
+ "ide.copilot.notDetected": "Copilot not detected (no copilot CLI, ~/.copilot, VS Code Copilot extension or JetBrains Copilot plugin)",
46
+ "ide.copilot.noSource": " Could not find the skills source (a skills/ folder is required).",
47
+ "ide.copilot.copying": "Copying GitHub Copilot Skills...",
48
+ "ide.copilot.installed": " GitHub Copilot Skills installed ({dest}/, v{version}). Call them with /pro- in Copilot chat (VS Code, JetBrains) or the Copilot CLI",
49
+ "ide.copilot.copyFailed": " GitHub Copilot Skills copy failed. Check the skills/ folder.",
50
+ "ide.copilot.removeNone": " No GitHub Copilot Skills are installed, skipping",
51
+ "ide.copilot.removed": " GitHub Copilot Skills removed (projectops-owned only, {dir}/)",
52
+ "ide.copilot.removeFailed": " GitHub Copilot Skills removal failed. Check manually: {dir}",
53
+ "ide.copilot.manual": " Tip for GitHub Copilot: copy skills/ to ~/.copilot/skills/ and scripts/ to ~/.copilot/scripts/ (the skill CLIs import scripts/common).",
41
54
  "ide.gemini.updating": "Updating the Gemini CLI extension...",
42
55
  "ide.gemini.updated": " Gemini CLI extension updated",
43
56
  "ide.gemini.installing": "Installing the Gemini CLI extension...",
@@ -30,14 +30,27 @@ export default {
30
30
  "ide.codex.legacy": " 레거시 Codex skills 정리: {name}",
31
31
  "ide.cursor.noSource": " 설치할 스킬 소스를 찾지 못했습니다 (skills/ 폴더 필요).",
32
32
  "ide.cursor.copying": "Cursor Skills 복사 중...",
33
- "ide.cursor.obsolete": " 폐기된 스킬 제거: {name}",
34
33
  "ide.cursor.installed": " Cursor Skills 설치 완료 ({dest}/, v{version})",
35
34
  "ide.cursor.copyFailed": " Cursor Skills 복사 실패 — skills/ 폴더를 확인하세요.",
36
35
  "ide.cursor.removeNone": " 설치된 Cursor Skills가 없어 건너뜁니다",
37
36
  "ide.cursor.removed": " Cursor Skills 제거 완료 (projectops 소유만, {dir}/)",
38
37
  "ide.cursor.removeFailed": " Cursor Skills 제거 실패 — 수동 확인: {dir}",
39
38
  "ide.cursor.legacy": " 레거시 Cursor Skills 정리(선별): name={name}, v={version} → 재설치",
40
- "ide.cursor.manual": " 💡 Cursor: skills/ 폴더를 ~/.cursor/skills/ 로 복사하면 됩니다.",
39
+ "ide.cursor.manual": " 💡 Cursor: skills/ 를 ~/.cursor/skills/ 로, scripts/ 를 ~/.cursor/scripts/ 로 복사하면 됩니다 (스킬 CLI 가 scripts/common 을 import).",
40
+ "ide.skillsCopy.obsolete": " 폐기된 스킬 제거: {name}",
41
+ "ide.skillsCopy.noScripts": " 공용 파이썬(scripts/common)을 찾지 못했습니다 — 전체 템플릿으로 다시 설치하기 전까지 스킬의 파이썬 CLI 는 실행되지 않습니다.",
42
+ "ide.skillsCopy.entrypoint": " 고정 진입점 설치: {path}",
43
+ "ide.skillsCopy.entrypointFailed": " ~/.projectops/bin 고정 진입점 설치 실패 (CLI 첫 실행 때 다시 시도)",
44
+ "ide.copilot.detected": "설치 가능 (Copilot 감지됨)",
45
+ "ide.copilot.notDetected": "Copilot 미감지 (copilot CLI · ~/.copilot · VS Code Copilot 확장 · JetBrains Copilot 플러그인 없음)",
46
+ "ide.copilot.noSource": " 설치할 스킬 소스를 찾지 못했습니다 (skills/ 폴더 필요).",
47
+ "ide.copilot.copying": "GitHub Copilot Skills 복사 중...",
48
+ "ide.copilot.installed": " GitHub Copilot Skills 설치 완료 ({dest}/, v{version}) — Copilot 채팅(VS Code·JetBrains)·Copilot CLI 에서 /pro- 로 호출",
49
+ "ide.copilot.copyFailed": " GitHub Copilot Skills 복사 실패 — skills/ 폴더를 확인하세요.",
50
+ "ide.copilot.removeNone": " 설치된 GitHub Copilot Skills가 없어 건너뜁니다",
51
+ "ide.copilot.removed": " GitHub Copilot Skills 제거 완료 (projectops 소유만, {dir}/)",
52
+ "ide.copilot.removeFailed": " GitHub Copilot Skills 제거 실패 — 수동 확인: {dir}",
53
+ "ide.copilot.manual": " 💡 GitHub Copilot: skills/ 를 ~/.copilot/skills/ 로, scripts/ 를 ~/.copilot/scripts/ 로 복사하면 됩니다 (스킬 CLI 가 scripts/common 을 import).",
41
54
  "ide.gemini.updating": "Gemini CLI extension 업데이트 중...",
42
55
  "ide.gemini.updated": " Gemini CLI extension 업데이트 완료",
43
56
  "ide.gemini.installing": "Gemini CLI extension 설치 중...",
package/src/i18n/ko.js CHANGED
@@ -29,7 +29,7 @@ export default {
29
29
  "mode.version": "버전 관리 전용 (자동화 시스템)",
30
30
  "mode.workflows": "워크플로우 전용 (GitHub Actions 빌드, 배포)",
31
31
  "mode.issues": "이슈/PR 템플릿 전용",
32
- "mode.skills": "AI 스킬 전용 (Claude, Cursor, Gemini, Codex, PI)",
32
+ "mode.skills": "AI 스킬 전용 (Claude, Cursor, Gemini, Codex, Copilot, PI)",
33
33
 
34
34
  "confirm.prompt": "이 정보로 진행할까요?",
35
35
  "confirm.continue": "예, 계속 진행",
@@ -106,7 +106,7 @@ export default {
106
106
  "flow.askBranch": "기본 브랜치",
107
107
  "flow.badVersion": "버전 형식이 올바르지 않습니다 (x.y.z 형태). 기존 값을 유지합니다.",
108
108
  "flow.badVersionTitle": "버전",
109
- "flow.askSkills": "AI 에이전트 스킬(Claude, Cursor, Gemini, Codex, PI)도 설치/업데이트할까요?",
109
+ "flow.askSkills": "AI 에이전트 스킬(Claude, Cursor, Gemini, Codex, Copilot, PI)도 설치/업데이트할까요?",
110
110
  "flow.done": "통합 완료. {mode} 모드로 설치했습니다.",
111
111
  "flow.updateScope": "업데이트 ({scope} 범위)",
112
112
  "flow.conflictsTitle": "템플릿이 갱신된 워크플로우 {count}개",
@@ -10,7 +10,7 @@ export default {
10
10
  "done.feat.coderabbit": "CodeRabbit AI review settings",
11
11
  "done.feat.gitignore": "Required .gitignore entries",
12
12
  "done.feat.guide": "Setup guide (PROJECTOPS-SETUP-GUIDE.md)",
13
- "done.feat.skills": "Agent skills (Claude, Cursor, Gemini, Codex, PI)",
13
+ "done.feat.skills": "Agent skills (Claude, Cursor, Gemini, Codex, Copilot, PI)",
14
14
  "done.addedFiles": "Added files:",
15
15
  "done.versionYml": "version.yml (version: {version}, types: {types})",
16
16
  "done.readmeAdded": "README.md (version section added)",
@@ -56,6 +56,10 @@ export default {
56
56
  "done.todo.pat2": "and allows automerge even with branch protection rules.",
57
57
  "done.todo.branchReady": "Branch {branch} is ready: the wizard checked and created it (nothing to do)",
58
58
  "done.todo.branch": "Create the {branch} branch (if it does not exist yet)",
59
+ "done.todo.branchCreated": "Branch {branch} was created from {base} and pushed to origin (--create-development-branch)",
60
+ "done.todo.branchExists": "Branch {branch} already exists: nothing was changed (--create-development-branch)",
61
+ "done.todo.branchFailed": "Branch {branch} could not be created or pushed ({reason}). The install itself completed. Run it yourself:",
62
+ "done.todo.branchFlagHint": "Non-interactive runs can create and push it for you: add --create-development-branch",
59
63
  "done.todo.ai": "(Optional) Get AI-polished release notes",
60
64
  "done.todo.ai1": "Release notes already work: they are built by analyzing commits, no setup needed.",
61
65
  "done.todo.ai2": "Do the steps below only if you want smoother wording.",
@@ -10,7 +10,7 @@ export default {
10
10
  "done.feat.coderabbit": "CodeRabbit AI 리뷰 설정",
11
11
  "done.feat.gitignore": ".gitignore 필수 항목",
12
12
  "done.feat.guide": "템플릿 설정 가이드 (PROJECTOPS-SETUP-GUIDE.md)",
13
- "done.feat.skills": "Agent Skill 설치 (Claude, Cursor, Gemini, Codex, PI)",
13
+ "done.feat.skills": "Agent Skill 설치 (Claude, Cursor, Gemini, Codex, Copilot, PI)",
14
14
  "done.addedFiles": "추가된 파일:",
15
15
  "done.versionYml": "version.yml (버전: {version}, 타입: {types})",
16
16
  "done.readmeAdded": "README.md (버전 섹션 추가)",
@@ -56,6 +56,10 @@ export default {
56
56
  "done.todo.pat2": "더 확실하게 이어지고, 브랜치 보호 규칙이 있어도 자동 머지가 가능합니다.",
57
57
  "done.todo.branchReady": "{branch} 브랜치 준비 완료: 마법사가 확인 및 생성했습니다 (추가 작업 불필요)",
58
58
  "done.todo.branch": "{branch} 브랜치 생성 (아직 없다면)",
59
+ "done.todo.branchCreated": "{branch} 브랜치를 {base}에서 만들고 origin에 push했습니다 (--create-development-branch)",
60
+ "done.todo.branchExists": "{branch} 브랜치가 이미 있어 아무것도 바꾸지 않았습니다 (--create-development-branch)",
61
+ "done.todo.branchFailed": "{branch} 브랜치를 만들거나 push하지 못했습니다 ({reason}). 통합은 완료됐습니다. 직접 실행하세요:",
62
+ "done.todo.branchFlagHint": "비대화형 실행에서 --create-development-branch 를 주면 자동으로 만들고 push합니다",
59
63
  "done.todo.ai": "(선택) AI가 다듬은 릴리스 노트 받기",
60
64
  "done.todo.ai1": "지금 상태로도 릴리스 노트는 나옵니다. 커밋 내용을 분석해 만들기 때문에 아무 설정을 하지 않아도 됩니다.",
61
65
  "done.todo.ai2": "아래는 문장을 더 매끄럽게 하고 싶을 때만 하세요.",
package/src/index.js CHANGED
@@ -20,6 +20,7 @@ import { createRunTrace, MIGRATION_DIR } from "./core/run-trace.js";
20
20
  import { appendGuideEntry } from "./core/migration-guide.js";
21
21
  import { resolveProjectPaths, markerForType } from "./core/paths-resolve.js";
22
22
  import { resolveOptions } from "./core/resolve-options.js";
23
+ import { runDevelopmentBranchStep } from "./core/development-branch.js";
23
24
  import { printBannerCompact } from "./ui/banner.js";
24
25
  import { printSummary } from "./ui/summary.js";
25
26
  import { runFull } from "./commands/full.js";
@@ -304,6 +305,14 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
304
305
  // 알 수 없는 모드 → 복사 0건, 에러 아님
305
306
  break;
306
307
  }
308
+ // 개발 브랜치 생성 (#715) — --create-development-branch 일 때만. 실패해도 실행은 계속(수동 명령 안내)
309
+ const developmentBranchResult = await runDevelopmentBranchStep({
310
+ isRequested: opts.createDevelopmentBranch === true, targetRoot: cwd,
311
+ branch: context.deployBranch || "develop", defaultBranch: branch, trace,
312
+ say: (line) => console.error(line),
313
+ });
314
+ const isDevelopmentBranchReady = developmentBranchResult ? developmentBranchResult.status !== "failed" : null;
315
+
307
316
  // 마이그레이션 기록 (#493/#494) — Layer 2/3 트레이스 파일 + Layer 1 가이드 엔트리
308
317
  let migrationGuidePath = null;
309
318
  // 기록 파일 경로는 먼저 계산하고(가이드가 참조), 실제 쓰기는 완료 화면 출력 뒤로 미룬다 —
@@ -316,7 +325,8 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
316
325
  now, mode: opts.mode, types, repoName,
317
326
  templateFrom: existing?.templateVersion || "", templateTo: context.templateVersion,
318
327
  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, storeLocales: context.storeLocales, storeLocalesIos: context.storeLocalesIos, storeLocalesPlay: context.storeLocalesPlay },
319
- branches: { defaultBranch: branch, deployBranch: context.deployBranch || "develop", ready: null, created: null },
328
+ branches: { defaultBranch: branch, deployBranch: context.deployBranch || "develop", ready: isDevelopmentBranchReady,
329
+ created: developmentBranchResult ? developmentBranchResult.status === "created" : null, result: developmentBranchResult },
320
330
  breaking: breakingReport, migrations: migrationsResult, orphans: { cleaned: [], pending: orphanPending },
321
331
  events: trace.events, counters: { skipped: result?.workflows?.skipped ?? 0 },
322
332
  traceFile: files?.traceFile ?? "", logFile: files?.logFile ?? "",
@@ -326,6 +336,8 @@ async function runCore(argv, { cwd = process.cwd(), source = { type: "git" }, cl
326
336
  // 완료 요약 (CLI 모드에서도 출력)
327
337
  printSummary({
328
338
  mode: opts.mode, types, version, deployBranch: context.deployBranch, migrationGuidePath,
339
+ developmentBranchResult, // #715 생성 결과 (플래그 없으면 null)
340
+ shouldSuggestCreateBranchFlag: !developmentBranchResult, // #715 플래그 없이 돌았으면 다음엔 쓰라고 안내
329
341
  counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
330
342
  skippedConflicts: result?.workflows?.skippedConflicts ?? [], // #654 병합 안내
331
343
  replacedBak: result?.workflows?.replacedBak ?? [], // #673 기준점 없이 교체한 파일 안내