Kaynağa Gözat

Refactor 1/2 + new stuff

* Refactor CLI
* Add config:print command
* Add diagnose command
* Allow including other files in config
* Watch for file changes and automatically restart server
Svilen Markov 8 ay önce
ebeveyn
işleme
2b0dd3ab99

+ 1 - 0
docs/configuration.md

@@ -34,6 +34,7 @@
   - [HTML](#html)
 
 ## Intro
+<!-- TODO: update -->
 Configuration is done via a single YAML file and a server restart is required in order for any changes to take effect. Trying to start the server with an invalid config file will result in an error.
 
 ## Preconfigured page

+ 2 - 0
go.mod

@@ -3,6 +3,7 @@ module github.com/glanceapp/glance
 go 1.23.1
 
 require (
+	github.com/fsnotify/fsnotify v1.8.0
 	github.com/mmcdole/gofeed v1.3.0
 	github.com/tidwall/gjson v1.18.0
 	golang.org/x/text v0.18.0
@@ -19,4 +20,5 @@ require (
 	github.com/tidwall/match v1.1.1 // indirect
 	github.com/tidwall/pretty v1.2.1 // indirect
 	golang.org/x/net v0.29.0 // indirect
+	golang.org/x/sys v0.25.0 // indirect
 )

+ 4 - 0
go.sum

@@ -5,6 +5,8 @@ github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
+github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
@@ -52,6 +54,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
+golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=

+ 41 - 16
internal/glance/cli.go

@@ -2,41 +2,66 @@ package glance
 
 import (
 	"flag"
+	"fmt"
 	"os"
+	"strings"
 )
 
-type CliIntent uint8
+type cliIntent uint8
 
 const (
-	CliIntentServe       CliIntent = iota
-	CliIntentCheckConfig           = iota
+	cliIntentServe          cliIntent = iota
+	cliIntentConfigValidate           = iota
+	cliIntentConfigPrint              = iota
+	cliIntentDiagnose                 = iota
 )
 
-type CliOptions struct {
-	Intent     CliIntent
-	ConfigPath string
+type cliOptions struct {
+	intent     cliIntent
+	configPath string
 }
 
-func ParseCliOptions() (*CliOptions, error) {
+func parseCliOptions() (*cliOptions, error) {
 	flags := flag.NewFlagSet("", flag.ExitOnError)
+	flags.Usage = func() {
+		fmt.Println("Usage: glance [options] command")
 
-	checkConfig := flags.Bool("check-config", false, "Check whether the config is valid")
-	configPath := flags.String("config", "glance.yml", "Set config path")
+		fmt.Println("\nOptions:")
+		flags.PrintDefaults()
 
+		fmt.Println("\nCommands:")
+		fmt.Println("  config:validate     Validate the config file")
+		fmt.Println("  config:print        Print the parsed config file with embedded includes")
+		fmt.Println("  diagnose            Run diagnostic checks")
+	}
+	configPath := flags.String("config", "glance.yml", "Set config path")
 	err := flags.Parse(os.Args[1:])
-
 	if err != nil {
 		return nil, err
 	}
 
-	intent := CliIntentServe
+	var intent cliIntent
+	var args = flags.Args()
+	unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " "))
 
-	if *checkConfig {
-		intent = CliIntentCheckConfig
+	if len(args) == 0 {
+		intent = cliIntentServe
+	} else if len(args) == 1 {
+		if args[0] == "config:validate" {
+			intent = cliIntentConfigValidate
+		} else if args[0] == "config:print" {
+			intent = cliIntentConfigPrint
+		} else if args[0] == "diagnose" {
+			intent = cliIntentDiagnose
+		} else {
+			return nil, unknownCommandErr
+		}
+	} else {
+		return nil, unknownCommandErr
 	}
 
-	return &CliOptions{
-		Intent:     intent,
-		ConfigPath: *configPath,
+	return &cliOptions{
+		intent:     intent,
+		configPath: *configPath,
 	}, nil
 }

+ 182 - 18
internal/glance/config.go

@@ -1,9 +1,16 @@
 package glance
 
 import (
+	"bytes"
 	"fmt"
-	"io"
+	"log"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"time"
 
+	"github.com/fsnotify/fsnotify"
 	"gopkg.in/yaml.v3"
 )
 
@@ -14,22 +21,16 @@ type Config struct {
 	Pages    []Page   `yaml:"pages"`
 }
 
-func NewConfigFromYml(contents io.Reader) (*Config, error) {
-	config := NewConfig()
-
-	contentBytes, err := io.ReadAll(contents)
-
-	if err != nil {
-		return nil, err
-	}
-
-	err = yaml.Unmarshal(contentBytes, config)
+func newConfigFromYAML(contents []byte) (*Config, error) {
+	config := &Config{}
+	config.Server.Port = 8080
 
+	err := yaml.Unmarshal(contents, config)
 	if err != nil {
 		return nil, err
 	}
 
-	if err = configIsValid(config); err != nil {
+	if err = isConfigStateValid(config); err != nil {
 		return nil, err
 	}
 
@@ -46,16 +47,179 @@ func NewConfigFromYml(contents io.Reader) (*Config, error) {
 	return config, nil
 }
 
-func NewConfig() *Config {
-	config := &Config{}
+var includePattern = regexp.MustCompile(`(?m)^(\s*)!include:\s*(.+)$`)
 
-	config.Server.Host = ""
-	config.Server.Port = 8080
+func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) {
+	mainFileContents, err := os.ReadFile(mainFilePath)
+	if err != nil {
+		return nil, nil, fmt.Errorf("could not read main YAML file: %w", err)
+	}
+
+	mainFileAbsPath, err := filepath.Abs(mainFilePath)
+	if err != nil {
+		return nil, nil, fmt.Errorf("could not get absolute path of main YAML file: %w", err)
+	}
+	mainFileDir := filepath.Dir(mainFileAbsPath)
+
+	includes := make(map[string]struct{})
+	var includesLastErr error
+
+	mainFileContents = includePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
+		if includesLastErr != nil {
+			return nil
+		}
+
+		matches := includePattern.FindSubmatch(match)
+		if len(matches) != 3 {
+			includesLastErr = fmt.Errorf("invalid include match: %v", matches)
+			return nil
+		}
+
+		indent := string(matches[1])
+		includeFilePath := strings.TrimSpace(string(matches[2]))
+		if !filepath.IsAbs(includeFilePath) {
+			includeFilePath = filepath.Join(mainFileDir, includeFilePath)
+		}
+
+		var fileContents []byte
+		var err error
+
+		fileContents, err = os.ReadFile(includeFilePath)
+		if err != nil {
+			includesLastErr = fmt.Errorf("could not read included file: %w", err)
+			return nil
+		}
+
+		includes[includeFilePath] = struct{}{}
+		return []byte(prefixStringLines(indent, string(fileContents)))
+	})
+
+	if includesLastErr != nil {
+		return nil, nil, includesLastErr
+	}
+
+	return mainFileContents, includes, nil
+}
+
+func prefixStringLines(prefix string, s string) string {
+	lines := strings.Split(s, "\n")
+
+	for i, line := range lines {
+		lines[i] = prefix + line
+	}
+
+	return strings.Join(lines, "\n")
+}
+
+func configFilesWatcher(
+	mainFilePath string,
+	lastContents []byte,
+	lastIncludes map[string]struct{},
+	onChange func(newContents []byte),
+	onErr func(error),
+) (func() error, error) {
+	watcher, err := fsnotify.NewWatcher()
+	if err != nil {
+		return nil, fmt.Errorf("could not create watcher: %w", err)
+	}
+
+	if err = watcher.Add(mainFilePath); err != nil {
+		watcher.Close()
+		return nil, fmt.Errorf("could not add main file to watcher: %w", err)
+	}
+
+	updateWatchedIncludes := func(previousIncludes map[string]struct{}, newIncludes map[string]struct{}) {
+		for includePath := range previousIncludes {
+			if _, ok := newIncludes[includePath]; !ok {
+				watcher.Remove(includePath)
+			}
+		}
+
+		for includePath := range newIncludes {
+			if _, ok := previousIncludes[includePath]; !ok {
+				if err := watcher.Add(includePath); err != nil {
+					log.Printf(
+						"Could not add included config file to watcher, changes to this file will not trigger a reload. path: %s, error: %v",
+						includePath, err,
+					)
+				}
+			}
+		}
+	}
+
+	updateWatchedIncludes(nil, lastIncludes)
+
+	checkForContentChangesBeforeCallback := func() {
+		currentContents, currentIncludes, err := parseYAMLIncludes(mainFilePath)
+		if err != nil {
+			onErr(fmt.Errorf("could not parse main file contents for comparison: %w", err))
+			return
+		}
+
+		if !bytes.Equal(lastContents, currentContents) {
+			updateWatchedIncludes(lastIncludes, currentIncludes)
+			lastContents, lastIncludes = currentContents, currentIncludes
+			onChange(currentContents)
+		}
+	}
 
-	return config
+	const debounceDuration = 500 * time.Millisecond
+	var debounceTimer *time.Timer
+	debouncedCallback := func() {
+		if debounceTimer != nil {
+			debounceTimer.Stop()
+			debounceTimer.Reset(debounceDuration)
+		} else {
+			debounceTimer = time.AfterFunc(debounceDuration, checkForContentChangesBeforeCallback)
+		}
+	}
+
+	go func() {
+		for {
+			select {
+			case event, isOpen := <-watcher.Events:
+				if !isOpen {
+					return
+				}
+				if event.Has(fsnotify.Write) {
+					debouncedCallback()
+				}
+				// maybe also handle .Remove event?
+				// from testing it appears that a removed file will stop triggering .Write events
+				// when it gets recreated, in which case we may need to watch the directory for the
+				// creation of that file and then re-add it to the watcher, though that's
+				// a lot of effort for a hopefully rare edge case
+			case err, isOpen := <-watcher.Errors:
+				if !isOpen {
+					return
+				}
+				onErr(fmt.Errorf("watcher error: %w", err))
+			}
+		}
+	}()
+
+	onChange(lastContents)
+
+	return func() error {
+		if debounceTimer != nil {
+			debounceTimer.Stop()
+		}
+
+		return watcher.Close()
+	}, nil
 }
 
-func configIsValid(config *Config) error {
+func isConfigStateValid(config *Config) error {
+	if len(config.Pages) == 0 {
+		return fmt.Errorf("no pages configured")
+	}
+
+	if config.Server.AssetsPath != "" {
+		if _, err := os.Stat(config.Server.AssetsPath); os.IsNotExist(err) {
+			return fmt.Errorf("assets directory does not exist: %s", config.Server.AssetsPath)
+		}
+	}
+
 	for i := range config.Pages {
 		if config.Pages[i].Title == "" {
 			return fmt.Errorf("Page %d has no title", i+1)

+ 219 - 0
internal/glance/diagnose.go

@@ -0,0 +1,219 @@
+package glance
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"os"
+	"runtime"
+	"strings"
+	"sync"
+	"time"
+)
+
+const httpTestRequestTimeout = 10 * time.Second
+
+var diagnosticSteps = []diagnosticStep{
+	{
+		name: "resolve cloudflare.com through Cloudflare DoH",
+		fn: func() (string, error) {
+			return testHttpRequestWithHeaders("GET", "https://1.1.1.1/dns-query?name=cloudflare.com", map[string]string{
+				"accept": "application/dns-json",
+			}, 200)
+		},
+	},
+	{
+		name: "resolve cloudflare.com through Google DoH",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://8.8.8.8/resolve?name=cloudflare.com", 200)
+		},
+	},
+	{
+		name: "resolve github.com",
+		fn: func() (string, error) {
+			return testDNSResolution("github.com")
+		},
+	},
+	{
+		name: "resolve reddit.com",
+		fn: func() (string, error) {
+			return testDNSResolution("reddit.com")
+		},
+	},
+	{
+		name: "resolve twitch.tv",
+		fn: func() (string, error) {
+			return testDNSResolution("twitch.tv")
+		},
+	},
+	{
+		name: "fetch data from YouTube RSS feed",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://www.youtube.com/feeds/videos.xml?channel_id=UCZU9T1ceaOgwfLRq7OKFU4Q", 200)
+		},
+	},
+	{
+		name: "fetch data from Twitch.tv GQL",
+		fn: func() (string, error) {
+			// this should always return 0 bytes, we're mainly looking for a 200 status code
+			return testHttpRequest("OPTIONS", "https://gql.twitch.tv/gql", 200)
+		},
+	},
+	{
+		name: "fetch data from GitHub API",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://api.github.com", 200)
+		},
+	},
+	{
+		name: "fetch data from Open-Meteo API",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://geocoding-api.open-meteo.com/v1/search?name=London", 200)
+		},
+	},
+	{
+		name: "fetch data from Reddit API",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://www.reddit.com/search.json", 200)
+		},
+	},
+	{
+		name: "fetch data from Yahoo finance API",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://query1.finance.yahoo.com/v8/finance/chart/NVDA", 200)
+		},
+	},
+	{
+		name: "fetch data from Hacker News Firebase API",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://hacker-news.firebaseio.com/v0/topstories.json", 200)
+		},
+	},
+	{
+		name: "fetch data from Docker Hub API",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://hub.docker.com/v2/namespaces/library/repositories/ubuntu/tags/latest", 200)
+		},
+	},
+}
+
+func runDiagnostic() {
+	fmt.Println("```")
+	fmt.Println("Glance version: " + buildVersion)
+	fmt.Println("Go version: " + runtime.Version())
+	fmt.Printf("Platform: %s / %s / %d CPUs\n", runtime.GOOS, runtime.GOARCH, runtime.NumCPU())
+	fmt.Println("In Docker container: " + boolToString(isRunningInsideDockerContainer(), "yes", "no"))
+
+	fmt.Printf("\nChecking network connectivity, this may take up to %d seconds...\n\n", int(httpTestRequestTimeout.Seconds()))
+
+	var wg sync.WaitGroup
+	for i := range diagnosticSteps {
+		step := &diagnosticSteps[i]
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			start := time.Now()
+			step.extraInfo, step.err = step.fn()
+			step.elapsed = time.Since(start)
+		}()
+	}
+	wg.Wait()
+
+	for _, step := range diagnosticSteps {
+		var extraInfo string
+
+		if step.extraInfo != "" {
+			extraInfo = "| " + step.extraInfo + " "
+		}
+
+		fmt.Printf(
+			"%s %s %s| %dms\n",
+			boolToString(step.err == nil, "✓ Can", "✗ Can't"),
+			step.name,
+			extraInfo,
+			step.elapsed.Milliseconds(),
+		)
+
+		if step.err != nil {
+			fmt.Printf("└╴ error: %v\n", step.err)
+		}
+	}
+	fmt.Println("```")
+}
+
+type diagnosticStep struct {
+	name      string
+	fn        func() (string, error)
+	extraInfo string
+	err       error
+	elapsed   time.Duration
+}
+
+func boolToString(b bool, trueValue, falseValue string) string {
+	if b {
+		return trueValue
+	}
+
+	return falseValue
+}
+
+func isRunningInsideDockerContainer() bool {
+	_, err := os.Stat("/.dockerenv")
+	return err == nil
+}
+
+func testHttpRequest(method, url string, expectedStatusCode int) (string, error) {
+	return testHttpRequestWithHeaders(method, url, nil, expectedStatusCode)
+}
+
+func testHttpRequestWithHeaders(method, url string, headers map[string]string, expectedStatusCode int) (string, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), httpTestRequestTimeout)
+	defer cancel()
+
+	request, _ := http.NewRequestWithContext(ctx, method, url, nil)
+	for key, value := range headers {
+		request.Header.Add(key, value)
+	}
+
+	response, err := http.DefaultClient.Do(request)
+	if err != nil {
+		return "", err
+	}
+	defer response.Body.Close()
+
+	body, err := io.ReadAll(response.Body)
+	if err != nil {
+		return "", err
+	}
+
+	printableBody := strings.ReplaceAll(string(body), "\n", "")
+	if len(printableBody) > 50 {
+		printableBody = printableBody[:50] + "..."
+	}
+	if len(printableBody) > 0 {
+		printableBody = ", " + printableBody
+	}
+
+	extraInfo := fmt.Sprintf("%d bytes%s", len(body), printableBody)
+
+	if response.StatusCode != expectedStatusCode {
+		return extraInfo, fmt.Errorf("expected status code %d, got %d", expectedStatusCode, response.StatusCode)
+	}
+
+	return extraInfo, nil
+}
+
+func testDNSResolution(domain string) (string, error) {
+	ips, err := net.LookupIP(domain)
+
+	var ipStrings []string
+	if err == nil {
+		for i := range ips {
+			ipStrings = append(ipStrings, ips[i].String())
+		}
+	}
+
+	return strings.Join(ipStrings, ", "), err
+}

