modpack-lock 0.2.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.
@@ -0,0 +1,105 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { getProjects } from './modrinth_interactions.js';
4
+ import * as config from './config/index.js';
5
+
6
+ /**
7
+ * @typedef {import('./config/types.js').ModpackInfo} ModpackInfo
8
+ * @typedef {import('./config/types.js').Options} Options
9
+ * @typedef {import('./config/types.js').Lockfile} Lockfile
10
+ */
11
+
12
+ /**
13
+ * Create a JSON object from the modpack information and dependencies
14
+ */
15
+ function createModpackJson(modpackInfo, dependencies) {
16
+ return {
17
+ ...modpackInfo,
18
+ dependencies: dependencies,
19
+ };
20
+ }
21
+
22
+ /**
23
+ * Write modpack.json to disk
24
+ */
25
+ async function writeJson(jsonObject, outputPath) {
26
+ const content = JSON.stringify(jsonObject, null, 2);
27
+ await fs.writeFile(path.join(outputPath, config.MODPACK_JSON_NAME), content, 'utf-8');
28
+ console.log(`${config.MODPACK_JSON_NAME} written to: ${path.join(outputPath, config.MODPACK_JSON_NAME)}`);
29
+ }
30
+
31
+ /**
32
+ * Generate a modpack.json file
33
+ * @param {ModpackInfo} modpackInfo - The modpack information
34
+ * @param {Lockfile} lockfile - The lockfile
35
+ * @param {string} outputDir - The path to write the JSON object to
36
+ * @param {Options} options - The options object
37
+ * @returns {Promise<Lockfile>} The JSON file's object
38
+ */
39
+ export default async function generateJson(modpackInfo, lockfile, outputDir, options = {}) {
40
+ // Validate modpack info
41
+ for (const field of config.MODPACK_INFO_REQUIRED_FIELDS) {
42
+ if (!modpackInfo[field]) {
43
+ throw new Error(`Modpack info is missing required field: ${field}`);
44
+ }
45
+ }
46
+
47
+ const projectIds = {};
48
+ const packDependencies = {};
49
+ for (const category of config.DEPENDENCY_CATEGORIES) {
50
+ projectIds[category] = new Set();
51
+ packDependencies[category] = [];
52
+ }
53
+
54
+ // Collect project IDs from lockfile
55
+ if (lockfile) if (lockfile.dependencies) {
56
+ for (const [category, entries] of Object.entries(lockfile.dependencies)) {
57
+ for (const entry of entries) {
58
+ if (entry.version && entry.version.project_id) {
59
+ projectIds[category].add(entry.version.project_id);
60
+ } else {
61
+ packDependencies[category].push(entry.path);
62
+ }
63
+ }
64
+ }
65
+ const allProjectIds = new Set();
66
+ for (const category of config.DEPENDENCY_CATEGORIES) {
67
+ for (const projectId of projectIds[category]) {
68
+ allProjectIds.add(projectId);
69
+ }
70
+ }
71
+
72
+ // Fetch projects from Modrinth
73
+ const projects = await getProjects(Array.from(allProjectIds));
74
+ const projectsMap = {};
75
+ for (const project of projects) {
76
+ projectsMap[project.id] = project.slug;
77
+ }
78
+
79
+ // Add projects to dependencies by category
80
+ for (const category of config.DEPENDENCY_CATEGORIES) {
81
+ for (const projectId of projectIds[category]) {
82
+ const projectSlug = projectsMap[projectId];
83
+ if (projectSlug) {
84
+ packDependencies[category].push(projectSlug);
85
+ }
86
+ }
87
+ //packDependencies[category].push(...packDependencies[category].map(item => item.path));
88
+ }
89
+ }
90
+
91
+ // Create modpack JSON object
92
+ const jsonObject = createModpackJson(modpackInfo, packDependencies);
93
+
94
+ // Write modpack JSON object to disk
95
+ if (options.dryRun) {
96
+ console.log(`[DRY RUN] Would write ${config.MODPACK_JSON_NAME} to: ${path.join(outputDir, config.MODPACK_JSON_NAME)}`);
97
+ } else {
98
+ await writeJson(jsonObject, outputDir);
99
+ }
100
+
101
+ return jsonObject;
102
+ }
103
+
104
+
105
+
@@ -0,0 +1,327 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { getVersionsFromHashes, getProjects, getUsers } from './modrinth_interactions.js';
4
+ import { getScanDirectories, scanDirectory } from './directory_scanning.js';
5
+ import * as config from './config/index.js';
6
+
7
+ /**
8
+ * @typedef {import('./config/types.js').Options} Options
9
+ * @typedef {import('./config/types.js').Lockfile} Lockfile
10
+ */
11
+
12
+ /**
13
+ * Create empty lockfile structure
14
+ */
15
+ function createEmptyLockfile() {
16
+ return {
17
+ version: config.LOCKFILE_VERSION,
18
+ generated: new Date().toISOString(),
19
+ total: 0,
20
+ counts: {},
21
+ dependencies: {},
22
+ };
23
+ }
24
+
25
+ /**
26
+ * Create lockfile structure from file info and version data
27
+ */
28
+ function createLockfile(fileEntries, versionData) {
29
+ const lockfile = createEmptyLockfile();
30
+
31
+ // Organize by category
32
+ for (const fileInfo of fileEntries) {
33
+ const version = versionData[fileInfo.hash];
34
+
35
+ lockfile.dependencies[fileInfo.category] ||= [];
36
+
37
+ const entry = {
38
+ path: fileInfo.path,
39
+ version: version || null,
40
+ };
41
+
42
+ if (!version) {
43
+ console.warn(`Warning: File ${fileInfo.path} not found on Modrinth`);
44
+ }
45
+
46
+ lockfile.dependencies[fileInfo.category].push(entry);
47
+ }
48
+
49
+ // Calculate counts for each category
50
+ for (const [category, entries] of Object.entries(lockfile.dependencies)) {
51
+ lockfile.counts[category] = entries.length;
52
+ }
53
+
54
+ lockfile.total = fileEntries.length;
55
+
56
+ return lockfile;
57
+ }
58
+
59
+ /**
60
+ * Write lockfile to disk
61
+ */
62
+ async function writeLockfile(lockfile, outputPath) {
63
+ const content = JSON.stringify(lockfile, null, 2);
64
+ await fs.writeFile(outputPath, content, 'utf-8');
65
+ console.log(`Lockfile written to: ${outputPath}`);
66
+ }
67
+
68
+ /**
69
+ * Generate README.md content for a category
70
+ */
71
+ function generateCategoryReadme(category, entries, projectsMap, usersMap) {
72
+ const categoryTitle = category.charAt(0).toUpperCase() + category.slice(1);
73
+ const lines = [`# ${categoryTitle}`, '', '| Name | Author | Version |', '|-|-|-|'];
74
+
75
+ // Map category to Modrinth URL path segment
76
+ const categoryPathMap = {};
77
+ for (const category of config.DEPENDENCY_CATEGORIES) {
78
+ categoryPathMap[category] = category === 'shaderpacks' ? 'shader' : category.toLowerCase().slice(0, -1);
79
+ }
80
+ const categoryPath = categoryPathMap[category] || 'project';
81
+
82
+ for (const entry of entries) {
83
+ const version = entry.version;
84
+ let nameCell = '';
85
+ let authorCell = '';
86
+ let versionCell = '';
87
+
88
+ if (version && version.project_id) {
89
+ const project = projectsMap[version.project_id];
90
+ const author = version.author_id ? usersMap[version.author_id] : null;
91
+
92
+ // Name column with icon and link
93
+ if (project) {
94
+ const projectName = project.title || project.slug || 'Unknown';
95
+ const projectSlug = project.slug || project.id;
96
+ const projectUrl = `https://modrinth.com/${categoryPath}/${projectSlug}`;
97
+
98
+ if (project.icon_url) {
99
+ nameCell = `<img alt="Icon" src="${project.icon_url}" height="20px"> [${projectName}](${projectUrl})`;
100
+ } else {
101
+ nameCell = `[${projectName}](${projectUrl})`;
102
+ }
103
+ } else {
104
+ // Project not found, use filename
105
+ const fileName = path.basename(entry.path);
106
+ nameCell = fileName;
107
+ }
108
+
109
+ // Author column with avatar and link
110
+ if (author) {
111
+ const authorName = author.username || 'Unknown';
112
+ const authorUrl = `https://modrinth.com/user/${authorName}`;
113
+
114
+ if (author.avatar_url) {
115
+ authorCell = `<img alt="Avatar" src="${author.avatar_url}" height="20px"> [${authorName}](${authorUrl})`;
116
+ } else {
117
+ authorCell = `[${authorName}](${authorUrl})`;
118
+ }
119
+ } else {
120
+ authorCell = 'Unknown';
121
+ }
122
+
123
+ // Version column
124
+ versionCell = version.version_number || 'Unknown';
125
+ } else {
126
+ // File not found on Modrinth
127
+ const fileName = path.basename(entry.path);
128
+ nameCell = fileName;
129
+ authorCell = 'Unknown';
130
+ versionCell = '-';
131
+ }
132
+
133
+ lines.push(`| ${nameCell} | ${authorCell} | ${versionCell} |`);
134
+ }
135
+
136
+ return lines.join('\n') + '\n';
137
+ }
138
+
139
+ /**
140
+ * Generate .gitignore rules for files not hosted on Modrinth
141
+ * @param {Lockfile} lockfile - The lockfile object
142
+ * @returns {string} The .gitignore rules
143
+ */
144
+ export function generateGitignoreRules(lockfile) {
145
+ const rules = [];
146
+ const exceptions = [];
147
+
148
+ // Base ignore patterns for each category
149
+ for (const category of config.DEPENDENCY_CATEGORIES) {
150
+ rules.push(`${category}/*.${category === "mods" ? "jar" : "zip"}`);
151
+ }
152
+ rules.push('\n## Exceptions');
153
+
154
+ // Find files not hosted on Modrinth
155
+ for (const [category, entries] of Object.entries(lockfile.dependencies)) {
156
+ for (const entry of entries) {
157
+ if (entry.version === null) {
158
+ exceptions.push(`!${entry.path}`);
159
+ }
160
+ }
161
+ }
162
+
163
+ // Add exceptions if any
164
+ if (exceptions.length > 0) {
165
+ rules.push(...exceptions);
166
+ } else {
167
+ rules.push('# No exceptions needed - all files are hosted on Modrinth');
168
+ }
169
+
170
+ return rules.join('\n');
171
+ }
172
+
173
+ /**
174
+ * Generate the README.md files for each category
175
+ * @param {Lockfile} lockfile - The lockfile object
176
+ * @param {string} workingDir - The working directory
177
+ * @param {Options} options - The options object
178
+ */
179
+ export async function generateReadmeFiles(lockfile, workingDir, options = {}) {
180
+ // Collect unique project IDs and author IDs from version data
181
+ const projectIds = new Set();
182
+ const authorIds = new Set();
183
+
184
+ for (const [category, entries] of Object.entries(lockfile.dependencies)) {
185
+ for (const entry of entries) {
186
+ if (entry.version && entry.version.project_id) {
187
+ projectIds.add(entry.version.project_id);
188
+ }
189
+ if (entry.version && entry.version.author_id) {
190
+ authorIds.add(entry.version.author_id);
191
+ }
192
+ }
193
+ }
194
+
195
+ // Fetch projects and users in parallel
196
+ console.log(`Fetching data for ${projectIds.size} project(s) and ${authorIds.size} user(s)...`);
197
+
198
+ const [projects, users] = await Promise.all([
199
+ getProjects(Array.from(projectIds)),
200
+ getUsers(Array.from(authorIds)),
201
+ ]);
202
+
203
+ // Map projects and users to their IDs
204
+ const projectsMap = {};
205
+ for (const project of projects) {
206
+ projectsMap[project.id] = project;
207
+ }
208
+
209
+ const usersMap = {};
210
+ for (const user of users) {
211
+ usersMap[user.id] = user;
212
+ }
213
+
214
+ // Generate README for each category
215
+ for (const [category, entries] of Object.entries(lockfile.dependencies)) {
216
+ if (entries.length === 0) {
217
+ continue;
218
+ }
219
+
220
+ const readmeContent = generateCategoryReadme(category, entries, projectsMap, usersMap);
221
+ const categoryDir = getScanDirectories(workingDir).find(d => d.name === category);
222
+
223
+ if (categoryDir) {
224
+ const readmePath = path.join(categoryDir.path, 'README.md');
225
+
226
+ if (options.dryRun) {
227
+ console.log(`[DRY RUN] Would write README to: ${readmePath}`);
228
+ } else {
229
+ try {
230
+ await fs.writeFile(readmePath, readmeContent, 'utf-8');
231
+ console.log(`Generated README: ${readmePath}`);
232
+ } catch (error) {
233
+ console.warn(`Warning: Could not write README to ${readmePath}: ${error.message}`);
234
+ }
235
+ }
236
+ }
237
+ }
238
+
239
+ console.log('README generation complete.');
240
+ }
241
+
242
+ /**
243
+ * Generate the lockfile
244
+ * @param {string} workingDir - The working directory
245
+ * @param {Options} options - The options object
246
+ * @returns {Lockfile} The lockfile object
247
+ */
248
+ export async function generateLockfile(workingDir, options = {}) {
249
+ if (options.dryRun) {
250
+ console.log('[DRY RUN] Preview mode - no files will be written');
251
+ }
252
+
253
+ console.log('Scanning directories for modpack files...');
254
+
255
+ // Scan all directories
256
+ const allFileEntries = [];
257
+ for (const dirInfo of getScanDirectories(workingDir)) {
258
+ console.log(`Scanning ${dirInfo.name}...`);
259
+ const fileEntries = await scanDirectory(dirInfo, workingDir);
260
+ console.log(` Found ${fileEntries.length} file(s)`);
261
+ allFileEntries.push(...fileEntries);
262
+ }
263
+
264
+ // Sort file entries
265
+ allFileEntries.sort((a, b) => {
266
+ if (a.category !== b.category) {
267
+ return a.category.localeCompare(b.category, 'en', { sensitivity: 'base' });
268
+ }
269
+ return a.path.localeCompare(b.path, 'en', { numeric: true, sensitivity: 'base' });
270
+ });
271
+
272
+ if (allFileEntries.length === 0) {
273
+ console.log('No files found. Creating empty lockfile.');
274
+ const outputPath = path.join(workingDir, config.MODPACK_LOCKFILE_NAME);
275
+ if (options.dryRun) {
276
+ console.log(`[DRY RUN] Would write lockfile to: ${outputPath}`);
277
+ } else {
278
+ await writeLockfile(createEmptyLockfile(), outputPath);
279
+ }
280
+ return;
281
+ }
282
+
283
+ console.log(`\nTotal files found: ${allFileEntries.length}`);
284
+ console.log('\nQuerying Modrinth API...');
285
+
286
+ // Extract all hashes
287
+ const hashes = allFileEntries.map(info => info.hash);
288
+
289
+ // Query Modrinth API
290
+ const versionData = await getVersionsFromHashes(hashes);
291
+
292
+ console.log(`\nFound version information for ${Object.keys(versionData).length} out of ${hashes.length} files`);
293
+
294
+ // Create lockfile
295
+ const lockfile = createLockfile(allFileEntries, versionData);
296
+
297
+ // Write lockfile
298
+ const outputPath = path.join(workingDir, config.MODPACK_LOCKFILE_NAME);
299
+ if (options.dryRun) {
300
+ console.log(`[DRY RUN] Would write lockfile to: ${outputPath}`);
301
+ } else {
302
+ await writeLockfile(lockfile, outputPath);
303
+ }
304
+
305
+ // Summary
306
+ console.log('\n=== Summary ===');
307
+ for (const [category, entries] of Object.entries(lockfile.dependencies)) {
308
+ const withVersion = entries.filter(e => e.version !== null).length;
309
+ const withoutVersion = entries.length - withVersion;
310
+ console.log(`${category}: ${entries.length} file(s) (${withVersion} found on Modrinth, ${withoutVersion} unknown)`);
311
+ }
312
+
313
+ // Generate .gitignore rules
314
+ if (options.gitignore) {
315
+ console.log('\n=== .gitignore Rules ===\n');
316
+ console.log(generateGitignoreRules(lockfile));
317
+ console.log();
318
+ }
319
+
320
+ // Generate README files
321
+ if (options.readme) {
322
+ console.log('\nGenerating README files...');
323
+ await generateReadmeFiles(lockfile, workingDir, options = {});
324
+ }
325
+
326
+ return lockfile;
327
+ }
@@ -1,32 +1,31 @@
1
- #!/usr/bin/env NODE_OPTIONS=--no-warnings node
1
+ import { generateLockfile, generateReadmeFiles, generateGitignoreRules } from './generate_lockfile.js';
2
+ import generateJson from './generate_json.js';
3
+ import promptUserForInfo from './modpack_info.js';
4
+ import { getModpackInfo, getLockfile } from './directory_scanning.js';
2
5
 
