Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ For programmatic usage, `modpack-lock` exports these functions:
- `getModpackInfo()`
- `getLockfile()`
- `generateJson()`
- `normalizeDependencies()`
- `generateGitignoreRules()`
- `generateReadmeFiles()`
- `generateLicense()`
Expand Down Expand Up @@ -233,10 +234,14 @@ The JSON file contains your modpack metadata and a dependency list:
"example": "echo 'example script'"
},
"dependencies": {
"mods": [ ... ],
"resourcepacks": [ ... ],
"datapacks": [ ... ],
"shaderpacks": [ ... ]
"mods": {
"fabric-api": "0.141.2+1.21.11",
"example-mod": "1.2.3",
"mods/local-mod.jar": "*"
},
"resourcepacks": { ... },
"datapacks": { ... },
"shaderpacks": { ... }
}
}
```
Expand Down
581 changes: 255 additions & 326 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "modpack-lock",
"version": "0.7.0",
"version": "0.8.0",
"description": "Creates a modpack lockfile for files hosted on Modrinth (mods, resource packs, shaders and datapacks)",
"bugs": {
"url": "https://github.com/nickesc/modpack-lock/issues"
Expand Down
6 changes: 5 additions & 1 deletion src/config/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export const PACKAGE_USER_AGENT: string = `${constants.AUTHOR_USERNAME}/${pkg.na
/** Modrinth API base URL */
export const MODRINTH_API_BASE: string = "https://api.modrinth.com/v2";

/** Default timeout for Modrinth API requests */
export const MODRINTH_API_TIMEOUT: number = 3000;

/** Modrinth version files endpoint */
export const MODRINTH_VERSION_FILES_ENDPOINT: string = `${MODRINTH_API_BASE}/version_files`;

Expand Down Expand Up @@ -35,7 +38,8 @@ export const GITHUB_LICENSES_ENDPOINT: string = `${GITHUB_API_BASE}/licenses`;
export const GITHUB_FEATURED_LICENSES_ENDPOINT: string = `${GITHUB_LICENSES_ENDPOINT}?featured=true`;

/** GitHub license endpoint */
export const GITHUB_LICENSE_ENDPOINT: (license: string) => string = (license) => `${GITHUB_API_BASE}/licenses/${license}`;
export const GITHUB_LICENSE_ENDPOINT: (license: string) => string = (license) =>
`${GITHUB_API_BASE}/licenses/${license}`;

/** GitHub Accept request header */
export const GITHUB_ACCEPT_HEADER: string = "application/vnd.github+json";
57 changes: 44 additions & 13 deletions src/generate_json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,41 @@ import path from "path";
import {getProjects} from "./modrinth_interactions.js";
import * as config from "./config/index.js";
import {logm} from "./logger.js";
import type {Lockfile, Jsonfile, Options, InitOptions, DependencyCategory} from "./types/index.js";
import type {Lockfile, Jsonfile, Options, InitOptions, DependencyCategory, DependencyMap} from "./types/index.js";

/**
* Normalize dependencies from the legacy array-of-strings format to the
* current versioned-object format. Passes through objects unchanged.
* @param dependencies - The raw dependencies object from modpack.json
* @returns Normalized dependencies with {@link DependencyMap} entries per category
*/
export function normalizeDependencies(
dependencies: Jsonfile["dependencies"] | null,
): Partial<Record<DependencyCategory, DependencyMap>> {
if (!dependencies || typeof dependencies !== "object") return {};

const normalized: Partial<Record<DependencyCategory, DependencyMap>> = {};
for (const [key, entries] of Object.entries(dependencies)) {
const category = key as DependencyCategory;
if (Array.isArray(entries)) {
const map: DependencyMap = {};
for (const entry of entries) {
map[entry] = "*";
}
normalized[category] = map;
} else if (typeof entries === "object" && entries !== null) {
normalized[category] = entries as DependencyMap;
} else {
normalized[category] = {};
}
}
return normalized;
}

/**
* Create a JSON object from the modpack information and dependencies
*/
function createModpackJson(modpackInfo: Jsonfile, dependencies: Record<DependencyCategory, string[]>): Jsonfile {
function createModpackJson(modpackInfo: Jsonfile, dependencies: Record<DependencyCategory, DependencyMap>): Jsonfile {
return {
...modpackInfo,
dependencies: dependencies,
Expand All @@ -32,7 +61,7 @@ async function writeJson(jsonObject: Jsonfile, outputPath: string): Promise<void
* @param options - The options object
* @returns The JSON file's object
*/
export default async function generateJson(
export async function generateJson(
modpackInfo: Jsonfile,
lockfile: Lockfile,
workingDir: string,
Expand All @@ -59,24 +88,26 @@ export default async function generateJson(
datapacks: new Set(),
shaderpacks: new Set(),
};
const packDependencies: Record<DependencyCategory, string[]> = {
mods: [],
resourcepacks: [],
datapacks: [],
shaderpacks: [],
const versionNumbers: Record<string, string> = {};
const packDependencies: Record<DependencyCategory, DependencyMap> = {
mods: {},
resourcepacks: {},
datapacks: {},
shaderpacks: {},
};

// Collect project IDs from lockfile
// Collect project IDs and version numbers from lockfile
if (lockfile)
if (lockfile.dependencies) {
for (const category of config.DEPENDENCY_CATEGORIES) {
if (lockfile.dependencies[category]) {
// TODO: consider initializing the categories with an empty array/set here
// TODO: consider initializing the categories with an empty object/set here
for (const entry of lockfile.dependencies[category]) {
if (entry.version && entry.version.project_id) {
projectIds[category].add(entry.version.project_id);
versionNumbers[entry.version.project_id] = entry.version.version_number || "*";
} else {
packDependencies[category].push(entry.path);
packDependencies[category][entry.path] = "*";
}
}
}
Expand All @@ -97,12 +128,12 @@ export default async function generateJson(
}
}

// Add projects to dependencies by category
// Add projects to dependencies by category with their version numbers
for (const category of config.DEPENDENCY_CATEGORIES) {
for (const projectId of projectIds[category]) {
const projectSlug: string | undefined = projectsMap[projectId];
if (projectSlug) {
packDependencies[category].push(projectSlug);
packDependencies[category][projectSlug] = versionNumbers[projectId] || "*";
}
}
}
Expand Down
5 changes: 4 additions & 1 deletion src/modpack-lock.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import {generateLockfile} from "./generate_lockfile.js";
import {generateReadmeFiles} from "./generate_readme.js";
import {generateGitignoreRules} from "./generate_gitignore.js";
import generateJson from "./generate_json.js";
import {generateJson, normalizeDependencies} from "./generate_json.js";
import generateLicense from "./generate_license.js";
import {logm} from "./logger.js";
import {promptUserForInfo} from "./user_prompts.js";
import {getModpackInfo, getLockfile} from "./directory_scanning.js";
import type {
Jsonfile,
DependencyMap,
Options,
InitOptions,
Lockfile,
Expand Down Expand Up @@ -67,6 +68,7 @@ async function generateModpackFiles(
export {
generateModpackFiles,
generateJson,
normalizeDependencies,
generateLockfile,
generateGitignoreRules,
generateReadmeFiles,
Expand All @@ -79,6 +81,7 @@ export type {
Lockfile, //
ModpackInfo,
Jsonfile,
DependencyMap,
Options,
InitOptions,
DependencyCategory,
Expand Down
5 changes: 5 additions & 0 deletions src/modrinth_interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export async function getVersionsFromHashes(hashes: string[]): Promise<Record<st
hashes: hashes,
algorithm: "sha1",
}),
signal: AbortSignal.timeout(config.MODRINTH_API_TIMEOUT),
});

if (!response.ok) {
Expand Down Expand Up @@ -79,6 +80,7 @@ export async function getProjects(projectIds: string[]): Promise<ProjectResponse
headers: {
"User-Agent": config.PACKAGE_USER_AGENT,
},
signal: AbortSignal.timeout(config.MODRINTH_API_TIMEOUT),
});

if (!response.ok) {
Expand Down Expand Up @@ -121,6 +123,7 @@ export async function getUsers(userIds: string[]): Promise<UserResponseItem[]> {
headers: {
"User-Agent": config.PACKAGE_USER_AGENT,
},
signal: AbortSignal.timeout(config.MODRINTH_API_TIMEOUT),
});

if (!response.ok) {
Expand Down Expand Up @@ -154,6 +157,7 @@ export async function getMinecraftVersions(): Promise<Choice[]> {
headers: {
"User-Agent": config.PACKAGE_USER_AGENT,
},
signal: AbortSignal.timeout(config.MODRINTH_API_TIMEOUT),
});
if (!response.ok) {
const errorText = await response.text();
Expand Down Expand Up @@ -193,6 +197,7 @@ export async function getModloaders(): Promise<Choice[]> {
headers: {
"User-Agent": config.PACKAGE_USER_AGENT,
},
signal: AbortSignal.timeout(config.MODRINTH_API_TIMEOUT),
});
if (!response.ok) {
const errorText = await response.text();
Expand Down
15 changes: 14 additions & 1 deletion src/types/Jsonfile.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
import type {DependencyCategory, ModpackInfo} from "./index.js";

/**
* A map of content names (Modrinth slugs or file paths) to version strings.
* Non-Modrinth content uses `"*"` to indicate an unversioned/local file.
*/
export type DependencyMap = Record<string, string>;

/**
* Legacy dependency format: a flat array of name strings without version info.
* Accepted on read for backward compatibility; normalised to {@link DependencyMap}
* via {@link normalizeDependencies} at runtime.
*/
export type LegacyDependencyList = string[];

/**
* The modpack.json file's shape; contains the modpack information and dependencies
* @property dependencies - The dependencies of the modpack
* @property scripts - The scripts of the modpack
* @property [key: string] - Any other properties of the modpack
*/
export type Jsonfile = Partial<ModpackInfo> & {
dependencies?: Partial<Record<DependencyCategory, string[]>>;
dependencies?: Partial<Record<DependencyCategory, DependencyMap | LegacyDependencyList>>;
scripts?: {
[key: string]: string;
};
Expand Down
115 changes: 114 additions & 1 deletion test/modpack-lock.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import unzipper from "unzipper";
import {
generateModpackFiles,
generateJson,
normalizeDependencies,
generateLockfile,
generateGitignoreRules,
generateReadmeFiles,
Expand Down Expand Up @@ -715,10 +716,70 @@ old content here

const result = await generateJson(modpackInfo, jsonLockfile, jsonWorkspace);

const allDeps = Object.values(result.dependencies).flat();
const allDeps = Object.values(result.dependencies).flatMap((obj) => Object.keys(obj));
expect(allDeps.length).toBeGreaterThan(0);
});

it("dependencies use versioned object format", async () => {
const jsonWorkspace = await extractWorkspaceFixture();
const jsonLockfile = await generateLockfile(jsonWorkspace);

const modpackInfo = {
name: "Test Modpack",
version: "1.0.0",
id: "test-modpack",
author: "Test Author",
modloader: "fabric",
targetMinecraftVersion: "1.21.1",
};

const result = await generateJson(modpackInfo, jsonLockfile, jsonWorkspace);

for (const [, categoryDeps] of Object.entries(result.dependencies)) {
expect(typeof categoryDeps).toBe("object");
expect(Array.isArray(categoryDeps)).toBe(false);

for (const [name, version] of Object.entries(categoryDeps)) {
expect(typeof name).toBe("string");
expect(typeof version).toBe("string");
expect(name.length).toBeGreaterThan(0);
expect(version.length).toBeGreaterThan(0);
}
}
});

it("Modrinth content has specific version numbers, non-Modrinth uses '*'", async () => {
const jsonWorkspace = await extractWorkspaceFixture();
const jsonLockfile = await generateLockfile(jsonWorkspace);

const modpackInfo = {
name: "Test Modpack",
version: "1.0.0",
id: "test-modpack",
author: "Test Author",
modloader: "fabric",
targetMinecraftVersion: "1.21.1",
};

const result = await generateJson(modpackInfo, jsonLockfile, jsonWorkspace);

const allEntries = Object.values(result.dependencies).flatMap((obj) => Object.entries(obj));
const modrinthEntries = allEntries.filter(([, v]) => v !== "*");
const nonModrinthEntries = allEntries.filter(([, v]) => v === "*");

expect(modrinthEntries.length).toBeGreaterThan(0);
expect(nonModrinthEntries.length).toBeGreaterThan(0);

for (const [name, version] of modrinthEntries) {
expect(name).not.toContain("/");
expect(version).not.toBe("*");
}

for (const [name] of nonModrinthEntries) {
expect(name).toContain("/");
}
});

it("throws error when required fields are missing", async () => {
const jsonWorkspace = await extractWorkspaceFixture();

Expand Down Expand Up @@ -753,6 +814,58 @@ old content here
});
});

describe("normalizeDependencies", () => {
it("converts legacy array format to versioned object format", () => {
const legacy = {
mods: ["fabric-api", "projector", "mods/x.jar"],
resourcepacks: ["neighborhood-dark"],
};

const result = normalizeDependencies(legacy);

expect(result.mods).toEqual({
"fabric-api": "*",
projector: "*",
"mods/x.jar": "*",
});
expect(result.resourcepacks).toEqual({
"neighborhood-dark": "*",
});
});

it("passes through versioned object format unchanged", () => {
const current = {
mods: {"fabric-api": "0.141.2+1.21.11", "mods/x.jar": "*"},
resourcepacks: {"neighborhood-dark": "1.1.0"},
};

const result = normalizeDependencies(current);

expect(result).toEqual(current);
});

it("handles null and undefined input", () => {
expect(normalizeDependencies(null)).toEqual({});
expect(normalizeDependencies(undefined)).toEqual({});
});

it("handles empty dependencies", () => {
expect(normalizeDependencies({})).toEqual({});
});

it("handles mixed legacy and current formats across categories", () => {
const mixed = {
mods: ["fabric-api"],
resourcepacks: {"neighborhood-dark": "1.1.0"},
};

const result = normalizeDependencies(mixed);

expect(result.mods).toEqual({"fabric-api": "*"});
expect(result.resourcepacks).toEqual({"neighborhood-dark": "1.1.0"});
});
});

describe("getModpackInfo / getLockfile", () => {
it("reads existing modpack.json", async () => {
const readWorkspace = await extractWorkspaceFixture();
Expand Down