From f3bd425fc8dd8b1d25dca5a2b4d92942dd8d2fc9 Mon Sep 17 00:00:00 2001 From: Zongqi Chen <137198879+zongqichen@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:57:42 +0200 Subject: [PATCH] feat: add safe update discovery --- .agents/skills/cfs/SKILL.md | 4 + CHANGELOG.md | 5 + README.md | 25 ++ SECURITY.md | 6 + cmd/cfs/main.go | 2 + docs/design.md | 23 +- docs/testing.md | 4 + go.mod | 6 +- go.sum | 4 + internal/app/app.go | 32 ++- internal/app/command_update.go | 90 +++++++ internal/app/command_update_test.go | 259 ++++++++++++++++++ internal/app/commands.go | 15 +- internal/envvar/envvar.go | 2 + internal/updatecheck/updatecheck.go | 321 +++++++++++++++++++++++ internal/updatecheck/updatecheck_test.go | 271 +++++++++++++++++++ 16 files changed, 1057 insertions(+), 12 deletions(-) create mode 100644 internal/app/command_update.go create mode 100644 internal/app/command_update_test.go create mode 100644 internal/updatecheck/updatecheck.go create mode 100644 internal/updatecheck/updatecheck_test.go diff --git a/.agents/skills/cfs/SKILL.md b/.agents/skills/cfs/SKILL.md index 2fd0192..c5ec861 100644 --- a/.agents/skills/cfs/SKILL.md +++ b/.agents/skills/cfs/SKILL.md @@ -15,3 +15,7 @@ Treat the cfs CLI as the source of truth. Do not inspect or edit CF configuratio Never select a context through `cf target`, a manual `CF_HOME`, `CFS_DISABLE`, or a direct official-CF binary path. Keep shared diagnostics both JSON-formatted and redacted. Selecting a context grants no authority to log in, deploy, import, create or remove contexts, change targets, or perform any other mutation. Run such commands only when the user has authorized that specific operation. If inspection shows the selected context is unavailable or not logged in, report that state and ask before changing it. + +When asked whether cfs itself has an update, run `cfs update --json`. This check +is read-only. Report its `status` and `latest_version`; do not execute any +returned update command unless the user separately authorizes installation. diff --git a/CHANGELOG.md b/CHANGELOG.md index b086821..4cf7add 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ contract may still change while cfs is pre-1.0. ## [Unreleased] +### Added + +- Added `cfs update` with agent-safe JSON output and cached, opt-out update + notices that never run from the transparent `cf` shim. + ## [0.2.0] - 2026-09-23 ### Added diff --git a/README.md b/README.md index 8c7ca3d..3c31296 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,30 @@ Another project or Git worktree receives a separate context automatically. Terminals and agents in the same worktree intentionally share its default context. +## Updating + +Check for a newer published release without changing the installed binary: + +```sh +cfs update +``` + +For scripts and coding agents, use `cfs update --json`. When an update is +available, follow the printed command or update through the same package manager +used for installation. A Go installation can be updated with: + +```sh +go install github.com/zongqichen/cfs/cmd/cfs@latest +cfs setup +cfs doctor +``` + +Successful interactive `cfs` control commands check at most once every 24 hours +and show one notice per new version. Checks never run from the transparent `cf` +shim, JSON output, CI, or non-interactive processes. Set +`CFS_NO_UPDATE_CHECK=1` to disable notices. Version checks contact only the public +GitHub Releases API and send no Cloud Foundry or workspace data. + ## Multiple targets in one project The normal `cf` command always uses the workspace's `default` context. Create a @@ -141,6 +165,7 @@ cfs reset Move this workspace's state to trash cfs gc Find stale workspace state cfs uninstall Remove the shim without deleting state cfs version Print version information +cfs update Check for a newer published release cfs help [command] Show help ``` diff --git a/SECURITY.md b/SECURITY.md index 7f2f7d6..4fed872 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -24,6 +24,12 @@ The project does not collect telemetry and must never log CF credentials or full command lines. CF plugins are executable code and should be installed only from trusted sources. +`cfs update` and the cached interactive update notice contact only the public +GitHub Releases API. Requests contain no Cloud Foundry target, credential, +workspace, context, or command data. The cache contains only public release +versions and timestamps. Set `CFS_NO_UPDATE_CHECK=1` to disable passive checks. +Update discovery never downloads or executes a replacement binary. + Managed `CF_HOME` directories contain the same authentication material as an ordinary official CF CLI home. Owner-only filesystem permissions protect them from other local users, but the files are not encrypted by `cfs`; backups and diff --git a/cmd/cfs/main.go b/cmd/cfs/main.go index 6bfe353..65fe763 100644 --- a/cmd/cfs/main.go +++ b/cmd/cfs/main.go @@ -5,6 +5,7 @@ import ( "runtime/debug" "github.com/zongqichen/cfs/internal/app" + "github.com/zongqichen/cfs/internal/updatecheck" ) var version = "dev" @@ -21,6 +22,7 @@ func main() { Version: resolvedVersion, Commit: resolvedCommit, BuildDate: resolvedBuildDate, + Updates: updatecheck.New(updatecheck.Options{}), })) } diff --git a/docs/design.md b/docs/design.md index 4dc5707..8cc7d0c 100644 --- a/docs/design.md +++ b/docs/design.md @@ -68,6 +68,8 @@ Cloud Foundry deployments. overwritten, renamed, or deleted. 10. **No telemetry by default.** The product does not collect command arguments, target names, credentials, or usage data. +11. **Updates stay off the data path.** Release checks never run from the + transparent `cf` shim and never change the installed executable. ## 4. User experience @@ -191,16 +193,32 @@ The initial control interface is deliberately small: | `cfs gc` | Report orphaned workspace state; deletion requires `--apply`. | | `cfs uninstall` | Remove the shim and PATH integration, preserving state. | | `cfs version` | Print the `cfs` version and build information. | +| `cfs update` | Check published releases and print safe update guidance. | | `cfs help [command]` | Show global or command-specific help. | Global conventions: - `--json` produces stable JSON for `status`, `context list`, `context status`, - `doctor`, and `gc`. + `doctor`, `gc`, and `update`. - Errors use the form `cfs: `. - Interactive prompts are never used when standard input is not a terminal. - Destructive commands require an explicit flag in non-interactive mode. +### 5.1 Update discovery + +`cfs update` compares the running version with published semantic-version tags +from the public GitHub Releases API. Drafts and malformed tags are ignored; +pre-1.0 GitHub pre-releases are included. The command is read-only and reports +the release URL plus commands for the existing Go installation path. Package +managers remain responsible for replacing the executable. + +Successful interactive control commands may perform the same check at most once +per 24 hours and show one notice for each new version. The cache contains only +public version metadata. Passive checks are disabled for the `cf` shim, JSON +output, CI, non-terminal output, development builds, and when +`CFS_NO_UPDATE_CHECK=1` is set. Network and cache failures never change the +original command's output or exit status. + Context names contain 1–63 lowercase ASCII letters or digits, with dots, hyphens, and underscores permitted internally. Unknown or invalid names fail closed. Credentials are never copied implicitly. @@ -487,6 +505,7 @@ internal/ lock/ platform-specific process locks runner/ child process and signal forwarding install/ shim and shell PATH integration + updatecheck/ published-release lookup and notification cache test/ e2e/ official CLI and mock CF/UAA lifecycle tests ``` @@ -506,7 +525,7 @@ The first usable release includes: - Per-context exclusive locking. - Workspace-local named contexts with explicit per-command selection. - `setup`, `status`, `context`, `import`, `doctor`, `reset`, `gc`, `uninstall`, - and `version`. + `version`, and read-only update discovery. - Human-readable English output and stable JSON diagnostics. - Linux and macOS support on amd64 and arm64. - Automated tests against supported official CF CLI versions. diff --git a/docs/testing.md b/docs/testing.md index 77fa30a..82308fe 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -11,6 +11,10 @@ routes the command through the exact named context. It uses the caller's Codex authentication and provider configuration, runs with a temporary home and workspace, and deletes the fixture repository afterward. +Update discovery tests use local HTTP servers and temporary caches. Unit and CI +tests never query GitHub Releases; a live `cfs update` check is a manual release +validation step. + Run the full protocol test with an official CF CLI binary: ```sh diff --git a/go.mod b/go.mod index 87cb5a0..18b3130 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,8 @@ module github.com/zongqichen/cfs go 1.26.8 -require golang.org/x/sys v0.48.0 +require ( + golang.org/x/mod v0.41.0 + golang.org/x/sys v0.48.0 + golang.org/x/term v0.46.0 +) diff --git a/go.sum b/go.sum index e3bf0e4..a36a29b 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,6 @@ +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= diff --git a/internal/app/app.go b/internal/app/app.go index 729aead..ac2e100 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,6 +1,7 @@ package app import ( + "context" "errors" "fmt" "io" @@ -13,6 +14,8 @@ import ( "github.com/zongqichen/cfs/internal/executable" "github.com/zongqichen/cfs/internal/lock" "github.com/zongqichen/cfs/internal/runner" + "github.com/zongqichen/cfs/internal/updatecheck" + "golang.org/x/term" ) const ( @@ -24,13 +27,20 @@ const ( ) type Options struct { - Args []string - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer - Version string - Commit string - BuildDate string + Args []string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + Version string + Commit string + BuildDate string + Updates UpdateService + IsInteractive func(io.Writer) bool +} + +type UpdateService interface { + Check(context.Context, string) (updatecheck.Result, error) + Notification(context.Context, string) (updatecheck.Result, bool, error) } func Run(options Options) int { @@ -169,9 +179,17 @@ func withDefaultStreams(options Options) Options { if options.Stderr == nil { options.Stderr = os.Stderr } + if options.IsInteractive == nil { + options.IsInteractive = isOutputTerminal + } return options } +func isOutputTerminal(writer io.Writer) bool { + file, ok := writer.(*os.File) + return ok && term.IsTerminal(int(file.Fd())) +} + func fprintf(writer io.Writer, format string, values ...any) { _, _ = fmt.Fprintf(writer, format, values...) } diff --git a/internal/app/command_update.go b/internal/app/command_update.go new file mode 100644 index 0000000..8fb5d6f --- /dev/null +++ b/internal/app/command_update.go @@ -0,0 +1,90 @@ +package app + +import ( + "context" + "strings" + + "github.com/zongqichen/cfs/internal/envvar" + "github.com/zongqichen/cfs/internal/updatecheck" +) + +func commandUpdate(options Options, args []string) int { + flags := newFlagSet("update", options.Stderr) + jsonOutput := flags.Bool("json", false, "print JSON output") + if code, ok := parseFlagSet(flags, args); !ok { + return code + } + if flags.NArg() != 0 { + fprintf(options.Stderr, "cfs: update does not accept positional arguments\n") + return exitUsage + } + if options.Updates == nil { + fprintf(options.Stderr, "cfs: update checking is unavailable in this build\n") + return exitUnavailable + } + + result, err := options.Updates.Check(context.Background(), options.Version) + if err != nil { + fprintf(options.Stderr, "cfs: check for updates: %v\n", err) + return exitUnavailable + } + if *jsonOutput { + return writeJSON(options, result) + } + printUpdateResult(options, result) + return exitOK +} + +func printUpdateResult(options Options, result updatecheck.Result) { + switch result.Status { + case updatecheck.StatusUpdateAvailable: + fprintf(options.Stdout, "Update available: %s -> %s\n", result.CurrentVersion, result.LatestVersion) + fprintf(options.Stdout, "Release: %s\n", result.ReleaseURL) + fprintf(options.Stdout, "Update with the same installation method, or run:\n") + for _, command := range result.Commands { + fprintf(options.Stdout, " %s\n", command) + } + case updatecheck.StatusUpToDate: + fprintf(options.Stdout, "cfs %s is up to date.\n", result.CurrentVersion) + case updatecheck.StatusAhead: + fprintf(options.Stdout, "cfs %s is newer than the latest published release (%s).\n", result.CurrentVersion, result.LatestVersion) + default: + fprintf(options.Stdout, "Current build %s cannot be compared with published releases.\n", result.CurrentVersion) + fprintf(options.Stdout, "Latest release: %s\n", result.LatestVersion) + fprintf(options.Stdout, "Release: %s\n", result.ReleaseURL) + } +} + +func maybeNotifyUpdate(options Options, command commandSpec, args []string) { + if !shouldNotifyUpdate(options, command, args) { + return + } + result, notify, err := options.Updates.Notification(context.Background(), options.Version) + if err != nil || !notify { + return + } + fprintf(options.Stderr, "cfs: update available: %s -> %s; run 'cfs update'\n", result.CurrentVersion, result.LatestVersion) +} + +func shouldNotifyUpdate(options Options, command commandSpec, args []string) bool { + if options.Updates == nil || isHelpRequest(args) || hasJSONFlag(args) { + return false + } + switch command.name { + case "help", "uninstall", "update": + return false + } + if envTrue(envvar.NoUpdateCheck) || envTrue(envvar.CI) { + return false + } + return options.IsInteractive != nil && options.IsInteractive(options.Stderr) +} + +func hasJSONFlag(args []string) bool { + for _, argument := range args { + if argument == "--json" || argument == "-json" || strings.HasPrefix(argument, "--json=") || strings.HasPrefix(argument, "-json=") { + return true + } + } + return false +} diff --git a/internal/app/command_update_test.go b/internal/app/command_update_test.go new file mode 100644 index 0000000..0a0db6c --- /dev/null +++ b/internal/app/command_update_test.go @@ -0,0 +1,259 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/zongqichen/cfs/internal/envvar" + "github.com/zongqichen/cfs/internal/updatecheck" +) + +func TestUpdateReportsAvailableRelease(t *testing.T) { + updates := &fakeUpdateService{result: availableUpdate()} + result := runUpdateCommand(t, updates) + + if result.code != exitOK || result.stderr != "" { + t.Fatalf("cfs update result = %#v", result) + } + for _, expected := range []string{ + "Update available: 0.2.0 -> 0.3.0", + "https://github.com/zongqichen/cfs/releases/tag/v0.3.0", + "go install github.com/zongqichen/cfs/cmd/cfs@v0.3.0", + "cfs setup", + "cfs doctor", + } { + if !strings.Contains(result.stdout, expected) { + t.Errorf("stdout = %q, want %q", result.stdout, expected) + } + } + if updates.checks != 1 || updates.notifications != 0 { + t.Fatalf("service calls = checks %d, notifications %d", updates.checks, updates.notifications) + } +} + +func TestUpdateJSONIsStableAndMachineReadable(t *testing.T) { + updates := &fakeUpdateService{result: availableUpdate()} + result := runUpdateCommand(t, updates, "--json") + + if result.code != exitOK || result.stderr != "" { + t.Fatalf("cfs update --json result = %#v", result) + } + var decoded updatecheck.Result + if err := json.Unmarshal([]byte(result.stdout), &decoded); err != nil { + t.Fatal(err) + } + if decoded.Status != updatecheck.StatusUpdateAvailable || !decoded.UpdateAvailable || len(decoded.Commands) != 3 { + t.Fatalf("JSON result = %#v", decoded) + } +} + +func TestUpdateReportsOtherVersionStates(t *testing.T) { + tests := []struct { + name string + result updatecheck.Result + want string + }{ + { + name: "up to date", + result: updatecheck.Result{CurrentVersion: "0.2.0", LatestVersion: "0.2.0", Status: updatecheck.StatusUpToDate}, + want: "cfs 0.2.0 is up to date.", + }, + { + name: "ahead", + result: updatecheck.Result{CurrentVersion: "0.3.0", LatestVersion: "0.2.0", Status: updatecheck.StatusAhead}, + want: "newer than the latest published release", + }, + { + name: "development", + result: updatecheck.Result{CurrentVersion: "dev", LatestVersion: "0.2.0", Status: updatecheck.StatusDevelopment}, + want: "cannot be compared with published releases", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := runUpdateCommand(t, &fakeUpdateService{result: test.result}) + if result.code != exitOK || !strings.Contains(result.stdout, test.want) { + t.Fatalf("result = %#v, want %q", result, test.want) + } + }) + } +} + +func TestUpdateFailsClearlyWhenReleaseCheckFails(t *testing.T) { + result := runUpdateCommand(t, &fakeUpdateService{err: errors.New("offline")}) + if result.code != exitUnavailable || result.stdout != "" || !strings.Contains(result.stderr, "check for updates: offline") { + t.Fatalf("result = %#v", result) + } +} + +func TestUpdateRejectsArguments(t *testing.T) { + updates := &fakeUpdateService{} + result := runUpdateCommand(t, updates, "unexpected") + if result.code != exitUsage || updates.checks != 0 { + t.Fatalf("result = %#v, checks = %d", result, updates.checks) + } +} + +func TestInteractiveControlCommandShowsOneUpdateNotice(t *testing.T) { + t.Setenv(envvar.CI, "") + t.Setenv(envvar.NoUpdateCheck, "") + updates := &fakeUpdateService{result: availableUpdate(), notify: true} + result := runCommandWithUpdates(t, updates, true, "version") + if result.code != exitOK || !strings.Contains(result.stderr, "run 'cfs update'") { + t.Fatalf("result = %#v", result) + } + if updates.checks != 0 || updates.notifications != 1 { + t.Fatalf("service calls = checks %d, notifications %d", updates.checks, updates.notifications) + } +} + +func TestPassiveUpdateNoticeNeverChangesCommandFailure(t *testing.T) { + t.Setenv(envvar.CI, "") + t.Setenv(envvar.NoUpdateCheck, "") + updates := &fakeUpdateService{err: errors.New("offline"), notify: true} + result := runCommandWithUpdates(t, updates, true, "version") + if result.code != exitOK || result.stderr != "" { + t.Fatalf("result = %#v", result) + } +} + +func TestPassiveUpdateNoticeIsSuppressed(t *testing.T) { + tests := []struct { + name string + interactive bool + args []string + environment map[string]string + }{ + {name: "non-interactive", args: []string{"version"}}, + {name: "JSON", interactive: true, args: []string{"version", "--json"}}, + {name: "help", interactive: true, args: []string{"help"}}, + {name: "command help", interactive: true, args: []string{"version", "--help"}}, + {name: "opt out", interactive: true, args: []string{"version"}, environment: map[string]string{envvar.NoUpdateCheck: "1"}}, + {name: "CI", interactive: true, args: []string{"version"}, environment: map[string]string{envvar.CI: "true"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Setenv(envvar.CI, "") + t.Setenv(envvar.NoUpdateCheck, "") + for name, value := range test.environment { + t.Setenv(name, value) + } + updates := &fakeUpdateService{result: availableUpdate(), notify: true} + result := runCommandWithUpdates(t, updates, test.interactive, test.args...) + if result.code != exitOK || updates.notifications != 0 { + t.Fatalf("result = %#v, notifications = %d", result, updates.notifications) + } + }) + } +} + +func TestOutputTerminalRejectsNonTTYCharacterDevice(t *testing.T) { + device, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + defer device.Close() + + if isOutputTerminal(device) { + t.Fatalf("isOutputTerminal(%s) = true, want false", os.DevNull) + } +} + +func TestCFShimNeverChecksForUpdates(t *testing.T) { + t.Setenv(envvar.ConfigFile, filepath.Join(t.TempDir(), "missing.json")) + updates := &fakeUpdateService{result: availableUpdate(), notify: true} + var stderr bytes.Buffer + code := Run(Options{ + Args: []string{"cf", "apps"}, + Stdin: strings.NewReader(""), + Stdout: io.Discard, + Stderr: &stderr, + Version: "0.2.0", + Updates: updates, + IsInteractive: func(io.Writer) bool { return true }, + }) + if code != exitUnavailable || updates.checks != 0 || updates.notifications != 0 { + t.Fatalf("code = %d, service calls = checks %d, notifications %d", code, updates.checks, updates.notifications) + } +} + +func TestNamedCFInvocationNeverChecksForUpdates(t *testing.T) { + t.Setenv(envvar.ConfigFile, filepath.Join(t.TempDir(), "missing.json")) + updates := &fakeUpdateService{result: availableUpdate(), notify: true} + var stderr bytes.Buffer + code := Run(Options{ + Args: []string{"cfs", "-c", "qa", "apps"}, + Stdin: strings.NewReader(""), + Stdout: io.Discard, + Stderr: &stderr, + Version: "0.2.0", + Updates: updates, + IsInteractive: func(io.Writer) bool { return true }, + }) + if code != exitUnavailable || updates.checks != 0 || updates.notifications != 0 { + t.Fatalf("code = %d, service calls = checks %d, notifications %d", code, updates.checks, updates.notifications) + } +} + +type fakeUpdateService struct { + result updatecheck.Result + err error + notify bool + checks int + notifications int +} + +func (service *fakeUpdateService) Check(context.Context, string) (updatecheck.Result, error) { + service.checks++ + return service.result, service.err +} + +func (service *fakeUpdateService) Notification(context.Context, string) (updatecheck.Result, bool, error) { + service.notifications++ + return service.result, service.notify, service.err +} + +func availableUpdate() updatecheck.Result { + return updatecheck.Result{ + CurrentVersion: "0.2.0", + LatestVersion: "0.3.0", + UpdateAvailable: true, + Status: updatecheck.StatusUpdateAvailable, + ReleaseURL: "https://github.com/zongqichen/cfs/releases/tag/v0.3.0", + Commands: []string{ + "go install github.com/zongqichen/cfs/cmd/cfs@v0.3.0", + "cfs setup", + "cfs doctor", + }, + } +} + +func runUpdateCommand(t *testing.T, updates UpdateService, args ...string) commandResult { + t.Helper() + return runCommandWithUpdates(t, updates, false, append([]string{"update"}, args...)...) +} + +func runCommandWithUpdates(t *testing.T, updates UpdateService, interactive bool, args ...string) commandResult { + t.Helper() + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run(Options{ + Args: append([]string{"cfs"}, args...), + Stdin: strings.NewReader(""), + Stdout: &stdout, + Stderr: &stderr, + Version: "0.2.0", + Commit: "test-commit", + BuildDate: "test-date", + Updates: updates, + IsInteractive: func(io.Writer) bool { return interactive }, + }) + return commandResult{code: code, stdout: stdout.String(), stderr: stderr.String()} +} diff --git a/internal/app/commands.go b/internal/app/commands.go index e656440..42553af 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -92,6 +92,13 @@ func allCommands() []commandSpec { examples: " cfs version", run: commandVersion, }, + { + name: "update", + summary: "Check for a newer cfs release", + usage: "cfs update [--json]", + examples: " cfs update\n cfs update --json", + run: commandUpdate, + }, { name: "help", summary: "Show help for cfs or a command", @@ -122,7 +129,11 @@ func runControl(options Options, args []string) int { if isHelpRequest(commandArgs) { options.Stderr = options.Stdout } - return command.run(options, commandArgs) + exitCode := command.run(options, commandArgs) + if exitCode == exitOK { + maybeNotifyUpdate(options, command, commandArgs) + } + return exitCode } func commandHelp(options Options, args []string) int { @@ -225,7 +236,7 @@ func printHelp(output io.Writer) { for _, command := range allCommands() { fprintf(output, " %-11s %s\n", command.name, command.summary) } - fprintf(output, "\nEnvironment:\n %s Override automatic workspace discovery\n %s Override the state directory\n %s Maximum wait for a context lock (default: %s)\n %s=1 Bypass workspace isolation for one invocation\n\nNormal Cloud Foundry commands remain unchanged:\n cf login --sso\n cf target -o my-org -s my-space\n cf apps\n", envvar.WorkspaceRoot, envvar.StateHome, envvar.LockTimeout, defaultLockTimeout, envvar.Disable) + fprintf(output, "\nEnvironment:\n %s Override automatic workspace discovery\n %s Override the state directory\n %s Maximum wait for a context lock (default: %s)\n %s=1 Bypass workspace isolation for one invocation\n %s=1 Disable interactive update notices\n\nNormal Cloud Foundry commands remain unchanged:\n cf login --sso\n cf target -o my-org -s my-space\n cf apps\n", envvar.WorkspaceRoot, envvar.StateHome, envvar.LockTimeout, defaultLockTimeout, envvar.Disable, envvar.NoUpdateCheck) } func shortID(id string) string { diff --git a/internal/envvar/envvar.go b/internal/envvar/envvar.go index ef77578..ff84de7 100644 --- a/internal/envvar/envvar.go +++ b/internal/envvar/envvar.go @@ -13,6 +13,7 @@ const ( ConfigFile = "CFS_CONFIG_FILE" Disable = "CFS_DISABLE" LockTimeout = "CFS_LOCK_TIMEOUT" + NoUpdateCheck = "CFS_NO_UPDATE_CHECK" ShimDir = "CFS_SHIM_DIR" StateHome = "CFS_STATE_HOME" WorkspaceRoot = "CFS_WORKSPACE_ROOT" @@ -20,6 +21,7 @@ const ( // Platform environment variables used to follow operating-system conventions. const ( + CI = "CI" LocalAppData = "LOCALAPPDATA" XDGStateHome = "XDG_STATE_HOME" ) diff --git a/internal/updatecheck/updatecheck.go b/internal/updatecheck/updatecheck.go new file mode 100644 index 0000000..17bf1b2 --- /dev/null +++ b/internal/updatecheck/updatecheck.go @@ -0,0 +1,321 @@ +package updatecheck + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/zongqichen/cfs/internal/lock" + "github.com/zongqichen/cfs/internal/securefs" + "golang.org/x/mod/semver" +) + +const ( + DefaultEndpoint = "https://api.github.com/repos/zongqichen/cfs/releases?per_page=100" + DefaultCheckInterval = 24 * time.Hour + DefaultHTTPTimeout = 3 * time.Second + + cacheVersion = 1 + cacheFileName = "update-check.json" + cacheReadLimit = 64 * 1024 + responseReadLimit = 1024 * 1024 + repositoryReleaseURL = "https://github.com/zongqichen/cfs/releases/tag/" +) + +type Status string + +const ( + StatusUpdateAvailable Status = "update-available" + StatusUpToDate Status = "up-to-date" + StatusAhead Status = "ahead" + StatusDevelopment Status = "development" +) + +type Result struct { + CurrentVersion string `json:"current_version"` + LatestVersion string `json:"latest_version"` + UpdateAvailable bool `json:"update_available"` + Status Status `json:"status"` + ReleaseURL string `json:"release_url"` + Commands []string `json:"commands"` +} + +type Options struct { + Client *http.Client + Endpoint string + CachePath string + Now func() time.Time + CheckInterval time.Duration +} + +type Service struct { + client *http.Client + endpoint string + cachePath string + now func() time.Time + checkInterval time.Duration +} + +type release struct { + TagName string `json:"tag_name"` + Draft bool `json:"draft"` +} + +type cache struct { + Version int `json:"version"` + CheckedAt time.Time `json:"checked_at"` + LatestVersion string `json:"latest_version,omitempty"` + NotifiedVersion string `json:"notified_version,omitempty"` +} + +func New(options Options) *Service { + client := options.Client + if client == nil { + client = &http.Client{Timeout: DefaultHTTPTimeout} + } + endpoint := options.Endpoint + if endpoint == "" { + endpoint = DefaultEndpoint + } + now := options.Now + if now == nil { + now = time.Now + } + interval := options.CheckInterval + if interval <= 0 { + interval = DefaultCheckInterval + } + cachePath := options.CachePath + if cachePath == "" { + cachePath = defaultCachePath() + } + return &Service{ + client: client, + endpoint: endpoint, + cachePath: cachePath, + now: now, + checkInterval: interval, + } +} + +func (service *Service) Check(ctx context.Context, currentVersion string) (Result, error) { + latestVersion, err := service.fetchLatest(ctx) + if err != nil { + return Result{}, err + } + result := compare(currentVersion, latestVersion) + service.remember(result, true) + return result, nil +} + +func (service *Service) Notification(ctx context.Context, currentVersion string) (Result, bool, error) { + if canonicalVersion(currentVersion) == "" || service.cachePath == "" { + return Result{}, false, nil + } + if err := securefs.EnsureDirectory(filepath.Dir(service.cachePath)); err != nil { + return Result{}, false, err + } + cacheLock, err := lock.Acquire(service.cachePath+".lock", 0) + if errors.Is(err, lock.ErrBusy) { + return Result{}, false, nil + } + if err != nil { + return Result{}, false, err + } + defer cacheLock.Release() + + stored, _ := readCache(service.cachePath) + now := service.now().UTC() + if stored.CheckedAt.IsZero() || now.Before(stored.CheckedAt) || now.Sub(stored.CheckedAt) >= service.checkInterval { + latestVersion, fetchErr := service.fetchLatest(ctx) + stored.CheckedAt = now + if fetchErr != nil { + _ = writeCache(service.cachePath, stored) + return Result{}, false, fetchErr + } + if stored.LatestVersion != latestVersion { + stored.NotifiedVersion = "" + } + stored.LatestVersion = latestVersion + } + if stored.LatestVersion == "" { + _ = writeCache(service.cachePath, stored) + return Result{}, false, nil + } + + result := compare(currentVersion, stored.LatestVersion) + notify := result.UpdateAvailable && stored.NotifiedVersion != stored.LatestVersion + if notify { + stored.NotifiedVersion = stored.LatestVersion + } + if err := writeCache(service.cachePath, stored); err != nil { + return Result{}, false, err + } + return result, notify, nil +} + +func (service *Service) fetchLatest(ctx context.Context) (string, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, service.endpoint, nil) + if err != nil { + return "", fmt.Errorf("create release request: %w", err) + } + request.Header.Set("Accept", "application/vnd.github+json") + request.Header.Set("User-Agent", "cfs-update-check") + request.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + response, err := service.client.Do(request) + if err != nil { + return "", fmt.Errorf("request releases: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub Releases returned HTTP %d", response.StatusCode) + } + raw, err := io.ReadAll(io.LimitReader(response.Body, responseReadLimit+1)) + if err != nil { + return "", fmt.Errorf("read releases: %w", err) + } + if len(raw) > responseReadLimit { + return "", fmt.Errorf("GitHub Releases response exceeds %d bytes", responseReadLimit) + } + + var releases []release + if err := json.Unmarshal(raw, &releases); err != nil { + return "", fmt.Errorf("decode releases: %w", err) + } + latest := "" + for _, candidate := range releases { + version := canonicalVersion(candidate.TagName) + if candidate.Draft || version == "" { + continue + } + if latest == "" || semver.Compare(version, latest) > 0 { + latest = version + } + } + if latest == "" { + return "", errors.New("GitHub Releases returned no published semantic versions") + } + return latest, nil +} + +func compare(currentVersion, latestVersion string) Result { + current := canonicalVersion(currentVersion) + latest := canonicalVersion(latestVersion) + result := Result{ + CurrentVersion: displayVersion(currentVersion), + LatestVersion: displayVersion(latest), + Status: StatusDevelopment, + ReleaseURL: releaseURL(latest), + Commands: []string{}, + } + if current == "" { + return result + } + switch semver.Compare(current, latest) { + case -1: + result.Status = StatusUpdateAvailable + result.UpdateAvailable = true + result.Commands = []string{ + "go install github.com/zongqichen/cfs/cmd/cfs@" + latest, + "cfs setup", + "cfs doctor", + } + case 0: + result.Status = StatusUpToDate + default: + result.Status = StatusAhead + } + return result +} + +func canonicalVersion(value string) string { + value = strings.TrimSpace(value) + if strings.HasSuffix(value, "+dirty") { + return "" + } + if !strings.HasPrefix(value, "v") { + value = "v" + value + } + if !semver.IsValid(value) { + return "" + } + return semver.Canonical(value) +} + +func displayVersion(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "unknown" + } + return strings.TrimPrefix(value, "v") +} + +func releaseURL(version string) string { + if version == "" { + return "" + } + return repositoryReleaseURL + url.PathEscape(version) +} + +func defaultCachePath() string { + root, err := os.UserCacheDir() + if err != nil { + return "" + } + return filepath.Join(root, "cfs", cacheFileName) +} + +func (service *Service) remember(result Result, notified bool) { + if service.cachePath == "" { + return + } + stored := cache{ + Version: cacheVersion, + CheckedAt: service.now().UTC(), + LatestVersion: canonicalVersion(result.LatestVersion), + } + if notified && result.UpdateAvailable { + stored.NotifiedVersion = stored.LatestVersion + } + _ = writeCache(service.cachePath, stored) +} + +func readCache(path string) (cache, error) { + file, err := securefs.OpenRegularFile(path) + if errors.Is(err, os.ErrNotExist) { + return cache{Version: cacheVersion}, nil + } + if err != nil { + return cache{}, err + } + defer file.Close() + raw, err := io.ReadAll(io.LimitReader(file, cacheReadLimit+1)) + if err != nil { + return cache{}, err + } + if len(raw) > cacheReadLimit { + return cache{}, fmt.Errorf("update cache exceeds %d bytes", cacheReadLimit) + } + var stored cache + if err := json.Unmarshal(raw, &stored); err != nil { + return cache{}, err + } + if stored.Version != cacheVersion { + return cache{}, fmt.Errorf("unsupported update cache version %d", stored.Version) + } + return stored, nil +} + +func writeCache(path string, stored cache) error { + stored.Version = cacheVersion + return securefs.WriteJSONAtomic(path, stored) +} diff --git a/internal/updatecheck/updatecheck_test.go b/internal/updatecheck/updatecheck_test.go new file mode 100644 index 0000000..055c31b --- /dev/null +++ b/internal/updatecheck/updatecheck_test.go @@ -0,0 +1,271 @@ +package updatecheck + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestCheckSelectsHighestPublishedSemanticVersion(t *testing.T) { + var headersOK atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + headersOK.Store( + request.Header.Get("Accept") == "application/vnd.github+json" && + request.Header.Get("User-Agent") == "cfs-update-check" && + request.Header.Get("X-GitHub-Api-Version") == "2022-11-28", + ) + fprintf(writer, `[ + {"tag_name":"v9.0.0","draft":true}, + {"tag_name":"not-a-version","draft":false}, + {"tag_name":"v0.3.0-rc.1","draft":false,"prerelease":true}, + {"tag_name":"v0.2.1","draft":false} + ]`) + })) + defer server.Close() + + service := New(Options{ + Endpoint: server.URL, + CachePath: filepath.Join(t.TempDir(), cacheFileName), + }) + result, err := service.Check(context.Background(), "0.2.0") + if err != nil { + t.Fatal(err) + } + if !headersOK.Load() { + t.Fatal("release request did not include the required GitHub headers") + } + if result.Status != StatusUpdateAvailable || !result.UpdateAvailable { + t.Fatalf("result = %#v, want update available", result) + } + if result.CurrentVersion != "0.2.0" || result.LatestVersion != "0.3.0-rc.1" { + t.Fatalf("result versions = %#v", result) + } + if result.ReleaseURL != repositoryReleaseURL+"v0.3.0-rc.1" { + t.Fatalf("release URL = %q", result.ReleaseURL) + } + wantCommand := "go install github.com/zongqichen/cfs/cmd/cfs@v0.3.0-rc.1" + if len(result.Commands) != 3 || result.Commands[0] != wantCommand { + t.Fatalf("commands = %q", result.Commands) + } +} + +func TestCompareVersionStates(t *testing.T) { + tests := []struct { + name string + current string + want Status + available bool + }{ + {name: "older release", current: "0.1.0", want: StatusUpdateAvailable, available: true}, + {name: "v prefix", current: "v0.2.0", want: StatusUpToDate}, + {name: "same release", current: "0.2.0", want: StatusUpToDate}, + {name: "newer build", current: "0.3.0", want: StatusAhead}, + {name: "development build", current: "dev", want: StatusDevelopment}, + {name: "dirty build", current: "v0.2.0+dirty", want: StatusDevelopment}, + {name: "unknown build", current: "unknown", want: StatusDevelopment}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := compare(test.current, "v0.2.0") + if result.Status != test.want || result.UpdateAvailable != test.available { + t.Fatalf("compare(%q) = %#v", test.current, result) + } + }) + } +} + +func TestCheckRejectsInvalidResponses(t *testing.T) { + tests := []struct { + name string + status int + body string + want string + }{ + {name: "http error", status: http.StatusTooManyRequests, body: `{}`, want: "HTTP 429"}, + {name: "invalid json", status: http.StatusOK, body: `{`, want: "decode releases"}, + {name: "no releases", status: http.StatusOK, body: `[{"tag_name":"bad"}]`, want: "no published semantic versions"}, + {name: "oversized", status: http.StatusOK, body: strings.Repeat(" ", responseReadLimit+1), want: "response exceeds"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(test.status) + _, _ = writer.Write([]byte(test.body)) + })) + defer server.Close() + service := New(Options{Endpoint: server.URL, CachePath: filepath.Join(t.TempDir(), cacheFileName)}) + _, err := service.Check(context.Background(), "0.2.0") + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Check() error = %v, want %q", err, test.want) + } + }) + } +} + +func TestCheckHonorsHTTPTimeout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) { + <-request.Context().Done() + })) + defer server.Close() + service := New(Options{ + Client: &http.Client{Timeout: 20 * time.Millisecond}, + Endpoint: server.URL, + CachePath: filepath.Join(t.TempDir(), cacheFileName), + }) + if _, err := service.Check(context.Background(), "0.2.0"); err == nil { + t.Fatal("Check() error = nil, want timeout") + } +} + +func TestNotificationUsesCacheAndNotifiesOncePerVersion(t *testing.T) { + var requests atomic.Int32 + latest := atomic.Value{} + latest.Store("v0.2.0") + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + requests.Add(1) + fprintf(writer, `[{"tag_name":%q}]`, latest.Load().(string)) + })) + defer server.Close() + + now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC) + cachePath := filepath.Join(t.TempDir(), cacheFileName) + service := New(Options{ + Endpoint: server.URL, + CachePath: cachePath, + Now: func() time.Time { return now }, + CheckInterval: time.Hour, + }) + + result, notify, err := service.Notification(context.Background(), "0.1.0") + if err != nil || !notify || !result.UpdateAvailable { + t.Fatalf("first notification = (%#v, %v, %v)", result, notify, err) + } + if _, notify, err := service.Notification(context.Background(), "0.1.0"); err != nil || notify { + t.Fatalf("cached notification = (%v, %v), want no notification", notify, err) + } + if requests.Load() != 1 { + t.Fatalf("requests = %d, want 1", requests.Load()) + } + + now = now.Add(2 * time.Hour) + if _, notify, err := service.Notification(context.Background(), "0.1.0"); err != nil || notify { + t.Fatalf("same-version refresh = (%v, %v), want no notification", notify, err) + } + latest.Store("v0.3.0") + now = now.Add(2 * time.Hour) + result, notify, err = service.Notification(context.Background(), "0.1.0") + if err != nil || !notify || result.LatestVersion != "0.3.0" { + t.Fatalf("new-version notification = (%#v, %v, %v)", result, notify, err) + } + if requests.Load() != 3 { + t.Fatalf("requests = %d, want 3", requests.Load()) + } + + raw, err := os.ReadFile(cachePath) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"token", "organization", "workspace", "context"} { + if strings.Contains(strings.ToLower(string(raw)), forbidden) { + t.Fatalf("cache unexpectedly contains %q: %s", forbidden, raw) + } + } + if runtime.GOOS != "windows" { + info, err := os.Stat(cachePath) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("cache permissions = %04o, want 0600", info.Mode().Perm()) + } + } +} + +func TestNotificationCachesNetworkFailure(t *testing.T) { + var requests atomic.Int32 + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + requests.Add(1) + return nil, errors.New("offline") + })} + service := New(Options{ + Client: client, + Endpoint: "https://example.invalid/releases", + CachePath: filepath.Join(t.TempDir(), cacheFileName), + Now: func() time.Time { return time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC) }, + CheckInterval: time.Hour, + }) + if _, _, err := service.Notification(context.Background(), "0.1.0"); err == nil { + t.Fatal("first Notification() error = nil, want offline error") + } + if _, notify, err := service.Notification(context.Background(), "0.1.0"); err != nil || notify { + t.Fatalf("cached Notification() = (%v, %v), want silent cache hit", notify, err) + } + if requests.Load() != 1 { + t.Fatalf("requests = %d, want 1", requests.Load()) + } +} + +func TestNotificationSkipsDevelopmentBuildWithoutRequest(t *testing.T) { + var requests atomic.Int32 + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + requests.Add(1) + return nil, errors.New("unexpected request") + })} + service := New(Options{ + Client: client, + Endpoint: "https://example.invalid/releases", + CachePath: filepath.Join(t.TempDir(), cacheFileName), + }) + + for _, version := range []string{"dev", "(devel)", "v0.2.0+dirty"} { + if _, notify, err := service.Notification(context.Background(), version); err != nil || notify { + t.Fatalf("Notification(%q) = (_, %v, %v), want (_, false, nil)", version, notify, err) + } + } + if requests.Load() != 0 { + t.Fatalf("requests = %d, want 0", requests.Load()) + } +} + +func TestConcurrentNotificationsShareOneRequest(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + requests.Add(1) + fprintf(writer, `[{"tag_name":"v0.2.0"}]`) + })) + defer server.Close() + service := New(Options{Endpoint: server.URL, CachePath: filepath.Join(t.TempDir(), cacheFileName)}) + + var wait sync.WaitGroup + for range 10 { + wait.Add(1) + go func() { + defer wait.Done() + _, _, _ = service.Notification(context.Background(), "0.1.0") + }() + } + wait.Wait() + if requests.Load() != 1 { + t.Fatalf("requests = %d, want 1", requests.Load()) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (function roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return function(request) +} + +func fprintf(writer http.ResponseWriter, format string, values ...any) { + _, _ = fmt.Fprintf(writer, format, values...) +}