@no-k/hermes 0.1.0 → 0.2.0
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 +175 -12
- package/catalog.json +2592 -98
- package/dist/bin/hermes.js +87 -0
- package/dist/lib/catalog-query.js +63 -0
- package/dist/lib/catalog-selection.js +199 -0
- package/dist/lib/catalog.js +144 -0
- package/dist/lib/command.js +50 -0
- package/dist/lib/config-syntax.js +210 -0
- package/dist/lib/conflict-selection.js +89 -0
- package/dist/lib/contracts.js +4 -0
- package/dist/lib/files.js +94 -0
- package/dist/lib/install-prompts.js +106 -0
- package/dist/lib/install-view.js +61 -0
- package/dist/lib/install-wizard.js +105 -0
- package/dist/lib/install.js +184 -0
- package/dist/lib/installation-status.js +80 -0
- package/dist/lib/json.js +19 -0
- package/dist/lib/picker-view.js +81 -0
- package/dist/lib/picker.js +135 -0
- package/dist/lib/project-config.js +171 -0
- package/dist/lib/report.js +89 -0
- package/dist/lib/terminal-input.js +61 -0
- package/dist/lib/terminal-style.js +15 -0
- package/dist/lib/terminal-text.js +75 -0
- package/dist/lib/terminal.js +133 -0
- package/dist/lib/versions.js +251 -0
- package/docs/interactive-install.md +405 -0
- package/docs/module-versioning.md +104 -0
- package/package.json +24 -4
- package/bin/hermes.mjs +0 -159
- package/lib/catalog.mjs +0 -51
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { valid } from "semver";
|
|
3
|
+
import { UTILITY_SCOPES, isItemName, isModuleSnapshot, isUtilityScope } from "./catalog.js";
|
|
4
|
+
import { appendLiteralEntries, applySourceEdits, parseConfigSource } from "./config-syntax.js";
|
|
5
|
+
import { DEFAULT_INSTALL_DIRECTORIES, } from "./contracts.js";
|
|
6
|
+
import { readDestination } from "./files.js";
|
|
7
|
+
import { isRecord } from "./json.js";
|
|
8
|
+
export const PROJECT_CONFIG_FILENAME = "no-k.config.ts";
|
|
9
|
+
function validDirectory(value) {
|
|
10
|
+
return typeof value === "string" && value.trim().length > 0 && !value.includes("\0");
|
|
11
|
+
}
|
|
12
|
+
export function resolveInstallDirectory(directory, cwd) {
|
|
13
|
+
if (!validDirectory(directory))
|
|
14
|
+
throw new Error("Specify a non-empty installation directory.");
|
|
15
|
+
return path.resolve(cwd, directory);
|
|
16
|
+
}
|
|
17
|
+
export function validateProjectConfig(value) {
|
|
18
|
+
if (!isRecord(value) || value.schemaVersion !== 1 || !isRecord(value.directories)) {
|
|
19
|
+
throw new Error("Invalid no-k.config.ts: expected schemaVersion: 1 and a directories object.");
|
|
20
|
+
}
|
|
21
|
+
const directories = {};
|
|
22
|
+
for (const scope of UTILITY_SCOPES) {
|
|
23
|
+
const directory = value.directories[scope];
|
|
24
|
+
if (directory !== undefined) {
|
|
25
|
+
if (!validDirectory(directory))
|
|
26
|
+
throw new Error("Invalid no-k.config.ts: directories." + scope);
|
|
27
|
+
directories[scope] = directory;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const installedValue = value.installed === undefined ? [] : value.installed;
|
|
31
|
+
if (!Array.isArray(installedValue))
|
|
32
|
+
throw new Error("Invalid no-k.config.ts: installed must be an array.");
|
|
33
|
+
const entries = installedValue;
|
|
34
|
+
const installed = entries.map((entry, index) => {
|
|
35
|
+
if (!isRecord(entry) || !isItemName(entry.name) || !isUtilityScope(entry.scope) || !validDirectory(entry.directory))
|
|
36
|
+
throw new Error("Invalid no-k.config.ts: installed[" + index + "]");
|
|
37
|
+
const source = entry.source;
|
|
38
|
+
if (source !== undefined &&
|
|
39
|
+
(!isRecord(source) ||
|
|
40
|
+
typeof source.catalogVersion !== "string" ||
|
|
41
|
+
typeof source.requestedVersion !== "string" ||
|
|
42
|
+
valid(source.requestedVersion) !== source.requestedVersion ||
|
|
43
|
+
(source.status !== "verified" && source.status !== "modified") ||
|
|
44
|
+
!Array.isArray(source.modules) ||
|
|
45
|
+
!source.modules.length ||
|
|
46
|
+
!source.modules.every(isModuleSnapshot) ||
|
|
47
|
+
new Set(source.modules.map((module) => module.packageName)).size !== source.modules.length ||
|
|
48
|
+
!source.modules.some((module) => module.name === entry.name && module.scope === entry.scope))) {
|
|
49
|
+
throw new Error("Invalid no-k.config.ts: installed[" + index + "].source");
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
name: entry.name,
|
|
53
|
+
scope: entry.scope,
|
|
54
|
+
directory: entry.directory,
|
|
55
|
+
...(source !== undefined ? { source: source } : {}),
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
return { schemaVersion: 1, directories, installed };
|
|
59
|
+
}
|
|
60
|
+
export function loadProjectConfig(cwd) {
|
|
61
|
+
const destination = path.resolve(cwd, PROJECT_CONFIG_FILENAME);
|
|
62
|
+
const content = readDestination(destination);
|
|
63
|
+
if (content === null)
|
|
64
|
+
return { status: "missing", destination };
|
|
65
|
+
const value = validateProjectConfig(parseConfigSource(content).value);
|
|
66
|
+
return { status: "loaded", destination, content, value };
|
|
67
|
+
}
|
|
68
|
+
export function initialInstallDirectories(config, directory) {
|
|
69
|
+
if (directory !== undefined) {
|
|
70
|
+
resolveInstallDirectory(directory, path.dirname(config.destination));
|
|
71
|
+
return { core: directory, lib: directory };
|
|
72
|
+
}
|
|
73
|
+
return { ...DEFAULT_INSTALL_DIRECTORIES, ...(config.status === "loaded" ? config.value.directories : {}) };
|
|
74
|
+
}
|
|
75
|
+
function installationKey(item, cwd) {
|
|
76
|
+
return JSON.stringify([item.scope, item.name, resolveInstallDirectory(item.directory, cwd)]);
|
|
77
|
+
}
|
|
78
|
+
export function mergeProjectConfig(config, targets) {
|
|
79
|
+
const current = config.status === "loaded" ? config.value : { schemaVersion: 1, directories: {}, installed: [] };
|
|
80
|
+
const directories = { ...current.directories };
|
|
81
|
+
const installed = [...current.installed];
|
|
82
|
+
const cwd = path.dirname(config.destination);
|
|
83
|
+
const keys = new Set(installed.map((item) => installationKey(item, cwd)));
|
|
84
|
+
for (const target of targets) {
|
|
85
|
+
resolveInstallDirectory(target.directory, cwd);
|
|
86
|
+
directories[target.scope] = target.directory;
|
|
87
|
+
for (const name of target.names) {
|
|
88
|
+
const item = { name, scope: target.scope, directory: target.directory };
|
|
89
|
+
const key = installationKey(item, cwd);
|
|
90
|
+
if (!keys.has(key)) {
|
|
91
|
+
installed.push(item);
|
|
92
|
+
keys.add(key);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return validateProjectConfig({ schemaVersion: 1, directories, installed });
|
|
97
|
+
}
|
|
98
|
+
export function planProjectConfig(config, next) {
|
|
99
|
+
const value = validateProjectConfig(next);
|
|
100
|
+
let content;
|
|
101
|
+
if (config.status === "missing")
|
|
102
|
+
content = "export default " + JSON.stringify(value, null, 2) + ";\n";
|
|
103
|
+
else {
|
|
104
|
+
const root = parseConfigSource(config.content);
|
|
105
|
+
const current = validateProjectConfig(root.value);
|
|
106
|
+
const properties = root.properties;
|
|
107
|
+
const directories = properties?.get("directories");
|
|
108
|
+
if (!directories?.properties)
|
|
109
|
+
throw new Error("Cannot update no-k.config.ts directories safely.");
|
|
110
|
+
const edits = [];
|
|
111
|
+
const newDirectories = [];
|
|
112
|
+
for (const scope of UTILITY_SCOPES) {
|
|
113
|
+
const directory = value.directories[scope];
|
|
114
|
+
if (directory !== undefined && directory !== current.directories[scope]) {
|
|
115
|
+
const previous = directories.properties.get(scope);
|
|
116
|
+
const text = JSON.stringify(directory);
|
|
117
|
+
if (previous)
|
|
118
|
+
edits.push({ start: previous.start, end: previous.end, text });
|
|
119
|
+
else
|
|
120
|
+
newDirectories.push(scope + ": " + text);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
edits.push(...appendLiteralEntries(config.content, directories, newDirectories));
|
|
124
|
+
if (value.installed.length < current.installed.length)
|
|
125
|
+
throw new Error("Cannot remove or rewrite existing installation records automatically.");
|
|
126
|
+
const newItems = value.installed.slice(current.installed.length);
|
|
127
|
+
const installed = properties?.get("installed");
|
|
128
|
+
if (installed) {
|
|
129
|
+
if (!installed.elements)
|
|
130
|
+
throw new Error("Cannot update no-k.config.ts installed safely.");
|
|
131
|
+
for (let index = 0; index < current.installed.length; index++) {
|
|
132
|
+
const previous = current.installed[index];
|
|
133
|
+
const next = value.installed[index];
|
|
134
|
+
const literal = installed.elements[index];
|
|
135
|
+
if (!previous ||
|
|
136
|
+
!next ||
|
|
137
|
+
!literal?.properties ||
|
|
138
|
+
previous.name !== next.name ||
|
|
139
|
+
previous.scope !== next.scope ||
|
|
140
|
+
previous.directory !== next.directory ||
|
|
141
|
+
(previous.source && !next.source)) {
|
|
142
|
+
throw new Error("Cannot remove or rewrite existing installation records automatically.");
|
|
143
|
+
}
|
|
144
|
+
if (next.source && JSON.stringify(previous.source) !== JSON.stringify(next.source)) {
|
|
145
|
+
const source = literal.properties.get("source");
|
|
146
|
+
const text = JSON.stringify(next.source);
|
|
147
|
+
if (source)
|
|
148
|
+
edits.push({ start: source.start, end: source.end, text });
|
|
149
|
+
else
|
|
150
|
+
edits.push(...appendLiteralEntries(config.content, literal, ["source: " + text]));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
edits.push(...appendLiteralEntries(config.content, installed, newItems.map((item) => JSON.stringify(item))));
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
edits.push(...appendLiteralEntries(config.content, root, ["installed: " + JSON.stringify(value.installed, null, 2)]));
|
|
157
|
+
}
|
|
158
|
+
content = applySourceEdits(config.content, edits);
|
|
159
|
+
if (JSON.stringify(validateProjectConfig(parseConfigSource(content).value)) !== JSON.stringify(value)) {
|
|
160
|
+
throw new Error("Cannot update no-k.config.ts safely; review directories and installed.");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const existingContent = config.status === "loaded" ? config.content : null;
|
|
164
|
+
return {
|
|
165
|
+
file: PROJECT_CONFIG_FILENAME,
|
|
166
|
+
destination: config.destination,
|
|
167
|
+
content,
|
|
168
|
+
existingContent,
|
|
169
|
+
action: existingContent === null ? "create" : existingContent === content ? "keep" : "replace",
|
|
170
|
+
};
|
|
171
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { resolveInstallDirectory } from "./project-config.js";
|
|
3
|
+
import { sourceHash } from "./versions.js";
|
|
4
|
+
export function formatInstallReview(plan, catalog) {
|
|
5
|
+
const cwd = path.dirname(plan.config.destination);
|
|
6
|
+
const actions = { create: "생성", keep: "유지", replace: "교체" };
|
|
7
|
+
const count = (action) => plan.files.filter((file) => file.action === action).length;
|
|
8
|
+
const resultingFiles = new Map([...plan.compatibilityGuards, ...plan.files].map((file) => [file.destination, file]));
|
|
9
|
+
const moduleLabel = (module, directory) => {
|
|
10
|
+
const verified = Object.entries(module.files).every(([file, hash]) => {
|
|
11
|
+
const planned = resultingFiles.get(path.join(directory, file));
|
|
12
|
+
const content = planned?.action === "keep" ? planned.existingContent : planned?.content;
|
|
13
|
+
return content != null && sourceHash(content) === hash;
|
|
14
|
+
});
|
|
15
|
+
return module.name + (verified ? "@" + module.version : " (로컬 수정 · 버전 미확인)");
|
|
16
|
+
};
|
|
17
|
+
return [
|
|
18
|
+
...plan.compatibilityErrors.flatMap((error) => ["! " + error]),
|
|
19
|
+
...(plan.compatibilityErrors.length ? [""] : []),
|
|
20
|
+
`파일: 생성 ${count("create")} · 유지 ${count("keep")} · 교체 ${count("replace")}`,
|
|
21
|
+
"",
|
|
22
|
+
...plan.targets.flatMap((target) => {
|
|
23
|
+
const items = catalog.items.filter((item) => target.names.includes(item.name));
|
|
24
|
+
const directory = resolveInstallDirectory(target.directory, cwd);
|
|
25
|
+
const records = plan.nextConfig.installed.filter((record) => record.scope === target.scope &&
|
|
26
|
+
target.names.includes(record.name) &&
|
|
27
|
+
resolveInstallDirectory(record.directory, cwd) === directory);
|
|
28
|
+
const modules = records.flatMap((record) => record.source?.modules ?? []);
|
|
29
|
+
const helpers = new Map(modules
|
|
30
|
+
.filter((module) => !target.names.includes(module.name))
|
|
31
|
+
.map((module) => [module.packageName, moduleLabel(module, directory)]));
|
|
32
|
+
return [
|
|
33
|
+
`${target.scope === "core" ? "Core" : "라이브러리"} → ${directory}`,
|
|
34
|
+
...items.map((item) => {
|
|
35
|
+
const requested = `${item.name}@${item.version}`;
|
|
36
|
+
const module = modules.find((module) => module.name === item.name);
|
|
37
|
+
const resulting = module ? moduleLabel(module, directory) : requested;
|
|
38
|
+
return " " + requested + (requested === resulting ? "" : ` (요청) → ${resulting}`);
|
|
39
|
+
}),
|
|
40
|
+
...(helpers.size ? [" 의존 모듈: " + [...helpers.values()].join(", ")] : []),
|
|
41
|
+
"",
|
|
42
|
+
];
|
|
43
|
+
}),
|
|
44
|
+
"파일별 작업",
|
|
45
|
+
...plan.files.map((file) => `[${actions[file.action]}] ${file.destination}${file.action === "keep"
|
|
46
|
+
? file.existingContent === file.content
|
|
47
|
+
? " (내용 동일)"
|
|
48
|
+
: " (기존 수정 내용 보존)"
|
|
49
|
+
: ""}`),
|
|
50
|
+
"",
|
|
51
|
+
`설정 ${plan.configFile.action === "replace" ? "갱신" : actions[plan.configFile.action]}: ${plan.configFile.destination}`,
|
|
52
|
+
"선택한 경로와 설치 모듈의 버전·해시를 기록합니다.",
|
|
53
|
+
"",
|
|
54
|
+
"npm 의존성: " + (plan.dependencies.join(", ") || "없음"),
|
|
55
|
+
"개발 의존성: " + (plan.devDependencies.join(", ") || "없음"),
|
|
56
|
+
...plan.runtimeRequirements.map((item) => `${item.name}: ${item.runtime === "node" ? "Node.js 필요" : "DOM API와 DOM TypeScript 타입 필요"}`),
|
|
57
|
+
"npm 패키지는 설치 완료 후 안내되는 명령으로 직접 설치해 주세요.",
|
|
58
|
+
...(plan.dryRun ? ["", "DRY RUN: 유틸 파일과 no-k.config.ts를 쓰지 않습니다."] : []),
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
export function formatFilePlan(file, dryRun) {
|
|
62
|
+
return ((dryRun ? "[dry-run] " : "") +
|
|
63
|
+
file.action +
|
|
64
|
+
" " +
|
|
65
|
+
file.destination +
|
|
66
|
+
(file.action === "keep" && file.existingContent !== file.content ? " (kept local contents)" : ""));
|
|
67
|
+
}
|
|
68
|
+
export function formatInstallSummary(plan, names, dryRun) {
|
|
69
|
+
const lines = [];
|
|
70
|
+
const modified = plan.files.filter((file) => file.action === "keep" && file.existingContent !== file.content);
|
|
71
|
+
if (modified.length)
|
|
72
|
+
lines.push(`\nKept local contents in ${modified.length} file(s). Utility versions below are requested versions.`);
|
|
73
|
+
if (plan.dependencies.length || plan.devDependencies.length) {
|
|
74
|
+
lines.push("\nThese utilities need npm packages. Install them in your consuming project:");
|
|
75
|
+
if (plan.dependencies.length)
|
|
76
|
+
lines.push(" npm install " + plan.dependencies.map((value) => JSON.stringify(value)).join(" "));
|
|
77
|
+
if (plan.devDependencies.length) {
|
|
78
|
+
lines.push(" npm install --save-dev " + plan.devDependencies.map((value) => JSON.stringify(value)).join(" "));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const item of plan.runtimeRequirements) {
|
|
82
|
+
if (item.runtime === "node")
|
|
83
|
+
lines.push("\n" + item.name + ": requires Node.js (imports a Node.js builtin).");
|
|
84
|
+
if (item.runtime === "dom")
|
|
85
|
+
lines.push("\n" + item.name + ": requires DOM APIs and DOM TypeScript types.");
|
|
86
|
+
}
|
|
87
|
+
lines.push("\n" + (dryRun ? "Planned" : "Added") + " " + names.join(", "));
|
|
88
|
+
return lines.join("\n");
|
|
89
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useInput, usePaste } from "ink";
|
|
2
|
+
import { useReducer, useRef } from "react";
|
|
3
|
+
export function useTerminalInput(onKey, onPaste) {
|
|
4
|
+
const [, refresh] = useReducer((value) => value + 1, 0);
|
|
5
|
+
const refreshPending = useRef(false);
|
|
6
|
+
const refreshSoon = () => {
|
|
7
|
+
if (refreshPending.current)
|
|
8
|
+
return;
|
|
9
|
+
refreshPending.current = true;
|
|
10
|
+
queueMicrotask(() => {
|
|
11
|
+
refreshPending.current = false;
|
|
12
|
+
refresh();
|
|
13
|
+
});
|
|
14
|
+
};
|
|
15
|
+
useInput((input, key) => {
|
|
16
|
+
if (key.eventType === "release")
|
|
17
|
+
return;
|
|
18
|
+
const controls = {
|
|
19
|
+
up: key.upArrow,
|
|
20
|
+
down: key.downArrow,
|
|
21
|
+
left: key.leftArrow,
|
|
22
|
+
right: key.rightArrow,
|
|
23
|
+
pageup: key.pageUp,
|
|
24
|
+
pagedown: key.pageDown,
|
|
25
|
+
home: key.home,
|
|
26
|
+
end: key.end,
|
|
27
|
+
return: key.return,
|
|
28
|
+
escape: key.escape,
|
|
29
|
+
tab: key.tab,
|
|
30
|
+
backspace: key.backspace || key.delete,
|
|
31
|
+
};
|
|
32
|
+
const name = Object.entries(controls).find(([, pressed]) => pressed)?.[0] ?? (input === " " ? "space" : input.toLowerCase());
|
|
33
|
+
if (!key.ctrl && !key.meta && input.length > 1 && !Object.values(controls).some(Boolean)) {
|
|
34
|
+
// Multiple keystrokes can arrive in one read. Bracketed paste uses usePaste below.
|
|
35
|
+
for (const character of input) {
|
|
36
|
+
const control = character.charCodeAt(0);
|
|
37
|
+
const name = character === "\r" || character === "\n"
|
|
38
|
+
? "return"
|
|
39
|
+
: character === "\t"
|
|
40
|
+
? "tab"
|
|
41
|
+
: character === " "
|
|
42
|
+
? "space"
|
|
43
|
+
: control > 0 && control < 27
|
|
44
|
+
? String.fromCharCode(control + 96)
|
|
45
|
+
: character.toLowerCase();
|
|
46
|
+
onKey(character, {
|
|
47
|
+
name,
|
|
48
|
+
ctrl: control > 0 && control < 27 && name !== "return" && name !== "tab",
|
|
49
|
+
shift: key.shift,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else
|
|
54
|
+
onKey(input, { name, ctrl: key.ctrl, meta: key.meta, shift: key.shift });
|
|
55
|
+
refreshSoon();
|
|
56
|
+
});
|
|
57
|
+
usePaste((text) => {
|
|
58
|
+
onPaste(text.replace(/[\r\n\t]/g, " "));
|
|
59
|
+
refreshSoon();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
import { fitTerminalLine } from "./terminal-text.js";
|
|
4
|
+
export const theme = {
|
|
5
|
+
accent: "#75e2d0",
|
|
6
|
+
purple: "#ad98f5",
|
|
7
|
+
muted: "#8c96ad",
|
|
8
|
+
border: "#526078",
|
|
9
|
+
focus: "#253d54",
|
|
10
|
+
bright: "#d9faff",
|
|
11
|
+
warning: "#ffc47e",
|
|
12
|
+
};
|
|
13
|
+
export function Line({ text, width, color, bold = false, }) {
|
|
14
|
+
return (_jsx(Text, { color: color, bold: bold, wrap: "truncate-end", children: fitTerminalLine(text, width) }));
|
|
15
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const segments = new Intl.Segmenter("und", { granularity: "grapheme" });
|
|
2
|
+
export function terminalText(value) {
|
|
3
|
+
// 파일 내용의 제어문자도 검토할 수 있도록 실행하거나 지우지 않고 표기한다.
|
|
4
|
+
return value.replace(/\t/g, " ").replace(/[\p{Cc}\p{Cs}\u202A-\u202E\u2066-\u2069]/gu, (character) => {
|
|
5
|
+
const point = character.codePointAt(0) ?? 0;
|
|
6
|
+
return point <= 0xff ? "\\x" + point.toString(16).padStart(2, "0") : "\\u" + point.toString(16).padStart(4, "0");
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
function cellWidth(grapheme) {
|
|
10
|
+
if (/^[\p{Mark}\p{Cf}]+$/u.test(grapheme))
|
|
11
|
+
return 0;
|
|
12
|
+
if (/\p{Emoji_Presentation}|\uFE0F|\u20E3/u.test(grapheme))
|
|
13
|
+
return 2;
|
|
14
|
+
const point = grapheme.codePointAt(0) ?? 0;
|
|
15
|
+
return point >= 0x1100 &&
|
|
16
|
+
(point <= 0x115f ||
|
|
17
|
+
point === 0x2329 ||
|
|
18
|
+
point === 0x232a ||
|
|
19
|
+
(point >= 0x2e80 && point <= 0xa4cf && point !== 0x303f) ||
|
|
20
|
+
(point >= 0xac00 && point <= 0xd7a3) ||
|
|
21
|
+
(point >= 0xf900 && point <= 0xfaff) ||
|
|
22
|
+
(point >= 0xfe10 && point <= 0xfe19) ||
|
|
23
|
+
(point >= 0xfe30 && point <= 0xfe6f) ||
|
|
24
|
+
(point >= 0xff01 && point <= 0xff60) ||
|
|
25
|
+
(point >= 0xffe0 && point <= 0xffe6) ||
|
|
26
|
+
(point >= 0x20000 && point <= 0x3fffd))
|
|
27
|
+
? 2
|
|
28
|
+
: 1;
|
|
29
|
+
}
|
|
30
|
+
export function terminalWidth(value) {
|
|
31
|
+
return [...segments.segment(terminalText(value))].reduce((width, { segment }) => width + cellWidth(segment), 0);
|
|
32
|
+
}
|
|
33
|
+
export function fitTerminalLine(value, columns) {
|
|
34
|
+
if (columns < 1)
|
|
35
|
+
return "";
|
|
36
|
+
const text = terminalText(value);
|
|
37
|
+
if (terminalWidth(text) <= columns)
|
|
38
|
+
return text;
|
|
39
|
+
let result = "";
|
|
40
|
+
let width = 0;
|
|
41
|
+
for (const { segment } of segments.segment(text)) {
|
|
42
|
+
const next = cellWidth(segment);
|
|
43
|
+
if (width + next > columns - 1)
|
|
44
|
+
break;
|
|
45
|
+
result += segment;
|
|
46
|
+
width += next;
|
|
47
|
+
}
|
|
48
|
+
return result + "…";
|
|
49
|
+
}
|
|
50
|
+
export function wrapTerminalLines(lines, columns) {
|
|
51
|
+
const width = Math.max(1, columns);
|
|
52
|
+
return lines.flatMap((line) => line.split(/\r?\n/u).flatMap((part) => {
|
|
53
|
+
const wrapped = [];
|
|
54
|
+
let current = "";
|
|
55
|
+
let used = 0;
|
|
56
|
+
for (const { segment } of segments.segment(terminalText(part))) {
|
|
57
|
+
const next = cellWidth(segment);
|
|
58
|
+
if (used + next > width && current) {
|
|
59
|
+
wrapped.push(current);
|
|
60
|
+
current = "";
|
|
61
|
+
used = 0;
|
|
62
|
+
}
|
|
63
|
+
current += next > width ? "?" : segment;
|
|
64
|
+
used += Math.min(next, width);
|
|
65
|
+
}
|
|
66
|
+
wrapped.push(current);
|
|
67
|
+
return wrapped;
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
export function removeLastGrapheme(value) {
|
|
71
|
+
return [...segments.segment(value)]
|
|
72
|
+
.slice(0, -1)
|
|
73
|
+
.map(({ segment }) => segment)
|
|
74
|
+
.join("");
|
|
75
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { render } from "ink";
|
|
2
|
+
import { createElement } from "react";
|
|
3
|
+
import { InstallApp } from "./install-view.js";
|
|
4
|
+
import { PickerApp } from "./picker-view.js";
|
|
5
|
+
export async function withSelectionTerminal(run, { input, output } = { input: process.stdin, output: process.stdout }) {
|
|
6
|
+
if (!input.isTTY || !output.isTTY || !input.setRawMode || !input.ref || !input.unref)
|
|
7
|
+
throw new Error("선택 화면은 입력과 출력이 모두 연결된 대화형 터미널에서 실행해 주세요.");
|
|
8
|
+
const wasRaw = input.isRaw ?? false;
|
|
9
|
+
const wasFlowing = input.readableFlowing === true;
|
|
10
|
+
let app;
|
|
11
|
+
let exited;
|
|
12
|
+
let active;
|
|
13
|
+
let stopped = input.readableEnded || input.destroyed ? { status: "cancelled", reason: "eof" } : undefined;
|
|
14
|
+
let failure;
|
|
15
|
+
const queue = [];
|
|
16
|
+
const cancel = (result) => {
|
|
17
|
+
const previous = active;
|
|
18
|
+
active = undefined;
|
|
19
|
+
previous?.cancel(result);
|
|
20
|
+
};
|
|
21
|
+
const fail = (error) => {
|
|
22
|
+
failure = error;
|
|
23
|
+
active?.reject(error);
|
|
24
|
+
active = undefined;
|
|
25
|
+
};
|
|
26
|
+
const dispatch = (text, key) => {
|
|
27
|
+
if (!active) {
|
|
28
|
+
queue.push({ text, key });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
if (key || active.acceptsPaste())
|
|
33
|
+
active.key(text, key ?? {});
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const onKey = (text, key) => dispatch(text, key);
|
|
40
|
+
const onPaste = (text) => dispatch(text);
|
|
41
|
+
const eof = () => {
|
|
42
|
+
stopped = { status: "cancelled", reason: "eof" };
|
|
43
|
+
cancel(stopped);
|
|
44
|
+
};
|
|
45
|
+
const interrupt = () => {
|
|
46
|
+
stopped = { status: "cancelled", reason: "interrupt" };
|
|
47
|
+
cancel(stopped);
|
|
48
|
+
};
|
|
49
|
+
input.on("end", eof);
|
|
50
|
+
input.on("close", eof);
|
|
51
|
+
input.on("error", fail);
|
|
52
|
+
output.on("error", fail);
|
|
53
|
+
process.on("SIGINT", interrupt);
|
|
54
|
+
process.on("SIGTERM", interrupt);
|
|
55
|
+
function show(key, view, acceptsPaste) {
|
|
56
|
+
if (failure)
|
|
57
|
+
return Promise.reject(failure);
|
|
58
|
+
if (stopped)
|
|
59
|
+
return Promise.resolve(stopped);
|
|
60
|
+
if (active)
|
|
61
|
+
return Promise.reject(new Error("A terminal screen is already active."));
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
active = {
|
|
64
|
+
key(text, event) {
|
|
65
|
+
const result = key(text, event);
|
|
66
|
+
if (result !== undefined) {
|
|
67
|
+
active = undefined;
|
|
68
|
+
resolve(result);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
acceptsPaste,
|
|
72
|
+
cancel: resolve,
|
|
73
|
+
reject,
|
|
74
|
+
};
|
|
75
|
+
if (app)
|
|
76
|
+
app.rerender(view);
|
|
77
|
+
else {
|
|
78
|
+
// Subscribe to exit before rendering a screen that could fail synchronously.
|
|
79
|
+
app = render(null, {
|
|
80
|
+
stdin: input,
|
|
81
|
+
stdout: output,
|
|
82
|
+
stderr: output,
|
|
83
|
+
exitOnCtrlC: false,
|
|
84
|
+
patchConsole: false,
|
|
85
|
+
interactive: true,
|
|
86
|
+
alternateScreen: true,
|
|
87
|
+
incrementalRendering: true,
|
|
88
|
+
maxFps: 60,
|
|
89
|
+
});
|
|
90
|
+
exited = app.waitUntilExit();
|
|
91
|
+
void exited.catch(fail);
|
|
92
|
+
app.rerender(view);
|
|
93
|
+
}
|
|
94
|
+
while (active && queue.length) {
|
|
95
|
+
const event = queue.shift();
|
|
96
|
+
if (event)
|
|
97
|
+
dispatch(event.text, event.key);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
return await run({
|
|
103
|
+
pick(picker) {
|
|
104
|
+
return show((text, key) => picker.key(text, key), createElement(PickerApp, { picker, onKey, onPaste }), () => picker.searching);
|
|
105
|
+
},
|
|
106
|
+
directory(prompt) {
|
|
107
|
+
return show((text, key) => prompt.key(text, key), createElement(InstallApp, { prompt, onKey, onPaste }), () => true);
|
|
108
|
+
},
|
|
109
|
+
review(prompt) {
|
|
110
|
+
return show((text, key) => prompt.key(text, key), createElement(InstallApp, { prompt, onKey, onPaste }), () => false);
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
try {
|
|
116
|
+
app?.cleanup();
|
|
117
|
+
await exited;
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
input.off("end", eof);
|
|
121
|
+
input.off("close", eof);
|
|
122
|
+
input.off("error", fail);
|
|
123
|
+
output.off("error", fail);
|
|
124
|
+
process.off("SIGINT", interrupt);
|
|
125
|
+
process.off("SIGTERM", interrupt);
|
|
126
|
+
input.setRawMode(wasRaw);
|
|
127
|
+
if (wasFlowing)
|
|
128
|
+
input.resume();
|
|
129
|
+
else
|
|
130
|
+
input.pause();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|