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
33 changes: 32 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,41 @@ Cert generation: `internal/cert/cert.go` — runs `mkcert localhost 127.0.0.1 ::

Template conditionality: `{{if .HTTPS}}` / `{{if .CustomPort}}` in Caddyfile.tmpl, nginx.conf.tmpl, compose fragments. Custom port suppresses HTTP→HTTPS redirect.

Vite dev server: Vite serves its own TLS using Frank's mkcert certs — **no proxy through Caddy/nginx**. `generate()` writes `.frank/vite-server.js` (HTTPS mode: full TLS config with `host: '0.0.0.0'`, cert paths, origin, cors; HTTP mode: just `host: '0.0.0.0'`). `frank new` auto-patches vite.config via `patchViteConfig()` in `cmd/install.go`. `frank setup`/`frank generate` show a hint if vite.config doesn't import `vite-server`. Port 5173 always mapped on `laravel.test` (both runtimes) — FPM moved it from nginx to laravel.test because Vite runs in the app container, not the proxy.
Vite dev server: Vite serves its own TLS using Frank's mkcert certs — **no proxy through Caddy/nginx**. `generate()` writes `.frank/vite-server.js` (HTTPS mode: full TLS config with `host: '0.0.0.0'`, cert paths, origin, cors; HTTP mode: just `host: '0.0.0.0'`). `frank new` auto-patches vite.config via `patchViteConfig()` in `cmd/install.go`. `frank setup`/`frank generate` show a hint if vite.config doesn't import `vite-server`. Port 5173 is published by the `laravel.vite` sidecar (see Dev Server below), not `laravel.test` — the dev-server feature moved the mapping off the app container. When `dev.enabled: false`, 5173 is left unmapped.

Key design decision: originally tried TLS-terminating Vite at the proxy layer (Caddy/nginx listening on :5173 with TLS, proxying to Vite). Failed because FrankenPHP's single-container architecture creates a port conflict (Caddy and Vite both want 5173). Direct Vite TLS is simpler and works for both runtimes.

## Dev Server (`frank dev`)

The frontend dev server (Vite) runs as a compose sidecar `laravel.vite`, built from
the same image as `laravel.test` (build-block tag-dedup, same as workers). Spec:
`docs/superpowers/specs/2026-06-25-dev-server-runner-design.md`.

Config: `Dev struct { Enabled *bool; Command string }` in `internal/config/config.go`
(nil=true pattern like `Server.HTTPS`; **not** materialized in `applyDefaults` — kept
nil so round-tripped `frank.yaml` stays clean). `Dev.IsEnabled()` and
`Dev.EffectiveCommand(pm)`. `EffectiveCommand` returns `Command` verbatim if set, else
derives `[ -d node_modules ] || <pm> install; <pm> dev` — the `node_modules` guard makes
the install a noop on every restart, and this is the **sole** node-deps installer (Frank
installs them nowhere else). Unknown-key warnings via `warnUnknownDevKeys` + `knownDevKeys`.

Compose: `emitVite()` in `internal/compose/compose.go` builds the service inline in Go
(single service, Go-computed command — same post-merge-injection philosophy as worker
build blocks), gated on `cfg.Dev.IsEnabled()`. Reuses worker invariants: tty:true,
healthcheck disable:true, WWWUSER, no `user:`, no `entrypoint:`, image+build tag-dedup.
**No** migrate/DB dependency (Vite touches neither). **Port move:** the `{{.VitePort}}:5173`
mapping was removed from `laravel.test` in both runtime fragments and now publishes on
`laravel.vite` via `vitePort` (5173 non-worktree, 5174–5199 in worktree mode). When dev is
disabled, no vite service and 5173 unmapped.

Command: `cmd/dev.go` — thin `docker.New(resolveDir()).Run(...)` passthroughs preserving the
project-dir invariant. `frank dev` = `compose logs -f --no-log-prefix laravel.vite` (Ctrl-C
detaches, container keeps running); `restart`/`stop`/`start` map to the compose verbs.

Lifecycle owned by compose: `frank up` starts it, `frank down` stops it — no pidfile/
daemonize/orphan infra (unlike `internal/watch`). New golden fixture `frankenphp-pgsql-no-dev`
covers the disabled path; `internal/compose/vite_test.go` asserts the port follows `vitePort`.

## Sail Interop Notes

`vendor/bin/sail` is a **bash script** (not PHP) — `./vendor/bin/sail <cmd>` works without a local PHP install. Keep this in mind when writing user-facing messages or docs that reference Sail commands.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ config:
| `frank test [-- <artisan/pest flags>]` | Run tests inside the app container (`php artisan test`). Pest parallel works out of the box — see [`docs/testing.md`](docs/testing.md) |
| `frank exec <cmd> [args...]` | Run a command inside the app container as sail (e.g. `frank exec bash`, `frank exec php vendor/bin/pint`) |
| `frank compose [--] <args>` | Pass-through to `docker compose` (e.g. `frank compose ps`, `frank compose logs`) |
| `frank dev [restart\|stop\|start]` | Attach to the frontend dev server (Vite) running as a compose sidecar; no args tails its logs (Ctrl-C detaches). Disable per-project with `dev.enabled: false` |
| `frank worker queue [--count N] [--queue …] [--tries …] [-- <artisan flags>]` | Spawn ad-hoc `queue:work` workers |
| `frank worker schedule` | Spawn an ad-hoc `schedule:work` container |
| `frank worker ps` | Show declared + ad-hoc worker containers |
Expand Down
3 changes: 3 additions & 0 deletions cmd/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,11 @@ func runAdd(cmd *cobra.Command, args []string) error {
if err := saveConfig(cfg, dir); err != nil {
return err
}

fmt.Printf(" added %s\n", service)

fmt.Println("\nRegenerating Docker files...")

return generate(cfg, dir, rootCmd.Version)
}

Expand All @@ -63,5 +65,6 @@ func saveConfig(cfg *config.Config, dir string) error {
if err != nil {
return err
}

return writeFile(filepath.Join(dir, config.ConfigFileName), content)
}
1 change: 1 addition & 0 deletions cmd/aliases.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ var aliasesCmd = &cobra.Command{
// shortCmd trims the verbose docker compose prefix for readability.
func shortCmd(cmd string) string {
const dcPrefix = "docker compose --project-directory . -f .frank/compose.yaml "

const execPrefix = dcPrefix + "exec --user sail laravel.test "
switch {
case len(cmd) > len(execPrefix) && cmd[:len(execPrefix)] == execPrefix:
Expand Down
18 changes: 18 additions & 0 deletions cmd/auto_regenerate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,25 +78,31 @@ server:
// the user had previously run `frank generate`. Returns the project dir.
func seedFrankProject(t *testing.T, yaml, version string) string {
t.Helper()

dir := filepath.Join(t.TempDir(), "proj")
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}

if err := os.WriteFile(filepath.Join(dir, "frank.yaml"), []byte(yaml), 0644); err != nil {
t.Fatalf("write frank.yaml: %v", err)
}

cfg, err := config.Load(dir)
if err != nil {
t.Fatalf("seed config.Load: %v", err)
}

if err := generate(cfg, dir, version); err != nil {
t.Fatalf("seed generate: %v", err)
}

return dir
}

func writeYAML(t *testing.T, dir, yaml string) {
t.Helper()

if err := os.WriteFile(filepath.Join(dir, "frank.yaml"), []byte(yaml), 0644); err != nil {
t.Fatalf("rewrite frank.yaml: %v", err)
}
Expand Down Expand Up @@ -255,9 +261,11 @@ func TestAutoRegenerate(t *testing.T) {
if err != nil {
t.Fatalf("autoRegenerate: %v", err)
}

if regen != tt.wantRegen {
t.Errorf("regenerated = %v, want %v", regen, tt.wantRegen)
}

if build != tt.wantBuild {
t.Errorf("needsBuild = %v, want %v", build, tt.wantBuild)
}
Expand All @@ -275,9 +283,11 @@ func TestAutoRegenerate_QueueRepro(t *testing.T) {
if err != nil {
t.Fatalf("autoRegenerate: %v", err)
}

if !regen {
t.Fatal("expected regeneration after queue edit")
}

if build {
t.Error("queue edit must not force --build")
}
Expand All @@ -293,6 +303,7 @@ func TestAutoRegenerate_QueueRepro(t *testing.T) {
// editing base.Dockerfile (leaving the primary Dockerfile intact) flips it true.
func TestDockerfileChanged_BaseDockerfile(t *testing.T) {
dir := seedFrankProject(t, yamlFrankenphpBase, "1.0.0")

cfg, err := config.Load(dir)
if err != nil {
t.Fatalf("config.Load: %v", err)
Expand All @@ -307,6 +318,7 @@ func TestDockerfileChanged_BaseDockerfile(t *testing.T) {
if err := os.WriteFile(base, []byte("FROM scratch\n"), 0644); err != nil {
t.Fatal(err)
}

if !dockerfileChanged(dir, cfg) {
t.Error("edited base.Dockerfile should report changed")
}
Expand All @@ -315,6 +327,7 @@ func TestDockerfileChanged_BaseDockerfile(t *testing.T) {
if err := os.Remove(base); err != nil {
t.Fatal(err)
}

if !dockerfileChanged(dir, cfg) {
t.Error("missing base.Dockerfile should report changed")
}
Expand All @@ -327,25 +340,30 @@ func TestFrankConfigHash_Deterministic(t *testing.T) {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}

writeYAML(t, dir, yamlFrankenphpBase)

h1 := frankConfigHash(dir)
h2 := frankConfigHash(dir)

if h1 == "" {
t.Fatal("hash empty for present frank.yaml")
}

if h1 != h2 {
t.Errorf("hash not deterministic: %q != %q", h1, h2)
}

// A semantic change must flip the hash.
writeYAML(t, dir, yamlFrankenphpPHP85)

if h3 := frankConfigHash(dir); h3 == h1 {
t.Error("hash unchanged after editing frank.yaml")
}

// Missing file → empty hash (treated as not-drifted by Tier 1).
os.Remove(filepath.Join(dir, "frank.yaml"))

if h := frankConfigHash(dir); h != "" {
t.Errorf("missing frank.yaml should hash to empty, got %q", h)
}
Expand Down
21 changes: 21 additions & 0 deletions cmd/config_set.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,20 @@ func runConfigSet(cmd *cobra.Command, args []string) error {
for k := range settableKeys {
keys = append(keys, k)
}

return fmt.Errorf("unknown key %q — valid keys: %s", key, strings.Join(keys, ", "))
}

// Validate value.
valid := false

for _, v := range allowed {
if v == value {
valid = true
break
}
}

if !valid {
return fmt.Errorf("invalid value %q for %s — valid options: %s", value, key, strings.Join(allowed, ", "))
}
Expand All @@ -81,6 +84,7 @@ func runConfigSet(cmd *cobra.Command, args []string) error {

// Walk the dotted path, creating intermediate mapping nodes if needed.
parts := strings.Split(key, ".")

target := walkOrCreateNodePath(&doc, parts)
if target == nil {
return fmt.Errorf("cannot set %s: unexpected YAML structure", key)
Expand All @@ -101,12 +105,14 @@ func runConfigSet(cmd *cobra.Command, args []string) error {
if err := os.WriteFile(cfgPath, out, 0644); err != nil {
return fmt.Errorf("write %s: %w", config.ConfigFileName, err)
}

cfg, loadErr := config.Load(dir)
if loadErr != nil {
// Restore original frank.yaml.
_ = os.WriteFile(cfgPath, raw, 0644)
return fmt.Errorf("validation failed: %w", loadErr)
}

output.Group(fmt.Sprintf("Set %s = %s", key, value), "")

// Regenerate .frank/ files.
Expand All @@ -115,6 +121,7 @@ func runConfigSet(cmd *cobra.Command, args []string) error {
stopGen(err)
return err
}

stopGen(nil)

// Prompt to rebuild.
Expand All @@ -131,6 +138,7 @@ func walkOrCreateNodePath(root *yaml.Node, path []string) *yaml.Node {
if root.Kind != yaml.DocumentNode || len(root.Content) == 0 {
return nil
}

node := root.Content[0]

for i, key := range path {
Expand All @@ -140,32 +148,40 @@ func walkOrCreateNodePath(root *yaml.Node, path []string) *yaml.Node {

// Search existing keys.
found := false

for j := 0; j+1 < len(node.Content); j += 2 {
if node.Content[j].Value == key {
if i == len(path)-1 {
// Final segment — return the value node.
return node.Content[j+1]
}

node = node.Content[j+1]
found = true

break
}
}

if found {
continue
}

// Key not found — create intermediate mapping or final scalar.
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key, Tag: "!!str"}

if i == len(path)-1 {
valNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str"}
node.Content = append(node.Content, keyNode, valNode)

return valNode
}

mapNode := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
node.Content = append(node.Content, keyNode, mapNode)
node = mapNode
}

return node
}

Expand All @@ -174,12 +190,15 @@ func marshalNode(doc *yaml.Node) ([]byte, error) {
var buf strings.Builder
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)

if err := enc.Encode(doc); err != nil {
return nil, err
}

if err := enc.Close(); err != nil {
return nil, err
}

return []byte(buf.String()), nil
}

Expand All @@ -192,12 +211,14 @@ func configSetCompletion(cmd *cobra.Command, args []string, toComplete string) (
for k := range settableKeys {
keys = append(keys, k)
}

return keys, cobra.ShellCompDirectiveNoFileComp
case 1:
// Complete valid values for the given key.
if vals, ok := settableKeys[args[0]]; ok {
return vals, cobra.ShellCompDirectiveNoFileComp
}

return nil, cobra.ShellCompDirectiveNoFileComp
default:
return nil, cobra.ShellCompDirectiveNoFileComp
Expand Down
12 changes: 12 additions & 0 deletions cmd/config_set_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ func TestConfigSet_InvalidValue(t *testing.T) {
for key, allowed := range settableKeys {
bad := "definitely-not-valid-xyz"
found := false

for _, v := range allowed {
if v == bad {
found = true
break
}
}

if found {
t.Fatalf("test value %q unexpectedly valid for %s", bad, key)
}
Expand All @@ -41,7 +43,9 @@ func TestConfigSet_WalkOrCreateNodePath_ExistingPath(t *testing.T) {
php:
version: "8.4"
`

