diff --git a/cli/dispatcher-contract.json b/cli/dispatcher-contract.json index dd0144087..ce80274ef 100644 --- a/cli/dispatcher-contract.json +++ b/cli/dispatcher-contract.json @@ -1,5 +1,5 @@ { "schemaVersion": 1, - "dispatcherVersion": "3.0.1-beta.2", + "dispatcherVersion": "3.0.1-beta.3", "dispatcherContractVersion": 1 } diff --git a/cli/internal/cli/dispatcher.go b/cli/internal/cli/dispatcher.go index d8f265b63..50ee42b47 100644 --- a/cli/internal/cli/dispatcher.go +++ b/cli/internal/cli/dispatcher.go @@ -18,20 +18,19 @@ import ( ) const ( - dispatcherCacheDirEnvName = "ULOOP_CACHE_DIR" - dispatcherDisableSelfUpdateEnvName = "ULOOP_DISABLE_SELF_UPDATE" - dispatcherCacheDirectoryName = "uloop" - dispatcherVersionsDirectoryName = "versions" - dispatcherUpdateStateFileName = "dispatcher-update.json" - dispatcherProjectPinRelativePath = ".uloop/cli-pin.json" - dispatcherPackagePinFileName = "cli-pin.json" - dispatcherUnityPackageName = "io.github.hatayama.uloopmcp" - dispatcherRealCLIUnixFileName = "uloop-cli" - dispatcherRealCLIWindowsFileName = "uloop-cli.exe" - dispatcherReleaseRepository = "hatayama/unity-cli-loop" - dispatcherReleaseBaseURL = "https://github.com/" + dispatcherReleaseRepository + "/releases/download" - dispatcherSelfUpdateInterval = 24 * time.Hour - dispatcherSelfUpdateRequiredRetryError = "Dispatcher update completed. Retry the command so the updated dispatcher can run." + dispatcherCacheDirEnvName = "ULOOP_CACHE_DIR" + dispatcherDisableSelfUpdateEnvName = "ULOOP_DISABLE_SELF_UPDATE" + dispatcherCacheDirectoryName = "uloop" + dispatcherVersionsDirectoryName = "versions" + dispatcherUpdateStateFileName = "dispatcher-update.json" + dispatcherProjectPinRelativePath = ".uloop/cli-pin.json" + dispatcherPackagePinFileName = "cli-pin.json" + dispatcherUnityPackageName = "io.github.hatayama.uloopmcp" + dispatcherRealCLIUnixFileName = "uloop-cli" + dispatcherRealCLIWindowsFileName = "uloop-cli.exe" + dispatcherReleaseRepository = "hatayama/unity-cli-loop" + dispatcherReleaseBaseURL = "https://github.com/" + dispatcherReleaseRepository + "/releases/download" + dispatcherSelfUpdateInterval = 24 * time.Hour ) var ( @@ -149,17 +148,12 @@ func enforceDispatcherFreshness(ctx context.Context, pin dispatcherPin, stderr i err := dispatcherRunUpdate(ctx) if err == nil { markDispatcherSelfUpdateChecked() + updatedVersion := dispatcherInstalledVersionOrEmpty(ctx) if updateRequired { - writeErrorEnvelope(stderr, cliError{ - ErrorCode: errorCodeCLIUpdateRequired, - Phase: errorPhaseExecution, - Message: dispatcherSelfUpdateRequiredRetryError, - Retryable: true, - SafeToRetry: true, - NextActions: []string{"Retry the same uloop command."}, - }) + writeDispatcherSelfUpdateRequiredError(stderr, updatedVersion) return true, 1 } + writeOptionalDispatcherUpdateCompletion(stderr, dispatcherVersion, updatedVersion) return false, 0 } @@ -172,6 +166,29 @@ func enforceDispatcherFreshness(ctx context.Context, pin dispatcherPin, stderr i return false, 0 } +func writeDispatcherSelfUpdateRequiredError(stderr io.Writer, updatedVersion string) { + currentVersion, nextVersion, changed := normalizedDispatcherUpdateVersions(dispatcherVersion, updatedVersion) + message := "Dispatcher update completed. Retry the command so the updated dispatcher can run." + if changed { + message = "Dispatcher updated from " + currentVersion + " to " + nextVersion + ". Retry the command so the updated dispatcher can run." + } + details := map[string]any{ + "CurrentDispatcherVersion": currentVersion, + } + if nextVersion != "" { + details["UpdatedDispatcherVersion"] = nextVersion + } + writeErrorEnvelope(stderr, cliError{ + ErrorCode: errorCodeCLIUpdateRequired, + Phase: errorPhaseExecution, + Message: message, + Retryable: true, + SafeToRetry: true, + NextActions: []string{"Retry the same uloop command."}, + Details: details, + }) +} + func writeDispatcherManualUpdateRequiredError(stderr io.Writer, minimumVersion string, reason string) { writeErrorEnvelope(stderr, cliError{ ErrorCode: errorCodeCLIUpdateRequired, diff --git a/cli/internal/cli/dispatcher_test.go b/cli/internal/cli/dispatcher_test.go index 23c60dc76..40374fb04 100644 --- a/cli/internal/cli/dispatcher_test.go +++ b/cli/internal/cli/dispatcher_test.go @@ -231,6 +231,87 @@ func TestEnforceDispatcherFreshnessDoesNotMarkFailedOptionalUpdateChecked(t *tes } } +func TestEnforceDispatcherFreshnessReportsOptionalUpdateVersionChange(t *testing.T) { + // Verifies optional dispatcher self-updates tell users which launcher version will run next. + t.Setenv(dispatcherCacheDirEnvName, t.TempDir()) + restoreDispatcherUpdateHooks := stubDispatcherUpdateHooks(t, "9.9.9") + defer restoreDispatcherUpdateHooks() + + var stderr bytes.Buffer + handled, code := enforceDispatcherFreshness( + context.Background(), + dispatcherPin{MinimumDispatcherVersion: dispatcherVersion}, + &stderr) + + if handled || code != 0 { + t.Fatalf("freshness result mismatch: handled=%t code=%d", handled, code) + } + expected := "uloop: dispatcher updated from " + dispatcherVersion + " to 9.9.9" + if !bytes.Contains(stderr.Bytes(), []byte(expected)) { + t.Fatalf("freshness output mismatch: %s", stderr.String()) + } +} + +func TestEnforceDispatcherFreshnessSkipsOptionalUpdateMessageWhenVersionDidNotChange(t *testing.T) { + // Verifies no-op optional dispatcher self-updates do not add noise before the real command output. + t.Setenv(dispatcherCacheDirEnvName, t.TempDir()) + restoreDispatcherUpdateHooks := stubDispatcherUpdateHooks(t, dispatcherVersion) + defer restoreDispatcherUpdateHooks() + + var stderr bytes.Buffer + handled, code := enforceDispatcherFreshness( + context.Background(), + dispatcherPin{MinimumDispatcherVersion: dispatcherVersion}, + &stderr) + + if handled || code != 0 { + t.Fatalf("freshness result mismatch: handled=%t code=%d", handled, code) + } + if stderr.Len() != 0 { + t.Fatalf("expected no optional update output, got: %s", stderr.String()) + } +} + +func TestEnforceDispatcherFreshnessReportsRequiredUpdateVersionChange(t *testing.T) { + // Verifies required dispatcher self-updates include the version change before asking for a retry. + t.Setenv(dispatcherCacheDirEnvName, t.TempDir()) + restoreDispatcherUpdateHooks := stubDispatcherUpdateHooks(t, "999.0.0") + defer restoreDispatcherUpdateHooks() + + var stderr bytes.Buffer + handled, code := enforceDispatcherFreshness( + context.Background(), + dispatcherPin{MinimumDispatcherVersion: "999.0.0"}, + &stderr) + + if !handled || code != 1 { + t.Fatalf("freshness result mismatch: handled=%t code=%d", handled, code) + } + expected := "Dispatcher updated from " + dispatcherVersion + " to 999.0.0" + if !bytes.Contains(stderr.Bytes(), []byte(expected)) { + t.Fatalf("freshness output mismatch: %s", stderr.String()) + } + if !bytes.Contains(stderr.Bytes(), []byte("Retry the command")) { + t.Fatalf("retry guidance missing: %s", stderr.String()) + } +} + +func stubDispatcherUpdateHooks(t *testing.T, updatedVersion string) func() { + t.Helper() + previousRunner := dispatcherRunUpdate + previousReader := dispatcherReadInstalledVersion + dispatcherRunUpdate = func(context.Context) error { + return nil + } + dispatcherReadInstalledVersion = func(context.Context) (string, error) { + return updatedVersion, nil + } + return func() { + dispatcherRunUpdate = previousRunner + dispatcherReadInstalledVersion = previousReader + } +} + func TestExtractDispatcherRealCLIFromTarPrefersRealCLI(t *testing.T) { // Verifies legacy bridge archives that contain dispatcher first still extract the real CLI binary. tempDir := t.TempDir() diff --git a/cli/internal/cli/dispatcher_update_version.go b/cli/internal/cli/dispatcher_update_version.go new file mode 100644 index 000000000..d899b277f --- /dev/null +++ b/cli/internal/cli/dispatcher_update_version.go @@ -0,0 +1,88 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "io" + "os" + "os/exec" +) + +var dispatcherReadInstalledVersion = readInstalledDispatcherVersion + +type dispatcherVersionPayload struct { + DispatcherVersion string +} + +func readInstalledDispatcherVersion(ctx context.Context) (string, error) { + executablePath, err := os.Executable() + if err != nil { + return "", err + } + + command := exec.CommandContext(ctx, executablePath, "--version", "--json") + output, err := command.Output() + if err != nil { + return "", err + } + + payload := dispatcherVersionPayload{} + if err := json.Unmarshal(output, &payload); err != nil { + return "", err + } + + updatedVersion := normalizeDispatcherVersion(payload.DispatcherVersion) + if updatedVersion == "" { + return "", errors.New("updated dispatcher version is empty") + } + if err := validateDispatcherCLIVersion(updatedVersion); err != nil { + return "", err + } + return updatedVersion, nil +} + +func dispatcherInstalledVersionOrEmpty(ctx context.Context) string { + updatedVersion, err := dispatcherReadInstalledVersion(ctx) + if err != nil { + return "" + } + return updatedVersion +} + +func dispatcherVersionChanged(fromVersion string, toVersion string) bool { + _, _, changed := normalizedDispatcherUpdateVersions(fromVersion, toVersion) + return changed +} + +func normalizedDispatcherUpdateVersions(fromVersion string, toVersion string) (string, string, bool) { + normalizedFromVersion := normalizeDispatcherVersion(fromVersion) + normalizedToVersion := normalizeDispatcherVersion(toVersion) + changed := normalizedFromVersion != "" && normalizedToVersion != "" && normalizedFromVersion != normalizedToVersion + return normalizedFromVersion, normalizedToVersion, changed +} + +func writeOptionalDispatcherUpdateCompletion(stderr io.Writer, fromVersion string, toVersion string) { + normalizedFromVersion, normalizedToVersion, changed := normalizedDispatcherUpdateVersions(fromVersion, toVersion) + if !changed { + return + } + writeFormat( + stderr, + "uloop: dispatcher updated from %s to %s. Future uloop commands will use the updated launcher.\n", + normalizedFromVersion, + normalizedToVersion) +} + +func writeManualDispatcherUpdateCompletion(stdout io.Writer, fromVersion string, toVersion string) { + if toVersion == "" { + writeLine(stdout, "uloop launcher update completed.") + return + } + normalizedFromVersion, normalizedToVersion, changed := normalizedDispatcherUpdateVersions(fromVersion, toVersion) + if !changed { + writeLine(stdout, "uloop launcher is already up to date at "+normalizedToVersion+".") + return + } + writeLine(stdout, "uloop launcher updated from "+normalizedFromVersion+" to "+normalizedToVersion+".") +} diff --git a/cli/internal/cli/dispatcher_update_version_test.go b/cli/internal/cli/dispatcher_update_version_test.go new file mode 100644 index 000000000..cae545ca7 --- /dev/null +++ b/cli/internal/cli/dispatcher_update_version_test.go @@ -0,0 +1,25 @@ +package cli + +import ( + "bytes" + "testing" +) + +func TestDispatcherVersionChangedNormalizesVersionPrefix(t *testing.T) { + // Verifies v-prefixed and unprefixed dispatcher versions are treated as the same release. + if dispatcherVersionChanged("v3.0.1-beta.3", "3.0.1-beta.3") { + t.Fatal("expected equivalent dispatcher versions to be unchanged") + } +} + +func TestWriteOptionalDispatcherUpdateCompletionReportsNormalizedVersions(t *testing.T) { + // Verifies dispatcher update messages report canonical versions after prefix normalization. + var stderr bytes.Buffer + + writeOptionalDispatcherUpdateCompletion(&stderr, "v3.0.1-beta.2", "3.0.1-beta.3") + + expected := "uloop: dispatcher updated from 3.0.1-beta.2 to 3.0.1-beta.3" + if !bytes.Contains(stderr.Bytes(), []byte(expected)) { + t.Fatalf("update output mismatch: %s", stderr.String()) + } +} diff --git a/cli/internal/cli/update.go b/cli/internal/cli/update.go index bd38060d9..1daeaf48e 100644 --- a/cli/internal/cli/update.go +++ b/cli/internal/cli/update.go @@ -6,7 +6,6 @@ import ( "os/exec" "runtime" - clicontract "github.com/hatayama/unity-cli-loop/cli" "github.com/hatayama/unity-cli-loop/cli/internal/update" ) @@ -16,6 +15,8 @@ const ( updateToVersionFlagName = "to-version" ) +var updateRunCommand = runUpdateCommand + type updateOptions struct { targetVersion string } @@ -35,7 +36,7 @@ func tryHandleUpdateRequest(ctx context.Context, args []string, stdout io.Writer } updateCommand, err := update.CommandForOS(runtime.GOOS, update.Options{ - CurrentVersion: clicontract.DispatcherCurrent.DispatcherVersion, + CurrentVersion: dispatcherVersion, TargetVersion: options.targetVersion, }) if err != nil { @@ -44,10 +45,7 @@ func tryHandleUpdateRequest(ctx context.Context, args []string, stdout io.Writer } writeLine(stdout, "Updating global uloop launcher...") - command := exec.CommandContext(ctx, updateCommand.Name, updateCommand.Args...) - command.Stdout = stdout - command.Stderr = stderr - if err := command.Run(); err != nil { + if err := updateRunCommand(ctx, updateCommand, stdout, stderr); err != nil { writeErrorEnvelope(stderr, cliError{ ErrorCode: errorCodeInternalError, Phase: errorPhaseExecution, @@ -62,7 +60,7 @@ func tryHandleUpdateRequest(ctx context.Context, args []string, stdout io.Writer }) return true, 1 } - writeLine(stdout, "uloop launcher update completed.") + writeManualDispatcherUpdateCompletion(stdout, dispatcherVersion, dispatcherInstalledVersionOrEmpty(ctx)) return true, 0 } @@ -72,7 +70,7 @@ func updateCommandForOS(goos string) (string, []string, error) { func updateCommandForOSWithOptions(goos string, options updateOptions) (string, []string, error) { command, err := update.CommandForOS(goos, update.Options{ - CurrentVersion: clicontract.DispatcherCurrent.DispatcherVersion, + CurrentVersion: dispatcherVersion, TargetVersion: options.targetVersion, }) if err != nil { @@ -81,6 +79,13 @@ func updateCommandForOSWithOptions(goos string, options updateOptions) (string, return command.Name, command.Args, nil } +func runUpdateCommand(ctx context.Context, updateCommand update.Command, stdout io.Writer, stderr io.Writer) error { + command := exec.CommandContext(ctx, updateCommand.Name, updateCommand.Args...) + command.Stdout = stdout + command.Stderr = stderr + return command.Run() +} + func printUpdateHelp(stdout io.Writer) { writeLine(stdout, "Usage:") writeLine(stdout, " uloop update [--to-version ]") diff --git a/cli/internal/cli/update_test.go b/cli/internal/cli/update_test.go index 40d0eca23..bbacb7414 100644 --- a/cli/internal/cli/update_test.go +++ b/cli/internal/cli/update_test.go @@ -1,6 +1,10 @@ package cli import ( + "bytes" + "context" + "io" + "runtime" "strings" "testing" @@ -187,6 +191,50 @@ func TestParseUpdateOptionsRejectsInvalidVersion(t *testing.T) { } } +func TestTryHandleUpdateRequestReportsVersionChange(t *testing.T) { + // Verifies manual dispatcher updates tell users which launcher version was installed. + skipWhenNativeUpdateIsUnsupported(t) + restoreUpdateHooks := stubManualUpdateHooks(t, "9.9.9") + defer restoreUpdateHooks() + + var stdout bytes.Buffer + var stderr bytes.Buffer + handled, code := tryHandleUpdateRequest(context.Background(), []string{updateCommandName}, &stdout, &stderr) + + if !handled || code != 0 { + t.Fatalf("update result mismatch: handled=%t code=%d stderr=%s", handled, code, stderr.String()) + } + expected := "uloop launcher updated from " + dispatcherVersion + " to 9.9.9." + if !bytes.Contains(stdout.Bytes(), []byte(expected)) { + t.Fatalf("update output mismatch: %s", stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("expected no stderr output, got: %s", stderr.String()) + } +} + +func TestTryHandleUpdateRequestReportsAlreadyCurrentVersion(t *testing.T) { + // Verifies manual dispatcher updates explain when the selected release matches the installed launcher. + skipWhenNativeUpdateIsUnsupported(t) + restoreUpdateHooks := stubManualUpdateHooks(t, dispatcherVersion) + defer restoreUpdateHooks() + + var stdout bytes.Buffer + var stderr bytes.Buffer + handled, code := tryHandleUpdateRequest(context.Background(), []string{updateCommandName}, &stdout, &stderr) + + if !handled || code != 0 { + t.Fatalf("update result mismatch: handled=%t code=%d stderr=%s", handled, code, stderr.String()) + } + expected := "uloop launcher is already up to date at " + dispatcherVersion + "." + if !bytes.Contains(stdout.Bytes(), []byte(expected)) { + t.Fatalf("update output mismatch: %s", stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("expected no stderr output, got: %s", stderr.String()) + } +} + func TestUpdateCommandForLinuxIsUnsupported(t *testing.T) { // Verifies Linux update fails before trying to run a platform-specific update. _, _, err := updateCommandForOS("linux") @@ -205,3 +253,27 @@ func TestUpdateCommandRejectsUnsupportedOS(t *testing.T) { t.Fatal("expected unsupported OS error") } } + +func skipWhenNativeUpdateIsUnsupported(t *testing.T) { + t.Helper() + if runtime.GOOS == "darwin" || runtime.GOOS == "windows" { + return + } + t.Skip("native update is supported only on macOS and Windows") +} + +func stubManualUpdateHooks(t *testing.T, updatedVersion string) func() { + t.Helper() + previousRunner := updateRunCommand + previousReader := dispatcherReadInstalledVersion + updateRunCommand = func(context.Context, update.Command, io.Writer, io.Writer) error { + return nil + } + dispatcherReadInstalledVersion = func(context.Context) (string, error) { + return updatedVersion, nil + } + return func() { + updateRunCommand = previousRunner + dispatcherReadInstalledVersion = previousReader + } +}