|
1 | 1 | #!/usr/bin/env bun |
2 | 2 |
|
3 | | -import { Command } from 'commander'; |
4 | | -import { ModQueryService } from 'mclib'; |
| 3 | +import { program } from '@commander-js/extra-typings'; |
| 4 | +import { CurseForgeRepository, LocalSolutionFinder, LoggerConfig, ModLoader, ModQueryService, ModrinthRepository, LogLevel, Constraints, Solution } from 'mclib'; |
| 5 | +import { readFileSync } from 'fs'; |
| 6 | +import pino from 'pino'; |
5 | 7 |
|
6 | | -const program = new Command(); |
| 8 | +// Logging setup |
| 9 | +const LOG_LEVEL = (process.env.LOG_LEVEL ?? "info") as LogLevel; |
| 10 | +const logger = pino({ |
| 11 | + level: LOG_LEVEL, |
| 12 | + base: { |
| 13 | + pid: false, |
| 14 | + }, |
| 15 | + transport: { |
| 16 | + target: 'pino-pretty', |
| 17 | + options: { |
| 18 | + colorize: true |
| 19 | + } |
| 20 | + } |
| 21 | +}); |
| 22 | +LoggerConfig.setLevel(LOG_LEVEL); |
| 23 | + |
| 24 | +// Fetch wrapper to log timing and errors |
| 25 | +async function fetchWrapper(input: RequestInfo | URL, options?: RequestInit): Promise<Response> { |
| 26 | + const start = Date.now(); |
| 27 | + const response = await fetch(input, options); |
| 28 | + const duration = Date.now() - start; |
| 29 | + logger.debug(`fetch(${input}): ${duration}ms`); |
| 30 | + if (!response.ok) { |
| 31 | + logger.error(`Fetch failed for ${input}: ${response.status} ${response.statusText}`); |
| 32 | + } |
| 33 | + return response; |
| 34 | +} |
| 35 | + |
| 36 | +function getModQueryService() { |
| 37 | + const repositories = [ |
| 38 | + new ModrinthRepository(fetchWrapper), |
| 39 | + new CurseForgeRepository(fetchWrapper), |
| 40 | + ]; |
| 41 | + return new ModQueryService(repositories); |
| 42 | +} |
| 43 | + |
| 44 | +interface CliOptions { |
| 45 | + modId?: string[]; |
| 46 | + modFile?: string[]; |
| 47 | + minVersion?: string; |
| 48 | + maxVersion?: string; |
| 49 | + loader?: string[]; |
| 50 | + details: boolean; |
| 51 | + nbSolutions: number; |
| 52 | + sinytra: boolean; |
| 53 | +} |
| 54 | + |
| 55 | +function validateCliOptions(options: CliOptions) { |
| 56 | + if ((!options.modId || options.modId.length === 0) && (!options.modFile || options.modFile.length === 0)) { |
| 57 | + throw new Error('At least one --mod-id or --mod-file is required.'); |
| 58 | + } |
| 59 | +} |
| 60 | + |
| 61 | +async function getModIds(modQueryService: ModQueryService, options: CliOptions): Promise<string[]> { |
| 62 | + const modIdSet = new Set<string>(); |
| 63 | + |
| 64 | + if (options.modId) { |
| 65 | + for (const id of options.modId) { |
| 66 | + if (modIdSet.has(id)) { |
| 67 | + logger.warn(`Duplicate mod ID from --mod-id: ${id}`); |
| 68 | + } else { |
| 69 | + modIdSet.add(id); |
| 70 | + } |
| 71 | + } |
| 72 | + } |
| 73 | + |
| 74 | + if (options.modFile) { |
| 75 | + for (const file of options.modFile) { |
| 76 | + try { |
| 77 | + // Read file |
| 78 | + const start = Date.now(); |
| 79 | + let modData = readFileSync(file); |
| 80 | + const duration = Date.now() - start; |
| 81 | + logger.debug(`readFileSync(${file}): ${duration}ms`); |
| 82 | + |
| 83 | + // Get metadata |
| 84 | + let modMetadata = await modQueryService.getModByDataHash(new Uint8Array(modData)); |
| 85 | + if (!modMetadata) { |
| 86 | + logger.warn(`Could not extract mod ID from file: ${file}`); |
| 87 | + continue; |
| 88 | + } |
| 89 | + |
| 90 | + // add to set |
| 91 | + if (modIdSet.has(modMetadata.id)) { |
| 92 | + logger.warn(`Duplicate mod ID from file: ${modMetadata.id} (file: ${file})`); |
| 93 | + } else { |
| 94 | + modIdSet.add(modMetadata.id); |
| 95 | + logger.info(`Found mod ID ${modMetadata.id} from file: ${file}`); |
| 96 | + } |
| 97 | + } catch (error) { |
| 98 | + logger.error(`Error processing mod file ${file}: ${error}`); |
| 99 | + continue; |
| 100 | + } |
| 101 | + } |
| 102 | + } |
| 103 | + |
| 104 | + return Array.from(modIdSet); |
| 105 | +} |
| 106 | + |
| 107 | +async function findSolutions( |
| 108 | + modQueryService: ModQueryService, |
| 109 | + requestedModIds: string[], |
| 110 | + constraints: Constraints, |
| 111 | + nbSolutions: number, |
| 112 | + sinytra: boolean |
| 113 | +): Promise<Solution[]> { |
| 114 | + let solutionFinder = new LocalSolutionFinder(modQueryService); |
| 115 | + |
| 116 | + // Resolve mods |
| 117 | + const mods = await solutionFinder.resolveMods(requestedModIds); |
| 118 | + |
| 119 | + // Sinytra loader injection |
| 120 | + if (sinytra) { |
| 121 | + logger.info('Sinytra mode: Injecting Forge and NeoForge into Fabric-compatible releases...'); |
| 122 | + for (const mod of mods) { |
| 123 | + for (const release of mod.releases) { |
| 124 | + if (release.loaders.has(ModLoader.FABRIC)) { |
| 125 | + release.loaders.add(ModLoader.FORGE); |
| 126 | + release.loaders.add(ModLoader.NEOFORGE); |
| 127 | + logger.trace(`Injected forge and neoforge into fabric-compatible release: ${mod.id} ${release.modVersion}`); |
| 128 | + } |
| 129 | + } |
| 130 | + } |
| 131 | + } |
| 132 | + |
| 133 | + // Get solutions |
| 134 | + return await solutionFinder.resolveSolutions( |
| 135 | + mods, constraints, nbSolutions); |
| 136 | +} |
7 | 137 |
|
8 | 138 | program |
9 | 139 | .name('modpack-cli') |
10 | 140 | .description('CLI for managing modpacks') |
11 | 141 | .version('0.0.1'); |
12 | 142 |
|
13 | 143 | program |
14 | | - .command('create') |
15 | | - .description('Create a new modpack') |
16 | | - .option('--mod-name <modName...>', 'Mod IDs to include in the modpack') |
17 | | - .option('--min-version <version>', 'Minimum version of the modpack') |
18 | | - .option('--loader <loader...>', 'Loaders to support (e.g., forge, fabric)') |
19 | | - .action(async (options) => { |
20 | | - const { modName, minVersion, loader } = options; |
| 144 | + .command('find-solutions').alias('fs') |
| 145 | + .description('Find mod versions that match constraints') |
| 146 | + .option('--mod-id <modID...>', 'Mod IDs to include in the modpack') |
| 147 | + .option('--mod-file <path...>', 'Mod IDs to include in the modpack') |
| 148 | + .option('--min-version <version>', 'Minimum Minecraft version to consider') |
| 149 | + .option('--max-version <version>', 'Maximum Minecraft version to consider') |
| 150 | + .option('--loader <loader...>', 'Loaders to consider (e.g., forge, fabric)', []) |
| 151 | + .option('-d, --details', 'Include details (e.g. unsupported mods in solutions found)', false) |
| 152 | + .option('-n, --nb-solutions <number>', 'Number of solutions to output', (value) => parseInt(value, 10), 3) |
| 153 | + .option('--sinytra', 'Inject forge and neoforge into fabric-compatible releases', false) |
| 154 | + .action(async (cliOptions: CliOptions & { sinytra?: boolean }) => { |
| 155 | + let modQueryService = getModQueryService(); |
| 156 | + validateCliOptions(cliOptions); |
21 | 157 |
|
22 | | - if (!modName || modName.length === 0) { |
23 | | - console.error('Error: At least one --mod-name is required.'); |
24 | | - process.exit(1); |
| 158 | + const requestedModIds = await getModIds(modQueryService, cliOptions); |
| 159 | + if (requestedModIds.length === 0) { |
| 160 | + logger.error('No valid mod IDs found from provided options.'); |
| 161 | + return; |
25 | 162 | } |
26 | 163 |
|
27 | | - if (!minVersion) { |
28 | | - console.error('Error: --min-version is required.'); |
29 | | - process.exit(1); |
30 | | - } |
| 164 | + logger.info(`Searching for solutions with ${requestedModIds.length} mod(s)...`); |
| 165 | + const solutions = await findSolutions( |
| 166 | + modQueryService, |
| 167 | + requestedModIds, |
| 168 | + { |
| 169 | + minVersion: cliOptions.minVersion, |
| 170 | + maxVersion: cliOptions.maxVersion, |
| 171 | + loaders: new Set(cliOptions.loader as ModLoader[]), |
| 172 | + }, |
| 173 | + cliOptions.nbSolutions, |
| 174 | + !!cliOptions.sinytra |
| 175 | + ); |
31 | 176 |
|
32 | | - if (!loader || loader.length === 0) { |
33 | | - console.error('Error: At least one --loader is required.'); |
34 | | - process.exit(1); |
| 177 | + if (solutions.length === 0) { |
| 178 | + logger.info('No solutions found.'); |
| 179 | + return; |
35 | 180 | } |
36 | | - }); |
37 | 181 |
|
38 | | -program |
39 | | - .command('list') |
40 | | - .description('List all modpacks') |
41 | | - .action(() => { |
42 | | - // TODO |
| 182 | + logger.info(`Found ${solutions.length} solution(s):`); |
| 183 | + for (const solution of solutions) { |
| 184 | + logger.info(`- Version: ${solution.mcConfig.mcVersion}, Loader: ${solution.mcConfig.loader}, Mods: ${solution.mods.length}/${requestedModIds.length}`); |
| 185 | + if (cliOptions.details && solution.mods.length != requestedModIds.length) { |
| 186 | + let unsupportedMods = requestedModIds.filter(modId => !solution.mods.some(mod => mod.id === modId)); |
| 187 | + logger.info(` Unsupported mods (${unsupportedMods.length}):`); |
| 188 | + for (const modId of unsupportedMods) { |
| 189 | + logger.info(` - ${modId}`); |
| 190 | + } |
| 191 | + } |
| 192 | + } |
| 193 | + if (!cliOptions.details) { |
| 194 | + logger.info('Use --details to see unsupported mods in solutions.'); |
| 195 | + } |
43 | 196 | }); |
44 | 197 |
|
45 | 198 | program.parse(process.argv); |
0 commit comments