+ 26 - 18
internal/glance/glance.go

@@ -5,7 +5,7 @@ import (
 	"context"
 	"fmt"
 	"html/template"
-	"log/slog"
+	"log"
 	"net/http"
 	"path/filepath"
 	"regexp"
@@ -122,11 +122,7 @@ func (a *Application) TransformUserDefinedAssetPath(path string) string {
 	return path
 }
 
-func NewApplication(config *Config) (*Application, error) {
-	if len(config.Pages) == 0 {
-		return nil, fmt.Errorf("no pages configured")
-	}
-
+func newApplication(config *Config) *Application {
 	app := &Application{
 		Version:    buildVersion,
 		Config:     *config,
@@ -180,7 +176,7 @@ func NewApplication(config *Config) (*Application, error) {
 
 	config.Branding.LogoURL = app.TransformUserDefinedAssetPath(config.Branding.LogoURL)
 
-	return app, nil
+	return app
 }
 
 func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request) {
@@ -276,7 +272,7 @@ func (a *Application) AssetPath(asset string) string {
 	return a.Config.Server.BaseURL + "/static/" + a.Config.Server.AssetsHash + "/" + asset
 }
 
-func (a *Application) Serve() error {
+func (a *Application) Server() (func() error, func() error) {
 	// TODO: add gzip support, static files must have their gzipped contents cached
 	// TODO: add HTTPS support
 	mux := http.NewServeMux()
@@ -295,14 +291,9 @@ func (a *Application) Serve() error {
 		http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 24*time.Hour)),
 	)
 
+	var absAssetsPath string
 	if a.Config.Server.AssetsPath != "" {
-		absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath)
-
-		if err != nil {
-			return fmt.Errorf("invalid assets path: %s", a.Config.Server.AssetsPath)
-		}
-
-		slog.Info("Serving assets", "path", absAssetsPath)
+		absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath)
 		assetsFS := FileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour)
 		mux.Handle("/assets/{path...}", http.StripPrefix("/assets/", assetsFS))
 	}
@@ -312,8 +303,25 @@ func (a *Application) Serve() error {
 		Handler: mux,
 	}
 
-	a.Config.Server.StartedAt = time.Now()
-	slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port, "base-url", a.Config.Server.BaseURL)
+	start := func() error {
+		a.Config.Server.StartedAt = time.Now()
+		log.Printf("Starting server on %s:%d (base-url: \"%s\", assets-path: \"%s\")\n",
+			a.Config.Server.Host,
+			a.Config.Server.Port,
+			a.Config.Server.BaseURL,
+			absAssetsPath,
+		)
+
+		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+			return err
+		}
+
+		return nil
+	}
+
+	stop := func() error {
+		return server.Close()
+	}
 
