Merge pull request #163 from carlosframework/worktree-proactive-cli
Proactive CLI: moved consoles explained, logins offered, self-update (#160 #161 #162)
9 files changed,
+1294
−13
account_choose.go+37 −0apimode.go+164 −6apimode_test.go+291 −0auth.go+43 −7config.go+3 −0docs/superpowers/specs/2026-08-12-proactive-cli-design.md+128 −0main.go+9 −0update.go+358 −0update_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 = ¬LoggedInError{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, ¬LoggedInError{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, ¬LoggedInError{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) |
| + } |
| +} |