projectops 4.2.1 → 4.2.3

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.3 (2026-07-09)
10
+ ## 최신 버전 : v4.2.2 (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.2.1",
3
+ "version": "4.2.3",
4
4
  "description": "ProjectOps — 완전 자동화 GitHub 프로젝트 관리 템플릿 통합 CLI (구 SUH-DEVOPS-TEMPLATE 마법사)",
5
5
  "keywords": [
6
6
  "devops",
@@ -63,6 +63,11 @@ export async function askAllOptionalWorkflows({
63
63
  let publish = current.publish ?? null;
64
64
  let secretBackup = current.secretBackup ?? null;
65
65
 
66
+ // basic 단독 타입은 서버 배포도 라이브러리 publish도 개념상 성립하지 않는다.
67
+ // 배포/publish 질문을 건너뛰고 none·[]로 조용히 확정한다 (타입 변경 시 재질문됨).
68
+ // (basic은 "그 외" 폴백이라 항상 단독으로만 존재 — every로 안전 판정)
69
+ const isBasicOnly = types.length > 0 && types.every((t) => t === "basic");
70
+
66
71
  // ① --force-ask가 아니면 version.yml 저장값을 먼저 읽어 재질문을 건너뛴다.
67
72
  // CLI 명시값(current)이 이미 있으면 그쪽이 우선 — 저장값은 빈 자리만 채운다.
68
73
  if (!forceAsk) {
@@ -84,47 +89,54 @@ export async function askAllOptionalWorkflows({
84
89
  }
85
90
  }
86
91
 
87
- // ── ② 배포 방식 (택1) ──
88
- if ((forceAsk || deploy === null)) {
89
- if (force || !tty || typeof io.select !== "function") {
90
- deploy = deploy ?? "docker-ssh";
91
- } else {
92
- say("");
93
- say("🚀 프로젝트를 어디에 배포하나요?");
94
- const ans = await io.select({
95
- message: "배포 방식을 선택하세요",
96
- options: [
97
- { value: "docker-ssh", label: "Docker + SSH 서버 배포 (기본)" },
98
- { value: "vercel", label: "Vercel" },
99
- { value: "none", label: "배포하지 않음 (라이브러리/CI 전용)" },
100
- ],
101
- });
102
- deploy = (!isCancel(ans) && DEPLOY_TARGETS.includes(ans)) ? ans : (deploy ?? "docker-ssh");
103
- say(`배포 방식: ${deploy}`);
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
+ }
104
115
  }
105
- }
106
116
 
107
- // ── ③ publish 타겟 (다중 선택) ──
108
- if ((forceAsk || publish === null)) {
109
- if (force || !tty || typeof io.multiselect !== "function") {
110
- publish = publish ?? [];
111
- } else {
112
- say("");
113
- say("📦 라이브러리/패키지 publish 레지스트리가 있나요? (없으면 그냥 Enter)");
114
- const ans = await io.multiselect({
115
- message: "publish 타겟을 선택하세요 (Space 토글, Enter 확정)",
116
- options: [
117
- { value: "nexus", label: "사내 Maven(Nexus) 라이브러리 배포" },
118
- { value: "npm", label: "공개 npmjs 패키지 배포 (NPM_TOKEN)" },
119
- { value: "github-packages", label: "GitHub Packages 라이브러리 배포" },
120
- ],
121
- initialValues: publish ?? [],
122
- required: false,
123
- });
124
- publish = (!isCancel(ans) && Array.isArray(ans))
125
- ? ans.filter((t) => PUBLISH_TARGETS.includes(t))
126
- : (publish ?? []);
127
- say(`Publish 타겟: ${publish.join(",") || "없음"}`);
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(",") || "없음"}`);
139
+ }
128
140
  }
129
141
  }
130
142
 
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
+ }
@@ -6,6 +6,7 @@
6
6
  // 계약: 취소(ESC/Ctrl+C)는 CANCEL 심볼 반환. 각 함수 async.
7
7
  import { emitKeypressEvents } from "node:readline";
8
8
  import { stdin, stdout } from "node:process";
9
+ import { visualWidth } from "./ansi.js";
9
10
 
10
11
  export const CANCEL = Symbol("cancel");
11
12
 
@@ -28,18 +29,31 @@ const S_BAR = paint("│", c.gray);
28
29
  const S_Q = paint("◆", c.cyan);
29
30
  const S_DONE = paint("◇", c.green);
30
31
 
32
+ // 한 논리 라인이 터미널 폭에서 차지하는 물리 줄 수 (wrap 고려).
33
+ // WHY: 긴 라벨(한글 2칸 포함)이 터미널 폭을 넘으면 터미널이 자동 줄바꿈해 물리적으로 2줄+가 된다.
34
+ // 커서 되감기(ESC[nA)는 물리 줄 기준이므로, 논리 줄 수만 세면 wrap된 만큼 덜 올라가
35
+ // 이전 출력이 안 지워지고 매 렌더마다 화면이 아래로 밀린다. (실측: Windows PowerShell 폭 80)
36
+ export function physicalRows(line, cols) {
37
+ const w = visualWidth(line); // ANSI 시퀀스 제외 + CJK 2칸
38
+ if (cols <= 0) return 1;
39
+ // 빈 줄도 최소 1행. 폭의 정수배로 딱 떨어지면 wrap 안 되므로 그대로(80칸 라인@80폭 = 1행).
40
+ if (w === 0) return 1;
41
+ return Math.max(1, Math.ceil(w / cols));
42
+ }
43
+
31
44
  // 여러 줄 지운 뒤 커서를 블록 시작으로 되돌리는 렌더러.
32
- // prevLines 만큼 위로 올라가 지우고 새로 그린다.
45
+ // 직전 렌더의 "물리 줄 수"만큼 위로 올라가 지우고 새로 그린다 (wrap 안전).
33
46
  function makeRenderer() {
34
- let prevLines = 0;
47
+ let prevRows = 0; // 직전 렌더가 실제로 차지한 물리 줄 수
35
48
  return {
36
49
  render(lines) {
37
- if (prevLines > 0) stdout.write(`${ESC}${prevLines}A`); // 위로
50
+ if (prevRows > 0) stdout.write(`${ESC}${prevRows}A`); // 물리 줄 기준으로 위로
38
51
  stdout.write(`${ESC}0J`); // 커서 아래 전부 지우기
39
52
  stdout.write(lines.join("\n") + "\n");
40
- prevLines = lines.length;
53
+ const cols = stdout.columns || 80; // 터미널 폭 (파이프 등으로 없으면 80 가정)
54
+ prevRows = lines.reduce((sum, l) => sum + physicalRows(l, cols), 0);
41
55
  },
42
- reset() { prevLines = 0; },
56
+ reset() { prevRows = 0; },
43
57
  };
44
58
  }
45
59
 
@@ -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
 
@@ -28,7 +28,9 @@ export function printAnalysisCard({ mode = "", modeLabel = "", types = [], versi
28
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);
@@ -78,11 +80,15 @@ export function printIdeStatus(statuses, out = (s) => process.stdout.write(s)) {
78
80
  }
79
81
 
80
82
  // 층5 — 신규 통합 vs 업데이트 판별 라인 (Breaking 박스는 breaking-check.js)
83
+ // WHY 판정: version.yml의 metadata.template.version(이전 통합 흔적)이 있으면 업데이트, 없으면 신규.
84
+ // 사용자가 "무슨 기준으로 신규/업데이트인지"를 바로 알도록 판정 근거를 화면에 밝힌다.
81
85
  export function printInstallKind({ currentTemplateVersion = "", templateVersion = "" }, out = (s) => process.stdout.write(s)) {
82
86
  if (currentTemplateVersion) {
83
87
  out(`${GUT} ♻️ ${paint("업데이트", A.bold)} — 템플릿 ${paint(`v${currentTemplateVersion}`, A.dim)} → ${paint(`v${templateVersion}`, A.green)}\n`);
88
+ out(`${GUT} ${paint("version.yml에 이전 통합 기록이 있어 업데이트로 진행합니다", A.dim)}\n`);
84
89
  } else {
85
90
  out(`${GUT} 🆕 ${paint("신규 통합", A.bold)} — 이 프로젝트에 처음 설치합니다 (템플릿 ${paint(`v${templateVersion}`, A.green)})\n`);
91
+ out(`${GUT} ${paint("version.yml에 이전 통합 기록이 없어 신규로 봅니다", A.dim)}\n`);
86
92
  }
87
93
  out(`${GUT}\n`);
88
94
  }