modpack-lock 0.1.2 → 0.2.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 CHANGED
@@ -1,6 +1,6 @@
1
-
2
- <a href="https://github.com/nickesc/modpack-lock"><img alt="Source: Github" src="https://img.shields.io/badge/source-github-brightgreen?style=for-the-badge&logo=github&labelColor=%23505050"></a>
3
1
  <a href="https://www.npmjs.com/package/modpack-lock"><img alt="NPM: npmjs.com/package/modpack-lock" src="https://img.shields.io/npm/v/modpack-lock?style=for-the-badge&logo=npm&logoColor=white&label=npm&color=%23C12127&labelColor=%23505050"></a>
2
+ <a href="https://github.com/nickesc/modpack-lock"><img alt="Source: Github" src="https://img.shields.io/badge/source-github-brightgreen?style=for-the-badge&logo=github&labelColor=%23505050"></a>
3
+ <a href="https://github.com/nickesc/modpack-lock/actions/workflows/generateLockfile-tests.yml"><img alt="Tests: github.com/nickesc/modpack-lock/actions/workflows/generateLockfile-tests.yml" src="https://img.shields.io/github/actions/workflow/status/nickesc/modpack-lock/generateLockfile-tests.yml?logo=github&label=tests&logoColor=white&style=for-the-badge&labelColor=%23505050"></a>
4
4
 
5
5
  # modpack-lock
6
6
 
@@ -37,16 +37,25 @@ npx modpack-lock
37
37
 
38
38
  Navigate to your Minecraft profile directory (the folder containing `mods`, `resourcepacks`, `datapacks`, and `shaderpacks` folders) and run:
39
39
 
40
- ```bash
41
- modpack-lock
42
- ```
40
+ ```text
41
+ Usage: modpack-lock [options]
42
+
43
+ Create a modpack lockfile for files hosted on Modrinth (mods, resource packs, shaders and datapacks)
43
44
 
44
- Flags:
45
+ Options:
46
+ -d, --dry-run Dry-run mode - no files will be written
47
+ -g, --gitignore Print .gitignore rules for files not hosted on Modrinth
48
+ -r, --readme Generate README.md files for each category
49
+ -p, --path <path> Path to the modpack directory
45
50
 
46
- - `--dry-run` or `-d`: Print the files that would be scanned, but don't actually scan them
47
- - `--quiet` or `-q`: Print only errors and warnings
48
- - `--silent` or `-s`: Print nothing
49
- - `--gitignore` or `-g`: Print the rules to add to your `.gitignore` file
51
+ LOGGING
52
+ -q, --quiet Quiet mode - only show errors and warnings
53
+ -s, --silent Silent mode - no output
54
+
55
+ INFORMATION
56
+ -V, --version output the version number
57
+ --help display help for modpack-lock
58
+ ```
50
59
 
51
60
  The script will:
52
61
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modpack-lock",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Create a modpack lockfile for files hosted on Modrinth (mods, resource packs, shaders and datapacks)",
5
5
  "bugs": {
6
6
  "url": "https://github.com/nickesc/modpack-lock/issues"
@@ -12,9 +12,9 @@
12
12
  "license": "MIT",
13
13
  "author": "N. Escobar <nick@nescobar.media> (https://nickesc.github.io/)",
14
14
  "type": "module",
15
- "main": "index.js",
15
+ "main": "src/generate-lockfile.js",
16
16
  "bin": {
17
- "modpack-lock": "index.js"
17
+ "modpack-lock": "src/modpack-lock.js"
18
18
  },
19
19
  "keywords": [
20
20
  "modrinth",
@@ -26,13 +26,21 @@
26
26
  "node": ">=18.0.0"
27
27
  },
28
28
  "scripts": {
29
- "test": "node index.js",
30
- "start": "node index.js"
29
+ "test": "vitest --run",
30
+ "start": "node src/modpack-lock.js",
31
+ "modpack-lock": "node src/modpack-lock.js"
31
32
  },
32
33
  "files": [
33
- "index.js",
34
34
  "README.md",
35
35
  "LICENSE",
36
- "package.json"
37
- ]
36
+ "package.json",
37
+ "src/*.js"
38
+ ],
39
+ "dependencies": {
40
+ "commander": "^14.0.2"
41
+ },
42
+ "devDependencies": {
43
+ "unzipper": "^0.12.3",
44
+ "vitest": "^4.0.16"
45
+ }
38
46
  }
