Skip to content

Commit ce3149a

Browse files
committed
STAC-22599: Validate --name in stackpack scaffold command
1 parent 74cad17 commit ce3149a

2 files changed

Lines changed: 271 additions & 13 deletions

File tree

‎cmd/stackpack/stackpack_scaffold.go‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package stackpack
33
import (
44
"fmt"
55
"os"
6+
"regexp"
67
"strings"
78

89
"github.com/spf13/cobra"
@@ -68,7 +69,7 @@ sts stackpack scaffold --template-github-repo stackvista/my-templates --name my-
6869

6970
// Common flags
7071
cmd.Flags().StringVar(&args.DestinationDir, "destination-dir", "", "Target directory where scaffolded files will be created. If not specified, uses current working directory")
71-
cmd.Flags().StringVar(&args.Name, "name", "", "Name of the stackpack (required)")
72+
cmd.Flags().StringVar(&args.Name, "name", "", "Name of the stackpack (required). Must start with [a-z] and contain only lowercase letters, digits, and hyphens")
7273
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")
7374
cmd.Flags().StringVar(&args.TemplateName, "template-name", defaultTemplateName, fmt.Sprintf("Name of the template subdirectory to use (default: %s)", defaultTemplateName))
7475
cmd.Flags().BoolVar(&args.Force, "force", false, "Overwrite existing files without prompting")
@@ -95,6 +96,11 @@ func RunStackpackScaffoldCommand(args *ScaffoldArgs) func(cli *di.Deps, cmd *cob
9596
}
9697
}
9798

