projectops 4.2.12 → 4.2.13

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
@@ -7,7 +7,7 @@
7
7
  > 이슈 등록부터 커밋, 보고서, 배포까지. 개발자는 코드만 작성하세요.
8
8
 
9
9
  <!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
10
- ## 최신 버전 : v4.2.11 (2026-07-12)
10
+ ## 최신 버전 : v4.2.12 (2026-07-12)
11
11
 
12
12
  [전체 버전 기록 보기](CHANGELOG.md)
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectops",
3
- "version": "4.2.12",
3
+ "version": "4.2.13",
4
4
  "description": "ProjectOps — 완전 자동화 GitHub 프로젝트 관리 템플릿 통합 CLI",
5
5
  "keywords": [
6
6
  "devops",
@@ -12,7 +12,7 @@ import { parseExisting } from "../core/version-yml.js";
12
12
  import { runBreakingCheck } from "../core/breaking-check.js";
13
13
  import { runMigrations } from "../core/migrations/index.js";
14
14
  import { resolveProjectPaths } from "../core/paths-resolve.js";
15
- import { askAllOptionalWorkflows } from "../core/options-ask.js";
15
+ import { askAllOptionalWorkflows, OPTION_AXES } from "../core/options-ask.js";
16
16
  import { promptEnvPlan } from "../ui/env-plan.js";
17
17
  import { listWorkflowConflicts } from "../core/copy/workflows.js";
18
18
  import { createContext, VALID_TYPES } from "../context.js";
@@ -159,15 +159,15 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
159
159
  } else if (what === "branch") {
160
160
  const b = await io.askText("기본 브랜치", branch);
161
161
  if (!isCancel(b) && b) branch = b;
162
- } else if (what === "deploy-publish") {
163
- // 배포/publish 축 재질문 (#439 forceAsk)
162
+ } else if (OPTION_AXES.includes(what)) {
163
+ // #483 — 선택한 하나만 재질문 (scope). 나머지 옵션은 현재값 그대로 유지.
164
164
  const r = await askAllOptionalWorkflows({
165
165
  tempDir, types, targetRoot: cwd, defaultBranch: branch,
166
166
  current: {
167
167
  deploy: deployTarget, publish: publishTargets, secretBackup: includeSecretBackup,
168
168
  codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch,
169
169
  },
170
- force: false, tty: realTty, forceAsk: true,
170
+ force: false, tty: realTty, forceAsk: true, scope: [what],
171
171
  io: {
172
172
  confirm: ({ message, initialValue }) => io.askYesNo(message, initialValue),
173
173
  select: io.engineIo?.select,
@@ -182,9 +182,6 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
182
182
  changelogProvider = r.changelogProvider;
183
183
  changelogBaseUrl = r.changelogBaseUrl;
184
184
  deployBranch = r.deployBranch;
185
- } else if (what === "secret") {
186
- const y = await io.askYesNo("Secret 백업 워크플로우를 포함할까요?", includeSecretBackup);
187
- if (!isCancel(y)) includeSecretBackup = y === true;
188
185
  }
189
186
  }
190
187
  }
@@ -24,7 +24,7 @@ export async function ensureDeployBranch({ targetRoot = ".", deployBranch = "",
24
24
  say(`ℹ️ '${deployBranch}' 브랜치가 원격에는 있고 로컬에 없습니다 — 필요 시 'git switch ${deployBranch}'로 가져오세요.`);
25
25
  return { created: false, pushed: false };
26
26
  }
27
- say(`⚠️ 배포 브랜치 '${deployBranch}'가 없습니다 — 릴리스 파이프라인(${deployBranch}→${defaultBranch || "기본 브랜치"} PR) 동작하려면 필요합니다.`);
27
+ say(`⚠️ 개발(릴리스 소스) 브랜치 '${deployBranch}'가 없습니다 — 릴리스(${deployBranch}→${defaultBranch || "기본 브랜치"} PR) 동작하려면 필요합니다.`);
28
28
  const mk = await io.confirm({ message: `${defaultBranch || "현재"} 브랜치에서 '${deployBranch}' 브랜치를 만들까요?`, initialValue: true });
29
29
  if (mk !== true) {
30
30
  say(`→ 건너뜁니다. 나중에 직접: git checkout -b ${deployBranch} && git push -u origin ${deployBranch}`);
@@ -35,7 +35,8 @@ export async function ensureDeployBranch({ targetRoot = ".", deployBranch = "",
35
35
  return { created: false, pushed: false };
36
36
  }
37
37
  say(`✅ 로컬 브랜치 '${deployBranch}' 생성 완료 (checkout은 하지 않았습니다)`);
38
- const up = await io.confirm({ message: "원격(origin)에도 push할까요?", initialValue: true });
38
+ // #481 push 질문에 브랜치명 명시 ("어느 브랜치를 push하는지" 불명확 방지)
39
+ const up = await io.confirm({ message: `원격(origin)에 '${deployBranch}' 브랜치도 push할까요?`, initialValue: true });
39
40
  if (up !== true) return { created: true, pushed: false };
40
41
  if (pushBranch(targetRoot, deployBranch)) {
41
42
  say(`✅ origin/${deployBranch} push 완료`);
@@ -85,15 +86,23 @@ async function askOptionalWorkflow({ dir, icon, short, desc, current, force, tty
85
86
  return include;
86
87
  }
87
88
 
89
+ // 옵션 질문의 축 키 (#483 수정 스코프 단위) — 수정 메뉴가 이 단위로 한 축만 재질문한다.
90
+ export const OPTION_AXES = ["deploy", "publish", "code-review", "changelog", "release-branch", "secret"];
91
+
88
92
  // 배포/publish 축 + Secret 백업을 순서대로 질문 (.sh ask_all_optional_workflows 등가).
89
93
  // tempDir: 템플릿 다운로드 루트 — project-types는 {tempDir}/.github/workflows/project-types
90
94
  // current: { deploy: string|null, publish: string[]|null, secretBackup: bool|null } — CLI 명시값
91
- // 반환: { deploy: string, publish: string[], secretBackup: bool }
95
+ // scope: null이면 축(초기 통합·전체 재질문). Set/배열이면 축만 forceAsk 대상 (#483 수정 메뉴 격리).
96
+ // 스코프 밖 축은 forceAsk여도 current/저장값을 그대로 유지하고 다시 묻지 않는다.
97
+ // 반환: { deploy, publish, secretBackup, codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch }
92
98
  export async function askAllOptionalWorkflows({
93
99
  tempDir, types = [], current = {}, targetRoot = ".",
94
- force = false, tty = true, io = {}, forceAsk = false, defaultBranch = "",
100
+ force = false, tty = true, io = {}, forceAsk = false, defaultBranch = "", scope = null,
95
101
  }) {
96
102
  const say = io.log || ((m) => process.stderr.write(`${m}\n`));
103
+ // 축별 재질문 여부: 전역 forceAsk이고, scope가 없거나 그 축을 포함할 때만 강제 질문.
104
+ const scopeSet = scope == null ? null : new Set(scope);
105
+ const ask = (axis) => forceAsk && (scopeSet === null || scopeSet.has(axis));
97
106
  let deploy = current.deploy ?? null;
98
107
  let publish = current.publish ?? null;
99
108
  let secretBackup = current.secretBackup ?? null;
@@ -139,36 +148,47 @@ export async function askAllOptionalWorkflows({
139
148
  if (deploy === null) deploy = "none";
140
149
  if (publish === null) publish = [];
141
150
  } else {
142
- if (forceAsk || deploy === null) {
151
+ const willAskDeploy = ask("deploy") || deploy === null;
152
+ const willAskPublish = ask("publish") || publish === null;
153
+ // #480 — deploy·publish는 서로 다른 두 축이다. 둘 다 물을 참이면 먼저 큰 그림을 한 번 안내한다.
154
+ // (수정 메뉴로 한 축만 고칠 때는 맥락이 이미 명확하므로 생략)
155
+ if (willAskDeploy && willAskPublish && tty && typeof io.select === "function") {
156
+ say("");
157
+ say("🧭 배포는 두 가지가 따로 있습니다 — 서로 독립이라 각각 답하시면 됩니다:");
158
+ say(" 1) 실행물 배포 — 서버/호스팅에 올려 돌리는 것 (Docker, Vercel …)");
159
+ say(" 2) 라이브러리 배포(publish) — 남이 가져다 쓰게 레지스트리에 내는 것 (Nexus, npm …)");
160
+ say(" 먼저 (1) 실행물 배포부터 물어보고, 이어서 (2) 라이브러리 배포를 물어봅니다.");
161
+ }
162
+ if (willAskDeploy) {
143
163
  if (force || !tty || typeof io.select !== "function") {
144
164
  deploy = deploy ?? "docker-ssh";
145
165
  } else {
146
166
  say("");
147
- say("🚀 프로젝트를 어디에 배포하나요?");
148
- say(" 서버·호스팅에 올릴 계획이 있으면 고르고, 지금 없으면 '배포 '으로 두면 됩니다.");
167
+ say("🚀 (1) 실행물(서버/앱)을 어디에 올리나요?");
168
+ say(" 서버·호스팅에 올릴 계획이 있으면 고르고, 없으면 '서버에 올리지 않음'으로 두세요.");
149
169
  const ans = await io.select({
150
- message: "배포 방식을 선택하세요",
170
+ message: "실행물 배포 방식을 선택하세요",
151
171
  options: [
152
172
  { value: "docker-ssh", label: "Docker + SSH 서버 배포 (기본)" },
153
173
  { value: "vercel", label: "Vercel" },
154
- { value: "none", label: "배포하지 않음 (라이브러리/CI 전용)" },
174
+ { value: "none", label: "서버에 올리지 않음 (빌드 검증만 · 라이브러리는 다음에서 선택)" },
155
175
  ],
156
176
  });
157
177
  deploy = (!isCancel(ans) && DEPLOY_TARGETS.includes(ans)) ? ans : (deploy ?? "docker-ssh");
158
- say(`배포 방식: ${deploy}`);
178
+ say(`실행물 배포: ${deploy}`);
159
179
  }
160
180
  }
161
181
 
162
182
  // ── ③ publish 타겟 (다중 선택) ──
163
- if (forceAsk || publish === null) {
183
+ if (willAskPublish) {
164
184
  if (force || !tty || typeof io.multiselect !== "function") {
165
185
  publish = publish ?? [];
166
186
  } else {
167
187
  say("");
168
- say("📦 라이브러리로 배포(publish)할 계획이 있나요?");
169
- say(" 사내 Nexus·npmjs·GitHub Packages 해당되는 걸 고르세요. 없으면 그냥 Enter.");
188
+ say("📦 (2) 이 프로젝트를 남이 가져다 쓰는 라이브러리로도 배포(publish)하나요?");
189
+ say(" (1) 실행물 배포와 별개입니다. 해당되는 걸 고르고, 라이브러리 배포를 안 하면 아무것도 고르지 말고 Enter.");
170
190
  const ans = await io.multiselect({
171
- message: "publish 타겟을 선택하세요 (Space 토글, Enter 확정)",
191
+ message: "라이브러리 배포 타겟 (없으면 선택 없이 Enter = 배포 안 함)",
172
192
  options: [
173
193
  { value: "nexus", label: "사내 Maven(Nexus) 라이브러리 배포" },
174
194
  { value: "npm", label: "공개 npmjs 패키지 배포 (NPM_TOKEN)" },
@@ -180,13 +200,13 @@ export async function askAllOptionalWorkflows({
180
200
  publish = (!isCancel(ans) && Array.isArray(ans))
181
201
  ? ans.filter((t) => PUBLISH_TARGETS.includes(t))
182
202
  : (publish ?? []);
183
- say(`Publish 타겟: ${publish.join(",") || "없음"}`);
203
+ say(publish.length ? `라이브러리 배포: ${publish.join(", ")}` : "라이브러리 배포: 안 함");
184
204
  }
185
205
  }
186
206
  }
187
207
 
188
208
  // ── code_review: CodeRabbit AI 코드 리뷰 (changelog와 무관 — #455) ──
189
- if (forceAsk || codeReviewCoderabbit === null) {
209
+ if (ask("code-review") || codeReviewCoderabbit === null) {
190
210
  if (force || !tty || typeof io.confirm !== "function") {
191
211
  codeReviewCoderabbit = codeReviewCoderabbit ?? false;
192
212
  } else {
@@ -195,24 +215,33 @@ export async function askAllOptionalWorkflows({
195
215
  const ans = await io.confirm({ message: "CodeRabbit AI 코드 리뷰 사용", initialValue: false });
196
216
  codeReviewCoderabbit = (ans === true && !isCancel(ans));
197
217
  say(`CodeRabbit 코드 리뷰: ${codeReviewCoderabbit ? "사용" : "미사용"}`);
218
+ // #481 — "사용"만으로는 안 붙는다. 앱 설치 + 레포 접근 권한이 있어야 실제로 리뷰가 달린다.
219
+ if (codeReviewCoderabbit) {
220
+ say(" ⚠️ 실제로 리뷰가 붙으려면 추가 설정이 필요합니다:");
221
+ say(" 1) https://coderabbit.ai 접속 → GitHub으로 로그인");
222
+ say(" 2) CodeRabbit GitHub 앱 설치 → 이 저장소에 접근 권한(grant access) 부여");
223
+ say(" (이 단계를 안 하면 워크플로우는 켜져도 PR에 리뷰 댓글이 달리지 않습니다)");
224
+ }
198
225
  }
199
226
  }
200
227
 
201
228
  // ── changelog: 릴리스 노트 생성기 (기본 커서 = github-ai — #455) ──
202
- if (forceAsk || changelogProvider === null) {
229
+ if (ask("changelog") || changelogProvider === null) {
203
230
  if (force || !tty || typeof io.select !== "function") {
204
231
  changelogProvider = changelogProvider ?? "github-ai";
205
232
  } else {
206
233
  say("");
207
- say("📝 릴리스 노트(changelog)는 뭘로 만들까요?");
234
+ say("📝 릴리스 노트(changelog)는 1순위로 뭘로 만들까요?");
208
235
  say(" GitHub AI는 설정 없이 바로 됩니다. 나머지는 나중에 GitHub Secret 등록이 필요할 수 있어요.");
236
+ // #481 — 하나만 골라야 하는 게 아니다. 고른 게 실패하면 자동 폴백하므로 안심하고 고르라고 안내.
237
+ say(" ✅ 고른 방식이 실패해도 자동으로 GitHub AI → 커밋 분석 순으로 폴백하니 릴리스 노트는 항상 생성됩니다.");
209
238
  const ans = await io.select({
210
- message: "changelog 생성기를 선택하세요",
239
+ message: "1순위 changelog 생성기를 선택하세요 (실패 시 자동 폴백)",
211
240
  options: [
212
241
  { value: "github-ai", label: "GitHub AI (추천 · 설정 불필요)" },
213
242
  { value: "coderabbit", label: "CodeRabbit" },
214
243
  { value: "openai", label: "OpenAI 호환 API (키 등록 필요)" },
215
- { value: "commit", label: "커밋 분석만 (AI 없음)" },
244
+ { value: "commit", label: "커밋 분석만 (AI 없음 · 최후 안전망)" },
216
245
  ],
217
246
  });
218
247
  changelogProvider = (!isCancel(ans) && CHANGELOG_PROVIDERS.includes(ans)) ? ans : (changelogProvider ?? "github-ai");
@@ -221,7 +250,7 @@ export async function askAllOptionalWorkflows({
221
250
  }
222
251
 
223
252
  // ollama 선택 시에만 base_url 질문 (나머지 provider는 preset base_url 자동 — #455)
224
- if (changelogProvider === "ollama" && (forceAsk || changelogBaseUrl === null || changelogBaseUrl === "")) {
253
+ if (changelogProvider === "ollama" && (ask("changelog") || changelogBaseUrl === null || changelogBaseUrl === "")) {
225
254
  if (force || !tty || typeof io.text !== "function") {
226
255
  changelogBaseUrl = changelogBaseUrl ?? "";
227
256
  } else {
@@ -233,18 +262,21 @@ export async function askAllOptionalWorkflows({
233
262
  changelogBaseUrl = "";
234
263
  }
235
264
 
236
- // ── deploy_branch: 릴리스 PR의 head 브랜치 (#456 — default_branch와 별개) ──
237
- // 대부분 develop→main 릴리스 구조라 기본값 develop. 다른 head를 쓰는 레포를 위해 물어본다.
238
- if (forceAsk || deployBranch === null) {
265
+ // ── 릴리스 소스(개발) 브랜치 (#456 필드 deploy_branch 이름과 달리 "개발 브랜치"다, #482) ──
266
+ // 값은 릴리스 PR(개발→기본)의 head, 개발한 모아 기본 브랜치로 올리는 브랜치다.
267
+ // "배포 브랜치"가 아니다 배포가 도는 곳은 기본 브랜치(default) 쪽 개념. #482 참조.
268
+ if (ask("release-branch") || deployBranch === null) {
239
269
  if (force || !tty || typeof io.text !== "function") {
240
270
  deployBranch = deployBranch ?? "develop";
241
271
  } else {
272
+ const base = defaultBranch || "main"; // git으로 감지된 기본 브랜치 (#481 동적 안내)
242
273
  say("");
243
- say("🌿 릴리스 배포 브랜치(릴리스 PR의 head)는 무엇인가요?");
244
- say(" develop→main 릴리스 구조면 develop 그대로 두세요. 배포 브랜치가 따로면 이름을 적어주세요.");
245
- const ans = await io.text({ message: "배포 브랜치", initialValue: deployBranch ?? "develop" });
274
+ say("🌿 개발한 코드를 모아서 배포로 올리는 '개발 브랜치'는 무엇인가요?");
275
+ say(` 감지된 기본(배포) 브랜치는 '${base}'입니다. 개발은 보통 앞단 브랜치(예: develop)에서 모아 올립니다.`);
276
+ say(" 특별한 이유가 없으면 develop 그대로 두세요.");
277
+ const ans = await io.text({ message: "개발(릴리스 소스) 브랜치", initialValue: deployBranch ?? "develop" });
246
278
  deployBranch = (typeof ans === "string" && !isCancel(ans) && ans.trim()) ? ans.trim() : (deployBranch ?? "develop");
247
- say(`배포 브랜치: ${deployBranch}`);
279
+ say(`개발(릴리스 소스) 브랜치: ${deployBranch}`);
248
280
  // 브랜치 존재 확인 + 생성 제안 (#477) — 없으면 릴리스 파이프라인이 조용히 놀게 된다
249
281
  await ensureDeployBranch({ targetRoot, deployBranch, defaultBranch, io, say });
250
282
  }
@@ -256,7 +288,7 @@ export async function askAllOptionalWorkflows({
256
288
  secretBackup = await askOptionalWorkflow({
257
289
  dir: join(ptDir, "common", "secret-backup"), icon: "🔐", short: "Secret 서버 백업",
258
290
  desc: "GitHub Secret에 저장한 설정 파일을 SSH로 서버에 업로드·이력관리하는 워크플로우입니다.",
259
- current: secretBackup, force, tty, io, forceAsk, say,
291
+ current: secretBackup, force, tty, io, forceAsk: ask("secret"), say,
260
292
  });
261
293
 
262
294
  return {
package/src/ui/prompts.js CHANGED
@@ -39,7 +39,12 @@ export async function editMenu({ showOptional = false } = {}) {
39
39
  { value: "branch", label: "기본 브랜치" },
40
40
  ];
41
41
  if (showOptional) {
42
- options.push({ value: "deploy-publish", label: "배포/Publish 방식" });
42
+ // #483 항목별 격리: 한 축만 골라 그 축만 재질문한다 (통짜 "배포/Publish 방식" 분해)
43
+ options.push({ value: "deploy", label: "배포 방식 (서버 실행물)" });
44
+ options.push({ value: "publish", label: "라이브러리 배포(publish) 타겟" });
45
+ options.push({ value: "code-review", label: "CodeRabbit 코드 리뷰" });
46
+ options.push({ value: "changelog", label: "릴리스 노트(changelog) 생성기" });
47
+ options.push({ value: "release-branch", label: "릴리스 소스(개발) 브랜치" });
43
48
  options.push({ value: "secret", label: "Secret 백업 포함 여부" });
44
49
  }
45
50
  options.push({ value: "done", label: "모두 맞음, 계속" });
package/src/ui/summary.js CHANGED
@@ -142,8 +142,8 @@ export function printSummary(ctx, targetRoot = ".") {
142
142
  err(` 2️⃣ ${deployBranchName} 브랜치 생성 (아직 없다면)`);
143
143
  err(` → git checkout -b ${deployBranchName} && git push -u origin ${deployBranchName}`);
144
144
  err("");
145
- err(" 3️⃣ CodeRabbit 활성화");
146
- err(" → https://coderabbit.ai 방문하여 저장소 활성화");
145
+ err(" 3️⃣ CodeRabbit 활성화 (코드 리뷰를 켰다면)");
146
+ err(" → https://coderabbit.ai 로그인 GitHub 앱 설치 → 이 저장소에 접근 권한(grant access) 부여");
147
147
  err("");
148
148
  err(SEPARATOR);
149
149
  err("");