projectops 4.2.18 → 4.2.20

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.17 (2026-07-14)
10
+ ## 최신 버전 : v4.2.19 (2026-07-14)
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.18",
3
+ "version": "4.2.20",
4
4
  "description": "ProjectOps — 완전 자동화 GitHub 프로젝트 관리 템플릿 통합 CLI",
5
5
  "keywords": [
6
6
  "devops",
@@ -13,7 +13,7 @@ import { runBreakingCheck } from "../core/breaking-check.js";
13
13
  import { runMigrations } from "../core/migrations/index.js";
14
14
  import { detectOrphanWorkflows, applyOrphanCleanup } from "../core/orphan-workflows.js";
15
15
  import { resolveProjectPaths, filterExcludedTypes } from "../core/paths-resolve.js";
16
- import { askAllOptionalWorkflows, OPTION_AXES } from "../core/options-ask.js";
16
+ import { askAllOptionalWorkflows, OPTION_AXES, applicableTargets } from "../core/options-ask.js";
17
17
  import { createRunTrace } from "../core/run-trace.js";
18
18
  import { appendGuideEntry } from "../core/migration-guide.js";
19
19
  import { promptEnvPlan } from "../ui/env-plan.js";
@@ -154,7 +154,8 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
154
154
  // edit 루프
155
155
  let editing = true;
156
156
  while (editing) {
157
- const what = await io.editMenu({ showOptional });
157
+ // #498 타입에 적용 불가한 배포 축 항목은 메뉴에서 숨김 (타입을 바꾸면 다음 진입부터 다시 노출)
158
+ const what = await io.editMenu({ showOptional, axes: applicableTargets(types) });
158
159
  if (isCancel(what) || what === "done") { editing = false; break; }
159
160
  if (what === "type") {
160
161
  const t = await io.selectTypes(types);
@@ -56,6 +56,42 @@ export { parseTemplateOptions };
56
56
 
57
57
  export const DEPLOY_TARGETS = ["docker-ssh", "vercel", "none"];
58
58
  export const PUBLISH_TARGETS = ["nexus", "npm", "github-packages"];
59
+
60
+ // #498 — 타입별 적용 가능 deploy/publish 타겟 선언.
61
+ // 빈 배열 = 그 축이 개념상 성립하지 않는 타입. 모바일 앱(flutter/react-native/expo)은 스토어 배포
62
+ // (Play Store/TestFlight/Firebase 등) 워크플로우가 타입 자체에 항상 포함되므로 서버 deploy 축과
63
+ // 무관하고, 레지스트리 publish 개념도 없다. 두 축이 모두 빈 타입만 선택되면 질문 자체를 스킵한다.
64
+ // 서버형 타입은 현행 선택지 전체를 유지한다 (동작 무변경 — 타입별 세분화는 후속 과제).
65
+ export const TYPE_DEPLOY_TARGETS = {
66
+ spring: ["docker-ssh", "vercel"],
67
+ react: ["docker-ssh", "vercel"],
68
+ node: ["docker-ssh", "vercel"],
69
+ python: ["docker-ssh", "vercel"],
70
+ flutter: [],
71
+ "react-native": [],
72
+ "react-native-expo": [],
73
+ basic: [],
74
+ };
75
+ export const TYPE_PUBLISH_TARGETS = {
76
+ spring: ["nexus", "npm", "github-packages"],
77
+ react: ["nexus", "npm", "github-packages"],
78
+ node: ["nexus", "npm", "github-packages"],
79
+ python: ["nexus", "npm", "github-packages"],
80
+ flutter: [],
81
+ "react-native": [],
82
+ "react-native-expo": [],
83
+ basic: [],
84
+ };
85
+
86
+ // 선택된 타입 집합의 합집합으로 적용 가능 타겟 산출 (#498). 'none'은 항상 허용이라 목록에서 제외.
87
+ // 미선언 타입은 보수적으로 전체 허용(질문 유지) — 새 타입 추가 시 위 선언도 함께 넣는 게 원칙.
88
+ export function applicableTargets(types = []) {
89
+ const deployAll = DEPLOY_TARGETS.filter((t) => t !== "none");
90
+ return {
91
+ deploy: deployAll.filter((t) => types.some((ty) => (TYPE_DEPLOY_TARGETS[ty] ?? deployAll).includes(t))),
92
+ publish: PUBLISH_TARGETS.filter((t) => types.some((ty) => (TYPE_PUBLISH_TARGETS[ty] ?? PUBLISH_TARGETS).includes(t))),
93
+ };
94
+ }
59
95
  // #455 — changelog 생성기 provider. github-ai가 기본(설정 제로). openai/gemini/claude/ollama는 openai 호환 한 갈래.
60
96
  export const CHANGELOG_PROVIDERS = ["github-ai", "coderabbit", "openai", "gemini", "claude", "ollama", "commit"];
61
97
 
@@ -124,10 +160,12 @@ export async function askAllOptionalWorkflows({
124
160
  let deployBranchCreated = null; // #493 — 이번 실행에서 마법사가 직접 생성했는지 (가이드 기록용)
125
161
  let intent = current.intent ?? null; // #485 프로젝트 성격 (app/library/both/none/manual)
126
162
 
127
- // basic 단독 타입은 서버 배포도 라이브러리 publish도 개념상 성립하지 않는다.
128
- // 배포/publish 질문을 건너뛰고 none·[]로 조용히 확정한다 (타입 변경 시 재질문됨).
129
- // (basic은 "그 외" 폴백이라 항상 단독으로만 존재every로 안전 판정)
130
- const isBasicOnly = types.length > 0 && types.every((t) => t === "basic");
163
+ // 두 축이 모두 빈 타입 조합(#498 — basic 단독, flutter 모바일 단독)은 서버 배포도
164
+ // 라이브러리 publish 개념상 성립하지 않는다. 질문을 전부 건너뛰고 none·[]로 조용히 확정한다
165
+ // (모바일 스토어 배포는 타입 워크플로우가 항상 포함되므로 안내조차 불필요 타입 변경 시 재질문됨).
166
+ // isBasicOnly 특례를 일반화한 판정이다.
167
+ const applicable = applicableTargets(types);
168
+ const noAxes = types.length > 0 && applicable.deploy.length === 0 && applicable.publish.length === 0;
131
169
 
132
170
  // ① --force-ask가 아니면 version.yml 저장값을 먼저 읽어 재질문을 건너뛴다.
133
171
  // CLI 명시값(current)이 이미 있으면 그쪽이 우선 — 저장값은 빈 자리만 채운다.
@@ -158,9 +196,14 @@ export async function askAllOptionalWorkflows({
158
196
  }
159
197
  }
160
198
 
199
+ // 적용 불가 타겟 조용한 정리 (#498) — 구버전에서 저장된 무의미 값(예: flutter 단독 + docker-ssh)은
200
+ // 어차피 복사 결과가 동일하므로 경고 없이 none/교집합으로 정리한다 (SSOT — 무의미 값 잔존 금지).
201
+ if (deploy !== null && deploy !== "none" && !applicable.deploy.includes(deploy)) deploy = "none";
202
+ if (Array.isArray(publish)) publish = publish.filter((t) => applicable.publish.includes(t));
203
+
161
204
  // ── ② intent(프로젝트 성격) 우선 분기 → 배포 축 유도 (#485) ──
162
- // basic 단독이면 intent 개념 자체가 없어 질문 스킵, none/[]로 확정.
163
- if (isBasicOnly) {
205
+ // 모두 빈 타입 조합이면 intent 개념 자체가 없어 질문 스킵, none/[]로 확정 (#498).
206
+ if (noAxes) {
164
207
  if (deploy === null) deploy = "none";
165
208
  if (publish === null) publish = [];
166
209
  intent = "none";
@@ -216,22 +259,28 @@ export async function askAllOptionalWorkflows({
216
259
  say(" 1) 실행물 배포 — 서버/호스팅에 올려 돌리는 것 (Docker, Vercel …)");
217
260
  say(" 2) 라이브러리 배포(publish) — 남이 가져다 쓰게 레지스트리에 내는 것 (Nexus, npm …)");
218
261
  }
262
+ // 비대화형/폴백 기본값 (#498) — docker-ssh가 적용 가능할 때만 기본, 아니면 none.
263
+ const deployDefault = applicable.deploy.includes("docker-ssh") ? "docker-ssh" : "none";
219
264
  if (willAskDeploy) {
220
265
  if (force || !tty || typeof io.select !== "function") {
221
- deploy = deploy ?? "docker-ssh";
266
+ deploy = deploy ?? deployDefault;
222
267
  } else {
223
268
  say("");
224
269
  say("🚀 (1) 실행물(서버/앱)을 어디에 올리나요?");
225
270
  say(" 서버·호스팅에 올릴 계획이 있으면 고르고, 없으면 '서버에 올리지 않음'으로 두세요.");
271
+ // 선택지는 적용 가능 타겟으로 필터링 (#498) — 'none'은 항상 노출.
272
+ const deployLabels = [
273
+ { value: "docker-ssh", label: "Docker + SSH 서버 배포 (기본)" },
274
+ { value: "vercel", label: "Vercel" },
275
+ ];
226
276
  const ans = await io.select({
227
277
  message: "실행물 배포 방식을 선택하세요",
228
278
  options: [
229
- { value: "docker-ssh", label: "Docker + SSH 서버 배포 (기본)" },
230
- { value: "vercel", label: "Vercel" },
279
+ ...deployLabels.filter((o) => applicable.deploy.includes(o.value)),
231
280
  { value: "none", label: "서버에 올리지 않음 (빌드 검증만 · 라이브러리는 다음에서 선택)" },
232
281
  ],
233
282
  });
234
- deploy = (!isCancel(ans) && DEPLOY_TARGETS.includes(ans)) ? ans : (deploy ?? "docker-ssh");
283
+ deploy = (!isCancel(ans) && (ans === "none" || applicable.deploy.includes(ans))) ? ans : (deploy ?? deployDefault);
235
284
  say(`실행물 배포: ${deploy}`);
236
285
  }
237
286
  }
@@ -244,18 +293,20 @@ export async function askAllOptionalWorkflows({
244
293
  say("");
245
294
  say("📦 (2) 이 프로젝트를 남이 가져다 쓰는 라이브러리로도 배포(publish)하나요?");
246
295
  say(" (1) 실행물 배포와 별개입니다. 해당되는 걸 고르고, 라이브러리 배포를 안 하면 아무것도 고르지 말고 Enter.");
296
+ // 선택지는 적용 가능 타겟으로 필터링 (#498)
297
+ const publishLabels = [
298
+ { value: "nexus", label: "사내 Maven(Nexus) 라이브러리 배포" },
299
+ { value: "npm", label: "공개 npmjs 패키지 배포 (NPM_TOKEN)" },
300
+ { value: "github-packages", label: "GitHub Packages 라이브러리 배포" },
301
+ ];
247
302
  const ans = await io.multiselect({
248
303
  message: "라이브러리 배포 타겟 (없으면 선택 없이 Enter = 배포 안 함)",
249
- options: [
250
- { value: "nexus", label: "사내 Maven(Nexus) 라이브러리 배포" },
251
- { value: "npm", label: "공개 npmjs 패키지 배포 (NPM_TOKEN)" },
252
- { value: "github-packages", label: "GitHub Packages 라이브러리 배포" },
253
- ],
304
+ options: publishLabels.filter((o) => applicable.publish.includes(o.value)),
254
305
  initialValues: publish ?? [],
255
306
  required: false,
256
307
  });
257
308
  publish = (!isCancel(ans) && Array.isArray(ans))
258
- ? ans.filter((t) => PUBLISH_TARGETS.includes(t))
309
+ ? ans.filter((t) => applicable.publish.includes(t))
259
310
  : (publish ?? []);
260
311
  say(publish.length ? `라이브러리 배포: ${publish.join(", ")}` : "라이브러리 배포: 안 함");
261
312
  }
@@ -351,7 +402,8 @@ export async function askAllOptionalWorkflows({
351
402
  current: secretBackup, force, tty, io, forceAsk: ask("secret"), say,
352
403
  });
353
404
 
354
- const finalDeploy = deploy ?? "docker-ssh";
405
+ // 최종 폴백도 적용 가능 타겟 기준 (#498) — 모바일/basic 조합에서 docker-ssh가 새어 나가지 않게.
406
+ const finalDeploy = deploy ?? (applicable.deploy.includes("docker-ssh") ? "docker-ssh" : "none");
355
407
  const finalPublish = publish ?? [];
356
408
  return {
357
409
  deploy: finalDeploy, publish: finalPublish, secretBackup: secretBackup === true,
package/src/index.js CHANGED
@@ -17,6 +17,7 @@ import { detectOrphanWorkflows } from "./core/orphan-workflows.js";
17
17
  import { createRunTrace } from "./core/run-trace.js";
18
18
  import { appendGuideEntry } from "./core/migration-guide.js";
19
19
  import { resolveProjectPaths } from "./core/paths-resolve.js";
20
+ import { applicableTargets } from "./core/options-ask.js";
20
21
  import { printBannerCompact } from "./ui/banner.js";
21
22
  import { printSummary } from "./ui/summary.js";
22
23
  import { runFull } from "./commands/full.js";
@@ -107,15 +108,23 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
107
108
  const tempDir = join(cwd, PATHS.tempDir);
108
109
 
109
110
  // 프로젝트 성격(#485): CLI --intent → version.yml 저장값. deploy/publish 유도의 기준.
110
- const intent = opts.intent ?? existing?.options?.intent ?? null;
111
- // 배포/publish 축(#439): CLI 플래그 최우선 → 저장값 → 기본값. --intent가 명시됐고 해당 축 플래그가
112
- // 없으면 intent가 유도한다 (#485 비대화형): library/none이면 deploy=none, app/none이면 publish=[].
113
- let deployTarget = opts.deployTarget ?? existing?.options?.deploy ?? "docker-ssh";
111
+ let intent = opts.intent ?? existing?.options?.intent ?? null;
112
+ // 배포/publish 축(#439): CLI 플래그 최우선 → 저장값 → 기본값(적용 가능할 때만 docker-ssh #498).
113
+ // 단 --intent가 명시됐고 해당 축 플래그가 없으면 intent가 유도한다 (#485 비대화형):
114
+ // library/none이면 deploy=none, app/none이면 publish=[].
115
+ const applicable = applicableTargets(types);
116
+ let deployTarget = opts.deployTarget ?? existing?.options?.deploy
117
+ ?? (applicable.deploy.includes("docker-ssh") ? "docker-ssh" : "none");
114
118
  let publishTargets = opts.publishTargets ?? existing?.options?.publish ?? [];
115
119
  if (opts.intent != null) {
116
120
  if ((intent === "library" || intent === "none") && opts.deployTarget == null) deployTarget = "none";
117
121
  if ((intent === "app" || intent === "none") && opts.publishTargets == null) publishTargets = [];
118
122
  }
123
+ // 적용 불가 타겟 조용한 정리 (#498) — 대화형과 동일 규칙. 타입에 성립하지 않는 축 값은
124
+ // 복사 결과가 동일하므로 경고 없이 none/교집합으로 정리한다 (모바일 앱/basic 단독 등).
125
+ if (deployTarget !== "none" && !applicable.deploy.includes(deployTarget)) deployTarget = "none";
126
+ publishTargets = publishTargets.filter((t) => applicable.publish.includes(t));
127
+ if (types.length > 0 && applicable.deploy.length === 0 && applicable.publish.length === 0) intent = "none";
119
128
 
120
129
  const context = createContext({
121
130
  mode: opts.mode, force: true, types, version, versionCode, branch,
package/src/ui/prompts.js CHANGED
@@ -32,18 +32,22 @@ export async function confirmProjectMenu() {
32
32
  }
33
33
 
34
34
  // 수정 메뉴 — 어떤 항목을 고칠지. showOptional=full/workflows에서만 nexus/secret 노출.
35
- export async function editMenu({ showOptional = false } = {}) {
35
+ // axes(#498): applicableTargets(types) 결과 적용 불가 축의 항목은 숨긴다 (모바일 앱/basic 단독 등).
36
+ // null이면 기존처럼 전부 노출 (테스트 스텁 하위호환).
37
+ export async function editMenu({ showOptional = false, axes = null } = {}) {
36
38
  const options = [
37
39
  { value: "type", label: "프로젝트 타입" },
38
40
  { value: "version", label: "버전" },
39
41
  { value: "branch", label: "기본 브랜치" },
40
42
  ];
41
43
  if (showOptional) {
42
- // #485 프로젝트 성격(intent): 재선택 배포/publish 축을 재유도한다.
43
- options.push({ value: "intent", label: "프로젝트 성격 (배포 유형)" });
44
+ const hasDeploy = axes == null || axes.deploy.length > 0;
45
+ const hasPublish = axes == null || axes.publish.length > 0;
46
+ // #485 — 프로젝트 성격(intent): 재선택 시 배포/publish 축을 재유도한다. 축이 하나도 없으면 무의미 → 숨김.
47
+ if (hasDeploy || hasPublish) options.push({ value: "intent", label: "프로젝트 성격 (배포 유형)" });
44
48
  // #483 — 항목별 격리: 한 축만 골라 그 축만 재질문한다 (통짜 "배포/Publish 방식" 분해)
45
- options.push({ value: "deploy", label: "배포 방식 (서버 실행물)" });
46
- options.push({ value: "publish", label: "라이브러리 배포(publish) 타겟" });
49
+ if (hasDeploy) options.push({ value: "deploy", label: "배포 방식 (서버 실행물)" });
50
+ if (hasPublish) options.push({ value: "publish", label: "라이브러리 배포(publish) 타겟" });
47
51
  options.push({ value: "code-review", label: "CodeRabbit 코드 리뷰" });
48
52
  options.push({ value: "changelog", label: "릴리스 노트(changelog) 생성기" });
49
53
  options.push({ value: "release-branch", label: "릴리스 소스(개발) 브랜치" });
package/src/ui/summary.js CHANGED
@@ -128,10 +128,12 @@ export function printSummary(ctx, targetRoot = ".") {
128
128
 
129
129
  err(" 📖 TEMPLATE REPO: https://github.com/Cassiiopeia/projectops");
130
130
  err(" 📚 워크플로우 가이드: .github/workflows/project-types/README.md");
131
- // #493 — 이번 실행의 마이그레이션 기록 (수동 체크리스트·breaking 조치·AI 메타데이터)
131
+ // #493 — 이번 실행의 마이그레이션 기록. "뭐가 남았고 AI에게 어떻게 시키는지"가 바로 보이게 행동 유도형으로 안내.
132
132
  if (ctx?.migrationGuidePath) {
133
133
  err(` 🧭 마이그레이션 가이드: ${ctx.migrationGuidePath}`);
134
- err(" (이번 실행의 변경 내역, 수동 확인 체크리스트, AI Agent용 메타데이터가 기록됐습니다)");
134
+ err(" 이번 설치에서 바뀐 내용과 직접 확인해야 작업이 파일에 정리되어 있습니다.");
135
+ err(" 💡 AI Agent(Claude, Cursor 등)에게 \"마이그레이션 가이드 확인하고");
136
+ err(" 남은 작업 처리해줘\"라고 요청하면 이 문서를 읽고 대신 진행합니다.");
135
137
  }
136
138
  err("");
137
139