|
| 1 | +/* |
| 2 | +Copyright © 2025 George <george@betterde.com> |
| 3 | +
|
| 4 | +Permission is hereby granted, free of charge, to any person obtaining a copy |
| 5 | +of this software and associated documentation files (the "Software"), to deal |
| 6 | +in the Software without restriction, including without limitation the rights |
| 7 | +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
| 8 | +copies of the Software, and to permit persons to whom the Software is |
| 9 | +furnished to do so, subject to the following conditions: |
| 10 | +
|
| 11 | +The above copyright notice and this permission notice shall be included in |
| 12 | +all copies or substantial portions of the Software. |
| 13 | +
|
| 14 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| 15 | +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| 16 | +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| 17 | +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| 18 | +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| 19 | +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN |
| 20 | +THE SOFTWARE. |
| 21 | +*/ |
| 22 | +package cmd |
| 23 | + |
| 24 | +import ( |
| 25 | + "bytes" |
| 26 | + "encoding/json" |
| 27 | + "fmt" |
| 28 | + "github.com/betterde/gonew/internal/edit" |
| 29 | + "github.com/manifoldco/promptui" |
| 30 | + "github.com/spf13/cobra" |
| 31 | + "go/parser" |
| 32 | + "go/token" |
| 33 | + "golang.org/x/mod/modfile" |
| 34 | + "golang.org/x/mod/module" |
| 35 | + "gopkg.in/yaml.v3" |
| 36 | + "io/fs" |
| 37 | + "log" |
| 38 | + "os" |
| 39 | + "os/exec" |
| 40 | + "path" |
| 41 | + "path/filepath" |
| 42 | + "strconv" |
| 43 | + "strings" |
| 44 | + "text/template" |
| 45 | +) |
| 46 | + |
| 47 | +// initCmd represents the init command |
| 48 | +var initCmd = &cobra.Command{ |
| 49 | + Use: "init <src> [dst]", |
| 50 | + Run: initProject, |
| 51 | + Args: cobra.MinimumNArgs(1), |
| 52 | + Short: "Initialize a new project using a template", |
| 53 | +} |
| 54 | + |
| 55 | +var ( |
| 56 | + src string |
| 57 | + dst string |
| 58 | +) |
| 59 | + |
| 60 | +func init() { |
| 61 | + rootCmd.AddCommand(initCmd) |
| 62 | + |
| 63 | + // Here you will define your flags and configuration settings. |
| 64 | + |
| 65 | + // Cobra supports Persistent Flags which will work for this command |
| 66 | + // and all subcommands, e.g.: |
| 67 | + // initCmd.PersistentFlags().String("foo", "", "A help for foo") |
| 68 | + |
| 69 | + // Cobra supports local flags which will only run when this command |
| 70 | + // is called directly, e.g.: |
| 71 | + // initCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") |
| 72 | +} |
| 73 | + |
| 74 | +func initProject(cmd *cobra.Command, args []string) { |
| 75 | + if len(args) < 1 || len(args) > 3 { |
| 76 | + err := cmd.Usage() |
| 77 | + if err != nil { |
| 78 | + return |
| 79 | + } |
| 80 | + } |
| 81 | + |
| 82 | + src = args[0] |
| 83 | + ver := src |
| 84 | + if !strings.Contains(ver, "@") { |
| 85 | + ver += "@latest" |
| 86 | + } |
| 87 | + |
| 88 | + src, _, _ = strings.Cut(src, "@") |
| 89 | + if err := module.CheckPath(src); err != nil { |
| 90 | + log.Fatalf("invalid source module name: %v", err) |
| 91 | + } |
| 92 | + |
| 93 | + dst = src |
| 94 | + if len(args) >= 2 { |
| 95 | + dst = args[1] |
| 96 | + if err := module.CheckPath(dst); err != nil { |
| 97 | + log.Fatalf("invalid destination module name: %v", err) |
| 98 | + } |
| 99 | + } |
| 100 | + |
| 101 | + var dir string |
| 102 | + if len(args) == 3 { |
| 103 | + dir = args[2] |
| 104 | + } else { |
| 105 | + dir = "." + string(filepath.Separator) + path.Base(dst) |
| 106 | + } |
| 107 | + |
| 108 | + // Dir must not exist or must be an empty directory. |
| 109 | + de, err := os.ReadDir(dir) |
| 110 | + if err == nil && len(de) > 0 { |
| 111 | + log.Fatalf("target directory %s exists and is non-empty", dir) |
| 112 | + } |
| 113 | + needMkdir := err != nil |
| 114 | + |
| 115 | + var stdout, stderr bytes.Buffer |
| 116 | + command := exec.Command("go", "mod", "download", "-json", ver) |
| 117 | + command.Stdout = &stdout |
| 118 | + command.Stderr = &stderr |
| 119 | + if err = command.Run(); err != nil { |
| 120 | + log.Fatalf("go mod download -json %s: %v\n%s%s", ver, err, stderr.Bytes(), stdout.Bytes()) |
| 121 | + } |
| 122 | + |
| 123 | + var info struct { |
| 124 | + Dir string |
| 125 | + } |
| 126 | + if err = json.Unmarshal(stdout.Bytes(), &info); err != nil { |
| 127 | + log.Fatalf("go mod download -json %s: invalid JSON output: %v\n%s%s", ver, err, stderr.Bytes(), stdout.Bytes()) |
| 128 | + } |
| 129 | + |
| 130 | + if needMkdir { |
| 131 | + if err := os.MkdirAll(dir, 0777); err != nil { |
| 132 | + log.Fatalf("mkdir error: %s", err) |
| 133 | + } |
| 134 | + } |
| 135 | + |
| 136 | + // Copy from module cache into new directory, making edits as needed. |
| 137 | + err = filepath.WalkDir(info.Dir, func(src string, d fs.DirEntry, err error) error { |
| 138 | + if err != nil { |
| 139 | + log.Fatal(err) |
| 140 | + } |
| 141 | + rel, err := filepath.Rel(info.Dir, src) |
| 142 | + if err != nil { |
| 143 | + log.Fatal(err) |
| 144 | + } |
| 145 | + dstPath := filepath.Join(dir, rel) |
| 146 | + if d.IsDir() { |
| 147 | + if err := os.MkdirAll(dstPath, 0777); err != nil { |
| 148 | + log.Fatal(err) |
| 149 | + } |
| 150 | + return nil |
| 151 | + } |
| 152 | + |
| 153 | + data, err := os.ReadFile(src) |
| 154 | + if err != nil { |
| 155 | + log.Fatal(err) |
| 156 | + } |
| 157 | + |
| 158 | + isRoot := !strings.Contains(rel, string(filepath.Separator)) |
| 159 | + if strings.HasSuffix(rel, ".go") { |
| 160 | + data = fixGo(data, rel, src, dst, isRoot) |
| 161 | + } |
| 162 | + if rel == "go.mod" { |
| 163 | + data = fixGoMod(data, dst) |
| 164 | + } |
| 165 | + |
| 166 | + if err := os.WriteFile(dstPath, data, 0666); err != nil { |
| 167 | + log.Fatal(err) |
| 168 | + } |
| 169 | + return nil |
| 170 | + }) |
| 171 | + if err != nil { |
| 172 | + log.Fatal(err) |
| 173 | + } |
| 174 | + |
| 175 | + templateFile := filepath.Join(dir, "template.yaml") |
| 176 | + prompts, err := readConfig(templateFile) |
| 177 | + if err != nil { |
| 178 | + log.Fatal(err) |
| 179 | + } |
| 180 | + |
| 181 | + inputs, err := runPrompts(prompts) |
| 182 | + if err != nil { |
| 183 | + log.Fatal(err) |
| 184 | + } |
| 185 | + |
| 186 | + err = replaceVars(dir, inputs) |
| 187 | + if err != nil { |
| 188 | + log.Fatal(err) |
| 189 | + } |
| 190 | + |
| 191 | + log.Printf("initialized %s in %s", dst, dir) |
| 192 | +} |
| 193 | + |
| 194 | +// fixGo rewrites the Go source in data to replace srcMod with dstMod. |
| 195 | +// isRoot indicates whether the file is in the root directory of the module, |
| 196 | +// in which case we also update the package name. |
| 197 | +func fixGo(data []byte, file string, srcMod, dstMod string, isRoot bool) []byte { |
| 198 | + fileSet := token.NewFileSet() |
| 199 | + f, err := parser.ParseFile(fileSet, file, data, parser.ImportsOnly) |
| 200 | + if err != nil { |
| 201 | + log.Fatalf("parsing source module:\n%s", err) |
| 202 | + } |
| 203 | + |
| 204 | + buf := edit.NewBuffer(data) |
| 205 | + at := func(p token.Pos) int { |
| 206 | + return fileSet.File(p).Offset(p) |
| 207 | + } |
| 208 | + |
| 209 | + srcName := path.Base(srcMod) |
| 210 | + dstName := path.Base(dstMod) |
| 211 | + if isRoot { |
| 212 | + if name := f.Name.Name; name == srcName || name == srcName+"_test" { |
| 213 | + dname := dstName + strings.TrimPrefix(name, srcName) |
| 214 | + if !token.IsIdentifier(dname) { |
| 215 | + log.Fatalf("%s: cannot rename package %s to package %s: invalid package name", file, name, dname) |
| 216 | + } |
| 217 | + buf.Replace(at(f.Name.Pos()), at(f.Name.End()), dname) |
| 218 | + } |
| 219 | + } |
| 220 | + |
| 221 | + for _, spec := range f.Imports { |
| 222 | + path, err := strconv.Unquote(spec.Path.Value) |
| 223 | + if err != nil { |
| 224 | + continue |
| 225 | + } |
| 226 | + if path == srcMod { |
| 227 | + if srcName != dstName && spec.Name == nil { |
| 228 | + // Add package rename because source code uses original name. |
| 229 | + // The renaming looks strange, but template authors are unlikely to |
| 230 | + // create a template where the root package is imported by packages |
| 231 | + // in subdirectories, and the renaming at least keeps the code working. |
| 232 | + // A more sophisticated approach would be to rename the uses of |
| 233 | + // the package identifier in the file too, but then you have to worry about |
| 234 | + // name collisions, and given how unlikely this is, it doesn't seem worth |
| 235 | + // trying to clean up the file that way. |
| 236 | + buf.Insert(at(spec.Path.Pos()), srcName+" ") |
| 237 | + } |
| 238 | + // Change import path to dstMod |
| 239 | + buf.Replace(at(spec.Path.Pos()), at(spec.Path.End()), strconv.Quote(dstMod)) |
| 240 | + } |
| 241 | + if strings.HasPrefix(path, srcMod+"/") { |
| 242 | + // Change import path to begin with dstMod |
| 243 | + buf.Replace(at(spec.Path.Pos()), at(spec.Path.End()), strconv.Quote(strings.Replace(path, srcMod, dstMod, 1))) |
| 244 | + } |
| 245 | + } |
| 246 | + return buf.Bytes() |
| 247 | +} |
| 248 | + |
| 249 | +// fixGoMod rewrites the go.mod content in data to replace srcMod with dstMod |
| 250 | +// in the module path. |
| 251 | +func fixGoMod(data []byte, dstMod string) []byte { |
| 252 | + file, err := modfile.ParseLax("go.mod", data, nil) |
| 253 | + if err != nil { |
| 254 | + log.Fatalf("parsing source module:\n%s", err) |
| 255 | + } |
| 256 | + err = file.AddModuleStmt(dstMod) |
| 257 | + if err != nil { |
| 258 | + log.Fatalf("add module stmt:\n%s", err) |
| 259 | + } |
| 260 | + format, err := file.Format() |
| 261 | + if err != nil { |
| 262 | + return data |
| 263 | + } |
| 264 | + return format |
| 265 | +} |
| 266 | + |
| 267 | +// readConfig Reading YAML configuration files |
| 268 | +func readConfig(filename string) (map[string]string, error) { |
| 269 | + var config map[string]string |
| 270 | + data, err := os.ReadFile(filename) |
| 271 | + if err != nil { |
| 272 | + return config, err |
| 273 | + } |
| 274 | + err = yaml.Unmarshal(data, &config) |
| 275 | + return config, err |
| 276 | +} |
| 277 | + |
| 278 | +// runPrompts Run interactive prompts based on configuration |
| 279 | +func runPrompts(config map[string]string) (map[string]string, error) { |
| 280 | + answers := make(map[string]string) |
| 281 | + |
| 282 | + for key, desc := range config { |
| 283 | + prompt := promptui.Prompt{ |
| 284 | + Label: desc, |
| 285 | + Validate: func(input string) error { |
| 286 | + if len(input) == 0 { |
| 287 | + return fmt.Errorf(desc) |
| 288 | + } |
| 289 | + return nil |
| 290 | + }, |
| 291 | + } |
| 292 | + |
| 293 | + name, err := prompt.Run() |
| 294 | + if err != nil { |
| 295 | + return nil, err |
| 296 | + } |
| 297 | + answers[key] = name |
| 298 | + } |
| 299 | + |
| 300 | + return answers, nil |
| 301 | +} |
| 302 | + |
| 303 | +func replaceVars(dir string, inputs map[string]string) error { |
| 304 | + return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { |
| 305 | + if err != nil { |
| 306 | + return err |
| 307 | + } |
| 308 | + if !info.IsDir() { |
| 309 | + relPath, err := filepath.Rel(dir, path) |
| 310 | + if err != nil { |
| 311 | + return err |
| 312 | + } |
| 313 | + content, err := os.ReadFile(path) |
| 314 | + if err != nil { |
| 315 | + return err |
| 316 | + } |
| 317 | + |
| 318 | + return generateFile(inputs, relPath, string(content), dir) |
| 319 | + } |
| 320 | + return nil |
| 321 | + }) |
| 322 | +} |
| 323 | + |
| 324 | +// generateFile creates a single file from a template |
| 325 | +func generateFile(data map[string]string, fileName, content, projectDir string) error { |
| 326 | + // Parse the template |
| 327 | + tmpl, err := template.New(fileName).Parse(content) |
| 328 | + if err != nil { |
| 329 | + return fmt.Errorf("error parsing template %s: %v", fileName, err) |
| 330 | + } |
| 331 | + |
| 332 | + // Create the output file |
| 333 | + filePath := filepath.Join(projectDir, fileName) |
| 334 | + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { |
| 335 | + return fmt.Errorf("error creating directories for %s: %v", fileName, err) |
| 336 | + } |
| 337 | + |
| 338 | + file, err := os.Create(filePath) |
| 339 | + if err != nil { |
| 340 | + return fmt.Errorf("error creating file %s: %v", fileName, err) |
| 341 | + } |
| 342 | + defer file.Close() |
| 343 | + |
| 344 | + // Execute the template and write to file |
| 345 | + if err := tmpl.Execute(file, data); err != nil { |
| 346 | + return fmt.Errorf("error executing template %s: %v", fileName, err) |
| 347 | + } |
| 348 | + |
| 349 | + return nil |
| 350 | +} |
0 commit comments