diff --git a/src/commands/build.ts b/src/commands/build.ts deleted file mode 100644 index 860c749..0000000 --- a/src/commands/build.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Command, EnumType, ValidationError } from '@cliffy/command' - -import { createEngine, Engine, EngineConfiguration, EnginePlatform, EngineTarget } from '../lib/engine.ts' -import { findProjectFile, getProjectName } from '../lib/utils.ts' -import type { GlobalOptions } from '../lib/types.ts' -import { Config } from '../lib/config.ts' - -const TargetError = (target: string, targets: string[]) => { - return new ValidationError(`Invalid Target: ${target}. Run 'runreal list-targets' to see valid targets.`) -} - -export type BuildOptions = typeof build extends Command - ? Options - : never - -export const build = new Command() - .description('build') - .type('Configuration', new EnumType(EngineConfiguration)) - .type('Platform', new EnumType(EnginePlatform)) - .option('-p, --platform ', 'Platform', { default: Engine.getCurrentPlatform() }) - .option('-c, --configuration ', 'Configuration', { - default: EngineConfiguration.Development, - }) - .option('-d, --dry-run', 'Dry run') - .arguments('') - .action(async (options, target = EngineTarget.Editor) => { - const { platform, configuration, dryRun } = options as BuildOptions - const config = Config.getInstance() - const { engine: { path: enginePath }, project: { path: projectPath } } = config.mergeConfigCLIConfig({ - cliOptions: options, - }) - - const engine = createEngine(enginePath) - const validTargets = await engine.parseEngineTargets() - const extraArgs: string[] = [] - - const projectFile = await findProjectFile(projectPath).catch(() => null) - if (projectFile) { - extraArgs.push(`-project=${projectFile}`) - validTargets.push(...(await engine.parseProjectTargets(projectPath))) - } - - // Shorthand target specifier: Editor, Game, Client, Server - if (Object.values(EngineTarget).includes(target as EngineTarget)) { - if (projectFile) { - const projectName = await getProjectName(projectPath) - target = `${projectName}${target}` - } else { - target = `Unreal${target}` - } - } - - if (!validTargets.includes(target)) { - throw TargetError(target, validTargets) - } - - if (dryRun) { - console.log(`[build] enginePath: ${enginePath}`) - console.log(`[build] projectPath: ${projectPath}`) - console.log(`[build] projectFile: ${projectFile}`) - console.log(`[build] command: ${configuration} ${target} ${platform}`) - } - - // const manifest = `${enginePath}/Manifests/${target}-${configuration}-${platform}-Manifest.xml` - // extraArgs.push(`-Manifest=${manifest}`) - extraArgs.push('-NoUBTMakefiles') - extraArgs.push('-NoHotReload') - extraArgs.push('-TraceWrites') - extraArgs.push('-NoXGE') - // extraArgs.push('-UsePrecompiled') - - // Build Target - // Build.bat TestProjectEditor Win64 Development -project=E:\Project\TestProject.uproject - await engine.runUBT({ - target, - configuration: configuration as EngineConfiguration, - platform: platform as EnginePlatform, - extraArgs, - dryRun, - }) - }) diff --git a/src/commands/buildgraph/run.ts b/src/commands/buildgraph/run.ts index 0323e39..175d91c 100644 --- a/src/commands/buildgraph/run.ts +++ b/src/commands/buildgraph/run.ts @@ -1,94 +1,13 @@ import { Command } from '@cliffy/command' -import * as path from '@std/path' -import { readNdjson } from 'ndjson' import { Config } from '../../lib/config.ts' import type { GlobalOptions } from '../../lib/types.ts' -import { createEngine } from '../../lib/engine.ts' +import { createProject } from '../../lib/project.ts' +import { writeMarkdownReport } from '../../lib/report.ts' +import { logger } from '../../lib/logger.ts' export type RunOptions = typeof run extends Command ? Options : never -interface AutomationToolLogs { - time: string - level: string - message: string - format: string - properties: Record - id?: number - line?: number - lineCount?: number -} - -async function getAutomationToolLogs(enginePath: string) { - const logJson = path.join(enginePath, 'Engine', 'Programs', 'AutomationTool', 'Saved', 'Logs', 'Log.json') - let logs: AutomationToolLogs[] = [] - try { - logs = await readNdjson(logJson) as unknown as AutomationToolLogs[] - } catch (e) { - // pass - } - return logs -} - -function generateMarkdownReport(logs: AutomationToolLogs[]): string { - const errorLogs = logs.filter(({ level }) => level === 'Error') - if (errorLogs.length === 0) { - return '# Build Report\n\nNo errors found.' - } - - let markdown = '# Build Error Report\n\n' - - // Group errors by id - const errorGroups = new Map() - - for (const log of errorLogs) { - const groupId = log.id !== undefined ? log.id : 'ungrouped' - if (!errorGroups.has(groupId)) { - errorGroups.set(groupId, []) - } - errorGroups.get(groupId)!.push(log) - } - - markdown += `## Errors (${errorLogs.length})\n\n` - - // Process each group of errors - for (const [groupId, groupLogs] of errorGroups) { - if (groupId !== 'ungrouped') { - markdown += `### Group ID: ${groupId} (${groupLogs.length} errors)\n\n` - } else { - markdown += `### Ungrouped Errors (${groupLogs.length} errors)\n\n` - } - - for (const log of groupLogs) { - markdown += `#### Error: ${log.message}\n` - - if (log.properties?.file) { - const file = log.properties.file.$text - const line = log.properties.line?.$text || log.line - markdown += `- **File**: ${file}${line ? `:${line}` : ''}\n` - } - - if (log.properties?.code) { - markdown += `- **Code**: ${log.properties.code.$text}\n` - } - - if (log.properties?.severity) { - markdown += `- **Severity**: ${log.properties.severity.$text}\n` - } - - markdown += '\n' - } - } - - return markdown -} - -async function writeMarkdownReport(logs: AutomationToolLogs[], outputPath: string): Promise { - const markdown = generateMarkdownReport(logs) - await Deno.writeTextFile(outputPath, markdown) - console.log(`[BUILDGRAPH RUN] Error report generated: ${outputPath}`) -} - export const run = new Command() .description('run buildgraph script') .arguments(' ') @@ -100,14 +19,17 @@ export const run = new Command() .stopEarly() .action(async (options, buildGraphScript: string, ...buildGraphArgs: Array) => { const config = Config.getInstance() - const { engine: { path: enginePath } } = config.mergeConfigCLIConfig({ cliOptions: options }) - const engine = createEngine(enginePath) - const { success, code } = await engine.runBuildGraph(buildGraphScript, buildGraphArgs) + const { engine: { path: enginePath }, project: { path: projectPath } } = config.mergeConfigCLIConfig({ + cliOptions: options, + }) + + const project = await createProject(enginePath, projectPath) + const { success, code } = await project.runBuildGraph(buildGraphScript, buildGraphArgs) if (!success) { - const logs = await getAutomationToolLogs(enginePath) + const logs = await project.engine.getAutomationToolLogs(enginePath) for (const log of logs.filter(({ level }) => level === 'Error')) { - console.log(`[BUILDGRAPH RUN] ${log.message}`) + logger.info(`[BUILDGRAPH RUN] ${log.message}`) } if (options.buildgraphReportErrors) { diff --git a/src/commands/clean.ts b/src/commands/clean.ts deleted file mode 100644 index 96d6ed1..0000000 --- a/src/commands/clean.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Engine } from '../lib/engine.ts' -import { Command } from '@cliffy/command' - -export const clean = new Command() - .option('--dry-run', 'Dry run', { default: false }) - .description('clean') - .action(async (options) => { - await Engine.runClean(options) - }) diff --git a/src/commands/gen.ts b/src/commands/gen.ts deleted file mode 100644 index fa6309c..0000000 --- a/src/commands/gen.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Command } from '@cliffy/command' -import { createEngine } from '../lib/engine.ts' -import { exec, findProjectFile } from '../lib/utils.ts' - -export type GenOptions = typeof gen extends Command, [], void> - ? Options - : never - -export const gen = new Command() - .description('generate') - .option('-p, --project-path ', 'Path to project folder', { - default: 'E:\\Project', - }) - .option('-e, --engine-path ', 'Path to engine folder', { - default: 'E:\\UnrealEngine', - }) - .option('-d, --dry-run', 'Dry run') - .arguments('') - .action( - async ( - { projectPath, enginePath, dryRun }, - ...args: string[] - ) => { - const projectFile = await findProjectFile(projectPath) - const engine = createEngine(enginePath) - - if (dryRun) { - console.log(`[gen] enginePath: ${enginePath}`) - console.log(`[gen] projectPath: ${projectPath}`) - console.log(`[gen] projectFile: ${projectFile}`) - console.log(`[gen] command: ${args.join(' ')}`) - } - - // Generate project - // GenerateProjectFiles.bat -project=E:\Project\TestProject.uproject -game -engine - await exec(engine.getGenerateScript(), [ - `-project=${projectFile}`, - ...args, - ]) - }, - ) diff --git a/src/commands/list-targets.ts b/src/commands/list-targets.ts index 80c66cc..484cdc2 100644 --- a/src/commands/list-targets.ts +++ b/src/commands/list-targets.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command' +import { createProject } from '../lib/project.ts' import { createEngine } from '../lib/engine.ts' import type { GlobalOptions } from '../lib/types.ts' import { Config } from '../lib/config.ts' @@ -29,9 +30,8 @@ export const listTargets = new Command() let projectTargets: string[] = [] if (projectPath) { - projectTargets = (await engine.parseProjectTargets(projectPath)).filter((target) => - !engineTargets.includes(target) - ) + const project = await createProject(enginePath, projectPath) + projectTargets = (await project.parseProjectTargets()).filter((target) => !engineTargets.includes(target)) } if (engineOnly) { diff --git a/src/commands/pkg.ts b/src/commands/pkg.ts deleted file mode 100644 index 2a78301..0000000 --- a/src/commands/pkg.ts +++ /dev/null @@ -1,125 +0,0 @@ -import * as path from '@std/path' -import { Command, EnumType, ValidationError } from '@cliffy/command' -import { createEngine, Engine, EngineConfiguration, EnginePlatform, EngineTarget } from '../lib/engine.ts' -import { findProjectFile, getProjectName } from '../lib/utils.ts' -import { Config } from '../lib/config.ts' -import type { GlobalOptions } from '../lib/types.ts' - -const defaultBCRArgs = [ - '-build', - '-cook', - '-stage', - '-package', - '-prereqs', - '-manifests', - '-pak', - '-compressed', - '-nop4', - '-utf8output', - '-nullrhi', - '-unattended', - // Might want to keep these off by default - '-nocompileeditor', - '-skipcompileeditor', - '-nocompile', - '-nocompileuat', - '-nodebuginfo', -] - -const clientBCRArgs = [ - '-client', - ...defaultBCRArgs, -] - -const gameBCRArgs = [ - '-game', - ...defaultBCRArgs, -] - -const serverBCRArgs = [ - '-server', - '-noclient', - ...defaultBCRArgs, -] - -const profiles = { - 'client': clientBCRArgs, - 'game': gameBCRArgs, - 'server': serverBCRArgs, -} - -const profileNameMap = { client: 'Client', game: 'Game', server: 'Server' } - -export type PkgOptions = typeof pkg extends Command ? Options - : never - -export const pkg = new Command() - .description('package') - .type('Configuration', new EnumType(EngineConfiguration)) - .type('Platform', new EnumType(EnginePlatform)) - .option('-p, --platform ', 'Platform', { default: Engine.getCurrentPlatform() }) - .option('-c, --configuration ', 'Configuration', { - default: EngineConfiguration.Development, - }) - .option('-a, --archive-directory ', 'Path to archive directory', { default: Deno.cwd() }) - .option('-z, --zip', 'Should we zip the archive') - .option('-d, --dry-run', 'Dry run') - .option('--profile ', 'Build profile', { default: 'client', required: true }) - .action(async (options) => { - const { platform, configuration, dryRun, profile, archiveDirectory, zip } = options as PkgOptions - const cfg = Config.getInstance() - const { engine: { path: enginePath }, project: { path: projectPath } } = cfg.mergeConfigCLIConfig({ - cliOptions: options, - }) - - const literal = pkg.getLiteralArgs().map((arg) => arg.toLowerCase()) - const profileArgs = profiles[profile as keyof typeof profiles] || [] - const bcrArgs = Array.from(new Set([...profileArgs, ...literal])) - - const engine = createEngine(enginePath) - const projectFile = await findProjectFile(projectPath).catch(() => null) - const projectName = await getProjectName(projectPath) - - if (projectFile) { - bcrArgs.push(`-project=${projectFile}`) - } else { - throw new ValidationError('.uproject file not found') - } - bcrArgs.push(`-platform=${platform}`) - if (profile === 'server') { - bcrArgs.push(`-serverconfig=${configuration}`) - } - if (profile === 'client') { - bcrArgs.push(`-clientconfig=${configuration}`) - } - if (profile === 'game') { - bcrArgs.push(`-gameconfig=${configuration}`) - } - if (archiveDirectory) { - bcrArgs.push('-archive') - bcrArgs.push(`-archiveDirectory=${archiveDirectory}`) - bcrArgs.push('-archivemetadata') - } - if (dryRun) { - console.log(`[package] package ${profile} ${configuration} ${platform}`) - console.log('[package] BCR args:') - console.log(bcrArgs) - return - } - - await engine.runUAT(['BuildCookRun', ...bcrArgs]) - - if (zip) { - // Reverse the EnginePlatform enum to get the build output platform name, ie Win64 => Windows - const platformName = - Object.keys(EnginePlatform)[Object.values(EnginePlatform).indexOf(platform as EnginePlatform)] - const profileName = profileNameMap[profile as keyof typeof profileNameMap] || '' - const archivePath = path.join(archiveDirectory, `${projectName}-${profileName}-${platformName}`) - const zipArgs = [ - `-add=${archivePath}`, - `-archive=${archivePath}.zip`, - '-compression=5', - ] - await engine.runUAT(['ZipUtils', ...zipArgs]) - } - }) diff --git a/src/commands/project/clean.ts b/src/commands/project/clean.ts new file mode 100644 index 0000000..544a293 --- /dev/null +++ b/src/commands/project/clean.ts @@ -0,0 +1,15 @@ +import { Command } from '@cliffy/command' +import { createProject } from '../../lib/project.ts' +import { Config } from '../../lib/config.ts' + +export const clean = new Command() + .option('--dry-run', 'Dry run', { default: false }) + .description('clean') + .action(async (options) => { + const config = Config.getInstance() + const { engine: { path: enginePath }, project: { path: projectPath } } = config.mergeConfigCLIConfig({ + cliOptions: options, + }) + const project = await createProject(enginePath, projectPath) + project.runClean(options.dryRun) + }) diff --git a/src/commands/project/compile.ts b/src/commands/project/compile.ts new file mode 100644 index 0000000..2643c57 --- /dev/null +++ b/src/commands/project/compile.ts @@ -0,0 +1,35 @@ +import { Command, EnumType, ValidationError } from '@cliffy/command' + +import { Engine, EngineConfiguration, EnginePlatform, EngineTarget } from '../../lib/engine.ts' +import { createProject } from '../../lib/project.ts' +import type { GlobalOptions } from '../../lib/types.ts' +import { Config } from '../../lib/config.ts' + +export type CompileOptions = typeof compile extends Command + ? Options + : never + +export const compile = new Command() + .description('Compile a project') + .type('Configuration', new EnumType(EngineConfiguration)) + .type('Platform', new EnumType(EnginePlatform)) + .option('-p, --platform ', 'Platform', { default: Engine.getCurrentPlatform() }) + .option('-c, --configuration ', 'Configuration', { + default: EngineConfiguration.Development, + }) + .option('--dry-run', 'Dry run', { default: false }) + .arguments('') + .action(async (options, target = EngineTarget.Editor) => { + const { platform, configuration, dryRun } = options as CompileOptions + const config = Config.getInstance() + const { engine: { path: enginePath }, project: { path: projectPath } } = config.mergeConfigCLIConfig({ + cliOptions: options, + }) + const project = await createProject(enginePath, projectPath) + await project.compile({ + target: target as EngineTarget, + configuration: configuration as EngineConfiguration, + platform: platform as EnginePlatform, + dryRun: dryRun, + }) + }) diff --git a/src/commands/project/cook.ts b/src/commands/project/cook.ts new file mode 100644 index 0000000..beea526 --- /dev/null +++ b/src/commands/project/cook.ts @@ -0,0 +1,25 @@ +import { Command } from '@cliffy/command' + +import { createProject } from '../../lib/project.ts' +import type { GlobalOptions } from '../../lib/types.ts' +import { Config } from '../../lib/config.ts' + +export const cook = new Command() + .description('Cook the project') + .arguments('') + .option('--dry-run', 'Dry run', { default: false }) + .option('--compile', 'Compile before Cook', { default: false }) + .stopEarly() + .action(async (options, ...cookArguments: Array) => { + const config = Config.getInstance() + const { engine: { path: enginePath }, project: { path: projectPath } } = config.mergeConfigCLIConfig({ + cliOptions: options, + }) + + const project = await createProject(enginePath, projectPath) + if (options.compile) { + await project.compile({}) + } + + await project.cookContent({ extraArgs: cookArguments }) + }) diff --git a/src/commands/project/editor.ts b/src/commands/project/editor.ts new file mode 100644 index 0000000..dc57116 --- /dev/null +++ b/src/commands/project/editor.ts @@ -0,0 +1,32 @@ +import { Command } from '@cliffy/command' + +import { createProject } from '../../lib/project.ts' +import type { GlobalOptions } from '../../lib/types.ts' +import { Config } from '../../lib/config.ts' + +export const editor = new Command() + .description('Run the editor') + .arguments('') + .option('--dry-run', 'Dry run', { default: false }) + .option('--compile', 'Compile binaries first', { default: false }) + .stopEarly() + .action(async (options, ...editorArguments: Array) => { + const config = Config.getInstance() + const { engine: { path: enginePath }, project: { path: projectPath } } = config.mergeConfigCLIConfig({ + cliOptions: options, + }) + const project = await createProject(enginePath, projectPath) + + if (options.dryRun) { + console.log(`Would open editor with ${editorArguments}`) + Deno.exit() + } + + console.log(`Running editor with ${editorArguments}`) + + if (options.compile) { + await project.compileAndRunEditor({ extraRunArgs: editorArguments }) + } else { + await project.runEditor({ extraArgs: editorArguments }) + } + }) diff --git a/src/commands/project/gen.ts b/src/commands/project/gen.ts new file mode 100644 index 0000000..4dbd13a --- /dev/null +++ b/src/commands/project/gen.ts @@ -0,0 +1,21 @@ +import { Command } from '@cliffy/command' +import { Config } from '../../lib/config.ts' +import { createProject } from '../../lib/project.ts' + +export type GenOptions = typeof gen extends Command, [], void> + ? Options + : never + +export const gen = new Command() + .description('generate') + .arguments('') + .option('--dry-run', 'Dry run', { default: false }) + .stopEarly() + .action(async (options, ...genArguments: Array) => { + const config = Config.getInstance() + const { engine: { path: enginePath }, project: { path: projectPath } } = config.mergeConfigCLIConfig({ + cliOptions: options, + }) + const project = await createProject(enginePath, projectPath) + project.genProjectFiles(genArguments) + }) diff --git a/src/commands/project/index.ts b/src/commands/project/index.ts new file mode 100644 index 0000000..81708d9 --- /dev/null +++ b/src/commands/project/index.ts @@ -0,0 +1,26 @@ +import { Command } from '@cliffy/command' + +import type { GlobalOptions } from '../../lib/types.ts' + +import { clean } from './clean.ts' +import { compile } from './compile.ts' +import { cook } from './cook.ts' +import { editor } from './editor.ts' +import { gen } from './gen.ts' +import { pkg } from './pkg.ts' +import { run } from './run.ts' +import { runpython } from './runpython.ts' + +export const project = new Command() + .description('project') + .action(function () { + this.showHelp() + }) + .command('clean', clean) + .command('compile', compile) + .command('cook', cook) + .command('editor', editor) + .command('gen', gen) + .command('pkg', pkg) + .command('run', run) + .command('runpython', runpython) diff --git a/src/commands/project/pkg.ts b/src/commands/project/pkg.ts new file mode 100644 index 0000000..46c0e6e --- /dev/null +++ b/src/commands/project/pkg.ts @@ -0,0 +1,36 @@ +import { Command, EnumType } from '@cliffy/command' +import { Engine, EngineConfiguration, EnginePlatform } from '../../lib/engine.ts' +import { Config } from '../../lib/config.ts' +import type { GlobalOptions } from '../../lib/types.ts' +import { createProject } from '../../lib/project.ts' + +export type PkgOptions = typeof pkg extends Command ? Options + : never + +export const pkg = new Command() + .description('package') + .type('Configuration', new EnumType(EngineConfiguration)) + .type('Platform', new EnumType(EnginePlatform)) + .arguments('') + .option('-p, --platform ', 'Platform', { default: Engine.getCurrentPlatform() }) + .option('-c, --configuration ', 'Configuration', { + default: EngineConfiguration.Development, + }) + .option('-a, --archive-directory ', 'Path to archive directory') + .option('-z, --zip', 'Should we zip the archive') + .option('-d, --dry-run', 'Dry run') + .option('--compile', 'Use the precompiled binaries', { default: false }) + .option('--profile ', 'Build profile', { default: 'client', required: true }) + .stopEarly() + .action(async (options, ...pkgArguments: Array) => { + const { platform, configuration, dryRun, profile, archiveDirectory, zip } = options as PkgOptions + const cfg = Config.getInstance() + const { engine: { path: enginePath }, project: { path: projectPath } } = cfg.mergeConfigCLIConfig({ + cliOptions: options, + }) + + const args = pkg.getLiteralArgs().concat(pkgArguments) + + const project = await createProject(enginePath, projectPath) + project.package({ archiveDirectory: archiveDirectory, profile: profile, extraArgs: args }) + }) diff --git a/src/commands/project/run.ts b/src/commands/project/run.ts new file mode 100644 index 0000000..9ebdf95 --- /dev/null +++ b/src/commands/project/run.ts @@ -0,0 +1,26 @@ +import { Command } from '@cliffy/command' + +import { createProject } from '../../lib/project.ts' +import type { GlobalOptions } from '../../lib/types.ts' +import { Config } from '../../lib/config.ts' + +export const run = new Command() + .description('Run the game') + .arguments('') + .option('--dry-run', 'Dry run', { default: false }) + .option('--compile', 'Use the precompiled binaries', { default: false }) + .stopEarly() + .action(async (options, ...runArguments: Array) => { + const config = Config.getInstance() + const { engine: { path: enginePath }, project: { path: projectPath } } = config.mergeConfigCLIConfig({ + cliOptions: options, + }) + + const project = await createProject(enginePath, projectPath) + + if (options.compile) { + await project.runEditor({ extraArgs: ['-game', ...runArguments] }) + } else { + await project.compileAndRunEditor({ extraRunArgs: ['-game', ...runArguments] }) + } + }) diff --git a/src/commands/runpython.ts b/src/commands/project/runpython.ts similarity index 55% rename from src/commands/runpython.ts rename to src/commands/project/runpython.ts index 1c2fae8..c1d70ca 100644 --- a/src/commands/runpython.ts +++ b/src/commands/project/runpython.ts @@ -1,12 +1,8 @@ import { Command } from '@cliffy/command' import * as path from '@std/path' -import { findProjectFile } from '../lib/utils.ts' -import { Config } from '../lib/config.ts' -import { logger } from '../lib/logger.ts' -import type { GlobalOptions } from '../lib/types.ts' - -import { exec } from '../lib/utils.ts' -import { Engine, getEditorPath } from '../lib/engine.ts' +import { Config } from '../../lib/config.ts' +import type { GlobalOptions } from '../../lib/types.ts' +import { createProject } from '../../lib/project.ts' export type RunPythonOptions = typeof runpython extends Command ? Options @@ -25,29 +21,13 @@ export const runpython = new Command() const { engine: { path: enginePath }, project: { path: projectPath } } = config.mergeConfigCLIConfig({ cliOptions: options, }) - - const projectFile = await findProjectFile(projectPath).catch(() => { - logger.error(`Could not find project file in ${projectPath}`) - Deno.exit(1) - }) - - if (!projectFile) { - logger.error(`Could not find project file in ${projectPath}`) - Deno.exit(1) - } + const project = await createProject(enginePath, projectPath) // Resolve absolute path to script const scriptPath = path.resolve(options.script) - // Get the editor executable path based on platform - const currentPlatform = Engine.getCurrentPlatform() - const editorExePath = getEditorPath(enginePath, currentPlatform) - // Build command arguments const args = [ - projectFile, - '-run=pythonscript', - `-script="${scriptPath}"`, '-unattended', ] @@ -62,14 +42,5 @@ export const runpython = new Command() args.push(options.args) } - logger.info(`Running Python script: ${scriptPath}`) - logger.debug(`Full command: ${editorExePath} ${args.join(' ')}`) - - try { - const result = await exec(editorExePath, args) - return result - } catch (error: unknown) { - logger.error(`Error running Python script: ${error instanceof Error ? error.message : String(error)}`) - Deno.exit(1) - } + project.runPython(scriptPath, args) }) diff --git a/src/index.ts b/src/index.ts index d714d00..778b4dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,20 +1,17 @@ import { VERSION } from './version.ts' import { debug } from './commands/debug/index.ts' -import { build } from './commands/build.ts' import { engine } from './commands/engine/index.ts' import { init } from './commands/init.ts' import { uat } from './commands/uat.ts' import { ubt } from './commands/ubt.ts' -import { pkg } from './commands/pkg.ts' import { buildgraph } from './commands/buildgraph/index.ts' import { workflow } from './commands/workflow/index.ts' -import { clean } from './commands/clean.ts' import { cmd } from './cmd.ts' import { script } from './commands/script.ts' -import { runpython } from './commands/runpython.ts' import { uasset } from './commands/uasset/index.ts' import { auth } from './commands/auth.ts' +import { project } from './commands/project/index.ts' import { listTargets } from './commands/list-targets.ts' await cmd @@ -26,17 +23,14 @@ await cmd }) .command('init', init) .command('debug', debug) - .command('clean', clean) - .command('build', build) .command('list-targets', listTargets) .command('engine', engine) .command('uat', uat) .command('ubt', ubt) - .command('pkg', pkg) .command('buildgraph', buildgraph) .command('workflow', workflow) .command('script', script) .command('auth', auth) - .command('runpython', runpython) .command('uasset', uasset) + .command('project', project) .parse(Deno.args) diff --git a/src/lib/engine.ts b/src/lib/engine.ts index aedf232..1741b1a 100644 --- a/src/lib/engine.ts +++ b/src/lib/engine.ts @@ -1,7 +1,6 @@ import * as path from '@std/path' -import { globber } from 'globber' -import { copyBuildGraphScripts, exec, findProjectFile } from './utils.ts' -import { Config } from './config.ts' +import { readNdjson } from 'ndjson' +import { exec } from '../lib/utils.ts' interface EngineVersionData { MajorVersion: number @@ -59,7 +58,18 @@ export enum EnginePlatform { Unknown = 'Unknown', } -interface TargetInfo { +export interface AutomationToolLogs { + time: string + level: string + message: string + format: string + properties: Record + id?: number + line?: number + lineCount?: number +} + +export interface TargetInfo { Name: string Path: string Type: string @@ -98,6 +108,8 @@ export abstract class Engine { abstract getGenerateScript(): string abstract getGitDependencesBin(): string abstract parseEngineTargets(): Promise + abstract getEditorBin(): string + abstract getEditorCmdBin(): string getEngineVersionData(): EngineVersionData { const engineVersionFile = path.join( @@ -160,59 +172,15 @@ export abstract class Engine { return await exec(buildScript, args, options) } - async parseProjectTargets(projectPath: string): Promise { - const projectFile = await findProjectFile(projectPath) - const args = ['-Mode=QueryTargets', `-Project=${projectFile}`] - await this.ubt(args, { quiet: true }) + async getAutomationToolLogs(enginePath: string) { + const logJson = path.join(enginePath, 'Engine', 'Programs', 'AutomationTool', 'Saved', 'Logs', 'Log.json') + let logs: AutomationToolLogs[] = [] try { - const targetInfoJson = path.resolve(path.join(projectPath, 'Intermediate', 'TargetInfo.json')) - const { Targets } = JSON.parse(Deno.readTextFileSync(targetInfoJson)) - const targets = Targets.map((target: TargetInfo) => target.Name) - return targets + logs = await readNdjson(logJson) as unknown as AutomationToolLogs[] } catch (e) { - return [] - } - } - - static async runClean({ - dryRun = false, - }: { - dryRun?: boolean - }) { - console.log('[runClean]', { dryRun }) - const config = Config.getInstance() - const binaryGlob = path.join(config.getConfig().project.path, '**/Binaries') - const intermediateGlob = path.join(config.getConfig().project.path, '**/Intermediate') - const cwd = config.getConfig().project?.path - const iterator = globber({ - cwd, - include: [binaryGlob, intermediateGlob], - }) - for await (const file of iterator) { - if (dryRun) { - console.log('Would delete:', file.relative) - continue - } - console.log('Deleting:', file.relative) - await Deno.remove(file.relative) - } - } - - async runBuildGraph(buildGraphScript: string, args: string[] = []) { - let bgScriptPath = path.resolve(buildGraphScript) - if (!bgScriptPath.endsWith('.xml')) { - throw new Error('Invalid buildgraph script') - } - if (path.relative(this.enginePath, bgScriptPath).startsWith('..')) { - console.log('Buildgraph script is outside of engine folder, copying...') - bgScriptPath = await copyBuildGraphScripts(this.enginePath, bgScriptPath) + // pass } - const uatArgs = [ - 'BuildGraph', - `-Script=${bgScriptPath}`, - ...args, - ] - return await this.runUAT(uatArgs) + return logs } } @@ -275,6 +243,26 @@ class WindowsEngine extends Engine { return [] } } + override getEditorBin(): string { + const editorPath = path.join( + this.enginePath, + 'Engine', + 'Binaries', + 'Win64', + 'UnrealEditor.exe', + ) + return editorPath + } + override getEditorCmdBin(): string { + const editorPath = path.join( + this.enginePath, + 'Engine', + 'Binaries', + 'Win64', + 'UnrealEditor-Cmd.exe', + ) + return editorPath + } } class MacosEngine extends Engine { @@ -338,6 +326,26 @@ class MacosEngine extends Engine { return [] } } + override getEditorBin(): string { + const editorPath = path.join( + this.enginePath, + 'Engine', + 'Binaries', + 'Mac', + 'UnrealEditor', + ) + return editorPath + } + override getEditorCmdBin(): string { + const editorPath = path.join( + this.enginePath, + 'Engine', + 'Binaries', + 'Mac', + 'UnrealEditor-Cmd', + ) + return editorPath + } } class LinuxEngine extends Engine { @@ -401,6 +409,26 @@ class LinuxEngine extends Engine { return [] } } + override getEditorBin(): string { + const editorPath = path.join( + this.enginePath, + 'Engine', + 'Binaries', + 'Linux', + 'UnrealEditor', + ) + return editorPath + } + override getEditorCmdBin(): string { + const editorPath = path.join( + this.enginePath, + 'Engine', + 'Binaries', + 'Linux', + 'UnrealEditor-Cmd', + ) + return editorPath + } } // Factory function to create the appropriate Engine instance @@ -450,3 +478,19 @@ export function getEditorPath(enginePath: string, platform: EnginePlatform): str throw new Error(`Unsupported platform: ${platform}`) } } + +/** + * Get the platform-specific cook target + */ +export function getPlatformCookTarget(platform: EnginePlatform): string { + switch (platform) { + case EnginePlatform.Windows: + return 'Windows' + case EnginePlatform.Mac: + return 'MAC' + case EnginePlatform.Linux: + return 'Linux' + default: + throw new Error(`Unsupported platform: ${platform}`) + } +} diff --git a/src/lib/project.ts b/src/lib/project.ts new file mode 100644 index 0000000..5c28ff1 --- /dev/null +++ b/src/lib/project.ts @@ -0,0 +1,338 @@ +import * as path from '@std/path' +import { globber } from 'globber' + +import { ValidationError } from '@cliffy/command' +import { logger } from '../lib/logger.ts' + +import { + createEngine, + Engine, + EngineConfiguration, + EnginePlatform, + EngineTarget, + getPlatformCookTarget, + TargetInfo, +} from '../lib/engine.ts' +import { copyBuildGraphScripts, exec, findProjectFile } from '../lib/utils.ts' + +const TargetError = (target: string, targets: string[]) => { + return new ValidationError(`Invalid Target: ${target} +Valid Targets: ${targets.join(', ')} + `) +} + +const defaultBCRArgs = [ + '-build', + '-cook', + '-stage', + '-package', + '-prereqs', + '-manifests', + '-pak', + '-compressed', + '-nop4', + '-utf8output', + '-nullrhi', + '-unattended', + // Might want to keep these off by default + '-nocompileeditor', + '-skipcompileeditor', + '-nocompile', + '-nocompileuat', + '-nodebuginfo', +] + +const clientBCRArgs = [ + '-client', + ...defaultBCRArgs, +] + +const gameBCRArgs = [ + '-game', + ...defaultBCRArgs, +] + +const serverBCRArgs = [ + '-server', + '-noclient', + ...defaultBCRArgs, +] + +const profiles = { + 'client': clientBCRArgs, + 'game': gameBCRArgs, + 'server': serverBCRArgs, +} + +const profileNameMap = { + client: 'Client', + game: 'Game', + server: 'Server', +} + +interface ProjectFileVars { + projectFullPath: string + projectName: string + projectArgument: string + projectDir: string +} + +export class Project { + public readonly engine: Engine + public readonly projectFileVars: ProjectFileVars + + constructor(enginePath: Engine, projectFileVars: ProjectFileVars) { + this.engine = enginePath + this.projectFileVars = projectFileVars + } + + async compile({ + target = EngineTarget.Editor, + configuration = EngineConfiguration.Development, + extraArgs = [], + dryRun = false, + platform = this.engine.getPlatformName(), + }: { + target?: EngineTarget + configuration?: EngineConfiguration + platform?: EnginePlatform + extraArgs?: string[] + dryRun?: boolean + }) { + const args = [ + this.projectFileVars.projectArgument, + '-NoUBTMakefiles', + '-NoXGE', + '-NoHotReload', + '-NoCodeSign', + '-NoP4', + '-TraceWrites', + ].concat(extraArgs) + + const projectTarget = `${this.projectFileVars.projectName}${target}` + + await this.checkTarget(projectTarget) + await this.engine.runUBT({ + target: projectTarget, + configuration: configuration, + platform: platform, + extraArgs: args, + dryRun: dryRun, + }) + } + + async package({ + configuration = EngineConfiguration.Development, + extraArgs = [], + dryRun = false, + platform = this.engine.getPlatformName(), + zip = false, + profile, + archiveDirectory, + }: { + archiveDirectory: string + profile: string + zip?: boolean + configuration?: EngineConfiguration + platform?: EnginePlatform + extraArgs?: string[] + dryRun?: boolean + }) { + const profileArgs = profiles[profile as keyof typeof profiles] || [] + const bcrArgs = Array.from(new Set([...profileArgs, ...extraArgs])) + + bcrArgs.push(this.projectFileVars.projectArgument) + bcrArgs.push(`-platform=${platform}`) + if (profile === 'server') { + bcrArgs.push(`-serverconfig=${configuration}`) + } + if (profile === 'client') { + bcrArgs.push(`-clientconfig=${configuration}`) + } + if (profile === 'game') { + bcrArgs.push(`-gameconfig=${configuration}`) + } + if (archiveDirectory) { + bcrArgs.push('-archive') + bcrArgs.push(`-archiveDirectory=${archiveDirectory}`) + bcrArgs.push('-archivemetadata') + } + if (dryRun) { + console.log(`[package] package ${profile} ${configuration} ${platform}`) + console.log('[package] BCR args:') + console.log(bcrArgs) + return + } + + await this.engine.runUAT(['BuildCookRun', ...bcrArgs]) + + if (zip) { + // Reverse the EnginePlatform enum to get the build output platform name, ie Win64 => Windows + const platformName = + Object.keys(EnginePlatform)[Object.values(EnginePlatform).indexOf(platform as EnginePlatform)] + const profileName = profileNameMap[profile as keyof typeof profileNameMap] || '' + const archivePath = path.join( + archiveDirectory, + `${this.projectFileVars.projectName}-${profileName}-${platformName}`, + ) + const zipArgs = [ + `-add=${archivePath}`, + `-archive=${archivePath}.zip`, + '-compression=5', + ] + await this.engine.runUAT(['ZipUtils', ...zipArgs]) + } + } + + async compileAndRunEditor({ + extraCompileArgs = [], + extraRunArgs = [], + }: { + extraCompileArgs?: string[] + extraRunArgs?: string[] + }) { + await this.compile({ extraArgs: extraCompileArgs }) + await this.runEditor({ extraArgs: extraRunArgs }) + } + + async runEditor({ + extraArgs = [], + }: { + extraArgs?: string[] + }) { + const args = [ + this.projectFileVars.projectFullPath, + ].concat(extraArgs) + + console.log(`Running editor with: ${this.engine.getEditorBin} ${args.join(' ')}`) + + try { + const result = await exec(this.engine.getEditorBin(), args) + return result + } catch (error: unknown) { + console.log(`Error running Editor: ${error instanceof Error ? error.message : String(error)}`) + Deno.exit(1) + } + } + + async cookContent({ + extraArgs = [], + }: { + extraArgs?: string[] + }) { + const platformTarget = getPlatformCookTarget(this.engine.getPlatformName()) + const args = [ + this.projectFileVars.projectFullPath, + '-run=Cook', + `-targetplatform=${platformTarget}`, + '-fileopenlog', + ].concat(extraArgs) + + console.log(`Running editor with: ${this.engine.getEditorCmdBin} ${args.join(' ')}`) + + try { + const result = await exec(this.engine.getEditorCmdBin(), args) + return result + } catch (error: unknown) { + console.log(`Error running Editor: ${error instanceof Error ? error.message : String(error)}`) + Deno.exit(1) + } + } + + async parseProjectTargets(): Promise { + const args = ['-Mode=QueryTargets', this.projectFileVars.projectArgument] + await this.engine.ubt(args, { quiet: true }) + try { + const targetInfoJson = path.resolve(path.join(this.projectFileVars.projectDir, 'Intermediate', 'TargetInfo.json')) + const { Targets } = JSON.parse(Deno.readTextFileSync(targetInfoJson)) + const targets = Targets.map((target: TargetInfo) => target.Name) + return targets + } catch (e) { + return [] + } + } + + async checkTarget(target: string) { + const validTargets = await this.parseProjectTargets() + if (!validTargets.includes(target)) { + throw TargetError(target, validTargets) + } + } + + async genProjectFiles(args: string[]) { + // Generate project + // GenerateProjectFiles.bat -project=E:\Project\TestProject.uproject -game -engine + await exec(this.engine.getGenerateScript(), [ + this.projectFileVars.projectArgument, + ...args, + ]) + } + + async runClean(dryRun?: boolean) { + const cwd = this.projectFileVars.projectDir + + const iterator = globber({ + cwd, + include: ['**/Binaries/**', '**/Intermediate/**'], + }) + for await (const file of iterator) { + if (dryRun) { + console.log('Would delete:', file.absolute) + continue + } + if (file.isFile) { + console.log('Deleting:', file.absolute) + await Deno.remove(file.absolute) + } + } + } + + async runPython(scriptPath: string, extraArgs: Array) { + const args = [ + '-run=pythonscript', + `-script=${scriptPath}`, + ...extraArgs, + ] + logger.info(`Running Python script: ${scriptPath}`) + await this.runEditor({ extraArgs: args }) + } + + async runBuildGraph(buildGraphScript: string, args: string[] = []) { + let bgScriptPath = path.resolve(buildGraphScript) + if (!bgScriptPath.endsWith('.xml')) { + throw new Error('Invalid buildgraph script') + } + if (path.relative(this.engine.enginePath, bgScriptPath).startsWith('..')) { + console.log('Buildgraph script is outside of engine folder, copying...') + bgScriptPath = await copyBuildGraphScripts(this.engine.enginePath, bgScriptPath) + } + const uatArgs = [ + 'BuildGraph', + `-Script=${bgScriptPath}`, + ...args, + ] + return await this.engine.runUAT(uatArgs) + } +} + +export async function createProject(enginePath: string, projectPath: string): Promise { + const projectFile = await findProjectFile(projectPath).catch(() => null) + + if (projectFile == null) { + console.log(`Could not find project file in path ${projectPath}`) + Deno.exit(1) + } + + const projectFileVars = { + projectFullPath: projectFile, + projectName: path.basename(projectFile, '.uproject'), + projectArgument: `-project=${projectFile}`, + projectDir: path.dirname(projectFile), + } + console.log( + `projectFullPath=${projectFileVars.projectFullPath} projectName=${projectFileVars.projectName} projectArgument=${projectFileVars.projectArgument} projectDir=${projectFileVars.projectDir}`, + ) + const project = new Project(createEngine(enginePath), projectFileVars) + + return Promise.resolve(project) +} diff --git a/src/lib/report.ts b/src/lib/report.ts new file mode 100644 index 0000000..8bcb5d8 --- /dev/null +++ b/src/lib/report.ts @@ -0,0 +1,60 @@ +import { AutomationToolLogs } from '../lib/engine.ts' + +export function generateMarkdownReport(logs: AutomationToolLogs[]): string { + const errorLogs = logs.filter(({ level }) => level === 'Error') + if (errorLogs.length === 0) { + return '# Build Report\n\nNo errors found.' + } + + let markdown = '# Build Error Report\n\n' + + // Group errors by id + const errorGroups = new Map() + + for (const log of errorLogs) { + const groupId = log.id !== undefined ? log.id : 'ungrouped' + if (!errorGroups.has(groupId)) { + errorGroups.set(groupId, []) + } + errorGroups.get(groupId)!.push(log) + } + + markdown += `## Errors (${errorLogs.length})\n\n` + + // Process each group of errors + for (const [groupId, groupLogs] of errorGroups) { + if (groupId !== 'ungrouped') { + markdown += `### Group ID: ${groupId} (${groupLogs.length} errors)\n\n` + } else { + markdown += `### Ungrouped Errors (${groupLogs.length} errors)\n\n` + } + + for (const log of groupLogs) { + markdown += `#### Error: ${log.message}\n` + + if (log.properties?.file) { + const file = log.properties.file.$text + const line = log.properties.line?.$text || log.line + markdown += `- **File**: ${file}${line ? `:${line}` : ''}\n` + } + + if (log.properties?.code) { + markdown += `- **Code**: ${log.properties.code.$text}\n` + } + + if (log.properties?.severity) { + markdown += `- **Severity**: ${log.properties.severity.$text}\n` + } + + markdown += '\n' + } + } + + return markdown +} + +export async function writeMarkdownReport(logs: AutomationToolLogs[], outputPath: string): Promise { + const markdown = generateMarkdownReport(logs) + await Deno.writeTextFile(outputPath, markdown) + console.log(`[BUILDGRAPH RUN] Error report generated: ${outputPath}`) +}