Docs overhaul, path expansion, more tests, better prompts

Signed-off-by: jolheiser <john.olheiser@gmail.com>
pull/7/head v0.0.5
jolheiser 2020-11-21 23:25:24 -06:00
parent ff2f159802
commit 0b0de4be35
Signed by: jolheiser
GPG Key ID: B853ADA5DA7BBF7A
16 changed files with 459 additions and 188 deletions

81
CLI.md 100644
View File

@ -0,0 +1,81 @@
# NAME
tmpl - Template automation
# SYNOPSIS
tmpl
```
[--registry|-r]=[value]
[--source|-s]=[value]
```
**Usage**:
```
tmpl [GLOBAL OPTIONS] command [COMMAND OPTIONS] [ARGUMENTS...]
```
# GLOBAL OPTIONS
**--registry, -r**="": Registry directory of tmpl (default: ~/.tmpl)
**--source, -s**="": Short-name source to use
# COMMANDS
## download
Download a template
**--branch, -b**="": Branch to clone (default: main)
## init
Initialize a template
## list
List templates in the registry
## remove
Remove a template
## save
Save a local template
## source
Commands for working with sources
### list
List available sources
### add
Add a source
### remove
Remove a source
## test
Test if a directory is a valid template
## update
Update a template
## use
Use a template
**--defaults**: Use template defaults
**--force**: Overwrite existing files

146
DOCS.md
View File

@ -1,79 +1,107 @@
# NAME
# tmpl templates
tmpl - Template automation
This documentation aims to cover FAQs and setup.
# SYNOPSIS
## Setting up a template
tmpl
A "valid" tmpl template only requires two things
```
[--registry|-r]=[value]
[--source|-s]=[value]
1. A `template.toml` file in the root directory.
2. A `template` directory that serves as the "root" of the template.
## template.toml
```toml
# Key-value pairs can be simple
# The user will receive a basic prompt asking them to fill out the variable
project = "my-project"
# Extended properties MUST be added after any simple key-value pairs (due to how TOML works)
# The "key" is enclosed in braces
[author]
# prompt is what will be shown to prompt the user
prompt = "The name of the author of this project"
# help would be extra information (generally seen by giving '?' to a prompt)
help = "Who will be primarily writing this project"
# default is the "value" part of the simple pair. This could be a suggested value
default = "me"
```
**Usage**:
## template directory
This directory contains any and all files that are part of the template.
Everything in this directory (including paths and file names!) will be executed as a [Go template](https://golang.org/pkg/text/template/).
See the [documentation](https://golang.org/pkg/text/template/) for every available possibility, but some basic examples are...
* A variable defined in template.toml (tmpl allows for keys to be called as a func or variable, whichever you prefer!)
* `{{project}}` or `{{.project}}`
* `{{author}}` or `{{.author}}`
* Conditionally including something
* `{{if eq project ""}} something... {{end}}`
### template helpers
For a full list, see [helper.go](registry/helper.go)
|Helper|Example|Output|
|-----|-----|-----|
|upper|`{{upper project}}`|`MY-PROJECT`|
|lower|`{{lower project}}`|`my-project`|
|title|`{{title project}}`|`My-Project`|
|snake|`{{snake project}}`|`my_project`|
|kebab|`{{kebab project}}`|`my-project`|
|pascal|`{{pascal project}}`|`MyProject`|
|camel|`{{camel project}}`|`myProject`|
|env|`{{env "USER"}}`|The current user|
|sep|`{{sep}}`|Filepath separator for current OS|
|time}|`{{time "01/02/2006"}}`|`11/21/2020` - The time according to the given [format](https://flaviocopes.com/go-date-time-format/)|
## Sources
tmpl was designed to work with any local or git-based template. Unfortunately, in contrast to boilr, this means
it cannot be used with `user/repo` notation out of the box.
However, you _can_ set up a source (and subsequent env variable) to make it easier to use your preferred source while
still allowing for others.
### Setting up a source
Let's set up a source for [Gitea](https://gitea.com)
```
tmpl [GLOBAL OPTIONS] command [COMMAND OPTIONS] [ARGUMENTS...]
tmpl source add https://gitea.com gitea
```
# GLOBAL OPTIONS
To use it, either pass it in with the `--source` flag
**--registry, -r**="": Registry directory of tmpl (default: ~/.tmpl)
```
tmpl --source gitea download jolheiser/tmpls tmpls
```
**--source, -s**="": Short-name source to use
Or set it as the env variable `TMPL_SOURCE`
## Using a different branch
# COMMANDS
By default, tmpl will want to use a branch called `main` in your repository.
## download
If you are using another branch as your default, you can set it as the env variable `TMPL_BRANCH`
Download a template
Alternatively, you can specify on the command-line with the `--branch` flag of the `download` command
**--branch, -b**="": Branch to clone (default: main)
```
tmpl --source gitea download --branch license jolheiser/tmpls license
```
The above command would download the [license](https://gitea.com/jolheiser/tmpls/src/branch/license) template from `jolheiser/tmpls`
## init
## Putting it all together
Initialize a template
I realize that many users will be using GitHub, and most will likely still be using the `master` branch.
## list
List templates in the registry
## remove
Remove a template
## save
Save a local template
## source
Commands for working with sources
### list
List available sources
### add
Add a source
### remove
Remove a source
## test
Test if a directory is a valid template
## update
Update a template
## use
Use a template
**--defaults**: Use template defaults
1. Set up a source for GitHub
1. `tmpl source add https://github.com github`
2. Set the env variable `TMPL_SOURCE` to `github`
2. Set the env variable `TMPL_BRANCH` to `master`
3. Happy templating! `tmpl download user/repo repo`

