Skip to content

Commit d0b34d5

Browse files
authored
feat: Make mods not have an ID (#46)
1 parent d817640 commit d0b34d5

16 files changed

Lines changed: 419 additions & 300 deletions

cli/src/index.ts

Lines changed: 94 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env bun
22

3-
import { program } from '@commander-js/extra-typings';
4-
import { CurseForgeRepository, LocalSolutionFinder, LoggerConfig, ModLoader, ModQueryService, ModrinthRepository, LogLevel, Constraints, Solution } from 'mclib';
3+
import { program, Option } from '@commander-js/extra-typings';
4+
import { CurseForgeRepository, LocalSolutionFinder, LoggerConfig, ModLoader, ModQueryService, ModrinthRepository, LogLevel, Constraints, Solution, ModMetadata, RepositoryUtil, ModRepositoryName, ModRepoMetadata } from 'mclib';
55
import { readFileSync } from 'fs';
66
import pino from 'pino';
77

@@ -33,17 +33,31 @@ async function fetchWrapper(input: RequestInfo | URL, options?: RequestInit): Pr
3333
return response;
3434
}
3535

36-
function getModQueryService() {
37-
const repositories = [
38-
new ModrinthRepository(fetchWrapper),
39-
new CurseForgeRepository(fetchWrapper),
40-
];
36+
function getModQueryService(selectedRepos?: string[]) {
37+
const repoMap = {
38+
modrinth: () => new ModrinthRepository(fetchWrapper),
39+
curseforge: () => new CurseForgeRepository(fetchWrapper),
40+
};
41+
let repositories: any[] = [];
42+
if (selectedRepos && selectedRepos.length > 0) {
43+
for (const repo of selectedRepos) {
44+
const factory = repoMap[repo.toLowerCase() as keyof typeof repoMap];
45+
if (factory) repositories.push(factory());
46+
else {
47+
logger.error(`Unknown repository: ${repo}`);
48+
process.exit(1);
49+
}
50+
}
51+
} else {
52+
repositories = [new ModrinthRepository(fetchWrapper), new CurseForgeRepository(fetchWrapper)];
53+
}
4154
return new ModQueryService(repositories);
4255
}
4356

4457
interface CliOptions {
4558
modId?: string[];
4659
modFile?: string[];
60+
exactVersion?: string;
4761
minVersion?: string;
4862
maxVersion?: string;
4963
loader?: string[];
@@ -58,15 +72,35 @@ function validateCliOptions(options: CliOptions) {
5872
}
5973
}
6074

61-
async function getModIds(modQueryService: ModQueryService, options: CliOptions): Promise<string[]> {
62-
const modIdSet = new Set<string>();
75+
async function getMods(modQueryService: ModQueryService, options: CliOptions): Promise<ModMetadata[]> {
76+
const modsMap = new Map<string, ModMetadata>();
6377

6478
if (options.modId) {
6579
for (const id of options.modId) {
66-
if (modIdSet.has(id)) {
80+
let [repoName, modId] = id.split('/');
81+
if (!modId) {
82+
logger.error(`Invalid mod ID format: ${id}. Expected format is 'repository/modId'.`);
83+
continue;
84+
}
85+
86+
let repo = RepositoryUtil.from(repoName as ModRepositoryName, fetch);
87+
if (!repo) {
88+
logger.error(`Unknown repository: ${repoName}`);
89+
continue;
90+
}
91+
92+
let fullId = `${repo.getRepositoryName()}/${modId}`.toLowerCase();
93+
if (modsMap.has(fullId)) {
6794
logger.warn(`Duplicate mod ID from --mod-id: ${id}`);
6895
} else {
69-
modIdSet.add(id);
96+
modsMap.set(fullId, [{
97+
id,
98+
repository: repo.getRepositoryName(),
99+
name: id,
100+
homepageURL: "",
101+
imageURL: "",
102+
downloadCount: 0
103+
}]);
70104
}
71105
}
72106
}
@@ -81,50 +115,54 @@ async function getModIds(modQueryService: ModQueryService, options: CliOptions):
81115
logger.debug(`readFileSync(${file}): ${duration}ms`);
82116

83117
// Get metadata
84-
let modMetadata = await modQueryService.getModByDataHash(new Uint8Array(modData));
85-
if (!modMetadata) {
118+
let modMetadatas = await modQueryService.getModByDataHash(new Uint8Array(modData));
119+
if (modMetadatas.length === 0) {
86120
logger.warn(`Could not extract mod ID from file: ${file}`);
87121
continue;
88122
}
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}`);
123+
logger.debug(`Extracted ${modMetadatas.length} match in repositories from file: ${file}`);
124+
125+
// verify if there are duplicates
126+
for (const modMetadata of modMetadatas) {
127+
let fullId = `${modMetadata.repository}|${modMetadata.id}`.toLowerCase();
128+
if (modsMap.has(fullId)) {
129+
logger.warn(`Duplicate mod ID from file: ${modMetadata.id} (file: ${file})`);
130+
}
96131
}
132+
133+
// add to set (with random key, we don't care anyway)
134+
modsMap.set(`|${modMetadatas[0].id}`, modMetadatas);
97135
} catch (error) {
98136
logger.error(`Error processing mod file ${file}: ${error}`);
99137
continue;
100138
}
101139
}
102140
}
103141

104-
return Array.from(modIdSet);
142+
return Array.from(modsMap.values());
105143
}
106144

107145
async function findSolutions(
108146
modQueryService: ModQueryService,
109-
requestedModIds: string[],
147+
requestedMods: ModMetadata[],
110148
constraints: Constraints,
111149
nbSolutions: number,
112150
sinytra: boolean
113151
): Promise<Solution[]> {
114152
let solutionFinder = new LocalSolutionFinder(modQueryService);
115153

116154
// Resolve mods
117-
const mods = await solutionFinder.resolveMods(requestedModIds);
155+
const mods = await solutionFinder.resolveMods(requestedMods);
118156

119157
// Sinytra loader injection
120158
if (sinytra) {
121159
logger.info('Sinytra mode: Injecting Forge and NeoForge into Fabric-compatible releases...');
122160
for (const mod of mods) {
123-
for (const release of mod.releases) {
161+
for (const release of mod) {
124162
if (release.loaders.has(ModLoader.FABRIC)) {
125163
release.loaders.add(ModLoader.FORGE);
126164
release.loaders.add(ModLoader.NEOFORGE);
127-
logger.trace(`Injected forge and neoforge into fabric-compatible release: ${mod.id} ${release.modVersion}`);
165+
logger.trace(`Injected forge and neoforge into fabric-compatible release: ${release.modMetadata.id} ${release.modVersion}`);
128166
}
129167
}
130168
}
@@ -145,29 +183,31 @@ program
145183
.description('Find mod versions that match constraints')
146184
.option('--mod-id <modID...>', 'Mod IDs to include in the modpack')
147185
.option('--mod-file <path...>', 'Mod IDs to include in the modpack')
186+
.addOption(new Option('--exact-version <version>', 'Exact Minecraft version to consider').conflicts(['minVersion', 'maxVersion']))
148187
.option('--min-version <version>', 'Minimum Minecraft version to consider')
149188
.option('--max-version <version>', 'Maximum Minecraft version to consider')
150189
.option('--loader <loader...>', 'Loaders to consider (e.g., forge, fabric)', [])
151190
.option('-d, --details', 'Include details (e.g. unsupported mods in solutions found)', false)
152191
.option('-n, --nb-solutions <number>', 'Number of solutions to output', (value) => parseInt(value, 10), 3)
153192
.option('--sinytra', 'Inject forge and neoforge into fabric-compatible releases', false)
154-
.action(async (cliOptions: CliOptions & { sinytra?: boolean }) => {
155-
let modQueryService = getModQueryService();
193+
.option('-r, --repository <repo...>', 'Repositories to use (modrinth, curseforge)')
194+
.action(async (cliOptions: CliOptions & { repository?: string[] }) => {
195+
let modQueryService = getModQueryService(cliOptions.repository);
156196
validateCliOptions(cliOptions);
157197

158-
const requestedModIds = await getModIds(modQueryService, cliOptions);
159-
if (requestedModIds.length === 0) {
198+
const requestedMods = await getMods(modQueryService, cliOptions);
199+
if (requestedMods.length === 0) {
160200
logger.error('No valid mod IDs found from provided options.');
161201
return;
162202
}
163203

164-
logger.info(`Searching for solutions with ${requestedModIds.length} mod(s)...`);
204+
logger.info(`Searching for solutions with ${requestedMods.length} mod(s)...`);
165205
const solutions = await findSolutions(
166206
modQueryService,
167-
requestedModIds,
207+
requestedMods,
168208
{
169-
minVersion: cliOptions.minVersion,
170-
maxVersion: cliOptions.maxVersion,
209+
minVersion: cliOptions.exactVersion || cliOptions.minVersion,
210+
maxVersion: cliOptions.exactVersion || cliOptions.maxVersion,
171211
loaders: new Set(cliOptions.loader as ModLoader[]),
172212
},
173213
cliOptions.nbSolutions,
@@ -181,13 +221,28 @@ program
181221

182222
logger.info(`Found ${solutions.length} solution(s):`);
183223
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}`);
224+
logger.info(`- Version: ${solution.mcConfig.mcVersion}, Loader: ${solution.mcConfig.loader}, Mods: ${solution.mods.length}/${requestedMods.length}`);
225+
if (cliOptions.details && solution.mods.length != requestedMods.length) {
226+
227+
// Get unsupported mods
228+
let unsupportedModsMeta: ModMetadata[] = [];
229+
for (const requestedMod of requestedMods) {
230+
let hasIncludedRelease = false;
231+
for (const release of solution.mods) {
232+
// check if we have repo-specific metadata of this mod in our solution
233+
if (requestedMod.includes(release.modMetadata)) {
234+
hasIncludedRelease = true;
235+
break;
236+
}
237+
}
238+
if (!hasIncludedRelease) unsupportedModsMeta.push(requestedMod);
190239
}
240+
241+
logger.info(` Unsupported mods (${unsupportedModsMeta.length}):`);
242+
for (const modMeta of unsupportedModsMeta) {
243+
logger.info(` - ${modMeta[0].id} (${modMeta[0].name})`);
244+
}
245+
191246
}
192247
}
193248
if (!cliOptions.details) {

mclib/src/ModQueryService.ts

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { MCVersion, ModAndReleases, ModRepositoryName, ModSearchMetadata, IRepository } from ".";
1+
import { type MCVersion, type ModRepositoryName, type ModRepoMetadata, type IRepository, type ModMetadata, type ModReleases, ModMetadataUtil } from ".";
22
import { logger } from "./logger";
33

44
export class ModQueryService {
@@ -27,8 +27,8 @@ export class ModQueryService {
2727
query: string,
2828
specifiedRepos: ModRepositoryName[],
2929
maxResults: number = 10,
30-
): Promise<Array<[ModRepositoryName, ModSearchMetadata]>> {
31-
const allResults: Array<[ModRepositoryName, ModSearchMetadata]> = [];
30+
): Promise<Array<[ModRepositoryName, ModRepoMetadata]>> {
31+
const allResults: Array<[ModRepositoryName, ModRepoMetadata]> = [];
3232

3333
for (const repo of this.repositories) {
3434
try {
@@ -55,36 +55,58 @@ export class ModQueryService {
5555
return allResults;
5656
}
5757

58-
// TODO do better
59-
async getModReleases(modId: string): Promise<ModAndReleases> {
58+
private getRepoByName(repoName: ModRepositoryName): IRepository | null {
6059
for (const repo of this.repositories) {
60+
if (repo.getRepositoryName() === repoName) {
61+
return repo;
62+
}
63+
}
64+
return null;
65+
}
66+
67+
// get releases of this mod across all repositories, using all metadata provided
68+
async getModReleasesFromMetadata(modMeta: ModMetadata): Promise<ModReleases> {
69+
logger.debug("getModReleasesFromMetadata(%s)", ModMetadataUtil.toString(modMeta));
70+
const releases: ModReleases = [];
71+
for (const modRepoMeta of modMeta) {
72+
const repo = this.getRepoByName(modRepoMeta.repository);
73+
if (!repo) {
74+
logger.warn("getModReleasesFromMetadata(%s): Repository %s not found", modRepoMeta.id, modRepoMeta.repository);
75+
continue;
76+
}
6177
// Try to get releases for this mod ID
6278
try {
63-
return await repo.getModReleases(modId);
64-
} catch {
65-
// Ignore and try next repository
66-
// TODO handle error
79+
const repoReleases = await repo.getModReleases(modRepoMeta.id);
80+
// Attach the metadata to the release
81+
repoReleases.forEach(release => {
82+
release.modMetadata = modRepoMeta;
83+
});
84+
releases.push(...repoReleases);
85+
} catch (error) {
86+
logger.error("Error fetching mod releases for %s from %s: %s", modRepoMeta.id, repo.getRepositoryName(), error);
6787
}
6888
}
69-
throw new Error(`Mod with ID ${modId} not found in any repository`);
89+
logger.debug("getModReleasesFromMetadata(%s) = %d releases found", ModMetadataUtil.toString(modMeta), releases.length);
90+
return releases;
7091
}
7192

72-
async getModByDataHash(modData: Uint8Array): Promise<ModSearchMetadata | undefined> {
93+
async getModByDataHash(modData: Uint8Array): Promise<ModMetadata> {
94+
const results: ModMetadata = [];
7395
for (const repo of this.repositories) {
7496
logger.debug("getModByDataHash(size = %s, %s)", modData.length, repo.getRepositoryName())
7597
try {
7698
const result = await repo.getByDataHash(modData);
7799
if (result) {
78100
logger.debug("getModByDataHash(size = %s, %s) = %s (%s)", modData.length, repo.getRepositoryName(), result.id, result.name);
79-
return result;
101+
results.push(result);
80102
} else {
81103
logger.trace("getModByDataHash(size = %s, %s) = not found", modData.length, repo.getRepositoryName());
82104
}
83105
} catch (error) {
84106
logger.error("Error fetching mod by hash from %s: %s", repo.getRepositoryName(), error);
85107
}
86108
}
87-
logger.debug("getModByDataHash(size = %s) = not found in any repository", modData.length);
88-
return undefined; // No mod found in any repository
109+
logger.debug("getModByDataHash(size = %s) = found in %s repositories", modData.length, results.length);
110+
return results
89111
}
90112
}
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Constraints, ModAndReleases, Solution } from "..";
1+
import { Constraints, ModMetadata, ModReleases, Solution } from "..";
22

33
export interface ISolutionFinder {
44
/**
@@ -9,15 +9,15 @@ export interface ISolutionFinder {
99
* @param nbSolutions Max number of solutions to return
1010
* @returns Array of solutions
1111
*/
12-
findSolutions(modIds: string[], constraints?: Constraints, nbSolutions?: number): Promise<Solution[]>;
12+
findSolutions(modIds: ModMetadata[], constraints?: Constraints, nbSolutions?: number): Promise<Solution[]>;
1313

1414
/**
1515
* Resolves releases of mods
1616
*/
17-
resolveMods(modIds: string[]): Promise<ModAndReleases[]>;
17+
resolveMods(modIds: ModMetadata[]): Promise<ModReleases[]>;
1818

1919
/**
2020
* Finds the best Minecraft configurations (version + loader) that satisfy all mods
2121
*/
22-
resolveSolutions(mods: ModAndReleases[], constraints: Constraints, nbSolution: number): Solution[];
22+
resolveSolutions(mods: ModReleases[], constraints: Constraints, nbSolution: number): Solution[];
2323
}

0 commit comments

Comments
 (0)