From c6f0af7046135d2c2ac9510930f657f005105fd4 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 16 Jul 2026 13:58:20 +0900 Subject: [PATCH 1/8] feat: Identify compatible legacy projects automatically (#1801) --- cli/common/errors/error_envelope.go | 1 + .../dispatcher/dispatcher_v2_detect.go | 176 ++++++++++++++++ .../dispatcher/dispatcher_v2_detect_test.go | 193 ++++++++++++++++++ .../internal/dispatcher/run_dispatcher.go | 23 +++ cli/dispatcher/shared-inputs-stamp.json | 2 +- cli/project-runner/shared-inputs-stamp.json | 2 +- 6 files changed, 395 insertions(+), 2 deletions(-) create mode 100644 cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go create mode 100644 cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go diff --git a/cli/common/errors/error_envelope.go b/cli/common/errors/error_envelope.go index acdbe8033..900db5b27 100644 --- a/cli/common/errors/error_envelope.go +++ b/cli/common/errors/error_envelope.go @@ -23,6 +23,7 @@ const ( errorCodeUnityRPCError = "UNITY_RPC_ERROR" errorCodeUnityServerBusy = "UNITY_SERVER_BUSY" ErrorCodeCLIUpdateRequired = "CLI_UPDATE_REQUIRED" + ErrorCodeV2ProjectDetected = "V2_PROJECT_DETECTED" ErrorCodeToolDisabled = "TOOL_DISABLED" ErrorCodeCompileWaitTimeout = "COMPILE_WAIT_TIMEOUT" ErrorCodeControlPlayModeWaitTimeout = "CONTROL_PLAY_MODE_WAIT_TIMEOUT" diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go new file mode 100644 index 000000000..89ef10523 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go @@ -0,0 +1,176 @@ +package dispatcher + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + sharedversion "github.com/hatayama/unity-cli-loop/common/version" +) + +const ( + dispatcherPackagesManifestRelativePath = "Packages/manifest.json" + dispatcherPackagesLockRelativePath = "Packages/packages-lock.json" + dispatcherPackageJSONFileName = "package.json" + dispatcherV2MajorVersion = "2" +) + +type dispatcherV2Project struct { + IsV2 bool + PackageVersion string +} + +type dispatcherPackagesManifest struct { + Dependencies map[string]json.RawMessage `json:"dependencies"` +} + +type dispatcherPackagesLock struct { + Dependencies map[string]dispatcherPackageLockEntry `json:"dependencies"` +} + +type dispatcherPackageLockEntry struct { + Version string `json:"version"` +} + +type dispatcherPackageJSON struct { + Version string `json:"version"` +} + +// detectV2DispatcherProject identifies pinless Unity projects that use the V2 package. +// Why: V2 projects have no project runner pin, but pin absence alone can also indicate a broken V3 installation. +func detectV2DispatcherProject(projectRoot string) (dispatcherV2Project, error) { + hasPin, err := dispatcherProjectHasPin(projectRoot) + if err != nil { + return dispatcherV2Project{}, err + } + if hasPin { + return dispatcherV2Project{}, nil + } + + hasPackage, err := dispatcherProjectHasUnityPackage(projectRoot) + if err != nil { + return dispatcherV2Project{}, err + } + if !hasPackage { + return dispatcherV2Project{}, nil + } + + packageVersion, found, err := dispatcherV2PackageCacheVersion(projectRoot) + if err != nil { + return dispatcherV2Project{}, err + } + if !found { + packageVersion, found, err = dispatcherV2PackageLockVersion(projectRoot) + if err != nil { + return dispatcherV2Project{}, err + } + } + if !found || !isDispatcherV2PackageVersion(packageVersion) { + return dispatcherV2Project{}, nil + } + + return dispatcherV2Project{IsV2: true, PackageVersion: packageVersion}, nil +} + +func dispatcherProjectHasPin(projectRoot string) (bool, error) { + for _, candidate := range dispatcherPinCandidatePaths(projectRoot) { + _, err := os.Stat(candidate.Path) + if err == nil { + return true, nil + } + if !errors.Is(err, os.ErrNotExist) { + return false, err + } + } + return false, nil +} + +func dispatcherProjectHasUnityPackage(projectRoot string) (bool, error) { + manifestPath := filepath.Join(projectRoot, filepath.FromSlash(dispatcherPackagesManifestRelativePath)) + content, err := os.ReadFile(manifestPath) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + + manifest := dispatcherPackagesManifest{} + if err := json.Unmarshal(content, &manifest); err != nil { + return false, fmt.Errorf("parse %s: %w", manifestPath, err) + } + _, found := manifest.Dependencies[dispatcherUnityPackageName] + return found, nil +} + +func dispatcherV2PackageCacheVersion(projectRoot string) (string, bool, error) { + cacheDirectory := filepath.Join(projectRoot, "Library", "PackageCache") + entries, err := os.ReadDir(cacheDirectory) + if errors.Is(err, os.ErrNotExist) { + return "", false, nil + } + if err != nil { + return "", false, err + } + + prefix := dispatcherUnityPackageName + "@" + for _, entry := range entries { + if !entry.IsDir() || !strings.HasPrefix(entry.Name(), prefix) { + continue + } + packagePath := filepath.Join(cacheDirectory, entry.Name(), dispatcherPackageJSONFileName) + version, err := readDispatcherPackageVersion(packagePath) + if err != nil { + continue + } + if isDispatcherV2PackageVersion(version) { + return version, true, nil + } + } + return "", false, nil +} + +func dispatcherV2PackageLockVersion(projectRoot string) (string, bool, error) { + lockPath := filepath.Join(projectRoot, filepath.FromSlash(dispatcherPackagesLockRelativePath)) + content, err := os.ReadFile(lockPath) + if errors.Is(err, os.ErrNotExist) { + return "", false, nil + } + if err != nil { + return "", false, err + } + + lock := dispatcherPackagesLock{} + if err := json.Unmarshal(content, &lock); err != nil { + return "", false, fmt.Errorf("parse %s: %w", lockPath, err) + } + entry, found := lock.Dependencies[dispatcherUnityPackageName] + if !found { + return "", false, nil + } + return entry.Version, entry.Version != "", nil +} + +func readDispatcherPackageVersion(packagePath string) (string, error) { + content, err := os.ReadFile(packagePath) + if err != nil { + return "", err + } + packageInfo := dispatcherPackageJSON{} + if err := json.Unmarshal(content, &packageInfo); err != nil { + return "", fmt.Errorf("parse %s: %w", packagePath, err) + } + return packageInfo.Version, nil +} + +func isDispatcherV2PackageVersion(version string) bool { + trimmed := strings.TrimSpace(version) + if !sharedversion.IsValid(trimmed) { + return false + } + major, _, _ := strings.Cut(strings.TrimLeft(trimmed, "vV"), ".") + return major == dispatcherV2MajorVersion +} diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go new file mode 100644 index 000000000..889160382 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go @@ -0,0 +1,193 @@ +package dispatcher + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + clierrors "github.com/hatayama/unity-cli-loop/common/errors" +) + +func TestDetectV2DispatcherProjectFindsPackageCacheVersion(t *testing.T) { + // Verifies a V2 package is detected from package.json even when its cache directory has a hash suffix. + projectRoot := createDispatcherUnityProject(t) + writeV2PackageManifest(t, projectRoot) + writeV2PackageCachePackageJSON(t, projectRoot, "abc123", "2.2.0") + + v2Project, err := detectV2DispatcherProject(projectRoot) + if err != nil { + t.Fatalf("detect V2 project: %v", err) + } + if !v2Project.IsV2 { + t.Fatal("expected V2 project") + } + if v2Project.PackageVersion != "2.2.0" { + t.Fatalf("package version = %q, want 2.2.0", v2Project.PackageVersion) + } +} + +func TestDetectV2DispatcherProjectFindsPackageCacheVersionWithBracketedProjectPath(t *testing.T) { + // Verifies PackageCache discovery treats glob characters in project paths literally. + projectRoot := filepath.Join(t.TempDir(), "project[legacy]") + for _, directory := range []string{"Assets", "ProjectSettings"} { + if err := os.MkdirAll(filepath.Join(projectRoot, directory), 0o755); err != nil { + t.Fatalf("create Unity project directory: %v", err) + } + } + writeV2PackageManifest(t, projectRoot) + writeV2PackageCachePackageJSON(t, projectRoot, "abc123", "2.2.0") + + v2Project, err := detectV2DispatcherProject(projectRoot) + if err != nil { + t.Fatalf("detect V2 project: %v", err) + } + if !v2Project.IsV2 { + t.Fatal("expected V2 project") + } +} + +func TestDetectV2DispatcherProjectSkipsInvalidPackageCacheEntry(t *testing.T) { + // Verifies an invalid stale PackageCache entry does not hide another valid V2 package. + projectRoot := createDispatcherUnityProject(t) + writeV2PackageManifest(t, projectRoot) + invalidPackagePath := filepath.Join(projectRoot, "Library", "PackageCache", dispatcherUnityPackageName+"@broken", "package.json") + if err := os.MkdirAll(filepath.Dir(invalidPackagePath), 0o755); err != nil { + t.Fatalf("create invalid package cache: %v", err) + } + if err := os.WriteFile(invalidPackagePath, []byte("{"), 0o644); err != nil { + t.Fatalf("write invalid package.json: %v", err) + } + writeV2PackageCachePackageJSON(t, projectRoot, "valid", "2.2.0") + + v2Project, err := detectV2DispatcherProject(projectRoot) + if err != nil { + t.Fatalf("detect V2 project: %v", err) + } + if !v2Project.IsV2 { + t.Fatal("expected V2 project") + } +} + +func TestDetectV2DispatcherProjectSkipsProjectWithPin(t *testing.T) { + // Verifies a project with a dispatcher pin is not classified as V2. + projectRoot := createDispatcherUnityProject(t) + writeV2PackageManifest(t, projectRoot) + writeV2PackageCachePackageJSON(t, projectRoot, "abc123", "2.2.0") + writeDispatcherProjectPin(t, projectRoot, "3.0.0") + + v2Project, err := detectV2DispatcherProject(projectRoot) + if err != nil { + t.Fatalf("detect V2 project: %v", err) + } + if v2Project.IsV2 { + t.Fatalf("unexpected V2 project: %#v", v2Project) + } +} + +func TestDetectV2DispatcherProjectSkipsProjectWithoutPackage(t *testing.T) { + // Verifies a missing Unity package does not classify a pinless project as V2. + projectRoot := createDispatcherUnityProject(t) + + v2Project, err := detectV2DispatcherProject(projectRoot) + if err != nil { + t.Fatalf("detect V2 project: %v", err) + } + if v2Project.IsV2 { + t.Fatalf("unexpected V2 project: %#v", v2Project) + } +} + +func TestDetectV2DispatcherProjectFindsPackageLockVersionWithoutPackageCache(t *testing.T) { + // Verifies a V2 package is detected from packages-lock.json when PackageCache is unavailable. + projectRoot := createDispatcherUnityProject(t) + writeV2PackageManifest(t, projectRoot) + writeV2PackagesLock(t, projectRoot, "2.2.0") + + v2Project, err := detectV2DispatcherProject(projectRoot) + if err != nil { + t.Fatalf("detect V2 project: %v", err) + } + if !v2Project.IsV2 { + t.Fatal("expected V2 project") + } + if v2Project.PackageVersion != "2.2.0" { + t.Fatalf("package version = %q, want 2.2.0", v2Project.PackageVersion) + } +} + +func TestDetectV2DispatcherProjectSkipsV3PackageLockVersion(t *testing.T) { + // Verifies a non-V2 packages-lock.json version does not classify a project as V2. + projectRoot := createDispatcherUnityProject(t) + writeV2PackageManifest(t, projectRoot) + writeV2PackagesLock(t, projectRoot, "3.0.0") + + v2Project, err := detectV2DispatcherProject(projectRoot) + if err != nil { + t.Fatalf("detect V2 project: %v", err) + } + if v2Project.IsV2 { + t.Fatalf("unexpected V2 project: %#v", v2Project) + } +} + +func TestRunDispatcherReportsV2ProjectGuidanceWhenPinIsMissing(t *testing.T) { + // Verifies pinless V2 projects receive migration guidance instead of the missing-pin error. + projectRoot := createDispatcherUnityProject(t) + writeV2PackageManifest(t, projectRoot) + writeV2PackageCachePackageJSON(t, projectRoot, "abc123", "2.2.0") + t.Chdir(projectRoot) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := RunDispatcher(context.Background(), []string{"compile"}, &stdout, &stderr) + + if code != 1 { + t.Fatalf("exit code = %d, want 1; stderr=%s", code, stderr.String()) + } + envelope := clierrors.CLIErrorEnvelope{} + if err := json.Unmarshal(stderr.Bytes(), &envelope); err != nil { + t.Fatalf("parse error envelope: %v; stderr=%s", err, stderr.String()) + } + if envelope.Error.ErrorCode != clierrors.ErrorCodeV2ProjectDetected { + t.Fatalf("error code = %q, want %q", envelope.Error.ErrorCode, clierrors.ErrorCodeV2ProjectDetected) + } + if len(envelope.Error.NextActions) < 2 { + t.Fatalf("next actions = %#v, want Node and npx guidance", envelope.Error.NextActions) + } +} + +func writeV2PackageManifest(t *testing.T, projectRoot string) { + t.Helper() + manifestPath := filepath.Join(projectRoot, "Packages", "manifest.json") + if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil { + t.Fatalf("create Packages directory: %v", err) + } + content := "{\n \"dependencies\": {\n \"" + dispatcherUnityPackageName + "\": \"https://example.invalid/package.git\"\n }\n}\n" + if err := os.WriteFile(manifestPath, []byte(content), 0o644); err != nil { + t.Fatalf("write manifest: %v", err) + } +} + +func writeV2PackageCachePackageJSON(t *testing.T, projectRoot string, suffix string, version string) { + t.Helper() + packagePath := filepath.Join(projectRoot, "Library", "PackageCache", dispatcherUnityPackageName+"@"+suffix, "package.json") + if err := os.MkdirAll(filepath.Dir(packagePath), 0o755); err != nil { + t.Fatalf("create package cache: %v", err) + } + content := "{\n \"version\": \"" + version + "\"\n}\n" + if err := os.WriteFile(packagePath, []byte(content), 0o644); err != nil { + t.Fatalf("write package.json: %v", err) + } +} + +func writeV2PackagesLock(t *testing.T, projectRoot string, version string) { + t.Helper() + lockPath := filepath.Join(projectRoot, "Packages", "packages-lock.json") + content := "{\n \"dependencies\": {\n \"" + dispatcherUnityPackageName + "\": {\n \"version\": \"" + version + "\"\n }\n }\n}\n" + if err := os.WriteFile(lockPath, []byte(content), 0o644); err != nil { + t.Fatalf("write packages-lock.json: %v", err) + } +} diff --git a/cli/dispatcher/internal/dispatcher/run_dispatcher.go b/cli/dispatcher/internal/dispatcher/run_dispatcher.go index 034ac1ebd..5503e6503 100644 --- a/cli/dispatcher/internal/dispatcher/run_dispatcher.go +++ b/cli/dispatcher/internal/dispatcher/run_dispatcher.go @@ -87,6 +87,11 @@ func runDispatcherWithDeps(ctx context.Context, args []string, stdout io.Writer, pin, err := loadDispatcherPin(projectRoot) if err != nil { + v2Project, detectErr := detectV2DispatcherProject(projectRoot) + if detectErr == nil && v2Project.IsV2 { + clierrors.WriteErrorEnvelope(stderr, dispatcherV2ProjectDetectedError(projectRoot, v2Project.PackageVersion)) + return 1 + } clierrors.WriteErrorEnvelope(stderr, dispatcherPinResolutionError(projectRoot, err)) return 1 } @@ -433,6 +438,24 @@ func dispatcherPinResolutionError(projectRoot string, cause error) clierrors.CLI } } +func dispatcherV2ProjectDetectedError(projectRoot string, packageVersion string) clierrors.CLIError { + return clierrors.CLIError{ + ErrorCode: clierrors.ErrorCodeV2ProjectDetected, + Phase: clierrors.ErrorPhaseProjectResolve, + Message: "This Unity project uses uloop V2 and requires Node.js 22 or later.", + Retryable: true, + SafeToRetry: true, + ProjectRoot: projectRoot, + NextActions: []string{ + "Install Node.js 22 or later, then retry the command.", + "As a last resort, run `npx uloop-cli@" + packageVersion + " ` from this project.", + }, + Details: map[string]any{ + "V2PackageVersion": packageVersion, + }, + } +} + func dispatcherRealCLIResolutionError(projectRoot string, pin dispatcherPin, cause error) clierrors.CLIError { return clierrors.CLIError{ ErrorCode: clierrors.ErrorCodeInternalError, diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index d9887930b..3623ea3ad 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "d59d3b093b55893ccd07b5906f02658f23030bc5" + "sharedInputsHash": "ff3f40c09d8fd92060e19a8c7eed079f9293fb23" } diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index 0f27802f8..ba851addc 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "1d0b11deca98e51fd880037a967a21e881619ed7" + "sharedInputsHash": "358bda67ddca27ff0f3e01a2a9ecd44630cb37ff" } From e0bc7f72d43fc755c61e3a2e7065750b1f231f0a Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 16 Jul 2026 14:11:16 +0900 Subject: [PATCH 2/8] feat: Run compatible legacy projects from one launcher (#1802) --- .../dispatcher/dispatcher_v2_delegate.go | 62 ++++++++++++ .../dispatcher/dispatcher_v2_delegate_test.go | 59 +++++++++++ .../dispatcher/dispatcher_v2_detect_test.go | 97 ++++++++++++++++++- .../dispatcher/dispatcher_v2_install.go | 93 ++++++++++++++++++ .../dispatcher/dispatcher_v2_install_test.go | 86 ++++++++++++++++ .../internal/dispatcher/dispatcher_v2_run.go | 48 +++++++++ .../internal/dispatcher/run_dispatcher.go | 65 ++++++++++--- 7 files changed, 498 insertions(+), 12 deletions(-) create mode 100644 cli/dispatcher/internal/dispatcher/dispatcher_v2_delegate.go create mode 100644 cli/dispatcher/internal/dispatcher/dispatcher_v2_delegate_test.go create mode 100644 cli/dispatcher/internal/dispatcher/dispatcher_v2_install.go create mode 100644 cli/dispatcher/internal/dispatcher/dispatcher_v2_install_test.go create mode 100644 cli/dispatcher/internal/dispatcher/dispatcher_v2_run.go diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_delegate.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_delegate.go new file mode 100644 index 000000000..f0ef9ecd1 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_delegate.go @@ -0,0 +1,62 @@ +package dispatcher + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +const dispatcherNodeCommandName = "node" + +type dispatcherV2CLIPackageJSON struct { + Bin json.RawMessage `json:"bin"` +} + +// resolveDispatcherV2CLIEntrypoint resolves the JavaScript file declared by the installed V2 CLI package. +// Why: executing the package entrypoint directly avoids platform-dependent node_modules/.bin shims. +func resolveDispatcherV2CLIEntrypoint(installPath string) (string, error) { + packageDirectory := filepath.Join(installPath, "node_modules", dispatcherV2CLIPackageName) + packagePath := filepath.Join(packageDirectory, dispatcherPackageJSONFileName) + content, err := os.ReadFile(packagePath) + if err != nil { + return "", err + } + packageInfo := dispatcherV2CLIPackageJSON{} + if err := json.Unmarshal(content, &packageInfo); err != nil { + return "", fmt.Errorf("parse %s: %w", packagePath, err) + } + entrypoint, err := dispatcherV2BinEntrypoint(packageInfo.Bin) + if err != nil { + return "", err + } + if filepath.IsAbs(entrypoint) { + return "", fmt.Errorf("%s bin entrypoint must be relative", dispatcherV2CLIPackageName) + } + return filepath.Join(packageDirectory, entrypoint), nil +} + +func dispatcherV2BinEntrypoint(bin json.RawMessage) (string, error) { + entrypoint := "" + if err := json.Unmarshal(bin, &entrypoint); err == nil { + return entrypoint, nil + } + entries := map[string]string{} + if err := json.Unmarshal(bin, &entries); err != nil { + return "", fmt.Errorf("%s package bin must be a string or object: %w", dispatcherV2CLIPackageName, err) + } + entrypoint, found := entries["uloop"] + if !found || entrypoint == "" { + return "", fmt.Errorf("%s package bin does not define uloop", dispatcherV2CLIPackageName) + } + return entrypoint, nil +} + +func resolveDispatcherV2Node(lookPath func(string) (string, error)) (string, error) { + return lookPath(dispatcherNodeCommandName) +} + +func defaultDispatcherV2NodePath() (string, error) { + return resolveDispatcherV2Node(exec.LookPath) +} diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_delegate_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_delegate_test.go new file mode 100644 index 000000000..5a552b5eb --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_delegate_test.go @@ -0,0 +1,59 @@ +package dispatcher + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveDispatcherV2CLIEntrypointReadsObjectBin(t *testing.T) { + // Verifies the V2 CLI entrypoint is resolved from the published object-form bin declaration. + installPath := t.TempDir() + writeDispatcherV2PackageBin(t, installPath, `{"uloop":"dist/cli.bundle.cjs"}`) + + entrypoint, err := resolveDispatcherV2CLIEntrypoint(installPath) + if err != nil { + t.Fatalf("resolve V2 CLI entrypoint: %v", err) + } + want := filepath.Join(installPath, "node_modules", dispatcherV2CLIPackageName, "dist", "cli.bundle.cjs") + if entrypoint != want { + t.Fatalf("entrypoint = %q, want %q", entrypoint, want) + } +} + +func TestResolveDispatcherV2CLIEntrypointReadsStringBin(t *testing.T) { + // Verifies the V2 CLI entrypoint also supports a string-form bin declaration. + installPath := t.TempDir() + writeDispatcherV2PackageBin(t, installPath, `"dist/cli.bundle.cjs"`) + + entrypoint, err := resolveDispatcherV2CLIEntrypoint(installPath) + if err != nil { + t.Fatalf("resolve V2 CLI entrypoint: %v", err) + } + want := filepath.Join(installPath, "node_modules", dispatcherV2CLIPackageName, "dist", "cli.bundle.cjs") + if entrypoint != want { + t.Fatalf("entrypoint = %q, want %q", entrypoint, want) + } +} + +func TestResolveDispatcherV2NodeReportsMissingNode(t *testing.T) { + // Verifies a missing Node executable is returned to the caller as an error. + _, err := resolveDispatcherV2Node(func(string) (string, error) { + return "", os.ErrNotExist + }) + if err == nil { + t.Fatal("expected missing Node error") + } +} + +func writeDispatcherV2PackageBin(t *testing.T, installPath string, bin string) { + t.Helper() + packagePath := filepath.Join(installPath, "node_modules", dispatcherV2CLIPackageName, dispatcherPackageJSONFileName) + if err := os.MkdirAll(filepath.Dir(packagePath), 0o755); err != nil { + t.Fatalf("create V2 package directory: %v", err) + } + content := "{\n \"bin\": " + bin + "\n}\n" + if err := os.WriteFile(packagePath, []byte(content), 0o644); err != nil { + t.Fatalf("write V2 package.json: %v", err) + } +} diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go index 889160382..08c04dc2e 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "io" "os" "path/filepath" "testing" @@ -142,7 +143,11 @@ func TestRunDispatcherReportsV2ProjectGuidanceWhenPinIsMissing(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := RunDispatcher(context.Background(), []string{"compile"}, &stdout, &stderr) + deps := defaultDispatcherRunDeps() + deps.runV2CLI = func(context.Context, string, []string, io.Writer, io.Writer) (int, error) { + return 0, os.ErrNotExist + } + code := runDispatcherWithDeps(context.Background(), []string{"compile"}, &stdout, &stderr, deps) if code != 1 { t.Fatalf("exit code = %d, want 1; stderr=%s", code, stderr.String()) @@ -159,6 +164,96 @@ func TestRunDispatcherReportsV2ProjectGuidanceWhenPinIsMissing(t *testing.T) { } } +func TestRunDispatcherDelegatesV2ProjectWithOriginalArguments(t *testing.T) { + // Verifies V2 delegation preserves the original argument sequence. + projectRoot := createDispatcherUnityProject(t) + writeV2PackageManifest(t, projectRoot) + writeV2PackageCachePackageJSON(t, projectRoot, "abc123", "2.2.0") + t.Chdir(projectRoot) + deps := defaultDispatcherRunDeps() + var actualVersion string + var actualArgs []string + deps.runV2CLI = func(ctx context.Context, version string, args []string, stdout io.Writer, stderr io.Writer) (int, error) { + actualVersion = version + actualArgs = append([]string{}, args...) + return 7, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runDispatcherWithDeps(context.Background(), []string{"compile", "--project-path", projectRoot}, &stdout, &stderr, deps) + + if code != 7 { + t.Fatalf("exit code = %d, want 7", code) + } + if actualVersion != "2.2.0" { + t.Fatalf("V2 version = %q, want 2.2.0", actualVersion) + } + assertStringSliceEqual(t, actualArgs, []string{"compile", "--project-path", projectRoot}) +} + +func TestRunDispatcherDelegatesBareVersionForV2Project(t *testing.T) { + // Verifies a V2 project receives its own CLI version for the Setup window check. + projectRoot := createDispatcherUnityProject(t) + writeV2PackageManifest(t, projectRoot) + writeV2PackageCachePackageJSON(t, projectRoot, "abc123", "2.2.0") + t.Chdir(projectRoot) + deps := defaultDispatcherRunDeps() + delegated := false + deps.runV2CLI = func(ctx context.Context, version string, args []string, stdout io.Writer, stderr io.Writer) (int, error) { + delegated = true + assertStringSliceEqual(t, args, []string{"--version"}) + return 0, nil + } + + code := runDispatcherWithDeps(context.Background(), []string{"--version"}, io.Discard, io.Discard, deps) + if code != 0 || !delegated { + t.Fatalf("V2 version was not delegated: code=%d delegated=%v", code, delegated) + } +} + +func TestRunDispatcherReturnsDispatcherVersionOutsideProject(t *testing.T) { + // Verifies a version request outside a project remains handled by the dispatcher. + t.Chdir(t.TempDir()) + deps := defaultDispatcherRunDeps() + deps.runV2CLI = func(context.Context, string, []string, io.Writer, io.Writer) (int, error) { + t.Fatal("V2 CLI must not run outside a project") + return 0, nil + } + var stdout bytes.Buffer + code := runDispatcherWithDeps(context.Background(), []string{"--version"}, &stdout, io.Discard, deps) + if code != 0 || stdout.String() != dispatcherVersion+"\n" { + t.Fatalf("dispatcher version output = %q, code=%d", stdout.String(), code) + } +} + +func TestRunDispatcherDelegatesSkillsForV2Project(t *testing.T) { + // Verifies the project-scoped skills command is delegated to the V2 CLI. + projectRoot := createDispatcherUnityProject(t) + writeV2PackageManifest(t, projectRoot) + writeV2PackageCachePackageJSON(t, projectRoot, "abc123", "2.2.0") + t.Chdir(projectRoot) + deps := defaultDispatcherRunDeps() + delegated := false + deps.runV2CLI = func(ctx context.Context, version string, args []string, stdout io.Writer, stderr io.Writer) (int, error) { + delegated = true + assertStringSliceEqual(t, args, []string{"skills", "list"}) + return 0, nil + } + + code := runDispatcherWithDeps(context.Background(), []string{"skills", "list"}, io.Discard, io.Discard, deps) + if code != 0 || !delegated { + t.Fatalf("V2 skills was not delegated: code=%d delegated=%v", code, delegated) + } +} + +func TestShouldKeepDispatcherProcessCommandKeepsUpdate(t *testing.T) { + // Verifies global update remains owned by the V3 dispatcher. + if !shouldKeepDispatcherProcessCommand([]string{"update"}) { + t.Fatal("update must remain in the dispatcher") + } +} + func writeV2PackageManifest(t *testing.T, projectRoot string) { t.Helper() manifestPath := filepath.Join(projectRoot, "Packages", "manifest.json") diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_install.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_install.go new file mode 100644 index 000000000..139ff651a --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_install.go @@ -0,0 +1,93 @@ +package dispatcher + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" +) + +const ( + dispatcherV2CacheDirectoryName = "v2" + dispatcherV2CLIPackageName = "uloop-cli" + dispatcherNPMCommandName = "npm" + dispatcherNPMWindowsCommandName = "npm.cmd" +) + +type dispatcherV2InstallDeps struct { + runCommand func(context.Context, string, []string, io.Writer) error +} + +func defaultDispatcherV2InstallDeps() dispatcherV2InstallDeps { + return dispatcherV2InstallDeps{runCommand: runDispatcherV2InstallCommand} +} + +// installDispatcherV2CLI installs the exact V2 CLI version into its isolated cache directory. +// Why: each Unity package version requires its matching CLI, so a shared cache slot would reinstall on every project switch. +func installDispatcherV2CLI(ctx context.Context, cacheRoot string, version string, goos string, stderr io.Writer, deps dispatcherV2InstallDeps) (string, error) { + installPath := dispatcherV2InstallPath(cacheRoot, version) + if isInstalledDispatcherV2CLI(installPath, version) { + return installPath, nil + } + + parentDirectory := filepath.Dir(installPath) + if err := os.MkdirAll(parentDirectory, 0o755); err != nil { + return "", err + } + temporaryDirectory, err := os.MkdirTemp(parentDirectory, ".install-") + if err != nil { + return "", err + } + defer func() { + _ = os.RemoveAll(temporaryDirectory) + }() + + args := []string{"install", "--prefix", temporaryDirectory, dispatcherV2CLIPackageName + "@" + version} + if err := deps.runCommand(ctx, dispatcherV2NPMCommandName(goos), args, stderr); err != nil { + return "", err + } + if !isInstalledDispatcherV2CLI(temporaryDirectory, version) { + return "", fmt.Errorf("npm did not install %s@%s", dispatcherV2CLIPackageName, version) + } + if err := os.Rename(temporaryDirectory, installPath); err == nil { + return installPath, nil + } + if isInstalledDispatcherV2CLI(installPath, version) { + return installPath, nil + } + if err := os.RemoveAll(installPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return "", err + } + if err := os.Rename(temporaryDirectory, installPath); err != nil { + return "", err + } + return installPath, nil +} + +func dispatcherV2InstallPath(cacheRoot string, version string) string { + return filepath.Join(cacheRoot, dispatcherV2CacheDirectoryName, version) +} + +func isInstalledDispatcherV2CLI(installPath string, version string) bool { + packagePath := filepath.Join(installPath, "node_modules", dispatcherV2CLIPackageName, dispatcherPackageJSONFileName) + installedVersion, err := readDispatcherPackageVersion(packagePath) + return err == nil && installedVersion == version +} + +func dispatcherV2NPMCommandName(goos string) string { + if goos == "windows" { + return dispatcherNPMWindowsCommandName + } + return dispatcherNPMCommandName +} + +func runDispatcherV2InstallCommand(ctx context.Context, name string, args []string, stderr io.Writer) error { + command := exec.CommandContext(ctx, name, args...) + command.Stdout = stderr + command.Stderr = stderr + command.Env = os.Environ() + return command.Run() +} diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_install_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_install_test.go new file mode 100644 index 000000000..0e10852a2 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_install_test.go @@ -0,0 +1,86 @@ +package dispatcher + +import ( + "context" + "io" + "os" + "path/filepath" + "testing" +) + +func TestInstallDispatcherV2CLIInstallsVersionIntoVersionedCache(t *testing.T) { + // Verifies the installer uses npm with an isolated cache directory for the requested version. + cacheRoot := t.TempDir() + var commandName string + var commandArgs []string + deps := dispatcherV2InstallDeps{ + runCommand: func(ctx context.Context, name string, args []string, stderr io.Writer) error { + commandName = name + commandArgs = append([]string{}, args...) + installPath := args[2] + writeInstalledDispatcherV2Package(t, installPath, "2.2.0") + return nil + }, + } + + installPath, err := installDispatcherV2CLI(context.Background(), cacheRoot, "2.2.0", "darwin", io.Discard, deps) + if err != nil { + t.Fatalf("install V2 CLI: %v", err) + } + wantPath := filepath.Join(cacheRoot, dispatcherV2CacheDirectoryName, "2.2.0") + if installPath != wantPath { + t.Fatalf("install path = %q, want %q", installPath, wantPath) + } + if commandName != dispatcherNPMCommandName { + t.Fatalf("command name = %q, want %q", commandName, dispatcherNPMCommandName) + } + if len(commandArgs) != 4 || commandArgs[0] != "install" || commandArgs[1] != "--prefix" || commandArgs[3] != dispatcherV2CLIPackageName+"@2.2.0" { + t.Fatalf("npm arguments = %#v", commandArgs) + } + if !filepath.IsAbs(commandArgs[2]) || filepath.Dir(commandArgs[2]) != filepath.Join(cacheRoot, dispatcherV2CacheDirectoryName) { + t.Fatalf("npm prefix = %q, want temporary directory under version cache", commandArgs[2]) + } + if !isInstalledDispatcherV2CLI(installPath, "2.2.0") { + t.Fatalf("installed V2 CLI missing from %s", installPath) + } +} + +func TestInstallDispatcherV2CLISkipsNPMWhenRequestedVersionIsInstalled(t *testing.T) { + // Verifies an already installed matching version does not invoke npm again. + cacheRoot := t.TempDir() + installPath := filepath.Join(cacheRoot, dispatcherV2CacheDirectoryName, "2.2.0") + writeInstalledDispatcherV2Package(t, installPath, "2.2.0") + deps := dispatcherV2InstallDeps{ + runCommand: func(context.Context, string, []string, io.Writer) error { + t.Fatal("npm must not run for an installed matching V2 CLI") + return nil + }, + } + + actualPath, err := installDispatcherV2CLI(context.Background(), cacheRoot, "2.2.0", "darwin", io.Discard, deps) + if err != nil { + t.Fatalf("install V2 CLI: %v", err) + } + if actualPath != installPath { + t.Fatalf("install path = %q, want %q", actualPath, installPath) + } +} + +func TestDispatcherV2NPMCommandNameUsesCmdOnWindows(t *testing.T) { + // Verifies the Windows npm command uses the cmd shim executable. + if actual := dispatcherV2NPMCommandName("windows"); actual != dispatcherNPMWindowsCommandName { + t.Fatalf("npm command = %q, want %q", actual, dispatcherNPMWindowsCommandName) + } +} + +func writeInstalledDispatcherV2Package(t *testing.T, installPath string, version string) { + t.Helper() + packagePath := filepath.Join(installPath, "node_modules", dispatcherV2CLIPackageName, dispatcherPackageJSONFileName) + if err := os.MkdirAll(filepath.Dir(packagePath), 0o755); err != nil { + t.Fatalf("create installed package directory: %v", err) + } + content := "{\n \"version\": \"" + version + "\"\n}\n" + if err := os.WriteFile(packagePath, []byte(content), 0o644); err != nil { + t.Fatalf("write installed package: %v", err) + } +} diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_run.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_run.go new file mode 100644 index 000000000..f6ecf9bad --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_run.go @@ -0,0 +1,48 @@ +package dispatcher + +import ( + "context" + "errors" + "io" + "os" + "os/exec" + "runtime" +) + +// runDispatcherV2CLI installs and executes the V2 CLI while preserving the original command arguments. +// Why: the dispatcher must select the CLI generation before command parsing can change user-supplied arguments. +func runDispatcherV2CLI(ctx context.Context, version string, args []string, stdout io.Writer, stderr io.Writer) (int, error) { + cacheRoot, err := dispatcherCacheRoot(runtime.GOOS) + if err != nil { + return 0, err + } + installPath, err := installDispatcherV2CLI(ctx, cacheRoot, version, runtime.GOOS, stderr, defaultDispatcherV2InstallDeps()) + if err != nil { + return 0, err + } + entrypoint, err := resolveDispatcherV2CLIEntrypoint(installPath) + if err != nil { + return 0, err + } + nodePath, err := defaultDispatcherV2NodePath() + if err != nil { + return 0, err + } + _, err = io.WriteString(stderr, "uloop: executing in V2 mode\n") + if err != nil { + return 0, err + } + command := exec.CommandContext(ctx, nodePath, append([]string{entrypoint}, args...)...) + command.Stdout = stdout + command.Stderr = stderr + command.Stdin = os.Stdin + command.Env = os.Environ() + if err := command.Run(); err != nil { + var exitError *exec.ExitError + if errors.As(err, &exitError) { + return exitError.ExitCode(), nil + } + return 0, err + } + return 0, nil +} diff --git a/cli/dispatcher/internal/dispatcher/run_dispatcher.go b/cli/dispatcher/internal/dispatcher/run_dispatcher.go index 5503e6503..e4cc49924 100644 --- a/cli/dispatcher/internal/dispatcher/run_dispatcher.go +++ b/cli/dispatcher/internal/dispatcher/run_dispatcher.go @@ -41,6 +41,7 @@ type dispatcherUpdateState struct { type dispatcherRunDeps struct { now func() time.Time runRealCLI func(context.Context, string, []string, io.Writer, io.Writer) int + runV2CLI func(context.Context, string, []string, io.Writer, io.Writer) (int, error) runUpdate func(context.Context) error launch launchDeps } @@ -49,6 +50,7 @@ func defaultDispatcherRunDeps() dispatcherRunDeps { return dispatcherRunDeps{ now: time.Now, runRealCLI: runRealCLICommand, + runV2CLI: runDispatcherV2CLI, runUpdate: runDispatcherUpdateCommand, launch: defaultLaunchDeps(), } @@ -59,18 +61,31 @@ func RunDispatcher(ctx context.Context, args []string, stdout io.Writer, stderr } func runDispatcherWithDeps(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, deps dispatcherRunDeps) int { - if handled, code := tryHandleDispatcherInfoRequest(args, stdout); handled { - return code - } - remainingArgs, projectPath, err := clicore.ParseGlobalProjectPath(args) if err != nil { clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{}) return 1 } + if len(args) == 0 || clicore.IsHelpRequest(remainingArgs) || clicore.IsVersionRequest(remainingArgs) || clicore.IsVersionJSONRequest(remainingArgs) { + if startPath, workingDirectoryErr := os.Getwd(); workingDirectoryErr == nil { + if projectRoot, resolveErr := resolveDispatcherProjectRoot(startPath, projectPath, remainingArgs); resolveErr == nil { + if v2Project, detectErr := detectV2DispatcherProject(projectRoot); detectErr == nil && v2Project.IsV2 { + code, runErr := deps.runV2CLI(ctx, v2Project.PackageVersion, args, stdout, stderr) + if runErr == nil { + return code + } + clierrors.WriteErrorEnvelope(stderr, dispatcherV2ProjectDetectedError(projectRoot, v2Project.PackageVersion)) + return 1 + } + } + } + if handled, code := tryHandleDispatcherInfoRequest(args, stdout); handled { + return code + } + } if shouldRunInDispatcherProcess(remainingArgs) { - return runDispatcherProcessCommandWithDeps(ctx, remainingArgs, projectPath, stdout, stderr, deps) + return runDispatcherProcessCommandWithDeps(ctx, args, remainingArgs, projectPath, stdout, stderr, deps) } startPath, err := os.Getwd() @@ -89,6 +104,10 @@ func runDispatcherWithDeps(ctx context.Context, args []string, stdout io.Writer, if err != nil { v2Project, detectErr := detectV2DispatcherProject(projectRoot) if detectErr == nil && v2Project.IsV2 { + code, runErr := deps.runV2CLI(ctx, v2Project.PackageVersion, args, stdout, stderr) + if runErr == nil { + return code + } clierrors.WriteErrorEnvelope(stderr, dispatcherV2ProjectDetectedError(projectRoot, v2Project.PackageVersion)) return 1 } @@ -115,12 +134,30 @@ func runDispatcherWithDeps(ctx context.Context, args []string, stdout io.Writer, // execution path must not run through RunProjectLocal. func runDispatcherProcessCommandWithDeps( ctx context.Context, + args []string, remainingArgs []string, projectPath string, stdout io.Writer, stderr io.Writer, deps dispatcherRunDeps, ) int { + startPath, err := os.Getwd() + if err != nil { + clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{}) + return 1 + } + if !shouldKeepDispatcherProcessCommand(remainingArgs) { + if projectRoot, resolveErr := resolveDispatcherProjectRoot(startPath, projectPath, remainingArgs); resolveErr == nil { + if v2Project, detectErr := detectV2DispatcherProject(projectRoot); detectErr == nil && v2Project.IsV2 { + code, runErr := deps.runV2CLI(ctx, v2Project.PackageVersion, args, stdout, stderr) + if runErr == nil { + return code + } + clierrors.WriteErrorEnvelope(stderr, dispatcherV2ProjectDetectedError(projectRoot, v2Project.PackageVersion)) + return 1 + } + } + } if handled, code := tryHandleProjectScopeHelpRequest(remainingArgs, projectPath, stdout); handled { return code } @@ -128,12 +165,6 @@ func runDispatcherProcessCommandWithDeps( command := remainingArgs[0] commandArgs := remainingArgs[1:] - startPath, err := os.Getwd() - if err != nil { - clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{Command: command}) - return 1 - } - if handled, code := tryHandlePreConnectionRequestWithDeps( ctx, remainingArgs, @@ -157,6 +188,18 @@ func runDispatcherProcessCommandWithDeps( return 1 } +func shouldKeepDispatcherProcessCommand(args []string) bool { + if len(args) == 0 || clicore.ShouldHandleCompletionRequest(args) { + return true + } + switch args[0] { + case clicore.InstallCommandName, clicore.UpdateCommandName, clicore.UninstallCommandName, clicore.LaunchCommandName: + return true + default: + return false + } +} + func shouldRunInDispatcherProcess(args []string) bool { if len(args) == 0 || clicore.IsHelpRequest(args) || clicore.ContainsHelpRequest(args) { return true From a026ed7d6c419f278555324fd453beb5d7c6984c Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Thu, 16 Jul 2026 16:50:16 +0900 Subject: [PATCH 3/8] docs: Explain V2 and V3 project coexistence (#1803) --- README.md | 16 ++++++++++------ README_ja.md | 18 ++++++++++++------ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a7eac2b59..b24babdb4 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,11 @@ Select Window > Unity CLI Loop > Settings. A dedicated window will open. If the The installer places the global `uloop` dispatcher on PATH. Project-specific `uloop-project-runner` binaries are downloaded into the user cache automatically from each project's `.uloop/project-runner-pin.json`. -To return to the v2 line, press **Uninstall CLI** in Settings, downgrade the U-LOOP package to a v2 version such as `2.1.1`, then press **Install CLI** again from Settings. +Keep the v3 dispatcher installed when working with both v2 and v3 projects. If a Unity project has a v2 `io.github.hatayama.uloopmcp` package and no v3 project-runner pin, the dispatcher automatically installs the matching v2 `uloop-cli` release into its versioned user cache and delegates the command to it. The initial npm installation and the v2-mode notice are written to stderr so stdout remains the delegated command's output. V3 projects continue to use the project runner selected by their pin. + +The global `install`, `update`, `uninstall`, `completion`, and `launch` commands remain owned by the v3 dispatcher in every project. Other project commands, help, and the project-scoped version request are delegated for detected v2 projects. + +V2 delegation requires Node.js 22 or later, including npm for the first command that populates the cache. Do not press **Update CLI** or **Downgrade CLI** in a v2 project's Settings window. These buttons are normally hidden because the delegated CLI reports the matching v2 version, but using one can restore a global npm CLI that hides the v3 dispatcher depending on PATH order.
CLI-only terminal install @@ -128,11 +132,11 @@ If npm is unavailable or the old command belongs to a different Node prefix, the npm uninstall -g uloop-cli ``` -If the Unity UI path is not available or your terminal still resolves `uloop` to the v3 CLI, remove that command first, then install the v2 version you want: +Do not install a v2 CLI globally to switch projects. If your terminal resolves `uloop` to an old npm installation instead of the native dispatcher, remove the npm installation and reinstall the native dispatcher: ```bash -rm -f "$HOME/.local/bin/uloop" -npm install -g uloop-cli@2.1.1 +npm uninstall -g uloop-cli +# Run the verified native installer above again. which uloop uloop --version ``` @@ -140,8 +144,8 @@ uloop --version On Windows PowerShell: ```powershell -Remove-Item "$env:LOCALAPPDATA\Programs\uloop\bin\uloop.exe" -Force -ErrorAction SilentlyContinue -npm install -g uloop-cli@2.1.1 +npm uninstall -g uloop-cli +# Run the verified native installer above again. Get-Command uloop uloop --version ``` diff --git a/README_ja.md b/README_ja.md index c9825fb3a..ee80d433e 100644 --- a/README_ja.md +++ b/README_ja.md @@ -74,7 +74,13 @@ Scope(s): io.github.hatayama.uloopmcp Window > Unity CLI Loop > Settingsを選択します。専用ウィンドウが開くので、**CLI** ボタンが青くなっていなければ **Install CLI** を押してください。 -v2系に戻したい場合は、Settings で **Uninstall CLI** を押し、Unity Package Manager か `manifest.json` で U-LOOP package を `2.1.1` などのv2系のバージョンへ下げてから、もう一度 Settings で **Install CLI** を押してください。 +installerはグローバルな`uloop` dispatcherをPATH上に配置します。プロジェクト固有の`uloop-project-runner` binaryは、各プロジェクトの`.uloop/project-runner-pin.json`に従ってuser cacheへ自動的にdownloadされます。 + +v2とv3のプロジェクトを併用するときも、v3 dispatcherをインストールしたままにしてください。Unityプロジェクトにv2系の`io.github.hatayama.uloopmcp` packageがあり、v3のproject-runner pinがない場合、dispatcherは同じバージョンのv2 `uloop-cli` releaseをバージョン別user cacheへ自動的にインストールし、コマンドを委譲します。初回のnpmインストールとv2モードの注記はstderrへ出力されるため、stdoutには委譲先コマンドの出力だけが残ります。v3プロジェクトはpinで選ばれたproject runnerを引き続き使用します。 + +グローバルな`install`、`update`、`uninstall`、`completion`、`launch`コマンドは、どのプロジェクトでもv3 dispatcherが処理します。検出されたv2プロジェクトでは、それ以外のプロジェクトコマンド、help、プロジェクトスコープのversion表示が委譲されます。 + +v2への委譲には、初回コマンドでcacheを作成するnpmを含むNode.js 22以降が必要です。v2プロジェクトのSettingsウィンドウでは、**Update CLI**または**Downgrade CLI**を押さないでください。委譲先CLIが同じv2バージョンを返すため通常はボタン自体が表示されませんが、使用するとグローバルnpm版CLIが復活し、PATHの順序によってv3 dispatcherが隠れる可能性があります。
CLIだけをterminalからinstallする場合はこちら @@ -125,11 +131,11 @@ npm が見つからない場合や、古い command が別の Node prefix に属 npm uninstall -g uloop-cli ``` -Unity UIから戻せない場合や、ターミナルの `uloop` がまだv3系のCLIを指している場合は、先にその `uloop` コマンドを削除してから、戻したいv2系のバージョンをインストールしてください。 +プロジェクトを切り替えるためにv2 CLIをグローバルインストールしないでください。ターミナルの`uloop`がnative dispatcherではなく古いnpm版を指している場合は、npm版を削除してnative dispatcherを再インストールしてください。 ```bash -rm -f "$HOME/.local/bin/uloop" -npm install -g uloop-cli@2.1.1 +npm uninstall -g uloop-cli +# 上記の検証済みnative installerをもう一度実行します。 which uloop uloop --version ``` @@ -137,8 +143,8 @@ uloop --version Windows PowerShell の場合: ```powershell -Remove-Item "$env:LOCALAPPDATA\Programs\uloop\bin\uloop.exe" -Force -ErrorAction SilentlyContinue -npm install -g uloop-cli@2.1.1 +npm uninstall -g uloop-cli +# 上記の検証済みnative installerをもう一度実行します。 Get-Command uloop uloop --version ``` From 53c84b51067a219120a65757a9724054fcb2dd97 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 16 Jul 2026 17:07:38 +0900 Subject: [PATCH 4/8] fix: Prefer resolved package versions for V2 delegation Use the resolved packages-lock semver before scanning PackageCache so stale cache entries cannot select the wrong legacy CLI. Preserve safe git dependency fallback and report actionable V2 guidance when multiple cached versions make selection ambiguous. --- .../dispatcher/dispatcher_v2_detect.go | 54 ++++++++---- .../dispatcher/dispatcher_v2_detect_test.go | 83 +++++++++++++++++++ .../internal/dispatcher/run_dispatcher.go | 77 +++++++++++------ 3 files changed, 174 insertions(+), 40 deletions(-) diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go index 89ef10523..4acdcb6e5 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" sharedversion "github.com/hatayama/unity-cli-loop/common/version" @@ -19,8 +20,9 @@ const ( ) type dispatcherV2Project struct { - IsV2 bool - PackageVersion string + IsV2 bool + PackageVersion string + PackageVersionCandidates []string } type dispatcherPackagesManifest struct { @@ -58,21 +60,37 @@ func detectV2DispatcherProject(projectRoot string) (dispatcherV2Project, error) return dispatcherV2Project{}, nil } - packageVersion, found, err := dispatcherV2PackageCacheVersion(projectRoot) + packageVersion, found, err := dispatcherV2PackageLockVersion(projectRoot) if err != nil { return dispatcherV2Project{}, err } - if !found { - packageVersion, found, err = dispatcherV2PackageLockVersion(projectRoot) - if err != nil { - return dispatcherV2Project{}, err + if found && sharedversion.IsValid(strings.TrimSpace(packageVersion)) { + if !isDispatcherV2PackageVersion(packageVersion) { + return dispatcherV2Project{}, nil } + return dispatcherV2Project{IsV2: true, PackageVersion: packageVersion}, nil } - if !found || !isDispatcherV2PackageVersion(packageVersion) { + + packageVersions, err := dispatcherPackageCacheVersions(projectRoot) + if err != nil { + return dispatcherV2Project{}, err + } + if len(packageVersions) == 0 { return dispatcherV2Project{}, nil } + if len(packageVersions) == 1 { + if !isDispatcherV2PackageVersion(packageVersions[0]) { + return dispatcherV2Project{}, nil + } + return dispatcherV2Project{IsV2: true, PackageVersion: packageVersions[0]}, nil + } + for _, version := range packageVersions { + if !isDispatcherV2PackageVersion(version) { + return dispatcherV2Project{}, fmt.Errorf("multiple package generations found in %s", filepath.Join(projectRoot, "Library", "PackageCache")) + } + } - return dispatcherV2Project{IsV2: true, PackageVersion: packageVersion}, nil + return dispatcherV2Project{IsV2: true, PackageVersionCandidates: packageVersions}, nil } func dispatcherProjectHasPin(projectRoot string) (bool, error) { @@ -106,17 +124,18 @@ func dispatcherProjectHasUnityPackage(projectRoot string) (bool, error) { return found, nil } -func dispatcherV2PackageCacheVersion(projectRoot string) (string, bool, error) { +func dispatcherPackageCacheVersions(projectRoot string) ([]string, error) { cacheDirectory := filepath.Join(projectRoot, "Library", "PackageCache") entries, err := os.ReadDir(cacheDirectory) if errors.Is(err, os.ErrNotExist) { - return "", false, nil + return nil, nil } if err != nil { - return "", false, err + return nil, err } prefix := dispatcherUnityPackageName + "@" + versions := map[string]struct{}{} for _, entry := range entries { if !entry.IsDir() || !strings.HasPrefix(entry.Name(), prefix) { continue @@ -126,11 +145,16 @@ func dispatcherV2PackageCacheVersion(projectRoot string) (string, bool, error) { if err != nil { continue } - if isDispatcherV2PackageVersion(version) { - return version, true, nil + if sharedversion.IsValid(strings.TrimSpace(version)) { + versions[strings.TrimSpace(version)] = struct{}{} } } - return "", false, nil + result := make([]string, 0, len(versions)) + for version := range versions { + result = append(result, version) + } + sort.Strings(result) + return result, nil } func dispatcherV2PackageLockVersion(projectRoot string) (string, bool, error) { diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go index 08c04dc2e..02063d4ab 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go @@ -72,6 +72,56 @@ func TestDetectV2DispatcherProjectSkipsInvalidPackageCacheEntry(t *testing.T) { } } +func TestDetectV2DispatcherProjectPrefersV2PackageLockVersionOverStaleCache(t *testing.T) { + // Verifies the resolved V2 lock version wins when PackageCache also contains an older version. + projectRoot := createDispatcherUnityProject(t) + writeV2PackageManifest(t, projectRoot) + writeV2PackageCachePackageJSON(t, projectRoot, "aaa-older", "2.1.0") + writeV2PackageCachePackageJSON(t, projectRoot, "zzz-current", "2.2.0") + writeV2PackagesLock(t, projectRoot, "2.2.0") + + v2Project, err := detectV2DispatcherProject(projectRoot) + if err != nil { + t.Fatalf("detect V2 project: %v", err) + } + if !v2Project.IsV2 || v2Project.PackageVersion != "2.2.0" { + t.Fatalf("V2 project = %#v, want package version 2.2.0", v2Project) + } +} + +func TestDetectV2DispatcherProjectPrefersV3PackageLockVersionOverStaleV2Cache(t *testing.T) { + // Verifies a resolved V3 lock version prevents a stale V2 cache entry from causing delegation. + projectRoot := createDispatcherUnityProject(t) + writeV2PackageManifest(t, projectRoot) + writeV2PackageCachePackageJSON(t, projectRoot, "stale", "2.2.0") + writeV2PackagesLock(t, projectRoot, "3.0.0") + + v2Project, err := detectV2DispatcherProject(projectRoot) + if err != nil { + t.Fatalf("detect V2 project: %v", err) + } + if v2Project.IsV2 { + t.Fatalf("unexpected V2 project: %#v", v2Project) + } +} + +func TestDetectV2DispatcherProjectRejectsAmbiguousPackageCacheVersions(t *testing.T) { + // Verifies a git dependency with multiple cached V2 versions is identified without choosing one arbitrarily. + projectRoot := createDispatcherUnityProject(t) + writeV2PackageManifest(t, projectRoot) + writeV2PackageCachePackageJSON(t, projectRoot, "older", "2.1.0") + writeV2PackageCachePackageJSON(t, projectRoot, "newer", "2.2.0") + + v2Project, err := detectV2DispatcherProject(projectRoot) + if err != nil { + t.Fatalf("detect V2 project: %v", err) + } + if !v2Project.IsV2 || v2Project.PackageVersion != "" { + t.Fatalf("ambiguous V2 project = %#v", v2Project) + } + assertStringSliceEqual(t, v2Project.PackageVersionCandidates, []string{"2.1.0", "2.2.0"}) +} + func TestDetectV2DispatcherProjectSkipsProjectWithPin(t *testing.T) { // Verifies a project with a dispatcher pin is not classified as V2. projectRoot := createDispatcherUnityProject(t) @@ -164,6 +214,39 @@ func TestRunDispatcherReportsV2ProjectGuidanceWhenPinIsMissing(t *testing.T) { } } +func TestRunDispatcherReportsVersionResolutionGuidanceForAmbiguousV2Cache(t *testing.T) { + // Verifies ambiguous V2 cache versions produce V2-specific recovery guidance without attempting delegation. + projectRoot := createDispatcherUnityProject(t) + writeV2PackageManifest(t, projectRoot) + writeV2PackageCachePackageJSON(t, projectRoot, "older", "2.1.0") + writeV2PackageCachePackageJSON(t, projectRoot, "newer", "2.2.0") + t.Chdir(projectRoot) + + var stderr bytes.Buffer + deps := defaultDispatcherRunDeps() + deps.runV2CLI = func(context.Context, string, []string, io.Writer, io.Writer) (int, error) { + t.Fatal("ambiguous V2 package version must not be delegated") + return 0, nil + } + code := runDispatcherWithDeps(context.Background(), []string{"compile"}, io.Discard, &stderr, deps) + + if code != 1 { + t.Fatalf("exit code = %d, want 1; stderr=%s", code, stderr.String()) + } + envelope := clierrors.CLIErrorEnvelope{} + if err := json.Unmarshal(stderr.Bytes(), &envelope); err != nil { + t.Fatalf("parse error envelope: %v; stderr=%s", err, stderr.String()) + } + if envelope.Error.ErrorCode != clierrors.ErrorCodeV2ProjectDetected { + t.Fatalf("error code = %q, want %q", envelope.Error.ErrorCode, clierrors.ErrorCodeV2ProjectDetected) + } + for _, expected := range []string{"Packages/packages-lock.json", "npx uloop-cli@2", "2.1.0", "2.2.0"} { + if !bytes.Contains(stderr.Bytes(), []byte(expected)) { + t.Fatalf("V2 recovery guidance missing %q: %s", expected, stderr.String()) + } + } +} + func TestRunDispatcherDelegatesV2ProjectWithOriginalArguments(t *testing.T) { // Verifies V2 delegation preserves the original argument sequence. projectRoot := createDispatcherUnityProject(t) diff --git a/cli/dispatcher/internal/dispatcher/run_dispatcher.go b/cli/dispatcher/internal/dispatcher/run_dispatcher.go index e4cc49924..223c13f34 100644 --- a/cli/dispatcher/internal/dispatcher/run_dispatcher.go +++ b/cli/dispatcher/internal/dispatcher/run_dispatcher.go @@ -69,13 +69,8 @@ func runDispatcherWithDeps(ctx context.Context, args []string, stdout io.Writer, if len(args) == 0 || clicore.IsHelpRequest(remainingArgs) || clicore.IsVersionRequest(remainingArgs) || clicore.IsVersionJSONRequest(remainingArgs) { if startPath, workingDirectoryErr := os.Getwd(); workingDirectoryErr == nil { if projectRoot, resolveErr := resolveDispatcherProjectRoot(startPath, projectPath, remainingArgs); resolveErr == nil { - if v2Project, detectErr := detectV2DispatcherProject(projectRoot); detectErr == nil && v2Project.IsV2 { - code, runErr := deps.runV2CLI(ctx, v2Project.PackageVersion, args, stdout, stderr) - if runErr == nil { - return code - } - clierrors.WriteErrorEnvelope(stderr, dispatcherV2ProjectDetectedError(projectRoot, v2Project.PackageVersion)) - return 1 + if handled, code := tryRunDetectedDispatcherV2Project(ctx, projectRoot, args, stdout, stderr, deps); handled { + return code } } } @@ -102,14 +97,8 @@ func runDispatcherWithDeps(ctx context.Context, args []string, stdout io.Writer, pin, err := loadDispatcherPin(projectRoot) if err != nil { - v2Project, detectErr := detectV2DispatcherProject(projectRoot) - if detectErr == nil && v2Project.IsV2 { - code, runErr := deps.runV2CLI(ctx, v2Project.PackageVersion, args, stdout, stderr) - if runErr == nil { - return code - } - clierrors.WriteErrorEnvelope(stderr, dispatcherV2ProjectDetectedError(projectRoot, v2Project.PackageVersion)) - return 1 + if handled, code := tryRunDetectedDispatcherV2Project(ctx, projectRoot, args, stdout, stderr, deps); handled { + return code } clierrors.WriteErrorEnvelope(stderr, dispatcherPinResolutionError(projectRoot, err)) return 1 @@ -148,13 +137,8 @@ func runDispatcherProcessCommandWithDeps( } if !shouldKeepDispatcherProcessCommand(remainingArgs) { if projectRoot, resolveErr := resolveDispatcherProjectRoot(startPath, projectPath, remainingArgs); resolveErr == nil { - if v2Project, detectErr := detectV2DispatcherProject(projectRoot); detectErr == nil && v2Project.IsV2 { - code, runErr := deps.runV2CLI(ctx, v2Project.PackageVersion, args, stdout, stderr) - if runErr == nil { - return code - } - clierrors.WriteErrorEnvelope(stderr, dispatcherV2ProjectDetectedError(projectRoot, v2Project.PackageVersion)) - return 1 + if handled, code := tryRunDetectedDispatcherV2Project(ctx, projectRoot, args, stdout, stderr, deps); handled { + return code } } } @@ -188,6 +172,31 @@ func runDispatcherProcessCommandWithDeps( return 1 } +func tryRunDetectedDispatcherV2Project( + ctx context.Context, + projectRoot string, + args []string, + stdout io.Writer, + stderr io.Writer, + deps dispatcherRunDeps, +) (bool, int) { + v2Project, err := detectV2DispatcherProject(projectRoot) + if err != nil || !v2Project.IsV2 { + return false, 0 + } + if v2Project.PackageVersion == "" { + clierrors.WriteErrorEnvelope(stderr, dispatcherV2ProjectDetectedError(projectRoot, v2Project)) + return true, 1 + } + + code, err := deps.runV2CLI(ctx, v2Project.PackageVersion, args, stdout, stderr) + if err == nil { + return true, code + } + clierrors.WriteErrorEnvelope(stderr, dispatcherV2ProjectDetectedError(projectRoot, v2Project)) + return true, 1 +} + func shouldKeepDispatcherProcessCommand(args []string) bool { if len(args) == 0 || clicore.ShouldHandleCompletionRequest(args) { return true @@ -481,7 +490,25 @@ func dispatcherPinResolutionError(projectRoot string, cause error) clierrors.CLI } } -func dispatcherV2ProjectDetectedError(projectRoot string, packageVersion string) clierrors.CLIError { +func dispatcherV2ProjectDetectedError(projectRoot string, v2Project dispatcherV2Project) clierrors.CLIError { + if len(v2Project.PackageVersionCandidates) > 0 { + return clierrors.CLIError{ + ErrorCode: clierrors.ErrorCodeV2ProjectDetected, + Phase: clierrors.ErrorPhaseProjectResolve, + Message: "This Unity project uses uloop V2, but its package version could not be resolved unambiguously.", + Retryable: true, + SafeToRetry: true, + ProjectRoot: projectRoot, + NextActions: []string{ + "Open the Unity project once so Unity Package Manager can refresh `Packages/packages-lock.json`, then retry the command.", + "As a last resort, run `npx uloop-cli@2 ` from this project.", + }, + Details: map[string]any{ + "V2PackageVersionCandidates": v2Project.PackageVersionCandidates, + }, + } + } + return clierrors.CLIError{ ErrorCode: clierrors.ErrorCodeV2ProjectDetected, Phase: clierrors.ErrorPhaseProjectResolve, @@ -491,10 +518,10 @@ func dispatcherV2ProjectDetectedError(projectRoot string, packageVersion string) ProjectRoot: projectRoot, NextActions: []string{ "Install Node.js 22 or later, then retry the command.", - "As a last resort, run `npx uloop-cli@" + packageVersion + " ` from this project.", + "As a last resort, run `npx uloop-cli@" + v2Project.PackageVersion + " ` from this project.", }, Details: map[string]any{ - "V2PackageVersion": packageVersion, + "V2PackageVersion": v2Project.PackageVersion, }, } } From 5cb5b60dfe1647c3adf14cf22caa27c407b20153 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 16 Jul 2026 17:20:34 +0900 Subject: [PATCH 5/8] fix: Prefer resolved V2 packages over stale pins Resolve the current Unity package generation before loading project-runner pins so V3 artifacts left after a downgrade cannot suppress V2 delegation. Keep malformed or ambiguous detection fail-open to the existing pinned V3 path, and document the precedence rule. --- README.md | 2 +- README_ja.md | 2 +- .../dispatcher/dispatcher_v2_detect.go | 21 ---- .../dispatcher/dispatcher_v2_detect_test.go | 98 ++++++++++++++++++- .../internal/dispatcher/run_dispatcher.go | 6 +- 5 files changed, 98 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index b24babdb4..a812ecb6e 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Select Window > Unity CLI Loop > Settings. A dedicated window will open. If the The installer places the global `uloop` dispatcher on PATH. Project-specific `uloop-project-runner` binaries are downloaded into the user cache automatically from each project's `.uloop/project-runner-pin.json`. -Keep the v3 dispatcher installed when working with both v2 and v3 projects. If a Unity project has a v2 `io.github.hatayama.uloopmcp` package and no v3 project-runner pin, the dispatcher automatically installs the matching v2 `uloop-cli` release into its versioned user cache and delegates the command to it. The initial npm installation and the v2-mode notice are written to stderr so stdout remains the delegated command's output. V3 projects continue to use the project runner selected by their pin. +Keep the v3 dispatcher installed when working with both v2 and v3 projects. If Unity resolves a project to a v2 `io.github.hatayama.uloopmcp` package, the dispatcher automatically installs the matching v2 `uloop-cli` release into its versioned user cache and delegates the command to it. The resolved package version takes precedence over a stale v3 project-runner pin left after a downgrade. The initial npm installation and the v2-mode notice are written to stderr so stdout remains the delegated command's output. V3 projects continue to use the project runner selected by their pin. The global `install`, `update`, `uninstall`, `completion`, and `launch` commands remain owned by the v3 dispatcher in every project. Other project commands, help, and the project-scoped version request are delegated for detected v2 projects. diff --git a/README_ja.md b/README_ja.md index ee80d433e..893eccc90 100644 --- a/README_ja.md +++ b/README_ja.md @@ -76,7 +76,7 @@ Window > Unity CLI Loop > Settingsを選択します。専用ウィンドウが installerはグローバルな`uloop` dispatcherをPATH上に配置します。プロジェクト固有の`uloop-project-runner` binaryは、各プロジェクトの`.uloop/project-runner-pin.json`に従ってuser cacheへ自動的にdownloadされます。 -v2とv3のプロジェクトを併用するときも、v3 dispatcherをインストールしたままにしてください。Unityプロジェクトにv2系の`io.github.hatayama.uloopmcp` packageがあり、v3のproject-runner pinがない場合、dispatcherは同じバージョンのv2 `uloop-cli` releaseをバージョン別user cacheへ自動的にインストールし、コマンドを委譲します。初回のnpmインストールとv2モードの注記はstderrへ出力されるため、stdoutには委譲先コマンドの出力だけが残ります。v3プロジェクトはpinで選ばれたproject runnerを引き続き使用します。 +v2とv3のプロジェクトを併用するときも、v3 dispatcherをインストールしたままにしてください。Unityがプロジェクトをv2系の`io.github.hatayama.uloopmcp` packageへ解決している場合、dispatcherは同じバージョンのv2 `uloop-cli` releaseをバージョン別user cacheへ自動的にインストールし、コマンドを委譲します。解決済みpackageのバージョンは、downgrade後に残った古いv3 project-runner pinより優先されます。初回のnpmインストールとv2モードの注記はstderrへ出力されるため、stdoutには委譲先コマンドの出力だけが残ります。v3プロジェクトはpinで選ばれたproject runnerを引き続き使用します。 グローバルな`install`、`update`、`uninstall`、`completion`、`launch`コマンドは、どのプロジェクトでもv3 dispatcherが処理します。検出されたv2プロジェクトでは、それ以外のプロジェクトコマンド、help、プロジェクトスコープのversion表示が委譲されます。 diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go index 4acdcb6e5..269675601 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go @@ -44,14 +44,6 @@ type dispatcherPackageJSON struct { // detectV2DispatcherProject identifies pinless Unity projects that use the V2 package. // Why: V2 projects have no project runner pin, but pin absence alone can also indicate a broken V3 installation. func detectV2DispatcherProject(projectRoot string) (dispatcherV2Project, error) { - hasPin, err := dispatcherProjectHasPin(projectRoot) - if err != nil { - return dispatcherV2Project{}, err - } - if hasPin { - return dispatcherV2Project{}, nil - } - hasPackage, err := dispatcherProjectHasUnityPackage(projectRoot) if err != nil { return dispatcherV2Project{}, err @@ -93,19 +85,6 @@ func detectV2DispatcherProject(projectRoot string) (dispatcherV2Project, error) return dispatcherV2Project{IsV2: true, PackageVersionCandidates: packageVersions}, nil } -func dispatcherProjectHasPin(projectRoot string) (bool, error) { - for _, candidate := range dispatcherPinCandidatePaths(projectRoot) { - _, err := os.Stat(candidate.Path) - if err == nil { - return true, nil - } - if !errors.Is(err, os.ErrNotExist) { - return false, err - } - } - return false, nil -} - func dispatcherProjectHasUnityPackage(projectRoot string) (bool, error) { manifestPath := filepath.Join(projectRoot, filepath.FromSlash(dispatcherPackagesManifestRelativePath)) content, err := os.ReadFile(manifestPath) diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go index 02063d4ab..5b241b4f0 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go @@ -10,6 +10,7 @@ import ( "testing" clierrors "github.com/hatayama/unity-cli-loop/common/errors" + "github.com/hatayama/unity-cli-loop/dispatcher/internal/nativepath" ) func TestDetectV2DispatcherProjectFindsPackageCacheVersion(t *testing.T) { @@ -122,19 +123,19 @@ func TestDetectV2DispatcherProjectRejectsAmbiguousPackageCacheVersions(t *testin assertStringSliceEqual(t, v2Project.PackageVersionCandidates, []string{"2.1.0", "2.2.0"}) } -func TestDetectV2DispatcherProjectSkipsProjectWithPin(t *testing.T) { - // Verifies a project with a dispatcher pin is not classified as V2. +func TestDetectV2DispatcherProjectUsesResolvedV2PackageDespiteStalePin(t *testing.T) { + // Verifies a resolved V2 package wins over a stale pin left by a previous V3 package. projectRoot := createDispatcherUnityProject(t) writeV2PackageManifest(t, projectRoot) - writeV2PackageCachePackageJSON(t, projectRoot, "abc123", "2.2.0") + writeV2PackagesLock(t, projectRoot, "2.2.0") writeDispatcherProjectPin(t, projectRoot, "3.0.0") v2Project, err := detectV2DispatcherProject(projectRoot) if err != nil { t.Fatalf("detect V2 project: %v", err) } - if v2Project.IsV2 { - t.Fatalf("unexpected V2 project: %#v", v2Project) + if !v2Project.IsV2 || v2Project.PackageVersion != "2.2.0" { + t.Fatalf("V2 project = %#v, want package version 2.2.0", v2Project) } } @@ -247,6 +248,93 @@ func TestRunDispatcherReportsVersionResolutionGuidanceForAmbiguousV2Cache(t *tes } } +func TestRunDispatcherDelegatesResolvedV2PackageDespiteStalePin(t *testing.T) { + // Verifies a resolved V2 lock version delegates before loading a stale V3 project-runner pin. + projectRoot := createDispatcherUnityProject(t) + writeV2PackageManifest(t, projectRoot) + writeV2PackagesLock(t, projectRoot, "2.2.0") + writeDispatcherProjectPin(t, projectRoot, "3.0.0") + t.Chdir(projectRoot) + + deps := defaultDispatcherRunDeps() + deps.runV2CLI = func(ctx context.Context, version string, args []string, stdout io.Writer, stderr io.Writer) (int, error) { + if version != "2.2.0" { + t.Fatalf("V2 version = %q, want 2.2.0", version) + } + return 7, nil + } + deps.runRealCLI = func(context.Context, string, []string, io.Writer, io.Writer) int { + t.Fatal("stale V3 pin must not run the project runner") + return 0 + } + + code := runDispatcherWithDeps(context.Background(), []string{"compile"}, io.Discard, io.Discard, deps) + if code != 7 { + t.Fatalf("exit code = %d, want 7", code) + } +} + +func TestRunDispatcherForwardsResolvedV3PackageToPinnedRunner(t *testing.T) { + // Verifies a resolved V3 lock version keeps using the pinned project runner. + projectRoot := createDispatcherUnityProject(t) + cacheRoot := t.TempDir() + writeV2PackageManifest(t, projectRoot) + writeV2PackagesLock(t, projectRoot, "3.0.0") + writeDispatcherProjectPin(t, projectRoot, "3.0.0") + writeCachedDispatcherRealCLI(t, cacheRoot, "3.0.0") + t.Setenv(nativepath.CacheDirEnvName, cacheRoot) + t.Setenv(dispatcherDisableSelfUpdateEnvName, "1") + t.Chdir(projectRoot) + + deps := defaultDispatcherRunDeps() + deps.runV2CLI = func(context.Context, string, []string, io.Writer, io.Writer) (int, error) { + t.Fatal("resolved V3 package must not delegate to V2") + return 0, nil + } + forwarded := false + deps.runRealCLI = func(context.Context, string, []string, io.Writer, io.Writer) int { + forwarded = true + return 9 + } + + code := runDispatcherWithDeps(context.Background(), []string{"compile"}, io.Discard, io.Discard, deps) + if code != 9 || !forwarded { + t.Fatalf("V3 runner was not forwarded: code=%d forwarded=%v", code, forwarded) + } +} + +func TestRunDispatcherFallsBackToPinnedRunnerWhenV2DetectionFails(t *testing.T) { + // Verifies a malformed package lock cannot block a healthy pinned V3 runner. + projectRoot := createDispatcherUnityProject(t) + cacheRoot := t.TempDir() + writeV2PackageManifest(t, projectRoot) + lockPath := filepath.Join(projectRoot, filepath.FromSlash(dispatcherPackagesLockRelativePath)) + if err := os.WriteFile(lockPath, []byte("{"), 0o644); err != nil { + t.Fatalf("write malformed packages lock: %v", err) + } + writeDispatcherProjectPin(t, projectRoot, "3.0.0") + writeCachedDispatcherRealCLI(t, cacheRoot, "3.0.0") + t.Setenv(nativepath.CacheDirEnvName, cacheRoot) + t.Setenv(dispatcherDisableSelfUpdateEnvName, "1") + t.Chdir(projectRoot) + + deps := defaultDispatcherRunDeps() + deps.runV2CLI = func(context.Context, string, []string, io.Writer, io.Writer) (int, error) { + t.Fatal("failed V2 detection must not delegate") + return 0, nil + } + forwarded := false + deps.runRealCLI = func(context.Context, string, []string, io.Writer, io.Writer) int { + forwarded = true + return 11 + } + + code := runDispatcherWithDeps(context.Background(), []string{"compile"}, io.Discard, io.Discard, deps) + if code != 11 || !forwarded { + t.Fatalf("V3 runner fallback failed: code=%d forwarded=%v", code, forwarded) + } +} + func TestRunDispatcherDelegatesV2ProjectWithOriginalArguments(t *testing.T) { // Verifies V2 delegation preserves the original argument sequence. projectRoot := createDispatcherUnityProject(t) diff --git a/cli/dispatcher/internal/dispatcher/run_dispatcher.go b/cli/dispatcher/internal/dispatcher/run_dispatcher.go index 223c13f34..67df15e04 100644 --- a/cli/dispatcher/internal/dispatcher/run_dispatcher.go +++ b/cli/dispatcher/internal/dispatcher/run_dispatcher.go @@ -94,12 +94,12 @@ func runDispatcherWithDeps(ctx context.Context, args []string, stdout io.Writer, clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{}) return 1 } + if handled, code := tryRunDetectedDispatcherV2Project(ctx, projectRoot, args, stdout, stderr, deps); handled { + return code + } pin, err := loadDispatcherPin(projectRoot) if err != nil { - if handled, code := tryRunDetectedDispatcherV2Project(ctx, projectRoot, args, stdout, stderr, deps); handled { - return code - } clierrors.WriteErrorEnvelope(stderr, dispatcherPinResolutionError(projectRoot, err)) return 1 } From 669b2261ee496e1c2d26787bd832845be5679ae5 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 16 Jul 2026 17:31:27 +0900 Subject: [PATCH 6/8] fix: Restrict V2 cache fallback to git packages Trust PackageCache only for git dependencies or missing lock entries because local and unknown package sources resolve outside that cache. Fail open to the pinned V3 runner for every other non-semver source, and align the detection comment with resolved-package precedence. --- .../dispatcher/dispatcher_v2_detect.go | 24 +++++---- .../dispatcher/dispatcher_v2_detect_test.go | 49 +++++++++++++++++++ 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go index 269675601..a2881d7db 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go @@ -17,6 +17,7 @@ const ( dispatcherPackagesLockRelativePath = "Packages/packages-lock.json" dispatcherPackageJSONFileName = "package.json" dispatcherV2MajorVersion = "2" + dispatcherPackageLockSourceGit = "git" ) type dispatcherV2Project struct { @@ -35,14 +36,15 @@ type dispatcherPackagesLock struct { type dispatcherPackageLockEntry struct { Version string `json:"version"` + Source string `json:"source"` } type dispatcherPackageJSON struct { Version string `json:"version"` } -// detectV2DispatcherProject identifies pinless Unity projects that use the V2 package. -// Why: V2 projects have no project runner pin, but pin absence alone can also indicate a broken V3 installation. +// detectV2DispatcherProject identifies V2 projects from Unity's currently resolved package state. +// Why: a resolved V2 package must take precedence over stale V3 pins that can remain after a downgrade. func detectV2DispatcherProject(projectRoot string) (dispatcherV2Project, error) { hasPackage, err := dispatcherProjectHasUnityPackage(projectRoot) if err != nil { @@ -52,16 +54,20 @@ func detectV2DispatcherProject(projectRoot string) (dispatcherV2Project, error) return dispatcherV2Project{}, nil } - packageVersion, found, err := dispatcherV2PackageLockVersion(projectRoot) + lockEntry, found, err := dispatcherPackageLockEntryForProject(projectRoot) if err != nil { return dispatcherV2Project{}, err } + packageVersion := lockEntry.Version if found && sharedversion.IsValid(strings.TrimSpace(packageVersion)) { if !isDispatcherV2PackageVersion(packageVersion) { return dispatcherV2Project{}, nil } return dispatcherV2Project{IsV2: true, PackageVersion: packageVersion}, nil } + if found && lockEntry.Source != dispatcherPackageLockSourceGit { + return dispatcherV2Project{}, nil + } packageVersions, err := dispatcherPackageCacheVersions(projectRoot) if err != nil { @@ -136,25 +142,25 @@ func dispatcherPackageCacheVersions(projectRoot string) ([]string, error) { return result, nil } -func dispatcherV2PackageLockVersion(projectRoot string) (string, bool, error) { +func dispatcherPackageLockEntryForProject(projectRoot string) (dispatcherPackageLockEntry, bool, error) { lockPath := filepath.Join(projectRoot, filepath.FromSlash(dispatcherPackagesLockRelativePath)) content, err := os.ReadFile(lockPath) if errors.Is(err, os.ErrNotExist) { - return "", false, nil + return dispatcherPackageLockEntry{}, false, nil } if err != nil { - return "", false, err + return dispatcherPackageLockEntry{}, false, err } lock := dispatcherPackagesLock{} if err := json.Unmarshal(content, &lock); err != nil { - return "", false, fmt.Errorf("parse %s: %w", lockPath, err) + return dispatcherPackageLockEntry{}, false, fmt.Errorf("parse %s: %w", lockPath, err) } entry, found := lock.Dependencies[dispatcherUnityPackageName] if !found { - return "", false, nil + return dispatcherPackageLockEntry{}, false, nil } - return entry.Version, entry.Version != "", nil + return entry, entry.Version != "", nil } func readDispatcherPackageVersion(packagePath string) (string, error) { diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go index 5b241b4f0..cb35e653e 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go @@ -17,6 +17,7 @@ func TestDetectV2DispatcherProjectFindsPackageCacheVersion(t *testing.T) { // Verifies a V2 package is detected from package.json even when its cache directory has a hash suffix. projectRoot := createDispatcherUnityProject(t) writeV2PackageManifest(t, projectRoot) + writePackagesLockWithSource(t, projectRoot, "https://example.invalid/package.git", "git") writeV2PackageCachePackageJSON(t, projectRoot, "abc123", "2.2.0") v2Project, err := detectV2DispatcherProject(projectRoot) @@ -335,6 +336,16 @@ func TestRunDispatcherFallsBackToPinnedRunnerWhenV2DetectionFails(t *testing.T) } } +func TestRunDispatcherKeepsPinnedRunnerForLocalPackageSource(t *testing.T) { + // Verifies a local V3 package cannot be replaced by a stale V2 PackageCache entry. + assertPackageLockSourceKeepsPinnedRunner(t, "file:../v3-package", "local") +} + +func TestRunDispatcherKeepsPinnedRunnerForUnknownPackageSource(t *testing.T) { + // Verifies an unknown package source fails open to the pinned runner instead of stale V2 cache data. + assertPackageLockSourceKeepsPinnedRunner(t, "vendor:v3-package", "future-source") +} + func TestRunDispatcherDelegatesV2ProjectWithOriginalArguments(t *testing.T) { // Verifies V2 delegation preserves the original argument sequence. projectRoot := createDispatcherUnityProject(t) @@ -450,10 +461,48 @@ func writeV2PackageCachePackageJSON(t *testing.T, projectRoot string, suffix str } func writeV2PackagesLock(t *testing.T, projectRoot string, version string) { + t.Helper() + writePackagesLockWithSource(t, projectRoot, version, "") +} + +func writePackagesLockWithSource(t *testing.T, projectRoot string, version string, source string) { t.Helper() lockPath := filepath.Join(projectRoot, "Packages", "packages-lock.json") content := "{\n \"dependencies\": {\n \"" + dispatcherUnityPackageName + "\": {\n \"version\": \"" + version + "\"\n }\n }\n}\n" + if source != "" { + content = "{\n \"dependencies\": {\n \"" + dispatcherUnityPackageName + "\": {\n \"version\": \"" + version + "\",\n \"source\": \"" + source + "\"\n }\n }\n}\n" + } if err := os.WriteFile(lockPath, []byte(content), 0o644); err != nil { t.Fatalf("write packages-lock.json: %v", err) } } + +func assertPackageLockSourceKeepsPinnedRunner(t *testing.T, lockVersion string, source string) { + t.Helper() + projectRoot := createDispatcherUnityProject(t) + cacheRoot := t.TempDir() + writeV2PackageManifest(t, projectRoot) + writePackagesLockWithSource(t, projectRoot, lockVersion, source) + writeV2PackageCachePackageJSON(t, projectRoot, "stale", "2.2.0") + writeDispatcherProjectPin(t, projectRoot, "3.0.0") + writeCachedDispatcherRealCLI(t, cacheRoot, "3.0.0") + t.Setenv(nativepath.CacheDirEnvName, cacheRoot) + t.Setenv(dispatcherDisableSelfUpdateEnvName, "1") + t.Chdir(projectRoot) + + deps := defaultDispatcherRunDeps() + deps.runV2CLI = func(context.Context, string, []string, io.Writer, io.Writer) (int, error) { + t.Fatal("non-git package source must not delegate using stale V2 cache data") + return 0, nil + } + forwarded := false + deps.runRealCLI = func(context.Context, string, []string, io.Writer, io.Writer) int { + forwarded = true + return 13 + } + + code := runDispatcherWithDeps(context.Background(), []string{"compile"}, io.Discard, io.Discard, deps) + if code != 13 || !forwarded { + t.Fatalf("pinned runner was not used: code=%d forwarded=%v", code, forwarded) + } +} From 6dc4c5febc9928618a5f93367646e6d80e5a130d Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 16 Jul 2026 18:36:25 +0900 Subject: [PATCH 7/8] refactor: Move V2 routing out of dispatcher entrypoint Keep the dispatcher entrypoint below the repository architecture limit by colocating V2 detection, delegation, and guidance with the V2 execution path. This is a mechanical responsibility split with no behavior change. --- .../internal/dispatcher/dispatcher_v2_run.go | 63 +++++++++++++++++++ .../internal/dispatcher/run_dispatcher.go | 61 ------------------ 2 files changed, 63 insertions(+), 61 deletions(-) diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_run.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_run.go index f6ecf9bad..54220ebf4 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_v2_run.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_run.go @@ -7,8 +7,71 @@ import ( "os" "os/exec" "runtime" + + clierrors "github.com/hatayama/unity-cli-loop/common/errors" ) +func tryRunDetectedDispatcherV2Project( + ctx context.Context, + projectRoot string, + args []string, + stdout io.Writer, + stderr io.Writer, + deps dispatcherRunDeps, +) (bool, int) { + v2Project, err := detectV2DispatcherProject(projectRoot) + if err != nil || !v2Project.IsV2 { + return false, 0 + } + if v2Project.PackageVersion == "" { + clierrors.WriteErrorEnvelope(stderr, dispatcherV2ProjectDetectedError(projectRoot, v2Project)) + return true, 1 + } + + code, err := deps.runV2CLI(ctx, v2Project.PackageVersion, args, stdout, stderr) + if err == nil { + return true, code + } + clierrors.WriteErrorEnvelope(stderr, dispatcherV2ProjectDetectedError(projectRoot, v2Project)) + return true, 1 +} + +func dispatcherV2ProjectDetectedError(projectRoot string, v2Project dispatcherV2Project) clierrors.CLIError { + if len(v2Project.PackageVersionCandidates) > 0 { + return clierrors.CLIError{ + ErrorCode: clierrors.ErrorCodeV2ProjectDetected, + Phase: clierrors.ErrorPhaseProjectResolve, + Message: "This Unity project uses uloop V2, but its package version could not be resolved unambiguously.", + Retryable: true, + SafeToRetry: true, + ProjectRoot: projectRoot, + NextActions: []string{ + "Open the Unity project once so Unity Package Manager can refresh `Packages/packages-lock.json`, then retry the command.", + "As a last resort, run `npx uloop-cli@2 ` from this project.", + }, + Details: map[string]any{ + "V2PackageVersionCandidates": v2Project.PackageVersionCandidates, + }, + } + } + + return clierrors.CLIError{ + ErrorCode: clierrors.ErrorCodeV2ProjectDetected, + Phase: clierrors.ErrorPhaseProjectResolve, + Message: "This Unity project uses uloop V2 and requires Node.js 22 or later.", + Retryable: true, + SafeToRetry: true, + ProjectRoot: projectRoot, + NextActions: []string{ + "Install Node.js 22 or later, then retry the command.", + "As a last resort, run `npx uloop-cli@" + v2Project.PackageVersion + " ` from this project.", + }, + Details: map[string]any{ + "V2PackageVersion": v2Project.PackageVersion, + }, + } +} + // runDispatcherV2CLI installs and executes the V2 CLI while preserving the original command arguments. // Why: the dispatcher must select the CLI generation before command parsing can change user-supplied arguments. func runDispatcherV2CLI(ctx context.Context, version string, args []string, stdout io.Writer, stderr io.Writer) (int, error) { diff --git a/cli/dispatcher/internal/dispatcher/run_dispatcher.go b/cli/dispatcher/internal/dispatcher/run_dispatcher.go index 67df15e04..edbce5dce 100644 --- a/cli/dispatcher/internal/dispatcher/run_dispatcher.go +++ b/cli/dispatcher/internal/dispatcher/run_dispatcher.go @@ -172,31 +172,6 @@ func runDispatcherProcessCommandWithDeps( return 1 } -func tryRunDetectedDispatcherV2Project( - ctx context.Context, - projectRoot string, - args []string, - stdout io.Writer, - stderr io.Writer, - deps dispatcherRunDeps, -) (bool, int) { - v2Project, err := detectV2DispatcherProject(projectRoot) - if err != nil || !v2Project.IsV2 { - return false, 0 - } - if v2Project.PackageVersion == "" { - clierrors.WriteErrorEnvelope(stderr, dispatcherV2ProjectDetectedError(projectRoot, v2Project)) - return true, 1 - } - - code, err := deps.runV2CLI(ctx, v2Project.PackageVersion, args, stdout, stderr) - if err == nil { - return true, code - } - clierrors.WriteErrorEnvelope(stderr, dispatcherV2ProjectDetectedError(projectRoot, v2Project)) - return true, 1 -} - func shouldKeepDispatcherProcessCommand(args []string) bool { if len(args) == 0 || clicore.ShouldHandleCompletionRequest(args) { return true @@ -490,42 +465,6 @@ func dispatcherPinResolutionError(projectRoot string, cause error) clierrors.CLI } } -func dispatcherV2ProjectDetectedError(projectRoot string, v2Project dispatcherV2Project) clierrors.CLIError { - if len(v2Project.PackageVersionCandidates) > 0 { - return clierrors.CLIError{ - ErrorCode: clierrors.ErrorCodeV2ProjectDetected, - Phase: clierrors.ErrorPhaseProjectResolve, - Message: "This Unity project uses uloop V2, but its package version could not be resolved unambiguously.", - Retryable: true, - SafeToRetry: true, - ProjectRoot: projectRoot, - NextActions: []string{ - "Open the Unity project once so Unity Package Manager can refresh `Packages/packages-lock.json`, then retry the command.", - "As a last resort, run `npx uloop-cli@2 ` from this project.", - }, - Details: map[string]any{ - "V2PackageVersionCandidates": v2Project.PackageVersionCandidates, - }, - } - } - - return clierrors.CLIError{ - ErrorCode: clierrors.ErrorCodeV2ProjectDetected, - Phase: clierrors.ErrorPhaseProjectResolve, - Message: "This Unity project uses uloop V2 and requires Node.js 22 or later.", - Retryable: true, - SafeToRetry: true, - ProjectRoot: projectRoot, - NextActions: []string{ - "Install Node.js 22 or later, then retry the command.", - "As a last resort, run `npx uloop-cli@" + v2Project.PackageVersion + " ` from this project.", - }, - Details: map[string]any{ - "V2PackageVersion": v2Project.PackageVersion, - }, - } -} - func dispatcherRealCLIResolutionError(projectRoot string, pin dispatcherPin, cause error) clierrors.CLIError { return clierrors.CLIError{ ErrorCode: clierrors.ErrorCodeInternalError, From 294c2c27c89a49698e2a70850008fc766410a170 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 16 Jul 2026 18:59:25 +0900 Subject: [PATCH 8/8] Harden V2 fallback and preserve execution causes Keep lock-entry presence independent from its version so non-git sources cannot fall through to stale PackageCache data. When the lock entry is missing, allow cache fallback only for explicit Git manifest dependencies. Preserve the underlying V2 execution failure in structured error details. --- .../dispatcher/dispatcher_v2_detect.go | 41 +++++++++++--- .../dispatcher/dispatcher_v2_detect_test.go | 53 ++++++++++++++++++- .../internal/dispatcher/dispatcher_v2_run.go | 16 +++--- 3 files changed, 95 insertions(+), 15 deletions(-) diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go index a2881d7db..fcbdeef3a 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "os" "path/filepath" "sort" @@ -46,7 +47,7 @@ type dispatcherPackageJSON struct { // detectV2DispatcherProject identifies V2 projects from Unity's currently resolved package state. // Why: a resolved V2 package must take precedence over stale V3 pins that can remain after a downgrade. func detectV2DispatcherProject(projectRoot string) (dispatcherV2Project, error) { - hasPackage, err := dispatcherProjectHasUnityPackage(projectRoot) + manifestDependency, hasPackage, err := dispatcherProjectUnityPackageDependency(projectRoot) if err != nil { return dispatcherV2Project{}, err } @@ -68,6 +69,9 @@ func detectV2DispatcherProject(projectRoot string) (dispatcherV2Project, error) if found && lockEntry.Source != dispatcherPackageLockSourceGit { return dispatcherV2Project{}, nil } + if !found && !isDispatcherGitPackageDependency(manifestDependency) { + return dispatcherV2Project{}, nil + } packageVersions, err := dispatcherPackageCacheVersions(projectRoot) if err != nil { @@ -91,22 +95,43 @@ func detectV2DispatcherProject(projectRoot string) (dispatcherV2Project, error) return dispatcherV2Project{IsV2: true, PackageVersionCandidates: packageVersions}, nil } -func dispatcherProjectHasUnityPackage(projectRoot string) (bool, error) { +func dispatcherProjectUnityPackageDependency(projectRoot string) (json.RawMessage, bool, error) { manifestPath := filepath.Join(projectRoot, filepath.FromSlash(dispatcherPackagesManifestRelativePath)) content, err := os.ReadFile(manifestPath) if errors.Is(err, os.ErrNotExist) { - return false, nil + return nil, false, nil } if err != nil { - return false, err + return nil, false, err } manifest := dispatcherPackagesManifest{} if err := json.Unmarshal(content, &manifest); err != nil { - return false, fmt.Errorf("parse %s: %w", manifestPath, err) + return nil, false, fmt.Errorf("parse %s: %w", manifestPath, err) + } + dependency, found := manifest.Dependencies[dispatcherUnityPackageName] + return dependency, found, nil +} + +func isDispatcherGitPackageDependency(dependency json.RawMessage) bool { + value := "" + if err := json.Unmarshal(dependency, &value); err != nil { + return false + } + withoutRevision, _, _ := strings.Cut(strings.TrimSpace(value), "#") + if strings.HasPrefix(withoutRevision, "git@") { + return strings.HasSuffix(withoutRevision, ".git") + } + parsed, err := url.Parse(withoutRevision) + if err != nil { + return false + } + switch strings.ToLower(parsed.Scheme) { + case "git", "ssh", "http", "https", "git+ssh", "git+http", "git+https": + return strings.HasSuffix(strings.TrimSuffix(parsed.Path, "/"), ".git") + default: + return false } - _, found := manifest.Dependencies[dispatcherUnityPackageName] - return found, nil } func dispatcherPackageCacheVersions(projectRoot string) ([]string, error) { @@ -160,7 +185,7 @@ func dispatcherPackageLockEntryForProject(projectRoot string) (dispatcherPackage if !found { return dispatcherPackageLockEntry{}, false, nil } - return entry, entry.Version != "", nil + return entry, true, nil } func readDispatcherPackageVersion(packagePath string) (string, error) { diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go index cb35e653e..59b5474ce 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go @@ -186,6 +186,49 @@ func TestDetectV2DispatcherProjectSkipsV3PackageLockVersion(t *testing.T) { } } +func TestDetectV2DispatcherProjectRejectsEmptyLocalPackageLockVersion(t *testing.T) { + // Verifies an existing local lock entry cannot use a stale V2 cache when its version is empty. + projectRoot := createDispatcherUnityProject(t) + writePackageManifest(t, projectRoot, "file:../v3-package") + writePackagesLockWithSource(t, projectRoot, "", "local") + writeV2PackageCachePackageJSON(t, projectRoot, "stale", "2.2.0") + + v2Project, err := detectV2DispatcherProject(projectRoot) + if err != nil { + t.Fatalf("detect V2 project: %v", err) + } + if v2Project.IsV2 { + t.Fatalf("unexpected V2 project: %#v", v2Project) + } +} + +func TestDetectV2DispatcherProjectRejectsNonGitManifestFallbackWithoutLockEntry(t *testing.T) { + // Verifies local, embedded, and unknown manifest sources cannot use stale V2 cache data without a lock entry. + testCases := []struct { + name string + dependency string + }{ + {name: "local", dependency: "file:../v3-package"}, + {name: "embedded", dependency: "file:../Packages/v3-package"}, + {name: "unknown", dependency: "vendor:v3-package"}, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + projectRoot := createDispatcherUnityProject(t) + writePackageManifest(t, projectRoot, testCase.dependency) + writeV2PackageCachePackageJSON(t, projectRoot, "stale", "2.2.0") + + v2Project, err := detectV2DispatcherProject(projectRoot) + if err != nil { + t.Fatalf("detect V2 project: %v", err) + } + if v2Project.IsV2 { + t.Fatalf("unexpected V2 project: %#v", v2Project) + } + }) + } +} + func TestRunDispatcherReportsV2ProjectGuidanceWhenPinIsMissing(t *testing.T) { // Verifies pinless V2 projects receive migration guidance instead of the missing-pin error. projectRoot := createDispatcherUnityProject(t) @@ -214,6 +257,9 @@ func TestRunDispatcherReportsV2ProjectGuidanceWhenPinIsMissing(t *testing.T) { if len(envelope.Error.NextActions) < 2 { t.Fatalf("next actions = %#v, want Node and npx guidance", envelope.Error.NextActions) } + if envelope.Error.Details["Cause"] != os.ErrNotExist.Error() { + t.Fatalf("cause = %#v, want %q", envelope.Error.Details["Cause"], os.ErrNotExist.Error()) + } } func TestRunDispatcherReportsVersionResolutionGuidanceForAmbiguousV2Cache(t *testing.T) { @@ -437,12 +483,17 @@ func TestShouldKeepDispatcherProcessCommandKeepsUpdate(t *testing.T) { } func writeV2PackageManifest(t *testing.T, projectRoot string) { + t.Helper() + writePackageManifest(t, projectRoot, "https://example.invalid/package.git") +} + +func writePackageManifest(t *testing.T, projectRoot string, dependency string) { t.Helper() manifestPath := filepath.Join(projectRoot, "Packages", "manifest.json") if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil { t.Fatalf("create Packages directory: %v", err) } - content := "{\n \"dependencies\": {\n \"" + dispatcherUnityPackageName + "\": \"https://example.invalid/package.git\"\n }\n}\n" + content := "{\n \"dependencies\": {\n \"" + dispatcherUnityPackageName + "\": \"" + dependency + "\"\n }\n}\n" if err := os.WriteFile(manifestPath, []byte(content), 0o644); err != nil { t.Fatalf("write manifest: %v", err) } diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_run.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_run.go index 54220ebf4..ebabbefe9 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_v2_run.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_run.go @@ -24,7 +24,7 @@ func tryRunDetectedDispatcherV2Project( return false, 0 } if v2Project.PackageVersion == "" { - clierrors.WriteErrorEnvelope(stderr, dispatcherV2ProjectDetectedError(projectRoot, v2Project)) + clierrors.WriteErrorEnvelope(stderr, dispatcherV2ProjectDetectedError(projectRoot, v2Project, nil)) return true, 1 } @@ -32,11 +32,11 @@ func tryRunDetectedDispatcherV2Project( if err == nil { return true, code } - clierrors.WriteErrorEnvelope(stderr, dispatcherV2ProjectDetectedError(projectRoot, v2Project)) + clierrors.WriteErrorEnvelope(stderr, dispatcherV2ProjectDetectedError(projectRoot, v2Project, err)) return true, 1 } -func dispatcherV2ProjectDetectedError(projectRoot string, v2Project dispatcherV2Project) clierrors.CLIError { +func dispatcherV2ProjectDetectedError(projectRoot string, v2Project dispatcherV2Project, executionErr error) clierrors.CLIError { if len(v2Project.PackageVersionCandidates) > 0 { return clierrors.CLIError{ ErrorCode: clierrors.ErrorCodeV2ProjectDetected, @@ -55,6 +55,12 @@ func dispatcherV2ProjectDetectedError(projectRoot string, v2Project dispatcherV2 } } + details := map[string]any{ + "V2PackageVersion": v2Project.PackageVersion, + } + if executionErr != nil { + details["Cause"] = executionErr.Error() + } return clierrors.CLIError{ ErrorCode: clierrors.ErrorCodeV2ProjectDetected, Phase: clierrors.ErrorPhaseProjectResolve, @@ -66,9 +72,7 @@ func dispatcherV2ProjectDetectedError(projectRoot string, v2Project dispatcherV2 "Install Node.js 22 or later, then retry the command.", "As a last resort, run `npx uloop-cli@" + v2Project.PackageVersion + " ` from this project.", }, - Details: map[string]any{ - "V2PackageVersion": v2Project.PackageVersion, - }, + Details: details, } }