@origami-minecraft/devbuilds
Version:
Origami is a terminal-first Minecraft launcher that supports authentication, installation, and launching of Minecraft versions — with built-in support for Microsoft accounts, mod loaders, profile management, and more. Designed for power users, modders, an
418 lines (347 loc) • 17.4 kB
text/typescript
import inquirer from 'inquirer'
import chalk from 'chalk'
import path from 'path'
import fs from 'fs'
import { ModrinthProjects } from './modrinth'
import { Logger } from '../../../tools/logger'
import { LauncherProfile } from '../../../../types/launcher'
import { ensureDir, minecraft_dir } from '../../../utils/common'
import { downloader } from '../../../utils/download'
import { ModData, ModrinthSearchParams, ModrinthSortOption, ModrinthSortOptions, ModrinthVersion, ModrinthVersionFile } from '../../../../types/modrinth'
import ModrinthModManager from './manager'
import ora from 'ora'
export class ModInstaller {
private modrinth: ModrinthProjects;
private pageSize = 10;
constructor(private logger: Logger) {
this.modrinth = new ModrinthProjects(logger);
}
public async configure_filters(project_type: string, version: string, loader: string, manager: ModrinthModManager, defaults?: {
sort?: ModrinthSortOption;
versionMatch?: 'strict' | 'match' | 'none';
selectedCategories?: string[];
}): Promise<{
sort: ModrinthSortOption;
versionFilter: string[] | undefined;
categories: string[] | undefined;
}> {
const all_categories = (await this.modrinth.tags.getCategories(project_type)) || [];
const categoryOptions = all_categories
.filter(cat => project_type === 'mod' ? cat.name.toLowerCase() !== loader.toLowerCase() : true)
.map(cat => ({ name: cat.name, value: cat.name }));
const { sort } = await inquirer.prompt([
{
type: 'list',
name: 'sort',
message: '📊 Sort results by:',
choices: ModrinthSortOptions.map(opt => ({
name: opt.charAt(0).toUpperCase() + opt.slice(1),
value: opt,
})),
default: defaults?.sort ?? 'relevance'
}
]);
const { versionMatch } = await inquirer.prompt([
{
type: 'list',
name: 'versionMatch',
message: '🎯 Minecraft version match strategy:',
choices: [
{ name: 'Strict (exact version match)', value: 'strict' },
{ name: 'Match (minor version match)', value: 'match' },
{ name: 'None (ignore version)', value: 'none' }
],
default: defaults?.versionMatch ?? 'strict'
}
]);
let versionFilter: string[] | undefined = undefined;
if(versionMatch === 'strict') {
versionFilter = [];
versionFilter.push(version);
} else if(versionMatch === 'match') {
versionFilter = [];
const matchedVersion = await this.modrinth.fetchAllMatchVersions(version);
versionFilter.push(version);
matchedVersion.forEach(ver => {
if(!versionFilter?.find(v => v === ver)) {
versionFilter?.push(ver);
}
});
}
let categories: string[] | undefined = defaults?.selectedCategories;
if (categoryOptions.length > 0) {
const { selectedCategories } = await inquirer.prompt([
{
type: 'checkbox',
name: 'selectedCategories',
message: '🧩 Select categories to filter by:',
choices: categoryOptions,
default: defaults?.selectedCategories ?? [],
}
]);
categories = selectedCategories.length > 0 ? selectedCategories : undefined;
}
if (project_type === 'mod' && !categories?.some(v => v.toLowerCase() === loader.toLowerCase())) {
categories = [...(categories ?? []), loader.toLowerCase()];
}
const { page_limit } = await inquirer.prompt([
{
type: 'input',
name: 'page_limit',
message: '📄 How many results per page?',
default: `${manager.getPageLimit()}`,
filter: input => parseInt(input, 10),
validate: input => {
const num = parseInt(input, 10);
if (isNaN(num) || num <= 0) return 'Page limit must be a positive number';
if (num > 100) return 'Maximum allowed is 100';
return true;
}
}
]);
manager.currentPageLimit(typeof page_limit === 'string' ? parseInt(page_limit) : page_limit);
manager.configureFilter(project_type as 'mod' | 'shader' | 'resourcepack', {
sort,
versionFilter,
selectedCategories: categories
});
return { sort, versionFilter, categories };
}
public async ask_confirmation(message: string, _applyToAll: boolean = false, _default: string | undefined = undefined): Promise<{choice: 'keep' | 'replace'; applyToAll: boolean; }> {
const { choice } = _default ? { choice: _default } : await inquirer.prompt([
{
type: 'list',
name: 'choice',
message,
choices: [
{ name: 'Keep existing file', value: 'keep' },
{ name: 'Replace with new file', value: 'replace' }
],
default: _default ?? 'keep',
}
]);
const { applyToAll } = _applyToAll ? { applyToAll: _applyToAll } : await inquirer.prompt([
{
type: 'confirm',
name: 'applyToAll',
message: 'Apply this choice to all remaining items?',
default: false
}
]);
return { choice, applyToAll };
}
public async install_modrinth_content(profile: LauncherProfile): Promise<void> {
const manager = new ModrinthModManager(profile);
const { type } = await inquirer.prompt({
type: 'list',
name: 'type',
message: '📦 Select content type:',
choices: [
{ name: 'Mods', value: 'mod' },
{ name: 'Resource Packs', value: 'resourcepack' },
{ name: 'Shaders', value: 'shader' }
]
});
let page = manager.getPage();
let mode: 'home' | 'search' = 'home';
let query = '';
const mcVersion = profile.lastVersionId;
const loader = profile.origami.metadata.name.toLowerCase();
let defaults_p = manager.getDefaultFilters(type);
let sort_p: ModrinthSortOption = defaults_p?.sort ?? 'relevance';
let versions_p: string[] | undefined = defaults_p?.versionFilter ?? (type === 'mod' ? [profile.lastVersionId] : undefined);
let categories_p: string[] | undefined = defaults_p?.selectedCategories ?? (type === 'mod' ? [loader] : undefined);
const version_folder = path.join(minecraft_dir(true), 'instances', profile.origami.path);
const folder = { mod: 'mods', resourcepack: 'resourcepacks', shader: 'shaderpacks' }[type as string] || 'mods';
const dest = path.join(version_folder, folder);
ensureDir(dest);
while (true) {
console.clear();
console.log(chalk.bold(`📦 ${mode === 'home' ? 'Featured' : 'Search'} ${type}s (MC ${mcVersion}) — Page ${page + 1}\n`));
const spinner = ora('🐾 Warming up the search engine...').start();
this.pageSize = manager.getPageLimit();
let searchResults;
const commonQuery: ModrinthSearchParams = {
query: mode === 'search' ? (query || '*') : '*',
limit: this.pageSize,
offset: page * this.pageSize,
index: sort_p,
facets: {
project_type: type,
versions: versions_p,
categories: categories_p,
}
};
spinner.text = '🔍 Looking through Modrinth...';
searchResults = await this.modrinth.searchProject(commonQuery);
const hits = searchResults?.hits ?? [];
const total = searchResults?.total_hits ?? 0;
const choices: any[] = [];
choices.push({ name: '[🔍 Search]', value: '__search' });
choices.push({ name: '[🛠️ Configure Filters]', value: '__configure_filters' });
let versions_data: ModData[] = [];
spinner.text = `🎀 Gathering ${type} files...`;
spinner.color = 'yellow';
for (const hit of hits) {
const versions = await this.modrinth.versions.fetchVersions(
hit.project_id,
type === 'mod' ? [loader] : undefined,
versions_p
);
const isInstalled = versions?.find(v => v.files.find(f => manager.getFromType(f.filename, type)));
const file = isInstalled ? isInstalled.files.find(f => manager.getFromType(f.filename, type)) : undefined
if (versions) {
versions_data.push({ hit: hit.project_id, is_installed: isInstalled, specific: file, versions });
} else {
versions_data.push({ hit: hit.project_id, is_installed: undefined, specific: undefined, versions: [] });
}
const displayName = isInstalled
? chalk.italic.underline(`${hit.title} — ⬇ ${hit.downloads.toLocaleString()} / ⭐ ${hit.follows.toLocaleString()}`)
: `${hit.title} — ⬇ ${hit.downloads.toLocaleString()} / ⭐ ${hit.follows.toLocaleString()}`;
choices.push({ name: displayName, value: hit.project_id });
}
if (page > 0) choices.push({ name: '⬅ Previous page', value: '__prev' });
if ((page + 1) * this.pageSize < total) choices.push({ name: '➡ Next page', value: '__next' });
choices.push({ name: '🔙 Back', value: '__back' });
spinner.succeed('Done');
const { selected } = await inquirer.prompt({
type: 'list',
name: 'selected',
message: 'Select an option:',
choices,
loop: false,
});
if (selected === '__back') break;
if (selected === '__next') { page++; manager.currentPage(page); continue; }
if (selected === '__prev') { page--; manager.currentPage(page); continue; }
if (selected === '__search') {
mode = 'search';
const resp = await inquirer.prompt({
type: 'input',
name: 'query',
message: `Search for ${type}s:`,
default: query
});
query = resp.query;
page = 0;
continue;
}
if (selected === '__configure_filters') {
let results = await this.configure_filters(type, profile.lastVersionId, loader, manager, {
sort: sort_p,
versionMatch: (versions_p?.length || 0) < 1 ? 'none' : versions_p?.length === 1 ? 'strict' : 'match',
selectedCategories: categories_p
});
versions_p = results.versionFilter;
sort_p = results.sort;
categories_p = results.categories;
continue;
}
let version_data = versions_data.find(v => v.hit === selected);
await this.handleProjectInstall(version_data?.versions, type, profile, dest, version_data, manager);
break;
}
}
private async handleProjectInstall(
versions_raw: ModrinthVersion[] | undefined,
type: 'mod' | 'resourcepack' | 'shader',
profile: LauncherProfile,
dest: string,
data: ModData | undefined,
manager: ModrinthModManager,
) {
console.clear();
console.log(chalk.bold('🔄 Fetching versions...'));
const versions = versions_raw;
if (!versions?.length) {
console.log(chalk.red('❌ No compatible versions found.'));
return;
}
const versionChoices = versions.map(v => ({
name: `${v.name} (${v.version_number})`,
value: v
}));
const { selectedVersion } = await inquirer.prompt({
type: 'list',
name: 'selectedVersion',
message: 'Select version to install:',
choices: versionChoices,
loop: false,
});
const file = selectedVersion.files.find((f: any) => f.primary) || selectedVersion.files[0];
if (!file) {
console.log(chalk.red('❌ No downloadable file found.'));
return;
}
let main_apply_to_all = false;
let main_default: string | undefined = undefined;
if (data?.is_installed && data?.specific && fs.existsSync(path.join(dest, data.specific.filename))) {
const confirm = await this.ask_confirmation(`You've already installed mod version '${data.specific.filename}'. What do you want to do?`, main_apply_to_all, main_default);
if(confirm.applyToAll) {
main_apply_to_all = confirm.applyToAll;
main_default = confirm.choice;
};
if(confirm.choice === 'replace') {
const fullPath = path.join(dest, data.specific.filename);
fs.unlinkSync(fullPath);
this.logger.log(chalk.yellow(`🗑 Removed old version: ${data.specific.filename}`));
manager.deleteFromType(data.specific.filename, type);
await downloadMod(file, this.logger, type);
} else if (confirm.choice === 'keep') {
this.logger.log(chalk.gray(`⏭️ Skipped: ${data.specific.filename} (already installed)`));
}
} else await downloadMod(file, this.logger, type);
async function downloadMod(file: ModrinthVersionFile, logger: Logger, type: 'mod' | 'resourcepack' | 'shader') {
const filename = file.filename;
const outPath = path.join(dest, filename);
if (fs.existsSync(outPath)) fs.unlinkSync(outPath);
logger.log(chalk.green(`📥 Downloading ${filename}...`));
await downloader(file.url, outPath);
logger.log(chalk.green(`✅ Installed ${filename} to ${type}s folder.`));
manager.addFromType(filename, type);
};
let deps_apply_to_all = false;
let deps_default: string | undefined = undefined;
for (const dep of selectedVersion.dependencies) {
if (dep.dependency_type !== 'required') continue;
const depProject = await this.modrinth.getProject(dep.project_id);
if (!depProject) {
this.logger.log(chalk.yellow(`⚠️ Skipped missing dependency: ${dep.project_id}`));
continue;
}
this.logger.log(chalk.blue(`📦 Installing dependency: ${depProject.title}`));
const depVersions = await this.modrinth.versions.fetchVersions(
dep.project_id,
type === 'mod' ? [profile.origami.metadata.name.toLowerCase()] : undefined,
selectedVersion.game_versions
);
if (!depVersions?.length) {
this.logger.log(chalk.red(`❌ No compatible version found for dependency: ${depProject.title}`));
continue;
}
const depFile = depVersions[0].files.find(f => f.primary) || depVersions[0].files[0];
if (!depFile) {
this.logger.log(chalk.red(`❌ No file found for dependency: ${depProject.title}`));
continue;
}
const isInstalled = depVersions?.find(v => v.files.find(f => manager.getFromType(f.filename, type)));
const file = isInstalled ? isInstalled.files.find(f => manager.getFromType(f.filename, type)) : undefined
if (isInstalled && file && fs.existsSync(path.join(dest, file.filename))) {
const confirm = await this.ask_confirmation(`You've already installed mod version '${file.filename}'. What do you want to do?`, deps_apply_to_all, deps_default);
if(confirm.applyToAll) {
deps_apply_to_all = confirm.applyToAll;
deps_default = confirm.choice;
};
if(confirm.choice === 'replace') {
const fullPath = path.join(dest, file.filename);
fs.unlinkSync(fullPath);
this.logger.log(chalk.yellow(`🗑 Removed old version: ${file.filename}`));
manager.deleteFromType(file.filename, type);
await downloadMod(depFile, this.logger, type);
} else if (confirm.choice === 'keep') {
this.logger.log(chalk.gray(`⏭️ Skipped: ${file.filename} (already installed)`));
}
} else await downloadMod(depFile, this.logger, type);
}
}
}