diff --git a/.github/workflows/install-scripts.yml b/.github/workflows/install-scripts.yml index c2edc28..4a8d2eb 100644 --- a/.github/workflows/install-scripts.yml +++ b/.github/workflows/install-scripts.yml @@ -60,15 +60,16 @@ jobs: aws configure set default.response_checksum_validation when_required PREFIX="${PREFIX#/}"; PREFIX="${PREFIX%/}" - # Bake the CDN as the default MIRROR_URL into the copy we serve from the - # CDN, so `curl /install.sh | sh` pulls binaries from the CDN with - # no MIRROR_URL arg. The repo / GitHub copy stays generic (GitHub default). src_sh=install.sh + src_ps1=install.ps1 if [ -n "${MIRROR_PUBLIC_URL:-}" ]; then pub="${MIRROR_PUBLIC_URL%/}${PREFIX:+/${PREFIX}}" - sed "s#MIRROR_URL=\"\${MIRROR_URL:-}\"#MIRROR_URL=\"\${MIRROR_URL:-${pub}}\"#" install.sh > /tmp/install.sh - grep -q "MIRROR_URL:-${pub}" /tmp/install.sh || { echo "ERROR: MIRROR_URL default not injected (install.sh default line changed?)" >&2; exit 1; } + sed "s#^DEFAULT_MIRROR_URL=.*#DEFAULT_MIRROR_URL=\"${pub}\"#" install.sh > /tmp/install.sh + grep -q "DEFAULT_MIRROR_URL=\"${pub}\"" /tmp/install.sh || { echo "ERROR: MIRROR_URL default not injected (install.sh default line changed?)" >&2; exit 1; } src_sh=/tmp/install.sh + sed "s#^\$DefaultMirrorUrl = .*#\$DefaultMirrorUrl = \"${pub}\"#" install.ps1 > /tmp/install.ps1 + grep -q "\$DefaultMirrorUrl = \"${pub}\"" /tmp/install.ps1 || { echo "ERROR: MIRROR_URL default not injected (install.ps1 default line changed?)" >&2; exit 1; } + src_ps1=/tmp/install.ps1 fi sh_key="${PREFIX:+${PREFIX}/}install.sh" aws --endpoint-url="$ENDPOINT" s3 cp "$src_sh" "s3://${BUCKET}/${sh_key}" \ @@ -76,6 +77,6 @@ jobs: --content-type "text/x-shellscript; charset=utf-8" ps1_key="${PREFIX:+${PREFIX}/}install.ps1" - aws --endpoint-url="$ENDPOINT" s3 cp install.ps1 "s3://${BUCKET}/${ps1_key}" \ + aws --endpoint-url="$ENDPOINT" s3 cp "$src_ps1" "s3://${BUCKET}/${ps1_key}" \ --cache-control "public, max-age=300" \ --content-type "text/plain; charset=utf-8" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3f20310..2155b22 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,21 +103,22 @@ jobs: # ships a stale/missing installer (install-scripts.yml only fires when # install.sh/.ps1 change on main; the scripts are version-agnostic, so # re-uploading the current copy here is the belt-and-suspenders guarantee). - # Bake the CDN as the default MIRROR_URL into the served copy so - # `curl /install.sh | sh` pulls binaries from the CDN with no - # MIRROR_URL arg. The repo / GitHub copy stays generic (GitHub default). src_sh=install.sh + src_ps1=install.ps1 if [ -n "${MIRROR_PUBLIC_URL:-}" ]; then pub="${MIRROR_PUBLIC_URL%/}${PREFIX:+/${PREFIX}}" - sed "s#MIRROR_URL=\"\${MIRROR_URL:-}\"#MIRROR_URL=\"\${MIRROR_URL:-${pub}}\"#" install.sh > /tmp/install.sh - grep -q "MIRROR_URL:-${pub}" /tmp/install.sh || { echo "ERROR: MIRROR_URL default not injected (install.sh default line changed?)" >&2; exit 1; } + sed "s#^DEFAULT_MIRROR_URL=.*#DEFAULT_MIRROR_URL=\"${pub}\"#" install.sh > /tmp/install.sh + grep -q "DEFAULT_MIRROR_URL=\"${pub}\"" /tmp/install.sh || { echo "ERROR: MIRROR_URL default not injected (install.sh default line changed?)" >&2; exit 1; } src_sh=/tmp/install.sh + sed "s#^\$DefaultMirrorUrl = .*#\$DefaultMirrorUrl = \"${pub}\"#" install.ps1 > /tmp/install.ps1 + grep -q "\$DefaultMirrorUrl = \"${pub}\"" /tmp/install.ps1 || { echo "ERROR: MIRROR_URL default not injected (install.ps1 default line changed?)" >&2; exit 1; } + src_ps1=/tmp/install.ps1 fi sh_key="${PREFIX:+${PREFIX}/}install.sh" aws --endpoint-url="$ENDPOINT" s3 cp "$src_sh" "s3://${BUCKET}/${sh_key}" \ --cache-control "public, max-age=300" \ --content-type "text/x-shellscript; charset=utf-8" ps1_key="${PREFIX:+${PREFIX}/}install.ps1" - aws --endpoint-url="$ENDPOINT" s3 cp install.ps1 "s3://${BUCKET}/${ps1_key}" \ + aws --endpoint-url="$ENDPOINT" s3 cp "$src_ps1" "s3://${BUCKET}/${ps1_key}" \ --cache-control "public, max-age=300" \ --content-type "text/plain; charset=utf-8" diff --git a/README.md b/README.md index e87e094..fd59fdd 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,13 @@ A command-line interface for the [Flashduty](https://flashcat.cloud) platform. M ### macOS / Linux ```bash -curl -sSL https://raw.githubusercontent.com/flashcatcloud/flashduty-cli/main/install.sh | sh +curl -sSL https://static.flashcat.cloud/flashduty-cli/install.sh | sh ``` ### Windows (PowerShell) ```powershell -irm https://raw.githubusercontent.com/flashcatcloud/flashduty-cli/main/install.ps1 | iex +irm https://static.flashcat.cloud/flashduty-cli/install.ps1 | iex ``` ### Manual Download @@ -33,6 +33,8 @@ Download the latest release for your platform from [GitHub Releases](https://git |----------|-------------|---------| | `FLASHDUTY_VERSION` | Install a specific version (e.g. `v0.1.2`) | latest | | `FLASHDUTY_INSTALL_DIR` | Custom install directory | `/usr/local/bin` (shell), `~\.flashduty\bin` (PowerShell) | +| `MIRROR_URL` | Override installer release asset mirror | `https://static.flashcat.cloud/flashduty-cli` | +| `FLASHDUTY_UPDATE_BASE_URL` | Override `flashduty update` and auto update-check base URL | `https://static.flashcat.cloud/flashduty-cli` | ## Agent Skills diff --git a/README_zh.md b/README_zh.md index 746903e..a2b66d8 100644 --- a/README_zh.md +++ b/README_zh.md @@ -14,13 +14,13 @@ ### macOS / Linux ```bash -curl -sSL https://raw.githubusercontent.com/flashcatcloud/flashduty-cli/main/install.sh | sh +curl -sSL https://static.flashcat.cloud/flashduty-cli/install.sh | sh ``` ### Windows (PowerShell) ```powershell -irm https://raw.githubusercontent.com/flashcatcloud/flashduty-cli/main/install.ps1 | iex +irm https://static.flashcat.cloud/flashduty-cli/install.ps1 | iex ``` ### 手动下载 @@ -33,6 +33,8 @@ irm https://raw.githubusercontent.com/flashcatcloud/flashduty-cli/main/install.p |------|------|--------| | `FLASHDUTY_VERSION` | 安装指定版本(如 `v0.1.2`) | 最新版 | | `FLASHDUTY_INSTALL_DIR` | 自定义安装目录 | `/usr/local/bin`(Shell)、`~\.flashduty\bin`(PowerShell) | +| `MIRROR_URL` | 覆盖安装脚本使用的 release 资源镜像 | `https://static.flashcat.cloud/flashduty-cli` | +| `FLASHDUTY_UPDATE_BASE_URL` | 覆盖 `flashduty update` 和自动更新检查的 base URL | `https://static.flashcat.cloud/flashduty-cli` | ## Agent Skills(AI 代理技能) diff --git a/install.ps1 b/install.ps1 index d0fd128..cc9e418 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,11 +1,12 @@ # Flashduty CLI installer for Windows -# Usage: irm https://raw.githubusercontent.com/flashcatcloud/flashduty-cli/main/install.ps1 | iex +# Usage: irm https://static.flashcat.cloud/flashduty-cli/install.ps1 | iex # # Environment variables: # FLASHDUTY_VERSION - specific version to install (e.g. "v0.1.2") # FLASHDUTY_INSTALL_DIR - install directory (default: $HOME\.flashduty\bin) -# MIRROR_URL - fetch release assets from this https mirror prefix -# instead of github.com. The mirror must replicate +# MIRROR_URL - fetch release assets from this https mirror prefix. +# Default: https://static.flashcat.cloud/flashduty-cli. +# The mirror must replicate # GitHub's release layout # (/releases/download//) and # expose a plain-text /releases/latest file @@ -18,8 +19,13 @@ $Repo = "flashcatcloud/flashduty-cli" $Binary = "flashduty-cli.exe" $InstalledName = "flashduty.exe" -# When set, all release downloads are fetched from this prefix instead of github.com. -$MirrorUrl = $env:MIRROR_URL +# By default release downloads are fetched from the Flashcat CDN. Set MIRROR_URL +# to another prefix to override, or to an empty string to force GitHub fallback. +$DefaultMirrorUrl = "https://static.flashcat.cloud/flashduty-cli" +$MirrorUrl = [Environment]::GetEnvironmentVariable("MIRROR_URL") +if ($null -eq $MirrorUrl) { + $MirrorUrl = $DefaultMirrorUrl +} if ($MirrorUrl) { $MirrorUrl = $MirrorUrl.TrimEnd('/') if ($MirrorUrl -notlike "https://*") { diff --git a/install.sh b/install.sh index 4da7abf..169ed8c 100755 --- a/install.sh +++ b/install.sh @@ -1,12 +1,13 @@ #!/bin/sh # Flashduty CLI installer -# Usage: curl -sSL https://raw.githubusercontent.com/flashcatcloud/flashduty-cli/main/install.sh | sh +# Usage: curl -sSL https://static.flashcat.cloud/flashduty-cli/install.sh | sh # # Environment: # FLASHDUTY_VERSION Install a specific version (e.g. v0.1.2). Default: latest. # FLASHDUTY_INSTALL_DIR Install directory. Default: /usr/local/bin. -# MIRROR_URL Fetch release assets from this https mirror prefix -# instead of github.com. The mirror must replicate +# MIRROR_URL Fetch release assets from this https mirror prefix. +# Default: https://static.flashcat.cloud/flashduty-cli. +# The mirror must replicate # GitHub's release layout # (/releases/download//) and expose # a plain-text /releases/latest file containing @@ -18,8 +19,10 @@ BINARY="flashduty-cli" INSTALLED_NAME="${INSTALLED_NAME:-flashduty}" INSTALL_DIR="${FLASHDUTY_INSTALL_DIR:-/usr/local/bin}" -# When set, all release downloads are fetched from this prefix instead of github.com. -MIRROR_URL="${MIRROR_URL:-}" +# By default release downloads are fetched from the Flashcat CDN. Set MIRROR_URL +# to another prefix to override, or to an empty string to force GitHub fallback. +DEFAULT_MIRROR_URL="https://static.flashcat.cloud/flashduty-cli" +MIRROR_URL="${MIRROR_URL-${DEFAULT_MIRROR_URL}}" MIRROR_URL="${MIRROR_URL%/}" if [ -n "${MIRROR_URL}" ]; then case "${MIRROR_URL}" in diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index 813d58d..6915794 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -24,6 +24,8 @@ func saveAndResetGlobals(t *testing.T) { origFlagAppKey := flagAppKey origFlagBaseURL := flagBaseURL origFlagOutputFormat := flagOutputFormat + origUpdateNotice := updateNotice + origUpdateCheckWarning := updateCheckWarning origStdinReader := stdinReader // Reset to defaults so tests start clean. @@ -32,6 +34,8 @@ func saveAndResetGlobals(t *testing.T) { flagAppKey = "" flagBaseURL = "" flagOutputFormat = "" + updateNotice = nil + updateCheckWarning = "" t.Cleanup(func() { newClientFn = origNewClientFn @@ -40,6 +44,8 @@ func saveAndResetGlobals(t *testing.T) { flagAppKey = origFlagAppKey flagBaseURL = origFlagBaseURL flagOutputFormat = origFlagOutputFormat + updateNotice = origUpdateNotice + updateCheckWarning = origUpdateCheckWarning stdinReader = origStdinReader }) } diff --git a/internal/cli/root.go b/internal/cli/root.go index bde2eb4..0c27475 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -32,6 +32,9 @@ var ( ) var updateNotice *update.CheckResult +var updateCheckWarning string +var isTerminalFn = term.IsTerminal +var checkForUpdateAutoFn = update.CheckForUpdateAuto var rootCmd = &cobra.Command{ Use: "flashduty", @@ -50,27 +53,41 @@ var rootCmd = &cobra.Command{ return err } } + updateNotice = nil + updateCheckWarning = "" if cmd.CommandPath() == "flashduty update" { return nil } - if !term.IsTerminal(int(os.Stderr.Fd())) { + if !isTerminalFn(int(os.Stderr.Fd())) { return nil } - updateNotice = update.StateHasUpdate(versionStr) if update.ShouldCheck(versionStr) { - go func() { - _, _ = update.CheckForUpdate(versionStr) - }() + result, err := checkForUpdateAutoFn(versionStr) + if err != nil { + if update.IsTimeout(err) { + updateCheckWarning = "auto update check timeout, please run 'flashduty update --check' manually" + } else { + updateNotice = update.StateHasUpdate(versionStr) + } + return nil + } + if result.UpdateAvailable { + updateNotice = result + } + return nil } + updateNotice = update.StateHasUpdate(versionStr) return nil }, - PersistentPostRun: func(_ *cobra.Command, _ []string) { - if updateNotice == nil { - return + PersistentPostRun: func(cmd *cobra.Command, _ []string) { + if updateCheckWarning != "" { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "\n%s\n", updateCheckWarning) + } + if updateNotice != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "\nA new version of flashduty is available: v%s -> %s\n", + update.StripV(updateNotice.CurrentVersion), updateNotice.LatestVersion) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "To update, run: flashduty update\n") } - _, _ = fmt.Fprintf(os.Stderr, "\nA new version of flashduty is available: v%s -> %s\n", - update.StripV(updateNotice.CurrentVersion), updateNotice.LatestVersion) - _, _ = fmt.Fprintf(os.Stderr, "To update, run: flashduty update\n") }, } diff --git a/internal/cli/root_update_test.go b/internal/cli/root_update_test.go new file mode 100644 index 0000000..83656d4 --- /dev/null +++ b/internal/cli/root_update_test.go @@ -0,0 +1,54 @@ +package cli + +import ( + "context" + "runtime" + "strings" + "testing" + + "github.com/flashcatcloud/flashduty-cli/internal/update" +) + +func TestRootAutoUpdateCheckTimeoutWarnsAfterCommand(t *testing.T) { + saveAndResetGlobals(t) + tmp := t.TempDir() + t.Setenv("HOME", tmp) + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", tmp) + } + t.Setenv("CI", "") + t.Setenv("GITHUB_ACTIONS", "") + t.Setenv("JENKINS_URL", "") + t.Setenv("GITLAB_CI", "") + t.Setenv("FLASHDUTY_NO_UPDATE_CHECK", "") + + origVersion := versionStr + versionStr = "0.6.0" + t.Cleanup(func() { versionStr = origVersion }) + + origIsTerminal := isTerminalFn + isTerminalFn = func(int) bool { return true } + t.Cleanup(func() { isTerminalFn = origIsTerminal }) + + called := false + origCheck := checkForUpdateAutoFn + checkForUpdateAutoFn = func(string) (*update.CheckResult, error) { + called = true + return nil, context.DeadlineExceeded + } + t.Cleanup(func() { checkForUpdateAutoFn = origCheck }) + + out, err := execCommand("version") + if err != nil { + t.Fatalf("version command should still run when auto update check times out: %v", err) + } + if !called { + t.Fatal("auto update check was not called") + } + if !strings.Contains(out, "flashduty version 0.6.0") { + t.Fatalf("version output missing, got:\n%s", out) + } + if !strings.Contains(out, "auto update check timeout, please run 'flashduty update --check' manually") { + t.Fatalf("timeout guidance missing, got:\n%s", out) + } +} diff --git a/internal/cli/update.go b/internal/cli/update.go index 7b738af..921aaef 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.go @@ -50,18 +50,13 @@ func newUpdateCmd() *cobra.Command { } func runInstaller(cmd *cobra.Command) error { - var c *exec.Cmd - if runtime.GOOS == "windows" { - c = exec.Command("powershell", "-Command", - fmt.Sprintf("irm %s | iex", update.InstallPowerShellURL())) - } else { - c = exec.Command("sh", "-c", - fmt.Sprintf("curl -fsSL %s | sh", update.InstallShellURL())) - } + name, args := installerCommandSpec(runtime.GOOS, update.InstallShellURL(), update.InstallPowerShellURL()) + c := exec.Command(name, args...) c.Stdout = cmd.OutOrStdout() c.Stderr = cmd.ErrOrStderr() c.Stdin = os.Stdin + c.Env = update.InstallerEnv(os.Environ()) if err := c.Run(); err != nil { return fmt.Errorf("update failed: %w", err) @@ -70,3 +65,21 @@ func runInstaller(cmd *cobra.Command) error { _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nUpdate complete. Run 'flashduty version' to verify.\n") return nil } + +func installerCommandSpec(goos, shellURL, powerShellURL string) (string, []string) { + if goos == "windows" { + return "powershell", []string{ + "-ExecutionPolicy", + "Bypass", + "-Command", + "$u = $args[0]; irm $u | iex", + powerShellURL, + } + } + return "sh", []string{ + "-c", + `curl -fsSL "$1" | sh`, + "flashduty-installer", + shellURL, + } +} diff --git a/internal/cli/update_test.go b/internal/cli/update_test.go new file mode 100644 index 0000000..acf05e6 --- /dev/null +++ b/internal/cli/update_test.go @@ -0,0 +1,36 @@ +package cli + +import ( + "slices" + "strings" + "testing" +) + +func TestInstallerCommandSpecPassesInstallerURLAsArgument(t *testing.T) { + shellURL := `https://mirror.example.com/fduty/install.sh; echo injected` + psURL := `https://mirror.example.com/fduty/install.ps1; Write-Host injected` + + name, args := installerCommandSpec("linux", shellURL, psURL) + if name != "sh" { + t.Fatalf("unix installer command = %q, want sh", name) + } + if len(args) == 0 || args[len(args)-1] != shellURL { + t.Fatalf("unix installer URL should be passed as the last argument, got %#v", args) + } + if strings.Contains(strings.Join(args[:len(args)-1], " "), "mirror.example.com") { + t.Fatalf("unix installer URL was interpolated into shell command args: %#v", args) + } + + name, args = installerCommandSpec("windows", shellURL, psURL) + if name != "powershell" { + t.Fatalf("windows installer command = %q, want powershell", name) + } + if !slices.Contains(args, psURL) { + t.Fatalf("windows installer URL should be passed as an argument, got %#v", args) + } + for _, arg := range args { + if arg != psURL && strings.Contains(arg, "mirror.example.com") { + t.Fatalf("windows installer URL was interpolated into PowerShell command: %#v", args) + } + } +} diff --git a/internal/update/check.go b/internal/update/check.go index ba207e2..ffc71ed 100644 --- a/internal/update/check.go +++ b/internal/update/check.go @@ -1,9 +1,11 @@ package update import ( - "encoding/json" + "context" + "errors" "fmt" "io" + "net" "net/http" "os" "path/filepath" @@ -15,17 +17,16 @@ import ( ) const ( - repoOwner = "flashcatcloud" - repoName = "flashduty-cli" - checkInterval = 24 * time.Hour - httpTimeout = 5 * time.Second - stateFileName = "state.yaml" - installShURL = "https://raw.githubusercontent.com/" + repoOwner + "/" + repoName + "/main/install.sh" - installPs1URL = "https://raw.githubusercontent.com/" + repoOwner + "/" + repoName + "/main/install.ps1" - maxResponseBytes = 1 << 20 // 1MB + repoOwner = "flashcatcloud" + repoName = "flashduty-cli" + defaultUpdateBaseURL = "https://static.flashcat.cloud/flashduty-cli" + checkInterval = 24 * time.Hour + httpTimeout = 5 * time.Second + stateFileName = "state.yaml" + maxResponseBytes = 1 << 20 // 1MB ) -var apiURL = "https://api.github.com/repos/" + repoOwner + "/" + repoName + "/releases/latest" +var autoHTTPTimeout = 2 * time.Second type State struct { CheckedAt time.Time `yaml:"checked_at"` @@ -40,13 +41,37 @@ type CheckResult struct { UpdateAvailable bool } -type githubRelease struct { - TagName string `json:"tag_name"` - HTMLURL string `json:"html_url"` +func UpdateBaseURL() string { + if v := strings.TrimSpace(os.Getenv("FLASHDUTY_UPDATE_BASE_URL")); v != "" { + return strings.TrimRight(v, "/") + } + if v := strings.TrimSpace(os.Getenv("MIRROR_URL")); v != "" { + return strings.TrimRight(v, "/") + } + return defaultUpdateBaseURL +} + +func InstallShellURL() string { return UpdateBaseURL() + "/install.sh" } +func InstallPowerShellURL() string { return UpdateBaseURL() + "/install.ps1" } + +func InstallerEnv(base []string) []string { + env := make([]string, 0, len(base)+1) + for _, item := range base { + if strings.HasPrefix(item, "MIRROR_URL=") { + continue + } + env = append(env, item) + } + return append(env, "MIRROR_URL="+UpdateBaseURL()) +} + +func latestPointerURL() string { + return UpdateBaseURL() + "/releases/latest" } -func InstallShellURL() string { return installShURL } -func InstallPowerShellURL() string { return installPs1URL } +func releasePageURL(tag string) string { + return "https://github.com/" + repoOwner + "/" + repoName + "/releases/tag/" + tag +} func stateDir() (string, error) { home, err := os.UserHomeDir() @@ -100,25 +125,50 @@ func saveState(s *State) error { } func fetchLatestVersion() (string, string, error) { - client := &http.Client{Timeout: httpTimeout} - resp, err := client.Get(apiURL) + return fetchLatestVersionWithTimeout(httpTimeout) +} + +func fetchLatestVersionWithTimeout(timeout time.Duration) (string, string, error) { + client := &http.Client{Timeout: timeout} + resp, err := client.Get(latestPointerURL()) if err != nil { return "", "", fmt.Errorf("failed to fetch latest release: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return "", "", fmt.Errorf("GitHub API returned %d", resp.StatusCode) + return "", "", fmt.Errorf("latest release endpoint returned %d", resp.StatusCode) } - var rel githubRelease - if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBytes)).Decode(&rel); err != nil { - return "", "", fmt.Errorf("failed to parse release response: %w", err) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return "", "", fmt.Errorf("failed to read latest release response: %w", err) + } + tag, err := parseLatestTag(string(body)) + if err != nil { + return "", "", err + } + return tag, releasePageURL(tag), nil +} + +func parseLatestTag(body string) (string, error) { + line, _, _ := strings.Cut(body, "\n") + tag := strings.TrimSpace(line) + if tag == "" { + return "", fmt.Errorf("empty latest release tag") } - if rel.TagName == "" { - return "", "", fmt.Errorf("empty tag_name in response") + if len(tag) < 2 || tag[0] != 'v' || tag[1] < '0' || tag[1] > '9' { + return "", fmt.Errorf("latest release tag is not valid: %q", tag) } - return rel.TagName, rel.HTMLURL, nil + for i := range len(tag) { + ch := tag[i] + if (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || + (ch >= '0' && ch <= '9') || ch == '.' || ch == '+' || ch == '-' { + continue + } + return "", fmt.Errorf("latest release tag contains illegal characters: %q", tag) + } + return tag, nil } func StripV(v string) string { @@ -179,7 +229,22 @@ func ShouldCheck(currentVersion string) bool { } func CheckForUpdate(currentVersion string) (*CheckResult, error) { - tag, url, err := fetchLatestVersion() + return checkForUpdateWithTimeout(currentVersion, httpTimeout) +} + +func CheckForUpdateAuto(currentVersion string) (*CheckResult, error) { + result, err := checkForUpdateWithTimeout(currentVersion, autoHTTPTimeout) + if err != nil { + if IsTimeout(err) { + _ = recordCheckAttempt() + } + return nil, err + } + return result, nil +} + +func checkForUpdateWithTimeout(currentVersion string, timeout time.Duration) (*CheckResult, error) { + tag, url, err := fetchLatestVersionWithTimeout(timeout) if err != nil { return nil, err } @@ -198,6 +263,23 @@ func CheckForUpdate(currentVersion string) (*CheckResult, error) { }, nil } +func recordCheckAttempt() error { + state := loadState() + state.CheckedAt = time.Now() + return saveState(state) +} + +func IsTimeout(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.DeadlineExceeded) { + return true + } + var netErr net.Error + return errors.As(err, &netErr) && netErr.Timeout() +} + func StateHasUpdate(currentVersion string) *CheckResult { if currentVersion == "dev" || currentVersion == "(devel)" { return nil diff --git a/internal/update/check_test.go b/internal/update/check_test.go index 78b98c8..51731af 100644 --- a/internal/update/check_test.go +++ b/internal/update/check_test.go @@ -1,12 +1,12 @@ package update import ( - "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" + "strings" "testing" "time" @@ -214,18 +214,13 @@ func TestLoadState_CorruptFile(t *testing.T) { func TestFetchLatestVersion(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - rel := githubRelease{ - TagName: "v0.7.0", - HTMLURL: "https://github.com/flashcatcloud/flashduty-cli/releases/tag/v0.7.0", - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(rel) + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("v0.7.0\n")) })) defer srv.Close() - origURL := apiURL - apiURL = srv.URL - defer func() { apiURL = origURL }() + t.Setenv("FLASHDUTY_UPDATE_BASE_URL", srv.URL) + t.Setenv("MIRROR_URL", "") tag, url, err := fetchLatestVersion() if err != nil { @@ -239,15 +234,100 @@ func TestFetchLatestVersion(t *testing.T) { } } +func TestFetchLatestVersion_FromCDNLatestPointer(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/releases/latest" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("v1.2.3\n")) + })) + defer srv.Close() + + t.Setenv("FLASHDUTY_UPDATE_BASE_URL", srv.URL+"/") + t.Setenv("MIRROR_URL", "") + + tag, url, err := fetchLatestVersion() + if err != nil { + t.Fatalf("fetchLatestVersion: %v", err) + } + if tag != "v1.2.3" { + t.Errorf("tag = %q, want %q", tag, "v1.2.3") + } + if url != "https://github.com/flashcatcloud/flashduty-cli/releases/tag/v1.2.3" { + t.Errorf("url = %q", url) + } +} + +func TestFetchLatestVersion_RejectsInvalidLatestPointer(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("../bad\n")) + })) + defer srv.Close() + + t.Setenv("FLASHDUTY_UPDATE_BASE_URL", srv.URL) + t.Setenv("MIRROR_URL", "") + + _, _, err := fetchLatestVersion() + if err == nil { + t.Fatal("expected invalid latest pointer to fail") + } +} + +func TestUpdateBaseURLAndInstallerURLs(t *testing.T) { + t.Setenv("FLASHDUTY_UPDATE_BASE_URL", "") + t.Setenv("MIRROR_URL", "") + if got := UpdateBaseURL(); got != defaultUpdateBaseURL { + t.Fatalf("UpdateBaseURL() = %q, want %q", got, defaultUpdateBaseURL) + } + if got := InstallShellURL(); got != "https://static.flashcat.cloud/flashduty-cli/install.sh" { + t.Fatalf("InstallShellURL() = %q", got) + } + if got := InstallPowerShellURL(); got != "https://static.flashcat.cloud/flashduty-cli/install.ps1" { + t.Fatalf("InstallPowerShellURL() = %q", got) + } + + t.Setenv("FLASHDUTY_UPDATE_BASE_URL", "https://mirror.example.com/fduty/") + if got := UpdateBaseURL(); got != "https://mirror.example.com/fduty" { + t.Fatalf("UpdateBaseURL() override = %q", got) + } + if got := InstallShellURL(); got != "https://mirror.example.com/fduty/install.sh" { + t.Fatalf("InstallShellURL() override = %q", got) + } + if got := InstallPowerShellURL(); got != "https://mirror.example.com/fduty/install.ps1" { + t.Fatalf("InstallPowerShellURL() override = %q", got) + } +} + +func TestInstallerEnvPassesUpdateBaseAsMirrorURL(t *testing.T) { + t.Setenv("FLASHDUTY_UPDATE_BASE_URL", "https://mirror.example.com/fduty/") + t.Setenv("MIRROR_URL", "") + + env := InstallerEnv([]string{"PATH=/bin", "MIRROR_URL=https://old.example.com"}) + want := "MIRROR_URL=https://mirror.example.com/fduty" + found := 0 + for _, item := range env { + if strings.HasPrefix(item, "MIRROR_URL=") { + found++ + if item != want { + t.Fatalf("MIRROR_URL entry = %q, want %q", item, want) + } + } + } + if found != 1 { + t.Fatalf("found %d MIRROR_URL entries, want 1 in %#v", found, env) + } +} + func TestFetchLatestVersion_Error(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) })) defer srv.Close() - origURL := apiURL - apiURL = srv.URL - defer func() { apiURL = origURL }() + t.Setenv("FLASHDUTY_UPDATE_BASE_URL", srv.URL) + t.Setenv("MIRROR_URL", "") _, _, err := fetchLatestVersion() if err == nil { @@ -255,20 +335,19 @@ func TestFetchLatestVersion_Error(t *testing.T) { } } -func TestFetchLatestVersion_InvalidJSON(t *testing.T) { +func TestFetchLatestVersion_EmptyLatestPointer(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{not valid json`)) + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("\n")) })) defer srv.Close() - origURL := apiURL - apiURL = srv.URL - defer func() { apiURL = origURL }() + t.Setenv("FLASHDUTY_UPDATE_BASE_URL", srv.URL) + t.Setenv("MIRROR_URL", "") _, _, err := fetchLatestVersion() if err == nil { - t.Error("expected error for invalid JSON response") + t.Error("expected error for empty latest pointer") } } @@ -277,18 +356,13 @@ func TestCheckForUpdate(t *testing.T) { setTestHome(t, tmp) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - rel := githubRelease{ - TagName: "v0.7.0", - HTMLURL: "https://github.com/flashcatcloud/flashduty-cli/releases/tag/v0.7.0", - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(rel) + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("v0.7.0\n")) })) defer srv.Close() - origURL := apiURL - apiURL = srv.URL - defer func() { apiURL = origURL }() + t.Setenv("FLASHDUTY_UPDATE_BASE_URL", srv.URL) + t.Setenv("MIRROR_URL", "") result, err := CheckForUpdate("0.6.0") if err != nil { @@ -307,6 +381,65 @@ func TestCheckForUpdate(t *testing.T) { } } +func TestCheckForUpdateAuto_RecordsAttemptOnTimeout(t *testing.T) { + tmp := t.TempDir() + setTestHome(t, tmp) + clearCIEnv(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(50 * time.Millisecond) + _, _ = w.Write([]byte("v0.7.0\n")) + })) + defer srv.Close() + + t.Setenv("FLASHDUTY_UPDATE_BASE_URL", srv.URL) + t.Setenv("MIRROR_URL", "") + + origTimeout := autoHTTPTimeout + autoHTTPTimeout = time.Nanosecond + defer func() { autoHTTPTimeout = origTimeout }() + + _, err := CheckForUpdateAuto("0.6.0") + if err == nil { + t.Fatal("expected timeout error") + } + if !IsTimeout(err) { + t.Fatalf("IsTimeout(%v) = false, want true", err) + } + if ShouldCheck("0.6.0") { + t.Fatal("ShouldCheck should be false after an auto-check timeout records today's attempt") + } + state := loadState() + if time.Since(state.CheckedAt) > time.Minute { + t.Fatalf("CheckedAt was not refreshed after timeout: %v", state.CheckedAt) + } +} + +func TestCheckForUpdateAuto_DoesNotRecordAttemptOnNonTimeoutError(t *testing.T) { + tmp := t.TempDir() + setTestHome(t, tmp) + clearCIEnv(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + t.Setenv("FLASHDUTY_UPDATE_BASE_URL", srv.URL) + t.Setenv("MIRROR_URL", "") + + _, err := CheckForUpdateAuto("0.6.0") + if err == nil { + t.Fatal("expected error") + } + if IsTimeout(err) { + t.Fatalf("IsTimeout(%v) = true, want false", err) + } + if !ShouldCheck("0.6.0") { + t.Fatal("ShouldCheck should stay true after a non-timeout auto-check error") + } +} + func TestStateHasUpdate(t *testing.T) { tmp := t.TempDir() setTestHome(t, tmp)