diff --git a/src/commands/compile/command.ts b/src/commands/compile/command.ts index 9318503..14460ce 100644 --- a/src/commands/compile/command.ts +++ b/src/commands/compile/command.ts @@ -15,7 +15,7 @@ */ import { buildCommand } from "@stricli/core"; -import { TARGETS, TargetSchema } from "~/igor"; +import { TARGETS, TargetSchema } from "~/target"; import { parseProjectPath } from "~/project"; import { parseToolchainVersion } from "~/toolchain"; diff --git a/src/commands/init/scaffold.ts b/src/commands/init/scaffold.ts index f00917f..3e6a8f2 100644 --- a/src/commands/init/scaffold.ts +++ b/src/commands/init/scaffold.ts @@ -52,7 +52,9 @@ export async function scaffoldProject( const projectToolPath = await downloadProjectTool(ctx, cache, noopLog, { verbose: false, }); - const gmpmPath = await downloadGmpm(ctx, cache, noopLog, { verbose: false }); + const { gmpmDllPath } = await downloadGmpm(ctx, cache, noopLog, { + verbose: false, + }); const packageToolPath = await downloadPackageTool(ctx, cache, noopLog, { verbose: false, }); @@ -61,7 +63,7 @@ export async function scaffoldProject( projectToolPath, projectPath: extractedYyp, packageToolPath, - gmpmPath, + gmpmDllPath, verbose: true, }); diff --git a/src/commands/package/command.ts b/src/commands/package/command.ts index c7fd90e..09c43b1 100644 --- a/src/commands/package/command.ts +++ b/src/commands/package/command.ts @@ -15,7 +15,7 @@ */ import { buildCommand } from "@stricli/core"; -import { TARGETS, TargetSchema } from "~/igor"; +import { TARGETS, TargetSchema } from "~/target"; import { parseProjectPath } from "~/project"; import { parseToolchainVersion } from "~/toolchain"; diff --git a/src/commands/package/impl.ts b/src/commands/package/impl.ts index 13c5807..65f46e5 100644 --- a/src/commands/package/impl.ts +++ b/src/commands/package/impl.ts @@ -16,7 +16,7 @@ import type { Context } from "~/context"; import { getProjectName, type ProjectPath } from "~/project"; -import { packageExtension, type Target } from "~/igor"; +import type { Target } from "~/target"; import { commonCompileSetup, type CommonCliBuildFlags, @@ -82,3 +82,16 @@ function getPackageAction(target: Target): string { // FIXME: exhaustiveness checking and fix for platforms like xbox: PackageSubmissionXboxOne", PackageSubmissionXboxSeriesXS } } + +function packageExtension(target: Target): string | undefined { + switch (target) { + case "windows": + case "linux": + case "mac": + case "operagx": + return ".zip"; + default: + // FIXME: implement for all platforms + return; + } +} diff --git a/src/commands/resourcetool/impl.ts b/src/commands/resourcetool/impl.ts index 9d88122..ef27294 100644 --- a/src/commands/resourcetool/impl.ts +++ b/src/commands/resourcetool/impl.ts @@ -60,7 +60,7 @@ export async function run( }); if (projectPath !== undefined) { - const gmpmPath = await downloadGmpm(ctx, cache, noopLog, { + const { gmpmDllPath } = await downloadGmpm(ctx, cache, noopLog, { verbose: false, }); const packageToolPath = await downloadPackageTool(ctx, cache, noopLog, { @@ -71,7 +71,7 @@ export async function run( projectToolPath, projectPath, packageToolPath, - gmpmPath, + gmpmDllPath: gmpmDllPath, verbose: false, }); } diff --git a/src/commands/run/command.ts b/src/commands/run/command.ts index bf7d195..3b7acef 100644 --- a/src/commands/run/command.ts +++ b/src/commands/run/command.ts @@ -15,7 +15,7 @@ */ import { buildCommand } from "@stricli/core"; -import { TARGETS, TargetSchema } from "~/igor"; +import { TARGETS, TargetSchema } from "~/target"; import { parseProjectPath } from "~/project"; import { parseToolchainVersion } from "~/toolchain"; diff --git a/src/common-compile-setup.ts b/src/common-compile-setup.ts index eca92df..e33eb74 100644 --- a/src/common-compile-setup.ts +++ b/src/common-compile-setup.ts @@ -16,10 +16,8 @@ import { exists, type Context } from "./context"; import { - targetForPlatform, downloadIgor, installRuntimeIfNeeded, - type Target, type CommonIgorBuildArgs, fetchLicense, } from "./igor"; @@ -35,6 +33,8 @@ import { Cache } from "./cache"; import { noopLog, type Log } from "./log"; import type { ToolchainVersion } from "./toolchain"; import { restorePrefabs } from "./restore-prefabs"; +import { installGmrtIfNeeded, spawnGmrt } from "./gmrt"; +import { targetForPlatform, type Target } from "./target"; /** * Command flags exposed in package/run/compile @@ -152,13 +152,6 @@ export async function commonCompileSetup( // flag for GMRT later too. Default to VM if not set. const runtime = flags.runtime === "native" ? "YYC" : "VM"; - // FIXME: Add support for GMRT - if (flags.toolchain?.type === "GMRT") { - throw new KnownError( - "Support for the GMRT toolchain is coming soon to GameMaker CLI.", - ); - } - // FIXME: Add full support for all platforms if (!["mac", "windows", "linux", "operagx"].includes(target)) { throw new KnownError( @@ -181,27 +174,20 @@ export async function commonCompileSetup( : { type: "infer", projectDir: ctx.path.dirname(projectPath) }, ); - const igorLog = ctx.makeTaskLogger("Downloading Igor"); - let igorPath: string; - try { - igorPath = await downloadIgor(ctx, igorLog, cache); - } catch (e) { - igorLog.error("Failed to download Igor"); - throw new KnownError(e); - } - igorLog.success("Igor downloaded"); - const gmToolLog = ctx.makeTaskLogger("Downloading tools"); let projectToolPath: string; - let gmpmPath: string; + let gmpmDllPath; + let gmpmExecutablePath; let packageToolPath: string; try { projectToolPath = await downloadProjectTool(ctx, cache, gmToolLog, { verbose: flags.verbose ?? false, }); - gmpmPath = await downloadGmpm(ctx, cache, gmToolLog, { + const gmpm = await downloadGmpm(ctx, cache, gmToolLog, { verbose: flags.verbose ?? false, }); + gmpmDllPath = gmpm.gmpmDllPath; + gmpmExecutablePath = gmpm.gmpmExecutablePath; packageToolPath = await downloadPackageTool(ctx, cache, gmToolLog, { verbose: flags.verbose ?? false, }); @@ -211,19 +197,6 @@ export async function commonCompileSetup( } gmToolLog.success("Tools downloaded"); - const licenseLog = ctx.makeTaskLogger("Fetching license"); - const licenseFile = await getLicense(ctx, flags, cache, igorPath, licenseLog); - licenseLog.success("License fetched"); - - const runtimeLog = ctx.makeTaskLogger("Installing runtime"); - const runtimeLocation = await installRuntimeIfNeeded(ctx, runtimeLog, { - licenseFile, - igorPath, - cache, - version: flags.toolchain?.version, - target, - }); - const prefabsLog = ctx.makeTaskLogger("Restoring prefabs"); let prefabsDir: string; try { @@ -231,7 +204,7 @@ export async function commonCompileSetup( projectToolPath, projectPath, packageToolPath, - gmpmPath, + gmpmDllPath, verbose: flags.verbose ?? false, }); } catch (e) { @@ -240,6 +213,95 @@ export async function commonCompileSetup( } prefabsLog.success("Prefabs restored"); + if (flags.toolchain?.type === "GMRT") { + const gmrtDownloadLog = ctx.makeTaskLogger("Downloading GMRT"); + + const gmrtRuntime = await installGmrtIfNeeded(ctx, cache, gmrtDownloadLog, { + verbose: flags.verbose ?? false, + version: flags.toolchain.version, + gmpmPath: gmpmExecutablePath, + }); + gmrtDownloadLog.success("GMRT downloaded"); + const gmrtRunLog = ctx.makeTaskLogger("Running GMRT", { noCollapse: true }); + + const buildCacheDir = await cache.getSubDirPath( + ctx, + `build-cache-gmrt-${target}-${runtime}`, + ); + + const buildDir = await cache.getSubDirPath( + ctx, + `build-gmrt-${target}-${runtime}`, + ); + + await spawnGmrt(ctx, gmrtRunLog, { + gmrtPath: gmrtRuntime.gmrtPath, + args: [ + projectPath, + "-o", + buildDir, + "-bg", + gmrtRuntime.buildGraphPath, + "-bj", + // TODO: Depends on target and command and options (e.g. native/vm) + "Build-native-macos-arm64;Run-macos-arm64", + ...(flags.verbose ? ["-v"] : []), + // TOOD: expose + "--build-type", + "Release", + "--script-build-type", + "Debug", + "--cache-dir", + buildCacheDir, + "--prefab-dir", + prefabsDir, + "--projecttool", + projectToolPath, + "--user-config", + "Default", + "--launch-type", + "run", + + // FIXME: remove these later (or create corresponding files in our own cache) + "--target-options", + "/Users/eli/Library/Application Support/GameMakerStudio2-Beta/Cache/GMS2CACHE/BLANK_GAME_39DEBCCB/targetoptions.json", + "--target-preferences", + "/Users/eli/Library/Application Support/GameMakerStudio2-Beta/Cache/GMS2CACHE/BLANK_GAME_39DEBCCB/preferences.json", + "--gmrt-preferences", + "/Users/eli/Library/Application Support/GameMakerStudio2-Beta/Cache/GMS2CACHE/BLANK_GAME_39DEBCCB/GMRTPreferences.json", + // FIXME: add license file + ], + }); + + // FIXME: Add full GMRT build invocation + throw new KnownError( + "Support for the GMRT toolchain is coming soon to GameMaker CLI.", + ); + } + + const igorLog = ctx.makeTaskLogger("Downloading Igor"); + let igorPath: string; + try { + igorPath = await downloadIgor(ctx, igorLog, cache); + } catch (e) { + igorLog.error("Failed to download Igor"); + throw new KnownError(e); + } + igorLog.success("Igor downloaded"); + + const licenseLog = ctx.makeTaskLogger("Fetching license"); + const licenseFile = await getLicense(ctx, flags, cache, igorPath, licenseLog); + licenseLog.success("License fetched"); + + const runtimeLog = ctx.makeTaskLogger("Installing runtime"); + const runtimeLocation = await installRuntimeIfNeeded(ctx, runtimeLog, { + licenseFile, + igorPath, + cache, + version: flags.toolchain?.version, + target, + }); + const buildCacheDir = await cache.getSubDirPath( ctx, `build-gms2-${target}-${runtime}`, diff --git a/src/gm-tools.ts b/src/gm-tools.ts index 4aa5db3..70decd1 100644 --- a/src/gm-tools.ts +++ b/src/gm-tools.ts @@ -73,8 +73,8 @@ export async function downloadGmpm( cache: Cache, log: Log, { verbose }: { verbose: boolean }, -) { - return download( +): Promise<{ gmpmDllPath: string; gmpmExecutablePath: string }> { + const dir = await download( ctx, "gmpm", cache, @@ -97,21 +97,18 @@ export async function downloadGmpm( "bundle", "Contents", "MacOS", - "gmpm.dll", ); } if (ctx.process.platform === "win32") { - return ctx.path.join(destDir, "node_modules", packageName, "gmpm.dll"); + return ctx.path.join(destDir, "node_modules", packageName); } - return ctx.path.join( - destDir, - "lib", - "node_modules", - packageName, - "gmpm.dll", - ); + return ctx.path.join(destDir, "lib", "node_modules", packageName); }, ); + return { + gmpmDllPath: ctx.path.join(dir, "gmpm.dll"), + gmpmExecutablePath: ctx.path.join(dir, "gmpm"), + }; } export async function downloadPackageTool( diff --git a/src/gmrt/index.ts b/src/gmrt/index.ts new file mode 100644 index 0000000..da3d933 --- /dev/null +++ b/src/gmrt/index.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2026, Opera Norway AS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from "./install-runtime"; +export * from "./spawn"; diff --git a/src/gmrt/install-runtime.ts b/src/gmrt/install-runtime.ts new file mode 100644 index 0000000..8e6a797 --- /dev/null +++ b/src/gmrt/install-runtime.ts @@ -0,0 +1,288 @@ +/** + * Copyright 2026, Opera Norway AS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { promisify } from "node:util"; +import type { Cache } from "~/cache"; +import { type Context } from "~/context"; +import type { Log } from "~/log"; +import { npmGetLatestVersion, getPlatformSuffix } from "~/npm"; +import semver from "semver"; +import { + type GmrtVersionRange, + type GmrtVersion, + gmrtVersionSchema, +} from "~/toolchain"; +import { KnownError } from "~/error"; + +// No mac-x64 or linux-arm64 packages exist; fall back to the closest available build. +const GMRT_PLATFORM_SUFFIX_OVERRIDES = { + darwin: { arm64: "mac-arm64" as const, x64: "mac-arm64" as const }, // FIXME: throw error x64 mac! + linux: { arm64: "linux-x64" as const }, +}; + +const GMRT_REGISTRY = "http://registry-1328462787.us-west-2.elb.amazonaws.com/"; + +async function resolveVersion( + ctx: Context, + version?: GmrtVersionRange, +): Promise { + const packageName = + gmrtPackageName(ctx) + "@" + (version ? version.raw : "latest"); + const versionStr = await npmGetLatestVersion(ctx, packageName, GMRT_REGISTRY); + if (!versionStr) { + if (version) { + // Probably given a non-existing version range like @200.3 + // so we will give a more friendly error message + throw new KnownError( + `Could not find a version of GMRT matching '${version.raw}'`, + ); + } + throw new KnownError( + `Could not resolve a version of GMRT. (${packageName}). Maybe you are having network issues?`, + ); + } + + const parsed = semver.parse(versionStr); + if (!parsed) { + throw new Error( + `Invariant broken: Invalid version returned from registry: ${versionStr}`, + ); + } + + return parsed; +} + +export async function installGmrtIfNeeded( + ctx: Context, + cache: Cache, + log: Log, + { + verbose, + version, + gmpmPath, + }: { verbose: boolean; version?: GmrtVersionRange; gmpmPath: string }, +): Promise<{ + gmrtPath: string; + runtimeDir: string; + buildGraphPath: string; +}> { + const runtimesDir = await cache.getSubDirPath(ctx, "runtimes-gmrt"); + + const existingRuntime = await findMatchingRuntime(ctx, runtimesDir, version); + if (existingRuntime) { + log.message(`Found existing runtime at '${existingRuntime}'`); + return { + gmrtPath: gmrtExecutablePath(ctx, existingRuntime), + runtimeDir: existingRuntime, + buildGraphPath: defaultBuildGraph(ctx, existingRuntime), + }; + } + + const resolvedVersion = await resolveVersion(ctx, version); + const installedDir = ctx.path.join(runtimesDir, resolvedVersion.version); + const packageName = gmrtPackageName(ctx); + log.message(`Installing '${packageName}@${resolvedVersion.version}'`); + await gmpmInstall(ctx, log, { + gmpmPath, + packageName, + packageVersion: resolvedVersion.version, + outputDir: installedDir, + verbose, + }); + + // TODO: should not be needed! + await installationFixup(ctx, installedDir, resolvedVersion); + + return { + gmrtPath: gmrtExecutablePath(ctx, installedDir), + runtimeDir: installedDir, + buildGraphPath: defaultBuildGraph(ctx, installedDir), + }; +} + +async function gmpmInstall( + ctx: Context, + log: Log, + { + gmpmPath, + packageName, + packageVersion, + outputDir, + verbose, + }: { + gmpmPath: string; + packageName: string; + packageVersion: string; + outputDir: string; + verbose: boolean; + }, +): Promise { + await ctx.fs.mkdir(outputDir, { recursive: true }); + + const packageJson = JSON.stringify( + { dependencies: { [packageName]: packageVersion } }, + null, + 2, + ); + + const packageJsonPath = ctx.path.join(outputDir, "package.json"); + await ctx.fs.writeFile(packageJsonPath, packageJson, "utf8"); + + const outputSubDir = ctx.path.join(outputDir, "package"); + const args = [ + "-i", + "-of", + outputSubDir, + "--reg", + GMRT_REGISTRY, + packageJsonPath, + ]; + + return new Promise((resolve, reject) => { + const child = ctx.child_process.spawn(gmpmPath, args, { + stdio: ["inherit", "pipe", "pipe"], + }); + + const onData = (data: Buffer) => { + if (!verbose) { + return; + } + for (const line of data.toString().split("\n")) { + if (line) { + log.message(line); + } + } + }; + child.stdout.on("data", onData); + child.stderr.on("data", onData); + + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`gmpm install failed with exit code ${String(code)}`)); + } + }); + }); +} + +async function findMatchingRuntime( + ctx: Context, + runtimesDir: string, + version?: GmrtVersionRange, +) { + const entries = await ctx.fs.readdir(runtimesDir); + const candidates = entries + .flatMap((name) => { + // Ignore directories that we fail to parse + const parseResult = gmrtVersionSchema.safeParse(name); + // TODO: log warning! + if (!parseResult.success) { + return []; + } + // FIXME: dirVersion should probably be a GrmtVersionComplete + const dirSemVer = semver.minVersion(parseResult.data); + if (!dirSemVer) { + return []; + } + // If a version was specified, only include runtimes that satisfy the requested range + if (version && !version.test(dirSemVer)) { + return []; + } + return [{ name, semVer: dirSemVer }]; + }) + // Most recent version first + .sort((a, b) => b.semVer.compare(a.semVer)); + + if (candidates.length === 0) { + return undefined; + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return ctx.path.join(runtimesDir, candidates[0]!.name); +} + +function gmrtPackageName(ctx: Context): string { + const suffix = getPlatformSuffix(ctx, GMRT_PLATFORM_SUFFIX_OVERRIDES); + return `@gmrt-group-release-${suffix}/gmrt`; +} + +function gmrtExecutablePath(ctx: Context, runtimeDir: string): string { + return ctx.path.join( + runtimeDir, + "package", + "Release", + `bin`, + ctx.os.platform() === "win32" ? "gmrt.exe" : "gmrt", + ); +} + +function defaultBuildGraph(ctx: Context, runtimeDir: string): string { + // TODO: maybe take arch into account here too + let buildGraphSuffix: string; + switch (ctx.os.platform()) { + case "win32": + buildGraphSuffix = "win64-prod"; + break; + case "linux": + buildGraphSuffix = "linux64-prod"; + break; + case "darwin": + buildGraphSuffix = "macarm64-prod"; + break; + default: + throw new KnownError( + `No default build graph for platform '${ctx.os.platform()}'`, + ); + } + return ctx.path.join( + runtimeDir, + "package", + "Release", + "bin", + "targets", + `buildgraph-${buildGraphSuffix}.xml`, + ); +} + +async function installationFixup( + ctx: Context, + installedDir: string, + version: GmrtVersion, +): Promise { + if (ctx.os.platform() !== "darwin" || !semver.satisfies(version, "0.19.x")) { + return; + } + + const releaseDir = ctx.path.join(installedDir, "package", "Release"); + const binDir = ctx.path.join(releaseDir, "bin"); + + const dawnSrc = ctx.path.join( + releaseDir, + "lib", + "arm64-apple-darwin", + "libwebgpu_dawn.dylib", + ); + const dawnDest = ctx.path.join(binDir, "libwebgpu_dawn.dylib"); + await ctx.fs.copyFile(dawnSrc, dawnDest); + + const assetCompilerPath = ctx.path.join(binDir, "AssetCompiler"); + await promisify(ctx.child_process.execFile)("install_name_tool", [ + "-add_rpath", + "@executable_path", + assetCompilerPath, + ]); +} diff --git a/src/gmrt/spawn.ts b/src/gmrt/spawn.ts new file mode 100644 index 0000000..79bf4e3 --- /dev/null +++ b/src/gmrt/spawn.ts @@ -0,0 +1,87 @@ +/** + * Copyright 2026, Opera Norway AS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Context } from "~/context"; +import { KnownError } from "~/error"; +import type { Log } from "~/log"; + +export async function spawnGmrt( + ctx: Context, + log: Log, + { gmrtPath, args }: { gmrtPath: string; args: string[] }, +) { + return new Promise((resolve, reject) => { + const child = ctx.child_process.spawn(gmrtPath, args, { + stdio: ["inherit", "pipe", "pipe"], + env: + ctx.process.platform === "darwin" + ? { ...ctx.process.env, COMPlus_ZapDisable: "1" } + : undefined, + }); + + child.stdout.on("data", (data: Buffer) => { + for (const line of data.toString().split("\n")) { + if (line) { + log.message(line); + } + } + }); + + // Stderr is assumed to only contain a JSON object and nothing else, + // so we collect the output and parse it on close. + const stderrChunks: Buffer[] = []; + child.stderr.on("data", (data: Buffer) => { + stderrChunks.push(Buffer.from(data)); + for (const line of data.toString().split("\n")) { + if (line) { + // FIXME improve this + log.message("stderr: " + line); + } + } + }); + + //if (onSignal) { + // ctx.process.on("SIGINT", onSignal); + // ctx.process.on("SIGTERM", onSignal); + //} + + child.on("error", (err) => { + //if (onSignal) { + // ctx.process.removeListener("SIGINT", onSignal); + // ctx.process.removeListener("SIGTERM", onSignal); + //} + reject(err); + }); + + child.on("close", (code) => { + //if (onSignal) { + // ctx.process.removeListener("SIGINT", onSignal); + // ctx.process.removeListener("SIGTERM", onSignal); + //} + + const stderrOutput = Buffer.concat(stderrChunks).toString().trim(); + if (!stderrOutput && (code === 0 || code === null)) { + resolve(); + } else { + reject( + new KnownError( + stderrOutput || `gmrt exited with code ${String(code)}`, + ), + ); + } + }); + }); +} diff --git a/src/igor/index.ts b/src/igor/index.ts index 10489a6..99dd4dc 100644 --- a/src/igor/index.ts +++ b/src/igor/index.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -export * from "./target"; export * from "./spawn"; export * from "./download"; export * from "./install-runtime"; diff --git a/src/igor/install-runtime.ts b/src/igor/install-runtime.ts index a31ab01..e48544a 100644 --- a/src/igor/install-runtime.ts +++ b/src/igor/install-runtime.ts @@ -19,17 +19,16 @@ import type { Cache } from "~/cache"; import { exists, type Context } from "~/context"; import type { Log } from "~/log"; import { KnownError } from "~/error"; -import type { Target } from "./target"; -import { getInstalledRuntimeModules } from "./target"; +import { TargetSchema, type Target } from "~/target"; import { installRuntime, listRuntimes } from "./spawn"; import { z } from "zod"; import { gms2VersionSchema, gms2VersionSatisfies, gms2VersionCompare, - type Gms2Version, + type Gms2VersionRange, gms2VersionToString, - type Gms2VersionComplete, + type Gms2Version, } from "~/toolchain"; const receiptSchema = z.record(z.string(), z.unknown()); @@ -107,7 +106,9 @@ async function chmodRecursive(ctx: Context, dir: string) { } } -function parseRuntimeVersionFromDirName(name: string): Gms2Version | undefined { +function parseRuntimeVersionFromDirName( + name: string, +): Gms2VersionRange | undefined { const versionStr = name.replace(/^runtime-/, ""); const result = gms2VersionSchema.safeParse(versionStr); return result.success ? result.data : undefined; @@ -116,13 +117,14 @@ function parseRuntimeVersionFromDirName(name: string): Gms2Version | undefined { async function findRuntimeLocation( ctx: Context, runtimeDir: string, - version?: Gms2Version, + version?: Gms2VersionRange, ): Promise { const entries = await ctx.fs.readdir(runtimeDir); const candidates = entries .flatMap((name) => { // Ignore directories that we fail to parse const dirVersion = parseRuntimeVersionFromDirName(name); + // TODO: log warning! if (dirVersion === undefined) { return []; } @@ -157,7 +159,7 @@ export async function installRuntimeIfNeeded( igorPath: string; cache: Cache; target: Target; - version?: Gms2Version; + version?: Gms2VersionRange; }, ): Promise { const runtimeDir = await cache.getSubDirPath(ctx, "runtimes-gms2", { @@ -178,7 +180,7 @@ export async function installRuntimeIfNeeded( // Looks like we need to actually download the runtime! // let us start by ensuring the version (if provided) actually exists in Igor's RSS feed - let completeVersion: Gms2VersionComplete | undefined; + let completeVersion: Gms2Version | undefined; if (version) { const allVersions = await listRuntimes(ctx, { igorPath }); const suitableVersions = allVersions @@ -260,3 +262,31 @@ export async function installRuntimeIfNeeded( await installationFixup(ctx, runtimeLocation); return runtimeLocation; } + +/** + * Modules are more or less just the targets + the base module for your host platform + */ +const ModuleSchema = z.union([ + TargetSchema, + z.literal("base"), + z + .string() + .regex(/^base-module-.+-.+$/) + .transform((s) => s as `base-module-${string}-${string}`), +]); + +export type Module = z.infer; + +export async function getInstalledRuntimeModules( + ctx: Context, + runtimeLocation: string, +): Promise { + const receiptPath = ctx.path.join(runtimeLocation, "receipt.json"); + const content = await ctx.fs.readFile(receiptPath, "utf-8"); + const receipt = z.record(z.string(), z.unknown()).parse(JSON.parse(content)); + + return Object.keys(receipt) + .map((key) => ModuleSchema.safeParse(key)) + .filter((result) => result.success) + .map((result) => result.data); +} diff --git a/src/igor/spawn.ts b/src/igor/spawn.ts index adec633..25d3cf5 100644 --- a/src/igor/spawn.ts +++ b/src/igor/spawn.ts @@ -18,10 +18,11 @@ import path from "path"; import { z } from "zod"; import type { Context } from "~/context"; import type { Log } from "~/log"; -import { type Module, type Target } from "./target"; +import { type Target } from "~/target"; import { getProjectName, type ProjectPath } from "~/project"; -import type { Gms2VersionComplete } from "~/toolchain"; +import type { Gms2Version } from "~/toolchain"; import { KnownError } from "~/error"; +import type { Module } from "./install-runtime"; const RunnerErrorSchema = z.object({ source: z.literal("Runner"), @@ -206,10 +207,10 @@ export function fetchLicense( export async function listRuntimes( ctx: Context, { igorPath }: { igorPath: string }, -): Promise { +): Promise { const output = await execIgor(ctx, igorPath, ["Runtime", "List"]); - const versions: Gms2VersionComplete[] = []; + const versions: Gms2Version[] = []; for (const match of output.matchAll( /Version\s+(\d+)\.(\d+)\.(\d+)\.(\d+)/g, )) { @@ -238,7 +239,7 @@ export function installRuntime( igorPath: string; runtimeDir: string; licenseFile: string; - version?: Gms2VersionComplete; + version?: Gms2Version; }, ): Promise { return spawnIgor(ctx, log, { diff --git a/src/npm.ts b/src/npm.ts index e17b756..88ce6bd 100644 --- a/src/npm.ts +++ b/src/npm.ts @@ -124,7 +124,15 @@ export async function npmGetLatestVersion( registry, ]); const { stdout } = await execFile(command, args); - return stdout.trim() || undefined; + const trimmed = stdout.trim(); + if (!trimmed) { + return undefined; + } + // When a version range matches multiple packages, npm prints one line per + // match: `@scope/pkg@x.y.z 'x.y.z'`. Extract the last (highest) version. + const lastLine = trimmed.split("\n").at(-1)!; + const quoted = /'([^']+)'/.exec(lastLine); + return quoted ? quoted[1] : lastLine; } catch { return undefined; } diff --git a/src/restore-prefabs.ts b/src/restore-prefabs.ts index c514e3f..70af8e3 100644 --- a/src/restore-prefabs.ts +++ b/src/restore-prefabs.ts @@ -28,14 +28,14 @@ export async function restorePrefabs( projectToolPath, projectPath, packageToolPath, - gmpmPath, + gmpmDllPath, verbose, }: { projectToolPath: string; projectPath: ProjectPath; packageToolPath: string; verbose: boolean; - gmpmPath: string; + gmpmDllPath: string; }, ): Promise { const prefabsDir = await cache.getSubDirPath(ctx, "prefabs"); @@ -46,7 +46,7 @@ export async function restorePrefabs( "RESTORE", `SOURCE=${projectPath}`, `PACKAGETOOL=${packageToolPath}`, - `GMPM_DLL=${gmpmPath}`, + `GMPM_DLL=${gmpmDllPath}`, `PACKAGETOOLVERBOSE=${verbose ? "TRUE" : "FALSE"}`, `PREFABSFOLDER=${prefabsDir}`, ]; diff --git a/src/igor/target.ts b/src/target.ts similarity index 56% rename from src/igor/target.ts rename to src/target.ts index 2e81b12..aaec838 100644 --- a/src/igor/target.ts +++ b/src/target.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import type { Context } from "~/context"; import { z } from "zod"; // From Zeus/Igor/Modules.cs and Zeus/Igor/Targets.cs in GameMaker repo @@ -46,6 +45,22 @@ export const TargetSchema = z export type Target = z.infer; +/** + * Currently, GMRT only includes a subset of the targets that's supported by the GMS2 toolchain + */ +const TARGETS_GMRT = [ + "mac", + "windows", + "linux", + "operagx", +] as const satisfies readonly Target[]; + +export type GmrtTarget = (typeof TARGETS_GMRT)[number]; + +export function supportedInGmrt(target: Target): target is GmrtTarget { + return target in TARGETS_GMRT; +} + export function targetForPlatform(platform: NodeJS.Platform): Target { switch (platform) { case "win32": @@ -58,44 +73,3 @@ export function targetForPlatform(platform: NodeJS.Platform): Target { throw new Error(`No default target for platform: ${platform}`); } } - -/** - * Modules are more or less just the targets + the base module for your host platform - */ -const ModuleSchema = z.union([ - TargetSchema, - z.literal("base"), - z - .string() - .regex(/^base-module-.+-.+$/) - .transform((s) => s as `base-module-${string}-${string}`), -]); - -export type Module = z.infer; - -export async function getInstalledRuntimeModules( - ctx: Context, - runtimeLocation: string, -): Promise { - const receiptPath = ctx.path.join(runtimeLocation, "receipt.json"); - const content = await ctx.fs.readFile(receiptPath, "utf-8"); - const receipt = z.record(z.string(), z.unknown()).parse(JSON.parse(content)); - - return Object.keys(receipt) - .map((key) => ModuleSchema.safeParse(key)) - .filter((result) => result.success) - .map((result) => result.data); -} - -export function packageExtension(target: Target): string | undefined { - switch (target) { - case "windows": - case "linux": - case "mac": - case "operagx": - return ".zip"; - default: - // FIXME: implement for all platforms - return; - } -} diff --git a/src/toolchain.ts b/src/toolchain.ts index a2f6ece..0ac097b 100644 --- a/src/toolchain.ts +++ b/src/toolchain.ts @@ -15,6 +15,7 @@ */ import { z } from "zod"; +import { Range, SemVer } from "semver"; const toolchainTypeSchema = z .string() @@ -26,7 +27,7 @@ export const gms2VersionSchema = z .string() .regex( /^\d+(\.\d+){0,3}$/, - "Expected version in format X.Y.Z.Z (fewer digits allowed too)", + "Expected version in format A.B.C.D (fewer digits allowed too)", ) .transform((s) => { const parts = s.split(".").map(Number); @@ -38,16 +39,13 @@ export const gms2VersionSchema = z ]; }); -const gmrtVersionSchema = z +export const gmrtVersionSchema = z .string() .regex( - /^\d+(\.\d+)?$/, - "Expected version in format X.Y (allowed to only have one digit too)", + /^\d+(\.\d+){0,2}$/, + "Expected version in format X.Y.Z (fewer digits allowed too)", ) - .transform((s) => { - const parts = s.split(".").map(Number); - return [parts[0], parts[1]] as [number, number | undefined]; - }); + .transform((s) => new Range(s)); export function parseToolchainVersion(s: string): ToolchainVersion { const [rawType, rawVersion] = s.split("@", 2); @@ -83,8 +81,8 @@ export function parseToolchainVersion(s: string): ToolchainVersion { * so a prefix of [2024] matches any 2024.x.x.x version. */ export function gms2VersionSatisfies( - version: Gms2Version, - prefix: Gms2Version, + version: Gms2VersionRange, + prefix: Gms2VersionRange, ): boolean { for (let i = 0; i < 4; i++) { const p = prefix[i]; @@ -102,7 +100,10 @@ export function gms2VersionSatisfies( * Compare two GMS2 versions for sorting. * Returns a positive number if `a` is newer than `b`, negative if older, 0 if equal. */ -export function gms2VersionCompare(a: Gms2Version, b: Gms2Version): number { +export function gms2VersionCompare( + a: Gms2VersionRange, + b: Gms2VersionRange, +): number { for (let i = 0; i < 4; i++) { const diff = (a[i] ?? 0) - (b[i] ?? 0); if (diff !== 0) { @@ -112,24 +113,30 @@ export function gms2VersionCompare(a: Gms2Version, b: Gms2Version): number { return 0; } -export function gms2VersionToString(version: Gms2Version): string { +export function gms2VersionToString(version: Gms2VersionRange): string { return version.map((v) => v?.toString() ?? "*").join("."); } -export type GmrtVersion = z.infer; +export function gmrtVersionToString(version: GmrtVersionRange): string { + return version.raw; +} + +export type GmrtVersionRange = z.infer; + +export type GmrtVersion = SemVer; -export type Gms2Version = z.infer; +export type Gms2VersionRange = z.infer; -export type Gms2VersionComplete = [number, number, number, number]; +export type Gms2Version = [number, number, number, number]; export type ToolchainType = z.infer; export type ToolchainVersion = | { type: "GMS2"; - version?: Gms2Version; + version?: Gms2VersionRange; } | { type: "GMRT"; - version?: GmrtVersion; + version?: GmrtVersionRange; };