carlos / platform Public

cli: carlos config destinations — manage deploy destinations

`.carlos/config` destinations (1bcc593) could only be edited by hand.
`carlos config destinations` now lists, adds, sets, renames and removes
them, and shows or sets the default (amadan discussion #157):

    carlos config destinations add tito --console <url> -a shop
    carlos config destinations default tito
    carlos config destinations set tito --app ""

It edits the nearest project .carlos/config (./.carlos/config when there
is none), or ~/.carlos/config under --global, never the global file by
accident. It works on the raw JSON rather than loadConfig, so `default`
can repair a file loadConfig refuses.

Nothing it does silently changes where a bare command deploys: adding
the first destination to a file with a top-level console/account/app
moves those into a named block that stays the default (asked for at a
terminal, --from-current elsewhere); adding a second pins the first as
the default; removing the default is refused while two others remain.
`add` warns when this machine is not logged in to the console.

`carlos auth default` takes -d, reading and writing one destination's
console.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
James Adam pushed by james@keymail.dev 6d2288b7de4638a437f8a8c8168893cf83d461ad parent 5a409bd
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 failed — 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 6a731adb4..8abcac2a7 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