@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.
- package/README.md +210 -13
- package/catalog.json +2640 -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 +105 -0
- package/package.json +25 -4
- package/templates/lint/fsd/fsd.ts +180 -0
- package/templates/lint/fsd/import-listener.ts +41 -0
- package/templates/lint/fsd/index.ts +32 -0
- package/templates/lint/fsd/options.ts +74 -0
- package/templates/lint/fsd/rules/fsd-layer-imports.ts +22 -0
- package/templates/lint/fsd/rules/fsd-public-api.ts +20 -0
- package/templates/lint/fsd/rules/fsd-slice-segments.ts +33 -0
- package/bin/hermes.mjs +0 -159
- package/lib/catalog.mjs +0 -51
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { validateCatalog } from "../lib/catalog.js";
|
|
5
|
+
import { parseCliArguments, resolveCommand } from "../lib/command.js";
|
|
6
|
+
import { InstallExecutionError } from "../lib/install.js";
|
|
7
|
+
import { readJson, readPackageVersion } from "../lib/json.js";
|
|
8
|
+
import { formatFilePlan, formatInstallSummary } from "../lib/report.js";
|
|
9
|
+
import { terminalText } from "../lib/terminal-text.js";
|
|
10
|
+
const packageDirectory = fileURLToPath(new URL("../../", import.meta.url));
|
|
11
|
+
const help = [
|
|
12
|
+
"",
|
|
13
|
+
"Usage:",
|
|
14
|
+
" hermes",
|
|
15
|
+
" hermes add [item...] [--dir <directory>] [--dry-run] [--overwrite]",
|
|
16
|
+
" hermes list",
|
|
17
|
+
"",
|
|
18
|
+
"Choose categories and utilities, review destinations and files, then confirm Yes to install.",
|
|
19
|
+
"Item names and --dir prefill the wizard; they never skip confirmation.",
|
|
20
|
+
"",
|
|
21
|
+
"Options:",
|
|
22
|
+
" -d, --dir Initial directory for each selected scope",
|
|
23
|
+
" --dry-run Review the plan without writing files or config",
|
|
24
|
+
" --overwrite Preselect replacements on the first conflict screen",
|
|
25
|
+
" -h, --help Show help",
|
|
26
|
+
" -v, --version Show version",
|
|
27
|
+
"",
|
|
28
|
+
"Defaults: Core → src/utils/hermes; libraries → libs/hermes.",
|
|
29
|
+
"Saved paths are read from no-k.config.ts in the current directory.",
|
|
30
|
+
"Run in an interactive terminal; choose No to edit your selection.",
|
|
31
|
+
"External npm dependencies are reported; they are not installed automatically.",
|
|
32
|
+
"",
|
|
33
|
+
"Example:",
|
|
34
|
+
" npx @no-k/hermes add number-lcm tailwindcss-cn",
|
|
35
|
+
].join("\n");
|
|
36
|
+
async function main() {
|
|
37
|
+
const version = readPackageVersion(path.join(packageDirectory, "package.json"));
|
|
38
|
+
const intent = resolveCommand(parseCliArguments(process.argv.slice(2)), {
|
|
39
|
+
stdinIsTTY: process.stdin.isTTY,
|
|
40
|
+
stdoutIsTTY: process.stdout.isTTY,
|
|
41
|
+
});
|
|
42
|
+
if (intent.kind === "help") {
|
|
43
|
+
console.log("Hermes " + version + " — copy TypeScript utilities into your project\n" + help);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (intent.kind === "version") {
|
|
47
|
+
console.log(version);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const catalog = validateCatalog(readJson(path.join(packageDirectory, "catalog.json")));
|
|
51
|
+
if (catalog.version !== version)
|
|
52
|
+
throw new Error("The catalog version does not match the package. Rebuild the CLI.");
|
|
53
|
+
if (intent.kind === "list") {
|
|
54
|
+
for (const item of catalog.items)
|
|
55
|
+
console.log(terminalText(item.name) + "\t" + terminalText(item.description));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const { runInstallWizard } = await import("../lib/install-wizard.js");
|
|
59
|
+
const { withSelectionTerminal } = await import("../lib/terminal.js");
|
|
60
|
+
const result = await withSelectionTerminal((terminal) => runInstallWizard(intent.request, catalog, packageDirectory, process.cwd(), terminal));
|
|
61
|
+
if (result.status === "cancelled") {
|
|
62
|
+
console.log(result.reason === "interrupt"
|
|
63
|
+
? "설치를 중단했습니다."
|
|
64
|
+
: result.reason === "eof"
|
|
65
|
+
? "터미널 입력이 종료되었습니다."
|
|
66
|
+
: "설치를 취소했습니다.");
|
|
67
|
+
process.exitCode = result.reason === "interrupt" ? 130 : result.reason === "eof" ? 1 : 0;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const { plan } = result;
|
|
71
|
+
for (const file of [...plan.files, plan.configFile])
|
|
72
|
+
console.log(terminalText(formatFilePlan(file, plan.dryRun)));
|
|
73
|
+
const selected = new Set(plan.targets.flatMap((target) => target.names));
|
|
74
|
+
console.log(formatInstallSummary(plan, catalog.items.filter((item) => selected.has(item.name)).map((item) => item.name + "@" + item.version), plan.dryRun));
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
await main();
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
console.error("Hermes: " + terminalText(error instanceof Error ? error.message : String(error)));
|
|
81
|
+
if (error instanceof InstallExecutionError) {
|
|
82
|
+
console.error(error.phase === "config" ? "설정 저장 실패. 이미 처리된 파일:" : "설정 저장 전 중단. 이미 처리된 파일:");
|
|
83
|
+
for (const file of error.completedFiles)
|
|
84
|
+
console.error(terminalText(formatFilePlan(file, false)));
|
|
85
|
+
}
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { UTILITY_SCOPES, } from "./catalog.js";
|
|
2
|
+
function searchText(value) {
|
|
3
|
+
return value.normalize("NFKC").toLowerCase();
|
|
4
|
+
}
|
|
5
|
+
export function filterCatalog(catalog, filter = {}) {
|
|
6
|
+
const terms = searchText(filter.query ?? "")
|
|
7
|
+
.split(/\s+/u)
|
|
8
|
+
.filter(Boolean);
|
|
9
|
+
const scopes = filter.scopes === undefined ? undefined : new Set(filter.scopes);
|
|
10
|
+
const categories = filter.categories === undefined ? undefined : new Set(filter.categories);
|
|
11
|
+
return catalog.items.filter((item) => {
|
|
12
|
+
if (scopes && !scopes.has(item.scope))
|
|
13
|
+
return false;
|
|
14
|
+
if (categories && !categories.has(item.category))
|
|
15
|
+
return false;
|
|
16
|
+
const name = searchText(item.name);
|
|
17
|
+
const description = searchText(item.description);
|
|
18
|
+
return terms.every((term) => name.includes(term) || description.includes(term));
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
export function getCatalogGroups(catalog, filter = {}) {
|
|
22
|
+
const items = filterCatalog(catalog, filter);
|
|
23
|
+
return UTILITY_SCOPES.flatMap((scope) => {
|
|
24
|
+
const selected = items.filter((item) => item.scope === scope);
|
|
25
|
+
if (!selected.length)
|
|
26
|
+
return [];
|
|
27
|
+
const categories = new Map();
|
|
28
|
+
for (const item of selected) {
|
|
29
|
+
const members = categories.get(item.category) ?? [];
|
|
30
|
+
members.push(item);
|
|
31
|
+
categories.set(item.category, members);
|
|
32
|
+
}
|
|
33
|
+
return [
|
|
34
|
+
{
|
|
35
|
+
scope,
|
|
36
|
+
count: selected.length,
|
|
37
|
+
categories: [...categories]
|
|
38
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
39
|
+
.map(([id, members]) => ({
|
|
40
|
+
id,
|
|
41
|
+
name: id.slice(scope.length + 1),
|
|
42
|
+
items: members,
|
|
43
|
+
})),
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const summarySegments = new Intl.Segmenter("und", { granularity: "grapheme" });
|
|
49
|
+
export function summarizeDescription(description, maxCharacters = 100) {
|
|
50
|
+
if (!Number.isSafeInteger(maxCharacters) || maxCharacters < 0) {
|
|
51
|
+
throw new RangeError("Summary length must be a non-negative safe integer.");
|
|
52
|
+
}
|
|
53
|
+
if (maxCharacters === 0)
|
|
54
|
+
return "";
|
|
55
|
+
const text = description.replace(/\s+/gu, " ").trim();
|
|
56
|
+
const characters = [];
|
|
57
|
+
for (const { segment } of summarySegments.segment(text)) {
|
|
58
|
+
if (characters.length === maxCharacters)
|
|
59
|
+
return characters.slice(0, -1).join("").trimEnd() + "…";
|
|
60
|
+
characters.push(segment);
|
|
61
|
+
}
|
|
62
|
+
return text;
|
|
63
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { filterCatalog, getCatalogGroups, summarizeDescription } from "./catalog-query.js";
|
|
2
|
+
import {} from "./catalog.js";
|
|
3
|
+
import { describeInstallation } from "./installation-status.js";
|
|
4
|
+
import { Picker } from "./picker.js";
|
|
5
|
+
const scopeLabels = { core: "Core", lib: "라이브러리" };
|
|
6
|
+
function includesQuery(value, query) {
|
|
7
|
+
const normalized = value.normalize("NFKC").toLowerCase();
|
|
8
|
+
return query
|
|
9
|
+
.normalize("NFKC")
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.trim()
|
|
12
|
+
.split(/\s+/u)
|
|
13
|
+
.every((term) => normalized.includes(term));
|
|
14
|
+
}
|
|
15
|
+
export class CatalogSelection {
|
|
16
|
+
catalog;
|
|
17
|
+
scopes;
|
|
18
|
+
categories;
|
|
19
|
+
names;
|
|
20
|
+
groups;
|
|
21
|
+
pageStates = new Map();
|
|
22
|
+
constructor(catalog, initial = {}) {
|
|
23
|
+
this.catalog = catalog;
|
|
24
|
+
this.groups = getCatalogGroups(catalog);
|
|
25
|
+
const items = (initial.names ?? []).map((name) => {
|
|
26
|
+
const item = catalog.items.find((candidate) => candidate.name === name);
|
|
27
|
+
if (!item)
|
|
28
|
+
throw new Error("Unknown item: " + name);
|
|
29
|
+
return item;
|
|
30
|
+
});
|
|
31
|
+
this.scopes = new Set(initial.scopes ?? items.map((item) => item.scope));
|
|
32
|
+
this.categories = new Set(initial.categories ?? items.map((item) => item.category));
|
|
33
|
+
this.names = new Set(items.map((item) => item.name));
|
|
34
|
+
}
|
|
35
|
+
snapshot() {
|
|
36
|
+
return {
|
|
37
|
+
scopes: this.groups.filter((group) => this.scopes.has(group.scope)).map((group) => group.scope),
|
|
38
|
+
categories: this.groups
|
|
39
|
+
.flatMap((group) => group.categories)
|
|
40
|
+
.filter((category) => this.categories.has(category.id))
|
|
41
|
+
.map((category) => category.id),
|
|
42
|
+
names: this.catalog.items.filter((item) => this.names.has(item.name)).map((item) => item.name),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
selectedNames() {
|
|
46
|
+
return this.catalog.items
|
|
47
|
+
.filter((item) => this.scopes.has(item.scope) && this.categories.has(item.category) && this.names.has(item.name))
|
|
48
|
+
.map((item) => item.name);
|
|
49
|
+
}
|
|
50
|
+
pages() {
|
|
51
|
+
const groups = this.groups.filter((group) => this.scopes.has(group.scope));
|
|
52
|
+
return [
|
|
53
|
+
{ kind: "scopes" },
|
|
54
|
+
...groups.map((group) => ({ kind: "categories", scope: group.scope })),
|
|
55
|
+
...groups.flatMap((group) => group.categories
|
|
56
|
+
.filter((category) => this.categories.has(category.id))
|
|
57
|
+
.map((category) => ({ kind: "items", scope: group.scope, category: category.id }))),
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
values(page) {
|
|
61
|
+
return [...(page.kind === "scopes" ? this.scopes : page.kind === "categories" ? this.categories : this.names)];
|
|
62
|
+
}
|
|
63
|
+
update(page, values) {
|
|
64
|
+
if (page.kind === "scopes") {
|
|
65
|
+
this.scopes.clear();
|
|
66
|
+
for (const value of values)
|
|
67
|
+
this.scopes.add(value);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
const ids = page.kind === "categories"
|
|
71
|
+
? (this.groups.find((group) => group.scope === page.scope)?.categories.map((category) => category.id) ?? [])
|
|
72
|
+
: filterCatalog(this.catalog, { categories: [page.category] }).map((item) => item.name);
|
|
73
|
+
const selected = page.kind === "categories" ? this.categories : this.names;
|
|
74
|
+
for (const id of ids)
|
|
75
|
+
selected.delete(id);
|
|
76
|
+
for (const value of values)
|
|
77
|
+
if (ids.includes(value))
|
|
78
|
+
selected.add(value);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
countWith(page, values) {
|
|
82
|
+
const copy = new CatalogSelection(this.catalog, this.snapshot());
|
|
83
|
+
copy.update(page, values);
|
|
84
|
+
return copy.selectedNames().length;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function itemDetails(item, installed) {
|
|
88
|
+
const included = item.modules.filter((module) => module.name !== item.name);
|
|
89
|
+
const peers = item.modules.flatMap((module) => module.requires
|
|
90
|
+
.filter((requirement) => requirement.kind === "peer" && !requirement.internal)
|
|
91
|
+
.map((requirement) => `${requirement.packageName}@${requirement.range}${requirement.optional ? " (선택 사항)" : ""}`));
|
|
92
|
+
return [
|
|
93
|
+
`${item.name}@${item.version}`,
|
|
94
|
+
item.description,
|
|
95
|
+
"",
|
|
96
|
+
"함께 생성: " + (included.map((module) => `${module.name}@${module.version}`).join(", ") || "없음"),
|
|
97
|
+
"실행 환경: " +
|
|
98
|
+
(item.runtime === "node"
|
|
99
|
+
? "Node.js 필요"
|
|
100
|
+
: item.runtime === "dom"
|
|
101
|
+
? "DOM API와 DOM TypeScript 타입 필요"
|
|
102
|
+
: "별도 제한 없음"),
|
|
103
|
+
"npm 의존성: " + (item.dependencies.join(", ") || "없음"),
|
|
104
|
+
"개발 의존성: " + (item.devDependencies?.join(", ") || "없음"),
|
|
105
|
+
"peer 호환 범위: " + ([...new Set(peers)].join(", ") || "없음"),
|
|
106
|
+
"",
|
|
107
|
+
...(installed.length
|
|
108
|
+
? installed.flatMap((entry) => [
|
|
109
|
+
describeInstallation(entry),
|
|
110
|
+
...(entry.via !== item.name ? [entry.via + "의 의존 모듈로 설치"] : []),
|
|
111
|
+
])
|
|
112
|
+
: ["이 프로젝트의 설치 기록 없음"]),
|
|
113
|
+
"",
|
|
114
|
+
"생성 파일:",
|
|
115
|
+
...item.files,
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
function choicesFor(selection, page, installed) {
|
|
119
|
+
if (page.kind === "scopes")
|
|
120
|
+
return selection.groups.map((group) => ({
|
|
121
|
+
id: group.scope,
|
|
122
|
+
label: scopeLabels[group.scope],
|
|
123
|
+
summary: `${group.categories.length}개 하위 분류 · ${group.count}개 유틸`,
|
|
124
|
+
details: () => group.categories.map((category) => `${category.name} · ${category.items.length}개 유틸`),
|
|
125
|
+
matches: (query) => includesQuery(scopeLabels[group.scope], query) ||
|
|
126
|
+
filterCatalog(selection.catalog, { scopes: [group.scope], query }).length > 0,
|
|
127
|
+
}));
|
|
128
|
+
if (page.kind === "categories")
|
|
129
|
+
return (selection.groups.find((group) => group.scope === page.scope)?.categories ?? []).map((category) => ({
|
|
130
|
+
id: category.id,
|
|
131
|
+
label: category.name,
|
|
132
|
+
summary: `${category.items.length}개 유틸`,
|
|
133
|
+
details: () => category.items.map((item) => `${item.name} — ${item.description}`),
|
|
134
|
+
matches: (query) => includesQuery(category.id, query) ||
|
|
135
|
+
filterCatalog(selection.catalog, { categories: [category.id], query }).length > 0,
|
|
136
|
+
}));
|
|
137
|
+
return filterCatalog(selection.catalog, { categories: [page.category] }).map((item) => {
|
|
138
|
+
const locations = installed.get(item.name) ?? [];
|
|
139
|
+
const present = locations.some((entry) => ["present", "modified", "unverified"].includes(entry.status));
|
|
140
|
+
return {
|
|
141
|
+
id: item.name,
|
|
142
|
+
label: item.name + (present ? " [설치됨]" : locations.length ? " [설치 기록 확인]" : ""),
|
|
143
|
+
summary: summarizeDescription(item.description),
|
|
144
|
+
details: () => itemDetails(item, locations),
|
|
145
|
+
matches: (query) => filterCatalog({ ...selection.catalog, items: [item] }, { query }).length > 0,
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
export async function selectCatalog(selection, terminal, installed = new Map()) {
|
|
150
|
+
const states = selection.pageStates;
|
|
151
|
+
let index = 0;
|
|
152
|
+
let notice = "";
|
|
153
|
+
while (true) {
|
|
154
|
+
const page = selection.pages()[index];
|
|
155
|
+
if (!page)
|
|
156
|
+
throw new Error("Invalid catalog selection page.");
|
|
157
|
+
const id = page.kind === "scopes" ? "scopes" : page.kind === "categories" ? page.scope : page.category;
|
|
158
|
+
const title = page.kind === "scopes"
|
|
159
|
+
? "어떤 것들을 추가하시겠어요? · 대분류 선택"
|
|
160
|
+
: page.kind === "categories"
|
|
161
|
+
? `${scopeLabels[page.scope]} · 하위 분류 선택`
|
|
162
|
+
: `${scopeLabels[page.scope]} / ${page.category.slice(page.scope.length + 1)} · 유틸 선택`;
|
|
163
|
+
const result = await terminal.pick(new Picker({
|
|
164
|
+
title,
|
|
165
|
+
stage: page.kind === "scopes" ? "scope" : page.kind === "categories" ? "category" : "utility",
|
|
166
|
+
breadcrumb: page.kind === "scopes"
|
|
167
|
+
? ["카탈로그", "시작"]
|
|
168
|
+
: page.kind === "categories"
|
|
169
|
+
? ["카탈로그", scopeLabels[page.scope]]
|
|
170
|
+
: ["카탈로그", scopeLabels[page.scope], page.category.slice(page.scope.length + 1)],
|
|
171
|
+
choices: choicesFor(selection, page, installed),
|
|
172
|
+
selected: selection.values(page),
|
|
173
|
+
summary: (values) => `총 선택 유틸 ${selection.countWith(page, values)}개 · 체크 ${values.length}개`,
|
|
174
|
+
...(page.kind !== "items"
|
|
175
|
+
? { validate: (values) => (values.length ? undefined : "분류를 하나 이상 선택해 주세요.") }
|
|
176
|
+
: {}),
|
|
177
|
+
state: states.get(id),
|
|
178
|
+
notice,
|
|
179
|
+
}));
|
|
180
|
+
if (result.status === "cancelled")
|
|
181
|
+
return { ...result, state: selection.snapshot() };
|
|
182
|
+
states.set(id, result.state);
|
|
183
|
+
selection.update(page, result.selected);
|
|
184
|
+
notice = "";
|
|
185
|
+
if (result.status === "back") {
|
|
186
|
+
if (!index)
|
|
187
|
+
return { status: "cancelled", reason: "cancel", state: selection.snapshot() };
|
|
188
|
+
index--;
|
|
189
|
+
}
|
|
190
|
+
else if (index + 1 < selection.pages().length)
|
|
191
|
+
index++;
|
|
192
|
+
else {
|
|
193
|
+
const [first, ...rest] = selection.selectedNames();
|
|
194
|
+
if (first)
|
|
195
|
+
return { status: "selected", names: [first, ...rest], state: selection.snapshot() };
|
|
196
|
+
notice = "유틸을 하나 이상 선택해 주세요.";
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { satisfies, valid, validRange } from "semver";
|
|
2
|
+
import { isRecord } from "./json.js";
|
|
3
|
+
export const UTILITY_SCOPES = Object.freeze(["core", "lib"]);
|
|
4
|
+
export const CATALOG_SCHEMA_VERSION = 3;
|
|
5
|
+
const itemNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
6
|
+
export function isItemName(value) {
|
|
7
|
+
return typeof value === "string" && itemNamePattern.test(value);
|
|
8
|
+
}
|
|
9
|
+
export function isUtilityScope(value) {
|
|
10
|
+
return value === "core" || value === "lib";
|
|
11
|
+
}
|
|
12
|
+
export function isCatalogCategoryId(value, scope) {
|
|
13
|
+
if (typeof value !== "string")
|
|
14
|
+
return false;
|
|
15
|
+
const [owner, category, ...rest] = value.split("/");
|
|
16
|
+
return owner === scope && isItemName(category) && rest.length === 0;
|
|
17
|
+
}
|
|
18
|
+
export function isSourcePath(value) {
|
|
19
|
+
return (typeof value === "string" &&
|
|
20
|
+
value.endsWith(".ts") &&
|
|
21
|
+
!value.includes("\\") &&
|
|
22
|
+
!value.includes("\0") &&
|
|
23
|
+
!value.includes(":") &&
|
|
24
|
+
value.split("/").every((part) => part && part !== "." && part !== "..") &&
|
|
25
|
+
!/\.(test|spec|stories|config|d)\.ts$/.test(value));
|
|
26
|
+
}
|
|
27
|
+
function isDependencyList(value) {
|
|
28
|
+
return (Array.isArray(value) &&
|
|
29
|
+
value.every((dependency) => {
|
|
30
|
+
if (typeof dependency !== "string")
|
|
31
|
+
return false;
|
|
32
|
+
const separator = dependency.lastIndexOf("@");
|
|
33
|
+
return (separator > 0 && isPackageName(dependency.slice(0, separator)) && !!validRange(dependency.slice(separator + 1)));
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
export function isPackageName(value) {
|
|
37
|
+
return typeof value === "string" && /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value);
|
|
38
|
+
}
|
|
39
|
+
export function isModuleSnapshot(value) {
|
|
40
|
+
return (isRecord(value) &&
|
|
41
|
+
isItemName(value.name) &&
|
|
42
|
+
isPackageName(value.packageName) &&
|
|
43
|
+
typeof value.version === "string" &&
|
|
44
|
+
valid(value.version) === value.version &&
|
|
45
|
+
isUtilityScope(value.scope) &&
|
|
46
|
+
isRecord(value.files) &&
|
|
47
|
+
Object.keys(value.files).length > 0 &&
|
|
48
|
+
Object.entries(value.files).every(([file, hash]) => isSourcePath(file) && typeof hash === "string" && /^[a-f0-9]{64}$/.test(hash)) &&
|
|
49
|
+
Array.isArray(value.requires) &&
|
|
50
|
+
value.requires.every((requirement) => isRecord(requirement) &&
|
|
51
|
+
isPackageName(requirement.packageName) &&
|
|
52
|
+
typeof requirement.range === "string" &&
|
|
53
|
+
!!validRange(requirement.range) &&
|
|
54
|
+
(requirement.kind === "dependency" || requirement.kind === "peer") &&
|
|
55
|
+
typeof requirement.internal === "boolean" &&
|
|
56
|
+
typeof requirement.optional === "boolean" &&
|
|
57
|
+
(!requirement.optional || requirement.kind === "peer")));
|
|
58
|
+
}
|
|
59
|
+
export function validateModuleGraph(modules) {
|
|
60
|
+
const providers = new Map(modules.map((module) => [module.packageName, module]));
|
|
61
|
+
if (providers.size !== modules.length)
|
|
62
|
+
throw new Error("Duplicate utility package in module graph.");
|
|
63
|
+
for (const module of modules) {
|
|
64
|
+
for (const requirement of module.requires.filter((entry) => entry.internal)) {
|
|
65
|
+
const provider = providers.get(requirement.packageName);
|
|
66
|
+
if ((!requirement.optional || provider) && (!provider || !satisfies(provider.version, requirement.range))) {
|
|
67
|
+
throw new Error("Incompatible utility dependency: " +
|
|
68
|
+
module.packageName +
|
|
69
|
+
"@" +
|
|
70
|
+
module.version +
|
|
71
|
+
" requires " +
|
|
72
|
+
requirement.packageName +
|
|
73
|
+
"@" +
|
|
74
|
+
requirement.range +
|
|
75
|
+
"; available: " +
|
|
76
|
+
(provider?.version ?? "missing") +
|
|
77
|
+
".");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function assertCatalog(catalog) {
|
|
83
|
+
if (!isRecord(catalog) ||
|
|
84
|
+
catalog.schemaVersion !== CATALOG_SCHEMA_VERSION ||
|
|
85
|
+
typeof catalog.version !== "string" ||
|
|
86
|
+
!Array.isArray(catalog.items) ||
|
|
87
|
+
catalog.items.length === 0) {
|
|
88
|
+
throw new Error("Invalid Hermes catalog. Rebuild the CLI with catalog schema " + CATALOG_SCHEMA_VERSION + ".");
|
|
89
|
+
}
|
|
90
|
+
const names = new Set();
|
|
91
|
+
const items = catalog.items;
|
|
92
|
+
for (const item of items) {
|
|
93
|
+
if (!isRecord(item) ||
|
|
94
|
+
!isItemName(item.name) ||
|
|
95
|
+
!isPackageName(item.packageName) ||
|
|
96
|
+
typeof item.version !== "string" ||
|
|
97
|
+
valid(item.version) !== item.version ||
|
|
98
|
+
!Array.isArray(item.modules) ||
|
|
99
|
+
!item.modules.every(isModuleSnapshot) ||
|
|
100
|
+
names.has(item.name) ||
|
|
101
|
+
!isUtilityScope(item.scope) ||
|
|
102
|
+
!isCatalogCategoryId(item.category, item.scope) ||
|
|
103
|
+
typeof item.description !== "string" ||
|
|
104
|
+
!Array.isArray(item.files) ||
|
|
105
|
+
item.files.length === 0 ||
|
|
106
|
+
!item.files.every(isSourcePath) ||
|
|
107
|
+
new Set(item.files).size !== item.files.length ||
|
|
108
|
+
(item.runtime !== undefined && item.runtime !== "node" && item.runtime !== "dom")) {
|
|
109
|
+
throw new Error("Invalid or duplicate Hermes item: " + (isRecord(item) ? item.name : undefined));
|
|
110
|
+
}
|
|
111
|
+
const modules = item.modules;
|
|
112
|
+
const own = modules.find((module) => module.name === item.name);
|
|
113
|
+
if (!own ||
|
|
114
|
+
own.packageName !== item.packageName ||
|
|
115
|
+
own.version !== item.version ||
|
|
116
|
+
own.scope !== item.scope ||
|
|
117
|
+
new Set(modules.map((module) => module.name)).size !== modules.length ||
|
|
118
|
+
JSON.stringify([...new Set(modules.flatMap((module) => Object.keys(module.files)))].sort()) !==
|
|
119
|
+
JSON.stringify([...item.files].sort())) {
|
|
120
|
+
throw new Error("Invalid module provenance for " + item.name);
|
|
121
|
+
}
|
|
122
|
+
validateModuleGraph(modules);
|
|
123
|
+
if (!isDependencyList(item.dependencies) ||
|
|
124
|
+
(item.devDependencies !== undefined && !isDependencyList(item.devDependencies))) {
|
|
125
|
+
throw new Error("Invalid dependencies for " + item.name);
|
|
126
|
+
}
|
|
127
|
+
names.add(item.name);
|
|
128
|
+
}
|
|
129
|
+
const roots = items.map((item) => item.modules.find((module) => module.name === item.name));
|
|
130
|
+
const byPackage = new Map(roots.map((module) => [module?.packageName, module]));
|
|
131
|
+
if (byPackage.size !== roots.length)
|
|
132
|
+
throw new Error("Duplicate utility package in catalog.");
|
|
133
|
+
for (const item of items) {
|
|
134
|
+
for (const module of item.modules) {
|
|
135
|
+
if (JSON.stringify(module) !== JSON.stringify(byPackage.get(module.packageName))) {
|
|
136
|
+
throw new Error("Inconsistent module snapshot: " + module.packageName);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export function validateCatalog(catalog) {
|
|
142
|
+
assertCatalog(catalog);
|
|
143
|
+
return catalog;
|
|
144
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
export function parseCliArguments(args) {
|
|
3
|
+
return parseArgs({
|
|
4
|
+
args: [...args],
|
|
5
|
+
options: {
|
|
6
|
+
dir: { type: "string", short: "d" },
|
|
7
|
+
"dry-run": { type: "boolean" },
|
|
8
|
+
overwrite: { type: "boolean" },
|
|
9
|
+
help: { type: "boolean", short: "h" },
|
|
10
|
+
version: { type: "boolean", short: "v" },
|
|
11
|
+
},
|
|
12
|
+
allowPositionals: true,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
// 실행 의도만 결정한다. 화면 연결과 설치 실행은 별도 단계에서 수행한다.
|
|
16
|
+
export function resolveCommand({ values, positionals }, terminal) {
|
|
17
|
+
if (values.help)
|
|
18
|
+
return { kind: "help" };
|
|
19
|
+
if (values.version)
|
|
20
|
+
return { kind: "version" };
|
|
21
|
+
const [command, ...requestedNames] = positionals;
|
|
22
|
+
const interactive = terminal.stdinIsTTY === true && terminal.stdoutIsTTY === true;
|
|
23
|
+
if (command === undefined && !interactive)
|
|
24
|
+
return { kind: "help" };
|
|
25
|
+
if (command !== undefined && command !== "list" && command !== "add") {
|
|
26
|
+
throw new Error("Unknown command: " + command + ". Use --help.");
|
|
27
|
+
}
|
|
28
|
+
if (command === "list") {
|
|
29
|
+
if (requestedNames.length || values.dir !== undefined || values.overwrite || values["dry-run"]) {
|
|
30
|
+
throw new Error("Usage: hermes list");
|
|
31
|
+
}
|
|
32
|
+
return { kind: "list" };
|
|
33
|
+
}
|
|
34
|
+
if (!interactive) {
|
|
35
|
+
throw new Error("Interactive installation requires a terminal for selection and confirmation. Use 'hermes list' to browse available items.");
|
|
36
|
+
}
|
|
37
|
+
const directory = values.dir;
|
|
38
|
+
if (directory !== undefined && !directory.trim()) {
|
|
39
|
+
throw new Error("Specify a destination with --dir <directory>.");
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
kind: "install-wizard",
|
|
43
|
+
request: {
|
|
44
|
+
initialNames: [...new Set(requestedNames)],
|
|
45
|
+
...(directory === undefined ? {} : { directory }),
|
|
46
|
+
dryRun: values["dry-run"] ?? false,
|
|
47
|
+
overwrite: values.overwrite ?? false,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|