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.
- cmd/server/cli.go +31 -0
- cmd/server/cli_test.go +53 -0
- cmd/server/deploy.go +222 -0
- cmd/server/hook.go +92 -0
- cmd/server/main.go +204 -0
- cmd/server/repo.go +68 -0
- go.mod +24 -0
- go.sum +63 -0
- internal/api/api.go +265 -0
- internal/api/api_test.go +242 -0
- internal/build/build.go +405 -0
- internal/build/e2e_test.go +321 -0
- internal/build/runner.go +44 -0
- internal/build/testdata/fixture-repo/backend/go.mod +3 -0
- internal/build/testdata/fixture-repo/backend/main.go +41 -0
- internal/client/client.go +176 -0
- internal/config/config.go +82 -0
- internal/db/artifacts.go +37 -0
- internal/db/db.go +53 -0
- internal/db/db_test.go +201 -0
- internal/db/deploys.go +247 -0
- internal/db/processes.go +61 -0
- internal/db/repos.go +71 -0
- internal/db/schema.sql +84 -0
- internal/gitrepo/gitrepo.go +249 -0
- internal/gitrepo/gitrepo_test.go +209 -0
- internal/hashkey/hashkey.go +92 -0
- internal/hashkey/hashkey_test.go +118 -0
- internal/manifest/manifest.go +203 -0
- internal/manifest/manifest_test.go +133 -0
- internal/proxy/proxy.go +286 -0
- internal/proxy/proxy_test.go +288 -0
- internal/store/store.go +231 -0
- internal/store/store_test.go +169 -0
- internal/supervise/supervise.go +466 -0
- internal/supervise/supervise_test.go +331 -0
- local_preview-0.1.0.data/scripts/preview +0 -0
- local_preview-0.1.0.dist-info/METADATA +765 -0
- local_preview-0.1.0.dist-info/RECORD +57 -0
- local_preview-0.1.0.dist-info/WHEEL +4 -0
- local_preview-0.1.0.dist-info/licenses/LICENSE +674 -0
- main.go +15 -0
- orchestrator/orchestrator.go +378 -0
- orchestrator/orchestrator_test.go +218 -0
- web/index.html +26 -0
- web/package-lock.json +2295 -0
- web/package.json +38 -0
- web/public/favicon.svg +6 -0
- web/src/App.tsx +207 -0
- web/src/api/client.ts +67 -0
- web/src/index.css +41 -0
- web/src/main.tsx +22 -0
- web/src/vite-env.d.ts +1 -0
- web/static.go +20 -0
- web/static_embed.go +44 -0
- web/tsconfig.json +24 -0
- 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
|
+
)
|