// 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 ` 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 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 [flags] list each destination's console, account, app and artifact; * marks the one used without -d add --console add one; --account, --app, --kind, --artifact and --target set its other keys, and --default makes it the default set -- change a destination's keys; "" removes one default [] show or set the destination used without -d remove delete one rename 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 `. 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 ` 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 ` 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 --console ") 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 --console [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 --console [--account ] [--app ] [--kind binary|static] [--artifact ] [--target ] [--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 -- ", 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 ", 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 ", 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 -- [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 [--console ] [--account ] [--app ] [--kind binary|static] [--artifact ] [--target ] [--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 ", 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 [] [--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 [] [--global]") } func destinationsRemove(out io.Writer, args []string) error { fs, global := destinationsFlagSet(out, "remove", "remove [--global]") pos, err := parseInterspersed(fs, args) if err != nil { return err } if len(pos) != 1 { return errors.New("usage: carlos config destinations remove [--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) } 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 [--global]") pos, err := parseInterspersed(fs, args) if err != nil { return err } if len(pos) != 2 { return errors.New("usage: carlos config destinations rename [--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 }