From 83d66504241fc4e018cd28dabde7de3bafc9a9c8 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 15 Sep 2026 19:24:34 -0400 Subject: [PATCH 01/11] The release-tag grammar has one home, and it is internal/update Stable, rc, dev and staging tags are parsed, compared and bumped in exactly one place now. cmd/codeaf-release keeps its next, kind and prune behaviour and its tests unchanged; it reads the grammar from the new package rather than owning a second copy of it. A second parser is the thing that drifts: the updater about to land needs the same answer to "is this tag newer than that one" that the release tool gives, and one source of truth is the only way they cannot disagree. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/codeaf-release/version.go | 188 ++----------------------- cmd/codeaf-release/version_test.go | 16 +++ internal/update/version.go | 216 +++++++++++++++++++++++++++++ 3 files changed, 246 insertions(+), 174 deletions(-) create mode 100644 internal/update/version.go diff --git a/cmd/codeaf-release/version.go b/cmd/codeaf-release/version.go index 2c8ce49504..fbbed31f0b 100644 --- a/cmd/codeaf-release/version.go +++ b/cmd/codeaf-release/version.go @@ -4,129 +4,13 @@ import ( "bufio" "fmt" "io" - "regexp" - "strconv" "strings" -) -var ( - stableTagPattern = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`) - rcTagPattern = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-rc\.([1-9][0-9]*)$`) - devTagPattern = regexp.MustCompile(`^dev-[0-9]{8}-[0-9a-f]{12}$`) - stagingTagPattern = regexp.MustCompile(`^staging-[0-9]{8}-[0-9a-f]{12}$`) - shaPattern = regexp.MustCompile(`^[0-9a-fA-F]{12,40}$`) - datePattern = regexp.MustCompile(`^[0-9]{8}$`) + codeupdate "github.com/Agent-Field/codeaf/internal/update" ) -type version struct { - major int - minor int - patch int -} - -func (v version) String() string { - return fmt.Sprintf("v%d.%d.%d", v.major, v.minor, v.patch) -} - -func (v version) compare(other version) int { - switch { - case v.major != other.major: - return compareInt(v.major, other.major) - case v.minor != other.minor: - return compareInt(v.minor, other.minor) - default: - return compareInt(v.patch, other.patch) - } -} - -func compareInt(left, right int) int { - switch { - case left < right: - return -1 - case left > right: - return 1 - default: - return 0 - } -} - -func (v version) bump(component string) (version, error) { - switch component { - case "patch": - v.patch++ - case "minor": - v.minor++ - v.patch = 0 - case "major": - v.major++ - v.minor = 0 - v.patch = 0 - default: - return version{}, usageErr("component %q must be patch, minor, or major", component) - } - return v, nil -} - -type rcTag struct { - base version - counter int -} - -func parseComponent(raw string) (int, bool) { - value, err := strconv.Atoi(raw) - // Every accepted component must leave room for the resolver's next bump. - // A number that this process cannot represent or increment is a junk tag, - // just like a tag that does not match the grammar at all. - if err != nil || value == int(^uint(0)>>1) { - return 0, false - } - return value, true -} - -func parseVersion(match []string) (version, bool) { - major, majorOK := parseComponent(match[1]) - minor, minorOK := parseComponent(match[2]) - patch, patchOK := parseComponent(match[3]) - if !majorOK || !minorOK || !patchOK { - return version{}, false - } - return version{major: major, minor: minor, patch: patch}, true -} - -func parseStable(tag string) (version, bool) { - match := stableTagPattern.FindStringSubmatch(tag) - if match == nil { - return version{}, false - } - return parseVersion(match) -} - -func parseRC(tag string) (rcTag, bool) { - match := rcTagPattern.FindStringSubmatch(tag) - if match == nil { - return rcTag{}, false - } - base, baseOK := parseVersion(match) - counter, counterOK := parseComponent(match[4]) - if !baseOK || !counterOK { - return rcTag{}, false - } - return rcTag{base: base, counter: counter}, true -} - func tagKind(tag string) string { - switch { - case stableTagPattern.MatchString(tag): - return "stable" - case rcTagPattern.MatchString(tag): - return "rc" - case devTagPattern.MatchString(tag): - return "dev" - case stagingTagPattern.MatchString(tag): - return "staging" - default: - return "other" - } + return codeupdate.Kind(tag) } type nextOptions struct { @@ -142,7 +26,10 @@ func runNext(args []string, stdin io.Reader, stdout io.Writer) error { return err } if options.channel == "dev" || options.channel == "staging" { - tag := fmt.Sprintf("%s-%s-%s", options.channel, options.date, strings.ToLower(options.sha[:12])) + tag, err := codeupdate.ChannelTag(options.channel, options.date, options.sha) + if err != nil { + return err + } fmt.Fprintln(stdout, tag) return nil } @@ -186,14 +73,14 @@ func parseNextOptions(args []string) (nextOptions, error) { if options.sha != "" || options.date != "" { return options, usageErr("--sha and --date are only for dev and staging") } - if _, err := (version{}).bump(options.component); err != nil { - return options, err + if _, err := (codeupdate.Version{}).Bump(options.component); err != nil { + return options, usageErr("%s", err) } case "dev", "staging": - if !shaPattern.MatchString(options.sha) { + if !codeupdate.ValidSHA(options.sha) { return options, usageErr("--sha must be 12 to 40 hexadecimal characters") } - if !datePattern.MatchString(options.date) { + if !codeupdate.ValidDate(options.date) { return options, usageErr("--date must be YYYYMMDD") } if options.component != "patch" { @@ -218,58 +105,11 @@ func readTags(reader io.Reader) ([]string, error) { } func nextSemverTag(tags []string, channel, component string) (string, error) { - stableLatest := version{} - for _, tag := range tags { - if candidate, ok := parseStable(tag); ok && candidate.compare(stableLatest) > 0 { - stableLatest = candidate - } - } - - var ( - openLine version - haveOpen bool - maxCounter int - ) - for _, tag := range tags { - candidate, ok := parseRC(tag) - if !ok || candidate.base.compare(stableLatest) <= 0 { - continue - } - switch comparison := candidate.base.compare(openLine); { - case !haveOpen || comparison > 0: - openLine, haveOpen, maxCounter = candidate.base, true, candidate.counter - case comparison == 0 && candidate.counter > maxCounter: - maxCounter = candidate.counter - } + answer, err := codeupdate.NextSemverTag(tags, channel, component) + if err != nil && (strings.Contains(err.Error(), "component") || strings.Contains(err.Error(), "channel")) { + return "", usageErr("--%s", err) } - - var answer string - switch channel { - case "rc": - if haveOpen { - answer = fmt.Sprintf("%s-rc.%d", openLine, maxCounter+1) - } else { - next, err := stableLatest.bump(component) - if err != nil { - return "", err - } - answer = next.String() + "-rc.1" - } - case "stable": - if component == "patch" && haveOpen { - answer = openLine.String() - } else { - next, err := stableLatest.bump(component) - if err != nil { - return "", err - } - answer = next.String() - } - default: - return "", usageErr("--channel must be stable, rc, dev, or staging") - } - - return answer, requireUnused(answer, tags) + return answer, err } // requireUnused is a final collision guard kept separate from version choice. diff --git a/cmd/codeaf-release/version_test.go b/cmd/codeaf-release/version_test.go index 830cc32924..9db4c1d586 100644 --- a/cmd/codeaf-release/version_test.go +++ b/cmd/codeaf-release/version_test.go @@ -115,3 +115,19 @@ func TestUsageErrorsUseExitTwoAndKeepStdoutEmpty(t *testing.T) { } } } + +// TestC12ReleaseCommandsKeepTheirPublicBehaviour proves C12. +func TestC12ReleaseCommandsKeepTheirPublicBehaviour(t *testing.T) { + code, stdout, stderr := invoke([]string{"next", "--channel", "stable"}, "v0.1.0\n") + if code != 0 || stdout != "v0.1.1\n" || stderr != "" { + t.Fatalf("next: code %d stdout %q stderr %q", code, stdout, stderr) + } + code, stdout, stderr = invoke([]string{"kind", "v0.1.1-rc.1"}, "") + if code != 0 || stdout != "rc\n" || stderr != "" { + t.Fatalf("kind: code %d stdout %q stderr %q", code, stdout, stderr) + } + code, stdout, stderr = invoke([]string{"prune", "--channel", "dev"}, "v0.1.0\t2026-09-15T00:00:00Z\n") + if code != 0 || stdout != "" || stderr != "" { + t.Fatalf("prune: code %d stdout %q stderr %q", code, stdout, stderr) + } +} diff --git a/internal/update/version.go b/internal/update/version.go new file mode 100644 index 0000000000..13fd6b51ef --- /dev/null +++ b/internal/update/version.go @@ -0,0 +1,216 @@ +// Package update finds, installs, and restarts onto codeaf releases. +package update + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +var ( + stableTagPattern = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`) + rcTagPattern = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-rc\.([1-9][0-9]*)$`) + devTagPattern = regexp.MustCompile(`^dev-[0-9]{8}-[0-9a-f]{12}$`) + stagingTagPattern = regexp.MustCompile(`^staging-[0-9]{8}-[0-9a-f]{12}$`) + shaPattern = regexp.MustCompile(`^[0-9a-fA-F]{12,40}$`) + datePattern = regexp.MustCompile(`^[0-9]{8}$`) +) + +// Version is the stable semantic part of a release tag. +type Version struct { + Major int + Minor int + Patch int +} + +func (v Version) String() string { + return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch) +} + +// Compare orders stable semantic versions. +func (v Version) Compare(other Version) int { + switch { + case v.Major != other.Major: + return compareInt(v.Major, other.Major) + case v.Minor != other.Minor: + return compareInt(v.Minor, other.Minor) + default: + return compareInt(v.Patch, other.Patch) + } +} + +func compareInt(left, right int) int { + switch { + case left < right: + return -1 + case left > right: + return 1 + default: + return 0 + } +} + +// Bump advances one semantic component. +func (v Version) Bump(component string) (Version, error) { + switch component { + case "patch": + v.Patch++ + case "minor": + v.Minor++ + v.Patch = 0 + case "major": + v.Major++ + v.Minor = 0 + v.Patch = 0 + default: + return Version{}, fmt.Errorf("component %q must be patch, minor, or major", component) + } + return v, nil +} + +// RCTag is a release candidate and the stable line it belongs to. +type RCTag struct { + Base Version + Counter int +} + +func parseComponent(raw string) (int, bool) { + value, err := strconv.Atoi(raw) + // Every accepted component must leave room for the resolver's next bump. + // A number this process cannot represent or increment is a junk tag, just + // like a tag that does not match the grammar at all. + if err != nil || value == int(^uint(0)>>1) { + return 0, false + } + return value, true +} + +func parseVersion(match []string) (Version, bool) { + major, majorOK := parseComponent(match[1]) + minor, minorOK := parseComponent(match[2]) + patch, patchOK := parseComponent(match[3]) + if !majorOK || !minorOK || !patchOK { + return Version{}, false + } + return Version{Major: major, Minor: minor, Patch: patch}, true +} + +// ParseStable parses a stable release tag. +func ParseStable(tag string) (Version, bool) { + match := stableTagPattern.FindStringSubmatch(tag) + if match == nil { + return Version{}, false + } + return parseVersion(match) +} + +// ParseRC parses a release-candidate tag. +func ParseRC(tag string) (RCTag, bool) { + match := rcTagPattern.FindStringSubmatch(tag) + if match == nil { + return RCTag{}, false + } + base, baseOK := parseVersion(match) + counter, counterOK := parseComponent(match[4]) + if !baseOK || !counterOK { + return RCTag{}, false + } + return RCTag{Base: base, Counter: counter}, true +} + +// Kind names the release channel encoded by a tag. +func Kind(tag string) string { + switch { + case stableTagPattern.MatchString(tag): + return "stable" + case rcTagPattern.MatchString(tag): + return "rc" + case devTagPattern.MatchString(tag): + return "dev" + case stagingTagPattern.MatchString(tag): + return "staging" + default: + return "other" + } +} + +// ValidSHA reports whether a revision can form a channel tag. +func ValidSHA(value string) bool { return shaPattern.MatchString(value) } + +// ValidDate reports whether a date has the channel tag's YYYYMMDD shape. +func ValidDate(value string) bool { return datePattern.MatchString(value) } + +// ChannelTag forms a dev or staging tag from a date and revision. +func ChannelTag(channel, date, sha string) (string, error) { + if channel != "dev" && channel != "staging" { + return "", fmt.Errorf("channel must be dev or staging") + } + if !ValidDate(date) { + return "", fmt.Errorf("date must be YYYYMMDD") + } + if !ValidSHA(sha) { + return "", fmt.Errorf("sha must be 12 to 40 hexadecimal characters") + } + return fmt.Sprintf("%s-%s-%s", channel, date, strings.ToLower(sha[:12])), nil +} + +// NextSemverTag returns the next stable or release-candidate tag. +func NextSemverTag(tags []string, channel, component string) (string, error) { + stableLatest := Version{} + for _, tag := range tags { + if candidate, ok := ParseStable(tag); ok && candidate.Compare(stableLatest) > 0 { + stableLatest = candidate + } + } + + var ( + openLine Version + haveOpen bool + maxCounter int + ) + for _, tag := range tags { + candidate, ok := ParseRC(tag) + if !ok || candidate.Base.Compare(stableLatest) <= 0 { + continue + } + switch comparison := candidate.Base.Compare(openLine); { + case !haveOpen || comparison > 0: + openLine, haveOpen, maxCounter = candidate.Base, true, candidate.Counter + case comparison == 0 && candidate.Counter > maxCounter: + maxCounter = candidate.Counter + } + } + + var answer string + switch channel { + case "rc": + if haveOpen { + answer = fmt.Sprintf("%s-rc.%d", openLine, maxCounter+1) + } else { + next, err := stableLatest.Bump(component) + if err != nil { + return "", err + } + answer = next.String() + "-rc.1" + } + case "stable": + if component == "patch" && haveOpen { + answer = openLine.String() + } else { + next, err := stableLatest.Bump(component) + if err != nil { + return "", err + } + answer = next.String() + } + default: + return "", fmt.Errorf("channel must be stable or rc") + } + for _, tag := range tags { + if tag == answer { + return "", fmt.Errorf("release tag %s already exists", answer) + } + } + return answer, nil +} From fb7434bb50d655b65f9a9b5d5a276124d298da78 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 15 Sep 2026 19:24:46 -0400 Subject: [PATCH 02/11] internal/update: look up a release, check it, and replace this binary with it The launch check answers only for a build that could sensibly be behind: a stable tag, or a release candidate whose stable line has since been published. A dev or staging build, a make build sha and an unstamped binary make no request at all, because a channel build is deliberately newer than stable and a source build belongs to whoever built it. The answer is cached for a day beside the profile, and every failure is silence. The install downloads into the target's own directory, matches the sha256 from checksums.txt, and only then renames over the running file, so a refused download, a wrong checksum or an unwritable directory all leave the original binary exactly where it was and no temp file behind. The API token never leaves the API host: the redirect policy strips it the moment a redirect changes host, which Go does not do on its own for another port or a subdomain. Windows has no atomic rename over a running image, so it goes through ReplaceFileW and puts the original back when Windows reports the one failure that moves it. Co-Authored-By: Claude Opus 5 (1M context) --- internal/update/check.go | 145 ++++++++ internal/update/cleanup_running_unix.go | 7 + internal/update/cleanup_running_unix_test.go | 16 + internal/update/cleanup_running_windows.go | 12 + internal/update/client.go | 238 +++++++++++++ internal/update/install.go | 170 +++++++++ internal/update/replace_contract_test.go | 54 +++ internal/update/replace_unix.go | 13 + internal/update/replace_windows.go | 48 +++ internal/update/replace_windows_contract.go | 35 ++ internal/update/restart.go | 35 ++ internal/update/restart_unix.go | 14 + internal/update/restart_windows.go | 17 + internal/update/update_test.go | 353 +++++++++++++++++++ 14 files changed, 1157 insertions(+) create mode 100644 internal/update/check.go create mode 100644 internal/update/cleanup_running_unix.go create mode 100644 internal/update/cleanup_running_unix_test.go create mode 100644 internal/update/cleanup_running_windows.go create mode 100644 internal/update/client.go create mode 100644 internal/update/install.go create mode 100644 internal/update/replace_contract_test.go create mode 100644 internal/update/replace_unix.go create mode 100644 internal/update/replace_windows.go create mode 100644 internal/update/replace_windows_contract.go create mode 100644 internal/update/restart.go create mode 100644 internal/update/restart_unix.go create mode 100644 internal/update/restart_windows.go create mode 100644 internal/update/update_test.go diff --git a/internal/update/check.go b/internal/update/check.go new file mode 100644 index 0000000000..f67cda79ac --- /dev/null +++ b/internal/update/check.go @@ -0,0 +1,145 @@ +package update + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/config" + internalenv "github.com/Agent-Field/codeaf/internal/env" +) + +const cacheLifetime = 24 * time.Hour + +// Available is the stable release comparison shown at launch or by --check. +type Available struct { + Latest string + Running string +} + +// Newer reports whether the selected stable release supersedes this build. +func (a Available) Newer() bool { + latest, latestOK := ParseStable(a.Latest) + switch Kind(a.Running) { + case "stable": + running, ok := ParseStable(a.Running) + return latestOK && ok && latest.Compare(running) > 0 + case "rc": + running, ok := ParseRC(a.Running) + return latestOK && ok && latest.Compare(running.Base) >= 0 + default: + return false + } +} + +// Notice is the one launch line carrying both update roads. +func (a Available) Notice() string { + if !a.Newer() { + return "" + } + return "codeaf " + a.Latest + " is out · you have " + a.Running + " · /update installs it and restarts · or: " + CurlCommand +} + +type checkCache struct { + CheckedAt time.Time `json:"checked_at"` + Latest string `json:"latest"` + Running string `json:"running"` +} + +// CheckOptions supplies the launch policy's disk, clock, and release client. +type CheckOptions struct { + Running string + ProfileDir string + Client *Client + Now func() time.Time + Disabled bool +} + +// CheckLaunch silently checks the newest stable release when this build is a +// stable or release-candidate build. Every failure is absence at this surface. +func CheckLaunch(ctx context.Context, options CheckOptions) (Available, bool) { + running := strings.TrimSpace(options.Running) + if options.Disabled || internalenv.Get(NoUpdateCheckEnv) == "1" || (Kind(running) != "stable" && Kind(running) != "rc") { + return Available{}, false + } + now := time.Now + if options.Now != nil { + now = options.Now + } + path := config.ProfilePath(options.ProfileDir, "update-check.json") + if cached, ok := loadCheckCache(path); ok && cached.Running == running { + age := now().Sub(cached.CheckedAt) + if age >= 0 && age < cacheLifetime { + answer := Available{Latest: cached.Latest, Running: running} + return answer, answer.Newer() + } + } + if options.Client == nil { + return Available{}, false + } + release, err := options.Client.Select(ctx, Choice{Channel: "stable"}) + if err != nil { + return Available{}, false + } + answer := Available{Latest: release.Tag, Running: running} + _ = saveCheckCache(path, checkCache{CheckedAt: now(), Latest: release.Tag, Running: running}) + return answer, answer.Newer() +} + +func loadCheckCache(path string) (checkCache, bool) { + if strings.TrimSpace(path) == "" { + return checkCache{}, false + } + raw, err := os.ReadFile(path) + if err != nil { + return checkCache{}, false + } + var cached checkCache + if json.Unmarshal(raw, &cached) != nil || cached.CheckedAt.IsZero() || cached.Latest == "" || cached.Running == "" { + return checkCache{}, false + } + return cached, true +} + +func saveCheckCache(path string, cached checkCache) error { + if strings.TrimSpace(path) == "" { + return nil + } + raw, err := json.Marshal(cached) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".update-check-*.json") + if err != nil { + return err + } + temporaryPath := temporary.Name() + keep := false + defer func() { + if !keep { + _ = os.Remove(temporaryPath) + } + }() + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return err + } + if _, err := temporary.Write(append(raw, '\n')); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryPath, path); err != nil { + return err + } + keep = true + return nil +} diff --git a/internal/update/cleanup_running_unix.go b/internal/update/cleanup_running_unix.go new file mode 100644 index 0000000000..b3a000027d --- /dev/null +++ b/internal/update/cleanup_running_unix.go @@ -0,0 +1,7 @@ +//go:build !windows + +package update + +// CleanupOldRunning does nothing where replacing a running executable leaves +// no backup. In particular, it does not resolve the executable during launch. +func CleanupOldRunning(func() (string, error)) {} diff --git a/internal/update/cleanup_running_unix_test.go b/internal/update/cleanup_running_unix_test.go new file mode 100644 index 0000000000..c5956ef537 --- /dev/null +++ b/internal/update/cleanup_running_unix_test.go @@ -0,0 +1,16 @@ +//go:build !windows + +package update + +import "testing" + +func TestUnixLaunchDoesNotResolveAnExecutableOnlyToCleanAWindowsBackup(t *testing.T) { + called := false + CleanupOldRunning(func() (string, error) { + called = true + return "", nil + }) + if called { + t.Fatal("the Unix launch resolved its executable for Windows cleanup") + } +} diff --git a/internal/update/cleanup_running_windows.go b/internal/update/cleanup_running_windows.go new file mode 100644 index 0000000000..4e411aee14 --- /dev/null +++ b/internal/update/cleanup_running_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package update + +// CleanupOldRunning removes the backup left by the previous Windows update. +// Resolution is platform-specific so other launches never pay for this read. +func CleanupOldRunning(executable func() (string, error)) { + target, err := ExecutableTarget(executable) + if err == nil { + CleanupOld(target) + } +} diff --git a/internal/update/client.go b/internal/update/client.go new file mode 100644 index 0000000000..c999e10280 --- /dev/null +++ b/internal/update/client.go @@ -0,0 +1,238 @@ +package update + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + internalenv "github.com/Agent-Field/codeaf/internal/env" +) + +const ( + // CurlCommand is the independent installation road shown after a failed or + // unavailable in-place update. + CurlCommand = "curl -fsSL https://raw.githubusercontent.com/Agent-Field/codeaf/main/scripts/install.sh | bash" + + NoUpdateCheckEnv = "CODEAF_NO_UPDATE_CHECK" + GitHubAPIEnv = "CODEAF_GITHUB_API" + GitHubDownloadEnv = "CODEAF_GITHUB_DOWNLOAD" +) + +const primaryRepository = "Agent-Field/codeaf" +const legacyRepository = "Agent-Field/aforge-v2" // legacy-name + +// Client talks to the release API and release asset host. +type Client struct { + HTTP *http.Client + APIBase string + DownloadBase string + Token string + Revision string +} + +// NewClient builds the release client from the same mirror and token inputs as +// the shell installer. The timeout covers the whole request, including bodies. +func NewClient(revision string, timeout time.Duration) *Client { + apiBase := strings.TrimRight(internalenv.Get(GitHubAPIEnv), "/") + if apiBase == "" { + apiBase = "https://api.github.com" + } + downloadBase := strings.TrimRight(internalenv.Get(GitHubDownloadEnv), "/") + if downloadBase == "" { + downloadBase = "https://github.com" + } + token := internalenv.Value("GITHUB_TOKEN") + if token == "" { + token = internalenv.Value("GH_TOKEN") + } + return &Client{ + HTTP: &http.Client{Timeout: timeout}, + APIBase: apiBase, + DownloadBase: downloadBase, + Token: token, + Revision: revision, + } +} + +func (c *Client) httpClient() *http.Client { + if c != nil && c.HTTP != nil { + return c.HTTP + } + return &http.Client{Timeout: 3 * time.Second} +} + +// requestClient keeps the caller's redirect decisions while enforcing the +// credential boundary the release API promises. Go considers another port on +// the same hostname safe for sensitive headers; the token here belongs to the +// exact API host, including its port, so that default is too broad. +func (c *Client) requestClient() *http.Client { + base := c.httpClient() + client := *base + checkRedirect := base.CheckRedirect + client.CheckRedirect = func(request *http.Request, via []*http.Request) error { + if checkRedirect != nil { + if err := checkRedirect(request, via); err != nil { + return err + } + } else if len(via) >= 10 { + return errors.New("stopped after 10 redirects") + } + if len(via) > 0 && !strings.EqualFold(request.URL.Host, via[0].URL.Host) { + request.Header.Del("Authorization") + } + return nil + } + return &client +} + +func (c *Client) userAgent() string { + revision := strings.TrimSpace(c.Revision) + if revision == "" { + revision = "source" + } + return "codeaf/" + revision +} + +type statusError struct { + code int + url string +} + +func (e *statusError) Error() string { + return fmt.Sprintf("%s answered HTTP %d", e.url, e.code) +} + +func isStatus(err error, code int) bool { + var status *statusError + return errors.As(err, &status) && status.code == code +} + +func (c *Client) get(ctx context.Context, rawURL, accept string, api bool) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", accept) + req.Header.Set("User-Agent", c.userAgent()) + // The token belongs only on API requests. Release downloads may redirect to + // another host, and no credential follows them there. + if api && strings.TrimSpace(c.Token) != "" { + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(c.Token)) + } + resp, err := c.requestClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return nil, &statusError{code: resp.StatusCode, url: rawURL} + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read %s: %w", rawURL, err) + } + return body, nil +} + +// Choice selects one release channel or one exact tag. +type Choice struct { + Channel string + Version string +} + +// Release is one selected GitHub release and the repository that answered. +type Release struct { + Tag string + Repository string +} + +type apiRelease struct { + TagName string `json:"tag_name"` + PublishedAt time.Time `json:"published_at"` + CreatedAt time.Time `json:"created_at"` +} + +func (r apiRelease) stamp() time.Time { + if !r.PublishedAt.IsZero() { + return r.PublishedAt + } + return r.CreatedAt +} + +// Select resolves a channel with the same timestamp law as the shell installer. +func (c *Client) Select(ctx context.Context, choice Choice) (Release, error) { + if version := strings.TrimSpace(choice.Version); version != "" { + if Kind(version) == "other" { + return Release{}, fmt.Errorf("%q is not a codeaf release tag", version) + } + return Release{Tag: version, Repository: primaryRepository}, nil + } + channel := strings.TrimSpace(choice.Channel) + if channel == "" { + channel = "stable" + } + if channel != "stable" && channel != "rc" && channel != "dev" && channel != "staging" { + return Release{}, fmt.Errorf("channel must be stable, rc, dev, or staging") + } + for index, repository := range []string{primaryRepository, legacyRepository} { // legacy-name + release, err := c.selectRepository(ctx, repository, channel) + if err == nil { + return release, nil + } + if !isStatus(err, http.StatusNotFound) || index == 1 { + return Release{}, err + } + } + return Release{}, errors.New("no release repository answered") +} + +func (c *Client) selectRepository(ctx context.Context, repository, channel string) (Release, error) { + suffix := "releases/latest" + if channel != "stable" { + suffix = "releases?per_page=100" + } + rawURL := strings.TrimRight(c.APIBase, "/") + "/repos/" + repository + "/" + suffix + body, err := c.get(ctx, rawURL, "application/vnd.github+json", true) + if err != nil { + return Release{}, err + } + if channel == "stable" { + var row apiRelease + if err := json.Unmarshal(body, &row); err != nil { + return Release{}, fmt.Errorf("read the latest release: %w", err) + } + if Kind(row.TagName) != "stable" { + return Release{}, fmt.Errorf("the latest release did not name a stable codeaf tag") + } + return Release{Tag: row.TagName, Repository: repository}, nil + } + var rows []apiRelease + if err := json.Unmarshal(body, &rows); err != nil { + return Release{}, fmt.Errorf("read the release list: %w", err) + } + var newest apiRelease + for _, row := range rows { + if Kind(row.TagName) != channel { + continue + } + if newest.TagName == "" || row.stamp().After(newest.stamp()) { + newest = row + } + } + if newest.TagName == "" { + return Release{}, fmt.Errorf("no %s build has been published yet", channel) + } + return Release{Tag: newest.TagName, Repository: repository}, nil +} + +func (c *Client) assetURL(release Release, name string) string { + base := strings.TrimRight(c.DownloadBase, "/") + return base + "/" + release.Repository + "/releases/download/" + url.PathEscape(release.Tag) + "/" + url.PathEscape(name) +} diff --git a/internal/update/install.go b/internal/update/install.go new file mode 100644 index 0000000000..82082e161b --- /dev/null +++ b/internal/update/install.go @@ -0,0 +1,170 @@ +package update + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" +) + +// InstallOptions describes one in-place replacement. +type InstallOptions struct { + Client *Client + Release Release + Target string + GOOS string + GOARCH string +} + +// InstallResult is the release and executable path that are ready to restart. +type InstallResult struct { + Release Release + Path string +} + +// Install downloads, checks, and atomically replaces one executable. +func Install(ctx context.Context, options InstallOptions) (InstallResult, error) { + if options.Client == nil { + return InstallResult{}, errors.New("no release client is available") + } + target := strings.TrimSpace(options.Target) + if target == "" { + return InstallResult{}, errors.New("the running executable path is empty") + } + goos, goarch := options.GOOS, options.GOARCH + if goos == "" { + goos = runtime.GOOS + } + if goarch == "" { + goarch = runtime.GOARCH + } + if (goos != "linux" && goos != "darwin" && goos != "windows") || (goarch != "amd64" && goarch != "arm64") { + return InstallResult{}, fmt.Errorf("unsupported platform: %s/%s", goos, goarch) + } + extension := "" + if goos == "windows" { + extension = ".exe" + } + asset := "codeaf-" + goos + "-" + goarch + extension + + release := options.Release + if release.Tag == "" { + return InstallResult{}, errors.New("the release tag is empty") + } + if release.Repository == "" { + release.Repository = primaryRepository + } + var ( + body []byte + checksums []byte + err error + ) + for index, repository := range installRepositories(release.Repository) { + release.Repository = repository + body, asset, checksums, err = options.Client.downloadRelease(ctx, release, asset, goos, goarch, extension) + if err == nil { + break + } + if !isStatus(err, http.StatusNotFound) || index == 1 { + return InstallResult{}, err + } + } + if err != nil { + return InstallResult{}, err + } + expected, ok := checksumFor(checksums, asset) + if !ok { + return InstallResult{}, fmt.Errorf("checksums.txt has no checksum for %s", asset) + } + actualBytes := sha256.Sum256(body) + actual := hex.EncodeToString(actualBytes[:]) + if !strings.EqualFold(expected, actual) { + return InstallResult{}, fmt.Errorf("the checksum for %s did not match", asset) + } + + temporaryFile, err := os.CreateTemp(filepath.Dir(target), fmt.Sprintf(".codeaf.tmp.%d.", os.Getpid())) + if err != nil { + return InstallResult{}, fmt.Errorf("cannot replace %s: %w; install a release with: %s", target, err, CurlCommand) + } + temporary := temporaryFile.Name() + defer os.Remove(temporary) + _, writeErr := temporaryFile.Write(body) + closeErr := temporaryFile.Close() + if writeErr != nil { + return InstallResult{}, fmt.Errorf("write %s: %w", temporary, writeErr) + } + if closeErr != nil { + return InstallResult{}, fmt.Errorf("close %s: %w", temporary, closeErr) + } + if err := os.Chmod(temporary, 0o755); err != nil { + return InstallResult{}, fmt.Errorf("make %s executable: %w", temporary, err) + } + if err := replaceExecutable(temporary, target); err != nil { + return InstallResult{}, fmt.Errorf("cannot replace %s: %w; install a release with: %s", target, err, CurlCommand) + } + return InstallResult{Release: release, Path: target}, nil +} + +func installRepositories(first string) []string { + if first == legacyRepository { + return []string{legacyRepository} + } + return []string{primaryRepository, legacyRepository} // legacy-name +} + +func (c *Client) downloadRelease(ctx context.Context, release Release, asset, goos, goarch, extension string) ([]byte, string, []byte, error) { + body, err := c.get(ctx, c.assetURL(release, asset), "application/octet-stream", false) + if isStatus(err, http.StatusNotFound) { + legacyAsset := "aforge-" + goos + "-" + goarch + extension // legacy-name + body, err = c.get(ctx, c.assetURL(release, legacyAsset), "application/octet-stream", false) + if err == nil { + asset = legacyAsset + } + } + if err != nil { + return nil, asset, nil, err + } + checksums, err := c.get(ctx, c.assetURL(release, "checksums.txt"), "application/octet-stream", false) + if err != nil { + return nil, asset, nil, err + } + return body, asset, checksums, nil +} + +func checksumFor(raw []byte, name string) (string, bool) { + for _, line := range strings.Split(string(raw), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + candidate := strings.TrimPrefix(fields[1], "*") + if candidate == name { + if decoded, err := hex.DecodeString(fields[0]); err == nil && len(decoded) == sha256.Size { + return strings.ToLower(fields[0]), true + } + } + } + return "", false +} + +// ExecutableTarget resolves the running binary before an installer replaces it. +func ExecutableTarget(executable func() (string, error)) (string, error) { + if executable == nil { + executable = os.Executable + } + path, err := executable() + if err != nil { + return "", fmt.Errorf("find the running codeaf: %w", err) + } + path, err = filepath.EvalSymlinks(path) + if err != nil { + return "", fmt.Errorf("resolve %s: %w", path, err) + } + return path, nil +} diff --git a/internal/update/replace_contract_test.go b/internal/update/replace_contract_test.go new file mode 100644 index 0000000000..d4bc117290 --- /dev/null +++ b/internal/update/replace_contract_test.go @@ -0,0 +1,54 @@ +package update + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestWindowsReplacementFailureLeavesTheOriginalAtItsTarget proves the +// Windows half of D9 without asking this host to run a Windows executable. +func TestWindowsReplacementFailureLeavesTheOriginalAtItsTarget(t *testing.T) { + directory := t.TempDir() + target := filepath.Join(directory, "codeaf.exe") + temporary := filepath.Join(directory, ".codeaf.tmp") + if err := os.WriteFile(target, []byte("original"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(temporary, []byte("replacement"), 0o755); err != nil { + t.Fatal(err) + } + want := windowsUnableToMoveReplacement2 + err := replaceWindowsExecutable(temporary, target, func(gotTarget, gotReplacement, gotBackup string) error { + if gotTarget != target || gotReplacement != temporary || gotBackup != target+".old" { + t.Fatalf("replace(%q, %q, %q)", gotTarget, gotReplacement, gotBackup) + } + // Windows has already moved the original to the backup when it reports + // ERROR_UNABLE_TO_MOVE_REPLACEMENT_2, while the replacement stays put. + if moveErr := os.Rename(gotTarget, gotBackup); moveErr != nil { + t.Fatal(moveErr) + } + return want + }) + if !errors.Is(err, want) { + t.Fatalf("replacement error = %v, want %v", err, want) + } + raw, readErr := os.ReadFile(target) + if readErr != nil || string(raw) != "original" { + t.Fatalf("original target = %q, %v", raw, readErr) + } +} + +// TestTheWindowsBuildUsesTheReplacementRecoveryContract keeps the platform +// door on the operation that repairs ReplaceFileW's partial failure. +func TestTheWindowsBuildUsesTheReplacementRecoveryContract(t *testing.T) { + raw, err := os.ReadFile("replace_windows.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), "return replaceWindowsExecutable(temporary, target, replaceFile)") { + t.Fatal("the Windows replacement door bypasses the atomic replacement contract") + } +} diff --git a/internal/update/replace_unix.go b/internal/update/replace_unix.go new file mode 100644 index 0000000000..c8ba53916a --- /dev/null +++ b/internal/update/replace_unix.go @@ -0,0 +1,13 @@ +//go:build !windows + +package update + +import "os" + +func replaceExecutable(temporary, target string) error { + return os.Rename(temporary, target) +} + +// CleanupOld removes nothing on systems where the running executable can be +// replaced directly. +func CleanupOld(string) {} diff --git a/internal/update/replace_windows.go b/internal/update/replace_windows.go new file mode 100644 index 0000000000..c04f4d1056 --- /dev/null +++ b/internal/update/replace_windows.go @@ -0,0 +1,48 @@ +//go:build windows + +package update + +import ( + "os" + "syscall" + "unsafe" +) + +var replaceFileW = syscall.NewLazyDLL("kernel32.dll").NewProc("ReplaceFileW") + +func replaceExecutable(temporary, target string) error { + return replaceWindowsExecutable(temporary, target, replaceFile) +} + +func replaceFile(target, replacement, backup string) error { + targetPointer, err := syscall.UTF16PtrFromString(target) + if err != nil { + return err + } + replacementPointer, err := syscall.UTF16PtrFromString(replacement) + if err != nil { + return err + } + backupPointer, err := syscall.UTF16PtrFromString(backup) + if err != nil { + return err + } + result, _, callErr := replaceFileW.Call( + uintptr(unsafe.Pointer(targetPointer)), + uintptr(unsafe.Pointer(replacementPointer)), + uintptr(unsafe.Pointer(backupPointer)), + 0, + 0, + 0, + ) + if result != 0 { + return nil + } + if callErr != syscall.Errno(0) { + return callErr + } + return syscall.EINVAL +} + +// CleanupOld removes the previous Windows executable when the next process can. +func CleanupOld(target string) { _ = os.Remove(target + ".old") } diff --git a/internal/update/replace_windows_contract.go b/internal/update/replace_windows_contract.go new file mode 100644 index 0000000000..cda7005ca6 --- /dev/null +++ b/internal/update/replace_windows_contract.go @@ -0,0 +1,35 @@ +package update + +import ( + "errors" + "fmt" + "os" + "syscall" +) + +type windowsFileReplacer func(target, replacement, backup string) error + +// Windows reports error 1177 after moving the original to the backup but +// before moving the checked replacement onto the target. +const windowsUnableToMoveReplacement2 syscall.Errno = 1177 + +// replaceWindowsExecutable gives ReplaceFileW the target, checked replacement, +// and backup path. On its documented partial failure, recovery first restores +// the backup and then tries to finish the replacement if that new move fails. +func replaceWindowsExecutable(temporary, target string, replace windowsFileReplacer) error { + backup := target + ".old" + if err := os.Remove(backup); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + err := replace(target, temporary, backup) + if !errors.Is(err, windowsUnableToMoveReplacement2) { + return err + } + if restoreErr := os.Rename(backup, target); restoreErr == nil { + return err + } else if installErr := os.Rename(temporary, target); installErr == nil { + return nil + } else { + return fmt.Errorf("replace target: %w; restore original: %v; move replacement: %v", err, restoreErr, installErr) + } +} diff --git a/internal/update/restart.go b/internal/update/restart.go new file mode 100644 index 0000000000..cf40019f66 --- /dev/null +++ b/internal/update/restart.go @@ -0,0 +1,35 @@ +package update + +import "strings" + +// Plan is the executable and arguments a restored terminal restarts with. +type Plan struct { + Path string + Args []string +} + +// RestartArgs keeps the original launch shape and points it at this transcript. +func RestartArgs(original []string, transcript string) []string { + args := append([]string(nil), original...) + transcript = strings.TrimSpace(transcript) + if transcript == "" { + return args + } + if len(args) == 0 { + return []string{"chat", "--session", transcript} + } + for index, arg := range args { + if arg == "--session" { + if index+1 < len(args) { + args[index+1] = transcript + return args + } + return append(args, transcript) + } + if strings.HasPrefix(arg, "--session=") { + args[index] = "--session=" + transcript + return args + } + } + return append(args, "--session", transcript) +} diff --git a/internal/update/restart_unix.go b/internal/update/restart_unix.go new file mode 100644 index 0000000000..9f8aa311c6 --- /dev/null +++ b/internal/update/restart_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package update + +import ( + "os" + "syscall" +) + +// Restart replaces this process after the terminal has been restored. +func Restart(plan Plan) error { + argv := append([]string{plan.Path}, plan.Args...) + return syscall.Exec(plan.Path, argv, os.Environ()) +} diff --git a/internal/update/restart_windows.go b/internal/update/restart_windows.go new file mode 100644 index 0000000000..0077fc99cc --- /dev/null +++ b/internal/update/restart_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package update + +import ( + "os" + "os/exec" +) + +// Restart starts the replacement process with this process's terminal streams. +func Restart(plan Plan) error { + command := exec.Command(plan.Path, plan.Args...) + command.Stdin = os.Stdin + command.Stdout = os.Stdout + command.Stderr = os.Stderr + return command.Start() +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000000..e0fbbffa2e --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,353 @@ +package update + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" + "time" +) + +func releaseClient(server *httptest.Server, revision string) *Client { + return &Client{ + HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL, Revision: revision, + } +} + +// TestC1StableLaunchComparisonAndNotice proves C1. +func TestC1StableLaunchComparisonAndNotice(t *testing.T) { + for _, row := range []struct { + running string + show bool + }{{"v0.1.1", true}, {"v0.2.0", false}, {"v0.3.0", false}} { + t.Run(row.running, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"tag_name":"v0.2.0"}`) + })) + defer server.Close() + answer, show := CheckLaunch(context.Background(), CheckOptions{ + Running: row.running, ProfileDir: t.TempDir(), Client: releaseClient(server, row.running), + }) + if show != row.show { + t.Fatalf("show = %t, want %t", show, row.show) + } + if row.show { + line := answer.Notice() + for _, want := range []string{"v0.2.0", "v0.1.1", "/update", CurlCommand} { + if strings.Count(line, want) != 1 { + t.Fatalf("notice %q does not contain %q exactly once", line, want) + } + } + } + }) + } +} + +// TestC2ReleaseCandidateIsPromptedOnlyWhenItsLineIsStable proves C2. +func TestC2ReleaseCandidateIsPromptedOnlyWhenItsLineIsStable(t *testing.T) { + for _, row := range []struct { + latest string + show bool + }{{"v0.2.0", true}, {"v0.1.9", false}} { + answer := Available{Latest: row.latest, Running: "v0.2.0-rc.3"} + if got := answer.Newer(); got != row.show { + t.Fatalf("latest %s: newer = %t, want %t", row.latest, got, row.show) + } + } +} + +// TestC3SourceAndChannelLaunchesNeverReachTheNetwork proves C3. +func TestC3SourceAndChannelLaunchesNeverReachTheNetwork(t *testing.T) { + var hits atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { hits.Add(1) })) + defer server.Close() + for _, running := range []string{"dev-20260915-abcdefabcdef", "staging-20260915-abcdefabcdef", "deadbeef", ""} { + answer, show := CheckLaunch(context.Background(), CheckOptions{ + Running: running, ProfileDir: t.TempDir(), Client: releaseClient(server, running), + }) + if show || answer != (Available{}) { + t.Fatalf("%q returned %+v, %t", running, answer, show) + } + } + if hits.Load() != 0 { + t.Fatalf("server saw %d requests", hits.Load()) + } +} + +// TestC4OptOutAndFreshCacheAvoidRequestsWhileStaleFactsRefresh proves C4. +func TestC4OptOutAndFreshCacheAvoidRequestsWhileStaleFactsRefresh(t *testing.T) { + base := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) + var hits atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + fmt.Fprint(w, `{"tag_name":"v0.2.0"}`) + })) + defer server.Close() + client := releaseClient(server, "v0.1.1") + + t.Setenv(NoUpdateCheckEnv, "1") + CheckLaunch(context.Background(), CheckOptions{Running: "v0.1.1", ProfileDir: t.TempDir(), Client: client}) + if hits.Load() != 0 { + t.Fatal("the opt-out reached the server") + } + t.Setenv(NoUpdateCheckEnv, "") + + fresh := t.TempDir() + path := filepath.Join(fresh, "update-check.json") + if err := saveCheckCache(path, checkCache{CheckedAt: base.Add(-23 * time.Hour), Latest: "v0.2.0", Running: "v0.1.1"}); err != nil { + t.Fatal(err) + } + answer, show := CheckLaunch(context.Background(), CheckOptions{Running: "v0.1.1", ProfileDir: fresh, Client: client, Now: func() time.Time { return base }}) + if !show || answer.Latest != "v0.2.0" || hits.Load() != 0 { + t.Fatalf("fresh cache = %+v, %t, hits %d", answer, show, hits.Load()) + } + + for _, cached := range []checkCache{ + {CheckedAt: base.Add(-25 * time.Hour), Latest: "v0.2.0", Running: "v0.1.1"}, + {CheckedAt: base.Add(-time.Hour), Latest: "v0.2.0", Running: "v0.1.0"}, + } { + dir := t.TempDir() + if err := saveCheckCache(filepath.Join(dir, "update-check.json"), cached); err != nil { + t.Fatal(err) + } + CheckLaunch(context.Background(), CheckOptions{Running: "v0.1.1", ProfileDir: dir, Client: client, Now: func() time.Time { return base }}) + } + if hits.Load() != 2 { + t.Fatalf("stale facts made %d requests, want 2", hits.Load()) + } +} + +// TestC5LaunchFailuresAreSilent proves C5. +func TestC5LaunchFailuresAreSilent(t *testing.T) { + tests := map[string]http.Handler{ + "403": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "no", http.StatusForbidden) }), + "404": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.NotFound(w, nil) }), + "500": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "no", http.StatusInternalServerError) }), + "malformed": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, "{") }), + "timeout": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { time.Sleep(50 * time.Millisecond) }), + } + for name, handler := range tests { + t.Run(name, func(t *testing.T) { + server := httptest.NewServer(handler) + defer server.Close() + client := releaseClient(server, "v0.1.1") + if name == "timeout" { + client.HTTP.Timeout = time.Millisecond + } + answer, show := CheckLaunch(context.Background(), CheckOptions{Running: "v0.1.1", ProfileDir: t.TempDir(), Client: client}) + if show || answer != (Available{}) { + t.Fatalf("failure drew %+v, %t", answer, show) + } + }) + } + client := &Client{HTTP: &http.Client{Timeout: 10 * time.Millisecond}, APIBase: "http://127.0.0.1:1", Revision: "v0.1.1"} + if answer, show := CheckLaunch(context.Background(), CheckOptions{Running: "v0.1.1", ProfileDir: t.TempDir(), Client: client}); show || answer != (Available{}) { + t.Fatalf("unreachable server drew %+v, %t", answer, show) + } +} + +func servedRelease(t *testing.T, asset []byte, checksum string) (*httptest.Server, *atomic.Int64) { + t.Helper() + requests := &atomic.Int64{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + switch { + case strings.HasSuffix(r.URL.Path, "/codeaf-"+runtime.GOOS+"-"+runtime.GOARCH): + _, _ = w.Write(asset) + case strings.HasSuffix(r.URL.Path, "/checksums.txt"): + fmt.Fprintf(w, "%s codeaf-%s-%s\n", checksum, runtime.GOOS, runtime.GOARCH) + default: + http.NotFound(w, r) + } + })) + return server, requests +} + +// TestC7CheckedInstallReplacesAtomicallyAndMismatchPreservesTheOriginal proves C7. +func TestC7CheckedInstallReplacesAtomicallyAndMismatchPreservesTheOriginal(t *testing.T) { + asset := []byte("new codeaf") + digest := sha256.Sum256(asset) + for _, row := range []struct { + name string + checksum string + ok bool + }{{"matching", hex.EncodeToString(digest[:]), true}, {"wrong", strings.Repeat("0", 64), false}} { + t.Run(row.name, func(t *testing.T) { + server, _ := servedRelease(t, asset, row.checksum) + defer server.Close() + dir := t.TempDir() + target := filepath.Join(dir, "codeaf") + if err := os.WriteFile(target, []byte("old codeaf"), 0o700); err != nil { + t.Fatal(err) + } + result, err := Install(context.Background(), InstallOptions{ + Client: releaseClient(server, "v0.1.1"), Release: Release{Tag: "v0.2.0", Repository: primaryRepository}, Target: target, + }) + if (err == nil) != row.ok { + t.Fatalf("error = %v, want success %t", err, row.ok) + } + got, readErr := os.ReadFile(target) + if readErr != nil { + t.Fatal(readErr) + } + want := []byte("old codeaf") + if row.ok { + want = asset + info, _ := os.Stat(target) + if info.Mode().Perm() != 0o755 || result.Path != target || result.Release.Tag != "v0.2.0" { + t.Fatalf("result = %+v mode %o", result, info.Mode().Perm()) + } + } else if !strings.Contains(err.Error(), "checksum") { + t.Fatalf("mismatch error = %v", err) + } + if string(got) != string(want) { + t.Fatalf("target = %q, want %q", got, want) + } + matches, _ := filepath.Glob(filepath.Join(dir, ".codeaf.tmp.*")) + if len(matches) != 0 { + t.Fatalf("temporary files remain: %v", matches) + } + }) + } +} + +// TestC10TokenReachesOnlyTheAPIHost proves C10. +func TestC10TokenReachesOnlyTheAPIHost(t *testing.T) { + var apiAuthorization, apiAccept, apiAgent string + var downloadAuthorization, downloadAccept, downloadAgent string + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + apiAuthorization = r.Header.Get("Authorization") + apiAccept = r.Header.Get("Accept") + apiAgent = r.Header.Get("User-Agent") + fmt.Fprint(w, `{"tag_name":"v0.2.0"}`) + })) + defer api.Close() + asset := []byte("release") + digest := sha256.Sum256(asset) + download := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + downloadAuthorization = r.Header.Get("Authorization") + downloadAccept = r.Header.Get("Accept") + downloadAgent = r.Header.Get("User-Agent") + if strings.HasSuffix(r.URL.Path, "checksums.txt") { + fmt.Fprintf(w, "%x codeaf-%s-%s\n", digest, runtime.GOOS, runtime.GOARCH) + return + } + _, _ = w.Write(asset) + })) + defer download.Close() + client := &Client{HTTP: api.Client(), APIBase: api.URL, DownloadBase: download.URL, Token: "secret", Revision: "v0.1.1"} + release, err := client.Select(context.Background(), Choice{Channel: "stable"}) + if err != nil { + t.Fatal(err) + } + target := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := Install(context.Background(), InstallOptions{Client: client, Release: release, Target: target}); err != nil { + t.Fatal(err) + } + if apiAuthorization != "Bearer secret" || downloadAuthorization != "" { + t.Fatalf("authorization: api %q download %q", apiAuthorization, downloadAuthorization) + } + if apiAccept != "application/vnd.github+json" || downloadAccept != "application/octet-stream" { + t.Fatalf("accept: api %q download %q", apiAccept, downloadAccept) + } + if apiAgent != "codeaf/v0.1.1" || downloadAgent != "codeaf/v0.1.1" { + t.Fatalf("user agent: api %q download %q", apiAgent, downloadAgent) + } +} + +// TestC10RedirectsNeverCarryTheAPITokenToAnotherHost proves the redirect case +// with two real servers that share a hostname and differ only by port. +func TestC10RedirectsNeverCarryTheAPITokenToAnotherHost(t *testing.T) { + var redirectedAuthorization string + destination := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + redirectedAuthorization = request.Header.Get("Authorization") + fmt.Fprint(w, `{"tag_name":"v0.2.0"}`) + })) + defer destination.Close() + var apiAuthorization string + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + apiAuthorization = request.Header.Get("Authorization") + http.Redirect(w, request, destination.URL+request.URL.Path, http.StatusFound) + })) + defer api.Close() + client := &Client{HTTP: api.Client(), APIBase: api.URL, Token: "secret", Revision: "v0.1.1"} + if _, err := client.Select(context.Background(), Choice{Channel: "stable"}); err != nil { + t.Fatal(err) + } + if apiAuthorization != "Bearer secret" || redirectedAuthorization != "" { + t.Fatalf("authorization: original host %q redirected host %q", apiAuthorization, redirectedAuthorization) + } +} + +// TestC11RepositoryAndAssetFallbacksKeepOldReleasesInstallable proves C11. +func TestC11RepositoryAndAssetFallbacksKeepOldReleasesInstallable(t *testing.T) { + asset := []byte("old named release") + digest := sha256.Sum256(asset) + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + switch { + case strings.Contains(r.URL.Path, "/repos/"+primaryRepository+"/"): + http.NotFound(w, r) + case strings.Contains(r.URL.Path, "/repos/"+legacyRepository+"/"): + fmt.Fprint(w, `{"tag_name":"v0.2.0"}`) + case strings.HasSuffix(r.URL.Path, "/aforge-"+runtime.GOOS+"-"+runtime.GOARCH): // legacy-name + _, _ = w.Write(asset) + case strings.HasSuffix(r.URL.Path, "/checksums.txt"): + fmt.Fprintf(w, "%x *aforge-%s-%s\n", digest, runtime.GOOS, runtime.GOARCH) // legacy-name + default: + http.NotFound(w, r) + } + })) + defer server.Close() + client := releaseClient(server, "v0.1.1") + release, err := client.Select(context.Background(), Choice{Channel: "stable"}) + if err != nil || release.Repository != legacyRepository { + t.Fatalf("release = %+v, error %v", release, err) + } + target := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := Install(context.Background(), InstallOptions{Client: client, Release: release, Target: target}); err != nil { + t.Fatal(err) + } + joined := strings.Join(paths, "\n") + for _, want := range []string{"/repos/" + primaryRepository, "/repos/" + legacyRepository, "/codeaf-", "/aforge-"} { // legacy-name + if !strings.Contains(joined, want) { + t.Fatalf("requests do not contain %q:\n%s", want, joined) + } + } +} + +// TestC14RestartArgumentsReopenTheSameConversation proves C14. +func TestC14RestartArgumentsReopenTheSameConversation(t *testing.T) { + file := "/tmp/conversation.jsonl" + for _, row := range []struct { + name string + args []string + want []string + }{ + {"bare", nil, []string{"chat", "--session", file}}, + {"chat", []string{"chat", "--model", "x"}, []string{"chat", "--model", "x", "--session", file}}, + {"resume", []string{"resume", "--session", file}, []string{"resume", "--session", file}}, + } { + t.Run(row.name, func(t *testing.T) { + got := RestartArgs(row.args, file) + if strings.Join(got, "\x00") != strings.Join(row.want, "\x00") { + t.Fatalf("args = %q, want %q", got, row.want) + } + }) + } +} From d8bbe66ce075f6f20b4960343aed8b7011d43091 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 15 Sep 2026 19:24:56 -0400 Subject: [PATCH 03/11] tui3: a launch says a newer codeaf is out, and /update installs it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The launch line is one dim transcript note carrying both versions, /update, and the curl line, because the command is what the owner asked to be given. It arrives as a command rather than an opening read, so the first frame is drawn before the network is asked anything and a slow or refused answer draws nothing. /update — /upgrade is the same road — names the tag, then the checksum and where it landed, then quits with a restart plan for the door to carry out. Already newest says so and stays. It refuses while a turn or a task is running, and once it has started, one shared guard keeps a second /update and a new turn from beginning underneath it: the restart must never quit on top of live work, and the home page's Enter roads go through the same guard chat's does. Co-Authored-By: Claude Opus 5 (1M context) --- internal/tui3/app.go | 41 +++++- internal/tui3/commands.go | 1 + internal/tui3/home.go | 3 + internal/tui3/homeexchange.go | 3 + internal/tui3/homeslash.go | 2 +- internal/tui3/input.go | 3 + internal/tui3/tui3.go | 17 +++ internal/tui3/updatecmd.go | 148 ++++++++++++++++++++++ internal/tui3/updatecmd_test.go | 217 ++++++++++++++++++++++++++++++++ internal/tui3/updateplatform.go | 8 ++ 10 files changed, 436 insertions(+), 7 deletions(-) create mode 100644 internal/tui3/updatecmd.go create mode 100644 internal/tui3/updatecmd_test.go create mode 100644 internal/tui3/updateplatform.go diff --git a/internal/tui3/app.go b/internal/tui3/app.go index a6faf53a3a..599a1a1254 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -21,6 +21,7 @@ import ( "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/subharness" "github.com/Agent-Field/codeaf/internal/tui2/tokens" + codeupdate "github.com/Agent-Field/codeaf/internal/update" ) // frameInterval is the repaint ceiling: at most one frame is BUILT per 33ms, @@ -851,11 +852,18 @@ type app struct { // workspace is the directory this conversation is about, whole; place is // its base name, which is what the status line has room for. The whole path // is what history is keyed by and what the @ completion walks. - workspace string - place string - file string - build string - resumed bool + workspace string + place string + file string + build string + resumed bool + updateCheck func(context.Context) (codeupdate.Available, bool) + resolveUpdate func(context.Context, codeupdate.Choice) (codeupdate.Release, error) + installUpdate func(context.Context, codeupdate.Release) (codeupdate.InstallResult, error) + updateRunning string + updateArgs []string + updateActive bool + restart *codeupdate.Plan // errandHome is the person's own home directory, resolved ONCE at `open` and // held: the `~` project an item that belongs to no repository runs in // ([app.errandPlace], [app.readBareBands], and homeexchange.go's @@ -2676,6 +2684,12 @@ func newApp(ctx context.Context, opts Options) *app { file: opts.SessionFile, build: strings.TrimSpace(opts.Build), resumed: opts.Resumed, + updateCheck: opts.UpdateCheck, + resolveUpdate: opts.ResolveUpdate, + installUpdate: opts.InstallUpdate, + updateRunning: strings.TrimSpace(opts.UpdateRunning), + updateArgs: append([]string(nil), opts.UpdateArgs...), + restart: opts.Restart, models: opts.Models, modelsForService: opts.ModelsForService, sources: opts.Sources, @@ -3110,7 +3124,7 @@ func (a *app) Init() tea.Cmd { // AND THE SETUP SCREEN'S EXAMPLE PANEL, when the setup is the first frame // and the controls screen is its first step. It answers nil in every other // case, which is most launches (onboarding.go). - a.setupDemoCmd(), titleSend(a.titleSent), + a.setupDemoCmd(), a.checkForUpdate(), titleSend(a.titleSent), // AND THE TWO DOORS INTO THE LOOP FROM ELSEWHERE, each with its one // command parked on it (doorbell.go). a.news.waitRing(), a.leaving.waitRing()} @@ -4666,6 +4680,18 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { // law's own clock, and the only line this surface draws about a wait. return a, a.remoteOpenSlow(msg) + case updateCheckMsg: + return a, a.tookUpdateCheck(msg) + + case updateResolveMsg: + return a, a.tookUpdateResolve(msg) + + case updateInstallMsg: + return a, a.tookUpdateInstall(msg) + + case updateRestartMsg: + return a, a.quit() + case frameMsg: return a, a.paint() } @@ -6687,6 +6713,9 @@ func (a *app) slash(line string) tea.Cmd { // The unknown-command hint below says back what was typed and not what it // resolved to, so the name as written is kept. switch canonicalCommand(name) { + case "update": + return a.runUpdateCommand(rest) + case "autonomy": if rest != "" { return a.changeAutonomy(rest) diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index ded0004f18..88e07ff71e 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -438,6 +438,7 @@ var commands = []command{ // twice in a day, and a row inserted higher would push a daily command // behind a scroll. {name: "debug", desc: "keep the full record of this conversation · says where it goes"}, + {name: "update", desc: "install the newest codeaf and restart on it", alias: []string{"upgrade"}}, {name: "help", desc: "this list", alias: []string{"?"}}, {name: "quit", desc: "close this conversation", alias: []string{"exit", "q"}}, } diff --git a/internal/tui3/home.go b/internal/tui3/home.go index 5672e4942b..5b82ea4fa1 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -3415,6 +3415,9 @@ func (a *app) homeRowGone(row session.SessionRow) bool { // the new conversation owes itself, so the submit below is talking to the new // agent and not to the one that just closed. func (a *app) homeStart(text string) tea.Cmd { + if (strings.TrimSpace(text) != "" || len(a.chips) > 0) && a.updateStopsTurn() { + return nil + } if !a.canStart() { a.home.say(newUnavailableWord, "") return nil diff --git a/internal/tui3/homeexchange.go b/internal/tui3/homeexchange.go index 21e7104ad4..dfdbd07312 100644 --- a/internal/tui3/homeexchange.go +++ b/internal/tui3/homeexchange.go @@ -972,6 +972,9 @@ func (a *app) askHereWith(text string, orders ErrandOrders) tea.Cmd { if text == "" { return nil } + if a.updateStopsTurn() { + return nil + } h := &a.home if a.errand == nil { h.say(homeAskUnavailableWord, "") diff --git a/internal/tui3/homeslash.go b/internal/tui3/homeslash.go index 1a0026bff4..7a373c8eae 100644 --- a/internal/tui3/homeslash.go +++ b/internal/tui3/homeslash.go @@ -253,7 +253,7 @@ func homeFate(word, rest string) string { return fatePlace } return fateAnswers - case "help", "manual", "status", "cost", "budget", "cache", "debug", + case "help", "manual", "status", "cost", "budget", "cache", "debug", "update", "stop", "remember", "forget": return fateAnswers } diff --git a/internal/tui3/input.go b/internal/tui3/input.go index 653e3ff8d6..27f82810f3 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -1330,6 +1330,9 @@ func (a *app) enterLine(marked bool) tea.Cmd { // empty message — "what is this?" is often the picture itself — so the two // tests below both ask about the tray as well as about the words. held := len(a.chips) > 0 + if !strings.HasPrefix(line, "/") && (line != "" || held) && a.updateStopsTurn() { + return nil + } // An empty draft with a call selected is a reader, not a typist: enter // opens what ↑/↓ picked out. A draft of any length is a sentence, and a // sentence wins. diff --git a/internal/tui3/tui3.go b/internal/tui3/tui3.go index cf7b97ac86..b5960b2ff2 100644 --- a/internal/tui3/tui3.go +++ b/internal/tui3/tui3.go @@ -55,6 +55,7 @@ import ( "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/standing" "github.com/Agent-Field/codeaf/internal/subharness" + codeupdate "github.com/Agent-Field/codeaf/internal/update" ) // Agent is the slice of *session.Agent this surface uses. It is an interface @@ -416,6 +417,22 @@ type Options struct { // machines, where this process's own build would be the wrong answer. Build string + // UpdateCheck is the silent launch look at the newest stable release. It is + // a command rather than an opening read so the first frame never waits for + // the network. Nil leaves the capability absent. + UpdateCheck func(context.Context) (codeupdate.Available, bool) + // ResolveUpdate and InstallUpdate are the two off-frame halves of /update. + // Keeping selection separate lets the surface name the tag before the + // download begins. Nil leaves the command with an honest refusal. + ResolveUpdate func(context.Context, codeupdate.Choice) (codeupdate.Release, error) + InstallUpdate func(context.Context, codeupdate.Release) (codeupdate.InstallResult, error) + // UpdateRunning is the exact revision of this process, without build-time + // decoration. UpdateArgs are its original arguments. Restart is the slot the + // surface fills before quitting and the door reads after the terminal is back. + UpdateRunning string + UpdateArgs []string + Restart *codeupdate.Plan + // Memory is the durable memory store behind the memory place. Nil means the // place is unavailable; the live door passes the same store it gave the // session, wrapped so that the two READING methods are spelled the way this diff --git a/internal/tui3/updatecmd.go b/internal/tui3/updatecmd.go new file mode 100644 index 0000000000..22168713f8 --- /dev/null +++ b/internal/tui3/updatecmd.go @@ -0,0 +1,148 @@ +package tui3 + +import ( + "context" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + + codeupdate "github.com/Agent-Field/codeaf/internal/update" +) + +type updateCheckMsg struct { + available codeupdate.Available + show bool +} + +type updateResolveMsg struct { + release codeupdate.Release + err error +} + +type updateInstallMsg struct { + result codeupdate.InstallResult + err error +} + +type updateRestartMsg struct{} + +const updateTurnRefusal = "finish the update before starting a turn" + +// updateStopsTurn keeps every turn-starting door behind the update that owns +// the next process start. Home also receives the same words on its visible +// message line because its full-page frame hides conversation notes. +func (a *app) updateStopsTurn() bool { + if !a.updateActive { + return false + } + a.note(updateTurnRefusal) + if a.at(pageHome) { + a.home.say(updateTurnRefusal, "") + } + return true +} + +// checkForUpdate starts after the app is ready to draw, so a slow or absent +// network can never become a slow first frame. +func (a *app) checkForUpdate() tea.Cmd { + if a.updateCheck == nil { + return nil + } + return func() tea.Msg { + available, show := a.updateCheck(a.ctx) + return updateCheckMsg{available: available, show: show} + } +} + +func (a *app) tookUpdateCheck(message updateCheckMsg) tea.Cmd { + if message.show { + a.note(message.available.Notice()) + } + return nil +} + +func (a *app) runUpdateCommand(argument string) tea.Cmd { + if a.state == stateWorking || len(a.runningNodes()) != 0 { + a.note("finish the running turn or task before updating codeaf") + return nil + } + if a.updateActive { + a.note("an update is already running") + return nil + } + if a.resolveUpdate == nil || a.installUpdate == nil || a.restart == nil { + a.note("this window cannot update codeaf here · install a release with: " + codeupdate.CurlCommand) + return nil + } + if codeupdate.Kind(a.updateRunning) == "other" { + a.note("this codeaf was built from source · rebuild with make build, or install a release: " + codeupdate.CurlCommand) + return nil + } + choice := updateChoice(argument) + resolve := a.resolveUpdate + // The mark is set before the command leaves the loop. A second /update or a + // new turn can therefore never enter while release selection is off-frame. + a.updateActive = true + return func() tea.Msg { + release, err := resolve(context.Background(), choice) + return updateResolveMsg{release: release, err: err} + } +} + +func updateChoice(argument string) codeupdate.Choice { + argument = strings.TrimSpace(argument) + switch argument { + case "", "stable": + return codeupdate.Choice{Channel: "stable"} + case "rc", "dev", "staging": + return codeupdate.Choice{Channel: argument} + default: + return codeupdate.Choice{Version: argument} + } +} + +func (a *app) tookUpdateResolve(message updateResolveMsg) tea.Cmd { + if message.err != nil { + a.updateFailed(message.err) + return nil + } + if message.release.Tag == a.updateRunning { + a.updateActive = false + a.note("you are on the newest codeaf, " + message.release.Tag) + return nil + } + a.note("downloading codeaf " + message.release.Tag + " for " + runtimePlatform() + "…") + install := a.installUpdate + return func() tea.Msg { + result, err := install(context.Background(), message.release) + return updateInstallMsg{result: result, err: err} + } +} + +func runtimePlatform() string { + return runtimeGOOS + "/" + runtimeGOARCH +} + +func (a *app) tookUpdateInstall(message updateInstallMsg) tea.Cmd { + if message.err != nil { + a.updateFailed(message.err) + return nil + } + a.note("checksum matched · installed at " + message.result.Path) + a.note("restarting on " + message.result.Release.Tag + "…") + *a.restart = codeupdate.Plan{ + Path: message.result.Path, + Args: codeupdate.RestartArgs(a.updateArgs, a.file), + } + // The final two notes get one frame of their own before the program quits. + // Quitting in this same update would restore the terminal before Bubble Tea + // had painted the checksum and restart lines at all. + return surfaceTick(frameInterval, func(time.Time) tea.Msg { return updateRestartMsg{} }) +} + +func (a *app) updateFailed(err error) { + a.updateActive = false + a.note("could not update codeaf: " + err.Error()) + a.note("install a release with: " + codeupdate.CurlCommand) +} diff --git a/internal/tui3/updatecmd_test.go b/internal/tui3/updatecmd_test.go new file mode 100644 index 0000000000..9aedd63948 --- /dev/null +++ b/internal/tui3/updatecmd_test.go @@ -0,0 +1,217 @@ +package tui3 + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" + codeupdate "github.com/Agent-Field/codeaf/internal/update" +) + +func updateNotes(a *app) string { + var lines []string + for _, entry := range a.entries { + if entry.kind == entryNote { + lines = append(lines, entry.text) + } + } + return strings.Join(lines, "\n") +} + +// TestC1LaunchAvailabilityBecomesOneTranscriptNote proves C1. +func TestC1LaunchAvailabilityBecomesOneTranscriptNote(t *testing.T) { + a := newTestApp(&fakeAgent{model: "test/model"}) + available := codeupdate.Available{Latest: "v0.2.0", Running: "v0.1.1"} + drive(t, a, updateCheckMsg{available: available, show: true}) + line := "codeaf v0.2.0 is out · you have v0.1.1 · /update installs it and restarts · or: " + codeupdate.CurlCommand + if got := strings.Count(updateNotes(a), line); got != 1 { + t.Fatalf("launch note count = %d, want 1:\n%s", got, updateNotes(a)) + } +} + +// TestOneUpdateOwnsTheSurfaceUntilItFinishes proves that a second installer +// and a home-page turn cannot begin while release selection is off-frame. +func TestOneUpdateOwnsTheSurfaceUntilItFinishes(t *testing.T) { + agent := &fakeAgent{model: "test/model"} + a := newApp(context.Background(), Options{ + Agent: agent, Workspace: "/tmp/lab", UpdateRunning: "v0.1.1", Restart: &codeupdate.Plan{}, + ResolveUpdate: func(context.Context, codeupdate.Choice) (codeupdate.Release, error) { + return codeupdate.Release{Tag: "v0.2.0"}, nil + }, + InstallUpdate: func(context.Context, codeupdate.Release) (codeupdate.InstallResult, error) { + return codeupdate.InstallResult{}, nil + }, + }) + first := a.slash("/update") + if first == nil || !a.updateActive { + t.Fatalf("first command = %v, active = %t", first, a.updateActive) + } + if second := a.slash("/upgrade"); second != nil { + t.Fatal("the overlapping update returned a command") + } + started := 0 + a.start = func(string) (Conversation, error) { + started++ + return Conversation{Agent: &fakeAgent{model: "test/model"}, SessionFile: "/tmp/next/transcript.jsonl"}, nil + } + a.openHome() + a.home.box.setText("start another turn") + a.home.build() + drive(t, a, key("enter")) + if started != 0 { + t.Fatalf("home enter started %d conversations while the update was running", started) + } + if len(agent.sent) != 0 || a.home.box.String() != "start another turn" { + t.Fatalf("sent = %q home draft = %q", agent.sent, a.home.box.String()) + } + for _, want := range []string{"an update is already running", "finish the update before starting a turn"} { + if strings.Count(updateNotes(a), want) != 1 { + t.Fatalf("notes do not contain %q once:\n%s", want, updateNotes(a)) + } + } +} + +// TestAnUpdateOwnsHomesAskHereRoad proves that the home-page chord cannot +// start an errand beneath the restart that an update is preparing. +func TestAnUpdateOwnsHomesAskHereRoad(t *testing.T) { + called := 0 + a := newTestApp(&fakeAgent{model: "test/model"}) + a.updateActive = true + a.standingRoot = t.TempDir() + a.errand = func(ErrandOrders) (Agent, error) { + called++ + return &errandAgent{fakeAgent: fakeAgent{model: "test/model"}}, nil + } + a.openHome() + a.home.box.setText("ask from home") + a.home.build() + drive(t, a, key("alt+enter")) + if !a.composerShowing() { + t.Fatal("the first ask-here chord did not open its composer") + } + drive(t, a, key("alt+enter")) + + if called != 0 || len(a.exchanges) != 0 { + t.Fatalf("errand calls = %d exchanges = %d", called, len(a.exchanges)) + } + if got := strings.Count(updateNotes(a), "finish the update before starting a turn"); got != 1 { + t.Fatalf("update refusal count = %d, want 1:\n%s", got, updateNotes(a)) + } +} + +// TestUpdateCheckCommandDefersTheReleaseDoor proves the command itself does no +// work until Bubble Tea runs it. The shared door test proves the first frame. +func TestUpdateCheckCommandDefersTheReleaseDoor(t *testing.T) { + called := false + a := newTestApp(&fakeAgent{model: "test/model"}) + a.updateCheck = func(context.Context) (codeupdate.Available, bool) { + called = true + return codeupdate.Available{}, false + } + command := a.checkForUpdate() + if called || command == nil { + t.Fatalf("building the first frame called = %t, command nil = %t", called, command == nil) + } + _ = command() + if !called { + t.Fatal("the command did not make the deferred check") + } +} + +// TestC6UpdateOnTheNewestBuildDoesNotInstallOrQuit proves C6. +func TestC6UpdateOnTheNewestBuildDoesNotInstallOrQuit(t *testing.T) { + installed := false + restart := &codeupdate.Plan{} + a := newApp(context.Background(), Options{ + Agent: &fakeAgent{model: "test/model"}, Workspace: "/tmp/lab", + UpdateRunning: "v0.2.0", Restart: restart, + ResolveUpdate: func(context.Context, codeupdate.Choice) (codeupdate.Release, error) { + return codeupdate.Release{Tag: "v0.2.0"}, nil + }, + InstallUpdate: func(context.Context, codeupdate.Release) (codeupdate.InstallResult, error) { + installed = true + return codeupdate.InstallResult{}, nil + }, + }) + command := a.slash("/update") + drive(t, a, command()) + if installed || a.updateActive || restart.Path != "" || !strings.Contains(updateNotes(a), "you are on the newest codeaf, v0.2.0") { + t.Fatalf("installed = %t active = %t restart = %+v notes:\n%s", installed, a.updateActive, restart, updateNotes(a)) + } +} + +// TestC7UpdateInstallReturnsTheRestartPlanForThisTranscript proves C7. +func TestC7UpdateInstallReturnsTheRestartPlanForThisTranscript(t *testing.T) { + restart := &codeupdate.Plan{} + a := newApp(context.Background(), Options{ + Agent: &fakeAgent{model: "test/model"}, Workspace: "/tmp/lab", SessionFile: "/tmp/this.jsonl", + UpdateRunning: "v0.1.1", UpdateArgs: []string{"chat", "--model", "x"}, Restart: restart, + ResolveUpdate: func(context.Context, codeupdate.Choice) (codeupdate.Release, error) { + return codeupdate.Release{Tag: "v0.2.0"}, nil + }, + InstallUpdate: func(context.Context, codeupdate.Release) (codeupdate.InstallResult, error) { + return codeupdate.InstallResult{Release: codeupdate.Release{Tag: "v0.2.0"}, Path: "/tmp/codeaf"}, nil + }, + }) + command := a.slash("/update") + drive(t, a, command()) + if restart.Path != "/tmp/codeaf" || strings.Join(restart.Args, " ") != "chat --model x --session /tmp/this.jsonl" { + t.Fatalf("restart = %+v", restart) + } + for _, want := range []string{"downloading codeaf v0.2.0", "checksum matched · installed at /tmp/codeaf", "restarting on v0.2.0"} { + if !strings.Contains(updateNotes(a), want) { + t.Fatalf("notes do not contain %q:\n%s", want, updateNotes(a)) + } + } +} + +// TestC7UpdateFailureNamesTheCauseAndLeavesNoRestart proves C7. +func TestC7UpdateFailureNamesTheCauseAndLeavesNoRestart(t *testing.T) { + restart := &codeupdate.Plan{} + a := newApp(context.Background(), Options{ + Agent: &fakeAgent{model: "test/model"}, Workspace: "/tmp/lab", UpdateRunning: "v0.1.1", Restart: restart, + ResolveUpdate: func(context.Context, codeupdate.Choice) (codeupdate.Release, error) { + return codeupdate.Release{Tag: "v0.2.0"}, nil + }, + InstallUpdate: func(context.Context, codeupdate.Release) (codeupdate.InstallResult, error) { + return codeupdate.InstallResult{}, errors.New("the checksum did not match") + }, + }) + drive(t, a, a.slash("/update")()) + if a.updateActive || restart.Path != "" || !strings.Contains(updateNotes(a), "the checksum did not match") || !strings.Contains(updateNotes(a), codeupdate.CurlCommand) { + t.Fatalf("active = %t restart = %+v notes:\n%s", a.updateActive, restart, updateNotes(a)) + } +} + +// TestC8UpdateRefusesWhileATurnOrTaskIsRunning proves C8. +func TestC8UpdateRefusesWhileATurnOrTaskIsRunning(t *testing.T) { + for _, kind := range []string{"turn", "task"} { + t.Run(kind, func(t *testing.T) { + resolved := false + a := newTestApp(&fakeAgent{model: "test/model"}) + a.updateRunning = "v0.1.1" + a.restart = &codeupdate.Plan{} + a.resolveUpdate = func(context.Context, codeupdate.Choice) (codeupdate.Release, error) { + resolved = true + return codeupdate.Release{}, nil + } + a.installUpdate = func(context.Context, codeupdate.Release) (codeupdate.InstallResult, error) { + return codeupdate.InstallResult{}, nil + } + if kind == "turn" { + a.state = stateWorking + } else { + a.tasks = map[uint64]*taskNode{1: {id: 1, state: session.TaskRunning}} + a.taskOrder = []uint64{1} + } + if command := a.slash("/update"); command != nil { + t.Fatal("the refusal returned a command") + } + if resolved || strings.Count(updateNotes(a), "finish the running turn or task before updating codeaf") != 1 { + t.Fatalf("resolved = %t notes:\n%s", resolved, updateNotes(a)) + } + }) + } +} diff --git a/internal/tui3/updateplatform.go b/internal/tui3/updateplatform.go new file mode 100644 index 0000000000..e8bc8347d8 --- /dev/null +++ b/internal/tui3/updateplatform.go @@ -0,0 +1,8 @@ +package tui3 + +import "runtime" + +var ( + runtimeGOOS = runtime.GOOS + runtimeGOARCH = runtime.GOARCH +) From 75ee2d667b154fc43001e025d2916e4d6d809e07 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 15 Sep 2026 19:25:06 -0400 Subject: [PATCH 04/11] codeaf update, and the door that restarts the same conversation codeaf update --check prints one line and exits 0 when this release is newest, 3 when a newer one exists, 1 when it could not ask. Without --check it installs stable, or whatever --rc, --dev, --staging or --version names, choosing a channel build by its own publish time the way the installer does. A binary built from source refuses in place and names its path rather than replacing something a developer is holding. The restart happens here and not inside Bubble Tea: the surface fills a slot, the door reads it once the frame is down and the terminal is back, and then execs the new binary with this launch's own arguments plus --session pointing at this transcript, so the conversation reopens where it was. A linked-local engine is asked to retire only after the surface has detached, and it still refuses while another window or any live work is there. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/codeaf/chatv3.go | 59 ++++++- cmd/codeaf/chatv3_at.go | 5 + cmd/codeaf/chatv3_host.go | 5 + cmd/codeaf/chatv3_local.go | 5 + cmd/codeaf/chatv3_surface.go | 42 ++++- cmd/codeaf/chatv3_surface_test.go | 147 ++++++++++++++++++ cmd/codeaf/main.go | 11 +- cmd/codeaf/update.go | 122 +++++++++++++++ cmd/codeaf/update_test.go | 250 ++++++++++++++++++++++++++++++ internal/config/settings.go | 8 + 10 files changed, 647 insertions(+), 7 deletions(-) create mode 100644 cmd/codeaf/update.go create mode 100644 cmd/codeaf/update_test.go diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index e2e3c6be2b..7e91f73e4c 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -18,6 +18,7 @@ import ( "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/connect" "github.com/Agent-Field/codeaf/internal/effort" + "github.com/Agent-Field/codeaf/internal/enginehost" "github.com/Agent-Field/codeaf/internal/env" "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/home" @@ -30,6 +31,7 @@ import ( "github.com/Agent-Field/codeaf/internal/subharness" "github.com/Agent-Field/codeaf/internal/trace" "github.com/Agent-Field/codeaf/internal/tui3" + codeupdate "github.com/Agent-Field/codeaf/internal/update" ) // runChatV3 is the v3 door: one session agent over this directory, and the @@ -68,6 +70,7 @@ func v3OpenRouterConnection(settings config.Config, interactive bool) func(conte } func openChatV3(name string, args []string, pickSession bool) error { + restart := &codeupdate.Plan{} flags := commandFlags(name) model := flags.String("model", "", "model slug for this session; beats the configured default") once := flags.String("once", "", "run one message non-interactively, print the reply, and exit") @@ -184,7 +187,7 @@ func openChatV3(name string, args []string, pickSession bool) error { // the harnesses are all the far machine's, and reading this one's would be // resolving a launch nobody asked for (chatv3_host.go). if dest := strings.TrimSpace(*host); dest != "" { - return openChatV3Host(hostLaunch{ + err := openChatV3Host(hostLaunch{ target: dest, session: strings.TrimSpace(*file), model: strings.TrimSpace(*model), @@ -194,7 +197,9 @@ func openChatV3(name string, args []string, pickSession bool) error { noCompact: *noCompact, yolo: *yolo, budget: chatBudget(*maxHours, *maxCost).Set(), + restart: restart, }) + return finishChatRestart(err, restart, "") } // THE THIRD DOOR, and it forks here for the reason --host does: a machine @@ -203,7 +208,7 @@ func openChatV3(name string, args []string, pickSession bool) error { // a relay tunnel here — which is the whole point of the transport being an // io.ReadWriteCloser and nothing more (chatv3_at.go, docs/REMOTE.md). if name := strings.TrimSpace(*at); name != "" { - return openChatV3At(atLaunch{ + err := openChatV3At(atLaunch{ target: name, session: strings.TrimSpace(*file), model: strings.TrimSpace(*model), @@ -213,7 +218,9 @@ func openChatV3(name string, args []string, pickSession bool) error { noCompact: *noCompact, yolo: *yolo, budget: chatBudget(*maxHours, *maxCost).Set(), + restart: restart, }) + return finishChatRestart(err, restart, "") } // The fourth door, and the only one with no machine in it: this workspace's @@ -255,6 +262,7 @@ func openChatV3(name string, args []string, pickSession bool) error { once: strings.TrimSpace(*once), pick: pickSession, shape: v3LaunchShape(*yolo, *noCompact, *oneModel, *maxHours, *maxCost, strings.TrimSpace(*once) == ""), + restart: restart, }) var taken *hostShapeTaken var unreachable *hostUnreachable @@ -264,7 +272,7 @@ func openChatV3(name string, args []string, pickSession bool) error { case errors.As(err, &unreachable): entryNotice = "this conversation opened in this terminal instead, and ends with it: " + unreachable.reason default: - return err + return finishChatRestart(err, restart, workspace) } } @@ -461,7 +469,7 @@ func openChatV3(name string, args []string, pickSession bool) error { // The byte meter and the logger redirect both belong to the surface rather // than to this door, and [runSurface] (chatv3_surface.go) is where every // door gets them. - return runSurface(ctx, tui3.Options{ + err = runSurface(ctx, tui3.Options{ Agent: agent, Build: buildinfo.String(), // The memory place and the search place read the SAME database the @@ -613,6 +621,7 @@ func openChatV3(name string, args []string, pickSession bool) error { Owned: launch.Place.Owned, SessionFile: transcript, Resumed: resumed, + Restart: restart, // AND THE LAUNCH FACTS A PERSON CAN ACT ON. The unattended boundary and // a replaced codeaf both ride the session-moved line — one dim row at // the top of the conversation — rather than growing surfaces of their @@ -637,6 +646,48 @@ func openChatV3(name string, args []string, pickSession bool) error { SaveBashApproval: bankBashApproval(agent, workspace, settings.ProfileDir, *yolo), ApplyApprovals: applyV3Approvals(agent, workspace, settings.ProfileDir, *yolo), }) + return finishChatRestart(err, restart, "") +} + +// finishChatRestart runs only after the surface door has returned, so every +// connection, session, and terminal cleanup deferred by that door has finished. +func finishChatRestart(err error, restart *codeupdate.Plan, localWorkspace string) error { + if err != nil || restart == nil || restart.Path == "" { + return err + } + // A linked-local engine outlives its surface, so replacing the executable + // does not by itself put the new build behind the conversation. Ask it to + // retire only after the surface has detached; the host itself refuses while + // another view or any work is active, and a refusal never stops that work. + if localWorkspace != "" { + retireUpdateEngine(localWorkspace) + } + return restartUpdatedChat(*restart) +} + +const updateEngineDetachGrace = time.Second +const updateEngineDetachPoll = 25 * time.Millisecond + +var ( + retireChatEngine = enginehost.Retire + restartUpdatedChat = codeupdate.Restart + updateRestartNow = time.Now + updateRestartPause = time.Sleep +) + +// retireUpdateEngine gives the connection close immediately above long enough +// to reach the host before deciding that somebody else really is keeping it. +// It never uses the force door: another window or live work keeps the old host, +// while the ordinary one-window handoff reliably starts the replacement build. +func retireUpdateEngine(workspace string) { + deadline := updateRestartNow().Add(updateEngineDetachGrace) + for { + err := retireChatEngine(workspace, false) + if !errors.Is(err, enginehost.ErrHostBusy) || !updateRestartNow().Before(deadline) { + return + } + updateRestartPause(updateEngineDetachPoll) + } } // ── the shared assembly ───────────────────────────────────────────────────── diff --git a/cmd/codeaf/chatv3_at.go b/cmd/codeaf/chatv3_at.go index 642a95c316..f15d74a523 100644 --- a/cmd/codeaf/chatv3_at.go +++ b/cmd/codeaf/chatv3_at.go @@ -49,6 +49,7 @@ import ( "github.com/Agent-Field/codeaf/internal/pair" "github.com/Agent-Field/codeaf/internal/remote" + codeupdate "github.com/Agent-Field/codeaf/internal/update" ) // ── the surface: codeaf chat --at ──────────────────────────────────── @@ -78,6 +79,9 @@ type atLaunch struct { // the session is on the far machine. A ceiling accepted here would bound // nothing at all. budget bool + // restart is filled by the surface and read after this door has closed the + // relay tunnel and all conversation connections. + restart *codeupdate.Plan } // check refuses the flags this door cannot honour, in the same words the ssh @@ -223,6 +227,7 @@ func openChatV3At(launch atLaunch) error { // one is resolved here; the only thing that differs between the two doors is // the pipe, so anything that differed in the surface would be a bug. options, _ := hostOptions(fleet, client.Welcome(), launch.pick) + options.Restart = launch.restart return runSurface(context.Background(), options) } diff --git a/cmd/codeaf/chatv3_host.go b/cmd/codeaf/chatv3_host.go index 6d3fe33c4b..d08ef3c490 100644 --- a/cmd/codeaf/chatv3_host.go +++ b/cmd/codeaf/chatv3_host.go @@ -26,6 +26,7 @@ import ( "github.com/Agent-Field/codeaf/internal/standing" "github.com/Agent-Field/codeaf/internal/store" "github.com/Agent-Field/codeaf/internal/tui3" + codeupdate "github.com/Agent-Field/codeaf/internal/update" ) // ── `codeaf chat --host devbox` ───────────────────────────────────────────── @@ -80,6 +81,9 @@ type hostLaunch struct { // (internal/session's principal.go), and the session is on the far machine. // A ceiling accepted here would bound nothing at all. budget bool + // restart is filled by the surface and read after this door has closed the + // ssh child and all conversation connections. + restart *codeupdate.Plan } // check refuses the flags this door cannot honour. @@ -517,6 +521,7 @@ func openChatV3Host(launch hostLaunch) error { }) defer fleet.closeAll() options, _ := hostOptions(fleet, welcome, launch.pick) + options.Restart = launch.restart // THE SAME WAY THE SURFACE IS RUN AT EVERY OTHER DOOR (chatv3_surface.go): // the byte meter and the logger redirect are the terminal's business rather // than this connection's, and a door does not state them for itself. diff --git a/cmd/codeaf/chatv3_local.go b/cmd/codeaf/chatv3_local.go index db3bad765e..443ea71b41 100644 --- a/cmd/codeaf/chatv3_local.go +++ b/cmd/codeaf/chatv3_local.go @@ -33,6 +33,7 @@ import ( "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/subharness" "github.com/Agent-Field/codeaf/internal/tui3" + codeupdate "github.com/Agent-Field/codeaf/internal/update" ) // localLaunch is one launch that goes through this machine's own session host. @@ -62,6 +63,9 @@ type localLaunch struct { // that conversation's shape back on the welcome and is refused here // ([hostShapeTaken]). shape *remote.LaunchShape + // restart is filled by the surface and read only after this door's + // connection cleanup has run. + restart *codeupdate.Plan } // localLink is the dialer for a conversation on this machine. @@ -272,6 +276,7 @@ func openChatV3Local(launch localLaunch) error { // and the surface lands on home with that row under the cursor // (internal/tui3's takeover.go). options.TakeOver = takeOver + options.Restart = launch.restart // AND WHAT THE DIAL FOUND ON THE WAY IN, on the same line the engine's own // welcome speaks (chatv3_host.go's [hostEntryNotice]). It is joined here // rather than inside that function because it is a fact about THIS ROAD's diff --git a/cmd/codeaf/chatv3_surface.go b/cmd/codeaf/chatv3_surface.go index 2499717050..0d7c620fc7 100644 --- a/cmd/codeaf/chatv3_surface.go +++ b/cmd/codeaf/chatv3_surface.go @@ -4,8 +4,22 @@ import ( "context" "log" "os" + "time" + "github.com/Agent-Field/codeaf/internal/buildinfo" + internalenv "github.com/Agent-Field/codeaf/internal/env" "github.com/Agent-Field/codeaf/internal/tui3" + codeupdate "github.com/Agent-Field/codeaf/internal/update" +) + +var ( + runSurfaceProgram = func(ctx context.Context, options tui3.Options) error { + return tui3.Run(ctx, options) + } + surfaceUpdateClient = codeupdate.NewClient + surfaceExecutable = codeupdate.ExecutableTarget + surfaceRevision = buildinfo.Revision + surfaceArguments = func() []string { return append([]string(nil), os.Args[1:]...) } ) // ── THE ONE WAY THE v3 SURFACE IS RUN ─────────────────────────────────────── @@ -29,6 +43,32 @@ import ( // it would lack is everything below, which is why [TestOnlyTheSurfaceHelperRunsTheV3Surface] // reads this package's sources rather than trusting the next door to remember. func runSurface(ctx context.Context, options tui3.Options) error { + revision := surfaceRevision() + client := surfaceUpdateClient(revision, 3*time.Second) + restart := options.Restart + if restart == nil { + restart = &codeupdate.Plan{} + options.Restart = restart + } + options.UpdateRunning = revision + options.UpdateArgs = surfaceArguments() + options.UpdateCheck = func(check context.Context) (codeupdate.Available, bool) { + return codeupdate.CheckLaunch(check, codeupdate.CheckOptions{ + Running: revision, ProfileDir: options.ProfileDir, Client: client, + Disabled: internalenv.Get(codeupdate.NoUpdateCheckEnv) == "1", + }) + } + options.ResolveUpdate = client.Select + options.InstallUpdate = func(install context.Context, release codeupdate.Release) (codeupdate.InstallResult, error) { + // Resolution belongs to the off-frame installation command. A launch may + // live on a slow mount, but its first frame never has to wait for that + // mount merely because /update exists. + target, err := surfaceExecutable(os.Executable) + if err != nil { + return codeupdate.InstallResult{}, err + } + return codeupdate.Install(install, codeupdate.InstallOptions{Client: client, Release: release, Target: target}) + } // The byte meter, off unless a developer named a log file (wire.go). It // measures what this surface DRAWS and is therefore as local as the terminal // is, so it is armed here and never at a door: with CODEAF_WIRE_LOG unset @@ -38,7 +78,7 @@ func runSurface(ctx context.Context, options tui3.Options) error { defer closeWire() options.Output = wire return withSurfaceLogger(options.ProfileDir, func() error { - return tui3.Run(ctx, options) + return runSurfaceProgram(ctx, options) }) } diff --git a/cmd/codeaf/chatv3_surface_test.go b/cmd/codeaf/chatv3_surface_test.go index 10e057a1e5..aa7a2ca7d8 100644 --- a/cmd/codeaf/chatv3_surface_test.go +++ b/cmd/codeaf/chatv3_surface_test.go @@ -2,15 +2,162 @@ package main import ( "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "io" "log" + "net/http" + "net/http/httptest" "os" "path/filepath" + "runtime" "strings" + "sync" "testing" + "time" "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/tui3" + codeupdate "github.com/Agent-Field/codeaf/internal/update" ) +type surfaceFrameWriter struct { + mu sync.Mutex + bytes.Buffer + ready chan struct{} + once sync.Once +} + +func (writer *surfaceFrameWriter) Write(raw []byte) (int, error) { + writer.mu.Lock() + written, err := writer.Buffer.Write(raw) + if strings.Contains(writer.Buffer.String(), "codeaf") { + writer.once.Do(func() { close(writer.ready) }) + } + writer.mu.Unlock() + return written, err +} + +func (writer *surfaceFrameWriter) String() string { + writer.mu.Lock() + defer writer.mu.Unlock() + return writer.Buffer.String() +} + +// TestRunSurfaceWiresTheDeferredLaunchCheckAndInstallerThroughRealInit proves +// C5, C7, and the live capture half of C14 at the shared surface door. +func TestRunSurfaceWiresTheDeferredLaunchCheckAndInstallerThroughRealInit(t *testing.T) { + asset := []byte("new executable") + digest := sha256.Sum256(asset) + requestStarted := make(chan struct{}) + releaseResponse := make(chan struct{}) + var requestOnce sync.Once + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + switch { + case strings.HasSuffix(request.URL.Path, "/releases/latest"): + requestOnce.Do(func() { close(requestStarted) }) + select { + case <-releaseResponse: + fmt.Fprint(w, `{"tag_name":"v0.2.0"}`) + case <-request.Context().Done(): + } + case strings.HasSuffix(request.URL.Path, "/checksums.txt"): + fmt.Fprintf(w, "%x codeaf-%s-%s\n", digest, runtime.GOOS, runtime.GOARCH) + default: + _, _ = w.Write(asset) + } + })) + defer server.Close() + + target := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(target, []byte("old executable"), 0o755); err != nil { + t.Fatal(err) + } + oldRun := runSurfaceProgram + oldClient, oldExecutable := surfaceUpdateClient, surfaceExecutable + oldRevision, oldArguments := surfaceRevision, surfaceArguments + t.Cleanup(func() { + runSurfaceProgram = oldRun + surfaceUpdateClient, surfaceExecutable = oldClient, oldExecutable + surfaceRevision, surfaceArguments = oldRevision, oldArguments + }) + frame := &surfaceFrameWriter{ready: make(chan struct{})} + input, inputWriter := io.Pipe() + defer inputWriter.Close() + optionsSeen := make(chan tui3.Options, 1) + runSurfaceProgram = func(ctx context.Context, options tui3.Options) error { + optionsSeen <- options + options.Input = input + options.Output = frame + options.Width, options.Height = 80, 24 + return tui3.Run(ctx, options) + } + surfaceUpdateClient = func(revision string, timeout time.Duration) *codeupdate.Client { + if revision != "v0.1.1" || timeout != 3*time.Second { + t.Fatalf("client revision %q timeout %s", revision, timeout) + } + return &codeupdate.Client{HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL, Revision: revision} + } + resolved := 0 + surfaceExecutable = func(executable func() (string, error)) (string, error) { + resolved++ + return target, nil + } + surfaceRevision = func() string { return "v0.1.1" } + surfaceArguments = func() []string { return []string{"chat", "--model", "x"} } + profile := t.TempDir() + + ctx, cancel := context.WithCancel(context.Background()) + finished := make(chan error, 1) + go func() { + finished <- runSurface(ctx, tui3.Options{ + Agent: &quietAgent{}, Workspace: "/tmp/lab", ProfileDir: profile, SessionFile: "/tmp/this.jsonl", + }) + }() + seen := <-optionsSeen + if seen.UpdateCheck == nil || seen.ResolveUpdate == nil || seen.InstallUpdate == nil { + cancel() + t.Fatal("runSurface did not wire every update door") + } + restartArgs := codeupdate.RestartArgs(seen.UpdateArgs, seen.SessionFile) + if strings.Join(restartArgs, " ") != "chat --model x --session /tmp/this.jsonl" || seen.UpdateRunning != "v0.1.1" { + cancel() + t.Fatalf("running %q restart args %q", seen.UpdateRunning, restartArgs) + } + select { + case <-requestStarted: + case <-time.After(5 * time.Second): + cancel() + t.Fatal("Init never started the launch check") + } + select { + case <-frame.ready: + case <-time.After(5 * time.Second): + cancel() + t.Fatal("the first frame did not render while the release request waited") + } + if resolved != 0 || !strings.Contains(frame.String(), "codeaf") { + cancel() + t.Fatalf("executable resolutions = %d; first frame:\n%s", resolved, frame.String()) + } + close(releaseResponse) + cancel() + if err := <-finished; err != nil && !errors.Is(err, context.Canceled) { + t.Fatalf("surface exit: %v", err) + } + + result, err := seen.InstallUpdate(context.Background(), codeupdate.Release{Tag: "v0.2.0", Repository: "Agent-Field/codeaf"}) + if err != nil { + t.Fatal(err) + } + installed, err := os.ReadFile(target) + if err != nil || string(installed) != string(asset) || result.Path != target || resolved != 1 { + t.Fatalf("result = %+v resolved = %d installed = %q, %v", result, resolved, installed, err) + } +} + // A SURFACE THAT OWNS THE TERMINAL OWNS THE LOGGER, AND IT OWNS IT FROM ONE // PLACE. // diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go index 9e03dd5229..a71178f97c 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -32,6 +32,7 @@ import ( "github.com/Agent-Field/codeaf/internal/plan" "github.com/Agent-Field/codeaf/internal/router" "github.com/Agent-Field/codeaf/internal/trace" + codeupdate "github.com/Agent-Field/codeaf/internal/update" ) func main() { @@ -134,6 +135,7 @@ func execute() (code int) { } func run() error { + codeupdate.CleanupOldRunning(os.Executable) if len(os.Args) < 2 { // No arguments opens the chat surface, and that surface is v3. The v2 // surface and its --v2 door (flag and environment pin both) were removed @@ -238,6 +240,8 @@ func run() error { // `codeaf --version`, and a person types `-v`. case "version", "--version", "-v": return runVersion() + case "update": + return runUpdate(os.Args[2:]) case "-h", "--help", "help": return usage(os.Args[2:]) default: @@ -360,8 +364,9 @@ Look at what happened — read-only, no key, nothing spent that page printed whole, or the sections that answer a question codeaf version print the build this binary was cut from (--version and -v say the same) - Housekeeping — changes state on disk or on the network + codeaf update [--check] [--stable|--rc|--dev|--staging] [--version tag] + check for or install a release; stable is the default codeaf cache what the shared build cache holds, and how big it is codeaf cache clean [--yes] @@ -401,7 +406,6 @@ Plan work by hand — a plan you can read, edit and diff [--yes-spend] [--model slug] [--plan-model slug] a plan written to a file, then executed exactly as written. It is not what most people want: nothing learnt mid-flight moves a frozen plan - Examples: codeaf open the conversation you were having codeaf do "add a health endpoint and a test for it" @@ -494,6 +498,9 @@ than fighting your shell. CODEAF_HOME the whole state root — journal, workspace, CAS, craft, profiles, catalog, skills (default ~/.codeaf). Move it to run a disposable store that touches nothing of yours. + CODEAF_NO_UPDATE_CHECK + 1 skips the launch check; /update and codeaf update + still work. CODEAF_PROFILE_DIR where measured behaviour is kept (default CODEAF_HOME) CODEAF_CALL_LOG the model-call log (default /logs/calls.jsonl). "off" writes nothing; any other value is the file to diff --git a/cmd/codeaf/update.go b/cmd/codeaf/update.go new file mode 100644 index 0000000000..f53f787079 --- /dev/null +++ b/cmd/codeaf/update.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/buildinfo" + codeupdate "github.com/Agent-Field/codeaf/internal/update" +) + +var ( + updateOut io.Writer = os.Stdout + updateErr io.Writer = os.Stderr + updateExecutable = os.Executable + updateClient = codeupdate.NewClient + updateVersionLine = runInstalledVersion + updateRevision = buildinfo.Revision +) + +func runUpdate(args []string) error { + flags := commandFlags("update") + check := flags.Bool("check", false, "check without installing") + stable := flags.Bool("stable", false, "use the newest stable release") + rc := flags.Bool("rc", false, "use the newest release candidate") + dev := flags.Bool("dev", false, "use the newest dev build") + staging := flags.Bool("staging", false, "use the newest staging build") + version := flags.String("version", "", "install one exact release tag") + if err := parseCommandFlags(flags, args); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("usage: codeaf update [--check] [--stable|--rc|--dev|--staging] [--version tag]") + } + chosen := 0 + for _, set := range []bool{*stable, *rc, *dev, *staging, strings.TrimSpace(*version) != ""} { + if set { + chosen++ + } + } + if chosen > 1 { + return fmt.Errorf("choose one of --stable, --rc, --dev, --staging, or --version") + } + channel := "stable" + switch { + case *rc: + channel = "rc" + case *dev: + channel = "dev" + case *staging: + channel = "staging" + } + choice := codeupdate.Choice{Channel: channel, Version: strings.TrimSpace(*version)} + running := updateRevision() + var target string + if !*check { + var err error + target, err = codeupdate.ExecutableTarget(updateExecutable) + if err != nil { + return err + } + if codeupdate.Kind(running) == "other" { + shown := strings.TrimSpace(running) + if shown == "" { + shown = "no revision" + } + fmt.Fprintf(updateErr, "this codeaf was built from source (%s) at %s — rebuild with make build, or install a release: %s\n", shown, target, codeupdate.CurlCommand) + return exitStatus(2) + } + } + client := updateClient(running, 3*time.Second) + release, err := client.Select(context.Background(), choice) + if err != nil { + fmt.Fprintln(updateErr, "codeaf: could not check for an update:", err) + return exitStatus(1) + } + if *check { + return sayUpdateCheck(running, release.Tag) + } + + result, err := codeupdate.Install(context.Background(), codeupdate.InstallOptions{ + Client: client, Release: release, Target: target, + }) + if err != nil { + return err + } + fmt.Fprintf(updateOut, "codeaf: installed %s at %s\n", result.Release.Tag, result.Path) + if err := updateVersionLine(result.Path, updateOut, updateErr); err != nil { + return fmt.Errorf("run the installed codeaf: %w", err) + } + return nil +} + +func sayUpdateCheck(running, latest string) error { + answer := codeupdate.Available{Latest: latest, Running: running} + switch { + case answer.Newer(): + fmt.Fprintf(updateOut, "codeaf %s is available · you have %s\n", latest, running) + return exitStatus(3) + case codeupdate.Kind(running) == "stable" || codeupdate.Kind(running) == "rc": + fmt.Fprintf(updateOut, "you are on the newest codeaf, %s\n", running) + return nil + default: + shown := strings.TrimSpace(running) + if shown == "" { + shown = "an unstamped source build" + } + fmt.Fprintf(updateOut, "the newest stable codeaf is %s · this codeaf is %s\n", latest, shown) + return nil + } +} + +func runInstalledVersion(path string, stdout, stderr io.Writer) error { + command := exec.Command(path, "version") + command.Stdout = stdout + command.Stderr = stderr + return command.Run() +} diff --git a/cmd/codeaf/update_test.go b/cmd/codeaf/update_test.go new file mode 100644 index 0000000000..2c3831e7b5 --- /dev/null +++ b/cmd/codeaf/update_test.go @@ -0,0 +1,250 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/enginehost" + codeupdate "github.com/Agent-Field/codeaf/internal/update" +) + +func withUpdateDoor(t *testing.T, revision string, client *codeupdate.Client, target string) (*bytes.Buffer, *bytes.Buffer) { + t.Helper() + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + oldOut, oldErr := updateOut, updateErr + oldExecutable, oldClient := updateExecutable, updateClient + oldVersion, oldRevision := updateVersionLine, updateRevision + updateOut, updateErr = stdout, stderr + updateExecutable = func() (string, error) { return target, nil } + updateClient = func(string, time.Duration) *codeupdate.Client { return client } + updateVersionLine = func(string, io.Writer, io.Writer) error { return nil } + updateRevision = func() string { return revision } + t.Cleanup(func() { + updateOut, updateErr = oldOut, oldErr + updateExecutable, updateClient = oldExecutable, oldClient + updateVersionLine, updateRevision = oldVersion, oldRevision + }) + return stdout, stderr +} + +func updateExit(err error) int { + if err == nil { + return 0 + } + var status exitStatus + if errors.As(err, &status) { + return int(status) + } + return 1 +} + +// TestC9UpdateCheckUsesTheRealDoorAndItsThreeExitCodes proves C9. +func TestC9UpdateCheckUsesTheRealDoorAndItsThreeExitCodes(t *testing.T) { + for _, row := range []struct { + name string + revision string + status int + body string + want int + }{ + {"newer", "v0.1.1", http.StatusOK, `{"tag_name":"v0.2.0"}`, 3}, + {"newest", "v0.2.0", http.StatusOK, `{"tag_name":"v0.2.0"}`, 0}, + {"failure", "v0.1.1", http.StatusInternalServerError, "no", 1}, + } { + t.Run(row.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(row.status) + fmt.Fprint(w, row.body) + })) + defer server.Close() + client := &codeupdate.Client{HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL} + stdout, stderr := withUpdateDoor(t, row.revision, client, filepath.Join(t.TempDir(), "codeaf")) + if got := updateExit(runUpdate([]string{"--check"})); got != row.want { + t.Fatalf("exit = %d, want %d; stdout %q stderr %q", got, row.want, stdout.String(), stderr.String()) + } + }) + } +} + +// TestC9SourceBuildRefusesBeforeTheNetwork proves C9. +func TestC9SourceBuildRefusesBeforeTheNetwork(t *testing.T) { + target := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(target, []byte("source"), 0o755); err != nil { + t.Fatal(err) + } + client := &codeupdate.Client{HTTP: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("source install reached the network") + return nil, nil + })}} + _, stderr := withUpdateDoor(t, "deadbeef", client, target) + if code := updateExit(runUpdate(nil)); code != 2 { + t.Fatalf("exit = %d, want 2", code) + } + for _, want := range []string{"this codeaf was built from source (deadbeef) at " + target, "rebuild with make build", codeupdate.CurlCommand} { + if !strings.Contains(stderr.String(), want) { + t.Fatalf("stderr does not contain %q: %q", want, stderr.String()) + } + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } + +// TestC9ExactVersionAndNewestDevSelectionInstallTheChosenTag proves C9. +func TestC9ExactVersionAndNewestDevSelectionInstallTheChosenTag(t *testing.T) { + for _, row := range []struct { + name string + args []string + tag string + }{ + {"exact", []string{"--version", "v0.1.1-rc.1"}, "v0.1.1-rc.1"}, + {"dev", []string{"--dev"}, "dev-20260915-bbbbbbbbbbbb"}, + } { + t.Run(row.name, func(t *testing.T) { + asset := []byte("installed " + row.tag) + digest := sha256.Sum256(asset) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/releases") && !strings.Contains(r.URL.Path, "/download/"): + fmt.Fprint(w, `[{"tag_name":"dev-20260915-aaaaaaaaaaaa","published_at":"2026-09-15T15:00:00Z"},{"tag_name":"dev-20260915-bbbbbbbbbbbb","published_at":"2026-09-15T16:00:00Z"}]`) + case strings.HasSuffix(r.URL.Path, "/checksums.txt"): + fmt.Fprintf(w, "%x codeaf-%s-%s\n", digest, runtime.GOOS, runtime.GOARCH) + case strings.Contains(r.URL.Path, "/releases/download/"+row.tag+"/"): + _, _ = w.Write(asset) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + target := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + client := &codeupdate.Client{HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL} + stdout, _ := withUpdateDoor(t, "v0.1.0", client, target) + if err := runUpdate(row.args); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(target) + if string(got) != string(asset) || !strings.Contains(stdout.String(), "installed "+row.tag) { + t.Fatalf("target %q stdout %q", got, stdout.String()) + } + }) + } +} + +// TestC9AnInstallEndsWithTheNewBinarysVersionLine proves C9 and D10's +// last-line contract through the command door. +func TestC9AnInstallEndsWithTheNewBinarysVersionLine(t *testing.T) { + asset := []byte("installed release") + digest := sha256.Sum256(asset) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/checksums.txt"): + fmt.Fprintf(w, "%x codeaf-%s-%s\n", digest, runtime.GOOS, runtime.GOARCH) + case strings.Contains(r.URL.Path, "/releases/download/v0.2.0/"): + _, _ = w.Write(asset) + default: + fmt.Fprint(w, `{"tag_name":"v0.2.0"}`) + } + })) + defer server.Close() + target := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + client := &codeupdate.Client{HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL} + stdout, _ := withUpdateDoor(t, "v0.1.0", client, target) + updateVersionLine = func(path string, output, _ io.Writer) error { + if path != target { + t.Fatalf("version path = %q, want %q", path, target) + } + _, err := fmt.Fprintln(output, "codeaf v0.2.0 built 2026-09-15 12:00") + return err + } + if err := runUpdate(nil); err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(stdout.String()), "\n") + if got := lines[len(lines)-1]; got != "codeaf v0.2.0 built 2026-09-15 12:00" { + t.Fatalf("last line = %q; all output:\n%s", got, stdout.String()) + } +} + +// TestC14RestartArgumentsPassTheRealChatFlagParser proves C14. +func TestC14RestartArgumentsPassTheRealChatFlagParser(t *testing.T) { + for _, original := range [][]string{nil, {"chat", "--model", "x"}, {"resume", "--session", "/tmp/this.jsonl"}} { + args := codeupdate.RestartArgs(original, "/tmp/this.jsonl") + name := args[0] + if name != "chat" && name != "resume" { + t.Fatalf("restart command = %q", name) + } + // An invalid reasoning value stops the real door immediately after its + // private flag set has parsed, before it can open a profile or session. + parsed, _, err := watchParses(t, func() error { + withStop := append(append([]string(nil), args[1:]...), "--reasoning", "not-a-level") + return openChatV3(name, withStop, name == "resume") + }) + if err == nil || len(parsed) != 1 { + t.Fatalf("%q reached %d parses and error %v", args, len(parsed), err) + } + if got := parsed[0].values["session"]; got != "/tmp/this.jsonl" { + t.Fatalf("%q parsed --session as %q", args, got) + } + if name == "chat" && len(original) > 1 && original[1] == "--model" && parsed[0].values["model"] != "x" { + t.Fatalf("%q lost --model: %+v", args, parsed[0].values) + } + } +} + +// TestC7TheDoorWaitsOutItsOwnDetachBeforeRestarting proves the local-engine +// half of C7 and D8 without sleeping or replacing the test process. +func TestC7TheDoorWaitsOutItsOwnDetachBeforeRestarting(t *testing.T) { + oldRetire, oldRestart := retireChatEngine, restartUpdatedChat + oldNow, oldPause := updateRestartNow, updateRestartPause + t.Cleanup(func() { + retireChatEngine, restartUpdatedChat = oldRetire, oldRestart + updateRestartNow, updateRestartPause = oldNow, oldPause + }) + now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) + updateRestartNow = func() time.Time { return now } + updateRestartPause = func(wait time.Duration) { now = now.Add(wait) } + var attempts int + retireChatEngine = func(workspace string, anyway bool) error { + attempts++ + if workspace != "/tmp/lab" || anyway { + t.Fatalf("retire(%q, %t)", workspace, anyway) + } + if attempts < 3 { + return enginehost.ErrHostBusy + } + return nil + } + want := codeupdate.Plan{Path: "/tmp/codeaf", Args: []string{"chat", "--session", "/tmp/this.jsonl"}} + restarted := false + restartUpdatedChat = func(got codeupdate.Plan) error { + restarted = true + if got.Path != want.Path || strings.Join(got.Args, "\x00") != strings.Join(want.Args, "\x00") { + t.Fatalf("restart = %+v, want %+v", got, want) + } + return nil + } + if err := finishChatRestart(nil, &want, "/tmp/lab"); err != nil { + t.Fatal(err) + } + if attempts != 3 || !restarted { + t.Fatalf("retire attempts = %d, restarted = %t", attempts, restarted) + } +} diff --git a/internal/config/settings.go b/internal/config/settings.go index cda9ea0c32..5ba43db327 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1057,6 +1057,14 @@ var OperatorEnvPins = []string{ // would be promising an override that does nothing, which is worse than // saying nothing at all. "CODEAF_PROFILE_DIR", + // The release check's one-launch opt-out and its two mirror addresses + // (internal/update). They are plumbing rather than settings rows: the first + // is a shell's decision not to make a launch request, while the other two + // redirect GitHub traffic for a mirror or a test. Persisting any of them in + // the profile would make a temporary network posture outlive its reason. + "CODEAF_NO_UPDATE_CHECK", + "CODEAF_GITHUB_API", + "CODEAF_GITHUB_DOWNLOAD", // The two pins on the model-call log (internal/calllog). CODEAF_CALL_LOG // switches it off or moves the file; CODEAF_CALL_LOG_BODIES adds the whole // request and response to every line. Plumbing rather than settings rows, From ee5179d03ac2b5ad679387b6f1dcfb92c55e4bfa Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 15 Sep 2026 19:25:12 -0400 Subject: [PATCH 05/11] remote: a window that says it is leaving needs no torn-link grace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The twenty-second grace exists for a terminal that was killed without saying so, and a deliberate detach is the opposite of that — the window has just announced its departure. Carrying the grace anyway left a cleanly closed window looking attached for two more call deadlines, which kept a freshly installed codeaf waiting behind the engine the old one had already left. It clears only once the last view is gone. Turns, tasks, unanswered questions, other views and live work all still refuse retirement, and a refusal still never stops the work it protects. Co-Authored-By: Claude Opus 5 (1M context) --- internal/remote/detach_test.go | 27 ++++++++++++++++++++++++++- internal/remote/server.go | 11 +++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/internal/remote/detach_test.go b/internal/remote/detach_test.go index fc9300d0ab..d29cb042a0 100644 --- a/internal/remote/detach_test.go +++ b/internal/remote/detach_test.go @@ -1,6 +1,9 @@ package remote -import "testing" +import ( + "testing" + "time" +) // viewOn dials a real client at one served conversation over an in-memory pipe. // It is [Loopback] for a session the test built itself, which is the only way to @@ -31,6 +34,9 @@ func TestDetachingFromAHostLeavesTheConversationRunning(t *testing.T) { agent := &fakeAgent{} sess := NewSession(engineOn(agent), true) client := viewOn(t, sess) + sess.mu.Lock() + sess.acted = time.Now() + sess.mu.Unlock() if !client.Welcome().Persistent { t.Fatal("a session host did not say its engine outlives the connection") @@ -49,6 +55,9 @@ func TestDetachingFromAHostLeavesTheConversationRunning(t *testing.T) { // what the process exiting looks like from the host's side. _ = client.Close() waitFor(t, "the host noticed the surface leave", func() bool { return sess.Attached() == 0 }) + if sess.IdleSince().IsZero() { + t.Fatal("a deliberate detach kept the torn-link watching grace") + } if sess.Ended() { t.Fatal("a dropped connection ended a hosted conversation") } @@ -57,6 +66,22 @@ func TestDetachingFromAHostLeavesTheConversationRunning(t *testing.T) { } } +// A TORN LINK KEEPS THE GRACE. Only MethodDetach is a window saying it left; +// an EOF by itself may be a stalled call or a laptop between redial attempts. +func TestATornHostedConnectionKeepsTheWatchingGrace(t *testing.T) { + sess := NewSession(engineOn(&fakeAgent{}), true) + client := viewOn(t, sess) + sess.mu.Lock() + sess.acted = time.Now() + sess.mu.Unlock() + + _ = client.Close() + waitFor(t, "the host noticed the torn link", func() bool { return sess.Attached() == 0 }) + if !sess.IdleSince().IsZero() { + t.Fatal("a torn link lost the watching grace that protects its redial") + } +} + // AND A ONE-SHOT ENGINE HAS NO LIFE APART FROM THE PIPE, so leaving it is // ending it: the flush is what keeps its session file readable. func TestDetachingFromAOneShotEngineEndsIt(t *testing.T) { diff --git a/internal/remote/server.go b/internal/remote/server.go index c246617a4f..edf8d2cec4 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -1029,7 +1029,7 @@ func (sess *Session) attach(s *server, hello Hello) error { // a killed terminal leaves without saying so, and this runs whenever its socket // gets around to reporting the end, which may be after the window that replaced // it has already been welcomed (held.go states the whole rule). -func (sess *Session) detach(s *server) { +func (sess *Session) detach(s *server, deliberate bool) { // THE RAIL'S SUBSCRIPTION GOES WITH THE WINDOW. It is left before anything // else because leaving it is what ends the goroutine pumping frames at a // pipe that is closing (tasklane.go). @@ -1051,6 +1051,13 @@ func (sess *Session) detach(s *server) { } if len(sess.surfaces) == 0 { sess.empty = time.Now() + // A deliberate detach is the window saying it has gone, so its last + // action needs no torn-link grace. Keeping that grace made a cleanly + // closed window look attached for another two call deadlines, which in + // turn kept an in-place update on the old engine it had just left. + if deliberate { + sess.acted = time.Time{} + } } sess.mu.Unlock() if moved { @@ -1696,7 +1703,7 @@ func (s *server) leave() { if sess == nil { return } - sess.detach(s) + sess.detach(s, s.detached.Load()) if s.goodbye.Load() { _ = sess.closeLeaving() return From ef89499aaa31bba372144420b9a11354228de0d1 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 15 Sep 2026 19:25:18 -0400 Subject: [PATCH 06/11] The installer and the updater agree on what a channel tag looks like Two roads now install codeaf, and they were reading the same release list by different rules: the shell accepted any dev-* or staging-* tag and semver parts with leading zeroes, while internal/update accepts only the shapes the release workflow actually publishes. A malformed tag newer than a real one would send the two roads to different builds. One helper carries the grammar to both channel selection and the line that names the channel, and an unrecognised tag for the asked-for channel now says so instead of installing whatever came back. The publish-time ordering #1056 added is untouched: only which candidates are eligible changed. Co-Authored-By: Claude Opus 5 (1M context) --- internal/release/install_test.go | 32 ++++++++++++++++++++++++++++++++ scripts/install.sh | 29 ++++++++++++++++++++++------- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/internal/release/install_test.go b/internal/release/install_test.go index f09e7f6b1e..7d4bb9c736 100644 --- a/internal/release/install_test.go +++ b/internal/release/install_test.go @@ -464,6 +464,38 @@ func TestInstallerSelectsTheNewestBuildOfEachChannel(t *testing.T) { // Contract: the newest published_at wins regardless of list order, created_at // replaces a null published_at, and the same ordering applies to staging. func TestInstallerPicksTheNewestChannelBuildWhateverTheListOrder(t *testing.T) { + t.Run("a newer malformed tag is ignored", func(t *testing.T) { + const ( + valid = "dev-20260915-abcdefabcdef" + malformed = "dev-backfill" + ) + github := newInstallGitHub(t, valid, malformed) + github.timings = map[string]fakeTiming{ + valid: {published: "2026-09-15T15:00:00Z"}, + malformed: {published: "2026-09-15T16:00:00Z"}, + } + run := runInstaller(t, github, []string{"--dev"}, "CODEAF_NO_MODIFY_PATH=1") + if run.code != 0 || !strings.Contains(run.output, "dev "+valid) || strings.Contains(run.output, malformed) { + t.Fatalf("exit %d, want valid tag %s:\n%s", run.code, valid, run.output) + } + }) + + t.Run("a release candidate with leading zeroes is ignored", func(t *testing.T) { + const ( + valid = "v2.1.0-rc.1" + malformed = "v02.1.0-rc.2" + ) + github := newInstallGitHub(t, valid, malformed) + github.timings = map[string]fakeTiming{ + valid: {published: "2026-09-15T15:00:00Z"}, + malformed: {published: "2026-09-15T16:00:00Z"}, + } + run := runInstaller(t, github, []string{"--rc"}, "CODEAF_NO_MODIFY_PATH=1") + if run.code != 0 || !strings.Contains(run.output, "rc "+valid) || strings.Contains(run.output, malformed) { + t.Fatalf("exit %d, want valid tag %s:\n%s", run.code, valid, run.output) + } + }) + t.Run("newest published_at", func(t *testing.T) { const ( oldest = "dev-20260915-e2ae913b7d0c" diff --git a/scripts/install.sh b/scripts/install.sh index 16a41aad77..39492e63c8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -166,6 +166,19 @@ extract_tags() { sed -E 's/^"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)"$/\1/' } +# Channel selection accepts exactly the grammar used by internal/update/version.go. +tag_matches_channel() { + local channel="$1" + local tag="$2" + case "$channel" in + stable) [[ "$tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] ;; + rc) [[ "$tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-rc\.([1-9][0-9]*)$ ]] ;; + dev) [[ "$tag" =~ ^dev-[0-9]{8}-[0-9a-f]{12}$ ]] ;; + staging) [[ "$tag" =~ ^staging-[0-9]{8}-[0-9a-f]{12}$ ]] ;; + *) return 1 ;; + esac +} + # GitHub's release-list order is not publish order, so the LAW carries each tag's own timestamp into channel selection. extract_dated_tags() { awk ' @@ -247,9 +260,7 @@ else TAG_STAMP="" # GitHub's list order is not publish order, so the LAW chooses a matching candidate by its own release timestamp. while IFS=' ' read -r stamp candidate; do - if [[ "$CHANNEL" == "rc" && "$candidate" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[1-9][0-9]*$ ]] || - [[ "$CHANNEL" == "dev" && "$candidate" == dev-* ]] || - [[ "$CHANNEL" == "staging" && "$candidate" == staging-* ]]; then + if tag_matches_channel "$CHANNEL" "$candidate"; then if [[ -z "$TAG" || "$stamp" > "$TAG_STAMP" ]]; then TAG="$candidate" TAG_STAMP="$stamp" @@ -267,13 +278,17 @@ if [[ -z "${TAG:-}" ]]; then api_problem fi -if [[ "$TAG" =~ ^dev-[0-9]{8}-[0-9a-f]{12}$ ]]; then +if [[ -z "$VERSION" ]] && ! tag_matches_channel "$CHANNEL" "$TAG"; then + fail "no $CHANNEL build has been published yet" +fi + +if tag_matches_channel "dev" "$TAG"; then DISPLAY_CHANNEL="dev" -elif [[ "$TAG" =~ ^staging-[0-9]{8}-[0-9a-f]{12}$ ]]; then +elif tag_matches_channel "staging" "$TAG"; then DISPLAY_CHANNEL="staging" -elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[1-9][0-9]*$ ]]; then +elif tag_matches_channel "rc" "$TAG"; then DISPLAY_CHANNEL="rc" -elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then +elif tag_matches_channel "stable" "$TAG"; then DISPLAY_CHANNEL="stable" else DISPLAY_CHANNEL="" From 2c6a606a046190494966b6ed6ef159e70bcec541 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 15 Sep 2026 19:25:26 -0400 Subject: [PATCH 07/11] The manual stops saying that nothing self-updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit That sentence was true until this change and is now the opposite of true, so it goes, and a section written in the words people bring takes its place: is there a newer version, how do I update codeaf, why does it say this every time I start. It states who gets the launch line and who never does, the opt-out variable, the day the cached answer lasts, the shell exit codes, and both refusals — a build made from source and an executable nobody may write. The command table names /update and /upgrade with the argument they take. Four probes reach the new section, and the remote page keeps its own asker's words in its heading so the new section cannot take that question from it. Co-Authored-By: Claude Opus 5 (1M context) --- internal/manual/chat/commands.md | 2 ++ .../chat/opening-files-from-that-machine.md | 2 +- .../manual/chat/running-from-the-terminal.md | 29 +++++++++++++++++-- internal/manual/chat_test.go | 26 +++++++++++++++++ 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 4eaffbeb86..dd9794f5b4 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -228,6 +228,8 @@ Canonical word, the other words it answers to, its argument form, and what it do | `/cache` | — | — | how big the shared build cache is, and where | | `/cache` | — | `clean` | asks first, then deletes the cache to free disk — confirm with `/cache clean now` | | `/debug` | — | — | keeps the full record of **this conversation** from here on, and says which folder it goes to | +| `/update` | `/upgrade` | — | installs the newest stable release and restarts this conversation on it | +| `/update` | `/upgrade` | `` | installs that channel's newest release or one exact tag, then restarts this conversation on it | | `/copy` | — | — | enters copy mode (also ctrl+b) | | `/select` | — | — | hands the pointer back to the terminal (also ctrl+s) | | `/export` | `/save` | — | writes the whole conversation to a file | diff --git a/internal/manual/chat/opening-files-from-that-machine.md b/internal/manual/chat/opening-files-from-that-machine.md index 296432361a..4d7aea479c 100644 --- a/internal/manual/chat/opening-files-from-that-machine.md +++ b/internal/manual/chat/opening-files-from-that-machine.md @@ -9,7 +9,7 @@ to send one the other way. Everything except that one drag only **reads**. Nothing here edits a file over there, and the copy that lands on this machine is a copy. -## Click a path over --host to open a file on the other machine +## Download a file from my dev box — click a path over --host to open a file on the other machine **A path in a reply is a real link again over a connection.** cmd+click it on a Mac, ctrl+click on Linux — the same gesture that opens a path in a local conversation. codeaf diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index cb6bc70c71..f454dd46f1 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -1,6 +1,31 @@ # Commands you type in a terminal -## How do I install or update codeaf to the latest version — the curl line, dev, staging, rc and stable +## Is there a newer version — how do I update codeaf — /update — codeaf update — why does it say this every time I start + +At launch, a stable release that is behind the newest stable release gets one dim +line naming both versions. The line offers `/update`, which downloads the release, +checks its sha256, replaces this executable and restarts the same conversation. Its +alias is `/upgrade`. Finish a running turn or task first. A matching release says +`you are on the newest codeaf, ` and does not restart. + +Release candidates get the launch line once their stable line is published. A +stable build already newest or ahead gets nothing. Dev and staging channel builds, +source builds and unstamped builds make no launch request and draw nothing. Set +`CODEAF_NO_UPDATE_CHECK=1` to skip this launch check only; `/update` and `codeaf +update` still work. The answer is cached in `update-check.json` beside the profile's +`config.json` for 24 hours for the same running build. A cached newer release is +still shown at every launch, because the reminder is the useful part. + +From a shell, `codeaf update --check` checks without installing; it exits 3 when a +newer stable exists, 0 when this release is newest, and 1 when it could not check. +`codeaf update` installs stable by default. `--rc`, `--dev`, `--staging`, or +`--version ` selects another release. A binary built from source refuses an +in-place install and names its path: rebuild it with `make build`, or install a +release with the curl line below. An unwritable executable is also left untouched +and offered that line; codeaf never tries sudo. A failed download or checksum leaves +the original executable in place. + +## The curl installer — dev, staging, rc and stable channels Build from source needs nothing published: clone the repository, run `make build`, then run `bin/codeaf` from the checkout. While the repository is private, both the @@ -32,7 +57,7 @@ curl -fsSL https://raw.githubusercontent.com/Agent-Field/codeaf/main/scripts/ins `https://agentfield.ai/get/codeaf` is not serving yet. Once it serves, it is the supported proxy for the same script, with `/dev`, `/staging`, or `/rc` selecting another channel. The installer writes `~/.codeaf/bin/codeaf`; its last line is -`codeaf version`. Nothing self-updates: run it again when you want a newer build. +`codeaf version`. ## Why codeaf do may download rtk — compressed shell output and how to turn it off diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 5009ae8b28..181966395b 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -2553,6 +2553,11 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { // TestTheChatManualMentionsEveryVerbTheCommandLineAnswersTo. {"can I run this without the chat", "running-from-the-terminal"}, {"how do I update codeaf to the latest version", "running-from-the-terminal"}, + // C13: These are the words a person brings to the update section. + {"is there a newer version", "running-from-the-terminal"}, + {"how do I update codeaf", "running-from-the-terminal"}, + {"update codeaf", "running-from-the-terminal"}, + {"it says a new version is out every time I start", "running-from-the-terminal"}, // The ending #593 added, in the words somebody meets it in: on the // stderr line they have just read, on the word in `--json`, and on the // exit code they are staring at with a perfectly good answer above it. @@ -2640,6 +2645,27 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { } } +// TestC13UpdateQuestionsReachTheNewManualSection proves C13. +func TestC13UpdateQuestionsReachTheNewManualSection(t *testing.T) { + for _, asked := range []string{ + "is there a newer version", + "how do I update codeaf", + "update codeaf", + "it says a new version is out every time I start", + } { + found := false + for _, section := range Chat().Search(asked, DefaultResults) { + if section.Page == "running-from-the-terminal" && strings.Contains(section.Title, "newer version") { + found = true + break + } + } + if !found { + t.Errorf("%q does not reach the update section", asked) + } + } +} + func TestTheServicesPageNamesCustomListingDiscoveryAndDisconnectConfirmation(t *testing.T) { page, ok := Chat().Page("services") if !ok { From f29f38d437248dd0d247c0336ca2e3c078cfcc20 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 15 Sep 2026 19:26:34 -0400 Subject: [PATCH 08/11] docs/changes: the update check and /update, for #1060 Co-Authored-By: Claude Opus 5 (1M context) --- docs/changes/unreleased/1060-update-check.md | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 docs/changes/unreleased/1060-update-check.md diff --git a/docs/changes/unreleased/1060-update-check.md b/docs/changes/unreleased/1060-update-check.md new file mode 100644 index 0000000000..411ad7d624 --- /dev/null +++ b/docs/changes/unreleased/1060-update-check.md @@ -0,0 +1,24 @@ +--- +kind: added +title: a launch says when a newer codeaf is out, and /update installs it and restarts +pr: 1060 +surface: [chat, build] +invalidates: + - "The chat manual's install section ended `Nothing self-updates: run it again when you want a newer build.` That sentence is gone and the opposite is now true: `/update` (alias `/upgrade`) downloads the newest stable release, checks its sha256, replaces the running executable and restarts on it with `--session` pointing at the same transcript, and `codeaf update` does the same from a shell. `running-from-the-terminal.md` has a new section, `Is there a newer version — how do I update codeaf — /update — codeaf update — why does it say this every time I start`, which states all of it." + - "A launch said nothing about newer releases. A build stamped with a stable tag that is behind the newest stable release now draws one dim transcript line naming both versions, `/update`, and the curl line. A release candidate gets it once its stable line is published. A `dev-*` or `staging-*` channel build, a `make build` sha and an unstamped binary make NO request at all and draw nothing, so a developer's build is never told it is behind. The check is asynchronous with a three-second budget, never delays the first frame, and says nothing when it fails; its answer is cached for a day in `update-check.json` beside `config.json`. `CODEAF_NO_UPDATE_CHECK=1` turns the launch check off without touching `/update` or `codeaf update`." + - "The release-tag grammar lived in `cmd/codeaf-release/version.go`. It is now `internal/update`, which the release tool reads; there is one parser. `scripts/install.sh` was accepting any `dev-*` or `staging-*` tag and semver parts with leading zeroes where `internal/update` accepts only the shapes the release workflow publishes — it now uses the same grammar, so the two install roads cannot pick different builds. The publish-time ordering #1056 added is unchanged; only which candidates are eligible." + - "`codeaf update` is a new command, and it sits under housekeeping in `codeaf --help` rather than beside the read-only rows: `--check` prints one line and exits 0 when this release is newest, 3 when a newer one exists and 1 when it could not ask, while a plain run installs stable or whatever `--rc`, `--dev`, `--staging` or `--version ` names. A binary built from source refuses to replace itself in place and names its path; an unwritable executable is left untouched and offered the curl line; sudo is never attempted." + - "A deliberate detach in `internal/remote` kept the twenty-second torn-link grace that exists for a terminal killed without warning, so a window that had announced its departure still looked attached for two more call deadlines. It now clears that grace once the last view leaves, which is what lets a freshly installed codeaf take the engine the old process had already let go of. Live turns, tasks, unanswered questions and other views still refuse retirement." +--- + +The owner asked for two things that turn out to be one: tell somebody a newer +codeaf exists, and give them the command. A line that only names a version makes +a person go looking, so this one carries `/update` and the curl line with it, and +`/update` finishes the job rather than printing homework. + +Everything else follows from not wanting to be wrong in front of somebody. The +check is silent on failure because a network complaint at launch is noise about a +thing nobody asked for. A developer's own build is never told it is behind. The +restart waits for the door, after the frame is down and the terminal is back, and +carries the transcript so the conversation the person was having is the one that +comes back. From 2d4ae825f7b6e5540a070c6466b0544c2464eaeb Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 15 Sep 2026 20:26:41 -0400 Subject: [PATCH 09/11] update: --check answers for the channel it was asked about, and a newer build is never downgraded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things a hand verification caught. --check parsed whatever tag it had selected with the stable grammar alone, so --dev, --staging and --rc could never be "newer" and every one of them answered "you are on the newest codeaf" and exited 0 while a newer build sat in the list. The check now keeps its channel all the way through the decision and always names the tag it is talking about: stable and rc compare as semver, a dev or staging tag is the same tag or a different one, and --version is equal or it is not. And a build AHEAD of the newest stable was quietly installed backwards — the check said newest, the install downgraded it. Both roads refuse now, naming both versions and the deliberate way past: this codeaf is v0.3.0, ahead of the newest stable v0.2.0 — pass --version v0.2.0 to install it anyway An explicit tag IS that deliberate road, so --version and /update still install exactly what was named. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/codeaf/update.go | 74 ++++++++++++++----- cmd/codeaf/update_test.go | 127 ++++++++++++++++++++++++++++++++ internal/tui3/updatecmd.go | 14 +++- internal/tui3/updatecmd_test.go | 64 ++++++++++++++++ internal/update/update_test.go | 27 +++++++ internal/update/version.go | 31 ++++++++ 6 files changed, 317 insertions(+), 20 deletions(-) diff --git a/cmd/codeaf/update.go b/cmd/codeaf/update.go index f53f787079..0906efe9a2 100644 --- a/cmd/codeaf/update.go +++ b/cmd/codeaf/update.go @@ -61,7 +61,7 @@ func runUpdate(args []string) error { var err error target, err = codeupdate.ExecutableTarget(updateExecutable) if err != nil { - return err + return updateFailure(err) } if codeupdate.Kind(running) == "other" { shown := strings.TrimSpace(running) @@ -75,43 +75,79 @@ func runUpdate(args []string) error { client := updateClient(running, 3*time.Second) release, err := client.Select(context.Background(), choice) if err != nil { - fmt.Fprintln(updateErr, "codeaf: could not check for an update:", err) - return exitStatus(1) + if *check { + fmt.Fprintln(updateErr, "codeaf: could not check for an update:", err) + return exitStatus(1) + } + return updateFailure(fmt.Errorf("could not select a release: %w", err)) } if *check { - return sayUpdateCheck(running, release.Tag) + return sayUpdateCheck(running, release.Tag, choice) + } + if choice.Version == "" { + if comparison, comparable := codeupdate.CompareSemverTags(running, release.Tag); comparable && comparison > 0 { + fmt.Fprintf(updateErr, "this codeaf is %s, ahead of the newest %s %s — pass --version %s to install it anyway\n", running, choice.Channel, release.Tag, release.Tag) + return exitStatus(2) + } } result, err := codeupdate.Install(context.Background(), codeupdate.InstallOptions{ Client: client, Release: release, Target: target, }) if err != nil { - return err + return updateFailure(err) } fmt.Fprintf(updateOut, "codeaf: installed %s at %s\n", result.Release.Tag, result.Path) if err := updateVersionLine(result.Path, updateOut, updateErr); err != nil { - return fmt.Errorf("run the installed codeaf: %w", err) + return updateFailure(fmt.Errorf("run the installed codeaf: %w", err)) } return nil } -func sayUpdateCheck(running, latest string) error { - answer := codeupdate.Available{Latest: latest, Running: running} - switch { - case answer.Newer(): - fmt.Fprintf(updateOut, "codeaf %s is available · you have %s\n", latest, running) +func sayUpdateCheck(running, selected string, choice codeupdate.Choice) error { + shown := strings.TrimSpace(running) + if shown == "" { + shown = "an unstamped source build" + } + if choice.Version != "" { + if running == selected { + fmt.Fprintf(updateOut, "this codeaf matches the selected tag, %s\n", selected) + return nil + } + fmt.Fprintf(updateOut, "selected codeaf %s differs · this codeaf is %s\n", selected, shown) return exitStatus(3) - case codeupdate.Kind(running) == "stable" || codeupdate.Kind(running) == "rc": - fmt.Fprintf(updateOut, "you are on the newest codeaf, %s\n", running) - return nil - default: - shown := strings.TrimSpace(running) - if shown == "" { - shown = "an unstamped source build" + } + channel := choice.Channel + if channel == "dev" || channel == "staging" { + if running == selected { + fmt.Fprintf(updateOut, "you are on the newest %s codeaf, %s\n", channel, selected) + return nil + } + fmt.Fprintf(updateOut, "codeaf %s is available · you have %s\n", selected, shown) + return exitStatus(3) + } + comparison, comparable := codeupdate.CompareSemverTags(selected, running) + if comparable && comparison > 0 { + fmt.Fprintf(updateOut, "codeaf %s is available · you have %s\n", selected, shown) + return exitStatus(3) + } + if comparable && comparison == 0 { + if channel == "stable" { + fmt.Fprintf(updateOut, "you are on the newest codeaf, %s\n", selected) + } else { + fmt.Fprintf(updateOut, "you are on the newest %s codeaf, %s\n", channel, selected) } - fmt.Fprintf(updateOut, "the newest stable codeaf is %s · this codeaf is %s\n", latest, shown) return nil } + fmt.Fprintf(updateOut, "the newest %s codeaf is %s · this codeaf is %s\n", channel, selected, shown) + return nil +} + +func updateFailure(err error) error { + if strings.Contains(err.Error(), codeupdate.CurlCommand) { + return err + } + return fmt.Errorf("%w; install a release with: %s", err, codeupdate.CurlCommand) } func runInstalledVersion(path string, stdout, stderr io.Writer) error { diff --git a/cmd/codeaf/update_test.go b/cmd/codeaf/update_test.go index 2c3831e7b5..69d3d77464 100644 --- a/cmd/codeaf/update_test.go +++ b/cmd/codeaf/update_test.go @@ -77,6 +77,52 @@ func TestC9UpdateCheckUsesTheRealDoorAndItsThreeExitCodes(t *testing.T) { } } +// TestC9bUpdateCheckNamesTheSelectedTagAndUsesItsChannelRules proves C9b. +func TestC9bUpdateCheckNamesTheSelectedTagAndUsesItsChannelRules(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/releases/latest") { + fmt.Fprint(w, `{"tag_name":"v0.2.0"}`) + return + } + fmt.Fprint(w, `[ + {"tag_name":"v0.1.1-rc.2","published_at":"2026-09-14T12:00:00Z"}, + {"tag_name":"dev-20260914-bbbbbbbbbbbb","published_at":"2026-09-14T13:00:00Z"}, + {"tag_name":"staging-20260914-cccccccccccc","published_at":"2026-09-14T14:00:00Z"} + ]`) + })) + defer server.Close() + client := &codeupdate.Client{HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL} + + for _, row := range []struct { + name string + args []string + running string + selected string + want int + }{ + {"stable newer", []string{"--check", "--stable"}, "v0.1.0", "v0.2.0", 3}, + {"stable equal", []string{"--check", "--stable"}, "v0.2.0", "v0.2.0", 0}, + {"rc newer", []string{"--check", "--rc"}, "v0.1.1-rc.1", "v0.1.1-rc.2", 3}, + {"rc equal", []string{"--check", "--rc"}, "v0.1.1-rc.2", "v0.1.1-rc.2", 0}, + {"dev different", []string{"--check", "--dev"}, "dev-20260913-aaaaaaaaaaaa", "dev-20260914-bbbbbbbbbbbb", 3}, + {"dev equal", []string{"--check", "--dev"}, "dev-20260914-bbbbbbbbbbbb", "dev-20260914-bbbbbbbbbbbb", 0}, + {"staging different", []string{"--check", "--staging"}, "staging-20260913-dddddddddddd", "staging-20260914-cccccccccccc", 3}, + {"staging equal", []string{"--check", "--staging"}, "staging-20260914-cccccccccccc", "staging-20260914-cccccccccccc", 0}, + {"version different", []string{"--check", "--version", "v0.2.0"}, "v0.3.0", "v0.2.0", 3}, + {"version equal", []string{"--check", "--version", "v0.2.0"}, "v0.2.0", "v0.2.0", 0}, + } { + t.Run(row.name, func(t *testing.T) { + stdout, stderr := withUpdateDoor(t, row.running, client, filepath.Join(t.TempDir(), "codeaf")) + if got := updateExit(runUpdate(row.args)); got != row.want { + t.Fatalf("exit = %d, want %d; stdout %q stderr %q", got, row.want, stdout.String(), stderr.String()) + } + if !strings.Contains(stdout.String(), row.selected) { + t.Fatalf("output does not name selected tag %q: %q", row.selected, stdout.String()) + } + }) + } +} + // TestC9SourceBuildRefusesBeforeTheNetwork proves C9. func TestC9SourceBuildRefusesBeforeTheNetwork(t *testing.T) { target := filepath.Join(t.TempDir(), "codeaf") @@ -183,6 +229,87 @@ func TestC9AnInstallEndsWithTheNewBinarysVersionLine(t *testing.T) { } } +// TestC15TerminalUpdateRefusesAnImplicitDowngradeAndAnExactTagInstalls proves C15. +func TestC15TerminalUpdateRefusesAnImplicitDowngradeAndAnExactTagInstalls(t *testing.T) { + asset := []byte("selected older release") + digest := sha256.Sum256(asset) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/releases/latest"): + fmt.Fprint(w, `{"tag_name":"v0.2.0"}`) + case strings.HasSuffix(r.URL.Path, "/checksums.txt"): + fmt.Fprintf(w, "%x codeaf-%s-%s\n", digest, runtime.GOOS, runtime.GOARCH) + case strings.Contains(r.URL.Path, "/releases/download/v0.2.0/"): + _, _ = w.Write(asset) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + client := &codeupdate.Client{HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL} + + t.Run("implicit stable", func(t *testing.T) { + target := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(target, []byte("ahead release"), 0o755); err != nil { + t.Fatal(err) + } + stdout, stderr := withUpdateDoor(t, "v0.3.0", client, target) + if got := updateExit(runUpdate(nil)); got != 2 { + t.Fatalf("exit = %d, want 2; stdout %q stderr %q", got, stdout.String(), stderr.String()) + } + want := "this codeaf is v0.3.0, ahead of the newest stable v0.2.0 — pass --version v0.2.0 to install it anyway\n" + if stderr.String() != want || stdout.Len() != 0 { + t.Fatalf("stdout %q stderr %q, want stderr %q", stdout.String(), stderr.String(), want) + } + if got, err := os.ReadFile(target); err != nil || string(got) != "ahead release" { + t.Fatalf("target = %q, %v", got, err) + } + }) + + t.Run("exact tag", func(t *testing.T) { + target := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(target, []byte("ahead release"), 0o755); err != nil { + t.Fatal(err) + } + stdout, _ := withUpdateDoor(t, "v0.3.0", client, target) + if err := runUpdate([]string{"--version", "v0.2.0"}); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(target); err != nil || string(got) != string(asset) { + t.Fatalf("target = %q, %v", got, err) + } + if !strings.Contains(stdout.String(), "installed v0.2.0") { + t.Fatalf("stdout = %q", stdout.String()) + } + }) +} + +// TestTerminalDownloadFailureEndsWithTheCurlFallback proves D9's failure line. +func TestTerminalDownloadFailureEndsWithTheCurlFallback(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/releases/latest") { + fmt.Fprint(w, `{"tag_name":"v0.2.0"}`) + return + } + http.NotFound(w, r) + })) + defer server.Close() + target := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + client := &codeupdate.Client{HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL} + _, _ = withUpdateDoor(t, "v0.1.0", client, target) + err := runUpdate(nil) + if err == nil { + t.Fatal("download failure returned no error") + } + wantEnd := "; install a release with: " + codeupdate.CurlCommand + if !strings.HasSuffix(err.Error(), wantEnd) { + t.Fatalf("failure does not end with the curl fallback: %q", err) + } +} + // TestC14RestartArgumentsPassTheRealChatFlagParser proves C14. func TestC14RestartArgumentsPassTheRealChatFlagParser(t *testing.T) { for _, original := range [][]string{nil, {"chat", "--model", "x"}, {"resume", "--session", "/tmp/this.jsonl"}} { diff --git a/internal/tui3/updatecmd.go b/internal/tui3/updatecmd.go index 22168713f8..f6cb1c84be 100644 --- a/internal/tui3/updatecmd.go +++ b/internal/tui3/updatecmd.go @@ -17,6 +17,7 @@ type updateCheckMsg struct { type updateResolveMsg struct { release codeupdate.Release + choice codeupdate.Choice err error } @@ -86,7 +87,7 @@ func (a *app) runUpdateCommand(argument string) tea.Cmd { a.updateActive = true return func() tea.Msg { release, err := resolve(context.Background(), choice) - return updateResolveMsg{release: release, err: err} + return updateResolveMsg{release: release, choice: choice, err: err} } } @@ -112,6 +113,17 @@ func (a *app) tookUpdateResolve(message updateResolveMsg) tea.Cmd { a.note("you are on the newest codeaf, " + message.release.Tag) return nil } + if message.choice.Version == "" { + if comparison, comparable := codeupdate.CompareSemverTags(a.updateRunning, message.release.Tag); comparable && comparison > 0 { + channel := message.choice.Channel + if channel == "" { + channel = "stable" + } + a.updateActive = false + a.note("this codeaf is " + a.updateRunning + ", ahead of the newest " + channel + " " + message.release.Tag + " — /update " + message.release.Tag + " installs it anyway") + return nil + } + } a.note("downloading codeaf " + message.release.Tag + " for " + runtimePlatform() + "…") install := a.installUpdate return func() tea.Msg { diff --git a/internal/tui3/updatecmd_test.go b/internal/tui3/updatecmd_test.go index 9aedd63948..a6594c0e25 100644 --- a/internal/tui3/updatecmd_test.go +++ b/internal/tui3/updatecmd_test.go @@ -142,6 +142,70 @@ func TestC6UpdateOnTheNewestBuildDoesNotInstallOrQuit(t *testing.T) { } } +// TestC15ChatUpdateRefusesAnImplicitDowngradeAndAnExactTagInstalls proves C15. +func TestC15ChatUpdateRefusesAnImplicitDowngradeAndAnExactTagInstalls(t *testing.T) { + t.Run("implicit stable", func(t *testing.T) { + installed := false + restart := &codeupdate.Plan{} + a := newApp(context.Background(), Options{ + Agent: &fakeAgent{model: "test/model"}, Workspace: "/tmp/lab", + UpdateRunning: "v0.3.0", Restart: restart, + ResolveUpdate: func(context.Context, codeupdate.Choice) (codeupdate.Release, error) { + return codeupdate.Release{Tag: "v0.2.0"}, nil + }, + InstallUpdate: func(context.Context, codeupdate.Release) (codeupdate.InstallResult, error) { + installed = true + return codeupdate.InstallResult{}, nil + }, + }) + command := a.slash("/update") + drive(t, a, command()) + want := "this codeaf is v0.3.0, ahead of the newest stable v0.2.0 — /update v0.2.0 installs it anyway" + if installed || a.updateActive || restart.Path != "" || !strings.Contains(updateNotes(a), want) { + t.Fatalf("installed = %t active = %t restart = %+v notes:\n%s", installed, a.updateActive, restart, updateNotes(a)) + } + }) + + t.Run("exact tag", func(t *testing.T) { + installed := false + restart := &codeupdate.Plan{} + a := newApp(context.Background(), Options{ + Agent: &fakeAgent{model: "test/model"}, Workspace: "/tmp/lab", SessionFile: "/tmp/this.jsonl", + UpdateRunning: "v0.3.0", Restart: restart, + ResolveUpdate: func(_ context.Context, choice codeupdate.Choice) (codeupdate.Release, error) { + if choice.Version != "v0.2.0" { + t.Fatalf("choice = %+v", choice) + } + return codeupdate.Release{Tag: "v0.2.0"}, nil + }, + InstallUpdate: func(context.Context, codeupdate.Release) (codeupdate.InstallResult, error) { + installed = true + return codeupdate.InstallResult{Release: codeupdate.Release{Tag: "v0.2.0"}, Path: "/tmp/codeaf"}, nil + }, + }) + command := a.slash("/update v0.2.0") + drive(t, a, command()) + if !installed || restart.Path != "/tmp/codeaf" { + t.Fatalf("installed = %t restart = %+v notes:\n%s", installed, restart, updateNotes(a)) + } + }) +} + +// TestUpdateCommandListNamesItsOptionalArgument proves the list agrees with the manual. +func TestUpdateCommandListNamesItsOptionalArgument(t *testing.T) { + bare, withArgument := false, false + for _, row := range commands { + if row.name != "update" { + continue + } + bare = bare || row.args == "" + withArgument = withArgument || row.args == "" + } + if !bare || !withArgument { + t.Fatalf("update rows: bare = %t argument = %t", bare, withArgument) + } +} + // TestC7UpdateInstallReturnsTheRestartPlanForThisTranscript proves C7. func TestC7UpdateInstallReturnsTheRestartPlanForThisTranscript(t *testing.T) { restart := &codeupdate.Plan{} diff --git a/internal/update/update_test.go b/internal/update/update_test.go index e0fbbffa2e..1351e1ac70 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -331,6 +331,33 @@ func TestC11RepositoryAndAssetFallbacksKeepOldReleasesInstallable(t *testing.T) } } +// TestAssetFailureNamesTheCurrentAssetAndStatusWithoutExposingFallbacks proves D9. +func TestAssetFailureNamesTheCurrentAssetAndStatusWithoutExposingFallbacks(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer server.Close() + target := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + _, err := Install(context.Background(), InstallOptions{ + Client: releaseClient(server, "v0.1.0"), + Release: Release{Tag: "v0.2.0", Repository: primaryRepository}, + Target: target, + }) + if err == nil { + t.Fatal("missing asset returned no error") + } + want := fmt.Sprintf("codeaf-%s-%s answered HTTP 404", runtime.GOOS, runtime.GOARCH) + if err.Error() != want { + t.Fatalf("error = %q, want %q", err, want) + } + if strings.Contains(err.Error(), server.URL) || strings.Contains(err.Error(), "aforge") { // legacy-name + t.Fatalf("person-facing error exposes a fallback: %q", err) + } +} + // TestC14RestartArgumentsReopenTheSameConversation proves C14. func TestC14RestartArgumentsReopenTheSameConversation(t *testing.T) { file := "/tmp/conversation.jsonl" diff --git a/internal/update/version.go b/internal/update/version.go index 13fd6b51ef..5bb1a954ae 100644 --- a/internal/update/version.go +++ b/internal/update/version.go @@ -75,6 +75,37 @@ type RCTag struct { Counter int } +// CompareSemverTags orders stable and release-candidate tags. A stable tag +// follows every release candidate on its own version line. +func CompareSemverTags(left, right string) (int, bool) { + leftVersion, leftStable := ParseStable(left) + leftRC, leftCandidate := ParseRC(left) + rightVersion, rightStable := ParseStable(right) + rightRC, rightCandidate := ParseRC(right) + if (!leftStable && !leftCandidate) || (!rightStable && !rightCandidate) { + return 0, false + } + if leftCandidate { + leftVersion = leftRC.Base + } + if rightCandidate { + rightVersion = rightRC.Base + } + if comparison := leftVersion.Compare(rightVersion); comparison != 0 { + return comparison, true + } + switch { + case leftStable && rightCandidate: + return 1, true + case leftCandidate && rightStable: + return -1, true + case leftCandidate && rightCandidate: + return compareInt(leftRC.Counter, rightRC.Counter), true + default: + return 0, true + } +} + func parseComponent(raw string) (int, bool) { value, err := strconv.Atoi(raw) // Every accepted component must leave room for the resolver's next bump. From b208bda3cc2eeed5e64d5adc2bd30b97056a3391 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 15 Sep 2026 20:26:51 -0400 Subject: [PATCH 10/11] update: a failure names the asset and the status, and ends with the curl line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A download that failed printed the whole fallback URL, which put the retired product name on somebody's screen. The line names the asset it asked for and the status it got back, and nothing about which repository answered — the compatibility attempts still happen, they are just not the person's business. Every terminal failure now ends with the curl line, which D9 asked for and the download path had been missing. The / list declared no argument for /update while the command page documented one, so the list gains the second row the way /cache has one. The manual section now says what the code does about a build ahead of the newest stable, where it had said such a build "gets nothing" — true of the launch line, and false of the install until this wave. Co-Authored-By: Claude Opus 5 (1M context) --- docs/changes/unreleased/1060-update-check.md | 2 + .../manual/chat/running-from-the-terminal.md | 46 +++++++++++-------- internal/tui3/commands.go | 1 + internal/update/client.go | 33 ++++++++++--- internal/update/install.go | 5 +- 5 files changed, 59 insertions(+), 28 deletions(-) diff --git a/docs/changes/unreleased/1060-update-check.md b/docs/changes/unreleased/1060-update-check.md index 411ad7d624..e18d16cbf4 100644 --- a/docs/changes/unreleased/1060-update-check.md +++ b/docs/changes/unreleased/1060-update-check.md @@ -8,6 +8,8 @@ invalidates: - "A launch said nothing about newer releases. A build stamped with a stable tag that is behind the newest stable release now draws one dim transcript line naming both versions, `/update`, and the curl line. A release candidate gets it once its stable line is published. A `dev-*` or `staging-*` channel build, a `make build` sha and an unstamped binary make NO request at all and draw nothing, so a developer's build is never told it is behind. The check is asynchronous with a three-second budget, never delays the first frame, and says nothing when it fails; its answer is cached for a day in `update-check.json` beside `config.json`. `CODEAF_NO_UPDATE_CHECK=1` turns the launch check off without touching `/update` or `codeaf update`." - "The release-tag grammar lived in `cmd/codeaf-release/version.go`. It is now `internal/update`, which the release tool reads; there is one parser. `scripts/install.sh` was accepting any `dev-*` or `staging-*` tag and semver parts with leading zeroes where `internal/update` accepts only the shapes the release workflow publishes — it now uses the same grammar, so the two install roads cannot pick different builds. The publish-time ordering #1056 added is unchanged; only which candidates are eligible." - "`codeaf update` is a new command, and it sits under housekeeping in `codeaf --help` rather than beside the read-only rows: `--check` prints one line and exits 0 when this release is newest, 3 when a newer one exists and 1 when it could not ask, while a plain run installs stable or whatever `--rc`, `--dev`, `--staging` or `--version ` names. A binary built from source refuses to replace itself in place and names its path; an unwritable executable is left untouched and offered the curl line; sudo is never attempted." + - "`codeaf update --check --rc`, `--dev`, `--staging` and `--version ` were believed to apply the selected channel's rule after selecting its tag. They did not: every answer ran through the stable-only comparison, so a newer rc or disposable channel build could be called newest with exit 0 and the selected tag omitted. Every answer now names that tag; stable and rc compare their semantic versions, dev and staging compare for equality because channel builds are disposable, and an exact tag is equal or different with no invented ordering." + - "A stable build ahead of the newest published stable was believed to get nothing. That was true only of the launch reminder and `--check`: both `codeaf update` and `/update` silently installed the older release. An implicit update now refuses, names both versions, and points at the deliberate override; `--version ` and `/update ` still install exactly what the person named." - "A deliberate detach in `internal/remote` kept the twenty-second torn-link grace that exists for a terminal killed without warning, so a window that had announced its departure still looked attached for two more call deadlines. It now clears that grace once the last view leaves, which is what lets a freshly installed codeaf take the engine the old process had already let go of. Live turns, tasks, unanswered questions and other views still refuse retirement." --- diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index f454dd46f1..0b765d61e4 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -2,28 +2,34 @@ ## Is there a newer version — how do I update codeaf — /update — codeaf update — why does it say this every time I start -At launch, a stable release that is behind the newest stable release gets one dim -line naming both versions. The line offers `/update`, which downloads the release, -checks its sha256, replaces this executable and restarts the same conversation. Its -alias is `/upgrade`. Finish a running turn or task first. A matching release says -`you are on the newest codeaf, ` and does not restart. +At launch, a stable release behind the newest stable gets one dim line naming both +versions and offering `/update`. That command downloads the release, checks its +sha256, replaces this executable and restarts the same conversation; `/upgrade` is +its alias. Finish a running turn or task first. A matching release says `you are on +the newest codeaf, ` and does not restart. Release candidates get the launch line once their stable line is published. A -stable build already newest or ahead gets nothing. Dev and staging channel builds, -source builds and unstamped builds make no launch request and draw nothing. Set -`CODEAF_NO_UPDATE_CHECK=1` to skip this launch check only; `/update` and `codeaf -update` still work. The answer is cached in `update-check.json` beside the profile's -`config.json` for 24 hours for the same running build. A cached newer release is -still shown at every launch, because the reminder is the useful part. - -From a shell, `codeaf update --check` checks without installing; it exits 3 when a -newer stable exists, 0 when this release is newest, and 1 when it could not check. -`codeaf update` installs stable by default. `--rc`, `--dev`, `--staging`, or -`--version ` selects another release. A binary built from source refuses an -in-place install and names its path: rebuild it with `make build`, or install a -release with the curl line below. An unwritable executable is also left untouched -and offered that line; codeaf never tries sudo. A failed download or checksum leaves -the original executable in place. +stable build already newest or ahead gets no launch line. That silence does not +authorize a downgrade: if this build is `v0.3.0` and stable is `v0.2.0`, `/update` +refuses with `this codeaf is v0.3.0, ahead of the newest stable v0.2.0 — /update +v0.2.0 installs it anyway`. Naming the tag is the deliberate road and installs it. +Dev, staging, source and unstamped builds make no launch request. Set +`CODEAF_NO_UPDATE_CHECK=1` to skip only this check. Its answer is cached in +`update-check.json` beside `config.json` for 24 hours for the same running build; +a cached newer release is still shown at every launch. + +From a shell, `codeaf update --check` exits 3 for a newer selected stable or rc, +or a different selected dev or staging tag; 0 when a stable or rc build is equal +or ahead, or a channel tag is equal; and 1 when it could not check. `--version +` exits 0 only on that tag and 3 otherwise. Every answer names the selected +tag. `codeaf update` installs stable by default; +`--rc`, `--dev`, and `--staging` select another channel. On `v0.3.0` with stable +at `v0.2.0`, it exits 2 with `this codeaf is v0.3.0, ahead of the newest stable +v0.2.0 — pass --version v0.2.0 to install it anyway`. `--version v0.2.0` installs +what was named. A source build refuses and names its path: rebuild with `make +build`, or use the curl line below. An unwritable target, failed download or bad +checksum leaves the original in place and offers that line; codeaf never tries +sudo. ## The curl installer — dev, staging, rc and stable channels diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index 88e07ff71e..a182bebc05 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -439,6 +439,7 @@ var commands = []command{ // behind a scroll. {name: "debug", desc: "keep the full record of this conversation · says where it goes"}, {name: "update", desc: "install the newest codeaf and restart on it", alias: []string{"upgrade"}}, + {name: "update", args: "", desc: "…that channel or exact tag, then restart"}, {name: "help", desc: "this list", alias: []string{"?"}}, {name: "quit", desc: "close this conversation", alias: []string{"exit", "q"}}, } diff --git a/internal/update/client.go b/internal/update/client.go index c999e10280..8a98aed069 100644 --- a/internal/update/client.go +++ b/internal/update/client.go @@ -100,19 +100,36 @@ func (c *Client) userAgent() string { } type statusError struct { - code int - url string + code int + resource string } func (e *statusError) Error() string { - return fmt.Sprintf("%s answered HTTP %d", e.url, e.code) + return fmt.Sprintf("%s answered HTTP %d", e.resource, e.code) } +type requestError struct{ cause error } + +func (e *requestError) Error() string { return "release request failed: " + e.cause.Error() } +func (e *requestError) Unwrap() error { return e.cause } + func isStatus(err error, code int) bool { var status *statusError return errors.As(err, &status) && status.code == code } +func nameReleaseResource(err error, resource string) error { + var status *statusError + if errors.As(err, &status) { + return &statusError{code: status.code, resource: resource} + } + var request *requestError + if errors.As(err, &request) { + return fmt.Errorf("%s request failed: %w", resource, request.cause) + } + return err +} + func (c *Client) get(ctx context.Context, rawURL, accept string, api bool) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { @@ -127,16 +144,20 @@ func (c *Client) get(ctx context.Context, rawURL, accept string, api bool) ([]by } resp, err := c.requestClient().Do(req) if err != nil { - return nil, err + var requestURL *url.Error + if errors.As(err, &requestURL) { + err = requestURL.Err + } + return nil, &requestError{cause: err} } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) - return nil, &statusError{code: resp.StatusCode, url: rawURL} + return nil, &statusError{code: resp.StatusCode, resource: "release API"} } body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("read %s: %w", rawURL, err) + return nil, fmt.Errorf("read the release response: %w", err) } return body, nil } diff --git a/internal/update/install.go b/internal/update/install.go index 82082e161b..320caaa644 100644 --- a/internal/update/install.go +++ b/internal/update/install.go @@ -119,6 +119,7 @@ func installRepositories(first string) []string { } func (c *Client) downloadRelease(ctx context.Context, release Release, asset, goos, goarch, extension string) ([]byte, string, []byte, error) { + shownAsset := asset body, err := c.get(ctx, c.assetURL(release, asset), "application/octet-stream", false) if isStatus(err, http.StatusNotFound) { legacyAsset := "aforge-" + goos + "-" + goarch + extension // legacy-name @@ -128,11 +129,11 @@ func (c *Client) downloadRelease(ctx context.Context, release Release, asset, go } } if err != nil { - return nil, asset, nil, err + return nil, asset, nil, nameReleaseResource(err, shownAsset) } checksums, err := c.get(ctx, c.assetURL(release, "checksums.txt"), "application/octet-stream", false) if err != nil { - return nil, asset, nil, err + return nil, asset, nil, nameReleaseResource(err, "checksums.txt") } return body, asset, checksums, nil } From 79230ec665a0f5207c01396606e60ecda7a277d4 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 17 Sep 2026 09:28:43 -0400 Subject: [PATCH 11/11] update: the curl line is agentfield.ai/get/codeaf, the supported road The launch line, the refusals and the terminal door offered the raw GitHub address of scripts/install.sh. The repository is public and agentfield.ai/get/codeaf has served that script from main since 2026-09-16, so the constant now spells the supported line, the manual's update section names it in place of "the curl line below", and the change entry says so. Asked for on the pull request by Santosh. Co-Authored-By: Claude Fable 5.1 --- docs/changes/unreleased/1060-update-check.md | 4 ++++ internal/manual/chat/running-from-the-terminal.md | 6 +++--- internal/update/client.go | 10 +++++++--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/changes/unreleased/1060-update-check.md b/docs/changes/unreleased/1060-update-check.md index e18d16cbf4..7c7f5321a5 100644 --- a/docs/changes/unreleased/1060-update-check.md +++ b/docs/changes/unreleased/1060-update-check.md @@ -24,3 +24,7 @@ thing nobody asked for. A developer's own build is never told it is behind. The restart waits for the door, after the frame is down and the terminal is back, and carries the transcript so the conversation the person was having is the one that comes back. + +The curl line every one of those roads offers is `curl -fsSL https://agentfield.ai/get/codeaf | bash`, +the supported address that serves `scripts/install.sh` from `main`, so the launch +line, the refusals and the README hand out one road. diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index 0b765d61e4..1eb3ba46b3 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -27,9 +27,9 @@ tag. `codeaf update` installs stable by default; at `v0.2.0`, it exits 2 with `this codeaf is v0.3.0, ahead of the newest stable v0.2.0 — pass --version v0.2.0 to install it anyway`. `--version v0.2.0` installs what was named. A source build refuses and names its path: rebuild with `make -build`, or use the curl line below. An unwritable target, failed download or bad -checksum leaves the original in place and offers that line; codeaf never tries -sudo. +build`, or install a release with `curl -fsSL https://agentfield.ai/get/codeaf | bash`. +An unwritable target, failed download or bad checksum leaves the original in +place and offers that same line; codeaf never tries sudo. ## The curl installer — dev, staging, rc and stable channels diff --git a/internal/update/client.go b/internal/update/client.go index 8a98aed069..1607932925 100644 --- a/internal/update/client.go +++ b/internal/update/client.go @@ -15,9 +15,13 @@ import ( ) const ( - // CurlCommand is the independent installation road shown after a failed or - // unavailable in-place update. - CurlCommand = "curl -fsSL https://raw.githubusercontent.com/Agent-Field/codeaf/main/scripts/install.sh | bash" + // CurlCommand is the independent installation road shown at launch beside + // /update and after a failed or unavailable in-place update. IT IS THE + // SUPPORTED LINE, NOT THE SCRIPT UNDER IT: agentfield.ai/get/codeaf serves + // scripts/install.sh from main, and the address is the one a person can + // remember and the one every other place codeaf documents spells, so a + // launch line and the README can never hand out two different roads. + CurlCommand = "curl -fsSL https://agentfield.ai/get/codeaf | bash" NoUpdateCheckEnv = "CODEAF_NO_UPDATE_CHECK" GitHubAPIEnv = "CODEAF_GITHUB_API"