diff --git a/README.md b/README.md index a7eac2b59..a812ecb6e 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 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. + +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..893eccc90 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へ解決している場合、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表示が委譲されます。 + +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 ``` 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_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.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go new file mode 100644 index 000000000..fcbdeef3a --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go @@ -0,0 +1,210 @@ +package dispatcher + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "sort" + "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" + dispatcherPackageLockSourceGit = "git" +) + +type dispatcherV2Project struct { + IsV2 bool + PackageVersion string + PackageVersionCandidates []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"` + Source string `json:"source"` +} + +type dispatcherPackageJSON struct { + Version string `json:"version"` +} + +// 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) { + manifestDependency, hasPackage, err := dispatcherProjectUnityPackageDependency(projectRoot) + if err != nil { + return dispatcherV2Project{}, err + } + if !hasPackage { + return dispatcherV2Project{}, nil + } + + 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 + } + if !found && !isDispatcherGitPackageDependency(manifestDependency) { + return dispatcherV2Project{}, nil + } + + 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, PackageVersionCandidates: packageVersions}, nil +} + +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 nil, false, nil + } + if err != nil { + return nil, false, err + } + + manifest := dispatcherPackagesManifest{} + if err := json.Unmarshal(content, &manifest); err != nil { + 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 + } +} + +func dispatcherPackageCacheVersions(projectRoot string) ([]string, error) { + cacheDirectory := filepath.Join(projectRoot, "Library", "PackageCache") + entries, err := os.ReadDir(cacheDirectory) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + + prefix := dispatcherUnityPackageName + "@" + versions := map[string]struct{}{} + 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 sharedversion.IsValid(strings.TrimSpace(version)) { + versions[strings.TrimSpace(version)] = struct{}{} + } + } + result := make([]string, 0, len(versions)) + for version := range versions { + result = append(result, version) + } + sort.Strings(result) + return result, nil +} + +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 dispatcherPackageLockEntry{}, false, nil + } + if err != nil { + return dispatcherPackageLockEntry{}, false, err + } + + lock := dispatcherPackagesLock{} + if err := json.Unmarshal(content, &lock); err != nil { + return dispatcherPackageLockEntry{}, false, fmt.Errorf("parse %s: %w", lockPath, err) + } + entry, found := lock.Dependencies[dispatcherUnityPackageName] + if !found { + return dispatcherPackageLockEntry{}, false, nil + } + return entry, true, 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..59b5474ce --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go @@ -0,0 +1,559 @@ +package dispatcher + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "testing" + + clierrors "github.com/hatayama/unity-cli-loop/common/errors" + "github.com/hatayama/unity-cli-loop/dispatcher/internal/nativepath" +) + +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) + 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 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 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) + 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 || v2Project.PackageVersion != "2.2.0" { + t.Fatalf("V2 project = %#v, want package version 2.2.0", 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 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) + writeV2PackageManifest(t, projectRoot) + writeV2PackageCachePackageJSON(t, projectRoot, "abc123", "2.2.0") + t.Chdir(projectRoot) + + var stdout bytes.Buffer + var stderr bytes.Buffer + 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()) + } + 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) + } + 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) { + // 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 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 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) + 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() + 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 + "\": \"" + dependency + "\"\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() + 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) + } +} 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..ebabbefe9 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_run.go @@ -0,0 +1,115 @@ +package dispatcher + +import ( + "context" + "errors" + "io" + "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, nil)) + 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, err)) + return true, 1 +} + +func dispatcherV2ProjectDetectedError(projectRoot string, v2Project dispatcherV2Project, executionErr error) 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, + }, + } + } + + details := map[string]any{ + "V2PackageVersion": v2Project.PackageVersion, + } + if executionErr != nil { + details["Cause"] = executionErr.Error() + } + 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: details, + } +} + +// 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 034ac1ebd..edbce5dce 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,26 @@ 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 handled, code := tryRunDetectedDispatcherV2Project(ctx, projectRoot, args, stdout, stderr, deps); handled { + return code + } + } + } + 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() @@ -84,6 +94,9 @@ 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 { @@ -110,12 +123,25 @@ 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 handled, code := tryRunDetectedDispatcherV2Project(ctx, projectRoot, args, stdout, stderr, deps); handled { + return code + } + } + } if handled, code := tryHandleProjectScopeHelpRequest(remainingArgs, projectPath, stdout); handled { return code } @@ -123,12 +149,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, @@ -152,6 +172,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 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" }