projectops 4.1.3 → 4.2.2

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.1.2 (2026-07-09)
10
+ ## 최신 버전 : v4.2.1 (2026-07-09)
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.1.3",
3
+ "version": "4.2.2",
4
4
  "description": "ProjectOps — 완전 자동화 GitHub 프로젝트 관리 템플릿 통합 CLI (구 SUH-DEVOPS-TEMPLATE 마법사)",
5
5
  "keywords": [
6
6
  "devops",
package/src/cli/args.js CHANGED
@@ -1,6 +1,9 @@
1
1
  // CLI 인자 파싱 (.sh top-level while-case 등가) — template_integrator.sh 842~920.
2
2
  import { VALID_TYPES } from "../context.js";
3
3
 
4
+ export const DEPLOY_TARGETS = ["docker-ssh", "vercel", "none"];
5
+ export const PUBLISH_TARGETS = ["nexus", "npm", "github-packages"];
6
+
4
7
  // argv(process.argv.slice(2)) → 파싱 결과. 오류 시 throw(호출부에서 exit 1).
5
8
  export function parseArgs(argv) {
6
9
  const result = {
@@ -8,9 +11,9 @@ export function parseArgs(argv) {
8
11
  version: "", // 통합 대상 프로젝트의 초기 버전 (--project-version)
9
12
  types: [],
10
13
  primaryType: "",
11
- includeNexus: null, // null=미설정
14
+ deployTarget: null, // 배포 축 (#439): docker-ssh|vercel|none, null=미설정
15
+ publishTargets: null, // publish 축 (#439): 타겟 배열, null=미설정
12
16
  includeSecretBackup: null,
13
- includeNpmPublish: null,
14
17
  pathsCsv: "", // "flutter=app,react=client" 원문 (정규화는 resolve 단계)
15
18
  force: false,
16
19
  help: false,
@@ -47,12 +50,46 @@ export function parseArgs(argv) {
47
50
  break;
48
51
  }
49
52
  case "--force": result.force = true; break;
50
- case "--nexus": result.includeNexus = true; break;
51
- case "--no-nexus": result.includeNexus = false; break;
53
+ case "--deploy": {
54
+ const v = args.shift() ?? "";
55
+ if (!DEPLOY_TARGETS.includes(v)) {
56
+ throw new CliError(`--deploy 값은 ${DEPLOY_TARGETS.join(" | ")} 중 하나여야 합니다: '${v}'`);
57
+ }
58
+ result.deployTarget = v;
59
+ break;
60
+ }
61
+ case "--publish": {
62
+ const csv = args.shift() ?? "";
63
+ const targets = [];
64
+ for (let t of csv.split(",")) {
65
+ t = t.replace(/\s/g, "");
66
+ if (t === "") continue;
67
+ if (!PUBLISH_TARGETS.includes(t)) {
68
+ throw new CliError(`--publish 값은 ${PUBLISH_TARGETS.join(" | ")} csv여야 합니다: '${t}'`);
69
+ }
70
+ if (!targets.includes(t)) targets.push(t);
71
+ }
72
+ result.publishTargets = targets;
73
+ break;
74
+ }
75
+ // ── deprecated alias (1 minor 유지 — #439) ──
76
+ case "--nexus":
77
+ process.stderr.write("⚠️ --nexus는 deprecated입니다. --publish nexus --deploy none 을 사용하세요.\n");
78
+ result.publishTargets = [...new Set([...(result.publishTargets ?? []), "nexus"])];
79
+ if (result.deployTarget === null) result.deployTarget = "none";
80
+ break;
81
+ case "--no-nexus":
82
+ result.publishTargets = (result.publishTargets ?? []).filter((t) => t !== "nexus");
83
+ break;
52
84
  case "--secret-backup": result.includeSecretBackup = true; break;
53
85
  case "--no-secret-backup": result.includeSecretBackup = false; break;
54
- case "--npm-publish": result.includeNpmPublish = true; break;
55
- case "--no-npm-publish": result.includeNpmPublish = false; break;
86
+ case "--npm-publish":
87
+ process.stderr.write("⚠️ --npm-publish deprecated입니다. --publish npm 을 사용하세요.\n");
88
+ result.publishTargets = [...new Set([...(result.publishTargets ?? []), "npm"])];
89
+ break;
90
+ case "--no-npm-publish":
91
+ result.publishTargets = (result.publishTargets ?? []).filter((t) => t !== "npm");
92
+ break;
56
93
  case "--paths": result.pathsCsv = args.shift() ?? ""; break;
57
94
  case "-h": case "--help": result.help = true; break;
58
95
  default:
package/src/cli/help.js CHANGED
@@ -13,9 +13,10 @@ 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
- --nexus / --no-nexus Nexus 라이브러리 publish 워크플로우 포함/제외
16
+ --deploy TARGET 배포 방식 택1: docker-ssh(기본) | vercel | none
17
+ --publish CSV publish 타겟 csv: nexus,npm,github-packages (기본: 없음)
17
18
  --secret-backup / --no-secret-backup Secret 백업 워크플로우 포함/제외
18
- --npm-publish / --no-npm-publish npm 패키지 publish 워크플로우 포함/제외
19
+ --nexus / --npm-publish (deprecated --publish nexus / --publish npm 사용)
19
20
  --force 모든 확인 생략, 비대화형 기본값 사용
20
21
  -v, --version projectops 버전 출력
21
22
  -h, --help 이 도움말 표시
@@ -16,13 +16,13 @@ import { copyUtilModules } from "../core/copy/util.js";
16
16
  import { copyCoderabbit } from "../core/copy/coderabbit.js";
17
17
  import { ensureGitignore } from "../core/copy/gitignore.js";
18
18
 
19
- // context: { version, types, paths:Map, branch, versionCode, includeNexus, includeSecretBackup,
19
+ // context: { version, types, paths:Map, branch, versionCode, deployTarget, publishTargets, includeSecretBackup,
20
20
  // force, repoName, resolvers, now, today }
21
21
  // tempDir: 획득된 템플릿. targetRoot: 통합 대상.
22
22
  export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
23
23
  const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
24
24
  force = true, now, today, templateVersion = "unknown",
25
- includeNexus = false, includeSecretBackup = false, includeNpmPublish = false } = context;
25
+ deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false } = context;
26
26
 
27
27
  // project_paths 마커 계산 (.sh existing_marker_in_dir 등가 — 대표 마커명)
28
28
  const pathMarkers = new Map();
@@ -38,7 +38,7 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
38
38
  buildVersionYml({
39
39
  version, types, paths, pathMarkers, branch, versionCode, now, today,
40
40
  deployValues,
41
- templateOptions: { templateVersion, includeNexus, includeSecretBackup, includeNpmPublish, optionsDate: today },
41
+ templateOptions: { templateVersion, deployTarget, publishTargets, includeSecretBackup, optionsDate: today },
42
42
  }));
43
43
 
44
44
  // 2. README 버전 섹션
@@ -79,27 +79,31 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
79
79
  let branch = detectDefaultBranch(cwd);
80
80
  const repoName = detectRepoName(cwd);
81
81
  const versionCode = existing?.versionCode ?? 1; // 기존 빌드번호 보존
82
- // 선택 워크플로우 초기값: version.yml 저장 옵션 (.sh read_template_options L2361 등가)
83
- let includeNexus = existing?.options?.nexus ?? false;
82
+ // 배포/publish 초기값(#439): version.yml 저장 옵션 ( 자동 마이그레이션 포함)
83
+ let deployTarget = existing?.options?.deploy ?? "docker-ssh";
84
+ let publishTargets = existing?.options?.publish ?? [];
84
85
  let includeSecretBackup = existing?.options?.secretBackup ?? false;
85
- let includeNpmPublish = existing?.options?.npmPublish ?? false;
86
86
  const showOptional = mode === "full" || mode === "workflows";
87
87
  const realTty = process.stdout.isTTY === true;
88
88
 
89
89
  // 층2 — 감지 로그 (#446)
90
90
  io.detectionLog?.({ types, version, branch });
91
91
 
92
- // 선택 워크플로우(Nexus/Secret) 질문 (.sh ask_all_optional_workflows L2707 — full/workflows만)
92
+ // 배포/publish 축 + Secret 백업 질문 (#439 — full/workflows만)
93
93
  if (showOptional) {
94
94
  const r = await askAllOptionalWorkflows({
95
95
  tempDir, types, targetRoot: cwd,
96
- current: { nexus: existing?.options?.nexus ?? null, secretBackup: existing?.options?.secretBackup ?? null, npmPublish: existing?.options?.npmPublish ?? null },
96
+ current: { deploy: existing?.options?.deploy ?? null, publish: existing?.options?.publish ?? null, secretBackup: existing?.options?.secretBackup ?? null },
97
97
  force: false, tty: realTty,
98
- io: { confirm: ({ message, initialValue }) => io.askYesNo(message, initialValue) },
98
+ io: {
99
+ confirm: ({ message, initialValue }) => io.askYesNo(message, initialValue),
100
+ select: io.engineIo?.select,
101
+ multiselect: io.engineIo?.multiselect,
102
+ },
99
103
  });
100
- includeNexus = r.nexus;
104
+ deployTarget = r.deploy;
105
+ publishTargets = r.publish;
101
106
  includeSecretBackup = r.secretBackup;
102
- includeNpmPublish = r.npmPublish;
103
107
  }
104
108
 
105
109
  // 확인/수정 루프 — ESC는 '머무르기' (.sh L1877~1881: 명시적 '아니오'만 종료)
@@ -108,9 +112,9 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
108
112
  while (!confirmed) {
109
113
  // 층3 — 프로젝트 분석 개요 카드 (#446). 스텁엔 없음 → note 폴백.
110
114
  if (io.analysisCard) {
111
- io.analysisCard({ mode, modeLabel: modeLabel(mode), types, version, branch, includeNexus, includeSecretBackup, includeNpmPublish, showOptional, paths });
115
+ io.analysisCard({ mode, modeLabel: modeLabel(mode), types, version, branch, deployTarget, publishTargets, includeSecretBackup, showOptional, paths });
112
116
  } else {
113
- io.note?.(summarize({ mode, types, version, branch, includeNexus, includeSecretBackup, includeNpmPublish, showOptional }), "프로젝트 분석 결과");
117
+ io.note?.(summarize({ mode, types, version, branch, deployTarget, publishTargets, includeSecretBackup, showOptional }), "프로젝트 분석 결과");
114
118
  }
115
119
  const choice = await io.confirmProjectMenu();
116
120
  if (choice === "cancel") { io.cancelMessage?.("설치를 취소했습니다."); return 0; }
@@ -139,15 +143,24 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
139
143
  } else if (what === "branch") {
140
144
  const b = await io.askText("기본 브랜치", branch);
141
145
  if (!isCancel(b) && b) branch = b;
142
- } else if (what === "nexus") {
143
- const y = await io.askYesNo("Nexus publish 워크플로우를 포함할까요?", includeNexus);
144
- if (!isCancel(y)) includeNexus = y === true;
146
+ } else if (what === "deploy-publish") {
147
+ // 배포/publish 재질문 (#439 forceAsk)
148
+ const r = await askAllOptionalWorkflows({
149
+ tempDir, types, targetRoot: cwd,
150
+ current: { deploy: deployTarget, publish: publishTargets, secretBackup: includeSecretBackup },
151
+ force: false, tty: realTty, forceAsk: true,
152
+ io: {
153
+ confirm: ({ message, initialValue }) => io.askYesNo(message, initialValue),
154
+ select: io.engineIo?.select,
155
+ multiselect: io.engineIo?.multiselect,
156
+ },
157
+ });
158
+ deployTarget = r.deploy;
159
+ publishTargets = r.publish;
160
+ includeSecretBackup = r.secretBackup;
145
161
  } else if (what === "secret") {
146
162
  const y = await io.askYesNo("Secret 백업 워크플로우를 포함할까요?", includeSecretBackup);
147
163
  if (!isCancel(y)) includeSecretBackup = y === true;
148
- } else if (what === "npm-publish") {
149
- const y = await io.askYesNo("npm publish 워크플로우를 포함할까요?", includeNpmPublish);
150
- if (!isCancel(y)) includeNpmPublish = y === true;
151
164
  }
152
165
  }
