feat(config): add configurable startup banner output (#893)
This commit is contained in:
@@ -221,6 +221,21 @@ air -- -h
|
|||||||
air -c .air.toml -- -h
|
air -c .air.toml -- -h
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Startup banner
|
||||||
|
|
||||||
|
Use `misc.startup_banner` to control what Air prints at startup.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[misc]
|
||||||
|
# Not set (default): show built-in ASCII banner with version.
|
||||||
|
|
||||||
|
# Set to empty string: print nothing.
|
||||||
|
startup_banner = ""
|
||||||
|
|
||||||
|
# Set to custom text: print this text instead of the built-in banner.
|
||||||
|
# startup_banner = "API watcher"
|
||||||
|
```
|
||||||
|
|
||||||
### Entrypoint
|
### Entrypoint
|
||||||
|
|
||||||
Use `build.entrypoint` to point at the binary generated by `build.cmd` and describe how it should be executed. The value can be either a string (just the executable) or an array of strings. When using an array, the first element is the executable (resolved relative to `root` unless it lacks a path separator, in which case `$PATH` is consulted) and every subsequent element is treated as a default argument. Values from `build.args_bin` and the command line are appended after the inline arguments. The legacy `build.bin` field is deprecated and will be removed in a future release, so prefer the entrypoint form going forward.
|
Use `build.entrypoint` to point at the binary generated by `build.cmd` and describe how it should be executed. The value can be either a string (just the executable) or an array of strings. When using an array, the first element is the executable (resolved relative to `root` unless it lacks a path separator, in which case `$PATH` is consulted) and every subsequent element is treated as a default argument. Values from `build.args_bin` and the command line are appended after the inline arguments. The legacy `build.bin` field is deprecated and will be removed in a future release, so prefer the entrypoint form going forward.
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ runner = "green"
|
|||||||
[misc]
|
[misc]
|
||||||
# Delete tmp directory on exit
|
# Delete tmp directory on exit
|
||||||
clean_on_exit = true
|
clean_on_exit = true
|
||||||
|
# Startup banner text. Set to "" to hide the banner.
|
||||||
|
# startup_banner = ""
|
||||||
|
|
||||||
[screen]
|
[screen]
|
||||||
clear_on_rebuild = true
|
clear_on_rebuild = true
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"runtime"
|
"runtime"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/air-verse/air/runner"
|
"github.com/air-verse/air/runner"
|
||||||
@@ -73,9 +74,9 @@ func GetVersionInfo() versionInfo { //revive:disable:unexported-return
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func printSplash() {
|
func defaultSplashText() string {
|
||||||
versionInfo := GetVersionInfo()
|
versionInfo := GetVersionInfo()
|
||||||
fmt.Fprintf(os.Stderr, `
|
return fmt.Sprintf(`
|
||||||
__ _ ___
|
__ _ ___
|
||||||
/ /\ | | | |_)
|
/ /\ | | | |_)
|
||||||
/_/--\ |_| |_| \_ %s, built with Go %s
|
/_/--\ |_| |_| \_ %s, built with Go %s
|
||||||
@@ -83,6 +84,49 @@ func printSplash() {
|
|||||||
`, versionInfo.airVersion, versionInfo.goVersion)
|
`, versionInfo.airVersion, versionInfo.goVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func versionLineText() string {
|
||||||
|
versionInfo := GetVersionInfo()
|
||||||
|
return fmt.Sprintf("air %s, built with Go %s\n", versionInfo.airVersion, versionInfo.goVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startupBannerText(cfg *runner.Config) string {
|
||||||
|
if cfg == nil || cfg.Misc.StartupBanner == nil {
|
||||||
|
return defaultSplashText()
|
||||||
|
}
|
||||||
|
return *cfg.Misc.StartupBanner
|
||||||
|
}
|
||||||
|
|
||||||
|
func printStartupBanner(cfg *runner.Config, respectSilent bool) {
|
||||||
|
if cfg != nil && respectSilent && cfg.Log.Silent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
banner := startupBannerText(cfg)
|
||||||
|
if banner == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprint(os.Stderr, banner)
|
||||||
|
if !strings.HasSuffix(banner, "\n") {
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printVersionOutput(cfg *runner.Config) {
|
||||||
|
if cfg == nil || cfg.Misc.StartupBanner == nil {
|
||||||
|
fmt.Fprint(os.Stderr, defaultSplashText())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
banner := *cfg.Misc.StartupBanner
|
||||||
|
if banner != "" {
|
||||||
|
fmt.Fprint(os.Stderr, banner)
|
||||||
|
if !strings.HasSuffix(banner, "\n") {
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprint(os.Stderr, versionLineText())
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
switch colorMode {
|
switch colorMode {
|
||||||
case "always":
|
case "always":
|
||||||
@@ -94,22 +138,25 @@ func main() {
|
|||||||
default:
|
default:
|
||||||
log.Fatal("unsupported color mode: use always, never, auto")
|
log.Fatal("unsupported color mode: use always, never, auto")
|
||||||
}
|
}
|
||||||
|
|
||||||
if showVersion {
|
if showVersion {
|
||||||
printSplash()
|
cfg, err := runner.InitConfigForDisplay(cfgPath, cmdArgs)
|
||||||
|
if err == nil {
|
||||||
|
printVersionOutput(cfg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprint(os.Stderr, defaultSplashText())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sigs := make(chan os.Signal, 1)
|
sigs := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
||||||
|
|
||||||
var err error
|
|
||||||
cfg, err := runner.InitConfig(cfgPath, cmdArgs)
|
cfg, err := runner.InitConfig(cfgPath, cmdArgs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !cfg.Log.Silent {
|
printStartupBanner(cfg, true)
|
||||||
printSplash()
|
|
||||||
}
|
|
||||||
if debugMode && !cfg.Log.Silent {
|
if debugMode && !cfg.Log.Silent {
|
||||||
fmt.Println("[debug] mode")
|
fmt.Println("[debug] mode")
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-9
@@ -167,7 +167,8 @@ type cfgColor struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type cfgMisc struct {
|
type cfgMisc struct {
|
||||||
CleanOnExit bool `toml:"clean_on_exit" usage:"Delete tmp directory on exit"`
|
CleanOnExit bool `toml:"clean_on_exit" usage:"Delete tmp directory on exit"`
|
||||||
|
StartupBanner *string `toml:"startup_banner" usage:"Custom startup banner text; set to empty string to hide banner"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type cfgScreen struct {
|
type cfgScreen struct {
|
||||||
@@ -197,7 +198,39 @@ func (t sliceTransformer) Transformer(typ reflect.Type) func(dst, src reflect.Va
|
|||||||
}
|
}
|
||||||
|
|
||||||
// InitConfig initializes the configuration.
|
// InitConfig initializes the configuration.
|
||||||
func InitConfig(path string, cmdArgs map[string]TomlInfo) (cfg *Config, err error) {
|
func InitConfig(path string, cmdArgs map[string]TomlInfo) (*Config, error) {
|
||||||
|
ret, err := initConfigWithoutPreprocess(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ret.preprocess(cmdArgs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
warnDeprecatedBin(ret)
|
||||||
|
|
||||||
|
return ret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitConfigForDisplay initializes config without preprocess side effects.
|
||||||
|
func InitConfigForDisplay(path string, cmdArgs map[string]TomlInfo) (*Config, error) {
|
||||||
|
ret, err := initConfigWithoutPreprocess(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cmdArgs != nil {
|
||||||
|
ret.withArgs(cmdArgs)
|
||||||
|
}
|
||||||
|
return ret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func initConfigWithoutPreprocess(path string) (*Config, error) {
|
||||||
|
var (
|
||||||
|
cfg *Config
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
if path == "" {
|
if path == "" {
|
||||||
cfg, err = defaultPathConfig()
|
cfg, err = defaultPathConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -226,13 +259,6 @@ func InitConfig(path string, cmdArgs map[string]TomlInfo) (cfg *Config, err erro
|
|||||||
if err = applyPlatformOverrides(ret); err != nil {
|
if err = applyPlatformOverrides(ret); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = ret.preprocess(cmdArgs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
warnDeprecatedBin(ret)
|
|
||||||
|
|
||||||
return ret, nil
|
return ret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -379,6 +379,115 @@ func TestDefaultConfigForOS(t *testing.T) {
|
|||||||
if linuxCfg.Build.Bin != "./tmp/main" {
|
if linuxCfg.Build.Bin != "./tmp/main" {
|
||||||
t.Fatalf("linux bin mismatch: got %q", linuxCfg.Build.Bin)
|
t.Fatalf("linux bin mismatch: got %q", linuxCfg.Build.Bin)
|
||||||
}
|
}
|
||||||
|
if linuxCfg.Misc.StartupBanner != nil {
|
||||||
|
t.Fatalf("startup_banner should default to nil, got %v", *linuxCfg.Misc.StartupBanner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithArgsSetsStartupBanner(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("custom text", func(t *testing.T) {
|
||||||
|
cfg := defaultConfig()
|
||||||
|
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||||
|
args := ParseConfigFlag(fs)
|
||||||
|
value := "Watcher A"
|
||||||
|
info, ok := args["misc.startup_banner"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("misc.startup_banner flag mapping missing")
|
||||||
|
}
|
||||||
|
*info.Value = value
|
||||||
|
|
||||||
|
cfg.withArgs(args)
|
||||||
|
|
||||||
|
if cfg.Misc.StartupBanner == nil {
|
||||||
|
t.Fatal("startup_banner should be set")
|
||||||
|
}
|
||||||
|
if got := *cfg.Misc.StartupBanner; got != value {
|
||||||
|
t.Fatalf("startup_banner mismatch: got %q want %q", got, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty string", func(t *testing.T) {
|
||||||
|
cfg := defaultConfig()
|
||||||
|
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||||
|
args := ParseConfigFlag(fs)
|
||||||
|
info, ok := args["misc.startup_banner"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("misc.startup_banner flag mapping missing")
|
||||||
|
}
|
||||||
|
*info.Value = ""
|
||||||
|
|
||||||
|
cfg.withArgs(args)
|
||||||
|
|
||||||
|
if cfg.Misc.StartupBanner == nil {
|
||||||
|
t.Fatal("startup_banner should be set")
|
||||||
|
}
|
||||||
|
if got := *cfg.Misc.StartupBanner; got != "" {
|
||||||
|
t.Fatalf("startup_banner mismatch: got %q want empty", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitConfigForDisplayStartupBanner(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("reads empty string from config", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(tmpDir, ".air.toml")
|
||||||
|
cfgContent := `
|
||||||
|
[misc]
|
||||||
|
startup_banner = ""
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := InitConfigForDisplay(cfgPath, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InitConfigForDisplay returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Misc.StartupBanner == nil {
|
||||||
|
t.Fatal("startup_banner should be set")
|
||||||
|
}
|
||||||
|
if got := *cfg.Misc.StartupBanner; got != "" {
|
||||||
|
t.Fatalf("startup_banner mismatch: got %q want empty", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("applies command arg override", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(tmpDir, ".air.toml")
|
||||||
|
cfgContent := `
|
||||||
|
[misc]
|
||||||
|
startup_banner = "FromConfig"
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||||
|
args := ParseConfigFlag(fs)
|
||||||
|
value := "FromFlag"
|
||||||
|
info, ok := args["misc.startup_banner"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("misc.startup_banner flag mapping missing")
|
||||||
|
}
|
||||||
|
*info.Value = value
|
||||||
|
|
||||||
|
cfg, err := InitConfigForDisplay(cfgPath, args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InitConfigForDisplay returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Misc.StartupBanner == nil {
|
||||||
|
t.Fatal("startup_banner should be set")
|
||||||
|
}
|
||||||
|
if got := *cfg.Misc.StartupBanner; got != value {
|
||||||
|
t.Fatalf("startup_banner mismatch: got %q want %q", got, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPlatformBuildOverridesSelection(t *testing.T) {
|
func TestPlatformBuildOverridesSelection(t *testing.T) {
|
||||||
|
|||||||
+6
-1
@@ -410,7 +410,12 @@ func setValue2Struct(v reflect.Value, fieldName string, value string) {
|
|||||||
b, _ := strconv.ParseBool(value)
|
b, _ := strconv.ParseBool(value)
|
||||||
field.SetBool(b)
|
field.SetBool(b)
|
||||||
case reflect.Ptr:
|
case reflect.Ptr:
|
||||||
field.SetString(value)
|
if field.Type().Elem().Kind() != reflect.String {
|
||||||
|
log.Fatalf("unsupported pointer type %s", field.Type().Elem().Kind())
|
||||||
|
}
|
||||||
|
v := reflect.New(field.Type().Elem())
|
||||||
|
v.Elem().SetString(value)
|
||||||
|
field.Set(v)
|
||||||
default:
|
default:
|
||||||
log.Fatalf("unsupported type %s", v.FieldByName(fields[0]).Kind())
|
log.Fatalf("unsupported type %s", v.FieldByName(fields[0]).Kind())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user