var doc yaml.Node

if err := yaml.Unmarshal([]byte(input), &doc); err != nil {
t.Fatal(err)
}
Expand All @@ -50,6 +54,7 @@ php:
if node == nil {
t.Fatal("walkOrCreateNodePath returned nil for existing path")
}

if node.Value != "8.4" {
t.Errorf("value = %q, want %q", node.Value, "8.4")
}
Expand All @@ -58,7 +63,9 @@ php:
func TestConfigSet_WalkOrCreateNodePath_MissingIntermediate(t *testing.T) {
input := `version: 1
`

var doc yaml.Node

if err := yaml.Unmarshal([]byte(input), &doc); err != nil {
t.Fatal(err)
}
Expand All @@ -76,15 +83,19 @@ func TestConfigSet_WalkOrCreateNodePath_MissingIntermediate(t *testing.T) {
// Verify the intermediate mapping was created.
top := doc.Content[0]
found := false

for i := 0; i+1 < len(top.Content); i += 2 {
if top.Content[i].Value == "node" {
if top.Content[i+1].Kind != yaml.MappingNode {
t.Errorf("intermediate 'node' is not a MappingNode")
}

found = true

break
}
}

if !found {
t.Error("intermediate 'node' key not created")
}
Expand All @@ -93,6 +104,7 @@ func TestConfigSet_WalkOrCreateNodePath_MissingIntermediate(t *testing.T) {
func TestConfigSet_WalkOrCreateNodePath_NonDocument(t *testing.T) {
// A bare scalar node (not a document) should return nil.
node := &yaml.Node{Kind: yaml.ScalarNode, Value: "hello"}

result := walkOrCreateNodePath(node, []string{"foo"})
if result != nil {
t.Error("expected nil for non-document node")
Expand Down
Loading