feat(cmd/gf): init update (#4572)

This pull request introduces a significant enhancement to the `gf init`
command by adding support for initializing GoFrame projects from remote
templates, including interactive and advanced options for template
selection. The changes include new interactive flows, support for remote
repositories (including git subdirectories), and modularization of the
template initialization logic into a new `geninit` package.

The most important changes are:

### New Features & Interactive Initialization

* Added support for initializing projects from remote templates via the
`--repo/-r` flag, interactive mode (`--interactive/-i`), and version
selection (`--select/-s`). Users can now select built-in or remote
templates, specify custom repositories, and interactively choose project
configuration. (`cmd/gf/internal/cmd/cmd_init.go`)
[[1]](diffhunk://#diff-1213f1d7ea9ec0979d1b7aafaf9c84d53846c95f541e0252ab976cca90c677bdR50-R55)
[[2]](diffhunk://#diff-1213f1d7ea9ec0979d1b7aafaf9c84d53846c95f541e0252ab976cca90c677bdL68-R161)
[[3]](diffhunk://#diff-1213f1d7ea9ec0979d1b7aafaf9c84d53846c95f541e0252ab976cca90c677bdR267-R398)
* Introduced interactive prompts for template and project configuration,
including project name, module path, and dependency upgrade options.
(`cmd/gf/internal/cmd/cmd_init.go`)

### Code Organization & Modularization

* Extracted remote template initialization logic into a new package,
`geninit`, with a clear API for processing templates, handling
Go/gomod/git environments, and managing project generation.
(`cmd/gf/internal/cmd/geninit/geninit.go`)
* Added helper modules for Go and Git environment checks
(`geninit_env.go`), template downloading (`geninit_downloader.go`), and
AST-based Go import path replacement (`geninit_ast.go`).
[[1]](diffhunk://#diff-6238f52cc62f1e0dd569c7b1eacec609337e6e9eb9faf8604dcfc82149d907d1R1-R90)
[[2]](diffhunk://#diff-bbc29bf9a77f7097721185062041ff8ef622176bfb2c3886a94e68485773b5e6R1-R99)
[[3]](diffhunk://#diff-269925976ae0929279513615dbafc06f8560859ff0830ce82702735a5a7d6c61R1-R127)

### Usability Improvements

* Updated help and usage documentation to reflect new flags and
initialization modes, making it easier for users to discover and use the
new features. (`cmd/gf/internal/cmd/cmd_init.go`)

These changes greatly improve the flexibility and user experience of
project initialization in GoFrame, enabling both simple and advanced
workflows.

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
hailaz
2025-12-26 12:01:32 +08:00
committed by GitHub
parent 18e77de02f
commit 4d6c7e3d3a
9 changed files with 1412 additions and 6 deletions

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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 <repo>
// 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 <repo>
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()
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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 ""
}

View File

@ -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
}
}
}

View File

@ -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
}