diff --git a/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md index 82aadc69d..fb0b0d77e 100644 --- a/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md @@ -23,6 +23,8 @@ uloop launch [project-path] [options] | `project-path` | string | Optional. Use only when the target Unity project is not in the current directory. | | `-r, --restart` | flag | Kill running Unity and restart | | `-q, --quit` | flag | Kill an existing Unity process for the project without launching | +| `-i, --ignore-compiler-errors` | flag | Continue opening Unity even when the project has compiler errors | +| `--editor-version ` | string | Use this Unity Editor version instead of ProjectVersion.txt | | `-p, --platform

` | string | Build target (e.g., StandaloneOSX, Android, iOS) | | `--max-depth ` | number | Search depth when project-path is omitted (default: 3, -1 for unlimited) | @@ -41,6 +43,12 @@ uloop launch -r # Launch with build target uloop launch -p Android +# Launch even when the project has compiler errors +uloop launch -i + +# Launch with a specific installed Editor version +uloop launch --editor-version 6000.0.0f1 + # Quit running Unity without launching uloop launch --quit ``` @@ -68,4 +76,6 @@ The final JSON payload includes: - If Unity is already running, focuses the existing window and verifies tool readiness - If process scan is blocked (e.g. sandboxed `ps`), plain launch falls back to IPC probing; `--restart` and `--quit` still fail because they need the process id +- `-i, --ignore-compiler-errors` only affects new Unity processes; it has no effect when reusing an already-running Editor +- `--editor-version` only affects new Unity processes; use it with `--restart` to replace an already-running Editor - The command waits until Unity finishes startup and the CLI can connect before returning diff --git a/cli/dispatcher-contract.json b/cli/dispatcher-contract.json index ce80274ef..0f1178cae 100644 --- a/cli/dispatcher-contract.json +++ b/cli/dispatcher-contract.json @@ -1,5 +1,5 @@ { "schemaVersion": 1, - "dispatcherVersion": "3.0.1-beta.3", + "dispatcherVersion": "3.0.1-beta.4", "dispatcherContractVersion": 1 } diff --git a/cli/internal/cli/completion_options.go b/cli/internal/cli/completion_options.go index e243839c6..436057c88 100644 --- a/cli/internal/cli/completion_options.go +++ b/cli/internal/cli/completion_options.go @@ -5,10 +5,13 @@ var nativeCommandOptions = map[string][]string{ launchCommandName: { "--" + projectPathFlagName, "--delete-recovery", + "--editor-version", + "--ignore-compiler-errors", "--max-depth", "--platform", "--quit", "--restart", + "-i", }, installCommandName: {"--" + installDirFlagName}, updateCommandName: {"--" + updateToVersionFlagName}, diff --git a/cli/internal/cli/completion_test.go b/cli/internal/cli/completion_test.go index 541384da0..03a830586 100644 --- a/cli/internal/cli/completion_test.go +++ b/cli/internal/cli/completion_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "strings" "testing" ) @@ -187,8 +188,9 @@ func TestCompletionListOptionsUsesNativeLaunchOptions(t *testing.T) { } output := stdout.String() - for _, option := range []string{"--project-path", "--restart", "--quit", "--delete-recovery", "--platform", "--max-depth"} { - if !strings.Contains(output, option) { + listedOptions := strings.Split(strings.TrimSpace(output), "\n") + for _, option := range []string{"--project-path", "--restart", "--quit", "--delete-recovery", "--platform", "--max-depth", "-i", "--ignore-compiler-errors", "--editor-version"} { + if !slices.Contains(listedOptions, option) { t.Fatalf("launch option %s was not listed: %s", option, output) } } diff --git a/cli/internal/cli/dispatcher_test.go b/cli/internal/cli/dispatcher_test.go index 40374fb04..9407a1513 100644 --- a/cli/internal/cli/dispatcher_test.go +++ b/cli/internal/cli/dispatcher_test.go @@ -155,6 +155,35 @@ func TestRunDispatcherLaunchQuitDoesNotRequireProjectPin(t *testing.T) { } } +func TestRunDispatcherLaunchOptionsDoNotRequireProjectPin(t *testing.T) { + // Verifies dispatcher-owned launch flags are parsed before project pin resolution. + projectRoot := createDispatcherUnityProject(t) + t.Chdir(t.TempDir()) + + previousFinder := findRunningUnityProcessForLaunch + findRunningUnityProcessForLaunch = func(context.Context, string) (*unityProcess, error) { + return nil, nil + } + defer func() { + findRunningUnityProcessForLaunch = previousFinder + }() + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := RunDispatcher( + context.Background(), + []string{"launch", "-i", "--editor-version", "6000.0.0f1", projectRoot, "--quit"}, + &stdout, + &stderr) + + if code != 0 { + t.Fatalf("dispatcher launch failed: code=%d stderr=%s", code, stderr.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"Quit": true`)) { + t.Fatalf("dispatcher launch output mismatch: %s", stdout.String()) + } +} + func TestRunDispatcherVersionUsesDispatcherVersion(t *testing.T) { // Verifies the global launcher reports its own dispatcher release version. t.Chdir(t.TempDir()) diff --git a/cli/internal/cli/help_test.go b/cli/internal/cli/help_test.go index e8a5b0e0d..eb3ef7d6c 100644 --- a/cli/internal/cli/help_test.go +++ b/cli/internal/cli/help_test.go @@ -241,6 +241,8 @@ func TestRunProjectLocalLaunchHelpShowsGlobalOptions(t *testing.T) { for _, expected := range []string{ "Usage:", "uloop launch [options] [project-path]", + "-i, --ignore-compiler-errors", + "--editor-version ", "Global options:", "--project-path ", } { diff --git a/cli/internal/cli/launch.go b/cli/internal/cli/launch.go index d75720152..6f29ce3d4 100644 --- a/cli/internal/cli/launch.go +++ b/cli/internal/cli/launch.go @@ -43,12 +43,14 @@ var ( var editorVersionPattern = regexp.MustCompile(`(?m)^m_EditorVersion:\s*(.+)$`) type launchOptions struct { - projectPath string - restart bool - quit bool - deleteRecovery bool - platform string - maxDepth int + projectPath string + restart bool + quit bool + deleteRecovery bool + ignoreCompilerErrors bool + editorVersion string + platform string + maxDepth int } func tryHandleLaunchRequest( @@ -177,6 +179,10 @@ func handleExistingLaunchProcess( stderr io.Writer, ) (bool, int) { if !options.restart && !options.quit { + if options.editorVersion != "" { + writeClassifiedError(stderr, launchEditorVersionRequiresRestartError(options.editorVersion), errorContext{projectRoot: projectRoot, command: launchCommandName}) + return true, 1 + } return true, waitForExistingLaunchReadiness(ctx, projectRoot, runningProcess.pid, stdout, stderr) } if err := killUnityProcessForLaunch(runningProcess.pid); err != nil { @@ -193,6 +199,18 @@ func handleExistingLaunchProcess( return false, 0 } +func launchEditorVersionRequiresRestartError(editorVersion string) error { + return &argumentError{ + message: "--editor-version requires --restart when Unity is already running for this project.", + option: "--editor-version", + received: editorVersion, + command: launchCommandName, + nextActions: []string{ + fmt.Sprintf("Run `uloop launch --restart --editor-version %s` to relaunch Unity with the requested Editor version.", editorVersion), + }, + } +} + func waitForExistingLaunchReadiness(ctx context.Context, projectRoot string, pid int, stdout io.Writer, stderr io.Writer) int { logLaunchExistingFocus(ctx, projectRoot, pid) spinner := newLaunchSpinner(stdout, stderr) @@ -227,7 +245,13 @@ func startUnityAndWaitForReadiness( writeLine(stdout, "") } - unityPath, err := resolveUnityExecutablePathForLaunch(projectRoot) + unityVersion, err := resolveLaunchEditorVersion(projectRoot, options) + if err != nil { + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName}) + return 1 + } + + unityPath, err := resolveUnityExecutablePathForLaunch(unityVersion) if err != nil { writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName}) return 1 @@ -238,15 +262,10 @@ func startUnityAndWaitForReadiness( writeLine(stdout, "Opening Unity...") writeFormat(stdout, "Project Path: %s\n", projectRoot) - writeFormat(stdout, "Detected Unity version: %s\n", readUnityVersionForLog(projectRoot)) + writeFormat(stdout, "Detected Unity version: %s\n", unityVersion) writeLine(stdout, "Unity Hub launch options: none") - launchArgs := []string{"-projectPath", projectRoot} - if options.platform != "" { - launchArgs = append(launchArgs, "-buildTarget", options.platform) - } - - command := newUnityLaunchCommand(unityPath, launchArgs) + command := newUnityLaunchCommand(unityPath, buildUnityLaunchArgs(projectRoot, options)) if err := command.Start(); err != nil { writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: launchCommandName}) return 1 @@ -273,6 +292,17 @@ func startUnityAndWaitForReadiness( return writeLaunchedReadyResponse(stdout, stderr, projectRoot, previousPid, currentPid) } +func buildUnityLaunchArgs(projectRoot string, options launchOptions) []string { + launchArgs := []string{"-projectPath", projectRoot} + if options.platform != "" { + launchArgs = append(launchArgs, "-buildTarget", options.platform) + } + if options.ignoreCompilerErrors { + launchArgs = append(launchArgs, "-ignorecompilererrors") + } + return launchArgs +} + func newUnityLaunchCommand(unityPath string, launchArgs []string) *exec.Cmd { command := exec.Command(unityPath, launchArgs...) command.Env = append(os.Environ(), "MSYS_NO_PATHCONV=1") @@ -365,16 +395,18 @@ func resolveLaunchProjectRoot(startPath string, options launchOptions) (string, return project.FindUnityProjectRootWithin(startPath, options.maxDepth) } -func resolveUnityExecutablePath(projectRoot string) (string, error) { - version, err := readUnityEditorVersion(projectRoot) - if err != nil { - return "", err - } - +func resolveUnityExecutablePath(version string) (string, error) { candidates := unityExecutableCandidates(version) return resolveExistingUnityExecutablePath(version, candidates) } +func resolveLaunchEditorVersion(projectRoot string, options launchOptions) (string, error) { + if options.editorVersion != "" { + return options.editorVersion, nil + } + return readUnityEditorVersion(projectRoot) +} + func resolveExistingUnityExecutablePath(version string, candidates []string) (string, error) { for _, candidate := range candidates { if _, err := os.Stat(candidate); err == nil { @@ -430,14 +462,6 @@ func readUnityEditorVersion(projectRoot string) (string, error) { return version, nil } -func readUnityVersionForLog(projectRoot string) string { - version, err := readUnityEditorVersion(projectRoot) - if err != nil { - return "unknown" - } - return version -} - func killUnityProcess(pid int) error { process, err := os.FindProcess(pid) if err != nil { @@ -454,6 +478,10 @@ func printLaunchHelp(stdout io.Writer) { writeLine(stdout, " -r, --restart Kill an existing Unity process for the project before launching") writeLine(stdout, " -q, --quit Kill an existing Unity process for the project without launching") writeLine(stdout, " -d, --delete-recovery Delete Assets/_Recovery before launch") + writeLine(stdout, " -i, --ignore-compiler-errors") + writeLine(stdout, " Continue opening Unity even when the project has compiler errors") + writeLine(stdout, " --editor-version ") + writeLine(stdout, " Use this Unity Editor version instead of ProjectVersion.txt") writeLine(stdout, " -p, --platform Pass Unity -buildTarget when launching") writeLine(stdout, " --max-depth Accepted for compatibility when searching from the current directory") writeLine(stdout, "") diff --git a/cli/internal/cli/launch_options.go b/cli/internal/cli/launch_options.go index 284eec285..9621a2479 100644 --- a/cli/internal/cli/launch_options.go +++ b/cli/internal/cli/launch_options.go @@ -17,6 +17,11 @@ func applyLaunchOption(options *launchOptions, args []string, index int) (int, e case arg == "-d" || arg == "--delete-recovery": options.deleteRecovery = true return index, nil + case arg == "-i" || arg == "--ignore-compiler-errors": + options.ignoreCompilerErrors = true + return index, nil + case arg == "--editor-version" || strings.HasPrefix(arg, "--editor-version="): + return applyLaunchEditorVersionOption(options, args, index) case isUnsupportedLaunchHubOption(arg): return index, unsupportedLaunchHubOptionError(arg) case arg == "-p" || arg == "--platform" || strings.HasPrefix(arg, "--platform="): @@ -69,6 +74,15 @@ func applyLaunchPlatformOption(options *launchOptions, args []string, index int) return nextLaunchOptionIndex(index, consumed), nil } +func applyLaunchEditorVersionOption(options *launchOptions, args []string, index int) (int, error) { + value, consumed, err := readLaunchOptionValue(args[index], args, index) + if err != nil { + return index, err + } + options.editorVersion = value + return nextLaunchOptionIndex(index, consumed), nil +} + func applyLaunchMaxDepthOption(options *launchOptions, args []string, index int) (int, error) { value, consumed, err := readLaunchOptionValue(args[index], args, index) if err != nil { diff --git a/cli/internal/cli/launch_test.go b/cli/internal/cli/launch_test.go index c5f772ae2..961e59b00 100644 --- a/cli/internal/cli/launch_test.go +++ b/cli/internal/cli/launch_test.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "testing" "time" @@ -16,10 +17,13 @@ import ( ) func TestParseLaunchOptionsSupportsCoreFlags(t *testing.T) { + // Verifies launch parses every supported core flag without dropping the project path. options, err := parseLaunchOptions( []string{ "--restart", "--delete-recovery", + "--ignore-compiler-errors", + "--editor-version", "6000.0.0f1", "--platform", "Android", "--max-depth", "-1", "/tmp/project", @@ -36,6 +40,12 @@ func TestParseLaunchOptionsSupportsCoreFlags(t *testing.T) { if !options.deleteRecovery { t.Fatal("delete recovery flag was not parsed") } + if !options.ignoreCompilerErrors { + t.Fatal("ignore compiler errors flag was not parsed") + } + if options.editorVersion != "6000.0.0f1" { + t.Fatalf("editor version mismatch: %s", options.editorVersion) + } if options.platform != "Android" { t.Fatalf("platform mismatch: %s", options.platform) } @@ -47,6 +57,80 @@ func TestParseLaunchOptionsSupportsCoreFlags(t *testing.T) { } } +func TestParseLaunchOptionsSupportsShortIgnoreCompilerErrorsFlag(t *testing.T) { + // Verifies -i is the short alias for --ignore-compiler-errors. + options, err := parseLaunchOptions([]string{"-i", "/tmp/project"}, "") + if err != nil { + t.Fatalf("parseLaunchOptions failed: %v", err) + } + + if !options.ignoreCompilerErrors { + t.Fatal("short ignore compiler errors flag was not parsed") + } +} + +func TestBuildUnityLaunchArgsIncludesIgnoreCompilerErrors(t *testing.T) { + // Verifies launch maps --ignore-compiler-errors to Unity Editor's native startup argument. + args := buildUnityLaunchArgs( + "/tmp/project", + launchOptions{platform: "Android", ignoreCompilerErrors: true}, + ) + expectedArgs := []string{ + "-projectPath", + "/tmp/project", + "-buildTarget", + "Android", + "-ignorecompilererrors", + } + + if !slices.Equal(args, expectedArgs) { + t.Fatalf("Unity launch args mismatch: got %#v want %#v", args, expectedArgs) + } +} + +func TestParseLaunchOptionsSupportsEditorVersionEqualsValue(t *testing.T) { + // Verifies --editor-version=value matches Unity CLI's value form. + options, err := parseLaunchOptions([]string{"--editor-version=6000.0.1f1", "/tmp/project"}, "") + if err != nil { + t.Fatalf("parseLaunchOptions failed: %v", err) + } + + if options.editorVersion != "6000.0.1f1" { + t.Fatalf("editor version mismatch: %s", options.editorVersion) + } +} + +func TestParseLaunchOptionsRejectsEmptyEditorVersionEqualsValue(t *testing.T) { + // Verifies --editor-version= cannot silently fall back to ProjectVersion.txt. + _, err := parseLaunchOptions([]string{"--editor-version="}, "") + + if err == nil { + t.Fatal("expected empty editor version value error") + } +} + +func TestParseLaunchOptionsRejectsMissingEditorVersionValue(t *testing.T) { + // Verifies --editor-version requires an explicit Editor version. + _, err := parseLaunchOptions([]string{"--editor-version"}, "") + + if err == nil { + t.Fatal("expected missing editor version value error") + } +} + +func TestResolveLaunchEditorVersionUsesOptionBeforeProjectVersion(t *testing.T) { + // Verifies --editor-version does not require or mutate ProjectVersion.txt. + projectRoot := createLaunchTestProject(t) + + version, err := resolveLaunchEditorVersion(projectRoot, launchOptions{editorVersion: "6000.0.2f1"}) + if err != nil { + t.Fatalf("resolveLaunchEditorVersion failed: %v", err) + } + if version != "6000.0.2f1" { + t.Fatalf("editor version mismatch: %s", version) + } +} + func TestParseLaunchOptionsRejectsUnityHubRegistration(t *testing.T) { _, err := parseLaunchOptions([]string{"--add-unity-hub"}, "") if err == nil { @@ -182,7 +266,7 @@ func TestRunLaunchWritesReadyResponseAfterToolReadiness(t *testing.T) { code := runLaunch( context.Background(), - launchOptions{projectPath: projectRoot}, + launchOptions{projectPath: projectRoot, editorVersion: "6000.0.0f1"}, projectRoot, &stdout, &stderr, @@ -376,6 +460,46 @@ func TestRunLaunchWritesStructuredResponseForExistingUnityProcess(t *testing.T) } } +func TestRunLaunchRequiresRestartForEditorVersionWithExistingUnityProcess(t *testing.T) { + // Verifies --editor-version cannot silently reuse an already running Editor process. + originalFinder := findRunningUnityProcessForLaunch + originalReadinessWait := waitForToolReadinessForLaunch + readinessChecked := false + findRunningUnityProcessForLaunch = func(context.Context, string) (*unityProcess, error) { + return &unityProcess{pid: 222}, nil + } + waitForToolReadinessForLaunch = func(context.Context, string, time.Duration) error { + readinessChecked = true + return nil + } + t.Cleanup(func() { + findRunningUnityProcessForLaunch = originalFinder + waitForToolReadinessForLaunch = originalReadinessWait + }) + + projectRoot := createLaunchTestProject(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := runLaunch( + context.Background(), + launchOptions{projectPath: projectRoot, editorVersion: "6000.0.0f1"}, + projectRoot, + &stdout, + &stderr, + ) + + if code != 1 { + t.Fatalf("exit code mismatch: %d stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if readinessChecked { + t.Fatal("launch should not report an existing process as ready for a different requested Editor version") + } + if !strings.Contains(stderr.String(), "`uloop launch --restart --editor-version 6000.0.0f1`") { + t.Fatalf("stderr should guide restart with the requested version:\n%s", stderr.String()) + } +} + func TestRunLaunchRestartWritesProcessTransitionResponse(t *testing.T) { // Verifies restart reports both the stopped process and the newly launched process. originalFinder := findRunningUnityProcessForLaunch @@ -421,7 +545,7 @@ func TestRunLaunchRestartWritesProcessTransitionResponse(t *testing.T) { code := runLaunch( context.Background(), - launchOptions{projectPath: projectRoot, restart: true}, + launchOptions{projectPath: projectRoot, restart: true, editorVersion: "6000.0.0f1"}, projectRoot, &stdout, &stderr,