1 | import { Installer } from "@xmcl/installer";
|
2 | import { downloadFileWork, got } from "@xmcl/net";
|
3 | import Task from "@xmcl/task";
|
4 | import Unzip from "@xmcl/unzip";
|
5 | import { JavaExecutor, MinecraftFolder, MinecraftLocation, vfs } from "@xmcl/util";
|
6 | import { Version } from "@xmcl/version";
|
7 | import * as path from "path";
|
8 | import { Readable } from "stream";
|
9 |
|
10 | async function findMainClass(lib: string) {
|
11 | const zip = await Unzip.open(lib, { lazyEntries: true });
|
12 | const [manifest] = await zip.filterEntries(["META-INF/MANIFEST.MF"]);
|
13 | let mainClass: string | undefined;
|
14 | if (manifest) {
|
15 | const content = await zip.readEntry(manifest).then((b) => b.toString());
|
16 | const mainClassPair = content.split("\n").map((l) => l.split(": ")).filter((arr) => arr[0] === "Main-Class")[0];
|
17 | if (mainClassPair) {
|
18 | mainClass = mainClassPair[1].trim();
|
19 | }
|
20 | }
|
21 | zip.close();
|
22 | return mainClass;
|
23 | }
|
24 |
|
25 |
|
26 |
|
27 |
|
28 | export namespace ForgeInstaller {
|
29 | |
30 |
|
31 |
|
32 | export interface VersionMeta {
|
33 | |
34 |
|
35 |
|
36 | installer: {
|
37 | md5: string;
|
38 | sha1: string;
|
39 | |
40 |
|
41 |
|
42 | path: string;
|
43 | };
|
44 | universal: {
|
45 | md5: string;
|
46 | sha1: string;
|
47 | |
48 |
|
49 |
|
50 | path: string;
|
51 | };
|
52 | |
53 |
|
54 |
|
55 | mcversion: string;
|
56 | |
57 |
|
58 |
|
59 | version: string;
|
60 | }
|
61 |
|
62 | export const DEFAULT_FORGE_MAVEN = "http://files.minecraftforge.net";
|
63 |
|
64 | export interface InstallProfile {
|
65 | spec: number;
|
66 | profile: string;
|
67 | version: string;
|
68 | json: string;
|
69 | path: string;
|
70 | minecraft: string;
|
71 | data: {
|
72 | [key: string]: {
|
73 | client: string,
|
74 | server: string,
|
75 | },
|
76 | };
|
77 | processors: Array<{
|
78 | jar: string,
|
79 | classpath: string[],
|
80 | args: string[],
|
81 | outputs?: {
|
82 | [key: string]: string,
|
83 | },
|
84 | }>;
|
85 | libraries: Version.NormalLibrary[];
|
86 | }
|
87 |
|
88 | |
89 |
|
90 |
|
91 | export interface Diagnosis {
|
92 | |
93 |
|
94 |
|
95 | badInstall: boolean;
|
96 | |
97 |
|
98 |
|
99 | badProcessedFiles: Array<InstallProfile["processors"][number]>;
|
100 | badVersionJson: boolean;
|
101 | |
102 |
|
103 |
|
104 | missingInstallDependencies: Version.NormalLibrary[];
|
105 |
|
106 | missingBinpatch: boolean;
|
107 |
|
108 | |
109 |
|
110 |
|
111 | missingSrgJar: boolean;
|
112 | |
113 |
|
114 |
|
115 | missingMinecraftExtraJar: boolean;
|
116 | |
117 |
|
118 |
|
119 | missingForgePatchesJar: boolean;
|
120 | }
|
121 |
|
122 | |
123 |
|
124 |
|
125 |
|
126 |
|
127 |
|
128 |
|
129 | export async function diagnoseForgeVersion(versionOrProfile: string | InstallProfile, minecraft: MinecraftLocation): Promise<Diagnosis> {
|
130 | const version = typeof versionOrProfile === "string" ? versionOrProfile : versionOrProfile.version;
|
131 | const mc = MinecraftFolder.from(minecraft);
|
132 | const verRoot = mc.getVersionRoot(version);
|
133 | const versionJsonPath = mc.getVersionJson(version);
|
134 |
|
135 | const diag: Diagnosis = {
|
136 | badProcessedFiles: [],
|
137 | missingInstallDependencies: [],
|
138 | badVersionJson: false,
|
139 | missingBinpatch: false,
|
140 | badInstall: false,
|
141 | missingSrgJar: false,
|
142 | missingMinecraftExtraJar: false,
|
143 | missingForgePatchesJar: false,
|
144 | };
|
145 |
|
146 | let prof: InstallProfile | undefined;
|
147 | if (typeof versionOrProfile === "string") {
|
148 | const installProfPath = path.join(verRoot, "install_profile.json");
|
149 | if (await vfs.exists(installProfPath)) {
|
150 | prof = JSON.parse(await vfs.readFile(installProfPath).then((b) => b.toString()));
|
151 | }
|
152 | } else {
|
153 | prof = versionOrProfile;
|
154 | }
|
155 | if (prof) {
|
156 | const processedProfile = postProcessInstallProfile(mc, prof);
|
157 | for (const proc of processedProfile.processors) {
|
158 | if (proc.outputs) {
|
159 | let bad = false;
|
160 | for (const file in proc.outputs) {
|
161 | if (! await vfs.validate(file, { algorithm: "sha1", hash: proc.outputs[file].replace(/\'/g, "") })) {
|
162 | bad = true;
|
163 | break;
|
164 | }
|
165 | }
|
166 | if (bad) {
|
167 | diag.badProcessedFiles.push(proc);
|
168 | }
|
169 | }
|
170 | }
|
171 |
|
172 | if (diag.badProcessedFiles.length !== 0) {
|
173 | const libValidMask = await Promise.all(processedProfile.libraries.map(async (lib) => {
|
174 | const artifact = lib.downloads.artifact;
|
175 | const libPath = mc.getLibraryByPath(artifact.path);
|
176 | if (await vfs.exists(libPath)) {
|
177 | return artifact.sha1 ? vfs.validate(libPath) : true;
|
178 | }
|
179 | return false;
|
180 | }));
|
181 | const missingLibraries = processedProfile.libraries.filter((_, i) => !libValidMask[i]);
|
182 | diag.missingInstallDependencies.push(...missingLibraries);
|
183 |
|
184 | const validClient = await vfs.stat(processedProfile.data.BINPATCH.client).then((s) => s.size !== 0).catch((_) => false);
|
185 | if (!validClient) {
|
186 | diag.missingBinpatch = true;
|
187 | diag.badInstall = true;
|
188 | }
|
189 | }
|
190 | }
|
191 | if (await vfs.exists(versionJsonPath)) {
|
192 | const versionJSON: Version = JSON.parse(await vfs.readFile(versionJsonPath).then((b) => b.toString()));
|
193 | if (versionJSON.arguments && versionJSON.arguments.game) {
|
194 | const args = versionJSON.arguments.game;
|
195 | const forgeVersion = args.indexOf("--fml.forgeVersion") + 1;
|
196 | const mcVersion = args.indexOf("--fml.mcVersion") + 1;
|
197 | const mcpVersion = args.indexOf("--fml.mcpVersion") + 1;
|
198 | if (!forgeVersion || !mcVersion || !mcpVersion) {
|
199 | diag.badVersionJson = true;
|
200 | diag.badInstall = true;
|
201 | } else {
|
202 | const srgPath = mc.getLibraryByPath(`net/minecraft/client/${mcVersion}-${mcpVersion}/client-${mcVersion}-${mcpVersion}-srg.jar`);
|
203 | const extraPath = mc.getLibraryByPath(`net/minecraft/client/${mcVersion}/client-${mcVersion}-extra.jar`);
|
204 | const forgePatchPath = mc.getLibraryByPath(`net/minecraftforge/forge/${mcVersion}-${forgeVersion}/forge-${mcVersion}-${forgeVersion}-client.jar`);
|
205 | diag.missingSrgJar = await vfs.missing(srgPath);
|
206 | diag.missingMinecraftExtraJar = await vfs.missing(extraPath);
|
207 | diag.missingForgePatchesJar = await vfs.missing(forgePatchPath);
|
208 | }
|
209 | } else {
|
210 | diag.badVersionJson = true;
|
211 | diag.badInstall = true;
|
212 | }
|
213 | } else {
|
214 | diag.badVersionJson = true;
|
215 | diag.badInstall = true;
|
216 | }
|
217 |
|
218 | return diag;
|
219 | }
|
220 |
|
221 | |
222 |
|
223 |
|
224 |
|
225 |
|
226 |
|
227 |
|
228 | export async function postProcess(mc: MinecraftFolder, proc: InstallProfile["processors"][number], java: JavaExecutor) {
|
229 | const jarRealPath = mc.getLibraryByPath(Version.getLibraryInfo(proc.jar).path);
|
230 | const mainClass = await findMainClass(jarRealPath);
|
231 | if (!mainClass) { throw new Error(`Cannot find main class for processor ${proc.jar}.`); }
|
232 | const cp = [...proc.classpath, proc.jar].map(Version.getLibraryInfo).map((p) => mc.getLibraryByPath(p.path)).join(path.delimiter);
|
233 | const cmd = ["-cp", cp, mainClass, ...proc.args];
|
234 | await java(cmd);
|
235 | let failed = false;
|
236 | if (proc.outputs) {
|
237 | for (const file in proc.outputs) {
|
238 | if (! await vfs.validate(file, { algorithm: "sha1", hash: proc.outputs[file].replace(/\'/g, "") })) {
|
239 | console.error(`Fail to process ${proc.jar} @ ${file} since its validation failed.`);
|
240 | failed = true;
|
241 | }
|
242 | }
|
243 | }
|
244 | if (failed) {
|
245 | console.error(`Java arguments: ${JSON.stringify(cmd)}`);
|
246 | throw new Error(`Fail to process post processing since its validation failed.`);
|
247 | }
|
248 | }
|
249 |
|
250 | function postProcessInstallProfile(mc: MinecraftFolder, installProfile: InstallProfile) {
|
251 | function processValue(v: string) {
|
252 | if (v.match(/^\[.+\]$/g)) {
|
253 | const targetId = v.substring(1, v.length - 1);
|
254 | return mc.getLibraryByPath(Version.getLibraryInfo(targetId).path);
|
255 | }
|
256 | return v;
|
257 | }
|
258 | function processMapping(data: InstallProfile["data"], m: string) {
|
259 | m = processValue(m);
|
260 | if (m.match(/^{.+}$/g)) {
|
261 | const key = m.substring(1, m.length - 1);
|
262 | m = data[key].client;
|
263 | }
|
264 | return m;
|
265 | }
|
266 | const profile: InstallProfile = JSON.parse(JSON.stringify(installProfile));
|
267 | profile.data.MINECRAFT_JAR = {
|
268 | client: mc.getVersionJar(profile.minecraft),
|
269 | server: "",
|
270 | };
|
271 | for (const key in profile.data) {
|
272 | const value = profile.data[key];
|
273 | value.client = processValue(value.client);
|
274 | value.server = processValue(value.server);
|
275 |
|
276 | if (key === "BINPATCH") {
|
277 | const verRoot = mc.getVersionRoot(profile.version);
|
278 | value.client = path.join(verRoot, value.client);
|
279 | value.server = path.join(verRoot, value.server);
|
280 | }
|
281 | }
|
282 | for (const proc of profile.processors) {
|
283 | proc.args = proc.args.map((a) => processMapping(profile.data, a));
|
284 | if (proc.outputs) {
|
285 | const replacedOutput: InstallProfile["processors"][0]["outputs"] = {};
|
286 | for (const key in proc.outputs) {
|
287 | replacedOutput[processMapping(profile.data, key)] = processMapping(profile.data, proc.outputs[key]);
|
288 | }
|
289 | proc.outputs = replacedOutput;
|
290 | }
|
291 | }
|
292 |
|
293 | return profile;
|
294 | }
|
295 |
|
296 | |
297 |
|
298 |
|
299 |
|
300 |
|
301 | export function installByInstallerPartialTask(version: string | InstallProfile, minecraft: MinecraftLocation, option: {
|
302 | java?: JavaExecutor,
|
303 | } & Installer.LibraryOption = {}) {
|
304 | return Task.create("installForge", async (context) => {
|
305 | const mc = MinecraftFolder.from(minecraft);
|
306 | let prof: InstallProfile;
|
307 | let ver: Version;
|
308 | if (typeof version === "string") {
|
309 | const versionRoot = mc.getVersionRoot(version);
|
310 | prof = await vfs.readFile(path.join(versionRoot, "install_profile.json")).then((b) => b.toString()).then(JSON.parse);
|
311 | } else {
|
312 | prof = version;
|
313 | }
|
314 | ver = await vfs.readFile(mc.getVersionJson(prof.version)).then((b) => b.toString()).then(JSON.parse);
|
315 | await installByInstallerPartialWork(mc, prof, ver, option.java || JavaExecutor.createSimple("java"), option)(context);
|
316 | });
|
317 | }
|
318 |
|
319 | |
320 |
|
321 |
|
322 |
|
323 |
|
324 | export async function installByInstallerPartial(version: string | InstallProfile, minecraft: MinecraftLocation, option: {
|
325 | java?: JavaExecutor,
|
326 | } & Installer.LibraryOption = {}) {
|
327 | return installByInstallerPartialTask(version, minecraft, option).execute();
|
328 | }
|
329 |
|
330 | function installByInstallerPartialWork(mc: MinecraftFolder, profile: InstallProfile, versionJson: Version, java: JavaExecutor, installLibOption: Installer.LibraryOption) {
|
331 | return async (context: Task.Context) => {
|
332 | profile = postProcessInstallProfile(mc, profile);
|
333 |
|
334 | const parsedLibs = Version.resolveLibraries([...profile.libraries, ...versionJson.libraries]);
|
335 | await context.execute("downloadLibraries", Installer.installLibrariesDirectTask(parsedLibs, mc, installLibOption).work);
|
336 |
|
337 | await context.execute("postProcessing", async (ctx) => {
|
338 | ctx.update(0, profile.processors.length);
|
339 | let i = 0;
|
340 | const errs: Error[] = [];
|
341 | for (const proc of profile.processors) {
|
342 | try {
|
343 | await postProcess(mc, proc, java);
|
344 | } catch (e) {
|
345 | errs.push(e);
|
346 | }
|
347 | ctx.update(i += 1, profile.processors.length);
|
348 | }
|
349 | i += 1;
|
350 | ctx.update(i, profile.processors.length);
|
351 |
|
352 | if (errs.length !== 0) {
|
353 | throw new Error(`Fail to post processing`);
|
354 | }
|
355 | });
|
356 | };
|
357 | }
|
358 |
|
359 | function installByInstallerTask(version: VersionMeta, minecraft: MinecraftLocation, maven: string, installLibOption: Installer.LibraryOption, java: JavaExecutor, tempDir?: string, clearTempDir: boolean = true) {
|
360 | return async (context: Task.Context) => {
|
361 | const mc = typeof minecraft === "string" ? new MinecraftFolder(minecraft) : minecraft;
|
362 | const forgeVersion = `${version.mcversion}-${version.version}`;
|
363 | let versionId: string;
|
364 |
|
365 | const installerURLFallback = `${maven}/maven/net/minecraftforge/forge/${forgeVersion}/forge-${forgeVersion}-installer.jar`;
|
366 | const installerURL = `${maven}${version.installer.path}`;
|
367 |
|
368 | function downloadInstallerTask(installer: string, dest: string) {
|
369 | return async (ctx: Task.Context) => {
|
370 | let inStream;
|
371 | if (await vfs.validate(dest, { algorithm: "md5", hash: version.installer.md5 }, { algorithm: "sha1", hash: version.installer.sha1 })) {
|
372 | inStream = vfs.createReadStream(dest);
|
373 | }
|
374 | if (!inStream) {
|
375 | inStream = got.stream(installer, {
|
376 | method: "GET",
|
377 | headers: { connection: "keep-alive" },
|
378 | }).on("error", () => { })
|
379 | .on("downloadProgress", (progress) => { ctx.update(progress.transferred, progress.total as number); });
|
380 |
|
381 | inStream.pipe(vfs.createWriteStream(dest));
|
382 | }
|
383 |
|
384 | return inStream.pipe(Unzip.createParseStream({ lazyEntries: true }))
|
385 | .wait();
|
386 | };
|
387 | }
|
388 |
|
389 | const temp = tempDir || await vfs.mkdtemp(mc.root + path.sep);
|
390 | await vfs.ensureDir(temp);
|
391 | const installJar = path.join(temp, "forge-installer.jar");
|
392 | let profile!: InstallProfile;
|
393 | let versionJson!: Version;
|
394 |
|
395 | async function processVersion(zip: Unzip.ZipFile, installProfileEntry: Unzip.Entry, versionEntry: Unzip.Entry, clientDataEntry: Unzip.Entry) {
|
396 | profile = await zip.readEntry(installProfileEntry).then((b) => b.toString()).then(JSON.parse);
|
397 | versionJson = await zip.readEntry(versionEntry).then((b) => b.toString()).then(JSON.parse);
|
398 | versionId = versionJson.id;
|
399 |
|
400 | const rootPath = mc.getVersionRoot(versionJson.id);
|
401 | const jsonPath = path.join(rootPath, `${versionJson.id}.json`);
|
402 | const installJsonPath = path.join(rootPath, `install_profile.json`);
|
403 | const clientDataPath = path.join(rootPath, profile.data.BINPATCH.client);
|
404 |
|
405 | await vfs.ensureFile(jsonPath);
|
406 | await vfs.writeFile(installJsonPath, JSON.stringify(profile));
|
407 | await vfs.writeFile(jsonPath, JSON.stringify(versionJson));
|
408 |
|
409 | await vfs.ensureFile(clientDataPath);
|
410 | const stream = await zip.openEntry(clientDataEntry);
|
411 | await vfs.waitStream(stream.pipe(vfs.createWriteStream(clientDataPath)));
|
412 | }
|
413 | async function processExtractLibrary(s: Readable, p: string) {
|
414 | const file = mc.getLibraryByPath(p);
|
415 | await vfs.ensureFile(file);
|
416 | await vfs.waitStream(s.pipe(vfs.createWriteStream(file)));
|
417 | }
|
418 | try {
|
419 | let zip: Unzip.LazyZipFile;
|
420 | try {
|
421 | zip = await context.execute("downloadInstaller", downloadInstallerTask(installerURL, installJar));
|
422 | } catch {
|
423 | zip = await context.execute("downloadInstaller", downloadInstallerTask(installerURLFallback, installJar));
|
424 | }
|
425 |
|
426 | const [forgeEntry, forgeUniversalEntry, clientDataEntry, installProfileEntry, versionEntry] = await zip.filterEntries([
|
427 | `maven/net/minecraftforge/forge/${forgeVersion}/forge-${forgeVersion}.jar`,
|
428 | `maven/net/minecraftforge/forge/${forgeVersion}/forge-${forgeVersion}-universal.jar`,
|
429 | "data/client.lzma",
|
430 | "install_profile.json",
|
431 | "version.json"]);
|
432 |
|
433 | if (!forgeEntry) {
|
434 | throw new Error("Missing forge jar entry");
|
435 | }
|
436 | if (!forgeUniversalEntry) {
|
437 | throw new Error("Missing forge universal entry");
|
438 | }
|
439 | if (!installProfileEntry) {
|
440 | throw new Error("Missing install profile");
|
441 | }
|
442 | if (!versionEntry) {
|
443 | throw new Error("Missing version entry");
|
444 | }
|
445 |
|
446 | await processExtractLibrary(await zip.openEntry(forgeEntry), forgeEntry.fileName);
|
447 | await processExtractLibrary(await zip.openEntry(forgeUniversalEntry), forgeUniversalEntry.fileName);
|
448 | await processVersion(zip, installProfileEntry, versionEntry, clientDataEntry);
|
449 |
|
450 | await installByInstallerPartialWork(mc, profile, versionJson, java, installLibOption)(context);
|
451 |
|
452 | return versionId!;
|
453 | } catch (e) {
|
454 | if (clearTempDir) {
|
455 | await vfs.remove(temp);
|
456 | }
|
457 | throw e;
|
458 | }
|
459 | };
|
460 | }
|
461 |
|
462 | function installByUniversalTask(version: VersionMeta, minecraft: MinecraftLocation, maven: string, checkDependecies: boolean) {
|
463 | return async (context: Task.Context) => {
|
464 | const mc = typeof minecraft === "string" ? new MinecraftFolder(minecraft) : minecraft;
|
465 | const forgeVersion = `${version.mcversion}-${version.version}`;
|
466 | const jarPath = mc.getLibraryByPath(`net/minecraftforge/forge/${forgeVersion}/forge-${forgeVersion}.jar`);
|
467 | let fullVersion: string;
|
468 | let realJarPath: string;
|
469 |
|
470 | const universalURLFallback = `${maven}/maven/net/minecraftforge/forge/${forgeVersion}/forge-${forgeVersion}-universal.jar`;
|
471 | const universalURL = `${maven}${version.universal.path}`;
|
472 |
|
473 | await context.execute("installForgeJar", async () => {
|
474 | if (await vfs.exists(jarPath)) {
|
475 | const valid = await vfs.validate(jarPath, { algorithm: "md5", hash: version.universal.md5 }, { algorithm: "sha1", hash: version.universal.sha1 });
|
476 | if (valid) {
|
477 | return;
|
478 | }
|
479 | }
|
480 |
|
481 | try {
|
482 | await context.execute("downloadJar", downloadFileWork({ url: universalURL, destination: jarPath }));
|
483 | } catch {
|
484 | await context.execute("redownloadJar", downloadFileWork({ url: universalURLFallback, destination: jarPath }));
|
485 | }
|
486 | });
|
487 |
|
488 | await context.execute("installForgeJson", async () => {
|
489 | const zip = await Unzip.open(jarPath, { lazyEntries: true });
|
490 | const [versionEntry] = await zip.filterEntries(["version.json"]);
|
491 |
|
492 | if (versionEntry) {
|
493 | const buf = await zip.readEntry(versionEntry);
|
494 | const raw = JSON.parse(buf.toString());
|
495 | const id = raw.id;
|
496 | fullVersion = id;
|
497 | const rootPath = mc.getVersionRoot(fullVersion);
|
498 | realJarPath = mc.getLibraryByPath(Version.getLibraryInfo(raw.libraries.find((l: any) => l.name.startsWith("net.minecraftforge:forge"))).path);
|
499 |
|
500 | await vfs.ensureDir(rootPath);
|
501 | await vfs.writeFile(path.join(rootPath, `${id}.json`), buf);
|
502 | } else {
|
503 | throw new Error(`Cannot install forge json for ${version.version} since the version json is missing!`);
|
504 | }
|
505 | });
|
506 |
|
507 | if (realJarPath! !== jarPath) {
|
508 | await vfs.ensureFile(realJarPath!);
|
509 | await vfs.copyFile(jarPath, realJarPath!);
|
510 | await vfs.unlink(jarPath);
|
511 | }
|
512 |
|
513 | if (checkDependecies) {
|
514 | const resolvedVersion = await Version.parse(minecraft, fullVersion!);
|
515 | context.execute("installDependencies", Installer.installDependenciesTask(resolvedVersion).work);
|
516 | }
|
517 |
|
518 | return fullVersion!;
|
519 | };
|
520 | }
|
521 |
|
522 | |
523 |
|
524 |
|
525 |
|
526 |
|
527 | export function install(version: VersionMeta, minecraft: MinecraftLocation, option?: {
|
528 | maven?: string,
|
529 | forceCheckDependencies?: boolean,
|
530 | java?: JavaExecutor,
|
531 | tempDir?: string,
|
532 | clearTempDirAfterInstall?: boolean,
|
533 | }) {
|
534 | return installTask(version, minecraft, option).execute();
|
535 | }
|
536 |
|
537 | |
538 |
|
539 |
|
540 |
|
541 |
|
542 | export function installTask(version: VersionMeta, minecraft: MinecraftLocation, option: {
|
543 | maven?: string,
|
544 | forceInstallDependencies?: boolean,
|
545 | java?: JavaExecutor,
|
546 | tempDir?: string,
|
547 | clearTempDirAfterInstall?: boolean,
|
548 | } & Installer.LibraryOption = {}): Task<string> {
|
549 | let byInstaller = true;
|
550 | try {
|
551 | const minorVersion = Number.parseInt(version.mcversion.split(".")[1], 10);
|
552 | byInstaller = minorVersion >= 13;
|
553 | } catch { }
|
554 | const work = byInstaller ? installByInstallerTask(version, minecraft, option.maven || DEFAULT_FORGE_MAVEN, option, option.java || JavaExecutor.createSimple("java"), option.tempDir, option.clearTempDirAfterInstall)
|
555 | : installByUniversalTask(version, minecraft, option.maven || DEFAULT_FORGE_MAVEN, option.forceInstallDependencies === true);
|
556 | return Task.create("installForge", work);
|
557 | }
|
558 | }
|
559 |
|
560 | export * from "./forgeweb";
|
561 |
|
562 | export default ForgeInstaller;
|