View File

@ -6,11 +6,13 @@ Heavily inspired by [boilr](https://github.com/tmrts/boilr).
The two projects share many similarities, however other than general layout/structure the implementation is entirely my own.
[CLI Docs](DOCS.md)
[CLI Docs](CLI.md)
[Project Docs/FAQs](DOCS.md)
## Examples
Checkout the [license](https://gitea.com/jolheiser/tmpls/src/branch/license) and [makefile](https://gitea.com/jolheiser/tmpls/src/branch/makefile) branch of my [template repository](https://gitea.com/jolheiser/tmpls).
Check out the [license](https://gitea.com/jolheiser/tmpls/src/branch/license) and [makefile](https://gitea.com/jolheiser/tmpls/src/branch/makefile) branch of my [template repository](https://gitea.com/jolheiser/tmpls).
## License

View File

@ -1,7 +1,6 @@
package cmd
import (
"errors"
"fmt"
"strings"
@ -16,6 +15,7 @@ var Download = &cli.Command{
Name: "download",
Usage: "Download a template",
Description: "Download a template and save it to the local registry",
ArgsUsage: "[repository URL] [name]",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "branch",
@ -30,7 +30,7 @@ var Download = &cli.Command{
func runDownload(ctx *cli.Context) error {
if ctx.NArg() < 2 {
return errors.New("<repo> <name>")
return cli.ShowCommandHelp(ctx, ctx.Command.Name)
}
reg, err := registry.Open(flags.Registry)

View File

@ -49,7 +49,8 @@ func runInit(_ *cli.Context) error {
var comments = `# template.toml
# Write any template args here to prompt the user for, giving any defaults/options as applicable
name = "MyProject"
lang = ["Go", "Rust", "Python"]
[name]
prompt = "Project Name"
help = "The name to use in the project"
default = "tmpl"
`

View File

@ -1,8 +1,6 @@
package cmd
import (
"errors"
"go.jolheiser.com/tmpl/cmd/flags"
"go.jolheiser.com/tmpl/registry"
@ -14,12 +12,13 @@ var Remove = &cli.Command{
Name: "remove",
Usage: "Remove a template",
Description: "Remove a template from the registry",
ArgsUsage: "[name]",
Action: runRemove,
}
func runRemove(ctx *cli.Context) error {
if ctx.NArg() < 1 {
return errors.New("<name>")
return cli.ShowCommandHelp(ctx, ctx.Command.Name)
}
reg, err := registry.Open(flags.Registry)

View File

@ -1,7 +1,6 @@
package cmd
import (
"errors"
"path/filepath"
"go.jolheiser.com/tmpl/cmd/flags"
@ -15,12 +14,13 @@ var Save = &cli.Command{
Name: "save",
Usage: "Save a local template",
Description: "Save a local template to the registry",
ArgsUsage: "[path] [name]",
Action: runSave,
}
func runSave(ctx *cli.Context) error {
if ctx.NArg() < 2 {
return errors.New("<path> <name>")
return cli.ShowCommandHelp(ctx, ctx.Command.Name)
}
reg, err := registry.Open(flags.Registry)

View File

@ -1,7 +1,6 @@
package cmd
import (
"errors"
"fmt"
"os"
"text/tabwriter"
@ -37,6 +36,7 @@ var (
Name: "add",
Usage: "Add a source",
Description: "Add a new source to the registry",
ArgsUsage: "[base URL] [name]",
Action: runSourceAdd,
}
@ -44,6 +44,7 @@ var (
Name: "remove",
Usage: "Remove a source",
Description: "Remove a source from the registry",
ArgsUsage: "[name]",
Action: runSourceRemove,
}
)
@ -68,7 +69,7 @@ func runSourceList(_ *cli.Context) error {
func runSourceAdd(ctx *cli.Context) error {
if ctx.NArg() < 2 {
return errors.New("<repo> <name>")
return cli.ShowCommandHelp(ctx, ctx.Command.Name)
}
reg, err := registry.Open(flags.Registry)
@ -87,7 +88,7 @@ func runSourceAdd(ctx *cli.Context) error {
func runSourceRemove(ctx *cli.Context) error {
if ctx.NArg() < 1 {
return errors.New("<name>")
return cli.ShowCommandHelp(ctx, ctx.Command.Name)
}
reg, err := registry.Open(flags.Registry)

View File

@ -2,6 +2,7 @@ package cmd
import (
"os"
"path/filepath"
"github.com/urfave/cli/v2"
"go.jolheiser.com/beaver"
@ -10,17 +11,23 @@ import (
var Test = &cli.Command{
Name: "test",
Usage: "Test if a directory is a valid template",
Description: "Test whether the current directory is valid for use with tmpl",
Description: "Test whether a directory is valid for use with tmpl",
ArgsUsage: "[path (default: \".\")]",
Action: runTest,
}
func runTest(_ *cli.Context) error {
func runTest(ctx *cli.Context) error {
testPath := "."
if ctx.NArg() > 0 {
testPath = ctx.Args().First()
}
var errs []string
if _, err := os.Lstat("template.toml"); err != nil {
if _, err := os.Lstat(filepath.Join(testPath, "template.toml")); err != nil {
errs = append(errs, "could not find template.toml")
}
fi, err := os.Lstat("template")
fi, err := os.Lstat(filepath.Join(testPath, "template"))
if err != nil {
errs = append(errs, "no template directory found")
}

View File

@ -1,8 +1,6 @@
package cmd
import (
"errors"
"go.jolheiser.com/tmpl/cmd/flags"
"go.jolheiser.com/tmpl/registry"
@ -14,12 +12,13 @@ var Update = &cli.Command{
Name: "update",
Usage: "Update a template",
Description: "Update a template in the registry from the original source",
ArgsUsage: "[name]",
Action: runUpdate,
}
func runUpdate(ctx *cli.Context) error {
if ctx.NArg() < 1 {
return errors.New("<name>")
return cli.ShowCommandHelp(ctx, ctx.Command.Name)
}
reg, err := registry.Open(flags.Registry)

View File

@ -1,8 +1,6 @@
package cmd
import (
"errors"
"go.jolheiser.com/tmpl/cmd/flags"
"go.jolheiser.com/tmpl/registry"
@ -19,13 +17,23 @@ var Use = &cli.Command{
Name: "defaults",
Usage: "Use template defaults",
},
&cli.BoolFlag{
Name: "force",
Usage: "Overwrite existing files",
},
},
Action: runUse,
ArgsUsage: "[name] [destination (default: \".\")]",
Action: runUse,
}
func runUse(ctx *cli.Context) error {
if ctx.NArg() < 2 {
return errors.New("<name> <dest>")
if ctx.NArg() < 1 {
return cli.ShowCommandHelp(ctx, ctx.Command.Name)
}
dest := "."
if ctx.NArg() >= 2 {
dest = ctx.Args().Get(1)
}
reg, err := registry.Open(flags.Registry)
@ -38,7 +46,7 @@ func runUse(ctx *cli.Context) error {
return err
}
if err := tmpl.Execute(ctx.Args().Get(1), ctx.Bool("defaults")); err != nil {
if err := tmpl.Execute(dest, ctx.Bool("defaults"), ctx.Bool("force")); err != nil {
return err
}

View File

@ -13,7 +13,7 @@ import (
func main() {
app := cmd.NewApp()
fi, err := os.Create("DOCS.md")
fi, err := os.Create("CLI.md")
if err != nil {
panic(err)
}

130
registry/prompt.go 100644
View File

@ -0,0 +1,130 @@
package registry
import (
"fmt"
"os"
"path/filepath"
"sort"
"text/template"
"github.com/AlecAivazis/survey/v2"
"github.com/pelletier/go-toml"
)
type templatePrompt struct {
Key string `toml:"-"`
Value interface{} `toml:"-"`
Message string `toml:"prompt"`
Help string `toml:"help"`
Default interface{} `toml:"default"`
}
func prompt(dir string, defaults bool) (templatePrompts, error) {
templatePath := filepath.Join(dir, "template.toml")
if _, err := os.Lstat(templatePath); err != nil {
return nil, err
}
tree, err := toml.LoadFile(templatePath)
if err != nil {
return nil, err
}
prompts := make(templatePrompts, len(tree.Keys()))
for idx, k := range tree.Keys() {
v := tree.Get(k)
obj, ok := v.(*toml.Tree)
if !ok {
prompts[idx] = templatePrompt{
Key: k,
Message: k,
Default: v,
}
continue
}
var p templatePrompt
if err := obj.Unmarshal(&p); err != nil {
return nil, err
}
p.Key = k
if p.Message == "" {
p.Message = p.Key
}
if p.Default == nil {
p.Default = ""
}
prompts[idx] = p
}
// Return early if we only want defaults
if defaults {
return prompts, nil
}
// Sort the prompts so they are consistent
sort.Sort(prompts)
for idx, prompt := range prompts {
var p survey.Prompt
switch t := prompt.Default.(type) {
case []string:
p = &survey.Select{
Message: prompt.Message,
Options: t,
Help: prompt.Help,
}
default:
p = &survey.Input{
Message: prompt.Message,
Default: fmt.Sprintf("%v", t),
Help: prompt.Help,
}
}
var a string
if err := survey.AskOne(p, &a); err != nil {
return nil, err
}
prompts[idx].Value = a
}
return prompts, nil
}
type templatePrompts []templatePrompt
func (t templatePrompts) ToMap() map[string]interface{} {
m := make(map[string]interface{})
for _, p := range t {
if p.Value != nil {
m[p.Key] = p.Value
continue
}
m[p.Key] = p.Default
}
return m
}
func (t templatePrompts) ToFuncMap() template.FuncMap {
m := make(map[string]interface{})
for k, v := range t.ToMap() {
vv := v // Enclosure
m[k] = func() string {
return fmt.Sprintf("%v", vv)
}
}
return m
}
func (t templatePrompts) Len() int {
return len(t)
}
func (t templatePrompts) Less(i, j int) bool {
return t[i].Key > t[j].Key
}
func (t templatePrompts) Swap(i, j int) {
t[i], t[j] = t[j], t[i]
}

View File

@ -13,11 +13,6 @@ var (
regDir string
destDir string
reg *Registry
tmplContents = `{{title name}} {{.year}}`
tmplTemplate = `name = "john olheiser"
year = 2020`
tmplGold = "John Olheiser 2020"
)
func TestMain(m *testing.M) {
@ -78,30 +73,6 @@ func testGetFail(t *testing.T) {
}
}
func testExecute(t *testing.T) {
tmpl, err := reg.GetTemplate("test")
if err != nil {
t.Logf("could not get template")
t.FailNow()
}
if err := tmpl.Execute(destDir, true); err != nil {
t.Logf("could not execute template: %v\n", err)
t.FailNow()
}
contents, err := ioutil.ReadFile(filepath.Join(destDir, "TEST"))
if err != nil {
t.Logf("could not read file: %v\n", err)
t.FailNow()
}
if string(contents) != tmplGold {
t.Logf("contents did not match:\n\tExpected: %s\n\tGot: %s", tmplGold, string(contents))
t.FailNow()
}
}
func setupTemplate() {
var err error
tmplDir, err = ioutil.TempDir(os.TempDir(), "tmpl")
@ -122,10 +93,20 @@ func setupTemplate() {
panic(err)
}
// Template file
if err := os.Mkdir(filepath.Join(tmplDir, "template"), os.ModePerm); err != nil {
// Template directories
pkgPath := filepath.Join(tmplDir, "template", "{{upper package}}")
if err := os.MkdirAll(pkgPath, os.ModePerm); err != nil {
panic(err)
}
fi, err = os.Create(filepath.Join(pkgPath, ".keep"))
if err != nil {
panic(err)
}
if err := fi.Close(); err != nil {
panic(err)
}
// Template file
fi, err = os.Create(filepath.Join(tmplDir, "template", "TEST"))
if err != nil {
panic(err)

View File

@ -1,18 +1,16 @@
package registry
import (
"bytes"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"sort"
"strings"
"text/template"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/mholt/archiver/v3"
"github.com/pelletier/go-toml"
)
// Template is a tmpl project
@ -36,7 +34,7 @@ func (t *Template) ArchivePath() string {
}
// Execute runs the Template and copies to dest
func (t *Template) Execute(dest string, defaults bool) error {
func (t *Template) Execute(dest string, defaults, overwrite bool) error {
tmp, err := ioutil.TempDir(os.TempDir(), "tmpl")
if err != nil {
return err
@ -47,11 +45,13 @@ func (t *Template) Execute(dest string, defaults bool) error {
return err
}
vars, err := prompt(tmp, defaults)
prompts, err := prompt(tmp, defaults)
if err != nil {
return err
}
funcs := mergeMaps(funcMap, prompts.ToFuncMap())
base := filepath.Join(tmp, "template")
return filepath.Walk(base, func(walkPath string, walkInfo os.FileInfo, walkErr error) error {
if walkErr != nil {
@ -67,13 +67,19 @@ func (t *Template) Execute(dest string, defaults bool) error {
return err
}
tmpl, err := template.New("tmpl").Funcs(mergeMaps(funcMap, convertMap(vars))).Parse(string(contents))
newDest := strings.TrimPrefix(walkPath, base+"/")
newDest = filepath.Join(dest, newDest)
tmplDest, err := template.New("dest").Funcs(funcs).Parse(newDest)
if err != nil {
return err
}
newDest := strings.TrimPrefix(walkPath, base+"/")
newDest = filepath.Join(dest, newDest)
var buf bytes.Buffer
if err := tmplDest.Execute(&buf, prompts.ToMap()); err != nil {
return err
}
newDest = buf.String()
if err := os.MkdirAll(filepath.Dir(newDest), os.ModePerm); err != nil {
return err
@ -83,12 +89,22 @@ func (t *Template) Execute(dest string, defaults bool) error {
if err != nil {
return err
}
// Check if new file exists. If it does, only skip if not overwriting
if _, err := os.Lstat(newDest); err == nil && !overwrite {
return nil
}
newFi, err := os.OpenFile(newDest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, oldFi.Mode())
if err != nil {
return err
}
if err := tmpl.Execute(newFi, vars); err != nil {
tmplContents, err := template.New("tmpl").Funcs(funcs).Parse(string(contents))
if err != nil {
return err
}
if err := tmplContents.Execute(newFi, prompts.ToMap()); err != nil {
return err
}
@ -96,66 +112,6 @@ func (t *Template) Execute(dest string, defaults bool) error {
})
}
func prompt(dir string, defaults bool) (map[string]interface{}, error) {
templatePath := filepath.Join(dir, "template.toml")
if _, err := os.Lstat(templatePath); err != nil {
return nil, err
}
tree, err := toml.LoadFile(templatePath)
if err != nil {
return nil, err
}
vars := tree.ToMap()
// Return early if we only want defaults
if defaults {
return vars, nil
}
// Sort the map keys so they are consistent
sorted := make([]string, 0, len(vars))
for k := range vars {
sorted = append(sorted, k)
}
sort.Strings(sorted)
for _, k := range sorted {
v := vars[k]
var p survey.Prompt
switch t := v.(type) {
case []string:
p = &survey.Select{
Message: k,
Options: t,
}
default:
p = &survey.Input{
Message: k,
Default: fmt.Sprintf("%v", t),
}
}
var a string
if err := survey.AskOne(p, &a); err != nil {
return nil, err
}
vars[k] = a
}
return vars, nil
}
func convertMap(m map[string]interface{}) template.FuncMap {
mm := make(template.FuncMap)
for k, v := range m {
vv := v // Enclosures in a loop
mm[k] = func() interface{} {
return fmt.Sprintf("%v", vv)
}
}
return mm
}
func mergeMaps(maps ...map[string]interface{}) map[string]interface{} {
m := make(map[string]interface{})
for _, mm := range maps {

View File

@ -0,0 +1,78 @@
package registry
import (
"io/ioutil"
"os"
"path/filepath"
"testing"
)
var (
tmplContents = `{{title name}} {{.year}}`
tmplTemplate = `name = "john olheiser"
[year]
default = 2020
[package]
default = "pkg"`
tmplGold = "John Olheiser 2020"
tmplNewGold = "DO NOT OVERWRITE!"
)
func testExecute(t *testing.T) {
// Get template
tmpl, err := reg.GetTemplate("test")
if err != nil {
t.Logf("could not get template")
t.FailNow()
}
// Execute template
if err := tmpl.Execute(destDir, true, true); err != nil {
t.Logf("could not execute template: %v\n", err)
t.FailNow()
}
// Check contents of file
testPath := filepath.Join(destDir, "TEST")
contents, err := ioutil.ReadFile(testPath)
if err != nil {
t.Logf("could not read file: %v\n", err)
t.FailNow()
}
if string(contents) != tmplGold {
t.Logf("contents did not match:\n\tExpected: %s\n\tGot: %s", tmplGold, string(contents))
t.FailNow()
}
// Check if directory was created
pkgPath := filepath.Join(destDir, "PKG")
if _, err := os.Lstat(pkgPath); err != nil {
t.Logf("expected a directory at %s: %v\n", pkgPath, err)
t.FailNow()
}
// Change file to test non-overwrite
if err := ioutil.WriteFile(testPath, []byte(tmplNewGold), os.ModePerm); err != nil {
t.Logf("could not write file: %v\n", err)
t.FailNow()
}
if err := tmpl.Execute(destDir, true, false); err != nil {
t.Logf("could not execute template: %v\n", err)
t.FailNow()
}
contents, err = ioutil.ReadFile(testPath)
if err != nil {
t.Logf("could not read file: %v\n", err)
t.FailNow()
}
if string(contents) != tmplNewGold {
t.Logf("contents did not match:\n\tExpected: %s\n\tGot: %s", tmplNewGold, string(contents))
t.FailNow()
}
}