-	return server.ListenAndServe()
+	return start, stop
 }

+ 93 - 18
internal/glance/main.go

@@ -2,45 +2,120 @@ package glance
 
 import (
 	"fmt"
-	"os"
+	"log"
 )
 
 func Main() int {
-	options, err := ParseCliOptions()
+	options, err := parseCliOptions()
 
 	if err != nil {
 		fmt.Println(err)
 		return 1
 	}
 
-	configFile, err := os.Open(options.ConfigPath)
+	switch options.intent {
+	case cliIntentServe:
+		if err := serveApp(options.configPath); err != nil {
+			fmt.Println(err)
+			return 1
+		}
+	case cliIntentConfigValidate:
+		contents, _, err := parseYAMLIncludes(options.configPath)
+		if err != nil {
+			fmt.Printf("failed to parse config file: %v\n", err)
+			return 1
+		}
 
-	if err != nil {
-		fmt.Printf("failed opening config file: %v\n", err)
-		return 1
+		if _, err := newConfigFromYAML(contents); err != nil {
+			fmt.Printf("config file is invalid: %v\n", err)
+			return 1
+		}
+	case cliIntentConfigPrint:
+		contents, _, err := parseYAMLIncludes(options.configPath)
+		if err != nil {
+			fmt.Printf("failed to parse config file: %v\n", err)
+			return 1
+		}
+
+		fmt.Println(string(contents))
+	case cliIntentDiagnose:
+		runDiagnostic()
+	}
+
+	return 0
+}
+
+func serveApp(configPath string) error {
+	exitChannel := make(chan struct{})
+	// the onChange method gets called at most once per 500ms due to debouncing so we shouldn't
+	// need to use atomic.Bool here unless newConfigFromYAML is very slow for some reason
+	hadValidConfigOnStartup := false
+	var stopServer func() error
+
+	onChange := func(newContents []byte) {
+		if stopServer != nil {
+			log.Println("Config file changed, attempting to restart server")
+		}
+
+		config, err := newConfigFromYAML(newContents)
+		if err != nil {
+			log.Printf("Config file is invalid: %v", err)
+
+			if !hadValidConfigOnStartup {
+				close(exitChannel)
+			}
+
+			return
+		} else if !hadValidConfigOnStartup {
+			hadValidConfigOnStartup = true
+		}
+
+		app := newApplication(config)
+
+		if stopServer != nil {
+			if err := stopServer(); err != nil {
+				log.Printf("Error while trying to stop server: %v", err)
+			}
+		}
+
+		go func() {
+			var startServer func() error
+			startServer, stopServer = app.Server()
+
+			if err := startServer(); err != nil {
+				log.Printf("Failed to start server: %v", err)
+			}
+		}()
 	}
 
-	config, err := NewConfigFromYml(configFile)
-	configFile.Close()
+	onErr := func(err error) {
+		log.Printf("Error watching config files: %v", err)
+	}
 
+	configContents, configIncludes, err := parseYAMLIncludes(configPath)
 	if err != nil {
-		fmt.Printf("failed parsing config file: %v\n", err)
-		return 1
+		return fmt.Errorf("failed to parse config file: %w", err)
 	}
 
-	if options.Intent == CliIntentServe {
-		app, err := NewApplication(config)
+	stopWatching, err := configFilesWatcher(configPath, configContents, configIncludes, onChange, onErr)
+	if err == nil {
+		defer stopWatching()
+	} else {
+		log.Printf("Error starting file watcher, config file changes will require a manual restart. (%v)", err)
 
+		config, err := newConfigFromYAML(configContents)
 		if err != nil {
-			fmt.Printf("failed creating application: %v\n", err)
-			return 1
+			return fmt.Errorf("could not parse config file: %w", err)
 		}
 
-		if err := app.Serve(); err != nil {
-			fmt.Printf("http server error: %v\n", err)
-			return 1
+		app := newApplication(config)
+
+		startServer, _ := app.Server()
+		if err := startServer(); err != nil {
+			return fmt.Errorf("failed to start server: %w", err)
 		}
 	}
 
-	return 0
+	<-exitChannel
+	return nil
 }