feat: add official game-ci CLI with build, activate, and orchestrate commands

Introduces a yargs-based CLI entry point (src/cli.ts) distributed as the
`game-ci` command. The CLI reuses existing unity-builder modules — Input,
BuildParameters, Orchestrator, Docker, MacBuilder — so the same build
engine powers both the GitHub Action and the standalone CLI.

Commands: build, activate, orchestrate, cache (list/restore/clear),
status, version.

Closes #812

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
frostebite
2026-03-05 16:50:25 +00:00
parent 9d475434d3
commit 5a42214cda
11 changed files with 1026 additions and 10 deletions

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -3,6 +3,9 @@
"version": "3.0.0",
"description": "Build Unity projects for different platforms.",
"main": "dist/index.js",
"bin": {
"game-ci": "./lib/cli.js"
},
"repository": "git@github.com:game-ci/unity-builder.git",
"author": "Webber <webber@takken.io>",
"license": "MIT",
@@ -12,6 +15,7 @@
"lint": "prettier --check \"src/**/*.{js,ts}\" && eslint src/**/*.ts",
"format": "prettier --write \"src/**/*.{js,ts}\"",
"cli": "yarn ts-node src/index.ts -m cli",
"game-ci": "ts-node src/cli.ts",
"gcp-secrets-tests": "cross-env providerStrategy=aws orchestratorTests=true inputPullCommand=\"gcp-secret-manager\" populateOverride=true pullInputList=UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD yarn test -i -t \"orchestrator\"",
"gcp-secrets-cli": "cross-env orchestratorTests=true USE_IL2CPP=false inputPullCommand=\"gcp-secret-manager\" yarn ts-node src/index.ts -m cli --populateOverride true --pullInputList UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD",
"aws-secrets-cli": "cross-env orchestratorTests=true inputPullCommand=\"aws-secret-manager\" yarn ts-node src/index.ts -m cli --populateOverride true --pullInputList UNITY_EMAIL,UNITY_SERIAL,UNITY_PASSWORD",
@@ -54,7 +58,8 @@
"ts-md5": "^1.3.1",
"unity-changeset": "^3.1.0",
"uuid": "^9.0.0",
"yaml": "^2.2.2"
"yaml": "^2.2.2",
"yargs": "^18.0.0"
},
"devDependencies": {
"@types/base-64": "^1.0.0",
@@ -62,6 +67,7 @@
"@types/node": "^17.0.23",
"@types/semver": "^7.3.9",
"@types/uuid": "^9.0.0",
"@types/yargs": "^17.0.35",
"@typescript-eslint/parser": "4.8.1",
"@vercel/ncc": "^0.36.1",
"cross-env": "^7.0.3",

39
src/cli.ts Normal file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env node
import yargs from 'yargs';
// eslint-disable-next-line import/no-unresolved
import { hideBin } from 'yargs/helpers';
import buildCommand from './cli/commands/build';
import activateCommand from './cli/commands/activate';
import orchestrateCommand from './cli/commands/orchestrate';
import cacheCommand from './cli/commands/cache';
import statusCommand from './cli/commands/status';
import versionCommand from './cli/commands/version';
import * as core from '@actions/core';
const cli = yargs(hideBin(process.argv))
.scriptName('game-ci')
.usage('$0 <command> [options]')
.command(buildCommand)
.command(activateCommand)
.command(orchestrateCommand)
.command(cacheCommand)
.command(statusCommand)
.command(versionCommand)
.demandCommand(1, 'You must specify a command. Run game-ci --help for available commands.')
.strict()
.alias('h', 'help')
.epilogue('For more information, visit https://game.ci')
.wrap(Math.min(120, process.stdout.columns || 80));
async function main() {
try {
await cli.parse();
} catch (error: any) {
if (error.name !== 'YError') {
core.error(`Error: ${error.message}`);
}
}
}
main();

View File

@@ -0,0 +1,83 @@
import type { CommandModule } from 'yargs';
import * as core from '@actions/core';
import { mapCliArgumentsToInput, CliArguments } from '../input-mapper';
interface ActivateArguments extends CliArguments {
unityVersion?: string;
unitySerial?: string;
unityLicensingServer?: string;
}
const activateCommand: CommandModule<object, ActivateArguments> = {
command: 'activate',
describe: 'Activate a Unity license',
builder: (yargs) => {
return yargs
.option('unity-version', {
alias: 'unityVersion',
type: 'string',
description: 'Version of Unity to activate',
default: 'auto',
})
.option('unity-licensing-server', {
alias: 'unityLicensingServer',
type: 'string',
description: 'The Unity licensing server address for floating licenses',
default: '',
})
.env('UNITY')
.example(
'UNITY_SERIAL=XXXX-XXXX-XXXX-XXXX game-ci activate',
'Activate Unity using a serial from environment variable',
)
.example(
'game-ci activate --unity-licensing-server http://license-server:8080',
'Activate Unity using a floating license server',
) as any;
},
handler: async (cliArguments) => {
try {
mapCliArgumentsToInput(cliArguments);
const unitySerial = process.env.UNITY_SERIAL;
const unityLicense = process.env.UNITY_LICENSE;
const licensingServer = cliArguments.unityLicensingServer || process.env.UNITY_LICENSING_SERVER || '';
if (licensingServer) {
core.info(`Activating Unity via licensing server: ${licensingServer}`);
core.info('Floating license activation is handled automatically during builds.');
core.info('No manual activation step is needed when using a licensing server.');
return;
}
if (!unitySerial && !unityLicense) {
throw new Error(
'No Unity license found.\n\n' +
'Provide one of the following:\n' +
' - UNITY_SERIAL environment variable (professional license)\n' +
' - UNITY_LICENSE environment variable (personal license file content)\n' +
' - --unity-licensing-server flag (floating license)\n\n' +
'For more information, visit: https://game.ci/docs/github/activation',
);
}
if (unitySerial) {
const maskedSerial = unitySerial.length > 8 ? `${unitySerial.slice(0, 4)}...${unitySerial.slice(-4)}` : '****';
core.info(`Unity serial detected: ${maskedSerial}`);
core.info('License will be activated automatically when running a build.');
} else if (unityLicense) {
core.info('Unity license file detected from UNITY_LICENSE environment variable.');
core.info('License will be activated automatically when running a build.');
}
core.info('\nActivation verified. You can now run: game-ci build --target-platform <platform>');
} catch (error: any) {
core.setFailed(`Activation failed: ${error.message}`);
throw error;
}
},
};
export default activateCommand;

262
src/cli/commands/build.ts Normal file
View File

@@ -0,0 +1,262 @@
import type { CommandModule } from 'yargs';
import * as core from '@actions/core';
import { BuildParameters, ImageTag, Orchestrator } from '../../model';
import { mapCliArgumentsToInput, CliArguments } from '../input-mapper';
import MacBuilder from '../../model/mac-builder';
import Docker from '../../model/docker';
import Action from '../../model/action';
import PlatformSetup from '../../model/platform-setup';
interface BuildArguments extends CliArguments {
targetPlatform: string;
}
const buildCommand: CommandModule<object, BuildArguments> = {
command: 'build',
describe: 'Build a Unity project',
builder: (yargs) => {
return yargs
.option('target-platform', {
alias: 'targetPlatform',
type: 'string',
description: 'Platform that the build should target',
demandOption: true,
})
.option('unity-version', {
alias: 'unityVersion',
type: 'string',
description: 'Version of Unity to use for building the project. Use "auto" to detect.',
default: 'auto',
})
.option('project-path', {
alias: 'projectPath',
type: 'string',
description: 'Path to the Unity project to be built',
default: '.',
})
.option('build-profile', {
alias: 'buildProfile',
type: 'string',
description: 'Path to the build profile to activate, relative to the project root',
default: '',
})
.option('build-name', {
alias: 'buildName',
type: 'string',
description: 'Name of the build (no file extension)',
default: '',
})
.option('builds-path', {
alias: 'buildsPath',
type: 'string',
description: 'Path where the builds should be stored',
default: 'build',
})
.option('build-method', {
alias: 'buildMethod',
type: 'string',
description: 'Path to a Namespace.Class.StaticMethod to run to perform the build',
default: '',
})
.option('custom-parameters', {
alias: 'customParameters',
type: 'string',
description: 'Custom parameters to configure the build',
default: '',
})
.option('versioning', {
type: 'string',
description: 'The versioning scheme to use when building the project',
default: 'Semantic',
})
.option('version', {
type: 'string',
description: 'The version, when used with the "Custom" versioning scheme',
default: '',
})
.option('custom-image', {
alias: 'customImage',
type: 'string',
description: 'Specific docker image that should be used for building the project',
default: '',
})
.option('manual-exit', {
alias: 'manualExit',
type: 'boolean',
description: 'Suppresses -quit. Exit your build method using EditorApplication.Exit(0) instead.',
default: false,
})
.option('enable-gpu', {
alias: 'enableGpu',
type: 'boolean',
description: 'Launches unity without specifying -nographics',
default: false,
})
.option('android-version-code', {
alias: 'androidVersionCode',
type: 'string',
description: 'The android versionCode',
default: '',
})
.option('android-export-type', {
alias: 'androidExportType',
type: 'string',
description: 'The android export type (androidPackage, androidAppBundle, androidStudioProject)',
default: 'androidPackage',
})
.option('android-keystore-name', {
alias: 'androidKeystoreName',
type: 'string',
description: 'The android keystoreName',
default: '',
})
.option('android-keystore-base64', {
alias: 'androidKeystoreBase64',
type: 'string',
description: 'The base64 contents of the android keystore file',
default: '',
})
.option('android-keystore-pass', {
alias: 'androidKeystorePass',
type: 'string',
description: 'The android keystorePass',
default: '',
})
.option('android-keyalias-name', {
alias: 'androidKeyaliasName',
type: 'string',
description: 'The android keyaliasName',
default: '',
})
.option('android-keyalias-pass', {
alias: 'androidKeyaliasPass',
type: 'string',
description: 'The android keyaliasPass',
default: '',
})
.option('android-target-sdk-version', {
alias: 'androidTargetSdkVersion',
type: 'string',
description: 'The android target API level',
default: '',
})
.option('android-symbol-type', {
alias: 'androidSymbolType',
type: 'string',
description: 'The android symbol type to export (none, public, debugging)',
default: 'none',
})
.option('docker-cpu-limit', {
alias: 'dockerCpuLimit',
type: 'string',
description: 'Number of CPU cores to assign the docker container',
default: '',
})
.option('docker-memory-limit', {
alias: 'dockerMemoryLimit',
type: 'string',
description: 'Amount of memory to assign the docker container (e.g. 512m, 4g)',
default: '',
})
.option('docker-workspace-path', {
alias: 'dockerWorkspacePath',
type: 'string',
description: 'The path to mount the workspace inside the docker container',
default: '/github/workspace',
})
.option('run-as-host-user', {
alias: 'runAsHostUser',
type: 'string',
description: 'Whether to run as a user that matches the host system',
default: 'false',
})
.option('chown-files-to', {
alias: 'chownFilesTo',
type: 'string',
description: 'User and optionally group to give ownership of build artifacts',
default: '',
})
.option('ssh-agent', {
alias: 'sshAgent',
type: 'string',
description: 'SSH Agent path to forward to the container',
default: '',
})
.option('git-private-token', {
alias: 'gitPrivateToken',
type: 'string',
description: 'GitHub private token to pull from GitHub',
default: '',
})
.option('provider-strategy', {
alias: 'providerStrategy',
type: 'string',
description: 'Execution strategy: local, k8s, or aws',
default: 'local',
})
.option('skip-activation', {
alias: 'skipActivation',
type: 'string',
description: 'Skip the activation/deactivation of Unity',
default: 'false',
})
.option('unity-licensing-server', {
alias: 'unityLicensingServer',
type: 'string',
description: 'The Unity licensing server address',
default: '',
})
.example('game-ci build --target-platform StandaloneLinux64', 'Build for Linux using auto-detected Unity version')
.example(
'game-ci build --target-platform Android --unity-version 2022.3.56f1 --build-method MyBuild.Run',
'Build for Android with a specific Unity version and build method',
) as any;
},
handler: async (cliArguments) => {
try {
mapCliArgumentsToInput(cliArguments);
const buildParameters = await BuildParameters.create();
const baseImage = new ImageTag(buildParameters);
let exitCode = -1;
if (buildParameters.providerStrategy === 'local') {
core.info(`Building locally for ${buildParameters.targetPlatform}...`);
core.info(`Unity version: ${buildParameters.editorVersion}`);
core.info(`Project path: ${buildParameters.projectPath}`);
const actionFolder = Action.actionFolder;
await PlatformSetup.setup(buildParameters, actionFolder);
exitCode =
process.platform === 'darwin'
? await MacBuilder.run(actionFolder)
: await Docker.run(baseImage.toString(), {
workspace: process.cwd(),
actionFolder,
...buildParameters,
});
} else {
core.info(`Building via orchestrator (${buildParameters.providerStrategy})...`);
await Orchestrator.run(buildParameters, baseImage.toString());
exitCode = 0;
}
// Output results
core.info(`\nBuild completed with exit code: ${exitCode}`);
core.info(`Build version: ${buildParameters.buildVersion}`);
core.info(`Build path: ${buildParameters.buildPath}`);
if (exitCode !== 0) {
throw new Error(`Build failed with exit code ${exitCode}`);
}
} catch (error: any) {
core.setFailed(`Build failed: ${error.message}`);
throw error;
}
},
};
export default buildCommand;

160
src/cli/commands/cache.ts Normal file
View File

@@ -0,0 +1,160 @@
import type { CommandModule } from 'yargs';
import * as core from '@actions/core';
import fs from 'node:fs';
import path from 'node:path';
const cacheCommand: CommandModule = {
command: 'cache <action>',
describe: 'Manage build caches',
builder: (yargs) => {
return yargs
.positional('action', {
describe: 'Cache action to perform',
choices: ['list', 'restore', 'clear'] as const,
})
.option('cache-dir', {
alias: 'cacheDir',
type: 'string',
description: 'Path to the cache directory',
default: '',
})
.option('project-path', {
alias: 'projectPath',
type: 'string',
description: 'Path to the Unity project',
default: '.',
})
.example('game-ci cache list', 'List all cached workspaces')
.example('game-ci cache restore --cache-dir ./my-cache', 'Restore a cached workspace')
.example('game-ci cache clear', 'Clear all cached workspaces');
},
handler: async (cliArguments) => {
const action = cliArguments.action as string;
const projectPath = (cliArguments.projectPath as string) || '.';
const cacheDirectory = (cliArguments.cacheDir as string) || path.join(projectPath, 'Library');
try {
switch (action) {
case 'list': {
await listCache(cacheDirectory, projectPath);
break;
}
case 'restore': {
await restoreCache(cacheDirectory);
break;
}
case 'clear': {
await clearCache(cacheDirectory);
break;
}
default: {
throw new Error(`Unknown cache action: ${action}. Available actions: list, restore, clear`);
}
}
} catch (error: any) {
core.setFailed(`Cache operation failed: ${error.message}`);
throw error;
}
},
};
async function listCache(cacheDirectory: string, projectPath: string): Promise<void> {
const libraryPath = path.resolve(projectPath, 'Library');
core.info('Cache Status:');
core.info('=============');
if (fs.existsSync(libraryPath)) {
const stats = fs.statSync(libraryPath);
const files = fs.readdirSync(libraryPath);
core.info(` Library folder: ${libraryPath}`);
core.info(` Entries: ${files.length}`);
core.info(` Last modified: ${stats.mtime.toISOString()}`);
// Show size of key subdirectories
const keyDirectories = ['PackageCache', 'ScriptAssemblies', 'ShaderCache', 'Bee'];
for (const directory of keyDirectories) {
const directoryPath = path.join(libraryPath, directory);
if (fs.existsSync(directoryPath)) {
const directoryStats = fs.statSync(directoryPath);
core.info(` ${directory}/: exists (modified ${directoryStats.mtime.toISOString()})`);
}
}
} else {
core.info(` Library folder not found at: ${libraryPath}`);
core.info(' No cache available. First build will be a clean build.');
}
// Check for .tar cache files if a custom cache dir is specified
if (cacheDirectory && cacheDirectory !== libraryPath && fs.existsSync(cacheDirectory)) {
core.info(`\nCache directory: ${cacheDirectory}`);
const cacheFiles = fs.readdirSync(cacheDirectory).filter((f) => f.endsWith('.tar') || f.endsWith('.tar.lz4'));
if (cacheFiles.length > 0) {
core.info(` Cache archives found: ${cacheFiles.length}`);
for (const file of cacheFiles) {
const filePath = path.join(cacheDirectory, file);
const fileStats = fs.statSync(filePath);
const sizeMegabytes = (fileStats.size / (1024 * 1024)).toFixed(1);
core.info(` - ${file} (${sizeMegabytes} MB, ${fileStats.mtime.toISOString()})`);
}
} else {
core.info(' No cache archives found.');
}
}
}
async function restoreCache(cacheDirectory: string): Promise<void> {
if (!cacheDirectory) {
throw new Error('--cache-dir is required for restore');
}
if (!fs.existsSync(cacheDirectory)) {
core.info(`Cache directory does not exist: ${cacheDirectory}`);
core.info('Nothing to restore.');
return;
}
const cacheFiles = fs.readdirSync(cacheDirectory).filter((f) => f.endsWith('.tar') || f.endsWith('.tar.lz4'));
if (cacheFiles.length === 0) {
core.info('No cache archives found to restore.');
return;
}
// Sort by modification time, newest first
const sorted = cacheFiles
.map((f) => ({ name: f, mtime: fs.statSync(path.join(cacheDirectory, f)).mtime }))
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
core.info(`Found ${sorted.length} cache archive(s). Latest: ${sorted[0].name}`);
core.info('Use the orchestrator cache system for full restore functionality:');
core.info(' game-ci orchestrate --cache-key <key> ...');
}
async function clearCache(cacheDirectory: string): Promise<void> {
let cleared = false;
if (cacheDirectory && fs.existsSync(cacheDirectory)) {
const cacheFiles = fs.readdirSync(cacheDirectory).filter((f) => f.endsWith('.tar') || f.endsWith('.tar.lz4'));
if (cacheFiles.length > 0) {
for (const file of cacheFiles) {
fs.unlinkSync(path.join(cacheDirectory, file));
core.info(` Removed: ${file}`);
}
cleared = true;
}
}
if (!cleared) {
core.info('No cache archives found to clear.');
} else {
core.info('Cache cleared.');
}
}
export default cacheCommand;

View File

@@ -0,0 +1,174 @@
import type { CommandModule } from 'yargs';
import * as core from '@actions/core';
import { BuildParameters, ImageTag, Orchestrator } from '../../model';
import { mapCliArgumentsToInput, CliArguments } from '../input-mapper';
interface OrchestrateArguments extends CliArguments {
targetPlatform: string;
providerStrategy?: string;
}
const orchestrateCommand: CommandModule<object, OrchestrateArguments> = {
command: 'orchestrate',
describe: 'Run a build via orchestrator providers (AWS, Kubernetes, etc.)',
builder: (yargs) => {
return yargs
.option('target-platform', {
alias: 'targetPlatform',
type: 'string',
description: 'Platform that the build should target',
demandOption: true,
})
.option('provider-strategy', {
alias: 'providerStrategy',
type: 'string',
description: 'Orchestrator provider: aws, k8s, local-docker, local-system',
default: 'aws',
})
.option('unity-version', {
alias: 'unityVersion',
type: 'string',
description: 'Version of Unity to use for building',
default: 'auto',
})
.option('project-path', {
alias: 'projectPath',
type: 'string',
description: 'Path to the Unity project to be built',
default: '.',
})
.option('build-name', {
alias: 'buildName',
type: 'string',
description: 'Name of the build',
default: '',
})
.option('builds-path', {
alias: 'buildsPath',
type: 'string',
description: 'Path where the builds should be stored',
default: 'build',
})
.option('build-method', {
alias: 'buildMethod',
type: 'string',
description: 'Path to a Namespace.Class.StaticMethod to run to perform the build',
default: '',
})
.option('custom-parameters', {
alias: 'customParameters',
type: 'string',
description: 'Custom parameters to configure the build',
default: '',
})
.option('versioning', {
type: 'string',
description: 'The versioning scheme to use',
default: 'None',
})
.option('aws-stack-name', {
alias: 'awsStackName',
type: 'string',
description: 'The Cloud Formation stack name (AWS provider)',
default: 'game-ci',
})
.option('kube-config', {
alias: 'kubeConfig',
type: 'string',
description: 'Base64 encoded Kubernetes config (K8s provider)',
default: '',
})
.option('kube-volume', {
alias: 'kubeVolume',
type: 'string',
description: 'Persistent Volume Claim name for Unity build (K8s provider)',
default: '',
})
.option('kube-volume-size', {
alias: 'kubeVolumeSize',
type: 'string',
description: 'Disc space for Kubernetes Persistent Volume',
default: '5Gi',
})
.option('container-cpu', {
alias: 'containerCpu',
type: 'string',
description: 'CPU allocation for remote build container',
default: '1024',
})
.option('container-memory', {
alias: 'containerMemory',
type: 'string',
description: 'Memory allocation for remote build container',
default: '3072',
})
.option('cache-key', {
alias: 'cacheKey',
type: 'string',
description: 'Cache key to indicate bucket for cache',
default: '',
})
.option('git-private-token', {
alias: 'gitPrivateToken',
type: 'string',
description: 'GitHub private token for repository access',
default: '',
})
.option('allow-dirty-build', {
alias: 'allowDirtyBuild',
type: 'boolean',
description: 'Allow builds from dirty branches',
default: false,
})
.option('watch-to-end', {
alias: 'watchToEnd',
type: 'string',
description: 'Whether to watch the build to completion',
default: 'true',
})
.option('clone-depth', {
alias: 'cloneDepth',
type: 'string',
description: 'Git clone depth (0 for full clone)',
default: '50',
})
.option('skip-activation', {
alias: 'skipActivation',
type: 'string',
description: 'Skip Unity activation/deactivation',
default: 'false',
})
.example(
'game-ci orchestrate --target-platform StandaloneLinux64 --provider-strategy aws',
'Build on AWS using the orchestrator',
)
.example(
'game-ci orchestrate --target-platform StandaloneLinux64 --provider-strategy k8s --kube-config <base64>',
'Build on Kubernetes',
) as any;
},
handler: async (cliArguments) => {
try {
mapCliArgumentsToInput(cliArguments);
const buildParameters = await BuildParameters.create();
const baseImage = new ImageTag(buildParameters);
core.info(`Orchestrating build via ${buildParameters.providerStrategy}...`);
core.info(`Target platform: ${buildParameters.targetPlatform}`);
core.info(`Unity version: ${buildParameters.editorVersion}`);
core.info(`Build GUID: ${buildParameters.buildGuid}`);
const result = await Orchestrator.run(buildParameters, baseImage.toString());
core.info(`\nOrchestrated build completed.`);
core.info(`Results: ${result.BuildResults}`);
} catch (error: any) {
core.setFailed(`Orchestrated build failed: ${error.message}`);
throw error;
}
},
};
export default orchestrateCommand;

View File

@@ -0,0 +1,84 @@
import type { CommandModule } from 'yargs';
import * as core from '@actions/core';
import fs from 'node:fs';
import path from 'node:path';
import UnityVersioning from '../../model/unity-versioning';
const statusCommand: CommandModule = {
command: 'status',
describe: 'Show build status and workspace info',
builder: (yargs) => {
return yargs.option('project-path', {
alias: 'projectPath',
type: 'string',
description: 'Path to the Unity project',
default: '.',
});
},
handler: async (cliArguments) => {
const projectPath = (cliArguments.projectPath as string) || '.';
core.info('game-ci Workspace Status');
core.info('========================\n');
// Project detection
const projectVersionPath = path.join(projectPath, 'ProjectSettings', 'ProjectVersion.txt');
const hasProject = fs.existsSync(projectVersionPath);
core.info(`Project Path: ${path.resolve(projectPath)}`);
core.info(`Unity Project Found: ${hasProject ? 'Yes' : 'No'}`);
if (hasProject) {
try {
const unityVersion = UnityVersioning.determineUnityVersion(projectPath, 'auto');
core.info(`Unity Version: ${unityVersion}`);
} catch {
core.info(`Unity Version: Unable to detect`);
}
// Library folder status
const libraryPath = path.join(projectPath, 'Library');
if (fs.existsSync(libraryPath)) {
const stats = fs.statSync(libraryPath);
core.info(`Library Cache: Present (modified ${stats.mtime.toISOString()})`);
} else {
core.info(`Library Cache: Not present (clean build required)`);
}
// Build output detection
const buildsPath = path.join(projectPath, '..', 'build');
if (fs.existsSync(buildsPath)) {
const builds = fs.readdirSync(buildsPath);
if (builds.length > 0) {
core.info(`\nBuild Outputs (${buildsPath}):`);
for (const build of builds) {
const buildPath = path.join(buildsPath, build);
const buildStats = fs.statSync(buildPath);
core.info(` - ${build} (${buildStats.isDirectory() ? 'dir' : 'file'}, ${buildStats.mtime.toISOString()})`);
}
}
}
}
// Environment
core.info('\nEnvironment:');
core.info(` Platform: ${process.platform}`);
core.info(` Node.js: ${process.version}`);
core.info(` UNITY_SERIAL: ${process.env.UNITY_SERIAL ? 'Set' : 'Not set'}`);
core.info(` UNITY_LICENSE: ${process.env.UNITY_LICENSE ? 'Set' : 'Not set'}`);
core.info(` UNITY_EMAIL: ${process.env.UNITY_EMAIL ? 'Set' : 'Not set'}`);
core.info(` UNITY_PASSWORD: ${process.env.UNITY_PASSWORD ? 'Set' : 'Not set'}`);
// Docker availability
core.info(`\nDocker: Checking...`);
try {
const { execSync } = await import('node:child_process');
const dockerVersion = execSync('docker --version', { encoding: 'utf8' }).trim();
core.info(` ${dockerVersion}`);
} catch {
core.info(` Docker not found or not accessible`);
}
},
};
export default statusCommand;

View File

@@ -0,0 +1,37 @@
import type { CommandModule } from 'yargs';
import * as core from '@actions/core';
import fs from 'node:fs';
import path from 'node:path';
const versionCommand: CommandModule = {
command: 'version',
describe: 'Show version info',
builder: {},
handler: async () => {
try {
// Read version from package.json
let packageJsonPath = path.join(__dirname, '..', '..', '..', 'package.json');
if (!fs.existsSync(packageJsonPath)) {
packageJsonPath = path.join(__dirname, '..', '..', 'package.json');
}
if (!fs.existsSync(packageJsonPath)) {
packageJsonPath = path.join(process.cwd(), 'package.json');
}
if (fs.existsSync(packageJsonPath)) {
const packageData = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
core.info(`game-ci (unity-builder) v${packageData.version}`);
core.info(`Node.js ${process.version}`);
core.info(`Platform: ${process.platform} ${process.arch}`);
} else {
core.info('game-ci (unity-builder)');
core.info('Version information unavailable');
}
} catch (error: any) {
core.info('game-ci (unity-builder)');
core.error(`Could not read version: ${error.message}`);
}
},
};
export default versionCommand;

100
src/cli/input-mapper.ts Normal file
View File

@@ -0,0 +1,100 @@
import { Cli } from '../model/cli/cli';
import GitHub from '../model/github';
/**
* Maps CLI arguments (kebab-case flags) to the Input/OrchestratorOptions
* interface used by the action. This bridges the gap between user-friendly
* CLI flags and the camelCase environment/input system unity-builder expects.
*
* The existing Input class already queries Cli.options, environment variables,
* and GitHub Action inputs in priority order. We populate Cli.options so that
* the rest of the codebase works unchanged.
*/
export interface CliArguments {
targetPlatform?: string;
unityVersion?: string;
projectPath?: string;
buildProfile?: string;
buildName?: string;
buildsPath?: string;
buildMethod?: string;
customParameters?: string;
versioning?: string;
version?: string;
customImage?: string;
manualExit?: boolean;
enableGpu?: boolean;
androidVersionCode?: string;
androidExportType?: string;
androidKeystoreName?: string;
androidKeystoreBase64?: string;
androidKeystorePass?: string;
androidKeyaliasName?: string;
androidKeyaliasPass?: string;
androidTargetSdkVersion?: string;
androidSymbolType?: string;
dockerCpuLimit?: string;
dockerMemoryLimit?: string;
dockerIsolationMode?: string;
dockerWorkspacePath?: string;
containerRegistryRepository?: string;
containerRegistryImageVersion?: string;
runAsHostUser?: string;
chownFilesTo?: string;
sshAgent?: string;
sshPublicKeysDirectoryPath?: string;
gitPrivateToken?: string;
providerStrategy?: string;
awsStackName?: string;
kubeConfig?: string;
kubeVolume?: string;
kubeVolumeSize?: string;
kubeStorageClass?: string;
containerCpu?: string;
containerMemory?: string;
cacheKey?: string;
watchToEnd?: string;
allowDirtyBuild?: boolean;
skipActivation?: string;
cloneDepth?: string;
unityLicensingServer?: string;
cacheUnityInstallationOnMac?: boolean;
unityHubVersionOnMac?: string;
mode?: string;
[key: string]: unknown;
}
/**
* Converts kebab-case CLI flags to camelCase keys matching the Input class
* property names, then injects them into Cli.options so the existing
* Input.getInput() / OrchestratorOptions.getInput() chain picks them up.
*/
export function mapCliArgumentsToInput(cliArguments: CliArguments): void {
// Disable GitHub Actions input reading when in CLI mode
GitHub.githubInputEnabled = false;
// The existing Cli.options mechanism is used by Input.getInput() to query
// CLI-provided values. We set it directly.
const mapped: Record<string, unknown> = {};
for (const [key, value] of Object.entries(cliArguments)) {
if (value !== undefined && key !== '_' && key !== '$0') {
mapped[key] = typeof value === 'boolean' ? String(value) : value;
}
}
// Ensure mode is set so Cli.isCliMode returns true
if (!mapped['mode']) {
mapped['mode'] = 'cli';
}
Cli.options = mapped;
}

View File

@@ -2667,6 +2667,13 @@
dependencies:
"@types/yargs-parser" "*"
"@types/yargs@^17.0.35":
version "17.0.35"
resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.35.tgz#07013e46aa4d7d7d50a49e15604c1c5340d4eb24"
integrity sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==
dependencies:
"@types/yargs-parser" "*"
"@types/yarnpkg__lockfile@^1.1.6":
version "1.1.6"
resolved "https://registry.yarnpkg.com/@types/yarnpkg__lockfile/-/yarnpkg__lockfile-1.1.6.tgz#60a35ede6197d8cbedd5bb8393f3921e8d56d44b"
@@ -2955,6 +2962,11 @@ ansi-regex@^5.0.1:
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
ansi-regex@^6.2.2:
version "6.2.2"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1"
integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==
ansi-styles@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
@@ -2974,6 +2986,11 @@ ansi-styles@^5.0.0:
resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz"
integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==
ansi-styles@^6.2.1:
version "6.2.3"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041"
integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==
anymatch@^3.0.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
@@ -3504,6 +3521,15 @@ cliui@^7.0.2:
strip-ansi "^6.0.0"
wrap-ansi "^7.0.0"
cliui@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-9.0.1.tgz#6f7890f386f6f1f79953adc1f78dec46fcc2d291"
integrity sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==
dependencies:
string-width "^7.2.0"
strip-ansi "^7.1.0"
wrap-ansi "^9.0.0"
clone-response@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.3.tgz#af2032aa47816399cf5f0a1d0db902f517abb8c3"
@@ -3882,6 +3908,11 @@ emittery@^0.8.1:
resolved "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz"
integrity sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==
emoji-regex@^10.3.0:
version "10.6.0"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.6.0.tgz#bf3d6e8f7f8fd22a65d9703475bc0147357a6b0d"
integrity sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==
emoji-regex@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
@@ -4642,6 +4673,11 @@ get-caller-file@^2.0.5:
resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
get-east-asian-width@^1.0.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz#ce7008fe345edcf5497a6f557cfa54bc318a9ce7"
integrity sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==
get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.0, get-intrinsic@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.1.tgz#d295644fed4505fc9cde952c37ee12b477a83d82"
@@ -6272,7 +6308,7 @@ no-case@^3.0.4:
lower-case "^2.0.2"
tslib "^2.0.3"
node-fetch@2:
node-fetch@2, node-fetch@^2.7.0:
version "2.7.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
@@ -6286,13 +6322,6 @@ node-fetch@^2.6.7:
dependencies:
whatwg-url "^5.0.0"
node-fetch@^2.7.0:
version "2.7.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
dependencies:
whatwg-url "^5.0.0"
node-int64@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"
@@ -7203,6 +7232,15 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^7.0.0, string-width@^7.2.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-7.2.0.tgz#b5bb8e2165ce275d4d43476dd2700ad9091db6dc"
integrity sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==
dependencies:
emoji-regex "^10.3.0"
get-east-asian-width "^1.0.0"
strip-ansi "^7.1.0"
string.prototype.trim@^1.2.7:
version "1.2.7"
resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.7.tgz#a68352740859f6893f14ce3ef1bb3037f7a90533"
@@ -7237,6 +7275,13 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1:
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^7.1.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3"
integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==
dependencies:
ansi-regex "^6.2.2"
strip-bom@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
@@ -7947,6 +7992,15 @@ wrap-ansi@^7.0.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^9.0.0:
version "9.0.2"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz#956832dea9494306e6d209eb871643bb873d7c98"
integrity sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==
dependencies:
ansi-styles "^6.2.1"
string-width "^7.0.0"
strip-ansi "^7.1.0"
wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
@@ -8032,6 +8086,11 @@ yargs-parser@20.x, yargs-parser@^20.2.2:
resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz"
integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==
yargs-parser@^22.0.0:
version "22.0.0"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-22.0.0.tgz#87b82094051b0567717346ecd00fd14804b357c8"
integrity sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==
yargs@^16.2.0:
version "16.2.0"
resolved "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz"
@@ -8045,6 +8104,18 @@ yargs@^16.2.0:
y18n "^5.0.5"
yargs-parser "^20.2.2"
yargs@^18.0.0:
version "18.0.0"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-18.0.0.tgz#6c84259806273a746b09f579087b68a3c2d25bd1"
integrity sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==
dependencies:
cliui "^9.0.1"
escalade "^3.1.1"
get-caller-file "^2.0.5"
string-width "^7.2.0"
y18n "^5.0.5"
yargs-parser "^22.0.0"
yarn-audit-fix@^9.3.8:
version "9.3.12"
resolved "https://registry.yarnpkg.com/yarn-audit-fix/-/yarn-audit-fix-9.3.12.tgz#cc34e87aa080bace32f2f105be6b581a3cb6eb24"