Skip to content

Commit e546c65

Browse files
authored
feat: init project (#161)
* feat(cli): add init-project command Added new 'init-project' command for creating a new project. Also updated root command to include 'init-project'. Added checks for project id in 'internal/projects' package. Additionally, changed an error message in 'internal/app' package to correctly state "invalid app ID". * fix typo * feat: use gitutil.EnsureGitignore in initialize and initproject - Import gitutil in initialize.go and initproject.go - Replace ensureGitignore() usage with gitutil.EnsureGitignore() in initialize.go - Add gitutil.EnsureGitignore() call in initproject.go * fix typo
1 parent b667a6f commit e546c65

File tree

6 files changed

+277
-5
lines changed

6 files changed

+277
-5
lines changed

cmd/initialize/initialize.go

+3-2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/Hyphen/cli/pkg/cprint"
1414
"github.com/Hyphen/cli/pkg/errors"
1515
"github.com/Hyphen/cli/pkg/flags"
16+
"github.com/Hyphen/cli/pkg/gitutil"
1617
"github.com/Hyphen/cli/pkg/prompt"
1718
"github.com/spf13/cobra"
1819
)
@@ -166,7 +167,7 @@ func runInit(cmd *cobra.Command, args []string) {
166167
return
167168
}
168169

169-
if err := ensureGitignore(manifest.ManifestSecretFile); err != nil {
170+
if err := gitutil.EnsureGitignore(manifest.ManifestSecretFile); err != nil {
170171
printer.Error(cmd, fmt.Errorf("error adding .hxkey to .gitignore: %w. Please do this manually if you wish", err))
171172
}
172173

@@ -279,7 +280,7 @@ func createGitignoredFile(cmd *cobra.Command, fileName string) error {
279280
return err
280281
}
281282

282-
if err := ensureGitignore(fileName); err != nil {
283+
if err := gitutil.EnsureGitignore(fileName); err != nil {
283284
printer.Error(cmd, fmt.Errorf("error adding %s to .gitignore: %w. Please do this manually if you wish", fileName, err))
284285
// don't error here. Keep going.
285286
}

cmd/initproject/initproject.go

+243
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
package initproject
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
9+
"github.com/Hyphen/cli/internal/manifest"
10+
"github.com/Hyphen/cli/internal/projects"
11+
"github.com/Hyphen/cli/pkg/cprint"
12+
"github.com/Hyphen/cli/pkg/errors"
13+
"github.com/Hyphen/cli/pkg/flags"
14+
"github.com/Hyphen/cli/pkg/gitutil"
15+
"github.com/Hyphen/cli/pkg/prompt"
16+
"github.com/spf13/cobra"
17+
)
18+
19+
var projectIDFlag string
20+
var printer *cprint.CPrinter
21+
22+
var InitProjectCmd = &cobra.Command{
23+
Use: "init-project <project name>",
24+
Short: "Initialize a new Hyphen project in the current directory",
25+
Long: `
26+
The init-project command sets up a new Hyphen project in your current directory.
27+
28+
This command will:
29+
- Create a new project in Hyphen
30+
- Generate a local configuration file
31+
32+
If no project name is provided, it will prompt to use the current directory name.
33+
34+
The command will guide you through:
35+
- Confirming or entering a project name
36+
- Generating or providing a project ID
37+
- Creating necessary local files
38+
39+
After initialization, you'll receive a summary of the new project, including its name,
40+
ID, and associated organization.
41+
42+
Examples:
43+
hyphen init-project
44+
hyphen init-project "My New Project"
45+
hyphen init-project "My New Project" --id my-custom-project-id
46+
`,
47+
Args: cobra.MaximumNArgs(1),
48+
Run: runInitProject,
49+
}
50+
51+
func init() {
52+
InitProjectCmd.Flags().StringVarP(&projectIDFlag, "id", "i", "", "Project ID (optional)")
53+
}
54+
55+
func runInitProject(cmd *cobra.Command, args []string) {
56+
printer = cprint.NewCPrinter(flags.VerboseFlag)
57+
58+
if err := isValidDirectory(cmd); err != nil {
59+
printer.Error(cmd, err)
60+
printer.Info("Please change to a project directory and try again.")
61+
return
62+
}
63+
64+
orgID, err := flags.GetOrganizationID()
65+
if err != nil {
66+
printer.Error(cmd, err)
67+
return
68+
}
69+
70+
projectService := projects.NewService(orgID)
71+
72+
projectName := ""
73+
if len(args) == 0 {
74+
cwd, err := os.Getwd()
75+
if err != nil {
76+
printer.Error(cmd, err)
77+
return
78+
}
79+
80+
projectName = filepath.Base(cwd)
81+
response := prompt.PromptYesNo(cmd, fmt.Sprintf("Use the current directory name '%s' as the project name?", projectName), true)
82+
if !response.Confirmed {
83+
if response.IsFlag {
84+
printer.Info("Operation cancelled due to --no flag.")
85+
return
86+
} else {
87+
projectName, err = prompt.PromptString(cmd, "What would you like the project name to be?")
88+
if err != nil {
89+
printer.Error(cmd, err)
90+
return
91+
}
92+
}
93+
}
94+
} else {
95+
projectName = args[0]
96+
}
97+
98+
projectAlternateId := getProjectID(cmd, projectName)
99+
if projectAlternateId == "" {
100+
return
101+
}
102+
103+
if manifest.ExistsLocal() {
104+
response := prompt.PromptYesNo(cmd, "Config file exists. Do you want to overwrite it?", false)
105+
if !response.Confirmed {
106+
if response.IsFlag {
107+
printer.Info("Operation cancelled due to --no flag.")
108+
} else {
109+
printer.Info("Operation cancelled.")
110+
}
111+
return
112+
}
113+
}
114+
115+
newProject := projects.Project{
116+
Name: projectName,
117+
AlternateID: projectAlternateId,
118+
}
119+
120+
createdProject, err := projectService.CreateProject(newProject)
121+
if err != nil {
122+
if !errors.Is(err, errors.ErrConflict) {
123+
printer.Error(cmd, err)
124+
return
125+
}
126+
127+
existingProject, handleErr := handleExistingProject(cmd, projectService, projectAlternateId)
128+
if handleErr != nil {
129+
printer.Error(cmd, handleErr)
130+
return
131+
}
132+
if existingProject == nil {
133+
printer.Info("Operation cancelled.")
134+
return
135+
}
136+
137+
createdProject = *existingProject
138+
}
139+
140+
mcl := manifest.Config{
141+
ProjectId: createdProject.ID,
142+
ProjectAlternateId: &createdProject.AlternateID,
143+
ProjectName: &createdProject.Name,
144+
OrganizationId: orgID,
145+
}
146+
147+
_, err = manifest.LocalInitialize(mcl)
148+
if err != nil {
149+
printer.Error(cmd, err)
150+
return
151+
}
152+
153+
if err := gitutil.EnsureGitignore(manifest.ManifestSecretFile); err != nil {
154+
printer.Error(cmd, fmt.Errorf("error adding .hxkey to .gitignore: %w. Please do this manually if you wish", err))
155+
}
156+
printInitializationSummary(createdProject.Name, createdProject.AlternateID, *createdProject.ID, orgID)
157+
}
158+
159+
func getProjectID(cmd *cobra.Command, projectName string) string {
160+
defaultProjectAlternateId := generateDefaultProjectId(projectName)
161+
projectAlternateId := projectIDFlag
162+
if projectAlternateId == "" {
163+
projectAlternateId = defaultProjectAlternateId
164+
}
165+
166+
err := projects.CheckProjectId(projectAlternateId)
167+
if err != nil {
168+
suggestedID := strings.TrimSpace(strings.Split(err.Error(), ":")[1])
169+
response := prompt.PromptYesNo(cmd, fmt.Sprintf("Invalid app ID. Do you want to use the suggested ID [%s]?", suggestedID), true)
170+
171+
if !response.Confirmed {
172+
if response.IsFlag {
173+
printer.Info("Operation cancelled due to --no flag.")
174+
return ""
175+
} else {
176+
// Prompt for a custom project ID
177+
for {
178+
var err error
179+
projectAlternateId, err = prompt.PromptString(cmd, "Enter a custom app ID:")
180+
if err != nil {
181+
printer.Error(cmd, err)
182+
return ""
183+
}
184+
185+
err = projects.CheckProjectId(projectAlternateId)
186+
if err == nil {
187+
return projectAlternateId
188+
}
189+
printer.Warning("Invalid app ID. Please try again.")
190+
}
191+
}
192+
} else {
193+
projectAlternateId = suggestedID
194+
}
195+
}
196+
return projectAlternateId
197+
}
198+
199+
func generateDefaultProjectId(projectName string) string {
200+
return strings.ToLower(strings.ReplaceAll(projectName, " ", "-"))
201+
}
202+
203+
func printInitializationSummary(projectName, projectAlternateId, projectID, orgID string) {
204+
printer.Success("Project successfully initialized")
205+
printer.Print("") // Print an empty line for spacing
206+
printer.PrintDetail("Project Name", projectName)
207+
printer.PrintDetail("Project AlternateId", projectAlternateId)
208+
printer.PrintDetail("Project ID", projectID)
209+
printer.PrintDetail("Organization ID", orgID)
210+
}
211+
212+
func isValidDirectory(cmd *cobra.Command) error {
213+
cwd, err := os.Getwd()
214+
if err != nil {
215+
return err
216+
}
217+
218+
homeDir, err := os.UserHomeDir()
219+
if err != nil {
220+
return err
221+
}
222+
223+
if cwd == homeDir {
224+
return fmt.Errorf("initialization in home directory not allowed")
225+
}
226+
227+
return nil
228+
}
229+
230+
func handleExistingProject(cmd *cobra.Command, projectService projects.ProjectService, projectAlternateId string) (*projects.Project, error) {
231+
response := prompt.PromptYesNo(cmd, fmt.Sprintf("A project with ID '%s' already exists. Do you want to use this existing project?", projectAlternateId), true)
232+
if !response.Confirmed {
233+
return nil, nil
234+
}
235+
236+
existingProject, err := projectService.GetProject(projectAlternateId)
237+
if err != nil {
238+
return nil, err
239+
}
240+
241+
printer.Info(fmt.Sprintf("Using existing project '%s' (%s)", existingProject.Name, existingProject.AlternateID))
242+
return &existingProject, nil
243+
}

cmd/root.go

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/Hyphen/cli/cmd/env/pull"
1111
"github.com/Hyphen/cli/cmd/env/push"
1212
"github.com/Hyphen/cli/cmd/initialize"
13+
"github.com/Hyphen/cli/cmd/initproject"
1314
"github.com/Hyphen/cli/cmd/link"
1415
"github.com/Hyphen/cli/cmd/project"
1516
"github.com/Hyphen/cli/cmd/setorg"
@@ -42,6 +43,7 @@ func init() {
4243
rootCmd.AddCommand(app.AppCmd)
4344
rootCmd.AddCommand(project.ProjectCmd)
4445
rootCmd.AddCommand(env.EnvCmd)
46+
rootCmd.AddCommand(initproject.InitProjectCmd)
4547

4648
// Override the default completion command with a hidden no-op command
4749
rootCmd.AddCommand(&cobra.Command{

internal/app/app.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func CheckAppId(appId string) error {
3636
suggested = strings.Trim(suggested, "-")
3737

3838
return errors.Wrapf(
39-
errors.New("invalid project ID"),
39+
errors.New("invalid app ID"),
4040
"You are using unpermitted characters. A valid App ID can only contain lowercase letters, numbers, hyphens, and underscores. Suggested valid ID: %s",
4141
suggested,
4242
)

internal/projects/projects.go

+26
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,33 @@
11
package projects
22

3+
import (
4+
"regexp"
5+
"strings"
6+
7+
"github.com/Hyphen/cli/pkg/errors"
8+
)
9+
310
type Project struct {
411
ID *string `json:"id,omitempty"`
512
AlternateID string `json:"alternateId"`
613
Name string `json:"name"`
714
}
15+
16+
func CheckProjectId(appId string) error {
17+
validRegex := regexp.MustCompile("^[a-z0-9-_]+$")
18+
if !validRegex.MatchString(appId) {
19+
suggested := strings.ToLower(appId)
20+
suggested = strings.ReplaceAll(suggested, " ", "-")
21+
suggested = regexp.MustCompile("[^a-z0-9-_]").ReplaceAllString(suggested, "-")
22+
suggested = regexp.MustCompile("-+").ReplaceAllString(suggested, "-")
23+
suggested = strings.Trim(suggested, "-")
24+
25+
return errors.Wrapf(
26+
errors.New("invalid project ID"),
27+
"You are using unpermitted characters. A valid Project ID can only contain lowercase letters, numbers, hyphens, and underscores. Suggested valid ID: %s",
28+
suggested,
29+
)
30+
}
31+
32+
return nil
33+
}

cmd/initialize/utils.go pkg/gitutil/gitutil.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package initialize
1+
package gitutil
22

33
import (
44
"bufio"
@@ -96,7 +96,7 @@ func fileEndsWithNewline(file *os.File) (bool, error) {
9696
return buf[0] == '\n', nil
9797
}
9898

99-
func ensureGitignore(appendStr string) error {
99+
func EnsureGitignore(appendStr string) error {
100100
if !gitExists() {
101101
return nil
102102
}

0 commit comments

Comments
 (0)