1// Package serve provides the serve command for soft-serve.
2package serve
3
4import (
5 "context"
6 "fmt"
7 "net/http"
8 "os"
9 "os/signal"
10 "path/filepath"
11 "strconv"
12 "sync"
13 "syscall"
14 "time"
15
16 "github.com/charmbracelet/soft-serve/cmd"
17 "github.com/charmbracelet/soft-serve/pkg/backend"
18 "github.com/charmbracelet/soft-serve/pkg/config"
19 "github.com/charmbracelet/soft-serve/pkg/db"
20 "github.com/charmbracelet/soft-serve/pkg/db/migrate"
21 "github.com/spf13/cobra"
22)
23
24var (
25 syncHooks bool
26
27 // Command is the serve command.
28 Command = &cobra.Command{
29 Use: "serve",
30 Short: "Start the server",
31 Args: cobra.NoArgs,
32 PersistentPreRunE: cmd.InitBackendContext,
33 PersistentPostRunE: cmd.CloseDBContext,
34 RunE: func(c *cobra.Command, _ []string) error {
35 ctx := c.Context()
36 cfg := config.DefaultConfig()
37 if cfg.Exist() {
38 if err := cfg.ParseFile(); err != nil {
39 return fmt.Errorf("parse config file: %w", err)
40 }
41 } else {
42 if err := cfg.WriteConfig(); err != nil {
43 return fmt.Errorf("write config file: %w", err)
44 }
45 }
46
47 if err := cfg.ParseEnv(); err != nil {
48 return fmt.Errorf("parse environment variables: %w", err)
49 }
50
51 // Create custom hooks directory if it doesn't exist
52 customHooksPath := filepath.Join(cfg.DataPath, "hooks")
53 if _, err := os.Stat(customHooksPath); err != nil && os.IsNotExist(err) {
54 os.MkdirAll(customHooksPath, os.ModePerm) //nolint:errcheck,gosec
55 // Generate update hook example without executable permissions
56 hookPath := filepath.Join(customHooksPath, "update.sample")
57 //nolint: gosec
58 if err := os.WriteFile(hookPath, []byte(updateHookExample), 0o744); err != nil {
59 return fmt.Errorf("failed to generate update hook example: %w", err)
60 }
61 }
62
63 // Create log directory if it doesn't exist
64 logPath := filepath.Join(cfg.DataPath, "log")
65 if _, err := os.Stat(logPath); err != nil && os.IsNotExist(err) {
66 os.MkdirAll(logPath, os.ModePerm) //nolint:errcheck,gosec
67 }
68
69 db := db.FromContext(ctx)
70 if err := migrate.Migrate(ctx, db); err != nil {
71 return fmt.Errorf("migration error: %w", err)
72 }
73
74 s, err := NewServer(ctx)
75 if err != nil {
76 return fmt.Errorf("start server: %w", err)
77 }
78
79 if syncHooks {
80 be := backend.FromContext(ctx)
81 if err := cmd.InitializeHooks(ctx, cfg, be); err != nil {
82 return fmt.Errorf("initialize hooks: %w", err)
83 }
84 }
85
86 lch := make(chan error, 1)
87 done := make(chan os.Signal, 1)
88 doneOnce := sync.OnceFunc(func() { close(done) })
89
90 signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
91
92 // This endpoint is added for testing purposes
93 // It allows us to stop the server from the test suite.
94 // This is needed since Windows doesn't support signals.
95 if testRun, _ := strconv.ParseBool(os.Getenv("SOFT_SERVE_TESTRUN")); testRun {
96 h := s.HTTPServer.Server.Handler
97 s.HTTPServer.Server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
98 if r.URL.Path == "/__stop" && r.Method == http.MethodHead {
99 doneOnce()
100 return
101 }
102 h.ServeHTTP(w, r)
103 })
104 }
105
106 go func() {
107 lch <- s.Start()
108 doneOnce()
109 }()
110
111 select {
112 case err := <-lch:
113 if err != nil {
114 return fmt.Errorf("server error: %w", err)
115 }
116 case <-done:
117 }
118
119 ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
120 defer cancel()
121 if err := s.Shutdown(ctx); err != nil {
122 return err
123 }
124
125 return nil
126 },
127 }
128)
129
130func init() {
131 Command.Flags().BoolVarP(&syncHooks, "sync-hooks", "", false, "synchronize hooks for all repositories before running the server")
132}
133
134const updateHookExample = `#!/bin/sh
135#
136# An example hook script to echo information about the push
137# and send it to the client.
138#
139# To enable this hook, rename this file to "update" and make it executable.
140
141refname="$1"
142oldrev="$2"
143newrev="$3"
144
145# Safety check
146if [ -z "$GIT_DIR" ]; then
147 echo "Don't run this script from the command line." >&2
148 echo " (if you want, you could supply GIT_DIR then run" >&2
149 echo " $0 <ref> <oldrev> <newrev>)" >&2
150 exit 1
151fi
152
153if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then
154 echo "usage: $0 <ref> <oldrev> <newrev>" >&2
155 exit 1
156fi
157
158# Check types
159# if $newrev is 0000...0000, it's a commit to delete a ref.
160zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
161if [ "$newrev" = "$zero" ]; then
162 newrev_type=delete
163else
164 newrev_type=$(git cat-file -t $newrev)
165fi
166
167echo "Hi from Soft Serve update hook!"
168echo
169echo "Repository: $SOFT_SERVE_REPO_NAME"
170echo "RefName: $refname"
171echo "Change Type: $newrev_type"
172echo "Old SHA1: $oldrev"
173echo "New SHA1: $newrev"
174
175exit 0
176`