projectops 4.2.12 → 4.2.14

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.13 (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.14",
4
4
  "description": "ProjectOps — 완전 자동화 GitHub 프로젝트 관리 템플릿 통합 CLI",
5
5
  "keywords": [
6
6
  "devops",
package/src/cli/args.js CHANGED
@@ -3,6 +3,7 @@ import { VALID_TYPES } from "../context.js";
3
3
 
4
4
  export const DEPLOY_TARGETS = ["docker-ssh", "vercel", "none"];
5
5
  export const PUBLISH_TARGETS = ["nexus", "npm", "github-packages"];
6
+ export const INTENT_VALUES = ["app", "library", "both", "none", "manual"];
6
7
 
7
8
  // argv(process.argv.slice(2)) → 파싱 결과. 오류 시 throw(호출부에서 exit 1).
8
9
  export function parseArgs(argv) {
@@ -14,6 +15,7 @@ export function parseArgs(argv) {
14
15
  deployTarget: null, // 배포 축 (#439): docker-ssh|vercel|none, null=미설정
15
16
  publishTargets: null, // publish 축 (#439): 타겟 배열, null=미설정
16
17
  deployBranch: "", // 릴리스 PR head 브랜치 (#456): --deploy-branch, 빈 값=미지정
18
+ intent: null, // 프로젝트 성격 (#485): --intent app|library|both|none|manual, null=미설정(역추론)
17
19
  includeSecretBackup: null,
18
20
  pathsCsv: "", // "flutter=app,react=client" 원문 (정규화는 resolve 단계)
19
21
  force: false,
@@ -79,6 +81,15 @@ export function parseArgs(argv) {
79
81
  if (v) result.deployBranch = v;
80
82
  break;
81
83
  }
84
+ case "--intent": {
85
+ // 프로젝트 성격 (#485). 미지정이면 --deploy/--publish에서 역추론.
86
+ const v = (args.shift() ?? "").trim();
87
+ if (!INTENT_VALUES.includes(v)) {
88
+ throw new CliError(`--intent 값은 ${INTENT_VALUES.join(" | ")} 중 하나여야 합니다: '${v}'`);
89
+ }
90
+ result.intent = v;
91
+ break;
92
+ }
82
93
  // ── deprecated alias (1 minor 유지 — #439) ──
83
94
  case "--nexus":
84
95
  process.stderr.write("⚠️ --nexus는 deprecated입니다. --publish nexus --deploy none 을 사용하세요.\n");
package/src/cli/help.js CHANGED
@@ -13,6 +13,8 @@ export const HELP_TEXT = `projectops — GitHub 프로젝트 자동화 템플릿
13
13
  (next는 react로 흡수됨 — Next.js 프로젝트는 react 사용)
14
14
  --project-version V 통합 대상의 초기 버전 (예: 1.0.0). 미지정 시 자동 감지
15
15
  --paths "t=p,..." 타입별 프로젝트 경로 (모노레포). 예: flutter=app,react=client
16
+ --intent KIND 프로젝트 성격(#485): app | library | both | none | manual
17
+ (미지정 시 --deploy/--publish에서 역추론. library/none→deploy 제외, app/none→publish 제외)
16
18
  --deploy TARGET 배포 방식 택1: docker-ssh(기본) | vercel | none
17
19
  --publish CSV publish 타겟 csv: nexus,npm,github-packages (기본: 없음)
18
20
  --deploy-branch NAME 릴리스 PR head 브랜치 (#456, 기본: develop). default_branch와 별개
@@ -24,7 +24,7 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
24
24
  force = true, now, today, templateVersion = "unknown",
25
25
  deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false,
26
26
  changelogProvider = "github-ai", changelogBaseUrl = "", codeReviewCoderabbit = true,
27
- deployBranch = "" } = context;
27
+ deployBranch = "", intent = null } = context;
28
28
 
29
29
  // project_paths 마커 계산 (.sh existing_marker_in_dir 등가 — 대표 마커명)
30
30
  const pathMarkers = new Map();
@@ -41,7 +41,7 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
41
41
  version, types, paths, pathMarkers, branch, deployBranch, versionCode, now, today,
42
42
  deployValues,
43
43
  templateOptions: { templateVersion, deployTarget, publishTargets, includeSecretBackup, optionsDate: today,
44
- changelogProvider, changelogBaseUrl, codeReviewCoderabbit },
44
+ changelogProvider, changelogBaseUrl, codeReviewCoderabbit, intent },
45
45
  }));
46
46
 
47
47
  // 2. README 버전 섹션
@@ -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";
@@ -88,6 +88,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
88
88
  let changelogProvider = existing?.options?.changelogProvider ?? "github-ai";
89
89
  let changelogBaseUrl = existing?.options?.changelogBaseUrl ?? "";
90
90
  let deployBranch = existing?.options?.deployBranch ?? "develop"; // #456
91
+ let intent = existing?.options?.intent ?? null; // #485 프로젝트 성격
91
92
  const showOptional = mode === "full" || mode === "workflows";
92
93
  const realTty = process.stdout.isTTY === true;
93
94
 
@@ -104,6 +105,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
104
105
  changelogProvider: existing?.options?.changelogProvider ?? null,
105
106
  changelogBaseUrl: existing?.options?.changelogBaseUrl ?? null,
106
107
  deployBranch: existing?.options?.deployBranch ?? null,
108
+ intent: existing?.options?.intent ?? null,
107
109
  },
108
110
  force: false, tty: realTty,
109
111
  io: {
@@ -120,6 +122,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
120
122
  changelogProvider = r.changelogProvider;
121
123
  changelogBaseUrl = r.changelogBaseUrl;
122
124
  deployBranch = r.deployBranch;
125
+ intent = r.intent;
123
126
  }
124
127
 
125
128
  // 확인/수정 루프 — ESC는 '머무르기' (.sh L1877~1881: 명시적 '아니오'만 종료)
@@ -159,15 +162,15 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
159
162
  } else if (what === "branch") {
160
163
  const b = await io.askText("기본 브랜치", branch);
161
164
  if (!isCancel(b) && b) branch = b;
162
- } else if (what === "deploy-publish") {
163
- // 배포/publish 축 재질문 (#439 forceAsk)
165
+ } else if (OPTION_AXES.includes(what)) {
166
+ // #483 — 선택한 하나만 재질문 (scope). 나머지 옵션은 현재값 그대로 유지.
164
167
  const r = await askAllOptionalWorkflows({
165
168
  tempDir, types, targetRoot: cwd, defaultBranch: branch,
166
169
  current: {
167
170
  deploy: deployTarget, publish: publishTargets, secretBackup: includeSecretBackup,
168
- codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch,
171
+ codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch, intent,
169
172
  },
170
- force: false, tty: realTty, forceAsk: true,
173
+ force: false, tty: realTty, forceAsk: true, scope: [what],
171
174
  io: {
172
175
  confirm: ({ message, initialValue }) => io.askYesNo(message, initialValue),
173
176
  select: io.engineIo?.select,
@@ -182,9 +185,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
182
185
  changelogProvider = r.changelogProvider;
183
186
  changelogBaseUrl = r.changelogBaseUrl;
184
187
  deployBranch = r.deployBranch;
185
- } else if (what === "secret") {
186
- const y = await io.askYesNo("Secret 백업 워크플로우를 포함할까요?", includeSecretBackup);
187
- if (!isCancel(y)) includeSecretBackup = y === true;
188
+ intent = r.intent;
188
189
  }
189
190
  }
190
191
  }
@@ -214,7 +215,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
214
215
  const { now, today } = clock || utcNow();
215
216
  const ctx = createContext({
216
217
  mode, force: true, types, version, versionCode, branch, paths, deployTarget, publishTargets, includeSecretBackup,
217
- codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch,
218
+ codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch, intent,
218
219
  repoName, templateVersion, resolvers, envValues, envUseDefaults, now, today,
219
220
  });
220
221
  ctx.templateVersion = templateVersion;
package/src/context.js CHANGED
@@ -24,6 +24,7 @@ export function createContext(overrides = {}) {
24
24
  changelogBaseUrl: null, // ollama일 때만 값
25
25
  codeReviewCoderabbit: null,
26
26
  deployBranch: "", // 릴리스 PR head 브랜치 (#456). 빈 값=metadata.deploy_branch 미출력
27
+ intent: null, // 프로젝트 성격 (#485 — app/library/both/none/manual). null=미설정(역추론)
27
28
  templateVersion: "",
28
29
  tempDir: "",
29
30
  deployValues: new Map(), // "type.KEY" -> value
@@ -10,7 +10,7 @@ import { existsSync, readFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  import { listYamlFiles } from "./fsutil.js";
12
12
  import { PATHS } from "./paths.js";
13
- import { parseTemplateOptions } from "./version-yml.js";
13
+ import { parseTemplateOptions, inferIntent } from "./version-yml.js";
14
14
  import { branchStatus, createBranch, pushBranch } from "./git-branch.js";
15
15
 
16
16
  // 개발(배포) 브랜치 존재 확인 + 생성 제안 (#477) — 대화형 전용.
@@ -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,28 @@ async function askOptionalWorkflow({ dir, icon, short, desc, current, force, tty
85
86
  return include;
86
87
  }
87
88
 
89
+ // 옵션 질문의 축 키 (#483 수정 스코프 단위) — 수정 메뉴가 이 단위로 한 축만 재질문한다.
90
+ // intent(#485)는 프로젝트 성격 — 재질문 시 deploy/publish 축을 재유도한다.
91
+ export const OPTION_AXES = ["intent", "deploy", "publish", "code-review", "changelog", "release-branch", "secret"];
92
+
93
+ // intent → 어떤 배포 축을 물을지 (#485). manual은 둘 다(기존 순차 질문), 나머지는 유도.
94
+ const INTENT_ASKS_DEPLOY = { app: true, both: true, manual: true, library: false, none: false };
95
+ const INTENT_ASKS_PUBLISH = { library: true, both: true, manual: true, app: false, none: false };
96
+
88
97
  // 배포/publish 축 + Secret 백업을 순서대로 질문 (.sh ask_all_optional_workflows 등가).
89
98
  // tempDir: 템플릿 다운로드 루트 — project-types는 {tempDir}/.github/workflows/project-types
90
99
  // current: { deploy: string|null, publish: string[]|null, secretBackup: bool|null } — CLI 명시값
91
- // 반환: { deploy: string, publish: string[], secretBackup: bool }
100
+ // scope: null이면 축(초기 통합·전체 재질문). Set/배열이면 축만 forceAsk 대상 (#483 수정 메뉴 격리).
101
+ // 스코프 밖 축은 forceAsk여도 current/저장값을 그대로 유지하고 다시 묻지 않는다.
102
+ // 반환: { deploy, publish, secretBackup, codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch }
92
103
  export async function askAllOptionalWorkflows({
93
104
  tempDir, types = [], current = {}, targetRoot = ".",
94
- force = false, tty = true, io = {}, forceAsk = false, defaultBranch = "",
105
+ force = false, tty = true, io = {}, forceAsk = false, defaultBranch = "", scope = null,
95
106
  }) {
96
107
  const say = io.log || ((m) => process.stderr.write(`${m}\n`));
108
+ // 축별 재질문 여부: 전역 forceAsk이고, scope가 없거나 그 축을 포함할 때만 강제 질문.
109
+ const scopeSet = scope == null ? null : new Set(scope);
110
+ const ask = (axis) => forceAsk && (scopeSet === null || scopeSet.has(axis));
97
111
  let deploy = current.deploy ?? null;
98
112
  let publish = current.publish ?? null;
99
113
  let secretBackup = current.secretBackup ?? null;
@@ -101,6 +115,7 @@ export async function askAllOptionalWorkflows({
101
115
  let changelogProvider = current.changelogProvider ?? null;
102
116
  let changelogBaseUrl = current.changelogBaseUrl ?? null;
103
117
  let deployBranch = current.deployBranch ?? null; // #456 릴리스 PR head 브랜치
118
+ let intent = current.intent ?? null; // #485 프로젝트 성격 (app/library/both/none/manual)
104
119
 
105
120
  // basic 단독 타입은 서버 배포도 라이브러리 publish도 개념상 성립하지 않는다.
106
121
  // 배포/publish 질문을 건너뛰고 none·[]로 조용히 확정한다 (타입 변경 시 재질문됨).
@@ -131,44 +146,99 @@ export async function askAllOptionalWorkflows({
131
146
  if (changelogBaseUrl === null && saved.changelogBaseUrl !== null) changelogBaseUrl = saved.changelogBaseUrl;
132
147
  // #456 deploy_branch 저장값 재사용
133
148
  if (deployBranch === null && saved.deployBranch != null) deployBranch = saved.deployBranch;
149
+ // #485 intent 저장값 재사용 — 없으면 저장된 deploy/publish에서 역추론(구 version.yml 하위호환)
150
+ if (intent === null) intent = saved.intent ?? inferIntent(saved.deploy, saved.publish);
134
151
  }
135
152
  }
136
153
 
137
- // ── ② 배포 방식 (택1) basic 단독이면 질문 스킵, none으로 확정 ──
154
+ // ── ② intent(프로젝트 성격) 우선 분기 배포 유도 (#485) ──
155
+ // basic 단독이면 intent 개념 자체가 없어 질문 스킵, none/[]로 확정.
138
156
  if (isBasicOnly) {
139
157
  if (deploy === null) deploy = "none";
140
158
  if (publish === null) publish = [];
159
+ intent = "none";
141
160
  } else {
142
- if (forceAsk || deploy === null) {
161
+ // 수정 메뉴에서 deploy/publish 축만 콕 집어 고칠 때(scope에 그 축이 있고 intent는 없음)
162
+ // intent 질문·게이팅을 건너뛰고 그 축만 바로 편집한다 (#483 격리 존중). intent는 값 유지·역추론.
163
+ const scopedAxisOnly = scopeSet !== null && !scopeSet.has("intent");
164
+ if (scopedAxisOnly) {
165
+ if (intent === null) intent = inferIntent(deploy, publish) ?? "manual";
166
+ } else {
167
+ // intent 질문: 미확정이거나(초기 통합) intent 축을 강제 재질문(수정 메뉴 "프로젝트 성격")일 때.
168
+ const askIntent = ask("intent") || intent === null;
169
+ if (askIntent) {
170
+ if (force || !tty || typeof io.select !== "function") {
171
+ // 비대화형: CLI intent 미지정이면 deploy/publish에서 역추론, 그래도 없으면 manual(기존 동작)
172
+ intent = intent ?? inferIntent(deploy, publish) ?? "manual";
173
+ } else {
174
+ say("");
175
+ say("🧭 이 프로젝트는 어떤 성격인가요? (배포 관련 질문을 여기에 맞춰 좁혀드립니다)");
176
+ const ans = await io.select({
177
+ message: "프로젝트 성격을 선택하세요",
178
+ options: [
179
+ { value: "app", label: "서버/호스팅에 올려 돌리는 앱·서비스 (배포만)" },
180
+ { value: "library", label: "남이 가져다 쓰는 라이브러리/패키지 (publish만)" },
181
+ { value: "both", label: "둘 다 (서버로도 돌리고 라이브러리로도 냄)" },
182
+ { value: "none", label: "배포 안 함 (CI·빌드 검증만)" },
183
+ { value: "manual", label: "직접 하나씩 고를게요 (모든 옵션 개별 질문)" },
184
+ ],
185
+ });
186
+ intent = (!isCancel(ans) && INTENT_ASKS_DEPLOY[ans] !== undefined) ? ans : (intent ?? "manual");
187
+ say(`프로젝트 성격: ${intent}`);
188
+ }
189
+ // intent 확정 후 유도: 안 묻는 축은 값 확정, 묻는 축은 재질문 대상으로 되돌린다.
190
+ if (!INTENT_ASKS_DEPLOY[intent]) deploy = "none";
191
+ if (!INTENT_ASKS_PUBLISH[intent]) publish = [];
192
+ if (INTENT_ASKS_DEPLOY[intent] && ask("intent")) deploy = null;
193
+ if (INTENT_ASKS_PUBLISH[intent] && ask("intent")) publish = null;
194
+ }
195
+ }
196
+ // intent가 정해진 뒤, 유도 규칙에 따라 각 축을 물을지 결정 (#485).
197
+ // scopedAxisOnly(수정 메뉴 단일 축)면 intent 게이트를 무시하고 그 축을 편집하게 한다.
198
+ const deployGate = scopedAxisOnly ? true : INTENT_ASKS_DEPLOY[intent];
199
+ const publishGate = scopedAxisOnly ? true : INTENT_ASKS_PUBLISH[intent];
200
+ const willAskDeploy = deployGate && (ask("deploy") || deploy === null);
201
+ const willAskPublish = publishGate && (ask("publish") || publish === null);
202
+ // 유도로 안 묻는 축은 기본값 확정.
203
+ if (!deployGate && deploy === null) deploy = "none";
204
+ if (!publishGate && publish === null) publish = [];
205
+ // manual(직접 고르기)에서 둘 다 물을 땐 두 축이 별개임을 한 번 안내 (#480 계승).
206
+ if (intent === "manual" && willAskDeploy && willAskPublish && tty && typeof io.select === "function") {
207
+ say("");
208
+ say("🧭 배포는 두 가지가 따로 있습니다 — 서로 독립이라 각각 답하시면 됩니다:");
209
+ say(" 1) 실행물 배포 — 서버/호스팅에 올려 돌리는 것 (Docker, Vercel …)");
210
+ say(" 2) 라이브러리 배포(publish) — 남이 가져다 쓰게 레지스트리에 내는 것 (Nexus, npm …)");
211
+ }
212
+ if (willAskDeploy) {
143
213
  if (force || !tty || typeof io.select !== "function") {
144
214
  deploy = deploy ?? "docker-ssh";
145
215
  } else {
146
216
  say("");
147
- say("🚀 프로젝트를 어디에 배포하나요?");
148
- say(" 서버·호스팅에 올릴 계획이 있으면 고르고, 지금 없으면 '배포 '으로 두면 됩니다.");
217
+ say("🚀 (1) 실행물(서버/앱)을 어디에 올리나요?");
218
+ say(" 서버·호스팅에 올릴 계획이 있으면 고르고, 없으면 '서버에 올리지 않음'으로 두세요.");
149
219
  const ans = await io.select({
150
- message: "배포 방식을 선택하세요",
220
+ message: "실행물 배포 방식을 선택하세요",
151
221
  options: [
152
222
  { value: "docker-ssh", label: "Docker + SSH 서버 배포 (기본)" },
153
223
  { value: "vercel", label: "Vercel" },
154
- { value: "none", label: "배포하지 않음 (라이브러리/CI 전용)" },
224
+ { value: "none", label: "서버에 올리지 않음 (빌드 검증만 · 라이브러리는 다음에서 선택)" },
155
225
  ],
156
226
  });
157
227
  deploy = (!isCancel(ans) && DEPLOY_TARGETS.includes(ans)) ? ans : (deploy ?? "docker-ssh");
158
- say(`배포 방식: ${deploy}`);
228
+ say(`실행물 배포: ${deploy}`);
159
229
  }
160
230
  }
161
231
 
162
232
  // ── ③ publish 타겟 (다중 선택) ──
163
- if (forceAsk || publish === null) {
233
+ if (willAskPublish) {
164
234
  if (force || !tty || typeof io.multiselect !== "function") {
165
235
  publish = publish ?? [];
166
236
  } else {
167
237
  say("");
168
- say("📦 라이브러리로 배포(publish)할 계획이 있나요?");
169
- say(" 사내 Nexus·npmjs·GitHub Packages 해당되는 걸 고르세요. 없으면 그냥 Enter.");
238
+ say("📦 (2) 이 프로젝트를 남이 가져다 쓰는 라이브러리로도 배포(publish)하나요?");
239
+ say(" (1) 실행물 배포와 별개입니다. 해당되는 걸 고르고, 라이브러리 배포를 안 하면 아무것도 고르지 말고 Enter.");
170
240
  const ans = await io.multiselect({
171
- message: "publish 타겟을 선택하세요 (Space 토글, Enter 확정)",
241
+ message: "라이브러리 배포 타겟 (없으면 선택 없이 Enter = 배포 안 함)",
172
242
  options: [
173
243
  { value: "nexus", label: "사내 Maven(Nexus) 라이브러리 배포" },
174
244
  { value: "npm", label: "공개 npmjs 패키지 배포 (NPM_TOKEN)" },
@@ -180,13 +250,13 @@ export async function askAllOptionalWorkflows({
180
250
  publish = (!isCancel(ans) && Array.isArray(ans))
181
251
  ? ans.filter((t) => PUBLISH_TARGETS.includes(t))
182
252
  : (publish ?? []);
183
- say(`Publish 타겟: ${publish.join(",") || "없음"}`);
253
+ say(publish.length ? `라이브러리 배포: ${publish.join(", ")}` : "라이브러리 배포: 안 함");
184
254
  }
185
255
  }
186
256
  }
187
257
 
188
258
  // ── code_review: CodeRabbit AI 코드 리뷰 (changelog와 무관 — #455) ──
189
- if (forceAsk || codeReviewCoderabbit === null) {
259
+ if (ask("code-review") || codeReviewCoderabbit === null) {
190
260
  if (force || !tty || typeof io.confirm !== "function") {
191
261
  codeReviewCoderabbit = codeReviewCoderabbit ?? false;
192
262
  } else {
@@ -195,24 +265,33 @@ export async function askAllOptionalWorkflows({
195
265
  const ans = await io.confirm({ message: "CodeRabbit AI 코드 리뷰 사용", initialValue: false });
196
266
  codeReviewCoderabbit = (ans === true && !isCancel(ans));
197
267
  say(`CodeRabbit 코드 리뷰: ${codeReviewCoderabbit ? "사용" : "미사용"}`);
268
+ // #481 — "사용"만으로는 안 붙는다. 앱 설치 + 레포 접근 권한이 있어야 실제로 리뷰가 달린다.
269
+ if (codeReviewCoderabbit) {
270
+ say(" ⚠️ 실제로 리뷰가 붙으려면 추가 설정이 필요합니다:");
271
+ say(" 1) https://coderabbit.ai 접속 → GitHub으로 로그인");
272
+ say(" 2) CodeRabbit GitHub 앱 설치 → 이 저장소에 접근 권한(grant access) 부여");
273
+ say(" (이 단계를 안 하면 워크플로우는 켜져도 PR에 리뷰 댓글이 달리지 않습니다)");
274
+ }
198
275
  }
199
276
  }
200
277
 
201
278
  // ── changelog: 릴리스 노트 생성기 (기본 커서 = github-ai — #455) ──
202
- if (forceAsk || changelogProvider === null) {
279
+ if (ask("changelog") || changelogProvider === null) {
203
280
  if (force || !tty || typeof io.select !== "function") {
204
281
  changelogProvider = changelogProvider ?? "github-ai";
205
282
  } else {
206
283
  say("");
207
- say("📝 릴리스 노트(changelog)는 뭘로 만들까요?");
284
+ say("📝 릴리스 노트(changelog)는 1순위로 뭘로 만들까요?");
208
285
  say(" GitHub AI는 설정 없이 바로 됩니다. 나머지는 나중에 GitHub Secret 등록이 필요할 수 있어요.");
286
+ // #481 — 하나만 골라야 하는 게 아니다. 고른 게 실패하면 자동 폴백하므로 안심하고 고르라고 안내.
287
+ say(" ✅ 고른 방식이 실패해도 자동으로 GitHub AI → 커밋 분석 순으로 폴백하니 릴리스 노트는 항상 생성됩니다.");
209
288
  const ans = await io.select({
210
- message: "changelog 생성기를 선택하세요",
289
+ message: "1순위 changelog 생성기를 선택하세요 (실패 시 자동 폴백)",
211
290
  options: [
212
291
  { value: "github-ai", label: "GitHub AI (추천 · 설정 불필요)" },
213
292
  { value: "coderabbit", label: "CodeRabbit" },
214
293
  { value: "openai", label: "OpenAI 호환 API (키 등록 필요)" },
215
- { value: "commit", label: "커밋 분석만 (AI 없음)" },
294
+ { value: "commit", label: "커밋 분석만 (AI 없음 · 최후 안전망)" },
216
295
  ],
217
296
  });
218
297
  changelogProvider = (!isCancel(ans) && CHANGELOG_PROVIDERS.includes(ans)) ? ans : (changelogProvider ?? "github-ai");
@@ -221,7 +300,7 @@ export async function askAllOptionalWorkflows({
221
300
  }
222
301
 
223
302
  // ollama 선택 시에만 base_url 질문 (나머지 provider는 preset base_url 자동 — #455)
224
- if (changelogProvider === "ollama" && (forceAsk || changelogBaseUrl === null || changelogBaseUrl === "")) {
303
+ if (changelogProvider === "ollama" && (ask("changelog") || changelogBaseUrl === null || changelogBaseUrl === "")) {
225
304
  if (force || !tty || typeof io.text !== "function") {
226
305
  changelogBaseUrl = changelogBaseUrl ?? "";
227
306
  } else {
@@ -233,18 +312,21 @@ export async function askAllOptionalWorkflows({
233
312
  changelogBaseUrl = "";
234
313
  }
235
314
 
236
- // ── deploy_branch: 릴리스 PR의 head 브랜치 (#456 — default_branch와 별개) ──
237
- // 대부분 develop→main 릴리스 구조라 기본값 develop. 다른 head를 쓰는 레포를 위해 물어본다.
238
- if (forceAsk || deployBranch === null) {
315
+ // ── 릴리스 소스(개발) 브랜치 (#456 필드 deploy_branch 이름과 달리 "개발 브랜치"다, #482) ──
316
+ // 값은 릴리스 PR(개발→기본)의 head, 개발한 모아 기본 브랜치로 올리는 브랜치다.
317
+ // "배포 브랜치"가 아니다 배포가 도는 곳은 기본 브랜치(default) 쪽 개념. #482 참조.
318
+ if (ask("release-branch") || deployBranch === null) {
239
319
  if (force || !tty || typeof io.text !== "function") {
240
320
  deployBranch = deployBranch ?? "develop";
241
321
  } else {
322
+ const base = defaultBranch || "main"; // git으로 감지된 기본 브랜치 (#481 동적 안내)
242
323
  say("");
243
- say("🌿 릴리스 배포 브랜치(릴리스 PR의 head)는 무엇인가요?");
244
- say(" develop→main 릴리스 구조면 develop 그대로 두세요. 배포 브랜치가 따로면 이름을 적어주세요.");
245
- const ans = await io.text({ message: "배포 브랜치", initialValue: deployBranch ?? "develop" });
324
+ say("🌿 개발한 코드를 모아서 배포로 올리는 '개발 브랜치'는 무엇인가요?");
325
+ say(` 감지된 기본(배포) 브랜치는 '${base}'입니다. 개발은 보통 앞단 브랜치(예: develop)에서 모아 올립니다.`);
326
+ say(" 특별한 이유가 없으면 develop 그대로 두세요.");
327
+ const ans = await io.text({ message: "개발(릴리스 소스) 브랜치", initialValue: deployBranch ?? "develop" });
246
328
  deployBranch = (typeof ans === "string" && !isCancel(ans) && ans.trim()) ? ans.trim() : (deployBranch ?? "develop");
247
- say(`배포 브랜치: ${deployBranch}`);
329
+ say(`개발(릴리스 소스) 브랜치: ${deployBranch}`);
248
330
  // 브랜치 존재 확인 + 생성 제안 (#477) — 없으면 릴리스 파이프라인이 조용히 놀게 된다
249
331
  await ensureDeployBranch({ targetRoot, deployBranch, defaultBranch, io, say });
250
332
  }
@@ -256,14 +338,18 @@ export async function askAllOptionalWorkflows({
256
338
  secretBackup = await askOptionalWorkflow({
257
339
  dir: join(ptDir, "common", "secret-backup"), icon: "🔐", short: "Secret 서버 백업",
258
340
  desc: "GitHub Secret에 저장한 설정 파일을 SSH로 서버에 업로드·이력관리하는 워크플로우입니다.",
259
- current: secretBackup, force, tty, io, forceAsk, say,
341
+ current: secretBackup, force, tty, io, forceAsk: ask("secret"), say,
260
342
  });
261
343
 
344
+ const finalDeploy = deploy ?? "docker-ssh";
345
+ const finalPublish = publish ?? [];
262
346
  return {
263
- deploy: deploy ?? "docker-ssh", publish: publish ?? [], secretBackup: secretBackup === true,
347
+ deploy: finalDeploy, publish: finalPublish, secretBackup: secretBackup === true,
264
348
  codeReviewCoderabbit: codeReviewCoderabbit === true,
265
349
  changelogProvider: changelogProvider ?? "github-ai",
266
350
  changelogBaseUrl: changelogBaseUrl ?? "",
267
351
  deployBranch: deployBranch ?? "develop",
352
+ // #485 intent — 확정값 우선, 없으면 최종 deploy/publish에서 역추론(basic·비대화형 경로 보정)
353
+ intent: intent ?? inferIntent(finalDeploy, finalPublish) ?? "manual",
268
354
  };
269
355
  }
@@ -52,7 +52,7 @@ const HEADER = `# ==============================================================
52
52
  export function parseTemplateOptions(content) {
53
53
  const out = { deploy: null, publish: null, secretBackup: null,
54
54
  changelogProvider: null, changelogBaseUrl: null, codeReviewCoderabbit: null,
55
- deployBranch: null };
55
+ deployBranch: null, intent: null };
56
56
  // deploy_branch는 metadata 직속(#456) — template.options 밖이라 별도로 스캔한다.
57
57
  for (const line of String(content || "").split("\n")) {
58
58
  if (line.startsWith("#")) continue;
@@ -85,6 +85,14 @@ export function parseTemplateOptions(content) {
85
85
  const bm = line.match(/^\s+base_url:\s*(.*)/);
86
86
  if (bm) { out.changelogBaseUrl = strip(bm[1]); continue; }
87
87
  }
88
+ // intent(프로젝트 성격, #485) — deploy 라인보다 먼저 매칭 (deploy: 와 intent: 구분).
89
+ // 값 뒤 인라인 주석(# ...)이 붙으므로 따옴표 안 or 첫 토큰만 취한다.
90
+ let im = line.match(/^\s+intent:\s*["']?([a-z]+)["']?/);
91
+ if (im) {
92
+ const v = im[1];
93
+ if (["app", "library", "both", "none", "manual"].includes(v)) out.intent = v;
94
+ continue;
95
+ }
88
96
  let m = line.match(/^\s+deploy:\s*(.+)/);
89
97
  if (m) {
90
98
  const v = strip(m[1]);
@@ -144,6 +152,22 @@ export function parseTemplateOptions(content) {
144
152
  return out;
145
153
  }
146
154
 
155
+ // deploy/publish 저장값에서 intent(프로젝트 성격) 역추론 (#485 — intent 키 없는 구 version.yml 하위호환).
156
+ // deploy≠none & publish=[] → app (서버/앱만)
157
+ // deploy=none & publish≠[] → library (라이브러리만)
158
+ // deploy≠none & publish≠[] → both
159
+ // deploy=none & publish=[] → none
160
+ // deploy/publish가 아직 null(미설정)이면 intent도 추론 불가 → null 반환(진입 질문을 하도록).
161
+ export function inferIntent(deploy, publish) {
162
+ if (deploy == null && (publish == null)) return null;
163
+ const hasServer = deploy != null && deploy !== "none";
164
+ const hasLib = Array.isArray(publish) && publish.length > 0;
165
+ if (hasServer && hasLib) return "both";
166
+ if (hasServer) return "app";
167
+ if (hasLib) return "library";
168
+ return "none";
169
+ }
170
+
147
171
  // v4.1.0 이전 단수 project_type 키 → project_types 배열 최소 변환 (#471).
148
172
  // version.yml을 재생성하지 않는 경로(workflows 모드)에서 신형 version_manager(단수 키 명시 거부)와의
149
173
  // 정합을 맞춘다 — 해당 라인만 교체하고 나머지 내용은 손대지 않는다.
@@ -267,14 +291,17 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
267
291
  // template 옵션 블록 (.sh save_template_options 신규 추가 케이스). templateOptions 지정 시.
268
292
  if (templateOptions) {
269
293
  const { templateVersion = "unknown", deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, optionsDate = today,
270
- changelogProvider = "github-ai", changelogBaseUrl = "", codeReviewCoderabbit = true } = templateOptions;
294
+ changelogProvider = "github-ai", changelogBaseUrl = "", codeReviewCoderabbit = true, intent = null } = templateOptions;
271
295
  const publishJson = `[${publishTargets.map((t) => `"${t}"`).join(",")}]`;
296
+ // intent(프로젝트 성격, #485) — 미지정이면 deploy/publish에서 역추론해 기록 (재통합 시 진입 질문 생략용)
297
+ const intentVal = intent || inferIntent(deployTarget, publishTargets) || "manual";
272
298
  out += ` template:\n`;
273
299
  out += ` source: "projectops"\n`;
274
300
  out += ` version: "${templateVersion}"\n`;
275
301
  out += ` integrated_date: "${optionsDate}"\n`;
276
302
  out += ` last_update_date: "${optionsDate}"\n`;
277
303
  out += ` options:\n`;
304
+ out += ` intent: "${intentVal}" # 프로젝트 성격(app/library/both/none/manual) — 배포 질문 유도 기준\n`;
278
305
  out += ` deploy: "${deployTarget}"\n`;
279
306
  out += ` publish: ${publishJson}\n`;
280
307
  out += ` secret_backup: ${includeSecretBackup}\n`;
package/src/index.js CHANGED
@@ -103,15 +103,26 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
103
103
  const { now, today } = clock || utcNow();
104
104
  const tempDir = join(cwd, PATHS.tempDir);
105
105
 
106
+ // 프로젝트 성격(#485): CLI --intent → version.yml 저장값. deploy/publish 유도의 기준.
107
+ const intent = opts.intent ?? existing?.options?.intent ?? null;
108
+ // 배포/publish 축(#439): CLI 플래그 최우선 → 저장값 → 기본값. 단 --intent가 명시됐고 해당 축 플래그가
109
+ // 없으면 intent가 유도한다 (#485 비대화형): library/none이면 deploy=none, app/none이면 publish=[].
110
+ let deployTarget = opts.deployTarget ?? existing?.options?.deploy ?? "docker-ssh";
111
+ let publishTargets = opts.publishTargets ?? existing?.options?.publish ?? [];
112
+ if (opts.intent != null) {
113
+ if ((intent === "library" || intent === "none") && opts.deployTarget == null) deployTarget = "none";
114
+ if ((intent === "app" || intent === "none") && opts.publishTargets == null) publishTargets = [];
115
+ }
116
+
106
117
  const context = createContext({
107
118
  mode: opts.mode, force: true, types, version, versionCode, branch,
108
119
  paths,
109
- // 배포/publish 축(#439): CLI 플래그 최우선 → version.yml 저장 옵션(구 키 자동 마이그레이션) → 기본값
110
- deployTarget: opts.deployTarget ?? existing?.options?.deploy ?? "docker-ssh",
111
- publishTargets: opts.publishTargets ?? existing?.options?.publish ?? [],
120
+ deployTarget,
121
+ publishTargets,
112
122
  includeSecretBackup: opts.includeSecretBackup ?? existing?.options?.secretBackup ?? false,
113
123
  // 릴리스 배포 브랜치(#456): CLI 플래그 → version.yml 저장값 → 빈 값(미출력, 스킬이 develop 폴백)
114
124
  deployBranch: opts.deployBranch || existing?.options?.deployBranch || "",
125
+ intent,
115
126
  // changelog/code_review 축(#455): 비대화형은 저장값 → 기본값. null이 흘러 provider:"null"로 기록되던 버그 수정.
116
127
  changelogProvider: existing?.options?.changelogProvider ?? "github-ai",
117
128
  changelogBaseUrl: existing?.options?.changelogBaseUrl ?? "",
package/src/ui/prompts.js CHANGED
@@ -39,7 +39,14 @@ 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
+ // #485 프로젝트 성격(intent): 재선택 시 배포/publish 축을 재유도한다.
43
+ options.push({ value: "intent", label: "프로젝트 성격 (배포 유형)" });
44
+ // #483 — 항목별 격리: 한 축만 골라 그 축만 재질문한다 (통짜 "배포/Publish 방식" 분해)
45
+ options.push({ value: "deploy", label: "배포 방식 (서버 실행물)" });
46
+ options.push({ value: "publish", label: "라이브러리 배포(publish) 타겟" });
47
+ options.push({ value: "code-review", label: "CodeRabbit 코드 리뷰" });
48
+ options.push({ value: "changelog", label: "릴리스 노트(changelog) 생성기" });
49
+ options.push({ value: "release-branch", label: "릴리스 소스(개발) 브랜치" });
43
50
  options.push({ value: "secret", label: "Secret 백업 포함 여부" });
44
51
  }
45
52
  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("");