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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

[#135]: https://github.com/cboxdk/init/issues/135

### Fixed

- **Symfony apps are now detected on a fresh deploy.** Framework detection
required `var/cache` to already exist — but `var/cache` is exactly what
the permission setup creates, so a fresh Symfony deploy (or a bind mount
without `var/`) was classified as generic and never got its cache
directory. Detection now corroborates `bin/console` with `symfony.lock`
or a `symfony/framework-bundle` composer dependency instead. Laravel
detection gained the same corroboration (`artisan` plus composer
dependency or `bootstrap/app.php`), so a stray file named `artisan` no
longer triggers Laravel permission handling.

## [3.1.2] - 2026-09-04

### Fixed
Expand Down
30 changes: 26 additions & 4 deletions internal/setup/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os/user"
"path/filepath"
"strconv"
"strings"
)

// Framework represents a detected PHP framework
Expand Down Expand Up @@ -42,13 +43,22 @@ func NewPermissionManager(workdir string, log *slog.Logger) *PermissionManager {

// detectFramework identifies the PHP framework in the working directory
func (pm *PermissionManager) detectFramework() Framework {
// Laravel: check for artisan file
if fileExists(filepath.Join(pm.workdir, "artisan")) {
// Laravel: `artisan` plus corroborating evidence (composer dependency or
// the framework bootstrap, which artisan itself requires) so a stray file
// named artisan does not pull a generic app into Laravel handling.
if fileExists(filepath.Join(pm.workdir, "artisan")) &&
(composerRequires(pm.workdir, "laravel/framework") ||
fileExists(filepath.Join(pm.workdir, "bootstrap", "app.php"))) {
return FrameworkLaravel
}
// Symfony: check for bin/console and var/cache
// Symfony: `bin/console` plus corroborating evidence. var/cache cannot be
// the marker - it is exactly what setupSymfony() creates, so requiring it
// meant a fresh deploy was never detected and its cache dir never created.
// symfony.lock covers Flex-managed apps; the composer dependency covers
// the rest.
if fileExists(filepath.Join(pm.workdir, "bin", "console")) &&
dirExists(filepath.Join(pm.workdir, "var", "cache")) {
(fileExists(filepath.Join(pm.workdir, "symfony.lock")) ||
composerRequires(pm.workdir, "symfony/framework-bundle")) {
return FrameworkSymfony
}
// WordPress: check for wp-config.php
Expand All @@ -58,6 +68,18 @@ func (pm *PermissionManager) detectFramework() Framework {
return FrameworkGeneric
}

// composerRequires reports whether the app's composer.json mentions the given
// package. A plain substring match mirrors the shell entrypoints and is
// deliberate: composer.json is developer-controlled input and the result only
// steers directory and permission setup.
func composerRequires(workdir, pkg string) bool {
data, err := os.ReadFile(filepath.Join(workdir, "composer.json"))
if err != nil {
return false
}
return strings.Contains(string(data), pkg)
}

func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
Expand Down
69 changes: 61 additions & 8 deletions internal/setup/permissions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,14 @@ func TestPermissionManager_Setup(t *testing.T) {
{
name: "Laravel setup",
setupFunc: func(dir string) error {
// Create artisan to make it a Laravel project
return os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php"), 0644)
// Create artisan + bootstrap/app.php to make it a Laravel project
if err := os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php"), 0644); err != nil {
return err
}
if err := os.MkdirAll(filepath.Join(dir, "bootstrap"), 0755); err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, "bootstrap", "app.php"), []byte("<?php"), 0644)
},
checkDirs: []string{
"storage/framework/sessions",
Expand All @@ -33,14 +39,15 @@ func TestPermissionManager_Setup(t *testing.T) {
{
name: "Symfony setup",
setupFunc: func(dir string) error {
// Create bin/console and var/cache to make it a Symfony project
// Create bin/console and symfony.lock to make it a Symfony
// project - deliberately WITHOUT var/, which Setup() creates.
if err := os.MkdirAll(filepath.Join(dir, "bin"), 0755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "bin", "console"), []byte("#!/usr/bin/env php"), 0644); err != nil {
return err
}
return os.MkdirAll(filepath.Join(dir, "var", "cache"), 0755)
return os.WriteFile(filepath.Join(dir, "symfony.lock"), []byte("{}"), 0644)
},
checkDirs: []string{
"var/cache",
Expand Down Expand Up @@ -146,25 +153,71 @@ func TestDetectFramework(t *testing.T) {
expected Framework
}{
{
name: "Laravel detection",
name: "Laravel detection via bootstrap",
setup: func(dir string) error {
return os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php"), 0644)
if err := os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php"), 0644); err != nil {
return err
}
if err := os.MkdirAll(filepath.Join(dir, "bootstrap"), 0755); err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, "bootstrap", "app.php"), []byte("<?php"), 0644)
},
expected: FrameworkLaravel,
},
{
name: "Symfony detection",
name: "Laravel detection via composer dependency",
setup: func(dir string) error {
if err := os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php"), 0644); err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, "composer.json"), []byte(`{"require":{"laravel/framework":"^11.0"}}`), 0644)
},
expected: FrameworkLaravel,
},
{
name: "Stray artisan without corroboration is generic",
setup: func(dir string) error {
return os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php"), 0644)
},
expected: FrameworkGeneric,
},
{
name: "Symfony detection via symfony.lock",
setup: func(dir string) error {
if err := os.MkdirAll(filepath.Join(dir, "bin"), 0755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "bin", "console"), []byte("#!/usr/bin/env php"), 0644); err != nil {
return err
}
return os.MkdirAll(filepath.Join(dir, "var", "cache"), 0755)
return os.WriteFile(filepath.Join(dir, "symfony.lock"), []byte("{}"), 0644)
},
expected: FrameworkSymfony,
},
{
name: "Symfony fresh deploy without var/cache detects via composer",
setup: func(dir string) error {
if err := os.MkdirAll(filepath.Join(dir, "bin"), 0755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "bin", "console"), []byte("#!/usr/bin/env php"), 0644); err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, "composer.json"), []byte(`{"require":{"symfony/framework-bundle":"^7.0"}}`), 0644)
},
expected: FrameworkSymfony,
},
{
name: "bin/console without corroboration is generic",
setup: func(dir string) error {
if err := os.MkdirAll(filepath.Join(dir, "bin"), 0755); err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, "bin", "console"), []byte("#!/usr/bin/env php"), 0644)
},
expected: FrameworkGeneric,
},
{
name: "WordPress detection",
setup: func(dir string) error {
Expand Down
Loading