99+
// Validate stackpack name
100+
if err := validateStackpackName(args.Name); err != nil {
101+
return common.NewCLIArgParseError(err)
102+
}
103+
98104
if args.TemplateLocalDir != "" {
99105
source = scaffold.NewLocalDirSource(args.TemplateLocalDir, args.TemplateName)
100106
} else {
@@ -165,6 +171,18 @@ func parseGitHubRepo(repoString string) (string, string, error) {
165171
return parts[0], parts[1], nil
166172
}
167173

174+
// validateStackpackName validates the stackpack name according to naming rules
175+
func validateStackpackName(name string) error {
176+
// Pattern: starts with [a-z], followed by [a-z0-9-]*
177+
validNamePattern := regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
178+
179+
if !validNamePattern.MatchString(name) {
180+
return fmt.Errorf("invalid stackpack name '%s': must start with a lowercase letter [a-z] and contain only lowercase letters, digits, and hyphens", name)
181+
}
182+
183+
return nil
184+
}
185+
168186
func displayNextSteps(cli *di.Deps, args *ScaffoldArgs) {
169187
cli.Printer.PrintLn("Next steps:")
170188
cli.Printer.PrintLn("1. Review the generated files in: " + args.DestinationDir)

‎cmd/stackpack/stackpack_scaffold_test.go‎

Lines changed: 252 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,26 @@ func TestStackpackScaffoldCommand_RequiredFlags(t *testing.T) {
4040

4141
for _, tt := range tests {
4242
t.Run(tt.name, func(t *testing.T) {
43+
if !tt.wantErr {
44+
// Create temporary directory for successful command execution to avoid polluting current directory
45+
tempDir, err := os.MkdirTemp("", "stackpack-required-flags-test-*")
46+
require.NoError(t, err)
47+
defer os.RemoveAll(tempDir)
48+
49+
// Change to temp directory so command uses it as default destination
50+
originalWd, err := os.Getwd()
51+
require.NoError(t, err)
52+
defer func() {
53+
err := os.Chdir(originalWd)
54+
require.NoError(t, err, "Failed to change back to original working directory")
55+
}()
56+
57+
err = os.Chdir(tempDir)
58+
require.NoError(t, err)
59+
}
60+
4361
cli, cmd := setupStackpackScaffoldCmd(t)
4462
_, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, tt.args...)
45-
defer removeStackPack()
4663

4764
if tt.wantErr {
4865
require.Error(t, err)
@@ -64,7 +81,6 @@ func TestStackpackScaffoldCommand_MutuallyExclusiveFlags(t *testing.T) {
6481
"--template-local-dir", "./templates",
6582
"--template-github-repo", "owner/repo",
6683
)
67-
defer removeStackPack()
6884
require.Error(t, err)
6985
assert.Contains(t, err.Error(), "template-github-repo")
7086
}
@@ -240,8 +256,6 @@ func TestStackpackScaffoldCommand_LocalDirSource(t *testing.T) {
240256
cli, cmd := setupStackpackScaffoldCmd(t)
241257

242258
_, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, tt.args...)
243-
defer removeStackPack()
244-
245259
if tt.wantErr {
246260
require.Error(t, err)
247261
} else {
@@ -318,7 +332,6 @@ func TestStackpackScaffoldCommand_JSONOutput(t *testing.T) {
318332
cli, cmd := setupStackpackScaffoldCmd(t)
319333

320334
_, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, tt.args...)
321-
defer removeStackPack()
322335
require.NoError(t, err)
323336

324337
if tt.expectJson {
@@ -381,7 +394,6 @@ func TestStackpackScaffoldCommand_ForceFlag(t *testing.T) {
381394
"--template-name", "generic",
382395
"--destination-dir", destDir,
383396
)
384-
defer removeStackPack()
385397
require.Error(t, err)
386398
assert.Contains(t, err.Error(), "conflicting files exist")
387399
})
@@ -397,7 +409,6 @@ func TestStackpackScaffoldCommand_ForceFlag(t *testing.T) {
397409
"--force",
398410
)
399411

400-
defer removeStackPack()
401412
require.NoError(t, err)
402413

403414
// Verify the file was overwritten
@@ -441,7 +452,6 @@ template: <<.TemplateName>>
441452
"--template-name", "custom-template",
442453
"--destination-dir", destDir,
443454
)
444-
defer removeStackPack()
445455

446456
require.NoError(t, err)
447457

@@ -609,7 +619,6 @@ display_name: <<.DisplayName>>
609619
cli, cmd := setupStackpackScaffoldCmd(t)
610620

611621
_, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, tt.args...)
612-
defer removeStackPack()
613622
require.NoError(t, err, tt.description)
614623

615624
// Verify the template variables were substituted correctly
@@ -627,7 +636,238 @@ display_name: <<.DisplayName>>
627636
}
628637
}
629638

630-
func removeStackPack() {
631-
_ = os.RemoveAll("src")
632-
_ = os.Remove("version.sbt")
639+
//nolint:funlen
640+
func TestValidateStackpackName(t *testing.T) {
641+
tests := []struct {
642+
name string
643+
stackName string
644+
wantErr bool
645+
errorSubstr string
646+
}{
647+
// Valid names
648+
{
649+
name: "valid single letter",
650+
stackName: "a",
651+
wantErr: false,
652+
},
653+
{
654+
name: "valid simple name",
655+
stackName: "app",
656+
wantErr: false,
657+
},
658+
{
659+
name: "valid name with hyphens",
660+
stackName: "my-stackpack",
661+
wantErr: false,
662+
},
663+
{
664+
name: "valid name with numbers",
665+
stackName: "app123",
666+
wantErr: false,
667+
},
668+
{
669+
name: "valid complex name",
670+
stackName: "my-app-v2-123",
671+
wantErr: false,
672+
},
673+
// Invalid names - starts with digit
674+
{
675+
name: "invalid starts with digit",
676+
stackName: "1-stackpack",
677+
wantErr: true,
678+
errorSubstr: "must start with a lowercase letter [a-z]",
679+
},
680+
{
681+
name: "invalid starts with number",
682+
stackName: "2app",
683+
wantErr: true,
684+
errorSubstr: "must start with a lowercase letter [a-z]",
685+
},
686+
// Invalid names - starts with hyphen
687+
{
688+
name: "invalid starts with hyphen",
689+
stackName: "-stackpack",
690+
wantErr: true,
691+
errorSubstr: "must start with a lowercase letter [a-z]",
692+
},
693+
// Invalid names - uppercase letters
694+
{
695+
name: "invalid uppercase first letter",
696+
stackName: "My-Stackpack",
697+
wantErr: true,
698+
errorSubstr: "must start with a lowercase letter [a-z]",
699+
},
700+
{
701+
name: "invalid camelCase",
702+
stackName: "myApp",
703+
wantErr: true,
704+
errorSubstr: "lowercase letters, digits, and hyphens",
705+
},
706+
{
707+
name: "invalid all uppercase",
708+
stackName: "APP",
709+
wantErr: true,
710+
errorSubstr: "must start with a lowercase letter [a-z]",
711+
},
712+
// Invalid names - spaces
713+
{
714+
name: "invalid with spaces",
715+
stackName: "my stackpack",
716+
wantErr: true,
717+
errorSubstr: "lowercase letters, digits, and hyphens",
718+
},
719+
{
720+
name: "invalid app name with space",
721+
stackName: "app name",
722+
wantErr: true,
723+
errorSubstr: "lowercase letters, digits, and hyphens",
724+
},
725+
// Invalid names - special characters
726+
{
727+
name: "invalid underscore",
728+
stackName: "my_stackpack",
729+
wantErr: true,
730+
errorSubstr: "lowercase letters, digits, and hyphens",
731+
},
732+
{
733+
name: "invalid at symbol",
734+
stackName: "app@domain",
735+
wantErr: true,
736+
errorSubstr: "lowercase letters, digits, and hyphens",
737+
},
738+
{
739+
name: "invalid dot",
740+
stackName: "stack.pack",
741+
wantErr: true,
742+
errorSubstr: "lowercase letters, digits, and hyphens",
743+
},
744+
{
745+
name: "invalid slash",
746+
stackName: "my/app",
747+
wantErr: true,
748+
errorSubstr: "lowercase letters, digits, and hyphens",
749+
},
750+
{
751+
name: "invalid colon",
752+
stackName: "app:v1",
753+
wantErr: true,
754+
errorSubstr: "lowercase letters, digits, and hyphens",
755+
},
756+
}
757+
758+
for _, tt := range tests {
759+
t.Run(tt.name, func(t *testing.T) {
760+
err := validateStackpackName(tt.stackName)
761+
if tt.wantErr {
762+
require.Error(t, err, "Expected validation to fail for name '%s'", tt.stackName)
763+
if tt.errorSubstr != "" {
764+
assert.Contains(t, err.Error(), tt.errorSubstr, "Error message should contain expected substring")
765+
}
766+
assert.Contains(t, err.Error(), tt.stackName, "Error message should contain the invalid name")
767+
} else {
768+
require.NoError(t, err, "Expected validation to pass for name '%s'", tt.stackName)
769+
}
770+
})
771+
}
772+
}
773+
774+
func TestStackpackScaffoldCommand_NameValidation(t *testing.T) {
775+
// Create temporary template directory for all tests to avoid GitHub template conflicts
776+
tempDir, err := os.MkdirTemp("", "stackpack-validation-test-*")
777+
require.NoError(t, err)
778+
defer os.RemoveAll(tempDir)
779+
780+
// Create a mock template structure
781+
templateDir := filepath.Join(tempDir, "templates")
782+
genericTemplateDir := filepath.Join(templateDir, "generic")
783+
err = os.MkdirAll(genericTemplateDir, 0755)
784+
require.NoError(t, err)
785+
786+
// Create a simple test template file
787+
templateFile := filepath.Join(genericTemplateDir, "test.txt")
788+
err = os.WriteFile(templateFile, []byte("Test stackpack: <<.Name>>"), 0644)
789+
require.NoError(t, err)
790+
791+
// Create destination directory
792+
destDir := filepath.Join(tempDir, "destination")
793+
err = os.MkdirAll(destDir, 0755)
794+
require.NoError(t, err)
795+
796+
tests := []struct {
797+
name string
798+
args []string
799+
wantErr bool
800+
errorSubstr string
801+
}{
802+
{
803+
name: "valid name passes validation",
804+
args: []string{
805+
"--name", "my-stackpack",
806+
"--template-local-dir", templateDir,
807+
"--destination-dir", destDir,
808+
},
809+
wantErr: false,
810+
},
811+
{
812+
name: "invalid name with uppercase fails",
813+
args: []string{
814+
"--name", "My-Stackpack",
815+
"--template-local-dir", templateDir,
816+
"--destination-dir", destDir,
817+
},
818+
wantErr: true,
819+
errorSubstr: "must start with a lowercase letter [a-z]",
820+
},
821+
{
822+
name: "invalid name with spaces fails",
823+
args: []string{
824+
"--name", "my stackpack",
825+
"--template-local-dir", templateDir,
826+
"--destination-dir", destDir,
827+
},
828+
wantErr: true,
829+
errorSubstr: "lowercase letters, digits, and hyphens",
830+
},
831+
{
832+
name: "invalid name starting with number fails",
833+
args: []string{
834+
"--name", "1-app",
835+
"--template-local-dir", templateDir,
836+
"--destination-dir", destDir,
837+
},
838+
wantErr: true,
839+
errorSubstr: "must start with a lowercase letter [a-z]",
840+
},
841+
{
842+
name: "invalid name with underscore fails",
843+
args: []string{
844+
"--name", "my_app",
845+
"--template-local-dir", templateDir,
846+
"--destination-dir", destDir,
847+
},
848+
wantErr: true,
849+
errorSubstr: "lowercase letters, digits, and hyphens",
850+
},
851+
}
852+
853+
for _, tt := range tests {
854+
t.Run(tt.name, func(t *testing.T) {
855+
cli, cmd := setupStackpackScaffoldCmd(t)
856+
857+
_, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, tt.args...)
858+
859+
if tt.wantErr {
860+
require.Error(t, err, "Expected command to fail for invalid name")
861+
if tt.errorSubstr != "" {
862+
assert.Contains(t, err.Error(), tt.errorSubstr, "Error message should contain expected validation message")
863+
}
864+
} else {
865+
require.NoError(t, err, "Expected command to succeed for valid name")
866+
867+
// Clean up destination for next test iteration
868+
_ = os.RemoveAll(destDir)
869+
_ = os.MkdirAll(destDir, 0755)
870+
}
871+
})
872+
}
633873
}

0 commit comments

Comments
 (0)