Skip to content

Commit 3034723

Browse files
authored
STAC-22599: Adding stackpack scaffold command to generate Stackpack from a template (#108)
* STAC-22599: Adding stackpack scaffold command
1 parent 2fe21d3 commit 3034723

File tree

12 files changed

+3232
-0
lines changed

12 files changed

+3232
-0
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,5 @@ result
2121
.gocache/
2222
release-notes.md
2323
release-notes.json
24+
25+
CLAUDE.*.md

cmd/stackpack.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package cmd
22

33
import (
4+
"os"
5+
46
"github.com/spf13/cobra"
57
"github.com/stackvista/stackstate-cli/cmd/stackpack"
68
"github.com/stackvista/stackstate-cli/internal/di"
@@ -22,5 +24,10 @@ func StackPackCommand(cli *di.Deps) *cobra.Command {
2224
cmd.AddCommand(stackpack.StackpackConfirmManualStepsCommand(cli))
2325
cmd.AddCommand(stackpack.StackpackDescribeCommand(cli))
2426

27+
// Only add scaffold command if experimental feature is enabled
28+
if os.Getenv("STS_EXPERIMENTAL_STACKPACK_SCAFFOLD") != "" {
29+
cmd.AddCommand(stackpack.StackpackScaffoldCommand(cli))
30+
}
31+
2532
return cmd
2633
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package stackpack
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"regexp"
8+
"strings"
9+
10+
"github.com/spf13/cobra"
11+
stscobra "github.com/stackvista/stackstate-cli/internal/cobra"
12+
"github.com/stackvista/stackstate-cli/internal/common"
13+
"github.com/stackvista/stackstate-cli/internal/di"
14+
"github.com/stackvista/stackstate-cli/pkg/scaffold"
15+
)
16+
17+
const (
18+
defaultTemplateGitHubRepo = "StackVista/stackpack-templates" // Default GitHub repository for templates
19+
defaultTemplateGitHubRef = "main" // Default branch for GitHub templates
20+
defaultTemplateGitHubPath = "templates" // Default path in GitHub repo for templates
21+
defaultTemplateName = "generic" // Default template name to use
22+
)
23+
24+
type ScaffoldArgs struct {
25+
// Local template source
26+
TemplateLocalDir string
27+
28+
// GitHub template source
29+
TemplateGitHubRepo string // Format: "owner/repo"
30+
TemplateGitHubRef string
31+
TemplateGitHubPath string
32+
33+
// Common flags
34+
DestinationDir string
35+
Name string
36+
DisplayName string
37+
TemplateName string
38+
Force bool
39+
}
40+
41+
func StackpackScaffoldCommand(cli *di.Deps) *cobra.Command {
42+
args := &ScaffoldArgs{}
43+
cmd := &cobra.Command{
44+
Use: "scaffold",
45+
Short: "Create a stackpack skeleton from a template",
46+
Long: `Create a stackpack skeleton from a template.
47+
48+
This command scaffolds a new stackpack project structure from a template source.
49+
The template can be from a local directory or a GitHub repository.
50+
The template can be customized with the stackpack name and other variables.`,
51+
Example: `# Create a stackpack using defaults (uses default GitHub repo and template)
52+
sts stackpack scaffold --name my-stackpack
53+
54+
# Create a stackpack from a local template (looks for ./templates/stackpack/ subdirectory)
55+
sts stackpack scaffold --template-local-dir ./templates --name my-awesome-stackpack --template-name stackpack
56+
57+
# Overwrite existing files without prompting
58+
sts stackpack scaffold --name my-awesome-stackpack --force
59+
60+
# Create a stackpack from a specific GitHub repository
61+
sts stackpack scaffold --template-github-repo stackvista/my-templates --name my-awesome-stackpack --template-name generic`,
62+
RunE: cli.CmdRunE(RunStackpackScaffoldCommand(args)),
63+
}
64+
65+
// Template source flags (mutually exclusive, defaults to GitHub repo if none specified)
66+
cmd.Flags().StringVar(&args.TemplateLocalDir, "template-local-dir", "", "Path to local directory containing template subdirectories")
67+
cmd.Flags().StringVar(&args.TemplateGitHubRepo, "template-github-repo", "", fmt.Sprintf("GitHub repository in format 'owner/repo' (default: %s)", defaultTemplateGitHubRepo))
68+
cmd.Flags().StringVar(&args.TemplateGitHubRef, "template-github-ref", "main", fmt.Sprintf("Git reference (branch, tag, or commit SHA) (default: %s)", defaultTemplateGitHubRef))
69+
cmd.Flags().StringVar(&args.TemplateGitHubPath, "template-github-path", "", fmt.Sprintf("Path within the repository containing template subdirectories (default: %s)", defaultTemplateGitHubPath))
70+
71+
// Common flags
72+
cmd.Flags().StringVar(&args.DestinationDir, "destination-dir", "", "Target directory where scaffolded files will be created. If not specified, uses current working directory")
73+
cmd.Flags().StringVar(&args.Name, "name", "", "Name of the stackpack (required). Must start with [a-z] and contain only lowercase letters, digits, and hyphens")
74+
cmd.Flags().StringVar(&args.DisplayName, "display-name", "", "Name that's displayed on both the StackPack listing page and on the title of the StackPack page. If not provided, the value of --name will be used")
75+
cmd.Flags().StringVar(&args.TemplateName, "template-name", defaultTemplateName, fmt.Sprintf("Name of the template subdirectory to use (default: %s)", defaultTemplateName))
76+
cmd.Flags().BoolVar(&args.Force, "force", false, "Overwrite existing files without prompting")
77+
78+
// Mark required flags
79+
cmd.MarkFlagRequired("name") //nolint:errcheck
80+
81+
// Template sources are mutually exclusive but not required (will use default GitHub repo if none specified)
82+
stscobra.MarkMutexFlags(cmd, []string{"template-local-dir", "template-github-repo"}, "template-source", false)
83+
84+
return cmd
85+
}
86+
87+
func RunStackpackScaffoldCommand(args *ScaffoldArgs) func(cli *di.Deps, cmd *cobra.Command) common.CLIError {
88+
return func(cli *di.Deps, cmd *cobra.Command) common.CLIError {
89+
// Create template source based on which source was specified
90+
var source scaffold.TemplateSource
91+
var err error
92+
93+
if args.DestinationDir == "" {
94+
args.DestinationDir, err = os.Getwd()
95+
if err != nil {
96+
return common.NewRuntimeError(fmt.Errorf("failed to get current working directory: %w", err))
97+
}
98+
}
99+
100+
// Validate stackpack name
101+
if err := validateStackpackName(args.Name); err != nil {
102+
return common.NewCLIArgParseError(err)
103+
}
104+
105+
if args.TemplateLocalDir != "" {
106+
source = scaffold.NewLocalDirSource(args.TemplateLocalDir, args.TemplateName)
107+
} else {
108+
// Use GitHub repository (either specified or default)
109+
githubRepo := defaultIfEmptyString(args.TemplateGitHubRepo, defaultTemplateGitHubRepo)
110+
githubRef := defaultIfEmptyString(args.TemplateGitHubRef, defaultTemplateGitHubRef)
111+
githubPath := defaultIfEmptyString(args.TemplateGitHubPath, defaultTemplateGitHubPath)
112+
113+
// Parse owner/repo format
114+
owner, repo, err := parseGitHubRepo(githubRepo)
115+
if err != nil {
116+
return common.NewCLIArgParseError(err)
117+
}
118+
source = scaffold.NewGitHubSource(owner, repo, githubRef, githubPath, args.TemplateName)
119+
}
120+
121+
// Create template context
122+
displayName := args.DisplayName
123+
if displayName == "" {
124+
displayName = args.Name
125+
}
126+
context := scaffold.TemplateContext{
127+
Name: args.Name,
128+
DisplayName: displayName,
129+
TemplateName: args.TemplateName,
130+
}
131+
132+
// Create scaffolder with force flag, printer, and JSON output mode
133+
scaffolder := scaffold.NewScaffolder(source, args.DestinationDir, context, args.Force, cli.Printer, cli.IsJson())
134+
// Execute scaffolding
135+
result, cleanUpFn, err := scaffolder.Scaffold(cmd.Context())
136+
if err != nil {
137+
return common.NewRuntimeError(err)
138+
}
139+
140+
err = cleanUpFn()
141+
if err != nil {
142+
return common.NewRuntimeError(fmt.Errorf("failed to clean up temporary files: %w", err))
143+
}
144+
145+
if cli.IsJson() {
146+
cli.Printer.PrintJson(map[string]interface{}{
147+
"success": result.Success,
148+
"source": result.Source,
149+
"destination": result.Destination,
150+
"name": result.Name,
151+
"template": result.Template,
152+
"files_count": result.FilesCount,
153+
"files": result.Files,
154+
})
155+
} else {
156+
// Display success message and next steps
157+
cli.Printer.Successf("✓ Scaffold complete!")
158+
cli.Printer.PrintLn("")
159+
displayNextSteps(cli, args)
160+
}
161+
162+
return nil
163+
}
164+
}
165+
166+
// parseGitHubRepo parses "owner/repo" format into separate owner and repo
167+
func parseGitHubRepo(repoString string) (string, string, error) {
168+
parts := strings.Split(repoString, "/")
169+
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
170+
return "", "", fmt.Errorf("invalid GitHub repository format '%s', expected 'owner/repo'", repoString)
171+
}
172+
return parts[0], parts[1], nil
173+
}
174+
175+
// validateStackpackName validates the stackpack name according to naming rules
176+
func validateStackpackName(name string) error {
177+
// Pattern: starts with [a-z], followed by [a-z0-9-]*
178+
validNamePattern := regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
179+
180+
if !validNamePattern.MatchString(name) {
181+
return fmt.Errorf("invalid stackpack name '%s': must start with a lowercase letter [a-z] and contain only lowercase letters, digits, and hyphens", name)
182+
}
183+
184+
return nil
185+
}
186+
187+
func displayNextSteps(cli *di.Deps, args *ScaffoldArgs) {
188+
cli.Printer.PrintLn("Next steps:")
189+
cli.Printer.PrintLn("1. Review the generated files in: " + args.DestinationDir)
190+
cli.Printer.PrintLn(fmt.Sprintf("2. Check the %s for instructions on what to do next.", filepath.Join(args.DestinationDir, "README.md")))
191+
}
192+
193+
func defaultIfEmptyString(value, defaultValue string) string {
194+
if value == "" {
195+
return defaultValue
196+
}
197+
return value
198+
}

0 commit comments

Comments
 (0)