local-preview 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. cmd/server/cli.go +31 -0
  2. cmd/server/cli_test.go +53 -0
  3. cmd/server/deploy.go +222 -0
  4. cmd/server/hook.go +92 -0
  5. cmd/server/main.go +204 -0
  6. cmd/server/repo.go +68 -0
  7. go.mod +24 -0
  8. go.sum +63 -0
  9. internal/api/api.go +265 -0
  10. internal/api/api_test.go +242 -0
  11. internal/build/build.go +405 -0
  12. internal/build/e2e_test.go +321 -0
  13. internal/build/runner.go +44 -0
  14. internal/build/testdata/fixture-repo/backend/go.mod +3 -0
  15. internal/build/testdata/fixture-repo/backend/main.go +41 -0
  16. internal/client/client.go +176 -0
  17. internal/config/config.go +82 -0
  18. internal/db/artifacts.go +37 -0
  19. internal/db/db.go +53 -0
  20. internal/db/db_test.go +201 -0
  21. internal/db/deploys.go +247 -0
  22. internal/db/processes.go +61 -0
  23. internal/db/repos.go +71 -0
  24. internal/db/schema.sql +84 -0
  25. internal/gitrepo/gitrepo.go +249 -0
  26. internal/gitrepo/gitrepo_test.go +209 -0
  27. internal/hashkey/hashkey.go +92 -0
  28. internal/hashkey/hashkey_test.go +118 -0
  29. internal/manifest/manifest.go +203 -0
  30. internal/manifest/manifest_test.go +133 -0
  31. internal/proxy/proxy.go +286 -0
  32. internal/proxy/proxy_test.go +288 -0
  33. internal/store/store.go +231 -0
  34. internal/store/store_test.go +169 -0
  35. internal/supervise/supervise.go +466 -0
  36. internal/supervise/supervise_test.go +331 -0
  37. local_preview-0.1.0.data/scripts/preview +0 -0
  38. local_preview-0.1.0.dist-info/METADATA +765 -0
  39. local_preview-0.1.0.dist-info/RECORD +57 -0
  40. local_preview-0.1.0.dist-info/WHEEL +4 -0
  41. local_preview-0.1.0.dist-info/licenses/LICENSE +674 -0
  42. main.go +15 -0
  43. orchestrator/orchestrator.go +378 -0
  44. orchestrator/orchestrator_test.go +218 -0
  45. web/index.html +26 -0
  46. web/package-lock.json +2295 -0
  47. web/package.json +38 -0
  48. web/public/favicon.svg +6 -0
  49. web/src/App.tsx +207 -0
  50. web/src/api/client.ts +67 -0
  51. web/src/index.css +41 -0
  52. web/src/main.tsx +22 -0
  53. web/src/vite-env.d.ts +1 -0
  54. web/static.go +20 -0
  55. web/static_embed.go +44 -0
  56. web/tsconfig.json +24 -0
  57. web/vite.config.ts +23 -0
