From 63f1103d840af36d517d2f2840f73d2ebe2517ff Mon Sep 17 00:00:00 2001 From: frostebite Date: Thu, 7 May 2026 17:11:59 +0100 Subject: [PATCH 1/2] feat(cli): collectUnityLogs + streamUnityLogs flags for unity build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the Orchestrator's Unity diagnostic log collection feature on the standalone CLI. After a Docker build the CLI gathers Editor.log, Unity.Licensing.Client.log, Unity.Entitlements.Audit.log, services-config.json, build report, bee_backend.log, ProjectVersion.txt and Packages/manifest.json into /Logs/UnityDiagnostics/ along with a manifest.json. Live tailing of the Unity -logFile output is also wired in. The orchestrator package owns the canonical, multi-platform path registry (UnityLogCollectorService) — the CLI keeps a slim subset for users running game-ci build directly. The orchestrate flow inherits the full implementation via the @game-ci/orchestrator plugin. Flags: --collectUnityLogs --collectUnityLogsOnSuccess --unityLogCategories --unityLogsIncludeSensitive --unityLogsOutputDir --streamUnityLogs --streamUnityLogPaths Refs: game-ci/unity-builder#740 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/command-options/unity-logs-options.ts | 67 ++++++ src/command/build/unity-build-command.ts | 63 +++++- src/model/unity-logs.ts | 249 ++++++++++++++++++++++ 3 files changed, 375 insertions(+), 4 deletions(-) create mode 100644 src/command-options/unity-logs-options.ts create mode 100644 src/model/unity-logs.ts diff --git a/src/command-options/unity-logs-options.ts b/src/command-options/unity-logs-options.ts new file mode 100644 index 0000000..ca82f82 --- /dev/null +++ b/src/command-options/unity-logs-options.ts @@ -0,0 +1,67 @@ +import type { YargsInstance } from '../dependencies.ts'; +import { IOptions } from './options-interface.ts'; + +/** + * Unity diagnostic log collection options. + * + * Mirrors the orchestrator's `collectUnityLogs` / `streamUnityLogs` inputs so + * the same flags work whether the user runs `game-ci build` directly against + * Docker or `game-ci orchestrate` against a remote provider. + * + * The orchestrator package owns the canonical path registry; the CLI gathers + * the most commonly requested files (Editor.log, Unity.Licensing.Client.log, + * Unity.Entitlements.Audit.log, services-config.json) from the host. + */ +export class UnityLogsOptions implements IOptions { + public static configure(yargs: YargsInstance): void { + yargs + .option('collectUnityLogs', { + description: String.dedent` + Collect Unity-internal logs (Editor.log, Unity.Licensing.Client.log, + Unity.Entitlements.Audit.log, services-config.json, …) into the + workspace after the build so they can be uploaded as artifacts. + Useful when Unity support requests these files.`, + type: 'boolean', + demandOption: false, + default: false, + }) + .option('collectUnityLogsOnSuccess', { + description: 'Also collect Unity logs on successful builds (default true).', + type: 'boolean', + demandOption: false, + default: true, + }) + .option('unityLogCategories', { + description: String.dedent` + Comma-separated subset of categories to collect. Default = all + non-sensitive categories. See orchestrator docs for the full list.`, + type: 'string', + demandOption: false, + default: '', + }) + .option('unityLogsIncludeSensitive', { + description: 'Include sensitive categories like license-file. Off by default.', + type: 'boolean', + demandOption: false, + default: false, + }) + .option('unityLogsOutputDir', { + description: 'Override the directory for collected Unity logs. Default /Logs/UnityDiagnostics.', + type: 'string', + demandOption: false, + default: '', + }) + .option('streamUnityLogs', { + description: 'Live-tail Unity log files during the build and forward each line to stdout.', + type: 'boolean', + demandOption: false, + default: false, + }) + .option('streamUnityLogPaths', { + description: 'Comma-separated files to live-tail. Defaults to /Builds/Logs/Editor.log.', + type: 'string', + demandOption: false, + default: '', + }); + } +} diff --git a/src/command/build/unity-build-command.ts b/src/command/build/unity-build-command.ts index f52ce89..7744584 100644 --- a/src/command/build/unity-build-command.ts +++ b/src/command/build/unity-build-command.ts @@ -2,12 +2,15 @@ import { CommandInterface } from '../command-interface.ts'; import { CacheValidation, Docker, RunnerImageTag, Output } from '../../model/index.ts'; import { PlatformSetup } from '../../model/platform-setup.ts'; import { MacBuilder } from '../../model/mac-builder.ts'; +import { UnityLogs } from '../../model/unity-logs.ts'; +import { path } from '../../dependencies.ts'; import { CommandBase } from '../command-base.ts'; import { UnityOptions } from '../../command-options/unity-options.ts'; import type { YargsInstance, Options } from '../../dependencies.ts'; import { VersioningOptions } from '../../command-options/versioning-options.ts'; import { BuildOptions } from '../../command-options/build-options.ts'; import { AndroidOptions } from '../../command-options/android-options.ts'; +import { UnityLogsOptions } from '../../command-options/unity-logs-options.ts'; import { PlatformValidation } from '../../logic/unity/platform-validation/platform-validation.ts'; import { ProjectOptions } from '../../command-options/project-options.ts'; @@ -22,12 +25,63 @@ export class UnityBuildCommand extends CommandBase implements CommandInterface { if (log.isVerbose) log.debug('Using image:', image); await PlatformSetup.setup(options); - if (hostPlatform === 'darwin') { - await MacBuilder.run(options); - } else { - await Docker.run(image.toString(), options); + + let stopTail: (() => void) | undefined; + if (options.streamUnityLogs) { + const projectDir: string = + (options as any).projectPath || (options as any).workspace || process.cwd(); + const explicit = String(options.streamUnityLogPaths || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const defaults = [path.join(projectDir, 'Builds', 'Logs', 'Editor.log')]; + stopTail = UnityLogs.streamFiles(explicit.length > 0 ? explicit : defaults); + log.info('[UnityLogs] Live log streaming started'); + } + + let buildError: unknown; + let buildSucceeded = true; + try { + if (hostPlatform === 'darwin') { + await MacBuilder.run(options); + } else { + await Docker.run(image.toString(), options); + } + } catch (error) { + buildError = error; + buildSucceeded = false; + } finally { + if (stopTail) { + try { + stopTail(); + } catch { + // ignore + } + } } + if ( + options.collectUnityLogs && + (!buildSucceeded || options.collectUnityLogsOnSuccess !== false) + ) { + try { + const projectDir: string = + (options as any).projectPath || (options as any).workspace || process.cwd(); + const workspace: string = (options as any).workspace || projectDir; + UnityLogs.collect({ + workspace, + projectPath: projectDir, + outputDir: options.unityLogsOutputDir || undefined, + categories: UnityLogs.parseCategories(options.unityLogCategories), + includeSensitive: !!options.unityLogsIncludeSensitive, + }); + } catch (collectError: any) { + log.warning(`[UnityLogs] collection failed: ${collectError.message}`); + } + } + + if (buildError) throw buildError; + await Output.setBuildVersion(options.buildVersion); await Output.setAndroidVersionCode(options.androidVersionCode); @@ -40,5 +94,6 @@ export class UnityBuildCommand extends CommandBase implements CommandInterface { await VersioningOptions.configure(yargs); await BuildOptions.configure(yargs); await AndroidOptions.configure(yargs); + await UnityLogsOptions.configure(yargs); } } diff --git a/src/model/unity-logs.ts b/src/model/unity-logs.ts new file mode 100644 index 0000000..57dc578 --- /dev/null +++ b/src/model/unity-logs.ts @@ -0,0 +1,249 @@ +import { fsSync as fs, path } from '../dependencies.ts'; +import os from 'node:os'; + +/** + * Lightweight Unity log collector for the CLI. + * + * The canonical implementation lives in @game-ci/orchestrator + * (UnityLogCollectorService). The CLI keeps a slim subset that covers the + * categories Unity support most often asks for: Editor.log, + * Unity.Licensing.Client.log, Unity.Entitlements.Audit.log, + * services-config.json, build-report, bee-backend, project-version, + * package-manifest. Users who need the full path registry should run via + * the orchestrator. + */ + +export type UnityLogPlatform = 'linux' | 'darwin' | 'win32'; + +interface PathDef { + category: string; + description: string; + paths: Partial>; + workspaceRelative?: boolean; + sensitive?: boolean; + isDirectory?: boolean; +} + +const PATHS: PathDef[] = [ + { + category: 'editor-log', + description: 'Unity Editor.log', + paths: { + linux: ['$HOME/.config/unity3d/Editor.log'], + darwin: ['$HOME/Library/Logs/Unity/Editor.log'], + win32: ['$LOCALAPPDATA/Unity/Editor/Editor.log'], + }, + }, + { + category: 'licensing-client', + description: 'Unity.Licensing.Client.log', + paths: { + linux: ['$HOME/.config/unity3d/Unity/Unity.Licensing.Client.log'], + darwin: ['$HOME/Library/Logs/Unity/Unity.Licensing.Client.log'], + win32: ['$LOCALAPPDATA/Unity/Unity.Licensing.Client.log'], + }, + }, + { + category: 'entitlements-audit', + description: 'Unity.Entitlements.Audit.log', + paths: { + linux: ['$HOME/.config/unity3d/Unity/Unity.Entitlements.Audit.log'], + darwin: ['$HOME/Library/Logs/Unity/Unity.Entitlements.Audit.log'], + win32: ['$LOCALAPPDATA/Unity/Unity.Entitlements.Audit.log'], + }, + }, + { + category: 'services-config', + description: 'services-config.json', + paths: { + linux: ['/usr/share/unity3d/config/services-config.json'], + darwin: ['/Library/Application Support/Unity/config/services-config.json'], + win32: ['$PROGRAMDATA/Unity/config/services-config.json'], + }, + }, + { + category: 'build-report', + description: 'LastBuild.buildreport', + workspaceRelative: true, + paths: { + linux: ['$PROJECT/Library/LastBuild.buildreport'], + darwin: ['$PROJECT/Library/LastBuild.buildreport'], + win32: ['$PROJECT/Library/LastBuild.buildreport'], + }, + }, + { + category: 'bee-backend', + description: 'bee_backend.log', + workspaceRelative: true, + paths: { + linux: ['$PROJECT/Library/Bee/bee_backend.log'], + darwin: ['$PROJECT/Library/Bee/bee_backend.log'], + win32: ['$PROJECT/Library/Bee/bee_backend.log'], + }, + }, + { + category: 'project-version', + description: 'ProjectVersion.txt', + workspaceRelative: true, + paths: { + linux: ['$PROJECT/ProjectSettings/ProjectVersion.txt'], + darwin: ['$PROJECT/ProjectSettings/ProjectVersion.txt'], + win32: ['$PROJECT/ProjectSettings/ProjectVersion.txt'], + }, + }, + { + category: 'package-manifest', + description: 'Packages/manifest.json + packages-lock.json', + workspaceRelative: true, + paths: { + linux: ['$PROJECT/Packages/manifest.json', '$PROJECT/Packages/packages-lock.json'], + darwin: ['$PROJECT/Packages/manifest.json', '$PROJECT/Packages/packages-lock.json'], + win32: ['$PROJECT/Packages/manifest.json', '$PROJECT/Packages/packages-lock.json'], + }, + }, +]; + +export interface UnityLogsCollectOptions { + workspace: string; + projectPath: string; + outputDir?: string; + categories?: string[]; + includeSensitive?: boolean; + platform?: UnityLogPlatform; + env?: NodeJS.ProcessEnv; +} + +export class UnityLogs { + static collect(options: UnityLogsCollectOptions): { outputDir: string; collected: string[]; missing: string[] } { + const platform: UnityLogPlatform = options.platform || UnityLogs.detectPlatform(); + const env = options.env || process.env; + const projectFullPath = path.isAbsolute(options.projectPath) + ? options.projectPath + : path.join(options.workspace, options.projectPath || ''); + const outputDir = + options.outputDir || path.join(options.workspace, 'Logs', 'UnityDiagnostics'); + + fs.mkdirSync(outputDir, { recursive: true }); + + const filtered = options.categories && options.categories.length > 0 + ? PATHS.filter((definition) => options.categories!.includes(definition.category)) + : PATHS.filter((definition) => !definition.sensitive || options.includeSensitive); + + const tokens: Record = { + HOME: env.HOME || os.homedir(), + USERPROFILE: env.USERPROFILE || os.homedir(), + LOCALAPPDATA: env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), + APPDATA: env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), + PROGRAMDATA: env.PROGRAMDATA || 'C:/ProgramData', + WORKSPACE: options.workspace, + PROJECT: projectFullPath, + }; + + const collected: string[] = []; + const missing: string[] = []; + + for (const definition of filtered) { + const templates = definition.paths[platform] || []; + let foundOne = false; + for (const template of templates) { + const sourcePath = template.replace(/\$([A-Z_]+)/g, (full, name) => + tokens[name] !== undefined ? tokens[name] : full, + ); + if (!fs.existsSync(sourcePath)) continue; + try { + const targetBase = path.join(outputDir, definition.category); + fs.mkdirSync(targetBase, { recursive: true }); + const targetFile = path.join(targetBase, path.basename(sourcePath)); + fs.copyFileSync(sourcePath, targetFile); + collected.push(`${definition.category}: ${sourcePath}`); + foundOne = true; + } catch (error: any) { + log.warning(`[UnityLogs] copy failed for ${definition.category}: ${error.message}`); + } + } + if (!foundOne) missing.push(definition.category); + } + + const manifestPath = path.join(outputDir, 'manifest.json'); + fs.writeFileSync( + manifestPath, + JSON.stringify( + { + generatedAt: new Date().toISOString(), + platform, + projectPath: projectFullPath, + collected, + missing, + }, + null, + 2, + ), + 'utf8', + ); + + log.info(`[UnityLogs] collected ${collected.length} item(s) → ${outputDir}`); + if (missing.length > 0) { + log.info(`[UnityLogs] missing categories: ${missing.join(', ')}`); + } + + return { outputDir, collected, missing }; + } + + /** + * Live-tail a list of files. Returns a stop() function. + */ + static streamFiles(files: string[]): () => void { + const positions = new Map(); + const buffers = new Map(); + let stopped = false; + + const tick = () => { + if (stopped) return; + for (const file of files) { + if (!fs.existsSync(file)) continue; + const stat = fs.statSync(file); + const previous = positions.get(file) ?? 0; + if (stat.size <= previous) { + if (stat.size < previous) positions.set(file, 0); + continue; + } + const buffer = Buffer.alloc(stat.size - previous); + const fd = fs.openSync(file, 'r'); + try { + fs.readSync(fd, buffer, 0, buffer.length, previous); + } finally { + fs.closeSync(fd); + } + positions.set(file, stat.size); + const text = (buffers.get(file) || '') + buffer.toString('utf8'); + const lines = text.split(/\r?\n/); + const last = lines.pop(); + buffers.set(file, last || ''); + for (const line of lines) { + if (line) log.info(`[UnityLogs] ${path.basename(file)}: ${line}`); + } + } + }; + + const interval = setInterval(tick, 1000); + return () => { + stopped = true; + clearInterval(interval); + tick(); + }; + } + + static detectPlatform(): UnityLogPlatform { + if (process.platform === 'darwin') return 'darwin'; + if (process.platform === 'win32') return 'win32'; + return 'linux'; + } + + static parseCategories(input: string | undefined): string[] | undefined { + if (!input) return undefined; + const trimmed = input.trim(); + if (!trimmed || trimmed === 'all') return undefined; + return trimmed.split(',').map((s) => s.trim()).filter(Boolean); + } +} + From 0714fc6f47c030ca6c0392715b211ab6f5a82325 Mon Sep 17 00:00:00 2001 From: frostebite Date: Thu, 7 May 2026 17:26:13 +0100 Subject: [PATCH 2/2] feat(cli): game-ci logs command group (collect, tail, pull, fetch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a discoverable verb-first surface for Unity diagnostic logs: game-ci logs collect [--unityLogCategories ...] [--unityLogsOutputDir ...] game-ci logs tail [--streamUnityLogPaths ...] game-ci logs pull # stub — points at orchestrator roadmap issue game-ci logs fetch # stub — points at orchestrator roadmap issue Replaces the awkward `game-ci orchestrate --providerStrategy local --collectUnityLogs ...` recipe with a one-liner. `logs collect` runs the same UnityLogs collector against the local host, so users can SSH into a self-hosted runner after a failing job and grab the Unity-support bundle without editing the workflow. `logs tail` runs UnityLogs.streamFiles until Ctrl+C and is the standalone counterpart to streamUnityLogs=true on a running build. `logs pull` and `logs fetch` are reserved for the upcoming remote-provider exec and retroactive-fetch follow-ups; for now they print actionable messages pointing users to the existing path. Wired into the unity plugin's command dispatcher. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/command/logs/unity-logs-command.ts | 112 +++++++++++++++++++++++++ src/plugin/builtin/unity-plugin.ts | 13 +++ 2 files changed, 125 insertions(+) create mode 100644 src/command/logs/unity-logs-command.ts diff --git a/src/command/logs/unity-logs-command.ts b/src/command/logs/unity-logs-command.ts new file mode 100644 index 0000000..d5f0271 --- /dev/null +++ b/src/command/logs/unity-logs-command.ts @@ -0,0 +1,112 @@ +import type { YargsArguments, YargsInstance } from '../../dependencies.ts'; +import { CommandBase } from '../command-base.ts'; +import { CommandInterface } from '../command-interface.ts'; +import { UnityLogs } from '../../model/unity-logs.ts'; +import { UnityLogsOptions } from '../../command-options/unity-logs-options.ts'; + +/** + * Top-level dispatch for `game-ci logs `. + * + * Subcommands: + * collect - run the Unity log collector against the local host (or a path) + * tail - live-tail one or more Unity log files + * pull - (stub) collect from a remote runner via the provider interface + * fetch - (stub) fetch logs persisted from a past orchestrator build + */ +export class UnityLogsCommand extends CommandBase implements CommandInterface { + private readonly subCommand: string; + + constructor(commandName: string, subCommand: string) { + super(commandName); + this.subCommand = subCommand || 'collect'; + } + + public async execute(options: YargsArguments): Promise { + switch (this.subCommand) { + case 'collect': + return this.runCollect(options); + case 'tail': + return this.runTail(options); + case 'pull': + log.warning( + '[logs pull] Remote provider pulls are not implemented yet. ' + + 'For now, ssh into the runner and run `game-ci logs collect` directly. ' + + 'Tracking: https://github.com/game-ci/orchestrator/issues', + ); + return false; + case 'fetch': + log.warning( + '[logs fetch] Retroactive fetch from past orchestrator builds is not implemented yet. ' + + 'For now, the diagnostic bundle is uploaded as a build artifact when collectUnityLogs=true. ' + + 'Tracking: https://github.com/game-ci/orchestrator/issues', + ); + return false; + default: + log.error( + `Unknown logs subcommand: ${this.subCommand}. ` + + `Valid subcommands: collect, tail, pull, fetch.`, + ); + return false; + } + } + + public async configureOptions(yargs: YargsInstance): Promise { + yargs + .option('projectPath', { + description: 'Path to the Unity project (defaults to cwd).', + type: 'string', + demandOption: false, + default: '', + }) + .option('workspace', { + description: 'Workspace root (defaults to projectPath or cwd).', + type: 'string', + demandOption: false, + default: '', + }); + await UnityLogsOptions.configure(yargs); + } + + private async runCollect(options: YargsArguments): Promise { + const workspace = (options.workspace as string) || (options.projectPath as string) || process.cwd(); + const projectPath = (options.projectPath as string) || workspace; + const result = UnityLogs.collect({ + workspace, + projectPath, + outputDir: (options.unityLogsOutputDir as string) || undefined, + categories: UnityLogs.parseCategories(options.unityLogCategories as string | undefined), + includeSensitive: !!options.unityLogsIncludeSensitive, + }); + + log.info(`[logs collect] Collected ${result.collected.length} item(s) → ${result.outputDir}`); + if (result.missing.length > 0) { + log.info(`[logs collect] Missing categories on this host: ${result.missing.join(', ')}`); + } + return false; + } + + private async runTail(options: YargsArguments): Promise { + const projectPath = + (options.projectPath as string) || (options.workspace as string) || process.cwd(); + const explicit = String(options.streamUnityLogPaths || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const defaults = [`${projectPath}/Builds/Logs/Editor.log`]; + const files = explicit.length > 0 ? explicit : defaults; + + log.info(`[logs tail] Tailing ${files.length} file(s) — Ctrl+C to stop`); + const stop = UnityLogs.streamFiles(files); + + const onSignal = (): void => { + stop(); + process.exit(0); + }; + process.on('SIGINT', onSignal); + process.on('SIGTERM', onSignal); + + return new Promise(() => { + // Resolves only on signal; tail runs until interrupted. + }); + } +} diff --git a/src/plugin/builtin/unity-plugin.ts b/src/plugin/builtin/unity-plugin.ts index fdc6aa6..316dcdc 100644 --- a/src/plugin/builtin/unity-plugin.ts +++ b/src/plugin/builtin/unity-plugin.ts @@ -2,6 +2,7 @@ import type { GameCIPlugin } from '../plugin-interface.ts'; import { UnityVersionDetector } from '../../middleware/engine-detection/unity-version-detector.ts'; import { UnityBuildCommand } from '../../command/build/unity-build-command.ts'; import { UnityOrchestrateCommand } from '../../command/orchestrate/unity-orchestrate-command.ts'; +import { UnityLogsCommand } from '../../command/logs/unity-logs-command.ts'; import { NonExistentCommand } from '../../command/null/non-existent-command.ts'; import type { CommandInterface } from '../../command/command-interface.ts'; @@ -43,6 +44,18 @@ export const unityPlugin: GameCIPlugin = { default: return new NonExistentCommand([command, ...subCommands].join(' ')); } + case 'logs': + switch (subCommands[0]) { + case 'collect': + case 'tail': + case 'pull': + case 'fetch': + case undefined: + case '': + return new UnityLogsCommand(command, subCommands[0] || 'collect'); + default: + return new NonExistentCommand([command, ...subCommands].join(' ')); + } default: return null; }