@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,251 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { Range, minVersion, satisfies, valid, validRange } from "semver";
|
|
6
|
+
import { readDestination } from "./files.js";
|
|
7
|
+
import { isRecord, readJsonObject } from "./json.js";
|
|
8
|
+
export function sourceHash(content) {
|
|
9
|
+
return createHash("sha256").update(content).digest("hex");
|
|
10
|
+
}
|
|
11
|
+
// OR 범위를 각각 전개한 뒤 AND 교집합의 실제 최소 버전이 모든 범위를 만족하는지 확인한다.
|
|
12
|
+
export function commonVersionRange(ranges) {
|
|
13
|
+
let combinations = [""];
|
|
14
|
+
for (const range of new Set(ranges)) {
|
|
15
|
+
const alternatives = new Range(range).set.map((set) => set.map((entry) => entry.value).join(" "));
|
|
16
|
+
combinations = combinations
|
|
17
|
+
.flatMap((previous) => alternatives.map((alternative) => (previous + " " + alternative).trim()))
|
|
18
|
+
.filter((combined) => minVersion(combined) !== null);
|
|
19
|
+
if (!combinations.length)
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
const compatible = combinations.flatMap((combined) => {
|
|
23
|
+
const minimum = minVersion(combined);
|
|
24
|
+
if (!minimum)
|
|
25
|
+
return [];
|
|
26
|
+
if (ranges.every((range) => satisfies(minimum, range)))
|
|
27
|
+
return [combined || "*"];
|
|
28
|
+
const stable = [minimum.major, minimum.minor, minimum.patch].join(".");
|
|
29
|
+
return satisfies(stable, combined) && ranges.every((range) => satisfies(stable, range))
|
|
30
|
+
? [(combined + " >=" + stable).trim()]
|
|
31
|
+
: [];
|
|
32
|
+
});
|
|
33
|
+
return compatible.length ? [...new Set(compatible)].join(" || ") : null;
|
|
34
|
+
}
|
|
35
|
+
export function haveCommonVersion(ranges) {
|
|
36
|
+
return commonVersionRange(ranges) !== null;
|
|
37
|
+
}
|
|
38
|
+
function installedPackage(cwd, packageName) {
|
|
39
|
+
const require = createRequire(path.join(cwd, "package.json"));
|
|
40
|
+
// import 전용 exports를 쓰는 패키지도 실제 node_modules manifest에서 확인한다.
|
|
41
|
+
for (const directory of require.resolve.paths(packageName) ?? []) {
|
|
42
|
+
const file = path.join(directory, packageName, "package.json");
|
|
43
|
+
if (existsSync(file))
|
|
44
|
+
return realpathSync(file);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
return realpathSync(require.resolve(packageName + "/package.json"));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// exports로 package.json을 숨긴 패키지도 실제 entry에서 manifest를 찾는다.
|
|
51
|
+
try {
|
|
52
|
+
let directory = path.dirname(realpathSync(require.resolve(packageName)));
|
|
53
|
+
while (directory !== path.dirname(directory)) {
|
|
54
|
+
const file = path.join(directory, "package.json");
|
|
55
|
+
if (existsSync(file) && readJsonObject(file).name === packageName)
|
|
56
|
+
return file;
|
|
57
|
+
directory = path.dirname(directory);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* 아직 설치하지 않은 외부 패키지는 설치 안내에 남긴다. */
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export function planInstalledVersions(current, targets, files, catalog, cwd) {
|
|
67
|
+
const planned = new Map(files.map((file) => [file.destination, file]));
|
|
68
|
+
const guards = new Map();
|
|
69
|
+
const errors = new Set();
|
|
70
|
+
function resultingContent(destination) {
|
|
71
|
+
const file = planned.get(destination);
|
|
72
|
+
if (file)
|
|
73
|
+
return file.action === "keep" ? file.existingContent : file.content;
|
|
74
|
+
const cached = guards.get(destination);
|
|
75
|
+
if (cached)
|
|
76
|
+
return cached.existingContent;
|
|
77
|
+
const content = readDestination(destination);
|
|
78
|
+
guards.set(destination, {
|
|
79
|
+
file: path.basename(destination),
|
|
80
|
+
destination,
|
|
81
|
+
existingContent: content,
|
|
82
|
+
content: content ?? "",
|
|
83
|
+
action: content === null ? "create" : "keep",
|
|
84
|
+
});
|
|
85
|
+
return content;
|
|
86
|
+
}
|
|
87
|
+
function matches(module, directory) {
|
|
88
|
+
return Object.entries(module.files).every(([file, hash]) => {
|
|
89
|
+
const content = resultingContent(path.join(directory, file));
|
|
90
|
+
return content !== null && sourceHash(content) === hash;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const groups = new Map();
|
|
94
|
+
function group(directory) {
|
|
95
|
+
const absolute = path.resolve(cwd, directory);
|
|
96
|
+
let value = groups.get(absolute);
|
|
97
|
+
if (!value) {
|
|
98
|
+
value = { candidates: new Map(), records: [] };
|
|
99
|
+
groups.set(absolute, value);
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
function add(directory, module) {
|
|
104
|
+
const candidates = group(directory).candidates;
|
|
105
|
+
const versions = candidates.get(module.packageName) ?? [];
|
|
106
|
+
if (!versions.some((entry) => JSON.stringify(entry) === JSON.stringify(module)))
|
|
107
|
+
versions.push(module);
|
|
108
|
+
candidates.set(module.packageName, versions);
|
|
109
|
+
}
|
|
110
|
+
// 새 카탈로그와 이전 설치의 해시 중 실제 설치 결과와 일치하는 버전을 사용한다.
|
|
111
|
+
for (const target of targets)
|
|
112
|
+
for (const item of target.items)
|
|
113
|
+
for (const module of item.modules)
|
|
114
|
+
add(target.directory, module);
|
|
115
|
+
for (const record of current.installed) {
|
|
116
|
+
group(record.directory).records.push(record);
|
|
117
|
+
for (const module of record.source?.modules ?? [])
|
|
118
|
+
add(record.directory, module);
|
|
119
|
+
}
|
|
120
|
+
const sources = new Map();
|
|
121
|
+
const externals = [];
|
|
122
|
+
for (const [directory, { candidates, records }] of groups) {
|
|
123
|
+
// 내부 소스는 목적지별로 검사하고, npm peer는 프로젝트 전체 설치 기록과 비교한다.
|
|
124
|
+
const affected = targets.some((target) => target.directory === directory) ||
|
|
125
|
+
[...candidates.values()]
|
|
126
|
+
.flat()
|
|
127
|
+
.some((module) => Object.keys(module.files).some((file) => planned.has(path.join(directory, file))));
|
|
128
|
+
const providers = new Map();
|
|
129
|
+
for (const [packageName, versions] of candidates) {
|
|
130
|
+
const matched = versions.find((module) => matches(module, directory));
|
|
131
|
+
const module = matched ?? versions[0];
|
|
132
|
+
if (!module)
|
|
133
|
+
throw new Error("Missing module candidate: " + packageName);
|
|
134
|
+
providers.set(packageName, {
|
|
135
|
+
module,
|
|
136
|
+
verified: !!matched,
|
|
137
|
+
present: Object.keys(module.files).some((file) => resultingContent(path.join(directory, file)) !== null),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
for (const { module } of [...providers.values()].filter((provider) => provider.present)) {
|
|
141
|
+
externals.push(...module.requires.filter((requirement) => !requirement.internal));
|
|
142
|
+
for (const requirement of module.requires.filter((entry) => affected && entry.internal)) {
|
|
143
|
+
const provider = providers.get(requirement.packageName);
|
|
144
|
+
const mustMatch = !requirement.optional || provider?.present === true;
|
|
145
|
+
if (mustMatch &&
|
|
146
|
+
(!provider?.present || !provider.verified || !satisfies(provider.module.version, requirement.range))) {
|
|
147
|
+
errors.add(module.packageName +
|
|
148
|
+
"@" +
|
|
149
|
+
module.version +
|
|
150
|
+
" requires " +
|
|
151
|
+
requirement.packageName +
|
|
152
|
+
"@" +
|
|
153
|
+
requirement.range +
|
|
154
|
+
"; installed result: " +
|
|
155
|
+
(!provider?.present
|
|
156
|
+
? "missing"
|
|
157
|
+
: provider.verified
|
|
158
|
+
? provider.module.version
|
|
159
|
+
: "modified / unknown version") +
|
|
160
|
+
". Review the shared files and update dependent utilities together.");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
for (const record of affected ? records : []) {
|
|
165
|
+
const item = targets
|
|
166
|
+
.filter((target) => target.directory === directory)
|
|
167
|
+
.flatMap((target) => target.items)
|
|
168
|
+
.find((candidate) => candidate.name === record.name && candidate.scope === record.scope);
|
|
169
|
+
if (item) {
|
|
170
|
+
sources.set(record, {
|
|
171
|
+
catalogVersion: catalog.version,
|
|
172
|
+
requestedVersion: item.version,
|
|
173
|
+
status: item.modules.every((module) => matches(module, directory)) ? "verified" : "modified",
|
|
174
|
+
modules: item.modules.map((module) => {
|
|
175
|
+
const provider = providers.get(module.packageName);
|
|
176
|
+
return provider?.verified ? provider.module : module;
|
|
177
|
+
}),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
else if (record.source) {
|
|
181
|
+
sources.set(record, {
|
|
182
|
+
...record.source,
|
|
183
|
+
status: record.source.modules.every((module) => matches(module, directory))
|
|
184
|
+
? record.source.status
|
|
185
|
+
: "modified",
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const projectManifestPath = path.join(cwd, "package.json");
|
|
191
|
+
const projectContent = resultingContent(projectManifestPath);
|
|
192
|
+
const projectManifest = projectContent === null ? {} : JSON.parse(projectContent);
|
|
193
|
+
if (!isRecord(projectManifest))
|
|
194
|
+
throw new Error("Invalid project package.json.");
|
|
195
|
+
const packages = new Map();
|
|
196
|
+
const dependencies = [];
|
|
197
|
+
const requestedPackages = new Set(targets.flatMap((target) => target.items.flatMap((item) => item.modules.flatMap((module) => module.requires
|
|
198
|
+
.filter((requirement) => !requirement.internal && !requirement.optional)
|
|
199
|
+
.map((requirement) => requirement.packageName)))));
|
|
200
|
+
for (const requirement of externals) {
|
|
201
|
+
const requirements = packages.get(requirement.packageName) ?? [];
|
|
202
|
+
requirements.push(requirement);
|
|
203
|
+
packages.set(requirement.packageName, requirements);
|
|
204
|
+
}
|
|
205
|
+
for (const [packageName, requirements] of packages) {
|
|
206
|
+
const packageFile = installedPackage(cwd, packageName);
|
|
207
|
+
const declared = [
|
|
208
|
+
projectManifest.dependencies,
|
|
209
|
+
projectManifest.devDependencies,
|
|
210
|
+
projectManifest.peerDependencies,
|
|
211
|
+
].flatMap((value) => isRecord(value) && typeof value[packageName] === "string" ? [value[packageName]] : []);
|
|
212
|
+
if (packageFile || declared.length || requirements.some((requirement) => !requirement.optional)) {
|
|
213
|
+
const ranges = [
|
|
214
|
+
...requirements.map((requirement) => requirement.range),
|
|
215
|
+
...declared.filter((range) => validRange(range)),
|
|
216
|
+
];
|
|
217
|
+
const compatibleRange = commonVersionRange(ranges);
|
|
218
|
+
if (!compatibleRange) {
|
|
219
|
+
errors.add("Incompatible package requirements for " + packageName + ": " + ranges.join(" AND "));
|
|
220
|
+
}
|
|
221
|
+
if (compatibleRange && requestedPackages.has(packageName))
|
|
222
|
+
dependencies.push(packageName + "@" + compatibleRange);
|
|
223
|
+
if (packageFile) {
|
|
224
|
+
const content = resultingContent(packageFile);
|
|
225
|
+
const manifest = content === null ? {} : JSON.parse(content);
|
|
226
|
+
const version = isRecord(manifest) && typeof manifest.version === "string" ? manifest.version : "";
|
|
227
|
+
if (!valid(version) || requirements.some((requirement) => !satisfies(version, requirement.range))) {
|
|
228
|
+
errors.add("Installed " +
|
|
229
|
+
packageName +
|
|
230
|
+
"@" +
|
|
231
|
+
(version || "unknown") +
|
|
232
|
+
" does not satisfy " +
|
|
233
|
+
requirements.map((requirement) => requirement.range).join(" AND ") +
|
|
234
|
+
".");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
config: {
|
|
241
|
+
...current,
|
|
242
|
+
installed: current.installed.map((item) => {
|
|
243
|
+
const source = sources.get(item);
|
|
244
|
+
return source ? { ...item, source } : item;
|
|
245
|
+
}),
|
|
246
|
+
},
|
|
247
|
+
compatibilityErrors: [...errors],
|
|
248
|
+
compatibilityGuards: [...guards.values()],
|
|
249
|
+
dependencies: dependencies.sort(),
|
|
250
|
+
};
|
|
251
|
+
}
|