Merge branch 'config-destinations-cli' into main
7 files changed,
+1127
−15
auth.go+12 −3config.go+32 −10config_cli.go+691 −0config_cli_test.go+340 −0docs/site/cli.md+41 −1flags.go+7 −1main.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 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 |