package cli import ( "flag" "fmt" "os" "path/filepath" "strings" "git.vakhrushev.me/av/convy/internal/manifest" "git.vakhrushev.me/av/convy/internal/source" "git.vakhrushev.me/av/convy/internal/suite" ) // projectSkeleton is what a project manifest starts as. It is written as text // with its comments in place for the same reason the suite manifest is: the // file is read far more often than a tool touches it, and what a component is // for is not deducible from three keys. const projectSkeleton = `# What this repository takes from a conventions suite. # # Two manifests exist in the model, each named after what it describes: # suite.toml in a suite describes the suite itself; .conventions.toml here # describes the subscription — where the copies come from and who takes what. # # Copies are committed. Nothing is fetched on the fly, and the answer to "what # did it look like last time" is given by git rather than by a lock file. # Where the copies come from: a path on disk, relative to this file or absolute, # or a git repository over http or https. A trailing #branch, #tag or #commit # pins a revision. source = "%s" # ─── Components ───────────────────────────────────────────────────────────── # # A component is a region of the repository where every selected layer holds at # once: one language, one set of tools, one kind of application. Each one has # its own directory, and the directories differ — that is the only thing that # tells two copies of one topic apart. # # lang and stack choose the layers: a layer travels when the axis it declares is # among them, and a layer declaring no axis travels always. topics is the # subscription itself, by the names the suite gives its topics. ` func runInit(env Env, args []string) ExitCode { fs := flag.NewFlagSet("convy init", flag.ContinueOnError) fs.SetOutput(env.Err) root := fs.String("root", "", "root of the project; the current directory by default") from := fs.String("source", "", "reference to the suite: a path on disk or a git repository") component := fs.String("component", "", "name of the first component") dir := fs.String("dir", "", "directory the copies of that component go into") langAxis := fs.String("lang", "", "language of the component") stackAxis := fs.String("stack", "", "stack of the component, comma-separated") if err := fs.Parse(args); err != nil { return Usage } where := *root if where == "" { where = env.Dir } if exists(filepath.Join(where, manifest.ProjectName)) { fmt.Fprintf(env.Err, "%s already holds %s: the conventions are wired up already\n", where, manifest.ProjectName) fmt.Fprintln(env.Err, "convy add subscribes to one more topic") return Usage } given := map[string]string{ "source": *from, "component": *component, "dir": *dir, "lang": *langAxis, "stack": *stackAxis, } if len(args) == 0 { if !env.Interactive { fmt.Fprintln(env.Err, "convy init without arguments asks questions, and there is no terminal to ask on; pass --source, --component and --dir") return Usage } if err := askAll(env, initFields(), given); err != nil { return Usage } } else if err := resolve(initFields(), given); err != nil { fmt.Fprintln(env.Err, err) return Usage } // The suite is reached before the manifest is written. A manifest naming a // suite nobody can reach passes every check the tool has and helps no one. ref, err := source.Parse(given["source"]) if err != nil { fmt.Fprintln(env.Err, err) return Usage } tree, err := source.Open(ref, where) if err != nil { fmt.Fprintln(env.Err, err) return Failed } defer tree.Close() s, err := suite.Load(tree.Dir()) if err != nil { fmt.Fprintf(env.Err, "the source %s is not a conventions suite: %s\n", ref, tree.Describe(err)) return Failed } entries := [][2]string{{"dir", quote(given["dir"])}} if list := split(given["lang"]); len(list) > 0 { entries = append(entries, [2]string{"lang", array(list)}) } if list := split(given["stack"]); len(list) > 0 { entries = append(entries, [2]string{"stack", array(list)}) } entries = append(entries, [2]string{"topics", "[]"}) body, err := manifest.AddTable([]byte(fmt.Sprintf(projectSkeleton, given["source"])), "components."+given["component"], entries) if err != nil { fmt.Fprintln(env.Err, err) return Failed } if err := os.MkdirAll(where, 0o755); err != nil { fmt.Fprintln(env.Err, err) return Failed } name := filepath.Join(where, manifest.ProjectName) if err := os.WriteFile(name, body, 0o644); err != nil { fmt.Fprintln(env.Err, err) return Failed } fmt.Fprintf(env.Out, "\ncreated %s\n", name) fmt.Fprintf(env.Out, "the suite speaks %s, conventions language version %d\n", s.Manifest.Language.Lang, s.Manifest.Language.Version) topics := s.Manifest.LiveTopics() if len(topics) > 0 { fmt.Fprintf(env.Out, "\n%s to take from:\n", plural(len(topics), "topic")) for _, t := range topics { fmt.Fprintf(env.Out, " %-20s %s\n", t, s.Manifest.Topics.Live[t]) } } fmt.Fprint(env.Out, ` next: convy add subscribe and assemble convy list what is wired up and what else is there `) return OK } func initFields() []Field { return []Field{{ Flag: "source", Ask: "Where the copies come from", Hint: "A path to the suite on disk — relative to this repository or absolute — or a git repository over http or https. A trailing #branch, #tag or #commit pins a revision.", }, { Flag: "component", Ask: "Name of the component", Hint: "A region of the repository where all the chosen layers hold at once: one language, one set of tools. The name is not internal — it is how the tool answers what it assembled and where.", Check: func(v string) error { if !nameRe.MatchString(v) { return fmt.Errorf("a component is named by a plain identifier: letters, digits, a dash") } return nil }, }, { Flag: "dir", Ask: "Directory of the copies", Hint: "Where the assembled files go. Every component has its own, and two components never share one: the copies of a topic would collide by name.", Default: "docs/conventions", }, { Flag: "lang", Ask: "Language of the component", Hint: "Chooses the language layers. Empty when the suite is flat and has no axes at all.", Optional: true, }, { Flag: "stack", Ask: "Stack of the component", Hint: "Chooses the stack layers — the storage, the transport, the tools. Several are allowed, comma-separated: sqlite and postgres hold together, being different tables of one service.", Optional: true, }} } func quote(s string) string { return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"` } func array(values []string) string { parts := make([]string, len(values)) for i, v := range values { parts[i] = quote(v) } return "[" + strings.Join(parts, ", ") + "]" }