carlos / platform Public

Merge pull request #163 from carlosframework/worktree-proactive-cli

Proactive CLI: moved consoles explained, logins offered, self-update (#160 #161 #162)
Paul Campbell e52d8ecc3e2e2813cff348bc144b7c37c04fb4db parent 9d1084eparent 557d13b
9 files changed, +1294 −13
  • account_choose.go +37 −0
  • apimode.go +164 −6
  • apimode_test.go +291 −0
  • auth.go +43 −7
  • config.go +3 −0
  • docs/superpowers/specs/2026-08-12-proactive-cli-design.md +128 −0
  • main.go +9 −0
  • update.go +358 −0
  • update_test.go +261 −0
diff --git a/account_choose.go b/account_choose.go
index 1f4594be4..185083a39 100644
--- a/account_choose.go
+++ b/account_choose.go
@@ -49,6 +49,37 @@ var interactiveTerminal = func() bool {
// tests for the same reason interactiveTerminal is a var.
var chooserIn io.Reader = os.Stdin
+// chooserSc is the ONE scanner over chooserIn, rebound when a test swaps
+// the reader. One scanner, not one per prompt: a Scanner buffers ahead, so
+// a second Scanner on the same reader would find the bytes the first one
+// already swallowed — the second answer in "y\ny\n" simply vanishes.
+var chooserSc *bufio.Scanner
+var chooserScFor io.Reader
+
+func chooserScanner() *bufio.Scanner {
+ if chooserSc == nil || chooserScFor != chooserIn {
+ chooserSc = bufio.NewScanner(chooserIn)
+ chooserScFor = chooserIn
+ }
+ return chooserSc
+}
+
+// promptLine prints one prompt and reads one trimmed line; EOF answers "".
+func promptLine(out io.Writer, prompt string) string {
+ fmt.Fprint(out, prompt)
+ sc := chooserScanner()
+ if !sc.Scan() {
+ return ""
+ }
+ return strings.TrimSpace(sc.Text())
+}
+
+// promptYes asks a y/N question; only y/Y is yes, EOF and everything else
+// is no.
+func promptYes(out io.Writer, prompt string) bool {
+ return strings.EqualFold(promptLine(out, prompt), "y")
+}
+
// chooseAccount asks which account an omitted -account means, then — when
// saveDest names a file — whether to remember the answer there (the caller
// writes the config; this function only asks). An empty saveDest skips the
@@ -64,6 +95,12 @@ func chooseAccount(in io.Reader, out io.Writer, console string, accts []apiAccou
fmt.Fprintf(out, " %d. %s (%s)\n", i+1, a.Name, a.Sqid)
}
sc := bufio.NewScanner(in)
+ if in == chooserIn {
+ // The process-wide input shares the process-wide scanner — a private
+ // one here would buffer past the chooser's answers and starve any
+ // later promptLine (see chooserScanner).
+ sc = chooserScanner()
+ }
var picked *apiAccountEntry
for attempt := 0; attempt < 2 && picked == nil; attempt++ {
fmt.Fprintf(out, "account [1-%d]: ", len(accts))
diff --git a/apimode.go b/apimode.go
index 2eb5a1b91..3dffd2b6e 100644
--- a/apimode.go
+++ b/apimode.go
@@ -97,14 +97,20 @@ func resolveMode(consoleFlag string) (*apiSession, error) {
// them here would tell someone who has in fact logged in before
// that nothing is configured at all.
if expired := expiredCredentialFirst(); expired != nil {
- return nil, fmt.Errorf("the credential for %s expired — run: carlos auth login -console %s",
- expired.Console, expired.Console)
+ err = &notLoggedInError{Console: expired.Console, Expired: true,
+ msg: fmt.Sprintf("the credential for %s expired — run: carlos auth login -console %s",
+ expired.Console, expired.Console)}
+ } else {
+ err = errNoDeploymentTarget
}
- return nil, fmt.Errorf("no deployment target configured: " +
- "set CARLOS_DEPLOYMENT_BUCKET (or CARLOS_DEPLOYMENT_DIR) for direct bucket access, " +
- "or run carlos auth login -console <url> to work through a console")
}
- return nil, err
+ // A terminal gets the remedy offered, not just named (#162): log in
+ // right here and CONTINUE the command the user actually typed.
+ // Scripts, n, and EOF keep the exact errors built above.
+ cred, err = offerLogin(err, consoleFlag)
+ if err != nil {
+ return nil, err
+ }
}
return &apiSession{
c: newConsoleClient(cred.Console, cred.Token),
@@ -113,6 +119,143 @@ func resolveMode(consoleFlag string) (*apiSession, error) {
}, nil
}
+// consoleMovedError is a permanent-redirect answer from a console (#160):
+// the host this CLI was pointed at now lives somewhere else, and the
+// message carries the whole story — a script that hits it needs no oracle.
+type consoleMovedError struct{ From, To string }
+
+func (e *consoleMovedError) Error() string {
+ return fmt.Sprintf("this console has moved: %s -> %s — run: carlos auth login -console %s",
+ e.From, e.To, e.To)
+}
+
+// consoleMoved inspects one response for a console-shaped redirect. Only a
+// PERMANENT redirect (301/308) whose Location names an ABSOLUTE origin
+// normalizeConsole would accept (https, or http strictly on-box) and that
+// differs from the one dialed counts — 302/307 are what SSO walls and
+// identity proxies answer with, and reading those as "the console moved"
+// would offer a login to accounts.google.com; a same-origin or path-level
+// redirect stays a plain unexpected status; and a Location the CLI would
+// refuse to log in to is never surfaced as a destination. The Location is
+// trusted for DISPLAY AND PROMPTING only: nothing follows it, and no
+// credential exists until the human confirms the device flow against it.
+func consoleMoved(from string, status int, location string) *consoleMovedError {
+ switch status {
+ case http.StatusMovedPermanently, http.StatusPermanentRedirect:
+ default:
+ return nil
+ }
+ u, err := url.Parse(location)
+ if err != nil || !u.IsAbs() || u.Host == "" {
+ return nil
+ }
+ origin, err := normalizeConsole(u.Scheme + "://" + u.Host)
+ if err != nil || origin == from {
+ return nil
+ }
+ return &consoleMovedError{From: from, To: origin}
+}
+
+// consoleMovedOffered makes the offer once-per-process: polling verbs
+// (`logs -f`, authLogin's approval loop) hit the same 3xx every interval,
+// and prompting on each would eat the terminal's stdin; and the offer
+// itself runs authLogin, whose polls reach these same response sites — the
+// guard is also the re-entrancy stop for a redirect chain. After the one
+// offer (taken or declined), every further hit is the typed error.
+var consoleMovedOffered bool
+
+// quietConsoleOffers suppresses the offer entirely — set around calls
+// whose contract is to degrade silently (whoami's memberships bonus line),
+// where even one prompt would make a working command look broken.
+var quietConsoleOffers bool
+
+// offerConsoleMoved is the terminal's version of consoleMovedError: explain
+// the move, offer the standard device flow against the new host, and — when
+// a .carlos/config names the old console — offer to repoint it (keeping
+// every other key, account included: the pair moves together). The verb
+// still ends nonzero: its request died against the old host mid-flight, and
+// "logged in — re-run your command" is the honest state.
+func offerConsoleMoved(e *consoleMovedError) error {
+ if !interactiveTerminal() || quietConsoleOffers || consoleMovedOffered {
+ return e
+ }
+ consoleMovedOffered = true
+ fmt.Fprintf(stdout, "this console has moved: %s -> %s\n", e.From, e.To)
+ if !promptYes(stdout, fmt.Sprintf("log in to %s now? [y/N] ", e.To)) {
+ return e
+ }
+ if err := authLogin(stdout, []string{"-console", e.To}); err != nil {
+ return err
+ }
+ if cfgConsole, src, err := configDefault(); err == nil && cfgConsole == e.From && src != "" {
+ // The destination is named in the prompt, not only the file: a y
+ // here changes where future ships go, and that must never rest on
+ // remembering what the previous prompt said.
+ if promptYes(stdout, fmt.Sprintf("update %s to point at %s too? [y/N] ", src, e.To)) {
+ if err := saveConfigFile(src, e.To, 0o755); err != nil {
+ warnf(stdout, "not updated (%s): %v", src, err)
+ } else {
+ mutedf(stdout, "updated: %s now names %s", src, e.To)
+ }
+ }
+ }
+ return fmt.Errorf("logged in to %s — re-run your command against it", e.To)
+}
+
+// errNoDeploymentTarget is resolveMode's "nothing configured at all"
+// refusal — a var so offerLogin can recognize it by identity rather than
+// by matching its wording.
+var errNoDeploymentTarget = errors.New("no deployment target configured: " +
+ "set CARLOS_DEPLOYMENT_BUCKET (or CARLOS_DEPLOYMENT_DIR) for direct bucket access, " +
+ "or run carlos auth login -console <url> to work through a console")
+
+// offerLogin is the interactive half of resolveMode's refusals (#162). On
+// a terminal, a refusal that names a console offers to run the device flow
+// against it right now; the nothing-configured refusal asks for a URL
+// (typing one IS the consent — no second prompt). Everything else — and
+// every non-terminal caller — gets the original error back verbatim. The
+// login flow itself is authLogin, unchanged: browser approval, per-origin
+// credential, no shortcuts.
+func offerLogin(origErr error, consoleFlag string) (*credential, error) {
+ if !interactiveTerminal() {
+ return nil, origErr
+ }
+ var target string
+ var nle *notLoggedInError
+ switch {
+ case errors.As(origErr, &nle):
+ if nle.Expired {
+ fmt.Fprintf(stdout, "your login for %s has expired\n", nle.Console)
+ if !promptYes(stdout, "log in again now? [y/N] ") {
+ return nil, origErr
+ }
+ } else {
+ fmt.Fprintf(stdout, "not logged in to %s\n", nle.Console)
+ if !promptYes(stdout, "log in now? [y/N] ") {
+ return nil, origErr
+ }
+ }
+ target = nle.Console
+ case errors.Is(origErr, errNoDeploymentTarget):
+ fmt.Fprintln(stdout, "no console configured and no login found.")
+ raw := promptLine(stdout, "console URL (e.g. https://console.carloku.com): ")
+ if raw == "" {
+ return nil, origErr
+ }
+ n, err := normalizeConsole(raw)
+ if err != nil {
+ return nil, err
+ }
+ target = n
+ default:
+ return nil, origErr
+ }
+ if err := authLogin(stdout, []string{"-console", target}); err != nil {
+ return nil, err
+ }
+ return loggedInCredential(consoleFlag)
+}
+
// noCredentialsMsg is (*credentialFile).pick's exact wording (auth.go) for
// "no -console given (and no CARLOS_CONSOLE either) and there is nothing
// live to pick from" — the one pick() refusal that never mentions direct
@@ -422,6 +565,9 @@ func (s *apiSession) getJSON(ctx context.Context, path string) (int, []byte, err
return 0, nil, err
}
defer res.Body.Close()
+ if merr := consoleMoved(s.c.base, res.StatusCode, res.Header.Get("Location")); merr != nil {
+ return res.StatusCode, nil, offerConsoleMoved(merr)
+ }
body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return res.StatusCode, nil, err
@@ -452,6 +598,9 @@ func (s *apiSession) postJSON(ctx context.Context, path string, in any) (int, []
return 0, nil, err
}
defer res.Body.Close()
+ if merr := consoleMoved(s.c.base, res.StatusCode, res.Header.Get("Location")); merr != nil {
+ return res.StatusCode, nil, offerConsoleMoved(merr)
+ }
body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return res.StatusCode, nil, err
@@ -1141,6 +1290,9 @@ func (s *apiSession) ledgerBlob(ctx context.Context, a apiAcct, app, name string
return "", err
}
defer res.Body.Close()
+ if merr := consoleMoved(s.c.base, res.StatusCode, res.Header.Get("Location")); merr != nil {
+ return "", offerConsoleMoved(merr)
+ }
respBody, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return "", err
@@ -1180,6 +1332,9 @@ func (s *apiSession) putJSON(ctx context.Context, path string, in any) (int, []b
return 0, nil, err
}
defer res.Body.Close()
+ if merr := consoleMoved(s.c.base, res.StatusCode, res.Header.Get("Location")); merr != nil {
+ return res.StatusCode, nil, offerConsoleMoved(merr)
+ }
body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return res.StatusCode, nil, err
@@ -1341,6 +1496,9 @@ func (s *apiSession) deleteJSON(ctx context.Context, path string) (int, []byte,
return 0, nil, err
}
defer res.Body.Close()
+ if merr := consoleMoved(s.c.base, res.StatusCode, res.Header.Get("Location")); merr != nil {
+ return res.StatusCode, nil, offerConsoleMoved(merr)
+ }
body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return res.StatusCode, nil, err
diff --git a/apimode_test.go b/apimode_test.go
index 5b1fd4415..6faeab651 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -114,6 +114,7 @@ func TestResolveModeAPI(t *testing.T) {
func TestResolveModeNothing(t *testing.T) {
useTempCredentials(t)
clearDeploymentEnv(t)
+ forceTerminal(t, false) // pin: this asserts the NON-interactive refusal
sess, err := resolveMode("")
if sess != nil {
@@ -137,6 +138,7 @@ func TestResolveModeNothing(t *testing.T) {
func TestResolveModeExpiredCredentialOnly(t *testing.T) {
useTempCredentials(t)
clearDeploymentEnv(t)
+ forceTerminal(t, false) // pin: this asserts the NON-interactive refusal
f, _ := loadCredentials()
f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "t1", ExpiresAt: time.Now().Add(-time.Hour)})
@@ -3006,6 +3008,295 @@ func TestDefaultAccountConfigDefault(t *testing.T) {
}
}
+func TestConsoleMovedDetection(t *testing.T) {
+ from := "https://dashboard.example"
+ // The real case: a permanent redirect to a new https origin.
+ if e := consoleMoved(from, 308, "https://console.example/api/cli/apps"); e == nil ||
+ e.To != "https://console.example" {
+ t.Fatalf("308 = %+v, want console.example", e)
+ }
+ want := "this console has moved: https://dashboard.example -> https://console.example — run: carlos auth login -console https://console.example"
+ if e := consoleMoved(from, 301, "https://console.example/"); e == nil || e.Error() != want {
+ t.Fatalf("moved error = %v\nwant %q", e, want)
+ }
+ // Everything that must NOT read as "moved" — including TEMPORARY
+ // redirects: 302/307 are what SSO walls answer with, and "log in to
+ // accounts.google.com" is not a remedy.
+ for name, tc := range map[string]struct {
+ status int
+ location string
+ }{
+ "non-redirect status": {404, "https://console.example"},
+ "302 sso wall": {302, "https://accounts.google.example"},
+ "307 temporary": {307, "https://console.example"},
+ "no location": {308, ""},
+ "relative location": {308, "/somewhere"},
+ "same origin": {308, from + "/new-path"},
+ "http off-box target": {308, "http://console.example"},
+ "garbage location": {308, "::not a url::"},
+ "schemeless authority": {308, "console.example/x"},
+ } {
+ if e := consoleMoved(from, tc.status, tc.location); e != nil {
+ t.Errorf("%s = %+v, want nil", name, e)
+ }
+ }
+}
+
+func TestRedirectsAreNeverFollowed(t *testing.T) {
+ useTempCredentials(t)
+ captureStdout(t)
+ forceTerminal(t, false)
+ // A "new" console that would answer if the client followed — its
+ // handler must never run.
+ followed := false
+ target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ followed = true
+ w.Write([]byte(`{}`))
+ }))
+ t.Cleanup(target.Close)
+ old := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Location", target.URL+r.URL.Path)
+ w.WriteHeader(http.StatusPermanentRedirect)
+ }))
+ t.Cleanup(old.Close)
+
+ s := newTestSession(old.URL)
+ // GET (the case Go's default policy would have silently followed with
+ // Authorization stripped) and POST both surface the typed error.
+ _, _, err := s.getJSON(context.Background(), "/api/cli/apps")
+ if err == nil || !strings.Contains(err.Error(), "this console has moved: "+old.URL+" -> "+target.URL) {
+ t.Fatalf("GET through a redirect = %v", err)
+ }
+ _, _, err = s.postJSON(context.Background(), "/api/cli/apps/a/b/promote", map[string]string{})
+ if err == nil || !strings.Contains(err.Error(), "this console has moved") {
+ t.Fatalf("POST through a redirect = %v", err)
+ }
+ if followed {
+ t.Fatal("the client followed a redirect — Authorization would have crossed hosts")
+ }
+}
+
+// resetMovedOffer clears the once-per-process offer guard for one test.
+func resetMovedOffer(t *testing.T) {
+ t.Helper()
+ consoleMovedOffered = false
+ t.Cleanup(func() { consoleMovedOffered = false })
+}
+
+func TestConsoleMovedOfferLogsInAndRepointsConfig(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ out := captureStdout(t)
+ forceTerminal(t, true)
+ resetMovedOffer(t)
+ fc := newFakeConsole(t) // the console's NEW home — real device flow
+ old := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Location", fc.ts.URL+r.URL.Path)
+ w.WriteHeader(http.StatusPermanentRedirect)
+ }))
+ t.Cleanup(old.Close)
+ cfgPath := writeConfig(t, ".", `{"console": "`+old.URL+`", "account": "acme"}`)
+ oldIn := chooserIn
+ chooserIn = strings.NewReader("y\ny\n") // log in? yes. repoint config? yes.
+ t.Cleanup(func() { chooserIn = oldIn })
+
+ s := newTestSession(old.URL)
+ _, _, err := s.getJSON(context.Background(), "/api/cli/apps")
+ if err == nil || !strings.Contains(err.Error(), "re-run your command") {
+ t.Fatalf("offer accepted = %v, want the re-run message", err)
+ }
+ // The device flow really ran against the new host…
+ f, ferr := loadCredentials()
+ if ferr != nil || f.find(fc.ts.URL) == nil {
+ t.Fatalf("no credential for the new console after the offer: %v", ferr)
+ }
+ // …and the config was repointed, keeping the account key.
+ cfg, cerr := loadConfig(cfgPath)
+ if cerr != nil || cfg.Console != fc.ts.URL || cfg.Account != "acme" {
+ t.Fatalf("config after repoint = %+v, %v; want new console + acme kept", cfg, cerr)
+ }
+ if !strings.Contains(out.String(), "this console has moved") {
+ t.Fatalf("the move must be explained:\n%s", out.String())
+ }
+ if !strings.Contains(out.String(), "to point at "+fc.ts.URL) {
+ t.Fatalf("the repoint prompt must name the destination:\n%s", out.String())
+ }
+
+ // The offer already ran once this process: a second hit — a polling
+ // verb's next tick — gets the typed error and NO prompt, even with
+ // answers queued up.
+ out.Reset()
+ chooserIn = strings.NewReader("y\ny\n")
+ _, _, err = s.getJSON(context.Background(), "/api/cli/apps")
+ if err == nil || !strings.Contains(err.Error(), "this console has moved") {
+ t.Fatalf("second hit = %v, want the plain typed error", err)
+ }
+ if strings.Contains(out.String(), "[y/N]") {
+ t.Fatalf("a second prompt fired:\n%s", out.String())
+ }
+}
+
+// whoami's memberships line degrades silently even through a MOVED console
+// — a bonus line must never turn into a prompt (the quietConsoleOffers
+// suppression).
+func TestWhoamiStaysSilentThroughAMovedConsole(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ captureStdout(t)
+ forceTerminal(t, true)
+ resetMovedOffer(t)
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /auth/cli/whoami", func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{"address": "tom@keymail.dev"})
+ })
+ mux.HandleFunc("GET /api/cli/apps", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Location", "https://console.elsewhere.example/api/cli/apps")
+ w.WriteHeader(http.StatusPermanentRedirect)
+ })
+ ts := httptest.NewServer(mux)
+ t.Cleanup(ts.Close)
+ f, _ := loadCredentials()
+ f.put(credential{Console: ts.URL, Address: "tom@keymail.dev", Token: "tok",
+ CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)})
+ if err := f.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ var out bytes.Buffer
+ if err := authWhoami(&out, []string{"-console", ts.URL}); err != nil {
+ t.Fatalf("whoami through a moved memberships endpoint: %v", err)
+ }
+ if strings.Contains(out.String(), "moved") || strings.Contains(out.String(), "[y/N]") ||
+ strings.Contains(out.String(), "accounts:") {
+ t.Fatalf("whoami must degrade to identity-only silence:\n%s", out.String())
+ }
+ if consoleMovedOffered {
+ t.Fatal("whoami consumed the once-per-process offer")
+ }
+}
+
+// resolveMode on a terminal offers to run the login it would otherwise
+// only name — and then the ORIGINAL command continues (#162).
+func TestResolveModeOffersLoginAndContinues(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ captureStdout(t)
+ forceTerminal(t, true)
+ fc := newFakeConsole(t)
+ writeConfig(t, ".", `{"console": "`+fc.ts.URL+`"}`)
+ oldIn := chooserIn
+ chooserIn = strings.NewReader("y\n")
+ t.Cleanup(func() { chooserIn = oldIn })
+
+ sess, err := resolveMode("")
+ if err != nil || sess == nil {
+ t.Fatalf("resolveMode after offered login = %v, %v", sess, err)
+ }
+ if sess.console != fc.ts.URL || sess.address != "paul@keymail.dev" {
+ t.Fatalf("session = %s as %s, want the fake console as paul", sess.console, sess.address)
+ }
+}
+
+func TestResolveModeOfferDeclinedKeepsTheError(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ captureStdout(t)
+ forceTerminal(t, true)
+ fc := newFakeConsole(t)
+ writeConfig(t, ".", `{"console": "`+fc.ts.URL+`"}`)
+ oldIn := chooserIn
+ chooserIn = strings.NewReader("n\n")
+ t.Cleanup(func() { chooserIn = oldIn })
+
+ _, err := resolveMode("")
+ if err == nil || !strings.Contains(err.Error(), "not logged in to "+fc.ts.URL) ||
+ !strings.Contains(err.Error(), "carlos auth login -console "+fc.ts.URL) {
+ t.Fatalf("declined offer = %v, want the original refusal verbatim", err)
+ }
+}
+
+func TestResolveModeNonTTYErrorsUnchanged(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ captureStdout(t)
+ forceTerminal(t, false)
+ fc := newFakeConsole(t)
+ writeConfig(t, ".", `{"console": "`+fc.ts.URL+`"}`)
+
+ // No prompt machinery runs at all: scripts get the string they have
+ // always matched.
+ _, err := resolveMode("")
+ if err == nil || !strings.Contains(err.Error(), "not logged in to "+fc.ts.URL) {
+ t.Fatalf("non-TTY = %v, want today's refusal", err)
+ }
+
+ // And the nothing-configured case keeps ITS exact wording too.
+ os.RemoveAll(".carlos")
+ if _, err := resolveMode(""); err == nil ||
+ !strings.Contains(err.Error(), "no deployment target configured") {
+ t.Fatalf("non-TTY unconfigured = %v", err)
+ }
+}
+
+func TestResolveModeExpiredOffersRelogin(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ out := captureStdout(t)
+ forceTerminal(t, true)
+ fc := newFakeConsole(t)
+ f, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "stale",
+ CreatedAt: time.Now().Add(-48 * time.Hour), ExpiresAt: time.Now().Add(-time.Hour)})
+ if err := f.save(); err != nil {
+ t.Fatal(err)
+ }
+ oldIn := chooserIn
+ chooserIn = strings.NewReader("y\n")
+ t.Cleanup(func() { chooserIn = oldIn })
+
+ // Only one credential and it is expired — pick's "" branch reaches the
+ // expired-first rewrite, which must offer a re-login, and the fresh
+ // credential must carry the NEW token.
+ sess, err := resolveMode("")
+ if err != nil || sess == nil || sess.console != fc.ts.URL {
+ t.Fatalf("expired relogin = %v, %v", sess, err)
+ }
+ if !strings.Contains(out.String(), "has expired") {
+ t.Fatalf("the expiry must be said before the offer:\n%s", out.String())
+ }
+}
+
+func TestResolveModeUnconfiguredPromptsForURL(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ captureStdout(t)
+ forceTerminal(t, true)
+ fc := newFakeConsole(t)
+ oldIn := chooserIn
+ chooserIn = strings.NewReader(fc.ts.URL + "\n")
+ t.Cleanup(func() { chooserIn = oldIn })
+
+ // An empty answer keeps the original two-remedies error. (Checked
+ // FIRST — a successful login below leaves a credential behind, and
+ // resolveMode would simply use it.)
+ chooserIn = strings.NewReader("\n")
+ if _, err := resolveMode(""); err == nil ||
+ !strings.Contains(err.Error(), "no deployment target configured") {
+ t.Fatalf("empty URL = %v", err)
+ }
+
+ // Nothing configured anywhere: the URL prompt IS the consent — typing
+ // one goes straight into the login flow.
+ chooserIn = strings.NewReader(fc.ts.URL + "\n")
+ sess, err := resolveMode("")
+ if err != nil || sess == nil || sess.console != fc.ts.URL {
+ t.Fatalf("URL-prompt login = %v, %v", sess, err)
+ }
+}
+
func TestResolvedAccountExplicitFlagsBypass(t *testing.T) {
useTempCredentials(t)
captureStdout(t)
diff --git a/auth.go b/auth.go
index de3a1e22e..81c66d55f 100644
--- a/auth.go
+++ b/auth.go
@@ -285,11 +285,13 @@ func (f *credentialFile) pick(console string) (*credential, error) {
}
c := f.find(want)
if c == nil {
- return nil, fmt.Errorf("not logged in to %s%s — run: carlos auth login -console %s", want, from, want)
+ return nil, &notLoggedInError{Console: want,
+ msg: fmt.Sprintf("not logged in to %s%s — run: carlos auth login -console %s", want, from, want)}
}
if c.Expired(time.Now()) {
- return nil, fmt.Errorf("the credential for %s%s expired %s — run: carlos auth login -console %s",
- want, from, c.ExpiresAt.Format(time.RFC3339), want)
+ return nil, &notLoggedInError{Console: want, Expired: true,
+ msg: fmt.Sprintf("the credential for %s%s expired %s — run: carlos auth login -console %s",
+ want, from, c.ExpiresAt.Format(time.RFC3339), want)}
}
return c, nil
}
@@ -316,6 +318,18 @@ func (f *credentialFile) pick(console string) (*credential, error) {
}
}
+// notLoggedInError is pick's refusal when a NAMED console has no live
+// credential — typed so resolveMode's interactive path (#162) can see
+// which console to offer a login for, while Error() stays byte-identical
+// to the strings scripts have matched since the credential file existed.
+type notLoggedInError struct {
+ Console string
+ Expired bool
+ msg string
+}
+
+func (e *notLoggedInError) Error() string { return e.msg }
+
// credentialPermWarning reports a credentials file other users can read.
// A warning rather than a refusal: locking someone out of their own tooling
// over a mode bit helps nobody, but saying nothing lets a world-readable
@@ -379,7 +393,15 @@ type consoleClient struct {
}
func newConsoleClient(base, token string) *consoleClient {
- return &consoleClient{base: base, token: token, httpc: &http.Client{Timeout: 30 * time.Second}}
+ // Redirects are NEVER followed (#160): Go's default policy follows a
+ // cross-host GET with Authorization stripped (a mystery 401 from the
+ // new host) and surfaces unreplayable POSTs as raw 3xx — both wrong.
+ // Refusing uniformly lets every method see the 3xx + Location, which
+ // consoleMoved (apimode.go) turns into an explanation and an offer.
+ return &consoleClient{base: base, token: token, httpc: &http.Client{
+ Timeout: 30 * time.Second,
+ CheckRedirect: neverFollow,
+ }}
}
// do runs one JSON round-trip. A non-200 comes back carrying the console's
@@ -409,6 +431,9 @@ func (c *consoleClient) do(ctx context.Context, method, path string, in, out any
return 0, err
}
defer res.Body.Close()
+ if merr := consoleMoved(c.base, res.StatusCode, res.Header.Get("Location")); merr != nil {
+ return res.StatusCode, offerConsoleMoved(merr)
+ }
raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return res.StatusCode, err
@@ -482,13 +507,16 @@ func (c *consoleClient) doMultipart(ctx context.Context, path string, write func
}
// A client of its own — see shipHTTPClientTimeout above — never c.httpc,
// whose 30s absolute Timeout is sized for the JSON verbs, not a large
- // upload.
- client := &http.Client{Timeout: shipHTTPClientTimeout}
+ // upload. Same no-redirect rule as c.httpc, same reason.
+ client := &http.Client{Timeout: shipHTTPClientTimeout, CheckRedirect: neverFollow}
res, err := client.Do(req)
if err != nil {
return 0, nil, err
}
defer res.Body.Close()
+ if merr := consoleMoved(c.base, res.StatusCode, res.Header.Get("Location")); merr != nil {
+ return res.StatusCode, nil, offerConsoleMoved(merr)
+ }
body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return res.StatusCode, nil, err
@@ -496,6 +524,11 @@ func (c *consoleClient) doMultipart(ctx context.Context, path string, write func
return res.StatusCode, body, nil
}
+// neverFollow is the one redirect policy every console client uses.
+func neverFollow(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+}
+
type startResponse struct {
UserCode string `json:"user_code"`
DeviceCode string `json:"device_code"`
@@ -735,7 +768,10 @@ func authWhoami(out io.Writer, args []string) error {
// Memberships are a bonus line, not part of whoami's contract: identity
// already printed, and a console from before GET /api/cli/apps (or a
// blip fetching it) must not make whoami look broken — so ANY error
- // here degrades to silence rather than a message.
+ // here degrades to silence rather than a message, and the moved-console
+ // OFFER is suppressed too: a bonus line must never turn into a prompt.
+ quietConsoleOffers = true
+ defer func() { quietConsoleOffers = false }()
var acc struct {
Accounts []apiAccountEntry `json:"accounts"`
}
diff --git a/config.go b/config.go
index 2b5c60370..4ea31d193 100644
--- a/config.go
+++ b/config.go
@@ -42,6 +42,9 @@ const defaultConsole = "https://platform.carlosframework.com"
type cliConfig struct {
Console string `json:"console"`
Account string `json:"account"`
+ // UpdateCheck: "off" disables the once-a-day newer-release hint
+ // (update.go). Any other value — including absent — leaves it on.
+ UpdateCheck string `json:"update_check"`
}
// loadConfig reads one .carlos/config whole. A missing file is a zero
diff --git a/docs/superpowers/specs/2026-08-12-proactive-cli-design.md b/docs/superpowers/specs/2026-08-12-proactive-cli-design.md
new file mode 100644
index 000000000..0087b81f1
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-12-proactive-cli-design.md
@@ -0,0 +1,128 @@
+# Proactive CLI: moved consoles, stale binaries, missing logins
+
+**Date:** 2026-08-12 · **Issues:** #160, #161, #162 · **Status:** approved
+(Paul, in-session: "let's get all that done"; the issues carry the designs)
+
+The direction (Paul, 2026-08-12): the CLI should never surface a bare
+status code or a dead end a person has to ask an oracle about. When it can
+see both the problem and the remedy, it explains the problem and — on a
+terminal — offers to perform the remedy. Scripts keep today's exact errors:
+they are load-bearing (`noCredentialsMsg` is string-matched) and a script
+cannot answer a prompt. All prompts ride the #159 machinery
+(`interactiveTerminal`, `chooserIn`, y/N wording).
+
+## 1. Console redirects (#160)
+
+**Today** the client follows Go's default redirect policy: GETs follow
+cross-host (Go strips Authorization → a mystery 401 from the new host),
+POSTs with unreplayable bodies surface the raw status ("unexpected
+response (status 308)" — Tom's case). Both are wrong.
+
+- **Never follow**: `CheckRedirect: http.ErrUseLastResponse` on the one
+ `consoleClient` http.Client. Every method now deterministically sees the
+ 3xx + `Location`.
+- **Typed**: 301/302/307/308 with a Location whose origin
+ `normalizeConsole` accepts becomes `consoleMovedError{From, To}` at the
+ response sites (`consoleClient.do`, `doMultipart`, `apiSession.getJSON`,
+ `postJSON`). Its `Error()` text carries the whole story for scripts:
+ `this console has moved: <from> -> <to> — run: carlos auth login -console <to>`.
+ A 3xx without a usable Location falls back to today's "unexpected
+ response" wording.
+- **Interactive**: where the typed error is born, a TTY gets:
+
+ this console has moved: https://dashboard.x -> https://console.x
+ log in to https://console.x now? [y/N]
+
+ y runs the standard device flow (`authLogin -console <to>` — browser
+ approval unchanged, credential minted per-origin, nothing reused). If a
+ `.carlos/config` on the walk names the old console, one more offer
+ rewrites it (`writeConfigKeys`, preserving everything else). The verb
+ then exits nonzero with `logged in to <to> — re-run your command` — the
+ original request died mid-flight against the old host; continuation is
+ #162's territory, not this one's.
+- Location is trusted for **display and prompting only**, the user
+ confirms before any credential exists, and `normalizeConsole`'s rules
+ (https, or http strictly on-box) gate the target.
+
+## 2. Offer to log in (#162)
+
+One seam: every API verb reaches credentials through `resolveMode` →
+`loggedInCredential` → `pick`. The two pick refusals that already name a
+console — "not logged in to X" and "the credential for X expired" — become
+a typed `notLoggedInError{Console, Expired}` with byte-identical message
+strings. On a TTY, `resolveMode`:
+
+ not logged in to https://console.carloku.com
+ log in now? [y/N]
+
+y runs `authLogin` inline against that console, reloads credentials,
+retries once, and **returns a working session — the original command
+continues**; the user typed `carlos ship` and a ship is what happens.
+The bare "no deployment target configured" case has no console to offer:
+a TTY is prompted `console URL: ` (validated by `normalizeConsole`, empty
+answer = today's error), then the same login-and-continue. n/EOF, non-TTY,
+and direct mode keep today's errors verbatim. The login flow itself is
+untouched — no shortcuts around the browser approval or step-up.
+
+## 3. Self-update (#161)
+
+**Detection** — passive, our infra, never blocking:
+- `latest.txt` (one line, `v0.7.0`) joins the apt static app; the check is
+ one GET to `https://apt.carlosframework.com/latest.txt` with a ~1.5s
+ timeout, silent on any failure.
+- Ran after a successful member verb, at most once per 24h (cache
+ `~/.carlos/update-check.json` `{checked_at, latest}`), and only when ALL
+ of: stdout is a TTY; `version.Build` is a release tag (a `dev` build
+ never checks); no `CARLOS_DEPLOYMENT_BUCKET`/`DIR`; not root; not opted
+ out (`CARLOS_NO_UPDATE_CHECK`, or `update_check: "off"` in config);
+ and the verb is not a box/daemon verb (edge, agent, adopt, bootstrap,
+ add, remove, route, ops, status, canary, vet, release-keygen).
+- The hint is one muted line:
+ `carlos v0.8.0 is available (you have v0.7.0) — run: carlos update`
+
+**`carlos update`** — detects how this binary is installed:
+- Homebrew path (`/Cellar/`, `/linuxbrew/`) → prints
+ `brew upgrade carlosframework/tap/carlos` and stops (brew owns brew).
+- dpkg owns the path (`dpkg -S`, when dpkg exists) → prints the apt
+ command and stops (apt owns apt).
+- Otherwise self-replace, y/N-gated on a TTY: download
+ `carlos-<GOOS>-<GOARCH>`, `SHA256SUMS.txt`, and `SHA256SUMS.txt.sig`
+ from the latest GitHub release; verify the sig, then the binary's
+ SHA256; write a temp file beside the current executable and rename over
+ it (the config files' crash-safety rule); a permission error names the
+ sudo-shaped fix instead of failing mid-way. Non-TTY never self-replaces.
+- Refusals: root, bucket env set (boxes roll via update-platform-binary.sh
+ and adoption — a self-updating box binary would fight the machinery),
+ `dev` builds (nothing meaningful to update to).
+
+**Integrity**: SHA256SUMS.txt gains a detached signature. A dedicated
+ed25519 CLI-update keypair (NOT the release key: different blast radius,
+different rotation story): private half at SSM `/carlos/cli-update-key`,
+public half a constant in the binary. `SHA256SUMS.txt.sig` =
+base64(ed25519.Sign(priv, file bytes)) — stdlib verify, no OpenPGP. The
+release recipe gains the signing step from v0.8.0 onward — the first
+binary that verifies is v0.8.0, and it downloads the LATEST release's
+sums, so retro-signing v0.7.0's would protect nothing.
+
+## Testing
+
+- Redirects: httptest consoles answering 3xx with/without Location, GET
+ and POST; typed error text; interactive path (forced TTY + injected
+ input) logs in against a fake new console and offers the config rewrite;
+ whoami's silent degrade still silent.
+- Login offer: resolveMode against empty/expired credentials + fake
+ console: y → session returned and original call proceeds; n/EOF/non-TTY
+ → today's exact errors (string-pinned); URL prompt path.
+- Update: check gating table (dev build, root skipped where testable,
+ bucket env, opt-outs, cache freshness, box verbs); hint text; `update`
+ verb against a fake release server — sig verify failure REFUSES, sha
+ mismatch REFUSES, happy path replaces a scratch binary; brew/dpkg paths
+ answer with the right command and touch nothing.
+- Everything through `carlosTestEnv` rules where the binary is shelled.
+
+## Out of scope
+
+- Following redirects automatically (deliberately never).
+- Auto-update without a prompt; package-manager installs self-replacing.
+- Signing the .debs themselves (the apt repo's InRelease already covers
+ apt-path integrity).
diff --git a/main.go b/main.go
index b7a85b745..57a13f569 100644
--- a/main.go
+++ b/main.go
@@ -106,12 +106,18 @@ func main() {
host.RunAdopt(args)
case "vet":
vet.Run(args)
+ case "update":
+ runUpdate(args)
case "canary":
log.Fatalf("not implemented: %s", cmd)
default:
usage(os.Stderr)
os.Exit(2)
}
+ // After a verb that ran to success: the once-a-day newer-release hint
+ // (#161). Verbs that fail never reach here — log.Fatal exits — so a
+ // broken command is never followed by an upsell.
+ maybeUpdateHint(cmd)
}
// usage prints the top-level command list, grouped by the role you are in
@@ -151,6 +157,9 @@ Your apps — ship and run software on a CARLOS deployment
events (wakes, restarts, failures); -f follows live
ledger open ledgers: append | head | list | publish | blob | verify
vet check a release against the platform contract
+ update update this carlos binary to the latest release (or name
+ the brew/apt command when a package manager owns it);
+ -y skips the prompt, terminal still required
Your deployment — stand up and administer boxes, routes and accounts
bootstrap prepare a host: units, dirs, user
diff --git a/update.go b/update.go
new file mode 100644
index 000000000..357b8e16f
--- /dev/null
+++ b/update.go
@@ -0,0 +1,358 @@
+// Self-update (#161): the binary knows its own version, the apt static
+// site publishes the latest, and carlosframework/releases holds the
+// artifacts — so a stale workstation install can notice and fix itself.
+// Everything here is workstation-only by construction: boxes (bucket env,
+// root) are rolled by update-platform-binary.sh and adoption, and a
+// self-updating box binary would fight that machinery.
+package main
+
+import (
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/version"
+)
+
+// Vars, not consts: the tests point them at httptest servers and a test
+// keypair. Production never changes them.
+var (
+ updateLatestURL = "https://apt.carlosframework.com/latest.txt"
+ // Tag-pinned, never "latest/download": the floating alias can serve an
+ // older authentic release than the one latest.txt promised, and every
+ // artifact of a self-replace must come from the ONE tag being claimed
+ // (review 2026-08-12: an authentic-but-old sums+binary pair must not
+ // be acceptable, or a caching layer can pin workstations to a
+ // known-vulnerable release without a signature error).
+ updateDownloadBase = "https://github.com/carlosframework/releases/releases/download"
+
+ // cliUpdatePubKey verifies SHA256SUMS.txt.sig on self-replace: the
+ // public half of the dedicated CLI-update keypair (private half at SSM
+ // /carlos/cli-update-key — NOT the release key; different blast
+ // radius, different rotation story).
+ cliUpdatePubKey = "R+EO3qQ47VsuKl9Qq/pkDXBucPYvzp5lU2ut4zpnJPc="
+)
+
+// releaseTag matches the version stamp of a published build ("v0.7.0").
+// Anything else — "dev" above all — never checks and never updates: there
+// is no meaningful "newer than dev".
+var releaseTag = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`)
+
+func releaseBuild(build string) bool { return releaseTag.MatchString(build) }
+
+// newerVersion reports whether latest (a release tag) is strictly newer
+// than current (also a release tag). Unparseable input is never "newer" —
+// a garbled feed must not nag anyone.
+func newerVersion(latest, current string) bool {
+ l, lok := release.ParseSemVer(strings.TrimPrefix(latest, "v"))
+ c, cok := release.ParseSemVer(strings.TrimPrefix(current, "v"))
+ return lok && cok && l.Compare(c) > 0
+}
+
+// noHintVerbs are the box/daemon/plumbing verbs the update hint stays out
+// of — either they run where updates are the platform's job, or their
+// output is parsed (version: the tap formula's test asserts on it).
+var noHintVerbs = map[string]bool{
+ "edge": true, "agent": true, "adopt": true, "bootstrap": true,
+ "add": true, "remove": true, "route": true, "ops": true,
+ "status": true, "canary": true, "vet": true, "release-keygen": true,
+ "version": true, "help": true, "-h": true, "--help": true,
+ "update": true,
+}
+
+type updateCache struct {
+ CheckedAt time.Time `json:"checked_at"`
+ Latest string `json:"latest"`
+}
+
+func updateCachePath() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(home, ".carlos", "update-check.json"), nil
+}
+
+// fetchLatest asks the apt site which release is current. One small GET
+// with its own short deadline — the caller is done with its real work, and
+// a slow feed must cost nobody anything.
+func fetchLatest(timeout time.Duration) (string, error) {
+ client := &http.Client{Timeout: timeout}
+ res, err := client.Get(updateLatestURL)
+ if err != nil {
+ return "", err
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("latest.txt: status %d", res.StatusCode)
+ }
+ raw, err := io.ReadAll(io.LimitReader(res.Body, 128))
+ if err != nil {
+ return "", err
+ }
+ tag := strings.TrimSpace(string(raw))
+ if !releaseBuild(tag) {
+ return "", fmt.Errorf("latest.txt: %q is not a release tag", tag)
+ }
+ return tag, nil
+}
+
+// maybeUpdateHint prints the one-line "a newer carlos exists" nudge after
+// a successful member verb — at most once per 24h (the network check and
+// the hint share the cache window), and only where a hint can possibly be
+// wanted: a human at a terminal, running a release build, off-box, not
+// opted out. Every failure is silence; this must never make a working
+// command look broken.
+func maybeUpdateHint(cmd string) {
+ if noHintVerbs[cmd] || !interactiveTerminal() || !releaseBuild(version.Build) {
+ return
+ }
+ if os.Getenv("CARLOS_DEPLOYMENT_BUCKET") != "" || os.Getenv("CARLOS_DEPLOYMENT_DIR") != "" {
+ return
+ }
+ if os.Geteuid() == 0 || os.Getenv("CARLOS_NO_UPDATE_CHECK") != "" {
+ return
+ }
+ if off, err := configUpdateCheckOff(); err != nil || off {
+ return
+ }
+ path, err := updateCachePath()
+ if err != nil {
+ return
+ }
+ var cache updateCache
+ if raw, err := os.ReadFile(path); err == nil {
+ // A future checked_at (clock skew, corrupt write) must re-check,
+ // not silence the hint until the clock catches up.
+ if json.Unmarshal(raw, &cache) == nil {
+ if since := time.Since(cache.CheckedAt); since >= 0 && since < 24*time.Hour {
+ return
+ }
+ }
+ }
+ // The attempt is stamped BEFORE the fetch: a blackholed egress must
+ // cost one command its 1.5s, not every command forever. Latest is
+ // informational (for a human reading the file) — the hint only ever
+ // speaks at check time, so a stale value never re-nags.
+ writeCache := func(latest string) {
+ if raw, err := json.Marshal(updateCache{CheckedAt: time.Now().UTC(), Latest: latest}); err == nil {
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err == nil {
+ os.WriteFile(path, raw, 0o600)
+ }
+ }
+ }
+ writeCache("")
+ latest, err := fetchLatest(1500 * time.Millisecond)
+ if err != nil {
+ return
+ }
+ writeCache(latest)
+ if newerVersion(latest, version.Build) {
+ mutedf(stdout, "carlos %s is available (you have %s) — run: carlos update", latest, version.Build)
+ }
+}
+
+// configUpdateCheckOff walks the config files like the other defaults do:
+// nearest .carlos/config with an update_check key, else the global. Only
+// the literal "off" disables.
+func configUpdateCheckOff() (bool, error) {
+ if dir, werr := os.Getwd(); werr == nil {
+ for {
+ cfg, err := loadConfig(filepath.Join(dir, ".carlos", "config"))
+ if err != nil {
+ return false, err
+ }
+ if cfg.UpdateCheck != "" {
+ return cfg.UpdateCheck == "off", nil
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ break
+ }
+ dir = parent
+ }
+ }
+ path, perr := globalConfigPath()
+ if perr != nil {
+ return false, nil
+ }
+ cfg, err := loadConfig(path)
+ if err != nil {
+ return false, err
+ }
+ return cfg.UpdateCheck == "off", nil
+}
+
+// installMethod classifies how the running binary got where it is, so
+// `carlos update` hands package-managed installs to their manager instead
+// of fighting it.
+func installMethod(exe string) string {
+ // Both brews keep formula binaries under a Cellar (/opt/homebrew/Cellar,
+ // ~/.linuxbrew/Cellar) — matching looser substrings ("/homebrew/") would
+ // also match unrelated paths that merely contain the word.
+ if strings.Contains(exe, "/Cellar/") {
+ return "brew"
+ }
+ if dpkg, err := exec.LookPath("dpkg"); err == nil {
+ out, err := exec.Command(dpkg, "-S", exe).Output()
+ if err == nil && strings.HasPrefix(string(out), "carlos:") {
+ return "apt"
+ }
+ }
+ return "binary"
+}
+
+func runUpdate(args []string) {
+ fs := flag.NewFlagSet("update", flag.ExitOnError)
+ yes := fs.Bool("y", false, "replace without asking (plain-binary installs only)")
+ fs.Parse(args)
+ if os.Getenv("CARLOS_DEPLOYMENT_BUCKET") != "" || os.Getenv("CARLOS_DEPLOYMENT_DIR") != "" {
+ log.Fatal("carlos update is for workstation installs — a box's binary is rolled by update-platform-binary.sh and adoption")
+ }
+ if os.Geteuid() == 0 {
+ log.Fatal("carlos update refuses to run as root — box binaries are rolled by the platform, not self-updated")
+ }
+ if !releaseBuild(version.Build) {
+ log.Fatalf("this is a %q build, not a published release — update by rebuilding from source", version.Build)
+ }
+ latest, err := fetchLatest(10 * time.Second)
+ if err != nil {
+ log.Fatalf("could not read the release feed: %v", err)
+ }
+ if !newerVersion(latest, version.Build) {
+ fmt.Fprintf(stdout, "carlos %s is the latest release — nothing to do\n", version.Build)
+ return
+ }
+ exe, err := os.Executable()
+ if err == nil {
+ exe, err = filepath.EvalSymlinks(exe)
+ }
+ if err != nil {
+ log.Fatalf("cannot locate the running binary: %v", err)
+ }
+ switch installMethod(exe) {
+ case "brew":
+ fmt.Fprintf(stdout, "carlos %s is available (you have %s)\n", latest, version.Build)
+ fmt.Fprintln(stdout, "this install is Homebrew's — update with: brew upgrade carlosframework/tap/carlos")
+ return
+ case "apt":
+ fmt.Fprintf(stdout, "carlos %s is available (you have %s)\n", latest, version.Build)
+ fmt.Fprintln(stdout, "this install is apt's — update with: sudo apt update && sudo apt install carlos")
+ return
+ }
+ fmt.Fprintf(stdout, "carlos %s is available (you have %s); this binary: %s\n", latest, version.Build, exe)
+ // Non-TTY never self-replaces, -y or not (spec): provisioning belongs
+ // to package managers and image builds, not to a CLI quietly swapping
+ // itself in a pipeline. -y only skips the question for a human.
+ if !interactiveTerminal() {
+ log.Fatal("carlos update replaces a binary only at a terminal — scripts should install the release directly")
+ }
+ if !*yes && !promptYes(stdout, fmt.Sprintf("download %s and replace it? [y/N] ", latest)) {
+ return
+ }
+ if err := selfReplace(exe, latest); err != nil {
+ if errors.Is(err, os.ErrPermission) {
+ log.Fatalf("no permission to replace %s — re-run as: sudo carlos update", exe)
+ }
+ log.Fatalf("update: %v", err)
+ }
+ fmt.Fprintf(stdout, "%s carlos %s -> %s (%s)\n", colorFor(stdout).ok("updated:"), version.Build, latest, exe)
+}
+
+// selfReplace downloads the matching artifact for this OS/arch — from the
+// TAG-pINNED release path, so the bytes installed are the version claimed —
+// proves it against the SIGNED checksum list, runs the replacement's own
+// `version` as a final lie detector, and renames it over exe: temp file
+// beside the target then rename, the same crash-safety rule as the config
+// writes, so no failure can leave half a binary answering `carlos`.
+func selfReplace(exe, latest string) error {
+ asset := fmt.Sprintf("carlos-%s-%s", runtime.GOOS, runtime.GOARCH)
+ sums, err := fetchUpdateFile(latest, "SHA256SUMS.txt", 1<<20)
+ if err != nil {
+ return err
+ }
+ sig, err := fetchUpdateFile(latest, "SHA256SUMS.txt.sig", 4096)
+ if err != nil {
+ return fmt.Errorf("%v (a release without a signed checksum list predates carlos update)", err)
+ }
+ pub, err := base64.StdEncoding.DecodeString(cliUpdatePubKey)
+ if err != nil || len(pub) != ed25519.PublicKeySize {
+ return errors.New("embedded update key is invalid — this binary cannot self-update")
+ }
+ rawSig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(sig)))
+ if err != nil || !ed25519.Verify(ed25519.PublicKey(pub), sums, rawSig) {
+ return errors.New("SHA256SUMS.txt signature does not verify — refusing the download")
+ }
+ wantSum := ""
+ for _, line := range strings.Split(string(sums), "\n") {
+ fields := strings.Fields(line)
+ if len(fields) == 2 && fields[1] == asset {
+ wantSum = fields[0]
+ }
+ }
+ if wantSum == "" {
+ return fmt.Errorf("no %s in the release's checksum list", asset)
+ }
+ binData, err := fetchUpdateFile(latest, asset, 256<<20)
+ if err != nil {
+ return err
+ }
+ if got := hex.EncodeToString(func() []byte { s := sha256.Sum256(binData); return s[:] }()); got != wantSum {
+ return fmt.Errorf("%s does not match its signed checksum — refusing to install it", asset)
+ }
+ tmp, err := os.CreateTemp(filepath.Dir(exe), ".carlos-update-*")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(tmp.Name())
+ if err := tmp.Chmod(0o755); err != nil {
+ tmp.Close()
+ return err
+ }
+ if _, err := tmp.Write(binData); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ // The lie detector: before anything is replaced, the candidate itself
+ // must report the version this whole exchange promised. Catches a
+ // re-pointed tag, a mislabeled artifact, and "updated!" prints that
+ // would otherwise claim a version that never arrived.
+ if out, err := exec.Command(tmp.Name(), "version").Output(); err != nil ||
+ !strings.Contains(string(out), "carlos "+latest) {
+ return fmt.Errorf("the downloaded binary reports %q, not %s — refusing to install it",
+ strings.TrimSpace(string(out)), latest)
+ }
+ return os.Rename(tmp.Name(), exe)
+}
+
+func fetchUpdateFile(tag, name string, limit int64) ([]byte, error) {
+ client := &http.Client{Timeout: 5 * time.Minute}
+ res, err := client.Get(updateDownloadBase + "/" + tag + "/" + name)
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("%s: status %d", name, res.StatusCode)
+ }
+ return io.ReadAll(io.LimitReader(res.Body, limit))
+}
diff --git a/update_test.go b/update_test.go
new file mode 100644
index 000000000..0a42356aa
--- /dev/null
+++ b/update_test.go
@@ -0,0 +1,261 @@
+package main
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/version"
+)
+
+func TestReleaseBuild(t *testing.T) {
+ for tag, want := range map[string]bool{
+ "v0.7.0": true, "v10.2.33": true,
+ "dev": false, "0.7.0": false, "v0.7": false, "v0.7.0-1": false, "": false,
+ } {
+ if got := releaseBuild(tag); got != want {
+ t.Errorf("releaseBuild(%q) = %v, want %v", tag, got, want)
+ }
+ }
+}
+
+func TestNewerVersion(t *testing.T) {
+ for _, tc := range []struct {
+ latest, current string
+ want bool
+ }{
+ {"v0.8.0", "v0.7.0", true},
+ {"v1.0.0", "v0.9.9", true},
+ {"v0.7.0", "v0.7.0", false},
+ {"v0.6.0", "v0.7.0", false},
+ {"garbage", "v0.7.0", false}, // a garbled feed never nags
+ {"v0.8.0", "dev", false},
+ } {
+ if got := newerVersion(tc.latest, tc.current); got != tc.want {
+ t.Errorf("newerVersion(%q, %q) = %v, want %v", tc.latest, tc.current, got, tc.want)
+ }
+ }
+}
+
+// useVersion pins the build stamp for one test — it is a package var
+// precisely so release-only behavior is testable from a dev build.
+func useVersion(t *testing.T, v string) {
+ t.Helper()
+ old := version.Build
+ version.Build = v
+ t.Cleanup(func() { version.Build = old })
+}
+
+// latestServer serves latest.txt with the given tag and counts fetches.
+func latestServer(t *testing.T, tag string) *int {
+ t.Helper()
+ hits := 0
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits++
+ fmt.Fprintln(w, tag)
+ }))
+ t.Cleanup(ts.Close)
+ oldURL := updateLatestURL
+ updateLatestURL = ts.URL
+ t.Cleanup(func() { updateLatestURL = oldURL })
+ return &hits
+}
+
+func TestUpdateHintShowsOncePerDay(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ out := captureStdout(t)
+ forceTerminal(t, true)
+ useVersion(t, "v0.7.0")
+ hits := latestServer(t, "v0.8.0")
+
+ maybeUpdateHint("ship")
+ if !strings.Contains(out.String(), "carlos v0.8.0 is available (you have v0.7.0) — run: carlos update") {
+ t.Fatalf("no hint:\n%s", out.String())
+ }
+ // Within the cache window: no second fetch, no second hint.
+ out.Reset()
+ maybeUpdateHint("ship")
+ if *hits != 1 || out.String() != "" {
+ t.Fatalf("second run: hits=%d out=%q, want cached silence", *hits, out.String())
+ }
+ // An expired cache checks again.
+ path, _ := updateCachePath()
+ stale := fmt.Sprintf(`{"checked_at": %q, "latest": "v0.8.0"}`, time.Now().Add(-25*time.Hour).Format(time.RFC3339))
+ if err := os.WriteFile(path, []byte(stale), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ maybeUpdateHint("ship")
+ if *hits != 2 {
+ t.Fatalf("hits after stale cache = %d, want 2", *hits)
+ }
+}
+
+func TestUpdateHintGates(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ out := captureStdout(t)
+ forceTerminal(t, true)
+ useVersion(t, "v0.7.0")
+ hits := latestServer(t, "v0.8.0")
+
+ // Each gate must keep the network untouched, not merely the output.
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "some-box-bucket")
+ maybeUpdateHint("ship")
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
+
+ t.Setenv("CARLOS_NO_UPDATE_CHECK", "1")
+ maybeUpdateHint("ship")
+ t.Setenv("CARLOS_NO_UPDATE_CHECK", "")
+
+ writeConfig(t, ".", `{"update_check": "off"}`)
+ maybeUpdateHint("ship")
+ os.RemoveAll(".carlos")
+
+ maybeUpdateHint("edge") // box verb
+ maybeUpdateHint("version") // parsed output
+ maybeUpdateHint("update") // the verb itself
+
+ forceTerminal(t, false)
+ maybeUpdateHint("ship")
+ forceTerminal(t, true)
+
+ useVersion(t, "dev")
+ maybeUpdateHint("ship")
+
+ if *hits != 0 || out.String() != "" {
+ t.Fatalf("gated runs: hits=%d out=%q, want zero of both", *hits, out.String())
+ }
+}
+
+func TestUpdateHintUpToDateIsSilent(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ out := captureStdout(t)
+ forceTerminal(t, true)
+ useVersion(t, "v0.8.0")
+ hits := latestServer(t, "v0.8.0")
+
+ maybeUpdateHint("ship")
+ if *hits != 1 || out.String() != "" {
+ t.Fatalf("up to date: hits=%d out=%q, want one silent check", *hits, out.String())
+ }
+ // And the silent check still stamped the cache.
+ path, _ := updateCachePath()
+ if _, err := os.Stat(path); err != nil {
+ t.Fatalf("cache not written on the up-to-date path: %v", err)
+ }
+}
+
+func TestInstallMethod(t *testing.T) {
+ for path, want := range map[string]string{
+ "/opt/homebrew/Cellar/carlos/0.7.0/bin/carlos": "brew",
+ "/home/linuxbrew/.linuxbrew/Cellar/carlos/x/carlos": "brew",
+ "/home/tom/bin/carlos": "binary",
+ } {
+ if got := installMethod(path); got != want {
+ t.Errorf("installMethod(%q) = %q, want %q", path, got, want)
+ }
+ }
+ // The dpkg branch depends on the host's dpkg database, so it is not
+ // asserted here — /usr/bin/carlos on a non-deb box is still "binary".
+}
+
+// releaseServer serves a fake carlosframework/releases download endpoint:
+// a binary, its SHA256SUMS.txt, and a signature by the given key.
+func releaseServer(t *testing.T, binary []byte, sums string, priv ed25519.PrivateKey, breakSig bool) {
+ t.Helper()
+ sig := ed25519.Sign(priv, []byte(sums))
+ if breakSig {
+ sig[0] ^= 0xff
+ }
+ asset := "carlos-" + goosArch()
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Downloads are tag-pinned: /vX.Y.Z/<asset>, never a floating
+ // "latest" alias — anything else here is a client bug.
+ if !strings.HasPrefix(r.URL.Path, "/v0.8.0/") {
+ t.Errorf("un-pinned download path %q", r.URL.Path)
+ http.NotFound(w, r)
+ return
+ }
+ switch strings.TrimPrefix(r.URL.Path, "/v0.8.0/") {
+ case "SHA256SUMS.txt":
+ w.Write([]byte(sums))
+ case "SHA256SUMS.txt.sig":
+ w.Write([]byte(base64.StdEncoding.EncodeToString(sig)))
+ case asset:
+ w.Write(binary)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ t.Cleanup(ts.Close)
+ oldBase := updateDownloadBase
+ updateDownloadBase = ts.URL
+ t.Cleanup(func() { updateDownloadBase = oldBase })
+}
+
+func goosArch() string { return runtime.GOOS + "-" + runtime.GOARCH }
+
+func TestSelfReplace(t *testing.T) {
+ useTempCredentials(t)
+ pub, priv, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ oldKey := cliUpdatePubKey
+ cliUpdatePubKey = base64.StdEncoding.EncodeToString(pub)
+ t.Cleanup(func() { cliUpdatePubKey = oldKey })
+
+ newBinary := []byte("#!/bin/sh\necho carlos v0.8.0\n")
+ sum := sha256.Sum256(newBinary)
+ asset := fmt.Sprintf("carlos-%s", goosArch())
+ sums := fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), asset)
+
+ exe := filepath.Join(t.TempDir(), "carlos")
+ if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ // A broken signature refuses before anything is downloaded or touched.
+ releaseServer(t, newBinary, sums, priv, true)
+ if err := selfReplace(exe, "v0.8.0"); err == nil ||
+ !strings.Contains(err.Error(), "signature does not verify") {
+ t.Fatalf("broken sig = %v, want the signature refusal", err)
+ }
+ if got, _ := os.ReadFile(exe); string(got) != "old" {
+ t.Fatal("the binary was touched despite a bad signature")
+ }
+
+ // A checksum mismatch refuses too.
+ releaseServer(t, []byte("not what was signed"), sums, priv, false)
+ if err := selfReplace(exe, "v0.8.0"); err == nil ||
+ !strings.Contains(err.Error(), "does not match its signed checksum") {
+ t.Fatalf("bad sum = %v, want the checksum refusal", err)
+ }
+
+ // The happy path replaces atomically and keeps 0755.
+ releaseServer(t, newBinary, sums, priv, false)
+ if err := selfReplace(exe, "v0.8.0"); err != nil {
+ t.Fatal(err)
+ }
+ got, err := os.ReadFile(exe)
+ if err != nil || string(got) != string(newBinary) {
+ t.Fatalf("replaced binary = %q, %v", got, err)
+ }
+ fi, err := os.Stat(exe)
+ if err != nil || fi.Mode().Perm() != 0o755 {
+ t.Fatalf("replaced mode = %v, %v; want 0755", fi.Mode(), err)
+ }
+}