carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// config_cli.go is `carlos config`: commands that edit .carlos/config
2// (config.go) so nobody has to hand-edit its JSON. One subcommand so far,
3// destinations — the named deploy blocks `--destination` / -d picks from
4// (amadan discussion #157).
5//
6// These verbs work on the raw file (readConfigMap), never through
7// loadConfig: a file with several destinations and no default is exactly
8// the state loadConfig refuses, and `destinations default <name>` has to be
9// able to fix it.
10package main
11
12import (
13 "errors"
14 "flag"
15 "fmt"
16 "io"
17 "os"
18 "path/filepath"
19 "regexp"
20 "strings"
21 "text/tabwriter"
22 "time"
23)
24
25const configUsage = `usage: carlos config <subcommand>
26
27 destinations the named deploy destinations in .carlos/config:
28 list | add | set | default | remove | rename
29
30"carlos config destinations help" describes each verb.
31`
32
33const destinationsUsage = `usage: carlos config destinations <verb> [flags]
34
35 list each destination's console, account, app and
36 artifact; * marks the one used without -d
37 add <name> --console <url> add one; --account, --app, --kind, --artifact
38 and --target set its other keys, and
39 --default makes it the default
40 set <name> --<key> <value> change a destination's keys; "" removes one
41 default [<name>] show or set the destination used without -d
42 remove <name> delete one
43 rename <old> <new> rename one; the default follows it
44
45Each verb works on the nearest project .carlos/config, walking up from this
46directory (creating ./.carlos/config when there is none); --global works on
47~/.carlos/config instead. A key a destination leaves out comes from the top
48of the file.
49`
50
51// RunConfig is `carlos config`.
52func RunConfig(args []string) {
53 if len(args) == 0 {
54 fmt.Fprint(os.Stderr, configUsage)
55 os.Exit(1)
56 }
57 var err error
58 switch args[0] {
59 case "destinations":
60 err = configDestinations(os.Stdout, args[1:])
61 default:
62 fmt.Fprint(os.Stderr, configUsage)
63 os.Exit(1)
64 }
65 if errors.Is(err, flag.ErrHelp) {
66 return // the flag set already printed its usage
67 }
68 if errors.As(err, new(flagParseError)) {
69 os.Exit(2) // the flag set already printed the error and its usage
70 }
71 if err != nil {
72 fmt.Fprintf(os.Stderr, "%s %v\n", colorFor(os.Stderr).bad("carlos config "+args[0]+":"), err)
73 os.Exit(1)
74 }
75}
76
77// configDestinations dispatches `carlos config destinations <verb>`. No
78// verb, or a flag first, is list.
79func configDestinations(out io.Writer, args []string) error {
80 verb := "list"
81 if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
82 verb, args = args[0], args[1:]
83 }
84 switch verb {
85 case "list", "ls":
86 return destinationsList(out, args)
87 case "add":
88 return destinationsAdd(out, args)
89 case "set":
90 return destinationsSet(out, args)
91 case "default":
92 return destinationsDefault(out, args)
93 case "remove", "rm":
94 return destinationsRemove(out, args)
95 case "rename":
96 return destinationsRename(out, args)
97 case "help":
98 fmt.Fprint(out, destinationsUsage)
99 return nil
100 }
101 return fmt.Errorf("no verb %q\n%s", verb, destinationsUsage)
102}
103
104// destinationKeys are the keys a destination block can set — cliConfig's
105// per-stack fields — in the order flags and messages present them.
106var destinationKeys = []string{"console", "account", "app", "kind", "artifact", "target"}
107
108// whereKeys are the keys that say WHERE a file deploys. A file that sets
109// any of them at the top level, with no destinations yet, must have them
110// moved into a named block before a destination is added — otherwise the
111// new block would silently become the only one, and so the default.
112var whereKeys = []string{"console", "account", "app"}
113
114var destinationNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
115
116// validDestinationName keeps names typeable as `-d <name>` and unambiguous
117// in a shell: no spaces, and never a leading "-".
118func validDestinationName(n string) error {
119 if !destinationNameRE.MatchString(n) {
120 return fmt.Errorf("%q is not a usable destination name: use letters, digits, '.', '_' and '-', starting with a letter or digit", n)
121 }
122 return nil
123}
124
125// destinationKeyFlags registers one flag per destination key, --app with
126// its claimed -a alias.
127func destinationKeyFlags(fs *flag.FlagSet) map[string]*string {
128 return map[string]*string{
129 "console": fs.String("console", "", "the console `url` this destination deploys through"),
130 "account": fs.String("account", "", "the `account` (sqid or name) the app belongs to"),
131 "app": appFlag(fs, "app name"),
132 "kind": fs.String("kind", "", "the `kind` of artifact: binary or static"),
133 "artifact": fs.String("artifact", "", "the `path` a bare carlos deploy ships: a binary, or a site directory, inside the project"),
134 "target": fs.String("target", "", "the `os-arch` the binary is built for, like linux-arm64 (binary kind)"),
135 }
136}
137
138// applyDestinationFlags copies every destination-key flag the user typed
139// into block, normalizing the console, and reports how many it changed.
140// With clear, an empty value removes the key; without, it is ignored.
141func applyDestinationFlags(fs *flag.FlagSet, vals map[string]*string, block map[string]any, clear bool) (int, error) {
142 n := 0
143 for _, k := range destinationKeys {
144 if !flagWasSet(fs, k) {
145 continue
146 }
147 v := strings.TrimSpace(*vals[k])
148 if v == "" {
149 if clear {
150 delete(block, k)
151 n++
152 }
153 continue
154 }
155 switch k {
156 case "console":
157 c, err := normalizeConsole(v)
158 if err != nil {
159 return 0, err
160 }
161 v = c
162 case "kind":
163 if v != "binary" && v != "static" {
164 return 0, fmt.Errorf("--kind %q: want binary or static", v)
165 }
166 }
167 block[k] = v
168 n++
169 }
170 return n, nil
171}
172
173// flagParseError is a flag the flag set refused. It has printed the error
174// and its usage already, so RunConfig exits without a second copy.
175type flagParseError struct{ error }
176
177func (e flagParseError) Unwrap() error { return e.error }
178
179// parseInterspersed parses flags on either side of the positional
180// arguments: Go's flag package stops at the first non-flag word, so
181// `add tito --console <url>` would otherwise leave --console unparsed.
182func parseInterspersed(fs *flag.FlagSet, args []string) ([]string, error) {
183 var pos []string
184 rest := args
185 for {
186 if err := fs.Parse(rest); err != nil {
187 if errors.Is(err, flag.ErrHelp) {
188 return nil, err
189 }
190 return nil, flagParseError{err}
191 }
192 rest = fs.Args()
193 if len(rest) == 0 {
194 return pos, nil
195 }
196 pos = append(pos, rest[0])
197 rest = rest[1:]
198 }
199}
200
201// destinationsFlagSet is the flag set every verb starts from: --global,
202// and the usage line it prints for -h.
203func destinationsFlagSet(out io.Writer, verb, usage string) (*flag.FlagSet, *bool) {
204 fs := flag.NewFlagSet("config destinations "+verb, flag.ContinueOnError)
205 fs.SetOutput(out)
206 global := fs.Bool("global", false, "work on ~/.carlos/config instead of the project's")
207 fs.Usage = flagUsage(fs, "usage: carlos config destinations "+usage)
208 return fs, global
209}
210
211// destinationsFile is the file a verb works on and the mode to create its
212// directory with: CARLOS_CONFIG when set (config.go's one-file override);
213// else ~/.carlos/config under --global; else the nearest project
214// .carlos/config walking up from here, else ./.carlos/config. The walk
215// skips ~/.carlos/config, which it meets on the way up through $HOME —
216// editing the global file must be asked for.
217func destinationsFile(global bool) (string, os.FileMode, error) {
218 if p := os.Getenv("CARLOS_CONFIG"); p != "" {
219 return p, 0o755, nil
220 }
221 gp, gerr := globalConfigPath()
222 if global {
223 return gp, 0o700, gerr // ~/.carlos also holds credentials
224 }
225 cwd, err := os.Getwd()
226 if err != nil {
227 return "", 0, err
228 }
229 for dir := cwd; ; {
230 p := filepath.Join(dir, ".carlos", "config")
231 if p != gp {
232 if _, err := os.Stat(p); err == nil {
233 return p, 0o755, nil
234 }
235 }
236 parent := filepath.Dir(dir)
237 if parent == dir {
238 break
239 }
240 dir = parent
241 }
242 p := filepath.Join(cwd, ".carlos", "config")
243 if p == gp {
244 return "", 0, errors.New("this directory's .carlos/config is the global ~/.carlos/config — run this in a project, or pass --global")
245 }
246 return p, 0o755, nil
247}
248
249// destinationsDoc is one config file opened for editing: every key kept,
250// the destinations object pulled out.
251type destinationsDoc struct {
252 path string
253 all map[string]any
254 dests map[string]any // nil or empty: the file has no destinations
255}
256
257func openDestinations(path string) (*destinationsDoc, error) {
258 all, err := readConfigMap(path)
259 if err != nil {
260 return nil, err
261 }
262 d := &destinationsDoc{path: path, all: all, dests: map[string]any{}}
263 if raw, ok := all["destinations"]; ok && raw != nil {
264 m, ok := raw.(map[string]any)
265 if !ok {
266 return nil, fmt.Errorf("%s: \"destinations\" is not an object", path)
267 }
268 d.dests = m
269 }
270 return d, nil
271}
272
273func (d *destinationsDoc) names() []string { return mapKeys(d.dests) }
274
275// block is a destination's object, or an error naming the ones there are.
276func (d *destinationsDoc) block(name string) (map[string]any, error) {
277 raw, ok := d.dests[name]
278 if !ok {
279 if len(d.dests) == 0 {
280 return nil, fmt.Errorf("%s has no destinations", d.path)
281 }
282 return nil, fmt.Errorf("%s has no destination %q (it has: %s)", d.path, name, strings.Join(d.names(), ", "))
283 }
284 m, ok := raw.(map[string]any)
285 if !ok {
286 return nil, fmt.Errorf("%s: destination %q is not an object", d.path, name)
287 }
288 return m, nil
289}
290
291func (d *destinationsDoc) explicitDefault() string { return configString(d.all, "default") }
292
293// resolvedDefault is what loadConfig uses with no destination selected,
294// or the reason it would refuse.
295func (d *destinationsDoc) resolvedDefault() (string, error) {
296 if len(d.dests) == 0 {
297 return "", nil
298 }
299 return fileDefaultDestination(d.path, d.explicitDefault(), d.names())
300}
301
302// effective is a key's value for a destination as loadConfig resolves it:
303// the block's, else the top of the file's.
304func (d *destinationsDoc) effective(name, key string) string {
305 if b, ok := d.dests[name].(map[string]any); ok {
306 if v := configString(b, key); v != "" {
307 return v
308 }
309 }
310 return configString(d.all, key)
311}
312
313func (d *destinationsDoc) save(dirPerm os.FileMode) error {
314 if len(d.dests) == 0 {
315 delete(d.all, "destinations")
316 delete(d.all, "default")
317 } else {
318 d.all["destinations"] = d.dests
319 }
320 return writeConfigMap(d.path, d.all, dirPerm)
321}
322
323func configString(m map[string]any, key string) string {
324 s, _ := m[key].(string)
325 return strings.TrimSpace(s)
326}
327
328// warnNotLoggedIn names the login a destination's console still needs.
329// Advice only: a destination may be written before its login, or
330// committed for colleagues who have their own.
331func warnNotLoggedIn(out io.Writer, console string) {
332 f, err := loadCredentials()
333 if err != nil {
334 return
335 }
336 switch c := f.find(console); {
337 case c == nil:
338 warnf(out, "not logged in to %s yet — run: carlos auth login --console %s", console, console)
339 case c.Expired(time.Now()):
340 warnf(out, "your login to %s has expired — run: carlos auth login --console %s", console, console)
341 }
342}
343
344func destinationsList(out io.Writer, args []string) error {
345 fs, global := destinationsFlagSet(out, "list", "list [--global]")
346 pos, err := parseInterspersed(fs, args)
347 if err != nil {
348 return err
349 }
350 if len(pos) != 0 {
351 return errors.New("usage: carlos config destinations list [--global]")
352 }
353 path, _, err := destinationsFile(*global)
354 if err != nil {
355 return err
356 }
357 d, err := openDestinations(path)
358 if err != nil {
359 return err
360 }
361 if len(d.dests) == 0 {
362 fmt.Fprintf(out, "no destinations in %s\n", path)
363 if c := configString(d.all, "console"); c != "" {
364 fmt.Fprintf(out, "without one it deploys to %s\n", c)
365 }
366 mutedf(out, "add one with: carlos config destinations add <name> --console <url>")
367 return nil
368 }
369 def, defErr := d.resolvedDefault()
370 fmt.Fprintf(out, "destinations in %s\n", path)
371 w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
372 fmt.Fprintln(w, " NAME\tCONSOLE\tACCOUNT\tAPP\tARTIFACT")
373 for _, n := range d.names() {
374 if _, err := d.block(n); err != nil {
375 return err
376 }
377 mark := " "
378 if n == def {
379 mark = "*"
380 }
381 cell := func(k string) string {
382 if v := d.effective(n, k); v != "" {
383 return v
384 }
385 return "-"
386 }
387 fmt.Fprintf(w, "%s %s\t%s\t%s\t%s\t%s\n", mark, n, cell("console"), cell("account"), cell("app"), cell("artifact"))
388 }
389 w.Flush()
390 if defErr != nil {
391 warnf(out, "%v", defErr)
392 } else {
393 mutedf(out, "* is used when no destination is named (-d, CARLOS_DESTINATION)")
394 }
395 return nil
396}
397
398func destinationsAdd(out io.Writer, args []string) error {
399 fs, global := destinationsFlagSet(out, "add", "add <name> --console <url> [flags]")
400 vals := destinationKeyFlags(fs)
401 makeDefault := fs.Bool("default", false, "make it the destination used when none is named")
402 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")
403 pos, err := parseInterspersed(fs, args)
404 if err != nil {
405 return err
406 }
407 if len(pos) != 1 {
408 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]")
409 }
410 name := pos[0]
411 if err := validDestinationName(name); err != nil {
412 return err
413 }
414 path, perm, err := destinationsFile(*global)
415 if err != nil {
416 return err
417 }
418 d, err := openDestinations(path)
419 if err != nil {
420 return err
421 }
422 if _, exists := d.dests[name]; exists {
423 return fmt.Errorf("%s already has a destination %q — change it with: carlos config destinations set %s --<key> <value>", path, name, name)
424 }
425 block := map[string]any{}
426 if _, err := applyDestinationFlags(fs, vals, block, false); err != nil {
427 return err
428 }
429
430 var notes []string
431 current := strings.TrimSpace(*fromCurrent)
432 var moving []string
433 for _, k := range whereKeys {
434 if configString(d.all, k) != "" {
435 moving = append(moving, k)
436 }
437 }
438 switch {
439 case len(d.dests) == 0 && len(moving) > 0:
440 // The file already deploys somewhere. Adding a block without moving
441 // those keys would make the new block the only one — the default —
442 // and every bare command here would quietly switch stacks.
443 if current == "" {
444 if !interactiveTerminal() {
445 return fmt.Errorf("%s already deploys to %s without a destination — name that destination too, so it keeps working: add --from-current <name>",
446 path, describeWhere(d.all))
447 }
448 fmt.Fprintf(out, "%s already deploys to %s without a destination.\n", path, describeWhere(d.all))
449 fmt.Fprintln(out, "Those settings move into a destination of their own, which stays the default.")
450 if current = promptLine(out, "name for it: "); current == "" {
451 return errors.New("nothing changed")
452 }
453 }
454 if err := validDestinationName(current); err != nil {
455 return err
456 }
457 if current == name {
458 return fmt.Errorf("the current settings and the new destination need different names, not both %q", name)
459 }
460 moved := map[string]any{}
461 for _, k := range moving {
462 moved[k] = d.all[k]
463 delete(d.all, k)
464 }
465 d.dests[current] = moved
466 d.all["default"] = current
467 notes = append(notes, fmt.Sprintf("moved this file's %s into destination %s, the default", strings.Join(moving, ", "), current))
468 case current != "":
469 return errors.New("--from-current is only for a file that has settings and no destinations yet")
470 case len(d.dests) == 1 && d.explicitDefault() == "" && !*makeDefault:
471 // The one block was the default by being alone; a second one would
472 // leave the file with no default at all, and every bare command here
473 // refusing. Pin it.
474 for only := range d.dests {
475 d.all["default"] = only
476 notes = append(notes, fmt.Sprintf("%s stays the default", only))
477 }
478 }
479 d.dests[name] = block
480 if *makeDefault {
481 d.all["default"] = name
482 notes = append(notes, fmt.Sprintf("%s is now the default", name))
483 }
484 console := d.effective(name, "console")
485 if console == "" {
486 return fmt.Errorf("destination %s would name no console — pass --console <url>", name)
487 }
488 if err := d.save(perm); err != nil {
489 return err
490 }
491 fmt.Fprintf(out, "added destination %s to %s\n", name, path)
492 for _, n := range notes {
493 mutedf(out, "%s", n)
494 }
495 warnNotLoggedIn(out, console)
496 return nil
497}
498
499// describeWhere is "console (account a, app x)" for a file's top level.
500func describeWhere(all map[string]any) string {
501 s := configString(all, "console")
502 if s == "" {
503 s = "its default console"
504 }
505 var extra []string
506 for _, k := range []string{"account", "app"} {
507 if v := configString(all, k); v != "" {
508 extra = append(extra, k+" "+v)
509 }
510 }
511 if len(extra) > 0 {
512 s += " (" + strings.Join(extra, ", ") + ")"
513 }
514 return s
515}
516
517func destinationsSet(out io.Writer, args []string) error {
518 fs, global := destinationsFlagSet(out, "set", "set <name> --<key> <value> [flags]")
519 vals := destinationKeyFlags(fs)
520 pos, err := parseInterspersed(fs, args)
521 if err != nil {
522 return err
523 }
524 if len(pos) != 1 {
525 return errors.New("usage: carlos config destinations set <name> [--console <url>] [--account <a>] [--app <a>] [--kind binary|static] [--artifact <path>] [--target <os-arch>] [--global]")
526 }
527 name := pos[0]
528 path, perm, err := destinationsFile(*global)
529 if err != nil {
530 return err
531 }
532 d, err := openDestinations(path)
533 if err != nil {
534 return err
535 }
536 block, err := d.block(name)
537 if err != nil {
538 return err
539 }
540 n, err := applyDestinationFlags(fs, vals, block, true)
541 if err != nil {
542 return err
543 }
544 if n == 0 {
545 return errors.New("nothing to change — pass one or more of --console, --account, --app, --kind, --artifact, --target (\"\" removes a key)")
546 }
547 console := d.effective(name, "console")
548 if console == "" {
549 return fmt.Errorf("destination %s would name no console — it needs --console <url>", name)
550 }
551 if err := d.save(perm); err != nil {
552 return err
553 }
554 fmt.Fprintf(out, "updated destination %s in %s\n", name, path)
555 if flagWasSet(fs, "console") {
556 warnNotLoggedIn(out, console)
557 }
558 return nil
559}
560
561func destinationsDefault(out io.Writer, args []string) error {
562 fs, global := destinationsFlagSet(out, "default", "default [<name>] [--global]")
563 pos, err := parseInterspersed(fs, args)
564 if err != nil {
565 return err
566 }
567 path, perm, err := destinationsFile(*global)
568 if err != nil {
569 return err
570 }
571 d, err := openDestinations(path)
572 if err != nil {
573 return err
574 }
575 switch len(pos) {
576 case 0:
577 if len(d.dests) == 0 {
578 fmt.Fprintf(out, "no destinations in %s\n", path)
579 return nil
580 }
581 def, err := d.resolvedDefault()
582 if err != nil {
583 return err
584 }
585 if d.explicitDefault() == "" {
586 fmt.Fprintf(out, "%s (the only destination in %s)\n", def, path)
587 } else {
588 fmt.Fprintf(out, "%s (from %s)\n", def, path)
589 }
590 return nil
591 case 1:
592 if _, err := d.block(pos[0]); err != nil {
593 return err
594 }
595 d.all["default"] = pos[0]
596 if err := d.save(perm); err != nil {
597 return err
598 }
599 fmt.Fprintf(out, "default destination: %s (written to %s)\n", pos[0], path)
600 return nil
601 }
602 return errors.New("usage: carlos config destinations default [<name>] [--global]")
603}
604
605func destinationsRemove(out io.Writer, args []string) error {
606 fs, global := destinationsFlagSet(out, "remove", "remove <name> [--global]")
607 pos, err := parseInterspersed(fs, args)
608 if err != nil {
609 return err
610 }
611 if len(pos) != 1 {
612 return errors.New("usage: carlos config destinations remove <name> [--global]")
613 }
614 name := pos[0]
615 path, perm, err := destinationsFile(*global)
616 if err != nil {
617 return err
618 }
619 d, err := openDestinations(path)
620 if err != nil {
621 return err
622 }
623 if _, err := d.block(name); err != nil {
624 return err
625 }
626 wasDefault := d.explicitDefault() == name
627 if wasDefault && len(d.dests) > 2 {
628 return fmt.Errorf("%s is the default destination — choose another first: carlos config destinations default <name>", name)
629 }
630 delete(d.dests, name)
631 var note string
632 switch {
633 case len(d.dests) == 0:
634 if c := configString(d.all, "console"); c != "" {
635 note = "no destinations left: this file deploys to " + c + " again"
636 } else {
637 note = "no destinations left, and nothing else in this file names a console"
638 }
639 case wasDefault:
640 delete(d.all, "default")
641 note = d.names()[0] + " is now the default, as the only destination left"
642 }
643 if err := d.save(perm); err != nil {
644 return err
645 }
646 fmt.Fprintf(out, "removed destination %s from %s\n", name, path)
647 if note != "" {
648 mutedf(out, "%s", note)
649 }
650 return nil
651}
652
653func destinationsRename(out io.Writer, args []string) error {
654 fs, global := destinationsFlagSet(out, "rename", "rename <old> <new> [--global]")
655 pos, err := parseInterspersed(fs, args)
656 if err != nil {
657 return err
658 }
659 if len(pos) != 2 {
660 return errors.New("usage: carlos config destinations rename <old> <new> [--global]")
661 }
662 from, to := pos[0], pos[1]
663 if err := validDestinationName(to); err != nil {
664 return err
665 }
666 path, perm, err := destinationsFile(*global)
667 if err != nil {
668 return err
669 }
670 d, err := openDestinations(path)
671 if err != nil {
672 return err
673 }
674 block, err := d.block(from)
675 if err != nil {
676 return err
677 }
678 if _, exists := d.dests[to]; exists {
679 return fmt.Errorf("%s already has a destination %q", path, to)
680 }
681 d.dests[to] = block
682 delete(d.dests, from)
683 if d.explicitDefault() == from {
684 d.all["default"] = to
685 }
686 if err := d.save(perm); err != nil {
687 return err
688 }
689 fmt.Fprintf(out, "renamed destination %s to %s in %s\n", from, to, path)
690 return nil
691}
692