Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Most settings are changed from the Flock app or the CLI. This page describes whe
| File | Purpose |
| --- | --- |
| `.flockrc` | PHP (`php=8.5`) and optionally Node (`node=22`) versions for the project. Commit it. |
| `flock.yml` | How the project's [git worktrees](worktrees.md) are set up. Commit it; `flock.local.yml` next to it holds your machine's settings. |
| `flock.yml` | How the project's [git worktrees](worktrees.md) are set up, and the site's [Caddy directives](sites.md#custom-caddy-directives). Commit it; `flock.local.yml` next to it holds your machine's settings. |
| `.nvmrc` / `.node-version` | Node version, read the same way as by nvm and other tools. Only version numbers count; aliases like `lts/*` are ignored. |

Project files are looked up from the current directory upwards. See [Per-project versions](project-versions.md).
Expand Down
27 changes: 27 additions & 0 deletions docs/sites.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,33 @@ Requests pass to the server with their `Host` header, so the app sees `softpack.

A proxy's name can't be a linked site's: Flock refuses it. A folder of the same name in a project folder is served by the proxy instead, so `flock proxy softpack 3000` works for `~/Code/softpack`; `flock unproxy softpack` serves the folder again.

## Custom Caddy directives

A PHP site can add its own [Caddy directives](https://caddyserver.com/docs/caddyfile/directives), for example CORS headers. Put them in a file and name it with `caddyfile:` in the project's `flock.yml`:

```yaml
caddyfile: flock.caddy
```

A relative path starts at the folder of the `flock.yml`. To use a file only on your machine, set `caddyfile:` in `flock.local.yml` instead: it replaces the one in `flock.yml`, and `caddyfile: ""` turns it off. Without `caddyfile:`, Flock serves the site as usual and ignores any `Caddyfile` the project has.

The file holds directives only, without a site address or the braces around them: Flock adds them to the site it serves, before PHP, and reloads them when the file changes. This file allows a separate frontend to call the site and answers CORS preflight requests:

```caddyfile
@cors_preflight method OPTIONS

header {
Access-Control-Allow-Origin "https://admin.example.test"
Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
Access-Control-Allow-Headers "Content-Type, Authorization"
Vary "Origin"
}

respond @cors_preflight 204
```

Site options such as `tls` and `handle_errors` aren't supported, and neither is `import`. If Flock can't use the file, it serves the site without it and says why in its warnings. The directives only apply to the site on your machine, so keep the production CORS setup in the app or the production web server.

## Listing sites

:::app
Expand Down
41 changes: 41 additions & 0 deletions internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -442,10 +442,45 @@ func (d *daemon) projectFiles() string {
fmt.Fprintf(&b, "%s\n", file)
}
}
// The settings files and the Caddy directives they name (see siteHandlers).
files := project.SettingsFiles(cfg, site)
if file, _, _ := project.CaddyFile(cfg, site); file != "" {
files = append(files, file)
}
for _, file := range files {
if info, err := os.Stat(file); err == nil {
fmt.Fprintf(&b, "%s %d %d\n", file, info.Size(), info.ModTime().UnixNano())
}
}
}
return b.String()
}

// siteHandlers reads the Caddy directives a site's flock.yml or flock.local.yml names with
// `caddyfile:` (see project.CaddyFile) and checks them in ws, the site as it is served. A problem
// is a warning: the site is served without them, and the other sites aren't affected.
func siteHandlers(cfg *config.Config, site config.Site, ws web.Site) (handlers, problem string) {
file, from, err := project.CaddyFile(cfg, site)
if err != nil {
return "", fmt.Sprintf("caddyfile: %s: %v", site.Name, err)
}
if file == "" {
return "", ""
}
data, err := os.ReadFile(file)
if errors.Is(err, os.ErrNotExist) {
return "", fmt.Sprintf("caddyfile: %s: %s names %s, which doesn't exist", site.Name, from, file)
}
if err != nil {
return "", fmt.Sprintf("caddyfile: %s: %v", site.Name, err)
}
ws.Handlers = string(data)
if err := web.CheckHandlers(ws, file); err != nil {
return "", fmt.Sprintf("caddyfile: %s is served without its directives: %v", site.Name, err)
}
return ws.Handlers, ""
}

// breakpointInterval is how often the sites' PhpStorm workspaces are checked for breakpoints.
var breakpointInterval = time.Second

Expand Down Expand Up @@ -728,6 +763,12 @@ func (d *daemon) reload() error {
status.URL = scheme + "://" + domain + portSuffix(scheme, cfg)
shareables[site.Name] = shareable{root: ws.Root, version: version}

// Checked once the site is complete: its directives adapt in the block it is served with.
if handlers, problem := siteHandlers(cfg, site, ws); problem != "" {
warnings = append(warnings, problem)
} else {
ws.Handlers = handlers
}
webSites = append(webSites, ws)
domains = append(append(domains, domain), served...)
statuses = append(statuses, status)
Expand Down
53 changes: 53 additions & 0 deletions internal/daemon/daemon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"flock/internal/binaries"
"flock/internal/config"
"flock/internal/paths"
"flock/internal/web"
"flock/internal/worktree"
)

Expand Down Expand Up @@ -358,6 +359,58 @@ func TestFoldersInParkedPathsAreFollowed(t *testing.T) {
waitFor(false)
}

func TestProjectFilesFollowsTheConfiguredCaddyfile(t *testing.T) {
site := t.TempDir()
d := daemon{cfg: &config.Config{Sites: []config.Site{{Name: "app", Path: site}}}}
before := d.projectFiles()

// A project's own Caddyfile isn't Flock's.
writeFile(t, filepath.Join(site, "Caddyfile"), "example.com {\n}\n")
if d.projectFiles() != before {
t.Fatal("a Caddyfile nobody configured changed the project fingerprint")
}

writeFile(t, filepath.Join(site, "flock.yml"), "caddyfile: ops/local.caddy\n")
named := d.projectFiles()
if named == before {
t.Fatal("flock.yml did not change the project fingerprint")
}
configured := filepath.Join(site, "ops", "local.caddy")
if err := os.MkdirAll(filepath.Dir(configured), 0o755); err != nil {
t.Fatal(err)
}
writeFile(t, configured, "header X-Configured yes\n")
if created := d.projectFiles(); created == named || !strings.Contains(created, configured) {
t.Fatalf("creating the configured file did not change the project fingerprint: %q", created)
}
}

func TestSiteHandlersProblemsLeaveTheSiteServedWithoutThem(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{}
site := config.Site{Name: "app", Path: dir}
ws := web.Site{Domain: "app.test", Root: dir, FPM: []string{"127.0.0.1:1"}}

if handlers, problem := siteHandlers(cfg, site, ws); handlers != "" || problem != "" {
t.Fatalf("without settings: got %q, %q", handlers, problem)
}

writeFile(t, filepath.Join(dir, "flock.yml"), "caddyfile: local.caddy\n")
if _, problem := siteHandlers(cfg, site, ws); !strings.Contains(problem, "doesn't exist") {
t.Fatalf("missing file: got %q", problem)
}

writeFile(t, filepath.Join(dir, "local.caddy"), "header X-A b\n}\n:9999 {\n")
if handlers, problem := siteHandlers(cfg, site, ws); handlers != "" || !strings.Contains(problem, "local.caddy:2") {
t.Fatalf("breakout: got %q, %q", handlers, problem)
}

writeFile(t, filepath.Join(dir, "local.caddy"), "header X-A b\n")
if handlers, problem := siteHandlers(cfg, site, ws); handlers != "header X-A b\n" || problem != "" {
t.Fatalf("valid file: got %q, %q", handlers, problem)
}
}

func TestGitWorktreesAreServedUnderTheirSite(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is not installed")
Expand Down
73 changes: 73 additions & 0 deletions internal/project/caddy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package project

import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"

"gopkg.in/yaml.v3"

"flock/internal/config"
)

// The project's Flock settings: ConfigFile is committed, LocalFile is this machine's and stays out
// of git.
const (
ConfigFile = "flock.yml"
LocalFile = "flock.local.yml"
)

// SettingsFiles are the flock.yml and flock.local.yml files that apply to a site, in order: later
// files win. A git worktree reads its own flock.yml (it comes with the branch), its site's
// flock.local.yml (a new worktree has none of its own yet), then its own flock.local.yml.
func SettingsFiles(cfg *config.Config, site config.Site) []string {
if site.IsWorktree() {
if parent, ok := cfg.FindSite(site.Parent); ok {
return []string{filepath.Join(site.Path, ConfigFile), filepath.Join(parent.Path, LocalFile), filepath.Join(site.Path, LocalFile)}
}
}
return []string{filepath.Join(site.Path, ConfigFile), filepath.Join(site.Path, LocalFile)}
}

// CaddyFile is the file of Caddy directives a site's settings name with `caddyfile:`, and the
// settings file that names it; both are "" when none does. The last file that sets the key wins,
// so flock.local.yml replaces flock.yml, and an empty value turns it off. A relative path starts
// at the folder of the file that names it, ~/ at the home folder.
func CaddyFile(cfg *config.Config, site config.Site) (file, from string, err error) {
for _, path := range SettingsFiles(cfg, site) {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return "", "", err
}
var settings struct {
Caddyfile *string `yaml:"caddyfile"`
}
if err := yaml.Unmarshal(data, &settings); err != nil {
return "", "", fmt.Errorf("%s: %w", path, err)
}
if settings.Caddyfile == nil {
continue
}
file, from = strings.TrimSpace(*settings.Caddyfile), path
switch {
case file == "":
case strings.HasPrefix(file, "~/") || strings.HasPrefix(file, `~\`):
home, err := os.UserHomeDir()
if err != nil {
return "", "", fmt.Errorf("%s: %w", path, err)
}
file = filepath.Join(home, file[2:])
case !filepath.IsAbs(file):
file = filepath.Join(filepath.Dir(path), file)
}
}
if file == "" {
return "", "", nil
}
return file, from, nil
}
80 changes: 80 additions & 0 deletions internal/project/caddy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package project

import (
"os"
"path/filepath"
"testing"

"flock/internal/config"
)

func writeSettings(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}

func TestCaddyFileIsOnlyWhatTheSettingsName(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{}
site := config.Site{Name: "app", Path: dir}

// A Caddyfile in the project is the project's own, e.g. for production: Flock leaves it alone.
writeSettings(t, filepath.Join(dir, "Caddyfile"), "example.com {\n\tphp_server\n}\n")
if file, _, err := CaddyFile(cfg, site); err != nil || file != "" {
t.Fatalf("without settings: got %q, %v", file, err)
}

writeSettings(t, filepath.Join(dir, ConfigFile), "caddyfile: ops/local.caddy\nworktrees:\n build: false\n")
file, from, err := CaddyFile(cfg, site)
if err != nil || file != filepath.Join(dir, "ops", "local.caddy") || from != filepath.Join(dir, ConfigFile) {
t.Fatalf("flock.yml: got %q from %q, %v", file, from, err)
}

// flock.local.yml replaces flock.yml; an absolute path is used as it is.
other := filepath.Join(t.TempDir(), "mine.caddy")
writeSettings(t, filepath.Join(dir, LocalFile), "caddyfile: "+other+"\n")
if file, from, err = CaddyFile(cfg, site); err != nil || file != other || from != filepath.Join(dir, LocalFile) {
t.Fatalf("flock.local.yml: got %q from %q, %v", file, from, err)
}

// An empty value turns the committed one off.
writeSettings(t, filepath.Join(dir, LocalFile), "caddyfile: \"\"\n")
if file, _, err = CaddyFile(cfg, site); err != nil || file != "" {
t.Fatalf("empty in flock.local.yml: got %q, %v", file, err)
}

writeSettings(t, filepath.Join(dir, LocalFile), "caddyfile: [nope\n")
if _, _, err = CaddyFile(cfg, site); err == nil {
t.Fatal("broken flock.local.yml: no error")
}
}

func TestCaddyFileOfAWorktreeFollowsItsSettingsFiles(t *testing.T) {
parent, wt := t.TempDir(), t.TempDir()
cfg := &config.Config{Sites: []config.Site{{Name: "shop", Path: parent}}}
site := config.Site{Name: "feature.shop", Path: wt, Parent: "shop"}

// The site's flock.yml isn't the worktree's: the branch brings its own.
writeSettings(t, filepath.Join(parent, ConfigFile), "caddyfile: site.caddy\n")
if file, _, err := CaddyFile(cfg, site); err != nil || file != "" {
t.Fatalf("site's flock.yml: got %q, %v", file, err)
}

writeSettings(t, filepath.Join(wt, ConfigFile), "caddyfile: branch.caddy\n")
if file, _, _ := CaddyFile(cfg, site); file != filepath.Join(wt, "branch.caddy") {
t.Fatalf("worktree's flock.yml: got %q", file)
}

// The site's flock.local.yml applies to its worktrees, relative to the site.
writeSettings(t, filepath.Join(parent, LocalFile), "caddyfile: mine.caddy\n")
if file, _, _ := CaddyFile(cfg, site); file != filepath.Join(parent, "mine.caddy") {
t.Fatalf("site's flock.local.yml: got %q", file)
}

writeSettings(t, filepath.Join(wt, LocalFile), "caddyfile: here.caddy\n")
if file, _, _ := CaddyFile(cfg, site); file != filepath.Join(wt, "here.caddy") {
t.Fatalf("worktree's flock.local.yml: got %q", file)
}
}
Loading
Loading