cmd/server/cli.go ADDED
@@ -0,0 +1,31 @@
1
+ package server
2
+
3
+ import (
4
+ "os"
5
+
6
+ "github.com/spf13/cobra"
7
+ )
8
+
9
+ // addClientCommands attaches the user-facing CLI subcommand groups to root.
10
+ // They're thin wrappers over the HTTP API, useful for scripting and remote
11
+ // control of a running `preview serve`.
12
+ func addClientCommands(root *cobra.Command) {
13
+ root.AddCommand(repoCmd(), deployCmd(), installHookCmd())
14
+ }
15
+
16
+ // resolveURL returns the effective server URL for a leaf command. PREVIEW_URL
17
+ // wins only when the user didn't explicitly pass --server.
18
+ func resolveURL(cmd *cobra.Command, serverURL string) string {
19
+ if env := os.Getenv("PREVIEW_URL"); env != "" && !cmd.Flags().Changed("server") {
20
+ return env
21
+ }
22
+ return serverURL
23
+ }
24
+
25
+ // addServerFlag registers --server as a persistent flag on the parent group
26
+ // so every leaf inherits it. The same string variable is reused across the
27
+ // group's subcommands.
28
+ func addServerFlag(parent *cobra.Command, dst *string) {
29
+ parent.PersistentFlags().StringVar(dst, "server", "http://localhost:8080",
30
+ "Base URL of the HTTP server")
31
+ }
cmd/server/cli_test.go ADDED
@@ -0,0 +1,53 @@
1
+ package server
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/spf13/cobra"
7
+ )
8
+
9
+ // runLeaf executes a parent command with an inherited --server flag and
10
+ // returns what resolveURL reports inside the leaf's RunE — the same shape as
11
+ // the real CLI subcommands.
12
+ func runLeaf(t *testing.T, args ...string) string {
13
+ t.Helper()
14
+ var serverURL string
15
+ var got string
16
+ parent := &cobra.Command{Use: "root"}
17
+ addServerFlag(parent, &serverURL)
18
+ leaf := &cobra.Command{
19
+ Use: "leaf",
20
+ RunE: func(cmd *cobra.Command, _ []string) error {
21
+ got = resolveURL(cmd, serverURL)
22
+ return nil
23
+ },
24
+ }
25
+ parent.AddCommand(leaf)
26
+ parent.SetArgs(append([]string{"leaf"}, args...))
27
+ if err := parent.Execute(); err != nil {
28
+ t.Fatal(err)
29
+ }
30
+ return got
31
+ }
32
+
33
+ func TestResolveURL(t *testing.T) {
34
+ t.Run("default", func(t *testing.T) {
35
+ if got := runLeaf(t); got != "http://localhost:8080" {
36
+ t.Errorf("resolveURL = %q, want default", got)
37
+ }
38
+ })
39
+
40
+ t.Run("env wins over default", func(t *testing.T) {
41
+ t.Setenv("PREVIEW_URL", "http://env:1234")
42
+ if got := runLeaf(t); got != "http://env:1234" {
43
+ t.Errorf("resolveURL = %q, want env value", got)
44
+ }
45
+ })
46
+
47
+ t.Run("explicit flag wins over env", func(t *testing.T) {
48
+ t.Setenv("PREVIEW_URL", "http://env:1234")
49
+ if got := runLeaf(t, "--server", "http://flag:5678"); got != "http://flag:5678" {
50
+ t.Errorf("resolveURL = %q, want flag value", got)
51
+ }
52
+ })
53
+ }
cmd/server/deploy.go ADDED
@@ -0,0 +1,222 @@
1
+ package server
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "os/exec"
9
+ "path/filepath"
10
+ "strconv"
11
+ "strings"
12
+ "text/tabwriter"
13
+ "time"
14
+
15
+ "github.com/spf13/cobra"
16
+
17
+ "github.com/jmelahman/local-preview/internal/client"
18
+ )
19
+
20
+ func deployCmd() *cobra.Command {
21
+ var serverURL string
22
+ var repoFlag string
23
+ var rebuild, noWait, asJSON bool
24
+
25
+ parent := &cobra.Command{
26
+ Use: "deploy [ref]",
27
+ Short: "Deploy a commit as a preview",
28
+ Long: "Deploy a commit (default: HEAD of the current repo) and print its\n" +
29
+ "preview URL. The repo is auto-detected from the working directory\n" +
30
+ "unless --repo is given.",
31
+ Args: cobra.MaximumNArgs(1),
32
+ RunE: func(cmd *cobra.Command, args []string) error {
33
+ ref := ""
34
+ if len(args) == 1 {
35
+ ref = args[0]
36
+ }
37
+ return runDeploy(cmd.Context(), resolveURL(cmd, serverURL), cmd.OutOrStdout(),
38
+ repoFlag, ref, rebuild, noWait, asJSON)
39
+ },
40
+ }
41
+ addServerFlag(parent, &serverURL)
42
+ parent.Flags().StringVar(&repoFlag, "repo", "", "Registered repo name (default: auto-detect from cwd)")
43
+ parent.Flags().BoolVar(&rebuild, "rebuild", false, "Rebuild artifacts even if cached (needs a restart to affect a live backend)")
44
+ parent.Flags().BoolVar(&noWait, "no-wait", false, "Return immediately instead of waiting for the build")
45
+ parent.Flags().BoolVar(&asJSON, "json", false, "Print the deploy as JSON")
46
+
47
+ list := &cobra.Command{
48
+ Use: "list",
49
+ Short: "List deploys",
50
+ RunE: func(cmd *cobra.Command, args []string) error {
51
+ return runDeployList(cmd.Context(), resolveURL(cmd, serverURL), cmd.OutOrStdout(), repoFlag)
52
+ },
53
+ }
54
+
55
+ show := &cobra.Command{
56
+ Use: "show <id>",
57
+ Short: "Show one deploy",
58
+ Args: cobra.ExactArgs(1),
59
+ RunE: func(cmd *cobra.Command, args []string) error {
60
+ id, err := parseInt64(args[0], "deploy id")
61
+ if err != nil {
62
+ return err
63
+ }
64
+ d, err := client.New(resolveURL(cmd, serverURL), nil).GetDeploy(cmd.Context(), id)
65
+ if err != nil {
66
+ return err
67
+ }
68
+ return printDeployJSON(cmd.OutOrStdout(), d)
69
+ },
70
+ }
71
+
72
+ logs := &cobra.Command{
73
+ Use: "logs <id>",
74
+ Short: "Print a deploy's build logs",
75
+ Args: cobra.ExactArgs(1),
76
+ RunE: func(cmd *cobra.Command, args []string) error {
77
+ id, err := parseInt64(args[0], "deploy id")
78
+ if err != nil {
79
+ return err
80
+ }
81
+ text, err := client.New(resolveURL(cmd, serverURL), nil).GetDeployLogs(cmd.Context(), id)
82
+ if err != nil {
83
+ return err
84
+ }
85
+ fmt.Fprint(cmd.OutOrStdout(), text)
86
+ return nil
87
+ },
88
+ }
89
+
90
+ parent.AddCommand(list, show, logs)
91
+ return parent
92
+ }
93
+
94
+ func runDeploy(ctx context.Context, url string, out io.Writer, repoName, ref string, rebuild, noWait, asJSON bool) error {
95
+ c := client.New(url, nil)
96
+
97
+ if ref == "" {
98
+ sha, err := gitOutput(ctx, "rev-parse", "HEAD")
99
+ if err != nil {
100
+ return fmt.Errorf("no ref given and no commit found in the current directory: %w", err)
101
+ }
102
+ ref = sha
103
+ }
104
+ if repoName == "" {
105
+ var err error
106
+ repoName, err = detectRepo(ctx, c)
107
+ if err != nil {
108
+ return err
109
+ }
110
+ }
111
+
112
+ d, err := c.CreateDeploy(ctx, repoName, ref, rebuild)
113
+ if err != nil {
114
+ return err
115
+ }
116
+ if noWait {
117
+ if asJSON {
118
+ return printDeployJSON(out, d)
119
+ }
120
+ fmt.Fprintf(out, "deploy %d (%s@%s) %s\n", d.ID, d.Repo, d.ShortSHA, d.Status)
121
+ return nil
122
+ }
123
+
124
+ for d.Status == "queued" || d.Status == "building" {
125
+ time.Sleep(500 * time.Millisecond)
126
+ d, err = c.GetDeploy(ctx, d.ID)
127
+ if err != nil {
128
+ return err
129
+ }
130
+ }
131
+ if asJSON {
132
+ return printDeployJSON(out, d)
133
+ }
134
+ switch d.Status {
135
+ case "ready":
136
+ fmt.Fprintf(out, "ready: %s\n", d.PreviewURL)
137
+ if rebuild {
138
+ fmt.Fprintf(out, "note: --rebuild replaced the artifacts on disk; a running backend keeps its old binary until restarted\n")
139
+ }
140
+ return nil
141
+ default:
142
+ fmt.Fprintf(out, "deploy %d %s: %s\n", d.ID, d.Status, d.Error)
143
+ fmt.Fprintf(out, "full logs: preview deploy logs %d\n", d.ID)
144
+ return fmt.Errorf("deploy did not become ready")
145
+ }
146
+ }
147
+
148
+ // detectRepo matches the cwd's git toplevel or origin URL against the
149
+ // server's registered repos.
150
+ func detectRepo(ctx context.Context, c *client.Client) (string, error) {
151
+ repos, err := c.ListRepos(ctx)
152
+ if err != nil {
153
+ return "", err
154
+ }
155
+ if top, err := gitOutput(ctx, "rev-parse", "--show-toplevel"); err == nil {
156
+ absTop, _ := filepath.Abs(top)
157
+ for _, r := range repos {
158
+ if absSource, err := filepath.Abs(r.Source); err == nil && absSource == absTop {
159
+ return r.Name, nil
160
+ }
161
+ }
162
+ }
163
+ if origin, err := gitOutput(ctx, "config", "--get", "remote.origin.url"); err == nil && origin != "" {
164
+ for _, r := range repos {
165
+ if r.Source == origin {
166
+ return r.Name, nil
167
+ }
168
+ }
169
+ }
170
+ return "", fmt.Errorf("could not match the current directory to a registered repo; pass --repo (registered: %s)", repoNames(repos))
171
+ }
172
+
173
+ func repoNames(repos []client.Repo) string {
174
+ if len(repos) == 0 {
175
+ return "none"
176
+ }
177
+ names := make([]string, len(repos))
178
+ for i, r := range repos {
179
+ names[i] = r.Name
180
+ }
181
+ return strings.Join(names, ", ")
182
+ }
183
+
184
+ func gitOutput(ctx context.Context, args ...string) (string, error) {
185
+ out, err := exec.CommandContext(ctx, "git", args...).Output()
186
+ if err != nil {
187
+ return "", err
188
+ }
189
+ return strings.TrimSpace(string(out)), nil
190
+ }
191
+
192
+ func runDeployList(ctx context.Context, url string, out io.Writer, repoName string) error {
193
+ deploys, err := client.New(url, nil).ListDeploys(ctx, repoName)
194
+ if err != nil {
195
+ return err
196
+ }
197
+ tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
198
+ fmt.Fprintln(tw, "ID\tREPO\tSHA\tSTATUS\tURL")
199
+ for _, d := range deploys {
200
+ url := d.PreviewURL
201
+ if url == "" {
202
+ url = "-"
203
+ }
204
+ fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\n", d.ID, d.Repo, d.ShortSHA, d.Status, url)
205
+ }
206
+ return tw.Flush()
207
+ }
208
+
209
+ func printDeployJSON(out io.Writer, d client.Deploy) error {
210
+ enc := json.NewEncoder(out)
211
+ enc.SetIndent("", " ")
212
+ return enc.Encode(d)
213
+ }
214
+
215
+ // parseInt64 wraps strconv.ParseInt with a descriptive error.
216
+ func parseInt64(s, label string) (int64, error) {
217
+ id, err := strconv.ParseInt(s, 10, 64)
218
+ if err != nil {
219
+ return 0, fmt.Errorf("invalid %s %q: %w", label, s, err)
220
+ }
221
+ return id, nil
222
+ }
cmd/server/hook.go ADDED
@@ -0,0 +1,92 @@
1
+ package server
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "io"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
10
+
11
+ "github.com/spf13/cobra"
12
+ )
13
+
14
+ // hookMarker identifies hooks we generated, so install-hook never clobbers a
15
+ // hand-written one.
16
+ const hookMarker = "# generated by local-preview (preview install-hook)"
17
+
18
+ const hookScript = `#!/bin/sh
19
+ ` + hookMarker + `
20
+ preview deploy "$(git rev-parse HEAD)" --no-wait >/dev/null 2>&1 || true
21
+ `
22
+
23
+ // preCommitStanza is printed (never auto-applied — silently mutating a file
24
+ // the pre-commit framework re-parses is riskier than asking the user to
25
+ // paste) when the target repo already uses the pre-commit framework.
26
+ const preCommitStanza = ` - repo: local
27
+ hooks:
28
+ - id: local-preview
29
+ name: local-preview deploy
30
+ entry: sh -c 'preview deploy "$(git rev-parse HEAD)" --no-wait'
31
+ language: system
32
+ stages: [post-commit]
33
+ always_run: true
34
+ pass_filenames: false
35
+ `
36
+
37
+ func installHookCmd() *cobra.Command {
38
+ var dryRun bool
39
+ cmd := &cobra.Command{
40
+ Use: "install-hook",
41
+ Short: "Install a post-commit hook that deploys every commit",
42
+ Long: "Run from inside a target repository's working tree. Installs a git\n" +
43
+ "post-commit hook that requests a preview deploy of each new commit.\n" +
44
+ "If the repo uses the pre-commit framework, the equivalent stanza is\n" +
45
+ "printed for .pre-commit-config.yaml instead.",
46
+ RunE: func(cmd *cobra.Command, args []string) error {
47
+ return runInstallHook(cmd.Context(), cmd.OutOrStdout(), dryRun)
48
+ },
49
+ }
50
+ cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Print what would be written without writing it")
51
+ return cmd
52
+ }
53
+
54
+ func runInstallHook(ctx context.Context, out io.Writer, dryRun bool) error {
55
+ top, err := gitOutput(ctx, "rev-parse", "--show-toplevel")
56
+ if err != nil {
57
+ return fmt.Errorf("not inside a git working tree: %w", err)
58
+ }
59
+
60
+ if _, err := os.Stat(filepath.Join(top, ".pre-commit-config.yaml")); err == nil {
61
+ fmt.Fprintf(out, "This repo uses the pre-commit framework. Add this hook to .pre-commit-config.yaml\n")
62
+ fmt.Fprintf(out, "(and install the post-commit stage with `pre-commit install --hook-type post-commit`):\n\n")
63
+ fmt.Fprint(out, preCommitStanza)
64
+ return nil
65
+ }
66
+
67
+ gitDir, err := gitOutput(ctx, "rev-parse", "--git-dir")
68
+ if err != nil {
69
+ return err
70
+ }
71
+ if !filepath.IsAbs(gitDir) {
72
+ gitDir = filepath.Join(top, gitDir)
73
+ }
74
+ hookPath := filepath.Join(gitDir, "hooks", "post-commit")
75
+
76
+ if existing, err := os.ReadFile(hookPath); err == nil && !strings.Contains(string(existing), hookMarker) {
77
+ return fmt.Errorf("%s already exists and wasn't generated by local-preview — add this line to it yourself:\n preview deploy \"$(git rev-parse HEAD)\" --no-wait || true", hookPath)
78
+ }
79
+
80
+ if dryRun {
81
+ fmt.Fprintf(out, "would write %s:\n\n%s", hookPath, hookScript)
82
+ return nil
83
+ }
84
+ if err := os.MkdirAll(filepath.Dir(hookPath), 0o755); err != nil {
85
+ return err
86
+ }
87
+ if err := os.WriteFile(hookPath, []byte(hookScript), 0o755); err != nil {
88
+ return err
89
+ }
90
+ fmt.Fprintf(out, "installed %s — every commit now deploys a preview\n", hookPath)
91
+ return nil
92
+ }
cmd/server/main.go ADDED
@@ -0,0 +1,204 @@
1
+ package server
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "log"
8
+ "net/http"
9
+ "os"
10
+ "os/signal"
11
+ "runtime/debug"
12
+ "syscall"
13
+ "time"
14
+
15
+ "github.com/spf13/cobra"
16
+
17
+ "github.com/jmelahman/local-preview/internal/api"
18
+ "github.com/jmelahman/local-preview/internal/build"
19
+ "github.com/jmelahman/local-preview/internal/config"
20
+ "github.com/jmelahman/local-preview/internal/db"
21
+ "github.com/jmelahman/local-preview/internal/gitrepo"
22
+ "github.com/jmelahman/local-preview/internal/proxy"
23
+ "github.com/jmelahman/local-preview/internal/store"
24
+ "github.com/jmelahman/local-preview/internal/supervise"
25
+ )
26
+
27
+ // version is populated at build time via -ldflags -X (see Dockerfile /
28
+ // .goreleaser.yaml). Use `git describe --tags --always --dirty` so a single
29
+ // string carries tag, distance, short sha, and dirty marker. Falls back to
30
+ // runtime/debug VCS info for dev builds without ldflags.
31
+ var version = ""
32
+
33
+ // BuildInfo describes the running binary.
34
+ type BuildInfo struct {
35
+ Version string `json:"version"`
36
+ }
37
+
38
+ // Build returns the build metadata for the running binary, falling back to
39
+ // runtime/debug VCS info when ldflags weren't set.
40
+ func Build() BuildInfo {
41
+ if version != "" {
42
+ return BuildInfo{Version: version}
43
+ }
44
+ if info, ok := debug.ReadBuildInfo(); ok {
45
+ var rev string
46
+ var modified bool
47
+ for _, s := range info.Settings {
48
+ switch s.Key {
49
+ case "vcs.revision":
50
+ rev = s.Value
51
+ case "vcs.modified":
52
+ modified = s.Value == "true"
53
+ }
54
+ }
55
+ if rev != "" {
56
+ short := rev
57
+ if len(short) > 7 {
58
+ short = short[:7]
59
+ }
60
+ if modified {
61
+ short += "-dirty"
62
+ }
63
+ return BuildInfo{Version: short}
64
+ }
65
+ }
66
+ return BuildInfo{Version: "dev"}
67
+ }
68
+
69
+ func Root() *cobra.Command {
70
+ var addr string
71
+ var dataDir string
72
+ var inMemory bool
73
+ var previewDomain string
74
+ var buildConcurrency int
75
+
76
+ cmd := &cobra.Command{
77
+ Use: "preview",
78
+ Short: "Local preview-deployment orchestrator",
79
+ Version: Build().Version,
80
+ }
81
+
82
+ serve := &cobra.Command{
83
+ Use: "serve",
84
+ Short: "Start the HTTP server",
85
+ RunE: func(cmd *cobra.Command, args []string) error {
86
+ return run(addr, dataDir, inMemory, previewDomain, buildConcurrency)
87
+ },
88
+ }
89
+ serve.Flags().StringVar(&addr, "addr", ":8080", "HTTP listen address")
90
+ serve.Flags().StringVar(&dataDir, "data-dir", "", "Override data directory (default: $PREVIEW_DATA_DIR or XDG)")
91
+ serve.Flags().BoolVar(&inMemory, "in-memory", false, "Use an ephemeral in-memory SQLite database; all data is discarded on shutdown")
92
+ serve.Flags().StringVar(&previewDomain, "preview-domain", "", "Base domain previews are served under (default: $PREVIEW_DOMAIN or preview.localhost)")
93
+ serve.Flags().IntVar(&buildConcurrency, "build-concurrency", 2, "Number of deploys built in parallel")
94
+ cmd.AddCommand(serve)
95
+
96
+ addClientCommands(cmd)
97
+
98
+ return cmd
99
+ }
100
+
101
+ func run(addr, dataDirOverride string, inMemory bool, previewDomain string, buildConcurrency int) error {
102
+ cfg, err := config.Load(dataDirOverride, previewDomain)
103
+ if err != nil {
104
+ return fmt.Errorf("load config: %w", err)
105
+ }
106
+ workCtx, stopWork := context.WithCancel(context.Background())
107
+ defer stopWork()
108
+ if err := gitrepo.CheckGit(workCtx); err != nil {
109
+ return err
110
+ }
111
+
112
+ dbPath := cfg.DBPath()
113
+ if inMemory {
114
+ log.Printf("WARNING: --in-memory set; using ephemeral SQLite, all data is lost on shutdown")
115
+ dbPath = ":memory:"
116
+ }
117
+ database, err := db.Open(dbPath)
118
+ if err != nil {
119
+ return fmt.Errorf("open db: %w", err)
120
+ }
121
+ defer database.Close()
122
+
123
+ files := store.New(cfg.ArtifactsDir(), cfg.StateDir(), cfg.TmpDir())
124
+ if err := files.SweepTmp(24 * time.Hour); err != nil {
125
+ log.Printf("sweep tmp: %v", err)
126
+ }
127
+ gitMgr := gitrepo.NewManager(cfg.ReposDir())
128
+ super := supervise.New(database, files, cfg.LogsDir())
129
+ super.ReclaimOrphans()
130
+ queue := build.NewQueue(database, gitMgr, files, super, cfg.LogsDir(), nil)
131
+ queue.Start(workCtx, buildConcurrency)
132
+
133
+ apex := api.NewMux(api.Deps{
134
+ Store: database,
135
+ Build: api.BuildInfo(Build()),
136
+ Config: cfg,
137
+ Git: gitMgr,
138
+ Queue: queue,
139
+ Super: super,
140
+ Addr: addr,
141
+ })
142
+ router := proxy.New(database, files, super, cfg.PreviewDomain, apex)
143
+
144
+ srv := &http.Server{
145
+ Addr: addr,
146
+ Handler: recoverPanics(logRequests(router)),
147
+ ReadHeaderTimeout: 10 * time.Second,
148
+ }
149
+
150
+ go func() {
151
+ log.Printf("listening on %s (previews at *.%s)", addr, cfg.PreviewDomain)
152
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
153
+ log.Fatalf("listen: %v", err)
154
+ }
155
+ }()
156
+
157
+ stop := make(chan os.Signal, 1)
158
+ signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
159
+ <-stop
160
+ log.Println("shutting down")
161
+
162
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
163
+ defer cancel()
164
+ err = srv.Shutdown(ctx)
165
+ stopWork()
166
+ super.StopAll()
167
+ return err
168
+ }
169
+
170
+ type statusRecorder struct {
171
+ http.ResponseWriter
172
+ status int
173
+ }
174
+
175
+ func (s *statusRecorder) WriteHeader(code int) {
176
+ s.status = code
177
+ s.ResponseWriter.WriteHeader(code)
178
+ }
179
+
180
+ // recoverPanics is the outermost middleware. It catches panics from any
181
+ // downstream handler so the process survives, logs the trace, and writes a
182
+ // 500 response.
183
+ func recoverPanics(next http.Handler) http.Handler {
184
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
185
+ defer func() {
186
+ rec := recover()
187
+ if rec == nil {
188
+ return
189
+ }
190
+ log.Printf("panic recovered: %v\n%s", rec, debug.Stack())
191
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
192
+ }()
193
+ next.ServeHTTP(w, r)
194
+ })
195
+ }
196
+
197
+ func logRequests(h http.Handler) http.Handler {
198
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
199
+ start := time.Now()
200
+ rec := &statusRecorder{ResponseWriter: w, status: 200}
201
+ h.ServeHTTP(rec, r)
202
+ log.Printf("%s %s %d %s", r.Method, r.URL.Path, rec.status, time.Since(start))
203
+ })
204
+ }
cmd/server/repo.go ADDED
@@ -0,0 +1,68 @@
1
+ package server
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "io"
7
+ "text/tabwriter"
8
+
9
+ "github.com/spf13/cobra"
10
+
11
+ "github.com/jmelahman/local-preview/internal/client"
12
+ )
13
+
14
+ func repoCmd() *cobra.Command {
15
+ var serverURL string
16
+ parent := &cobra.Command{
17
+ Use: "repo",
18
+ Short: "Manage registered repositories",
19
+ }
20
+ addServerFlag(parent, &serverURL)
21
+
22
+ var source string
23
+ create := &cobra.Command{
24
+ Use: "create <name>",
25
+ Short: "Register a repository for previews",
26
+ Long: "Register a repository: the server keeps a mirror clone of --source\n" +
27
+ "(a local path or clone URL) and serves previews at <sha>.<name>.<domain>.",
28
+ Args: cobra.ExactArgs(1),
29
+ RunE: func(cmd *cobra.Command, args []string) error {
30
+ return runRepoCreate(cmd.Context(), resolveURL(cmd, serverURL), cmd.OutOrStdout(), args[0], source)
31
+ },
32
+ }
33
+ create.Flags().StringVar(&source, "source", "", "Local path or clone URL of the repository (required)")
34
+ _ = create.MarkFlagRequired("source")
35
+
36
+ list := &cobra.Command{
37
+ Use: "list",
38
+ Short: "List registered repositories",
39
+ RunE: func(cmd *cobra.Command, args []string) error {
40
+ return runRepoList(cmd.Context(), resolveURL(cmd, serverURL), cmd.OutOrStdout())
41
+ },
42
+ }
43
+
44
+ parent.AddCommand(create, list)
45
+ return parent
46
+ }
47
+
48
+ func runRepoCreate(ctx context.Context, url string, out io.Writer, name, source string) error {
49
+ repo, err := client.New(url, nil).CreateRepo(ctx, name, source)
50
+ if err != nil {
51
+ return err
52
+ }
53
+ fmt.Fprintf(out, "registered %s (source %s)\n", repo.Name, repo.Source)
54
+ return nil
55
+ }
56
+
57
+ func runRepoList(ctx context.Context, url string, out io.Writer) error {
58
+ repos, err := client.New(url, nil).ListRepos(ctx)
59
+ if err != nil {
60
+ return err
61
+ }
62
+ tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
63
+ fmt.Fprintln(tw, "NAME\tSOURCE\tCREATED")
64
+ for _, r := range repos {
65
+ fmt.Fprintf(tw, "%s\t%s\t%s\n", r.Name, r.Source, r.CreatedAt)
66
+ }
67
+ return tw.Flush()
68
+ }
go.mod ADDED
@@ -0,0 +1,24 @@
1
+ module github.com/jmelahman/local-preview
2
+
3
+ go 1.26.5
4
+
5
+ require (
6
+ github.com/BurntSushi/toml v1.6.0
7
+ github.com/spf13/cobra v1.10.2
8
+ golang.org/x/sync v0.22.0
9
+ modernc.org/sqlite v1.55.0
10
+ )
11
+
12
+ require (
13
+ github.com/dustin/go-humanize v1.0.1 // indirect
14
+ github.com/google/uuid v1.6.0 // indirect
15
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
16
+ github.com/mattn/go-isatty v0.0.20 // indirect
17
+ github.com/ncruces/go-strftime v1.0.0 // indirect
18
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
19
+ github.com/spf13/pflag v1.0.9 // indirect
20
+ golang.org/x/sys v0.46.0 // indirect
21
+ modernc.org/libc v1.74.1 // indirect
22
+ modernc.org/mathutil v1.7.1 // indirect
23
+ modernc.org/memory v1.11.0 // indirect
24
+ )