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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ yarn game-ci --help # Run CLI locally via ts-node
- `.github/workflows/test.yml` — unit tests
- `.github/workflows/ci.yml` — caller workflow for test + integrity
- `.github/workflows/orchestrator-integrity.yml` — integration tests (AWS via LocalStack, K8s via k3d)
- `.github/workflows/release-cli.yml` — CLI binary releases
- `.github/workflows/release-cli.yml` — CLI binary releases (npm publish is not yet active — no `NPM_TOKEN` secret configured)

## Conventions

Expand Down
12 changes: 12 additions & 0 deletions src/cli/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,18 @@ const buildCommand: CommandModule<object, BuildArguments> = {
description: 'Path to a directory containing SSH public keys to forward to the container',
default: '',
})
.option('engine', {
type: 'string',
description: 'Game engine name (unity, godot, unreal, etc.)',
default: 'unity',
})
.option('engine-plugin', {
alias: 'enginePlugin',
type: 'string',
description:
'Engine plugin source: module:<npm-pkg>, cli:<executable>, docker:<image>, or an npm package name',
default: '',
})
.option('cache-unity-installation-on-mac', {
alias: 'cacheUnityInstallationOnMac',
type: 'boolean',
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export { Action } from './model/action';
export { Platform } from './model/platform';
export { StringKeyValuePair, DockerParameters } from './model/shared-types';

// Engine plugin system
export type { EnginePlugin } from './model/engine';
export { getEngine, setEngine, initEngine, UnityPlugin } from './model/engine';
export { loadEngineFromModule, loadEngineFromCli, loadEngineFromDocker } from './model/engine';

// Re-export services for direct access
export { BuildReliabilityService } from './model/orchestrator/services/reliability';
export { TestWorkflowService } from './model/orchestrator/services/test-workflow';
Expand Down
11 changes: 11 additions & 0 deletions src/model/build-parameters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@
import * as core from '@actions/core';
import { Cli } from './cli/cli';
import Input from './input';
import { initEngine } from './engine';

class BuildParameters {
// ── engine ─────────────────────────────────────────────────────────
engine!: string;
enginePlugin!: string;

// ── identity ────────────────────────────────────────────────────────
editorVersion!: string;
customImage!: string;
Expand Down Expand Up @@ -220,6 +225,12 @@ class BuildParameters {
p.dockerMemoryLimit = '';
p.dockerIsolationMode = '';
p.gitPrivateToken = Input.getInput('gitPrivateToken') || process.env.GIT_PRIVATE_TOKEN || '';
p.engine = Input.getInput('engine') || 'unity';
p.enginePlugin = Input.getInput('enginePlugin') || '';

// Initialize the engine plugin (Unity is built-in, others require enginePlugin source)
initEngine(p.engine, p.enginePlugin || undefined);

p.orchestratorRepoName =
Input.getInput('orchestratorRepoName') || process.env.GITHUB_REPOSITORY || 'game-ci/unity-builder';
p.orchestratorBranch = Input.getInput('orchestratorBranch') || 'main';
Expand Down
70 changes: 70 additions & 0 deletions src/model/engine/cli-engine-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { spawnSync } from 'child_process';
import { EnginePlugin } from './engine-plugin';
import OrchestratorLogger from '../orchestrator/services/core/orchestrator-logger';

/**
* Load an EnginePlugin from an external CLI executable.
*
* Protocol: the executable receives `{"command":"get-engine-config"}` on stdin
* and must print a JSON response on stdout:
*
* { "name": "godot", "cacheFolders": [".godot/imported"], "preStopCommand": "..." }
*
* The executable can be any language (Go, Python, Rust, shell, etc.).
*/
export function loadEngineFromCli(executablePath: string): EnginePlugin {
const request = JSON.stringify({ command: 'get-engine-config' });

const result = spawnSync(executablePath, ['get-engine-config'], {
input: request,
encoding: 'utf-8',
timeout: 30_000,
shell: process.platform === 'win32',
});
Comment on lines +16 to +23
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Protocol mismatch: loader sends both stdin JSON and CLI args.

The contract says plugin receives {"command":"get-engine-config"} on stdin, but Line 18 also forces ['get-engine-config'] argv. Plugins that validate argv strictly can fail despite correct stdin handling.

Proposed fix
-  const result = spawnSync(executablePath, ['get-engine-config'], {
+  const result = spawnSync(executablePath, [], {
     input: request,
     encoding: 'utf-8',
     timeout: 30_000,
     shell: process.platform === 'win32',
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const request = JSON.stringify({ command: 'get-engine-config' });
const result = spawnSync(executablePath, ['get-engine-config'], {
input: request,
encoding: 'utf-8',
timeout: 30_000,
shell: process.platform === 'win32',
});
const request = JSON.stringify({ command: 'get-engine-config' });
const result = spawnSync(executablePath, [], {
input: request,
encoding: 'utf-8',
timeout: 30_000,
shell: process.platform === 'win32',
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/model/engine/cli-engine-loader.ts` around lines 16 - 23, The loader is
sending the command twice (JSON on stdin and also as CLI arg) which violates the
plugin protocol and can break plugins that validate argv; update the spawnSync
invocation so it does not pass ['get-engine-config'] as argv—only send the JSON
request via stdin (keep the request variable and executablePath usage) and
preserve the existing options (encoding, timeout, shell) so the plugin receives
the command exclusively through stdin.


if (result.error) {
throw new Error(`Failed to spawn engine plugin executable '${executablePath}': ${result.error.message}`);
}

if (result.status !== 0) {
const stderr = result.stderr?.trim() || '';
throw new Error(
`Engine plugin executable '${executablePath}' exited with code ${result.status}${stderr ? ': ' + stderr : ''}`,
);
}

const stdout = result.stdout?.trim() || '';

// Find the last JSON object in stdout (skip any non-JSON preamble)
const lines = stdout.split('\n');
let config: any;
for (let i = lines.length - 1; i >= 0; i--) {
try {
const parsed = JSON.parse(lines[i].trim());
if (typeof parsed === 'object' && parsed !== null && parsed.name) {
config = parsed;
break;
}
} catch {
// Not JSON, skip
}
}
Comment on lines +39 to +51
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Line-by-line JSON parsing will reject valid multi-line plugin responses.

Current parsing only attempts JSON.parse per line, so pretty-printed JSON objects are treated as invalid output.

Proposed fix
-  const lines = stdout.split('\n');
-  let config: any;
-  for (let i = lines.length - 1; i >= 0; i--) {
-    try {
-      const parsed = JSON.parse(lines[i].trim());
-      if (typeof parsed === 'object' && parsed !== null && parsed.name) {
-        config = parsed;
-        break;
-      }
-    } catch {
-      // Not JSON, skip
-    }
-  }
+  const lines = stdout.split('\n');
+  let config: any;
+  for (let i = lines.length - 1; i >= 0; i--) {
+    const candidate = lines.slice(i).join('\n').trim();
+    if (!candidate) continue;
+    try {
+      const parsed = JSON.parse(candidate);
+      if (typeof parsed === 'object' && parsed !== null && parsed.name) {
+        config = parsed;
+        break;
+      }
+    } catch {
+      // Not valid JSON from this boundary, continue scanning upward
+    }
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const lines = stdout.split('\n');
let config: any;
for (let i = lines.length - 1; i >= 0; i--) {
try {
const parsed = JSON.parse(lines[i].trim());
if (typeof parsed === 'object' && parsed !== null && parsed.name) {
config = parsed;
break;
}
} catch {
// Not JSON, skip
}
}
const lines = stdout.split('\n');
let config: any;
for (let i = lines.length - 1; i >= 0; i--) {
const candidate = lines.slice(i).join('\n').trim();
if (!candidate) continue;
try {
const parsed = JSON.parse(candidate);
if (typeof parsed === 'object' && parsed !== null && parsed.name) {
config = parsed;
break;
}
} catch {
// Not valid JSON from this boundary, continue scanning upward
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/model/engine/cli-engine-loader.ts` around lines 39 - 51, The current loop
splits stdout into lines and calls JSON.parse on each single line (variables:
stdout, lines, config), which breaks multi-line/pretty JSON; change the logic to
locate and parse the last valid JSON object in the full stdout by extracting a
contiguous JSON substring (e.g., find the last '{' and corresponding matching
'}' or use a regex to capture the last JSON object) and call JSON.parse once on
that multi-line substring, set config to the parsed object, and keep the
existing guard that ensures parsed is an object with a name before breaking.


if (!config) {
throw new Error(`Engine plugin executable '${executablePath}' did not return valid JSON config. Output: ${stdout}`);
}

if (!config.name || !Array.isArray(config.cacheFolders)) {
throw new Error(
`Engine plugin config from '${executablePath}' missing required fields (name, cacheFolders). Got: ${JSON.stringify(config)}`,
);
}

OrchestratorLogger.log(`Loaded engine plugin '${config.name}' from CLI: ${executablePath}`);

return {
name: config.name,
cacheFolders: config.cacheFolders,
preStopCommand: config.preStopCommand || undefined,
};
}
67 changes: 67 additions & 0 deletions src/model/engine/docker-engine-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { spawnSync } from 'child_process';
import { EnginePlugin } from './engine-plugin';
import OrchestratorLogger from '../orchestrator/services/core/orchestrator-logger';

/**
* Load an EnginePlugin from a Docker image.
*
* The container is run with `docker run --rm <image> get-engine-config`
* and must print a JSON config on stdout:
*
* { "name": "godot", "cacheFolders": [".godot/imported"], "preStopCommand": "..." }
*
* This allows community engine plugins to be distributed as Docker images.
*/
export function loadEngineFromDocker(image: string): EnginePlugin {
const result = spawnSync('docker', ['run', '--rm', image, 'get-engine-config'], {
encoding: 'utf-8',
timeout: 120_000,
shell: process.platform === 'win32',
});

if (result.error) {
throw new Error(`Failed to run engine plugin Docker image '${image}': ${result.error.message}`);
}

if (result.status !== 0) {
const stderr = result.stderr?.trim() || '';
throw new Error(
`Engine plugin Docker image '${image}' exited with code ${result.status}${stderr ? ': ' + stderr : ''}`,
);
}

const stdout = result.stdout?.trim() || '';

// Find the last JSON object in stdout
const lines = stdout.split('\n');
let config: any;
for (let i = lines.length - 1; i >= 0; i--) {
try {
const parsed = JSON.parse(lines[i].trim());
if (typeof parsed === 'object' && parsed !== null && parsed.name) {
config = parsed;
break;
}
} catch {
// Not JSON, skip
}
}
Comment on lines +35 to +48
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Line-oriented parsing rejects valid Docker plugin output.

At Lines 35-48, the loader only JSON.parses one line at a time. A plugin that prints a valid multi-line JSON object, such as JSON.stringify(config, null, 2), will fall through to the "did not return valid JSON config" error even though stdout is well-formed JSON. Either require the last non-empty stdout line to be compact JSON or extract the last balanced JSON object from the full stdout buffer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/model/engine/docker-engine-loader.ts` around lines 35 - 48, The current
line-by-line JSON.parse loop using stdout, lines, parsed and config fails for
pretty-printed multi-line JSON; update docker-engine-loader logic to first try
parsing the entire stdout.trim() as JSON and if that fails extract the last
balanced JSON object from the full stdout buffer (scan from the end for matching
'{' and '}' or '[' and ']' pairs) and JSON.parse that substring, assigning it to
config; keep the existing fallback that rejects non-objects or missing name
after parsing.


if (!config) {
throw new Error(`Engine plugin Docker image '${image}' did not return valid JSON config. Output: ${stdout}`);
}

if (!config.name || !Array.isArray(config.cacheFolders)) {
throw new Error(
`Engine plugin config from Docker image '${image}' missing required fields (name, cacheFolders). Got: ${JSON.stringify(config)}`,
);
}

OrchestratorLogger.log(`Loaded engine plugin '${config.name}' from Docker: ${image}`);

return {
name: config.name,
cacheFolders: config.cacheFolders,
preStopCommand: config.preStopCommand || undefined,
};
}
25 changes: 25 additions & 0 deletions src/model/engine/engine-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Engine plugin interface — allows the orchestrator to support
* different game engines (Unity, Godot, Unreal, etc.) without
* hardcoding engine-specific behavior.
*
* Each engine provides:
* - Cache folder names (e.g. 'Library' for Unity, '.godot/imported' for Godot)
* - Optional container preStop command (e.g. license cleanup for Unity)
*/
export interface EnginePlugin {
/** Engine identifier: 'unity', 'godot', 'unreal', etc. */
readonly name: string;

/**
* Folders to cache between builds, relative to projectPath.
* Examples: ['Library'] for Unity, ['.godot/imported', '.godot/shader_cache'] for Godot.
*/
readonly cacheFolders: string[];

/**
* Shell command for container preStop hook (e.g. license cleanup).
* Undefined means no preStop hook.
*/
readonly preStopCommand?: string;
}
68 changes: 68 additions & 0 deletions src/model/engine/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
export { EnginePlugin } from './engine-plugin';
export { UnityPlugin } from './unity-plugin';
export { loadEngineFromModule } from './module-engine-loader';
export { loadEngineFromCli } from './cli-engine-loader';
export { loadEngineFromDocker } from './docker-engine-loader';

import { EnginePlugin } from './engine-plugin';
import { UnityPlugin } from './unity-plugin';
import { loadEngineFromModule } from './module-engine-loader';
import { loadEngineFromCli } from './cli-engine-loader';
import { loadEngineFromDocker } from './docker-engine-loader';
import OrchestratorLogger from '../orchestrator/services/core/orchestrator-logger';

/** Current engine plugin — defaults to Unity. */
let currentEngine: EnginePlugin = UnityPlugin;

/** Get the current engine plugin. */
export function getEngine(): EnginePlugin {
return currentEngine;
}

/** Replace the engine plugin (e.g. for Godot, Unreal, or testing). */
export function setEngine(engine: EnginePlugin): void {
currentEngine = engine;
}

/**
* Initialize the engine from a plugin source string.
*
* Source formats:
* - `docker:<image>` — load from a Docker image
* - `cli:<path>` — load from an external CLI executable
* - `module:<id>` — load from an npm package or local JS/TS file
* - `<id>` (no prefix) — treated as a module (npm package or local path)
*
* When engine is 'unity' (or unset) with no enginePlugin, the built-in
* UnityPlugin is used — no loading needed.
*/
export function initEngine(engine: string, enginePlugin?: string): void {
if (!enginePlugin) {
if (engine === 'unity') {
currentEngine = UnityPlugin;
return;
}
throw new Error(
`Engine '${engine}' requires an enginePlugin source. ` +
`Use one of: module:<npm-package>, cli:<executable-path>, docker:<image>`,
);
}

OrchestratorLogger.log(`Loading engine plugin for '${engine}' from: ${enginePlugin}`);

let loaded: EnginePlugin;

if (enginePlugin.startsWith('docker:')) {
loaded = loadEngineFromDocker(enginePlugin.slice('docker:'.length));
} else if (enginePlugin.startsWith('cli:')) {
loaded = loadEngineFromCli(enginePlugin.slice('cli:'.length));
} else if (enginePlugin.startsWith('module:')) {
loaded = loadEngineFromModule(enginePlugin.slice('module:'.length));
} else {
// No prefix — default to module loader (npm package or local path)
loaded = loadEngineFromModule(enginePlugin);
}

currentEngine = loaded;
OrchestratorLogger.log(`Engine plugin '${loaded.name}' initialized successfully`);
Comment on lines +39 to +67
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate the loaded plugin before mutating global state.

initEngine() assigns whatever the loader returned straight into currentEngine, and it never checks that loaded.name matches the requested engine. Downstream code uses cacheFolders in path.join(...) and shell generation, so a malformed plugin survives init and then fails much later in src/model/orchestrator/options/orchestrator-folders.ts at Lines 52-84 or src/model/orchestrator/workflows/build-automation-workflow.ts at Lines 249-267. Please validate here that name is a non-empty string, cacheFolders is a string array of relative paths without .., preStopCommand is either undefined or a string, and loaded.name === engine unless you intentionally normalize one source of truth.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/model/engine/index.ts` around lines 39 - 67, Validate the loaded plugin
before assigning currentEngine in initEngine: after the loader call that sets
`loaded` (from loadEngineFromDocker/loadEngineFromCli/loadEngineFromModule)
verify `loaded.name` is a non-empty string and equals the requested `engine` (or
document/normalize if you accept different canonical names), ensure
`loaded.cacheFolders` exists and is an array of strings where each entry is a
relative path that does not contain '..', and ensure `loaded.preStopCommand` is
either undefined or a string; if any check fails, throw a descriptive Error and
do not set `currentEngine` (log the failure via OrchestratorLogger.log/error) so
malformed plugins cannot be assigned and will fail early.

}
47 changes: 47 additions & 0 deletions src/model/engine/module-engine-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { EnginePlugin } from './engine-plugin';
import OrchestratorLogger from '../orchestrator/services/core/orchestrator-logger';

/**
* Load an EnginePlugin from a TypeScript/JavaScript module.
*
* Accepts:
* - npm package name: `@game-ci/godot-engine`
* - Local file path: `./my-engine-plugin.js` or `/absolute/path/plugin.js`
*
Comment on lines +5 to +10
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

git ls-files src/model/engine/ | head -20

Repository: game-ci/orchestrator

Length of output: 276


🏁 Script executed:

cat -n src/model/engine/module-engine-loader.ts

Repository: game-ci/orchestrator

Length of output: 1992


🏁 Script executed:

# Check if there's a tsconfig or jest config that might enable TS loading
fd -t f "tsconfig\|jest\|loader" . --max-depth 3

Repository: game-ci/orchestrator

Length of output: 46


🏁 Script executed:

# Look for existing require() patterns and how paths are handled elsewhere
rg "require\(" src/model/ -B 2 -A 2 | head -40

Repository: game-ci/orchestrator

Length of output: 3982


🏁 Script executed:

# Check for any path resolution utilities or similar patterns
rg "process\.cwd\|path\.resolve" src/ | head -20

Repository: game-ci/orchestrator

Length of output: 46


🏁 Script executed:

rg "loadEngineFromModule" src/ --type ts --type js

Repository: game-ci/orchestrator

Length of output: 636


🏁 Script executed:

# Check the test file for this module
fd "module-engine-loader" src/ -t f

Repository: game-ci/orchestrator

Length of output: 104


🏁 Script executed:

# Look for build configuration and runtime context
cat package.json | head -60

Repository: game-ci/orchestrator

Length of output: 1737


🏁 Script executed:

# Check if TypeScript is compiled as part of build
rg "tsc\|esbuild\|webpack" . --type json --type js --type ts | head -20

Repository: game-ci/orchestrator

Length of output: 46


🏁 Script executed:

cat -n src/model/engine/index.ts

Repository: game-ci/orchestrator

Length of output: 3095


🏁 Script executed:

# Check if there are any tests related to engine loading
fd -t f "\.test\|\.spec" src/ | xargs grep -l "loadEngineFromModule\|module-engine-loader" 2>/dev/null

Repository: game-ci/orchestrator

Length of output: 48


🏁 Script executed:

# Check if there's any dynamic require or loader configuration
cat tsconfig.json | head -40

Repository: game-ci/orchestrator

Length of output: 631


🏁 Script executed:

# Check if there's any documentation about the expected behavior
fd -t f "README\|CONTRIBUTING\|docs" . --max-depth 2 | head -10

Repository: game-ci/orchestrator

Length of output: 46


🏁 Script executed:

# Look for any examples or tests that show how enginePlugin paths are used
rg "module:\|enginePlugin" src/ --type ts -B 2 -A 2 | head -40

Repository: game-ci/orchestrator

Length of output: 46


🏁 Script executed:

# Check if there are any built-in plugins or examples
fd "plugin" src/ --type ts | head -10

Repository: game-ci/orchestrator

Length of output: 233


🏁 Script executed:

# Look for any example plugins or documentation about the API
rg "EnginePlugin\|enginePlugin" src/ -A 3 | head -60

Repository: game-ci/orchestrator

Length of output: 46


🏁 Script executed:

# Check if the documented TypeScript support makes sense given the compilation step
# Look at what actually happens when require() is called
python3 << 'PYTHON_EOF'
# Simulate require() resolution in Node.js CommonJS
# When require('./my-engine-plugin.js') is called from dist/model/engine/module-engine-loader.js
# Node resolves it relative to dist/model/engine/

import os
import json

# The module file path after compilation
module_file = "dist/model/engine/module-engine-loader.js"
module_dir = os.path.dirname(module_file)

# Test path: './my-engine-plugin.js' as documented
test_path = "./my-engine-plugin.js"

# How Node.js resolves this
resolved = os.path.normpath(os.path.join(module_dir, test_path))
print(f"Module directory: {module_dir}")
print(f"Documented path: {test_path}")
print(f"Node.js would resolve to: {resolved}")
print(f"Expected by user (cwd-based): ./my-engine-plugin.js")
print(f"Mismatch: {resolved != './my-engine-plugin.js'}")

# For absolute paths, require() works fine
abs_path = "/absolute/path/plugin.js"
print(f"\nAbsolute path {abs_path} resolves to itself: ✓")

# For npm packages
npm_pkg = "@game-ci/godot-engine"
print(f"npm package {npm_pkg} resolves via node_modules: ✓")
PYTHON_EOF

Repository: game-ci/orchestrator

Length of output: 394


🏁 Script executed:

# Check if .ts files are mentioned in any actual implementation or tests
rg "\.ts" src/model/engine/ --type ts

Repository: game-ci/orchestrator

Length of output: 46


Relative file paths and TypeScript support are not actually implemented despite being documented.

The docstring (lines 5-10) advertises support for local file paths like ./my-engine-plugin.js and TypeScript files. However:

  1. Relative paths fail: require(moduleId) on line 22 resolves paths relative to the compiled module file location (dist/model/engine/), not the caller's working directory. A user passing ./my-engine-plugin.js will fail unless the file exists at dist/model/engine/my-engine-plugin.js.

  2. TypeScript files cannot load: Raw .ts files in the documented examples require a TypeScript loader, which isn't configured for runtime. The built code runs compiled JavaScript only.

Absolute paths and npm packages work correctly via require(). To support the documented API, detect file-like inputs and resolve relative paths from process.cwd(). Narrowing the docs to .js only or adding explicit TypeScript loader support would be required for full feature parity.

🛠️ Fix for relative file path resolution
+import path from 'node:path';
 import { EnginePlugin } from './engine-plugin';
 import OrchestratorLogger from '../orchestrator/services/core/orchestrator-logger';
 
 export function loadEngineFromModule(moduleId: string): EnginePlugin {
   let loaded: any;
   try {
-    loaded = require(moduleId);
+    const request =
+      moduleId.startsWith('.') || path.isAbsolute(moduleId)
+        ? path.resolve(process.cwd(), moduleId)
+        : moduleId;
+    loaded = require(request);
   } catch (error: any) {
     throw new Error(`Failed to load engine plugin module '${moduleId}': ${error.message}`);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/model/engine/module-engine-loader.ts` around lines 5 - 10, The docs claim
relative paths and TypeScript files are supported but the loader uses
require(moduleId) (around the moduleId resolution in module-engine-loader.ts)
which resolves relative paths from the compiled file location and cannot load
raw .ts files; update the loader to detect file-like inputs (moduleId values
beginning with ./, ../, / or ending with .js/.ts), and for those resolve the
path against process.cwd() (e.g. path.resolve(process.cwd(), moduleId)) before
calling require; if you cannot add a runtime TypeScript loader, explicitly
reject or error on .ts inputs with a clear message (or update docs to say only
.js is supported) so callers know to provide compiled .js. Ensure changes touch
the code paths that build moduleId and call require(moduleId) and preserve
existing behavior for npm package names.

* The module must export an EnginePlugin-compatible object as default or named `plugin`:
*
* // ES module
* export default { name: 'godot', cacheFolders: ['.godot/imported'] };
*
* // CommonJS
* module.exports = { name: 'godot', cacheFolders: ['.godot/imported'] };
*/
export function loadEngineFromModule(moduleId: string): EnginePlugin {
let loaded: any;
try {
loaded = require(moduleId);
} catch (error: any) {
throw new Error(`Failed to load engine plugin module '${moduleId}': ${error.message}`);
}

// Support default export, named `plugin` export, or the module itself
const config = loaded.default || loaded.plugin || loaded;

if (!config || typeof config !== 'object') {
throw new Error(`Engine plugin module '${moduleId}' did not export a valid object`);
}

if (!config.name || !Array.isArray(config.cacheFolders)) {
throw new Error(
`Engine plugin from module '${moduleId}' missing required fields (name, cacheFolders). Got: ${JSON.stringify(config)}`,
);
}

OrchestratorLogger.log(`Loaded engine plugin '${config.name}' from module: ${moduleId}`);

return {
name: config.name,
cacheFolders: config.cacheFolders,
preStopCommand: config.preStopCommand || undefined,
};
}
17 changes: 17 additions & 0 deletions src/model/engine/unity-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { EnginePlugin } from './engine-plugin';

/**
* Unity engine plugin — built-in.
*
* This is a plugin like any other, it just ships with the orchestrator.
* unity-builder owns everything else (image resolution, versioning, build
* scripts, licensing activation flow). This plugin only provides the
* minimal config the orchestrator needs to handle caching and container
* lifecycle generically.
*/
export const UnityPlugin: EnginePlugin = {
name: 'unity',
cacheFolders: ['Library'],
preStopCommand:
'cd /data/builder/action/steps && chmod +x /steps/return_license.sh 2>/dev/null || true; /steps/return_license.sh 2>/dev/null || true',
};
Loading
Loading