diff --git a/CLAUDE.md b/CLAUDE.md index 6c955fd..799ff99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ] || install; 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 ` works without a local PHP install. Keep this in mind when writing user-facing messages or docs that reference Sail commands. diff --git a/README.md b/README.md index 846bd33..986d7b3 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ config: | `frank test [-- ]` | 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 [args...]` | Run a command inside the app container as sail (e.g. `frank exec bash`, `frank exec php vendor/bin/pint`) | | `frank compose [--] ` | 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 …] [-- ]` | 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 | diff --git a/cmd/add.go b/cmd/add.go index bc32720..2a1a7d7 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -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) } @@ -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) } diff --git a/cmd/aliases.go b/cmd/aliases.go index 4553c6e..18bca86 100644 --- a/cmd/aliases.go +++ b/cmd/aliases.go @@ -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: diff --git a/cmd/auto_regenerate_test.go b/cmd/auto_regenerate_test.go index a1a07c9..c8115ed 100644 --- a/cmd/auto_regenerate_test.go +++ b/cmd/auto_regenerate_test.go @@ -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) } @@ -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) } @@ -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") } @@ -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) @@ -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") } @@ -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") } @@ -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) } diff --git a/cmd/config_set.go b/cmd/config_set.go index be54f53..04c8b80 100644 --- a/cmd/config_set.go +++ b/cmd/config_set.go @@ -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, ", ")) } @@ -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) @@ -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. @@ -115,6 +121,7 @@ func runConfigSet(cmd *cobra.Command, args []string) error { stopGen(err) return err } + stopGen(nil) // Prompt to rebuild. @@ -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 { @@ -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 } @@ -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 } @@ -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 diff --git a/cmd/config_set_test.go b/cmd/config_set_test.go index 8fb49b0..6dad329 100644 --- a/cmd/config_set_test.go +++ b/cmd/config_set_test.go @@ -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) } @@ -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) } @@ -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") } @@ -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) } @@ -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") } @@ -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") diff --git a/cmd/config_show_test.go b/cmd/config_show_test.go index 9765cd9..f27dbfc 100644 --- a/cmd/config_show_test.go +++ b/cmd/config_show_test.go @@ -15,6 +15,7 @@ func TestConfigShow_OutputContainsDefaults(t *testing.T) { if err := os.MkdirAll(dir, 0755); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(dir, config.ConfigFileName), []byte("version: 1\n"), 0644); err != nil { t.Fatal(err) } @@ -22,9 +23,11 @@ func TestConfigShow_OutputContainsDefaults(t *testing.T) { // Override Dir so resolveDir() returns our temp dir. oldDir := Dir Dir = dir + defer func() { Dir = oldDir }() var buf bytes.Buffer + configShowCmd.SetOut(&buf) configShowCmd.SetErr(&buf) @@ -36,6 +39,7 @@ func TestConfigShow_OutputContainsDefaults(t *testing.T) { err := configShowCmd.RunE(configShowCmd, nil) w.Close() + os.Stdout = oldStdout if err != nil { @@ -43,6 +47,7 @@ func TestConfigShow_OutputContainsDefaults(t *testing.T) { } var out bytes.Buffer + out.ReadFrom(r) output := out.String() diff --git a/cmd/dev.go b/cmd/dev.go new file mode 100644 index 0000000..5bc2728 --- /dev/null +++ b/cmd/dev.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "github.com/phlisg/frank/internal/docker" + "github.com/spf13/cobra" +) + +func init() { + devCmd.AddCommand(devRestartCmd, devStopCmd, devStartCmd) + rootCmd.AddCommand(devCmd) +} + +// devCmd with no subcommand tails the laravel.vite logs. Ctrl-C detaches; the +// container keeps running (it lives in compose.yaml, started by `frank up`). +var devCmd = &cobra.Command{ + Use: "dev", + Short: "Attach to the frontend dev server (Vite)", + Long: `Tail the laravel.vite dev-server logs. Ctrl-C detaches without stopping it. + +The dev server runs as a compose sidecar (laravel.vite), started by frank up and +stopped by frank down. Disable it per-project with dev.enabled: false in frank.yaml.`, + SilenceUsage: true, + Args: cobra.NoArgs, + ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + return docker.New(resolveDir()).Run("logs", "-f", "--no-log-prefix", "laravel.vite") + }, +} + +var devRestartCmd = &cobra.Command{ + Use: "restart", + Short: "Restart the dev server", + SilenceUsage: true, + Args: cobra.NoArgs, + ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + return docker.New(resolveDir()).Run("restart", "laravel.vite") + }, +} + +var devStopCmd = &cobra.Command{ + Use: "stop", + Short: "Stop the dev server", + SilenceUsage: true, + Args: cobra.NoArgs, + ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + return docker.New(resolveDir()).Run("stop", "laravel.vite") + }, +} + +var devStartCmd = &cobra.Command{ + Use: "start", + Short: "Start the dev server", + SilenceUsage: true, + Args: cobra.NoArgs, + ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + return docker.New(resolveDir()).Run("start", "laravel.vite") + }, +} diff --git a/cmd/eject.go b/cmd/eject.go index a21b908..db37695 100644 --- a/cmd/eject.go +++ b/cmd/eject.go @@ -40,6 +40,7 @@ func runEject(cmd *cobra.Command, args []string) error { } client := docker.New(dir) + state, _, _ := client.ContainerStatus() if state != docker.StateRunning { return fmt.Errorf("containers are not running — run frank up first") @@ -47,12 +48,15 @@ func runEject(cmd *cobra.Command, args []string) error { // Build --with list: map Frank services to Sail equivalents, dropping sqlite. var sailServices []string + for _, svc := range cfg.Services { if svc == "sqlite" { continue } + sailServices = append(sailServices, svc) } + withList := strings.Join(sailServices, ",") reqRegion := output.Region("Installing Sail") @@ -60,6 +64,7 @@ func runEject(cmd *cobra.Command, args []string) error { reqRegion.Stop(err) return fmt.Errorf("composer require laravel/sail failed: %w", err) } + reqRegion.Stop(nil) installRegion := output.Region("Configuring Sail") @@ -70,6 +75,7 @@ func runEject(cmd *cobra.Command, args []string) error { installRegion.Stop(err) return fmt.Errorf("sail:install failed: %w", err) } + installRegion.Stop(nil) // Restore phpunit.xml to Laravel defaults (sqlite/:memory:). @@ -89,6 +95,7 @@ func runEject(cmd *cobra.Command, args []string) error { } fmt.Println(" eject complete — run ./vendor/bin/sail up to start containers") + return nil } @@ -126,5 +133,6 @@ func flattenDockerfile(dir string, cfg *config.Config) error { if err := writeFile(filepath.Join(dir, ".frank", "Dockerfile"), body); err != nil { return err } + return nil } diff --git a/cmd/eject_test.go b/cmd/eject_test.go index 7a79747..af680a3 100644 --- a/cmd/eject_test.go +++ b/cmd/eject_test.go @@ -42,6 +42,7 @@ func TestFlattenDockerfile(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dir := t.TempDir() + frankDir := filepath.Join(dir, ".frank") if err := os.MkdirAll(frankDir, 0755); err != nil { t.Fatal(err) @@ -50,6 +51,7 @@ func TestFlattenDockerfile(t *testing.T) { if err := os.WriteFile(filepath.Join(frankDir, "Caddyfile"), []byte("# placeholder\n"), 0644); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(frankDir, "Dockerfile"), []byte("FROM frank/runtime:thin\n"), 0644); err != nil { t.Fatal(err) } @@ -62,14 +64,17 @@ func TestFlattenDockerfile(t *testing.T) { if err != nil { t.Fatal(err) } + got := string(out) if strings.Contains(got, "FROM frank/runtime") { t.Errorf("flattened Dockerfile still contains FROM frank/runtime") } + if !strings.Contains(got, tt.wantFrom) { t.Errorf("flattened Dockerfile missing %q\n---\n%s", tt.wantFrom, got) } + hasCaddy := strings.Contains(got, "COPY .frank/Caddyfile") if hasCaddy != tt.wantCaddyCopy { t.Errorf("Caddyfile COPY present=%v, want %v", hasCaddy, tt.wantCaddyCopy) diff --git a/cmd/exec.go b/cmd/exec.go index 6a03011..76753d8 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -36,5 +36,6 @@ func runExec(cmd *cobra.Command, args []string) error { } dir, execArgs := stripDirFlag(args) + return docker.New(dir).Exec("laravel.test", execArgs...) } diff --git a/cmd/generate.go b/cmd/generate.go index 8a5da5f..46c2d67 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -54,7 +54,9 @@ func frankConfigHash(dir string) string { if err != nil { return "" } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) } @@ -111,8 +113,10 @@ func generate(cfg *config.Config, dir, version string) error { ephemeralPorts := config.IsWorktree(dir) vitePort := 5173 + if ephemeralPorts { vitePort = config.ViteWorktreePort(projectName) + output.Detail("worktree detected — using ephemeral ports") } @@ -128,9 +132,11 @@ func generate(cfg *config.Config, dir, version string) error { if err != nil { return fmt.Errorf("cert generation: %w", err) } + switch { case result.Generated: output.Detail("generated TLS certificates") + if result.CANotTrusted { output.Warning("mkcert CA not trusted — run `mkcert -install` for browser-trusted certs") } @@ -144,11 +150,13 @@ func generate(cfg *config.Config, dir, version string) error { if err := gen.Write(cfg, projectName, mainProjectName, dir, ephemeralPorts, vitePort); err != nil { return fmt.Errorf("generate compose.yaml: %w", err) } + output.Detail("wrote .frank/compose.yaml") if err := gen.WriteEnv(cfg, projectName, dir); err != nil { return fmt.Errorf("generate .env: %w", err) } + output.Detail("wrote .env") output.Detail("wrote .env.example") @@ -158,9 +166,11 @@ func generate(cfg *config.Config, dir, version string) error { if err != nil { return fmt.Errorf("render Dockerfile: %w", err) } + if err := writeFile(filepath.Join(frankDir, "Dockerfile"), dockerfile); err != nil { return err } + output.Detail("wrote .frank/Dockerfile") // Render the shared base Dockerfile (project-invariant; built once and reused @@ -170,9 +180,11 @@ func generate(cfg *config.Config, dir, version string) error { if err != nil { return fmt.Errorf("render base.Dockerfile: %w", err) } + if err := writeFile(filepath.Join(frankDir, "base.Dockerfile"), baseDockerfile); err != nil { return err } + output.Detail("wrote .frank/base.Dockerfile") switch cfg.PHP.Runtime { @@ -181,9 +193,11 @@ func generate(cfg *config.Config, dir, version string) error { if err != nil { return fmt.Errorf("render Caddyfile: %w", err) } + if err := writeFile(filepath.Join(frankDir, "Caddyfile"), caddyfile); err != nil { return err } + output.Detail("wrote .frank/Caddyfile") case "fpm": @@ -191,18 +205,22 @@ func generate(cfg *config.Config, dir, version string) error { if err != nil { return fmt.Errorf("render nginx.conf: %w", err) } + if err := writeFile(filepath.Join(frankDir, "nginx.conf"), nginxConf); err != nil { return err } + output.Detail("wrote .frank/nginx.conf") nginxDockerfile, err := engine.RenderRuntime("fpm", "nginx.Dockerfile.tmpl", data) if err != nil { return fmt.Errorf("render nginx.Dockerfile: %w", err) } + if err := writeFile(filepath.Join(frankDir, "nginx.Dockerfile"), nginxDockerfile); err != nil { return err } + output.Detail("wrote .frank/nginx.Dockerfile") } @@ -237,6 +255,7 @@ func generate(cfg *config.Config, dir, version string) error { } var tmplPath, outFile string + switch db { case "pgsql": tmplPath = "templates/services/pgsql/create-testing-database.sql" @@ -253,9 +272,11 @@ func generate(cfg *config.Config, dir, version string) error { if err != nil { return fmt.Errorf("read init script template: %w", err) } + if err := writeFile(filepath.Join(scriptsDir, outFile), content); err != nil { return err } + output.Detail("wrote .frank/scripts/" + outFile) } @@ -280,6 +301,7 @@ func generate(cfg *config.Config, dir, version string) error { if err := os.WriteFile(filepath.Join(frankDir, "vite-server.js"), []byte(viteServer), 0644); err != nil { return fmt.Errorf("write .frank/vite-server.js: %w", err) } + output.Detail("wrote .frank/vite-server.js") // Migration: delete leftover Frank files at project root. @@ -295,6 +317,7 @@ func generate(cfg *config.Config, dir, version string) error { if err := writeMCPConfig(dir); err != nil { return fmt.Errorf("write .mcp.json: %w", err) } + output.Detail("wrote .mcp.json") return nil @@ -315,6 +338,7 @@ func writeMCPConfig(dir string) error { if !ok { servers = make(map[string]any) } + servers["frank"] = map[string]any{ "command": "frank", "args": []string{"mcp"}, @@ -325,6 +349,7 @@ func writeMCPConfig(dir string) error { if err != nil { return err } + return os.WriteFile(path, append(out, '\n'), 0644) } @@ -339,6 +364,7 @@ export default { }; ` } + return fmt.Sprintf(`// Generated by Frank — Vite dev server config import fs from 'fs'; @@ -361,6 +387,7 @@ func printViteHTTPSHint(dir string) { if viteConfigHasFrankServer(dir) { return } + output.Warning(`HTTPS enabled — add to your vite.config.js/ts: const frankServer = await import('./.frank/vite-server.js').then(m => m.default).catch(() => ({})); @@ -375,10 +402,12 @@ func viteConfigHasFrankServer(dir string) bool { if err != nil { continue } + if strings.Contains(string(data), "vite-server") { return true } } + return false } @@ -387,15 +416,19 @@ func ensureGitignoreLine(path, line string) error { if err != nil && !os.IsNotExist(err) { return err } + if strings.Contains(string(data), line) { return nil } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return err } + defer f.Close() _, err = fmt.Fprintf(f, "\n%s\n", line) + return err } @@ -420,5 +453,6 @@ func ensureCertsGitignore(dir string) { if len(content) > 0 && !strings.HasSuffix(content, "\n") { f.WriteString("\n") } + f.WriteString(entry + "\n") } diff --git a/cmd/generate_test.go b/cmd/generate_test.go index e701073..66e2452 100644 --- a/cmd/generate_test.go +++ b/cmd/generate_test.go @@ -108,6 +108,17 @@ var integrationFixtures = []integrationFixture{ }, files: []string{".frank/compose.yaml", ".env", ".env.example", ".frank/Dockerfile", ".frank/base.Dockerfile", ".frank/Caddyfile", ".frank/vite-server.js", ".mcp.json"}, }, + { + // dev.enabled: false → no laravel.vite service, Vite port left unmapped. + name: "frankenphp-pgsql-no-dev", + cfg: &config.Config{ + PHP: config.PHP{Version: "8.5", Runtime: "frankenphp"}, + Laravel: config.Laravel{Version: "13.x"}, + Services: []string{"pgsql", "mailpit"}, + Dev: config.Dev{Enabled: new(bool)}, + }, + files: []string{".frank/compose.yaml", ".env", ".env.example", ".frank/Dockerfile", ".frank/base.Dockerfile", ".frank/Caddyfile", ".frank/vite-server.js", ".mcp.json"}, + }, } func TestGenerate_Integration(t *testing.T) { @@ -131,6 +142,7 @@ func TestGenerate_Integration(t *testing.T) { t.Errorf("output file %s missing: %v", fname, err) continue } + got := string(data) goldenPath := filepath.Join(goldenDir, fname) @@ -138,10 +150,13 @@ func TestGenerate_Integration(t *testing.T) { if err := os.MkdirAll(filepath.Dir(goldenPath), 0755); err != nil { t.Fatalf("mkdir golden: %v", err) } + if err := os.WriteFile(goldenPath, []byte(got), 0644); err != nil { t.Fatalf("write golden %s: %v", goldenPath, err) } + t.Logf("updated golden: %s", goldenPath) + continue } @@ -150,6 +165,7 @@ func TestGenerate_Integration(t *testing.T) { t.Errorf("golden file %s missing — run: go test ./cmd/ -update\nerror: %v", goldenPath, err) continue } + if got != string(goldenBytes) { t.Errorf("file %s differs from golden\n--- want ---\n%s\n--- got ---\n%s", fname, string(goldenBytes), got) } @@ -171,6 +187,7 @@ func checkInvariants(t *testing.T, fx integrationFixture, dir string) { if !strings.Contains(env, "APP_KEY=\n") { t.Error(".env: APP_KEY must be present with an empty value") } + if !strings.Contains(example, "APP_KEY=\n") { t.Error(".env.example: APP_KEY must be present with an empty value") } @@ -190,6 +207,7 @@ func checkInvariants(t *testing.T, fx integrationFixture, dir string) { if !strings.Contains(env, "DB_URL=postgresql://") { t.Error(".env: DB_URL must be non-empty for pgsql") } + if !strings.Contains(example, "DB_URL=\n") { t.Error(".env.example: DB_URL must be present but empty (redacted)") } @@ -198,19 +216,23 @@ func checkInvariants(t *testing.T, fx integrationFixture, dir string) { // .env and .env.example must expose the same set of keys envKeys := extractTestKeys(env) exampleKeys := extractTestKeys(example) + envKeySet := make(map[string]bool, len(envKeys)) for _, k := range envKeys { envKeySet[k] = true } + for _, k := range exampleKeys { if !envKeySet[k] { t.Errorf(".env.example has key %q not present in .env", k) } } + exKeySet := make(map[string]bool, len(exampleKeys)) for _, k := range exampleKeys { exKeySet[k] = true } + for _, k := range envKeys { if !exKeySet[k] { t.Errorf(".env has key %q not present in .env.example", k) @@ -226,11 +248,13 @@ func checkInvariants(t *testing.T, fx integrationFixture, dir string) { func readTestFile(t *testing.T, dir, name string) string { t.Helper() + data, err := os.ReadFile(filepath.Join(dir, name)) if err != nil { t.Errorf("readTestFile: %s: %v", name, err) return "" } + return string(data) } @@ -258,6 +282,7 @@ func TestGenerate_BaseDockerfile(t *testing.T) { if !strings.Contains(base, "FROM dunglas/frankenphp") { t.Errorf("base.Dockerfile must build from upstream image, got:\n%s", base) } + if strings.Contains(base, "FROM frank/runtime") { t.Error("base.Dockerfile must not reference frank/runtime (it IS the base)") } @@ -274,6 +299,7 @@ func TestWriteMCPConfig(t *testing.T) { if err := writeMCPConfig(dir); err != nil { t.Fatalf("writeMCPConfig: %v", err) } + root := readMCPJSON(t, dir) assertFrankServer(t, root) }) @@ -288,9 +314,11 @@ func TestWriteMCPConfig(t *testing.T) { } } }`) + if err := writeMCPConfig(dir); err != nil { t.Fatalf("writeMCPConfig: %v", err) } + root := readMCPJSON(t, dir) assertFrankServer(t, root) @@ -310,9 +338,11 @@ func TestWriteMCPConfig(t *testing.T) { } } }`) + if err := writeMCPConfig(dir); err != nil { t.Fatalf("writeMCPConfig: %v", err) } + root := readMCPJSON(t, dir) assertFrankServer(t, root) }) @@ -320,9 +350,11 @@ func TestWriteMCPConfig(t *testing.T) { t.Run("malformed_json", func(t *testing.T) { dir := t.TempDir() writeTestFile(t, dir, ".mcp.json", "this is not json") + if err := writeMCPConfig(dir); err != nil { t.Fatalf("writeMCPConfig: %v", err) } + root := readMCPJSON(t, dir) assertFrankServer(t, root) }) @@ -333,9 +365,11 @@ func TestWriteMCPConfig(t *testing.T) { "mcpServers": {"existing": {"command": "x"}}, "someOtherKey": "value" }`) + if err := writeMCPConfig(dir); err != nil { t.Fatalf("writeMCPConfig: %v", err) } + root := readMCPJSON(t, dir) assertFrankServer(t, root) @@ -343,6 +377,7 @@ func TestWriteMCPConfig(t *testing.T) { if _, ok := servers["existing"]; !ok { t.Error("existing server entry was lost during merge") } + if v, ok := root["someOtherKey"]; !ok || v != "value" { t.Errorf("someOtherKey lost or changed: got %v", v) } @@ -352,31 +387,38 @@ func TestWriteMCPConfig(t *testing.T) { // readMCPJSON reads and parses .mcp.json from dir. func readMCPJSON(t *testing.T, dir string) map[string]any { t.Helper() + data, err := os.ReadFile(filepath.Join(dir, ".mcp.json")) if err != nil { t.Fatalf("read .mcp.json: %v", err) } + var root map[string]any if err := json.Unmarshal(data, &root); err != nil { t.Fatalf("parse .mcp.json: %v\ncontent: %s", err, data) } + return root } // assertFrankServer verifies the frank entry in mcpServers has the expected shape. func assertFrankServer(t *testing.T, root map[string]any) { t.Helper() + servers, ok := root["mcpServers"].(map[string]any) if !ok { t.Fatal("mcpServers key missing or not an object") } + frank, ok := servers["frank"].(map[string]any) if !ok { t.Fatal("mcpServers.frank missing or not an object") } + if frank["command"] != "frank" { t.Errorf("frank command: got %v, want frank", frank["command"]) } + args, ok := frank["args"].([]any) if !ok || len(args) != 1 || args[0] != "mcp" { t.Errorf("frank args: got %v, want [mcp]", frank["args"]) @@ -386,6 +428,7 @@ func assertFrankServer(t *testing.T, root map[string]any) { // writeTestFile is a test helper that writes content to dir/name. func writeTestFile(t *testing.T, dir, name, content string) { t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { t.Fatalf("writeFile %s: %v", name, err) } @@ -398,6 +441,7 @@ func TestGenerate_WorktreeIntegration(t *testing.T) { cleanEnv := os.Environ() filtered := cleanEnv[:0] + for _, e := range cleanEnv { if !strings.HasPrefix(e, "GIT_DIR=") && !strings.HasPrefix(e, "GIT_WORK_TREE=") && !strings.HasPrefix(e, "GIT_INDEX_FILE=") { filtered = append(filtered, e) @@ -406,9 +450,11 @@ func TestGenerate_WorktreeIntegration(t *testing.T) { git := func(dir string, args ...string) { t.Helper() + c := exec.Command("git", args...) c.Dir = dir c.Env = filtered + if out, err := c.CombinedOutput(); err != nil { t.Fatalf("git %v: %s (%v)", args, out, err) } @@ -447,9 +493,11 @@ func TestGenerate_WorktreeIntegration(t *testing.T) { if strings.Contains(wtCompose, "5432:5432") { t.Error("worktree compose should not have host-bound pgsql port") } + if !strings.Contains(wtCompose, `"5432"`) { t.Error("worktree compose should have container-only pgsql port") } + if strings.Contains(wtCompose, "1025:1025") { t.Error("worktree compose should not have host-bound mailpit port") } @@ -459,6 +507,7 @@ func TestGenerate_WorktreeIntegration(t *testing.T) { if !strings.Contains(wtCompose, fmt.Sprintf("%d:5173", expectedVitePort)) { t.Errorf("worktree compose should map vite to port %d", expectedVitePort) } + if !strings.Contains(wtVite, fmt.Sprintf("localhost:%d", expectedVitePort)) { t.Errorf("worktree vite-server.js should reference port %d", expectedVitePort) } @@ -474,9 +523,11 @@ func TestGenerate_WorktreeIntegration(t *testing.T) { if !strings.Contains(mainCompose, "5432:5432") { t.Error("main compose should have host-bound pgsql port") } + if !strings.Contains(mainCompose, "5173:5173") { t.Error("main compose should have standard vite port") } + if !strings.Contains(mainVite, "localhost:5173") { t.Error("main vite-server.js should reference port 5173") } @@ -487,14 +538,17 @@ func TestGenerate_WorktreeIntegration(t *testing.T) { // the two packages cannot share test helpers in Go. func extractTestKeys(env string) []string { var keys []string + for line := range strings.SplitSeq(env, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } + if idx := strings.IndexByte(line, '='); idx > 0 { keys = append(keys, line[:idx]) } } + return keys } diff --git a/cmd/import.go b/cmd/import.go index a7762a6..f1c2fdd 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -84,6 +84,7 @@ func runImport(cmd *cobra.Command, args []string) error { if len(services) == 0 { fmt.Println("Warning: no known services detected — using defaults") + services = []string{"pgsql", "mailpit"} } @@ -95,13 +96,16 @@ func runImport(cmd *cobra.Command, args []string) error { if err != nil { return err } + if err := writeFile(filepath.Join(dir, config.ConfigFileName), yamlBytes); err != nil { return err } + fmt.Printf(" imported PHP %s, services: %s\n", phpVersion, strings.Join(services, ", ")) fmt.Println(" wrote frank.yaml") fmt.Println("\nGenerating Docker files...") + return generate(cfg, dir, rootCmd.Version) } @@ -122,17 +126,21 @@ func parseSailCompose(compose sailComposeFile) (phpVersion string, services []st // Deduplicate (mailhog + mailpit could both appear). services = dedup(services) + return } func dedup(strs []string) []string { seen := map[string]bool{} out := strs[:0] + for _, str := range strs { if !seen[str] { seen[str] = true + out = append(out, str) } } + return out } diff --git a/cmd/install.go b/cmd/install.go index ccc7366..5824de4 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -31,13 +31,17 @@ func composerCacheDir() (string, error) { if err != nil { return "", err } + base = filepath.Join(home, ".cache") } + dir = filepath.Join(base, "frank", "composer") } + if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } + return dir, nil } @@ -51,6 +55,7 @@ func composerCacheArgs() []string { output.Warning(fmt.Sprintf("composer cache disabled: %v", err)) return nil } + return []string{ "-v", dir + ":/tmp/composer-cache", "-e", "COMPOSER_CACHE_DIR=/tmp/composer-cache", @@ -99,6 +104,7 @@ func installLaravel(dir string, cfg *config.Config, regenerate bool) error { if err != nil { return fmt.Errorf("read laravel-init.sh: %w", err) } + script := string(scriptBytes) laravelVersion := cfg.Laravel.Version @@ -133,6 +139,7 @@ func installLaravel(dir string, cfg *config.Config, regenerate bool) error { region.Stop(err) return fmt.Errorf("laravel-init container: %w", err) } + region.Stop(nil) if err := patchComposerPHPVersion(dir, cfg.PHP.Version); err != nil { @@ -141,6 +148,7 @@ func installLaravel(dir string, cfg *config.Config, regenerate bool) error { if regenerate { output.Detail("regenerating Docker files") + if err := generate(cfg, dir, rootCmd.Version); err != nil { return err } @@ -189,6 +197,7 @@ func composerRequireDev(dir string, packages []string) error { c.Stderr = region err = c.Run() region.Stop(err) + return err } @@ -240,7 +249,9 @@ php artisan sail:install --with="$1" --php="$2" region.Stop(err) return fmt.Errorf("sail-install container: %w", err) } + region.Stop(nil) + return nil } @@ -260,16 +271,19 @@ var composerPHPRe = regexp.MustCompile(`("php":\s*")[^"]*(")`) func patchComposerPHPVersion(dir, phpVersion string) error { path := filepath.Join(dir, "composer.json") + data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil } + return err } original := string(data) patched := composerPHPRe.ReplaceAllString(original, "${1}^"+phpVersion+"${2}") + if patched == original { // Nothing changed — either php constraint already matches or key not found. return nil @@ -278,7 +292,9 @@ func patchComposerPHPVersion(dir, phpVersion string) error { if err := os.WriteFile(path, []byte(patched), 0644); err != nil { return err } + output.Detail(fmt.Sprintf("patched composer.json (php constraint → ^%s)", phpVersion)) + return nil } @@ -290,15 +306,19 @@ func patchComposerPHPVersion(dir, phpVersion string) error { // already reference vite-server. Covers Docker host binding, CORS, and HTTPS. func patchViteConfig(dir string) error { var name string + var data []byte + for _, n := range []string{"vite.config.js", "vite.config.ts"} { d, err := os.ReadFile(filepath.Join(dir, n)) if err == nil { name = n data = d + break } } + if name == "" { return nil } @@ -308,12 +328,15 @@ func patchViteConfig(dir string) error { // Migrate old static import → dynamic import with fallback. oldImport := "import frankServer from './.frank/vite-server.js';" newImport := "const frankServer = await import('./.frank/vite-server.js').then(m => m.default).catch(() => ({}));" + if strings.Contains(content, oldImport) { content = strings.Replace(content, oldImport, newImport, 1) if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { return err } + output.Detail("migrated vite.config (dynamic import)") + return nil } @@ -325,35 +348,43 @@ func patchViteConfig(dir string) error { // Insert import after the last import line. lastImport := -1 + for i, line := range lines { if strings.HasPrefix(strings.TrimSpace(line), "import ") { lastImport = i } } + if lastImport == -1 { return fmt.Errorf("no import lines found") } + importLine := "const frankServer = await import('./.frank/vite-server.js').then(m => m.default).catch(() => ({}));" lines = append(lines[:lastImport+1], append([]string{importLine}, lines[lastImport+1:]...)...) // Insert server: frankServer before closing }); closingIdx := -1 + for i := len(lines) - 1; i >= 0; i-- { if strings.TrimSpace(lines[i]) == "});" { closingIdx = i break } } + if closingIdx == -1 { return fmt.Errorf("could not find closing });") } + serverLine := " server: frankServer," lines = append(lines[:closingIdx], append([]string{serverLine}, lines[closingIdx:]...)...) if err := os.WriteFile(filepath.Join(dir, name), []byte(strings.Join(lines, "\n")), 0644); err != nil { return err } + output.Detail("patched vite.config (Frank server)") + return nil } @@ -372,6 +403,8 @@ func copyPsysh(dir string) error { if err := os.WriteFile(dst, content, 0644); err != nil { return err } + output.Detail("wrote .psysh.php") + return nil } diff --git a/cmd/install_test.go b/cmd/install_test.go index 85ca685..140499a 100644 --- a/cmd/install_test.go +++ b/cmd/install_test.go @@ -250,6 +250,7 @@ func TestPatchComposerPHPVersion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dir := t.TempDir() + path := filepath.Join(dir, "composer.json") if err := os.WriteFile(path, []byte(tt.input), 0644); err != nil { t.Fatalf("write composer.json: %v", err) @@ -263,6 +264,7 @@ func TestPatchComposerPHPVersion(t *testing.T) { if err != nil { t.Fatalf("read composer.json: %v", err) } + if string(got) != tt.want { t.Errorf("result mismatch:\n got: %s\nwant: %s", got, tt.want) } @@ -281,13 +283,16 @@ func TestComposerCacheDir(t *testing.T) { t.Run("honors COMPOSER_CACHE_DIR", func(t *testing.T) { want := filepath.Join(t.TempDir(), "explicit") t.Setenv("COMPOSER_CACHE_DIR", want) + got, err := composerCacheDir() if err != nil { t.Fatal(err) } + if got != want { t.Errorf("got %q, want %q", got, want) } + if _, err := os.Stat(got); err != nil { t.Errorf("dir not created: %v", err) } @@ -297,10 +302,12 @@ func TestComposerCacheDir(t *testing.T) { base := t.TempDir() t.Setenv("COMPOSER_CACHE_DIR", "") t.Setenv("XDG_CACHE_HOME", base) + got, err := composerCacheDir() if err != nil { t.Fatal(err) } + if want := filepath.Join(base, "frank", "composer"); got != want { t.Errorf("got %q, want %q", got, want) } @@ -311,14 +318,17 @@ func TestComposerCacheArgs(t *testing.T) { base := t.TempDir() t.Setenv("COMPOSER_CACHE_DIR", "") t.Setenv("XDG_CACHE_HOME", base) + args := composerCacheArgs() want := []string{ "-v", filepath.Join(base, "frank", "composer") + ":/tmp/composer-cache", "-e", "COMPOSER_CACHE_DIR=/tmp/composer-cache", } + if len(args) != len(want) { t.Fatalf("got %v, want %v", args, want) } + for i := range want { if args[i] != want[i] { t.Errorf("arg %d: got %q, want %q", i, args[i], want[i]) diff --git a/cmd/log.go b/cmd/log.go index ada8f72..bfd01aa 100644 --- a/cmd/log.go +++ b/cmd/log.go @@ -62,12 +62,14 @@ func runLog(cmd *cobra.Command, args []string) error { if !logNoFollow { tailArgs = append(tailArgs, "-f") } + tailArgs = append(tailArgs, "storage/logs/laravel.log") err := client.Exec("laravel.test", tailArgs...) if err != nil && isInterrupt(err) { return nil } + return err } @@ -80,6 +82,7 @@ func runLogReset(cmd *cobra.Command, args []string) error { } output.Group("Reset laravel.log", "") + return nil } diff --git a/cmd/new.go b/cmd/new.go index 0ad6162..5cec163 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -94,6 +94,7 @@ func runNew(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("get working directory: %w", err) } + target = filepath.Join(cwd, target) } @@ -101,10 +102,12 @@ func runNew(cmd *cobra.Command, args []string) error { if !info.IsDir() { return fmt.Errorf("%q exists and is not a directory", projectName) } + entries, err := os.ReadDir(target) if err != nil { return fmt.Errorf("read directory: %w", err) } + if len(entries) > 0 { return fmt.Errorf("directory %q already exists and is not empty", projectName) } @@ -134,6 +137,7 @@ func runNew(cmd *cobra.Command, args []string) error { } else { initErr = runFrankInit(cmd, cfg, dir, existingCompose) } + if initErr != nil { output.Warning(fmt.Sprintf("failed: %v — remove the directory and start fresh", initErr)) return initErr @@ -146,6 +150,7 @@ func runNew(cmd *cobra.Command, args []string) error { } printNewNextSteps(projectName, dir, !flagNoUp, cfg) + return nil } @@ -162,9 +167,11 @@ func runNew(cmd *cobra.Command, args []string) error { } else { cfg.Laravel.Version = "13.*" } + if flagRuntime != "" { cfg.PHP.Runtime = flagRuntime } + if flagPM != "" { switch flagPM { case "npm", "pnpm", "bun": @@ -197,18 +204,22 @@ func runNew(cmd *cobra.Command, args []string) error { // Workers (non-interactive defaults: schedule on, 1 queue worker) scheduleWorker := flagSchedule queueCount := flagQueueCount + if !cmd.Flags().Changed("schedule") { scheduleWorker = true } + if !cmd.Flags().Changed("queue-count") { queueCount = 1 } + applyWorkersFromInit(cfg, scheduleWorker, queueCount) // Tools if !flagNoTools { allTools := tool.AllNames() cfg.Tools = make([]string, 0, len(allTools)) + for _, t := range allTools { switch t { case "pint": @@ -237,18 +248,22 @@ func runNew(cmd *cobra.Command, args []string) error { if sailMode { cfg.PHP.Runtime = "fpm" existingCompose := detectExistingCompose(dir) + if err := writeConfigAndGenerate(cfg, dir, existingCompose); err != nil { output.Warning(fmt.Sprintf("failed: %v — remove the directory and start fresh", err)) return err } var sailServices []string + for _, svc := range cfg.Services { if svc == "sqlite" { continue } + sailServices = append(sailServices, svc) } + if err := runSailInstall(dir, sailServices, cfg.PHP.Version); err != nil { output.Warning(fmt.Sprintf("sail install failed: %v — remove the directory and start fresh", err)) return fmt.Errorf("sail install: %w", err) @@ -258,6 +273,7 @@ func runNew(cmd *cobra.Command, args []string) error { fmt.Sprintf("cd %s", projectName), "vendor/bin/sail up", }) + return nil } @@ -277,6 +293,7 @@ func runNew(cmd *cobra.Command, args []string) error { // 9. NextSteps printNewNextSteps(projectName, dir, !flagNoUp, cfg) + return nil } @@ -289,6 +306,7 @@ func runNewUp(dir string, cfg *config.Config) error { // npm install inside the container client := docker.New(dir) + if _, err := os.Stat(filepath.Join(dir, "package.json")); err == nil { pm := "npm" if cfg != nil && cfg.Node.PackageManager != "" { @@ -304,6 +322,7 @@ func runNewUp(dir string, cfg *config.Config) error { region := output.Region(fmt.Sprintf("Installing %s dependencies", pm)) npmErr := client.ExecStream(region, "laravel.test", pm, "install") region.Stop(npmErr) + if npmErr != nil { output.Warning(fmt.Sprintf("%s install failed: %v", pm, npmErr)) } @@ -317,20 +336,25 @@ func printNewNextSteps(projectName, dir string, containersStarted bool, cfg *con if cfg.Server.IsHTTPS() { printViteHTTPSHint(dir) } + steps := []string{fmt.Sprintf("cd %s", projectName)} + if containersStarted { scheme := "http" if cfg.Server.IsHTTPS() { scheme = "https" } + port := "" if cfg.Server.Port != 0 { port = fmt.Sprintf(":%d", cfg.Server.Port) } + steps = append(steps, fmt.Sprintf("%s://localhost%s", scheme, port)) } else { steps = append(steps, "frank up -d") } + steps = append(steps, "") steps = append(steps, "Tip: run `frank activate` to load shell aliases (up, down, artisan, etc.)") output.NextSteps(steps) @@ -346,6 +370,7 @@ func runFrankInit(cmd *cobra.Command, cfg *config.Config, dir, existingCompose s if !cmd.Flags().Changed("schedule") { scheduleWorker = true } + if !cmd.Flags().Changed("queue-count") { queueCount = 1 } @@ -474,8 +499,10 @@ func runFrankInit(cmd *cobra.Command, cfg *config.Config, dir, existingCompose s if !flagNoTools { allTools := tool.AllNames() + if flagPHP != "" || sailMode { cfg.Tools = make([]string, 0) + for _, t := range allTools { switch t { case "pint": @@ -501,10 +528,12 @@ func runFrankInit(cmd *cobra.Command, cfg *config.Config, dir, existingCompose s } else { selectedTools := make([]string, len(allTools)) copy(selectedTools, allTools) + options := make([]huh.Option[string], len(allTools)) for i, t := range allTools { options[i] = huh.NewOption(t, t) } + err := huh.NewForm( huh.NewGroup( huh.NewMultiSelect[string](). @@ -516,6 +545,7 @@ func runFrankInit(cmd *cobra.Command, cfg *config.Config, dir, existingCompose s if err != nil { return err } + cfg.Tools = selectedTools } } @@ -590,6 +620,7 @@ func runSailInit(cfg *config.Config, dir, existingCompose string) error { if !flagNoTools { allTools := tool.AllNames() cfg.Tools = make([]string, 0) + for _, t := range allTools { switch t { case "pint": @@ -619,17 +650,21 @@ func runSailInit(cfg *config.Config, dir, existingCompose string) error { } var sailServices []string + for _, svc := range cfg.Services { if svc == "sqlite" { continue } + sailServices = append(sailServices, svc) } + if err := runSailInstall(dir, sailServices, cfg.PHP.Version); err != nil { return fmt.Errorf("sail install: %w", err) } output.NextSteps([]string{"vendor/bin/sail up"}) + return nil } @@ -640,6 +675,7 @@ func normalizeLaravelVersion(v string) string { v = strings.TrimSpace(v) v = strings.TrimSuffix(v, ".*") v = strings.TrimSuffix(v, ".x") + return v + ".*" } @@ -647,11 +683,13 @@ func normalizeLaravelVersion(v string) string { func parseServices(s string) []string { parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) + for _, p := range parts { if t := strings.TrimSpace(p); t != "" { out = append(out, t) } } + return out } @@ -662,6 +700,7 @@ func applyWorkersFromInit(cfg *config.Config, schedule bool, queueCount int) { if schedule { cfg.Workers.Schedule = true } + if queueCount > 0 { cfg.Workers.Queue = []config.QueuePool{{ Name: "default", @@ -680,9 +719,11 @@ func writeConfigAndGenerate(cfg *config.Config, dir, existingCompose string) err if err != nil { return err } + if err := writeFile(filepath.Join(dir, config.ConfigFileName), yamlBytes); err != nil { return err } + output.Detail("wrote frank.yaml") output.Group("Wrote frank.yaml", "") @@ -691,6 +732,7 @@ func writeConfigAndGenerate(cfg *config.Config, dir, existingCompose string) err stopGen(err) return err } + stopGen(nil) if err := installLaravel(dir, cfg, true); err != nil { @@ -704,6 +746,7 @@ func writeConfigAndGenerate(cfg *config.Config, dir, existingCompose string) err if len(cfg.Tools) > 0 { phpTools := tool.PHPTools(cfg.Tools) + packages := tool.ComposerDevPackages(dir, phpTools) if len(packages) > 0 { if err := composerRequireDev(dir, packages); err != nil { @@ -713,10 +756,12 @@ func writeConfigAndGenerate(cfg *config.Config, dir, existingCompose string) err stopTools := output.Spin("Installing dev tools") res, err := tool.Install(cfg.Tools, dir) + if err != nil { stopTools(err) return err } + stopTools(nil) output.Detail(fmt.Sprintf("%d created, %d skipped", len(res.Created), len(res.Skipped))) } @@ -748,21 +793,26 @@ func marshalConfig(cfg *config.Config) (string, error) { Services: cfg.Services, Config: cfg.Config, } + if cfg.Workers.Schedule || len(cfg.Workers.Queue) > 0 { w := cfg.Workers out.Workers = &w } + if cfg.Server.HTTPS != nil || cfg.Server.Port != 0 { s := cfg.Server out.Server = &s } + if cfg.Node.PackageManager != "" && cfg.Node.PackageManager != "npm" { n := cfg.Node out.Node = &n } + if len(cfg.Tools) > 0 { out.Tools = cfg.Tools } + if len(cfg.Aliases) > 0 { out.Aliases = cfg.Aliases } @@ -771,6 +821,7 @@ func marshalConfig(cfg *config.Config) (string, error) { if err != nil { return "", fmt.Errorf("marshal frank.yaml: %w", err) } + header := "# Generated by Frank — edit this file to customise your environment\n\n" body := header + strings.TrimSpace(string(b)) + "\n" @@ -778,6 +829,7 @@ func marshalConfig(cfg *config.Config) (string, error) { if comment != "" { body += "\n" + comment } + return body, nil } @@ -797,6 +849,7 @@ var serviceDefaults = map[string]config.ServiceConfig{ // available configuration options that are not already set in the active config. func buildConfigComment(cfg *config.Config) string { var sb strings.Builder + sb.WriteString("# All available configuration options with defaults shown.\n") sb.WriteString("# Uncomment and edit as needed.\n") sb.WriteString("#\n") @@ -811,11 +864,13 @@ func buildConfigComment(cfg *config.Config) string { sb.WriteString("#\n") sb.WriteString("# node:\n") sb.WriteString("# packageManager: npm # npm, pnpm, bun\n") + hasExtras = true } // config — show defaults for currently selected services (skip sqlite, no config) configServices := make([]string, 0) + for _, svc := range cfg.Services { if svc == "sqlite" { continue @@ -824,19 +879,23 @@ func buildConfigComment(cfg *config.Config) string { if _, already := cfg.Config[svc]; already { continue } + if _, ok := serviceDefaults[svc]; ok { configServices = append(configServices, svc) } } + if len(configServices) > 0 { sb.WriteString("#\n") sb.WriteString("# config:\n") + for _, svc := range configServices { d := serviceDefaults[svc] sb.WriteString(fmt.Sprintf("# %s:\n", svc)) sb.WriteString(fmt.Sprintf("# port: %d\n", d.Port)) sb.WriteString(fmt.Sprintf("# version: \"%s\"\n", d.Version)) } + hasExtras = true } @@ -848,6 +907,7 @@ func buildConfigComment(cfg *config.Config) string { sb.WriteString("# queue:\n") sb.WriteString("# - queues: [default]\n") sb.WriteString("# count: 1\n") + hasExtras = true } @@ -857,6 +917,7 @@ func buildConfigComment(cfg *config.Config) string { sb.WriteString("# tools:\n") sb.WriteString("# - pint\n") sb.WriteString("# - phpstan\n") + hasExtras = true } @@ -865,10 +926,12 @@ func buildConfigComment(cfg *config.Config) string { sb.WriteString("#\n") sb.WriteString("# aliases:\n") sb.WriteString("# myalias: \"php artisan my:command\"\n") + hasExtras = true } _ = hasExtras + return sb.String() } @@ -879,5 +942,6 @@ func detectExistingCompose(dir string) string { return name } } + return "" } diff --git a/cmd/new_test.go b/cmd/new_test.go index 1b41fb0..97db2fb 100644 --- a/cmd/new_test.go +++ b/cmd/new_test.go @@ -11,9 +11,11 @@ import ( func TestApplyWorkersFromInitNone(t *testing.T) { cfg := config.New() applyWorkersFromInit(cfg, false, 0) + if cfg.Workers.Schedule { t.Error("Schedule should be false") } + if len(cfg.Workers.Queue) != 0 { t.Errorf("Queue len = %d, want 0", len(cfg.Workers.Queue)) } @@ -22,9 +24,11 @@ func TestApplyWorkersFromInitNone(t *testing.T) { func TestApplyWorkersFromInitScheduleOnly(t *testing.T) { cfg := config.New() applyWorkersFromInit(cfg, true, 0) + if !cfg.Workers.Schedule { t.Error("Schedule should be true") } + if len(cfg.Workers.Queue) != 0 { t.Errorf("Queue len = %d, want 0", len(cfg.Workers.Queue)) } @@ -33,16 +37,20 @@ func TestApplyWorkersFromInitScheduleOnly(t *testing.T) { func TestApplyWorkersFromInitQueueOnly(t *testing.T) { cfg := config.New() applyWorkersFromInit(cfg, false, 3) + if cfg.Workers.Schedule { t.Error("Schedule should be false") } + if len(cfg.Workers.Queue) != 1 { t.Fatalf("Queue len = %d, want 1", len(cfg.Workers.Queue)) } + p := cfg.Workers.Queue[0] if p.Name != "default" || p.Count != 3 { t.Errorf("pool = %+v, want name=default count=3", p) } + if len(p.Queues) != 1 || p.Queues[0] != "default" { t.Errorf("Queues = %v, want [default]", p.Queues) } @@ -51,9 +59,11 @@ func TestApplyWorkersFromInitQueueOnly(t *testing.T) { func TestApplyWorkersFromInitBoth(t *testing.T) { cfg := config.New() applyWorkersFromInit(cfg, true, 2) + if !cfg.Workers.Schedule { t.Error("Schedule should be true") } + if len(cfg.Workers.Queue) != 1 || cfg.Workers.Queue[0].Count != 2 { t.Errorf("Queue = %+v", cfg.Workers.Queue) } @@ -63,11 +73,13 @@ func TestApplyWorkersFromInitBoth(t *testing.T) { // so tests can check the active YAML without matching the reference comment block. func stripComments(s string) string { var lines []string + for _, line := range strings.Split(s, "\n") { if !strings.HasPrefix(line, "#") { lines = append(lines, line) } } + return strings.Join(lines, "\n") } @@ -75,10 +87,12 @@ func TestMarshalConfigOmitsEmptyWorkers(t *testing.T) { cfg := config.New() // Explicitly clear workers to test omission behaviour. cfg.Workers = config.Workers{} + out, err := marshalConfig(cfg) if err != nil { t.Fatalf("marshalConfig: %v", err) } + active := stripComments(out) if strings.Contains(active, "workers:") { t.Errorf("expected no workers key for empty workers, got:\n%s", out) @@ -87,13 +101,16 @@ func TestMarshalConfigOmitsEmptyWorkers(t *testing.T) { func TestMarshalConfigEmitsDefaultWorkers(t *testing.T) { cfg := config.New() + out, err := marshalConfig(cfg) if err != nil { t.Fatalf("marshalConfig: %v", err) } + if !strings.Contains(out, "workers:") { t.Errorf("expected workers key for default config, got:\n%s", out) } + if !strings.Contains(out, "schedule: true") { t.Errorf("expected schedule: true in default config, got:\n%s", out) } @@ -101,10 +118,12 @@ func TestMarshalConfigEmitsDefaultWorkers(t *testing.T) { func TestMarshalConfigOmitsDefaultNode(t *testing.T) { cfg := config.New() // Node.PackageManager = "npm" (default) + out, err := marshalConfig(cfg) if err != nil { t.Fatalf("marshalConfig: %v", err) } + active := stripComments(out) if strings.Contains(active, "node:") { t.Errorf("expected no node key for default npm, got:\n%s", out) @@ -114,13 +133,16 @@ func TestMarshalConfigOmitsDefaultNode(t *testing.T) { func TestMarshalConfigEmitsNonDefaultNode(t *testing.T) { cfg := config.New() cfg.Node.PackageManager = "pnpm" + out, err := marshalConfig(cfg) if err != nil { t.Fatalf("marshalConfig: %v", err) } + if !strings.Contains(out, "node:") { t.Errorf("expected node key for pnpm, got:\n%s", out) } + if !strings.Contains(out, "packageManager: pnpm") { t.Errorf("expected packageManager: pnpm, got:\n%s", out) } @@ -145,6 +167,7 @@ func TestMarshalConfigRoundtripPreservesAllFields(t *testing.T) { // Parse back and verify nothing was dropped. var roundtripped config.Config + clean := stripComments(out) if err := yaml.Unmarshal([]byte(clean), &roundtripped); err != nil { t.Fatalf("unmarshal roundtripped yaml: %v", err) @@ -154,6 +177,7 @@ func TestMarshalConfigRoundtripPreservesAllFields(t *testing.T) { if len(roundtripped.Aliases) != 2 { t.Errorf("aliases count = %d, want 2", len(roundtripped.Aliases)) } + if a, ok := roundtripped.Aliases["migrate"]; !ok || a.Cmd != "php artisan migrate" { t.Errorf("alias migrate = %+v", roundtripped.Aliases["migrate"]) } @@ -162,6 +186,7 @@ func TestMarshalConfigRoundtripPreservesAllFields(t *testing.T) { if roundtripped.Server.HTTPS == nil || *roundtripped.Server.HTTPS != false { t.Errorf("server.https = %v, want false", roundtripped.Server.HTTPS) } + if roundtripped.Server.Port != 8443 { t.Errorf("server.port = %d, want 8443", roundtripped.Server.Port) } @@ -175,6 +200,7 @@ func TestMarshalConfigRoundtripPreservesAllFields(t *testing.T) { if !roundtripped.Workers.Schedule { t.Error("workers.schedule should be true") } + if len(roundtripped.Workers.Queue) != 1 || roundtripped.Workers.Queue[0].Count != 3 { t.Errorf("workers.queue = %+v", roundtripped.Workers.Queue) } @@ -188,16 +214,20 @@ func TestMarshalConfigRoundtripPreservesAllFields(t *testing.T) { func TestMarshalConfigEmitsWorkers(t *testing.T) { cfg := config.New() applyWorkersFromInit(cfg, true, 2) + out, err := marshalConfig(cfg) if err != nil { t.Fatalf("marshalConfig: %v", err) } + if !strings.Contains(out, "workers:") { t.Errorf("expected workers key, got:\n%s", out) } + if !strings.Contains(out, "schedule: true") { t.Errorf("expected schedule: true, got:\n%s", out) } + if !strings.Contains(out, "count: 2") { t.Errorf("expected count: 2, got:\n%s", out) } diff --git a/cmd/passthrough.go b/cmd/passthrough.go index c52ddeb..527b801 100644 --- a/cmd/passthrough.go +++ b/cmd/passthrough.go @@ -13,11 +13,13 @@ func splitPassthrough(cmd *cobra.Command, args []string) []string { return args[dash:] } } + for i, a := range args { if a == "--" { return args[i+1:] } } + return nil } @@ -28,13 +30,17 @@ func splitPassthrough(cmd *cobra.Command, args []string) []string { // not run. func stripDirFlag(args []string) (dir string, rest []string) { dir = resolveDir() + for i := 0; i < len(args); i++ { if args[i] == "--dir" && i+1 < len(args) { dir = args[i+1] i++ + continue } + rest = append(rest, args[i]) } + return dir, rest } diff --git a/cmd/passthrough_test.go b/cmd/passthrough_test.go index e9043e2..36adebc 100644 --- a/cmd/passthrough_test.go +++ b/cmd/passthrough_test.go @@ -12,6 +12,7 @@ func TestSplitPassthrough_NoSeparator(t *testing.T) { func TestSplitPassthrough_TokenPresent(t *testing.T) { got := splitPassthrough(nil, []string{"a", "--", "b", "c"}) want := []string{"b", "c"} + if !equalSlice(got, want) { t.Errorf("got %v, want %v", got, want) } @@ -20,6 +21,7 @@ func TestSplitPassthrough_TokenPresent(t *testing.T) { func TestSplitPassthrough_LeadingSeparator(t *testing.T) { got := splitPassthrough(nil, []string{"--", "--force-recreate"}) want := []string{"--force-recreate"} + if !equalSlice(got, want) { t.Errorf("got %v, want %v", got, want) } @@ -35,6 +37,7 @@ func TestSplitPassthrough_TrailingSeparator(t *testing.T) { func TestStripDirFlag_NoDir(t *testing.T) { _, rest := stripDirFlag([]string{"up", "-d"}) want := []string{"up", "-d"} + if !equalSlice(rest, want) { t.Errorf("rest = %v, want %v", rest, want) } @@ -45,6 +48,7 @@ func TestStripDirFlag_WithDir(t *testing.T) { if dir != "/tmp/foo" { t.Errorf("dir = %q, want /tmp/foo", dir) } + want := []string{"build", "--no-cache"} if !equalSlice(rest, want) { t.Errorf("rest = %v, want %v", rest, want) @@ -56,6 +60,7 @@ func TestStripDirFlag_DirInMiddle(t *testing.T) { if dir != "/x" { t.Errorf("dir = %q, want /x", dir) } + want := []string{"build", "--no-cache"} if !equalSlice(rest, want) { t.Errorf("rest = %v, want %v", rest, want) @@ -66,6 +71,7 @@ func TestStripDirFlag_DirWithoutValue(t *testing.T) { // Trailing --dir with no value: leave it in rest rather than panic. _, rest := stripDirFlag([]string{"build", "--dir"}) want := []string{"build", "--dir"} + if !equalSlice(rest, want) { t.Errorf("rest = %v, want %v", rest, want) } diff --git a/cmd/remove.go b/cmd/remove.go index 4c5d833..4d76538 100644 --- a/cmd/remove.go +++ b/cmd/remove.go @@ -39,11 +39,13 @@ func runRemove(cmd *cobra.Command, args []string) error { } filtered := cfg.Services[:0] + for _, svc := range cfg.Services { if svc != service { filtered = append(filtered, svc) } } + cfg.Services = filtered // Drop any per-service config entry too. @@ -52,8 +54,10 @@ func runRemove(cmd *cobra.Command, args []string) error { if err := saveConfig(cfg, dir); err != nil { return err } + fmt.Printf(" removed %s\n", service) fmt.Println("\nRegenerating Docker files...") + return generate(cfg, dir, rootCmd.Version) } diff --git a/cmd/root.go b/cmd/root.go index 1ec37ee..d334c55 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -55,6 +55,7 @@ func Execute(fsys fs.FS, version string) { default: output.SetLevel(output.Normal) } + name := cmd.Name() if name == "up" || name == "setup" || name == "frank" { if status, err := selfupdate.Check(rootCmd.Version); err == nil && status.Available { @@ -92,6 +93,7 @@ func runRoot(cmd *cobra.Command, args []string) error { fmt.Printf(" Run %sfrank new%s or %sfrank setup%s to get started.\n", ansiBold, ansiReset, ansiBold, ansiReset) fmt.Println() printCommands(cmd) + return nil } @@ -112,17 +114,21 @@ func runRoot(cmd *cobra.Command, args []string) error { // Workers summary if cfg.Workers.Schedule || len(cfg.Workers.Queue) > 0 { var parts []string + if cfg.Workers.Schedule { dot := colorDot(state) parts = append(parts, dot+" scheduler") } + if len(cfg.Workers.Queue) > 0 { queueTotal := 0 for _, p := range cfg.Workers.Queue { queueTotal += p.Count } + parts = append(parts, fmt.Sprintf("%d× queue", queueTotal)) } + printRow("Workers", strings.Join(parts, " ")) } @@ -138,6 +144,7 @@ func runRoot(cmd *cobra.Command, args []string) error { // Next-step hints fmt.Println() + switch state { case docker.StateRunning, docker.StatePartial: printHint("frank compose ps", "view running services") @@ -145,6 +152,7 @@ func runRoot(cmd *cobra.Command, args []string) error { default: printHint("frank up", "start containers") } + fmt.Println() return nil @@ -155,6 +163,7 @@ func colorDot(state docker.ContainerState) string { if state == docker.StateRunning { return ansiGreen + ansiBold + "●" + ansiReset } + return ansiRed + ansiBold + "●" + ansiReset } @@ -171,35 +180,44 @@ func printHint(command, desc string) { func printCommands(cmd *cobra.Command) { mainNames := []string{"new", "up", "down", "install", "setup"} mainSet := make(map[string]bool, len(mainNames)) + for _, n := range mainNames { mainSet[n] = true } subs := make(map[string]*cobra.Command) + var otherNames []string + for _, sub := range cmd.Commands() { if sub.Hidden { continue } + subs[sub.Name()] = sub + if !mainSet[sub.Name()] { otherNames = append(otherNames, sub.Name()) } } fmt.Printf(" %sMain Commands:%s\n", ansiDim, ansiReset) + for _, name := range mainNames { if sub, ok := subs[name]; ok { fmt.Printf(" %-14s%s%s%s\n", name, ansiDim, sub.Short, ansiReset) } } + fmt.Println() fmt.Printf(" %sOther Commands:%s\n", ansiDim, ansiReset) + for _, name := range otherNames { sub := subs[name] fmt.Printf(" %-14s%s%s%s\n", name, ansiDim, sub.Short, ansiReset) } + fmt.Println() } @@ -211,6 +229,7 @@ func openSessionAppend(dir string) func() { if err := output.OpenSessionLog(dir, rootCmd.Version, false); err != nil { output.Warning(fmt.Sprintf("could not open debug.log: %v", err)) } + return output.CloseSessionLog } @@ -219,10 +238,12 @@ func resolveDir() string { if Dir != "" { return Dir } + dir, err := os.Getwd() if err != nil { return "." } + return dir } @@ -231,5 +252,6 @@ func writeFile(path, content string) error { if err := os.WriteFile(path, []byte(content), 0644); err != nil { return fmt.Errorf("write %s: %w", filepath.Base(path), err) } + return nil } diff --git a/cmd/setup.go b/cmd/setup.go index 2cab403..17d2a47 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -60,6 +60,7 @@ func runSetupFrank(cmd *cobra.Command, dir string) error { } scheduleWorker := cfg.Workers.Schedule + queueCount := 1 if len(cfg.Workers.Queue) > 0 { queueCount = cfg.Workers.Queue[0].Count @@ -162,6 +163,7 @@ func runSetupFrank(cmd *cobra.Command, dir string) error { for i, t := range allTools { options[i] = huh.NewOption(t, t) } + if err := huh.NewForm( huh.NewGroup( huh.NewMultiSelect[string](). @@ -172,6 +174,7 @@ func runSetupFrank(cmd *cobra.Command, dir string) error { ).Run(); err != nil { return err } + cfg.Tools = selectedTools // Write frank.yaml + generate .frank/ + install tools (no Laravel install). @@ -187,7 +190,9 @@ func runSetupFrank(cmd *cobra.Command, dir string) error { if cfg.Server.IsHTTPS() { printViteHTTPSHint(dir) } + output.NextSteps([]string{"frank up -d"}) + return nil } @@ -245,17 +250,21 @@ func runSetupSail(dir string) error { // Sail install — no Frank file generation, just sail:install. var sailServices []string + for _, svc := range cfg.Services { if svc == "sqlite" { continue } + sailServices = append(sailServices, svc) } + if err := runSailInstall(dir, sailServices, cfg.PHP.Version); err != nil { return fmt.Errorf("sail install: %w", err) } output.NextSteps([]string{"vendor/bin/sail up"}) + return nil } @@ -272,9 +281,11 @@ func setupWriteAndGenerate(cfg *config.Config, dir string) error { if err != nil { return err } + if err := writeFile(filepath.Join(dir, config.ConfigFileName), yamlBytes); err != nil { return err } + output.Detail("wrote frank.yaml") output.Group("Wrote frank.yaml", "") @@ -283,15 +294,18 @@ func setupWriteAndGenerate(cfg *config.Config, dir string) error { stopGen(err) return err } + stopGen(nil) if len(cfg.Tools) > 0 { stopTools := output.Spin("Installing dev tools") res, err := tool.Install(cfg.Tools, dir) + if err != nil { stopTools(err) return err } + stopTools(nil) output.Detail(fmt.Sprintf("%d created, %d skipped", len(res.Created), len(res.Skipped))) } @@ -331,5 +345,6 @@ func setupRebuildPrompt(dir string) error { region := output.Region("Building image") err := dc.RunStream(region, "build") region.Stop(err) + return err } diff --git a/cmd/test.go b/cmd/test.go index 67e4060..2eb9c9e 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -42,6 +42,7 @@ func runTest(cmd *cobra.Command, args []string) error { } client := docker.New(dir) + state, _, _ := client.ContainerStatus() if state != docker.StateRunning && state != docker.StatePartial { return fmt.Errorf("containers are not running — run frank up first") diff --git a/cmd/testdata/fpm-mysql-redis-workers/.frank/compose.yaml b/cmd/testdata/fpm-mysql-redis-workers/.frank/compose.yaml index 082e6cc..912ad22 100644 --- a/cmd/testdata/fpm-mysql-redis-workers/.frank/compose.yaml +++ b/cmd/testdata/fpm-mysql-redis-workers/.frank/compose.yaml @@ -21,11 +21,40 @@ services: - PSYSH_HOME=/var/www/html/storage/psysh - XDG_CONFIG_HOME=/var/www/html/storage/psysh - WWWUSER=${UID:-1000} + networks: + - frank + stdin_open: true + tty: true + volumes: + - .:/var/www/html + working_dir: /var/www/html + laravel.vite: + build: + args: + WWWGROUP: ${GID:-1000} + context: . + dockerfile: .frank/Dockerfile + command: + - sh + - -c + - '[ -d node_modules ] || npm install; npm run dev' + container_name: laravel.vite + depends_on: + laravel.test: + condition: service_started + env_file: + - .env + environment: + - WWWUSER=${UID:-1000} + - COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + healthcheck: + disable: true + image: frank-fpm-mysql-redis-workers-laravel.test networks: - frank ports: - 5173:5173 - stdin_open: true + restart: unless-stopped tty: true volumes: - .:/var/www/html diff --git a/cmd/testdata/fpm-mysql-redis/.frank/compose.yaml b/cmd/testdata/fpm-mysql-redis/.frank/compose.yaml index dab0fa7..912ac4c 100644 --- a/cmd/testdata/fpm-mysql-redis/.frank/compose.yaml +++ b/cmd/testdata/fpm-mysql-redis/.frank/compose.yaml @@ -21,11 +21,40 @@ services: - PSYSH_HOME=/var/www/html/storage/psysh - XDG_CONFIG_HOME=/var/www/html/storage/psysh - WWWUSER=${UID:-1000} + networks: + - frank + stdin_open: true + tty: true + volumes: + - .:/var/www/html + working_dir: /var/www/html + laravel.vite: + build: + args: + WWWGROUP: ${GID:-1000} + context: . + dockerfile: .frank/Dockerfile + command: + - sh + - -c + - '[ -d node_modules ] || npm install; npm run dev' + container_name: laravel.vite + depends_on: + laravel.test: + condition: service_started + env_file: + - .env + environment: + - WWWUSER=${UID:-1000} + - COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + healthcheck: + disable: true + image: frank-fpm-mysql-redis-laravel.test networks: - frank ports: - 5173:5173 - stdin_open: true + restart: unless-stopped tty: true volumes: - .:/var/www/html diff --git a/cmd/testdata/frankenphp-pgsql-mailpit/.frank/compose.yaml b/cmd/testdata/frankenphp-pgsql-mailpit/.frank/compose.yaml index 17ab889..49fde18 100644 --- a/cmd/testdata/frankenphp-pgsql-mailpit/.frank/compose.yaml +++ b/cmd/testdata/frankenphp-pgsql-mailpit/.frank/compose.yaml @@ -27,13 +27,43 @@ services: - 80:80 - 443:443 - 443:443/udp - - 5173:5173 stdin_open: true tty: true volumes: - .:/var/www/html - .frank/certs/:/etc/certs/:ro working_dir: /var/www/html + laravel.vite: + build: + args: + WWWGROUP: ${GID:-1000} + context: . + dockerfile: .frank/Dockerfile + command: + - sh + - -c + - '[ -d node_modules ] || npm install; npm run dev' + container_name: laravel.vite + depends_on: + laravel.test: + condition: service_started + env_file: + - .env + environment: + - WWWUSER=${UID:-1000} + - COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + healthcheck: + disable: true + image: frank-frankenphp-pgsql-mailpit-laravel.test + networks: + - frank + ports: + - 5173:5173 + restart: unless-stopped + tty: true + volumes: + - .:/var/www/html + working_dir: /var/www/html mailpit: healthcheck: interval: 1s diff --git a/cmd/testdata/frankenphp-pgsql-no-dev/.env b/cmd/testdata/frankenphp-pgsql-no-dev/.env new file mode 100644 index 0000000..2246fe9 --- /dev/null +++ b/cmd/testdata/frankenphp-pgsql-no-dev/.env @@ -0,0 +1,74 @@ +# Generated by Frank — edit frank.yaml, not this file + +APP_NAME=frankenphp-pgsql-no-dev +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=https://localhost + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +#APP_MAINTENANCE_STORE=database + +#PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=pgsql +DB_HOST=pgsql +DB_PORT=5432 +DB_DATABASE=frankenphp-pgsql-no-dev +DB_USERNAME=sail +DB_PASSWORD=password + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=database +#CACHE_PREFIX= + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=smtp +MAIL_SCHEME=null +MAIL_HOST=mailpit +MAIL_PORT=1025 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="frankenphp-pgsql-no-dev" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" + +# pgsql +DB_SSLMODE=prefer +DB_URL=postgresql://sail:password@pgsql:5432/frankenphp-pgsql-no-dev + +# mailpit +MAIL_ENCRYPTION=null diff --git a/cmd/testdata/frankenphp-pgsql-no-dev/.env.example b/cmd/testdata/frankenphp-pgsql-no-dev/.env.example new file mode 100644 index 0000000..8a95559 --- /dev/null +++ b/cmd/testdata/frankenphp-pgsql-no-dev/.env.example @@ -0,0 +1,74 @@ +# Generated by Frank — edit frank.yaml, not this file + +APP_NAME=frankenphp-pgsql-no-dev +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=https://localhost + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +#APP_MAINTENANCE_STORE=database + +#PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=pgsql +DB_HOST=pgsql +DB_PORT=5432 +DB_DATABASE=frankenphp-pgsql-no-dev +DB_USERNAME=sail +DB_PASSWORD= + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=database +#CACHE_PREFIX= + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD= +REDIS_PORT=6379 + +MAIL_MAILER=smtp +MAIL_SCHEME=null +MAIL_HOST=mailpit +MAIL_PORT=1025 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="frankenphp-pgsql-no-dev" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" + +# pgsql +DB_SSLMODE=prefer +DB_URL= + +# mailpit +MAIL_ENCRYPTION=null diff --git a/cmd/testdata/frankenphp-pgsql-no-dev/.frank/Caddyfile b/cmd/testdata/frankenphp-pgsql-no-dev/.frank/Caddyfile new file mode 100644 index 0000000..c3bb63b --- /dev/null +++ b/cmd/testdata/frankenphp-pgsql-no-dev/.frank/Caddyfile @@ -0,0 +1,37 @@ +# Generated by Frank — edit frank.yaml, not this file +{ + frankenphp + order php_server before file_server +} + + +:80 { + redir https://{host}{uri} permanent +} + + +:443 { + tls /etc/certs/localhost.pem /etc/certs/localhost-key.pem + root * /var/www/html/public + php_server + try_files {path} {path}/ /index.php?{query} + + header { + -Server + X-Content-Type-Options nosniff + X-Frame-Options SAMEORIGIN + Referrer-Policy strict-origin-when-cross-origin + } + + @static { + file + path *.ico *.css *.js *.gif *.jpg *.jpeg *.png *.svg *.woff *.woff2 *.ttf *.eot + } + header @static Cache-Control max-age=31536000 + + log { + output stdout + format console + } +} + diff --git a/cmd/testdata/frankenphp-pgsql-no-dev/.frank/Dockerfile b/cmd/testdata/frankenphp-pgsql-no-dev/.frank/Dockerfile new file mode 100644 index 0000000..39e4d87 --- /dev/null +++ b/cmd/testdata/frankenphp-pgsql-no-dev/.frank/Dockerfile @@ -0,0 +1,5 @@ +# Generated by Frank — edit frank.yaml, not this file +FROM frank/runtime:8.5-frankenphp-node24-pg17 + +# Copy Caddyfile (generated at .frank/Caddyfile; build context is project root) +COPY .frank/Caddyfile /etc/caddy/Caddyfile diff --git a/cmd/testdata/frankenphp-pgsql-no-dev/.frank/base.Dockerfile b/cmd/testdata/frankenphp-pgsql-no-dev/.frank/base.Dockerfile new file mode 100644 index 0000000..68db2f6 --- /dev/null +++ b/cmd/testdata/frankenphp-pgsql-no-dev/.frank/base.Dockerfile @@ -0,0 +1,126 @@ +# Generated by Frank — edit frank.yaml, not this file + +# ── Builder: compile PHP extensions ────────────────────────────────────────── +FROM dunglas/frankenphp:1-php8.5 AS builder + +# Dev libs required to compile extensions (not present in final image) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libmemcached-dev libmagickwand-dev libkrb5-dev libreadline-dev \ + libldap-dev libsqlite3-dev libxml2-dev libzip-dev \ + libpng-dev libonig-dev libicu-dev libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Bundled extensions (compiled into PHP, enabled here). +# Note: imap is not installed — libc-client-dev was dropped from Debian trixie (the FrankenPHP +# base OS) and the extension cannot be compiled. Use the fpm runtime if imap is required. +# Note: pdo_sqlite, sqlite3, readline, and mbstring are already statically compiled into the +# FrankenPHP binary — omitting them here avoids a mid-run source tree cleanup that breaks +# subsequent extensions. +RUN docker-php-ext-install \ + soap ldap \ + pdo_mysql pdo_pgsql pgsql exif pcntl bcmath gd intl zip + +# PECL extensions — install igbinary/msgpack first as other extensions can link against them +RUN pecl install igbinary \ + && pecl install msgpack \ + && pecl install redis \ + && pecl install memcached \ + && pecl install mongodb \ + && pecl install imagick \ + && pecl install xdebug \ + && pecl install pcov \ + && docker-php-ext-enable igbinary msgpack redis memcached mongodb imagick xdebug pcov \ + && docker-php-source delete \ + && pecl clear-cache \ + && find /usr/local/lib/php/extensions -name '*.so' -exec strip --strip-all {} + + +# ── Final image ─────────────────────────────────────────────────────────────── +FROM dunglas/frankenphp:1-php8.5 AS final + +# Copy compiled extensions from builder. +# The .so files live in a versioned ABI subdirectory (e.g. no-debug-zts-20240924/). +# Both stages share the same base so the ABI path is identical — copy the whole tree. +COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/ +COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/ + +# Dev-friendly OPcache: recheck file timestamps every request (no stale bytecode) +RUN printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /usr/local/etc/php/conf.d/zz-frank-dev.ini + +ARG WWWGROUP=1000 +ARG NODE_VERSION=24 +ARG POSTGRES_VERSION=17 +ARG MYSQL_CLIENT=default-mysql-client + +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# Runtime libs for compiled extensions + system tools +# Runtime lib names are Debian trixie-specific (the FrankenPHP base OS as of 2026-04). +RUN mkdir -p /etc/apt/keyrings \ + && apt-get update && apt-get install -y --no-install-recommends \ + libmagickwand-7.q16-10 libmemcached11t64 libldap2 libkrb5-3 \ + libreadline8 libsqlite3-0 libicu76 libzip5 \ + gosu supervisor sqlite3 python3 dnsutils librsvg2-bin \ + fswatch ffmpeg nano git curl ca-certificates gnupg zip unzip \ + && rm -rf /usr/share/man /usr/share/doc \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Node.js +RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && npm install -g bun \ + && corepack enable \ + && corepack prepare npm@latest pnpm@latest --activate \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Database clients (pgdg repo — detect Debian codename dynamically) +RUN curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg > /dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo $VERSION_CODENAME)-pgdg main" \ + > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + $MYSQL_CLIENT postgresql-client-$POSTGRES_VERSION \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +# User setup +RUN groupadd --force -g $WWWGROUP sail \ + && useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail \ + && git config --system --add safe.directory /var/www/html + +# Entrypoint (heredoc — single-quoted delimiter suppresses $VAR expansion in body) +RUN cat <<'SCRIPT' > /entrypoint.sh +#!/usr/bin/env bash +if [ -n "$WWWUSER" ]; then + usermod -u "$WWWUSER" sail +fi +mkdir -p /var/www/html/storage/psysh 2>/dev/null || true +if [ -d "/var/www/html/storage" ]; then + chown -R sail:sail /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true + chmod -R 755 /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true +fi +if [ -f "/var/www/html/.env.example" ] && [ ! -f "/var/www/html/.env" ]; then + cp /var/www/html/.env.example /var/www/html/.env + php artisan key:generate --no-interaction 2>/dev/null || true +fi +mkdir -p /config/caddy /data/caddy +chown -R sail:sail /config/caddy /data/caddy +exec gosu sail "$@" +SCRIPT +RUN chmod +x /entrypoint.sh + +WORKDIR /var/www/html +EXPOSE 80 443 5173 + +USER root +ENTRYPOINT ["/entrypoint.sh"] +CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"] diff --git a/cmd/testdata/frankenphp-pgsql-no-dev/.frank/compose.yaml b/cmd/testdata/frankenphp-pgsql-no-dev/.frank/compose.yaml new file mode 100644 index 0000000..feec0b8 --- /dev/null +++ b/cmd/testdata/frankenphp-pgsql-no-dev/.frank/compose.yaml @@ -0,0 +1,77 @@ +# Generated by Frank — edit frank.yaml, not this file + +networks: + frank: + driver: bridge +services: + laravel.test: + build: + args: + WWWGROUP: ${GID:-1000} + context: . + dockerfile: .frank/Dockerfile + depends_on: + mailpit: + condition: service_healthy + pgsql: + condition: service_healthy + environment: + - APP_ENV=local + - APP_DEBUG=true + - PSYSH_HOME=/var/www/html/storage/psysh + - XDG_CONFIG_HOME=/var/www/html/storage/psysh + - WWWUSER=${UID:-1000} + networks: + - frank + ports: + - 80:80 + - 443:443 + - 443:443/udp + stdin_open: true + tty: true + volumes: + - .:/var/www/html + - .frank/certs/:/etc/certs/:ro + working_dir: /var/www/html + mailpit: + healthcheck: + interval: 1s + retries: 30 + start_period: 30s + test: + - CMD + - wget + - --no-verbose + - --spider + - http://127.0.0.1:8025/api/v1/info + timeout: 3s + image: axllent/mailpit:latest + networks: + - frank + ports: + - 1025:1025 + - 8025:8025 + pgsql: + environment: + POSTGRES_DB: ${DB_DATABASE} + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_USER: ${DB_USERNAME} + healthcheck: + interval: 1s + retries: 30 + start_period: 30s + test: + - CMD-SHELL + - pg_isready -U ${DB_USERNAME:-sail} + timeout: 3s + image: postgres:latest + networks: + - frank + ports: + - 5432:5432 + volumes: + - pgsql_data:/var/lib/postgresql/data + - .frank/scripts/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql +volumes: + pgsql_data: + driver: local diff --git a/cmd/testdata/frankenphp-pgsql-no-dev/.frank/vite-server.js b/cmd/testdata/frankenphp-pgsql-no-dev/.frank/vite-server.js new file mode 100644 index 0000000..b153835 --- /dev/null +++ b/cmd/testdata/frankenphp-pgsql-no-dev/.frank/vite-server.js @@ -0,0 +1,15 @@ +// Generated by Frank — Vite dev server config +import fs from 'fs'; + +export default { + host: '0.0.0.0', + https: { + key: fs.readFileSync('.frank/certs/localhost-key.pem'), + cert: fs.readFileSync('.frank/certs/localhost.pem'), + }, + origin: 'https://localhost:5173', + cors: true, + watch: { + ignored: ['**/vendor/**', '**/node_modules/**', '**/storage/**', '**/public/**', '**/.frank/**', '**/.git/**'], + }, +}; diff --git a/cmd/testdata/frankenphp-pgsql-no-dev/.mcp.json b/cmd/testdata/frankenphp-pgsql-no-dev/.mcp.json new file mode 100644 index 0000000..5b8cbd1 --- /dev/null +++ b/cmd/testdata/frankenphp-pgsql-no-dev/.mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "frank": { + "args": [ + "mcp" + ], + "command": "frank" + } + } +} diff --git a/cmd/testdata/frankenphp-pgsql-no-https/.frank/compose.yaml b/cmd/testdata/frankenphp-pgsql-no-https/.frank/compose.yaml index cdfc531..ef5411c 100644 --- a/cmd/testdata/frankenphp-pgsql-no-https/.frank/compose.yaml +++ b/cmd/testdata/frankenphp-pgsql-no-https/.frank/compose.yaml @@ -25,12 +25,42 @@ services: - frank ports: - 80:80 - - 5173:5173 stdin_open: true tty: true volumes: - .:/var/www/html working_dir: /var/www/html + laravel.vite: + build: + args: + WWWGROUP: ${GID:-1000} + context: . + dockerfile: .frank/Dockerfile + command: + - sh + - -c + - '[ -d node_modules ] || npm install; npm run dev' + container_name: laravel.vite + depends_on: + laravel.test: + condition: service_started + env_file: + - .env + environment: + - WWWUSER=${UID:-1000} + - COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + healthcheck: + disable: true + image: frank-frankenphp-pgsql-no-https-laravel.test + networks: + - frank + ports: + - 5173:5173 + restart: unless-stopped + tty: true + volumes: + - .:/var/www/html + working_dir: /var/www/html mailpit: healthcheck: interval: 1s diff --git a/cmd/testdata/frankenphp-pgsql-pnpm/.frank/compose.yaml b/cmd/testdata/frankenphp-pgsql-pnpm/.frank/compose.yaml index 17ab889..6740c58 100644 --- a/cmd/testdata/frankenphp-pgsql-pnpm/.frank/compose.yaml +++ b/cmd/testdata/frankenphp-pgsql-pnpm/.frank/compose.yaml @@ -27,13 +27,43 @@ services: - 80:80 - 443:443 - 443:443/udp - - 5173:5173 stdin_open: true tty: true volumes: - .:/var/www/html - .frank/certs/:/etc/certs/:ro working_dir: /var/www/html + laravel.vite: + build: + args: + WWWGROUP: ${GID:-1000} + context: . + dockerfile: .frank/Dockerfile + command: + - sh + - -c + - '[ -d node_modules ] || pnpm install; pnpm dev' + container_name: laravel.vite + depends_on: + laravel.test: + condition: service_started + env_file: + - .env + environment: + - WWWUSER=${UID:-1000} + - COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + healthcheck: + disable: true + image: frank-frankenphp-pgsql-pnpm-laravel.test + networks: + - frank + ports: + - 5173:5173 + restart: unless-stopped + tty: true + volumes: + - .:/var/www/html + working_dir: /var/www/html mailpit: healthcheck: interval: 1s diff --git a/cmd/testdata/frankenphp-pgsql-workers/.frank/compose.yaml b/cmd/testdata/frankenphp-pgsql-workers/.frank/compose.yaml index 0548d0b..033c95e 100644 --- a/cmd/testdata/frankenphp-pgsql-workers/.frank/compose.yaml +++ b/cmd/testdata/frankenphp-pgsql-workers/.frank/compose.yaml @@ -27,13 +27,43 @@ services: - 80:80 - 443:443 - 443:443/udp - - 5173:5173 stdin_open: true tty: true volumes: - .:/var/www/html - .frank/certs/:/etc/certs/:ro working_dir: /var/www/html + laravel.vite: + build: + args: + WWWGROUP: ${GID:-1000} + context: . + dockerfile: .frank/Dockerfile + command: + - sh + - -c + - '[ -d node_modules ] || npm install; npm run dev' + container_name: laravel.vite + depends_on: + laravel.test: + condition: service_started + env_file: + - .env + environment: + - WWWUSER=${UID:-1000} + - COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + healthcheck: + disable: true + image: frank-frankenphp-pgsql-workers-laravel.test + networks: + - frank + ports: + - 5173:5173 + restart: unless-stopped + tty: true + volumes: + - .:/var/www/html + working_dir: /var/www/html mailpit: healthcheck: interval: 1s diff --git a/cmd/testdata/frankenphp-sqlite/.frank/compose.yaml b/cmd/testdata/frankenphp-sqlite/.frank/compose.yaml index 10a121d..d832b53 100644 --- a/cmd/testdata/frankenphp-sqlite/.frank/compose.yaml +++ b/cmd/testdata/frankenphp-sqlite/.frank/compose.yaml @@ -22,10 +22,40 @@ services: - 80:80 - 443:443 - 443:443/udp - - 5173:5173 stdin_open: true tty: true volumes: - .:/var/www/html - .frank/certs/:/etc/certs/:ro working_dir: /var/www/html + laravel.vite: + build: + args: + WWWGROUP: ${GID:-1000} + context: . + dockerfile: .frank/Dockerfile + command: + - sh + - -c + - '[ -d node_modules ] || npm install; npm run dev' + container_name: laravel.vite + depends_on: + laravel.test: + condition: service_started + env_file: + - .env + environment: + - WWWUSER=${UID:-1000} + - COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + healthcheck: + disable: true + image: frank-frankenphp-sqlite-laravel.test + networks: + - frank + ports: + - 5173:5173 + restart: unless-stopped + tty: true + volumes: + - .:/var/www/html + working_dir: /var/www/html diff --git a/cmd/up.go b/cmd/up.go index 799dba1..576c028 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -89,10 +89,13 @@ func doUp(dir string, detach, quick bool, passthrough []string, showNextSteps bo if loadErr != nil { return fmt.Errorf("no Docker config found — run frank generate first") } + output.Group("Generating Docker files", "frank.yaml found but .frank/ missing") + if err := generate(cfg, dir, rootCmd.Version); err != nil { return fmt.Errorf("auto-generate failed: %w", err) } + composeArgs = append(composeArgs, "--build") } @@ -103,6 +106,7 @@ func doUp(dir string, detach, quick bool, passthrough []string, showNextSteps bo if needsBuild { composeArgs = append(composeArgs, "--build") } + quick = false } @@ -144,8 +148,10 @@ func doUp(dir string, detach, quick bool, passthrough []string, showNextSteps bo // edit during container boot still lands a reload trigger once the // arm-suppression window clears. SIGINT/SIGTERM cancels both. var stopWatcher func() error + if !detach && wantWatcher { var err error + stopWatcher, err = startForegroundWatcher(dir, cfg) if err != nil { output.Warning(fmt.Sprintf("watcher not started: %v", err)) @@ -162,6 +168,7 @@ func doUp(dir string, detach, quick bool, passthrough []string, showNextSteps bo } region.Stop(upErr) + if upErr != nil { return upErr } @@ -174,14 +181,17 @@ func doUp(dir string, detach, quick bool, passthrough []string, showNextSteps bo if err := client.WaitForContainer("laravel.test", 30*time.Second); err != nil { stopWait(err) output.Warning(fmt.Sprintf("%v — skipping post-start tasks", err)) + return nil } + stopWait(nil) if _, err := os.Stat(filepath.Join(dir, "composer.json")); err == nil { region := output.Region("Installing Composer dependencies") err := client.ExecStream(region, "laravel.test", "composer", "install", "--no-interaction") region.Stop(err) + if err != nil { output.Warning(fmt.Sprintf("composer install failed: %v", err)) } @@ -191,6 +201,7 @@ func doUp(dir string, detach, quick bool, passthrough []string, showNextSteps bo region := output.Region("Running migrations") err := client.ExecStream(region, "laravel.test", "php", "artisan", "migrate", "--force") region.Stop(err) + if err != nil { output.Warning(fmt.Sprintf("artisan migrate failed: %v", err)) } @@ -202,13 +213,21 @@ func doUp(dir string, detach, quick bool, passthrough []string, showNextSteps bo if detach && showNextSteps { var steps []string + pm := "npm" if cfg != nil && cfg.Node.PackageManager != "" { pm = cfg.Node.PackageManager } + if _, err := os.Stat(filepath.Join(dir, "package.json")); err == nil { - steps = append(steps, fmt.Sprintf("%s install && %s run dev", pm, pm)) + if cfg != nil && cfg.Dev.IsEnabled() { + // Dev server runs automatically in the laravel.vite sidecar. + steps = append(steps, "frank dev # attach to the dev server (Vite)") + } else { + steps = append(steps, fmt.Sprintf("%s install && %s run dev", pm, pm)) + } } + output.NextSteps(steps) } @@ -232,7 +251,9 @@ func ensureBaseImage(dir string) error { if err != nil { return nil // let the normal flow surface the config error } + engine := template.New(TemplateFS) + return baseimage.EnsureBase(engine, cfg) } @@ -248,6 +269,7 @@ func composeSubcmdBuilds(args []string) bool { if a == "--" { continue } + if strings.HasPrefix(a, "-") { // `-f file` / `--file file` style: skip the value too when the // flag has no "=" and isn't a bundled short flag. We can't know @@ -260,8 +282,10 @@ func composeSubcmdBuilds(args []string) bool { i+1 < len(args) { i++ } + continue } + switch a { case "build", "up", "run", "create": return true @@ -269,19 +293,20 @@ func composeSubcmdBuilds(args []string) bool { return false } } + return false } // autoRegenerate detects a stale .frank/ and regenerates it. Two tiers: // -// Tier 1 (should we regenerate at all?): stale if .state is missing/corrupt, -// the frank version bumped, this is a "dev" build, or sha256(frank.yaml) no -// longer matches the stored configHash. The hash check subsumes the old -// explicit php.version/runtime comparison — those fields live in frank.yaml, -// so any change to them flips the hash. +// Tier 1 (should we regenerate at all?): stale if .state is missing/corrupt, +// the frank version bumped, this is a "dev" build, or sha256(frank.yaml) no +// longer matches the stored configHash. The hash check subsumes the old +// explicit php.version/runtime comparison — those fields live in frank.yaml, +// so any change to them flips the hash. // -// Tier 2 (does the image need a rebuild?): only when regenerating, and BEFORE -// generate() overwrites the on-disk Dockerfile — see dockerfileChanged. +// Tier 2 (does the image need a rebuild?): only when regenerating, and BEFORE +// generate() overwrites the on-disk Dockerfile — see dockerfileChanged. // // Returns (regenerated, needsBuild, err). On a config-load failure it skips // gracefully, returning (false, false, nil) so the normal up flow proceeds. @@ -312,6 +337,7 @@ func autoRegenerate(dir, currentVersion string) (regenerated, needsBuild bool, e } else if state.FrankVersion != "dev" { vc := "v" + currentVersion vs := "v" + state.FrankVersion + if semver.IsValid(vc) && semver.IsValid(vs) && semver.Compare(vc, vs) > 0 { stale = true reason = fmt.Sprintf("frank updated %s → %s", state.FrankVersion, currentVersion) @@ -360,6 +386,7 @@ func autoRegenerate(dir, currentVersion string) (regenerated, needsBuild bool, e stopGen(genErr) return false, false, fmt.Errorf("auto-regenerate failed: %w", genErr) } + stopGen(nil) return true, needsBuild, nil @@ -394,11 +421,13 @@ func dockerfileChanged(dir string, cfg *config.Config) bool { if err != nil { return true } + onDisk, err := os.ReadFile(filepath.Join(frankDir, d.file)) if err != nil || string(onDisk) != rendered { return true } } + return false } @@ -408,11 +437,13 @@ func needsAppKey(dir string) bool { if err != nil { return false } + for _, line := range strings.Split(string(data), "\n") { if strings.TrimSpace(line) == "APP_KEY=" { return true } } + return false } @@ -423,13 +454,16 @@ func generateAppKey(dir string) error { if _, err := rand.Read(key); err != nil { return err } + value := "base64:" + base64.StdEncoding.EncodeToString(key) path := filepath.Join(dir, ".env") + data, err := os.ReadFile(path) if err != nil { return err } + lines := strings.Split(string(data), "\n") for i, line := range lines { if strings.HasPrefix(line, "APP_KEY=") { @@ -437,8 +471,11 @@ func generateAppKey(dir string) error { break } } + updated := strings.Join(lines, "\n") + output.Detail("generated APP_KEY") + return os.WriteFile(path, []byte(updated), 0644) } @@ -452,16 +489,19 @@ func shouldRunWatcher(cfg *config.Config, client *docker.Client, projectRoot str if cfg.Workers.Schedule { return true } + if totalQueueCount(cfg) > 0 { return true } } + if client != nil { project := config.ProjectName(projectRoot) if names, err := client.AdhocWorkerNames(project); err == nil && len(names) > 0 { return true } } + return false } @@ -488,6 +528,7 @@ func startForegroundWatcher(projectRoot string, cfg *config.Config) (func() erro ctx, cancel := context.WithCancel(context.Background()) sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { select { case <-sigCh: @@ -504,7 +545,9 @@ func startForegroundWatcher(projectRoot string, cfg *config.Config) (func() erro return func() error { signal.Stop(sigCh) cancel() + err := <-done + return err }, nil } @@ -517,11 +560,15 @@ func spawnDetachedWatcher(projectRoot string) error { if err != nil { return fmt.Errorf("resolve executable: %w", err) } + argv := []string{self, "--dir", projectRoot, "watch"} + pid, err := watch.Daemonize(argv, watch.LogfilePath(projectRoot)) if err != nil { return err } + output.Detail(fmt.Sprintf("frank watch: detached child (pid %d)", pid)) + return nil } diff --git a/cmd/up_test.go b/cmd/up_test.go index cf96433..6191613 100644 --- a/cmd/up_test.go +++ b/cmd/up_test.go @@ -13,9 +13,11 @@ func TestUpCmd_TypedDetachFlag(t *testing.T) { if f := upCmd.Flags().Lookup("detach"); f == nil || f.Shorthand != "d" { t.Fatalf("upCmd missing -d/--detach typed flag") } + if f := upCmd.Flags().Lookup("quick"); f == nil { t.Fatalf("upCmd missing --quick typed flag") } + if upCmd.Flags().Lookup("build") != nil { t.Fatalf("upCmd should not own --build (belongs to docker compose)") } @@ -27,6 +29,7 @@ func TestUpCmd_UnknownFlagHintsAtDash(t *testing.T) { if err == nil { t.Fatalf("upFlagError returned nil") } + if !strings.Contains(err.Error(), "--") || !strings.Contains(err.Error(), "docker compose") { t.Errorf("unknown-flag error should hint about `--` and compose, got: %v", err) } @@ -39,12 +42,14 @@ func buildUpComposeArgs(detach bool, passthrough []string) []string { if detach { out = append([]string{"-d"}, out...) } + return out } func TestUpCmd_DetachInjectedIntoComposeArgs(t *testing.T) { got := buildUpComposeArgs(true, []string{"--remove-orphans"}) want := []string{"-d", "--remove-orphans"} + if !equalSlice(got, want) { t.Errorf("got %v, want %v", got, want) } @@ -53,6 +58,7 @@ func TestUpCmd_DetachInjectedIntoComposeArgs(t *testing.T) { func TestUpCmd_NoDetachNoInjection(t *testing.T) { got := buildUpComposeArgs(false, []string{"--remove-orphans"}) want := []string{"--remove-orphans"} + if !equalSlice(got, want) { t.Errorf("got %v, want %v", got, want) } @@ -61,6 +67,7 @@ func TestUpCmd_NoDetachNoInjection(t *testing.T) { func TestShouldRunWatcher_ScheduleEnabled(t *testing.T) { cfg := &config.Config{} cfg.Workers.Schedule = true + if !shouldRunWatcher(cfg, nil, t.TempDir()) { t.Errorf("schedule=true should request watcher") } @@ -69,6 +76,7 @@ func TestShouldRunWatcher_ScheduleEnabled(t *testing.T) { func TestShouldRunWatcher_QueueDeclared(t *testing.T) { cfg := &config.Config{} cfg.Workers.Queue = []config.QueuePool{{Count: 1}} + if !shouldRunWatcher(cfg, nil, t.TempDir()) { t.Errorf("queue count > 0 should request watcher") } diff --git a/cmd/watch.go b/cmd/watch.go index 24e2ecd..727ea26 100644 --- a/cmd/watch.go +++ b/cmd/watch.go @@ -55,6 +55,7 @@ func runWatch(cmd *cobra.Command, args []string) error { if msg != "" { fmt.Println(msg) } + return err case watchStatus: return runWatchStatus(dir) @@ -80,7 +81,9 @@ func runWatchForeground(projectRoot string) error { // returns cleanly, flushing any in-flight dispatch. sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigCh) + go func() { select { case <-sigCh: @@ -99,10 +102,13 @@ func runWatchForeground(projectRoot string) error { if err != nil { return err } + fmt.Fprintf(os.Stderr, "frank watch: foreground (pid %d) — Ctrl-C to stop\n", os.Getpid()) + if err := w.Start(ctx); err != nil && !errors.Is(err, context.Canceled) { return err } + return nil } @@ -112,19 +118,23 @@ func runWatchForeground(projectRoot string) error { func runWatchStop(projectRoot string) (bool, string, error) { path := watch.PidfilePath(projectRoot) pid, err := watch.ReadPidfile(path) + if err != nil { // Malformed pidfile: unlink + report. _ = os.Remove(path) return false, "", fmt.Errorf("stale or malformed pidfile removed: %w", err) } + if pid == 0 { return false, "frank watch: no watcher running", nil } + if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { if errors.Is(err, syscall.ESRCH) { _ = os.Remove(path) return false, fmt.Sprintf("frank watch: stale pidfile (pid %d not running) removed", pid), nil } + return false, "", fmt.Errorf("SIGTERM %d: %w", pid, err) } @@ -135,9 +145,12 @@ func runWatchStop(projectRoot string) (bool, string, error) { if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { return true, fmt.Sprintf("frank watch: stopped (pid %d)", pid), nil } + time.Sleep(50 * time.Millisecond) } + _ = os.Remove(path) + return true, fmt.Sprintf("frank watch: sent SIGTERM to pid %d; pidfile force-unlinked after timeout", pid), nil } @@ -156,19 +169,23 @@ func runWatchStatus(projectRoot string) error { fmt.Printf("project: %s\n", projectName) fmt.Printf("state: %s\n", st.State) + if st.PID != 0 { fmt.Printf("pid: %d\n", st.PID) } + if !st.StartedAt.IsZero() { fmt.Printf("uptime: %s (started %s)\n", formatUptime(st.Uptime()), st.StartedAt.Format(time.RFC3339), ) } + fmt.Printf("pidfile: %s\n", watch.PidfilePath(projectRoot)) fmt.Printf("logfile: %s\n", watch.LogfilePath(projectRoot)) fmt.Println() fmt.Println(".gitignore edit? restart `frank watch` — new ignore rules only apply after a re-arm.") + return nil } @@ -177,11 +194,13 @@ func runWatchStatus(projectRoot string) error { // actual container names come from compose. func totalQueueCount(cfg *config.Config) int { total := 0 + for _, pool := range cfg.Workers.Queue { if pool.Count > 0 { total += pool.Count } } + return total } @@ -190,13 +209,17 @@ func formatUptime(d time.Duration) string { if d < time.Minute { return fmt.Sprintf("%ds", int(d.Seconds())) } + if d < time.Hour { return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60) } + if d < 24*time.Hour { return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60) } + days := int(d / (24 * time.Hour)) rem := d - time.Duration(days)*24*time.Hour + return fmt.Sprintf("%dd%02dh", days, int(rem.Hours())) } diff --git a/cmd/watch_test.go b/cmd/watch_test.go index d6c3a26..598f731 100644 --- a/cmd/watch_test.go +++ b/cmd/watch_test.go @@ -38,6 +38,7 @@ func TestTotalQueueCount_SumsPositiveCounts(t *testing.T) { {Count: 3}, {Count: 0}, // ignored } + if got := totalQueueCount(cfg); got != 5 { t.Errorf("totalQueueCount = %d, want 5", got) } @@ -48,7 +49,9 @@ func TestTotalQueueCount_SumsPositiveCounts(t *testing.T) { // pidfile and returns nil. func TestRunWatchStop_StaleDeadPidCleansUp(t *testing.T) { root := t.TempDir() + const deadPid = 2_147_483_600 + if err := watch.WritePidfile(watch.PidfilePath(root), deadPid); err != nil { t.Fatalf("seed: %v", err) } @@ -56,6 +59,7 @@ func TestRunWatchStop_StaleDeadPidCleansUp(t *testing.T) { if _, _, err := runWatchStop(root); err != nil { t.Fatalf("runWatchStop: %v", err) } + if _, err := os.Stat(watch.PidfilePath(root)); !errors.Is(err, os.ErrNotExist) { t.Errorf("pidfile should be removed, stat err = %v", err) } @@ -74,9 +78,11 @@ func TestRunWatchStop_NoPidfile(t *testing.T) { func TestRunWatchStop_MalformedPidfile(t *testing.T) { root := t.TempDir() path := watch.PidfilePath(root) + if err := os.MkdirAll(rootDotFrank(root), 0o755); err != nil { t.Fatal(err) } + if err := os.WriteFile(path, []byte("garbage"), 0o644); err != nil { t.Fatal(err) } @@ -84,6 +90,7 @@ func TestRunWatchStop_MalformedPidfile(t *testing.T) { if _, _, err := runWatchStop(root); err == nil { t.Fatalf("expected error for malformed pidfile") } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { t.Errorf("malformed pidfile should be unlinked, stat err = %v", err) } diff --git a/cmd/worker.go b/cmd/worker.go index 4ce4737..b613cbc 100644 --- a/cmd/worker.go +++ b/cmd/worker.go @@ -110,19 +110,25 @@ func buildQueueArtisanArgs(queue string, tries, timeout, memory, sleep, backoff if tries > 0 { args = append(args, fmt.Sprintf("--tries=%d", tries)) } + if timeout > 0 { args = append(args, fmt.Sprintf("--timeout=%d", timeout)) } + if memory > 0 { args = append(args, fmt.Sprintf("--memory=%d", memory)) } + if sleep > 0 { args = append(args, fmt.Sprintf("--sleep=%d", sleep)) } + if backoff > 0 { args = append(args, fmt.Sprintf("--backoff=%d", backoff)) } + args = append(args, passthrough...) + return args } @@ -177,10 +183,12 @@ func runWorkerQueue(cmd *cobra.Command, args []string) error { if err := client.RunAdhoc(name, labels, cmdArgs); err != nil { return fmt.Errorf("spawn %s: %w", name, err) } + fmt.Println(name) } printWatcherHintIfNeeded(dir, client) + return nil } @@ -196,6 +204,7 @@ func runWorkerSchedule(cmd *cobra.Command, args []string) error { if line == "" { continue } + parts := strings.Split(line, "\t") if len(parts) >= 2 && parts[1] == "schedule" { return fmt.Errorf("ad-hoc schedule already running: %s", parts[0]) @@ -221,9 +230,11 @@ func runWorkerSchedule(cmd *cobra.Command, args []string) error { if err := client.RunAdhoc(name, labels, cmdArgs); err != nil { return fmt.Errorf("spawn %s: %w", name, err) } + fmt.Println(name) printWatcherHintIfNeeded(dir, client) + return nil } @@ -239,6 +250,7 @@ func printWatcherHintIfNeeded(projectRoot string, client *docker.Client) { checker := watch.NewStatusChecker(projectRoot, func() bool { return client.ComposePSServiceExists("laravel.test") }) + st, _ := checker.Check() switch st.State { case watch.StatusRunning: @@ -256,6 +268,7 @@ func runWorkerList(cmd *cobra.Command, args []string) error { client := docker.New(dir) format := "table {{.Names}}\t{{.Label \"frank.worker\"}}\t{{.Label \"frank.worker.pool\"}}\t{{.Command}}\t{{.RunningFor}}\t{{.Status}}" + out, err := client.ListContainers(projectName, "", format) if err != nil { fmt.Printf("No worker containers found for project %s.\n", projectName) @@ -272,6 +285,7 @@ func runWorkerList(cmd *cobra.Command, args []string) error { // Replace default header with our column names (TYPE=frank.worker, POOL=frank.worker.pool). lines[0] = "NAME\tTYPE\tPOOL\tCOMMAND\tUPTIME\tSTATUS" fmt.Println(strings.Join(lines, "\n")) + return nil } @@ -282,7 +296,9 @@ func runWorkerStop(cmd *cobra.Command, args []string) error { // Always collect ad-hoc names (force-removed via `docker rm -f`). adhocOut, _ := client.ListContainers(projectName, "adhoc", "{{.Names}}") + var adhocNames []string + for _, line := range strings.Split(strings.TrimSpace(adhocOut), "\n") { line = strings.TrimSpace(line) if line != "" { @@ -294,6 +310,7 @@ func runWorkerStop(cmd *cobra.Command, args []string) error { if err := client.StopContainers(adhocNames); err != nil { return fmt.Errorf("stop ad-hoc workers: %w", err) } + for _, n := range adhocNames { fmt.Println(n) } @@ -303,6 +320,7 @@ func runWorkerStop(cmd *cobra.Command, args []string) error { if len(adhocNames) == 0 { fmt.Println("No ad-hoc workers running.") } + return nil } @@ -312,26 +330,33 @@ func runWorkerStop(cmd *cobra.Command, args []string) error { if err != nil { return nil } + var declaredNames []string + for _, line := range strings.Split(strings.TrimSpace(declaredOut), "\n") { line = strings.TrimSpace(line) if line != "" { declaredNames = append(declaredNames, line) } } + if len(declaredNames) == 0 { if len(adhocNames) == 0 { fmt.Println("No workers running.") } + return nil } + stopArgs := append([]string{"stop"}, declaredNames...) if err := client.Run(stopArgs...); err != nil { return fmt.Errorf("stop declared workers: %w", err) } + for _, n := range declaredNames { fmt.Println(n) } + return nil } @@ -346,6 +371,7 @@ func runWorkerLogs(cmd *cobra.Command, args []string) error { if client.ComposePSServiceExists(name) { return client.LogsForWorkers([]string{name}, workerLogsFollow) } + return client.LogsRaw(name, workerLogsFollow) } @@ -359,6 +385,7 @@ func runWorkerLogs(cmd *cobra.Command, args []string) error { if len(adhoc) == 0 { return client.LogsForWorkers(declared, workerLogsFollow) } + if len(declared) == 0 && len(adhoc) == 1 && !workerLogsFollow { return client.LogsRaw(adhoc[0], workerLogsFollow) } @@ -378,7 +405,9 @@ func listWorkerNames(client *docker.Client, projectName string) (declared, adhoc if err != nil { return nil, nil, err } + declared, adhoc = parseWorkerList(out) + return declared, adhoc, nil } @@ -392,15 +421,19 @@ func parseWorkerList(out string) (declared, adhoc []string) { if line == "" { continue } + parts := strings.SplitN(line, "\t", 3) + name := strings.TrimSpace(parts[0]) if name == "" { continue } + kind := "" if len(parts) >= 2 { kind = strings.TrimSpace(parts[1]) } + if kind == "adhoc" { adhoc = append(adhoc, name) } else { @@ -408,6 +441,7 @@ func parseWorkerList(out string) (declared, adhoc []string) { if len(parts) >= 3 { svc = strings.TrimSpace(parts[2]) } + if svc != "" { declared = append(declared, svc) } else { @@ -415,6 +449,7 @@ func parseWorkerList(out string) (declared, adhoc []string) { } } } + return declared, adhoc } @@ -425,21 +460,27 @@ func parseWorkerList(out string) (declared, adhoc []string) { // so the prefix form matches. Blocks until every backend exits. func streamMixedWorkerLogs(client *docker.Client, declared, adhoc []string, follow bool) error { var wg sync.WaitGroup + errs := make(chan error, len(adhoc)+1) if len(declared) > 0 { wg.Add(1) + go func() { defer wg.Done() + if err := client.LogsForWorkers(declared, follow); err != nil { errs <- fmt.Errorf("declared logs: %w", err) } }() } + for _, name := range adhoc { wg.Add(1) + go func(n string) { defer wg.Done() + if err := client.LogsRawPrefixed(n, follow); err != nil { errs <- fmt.Errorf("%s logs: %w", n, err) } @@ -448,11 +489,13 @@ func streamMixedWorkerLogs(client *docker.Client, declared, adhoc []string, foll wg.Wait() close(errs) + var firstErr error for e := range errs { if firstErr == nil { firstErr = e } } + return firstErr } diff --git a/cmd/worker_test.go b/cmd/worker_test.go index 8fd6306..ac66338 100644 --- a/cmd/worker_test.go +++ b/cmd/worker_test.go @@ -8,6 +8,7 @@ import ( func TestAdhocQueueName(t *testing.T) { name := adhocQueueName(1700000000, 3) want := "queue.adhoc.1700000000.3" + if name != want { t.Errorf("adhocQueueName = %q, want %q", name, want) } @@ -16,6 +17,7 @@ func TestAdhocQueueName(t *testing.T) { func TestAdhocScheduleName(t *testing.T) { name := adhocScheduleName(1700000000) want := "schedule.adhoc.1700000000" + if name != want { t.Errorf("adhocScheduleName = %q, want %q", name, want) } @@ -38,6 +40,7 @@ func TestParseWorkerList_PartitionsByLabel(t *testing.T) { if !equalSlice(declared, wantDeclared) { t.Errorf("declared = %v, want %v", declared, wantDeclared) } + if !equalSlice(adhoc, wantAdhoc) { t.Errorf("adhoc = %v, want %v", adhoc, wantAdhoc) } @@ -57,10 +60,12 @@ func TestParseWorkerList_EmptyAndBlankLines(t *testing.T) { func TestParseWorkerList_MissingKindDefaultsDeclared(t *testing.T) { in := "legacy-worker-1\t\tlegacy.worker" + declared, adhoc := parseWorkerList(in) if len(adhoc) != 0 { t.Errorf("empty kind should not classify as adhoc: %v", adhoc) } + if len(declared) != 1 || declared[0] != "legacy.worker" { t.Errorf("declared = %v, want [legacy.worker]", declared) } @@ -69,6 +74,7 @@ func TestParseWorkerList_MissingKindDefaultsDeclared(t *testing.T) { func TestBuildQueueArtisanArgs_Defaults(t *testing.T) { got := buildQueueArtisanArgs("default", 0, 0, 0, 0, 0, nil) want := []string{"php", "artisan", "queue:work", "--queue=default"} + if !equalSlice(got, want) { t.Errorf("default args = %v, want %v", got, want) } @@ -76,6 +82,7 @@ func TestBuildQueueArtisanArgs_Defaults(t *testing.T) { func TestBuildQueueArtisanArgs_AllFlags(t *testing.T) { got := buildQueueArtisanArgs("high,default", 3, 60, 128, 5, 2, nil) + joined := strings.Join(got, " ") for _, expect := range []string{ "--queue=high,default", @@ -94,9 +101,11 @@ func TestBuildQueueArtisanArgs_AllFlags(t *testing.T) { func TestBuildQueueArtisanArgs_ZeroFlagsOmitted(t *testing.T) { got := buildQueueArtisanArgs("default", 0, 60, 0, 0, 0, nil) joined := strings.Join(got, " ") + if strings.Contains(joined, "--tries=") { t.Errorf("--tries should be omitted when zero: %v", got) } + if !strings.Contains(joined, "--timeout=60") { t.Errorf("--timeout=60 should be present: %v", got) } @@ -105,6 +114,7 @@ func TestBuildQueueArtisanArgs_ZeroFlagsOmitted(t *testing.T) { func TestBuildQueueArtisanArgs_Passthrough(t *testing.T) { got := buildQueueArtisanArgs("default", 0, 0, 0, 0, 0, []string{"--once", "--stop-when-empty"}) joined := strings.Join(got, " ") + if !strings.Contains(joined, "--once") || !strings.Contains(joined, "--stop-when-empty") { t.Errorf("passthrough not appended: %v", got) } @@ -114,10 +124,12 @@ func equalSlice(a, b []string) bool { if len(a) != len(b) { return false } + for i := range a { if a[i] != b[i] { return false } } + return true } diff --git a/cmd/worker_top.go b/cmd/worker_top.go index 354f90f..df40d49 100644 --- a/cmd/worker_top.go +++ b/cmd/worker_top.go @@ -33,10 +33,12 @@ var workerTopCmd = &cobra.Command{ func runWorkerTop(cmd *cobra.Command, _ []string) error { dir := resolveDir() + cfg, err := config.Load(dir) if err != nil { return fmt.Errorf("load frank.yaml: %w", err) } + projectName := config.ProjectName(dir) client := docker.New(dir) diff --git a/cmd/worktree.go b/cmd/worktree.go index 84397c4..0947fbc 100644 --- a/cmd/worktree.go +++ b/cmd/worktree.go @@ -15,14 +15,17 @@ import ( func branchForPath(porcelain, path string) string { var current string + for _, line := range strings.Split(porcelain, "\n") { if strings.HasPrefix(line, "worktree ") { current = strings.TrimPrefix(line, "worktree ") } + if current == path && strings.HasPrefix(line, "branch refs/heads/") { return strings.TrimPrefix(line, "branch refs/heads/") } } + return "" } @@ -33,8 +36,8 @@ func init() { } var worktreeCmd = &cobra.Command{ - Use: "worktree", - Short: "Manage git worktrees", + Use: "worktree", + Short: "Manage git worktrees", ValidArgsFunction: cobra.NoFileCompletions, } diff --git a/docs/config.md b/docs/config.md index 9148f0a..29badf1 100644 --- a/docs/config.md +++ b/docs/config.md @@ -63,9 +63,41 @@ After setting, Frank automatically regenerates `.frank/` files and prompts to re | `php.runtime` | `frankenphp`, `fpm` | `frankenphp` | | `laravel.version` | `12.*`, `13.*`, `latest` | `latest` | | `node.packageManager` | `npm`, `pnpm`, `bun` | `npm` | +| `dev.enabled` | `true`, `false` | `true` | +| `dev.command` | any shell command | derived from `node.packageManager` | Unknown keys or invalid values produce an error listing valid options. Shell completion is available for both keys and values. +### Dev server (`dev`) + +Frank runs the frontend dev server (Vite) as a managed compose sidecar +(`laravel.vite`), started by `frank up` and stopped by `frank down`. Attach to +its output with `frank dev` (Ctrl-C detaches; the server keeps running). + +```yaml +dev: + enabled: true # false omits the laravel.vite service entirely + command: "" # empty → derived from node.packageManager +``` + +When `command` is empty, Frank derives it from the package manager — e.g. for +`pnpm` it runs `pnpm install` (only when `node_modules` is absent) then +`pnpm dev`. Set `command` to override verbatim (it is run via `sh -c` inside the +container) — useful for extra flags, a different script, or skipping the install: + +```yaml +dev: + command: "npm run dev -- --force" +``` + +Keep Vite listening on **port 5173 inside the container** — that's the only port +compose publishes (`:5173`). Telling Vite to use a different port in +`command` leaves it unmapped and unreachable from the host. Change the *host* +port via worktree mode, not here. + +With `dev.enabled: false`, no dev-server container is created and the Vite port +is left unmapped. + For services, workers, tools, and aliases, use the dedicated commands instead: - `frank add ` / `frank remove ` diff --git a/internal/baseimage/baseimage_test.go b/internal/baseimage/baseimage_test.go index 15fb1d8..002805f 100644 --- a/internal/baseimage/baseimage_test.go +++ b/internal/baseimage/baseimage_test.go @@ -27,6 +27,7 @@ func TestTag(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { cfg := &config.Config{PHP: config.PHP{Version: tc.php, Runtime: tc.runtime}} + got := Tag(cfg) if got != tc.want { t.Fatalf("Tag = %q, want %q", got, tc.want) @@ -40,9 +41,11 @@ func TestHashStableAndSensitive(t *testing.T) { a := Hash(in) b := Hash(in) + if a != b { t.Fatalf("Hash not stable: %q != %q", a, b) } + if len(a) != 64 { t.Fatalf("Hash length = %d, want 64 hex chars", len(a)) } @@ -56,19 +59,23 @@ func TestRender(t *testing.T) { e := newTestEngine() cfg := &config.Config{PHP: config.PHP{Version: "8.5", Runtime: "frankenphp"}} + out, err := Render(e, cfg) if err != nil { t.Fatalf("Render(frankenphp) error: %v", err) } + if !strings.Contains(out, "dunglas/frankenphp:1-php8.5") { t.Fatalf("Render(frankenphp) missing PHP version interpolation:\n%s", out) } fpmCfg := &config.Config{PHP: config.PHP{Version: "8.4", Runtime: "fpm"}} + fpmOut, err := Render(e, fpmCfg) if err != nil { t.Fatalf("Render(fpm) error: %v", err) } + if !strings.Contains(fpmOut, "php8.4-fpm") { t.Fatalf("Render(fpm) missing PHP version interpolation:\n%s", fpmOut) } diff --git a/internal/baseimage/ensure.go b/internal/baseimage/ensure.go index 1949d15..3002d26 100644 --- a/internal/baseimage/ensure.go +++ b/internal/baseimage/ensure.go @@ -30,10 +30,12 @@ func needsBuild(present bool, gotLabel, wantHash string) bool { if !present { return true } + gotLabel = strings.TrimSpace(gotLabel) if gotLabel == "" || gotLabel == "" { return true } + return gotLabel != wantHash } @@ -50,6 +52,7 @@ func EnsureBase(engine *template.Engine, cfg *config.Config) error { if err != nil { return fmt.Errorf("render base Dockerfile: %w", err) } + hash := Hash(rendered) tag := Tag(cfg) @@ -68,19 +71,24 @@ func EnsureBase(engine *template.Engine, cfg *config.Config) error { if err != nil { return err } + lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o644) if err != nil { return fmt.Errorf("open base lock %s: %w", lockPath, err) } + defer lockFile.Close() + if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX); err != nil { return fmt.Errorf("lock base image build: %w", err) } + defer syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN) // Re-check under the lock: another holder may have finished building while // we waited, so we don't rebuild needlessly. var oldID string + present, gotLabel, oldID = inspectBase(tag) if !needsBuild(present, gotLabel, hash) { output.Detail(fmt.Sprintf("base image %s up to date", tag)) @@ -92,6 +100,7 @@ func EnsureBase(engine *template.Engine, cfg *config.Config) error { region.Stop(err) return fmt.Errorf("build base image %s: %w", tag, err) } + region.Stop(nil) // Best-effort prune of the prior base: an in-place rebuild leaves the old @@ -102,6 +111,7 @@ func EnsureBase(engine *template.Engine, cfg *config.Config) error { pruneImage(oldID) } } + return nil } @@ -111,26 +121,33 @@ func EnsureBase(engine *template.Engine, cfg *config.Config) error { func inspectBase(tag string) (present bool, gotLabel, oldID string) { cmd := exec.Command("docker", "image", "inspect", tag, "--format", fmt.Sprintf(`{{ index .Config.Labels "%s" }}`, labelKey)) + var stdout bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = io.Discard + if err := cmd.Run(); err != nil { return false, "", "" } + gotLabel = strings.TrimSpace(stdout.String()) _, oldID, _ = inspectID(tag) + return true, gotLabel, oldID } // inspectID returns the image ID for tag. present is false when absent. func inspectID(tag string) (present bool, id string, err error) { cmd := exec.Command("docker", "image", "inspect", tag, "--format", "{{.Id}}") + var stdout bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = io.Discard + if err := cmd.Run(); err != nil { return false, "", err } + return true, strings.TrimSpace(stdout.String()), nil } @@ -147,6 +164,7 @@ func buildBase(tag, hash, rendered string, w io.Writer) error { cmd.Stdin = strings.NewReader(rendered) cmd.Stdout = w cmd.Stderr = w + return cmd.Run() } @@ -166,10 +184,12 @@ func baseLockPath(tag string) (string, error) { if err != nil { return "", fmt.Errorf("resolve user cache dir: %w", err) } + dir := filepath.Join(cacheDir, "frank") if err := os.MkdirAll(dir, 0o755); err != nil { return "", fmt.Errorf("create lock dir %s: %w", dir, err) } + return filepath.Join(dir, "base-"+sanitizeTag(tag)+".lock"), nil } diff --git a/internal/baseimage/ensure_test.go b/internal/baseimage/ensure_test.go index f6685f9..13ba023 100644 --- a/internal/baseimage/ensure_test.go +++ b/internal/baseimage/ensure_test.go @@ -4,6 +4,7 @@ import "testing" func TestNeedsBuild(t *testing.T) { const want = "abc123" + tests := []struct { name string present bool diff --git a/internal/cert/cert.go b/internal/cert/cert.go index 8f871ed..e5a888a 100644 --- a/internal/cert/cert.go +++ b/internal/cert/cert.go @@ -29,6 +29,7 @@ func CertsExist(frankDir string) bool { keyFile := filepath.Join(frankDir, "certs", "localhost-key.pem") _, err1 := os.Stat(certFile) _, err2 := os.Stat(keyFile) + return err1 == nil && err2 == nil } @@ -111,9 +112,11 @@ func caInstalled(runner commandRunner, mkcertPath string, fs fileSystem) bool { if err != nil { return false } + caRoot := strings.TrimSpace(string(out)) if caRoot == "" { return false } + return fs.Exists(filepath.Join(caRoot, "rootCA.pem")) } diff --git a/internal/cert/cert_test.go b/internal/cert/cert_test.go index 7a75476..028b577 100644 --- a/internal/cert/cert_test.go +++ b/internal/cert/cert_test.go @@ -44,9 +44,11 @@ func TestGenerate_CertsAlreadyExist(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if !result.Skipped { t.Error("expected Skipped=true") } + if result.Generated || result.MkcertMissing || result.CANotTrusted { t.Error("expected only Skipped to be true") } @@ -66,9 +68,11 @@ func TestGenerate_MkcertNotInstalled(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if !result.MkcertMissing { t.Error("expected MkcertMissing=true") } + if result.Generated || result.Skipped { t.Error("expected only MkcertMissing to be true") } @@ -99,9 +103,11 @@ func TestGenerate_CANotTrusted(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if !result.Generated { t.Error("expected Generated=true") } + if !result.CANotTrusted { t.Error("expected CANotTrusted=true") } @@ -134,12 +140,15 @@ func TestGenerate_Success(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if !result.Generated { t.Error("expected Generated=true") } + if result.CANotTrusted { t.Error("expected CANotTrusted=false") } + if result.Skipped || result.MkcertMissing { t.Error("expected Skipped and MkcertMissing to be false") } @@ -168,9 +177,11 @@ func TestGenerate_MkcertRunFails(t *testing.T) { if err == nil { t.Fatal("expected error") } + if result.Generated || result.Skipped || result.MkcertMissing { t.Error("expected all result fields to be false on error") } + if !errors.Is(err, errors.Unwrap(err)) { // Just verify the error message contains useful info if got := err.Error(); got == "" { diff --git a/internal/compose/compose.go b/internal/compose/compose.go index 0ddfca1..7bd4eb8 100644 --- a/internal/compose/compose.go +++ b/internal/compose/compose.go @@ -57,6 +57,7 @@ func (g *Generator) Generate(cfg *config.Config, projectName, mainProjectName st if err != nil { return "", fmt.Errorf("runtime fragment: %w", err) } + if err := mergeFragment(services, runtimeFrag); err != nil { return "", fmt.Errorf("merge runtime fragment: %w", err) } @@ -76,14 +77,18 @@ func (g *Generator) Generate(cfg *config.Config, projectName, mainProjectName st if svc == "sqlite" { continue // sqlite has no compose fragment } + svcCfg := cfg.Config[svc] + frag, err := g.engine.RenderServiceCompose(svc, svcCfg, projectName, ephemeralPorts) if err != nil { return "", fmt.Errorf("service %q fragment: %w", svc, err) } + if err := mergeFragment(services, frag); err != nil { return "", fmt.Errorf("merge service %q fragment: %w", svc, err) } + if vol, ok := volumeServices[svc]; ok { volumes[vol] = map[string]interface{}{"driver": "local"} } @@ -103,6 +108,10 @@ func (g *Generator) Generate(cfg *config.Config, projectName, mainProjectName st return "", err } + // 4b. Emit the dev server sidecar (laravel.vite) when enabled. It publishes + // the Vite port that laravel.test used to own (port move). + emitVite(services, cfg, projectName, vitePort) + // 5. Validate host port uniqueness. if err := validatePorts(services); err != nil { return "", err @@ -135,10 +144,12 @@ func (g *Generator) Write(cfg *config.Config, projectName, mainProjectName, dir if err != nil { return err } + frankDir := filepath.Join(dir, ".frank") if err := os.MkdirAll(frankDir, 0755); err != nil { return fmt.Errorf("create .frank directory: %w", err) } + return os.WriteFile(filepath.Join(frankDir, "compose.yaml"), []byte(content), 0644) } @@ -148,16 +159,20 @@ func (g *Generator) Write(cfg *config.Config, projectName, mainProjectName, dir // service_started). sqlite has no compose service and is skipped. func serviceDepends(cfg *config.Config) map[string]interface{} { deps := map[string]interface{}{} + for _, svc := range cfg.Services { if svc == "sqlite" { continue } + condition := "service_started" if healthcheckedServices[svc] { condition = "service_healthy" } + deps[svc] = map[string]interface{}{"condition": condition} } + return deps } @@ -195,15 +210,19 @@ func (g *Generator) emitWorkers(services map[string]interface{}, cfg *config.Con if err != nil { return fmt.Errorf("init fragment: %w", err) } + if err := mergeFragment(services, initFrag); err != nil { return fmt.Errorf("merge init fragment: %w", err) } + injectBuild(services, "migrate", laravelBuild) + if migrateDeps := serviceDepends(cfg); len(migrateDeps) > 0 { if svc, ok := services["migrate"].(map[string]interface{}); ok { svc["depends_on"] = migrateDeps } } + if w.Schedule { frag, err := g.engine.RenderWorker("schedule", template.WorkerData{ ProjectName: projectName, @@ -211,14 +230,17 @@ func (g *Generator) emitWorkers(services map[string]interface{}, cfg *config.Con if err != nil { return fmt.Errorf("schedule worker fragment: %w", err) } + if err := mergeFragment(services, frag); err != nil { return fmt.Errorf("merge schedule worker fragment: %w", err) } + injectBuild(services, "schedule", laravelBuild) } for _, pool := range w.Queue { queuesCSV := strings.Join(pool.Queues, ",") + for i := 1; i <= pool.Count; i++ { name := fmt.Sprintf("queue.%s.%d", pool.Name, i) frag, err := g.engine.RenderWorker("queue", template.WorkerData{ @@ -232,12 +254,15 @@ func (g *Generator) emitWorkers(services map[string]interface{}, cfg *config.Con Sleep: pool.Sleep, Backoff: pool.Backoff, }) + if err != nil { return fmt.Errorf("queue worker fragment %q: %w", name, err) } + if err := mergeFragment(services, frag); err != nil { return fmt.Errorf("merge queue worker fragment %q: %w", name, err) } + injectBuild(services, name, laravelBuild) } } @@ -245,6 +270,43 @@ func (g *Generator) emitWorkers(services map[string]interface{}, cfg *config.Con return nil } +// emitVite adds the laravel.vite dev-server sidecar to services when dev is +// enabled. It reuses the laravel.test image (build-block tag-dedup, same +// mechanism as workers) and publishes the Vite port on the host. The service +// runs the package-manager dev command via sh -c; the guarded install inside +// that command is the sole node-deps installer (Frank installs them nowhere +// else). Kept inline in Go rather than templated because it's one service with +// a Go-computed command — same post-merge-injection philosophy as worker build +// blocks and depends_on. +func emitVite(services map[string]interface{}, cfg *config.Config, projectName string, vitePort int) { + if !cfg.Dev.IsEnabled() { + return + } + + cmd := cfg.Dev.EffectiveCommand(cfg.Node.PackageManager) + services["laravel.vite"] = map[string]interface{}{ + "image": fmt.Sprintf("frank-%s-laravel.test", projectName), + "container_name": "laravel.vite", + "tty": true, + "command": []interface{}{"sh", "-c", cmd}, + "volumes": []interface{}{".:/var/www/html"}, + "working_dir": "/var/www/html", + // COREPACK_ENABLE_DOWNLOAD_PROMPT=0: pnpm pinned via package.json's + // packageManager field is fetched by corepack, which otherwise prompts + // "about to download … continue?" and wedges — the container has no stdin. + "environment": []interface{}{"WWWUSER=${UID:-1000}", "COREPACK_ENABLE_DOWNLOAD_PROMPT=0"}, + "env_file": []interface{}{".env"}, + "healthcheck": map[string]interface{}{"disable": true}, + "restart": "unless-stopped", + "networks": []interface{}{"frank"}, + "ports": []interface{}{fmt.Sprintf("%d:5173", vitePort)}, + "depends_on": map[string]interface{}{ + "laravel.test": map[string]interface{}{"condition": "service_started"}, + }, + } + injectBuild(services, "laravel.vite", laravelTestBuild(services)) +} + // laravelTestBuild returns the build: block declared on the laravel.test // service, or nil if the service has no build (shouldn't happen for supported // runtimes). The returned value is safe to share across worker services — @@ -254,6 +316,7 @@ func laravelTestBuild(services map[string]interface{}) interface{} { if !ok { return nil } + return lt["build"] } @@ -263,10 +326,12 @@ func injectBuild(services map[string]interface{}, name string, laravelBuild inte if laravelBuild == nil { return } + svc, ok := services[name].(map[string]interface{}) if !ok { return } + svc["build"] = laravelBuild } @@ -277,39 +342,48 @@ func mergeFragment(services map[string]interface{}, fragment string) error { if err := yaml.Unmarshal([]byte(fragment), &parsed); err != nil { return fmt.Errorf("parse fragment YAML: %w", err) } + for name, def := range parsed { services[name] = def } + return nil } // validatePorts checks that no two services bind the same host port + protocol. func validatePorts(services map[string]interface{}) error { seen := map[string]string{} // "port/proto" → service name + for svcName, svcDef := range services { svcMap, ok := svcDef.(map[string]interface{}) if !ok { continue } + ports, ok := svcMap["ports"].([]interface{}) if !ok { continue } + for _, portEntry := range ports { portStr, ok := portEntry.(string) if !ok { continue } + key := hostPortKey(portStr) if key == "" { continue } + if prev, conflict := seen[key]; conflict { return fmt.Errorf("port conflict on %s: services %q and %q both bind the same host port", key, prev, svcName) } + seen[key] = svcName } } + return nil } @@ -321,9 +395,12 @@ func hostPortKey(mapping string) string { proto = mapping[idx+1:] mapping = mapping[:idx] } + if !strings.Contains(mapping, ":") { return "" } + host := strings.SplitN(mapping, ":", 2)[0] + return host + "/" + proto } diff --git a/internal/compose/compose_test.go b/internal/compose/compose_test.go index 23167ba..a01c322 100644 --- a/internal/compose/compose_test.go +++ b/internal/compose/compose_test.go @@ -13,7 +13,9 @@ import ( func newTestGenerator(t *testing.T) *Generator { t.Helper() + engine := template.New(os.DirFS("../..")) + return New(engine) } @@ -62,18 +64,23 @@ func TestGenerate_FPMWithMySQL(t *testing.T) { if !strings.Contains(out, "laravel.test:") { t.Error("expected laravel.test service") } + if !strings.Contains(out, "nginx:") { t.Error("expected nginx service for fpm runtime") } + if !strings.Contains(out, "mysql:") { t.Error("expected mysql service") } + if !strings.Contains(out, "redis:") { t.Error("expected redis service") } + if !strings.Contains(out, "mysql_data:") { t.Error("expected mysql_data volume") } + if !strings.Contains(out, "redis_data:") { t.Error("expected redis_data volume") } @@ -121,9 +128,11 @@ func TestGenerate_NetworkIsFrank(t *testing.T) { if err != nil { t.Fatalf("Generate error: %v", err) } + if !strings.Contains(out, "frank:") { t.Error("expected frank network") } + if strings.Contains(out, "sail:") { t.Error("unexpected sail network — should be frank") } @@ -137,6 +146,7 @@ func TestGenerate_HeaderComment(t *testing.T) { if err != nil { t.Fatalf("Generate error: %v", err) } + if !strings.HasPrefix(out, "# Generated by Frank") { t.Error("compose.yaml should start with the generated-by header") } @@ -160,6 +170,7 @@ func TestWrite_CreatesFile(t *testing.T) { if err != nil { t.Fatalf(".frank/compose.yaml not created: %v", err) } + if !strings.Contains(string(data), "laravel.test:") { t.Error("expected laravel.test in written compose.yaml") } @@ -206,13 +217,16 @@ func TestGenerate_NoWorkers_Unchanged(t *testing.T) { Laravel: config.Laravel{Version: "latest"}, Services: []string{"pgsql", "mailpit"}, } + out, err := g.Generate(cfg, "myapp", "myapp", false, 5173) if err != nil { t.Fatalf("Generate error: %v", err) } + if strings.Contains(out, "\n schedule:\n") { t.Error("no schedule worker expected when Workers.Schedule == false") } + if strings.Contains(out, "queue.") { t.Error("no queue workers expected when Workers.Queue is empty") } @@ -226,10 +240,12 @@ func TestGenerate_ScheduleOnly(t *testing.T) { Services: []string{"pgsql"}, Workers: config.Workers{Schedule: true}, } + out, err := g.Generate(cfg, "myapp", "myapp", false, 5173) if err != nil { t.Fatalf("Generate error: %v", err) } + checks := []string{ "\n schedule:\n", "frank-myapp-laravel.test", @@ -243,6 +259,7 @@ func TestGenerate_ScheduleOnly(t *testing.T) { t.Errorf("expected %q in output:\n%s", want, out) } } + if strings.Contains(out, "queue.") { t.Error("unexpected queue worker when only schedule enabled") } @@ -260,21 +277,26 @@ func TestGenerate_QueuePoolCountThree(t *testing.T) { }, }, } + out, err := g.Generate(cfg, "myapp", "myapp", false, 5173) if err != nil { t.Fatalf("Generate error: %v", err) } + for _, name := range []string{"queue.default.1:", "queue.default.2:", "queue.default.3:"} { if !strings.Contains(out, name) { t.Errorf("expected %q in output:\n%s", name, out) } } + if strings.Contains(out, "queue.default.4") { t.Error("unexpected 4th queue worker for count=3") } + if !strings.Contains(out, "frank.worker.pool: default") { t.Error("expected pool label") } + if !strings.Contains(out, "--queue=default") { t.Error("expected --queue=default flag in command") } @@ -293,10 +315,12 @@ func TestGenerate_MultiplePoolsNamed(t *testing.T) { }, }, } + out, err := g.Generate(cfg, "myapp", "myapp", false, 5173) if err != nil { t.Fatalf("Generate error: %v", err) } + for _, name := range []string{ "queue.high.1:", "queue.high.2:", @@ -306,12 +330,15 @@ func TestGenerate_MultiplePoolsNamed(t *testing.T) { t.Errorf("expected %q in output:\n%s", name, out) } } + if strings.Contains(out, "queue.high.3") { t.Error("unexpected 3rd high worker for count=2") } + if strings.Contains(out, "queue.default.2") { t.Error("unexpected 2nd default worker for count=1") } + if !strings.Contains(out, "--queue=high,critical") { t.Error("expected CSV queue list for pool with multiple queues") } @@ -328,6 +355,7 @@ func TestGenerate_FrankenPHPWorkersNoUserKey(t *testing.T) { Queue: []config.QueuePool{{Name: "default", Queues: []string{"default"}, Count: 1}}, }, } + out, err := g.Generate(cfg, "myapp", "myapp", false, 5173) if err != nil { t.Fatalf("Generate error: %v", err) @@ -348,6 +376,7 @@ func TestGenerate_FPMWorkersNoSailUser(t *testing.T) { Queue: []config.QueuePool{{Name: "default", Queues: []string{"default"}, Count: 2}}, }, } + out, err := g.Generate(cfg, "myapp", "myapp", false, 5173) if err != nil { t.Fatalf("Generate error: %v", err) @@ -375,10 +404,12 @@ func TestGenerate_QueuePassthroughFlags(t *testing.T) { }, }, } + out, err := g.Generate(cfg, "myapp", "myapp", false, 5173) if err != nil { t.Fatalf("Generate error: %v", err) } + for _, flag := range []string{"--tries=3", "--timeout=60", "--memory=128", "--sleep=5", "--backoff=2"} { if !strings.Contains(out, flag) { t.Errorf("expected passthrough flag %q in:\n%s", flag, out) @@ -398,10 +429,12 @@ func TestGenerate_QueuePassthroughOmittedWhenZero(t *testing.T) { }, }, } + out, err := g.Generate(cfg, "myapp", "myapp", false, 5173) if err != nil { t.Fatalf("Generate error: %v", err) } + for _, flag := range []string{"--tries=", "--timeout=", "--memory=", "--sleep=", "--backoff="} { if strings.Contains(out, flag) { t.Errorf("unexpected passthrough flag %q with zero value in:\n%s", flag, out) @@ -429,6 +462,7 @@ func TestGenerate_EphemeralPorts(t *testing.T) { } vitePort := 5182 + out, err := g.Generate(cfg, "worktree-test", "mainapp", true, vitePort) if err != nil { t.Fatalf("Generate error: %v", err) diff --git a/internal/compose/env.go b/internal/compose/env.go index e3bc01a..191c660 100644 --- a/internal/compose/env.go +++ b/internal/compose/env.go @@ -40,21 +40,27 @@ func (g *Generator) buildEnvLines(cfg *config.Config, projectName string, isExam if err != nil { return nil, err } + for _, svc := range cfg.Services { svcCfg := cfg.Config[svc] + rendered, err := g.engine.RenderServiceEnv(svc, svcCfg, projectName, false) if err != nil { return nil, fmt.Errorf("service %q env: %w", svc, err) } + block, err := parseEnvBlock(rendered) if err != nil { return nil, fmt.Errorf("parse service %q env block: %w", svc, err) } + lines = mergeEnvBlock(lines, svc, block) } + if isExample { lines = redactSensitive(lines) } + return lines, nil } @@ -64,6 +70,7 @@ func (g *Generator) GenerateEnv(cfg *config.Config, projectName string) (string, if err != nil { return "", err } + return marshalEnv(lines), nil } @@ -73,6 +80,7 @@ func (g *Generator) GenerateEnvExample(cfg *config.Config, projectName string) ( if err != nil { return "", err } + return marshalEnv(lines), nil } @@ -92,14 +100,17 @@ func (g *Generator) WriteEnv(cfg *config.Config, projectName, dir string) error if readErr == nil { // Existing .env — patch only frank-managed keys. envLines = parseFullEnvFile(string(existingData)) + frankLines, err := g.buildEnvLines(cfg, projectName, false) if err != nil { return err } + envLines = patchManagedKeys(envLines, frankLines) } else { // No .env — generate from scratch. var err error + envLines, err = g.buildEnvLines(cfg, projectName, false) if err != nil { return err @@ -147,15 +158,18 @@ var managedKeys = map[string]bool{ func patchManagedKeys(existing, frankLines []envLine) []envLine { // Build set of frank-managed keys: explicitly managed + all service keys. frankKeys := make(map[string]string) + for _, line := range frankLines { if line.comment || line.disabled { continue } + frankKeys[line.key] = line.value } // Build index of existing keys for fast lookup. existingIndex := make(map[string]int) + for i, line := range existing { if !line.comment { existingIndex[line.key] = i @@ -171,13 +185,16 @@ func patchManagedKeys(existing, frankLines []envLine) []envLine { copy(result, existing) var appendLines []envLine + for _, fl := range frankLines { if fl.comment || fl.disabled { continue } + if !managedKeys[fl.key] && !isServiceKey(fl.key) { continue } + if idx, ok := existingIndex[fl.key]; ok { result[idx].value = fl.value result[idx].disabled = false @@ -207,6 +224,7 @@ func isServiceKey(key string) bool { return true } } + return false } @@ -214,6 +232,7 @@ func isServiceKey(key string) bool { // prepending the Frank header and substituting APP_NAME with the project name. func (g *Generator) loadLaravelBaseEnv(cfg *config.Config, projectName string) ([]envLine, error) { templateVersion := "13.x" // default: latest + switch cfg.Laravel.Version { case "12.x": templateVersion = "12.x" @@ -237,11 +256,14 @@ func (g *Generator) loadLaravelBaseEnv(cfg *config.Config, projectName string) ( if cfg.Server.IsHTTPS() { scheme = "https" } + portSuffix := "" if cfg.Server.Port != 0 { portSuffix = fmt.Sprintf(":%d", cfg.Server.Port) } + appURL := fmt.Sprintf("%s://localhost%s", scheme, portSuffix) + for i, line := range lines { if !line.comment && !line.disabled && line.key == "APP_URL" { lines[i].value = appURL @@ -258,6 +280,7 @@ func (g *Generator) loadLaravelBaseEnv(cfg *config.Config, projectName string) ( func mergeEnvBlock(base []envLine, svc string, block []envLine) []envLine { // Index both active and disabled lines (anything with comment=false). keyIndex := map[string]int{} + for i, line := range base { if !line.comment { keyIndex[line.key] = i @@ -268,6 +291,7 @@ func mergeEnvBlock(base []envLine, svc string, block []envLine) []envLine { copy(result, base) var newLines []envLine + for _, bline := range block { if idx, exists := keyIndex[bline.key]; exists { result[idx].value = bline.value @@ -290,14 +314,17 @@ func isEnvKey(s string) bool { if s == "" { return false } + for i, c := range s { if i == 0 && !(c >= 'A' && c <= 'Z') { return false } + if i > 0 && !((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') { return false } } + return true } @@ -308,51 +335,63 @@ func isEnvKey(s string) bool { // serialisation is canonical (#KEY=value, no space). func parseFullEnvFile(content string) []envLine { var lines []envLine + for _, raw := range strings.Split(content, "\n") { if raw == "" { lines = append(lines, blank()) continue } + if strings.HasPrefix(raw, "#") { rest := raw[1:] if len(rest) > 0 && rest[0] == ' ' { rest = rest[1:] } + if eqIdx := strings.IndexByte(rest, '='); eqIdx > 0 && isEnvKey(rest[:eqIdx]) { lines = append(lines, disabled(rest[:eqIdx], rest[eqIdx+1:])) continue } + lines = append(lines, comment(raw)) + continue } + eqIdx := strings.IndexByte(raw, '=') if eqIdx < 0 { lines = append(lines, comment(raw)) continue } + lines = append(lines, kv(raw[:eqIdx], raw[eqIdx+1:])) } // trim trailing blank lines for len(lines) > 0 && lines[len(lines)-1].comment && lines[len(lines)-1].value == "" { lines = lines[:len(lines)-1] } + return lines } // parseEnvBlock converts a rendered env template string into envLine values. func parseEnvBlock(rendered string) ([]envLine, error) { var out []envLine + for _, raw := range strings.Split(strings.TrimSpace(rendered), "\n") { raw = strings.TrimSpace(raw) if raw == "" || strings.HasPrefix(raw, "#") { continue } + idx := strings.IndexByte(raw, '=') if idx < 0 { return nil, fmt.Errorf("unexpected env line %q", raw) } + out = append(out, kv(raw[:idx], raw[idx+1:])) } + return out, nil } @@ -368,17 +407,20 @@ var sensitiveKeys = map[string]bool{ func redactSensitive(lines []envLine) []envLine { out := make([]envLine, len(lines)) copy(out, lines) + for i, line := range out { if !line.comment && sensitiveKeys[line.key] { out[i].value = "" } } + return out } // marshalEnv serialises envLine values to a .env file string. func marshalEnv(lines []envLine) string { var sb strings.Builder + for _, line := range lines { if line.comment { if line.value == "" { @@ -400,5 +442,6 @@ func marshalEnv(lines []envLine) string { sb.WriteByte('\n') } } + return sb.String() } diff --git a/internal/compose/env_test.go b/internal/compose/env_test.go index fbd025e..4368627 100644 --- a/internal/compose/env_test.go +++ b/internal/compose/env_test.go @@ -83,12 +83,15 @@ func TestGenerateEnv_RedisOverridesCacheSession(t *testing.T) { if !strings.Contains(out, "REDIS_HOST=redis") { t.Error("expected REDIS_HOST=redis") } + if !strings.Contains(out, "CACHE_STORE=redis") { t.Error("expected CACHE_STORE=redis") } + if !strings.Contains(out, "SESSION_DRIVER=redis") { t.Error("expected SESSION_DRIVER=redis") } + if !strings.Contains(out, "QUEUE_CONNECTION=redis") { t.Error("expected QUEUE_CONNECTION=redis") } @@ -109,9 +112,11 @@ func TestGenerateEnv_MailpitOverridesMail(t *testing.T) { if !strings.Contains(out, "MAIL_MAILER=smtp") { t.Error("expected MAIL_MAILER=smtp") } + if !strings.Contains(out, "MAIL_HOST=mailpit") { t.Error("expected MAIL_HOST=mailpit") } + if !strings.Contains(out, "MAIL_PORT=1025") { t.Error("expected MAIL_PORT=1025") } @@ -132,6 +137,7 @@ func TestGenerateEnv_MeilisearchOverridesScout(t *testing.T) { if !strings.Contains(out, "SCOUT_DRIVER=meilisearch") { t.Error("expected SCOUT_DRIVER=meilisearch") } + if !strings.Contains(out, "MEILISEARCH_HOST=http://meilisearch:7700") { t.Error("expected MEILISEARCH_HOST") } @@ -169,6 +175,7 @@ func TestGenerateEnvExample_RedactsSensitive(t *testing.T) { if strings.Contains(out, "DB_PASSWORD=password") { t.Error(".env.example should not contain real DB_PASSWORD") } + if !strings.Contains(out, "DB_PASSWORD=") { t.Error(".env.example should still have DB_PASSWORD key") } @@ -188,6 +195,7 @@ func TestGenerateEnvExample_NoSensitiveValues(t *testing.T) { Laravel: config.Laravel{Version: "13.x"}, Services: []string{"pgsql", "redis"}, } + out, err := g.GenerateEnvExample(cfg, "myapp") if err != nil { t.Fatalf("GenerateEnvExample error: %v", err) @@ -198,6 +206,7 @@ func TestGenerateEnvExample_NoSensitiveValues(t *testing.T) { key string badValue string // a value that must NOT appear } + checks := []sensitiveCheck{ {"APP_KEY", "base64"}, {"DB_PASSWORD", "password"}, @@ -208,6 +217,7 @@ func TestGenerateEnvExample_NoSensitiveValues(t *testing.T) { if !strings.Contains(out, c.key+"=") { t.Errorf(".env.example missing key %q", c.key) } + if strings.Contains(out, c.key+"="+c.badValue) { t.Errorf(".env.example key %q must not contain value %q", c.key, c.badValue) } @@ -222,6 +232,7 @@ func TestGenerateEnvExample_StructureMatchesEnv(t *testing.T) { if err != nil { t.Fatalf("GenerateEnv error: %v", err) } + example, err := g.GenerateEnvExample(cfg, "myapp") if err != nil { t.Fatalf("GenerateEnvExample error: %v", err) @@ -230,6 +241,7 @@ func TestGenerateEnvExample_StructureMatchesEnv(t *testing.T) { // Both should have same keys envKeys := extractKeys(env) exampleKeys := extractKeys(example) + if len(envKeys) != len(exampleKeys) { t.Errorf(".env has %d keys, .env.example has %d keys", len(envKeys), len(exampleKeys)) } @@ -250,6 +262,7 @@ func TestWriteEnv_CreatesBothFiles(t *testing.T) { t.Errorf("%s not created: %v", name, err) continue } + if !strings.Contains(string(data), "APP_NAME=myapp") { t.Errorf("%s missing APP_NAME", name) } @@ -262,6 +275,7 @@ func TestGenerateEnv_Laravel12(t *testing.T) { PHP: config.PHP{Version: "8.5", Runtime: "frankenphp"}, Laravel: config.Laravel{Version: "12.x"}, } + out, err := g.GenerateEnv(cfg, "myapp") if err != nil { t.Fatalf("GenerateEnv error: %v", err) @@ -270,6 +284,7 @@ func TestGenerateEnv_Laravel12(t *testing.T) { if !strings.Contains(out, "#PHP_CLI_SERVER_WORKERS=4") { t.Error("expected #PHP_CLI_SERVER_WORKERS=4 (disabled) for Laravel 12.x") } + if strings.Contains(out, "\nPHP_CLI_SERVER_WORKERS=4") { t.Error("PHP_CLI_SERVER_WORKERS must not be active for Laravel 12.x") } @@ -281,6 +296,7 @@ func TestGenerateEnv_Laravel13(t *testing.T) { PHP: config.PHP{Version: "8.5", Runtime: "frankenphp"}, Laravel: config.Laravel{Version: "13.x"}, } + out, err := g.GenerateEnv(cfg, "myapp") if err != nil { t.Fatalf("GenerateEnv error: %v", err) @@ -301,13 +317,16 @@ func TestMarshalEnv_DisabledLine(t *testing.T) { {disabled: true, key: "DISABLED_KEY", value: "original"}, comment("# a comment"), } + out := marshalEnv(lines) if !strings.Contains(out, "ACTIVE=value\n") { t.Error("expected ACTIVE=value") } + if !strings.Contains(out, "#DISABLED_KEY=original\n") { t.Error("expected #DISABLED_KEY=original (no space)") } + if strings.Contains(out, "\nDISABLED_KEY=original\n") { t.Error("disabled key must not appear as active KEY=value") } @@ -318,6 +337,7 @@ func TestParseFullEnvFile_DisabledKey(t *testing.T) { lines := parseFullEnvFile(input) var active, disabledHost, disabledPort, pureComment envLine + for _, l := range lines { switch { case !l.comment && !l.disabled && l.key == "ACTIVE": @@ -334,12 +354,15 @@ func TestParseFullEnvFile_DisabledKey(t *testing.T) { if active.key != "ACTIVE" || active.value != "yes" { t.Error("expected active ACTIVE=yes") } + if disabledHost.key != "DB_HOST" || disabledHost.value != "127.0.0.1" { t.Errorf("expected disabled DB_HOST=127.0.0.1, got key=%q value=%q", disabledHost.key, disabledHost.value) } + if disabledPort.key != "DB_PORT" || disabledPort.value != "3306" { t.Errorf("expected disabled DB_PORT=3306, got key=%q value=%q", disabledPort.key, disabledPort.value) } + if pureComment.value != "# This is a comment" { t.Error("expected pure comment line preserved") } @@ -352,6 +375,7 @@ func TestGenerateEnv_DisabledKeyEnabled(t *testing.T) { Laravel: config.Laravel{Version: "13.x"}, Services: []string{"pgsql"}, } + out, err := g.GenerateEnv(cfg, "myapp") if err != nil { t.Fatalf("GenerateEnv error: %v", err) @@ -367,6 +391,7 @@ func TestGenerateEnv_DisabledKeyEnabled(t *testing.T) { if !strings.Contains(out, want+"\n") { t.Errorf("expected active %q in output", want) } + if strings.Contains(out, "#"+want) { t.Errorf("%q must not appear as a disabled key", want) } @@ -381,13 +406,16 @@ func TestGenerateEnv_DisabledKeyUnchanged(t *testing.T) { Laravel: config.Laravel{Version: "13.x"}, Services: []string{}, } + out, err := g.GenerateEnv(cfg, "myapp") if err != nil { t.Fatalf("GenerateEnv error: %v", err) } + if !strings.Contains(out, "#DB_HOST=127.0.0.1") { t.Error("expected #DB_HOST=127.0.0.1 (disabled) when no DB service configured") } + if strings.Contains(out, "\nDB_HOST=") { t.Error("DB_HOST must not be active when no DB service is configured") } @@ -416,6 +444,7 @@ func TestWriteEnv_AppKeyPreserved(t *testing.T) { if err != nil { t.Fatalf("read .env: %v", err) } + example, err := os.ReadFile(filepath.Join(dir, ".env.example")) if err != nil { t.Fatalf("read .env.example: %v", err) @@ -424,9 +453,11 @@ func TestWriteEnv_AppKeyPreserved(t *testing.T) { if !strings.Contains(string(env), "APP_KEY=base64:testkey==") { t.Errorf(".env should contain preserved APP_KEY, got:\n%s", string(env)) } + if strings.Contains(string(example), "APP_KEY=base64") { t.Error(".env.example must not contain real APP_KEY") } + if !strings.Contains(string(example), "APP_KEY=") { t.Error(".env.example must still contain APP_KEY= key") } @@ -474,15 +505,18 @@ REDIS_HOST=redis if err != nil { t.Fatalf("read .env: %v", err) } + out := string(env) // User custom keys must survive. if !strings.Contains(out, "STRIPE_KEY=sk_live_abc123") { t.Error("user's STRIPE_KEY was lost") } + if !strings.Contains(out, "OPENAI_API_KEY=sk-proj-xyz") { t.Error("user's OPENAI_API_KEY was lost") } + if !strings.Contains(out, "CUSTOM_FEATURE_FLAG=true") { t.Error("user's CUSTOM_FEATURE_FLAG was lost") } @@ -496,6 +530,7 @@ REDIS_HOST=redis if !strings.Contains(out, "APP_ENV=production") { t.Error("user's APP_ENV=production was overwritten") } + if !strings.Contains(out, "APP_DEBUG=false") { t.Error("user's APP_DEBUG=false was overwritten") } @@ -514,9 +549,11 @@ REDIS_HOST=redis if !strings.Contains(out, "APP_NAME=myapp") { t.Error("APP_NAME missing or wrong") } + if !strings.Contains(out, "APP_URL=https://localhost") { t.Error("APP_URL missing or wrong") } + if !strings.Contains(out, "DB_CONNECTION=pgsql") { t.Error("DB_CONNECTION missing") } @@ -543,10 +580,12 @@ func TestGenerateEnv_AppURL(t *testing.T) { PHP: config.PHP{Version: "8.5", Runtime: "frankenphp"}, Server: tt.server, } + out, err := g.GenerateEnv(cfg, "myapp") if err != nil { t.Fatalf("GenerateEnv error: %v", err) } + if !strings.Contains(out, tt.want+"\n") { t.Errorf("expected %q in output, got:\n%s", tt.want, out) } @@ -557,14 +596,17 @@ func TestGenerateEnv_AppURL(t *testing.T) { // extractKeys returns all non-comment KEY names from a .env string. func extractKeys(env string) []string { var keys []string + for _, line := range strings.Split(env, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } + if idx := strings.IndexByte(line, '='); idx > 0 { keys = append(keys, line[:idx]) } } + return keys } diff --git a/internal/compose/phpunit.go b/internal/compose/phpunit.go index 59678c6..f314268 100644 --- a/internal/compose/phpunit.go +++ b/internal/compose/phpunit.go @@ -23,6 +23,7 @@ func PatchPHPUnitXML(dir string, dbConnection string) error { if dbConnection == "" || dbConnection == "sqlite" { return nil } + return patchPHPUnit(dir, dbConnection, "testing") } @@ -38,11 +39,13 @@ func RestorePHPUnitXML(dir string) error { // DB_CONNECTION and DB_DATABASE, inserting missing lines before . func patchPHPUnit(dir string, dbConnection string, dbDatabase string) error { path := filepath.Join(dir, "phpunit.xml") + data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil } + return fmt.Errorf("read phpunit.xml: %w", err) } @@ -58,6 +61,7 @@ func patchPHPUnit(dir string, dbConnection string, dbDatabase string) error { for i, line := range lines { if m := phpunitVarRe.FindStringSubmatch(line); m != nil { tag := extractTag(line) + switch { case m[2] == "DB_CONNECTION" && tag == "env": lines[i] = m[1] + m[2] + m[3] + dbConnection + ensureForce(m[5]) @@ -73,6 +77,7 @@ func patchPHPUnit(dir string, dbConnection string, dbDatabase string) error { foundServerDB = true } } + if strings.TrimSpace(line) == "" { phpCloseIdx = i } @@ -92,12 +97,15 @@ func patchPHPUnit(dir string, dbConnection string, dbDatabase string) error { if !foundEnvConn { inserts = append(inserts, fmt.Sprintf(`%s`, indent, dbConnection)) } + if !foundEnvDB { inserts = append(inserts, fmt.Sprintf(`%s`, indent, dbDatabase)) } + if !foundServerConn { inserts = append(inserts, fmt.Sprintf(`%s`, indent, dbConnection)) } + if !foundServerDB { inserts = append(inserts, fmt.Sprintf(`%s`, indent, dbDatabase)) } @@ -118,6 +126,7 @@ func extractTag(line string) string { if strings.HasPrefix(trimmed, "`, `" force="true"/>`, 1) } diff --git a/internal/compose/phpunit_test.go b/internal/compose/phpunit_test.go index 20adb94..47cc542 100644 --- a/internal/compose/phpunit_test.go +++ b/internal/compose/phpunit_test.go @@ -12,6 +12,7 @@ func TestPatchPHPUnitXML_FileNotExist(t *testing.T) { if err := PatchPHPUnitXML(dir, "mysql"); err != nil { t.Fatalf("expected nil, got %v", err) } + if _, err := os.Stat(filepath.Join(dir, "phpunit.xml")); !os.IsNotExist(err) { t.Fatal("phpunit.xml should not be created") } @@ -30,6 +31,7 @@ func TestPatchPHPUnitXML_SqliteSkip(t *testing.T) { if err := PatchPHPUnitXML(dir, "sqlite"); err != nil { t.Fatalf("expected nil, got %v", err) } + got, _ := os.ReadFile(path) if string(got) != content { t.Fatal("file should not be modified for sqlite") @@ -49,6 +51,7 @@ func TestPatchPHPUnitXML_EmptyStringSkip(t *testing.T) { if err := PatchPHPUnitXML(dir, ""); err != nil { t.Fatalf("expected nil, got %v", err) } + got, _ := os.ReadFile(path) if string(got) != content { t.Fatal("file should not be modified for empty string") @@ -276,6 +279,7 @@ func TestRestorePHPUnitXML_FileNotExist(t *testing.T) { func requireContains(t *testing.T, haystack, needle string) { t.Helper() + if !strings.Contains(haystack, needle) { t.Errorf("expected to find %q in:\n%s", needle, haystack) } diff --git a/internal/compose/vite_test.go b/internal/compose/vite_test.go new file mode 100644 index 0000000..ad05b3f --- /dev/null +++ b/internal/compose/vite_test.go @@ -0,0 +1,108 @@ +package compose + +import ( + "strings" + "testing" + + "github.com/phlisg/frank/internal/config" +) + +func baseDevCfg() *config.Config { + return &config.Config{ + PHP: config.PHP{Version: "8.5", Runtime: "frankenphp"}, + Laravel: config.Laravel{Version: "latest"}, + Services: []string{"pgsql", "mailpit"}, + } +} + +// The emitted laravel.vite port must track the vitePort argument, not a +// hardcoded 5173:5173 — otherwise sibling worktrees collide on the host port. +func TestGenerate_VitePortFollowsArg(t *testing.T) { + g := newTestGenerator(t) + + // Non-worktree: vitePort 5173. + out, err := g.Generate(baseDevCfg(), "myapp", "myapp", false, 5173) + if err != nil { + t.Fatalf("Generate error: %v", err) + } + + if !strings.Contains(out, "laravel.vite:") { + t.Fatalf("expected laravel.vite service:\n%s", out) + } + + if !strings.Contains(out, "5173:5173") { + t.Errorf("expected 5173:5173 vite mapping:\n%s", out) + } + + // Worktree mode: an ephemeral port in 5174–5199. + out, err = g.Generate(baseDevCfg(), "wt", "wt", true, 5187) + if err != nil { + t.Fatalf("Generate error: %v", err) + } + + if !strings.Contains(out, "5187:5173") { + t.Errorf("expected 5187:5173 vite mapping:\n%s", out) + } + + if strings.Contains(out, "5173:5173") { + t.Errorf("worktree output must not bind host 5173:\n%s", out) + } +} + +// laravel.test no longer publishes 5173 — it moved to laravel.vite. +func TestGenerate_VitePortNotOnLaravelTest(t *testing.T) { + g := newTestGenerator(t) + + out, err := g.Generate(baseDevCfg(), "myapp", "myapp", false, 5173) + if err != nil { + t.Fatalf("Generate error: %v", err) + } + // laravel.test section ends before laravel.vite; its slice must lack 5173. + ltStart := strings.Index(out, "laravel.test:") + viteStart := strings.Index(out, "laravel.vite:") + + if ltStart < 0 || viteStart < 0 || viteStart < ltStart { + t.Fatalf("unexpected service ordering:\n%s", out) + } + + if strings.Contains(out[ltStart:viteStart], "5173") { + t.Errorf("laravel.test must not bind 5173:\n%s", out[ltStart:viteStart]) + } +} + +// dev.enabled: false → no laravel.vite service, port unmapped. +func TestGenerate_DevDisabledOmitsVite(t *testing.T) { + g := newTestGenerator(t) + cfg := baseDevCfg() + off := false + cfg.Dev = config.Dev{Enabled: &off} + + out, err := g.Generate(cfg, "myapp", "myapp", false, 5173) + if err != nil { + t.Fatalf("Generate error: %v", err) + } + + if strings.Contains(out, "laravel.vite:") { + t.Errorf("dev disabled: expected no laravel.vite:\n%s", out) + } + + if strings.Contains(out, "5173") { + t.Errorf("dev disabled: expected no 5173 mapping:\n%s", out) + } +} + +// The dev command derives from the package manager. +func TestGenerate_ViteCommandFromPackageManager(t *testing.T) { + g := newTestGenerator(t) + cfg := baseDevCfg() + cfg.Node = config.Node{PackageManager: "pnpm"} + + out, err := g.Generate(cfg, "myapp", "myapp", false, 5173) + if err != nil { + t.Fatalf("Generate error: %v", err) + } + + if !strings.Contains(out, "pnpm dev") { + t.Errorf("expected pnpm dev command:\n%s", out) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 320844f..08c57dd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -32,6 +32,11 @@ var knownNodeKeys = map[string]bool{ "packageManager": true, } +var knownDevKeys = map[string]bool{ + "enabled": true, + "command": true, +} + var validPHPVersions = map[string]bool{ "8.2": true, "8.3": true, @@ -116,6 +121,7 @@ type Config struct { Workers Workers `yaml:"workers"` Server Server `yaml:"server,omitempty"` Node Node `yaml:"node,omitempty"` + Dev Dev `yaml:"dev,omitempty"` Tools []string `yaml:"tools,omitempty"` Aliases map[string]Alias `yaml:"aliases,omitempty"` } @@ -135,9 +141,11 @@ func (s Server) EffectivePort() int { if s.Port != 0 { return s.Port } + if s.IsHTTPS() { return 443 } + return 80 } @@ -151,7 +159,9 @@ func (a *Alias) UnmarshalYAML(value *yaml.Node) error { a.Cmd = value.Value return nil } + type raw Alias + return value.Decode((*raw)(a)) } @@ -159,6 +169,39 @@ type Node struct { PackageManager string `yaml:"packageManager,omitempty"` } +// Dev configures the long-lived frontend dev server (Vite) run as a compose +// sidecar (laravel.vite). Enabled is a nil-pointer-defaults-true field, same +// pattern as Server.HTTPS — left nil so the round-tripped frank.yaml stays clean. +type Dev struct { + Enabled *bool `yaml:"enabled"` + Command string `yaml:"command,omitempty"` +} + +// IsEnabled reports whether the dev server is enabled (defaults true when unset). +func (d Dev) IsEnabled() bool { return d.Enabled == nil || *d.Enabled } + +// EffectiveCommand returns the shell command run inside the laravel.vite +// container. An explicit Command is trusted verbatim; otherwise it derives from +// the package manager. The install is guarded on node_modules so it runs only on +// first boot (mirrors the vendor guard in templates/workers/init.fragment.tmpl) — +// without the guard, restart: unless-stopped would re-resolve the lockfile on +// every container cycle. Frank installs node deps nowhere else, so this is the +// sole installer, not a backstop. +func (d Dev) EffectiveCommand(pm string) string { + if d.Command != "" { + return d.Command + } + + switch pm { + case "pnpm": + return "[ -d node_modules ] || pnpm install; pnpm dev" + case "bun": + return "[ -d node_modules ] || bun install; bun run dev" + default: // npm + return "[ -d node_modules ] || npm install; npm run dev" + } +} + type Workers struct { Schedule bool `yaml:"schedule,omitempty"` Queue []QueuePool `yaml:"queue,omitempty"` @@ -195,17 +238,21 @@ func IsWorktree(dir string) bool { gitDir := exec.Command("git", "rev-parse", "--git-dir") gitDir.Dir = dir gitDir.Env = env + gdOut, err := gitDir.Output() if err != nil { return false } + commonDir := exec.Command("git", "rev-parse", "--git-common-dir") commonDir.Dir = dir commonDir.Env = env + cdOut, err := commonDir.Output() if err != nil { return false } + gd := strings.TrimSpace(string(gdOut)) cd := strings.TrimSpace(string(cdOut)) // Resolve to absolute — git may return relative for one and absolute for the other. @@ -213,12 +260,15 @@ func IsWorktree(dir string) bool { if base == "" { base, _ = os.Getwd() } + if !filepath.IsAbs(gd) { gd = filepath.Join(base, gd) } + if !filepath.IsAbs(cd) { cd = filepath.Join(base, cd) } + return filepath.Clean(gd) != filepath.Clean(cd) } @@ -227,12 +277,15 @@ func IsWorktree(dir string) bool { // with git commands that need to inspect the real worktree layout. func CleanGitEnv() []string { var env []string + for _, e := range os.Environ() { if strings.HasPrefix(e, "GIT_DIR=") || strings.HasPrefix(e, "GIT_WORK_TREE=") || strings.HasPrefix(e, "GIT_INDEX_FILE=") { continue } + env = append(env, e) } + return env } @@ -242,6 +295,7 @@ func ProjectName(dir string) string { if err != nil { return filepath.Base(dir) } + return filepath.Base(abs) } @@ -252,23 +306,28 @@ func MainProjectName(dir string) string { if !IsWorktree(dir) { return ProjectName(dir) } + cmd := exec.Command("git", "rev-parse", "--git-common-dir") cmd.Dir = dir cmd.Env = CleanGitEnv() + out, err := cmd.Output() if err != nil { return ProjectName(dir) } + cd := strings.TrimSpace(string(out)) if !filepath.IsAbs(cd) { cd = filepath.Join(dir, cd) } + return filepath.Base(filepath.Dir(filepath.Clean(cd))) } // Load reads frank.yaml from dir, applies defaults, and validates. func Load(dir string) (*Config, error) { path := filepath.Join(dir, ConfigFileName) + data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("could not read %s: %w", ConfigFileName, err) @@ -287,6 +346,7 @@ func Load(dir string) (*Config, error) { warnUnknownServerKeys(&root) warnUnknownWorkerKeys(&root) warnUnknownNodeKeys(&root) + warnUnknownDevKeys(&root) // Capture explicit-empty-queues before defaulting overwrites. explicitEmptyQueues := make([]bool, len(cfg.Workers.Queue)) @@ -307,6 +367,7 @@ func Load(dir string) (*Config, error) { func New() *Config { cfg := &Config{} applyDefaults(cfg) + return cfg } @@ -314,15 +375,19 @@ func applyDefaults(cfg *Config) { if cfg.PHP.Version == "" { cfg.PHP.Version = DefaultPHPVersion } + if cfg.PHP.Runtime == "" { cfg.PHP.Runtime = DefaultPHPRuntime } + if cfg.Laravel.Version == "" { cfg.Laravel.Version = DefaultLaravelVersion } + if len(cfg.Services) == 0 { cfg.Services = append([]string{}, defaultServices...) } + if cfg.Node.PackageManager == "" { cfg.Node.PackageManager = DefaultPackageManager } @@ -336,14 +401,17 @@ func applyDefaults(cfg *Config) { cfg.Workers.Schedule = true cfg.Workers.Queue = []QueuePool{{Count: 1}} } + for i := range cfg.Workers.Queue { p := &cfg.Workers.Queue[i] if p.Queues == nil { p.Queues = []string{"default"} } + if p.Name == "" && len(p.Queues) > 0 { p.Name = p.Queues[0] } + if p.Count == 0 { p.Count = 1 } @@ -354,12 +422,15 @@ func validate(cfg *Config, explicitEmptyQueues []bool) error { if !validPHPVersions[cfg.PHP.Version] { return fmt.Errorf("unsupported PHP version %q — valid options: 8.2, 8.3, 8.4, 8.5", cfg.PHP.Version) } + if !validLaravelVersions[cfg.Laravel.Version] { return fmt.Errorf("unsupported Laravel version %q — valid options: 12.*, 13.*, latest", cfg.Laravel.Version) } + if !validRuntimes[cfg.PHP.Runtime] { return fmt.Errorf("unsupported runtime %q — valid options: frankenphp, fpm", cfg.PHP.Runtime) } + if !validPackageManagers[cfg.Node.PackageManager] { return fmt.Errorf("unsupported package manager %q — valid options: npm, pnpm, bun", cfg.Node.PackageManager) } @@ -369,14 +440,17 @@ func validate(cfg *Config, explicitEmptyQueues []bool) error { } var dbCount int + for _, svc := range cfg.Services { if !validServices[svc] { return fmt.Errorf("unsupported service %q — valid options: pgsql, mysql, mariadb, sqlite, redis, memcached, meilisearch, mailpit", svc) } + if databaseServices[svc] { dbCount++ } } + if dbCount > 1 { return fmt.Errorf("only one database service is allowed (pgsql, mysql, mariadb, sqlite) — found %d", dbCount) } @@ -394,27 +468,35 @@ func validate(cfg *Config, explicitEmptyQueues []bool) error { func validateWorkers(w *Workers, explicitEmptyQueues []bool) error { names := make(map[string]int, len(w.Queue)) + for i, p := range w.Queue { if i < len(explicitEmptyQueues) && explicitEmptyQueues[i] { return fmt.Errorf("workers.queue[%d]: queues must not be empty", i) } + if p.Count < 1 { return fmt.Errorf("workers.queue[%d] (%s): count must be ≥ 1", i, p.Name) } + if !workerPoolNameRe.MatchString(p.Name) { return fmt.Errorf("workers.queue[%d]: invalid pool name %q — must match [a-z0-9_-]+", i, p.Name) } + if p.Tries < 0 || p.Timeout < 0 || p.Memory < 0 || p.Sleep < 0 || p.Backoff < 0 { return fmt.Errorf("workers.queue[%d] (%s): passthrough values must be ≥ 0", i, p.Name) } + if slices.Contains(p.Queues, "") { return fmt.Errorf("workers.queue[%d] (%s): queue name must not be empty", i, p.Name) } + if prev, ok := names[p.Name]; ok { return fmt.Errorf("workers.queue[%d] and [%d]: duplicate pool name %q", prev, i, p.Name) } + names[p.Name] = i } + return nil } @@ -423,14 +505,17 @@ func warnUnknownServerKeys(root *yaml.Node) { if root.Kind != yaml.DocumentNode || len(root.Content) == 0 { return } + top := root.Content[0] if top.Kind != yaml.MappingNode { return } + server := mapValue(top, "server") if server == nil || server.Kind != yaml.MappingNode { return } + for i := 0; i+1 < len(server.Content); i += 2 { key := server.Content[i].Value if !knownServerKeys[key] { @@ -445,28 +530,34 @@ func warnUnknownWorkerKeys(root *yaml.Node) { if root.Kind != yaml.DocumentNode || len(root.Content) == 0 { return } + top := root.Content[0] if top.Kind != yaml.MappingNode { return } + workers := mapValue(top, "workers") if workers == nil || workers.Kind != yaml.MappingNode { return } + for i := 0; i+1 < len(workers.Content); i += 2 { key := workers.Content[i].Value if !knownWorkersKeys[key] { fmt.Fprintf(os.Stderr, "warning: unknown key %q under workers — ignored\n", key) } } + queue := mapValue(workers, "queue") if queue == nil || queue.Kind != yaml.SequenceNode { return } + for idx, item := range queue.Content { if item.Kind != yaml.MappingNode { continue } + for i := 0; i+1 < len(item.Content); i += 2 { key := item.Content[i].Value if !knownQueueItemKeys[key] { @@ -482,14 +573,17 @@ func warnUnknownNodeKeys(root *yaml.Node) { if root.Kind != yaml.DocumentNode || len(root.Content) == 0 { return } + top := root.Content[0] if top.Kind != yaml.MappingNode { return } + node := mapValue(top, "node") if node == nil || node.Kind != yaml.MappingNode { return } + for i := 0; i+1 < len(node.Content); i += 2 { key := node.Content[i].Value if !knownNodeKeys[key] { @@ -498,27 +592,59 @@ func warnUnknownNodeKeys(root *yaml.Node) { } } +// warnUnknownDevKeys emits a warning for unknown keys under the dev block, +// for forward-compat with future fields. +func warnUnknownDevKeys(root *yaml.Node) { + if root.Kind != yaml.DocumentNode || len(root.Content) == 0 { + return + } + + top := root.Content[0] + if top.Kind != yaml.MappingNode { + return + } + + dev := mapValue(top, "dev") + if dev == nil || dev.Kind != yaml.MappingNode { + return + } + + for i := 0; i+1 < len(dev.Content); i += 2 { + key := dev.Content[i].Value + if !knownDevKeys[key] { + fmt.Fprintf(os.Stderr, "warning: unknown key %q under dev — ignored\n", key) + } + } +} + func validateAliases(aliases map[string]Alias) error { seen := make(map[string]string, len(aliases)) + for name, a := range aliases { if !aliasNameRe.MatchString(name) { return fmt.Errorf("aliases.%s: invalid name — must match [a-zA-Z_][a-zA-Z0-9_-]*", name) } + if a.Cmd == "" { return fmt.Errorf("aliases.%s: cmd must not be empty", name) } + lower := strings.ToLower(name) if builtinAliasNames[lower] { return fmt.Errorf("aliases.%s: collides with built-in alias %q", name, lower) } + if prev, ok := seen[lower]; ok { return fmt.Errorf("aliases.%s: case-insensitive collision with %q", name, prev) } + seen[lower] = name + if shellBuiltins[lower] { fmt.Fprintf(os.Stderr, "warning: alias %q shadows shell builtin\n", name) } } + return nil } @@ -526,11 +652,13 @@ func mapValue(m *yaml.Node, key string) *yaml.Node { if m.Kind != yaml.MappingNode { return nil } + for i := 0; i+1 < len(m.Content); i += 2 { if m.Content[i].Value == key { return m.Content[i+1] } } + return nil } @@ -555,7 +683,9 @@ func AllServices() []string { for name := range validServices { names = append(names, name) } + sort.Strings(names) + return names } @@ -566,6 +696,7 @@ func (cfg *Config) Database() string { return svc } } + return "" } @@ -575,5 +706,6 @@ func (cfg *Config) Database() string { func ViteWorktreePort(projectName string) int { h := fnv.New32a() h.Write([]byte(projectName)) + return 5174 + int(h.Sum32()%26) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index bbdace5..971344d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -10,6 +10,7 @@ import ( func writeYAML(t *testing.T, dir, content string) { t.Helper() + if err := os.WriteFile(filepath.Join(dir, ConfigFileName), []byte(content), 0644); err != nil { t.Fatal(err) } @@ -20,24 +21,31 @@ func TestDefaults(t *testing.T) { if cfg.PHP.Version != DefaultPHPVersion { t.Errorf("PHP.Version = %q, want %q", cfg.PHP.Version, DefaultPHPVersion) } + if cfg.PHP.Runtime != DefaultPHPRuntime { t.Errorf("PHP.Runtime = %q, want %q", cfg.PHP.Runtime, DefaultPHPRuntime) } + if cfg.Laravel.Version != DefaultLaravelVersion { t.Errorf("Laravel.Version = %q, want %q", cfg.Laravel.Version, DefaultLaravelVersion) } + if len(cfg.Services) != 2 || cfg.Services[0] != "pgsql" || cfg.Services[1] != "mailpit" { t.Errorf("Services = %v, want [pgsql mailpit]", cfg.Services) } + if !cfg.Workers.Schedule { t.Error("Workers.Schedule should default to true") } + if len(cfg.Workers.Queue) != 1 { t.Fatalf("Workers.Queue len = %d, want 1", len(cfg.Workers.Queue)) } + if cfg.Workers.Queue[0].Name != "default" { t.Errorf("Workers.Queue[0].Name = %q, want default", cfg.Workers.Queue[0].Name) } + if cfg.Workers.Queue[0].Count != 1 { t.Errorf("Workers.Queue[0].Count = %d, want 1", cfg.Workers.Queue[0].Count) } @@ -51,6 +59,7 @@ func TestLoadMinimal(t *testing.T) { if err != nil { t.Fatalf("Load error: %v", err) } + if cfg.PHP.Version != DefaultPHPVersion { t.Errorf("PHP.Version = %q after minimal load", cfg.PHP.Version) } @@ -75,18 +84,23 @@ services: if err != nil { t.Fatalf("Load error: %v", err) } + if cfg.PHP.Version != "8.3" { t.Errorf("PHP.Version = %q", cfg.PHP.Version) } + if cfg.PHP.Runtime != "fpm" { t.Errorf("PHP.Runtime = %q", cfg.PHP.Runtime) } + if cfg.Laravel.Version != "12.*" { t.Errorf("Laravel.Version = %q", cfg.Laravel.Version) } + if !cfg.HasService("redis") { t.Error("expected redis service") } + if cfg.Database() != "mysql" { t.Errorf("Database() = %q, want mysql", cfg.Database()) } @@ -95,6 +109,7 @@ services: func TestValidationBadPHPVersion(t *testing.T) { dir := t.TempDir() writeYAML(t, dir, "version: 1\nphp:\n version: \"7.4\"\n") + _, err := Load(dir) if err == nil { t.Error("expected error for unsupported PHP version") @@ -104,6 +119,7 @@ func TestValidationBadPHPVersion(t *testing.T) { func TestValidationBadLaravelVersion(t *testing.T) { dir := t.TempDir() writeYAML(t, dir, "version: 1\nlaravel:\n version: \"11.*\"\n") + _, err := Load(dir) if err == nil { t.Error("expected error for unsupported Laravel version") @@ -113,6 +129,7 @@ func TestValidationBadLaravelVersion(t *testing.T) { func TestValidationBadRuntime(t *testing.T) { dir := t.TempDir() writeYAML(t, dir, "version: 1\nphp:\n runtime: apache\n") + _, err := Load(dir) if err == nil { t.Error("expected error for unsupported runtime") @@ -122,6 +139,7 @@ func TestValidationBadRuntime(t *testing.T) { func TestValidationBadService(t *testing.T) { dir := t.TempDir() writeYAML(t, dir, "version: 1\nservices:\n - mongodb\n") + _, err := Load(dir) if err == nil { t.Error("expected error for unsupported service") @@ -131,6 +149,7 @@ func TestValidationBadService(t *testing.T) { func TestValidationMultipleDatabases(t *testing.T) { dir := t.TempDir() writeYAML(t, dir, "version: 1\nservices:\n - pgsql\n - mysql\n") + _, err := Load(dir) if err == nil { t.Error("expected error for multiple databases") @@ -207,6 +226,7 @@ workers: t.Run(name, func(t *testing.T) { dir := t.TempDir() writeYAML(t, dir, yamlBody) + if _, err := Load(dir); err != nil { t.Fatalf("Load error: %v", err) } @@ -224,16 +244,20 @@ workers: count: 2 - count: 1 `) + cfg, err := Load(dir) if err != nil { t.Fatalf("Load error: %v", err) } + if got := cfg.Workers.Queue[0].Name; got != "mail" { t.Errorf("pool[0].Name = %q, want mail", got) } + if got := cfg.Workers.Queue[1].Name; got != "default" { t.Errorf("pool[1].Name = %q, want default (derived from queues default)", got) } + if got := cfg.Workers.Queue[1].Queues; len(got) != 1 || got[0] != "default" { t.Errorf("pool[1].Queues = %v, want [default]", got) } @@ -247,10 +271,12 @@ workers: queue: - queues: [default] `) + cfg, err := Load(dir) if err != nil { t.Fatalf("Load error: %v", err) } + if got := cfg.Workers.Queue[0].Count; got != 1 { t.Errorf("Count = %d, want 1", got) } @@ -310,6 +336,7 @@ workers: t.Run(name, func(t *testing.T) { dir := t.TempDir() writeYAML(t, dir, yamlBody) + if _, err := Load(dir); err == nil { t.Errorf("expected error for %s", name) } @@ -329,17 +356,23 @@ workers: count: 1 unknownField: foo `) + r, w, _ := os.Pipe() oldStderr := os.Stderr os.Stderr = w _, err := Load(dir) + w.Close() + os.Stderr = oldStderr + if err != nil { t.Fatalf("Load error: %v", err) } + buf := make([]byte, 4096) n, _ := r.Read(buf) + out := string(buf[:n]) if !containsAll(out, "futureThing", "unknownField") { t.Errorf("expected warnings for unknown keys, got: %q", out) @@ -349,16 +382,19 @@ workers: func containsAll(s string, subs ...string) bool { for _, sub := range subs { found := false + for i := 0; i+len(sub) <= len(s); i++ { if s[i:i+len(sub)] == sub { found = true break } } + if !found { return false } } + return true } @@ -374,10 +410,12 @@ func TestPackageManagerValid(t *testing.T) { t.Run(pm, func(t *testing.T) { dir := t.TempDir() writeYAML(t, dir, "version: 1\nnode:\n packageManager: "+pm+"\n") + cfg, err := Load(dir) if err != nil { t.Fatalf("Load error: %v", err) } + if cfg.Node.PackageManager != pm { t.Errorf("Node.PackageManager = %q, want %q", cfg.Node.PackageManager, pm) } @@ -390,6 +428,7 @@ func TestPackageManagerInvalid(t *testing.T) { t.Run(pm, func(t *testing.T) { dir := t.TempDir() writeYAML(t, dir, "version: 1\nnode:\n packageManager: "+pm+"\n") + if _, err := Load(dir); err == nil { t.Errorf("expected error for package manager %q", pm) } @@ -405,17 +444,23 @@ node: packageManager: pnpm futureThing: yes `) + r, w, _ := os.Pipe() oldStderr := os.Stderr os.Stderr = w _, err := Load(dir) + w.Close() + os.Stderr = oldStderr + if err != nil { t.Fatalf("Load error: %v", err) } + buf := make([]byte, 4096) n, _ := r.Read(buf) + out := string(buf[:n]) if !containsAll(out, "futureThing") { t.Errorf("expected warning for unknown node key, got: %q", out) @@ -427,6 +472,7 @@ func TestServerDefaults(t *testing.T) { if !cfg.Server.IsHTTPS() { t.Error("Server.IsHTTPS() should default to true") } + if got := cfg.Server.EffectivePort(); got != 443 { t.Errorf("Server.EffectivePort() = %d, want 443", got) } @@ -439,13 +485,16 @@ version: 1 server: https: false `) + cfg, err := Load(dir) if err != nil { t.Fatalf("Load error: %v", err) } + if cfg.Server.IsHTTPS() { t.Error("Server.IsHTTPS() should be false when explicitly set") } + if got := cfg.Server.EffectivePort(); got != 80 { t.Errorf("Server.EffectivePort() = %d, want 80", got) } @@ -459,13 +508,16 @@ server: https: true port: 4433 `) + cfg, err := Load(dir) if err != nil { t.Fatalf("Load error: %v", err) } + if !cfg.Server.IsHTTPS() { t.Error("Server.IsHTTPS() should be true") } + if got := cfg.Server.EffectivePort(); got != 4433 { t.Errorf("Server.EffectivePort() = %d, want 4433", got) } @@ -478,6 +530,7 @@ version: 1 server: port: 99999 `) + _, err := Load(dir) if err == nil { t.Error("expected error for invalid port 99999") @@ -492,17 +545,23 @@ server: https: true futureThing: yes `) + r, w, _ := os.Pipe() oldStderr := os.Stderr os.Stderr = w _, err := Load(dir) + w.Close() + os.Stderr = oldStderr + if err != nil { t.Fatalf("Load error: %v", err) } + buf := make([]byte, 4096) n, _ := r.Read(buf) + out := string(buf[:n]) if !containsAll(out, "futureThing") { t.Errorf("expected warning for unknown server key, got: %q", out) @@ -514,6 +573,7 @@ func TestHasService(t *testing.T) { if !cfg.HasService("pgsql") { t.Error("HasService(pgsql) = false") } + if cfg.HasService("redis") { t.Error("HasService(redis) = true") } @@ -526,17 +586,21 @@ version: 1 aliases: lint: "vendor/bin/pint" `) + cfg, err := Load(dir) if err != nil { t.Fatalf("Load error: %v", err) } + a, ok := cfg.Aliases["lint"] if !ok { t.Fatal("alias 'lint' not found") } + if a.Cmd != "vendor/bin/pint" { t.Errorf("Cmd = %q, want %q", a.Cmd, "vendor/bin/pint") } + if a.Host { t.Error("Host should be false for string shorthand") } @@ -551,17 +615,21 @@ aliases: cmd: "code ." host: true `) + cfg, err := Load(dir) if err != nil { t.Fatalf("Load error: %v", err) } + a, ok := cfg.Aliases["code"] if !ok { t.Fatal("alias 'code' not found") } + if a.Cmd != "code ." { t.Errorf("Cmd = %q, want %q", a.Cmd, "code .") } + if !a.Host { t.Error("Host should be true for map form with host: true") } @@ -574,6 +642,7 @@ version: 1 aliases: artisan: "php artisan" `) + _, err := Load(dir) if err == nil { t.Error("expected error for builtin collision with 'artisan'") @@ -587,6 +656,7 @@ version: 1 aliases: Artisan: "php artisan" `) + _, err := Load(dir) if err == nil { t.Error("expected error for case-insensitive collision with builtin 'artisan'") @@ -615,6 +685,7 @@ aliases: t.Run(name, func(t *testing.T) { dir := t.TempDir() writeYAML(t, dir, yamlBody) + if _, err := Load(dir); err == nil { t.Errorf("expected error for invalid alias name: %s", name) } @@ -630,6 +701,7 @@ aliases: lint: cmd: "" `) + _, err := Load(dir) if err == nil { t.Error("expected error for empty cmd") @@ -662,6 +734,7 @@ func TestViteWorktreePort(t *testing.T) { for _, name := range inputs[:5] { seen[ViteWorktreePort(name)] = true } + if len(seen) < 3 { t.Errorf("expected at least 3 distinct ports from 5 inputs, got %d", len(seen)) } @@ -680,10 +753,12 @@ aliases: host: true _internal: "some-cmd" `) + cfg, err := Load(dir) if err != nil { t.Fatalf("Load error: %v", err) } + if len(cfg.Aliases) != 4 { t.Errorf("Aliases count = %d, want 4", len(cfg.Aliases)) } @@ -698,6 +773,7 @@ func TestIsWorktree(t *testing.T) { cleanEnv := os.Environ() filtered := cleanEnv[:0] + for _, e := range cleanEnv { if !strings.HasPrefix(e, "GIT_DIR=") && !strings.HasPrefix(e, "GIT_WORK_TREE=") && !strings.HasPrefix(e, "GIT_INDEX_FILE=") { filtered = append(filtered, e) @@ -706,9 +782,11 @@ func TestIsWorktree(t *testing.T) { run := func(dir string, args ...string) { t.Helper() + c := exec.Command("git", args...) c.Dir = dir c.Env = filtered + if out, err := c.CombinedOutput(); err != nil { t.Fatalf("git %v: %s (%v)", args, out, err) } @@ -725,9 +803,11 @@ func TestIsWorktree(t *testing.T) { if IsWorktree(main) { t.Error("main repo reported as worktree") } + if !IsWorktree(wt) { t.Error("worktree not detected") } + if IsWorktree(t.TempDir()) { t.Error("non-git dir reported as worktree") } diff --git a/internal/config/dev_test.go b/internal/config/dev_test.go new file mode 100644 index 0000000..0457f9c --- /dev/null +++ b/internal/config/dev_test.go @@ -0,0 +1,98 @@ +package config + +import ( + "os" + "strings" + "testing" +) + +func TestDevIsEnabled(t *testing.T) { + tr, fa := true, false + + cases := []struct { + name string + in *bool + want bool + }{ + {"nil defaults true", nil, true}, + {"explicit true", &tr, true}, + {"explicit false", &fa, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := (Dev{Enabled: c.in}).IsEnabled(); got != c.want { + t.Errorf("IsEnabled() = %v, want %v", got, c.want) + } + }) + } +} + +func TestDevEffectiveCommand(t *testing.T) { + cases := []struct { + pm string + want string + }{ + {"npm", "[ -d node_modules ] || npm install; npm run dev"}, + {"pnpm", "[ -d node_modules ] || pnpm install; pnpm dev"}, + {"bun", "[ -d node_modules ] || bun install; bun run dev"}, + {"", "[ -d node_modules ] || npm install; npm run dev"}, // unset → npm + } + for _, c := range cases { + t.Run(c.pm, func(t *testing.T) { + if got := (Dev{}).EffectiveCommand(c.pm); got != c.want { + t.Errorf("EffectiveCommand(%q) = %q, want %q", c.pm, got, c.want) + } + }) + } +} + +func TestDevEffectiveCommandVerbatim(t *testing.T) { + d := Dev{Command: "vite --host"} + // Explicit command is trusted verbatim, ignoring the package manager. + if got := d.EffectiveCommand("pnpm"); got != "vite --host" { + t.Errorf("EffectiveCommand = %q, want verbatim override", got) + } +} + +// New() must NOT materialize Dev.Enabled — keeping it nil avoids changing the +// round-tripped frank.yaml. IsEnabled still resolves it to true. +func TestDevDefaultNotMaterialized(t *testing.T) { + cfg := New() + if cfg.Dev.Enabled != nil { + t.Errorf("Dev.Enabled should stay nil after defaults, got %v", *cfg.Dev.Enabled) + } + + if !cfg.Dev.IsEnabled() { + t.Error("Dev.IsEnabled() should default true") + } +} + +func TestDevUnknownKeyWarning(t *testing.T) { + dir := t.TempDir() + writeYAML(t, dir, ` +version: 1 +dev: + enabled: true + futureThing: yes +`) + + r, w, _ := os.Pipe() + oldStderr := os.Stderr + os.Stderr = w + _, err := Load(dir) + + w.Close() + + os.Stderr = oldStderr + + if err != nil { + t.Fatalf("Load error: %v", err) + } + + buf := make([]byte, 4096) + n, _ := r.Read(buf) + + if out := string(buf[:n]); !strings.Contains(out, "futureThing") { + t.Errorf("expected warning for unknown dev key, got: %q", out) + } +} diff --git a/internal/docker/docker.go b/internal/docker/docker.go index d488176..341d9af 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -47,6 +47,7 @@ func CheckDependencies() error { if err != nil { return errors.New("docker compose plugin not found — please install Docker Compose: https://docs.docker.com/compose/install/") } + if !strings.Contains(string(out), "Docker Compose") { return errors.New("unexpected output from 'docker compose version' — please ensure Docker Compose v2 is installed") } @@ -66,6 +67,7 @@ func (c *Client) Run(args ...string) error { cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin + return runCmd(cmd) } @@ -76,6 +78,7 @@ func (c *Client) RunStream(w io.Writer, args ...string) error { cmd := c.composeCmd(args...) cmd.Stdout = w cmd.Stderr = w + return runCmd(cmd) } @@ -83,10 +86,12 @@ func (c *Client) RunStream(w io.Writer, args ...string) error { // returning it as a string. Used for state detection (e.g. ps). func (c *Client) RunQuiet(args ...string) (string, error) { cmd := c.composeCmd(args...) + var buf bytes.Buffer cmd.Stdout = &buf cmd.Stderr = &buf err := runCmd(cmd) + return buf.String(), err } @@ -136,17 +141,23 @@ func (c *Client) RunAdhoc(name string, labels map[string]string, cmdArgs []strin for k := range labels { keys = append(keys, k) } + sort.Strings(keys) + for _, k := range keys { args = append(args, "--label", k+"="+labels[k]) } + args = append(args, "laravel.test") args = append(args, cmdArgs...) + if err := c.Run(args...); err != nil { return err } + cmd := exec.Command("docker", "update", "--restart=unless-stopped", name) cmd.Dir = c.dir + return runCmd(cmd) } @@ -156,11 +167,13 @@ func (c *Client) StopContainers(names []string) error { if len(names) == 0 { return nil } + args := append([]string{"rm", "-f"}, names...) cmd := exec.Command("docker", args...) cmd.Dir = c.dir cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr + return runCmd(cmd) } @@ -178,17 +191,22 @@ func (c *Client) ListContainers(projectName string, workerFilter string, format } else { args = append(args, "--filter", "label=frank.worker="+workerFilter) } + if format != "" { args = append(args, "--format", format) } + cmd := exec.Command("docker", args...) cmd.Dir = c.dir + var buf bytes.Buffer cmd.Stdout = &buf cmd.Stderr = os.Stderr + if err := runCmd(cmd); err != nil { return "", err } + return buf.String(), nil } @@ -204,16 +222,20 @@ func (c *Client) AdhocWorkerNames(projectName string) ([]string, error) { } cmd := exec.Command("docker", args...) cmd.Dir = c.dir + var buf bytes.Buffer cmd.Stdout = &buf cmd.Stderr = os.Stderr + if err := runCmd(cmd); err != nil { return nil, err } + out := strings.TrimSpace(buf.String()) if out == "" { return nil, nil } + return strings.Split(out, "\n"), nil } @@ -239,28 +261,36 @@ func (c *Client) ListAdhocWorkersWithIDs(projectName string) ([]AdhocWorker, err } cmd := exec.Command("docker", args...) cmd.Dir = c.dir + var buf bytes.Buffer cmd.Stdout = &buf cmd.Stderr = os.Stderr + if err := runCmd(cmd); err != nil { return nil, err } + out := strings.TrimSpace(buf.String()) if out == "" { return nil, nil } + var workers []AdhocWorker + for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if line == "" { continue } + fields := strings.Fields(line) if len(fields) < 2 { continue } + workers = append(workers, AdhocWorker{ID: fields[0], Name: fields[1]}) } + return workers, nil } @@ -277,9 +307,11 @@ func (c *Client) InspectContainer(name string) (string, int, string, error) { name, ) cmd.Dir = c.dir + var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { // `docker inspect` on a missing container exits non-zero with // "No such object" on stderr. Treat that as a missing container, @@ -287,18 +319,24 @@ func (c *Client) InspectContainer(name string) (string, int, string, error) { if strings.Contains(strings.ToLower(stderr.String()), "no such object") { return "", 0, "", nil } + return "", 0, "", fmt.Errorf("docker inspect %s: %w: %s", name, err, strings.TrimSpace(stderr.String())) } + fields := strings.Fields(strings.TrimSpace(stdout.String())) if len(fields) < 3 { return "", 0, "", fmt.Errorf("docker inspect %s: unexpected output %q", name, stdout.String()) } + status := fields[0] + var exitCode int if _, err := fmt.Sscanf(fields[1], "%d", &exitCode); err != nil { return "", 0, "", fmt.Errorf("docker inspect %s: parse exit code %q: %w", name, fields[1], err) } + id := fields[2] + return status, exitCode, id, nil } @@ -309,7 +347,9 @@ func (c *Client) LogsForWorkers(services []string, follow bool) error { if follow { args = append(args, "-f") } + args = append(args, services...) + return c.Run(args...) } @@ -321,6 +361,7 @@ func (c *Client) ComposePSServiceExists(name string) bool { if err != nil { return false } + return strings.TrimSpace(out) != "" } @@ -332,12 +373,14 @@ func (c *Client) LogsRaw(name string, follow bool) error { if follow { args = append(args, "-f") } + args = append(args, name) cmd := exec.Command("docker", args...) cmd.Dir = c.dir cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin + return runCmd(cmd) } @@ -350,6 +393,7 @@ func (c *Client) LogsRawPrefixed(name string, follow bool) error { if follow { args = append(args, "-f") } + args = append(args, name) cmd := exec.Command("docker", args...) cmd.Dir = c.dir @@ -358,20 +402,26 @@ func (c *Client) LogsRawPrefixed(name string, follow bool) error { if err != nil { return err } + stderr, err := cmd.StderrPipe() if err != nil { return err } + if err := cmd.Start(); err != nil { return err } var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); copyWithPrefix(os.Stdout, stdout, name) }() go func() { defer wg.Done(); copyWithPrefix(os.Stderr, stderr, name) }() + waitErr := cmd.Wait() wg.Wait() + return waitErr } @@ -380,6 +430,7 @@ func (c *Client) LogsRawPrefixed(name string, follow bool) error { func copyWithPrefix(dst io.Writer, src io.Reader, name string) { sc := bufio.NewScanner(src) sc.Buffer(make([]byte, 64*1024), 1024*1024) + for sc.Scan() { fmt.Fprintf(dst, "%s | %s\n", name, sc.Text()) } @@ -392,6 +443,7 @@ func (c *Client) ContainerStatus() (ContainerState, int, int) { if err != nil { return StateStopped, 0, 0 } + return parseContainerStatus(out) } @@ -404,8 +456,10 @@ func (c *Client) WaitForContainer(service string, timeout time.Duration) error { if _, err := c.ExecQuiet(service, "true"); err == nil { return nil } + time.Sleep(2 * time.Second) } + return fmt.Errorf("container %q did not become ready within %s", service, timeout) } @@ -417,11 +471,14 @@ func parseContainerStatus(out string) (ContainerState, int, int) { } var running, total int + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { if strings.TrimSpace(line) == "" { continue } + total++ + if strings.Contains(line, `"State":"running"`) || strings.Contains(line, `"Status":"running"`) { running++ } @@ -430,9 +487,11 @@ func parseContainerStatus(out string) (ContainerState, int, int) { if running == 0 { return StateStopped, 0, 0 } + if running == total { return StateRunning, running, total } + return StatePartial, running, total } @@ -442,6 +501,7 @@ func (c *Client) composeCmd(args ...string) *exec.Cmd { cmdArgs := append(prefix, args...) cmd := exec.Command("docker", cmdArgs...) cmd.Dir = c.dir + return cmd } @@ -452,7 +512,9 @@ func runCmd(cmd *exec.Cmd) error { if errors.As(err, &exitErr) { return fmt.Errorf("command exited with code %d", exitErr.ExitCode()) } + return err } + return nil } diff --git a/internal/docker/docker_test.go b/internal/docker/docker_test.go index 3e01140..a06a611 100644 --- a/internal/docker/docker_test.go +++ b/internal/docker/docker_test.go @@ -23,9 +23,11 @@ func TestComposeCmd_Args(t *testing.T) { // Args[0] is the binary path; check the rest. args := cmd.Args[1:] // skip binary name want := []string{"compose", "--project-directory", ".", "-f", ".frank/compose.yaml", "up", "-d", "--build"} + if len(args) != len(want) { t.Fatalf("args = %v, want %v", args, want) } + for i, a := range args { if a != want[i] { t.Errorf("args[%d] = %q, want %q", i, a, want[i]) @@ -35,6 +37,7 @@ func TestComposeCmd_Args(t *testing.T) { func TestComposeCmd_Dir(t *testing.T) { c := New("/my/project") + cmd := c.composeCmd("ps") if cmd.Dir != "/my/project" { t.Errorf("cmd.Dir = %q, want /my/project", cmd.Dir) @@ -50,10 +53,12 @@ func TestRunCmd_Success(t *testing.T) { func TestRunCmd_Failure(t *testing.T) { cmd := exec.Command("false") + err := runCmd(cmd) if err == nil { t.Error("expected error for 'false'") } + if !strings.Contains(err.Error(), "code 1") { t.Errorf("expected exit code in error, got: %v", err) } @@ -64,9 +69,11 @@ func TestUp_NoArgs(t *testing.T) { cmd := c.composeCmd(upArgs()...) args := cmd.Args[1:] want := []string{"compose", "--project-directory", ".", "-f", ".frank/compose.yaml", "up"} + if len(args) != len(want) { t.Fatalf("args = %v, want %v", args, want) } + for i, a := range args { if a != want[i] { t.Errorf("args[%d] = %q, want %q", i, a, want[i]) @@ -79,9 +86,11 @@ func TestUp_WithDetach(t *testing.T) { cmd := c.composeCmd(upArgs("-d")...) args := cmd.Args[1:] want := []string{"compose", "--project-directory", ".", "-f", ".frank/compose.yaml", "up", "-d"} + if len(args) != len(want) { t.Fatalf("args = %v, want %v", args, want) } + for i, a := range args { if a != want[i] { t.Errorf("args[%d] = %q, want %q", i, a, want[i]) @@ -139,9 +148,11 @@ func TestContainerStatus_ParseRunning(t *testing.T) { if state != tc.wantState { t.Errorf("state = %v, want %v", state, tc.wantState) } + if running != tc.wantRunning { t.Errorf("running = %d, want %d", running, tc.wantRunning) } + if total != tc.wantTotal { t.Errorf("total = %d, want %d", total, tc.wantTotal) } diff --git a/internal/mcp/handlers.go b/internal/mcp/handlers.go index cf0565a..9de3f82 100644 --- a/internal/mcp/handlers.go +++ b/internal/mcp/handlers.go @@ -39,14 +39,17 @@ func (h *handlers) handleStatus(_ context.Context, _ mcp.CallToolRequest) (*mcp. // docker compose ps --format json outputs one JSON object per line var services []json.RawMessage + for _, line := range splitLines(out) { if line == "" { continue } + var obj map[string]any if err := json.Unmarshal([]byte(line), &obj); err != nil { continue } + compact := map[string]any{ "Name": obj["Name"], "State": obj["State"], @@ -60,6 +63,7 @@ func (h *handlers) handleStatus(_ context.Context, _ mcp.CallToolRequest) (*mcp. response := map[string]any{ "services": services, } + if config.IsWorktree(h.dir) { projectName := config.ProjectName(h.dir) response["worktree"] = map[string]any{ @@ -69,6 +73,7 @@ func (h *handlers) handleStatus(_ context.Context, _ mcp.CallToolRequest) (*mcp. } result, _ := json.MarshalIndent(response, "", " ") + return textResult(string(result)), nil } @@ -77,6 +82,7 @@ func (h *handlers) handleConfig(_ context.Context, _ mcp.CallToolRequest) (*mcp. if err != nil { return errorResult(fmt.Sprintf(`{"error": %q}`, err.Error())), nil } + return textResult(string(b)), nil } @@ -93,6 +99,7 @@ func (h *handlers) handleLogs(_ context.Context, req mcp.CallToolRequest) (*mcp. if err != nil { return errorResult(err.Error()), nil } + return textResult(out), nil } @@ -101,6 +108,7 @@ func (h *handlers) handleExec(_ context.Context, req mcp.CallToolRequest) (*mcp. if err != nil { return errorResult(err.Error()), nil } + if len(command) == 0 { return errorResult("command array must not be empty"), nil } @@ -111,6 +119,7 @@ func (h *handlers) handleExec(_ context.Context, req mcp.CallToolRequest) (*mcp. if err != nil { return errorResult(err.Error()), nil } + return textResult(out), nil } @@ -123,6 +132,7 @@ func (h *handlers) handleWorktrees(_ context.Context, req mcp.CallToolRequest) ( if err != nil { return errorResult(fmt.Sprintf("discover: %v", err)), nil } + type wtJSON struct { Path string `json:"path"` Branch string `json:"branch"` @@ -130,6 +140,7 @@ func (h *handlers) handleWorktrees(_ context.Context, req mcp.CallToolRequest) ( Status string `json:"status"` Ports string `json:"ports,omitempty"` } + var result []wtJSON for _, item := range items { result = append(result, wtJSON{ @@ -140,7 +151,9 @@ func (h *handlers) handleWorktrees(_ context.Context, req mcp.CallToolRequest) ( Ports: item.PortSummary(), }) } + b, _ := json.MarshalIndent(result, "", " ") + return textResult(string(b)), nil case "remove": @@ -148,21 +161,27 @@ func (h *handlers) handleWorktrees(_ context.Context, req mcp.CallToolRequest) ( if path == "" { return errorResult("path required for remove"), nil } + absPath, err := filepath.Abs(path) if err != nil { return errorResult(fmt.Sprintf("resolve path: %v", err)), nil } + items, _ := worktreelist.Discover(h.dir) + var branch string + for _, item := range items { if item.Path == absPath { branch = item.Branch break } } + if err := worktreelist.RemoveWorktree(absPath, branch); err != nil { return errorResult(fmt.Sprintf("remove: %v", err)), nil } + return textResult(fmt.Sprintf("removed worktree %s", absPath)), nil case "create": @@ -170,13 +189,16 @@ func (h *handlers) handleWorktrees(_ context.Context, req mcp.CallToolRequest) ( if branch == "" { return errorResult("branch required for create"), nil } + projectName := config.ProjectName(h.dir) kebab := worktreelist.BranchToKebab(branch) parentDir := filepath.Dir(h.dir) + wtPath := filepath.Join(parentDir, projectName+"-"+kebab) if err := worktreelist.CreateWorktree(h.dir, wtPath, branch); err != nil { return errorResult(fmt.Sprintf("create: %v", err)), nil } + return textResult(fmt.Sprintf("created worktree at %s", wtPath)), nil default: @@ -187,15 +209,19 @@ func (h *handlers) handleWorktrees(_ context.Context, req mcp.CallToolRequest) ( // splitLines splits a string into non-empty lines. func splitLines(s string) []string { var lines []string + start := 0 + for i := 0; i < len(s); i++ { if s[i] == '\n' { lines = append(lines, s[start:i]) start = i + 1 } } + if start < len(s) { lines = append(lines, s[start:]) } + return lines } diff --git a/internal/mcp/handlers_test.go b/internal/mcp/handlers_test.go index 9fb3fb9..7c98583 100644 --- a/internal/mcp/handlers_test.go +++ b/internal/mcp/handlers_test.go @@ -26,18 +26,22 @@ func (m *mockDocker) ExecQuiet(service string, command ...string) (string, error func makeRequest(args map[string]any) mcplib.CallToolRequest { req := mcplib.CallToolRequest{} req.Params.Arguments = args + return req } func contentText(t *testing.T, result *mcplib.CallToolResult) string { t.Helper() + if len(result.Content) == 0 { t.Fatal("expected at least one content block") } + tc, ok := result.Content[0].(mcplib.TextContent) if !ok { t.Fatalf("expected TextContent, got %T", result.Content[0]) } + return tc.Text } @@ -57,6 +61,7 @@ func TestHandleStatus_Success(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if result.IsError { t.Fatal("expected IsError=false") } @@ -72,6 +77,7 @@ func TestHandleStatus_Success(t *testing.T) { if !ok { t.Fatal("expected services array") } + if len(svcList) != 2 { t.Fatalf("expected 2 services, got %d", len(svcList)) } @@ -89,12 +95,15 @@ func TestHandleStatus_Success(t *testing.T) { if services[0]["Name"] != "laravel.test" { t.Errorf("expected Name=laravel.test, got %v", services[0]["Name"]) } + if services[0]["State"] != "running" { t.Errorf("expected State=running, got %v", services[0]["State"]) } + if services[0]["Health"] != "" { t.Errorf("expected Health='', got %v", services[0]["Health"]) } + publishers, ok := services[0]["Publishers"].([]any) if !ok || len(publishers) == 0 { t.Error("expected non-empty Publishers for laravel.test") @@ -103,6 +112,7 @@ func TestHandleStatus_Success(t *testing.T) { if services[1]["Name"] != "pgsql" { t.Errorf("expected Name=pgsql, got %v", services[1]["Name"]) } + if services[1]["Health"] != "healthy" { t.Errorf("expected Health=healthy, got %v", services[1]["Health"]) } @@ -121,6 +131,7 @@ func TestHandleStatus_DockerError(t *testing.T) { if err != nil { t.Fatalf("unexpected Go error: %v", err) } + if !result.IsError { t.Fatal("expected IsError=true") } @@ -143,6 +154,7 @@ func TestHandleConfig(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if result.IsError { t.Fatal("expected IsError=false") } @@ -158,9 +170,11 @@ func TestHandleConfig(t *testing.T) { if !ok { t.Fatal("expected PHP key in config") } + if php["Version"] != "8.4" { t.Errorf("expected PHP.Version=8.4, got %v", php["Version"]) } + if php["Runtime"] != "frankenphp" { t.Errorf("expected PHP.Runtime=frankenphp, got %v", php["Runtime"]) } @@ -169,6 +183,7 @@ func TestHandleConfig(t *testing.T) { if !ok { t.Fatal("expected Services array in config") } + if len(services) != 2 { t.Errorf("expected 2 services, got %d", len(services)) } @@ -176,6 +191,7 @@ func TestHandleConfig(t *testing.T) { func TestHandleLogs_AllServices(t *testing.T) { var captured []string + h := &handlers{ client: &mockDocker{ runQuietFn: func(args ...string) (string, error) { @@ -186,10 +202,12 @@ func TestHandleLogs_AllServices(t *testing.T) { } req := makeRequest(nil) + result, err := h.handleLogs(context.Background(), req) if err != nil { t.Fatalf("unexpected error: %v", err) } + if result.IsError { t.Fatal("expected IsError=false") } @@ -198,6 +216,7 @@ func TestHandleLogs_AllServices(t *testing.T) { if len(captured) != 3 { t.Fatalf("expected 3 args, got %d: %v", len(captured), captured) } + if captured[0] != "logs" || captured[1] != "--tail" || captured[2] != "50" { t.Errorf("expected [logs --tail 50], got %v", captured) } @@ -210,6 +229,7 @@ func TestHandleLogs_AllServices(t *testing.T) { func TestHandleLogs_SpecificService(t *testing.T) { var captured []string + h := &handlers{ client: &mockDocker{ runQuietFn: func(args ...string) (string, error) { @@ -223,10 +243,12 @@ func TestHandleLogs_SpecificService(t *testing.T) { "service": "pgsql", "lines": float64(100), }) + result, err := h.handleLogs(context.Background(), req) if err != nil { t.Fatalf("unexpected error: %v", err) } + if result.IsError { t.Fatal("expected IsError=false") } @@ -234,6 +256,7 @@ func TestHandleLogs_SpecificService(t *testing.T) { if len(captured) != 4 { t.Fatalf("expected 4 args, got %d: %v", len(captured), captured) } + if captured[0] != "logs" || captured[1] != "--tail" || captured[2] != "100" || captured[3] != "pgsql" { t.Errorf("expected [logs --tail 100 pgsql], got %v", captured) } @@ -241,7 +264,9 @@ func TestHandleLogs_SpecificService(t *testing.T) { func TestHandleExec_Default(t *testing.T) { var capturedService string + var capturedCmd []string + h := &handlers{ client: &mockDocker{ execQuietFn: func(service string, command ...string) (string, error) { @@ -255,10 +280,12 @@ func TestHandleExec_Default(t *testing.T) { req := makeRequest(map[string]any{ "command": []any{"php", "artisan", "migrate"}, }) + result, err := h.handleExec(context.Background(), req) if err != nil { t.Fatalf("unexpected error: %v", err) } + if result.IsError { t.Fatal("expected IsError=false") } @@ -266,6 +293,7 @@ func TestHandleExec_Default(t *testing.T) { if capturedService != "laravel.test" { t.Errorf("expected service=laravel.test, got %q", capturedService) } + if len(capturedCmd) != 3 || capturedCmd[0] != "php" || capturedCmd[1] != "artisan" || capturedCmd[2] != "migrate" { t.Errorf("expected [php artisan migrate], got %v", capturedCmd) } @@ -278,7 +306,9 @@ func TestHandleExec_Default(t *testing.T) { func TestHandleExec_CustomService(t *testing.T) { var capturedService string + var capturedCmd []string + h := &handlers{ client: &mockDocker{ execQuietFn: func(service string, command ...string) (string, error) { @@ -293,10 +323,12 @@ func TestHandleExec_CustomService(t *testing.T) { "command": []any{"psql"}, "service": "pgsql", }) + result, err := h.handleExec(context.Background(), req) if err != nil { t.Fatalf("unexpected error: %v", err) } + if result.IsError { t.Fatal("expected IsError=false") } @@ -304,6 +336,7 @@ func TestHandleExec_CustomService(t *testing.T) { if capturedService != "pgsql" { t.Errorf("expected service=pgsql, got %q", capturedService) } + if len(capturedCmd) != 1 || capturedCmd[0] != "psql" { t.Errorf("expected [psql], got %v", capturedCmd) } @@ -322,10 +355,12 @@ func TestHandleExec_EmptyCommand(t *testing.T) { req := makeRequest(map[string]any{ "command": []any{}, }) + result, err := h.handleExec(context.Background(), req) if err != nil { t.Fatalf("unexpected error: %v", err) } + if !result.IsError { t.Fatal("expected IsError=true for empty command") } @@ -347,10 +382,12 @@ func TestHandleExec_MissingCommand(t *testing.T) { } req := makeRequest(nil) + result, err := h.handleExec(context.Background(), req) if err != nil { t.Fatalf("unexpected error: %v", err) } + if !result.IsError { t.Fatal("expected IsError=true for missing command") } diff --git a/internal/output/output.go b/internal/output/output.go index a53f25d..605531f 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -41,9 +41,11 @@ func Group(label, detail string) { } else { logLine("OK %s (%s)", label, detail) } + if current == Quiet { return } + if detail == "" { fmt.Printf("%s✓%s %s\n", ansiGreen, ansiReset, label) } else { @@ -60,14 +62,17 @@ func Spin(label string) func(err error) { } var once sync.Once + done := make(chan struct{}) go func() { i := 0 + ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() // Print initial frame immediately. fmt.Printf("%s%s%s %s", ansiYellow, spinFrames[0], ansiReset, label) + for { select { case <-done: @@ -82,6 +87,7 @@ func Spin(label string) func(err error) { return func(err error) { once.Do(func() { close(done) + if err != nil { fmt.Printf("%s%s✗%s %s\n", ansiClear, ansiRed, ansiReset, label) } else { @@ -94,9 +100,11 @@ func Spin(label string) func(err error) { // Detail prints a single operation line. Shown in Verbose only. func Detail(msg string) { logLine(" %s", msg) + if current != Verbose { return } + fmt.Printf(" %s\n", msg) } @@ -106,7 +114,9 @@ func NextSteps(lines []string) { if current == Quiet || len(lines) == 0 { return } + fmt.Println("\nNext steps:") + for _, l := range lines { fmt.Printf(" %s\n", l) } diff --git a/internal/output/output_test.go b/internal/output/output_test.go index 2e1a41e..c2724ea 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -16,10 +16,13 @@ func captureStdout(fn func()) string { fn() w.Close() + os.Stdout = old var buf bytes.Buffer + io.Copy(&buf, r) + return buf.String() } @@ -31,10 +34,13 @@ func captureStderr(fn func()) string { fn() w.Close() + os.Stderr = old var buf bytes.Buffer + io.Copy(&buf, r) + return buf.String() } @@ -120,6 +126,7 @@ func TestNextSteps_Normal(t *testing.T) { if !strings.Contains(out, "Next steps:") { t.Fatalf("expected header, got: %q", out) } + if !strings.Contains(out, " run frank up") { t.Fatalf("expected indented line, got: %q", out) } @@ -167,6 +174,7 @@ func TestSpin_Quiet(t *testing.T) { func TestWarning_Always(t *testing.T) { for _, level := range []Level{Quiet, Normal, Verbose} { SetLevel(level) + out := captureStderr(func() { Warning("something broke") }) @@ -174,5 +182,6 @@ func TestWarning_Always(t *testing.T) { t.Fatalf("expected warning at level %d, got: %q", level, out) } } + SetLevel(Normal) } diff --git a/internal/output/region.go b/internal/output/region.go index 8dc08f7..ba69c68 100644 --- a/internal/output/region.go +++ b/internal/output/region.go @@ -68,8 +68,10 @@ func Region(label string) *LiveRegion { r.lines = make(chan string, 128) r.done = make(chan struct{}) r.finished = make(chan struct{}) + go r.render() } + return r } @@ -88,11 +90,13 @@ func (r *LiveRegion) Write(p []byte) (int, error) { // regionLive: accumulate, split on newlines, forward whole lines. r.mu.Lock() r.partial = append(r.partial, p...) + for { i := bytes.IndexByte(r.partial, '\n') if i < 0 { break } + line := strings.TrimRight(string(r.partial[:i]), "\r") r.partial = r.partial[i+1:] select { @@ -101,6 +105,7 @@ func (r *LiveRegion) Write(p []byte) (int, error) { } } r.mu.Unlock() + return len(p), nil } @@ -120,6 +125,7 @@ func (r *LiveRegion) Stop(err error) { close(r.done) <-r.finished } + if err != nil { fmt.Printf("%s✗%s %s\n", ansiRed, ansiReset, r.label) } else { @@ -138,40 +144,50 @@ func (r *LiveRegion) render() { draw := func() { width := regionWidth() + var b strings.Builder if prevRows > 0 { fmt.Fprintf(&b, "\033[%dA", prevRows) // up to region top } + b.WriteString("\r\033[2K") fmt.Fprintf(&b, "%s%s%s %s\n", ansiYellow, spinFrames[frame], ansiReset, r.label) + for _, ln := range ring { b.WriteString("\033[2K") b.WriteString(body.MaxWidth(width).Render(" │ " + ln)) b.WriteByte('\n') } + b.WriteString("\033[J") // wipe any leftover rows below + prevRows = 1 + len(ring) + fmt.Print(b.String()) } ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() draw() + for { select { case <-ticker.C: frame = (frame + 1) % len(spinFrames) + draw() case ln := <-r.lines: ring = append(ring, ln) if len(ring) > regionLines { ring = ring[len(ring)-regionLines:] } + draw() case <-r.done: if prevRows > 0 { fmt.Printf("\033[%dA\r\033[J", prevRows) // erase whole region } + return } } @@ -182,5 +198,6 @@ func regionWidth() int { if err != nil || w <= 0 { return 80 } + return w } diff --git a/internal/output/region_test.go b/internal/output/region_test.go index 99a571e..f778d97 100644 --- a/internal/output/region_test.go +++ b/internal/output/region_test.go @@ -16,6 +16,7 @@ func TestRegionWrite_LineSplitting(t *testing.T) { r.Write([]byte("tial\ngamma")) // "gamma" has no newline → stays buffered close(r.lines) + var got []string for ln := range r.lines { got = append(got, ln) @@ -25,11 +26,13 @@ func TestRegionWrite_LineSplitting(t *testing.T) { if len(got) != len(want) { t.Fatalf("got %d lines %q, want %d %q", len(got), got, len(want), want) } + for i := range want { if got[i] != want[i] { t.Errorf("line %d = %q, want %q", i, got[i], want[i]) } } + if string(r.partial) != "gamma" { t.Errorf("partial = %q, want %q", r.partial, "gamma") } diff --git a/internal/output/sessionlog.go b/internal/output/sessionlog.go index cfd9599..ae9fd94 100644 --- a/internal/output/sessionlog.go +++ b/internal/output/sessionlog.go @@ -56,10 +56,12 @@ func OpenSessionLog(dir, version string, truncate bool) error { if err != nil { return err } + sessionLog = f ts := time.Now().Format(sessionTimeFmt) fmt.Fprintf(f, "\n=== %s | %s | frank %s ===\n", ts, strings.Join(os.Args, " "), version) + return nil } @@ -67,6 +69,7 @@ func OpenSessionLog(dir, version string, truncate bool) error { func CloseSessionLog() { sessionMu.Lock() defer sessionMu.Unlock() + if sessionLog != nil { _ = sessionLog.Close() sessionLog = nil @@ -78,6 +81,7 @@ func CloseSessionLog() { func logWrite(p []byte) { sessionMu.Lock() defer sessionMu.Unlock() + if sessionLog != nil { _, _ = sessionLog.Write(collapseCR(ansiRe.ReplaceAll(p, nil))) } @@ -92,24 +96,32 @@ func collapseCR(b []byte) []byte { if bytes.IndexByte(b, '\r') < 0 { return b } + var out []byte + for { i := bytes.IndexByte(b, '\n') seg := b + if i >= 0 { seg = b[:i] } + seg = bytes.TrimSuffix(seg, []byte{'\r'}) // CRLF / trailing return if j := bytes.LastIndexByte(seg, '\r'); j >= 0 { seg = seg[j+1:] // keep only the last redraw frame } + out = append(out, seg...) + if i < 0 { break } + out = append(out, '\n') b = b[i+1:] } + return out } @@ -118,6 +130,7 @@ func collapseCR(b []byte) []byte { func logLine(format string, args ...any) { sessionMu.Lock() defer sessionMu.Unlock() + if sessionLog != nil { fmt.Fprintf(sessionLog, format+"\n", args...) } @@ -126,9 +139,11 @@ func logLine(format string, args ...any) { // stepHeader renders a region-start marker: "-- label ----- ". func stepHeader(label string) string { const width = 52 + prefix := "-- " + label + " " if pad := width - len(prefix); pad > 0 { prefix += strings.Repeat("-", pad) } + return prefix + " " + time.Now().Format(sessionTimeFmt) } diff --git a/internal/output/sessionlog_test.go b/internal/output/sessionlog_test.go index 0cb36af..57a092a 100644 --- a/internal/output/sessionlog_test.go +++ b/internal/output/sessionlog_test.go @@ -18,6 +18,7 @@ func TestSessionLog_TruncateVsAppend(t *testing.T) { if err := OpenSessionLog(dir, "test", false); err != nil { t.Fatalf("open append: %v", err) } + r := Region("Doing thing") r.Write([]byte("\x1b[37;44m INFO \x1b[39;49m line from disposable container\n")) r.Stop(nil) @@ -27,6 +28,7 @@ func TestSessionLog_TruncateVsAppend(t *testing.T) { if !strings.Contains(first, "line from disposable container") { t.Errorf("tee missing raw stream; got:\n%s", first) } + if strings.Contains(first, "\x1b[") { t.Errorf("ANSI escapes not stripped from log; got:\n%q", first) } @@ -35,15 +37,19 @@ func TestSessionLog_TruncateVsAppend(t *testing.T) { if got := string(collapseCR([]byte("Downloading 45%\rDownloading 50%\rDownloading 100%\n"))); got != "Downloading 100%\n" { t.Errorf("collapseCR redraw: got %q", got) } + if got := string(collapseCR([]byte("plain text\r\n"))); got != "plain text\n" { t.Errorf("collapseCR CRLF: got %q", got) } + if got := string(collapseCR([]byte("no cr here"))); got != "no cr here" { t.Errorf("collapseCR passthrough: got %q", got) } + if !strings.Contains(first, "-- Doing thing ") { t.Errorf("missing step header; got:\n%s", first) } + if !strings.Contains(first, "OK Doing thing") { t.Errorf("missing OK marker; got:\n%s", first) } @@ -52,8 +58,10 @@ func TestSessionLog_TruncateVsAppend(t *testing.T) { if err := OpenSessionLog(dir, "test", false); err != nil { t.Fatalf("open append 2: %v", err) } + logLine("second session") CloseSessionLog() + if got := readFile(t, logPath); !strings.Contains(got, "line from disposable container") || !strings.Contains(got, "second session") { t.Errorf("append wiped prior session; got:\n%s", got) } @@ -62,7 +70,9 @@ func TestSessionLog_TruncateVsAppend(t *testing.T) { if err := OpenSessionLog(dir, "test", true); err != nil { t.Fatalf("open truncate: %v", err) } + CloseSessionLog() + if got := readFile(t, logPath); strings.Contains(got, "line from disposable container") { t.Errorf("truncate did not wipe prior content; got:\n%s", got) } @@ -77,6 +87,7 @@ func TestSessionLog_LevelIndependent(t *testing.T) { if err := OpenSessionLog(dir, "test", true); err != nil { t.Fatalf("open: %v", err) } + Group("Quiet group", "") Warning("quiet warning") CloseSessionLog() @@ -89,9 +100,11 @@ func TestSessionLog_LevelIndependent(t *testing.T) { func readFile(t *testing.T, path string) string { t.Helper() + b, err := os.ReadFile(path) if err != nil { t.Fatalf("read %s: %v", path, err) } + return string(b) } diff --git a/internal/shell/shell.go b/internal/shell/shell.go index 7585cf4..1c2c19f 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -50,12 +50,14 @@ var aliasTable = []struct { // Custom aliases from cfg.Aliases are appended in sorted key order. func Activate(cfg *config.Config) string { var sb strings.Builder + var names []string for _, a := range aliasTable { if a.service != "" && !cfg.HasService(a.service) { continue } + names = append(names, a.name) } @@ -64,6 +66,7 @@ func Activate(cfg *config.Config) string { for name := range cfg.Aliases { customNames = append(customNames, name) } + sort.Strings(customNames) names = append(names, customNames...) @@ -77,6 +80,7 @@ func Activate(cfg *config.Config) string { if a.service != "" && !cfg.HasService(a.service) { continue } + alias(&sb, a.name, a.cmd) } @@ -95,23 +99,26 @@ func Activate(cfg *config.Config) string { // AliasEntry describes a single alias for display purposes. type AliasEntry struct { - Name string - Cmd string - Note string // e.g. "pgsql", "host", "" - Custom bool + Name string + Cmd string + Note string // e.g. "pgsql", "host", "" + Custom bool } // ListAliases returns all active aliases for the given config. func ListAliases(cfg *config.Config) []AliasEntry { var entries []AliasEntry + for _, a := range aliasTable { if a.service != "" && !cfg.HasService(a.service) { continue } + note := "" if a.service != "" { note = a.service } + entries = append(entries, AliasEntry{Name: a.name, Cmd: a.cmd, Note: note}) } @@ -119,18 +126,23 @@ func ListAliases(cfg *config.Config) []AliasEntry { for name := range cfg.Aliases { customNames = append(customNames, name) } + sort.Strings(customNames) + for _, name := range customNames { a := cfg.Aliases[name] cmd := a.Cmd + note := "" if a.Host { note = "host" } else { cmd = execSail + " " + cmd } + entries = append(entries, AliasEntry{Name: name, Cmd: cmd, Note: note, Custom: true}) } + return entries } @@ -147,6 +159,7 @@ func ShellSetup(shell string) string { if shell == "" { shell = detectShell() } + switch shell { case "zsh": return zshHook() @@ -167,6 +180,7 @@ func detectShell() string { if shellPath := os.Getenv("SHELL"); strings.Contains(shellPath, "zsh") { return "zsh" } + return "bash" } diff --git a/internal/shell/shell_test.go b/internal/shell/shell_test.go index 0cb4f4a..a90fea6 100644 --- a/internal/shell/shell_test.go +++ b/internal/shell/shell_test.go @@ -30,6 +30,7 @@ func TestActivate(t *testing.T) { if !strings.Contains(output, "alias psql=") { t.Errorf("expected psql alias when pgsql service present:\n%s", output) } + if strings.Contains(output, "alias mysql=") { t.Errorf("did not expect mysql alias without mysql service:\n%s", output) } @@ -70,9 +71,11 @@ func TestActivate(t *testing.T) { aaIdx := strings.Index(output, "alias aa=") mmIdx := strings.Index(output, "alias mm=") zzIdx := strings.Index(output, "alias zz=") + if aaIdx == -1 || mmIdx == -1 || zzIdx == -1 { t.Fatalf("missing custom aliases in output:\n%s", output) } + if !(aaIdx < mmIdx && mmIdx < zzIdx) { t.Errorf("custom aliases not in sorted order:\n%s", output) } @@ -105,6 +108,7 @@ func TestActivate_FRANK_ALIASES(t *testing.T) { // _FRANK_ALIASES must appear before alias lines aliasVarIdx := strings.Index(output, `_FRANK_ALIASES="`) firstAlias := strings.Index(output, "alias ") + if aliasVarIdx >= firstAlias { t.Errorf("_FRANK_ALIASES must appear before alias definitions") } @@ -121,6 +125,7 @@ func TestActivate_SingleQuoteEscape(t *testing.T) { if !strings.Contains(output, `'\''`) { t.Errorf("expected single quote escaping ('\\''') in output:\n%s", output) } + if !strings.Contains(output, "alias tricky=") { t.Errorf("expected tricky alias in output:\n%s", output) } @@ -132,6 +137,7 @@ func TestDeactivate(t *testing.T) { if !strings.Contains(output, "for _a in $_FRANK_ALIASES") { t.Errorf("expected _FRANK_ALIASES loop in deactivate output:\n%s", output) } + if !strings.Contains(output, "unset _FRANK_ALIASES") { t.Errorf("expected unset _FRANK_ALIASES in deactivate output:\n%s", output) } @@ -168,9 +174,11 @@ func TestShellSetup_Zsh(t *testing.T) { // The completion call must appear inside _frank_setup, not at top-level eval time. setupIdx := strings.Index(output, "_frank_setup") completionIdx := strings.Index(output, `frank config shell completion zsh`) + if completionIdx != -1 && setupIdx != -1 && completionIdx < setupIdx { t.Errorf("zsh hook: completion call must be inside _frank_setup, not before it (top-level eval is the old bug)") } + if setupIdx == -1 { t.Errorf("zsh hook: _frank_setup not found, cannot verify completion placement") } @@ -209,11 +217,13 @@ func TestShellSetup_Bash(t *testing.T) { func TestShellSetup_DefaultDetect(t *testing.T) { t.Run("detects zsh from SHELL env", func(t *testing.T) { t.Setenv("SHELL", "/bin/zsh") + output := ShellSetup("") if !strings.Contains(output, "_frank_precmd_init") { t.Errorf("default detect with SHELL=/bin/zsh: expected zsh hook (containing _frank_precmd_init), got:\n%s", output) } + if strings.Contains(output, "PROMPT_COMMAND") { t.Errorf("default detect with SHELL=/bin/zsh: expected zsh hook, but got bash-specific PROMPT_COMMAND, got:\n%s", output) } @@ -221,11 +231,13 @@ func TestShellSetup_DefaultDetect(t *testing.T) { t.Run("detects bash from SHELL env", func(t *testing.T) { t.Setenv("SHELL", "/bin/bash") + output := ShellSetup("") if !strings.Contains(output, "PROMPT_COMMAND") { t.Errorf("default detect with SHELL=/bin/bash: expected bash hook (containing PROMPT_COMMAND), got:\n%s", output) } + if strings.Contains(output, "_frank_precmd_init") { t.Errorf("default detect with SHELL=/bin/bash: expected bash hook, but got zsh-specific _frank_precmd_init, got:\n%s", output) } diff --git a/internal/template/template.go b/internal/template/template.go index 0e2bc74..8378ab8 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -115,17 +115,21 @@ func (e *Engine) RenderRuntime(runtime, file string, data Data) (string, error) func (e *Engine) RenderWorker(kind string, data WorkerData) (string, error) { tmplPath := path.Join("templates", "workers", kind+".fragment.tmpl") content, err := fs.ReadFile(e.fs, tmplPath) + if err != nil { return "", fmt.Errorf("worker template %q not found: %w", tmplPath, err) } + t, err := texttemplate.New(path.Base(tmplPath)).Parse(string(content)) if err != nil { return "", fmt.Errorf("worker template %q parse error: %w", tmplPath, err) } + var buf bytes.Buffer if err := t.Execute(&buf, data); err != nil { return "", fmt.Errorf("worker template %q render error: %w", tmplPath, err) } + return buf.String(), nil } @@ -142,6 +146,7 @@ func (e *Engine) ReadFile(filePath string) (string, error) { if err != nil { return "", fmt.Errorf("read %q: %w", filePath, err) } + return string(data), nil } diff --git a/internal/template/template_test.go b/internal/template/template_test.go index f10de48..51727fd 100644 --- a/internal/template/template_test.go +++ b/internal/template/template_test.go @@ -13,11 +13,13 @@ const templatesDir = "../../templates" func newTestEngine(t *testing.T) *Engine { t.Helper() + fsys := os.DirFS(templatesDir) // Wrap in a sub-FS that mimics the "templates/" prefix the engine expects. // Since the engine prepends "templates/", we need to provide an FS rooted // one level above. Use os.DirFS at the frank root instead. _ = fsys + return New(os.DirFS("../..")) } @@ -31,6 +33,7 @@ func TestRenderRuntimeDockerfile_FrankenPHP(t *testing.T) { if err != nil { t.Fatalf("RenderRuntime error: %v", err) } + thinChecks := []string{ "FROM frank/runtime:8.4-frankenphp-node24-pg17", "COPY .frank/Caddyfile /etc/caddy/Caddyfile", @@ -41,6 +44,7 @@ func TestRenderRuntimeDockerfile_FrankenPHP(t *testing.T) { t.Errorf("expected %q in thin FrankenPHP Dockerfile, got:\n%s", want, out) } } + if strings.Contains(out, "dunglas/frankenphp") { t.Error("thin Dockerfile must derive from frank/runtime, not the upstream image directly") } @@ -48,10 +52,12 @@ func TestRenderRuntimeDockerfile_FrankenPHP(t *testing.T) { func TestRenderBaseDockerfile_FrankenPHP(t *testing.T) { e := newTestEngine(t) + out, err := e.RenderRuntime("frankenphp", "base.Dockerfile.tmpl", Data{PHPVersion: "8.4"}) if err != nil { t.Fatalf("RenderRuntime error: %v", err) } + checks := []string{ "dunglas/frankenphp:1-php8.4", "AS builder", @@ -81,6 +87,7 @@ func TestRenderBaseDockerfile_FrankenPHP(t *testing.T) { if strings.Contains(out, "COPY .frank/Caddyfile") { t.Error("Caddyfile COPY belongs in the thin Dockerfile, not the base") } + if strings.Contains(out, `"/app`) { t.Error("old /app path must not appear; use /var/www/html throughout") } @@ -95,15 +102,19 @@ func TestRenderRuntimeDockerfile_FPM(t *testing.T) { if err != nil { t.Fatalf("RenderRuntime error: %v", err) } + if !strings.Contains(out, "FROM frank/runtime:8.3-fpm-node24-pg17") { t.Errorf("expected FROM frank/runtime in thin FPM Dockerfile, got:\n%s", out) } + if !strings.Contains(out, "# Generated by Frank") { t.Errorf("expected Frank header in thin FPM Dockerfile, got:\n%s", out) } + if strings.Contains(out, "ubuntu:24.04") { t.Error("thin Dockerfile must derive from frank/runtime, not ubuntu directly") } + if strings.Contains(out, "COPY .frank/Caddyfile") { t.Error("FPM thin Dockerfile must not COPY a Caddyfile (nginx is separate)") } @@ -111,6 +122,7 @@ func TestRenderRuntimeDockerfile_FPM(t *testing.T) { func TestRenderBaseDockerfile_FPM(t *testing.T) { e := newTestEngine(t) + out, err := e.RenderRuntime("fpm", "base.Dockerfile.tmpl", Data{PHPVersion: "8.3"}) if err != nil { t.Fatalf("RenderRuntime error: %v", err) @@ -137,12 +149,15 @@ func TestRenderBaseDockerfile_FPM(t *testing.T) { t.Errorf("expected %q in FPM base Dockerfile, got:\n%s", want, out) } } + if strings.Contains(out, "frank/runtime") { t.Error("base Dockerfile must not reference frank/runtime (it is the base)") } + if strings.Contains(out, "php:8.3-fpm") { t.Error("old php:8.3-fpm base image must be removed") } + if strings.Contains(out, ".sock") { t.Error("FPM must not use a Unix socket; expected TCP 9000") } @@ -156,9 +171,11 @@ func TestRenderRuntimeCaddyfile(t *testing.T) { if err != nil { t.Fatalf("RenderRuntime error: %v", err) } + if !strings.Contains(out, ":80 {") { t.Error("expected :80 block in HTTP-mode Caddyfile") } + if strings.Contains(out, ":443") { t.Error("HTTP mode should not contain :443") } @@ -168,15 +185,19 @@ func TestRenderRuntimeCaddyfile(t *testing.T) { if err != nil { t.Fatalf("RenderRuntime HTTPS error: %v", err) } + if !strings.Contains(out, ":443 {") { t.Error("expected :443 block in HTTPS-mode Caddyfile") } + if !strings.Contains(out, "tls /etc/certs/localhost.pem") { t.Error("expected tls directive in HTTPS mode") } + if !strings.Contains(out, "redir https://") { t.Error("expected :80 redirect in HTTPS mode without CustomPort") } + if strings.Contains(out, ":5173 {") { t.Error("Vite proxy block should not be present — Vite handles its own TLS") } @@ -186,6 +207,7 @@ func TestRenderRuntimeCaddyfile(t *testing.T) { if err != nil { t.Fatalf("RenderRuntime HTTPS+CustomPort error: %v", err) } + if strings.Contains(out, "redir https://") { t.Error("CustomPort should suppress :80 redirect") } @@ -193,13 +215,16 @@ func TestRenderRuntimeCaddyfile(t *testing.T) { func TestRenderServiceCompose_PgsqlDefaults(t *testing.T) { e := newTestEngine(t) + out, err := e.RenderServiceCompose("pgsql", config.ServiceConfig{}, "myapp", false) if err != nil { t.Fatalf("RenderServiceCompose error: %v", err) } + if !strings.Contains(out, "postgres:latest") { t.Errorf("expected default postgres image, got:\n%s", out) } + if !strings.Contains(out, "5432:5432") { t.Errorf("expected default pgsql port mapping, got:\n%s", out) } @@ -207,13 +232,16 @@ func TestRenderServiceCompose_PgsqlDefaults(t *testing.T) { func TestRenderServiceCompose_PgsqlOverride(t *testing.T) { e := newTestEngine(t) + out, err := e.RenderServiceCompose("pgsql", config.ServiceConfig{Version: "16", Port: 5433}, "myapp", false) if err != nil { t.Fatalf("RenderServiceCompose error: %v", err) } + if !strings.Contains(out, "postgres:16") { t.Errorf("expected overridden postgres version, got:\n%s", out) } + if !strings.Contains(out, "5433:5432") { t.Errorf("expected overridden port, got:\n%s", out) } @@ -221,10 +249,12 @@ func TestRenderServiceCompose_PgsqlOverride(t *testing.T) { func TestRenderServiceEnv_Pgsql(t *testing.T) { e := newTestEngine(t) + out, err := e.RenderServiceEnv("pgsql", config.ServiceConfig{}, "my-project", false) if err != nil { t.Fatalf("RenderServiceEnv error: %v", err) } + checks := []string{ "DB_CONNECTION=pgsql", "DB_HOST=pgsql", @@ -242,10 +272,12 @@ func TestRenderServiceEnv_Pgsql(t *testing.T) { func TestRenderServiceEnv_Sqlite(t *testing.T) { e := newTestEngine(t) + out, err := e.RenderServiceEnv("sqlite", config.ServiceConfig{}, "myapp", false) if err != nil { t.Fatalf("RenderServiceEnv error: %v", err) } + if !strings.Contains(out, "DB_CONNECTION=sqlite") { t.Errorf("expected DB_CONNECTION=sqlite, got:\n%s", out) } @@ -253,13 +285,16 @@ func TestRenderServiceEnv_Sqlite(t *testing.T) { func TestRenderServiceCompose_Mailpit(t *testing.T) { e := newTestEngine(t) + out, err := e.RenderServiceCompose("mailpit", config.ServiceConfig{}, "myapp", false) if err != nil { t.Fatalf("RenderServiceCompose error: %v", err) } + if !strings.Contains(out, "1025:1025") { t.Errorf("expected SMTP port mapping, got:\n%s", out) } + if !strings.Contains(out, "8025:8025") { t.Errorf("expected dashboard port mapping, got:\n%s", out) } @@ -267,13 +302,16 @@ func TestRenderServiceCompose_Mailpit(t *testing.T) { func TestRenderServiceEnv_Redis(t *testing.T) { e := newTestEngine(t) + out, err := e.RenderServiceEnv("redis", config.ServiceConfig{}, "myapp", false) if err != nil { t.Fatalf("RenderServiceEnv error: %v", err) } + if !strings.Contains(out, "REDIS_HOST=redis") { t.Errorf("expected REDIS_HOST, got:\n%s", out) } + if !strings.Contains(out, "CACHE_STORE=redis") { t.Errorf("expected CACHE_STORE=redis, got:\n%s", out) } @@ -281,6 +319,7 @@ func TestRenderServiceEnv_Redis(t *testing.T) { func TestRenderMissingTemplate(t *testing.T) { e := newTestEngine(t) + _, err := e.Render("templates/nonexistent.tmpl", Data{}) if err == nil { t.Error("expected error for missing template") @@ -290,6 +329,7 @@ func TestRenderMissingTemplate(t *testing.T) { func TestAllServicesHaveEnvTemplate(t *testing.T) { e := newTestEngine(t) services := []string{"pgsql", "mysql", "mariadb", "sqlite", "redis", "memcached", "meilisearch", "mailpit"} + for _, svc := range services { _, err := e.RenderServiceEnv(svc, config.ServiceConfig{}, "testapp", false) if err != nil { @@ -301,6 +341,7 @@ func TestAllServicesHaveEnvTemplate(t *testing.T) { func TestNonSqliteServicesHaveComposeFragment(t *testing.T) { e := newTestEngine(t) services := []string{"pgsql", "mysql", "mariadb", "redis", "memcached", "meilisearch", "mailpit"} + for _, svc := range services { _, err := e.RenderServiceCompose(svc, config.ServiceConfig{}, "testapp", false) if err != nil { @@ -311,16 +352,20 @@ func TestNonSqliteServicesHaveComposeFragment(t *testing.T) { func TestRenderRuntimeCompose_FPM_SailUser(t *testing.T) { e := newTestEngine(t) + out, err := e.RenderRuntime("fpm", "compose.fragment.tmpl", Data{PHPVersion: "8.4"}) if err != nil { t.Fatalf("RenderRuntime error: %v", err) } + if !strings.Contains(out, "WWWGROUP") { t.Error("expected WWWGROUP build arg in fpm compose fragment") } + if !strings.Contains(out, "WWWUSER") { t.Error("expected WWWUSER env var in fpm compose fragment") } + if strings.Contains(out, "user: ") { t.Error("fpm compose fragment must not contain user: directive (gosu handles it)") } @@ -328,10 +373,12 @@ func TestRenderRuntimeCompose_FPM_SailUser(t *testing.T) { func TestRenderWorker_Schedule(t *testing.T) { e := newTestEngine(t) + out, err := e.RenderWorker("schedule", WorkerData{ProjectName: "myapp"}) if err != nil { t.Fatalf("RenderWorker error: %v", err) } + checks := []string{ "schedule:", "frank-myapp-laravel.test", @@ -345,6 +392,7 @@ func TestRenderWorker_Schedule(t *testing.T) { t.Errorf("expected %q in schedule worker, got:\n%s", want, out) } } + if strings.Contains(out, "user:") { t.Error("worker templates must stay runtime-agnostic (no user:)") } @@ -352,6 +400,7 @@ func TestRenderWorker_Schedule(t *testing.T) { func TestRenderWorker_QueueWithFlags(t *testing.T) { e := newTestEngine(t) + out, err := e.RenderWorker("queue", WorkerData{ ProjectName: "app", ServiceName: "queue.high.1", @@ -363,6 +412,7 @@ func TestRenderWorker_QueueWithFlags(t *testing.T) { if err != nil { t.Fatalf("RenderWorker error: %v", err) } + checks := []string{ "queue.high.1:", "frank.worker.pool: \"high\"", @@ -375,6 +425,7 @@ func TestRenderWorker_QueueWithFlags(t *testing.T) { t.Errorf("expected %q in queue worker, got:\n%s", want, out) } } + for _, forbid := range []string{"--memory=", "--sleep=", "--backoff="} { if strings.Contains(out, forbid) { t.Errorf("unexpected %q for zero-valued passthrough, got:\n%s", forbid, out) @@ -384,16 +435,20 @@ func TestRenderWorker_QueueWithFlags(t *testing.T) { func TestRenderRuntimeCompose_FrankenPHP_SailUser(t *testing.T) { e := newTestEngine(t) + out, err := e.RenderRuntime("frankenphp", "compose.fragment.tmpl", Data{PHPVersion: "8.4"}) if err != nil { t.Fatalf("RenderRuntime error: %v", err) } + if !strings.Contains(out, "WWWGROUP") { t.Error("expected WWWGROUP build arg in frankenphp compose fragment") } + if !strings.Contains(out, "WWWUSER") { t.Error("expected WWWUSER env var in frankenphp compose fragment") } + if strings.Contains(out, "user: ") { t.Error("frankenphp compose fragment must not contain user: directive (gosu handles it)") } diff --git a/internal/tool/composer.go b/internal/tool/composer.go index a5fd5c8..1cf4518 100644 --- a/internal/tool/composer.go +++ b/internal/tool/composer.go @@ -12,40 +12,50 @@ import ( // for the given tools, filtering out any already present in composer.json. func ComposerDevPackages(dir string, tools []Tool) []string { existing := existingDevDeps(dir) + var packages []string + for _, t := range tools { for _, entry := range t.ComposerDev { parts := strings.SplitN(entry, ":", 2) if len(parts) != 2 { continue } + if _, exists := existing[parts[0]]; exists { continue } + packages = append(packages, entry) } } + return packages } func existingDevDeps(dir string) map[string]bool { path := filepath.Join(dir, "composer.json") + data, err := os.ReadFile(path) if err != nil { return nil } + var doc map[string]any if err := json.Unmarshal(data, &doc); err != nil { return nil } + reqDev, _ := doc["require-dev"].(map[string]any) if reqDev == nil { return nil } + out := make(map[string]bool, len(reqDev)) for k := range reqDev { out[k] = true } + return out } @@ -59,6 +69,7 @@ func PatchComposerScripts(dir string, tools []Tool) error { if os.IsNotExist(err) { return nil } + return err } @@ -68,6 +79,7 @@ func PatchComposerScripts(dir string, tools []Tool) error { } changed := false + for _, t := range tools { for key, cmd := range t.ComposerScripts { scripts, _ := doc["scripts"].(map[string]any) @@ -75,9 +87,11 @@ func PatchComposerScripts(dir string, tools []Tool) error { scripts = make(map[string]any) doc["scripts"] = scripts } + if _, exists := scripts[key]; exists { continue } + scripts[key] = cmd changed = true } @@ -91,6 +105,7 @@ func PatchComposerScripts(dir string, tools []Tool) error { if err != nil { return fmt.Errorf("marshaling composer.json: %w", err) } + out = append(out, '\n') return os.WriteFile(path, out, 0644) diff --git a/internal/tool/composer_test.go b/internal/tool/composer_test.go index 0943854..b434285 100644 --- a/internal/tool/composer_test.go +++ b/internal/tool/composer_test.go @@ -64,6 +64,7 @@ func TestPatchComposerScripts_AddNew(t *testing.T) { } doc := readJSON(t, dir) + scripts := doc["scripts"].(map[string]any) if scripts["lint"] != "do-lint" { t.Errorf("expected lint=do-lint, got %v", scripts["lint"]) @@ -85,6 +86,7 @@ func TestPatchComposerScripts_SkipExisting(t *testing.T) { } doc := readJSON(t, dir) + scripts := doc["scripts"].(map[string]any) if scripts["lint"] != "old-lint" { t.Errorf("existing script overwritten: got %v", scripts["lint"]) @@ -104,6 +106,7 @@ func TestPatchComposerScripts_Indent(t *testing.T) { } data, _ := os.ReadFile(filepath.Join(dir, "composer.json")) + content := string(data) if !strings.Contains(content, " ") { t.Error("expected 4-space indent in output") @@ -114,10 +117,12 @@ func TestPatchComposerScripts_Indent(t *testing.T) { func writeJSON(t *testing.T, dir string, doc map[string]any) { t.Helper() + data, err := json.MarshalIndent(doc, "", " ") if err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(dir, "composer.json"), data, 0644); err != nil { t.Fatal(err) } @@ -125,13 +130,16 @@ func writeJSON(t *testing.T, dir string, doc map[string]any) { func readJSON(t *testing.T, dir string) map[string]any { t.Helper() + data, err := os.ReadFile(filepath.Join(dir, "composer.json")) if err != nil { t.Fatal(err) } + var doc map[string]any if err := json.Unmarshal(data, &doc); err != nil { t.Fatal(err) } + return doc } diff --git a/internal/tool/content_test.go b/internal/tool/content_test.go index f99cca5..1db2252 100644 --- a/internal/tool/content_test.go +++ b/internal/tool/content_test.go @@ -10,6 +10,7 @@ func TestPintJSON_Excludes(t *testing.T) { if err != nil { t.Fatalf("failed to read pint.json: %v", err) } + content := string(data) for _, want := range []string{".phpstorm.meta.php", "_ide_helper.php"} { @@ -24,6 +25,7 @@ func TestPhpstanNeon_Excludes(t *testing.T) { if err != nil { t.Fatalf("failed to read phpstan.neon: %v", err) } + content := string(data) for _, want := range []string{".phpstorm.meta.php", "_ide_helper.php"} { @@ -42,6 +44,7 @@ func TestRectorPHP_Paths(t *testing.T) { if err != nil { t.Fatalf("failed to read rector.php: %v", err) } + content := string(data) dirs := []string{"app", "bootstrap", "config", "public", "resources", "routes", "tests"} diff --git a/internal/tool/install.go b/internal/tool/install.go index c023250..6900c2c 100644 --- a/internal/tool/install.go +++ b/internal/tool/install.go @@ -30,6 +30,7 @@ func Install(tools []string, dir string) (*InstallResult, error) { if _, err := os.Stat(destPath); err == nil { output.Detail(fmt.Sprintf("skipped %s (already exists)", dest)) res.Skipped = append(res.Skipped, dest) + continue } @@ -41,6 +42,7 @@ func Install(tools []string, dir string) (*InstallResult, error) { if err := os.WriteFile(destPath, data, 0644); err != nil { return nil, fmt.Errorf("write %s: %w", dest, err) } + output.Detail(fmt.Sprintf("created %s", dest)) res.Created = append(res.Created, dest) } @@ -52,13 +54,16 @@ func Install(tools []string, dir string) (*InstallResult, error) { lefthookPath := filepath.Join(dir, "lefthook.yml") if _, err := os.Stat(lefthookPath); err == nil { output.Detail("skipped lefthook.yml (already exists)") + res.Skipped = append(res.Skipped, "lefthook.yml") } else { content := AssembleLefthook(tools) if err := os.WriteFile(lefthookPath, []byte(content), 0644); err != nil { return nil, fmt.Errorf("write lefthook.yml: %w", err) } + output.Detail("created lefthook.yml") + res.Created = append(res.Created, "lefthook.yml") } } @@ -82,22 +87,27 @@ func runLefthookInstall(dir string) { if _, err := os.Stat(gitDir); err != nil { initCmd := exec.Command("git", "init") initCmd.Dir = dir + if err := initCmd.Run(); err != nil { output.Detail("hint: run `lefthook install` after git init") return } } + path, err := exec.LookPath("lefthook") if err != nil { output.Detail("hint: install lefthook to enable git hooks: https://github.com/evilmartians/lefthook") return } + cmd := exec.Command(path, "install", "--force") cmd.Dir = dir + if output.GetLevel() == output.Verbose { cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr } + if err := cmd.Run(); err != nil { output.Warning(fmt.Sprintf("lefthook install failed: %v", err)) } @@ -108,5 +118,6 @@ func LefthookHint(toolName string) string { if !ok || t.Category != "php" { return "" } + return lefthookEntry(t) } diff --git a/internal/tool/lefthook.go b/internal/tool/lefthook.go index c4b93e2..fd73808 100644 --- a/internal/tool/lefthook.go +++ b/internal/tool/lefthook.go @@ -40,11 +40,13 @@ func AssembleLefthook(tools []string) string { b.WriteString("pre-commit:\n") b.WriteString(" parallel: true\n") b.WriteString(" commands:\n") + for _, name := range selected { t := lookupTool(name) if t == nil { continue } + b.WriteString(lefthookEntry(*t)) } } @@ -81,16 +83,20 @@ func lefthookEntry(t Tool) string { // in a stable order (pint, rector, larastan). func phpToolsSelected(tools []string) []string { order := []string{"pint", "rector", "larastan"} + set := make(map[string]bool, len(tools)) for _, t := range tools { set[t] = true } + var result []string + for _, name := range order { if set[name] { result = append(result, name) } } + return result } @@ -101,5 +107,6 @@ func lookupTool(name string) *Tool { return ®istry[i] } } + return nil } diff --git a/internal/tool/lefthook_test.go b/internal/tool/lefthook_test.go index fa8aa2a..a903894 100644 --- a/internal/tool/lefthook_test.go +++ b/internal/tool/lefthook_test.go @@ -11,12 +11,15 @@ func TestAssembleLefthook_AllTools(t *testing.T) { if !strings.Contains(out, "assert_lefthook_installed: true") { t.Error("expected assert_lefthook_installed: true") } + if !strings.Contains(out, "post-merge:") { t.Error("expected post-merge section") } + if !strings.Contains(out, "pre-commit:") { t.Error("expected pre-commit section") } + for _, name := range []string{"pint:", "rector:", "larastan:"} { if !strings.Contains(out, name) { t.Errorf("expected pre-commit entry for %s", name) @@ -30,12 +33,15 @@ func TestAssembleLefthook_SubsetPintOnly(t *testing.T) { if !strings.Contains(out, "pre-commit:") { t.Error("expected pre-commit section") } + if !strings.Contains(out, "pint:") { t.Error("expected pint entry") } + if strings.Contains(out, "rector:") { t.Error("unexpected rector entry") } + if strings.Contains(out, "larastan:") { t.Error("unexpected larastan entry") } @@ -47,6 +53,7 @@ func TestAssembleLefthook_NoPhpTools(t *testing.T) { if !strings.Contains(out, "post-merge:") { t.Error("expected post-merge section") } + if strings.Contains(out, "pre-commit:") { t.Error("unexpected pre-commit section when no PHP tools selected") } @@ -59,9 +66,11 @@ func TestAssembleLefthook_PMDetection(t *testing.T) { if !strings.Contains(out, "if command -v pnpm") { t.Error("expected pnpm detection") } + if !strings.Contains(out, "elif command -v bun") { t.Error("expected bun detection") } + if !strings.Contains(out, "else npm install") { t.Error("expected npm fallback") } @@ -86,9 +95,11 @@ func TestAssembleLefthook_StageFixed(t *testing.T) { if !strings.Contains(pintSection, "stage_fixed: true") { t.Error("pint should have stage_fixed: true") } + if !strings.Contains(rectorSection, "stage_fixed: true") { t.Error("rector should have stage_fixed: true") } + if strings.Contains(larastanSection, "stage_fixed: true") { t.Error("larastan should NOT have stage_fixed: true") } @@ -111,13 +122,16 @@ func TestLefthookEntry(t *testing.T) { if tool == nil { t.Fatalf("tool %q not found in registry", tt.name) } + entry := lefthookEntry(*tool) if !strings.Contains(entry, tt.name+":") { t.Errorf("entry missing tool name header %q", tt.name+":") } + if !strings.Contains(entry, tt.wantGlob) { t.Errorf("entry missing glob %q", tt.wantGlob) } + if !strings.Contains(entry, tt.wantRun) { t.Errorf("entry missing run command %q", tt.wantRun) } diff --git a/internal/tool/tool.go b/internal/tool/tool.go index 4e8dd0d..8bf58a9 100644 --- a/internal/tool/tool.go +++ b/internal/tool/tool.go @@ -56,6 +56,7 @@ func Lookup(name string) (Tool, bool) { return t, true } } + return Tool{}, false } @@ -69,13 +70,16 @@ func AllNames() []string { for i, t := range registry { names[i] = t.Name } + sort.Strings(names) + return names } func AllTools() []Tool { out := make([]Tool, len(registry)) copy(out, registry) + return out } @@ -84,12 +88,15 @@ func PHPTools(selected []string) []Tool { for _, s := range selected { set[s] = true } + var out []Tool + for _, t := range registry { if t.Category == "php" && set[t.Name] { out = append(out, t) } } + return out } @@ -99,5 +106,6 @@ func ValidateNames(names []string) error { return fmt.Errorf("unknown tool %q — valid options: %v", n, AllNames()) } } + return nil } diff --git a/internal/tool/tool_test.go b/internal/tool/tool_test.go index eecc1f1..c8cce26 100644 --- a/internal/tool/tool_test.go +++ b/internal/tool/tool_test.go @@ -10,9 +10,11 @@ func TestLookup(t *testing.T) { if !ok { t.Fatal("expected pint to be found") } + if tool.Name != "pint" { t.Fatalf("expected Name=pint, got %q", tool.Name) } + if tool.Category != "php" { t.Fatalf("expected Category=php, got %q", tool.Category) } @@ -70,6 +72,7 @@ func TestAllTools(t *testing.T) { // Verify it's a copy (mutating shouldn't affect registry) tools[0].Name = "mutated" + orig, _ := Lookup("pint") if orig.Name != "pint" { t.Fatal("AllTools did not return a copy") @@ -82,6 +85,7 @@ func TestPHPTools(t *testing.T) { if len(tools) != 2 { t.Fatalf("expected 2 PHP tools, got %d", len(tools)) } + for _, tool := range tools { if tool.Category != "php" { t.Errorf("expected php category, got %q for %s", tool.Category, tool.Name) diff --git a/internal/update/check.go b/internal/update/check.go index 29e1f39..73937a8 100644 --- a/internal/update/check.go +++ b/internal/update/check.go @@ -45,6 +45,7 @@ func Check(currentVersion string) (Status, error) { } writeCache(cacheFile, latest) + return compareVersions(currentVersion, latest), nil } @@ -101,6 +102,7 @@ func fetchLatest() (string, error) { var release struct { TagName string `json:"tag_name"` } + if err := json.Unmarshal(body, &release); err != nil { return "", err } diff --git a/internal/update/check_test.go b/internal/update/check_test.go index 2785a5a..74fb60f 100644 --- a/internal/update/check_test.go +++ b/internal/update/check_test.go @@ -44,12 +44,16 @@ func mockClientError() *http.Client { func writeCacheFile(t *testing.T, ts int64, version string) string { t.Helper() + path := cacheFilePath() content := fmt.Sprintf("%d\n%s\n", ts, version) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } + t.Cleanup(func() { os.Remove(path) }) + return path } @@ -67,6 +71,7 @@ func TestCheck_CacheHit(t *testing.T) { }, }, } + defer func() { client = orig }() writeCacheFile(t, time.Now().Unix(), "1.5.0") @@ -75,12 +80,15 @@ func TestCheck_CacheHit(t *testing.T) { if err != nil { t.Fatal(err) } + if called { t.Error("expected no HTTP call when cache is fresh") } + if !status.Available { t.Error("expected Available=true") } + if status.Latest != "1.5.0" { t.Errorf("expected Latest=1.5.0, got %s", status.Latest) } @@ -89,6 +97,7 @@ func TestCheck_CacheHit(t *testing.T) { func TestCheck_CacheExpired(t *testing.T) { orig := client client = mockClient(200, `{"tag_name":"v2.0.0"}`) + defer func() { client = orig }() staleTS := time.Now().Add(-20 * time.Minute).Unix() @@ -98,9 +107,11 @@ func TestCheck_CacheExpired(t *testing.T) { if err != nil { t.Fatal(err) } + if !status.Available { t.Error("expected Available=true") } + if status.Latest != "2.0.0" { t.Errorf("expected Latest=2.0.0, got %s", status.Latest) } @@ -109,6 +120,7 @@ func TestCheck_CacheExpired(t *testing.T) { func TestCheck_CacheMiss(t *testing.T) { orig := client client = mockClient(200, `{"tag_name":"v1.3.0"}`) + defer func() { client = orig }() // Ensure no cache file exists @@ -119,9 +131,11 @@ func TestCheck_CacheMiss(t *testing.T) { if err != nil { t.Fatal(err) } + if !status.Available { t.Error("expected Available=true") } + if status.Latest != "1.3.0" { t.Errorf("expected Latest=1.3.0, got %s", status.Latest) } @@ -130,6 +144,7 @@ func TestCheck_CacheMiss(t *testing.T) { func TestCheck_SameVersion(t *testing.T) { orig := client client = mockClient(200, `{"tag_name":"v1.0.0"}`) + defer func() { client = orig }() os.Remove(cacheFilePath()) @@ -139,6 +154,7 @@ func TestCheck_SameVersion(t *testing.T) { if err != nil { t.Fatal(err) } + if status.Available { t.Error("expected Available=false for same version") } @@ -147,6 +163,7 @@ func TestCheck_SameVersion(t *testing.T) { func TestCheck_APIFailure(t *testing.T) { orig := client client = mockClientError() + defer func() { client = orig }() os.Remove(cacheFilePath()) @@ -155,6 +172,7 @@ func TestCheck_APIFailure(t *testing.T) { if err != nil { t.Error("expected no error on API failure") } + if status.Available { t.Error("expected Available=false on API failure") } @@ -163,6 +181,7 @@ func TestCheck_APIFailure(t *testing.T) { func TestCheck_CorruptCache(t *testing.T) { orig := client client = mockClient(200, `{"tag_name":"v3.0.0"}`) + defer func() { client = orig }() // Write corrupt cache @@ -170,15 +189,18 @@ func TestCheck_CorruptCache(t *testing.T) { if err := os.WriteFile(path, []byte("garbage"), 0o644); err != nil { t.Fatal(err) } + t.Cleanup(func() { os.Remove(path) }) status, err := Check("1.0.0") if err != nil { t.Fatal(err) } + if !status.Available { t.Error("expected Available=true after refetch") } + if status.Latest != "3.0.0" { t.Errorf("expected Latest=3.0.0, got %s", status.Latest) } @@ -187,6 +209,7 @@ func TestCheck_CorruptCache(t *testing.T) { func TestCheck_NewerCurrent(t *testing.T) { orig := client client = mockClient(200, `{"tag_name":"v1.0.0"}`) + defer func() { client = orig }() os.Remove(cacheFilePath()) @@ -196,6 +219,7 @@ func TestCheck_NewerCurrent(t *testing.T) { if err != nil { t.Fatal(err) } + if status.Available { t.Error("expected Available=false when current is newer") } @@ -205,6 +229,7 @@ func TestCheck_NewerCurrent(t *testing.T) { func TestCacheFilePath_ContainsUID(t *testing.T) { path := cacheFilePath() uid := strconv.Itoa(os.Getuid()) + if !strings.Contains(path, uid) { t.Errorf("cache path %q does not contain UID %s", path, uid) } diff --git a/internal/update/method.go b/internal/update/method.go index 8c54a0d..48cd362 100644 --- a/internal/update/method.go +++ b/internal/update/method.go @@ -34,9 +34,11 @@ func detectFromPath(path string) Method { if strings.Contains(lower, "/cellar/") || strings.Contains(lower, "/homebrew/") { return MethodBrew } + if strings.Contains(lower, "/go/bin/") { return MethodGo } + return MethodUnknown } @@ -45,9 +47,11 @@ func executablePath() string { if err != nil { return "" } + resolved, err := filepath.EvalSymlinks(exe) if err != nil { return exe } + return resolved } diff --git a/internal/update/run.go b/internal/update/run.go index 35beb96..ebbced7 100644 --- a/internal/update/run.go +++ b/internal/update/run.go @@ -16,6 +16,7 @@ func (e *execCommander) Run(name string, args ...string) error { c := exec.Command(name, args...) c.Stdout = os.Stdout c.Stderr = os.Stderr + return c.Run() } diff --git a/internal/update/run_test.go b/internal/update/run_test.go index a26df9e..39edf73 100644 --- a/internal/update/run_test.go +++ b/internal/update/run_test.go @@ -42,12 +42,14 @@ func TestRun(t *testing.T) { if len(mock.calls) != 0 { t.Fatalf("expected no calls, got %v", mock.calls) } + return } if len(mock.calls) != 1 { t.Fatalf("expected 1 call, got %d: %v", len(mock.calls), mock.calls) } + if mock.calls[0] != tt.wantCall { t.Errorf("got %q, want %q", mock.calls[0], tt.wantCall) } diff --git a/internal/watch/daemon.go b/internal/watch/daemon.go index 00fb3fe..ecf8e47 100644 --- a/internal/watch/daemon.go +++ b/internal/watch/daemon.go @@ -27,12 +27,14 @@ func LogfilePath(projectRoot string) string { func (w *Watcher) acquirePidfile() error { path := PidfilePath(w.cfg.ProjectRoot) existing, err := ReadPidfile(path) + if err != nil { // Malformed content: wipe and proceed. _ = os.Remove(path) } else if existing != 0 && pidAlive(existing) && existing != os.Getpid() { return fmt.Errorf("watch: already running (pid %d); run `frank watch --stop` first", existing) } + return WritePidfile(path, os.Getpid()) } @@ -54,9 +56,11 @@ func Daemonize(argv []string, logPath string) (int, error) { if len(argv) == 0 { return 0, errors.New("watch: Daemonize requires a non-empty argv") } + if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { return 0, fmt.Errorf("watch: prepare log dir: %w", err) } + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { return 0, fmt.Errorf("watch: open log file %s: %w", logPath, err) @@ -82,5 +86,6 @@ func Daemonize(argv []string, logPath string) (int, error) { // Non-fatal on error: child is already running; worst case the runtime // keeps a process record around briefly. _ = cmd.Process.Release() + return pid, nil } diff --git a/internal/watch/daemon_test.go b/internal/watch/daemon_test.go index 5df9301..415b80c 100644 --- a/internal/watch/daemon_test.go +++ b/internal/watch/daemon_test.go @@ -18,12 +18,14 @@ func TestAcquirePidfile_WritesCurrentPid(t *testing.T) { if err := w.acquirePidfile(); err != nil { t.Fatalf("acquirePidfile: %v", err) } + t.Cleanup(w.releasePidfile) pid, err := ReadPidfile(PidfilePath(root)) if err != nil { t.Fatalf("ReadPidfile: %v", err) } + if pid != os.Getpid() { t.Errorf("pidfile pid = %d, want %d", pid, os.Getpid()) } @@ -46,12 +48,14 @@ func TestAcquirePidfile_OverwritesStalePid(t *testing.T) { if err := w.acquirePidfile(); err != nil { t.Fatalf("acquirePidfile: %v", err) } + t.Cleanup(w.releasePidfile) pid, err := ReadPidfile(path) if err != nil { t.Fatalf("ReadPidfile: %v", err) } + if pid != os.Getpid() { t.Errorf("pidfile = %d, want overwrite to %d", pid, os.Getpid()) } @@ -69,10 +73,12 @@ func TestAcquirePidfile_RejectsLiveOther(t *testing.T) { } w := &Watcher{cfg: Config{ProjectRoot: root}} + err := w.acquirePidfile() if err == nil { t.Fatalf("expected already-running error, got nil") } + if !containsAll(err.Error(), "already running", "pid 1") { t.Errorf("error %q missing expected phrases", err.Error()) } @@ -82,10 +88,12 @@ func TestAcquirePidfile_RejectsLiveOther(t *testing.T) { // it and writes our pid. func TestAcquirePidfile_OverwritesMalformed(t *testing.T) { root := t.TempDir() + path := PidfilePath(root) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } + if err := os.WriteFile(path, []byte("not-a-pid"), 0o644); err != nil { t.Fatal(err) } @@ -94,12 +102,14 @@ func TestAcquirePidfile_OverwritesMalformed(t *testing.T) { if err := w.acquirePidfile(); err != nil { t.Fatalf("acquirePidfile: %v", err) } + t.Cleanup(w.releasePidfile) pid, err := ReadPidfile(path) if err != nil { t.Fatalf("ReadPidfile: %v", err) } + if pid != os.Getpid() { t.Errorf("pidfile = %d, want %d", pid, os.Getpid()) } @@ -109,10 +119,12 @@ func TestAcquirePidfile_OverwritesMalformed(t *testing.T) { // detection already removed it before Stop runs). func TestReleasePidfile_Idempotent(t *testing.T) { root := t.TempDir() + w := &Watcher{cfg: Config{ProjectRoot: root}} if err := w.acquirePidfile(); err != nil { t.Fatalf("acquire: %v", err) } + w.releasePidfile() w.releasePidfile() // no panic, no error } @@ -140,6 +152,7 @@ func TestStart_WritesAndUnlinksPidfile(t *testing.T) { // Wait until Start has armed + written the pidfile. path := PidfilePath(root) deadline := time.After(2 * time.Second) + for { if _, err := os.Stat(path); err == nil { break @@ -161,6 +174,7 @@ func TestStart_WritesAndUnlinksPidfile(t *testing.T) { case <-time.After(2 * time.Second): t.Fatalf("Start did not return") } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { t.Errorf("pidfile should be unlinked after Stop, stat err = %v", err) } @@ -173,12 +187,14 @@ func TestStart_RefusesSecondInstance(t *testing.T) { if err := WritePidfile(PidfilePath(root), 1); err != nil { t.Fatalf("seed: %v", err) } + t.Cleanup(func() { _ = os.Remove(PidfilePath(root)) }) w, err := New(Config{ProjectRoot: root, Runner: &fakeRunner{}}) if err != nil { t.Fatalf("New: %v", err) } + ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -186,6 +202,7 @@ func TestStart_RefusesSecondInstance(t *testing.T) { if err == nil { t.Fatalf("expected already-running error") } + if !containsAll(err.Error(), "already running") { t.Errorf("error %q missing 'already running'", err.Error()) } @@ -212,12 +229,15 @@ func TestDaemonize_SpawnsSurvivingChild(t *testing.T) { if err != nil { t.Fatalf("Daemonize: %v", err) } + if pid <= 0 { t.Fatalf("expected positive pid, got %d", pid) } + if !pidAlive(pid) { t.Fatalf("child pid %d not alive immediately after Daemonize", pid) } + if _, err := os.Stat(logPath); err != nil { t.Errorf("log file not created: %v", err) } @@ -236,6 +256,7 @@ func containsAll(s string, subs ...string) bool { return false } } + return true } @@ -245,5 +266,6 @@ func contains(s, sub string) bool { return true } } + return false } diff --git a/internal/watch/dispatcher.go b/internal/watch/dispatcher.go index 5141277..167c1e5 100644 --- a/internal/watch/dispatcher.go +++ b/internal/watch/dispatcher.go @@ -29,12 +29,15 @@ func newDockerRunner(projectDir, composeFile string) *dockerRunner { if composeFile == "" { composeFile = ".frank/compose.yaml" } + return &dockerRunner{projectDir: projectDir, composeFile: composeFile} } func (d *dockerRunner) Trigger(ctx context.Context, kind TriggerKind) error { prefix := []string{"compose", "--project-directory", ".", "-f", d.composeFile} + var args []string + switch kind { case TriggerQueueRestart: args = append(prefix, "exec", "-T", "laravel.test", "php", "artisan", "queue:restart") @@ -43,18 +46,23 @@ func (d *dockerRunner) Trigger(ctx context.Context, kind TriggerKind) error { default: return fmt.Errorf("watch: unknown trigger kind %d", kind) } + cmd := exec.CommandContext(ctx, "docker", args...) cmd.Dir = d.projectDir + var buf bytes.Buffer cmd.Stdout = &buf cmd.Stderr = &buf + if err := cmd.Run(); err != nil { out := bytes.TrimSpace(buf.Bytes()) if len(out) > 0 { return fmt.Errorf("%w: %s", err, string(out)) } + return err } + return nil } @@ -76,10 +84,12 @@ func (w *Watcher) runDebouncer(ctx context.Context) { if base <= 0 { base = defaultDebounceBase } + max := w.cfg.DebounceMax if max <= 0 { max = defaultDebounceMax } + if base > max { base = max } @@ -125,6 +135,7 @@ func (w *Watcher) runDebouncer(ctx context.Context) { // in-flight docker calls promptly. dispatchCtx, cancel := context.WithCancel(ctx) ok := w.dispatch(dispatchCtx) + cancel() if ok { @@ -158,24 +169,30 @@ func (w *Watcher) dispatch(ctx context.Context) bool { } results := make(chan result, len(kinds)) + var wg sync.WaitGroup for _, k := range kinds { wg.Add(1) + go func(kind TriggerKind) { defer wg.Done() results <- result{kind: kind, err: w.runner.Trigger(ctx, kind)} }(k) } + wg.Wait() close(results) allOK := true + for r := range results { if r.err != nil { allOK = false + fmt.Fprintf(os.Stderr, "frank watch: WARN %s failed: %v\n", triggerLabel(r.kind), r.err) } } + return allOK } diff --git a/internal/watch/dispatcher_test.go b/internal/watch/dispatcher_test.go index e291ac1..52b8cea 100644 --- a/internal/watch/dispatcher_test.go +++ b/internal/watch/dispatcher_test.go @@ -37,9 +37,11 @@ func (f *fakeRunner) Trigger(_ context.Context, kind TriggerKind) error { if f.onTrigger != nil { return f.onTrigger(kind) } + if err, ok := f.errByKind[kind]; ok { return err } + return nil } @@ -48,6 +50,7 @@ func (f *fakeRunner) snapshot() []TriggerKind { defer f.mu.Unlock() out := make([]TriggerKind, len(f.calls)) copy(out, f.calls) + return out } @@ -56,6 +59,7 @@ func (f *fakeRunner) snapshot() []TriggerKind { // runDebouncer directly or push to w.events manually. func newTestWatcher(t *testing.T, runner Runner, scheduleEnabled bool) *Watcher { t.Helper() + w, err := New(Config{ ProjectRoot: t.TempDir(), ScheduleEnabled: scheduleEnabled, @@ -66,6 +70,7 @@ func newTestWatcher(t *testing.T, runner Runner, scheduleEnabled bool) *Watcher if err != nil { t.Fatalf("New: %v", err) } + return w } @@ -82,12 +87,14 @@ func TestDebounce_CoalescesBurst(t *testing.T) { // Push 100 events into the channel within ~50ms. start := time.Now() + for i := 0; i < 100; i++ { select { case w.events <- fsnotify.Event{Name: "x.php", Op: fsnotify.Write}: default: // Channel full — ignore; the debouncer already sees *something*. } + if time.Since(start) > 50*time.Millisecond { break } @@ -103,9 +110,11 @@ func TestDebounce_CoalescesBurst(t *testing.T) { if len(calls) != 1 { t.Fatalf("expected exactly 1 trigger after coalesced burst, got %d (calls=%v)", len(calls), calls) } + if calls[0] != TriggerQueueRestart { t.Errorf("coalesced trigger should be queue:restart, got %v", calls[0]) } + if n := atomic.LoadInt32(&fake.scheduleCalls); n != 0 { t.Errorf("schedule restart should NOT fire when ScheduleEnabled=false, got %d", n) } @@ -120,9 +129,11 @@ func TestDispatch_SkipsScheduleWhenDisabled(t *testing.T) { if ok := w.dispatch(context.Background()); !ok { t.Fatalf("dispatch should succeed with no errors") } + if n := atomic.LoadInt32(&fake.queueCalls); n != 1 { t.Errorf("expected 1 queue:restart, got %d", n) } + if n := atomic.LoadInt32(&fake.scheduleCalls); n != 0 { t.Errorf("expected 0 schedule restarts, got %d", n) } @@ -136,9 +147,11 @@ func TestDispatch_FiresBothWhenScheduleEnabled(t *testing.T) { if ok := w.dispatch(context.Background()); !ok { t.Fatalf("dispatch should succeed") } + if n := atomic.LoadInt32(&fake.queueCalls); n != 1 { t.Errorf("expected 1 queue:restart, got %d", n) } + if n := atomic.LoadInt32(&fake.scheduleCalls); n != 1 { t.Errorf("expected 1 schedule restart, got %d", n) } @@ -157,9 +170,11 @@ func TestDispatch_PartialFailure(t *testing.T) { if ok := w.dispatch(context.Background()); ok { t.Fatalf("dispatch should report failure when any trigger errors") } + if n := atomic.LoadInt32(&fake.queueCalls); n != 1 { t.Errorf("queue:restart must still attempt despite schedule failure, got %d", n) } + if n := atomic.LoadInt32(&fake.scheduleCalls); n != 1 { t.Errorf("schedule restart must attempt once, got %d", n) } @@ -170,6 +185,7 @@ func TestDispatch_PartialFailure(t *testing.T) { // runner starts succeeding → next window uses base (20ms) again. func TestBackoff_EscalatesOnFailureAndResets(t *testing.T) { var attempts int32 + fake := &fakeRunner{ onTrigger: func(kind TriggerKind) error { n := atomic.AddInt32(&attempts, 1) @@ -223,12 +239,16 @@ func TestBackoff_EscalatesOnFailureAndResets(t *testing.T) { // more window with a tight timing budget. fake2 := &fakeRunner{} w2 := newTestWatcher(t, fake2, false) + ctx2, cancel2 := context.WithCancel(context.Background()) defer cancel2() + done2 := make(chan struct{}) + go func() { w2.runDebouncer(ctx2); close(done2) }() w2.events <- fsnotify.Event{Name: "x.php", Op: fsnotify.Write} + time.Sleep(60 * time.Millisecond) // 3x base cancel2() <-done2 diff --git a/internal/watch/status.go b/internal/watch/status.go index 7d18372..bbc0436 100644 --- a/internal/watch/status.go +++ b/internal/watch/status.go @@ -27,6 +27,7 @@ func WritePidfile(path string, pid int) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } + return os.WriteFile(path, []byte(strconv.Itoa(pid)), 0o644) } @@ -39,16 +40,20 @@ func ReadPidfile(path string) (int, error) { if errors.Is(err, fs.ErrNotExist) { return 0, nil } + return 0, err } + s := strings.TrimSpace(string(data)) if s == "" { return 0, nil } + pid, err := strconv.Atoi(s) if err != nil { return 0, fmt.Errorf("pidfile %q: invalid pid %q", path, s) } + return pid, nil } @@ -62,14 +67,17 @@ func pidAlive(pid int) bool { if pid <= 0 { return false } + p, err := os.FindProcess(pid) if err != nil { return false } + err = p.Signal(syscall.Signal(0)) if err == nil { return true } + return errors.Is(err, syscall.EPERM) } @@ -119,6 +127,7 @@ func (s Status) Uptime() time.Duration { if s.StartedAt.IsZero() { return 0 } + return time.Since(s.StartedAt) } @@ -183,6 +192,7 @@ func (c *StatusChecker) Check() (Status, error) { // signal errors (already exiting, race with shutdown). _ = c.killFn(pid) _ = c.removeFn(c.PidfilePath) + return Status{State: StatusOrphaned, PID: pid, StartedAt: startedAt}, nil } @@ -194,5 +204,6 @@ func (c *StatusChecker) pidfileMTime() time.Time { if err != nil { return time.Time{} } + return info.ModTime() } diff --git a/internal/watch/status_test.go b/internal/watch/status_test.go index 84cfbc0..24a6ca7 100644 --- a/internal/watch/status_test.go +++ b/internal/watch/status_test.go @@ -12,6 +12,7 @@ func TestReadPidfile_Missing(t *testing.T) { if err != nil { t.Fatalf("missing pidfile should not error, got %v", err) } + if pid != 0 { t.Fatalf("missing pidfile should return 0, got %d", pid) } @@ -19,10 +20,12 @@ func TestReadPidfile_Missing(t *testing.T) { func TestReadPidfile_Malformed(t *testing.T) { dir := t.TempDir() + path := filepath.Join(dir, "watch.pid") if err := os.WriteFile(path, []byte("not-a-number\n"), 0o644); err != nil { t.Fatal(err) } + if _, err := ReadPidfile(path); err == nil { t.Fatalf("malformed pidfile should error") } @@ -33,10 +36,12 @@ func TestWriteAndReadPidfile_RoundTrip(t *testing.T) { if err := WritePidfile(path, 4242); err != nil { t.Fatalf("WritePidfile: %v", err) } + pid, err := ReadPidfile(path) if err != nil { t.Fatalf("ReadPidfile: %v", err) } + if pid != 4242 { t.Fatalf("round-trip pid mismatch: got %d want 4242", pid) } @@ -56,6 +61,7 @@ func newCheckerWithFakes(t *testing.T, laravelRunning bool, pidAlive bool) (*Sta c.killFn = func(pid int) error { f.killedPID = pid f.killCount++ + return nil } prevRemove := c.removeFn @@ -63,6 +69,7 @@ func newCheckerWithFakes(t *testing.T, laravelRunning bool, pidAlive bool) (*Sta f.removedPaths = append(f.removedPaths, p) return prevRemove(p) } + return c, f } @@ -75,6 +82,7 @@ type fakes struct { func writePid(t *testing.T, path string, pid int) { t.Helper() + if err := WritePidfile(path, pid); err != nil { t.Fatalf("WritePidfile: %v", err) } @@ -89,9 +97,11 @@ func TestCheck_StoppedWhenPidfileMissing(t *testing.T) { if err != nil { t.Fatalf("Check: %v", err) } + if got.State != StatusStopped { t.Errorf("state = %v, want stopped", got.State) } + if f.killCount != 0 { t.Errorf("no kill expected, got %d", f.killCount) } @@ -107,12 +117,15 @@ func TestCheck_StoppedCleansStalePidfile(t *testing.T) { if err != nil { t.Fatalf("Check: %v", err) } + if got.State != StatusStopped { t.Errorf("state = %v, want stopped", got.State) } + if _, err := os.Stat(c.PidfilePath); !errors.Is(err, os.ErrNotExist) { t.Errorf("stale pidfile should be removed, stat err = %v", err) } + if f.killCount != 0 { t.Errorf("dead pid should not be signalled, got kill count %d", f.killCount) } @@ -128,18 +141,23 @@ func TestCheck_RunningWhenPidAliveAndLaravelUp(t *testing.T) { if err != nil { t.Fatalf("Check: %v", err) } + if got.State != StatusRunning { t.Errorf("state = %v, want running", got.State) } + if got.PID != 12345 { t.Errorf("PID = %d, want 12345", got.PID) } + if got.StartedAt.IsZero() { t.Errorf("StartedAt should be set from pidfile mtime") } + if _, err := os.Stat(c.PidfilePath); err != nil { t.Errorf("running state must preserve pidfile, stat err = %v", err) } + if f.killCount != 0 { t.Errorf("running state must not signal, got kill count %d", f.killCount) } @@ -155,15 +173,19 @@ func TestCheck_OrphanKillsAndUnlinks(t *testing.T) { if err != nil { t.Fatalf("Check: %v", err) } + if got.State != StatusOrphaned { t.Errorf("state = %v, want orphaned", got.State) } + if got.PID != 54321 { t.Errorf("PID = %d, want 54321", got.PID) } + if f.killCount != 1 || f.killedPID != 54321 { t.Errorf("expected one kill of 54321, got count=%d pid=%d", f.killCount, f.killedPID) } + if _, err := os.Stat(c.PidfilePath); !errors.Is(err, os.ErrNotExist) { t.Errorf("orphan path must unlink pidfile, stat err = %v", err) } @@ -177,6 +199,7 @@ func TestCheck_MalformedPidfileReturnsErrorAndCleans(t *testing.T) { if err := os.MkdirAll(filepath.Dir(c.PidfilePath), 0o755); err != nil { t.Fatal(err) } + if err := os.WriteFile(c.PidfilePath, []byte("garbage"), 0o644); err != nil { t.Fatal(err) } @@ -185,9 +208,11 @@ func TestCheck_MalformedPidfileReturnsErrorAndCleans(t *testing.T) { if err == nil { t.Fatalf("malformed pidfile should surface parse error") } + if got.State != StatusStopped { t.Errorf("state = %v, want stopped", got.State) } + if _, err := os.Stat(c.PidfilePath); !errors.Is(err, os.ErrNotExist) { t.Errorf("malformed pidfile must unlink, stat err = %v", err) } @@ -197,6 +222,7 @@ func TestCheck_MalformedPidfileReturnsErrorAndCleans(t *testing.T) { func TestPidfilePath_AlwaysDotFrank(t *testing.T) { got := PidfilePath("/some/project") want := filepath.Join("/some/project", ".frank", "watch.pid") + if got != want { t.Errorf("PidfilePath = %q, want %q", got, want) } diff --git a/internal/watch/walker.go b/internal/watch/walker.go index 4835fef..d71f3bb 100644 --- a/internal/watch/walker.go +++ b/internal/watch/walker.go @@ -24,15 +24,18 @@ func compileIgnore(projectRoot string) *ignore.GitIgnore { baseline := append([]string(nil), baselineIgnorePatterns...) giPath := filepath.Join(projectRoot, ".gitignore") + data, err := os.ReadFile(giPath) if err != nil { if !errors.Is(err, fs.ErrNotExist) { fmt.Fprintf(os.Stderr, "frank watch: WARN failed to read %s (%v); falling back to baseline ignore patterns only\n", giPath, err) } + return ignore.CompileIgnoreLines(baseline...) } lines := strings.Split(string(data), "\n") + return ignore.CompileIgnoreLines(append(baseline, lines...)...) } @@ -51,18 +54,22 @@ func (m *ignoreMatcher) Matches(relPath string, isDir bool) bool { if m == nil || m.gi == nil { return false } + p := filepath.ToSlash(relPath) if p == "" || p == "." { return false } + if m.gi.MatchesPath(p) { return true } + if isDir && !strings.HasSuffix(p, "/") { if m.gi.MatchesPath(p + "/") { return true } } + return false } @@ -81,23 +88,28 @@ func (w *Watcher) armWatches() (int, error) { matcher := &ignoreMatcher{gi: w.gitignore} added := make(map[string]struct{}) + var firstErr error add := func(dir string) { if _, ok := added[dir]; ok { return } + if err := w.fsw.Add(dir); err != nil { if firstErr == nil { firstErr = fmt.Errorf("add watch %q: %w", dir, err) } + return } + added[dir] = struct{}{} } for _, root := range defaultWatchRoots { absRoot := filepath.Join(w.cfg.ProjectRoot, root) + info, err := os.Stat(absRoot) if err != nil || !info.IsDir() { // Missing root is not an error — Laravel projects vary; e.g. @@ -112,11 +124,14 @@ func (w *Watcher) armWatches() (int, error) { if firstErr == nil { firstErr = err } + if d != nil && d.IsDir() { return fs.SkipDir } + return nil } + if !d.IsDir() { return nil } @@ -129,7 +144,9 @@ func (w *Watcher) armWatches() (int, error) { if matcher.Matches(rel, true) { return fs.SkipDir } + add(path) + return nil }) if werr != nil && firstErr == nil { @@ -142,14 +159,17 @@ func (w *Watcher) armWatches() (int, error) { // parent dir is ignored (defensive — unlikely for root-level files). for _, file := range defaultWatchFiles { parent := filepath.Dir(filepath.Join(w.cfg.ProjectRoot, file)) + info, err := os.Stat(parent) if err != nil || !info.IsDir() { continue } + rel, relErr := filepath.Rel(w.cfg.ProjectRoot, parent) if relErr == nil && matcher.Matches(rel, true) { continue } + add(parent) } @@ -174,14 +194,17 @@ func (w *Watcher) handleDirEvent(ev fsnotify.Event) { if err != nil || !info.IsDir() { return } + rel, err := filepath.Rel(w.cfg.ProjectRoot, ev.Name) if err != nil { return } + matcher := &ignoreMatcher{gi: w.gitignore} if matcher.Matches(rel, true) { return } + _ = w.fsw.Add(ev.Name) case ev.Op&fsnotify.Remove != 0: diff --git a/internal/watch/walker_test.go b/internal/watch/walker_test.go index bc200f7..02c3ecf 100644 --- a/internal/watch/walker_test.go +++ b/internal/watch/walker_test.go @@ -49,10 +49,12 @@ func fakeLaravelProject(t *testing.T) string { if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { t.Fatalf("mkdir %s: %v", abs, err) } + if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { t.Fatalf("write %s: %v", abs, err) } } + return root } @@ -60,6 +62,7 @@ func fakeLaravelProject(t *testing.T) string { // relative to the project root. Used by multiple tests. func watchedDirs(t *testing.T, root string, withGitignore bool) (map[string]struct{}, *Watcher) { t.Helper() + if !withGitignore { // Remove .gitignore to test baseline-only path. _ = os.Remove(filepath.Join(root, ".gitignore")) @@ -69,6 +72,7 @@ func watchedDirs(t *testing.T, root string, withGitignore bool) (map[string]stru if err != nil { t.Fatalf("fsnotify.NewWatcher: %v", err) } + t.Cleanup(func() { _ = fsw.Close() }) w := &Watcher{ @@ -84,13 +88,16 @@ func watchedDirs(t *testing.T, root string, withGitignore bool) (map[string]stru } set := make(map[string]struct{}) + for _, p := range fsw.WatchList() { rel, err := filepath.Rel(root, p) if err != nil { t.Fatalf("rel: %v", err) } + set[filepath.ToSlash(rel)] = struct{}{} } + return set, w } @@ -132,11 +139,13 @@ func TestWalker_RootParentAddedOnce(t *testing.T) { } count := 0 + for p := range watched { if p == "." { count++ } } + if count != 1 { t.Errorf("project root added %d times, want 1", count) } @@ -173,6 +182,7 @@ func TestWalker_DegradedExtraWatches(t *testing.T) { if err := os.MkdirAll(gen, 0o755); err != nil { t.Fatalf("mkdir: %v", err) } + if err := os.WriteFile(filepath.Join(gen, "gen.php"), []byte("= 1 { break @@ -374,6 +390,7 @@ func TestArmSuppression_DropsEventsDuringQuietWindow(t *testing.T) { root := fakeLaravelProject(t) fake := &fakeRunner{} + w, err := New(Config{ ProjectRoot: root, Runner: fake, @@ -387,7 +404,9 @@ func TestArmSuppression_DropsEventsDuringQuietWindow(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + done := make(chan error, 1) + go func() { done <- w.Start(ctx) }() // Let Start arm watches and start the suppression window. @@ -401,6 +420,7 @@ func TestArmSuppression_DropsEventsDuringQuietWindow(t *testing.T) { // Wait past the debounce window but still under suppression. time.Sleep(100 * time.Millisecond) + if n := atomic.LoadInt32(&fake.queueCalls); n != 0 { t.Fatalf("dispatch fired inside suppression window: got %d calls", n) } @@ -414,6 +434,7 @@ func TestArmSuppression_DropsEventsDuringQuietWindow(t *testing.T) { } deadline := time.After(2 * time.Second) + for { if n := atomic.LoadInt32(&fake.queueCalls); n >= 1 { break @@ -443,9 +464,11 @@ func TestStop_Idempotent(t *testing.T) { if err != nil { t.Fatalf("New: %v", err) } + if err := w.Stop(); err != nil { t.Fatalf("first Stop: %v", err) } + if err := w.Stop(); err != nil { t.Fatalf("second Stop: %v", err) } @@ -457,6 +480,7 @@ func TestNewSubdir_AutoWatched(t *testing.T) { root := fakeLaravelProject(t) fake := &fakeRunner{} + w, err := New(Config{ ProjectRoot: root, Runner: fake, @@ -490,6 +514,7 @@ func TestNewSubdir_AutoWatched(t *testing.T) { } deadline := time.After(2 * time.Second) + for { if n := atomic.LoadInt32(&fake.queueCalls); n >= 1 { break @@ -520,11 +545,13 @@ func TestDeletedSubdir_WatchRemoved(t *testing.T) { if err := os.MkdirAll(jobsDir, 0o755); err != nil { t.Fatalf("mkdir: %v", err) } + if err := os.WriteFile(filepath.Join(jobsDir, "Job.php"), []byte("= 1 { break @@ -582,5 +610,6 @@ func keys(m map[string]struct{}) []string { for k := range m { out = append(out, k) } + return out } diff --git a/internal/watch/watch.go b/internal/watch/watch.go index aa38b25..8f4ef38 100644 --- a/internal/watch/watch.go +++ b/internal/watch/watch.go @@ -152,6 +152,7 @@ func New(cfg Config) (*Watcher, error) { if runner == nil { runner = newDockerRunner(cfg.ProjectRoot, cfg.DockerComposeFile) } + return &Watcher{ cfg: cfg, events: make(chan fsnotify.Event, 128), @@ -181,6 +182,7 @@ func (w *Watcher) Start(ctx context.Context) error { if err != nil { return err } + w.fsw = fsw w.gitignore = compileIgnore(w.cfg.ProjectRoot) @@ -189,6 +191,7 @@ func (w *Watcher) Start(ctx context.Context) error { // watches succeeded. Detailed logging lives in armWatches. _ = werr } + w.armedAt = time.Now() debouncerDone := make(chan struct{}) @@ -196,6 +199,7 @@ func (w *Watcher) Start(ctx context.Context) error { defer close(debouncerDone) w.runDebouncer(ctx) }() + defer func() { <-debouncerDone }() for { @@ -203,6 +207,7 @@ func (w *Watcher) Start(ctx context.Context) error { case <-ctx.Done(): _ = w.fsw.Close() w.stopOnce.Do(func() { close(w.done) }) + return ctx.Err() case <-w.done: _ = w.fsw.Close() @@ -211,7 +216,9 @@ func (w *Watcher) Start(ctx context.Context) error { if !ok { return nil } + w.handleDirEvent(ev) + if _, fire := w.classify(ev); !fire { continue } @@ -246,5 +253,6 @@ func (w *Watcher) Stop() error { w.stopOnce.Do(func() { close(w.done) }) + return nil } diff --git a/internal/watch/watch_test.go b/internal/watch/watch_test.go index 0b724c8..93e2ca3 100644 --- a/internal/watch/watch_test.go +++ b/internal/watch/watch_test.go @@ -14,6 +14,7 @@ func TestNew(t *testing.T) { if err != nil { t.Fatalf("New() returned error: %v", err) } + if w == nil { t.Fatal("New() returned nil watcher") } diff --git a/internal/workertop/discover.go b/internal/workertop/discover.go index df88d04..6066825 100644 --- a/internal/workertop/discover.go +++ b/internal/workertop/discover.go @@ -80,6 +80,7 @@ func discoverWorkers(cfg *config.Config, projectName string, d containerInspecto if cfg == nil { return nil, fmt.Errorf("discoverWorkers: nil config") } + if d == nil { return nil, fmt.Errorf("discoverWorkers: nil inspector") } @@ -94,6 +95,7 @@ func discoverWorkers(cfg *config.Config, projectName string, d containerInspecto if err := resolveState(d, containerName(projectName, spec.Name), &spec); err != nil { return nil, err } + specs = append(specs, spec) } @@ -107,6 +109,7 @@ func discoverWorkers(cfg *config.Config, projectName string, d containerInspecto if err := resolveState(d, containerName(projectName, spec.Name), &spec); err != nil { return nil, err } + specs = append(specs, spec) } } @@ -115,6 +118,7 @@ func discoverWorkers(cfg *config.Config, projectName string, d containerInspecto if err != nil { return nil, fmt.Errorf("discoverWorkers: list ad-hoc workers: %w", err) } + for _, name := range adhoc { spec := PaneSpec{ Name: name, @@ -123,6 +127,7 @@ func discoverWorkers(cfg *config.Config, projectName string, d containerInspecto if err := resolveState(d, name, &spec); err != nil { return nil, err } + specs = append(specs, spec) } @@ -142,6 +147,7 @@ func resolveState(d containerInspector, name string, spec *PaneSpec) error { if err != nil { return fmt.Errorf("inspect %s: %w", name, err) } + spec.ContainerID = id switch status { case "": @@ -156,5 +162,6 @@ func resolveState(d containerInspector, name string, spec *PaneSpec) error { spec.State = StateExited spec.ExitCode = exitCode } + return nil } diff --git a/internal/workertop/discover_test.go b/internal/workertop/discover_test.go index 93574d3..7c5fd37 100644 --- a/internal/workertop/discover_test.go +++ b/internal/workertop/discover_test.go @@ -30,10 +30,12 @@ func (f *fakeInspector) InspectContainer(name string) (string, int, string, erro if err := f.inspectErr[name]; err != nil { return "", 0, "", err } + c, ok := f.containers[name] if !ok { return "", 0, "", nil } + return c.status, c.exitCode, c.id, nil } @@ -41,6 +43,7 @@ func (f *fakeInspector) AdhocWorkerNames(projectName string) ([]string, error) { if f.adhocErr != nil { return nil, f.adhocErr } + return f.adhoc, nil } @@ -210,11 +213,14 @@ func TestDiscoverWorkers(t *testing.T) { if err == nil { t.Fatalf("expected error, got nil") } + return } + if err != nil { t.Fatalf("unexpected error: %v", err) } + if !reflect.DeepEqual(got, tt.want) { t.Errorf("pane specs mismatch\n got: %#v\nwant: %#v", got, tt.want) } @@ -226,6 +232,7 @@ func TestDiscoverWorkers_NilArgs(t *testing.T) { if _, err := discoverWorkers(nil, "p", &fakeInspector{}); err == nil { t.Errorf("expected error for nil config") } + if _, err := discoverWorkers(&config.Config{}, "p", nil); err == nil { t.Errorf("expected error for nil inspector") } diff --git a/internal/workertop/layout.go b/internal/workertop/layout.go index 0dccb93..bb6e562 100644 --- a/internal/workertop/layout.go +++ b/internal/workertop/layout.go @@ -101,9 +101,11 @@ func ComputeLayout(w, h int, rows []RowSpec, minPaneWidth int) Layout { visibleRows := len(rows) perRow := 0 + if visibleRows > 0 { perRow = budget / visibleRows } + if perRow < minRowHeight { layout.Vertical = true perRow = minRowHeight @@ -148,6 +150,7 @@ func computeRow(w, perRow int, spec RowSpec, minPaneWidth int) RowLayout { if panesPerPage < 1 { panesPerPage = 1 } + if panesPerPage > paneCount { panesPerPage = paneCount } @@ -168,6 +171,7 @@ func computeRow(w, perRow int, spec RowSpec, minPaneWidth int) RowLayout { for i := range row.Panes { row.Panes[i] = PaneLayout{Width: pageWidth, Height: perRow} } + return row } @@ -180,5 +184,6 @@ func computeRow(w, perRow int, spec RowSpec, minPaneWidth int) RowLayout { for i := range row.Panes { row.Panes[i] = PaneLayout{Width: colWidth, Height: perRow} } + return row } diff --git a/internal/workertop/layout_test.go b/internal/workertop/layout_test.go index 50b27af..861343c 100644 --- a/internal/workertop/layout_test.go +++ b/internal/workertop/layout_test.go @@ -7,9 +7,11 @@ func TestComputeLayout_ZeroRows(t *testing.T) { if got.HeaderHeight != 1 || got.FooterHeight != 1 { t.Fatalf("expected header/footer = 1/1, got %d/%d", got.HeaderHeight, got.FooterHeight) } + if len(got.Rows) != 0 { t.Fatalf("expected zero rows, got %d", len(got.Rows)) } + if got.Vertical { t.Fatalf("expected Vertical=false for empty layout") } @@ -29,22 +31,28 @@ func TestComputeLayout_NarrowStandard80x24(t *testing.T) { if got.Vertical { t.Fatalf("22/4 = 5 meets the minimum; expected Vertical=false") } + if len(got.Rows) != 4 { t.Fatalf("expected 4 rows, got %d", len(got.Rows)) } + for i, r := range got.Rows { if r.Height != 5 { t.Errorf("row %d: height %d, want 5", i, r.Height) } + if len(r.Panes) != 1 { t.Errorf("row %d: pane count %d, want 1", i, len(r.Panes)) } + if r.Panes[0].Width != 80 { t.Errorf("row %d: pane width %d, want 80", i, r.Panes[0].Width) } + if r.Paginated { t.Errorf("row %d: unexpected Paginated=true", i) } + if r.TruncateTitles { t.Errorf("row %d: unexpected TruncateTitles=true (80 >= 30)", i) } @@ -65,24 +73,31 @@ func TestComputeLayout_Ultrawide320x80(t *testing.T) { if got.Vertical { t.Fatal("ultrawide should not force vertical scroll") } + if len(got.Rows) != 4 { t.Fatalf("expected 4 rows, got %d", len(got.Rows)) } + wantWidths := []int{320, 320 / 3, 320 / 2, 320 / 2} wantCounts := []int{1, 3, 2, 2} + for i, r := range got.Rows { if r.Height != 19 { t.Errorf("row %d: height %d, want 19", i, r.Height) } + if len(r.Panes) != wantCounts[i] { t.Errorf("row %d: pane count %d, want %d", i, len(r.Panes), wantCounts[i]) } + if r.Panes[0].Width != wantWidths[i] { t.Errorf("row %d: pane width %d, want %d", i, r.Panes[0].Width, wantWidths[i]) } + if r.Paginated { t.Errorf("row %d: unexpected Paginated=true", i) } + if r.TruncateTitles { t.Errorf("row %d: unexpected TruncateTitles=true", i) } @@ -98,24 +113,30 @@ func TestComputeLayout_PaginatedSingleRow9Panes80Cols(t *testing.T) { if len(got.Rows) != 1 { t.Fatalf("expected 1 row, got %d", len(got.Rows)) } + r := got.Rows[0] if !r.Paginated { t.Fatal("expected Paginated=true") } + if r.PanesPerPage != 4 { t.Errorf("PanesPerPage = %d, want 4", r.PanesPerPage) } + if r.PageCount != 3 { t.Errorf("PageCount = %d, want 3", r.PageCount) } + if len(r.Panes) != 4 { t.Errorf("rendered pane slots = %d, want 4 (panes per page)", len(r.Panes)) } + for _, p := range r.Panes { if p.Width != 20 { t.Errorf("pane width %d, want 20", p.Width) } } + if !r.TruncateTitles { t.Error("expected TruncateTitles=true (20 < 30)") } @@ -133,6 +154,7 @@ func TestComputeLayout_VeryShortTerminal(t *testing.T) { if !got.Vertical { t.Fatal("expected Vertical=true when budget < rows*minRowHeight") } + for i, r := range got.Rows { if r.Height != 5 { t.Errorf("row %d: height %d, want 5 (virtual min)", i, r.Height) @@ -150,12 +172,15 @@ func TestComputeLayout_MinPaneWidthTruncatesWithoutPaginating(t *testing.T) { if r.Paginated { t.Error("expected Paginated=false (33 >= 20)") } + if !r.TruncateTitles { t.Error("expected TruncateTitles=true (33 < 40)") } + if len(r.Panes) != 3 { t.Fatalf("expected 3 panes, got %d", len(r.Panes)) } + if r.Panes[0].Width != 33 { t.Errorf("pane width %d, want 33", r.Panes[0].Width) } @@ -172,12 +197,15 @@ func TestComputeLayout_BoundaryExactly20Cols(t *testing.T) { if r.Paginated { t.Errorf("colWidth == 20 must not paginate (threshold is strict)") } + if !r.TruncateTitles { t.Errorf("colWidth 20 < minPaneWidth 30 should truncate titles") } + if len(r.Panes) != 4 { t.Fatalf("expected 4 panes, got %d", len(r.Panes)) } + if r.Panes[0].Width != 20 { t.Errorf("pane width %d, want 20", r.Panes[0].Width) } @@ -207,14 +235,17 @@ func TestComputeLayout_ZeroPaneCountRowTreatedAsOne(t *testing.T) { // Defensive: a row with PaneCount == 0 should not crash layout; it // gets treated as a single full-width pane so rendering can proceed. rows := []RowSpec{{Label: "empty", PaneCount: 0}} + got := ComputeLayout(80, 24, rows, 30) if len(got.Rows) != 1 { t.Fatalf("expected 1 row, got %d", len(got.Rows)) } + r := got.Rows[0] if len(r.Panes) != 1 { t.Fatalf("expected 1 pane, got %d", len(r.Panes)) } + if r.Panes[0].Width != 80 { t.Errorf("pane width %d, want 80", r.Panes[0].Width) } @@ -236,16 +267,20 @@ func TestComputeLayout_DefaultsWhenMinPaneWidthZero(t *testing.T) { func TestComputeLayout_WideRowWithManyPanes(t *testing.T) { rows := []RowSpec{{Label: "pool", PaneCount: 4}} got := ComputeLayout(200, 40, rows, 30) + r := got.Rows[0] if r.Paginated { t.Error("200/4 = 50 should not paginate") } + if r.TruncateTitles { t.Error("50 >= 30 should not truncate") } + if len(r.Panes) != 4 { t.Fatalf("expected 4 panes, got %d", len(r.Panes)) } + if r.Panes[0].Width != 50 { t.Errorf("pane width %d, want 50", r.Panes[0].Width) } diff --git a/internal/workertop/logs.go b/internal/workertop/logs.go index a239d85..6fda0fa 100644 --- a/internal/workertop/logs.go +++ b/internal/workertop/logs.go @@ -48,13 +48,16 @@ type CmdStartFn func(ctx context.Context, name string, args ...string) (io.ReadC // returns cmd.Wait so callers can reap the process. func DefaultCmdStartFn(ctx context.Context, name string, args ...string) (io.ReadCloser, func() error, error) { cmd := exec.CommandContext(ctx, name, args...) + stdout, err := cmd.StdoutPipe() if err != nil { return nil, nil, err } + if err := cmd.Start(); err != nil { return nil, nil, err } + return stdout, cmd.Wait, nil } @@ -86,6 +89,7 @@ func NewLogsReader(spec PaneSpec, exec CmdStartFn) *LogsReader { } var argv []string + argv = append(argv, "docker") if spec.Kind == KindAdhoc { argv = append(argv, "logs", "-f", "--tail", "25", spec.Name) @@ -156,8 +160,10 @@ func (r *LogsReader) Run(ctx context.Context) { // and fall through to cleanup. Draining stdout here would // just delay teardown. close(r.lines) + _ = stdout.Close() _ = wait() + return case r.lines <- line: } @@ -165,6 +171,7 @@ func (r *LogsReader) Run(ctx context.Context) { // Scanner is done (EOF, scan error, or underlying reader closed). close(r.lines) + _ = stdout.Close() _ = wait() } diff --git a/internal/workertop/logs_test.go b/internal/workertop/logs_test.go index b5ece83..2805a8c 100644 --- a/internal/workertop/logs_test.go +++ b/internal/workertop/logs_test.go @@ -26,15 +26,19 @@ type fakeExec struct { func (f *fakeExec) fn(ctx context.Context, name string, args ...string) (io.ReadCloser, func() error, error) { f.mu.Lock() f.gotName = name + f.gotArgs = append([]string(nil), args...) f.mu.Unlock() + if f.startErr != nil { return nil, nil, f.startErr } + wait := func() error { if f.waitSignal != nil { <-f.waitSignal } + return f.waitErr } // When ctx is cancelled, release wait so Run can return promptly — @@ -49,24 +53,29 @@ func (f *fakeExec) fn(ctx context.Context, name string, args ...string) (io.Read } }() } + return f.stdout, wait, nil } // argvFor runs NewLogsReader and returns the full argv it would invoke. func argvFor(t *testing.T, spec PaneSpec) []string { t.Helper() + var captured []string + f := &fakeExec{ stdout: io.NopCloser(emptyReader{}), } // Start reader with a cancelled ctx so it bails immediately. ctx, cancel := context.WithCancel(context.Background()) cancel() + r := NewLogsReader(spec, func(ctx context.Context, name string, args ...string) (io.ReadCloser, func() error, error) { captured = append([]string{name}, args...) return f.fn(ctx, name, args...) }) r.Run(ctx) + return captured } @@ -85,6 +94,7 @@ func TestLogsReader_Declared(t *testing.T) { "docker", "compose", "--project-directory", ".", "-f", ".frank/compose.yaml", "logs", "-f", "--no-log-prefix", "--tail", "25", "queue.default.1", } + if !reflect.DeepEqual(argv, want) { t.Fatalf("declared argv mismatch:\n got: %q\nwant: %q", argv, want) } @@ -109,6 +119,7 @@ func TestLogsReader_Declared(t *testing.T) { }() var got []LogLine + timeout := time.After(2 * time.Second) loop: for { @@ -144,6 +155,7 @@ func TestLogsReader_Adhoc(t *testing.T) { argv := argvFor(t, spec) want := []string{"docker", "logs", "-f", "--tail", "25", "queue.default.1.adhoc"} + if !reflect.DeepEqual(argv, want) { t.Fatalf("adhoc argv mismatch:\n got: %q\nwant: %q", argv, want) } @@ -154,15 +166,18 @@ func TestLogsReader_Adhoc(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + go r.Run(ctx) go func() { _, _ = pw.Write([]byte("a\nb\nc\n")) _ = pw.Close() + close(f.waitSignal) }() var got []LogLine + timeout := time.After(2 * time.Second) loop: for { @@ -214,6 +229,7 @@ func TestLogsReader_Cancel(t *testing.T) { // Wait for ctx cancel, then close the pipe so any pending // scanner read unblocks even if the select lost the race. <-ctx.Done() + _ = pw.Close() }() @@ -222,6 +238,7 @@ func TestLogsReader_Cancel(t *testing.T) { if !ok { t.Fatal("lines closed before first line delivered") } + if line.Line != "first" { t.Fatalf("first line = %q, want %q", line.Line, "first") } @@ -233,6 +250,7 @@ func TestLogsReader_Cancel(t *testing.T) { // Drain: lines channel must close after cancel. drainTimeout := time.After(2 * time.Second) + for { select { case _, ok := <-r.Lines(): @@ -261,6 +279,7 @@ func TestLogsReader_StartError(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + go r.Run(ctx) // Channel must close immediately. @@ -289,11 +308,13 @@ func TestLogsReader_EOF(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + go r.Run(ctx) // Close pipe immediately — process exits with no output. Release // wait() so Run can finish. _ = pw.Close() + close(f.waitSignal) // Lines channel closes with no values delivered. diff --git a/internal/workertop/pane.go b/internal/workertop/pane.go index c4fb391..0b1a5d3 100644 --- a/internal/workertop/pane.go +++ b/internal/workertop/pane.go @@ -100,6 +100,7 @@ type StateMsg struct { // ResizeMsg lands. func NewPane(spec PaneSpec) *Pane { vp := viewport.New(0, 0) + return &Pane{ spec: spec, viewport: vp, @@ -124,23 +125,28 @@ func (p *Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.PaneID != p.spec.Name { return p, nil } + p.appendLine(m.Line) + return p, nil case StatsMsg: if p.spec.ContainerID == "" { return p, nil } + if sample, ok := m[p.spec.ContainerID]; ok { p.stats = sample p.statsAge = time.Now() } + return p, nil case ResizeMsg: if m.PaneID != p.spec.Name { return p, nil } + p.width = m.Width p.height = m.Height p.truncateTitle = m.TruncateTitle @@ -148,31 +154,40 @@ func (p *Pane) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // bar. Minimum 1×1 so the viewport never goes negative. innerW := p.width - 2 innerH := p.height - 2 - 1 + if innerW < 1 { innerW = 1 } + if innerH < 1 { innerH = 1 } + p.viewport.Width = innerW p.viewport.Height = innerH + return p, nil case FocusMsg: if m.PaneID != p.spec.Name { return p, nil } + p.focused = m.Focused + return p, nil case StateMsg: if m.PaneID != p.spec.Name { return p, nil } + p.state = m.State p.exitCode = m.ExitCode + return p, nil } + return p, nil } @@ -190,12 +205,15 @@ func (p *Pane) View() string { style := p.borderStyle() w := p.width - 2 h := p.height - 2 + if w < 1 { w = 1 } + if h < 1 { h = 1 } + return style.Width(w).Height(h).Render(body) } @@ -216,6 +234,7 @@ func isRestartNoise(line string) bool { return true } } + return false } @@ -231,16 +250,19 @@ func (p *Pane) appendLine(line string) { } else { p.buffer = append(p.buffer, line) } + if len(p.buffer) > PaneBufferCap { drop := len(p.buffer) - PaneBufferCap p.buffer = append(p.buffer[:0], p.buffer[drop:]...) } + if p.searchQuery == "" { p.viewport.SetContent(strings.Join(p.buffer, "\n")) } else { p.rebuildViewport() return } + if !p.paused { p.viewport.GotoBottom() } @@ -275,14 +297,18 @@ func (p *Pane) rebuildViewport() { p.viewport.SetContent(strings.Join(p.buffer, "\n")) } else { q := strings.ToLower(p.searchQuery) + var filtered []string + for _, line := range p.buffer { if strings.Contains(strings.ToLower(line), q) { filtered = append(filtered, line) } } + p.viewport.SetContent(strings.Join(filtered, "\n")) } + p.viewport.GotoBottom() } @@ -292,6 +318,7 @@ func (p *Pane) borderStyle() lipgloss.Style { if p.focused { return BorderFocused } + switch p.state { case StateExited: return BorderExited @@ -302,6 +329,7 @@ func (p *Pane) borderStyle() lipgloss.Style { if !p.statsAge.IsZero() && time.Since(p.statsAge) > degradedAfter { return BorderDegraded } + return BorderRunning default: // StateMissing and anything else fall through to the exited @@ -319,20 +347,24 @@ func (p *Pane) titleBar() string { if p.truncateTitle { name = p.shortName() } + left := TitleName.Render(name) memStr := "—" + if p.stats.MemBytes > 0 { // Round to nearest MB. docker stats reports in MiB internally; // display rounds to integer for CCTV-clean readability. mb := (p.stats.MemBytes + (1<<20)/2) / (1 << 20) memStr = fmt.Sprintf("%d MB", mb) } + right := TitleMem.Render(memStr) if p.paused { right = right + " " + TitlePaused.Render("[PAUSED]") } + if p.state == StateExited { right = right + " " + TitleExit.Render(fmt.Sprintf("[exited %d]", p.exitCode)) } @@ -346,6 +378,7 @@ func (p *Pane) titleBar() string { leftW := lipgloss.Width(left) rightW := lipgloss.Width(right) + gap := innerW - leftW - rightW if gap < 1 { // Not enough room for both halves; prefer showing the name. @@ -353,6 +386,7 @@ func (p *Pane) titleBar() string { // collide with the name. return left + " " + right } + return left + strings.Repeat(" ", gap) + right } @@ -372,10 +406,12 @@ func (p *Pane) shortName() string { if len(parts) != 3 || parts[0] != "queue" { return p.spec.Name } + pool := parts[1] if len(pool) > 3 { pool = pool[:3] } + return fmt.Sprintf("q.%s.%s", pool, parts[2]) default: return p.spec.Name diff --git a/internal/workertop/pane_test.go b/internal/workertop/pane_test.go index 2052966..fbc7d42 100644 --- a/internal/workertop/pane_test.go +++ b/internal/workertop/pane_test.go @@ -59,31 +59,37 @@ func TestSearch_FiltersBufferLines(t *testing.T) { p.appendLine("ERROR timeout on order #789") p.SetSearch("order") + content := p.viewport.View() if !strings.Contains(content, "order #123") { t.Error("search should include matching lines") } + if strings.Contains(content, "cache hit") { t.Error("search should exclude non-matching lines") } p.SetSearch("ERROR") + content = p.viewport.View() if !strings.Contains(content, "timeout") { t.Error("ERROR search should match error line") } + if strings.Contains(content, "order #123") { t.Error("ERROR search should exclude INFO lines") } // Case insensitive p.SetSearch("error") + content = p.viewport.View() if !strings.Contains(content, "timeout") { t.Error("search should be case-insensitive") } p.ClearSearch() + content = p.viewport.View() if !strings.Contains(content, "cache hit") { t.Error("clear should restore all lines") @@ -99,19 +105,23 @@ func TestPause_FreezesScrollAndUnpauseCatchesUp(t *testing.T) { p.appendLine("line 2") p.TogglePause() + if !p.paused { t.Fatal("expected paused=true after toggle") } posBefore := p.viewport.YOffset + for i := 0; i < 20; i++ { p.appendLine("scrolling line") } + if p.viewport.YOffset != posBefore { t.Errorf("viewport scrolled while paused: was %d, now %d", posBefore, p.viewport.YOffset) } p.TogglePause() + if p.paused { t.Fatal("expected paused=false after second toggle") } diff --git a/internal/workertop/reconciler.go b/internal/workertop/reconciler.go index d69eaa9..a4de458 100644 --- a/internal/workertop/reconciler.go +++ b/internal/workertop/reconciler.go @@ -55,11 +55,13 @@ type Reconciler struct { // were already on screen. Pass nil when there is no pre-existing set. func NewReconciler(lister adhocLister, interval time.Duration, initial []PaneSpec) *Reconciler { current := make(map[string]struct{}) + for _, s := range initial { if s.Kind == KindAdhoc { current[s.Name] = struct{}{} } } + return &Reconciler{ lister: lister, interval: interval, @@ -92,6 +94,7 @@ func (r *Reconciler) Run(ctx context.Context) { if err != nil { continue } + added, removed := diffAdhoc(r.current, latest) for _, c := range added { select { @@ -107,8 +110,10 @@ func (r *Reconciler) Run(ctx context.Context) { }, }: } + r.current[c.Name] = struct{}{} } + for _, name := range removed { select { case <-ctx.Done(): @@ -135,14 +140,17 @@ func diffAdhoc(current map[string]struct{}, latest []AdhocContainer) (added []Ad latestSet := make(map[string]struct{}, len(latest)) for _, c := range latest { latestSet[c.Name] = struct{}{} + if _, ok := current[c.Name]; !ok { added = append(added, c) } } + for name := range current { if _, ok := latestSet[name]; !ok { removed = append(removed, name) } } + return added, removed } diff --git a/internal/workertop/reconciler_test.go b/internal/workertop/reconciler_test.go index 51f7c72..a7c4463 100644 --- a/internal/workertop/reconciler_test.go +++ b/internal/workertop/reconciler_test.go @@ -27,18 +27,22 @@ type fakeTick struct { func (f *fakeLister) ListAdhocWorkers() ([]AdhocContainer, error) { f.mu.Lock() defer f.mu.Unlock() + i := f.calls if i >= len(f.ticks) { i = len(f.ticks) - 1 } + f.calls++ t := f.ticks[i] + return t.list, t.err } func (f *fakeLister) callCount() int { f.mu.Lock() defer f.mu.Unlock() + return f.calls } @@ -46,19 +50,24 @@ func (f *fakeLister) callCount() int { // test if they don't arrive within timeout. func collectEvents(t *testing.T, ch <-chan ReconcileEvent, want int, timeout time.Duration) []ReconcileEvent { t.Helper() + var got []ReconcileEvent + deadline := time.After(timeout) + for len(got) < want { select { case ev, ok := <-ch: if !ok { t.Fatalf("events channel closed early; got %d/%d events: %+v", len(got), want, got) } + got = append(got, ev) case <-deadline: t.Fatalf("timed out waiting for %d events; got %d: %+v", want, len(got), got) } } + return got } @@ -131,22 +140,27 @@ func TestDiffAdhoc(t *testing.T) { for _, n := range tt.current { cur[n] = struct{}{} } + added, removed := diffAdhoc(cur, tt.latest) gotAdded := make([]string, 0, len(added)) for _, a := range added { gotAdded = append(gotAdded, a.Name) } + sort.Strings(gotAdded) sort.Strings(removed) + wantAdded := append([]string(nil), tt.wantAdded...) wantRemoved := append([]string(nil), tt.wantRemoved...) + sort.Strings(wantAdded) sort.Strings(wantRemoved) if !stringSliceEqual(gotAdded, wantAdded) { t.Errorf("added: got %v, want %v", gotAdded, wantAdded) } + if !stringSliceEqual(removed, wantRemoved) { t.Errorf("removed: got %v, want %v", removed, wantRemoved) } @@ -158,14 +172,17 @@ func stringSliceEqual(a, b []string) bool { if len(a) == 0 && len(b) == 0 { return true } + if len(a) != len(b) { return false } + for i := range a { if a[i] != b[i] { return false } } + return true } @@ -179,32 +196,40 @@ func TestReconciler_NoPreexisting(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + go r.Run(ctx) events := collectEvents(t, r.Events(), 2, 500*time.Millisecond) got := map[string]ReconcileEvent{} + for _, ev := range events { if ev.Type != EventAdd { t.Errorf("unexpected event type %v for %s", ev.Type, ev.Spec.Name) } + got[ev.Spec.Name] = ev } + if len(got) != 2 { t.Fatalf("want 2 distinct adds, got %d", len(got)) } + for _, name := range []string{"A", "B"} { ev, ok := got[name] if !ok { t.Errorf("missing EventAdd for %s", name) continue } + if ev.Spec.Kind != KindAdhoc { t.Errorf("%s: kind = %v, want KindAdhoc", name, ev.Spec.Kind) } + if ev.Spec.State != StateRunning { t.Errorf("%s: state = %v, want StateRunning", name, ev.Spec.State) } + if ev.Spec.ContainerID == "" { t.Errorf("%s: empty ContainerID", name) } @@ -226,6 +251,7 @@ func TestReconciler_Preexisting(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + go r.Run(ctx) events := collectEvents(t, r.Events(), 1, 500*time.Millisecond) @@ -233,10 +259,12 @@ func TestReconciler_Preexisting(t *testing.T) { if len(events) != 1 { t.Fatalf("want exactly 1 event, got %d: %+v", len(events), events) } + ev := events[0] if ev.Type != EventAdd { t.Errorf("type = %v, want EventAdd", ev.Type) } + if ev.Spec.Name != "B" { t.Errorf("name = %q, want %q (A was preseeded)", ev.Spec.Name, "B") } @@ -260,24 +288,30 @@ func TestReconciler_Churn(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + go r.Run(ctx) // First tick: adds A and B. first := collectEvents(t, r.Events(), 2, 500*time.Millisecond) firstNames := map[string]bool{} + for _, ev := range first { if ev.Type != EventAdd { t.Errorf("first tick: unexpected type %v for %s", ev.Type, ev.Spec.Name) } + firstNames[ev.Spec.Name] = true } + if !firstNames["A"] || !firstNames["B"] { t.Fatalf("first tick: expected adds {A, B}, got %v", firstNames) } // Second tick: remove A, add C. second := collectEvents(t, r.Events(), 2, 500*time.Millisecond) + var sawRemoveA, sawAddC bool + for _, ev := range second { switch { case ev.Type == EventRemove && ev.Spec.Name == "A": @@ -288,9 +322,11 @@ func TestReconciler_Churn(t *testing.T) { t.Errorf("second tick: unexpected event %+v", ev) } } + if !sawRemoveA { t.Error("second tick: missing EventRemove for A") } + if !sawAddC { t.Error("second tick: missing EventAdd for C") } @@ -308,6 +344,7 @@ func TestReconciler_ListError(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + go r.Run(ctx) // An add should come through eventually despite the two error ticks. @@ -315,6 +352,7 @@ func TestReconciler_ListError(t *testing.T) { if events[0].Type != EventAdd || events[0].Spec.Name != "A" { t.Fatalf("want EventAdd for A, got %+v", events[0]) } + if lister.callCount() < 3 { t.Errorf("want >= 3 list calls (two errors + success), got %d", lister.callCount()) } @@ -330,6 +368,7 @@ func TestReconciler_Cancel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) + go func() { r.Run(ctx) close(done) diff --git a/internal/workertop/stats.go b/internal/workertop/stats.go index 2675a0c..9f29250 100644 --- a/internal/workertop/stats.go +++ b/internal/workertop/stats.go @@ -56,6 +56,7 @@ func NewHub(containerIDs []string, interval time.Duration, exec ExecFn) *Hub { if interval <= 0 { interval = DefaultInterval } + if exec == nil { exec = DefaultExecFn } @@ -114,22 +115,26 @@ func (h *Hub) sampleAndEmit(ctx context.Context) { []string{"stats", "--no-stream", "--format", "{{.ID}} {{.MemPerc}} {{.MemUsage}}"}, h.containerIDs..., ) + out, err := h.exec(ctx, "docker", args...) if err != nil { return } snapshot := make(map[string]StatsSample, len(h.containerIDs)) + scanner := bufio.NewScanner(strings.NewReader(string(out))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } + sample, err := parseStatsLine(line) if err != nil { continue } + snapshot[sample.ContainerID] = sample } @@ -156,6 +161,7 @@ func parseStatsLine(line string) (StatsSample, error) { } pctToken := strings.TrimSuffix(fields[1], "%") + pct, err := strconv.ParseFloat(pctToken, 64) if err != nil { return StatsSample{}, fmt.Errorf("workertop: parse MemPerc %q: %w", fields[1], err) @@ -200,19 +206,24 @@ func parseBytes(s string) (int64, error) { if s == "" { return 0, fmt.Errorf("workertop: empty size string") } + for _, u := range byteUnits { if !strings.HasSuffix(s, u.suffix) { continue } + numStr := strings.TrimSpace(strings.TrimSuffix(s, u.suffix)) if numStr == "" { return 0, fmt.Errorf("workertop: size %q missing numeric part", s) } + num, err := strconv.ParseFloat(numStr, 64) if err != nil { return 0, fmt.Errorf("workertop: parse size %q: %w", s, err) } + return int64(num * float64(u.mult)), nil } + return 0, fmt.Errorf("workertop: unknown size unit in %q", s) } diff --git a/internal/workertop/stats_test.go b/internal/workertop/stats_test.go index 6d7d76a..caa3ade 100644 --- a/internal/workertop/stats_test.go +++ b/internal/workertop/stats_test.go @@ -55,11 +55,14 @@ func TestParseStatsLine(t *testing.T) { if err == nil { t.Fatalf("parseStatsLine(%q): want error, got nil (sample=%+v)", tc.line, got) } + return } + if err != nil { t.Fatalf("parseStatsLine(%q): unexpected error: %v", tc.line, err) } + if got != tc.want { t.Fatalf("parseStatsLine(%q): got %+v, want %+v", tc.line, got, tc.want) } @@ -92,11 +95,14 @@ func TestParseBytes(t *testing.T) { if err == nil { t.Fatalf("parseBytes(%q): want error, got %d", tc.in, got) } + return } + if err != nil { t.Fatalf("parseBytes(%q): unexpected error: %v", tc.in, err) } + if got != tc.want { t.Fatalf("parseBytes(%q): got %d, want %d", tc.in, got, tc.want) } @@ -112,11 +118,14 @@ func TestHubRunEmitsSnapshot(t *testing.T) { calls int args [][]string ) + mockExec := func(ctx context.Context, name string, a ...string) ([]byte, error) { mu.Lock() calls++ + args = append(args, append([]string(nil), a...)) mu.Unlock() + return []byte(canned), nil } @@ -142,9 +151,11 @@ func TestHubRunEmitsSnapshot(t *testing.T) { if len(snap) != 2 { t.Fatalf("snapshot size: got %d, want 2 (snap=%+v)", len(snap), snap) } + if s, ok := snap["abc123"]; !ok || s.MemBytes != 128*1024*1024 || s.MemPct != 42.12 { t.Fatalf("abc123 entry wrong: %+v", s) } + if s, ok := snap["def456"]; !ok || s.MemBytes != 256*1024*1024 { t.Fatalf("def456 entry wrong: %+v", s) } @@ -154,12 +165,15 @@ func TestHubRunEmitsSnapshot(t *testing.T) { mu.Lock() gotArgs := args[0] mu.Unlock() + if len(gotArgs) < 5 { t.Fatalf("docker args too short: %v", gotArgs) } + if gotArgs[0] != "stats" || gotArgs[1] != "--no-stream" || gotArgs[2] != "--format" { t.Fatalf("docker args prefix wrong: %v", gotArgs) } + if !strings.Contains(gotArgs[3], "{{.ID}}") { t.Fatalf("docker format missing ID template: %q", gotArgs[3]) } @@ -249,6 +263,7 @@ func TestHubRunSkipsMalformedLines(t *testing.T) { } hub := NewHub([]string{"abc123", "def456", "ghi789"}, 10*time.Millisecond, mockExec) + ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -259,12 +274,15 @@ func TestHubRunSkipsMalformedLines(t *testing.T) { if len(snap) != 2 { t.Fatalf("want 2 valid entries, got %d: %+v", len(snap), snap) } + if _, ok := snap["abc123"]; !ok { t.Fatal("abc123 missing") } + if _, ok := snap["ghi789"]; !ok { t.Fatal("ghi789 missing") } + if _, ok := snap["def456"]; ok { t.Fatal("def456 should have been skipped (bad percent)") } diff --git a/internal/workertop/top.go b/internal/workertop/top.go index e35b04f..bc1637d 100644 --- a/internal/workertop/top.go +++ b/internal/workertop/top.go @@ -135,6 +135,7 @@ func Run(ctx context.Context, cfg *config.Config, projectName string, dc *docker if inspector == nil { inspector = &dockerInspector{c: dc} } + lister := opts.lister if lister == nil { lister = &dockerLister{c: dc, projectName: projectName} @@ -145,6 +146,7 @@ func Run(ctx context.Context, cfg *config.Config, projectName string, dc *docker if err != nil { return fmt.Errorf("workertop: discover: %w", err) } + if len(specs) == 0 { fmt.Fprintln(os.Stderr, "no workers running — declare in frank.yaml or run `frank worker queue`") return nil @@ -155,11 +157,13 @@ func Run(ctx context.Context, cfg *config.Config, projectName string, dc *docker // 3. Stats hub for every pane with a resolved ID. var ids []string + for _, s := range specs { if s.ContainerID != "" { ids = append(ids, s.ContainerID) } } + hub := NewHub(ids, DefaultInterval, opts.statsExec) // 4. Per-pane log readers — only for running panes. Missing/exited @@ -168,11 +172,14 @@ func Run(ctx context.Context, cfg *config.Config, projectName string, dc *docker if logsExec == nil { logsExec = DefaultCmdStartFn } + readers := make(map[string]*LogsReader, len(specs)) + for _, s := range specs { if s.State != StateRunning { continue } + readers[s.Name] = NewLogsReader(s, logsExec) } @@ -201,18 +208,22 @@ func Run(ctx context.Context, cfg *config.Config, projectName string, dc *docker // 7. Launch background services under a shared cancellable context. // On program exit we cancel and wait for everything to reap. svcCtx, cancel := context.WithCancel(ctx) + var wg sync.WaitGroup wg.Add(1) + go func() { defer wg.Done(); hub.Run(svcCtx) }() for _, r := range readers { wg.Add(1) + go func(r *LogsReader) { defer wg.Done(); r.Run(svcCtx) }(r) } if rec != nil { wg.Add(1) + go func() { defer wg.Done(); rec.Run(svcCtx) }() } @@ -234,6 +245,7 @@ func Run(ctx context.Context, cfg *config.Config, projectName string, dc *docker if runErr != nil { return fmt.Errorf("workertop: bubbletea run: %w", runErr) } + return nil } @@ -250,12 +262,14 @@ func buildRows(cfg *config.Config, specs []PaneSpec) ([]rowGroup, map[string]*Pa // whatever hash order specs might otherwise fall in. poolOrder := make([]string, 0, len(cfg.Workers.Queue)) poolIdx := make(map[string]int, len(cfg.Workers.Queue)) + for i, p := range cfg.Workers.Queue { poolOrder = append(poolOrder, p.Name) poolIdx[p.Name] = i } var scheduleIDs, adhocIDs []string + poolIDs := make([][]string, len(poolOrder)) for _, s := range specs { @@ -279,10 +293,12 @@ func buildRows(cfg *config.Config, specs []PaneSpec) ([]rowGroup, map[string]*Pa paneIDs: scheduleIDs, }) } + for i, name := range poolOrder { if len(poolIDs[i]) == 0 { continue } + rows = append(rows, rowGroup{ kind: KindQueue, label: "pool:" + name, @@ -290,6 +306,7 @@ func buildRows(cfg *config.Config, specs []PaneSpec) ([]rowGroup, map[string]*Pa paneIDs: poolIDs[i], }) } + if len(adhocIDs) > 0 { rows = append(rows, rowGroup{ kind: KindAdhoc, @@ -297,6 +314,7 @@ func buildRows(cfg *config.Config, specs []PaneSpec) ([]rowGroup, map[string]*Pa paneIDs: adhocIDs, }) } + return rows, panes } @@ -308,9 +326,11 @@ func (m *TopModel) Init() tea.Cmd { for id, r := range m.logsReaders { cmds = append(cmds, waitForLogsFor(id, r)) } + if m.reconciler != nil { cmds = append(cmds, waitForReconcile(m.reconciler)) } + return tea.Batch(cmds...) } @@ -324,6 +344,7 @@ func waitForStats(h *Hub) tea.Cmd { if !ok { return nil } + return StatsMsg(snap) } } @@ -338,6 +359,7 @@ func waitForLogsFor(paneID string, r *LogsReader) tea.Cmd { if !ok { return StateMsg{PaneID: paneID, State: StateExited} } + return LogLineMsg(line) } } @@ -351,6 +373,7 @@ func waitForReconcile(r *Reconciler) tea.Cmd { if !ok { return nil } + return reconcileMsg{evt: evt} } } @@ -359,11 +382,11 @@ func waitForReconcile(r *Reconciler) tea.Cmd { // messages are handled and which re-subscribe. func (m *TopModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.recomputeLayout() + return m, m.resizeAllCmd() case tea.KeyMsg: @@ -375,13 +398,16 @@ func (m *TopModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case StatsMsg: // Fan out to every pane, then re-subscribe. var cmds []tea.Cmd + for _, p := range m.panesByID { _, c := p.Update(msg) if c != nil { cmds = append(cmds, c) } } + cmds = append(cmds, waitForStats(m.statsHub)) + return m, tea.Batch(cmds...) case LogLineMsg: @@ -393,6 +419,7 @@ func (m *TopModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if r, ok := m.logsReaders[msg.PaneID]; ok { cmd = waitForLogsFor(msg.PaneID, r) } + return m, cmd case StateMsg: @@ -402,6 +429,7 @@ func (m *TopModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // EOF → the reader's goroutine is done. Drop it from the map // so we don't keep a dead subscription alive. No re-subscribe. delete(m.logsReaders, msg.PaneID) + return m, nil case restartSequenceMsg: @@ -424,8 +452,10 @@ func (m *TopModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case paneCleanupMsg: m.removePane(msg.paneID) m.recomputeLayout() + return m, m.resizeAllCmd() } + return m, nil } @@ -434,6 +464,7 @@ func (m *TopModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.searching { return m.handleSearchKey(msg) } + switch msg.String() { case "q", "ctrl+c": return m, tea.Quit @@ -450,28 +481,34 @@ func (m *TopModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.focusedID != "" { m.zoomedID = m.focusedID m.recomputeLayout() + return m, m.resizeAllCmd() } + return m, nil case "esc": if m.zoomedID != "" { m.zoomedID = "" m.recomputeLayout() + return m, m.resizeAllCmd() } + return m, nil case "r": if m.restartStatus == "" { return m, m.restartAllWorkersCmd() } + return m, nil case "R": if m.restartStatus == "" && m.focusedID != "" { return m, m.restartOneWorkerCmd(m.focusedID) } + return m, nil case "p": @@ -480,6 +517,7 @@ func (m *TopModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { p.TogglePause() } } + return m, nil case "/": @@ -487,6 +525,7 @@ func (m *TopModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.searching = true m.searchQuery = "" } + return m, nil case "pgup", "pgdown", "g", "G": @@ -498,8 +537,10 @@ func (m *TopModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { p.viewport, _ = p.viewport.Update(msg) } } + return m, nil } + return m, nil } @@ -512,9 +553,11 @@ func (m *TopModel) handleSearchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case tea.KeyEsc: m.searching = false m.searchQuery = "" + if p, ok := m.panesByID[m.focusedID]; ok { p.ClearSearch() } + return m, nil case tea.KeyEnter: @@ -533,6 +576,7 @@ func (m *TopModel) handleSearchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if p, ok := m.panesByID[m.focusedID]; ok { p.SetSearch(m.searchQuery) } + return m, nil } @@ -575,10 +619,12 @@ func (m *TopModel) handleReconcile(evt ReconcileEvent) tea.Cmd { if p, ok := m.panesByID[evt.Spec.Name]; ok { p.Update(StateMsg{PaneID: evt.Spec.Name, State: StateExited}) } + cmds = append(cmds, cleanupAfter(evt.Spec.Name, 2*time.Second)) } cmds = append(cmds, waitForReconcile(m.reconciler)) + return tea.Batch(cmds...) } @@ -598,8 +644,10 @@ func (m *TopModel) removePane(paneID string) { // don't control here. The reader will reap when the container's // docker logs subprocess hits EOF or when the outer ctx fires. _ = r + delete(m.logsReaders, paneID) } + delete(m.panesByID, paneID) for i := range m.rowOrder { @@ -613,16 +661,19 @@ func (m *TopModel) removePane(paneID string) { } // Drop now-empty rows so the layout doesn't render hollow space. pruned := m.rowOrder[:0] + for _, row := range m.rowOrder { if len(row.paneIDs) > 0 { pruned = append(pruned, row) } } + m.rowOrder = pruned if m.focusedID == paneID { m.focusedID = m.firstPaneID() } + if m.zoomedID == paneID { m.zoomedID = "" } @@ -637,6 +688,7 @@ func (m *TopModel) appendAdhocRow(paneID string) { return } } + m.rowOrder = append(m.rowOrder, rowGroup{ kind: KindAdhoc, label: "adhoc", @@ -651,13 +703,16 @@ func (m *TopModel) focusShift(delta int) { if len(order) == 0 { return } + idx := 0 + for i, id := range order { if id == m.focusedID { idx = i break } } + idx = (idx + delta + len(order)) % len(order) m.focusedID = order[idx] } @@ -668,6 +723,7 @@ func (m *TopModel) flatPaneOrder() []string { for _, row := range m.rowOrder { out = append(out, row.paneIDs...) } + return out } @@ -679,6 +735,7 @@ func (m *TopModel) firstPaneID() string { return row.paneIDs[0] } } + return "" } @@ -687,6 +744,7 @@ func (m *TopModel) broadcastFocus() tea.Cmd { for id, p := range m.panesByID { p.Update(FocusMsg{PaneID: id, Focused: id == m.focusedID}) } + return nil } @@ -697,6 +755,7 @@ func (m *TopModel) recomputeLayout() { for i, r := range m.rowOrder { specs[i] = RowSpec{Label: r.label, PaneCount: len(r.paneIDs)} } + m.layout = ComputeLayout(m.width, m.height, specs, m.opts.MinPaneWidth) m.recomputeBounds() } @@ -710,26 +769,34 @@ func (m *TopModel) recomputeBounds() { if h < 1 { h = 1 } + m.paneBounds[m.zoomedID] = paneRect{ x: 0, y: m.layout.HeaderHeight, w: m.width, h: h, } + return } + y := m.layout.HeaderHeight + for i, row := range m.rowOrder { if i >= len(m.layout.Rows) { break } + rl := m.layout.Rows[i] x := 0 + for j, paneID := range row.paneIDs { if j >= len(rl.Panes) { break } + pl := rl.Panes[j] m.paneBounds[paneID] = paneRect{x: x, y: y, w: pl.Width, h: rl.Height} x += pl.Width } + y += rl.Height } } @@ -743,20 +810,25 @@ func (m *TopModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { if p := m.paneAt(msg.X, msg.Y); p != nil { p.ScrollUp(3) } + return m, nil case tea.MouseButtonWheelDown: if p := m.paneAt(msg.X, msg.Y); p != nil { p.ScrollDown(3) } + return m, nil } + if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft { return m, nil } + for paneID, r := range m.paneBounds { if !r.contains(msg.X, msg.Y) { continue } + if m.zoomedID == paneID { // Click on zoomed pane → unzoom. m.zoomedID = "" @@ -764,9 +836,12 @@ func (m *TopModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { m.focusedID = paneID m.zoomedID = paneID } + m.recomputeLayout() + return m, tea.Batch(m.broadcastFocus(), m.resizeAllCmd()) } + return m, nil } @@ -777,6 +852,7 @@ func (m *TopModel) paneAt(x, y int) *Pane { return m.panesByID[paneID] } } + return nil } @@ -793,12 +869,14 @@ func (m *TopModel) resizeAllCmd() tea.Cmd { if h < 1 { h = 1 } + p.Update(ResizeMsg{ PaneID: m.zoomedID, Width: m.width, Height: h, TruncateTitle: false, }) + return nil } @@ -807,11 +885,13 @@ func (m *TopModel) resizeAllCmd() tea.Cmd { if i >= len(m.layout.Rows) { break } + rl := m.layout.Rows[i] for j, paneID := range row.paneIDs { if j >= len(rl.Panes) { break } + pl := rl.Panes[j] if p, ok := m.panesByID[paneID]; ok { p.Update(ResizeMsg{ @@ -823,12 +903,14 @@ func (m *TopModel) resizeAllCmd() tea.Cmd { } } } + return nil } // View renders the header, grid (or zoom), and footer. func (m *TopModel) View() string { var b strings.Builder + b.WriteString(m.header()) b.WriteByte('\n') @@ -842,12 +924,15 @@ func (m *TopModel) View() string { if i >= len(m.layout.Rows) { break } + panes := make([]string, 0, len(row.paneIDs)) + for _, paneID := range row.paneIDs { if p, ok := m.panesByID[paneID]; ok { panes = append(panes, p.View()) } } + if len(panes) > 0 { b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, panes...)) b.WriteByte('\n') @@ -856,6 +941,7 @@ func (m *TopModel) View() string { } b.WriteString(m.footer()) + return b.String() } @@ -865,11 +951,14 @@ func (m *TopModel) header() string { if m.opts.Live { mode = "live" } + focus := m.focusedID if focus == "" { focus = "no focus" } + text := fmt.Sprintf("frank worker top · %s · %s · %s", m.projectName, mode, focus) + return Header.Render(text) } @@ -879,15 +968,18 @@ func (m *TopModel) footer() string { if m.searching { return Footer.Render("/" + m.searchQuery + "█ (esc cancel · enter keep)") } + if m.restartStatus != "" { return Footer.Render(RestartBanner.Render("restarting " + m.restartStatus + "...")) } + var text string if m.zoomedID != "" { text = "esc back · pgup/pgdn scroll · / search · p pause · r/R restart all/one · q quit" } else { text = "q quit · tab focus · enter zoom · / search · p pause · r/R restart · esc back" } + return Footer.Render(text) } @@ -897,6 +989,7 @@ func (m *TopModel) restartOneWorkerCmd(paneID string) tea.Cmd { if p, ok := m.panesByID[paneID]; ok && p.spec.Kind == KindAdhoc { return nil } + return func() tea.Msg { return restartSequenceMsg{services: []string{paneID}} } @@ -907,15 +1000,19 @@ func (m *TopModel) restartOneWorkerCmd(paneID string) tea.Cmd { // compose services). func (m *TopModel) restartAllWorkersCmd() tea.Cmd { var services []string + for _, row := range m.rowOrder { if row.kind == KindAdhoc { continue } + services = append(services, row.paneIDs...) } + if len(services) == 0 { return nil } + return func() tea.Msg { return restartSequenceMsg{services: services} } @@ -932,7 +1029,9 @@ func restartOneCmd(services []string, idx int) tea.Cmd { if idx >= len(services) { return func() tea.Msg { return restartDoneMsg{} } } + svc := services[idx] + return tea.Sequence( func() tea.Msg { return restartingMsg{service: svc} }, func() tea.Msg { @@ -940,6 +1039,7 @@ func restartOneCmd(services []string, idx int) tea.Cmd { argv = append(argv, "restart", svc) cmd := exec.Command(argv[0], argv[1:]...) _ = cmd.Run() + return restartNextMsg{services: services, next: idx + 1} }, ) @@ -981,10 +1081,12 @@ func (d *dockerInspector) AdhocWorkerNames(projectName string) ([]string, error) if err != nil { return nil, err } + names := make([]string, len(workers)) for i, w := range workers { names[i] = w.Name } + return names, nil } @@ -1001,9 +1103,11 @@ func (d *dockerLister) ListAdhocWorkers() ([]AdhocContainer, error) { if err != nil { return nil, err } + out := make([]AdhocContainer, len(workers)) for i, w := range workers { out[i] = AdhocContainer{ID: w.ID, Name: w.Name} } + return out, nil } diff --git a/internal/worktreelist/actions.go b/internal/worktreelist/actions.go index ef0a31f..8f96709 100644 --- a/internal/worktreelist/actions.go +++ b/internal/worktreelist/actions.go @@ -30,15 +30,18 @@ func openBrowser(item WorktreeItem) error { if cfg.Server.IsHTTPS() { scheme = "https" } + url := fmt.Sprintf("%s://localhost:%d", scheme, port) var opener string + switch runtime.GOOS { case "darwin": opener = "open" default: opener = "xdg-open" } + return exec.Command(opener, url).Start() } @@ -53,6 +56,7 @@ func RemoveWorktree(path, branch string) error { if branch != "" && branch != "(detached)" { _ = exec.Command("git", "branch", "-D", branch).Run() } + return nil } @@ -67,14 +71,17 @@ func upContainers(path string) error { return err } } + frank, err := os.Executable() if err != nil { frank = "frank" } + out, err := exec.Command(frank, "up", "-d", "--dir", path).CombinedOutput() if err != nil { return fmt.Errorf("frank up: %s", out) } + return nil } @@ -83,10 +90,12 @@ func downContainers(path string) error { if err != nil { frank = "frank" } + out, err := exec.Command(frank, "down", "--dir", path).CombinedOutput() if err != nil { return fmt.Errorf("frank down: %s", out) } + return nil } @@ -100,6 +109,7 @@ func openEditor(path string) error { cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr + return cmd.Run() } @@ -110,6 +120,7 @@ func tailLogs(path string) error { func CreateWorktree(repoDir, wtPath, branch string) error { cmd := exec.Command("git", "worktree", "add", wtPath, "-b", branch) cmd.Dir = repoDir + out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("git worktree add: %s", out) @@ -122,15 +133,19 @@ func CreateWorktree(repoDir, wtPath, branch string) error { func copyIfExists(srcDir, dstDir, name string) { src := filepath.Join(srcDir, name) + data, err := os.ReadFile(src) if err != nil { return } + info, _ := os.Stat(src) perm := os.FileMode(0644) + if info != nil { perm = info.Mode().Perm() } + _ = os.WriteFile(filepath.Join(dstDir, name), data, perm) } @@ -139,9 +154,11 @@ func regenerate(path string) error { if err != nil { frank = "frank" } + out, err := exec.Command(frank, "generate", "--dir", path).CombinedOutput() if err != nil { return fmt.Errorf("frank generate: %s", out) } + return nil } diff --git a/internal/worktreelist/actions_test.go b/internal/worktreelist/actions_test.go index 3b717b1..b39a73a 100644 --- a/internal/worktreelist/actions_test.go +++ b/internal/worktreelist/actions_test.go @@ -136,6 +136,7 @@ func TestPortSummary(t *testing.T) { } got := item.PortSummary() want := ":443 :5173 :5432" + if got != want { t.Errorf("PortSummary() = %q, want %q", got, want) } diff --git a/internal/worktreelist/discover.go b/internal/worktreelist/discover.go index 9b7b9c0..ad470aa 100644 --- a/internal/worktreelist/discover.go +++ b/internal/worktreelist/discover.go @@ -40,33 +40,42 @@ func (w WorktreeItem) StatusLabel() string { if !w.HasFrank { return "not configured" } + if len(w.Services) == 0 { return "stopped" } + running := 0 + for _, s := range w.Services { if s.State == "running" { running++ } } + total := len(w.Services) + if running == 0 { return "stopped" } + if running == total { return fmt.Sprintf("running (%d/%d)", running, total) } + return fmt.Sprintf("partial (%d/%d)", running, total) } // PortSummary returns a compact port listing from running services. func (w WorktreeItem) PortSummary() string { var ports []string + for _, s := range w.Services { if s.Ports != "" && s.State == "running" { ports = append(ports, s.Ports) } } + return strings.Join(ports, " ") } @@ -77,6 +86,7 @@ func (w WorktreeItem) IsRunning() bool { return true } } + return false } @@ -88,6 +98,7 @@ func Discover(dir string) ([]WorktreeItem, error) { } var items []WorktreeItem + for _, e := range entries { item := WorktreeItem{ Path: e.path, @@ -108,6 +119,7 @@ func Discover(dir string) ([]WorktreeItem, error) { items = append(items, item) } + return items, nil } @@ -122,10 +134,12 @@ func parseWorktrees(dir string) ([]worktreeEntry, error) { cmd := exec.Command("git", "worktree", "list", "--porcelain") cmd.Dir = dir cmd.Env = config.CleanGitEnv() + out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("git worktree list: %w", err) } + return parsePorcelain(string(out)), nil } @@ -138,47 +152,59 @@ func parsePorcelain(raw string) []worktreeEntry { } var entries []worktreeEntry + for _, block := range blocks[1:] { e := parsePorcelainBlock(block) if e.path != "" { entries = append(entries, e) } } + return entries } func splitPorcelainBlocks(raw string) []string { var blocks []string + var current []string + for _, line := range strings.Split(raw, "\n") { if line == "" { if len(current) > 0 { blocks = append(blocks, strings.Join(current, "\n")) current = nil } + continue } + current = append(current, line) } + if len(current) > 0 { blocks = append(blocks, strings.Join(current, "\n")) } + return blocks } func parsePorcelainBlock(block string) worktreeEntry { var e worktreeEntry + for _, line := range strings.Split(block, "\n") { if strings.HasPrefix(line, "worktree ") { e.path = strings.TrimPrefix(line, "worktree ") } + if strings.HasPrefix(line, "branch refs/heads/") { e.branch = strings.TrimPrefix(line, "branch refs/heads/") } } + if e.branch == "" && e.path != "" { e.branch = "(detached)" } + return e } @@ -196,22 +222,27 @@ type composePSEntry struct { func probeServices(worktreePath string) []ServiceInfo { client := docker.New(worktreePath) + out, err := client.RunQuiet("ps", "--format", "json") if err != nil { return nil } var services []ServiceInfo + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { line = strings.TrimSpace(line) if line == "" { continue } + var entry composePSEntry if err := json.Unmarshal([]byte(line), &entry); err != nil { continue } + var pubs []Publisher + for _, p := range entry.Publishers { if p.PublishedPort > 0 { pubs = append(pubs, Publisher{ @@ -221,6 +252,7 @@ func probeServices(worktreePath string) []ServiceInfo { }) } } + services = append(services, ServiceInfo{ Name: entry.Service, State: entry.State, @@ -228,18 +260,23 @@ func probeServices(worktreePath string) []ServiceInfo { Publishers: pubs, }) } + return services } func formatPorts(entry composePSEntry) string { seen := make(map[int]bool) + var parts []string + for _, p := range entry.Publishers { if p.PublishedPort > 0 && !seen[p.PublishedPort] { seen[p.PublishedPort] = true + parts = append(parts, fmt.Sprintf(":%d", p.PublishedPort)) } } + return strings.Join(parts, " ") } @@ -259,5 +296,6 @@ func (w WorktreeItem) WebPort() int { } } } + return 0 } diff --git a/internal/worktreelist/discover_test.go b/internal/worktreelist/discover_test.go index 086af21..2eac7e4 100644 --- a/internal/worktreelist/discover_test.go +++ b/internal/worktreelist/discover_test.go @@ -14,6 +14,7 @@ func TestParsePorcelain_Empty(t *testing.T) { func TestParsePorcelain_OnlyMain(t *testing.T) { raw := "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n" + got := parsePorcelain(raw) if got != nil { t.Fatalf("expected nil (skip main), got %v", got) @@ -34,19 +35,24 @@ HEAD 789abc branch refs/heads/fix/y ` + got := parsePorcelain(raw) if len(got) != 2 { t.Fatalf("expected 2 entries, got %d", len(got)) } + if got[0].path != "/home/user/project/.worktrees/feat-x" { t.Errorf("entry[0].path = %q", got[0].path) } + if got[0].branch != "feature/x" { t.Errorf("entry[0].branch = %q", got[0].branch) } + if got[1].path != "/home/user/project/.worktrees/fix-y" { t.Errorf("entry[1].path = %q", got[1].path) } + if got[1].branch != "fix/y" { t.Errorf("entry[1].branch = %q", got[1].branch) } @@ -61,10 +67,12 @@ worktree /home/user/project/.worktrees/detached HEAD deadbeef ` + got := parsePorcelain(raw) if len(got) != 1 { t.Fatalf("expected 1 entry, got %d", len(got)) } + if got[0].branch != "(detached)" { t.Errorf("expected (detached), got %q", got[0].branch) } @@ -72,16 +80,20 @@ HEAD deadbeef func TestSplitPorcelainBlocks(t *testing.T) { raw := "a\nb\n\nc\nd\n\ne\n" + blocks := splitPorcelainBlocks(raw) if len(blocks) != 3 { t.Fatalf("expected 3 blocks, got %d: %v", len(blocks), blocks) } + if blocks[0] != "a\nb" { t.Errorf("block[0] = %q", blocks[0]) } + if blocks[1] != "c\nd" { t.Errorf("block[1] = %q", blocks[1]) } + if blocks[2] != "e" { t.Errorf("block[2] = %q", blocks[2]) } diff --git a/internal/worktreelist/item.go b/internal/worktreelist/item.go index 7636523..574db53 100644 --- a/internal/worktreelist/item.go +++ b/internal/worktreelist/item.go @@ -64,13 +64,16 @@ func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, item list.Ite // Line 1: branch name title := wt.Branch + if busy { frame := spinnerFrames[0] if d.SpinnerFrame != nil { frame = spinnerFrames[*d.SpinnerFrame%len(spinnerFrames)] } + title = busyStyle.Render(frame) + " " + title } + if selected { title = itemTitleSelected.Render(title) } else { @@ -86,11 +89,13 @@ func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, item list.Ite if !busy { descParts = append(descParts, indicatorStyle.Render(indicator)) } + if selected { descParts = append(descParts, itemDescSelected.Render(label)) } else { descParts = append(descParts, itemDesc.Render(label)) } + if ports != "" { if selected { descParts = append(descParts, itemDescSelected.Render("— "+ports)) @@ -98,6 +103,7 @@ func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, item list.Ite descParts = append(descParts, itemDesc.Render("— "+ports)) } } + descLine := strings.Join(descParts, " ") // Line 3: shortened path @@ -112,6 +118,7 @@ func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, item list.Ite if selected { prefix = "→ " } + fmt.Fprintf(w, "%s%s\n%s\n%s", prefix, title, descLine, path) } @@ -119,21 +126,27 @@ func statusIndicator(wt WorktreeItem) (string, lipgloss.Style) { if !wt.HasFrank { return indicatorNotConfigured, indicatorNotConfiguredStyle } + if len(wt.Services) == 0 { return indicatorStopped, indicatorStoppedStyle } + running := 0 + for _, s := range wt.Services { if s.State == "running" { running++ } } + if running == 0 { return indicatorStopped, indicatorStoppedStyle } + if running == len(wt.Services) { return indicatorRunning, indicatorRunningStyle } + return indicatorPartial, indicatorPartialStyle } @@ -142,11 +155,14 @@ func shortenHome(path string) string { if err != nil || home == "" { return path } + if path == home { return "~" } + if strings.HasPrefix(path, home+"/") { return "~" + path[len(home):] } + return path } diff --git a/internal/worktreelist/model.go b/internal/worktreelist/model.go index 888d1fe..8898d8a 100644 --- a/internal/worktreelist/model.go +++ b/internal/worktreelist/model.go @@ -68,6 +68,7 @@ func BranchToKebab(branch string) string { s = strings.ReplaceAll(s, "_", "-") s = nonAlphanumDash.ReplaceAllString(s, "") s = strings.Trim(s, "-") + return s } @@ -111,6 +112,7 @@ func New(items []WorktreeItem, dir string) Model { } m.list = l + return m } @@ -132,12 +134,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.list.FilterState() == list.Filtering { var cmd tea.Cmd m.list, cmd = m.list.Update(msg) + return m, cmd } if m.confirmRemove { return m.handleConfirmKey(msg) } + return m.handleKey(msg) case actionDoneMsg: @@ -147,6 +151,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.statusMsg = "done" } + return m, m.refresh() case refreshMsg: @@ -157,11 +162,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.shared.spinnerFrame++ return m, spinnerTick() } + return m, nil } var cmd tea.Cmd m.list, cmd = m.list.Update(msg) + return m, cmd } @@ -176,6 +183,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.branchInput.Reset() m.branchInput.Focus() m.statusMsg = "" + return m, m.branchInput.Cursor.BlinkCmd() case "o": @@ -183,10 +191,12 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if !ok { break } + err := openBrowser(item) if err != nil { m.statusMsg = fmt.Sprintf("browser: %v", err) } + return m, nil case "r": @@ -194,8 +204,10 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if !ok { break } + m.confirmRemove = true m.statusMsg = fmt.Sprintf("remove %s? (y/n)", item.Branch) + return m, nil case "u": @@ -203,12 +215,14 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if !ok { break } + m.shared.busyIdx = m.list.Index() if needsGenerate(item.Path) { m.statusMsg = "generating + starting containers..." } else { m.statusMsg = "starting containers..." } + return m, tea.Batch(m.runAction(func() error { return upContainers(item.Path) }), spinnerTick()) @@ -218,8 +232,10 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if !ok { break } + m.shared.busyIdx = m.list.Index() m.statusMsg = "stopping containers..." + return m, tea.Batch(m.runAction(func() error { return downContainers(item.Path) }), spinnerTick()) @@ -229,8 +245,10 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if !ok { break } + m.postQuit = &PostQuitAction{Kind: "logs", Path: item.Path} m.quitting = true + return m, tea.Quit case "g": @@ -238,8 +256,10 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if !ok { break } + m.shared.busyIdx = m.list.Index() m.statusMsg = "regenerating..." + return m, tea.Batch(m.runAction(func() error { return regenerate(item.Path) }), spinnerTick()) @@ -249,13 +269,16 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if !ok { break } + m.postQuit = &PostQuitAction{Kind: "editor", Path: item.Path} m.quitting = true + return m, tea.Quit } var cmd tea.Cmd m.list, cmd = m.list.Update(msg) + return m, cmd } @@ -266,8 +289,10 @@ func (m Model) handleCreateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if branch == "" { m.creating = false m.statusMsg = "" + return m, nil } + m.creating = false projectName := config.ProjectName(m.dir) @@ -276,6 +301,7 @@ func (m Model) handleCreateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { wtPath := filepath.Join(parentDir, projectName+"-"+kebab) m.statusMsg = fmt.Sprintf("creating %s...", kebab) + return m, tea.Batch(m.runAction(func() error { return CreateWorktree(m.dir, wtPath, branch) }), spinnerTick()) @@ -283,11 +309,13 @@ func (m Model) handleCreateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case tea.KeyEsc: m.creating = false m.statusMsg = "" + return m, nil } var cmd tea.Cmd m.branchInput, cmd = m.branchInput.Update(msg) + return m, cmd } @@ -296,11 +324,14 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "y", "Y": m.confirmRemove = false item, ok := m.selectedItem() + if !ok { return m, nil } + m.shared.busyIdx = m.list.Index() m.statusMsg = "removing worktree..." + return m, tea.Batch(m.runAction(func() error { return RemoveWorktree(item.Path, item.Branch) }), spinnerTick()) @@ -308,6 +339,7 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { default: m.confirmRemove = false m.statusMsg = "" + return m, nil } } @@ -335,7 +367,9 @@ func (m Model) doRefresh() (tea.Model, tea.Cmd) { for i, item := range items { listItems[i] = item } + m.list.SetItems(listItems) + return m, nil } @@ -344,7 +378,9 @@ func (m Model) selectedItem() (WorktreeItem, bool) { if item == nil { return WorktreeItem{}, false } + wt, ok := item.(WorktreeItem) + return wt, ok } @@ -352,13 +388,16 @@ func (m Model) View() string { if m.quitting { return "" } + if m.creating { return m.list.View() + "\n\n Branch name: " + m.branchInput.View() } + if m.statusMsg != "" { m.list.NewStatusMessage(m.statusMsg) m.statusMsg = "" } + return m.list.View() } @@ -391,5 +430,6 @@ func Run(dir string, items []WorktreeItem) error { case "editor": return openEditor(pq.Path) } + return nil } diff --git a/main.go b/main.go index 5784ad0..c65d549 100644 --- a/main.go +++ b/main.go @@ -18,5 +18,6 @@ func main() { version = info.Main.Version } } + cmd.Execute(templateFS, version) } diff --git a/templates/runtimes/fpm/compose.fragment.tmpl b/templates/runtimes/fpm/compose.fragment.tmpl index 981563f..2d3fd8e 100644 --- a/templates/runtimes/fpm/compose.fragment.tmpl +++ b/templates/runtimes/fpm/compose.fragment.tmpl @@ -4,8 +4,6 @@ laravel.test: dockerfile: .frank/Dockerfile args: WWWGROUP: ${GID:-1000} - ports: - - "{{.VitePort}}:5173" networks: - frank volumes: diff --git a/templates/runtimes/frankenphp/compose.fragment.tmpl b/templates/runtimes/frankenphp/compose.fragment.tmpl index 1814371..4229c2b 100644 --- a/templates/runtimes/frankenphp/compose.fragment.tmpl +++ b/templates/runtimes/frankenphp/compose.fragment.tmpl @@ -12,7 +12,6 @@ laravel.test: {{- else}} - "80" {{- end}} - - "{{.VitePort}}:5173" {{- else}} {{- if .HTTPS}} {{- if .CustomPort}} @@ -30,7 +29,6 @@ laravel.test: - "80:80" {{- end}} {{- end}} - - "{{.VitePort}}:5173" {{- end}} networks: - frank