Skip to content

Commit eaf4694

Browse files
feat(template): template new authoring wizard + scaffold builder (spec 23) (#81)
Add `devstack template new` — the interactive sibling of `template init` that authors a complete template bundle (template.yaml + optional build/ tree + golden) through one deterministic builder fed by two front-ends. - internal/template/scaffold: pure, deterministic Build(Spec)->Bundle (app vs engine is a compile-time branch; meta emitted as literal YAML via ordered goccy MapSlice; actions only under service:/build); WriteBundle atomic + no-clobber/--force backup; PreviewSource for the real Resolve+LintResolved preview. - Three authoring lints (meta-templating hard error; delimiter-collision and param-type warnings) shared by `template new` preview AND wired into `template lint` (template.ParseCheck added for collision detection). - CLI: newTemplateNewCmd with the full flag surface (--kind/--name/--from/ --extends/--base-image/--param/--port/--provides/--exports/--entrypoint/ --golden/--regold/--dir/--dry-run/--force/--print-spec) + a Bubble Tea v2 wizard gated on prompt.IsInteractive so --json/--quiet/--no-input/non-TTY/CI never enter bubbletea. --print-spec ⇒ --from is a byte-identical round-trip. - template.ValidRef exported for name validation; `template init` unchanged. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 19bf7b9 commit eaf4694

13 files changed

Lines changed: 1791 additions & 9 deletions

File tree

internal/cli/template.go

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/open-source-cloud/devstack/internal/generate"
1111
"github.com/open-source-cloud/devstack/internal/store"
1212
"github.com/open-source-cloud/devstack/internal/template"
13+
"github.com/open-source-cloud/devstack/internal/template/scaffold"
1314
)
1415

1516
// newTemplateCmd wires `devstack template list|lint|test|init` — the M1 template
@@ -25,6 +26,7 @@ func newTemplateCmd(g *GlobalOpts) *cobra.Command {
2526
newTemplateLintCmd(g),
2627
newTemplateTestCmd(g),
2728
newTemplateInitCmd(g),
29+
newTemplateNewCmd(g),
2830
)
2931
return cmd
3032
}
@@ -74,12 +76,15 @@ func newTemplateLintCmd(g *GlobalOpts) *cobra.Command {
7476
Short: "Render a template with defaults and validate it through compose-go",
7577
Args: cobra.ExactArgs(1),
7678
RunE: func(cmd *cobra.Command, args []string) error {
77-
compose, name, err := lintTemplateDir(args[0])
79+
compose, name, warnings, err := lintTemplateDir(args[0])
7880
if err != nil {
7981
return err
8082
}
8183
if g.JSON {
82-
return writeJSON(cmd, map[string]any{"ok": true, "template": name})
84+
return writeJSON(cmd, map[string]any{"ok": true, "template": name, "warnings": warnings})
85+
}
86+
for _, w := range warnings {
87+
fmt.Fprintf(cmd.ErrOrStderr(), "warning: %s\n", w)
8388
}
8489
if show {
8590
fmt.Fprintf(cmd.OutOrStdout(), "%s", compose)
@@ -101,7 +106,7 @@ func newTemplateTestCmd(g *GlobalOpts) *cobra.Command {
101106
Short: "Render a template with defaults and assert it validates (and matches golden, if present)",
102107
Args: cobra.ExactArgs(1),
103108
RunE: func(cmd *cobra.Command, args []string) error {
104-
compose, name, err := lintTemplateDir(args[0])
109+
compose, name, _, err := lintTemplateDir(args[0])
105110
if err != nil {
106111
return err
107112
}
@@ -146,21 +151,54 @@ func newTemplateInitCmd(g *GlobalOpts) *cobra.Command {
146151
}
147152

148153
// lintTemplateDir resolves and validates a template directory, returning the
149-
// rendered single-service compose and the template name.
150-
func lintTemplateDir(dir string) ([]byte, string, error) {
154+
// rendered single-service compose, the template name, and any authoring-lint
155+
// warnings. The authoring lints (spec 23: meta-templating/delimiter-collision/
156+
// param-type) run first — a meta-templating action is a hard error — then the
157+
// compose-go render+validation.
158+
func lintTemplateDir(dir string) ([]byte, string, []string, error) {
151159
src, name, err := template.NewDirSource(dir)
152160
if err != nil {
153-
return nil, "", err
161+
return nil, "", nil, err
162+
}
163+
manifest, err := os.ReadFile(filepath.Join(dir, template.TemplateFile))
164+
if err != nil {
165+
return nil, name, nil, err
166+
}
167+
lr, err := scaffold.Lint(manifest, readBuildFiles(dir))
168+
if err != nil {
169+
return nil, name, nil, err
154170
}
155171
res, err := template.Resolve(src, name, nil)
156172
if err != nil {
157-
return nil, name, err
173+
return nil, name, lr.Warnings, err
158174
}
159175
compose, err := generate.LintResolved(name, res)
160176
if err != nil {
161-
return nil, name, err
177+
return nil, name, lr.Warnings, err
162178
}
163-
return compose, name, nil
179+
return compose, name, lr.Warnings, nil
180+
}
181+
182+
// readBuildFiles reads a template dir's build/ tree into a relpath→bytes map
183+
// (keys like "build/Dockerfile"), the shape scaffold.Lint expects. A missing
184+
// build/ dir yields an empty map.
185+
func readBuildFiles(dir string) map[string][]byte {
186+
out := map[string][]byte{}
187+
buildDir := filepath.Join(dir, template.BuildDir)
188+
_ = filepath.WalkDir(buildDir, func(p string, d os.DirEntry, err error) error {
189+
if err != nil || d.IsDir() {
190+
return nil
191+
}
192+
rel, rerr := filepath.Rel(dir, p)
193+
if rerr != nil {
194+
return nil
195+
}
196+
if data, rerr := os.ReadFile(p); rerr == nil {
197+
out[filepath.ToSlash(rel)] = data
198+
}
199+
return nil
200+
})
201+
return out
164202
}
165203

166204
// scaffoldTemplate writes a minimal template.yaml + build/Dockerfile skeleton.

0 commit comments

Comments
 (0)