@zaxlck/modder-installer 0.0.0-stage → 0.1.3

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.
@@ -0,0 +1,767 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const os = require("os");
4
+
5
+ const {
6
+ zipSync,
7
+ strToU8,
8
+ strFromU8,
9
+ Unzip,
10
+ UnzipInflate,
11
+ UnzipPassThrough
12
+ } = require("../backend/node_modules/fflate");
13
+
14
+ const MAX_ARCHIVE_SIZE = 250 * 1024 * 1024;
15
+ const MAX_TOTAL_SIZE = 500 * 1024 * 1024;
16
+ const MAX_FILE_SIZE = 250 * 1024 * 1024;
17
+ const MAX_FILES = 1000;
18
+
19
+ function safeName(value) {
20
+ return typeof value === "string" &&
21
+ /^[a-zA-Z0-9_-]{1,64}$/.test(value);
22
+ }
23
+
24
+ function safeArchivePath(name) {
25
+ if (typeof name !== "string" || !name || name.includes("\0")) {
26
+ return false;
27
+ }
28
+
29
+ // ZIP paths should use forward slashes.
30
+ if (name.includes("\\")) {
31
+ return false;
32
+ }
33
+
34
+ if (
35
+ name.startsWith("/") ||
36
+ name.startsWith("//") ||
37
+ /^[a-zA-Z]:/.test(name)
38
+ ) {
39
+ return false;
40
+ }
41
+
42
+ const isDirectory = name.endsWith("/");
43
+ const checkedName = isDirectory ? name.slice(0, -1) : name;
44
+
45
+ if (!checkedName) return false;
46
+
47
+ const parts = checkedName.split("/");
48
+
49
+ for (const part of parts) {
50
+ if (
51
+ !part ||
52
+ part === "." ||
53
+ part === ".." ||
54
+ /[<>:"|?*\x00-\x1F]/.test(part) ||
55
+ /[. ]$/.test(part) ||
56
+ /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$/i.test(part)
57
+ ) {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ return true;
63
+ }
64
+
65
+ function isInside(parent, target) {
66
+ const relative = path.relative(parent, target);
67
+
68
+ return relative !== "" &&
69
+ relative !== ".." &&
70
+ !relative.startsWith(".." + path.sep) &&
71
+ !path.isAbsolute(relative);
72
+ }
73
+
74
+
75
+ const CRC32_TABLE = new Uint32Array(256);
76
+
77
+ for (let i = 0; i < 256; i++) {
78
+ let value = i;
79
+
80
+ for (let bit = 0; bit < 8; bit++) {
81
+ value = (value & 1)
82
+ ? 0xEDB88320 ^ (value >>> 1)
83
+ : value >>> 1;
84
+ }
85
+
86
+ CRC32_TABLE[i] = value >>> 0;
87
+ }
88
+
89
+ function crc32(data) {
90
+ let checksum = 0xFFFFFFFF;
91
+
92
+ for (let i = 0; i < data.length; i++) {
93
+ checksum =
94
+ CRC32_TABLE[(checksum ^ data[i]) & 0xFF] ^
95
+ (checksum >>> 8);
96
+ }
97
+
98
+ return (checksum ^ 0xFFFFFFFF) >>> 0;
99
+ }
100
+
101
+ // Validate the ZIP directory and local headers before extracting anything.
102
+ function validateZipStructure(buffer) {
103
+ const expectedCrcs = new Map();
104
+ const minimumEOCD = 22;
105
+ const maximumComment = 65535;
106
+
107
+ if (buffer.length < minimumEOCD) {
108
+ throw new Error("Invalid or damaged .modder archive.");
109
+ }
110
+
111
+ let eocd = -1;
112
+ const searchStart = Math.max(
113
+ 0,
114
+ buffer.length - minimumEOCD - maximumComment
115
+ );
116
+
117
+ for (let i = buffer.length - minimumEOCD; i >= searchStart; i--) {
118
+ if (buffer.readUInt32LE(i) === 0x06054b50) {
119
+ const commentLength = buffer.readUInt16LE(i + 20);
120
+
121
+ if (i + minimumEOCD + commentLength === buffer.length) {
122
+ eocd = i;
123
+ break;
124
+ }
125
+ }
126
+ }
127
+
128
+ if (eocd === -1) {
129
+ throw new Error("Invalid or damaged .modder archive.");
130
+ }
131
+
132
+ const diskNumber = buffer.readUInt16LE(eocd + 4);
133
+ const centralDisk = buffer.readUInt16LE(eocd + 6);
134
+ const diskEntries = buffer.readUInt16LE(eocd + 8);
135
+ const totalEntries = buffer.readUInt16LE(eocd + 10);
136
+ const centralSize = buffer.readUInt32LE(eocd + 12);
137
+ const centralOffset = buffer.readUInt32LE(eocd + 16);
138
+
139
+ // Modder currently supports standard single-disk ZIP archives only.
140
+ if (
141
+ diskNumber !== 0 ||
142
+ centralDisk !== 0 ||
143
+ diskEntries !== totalEntries ||
144
+ totalEntries === 0xffff ||
145
+ centralSize === 0xffffffff ||
146
+ centralOffset === 0xffffffff
147
+ ) {
148
+ throw new Error("Unsupported or invalid ZIP structure.");
149
+ }
150
+
151
+ if (
152
+ centralOffset + centralSize !== eocd ||
153
+ centralOffset > buffer.length
154
+ ) {
155
+ throw new Error("Invalid or damaged .modder archive.");
156
+ }
157
+
158
+ let cursor = centralOffset;
159
+
160
+ for (let i = 0; i < totalEntries; i++) {
161
+ if (
162
+ cursor + 46 > eocd ||
163
+ buffer.readUInt32LE(cursor) !== 0x02014b50
164
+ ) {
165
+ throw new Error("Invalid ZIP central directory.");
166
+ }
167
+
168
+ const flags = buffer.readUInt16LE(cursor + 8);
169
+ const expectedCrc = buffer.readUInt32LE(cursor + 16);
170
+ const method = buffer.readUInt16LE(cursor + 10);
171
+ const compressedSize = buffer.readUInt32LE(cursor + 20);
172
+ const uncompressedSize = buffer.readUInt32LE(cursor + 24);
173
+ const nameLength = buffer.readUInt16LE(cursor + 28);
174
+ const extraLength = buffer.readUInt16LE(cursor + 30);
175
+ const commentLength = buffer.readUInt16LE(cursor + 32);
176
+ const startDisk = buffer.readUInt16LE(cursor + 34);
177
+ const localOffset = buffer.readUInt32LE(cursor + 42);
178
+
179
+ const entryEnd =
180
+ cursor + 46 + nameLength + extraLength + commentLength;
181
+
182
+ if (
183
+ entryEnd > eocd ||
184
+ startDisk !== 0 ||
185
+ compressedSize === 0xffffffff ||
186
+ uncompressedSize === 0xffffffff ||
187
+ localOffset === 0xffffffff ||
188
+ (flags & 1) !== 0 ||
189
+ (method !== 0 && method !== 8)
190
+ ) {
191
+ throw new Error("Unsupported or invalid ZIP entry.");
192
+ }
193
+
194
+ if (
195
+ localOffset + 30 > centralOffset ||
196
+ buffer.readUInt32LE(localOffset) !== 0x04034b50
197
+ ) {
198
+ throw new Error("Invalid ZIP local file header.");
199
+ }
200
+
201
+ const localFlags = buffer.readUInt16LE(localOffset + 6);
202
+ const localMethod = buffer.readUInt16LE(localOffset + 8);
203
+ const localNameLength = buffer.readUInt16LE(localOffset + 26);
204
+ const localExtraLength = buffer.readUInt16LE(localOffset + 28);
205
+
206
+ const localNameStart = localOffset + 30;
207
+ const localDataStart =
208
+ localNameStart + localNameLength + localExtraLength;
209
+
210
+ const centralNameStart = cursor + 46;
211
+ const entryName = strFromU8(
212
+ buffer.subarray(
213
+ centralNameStart,
214
+ centralNameStart + nameLength
215
+ ),
216
+ (flags & 0x0800) === 0
217
+ );
218
+
219
+ expectedCrcs.set(entryName, expectedCrc);
220
+
221
+ if (
222
+ localFlags !== flags ||
223
+ localMethod !== method ||
224
+ localNameLength !== nameLength ||
225
+ localDataStart + compressedSize > centralOffset ||
226
+ !buffer.subarray(
227
+ localNameStart,
228
+ localNameStart + localNameLength
229
+ ).equals(
230
+ buffer.subarray(
231
+ centralNameStart,
232
+ centralNameStart + nameLength
233
+ )
234
+ )
235
+ ) {
236
+ throw new Error("Invalid ZIP local file entry.");
237
+ }
238
+
239
+ cursor = entryEnd;
240
+ }
241
+
242
+ if (cursor !== centralOffset + centralSize) {
243
+ throw new Error("Invalid ZIP central directory size.");
244
+ }
245
+
246
+ return expectedCrcs;
247
+ }
248
+
249
+ function readArchive(filePath) {
250
+ if (
251
+ !fs.existsSync(filePath) ||
252
+ !fs.statSync(filePath).isFile()
253
+ ) {
254
+ throw new Error("The .modder file does not exist.");
255
+ }
256
+
257
+ if (path.extname(filePath).toLowerCase() !== ".modder") {
258
+ throw new Error("Expected a .modder file.");
259
+ }
260
+
261
+ const archiveSize = fs.statSync(filePath).size;
262
+
263
+ if (archiveSize > MAX_ARCHIVE_SIZE) {
264
+ throw new Error("Archive exceeds the 250 MB limit.");
265
+ }
266
+
267
+ let archiveData;
268
+
269
+ try {
270
+ archiveData = fs.readFileSync(filePath);
271
+ } catch {
272
+ throw new Error("Could not read the .modder archive.");
273
+ }
274
+
275
+ const expectedCrcs = validateZipStructure(archiveData);
276
+
277
+ const files = {};
278
+ const seenNames = new Set();
279
+
280
+ let fileCount = 0;
281
+ let totalSize = 0;
282
+ let failure = null;
283
+
284
+ try {
285
+ const unzipper = new Unzip();
286
+
287
+ unzipper.register(UnzipPassThrough);
288
+ unzipper.register(UnzipInflate);
289
+
290
+ unzipper.onfile = (entry) => {
291
+ if (failure) {
292
+ entry.terminate();
293
+ return;
294
+ }
295
+
296
+ fileCount++;
297
+
298
+ if (fileCount > MAX_FILES) {
299
+ failure = new Error("Archive contains too many files.");
300
+ entry.terminate();
301
+ return;
302
+ }
303
+
304
+ const name = entry.name;
305
+
306
+ if (!safeArchivePath(name)) {
307
+ failure = new Error("Unsafe archive path: " + name);
308
+ entry.terminate();
309
+ return;
310
+ }
311
+
312
+ // Directory entries are allowed only when they contain no data.
313
+ if (name.endsWith("/")) {
314
+ entry.ondata = (error, data) => {
315
+ if (error) {
316
+ failure = new Error("Invalid or damaged .modder archive.");
317
+ return;
318
+ }
319
+
320
+ if (data.length > 0) {
321
+ failure = new Error("Directory entries must not contain file data.");
322
+ entry.terminate();
323
+ }
324
+ };
325
+
326
+ entry.start();
327
+ return;
328
+ }
329
+
330
+ // Windows paths are case-insensitive.
331
+ const key = name.toLowerCase();
332
+
333
+ if (seenNames.has(key)) {
334
+ failure = new Error("Archive contains duplicate paths: " + name);
335
+ entry.terminate();
336
+ return;
337
+ }
338
+
339
+ seenNames.add(key);
340
+
341
+ const chunks = [];
342
+ let fileSize = 0;
343
+
344
+ entry.ondata = (error, data, final) => {
345
+ if (error) {
346
+ failure = new Error("Invalid or damaged .modder archive.");
347
+ return;
348
+ }
349
+
350
+ if (failure) return;
351
+
352
+ fileSize += data.length;
353
+ totalSize += data.length;
354
+
355
+ // Count actual decompressed bytes, not ZIP metadata.
356
+ if (fileSize > MAX_FILE_SIZE) {
357
+ failure = new Error("An archive file exceeds the 250 MB limit.");
358
+ entry.terminate();
359
+ return;
360
+ }
361
+
362
+ if (totalSize > MAX_TOTAL_SIZE) {
363
+ failure = new Error("Archive expands beyond the 500 MB limit.");
364
+ entry.terminate();
365
+ return;
366
+ }
367
+
368
+ if (data.length > 0) {
369
+ chunks.push(Buffer.from(data));
370
+ }
371
+
372
+ if (final) {
373
+ const extracted = Buffer.concat(chunks, fileSize);
374
+ const expectedCrc = expectedCrcs.get(name);
375
+
376
+ if (
377
+ expectedCrc === undefined ||
378
+ crc32(extracted) !== expectedCrc
379
+ ) {
380
+ failure = new Error(
381
+ "Invalid or damaged .modder archive."
382
+ );
383
+ return;
384
+ }
385
+
386
+ files[name] = extracted;
387
+ }
388
+ };
389
+
390
+ entry.start();
391
+ };
392
+
393
+ unzipper.push(archiveData, true);
394
+
395
+ if (failure) {
396
+ throw failure;
397
+ }
398
+ } catch (error) {
399
+ if (error && error.message && (
400
+ error.message.startsWith("Archive") ||
401
+ error.message.startsWith("An archive") ||
402
+ error.message.startsWith("Unsafe archive") ||
403
+ error.message.startsWith("Invalid or damaged")
404
+ )) {
405
+ throw error;
406
+ }
407
+
408
+ throw new Error("Invalid or damaged .modder archive.");
409
+ }
410
+
411
+ if (!files["manifest.json"]) {
412
+ throw new Error("Archive is missing manifest.json.");
413
+ }
414
+
415
+ let manifest;
416
+
417
+ try {
418
+ manifest = JSON.parse(strFromU8(files["manifest.json"]));
419
+ } catch {
420
+ throw new Error("manifest.json is invalid.");
421
+ }
422
+
423
+ if (
424
+ !manifest ||
425
+ manifest.format !== "modder" ||
426
+ manifest.formatVersion !== 1 ||
427
+ !safeName(manifest.id) ||
428
+ typeof manifest.name !== "string" ||
429
+ !["project", "package"].includes(manifest.type)
430
+ ) {
431
+ throw new Error("Unsupported or invalid Modder manifest.");
432
+ }
433
+
434
+ return { files, manifest };
435
+ }
436
+
437
+ function createProject(name, destination = process.cwd()) {
438
+ if (!safeName(name)) {
439
+ throw new Error("Use a name containing letters, numbers, _ or -.");
440
+ }
441
+
442
+ const root = path.resolve(destination, name);
443
+
444
+ if (fs.existsSync(root)) {
445
+ throw new Error("That project folder already exists.");
446
+ }
447
+
448
+ const manifest = {
449
+ format: "modder",
450
+ formatVersion: 1,
451
+ type: "project",
452
+ id: name,
453
+ name,
454
+ version: "1.0.0",
455
+ description: "A Geometry Dash mod project",
456
+ author: "",
457
+ gameVersion: "2.2091"
458
+ };
459
+
460
+ fs.mkdirSync(path.join(root, "src"), { recursive: true });
461
+ fs.mkdirSync(path.join(root, "assets"), { recursive: true });
462
+ fs.mkdirSync(path.join(root, "build"), { recursive: true });
463
+
464
+ fs.writeFileSync(
465
+ path.join(root, "manifest.json"),
466
+ JSON.stringify(manifest, null, 2)
467
+ );
468
+
469
+ fs.writeFileSync(
470
+ path.join(root, "src", "main.cpp"),
471
+ "// Your Geode mod source code goes here.\n"
472
+ );
473
+
474
+ fs.writeFileSync(
475
+ path.join(root, "CMakeLists.txt"),
476
+ "# Add your Geode build configuration here.\n"
477
+ );
478
+
479
+ fs.writeFileSync(
480
+ path.join(root, "README.md"),
481
+ "# " + name + "\n\nA Modder project.\n"
482
+ );
483
+
484
+ console.log("Created Modder project:", root);
485
+ }
486
+
487
+ function validateArchive(file) {
488
+ const { files, manifest } = readArchive(file);
489
+
490
+ if (manifest.type === "package" && !files["mod.geode"]) {
491
+ throw new Error("Installable package is missing mod.geode.");
492
+ }
493
+
494
+ if (manifest.type === "project" && !files["src/main.cpp"]) {
495
+ throw new Error("Project archive is missing src/main.cpp.");
496
+ }
497
+
498
+ console.log("Valid .modder archive!");
499
+ console.log("Name:", manifest.name);
500
+ console.log("Type:", manifest.type);
501
+ console.log("Version:", manifest.version);
502
+ console.log("Files:", Object.keys(files).length);
503
+ }
504
+
505
+ function openProject(file, destination = process.cwd()) {
506
+ const { files, manifest } = readArchive(file);
507
+
508
+ if (manifest.type !== "project") {
509
+ throw new Error("Only project archives can be opened.");
510
+ }
511
+
512
+ if (!files["src/main.cpp"]) {
513
+ throw new Error("Project is missing src/main.cpp.");
514
+ }
515
+
516
+ const root = path.resolve(destination, manifest.id);
517
+
518
+ if (fs.existsSync(root)) {
519
+ throw new Error("Destination already exists: " + root);
520
+ }
521
+
522
+ fs.mkdirSync(root, { recursive: true });
523
+
524
+ try {
525
+ for (const [name, data] of Object.entries(files)) {
526
+ const target = path.resolve(root, ...name.split("/"));
527
+
528
+ if (!isInside(root, target)) {
529
+ throw new Error("Unsafe extraction path: " + name);
530
+ }
531
+
532
+ fs.mkdirSync(path.dirname(target), { recursive: true });
533
+ fs.writeFileSync(target, data);
534
+ }
535
+ } catch (error) {
536
+ fs.rmSync(root, { recursive: true, force: true });
537
+ throw error;
538
+ }
539
+
540
+ console.log("Project extracted to:", root);
541
+ }
542
+
543
+ function packProject(projectDirectory, outputFile) {
544
+ const root = path.resolve(projectDirectory);
545
+
546
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
547
+ throw new Error("Project folder not found.");
548
+ }
549
+
550
+ const manifestPath = path.join(root, "manifest.json");
551
+
552
+ if (!fs.existsSync(manifestPath)) {
553
+ throw new Error("Project manifest.json not found.");
554
+ }
555
+
556
+ let manifest;
557
+
558
+ try {
559
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
560
+ } catch {
561
+ throw new Error("Project manifest.json is invalid.");
562
+ }
563
+
564
+ if (
565
+ manifest.format !== "modder" ||
566
+ manifest.formatVersion !== 1 ||
567
+ manifest.type !== "project" ||
568
+ !safeName(manifest.id)
569
+ ) {
570
+ throw new Error("Invalid Modder project manifest.");
571
+ }
572
+
573
+
574
+
575
+ const files = {};
576
+ let fileCount = 0;
577
+ let totalSize = 0;
578
+
579
+ function collect(folder, relativeFolder = "") {
580
+ for (const entry of fs.readdirSync(folder, { withFileTypes: true })) {
581
+ if ([".git", "node_modules", "build", "dist", "out"].includes(entry.name)) {
582
+ continue;
583
+ }
584
+
585
+ const fullPath = path.join(folder, entry.name);
586
+ const relativePath = path.posix.join(relativeFolder, entry.name);
587
+
588
+ if (entry.isSymbolicLink()) {
589
+ throw new Error("Symbolic links are not allowed: " + relativePath);
590
+ }
591
+
592
+ if (!safeArchivePath(relativePath)) {
593
+ throw new Error("Unsafe project file path: " + relativePath);
594
+ }
595
+
596
+ if (entry.isDirectory()) {
597
+ collect(fullPath, relativePath);
598
+ } else if (entry.isFile()) {
599
+ fileCount++;
600
+
601
+ if (fileCount > MAX_FILES) {
602
+ throw new Error("Project contains too many files.");
603
+ }
604
+
605
+ const data = fs.readFileSync(fullPath);
606
+
607
+ totalSize += data.length;
608
+
609
+ if (data.length > MAX_FILE_SIZE || totalSize > MAX_TOTAL_SIZE) {
610
+ throw new Error("Project exceeds the archive size limits.");
611
+ }
612
+
613
+ files[relativePath] = new Uint8Array(data);
614
+ }
615
+ }
616
+ }
617
+
618
+ collect(root);
619
+
620
+ if (!files["src/main.cpp"]) {
621
+ throw new Error("Project is missing src/main.cpp.");
622
+ }
623
+
624
+ files["manifest.json"] = strToU8(JSON.stringify(manifest, null, 2));
625
+
626
+ const destination = path.resolve(
627
+ outputFile || (manifest.id + ".modder")
628
+ );
629
+
630
+ if (destination === root || isInside(root, destination)) {
631
+ throw new Error("Output archive cannot be inside the project folder.");
632
+ }
633
+
634
+ const archive = zipSync(files);
635
+
636
+ if (archive.length > MAX_ARCHIVE_SIZE) {
637
+ throw new Error("Created archive exceeds the 250 MB limit.");
638
+ }
639
+
640
+ fs.writeFileSync(destination, archive);
641
+
642
+ console.log("Created project archive: " + destination);
643
+ }
644
+
645
+ function buildPackage(projectDirectory, outputFile) {
646
+ const root = path.resolve(projectDirectory);
647
+ const manifestPath = path.join(root, "manifest.json");
648
+ const artifact = path.join(root, "build", "mod.geode");
649
+
650
+ if (!fs.existsSync(manifestPath)) {
651
+ throw new Error("Project manifest.json not found.");
652
+ }
653
+
654
+ let manifest;
655
+
656
+ try {
657
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
658
+ } catch {
659
+ throw new Error("Project manifest.json is invalid.");
660
+ }
661
+
662
+ if (
663
+ manifest.format !== "modder" ||
664
+ manifest.formatVersion !== 1 ||
665
+ manifest.type !== "project" ||
666
+ !safeName(manifest.id)
667
+ ) {
668
+ throw new Error("Invalid Modder project manifest.");
669
+ }
670
+
671
+ if (!fs.existsSync(artifact) || !fs.statSync(artifact).isFile()) {
672
+ throw new Error(
673
+ "Build output not found. Place your compiled Geode mod at build\\mod.geode first."
674
+ );
675
+ }
676
+
677
+ const artifactData = fs.readFileSync(artifact);
678
+
679
+ if (artifactData.length > MAX_FILE_SIZE) {
680
+ throw new Error("mod.geode exceeds the 250 MB file limit.");
681
+ }
682
+
683
+ const packageManifest = {
684
+ ...manifest,
685
+ type: "package"
686
+ };
687
+
688
+ const archive = {
689
+ "manifest.json": strToU8(JSON.stringify(packageManifest, null, 2)),
690
+ "mod.geode": artifactData
691
+ };
692
+
693
+ const readme = path.join(root, "README.md");
694
+
695
+ if (fs.existsSync(readme) && fs.statSync(readme).isFile()) {
696
+ const readmeData = fs.readFileSync(readme);
697
+
698
+ if (readmeData.length + artifactData.length > MAX_TOTAL_SIZE) {
699
+ throw new Error("Package exceeds the 500 MB expanded size limit.");
700
+ }
701
+
702
+ archive["README.md"] = readmeData;
703
+ }
704
+
705
+ const output = path.resolve(outputFile || (manifest.id + ".modder"));
706
+ const packed = zipSync(archive, { level: 6 });
707
+
708
+ if (packed.length > MAX_ARCHIVE_SIZE) {
709
+ throw new Error("Created package exceeds the 250 MB archive limit.");
710
+ }
711
+
712
+ fs.writeFileSync(output, packed);
713
+
714
+ console.log("Created installable package:", output);
715
+ }
716
+
717
+ function installPackage(file) {
718
+ const { files, manifest } = readArchive(file);
719
+
720
+ if (manifest.type !== "package" || !files["mod.geode"]) {
721
+ throw new Error("This is not a valid installable package.");
722
+ }
723
+
724
+ const packagesDirectory = path.join(
725
+ process.env.LOCALAPPDATA || os.homedir(),
726
+ "Modder",
727
+ "packages"
728
+ );
729
+
730
+ const root = path.join(packagesDirectory, manifest.id);
731
+
732
+ fs.mkdirSync(packagesDirectory, { recursive: true });
733
+
734
+ if (fs.existsSync(root)) {
735
+ throw new Error(
736
+ "This package is already in your Modder library. Remove it before installing a replacement."
737
+ );
738
+ }
739
+
740
+ // Deliberately do not use recursive here: this prevents overwriting
741
+ // an existing package directory.
742
+ fs.mkdirSync(root);
743
+
744
+ try {
745
+ fs.writeFileSync(path.join(root, "mod.geode"), files["mod.geode"]);
746
+
747
+ fs.writeFileSync(
748
+ path.join(root, "manifest.json"),
749
+ JSON.stringify(manifest, null, 2)
750
+ );
751
+ } catch (error) {
752
+ fs.rmSync(root, { recursive: true, force: true });
753
+ throw error;
754
+ }
755
+
756
+ console.log("Package added to Modder's local library:", root);
757
+ console.log("No files were executed.");
758
+ }
759
+
760
+ module.exports = {
761
+ createProject,
762
+ validateArchive,
763
+ openProject,
764
+ buildPackage,
765
+ packProject,
766
+ installPackage
767
+ };