3
- import { Command } from 'commander';
4
- import generateLockfile from './generate-lockfile.js';
6
+ /**
7
+ * @typedef {import('./config/types.js').ModpackInfo} ModpackInfo
8
+ * @typedef {import('./config/types.js').Options} Options
9
+ * @typedef {import('./config/types.js').Lockfile} Lockfile
10
+ */
5
11
 
6
- import pkg from '../package.json' with { type: 'json' };
7
- const modpackLock = new Command('modpack-lock');
12
+ /**
13
+ * @license MIT
14
+ * @author nickesc
15
+ * @module modpack-lock
16
+ */
8
17
 
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()
18
+ /**
19
+ * Generate the modpack files (lockfile and JSON)
20
+ * @param {ModpackInfo} modpackInfo - The modpack information
21
+ * @param {string} directory - The directory to generate the files in
22
+ * @param {Options} options - The options object
23
+ * @returns {Promise<Lockfile>} The lockfile object
24
+ */
25
+ async function generateModpackFiles(modpackInfo, directory, options = {}) {
26
+ const lockfile = await generateLockfile(directory, options);
27
+ await generateJson(modpackInfo, lockfile, directory, options);
28
+ return lockfile;
29
+ }
32
30
 
31
+ export { generateModpackFiles, generateJson, generateLockfile, generateGitignoreRules, generateReadmeFiles, getModpackInfo, getLockfile, promptUserForInfo };
@@ -0,0 +1,134 @@
1
+ import prompts from 'prompts';
2
+ import slugify from 'slugify';
3
+ import * as config from './config/index.js';
4
+
5
+ /**
6
+ * Validate that a value is not empty
7
+ */
8
+ function validateNotEmpty(value, field) {
9
+ if (value.trim().length === 0) {
10
+ return `${field} cannot be empty`;
11
+ }
12
+ return true;
13
+ }
14
+
15
+ /**
16
+ * @typedef {import('./config/types.js').ModpackInfo} ModpackInfo
17
+ * @typedef {import('./config/types.js').Options} Options
18
+ * @typedef {import('./config/types.js').Lockfile} Lockfile
19
+ */
20
+
21
+ /**
22
+ * Get user input for modpack information
23
+ * @param {ModpackInfo} defaults - The initial/default modpack information
24
+ * @returns {Promise<ModpackInfo>} The modpack information from the user
25
+ */
26
+ export default async function promptUserForInfo(defaults = {}) {
27
+ let answers = await prompts([
28
+ {
29
+ type: 'text',
30
+ name: 'name',
31
+ message: 'Modpack name',
32
+ initial: defaults.name,
33
+ validate: (value) => {
34
+ return validateNotEmpty(value, 'Name');
35
+ },
36
+ },
37
+ {
38
+ type: 'text',
39
+ name: 'version',
40
+ message: 'Modpack version',
41
+ initial: defaults.version || '1.0.0',
42
+ validate: (value) => {
43
+ return validateNotEmpty(value, 'Version');
44
+ },
45
+ },
46
+
47
+ {
48
+ type: 'text',
49
+ name: 'id',
50
+ message: 'Modpack slug/ID',
51
+ initial: slugify(defaults.id || defaults.name, config.SLUGIFY_OPTIONS),
52
+ validate: (value) => {
53
+ return validateNotEmpty(value, 'ID');
54
+ },
55
+ },
56
+ {
57
+ type: 'text',
58
+ name: 'description',
59
+ message: 'Modpack description',
60
+ initial: defaults.description || undefined,
61
+ },
62
+ {
63
+ type: 'text',
64
+ name: 'author',
65
+ message: 'Modpack author',
66
+ initial: defaults.author || undefined,
67
+ validate: (value) => {
68
+ return validateNotEmpty(value, 'Author');
69
+ },
70
+ },
71
+ {
72
+ type: 'text',
73
+ name: 'projectUrl',
74
+ message: 'Modpack URL',
75
+ initial: defaults.projectUrl || undefined,
76
+ },
77
+ {
78
+ type: 'text',
79
+ name: 'sourceUrl',
80
+ message: 'Modpack source code URL',
81
+ initial: defaults.sourceUrl || undefined,
82
+ },
83
+ {
84
+ type: 'text',
85
+ name: 'license',
86
+ message: 'Modpack license',
87
+ initial: defaults.license || undefined,
88
+ },
89
+ {
90
+ type: 'autocomplete',
91
+ name: 'modloader',
92
+ message: 'Modpack modloader',
93
+ initial: defaults.modloader || undefined,
94
+ choices: [
95
+ { title: 'fabric' },
96
+ { title: 'forge' },
97
+ { title: 'quilt' },
98
+ { title: 'neoforge' },
99
+ { title: 'sponge' },
100
+ { title: 'paper' },
101
+ { title: 'velocity' },
102
+ { title: 'bungeecord' },
103
+ { title: 'waterfall' },
104
+ { title: 'travertia' },
105
+ { title: 'nukkit' },
106
+ { title: 'pufferfish' },
107
+ { title: 'purpur' },
108
+ ],
109
+ validate: (value) => {
110
+ return validateNotEmpty(value, 'Modloader');
111
+ },
112
+ },
113
+ {
114
+ type: 'text',
115
+ name: 'targetModloaderVersion',
116
+ message: 'Target modloader version',
117
+ initial: defaults.targetModloaderVersion || undefined,
118
+ },
119
+ {
120
+ type: 'text',
121
+ name: 'targetMinecraftVersion',
122
+ message: 'Target Minecraft version',
123
+ initial: defaults.targetMinecraftVersion || undefined,
124
+ validate: (value) => {
125
+ return validateNotEmpty(value, 'Minecraft Version');
126
+ },
127
+ }
128
+ ]);
129
+ if (Object.keys(answers).length < 11) {
130
+ console.warn('Modpack initialization was interrupted');
131
+ process.exit(1);
132
+ }
133
+ return answers;
134
+ }