Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/dispatcher-contract.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"schemaVersion": 1,
"dispatcherVersion": "3.0.1-beta.2",
"dispatcherVersion": "3.0.1-beta.3",
"dispatcherContractVersion": 1
}
61 changes: 39 additions & 22 deletions cli/internal/cli/dispatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
}

Expand All @@ -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,
Expand Down
81 changes: 81 additions & 0 deletions cli/internal/cli/dispatcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
88 changes: 88 additions & 0 deletions cli/internal/cli/dispatcher_update_version.go
Original file line number Diff line number Diff line change
@@ -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+".")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
25 changes: 25 additions & 0 deletions cli/internal/cli/dispatcher_update_version_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
21 changes: 13 additions & 8 deletions cli/internal/cli/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -16,6 +15,8 @@ const (
updateToVersionFlagName = "to-version"
)

var updateRunCommand = runUpdateCommand

type updateOptions struct {
targetVersion string
}
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -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
}

Expand All @@ -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 {
Expand All @@ -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 <version>]")
Expand Down
Loading