projectops 4.0.2 → 4.0.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 +2 -2
- package/bin/projectops.js +3 -3
- package/package.json +1 -5
- package/src/cli/args.js +5 -1
- package/src/cli/help.js +2 -1
- package/src/index.js +15 -1
- package/src/ui/prompts.js +16 -22
- package/src/ui/readline-engine.js +257 -0
- package/src/ui/skills-prompts.js +9 -10
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
> 이슈 등록부터 커밋, 보고서, 배포까지. 개발자는 코드만 작성하세요.
|
|
8
8
|
|
|
9
9
|
<!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
|
|
10
|
-
## 최신 버전 : v4.0.
|
|
10
|
+
## 최신 버전 : v4.0.2 (2026-07-08)
|
|
11
11
|
|
|
12
12
|
[전체 버전 기록 보기](CHANGELOG.md)
|
|
13
13
|
|
|
@@ -91,7 +91,7 @@ GitHub에서 **"Use this template"** 클릭 → 1분 내 자동 초기화 완료
|
|
|
91
91
|
npx projectops
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
> Node.js
|
|
94
|
+
> Node.js 20.12+ 만 있으면 별도 설치 없이 대화형 마법사가 실행됩니다. 비대화형: `npx projectops --mode full --type spring,react --force`
|
|
95
95
|
|
|
96
96
|
<details>
|
|
97
97
|
<summary>대안 — 스크립트 직접 실행 (Node 없이)</summary>
|
package/bin/projectops.js
CHANGED
|
@@ -4,9 +4,9 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
6
|
|
|
7
|
-
const nodeMajor =
|
|
8
|
-
if (nodeMajor <
|
|
9
|
-
console.error(`Node.js
|
|
7
|
+
const [nodeMajor, nodeMinor] = process.versions.node.split(".").map(Number);
|
|
8
|
+
if (nodeMajor < 20 || (nodeMajor === 20 && nodeMinor < 12)) {
|
|
9
|
+
console.error(`Node.js 20.12 이상이 필요합니다 (현재: ${process.versions.node})`);
|
|
10
10
|
process.exit(1);
|
|
11
11
|
}
|
|
12
12
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "projectops",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.3",
|
|
4
4
|
"description": "ProjectOps — 완전 자동화 GitHub 프로젝트 관리 템플릿 통합 CLI (구 SUH-DEVOPS-TEMPLATE 마법사)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"devops",
|
|
@@ -41,9 +41,5 @@
|
|
|
41
41
|
"skills": [
|
|
42
42
|
"./skills"
|
|
43
43
|
]
|
|
44
|
-
},
|
|
45
|
-
"dependencies": {
|
|
46
|
-
"@clack/prompts": "^1.7.0",
|
|
47
|
-
"picocolors": "^1.1.1"
|
|
48
44
|
}
|
|
49
45
|
}
|
package/src/cli/args.js
CHANGED
|
@@ -5,7 +5,7 @@ import { VALID_TYPES } from "../context.js";
|
|
|
5
5
|
export function parseArgs(argv) {
|
|
6
6
|
const result = {
|
|
7
7
|
mode: "interactive",
|
|
8
|
-
version: "",
|
|
8
|
+
version: "", // 통합 대상 프로젝트의 초기 버전 (--project-version)
|
|
9
9
|
types: [],
|
|
10
10
|
primaryType: "",
|
|
11
11
|
includeNexus: null, // null=미설정
|
|
@@ -13,6 +13,7 @@ export function parseArgs(argv) {
|
|
|
13
13
|
pathsCsv: "", // "flutter=app,react=client" 원문 (정규화는 resolve 단계)
|
|
14
14
|
force: false,
|
|
15
15
|
help: false,
|
|
16
|
+
showVersion: false, // -v/--version → projectops 패키지 버전 출력 (npm 관례)
|
|
16
17
|
};
|
|
17
18
|
const args = [...argv];
|
|
18
19
|
while (args.length > 0) {
|
|
@@ -21,6 +22,9 @@ export function parseArgs(argv) {
|
|
|
21
22
|
case "-m": case "--mode":
|
|
22
23
|
result.mode = args.shift() ?? ""; break;
|
|
23
24
|
case "-v": case "--version":
|
|
25
|
+
// npm 관례: -v/--version 은 패키지 버전 출력. (초기 버전 지정은 --project-version)
|
|
26
|
+
result.showVersion = true; break;
|
|
27
|
+
case "--project-version":
|
|
24
28
|
result.version = args.shift() ?? ""; break;
|
|
25
29
|
case "-t": case "--type": {
|
|
26
30
|
const csv = args.shift() ?? "";
|
package/src/cli/help.js
CHANGED
|
@@ -10,11 +10,12 @@ export const HELP_TEXT = `projectops — GitHub 프로젝트 자동화 템플릿
|
|
|
10
10
|
-t, --type CSV 프로젝트 타입 csv (예: spring,react,python)
|
|
11
11
|
지원: spring flutter next react react-native
|
|
12
12
|
react-native-expo node python basic
|
|
13
|
-
|
|
13
|
+
--project-version V 통합 대상의 초기 버전 (예: 1.0.0). 미지정 시 자동 감지
|
|
14
14
|
--paths "t=p,..." 타입별 프로젝트 경로 (모노레포). 예: flutter=app,react=client
|
|
15
15
|
--nexus / --no-nexus Nexus 라이브러리 publish 워크플로우 포함/제외
|
|
16
16
|
--secret-backup / --no-secret-backup Secret 백업 워크플로우 포함/제외
|
|
17
17
|
--force 모든 확인 생략, 비대화형 기본값 사용
|
|
18
|
+
-v, --version projectops 버전 출력
|
|
18
19
|
-h, --help 이 도움말 표시
|
|
19
20
|
|
|
20
21
|
예시:
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// projectops CLI 진입 파이프라인 (.sh main + execute_integration 등가).
|
|
2
2
|
// 감지 → 다운로드 → 모드 라우팅 → 통합 실행 → 정리. 비대화형(--force) 우선.
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
4
6
|
import { parseArgs, parsePathsCsv, CliError } from "./cli/args.js";
|
|
5
7
|
import { HELP_TEXT } from "./cli/help.js";
|
|
6
8
|
import { createContext } from "./context.js";
|
|
@@ -15,6 +17,17 @@ import { runIssues } from "./commands/issues.js";
|
|
|
15
17
|
import { runInteractive } from "./commands/interactive.js";
|
|
16
18
|
import { runSkills } from "./commands/skills.js";
|
|
17
19
|
|
|
20
|
+
// projectops 패키지 버전 읽기 (-v/--version 출력용). src/../package.json.
|
|
21
|
+
function readPkgVersion() {
|
|
22
|
+
try {
|
|
23
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
|
|
25
|
+
return pkg.version || "unknown";
|
|
26
|
+
} catch {
|
|
27
|
+
return "unknown";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
18
31
|
// 결정적 UTC 타임스탬프 (주입 가능 — 테스트/골든용)
|
|
19
32
|
function utcNow(date = new Date()) {
|
|
20
33
|
const p = (n) => String(n).padStart(2, "0");
|
|
@@ -34,6 +47,7 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
|
|
|
34
47
|
if (e instanceof CliError) { console.error(e.message); return 1; }
|
|
35
48
|
throw e;
|
|
36
49
|
}
|
|
50
|
+
if (opts.showVersion) { console.log(readPkgVersion()); return 0; }
|
|
37
51
|
if (opts.help) { console.log(HELP_TEXT); return 0; }
|
|
38
52
|
|
|
39
53
|
// skills 모드 — IDE 스킬 설치/업데이트/제거 (템플릿 통합 없음).
|
package/src/ui/prompts.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
// 대화형 프롬프트 래핑 (.sh interactive_menu/choose_menu/ask_* 등가)
|
|
1
|
+
// 대화형 프롬프트 래핑 (.sh interactive_menu/choose_menu/ask_* 등가).
|
|
2
|
+
// node:readline 기반 자체 엔진 사용 (@clack/prompts 는 Windows TTY에서 Enter가 멈추는 버그로 제거).
|
|
2
3
|
// 취소(ESC/Ctrl+C)는 각 함수가 CANCEL 심볼을 반환 → 호출부가 정상 종료(exit 0) 처리.
|
|
3
|
-
import * as
|
|
4
|
+
import * as engine from "./readline-engine.js";
|
|
4
5
|
|
|
5
|
-
export const CANCEL =
|
|
6
|
-
const wrap = (v) => (clack.isCancel(v) ? CANCEL : v);
|
|
6
|
+
export const CANCEL = engine.CANCEL;
|
|
7
7
|
|
|
8
8
|
// 모드 선택 — 한국어 라벨, 내부 키 반환. 취소 시 CANCEL.
|
|
9
9
|
export async function selectMode() {
|
|
10
|
-
|
|
10
|
+
return engine.select({
|
|
11
11
|
message: "무엇을 설치할까요?",
|
|
12
12
|
options: [
|
|
13
13
|
{ value: "full", label: "전체 설치 — 버전관리 + 자동화 워크플로우 + 이슈·PR 템플릿 (처음이라면 추천)" },
|
|
@@ -17,12 +17,11 @@ export async function selectMode() {
|
|
|
17
17
|
{ value: "skills", label: "AI 스킬만 — Claude·Cursor·Gemini·Codex·PI용 스킬만 설치" },
|
|
18
18
|
],
|
|
19
19
|
});
|
|
20
|
-
return wrap(v);
|
|
21
20
|
}
|
|
22
21
|
|
|
23
22
|
// 프로젝트 확인 화면 메뉴 (계속/수정/취소).
|
|
24
23
|
export async function confirmProjectMenu() {
|
|
25
|
-
|
|
24
|
+
return engine.select({
|
|
26
25
|
message: "이 정보로 진행할까요?",
|
|
27
26
|
options: [
|
|
28
27
|
{ value: "continue", label: "예, 계속 진행" },
|
|
@@ -30,7 +29,6 @@ export async function confirmProjectMenu() {
|
|
|
30
29
|
{ value: "cancel", label: "아니오, 취소" },
|
|
31
30
|
],
|
|
32
31
|
});
|
|
33
|
-
return wrap(v);
|
|
34
32
|
}
|
|
35
33
|
|
|
36
34
|
// 수정 메뉴 — 어떤 항목을 고칠지. showOptional=full/workflows에서만 nexus/secret 노출.
|
|
@@ -45,38 +43,34 @@ export async function editMenu({ showOptional = false } = {}) {
|
|
|
45
43
|
options.push({ value: "secret", label: "Secret 백업 포함 여부" });
|
|
46
44
|
}
|
|
47
45
|
options.push({ value: "done", label: "모두 맞음, 계속" });
|
|
48
|
-
|
|
49
|
-
return wrap(v);
|
|
46
|
+
return engine.select({ message: "어떤 항목을 수정할까요?", options });
|
|
50
47
|
}
|
|
51
48
|
|
|
52
49
|
// 타입 멀티선택.
|
|
53
50
|
export async function selectTypes(current = []) {
|
|
54
51
|
const all = ["spring", "flutter", "next", "react", "react-native", "react-native-expo", "node", "python", "basic"];
|
|
55
|
-
|
|
52
|
+
return engine.multiselect({
|
|
56
53
|
message: "프로젝트 타입을 선택하세요 (Space 토글, Enter 확정)",
|
|
57
54
|
options: all.map((t) => ({ value: t, label: t })),
|
|
58
55
|
initialValues: current.length ? current : ["basic"],
|
|
59
56
|
required: true,
|
|
60
57
|
});
|
|
61
|
-
return wrap(v);
|
|
62
58
|
}
|
|
63
59
|
|
|
64
60
|
// 텍스트 입력 (빈 입력=기본값 유지).
|
|
65
61
|
export async function askText(message, defaultValue = "") {
|
|
66
|
-
const v = await
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return w === "" || w == null ? defaultValue : w;
|
|
62
|
+
const v = await engine.text({ message, defaultValue });
|
|
63
|
+
if (v === CANCEL) return CANCEL;
|
|
64
|
+
return v === "" || v == null ? defaultValue : v;
|
|
70
65
|
}
|
|
71
66
|
|
|
72
67
|
// 예/아니오.
|
|
73
68
|
export async function askYesNo(message, initial = true) {
|
|
74
|
-
|
|
75
|
-
return wrap(v);
|
|
69
|
+
return engine.confirm({ message, initialValue: initial });
|
|
76
70
|
}
|
|
77
71
|
|
|
78
72
|
// 배너·안내 출력.
|
|
79
|
-
export function intro(text) {
|
|
80
|
-
export function outro(text) {
|
|
81
|
-
export function note(text, title) {
|
|
82
|
-
export function cancelMessage(text = "취소했습니다.") {
|
|
73
|
+
export function intro(text) { engine.intro(text); }
|
|
74
|
+
export function outro(text) { engine.outro(text); }
|
|
75
|
+
export function note(text, title) { engine.note(text, title); }
|
|
76
|
+
export function cancelMessage(text = "취소했습니다.") { engine.cancelMessage(text); }
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// node:readline 기반 대화형 프롬프트 엔진 (@clack/prompts 대체).
|
|
2
|
+
// WHY: @clack/prompts 1.7.0 이 Windows TTY 콘솔에서 Enter(return) 키를 처리하지 못하고
|
|
3
|
+
// 멈추는 버그가 있다(실측 확정). node:readline 의 keypress 이벤트는 Windows에서 정상 동작한다.
|
|
4
|
+
// .sh/.ps1 이 자체 메뉴를 구현한 것과 동일한 접근. 외부 의존성 0 → 내부망에서도 안전.
|
|
5
|
+
//
|
|
6
|
+
// 계약: 취소(ESC/Ctrl+C)는 CANCEL 심볼 반환. 각 함수 async.
|
|
7
|
+
import { emitKeypressEvents } from "node:readline";
|
|
8
|
+
import { stdin, stdout } from "node:process";
|
|
9
|
+
|
|
10
|
+
export const CANCEL = Symbol("cancel");
|
|
11
|
+
|
|
12
|
+
// ── ANSI 헬퍼 (picocolors 대체 — 의존성 0) ───────────────────────────
|
|
13
|
+
const ESC = "\x1b[";
|
|
14
|
+
const c = {
|
|
15
|
+
reset: `${ESC}0m`, dim: `${ESC}2m`, bold: `${ESC}1m`,
|
|
16
|
+
cyan: `${ESC}36m`, green: `${ESC}32m`, gray: `${ESC}90m`, yellow: `${ESC}33m`,
|
|
17
|
+
};
|
|
18
|
+
const paint = (s, color) => `${color}${s}${c.reset}`;
|
|
19
|
+
const hideCursor = () => stdout.write(`${ESC}?25l`);
|
|
20
|
+
const showCursor = () => stdout.write(`${ESC}?25h`);
|
|
21
|
+
|
|
22
|
+
// 심볼 (clack 톤 유지)
|
|
23
|
+
const S_ACTIVE = paint("●", c.green);
|
|
24
|
+
const S_INACTIVE = paint("○", c.dim);
|
|
25
|
+
const S_CHECK_ON = paint("◼", c.green);
|
|
26
|
+
const S_CHECK_OFF = paint("◻", c.dim);
|
|
27
|
+
const S_BAR = paint("│", c.gray);
|
|
28
|
+
const S_Q = paint("◆", c.cyan);
|
|
29
|
+
const S_DONE = paint("◇", c.green);
|
|
30
|
+
|
|
31
|
+
// 여러 줄 지운 뒤 커서를 블록 시작으로 되돌리는 렌더러.
|
|
32
|
+
// prevLines 만큼 위로 올라가 지우고 새로 그린다.
|
|
33
|
+
function makeRenderer() {
|
|
34
|
+
let prevLines = 0;
|
|
35
|
+
return {
|
|
36
|
+
render(lines) {
|
|
37
|
+
if (prevLines > 0) stdout.write(`${ESC}${prevLines}A`); // 위로
|
|
38
|
+
stdout.write(`${ESC}0J`); // 커서 아래 전부 지우기
|
|
39
|
+
stdout.write(lines.join("\n") + "\n");
|
|
40
|
+
prevLines = lines.length;
|
|
41
|
+
},
|
|
42
|
+
reset() { prevLines = 0; },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// raw keypress 세션 공통 래퍼. onKey(str,key) → true 반환 시 종료.
|
|
47
|
+
// 반환값은 finalize()가 만든다. 취소 시 CANCEL.
|
|
48
|
+
function keySession(renderFn, onKey) {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
const wasRaw = stdin.isTTY ? stdin.isRaw : false;
|
|
51
|
+
emitKeypressEvents(stdin);
|
|
52
|
+
if (stdin.isTTY) stdin.setRawMode(true);
|
|
53
|
+
stdin.resume();
|
|
54
|
+
hideCursor();
|
|
55
|
+
|
|
56
|
+
const cleanup = () => {
|
|
57
|
+
stdin.removeListener("keypress", handler);
|
|
58
|
+
if (stdin.isTTY) stdin.setRawMode(wasRaw);
|
|
59
|
+
stdin.pause();
|
|
60
|
+
showCursor();
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const handler = (str, key) => {
|
|
64
|
+
key = key || {};
|
|
65
|
+
// 취소: Ctrl+C / ESC
|
|
66
|
+
if ((key.ctrl && key.name === "c") || key.name === "escape") {
|
|
67
|
+
cleanup();
|
|
68
|
+
resolve(CANCEL);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const done = onKey(str, key);
|
|
72
|
+
if (done !== undefined) {
|
|
73
|
+
cleanup();
|
|
74
|
+
resolve(done);
|
|
75
|
+
} else {
|
|
76
|
+
renderFn();
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
stdin.on("keypress", handler);
|
|
80
|
+
renderFn(); // 최초 렌더
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── 단일 선택 (방향키 + Enter) ───────────────────────────────────────
|
|
85
|
+
// options: [{value,label,hint?}]. 반환: 선택 value 또는 CANCEL.
|
|
86
|
+
export async function select({ message, options, initialIndex = 0 }) {
|
|
87
|
+
if (!stdin.isTTY) {
|
|
88
|
+
// 비-TTY: 기본값(첫 항목) 반환 — 파이프 환경 방어
|
|
89
|
+
return options[initialIndex]?.value;
|
|
90
|
+
}
|
|
91
|
+
const r = makeRenderer();
|
|
92
|
+
let idx = Math.max(0, Math.min(initialIndex, options.length - 1));
|
|
93
|
+
|
|
94
|
+
const draw = () => {
|
|
95
|
+
const lines = [S_BAR, `${S_Q} ${paint(message, c.bold)}`];
|
|
96
|
+
options.forEach((o, i) => {
|
|
97
|
+
const sel = i === idx;
|
|
98
|
+
const marker = sel ? S_ACTIVE : S_INACTIVE;
|
|
99
|
+
const label = sel ? paint(o.label, c.cyan) : o.label;
|
|
100
|
+
const hint = o.hint && sel ? paint(` (${o.hint})`, c.dim) : "";
|
|
101
|
+
lines.push(`${S_BAR} ${marker} ${label}${hint}`);
|
|
102
|
+
});
|
|
103
|
+
lines.push(paint(`└ ↑/↓ 이동 · Enter 확정 · ESC 취소`, c.gray));
|
|
104
|
+
r.render(lines);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const result = await keySession(draw, (str, key) => {
|
|
108
|
+
if (key.name === "up" || key.name === "k") { idx = (idx - 1 + options.length) % options.length; return; }
|
|
109
|
+
if (key.name === "down" || key.name === "j") { idx = (idx + 1) % options.length; return; }
|
|
110
|
+
// 숫자 점프 (1-9)
|
|
111
|
+
if (/^[1-9]$/.test(str || "")) {
|
|
112
|
+
const n = Number(str) - 1;
|
|
113
|
+
if (n < options.length) { idx = n; return; }
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (key.name === "return" || key.name === "enter") return options[idx].value;
|
|
117
|
+
return; // 그 외 키: 무시하고 계속
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// 확정 화면 다시 그리기 (◇ 완료 심볼 + 선택값)
|
|
121
|
+
if (result !== CANCEL) {
|
|
122
|
+
const chosen = options.find((o) => o.value === result);
|
|
123
|
+
r.render([S_BAR, `${S_DONE} ${paint(message, c.dim)}`, `${S_BAR} ${paint(chosen?.label ?? "", c.dim)}`]);
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── 다중 선택 (Space 토글 + Enter) ──────────────────────────────────
|
|
129
|
+
// options: [{value,label,hint?,disabled?}]. 반환: 선택 value 배열 또는 CANCEL.
|
|
130
|
+
export async function multiselect({ message, options, initialValues = [], required = false }) {
|
|
131
|
+
if (!stdin.isTTY) {
|
|
132
|
+
return initialValues.length ? [...initialValues] : (required ? [options[0]?.value].filter(Boolean) : []);
|
|
133
|
+
}
|
|
134
|
+
const r = makeRenderer();
|
|
135
|
+
let idx = 0;
|
|
136
|
+
const chosen = new Set(initialValues);
|
|
137
|
+
let warn = "";
|
|
138
|
+
|
|
139
|
+
const draw = () => {
|
|
140
|
+
const lines = [S_BAR, `${S_Q} ${paint(message, c.bold)}`];
|
|
141
|
+
options.forEach((o, i) => {
|
|
142
|
+
const cur = i === idx;
|
|
143
|
+
const box = chosen.has(o.value) ? S_CHECK_ON : S_CHECK_OFF;
|
|
144
|
+
const pointer = cur ? paint("❯", c.cyan) : " ";
|
|
145
|
+
const label = cur ? paint(o.label, c.cyan) : (o.disabled ? paint(o.label, c.dim) : o.label);
|
|
146
|
+
const hint = o.hint ? paint(` (${o.hint})`, c.dim) : "";
|
|
147
|
+
lines.push(`${S_BAR} ${pointer} ${box} ${label}${hint}`);
|
|
148
|
+
});
|
|
149
|
+
if (warn) lines.push(paint(` ${warn}`, c.yellow));
|
|
150
|
+
lines.push(paint(`└ ↑/↓ 이동 · Space 토글 · Enter 확정 · ESC 취소`, c.gray));
|
|
151
|
+
r.render(lines);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const result = await keySession(draw, (str, key) => {
|
|
155
|
+
warn = "";
|
|
156
|
+
if (key.name === "up" || key.name === "k") { idx = (idx - 1 + options.length) % options.length; return; }
|
|
157
|
+
if (key.name === "down" || key.name === "j") { idx = (idx + 1) % options.length; return; }
|
|
158
|
+
if (key.name === "space" || str === " ") {
|
|
159
|
+
const o = options[idx];
|
|
160
|
+
if (o.disabled) { warn = "선택할 수 없는 항목입니다."; return; }
|
|
161
|
+
if (chosen.has(o.value)) chosen.delete(o.value); else chosen.add(o.value);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (key.name === "return" || key.name === "enter") {
|
|
165
|
+
if (required && chosen.size === 0) { warn = "최소 1개 이상 선택하세요."; return; }
|
|
166
|
+
return [...chosen];
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (result !== CANCEL) {
|
|
172
|
+
const labels = options.filter((o) => chosen.has(o.value)).map((o) => o.label).join(", ") || "(없음)";
|
|
173
|
+
r.render([S_BAR, `${S_DONE} ${paint(message, c.dim)}`, `${S_BAR} ${paint(labels, c.dim)}`]);
|
|
174
|
+
}
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── 텍스트 입력 (Enter 확정, 빈 입력=기본값) ─────────────────────────
|
|
179
|
+
// 반환: 입력 문자열(빈 입력 시 defaultValue) 또는 CANCEL.
|
|
180
|
+
export async function text({ message, defaultValue = "" }) {
|
|
181
|
+
if (!stdin.isTTY) return defaultValue;
|
|
182
|
+
return new Promise((resolve) => {
|
|
183
|
+
const wasRaw = stdin.isTTY ? stdin.isRaw : false;
|
|
184
|
+
emitKeypressEvents(stdin);
|
|
185
|
+
if (stdin.isTTY) stdin.setRawMode(true);
|
|
186
|
+
stdin.resume();
|
|
187
|
+
let buf = "";
|
|
188
|
+
|
|
189
|
+
const prompt = () => {
|
|
190
|
+
stdout.write(`\r${ESC}0K`); // 줄 초기화
|
|
191
|
+
const shown = buf.length ? buf : paint(defaultValue || "", c.dim);
|
|
192
|
+
stdout.write(`${S_Q} ${paint(message, c.bold)} ${shown}`);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const cleanup = () => {
|
|
196
|
+
stdin.removeListener("keypress", handler);
|
|
197
|
+
if (stdin.isTTY) stdin.setRawMode(wasRaw);
|
|
198
|
+
stdin.pause();
|
|
199
|
+
stdout.write("\n");
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const handler = (str, key) => {
|
|
203
|
+
key = key || {};
|
|
204
|
+
if ((key.ctrl && key.name === "c") || key.name === "escape") { cleanup(); resolve(CANCEL); return; }
|
|
205
|
+
if (key.name === "return" || key.name === "enter") {
|
|
206
|
+
cleanup();
|
|
207
|
+
resolve(buf.length ? buf : defaultValue);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (key.name === "backspace") { buf = buf.slice(0, -1); prompt(); return; }
|
|
211
|
+
// 일반 문자 (제어문자 제외)
|
|
212
|
+
if (str && !key.ctrl && !key.meta && str.length === 1 && str >= " ") { buf += str; prompt(); return; }
|
|
213
|
+
};
|
|
214
|
+
stdin.on("keypress", handler);
|
|
215
|
+
prompt();
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Y/N 확인 (←→ 또는 y/n, Enter 확정) ──────────────────────────────
|
|
220
|
+
// 반환: true/false 또는 CANCEL.
|
|
221
|
+
export async function confirm({ message, initialValue = true }) {
|
|
222
|
+
if (!stdin.isTTY) return initialValue;
|
|
223
|
+
const r = makeRenderer();
|
|
224
|
+
let val = initialValue;
|
|
225
|
+
|
|
226
|
+
const draw = () => {
|
|
227
|
+
const yes = val ? paint("● 예", c.green) : paint("○ 예", c.dim);
|
|
228
|
+
const no = !val ? paint("● 아니오", c.green) : paint("○ 아니오", c.dim);
|
|
229
|
+
r.render([S_BAR, `${S_Q} ${paint(message, c.bold)}`, `${S_BAR} ${yes} ${no}`,
|
|
230
|
+
paint(`└ ←/→ 또는 y/n · Enter 확정 · ESC 취소`, c.gray)]);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const result = await keySession(draw, (str, key) => {
|
|
234
|
+
if (key.name === "left" || key.name === "right" || key.name === "tab") { val = !val; return; }
|
|
235
|
+
if ((str || "").toLowerCase() === "y") { val = true; return; }
|
|
236
|
+
if ((str || "").toLowerCase() === "n") { val = false; return; }
|
|
237
|
+
if (key.name === "return" || key.name === "enter") return val;
|
|
238
|
+
return;
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
if (result !== CANCEL) {
|
|
242
|
+
r.render([S_BAR, `${S_DONE} ${paint(message, c.dim)}`, `${S_BAR} ${paint(result ? "예" : "아니오", c.dim)}`]);
|
|
243
|
+
}
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── 출력 헬퍼 (clack intro/outro/note/cancel 대체) ──────────────────
|
|
248
|
+
export function intro(text) { stdout.write(`\n${paint("┌", c.gray)} ${paint(text, c.bold)}\n`); }
|
|
249
|
+
export function outro(text) { stdout.write(`${paint("└", c.gray)} ${paint(text, c.green)}\n\n`); }
|
|
250
|
+
export function cancelMessage(text = "취소했습니다.") { stdout.write(`${paint("■", c.yellow)} ${paint(text, c.yellow)}\n`); }
|
|
251
|
+
export function note(text, title = "") {
|
|
252
|
+
const lines = String(text).split("\n");
|
|
253
|
+
stdout.write(`${paint("○", c.cyan)} ${paint(title, c.bold)}\n`);
|
|
254
|
+
for (const l of lines) stdout.write(`${S_BAR} ${l}\n`);
|
|
255
|
+
stdout.write(`${S_BAR}\n`);
|
|
256
|
+
}
|
|
257
|
+
export function log(text = "") { stdout.write(`${text}\n`); }
|
package/src/ui/skills-prompts.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
// IDE Skills 대화형 프롬프트 (.sh 라우터 choose_menu 등가)
|
|
2
|
-
|
|
1
|
+
// IDE Skills 대화형 프롬프트 (.sh 라우터 choose_menu 등가).
|
|
2
|
+
// node:readline 기반 자체 엔진 사용 (@clack/prompts 는 Windows TTY Enter 버그로 제거).
|
|
3
|
+
import * as engine from "./readline-engine.js";
|
|
3
4
|
|
|
4
|
-
export const CANCEL =
|
|
5
|
-
const wrap = (v) => (clack.isCancel(v) ? CANCEL : v);
|
|
5
|
+
export const CANCEL = engine.CANCEL;
|
|
6
6
|
|
|
7
7
|
// 동작 선택: 설치/업데이트 · 제거 · 그대로. 취소=skip.
|
|
8
8
|
export async function selectAction() {
|
|
9
|
-
const v = await
|
|
9
|
+
const v = await engine.select({
|
|
10
10
|
message: "AI 스킬을 어떻게 할까요?",
|
|
11
11
|
options: [
|
|
12
12
|
{ value: "apply", label: "설치 / 업데이트 — 최신 상태로 맞추기" },
|
|
@@ -14,8 +14,7 @@ export async function selectAction() {
|
|
|
14
14
|
{ value: "skip", label: "그대로 두기" },
|
|
15
15
|
],
|
|
16
16
|
});
|
|
17
|
-
|
|
18
|
-
return w === CANCEL ? "skip" : w;
|
|
17
|
+
return v === CANCEL ? "skip" : v;
|
|
19
18
|
}
|
|
20
19
|
|
|
21
20
|
// IDE 멀티셀렉트. choices=[{id,label,disabled}], preselect=[id...], action.
|
|
@@ -24,14 +23,14 @@ export async function selectTargets(choices, preselect = [], action = "apply") {
|
|
|
24
23
|
value: c.id,
|
|
25
24
|
label: c.label + (c.disabled ? " (미감지)" : ""),
|
|
26
25
|
hint: c.disabled ? "CLI 없음" : undefined,
|
|
26
|
+
disabled: c.disabled,
|
|
27
27
|
}));
|
|
28
|
-
|
|
28
|
+
return engine.multiselect({
|
|
29
29
|
message: `${action === "apply" ? "설치 / 업데이트" : "제거"}할 IDE를 고르세요 (Space 토글, Enter 확정)`,
|
|
30
30
|
options,
|
|
31
31
|
initialValues: preselect,
|
|
32
32
|
required: false,
|
|
33
33
|
});
|
|
34
|
-
return wrap(v);
|
|
35
34
|
}
|
|
36
35
|
|
|
37
|
-
export function note(text, title) {
|
|
36
|
+
export function note(text, title) { engine.note(text, title); }
|