Skip to content

Commit d817640

Browse files
authored
feat: add CLI (#45)
1 parent a55835a commit d817640

13 files changed

Lines changed: 461 additions & 157 deletions

bun.lock

Lines changed: 63 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/package.json

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22
"name": "cli",
33
"version": "0.0.1",
44
"dependencies": {
5+
"@commander-js/extra-typings": "^14.0.0",
6+
"commander": "^10.0.0",
57
"mclib": "workspace:*",
6-
"commander": "^10.0.0"
8+
"pino": "^9.7.0",
9+
"pino-pretty": "^13.0.0"
710
},
811
"scripts": {
912
"build": "(cd ../mclib && bun run build) && bun build src/index.ts --outdir dist --minify --target node",
10-
"start": "(cd ../mclib && bun run build) && bun src/index.ts"
11-
},
12-
}
13+
"start": "(cd ../mclib && bun run build) && bun src/index.ts",
14+
"proxy-start": "HTTP_PROXY=http://127.0.0.1:8080 HTTPS_PROXY=http://127.0.0.1:8080 bun run start"
15+
}
16+
}

cli/src/index.ts

Lines changed: 179 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,198 @@
11
#!/usr/bin/env bun
22

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';
57

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+
}
7137

8138
program
9139
.name('modpack-cli')
10140
.description('CLI for managing modpacks')
11141
.version('0.0.1');
12142

13143
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);
21157

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;
25162
}
26163

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+
);
31176

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;
35180
}
36-
});
37181

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+
}
43196
});
44197

45198
program.parse(process.argv);

mclib/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@
1010
"typescript-eslint": "^8.32.1"
1111
},
1212
"scripts": {
13-
"build": "tsc"
13+
"build": "tsc",
14+
"test": "bunx vitest run"
1415
},
1516
"type": "module",
1617
"types": "dist/index.d.ts",
1718
"dependencies": {
18-
"cf-fingerprint": "https://github.com/arschedev/cf-fingerprint"
19+
"cf-fingerprint": "https://github.com/iTrooz/cf-fingerprint/",
20+
"pino": "^9.7.0",
21+
"pino-pretty": "^13.0.0"
1922
}
2023
}

mclib/src/ModQueryService.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { MCVersion, ModAndReleases, ModRepositoryName, ModSearchMetadata, IRepository } from ".";
2+
import { logger } from "./logger";
23

34
export class ModQueryService {
45

@@ -67,4 +68,23 @@ export class ModQueryService {
6768
}
6869
throw new Error(`Mod with ID ${modId} not found in any repository`);
6970
}
71+
72+
async getModByDataHash(modData: Uint8Array): Promise<ModSearchMetadata | undefined> {
73+
for (const repo of this.repositories) {
74+
logger.debug("getModByDataHash(size = %s, %s)", modData.length, repo.getRepositoryName())
75+
try {
76+
const result = await repo.getByDataHash(modData);
77+
if (result) {
78+
logger.debug("getModByDataHash(size = %s, %s) = %s (%s)", modData.length, repo.getRepositoryName(), result.id, result.name);
79+
return result;
80+
} else {
81+
logger.trace("getModByDataHash(size = %s, %s) = not found", modData.length, repo.getRepositoryName());
82+
}
83+
} catch (error) {
84+
logger.error("Error fetching mod by hash from %s: %s", repo.getRepositoryName(), error);
85+
}
86+
}
87+
logger.debug("getModByDataHash(size = %s) = not found in any repository", modData.length);
88+
return undefined; // No mod found in any repository
89+
}
7090
}
Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
1-
import { Constraints, Solution } from "..";
1+
import { Constraints, ModAndReleases, Solution } from "..";
22

33
export interface ISolutionFinder {
44
/**
55
* Download releases for all mods, and return solutions that try to satisfy the constraints (Best solutions are returned first)
66
* This is the method you should use for the main functionality of the class.
7-
* @param mods List of mod IDs
7+
* @param modIds List of mod IDs
88
* @param constraints Constraints to apply on the solution
9-
* @param nbSolution Max number of solutions to return
9+
* @param nbSolutions Max number of solutions to return
1010
* @returns Array of solutions
1111
*/
12-
findSolutions(mods: string[], constraints?: Constraints, nbSolution?: number): Promise<Solution[]>;
12+
findSolutions(modIds: string[], constraints?: Constraints, nbSolutions?: number): Promise<Solution[]>;
13+
14+
/**
15+
* Resolves releases of mods
16+
*/
17+
resolveMods(modIds: string[]): Promise<ModAndReleases[]>;
18+
19+
/**
20+
* Finds the best Minecraft configurations (version + loader) that satisfy all mods
21+
*/
22+
resolveSolutions(mods: ModAndReleases[], constraints: Constraints, nbSolution: number): Solution[];
1323
}

0 commit comments

Comments
 (0)