diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go index 9590b48200..08cad6d146 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go @@ -16,15 +16,20 @@ import ( const ( dispatcherPackagesManifestRelativePath = "Packages/manifest.json" dispatcherPackagesLockRelativePath = "Packages/packages-lock.json" + dispatcherPackagesRelativePath = "Packages" dispatcherPackageJSONFileName = "package.json" dispatcherV2MajorVersion = "2" dispatcherPackageLockSourceGit = "git" + dispatcherFileDependencyPrefix = "file:" ) type dispatcherV2Project struct { IsV2 bool PackageVersion string PackageVersionCandidates []string + // AmbiguousEmbedded is set when PackageVersionCandidates came from duplicate embedded package + // directories rather than PackageCache, since the two ambiguities need different recovery guidance. + AmbiguousEmbedded bool } type dispatcherPackagesManifest struct { @@ -38,21 +43,27 @@ type dispatcherPackagesLock struct { type dispatcherPackageLockEntry struct { Version string `json:"version"` Source string `json:"source"` + Hash string `json:"hash"` } type dispatcherPackageJSON struct { + Name string `json:"name"` 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. +// Why disk vs. lock priority: authority follows where Unity actually loads the package entity from. +// For registry/git dependencies Unity loads a PackageCache copy, so the lock's resolved semver is +// authoritative; for file:/embedded packages Unity loads the on-disk directory directly, so that +// directory's own package.json is authoritative there, even when the lock has no usable version for it. func detectV2DispatcherProject(projectRoot string) (dispatcherV2Project, error) { manifestDependency, hasPackage, err := dispatcherProjectUnityPackageDependency(projectRoot) if err != nil { return dispatcherV2Project{}, err } if !hasPackage { - return dispatcherV2Project{}, nil + return detectV2DispatcherEmbeddedProject(projectRoot) } lockEntry, found, err := dispatcherPackageLockEntryForProject(projectRoot) @@ -66,6 +77,11 @@ func detectV2DispatcherProject(projectRoot string) (dispatcherV2Project, error) } return dispatcherV2Project{IsV2: true, PackageVersion: packageVersion}, nil } + + if isDispatcherFileDependency(manifestDependency) { + return detectV2DispatcherFileDependencyProject(projectRoot, manifestDependency) + } + if found && lockEntry.Source != dispatcherPackageLockSourceGit { return dispatcherV2Project{}, nil } @@ -73,10 +89,20 @@ func detectV2DispatcherProject(projectRoot string) (dispatcherV2Project, error) return dispatcherV2Project{}, nil } - packageVersions, err := dispatcherPackageCacheVersions(projectRoot) + packageCacheEntries, err := dispatcherPackageCacheEntries(projectRoot) if err != nil { return dispatcherV2Project{}, err } + if found && lockEntry.Source == dispatcherPackageLockSourceGit { + if version, ok := dispatcherPackageCacheVersionForHash(packageCacheEntries, lockEntry.Hash); ok { + if !isDispatcherV2PackageVersion(version) { + return dispatcherV2Project{}, nil + } + return dispatcherV2Project{IsV2: true, PackageVersion: version}, nil + } + } + + packageVersions := dispatcherDistinctPackageCacheVersions(packageCacheEntries) if len(packageVersions) == 0 { return dispatcherV2Project{}, nil } @@ -95,6 +121,110 @@ func detectV2DispatcherProject(projectRoot string) (dispatcherV2Project, error) return dispatcherV2Project{IsV2: true, PackageVersionCandidates: packageVersions}, nil } +// isDispatcherFileDependency reports whether a manifest dependency value is a file: reference, +// covering both local checkouts outside Packages/ and embedded packages referenced by relative path. +func isDispatcherFileDependency(dependency json.RawMessage) bool { + value := "" + if err := json.Unmarshal(dependency, &value); err != nil { + return false + } + return strings.HasPrefix(strings.TrimSpace(value), dispatcherFileDependencyPrefix) +} + +// detectV2DispatcherFileDependencyProject reads the package.json at a file: dependency's target +// directly, since file: sources have no reliable semver in packages-lock.json or PackageCache. +func detectV2DispatcherFileDependencyProject(projectRoot string, dependency json.RawMessage) (dispatcherV2Project, error) { + value := "" + if err := json.Unmarshal(dependency, &value); err != nil { + return dispatcherV2Project{}, nil + } + targetDirectory, ok := resolveDispatcherFileDependencyTarget(projectRoot, value) + if !ok { + return dispatcherV2Project{}, nil + } + return detectV2DispatcherPackageAtPath(filepath.Join(targetDirectory, dispatcherPackageJSONFileName)) +} + +// resolveDispatcherFileDependencyTarget resolves a manifest file: dependency value to an absolute +// directory. Relative values are resolved against Packages/, matching Unity's own resolution base. +// Backslashes are normalized before joining so Windows-authored manifest values still resolve on any OS. +func resolveDispatcherFileDependencyTarget(projectRoot string, dependencyValue string) (string, bool) { + trimmed := strings.TrimSpace(dependencyValue) + if !strings.HasPrefix(trimmed, dispatcherFileDependencyPrefix) { + return "", false + } + rawPath := strings.TrimPrefix(trimmed, dispatcherFileDependencyPrefix) + normalizedPath := filepath.FromSlash(strings.ReplaceAll(rawPath, "\\", "/")) + if normalizedPath == "" { + return "", false + } + if filepath.IsAbs(normalizedPath) { + return filepath.Clean(normalizedPath), true + } + packagesDirectory := filepath.Join(projectRoot, dispatcherPackagesRelativePath) + return filepath.Clean(filepath.Join(packagesDirectory, normalizedPath)), true +} + +// detectV2DispatcherEmbeddedProject scans Packages/ for embedded packages, which never appear in +// manifest.json dependencies and are therefore invisible to the manifest-driven detection above. +func detectV2DispatcherEmbeddedProject(projectRoot string) (dispatcherV2Project, error) { + packagesDirectory := filepath.Join(projectRoot, dispatcherPackagesRelativePath) + entries, err := os.ReadDir(packagesDirectory) + if errors.Is(err, os.ErrNotExist) { + return dispatcherV2Project{}, nil + } + if err != nil { + return dispatcherV2Project{}, err + } + + versions := map[string]struct{}{} + for _, entry := range entries { + if !entry.IsDir() { + continue + } + packagePath := filepath.Join(packagesDirectory, entry.Name(), dispatcherPackageJSONFileName) + project, err := detectV2DispatcherPackageAtPath(packagePath) + if err != nil { + return dispatcherV2Project{}, err + } + if project.IsV2 { + versions[project.PackageVersion] = struct{}{} + } + } + + switch len(versions) { + case 0: + return dispatcherV2Project{}, nil + case 1: + for version := range versions { + return dispatcherV2Project{IsV2: true, PackageVersion: version}, nil + } + } + candidates := make([]string, 0, len(versions)) + for version := range versions { + candidates = append(candidates, version) + } + sort.Strings(candidates) + return dispatcherV2Project{IsV2: true, PackageVersionCandidates: candidates, AmbiguousEmbedded: true}, nil +} + +// detectV2DispatcherPackageAtPath reads a package.json directly and reports whether it is the V2 +// Unity package by name, matching by content rather than by directory naming convention. +func detectV2DispatcherPackageAtPath(packagePath string) (dispatcherV2Project, error) { + packageInfo, err := readDispatcherPackageInfo(packagePath) + if err != nil { + return dispatcherV2Project{}, nil + } + if packageInfo.Name != dispatcherUnityPackageName { + return dispatcherV2Project{}, nil + } + version := strings.TrimSpace(packageInfo.Version) + if !isDispatcherV2PackageVersion(version) { + return dispatcherV2Project{}, nil + } + return dispatcherV2Project{IsV2: true, PackageVersion: version}, nil +} + func dispatcherProjectUnityPackageDependency(projectRoot string) (json.RawMessage, bool, error) { manifestPath := filepath.Join(projectRoot, filepath.FromSlash(dispatcherPackagesManifestRelativePath)) content, err := os.ReadFile(manifestPath) @@ -135,7 +265,14 @@ func isDispatcherGitPackageDependency(dependency json.RawMessage) bool { } } -func dispatcherPackageCacheVersions(projectRoot string) ([]string, error) { +// dispatcherPackageCacheEntry pairs a PackageCache directory's hash suffix with its resolved +// package version, so a lock's git hash can be matched to the exact generation it produced. +type dispatcherPackageCacheEntry struct { + DirectorySuffix string + Version string +} + +func dispatcherPackageCacheEntries(projectRoot string) ([]dispatcherPackageCacheEntry, error) { cacheDirectory := filepath.Join(projectRoot, "Library", "PackageCache") entries, err := os.ReadDir(cacheDirectory) if errors.Is(err, os.ErrNotExist) { @@ -146,7 +283,7 @@ func dispatcherPackageCacheVersions(projectRoot string) ([]string, error) { } prefix := dispatcherUnityPackageName + "@" - versions := map[string]struct{}{} + result := make([]dispatcherPackageCacheEntry, 0, len(entries)) for _, entry := range entries { if !entry.IsDir() || !strings.HasPrefix(entry.Name(), prefix) { continue @@ -156,16 +293,47 @@ func dispatcherPackageCacheVersions(projectRoot string) ([]string, error) { if err != nil { continue } - if sharedversion.IsValid(strings.TrimSpace(version)) { - versions[strings.TrimSpace(version)] = struct{}{} + trimmedVersion := strings.TrimSpace(version) + if !sharedversion.IsValid(trimmedVersion) { + continue } + result = append(result, dispatcherPackageCacheEntry{ + DirectorySuffix: strings.TrimPrefix(entry.Name(), prefix), + Version: trimmedVersion, + }) + } + return result, nil +} + +func dispatcherDistinctPackageCacheVersions(entries []dispatcherPackageCacheEntry) []string { + versions := map[string]struct{}{} + for _, entry := range entries { + versions[entry.Version] = struct{}{} } result := make([]string, 0, len(versions)) for version := range versions { result = append(result, version) } sort.Strings(result) - return result, nil + return result +} + +// dispatcherPackageCacheVersionForHash disambiguates multiple cached git package generations by +// matching packages-lock.json's resolved commit hash against each PackageCache directory's suffix. +// Why prefix match, not a fixed length: Unity truncates the hash to a directory suffix whose length +// can differ across Editor versions, so the suffix is compared as a leading prefix of the full hash +// rather than a hardcoded character count. +func dispatcherPackageCacheVersionForHash(entries []dispatcherPackageCacheEntry, hash string) (string, bool) { + trimmedHash := strings.TrimSpace(hash) + if trimmedHash == "" { + return "", false + } + for _, entry := range entries { + if entry.DirectorySuffix != "" && strings.HasPrefix(trimmedHash, entry.DirectorySuffix) { + return entry.Version, true + } + } + return "", false } func dispatcherPackageLockEntryForProject(projectRoot string) (dispatcherPackageLockEntry, bool, error) { @@ -190,15 +358,23 @@ func dispatcherPackageLockEntryForProject(projectRoot string) (dispatcherPackage } func readDispatcherPackageVersion(packagePath string) (string, error) { - content, err := os.ReadFile(packagePath) + packageInfo, err := readDispatcherPackageInfo(packagePath) if err != nil { return "", err } + return packageInfo.Version, nil +} + +func readDispatcherPackageInfo(packagePath string) (dispatcherPackageJSON, error) { + content, err := os.ReadFile(packagePath) + if err != nil { + return dispatcherPackageJSON{}, err + } packageInfo := dispatcherPackageJSON{} if err := json.Unmarshal(content, &packageInfo); err != nil { - return "", fmt.Errorf("parse %s: %w", packagePath, err) + return dispatcherPackageJSON{}, fmt.Errorf("parse %s: %w", packagePath, err) } - return packageInfo.Version, nil + return packageInfo, nil } func isDispatcherV2PackageVersion(version string) bool { diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go index 2d218067f6..8edb4e580d 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go @@ -253,6 +253,187 @@ func TestIsDispatcherGitPackageDependencyAcceptsPathQueries(t *testing.T) { } } +func TestDetectV2DispatcherProjectFindsFileDependencyPackage(t *testing.T) { + // Verifies a file: manifest dependency is detected as V2 by reading the target package.json directly. + projectRoot := createDispatcherUnityProject(t) + writePackageManifest(t, projectRoot, "file:../v2-file-pkg") + writeDispatcherPackageJSONFile(t, filepath.Join(projectRoot, "v2-file-pkg", "package.json"), dispatcherUnityPackageName, "2.1.6") + + v2Project, err := detectV2DispatcherProject(projectRoot) + if err != nil { + t.Fatalf("detect V2 project: %v", err) + } + if !v2Project.IsV2 || v2Project.PackageVersion != "2.1.6" { + t.Fatalf("V2 project = %#v, want package version 2.1.6", v2Project) + } +} + +func TestDetectV2DispatcherProjectSkipsFileDependencyV3Package(t *testing.T) { + // Verifies a file: dependency pointing at a V3 source checkout is not misdetected as V2. + projectRoot := createDispatcherUnityProject(t) + writePackageManifest(t, projectRoot, "file:../v3-file-pkg") + writeDispatcherPackageJSONFile(t, filepath.Join(projectRoot, "v3-file-pkg", "package.json"), dispatcherUnityPackageName, "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 TestDetectV2DispatcherProjectSkipsFileDependencyWithoutMatchingPackage(t *testing.T) { + // Verifies a file: dependency whose target has no package.json, or a mismatched name, stays non-detected without error. + testCases := []struct { + name string + writeTarget func(t *testing.T, projectRoot string) + }{ + { + name: "missing package.json", + writeTarget: func(t *testing.T, projectRoot string) {}, + }, + { + name: "mismatched name", + writeTarget: func(t *testing.T, projectRoot string) { + writeDispatcherPackageJSONFile(t, filepath.Join(projectRoot, "unrelated-pkg", "package.json"), "com.example.other", "2.0.0") + }, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + projectRoot := createDispatcherUnityProject(t) + writePackageManifest(t, projectRoot, "file:../unrelated-pkg") + testCase.writeTarget(t, projectRoot) + + 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 TestDetectV2DispatcherProjectFindsEmbeddedPackage(t *testing.T) { + // Verifies an embedded package (no manifest dependency entry) is detected as V2 from its on-disk package.json. + projectRoot := createDispatcherUnityProject(t) + writeDispatcherPackageJSONFile(t, filepath.Join(projectRoot, "Packages", "v2-embedded-pkg", "package.json"), dispatcherUnityPackageName, "2.3.0") + + v2Project, err := detectV2DispatcherProject(projectRoot) + if err != nil { + t.Fatalf("detect V2 project: %v", err) + } + if !v2Project.IsV2 || v2Project.PackageVersion != "2.3.0" { + t.Fatalf("V2 project = %#v, want package version 2.3.0", v2Project) + } +} + +func TestDetectV2DispatcherProjectSkipsEmbeddedV3Package(t *testing.T) { + // Verifies an embedded V3 package does not get misdetected as V2. + projectRoot := createDispatcherUnityProject(t) + writeDispatcherPackageJSONFile(t, filepath.Join(projectRoot, "Packages", "v3-embedded-pkg", "package.json"), dispatcherUnityPackageName, "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 TestDetectV2DispatcherProjectMarksAmbiguousEmbeddedCandidates(t *testing.T) { + // Verifies multiple embedded V2 package directories are flagged as embedded-sourced ambiguity, + // which needs different recovery guidance than a PackageCache multi-generation ambiguity. + projectRoot := createDispatcherUnityProject(t) + writeDispatcherPackageJSONFile(t, filepath.Join(projectRoot, "Packages", "v2-embedded-a", "package.json"), dispatcherUnityPackageName, "2.1.0") + writeDispatcherPackageJSONFile(t, filepath.Join(projectRoot, "Packages", "v2-embedded-b", "package.json"), dispatcherUnityPackageName, "2.2.0") + + v2Project, err := detectV2DispatcherProject(projectRoot) + if err != nil { + t.Fatalf("detect V2 project: %v", err) + } + if !v2Project.IsV2 || v2Project.PackageVersion != "" || !v2Project.AmbiguousEmbedded { + t.Fatalf("ambiguous embedded V2 project = %#v", v2Project) + } + assertStringSliceEqual(t, v2Project.PackageVersionCandidates, []string{"2.1.0", "2.2.0"}) +} + +func TestDetectV2DispatcherProjectResolvesFileDependencyWithBackslashPath(t *testing.T) { + // Verifies a file: dependency value with backslash separators (Windows-authored manifest) still resolves. + projectRoot := createDispatcherUnityProject(t) + writePackageManifest(t, projectRoot, "file:..\\v2-windows-pkg") + writeDispatcherPackageJSONFile(t, filepath.Join(projectRoot, "v2-windows-pkg", "package.json"), dispatcherUnityPackageName, "2.0.1") + + v2Project, err := detectV2DispatcherProject(projectRoot) + if err != nil { + t.Fatalf("detect V2 project: %v", err) + } + if !v2Project.IsV2 || v2Project.PackageVersion != "2.0.1" { + t.Fatalf("V2 project = %#v, want package version 2.0.1", v2Project) + } +} + +func TestDetectV2DispatcherProjectDisambiguatesPackageCacheUsingLockHash(t *testing.T) { + // Verifies a git dependency's packages-lock.json hash resolves the exact PackageCache generation + // instead of surfacing the multi-version candidates ambiguity error. + projectRoot := createDispatcherUnityProject(t) + writeV2PackageManifest(t, projectRoot) + writeV2PackageCachePackageJSON(t, projectRoot, "aaaaaaaaaa", "2.1.0") + writeV2PackageCachePackageJSON(t, projectRoot, "bbbbbbbbbb", "2.2.0") + writePackagesLockWithGitHash(t, projectRoot, "bbbbbbbbbbccccccccccddddddddddeeeeeeeeee") + + 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) + } + if len(v2Project.PackageVersionCandidates) != 0 { + t.Fatalf("unexpected candidates: %#v", v2Project.PackageVersionCandidates) + } +} + +func TestDetectV2DispatcherProjectDisambiguatesPackageCacheUsingLockHashWithNonDefaultSuffixLength(t *testing.T) { + // Verifies the hash match is a prefix comparison, not a hardcoded suffix length, since Unity's + // PackageCache directory suffix length is not guaranteed to be identical across Editor versions. + projectRoot := createDispatcherUnityProject(t) + writeV2PackageManifest(t, projectRoot) + writeV2PackageCachePackageJSON(t, projectRoot, "aaaaaaaa", "2.1.0") + writeV2PackageCachePackageJSON(t, projectRoot, "bbbbbbbbbbbbbbbb", "2.2.0") + writePackagesLockWithGitHash(t, projectRoot, "bbbbbbbbbbbbbbbbccccccccccdddddddddd") + + 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 TestDetectV2DispatcherProjectFallsBackToAmbiguousCandidatesWhenHashHasNoMatch(t *testing.T) { + // Verifies a lock hash that matches no cached directory still falls back to listing all candidates. + projectRoot := createDispatcherUnityProject(t) + writeV2PackageManifest(t, projectRoot) + writeV2PackageCachePackageJSON(t, projectRoot, "aaaaaaaaaa", "2.1.0") + writeV2PackageCachePackageJSON(t, projectRoot, "bbbbbbbbbb", "2.2.0") + writePackagesLockWithGitHash(t, projectRoot, "ccccccccccddddddddddeeeeeeeeeeffffffffff") + + 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 TestRunDispatcherReportsV2ProjectGuidanceWhenPinIsMissing(t *testing.T) { // Verifies pinless V2 projects receive migration guidance instead of the missing-pin error. projectRoot := createDispatcherUnityProject(t) @@ -319,6 +500,33 @@ func TestRunDispatcherReportsVersionResolutionGuidanceForAmbiguousV2Cache(t *tes } } +func TestRunDispatcherReportsEmbeddedDuplicateGuidanceForAmbiguousEmbeddedPackages(t *testing.T) { + // Verifies ambiguous embedded packages recommend removing duplicate directories, since the + // PackageCache guidance (refresh packages-lock.json) does not fix an embedded duplication. + projectRoot := createDispatcherUnityProject(t) + writeDispatcherPackageJSONFile(t, filepath.Join(projectRoot, "Packages", "v2-embedded-a", "package.json"), dispatcherUnityPackageName, "2.1.0") + writeDispatcherPackageJSONFile(t, filepath.Join(projectRoot, "Packages", "v2-embedded-b", "package.json"), dispatcherUnityPackageName, "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 embedded V2 packages 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()) + } + if bytes.Contains(stderr.Bytes(), []byte("packages-lock.json")) { + t.Fatalf("embedded ambiguity must not reuse PackageCache guidance: %s", stderr.String()) + } + if !bytes.Contains(stderr.Bytes(), []byte("duplicate embedded package directories")) { + t.Fatalf("embedded ambiguity guidance missing duplicate-directory advice: %s", 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) @@ -543,7 +751,11 @@ func writePackageManifest(t *testing.T, projectRoot string, dependency string) { 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" + encodedDependency, err := json.Marshal(dependency) + if err != nil { + t.Fatalf("marshal dependency: %v", err) + } + content := "{\n \"dependencies\": {\n \"" + dispatcherUnityPackageName + "\": " + string(encodedDependency) + "\n }\n}\n" if err := os.WriteFile(manifestPath, []byte(content), 0o644); err != nil { t.Fatalf("write manifest: %v", err) } @@ -561,6 +773,26 @@ func writeV2PackageCachePackageJSON(t *testing.T, projectRoot string, suffix str } } +func writePackagesLockWithGitHash(t *testing.T, projectRoot string, hash string) { + t.Helper() + lockPath := filepath.Join(projectRoot, "Packages", "packages-lock.json") + content := "{\n \"dependencies\": {\n \"" + dispatcherUnityPackageName + "\": {\n \"version\": \"https://example.invalid/package.git\",\n \"source\": \"git\",\n \"hash\": \"" + hash + "\"\n }\n }\n}\n" + if err := os.WriteFile(lockPath, []byte(content), 0o644); err != nil { + t.Fatalf("write packages-lock.json: %v", err) + } +} + +func writeDispatcherPackageJSONFile(t *testing.T, packagePath string, name string, version string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(packagePath), 0o755); err != nil { + t.Fatalf("create package directory: %v", err) + } + content := "{\n \"name\": \"" + name + "\",\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, "") diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_run.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_run.go index 557927ff9d..a36560fc5f 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_v2_run.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_run.go @@ -42,6 +42,16 @@ func tryRunDetectedDispatcherV2Project( func dispatcherV2ProjectDetectedError(projectRoot string, v2Project dispatcherV2Project, executionErr error) clierrors.CLIError { if len(v2Project.PackageVersionCandidates) > 0 { + 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.", + } + if v2Project.AmbiguousEmbedded { + nextActions = []string{ + "Remove the duplicate embedded package directories under `Packages/` so only one copy of the V2 package remains, then retry the command.", + "As a last resort, run `npx uloop-cli@2 ` from this project.", + } + } return clierrors.CLIError{ ErrorCode: clierrors.ErrorCodeV2ProjectDetected, Phase: clierrors.ErrorPhaseProjectResolve, @@ -49,10 +59,7 @@ func dispatcherV2ProjectDetectedError(projectRoot string, v2Project dispatcherV2 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.", - }, + NextActions: nextActions, Details: map[string]any{ "V2PackageVersionCandidates": v2Project.PackageVersionCandidates, },