From 0c8574df935553c985d544e9c4f32b429d5a9ae9 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 28 Jun 2026 20:58:30 +0900 Subject: [PATCH 1/3] Add launch flag for compiler error startup Allow uloop launch to pass Unity Editor's -ignorecompilererrors argument so projects can open even when compiler errors would otherwise block startup. - Add -i/--ignore-compiler-errors parsing and launch argument construction. - Document the flag in help, completion, and the launch skill. --- .../CliOnlyTools~/Launch/Skill/SKILL.md | 5 +++ cli/internal/cli/completion_options.go | 2 + cli/internal/cli/completion_test.go | 6 ++- cli/internal/cli/help_test.go | 1 + cli/internal/cli/launch.go | 33 +++++++++++------ cli/internal/cli/launch_options.go | 3 ++ cli/internal/cli/launch_test.go | 37 +++++++++++++++++++ 7 files changed, 73 insertions(+), 14 deletions(-) diff --git a/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md index 82aadc69d..cb30efc15 100644 --- a/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md @@ -23,6 +23,7 @@ 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 | | `-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 +42,9 @@ uloop launch -r # Launch with build target uloop launch -p Android +# Launch even when the project has compiler errors +uloop launch -i + # Quit running Unity without launching uloop launch --quit ``` @@ -68,4 +72,5 @@ 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 - The command waits until Unity finishes startup and the CLI can connect before returning diff --git a/cli/internal/cli/completion_options.go b/cli/internal/cli/completion_options.go index e243839c6..2fa882167 100644 --- a/cli/internal/cli/completion_options.go +++ b/cli/internal/cli/completion_options.go @@ -5,10 +5,12 @@ var nativeCommandOptions = map[string][]string{ launchCommandName: { "--" + projectPathFlagName, "--delete-recovery", + "--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..6e6b70358 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"} { + if !slices.Contains(listedOptions, option) { t.Fatalf("launch option %s was not listed: %s", option, output) } } diff --git a/cli/internal/cli/help_test.go b/cli/internal/cli/help_test.go index e8a5b0e0d..7f327dab7 100644 --- a/cli/internal/cli/help_test.go +++ b/cli/internal/cli/help_test.go @@ -241,6 +241,7 @@ func TestRunProjectLocalLaunchHelpShowsGlobalOptions(t *testing.T) { for _, expected := range []string{ "Usage:", "uloop launch [options] [project-path]", + "-i, --ignore-compiler-errors", "Global options:", "--project-path ", } { diff --git a/cli/internal/cli/launch.go b/cli/internal/cli/launch.go index d75720152..f81453f55 100644 --- a/cli/internal/cli/launch.go +++ b/cli/internal/cli/launch.go @@ -43,12 +43,13 @@ 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 + platform string + maxDepth int } func tryHandleLaunchRequest( @@ -241,12 +242,7 @@ func startUnityAndWaitForReadiness( writeFormat(stdout, "Detected Unity version: %s\n", readUnityVersionForLog(projectRoot)) 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 +269,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") @@ -454,6 +461,8 @@ 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, " -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..14d687043 100644 --- a/cli/internal/cli/launch_options.go +++ b/cli/internal/cli/launch_options.go @@ -17,6 +17,9 @@ 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 isUnsupportedLaunchHubOption(arg): return index, unsupportedLaunchHubOptionError(arg) case arg == "-p" || arg == "--platform" || strings.HasPrefix(arg, "--platform="): diff --git a/cli/internal/cli/launch_test.go b/cli/internal/cli/launch_test.go index c5f772ae2..bbbffbe63 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,12 @@ 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", "--platform", "Android", "--max-depth", "-1", "/tmp/project", @@ -36,6 +39,9 @@ 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.platform != "Android" { t.Fatalf("platform mismatch: %s", options.platform) } @@ -47,6 +53,37 @@ 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 TestParseLaunchOptionsRejectsUnityHubRegistration(t *testing.T) { _, err := parseLaunchOptions([]string{"--add-unity-hub"}, "") if err == nil { From 7b82ab31541eea5c83e4cc0e14d0e4fd1538aa4c Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 28 Jun 2026 21:27:39 +0900 Subject: [PATCH 2/3] Add launch editor version override Allow uloop launch to select an installed Unity Editor version without changing ProjectVersion.txt, and advance the dispatcher release metadata because launch is handled by the dispatcher. - Parse and document --editor-version for launch. - Use the override when resolving the Unity executable path. - Cover dispatcher-owned launch options before project pin resolution. --- .../CliOnlyTools~/Launch/Skill/SKILL.md | 5 ++ cli/dispatcher-contract.json | 2 +- cli/internal/cli/completion_options.go | 1 + cli/internal/cli/completion_test.go | 2 +- cli/internal/cli/dispatcher_test.go | 29 +++++++++++ cli/internal/cli/help_test.go | 1 + cli/internal/cli/launch.go | 35 +++++++------ cli/internal/cli/launch_options.go | 11 ++++ cli/internal/cli/launch_test.go | 51 ++++++++++++++++++- 9 files changed, 117 insertions(+), 20 deletions(-) diff --git a/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md index cb30efc15..fb0b0d77e 100644 --- a/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md @@ -24,6 +24,7 @@ uloop launch [project-path] [options] | `-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) | @@ -45,6 +46,9 @@ 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 ``` @@ -73,4 +77,5 @@ 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 2fa882167..436057c88 100644 --- a/cli/internal/cli/completion_options.go +++ b/cli/internal/cli/completion_options.go @@ -5,6 +5,7 @@ var nativeCommandOptions = map[string][]string{ launchCommandName: { "--" + projectPathFlagName, "--delete-recovery", + "--editor-version", "--ignore-compiler-errors", "--max-depth", "--platform", diff --git a/cli/internal/cli/completion_test.go b/cli/internal/cli/completion_test.go index 6e6b70358..03a830586 100644 --- a/cli/internal/cli/completion_test.go +++ b/cli/internal/cli/completion_test.go @@ -189,7 +189,7 @@ func TestCompletionListOptionsUsesNativeLaunchOptions(t *testing.T) { output := stdout.String() listedOptions := strings.Split(strings.TrimSpace(output), "\n") - for _, option := range []string{"--project-path", "--restart", "--quit", "--delete-recovery", "--platform", "--max-depth", "-i", "--ignore-compiler-errors"} { + 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 7f327dab7..eb3ef7d6c 100644 --- a/cli/internal/cli/help_test.go +++ b/cli/internal/cli/help_test.go @@ -242,6 +242,7 @@ func TestRunProjectLocalLaunchHelpShowsGlobalOptions(t *testing.T) { "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 f81453f55..36d99ee94 100644 --- a/cli/internal/cli/launch.go +++ b/cli/internal/cli/launch.go @@ -48,6 +48,7 @@ type launchOptions struct { quit bool deleteRecovery bool ignoreCompilerErrors bool + editorVersion string platform string maxDepth int } @@ -228,7 +229,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 @@ -239,7 +246,7 @@ 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") command := newUnityLaunchCommand(unityPath, buildUnityLaunchArgs(projectRoot, options)) @@ -372,16 +379,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 { @@ -437,14 +446,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 { @@ -463,6 +464,8 @@ func printLaunchHelp(stdout io.Writer) { 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 14d687043..9621a2479 100644 --- a/cli/internal/cli/launch_options.go +++ b/cli/internal/cli/launch_options.go @@ -20,6 +20,8 @@ func applyLaunchOption(options *launchOptions, args []string, index int) (int, e 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="): @@ -72,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 bbbffbe63..594590247 100644 --- a/cli/internal/cli/launch_test.go +++ b/cli/internal/cli/launch_test.go @@ -23,6 +23,7 @@ func TestParseLaunchOptionsSupportsCoreFlags(t *testing.T) { "--restart", "--delete-recovery", "--ignore-compiler-errors", + "--editor-version", "6000.0.0f1", "--platform", "Android", "--max-depth", "-1", "/tmp/project", @@ -42,6 +43,9 @@ func TestParseLaunchOptionsSupportsCoreFlags(t *testing.T) { 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) } @@ -84,6 +88,49 @@ func TestBuildUnityLaunchArgsIncludesIgnoreCompilerErrors(t *testing.T) { } } +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 { @@ -219,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, @@ -458,7 +505,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, From 998b759f799d72890b24816f458b9aed681a9440 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 28 Jun 2026 21:45:07 +0900 Subject: [PATCH 3/3] Require restart for editor version relaunch Prevent uloop launch from reporting an existing Unity process as ready when --editor-version requests a potentially different Editor version. Users must opt into --restart so the requested version can be applied to a new Editor process. - Fail fast when --editor-version is used against an already running project without --restart. - Add a regression test for the existing-process path. --- cli/internal/cli/launch.go | 16 +++++++++++++ cli/internal/cli/launch_test.go | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/cli/internal/cli/launch.go b/cli/internal/cli/launch.go index 36d99ee94..6f29ce3d4 100644 --- a/cli/internal/cli/launch.go +++ b/cli/internal/cli/launch.go @@ -179,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 { @@ -195,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) diff --git a/cli/internal/cli/launch_test.go b/cli/internal/cli/launch_test.go index 594590247..961e59b00 100644 --- a/cli/internal/cli/launch_test.go +++ b/cli/internal/cli/launch_test.go @@ -460,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