@no-k/hermes 0.1.0 → 0.3.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.
Files changed (38) hide show
  1. package/README.md +210 -13
  2. package/catalog.json +2640 -98
  3. package/dist/bin/hermes.js +87 -0
  4. package/dist/lib/catalog-query.js +63 -0
  5. package/dist/lib/catalog-selection.js +199 -0
  6. package/dist/lib/catalog.js +144 -0
  7. package/dist/lib/command.js +50 -0
  8. package/dist/lib/config-syntax.js +210 -0
  9. package/dist/lib/conflict-selection.js +89 -0
  10. package/dist/lib/contracts.js +4 -0
  11. package/dist/lib/files.js +94 -0
  12. package/dist/lib/install-prompts.js +106 -0
  13. package/dist/lib/install-view.js +61 -0
  14. package/dist/lib/install-wizard.js +105 -0
  15. package/dist/lib/install.js +184 -0
  16. package/dist/lib/installation-status.js +80 -0
  17. package/dist/lib/json.js +19 -0
  18. package/dist/lib/picker-view.js +81 -0
  19. package/dist/lib/picker.js +135 -0
  20. package/dist/lib/project-config.js +171 -0
  21. package/dist/lib/report.js +89 -0
  22. package/dist/lib/terminal-input.js +61 -0
  23. package/dist/lib/terminal-style.js +15 -0
  24. package/dist/lib/terminal-text.js +75 -0
  25. package/dist/lib/terminal.js +133 -0
  26. package/dist/lib/versions.js +251 -0
  27. package/docs/interactive-install.md +405 -0
  28. package/docs/module-versioning.md +105 -0
  29. package/package.json +25 -4
  30. package/templates/lint/fsd/fsd.ts +180 -0
  31. package/templates/lint/fsd/import-listener.ts +41 -0
  32. package/templates/lint/fsd/index.ts +32 -0
  33. package/templates/lint/fsd/options.ts +74 -0
  34. package/templates/lint/fsd/rules/fsd-layer-imports.ts +22 -0
  35. package/templates/lint/fsd/rules/fsd-public-api.ts +20 -0
  36. package/templates/lint/fsd/rules/fsd-slice-segments.ts +33 -0
  37. package/bin/hermes.mjs +0 -159
  38. package/lib/catalog.mjs +0 -51
