carlos / platform Public

Merge amadan main (config destinations) back into main

amadan's main carried 6d2288b7 (carlos config destinations), merged there
as 1643418d but never on GitHub. Meanwhile GitHub's main gained 1170957,
pushed to main by accident instead of to PR #406's branch, and the
relay-review-followups merge on top of it. This rejoins the two so amadan
can fast-forward to main again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
James Adam pushed by james@keymail.dev 6a01e60c6785ea3be3ab0e04a4d2255ffc1c1133 parent 752348dparent 1643418
7 files changed, +1127 −15
  • auth.go +12 −3
  • config.go +32 −10
  • config_cli.go +691 −0
  • config_cli_test.go +340 −0
  • docs/site/cli.md +41 −1
  • flags.go +7 −1
  • main.go +4 −0

CI passed — run details

diff --git a/auth.go b/auth.go
index d165c3f0f..3e282cf12 100644
--- a/auth.go
+++ b/auth.go
@@ -60,12 +60,14 @@ const authUsage = `usage: carlos auth <login|whoami|logout|default>
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>]
+ default [--project] [-d <destination>] [<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
+ pins its own deployment. -d reads or writes that
+ destination's console ("carlos config
+ destinations" manages the rest)
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
@@ -847,6 +849,7 @@ 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")
+ destinationOption(fs) // reads, and writes into, that destination's block
fs.Usage = flagUsage(fs, "")
if err := fs.Parse(args); err != nil {
return err
@@ -867,6 +870,9 @@ func authDefault(out io.Writer, args []string) error {
fmt.Fprintf(out, "Set one with: carlos auth default <url> (--project pins this directory)\n")
return nil
}
+ if d := configDestination(console); d != "" {
+ src = "the " + d + " destination in " + src
+ }
fmt.Fprintf(out, "%s (from %s)\n", console, src)
return nil
case 1:
@@ -887,10 +893,13 @@ func authDefault(out io.Writer, args []string) error {
if err != nil {
return err
}
+ if d := selectedDestination(); d != "" {
+ path = "the " + d + " destination in " + path
+ }
fmt.Fprintf(out, "default console: %s (written to %s)\n", console, path)
return nil
default:
- return errors.New("usage: carlos auth default [--project] [<url>]")
+ return errors.New("usage: carlos auth default [--project] [-d <destination>] [<url>]")
}
}
diff --git a/config.go b/config.go
index 508a69f25..5dd1c58c1 100644
--- a/config.go
+++ b/config.go
@@ -406,16 +406,8 @@ func saveConfigAccount(path, console, account string, dirPerm os.FileMode) error
// default or only block, else at the top level. A file that cannot say
// which block is refused, as it is on read.
func writeConfigKeys(path string, set map[string]any, dirPerm os.FileMode) error {
- dir := filepath.Dir(path)
- if err := os.MkdirAll(dir, dirPerm); err != nil {
- return err
- }
- all := map[string]any{}
- if raw, err := os.ReadFile(path); err == nil {
- if err := json.Unmarshal(raw, &all); err != nil {
- return fmt.Errorf("%s is not readable as a carlos config: %w", path, err)
- }
- } else if !errors.Is(err, os.ErrNotExist) {
+ all, err := readConfigMap(path)
+ if err != nil {
return err
}
into, err := configWriteTarget(path, all)
@@ -425,6 +417,36 @@ func writeConfigKeys(path string, set map[string]any, dirPerm os.FileMode) error
for k, v := range set {
into[k] = v
}
+ return writeConfigMap(path, all, dirPerm)
+}
+
+// readConfigMap reads a config as raw JSON, every key kept — the form a
+// rewrite must start from. A missing file is an empty map.
+func readConfigMap(path string) (map[string]any, error) {
+ all := map[string]any{}
+ raw, err := os.ReadFile(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return all, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ if err := json.Unmarshal(raw, &all); err != nil {
+ return nil, fmt.Errorf("%s is not readable as a carlos config: %w", path, err)
+ }
+ if all == nil { // the file said null
+ all = map[string]any{}
+ }
+ return all, nil
+}
+
+// writeConfigMap writes all to path, temp-file-then-rename (see
+// writeConfigKeys).
+func writeConfigMap(path string, all map[string]any, dirPerm os.FileMode) error {
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, dirPerm); err != nil {
+ return err
+ }
body, err := json.MarshalIndent(all, "", " ")
if err != nil {
return err
diff --git a/config_cli.go b/config_cli.go
new file mode 100644
index 000000000..7e11ba5f9
--- /dev/null
+++ b/config_cli.go
@@ -0,0 +1,691 @@
+// config_cli.go is `carlos config`: commands that edit .carlos/config
+// (config.go) so nobody has to hand-edit its JSON. One subcommand so far,
+// destinations — the named deploy blocks `--destination` / -d picks from
+// (amadan discussion #157).
+//
+// These verbs work on the raw file (readConfigMap), never through
+// loadConfig: a file with several destinations and no default is exactly
+// the state loadConfig refuses, and `destinations default <name>` has to be
+// able to fix it.
+package main
+
+import (
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "text/tabwriter"
+ "time"
+)
+
+const configUsage = `usage: carlos config <subcommand>
+
+ destinations the named deploy destinations in .carlos/config:
+ list | add | set | default | remove | rename
+
+"carlos config destinations help" describes each verb.
+`
+
+const destinationsUsage = `usage: carlos config destinations <verb> [flags]
+
+ list each destination's console, account, app and
+ artifact; * marks the one used without -d
+ add <name> --console <url> add one; --account, --app, --kind, --artifact
+ and --target set its other keys, and
+ --default makes it the default
+ set <name> --<key> <value> change a destination's keys; "" removes one
+ default [<name>] show or set the destination used without -d
+ remove <name> delete one
+ rename <old> <new> rename one; the default follows it
+
+Each verb works on the nearest project .carlos/config, walking up from this
+directory (creating ./.carlos/config when there is none); --global works on
+~/.carlos/config instead. A key a destination leaves out comes from the top
+of the file.
+`
+
+// RunConfig is `carlos config`.
+func RunConfig(args []string) {
+ if len(args) == 0 {
+ fmt.Fprint(os.Stderr, configUsage)
+ os.Exit(1)
+ }
+ var err error
+ switch args[0] {
+ case "destinations":
+ err = configDestinations(os.Stdout, args[1:])
+ default:
+ fmt.Fprint(os.Stderr, configUsage)
+ os.Exit(1)
+ }
+ if errors.Is(err, flag.ErrHelp) {
+ return // the flag set already printed its usage
+ }
+ if errors.As(err, new(flagParseError)) {
+ os.Exit(2) // the flag set already printed the error and its usage
+ }
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "%s %v\n", colorFor(os.Stderr).bad("carlos config "+args[0]+":"), err)
+ os.Exit(1)
+ }
+}
+
+// configDestinations dispatches `carlos config destinations <verb>`. No
+// verb, or a flag first, is list.
+func configDestinations(out io.Writer, args []string) error {
+ verb := "list"
+ if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
+ verb, args = args[0], args[1:]
+ }
+ switch verb {
+ case "list", "ls":
+ return destinationsList(out, args)
+ case "add":
+ return destinationsAdd(out, args)
+ case "set":
+ return destinationsSet(out, args)
+ case "default":
+ return destinationsDefault(out, args)
+ case "remove", "rm":
+ return destinationsRemove(out, args)
+ case "rename":
+ return destinationsRename(out, args)
+ case "help":
+ fmt.Fprint(out, destinationsUsage)
+ return nil
+ }
+ return fmt.Errorf("no verb %q\n%s", verb, destinationsUsage)
+}
+
+// destinationKeys are the keys a destination block can set — cliConfig's
+// per-stack fields — in the order flags and messages present them.
+var destinationKeys = []string{"console", "account", "app", "kind", "artifact", "target"}
+
+// whereKeys are the keys that say WHERE a file deploys. A file that sets
+// any of them at the top level, with no destinations yet, must have them
+// moved into a named block before a destination is added — otherwise the
+// new block would silently become the only one, and so the default.
+var whereKeys = []string{"console", "account", "app"}
+
+var destinationNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
+
+// validDestinationName keeps names typeable as `-d <name>` and unambiguous
+// in a shell: no spaces, and never a leading "-".
+func validDestinationName(n string) error {
+ if !destinationNameRE.MatchString(n) {
+ return fmt.Errorf("%q is not a usable destination name: use letters, digits, '.', '_' and '-', starting with a letter or digit", n)
+ }
+ return nil
+}
+
+// destinationKeyFlags registers one flag per destination key, --app with
+// its claimed -a alias.
+func destinationKeyFlags(fs *flag.FlagSet) map[string]*string {
+ return map[string]*string{
+ "console": fs.String("console", "", "the console `url` this destination deploys through"),
+ "account": fs.String("account", "", "the `account` (sqid or name) the app belongs to"),
+ "app": appFlag(fs, "app name"),
+ "kind": fs.String("kind", "", "the `kind` of artifact: binary or static"),
+ "artifact": fs.String("artifact", "", "the `path` a bare carlos deploy ships: a binary, or a site directory, inside the project"),
+ "target": fs.String("target", "", "the `os-arch` the binary is built for, like linux-arm64 (binary kind)"),
+ }
+}
+
+// applyDestinationFlags copies every destination-key flag the user typed
+// into block, normalizing the console, and reports how many it changed.
+// With clear, an empty value removes the key; without, it is ignored.
+func applyDestinationFlags(fs *flag.FlagSet, vals map[string]*string, block map[string]any, clear bool) (int, error) {
+ n := 0
+ for _, k := range destinationKeys {
+ if !flagWasSet(fs, k) {
+ continue
+ }
+ v := strings.TrimSpace(*vals[k])
+ if v == "" {
+ if clear {
+ delete(block, k)
+ n++
+ }
+ continue
+ }
+ switch k {
+ case "console":
+ c, err := normalizeConsole(v)
+ if err != nil {
+ return 0, err
+ }
+ v = c
+ case "kind":
+ if v != "binary" && v != "static" {
+ return 0, fmt.Errorf("--kind %q: want binary or static", v)
+ }
+ }
+ block[k] = v
+ n++
+ }
+ return n, nil
+}
+
+// flagParseError is a flag the flag set refused. It has printed the error
+// and its usage already, so RunConfig exits without a second copy.
+type flagParseError struct{ error }
+
+func (e flagParseError) Unwrap() error { return e.error }
+
+// parseInterspersed parses flags on either side of the positional
+// arguments: Go's flag package stops at the first non-flag word, so
+// `add tito --console <url>` would otherwise leave --console unparsed.
+func parseInterspersed(fs *flag.FlagSet, args []string) ([]string, error) {
+ var pos []string
+ rest := args
+ for {
+ if err := fs.Parse(rest); err != nil {
+ if errors.Is(err, flag.ErrHelp) {
+ return nil, err
+ }
+ return nil, flagParseError{err}
+ }
+ rest = fs.Args()
+ if len(rest) == 0 {
+ return pos, nil
+ }
+ pos = append(pos, rest[0])
+ rest = rest[1:]
+ }
+}
+
+// destinationsFlagSet is the flag set every verb starts from: --global,
+// and the usage line it prints for -h.
+func destinationsFlagSet(out io.Writer, verb, usage string) (*flag.FlagSet, *bool) {
+ fs := flag.NewFlagSet("config destinations "+verb, flag.ContinueOnError)
+ fs.SetOutput(out)
+ global := fs.Bool("global", false, "work on ~/.carlos/config instead of the project's")
+ fs.Usage = flagUsage(fs, "usage: carlos config destinations "+usage)
+ return fs, global
+}
+
+// destinationsFile is the file a verb works on and the mode to create its
+// directory with: CARLOS_CONFIG when set (config.go's one-file override);
+// else ~/.carlos/config under --global; else the nearest project
+// .carlos/config walking up from here, else ./.carlos/config. The walk
+// skips ~/.carlos/config, which it meets on the way up through $HOME —
+// editing the global file must be asked for.
+func destinationsFile(global bool) (string, os.FileMode, error) {
+ if p := os.Getenv("CARLOS_CONFIG"); p != "" {
+ return p, 0o755, nil
+ }
+ gp, gerr := globalConfigPath()
+ if global {
+ return gp, 0o700, gerr // ~/.carlos also holds credentials
+ }
+ cwd, err := os.Getwd()
+ if err != nil {
+ return "", 0, err
+ }
+ for dir := cwd; ; {
+ p := filepath.Join(dir, ".carlos", "config")
+ if p != gp {
+ if _, err := os.Stat(p); err == nil {
+ return p, 0o755, nil
+ }
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ break
+ }
+ dir = parent
+ }
+ p := filepath.Join(cwd, ".carlos", "config")
+ if p == gp {
+ return "", 0, errors.New("this directory's .carlos/config is the global ~/.carlos/config — run this in a project, or pass --global")
+ }
+ return p, 0o755, nil
+}
+
+// destinationsDoc is one config file opened for editing: every key kept,
+// the destinations object pulled out.
+type destinationsDoc struct {
+ path string
+ all map[string]any
+ dests map[string]any // nil or empty: the file has no destinations
+}
+
+func openDestinations(path string) (*destinationsDoc, error) {
+ all, err := readConfigMap(path)
+ if err != nil {
+ return nil, err
+ }
+ d := &destinationsDoc{path: path, all: all, dests: map[string]any{}}
+ if raw, ok := all["destinations"]; ok && raw != nil {
+ m, ok := raw.(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("%s: \"destinations\" is not an object", path)
+ }
+ d.dests = m
+ }
+ return d, nil
+}
+
+func (d *destinationsDoc) names() []string { return mapKeys(d.dests) }
+
+// block is a destination's object, or an error naming the ones there are.
+func (d *destinationsDoc) block(name string) (map[string]any, error) {
+ raw, ok := d.dests[name]
+ if !ok {
+ if len(d.dests) == 0 {
+ return nil, fmt.Errorf("%s has no destinations", d.path)
+ }
+ return nil, fmt.Errorf("%s has no destination %q (it has: %s)", d.path, name, strings.Join(d.names(), ", "))
+ }
+ m, ok := raw.(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("%s: destination %q is not an object", d.path, name)
+ }
+ return m, nil
+}
+
+func (d *destinationsDoc) explicitDefault() string { return configString(d.all, "default") }
+
+// resolvedDefault is what loadConfig uses with no destination selected,
+// or the reason it would refuse.
+func (d *destinationsDoc) resolvedDefault() (string, error) {
+ if len(d.dests) == 0 {
+ return "", nil
+ }
+ return fileDefaultDestination(d.path, d.explicitDefault(), d.names())
+}
+
+// effective is a key's value for a destination as loadConfig resolves it:
+// the block's, else the top of the file's.
+func (d *destinationsDoc) effective(name, key string) string {
+ if b, ok := d.dests[name].(map[string]any); ok {
+ if v := configString(b, key); v != "" {
+ return v
+ }
+ }
+ return configString(d.all, key)
+}
+
+func (d *destinationsDoc) save(dirPerm os.FileMode) error {
+ if len(d.dests) == 0 {
+ delete(d.all, "destinations")
+ delete(d.all, "default")
+ } else {
+ d.all["destinations"] = d.dests
+ }
+ return writeConfigMap(d.path, d.all, dirPerm)
+}
+
+func configString(m map[string]any, key string) string {
+ s, _ := m[key].(string)
+ return strings.TrimSpace(s)
+}
+
+// warnNotLoggedIn names the login a destination's console still needs.
+// Advice only: a destination may be written before its login, or
+// committed for colleagues who have their own.
+func warnNotLoggedIn(out io.Writer, console string) {
+ f, err := loadCredentials()
+ if err != nil {
+ return
+ }
+ switch c := f.find(console); {
+ case c == nil:
+ warnf(out, "not logged in to %s yet — run: carlos auth login --console %s", console, console)
+ case c.Expired(time.Now()):
+ warnf(out, "your login to %s has expired — run: carlos auth login --console %s", console, console)
+ }
+}
+
+func destinationsList(out io.Writer, args []string) error {
+ fs, global := destinationsFlagSet(out, "list", "list [--global]")
+ pos, err := parseInterspersed(fs, args)
+ if err != nil {
+ return err
+ }
+ if len(pos) != 0 {
+ return errors.New("usage: carlos config destinations list [--global]")
+ }
+ path, _, err := destinationsFile(*global)
+ if err != nil {
+ return err
+ }
+ d, err := openDestinations(path)
+ if err != nil {
+ return err
+ }
+ if len(d.dests) == 0 {
+ fmt.Fprintf(out, "no destinations in %s\n", path)
+ if c := configString(d.all, "console"); c != "" {
+ fmt.Fprintf(out, "without one it deploys to %s\n", c)
+ }
+ mutedf(out, "add one with: carlos config destinations add <name> --console <url>")
+ return nil
+ }
+ def, defErr := d.resolvedDefault()
+ fmt.Fprintf(out, "destinations in %s\n", path)
+ w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
+ fmt.Fprintln(w, " NAME\tCONSOLE\tACCOUNT\tAPP\tARTIFACT")
+ for _, n := range d.names() {
+ if _, err := d.block(n); err != nil {
+ return err
+ }
+ mark := " "
+ if n == def {
+ mark = "*"
+ }
+ cell := func(k string) string {
+ if v := d.effective(n, k); v != "" {
+ return v
+ }
+ return "-"
+ }
+ fmt.Fprintf(w, "%s %s\t%s\t%s\t%s\t%s\n", mark, n, cell("console"), cell("account"), cell("app"), cell("artifact"))
+ }
+ w.Flush()
+ if defErr != nil {
+ warnf(out, "%v", defErr)
+ } else {
+ mutedf(out, "* is used when no destination is named (-d, CARLOS_DESTINATION)")
+ }
+ return nil
+}
+
+func destinationsAdd(out io.Writer, args []string) error {
+ fs, global := destinationsFlagSet(out, "add", "add <name> --console <url> [flags]")
+ vals := destinationKeyFlags(fs)
+ makeDefault := fs.Bool("default", false, "make it the destination used when none is named")
+ fromCurrent := fs.String("from-current", "", "first destination only: the `name` to move this file's existing console, account and app into, so a bare command keeps deploying where it did")
+ pos, err := parseInterspersed(fs, args)
+ if err != nil {
+ return err
+ }
+ if len(pos) != 1 {
+ return errors.New("usage: carlos config destinations add <name> --console <url> [--account <a>] [--app <a>] [--kind binary|static] [--artifact <path>] [--target <os-arch>] [--default] [--global]")
+ }
+ name := pos[0]
+ if err := validDestinationName(name); err != nil {
+ return err
+ }
+ path, perm, err := destinationsFile(*global)
+ if err != nil {
+ return err
+ }
+ d, err := openDestinations(path)
+ if err != nil {
+ return err
+ }
+ if _, exists := d.dests[name]; exists {
+ return fmt.Errorf("%s already has a destination %q — change it with: carlos config destinations set %s --<key> <value>", path, name, name)
+ }
+ block := map[string]any{}
+ if _, err := applyDestinationFlags(fs, vals, block, false); err != nil {
+ return err
+ }
+
+ var notes []string
+ current := strings.TrimSpace(*fromCurrent)
+ var moving []string
+ for _, k := range whereKeys {
+ if configString(d.all, k) != "" {
+ moving = append(moving, k)
+ }
+ }
+ switch {
+ case len(d.dests) == 0 && len(moving) > 0:
+ // The file already deploys somewhere. Adding a block without moving
+ // those keys would make the new block the only one — the default —
+ // and every bare command here would quietly switch stacks.
+ if current == "" {
+ if !interactiveTerminal() {
+ return fmt.Errorf("%s already deploys to %s without a destination — name that destination too, so it keeps working: add --from-current <name>",
+ path, describeWhere(d.all))
+ }
+ fmt.Fprintf(out, "%s already deploys to %s without a destination.\n", path, describeWhere(d.all))
+ fmt.Fprintln(out, "Those settings move into a destination of their own, which stays the default.")
+ if current = promptLine(out, "name for it: "); current == "" {
+ return errors.New("nothing changed")
+ }
+ }
+ if err := validDestinationName(current); err != nil {
+ return err
+ }
+ if current == name {
+ return fmt.Errorf("the current settings and the new destination need different names, not both %q", name)
+ }
+ moved := map[string]any{}
+ for _, k := range moving {
+ moved[k] = d.all[k]
+ delete(d.all, k)
+ }
+ d.dests[current] = moved
+ d.all["default"] = current
+ notes = append(notes, fmt.Sprintf("moved this file's %s into destination %s, the default", strings.Join(moving, ", "), current))
+ case current != "":
+ return errors.New("--from-current is only for a file that has settings and no destinations yet")
+ case len(d.dests) == 1 && d.explicitDefault() == "" && !*makeDefault:
+ // The one block was the default by being alone; a second one would
+ // leave the file with no default at all, and every bare command here
+ // refusing. Pin it.
+ for only := range d.dests {
+ d.all["default"] = only
+ notes = append(notes, fmt.Sprintf("%s stays the default", only))
+ }
+ }
+ d.dests[name] = block
+ if *makeDefault {
+ d.all["default"] = name
+ notes = append(notes, fmt.Sprintf("%s is now the default", name))
+ }
+ console := d.effective(name, "console")
+ if console == "" {
+ return fmt.Errorf("destination %s would name no console — pass --console <url>", name)
+ }
+ if err := d.save(perm); err != nil {
+ return err
+ }
+ fmt.Fprintf(out, "added destination %s to %s\n", name, path)
+ for _, n := range notes {
+ mutedf(out, "%s", n)
+ }
+ warnNotLoggedIn(out, console)
+ return nil
+}
+
+// describeWhere is "console (account a, app x)" for a file's top level.
+func describeWhere(all map[string]any) string {
+ s := configString(all, "console")
+ if s == "" {
+ s = "its default console"
+ }
+ var extra []string
+ for _, k := range []string{"account", "app"} {
+ if v := configString(all, k); v != "" {
+ extra = append(extra, k+" "+v)
+ }
+ }
+ if len(extra) > 0 {
+ s += " (" + strings.Join(extra, ", ") + ")"
+ }
+ return s
+}
+
+func destinationsSet(out io.Writer, args []string) error {
+ fs, global := destinationsFlagSet(out, "set", "set <name> --<key> <value> [flags]")
+ vals := destinationKeyFlags(fs)
+ pos, err := parseInterspersed(fs, args)
+ if err != nil {
+ return err
+ }
+ if len(pos) != 1 {
+ return errors.New("usage: carlos config destinations set <name> [--console <url>] [--account <a>] [--app <a>] [--kind binary|static] [--artifact <path>] [--target <os-arch>] [--global]")
+ }
+ name := pos[0]
+ path, perm, err := destinationsFile(*global)
+ if err != nil {
+ return err
+ }
+ d, err := openDestinations(path)
+ if err != nil {
+ return err
+ }
+ block, err := d.block(name)
+ if err != nil {
+ return err
+ }
+ n, err := applyDestinationFlags(fs, vals, block, true)
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return errors.New("nothing to change — pass one or more of --console, --account, --app, --kind, --artifact, --target (\"\" removes a key)")
+ }
+ console := d.effective(name, "console")
+ if console == "" {
+ return fmt.Errorf("destination %s would name no console — it needs --console <url>", name)
+ }
+ if err := d.save(perm); err != nil {
+ return err
+ }
+ fmt.Fprintf(out, "updated destination %s in %s\n", name, path)
+ if flagWasSet(fs, "console") {
+ warnNotLoggedIn(out, console)
+ }
+ return nil
+}
+
+func destinationsDefault(out io.Writer, args []string) error {
+ fs, global := destinationsFlagSet(out, "default", "default [<name>] [--global]")
+ pos, err := parseInterspersed(fs, args)
+ if err != nil {
+ return err
+ }
+ path, perm, err := destinationsFile(*global)
+ if err != nil {
+ return err
+ }
+ d, err := openDestinations(path)
+ if err != nil {
+ return err
+ }
+ switch len(pos) {
+ case 0:
+ if len(d.dests) == 0 {
+ fmt.Fprintf(out, "no destinations in %s\n", path)
+ return nil
+ }
+ def, err := d.resolvedDefault()
+ if err != nil {
+ return err
+ }
+ if d.explicitDefault() == "" {
+ fmt.Fprintf(out, "%s (the only destination in %s)\n", def, path)
+ } else {
+ fmt.Fprintf(out, "%s (from %s)\n", def, path)
+ }
+ return nil
+ case 1:
+ if _, err := d.block(pos[0]); err != nil {
+ return err
+ }
+ d.all["default"] = pos[0]
+ if err := d.save(perm); err != nil {
+ return err
+ }
+ fmt.Fprintf(out, "default destination: %s (written to %s)\n", pos[0], path)
+ return nil
+ }
+ return errors.New("usage: carlos config destinations default [<name>] [--global]")
+}
+
+func destinationsRemove(out io.Writer, args []string) error {
+ fs, global := destinationsFlagSet(out, "remove", "remove <name> [--global]")
+ pos, err := parseInterspersed(fs, args)
+ if err != nil {
+ return err
+ }
+ if len(pos) != 1 {
+ return errors.New("usage: carlos config destinations remove <name> [--global]")
+ }
+ name := pos[0]
+ path, perm, err := destinationsFile(*global)
+ if err != nil {
+ return err
+ }
+ d, err := openDestinations(path)
+ if err != nil {
+ return err
+ }
+ if _, err := d.block(name); err != nil {
+ return err
+ }
+ wasDefault := d.explicitDefault() == name
+ if wasDefault && len(d.dests) > 2 {
+ return fmt.Errorf("%s is the default destination — choose another first: carlos config destinations default <name>", name)
+ }
+ delete(d.dests, name)
+ var note string
+ switch {
+ case len(d.dests) == 0:
+ if c := configString(d.all, "console"); c != "" {
+ note = "no destinations left: this file deploys to " + c + " again"
+ } else {
+ note = "no destinations left, and nothing else in this file names a console"
+ }
+ case wasDefault:
+ delete(d.all, "default")
+ note = d.names()[0] + " is now the default, as the only destination left"
+ }
+ if err := d.save(perm); err != nil {
+ return err
+ }
+ fmt.Fprintf(out, "removed destination %s from %s\n", name, path)
+ if note != "" {
+ mutedf(out, "%s", note)
+ }
+ return nil
+}
+
+func destinationsRename(out io.Writer, args []string) error {
+ fs, global := destinationsFlagSet(out, "rename", "rename <old> <new> [--global]")
+ pos, err := parseInterspersed(fs, args)
+ if err != nil {
+ return err
+ }
+ if len(pos) != 2 {
+ return errors.New("usage: carlos config destinations rename <old> <new> [--global]")
+ }
+ from, to := pos[0], pos[1]
+ if err := validDestinationName(to); err != nil {
+ return err
+ }
+ path, perm, err := destinationsFile(*global)
+ if err != nil {
+ return err
+ }
+ d, err := openDestinations(path)
+ if err != nil {
+ return err
+ }
+ block, err := d.block(from)
+ if err != nil {
+ return err
+ }
+ if _, exists := d.dests[to]; exists {
+ return fmt.Errorf("%s already has a destination %q", path, to)
+ }
+ d.dests[to] = block
+ delete(d.dests, from)
+ if d.explicitDefault() == from {
+ d.all["default"] = to
+ }
+ if err := d.save(perm); err != nil {
+ return err
+ }
+ fmt.Fprintf(out, "renamed destination %s to %s in %s\n", from, to, path)
+ return nil
+}
diff --git a/config_cli_test.go b/config_cli_test.go
new file mode 100644
index 000000000..68cab204f
--- /dev/null
+++ b/config_cli_test.go
@@ -0,0 +1,340 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// destinationsEnv is a scratch project with no config anywhere, no
+// credentials, no destination selected and no terminal. It returns the
+// project directory, which is the working directory.
+func destinationsEnv(t *testing.T) string {
+ t.Helper()
+ useTempCredentials(t)
+ t.Setenv("CARLOS_CONFIG", "")
+ t.Setenv("CARLOS_DESTINATION", "")
+ selectDestination(t, "")
+ forceTerminal(t, false)
+ project := t.TempDir()
+ t.Chdir(project)
+ return project
+}
+
+func runDestinations(args ...string) (string, error) {
+ var b bytes.Buffer
+ err := configDestinations(&b, args)
+ return b.String(), err
+}
+
+func mustDestinations(t *testing.T, args ...string) string {
+ t.Helper()
+ out, err := runDestinations(args...)
+ if err != nil {
+ t.Fatalf("config destinations %v: %v\n%s", args, err, out)
+ }
+ return out
+}
+
+func readConfigJSON(t *testing.T, path string) map[string]any {
+ t.Helper()
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m map[string]any
+ if err := json.Unmarshal(raw, &m); err != nil {
+ t.Fatal(err)
+ }
+ return m
+}
+
+func destBlock(t *testing.T, m map[string]any, name string) map[string]any {
+ t.Helper()
+ b, ok := m["destinations"].(map[string]any)[name].(map[string]any)
+ if !ok {
+ t.Fatalf("no destination %q in %v", name, m)
+ }
+ return b
+}
+
+// Adding to a file that already deploys somewhere must not silently move
+// it: without a terminal the add is refused until the current settings
+// are named, and --from-current moves exactly the where-keys into a block
+// that stays the default. Shared build keys stay at the top.
+func TestDestinationsAddConvertsAFlatFile(t *testing.T) {
+ project := destinationsEnv(t)
+ path := writeConfig(t, project, `{"console":"https://a.example.com","account":"acme","app":"shop","artifact":"./bin/server","update_check":"off"}`)
+
+ if _, err := runDestinations("add", "tito", "--console", "https://tito.example.com"); err == nil ||
+ !strings.Contains(err.Error(), "--from-current") {
+ t.Fatalf("flat-file add without --from-current = %v, want a refusal naming the flag", err)
+ }
+ if m := readConfigJSON(t, path); m["destinations"] != nil {
+ t.Fatalf("a refused add wrote the file: %v", m)
+ }
+
+ mustDestinations(t, "add", "tito", "--console", "https://tito.example.com", "--from-current", "exciting")
+ m := readConfigJSON(t, path)
+ ex := destBlock(t, m, "exciting")
+ if ex["console"] != "https://a.example.com" || ex["account"] != "acme" || ex["app"] != "shop" {
+ t.Fatalf("moved block = %v", ex)
+ }
+ if m["console"] != nil || m["account"] != nil || m["app"] != nil {
+ t.Fatalf("where-keys left at the top: %v", m)
+ }
+ if m["artifact"] != "./bin/server" || m["update_check"] != "off" || m["default"] != "exciting" {
+ t.Fatalf("shared keys or default wrong: %v", m)
+ }
+
+ // What loadConfig makes of it: bare → exciting as before; -d tito →
+ // tito's console, inheriting the shared artifact.
+ if cfg, err := loadConfig(path); err != nil || cfg.Console != "https://a.example.com" || cfg.App != "shop" {
+ t.Fatalf("bare read after convert = %+v, %v", cfg, err)
+ }
+ selectDestination(t, "tito")
+ if cfg, err := loadConfig(path); err != nil || cfg.Console != "https://tito.example.com" || cfg.Artifact != "./bin/server" {
+ t.Fatalf("tito read = %+v, %v", cfg, err)
+ }
+}
+
+// At a terminal the name for the current settings is asked for instead.
+func TestDestinationsAddConvertsAtATerminal(t *testing.T) {
+ project := destinationsEnv(t)
+ forceTerminal(t, true)
+ old := chooserIn
+ chooserIn = strings.NewReader("exciting\n")
+ t.Cleanup(func() { chooserIn = old })
+ path := writeConfig(t, project, `{"console":"https://a.example.com"}`)
+ out := mustDestinations(t, "add", "tito", "--console", "https://tito.example.com")
+ if !strings.Contains(out, "name for it:") {
+ t.Fatalf("no prompt:\n%s", out)
+ }
+ if m := readConfigJSON(t, path); destBlock(t, m, "exciting")["console"] != "https://a.example.com" || m["default"] != "exciting" {
+ t.Fatalf("terminal convert = %v", m)
+ }
+}
+
+func TestDestinationsAdd(t *testing.T) {
+ project := destinationsEnv(t)
+ path := filepath.Join(project, ".carlos", "config")
+
+ // Flags on either side of the name; the console is normalized; the
+ // file and its directory are created.
+ out := mustDestinations(t, "add", "--console", "https://a.example.com/", "prod", "-a", "shop", "--kind", "binary")
+ if !strings.Contains(out, "not logged in to https://a.example.com") {
+ t.Fatalf("no login warning:\n%s", out)
+ }
+ m := readConfigJSON(t, path)
+ if p := destBlock(t, m, "prod"); p["console"] != "https://a.example.com" || p["app"] != "shop" || p["kind"] != "binary" {
+ t.Fatalf("prod = %v", p)
+ }
+ if m["default"] != nil {
+ t.Fatalf("a lone destination needs no default: %v", m)
+ }
+
+ // The second add pins the first as the default, so bare commands do
+ // not start refusing.
+ out = mustDestinations(t, "add", "staging", "--console", "https://a.example.com", "--app", "shop-staging")
+ if !strings.Contains(out, "prod stays the default") {
+ t.Fatalf("no default note:\n%s", out)
+ }
+ if m = readConfigJSON(t, path); m["default"] != "prod" {
+ t.Fatalf("default = %v", m["default"])
+ }
+ // --default moves it.
+ mustDestinations(t, "add", "eu", "--console", "https://eu.example.com", "--default")
+ if m = readConfigJSON(t, path); m["default"] != "eu" {
+ t.Fatalf("--default = %v", m["default"])
+ }
+
+ for _, c := range []struct {
+ args []string
+ want string
+ }{
+ {[]string{"add", "prod", "--console", "https://x.example.com"}, "already has"},
+ {[]string{"add", "noconsole", "--app", "x"}, "no console"},
+ {[]string{"add", "bad name", "--console", "https://x.example.com"}, "not a usable destination name"},
+ {[]string{"add", "k", "--console", "https://x.example.com", "--kind", "docker"}, "binary or static"},
+ {[]string{"add", "u", "--console", "ftp://x"}, ""},
+ {[]string{"add", "f", "--console", "https://x.example.com", "--from-current", "g"}, "--from-current is only for"},
+ {[]string{"add"}, "usage:"},
+ } {
+ if _, err := runDestinations(c.args...); err == nil || !strings.Contains(err.Error(), c.want) {
+ t.Errorf("%v = %v, want an error containing %q", c.args, err, c.want)
+ }
+ }
+ if m2 := readConfigJSON(t, path); len(m2["destinations"].(map[string]any)) != 3 {
+ t.Fatalf("a refused add changed the file: %v", m2)
+ }
+}
+
+// A console this machine is logged in to draws no warning.
+func TestDestinationsAddLoggedInIsQuiet(t *testing.T) {
+ destinationsEnv(t)
+ f := &credentialFile{Credentials: []credential{{Console: "https://a.example.com", Address: "me@example.com", Token: "t"}}}
+ if err := f.save(); err != nil {
+ t.Fatal(err)
+ }
+ if out := mustDestinations(t, "add", "prod", "--console", "https://a.example.com"); strings.Contains(out, "not logged in") {
+ t.Fatalf("warned despite a credential:\n%s", out)
+ }
+}
+
+func TestDestinationsSet(t *testing.T) {
+ project := destinationsEnv(t)
+ path := writeConfig(t, project, `{"destinations":{"prod":{"console":"https://a.example.com","app":"shop","kind":"binary"}}}`)
+
+ mustDestinations(t, "set", "prod", "--app", "", "--kind", "static", "--artifact", "./dist")
+ p := destBlock(t, readConfigJSON(t, path), "prod")
+ if _, has := p["app"]; has || p["kind"] != "static" || p["artifact"] != "./dist" || p["console"] != "https://a.example.com" {
+ t.Fatalf("after set = %v", p)
+ }
+ for _, c := range []struct {
+ args []string
+ want string
+ }{
+ {[]string{"set", "prod"}, "nothing to change"},
+ {[]string{"set", "nope", "--app", "x"}, `no destination "nope"`},
+ {[]string{"set", "prod", "--console", ""}, "no console"},
+ {[]string{"set", "prod", "--kind", "nope"}, "binary or static"},
+ } {
+ if _, err := runDestinations(c.args...); err == nil || !strings.Contains(err.Error(), c.want) {
+ t.Errorf("%v = %v, want an error containing %q", c.args, err, c.want)
+ }
+ }
+}
+
+func TestDestinationsListAndDefault(t *testing.T) {
+ project := destinationsEnv(t)
+ if out := mustDestinations(t); !strings.Contains(out, "no destinations in") {
+ t.Fatalf("empty list = %q", out)
+ }
+ path := writeConfig(t, project, `{"artifact":"./bin/server","destinations":{
+ "prod":{"console":"https://a.example.com","app":"shop"},
+ "eu":{"console":"https://eu.example.com","app":"shop-eu"}}}`)
+
+ // Several and no default: list still works (it is how you find out),
+ // and says what is wrong; default without a name refuses the same way.
+ out := mustDestinations(t, "list")
+ for _, want := range []string{"prod", "https://eu.example.com", "shop-eu", "./bin/server", "no default"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("list missing %q:\n%s", want, out)
+ }
+ }
+ if _, err := runDestinations("default"); err == nil {
+ t.Fatal("default with several and none set must refuse")
+ }
+
+ mustDestinations(t, "default", "eu")
+ if m := readConfigJSON(t, path); m["default"] != "eu" {
+ t.Fatalf("default = %v", m["default"])
+ }
+ if out = mustDestinations(t, "default"); !strings.HasPrefix(out, "eu (from ") {
+ t.Fatalf("show default = %q", out)
+ }
+ if out = mustDestinations(t); !strings.Contains(out, "* eu") || strings.Contains(out, "* prod") {
+ t.Fatalf("list default mark:\n%s", out)
+ }
+ if _, err := runDestinations("default", "nope"); err == nil {
+ t.Fatal("default naming no destination must refuse")
+ }
+}
+
+func TestDestinationsRemoveAndRename(t *testing.T) {
+ project := destinationsEnv(t)
+ path := writeConfig(t, project, `{"default":"prod","destinations":{
+ "prod":{"console":"https://a.example.com"},
+ "eu":{"console":"https://eu.example.com"},
+ "staging":{"console":"https://a.example.com"}}}`)
+
+ // Rename carries the default with it.
+ mustDestinations(t, "rename", "prod", "production")
+ m := readConfigJSON(t, path)
+ if m["default"] != "production" || destBlock(t, m, "production")["console"] != "https://a.example.com" {
+ t.Fatalf("after rename = %v", m)
+ }
+ if _, err := runDestinations("rename", "eu", "staging"); err == nil {
+ t.Fatal("rename onto an existing name must refuse")
+ }
+
+ // The default cannot be removed while two others would be left.
+ if _, err := runDestinations("remove", "production"); err == nil || !strings.Contains(err.Error(), "is the default") {
+ t.Fatalf("remove default = %v", err)
+ }
+ mustDestinations(t, "remove", "staging")
+ // Now one would be left: removing the default promotes it.
+ out := mustDestinations(t, "remove", "production")
+ if !strings.Contains(out, "eu is now the default") {
+ t.Fatalf("promotion note missing:\n%s", out)
+ }
+ if m = readConfigJSON(t, path); m["default"] != nil {
+ t.Fatalf("stale default left: %v", m)
+ }
+ // Removing the last clears both keys.
+ mustDestinations(t, "remove", "eu")
+ if m = readConfigJSON(t, path); m["destinations"] != nil || m["default"] != nil {
+ t.Fatalf("after last remove = %v", m)
+ }
+}
+
+// The file a verb edits: the nearest project file walking up, never the
+// global one met on the way up through $HOME; --global asks for that.
+func TestDestinationsFile(t *testing.T) {
+ destinationsEnv(t)
+ home := os.Getenv("HOME")
+ project := filepath.Join(home, "code", "shop")
+ deep := filepath.Join(project, "cmd", "server")
+ if err := os.MkdirAll(deep, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ global := writeConfig(t, home, `{"console":"https://global.example.com"}`)
+ t.Chdir(deep)
+
+ // Nothing in the project: a new file right here, not the global.
+ p, _, err := destinationsFile(false)
+ if err != nil || p != filepath.Join(deep, ".carlos", "config") {
+ t.Fatalf("no project file = %q %v", p, err)
+ }
+ // A project file further up is the one edited.
+ want := writeConfig(t, project, `{}`)
+ if p, _, err = destinationsFile(false); err != nil || p != want {
+ t.Fatalf("nearest = %q %v, want %q", p, err, want)
+ }
+ if p, _, err = destinationsFile(true); err != nil || p != global {
+ t.Fatalf("--global = %q %v", p, err)
+ }
+ mustDestinations(t, "add", "g", "--console", "https://g.example.com", "--global", "--from-current", "home")
+ if destBlock(t, readConfigJSON(t, global), "g")["console"] != "https://g.example.com" {
+ t.Fatal("--global add missed the global file")
+ }
+ // In $HOME itself, ./.carlos/config IS the global file: refuse rather
+ // than edit it by accident.
+ t.Chdir(home)
+ if _, _, err = destinationsFile(false); err == nil {
+ t.Fatal("editing ~/.carlos/config without --global must refuse")
+ }
+}
+
+// auth default -d reads and writes one destination's console.
+func TestAuthDefaultDestination(t *testing.T) {
+ project := destinationsEnv(t)
+ path := writeConfig(t, project, `{"default":"prod","destinations":{"prod":{"console":"https://a.example.com"}}}`)
+ var b bytes.Buffer
+ if err := authDefault(&b, []string{"--project", "-d", "tito", "https://tito.example.com"}); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(b.String(), "the tito destination in") {
+ t.Fatalf("write echo = %q", b.String())
+ }
+ if destBlock(t, readConfigJSON(t, path), "tito")["console"] != "https://tito.example.com" {
+ t.Fatal("auth default -d did not write the tito block")
+ }
+ b.Reset()
+ if err := authDefault(&b, []string{"-d", "tito"}); err != nil || !strings.Contains(b.String(), "https://tito.example.com (from the tito destination in") {
+ t.Fatalf("show = %q %v", b.String(), err)
+ }
+}
diff --git a/docs/site/cli.md b/docs/site/cli.md
index fc75af093..60d098f31 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -69,7 +69,45 @@ watching; pass `--owner <address>` to
[carlos accounts](/docs/cli#carlos-accounts) instead. `carlos auth default`
shows the console this machine talks to by default, and sets it when you give
it a URL. With `--project` it writes `./.carlos/config`, which you can commit
-so a checkout pins its own deployment.
+so a checkout pins its own deployment. With `-d <name>` it shows or sets the
+console of one [destination](/docs/cli#deploying-one-checkout-to-several-places).
+
+### carlos config
+
+Edits `.carlos/config` for you, so you never have to hand-edit its JSON.
+`carlos config destinations` manages the named places that
+[carlos deploy](/docs/cli#carlos-deploy) and every other command reach with
+`-d <name>`:
+
+```sh
+carlos config destinations add production --console https://carlos.example.com --app shop
+carlos config destinations add partner --console https://carlos.example.org --app shop
+carlos config destinations
+carlos config destinations default partner
+carlos config destinations set partner --account me@example.org
+carlos config destinations rename partner eu
+carlos config destinations remove eu
+```
+
+`add` takes `--console`, `--account`, `--app`, `--kind`, `--artifact` and
+`--target`, and `--default` makes the new destination the default. `set`
+changes the same keys on an existing destination, and an empty value
+(`--app ""`) removes a key. `list`, which is also what the bare command does,
+marks the destination used when none is named with `*`.
+
+Each verb edits the nearest `.carlos/config` in this directory or above it,
+and creates `./.carlos/config` when there is none. `--global` edits
+`~/.carlos/config` instead. `add` warns when this machine is not logged in to
+the destination's console, and still writes the destination, so you can set
+up a console before you log in to it.
+
+These commands never let a file quietly start deploying somewhere new. If the
+file already names a console, account or app at the top, adding the first
+destination moves those keys into a destination of their own, which becomes
+the default. At a terminal, `add` asks what to call it; elsewhere you name it
+with `--from-current <name>`. When a second destination is added, the first
+one is recorded as the default. Removing the default is refused while more
+than one other destination remains.
### carlos apps
@@ -236,6 +274,8 @@ shell. With no destination named, the file's `default` applies. A file with
only one destination needs no default. A file with several destinations and
no default refuses to guess, and so does a destination name that no config
file defines. Saved answers go into the destination you are using.
+[carlos config destinations](/docs/cli#carlos-config) adds, changes and
+removes destinations, so you don't need to edit the file yourself.
`--target` is unrelated: it names the platform (GOOS-GOARCH) a binary was
built for.
diff --git a/flags.go b/flags.go
index b9495d604..ae9f0d64d 100644
--- a/flags.go
+++ b/flags.go
@@ -38,10 +38,16 @@ func flagUsage(fs *flag.FlagSet, header string) func() {
// loadConfig reads (config.go).
func consoleOption(fs *flag.FlagSet, usage string) *string {
c := fs.String("console", "", usage)
+ destinationOption(fs)
+ return c
+}
+
+// destinationOption registers --destination / -d alone, for the verbs that
+// read or write .carlos/config without taking --console (auth default).
+func destinationOption(fs *flag.FlagSet) {
fs.Var(destinationValue{}, "destination",
"use the `name`d block from .carlos/config's destinations (default: CARLOS_DESTINATION, else the file's default or only one)")
fs.Var(destinationValue{}, cliAliases["destination"], "alias for --destination")
- return c
}
// destinationValue writes straight to destinationFlag. A flag.Value rather
diff --git a/main.go b/main.go
index cb75ccf2d..ebec0f470 100644
--- a/main.go
+++ b/main.go
@@ -112,6 +112,8 @@ func main() {
RunLedger(args)
case "auth":
RunAuth(args)
+ case "config":
+ RunConfig(args)
case "accounts":
RunAccounts(args)
case "apps":
@@ -177,6 +179,8 @@ flags; --app, the flag on nearly every app verb, shortens to -a.
Your apps — ship and run software on a CARLOS deployment
auth log in, see who this terminal is, log out
+ config edit .carlos/config: "config destinations" lists, adds,
+ changes and removes the named places -d deploys to
apps create | place | delete | restore an app name — --place
fleet/<f> claims and places in one step, and "apps place"
repoints an app's routes and instances onto a customer