fuzzyworld

template / files / internal / core / app.go

app.go

14.1 KB · history · blame

1package core
2
3import (
4 "bufio"
5 "fmt"
6 "io/fs"
7 "os"
8 "os/user"
9 "path"
10 "path/filepath"
11 "strconv"
12 "strings"
13
14 log "github.com/sirupsen/logrus"
15 "github.com/urfave/cli/v2"
16 "gopkg.in/yaml.v3"
17)
18
19const defaultUserConfigPath = "~/.template.user.yaml"
20const defaultConfigPath = "~/.template.yaml"
21const defaultLogLevel = "info"
22
23type argumentValues struct {
24 profile string
25 projectName string
26 fileTemplate string
27 dirTemplate string
28 scriptTemplate string
29 configFilePath string
30 userConfigPath string
31 extrasFilePath string
32 logLevel string
33}
34type envVarValues struct {
35 useDefaultConfig bool
36}
37type settings struct {
38 argumentValues
39 envVarValues
40 extras map[string]string
41 fullConfigPath string
42 config Config
43 usr *user.User
44}
45
46// Run is the entry point for the CLI application.
47func Run(args []string, version string) error {
48 av := argumentValues{}
49
50 app := &cli.App{
51 Name: "template",
52 Usage: "create file or directory from a template",
53 EnableBashCompletion: true,
54 Suggest: true,
55 Flags: []cli.Flag{
56 &cli.StringFlag{
57 Name: "profile",
58 Aliases: []string{"p"},
59 Value: "",
60 Usage: "`PROFILE` to use for the template",
61 Destination: &av.profile,
62 },
63 &cli.StringFlag{
64 Name: "file",
65 Aliases: []string{"f"},
66 Value: "",
67 Usage: "file template to use to create a file",
68 Destination: &av.fileTemplate,
69 },
70 &cli.StringFlag{
71 Name: "dir",
72 Aliases: []string{"d"},
73 Value: "",
74 Usage: "folder pattern to use to create a directory",
75 Destination: &av.dirTemplate,
76 },
77 &cli.StringFlag{
78 Name: "script",
79 Aliases: []string{"s"},
80 Value: "",
81 Usage: "templated script to run in the shell",
82 Destination: &av.scriptTemplate,
83 },
84 &cli.StringFlag{
85 Name: "name",
86 Aliases: []string{"n"},
87 Usage: "name of the project to create",
88 Value: "",
89 Destination: &av.projectName,
90 },
91 &cli.PathFlag{
92 Name: "config-yaml",
93 Aliases: []string{"c"},
94 Usage: "path to the config yaml file",
95 Value: defaultConfigPath,
96 Destination: &av.configFilePath,
97 },
98 &cli.PathFlag{
99 Name: "user-config-yaml",
100 Aliases: []string{"u"},
101 Usage: "path to the user config yaml file",
102 Value: defaultUserConfigPath,
103 Destination: &av.userConfigPath,
104 },
105 &cli.PathFlag{
106 Name: "extras-yaml",
107 Aliases: []string{"e"},
108 Usage: "path to the extras yaml file",
109 Value: "~/.template-extras.yaml",
110 Destination: &av.extrasFilePath,
111 },
112 &cli.StringFlag{
113 Name: "log-level",
114 Aliases: []string{"l"},
115 Usage: "log level",
116 Value: defaultLogLevel,
117 Destination: &av.logLevel,
118 },
119 },
120 Commands: []*cli.Command{
121 {
122 Name: "list",
123 Usage: "list available profiles",
124 Action: func(c *cli.Context) error {
125 s, err := createSettings(av, c)
126 if err != nil {
127 return fmt.Errorf("error creating settings: %v", err)
128 }
129
130 cfg := s.config
131 fmt.Println("Available profiles:")
132 for i, p := range cfg.Profiles {
133 fmt.Printf(" %d: %v\n", i+1, p.Name)
134 fmt.Printf(" %v\n", p.Description)
135 }
136 fmt.Println()
137 return nil
138 },
139 },
140 {
141 Name: "create",
142 Usage: "create a fully-hardcoded template profile from a file or folder",
143 Flags: []cli.Flag{
144 &cli.StringFlag{
145 Name: "dir",
146 Aliases: []string{"d"},
147 Value: "",
148 Usage: "folder from which to create the template",
149 Destination: &av.dirTemplate,
150 },
151 &cli.StringFlag{
152 Name: "file",
153 Aliases: []string{"f"},
154 Value: "",
155 Usage: "file from which to create the template",
156 Destination: &av.fileTemplate,
157 },
158 },
159 Action: func(c *cli.Context) error {
160 c.Args()
161
162 filePath := av.fileTemplate
163 dirPath := av.dirTemplate
164 if filePath == "" && dirPath == "" {
165 return fmt.Errorf("file or dir not specified, use `file` or `dir` flag")
166 }
167
168 profileName := av.profile
169 if profileName == "" {
170 profileName = "temp"
171 }
172
173 sourcePath := filePath
174 profileType := FileType
175 if dirPath != "" {
176 profileType = FolderType
177 sourcePath = dirPath
178 }
179 profile := Profile{
180 Name: profileName,
181 Description: fmt.Sprintf("temp %s profile generated from %s", profileType, sourcePath),
182 Type: profileType,
183 TargetDirectory: ".",
184 }
185
186 dir, file := path.Split(sourcePath)
187 profile.TargetDirectory = dir
188 profile.NamePatterns = []ParameterizedText{
189 ParameterizedText(file),
190 }
191 profile.PostScripts = []ParameterizedText{
192 ParameterizedText(fmt.Sprintf("code ./%s", file)),
193 }
194
195 if filePath != "" {
196 fileContent, err := readFile(filePath)
197 if err != nil {
198 return fmt.Errorf("error reading file %s: %w", filePath, err)
199 }
200 profile.FileTemplate = ParameterizedText(fileContent)
201 } else {
202 var files []FileInFolder
203 err := filepath.WalkDir(dirPath, func(p string, d fs.DirEntry, err error) error {
204 if err != nil {
205 fmt.Printf("prevent panic by handling failure accessing a path %q: %v\n", p, err)
206 return err
207 }
208
209 isRoot := p == dirPath
210 isDir := d.IsDir()
211 if isRoot || isDir {
212 return nil
213 }
214 relPath, err := filepath.Rel(dirPath, p)
215 if err != nil {
216 return fmt.Errorf("error getting relative path for %s: %w", p, err)
217 }
218 contents, err := readFile(p)
219 if err != nil {
220 return fmt.Errorf("error reading file %s: %w", p, err)
221 }
222 files = append(files, FileInFolder{
223 Name: ParameterizedText(relPath),
224 FileTemplate: ParameterizedText(contents),
225 })
226 return nil
227 })
228 if err != nil {
229 return fmt.Errorf("error walking dir %s: %w", dirPath, err)
230 }
231 profile.Files = files
232 }
233
234 cfg := Config{Profiles: []Profile{profile}}
235 writer := strings.Builder{}
236 encoder := yaml.NewEncoder(&writer)
237 if err := encoder.Encode(cfg); err != nil {
238 return fmt.Errorf("error encoding config: %v", err)
239 }
240 fmt.Println(writer.String())
241 return nil
242 },
243 },
244 {
245 Name: "get",
246 Usage: "get a profile",
247 Action: func(c *cli.Context) error {
248 s, err := createSettings(av, c)
249 if err != nil {
250 return fmt.Errorf("error creating settings: %v", err)
251 }
252 cfg := s.config
253
254 index, err := findProfileIndex(cfg.Profiles, s.profile)
255 if err != nil {
256 return fmt.Errorf("profile '%v' not found", s.profile)
257 }
258 profile := cfg.Profiles[index]
259
260 writer := strings.Builder{}
261 encoder := yaml.NewEncoder(&writer)
262 if err = encoder.Encode(profile); err != nil {
263 return fmt.Errorf("error encoding profile: %v", err)
264 }
265
266 fmt.Printf("Profile '%v':\n", s.profile)
267 fmt.Println(writer.String())
268 fmt.Println()
269 return nil
270 },
271 },
272 {
273 Name: "init",
274 Usage: "if global template config is not present, initializes the global template config with default profiles",
275 Action: func(c *cli.Context) error {
276 usr, err := user.Current()
277 if err != nil {
278 log.Fatal(err)
279 }
280 fullConfigPath := getFullPath(av.configFilePath, *usr)
281
282 _, err = os.Stat(fullConfigPath)
283 if err == nil {
284 fmt.Printf("Config file already exists at '%v', skipping init\n", fullConfigPath)
285 fmt.Println()
286 return nil
287 }
288
289 fmt.Printf("Setting config at '%v' to default\n", fullConfigPath)
290 if err = saveConfig(fullConfigPath, defaultConfig()); err != nil {
291 return fmt.Errorf("error saving default config: %v", err)
292 }
293 fmt.Println()
294 return nil
295 },
296 },
297 {
298 Name: "reset",
299 Usage: "reset the global template config to default profiles",
300 Action: func(c *cli.Context) error {
301 usr, err := user.Current()
302 if err != nil {
303 log.Fatal(err)
304 }
305 fullConfigPath := getFullPath(av.configFilePath, *usr)
306
307 fmt.Printf("Resetting config at '%v' to default\n", fullConfigPath)
308 if err = saveConfig(fullConfigPath, defaultConfig()); err != nil {
309 return fmt.Errorf("error saving default config: %v", err)
310 }
311 fmt.Println()
312 return nil
313 },
314 },
315 {
316 Name: "version",
317 Usage: "print version",
318 Action: func(c *cli.Context) error {
319 fmt.Println(version)
320 return nil
321 },
322 },
323 },
324 Action: func(c *cli.Context) error {
325 s, err := createSettings(av, c)
326 if err != nil {
327 return fmt.Errorf("error parsing arguments: %w", err)
328 }
329 if err = runMain(s); err != nil {
330 return fmt.Errorf("error in main logic: %w", err)
331 }
332 fmt.Println()
333 return nil
334 },
335 }
336
337 return app.Run(args)
338}
339
340func createSettings(av argumentValues, context *cli.Context) (settings, error) {
341 ev, err := readEnvVars()
342 if err != nil {
343 return settings{}, fmt.Errorf("error getting env var values: %w", err)
344 }
345
346 s := settings{
347 argumentValues: av,
348 envVarValues: ev,
349 }
350
351 args := context.Args()
352
353 var level log.Level
354 switch strings.ToLower(s.logLevel) {
355 case "debug":
356 level = log.DebugLevel
357 case "info":
358 level = log.InfoLevel
359 case "warn":
360 level = log.WarnLevel
361 case "error":
362 level = log.ErrorLevel
363 case "fatal":
364 level = log.FatalLevel
365 case "panic":
366 level = log.PanicLevel
367 default:
368 return settings{}, fmt.Errorf("invalid log level specified %v", s.logLevel)
369 }
370 log.SetLevel(level)
371 log.SetOutput(os.Stdout)
372
373 log.WithFields(log.Fields{"args": args}).Debug("Args parsed")
374
375 skipProfile := len(s.fileTemplate) > 0 || len(s.dirTemplate) > 0 || len(s.scriptTemplate) > 0
376 currentParamIndex := 0
377
378 if !skipProfile {
379 if s.profile == "" && args.Len() > currentParamIndex {
380 s.profile = args.Get(currentParamIndex)
381 }
382 if s.profile != "" {
383 fmt.Println("Profile:", s.profile)
384 }
385 currentParamIndex++
386 }
387
388 if s.projectName == "" && args.Len() > currentParamIndex {
389 s.projectName = args.Get(currentParamIndex)
390 }
391 if s.projectName != "" {
392 fmt.Println("Project name:", s.projectName)
393 }
394
395 _, err = os.Stat(s.extrasFilePath)
396 if err == nil {
397 extras, err := loadExtras(s.extrasFilePath)
398 if err != nil {
399 return s, fmt.Errorf("error loading extras from %v: %w", s.extrasFilePath, err)
400 }
401 s.extras = extras
402 }
403
404 usr, err := user.Current()
405 if err != nil {
406 log.Fatal(err)
407 }
408 s.usr = usr
409
410 fullConfigPath := getFullPath(s.configFilePath, *usr)
411 s.fullConfigPath = fullConfigPath
412
413 var fullConfig Config
414 if s.useDefaultConfig {
415 fullConfig = defaultConfig()
416 } else {
417 config, err := loadConfig(fullConfigPath)
418 if err != nil {
419 return s, fmt.Errorf("error loading config from %v: %w\nConsider running `template reset` to recreate the config file, but keep in mind that `reset` will remove existing config settings", fullConfigPath, err)
420 }
421 fmt.Printf("Loaded config from %v\n", fullConfigPath)
422 fullConfig = loadAndMergeUserConfig(s.userConfigPath, usr, config)
423 }
424 s.config = fullConfig
425
426 return s, nil
427}
428
429func loadAndMergeUserConfig(userConfigPath string, usr *user.User, globalConfig Config) Config {
430 fullUserConfigPath := getFullPath(userConfigPath, *usr)
431 _, err := os.Stat(fullUserConfigPath)
432 if err != nil {
433 fmt.Printf("User config file not found at %v, skipping\n", fullUserConfigPath)
434 return globalConfig
435 }
436
437 userConfig, err := loadConfig(fullUserConfigPath)
438 if err != nil {
439 fmt.Printf("Error loading user config from %v: %v\n", fullUserConfigPath, err)
440 return globalConfig
441 }
442
443 fmt.Printf("Loaded user config from %v, combining with global config\n", fullUserConfigPath)
444 fullConfig, err := mergeConfigs(globalConfig, userConfig)
445 if err != nil {
446 fmt.Printf("Error merging configs: %v\n", err)
447 }
448
449 return fullConfig
450}
451
452func readEnvVars() (envVarValues, error) {
453 ev := envVarValues{}
454
455 useDefaultConfigText := os.Getenv("TEMPLATE_USE_DEFAULT_CONFIG")
456 if useDefaultConfigText != "" {
457 useDefaultConfig, err := strconv.ParseBool(useDefaultConfigText)
458 if err != nil {
459 return ev, fmt.Errorf("error parsing env var TEMPLATE_USE_DEFAULT_CONFIG: \"%s\" %w", useDefaultConfigText, err)
460 }
461 ev.useDefaultConfig = useDefaultConfig
462 }
463
464 return ev, nil
465}
466
467func findProfile(s settings) (Profile, error) {
468 var profile Profile
469 cfg := s.config
470 createTempProfile := len(s.fileTemplate) > 0 || len(s.dirTemplate) > 0 || len(s.scriptTemplate) > 0
471
472 if createTempProfile {
473 if len(s.scriptTemplate) > 0 {
474 profile = Profile{
475 Name: "temp",
476 Description: "temp-script-profile",
477 Type: ScriptType,
478 PostScripts: []ParameterizedText{ParameterizedText(s.scriptTemplate)},
479 }
480 } else {
481 profileType := FileType
482 if len(s.dirTemplate) > 0 {
483 profileType = FolderType
484 }
485 var patterns []ParameterizedText
486 if len(s.fileTemplate) > 0 {
487 patterns = append(patterns, ParameterizedText(s.fileTemplate))
488 } else if len(s.dirTemplate) > 0 {
489 patterns = append(patterns, ParameterizedText(s.dirTemplate))
490 }
491 profile = Profile{
492 Name: "temp",
493 Description: fmt.Sprintf("temp-%s-profile", profileType),
494 Type: profileType,
495 NamePatterns: patterns,
496 }
497 }
498 } else {
499 if s.profile == "" {
500 var err error
501 profile, err = findDefaultProfile(cfg.Profiles)
502 if err != nil {
503 return profile, fmt.Errorf("no profile specified and no default profile found")
504 }
505 } else {
506 index, err := findProfileIndex(cfg.Profiles, s.profile)
507 if err != nil {
508 return profile, fmt.Errorf("error finding profile: %v", err)
509 }
510 profile = cfg.Profiles[index]
511 }
512 }
513 return profile, nil
514}
515
516func runMain(s settings) error {
517 profile, err := findProfile(s)
518 if err != nil {
519 return fmt.Errorf("error finding profile: %v", err)
520 }
521
522 stat, err := os.Stdin.Stat()
523 if err != nil {
524 return fmt.Errorf("error getting stdin stat: %w", err)
525 }
526 var pipeInputScanner *bufio.Scanner
527 incomingPipeData := (stat.Mode() & os.ModeCharDevice) == 0
528 if incomingPipeData {
529 pipeInputScanner = bufio.NewScanner(os.Stdin)
530 fmt.Printf("Reading from stdin\n")
531 }
532
533 errors := processProfile(profile, s.projectName, s.extras, *s.usr, pipeInputScanner)
534 if len(errors) > 0 {
535 return fmt.Errorf("error processing profile: %v", errors)
536 }
537
538 return nil
539}