carlos / platform Public

auth: carlos auth default shows and sets the default console

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxXEWoRXU8WVTXjTJypuJ5
Paul Campbell e6956d50c4f03eb3405bb57826b7586777867578 parent 2364daf
2 files changed, +138 −7
  • auth.go +75 −7
  • config_test.go +63 −0
diff --git a/auth.go b/auth.go
index 1d0fd9a27..e8053f778 100644
--- a/auth.go
+++ b/auth.go
@@ -48,20 +48,29 @@ import (
"time"
)
-const authUsage = `usage: carlos auth <login|whoami|logout>
-
- login -console <url> log this terminal in to a deployment's console.
- Prints a code; you approve it in a browser that
- is already signed in to that console. No browser
- on this machine is needed — the code travels.
+const authUsage = `usage: carlos auth <login|whoami|logout|default>
+
+ login [-console <url>] log this terminal in to a deployment's console
+ (default: the configured default console, else
+ https://platform.carlosframework.com). Prints a
+ code; you approve it in a browser that is already
+ signed in to that console. No browser on this
+ machine is needed — the code travels.
[-label <name>] what to call this terminal on the console's
command-line access page (default: user@host)
whoami [-console <url>] ask the console who this terminal is
logout [-console <url>] revoke this terminal's token and forget it
+ default [-project] [<url>]
+ show the default console and where it is set, or
+ set it: writes ~/.carlos/config — or, with
+ -project, ./.carlos/config, which overrides the
+ global and is meant to be committed so a checkout
+ pins its own deployment
The token lands in ~/.carlos/credentials (mode 0600), one entry per console
origin — a laptop can hold credentials for the flagship and a self-hosted
-deployment at once. -console may be omitted when there is exactly one.
+deployment at once. -console may be omitted when there is exactly one, or
+when a default is set.
HEADLESS AND BOX-SIDE USE: do not log in. A box has no browser and no human,
and a long-lived token on it is a standing credential nobody is watching.
@@ -83,6 +92,8 @@ func RunAuth(args []string) {
err = authWhoami(os.Stdout, args[1:])
case "logout":
err = authLogout(os.Stdout, args[1:])
+ case "default":
+ err = authDefault(os.Stdout, args[1:])
default:
fmt.Fprint(os.Stderr, authUsage)
os.Exit(1)
@@ -759,6 +770,63 @@ func authLogout(out io.Writer, args []string) error {
return nil
}
+// authDefault shows or sets the default console. Bare: report the effective
+// configured default and which file said so (flag and env are not consulted
+// — this verb reports CONFIG, and someone with CARLOS_CONSOLE exported can
+// see their own shell). With a URL: write ~/.carlos/config, or
+// ./.carlos/config under -project — relative to the working directory,
+// deliberately not "the repo root": the CLI has no VCS awareness, and
+// "run it where you want the file" beats guessing.
+func authDefault(out io.Writer, args []string) error {
+ fs := flag.NewFlagSet("auth default", flag.ContinueOnError)
+ fs.SetOutput(out)
+ project := fs.Bool("project", false, "write ./.carlos/config (this directory) instead of ~/.carlos/config")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ switch fs.NArg() {
+ case 0:
+ if *project {
+ return errors.New("-project sets a default: carlos auth default -project <url>")
+ }
+ console, src, err := configDefault()
+ if err != nil {
+ return err
+ }
+ if console == "" {
+ fmt.Fprintf(out, "no default console configured.\n")
+ fmt.Fprintf(out, "`carlos auth login` defaults to %s; other commands use the\n", defaultConsole)
+ fmt.Fprintf(out, "only stored credential, or ask when there are several.\n")
+ fmt.Fprintf(out, "Set one with: carlos auth default <url> (-project pins this directory)\n")
+ return nil
+ }
+ fmt.Fprintf(out, "%s (from %s)\n", console, src)
+ return nil
+ case 1:
+ path, err := globalConfigPath()
+ perm := os.FileMode(0o700) // ~/.carlos also holds credentials
+ if *project {
+ path, err = filepath.Join(".carlos", "config"), nil
+ perm = 0o755 // meant to be committed; nothing secret inside
+ }
+ if err != nil {
+ return err
+ }
+ if err := saveConfigFile(path, fs.Arg(0), perm); err != nil {
+ return err
+ }
+ // Echo the NORMALIZED value: what was saved, not what was typed.
+ console, err := normalizeConsole(fs.Arg(0))
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(out, "default console: %s (written to %s)\n", console, path)
+ return nil
+ default:
+ return errors.New("usage: carlos auth default [-project] [<url>]")
+ }
+}
+
// ---- what other verbs use ---------------------------------------------------
// loggedInCredential is the seam every other verb reaches through: the
diff --git a/config_test.go b/config_test.go
index 5d695d5b1..4e835c085 100644
--- a/config_test.go
+++ b/config_test.go
@@ -262,3 +262,66 @@ func TestAuthLoginUsesConfiguredDefault(t *testing.T) {
t.Fatalf("credential stored = %+v, want one for the configured console", c)
}
}
+
+func TestAuthDefaultVerb(t *testing.T) {
+ useTempCredentials(t)
+ cwd, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var out bytes.Buffer
+
+ // Nothing configured: the report explains the built-in behavior rather
+ // than printing an empty string.
+ if err := authDefault(&out, nil); err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{"no default", defaultConsole, "carlos auth default"} {
+ if !strings.Contains(out.String(), want) {
+ t.Fatalf("bare report missing %q:\n%s", want, out.String())
+ }
+ }
+
+ // Set the global; the value is normalized on write and read back with
+ // its source.
+ out.Reset()
+ if err := authDefault(&out, []string{"https://global.example/"}); err != nil {
+ t.Fatal(err)
+ }
+ out.Reset()
+ if err := authDefault(&out, nil); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out.String(), "https://global.example") ||
+ !strings.Contains(out.String(), filepath.Join(".carlos", "config")) {
+ t.Fatalf("report after set:\n%s", out.String())
+ }
+
+ // -project writes ./.carlos/config, which then overrides the global.
+ out.Reset()
+ if err := authDefault(&out, []string{"-project", "https://project.example"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(cwd, ".carlos", "config")); err != nil {
+ t.Fatalf("-project did not write ./.carlos/config: %v", err)
+ }
+ out.Reset()
+ if err := authDefault(&out, nil); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out.String(), "https://project.example") {
+ t.Fatalf("project default not effective:\n%s", out.String())
+ }
+
+ // A bad URL is refused at set time, and -project with nothing to set
+ // is a usage error, not a silent no-op.
+ if err := authDefault(&out, []string{"ftp://nope"}); err == nil {
+ t.Fatal("setting an invalid console must fail")
+ }
+ if err := authDefault(&out, []string{"-project"}); err == nil {
+ t.Fatal("-project with no URL must be a usage error")
+ }
+ if err := authDefault(&out, []string{"a", "b"}); err == nil {
+ t.Fatal("two positional args must be a usage error")
+ }
+}