- манифест читается так, как записан: решётка внутри строки не открывает комментарий, скобка внутри комментария не закрывает массив, имя внутри комментария не становится подпиской; новый ключ встаёт после массива, а не внутрь него - всё записываемое проходит через manifest.Quote — обратный слэш в пути делал файл, который инструмент сам не читает - маркер локальной части переехал в doc и пропускает огороженные блоки: процитированный в примере маркер больше не считается границей, а копия без маркера не перезаписывается молча - лишний позиционный аргумент отсекается: flag прекращал разбор и прятал флаги после себя, из-за чего pull, list и check игнорировали --for - заведены тесты проверок копий, включая молчание на исправной копии
193 lines
6.9 KiB
Go
193 lines
6.9 KiB
Go
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
|
|
}
|
|
if code := noStrayArgs(env, "convy init", fs.Args()); code != OK {
|
|
return code
|
|
}
|
|
|
|
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", manifest.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(fmt.Appendf(nil, projectSkeleton, manifest.Quote(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 <topic> 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,
|
|
}}
|
|
}
|
|
|
|
// array writes a TOML array of strings.
|
|
func array(values []string) string {
|
|
parts := make([]string, len(values))
|
|
for i, v := range values {
|
|
parts[i] = manifest.Quote(v)
|
|
}
|
|
return "[" + strings.Join(parts, ", ") + "]"
|
|
}
|