153
166
  }
@@ -168,7 +181,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
168
181
  if (showOptional) {
169
182
  const plan = await promptEnvPlan({
170
183
  tempDir, types, io: io.engineIo ?? null, force: false,
171
- resolvers, includeNexus, targetRoot: cwd, repoName,
184
+ resolvers, deployTarget, publishTargets, targetRoot: cwd, repoName,
172
185
  });
173
186
  envValues = plan.values;
174
187
  envUseDefaults = plan.useDefaults;
@@ -176,7 +189,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
176
189
 
177
190
  const { now, today } = clock || utcNow();
178
191
  const ctx = createContext({
179
- mode, force: true, types, version, versionCode, branch, paths, includeNexus, includeSecretBackup, includeNpmPublish,
192
+ mode, force: true, types, version, versionCode, branch, paths, deployTarget, publishTargets, includeSecretBackup,
180
193
  repoName, templateVersion, resolvers, envValues, envUseDefaults, now, today,
181
194
  });
182
195
  ctx.templateVersion = templateVersion;
@@ -227,7 +240,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
227
240
  }
228
241
  }
229
242
 
230
- function summarize({ mode, types, version, branch, includeNexus, includeSecretBackup, includeNpmPublish, showOptional }) {
243
+ function summarize({ mode, types, version, branch, deployTarget, publishTargets, includeSecretBackup, showOptional }) {
231
244
  const lines = [
232
245
  `통합 모드 : ${modeLabel(mode)}`,
233
246
  `프로젝트 타입 : ${types.join(", ")}${types.length > 1 ? " (멀티)" : ""}`,
@@ -235,9 +248,9 @@ function summarize({ mode, types, version, branch, includeNexus, includeSecretBa
235
248
  `기본 브랜치 : ${branch}`,
236
249
  ];
237
250
  if (showOptional) {
238
- lines.push(`Nexus publish : ${includeNexus ? "포함" : "제외"}`);
251
+ lines.push(`배포 방식 : ${deployTarget || "docker-ssh"}`);
252
+ lines.push(`Publish : ${(publishTargets ?? []).join(",") || "없음"}`);
239
253
  lines.push(`Secret 백업 : ${includeSecretBackup ? "포함" : "제외"}`);
240
- lines.push(`npm publish : ${includeNpmPublish ? "포함" : "제외"}`);
241
254
  }
242
255
  return lines.join("\n");
243
256
  }
@@ -12,7 +12,7 @@ import { ensureGitignore } from "../core/copy/gitignore.js";
12
12
 
13
13
  export function runVersion(context, tempDir, targetRoot = ".") {
14
14
  const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
15
- now, today, templateVersion = "unknown", includeNexus = false, includeSecretBackup = false, includeNpmPublish = false } = context;
15
+ now, today, templateVersion = "unknown", deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false } = context;
16
16
 
17
17
  const pathMarkers = new Map();
18
18
  for (const [t] of paths) pathMarkers.set(t, markerForType(t));
@@ -20,7 +20,7 @@ export function runVersion(context, tempDir, targetRoot = ".") {
20
20
  writeText(join(targetRoot, PATHS.versionFile),
21
21
  buildVersionYml({
22
22
  version, types, paths, pathMarkers, branch, versionCode, now, today,
23
- templateOptions: { templateVersion, includeNexus, includeSecretBackup, includeNpmPublish, optionsDate: today },
23
+ templateOptions: { templateVersion, deployTarget, publishTargets, includeSecretBackup, optionsDate: today },
24
24
  }));
25
25
  addVersionSectionToReadme(version, targetRoot);
26
26
  copyScripts(tempDir, targetRoot);
package/src/context.js CHANGED
@@ -15,8 +15,9 @@ export function createContext(overrides = {}) {
15
15
  version: "",
16
16
  branch: "",
17
17
  paths: new Map(), // type -> path
18
- includeNexus: null, // null=미설정, true/false=명시
19
- includeNpmPublish: null, // null=미설정, true/false=명시 (node/npm-publish/)
18
+ // 배포/publish 축 (#439 — 타입 비종속. null=미설정)
19
+ deployTarget: null, // 'docker-ssh'(기본) | 'vercel' | 'none'
20
+ publishTargets: null, // ['nexus','npm','github-packages'] 부분집합
20
21
  includeSecretBackup: null,
21
22
  templateVersion: "",
22
23
  tempDir: "",
@@ -36,13 +36,14 @@ function classify(srcDir, workflowsDir, envOpts) {
36
36
  }
37
37
 
38
38
  // copy_workflows 본체 (동기 — 기존 호출부 무변경).
39
- // context: { types:[], paths:Map, includeNexus, includeSecretBackup, force, repoName, resolvers,
39
+ // context: { types:[], paths:Map, deployTarget, publishTargets:[], includeSecretBackup, force, repoName, resolvers,
40
40
  // envValues?:Map<key,value>, envUseDefaults?:boolean } ← env 계획(promptEnvPlan) 결과 주입점
41
+ // deployTarget(#439 택1): 'docker-ssh'(기본) | 'vercel' | 'none' — server-deploy는 docker-ssh일 때만,
42
+ // common/deploy/<target>/은 해당 타겟일 때 복사. publishTargets(#439 다중): 'nexus'|'npm'|'github-packages'.
41
43
  // hooks: { decisions?: Map<filename, 'skip'|'backup'|'template'> } — 기존 파일(changed) 충돌 결정.
42
- // 미지정 파일은 'skip'(현행 force 동작 100% 유지). 대화형 수집은 copyWorkflowsInteractive 참조.
43
44
  // 반환: {copied, skipped, templateAdded, optionalCopied}
44
45
  export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
45
- const { types = [], paths = new Map(), includeNexus = false, includeSecretBackup = false, repoName = "", resolvers = {}, envValues = new Map(), envUseDefaults = true } = context;
46
+ const { types = [], paths = new Map(), deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, repoName = "", resolvers = {}, envValues = new Map(), envUseDefaults = true } = context;
46
47
  const decisions = hooks.decisions instanceof Map ? hooks.decisions : new Map();
47
48
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
48
49
  const projectTypesDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
@@ -72,10 +73,27 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
72
73
  // (2~4) 타입별
73
74
  for (const type of types) {
74
75
  const asks = new Map();
75
- copyWorkflowsForType(type, projectTypesDir, workflowsDir, { includeNexus, ...context, envOptsFor, collectAsks: asks, decisions }, counters);
76
+ copyWorkflowsForType(type, projectTypesDir, workflowsDir, { deployTarget, publishTargets, ...context, envOptsFor, collectAsks: asks, decisions }, counters);
76
77
  if (asks.size) deployValues.set(type, asks);
77
78
  }
78
79
 
80
+ // (4.5) common/deploy/<target> — 타입 비종속 배포 타겟 (vercel 등, #439)
81
+ const commonDeployDir = join(commonDir, "deploy", deployTarget || "docker-ssh");
82
+ if (exists(commonDeployDir)) {
83
+ for (const filename of listYamlFiles(commonDeployDir)) {
84
+ const src = join(commonDeployDir, filename);
85
+ const dst = join(workflowsDir, filename);
86
+ if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOptsFor("common"))) {
87
+ counters.skipped++;
88
+ continue;
89
+ }
90
+ if (existsSync(dst)) renameSync(dst, dst + ".bak");
91
+ copyFileSync(src, dst);
92
+ counters.optionalCopied++;
93
+ counters.copied++;
94
+ }
95
+ }
96
+
79
97
  // (5) common/secret-backup — 있으면 무조건 스킵/신규만 복사
80
98
  const secretDir = join(commonDir, "secret-backup");
81
99
  if (exists(secretDir) && includeSecretBackup) {
@@ -116,7 +134,7 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters) {
116
134
  // 대상 워크플로우 디렉토리에서 changed(충돌) 파일 목록만 뽑는다 — copyWorkflowsInteractive의 사전 조사용.
117
135
  // copyWorkflows 본체와 동일한 classify 기준을 써야 결정 Map이 실제 처리 대상과 1:1로 맞는다.
118
136
  export function listWorkflowConflicts(context, tempDir, targetRoot = ".") {
119
- const { types = [], paths = new Map(), includeNexus = false, repoName = "", resolvers = {} } = context;
137
+ const { types = [], paths = new Map(), deployTarget = "docker-ssh", repoName = "", resolvers = {} } = context;
120
138
  const workflowsDir = join(targetRoot, PATHS.workflowsDir);
121
139
  const projectTypesDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
122
140
  const conflicts = []; // [{ filename, type }] — 엔진 처리 순서와 동일 (타입 순회 → 직하위 → server-deploy)
@@ -127,7 +145,7 @@ export function listWorkflowConflicts(context, tempDir, targetRoot = ".") {
127
145
  for (const f of classify(typeDir, workflowsDir, envOpts).changed) conflicts.push({ filename: f, type });
128
146
  }
129
147
  const serverDeployDir = join(typeDir, "server-deploy");
130
- if (exists(serverDeployDir) && !includeNexus) {
148
+ if (exists(serverDeployDir) && (deployTarget || "docker-ssh") === "docker-ssh") {
131
149
  for (const f of classify(serverDeployDir, workflowsDir, envOpts).changed) conflicts.push({ filename: f, type });
132
150
  }
133
151
  }
@@ -149,8 +167,10 @@ export async function copyWorkflowsInteractive(context, tempDir, targetRoot = ".
149
167
  return copyWorkflows(context, tempDir, targetRoot, { decisions });
150
168
  }
151
169
 
170
+ const PUBLISH_TARGETS = ["nexus", "npm", "github-packages"];
171
+
152
172
  function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters) {
153
- const { includeNexus, includeNpmPublish = false, force = false, paths = new Map(), repoName = "", resolvers = {}, envOptsFor, collectAsks = null, decisions = new Map() } = ctx;
173
+ const { deployTarget = "docker-ssh", publishTargets = [], force = false, paths = new Map(), repoName = "", resolvers = {}, envOptsFor, collectAsks = null, decisions = new Map() } = ctx;
154
174
  const typeDir = join(projectTypesDir, type);
155
175
  const envOpts = envOptsFor(type);
156
176
  let unchangedNames = [];
@@ -165,41 +185,23 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
165
185
  for (const f of changed) applyDecision(decisions.get(f), typeDir, workflowsDir, f, counters);
166
186
  }
167
187
 
168
- // server-deploy
188
+ // server-deploy — deploy=docker-ssh일 때만 포함 (#439)
169
189
  const serverDeployDir = join(typeDir, "server-deploy");
170
- if (exists(serverDeployDir)) {
171
- if (includeNexus) {
172
- // Nexus 프로젝트 폴더째 제외 (복사 안 함)
173
- } else {
174
- const { newFiles, unchanged, changed } = classify(serverDeployDir, workflowsDir, envOpts);
175
- for (const f of unchanged) counters.skipped++;
176
- for (const f of newFiles) { copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; }
177
- for (const f of changed) applyDecision(decisions.get(f), serverDeployDir, workflowsDir, f, counters);
178
- }
179
- }
180
-
181
- // nexus (opt-in)
182
- const nexusDir = join(typeDir, "nexus");
183
- if (exists(nexusDir) && includeNexus) {
184
- for (const filename of listYamlFiles(nexusDir)) {
185
- const src = join(nexusDir, filename);
186
- const dst = join(workflowsDir, filename);
187
- if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOpts)) {
188
- counters.skipped++;
189
- continue;
190
- }
191
- if (existsSync(dst)) renameSync(dst, dst + ".bak");
192
- copyFileSync(src, dst);
193
- counters.optionalCopied++;
194
- counters.copied++;
195
- }
190
+ if (exists(serverDeployDir) && (deployTarget || "docker-ssh") === "docker-ssh") {
191
+ const { newFiles, unchanged, changed } = classify(serverDeployDir, workflowsDir, envOpts);
192
+ for (const f of unchanged) counters.skipped++;
193
+ for (const f of newFiles) { copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; }
194
+ for (const f of changed) applyDecision(decisions.get(f), serverDeployDir, workflowsDir, f, counters);
196
195
  }
197
196
 
198
- // npm-publish (opt-in — 현재 node/npm-publish/만 존재)
199
- const npmPublishDir = join(typeDir, "npm-publish");
200
- if (exists(npmPublishDir) && includeNpmPublish) {
201
- for (const filename of listYamlFiles(npmPublishDir)) {
202
- const src = join(npmPublishDir, filename);
197
+ // publish/<target> (opt-in — #439 publish 축. 타입은 파일 위치일 뿐 게이트가 아니다)
198
+ const pubDirs = [];
199
+ for (const target of PUBLISH_TARGETS) {
200
+ const pubDir = join(typeDir, "publish", target);
201
+ pubDirs.push(pubDir);
202
+ if (!exists(pubDir) || !publishTargets.includes(target)) continue;
203
+ for (const filename of listYamlFiles(pubDir)) {
204
+ const src = join(pubDir, filename);
203
205
  const dst = join(workflowsDir, filename);
204
206
  if (existsSync(dst) && isUnchanged(readFileSync(src, "utf8"), readFileSync(dst, "utf8"), envOpts)) {
205
207
  counters.skipped++;
@@ -213,7 +215,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
213
215
  }
214
216
 
215
217
  // env 치환 — 이 타입의 원본 디렉토리들에서 복사돼 존재하고 unchanged 아닌 파일만
216
- for (const srcDir of [typeDir, serverDeployDir, nexusDir, npmPublishDir]) {
218
+ for (const srcDir of [typeDir, serverDeployDir, ...pubDirs]) {
217
219
  if (!exists(srcDir)) continue;
218
220
  for (const filename of listYamlFiles(srcDir)) {
219
221
  const target = join(workflowsDir, filename);
@@ -1,9 +1,11 @@
1
- // 선택(opt-in) 워크플로우 포함 여부 질문 (.sh ask_optional_workflow L2651~2702 /
2
- // ask_all_optional_workflows L2708~2732 등가). Nexus publish + Secret 서버 백업.
1
+ // 배포/publish 축(#439) + Secret 백업 opt-in 질문 (.sh ask_deploy_publish /
2
+ // ask_optional_workflow / ask_all_optional_workflows 등가).
3
3
  //
4
4
  // io 주입 계약(readline-engine 시그니처):
5
- // io.confirm({message, initialValue}) → bool | CANCEL(symbol)
6
- // io.log(line) 안내 출력 (없으면 stderr)
5
+ // io.confirm({message, initialValue}) → bool | CANCEL(symbol)
6
+ // io.select({message, options}) value | CANCEL (deploy 택1)
7
+ // io.multiselect({message, options, initialValues}) → value[] | CANCEL (publish 다중)
8
+ // io.log(line) → 안내 출력 (없으면 stderr)
7
9
  import { existsSync, readFileSync } from "node:fs";
8
10
  import { join } from "node:path";
9
11
  import { listYamlFiles } from "./fsutil.js";
@@ -13,20 +15,23 @@ import { parseTemplateOptions } from "./version-yml.js";
13
15
  // 재노출 — 파서 본체는 version-yml.js에 있다 (순환 import 방지: options-ask → version-yml 방향만 허용)
14
16
  export { parseTemplateOptions };
15
17
 
18
+ export const DEPLOY_TARGETS = ["docker-ssh", "vercel", "none"];
19
+ export const PUBLISH_TARGETS = ["nexus", "npm", "github-packages"];
20
+
16
21
  const isCancel = (v) => typeof v === "symbol";
17
22
 
18
- // 옵션 1종 질문 (.sh ask_optional_workflow 등가).
23
+ // Secret 백업 등 폴더 기반 opt-in 1종 질문 (.sh ask_optional_workflow 등가).
19
24
  // 반환: true/false/null(폴더 없음·파일 0개로 질문 자체 생략 → 현재값 유지).
20
25
  async function askOptionalWorkflow({ dir, icon, short, desc, current, force, tty, io, forceAsk, say }) {
21
- // 폴더가 없거나 yaml이 0개면 조용히 건너뜀 (.sh L2664~2669) — 질문 자체가 성립 안 함
26
+ // 폴더가 없거나 yaml이 0개면 조용히 건너뜀 — 질문 자체가 성립 안 함
22
27
  if (!existsSync(dir)) return current;
23
28
  const files = listYamlFiles(dir);
24
29
  if (files.length === 0) return current;
25
30
 
26
- // 이미 값이 설정돼 있고 force-ask 아니면 유지 (CLI/version.yml 우선, .sh L2672~2674)
31
+ // 이미 값이 설정돼 있고 force-ask 아니면 유지 (CLI/version.yml 우선)
27
32
  if (!forceAsk && (current === true || current === false)) return current;
28
33
 
29
- // 비대화형(--force 또는 TTY 없음)이면 기본 제외 (.sh L2677~2679)
34
+ // 비대화형(--force 또는 TTY 없음)이면 기본 제외
30
35
  if (force || !tty) return false;
31
36
 
32
37
  say("");
@@ -38,7 +43,6 @@ async function askOptionalWorkflow({ dir, icon, short, desc, current, force, tty
38
43
  say("");
39
44
 
40
45
  const ans = await io.confirm({ message: `${short} 워크플로우를 포함할까요?`, initialValue: false });
41
- // ESC(취소)는 '아니오'와 동일 취급 (.sh ask_yes_no 비-0 반환 등가)
42
46
  const include = ans === true && !isCancel(ans);
43
47
  say(include
44
48
  ? `${short} 워크플로우를 포함합니다 — GitHub Actions에 추가됩니다`
@@ -46,68 +50,104 @@ async function askOptionalWorkflow({ dir, icon, short, desc, current, force, tty
46
50
  return include;
47
51
  }
48
52
 
49
- // 모든 opt-in 워크플로우를 순서대로 질문 (.sh ask_all_optional_workflows 등가).
53
+ // 배포/publish + Secret 백업을 순서대로 질문 (.sh ask_all_optional_workflows 등가).
50
54
  // tempDir: 템플릿 다운로드 루트 — project-types는 {tempDir}/.github/workflows/project-types
51
- // (copyWorkflows와 동일 규약. 테스트 픽스처용으로 {tempDir}/project-types 허용.)
52
- // current: { nexus: bool|null, secretBackup: bool|null, npmPublish: bool|null } — CLI(--nexus 등) 명시값
53
- // 반환: { nexus: bool, secretBackup: bool, npmPublish: bool } (미결정 null은 false로 확정)
55
+ // current: { deploy: string|null, publish: string[]|null, secretBackup: bool|null } CLI 명시값
56
+ // 반환: { deploy: string, publish: string[], secretBackup: bool }
54
57
  export async function askAllOptionalWorkflows({
55
58
  tempDir, types = [], current = {}, targetRoot = ".",
56
59
  force = false, tty = true, io = {}, forceAsk = false,
57
60
  }) {
58
61
  const say = io.log || ((m) => process.stderr.write(`${m}\n`));
59
- let nexus = current.nexus ?? null;
62
+ let deploy = current.deploy ?? null;
63
+ let publish = current.publish ?? null;
60
64
  let secretBackup = current.secretBackup ?? null;
61
- let npmPublish = current.npmPublish ?? null;
62
65
 
63
- // --force-ask가 아니면 version.yml 저장값을 먼저 읽어 재질문을 건너뛴다 (.sh L2715~2717).
66
+ // basic 단독 타입은 서버 배포도 라이브러리 publish도 개념상 성립하지 않는다.
67
+ // 배포/publish 질문을 건너뛰고 none·[]로 조용히 확정한다 (타입 변경 시 재질문됨).
68
+ // (basic은 "그 외" 폴백이라 항상 단독으로만 존재 — every로 안전 판정)
69
+ const isBasicOnly = types.length > 0 && types.every((t) => t === "basic");
70
+
71
+ // ① --force-ask가 아니면 version.yml 저장값을 먼저 읽어 재질문을 건너뛴다.
64
72
  // CLI 명시값(current)이 이미 있으면 그쪽이 우선 — 저장값은 빈 자리만 채운다.
65
73
  if (!forceAsk) {
66
74
  const vy = join(targetRoot, PATHS.versionFile);
67
75
  if (existsSync(vy)) {
68
76
  const saved = parseTemplateOptions(readFileSync(vy, "utf8"));
69
- if (nexus === null && saved.nexus !== null) {
70
- nexus = saved.nexus;
71
- say(`Nexus 옵션: version.yml 저장값(${nexus}) 유지 — 재질문 생략`);
77
+ if (deploy === null && saved.deploy !== null) {
78
+ deploy = saved.deploy;
79
+ say(`배포 방식: version.yml 저장값(${deploy}) 유지 — 재질문 생략`);
80
+ }
81
+ if (publish === null && saved.publish !== null) {
82
+ publish = saved.publish;
83
+ say(`Publish 타겟: version.yml 저장값(${publish.join(",") || "없음"}) 유지 — 재질문 생략`);
72
84
  }
73
85
  if (secretBackup === null && saved.secretBackup !== null) {
74
86
  secretBackup = saved.secretBackup;
75
87
  say(`Secret 백업 옵션: version.yml 저장값(${secretBackup}) 유지 — 재질문 생략`);
76
88
  }
77
- if (npmPublish === null && saved.npmPublish !== null) {
78
- npmPublish = saved.npmPublish;
79
- say(`npm publish 옵션: version.yml 저장값(${npmPublish}) 유지 — 재질문 생략`);
89
+ }
90
+ }
91
+
92
+ // ── ② 배포 방식 (택1) — basic 단독이면 질문 스킵, none으로 확정 ──
93
+ if (isBasicOnly) {
94
+ if (deploy === null) deploy = "none";
95
+ if (publish === null) publish = [];
96
+ } else {
97
+ if (forceAsk || deploy === null) {
98
+ if (force || !tty || typeof io.select !== "function") {
99
+ deploy = deploy ?? "docker-ssh";
100
+ } else {
101
+ say("");
102
+ say("🚀 이 프로젝트를 어디에 배포하나요?");
103
+ say(" 서버·호스팅에 올릴 계획이 있으면 고르고, 지금 없으면 '배포 안 함'으로 두면 됩니다.");
104
+ const ans = await io.select({
105
+ message: "배포 방식을 선택하세요",
106
+ options: [
107
+ { value: "docker-ssh", label: "Docker + SSH 서버 배포 (기본)" },
108
+ { value: "vercel", label: "Vercel" },
109
+ { value: "none", label: "배포하지 않음 (라이브러리/CI 전용)" },
110
+ ],
111
+ });
112
+ deploy = (!isCancel(ans) && DEPLOY_TARGETS.includes(ans)) ? ans : (deploy ?? "docker-ssh");
113
+ say(`배포 방식: ${deploy}`);
114
+ }
115
+ }
116
+
117
+ // ── ③ publish 타겟 (다중 선택) ──
118
+ if (forceAsk || publish === null) {
119
+ if (force || !tty || typeof io.multiselect !== "function") {
120
+ publish = publish ?? [];
121
+ } else {
122
+ say("");
123
+ say("📦 라이브러리로 배포(publish)할 계획이 있나요?");
124
+ say(" 사내 Nexus·npmjs·GitHub Packages 중 해당되는 걸 고르세요. 없으면 그냥 Enter.");
125
+ const ans = await io.multiselect({
126
+ message: "publish 타겟을 선택하세요 (Space 토글, Enter 확정)",
127
+ options: [
128
+ { value: "nexus", label: "사내 Maven(Nexus) 라이브러리 배포" },
129
+ { value: "npm", label: "공개 npmjs 패키지 배포 (NPM_TOKEN)" },
130
+ { value: "github-packages", label: "GitHub Packages 라이브러리 배포" },
131
+ ],
132
+ initialValues: publish ?? [],
133
+ required: false,
134
+ });
135
+ publish = (!isCancel(ans) && Array.isArray(ans))
136
+ ? ans.filter((t) => PUBLISH_TARGETS.includes(t))
137
+ : (publish ?? []);
138
+ say(`Publish 타겟: ${publish.join(",") || "없음"}`);
80
139
  }
81
140
  }
82
141
  }
83
142
 
84
- // project-types 루트 결정실제 temp 레이아웃 우선, 픽스처(직하위) 폴백
143
+ // ── Secret 백업: 공통 폴더 (배포축 아님 기존 폴더 질문 유지) ──
85
144
  const real = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
86
145
  const ptDir = existsSync(real) ? real : join(tempDir, PATHS.projectTypesDir);
87
-
88
- // ② Nexus: 각 타입의 nexus/ 폴더 (현재 spring만 존재, .sh L2719~2725)
89
- for (const t of types) {
90
- nexus = await askOptionalWorkflow({
91
- dir: join(ptDir, t, "nexus"), icon: "📦", short: "Nexus 라이브러리 publish",
92
- desc: "라이브러리/모듈을 Maven 저장소(Nexus)에 배포하는 워크플로우입니다. 일반 서버 배포가 아니라 라이브러리 프로젝트에만 필요합니다.",
93
- current: nexus, force, tty, io, forceAsk, say,
94
- });
95
- }
96
- // ②-2 npm publish: 각 타입의 npm-publish/ 폴더 (현재 node만 존재)
97
- for (const t of types) {
98
- npmPublish = await askOptionalWorkflow({
99
- dir: join(ptDir, t, "npm-publish"), icon: "📦", short: "npm 패키지 publish",
100
- desc: "패키지를 공개 npmjs 레지스트리에 자동 배포하는 워크플로우입니다. npm 라이브러리/CLI 프로젝트에만 필요합니다 (NPM_TOKEN secret 필요).",
101
- current: npmPublish, force, tty, io, forceAsk, say,
102
- });
103
- }
104
- // ③ Secret 백업: 공통 폴더 (.sh L2726~2729)
105
146
  secretBackup = await askOptionalWorkflow({
106
147
  dir: join(ptDir, "common", "secret-backup"), icon: "🔐", short: "Secret 서버 백업",
107
148
  desc: "GitHub Secret에 저장한 설정 파일을 SSH로 서버에 업로드·이력관리하는 워크플로우입니다.",
108
149
  current: secretBackup, force, tty, io, forceAsk, say,
109
150
  });
110
151
 
111
- // 미결정(null)은 false로 확정 .sh에서 INCLUDE_* 이후 false 취급되는 것과 동일
112
- return { nexus: nexus === true, secretBackup: secretBackup === true, npmPublish: npmPublish === true };
152
+ return { deploy: deploy ?? "docker-ssh", publish: publish ?? [], secretBackup: secretBackup === true };
113
153
  }
@@ -41,11 +41,17 @@ const HEADER = `# ==============================================================
41
41
  `;
42
42
 
43
43
  // metadata.template.options 상태머신 파싱 (.sh read_template_options L2361~2416 등가).
44
- // 반환: { nexus: bool|null, secretBackup: bool|null, npmPublish: bool|null } — null=미기재.
44
+ // 반환(#439 배포/publish 축): { deploy: string|null, publish: string[]|null, secretBackup: bool|null }
45
+ // deploy: 'docker-ssh'|'vercel'|'none', publish: ['nexus','npm','github-packages'] 부분집합. null=미기재.
46
+ // 구 키(nexus/npm_publish)는 신 축으로 자동 마이그레이션해 읽는다 (v4.2.0 이전 파일 호환):
47
+ // nexus:true → publish에 'nexus' + deploy 미기재면 'none' (구 동작: nexus면 서버 배포 제외)
48
+ // npm_publish:true → publish에 'npm'
45
49
  // 구 synology 키 등 다른 키는 어느 분기에도 안 걸려 자연히 무시된다.
46
50
  // (options-ask.js가 이 함수를 import한다 — 순환 방지 위해 여기(version-yml)에 정의.)
47
51
  export function parseTemplateOptions(content) {
48
- const out = { nexus: null, secretBackup: null, npmPublish: null };
52
+ const out = { deploy: null, publish: null, secretBackup: null };
53
+ let legacyNexus = null;
54
+ let legacyNpm = null;
49
55
  // 값 정규화: 따옴표 제거 + 트림 (.sh tr -d '"' | tr -d "'" | xargs 등가)
50
56
  const strip = (s) => String(s).replace(/["']/g, "").trim();
51
57
  let inTemplate = false;
@@ -54,11 +60,22 @@ export function parseTemplateOptions(content) {
54
60
  if (/^\s*template:/.test(line)) { inTemplate = true; continue; }
55
61
  if (inTemplate && /^\s+options:/.test(line)) { inOptions = true; continue; }
56
62
  if (inTemplate && inOptions) {
57
- let m = line.match(/^\s+nexus:\s*(.+)/);
63
+ let m = line.match(/^\s+deploy:\s*(.+)/);
58
64
  if (m) {
59
65
  const v = strip(m[1]);
60
- if (v === "true") out.nexus = true;
61
- if (v === "false") out.nexus = false;
66
+ if (["docker-ssh", "vercel", "none"].includes(v)) out.deploy = v;
67
+ continue;
68
+ }
69
+ m = line.match(/^\s+publish:\s*\[([^\]]*)\]/);
70
+ if (m) {
71
+ out.publish = strip(m[1]).split(",").map((t) => t.trim()).filter(Boolean);
72
+ continue;
73
+ }
74
+ m = line.match(/^\s+nexus:\s*(.+)/);
75
+ if (m) {
76
+ const v = strip(m[1]);
77
+ if (v === "true") legacyNexus = true;
78
+ if (v === "false") legacyNexus = false;
62
79
  continue;
63
80
  }
64
81
  m = line.match(/^\s+secret_backup:\s*(.+)/);
@@ -71,8 +88,8 @@ export function parseTemplateOptions(content) {
71
88
  m = line.match(/^\s+npm_publish:\s*(.+)/);
72
89
  if (m) {
73
90
  const v = strip(m[1]);
74
- if (v === "true") out.npmPublish = true;
75
- if (v === "false") out.npmPublish = false;
91
+ if (v === "true") legacyNpm = true;
92
+ if (v === "false") legacyNpm = false;
76
93
  continue;
77
94
  }
78
95
  // 들여쓰기 0~4칸의 다른 키 → options 섹션 종료 (.sh L2404~2408)
@@ -81,6 +98,15 @@ export function parseTemplateOptions(content) {
81
98
  // 최상위 키 → template 섹션 종료 (.sh L2411~2415)
82
99
  if (inTemplate && /^[a-z_]+:/.test(line)) { inTemplate = false; inOptions = false; }
83
100
  }
101
+ // ── 구 키 마이그레이션 — 신 publish 키가 없을 때만 ──
102
+ if (out.publish === null && (legacyNexus !== null || legacyNpm !== null)) {
103
+ out.publish = [];
104
+ if (legacyNexus === true) {
105
+ out.publish.push("nexus");
106
+ if (out.deploy === null) out.deploy = "none";
107
+ }
108
+ if (legacyNpm === true) out.publish.push("npm");
109
+ }
84
110
  return out;
85
111
  }
86
112
 
@@ -137,7 +163,7 @@ export function parseExisting(content) {
137
163
  // now = "YYYY-MM-DD HH:MM:SS" (UTC) — 결정성 위해 주입
138
164
  // today = "YYYY-MM-DD" (UTC)
139
165
  // pathMarkers = Map<type, markerFilename> (project_paths 주석용)
140
- // templateOptions = { templateVersion, includeNexus, includeSecretBackup, optionsDate } (template 블록)
166
+ // templateOptions = { templateVersion, deployTarget, publishTargets, includeSecretBackup, optionsDate } (template 블록)
141
167
  export function buildVersionYml({ version, types = [], paths = new Map(), pathMarkers = new Map(), branch = "main", versionCode = 1, now, today, templateOptions = null, deployValues = new Map() }) {
142
168
  const typesJson = types.length ? `[${types.map((t) => `"${t}"`).join(",")}]` : `["basic"]`;
143
169
 
@@ -177,16 +203,17 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
177
203
 
178
204
  // template 옵션 블록 (.sh save_template_options 신규 추가 케이스). templateOptions 지정 시.
179
205
  if (templateOptions) {
180
- const { templateVersion = "unknown", includeNexus = false, includeSecretBackup = false, includeNpmPublish = false, optionsDate = today } = templateOptions;
206
+ const { templateVersion = "unknown", deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, optionsDate = today } = templateOptions;
207
+ const publishJson = `[${publishTargets.map((t) => `"${t}"`).join(",")}]`;
181
208
  out += ` template:\n`;
182
209
  out += ` source: "projectops"\n`;
183
210
  out += ` version: "${templateVersion}"\n`;
184
211
  out += ` integrated_date: "${optionsDate}"\n`;
185
212
  out += ` last_update_date: "${optionsDate}"\n`;
186
213
  out += ` options:\n`;
187
- out += ` nexus: ${includeNexus}\n`;
214
+ out += ` deploy: "${deployTarget}"\n`;
215
+ out += ` publish: ${publishJson}\n`;
188
216
  out += ` secret_backup: ${includeSecretBackup}\n`;
189
- out += ` npm_publish: ${includeNpmPublish}\n`;
190
217
  }
191
218
  return out;
192
219
  }
package/src/index.js CHANGED
@@ -105,10 +105,10 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
105
105
  const context = createContext({
106
106
  mode: opts.mode, force: true, types, version, versionCode, branch,
107
107
  paths,
108
- // 옵션 워크플로우: CLI 플래그 최우선 → version.yml 저장 옵션(.sh read_template_options 등가) → false
109
- includeNexus: opts.includeNexus ?? existing?.options?.nexus ?? false,
108
+ // 배포/publish 축(#439): CLI 플래그 최우선 → version.yml 저장 옵션( 자동 마이그레이션) → 기본값
109
+ deployTarget: opts.deployTarget ?? existing?.options?.deploy ?? "docker-ssh",
110
+ publishTargets: opts.publishTargets ?? existing?.options?.publish ?? [],
110
111
  includeSecretBackup: opts.includeSecretBackup ?? existing?.options?.secretBackup ?? false,
111
- includeNpmPublish: opts.includeNpmPublish ?? existing?.options?.npmPublish ?? false,
112
112
  repoName,
113
113
  // 실 resolver 4종 (.sh resolve_token 등가 — spring-app-yml 스텁 제거)
114
114
  resolvers: makeResolvers(cwd, repoName, paths),
package/src/ui/ansi.js CHANGED
@@ -24,3 +24,10 @@ export function visualWidth(s) {
24
24
  }
25
25
  return w;
26
26
  }
27
+
28
+ // 시각 폭(CJK 2칸) 기준으로 오른쪽 스페이스 패딩 — 한글·영문 혼합 라벨 열 정렬용.
29
+ // JS 기본 String.padEnd는 문자 수 기준이라 한글이 섞이면 열이 어긋난다.
30
+ export function padEndVisual(s, targetWidth) {
31
+ const pad = targetWidth - visualWidth(s);
32
+ return pad > 0 ? String(s) + " ".repeat(pad) : String(s);
33
+ }
@@ -32,13 +32,14 @@ export function scopeString(usages = []) {
32
32
  // ask KEY 수집 (.sh wf_collect_asks 등가) — 실제 설치되는 워크플로우와 같은 소스를 스캔한다.
33
33
  // tempDir: 다운로드 원본 루트. types: 설치 대상 타입 목록.
34
34
  // opts:
35
- // resolvers - @접두 기본값(@repo 등) 해석용 (.sh는 수집 시점에 resolve_token — 동일)
36
- // includeNexus - true면 server-deploy 제외 + nexus/ 포함 (복사 엔진과 스캔 범위 일치)
37
- // prompts - wizard-labels 파싱 객체 (워크플로우 표시명용, null이면 확장자 제거 폴백)
35
+ // resolvers - @접두 기본값(@repo 등) 해석용 (.sh는 수집 시점에 resolve_token — 동일)
36
+ // deployTarget - #439 배포 축: docker-ssh(기본)일 때만 server-deploy 스캔 (복사 엔진과 일치)
37
+ // publishTargets - #439 publish 축: 선택된 타겟의 publish/<target>/ 스캔
38
+ // prompts - wizard-labels 파싱 객체 (워크플로우 표시명용, null이면 확장자 제거 폴백)
38
39
  // 반환: { keys:[], defaults:Map<key,default>, typeDefaults:Map<"type|key",default>,
39
40
  // usages:Map<key,[{type,workflowName}]> }
40
41
  export function collectAsks(tempDir, types = [], opts = {}) {
41
- const { resolvers = {}, includeNexus = false, prompts = null } = opts;
42
+ const { resolvers = {}, deployTarget = "docker-ssh", publishTargets = [], prompts = null } = opts;
42
43
  const baseDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
43
44
  const keys = [];
44
45
  const defaults = new Map();
@@ -48,10 +49,10 @@ export function collectAsks(tempDir, types = [], opts = {}) {
48
49
  for (const type of types) {
49
50
  const typeDir = join(baseDir, type);
50
51
  if (!exists(typeDir)) continue;
51
- // 복사 엔진과 동일한 폴더 구성: 타입 직하위 + (nexus 아니면) server-deploy + (nexus면) nexus
52
+ // 복사 엔진과 동일한 폴더 구성: 타입 직하위 + (docker-ssh면) server-deploy + 선택된 publish/<target>
52
53
  const dirs = [typeDir];
53
- if (!includeNexus) dirs.push(join(typeDir, "server-deploy"));
54
- else dirs.push(join(typeDir, "nexus"));
54
+ if ((deployTarget || "docker-ssh") === "docker-ssh") dirs.push(join(typeDir, "server-deploy"));
55
+ for (const t of publishTargets) dirs.push(join(typeDir, "publish", t));
55
56
 
56
57
  for (const dir of dirs) {
57
58
  if (!exists(dir)) continue;
@@ -129,17 +130,17 @@ async function promptEach(io, prompts, asks, todoKeys, values, log) {
129
130
  // - useDefaults=false → values에 담긴 키만 사용자 확정값으로 치환, 나머지는 기본값
130
131
  // (⚠️ substituteEnv는 useDefaults=false일 때만 values를 참조하므로 이 플래그를 반드시 함께 전달)
131
132
  // 인자:
132
- // tempDir/types/resolvers/includeNexus — collectAsks와 동일 의미
133
+ // tempDir/types/resolvers/deployTarget/publishTargets — collectAsks와 동일 의미
133
134
  // targetRoot — wizard-prompts.yml 1차 탐색 위치(기본 ".")
134
135
  // force — true면 질문 없이 전부 기본값 (.sh FORCE_MODE 등가)
135
136
  // io — {select, multiselect, text} 주입 (기본 readline-engine). 테스트 스텁 지점.
136
137
  // log — 카드·안내 출력 함수 주입 (기본 stderr)
137
138
  export async function promptEnvPlan({
138
139
  tempDir, types = [], io = null, force = false, resolvers = {},
139
- includeNexus = false, targetRoot = ".", repoName = "", log = defaultLog,
140
+ deployTarget = "docker-ssh", publishTargets = [], targetRoot = ".", repoName = "", log = defaultLog,
140
141
  } = {}) {
141
142
  const prompts = loadWizardPrompts(targetRoot, tempDir);
142
- const asks = collectAsks(tempDir, types, { resolvers, includeNexus, prompts });
143
+ const asks = collectAsks(tempDir, types, { resolvers, deployTarget, publishTargets, prompts });
143
144
  const defaults = asks.defaults;
144
145
 
145
146
  // 수집 키 0개 → 질문 자체가 없음 (.sh `[ ${#WF_ASK_KEYS[@]} -eq 0 ]` 등가)
package/src/ui/prompts.js CHANGED
@@ -39,9 +39,8 @@ export async function editMenu({ showOptional = false } = {}) {
39
39
  { value: "branch", label: "기본 브랜치" },
40
40
  ];
41
41
  if (showOptional) {
42
- options.push({ value: "nexus", label: "Nexus publish 포함 여부" });
42
+ options.push({ value: "deploy-publish", label: "배포/Publish 방식" });
43
43
  options.push({ value: "secret", label: "Secret 백업 포함 여부" });
44
- options.push({ value: "npm-publish", label: "npm publish 포함 여부" });
45
44
  }
46
45
  options.push({ value: "done", label: "모두 맞음, 계속" });
47
46
  return engine.select({ message: "어떤 항목을 수정할까요?", options });
@@ -1,6 +1,6 @@
1
1
  // 첫 화면 상태 표시 층 (#446 층2~5) — 감지 로그 · 분석 카드 · IDE 상태 · 신규/업데이트 판별
2
2
  // (층5의 Breaking Changes 박스는 core/breaking-check.js가 담당)
3
- import { A, paint } from "./ansi.js";
3
+ import { A, paint, padEndVisual } from "./ansi.js";
4
4
  import { ADAPTERS } from "../core/ide/registry.js";
5
5
  import { markerForType } from "../core/detect.js";
6
6
 
@@ -25,18 +25,21 @@ export function printDetectionLog({ types = [], version = "", branch = "" }, out
25
25
 
26
26
  // 층3 — 프로젝트 분석 개요 카드 (.ps1 Print-ProjectAnalysis 등가+)
27
27
  export function printAnalysisCard({ mode = "", modeLabel = "", types = [], version = "", branch = "",
28
- includeNexus = null, includeSecretBackup = null, includeNpmPublish = null, paths = new Map(), showOptional = false },
28
+ deployTarget = null, publishTargets = null, includeSecretBackup = null, paths = new Map(), showOptional = false },
29
29
  out = (s) => process.stdout.write(s)) {
30
30
  out(`${HEAD} ${paint("프로젝트 분석 결과", A.bold)}\n`);
31
- const row = (icon, label, value) => out(`${GUT} ${icon} ${label.padEnd(10)} ${value}\n`);
31
+ // 라벨을 시각 (CJK 2칸) 기준으로 패딩 — 한글·영문 혼합 라벨(타입·Publish·Secret백업) 열 정렬.
32
+ // 가장 긴 라벨 "Secret백업"(=8칸) 기준 여유 두고 12.
33
+ const row = (icon, label, value) => out(`${GUT} ${icon} ${padEndVisual(label, 12)} ${value}\n`);
32
34
  row("📂", types.length > 1 ? "타입(멀티)" : "타입", paint(types.join(", ") || "basic", A.bold));
33
35
  row("🌙", "버전", paint(`v${version}`, A.green));
34
36
  row("🌿", "브랜치", branch);
35
37
  if (modeLabel || mode) row("💫", "통합 모드", modeLabel || mode);
36
38
  if (showOptional) {
37
- row("📦", "Nexus", includeNexus === true ? paint("포함", A.green) : paint("제외", A.dim));
39
+ row("🚀", "배포", paint(deployTarget || "docker-ssh", A.bold));
40
+ const pub = (publishTargets ?? []).join(",");
41
+ row("📦", "Publish", pub ? paint(pub, A.green) : paint("없음", A.dim));
38
42
  row("🔐", "Secret백업", includeSecretBackup === true ? paint("포함", A.green) : paint("제외", A.dim));
39
- row("📦", "npm publish", includeNpmPublish === true ? paint("포함", A.green) : paint("제외", A.dim));
40
43
  }
41
44
  // 모노레포 경로 — 루트가 아닌 항목이 하나라도 있으면 표시
42
45
  const nonRoot = [...paths.entries()].filter(([, p]) => p && p !== ".");