feat(config): add configurable startup banner output (#893)

This commit is contained in:
xiantang
2026-04-05 22:59:22 +08:00
committed by GitHub
parent af3caa084f
commit 24363a00a2
6 changed files with 221 additions and 17 deletions
+15
View File
@@ -221,6 +221,21 @@ air -- -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
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.
+2
View File
@@ -87,6 +87,8 @@ runner = "green"
[misc]
# Delete tmp directory on exit
clean_on_exit = true
# Startup banner text. Set to "" to hide the banner.
# startup_banner = ""
[screen]
clear_on_rebuild = true
+54 -7
View File
@@ -8,6 +8,7 @@ import (
"os/signal"
"runtime"
"runtime/debug"
"strings"
"syscall"
"github.com/air-verse/air/runner"
@@ -73,9 +74,9 @@ func GetVersionInfo() versionInfo { //revive:disable:unexported-return
}
}
func printSplash() {
func defaultSplashText() string {
versionInfo := GetVersionInfo()
fmt.Fprintf(os.Stderr, `
return fmt.Sprintf(`
__ _ ___
/ /\ | | | |_)
/_/--\ |_| |_| \_ %s, built with Go %s
@@ -83,6 +84,49 @@ func printSplash() {
`, 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() {
switch colorMode {
case "always":
@@ -94,22 +138,25 @@ func main() {
default:
log.Fatal("unsupported color mode: use always, never, auto")
}
if showVersion {
printSplash()
cfg, err := runner.InitConfigForDisplay(cfgPath, cmdArgs)
if err == nil {
printVersionOutput(cfg)
return
}
fmt.Fprint(os.Stderr, defaultSplashText())
return
}
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
var err error
cfg, err := runner.InitConfig(cfgPath, cmdArgs)
if err != nil {
log.Fatal(err)
return
}
if !cfg.Log.Silent {
printSplash()
}
printStartupBanner(cfg, true)
if debugMode && !cfg.Log.Silent {
fmt.Println("[debug] mode")
}
+35 -9
View File
@@ -167,7 +167,8 @@ type cfgColor 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 {
@@ -197,7 +198,39 @@ func (t sliceTransformer) Transformer(typ reflect.Type) func(dst, src reflect.Va
}
// 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 == "" {
cfg, err = defaultPathConfig()
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 {
return nil, err
}
err = ret.preprocess(cmdArgs)
if err != nil {
return nil, err
}
warnDeprecatedBin(ret)
return ret, nil
}
+109
View File
@@ -379,6 +379,115 @@ func TestDefaultConfigForOS(t *testing.T) {
if linuxCfg.Build.Bin != "./tmp/main" {
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) {
+6 -1
View File
@@ -410,7 +410,12 @@ func setValue2Struct(v reflect.Value, fieldName string, value string) {
b, _ := strconv.ParseBool(value)
field.SetBool(b)
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:
log.Fatalf("unsupported type %s", v.FieldByName(fields[0]).Kind())
}