Skip to content

Commit a020d7e

Browse files
committed
feat(config): enforce npm run and package.json for official templates
1 parent 43058bc commit a020d7e

3 files changed

Lines changed: 132 additions & 1 deletion

File tree

‎internal/config/config.go‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"os"
88
"path/filepath"
99
"strings"
10+
11+
"github.com/hackctl/hackctl/cli/internal/templates"
1012
)
1113

1214
const ProjectConfigFilename = "hackctl.config.json"
@@ -54,6 +56,8 @@ func LoadProjectConfig(rootPath string) (ProjectConfig, error) {
5456
}
5557

5658
func validateProjectConfig(rootPath string, cfg ProjectConfig) error {
59+
officialTemplate := templates.IsOfficial(cfg.Template)
60+
5761
if len(cfg.Services) == 0 {
5862
return errors.New("config has no services")
5963
}
@@ -92,6 +96,18 @@ func validateProjectConfig(rootPath string, cfg ProjectConfig) error {
9296
return fmt.Errorf("service %s: port invalid", name)
9397
}
9498

99+
if officialTemplate {
100+
if !isNPMRunCommand(svc.Run) {
101+
return fmt.Errorf("service %s: run must use npm run", name)
102+
}
103+
104+
packageJSONPath := filepath.Join(servicePath, "package.json")
105+
info, err := os.Stat(packageJSONPath)
106+
if err != nil || info.IsDir() {
107+
return fmt.Errorf("service %s: package.json missing", name)
108+
}
109+
}
110+
95111
if strings.TrimSpace(svc.EnvFile) != "" {
96112
if _, err := resolveProjectPath(rootPath, svc.EnvFile); err != nil {
97113
return fmt.Errorf("service %s: envFile invalid", name)
@@ -131,3 +147,16 @@ func resolveProjectPath(rootPath string, value string) (string, error) {
131147

132148
return resolved, nil
133149
}
150+
151+
func isNPMRunCommand(command string) bool {
152+
fields := strings.Fields(strings.TrimSpace(command))
153+
if len(fields) < 3 {
154+
return false
155+
}
156+
157+
if !strings.EqualFold(fields[0], "npm") && !strings.EqualFold(fields[0], "npm.cmd") {
158+
return false
159+
}
160+
161+
return strings.EqualFold(fields[1], "run")
162+
}

‎internal/config/config_test.go‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ func TestLoadProjectConfigValid(t *testing.T) {
1717
if err := os.MkdirAll(filepath.Join(root, "backend"), 0o755); err != nil {
1818
t.Fatalf("could not create backend dir: %v", err)
1919
}
20+
writePackageJSON(t, filepath.Join(root, "frontend"))
21+
writePackageJSON(t, filepath.Join(root, "backend"))
2022

2123
cfg := ProjectConfig{
2224
Name: "my-app",
@@ -46,6 +48,7 @@ func TestLoadProjectConfigMissingServiceFolder(t *testing.T) {
4648
if err := os.MkdirAll(filepath.Join(root, "frontend"), 0o755); err != nil {
4749
t.Fatalf("could not create frontend dir: %v", err)
4850
}
51+
writePackageJSON(t, filepath.Join(root, "frontend"))
4952

5053
cfg := ProjectConfig{
5154
Name: "my-app",
@@ -75,6 +78,7 @@ func TestLoadProjectConfigInvalidShareDefaultService(t *testing.T) {
7578
if err := os.MkdirAll(filepath.Join(root, "frontend"), 0o755); err != nil {
7679
t.Fatalf("could not create frontend dir: %v", err)
7780
}
81+
writePackageJSON(t, filepath.Join(root, "frontend"))
7882

7983
cfg := ProjectConfig{
8084
Name: "my-app",
@@ -121,6 +125,87 @@ func TestLoadProjectConfigInvalidPathEscape(t *testing.T) {
121125
}
122126
}
123127

128+
func TestLoadProjectConfigOfficialTemplateRequiresNPMRun(t *testing.T) {
129+
root := t.TempDir()
130+
131+
if err := os.MkdirAll(filepath.Join(root, "frontend"), 0o755); err != nil {
132+
t.Fatalf("could not create frontend dir: %v", err)
133+
}
134+
writePackageJSON(t, filepath.Join(root, "frontend"))
135+
136+
cfg := ProjectConfig{
137+
Name: "my-app",
138+
Template: "mern",
139+
Services: []ServiceConfig{
140+
{Name: "frontend", CWD: "frontend", Run: "node src/index.js", Port: 3000},
141+
},
142+
Share: ShareConfig{DefaultService: "frontend", DefaultPort: 3000},
143+
}
144+
145+
writeProjectConfig(t, root, cfg)
146+
147+
_, err := LoadProjectConfig(root)
148+
if err == nil {
149+
t.Fatalf("expected npm run validation error")
150+
}
151+
152+
if !strings.Contains(err.Error(), "service frontend: run must use npm run") {
153+
t.Fatalf("unexpected error: %v", err)
154+
}
155+
}
156+
157+
func TestLoadProjectConfigOfficialTemplateRequiresPackageJSON(t *testing.T) {
158+
root := t.TempDir()
159+
160+
if err := os.MkdirAll(filepath.Join(root, "frontend"), 0o755); err != nil {
161+
t.Fatalf("could not create frontend dir: %v", err)
162+
}
163+
164+
cfg := ProjectConfig{
165+
Name: "my-app",
166+
Template: "mern",
167+
Services: []ServiceConfig{
168+
{Name: "frontend", CWD: "frontend", Run: "npm run dev", Port: 3000},
169+
},
170+
Share: ShareConfig{DefaultService: "frontend", DefaultPort: 3000},
171+
}
172+
173+
writeProjectConfig(t, root, cfg)
174+
175+
_, err := LoadProjectConfig(root)
176+
if err == nil {
177+
t.Fatalf("expected package.json validation error")
178+
}
179+
180+
if !strings.Contains(err.Error(), "service frontend: package.json missing") {
181+
t.Fatalf("unexpected error: %v", err)
182+
}
183+
}
184+
185+
func TestLoadProjectConfigCustomTemplateAllowsNonNPMRun(t *testing.T) {
186+
root := t.TempDir()
187+
188+
if err := os.MkdirAll(filepath.Join(root, "frontend"), 0o755); err != nil {
189+
t.Fatalf("could not create frontend dir: %v", err)
190+
}
191+
192+
cfg := ProjectConfig{
193+
Name: "my-app",
194+
Template: "custom",
195+
Services: []ServiceConfig{
196+
{Name: "frontend", CWD: "frontend", Run: "node src/index.js", Port: 3000},
197+
},
198+
Share: ShareConfig{DefaultService: "frontend", DefaultPort: 3000},
199+
}
200+
201+
writeProjectConfig(t, root, cfg)
202+
203+
_, err := LoadProjectConfig(root)
204+
if err != nil {
205+
t.Fatalf("unexpected error for custom template: %v", err)
206+
}
207+
}
208+
124209
func writeProjectConfig(t *testing.T, root string, cfg ProjectConfig) {
125210
t.Helper()
126211

@@ -134,3 +219,11 @@ func writeProjectConfig(t *testing.T, root string, cfg ProjectConfig) {
134219
t.Fatalf("could not write config: %v", err)
135220
}
136221
}
222+
223+
func writePackageJSON(t *testing.T, dir string) {
224+
t.Helper()
225+
226+
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}\n"), 0o644); err != nil {
227+
t.Fatalf("could not write package.json in %s: %v", dir, err)
228+
}
229+
}

‎internal/templates/registry.go‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ var registry = map[string]TemplateSource{
2323
}
2424

2525
func Resolve(templateID string) (TemplateSource, error) {
26-
templateID = strings.ToLower(strings.TrimSpace(templateID))
26+
templateID = normalizedTemplateID(templateID)
2727

2828
source, ok := registry[templateID]
2929
if ok {
@@ -33,6 +33,11 @@ func Resolve(templateID string) (TemplateSource, error) {
3333
return TemplateSource{}, fmt.Errorf("Unsupporteed template %s", templateID)
3434
}
3535

36+
func IsOfficial(templateID string) bool {
37+
_, ok := registry[normalizedTemplateID(templateID)]
38+
return ok
39+
}
40+
3641
func SupportedIDs() []string {
3742
ids := make([]string, 0, len(registry))
3843
for id := range registry {
@@ -42,3 +47,7 @@ func SupportedIDs() []string {
4247
sort.Strings(ids)
4348
return ids
4449
}
50+
51+
func normalizedTemplateID(templateID string) string {
52+
return strings.ToLower(strings.TrimSpace(templateID))
53+
}

0 commit comments

Comments
 (0)