@@ -0,0 +1,210 @@
1
+ // 생성된 설정의 데이터 리터럴만 읽는다. 설정 파일의 코드를 실행하지 않는다.
2
+ class ConfigParser {
3
+ source;
4
+ offset = 0;
5
+ constructor(source) {
6
+ this.source = source;
7
+ }
8
+ fail(message) {
9
+ const lines = this.source.slice(0, this.offset).split("\n");
10
+ throw new Error("Unsupported no-k.config.ts syntax at " +
11
+ lines.length +
12
+ ":" +
13
+ ((lines.at(-1)?.length ?? 0) + 1) +
14
+ ": " +
15
+ message +
16
+ ". Use an export default object with literal values.");
17
+ }
18
+ skip() {
19
+ while (this.offset < this.source.length) {
20
+ const rest = this.source.slice(this.offset);
21
+ const whitespace = /^\s+/.exec(rest);
22
+ if (whitespace)
23
+ this.offset += whitespace[0].length;
24
+ else if (rest.startsWith("//")) {
25
+ const end = this.source.indexOf("\n", this.offset);
26
+ this.offset = end < 0 ? this.source.length : end + 1;
27
+ }
28
+ else if (rest.startsWith("/*")) {
29
+ const end = this.source.indexOf("*/", this.offset + 2);
30
+ if (end < 0)
31
+ this.fail("Unclosed comment");
32
+ this.offset = end + 2;
33
+ }
34
+ else
35
+ return;
36
+ }
37
+ }
38
+ take(token) {
39
+ this.skip();
40
+ if (!this.source.startsWith(token, this.offset))
41
+ return false;
42
+ if (/^[a-z]+$/.test(token) && /[\w$]/.test(this.source.charAt(this.offset + token.length)))
43
+ return false;
44
+ this.offset += token.length;
45
+ return true;
46
+ }
47
+ expect(token) {
48
+ if (!this.take(token))
49
+ this.fail("Expected " + token);
50
+ }
51
+ string() {
52
+ const quote = this.source.charAt(this.offset++);
53
+ let result = "";
54
+ while (this.offset < this.source.length) {
55
+ const character = this.source.charAt(this.offset++);
56
+ if (character === quote)
57
+ return result;
58
+ if (character === "\n" || character === "\r")
59
+ this.fail("Unescaped newline in string");
60
+ if (character !== "\\")
61
+ result += character;
62
+ else {
63
+ const escaped = this.source.charAt(this.offset++);
64
+ const simple = {
65
+ n: "\n",
66
+ r: "\r",
67
+ t: "\t",
68
+ b: "\b",
69
+ f: "\f",
70
+ v: "\v",
71
+ "\\": "\\",
72
+ '"': '"',
73
+ "'": "'",
74
+ "/": "/",
75
+ };
76
+ if (Object.hasOwn(simple, escaped))
77
+ result += simple[escaped];
78
+ else if (escaped === "0" && !/\d/.test(this.source.charAt(this.offset)))
79
+ result += "\0";
80
+ else if (escaped === "u" || escaped === "x") {
81
+ const rest = this.source.slice(this.offset);
82
+ const match = escaped === "x" ? /^[\da-fA-F]{2}/.exec(rest) : /^(?:[\da-fA-F]{4}|\{[\da-fA-F]{1,6}\})/.exec(rest);
83
+ if (!match)
84
+ this.fail("Invalid string escape");
85
+ const point = Number.parseInt(match[0].replace(/[{}]/g, ""), 16);
86
+ if (point > 0x10ffff)
87
+ this.fail("Invalid Unicode code point");
88
+ result += String.fromCodePoint(point);
89
+ this.offset += match[0].length;
90
+ }
91
+ else
92
+ this.fail("Unsupported string escape");
93
+ }
94
+ }
95
+ return this.fail("Unclosed string");
96
+ }
97
+ value(depth) {
98
+ this.skip();
99
+ if (depth > 64)
100
+ this.fail("Configuration is nested too deeply");
101
+ const start = this.offset;
102
+ if (this.take("{")) {
103
+ const value = Object.create(null);
104
+ const properties = new Map();
105
+ let trailingComma = false;
106
+ while (!this.take("}")) {
107
+ this.skip();
108
+ let key;
109
+ if (/['"]/.test(this.source.charAt(this.offset)) && this.offset < this.source.length)
110
+ key = this.string();
111
+ else {
112
+ const identifier = /^[a-zA-Z_$][\w$]*/.exec(this.source.slice(this.offset));
113
+ if (!identifier)
114
+ this.fail("Expected a property name");
115
+ key = identifier[0];
116
+ this.offset += key.length;
117
+ }
118
+ if (properties.has(key))
119
+ this.fail("Duplicate property " + key);
120
+ this.expect(":");
121
+ const child = this.value(depth + 1);
122
+ properties.set(key, child);
123
+ value[key] = child.value;
124
+ trailingComma = false;
125
+ if (this.take("}"))
126
+ return { start, end: this.offset, value, properties, trailingComma };
127
+ this.expect(",");
128
+ trailingComma = true;
129
+ }
130
+ return { start, end: this.offset, value, properties, trailingComma };
131
+ }
132
+ if (this.take("[")) {
133
+ const elements = [];
134
+ let trailingComma = false;
135
+ while (!this.take("]")) {
136
+ elements.push(this.value(depth + 1));
137
+ trailingComma = false;
138
+ if (this.take("]"))
139
+ return { start, end: this.offset, value: elements.map((node) => node.value), elements, trailingComma };
140
+ this.expect(",");
141
+ trailingComma = true;
142
+ }
143
+ return { start, end: this.offset, value: elements.map((node) => node.value), elements, trailingComma };
144
+ }
145
+ const character = this.source.charAt(this.offset);
146
+ if (character === "'" || character === '"') {
147
+ const value = this.string();
148
+ return { start, end: this.offset, value };
149
+ }
150
+ for (const [token, value] of [
151
+ ["true", true],
152
+ ["false", false],
153
+ ["null", null],
154
+ ]) {
155
+ if (this.take(token))
156
+ return { start, end: this.offset, value };
157
+ }
158
+ const number = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(this.source.slice(this.offset));
159
+ if (!number || !Number.isFinite(Number(number[0])))
160
+ this.fail("Expected a literal value");
161
+ this.offset += number[0].length;
162
+ return { start, end: this.offset, value: Number(number[0]) };
163
+ }
164
+ parse() {
165
+ this.expect("export");
166
+ this.expect("default");
167
+ const root = this.value(0);
168
+ if (!root.properties)
169
+ this.fail("Expected an object");
170
+ if (this.take("as"))
171
+ this.expect("const");
172
+ this.take(";");
173
+ this.skip();
174
+ if (this.offset !== this.source.length)
175
+ this.fail("Unexpected expression");
176
+ return root;
177
+ }
178
+ }
179
+ export function parseConfigSource(source) {
180
+ return new ConfigParser(source).parse();
181
+ }
182
+ export function appendLiteralEntries(source, node, entries) {
183
+ if (!entries.length)
184
+ return [];
185
+ const children = node.elements ?? [...(node.properties?.values() ?? [])];
186
+ const last = children.at(-1);
187
+ const edits = [];
188
+ if (last && !node.trailingComma)
189
+ edits.push({ start: last.end, end: last.end, text: "," });
190
+ const lineStart = source.lastIndexOf("\n", node.start - 1) + 1;
191
+ const indent = /^\s*/.exec(source.slice(lineStart, node.start))?.[0] ?? "";
192
+ const newline = source.includes("\r\n") ? "\r\n" : "\n";
193
+ const text = newline +
194
+ entries.map((entry) => indent + " " + entry.replace(/\n/g, newline + indent + " ") + ",").join(newline) +
195
+ newline +
196
+ indent;
197
+ edits.push({ start: node.end - 1, end: node.end - 1, text });
198
+ return edits;
199
+ }
200
+ export function applySourceEdits(source, edits) {
201
+ let result = source;
202
+ let boundary = source.length;
203
+ for (const edit of [...edits].sort((a, b) => b.start - a.start)) {
204
+ if (edit.end > boundary)
205
+ throw new Error("Overlapping configuration edits.");
206
+ result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
207
+ boundary = edit.start;
208
+ }
209
+ return result;
210
+ }
@@ -0,0 +1,89 @@
1
+ import { Picker } from "./picker.js";
2
+ // 공통 앞뒤 문맥을 보존한 단일 hunk다. 큰 파일도 제곱 크기 비교 테이블 없이 검토한다.
3
+ export function formatFileDiff(before, after) {
4
+ if (before === after)
5
+ return ["내용 차이 없음"];
6
+ const oldLines = before.match(/[^\n]*\n|[^\n]+$/g) ?? [];
7
+ const newLines = after.match(/[^\n]*\n|[^\n]+$/g) ?? [];
8
+ let prefix = 0;
9
+ let suffix = 0;
10
+ while (prefix < Math.min(oldLines.length, newLines.length) && oldLines[prefix] === newLines[prefix])
11
+ prefix++;
12
+ while (suffix < Math.min(oldLines.length, newLines.length) - prefix &&
13
+ oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix])
14
+ suffix++;
15
+ const start = Math.max(0, prefix - 3);
16
+ const oldEnd = Math.min(oldLines.length, oldLines.length - suffix + 3);
17
+ const newEnd = Math.min(newLines.length, newLines.length - suffix + 3);
18
+ const marked = (lines, mark) => lines.flatMap((line) => [
19
+ mark + line.replace(/\n$/, "").replace(/\r$/, "\\r"),
20
+ ...(line.endsWith("\n") ? [] : ["\"]),
21
+ ]);
22
+ return [
23
+ "--- 기존 파일",
24
+ "+++ 생성 후보",
25
+ `@@ -${oldLines.length ? start + 1 : 0},${oldEnd - start} +${newLines.length ? start + 1 : 0},${newEnd - start} @@`,
26
+ ...marked(oldLines.slice(start, prefix), " "),
27
+ ...marked(oldLines.slice(prefix, oldLines.length - suffix), "-"),
28
+ ...marked(newLines.slice(prefix, newLines.length - suffix), "+"),
29
+ ...marked(newLines.slice(newLines.length - suffix, newEnd), " "),
30
+ ];
31
+ }
32
+ export async function selectConflicts(conflicts, terminal, previous = []) {
33
+ const unique = new Map();
34
+ for (const conflict of conflicts) {
35
+ const existing = unique.get(conflict.destination);
36
+ if (existing && (existing.content !== conflict.content || existing.existingContent !== conflict.existingContent))
37
+ throw new Error("Conflicting file review: " + conflict.destination);
38
+ unique.set(conflict.destination, existing
39
+ ? {
40
+ ...existing,
41
+ names: [
42
+ existing.names[0],
43
+ ...new Set([...existing.names.slice(1), ...conflict.names].filter((name) => name !== existing.names[0])),
44
+ ],
45
+ }
46
+ : conflict);
47
+ }
48
+ if (!unique.size)
49
+ return { status: "selected", decisions: [] };
50
+ const files = [...unique.values()];
51
+ const result = await terminal.pick(new Picker({
52
+ title: "교체할 파일을 선택해 주세요 · 기본값은 유지",
53
+ stage: "conflict",
54
+ breadcrumb: ["설치 계획", "파일 변경 검토"],
55
+ choices: files.map((file) => ({
56
+ id: file.destination,
57
+ label: file.file,
58
+ summary: `${file.names.join(", ")} · ${file.destination}`,
59
+ details: () => [
60
+ file.destination,
61
+ "영향받는 유틸: " + file.names.join(", "),
62
+ "",
63
+ ...formatFileDiff(file.existingContent, file.content),
64
+ ],
65
+ matches: (query) => query
66
+ .normalize("NFKC")
67
+ .toLowerCase()
68
+ .trim()
69
+ .split(/\s+/u)
70
+ .every((term) => `${file.destination} ${file.names.join(" ")}`.normalize("NFKC").toLowerCase().includes(term)),
71
+ })),
72
+ selected: files
73
+ .filter((file) => previous.some((decision) => decision.action === "replace" &&
74
+ decision.conflict.destination === file.destination &&
75
+ decision.conflict.existingContent === file.existingContent &&
76
+ decision.conflict.content === file.content))
77
+ .map((file) => file.destination),
78
+ summary: (selected) => `교체 ${selected.length}개 · 유지 ${files.length - selected.length}개 · Tab 차이 보기`,
79
+ }));
80
+ if (result.status === "cancelled")
81
+ return result;
82
+ return {
83
+ status: result.status === "next" ? "selected" : "back",
84
+ decisions: files.map((conflict) => ({
85
+ conflict,
86
+ action: result.selected.includes(conflict.destination) ? "replace" : "keep",
87
+ })),
88
+ };
89
+ }
@@ -0,0 +1,4 @@
1
+ export const DEFAULT_INSTALL_DIRECTORIES = Object.freeze({
2
+ core: "src/utils/hermes",
3
+ lib: "libs/hermes",
4
+ });
@@ -0,0 +1,94 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { isRecord } from "./json.js";
5
+ export class PlanChangedError extends Error {
6
+ destination;
7
+ constructor(destination) {
8
+ super("File changed after planning; review a new plan: " + destination);
9
+ this.destination = destination;
10
+ this.name = "PlanChangedError";
11
+ }
12
+ }
13
+ export function readDestination(destination) {
14
+ const absolute = path.resolve(destination);
15
+ const root = path.parse(absolute).root;
16
+ const segments = absolute.slice(root.length).split(path.sep).filter(Boolean);
17
+ let current = root;
18
+ for (const [index, segment] of segments.entries()) {
19
+ current = path.join(current, segment);
20
+ let info;
21
+ try {
22
+ info = lstatSync(current);
23
+ }
24
+ catch (error) {
25
+ if (isRecord(error) && error.code === "ENOENT")
26
+ return null;
27
+ throw error;
28
+ }
29
+ if (info.isSymbolicLink())
30
+ throw new Error("Refusing to write through a symbolic link: " + current);
31
+ if (index < segments.length - 1 && !info.isDirectory())
32
+ throw new Error("Expected a directory: " + current);
33
+ if (index === segments.length - 1) {
34
+ if (!info.isFile())
35
+ throw new Error("Expected a regular file: " + current);
36
+ return readFileSync(current, "utf8");
37
+ }
38
+ }
39
+ throw new Error("Expected a file path: " + destination);
40
+ }
41
+ export function assertUnchanged(file) {
42
+ if (readDestination(file.destination) !== file.existingContent)
43
+ throw new PlanChangedError(file.destination);
44
+ }
45
+ export function validateFilePlans(files) {
46
+ const destinations = new Set();
47
+ for (const file of files) {
48
+ if (!path.isAbsolute(file.destination) || destinations.has(file.destination)) {
49
+ throw new Error("Invalid or duplicate file destination: " + file.destination);
50
+ }
51
+ destinations.add(file.destination);
52
+ if ((file.action === "create") !== (file.existingContent === null)) {
53
+ throw new Error("File action does not match its previous state: " + file.destination);
54
+ }
55
+ assertUnchanged(file);
56
+ }
57
+ for (const destination of destinations) {
58
+ let parent = path.dirname(destination);
59
+ while (parent !== path.dirname(parent)) {
60
+ if (destinations.has(parent))
61
+ throw new Error("A planned file is also a destination directory: " + parent);
62
+ parent = path.dirname(parent);
63
+ }
64
+ }
65
+ }
66
+ export function writePlannedFile(file) {
67
+ assertUnchanged(file);
68
+ if (file.action === "keep")
69
+ return;
70
+ mkdirSync(path.dirname(file.destination), { recursive: true });
71
+ assertUnchanged(file);
72
+ if (file.action === "create") {
73
+ writeFileSync(file.destination, file.content, { flag: "wx" });
74
+ return;
75
+ }
76
+ const temporary = path.join(path.dirname(file.destination), ".hermes-" + randomUUID() + ".tmp");
77
+ try {
78
+ const mode = lstatSync(file.destination).mode & 0o777;
79
+ writeFileSync(temporary, file.content, { flag: "wx", mode });
80
+ assertUnchanged(file);
81
+ renameSync(temporary, file.destination);
82
+ }
83
+ finally {
84
+ rmSync(temporary, { force: true });
85
+ }
86
+ }
87
+ export function executeFilePlans(files, dryRun, onFile) {
88
+ validateFilePlans(files);
89
+ for (const file of files) {
90
+ if (!dryRun)
91
+ writePlannedFile(file);
92
+ onFile?.(file);
93
+ }
94
+ }
@@ -0,0 +1,106 @@
1
+ import { resolveInstallDirectory } from "./project-config.js";
2
+ import { removeLastGrapheme, wrapTerminalLines } from "./terminal-text.js";
3
+ function cancellation(key) {
4
+ if (key.ctrl && key.name === "c")
5
+ return { status: "cancelled", reason: "interrupt" };
6
+ if (key.ctrl && key.name === "d")
7
+ return { status: "cancelled", reason: "eof" };
8
+ return undefined;
9
+ }
10
+ export class DirectoryPrompt {
11
+ scope;
12
+ value;
13
+ cwd;
14
+ notice = "";
15
+ usable = true;
16
+ constructor(scope, value, cwd) {
17
+ this.scope = scope;
18
+ this.value = value;
19
+ this.cwd = cwd;
20
+ }
21
+ key(text, key) {
22
+ const cancelled = cancellation(key);
23
+ if (cancelled)
24
+ return cancelled;
25
+ if (key.name === "escape")
26
+ return { status: "back", value: this.value };
27
+ if (!this.usable)
28
+ return;
29
+ if (key.name === "return") {
30
+ try {
31
+ resolveInstallDirectory(this.value, this.cwd);
32
+ return { status: "next", value: this.value };
33
+ }
34
+ catch {
35
+ this.notice = "설치 경로를 입력해 주세요.";
36
+ return;
37
+ }
38
+ }
39
+ if (key.ctrl && key.name === "u")
40
+ this.value = "";
41
+ else if (key.name === "backspace")
42
+ this.value = removeLastGrapheme(this.value);
43
+ else if (!key.ctrl && !key.meta && text && !/[\p{Cc}\p{Cs}]/u.test(text))
44
+ this.value += text;
45
+ this.notice = "";
46
+ }
47
+ }
48
+ export class ReviewPrompt {
49
+ plan;
50
+ lines;
51
+ confirmed = false;
52
+ usable = true;
53
+ offset = 0;
54
+ pageSize = 1;
55
+ width = 0;
56
+ wrapped = [];
57
+ constructor(plan, lines) {
58
+ this.plan = plan;
59
+ this.lines = lines;
60
+ }
61
+ viewport(width, pageSize) {
62
+ if (this.width !== width) {
63
+ this.width = width;
64
+ this.wrapped = wrapTerminalLines(this.lines, width);
65
+ }
66
+ this.pageSize = pageSize;
67
+ this.offset = Math.max(0, Math.min(this.offset, this.wrapped.length - pageSize));
68
+ return { lines: this.wrapped.slice(this.offset, this.offset + pageSize), total: this.wrapped.length };
69
+ }
70
+ key(text, key) {
71
+ const cancelled = cancellation(key);
72
+ if (cancelled)
73
+ return cancelled;
74
+ if (text === "q" && !key.ctrl && !key.meta)
75
+ return { status: "cancelled", reason: "cancel" };
76
+ if (key.name === "escape")
77
+ return { status: "edit" };
78
+ if (!this.usable)
79
+ return;
80
+ if (key.ctrl || key.meta)
81
+ return;
82
+ if (key.name === "up")
83
+ this.offset--;
84
+ if (key.name === "down")
85
+ this.offset++;
86
+ if (key.name === "pageup")
87
+ this.offset -= this.pageSize;
88
+ if (key.name === "pagedown")
89
+ this.offset += this.pageSize;
90
+ if (key.name === "home")
91
+ this.offset = 0;
92
+ if (key.name === "end")
93
+ this.offset = this.wrapped.length;
94
+ this.offset = Math.max(0, Math.min(this.offset, this.wrapped.length - this.pageSize));
95
+ if (key.name === "tab")
96
+ this.confirmed = !this.confirmed;
97
+ if (text.toLowerCase() === "y")
98
+ this.confirmed = true;
99
+ if (text.toLowerCase() === "n")
100
+ this.confirmed = false;
101
+ if (this.plan.compatibilityErrors.length)
102
+ this.confirmed = false;
103
+ if (key.name === "return")
104
+ return { status: this.confirmed ? "confirmed" : "edit" };
105
+ }
106
+ }
@@ -0,0 +1,61 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import path from "node:path";
3
+ import { Box, Text, useWindowSize } from "ink";
4
+ import { DirectoryPrompt } from "./install-prompts.js";
5
+ import { useTerminalInput } from "./terminal-input.js";
6
+ import { Line, theme } from "./terminal-style.js";
7
+ import { wrapTerminalLines } from "./terminal-text.js";
8
+ export function InstallApp({ prompt, onKey, onPaste, }) {
9
+ useTerminalInput(onKey, onPaste);
10
+ const { columns, rows } = useWindowSize();
11
+ return _jsx(InstallView, { prompt: prompt, columns: columns || 80, rows: rows || 24 });
12
+ }
13
+ function ReviewLine({ text, width }) {
14
+ const color = text.startsWith("[생성]")
15
+ ? theme.accent
16
+ : text.startsWith("[교체]") || text.startsWith("!")
17
+ ? theme.warning
18
+ : text.startsWith("[유지]")
19
+ ? theme.muted
20
+ : undefined;
21
+ return _jsx(Line, { text: text, width: width, color: color });
22
+ }
23
+ export function InstallView({ prompt, columns, rows, }) {
24
+ const width = Math.max(1, columns - 1);
25
+ const height = Math.max(1, rows - 1);
26
+ const compact = width < 64 || height < 20;
27
+ const directory = prompt instanceof DirectoryPrompt;
28
+ prompt.usable = width >= 20 && height >= 7;
29
+ if (!prompt.usable)
30
+ return (_jsxs(Box, { width: width, height: height, flexDirection: "column", children: [_jsx(Text, { color: theme.warning, children: "\uD130\uBBF8\uB110\uC744 \uB113\uD600 \uC8FC\uC138\uC694." }), _jsx(Text, { children: "\uCD5C\uC18C 21\uC5F4 \u00D7 8\uD589" }), _jsx(Text, { dimColor: true, children: "Ctrl+C \uC911\uB2E8" })] }));
31
+ const bodyHeight = height - (compact ? 5 : 8);
32
+ const contentWidth = width - (compact ? 0 : 4);
33
+ const pageSize = Math.max(1, bodyHeight - (compact ? 0 : 2));
34
+ let body;
35
+ let footer;
36
+ if (directory) {
37
+ const lines = wrapTerminalLines([
38
+ "실제 위치: " + path.resolve(prompt.cwd, prompt.value || "."),
39
+ "",
40
+ "Enter로 현재 경로를 사용합니다.",
41
+ "Ctrl+U로 지우고 새 경로를 입력할 수 있어요.",
42
+ "설치 성공 후 no-k.config.ts에 저장합니다.",
43
+ ], contentWidth);
44
+ const input = wrapTerminalLines(["❯ " + prompt.value + "▏"], contentWidth).at(-1) ?? "";
45
+ body = (_jsxs(_Fragment, { children: [lines.slice(0, Math.max(0, pageSize - 2)).map((line, index) => (_jsx(Line, { text: line, width: contentWidth, color: theme.muted }, index))), _jsx(Box, { flexGrow: 1 }), _jsx(Line, { text: input, width: contentWidth, color: theme.accent, bold: true }), _jsx(Line, { text: prompt.notice || " ", width: contentWidth, color: theme.warning })] }));
46
+ footer = (_jsxs(_Fragment, { children: [_jsx(Line, { text: "Enter \uACBD\uB85C \uD655\uC815 \u00B7 Esc \uC774\uC804", width: width, color: theme.accent }), _jsx(Line, { text: "Ctrl+U \uC9C0\uC6B0\uAE30 \u00B7 Ctrl+C \uC911\uB2E8", width: width, color: theme.muted }), !compact && (_jsx(Line, { text: "\uC0C1\uB300 \uACBD\uB85C\uB294 \uD604\uC7AC \uC2E4\uD589 \uB514\uB809\uD130\uB9AC\uB97C \uAE30\uC900\uC73C\uB85C \uD569\uB2C8\uB2E4.", width: width, color: theme.muted }))] }));
47
+ }
48
+ else {
49
+ const view = prompt.viewport(contentWidth, pageSize);
50
+ body = view.lines.map((line, index) => _jsx(ReviewLine, { text: line, width: contentWidth }, prompt.offset + index));
51
+ const yes = width < 40 ? "Yes" : prompt.plan.dryRun ? "Yes · 계획 확인" : "Yes · 설치";
52
+ footer = (_jsxs(_Fragment, { children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: prompt.confirmed ? theme.accent : theme.muted, bold: prompt.confirmed, children: [prompt.confirmed ? "❯ " : " ", yes] }), " ", _jsxs(Text, { color: !prompt.confirmed ? theme.accent : theme.muted, bold: !prompt.confirmed, children: [!prompt.confirmed ? "❯ " : " ", width < 40 ? "No" : "No · 선택 수정"] })] }), _jsx(Line, { text: width < 40 ? "y/n 선택 · Enter 확정" : "y / n 또는 Tab 선택 · Enter 확정", width: width, color: theme.purple }), !compact && (_jsx(Line, { text: `↑↓ 스크롤 · PgUp/PgDn · Home/End · ${prompt.offset + 1}–${Math.min(view.total, prompt.offset + pageSize)}/${view.total} · Esc 수정 · q 취소`, width: width, color: theme.muted }))] }));
53
+ }
54
+ return (_jsxs(Box, { width: width, height: height, flexDirection: "column", children: [_jsx(Box, { height: compact ? 1 : 2, children: _jsxs(Text, { color: theme.accent, bold: true, children: [compact ? "◈ HERMES" : " ◈ H E R M E S", _jsx(Text, { color: theme.purple, children: " / INSTALL" })] }) }), _jsx(Line, { text: directory ? `${prompt.scope === "core" ? "Core" : "라이브러리"} · 설치 경로` : "최종 설치 목록", width: width, bold: true }), _jsx(Line, { text: directory
55
+ ? "선택 → 경로 → 파일 검토 → 최종 확인"
56
+ : prompt.plan.compatibilityErrors.length
57
+ ? "호환성 문제를 먼저 해결해 주세요. No로 선택을 수정할 수 있습니다."
58
+ : prompt.plan.dryRun
59
+ ? "DRY RUN · 파일과 설정을 쓰지 않습니다."
60
+ : "아래 내용으로 설치하시겠어요?", width: width, color: !directory && prompt.plan.compatibilityErrors.length ? theme.warning : theme.muted }), !compact && _jsx(Box, { height: 1 }), _jsx(Box, { width: width, height: bodyHeight, flexDirection: "column", flexShrink: 0, borderStyle: compact ? undefined : "round", borderColor: directory ? theme.accent : theme.border, paddingX: compact ? 0 : 1, overflow: "hidden", children: body }), footer] }));
61
+ }
@@ -0,0 +1,105 @@
1
+ import { CatalogSelection, selectCatalog } from "./catalog-selection.js";
2
+ import { UTILITY_SCOPES } from "./catalog.js";
3
+ import { selectConflicts } from "./conflict-selection.js";
4
+ import { PlanChangedError } from "./files.js";
5
+ import { DirectoryPrompt, ReviewPrompt } from "./install-prompts.js";
6
+ import { createInstallPlan, executeInstallPlan, planUtilityFiles } from "./install.js";
7
+ import { inspectInstallations } from "./installation-status.js";
8
+ import { initialInstallDirectories, loadProjectConfig, resolveInstallDirectory } from "./project-config.js";
9
+ import { formatInstallReview } from "./report.js";
10
+ export async function runInstallWizard(request, catalog, packageDirectory, cwd, terminal) {
11
+ const selection = new CatalogSelection(catalog, { names: request.initialNames });
12
+ let config = loadProjectConfig(cwd);
13
+ const directories = { ...initialInstallDirectories(config, request.directory) };
14
+ const decisions = new Map();
15
+ let firstReview = true;
16
+ let stage = "selection";
17
+ let pathIndex = 0;
18
+ let notice = "";
19
+ while (true) {
20
+ const names = selection.selectedNames();
21
+ const scopes = UTILITY_SCOPES.filter((scope) => catalog.items.some((item) => item.scope === scope && names.includes(item.name)));
22
+ switch (stage) {
23
+ case "selection": {
24
+ const result = await selectCatalog(selection, terminal, inspectInstallations(catalog, config));
25
+ if (result.status === "cancelled")
26
+ return result;
27
+ stage = "directories";
28
+ pathIndex = 0;
29
+ break;
30
+ }
31
+ case "directories": {
32
+ const scope = scopes[pathIndex];
33
+ if (!scope)
34
+ stage = "review";
35
+ else {
36
+ const result = await terminal.directory(new DirectoryPrompt(scope, directories[scope], cwd));
37
+ if (result.status === "cancelled")
38
+ return result;
39
+ directories[scope] = result.value;
40
+ if (result.status === "back") {
41
+ if (pathIndex === 0)
42
+ stage = "selection";
43
+ else
44
+ pathIndex--;
45
+ }
46
+ else
47
+ pathIndex++;
48
+ }
49
+ break;
50
+ }
51
+ case "review": {
52
+ const targets = scopes.flatMap((scope) => {
53
+ const [first, ...rest] = names.filter((name) => catalog.items.some((item) => item.name === name && item.scope === scope));
54
+ return first ? [{ scope, names: [first, ...rest], directory: directories[scope] }] : [];
55
+ });
56
+ const [first, ...rest] = targets;
57
+ if (!first)
58
+ throw new Error("Specify at least one item.");
59
+ try {
60
+ const preview = planUtilityFiles(targets.map((target) => ({
61
+ items: catalog.items.filter((item) => target.names.includes(item.name)),
62
+ directory: resolveInstallDirectory(target.directory, cwd),
63
+ })), packageDirectory);
64
+ const previous = firstReview && request.overwrite
65
+ ? preview.conflicts.map((conflict) => ({ conflict, action: "replace" }))
66
+ : [...decisions.values()];
67
+ firstReview = false;
68
+ const conflicts = await selectConflicts(preview.conflicts, terminal, previous);
69
+ if (conflicts.status === "cancelled")
70
+ return conflicts;
71
+ for (const decision of conflicts.decisions)
72
+ decisions.set(decision.conflict.destination, decision);
73
+ if (conflicts.status === "back") {
74
+ stage = "directories";
75
+ pathIndex = scopes.length - 1;
76
+ break;
77
+ }
78
+ // 충돌 화면을 보고 있는 사이 생긴 변경도 최종 확인 전에 다시 검토한다.
79
+ const plan = createInstallPlan({ config, targets: [first, ...rest], dryRun: request.dryRun, fileDecisions: conflicts.decisions }, catalog, packageDirectory);
80
+ const changedConflict = plan.conflicts.find((file) => !preview.conflicts.some((before) => before.destination === file.destination &&
81
+ before.existingContent === file.existingContent &&
82
+ before.content === file.content));
83
+ if (changedConflict)
84
+ throw new PlanChangedError(changedConflict.destination);
85
+ const review = await terminal.review(new ReviewPrompt(plan, [...(notice ? ["! " + notice, ""] : []), ...formatInstallReview(plan, catalog)]));
86
+ if (review.status === "cancelled")
87
+ return review;
88
+ if (review.status === "edit" || plan.compatibilityErrors.length) {
89
+ stage = "selection";
90
+ break;
91
+ }
92
+ const result = executeInstallPlan(plan);
93
+ return { status: result.status, plan, result };
94
+ }
95
+ catch (error) {
96
+ if (!(error instanceof PlanChangedError))
97
+ throw error;
98
+ config = loadProjectConfig(cwd);
99
+ notice = "검토 중 파일이 변경되어 설치 계획을 다시 계산했습니다. 새 목록을 확인해 주세요.";
100
+ }
101
+ break;
102
+ }
103
+ }
104
+ }
105
+ }