@@ -0,0 +1,517 @@
1
+ import fs from 'fs/promises';
2
+ import crypto from 'crypto';
3
+ import path from 'path';
4
+
5
+ const LOCKFILE_VERSION = '1.0.1';
6
+ const MODPACK_LOCKFILE_NAME = 'modpack.lock';
7
+ const MODRINTH_API_BASE = 'https://api.modrinth.com/v2';
8
+ const MODRINTH_VERSION_FILES_ENDPOINT = `${MODRINTH_API_BASE}/version_files`;
9
+ const MODRINTH_PROJECTS_ENDPOINT = `${MODRINTH_API_BASE}/projects`;
10
+ const MODRINTH_USERS_ENDPOINT = `${MODRINTH_API_BASE}/users`;
11
+ const BATCH_SIZE = 100;
12
+
13
+ // Get the workspace root from the current working directory
14
+ //const WORKSPACE_ROOT = process.cwd();
15
+
16
+ /**
17
+ * Create a logger function that respects quiet mode
18
+ */
19
+ function createLogger(quiet) {
20
+ if (quiet) {
21
+ return () => {};
22
+ }
23
+ return (...args) => console.log(...args);
24
+ }
25
+
26
+ /**
27
+ * Silence all console.log output
28
+ */
29
+ function silenceConsole() {
30
+ console.log = () => {};
31
+ console.warn = () => {};
32
+ console.error = () => {};
33
+ console.info = () => {};
34
+ }
35
+
36
+ /**
37
+ * Get the directories to scan for modpack files
38
+ */
39
+ function getScanDirectories(directoryPath) {
40
+ return [
41
+ { name: 'mods', path: path.join(directoryPath, 'mods') },
42
+ { name: 'resourcepacks', path: path.join(directoryPath, 'resourcepacks') },
43
+ { name: 'datapacks', path: path.join(directoryPath, 'datapacks') },
44
+ { name: 'shaderpacks', path: path.join(directoryPath, 'shaderpacks') },
45
+ ];
46
+ }
47
+
48
+ /**
49
+ * Calculate SHA1 hash of a file
50
+ */
51
+ async function calculateSHA1(filePath) {
52
+ const fileBuffer = await fs.readFile(filePath);
53
+ return crypto.createHash('sha1').update(fileBuffer).digest('hex');
54
+ }
55
+
56
+ /**
57
+ * Find all files in a directory
58
+ */
59
+ async function findFiles(dirPath) {
60
+ const files = [];
61
+
62
+ try {
63
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
64
+
65
+ for (const entry of entries) {
66
+ if (entry.isFile() && (entry.name.endsWith('.jar') || entry.name.endsWith('.zip'))) {
67
+ const fullPath = path.join(dirPath, entry.name);
68
+ files.push(fullPath);
69
+ }
70
+ }
71
+ } catch (error) {
72
+ if (error.code !== 'ENOENT') {
73
+ console.warn(`Warning: Could not read directory ${dirPath}: ${error.message}`);
74
+ }
75
+ }
76
+
77
+ return files;
78
+ }
79
+
80
+ /**
81
+ * Scan a directory and return file info with hashes
82
+ */
83
+ async function scanDirectory(dirInfo, workspaceRoot) {
84
+ const files = await findFiles(dirInfo.path);
85
+ const fileEntries = [];
86
+
87
+ for (const filePath of files) {
88
+ try {
89
+ const hash = await calculateSHA1(filePath);
90
+ const relativePath = path.relative(workspaceRoot, filePath);
91
+
92
+ fileEntries.push({
93
+ path: relativePath,
94
+ fullPath: filePath,
95
+ hash: hash,
96
+ category: dirInfo.name,
97
+ });
98
+ } catch (error) {
99
+ console.warn(`Warning: Could not hash file ${filePath}: ${error.message}`);
100
+ }
101
+ }
102
+
103
+ return fileEntries;
104
+ }
105
+
106
+ /**
107
+ * Query Modrinth API for version information from hashes
108
+ */
109
+ async function getVersionsFromHashes(hashes) {
110
+ if (hashes.length === 0) {
111
+ return {};
112
+ }
113
+
114
+ try {
115
+ const response = await fetch(MODRINTH_VERSION_FILES_ENDPOINT, {
116
+ method: 'POST',
117
+ headers: {
118
+ 'Content-Type': 'application/json',
119
+ },
120
+ body: JSON.stringify({
121
+ hashes: hashes,
122
+ algorithm: 'sha1',
123
+ }),
124
+ });
125
+
126
+ if (!response.ok) {
127
+ const errorText = await response.text();
128
+ throw new Error(`Modrinth API error (${response.status}): ${errorText}`);
129
+ }
130
+
131
+ return await response.json();
132
+ } catch (error) {
133
+ console.error(`Error querying Modrinth API: ${error.message}`);
134
+ throw error;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Split an array into chunks of specified size
140
+ */
141
+ function chunkArray(array, size) {
142
+ const chunks = [];
143
+ for (let i = 0; i < array.length; i += size) {
144
+ chunks.push(array.slice(i, i + size));
145
+ }
146
+ return chunks;
147
+ }
148
+
149
+ /**
150
+ * Fetch multiple projects by their IDs in batches
151
+ */
152
+ async function getProjects(projectIds) {
153
+ if (projectIds.length === 0) {
154
+ return [];
155
+ }
156
+
157
+ const chunks = chunkArray(projectIds, BATCH_SIZE);
158
+ const results = [];
159
+
160
+ for (const chunk of chunks) {
161
+ try {
162
+ const url = `${MODRINTH_PROJECTS_ENDPOINT}?ids=${encodeURIComponent(JSON.stringify(chunk))}`;
163
+ const response = await fetch(url);
164
+
165
+ if (!response.ok) {
166
+ const errorText = await response.text();
167
+ throw new Error(`Modrinth API error (${response.status}): ${errorText}`);
168
+ }
169
+
170
+ const data = await response.json();
171
+ results.push(...data);
172
+ } catch (error) {
173
+ console.error(`Error fetching projects: ${error.message}`);
174
+ throw error;
175
+ }
176
+ }
177
+
178
+ return results;
179
+ }
180
+
181
+ /**
182
+ * Fetch multiple users by their IDs in batches
183
+ */
184
+ async function getUsers(userIds) {
185
+ if (userIds.length === 0) {
186
+ return [];
187
+ }
188
+
189
+ const chunks = chunkArray(userIds, BATCH_SIZE);
190
+ const results = [];
191
+
192
+ for (const chunk of chunks) {
193
+ try {
194
+ const url = `${MODRINTH_USERS_ENDPOINT}?ids=${encodeURIComponent(JSON.stringify(chunk))}`;
195
+ const response = await fetch(url);
196
+
197
+ if (!response.ok) {
198
+ const errorText = await response.text();
199
+ throw new Error(`Modrinth API error (${response.status}): ${errorText}`);
200
+ }
201
+
202
+ const data = await response.json();
203
+ results.push(...data);
204
+ } catch (error) {
205
+ console.error(`Error fetching users: ${error.message}`);
206
+ throw error;
207
+ }
208
+ }
209
+
210
+ return results;
211
+ }
212
+
213
+
214
+ /**
215
+ * Create empty lockfile structure
216
+ */
217
+ function createEmptyLockfile() {
218
+ return {
219
+ version: LOCKFILE_VERSION,
220
+ generated: new Date().toISOString(),
221
+ total: 0,
222
+ counts: {},
223
+ dependencies: {},
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Create lockfile structure from file info and version data
229
+ */
230
+ function createLockfile(fileEntries, versionData) {
231
+ const lockfile = createEmptyLockfile();
232
+
233
+ // Organize by category
234
+ for (const fileInfo of fileEntries) {
235
+ const version = versionData[fileInfo.hash];
236
+
237
+ lockfile.dependencies[fileInfo.category] ||= [];
238
+
239
+ const entry = {
240
+ path: fileInfo.path,
241
+ version: version || null,
242
+ };
243
+
244
+ if (!version) {
245
+ console.warn(`Warning: File ${fileInfo.path} not found on Modrinth`);
246
+ }
247
+
248
+ lockfile.dependencies[fileInfo.category].push(entry);
249
+ }
250
+
251
+ // Calculate counts for each category
252
+ for (const [category, entries] of Object.entries(lockfile.dependencies)) {
253
+ lockfile.counts[category] = entries.length;
254
+ }
255
+
256
+ lockfile.total = fileEntries.length;
257
+
258
+ return lockfile;
259
+ }
260
+
261
+ /**
262
+ * Write lockfile to disk
263
+ */
264
+ async function writeLockfile(lockfile, outputPath, log) {
265
+ const content = JSON.stringify(lockfile, null, 2);
266
+ await fs.writeFile(outputPath, content, 'utf-8');
267
+ log(`Lockfile written to: ${outputPath}`);
268
+ }
269
+
270
+ /**
271
+ * Generate README.md content for a category
272
+ */
273
+ function generateCategoryReadme(category, entries, projectsMap, usersMap) {
274
+ const categoryTitle = category.charAt(0).toUpperCase() + category.slice(1);
275
+ const lines = [`# ${categoryTitle}`, '', '| Name | Author | Version |', '|-|-|-|'];
276
+
277
+ // Map category to Modrinth URL path segment
278
+ const categoryPathMap = {
279
+ mods: 'mod',
280
+ resourcepacks: 'resourcepack',
281
+ shaderpacks: 'shader',
282
+ datapacks: 'datapack',
283
+ };
284
+ const categoryPath = categoryPathMap[category] || 'project';
285
+
286
+ for (const entry of entries) {
287
+ const version = entry.version;
288
+ let nameCell = '';
289
+ let authorCell = '';
290
+ let versionCell = '';
291
+
292
+ if (version && version.project_id) {
293
+ const project = projectsMap[version.project_id];
294
+ const author = version.author_id ? usersMap[version.author_id] : null;
295
+
296
+ // Name column with icon and link
297
+ if (project) {
298
+ const projectName = project.title || project.slug || 'Unknown';
299
+ const projectSlug = project.slug || project.id;
300
+ const projectUrl = `https://modrinth.com/${categoryPath}/${projectSlug}`;
301
+
302
+ if (project.icon_url) {
303
+ nameCell = `<img alt="Icon" src="${project.icon_url}" height="20px"> [${projectName}](${projectUrl})`;
304
+ } else {
305
+ nameCell = `[${projectName}](${projectUrl})`;
306
+ }
307
+ } else {
308
+ // Project not found, use filename
309
+ const fileName = path.basename(entry.path);
310
+ nameCell = fileName;
311
+ }
312
+
313
+ // Author column with avatar and link
314
+ if (author) {
315
+ const authorName = author.username || 'Unknown';
316
+ const authorUrl = `https://modrinth.com/user/${authorName}`;
317
+
318
+ if (author.avatar_url) {
319
+ authorCell = `<img alt="Avatar" src="${author.avatar_url}" height="20px"> [${authorName}](${authorUrl})`;
320
+ } else {
321
+ authorCell = `[${authorName}](${authorUrl})`;
322
+ }
323
+ } else {
324
+ authorCell = 'Unknown';
325
+ }
326
+
327
+ // Version column
328
+ versionCell = version.version_number || 'Unknown';
329
+ } else {
330
+ // File not found on Modrinth
331
+ const fileName = path.basename(entry.path);
332
+ nameCell = fileName;
333
+ authorCell = 'Unknown';
334
+ versionCell = '-';
335
+ }
336
+
337
+ lines.push(`| ${nameCell} | ${authorCell} | ${versionCell} |`);
338
+ }
339
+
340
+ return lines.join('\n') + '\n';
341
+ }
342
+
343
+ /**
344
+ * Generate .gitignore rules for files not hosted on Modrinth
345
+ */
346
+ function generateGitignoreRules(lockfile) {
347
+ const rules = [];
348
+ const exceptions = [];
349
+
350
+ // Base ignore patterns for each category
351
+ rules.push('mods/*.jar');
352
+ rules.push('resourcepacks/*.zip');
353
+ rules.push('datapacks/*.zip');
354
+ rules.push('shaderpacks/*.zip');
355
+ rules.push('');
356
+ rules.push('## Exceptions');
357
+
358
+ // Find files not hosted on Modrinth
359
+ for (const [category, entries] of Object.entries(lockfile.dependencies)) {
360
+ for (const entry of entries) {
361
+ if (entry.version === null) {
362
+ exceptions.push(`!${entry.path}`);
363
+ }
364
+ }
365
+ }
366
+
367
+ // Add exceptions if any
368
+ if (exceptions.length > 0) {
369
+ rules.push(...exceptions);
370
+ } else {
371
+ rules.push('# No exceptions needed - all files are hosted on Modrinth');
372
+ }
373
+
374
+ return rules.join('\n');
375
+ }
376
+
377
+ /**
378
+ * Main execution function
379
+ */
380
+ async function generateLockfile(config) {
381
+ const log = createLogger(config.quiet);
382
+
383
+ if (config.silent) {
384
+ silenceConsole();
385
+ }
386
+
387
+ if (config.dryRun) {
388
+ log('[DRY RUN] Preview mode - no files will be written');
389
+ }
390
+
391
+ log('Scanning directories for modpack files...');
392
+
393
+ // Scan all directories
394
+ const allFileEntries = [];
395
+ for (const dirInfo of getScanDirectories(config.path)) {
396
+ log(`Scanning ${dirInfo.name}...`);
397
+ const fileEntries = await scanDirectory(dirInfo, config.path);
398
+ log(` Found ${fileEntries.length} file(s)`);
399
+ allFileEntries.push(...fileEntries);
400
+ }
401
+
402
+ if (allFileEntries.length === 0) {
403
+ log('No files found. Creating empty lockfile.');
404
+ const outputPath = path.join(config.path, MODPACK_LOCKFILE_NAME);
405
+ if (config.dryRun) {
406
+ log(`[DRY RUN] Would write lockfile to: ${outputPath}`);
407
+ } else {
408
+ await writeLockfile(createEmptyLockfile(), outputPath, log);
409
+ }
410
+ return;
411
+ }
412
+
413
+ log(`\nTotal files found: ${allFileEntries.length}`);
414
+ log('\nQuerying Modrinth API...');
415
+
416
+ // Extract all hashes
417
+ const hashes = allFileEntries.map(info => info.hash);
418
+
419
+ // Query Modrinth API
420
+ const versionData = await getVersionsFromHashes(hashes);
421
+
422
+ log(`\nFound version information for ${Object.keys(versionData).length} out of ${hashes.length} files`);
423
+
424
+ // Create lockfile
425
+ const lockfile = createLockfile(allFileEntries, versionData);
426
+
427
+ // Write lockfile
428
+ const outputPath = path.join(config.path, MODPACK_LOCKFILE_NAME);
429
+ if (config.dryRun) {
430
+ log(`[DRY RUN] Would write lockfile to: ${outputPath}`);
431
+ } else {
432
+ await writeLockfile(lockfile, outputPath, log);
433
+ }
434
+
435
+ // Summary
436
+ log('\n=== Summary ===');
437
+ for (const [category, entries] of Object.entries(lockfile.dependencies)) {
438
+ const withVersion = entries.filter(e => e.version !== null).length;
439
+ const withoutVersion = entries.length - withVersion;
440
+ log(`${category}: ${entries.length} file(s) (${withVersion} found on Modrinth, ${withoutVersion} unknown)`);
441
+ }
442
+
443
+ // Generate .gitignore rules
444
+ if (config.gitignore) {
445
+ log('\n=== .gitignore Rules ===');
446
+ log(generateGitignoreRules(lockfile));
447
+ }
448
+
449
+ // Generate README files
450
+ if (config.readme) {
451
+ log('\nGenerating README files...');
452
+
453
+ // Collect unique project IDs and author IDs from version data
454
+ const projectIds = new Set();
455
+ const authorIds = new Set();
456
+
457
+ for (const [category, entries] of Object.entries(lockfile.dependencies)) {
458
+ for (const entry of entries) {
459
+ if (entry.version && entry.version.project_id) {
460
+ projectIds.add(entry.version.project_id);
461
+ }
462
+ if (entry.version && entry.version.author_id) {
463
+ authorIds.add(entry.version.author_id);
464
+ }
465
+ }
466
+ }
467
+
468
+ // Fetch projects and users in parallel
469
+ log(`Fetching data for ${projectIds.size} project(s) and ${authorIds.size} user(s)...`);
470
+
471
+ const [projects, users] = await Promise.all([
472
+ getProjects(Array.from(projectIds)),
473
+ getUsers(Array.from(authorIds)),
474
+ ]);
475
+
476
+ // Map projects and users to their IDs
477
+ const projectsMap = {};
478
+ for (const project of projects) {
479
+ projectsMap[project.id] = project;
480
+ }
481
+
482
+ const usersMap = {};
483
+ for (const user of users) {
484
+ usersMap[user.id] = user;
485
+ }
486
+
487
+ // Generate README for each category
488
+ for (const [category, entries] of Object.entries(lockfile.dependencies)) {
489
+ if (entries.length === 0) {
490
+ continue;
491
+ }
492
+
493
+ const readmeContent = generateCategoryReadme(category, entries, projectsMap, usersMap);
494
+ const categoryDir = getScanDirectories(config.path).find(d => d.name === category);
495
+
496
+ if (categoryDir) {
497
+ const readmePath = path.join(categoryDir.path, 'README.md');
498
+
499
+ if (config.dryRun) {
500
+ log(`[DRY RUN] Would write README to: ${readmePath}`);
501
+ } else {
502
+ try {
503
+ await fs.writeFile(readmePath, readmeContent, 'utf-8');
504
+ log(`Generated README: ${readmePath}`);
505
+ } catch (error) {
506
+ console.warn(`Warning: Could not write README to ${readmePath}: ${error.message}`);
507
+ }
508
+ }
509
+ }
510
+ }
511
+
512
+ log('README generation complete.');
513
+ }
514
+ return true;
515
+ }
516
+
517
+ export default generateLockfile;
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env NODE_OPTIONS=--no-warnings node
2
+
3
+ import { Command } from 'commander';
4
+ import generateLockfile from './generate-lockfile.js';
5
+
6
+ import pkg from '../package.json' with { type: 'json' };
7
+ const modpackLock = new Command('modpack-lock');
8
+
9
+ modpackLock
10
+ .name(pkg.name)
11
+ .description(pkg.description)
12
+ .summary("Create a modpack lockfile")
13
+ .optionsGroup("Options:")
14
+ .option('-d, --dry-run', 'Dry-run mode - no files will be written')
15
+ .option('-g, --gitignore', 'Print .gitignore rules for files not hosted on Modrinth')
16
+ .option('-r, --readme', 'Generate README.md files for each category')
17
+ .option('-p, --path <path>', 'Path to the modpack directory')
18
+ .optionsGroup("LOGGING")
19
+ .option('-q, --quiet', 'Quiet mode - only show errors and warnings')
20
+ .option('-s, --silent', 'Silent mode - no output')
21
+ .optionsGroup("INFORMATION")
22
+ .helpOption("--help", `display help for ${pkg.name}`)
23
+ .version(pkg.version)
24
+ .action((options) => {
25
+ generateLockfile({ ...options, path: options.path || process.cwd() }).catch(error => {
26
+ console.error('Error:', error);
27
+ process.exit(1);
28
+ });
29
+ });
30
+
31
+ modpackLock.parse()
32
+
package/index.js DELETED
@@ -1,315 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import fs from 'fs/promises';
4
- import crypto from 'crypto';
5
- import path from 'path';
6
-
7
- const LOCKFILE_VERSION = '1.0.1';
8
- const MODPACK_LOCKFILE_NAME = 'modpack.lock';
9
- const MODRINTH_API_BASE = 'https://api.modrinth.com/v2';
10
- const MODRINTH_VERSION_FILES_ENDPOINT = `${MODRINTH_API_BASE}/version_files`;
11
-
12
- // Get the workspace root from the current working directory
13
- const WORKSPACE_ROOT = process.cwd();
14
-
15
- /**
16
- * Parse command-line arguments
17
- */
18
- function parseArgs() {
19
- const args = process.argv.slice(2);
20
- return {
21
- dryRun: args.includes('--dry-run') || args.includes('-d'),
22
- quiet: args.includes('--quiet') || args.includes('-q'),
23
- silent: args.includes('--silent') || args.includes('-s'),
24
- gitignore: args.includes('--gitignore') || args.includes('-g'),
25
- };
26
- }
27
-
28
- /**
29
- * Create a logger function that respects quiet mode
30
- */
31
- function createLogger(quiet) {
32
- if (quiet) {
33
- return () => {}; // No-op function when quiet
34
- }
35
- return (...args) => console.log(...args);
36
- }
37
-
38
- /**
39
- * Silence all console.log output
40
- */
41
- function silenceConsole() {
42
- console.log = () => {};
43
- console.warn = () => {};
44
- console.error = () => {};
45
- console.info = () => {};
46
- }
47
-
48
- const DIRECTORIES_TO_SCAN = [
49
- { name: 'mods', path: path.join(WORKSPACE_ROOT, 'mods') },
50
- { name: 'resourcepacks', path: path.join(WORKSPACE_ROOT, 'resourcepacks') },
51
- { name: 'datapacks', path: path.join(WORKSPACE_ROOT, 'datapacks') },
52
- { name: 'shaderpacks', path: path.join(WORKSPACE_ROOT, 'shaderpacks') },
53
- ];
54
-
55
- /**
56
- * Calculate SHA1 hash of a file
57
- */
58
- async function calculateSHA1(filePath) {
59
- const fileBuffer = await fs.readFile(filePath);
60
- return crypto.createHash('sha1').update(fileBuffer).digest('hex');
61
- }
62
-
63
- /**
64
- * Find all files in a directory
65
- */
66
- async function findFiles(dirPath) {
67
- const files = [];
68
-
69
- try {
70
- const entries = await fs.readdir(dirPath, { withFileTypes: true });
71
-
72
- for (const entry of entries) {
73
- if (entry.isFile() && (entry.name.endsWith('.jar') || entry.name.endsWith('.zip'))) {
74
- const fullPath = path.join(dirPath, entry.name);
75
- files.push(fullPath);
76
- }
77
- }
78
- } catch (error) {
79
- // Directory doesn't exist or can't be read - skip it
80
- if (error.code !== 'ENOENT') {
81
- console.warn(`Warning: Could not read directory ${dirPath}: ${error.message}`);
82
- }
83
- }
84
-
85
- return files;
86
- }
87
-
88
- /**
89
- * Scan a directory and return file info with hashes
90
- */
91
- async function scanDirectory(dirInfo) {
92
- const files = await findFiles(dirInfo.path);
93
- const fileEntries = [];
94
-
95
- for (const filePath of files) {
96
- try {
97
- const hash = await calculateSHA1(filePath);
98
- const relativePath = path.relative(WORKSPACE_ROOT, filePath);
99
-
100
- fileEntries.push({
101
- path: relativePath,
102
- fullPath: filePath,
103
- hash: hash,
104
- category: dirInfo.name,
105
- });
106
- } catch (error) {
107
- console.warn(`Warning: Could not hash file ${filePath}: ${error.message}`);
108
- }
109
- }
110
-
111
- return fileEntries;
112
- }
113
-
114
- /**
115
- * Query Modrinth API for version information from hashes
116
- */
117
- async function getVersionsFromHashes(hashes) {
118
- if (hashes.length === 0) {
119
- return {};
120
- }
121
-
122
- try {
123
- const response = await fetch(MODRINTH_VERSION_FILES_ENDPOINT, {
124
- method: 'POST',
125
- headers: {
126
- 'Content-Type': 'application/json',
127
- },
128
- body: JSON.stringify({
129
- hashes: hashes,
130
- algorithm: 'sha1',
131
- }),
132
- });
133
-
134
- if (!response.ok) {
135
- const errorText = await response.text();
136
- throw new Error(`Modrinth API error (${response.status}): ${errorText}`);
137
- }
138
-
139
- return await response.json();
140
- } catch (error) {
141
- console.error(`Error querying Modrinth API: ${error.message}`);
142
- throw error;
143
- }
144
- }
145
-
146
-
147
- /**
148
- * Create empty lockfile structure
149
- */
150
- function createEmptyLockfile() {
151
- return {
152
- version: LOCKFILE_VERSION,
153
- generated: new Date().toISOString(),
154
- total: 0,
155
- counts: {},
156
- dependencies: {},
157
- };
158
- }
159
-
160
- /**
161
- * Create lockfile structure from file info and version data
162
- */
163
- function createLockfile(fileEntries, versionData) {
164
- const lockfile = createEmptyLockfile();
165
-
166
- // Organize by category
167
- for (const fileInfo of fileEntries) {
168
- const version = versionData[fileInfo.hash];
169
-
170
- lockfile.dependencies[fileInfo.category] ||= [];
171
-
172
- const entry = {
173
- path: fileInfo.path,
174
- version: version || null,
175
- };
176
-
177
- if (!version) {
178
- console.warn(`Warning: File ${fileInfo.path} not found on Modrinth`);
179
- }
180
-
181
- lockfile.dependencies[fileInfo.category].push(entry);
182
- }
183
-
184
- // Calculate counts for each category
185
- for (const [category, entries] of Object.entries(lockfile.dependencies)) {
186
- lockfile.counts[category] = entries.length;
187
- }
188
-
189
- // Calculate total count
190
- lockfile.total = fileEntries.length;
191
-
192
- return lockfile;
193
- }
194
-
195
- /**
196
- * Write lockfile to disk
197
- */
198
- async function writeLockfile(lockfile, outputPath, log) {
199
- const content = JSON.stringify(lockfile, null, 2);
200
- await fs.writeFile(outputPath, content, 'utf-8');
201
- log(`Lockfile written to: ${outputPath}`);
202
- }
203
-
204
- /**
205
- * Generate .gitignore rules for files not hosted on Modrinth
206
- */
207
- function generateGitignoreRules(lockfile) {
208
- const rules = [];
209
- const exceptions = [];
210
-
211
- // Base ignore patterns for each category
212
- rules.push('mods/*.jar');
213
- rules.push('resourcepacks/*.zip');
214
- rules.push('datapacks/*.zip');
215
- rules.push('shaderpacks/*.zip');
216
- rules.push('');
217
- rules.push('## Exceptions');
218
-
219
- // Find files not hosted on Modrinth
220
- for (const [category, entries] of Object.entries(lockfile.dependencies)) {
221
- for (const entry of entries) {
222
- if (entry.version === null) {
223
- exceptions.push(`!${entry.path}`);
224
- }
225
- }
226
- }
227
-
228
- // Add exceptions if any
229
- if (exceptions.length > 0) {
230
- rules.push(...exceptions);
231
- } else {
232
- rules.push('# No exceptions needed - all files are hosted on Modrinth');
233
- }
234
-
235
- return rules.join('\n');
236
- }
237
-
238
- /**
239
- * Main execution function
240
- */
241
- async function main() {
242
- const config = parseArgs();
243
- const log = createLogger(config.quiet);
244
-
245
- if (config.silent) {
246
- silenceConsole();
247
- }
248
-
249
- if (config.dryRun) {
250
- log('[DRY RUN] Preview mode - no files will be written');
251
- }
252
-
253
- log('Scanning directories for modpack files...');
254
-
255
- // Scan all directories
256
- const allFileEntries = [];
257
- for (const dirInfo of DIRECTORIES_TO_SCAN) {
258
- log(`Scanning ${dirInfo.name}...`);
259
- const fileEntries = await scanDirectory(dirInfo);
260
- log(` Found ${fileEntries.length} file(s)`);
261
- allFileEntries.push(...fileEntries);
262
- }
263
-
264
- if (allFileEntries.length === 0) {
265
- log('No files found. Creating empty lockfile.');
266
- const outputPath = path.join(WORKSPACE_ROOT, MODPACK_LOCKFILE_NAME);
267
- if (config.dryRun) {
268
- log(`[DRY RUN] Would write lockfile to: ${outputPath}`);
269
- } else {
270
- await writeLockfile(createEmptyLockfile(), outputPath, log);
271
- }
272
- return;
273
- }
274
-
275
- log(`\nTotal files found: ${allFileEntries.length}`);
276
- log('\nQuerying Modrinth API...');
277
-
278
- // Extract all hashes
279
- const hashes = allFileEntries.map(info => info.hash);
280
-
281
- // Query Modrinth API
282
- const versionData = await getVersionsFromHashes(hashes);
283
-
284
- log(`\nFound version information for ${Object.keys(versionData).length} out of ${hashes.length} files`);
285
-
286
- // Create lockfile
287
- const lockfile = createLockfile(allFileEntries, versionData);
288
-
289
- // Write lockfile
290
- const outputPath = path.join(WORKSPACE_ROOT, MODPACK_LOCKFILE_NAME);
291
- if (config.dryRun) {
292
- log(`[DRY RUN] Would write lockfile to: ${outputPath}`);
293
- } else {
294
- await writeLockfile(lockfile, outputPath, log);
295
- }
296
-
297
- // Summary
298
- log('\n=== Summary ===');
299
- for (const [category, entries] of Object.entries(lockfile.dependencies)) {
300
- const withVersion = entries.filter(e => e.version !== null).length;
301
- const withoutVersion = entries.length - withVersion;
302
- log(`${category}: ${entries.length} file(s) (${withVersion} found on Modrinth, ${withoutVersion} unknown)`);
303
- }
304
-
305
- // Generate .gitignore rules if requested
306
- if (config.gitignore) {
307
- log('\n=== .gitignore Rules ===');
308
- log(generateGitignoreRules(lockfile));
309
- }
310
- }
311
-
312
- main().catch(error => {
313
- console.error('Error:', error);
314
- process.exit(1);
315
- });