diff --git a/cmd/gf/internal/cmd/cmd_init.go b/cmd/gf/internal/cmd/cmd_init.go index 5b583345d..85187256e 100644 --- a/cmd/gf/internal/cmd/cmd_init.go +++ b/cmd/gf/internal/cmd/cmd_init.go @@ -7,9 +7,11 @@ package cmd import ( + "bufio" "context" "fmt" "os" + "strconv" "strings" "github.com/gogf/gf/v2/frame/g" @@ -20,6 +22,7 @@ import ( "github.com/gogf/gf/v2/text/gstr" "github.com/gogf/gf/v2/util/gtag" + "github.com/gogf/gf/cmd/gf/v2/internal/cmd/geninit" "github.com/gogf/gf/cmd/gf/v2/internal/utility/allyes" "github.com/gogf/gf/cmd/gf/v2/internal/utility/mlog" "github.com/gogf/gf/cmd/gf/v2/internal/utility/utils" @@ -44,6 +47,12 @@ const ( gf init my-project gf init my-mono-repo -m gf init my-mono-repo -a +gf init my-project -u +gf init my-project -g "github.com/myorg/myproject" +gf init -r github.com/gogf/template-single my-project +gf init -r github.com/gogf/template-single my-project -s +gf init -r github.com/gogf/examples/httpserver/jwt my-jwt +gf init -i ` cInitNameBrief = ` name for the project. It will create a folder with NAME in current directory. @@ -55,6 +64,16 @@ The NAME will also be the module name for the project. cInitGitignore = ".gitignore" ) +// defaultTemplates is the list of predefined templates for interactive selection +var defaultTemplates = []struct { + Name string + Repo string + Desc string +}{ + {"template-single", "github.com/gogf/template-single", "Single project template"}, + {"template-mono", "github.com/gogf/template-mono", "Mono-repo project template"}, +} + func init() { gtag.Sets(g.MapStrStr{ `cInitBrief`: cInitBrief, @@ -64,17 +83,86 @@ func init() { } type cInitInput struct { - g.Meta `name:"init"` - Name string `name:"NAME" arg:"true" v:"required" brief:"{cInitNameBrief}"` - Mono bool `name:"mono" short:"m" brief:"initialize a mono-repo instead a single-repo" orphan:"true"` - MonoApp bool `name:"monoApp" short:"a" brief:"initialize a mono-repo-app instead a single-repo" orphan:"true"` - Update bool `name:"update" short:"u" brief:"update to the latest goframe version" orphan:"true"` - Module string `name:"module" short:"g" brief:"custom go module"` + g.Meta `name:"init"` + Name string `name:"NAME" arg:"true" brief:"{cInitNameBrief}"` + Mono bool `name:"mono" short:"m" brief:"initialize a mono-repo instead a single-repo" orphan:"true"` + MonoApp bool `name:"monoApp" short:"a" brief:"initialize a mono-repo-app instead a single-repo" orphan:"true"` + Update bool `name:"update" short:"u" brief:"update to the latest goframe version" orphan:"true"` + Module string `name:"module" short:"g" brief:"custom go module"` + Repo string `name:"repo" short:"r" brief:"remote repository URL for template download"` + SelectVer bool `name:"select" short:"s" brief:"enable interactive version selection for remote template" orphan:"true"` + Interactive bool `name:"interactive" short:"i" brief:"enable interactive mode to select template" orphan:"true"` } type cInitOutput struct{} func (c cInit) Index(ctx context.Context, in cInitInput) (out *cInitOutput, err error) { + // Check if using remote template mode + if in.Repo != "" || in.Interactive { + return c.initFromRemote(ctx, in) + } + + // If no name provided and no remote mode, enter interactive mode + if in.Name == "" { + return c.initInteractive(ctx, in) + } + + // Default: use built-in template + return c.initFromBuiltin(ctx, in) +} + +// initFromRemote initializes project from remote repository +func (c cInit) initFromRemote(ctx context.Context, in cInitInput) (out *cInitOutput, err error) { + repo := in.Repo + name := in.Name + + // If interactive mode and no repo specified, let user select + if in.Interactive && repo == "" { + var modPath string + var upgradeDeps bool + repo, name, modPath, upgradeDeps, err = interactiveSelectTemplate() + if err != nil { + return nil, err + } + if modPath != "" { + in.Module = modPath + } + if upgradeDeps { + in.Update = true + } + } + + if repo == "" { + return nil, fmt.Errorf("repository URL is required for remote template mode") + } + + // Default name to repo basename if empty + if name == "" { + name = gfile.Basename(repo) + mlog.Printf("Using repository basename as project name: %s", name) + } + + mlog.Print("initializing from remote template...") + + opts := &geninit.ProcessOptions{ + SelectVersion: in.SelectVer, + ModulePath: in.Module, + UpgradeDeps: in.Update, + } + + if err = geninit.Process(ctx, repo, name, opts); err != nil { + return nil, err + } + + mlog.Print("initialization done!") + if name != "" && name != "." { + mlog.Printf(`you can now run "cd %s && gf run main.go" to start your journey, enjoy!`, name) + } + return +} + +// initFromBuiltin initializes project from built-in template +func (c cInit) initFromBuiltin(ctx context.Context, in cInitInput) (out *cInitOutput, err error) { var overwrote = false if !gfile.IsEmpty(in.Name) && !allyes.Check() { s := gcmd.Scanf(`the folder "%s" is not empty, files might be overwrote, continue? [y/n]: `, in.Name) @@ -180,3 +268,170 @@ func (c cInit) Index(ctx context.Context, in cInitInput) (out *cInitOutput, err } return } + +// initInteractive enters interactive mode when no arguments provided +func (c cInit) initInteractive(ctx context.Context, in cInitInput) (out *cInitOutput, err error) { + reader := bufio.NewReader(os.Stdin) + + // Ask user which mode to use + fmt.Println("\nPlease select initialization mode:") + fmt.Println(strings.Repeat("-", 50)) + fmt.Println(" [1] Built-in template (default)") + fmt.Println(" [2] Remote template") + fmt.Println(strings.Repeat("-", 50)) + + fmt.Print("Select mode [1-2] (default: 1): ") + input, err := reader.ReadString('\n') + if err != nil { + mlog.Fatalf("failed to read input: %v", err) + return + } + input = strings.TrimSpace(input) + + if input == "2" { + in.Interactive = true + return c.initFromRemote(ctx, in) + } + + // Built-in template mode + fmt.Println("\nPlease select project type:") + fmt.Println(strings.Repeat("-", 50)) + fmt.Println(" [1] Single project (default)") + fmt.Println(" [2] Mono-repo project") + fmt.Println(" [3] Mono-repo app") + fmt.Println(strings.Repeat("-", 50)) + + fmt.Print("Select type [1-3] (default: 1): ") + input, err = reader.ReadString('\n') + if err != nil { + mlog.Fatalf("failed to read input: %v", err) + return + } + input = strings.TrimSpace(input) + + switch input { + case "2": + in.Mono = true + case "3": + in.MonoApp = true + } + + // Get project name + for { + fmt.Print("Enter project name: ") + input, err = reader.ReadString('\n') + if err != nil { + mlog.Fatalf("failed to read input: %v", err) + return + } + in.Name = strings.TrimSpace(input) + if in.Name != "" { + break + } + fmt.Println("Project name cannot be empty") + } + + // Get module path (optional) + fmt.Printf("Enter Go module path (leave empty to use \"%s\"): ", in.Name) + input, err = reader.ReadString('\n') + if err != nil { + mlog.Fatalf("failed to read input: %v", err) + return + } + in.Module = strings.TrimSpace(input) + + // Ask about update + fmt.Print("Update to latest GoFrame version? [y/N]: ") + input, err = reader.ReadString('\n') + if err != nil { + mlog.Fatalf("failed to read input: %v", err) + return + } + input = strings.TrimSpace(strings.ToLower(input)) + in.Update = input == "y" || input == "yes" + + fmt.Println() + return c.initFromBuiltin(ctx, in) +} + +// interactiveSelectTemplate prompts user to select a template interactively +func interactiveSelectTemplate() (repo, name, modPath string, upgradeDeps bool, err error) { + reader := bufio.NewReader(os.Stdin) + + // 1. Select template + fmt.Println("\nPlease select a project template:") + fmt.Println(strings.Repeat("-", 50)) + for i, t := range defaultTemplates { + fmt.Printf(" [%d] %s - %s\n", i+1, t.Name, t.Desc) + } + fmt.Printf(" [%d] Custom repository URL\n", len(defaultTemplates)+1) + fmt.Println(strings.Repeat("-", 50)) + + for { + fmt.Printf("Select template [1-%d]: ", len(defaultTemplates)+1) + input, err := reader.ReadString('\n') + if err != nil { + return "", "", "", false, fmt.Errorf("failed to read template selection: %w", err) + } + input = strings.TrimSpace(input) + + idx, e := strconv.Atoi(input) + if e != nil || idx < 1 || idx > len(defaultTemplates)+1 { + fmt.Printf("Invalid selection, please enter a number between 1-%d\n", len(defaultTemplates)+1) + continue + } + + if idx <= len(defaultTemplates) { + repo = defaultTemplates[idx-1].Repo + fmt.Printf("Selected: %s\n\n", repo) + } else { + // Custom URL + fmt.Print("Enter repository URL: ") + input, err = reader.ReadString('\n') + if err != nil { + return "", "", "", false, fmt.Errorf("failed to read repository URL: %w", err) + } + repo = strings.TrimSpace(input) + if repo == "" { + fmt.Println("Repository URL cannot be empty") + continue + } + } + break + } + + // 2. Enter project name + for { + fmt.Print("Enter project name: ") + input, err := reader.ReadString('\n') + if err != nil { + return "", "", "", false, fmt.Errorf("failed to read project name: %w", err) + } + name = strings.TrimSpace(input) + if name == "" { + fmt.Println("Project name cannot be empty") + continue + } + break + } + + // 3. Enter module path (optional) + fmt.Printf("Enter Go module path (leave empty to use \"%s\"): ", name) + input, err := reader.ReadString('\n') + if err != nil { + return "", "", "", false, fmt.Errorf("failed to read module path: %w", err) + } + modPath = strings.TrimSpace(input) + + // 4. Ask about upgrade + fmt.Print("Upgrade dependencies to latest (go get -u)? [y/N]: ") + input, err = reader.ReadString('\n') + if err != nil { + return "", "", "", false, fmt.Errorf("failed to read upgrade confirmation: %w", err) + } + input = strings.TrimSpace(strings.ToLower(input)) + upgradeDeps = input == "y" || input == "yes" + + fmt.Println() + return repo, name, modPath, upgradeDeps, nil +} diff --git a/cmd/gf/internal/cmd/geninit/geninit.go b/cmd/gf/internal/cmd/geninit/geninit.go new file mode 100644 index 000000000..69ba9ae51 --- /dev/null +++ b/cmd/gf/internal/cmd/geninit/geninit.go @@ -0,0 +1,236 @@ +// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +package geninit + +import ( + "context" + "path/filepath" + + "github.com/gogf/gf/v2/os/gfile" + "github.com/gogf/gf/v2/text/gstr" + + "github.com/gogf/gf/cmd/gf/v2/internal/utility/mlog" +) + +// ProcessOptions contains options for the Process function +type ProcessOptions struct { + SelectVersion bool // Enable interactive version selection + ModulePath string // Custom go.mod module path (e.g., github.com/xxx/xxx) + UpgradeDeps bool // Upgrade dependencies to latest (go get -u ./...) +} + +// Process handles the template generation flow from remote repository +func Process(ctx context.Context, repo, name string, opts *ProcessOptions) error { + if opts == nil { + opts = &ProcessOptions{} + } + + // 0. Check Go environment first + mlog.Print("Checking Go environment...") + goEnv, err := CheckGoEnv(ctx) + if err != nil { + mlog.Printf("Go environment check failed: %v", err) + return err + } + mlog.Printf("Go environment OK (version: %s)", goEnv.GOVERSION) + + // Check if this is a git subdirectory URL + if IsSubdirRepo(repo) { + return processGitSubdir(ctx, repo, name, opts) + } + + // Try Go module download first, fallback to git subdirectory if it fails + // This handles edge cases where the heuristic may be incorrect + err = processGoModule(ctx, repo, name, opts) + if err != nil { + mlog.Printf("Go module download failed, trying git subdirectory mode: %v", err) + mlog.Print("Note: If this is a git subdirectory, you can force git mode by using a full git URL") + + // If Go module download fails, try git subdirectory as fallback + // This handles cases where the heuristic incorrectly classified a git subdir as Go module + if IsSubdirRepo(repo) { + mlog.Print("Falling back to git subdirectory download...") + return processGitSubdir(ctx, repo, name, opts) + } + } + + return err +} + +// processGoModule handles standard Go module download via go get +func processGoModule(ctx context.Context, repo, name string, opts *ProcessOptions) error { + // Extract module path (without version) + modulePath := repo + specifiedVersion := "" + if gstr.Contains(repo, "@") { + parts := gstr.Split(repo, "@") + modulePath = parts[0] + specifiedVersion = parts[1] + } + + // Default name to repo basename if empty + if name == "" { + name = filepath.Base(modulePath) + } + + // Determine the target module path for go.mod + targetModulePath := name + if opts.ModulePath != "" { + targetModulePath = opts.ModulePath + } + + // 1. Determine version to use + var targetVersion string + if specifiedVersion != "" { + // User specified version + targetVersion = specifiedVersion + mlog.Printf("Using specified version: %s", targetVersion) + } else if opts.SelectVersion { + // Interactive version selection + mlog.Print("Fetching available versions...") + versionInfo, err := GetModuleVersions(ctx, modulePath) + if err != nil { + mlog.Printf("Failed to get versions: %v", err) + return err + } + + targetVersion, err = SelectVersion(ctx, versionInfo.Versions, modulePath) + if err != nil { + mlog.Printf("Version selection failed: %v", err) + return err + } + } else { + // Default: use latest version + mlog.Print("Fetching latest version...") + latest, err := GetLatestVersion(ctx, modulePath) + if err != nil { + mlog.Printf("Failed to get latest version, will try @latest tag: %v", err) + targetVersion = "latest" + } else { + targetVersion = latest + mlog.Printf("Latest version: %s", targetVersion) + } + } + + // 2. Download Template with determined version + repoWithVersion := modulePath + "@" + targetVersion + srcDir, err := downloadTemplate(ctx, repoWithVersion) + if err != nil { + mlog.Printf("Download failed: %v", err) + return err + } + + mlog.Debugf("Template located at: %s", srcDir) + + // 3. Generate Project + if err := generateProject(ctx, srcDir, name, modulePath, targetModulePath); err != nil { + mlog.Printf("Generation failed: %v", err) + return err + } + + // 4. Handle dependencies + var projectDir string + if name == "." { + projectDir = gfile.Pwd() + } else { + projectDir = filepath.Join(gfile.Pwd(), name) + } + if opts.UpgradeDeps { + // Upgrade all dependencies to latest + if err := upgradeDependencies(ctx, projectDir); err != nil { + mlog.Printf("Failed to upgrade dependencies: %v", err) + } + } else { + // Default: just tidy dependencies + if err := tidyDependencies(ctx, projectDir); err != nil { + mlog.Printf("Failed to tidy dependencies: %v", err) + } + } + + return nil +} + +// processGitSubdir handles git subdirectory download via sparse checkout +func processGitSubdir(ctx context.Context, repo, name string, opts *ProcessOptions) error { + mlog.Print("Detected subdirectory URL, using git sparse checkout...") + + // Check if git is available + gitVersion, err := CheckGitEnv(ctx) + if err != nil { + mlog.Printf("Git is required for subdirectory templates: %v", err) + return err + } + mlog.Printf("Git available (%s)", gitVersion) + + // Download via git sparse checkout + srcDir, gitInfo, err := downloadGitSubdir(ctx, repo) + if err != nil { + mlog.Printf("Git download failed: %v", err) + return err + } + + // Clean up temp directory after generation + // The temp dir is parent of parent of srcDir (tempDir/repo/subpath) + tempDir := filepath.Dir(filepath.Dir(srcDir)) + if tempDir != "" && gfile.Exists(tempDir) && gstr.Contains(tempDir, "gf-init-git") { + defer func() { + if err := gfile.Remove(tempDir); err != nil { + mlog.Debugf("Failed to remove temp directory %s: %v", tempDir, err) + } else { + mlog.Debugf("Cleaned up temp directory: %s", tempDir) + } + }() + } + + // Default name to subpath basename if empty + if name == "" { + name = filepath.Base(gitInfo.SubPath) + } + + // Get original module name from go.mod (might be "main" or something else) + oldModule := GetModuleNameFromGoMod(srcDir) + if oldModule == "" { + // Fallback: construct from git info + oldModule = gitInfo.Host + "/" + gitInfo.Owner + "/" + gitInfo.Repo + "/" + gitInfo.SubPath + } + + // Determine the target module path for go.mod + targetModulePath := name + if opts.ModulePath != "" { + targetModulePath = opts.ModulePath + } + + mlog.Debugf("Template located at: %s", srcDir) + mlog.Debugf("Original module: %s", oldModule) + + // Generate Project + if err := generateProject(ctx, srcDir, name, oldModule, targetModulePath); err != nil { + mlog.Printf("Generation failed: %v", err) + return err + } + + // Handle dependencies + var projectDir string + if name == "." { + projectDir = gfile.Pwd() + } else { + projectDir = filepath.Join(gfile.Pwd(), name) + } + if opts.UpgradeDeps { + // Upgrade all dependencies to latest + if err := upgradeDependencies(ctx, projectDir); err != nil { + mlog.Printf("Failed to upgrade dependencies: %v", err) + } + } else { + // Default: just tidy dependencies + if err := tidyDependencies(ctx, projectDir); err != nil { + mlog.Printf("Failed to tidy dependencies: %v", err) + } + } + + return nil +} diff --git a/cmd/gf/internal/cmd/geninit/geninit_ast.go b/cmd/gf/internal/cmd/geninit/geninit_ast.go new file mode 100644 index 000000000..6fcfe8a6c --- /dev/null +++ b/cmd/gf/internal/cmd/geninit/geninit_ast.go @@ -0,0 +1,126 @@ +// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +package geninit + +import ( + "bytes" + "context" + "go/ast" + "go/parser" + "go/printer" + "go/token" + "os" + "path/filepath" + "strings" + + "github.com/gogf/gf/v2/os/gfile" + + "github.com/gogf/gf/cmd/gf/v2/internal/utility/mlog" +) + +// ASTReplacer handles import path replacement using Go AST +type ASTReplacer struct { + oldModule string + newModule string + fset *token.FileSet +} + +// NewASTReplacer creates a new AST-based import replacer +func NewASTReplacer(oldModule, newModule string) *ASTReplacer { + return &ASTReplacer{ + oldModule: oldModule, + newModule: newModule, + fset: token.NewFileSet(), + } +} + +// ReplaceInFile replaces import paths in a single Go file +func (r *ASTReplacer) ReplaceInFile(ctx context.Context, filePath string) error { + // Read file content + content := gfile.GetContents(filePath) + if content == "" { + return nil + } + + // Parse the file + file, err := parser.ParseFile(r.fset, filePath, content, parser.ParseComments) + if err != nil { + mlog.Debugf("Failed to parse %s: %v", filePath, err) + return nil // Skip files that can't be parsed + } + + // Track if any changes were made + changed := false + + // Traverse and modify imports + ast.Inspect(file, func(n ast.Node) bool { + switch x := n.(type) { + case *ast.ImportSpec: + if x.Path != nil { + importPath := strings.Trim(x.Path.Value, `"`) + if strings.HasPrefix(importPath, r.oldModule) { + // Replace only the leading module prefix for clarity and correctness. + newPath := r.newModule + strings.TrimPrefix(importPath, r.oldModule) + x.Path.Value = `"` + newPath + `"` + changed = true + mlog.Debugf("Replaced import: %s -> %s in %s", importPath, newPath, filePath) + } + } + } + return true + }) + + if !changed { + return nil + } + + // Write back to file + var buf bytes.Buffer + // Use default printer configuration to match gofmt output + cfg := &printer.Config{} + if err := cfg.Fprint(&buf, r.fset, file); err != nil { + return err + } + + return gfile.PutContents(filePath, buf.String()) +} + +// ReplaceInDir replaces import paths in all Go files in a directory (recursively) +func (r *ASTReplacer) ReplaceInDir(ctx context.Context, dir string) error { + mlog.Printf("Replacing imports: %s -> %s", r.oldModule, r.newModule) + + // Find all .go files + files, err := findGoFiles(dir) + if err != nil { + return err + } + + for _, file := range files { + if err := r.ReplaceInFile(ctx, file); err != nil { + mlog.Printf("Failed to process %s: %v", file, err) + } + } + + return nil +} + +// findGoFiles recursively finds all .go files in a directory +func findGoFiles(dir string) ([]string, error) { + var files []string + + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && strings.HasSuffix(path, ".go") { + files = append(files, path) + } + return nil + }) + + return files, err +} diff --git a/cmd/gf/internal/cmd/geninit/geninit_downloader.go b/cmd/gf/internal/cmd/geninit/geninit_downloader.go new file mode 100644 index 000000000..cb3150e8c --- /dev/null +++ b/cmd/gf/internal/cmd/geninit/geninit_downloader.go @@ -0,0 +1,111 @@ +// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +package geninit + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/gogf/gf/v2/os/gfile" + "github.com/gogf/gf/v2/text/gstr" + + "github.com/gogf/gf/cmd/gf/v2/internal/utility/mlog" +) + +// downloadTemplate fetches the remote repository using go get +func downloadTemplate(ctx context.Context, repo string) (string, error) { + // 1. Create a temporary directory workspace + tempDir := gfile.Temp("gf-init-cli") + if tempDir == "" { + return "", fmt.Errorf("failed to create temporary directory") + } + if err := gfile.Mkdir(tempDir); err != nil { + return "", err + } + defer func() { + if err := gfile.Remove(tempDir); err != nil { + mlog.Debugf("Failed to remove temp directory %s: %v", tempDir, err) + } + }() // Clean up the temp workspace + + mlog.Debugf("Using temp workspace: %s", tempDir) + + // 2. Initialize a temp go module to perform go get + // We run commands inside the temp directory + if err := runCmd(ctx, tempDir, "go", "mod", "init", "temp"); err != nil { + return "", err + } + + // 3. Run go get + // Try different version strategies: original -> @latest -> @master + moduleName := repo + if gstr.Contains(repo, "@") { + moduleName = gstr.Split(repo, "@")[0] + } + + var downloadErrs []string + versionsToTry := []string{repo} + if !gstr.Contains(repo, "@") { + versionsToTry = append(versionsToTry, repo+"@latest", repo+"@master") + } + + var successRepo string + for _, tryRepo := range versionsToTry { + mlog.Printf("Downloading template %s...", tryRepo) + if err := runCmd(ctx, tempDir, "go", "get", tryRepo); err == nil { + successRepo = tryRepo + break + } else { + downloadErrs = append(downloadErrs, fmt.Sprintf("%s: %v", tryRepo, err)) + mlog.Debugf("Failed to download %s, trying next...", tryRepo) + } + } + + if successRepo == "" { + errMsg := "all download attempts failed" + if len(downloadErrs) > 0 { + errMsg = strings.Join(downloadErrs, "; ") + } + return "", fmt.Errorf("failed to download repo %s: %s", repo, errMsg) + } + + // 4. Find the local path using go list -m -json + listCmd := exec.CommandContext(ctx, "go", "list", "-m", "-json", moduleName) + listCmd.Dir = tempDir + output, err := listCmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return "", fmt.Errorf("go list failed: %s", string(exitErr.Stderr)) + } + return "", fmt.Errorf("failed to locate module path: %w", err) + } + + var modInfo struct { + Dir string `json:"Dir"` + } + if err := json.Unmarshal(output, &modInfo); err != nil { + return "", fmt.Errorf("failed to parse go list output: %w", err) + } + + if modInfo.Dir == "" { + return "", fmt.Errorf("module directory not found for %s", repo) + } + + return modInfo.Dir, nil +} + +func runCmd(ctx context.Context, dir string, name string, args ...string) error { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/cmd/gf/internal/cmd/geninit/geninit_env.go b/cmd/gf/internal/cmd/geninit/geninit_env.go new file mode 100644 index 000000000..4f5b27812 --- /dev/null +++ b/cmd/gf/internal/cmd/geninit/geninit_env.go @@ -0,0 +1,90 @@ +// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +package geninit + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + + "github.com/gogf/gf/cmd/gf/v2/internal/utility/mlog" +) + +// GoEnv represents Go environment variables +type GoEnv struct { + GOVERSION string `json:"GOVERSION"` + GOROOT string `json:"GOROOT"` + GOPATH string `json:"GOPATH"` + GOMODCACHE string `json:"GOMODCACHE"` + GOPROXY string `json:"GOPROXY"` + GO111MODULE string `json:"GO111MODULE"` +} + +// CheckGoEnv verifies Go is installed and properly configured +func CheckGoEnv(ctx context.Context) (*GoEnv, error) { + // 1. Check if go binary exists + goPath, err := exec.LookPath("go") + if err != nil { + return nil, fmt.Errorf("go is not installed or not in PATH: %w", err) + } + mlog.Debugf("Found go binary at: %s", goPath) + + // 2. Get go env as JSON + cmd := exec.CommandContext(ctx, "go", "env", "-json") + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("go env failed: %s", string(exitErr.Stderr)) + } + return nil, fmt.Errorf("failed to run go env: %w", err) + } + + // 3. Parse JSON output + var env GoEnv + if err := json.Unmarshal(output, &env); err != nil { + return nil, fmt.Errorf("failed to parse go env output: %w", err) + } + + // 4. Validate critical environment variables + if env.GOROOT == "" { + return nil, fmt.Errorf("GOROOT is not set") + } + if env.GOMODCACHE == "" && env.GOPATH == "" { + return nil, fmt.Errorf("neither GOMODCACHE nor GOPATH is set") + } + + mlog.Debugf("Go Version: %s", env.GOVERSION) + mlog.Debugf("GOROOT: %s", env.GOROOT) + mlog.Debugf("GOMODCACHE: %s", env.GOMODCACHE) + mlog.Debugf("GOPROXY: %s", env.GOPROXY) + + return &env, nil +} + +// CheckGitEnv verifies Git is installed and returns its version +func CheckGitEnv(ctx context.Context) (string, error) { + // 1. Check if git binary exists + gitPath, err := exec.LookPath("git") + if err != nil { + return "", fmt.Errorf("git is not installed or not in PATH: %w", err) + } + mlog.Debugf("Found git binary at: %s", gitPath) + + // 2. Get git version + cmd := exec.CommandContext(ctx, "git", "--version") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get git version: %w", err) + } + + version := strings.TrimSpace(string(output)) + mlog.Debugf("Git version: %s", version) + + return version, nil +} diff --git a/cmd/gf/internal/cmd/geninit/geninit_generator.go b/cmd/gf/internal/cmd/geninit/geninit_generator.go new file mode 100644 index 000000000..62c1c68d7 --- /dev/null +++ b/cmd/gf/internal/cmd/geninit/geninit_generator.go @@ -0,0 +1,110 @@ +// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +package geninit + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/gogf/gf/v2/os/gfile" + "github.com/gogf/gf/v2/text/gstr" + + "github.com/gogf/gf/cmd/gf/v2/internal/utility/mlog" +) + +// generateProject copies the template to the destination and performs cleanup +// oldModule: original module path from template +// newModule: target module path for go.mod (can be different from project name) +func generateProject(ctx context.Context, srcPath, name, oldModule, newModule string) error { + pwd := gfile.Pwd() + + dstPath := filepath.Join(pwd, name) + if name == "." { + dstPath = pwd + } + + if gfile.Exists(dstPath) && !gfile.IsEmpty(dstPath) { + return fmt.Errorf("target directory %s is not empty", dstPath) + } + + mlog.Printf("Generating project in %s...", dstPath) + + // 1. Copy files + if err := gfile.Copy(srcPath, dstPath); err != nil { + return err + } + + // 2. Clean up .git directory + gitDir := filepath.Join(dstPath, ".git") + if gfile.Exists(gitDir) { + if err := gfile.Remove(gitDir); err != nil { + mlog.Debugf("Failed to remove .git directory: %v", err) + } + } + + // 3. Clean up go.work and go.work.sum (workspace files should not be in generated project) + for _, workFile := range []string{"go.work", "go.work.sum"} { + workPath := filepath.Join(dstPath, workFile) + if gfile.Exists(workPath) { + if err := gfile.Remove(workPath); err != nil { + mlog.Printf("Failed to remove %s: %v", workFile, err) + } else { + mlog.Debugf("Removed %s", workFile) + } + } + } + + // 4. Update go.mod module name + goModPath := filepath.Join(dstPath, "go.mod") + if gfile.Exists(goModPath) { + content := gfile.GetContents(goModPath) + lines := gstr.Split(content, "\n") + if len(lines) > 0 && gstr.HasPrefix(lines[0], "module ") { + lines[0] = "module " + newModule + newContent := gstr.Join(lines, "\n") + if err := gfile.PutContents(goModPath, newContent); err != nil { + mlog.Printf("Failed to update go.mod: %v", err) + } + } + } + + // 5. Use AST to replace import paths in all Go files + if oldModule != "" && oldModule != newModule { + replacer := NewASTReplacer(oldModule, newModule) + if err := replacer.ReplaceInDir(ctx, dstPath); err != nil { + return fmt.Errorf("failed to replace imports: %w", err) + } + } + + mlog.Print("Project generated successfully!") + return nil +} + +// tidyDependencies runs go mod tidy in the project directory +func tidyDependencies(ctx context.Context, projectDir string) error { + mlog.Print("Tidying dependencies (go mod tidy)...") + if err := runCmd(ctx, projectDir, "go", "mod", "tidy"); err != nil { + return fmt.Errorf("go mod tidy failed: %w", err) + } + mlog.Print("Dependencies tidied successfully!") + return nil +} + +// upgradeDependencies runs go get -u ./... to upgrade all dependencies to latest +func upgradeDependencies(ctx context.Context, projectDir string) error { + mlog.Print("Upgrading dependencies to latest (go get -u ./...)...") + if err := runCmd(ctx, projectDir, "go", "get", "-u", "./..."); err != nil { + return fmt.Errorf("go get -u failed: %w", err) + } + // Run tidy again after upgrade + if err := runCmd(ctx, projectDir, "go", "mod", "tidy"); err != nil { + return fmt.Errorf("go mod tidy after upgrade failed: %w", err) + } + mlog.Print("Dependencies upgraded successfully!") + return nil +} diff --git a/cmd/gf/internal/cmd/geninit/geninit_git_downloader.go b/cmd/gf/internal/cmd/geninit/geninit_git_downloader.go new file mode 100644 index 000000000..ec26f13a1 --- /dev/null +++ b/cmd/gf/internal/cmd/geninit/geninit_git_downloader.go @@ -0,0 +1,241 @@ +// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +package geninit + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "github.com/gogf/gf/v2/os/gfile" + "github.com/gogf/gf/v2/text/gstr" + + "github.com/gogf/gf/cmd/gf/v2/internal/utility/mlog" +) + +// GitRepoInfo holds parsed git repository information +type GitRepoInfo struct { + Host string // e.g., github.com + Owner string // e.g., gogf + Repo string // e.g., examples + Branch string // e.g., main (default: main) + SubPath string // e.g., httpserver/jwt + CloneURL string // e.g., https://github.com/gogf/examples.git +} + +// ParseGitURL parses a git URL and extracts repository info +// Supports formats: +// - github.com/owner/repo +// - github.com/owner/repo/subdir/path +// - github.com/owner/repo/tree/branch/subdir/path (from GitHub web URL) +func ParseGitURL(url string) (*GitRepoInfo, error) { + // Remove protocol prefix if present + url = strings.TrimPrefix(url, "https://") + url = strings.TrimPrefix(url, "http://") + url = strings.TrimSuffix(url, ".git") + + // Remove version suffix like @v1.0.0 + if idx := strings.Index(url, "@"); idx != -1 { + url = url[:idx] + } + + parts := strings.Split(url, "/") + if len(parts) < 3 { + return nil, fmt.Errorf("invalid git URL: %s", url) + } + + info := &GitRepoInfo{ + Host: parts[0], + Owner: parts[1], + Repo: parts[2], + Branch: "main", // default branch + } + + // Check for /tree/branch/ pattern (GitHub web URL) + if len(parts) > 4 && parts[3] == "tree" { + info.Branch = parts[4] + if len(parts) > 5 { + info.SubPath = strings.Join(parts[5:], "/") + } + } else if len(parts) > 3 { + // Direct subpath: github.com/owner/repo/subdir/path + info.SubPath = strings.Join(parts[3:], "/") + } + + info.CloneURL = fmt.Sprintf("https://%s/%s/%s.git", info.Host, info.Owner, info.Repo) + + return info, nil +} + +// IsSubdirRepo checks if the URL points to a subdirectory of a repository +// Returns false for Go module paths (which may have /vN suffix or nested module paths) +// Note: This uses heuristics that may have false positives/negatives in edge cases +func IsSubdirRepo(url string) bool { + info, err := ParseGitURL(url) + if err != nil { + return false + } + if info.SubPath == "" { + return false + } + + // Check if this looks like a Go module path rather than a git subdirectory + // Go modules can have nested paths like github.com/owner/repo/cmd/tool/v2 + // We should try to resolve it as a Go module first + + // If the URL can be resolved as a Go module, it's not a subdir repo + // We use a heuristic: check if the full path looks like a valid Go module + // by checking if it ends with /vN (major version) or contains common module patterns + + // Remove version suffix for checking + cleanURL := url + if before, _, ok := strings.Cut(url, "@"); ok { + cleanURL = before + } + + // Check if the path ends with /vN (Go module major version) + parts := strings.Split(cleanURL, "/") + if len(parts) > 0 { + lastPart := parts[len(parts)-1] + if len(lastPart) >= 2 && lastPart[0] == 'v' { + // Check if it's v2, v3, etc. + if _, err := fmt.Sscanf(lastPart, "v%d", new(int)); err == nil { + // This looks like a Go module with major version suffix + // It could be either a versioned module or a subdir ending in vN + // We'll treat it as a Go module and let go get handle it + mlog.Debugf("URL %s detected as Go module (ends with /vN)", url) + return false + } + } + } + + // For GitHub URLs, check if the subpath could be a nested Go module + // Common patterns: cmd/*, internal/*, pkg/*, contrib/* + subPathParts := strings.Split(info.SubPath, "/") + if len(subPathParts) > 0 { + firstPart := subPathParts[0] + // These are common Go module nesting patterns + if firstPart == "cmd" || firstPart == "contrib" || firstPart == "tools" { + // This might be a nested Go module, not a simple subdirectory + // Let go get try first + mlog.Debugf("URL %s detected as Go module (starts with common pattern)", url) + return false + } + } + + mlog.Debugf("URL %s detected as git subdirectory", url) + return true +} + +// downloadGitSubdir downloads a subdirectory from a git repository using sparse checkout +func downloadGitSubdir(ctx context.Context, repoURL string) (string, *GitRepoInfo, error) { + info, err := ParseGitURL(repoURL) + if err != nil { + return "", nil, err + } + + if info.SubPath == "" { + return "", nil, fmt.Errorf("not a subdirectory URL: %s", repoURL) + } + + // Create temp directory for clone + tempDir := gfile.Temp("gf-init-git") + if tempDir == "" { + return "", nil, fmt.Errorf("failed to create temporary directory") + } + if err := gfile.Mkdir(tempDir); err != nil { + return "", nil, err + } + + cloneDir := filepath.Join(tempDir, info.Repo) + mlog.Debugf("Using git temp workspace: %s", tempDir) + mlog.Printf("Cloning %s (sparse checkout: %s)...", info.CloneURL, info.SubPath) + + // 1. Clone with no checkout, filter, and sparse + if err := runCmd(ctx, tempDir, "git", "clone", "--filter=blob:none", "--no-checkout", "--sparse", info.CloneURL); err != nil { + // Fallback: try without filter for older git versions + mlog.Debugf("Sparse clone failed, trying full clone...") + if err := gfile.Remove(cloneDir); err != nil { + mlog.Debugf("Failed to remove clone directory: %v", err) + } + if err := runCmd(ctx, tempDir, "git", "clone", "--no-checkout", info.CloneURL); err != nil { + if err := gfile.Remove(tempDir); err != nil { + mlog.Debugf("Failed to remove temp directory: %v", err) + } + return "", nil, fmt.Errorf("git clone failed: %w", err) + } + } + + // 2. Set sparse-checkout to the subpath + if err := runCmd(ctx, cloneDir, "git", "sparse-checkout", "set", info.SubPath); err != nil { + // Fallback for older git: use sparse-checkout init + set + mlog.Debugf("sparse-checkout set failed, trying legacy method...") + if err := runCmd(ctx, cloneDir, "git", "sparse-checkout", "init", "--cone"); err != nil { + if err := gfile.Remove(tempDir); err != nil { + mlog.Debugf("Failed to remove temp directory: %v", err) + } + return "", nil, fmt.Errorf("git sparse-checkout init (legacy) failed: %w", err) + } + if err := runCmd(ctx, cloneDir, "git", "sparse-checkout", "set", info.SubPath); err != nil { + if err := gfile.Remove(tempDir); err != nil { + mlog.Debugf("Failed to remove temp directory: %v", err) + } + return "", nil, fmt.Errorf("git sparse-checkout set (legacy) failed: %w", err) + } + } + + // 3. Checkout the branch + if err := runCmd(ctx, cloneDir, "git", "checkout", info.Branch); err != nil { + // Try master if main fails + if info.Branch == "main" { + mlog.Debugf("Branch 'main' not found, trying 'master'...") + info.Branch = "master" + if err := runCmd(ctx, cloneDir, "git", "checkout", "master"); err != nil { + if err := gfile.Remove(tempDir); err != nil { + mlog.Debugf("Failed to remove temp directory: %v", err) + } + return "", nil, fmt.Errorf("git checkout failed: %w", err) + } + } else { + if err := gfile.Remove(tempDir); err != nil { + mlog.Debugf("Failed to remove temp directory: %v", err) + } + return "", nil, fmt.Errorf("git checkout failed: %w", err) + } + } + + // Return the path to the subdirectory + subDirPath := filepath.Join(cloneDir, info.SubPath) + if !gfile.Exists(subDirPath) { + if err := gfile.Remove(tempDir); err != nil { + mlog.Debugf("Failed to remove temp directory: %v", err) + } + return "", nil, fmt.Errorf("subdirectory not found: %s", info.SubPath) + } + + mlog.Debugf("Subdirectory located at: %s", subDirPath) + return subDirPath, info, nil +} + +// GetModuleNameFromGoMod reads module name from go.mod file +func GetModuleNameFromGoMod(dir string) string { + goModPath := filepath.Join(dir, "go.mod") + if !gfile.Exists(goModPath) { + return "" + } + + content := gfile.GetContents(goModPath) + lines := gstr.Split(content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if after, ok := strings.CutPrefix(line, "module "); ok { + return strings.TrimSpace(after) + } + } + return "" +} diff --git a/cmd/gf/internal/cmd/geninit/geninit_selector.go b/cmd/gf/internal/cmd/geninit/geninit_selector.go new file mode 100644 index 000000000..61cec344b --- /dev/null +++ b/cmd/gf/internal/cmd/geninit/geninit_selector.go @@ -0,0 +1,99 @@ +// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +package geninit + +import ( + "bufio" + "context" + "fmt" + "os" + "strconv" + "strings" + + "github.com/gogf/gf/cmd/gf/v2/internal/utility/mlog" +) + +// SelectVersion prompts user to select a version interactively +func SelectVersion(ctx context.Context, versions []string, modulePath string) (string, error) { + if len(versions) == 0 { + return "", fmt.Errorf("no versions available for selection") + } + + if len(versions) == 1 { + mlog.Printf("Only one version available: %s", versions[0]) + return versions[0], nil + } + + // Display available versions + fmt.Printf("\nAvailable versions for %s:\n", modulePath) + fmt.Println(strings.Repeat("-", 40)) + + // Show versions with index (newest first) + maxDisplay := 20 // Limit display to avoid overwhelming output + displayCount := len(versions) + if displayCount > maxDisplay { + displayCount = maxDisplay + } + + for i := 0; i < displayCount; i++ { + marker := "" + if i == 0 { + marker = " (latest)" + } + fmt.Printf(" [%2d] %s%s\n", i+1, versions[i], marker) + } + + if len(versions) > maxDisplay { + fmt.Printf(" ... and %d more versions\n", len(versions)-maxDisplay) + } + + fmt.Println(strings.Repeat("-", 40)) + + // Prompt for selection + reader := bufio.NewReader(os.Stdin) + for { + fmt.Printf("Select version [1-%d] or enter version string (default: 1 for latest): ", displayCount) + + input, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("failed to read input: %w", err) + } + + input = strings.TrimSpace(input) + + // Default to latest + if input == "" { + fmt.Printf("Selected: %s (latest)\n", versions[0]) + return versions[0], nil + } + + // Try parsing as number first + idx, err := strconv.Atoi(input) + if err == nil { + // Valid number - check if in range + if idx >= 1 && idx <= len(versions) { + // Allow selection from all versions, not just displayed ones + selected := versions[idx-1] + fmt.Printf("Selected: %s\n", selected) + return selected, nil + } else if idx < 1 || idx > displayCount { + fmt.Printf("Invalid selection. Please enter a number between 1 and %d, or type a version string.\n", displayCount) + continue + } + } else { + // Try matching the input as a version string (e.g., "v1.2.3") + for _, v := range versions { + if v == input || strings.Contains(v, input) { + fmt.Printf("Selected: %s\n", v) + return v, nil + } + } + fmt.Printf("Version '%s' not found. Please select by number or type a valid version string.\n", input) + continue + } + } +} diff --git a/cmd/gf/internal/cmd/geninit/geninit_version.go b/cmd/gf/internal/cmd/geninit/geninit_version.go new file mode 100644 index 000000000..380cae227 --- /dev/null +++ b/cmd/gf/internal/cmd/geninit/geninit_version.go @@ -0,0 +1,138 @@ +// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +package geninit + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "sort" + "strings" + + "golang.org/x/mod/semver" + + "github.com/gogf/gf/v2/os/gfile" + + "github.com/gogf/gf/cmd/gf/v2/internal/utility/mlog" +) + +// VersionInfo contains module version information +type VersionInfo struct { + Module string `json:"module"` + Versions []string `json:"versions"` + Latest string `json:"latest"` +} + +// GetModuleVersions fetches available versions for a Go module +func GetModuleVersions(ctx context.Context, modulePath string) (*VersionInfo, error) { + // Create a temporary directory for go list + tempDir := gfile.Temp("gf-init-version") + if tempDir == "" { + return nil, fmt.Errorf("failed to create temporary directory for go list") + } + if err := gfile.Mkdir(tempDir); err != nil { + return nil, err + } + defer func() { + if err := gfile.Remove(tempDir); err != nil { + mlog.Debugf("Failed to remove temp directory: %v", err) + } + }() + + // Initialize a temp go module + if err := runCmd(ctx, tempDir, "go", "mod", "init", "temp"); err != nil { + return nil, fmt.Errorf("failed to init temp module: %w", err) + } + + // Get versions using go list -m -versions + cmd := exec.CommandContext(ctx, "go", "list", "-m", "-versions", modulePath) + cmd.Dir = tempDir + output, err := cmd.Output() + if err != nil { + // Try with @latest to see if module exists + mlog.Debugf("go list -versions failed, trying @latest: %v", err) + return getLatestOnly(ctx, tempDir, modulePath) + } + + // Parse output: "module/path v1.0.0 v1.1.0 v2.0.0" + parts := strings.Fields(strings.TrimSpace(string(output))) + if len(parts) < 1 { + return nil, fmt.Errorf("no version information found for %s", modulePath) + } + + info := &VersionInfo{ + Module: parts[0], + Versions: []string{}, + } + + if len(parts) > 1 { + info.Versions = parts[1:] + // Sort versions in descending order (newest first) + sort.Slice(info.Versions, func(i, j int) bool { + return semver.Compare(info.Versions[i], info.Versions[j]) > 0 + }) + info.Latest = info.Versions[0] + } + + // If no tagged versions, try to get latest + if len(info.Versions) == 0 { + latestInfo, err := getLatestOnly(ctx, tempDir, modulePath) + if err != nil { + return nil, err + } + info.Latest = latestInfo.Latest + if latestInfo.Latest != "" { + info.Versions = []string{latestInfo.Latest} + } + } + + return info, nil +} + +// getLatestOnly gets only the latest version when go list -versions fails +func getLatestOnly(ctx context.Context, tempDir, modulePath string) (*VersionInfo, error) { + // Try go list -m modulePath@latest + cmd := exec.CommandContext(ctx, "go", "list", "-m", "-json", modulePath+"@latest") + cmd.Dir = tempDir + output, err := cmd.Output() + if err != nil { + // Try without @latest + cmd = exec.CommandContext(ctx, "go", "list", "-m", "-json", modulePath) + cmd.Dir = tempDir + output, err = cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to get module info for %s: %w", modulePath, err) + } + } + + var modInfo struct { + Path string `json:"Path"` + Version string `json:"Version"` + } + if err := json.Unmarshal(output, &modInfo); err != nil { + return nil, fmt.Errorf("failed to parse module info: %w", err) + } + + return &VersionInfo{ + Module: modInfo.Path, + Versions: []string{modInfo.Version}, + Latest: modInfo.Version, + }, nil +} + +// GetLatestVersion returns the latest version of a module +func GetLatestVersion(ctx context.Context, modulePath string) (string, error) { + info, err := GetModuleVersions(ctx, modulePath) + if err != nil { + return "", err + } + if info.Latest == "" { + return "", fmt.Errorf("no version found for %s", modulePath) + } + return info.Latest, nil +}