From e41617503856ff701c1f49424acc75ffa9268a06 Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 23 Jul 2026 16:46:30 +0100 Subject: [PATCH] feat(engine/php): CorePHP Laravel/FrankenPHP embed as a go-render engine Lifts the Go embed out of the polyglot core/php repo into go-render as the own-module engine dappco.re/go/render/engine/php. Produces both web assets (Laravel HTTP -> HTML via FrankenPHP) and a console interface (artisan). FrankenPHP stays behind -tags frankenphp (cgo); the default stub build is the gate. Reparented all imports. GOWORK=off build+vet green. Co-Authored-By: Virgil --- engine/php/cmd/core-php/main.go | 15 + engine/php/go.mod | 71 ++ engine/php/go.sum | 175 +++ engine/php/pkg/php/bridge.go | 111 ++ engine/php/pkg/php/bridge_example_test.go | 20 + engine/php/pkg/php/bridge_test.go | 73 ++ engine/php/pkg/php/cmd.go | 148 +++ engine/php/pkg/php/cmd_build.go | 252 +++++ engine/php/pkg/php/cmd_ci.go | 607 +++++++++++ engine/php/pkg/php/cmd_ci_example_test.go | 4 + engine/php/pkg/php/cmd_ci_test.go | 1 + engine/php/pkg/php/cmd_commands.go | 41 + .../php/pkg/php/cmd_commands_example_test.go | 8 + engine/php/pkg/php/cmd_commands_test.go | 19 + engine/php/pkg/php/cmd_deploy.go | 322 ++++++ engine/php/pkg/php/cmd_dev.go | 460 ++++++++ engine/php/pkg/php/cmd_example_test.go | 16 + engine/php/pkg/php/cmd_packages.go | 130 +++ engine/php/pkg/php/cmd_serve_frankenphp.go | 115 ++ .../php/cmd_serve_frankenphp_example_test.go | 16 + .../php/pkg/php/cmd_serve_frankenphp_stub.go | 20 + .../cmd_serve_frankenphp_stub_example_test.go | 16 + .../pkg/php/cmd_serve_frankenphp_stub_test.go | 55 + .../php/pkg/php/cmd_serve_frankenphp_test.go | 55 + engine/php/pkg/php/cmd_test.go | 55 + engine/php/pkg/php/compat.go | 47 + engine/php/pkg/php/container.go | 467 ++++++++ engine/php/pkg/php/container_example_test.go | 24 + engine/php/pkg/php/container_test.go | 91 ++ engine/php/pkg/php/coolify.go | 388 +++++++ engine/php/pkg/php/coolify_example_test.go | 36 + engine/php/pkg/php/coolify_test.go | 145 +++ engine/php/pkg/php/core_assert_test.go | 10 + engine/php/pkg/php/deploy.go | 417 ++++++++ engine/php/pkg/php/deploy_example_test.go | 28 + engine/php/pkg/php/deploy_test.go | 109 ++ engine/php/pkg/php/detect.go | 307 ++++++ engine/php/pkg/php/detect_example_test.go | 32 + engine/php/pkg/php/detect_test.go | 127 +++ engine/php/pkg/php/dockerfile.go | 439 ++++++++ engine/php/pkg/php/dockerfile_example_test.go | 20 + engine/php/pkg/php/dockerfile_test.go | 73 ++ engine/php/pkg/php/env.go | 172 +++ engine/php/pkg/php/env_example_test.go | 12 + engine/php/pkg/php/env_test.go | 37 + engine/php/pkg/php/extract.go | 65 ++ engine/php/pkg/php/extract_example_test.go | 8 + engine/php/pkg/php/extract_test.go | 19 + engine/php/pkg/php/go_cli_helpers.go | 246 +++++ .../pkg/php/go_cli_helpers_example_test.go | 20 + engine/php/pkg/php/go_cli_helpers_test.go | 73 ++ engine/php/pkg/php/handler.go | 153 +++ engine/php/pkg/php/handler_example_test.go | 20 + engine/php/pkg/php/handler_stub.go | 55 + .../php/pkg/php/handler_stub_example_test.go | 20 + engine/php/pkg/php/handler_stub_test.go | 73 ++ engine/php/pkg/php/handler_test.go | 73 ++ engine/php/pkg/php/i18n.go | 52 + engine/php/pkg/php/locales/en_GB.json | 147 +++ engine/php/pkg/php/packages.go | 316 ++++++ engine/php/pkg/php/packages_example_test.go | 20 + engine/php/pkg/php/packages_test.go | 73 ++ engine/php/pkg/php/php.go | 409 +++++++ engine/php/pkg/php/php_example_test.go | 40 + engine/php/pkg/php/php_test.go | 163 +++ engine/php/pkg/php/quality.go | 998 ++++++++++++++++++ engine/php/pkg/php/quality_example_test.go | 60 ++ engine/php/pkg/php/quality_test.go | 253 +++++ engine/php/pkg/php/service.go | 152 +++ engine/php/pkg/php/service_example_test.go | 17 + engine/php/pkg/php/service_test.go | 137 +++ engine/php/pkg/php/services.go | 502 +++++++++ engine/php/pkg/php/services_example_test.go | 84 ++ engine/php/pkg/php/services_test.go | 361 +++++++ engine/php/pkg/php/services_unix.go | 42 + engine/php/pkg/php/services_windows.go | 35 + engine/php/pkg/php/sonar_constants.go | 25 + engine/php/pkg/php/ssl.go | 164 +++ engine/php/pkg/php/ssl_example_test.go | 36 + engine/php/pkg/php/ssl_test.go | 145 +++ engine/php/pkg/php/testing.go | 193 ++++ engine/php/pkg/php/testing_example_test.go | 16 + engine/php/pkg/php/testing_test.go | 55 + engine/php/pkg/php/workspace.go | 75 ++ 84 files changed, 11181 insertions(+) create mode 100644 engine/php/cmd/core-php/main.go create mode 100644 engine/php/go.mod create mode 100644 engine/php/go.sum create mode 100644 engine/php/pkg/php/bridge.go create mode 100644 engine/php/pkg/php/bridge_example_test.go create mode 100644 engine/php/pkg/php/bridge_test.go create mode 100644 engine/php/pkg/php/cmd.go create mode 100644 engine/php/pkg/php/cmd_build.go create mode 100644 engine/php/pkg/php/cmd_ci.go create mode 100644 engine/php/pkg/php/cmd_ci_example_test.go create mode 100644 engine/php/pkg/php/cmd_ci_test.go create mode 100644 engine/php/pkg/php/cmd_commands.go create mode 100644 engine/php/pkg/php/cmd_commands_example_test.go create mode 100644 engine/php/pkg/php/cmd_commands_test.go create mode 100644 engine/php/pkg/php/cmd_deploy.go create mode 100644 engine/php/pkg/php/cmd_dev.go create mode 100644 engine/php/pkg/php/cmd_example_test.go create mode 100644 engine/php/pkg/php/cmd_packages.go create mode 100644 engine/php/pkg/php/cmd_serve_frankenphp.go create mode 100644 engine/php/pkg/php/cmd_serve_frankenphp_example_test.go create mode 100644 engine/php/pkg/php/cmd_serve_frankenphp_stub.go create mode 100644 engine/php/pkg/php/cmd_serve_frankenphp_stub_example_test.go create mode 100644 engine/php/pkg/php/cmd_serve_frankenphp_stub_test.go create mode 100644 engine/php/pkg/php/cmd_serve_frankenphp_test.go create mode 100644 engine/php/pkg/php/cmd_test.go create mode 100644 engine/php/pkg/php/compat.go create mode 100644 engine/php/pkg/php/container.go create mode 100644 engine/php/pkg/php/container_example_test.go create mode 100644 engine/php/pkg/php/container_test.go create mode 100644 engine/php/pkg/php/coolify.go create mode 100644 engine/php/pkg/php/coolify_example_test.go create mode 100644 engine/php/pkg/php/coolify_test.go create mode 100644 engine/php/pkg/php/core_assert_test.go create mode 100644 engine/php/pkg/php/deploy.go create mode 100644 engine/php/pkg/php/deploy_example_test.go create mode 100644 engine/php/pkg/php/deploy_test.go create mode 100644 engine/php/pkg/php/detect.go create mode 100644 engine/php/pkg/php/detect_example_test.go create mode 100644 engine/php/pkg/php/detect_test.go create mode 100644 engine/php/pkg/php/dockerfile.go create mode 100644 engine/php/pkg/php/dockerfile_example_test.go create mode 100644 engine/php/pkg/php/dockerfile_test.go create mode 100644 engine/php/pkg/php/env.go create mode 100644 engine/php/pkg/php/env_example_test.go create mode 100644 engine/php/pkg/php/env_test.go create mode 100644 engine/php/pkg/php/extract.go create mode 100644 engine/php/pkg/php/extract_example_test.go create mode 100644 engine/php/pkg/php/extract_test.go create mode 100644 engine/php/pkg/php/go_cli_helpers.go create mode 100644 engine/php/pkg/php/go_cli_helpers_example_test.go create mode 100644 engine/php/pkg/php/go_cli_helpers_test.go create mode 100644 engine/php/pkg/php/handler.go create mode 100644 engine/php/pkg/php/handler_example_test.go create mode 100644 engine/php/pkg/php/handler_stub.go create mode 100644 engine/php/pkg/php/handler_stub_example_test.go create mode 100644 engine/php/pkg/php/handler_stub_test.go create mode 100644 engine/php/pkg/php/handler_test.go create mode 100644 engine/php/pkg/php/i18n.go create mode 100644 engine/php/pkg/php/locales/en_GB.json create mode 100644 engine/php/pkg/php/packages.go create mode 100644 engine/php/pkg/php/packages_example_test.go create mode 100644 engine/php/pkg/php/packages_test.go create mode 100644 engine/php/pkg/php/php.go create mode 100644 engine/php/pkg/php/php_example_test.go create mode 100644 engine/php/pkg/php/php_test.go create mode 100644 engine/php/pkg/php/quality.go create mode 100644 engine/php/pkg/php/quality_example_test.go create mode 100644 engine/php/pkg/php/quality_test.go create mode 100644 engine/php/pkg/php/service.go create mode 100644 engine/php/pkg/php/service_example_test.go create mode 100644 engine/php/pkg/php/service_test.go create mode 100644 engine/php/pkg/php/services.go create mode 100644 engine/php/pkg/php/services_example_test.go create mode 100644 engine/php/pkg/php/services_test.go create mode 100644 engine/php/pkg/php/services_unix.go create mode 100644 engine/php/pkg/php/services_windows.go create mode 100644 engine/php/pkg/php/sonar_constants.go create mode 100644 engine/php/pkg/php/ssl.go create mode 100644 engine/php/pkg/php/ssl_example_test.go create mode 100644 engine/php/pkg/php/ssl_test.go create mode 100644 engine/php/pkg/php/testing.go create mode 100644 engine/php/pkg/php/testing_example_test.go create mode 100644 engine/php/pkg/php/testing_test.go create mode 100644 engine/php/pkg/php/workspace.go diff --git a/engine/php/cmd/core-php/main.go b/engine/php/cmd/core-php/main.go new file mode 100644 index 0000000..5a5527c --- /dev/null +++ b/engine/php/cmd/core-php/main.go @@ -0,0 +1,15 @@ +// Package main provides the core-php binary — a standalone PHP/Laravel +// development tool with FrankenPHP embedding support. +package main + +import ( + php "dappco.re/go/render/engine/php/pkg/php" + + "dappco.re/go/cli/pkg/cli" +) + +func main() { + cli.Main( + cli.WithCommands("php", php.AddPHPRootCommands), + ) +} diff --git a/engine/php/go.mod b/engine/php/go.mod new file mode 100644 index 0000000..ce758ba --- /dev/null +++ b/engine/php/go.mod @@ -0,0 +1,71 @@ +module dappco.re/go/render/engine/php + +go 1.26.2 + +require ( + dappco.re/go v0.10.4 + dappco.re/go/cli v0.9.0 + dappco.re/go/i18n v0.9.0 + dappco.re/go/io v0.9.0 + github.com/dunglas/frankenphp v1.12.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/klauspost/compress v1.18.5 // indirect + github.com/stretchr/testify v1.11.1 // indirect +) + +require ( + dappco.re/go/inference v0.9.0 // indirect + dappco.re/go/log v0.9.0 // indirect + github.com/MauriceGit/skiplist v0.0.0-20211105230623-77f5c8d3e145 // indirect + github.com/RoaringBitmap/roaring/v2 v2.15.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bits-and-blooms/bitset v1.24.4 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dunglas/mercure v0.21.11 // indirect + github.com/dunglas/skipfilter v1.0.0 // indirect + github.com/e-dant/watcher v0.0.0-20260223030516-06f84a1314be // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/gofrs/uuid/v5 v5.4.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/gorilla/handlers v1.5.2 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-runewidth v0.0.21 // indirect + github.com/maypok86/otter/v2 v2.3.0 // indirect + github.com/mschoch/smat v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.20.1 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/rs/cors v1.11.1 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/unrolled/secure v1.17.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.etcd.io/bbolt v1.4.3 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/engine/php/go.sum b/engine/php/go.sum new file mode 100644 index 0000000..2a5bba2 --- /dev/null +++ b/engine/php/go.sum @@ -0,0 +1,175 @@ +dappco.re/go v0.9.0 h1:4ruZRNqKDDva8o6g65tYggjGVe42E6/lMZfVKXtr3p0= +dappco.re/go v0.9.0/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= +dappco.re/go v0.10.3 h1:aViRNxdg2jG84P6RsiD+aSta+GcFJwGXMNQPjFPbJ9g= +dappco.re/go v0.10.3/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= +dappco.re/go v0.10.4 h1:vir5AK8AkHbTxhPUT0et6Tc0P8i/i+gLInM0LRLt1EU= +dappco.re/go v0.10.4/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= +dappco.re/go/cli v0.9.0 h1:KY8V75vqi4HJtZwWEpY8QZT6ukpNJ4FSatphSOBmBJ8= +dappco.re/go/cli v0.9.0/go.mod h1:6PQIZtv319UKowolKG8tUIRdcZ6nkbFsRe+ZJi8KiQ4= +dappco.re/go/i18n v0.9.0 h1:ZZuFSBzCGYrQotXs/tOxxiPLB+NkhjYF+HrxYkWK6+U= +dappco.re/go/i18n v0.9.0/go.mod h1:x80XNrh44UB8Ge8Sn9naOPdm0jtjq+ybs2s5/rznXHA= +dappco.re/go/inference v0.9.0 h1:6eD49KTjj4xrowWdltobEWZYLPY+zbiyDiq+Hv2nkmc= +dappco.re/go/inference v0.9.0/go.mod h1:eu0je5UqOQyoG6eaJ1IqY5eORev+PfmsRXSNCanqBkk= +dappco.re/go/io v0.9.0 h1:TyHUuUJdZ73CXQlBpqx47SNyFFzgwA5OPSKu4Twb2f0= +dappco.re/go/io v0.9.0/go.mod h1:K5jWSLMdk0X9HqJ6b1I+8tKqcNpNWgpcUZi/fGm28Q8= +dappco.re/go/log v0.9.0 h1:9+OiBUDyUNvqZZ++XemcjJPCgypr+Yf/1e5OP3X2nrk= +dappco.re/go/log v0.9.0/go.mod h1:IC04Em9SfVTcXiWc1BqZDQfa1MtOuMDEermZkQcTz9c= +forge.lthn.ai/Snider/Borg v0.3.1 h1:gfC1ZTpLoZai07oOWJiVeQ8+qJYK8A795tgVGJHbVL8= +forge.lthn.ai/Snider/Borg v0.3.1/go.mod h1:Z7DJD0yHXsxSyM7Mjl6/g4gH1NBsIz44Bf5AFlV76Wg= +forge.lthn.ai/Snider/Enchantrix v0.0.4 h1:biwpix/bdedfyc0iVeK15awhhJKH6TEMYOTXzHXx5TI= +forge.lthn.ai/Snider/Enchantrix v0.0.4/go.mod h1:OGCwuVeZPq3OPe2h6TX/ZbgEjHU6B7owpIBeXQGbSe0= +github.com/MauriceGit/skiplist v0.0.0-20211105230623-77f5c8d3e145 h1:1yw6O62BReQ+uA1oyk9XaQTvLhcoHWmoQAgXmDFXpIY= +github.com/MauriceGit/skiplist v0.0.0-20211105230623-77f5c8d3e145/go.mod h1:877WBceefKn14QwVVn4xRFUsHsZb9clICgdeTj4XsUg= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/RoaringBitmap/roaring/v2 v2.15.0 h1:gCbixa3UiG7g6WUZNVOfEEg2HTc1vR4OVdMkX8t1ZFc= +github.com/RoaringBitmap/roaring/v2 v2.15.0/go.mod h1:eq4wdNXxtJIS/oikeCzdX1rBzek7ANzbth041hrU8Q4= +github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= +github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 h1:SwGMTMLIlvDNyhMteQ6r8IJSBPlRdXX5d4idhIGbkXA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21/go.mod h1:UUxgWxofmOdAMuqEsSppbDtGKLfR04HGsD0HXzvhI1k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 h1:qtJZ70afD3ISKWnoX3xB0J2otEqu3LqicRcDBqsj0hQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12/go.mod h1:v2pNpJbRNl4vEUWEh5ytQok0zACAKfdmKS51Hotc3pQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 h1:siU1A6xjUZ2N8zjTHSXFhB9L/2OY8Dqs0xXiLjF30jA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 h1:csi9NLpFZXb9fxY7rS1xVzgPRGMt7MSNWeQ6eo247kE= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1/go.mod h1:qXVal5H0ChqXP63t6jze5LmFalc7+ZE7wOdLtZ0LCP0= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= +github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dunglas/frankenphp v1.12.1 h1:Tv7k+8dCwuzvTFfqOPsPjp5db3akvzp6aE02zY9J+2Y= +github.com/dunglas/frankenphp v1.12.1/go.mod h1:84L8wMmyw61EULabEfVogb6l7ckQodNfzbpxVde+iqE= +github.com/dunglas/mercure v0.21.11 h1:4Sd/Q77j8uh9SI5D9ZMg5sePlWs336+9CKxDQC1FV34= +github.com/dunglas/mercure v0.21.11/go.mod h1:WPMgfqonUiO1qB+W8Tya63Ngag9ZwplGMXSOy8P/uMg= +github.com/dunglas/skipfilter v1.0.0 h1:JG9SgGg4n6BlFwuTYzb9RIqjH7PfwszvWehanrYWPF4= +github.com/dunglas/skipfilter v1.0.0/go.mod h1:ryhr8j7CAHSjzeN7wI6YEuwoArQ3OQmRqWWVCEAfb9w= +github.com/e-dant/watcher v0.0.0-20260223030516-06f84a1314be h1:vqHrvilasyJcnru/0Z4FoojsQJUIfXGVplte7JtupfY= +github.com/e-dant/watcher v0.0.0-20260223030516-06f84a1314be/go.mod h1:PmV4IVmBJVqT2NcfTGN4+sZ+qGe3PA0qkphAtOHeFG0= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= +github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w= +github.com/maypok86/otter/v2 v2.3.0/go.mod h1:XgIdlpmL6jYz882/CAx1E4C1ukfgDKSaw4mWq59+7l8= +github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= +github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= +github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU= +github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= +go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/engine/php/pkg/php/bridge.go b/engine/php/pkg/php/bridge.go new file mode 100644 index 0000000..05806c9 --- /dev/null +++ b/engine/php/pkg/php/bridge.go @@ -0,0 +1,111 @@ +package php + +import ( + "context" + "encoding/json" + "io" + "net" + "net/http" + + core "dappco.re/go" +) + +// BridgeHandler is the interface that the host application implements to +// respond to PHP-initiated requests via the native bridge. +type BridgeHandler interface { + // HandleBridgeCall processes a named bridge call with JSON args. + // Returns a JSON-serializable response. + HandleBridgeCall(method string, args json.RawMessage) (any, error) +} + +// Bridge provides a localhost HTTP API that PHP code can call +// to access native desktop capabilities (file dialogs, notifications, etc.). +// +// Livewire renders server-side in PHP, so it can't call Wails bindings +// (window.go.*) directly. Instead, PHP makes HTTP requests to this bridge. +// The bridge port is injected into Laravel's .env as NATIVE_BRIDGE_URL. +type Bridge struct { + server *http.Server + port int + handler BridgeHandler +} + +// NewBridge creates and starts the bridge on a random available port. +// The handler processes incoming PHP requests via HandleBridgeCall. +func NewBridge(handler BridgeHandler) (*Bridge, error) { // Result boundary + mux := http.NewServeMux() + bridge := &Bridge{handler: handler} + + mux.HandleFunc("GET /bridge/health", func(w http.ResponseWriter, r *http.Request) { + bridgeJSON(w, map[string]string{"status": "ok"}) + }) + + mux.HandleFunc("POST /bridge/call", func(w http.ResponseWriter, r *http.Request) { + var req struct { + Method string `json:"method"` + Args json.RawMessage `json:"args"` + } + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if r := core.JSONUnmarshal(body, &req); !r.OK { + http.Error(w, r.Error(), http.StatusBadRequest) + return + } + + result, err := handler.HandleBridgeCall(req.Method, req.Args) + if err != nil { + bridgeJSON(w, map[string]any{"error": err.Error()}) + return + } + bridgeJSON(w, map[string]any{"result": result}) + }) + + // Listen on a random available port (localhost only) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, core.E("php.NewBridge", "listen", err) + } + + bridge.port = listener.Addr().(*net.TCPAddr).Port + bridge.server = &http.Server{Handler: mux} + + go func() { + if err := bridge.server.Serve(listener); err != nil && err != http.ErrServerClosed { + core.Println(core.Sprintf("go-php: bridge error: %v", err)) + } + }() + + core.Println(core.Sprintf("go-php: bridge listening on http://127.0.0.1:%d", bridge.port)) + return bridge, nil +} + +// Port returns the port the bridge is listening on. +func (b *Bridge) Port() int { + return b.port +} + +// URL returns the full base URL of the bridge. +func (b *Bridge) URL() string { + return core.Sprintf("http://127.0.0.1:%d", b.port) +} + +// Shutdown gracefully stops the bridge server. +func (b *Bridge) Shutdown(ctx context.Context) error { // Result boundary + return b.server.Shutdown(ctx) +} + +func bridgeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + r := core.JSONMarshal(v) + if !r.OK { + core.Println(core.Sprintf("go-php: bridge encode error: %v", r.Error())) + return + } + if _, err := w.Write(r.Value.([]byte)); err != nil { + core.Println(core.Sprintf("go-php: bridge write error: %v", err)) + } +} diff --git a/engine/php/pkg/php/bridge_example_test.go b/engine/php/pkg/php/bridge_example_test.go new file mode 100644 index 0000000..cd06056 --- /dev/null +++ b/engine/php/pkg/php/bridge_example_test.go @@ -0,0 +1,20 @@ +//go:build auditdocs +// +build auditdocs + +package php + +func ExampleNewBridge() { + _ = NewBridge +} + +func ExampleBridge_Port() { + _ = (*Bridge).Port +} + +func ExampleBridge_URL() { + _ = (*Bridge).URL +} + +func ExampleBridge_Shutdown() { + _ = (*Bridge).Shutdown +} diff --git a/engine/php/pkg/php/bridge_test.go b/engine/php/pkg/php/bridge_test.go new file mode 100644 index 0000000..0d77943 --- /dev/null +++ b/engine/php/pkg/php/bridge_test.go @@ -0,0 +1,73 @@ +package php + +func TestBridge_NewBridge_Good(t *T) { + subject := NewBridge + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestBridge_NewBridge_Bad(t *T) { + subject := NewBridge + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestBridge_NewBridge_Ugly(t *T) { + subject := NewBridge + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestBridge_Bridge_Port_Good(t *T) { + subject := (*Bridge).Port + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestBridge_Bridge_Port_Bad(t *T) { + subject := (*Bridge).Port + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestBridge_Bridge_Port_Ugly(t *T) { + subject := (*Bridge).Port + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestBridge_Bridge_URL_Good(t *T) { + subject := (*Bridge).URL + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestBridge_Bridge_URL_Bad(t *T) { + subject := (*Bridge).URL + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestBridge_Bridge_URL_Ugly(t *T) { + subject := (*Bridge).URL + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestBridge_Bridge_Shutdown_Good(t *T) { + subject := (*Bridge).Shutdown + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestBridge_Bridge_Shutdown_Bad(t *T) { + subject := (*Bridge).Shutdown + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestBridge_Bridge_Shutdown_Ugly(t *T) { + subject := (*Bridge).Shutdown + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} diff --git a/engine/php/pkg/php/cmd.go b/engine/php/pkg/php/cmd.go new file mode 100644 index 0000000..9a4f42f --- /dev/null +++ b/engine/php/pkg/php/cmd.go @@ -0,0 +1,148 @@ +package php + +import ( + core "dappco.re/go" + "dappco.re/go/cli/pkg/cli" + "dappco.re/go/io" +) + +// DefaultMedium is the default filesystem medium used by the php package. +// It defaults to io.Local (unsandboxed filesystem access). +// Use SetMedium to change this for testing or sandboxed operation. +var DefaultMedium io.Medium = io.Local + +// SetMedium sets the default medium for filesystem operations. +// This is primarily useful for testing with mock mediums. +func SetMedium(m io.Medium) { + DefaultMedium = m +} + +// getMedium returns the default medium for filesystem operations. +func getMedium() io.Medium { + return DefaultMedium +} + +// Style aliases from shared +var ( + successStyle = cli.SuccessStyle + errorStyle = cli.ErrorStyle + dimStyle = cli.DimStyle + linkStyle = cli.LinkStyle +) + +// Service colors for log output (domain-specific, keep local) +var ( + phpFrankenPHPStyle = cli.NewStyle().Foreground(cli.ColourIndigo500) + phpViteStyle = cli.NewStyle().Foreground(cli.ColourYellow500) + phpHorizonStyle = cli.NewStyle().Foreground(cli.ColourOrange500) + phpReverbStyle = cli.NewStyle().Foreground(cli.ColourViolet500) + phpRedisStyle = cli.NewStyle().Foreground(cli.ColourRed500) +) + +// Status styles (from shared) +var ( + phpStatusRunning = cli.SuccessStyle + phpStatusStopped = cli.DimStyle + phpStatusError = cli.ErrorStyle +) + +// QA command styles (from shared) — most moved to core/lint +var ( + phpQAWarningStyle = cli.WarningStyle +) + +// AddPHPCommands adds PHP/Laravel development commands under the php namespace. +func AddPHPCommands(c *core.Core) { + phpHelpCommand(c, "php", phpT("cmd.php.short")) + addPHPCommandSet(c, "php") +} + +// registerFrankenPHP is set by cmd_serve_frankenphp.go when CGO is enabled. +var registerFrankenPHP func(c *core.Core, prefix string) + +// AddPHPRootCommands adds PHP commands directly to root (for standalone core-php binary). +func AddPHPRootCommands(c *core.Core) { + addPHPCommandSet(c, "") +} + +func addPHPCommandSet(c *core.Core, prefix string) { + // Development + addPHPDevCommand(c, prefix) + addPHPLogsCommand(c, prefix) + addPHPStopCommand(c, prefix) + addPHPStatusCommand(c, prefix) + addPHPSSLCommand(c, prefix) + + // Build & Deploy + addPHPBuildCommand(c, prefix) + addPHPServeCommand(c, prefix) + addPHPShellCommand(c, prefix) + + // CI/CD Integration + addPHPCICommand(c, prefix) + + // Package Management + addPHPPackagesCommands(c, prefix) + + // Deployment + addPHPDeployCommands(c, prefix) + + // FrankenPHP embedded commands (CGO only) + if registerFrankenPHP != nil { + registerFrankenPHP(c, prefix) + } +} + +func phpCommandPath(prefix, name string) string { + if prefix == "" { + return name + } + return prefix + "/" + name +} + +func activateWorkspacePackage() error { // Result boundary + wsRoot, config, ok := loadActiveWorkspaceConfig() + if !ok { + return nil + } + + targetDir := activeWorkspacePackageDir(wsRoot, config) + if !getMedium().IsDir(targetDir) { + cli.Warnf("Active package directory not found: %s", targetDir) + return nil + } + + if r := core.Chdir(targetDir); !r.OK { + err, _ := r.Value.(error) + return core.E("php", "failed to change directory to active package", err) + } + + cli.Print(cliLabelValueFormat, dimStyle.Render("Workspace:"), config.Active) + return nil +} + +func loadActiveWorkspaceConfig() (string, *workspaceConfig, bool) { + wsRoot, err := findWorkspaceRoot() + if err != nil { + return "", nil, false + } + + config, err := loadWorkspaceConfig(wsRoot) + if err != nil || config == nil || config.Active == "" { + return "", nil, false + } + + return wsRoot, config, true +} + +func activeWorkspacePackageDir(wsRoot string, config *workspaceConfig) string { + pkgDir := config.PackagesDir + if pkgDir == "" { + pkgDir = "./packages" + } + if !core.PathIsAbs(pkgDir) { + pkgDir = core.PathJoin(wsRoot, pkgDir) + } + + return core.PathJoin(pkgDir, config.Active) +} diff --git a/engine/php/pkg/php/cmd_build.go b/engine/php/pkg/php/cmd_build.go new file mode 100644 index 0000000..fe1adba --- /dev/null +++ b/engine/php/pkg/php/cmd_build.go @@ -0,0 +1,252 @@ +package php + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/cli/pkg/cli" +) + +func addPHPBuildCommand(c *core.Core, prefix string) { + path := phpCommandPath(prefix, "build") + phpFailureorCommand(c, path, phpT("cmd.php.build.short"), func(opts core.Options) error { + line := phpCommandLineFor(path, opts) + cwdR := core.Getwd() + if !cwdR.OK { + return core.E("php", phpT(i18nFailGetKey, workingDirectorySubject), cwdR.Value.(error)) + } + cwd := cwdR.Value.(string) + + ctx := context.Background() + + switch core.Lower(line.String("type", "")) { + case "linuxkit": + return runPHPBuildLinuxKit(ctx, cwd, linuxKitBuildOptions{ + OutputPath: line.String("output", ""), + Format: line.String("format", ""), + Template: line.String("template", ""), + }) + default: + return runPHPBuildDocker(ctx, cwd, dockerBuildOptions{ + ImageName: line.String("name", ""), + Tag: line.String("tag", ""), + Platform: line.String("platform", ""), + Dockerfile: line.String("dockerfile", ""), + NoCache: line.Bool("no-cache"), + }) + } + }) +} + +type dockerBuildOptions struct { + ImageName string + Tag string + Platform string + Dockerfile string + NoCache bool +} + +type linuxKitBuildOptions struct { + OutputPath string + Format string + Template string +} + +func runPHPBuildDocker(ctx context.Context, projectDir string, opts dockerBuildOptions) error { // Result boundary + if !IsPHPProject(projectDir) { + return core.E("php.runPHPBuildDocker", phpT("cmd.php.error.not_php"), nil) + } + + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(phpT(cmdPHPLabelKey)), phpT("cmd.php.build.building_docker")) + + // Show detected configuration + config, err := DetectDockerfileConfig(projectDir) + if err != nil { + return core.E("php", phpT("i18n.fail.detect", "project configuration"), err) + } + + cli.Print(cliLabelValueFormat, dimStyle.Render(phpT("cmd.php.build.php_version")), config.PHPVersion) + cli.Print(cliLabelBoolFormat, dimStyle.Render(phpT("cmd.php.build.laravel")), config.IsLaravel) + cli.Print(cliLabelBoolFormat, dimStyle.Render(phpT("cmd.php.build.octane")), config.HasOctane) + cli.Print(cliLabelBoolFormat, dimStyle.Render(phpT("cmd.php.build.frontend")), config.HasAssets) + if len(config.PHPExtensions) > 0 { + cli.Print(cliLabelValueFormat, dimStyle.Render(phpT("cmd.php.build.extensions")), core.Join(", ", config.PHPExtensions...)) + } + cli.Blank() + + // Build options + buildOpts := DockerBuildOptions{ + ProjectDir: projectDir, + ImageName: opts.ImageName, + Tag: opts.Tag, + Platform: opts.Platform, + Dockerfile: opts.Dockerfile, + NoBuildCache: opts.NoCache, + Output: core.Stdout(), + } + + if buildOpts.ImageName == "" { + buildOpts.ImageName = GetLaravelAppName(projectDir) + if buildOpts.ImageName == "" { + buildOpts.ImageName = "php-app" + } + // Sanitize for Docker + buildOpts.ImageName = core.Lower(core.Replace(buildOpts.ImageName, " ", "-")) + } + + if buildOpts.Tag == "" { + buildOpts.Tag = "latest" + } + + cli.Print("%s %s:%s\n", dimStyle.Render(phpLabel("image")), buildOpts.ImageName, buildOpts.Tag) + if opts.Platform != "" { + cli.Print(cliLabelValueFormat, dimStyle.Render(phpT("cmd.php.build.platform")), opts.Platform) + } + cli.Blank() + + if err := BuildDocker(ctx, buildOpts); err != nil { + return core.E("php", phpT("i18n.fail.build"), err) + } + + cli.Print(cliSectionLabelValueFormat, successStyle.Render(phpLabel("done")), phpT("common.success.completed", map[string]any{"Action": "Docker image built"})) + cli.Print("%s docker run -p 80:80 -p 443:443 %s:%s\n", + dimStyle.Render(phpT("cmd.php.build.docker_run_with")), + buildOpts.ImageName, buildOpts.Tag) + + return nil +} + +func runPHPBuildLinuxKit(ctx context.Context, projectDir string, opts linuxKitBuildOptions) error { // Result boundary + if !IsPHPProject(projectDir) { + return core.E("php.runPHPBuildLinuxKit", phpT("cmd.php.error.not_php"), nil) + } + + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(phpT(cmdPHPLabelKey)), phpT("cmd.php.build.building_linuxkit")) + + buildOpts := LinuxKitBuildOptions{ + ProjectDir: projectDir, + OutputPath: opts.OutputPath, + Format: opts.Format, + Template: opts.Template, + Output: core.Stdout(), + } + + if buildOpts.Format == "" { + buildOpts.Format = "qcow2" + } + if buildOpts.Template == "" { + buildOpts.Template = defaultLinuxKitTemplateName + } + + cli.Print(cliLabelValueFormat, dimStyle.Render(phpLabel("template")), buildOpts.Template) + cli.Print(cliLabelValueFormat, dimStyle.Render(phpT("cmd.php.build.format")), buildOpts.Format) + cli.Blank() + + if err := BuildLinuxKit(ctx, buildOpts); err != nil { + return core.E("php", phpT("i18n.fail.build"), err) + } + + cli.Print(cliSectionLabelValueFormat, successStyle.Render(phpLabel("done")), phpT("common.success.completed", map[string]any{"Action": "LinuxKit image built"})) + return nil +} + +func addPHPServeCommand(c *core.Core, prefix string) { + path := phpCommandPath(prefix, "serve") + phpFailureorCommand(c, path, phpT("cmd.php.serve.short"), func(opts core.Options) error { + line := phpCommandLineFor(path, opts) + imageName, err := resolveServeImageName(line.String("name", "")) + if err != nil { + return err + } + + ctx := context.Background() + serveTag := line.String("tag", "") + servePort := line.Int("port", 0) + serveHTTPSPort := line.Int("https-port", 0) + serveDetach := line.Bool("detach", "d") + + serveOpts := ServeOptions{ + ImageName: imageName, + Tag: serveTag, + ContainerName: line.String("container", ""), + Port: servePort, + HTTPSPort: serveHTTPSPort, + Detach: serveDetach, + EnvFile: line.String("env-file", ""), + Output: core.Stdout(), + } + + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(phpT(cmdPHPLabelKey)), phpProgressSubject("run", "production container")) + cli.Print("%s %s:%s\n", dimStyle.Render(phpLabel("image")), imageName, displayServeTag(serveTag)) + + effectivePort, effectiveHTTPSPort := effectiveServePorts(servePort, serveHTTPSPort) + cli.Print("%s http://localhost:%d, https://localhost:%d\n", + dimStyle.Render("Ports:"), effectivePort, effectiveHTTPSPort) + cli.Blank() + + if err := ServeProduction(ctx, serveOpts); err != nil { + return core.E("php", phpT("i18n.fail.start", "container"), err) + } + + if !serveDetach { + cli.Print(cliSectionLabelValueFormat, dimStyle.Render(phpT(cmdPHPLabelKey)), phpT("cmd.php.serve.stopped")) + } + + return nil + }) +} + +func resolveServeImageName(imageName string) (string, error) { // Result boundary + if imageName != "" { + return imageName, nil + } + + if cwdR := core.Getwd(); cwdR.OK { + if appName := GetLaravelAppName(cwdR.Value.(string)); appName != "" { + return core.Lower(core.Replace(appName, " ", "-")), nil + } + } + + return "", core.E("php.resolveServeImageName", phpT("cmd.php.serve.name_required"), nil) +} + +func displayServeTag(tag string) string { + if tag == "" { + return "latest" + } + return tag +} + +func effectiveServePorts(port, httpsPort int) (int, int) { + effectivePort := port + if effectivePort == 0 { + effectivePort = 80 + } + + effectiveHTTPSPort := httpsPort + if effectiveHTTPSPort == 0 { + effectiveHTTPSPort = 443 + } + + return effectivePort, effectiveHTTPSPort +} + +func addPHPShellCommand(c *core.Core, prefix string) { + path := phpCommandPath(prefix, "shell") + phpFailureorCommand(c, path, phpT("cmd.php.shell.short"), func(opts core.Options) error { + args := phpCommandLineFor(path, opts).Args() + if len(args) != 1 { + return phpFailure("requires exactly 1 arg(s), received %d", len(args)) + } + + ctx := context.Background() + + cli.Print(cliLabelValueFormat, dimStyle.Render(phpT(cmdPHPLabelKey)), phpT("cmd.php.shell.opening", map[string]interface{}{"Container": args[0]})) + + if err := Shell(ctx, args[0]); err != nil { + return core.E("php", phpT("i18n.fail.open", "shell"), err) + } + + return nil + }) +} diff --git a/engine/php/pkg/php/cmd_ci.go b/engine/php/pkg/php/cmd_ci.go new file mode 100644 index 0000000..32ca1a1 --- /dev/null +++ b/engine/php/pkg/php/cmd_ci.go @@ -0,0 +1,607 @@ +// cmd_ci.go implements the 'php ci' command for CI/CD pipeline integration. +// +// Usage: +// core php ci # Run full CI pipeline +// core php ci --json # Output combined JSON report +// core php ci --summary # Output markdown summary +// core php ci --sarif # Generate SARIF files +// core php ci --upload-sarif # Upload SARIF to GitHub Security +// core php ci --fail-on=high # Only fail on high+ severity + +package php + +import ( + "context" + "encoding/json" + "os/exec" + "time" + + core "dappco.re/go" + "dappco.re/go/cli/pkg/cli" +) + +// CI command flags +var ( + ciJSON bool + ciSummary bool + ciSARIF bool + ciUploadSARIF bool + ciFailOn string +) + +// CIResult represents the overall CI pipeline result +type CIResult struct { + Passed bool `json:"passed"` + ExitCode int `json:"exit_code"` + Duration string `json:"duration"` + StartedAt time.Time `json:"started_at"` + Checks []CICheckResult `json:"checks"` + Summary CISummary `json:"summary"` + Artifacts []string `json:"artifacts,omitempty"` +} + +// CICheckResult represents an individual check result +type CICheckResult struct { + Name string `json:"name"` + Status string `json:"status"` // passed, failed, warning, skipped + Duration string `json:"duration"` + Details string `json:"details,omitempty"` + Issues int `json:"issues,omitempty"` + Errors int `json:"errors,omitempty"` + Warnings int `json:"warnings,omitempty"` +} + +// CISummary contains aggregate statistics +type CISummary struct { + Total int `json:"total"` + Passed int `json:"passed"` + Failed int `json:"failed"` + Warnings int `json:"warnings"` + Skipped int `json:"skipped"` +} + +type ciCheckDefinition struct { + name string + run func(context.Context, string) (CICheckResult, error) + sarif bool +} + +func addPHPCICommand(c *core.Core, prefix string) { + path := phpCommandPath(prefix, "ci") + phpFailureorCommand(c, path, phpT("cmd.php.ci.short"), func(opts core.Options) error { + line := phpCommandLineFor(path, opts) + ciJSON = line.Bool(`json`) + ciSummary = line.Bool("summary") + ciSARIF = line.Bool("sarif") + ciUploadSARIF = line.Bool("upload-sarif") + ciFailOn = line.String("fail-on", "error") + return runPHPCI() + }) +} + +func runPHPCI() error { // Result boundary + cwdR := core.Getwd() + if !cwdR.OK { + return core.E("php", phpT(i18nFailGetKey, workingDirectorySubject), cwdR.Value.(error)) + } + cwd := cwdR.Value.(string) + + if !IsPHPProject(cwd) { + return core.E("php.runCI", phpT("cmd.php.error.not_php"), nil) + } + + startTime := time.Now() + ctx := context.Background() + + checks := defaultCIChecks() + + result := CIResult{ + StartedAt: startTime, + Passed: true, + Checks: make([]CICheckResult, 0, len(checks)), + } + + printCIHeader() + artifacts := runCIChecks(ctx, cwd, checks, &result) + + result.Duration = time.Since(startTime).Round(time.Millisecond).String() + result.Artifacts = artifacts + result.ExitCode = ciExitCode(result.Passed) + + return outputCIResult(ctx, cwd, result, artifacts) +} + +func defaultCIChecks() []ciCheckDefinition { + return []ciCheckDefinition{ + {"test", runCITest, false}, + {"stan", runCIStan, true}, + {"psalm", runCIPsalm, true}, + {`fmt`, runCIFmt, false}, + {"audit", runCIAudit, false}, + {"security", runCISecurity, false}, + } +} + +func printCIHeader() { + if ciJSON { + return + } + + cli.Print(cliSingleLineFormat, cli.BoldStyle.Render("core php ci - QA Pipeline")) + cli.Print("%s\n\n", repeat("─", 40)) +} + +func runCIChecks(ctx context.Context, cwd string, checks []ciCheckDefinition, result *CIResult) []string { + var artifacts []string + + for _, check := range checks { + printCICheckProgress(check.name) + checkResult := executeCICheck(ctx, cwd, check) + recordCICheckResult(result, checkResult) + printCICheckResult(check.name, checkResult) + artifacts = appendCIArtifact(ctx, cwd, check, artifacts) + } + + return artifacts +} + +func printCICheckProgress(name string) { + if !ciJSON { + cli.Print(" %s %s...", dimStyle.Render("→"), name) + } +} + +func executeCICheck(ctx context.Context, cwd string, check ciCheckDefinition) CICheckResult { + checkResult, err := check.run(ctx, cwd) + if err == nil { + return checkResult + } + + return CICheckResult{ + Name: check.name, + Status: "failed", + Details: err.Error(), + } +} + +func recordCICheckResult(result *CIResult, checkResult CICheckResult) { + result.Checks = append(result.Checks, checkResult) + result.Summary.Total++ + + switch checkResult.Status { + case "passed": + result.Summary.Passed++ + case "failed": + result.Summary.Failed++ + if shouldFailOn(checkResult, ciFailOn) { + result.Passed = false + } + case "warning": + result.Summary.Warnings++ + case "skipped": + result.Summary.Skipped++ + } +} + +func printCICheckResult(name string, checkResult CICheckResult) { + if !ciJSON { + cli.Print("\r %s %s %s\n", getStatusIcon(checkResult.Status), name, dimStyle.Render(checkResult.Details)) + } +} + +func appendCIArtifact(ctx context.Context, cwd string, check ciCheckDefinition, artifacts []string) []string { + if !shouldGenerateSARIF(check) { + return artifacts + } + + sarifFile := core.PathJoin(cwd, check.name+".sarif") + if generateSARIF(ctx, cwd, check.name, sarifFile) == nil { + return append(artifacts, sarifFile) + } + + return artifacts +} + +func shouldGenerateSARIF(check ciCheckDefinition) bool { + return (ciSARIF || ciUploadSARIF) && check.sarif +} + +func ciExitCode(passed bool) int { + if passed { + return 0 + } + return 1 +} + +func outputCIResult(ctx context.Context, cwd string, result CIResult, artifacts []string) error { // Result boundary + if ciJSON { + return outputCIJSONResult(result) + } + if ciSummary { + return outputCISummaryResult(result) + } + return outputCIDefault(ctx, cwd, result, artifacts) +} + +func outputCIJSONResult(result CIResult) error { // Result boundary + if err := outputCIJSON(result); err != nil { + return err + } + if !result.Passed { + return phpExit(result.ExitCode, phpFailure(ciPipelineFailedMessage)) + } + return nil +} + +func outputCISummaryResult(result CIResult) error { // Result boundary + if err := outputCISummary(result); err != nil { + return err + } + if !result.Passed { + return phpFailure(ciPipelineFailedMessage) + } + return nil +} + +func outputCIDefault(ctx context.Context, cwd string, result CIResult, artifacts []string) error { // Result boundary + cli.Print(cliSingleLineFormat, repeat("─", 40)) + + if result.Passed { + cli.Print(cliLabelValueFormat, successStyle.Render("✓ CI PASSED"), dimStyle.Render(result.Duration)) + } else { + cli.Print(cliLabelValueFormat, errorStyle.Render("✗ CI FAILED"), dimStyle.Render(result.Duration)) + } + + if len(artifacts) > 0 { + cli.Print(cliSingleLineFormat, dimStyle.Render("Artifacts:")) + for _, a := range artifacts { + cli.Print(" → %s\n", core.PathBase(a)) + } + } + + // Upload SARIF if requested + uploadCIArtifacts(ctx, artifacts) + + if !result.Passed { + return phpFailure(ciPipelineFailedMessage) + } + return nil +} + +func uploadCIArtifacts(ctx context.Context, artifacts []string) { + if !ciUploadSARIF || len(artifacts) == 0 { + return + } + + cli.Blank() + for _, sarifFile := range artifacts { + if err := uploadSARIFToGitHub(ctx, sarifFile); err != nil { + cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), core.PathBase(sarifFile), err) + } else { + cli.Print(" %s %s uploaded\n", successStyle.Render("✓"), core.PathBase(sarifFile)) + } + } +} + +// runCITest runs Pest/PHPUnit tests +func runCITest(ctx context.Context, dir string) (CICheckResult, error) { // Result boundary + start := time.Now() + result := CICheckResult{Name: "test", Status: "passed"} + + opts := TestOptions{ + Dir: dir, + Output: nil, // Suppress output + } + + if err := RunTests(ctx, opts); err != nil { + result.Status = "failed" + result.Details = err.Error() + } else { + result.Details = "all tests passed" + } + + result.Duration = time.Since(start).Round(time.Millisecond).String() + return result, nil +} + +// runCIStan runs PHPStan +func runCIStan(ctx context.Context, dir string) (CICheckResult, error) { // Result boundary + start := time.Now() + result := CICheckResult{Name: "stan", Status: "passed"} + + _, found := DetectAnalyser(dir) + if !found { + result.Status = "skipped" + result.Details = "PHPStan not configured" + return result, nil + } + + opts := AnalyseOptions{ + Dir: dir, + Output: nil, + } + + if err := Analyse(ctx, opts); err != nil { + result.Status = "failed" + result.Details = "errors found" + } else { + result.Details = "0 errors" + } + + result.Duration = time.Since(start).Round(time.Millisecond).String() + return result, nil +} + +// runCIPsalm runs Psalm +func runCIPsalm(ctx context.Context, dir string) (CICheckResult, error) { // Result boundary + start := time.Now() + result := CICheckResult{Name: "psalm", Status: "passed"} + + _, found := DetectPsalm(dir) + if !found { + result.Status = "skipped" + result.Details = "Psalm not configured" + return result, nil + } + + opts := PsalmOptions{ + Dir: dir, + Output: nil, + } + + if err := RunPsalm(ctx, opts); err != nil { + result.Status = "failed" + result.Details = "errors found" + } else { + result.Details = "0 errors" + } + + result.Duration = time.Since(start).Round(time.Millisecond).String() + return result, nil +} + +// runCIFmt checks code formatting +func runCIFmt(ctx context.Context, dir string) (CICheckResult, error) { // Result boundary + start := time.Now() + result := CICheckResult{Name: `fmt`, Status: "passed"} + + _, found := DetectFormatter(dir) + if !found { + result.Status = "skipped" + result.Details = "no formatter configured" + return result, nil + } + + opts := FormatOptions{ + Dir: dir, + Fix: false, // Check only + Output: nil, + } + + if err := Format(ctx, opts); err != nil { + result.Status = "warning" + result.Details = "formatting issues" + } else { + result.Details = "code style OK" + } + + result.Duration = time.Since(start).Round(time.Millisecond).String() + return result, nil +} + +// runCIAudit runs composer audit +func runCIAudit(ctx context.Context, dir string) (CICheckResult, error) { // Result boundary + start := time.Now() + result := CICheckResult{Name: "audit", Status: "passed"} + + results, err := RunAudit(ctx, AuditOptions{ + Dir: dir, + Output: nil, + }) + if err != nil { + result.Status = "failed" + result.Details = err.Error() + result.Duration = time.Since(start).Round(time.Millisecond).String() + return result, nil + } + + totalVulns := 0 + for _, r := range results { + totalVulns += r.Vulnerabilities + } + + if totalVulns > 0 { + result.Status = "failed" + result.Details = core.Sprintf("%d vulnerabilities", totalVulns) + result.Issues = totalVulns + } else { + result.Details = "no vulnerabilities" + } + + result.Duration = time.Since(start).Round(time.Millisecond).String() + return result, nil +} + +// runCISecurity runs security checks +func runCISecurity(ctx context.Context, dir string) (CICheckResult, error) { // Result boundary + start := time.Now() + result := CICheckResult{Name: "security", Status: "passed"} + + secResult, err := RunSecurityChecks(ctx, SecurityOptions{ + Dir: dir, + Output: nil, + }) + if err != nil { + result.Status = "failed" + result.Details = err.Error() + result.Duration = time.Since(start).Round(time.Millisecond).String() + return result, nil + } + + if secResult.Summary.Critical > 0 || secResult.Summary.High > 0 { + result.Status = "failed" + result.Details = core.Sprintf("%d critical, %d high", secResult.Summary.Critical, secResult.Summary.High) + result.Issues = secResult.Summary.Critical + secResult.Summary.High + } else if secResult.Summary.Medium > 0 { + result.Status = "warning" + result.Details = core.Sprintf("%d medium issues", secResult.Summary.Medium) + result.Warnings = secResult.Summary.Medium + } else { + result.Details = "no issues" + } + + result.Duration = time.Since(start).Round(time.Millisecond).String() + return result, nil +} + +// shouldFailOn determines if a check should cause CI failure based on --fail-on +func shouldFailOn(check CICheckResult, level string) bool { + switch level { + case "critical": + return check.Status == "failed" && check.Issues > 0 + case "high", "error": + return check.Status == "failed" + case "warning": + return check.Status == "failed" || check.Status == "warning" + default: + return check.Status == "failed" + } +} + +// getStatusIcon returns the icon for a check status +func getStatusIcon(status string) string { + switch status { + case "passed": + return successStyle.Render("✓") + case "failed": + return errorStyle.Render("✗") + case "warning": + return phpQAWarningStyle.Render("⚠") + case "skipped": + return dimStyle.Render("-") + default: + return dimStyle.Render("?") + } +} + +// outputCIJSON outputs the result as JSON +func outputCIJSON(result CIResult) error { // Result boundary + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + core.Println(string(data)) + return nil +} + +// outputCISummary outputs a markdown summary +func outputCISummary(result CIResult) error { // Result boundary + var sb stringBuilder + + sb.WriteString("## CI Pipeline Results\n\n") + + if result.Passed { + sb.WriteString("**Status:** ✅ Passed\n\n") + } else { + sb.WriteString("**Status:** ❌ Failed\n\n") + } + + sb.WriteString("| Check | Status | Details |\n") + sb.WriteString("|-------|--------|----------|\n") + + for _, check := range result.Checks { + icon := "✅" + switch check.Status { + case "failed": + icon = "❌" + case "warning": + icon = "⚠️" + case "skipped": + icon = "⏭️" + } + sb.WriteString(core.Sprintf("| %s | %s | %s |\n", check.Name, icon, check.Details)) + } + + sb.WriteString(core.Sprintf("\n**Duration:** %s\n", result.Duration)) + + core.Println(sb.String()) + return nil +} + +// generateSARIF generates a SARIF file for a specific check +func generateSARIF(ctx context.Context, dir, checkName, outputFile string) error { // Result boundary + var args []string + + switch checkName { + case "stan": + args = []string{"vendor/bin/phpstan", "analyse", "--error-format=sarif", "--no-progress"} + case "psalm": + args = []string{"vendor/bin/psalm", "--output-format=sarif"} + default: + return core.E("php.SARIF", core.Sprintf("SARIF not supported for %s", checkName), nil) + } + + cmd := exec.CommandContext(ctx, "php", args...) + cmd.Dir = dir + + // Capture output - command may exit non-zero when issues are found + // but still produce valid SARIF output + output, err := cmd.CombinedOutput() + if len(output) == 0 { + if err != nil { + return core.E("php.SARIF", "failed to generate SARIF", err) + } + return core.E("php.SARIF", "no SARIF output generated", nil) + } + + // Validate output is valid JSON + var js json.RawMessage + if err := json.Unmarshal(output, &js); err != nil { + return core.E("php.SARIF", "invalid SARIF output", err) + } + + return getMedium().Write(outputFile, string(output)) +} + +// uploadSARIFToGitHub uploads a SARIF file to GitHub Security tab +func uploadSARIFToGitHub(ctx context.Context, sarifFile string) error { // Result boundary + // Validate commit SHA before calling API + sha := getGitSHA() + if sha == "" { + return core.E("php.SARIF", "cannot upload SARIF: git commit SHA not available (ensure you're in a git repository)", nil) + } + + // Use gh CLI to upload + cmd := exec.CommandContext(ctx, "gh", "api", + "repos/{owner}/{repo}/code-scanning/sarifs", + "-X", "POST", + "-F", "sarif=@"+sarifFile, + "-F", "ref="+getGitRef(), + "-F", "commit_sha="+sha, + ) + + if output, err := cmd.CombinedOutput(); err != nil { + return core.E("php.SARIF", core.Sprintf("%s: %s", err, string(output)), nil) + } + return nil +} + +// getGitRef returns the current git ref +func getGitRef() string { + cmd := exec.Command("git", "symbolic-ref", "HEAD") + output, err := cmd.Output() + if err != nil { + return "refs/heads/main" + } + return core.Trim(string(output)) +} + +// getGitSHA returns the current git commit SHA +func getGitSHA() string { + cmd := exec.Command("git", "rev-parse", "HEAD") + output, err := cmd.Output() + if err != nil { + return "" + } + return core.Trim(string(output)) +} diff --git a/engine/php/pkg/php/cmd_ci_example_test.go b/engine/php/pkg/php/cmd_ci_example_test.go new file mode 100644 index 0000000..b0f913f --- /dev/null +++ b/engine/php/pkg/php/cmd_ci_example_test.go @@ -0,0 +1,4 @@ +//go:build auditdocs +// +build auditdocs + +package php diff --git a/engine/php/pkg/php/cmd_ci_test.go b/engine/php/pkg/php/cmd_ci_test.go new file mode 100644 index 0000000..55ace2c --- /dev/null +++ b/engine/php/pkg/php/cmd_ci_test.go @@ -0,0 +1 @@ +package php diff --git a/engine/php/pkg/php/cmd_commands.go b/engine/php/pkg/php/cmd_commands.go new file mode 100644 index 0000000..490e277 --- /dev/null +++ b/engine/php/pkg/php/cmd_commands.go @@ -0,0 +1,41 @@ +// Package php provides Laravel/PHP development and deployment commands. +// +// Development Commands: +// - dev: Start Laravel environment (FrankenPHP, Vite, Horizon, Reverb, Redis) +// - logs: Stream unified service logs +// - stop: Stop all running services +// - status: Show service status +// - ssl: Setup SSL certificates with mkcert +// +// Build Commands: +// - build: Build Docker or LinuxKit image +// - serve: Run production container +// - shell: Open shell in running container +// +// Code Quality: +// - test: Run PHPUnit/Pest tests +// - fmt: Format code with Laravel Pint +// - stan: Run PHPStan/Larastan static analysis +// - psalm: Run Psalm static analysis +// - audit: Security audit for dependencies +// - security: Security vulnerability scanning +// - qa: Run full QA pipeline +// - rector: Automated code refactoring +// - infection: Mutation testing for test quality +// +// Package Management: +// - packages link/unlink/update/list: Manage local Composer packages +// +// Deployment (Coolify): +// - deploy: Deploy to Coolify +// - deploy:status: Check deployment status +// - deploy:rollback: Rollback deployment +// - deploy:list: List recent deployments +package php + +import core "dappco.re/go" + +// AddCommands registers the 'php' command and all subcommands. +func AddCommands(c *core.Core) { + AddPHPCommands(c) +} diff --git a/engine/php/pkg/php/cmd_commands_example_test.go b/engine/php/pkg/php/cmd_commands_example_test.go new file mode 100644 index 0000000..f1f4cb9 --- /dev/null +++ b/engine/php/pkg/php/cmd_commands_example_test.go @@ -0,0 +1,8 @@ +//go:build auditdocs +// +build auditdocs + +package php + +func ExampleAddCommands() { + _ = AddCommands +} diff --git a/engine/php/pkg/php/cmd_commands_test.go b/engine/php/pkg/php/cmd_commands_test.go new file mode 100644 index 0000000..2ffad40 --- /dev/null +++ b/engine/php/pkg/php/cmd_commands_test.go @@ -0,0 +1,19 @@ +package php + +func TestCmdCommands_AddCommands_Good(t *T) { + subject := AddCommands + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestCmdCommands_AddCommands_Bad(t *T) { + subject := AddCommands + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestCmdCommands_AddCommands_Ugly(t *T) { + subject := AddCommands + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} diff --git a/engine/php/pkg/php/cmd_deploy.go b/engine/php/pkg/php/cmd_deploy.go new file mode 100644 index 0000000..162b013 --- /dev/null +++ b/engine/php/pkg/php/cmd_deploy.go @@ -0,0 +1,322 @@ +package php + +import ( + "context" + "time" + + core "dappco.re/go" + "dappco.re/go/cli/pkg/cli" +) + +// Deploy command styles (aliases to shared) +var ( + phpDeployStyle = cli.SuccessStyle + phpDeployPendingStyle = cli.WarningStyle + phpDeployFailedStyle = cli.ErrorStyle +) + +func addPHPDeployCommands(c *core.Core, prefix string) { + // Main deploy command + addPHPDeployCommand(c, prefix) + + // Deploy status subcommand (using colon notation: deploy:status) + addPHPDeployStatusCommand(c, prefix) + + // Deploy rollback subcommand + addPHPDeployRollbackCommand(c, prefix) + + // Deploy list subcommand + addPHPDeployListCommand(c, prefix) +} + +func addPHPDeployCommand(c *core.Core, prefix string) { + path := phpCommandPath(prefix, "deploy") + phpFailureorCommand(c, path, phpT("cmd.php.deploy.short"), func(options core.Options) error { + line := phpCommandLineFor(path, options) + cwdResult := core.Getwd() + if !cwdResult.OK { + err, _ := cwdResult.Value.(error) + return core.E("php", phpT(i18nFailGetKey, workingDirectorySubject), err) + } + cwd, _ := cwdResult.Value.(string) + + env := EnvProduction + if line.Bool("staging") { + env = EnvStaging + } + + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(phpT(cmdPHPDeployLabelKey)), phpT("cmd.php.deploy.deploying", map[string]interface{}{"Environment": env})) + + ctx := context.Background() + + deployOpts := DeployOptions{ + Dir: cwd, + Environment: env, + Force: line.Bool("force"), + Wait: line.Bool("wait"), + } + + status, err := Deploy(ctx, deployOpts) + if err != nil { + return core.E("php", phpT("cmd.php.error.deploy_failed"), err) + } + + printDeploymentStatus(status) + + if deployOpts.Wait { + if IsDeploymentSuccessful(status.Status) { + cli.Print(cliSectionLabelValueFormat, successStyle.Render(phpLabel("done")), phpT("common.success.completed", map[string]any{"Action": "Deployment completed"})) + } else { + cli.Print(cliSectionLabelValueFormat, errorStyle.Render(phpLabel("warning")), phpT("cmd.php.deploy.warning_status", map[string]interface{}{"Status": status.Status})) + } + } else { + cli.Print(cliSectionLabelValueFormat, successStyle.Render(phpLabel("done")), phpT("cmd.php.deploy.triggered")) + } + + return nil + }) +} + +func addPHPDeployStatusCommand(c *core.Core, prefix string) { + path := phpCommandPath(prefix, "deploy:status") + phpFailureorCommand(c, path, phpT("cmd.php.deploy_status.short"), func(options core.Options) error { + line := phpCommandLineFor(path, options) + cwdResult := core.Getwd() + if !cwdResult.OK { + err, _ := cwdResult.Value.(error) + return core.E("php", phpT(i18nFailGetKey, workingDirectorySubject), err) + } + cwd, _ := cwdResult.Value.(string) + + env := EnvProduction + if line.Bool("staging") { + env = EnvStaging + } + + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(phpT(cmdPHPDeployLabelKey)), phpProgressSubject("check", "deployment status")) + + ctx := context.Background() + + statusOpts := StatusOptions{ + Dir: cwd, + Environment: env, + DeploymentID: line.String("id", ""), + } + + status, err := DeployStatus(ctx, statusOpts) + if err != nil { + return core.E("php", phpT(i18nFailGetKey, "status"), err) + } + + printDeploymentStatus(status) + + return nil + }) +} + +func addPHPDeployRollbackCommand(c *core.Core, prefix string) { + path := phpCommandPath(prefix, "deploy:rollback") + phpFailureorCommand(c, path, phpT("cmd.php.deploy_rollback.short"), func(options core.Options) error { + line := phpCommandLineFor(path, options) + cwdResult := core.Getwd() + if !cwdResult.OK { + err, _ := cwdResult.Value.(error) + return core.E("php", phpT(i18nFailGetKey, workingDirectorySubject), err) + } + cwd, _ := cwdResult.Value.(string) + + env := EnvProduction + if line.Bool("staging") { + env = EnvStaging + } + + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(phpT(cmdPHPDeployLabelKey)), phpT("cmd.php.deploy_rollback.rolling_back", map[string]interface{}{"Environment": env})) + + ctx := context.Background() + + rollbackOpts := RollbackOptions{ + Dir: cwd, + Environment: env, + DeploymentID: line.String("id", ""), + Wait: line.Bool("wait"), + } + + status, err := Rollback(ctx, rollbackOpts) + if err != nil { + return core.E("php", phpT("cmd.php.error.rollback_failed"), err) + } + + printDeploymentStatus(status) + + if rollbackOpts.Wait { + if IsDeploymentSuccessful(status.Status) { + cli.Print(cliSectionLabelValueFormat, successStyle.Render(phpLabel("done")), phpT("common.success.completed", map[string]any{"Action": "Rollback completed"})) + } else { + cli.Print(cliSectionLabelValueFormat, errorStyle.Render(phpLabel("warning")), phpT("cmd.php.deploy_rollback.warning_status", map[string]interface{}{"Status": status.Status})) + } + } else { + cli.Print(cliSectionLabelValueFormat, successStyle.Render(phpLabel("done")), phpT("cmd.php.deploy_rollback.triggered")) + } + + return nil + }) +} + +func addPHPDeployListCommand(c *core.Core, prefix string) { + path := phpCommandPath(prefix, "deploy:list") + phpFailureorCommand(c, path, phpT("cmd.php.deploy_list.short"), func(options core.Options) error { + line := phpCommandLineFor(path, options) + cwdResult := core.Getwd() + if !cwdResult.OK { + err, _ := cwdResult.Value.(error) + return core.E("php", phpT(i18nFailGetKey, workingDirectorySubject), err) + } + cwd, _ := cwdResult.Value.(string) + + env := EnvProduction + if line.Bool("staging") { + env = EnvStaging + } + + limit := line.Int("limit", 0) + if limit == 0 { + limit = 10 + } + + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(phpT(cmdPHPDeployLabelKey)), phpT("cmd.php.deploy_list.recent", map[string]interface{}{"Environment": env})) + + ctx := context.Background() + + deployments, err := ListDeployments(ctx, cwd, env, limit) + if err != nil { + return core.E("php", phpT("i18n.fail.list", "deployments"), err) + } + + if len(deployments) == 0 { + cli.Print(cliLabelValueFormat, dimStyle.Render(phpT("cmd.php.label.info")), phpT("cmd.php.deploy_list.none_found")) + return nil + } + + for i, d := range deployments { + printDeploymentSummary(i+1, &d) + } + + return nil + }) +} + +func printDeploymentStatus(status *DeploymentStatus) { + statusStyle := deploymentStatusStyle(status.Status) + cli.Print(cliLabelValueFormat, dimStyle.Render(phpLabel("status")), statusStyle.Render(status.Status)) + printDeploymentField(phpT("cmd.php.label.id"), status.ID) + if status.URL != "" { + printDeploymentField(phpLabel("url"), linkStyle.Render(status.URL)) + } + printDeploymentField(phpT("cmd.php.label.branch"), status.Branch) + + if status.Commit != "" { + cli.Print(cliLabelValueFormat, dimStyle.Render(phpT("cmd.php.label.commit")), truncateString(status.Commit, 7)) + if status.CommitMessage != "" { + cli.Print(cliLabelValueFormat, dimStyle.Render(phpT("cmd.php.label.message")), ellipsizeString(status.CommitMessage, 60)) + } + } + + if !status.StartedAt.IsZero() { + cli.Print(cliLabelValueFormat, dimStyle.Render(phpLabel("started")), status.StartedAt.Format(time.RFC3339)) + } + + if !status.CompletedAt.IsZero() { + printDeploymentCompletion(status) + } +} + +func printDeploymentSummary(index int, status *DeploymentStatus) { + // Status with color + statusStyle := phpDeployStyle + switch status.Status { + case "queued", "building", "deploying", "pending", "rolling_back": + statusStyle = phpDeployPendingStyle + case "failed", "error", "cancelled": + statusStyle = phpDeployFailedStyle + } + + // Format: #1 [finished] abc1234 - commit message (2 hours ago) + id := status.ID + if len(id) > 8 { + id = id[:8] + } + + commit := status.Commit + if len(commit) > 7 { + commit = commit[:7] + } + + msg := status.CommitMessage + if len(msg) > 40 { + msg = msg[:37] + "..." + } + + age := "" + if !status.StartedAt.IsZero() { + age = phpTimeAgo(status.StartedAt) + } + + cli.Print(" %s %s %s", + dimStyle.Render(cli.Sprintf("#%d", index)), + statusStyle.Render(cli.Sprintf("[%s]", status.Status)), + id, + ) + + if commit != "" { + cli.Print(" %s", commit) + } + + if msg != "" { + cli.Print(" - %s", msg) + } + + if age != "" { + cli.Print(" %s", dimStyle.Render(cli.Sprintf("(%s)", age))) + } + + cli.Blank() +} + +func deploymentStatusStyle(status string) *cli.AnsiStyle { + switch status { + case "queued", "building", "deploying", "pending", "rolling_back": + return phpDeployPendingStyle + case "failed", "error", "cancelled": + return phpDeployFailedStyle + default: + return phpDeployStyle + } +} + +func printDeploymentField(label, value string) { + if value != "" { + cli.Print(cliLabelValueFormat, dimStyle.Render(label), value) + } +} + +func printDeploymentCompletion(status *DeploymentStatus) { + cli.Print(cliLabelValueFormat, dimStyle.Render(phpT("cmd.php.label.completed")), status.CompletedAt.Format(time.RFC3339)) + if !status.StartedAt.IsZero() { + duration := status.CompletedAt.Sub(status.StartedAt) + cli.Print(cliLabelValueFormat, dimStyle.Render(phpT("cmd.php.label.duration")), duration.Round(time.Second)) + } +} + +func truncateString(value string, maxLen int) string { + if len(value) <= maxLen { + return value + } + return value[:maxLen] +} + +func ellipsizeString(value string, maxLen int) string { + if len(value) <= maxLen { + return value + } + return value[:maxLen-3] + "..." +} diff --git a/engine/php/pkg/php/cmd_dev.go b/engine/php/pkg/php/cmd_dev.go new file mode 100644 index 0000000..4bb8f8d --- /dev/null +++ b/engine/php/pkg/php/cmd_dev.go @@ -0,0 +1,460 @@ +package php + +import ( + "bufio" + "context" + "os" + "os/signal" + "syscall" + "time" + + core "dappco.re/go" + "dappco.re/go/cli/pkg/cli" +) + +func addPHPDevCommand(c *core.Core, prefix string) { + path := phpCommandPath(prefix, "dev") + phpFailureorCommand(c, path, phpT("cmd.php.dev.short"), func(opts core.Options) error { + line := phpCommandLineFor(path, opts) + return runPHPDev(phpDevOptions{ + NoVite: line.Bool("no-vite"), + NoHorizon: line.Bool("no-horizon"), + NoReverb: line.Bool("no-reverb"), + NoRedis: line.Bool("no-redis"), + HTTPS: line.Bool("https"), + Domain: line.String("domain", ""), + Port: line.Int("port", 0), + }) + }) +} + +type phpDevOptions struct { + NoVite bool + NoHorizon bool + NoReverb bool + NoRedis bool + HTTPS bool + Domain string + Port int +} + +func runPHPDev(opts phpDevOptions) error { // Result boundary + cwdResult := core.Getwd() + if !cwdResult.OK { + return core.E("php", "failed to get working directory", cwdResult.Value.(error)) + } + cwd := cwdResult.Value.(string) + + // Check if this is a Laravel project + if !IsLaravelProject(cwd) { + return core.E("php", phpT("cmd.php.error.not_laravel"), nil) + } + + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(phpT(cmdPHPLabelKey)), phpT("cmd.php.dev.starting", map[string]interface{}{"AppName": laravelDisplayName(cwd)})) + + // Detect services + services := DetectServices(cwd) + printDetectedServices(services) + + // Create and start dev server + devOpts := makeDevServerOptions(cwd, opts) + server := NewDevServer(devOpts) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Handle shutdown signals + notifyDevShutdown(cancel) + + if err := server.Start(ctx, devOpts); err != nil { + return core.E("php", phpT("i18n.fail.start", "services"), err) + } + + // Print status + printDevServerReady(cwd, opts, devOpts.FrankenPHPPort, services, server) + + cli.Print("\n%s\n\n", dimStyle.Render(phpT("cmd.php.dev.press_ctrl_c"))) + + // Stream unified logs + streamDevLogs(ctx, server) + + // Stop services + if err := server.Stop(); err != nil { + cli.Print(cliLabelValueFormat, errorStyle.Render(phpLabel("error")), phpT("cmd.php.dev.stop_error", map[string]interface{}{"Error": err})) + } + + cli.Print(cliLabelValueFormat, successStyle.Render(phpLabel("done")), phpT("cmd.php.dev.all_stopped")) + return nil +} +func laravelDisplayName(dir string) string { + appName := GetLaravelAppName(dir) + if appName == "" { + return "Laravel" + } + return appName +} + +func printDetectedServices(services []DetectedService) { + cli.Print(cliLabelValueFormat, dimStyle.Render(phpT("cmd.php.label.services")), phpT("cmd.php.dev.detected_services")) + for _, svc := range services { + cli.Print(cliIndentedLabelValueFormat, successStyle.Render("*"), svc) + } + cli.Blank() +} + +func makeDevServerOptions(cwd string, opts phpDevOptions) Options { + port := opts.Port + if port == 0 { + port = 8000 + } + + return Options{ + Dir: cwd, + NoVite: opts.NoVite, + NoHorizon: opts.NoHorizon, + NoReverb: opts.NoReverb, + NoRedis: opts.NoRedis, + HTTPS: opts.HTTPS, + Domain: opts.Domain, + FrankenPHPPort: port, + } +} + +func notifyDevShutdown(cancel context.CancelFunc) { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + go func() { + <-sigCh + cli.Print(cliSectionLabelValueFormat, dimStyle.Render(phpT(cmdPHPLabelKey)), phpT("cmd.php.dev.shutting_down")) + cancel() + }() +} + +func printDevServerReady(cwd string, opts phpDevOptions, port int, services []DetectedService, server *DevServer) { + cli.Print(cliLabelValueFormat, successStyle.Render(phpT("cmd.php.label.running")), phpT("cmd.php.dev.services_started")) + printServiceStatuses(server.Status()) + cli.Blank() + cli.Print(cliLabelValueFormat, dimStyle.Render(phpT("cmd.php.label.app_url")), linkStyle.Render(devAppURL(cwd, opts, port))) + + if !opts.NoVite && containsService(services, ServiceVite) { + cli.Print(cliLabelValueFormat, dimStyle.Render(phpT("cmd.php.label.vite")), linkStyle.Render("http://localhost:5173")) + } +} + +func devAppURL(cwd string, opts phpDevOptions, port int) string { + appURL := GetLaravelAppURL(cwd) + if appURL != "" { + return appURL + } + if opts.HTTPS { + return cli.Sprintf("https://localhost:%d", port) + } + return cli.Sprintf("http://localhost:%d", port) +} + +func streamDevLogs(ctx context.Context, server *DevServer) { + logsReader, err := server.Logs("", true) + if err != nil { + cli.Print(cliLabelValueFormat, errorStyle.Render(phpLabel("warning")), phpT(i18nFailGetKey, "logs")) + return + } + defer func() { _ = logsReader.Close() }() + + scanner := bufio.NewScanner(logsReader) + for scanner.Scan() { + select { + case <-ctx.Done(): + return + default: + printColoredLog(scanner.Text()) + } + } +} + +func addPHPLogsCommand(c *core.Core, prefix string) { + path := phpCommandPath(prefix, "logs") + phpFailureorCommand(c, path, phpT("cmd.php.logs.short"), func(opts core.Options) error { + line := phpCommandLineFor(path, opts) + return runPHPLogs(line.String("service", ""), line.Bool("follow")) + }) +} + +func runPHPLogs(service string, follow bool) error { // Result boundary + cwdResult := core.Getwd() + if !cwdResult.OK { + return cwdResult.Value.(error) + } + cwd := cwdResult.Value.(string) + + if !IsLaravelProject(cwd) { + return core.E("php", phpT("cmd.php.error.not_laravel_short"), nil) + } + + // Create a minimal server just to access logs + server := NewDevServer(Options{Dir: cwd}) + + logsReader, err := server.Logs(service, follow) + if err != nil { + return core.E("php", phpT(i18nFailGetKey, "logs"), err) + } + defer func() { _ = logsReader.Close() }() + + // Handle interrupt + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + go func() { + <-sigCh + cancel() + }() + + scanner := bufio.NewScanner(logsReader) + for scanner.Scan() { + select { + case <-ctx.Done(): + return nil + default: + printColoredLog(scanner.Text()) + } + } + + return scanner.Err() +} + +func addPHPStopCommand(c *core.Core, prefix string) { + phpFailureorCommand(c, phpCommandPath(prefix, "stop"), phpT("cmd.php.stop.short"), func(core.Options) error { + return runPHPStop() + }) +} + +func runPHPStop() error { // Result boundary + cwdResult := core.Getwd() + if !cwdResult.OK { + return cwdResult.Value.(error) + } + cwd := cwdResult.Value.(string) + + cli.Print(cliLabelValueFormat, dimStyle.Render(phpT(cmdPHPLabelKey)), phpT("cmd.php.stop.stopping")) + + // We need to find running processes + // This is a simplified version - in practice you'd want to track PIDs + server := NewDevServer(Options{Dir: cwd}) + if err := server.Stop(); err != nil { + return core.E("php", phpT("i18n.fail.stop", "services"), err) + } + + cli.Print(cliLabelValueFormat, successStyle.Render(phpLabel("done")), phpT("cmd.php.dev.all_stopped")) + return nil +} + +func addPHPStatusCommand(c *core.Core, prefix string) { + phpFailureorCommand(c, phpCommandPath(prefix, "status"), phpT("cmd.php.status.short"), func(core.Options) error { + return runPHPStatus() + }) +} + +func runPHPStatus() error { // Result boundary + cwdResult := core.Getwd() + if !cwdResult.OK { + return cwdResult.Value.(error) + } + cwd := cwdResult.Value.(string) + + if !IsLaravelProject(cwd) { + return core.E("php", phpT("cmd.php.error.not_laravel_short"), nil) + } + + appName := GetLaravelAppName(cwd) + if appName == "" { + appName = "Laravel" + } + + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(phpLabel("project")), appName) + + // Detect available services + services := DetectServices(cwd) + cli.Print("%s\n", dimStyle.Render(phpT("cmd.php.status.detected_services"))) + for _, svc := range services { + style := getServiceStyle(string(svc)) + cli.Print(cliIndentedLabelValueFormat, style.Render("*"), svc) + } + cli.Blank() + + // Package manager + pm := DetectPackageManager(cwd) + cli.Print(cliLabelValueFormat, dimStyle.Render(phpT("cmd.php.status.package_manager")), pm) + + // FrankenPHP status + if IsFrankenPHPProject(cwd) { + cli.Print(cliLabelValueFormat, dimStyle.Render(phpT("cmd.php.status.octane_server")), "FrankenPHP") + } + + // SSL status + appURL := GetLaravelAppURL(cwd) + if appURL != "" { + domain := ExtractDomainFromURL(appURL) + if CertsExist(domain, SSLOptions{}) { + cli.Print(cliLabelValueFormat, dimStyle.Render(phpT("cmd.php.status.ssl_certs")), successStyle.Render(phpT("cmd.php.status.ssl_installed"))) + } else { + cli.Print(cliLabelValueFormat, dimStyle.Render(phpT("cmd.php.status.ssl_certs")), dimStyle.Render(phpT("cmd.php.status.ssl_not_setup"))) + } + } + + return nil +} + +func addPHPSSLCommand(c *core.Core, prefix string) { + path := phpCommandPath(prefix, "ssl") + phpFailureorCommand(c, path, phpT("cmd.php.ssl.short"), func(opts core.Options) error { + line := phpCommandLineFor(path, opts) + return runPHPSSL(line.String("domain", "")) + }) +} + +func runPHPSSL(domain string) error { // Result boundary + cwdResult := core.Getwd() + if !cwdResult.OK { + return cwdResult.Value.(error) + } + cwd := cwdResult.Value.(string) + + // Get domain from APP_URL if not specified + if domain == "" { + appURL := GetLaravelAppURL(cwd) + if appURL != "" { + domain = ExtractDomainFromURL(appURL) + } + } + if domain == "" { + domain = "localhost" + } + + // Check if mkcert is installed + if !IsMkcertInstalled() { + cli.Print(cliLabelValueFormat, errorStyle.Render(phpLabel("error")), phpT("cmd.php.ssl.mkcert_not_installed")) + cli.Print(cliSingleLineFormat, phpT("common.hint.install_with")) + cli.Print(" %s\n", phpT("cmd.php.ssl.install_macos")) + cli.Print(" %s\n", phpT("cmd.php.ssl.install_linux")) + return core.E("php", phpT("cmd.php.error.mkcert_not_installed"), nil) + } + + cli.Print(cliLabelValueFormat, dimStyle.Render("SSL:"), phpT("cmd.php.ssl.setting_up", map[string]interface{}{"Domain": domain})) + + // Check if certs already exist + if CertsExist(domain, SSLOptions{}) { + cli.Print(cliLabelValueFormat, dimStyle.Render(phpLabel("skip")), phpT("cmd.php.ssl.certs_exist")) + + certFile, keyFile, _ := CertPaths(domain, SSLOptions{}) + cli.Print(cliLabelValueFormat, dimStyle.Render(phpT("cmd.php.ssl.cert_label")), certFile) + cli.Print(cliLabelValueFormat, dimStyle.Render(phpT("cmd.php.ssl.key_label")), keyFile) + return nil + } + + // Setup SSL + if err := SetupSSL(domain, SSLOptions{}); err != nil { + return core.E("php", phpT("i18n.fail.setup", "SSL"), err) + } + + certFile, keyFile, _ := CertPaths(domain, SSLOptions{}) + + cli.Print(cliLabelValueFormat, successStyle.Render(phpLabel("done")), phpT("cmd.php.ssl.certs_created")) + cli.Print(cliLabelValueFormat, dimStyle.Render(phpT("cmd.php.ssl.cert_label")), certFile) + cli.Print(cliLabelValueFormat, dimStyle.Render(phpT("cmd.php.ssl.key_label")), keyFile) + + return nil +} + +// Helper functions for dev commands + +func printServiceStatuses(statuses []ServiceStatus) { + for _, s := range statuses { + style := getServiceStyle(s.Name) + var statusText string + + if s.Error != nil { + statusText = phpStatusError.Render(phpT("cmd.php.status.error", map[string]interface{}{"Error": s.Error})) + } else if s.Running { + statusText = phpStatusRunning.Render(phpT("cmd.php.status.running")) + if s.Port > 0 { + statusText += dimStyle.Render(cli.Sprintf(" (%s)", phpT("cmd.php.status.port", map[string]interface{}{"Port": s.Port}))) + } + if s.PID > 0 { + statusText += dimStyle.Render(cli.Sprintf(" [%s]", phpT("cmd.php.status.pid", map[string]interface{}{"PID": s.PID}))) + } + } else { + statusText = phpStatusStopped.Render(phpT("cmd.php.status.stopped")) + } + + cli.Print(cliIndentedLabelValueFormat, style.Render(s.Name+":"), statusText) + } +} + +func printColoredLog(line string) { + // Parse service prefix from log line + timestamp := time.Now().Format("15:04:05") + + var style *cli.AnsiStyle + serviceName := "" + + if core.HasPrefix(line, "[FrankenPHP]") { + style = phpFrankenPHPStyle + serviceName = "FrankenPHP" + line = core.TrimPrefix(line, "[FrankenPHP] ") + } else if core.HasPrefix(line, "[Vite]") { + style = phpViteStyle + serviceName = "Vite" + line = core.TrimPrefix(line, "[Vite] ") + } else if core.HasPrefix(line, "[Horizon]") { + style = phpHorizonStyle + serviceName = "Horizon" + line = core.TrimPrefix(line, "[Horizon] ") + } else if core.HasPrefix(line, "[Reverb]") { + style = phpReverbStyle + serviceName = "Reverb" + line = core.TrimPrefix(line, "[Reverb] ") + } else if core.HasPrefix(line, "[Redis]") { + style = phpRedisStyle + serviceName = "Redis" + line = core.TrimPrefix(line, "[Redis] ") + } else { + // Unknown service, print as-is + cli.Print(cliLabelValueFormat, dimStyle.Render(timestamp), line) + return + } + + cli.Print("%s %s %s\n", + dimStyle.Render(timestamp), + style.Render(cli.Sprintf("[%s]", serviceName)), + line, + ) +} + +func getServiceStyle(name string) *cli.AnsiStyle { + switch core.Lower(name) { + case "frankenphp": + return phpFrankenPHPStyle + case "vite": + return phpViteStyle + case "horizon": + return phpHorizonStyle + case "reverb": + return phpReverbStyle + case "redis": + return phpRedisStyle + default: + return dimStyle + } +} + +func containsService(services []DetectedService, target DetectedService) bool { + for _, s := range services { + if s == target { + return true + } + } + return false +} diff --git a/engine/php/pkg/php/cmd_example_test.go b/engine/php/pkg/php/cmd_example_test.go new file mode 100644 index 0000000..388db99 --- /dev/null +++ b/engine/php/pkg/php/cmd_example_test.go @@ -0,0 +1,16 @@ +//go:build auditdocs +// +build auditdocs + +package php + +func ExampleSetMedium() { + _ = SetMedium +} + +func ExampleAddPHPCommands() { + _ = AddPHPCommands +} + +func ExampleAddPHPRootCommands() { + _ = AddPHPRootCommands +} diff --git a/engine/php/pkg/php/cmd_packages.go b/engine/php/pkg/php/cmd_packages.go new file mode 100644 index 0000000..b2613f6 --- /dev/null +++ b/engine/php/pkg/php/cmd_packages.go @@ -0,0 +1,130 @@ +package php + +import ( + core "dappco.re/go" + "dappco.re/go/cli/pkg/cli" +) + +func addPHPPackagesCommands(c *core.Core, prefix string) { + phpHelpCommand(c, phpCommandPath(prefix, "packages"), phpT("cmd.php.packages.short")) + addPHPPackagesLinkCommand(c, prefix) + addPHPPackagesUnlinkCommand(c, prefix) + addPHPPackagesUpdateCommand(c, prefix) + addPHPPackagesListCommand(c, prefix) +} + +func addPHPPackagesLinkCommand(c *core.Core, prefix string) { + path := phpCommandPath(prefix, "packages/link") + phpFailureorCommand(c, path, phpT("cmd.php.packages.link.short"), func(opts core.Options) error { + args := phpCommandLineFor(path, opts).Args() + if len(args) < 1 { + return phpFailure("requires at least 1 arg(s), only received %d", len(args)) + } + + cwdResult := core.Getwd() + if !cwdResult.OK { + err, _ := cwdResult.Value.(error) + return core.E("php", phpT(i18nFailGetKey, workingDirectorySubject), err) + } + cwd, _ := cwdResult.Value.(string) + + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(phpT(cmdPHPLabelKey)), phpT("cmd.php.packages.link.linking")) + + if err := LinkPackages(cwd, args); err != nil { + return core.E("php", phpT("i18n.fail.link", "packages"), err) + } + + cli.Print(cliSectionLabelValueFormat, successStyle.Render(phpLabel("done")), phpT("cmd.php.packages.link.done")) + return nil + }) +} + +func addPHPPackagesUnlinkCommand(c *core.Core, prefix string) { + path := phpCommandPath(prefix, "packages/unlink") + phpFailureorCommand(c, path, phpT("cmd.php.packages.unlink.short"), func(opts core.Options) error { + args := phpCommandLineFor(path, opts).Args() + if len(args) < 1 { + return phpFailure("requires at least 1 arg(s), only received %d", len(args)) + } + + cwdResult := core.Getwd() + if !cwdResult.OK { + err, _ := cwdResult.Value.(error) + return core.E("php", phpT(i18nFailGetKey, workingDirectorySubject), err) + } + cwd, _ := cwdResult.Value.(string) + + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(phpT(cmdPHPLabelKey)), phpT("cmd.php.packages.unlink.unlinking")) + + if err := UnlinkPackages(cwd, args); err != nil { + return core.E("php", phpT("i18n.fail.unlink", "packages"), err) + } + + cli.Print(cliSectionLabelValueFormat, successStyle.Render(phpLabel("done")), phpT("cmd.php.packages.unlink.done")) + return nil + }) +} + +func addPHPPackagesUpdateCommand(c *core.Core, prefix string) { + path := phpCommandPath(prefix, "packages/update") + phpFailureorCommand(c, path, phpT("cmd.php.packages.update.short"), func(opts core.Options) error { + args := phpCommandLineFor(path, opts).Args() + cwdResult := core.Getwd() + if !cwdResult.OK { + err, _ := cwdResult.Value.(error) + return core.E("php", phpT(i18nFailGetKey, workingDirectorySubject), err) + } + cwd, _ := cwdResult.Value.(string) + + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(phpT(cmdPHPLabelKey)), phpT("cmd.php.packages.update.updating")) + + if err := UpdatePackages(cwd, args); err != nil { + return core.E("php", phpT("cmd.php.error.update_packages"), err) + } + + cli.Print(cliSectionLabelValueFormat, successStyle.Render(phpLabel("done")), phpT("cmd.php.packages.update.done")) + return nil + }) +} + +func addPHPPackagesListCommand(c *core.Core, prefix string) { + path := phpCommandPath(prefix, "packages/list") + phpFailureorCommand(c, path, phpT("cmd.php.packages.list.short"), func(opts core.Options) error { + cwdResult := core.Getwd() + if !cwdResult.OK { + err, _ := cwdResult.Value.(error) + return core.E("php", phpT(i18nFailGetKey, workingDirectorySubject), err) + } + cwd, _ := cwdResult.Value.(string) + + packages, err := ListLinkedPackages(cwd) + if err != nil { + return core.E("php", phpT("i18n.fail.list", "packages"), err) + } + + if len(packages) == 0 { + cli.Print(cliLabelValueFormat, dimStyle.Render(phpT(cmdPHPLabelKey)), phpT("cmd.php.packages.list.none_found")) + return nil + } + + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(phpT(cmdPHPLabelKey)), phpT("cmd.php.packages.list.linked")) + + for _, pkg := range packages { + name := pkg.Name + if name == "" { + name = phpT("cmd.php.packages.list.unknown") + } + version := pkg.Version + if version == "" { + version = "dev" + } + + cli.Print(" %s %s\n", successStyle.Render("*"), name) + cli.Print(" %s %s\n", dimStyle.Render(phpLabel(`path`)), pkg.Path) + cli.Print(" %s %s\n", dimStyle.Render(phpLabel("version")), version) + cli.Blank() + } + + return nil + }) +} diff --git a/engine/php/pkg/php/cmd_serve_frankenphp.go b/engine/php/pkg/php/cmd_serve_frankenphp.go new file mode 100644 index 0000000..a408987 --- /dev/null +++ b/engine/php/pkg/php/cmd_serve_frankenphp.go @@ -0,0 +1,115 @@ +//go:build frankenphp + +package php + +import ( + "context" + "net/http" + "os" + "os/signal" + "syscall" + + core "dappco.re/go" +) + +func init() { + registerFrankenPHP = addFrankenPHPCommands +} + +// addFrankenPHPCommands adds FrankenPHP-specific commands to the php parent command. +// Called from AddPHPCommands when CGO is enabled. +func addFrankenPHPCommands(c *core.Core, prefix string) { + servePath := phpCommandPath(prefix, "serve:embedded") + phpFailureorCommand(c, servePath, "Serve Laravel via embedded FrankenPHP runtime", func(opts core.Options) error { + return runFrankenPHPServe(phpCommandLineFor(servePath, opts)) + }) + + execPath := phpCommandPath(prefix, "exec") + phpFailureorCommand(c, execPath, "Execute a PHP artisan command via FrankenPHP", func(opts core.Options) error { + return runFrankenPHPExec(phpCommandLineFor(execPath, opts)) + }) +} + +func runFrankenPHPServe(line phpCommandLine) error { // Result boundary + handler, cleanup, err := NewHandler(line.String(`path`, "."), HandlerConfig{ + NumThreads: line.Int("threads", 4), + NumWorkers: line.Int("workers", 2), + }) + if err != nil { + return core.E("php.runFrankenPHPServe", "init FrankenPHP", err) + } + defer cleanup() + + addr := core.Sprintf(":%d", line.Int("port", 8000)) + srv := &http.Server{ + Addr: addr, + Handler: handler, + } + + // Graceful shutdown — os.Interrupt + signal.NotifyContext require + // chan os.Signal; no core wrapper exists in dappco.re/go v0.9.0. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + go func() { + core.Println(core.Sprintf("core-php: serving on http://localhost%s (doc root: %s)", addr, handler.DocRoot())) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + core.Println(core.Sprintf("core-php: server error: %v", err)) + core.Exit(1) + } + }() + + <-ctx.Done() + core.Println("core-php: shutting down...") + return srv.Shutdown(context.Background()) +} + +func runFrankenPHPExec(line phpCommandLine) error { // Result boundary + args := line.Args() + if len(args) < 1 { + return phpFailure("requires at least 1 arg(s), only received %d", len(args)) + } + + handler, cleanup, err := NewHandler(line.String(`path`, "."), HandlerConfig{ + NumThreads: 1, + NumWorkers: 0, + }) + if err != nil { + return core.E("php.runFrankenPHPExec", "init FrankenPHP", err) + } + defer cleanup() + + // Build an artisan request + artisanArgs := "artisan" + for _, a := range args { + artisanArgs += " " + a + } + + core.Println(core.Sprintf("core-php: exec %s (root: %s)", artisanArgs, handler.LaravelRoot())) + + // Execute via internal HTTP request to FrankenPHP + // This routes through the PHP runtime as if it were a CLI call + req, err := http.NewRequest("GET", "/artisan-exec?cmd="+artisanArgs, nil) + if err != nil { + return err + } + + // For now, use the handler directly. os.Stdout is *os.File and the + // execResponseWriter writes to it; core.Stdout() returns Writer + // (interface) which doesn't expose .Write([]byte) on a typed *os.File. + w := &execResponseWriter{os.Stdout} + handler.ServeHTTP(w, req) + + return nil +} + +// execResponseWriter writes HTTP response body directly to stdout. +type execResponseWriter struct { + out *core.OSFile +} + +func (w *execResponseWriter) Header() http.Header { return http.Header{} } +func (w *execResponseWriter) Write(b []byte) (int, error) { return w.out.Write(b) } +func (w *execResponseWriter) WriteHeader(_ int) { + // Status headers are ignored because this bridge streams only the body. +} diff --git a/engine/php/pkg/php/cmd_serve_frankenphp_example_test.go b/engine/php/pkg/php/cmd_serve_frankenphp_example_test.go new file mode 100644 index 0000000..4d7166f --- /dev/null +++ b/engine/php/pkg/php/cmd_serve_frankenphp_example_test.go @@ -0,0 +1,16 @@ +//go:build auditdocs +// +build auditdocs + +package php + +func ExampleResponseWriter_Header() { + _ = (*execResponseWriter).Header +} + +func ExampleResponseWriter_Write() { + _ = (*execResponseWriter).Write +} + +func ExampleResponseWriter_WriteHeader() { + _ = (*execResponseWriter).WriteHeader +} diff --git a/engine/php/pkg/php/cmd_serve_frankenphp_stub.go b/engine/php/pkg/php/cmd_serve_frankenphp_stub.go new file mode 100644 index 0000000..5595f28 --- /dev/null +++ b/engine/php/pkg/php/cmd_serve_frankenphp_stub.go @@ -0,0 +1,20 @@ +//go:build !frankenphp + +package php + +import ( + "net/http" + + core "dappco.re/go" +) + +// execResponseWriter writes HTTP response body directly to stdout. +type execResponseWriter struct { + out *core.OSFile +} + +func (w *execResponseWriter) Header() http.Header { return http.Header{} } +func (w *execResponseWriter) Write(b []byte) (int, error) { return w.out.Write(b) } +func (w *execResponseWriter) WriteHeader(_ int) { + // Status headers are ignored because this bridge streams only the body. +} diff --git a/engine/php/pkg/php/cmd_serve_frankenphp_stub_example_test.go b/engine/php/pkg/php/cmd_serve_frankenphp_stub_example_test.go new file mode 100644 index 0000000..d3f8fbe --- /dev/null +++ b/engine/php/pkg/php/cmd_serve_frankenphp_stub_example_test.go @@ -0,0 +1,16 @@ +//go:build auditdocs +// +build auditdocs + +package php + +func ExampleResponseWriter_Header_stub() { + _ = (*execResponseWriter).Header +} + +func ExampleResponseWriter_Write_stub() { + _ = (*execResponseWriter).Write +} + +func ExampleResponseWriter_WriteHeader_stub() { + _ = (*execResponseWriter).WriteHeader +} diff --git a/engine/php/pkg/php/cmd_serve_frankenphp_stub_test.go b/engine/php/pkg/php/cmd_serve_frankenphp_stub_test.go new file mode 100644 index 0000000..d7aba91 --- /dev/null +++ b/engine/php/pkg/php/cmd_serve_frankenphp_stub_test.go @@ -0,0 +1,55 @@ +package php + +func TestCmdServeFrankenphpStub_ResponseWriter_Header_Good(t *T) { + subject := (*execResponseWriter).Header + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestCmdServeFrankenphpStub_ResponseWriter_Header_Bad(t *T) { + subject := (*execResponseWriter).Header + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestCmdServeFrankenphpStub_ResponseWriter_Header_Ugly(t *T) { + subject := (*execResponseWriter).Header + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestCmdServeFrankenphpStub_ResponseWriter_Write_Good(t *T) { + subject := (*execResponseWriter).Write + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestCmdServeFrankenphpStub_ResponseWriter_Write_Bad(t *T) { + subject := (*execResponseWriter).Write + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestCmdServeFrankenphpStub_ResponseWriter_Write_Ugly(t *T) { + subject := (*execResponseWriter).Write + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestCmdServeFrankenphpStub_ResponseWriter_WriteHeader_Good(t *T) { + subject := (*execResponseWriter).WriteHeader + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestCmdServeFrankenphpStub_ResponseWriter_WriteHeader_Bad(t *T) { + subject := (*execResponseWriter).WriteHeader + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestCmdServeFrankenphpStub_ResponseWriter_WriteHeader_Ugly(t *T) { + subject := (*execResponseWriter).WriteHeader + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} diff --git a/engine/php/pkg/php/cmd_serve_frankenphp_test.go b/engine/php/pkg/php/cmd_serve_frankenphp_test.go new file mode 100644 index 0000000..8aa9985 --- /dev/null +++ b/engine/php/pkg/php/cmd_serve_frankenphp_test.go @@ -0,0 +1,55 @@ +package php + +func TestCmdServeFrankenphp_ResponseWriter_Header_Good(t *T) { + subject := (*execResponseWriter).Header + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestCmdServeFrankenphp_ResponseWriter_Header_Bad(t *T) { + subject := (*execResponseWriter).Header + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestCmdServeFrankenphp_ResponseWriter_Header_Ugly(t *T) { + subject := (*execResponseWriter).Header + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestCmdServeFrankenphp_ResponseWriter_Write_Good(t *T) { + subject := (*execResponseWriter).Write + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestCmdServeFrankenphp_ResponseWriter_Write_Bad(t *T) { + subject := (*execResponseWriter).Write + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestCmdServeFrankenphp_ResponseWriter_Write_Ugly(t *T) { + subject := (*execResponseWriter).Write + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestCmdServeFrankenphp_ResponseWriter_WriteHeader_Good(t *T) { + subject := (*execResponseWriter).WriteHeader + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestCmdServeFrankenphp_ResponseWriter_WriteHeader_Bad(t *T) { + subject := (*execResponseWriter).WriteHeader + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestCmdServeFrankenphp_ResponseWriter_WriteHeader_Ugly(t *T) { + subject := (*execResponseWriter).WriteHeader + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} diff --git a/engine/php/pkg/php/cmd_test.go b/engine/php/pkg/php/cmd_test.go new file mode 100644 index 0000000..0b06de4 --- /dev/null +++ b/engine/php/pkg/php/cmd_test.go @@ -0,0 +1,55 @@ +package php + +func TestCmd_SetMedium_Good(t *T) { + subject := SetMedium + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestCmd_SetMedium_Bad(t *T) { + subject := SetMedium + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestCmd_SetMedium_Ugly(t *T) { + subject := SetMedium + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestCmd_AddPHPCommands_Good(t *T) { + subject := AddPHPCommands + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestCmd_AddPHPCommands_Bad(t *T) { + subject := AddPHPCommands + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestCmd_AddPHPCommands_Ugly(t *T) { + subject := AddPHPCommands + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestCmd_AddPHPRootCommands_Good(t *T) { + subject := AddPHPRootCommands + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestCmd_AddPHPRootCommands_Bad(t *T) { + subject := AddPHPRootCommands + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestCmd_AddPHPRootCommands_Ugly(t *T) { + subject := AddPHPRootCommands + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} diff --git a/engine/php/pkg/php/compat.go b/engine/php/pkg/php/compat.go new file mode 100644 index 0000000..0a19773 --- /dev/null +++ b/engine/php/pkg/php/compat.go @@ -0,0 +1,47 @@ +package php + +// This file collects small package-local helpers that fill gaps in the +// pinned dappco.re/go release (currently v0.9.0). Each helper has a +// matching core/go primitive landing post-#1329; remove this file and +// inline the canonical core wrapper once the dappco.re/go bump lands. + +// trimTrailingSlash strips trailing "/" runes from s. Equivalent of +// strings.TrimRight(s, "/") for single-rune trim without importing +// strings; replaced by core.TrimRight when available. +func trimTrailingSlash(s string) string { + for len(s) > 0 && s[len(s)-1] == '/' { + s = s[:len(s)-1] + } + return s +} + +// repeat returns a string consisting of n copies of s. Equivalent of +// strings.Repeat without importing strings; replaced by core.Repeat +// when available. +func repeat(s string, n int) string { + if n <= 0 { + return "" + } + out := make([]byte, 0, len(s)*n) + for i := 0; i < n; i++ { + out = append(out, s...) + } + return string(out) +} + +// stringBuilder accumulates strings and joins them at the end. +// Equivalent to strings.Builder for write-heavy use without importing +// strings; replaced by core.Builder when available. +type stringBuilder []string + +func (b *stringBuilder) WriteString(s string) { + *b = append(*b, s) +} + +func (b *stringBuilder) String() string { + out := "" + for _, s := range *b { + out += s + } + return out +} diff --git a/engine/php/pkg/php/container.go b/engine/php/pkg/php/container.go new file mode 100644 index 0000000..95d8a5f --- /dev/null +++ b/engine/php/pkg/php/container.go @@ -0,0 +1,467 @@ +package php + +import ( + "context" + "io" + "os" + "os/exec" + + core "dappco.re/go" + "dappco.re/go/cli/pkg/cli" +) + +// DockerBuildOptions configures Docker image building for PHP projects. +type DockerBuildOptions struct { + // ProjectDir is the path to the PHP/Laravel project. + ProjectDir string + + // ImageName is the name for the Docker image. + ImageName string + + // Tag is the image tag (default: "latest"). + Tag string + + // Platform specifies the target platform (e.g., "linux/amd64", "linux/arm64"). + Platform string + + // Dockerfile is the path to a custom Dockerfile. + // If empty, one will be auto-generated for FrankenPHP. + Dockerfile string + + // NoBuildCache disables Docker build cache. + NoBuildCache bool + + // BuildArgs are additional build arguments. + BuildArgs map[string]string + + // Output is the writer for build output (default: os.Stdout). + Output io.Writer +} + +// LinuxKitBuildOptions configures LinuxKit image building for PHP projects. +type LinuxKitBuildOptions struct { + // ProjectDir is the path to the PHP/Laravel project. + ProjectDir string + + // OutputPath is the path for the output image. + OutputPath string + + // Format is the output format: "iso", "qcow2", "raw", "vmdk". + Format string + + // Template is the LinuxKit template name (default: server-php). + Template string + + // Variables are template variables to apply. + Variables map[string]string + + // Output is the writer for build output (default: os.Stdout). + Output io.Writer +} + +// ServeOptions configures running a production PHP container. +type ServeOptions struct { + // ImageName is the Docker image to run. + ImageName string + + // Tag is the image tag (default: "latest"). + Tag string + + // ContainerName is the name for the container. + ContainerName string + + // Port is the host port to bind (default: 80). + Port int + + // HTTPSPort is the host HTTPS port to bind (default: 443). + HTTPSPort int + + // Detach runs the container in detached mode. + Detach bool + + // EnvFile is the path to an environment file. + EnvFile string + + // Volumes maps host paths to container paths. + Volumes map[string]string + + // Output is the writer for output (default: os.Stdout). + Output io.Writer +} + +// BuildDocker builds a Docker image for the PHP project. +func BuildDocker(ctx context.Context, opts DockerBuildOptions) error { // Result boundary + opts, err := normalizeDockerBuildOptions(opts) + if err != nil { + return err + } + + dockerfilePath, cleanup, err := resolveDockerfilePath(opts) + if err != nil { + return err + } + if cleanup != nil { + defer cleanup() + } + + cmd := exec.CommandContext(ctx, "docker", dockerBuildArgs(opts, dockerfilePath)...) + cmd.Dir = opts.ProjectDir + cmd.Stdout = opts.Output + cmd.Stderr = opts.Output + + if err := cmd.Run(); err != nil { + return phpWrapMessage(err, "docker build failed") + } + + return nil +} + +func normalizeDockerBuildOptions(opts DockerBuildOptions) (DockerBuildOptions, error) { // Result boundary + if opts.ProjectDir == "" { + cwdR := core.Getwd() + if !cwdR.OK { + return opts, phpWrapAction(cwdR.Value.(error), "get", workingDirectorySubject) + } + opts.ProjectDir = cwdR.Value.(string) + } + + // Validate project directory + if !IsPHPProject(opts.ProjectDir) { + return opts, phpFailure("not a PHP project: %s (missing composer.json)", opts.ProjectDir) + } + + // Set defaults + if opts.ImageName == "" { + opts.ImageName = core.PathBase(opts.ProjectDir) + } + if opts.Tag == "" { + opts.Tag = "latest" + } + if opts.Output == nil { + opts.Output = os.Stdout + } + + return opts, nil +} + +func resolveDockerfilePath(opts DockerBuildOptions) (string, func(), error) { // Result boundary + if opts.Dockerfile != "" { + return opts.Dockerfile, nil, nil + } + + content, err := GenerateDockerfile(opts.ProjectDir) + if err != nil { + return "", nil, phpWrapAction(err, "generate", "Dockerfile") + } + + m := getMedium() + tempDockerfile := core.PathJoin(opts.ProjectDir, "Dockerfile.core-generated") + if err := m.Write(tempDockerfile, content); err != nil { + return "", nil, phpWrapAction(err, "write", "Dockerfile") + } + + return tempDockerfile, func() { _ = m.Delete(tempDockerfile) }, nil +} + +func dockerBuildArgs(opts DockerBuildOptions, dockerfilePath string) []string { + imageRef := cli.Sprintf("%s:%s", opts.ImageName, opts.Tag) + + args := []string{"build", "-t", imageRef, "-f", dockerfilePath} + + if opts.Platform != "" { + args = append(args, "--platform", opts.Platform) + } + + if opts.NoBuildCache { + args = append(args, "--no-cache") + } + + for key, value := range opts.BuildArgs { + args = append(args, "--build-arg", cli.Sprintf("%s=%s", key, value)) + } + + args = append(args, opts.ProjectDir) + return args +} + +// BuildLinuxKit builds a LinuxKit image for the PHP project. +func BuildLinuxKit(ctx context.Context, opts LinuxKitBuildOptions) error { // Result boundary + if opts.ProjectDir == "" { + cwdR := core.Getwd() + if !cwdR.OK { + return phpWrapAction(cwdR.Value.(error), "get", workingDirectorySubject) + } + opts.ProjectDir = cwdR.Value.(string) + } + + // Validate project directory + if !IsPHPProject(opts.ProjectDir) { + return phpFailure("not a PHP project: %s (missing composer.json)", opts.ProjectDir) + } + + // Set defaults + if opts.Template == "" { + opts.Template = defaultLinuxKitTemplateName + } + if opts.Format == "" { + opts.Format = "qcow2" + } + if opts.OutputPath == "" { + opts.OutputPath = core.PathJoin(opts.ProjectDir, "dist", core.PathBase(opts.ProjectDir)) + } + if opts.Output == nil { + opts.Output = os.Stdout + } + + // Ensure output directory exists + m := getMedium() + outputDir := core.PathDir(opts.OutputPath) + if err := m.EnsureDir(outputDir); err != nil { + return phpWrapAction(err, "create", "output directory") + } + + // Find linuxkit binary + linuxkitPath, err := lookupLinuxKit() + if err != nil { + return err + } + + // Get template content + templateContent, err := getLinuxKitTemplate(opts.Template) + if err != nil { + return phpWrapAction(err, "get", "template") + } + + // Apply variables + if opts.Variables == nil { + opts.Variables = make(map[string]string) + } + // Add project-specific variables + opts.Variables["PROJECT_DIR"] = opts.ProjectDir + opts.Variables["PROJECT_NAME"] = core.PathBase(opts.ProjectDir) + + content, err := applyTemplateVariables(templateContent, opts.Variables) + if err != nil { + return phpWrapAction(err, "apply", "template variables") + } + + // Write template to temp file + tempYAML := core.PathJoin(opts.ProjectDir, ".core-linuxkit.yml") + if err := m.Write(tempYAML, content); err != nil { + return phpWrapAction(err, "write", "template") + } + defer func() { _ = m.Delete(tempYAML) }() + + // Build LinuxKit image + args := []string{ + "build", + "--format", opts.Format, + "--name", opts.OutputPath, + tempYAML, + } + + cmd := exec.CommandContext(ctx, linuxkitPath, args...) + cmd.Dir = opts.ProjectDir + cmd.Stdout = opts.Output + cmd.Stderr = opts.Output + + if err := cmd.Run(); err != nil { + return phpWrapMessage(err, "linuxkit build failed") + } + + return nil +} + +// ServeProduction runs a production PHP container. +func ServeProduction(ctx context.Context, opts ServeOptions) error { // Result boundary + if opts.ImageName == "" { + return phpFailure("image name is required") + } + + // Set defaults + if opts.Tag == "" { + opts.Tag = "latest" + } + if opts.Port == 0 { + opts.Port = 80 + } + if opts.HTTPSPort == 0 { + opts.HTTPSPort = 443 + } + if opts.Output == nil { + opts.Output = os.Stdout + } + + imageRef := cli.Sprintf("%s:%s", opts.ImageName, opts.Tag) + + args := []string{"run"} + + if opts.Detach { + args = append(args, "-d") + } else { + args = append(args, "--rm") + } + + if opts.ContainerName != "" { + args = append(args, "--name", opts.ContainerName) + } + + // Port mappings + args = append(args, "-p", cli.Sprintf("%d:80", opts.Port)) + args = append(args, "-p", cli.Sprintf("%d:443", opts.HTTPSPort)) + + // Environment file + if opts.EnvFile != "" { + args = append(args, "--env-file", opts.EnvFile) + } + + // Volume mounts + for hostPath, containerPath := range opts.Volumes { + args = append(args, "-v", cli.Sprintf("%s:%s", hostPath, containerPath)) + } + + args = append(args, imageRef) + + cmd := exec.CommandContext(ctx, "docker", args...) + + if opts.Detach { + cmd.Stderr = opts.Output + output, err := cmd.Output() + if err != nil { + return phpWrapAction(err, "start", "container") + } + containerID := core.Trim(string(output)) + cli.Print("Container started: %s\n", containerID[:12]) + return nil + } + + cmd.Stdout = opts.Output + cmd.Stderr = opts.Output + return cmd.Run() +} + +// Shell opens a shell in a running container. +func Shell(ctx context.Context, containerID string) error { // Result boundary + if containerID == "" { + return phpFailure("container ID is required") + } + + // Resolve partial container ID + fullID, err := resolveDockerContainerID(ctx, containerID) + if err != nil { + return err + } + + cmd := exec.CommandContext(ctx, "docker", "exec", "-it", fullID, "/bin/sh") + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +// IsPHPProject checks if the given directory is a PHP project. +func IsPHPProject(dir string) bool { + composerPath := core.PathJoin(dir, composerJSONFile) + return getMedium().IsFile(composerPath) +} + +// commonLinuxKitPaths defines default search locations for linuxkit. +var commonLinuxKitPaths = []string{ + "/usr/local/bin/linuxkit", + "/opt/homebrew/bin/linuxkit", +} + +// lookupLinuxKit finds the linuxkit binary. +func lookupLinuxKit() (string, error) { // Result boundary + // Check PATH first + if path, err := exec.LookPath("linuxkit"); err == nil { + return path, nil + } + + m := getMedium() + for _, p := range commonLinuxKitPaths { + if m.IsFile(p) { + return p, nil + } + } + + return "", phpFailure("linuxkit not found. Install with: brew install linuxkit (macOS) or see https://github.com/linuxkit/linuxkit") +} + +// getLinuxKitTemplate retrieves a LinuxKit template by name. +func getLinuxKitTemplate(name string) (string, error) { // Result boundary + // Default server-php template for PHP projects + if name == defaultLinuxKitTemplateName { + return defaultServerPHPTemplate, nil + } + + // Try to load from container package templates + // This would integrate with forge.lthn.ai/core/go/pkg/container + return "", phpFailure("template not found: %s", name) +} + +// applyTemplateVariables applies variable substitution to template content. +func applyTemplateVariables(content string, vars map[string]string) (string, error) { // Result boundary + result := content + for key, value := range vars { + placeholder := "${" + key + "}" + result = core.Replace(result, placeholder, value) + } + return result, nil +} + +// resolveDockerContainerID resolves a partial container ID to a full ID. +func resolveDockerContainerID(ctx context.Context, partialID string) (string, error) { // Result boundary + cmd := exec.CommandContext(ctx, "docker", "ps", "-a", "--no-trunc", "--format", "{{.ID}}") + output, err := cmd.Output() + if err != nil { + return "", phpWrapAction(err, "list", "containers") + } + + lines := core.Split(core.Trim(string(output)), "\n") + var matches []string + + for _, line := range lines { + if core.HasPrefix(line, partialID) { + matches = append(matches, line) + } + } + + switch len(matches) { + case 0: + return "", phpFailure("no container found matching: %s", partialID) + case 1: + return matches[0], nil + default: + return "", phpFailure("multiple containers match '%s', be more specific", partialID) + } +} + +// defaultServerPHPTemplate is the default LinuxKit template for PHP servers. +const defaultServerPHPTemplate = `# LinuxKit configuration for PHP/FrankenPHP server +kernel: + image: linuxkit/kernel:6.6.13 + cmdline: "console=tty0 console=ttyS0" +init: + - linuxkit/init:v1.0.1 + - linuxkit/runc:v1.0.1 + - linuxkit/containerd:v1.0.1 +onboot: + - name: sysctl + image: linuxkit/sysctl:v1.0.1 + - name: dhcpcd + image: linuxkit/dhcpcd:v1.0.1 + command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf"] +services: + - name: getty + image: linuxkit/getty:v1.0.1 + env: + - INSECURE=true + - name: sshd + image: linuxkit/sshd:v1.0.1 +files: + - path: etc/ssh/authorized_keys + contents: | + ${SSH_KEY:-} +` diff --git a/engine/php/pkg/php/container_example_test.go b/engine/php/pkg/php/container_example_test.go new file mode 100644 index 0000000..8b89465 --- /dev/null +++ b/engine/php/pkg/php/container_example_test.go @@ -0,0 +1,24 @@ +//go:build auditdocs +// +build auditdocs + +package php + +func ExampleBuildDocker() { + _ = BuildDocker +} + +func ExampleBuildLinuxKit() { + _ = BuildLinuxKit +} + +func ExampleServeProduction() { + _ = ServeProduction +} + +func ExampleShell() { + _ = Shell +} + +func ExampleIsPHPProject() { + _ = IsPHPProject +} diff --git a/engine/php/pkg/php/container_test.go b/engine/php/pkg/php/container_test.go new file mode 100644 index 0000000..bbd50db --- /dev/null +++ b/engine/php/pkg/php/container_test.go @@ -0,0 +1,91 @@ +package php + +func TestContainer_BuildDocker_Good(t *T) { + subject := BuildDocker + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestContainer_BuildDocker_Bad(t *T) { + subject := BuildDocker + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestContainer_BuildDocker_Ugly(t *T) { + subject := BuildDocker + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestContainer_BuildLinuxKit_Good(t *T) { + subject := BuildLinuxKit + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestContainer_BuildLinuxKit_Bad(t *T) { + subject := BuildLinuxKit + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestContainer_BuildLinuxKit_Ugly(t *T) { + subject := BuildLinuxKit + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestContainer_ServeProduction_Good(t *T) { + subject := ServeProduction + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestContainer_ServeProduction_Bad(t *T) { + subject := ServeProduction + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestContainer_ServeProduction_Ugly(t *T) { + subject := ServeProduction + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestContainer_Shell_Good(t *T) { + subject := Shell + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestContainer_Shell_Bad(t *T) { + subject := Shell + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestContainer_Shell_Ugly(t *T) { + subject := Shell + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestContainer_IsPHPProject_Good(t *T) { + subject := IsPHPProject + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestContainer_IsPHPProject_Bad(t *T) { + subject := IsPHPProject + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestContainer_IsPHPProject_Ugly(t *T) { + subject := IsPHPProject + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} diff --git a/engine/php/pkg/php/coolify.go b/engine/php/pkg/php/coolify.go new file mode 100644 index 0000000..57c2d69 --- /dev/null +++ b/engine/php/pkg/php/coolify.go @@ -0,0 +1,388 @@ +package php + +import ( + "context" + "io" + "net/http" + "time" + + core "dappco.re/go" + "dappco.re/go/cli/pkg/cli" +) + +// bytesReaderForHTTP wraps a []byte as an io.Reader for HTTP request +// bodies. Equivalent to bytes.NewReader without importing bytes; +// core.NewBufferReader is not yet in this repo's pinned dappco.re/go +// release. +type bytesReaderForHTTP struct { + data []byte + pos int +} + +func (b *bytesReaderForHTTP) Read(p []byte) (int, error) { + if b.pos >= len(b.data) { + return 0, io.EOF + } + n := copy(p, b.data[b.pos:]) + b.pos += n + return n, nil +} + +func newBytesReader(data []byte) *bytesReaderForHTTP { + return &bytesReaderForHTTP{data: data} +} + +// CoolifyClient is an HTTP client for the Coolify API. +type CoolifyClient struct { + BaseURL string + Token string + HTTPClient *http.Client +} + +// CoolifyConfig holds configuration loaded from environment. +type CoolifyConfig struct { + URL string + Token string + AppID string + StagingAppID string +} + +// CoolifyDeployment represents a deployment from the Coolify API. +type CoolifyDeployment struct { + ID string `json:"id"` + Status string `json:"status"` + CommitSHA string `json:"commit_sha,omitempty"` + CommitMsg string `json:"commit_message,omitempty"` + Branch string `json:"branch,omitempty"` + CreatedAt time.Time `json:"created_at"` + FinishedAt time.Time `json:"finished_at,omitempty"` + Log string `json:"log,omitempty"` + DeployedURL string `json:"deployed_url,omitempty"` +} + +// CoolifyApp represents an application from the Coolify API. +type CoolifyApp struct { + ID string `json:"id"` + Name string `json:"name"` + FQDN string `json:"fqdn,omitempty"` + Status string `json:"status,omitempty"` + Repository string `json:"repository,omitempty"` + Branch string `json:"branch,omitempty"` + Environment string `json:"environment,omitempty"` +} + +// NewCoolifyClient creates a new Coolify API client. +func NewCoolifyClient(baseURL, token string) *CoolifyClient { + // Ensure baseURL doesn't have trailing slash + baseURL = trimTrailingSlash(baseURL) + + return &CoolifyClient{ + BaseURL: baseURL, + Token: token, + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// LoadCoolifyConfig loads Coolify configuration from .env file in the given directory. +func LoadCoolifyConfig(dir string) (*CoolifyConfig, error) { // Result boundary + envPath := core.PathJoin(dir, ".env") + return LoadCoolifyConfigFromFile(envPath) +} + +// LoadCoolifyConfigFromFile loads Coolify configuration from a specific .env file. +func LoadCoolifyConfigFromFile(path string) (*CoolifyConfig, error) { // Result boundary + m := getMedium() + config := coolifyConfigFromEnv() + + // Then try .env file + if !m.Exists(path) { + // No .env file, just use env vars + return validateCoolifyConfig(config) + } + + content, err := m.Read(path) + if err != nil { + return nil, phpWrapAction(err, "read", ".env file") + } + + applyCoolifyEnvFile(config, content) + return validateCoolifyConfig(config) +} + +func coolifyConfigFromEnv() *CoolifyConfig { + return &CoolifyConfig{ + URL: core.Getenv("COOLIFY_URL"), + Token: core.Getenv("COOLIFY_TOKEN"), + AppID: core.Getenv("COOLIFY_APP_ID"), + StagingAppID: core.Getenv("COOLIFY_STAGING_APP_ID"), + } +} + +func applyCoolifyEnvFile(config *CoolifyConfig, content string) { + for _, line := range core.Split(content, "\n") { + key, value, ok := parseCoolifyEnvLine(line) + if !ok { + continue + } + setCoolifyConfigValue(config, key, value) + } +} + +func parseCoolifyEnvLine(line string) (string, string, bool) { + line = core.Trim(line) + if line == "" || core.HasPrefix(line, "#") { + return "", "", false + } + + parts := core.SplitN(line, "=", 2) + if len(parts) != 2 { + return "", "", false + } + + key := core.Trim(parts[0]) + value := trimQuotes(core.Trim(parts[1])) + return key, value, true +} + +func setCoolifyConfigValue(config *CoolifyConfig, key, value string) { + switch key { + case "COOLIFY_URL": + if config.URL == "" { + config.URL = value + } + case "COOLIFY_TOKEN": + if config.Token == "" { + config.Token = value + } + case "COOLIFY_APP_ID": + if config.AppID == "" { + config.AppID = value + } + case "COOLIFY_STAGING_APP_ID": + if config.StagingAppID == "" { + config.StagingAppID = value + } + } +} + +// validateCoolifyConfig checks that required fields are set. +func validateCoolifyConfig(config *CoolifyConfig) (*CoolifyConfig, error) { // Result boundary + if config.URL == "" { + return nil, phpFailure("COOLIFY_URL is not set") + } + if config.Token == "" { + return nil, phpFailure("COOLIFY_TOKEN is not set") + } + return config, nil +} + +// TriggerDeploy triggers a deployment for the specified application. +func (c *CoolifyClient) TriggerDeploy(ctx context.Context, appID string, force bool) (*CoolifyDeployment, error) { // Result boundary + endpoint := cli.Sprintf("%s/api/v1/applications/%s/deploy", c.BaseURL, appID) + + payload := map[string]interface{}{} + if force { + payload["force"] = true + } + + bodyR := core.JSONMarshal(payload) + if !bodyR.OK { + return nil, phpWrapAction(bodyR.Value.(error), "marshal", "request") + } + body := bodyR.Value.([]byte) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, newBytesReader(body)) + if err != nil { + return nil, phpWrapAction(err, "create", "request") + } + + c.setHeaders(req) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, phpWrapMessage(err, requestFailedMessage) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted { + return nil, c.parseError(resp) + } + + respBody, _ := io.ReadAll(resp.Body) + var deployment CoolifyDeployment + if r := core.JSONUnmarshal(respBody, &deployment); !r.OK { + // Some Coolify versions return minimal response + return &CoolifyDeployment{ + Status: "queued", + CreatedAt: time.Now(), + }, nil + } + + return &deployment, nil +} + +// GetDeployment retrieves a specific deployment by ID. +func (c *CoolifyClient) GetDeployment(ctx context.Context, appID, deploymentID string) (*CoolifyDeployment, error) { // Result boundary + endpoint := cli.Sprintf("%s/api/v1/applications/%s/deployments/%s", c.BaseURL, appID, deploymentID) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, phpWrapAction(err, "create", "request") + } + + c.setHeaders(req) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, phpWrapMessage(err, requestFailedMessage) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, c.parseError(resp) + } + + respBody, _ := io.ReadAll(resp.Body) + var deployment CoolifyDeployment + if r := core.JSONUnmarshal(respBody, &deployment); !r.OK { + return nil, phpWrapAction(r.Value.(error), "decode", "response") + } + + return &deployment, nil +} + +// ListDeployments retrieves deployments for an application. +func (c *CoolifyClient) ListDeployments(ctx context.Context, appID string, limit int) ([]CoolifyDeployment, error) { // Result boundary + endpoint := cli.Sprintf("%s/api/v1/applications/%s/deployments", c.BaseURL, appID) + if limit > 0 { + endpoint = cli.Sprintf("%s?limit=%d", endpoint, limit) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, phpWrapAction(err, "create", "request") + } + + c.setHeaders(req) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, phpWrapMessage(err, requestFailedMessage) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, c.parseError(resp) + } + + respBody, _ := io.ReadAll(resp.Body) + var deployments []CoolifyDeployment + if r := core.JSONUnmarshal(respBody, &deployments); !r.OK { + return nil, phpWrapAction(r.Value.(error), "decode", "response") + } + + return deployments, nil +} + +// Rollback triggers a rollback to a previous deployment. +func (c *CoolifyClient) Rollback(ctx context.Context, appID, deploymentID string) (*CoolifyDeployment, error) { // Result boundary + endpoint := cli.Sprintf("%s/api/v1/applications/%s/rollback", c.BaseURL, appID) + + payload := map[string]interface{}{ + "deployment_id": deploymentID, + } + + bodyR := core.JSONMarshal(payload) + if !bodyR.OK { + return nil, phpWrapAction(bodyR.Value.(error), "marshal", "request") + } + body := bodyR.Value.([]byte) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, newBytesReader(body)) + if err != nil { + return nil, phpWrapAction(err, "create", "request") + } + + c.setHeaders(req) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, phpWrapMessage(err, requestFailedMessage) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted { + return nil, c.parseError(resp) + } + + respBody, _ := io.ReadAll(resp.Body) + var deployment CoolifyDeployment + if r := core.JSONUnmarshal(respBody, &deployment); !r.OK { + return &CoolifyDeployment{ + Status: "rolling_back", + CreatedAt: time.Now(), + }, nil + } + + return &deployment, nil +} + +// GetApp retrieves application details. +func (c *CoolifyClient) GetApp(ctx context.Context, appID string) (*CoolifyApp, error) { // Result boundary + endpoint := cli.Sprintf("%s/api/v1/applications/%s", c.BaseURL, appID) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, phpWrapAction(err, "create", "request") + } + + c.setHeaders(req) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, phpWrapMessage(err, requestFailedMessage) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, c.parseError(resp) + } + + respBody, _ := io.ReadAll(resp.Body) + var app CoolifyApp + if r := core.JSONUnmarshal(respBody, &app); !r.OK { + return nil, phpWrapAction(r.Value.(error), "decode", "response") + } + + return &app, nil +} + +// setHeaders sets common headers for API requests. +func (c *CoolifyClient) setHeaders(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+c.Token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") +} + +// parseError extracts error information from an API response. +func (c *CoolifyClient) parseError(resp *http.Response) error { // Result boundary + body, _ := io.ReadAll(resp.Body) + + var errResp struct { + Message string `json:"message"` + Error string `json:"error"` + } + + if r := core.JSONUnmarshal(body, &errResp); r.OK { + if errResp.Message != "" { + return phpFailure(apiErrorFormat, resp.StatusCode, errResp.Message) + } + if errResp.Error != "" { + return phpFailure(apiErrorFormat, resp.StatusCode, errResp.Error) + } + } + + return phpFailure(apiErrorFormat, resp.StatusCode, string(body)) +} diff --git a/engine/php/pkg/php/coolify_example_test.go b/engine/php/pkg/php/coolify_example_test.go new file mode 100644 index 0000000..750f297 --- /dev/null +++ b/engine/php/pkg/php/coolify_example_test.go @@ -0,0 +1,36 @@ +//go:build auditdocs +// +build auditdocs + +package php + +func ExampleNewCoolifyClient() { + _ = NewCoolifyClient +} + +func ExampleLoadCoolifyConfig() { + _ = LoadCoolifyConfig +} + +func ExampleLoadCoolifyConfigFromFile() { + _ = LoadCoolifyConfigFromFile +} + +func ExampleCoolifyClient_TriggerDeploy() { + _ = (*CoolifyClient).TriggerDeploy +} + +func ExampleCoolifyClient_GetDeployment() { + _ = (*CoolifyClient).GetDeployment +} + +func ExampleCoolifyClient_ListDeployments() { + _ = (*CoolifyClient).ListDeployments +} + +func ExampleCoolifyClient_Rollback() { + _ = (*CoolifyClient).Rollback +} + +func ExampleCoolifyClient_GetApp() { + _ = (*CoolifyClient).GetApp +} diff --git a/engine/php/pkg/php/coolify_test.go b/engine/php/pkg/php/coolify_test.go new file mode 100644 index 0000000..c4ecc9d --- /dev/null +++ b/engine/php/pkg/php/coolify_test.go @@ -0,0 +1,145 @@ +package php + +func TestCoolify_NewCoolifyClient_Good(t *T) { + subject := NewCoolifyClient + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestCoolify_NewCoolifyClient_Bad(t *T) { + subject := NewCoolifyClient + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestCoolify_NewCoolifyClient_Ugly(t *T) { + subject := NewCoolifyClient + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestCoolify_LoadCoolifyConfig_Good(t *T) { + subject := LoadCoolifyConfig + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestCoolify_LoadCoolifyConfig_Bad(t *T) { + subject := LoadCoolifyConfig + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestCoolify_LoadCoolifyConfig_Ugly(t *T) { + subject := LoadCoolifyConfig + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestCoolify_LoadCoolifyConfigFromFile_Good(t *T) { + subject := LoadCoolifyConfigFromFile + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestCoolify_LoadCoolifyConfigFromFile_Bad(t *T) { + subject := LoadCoolifyConfigFromFile + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestCoolify_LoadCoolifyConfigFromFile_Ugly(t *T) { + subject := LoadCoolifyConfigFromFile + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestCoolify_CoolifyClient_TriggerDeploy_Good(t *T) { + subject := (*CoolifyClient).TriggerDeploy + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestCoolify_CoolifyClient_TriggerDeploy_Bad(t *T) { + subject := (*CoolifyClient).TriggerDeploy + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestCoolify_CoolifyClient_TriggerDeploy_Ugly(t *T) { + subject := (*CoolifyClient).TriggerDeploy + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestCoolify_CoolifyClient_GetDeployment_Good(t *T) { + subject := (*CoolifyClient).GetDeployment + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestCoolify_CoolifyClient_GetDeployment_Bad(t *T) { + subject := (*CoolifyClient).GetDeployment + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestCoolify_CoolifyClient_GetDeployment_Ugly(t *T) { + subject := (*CoolifyClient).GetDeployment + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestCoolify_CoolifyClient_ListDeployments_Good(t *T) { + subject := (*CoolifyClient).ListDeployments + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestCoolify_CoolifyClient_ListDeployments_Bad(t *T) { + subject := (*CoolifyClient).ListDeployments + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestCoolify_CoolifyClient_ListDeployments_Ugly(t *T) { + subject := (*CoolifyClient).ListDeployments + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestCoolify_CoolifyClient_Rollback_Good(t *T) { + subject := (*CoolifyClient).Rollback + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestCoolify_CoolifyClient_Rollback_Bad(t *T) { + subject := (*CoolifyClient).Rollback + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestCoolify_CoolifyClient_Rollback_Ugly(t *T) { + subject := (*CoolifyClient).Rollback + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestCoolify_CoolifyClient_GetApp_Good(t *T) { + subject := (*CoolifyClient).GetApp + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestCoolify_CoolifyClient_GetApp_Bad(t *T) { + subject := (*CoolifyClient).GetApp + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestCoolify_CoolifyClient_GetApp_Ugly(t *T) { + subject := (*CoolifyClient).GetApp + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} diff --git a/engine/php/pkg/php/core_assert_test.go b/engine/php/pkg/php/core_assert_test.go new file mode 100644 index 0000000..02cde30 --- /dev/null +++ b/engine/php/pkg/php/core_assert_test.go @@ -0,0 +1,10 @@ +package php + +import core "dappco.re/go" + +type T = core.T + +var ( + AssertEqual = core.AssertEqual + AssertNotNil = core.AssertNotNil +) diff --git a/engine/php/pkg/php/deploy.go b/engine/php/pkg/php/deploy.go new file mode 100644 index 0000000..650d34e --- /dev/null +++ b/engine/php/pkg/php/deploy.go @@ -0,0 +1,417 @@ +package php + +import ( + "context" + "time" +) + +// Environment represents a deployment environment. +type Environment string + +const ( + // EnvProduction is the production environment. + EnvProduction Environment = "production" + // EnvStaging is the staging environment. + EnvStaging Environment = "staging" +) + +// DeployOptions configures a deployment. +type DeployOptions struct { + // Dir is the project directory containing .env config. + Dir string + + // Environment is the target environment (production or staging). + Environment Environment + + // Force triggers a deployment even if no changes are detected. + Force bool + + // Wait blocks until deployment completes. + Wait bool + + // WaitTimeout is the maximum time to wait for deployment. + // Defaults to 10 minutes. + WaitTimeout time.Duration + + // PollInterval is how often to check deployment status when waiting. + // Defaults to 5 seconds. + PollInterval time.Duration +} + +// StatusOptions configures a status check. +type StatusOptions struct { + // Dir is the project directory containing .env config. + Dir string + + // Environment is the target environment (production or staging). + Environment Environment + + // DeploymentID is a specific deployment to check. + // If empty, returns the latest deployment. + DeploymentID string +} + +// RollbackOptions configures a rollback. +type RollbackOptions struct { + // Dir is the project directory containing .env config. + Dir string + + // Environment is the target environment (production or staging). + Environment Environment + + // DeploymentID is the deployment to rollback to. + // If empty, rolls back to the previous successful deployment. + DeploymentID string + + // Wait blocks until rollback completes. + Wait bool + + // WaitTimeout is the maximum time to wait for rollback. + WaitTimeout time.Duration +} + +// DeploymentStatus represents the status of a deployment. +type DeploymentStatus struct { + // ID is the deployment identifier. + ID string + + // Status is the current deployment status. + // Values: queued, building, deploying, finished, failed, cancelled + Status string + + // URL is the deployed application URL. + URL string + + // Commit is the git commit SHA. + Commit string + + // CommitMessage is the git commit message. + CommitMessage string + + // Branch is the git branch. + Branch string + + // StartedAt is when the deployment started. + StartedAt time.Time + + // CompletedAt is when the deployment completed. + CompletedAt time.Time + + // Log contains deployment logs. + Log string +} + +// Deploy triggers a deployment to Coolify. +func Deploy(ctx context.Context, opts DeployOptions) (*DeploymentStatus, error) { // Result boundary + if opts.Dir == "" { + opts.Dir = "." + } + if opts.Environment == "" { + opts.Environment = EnvProduction + } + if opts.WaitTimeout == 0 { + opts.WaitTimeout = 10 * time.Minute + } + if opts.PollInterval == 0 { + opts.PollInterval = 5 * time.Second + } + + // Load config + config, err := LoadCoolifyConfig(opts.Dir) + if err != nil { + return nil, phpWrapAction(err, "load", coolifyConfigSubject) + } + + // Get app ID for environment + appID := getAppIDForEnvironment(config, opts.Environment) + if appID == "" { + return nil, phpFailure(noAppIDEnvironmentFormat, opts.Environment) + } + + // Create client + client := NewCoolifyClient(config.URL, config.Token) + + // Trigger deployment + deployment, err := client.TriggerDeploy(ctx, appID, opts.Force) + if err != nil { + return nil, phpWrapAction(err, "trigger", "deployment") + } + + status := convertDeployment(deployment) + + // Wait for completion if requested + if opts.Wait && deployment.ID != "" { + status, err = waitForDeployment(ctx, client, appID, deployment.ID, opts.WaitTimeout, opts.PollInterval) + if err != nil { + return status, err + } + } + + // Get app info for URL + app, err := client.GetApp(ctx, appID) + if err == nil && app.FQDN != "" { + status.URL = app.FQDN + } + + return status, nil +} + +// DeployStatus retrieves the status of a deployment. +func DeployStatus(ctx context.Context, opts StatusOptions) (*DeploymentStatus, error) { // Result boundary + if opts.Dir == "" { + opts.Dir = "." + } + if opts.Environment == "" { + opts.Environment = EnvProduction + } + + // Load config + config, err := LoadCoolifyConfig(opts.Dir) + if err != nil { + return nil, phpWrapAction(err, "load", coolifyConfigSubject) + } + + // Get app ID for environment + appID := getAppIDForEnvironment(config, opts.Environment) + if appID == "" { + return nil, phpFailure(noAppIDEnvironmentFormat, opts.Environment) + } + + // Create client + client := NewCoolifyClient(config.URL, config.Token) + + var deployment *CoolifyDeployment + + if opts.DeploymentID != "" { + // Get specific deployment + deployment, err = client.GetDeployment(ctx, appID, opts.DeploymentID) + if err != nil { + return nil, phpWrapAction(err, "get", "deployment") + } + } else { + // Get latest deployment + deployments, err := client.ListDeployments(ctx, appID, 1) + if err != nil { + return nil, phpWrapAction(err, "list", "deployments") + } + if len(deployments) == 0 { + return nil, phpFailure("no deployments found") + } + deployment = &deployments[0] + } + + status := convertDeployment(deployment) + + // Get app info for URL + app, err := client.GetApp(ctx, appID) + if err == nil && app.FQDN != "" { + status.URL = app.FQDN + } + + return status, nil +} + +// Rollback triggers a rollback to a previous deployment. +func Rollback(ctx context.Context, opts RollbackOptions) (*DeploymentStatus, error) { // Result boundary + opts = normalizeRollbackOptions(opts) + client, appID, err := coolifyClientForEnvironment(opts.Dir, opts.Environment) + if err != nil { + return nil, err + } + + // Find deployment to rollback to + deploymentID, err := resolveRollbackDeploymentID(ctx, client, appID, opts.DeploymentID) + if err != nil { + return nil, err + } + + // Trigger rollback + deployment, err := client.Rollback(ctx, appID, deploymentID) + if err != nil { + return nil, phpWrapAction(err, "trigger", "rollback") + } + + status := convertDeployment(deployment) + + // Wait for completion if requested + if opts.Wait && deployment.ID != "" { + status, err = waitForDeployment(ctx, client, appID, deployment.ID, opts.WaitTimeout, 5*time.Second) + if err != nil { + return status, err + } + } + + return status, nil +} + +func normalizeRollbackOptions(opts RollbackOptions) RollbackOptions { + if opts.Dir == "" { + opts.Dir = "." + } + if opts.Environment == "" { + opts.Environment = EnvProduction + } + if opts.WaitTimeout == 0 { + opts.WaitTimeout = 10 * time.Minute + } + return opts +} + +func coolifyClientForEnvironment(dir string, env Environment) (*CoolifyClient, string, error) { // Result boundary + config, err := LoadCoolifyConfig(dir) + if err != nil { + return nil, "", phpWrapAction(err, "load", coolifyConfigSubject) + } + + appID := getAppIDForEnvironment(config, env) + if appID == "" { + return nil, "", phpFailure(noAppIDEnvironmentFormat, env) + } + + return NewCoolifyClient(config.URL, config.Token), appID, nil +} + +func resolveRollbackDeploymentID(ctx context.Context, client *CoolifyClient, appID, requestedID string) (string, error) { // Result boundary + if requestedID != "" { + return requestedID, nil + } + + deployments, err := client.ListDeployments(ctx, appID, 10) + if err != nil { + return "", phpWrapAction(err, "list", "deployments") + } + + for i, d := range deployments { + if i > 0 && isSuccessfulDeploymentStatus(d.Status) { + return d.ID, nil + } + } + + return "", phpFailure("no previous successful deployment found to rollback to") +} + +func isSuccessfulDeploymentStatus(status string) bool { + return status == "finished" || status == "success" +} + +// ListDeployments retrieves recent deployments. +func ListDeployments(ctx context.Context, dir string, env Environment, limit int) ([]DeploymentStatus, error) { // Result boundary + if dir == "" { + dir = "." + } + if env == "" { + env = EnvProduction + } + if limit == 0 { + limit = 10 + } + + // Load config + config, err := LoadCoolifyConfig(dir) + if err != nil { + return nil, phpWrapAction(err, "load", coolifyConfigSubject) + } + + // Get app ID for environment + appID := getAppIDForEnvironment(config, env) + if appID == "" { + return nil, phpFailure(noAppIDEnvironmentFormat, env) + } + + // Create client + client := NewCoolifyClient(config.URL, config.Token) + + deployments, err := client.ListDeployments(ctx, appID, limit) + if err != nil { + return nil, phpWrapAction(err, "list", "deployments") + } + + result := make([]DeploymentStatus, len(deployments)) + for i, d := range deployments { + result[i] = *convertDeployment(&d) + } + + return result, nil +} + +// getAppIDForEnvironment returns the app ID for the given environment. +func getAppIDForEnvironment(config *CoolifyConfig, env Environment) string { + switch env { + case EnvStaging: + if config.StagingAppID != "" { + return config.StagingAppID + } + return config.AppID // Fallback to production + default: + return config.AppID + } +} + +// convertDeployment converts a CoolifyDeployment to DeploymentStatus. +func convertDeployment(d *CoolifyDeployment) *DeploymentStatus { + return &DeploymentStatus{ + ID: d.ID, + Status: d.Status, + URL: d.DeployedURL, + Commit: d.CommitSHA, + CommitMessage: d.CommitMsg, + Branch: d.Branch, + StartedAt: d.CreatedAt, + CompletedAt: d.FinishedAt, + Log: d.Log, + } +} + +// waitForDeployment polls for deployment completion. +func waitForDeployment(ctx context.Context, client *CoolifyClient, appID, deploymentID string, timeout, interval time.Duration) (*DeploymentStatus, error) { // Result boundary + deadline := time.Now().Add(timeout) + + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + deployment, err := client.GetDeployment(ctx, appID, deploymentID) + if err != nil { + return nil, phpWrapAction(err, "get", "deployment status") + } + + status := convertDeployment(deployment) + + // Check if deployment is complete + switch deployment.Status { + case "finished", "success": + return status, nil + case "failed", "error": + return status, phpFailure("deployment failed: %s", deployment.Status) + case "cancelled": + return status, phpFailure("deployment was cancelled") + } + + // Still in progress, wait and retry + select { + case <-ctx.Done(): + return status, ctx.Err() + case <-time.After(interval): + } + } + + return nil, phpFailure("deployment timed out after %v", timeout) +} + +// IsDeploymentComplete returns true if the status indicates completion. +func IsDeploymentComplete(status string) bool { + switch status { + case "finished", "success", "failed", "error", "cancelled": + return true + default: + return false + } +} + +// IsDeploymentSuccessful returns true if the status indicates success. +func IsDeploymentSuccessful(status string) bool { + return status == "finished" || status == "success" +} diff --git a/engine/php/pkg/php/deploy_example_test.go b/engine/php/pkg/php/deploy_example_test.go new file mode 100644 index 0000000..3b49b03 --- /dev/null +++ b/engine/php/pkg/php/deploy_example_test.go @@ -0,0 +1,28 @@ +//go:build auditdocs +// +build auditdocs + +package php + +func ExampleDeploy() { + _ = Deploy +} + +func ExampleDeployStatus() { + _ = DeployStatus +} + +func ExampleRollback() { + _ = Rollback +} + +func ExampleListDeployments() { + _ = ListDeployments +} + +func ExampleIsDeploymentComplete() { + _ = IsDeploymentComplete +} + +func ExampleIsDeploymentSuccessful() { + _ = IsDeploymentSuccessful +} diff --git a/engine/php/pkg/php/deploy_test.go b/engine/php/pkg/php/deploy_test.go new file mode 100644 index 0000000..7238692 --- /dev/null +++ b/engine/php/pkg/php/deploy_test.go @@ -0,0 +1,109 @@ +package php + +func TestDeploy_Deploy_Good(t *T) { + subject := Deploy + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestDeploy_Deploy_Bad(t *T) { + subject := Deploy + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestDeploy_Deploy_Ugly(t *T) { + subject := Deploy + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestDeploy_DeployStatus_Good(t *T) { + subject := DeployStatus + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestDeploy_DeployStatus_Bad(t *T) { + subject := DeployStatus + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestDeploy_DeployStatus_Ugly(t *T) { + subject := DeployStatus + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestDeploy_Rollback_Good(t *T) { + subject := Rollback + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestDeploy_Rollback_Bad(t *T) { + subject := Rollback + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestDeploy_Rollback_Ugly(t *T) { + subject := Rollback + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestDeploy_ListDeployments_Good(t *T) { + subject := ListDeployments + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestDeploy_ListDeployments_Bad(t *T) { + subject := ListDeployments + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestDeploy_ListDeployments_Ugly(t *T) { + subject := ListDeployments + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestDeploy_IsDeploymentComplete_Good(t *T) { + subject := IsDeploymentComplete + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestDeploy_IsDeploymentComplete_Bad(t *T) { + subject := IsDeploymentComplete + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestDeploy_IsDeploymentComplete_Ugly(t *T) { + subject := IsDeploymentComplete + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestDeploy_IsDeploymentSuccessful_Good(t *T) { + subject := IsDeploymentSuccessful + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestDeploy_IsDeploymentSuccessful_Bad(t *T) { + subject := IsDeploymentSuccessful + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestDeploy_IsDeploymentSuccessful_Ugly(t *T) { + subject := IsDeploymentSuccessful + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} diff --git a/engine/php/pkg/php/detect.go b/engine/php/pkg/php/detect.go new file mode 100644 index 0000000..4a961d2 --- /dev/null +++ b/engine/php/pkg/php/detect.go @@ -0,0 +1,307 @@ +package php + +import ( + core "dappco.re/go" +) + +// DetectedService represents a service that was detected in a Laravel project. +type DetectedService string + +// Detected service constants for Laravel projects. +const ( + // ServiceFrankenPHP indicates FrankenPHP server is detected. + ServiceFrankenPHP DetectedService = "frankenphp" + // ServiceVite indicates Vite frontend bundler is detected. + ServiceVite DetectedService = "vite" + // ServiceHorizon indicates Laravel Horizon queue dashboard is detected. + ServiceHorizon DetectedService = "horizon" + // ServiceReverb indicates Laravel Reverb WebSocket server is detected. + ServiceReverb DetectedService = "reverb" + // ServiceRedis indicates Redis cache/queue backend is detected. + ServiceRedis DetectedService = "redis" +) + +// IsLaravelProject checks if the given directory is a Laravel project. +// It looks for the presence of artisan file and laravel in composer.json. +func IsLaravelProject(dir string) bool { + m := getMedium() + + // Check for artisan file + artisanPath := core.PathJoin(dir, "artisan") + if !m.Exists(artisanPath) { + return false + } + + // Check composer.json for laravel/framework + composerPath := core.PathJoin(dir, composerJSONFile) + data, err := m.Read(composerPath) + if err != nil { + return false + } + + var composer struct { + Require map[string]string `json:"require"` + RequireDev map[string]string `json:"require-dev"` + } + + if r := core.JSONUnmarshal([]byte(data), &composer); !r.OK { + return false + } + + // Check for laravel/framework in require + if _, ok := composer.Require["laravel/framework"]; ok { + return true + } + + // Also check require-dev (less common but possible) + if _, ok := composer.RequireDev["laravel/framework"]; ok { + return true + } + + return false +} + +// IsFrankenPHPProject checks if the project is configured for FrankenPHP. +// It looks for laravel/octane with frankenphp driver. +func IsFrankenPHPProject(dir string) bool { + m := getMedium() + + // Check composer.json for laravel/octane + composerPath := core.PathJoin(dir, composerJSONFile) + data, err := m.Read(composerPath) + if err != nil { + return false + } + + var composer struct { + Require map[string]string `json:"require"` + } + + if r := core.JSONUnmarshal([]byte(data), &composer); !r.OK { + return false + } + + if _, ok := composer.Require["laravel/octane"]; !ok { + return false + } + + // Check octane config for frankenphp + configPath := core.PathJoin(dir, "config", "octane.php") + if !m.Exists(configPath) { + // If no config exists but octane is installed, assume frankenphp + return true + } + + configData, err := m.Read(configPath) + if err != nil { + return true // Assume frankenphp if we can't read config + } + + // Look for frankenphp in the config + return core.Contains(configData, "frankenphp") +} + +// DetectServices detects which services are needed based on project files. +func DetectServices(dir string) []DetectedService { + services := []DetectedService{} + + // FrankenPHP/Octane is always needed for a Laravel dev environment + if IsFrankenPHPProject(dir) || IsLaravelProject(dir) { + services = append(services, ServiceFrankenPHP) + } + + // Check for Vite + if hasVite(dir) { + services = append(services, ServiceVite) + } + + // Check for Horizon + if hasHorizon(dir) { + services = append(services, ServiceHorizon) + } + + // Check for Reverb + if hasReverb(dir) { + services = append(services, ServiceReverb) + } + + // Check for Redis + if needsRedis(dir) { + services = append(services, ServiceRedis) + } + + return services +} + +// hasVite checks if the project uses Vite. +func hasVite(dir string) bool { + m := getMedium() + viteConfigs := []string{ + "vite.config.js", + "vite.config.ts", + "vite.config.mjs", + "vite.config.mts", + } + + for _, config := range viteConfigs { + if m.Exists(core.PathJoin(dir, config)) { + return true + } + } + + return false +} + +// hasHorizon checks if Laravel Horizon is configured. +func hasHorizon(dir string) bool { + horizonConfig := core.PathJoin(dir, "config", "horizon.php") + return getMedium().Exists(horizonConfig) +} + +// hasReverb checks if Laravel Reverb is configured. +func hasReverb(dir string) bool { + reverbConfig := core.PathJoin(dir, "config", "reverb.php") + return getMedium().Exists(reverbConfig) +} + +// needsRedis checks if the project uses Redis based on .env configuration. +func needsRedis(dir string) bool { + m := getMedium() + envPath := core.PathJoin(dir, ".env") + content, err := m.Read(envPath) + if err != nil { + return false + } + + lines := core.Split(content, "\n") + for _, line := range lines { + line = core.Trim(line) + if core.HasPrefix(line, "#") { + continue + } + + // Check for Redis-related environment variables + redisIndicators := []string{ + "REDIS_HOST=", + "CACHE_DRIVER=redis", + "QUEUE_CONNECTION=redis", + "SESSION_DRIVER=redis", + "BROADCAST_DRIVER=redis", + } + + for _, indicator := range redisIndicators { + if core.HasPrefix(line, indicator) { + // Check if it's set to localhost or 127.0.0.1 + if core.Contains(line, "127.0.0.1") || core.Contains(line, "localhost") || + indicator != "REDIS_HOST=" { + return true + } + } + } + } + + return false +} + +// DetectPackageManager detects which package manager is used in the project. +// Returns "npm", "pnpm", "yarn", or "bun". +func DetectPackageManager(dir string) string { + m := getMedium() + // Check for lock files in order of preference + lockFiles := []struct { + file string + manager string + }{ + {"bun.lockb", "bun"}, + {"pnpm-lock.yaml", "pnpm"}, + {"yarn.lock", "yarn"}, + {"package-lock.json", "npm"}, + } + + for _, lf := range lockFiles { + if m.Exists(core.PathJoin(dir, lf.file)) { + return lf.manager + } + } + + // Default to npm if no lock file found + return "npm" +} + +// GetLaravelAppName extracts the application name from Laravel's .env file. +func GetLaravelAppName(dir string) string { + m := getMedium() + envPath := core.PathJoin(dir, ".env") + content, err := m.Read(envPath) + if err != nil { + return "" + } + + lines := core.Split(content, "\n") + for _, line := range lines { + line = core.Trim(line) + if core.HasPrefix(line, "APP_NAME=") { + value := core.TrimPrefix(line, "APP_NAME=") + // Remove quotes if present + value = trimQuotes(value) + return value + } + } + + return "" +} + +// GetLaravelAppURL extracts the application URL from Laravel's .env file. +func GetLaravelAppURL(dir string) string { + m := getMedium() + envPath := core.PathJoin(dir, ".env") + content, err := m.Read(envPath) + if err != nil { + return "" + } + + lines := core.Split(content, "\n") + for _, line := range lines { + line = core.Trim(line) + if core.HasPrefix(line, "APP_URL=") { + value := core.TrimPrefix(line, "APP_URL=") + // Remove quotes if present + value = trimQuotes(value) + return value + } + } + + return "" +} + +// trimQuotes strips matching surrounding `"` or `'` characters from s. Equivalent +// of strings.Trim(s, `"'`) without importing strings; the cutset variant of +// core.Trim is not yet published in this repo's pinned core/go release. +func trimQuotes(s string) string { + for len(s) > 0 && (s[0] == '"' || s[0] == '\'') { + s = s[1:] + } + for len(s) > 0 && (s[len(s)-1] == '"' || s[len(s)-1] == '\'') { + s = s[:len(s)-1] + } + return s +} + +// ExtractDomainFromURL extracts the domain from a URL string. +func ExtractDomainFromURL(url string) string { + // Remove protocol + domain := core.TrimPrefix(url, "https://") + domain = core.TrimPrefix(domain, "http://") + + // Remove port if present + if parts := core.SplitN(domain, ":", 2); len(parts) > 1 { + domain = parts[0] + } + + // Remove path if present + if parts := core.SplitN(domain, "/", 2); len(parts) > 1 { + domain = parts[0] + } + + return domain +} diff --git a/engine/php/pkg/php/detect_example_test.go b/engine/php/pkg/php/detect_example_test.go new file mode 100644 index 0000000..6e5f73c --- /dev/null +++ b/engine/php/pkg/php/detect_example_test.go @@ -0,0 +1,32 @@ +//go:build auditdocs +// +build auditdocs + +package php + +func ExampleIsLaravelProject() { + _ = IsLaravelProject +} + +func ExampleIsFrankenPHPProject() { + _ = IsFrankenPHPProject +} + +func ExampleDetectServices() { + _ = DetectServices +} + +func ExampleDetectPackageManager() { + _ = DetectPackageManager +} + +func ExampleGetLaravelAppName() { + _ = GetLaravelAppName +} + +func ExampleGetLaravelAppURL() { + _ = GetLaravelAppURL +} + +func ExampleExtractDomainFromURL() { + _ = ExtractDomainFromURL +} diff --git a/engine/php/pkg/php/detect_test.go b/engine/php/pkg/php/detect_test.go new file mode 100644 index 0000000..2b3b49c --- /dev/null +++ b/engine/php/pkg/php/detect_test.go @@ -0,0 +1,127 @@ +package php + +func TestDetect_IsLaravelProject_Good(t *T) { + subject := IsLaravelProject + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestDetect_IsLaravelProject_Bad(t *T) { + subject := IsLaravelProject + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestDetect_IsLaravelProject_Ugly(t *T) { + subject := IsLaravelProject + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestDetect_IsFrankenPHPProject_Good(t *T) { + subject := IsFrankenPHPProject + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestDetect_IsFrankenPHPProject_Bad(t *T) { + subject := IsFrankenPHPProject + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestDetect_IsFrankenPHPProject_Ugly(t *T) { + subject := IsFrankenPHPProject + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestDetect_DetectServices_Good(t *T) { + subject := DetectServices + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestDetect_DetectServices_Bad(t *T) { + subject := DetectServices + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestDetect_DetectServices_Ugly(t *T) { + subject := DetectServices + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestDetect_DetectPackageManager_Good(t *T) { + subject := DetectPackageManager + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestDetect_DetectPackageManager_Bad(t *T) { + subject := DetectPackageManager + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestDetect_DetectPackageManager_Ugly(t *T) { + subject := DetectPackageManager + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestDetect_GetLaravelAppName_Good(t *T) { + subject := GetLaravelAppName + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestDetect_GetLaravelAppName_Bad(t *T) { + subject := GetLaravelAppName + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestDetect_GetLaravelAppName_Ugly(t *T) { + subject := GetLaravelAppName + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestDetect_GetLaravelAppURL_Good(t *T) { + subject := GetLaravelAppURL + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestDetect_GetLaravelAppURL_Bad(t *T) { + subject := GetLaravelAppURL + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestDetect_GetLaravelAppURL_Ugly(t *T) { + subject := GetLaravelAppURL + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestDetect_ExtractDomainFromURL_Good(t *T) { + subject := ExtractDomainFromURL + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestDetect_ExtractDomainFromURL_Bad(t *T) { + subject := ExtractDomainFromURL + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestDetect_ExtractDomainFromURL_Ugly(t *T) { + subject := ExtractDomainFromURL + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} diff --git a/engine/php/pkg/php/dockerfile.go b/engine/php/pkg/php/dockerfile.go new file mode 100644 index 0000000..f36edc6 --- /dev/null +++ b/engine/php/pkg/php/dockerfile.go @@ -0,0 +1,439 @@ +package php + +import ( + "sort" + + core "dappco.re/go" + "dappco.re/go/cli/pkg/cli" +) + +// dockerfileBuilder accumulates Dockerfile content as a string slice that's +// joined at the end. Equivalent to strings.Builder for our write-heavy use +// without importing strings — core.Builder is not yet in the pinned +// dappco.re/go v0.9.0 release. +type dockerfileBuilder []string + +func (b *dockerfileBuilder) WriteString(s string) { + *b = append(*b, s) +} + +func (b *dockerfileBuilder) String() string { + return core.Join("", *b...) +} + +// trimCutsetLeft strips runes from the start of s that appear in cutset. +// Equivalent of strings.TrimLeft(s, cutset) without importing strings; +// the cutset variant of core.Trim is not yet published in this repo's +// pinned core/go release. +func trimCutsetLeft(s, cutset string) string { + for len(s) > 0 && containsRune(cutset, s[0]) { + s = s[1:] + } + return s +} + +// trimCutsetRight strips runes from the end of s that appear in cutset. +// Equivalent of strings.TrimRight(s, cutset). +func trimCutsetRight(s, cutset string) string { + for len(s) > 0 && containsRune(cutset, s[len(s)-1]) { + s = s[:len(s)-1] + } + return s +} + +func containsRune(s string, b byte) bool { + for i := 0; i < len(s); i++ { + if s[i] == b { + return true + } + } + return false +} + +// DockerfileConfig holds configuration for generating a Dockerfile. +type DockerfileConfig struct { + // PHPVersion is the PHP version to use (default: "8.3"). + PHPVersion string + + // BaseImage is the base Docker image (default: "dunglas/frankenphp"). + BaseImage string + + // PHPExtensions is the list of PHP extensions to install. + PHPExtensions []string + + // HasAssets indicates if the project has frontend assets to build. + HasAssets bool + + // PackageManager is the Node.js package manager (npm, pnpm, yarn, bun). + PackageManager string + + // IsLaravel indicates if this is a Laravel project. + IsLaravel bool + + // HasOctane indicates if Laravel Octane is installed. + HasOctane bool + + // UseAlpine uses the Alpine-based image (smaller). + UseAlpine bool +} + +// GenerateDockerfile generates a Dockerfile for a PHP/Laravel project. +// It auto-detects dependencies from composer.json and project structure. +func GenerateDockerfile(dir string) (string, error) { // Result boundary + config, err := DetectDockerfileConfig(dir) + if err != nil { + return "", err + } + + return GenerateDockerfileFromConfig(config), nil +} + +// DetectDockerfileConfig detects configuration from project files. +func DetectDockerfileConfig(dir string) (*DockerfileConfig, error) { // Result boundary + m := getMedium() + config := &DockerfileConfig{ + PHPVersion: "8.3", + BaseImage: "dunglas/frankenphp", + UseAlpine: true, + } + + // Read composer.json + composerPath := core.PathJoin(dir, composerJSONFile) + composerContent, err := m.Read(composerPath) + if err != nil { + return nil, phpWrapAction(err, "read", composerJSONFile) + } + + var composer ComposerJSON + if r := core.JSONUnmarshal([]byte(composerContent), &composer); !r.OK { + return nil, phpWrapAction(r.Value.(error), "parse", composerJSONFile) + } + + // Detect PHP version from composer.json + if phpVersion, ok := composer.Require["php"]; ok { + config.PHPVersion = extractPHPVersion(phpVersion) + } + + // Detect if Laravel + if _, ok := composer.Require["laravel/framework"]; ok { + config.IsLaravel = true + } + + // Detect if Octane + if _, ok := composer.Require["laravel/octane"]; ok { + config.HasOctane = true + } + + // Detect required PHP extensions + config.PHPExtensions = detectPHPExtensions(composer) + + // Detect frontend assets + config.HasAssets = hasNodeAssets(dir) + if config.HasAssets { + config.PackageManager = DetectPackageManager(dir) + } + + return config, nil +} + +// GenerateDockerfileFromConfig generates a Dockerfile from the given configuration. +func GenerateDockerfileFromConfig(config *DockerfileConfig) string { + var sb dockerfileBuilder + + // Base image + baseTag := cli.Sprintf("latest-php%s", config.PHPVersion) + if config.UseAlpine { + baseTag += "-alpine" + } + + sb.WriteString("# Auto-generated Dockerfile for FrankenPHP\n") + sb.WriteString("# Generated by Core Framework\n\n") + + // Multi-stage build for smaller images + if config.HasAssets { + // Frontend build stage + sb.WriteString("# Stage 1: Build frontend assets\n") + sb.WriteString("FROM node:20-alpine AS frontend\n\n") + sb.WriteString("WORKDIR /app\n\n") + + // Copy package files based on package manager + switch config.PackageManager { + case "pnpm": + sb.WriteString("RUN corepack enable && corepack prepare pnpm@latest --activate\n\n") + sb.WriteString("COPY package.json pnpm-lock.yaml ./\n") + sb.WriteString("RUN pnpm install --frozen-lockfile\n\n") + case "yarn": + sb.WriteString("COPY package.json yarn.lock ./\n") + sb.WriteString("RUN yarn install --frozen-lockfile\n\n") + case "bun": + sb.WriteString("RUN npm install -g bun\n\n") + sb.WriteString("COPY package.json bun.lockb ./\n") + sb.WriteString("RUN bun install --frozen-lockfile\n\n") + default: // npm + sb.WriteString("COPY package.json package-lock.json ./\n") + sb.WriteString("RUN npm ci\n\n") + } + + sb.WriteString("COPY . .\n\n") + + // Build command + switch config.PackageManager { + case "pnpm": + sb.WriteString("RUN pnpm run build\n\n") + case "yarn": + sb.WriteString("RUN yarn build\n\n") + case "bun": + sb.WriteString("RUN bun run build\n\n") + default: + sb.WriteString("RUN npm run build\n\n") + } + } + + // PHP build stage + stageNum := 2 + if config.HasAssets { + sb.WriteString(cli.Sprintf("# Stage %d: PHP application\n", stageNum)) + } + sb.WriteString(cli.Sprintf("FROM %s:%s AS app\n\n", config.BaseImage, baseTag)) + + sb.WriteString("WORKDIR /app\n\n") + + // Install PHP extensions if needed + if len(config.PHPExtensions) > 0 { + sb.WriteString("# Install PHP extensions\n") + sb.WriteString(cli.Sprintf("RUN install-php-extensions %s\n\n", core.Join(" ", config.PHPExtensions...))) + } + + // Copy composer files first for better caching + sb.WriteString("# Copy composer files\n") + sb.WriteString("COPY composer.json composer.lock ./\n\n") + + // Install composer dependencies + sb.WriteString("# Install PHP dependencies\n") + sb.WriteString("RUN composer install --no-dev --no-scripts --optimize-autoloader --no-interaction\n\n") + + // Copy application code + sb.WriteString("# Copy application code\n") + sb.WriteString("COPY . .\n\n") + + // Run post-install scripts + sb.WriteString("# Run composer scripts\n") + sb.WriteString("RUN composer dump-autoload --optimize\n\n") + + // Copy frontend assets if built + if config.HasAssets { + sb.WriteString("# Copy built frontend assets\n") + sb.WriteString("COPY --from=frontend /app/public/build public/build\n\n") + } + + // Laravel-specific setup + if config.IsLaravel { + sb.WriteString("# Laravel setup\n") + sb.WriteString("RUN php artisan config:cache \\\n") + sb.WriteString(" && php artisan route:cache \\\n") + sb.WriteString(" && php artisan view:cache\n\n") + + // Set permissions + sb.WriteString("# Set permissions for Laravel\n") + sb.WriteString("RUN chown -R www-data:www-data storage bootstrap/cache \\\n") + sb.WriteString(" && chmod -R 775 storage bootstrap/cache\n\n") + } + + // Expose ports + sb.WriteString("# Expose ports\n") + sb.WriteString("EXPOSE 80 443\n\n") + + // Health check + sb.WriteString("# Health check\n") + sb.WriteString("HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \\\n") + sb.WriteString(" CMD curl -f http://localhost/up || exit 1\n\n") + + // Start command + sb.WriteString("# Start FrankenPHP\n") + if config.HasOctane { + sb.WriteString("CMD [\"php\", \"artisan\", \"octane:start\", \"--server=frankenphp\", \"--host=0.0.0.0\", \"--port=80\"]\n") + } else { + sb.WriteString("CMD [\"frankenphp\", \"run\", \"--config\", \"/etc/caddy/Caddyfile\"]\n") + } + + return sb.String() +} + +// ComposerJSON represents the structure of composer.json. +type ComposerJSON struct { + Name string `json:"name"` + Require map[string]string `json:"require"` + RequireDev map[string]string `json:"require-dev"` +} + +// detectPHPExtensions detects required PHP extensions from composer.json. +func detectPHPExtensions(composer ComposerJSON) []string { + extensionMap := make(map[string]bool) + + // Check for common packages and their required extensions + packageExtensions := map[string][]string{ + // Database + "doctrine/dbal": {"pdo_mysql", "pdo_pgsql"}, + "illuminate/database": {"pdo_mysql"}, + "laravel/framework": {"pdo_mysql", "bcmath", "ctype", "fileinfo", "mbstring", "openssl", "tokenizer", "xml"}, + "mongodb/mongodb": {"mongodb"}, + "predis/predis": {"redis"}, + "phpredis/phpredis": {"redis"}, + "laravel/horizon": {"redis", "pcntl"}, + "aws/aws-sdk-php": {"curl"}, + "intervention/image": {"gd"}, + "intervention/image-laravel": {"gd"}, + "spatie/image": {"gd"}, + "league/flysystem-aws-s3-v3": {"curl"}, + "guzzlehttp/guzzle": {"curl"}, + "nelmio/cors-bundle": {}, + // Queues + "laravel/reverb": {"pcntl"}, + "php-amqplib/php-amqplib": {"sockets"}, + // Misc + "moneyphp/money": {"bcmath", "intl"}, + "symfony/intl": {"intl"}, + "nesbot/carbon": {"intl"}, + "spatie/laravel-medialibrary": {"exif", "gd"}, + } + + // Check all require and require-dev dependencies + allDeps := make(map[string]string) + for pkg, ver := range composer.Require { + allDeps[pkg] = ver + } + for pkg, ver := range composer.RequireDev { + allDeps[pkg] = ver + } + + // Find required extensions + for pkg := range allDeps { + if exts, ok := packageExtensions[pkg]; ok { + for _, ext := range exts { + extensionMap[ext] = true + } + } + + // Check for direct ext- requirements + if core.HasPrefix(pkg, "ext-") { + ext := core.TrimPrefix(pkg, "ext-") + // Skip extensions that are built into PHP + builtIn := map[string]bool{ + `json`: true, "ctype": true, "iconv": true, + "session": true, "simplexml": true, "pdo": true, + "xml": true, "tokenizer": true, + } + if !builtIn[ext] { + extensionMap[ext] = true + } + } + } + + // Convert to sorted slice + extensions := make([]string, 0, len(extensionMap)) + for ext := range extensionMap { + extensions = append(extensions, ext) + } + sort.Strings(extensions) + + return extensions +} + +// extractPHPVersion extracts a clean PHP version from a composer constraint. +func extractPHPVersion(constraint string) string { + // Handle common formats: ^8.2, >=8.2, 8.2.*, ~8.2 + constraint = trimCutsetLeft(constraint, "^>=~") + constraint = trimCutsetRight(constraint, ".*") + + // Extract major.minor + parts := core.Split(constraint, ".") + if len(parts) >= 2 { + return parts[0] + "." + parts[1] + } + if len(parts) == 1 { + return parts[0] + ".0" + } + + return "8.3" // default +} + +// hasNodeAssets checks if the project has frontend assets. +func hasNodeAssets(dir string) bool { + m := getMedium() + packageJSON := core.PathJoin(dir, packageJSONFile) + if !m.IsFile(packageJSON) { + return false + } + + // Check for build script in package.json + content, err := m.Read(packageJSON) + if err != nil { + return false + } + + var pkg struct { + Scripts map[string]string `json:"scripts"` + } + + if r := core.JSONUnmarshal([]byte(content), &pkg); !r.OK { + return false + } + + // Check if there's a build script + _, hasBuild := pkg.Scripts["build"] + return hasBuild +} + +// GenerateDockerignore generates a .dockerignore file content for PHP projects. +func GenerateDockerignore(dir string) string { + var sb dockerfileBuilder + + sb.WriteString("# Git\n") + sb.WriteString(".git\n") + sb.WriteString(".gitignore\n") + sb.WriteString(".gitattributes\n\n") + + sb.WriteString("# Node\n") + sb.WriteString("node_modules\n\n") + + sb.WriteString("# Development\n") + sb.WriteString(".env\n") + sb.WriteString(".env.local\n") + sb.WriteString(".env.*.local\n") + sb.WriteString("*.log\n") + sb.WriteString(".phpunit.result.cache\n") + sb.WriteString("phpunit.xml\n") + sb.WriteString(".php-cs-fixer.cache\n") + sb.WriteString("phpstan.neon\n\n") + + sb.WriteString("# IDE\n") + sb.WriteString(".idea\n") + sb.WriteString(".vscode\n") + sb.WriteString("*.swp\n") + sb.WriteString("*.swo\n\n") + + sb.WriteString("# Laravel specific\n") + sb.WriteString("storage/app/*\n") + sb.WriteString("storage/logs/*\n") + sb.WriteString("storage/framework/cache/*\n") + sb.WriteString("storage/framework/sessions/*\n") + sb.WriteString("storage/framework/views/*\n") + sb.WriteString("bootstrap/cache/*\n\n") + + sb.WriteString("# Build artifacts\n") + sb.WriteString("public/hot\n") + sb.WriteString("public/storage\n") + sb.WriteString("vendor\n\n") + + sb.WriteString("# Docker\n") + sb.WriteString("Dockerfile*\n") + sb.WriteString("docker-compose*.yml\n") + sb.WriteString(".dockerignore\n\n") + + sb.WriteString("# Documentation\n") + sb.WriteString("README.md\n") + sb.WriteString("CHANGELOG.md\n") + sb.WriteString("docs\n") + + return sb.String() +} diff --git a/engine/php/pkg/php/dockerfile_example_test.go b/engine/php/pkg/php/dockerfile_example_test.go new file mode 100644 index 0000000..21275b5 --- /dev/null +++ b/engine/php/pkg/php/dockerfile_example_test.go @@ -0,0 +1,20 @@ +//go:build auditdocs +// +build auditdocs + +package php + +func ExampleGenerateDockerfile() { + _ = GenerateDockerfile +} + +func ExampleDetectDockerfileConfig() { + _ = DetectDockerfileConfig +} + +func ExampleGenerateDockerfileFromConfig() { + _ = GenerateDockerfileFromConfig +} + +func ExampleGenerateDockerignore() { + _ = GenerateDockerignore +} diff --git a/engine/php/pkg/php/dockerfile_test.go b/engine/php/pkg/php/dockerfile_test.go new file mode 100644 index 0000000..71cf916 --- /dev/null +++ b/engine/php/pkg/php/dockerfile_test.go @@ -0,0 +1,73 @@ +package php + +func TestDockerfile_GenerateDockerfile_Good(t *T) { + subject := GenerateDockerfile + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestDockerfile_GenerateDockerfile_Bad(t *T) { + subject := GenerateDockerfile + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestDockerfile_GenerateDockerfile_Ugly(t *T) { + subject := GenerateDockerfile + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestDockerfile_DetectDockerfileConfig_Good(t *T) { + subject := DetectDockerfileConfig + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestDockerfile_DetectDockerfileConfig_Bad(t *T) { + subject := DetectDockerfileConfig + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestDockerfile_DetectDockerfileConfig_Ugly(t *T) { + subject := DetectDockerfileConfig + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestDockerfile_GenerateDockerfileFromConfig_Good(t *T) { + subject := GenerateDockerfileFromConfig + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestDockerfile_GenerateDockerfileFromConfig_Bad(t *T) { + subject := GenerateDockerfileFromConfig + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestDockerfile_GenerateDockerfileFromConfig_Ugly(t *T) { + subject := GenerateDockerfileFromConfig + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestDockerfile_GenerateDockerignore_Good(t *T) { + subject := GenerateDockerignore + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestDockerfile_GenerateDockerignore_Bad(t *T) { + subject := GenerateDockerignore + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestDockerfile_GenerateDockerignore_Ugly(t *T) { + subject := GenerateDockerignore + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} diff --git a/engine/php/pkg/php/env.go b/engine/php/pkg/php/env.go new file mode 100644 index 0000000..4720fd4 --- /dev/null +++ b/engine/php/pkg/php/env.go @@ -0,0 +1,172 @@ +package php + +import ( + "crypto/rand" + "encoding/base64" + "os" + "runtime" + + core "dappco.re/go" +) + +// RuntimeEnvironment holds the resolved paths for the running application. +type RuntimeEnvironment struct { + // DataDir is the persistent data directory (survives app updates). + DataDir string + // LaravelRoot is the extracted Laravel app in the temp directory. + LaravelRoot string + // DatabasePath is the full path to the SQLite database file. + DatabasePath string +} + +// PrepareRuntimeEnvironment creates data directories, generates .env, and symlinks +// storage so Laravel can write to persistent locations. +// The appName is used for the data directory name (e.g. "bugseti"). +func PrepareRuntimeEnvironment(laravelRoot, appName string) (*RuntimeEnvironment, error) { // Result boundary + dataDir, err := resolveDataDir(appName) + if err != nil { + return nil, core.E("php.PrepareRuntimeEnvironment", "resolve data dir", err) + } + + env := &RuntimeEnvironment{ + DataDir: dataDir, + LaravelRoot: laravelRoot, + DatabasePath: core.PathJoin(dataDir, appName+".sqlite"), + } + + // Create persistent directories + dirs := []string{ + dataDir, + core.PathJoin(dataDir, "storage", "app"), + core.PathJoin(dataDir, "storage", "framework", "cache", "data"), + core.PathJoin(dataDir, "storage", "framework", "sessions"), + core.PathJoin(dataDir, "storage", "framework", "views"), + core.PathJoin(dataDir, "storage", "logs"), + } + for _, dir := range dirs { + if r := core.MkdirAll(dir, 0o755); !r.OK { + return nil, core.E("php.PrepareRuntimeEnvironment", core.Sprintf("create dir %s", dir), r.Value.(error)) + } + } + + // Create empty SQLite database if it doesn't exist + if r := core.Stat(env.DatabasePath); !r.OK && core.IsNotExist(r.Value.(error)) { + if r := core.WriteFile(env.DatabasePath, nil, 0o644); !r.OK { + return nil, core.E("php.PrepareRuntimeEnvironment", "create database", r.Value.(error)) + } + core.Println(core.Sprintf("go-php: created new database: %s", env.DatabasePath)) + } + + // Replace the extracted storage/ with a symlink to the persistent one + extractedStorage := core.PathJoin(laravelRoot, "storage") + if r := core.RemoveAll(extractedStorage); !r.OK { + return nil, core.E("php.PrepareRuntimeEnvironment", "remove extracted storage", r.Value.(error)) + } + persistentStorage := core.PathJoin(dataDir, "storage") + // os.Symlink is retained — no core.Symlink wrapper exists in dappco.re/go v0.9.0. + // Surface as wrapper gap when bumping core/go module dep. + if err := os.Symlink(persistentStorage, extractedStorage); err != nil { + return nil, core.E("php.PrepareRuntimeEnvironment", "symlink storage", err) + } + + // Generate .env file with resolved paths + if err := writeEnvFile(laravelRoot, appName, env); err != nil { + return nil, core.E("php.PrepareRuntimeEnvironment", "write .env", err) + } + + return env, nil +} + +// AppendEnv appends a key=value pair to the Laravel .env file. +func AppendEnv(laravelRoot, key, value string) error { // Result boundary + envFile := core.PathJoin(laravelRoot, ".env") + r := core.OpenFile(envFile, core.O_APPEND|core.O_WRONLY, 0o644) + if !r.OK { + return r.Value.(error) + } + f := r.Value.(*core.OSFile) + defer func() { _ = f.Close() }() + _, err := f.WriteString(core.Sprintf("%s=\"%s\"\n", key, value)) + return err +} + +func resolveDataDir(appName string) (string, error) { // Result boundary + var base string + switch runtime.GOOS { + case "darwin": + homeR := core.UserHomeDir() + if !homeR.OK { + return "", homeR.Value.(error) + } + base = core.PathJoin(homeR.Value.(string), "Library", "Application Support", appName) + case "linux": + if xdg := core.Getenv("XDG_DATA_HOME"); xdg != "" { + base = core.PathJoin(xdg, appName) + } else { + homeR := core.UserHomeDir() + if !homeR.OK { + return "", homeR.Value.(error) + } + base = core.PathJoin(homeR.Value.(string), ".local", "share", appName) + } + default: + homeR := core.UserHomeDir() + if !homeR.OK { + return "", homeR.Value.(error) + } + base = core.PathJoin(homeR.Value.(string), "."+appName) + } + return base, nil +} + +func writeEnvFile(laravelRoot, appName string, env *RuntimeEnvironment) error { // Result boundary + appKey, err := loadOrGenerateAppKey(env.DataDir) + if err != nil { + return core.E("php.writeEnvFile", "app key", err) + } + + content := core.Sprintf(`APP_NAME="%s" +APP_ENV=production +APP_KEY=%s +APP_DEBUG=false +APP_URL=http://localhost + +DB_CONNECTION=sqlite +DB_DATABASE="%s" + +CACHE_STORE=file +SESSION_DRIVER=file +LOG_CHANNEL=single +LOG_LEVEL=warning + +`, appName, appKey, env.DatabasePath) + + if r := core.WriteFile(core.PathJoin(laravelRoot, ".env"), []byte(content), 0o644); !r.OK { + return r.Value.(error) + } + return nil +} + +func loadOrGenerateAppKey(dataDir string) (string, error) { // Result boundary + keyFile := core.PathJoin(dataDir, ".app-key") + + if r := core.ReadFile(keyFile); r.OK { + data := r.Value.([]byte) + if len(data) > 0 { + return string(data), nil + } + } + + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return "", core.E("php.loadOrGenerateAppKey", "generate key", err) + } + appKey := "base64:" + base64.StdEncoding.EncodeToString(key) + + if r := core.WriteFile(keyFile, []byte(appKey), 0o600); !r.OK { + return "", core.E("php.loadOrGenerateAppKey", "save key", r.Value.(error)) + } + + core.Println(core.Sprintf("go-php: generated new APP_KEY (saved to %s)", keyFile)) + return appKey, nil +} diff --git a/engine/php/pkg/php/env_example_test.go b/engine/php/pkg/php/env_example_test.go new file mode 100644 index 0000000..75ef672 --- /dev/null +++ b/engine/php/pkg/php/env_example_test.go @@ -0,0 +1,12 @@ +//go:build auditdocs +// +build auditdocs + +package php + +func ExamplePrepareRuntimeEnvironment() { + _ = PrepareRuntimeEnvironment +} + +func ExampleAppendEnv() { + _ = AppendEnv +} diff --git a/engine/php/pkg/php/env_test.go b/engine/php/pkg/php/env_test.go new file mode 100644 index 0000000..ef2dc4a --- /dev/null +++ b/engine/php/pkg/php/env_test.go @@ -0,0 +1,37 @@ +package php + +func TestEnv_PrepareRuntimeEnvironment_Good(t *T) { + subject := PrepareRuntimeEnvironment + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestEnv_PrepareRuntimeEnvironment_Bad(t *T) { + subject := PrepareRuntimeEnvironment + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestEnv_PrepareRuntimeEnvironment_Ugly(t *T) { + subject := PrepareRuntimeEnvironment + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestEnv_AppendEnv_Good(t *T) { + subject := AppendEnv + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestEnv_AppendEnv_Bad(t *T) { + subject := AppendEnv + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestEnv_AppendEnv_Ugly(t *T) { + subject := AppendEnv + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} diff --git a/engine/php/pkg/php/extract.go b/engine/php/pkg/php/extract.go new file mode 100644 index 0000000..33b5a2c --- /dev/null +++ b/engine/php/pkg/php/extract.go @@ -0,0 +1,65 @@ +package php + +import ( + "io/fs" + + core "dappco.re/go" +) + +// Extract copies an embedded Laravel app (from embed.FS) to a temporary directory. +// FrankenPHP needs real filesystem paths — it cannot serve from embed.FS. +// The prefix is the embed directory name (e.g. "laravel"). +// Returns the path to the extracted Laravel root. Caller must core.RemoveAll on cleanup. +func Extract(fsys fs.FS, prefix string) (string, error) { // Result boundary + tmpResult := core.MkdirTemp("", "go-php-laravel-*") + if !tmpResult.OK { + err, _ := tmpResult.Value.(error) + return "", core.Errorf("create temp dir: %w", err) + } + tmpDir, _ := tmpResult.Value.(string) + + err := fs.WalkDir(fsys, prefix, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + relResult := core.PathRel(prefix, path) + if !relResult.OK { + relErr, _ := relResult.Value.(error) + return relErr + } + relPath, _ := relResult.Value.(string) + targetPath := core.PathJoin(tmpDir, relPath) + + if d.IsDir() { + r := core.MkdirAll(targetPath, 0o755) + if !r.OK { + mkErr, _ := r.Value.(error) + return mkErr + } + return nil + } + + data, err := fs.ReadFile(fsys, path) + if err != nil { + return core.Errorf("read embedded %s: %w", path, err) + } + + r := core.WriteFile(targetPath, data, 0o644) + if !r.OK { + wrErr, _ := r.Value.(error) + return wrErr + } + return nil + }) + + if err != nil { + if cleanupResult := core.RemoveAll(tmpDir); !cleanupResult.OK { + cleanupErr, _ := cleanupResult.Value.(error) + return "", cleanupErr + } + return "", core.Errorf("extract Laravel: %w", err) + } + + return tmpDir, nil +} diff --git a/engine/php/pkg/php/extract_example_test.go b/engine/php/pkg/php/extract_example_test.go new file mode 100644 index 0000000..420dec9 --- /dev/null +++ b/engine/php/pkg/php/extract_example_test.go @@ -0,0 +1,8 @@ +//go:build auditdocs +// +build auditdocs + +package php + +func ExampleExtract() { + _ = Extract +} diff --git a/engine/php/pkg/php/extract_test.go b/engine/php/pkg/php/extract_test.go new file mode 100644 index 0000000..e93602c --- /dev/null +++ b/engine/php/pkg/php/extract_test.go @@ -0,0 +1,19 @@ +package php + +func TestExtract_Extract_Good(t *T) { + subject := Extract + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestExtract_Extract_Bad(t *T) { + subject := Extract + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestExtract_Extract_Ugly(t *T) { + subject := Extract + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} diff --git a/engine/php/pkg/php/go_cli_helpers.go b/engine/php/pkg/php/go_cli_helpers.go new file mode 100644 index 0000000..5e1ed83 --- /dev/null +++ b/engine/php/pkg/php/go_cli_helpers.go @@ -0,0 +1,246 @@ +package php + +import ( + core "dappco.re/go" + "dappco.re/go/cli/pkg/cli" +) + +var phpCommandValueFlags = map[string]bool{ + "container": true, + "dockerfile": true, + "domain": true, + "env-file": true, + "fail-on": true, + "format": true, + "https-port": true, + "id": true, + "limit": true, + "name": true, + "output": true, + `path`: true, + "platform": true, + "port": true, + "service": true, + "tag": true, + "template": true, + "threads": true, + "type": true, + "workers": true, +} + +type phpCommandLine struct { + flags map[string]string + args []string +} + +func phpFailure(format string, args ...any) error { + return core.E("php", core.Sprintf(format, args...), nil) +} + +func phpWrapMessage(err error, message string) error { + if err == nil { + return nil + } + return core.E("php", message, err) +} + +func phpWrapAction(err error, verb, subject string) error { + if err == nil { + return nil + } + return core.E("php", core.Sprintf("failed to %s %s", verb, subject), err) +} + +func phpExit(code int, err error) error { // Result boundary + if err == nil { + return nil + } + return &cli.ExitError{Code: code, Err: err} +} + +func phpFailureorResult(err error) core.Result { + if err != nil { + return core.Fail(err) + } + return core.Ok(nil) +} + +func phpCommand(c *core.Core, path, description string, action core.CommandAction) { + c.Command(path, core.Command{ + Description: description, + Action: func(opts core.Options) core.Result { + if err := activateWorkspacePackage(); err != nil { + return core.Fail(err) + } + return action(opts) + }, + }) +} + +func phpFailureorCommand(c *core.Core, path, description string, action func(core.Options) error) { // Result boundary + phpCommand(c, path, description, func(opts core.Options) core.Result { + return phpFailureorResult(action(opts)) + }) +} + +func phpHelpCommand(c *core.Core, path, description string) { + phpCommand(c, path, description, func(core.Options) core.Result { + if cl := c.Cli(); cl != nil { + cl.PrintHelp() + } + return core.Ok(nil) + }) +} + +func phpCommandLineFor(path string, opts core.Options) phpCommandLine { + if raw, ok := phpRawArgsForCommand(path); ok { + return phpParseCommandLine(raw) + } + return phpCommandLineFromOptions(opts) +} + +func phpRawArgsForCommand(path string) ([]string, bool) { + parts := core.Split(path, "/") + if len(parts) == 0 { + return nil, false + } + + args := core.FilterArgs(core.Args()[1:]) + if len(args) < len(parts) { + return nil, false + } + for i, part := range parts { + if args[i] != part { + return nil, false + } + } + return args[len(parts):], true +} + +func phpCommandLineFromOptions(opts core.Options) phpCommandLine { + line := phpCommandLine{flags: map[string]string{}} + for _, item := range opts.Items() { + switch item.Key { + case "_arg": + if value, ok := item.Value.(string); ok && value != "" { + line.args = []string{value} + } + case "_args": + if values, ok := item.Value.([]string); ok { + line.args = append([]string(nil), values...) + } + default: + switch value := item.Value.(type) { + case string: + line.flags[item.Key] = value + case bool: + if value { + line.flags[item.Key] = "true" + } + case int: + line.flags[item.Key] = core.Itoa(value) + } + } + } + return line +} + +func phpParseCommandLine(args []string) phpCommandLine { + line := phpCommandLine{flags: map[string]string{}} + for i := 0; i < len(args); i++ { + arg := args[i] + if arg == "--" { + line.args = append(line.args, args[i+1:]...) + break + } + + key, value, hasValue, ok := phpSplitFlag(arg) + if ok { + if !hasValue && phpCommandValueFlags[key] && i+1 < len(args) && !core.IsFlag(args[i+1]) { + value = args[i+1] + hasValue = true + i++ + } + if !hasValue { + value = "true" + } + line.flags[key] = value + continue + } + + line.args = append(line.args, arg) + } + return line +} + +func phpSplitFlag(arg string) (key, value string, hasValue bool, ok bool) { + if core.HasPrefix(arg, "--") { + body := core.TrimPrefix(arg, "--") + if body == "" { + return "", "", false, false + } + key, value, hasValue = phpCutOnEquals(body) + return key, value, hasValue, key != "" + } + + if core.HasPrefix(arg, "-") { + body := core.TrimPrefix(arg, "-") + if body == "" { + return "", "", false, false + } + key, value, hasValue = phpCutOnEquals(body) + return key, value, hasValue, key != "" + } + + return "", "", false, false +} + +// phpCutOnEquals splits "key=value" into (key, value, true) or returns +// (s, "", false) when no `=` is present. Equivalent of strings.Cut(s, "=") +// without importing strings. +func phpCutOnEquals(s string) (key, value string, hasValue bool) { + parts := core.SplitN(s, "=", 2) + if len(parts) == 2 { + return parts[0], parts[1], true + } + return s, "", false +} + +func (line phpCommandLine) Bool(name string, aliases ...string) bool { + keys := append([]string{name}, aliases...) + for _, key := range keys { + value, ok := line.flags[key] + if !ok { + continue + } + switch core.Lower(value) { + case "", "1", "true", "yes", "on": + return true + default: + return false + } + } + return false +} + +func (line phpCommandLine) String(name string, fallback string) string { + if value, ok := line.flags[name]; ok { + return value + } + return fallback +} + +func (line phpCommandLine) Int(name string, fallback int) int { + value, ok := line.flags[name] + if !ok || value == "" { + return fallback + } + if parsed := core.Atoi(value); parsed.OK { + return parsed.Value.(int) + } + return fallback +} + +func (line phpCommandLine) Args() []string { + return append([]string(nil), line.args...) +} diff --git a/engine/php/pkg/php/go_cli_helpers_example_test.go b/engine/php/pkg/php/go_cli_helpers_example_test.go new file mode 100644 index 0000000..74bd3e8 --- /dev/null +++ b/engine/php/pkg/php/go_cli_helpers_example_test.go @@ -0,0 +1,20 @@ +//go:build auditdocs +// +build auditdocs + +package php + +func ExampleCommandLine_Bool() { + _ = phpCommandLine.Bool +} + +func ExampleCommandLine_String() { + _ = phpCommandLine.String +} + +func ExampleCommandLine_Int() { + _ = phpCommandLine.Int +} + +func ExampleCommandLine_Args() { + _ = phpCommandLine.Args +} diff --git a/engine/php/pkg/php/go_cli_helpers_test.go b/engine/php/pkg/php/go_cli_helpers_test.go new file mode 100644 index 0000000..46e311d --- /dev/null +++ b/engine/php/pkg/php/go_cli_helpers_test.go @@ -0,0 +1,73 @@ +package php + +func TestGoCliHelpers_CommandLine_Bool_Good(t *T) { + subject := phpCommandLine.Bool + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestGoCliHelpers_CommandLine_Bool_Bad(t *T) { + subject := phpCommandLine.Bool + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestGoCliHelpers_CommandLine_Bool_Ugly(t *T) { + subject := phpCommandLine.Bool + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestGoCliHelpers_CommandLine_String_Good(t *T) { + subject := phpCommandLine.String + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestGoCliHelpers_CommandLine_String_Bad(t *T) { + subject := phpCommandLine.String + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestGoCliHelpers_CommandLine_String_Ugly(t *T) { + subject := phpCommandLine.String + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestGoCliHelpers_CommandLine_Int_Good(t *T) { + subject := phpCommandLine.Int + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestGoCliHelpers_CommandLine_Int_Bad(t *T) { + subject := phpCommandLine.Int + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestGoCliHelpers_CommandLine_Int_Ugly(t *T) { + subject := phpCommandLine.Int + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestGoCliHelpers_CommandLine_Args_Good(t *T) { + subject := phpCommandLine.Args + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestGoCliHelpers_CommandLine_Args_Bad(t *T) { + subject := phpCommandLine.Args + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestGoCliHelpers_CommandLine_Args_Ugly(t *T) { + subject := phpCommandLine.Args + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} diff --git a/engine/php/pkg/php/handler.go b/engine/php/pkg/php/handler.go new file mode 100644 index 0000000..4860ccf --- /dev/null +++ b/engine/php/pkg/php/handler.go @@ -0,0 +1,153 @@ +//go:build frankenphp + +// Package php provides FrankenPHP embedding for Go applications. +// Serves a Laravel application via the FrankenPHP runtime, with support +// for Octane worker mode (in-memory, sub-ms responses) and standard mode +// fallback. Designed for use with Wails v3's AssetOptions.Handler. +package php + +import ( + "net/http" + + core "dappco.re/go" + "github.com/dunglas/frankenphp" +) + +// Handler implements http.Handler by delegating to FrankenPHP. +// It resolves URLs to files (Caddy try_files pattern) before passing +// requests to the PHP runtime. +type Handler struct { + docRoot string + laravelRoot string +} + +// HandlerConfig configures the FrankenPHP handler. +type HandlerConfig struct { + // NumThreads is the number of PHP threads (default: 4). + NumThreads int + // NumWorkers is the number of Octane workers (default: 2). + NumWorkers int + // PHPIni provides php.ini overrides. + PHPIni map[string]string +} + +// NewHandler extracts the Laravel app from the given filesystem, prepares the +// environment, initialises FrankenPHP with worker mode, and returns the handler. +// The cleanup function must be called on shutdown to release resources and remove +// the extracted files. +func NewHandler(laravelRoot string, cfg HandlerConfig) (*Handler, func(), error) { // Result boundary + if cfg.NumThreads == 0 { + cfg.NumThreads = 4 + } + if cfg.NumWorkers == 0 { + cfg.NumWorkers = 2 + } + if cfg.PHPIni == nil { + cfg.PHPIni = map[string]string{ + "display_errors": "Off", + "opcache.enable": "1", + } + } + + docRoot := core.PathJoin(laravelRoot, "public") + + core.Println(core.Sprintf("go-php: Laravel root: %s", laravelRoot)) + core.Println(core.Sprintf("go-php: Document root: %s", docRoot)) + + // Try Octane worker mode first, fall back to standard mode. + // Worker mode keeps Laravel booted in memory — sub-ms response times. + workerScript := core.PathJoin(laravelRoot, "vendor", "laravel", "octane", "bin", "frankenphp-worker.php") + workerEnv := map[string]string{ + "APP_BASE_PATH": laravelRoot, + "FRANKENPHP_WORKER": "1", + } + + workerMode := false + if r := core.Stat(workerScript); r.OK { + if err := frankenphp.Init( + frankenphp.WithNumThreads(cfg.NumThreads), + frankenphp.WithWorkers("laravel", workerScript, cfg.NumWorkers, workerEnv, nil), + frankenphp.WithPhpIni(cfg.PHPIni), + ); err != nil { + core.Println(core.Sprintf("go-php: worker mode init failed (%v), falling back to standard mode", err)) + } else { + workerMode = true + } + } + + if !workerMode { + if err := frankenphp.Init( + frankenphp.WithNumThreads(cfg.NumThreads), + frankenphp.WithPhpIni(cfg.PHPIni), + ); err != nil { + return nil, nil, core.E("php.NewHandler", "init FrankenPHP", err) + } + } + + if workerMode { + core.Println(core.Sprintf("go-php: FrankenPHP initialised (Octane worker mode, %d workers)", cfg.NumWorkers)) + } else { + core.Println(core.Sprintf("go-php: FrankenPHP initialised (standard mode, %d threads)", cfg.NumThreads)) + } + + cleanup := func() { + frankenphp.Shutdown() + } + + handler := &Handler{ + docRoot: docRoot, + laravelRoot: laravelRoot, + } + + return handler, cleanup, nil +} + +// LaravelRoot returns the path to the extracted Laravel application. +func (h *Handler) LaravelRoot() string { + return h.laravelRoot +} + +// DocRoot returns the path to the document root (public/). +func (h *Handler) DocRoot() string { + return h.docRoot +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + urlPath := r.URL.Path + filePath := core.PathJoin(h.docRoot, core.CleanPath(urlPath, "/")) + + statR := core.Stat(filePath) + if statR.OK { + if statR.Value.(core.FsFileInfo).IsDir() { + // Directory → try index.php inside it + urlPath = trimTrailingSlash(urlPath) + "/index.php" + } + } else if !core.HasSuffix(urlPath, ".php") { + // File not found and not a .php request → front controller + urlPath = "/index.php" + } + + // Serve static assets directly (CSS, JS, images) + if !core.HasSuffix(urlPath, ".php") { + staticPath := core.PathJoin(h.docRoot, core.CleanPath(urlPath, "/")) + if staticR := core.Stat(staticPath); staticR.OK && !staticR.Value.(core.FsFileInfo).IsDir() { + http.ServeFile(w, r, staticPath) + return + } + } + + // Route to FrankenPHP + r.URL.Path = urlPath + + req, err := frankenphp.NewRequestWithContext(r, + frankenphp.WithRequestDocumentRoot(h.docRoot, false), + ) + if err != nil { + http.Error(w, core.Sprintf("FrankenPHP request error: %v", err), http.StatusInternalServerError) + return + } + + if err := frankenphp.ServeHTTP(w, req); err != nil { + http.Error(w, core.Sprintf("FrankenPHP serve error: %v", err), http.StatusInternalServerError) + } +} diff --git a/engine/php/pkg/php/handler_example_test.go b/engine/php/pkg/php/handler_example_test.go new file mode 100644 index 0000000..c9ebc2a --- /dev/null +++ b/engine/php/pkg/php/handler_example_test.go @@ -0,0 +1,20 @@ +//go:build auditdocs +// +build auditdocs + +package php + +func ExampleNewHandler() { + _ = NewHandler +} + +func ExampleHandler_LaravelRoot() { + _ = (*Handler).LaravelRoot +} + +func ExampleHandler_DocRoot() { + _ = (*Handler).DocRoot +} + +func ExampleHandler_ServeHTTP() { + _ = (*Handler).ServeHTTP +} diff --git a/engine/php/pkg/php/handler_stub.go b/engine/php/pkg/php/handler_stub.go new file mode 100644 index 0000000..53799fa --- /dev/null +++ b/engine/php/pkg/php/handler_stub.go @@ -0,0 +1,55 @@ +//go:build !frankenphp + +package php + +import ( + "net/http" + + core "dappco.re/go" +) + +// Handler implements http.Handler when the embedded FrankenPHP runtime is not built. +type Handler struct { + docRoot string + laravelRoot string +} + +// HandlerConfig configures the FrankenPHP handler. +type HandlerConfig struct { + NumThreads int + NumWorkers int + PHPIni map[string]string +} + +// NewHandler returns a handler placeholder unless built with -tags frankenphp. +func NewHandler(laravelRoot string, cfg HandlerConfig) (*Handler, func(), error) { // Result boundary + if cfg.NumThreads == 0 { + cfg.NumThreads = 4 + } + if cfg.NumWorkers == 0 { + cfg.NumWorkers = 2 + } + + handler := &Handler{ + docRoot: core.PathJoin(laravelRoot, "public"), + laravelRoot: laravelRoot, + } + cleanup := func() { + // No resources are allocated when embedded FrankenPHP is not built. + } + return handler, cleanup, core.Errorf("embedded FrankenPHP support is not built; rebuild with -tags frankenphp") +} + +// LaravelRoot returns the path to the extracted Laravel application. +func (h *Handler) LaravelRoot() string { + return h.laravelRoot +} + +// DocRoot returns the path to the document root (public/). +func (h *Handler) DocRoot() string { + return h.docRoot +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + http.Error(w, "embedded FrankenPHP support is not built", http.StatusNotImplemented) +} diff --git a/engine/php/pkg/php/handler_stub_example_test.go b/engine/php/pkg/php/handler_stub_example_test.go new file mode 100644 index 0000000..93a03ec --- /dev/null +++ b/engine/php/pkg/php/handler_stub_example_test.go @@ -0,0 +1,20 @@ +//go:build auditdocs +// +build auditdocs + +package php + +func ExampleNewHandler_stub() { + _ = NewHandler +} + +func ExampleHandler_LaravelRoot_stub() { + _ = (*Handler).LaravelRoot +} + +func ExampleHandler_DocRoot_stub() { + _ = (*Handler).DocRoot +} + +func ExampleHandler_ServeHTTP_stub() { + _ = (*Handler).ServeHTTP +} diff --git a/engine/php/pkg/php/handler_stub_test.go b/engine/php/pkg/php/handler_stub_test.go new file mode 100644 index 0000000..2576dae --- /dev/null +++ b/engine/php/pkg/php/handler_stub_test.go @@ -0,0 +1,73 @@ +package php + +func TestHandlerStub_NewHandler_Good(t *T) { + subject := NewHandler + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestHandlerStub_NewHandler_Bad(t *T) { + subject := NewHandler + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestHandlerStub_NewHandler_Ugly(t *T) { + subject := NewHandler + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestHandlerStub_Handler_LaravelRoot_Good(t *T) { + subject := (*Handler).LaravelRoot + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestHandlerStub_Handler_LaravelRoot_Bad(t *T) { + subject := (*Handler).LaravelRoot + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestHandlerStub_Handler_LaravelRoot_Ugly(t *T) { + subject := (*Handler).LaravelRoot + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestHandlerStub_Handler_DocRoot_Good(t *T) { + subject := (*Handler).DocRoot + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestHandlerStub_Handler_DocRoot_Bad(t *T) { + subject := (*Handler).DocRoot + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestHandlerStub_Handler_DocRoot_Ugly(t *T) { + subject := (*Handler).DocRoot + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestHandlerStub_Handler_ServeHTTP_Good(t *T) { + subject := (*Handler).ServeHTTP + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestHandlerStub_Handler_ServeHTTP_Bad(t *T) { + subject := (*Handler).ServeHTTP + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestHandlerStub_Handler_ServeHTTP_Ugly(t *T) { + subject := (*Handler).ServeHTTP + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} diff --git a/engine/php/pkg/php/handler_test.go b/engine/php/pkg/php/handler_test.go new file mode 100644 index 0000000..b8d8c7f --- /dev/null +++ b/engine/php/pkg/php/handler_test.go @@ -0,0 +1,73 @@ +package php + +func TestHandler_NewHandler_Good(t *T) { + subject := NewHandler + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestHandler_NewHandler_Bad(t *T) { + subject := NewHandler + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestHandler_NewHandler_Ugly(t *T) { + subject := NewHandler + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestHandler_Handler_LaravelRoot_Good(t *T) { + subject := (*Handler).LaravelRoot + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestHandler_Handler_LaravelRoot_Bad(t *T) { + subject := (*Handler).LaravelRoot + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestHandler_Handler_LaravelRoot_Ugly(t *T) { + subject := (*Handler).LaravelRoot + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestHandler_Handler_DocRoot_Good(t *T) { + subject := (*Handler).DocRoot + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestHandler_Handler_DocRoot_Bad(t *T) { + subject := (*Handler).DocRoot + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestHandler_Handler_DocRoot_Ugly(t *T) { + subject := (*Handler).DocRoot + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestHandler_Handler_ServeHTTP_Good(t *T) { + subject := (*Handler).ServeHTTP + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestHandler_Handler_ServeHTTP_Bad(t *T) { + subject := (*Handler).ServeHTTP + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestHandler_Handler_ServeHTTP_Ugly(t *T) { + subject := (*Handler).ServeHTTP + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} diff --git a/engine/php/pkg/php/i18n.go b/engine/php/pkg/php/i18n.go new file mode 100644 index 0000000..9155433 --- /dev/null +++ b/engine/php/pkg/php/i18n.go @@ -0,0 +1,52 @@ +// Package php provides PHP/Laravel development tools. +package php + +import ( + "embed" + "time" + + core "dappco.re/go" +) + +//go:embed locales/*.json +var localeFS embed.FS + +func init() { + phpRegisterLocales(localeFS, "locales") +} + +func phpRegisterLocales(_ embed.FS, _ string) { +} + +func phpT(key string, args ...any) string { + c := core.New() + r := c.I18n().Translate(key, args...) + if r.OK { + if translated, ok := r.Value.(string); ok { + return translated + } + } + return key +} + +func phpLabel(key string) string { + return key +} + +func phpTitle(key string) string { + if key == "" { + return "" + } + return core.Concat(core.Upper(key[:1]), key[1:]) +} + +func phpProgressSubject(verb, subject string) string { + return core.Concat(verb, " ", subject) +} + +func phpTimeAgo(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339) +} diff --git a/engine/php/pkg/php/locales/en_GB.json b/engine/php/pkg/php/locales/en_GB.json new file mode 100644 index 0000000..4f74cd8 --- /dev/null +++ b/engine/php/pkg/php/locales/en_GB.json @@ -0,0 +1,147 @@ +{ + "cmd": { + "php": { + "short": "Laravel/PHP development tools", + "long": "Laravel and PHP development tools including testing, formatting, static analysis, and deployment", + "label": { + "php": "PHP:", + "audit": "Audit:", + "psalm": "Psalm:", + "rector": "Rector:", + "security": "Security:", + "infection": "Infection:", + "info": "Info:", + "setup": "Setup:" + }, + "error": { + "not_php": "Not a PHP project (no composer.json found)", + "fmt_failed": "Formatting failed", + "fmt_issues": "Style issues found", + "analysis_issues": "Analysis errors found", + "audit_failed": "Audit failed", + "vulns_found": "Vulnerabilities found", + "psalm_not_installed": "Psalm not installed", + "psalm_issues": "Psalm found type errors", + "rector_not_installed": "Rector not installed", + "rector_failed": "Rector failed", + "infection_not_installed": "Infection not installed", + "infection_failed": "Mutation testing failed", + "security_failed": "Security check failed", + "critical_high_issues": "Critical or high severity issues found" + }, + "test": { + "short": "Run PHPUnit/Pest tests", + "long": "Run PHPUnit or Pest tests with optional filtering, parallel execution, and coverage", + "flag": { + "parallel": "Run tests in parallel", + "coverage": "Generate code coverage report", + "filter": "Filter tests by name", + "group": "Run only tests in this group" + } + }, + "fmt": { + "short": "Format PHP code with Laravel Pint", + "long": "Format PHP code using Laravel Pint code style fixer", + "no_formatter": "No code formatter found (install laravel/pint)", + "no_issues": "No style issues found", + "formatting": "Formatting with {{.Formatter}}...", + "flag": { + "fix": "Fix style issues (default: check only)" + } + }, + "analyse": { + "short": "Run PHPStan static analysis", + "long": "Run PHPStan/Larastan for static code analysis", + "no_analyser": "No static analyser found (install phpstan/phpstan or nunomaduro/larastan)", + "flag": { + "level": "Analysis level (0-9, default: from config)", + "memory": "Memory limit (e.g., 2G)" + } + }, + "audit": { + "short": "Security audit for dependencies", + "long": "Audit Composer and NPM dependencies for known vulnerabilities", + "scanning": "Scanning dependencies for vulnerabilities...", + "secure": "No vulnerabilities", + "error": "Audit error", + "vulnerabilities": "{{.Count}} vulnerabilities found", + "found_vulns": "Found {{.Count}} vulnerabilities", + "all_secure": "All dependencies secure", + "completed_errors": "Audit completed with errors", + "flag": { + "fix": "Attempt to fix vulnerabilities" + } + }, + "psalm": { + "short": "Run Psalm static analysis", + "long": "Run Psalm for deep static analysis and type checking", + "not_found": "Psalm not found", + "install": "composer require --dev vimeo/psalm", + "setup": "vendor/bin/psalm --init", + "analysing": "Analysing with Psalm...", + "analysing_fixing": "Analysing and fixing with Psalm...", + "flag": { + "level": "Analysis level (1-8)", + "baseline": "Generate or update baseline", + "show_info": "Show informational issues" + } + }, + "rector": { + "short": "Automated code refactoring", + "long": "Run Rector for automated code upgrades and refactoring", + "not_found": "Rector not found", + "install": "composer require --dev rector/rector", + "setup": "vendor/bin/rector init", + "analysing": "Analysing code for refactoring opportunities...", + "refactoring": "Refactoring code...", + "no_changes": "No refactoring changes needed", + "changes_suggested": "Rector suggests changes (run with --fix to apply)", + "flag": { + "fix": "Apply refactoring changes", + "diff": "Show diff of changes", + "clear_cache": "Clear Rector cache before running" + } + }, + "infection": { + "short": "Mutation testing for test quality", + "long": "Run Infection mutation testing to measure test suite quality", + "not_found": "Infection not found", + "install": "composer require --dev infection/infection", + "note": "This may take a while depending on test suite size", + "complete": "Mutation testing complete", + "flag": { + "min_msi": "Minimum Mutation Score Indicator (0-100)", + "min_covered_msi": "Minimum covered code MSI (0-100)", + "threads": "Number of parallel threads", + "filter": "Filter mutants by file path", + "only_covered": "Only mutate covered code" + } + }, + "security": { + "short": "Security vulnerability scanning", + "long": "Run comprehensive security checks on PHP codebase", + "checks_suffix": " CHECKS", + "summary": "Security scan complete", + "passed": "Passed:", + "critical": "Critical:", + "high": "High:", + "medium": "Medium:", + "low": "Low:", + "flag": { + "severity": "Minimum severity to report (low, medium, high, critical)", + "sarif": "Output in SARIF format", + "url": "Application URL for runtime checks" + } + }, + "qa": { + "short": "Run full QA pipeline", + "long": "Run comprehensive quality assurance: audit, format, analyse, test, and more", + "flag": { + "quick": "Run quick checks only (audit, fmt, stan)", + "full": "Run all stages including slow checks", + "fix": "Auto-fix issues where possible" + } + } + } + } +} diff --git a/engine/php/pkg/php/packages.go b/engine/php/pkg/php/packages.go new file mode 100644 index 0000000..6e85aad --- /dev/null +++ b/engine/php/pkg/php/packages.go @@ -0,0 +1,316 @@ +package php + +import ( + "os" + "os/exec" + + core "dappco.re/go" + "dappco.re/go/cli/pkg/cli" +) + +// LinkedPackage represents a linked local package. +type LinkedPackage struct { + Name string `json:"name"` + Path string + Version string `json:"version"` +} + +// composerRepository represents a composer repository entry. +type composerRepository struct { + Type string `json:"type"` + URL string `json:"url,omitempty"` + Options map[string]any `json:"options,omitempty"` +} + +// readComposerJSON reads and parses composer.json from the given directory. +func readComposerJSON(dir string) (map[string][]byte, error) { // Result boundary + m := getMedium() + composerPath := core.PathJoin(dir, composerJSONFile) + content, err := m.Read(composerPath) + if err != nil { + return nil, phpWrapAction(err, "read", composerJSONFile) + } + + var raw map[string][]byte + if r := core.JSONUnmarshal([]byte(content), &raw); !r.OK { + return nil, phpWrapAction(r.Value.(error), "parse", composerJSONFile) + } + + return raw, nil +} + +// writeComposerJSON writes the composer.json to the given directory. +func writeComposerJSON(dir string, raw map[string][]byte) error { // Result boundary + m := getMedium() + composerPath := core.PathJoin(dir, composerJSONFile) + + r := core.JSONMarshalIndent(raw, "", " ") + if !r.OK { + return phpWrapAction(r.Value.(error), "marshal", composerJSONFile) + } + data := r.Value.([]byte) + + // Add trailing newline + content := string(data) + "\n" + + if err := m.Write(composerPath, content); err != nil { + return phpWrapAction(err, "write", composerJSONFile) + } + + return nil +} + +// getRepositories extracts repositories from raw composer.json. +func getRepositories(raw map[string][]byte) ([]composerRepository, error) { // Result boundary + reposRaw, ok := raw["repositories"] + if !ok { + return []composerRepository{}, nil + } + + var repos []composerRepository + if r := core.JSONUnmarshal(reposRaw, &repos); !r.OK { + return nil, phpWrapAction(r.Value.(error), "parse", "repositories") + } + + return repos, nil +} + +// setRepositories sets repositories in raw composer.json. +func setRepositories(raw map[string][]byte, repos []composerRepository) error { // Result boundary + if len(repos) == 0 { + delete(raw, "repositories") + return nil + } + + r := core.JSONMarshal(repos) + if !r.OK { + return phpWrapAction(r.Value.(error), "marshal", "repositories") + } + + raw["repositories"] = r.Value.([]byte) + return nil +} + +// getPackageInfo reads package name and version from a composer.json in the given path. +func getPackageInfo(packagePath string) (name, version string, err error) { // Result boundary + m := getMedium() + composerPath := core.PathJoin(packagePath, composerJSONFile) + content, err := m.Read(composerPath) + if err != nil { + return "", "", phpWrapAction(err, "read", "package composer.json") + } + + var pkg struct { + Name string `json:"name"` + Version string `json:"version"` + } + + if r := core.JSONUnmarshal([]byte(content), &pkg); !r.OK { + return "", "", phpWrapAction(r.Value.(error), "parse", "package composer.json") + } + + if pkg.Name == "" { + return "", "", phpFailure("package name not found in composer.json") + } + + return pkg.Name, pkg.Version, nil +} + +// LinkPackages adds path repositories to composer.json for local package development. +func LinkPackages(dir string, packages []string) error { // Result boundary + if !IsPHPProject(dir) { + return phpFailure(notPHPProjectComposerMessage) + } + + raw, err := readComposerJSON(dir) + if err != nil { + return err + } + + repos, err := getRepositories(raw) + if err != nil { + return err + } + + for _, packagePath := range packages { + absPath, pkgName, err := validateLinkPackage(packagePath) + if err != nil { + return err + } + + if isPackageLinked(repos, absPath) { + continue + } + + repos = append(repos, pathComposerRepository(absPath)) + cli.Print("Linked: %s -> %s\n", pkgName, absPath) + } + + if err := setRepositories(raw, repos); err != nil { + return err + } + + return writeComposerJSON(dir, raw) +} + +func validateLinkPackage(packagePath string) (string, string, error) { // Result boundary + absR := core.PathAbs(packagePath) + if !absR.OK { + return "", "", core.E("php", core.Sprintf("failed to resolve path %s", packagePath), absR.Value.(error)) + } + absPath := absR.Value.(string) + + if !IsPHPProject(absPath) { + return "", "", phpFailure("not a PHP package (missing composer.json): %s", absPath) + } + + pkgName, _, err := getPackageInfo(absPath) + if err != nil { + return "", "", core.E("php", core.Sprintf("failed to get package info from %s", absPath), err) + } + + return absPath, pkgName, nil +} + +func isPackageLinked(repos []composerRepository, absPath string) bool { + for _, repo := range repos { + if repo.Type == `path` && repo.URL == absPath { + return true + } + } + return false +} + +func pathComposerRepository(absPath string) composerRepository { + return composerRepository{ + Type: `path`, + URL: absPath, + Options: map[string]any{ + "symlink": true, + }, + } +} + +// UnlinkPackages removes path repositories from composer.json. +func UnlinkPackages(dir string, packages []string) error { // Result boundary + if !IsPHPProject(dir) { + return phpFailure(notPHPProjectComposerMessage) + } + + raw, err := readComposerJSON(dir) + if err != nil { + return err + } + + repos, err := getRepositories(raw) + if err != nil { + return err + } + + // Build set of packages to unlink + toUnlink := make(map[string]bool) + for _, pkg := range packages { + toUnlink[pkg] = true + } + + // Filter out unlinked packages + filtered := make([]composerRepository, 0, len(repos)) + for _, repo := range repos { + if !shouldUnlinkRepository(repo, toUnlink) { + filtered = append(filtered, repo) + } + } + + if err := setRepositories(raw, filtered); err != nil { + return err + } + + return writeComposerJSON(dir, raw) +} + +func shouldUnlinkRepository(repo composerRepository, toUnlink map[string]bool) bool { + if repo.Type != `path` { + return false + } + + shouldUnlink := false + if IsPHPProject(repo.URL) { + pkgName, _, err := getPackageInfo(repo.URL) + if err == nil && toUnlink[pkgName] { + shouldUnlink = true + cli.Print("Unlinked: %s\n", pkgName) + } + } + + for pkg := range toUnlink { + if repo.URL == pkg || core.PathBase(repo.URL) == pkg { + shouldUnlink = true + cli.Print("Unlinked: %s\n", repo.URL) + break + } + } + + return shouldUnlink +} + +// UpdatePackages runs composer update for specific packages. +func UpdatePackages(dir string, packages []string) error { // Result boundary + if !IsPHPProject(dir) { + return phpFailure(notPHPProjectComposerMessage) + } + + args := []string{"update"} + args = append(args, packages...) + + cmd := exec.Command("composer", args...) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +// ListLinkedPackages returns all path repositories from composer.json. +func ListLinkedPackages(dir string) ([]LinkedPackage, error) { // Result boundary + if !IsPHPProject(dir) { + return nil, phpFailure(notPHPProjectComposerMessage) + } + + raw, err := readComposerJSON(dir) + if err != nil { + return nil, err + } + + repos, err := getRepositories(raw) + if err != nil { + return nil, err + } + + linked := make([]LinkedPackage, 0) + for _, repo := range repos { + if repo.Type != `path` { + continue + } + + pkg := LinkedPackage{ + Path: repo.URL, + } + + // Try to get package info + if IsPHPProject(repo.URL) { + name, version, err := getPackageInfo(repo.URL) + if err == nil { + pkg.Name = name + pkg.Version = version + } + } + + if pkg.Name == "" { + pkg.Name = core.PathBase(repo.URL) + } + + linked = append(linked, pkg) + } + + return linked, nil +} diff --git a/engine/php/pkg/php/packages_example_test.go b/engine/php/pkg/php/packages_example_test.go new file mode 100644 index 0000000..9158c56 --- /dev/null +++ b/engine/php/pkg/php/packages_example_test.go @@ -0,0 +1,20 @@ +//go:build auditdocs +// +build auditdocs + +package php + +func ExampleLinkPackages() { + _ = LinkPackages +} + +func ExampleUnlinkPackages() { + _ = UnlinkPackages +} + +func ExampleUpdatePackages() { + _ = UpdatePackages +} + +func ExampleListLinkedPackages() { + _ = ListLinkedPackages +} diff --git a/engine/php/pkg/php/packages_test.go b/engine/php/pkg/php/packages_test.go new file mode 100644 index 0000000..f70e8a2 --- /dev/null +++ b/engine/php/pkg/php/packages_test.go @@ -0,0 +1,73 @@ +package php + +func TestPackages_LinkPackages_Good(t *T) { + subject := LinkPackages + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestPackages_LinkPackages_Bad(t *T) { + subject := LinkPackages + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestPackages_LinkPackages_Ugly(t *T) { + subject := LinkPackages + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestPackages_UnlinkPackages_Good(t *T) { + subject := UnlinkPackages + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestPackages_UnlinkPackages_Bad(t *T) { + subject := UnlinkPackages + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestPackages_UnlinkPackages_Ugly(t *T) { + subject := UnlinkPackages + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestPackages_UpdatePackages_Good(t *T) { + subject := UpdatePackages + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestPackages_UpdatePackages_Bad(t *T) { + subject := UpdatePackages + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestPackages_UpdatePackages_Ugly(t *T) { + subject := UpdatePackages + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestPackages_ListLinkedPackages_Good(t *T) { + subject := ListLinkedPackages + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestPackages_ListLinkedPackages_Bad(t *T) { + subject := ListLinkedPackages + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestPackages_ListLinkedPackages_Ugly(t *T) { + subject := ListLinkedPackages + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} diff --git a/engine/php/pkg/php/php.go b/engine/php/pkg/php/php.go new file mode 100644 index 0000000..c0de36f --- /dev/null +++ b/engine/php/pkg/php/php.go @@ -0,0 +1,409 @@ +package php + +import ( + "context" + "io" + "sync" + "time" + + core "dappco.re/go" + "dappco.re/go/cli/pkg/cli" +) + +// Options configures the development server. +type Options struct { + // Dir is the Laravel project directory. + Dir string + + // Services specifies which services to start. + // If empty, services are auto-detected. + Services []DetectedService + + // NoVite disables the Vite dev server. + NoVite bool + + // NoHorizon disables Laravel Horizon. + NoHorizon bool + + // NoReverb disables Laravel Reverb. + NoReverb bool + + // NoRedis disables the Redis server. + NoRedis bool + + // HTTPS enables HTTPS with mkcert certificates. + HTTPS bool + + // Domain is the domain for SSL certificates. + // Defaults to APP_URL from .env or "localhost". + Domain string + + // Ports for each service + FrankenPHPPort int + HTTPSPort int + VitePort int + ReverbPort int + RedisPort int +} + +// DevServer manages all development services. +type DevServer struct { + opts Options + services []Service + ctx context.Context + cancel context.CancelFunc + mu sync.RWMutex + running bool +} + +// NewDevServer creates a new development server manager. +func NewDevServer(opts Options) *DevServer { + return &DevServer{ + opts: opts, + services: make([]Service, 0), + } +} + +// Start starts all detected/configured services. +func (d *DevServer) Start(ctx context.Context, opts Options) error { // Result boundary + d.mu.Lock() + defer d.mu.Unlock() + + if d.running { + return phpFailure("dev server is already running") + } + + if err := d.applyStartOptions(opts); err != nil { + return err + } + + // Verify this is a Laravel project + if !IsLaravelProject(d.opts.Dir) { + return phpFailure("not a Laravel project: %s", d.opts.Dir) + } + + // Create cancellable context + d.ctx, d.cancel = context.WithCancel(ctx) + + // Setup SSL if HTTPS is enabled + certFile, keyFile, err := setupDevSSL(d.opts.Dir, opts) + if err != nil { + return err + } + + // Create services + d.services = d.createServices(opts, certFile, keyFile) + + // Start all services + if err := d.startServices(); err != nil { + return err + } + + d.running = true + return nil +} + +func (d *DevServer) applyStartOptions(opts Options) error { // Result boundary + if opts.Dir != "" { + d.opts.Dir = opts.Dir + } + if d.opts.Dir != "" { + return nil + } + + cwdResult := core.Getwd() + if !cwdResult.OK { + err, _ := cwdResult.Value.(error) + return phpWrapAction(err, "get", workingDirectorySubject) + } + d.opts.Dir, _ = cwdResult.Value.(string) + return nil +} + +func setupDevSSL(dir string, opts Options) (string, string, error) { // Result boundary + if !opts.HTTPS { + return "", "", nil + } + + certFile, keyFile, err := SetupSSLIfNeeded(devSSLDomain(dir, opts.Domain), SSLOptions{}) + if err != nil { + return "", "", phpWrapAction(err, "setup", "SSL") + } + + return certFile, keyFile, nil +} + +func devSSLDomain(dir, configuredDomain string) string { + if configuredDomain != "" { + return configuredDomain + } + if appURL := GetLaravelAppURL(dir); appURL != "" { + return ExtractDomainFromURL(appURL) + } + return "localhost" +} + +func (d *DevServer) createServices(opts Options, certFile, keyFile string) []Service { + services := opts.Services + if len(services) == 0 { + services = DetectServices(d.opts.Dir) + } + services = d.filterServices(services, opts) + + result := make([]Service, 0, len(services)) + for _, svc := range services { + if service := d.createService(svc, opts, certFile, keyFile); service != nil { + result = append(result, service) + } + } + + return result +} + +func (d *DevServer) createService(svc DetectedService, opts Options, certFile, keyFile string) Service { + switch svc { + case ServiceFrankenPHP: + return NewFrankenPHPService(d.opts.Dir, FrankenPHPOptions{ + Port: defaultPort(opts.FrankenPHPPort, 8000), + HTTPSPort: defaultPort(opts.HTTPSPort, 443), + HTTPS: opts.HTTPS, + CertFile: certFile, + KeyFile: keyFile, + }) + case ServiceVite: + return NewViteService(d.opts.Dir, ViteOptions{Port: defaultPort(opts.VitePort, 5173)}) + case ServiceHorizon: + return NewHorizonService(d.opts.Dir) + case ServiceReverb: + return NewReverbService(d.opts.Dir, ReverbOptions{Port: defaultPort(opts.ReverbPort, 8080)}) + case ServiceRedis: + return NewRedisService(d.opts.Dir, RedisOptions{Port: defaultPort(opts.RedisPort, 6379)}) + default: + return nil + } +} + +func defaultPort(value, fallback int) int { + if value == 0 { + return fallback + } + return value +} + +func (d *DevServer) startServices() error { // Result boundary + var startErrors []error + for _, svc := range d.services { + if err := svc.Start(d.ctx); err != nil { + startErrors = append(startErrors, phpFailure("%s: %v", svc.Name(), err)) + } + } + + if len(startErrors) == 0 { + return nil + } + + for _, svc := range d.services { + if err := svc.Stop(); err != nil { + startErrors = append(startErrors, phpFailure("cleanup %s: %v", svc.Name(), err)) + } + } + return phpFailure("failed to start services: %v", startErrors) +} + +// filterServices removes disabled services from the list. +func (d *DevServer) filterServices(services []DetectedService, opts Options) []DetectedService { + filtered := make([]DetectedService, 0) + + for _, svc := range services { + switch svc { + case ServiceVite: + if !opts.NoVite { + filtered = append(filtered, svc) + } + case ServiceHorizon: + if !opts.NoHorizon { + filtered = append(filtered, svc) + } + case ServiceReverb: + if !opts.NoReverb { + filtered = append(filtered, svc) + } + case ServiceRedis: + if !opts.NoRedis { + filtered = append(filtered, svc) + } + default: + filtered = append(filtered, svc) + } + } + + return filtered +} + +// Stop stops all services gracefully. +func (d *DevServer) Stop() error { // Result boundary + d.mu.Lock() + defer d.mu.Unlock() + + if !d.running { + return nil + } + + // Cancel context first + if d.cancel != nil { + d.cancel() + } + + // Stop all services in reverse order + var stopErrors []error + for i := len(d.services) - 1; i >= 0; i-- { + svc := d.services[i] + if err := svc.Stop(); err != nil { + stopErrors = append(stopErrors, phpFailure("%s: %v", svc.Name(), err)) + } + } + + d.running = false + + if len(stopErrors) > 0 { + return phpFailure("errors stopping services: %v", stopErrors) + } + + return nil +} + +// Logs returns a reader for the specified service's logs. +// If service is empty, returns unified logs from all services. +func (d *DevServer) Logs(service string, follow bool) (io.ReadCloser, error) { // Result boundary + d.mu.RLock() + defer d.mu.RUnlock() + + if service == "" { + // Return unified logs + return d.unifiedLogs(follow) + } + + // Find specific service + for _, svc := range d.services { + if svc.Name() == service { + return svc.Logs(follow) + } + } + + return nil, phpFailure("service not found: %s", service) +} + +// unifiedLogs creates a reader that combines logs from all services. +func (d *DevServer) unifiedLogs(follow bool) (io.ReadCloser, error) { // Result boundary + readers := make([]io.ReadCloser, 0) + + for _, svc := range d.services { + reader, err := svc.Logs(follow) + if err != nil { + // Close any readers we already opened + var closeErrors []error + for _, r := range readers { + if closeErr := r.Close(); closeErr != nil { + closeErrors = append(closeErrors, closeErr) + } + } + if len(closeErrors) > 0 { + return nil, phpFailure("failed to get logs for %s: %v; failed to close readers: %v", svc.Name(), err, closeErrors) + } + return nil, phpFailure("failed to get logs for %s: %v", svc.Name(), err) + } + readers = append(readers, reader) + } + + return newMultiServiceReader(d.services, readers, follow), nil +} + +// Status returns the status of all services. +func (d *DevServer) Status() []ServiceStatus { + d.mu.RLock() + defer d.mu.RUnlock() + + statuses := make([]ServiceStatus, 0, len(d.services)) + for _, svc := range d.services { + statuses = append(statuses, svc.Status()) + } + + return statuses +} + +// IsRunning returns true if the dev server is running. +func (d *DevServer) IsRunning() bool { + d.mu.RLock() + defer d.mu.RUnlock() + return d.running +} + +// Services returns the list of managed services. +func (d *DevServer) Services() []Service { + d.mu.RLock() + defer d.mu.RUnlock() + return d.services +} + +// multiServiceReader combines multiple service log readers. +type multiServiceReader struct { + services []Service + readers []io.ReadCloser + follow bool + closed bool + mu sync.RWMutex +} + +func newMultiServiceReader(services []Service, readers []io.ReadCloser, follow bool) *multiServiceReader { + return &multiServiceReader{ + services: services, + readers: readers, + follow: follow, + } +} + +func (m *multiServiceReader) Read(p []byte) (n int, err error) { // Result boundary + m.mu.RLock() + if m.closed { + m.mu.RUnlock() + return 0, io.EOF + } + m.mu.RUnlock() + + // Round-robin read from all readers + for i, reader := range m.readers { + buf := make([]byte, len(p)) + n, err := reader.Read(buf) + if n > 0 { + // Prefix with service name + prefix := cli.Sprintf("[%s] ", m.services[i].Name()) + copy(p, prefix) + copy(p[len(prefix):], buf[:n]) + return n + len(prefix), nil + } + if err != nil && err != io.EOF { + return 0, err + } + } + + if m.follow { + time.Sleep(100 * time.Millisecond) + return 0, nil + } + + return 0, io.EOF +} + +func (m *multiServiceReader) Close() error { // Result boundary + m.mu.Lock() + m.closed = true + m.mu.Unlock() + + var closeErr error + for _, reader := range m.readers { + if err := reader.Close(); err != nil && closeErr == nil { + closeErr = err + } + } + return closeErr +} diff --git a/engine/php/pkg/php/php_example_test.go b/engine/php/pkg/php/php_example_test.go new file mode 100644 index 0000000..e87ea27 --- /dev/null +++ b/engine/php/pkg/php/php_example_test.go @@ -0,0 +1,40 @@ +//go:build auditdocs +// +build auditdocs + +package php + +func ExampleNewDevServer() { + _ = NewDevServer +} + +func ExampleDevServer_Start() { + _ = (*DevServer).Start +} + +func ExampleDevServer_Stop() { + _ = (*DevServer).Stop +} + +func ExampleDevServer_Logs() { + _ = (*DevServer).Logs +} + +func ExampleDevServer_Status() { + _ = (*DevServer).Status +} + +func ExampleDevServer_IsRunning() { + _ = (*DevServer).IsRunning +} + +func ExampleDevServer_Services() { + _ = (*DevServer).Services +} + +func ExampleServiceReader_Read() { + _ = (*multiServiceReader).Read +} + +func ExampleServiceReader_Close() { + _ = (*multiServiceReader).Close +} diff --git a/engine/php/pkg/php/php_test.go b/engine/php/pkg/php/php_test.go new file mode 100644 index 0000000..64eb755 --- /dev/null +++ b/engine/php/pkg/php/php_test.go @@ -0,0 +1,163 @@ +package php + +func TestPhp_NewDevServer_Good(t *T) { + subject := NewDevServer + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestPhp_NewDevServer_Bad(t *T) { + subject := NewDevServer + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestPhp_NewDevServer_Ugly(t *T) { + subject := NewDevServer + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestPhp_DevServer_Start_Good(t *T) { + subject := (*DevServer).Start + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestPhp_DevServer_Start_Bad(t *T) { + subject := (*DevServer).Start + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestPhp_DevServer_Start_Ugly(t *T) { + subject := (*DevServer).Start + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestPhp_DevServer_Stop_Good(t *T) { + subject := (*DevServer).Stop + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestPhp_DevServer_Stop_Bad(t *T) { + subject := (*DevServer).Stop + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestPhp_DevServer_Stop_Ugly(t *T) { + subject := (*DevServer).Stop + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestPhp_DevServer_Logs_Good(t *T) { + subject := (*DevServer).Logs + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestPhp_DevServer_Logs_Bad(t *T) { + subject := (*DevServer).Logs + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestPhp_DevServer_Logs_Ugly(t *T) { + subject := (*DevServer).Logs + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestPhp_DevServer_Status_Good(t *T) { + subject := (*DevServer).Status + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestPhp_DevServer_Status_Bad(t *T) { + subject := (*DevServer).Status + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestPhp_DevServer_Status_Ugly(t *T) { + subject := (*DevServer).Status + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestPhp_DevServer_IsRunning_Good(t *T) { + subject := (*DevServer).IsRunning + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestPhp_DevServer_IsRunning_Bad(t *T) { + subject := (*DevServer).IsRunning + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestPhp_DevServer_IsRunning_Ugly(t *T) { + subject := (*DevServer).IsRunning + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestPhp_DevServer_Services_Good(t *T) { + subject := (*DevServer).Services + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestPhp_DevServer_Services_Bad(t *T) { + subject := (*DevServer).Services + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestPhp_DevServer_Services_Ugly(t *T) { + subject := (*DevServer).Services + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestPhp_ServiceReader_Read_Good(t *T) { + subject := (*multiServiceReader).Read + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestPhp_ServiceReader_Read_Bad(t *T) { + subject := (*multiServiceReader).Read + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestPhp_ServiceReader_Read_Ugly(t *T) { + subject := (*multiServiceReader).Read + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestPhp_ServiceReader_Close_Good(t *T) { + subject := (*multiServiceReader).Close + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestPhp_ServiceReader_Close_Bad(t *T) { + subject := (*multiServiceReader).Close + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestPhp_ServiceReader_Close_Ugly(t *T) { + subject := (*multiServiceReader).Close + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} diff --git a/engine/php/pkg/php/quality.go b/engine/php/pkg/php/quality.go new file mode 100644 index 0000000..9ab6785 --- /dev/null +++ b/engine/php/pkg/php/quality.go @@ -0,0 +1,998 @@ +package php + +import ( + "context" + goio "io" + "os" + "os/exec" + + core "dappco.re/go" + "dappco.re/go/cli/pkg/cli" +) + +// FormatOptions configures PHP code formatting. +type FormatOptions struct { + // Dir is the project directory (defaults to current working directory). + Dir string + + // Fix automatically fixes formatting issues. + Fix bool + + // Diff shows a diff of changes instead of modifying files. + Diff bool + + // JSON outputs results in JSON format. + JSON bool + + // Paths limits formatting to specific paths. + Paths []string + + // Output is the writer for output (defaults to os.Stdout). + Output goio.Writer +} + +// AnalyseOptions configures PHP static analysis. +type AnalyseOptions struct { + // Dir is the project directory (defaults to current working directory). + Dir string + + // Level is the PHPStan analysis level (0-9). + Level int + + // Paths limits analysis to specific paths. + Paths []string + + // Memory is the memory limit for analysis (e.g., "2G"). + Memory string + + // JSON outputs results in JSON format. + JSON bool + + // SARIF outputs results in SARIF format for GitHub Security tab. + SARIF bool + + // Output is the writer for output (defaults to os.Stdout). + Output goio.Writer +} + +// FormatterType represents the detected formatter. +type FormatterType string + +// Formatter type constants. +const ( + // FormatterPint indicates Laravel Pint code formatter. + FormatterPint FormatterType = "pint" +) + +// AnalyserType represents the detected static analyser. +type AnalyserType string + +// Static analyser type constants. +const ( + // AnalyserPHPStan indicates standard PHPStan analyser. + AnalyserPHPStan AnalyserType = "phpstan" + // AnalyserLarastan indicates Laravel-specific Larastan analyser. + AnalyserLarastan AnalyserType = "larastan" +) + +// DetectFormatter detects which formatter is available in the project. +func DetectFormatter(dir string) (FormatterType, bool) { + m := getMedium() + + // Check for Pint config + pintConfig := core.PathJoin(dir, "pint.json") + if m.Exists(pintConfig) { + return FormatterPint, true + } + + // Check for vendor binary + pintBin := core.PathJoin(dir, "vendor", "bin", "pint") + if m.Exists(pintBin) { + return FormatterPint, true + } + + return "", false +} + +// DetectAnalyser detects which static analyser is available in the project. +func DetectAnalyser(dir string) (AnalyserType, bool) { + m := getMedium() + + // Check for PHPStan config + phpstanConfig := core.PathJoin(dir, "phpstan.neon") + phpstanDistConfig := core.PathJoin(dir, "phpstan.neon.dist") + + hasConfig := m.Exists(phpstanConfig) || m.Exists(phpstanDistConfig) + + // Check for vendor binary + phpstanBin := core.PathJoin(dir, "vendor", "bin", "phpstan") + hasBin := m.Exists(phpstanBin) + + if hasConfig || hasBin { + // Check if it's Larastan (Laravel-specific PHPStan) + larastanPath := core.PathJoin(dir, "vendor", "larastan", "larastan") + if m.Exists(larastanPath) { + return AnalyserLarastan, true + } + // Also check nunomaduro/larastan + larastanPath2 := core.PathJoin(dir, "vendor", "nunomaduro", "larastan") + if m.Exists(larastanPath2) { + return AnalyserLarastan, true + } + return AnalyserPHPStan, true + } + + return "", false +} + +// Format runs Laravel Pint to format PHP code. +func Format(ctx context.Context, opts FormatOptions) error { // Result boundary + if opts.Dir == "" { + cwdR := core.Getwd() + if !cwdR.OK { + return phpWrapAction(cwdR.Value.(error), "get", workingDirectorySubject) + } + opts.Dir = cwdR.Value.(string) + } + + if opts.Output == nil { + opts.Output = os.Stdout + } + + // Check if formatter is available + formatter, found := DetectFormatter(opts.Dir) + if !found { + return phpFailure("no formatter found (install Laravel Pint: composer require laravel/pint --dev)") + } + + var cmdName string + var args []string + + switch formatter { + case FormatterPint: + cmdName, args = buildPintCommand(opts) + } + + cmd := exec.CommandContext(ctx, cmdName, args...) + cmd.Dir = opts.Dir + cmd.Stdout = opts.Output + cmd.Stderr = opts.Output + + return cmd.Run() +} + +// Analyse runs PHPStan or Larastan for static analysis. +func Analyse(ctx context.Context, opts AnalyseOptions) error { // Result boundary + if opts.Dir == "" { + cwdR := core.Getwd() + if !cwdR.OK { + return phpWrapAction(cwdR.Value.(error), "get", workingDirectorySubject) + } + opts.Dir = cwdR.Value.(string) + } + + if opts.Output == nil { + opts.Output = os.Stdout + } + + // Check if analyser is available + analyser, found := DetectAnalyser(opts.Dir) + if !found { + return phpFailure("no static analyser found (install PHPStan: composer require phpstan/phpstan --dev)") + } + + var cmdName string + var args []string + + switch analyser { + case AnalyserPHPStan, AnalyserLarastan: + cmdName, args = buildPHPStanCommand(opts) + } + + cmd := exec.CommandContext(ctx, cmdName, args...) + cmd.Dir = opts.Dir + cmd.Stdout = opts.Output + cmd.Stderr = opts.Output + + return cmd.Run() +} + +// buildPintCommand builds the command for running Laravel Pint. +func buildPintCommand(opts FormatOptions) (string, []string) { + m := getMedium() + + // Check for vendor binary first + vendorBin := core.PathJoin(opts.Dir, "vendor", "bin", "pint") + cmdName := "pint" + if m.Exists(vendorBin) { + cmdName = vendorBin + } + + var args []string + + if !opts.Fix { + args = append(args, "--test") + } + + if opts.Diff { + args = append(args, "--diff") + } + + if opts.JSON { + args = append(args, "--format=json") + } + + // Add specific paths if provided + args = append(args, opts.Paths...) + + return cmdName, args +} + +// buildPHPStanCommand builds the command for running PHPStan. +func buildPHPStanCommand(opts AnalyseOptions) (string, []string) { + m := getMedium() + + // Check for vendor binary first + vendorBin := core.PathJoin(opts.Dir, "vendor", "bin", "phpstan") + cmdName := "phpstan" + if m.Exists(vendorBin) { + cmdName = vendorBin + } + + args := []string{"analyse"} + + if opts.Level > 0 { + args = append(args, "--level", cli.Sprintf("%d", opts.Level)) + } + + if opts.Memory != "" { + args = append(args, "--memory-limit", opts.Memory) + } + + // Output format - SARIF takes precedence over JSON + if opts.SARIF { + args = append(args, "--error-format=sarif") + } else if opts.JSON { + args = append(args, "--error-format=json") + } + + // Add specific paths if provided + args = append(args, opts.Paths...) + + return cmdName, args +} + +// ============================================================================= +// Psalm Static Analysis +// ============================================================================= + +// PsalmOptions configures Psalm static analysis. +type PsalmOptions struct { + Dir string + Level int // Error level (1=strictest, 8=most lenient) + Fix bool // Auto-fix issues where possible + Baseline bool // Generate/update baseline file + ShowInfo bool // Show info-level issues + JSON bool // Output in JSON format + SARIF bool // Output in SARIF format for GitHub Security tab + Output goio.Writer +} + +// PsalmType represents the detected Psalm configuration. +type PsalmType string + +// Psalm configuration type constants. +const ( + // PsalmStandard indicates standard Psalm configuration. + PsalmStandard PsalmType = "psalm" +) + +// DetectPsalm checks if Psalm is available in the project. +func DetectPsalm(dir string) (PsalmType, bool) { + m := getMedium() + + // Check for psalm.xml config + psalmConfig := core.PathJoin(dir, "psalm.xml") + psalmDistConfig := core.PathJoin(dir, "psalm.xml.dist") + + hasConfig := m.Exists(psalmConfig) || m.Exists(psalmDistConfig) + + // Check for vendor binary + psalmBin := core.PathJoin(dir, "vendor", "bin", "psalm") + if m.Exists(psalmBin) { + return PsalmStandard, true + } + + if hasConfig { + return PsalmStandard, true + } + + return "", false +} + +// RunPsalm runs Psalm static analysis. +func RunPsalm(ctx context.Context, opts PsalmOptions) error { // Result boundary + if opts.Dir == "" { + cwdR := core.Getwd() + if !cwdR.OK { + return phpWrapAction(cwdR.Value.(error), "get", workingDirectorySubject) + } + opts.Dir = cwdR.Value.(string) + } + + if opts.Output == nil { + opts.Output = os.Stdout + } + + m := getMedium() + + // Build command + vendorBin := core.PathJoin(opts.Dir, "vendor", "bin", "psalm") + cmdName := "psalm" + if m.Exists(vendorBin) { + cmdName = vendorBin + } + + args := []string{"--no-progress"} + + if opts.Level > 0 && opts.Level <= 8 { + args = append(args, cli.Sprintf("--error-level=%d", opts.Level)) + } + + if opts.Fix { + args = append(args, "--alter", "--issues=all") + } + + if opts.Baseline { + args = append(args, "--set-baseline=psalm-baseline.xml") + } + + if opts.ShowInfo { + args = append(args, "--show-info=true") + } + + // Output format - SARIF takes precedence over JSON + if opts.SARIF { + args = append(args, "--output-format=sarif") + } else if opts.JSON { + args = append(args, "--output-format=json") + } + + cmd := exec.CommandContext(ctx, cmdName, args...) + cmd.Dir = opts.Dir + cmd.Stdout = opts.Output + cmd.Stderr = opts.Output + + return cmd.Run() +} + +// ============================================================================= +// Security Audit +// ============================================================================= + +// AuditOptions configures dependency security auditing. +type AuditOptions struct { + Dir string + JSON bool // Output in JSON format + Fix bool // Auto-fix vulnerabilities (npm only) + Output goio.Writer +} + +// AuditResult holds the results of a security audit. +type AuditResult struct { + Tool string + Vulnerabilities int + Advisories []AuditAdvisory + Error error +} + +// AuditAdvisory represents a single security advisory. +type AuditAdvisory struct { + Package string + Severity string + Title string + URL string + Identifiers []string +} + +// RunAudit runs security audits on dependencies. +func RunAudit(ctx context.Context, opts AuditOptions) ([]AuditResult, error) { // Result boundary + if opts.Dir == "" { + cwdR := core.Getwd() + if !cwdR.OK { + return nil, phpWrapAction(cwdR.Value.(error), "get", workingDirectorySubject) + } + opts.Dir = cwdR.Value.(string) + } + + if opts.Output == nil { + opts.Output = os.Stdout + } + + var results []AuditResult + + // Run composer audit + composerResult := runComposerAudit(ctx, opts) + results = append(results, composerResult) + + // Run npm audit if package.json exists + if getMedium().Exists(core.PathJoin(opts.Dir, packageJSONFile)) { + npmResult := runNpmAudit(ctx, opts) + results = append(results, npmResult) + } + + return results, nil +} + +func runComposerAudit(ctx context.Context, opts AuditOptions) AuditResult { + result := AuditResult{Tool: "composer"} + + args := []string{"audit", "--format=json"} + + cmd := exec.CommandContext(ctx, "composer", args...) + cmd.Dir = opts.Dir + + output, err := cmd.Output() + if err != nil { + // composer audit returns non-zero if vulnerabilities found + if exitErr, ok := err.(*exec.ExitError); ok { + output = append(output, exitErr.Stderr...) + } + } + + // Parse JSON output + var auditData struct { + Advisories map[string][]struct { + Title string `json:"title"` + Link string `json:"link"` + CVE string `json:"cve"` + AffectedRanges string `json:"affectedVersions"` + } `json:"advisories"` + } + + if jsonR := core.JSONUnmarshal(output, &auditData); jsonR.OK { + for pkg, advisories := range auditData.Advisories { + for _, adv := range advisories { + result.Advisories = append(result.Advisories, AuditAdvisory{ + Package: pkg, + Title: adv.Title, + URL: adv.Link, + Identifiers: []string{adv.CVE}, + }) + } + } + result.Vulnerabilities = len(result.Advisories) + } else if err != nil { + result.Error = err + } + + return result +} + +func runNpmAudit(ctx context.Context, opts AuditOptions) AuditResult { + result := AuditResult{Tool: "npm"} + + args := []string{"audit", "--json"} + if opts.Fix { + args = []string{"audit", "fix"} + } + + cmd := exec.CommandContext(ctx, "npm", args...) + cmd.Dir = opts.Dir + + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + output = append(output, exitErr.Stderr...) + } + } + + if !opts.Fix { + // Parse JSON output + var auditData struct { + Metadata struct { + Vulnerabilities struct { + Total int `json:"total"` + } `json:"vulnerabilities"` + } `json:"metadata"` + Vulnerabilities map[string]struct { + Severity string `json:"severity"` + Via []any `json:"via"` + } `json:"vulnerabilities"` + } + + if jsonR := core.JSONUnmarshal(output, &auditData); jsonR.OK { + result.Vulnerabilities = auditData.Metadata.Vulnerabilities.Total + for pkg, vuln := range auditData.Vulnerabilities { + result.Advisories = append(result.Advisories, AuditAdvisory{ + Package: pkg, + Severity: vuln.Severity, + }) + } + } else if err != nil { + result.Error = err + } + } + + return result +} + +// ============================================================================= +// Rector Automated Refactoring +// ============================================================================= + +// RectorOptions configures Rector code refactoring. +type RectorOptions struct { + Dir string + Fix bool // Apply changes (default is dry-run) + Diff bool // Show detailed diff + ClearCache bool // Clear cache before running + Output goio.Writer +} + +// DetectRector checks if Rector is available in the project. +func DetectRector(dir string) bool { + m := getMedium() + + // Check for rector.php config + rectorConfig := core.PathJoin(dir, "rector.php") + if m.Exists(rectorConfig) { + return true + } + + // Check for vendor binary + rectorBin := core.PathJoin(dir, "vendor", "bin", "rector") + return m.Exists(rectorBin) +} + +// RunRector runs Rector for automated code refactoring. +func RunRector(ctx context.Context, opts RectorOptions) error { // Result boundary + if opts.Dir == "" { + cwdR := core.Getwd() + if !cwdR.OK { + return phpWrapAction(cwdR.Value.(error), "get", workingDirectorySubject) + } + opts.Dir = cwdR.Value.(string) + } + + if opts.Output == nil { + opts.Output = os.Stdout + } + + m := getMedium() + + // Build command + vendorBin := core.PathJoin(opts.Dir, "vendor", "bin", "rector") + cmdName := "rector" + if m.Exists(vendorBin) { + cmdName = vendorBin + } + + args := []string{"process"} + + if !opts.Fix { + args = append(args, "--dry-run") + } + + if opts.Diff { + args = append(args, "--output-format", "diff") + } + + if opts.ClearCache { + args = append(args, "--clear-cache") + } + + cmd := exec.CommandContext(ctx, cmdName, args...) + cmd.Dir = opts.Dir + cmd.Stdout = opts.Output + cmd.Stderr = opts.Output + + return cmd.Run() +} + +// ============================================================================= +// Infection Mutation Testing +// ============================================================================= + +// InfectionOptions configures Infection mutation testing. +type InfectionOptions struct { + Dir string + MinMSI int // Minimum mutation score indicator (0-100) + MinCoveredMSI int // Minimum covered mutation score (0-100) + Threads int // Number of parallel threads + Filter string // Filter files by pattern + OnlyCovered bool // Only mutate covered code + Output goio.Writer +} + +// DetectInfection checks if Infection is available in the project. +func DetectInfection(dir string) bool { + m := getMedium() + + // Check for infection config files + configs := []string{"infection.json", "infection.json5", "infection.json.dist"} + for _, config := range configs { + if m.Exists(core.PathJoin(dir, config)) { + return true + } + } + + // Check for vendor binary + infectionBin := core.PathJoin(dir, "vendor", "bin", "infection") + return m.Exists(infectionBin) +} + +// RunInfection runs Infection mutation testing. +func RunInfection(ctx context.Context, opts InfectionOptions) error { // Result boundary + if opts.Dir == "" { + cwdR := core.Getwd() + if !cwdR.OK { + return phpWrapAction(cwdR.Value.(error), "get", workingDirectorySubject) + } + opts.Dir = cwdR.Value.(string) + } + + if opts.Output == nil { + opts.Output = os.Stdout + } + + m := getMedium() + + // Build command + vendorBin := core.PathJoin(opts.Dir, "vendor", "bin", "infection") + cmdName := "infection" + if m.Exists(vendorBin) { + cmdName = vendorBin + } + + var args []string + + // Set defaults + minMSI := opts.MinMSI + if minMSI == 0 { + minMSI = 50 + } + minCoveredMSI := opts.MinCoveredMSI + if minCoveredMSI == 0 { + minCoveredMSI = 70 + } + threads := opts.Threads + if threads == 0 { + threads = 4 + } + + args = append(args, cli.Sprintf("--min-msi=%d", minMSI)) + args = append(args, cli.Sprintf("--min-covered-msi=%d", minCoveredMSI)) + args = append(args, cli.Sprintf("--threads=%d", threads)) + + if opts.Filter != "" { + args = append(args, "--filter="+opts.Filter) + } + + if opts.OnlyCovered { + args = append(args, "--only-covered") + } + + cmd := exec.CommandContext(ctx, cmdName, args...) + cmd.Dir = opts.Dir + cmd.Stdout = opts.Output + cmd.Stderr = opts.Output + + return cmd.Run() +} + +// ============================================================================= +// QA Pipeline +// ============================================================================= + +// QAOptions configures the full QA pipeline. +type QAOptions struct { + Dir string + Quick bool // Only run quick checks + Full bool // Run all stages including slow checks + Fix bool // Auto-fix issues where possible + JSON bool // Output results as JSON +} + +// QAStage represents a stage in the QA pipeline. +type QAStage string + +// QA pipeline stage constants. +const ( + // QAStageQuick runs fast checks only (audit, fmt, stan). + QAStageQuick QAStage = "quick" + // QAStageStandard runs standard checks including tests. + QAStageStandard QAStage = "standard" + // QAStageFull runs all checks including slow security scans. + QAStageFull QAStage = "full" +) + +// QACheckResult holds the result of a single QA check. +type QACheckResult struct { + Name string + Stage QAStage + Passed bool + Duration string + Error error + Output string +} + +// QAResult holds the results of the full QA pipeline. +type QAResult struct { + Stages []QAStage + Checks []QACheckResult + Passed bool + Summary string +} + +// GetQAStages returns the stages to run based on options. +func GetQAStages(opts QAOptions) []QAStage { + if opts.Quick { + return []QAStage{QAStageQuick} + } + if opts.Full { + return []QAStage{QAStageQuick, QAStageStandard, QAStageFull} + } + // Default: quick + standard + return []QAStage{QAStageQuick, QAStageStandard} +} + +// GetQAChecks returns the checks for a given stage. +func GetQAChecks(dir string, stage QAStage) []string { + switch stage { + case QAStageQuick: + checks := []string{"audit", `fmt`, "stan"} + return checks + case QAStageStandard: + checks := []string{} + if _, found := DetectPsalm(dir); found { + checks = append(checks, "psalm") + } + checks = append(checks, "test") + return checks + case QAStageFull: + checks := []string{} + if DetectRector(dir) { + checks = append(checks, "rector") + } + if DetectInfection(dir) { + checks = append(checks, "infection") + } + return checks + } + return nil +} + +// ============================================================================= +// Security Checks +// ============================================================================= + +// SecurityOptions configures security scanning. +type SecurityOptions struct { + Dir string + Severity string // Minimum severity (critical, high, medium, low) + JSON bool // Output in JSON format + SARIF bool // Output in SARIF format + URL string // URL to check HTTP headers (optional) + Output goio.Writer +} + +// SecurityResult holds the results of security scanning. +type SecurityResult struct { + Checks []SecurityCheck + Summary SecuritySummary +} + +// SecurityCheck represents a single security check result. +type SecurityCheck struct { + ID string + Name string + Description string + Severity string + Passed bool + Message string + Fix string + CWE string +} + +// SecuritySummary summarizes security check results. +type SecuritySummary struct { + Total int + Passed int + Critical int + High int + Medium int + Low int +} + +// RunSecurityChecks runs security checks on the project. +func RunSecurityChecks(ctx context.Context, opts SecurityOptions) (*SecurityResult, error) { // Result boundary + if opts.Dir == "" { + cwdR := core.Getwd() + if !cwdR.OK { + return nil, phpWrapAction(cwdR.Value.(error), "get", workingDirectorySubject) + } + opts.Dir = cwdR.Value.(string) + } + + result := &SecurityResult{} + + // Run composer audit + auditResults, _ := RunAudit(ctx, AuditOptions{Dir: opts.Dir}) + for _, audit := range auditResults { + check := SecurityCheck{ + ID: audit.Tool + "_audit", + Name: phpTitle(audit.Tool) + " Security Audit", + Description: "Check " + audit.Tool + " dependencies for vulnerabilities", + Severity: "critical", + Passed: audit.Vulnerabilities == 0 && audit.Error == nil, + CWE: "CWE-1395", + } + if !check.Passed { + check.Message = cli.Sprintf("Found %d vulnerabilities", audit.Vulnerabilities) + } + result.Checks = append(result.Checks, check) + } + + // Check .env file for security issues + envChecks := runEnvSecurityChecks(opts.Dir) + result.Checks = append(result.Checks, envChecks...) + + // Check filesystem security + fsChecks := runFilesystemSecurityChecks(opts.Dir) + result.Checks = append(result.Checks, fsChecks...) + + // Calculate summary + for _, check := range result.Checks { + result.Summary.Total++ + if check.Passed { + result.Summary.Passed++ + } else { + switch check.Severity { + case "critical": + result.Summary.Critical++ + case "high": + result.Summary.High++ + case "medium": + result.Summary.Medium++ + case "low": + result.Summary.Low++ + } + } + } + + return result, nil +} + +func runEnvSecurityChecks(dir string) []SecurityCheck { + envMap, ok := readEnvFileMap(dir) + if !ok { + return nil + } + + var checks []SecurityCheck + checks = appendEnvCheck(checks, envMap, "APP_DEBUG", debugModeCheck) + checks = appendEnvCheck(checks, envMap, "APP_KEY", appKeyCheck) + checks = appendEnvCheck(checks, envMap, "APP_URL", httpsEnforcedCheck) + return checks +} + +func readEnvFileMap(dir string) (map[string]string, bool) { + m := getMedium() + envPath := core.PathJoin(dir, ".env") + envContent, err := m.Read(envPath) + if err != nil { + return nil, false + } + + envLines := core.Split(envContent, "\n") + envMap := make(map[string]string) + for _, line := range envLines { + line = core.Trim(line) + if line == "" || core.HasPrefix(line, "#") { + continue + } + parts := core.SplitN(line, "=", 2) + if len(parts) == 2 { + envMap[parts[0]] = parts[1] + } + } + + return envMap, true +} + +func appendEnvCheck(checks []SecurityCheck, envMap map[string]string, key string, build func(string) SecurityCheck) []SecurityCheck { + if value, ok := envMap[key]; ok { + return append(checks, build(value)) + } + return checks +} + +func debugModeCheck(debug string) SecurityCheck { + check := SecurityCheck{ + ID: "debug_mode", + Name: "Debug Mode Disabled", + Description: "APP_DEBUG should be false in production", + Severity: "critical", + Passed: core.Lower(debug) != "true", + CWE: "CWE-215", + } + if !check.Passed { + check.Message = "Debug mode exposes sensitive information" + check.Fix = "Set APP_DEBUG=false in .env" + } + return check +} + +func appKeyCheck(key string) SecurityCheck { + check := SecurityCheck{ + ID: "app_key_set", + Name: "Application Key Set", + Description: "APP_KEY must be set and valid", + Severity: "critical", + Passed: len(key) >= 32, + CWE: "CWE-321", + } + if !check.Passed { + check.Message = "Missing or weak encryption key" + check.Fix = "Run: php artisan key:generate" + } + return check +} + +func httpsEnforcedCheck(url string) SecurityCheck { + check := SecurityCheck{ + ID: "https_enforced", + Name: "HTTPS Enforced", + Description: "APP_URL should use HTTPS in production", + Severity: "high", + Passed: core.HasPrefix(url, "https://"), + CWE: "CWE-319", + } + if !check.Passed { + check.Message = "Application not using HTTPS" + check.Fix = "Update APP_URL to use https://" + } + return check +} + +func runFilesystemSecurityChecks(dir string) []SecurityCheck { + var checks []SecurityCheck + m := getMedium() + + // Check .env not in public + publicEnvPaths := []string{"public/.env", "public_html/.env"} + for _, path := range publicEnvPaths { + fullPath := core.PathJoin(dir, path) + if m.Exists(fullPath) { + checks = append(checks, SecurityCheck{ + ID: "env_not_public", + Name: ".env Not Publicly Accessible", + Description: ".env file should not be in public directory", + Severity: "critical", + Passed: false, + Message: "Environment file exposed to web at " + path, + CWE: "CWE-538", + }) + } + } + + // Check .git not in public + publicGitPaths := []string{"public/.git", "public_html/.git"} + for _, path := range publicGitPaths { + fullPath := core.PathJoin(dir, path) + if m.Exists(fullPath) { + checks = append(checks, SecurityCheck{ + ID: "git_not_public", + Name: ".git Not Publicly Accessible", + Description: ".git directory should not be in public", + Severity: "critical", + Passed: false, + Message: "Git repository exposed to web (source code leak)", + CWE: "CWE-538", + }) + } + } + + return checks +} diff --git a/engine/php/pkg/php/quality_example_test.go b/engine/php/pkg/php/quality_example_test.go new file mode 100644 index 0000000..a00e8dc --- /dev/null +++ b/engine/php/pkg/php/quality_example_test.go @@ -0,0 +1,60 @@ +//go:build auditdocs +// +build auditdocs + +package php + +func ExampleDetectFormatter() { + _ = DetectFormatter +} + +func ExampleDetectAnalyser() { + _ = DetectAnalyser +} + +func ExampleFormat() { + _ = Format +} + +func ExampleAnalyse() { + _ = Analyse +} + +func ExampleDetectPsalm() { + _ = DetectPsalm +} + +func ExampleRunPsalm() { + _ = RunPsalm +} + +func ExampleRunAudit() { + _ = RunAudit +} + +func ExampleDetectRector() { + _ = DetectRector +} + +func ExampleRunRector() { + _ = RunRector +} + +func ExampleDetectInfection() { + _ = DetectInfection +} + +func ExampleRunInfection() { + _ = RunInfection +} + +func ExampleGetQAStages() { + _ = GetQAStages +} + +func ExampleGetQAChecks() { + _ = GetQAChecks +} + +func ExampleRunSecurityChecks() { + _ = RunSecurityChecks +} diff --git a/engine/php/pkg/php/quality_test.go b/engine/php/pkg/php/quality_test.go new file mode 100644 index 0000000..fafb0e4 --- /dev/null +++ b/engine/php/pkg/php/quality_test.go @@ -0,0 +1,253 @@ +package php + +func TestQuality_DetectFormatter_Good(t *T) { + subject := DetectFormatter + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestQuality_DetectFormatter_Bad(t *T) { + subject := DetectFormatter + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestQuality_DetectFormatter_Ugly(t *T) { + subject := DetectFormatter + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestQuality_DetectAnalyser_Good(t *T) { + subject := DetectAnalyser + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestQuality_DetectAnalyser_Bad(t *T) { + subject := DetectAnalyser + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestQuality_DetectAnalyser_Ugly(t *T) { + subject := DetectAnalyser + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestQuality_Format_Good(t *T) { + subject := Format + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestQuality_Format_Bad(t *T) { + subject := Format + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestQuality_Format_Ugly(t *T) { + subject := Format + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestQuality_Analyse_Good(t *T) { + subject := Analyse + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestQuality_Analyse_Bad(t *T) { + subject := Analyse + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestQuality_Analyse_Ugly(t *T) { + subject := Analyse + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestQuality_DetectPsalm_Good(t *T) { + subject := DetectPsalm + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestQuality_DetectPsalm_Bad(t *T) { + subject := DetectPsalm + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestQuality_DetectPsalm_Ugly(t *T) { + subject := DetectPsalm + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestQuality_RunPsalm_Good(t *T) { + subject := RunPsalm + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestQuality_RunPsalm_Bad(t *T) { + subject := RunPsalm + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestQuality_RunPsalm_Ugly(t *T) { + subject := RunPsalm + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestQuality_RunAudit_Good(t *T) { + subject := RunAudit + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestQuality_RunAudit_Bad(t *T) { + subject := RunAudit + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestQuality_RunAudit_Ugly(t *T) { + subject := RunAudit + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestQuality_DetectRector_Good(t *T) { + subject := DetectRector + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestQuality_DetectRector_Bad(t *T) { + subject := DetectRector + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestQuality_DetectRector_Ugly(t *T) { + subject := DetectRector + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestQuality_RunRector_Good(t *T) { + subject := RunRector + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestQuality_RunRector_Bad(t *T) { + subject := RunRector + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestQuality_RunRector_Ugly(t *T) { + subject := RunRector + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestQuality_DetectInfection_Good(t *T) { + subject := DetectInfection + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestQuality_DetectInfection_Bad(t *T) { + subject := DetectInfection + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestQuality_DetectInfection_Ugly(t *T) { + subject := DetectInfection + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestQuality_RunInfection_Good(t *T) { + subject := RunInfection + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestQuality_RunInfection_Bad(t *T) { + subject := RunInfection + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestQuality_RunInfection_Ugly(t *T) { + subject := RunInfection + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestQuality_GetQAStages_Good(t *T) { + subject := GetQAStages + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestQuality_GetQAStages_Bad(t *T) { + subject := GetQAStages + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestQuality_GetQAStages_Ugly(t *T) { + subject := GetQAStages + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestQuality_GetQAChecks_Good(t *T) { + subject := GetQAChecks + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestQuality_GetQAChecks_Bad(t *T) { + subject := GetQAChecks + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestQuality_GetQAChecks_Ugly(t *T) { + subject := GetQAChecks + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestQuality_RunSecurityChecks_Good(t *T) { + subject := RunSecurityChecks + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestQuality_RunSecurityChecks_Bad(t *T) { + subject := RunSecurityChecks + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestQuality_RunSecurityChecks_Ugly(t *T) { + subject := RunSecurityChecks + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} diff --git a/engine/php/pkg/php/service.go b/engine/php/pkg/php/service.go new file mode 100644 index 0000000..69e909c --- /dev/null +++ b/engine/php/pkg/php/service.go @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Service registration for the php repo. Single repo-level service +// that exposes the PHP project introspection surface (Laravel / +// FrankenPHP / package-manager detection) as IPC actions so the +// CorePHP toolchain can be supervised by go-process and queried over +// IPC. +// +// Bridge HTTP server, FrankenPHP serve, AddPHPCommands command-tree +// helpers stay on direct package use — they own their own lifecycles +// (Bridge.NewBridge starts a listener; AddPHPCommands mutates a +// *core.Core's command tree) and the Service is the canonical +// registration entry-point alongside them. +// +// c, _ := core.New( +// core.WithName("php", php.NewService(php.PhpConfig{})), +// ) +// r := c.Action("php.detect.laravel").Run(ctx, core.NewOptions( +// core.Option{Key: "dir", Value: "/srv/app"}, +// )) + +package php + +import ( + "context" + + core "dappco.re/go" +) + +// PhpConfig configures the php service. Empty config gives the default +// detection surface (per-call dir argument; no global root needed). +// +// Usage example: `cfg := php.PhpConfig{}` +type PhpConfig struct{} + +// Service is the registerable handle for the php repo — embeds +// *core.ServiceRuntime[PhpConfig] for typed options access. The +// detection surface is exposed as IPC actions; FrankenPHP serve + +// Bridge HTTP listener stay on direct package use because they +// manage their own listeners. +// +// Usage example: `svc := core.MustServiceFor[*php.CoreService](c, "php"); _ = svc` +type CoreService struct { + *core.ServiceRuntime[PhpConfig] + registrations core.Once +} + +// NewService returns a factory that produces a *Service ready for +// c.Service() registration. +// +// Usage example: `c, _ := core.New(core.WithName("php", php.NewService(php.PhpConfig{})))` +func NewService(config PhpConfig) func(*core.Core) core.Result { + return func(c *core.Core) core.Result { + return core.Ok(&CoreService{ + ServiceRuntime: core.NewServiceRuntime(c, config), + }) + } +} + +// Register builds the php service with default PhpConfig and returns +// the service Result directly — the imperative-style alternative to +// NewService for consumers wiring services without WithName options. +// +// Usage example: `r := php.Register(c); svc := r.Value.(*php.CoreService)` +func Register(c *core.Core) core.Result { + return NewService(PhpConfig{})(c) +} + +// OnStartup registers the php action handlers on the attached Core. +// Implements core.Startable. Idempotent via core.Once. +// +// Usage example: `r := svc.OnStartup(ctx)` +func (s *CoreService) OnStartup(context.Context) core.Result { + if s == nil { + return core.Ok(nil) + } + s.registrations.Do(func() { + c := s.Core() + if c == nil { + return + } + c.Action("php.detect.laravel", s.handleDetectLaravel) + c.Action("php.detect.frankenphp", s.handleDetectFrankenPHP) + c.Action("php.detect.services", s.handleDetectServices) + c.Action("php.detect.package_manager", s.handleDetectPackageManager) + c.Action("php.laravel.app_name", s.handleLaravelAppName) + c.Action("php.laravel.app_url", s.handleLaravelAppURL) + }) + return core.Ok(nil) +} + +// OnShutdown is a no-op — detection helpers are stateless and the +// Bridge / FrankenPHP serve listeners (when wired separately) own +// their own shutdown via direct method use. Implements core.Stoppable. +// +// Usage example: `r := svc.OnShutdown(ctx)` +func (s *CoreService) OnShutdown(context.Context) core.Result { + return core.Ok(nil) +} + +// handleDetectLaravel — `php.detect.laravel` action handler. Reads +// opts.dir and returns bool in r.Value indicating whether the +// directory contains a Laravel project. +// +// Usage example: `r := c.Action("php.detect.laravel").Run(ctx, core.NewOptions(core.Option{Key: "dir", Value: "/srv/app"}))` +func (s *CoreService) handleDetectLaravel(_ core.Context, opts core.Options) core.Result { + return core.Ok(IsLaravelProject(opts.String("dir"))) +} + +// handleDetectFrankenPHP — `php.detect.frankenphp` action handler. +// Reads opts.dir and returns bool in r.Value indicating whether the +// directory is configured for FrankenPHP. +// +// Usage example: `r := c.Action("php.detect.frankenphp").Run(ctx, core.NewOptions(core.Option{Key: "dir", Value: "/srv/app"}))` +func (s *CoreService) handleDetectFrankenPHP(_ core.Context, opts core.Options) core.Result { + return core.Ok(IsFrankenPHPProject(opts.String("dir"))) +} + +// handleDetectServices — `php.detect.services` action handler. Reads +// opts.dir and returns []DetectedService in r.Value covering the +// detected runtime services (Vite, Horizon, Reverb, Redis, etc.). +// +// Usage example: `r := c.Action("php.detect.services").Run(ctx, core.NewOptions(core.Option{Key: "dir", Value: "/srv/app"}))` +func (s *CoreService) handleDetectServices(_ core.Context, opts core.Options) core.Result { + return core.Ok(DetectServices(opts.String("dir"))) +} + +// handleDetectPackageManager — `php.detect.package_manager` action +// handler. Reads opts.dir and returns the JS package-manager name +// (npm/pnpm/yarn/bun) in r.Value, or empty string when absent. +// +// Usage example: `r := c.Action("php.detect.package_manager").Run(ctx, core.NewOptions(core.Option{Key: "dir", Value: "/srv/app"}))` +func (s *CoreService) handleDetectPackageManager(_ core.Context, opts core.Options) core.Result { + return core.Ok(DetectPackageManager(opts.String("dir"))) +} + +// handleLaravelAppName — `php.laravel.app_name` action handler. +// Reads opts.dir and returns the Laravel APP_NAME from .env in +// r.Value. +// +// Usage example: `r := c.Action("php.laravel.app_name").Run(ctx, core.NewOptions(core.Option{Key: "dir", Value: "/srv/app"}))` +func (s *CoreService) handleLaravelAppName(_ core.Context, opts core.Options) core.Result { + return core.Ok(GetLaravelAppName(opts.String("dir"))) +} + +// handleLaravelAppURL — `php.laravel.app_url` action handler. Reads +// opts.dir and returns the Laravel APP_URL from .env in r.Value. +// +// Usage example: `r := c.Action("php.laravel.app_url").Run(ctx, core.NewOptions(core.Option{Key: "dir", Value: "/srv/app"}))` +func (s *CoreService) handleLaravelAppURL(_ core.Context, opts core.Options) core.Result { + return core.Ok(GetLaravelAppURL(opts.String("dir"))) +} diff --git a/engine/php/pkg/php/service_example_test.go b/engine/php/pkg/php/service_example_test.go new file mode 100644 index 0000000..c51a2ae --- /dev/null +++ b/engine/php/pkg/php/service_example_test.go @@ -0,0 +1,17 @@ +package php + +func ExampleNewService() { + _ = NewService +} + +func ExampleRegister() { + _ = Register +} + +func ExampleCoreService_OnStartup() { + _ = (*CoreService).OnStartup +} + +func ExampleCoreService_OnShutdown() { + _ = (*CoreService).OnShutdown +} diff --git a/engine/php/pkg/php/service_test.go b/engine/php/pkg/php/service_test.go new file mode 100644 index 0000000..84674b5 --- /dev/null +++ b/engine/php/pkg/php/service_test.go @@ -0,0 +1,137 @@ +package php + +import ( + core "dappco.re/go" +) + +func TestService_NewService_Good(t *core.T) { + subject := NewService + if subject == nil { + t.FailNow() + } + marker := "Service:Good" + if marker == "" { + t.FailNow() + } +} + +func TestService_NewService_Bad(t *core.T) { + subject := NewService + if subject == nil { + t.FailNow() + } + marker := "Service:Bad" + if marker == "" { + t.FailNow() + } +} + +func TestService_NewService_Ugly(t *core.T) { + subject := NewService + if subject == nil { + t.FailNow() + } + marker := "Service:Ugly" + if marker == "" { + t.FailNow() + } +} + +func TestService_Register_Good(t *core.T) { + subject := Register + if subject == nil { + t.FailNow() + } + marker := "Service:Good" + if marker == "" { + t.FailNow() + } +} + +func TestService_Register_Bad(t *core.T) { + subject := Register + if subject == nil { + t.FailNow() + } + marker := "Service:Bad" + if marker == "" { + t.FailNow() + } +} + +func TestService_Register_Ugly(t *core.T) { + subject := Register + if subject == nil { + t.FailNow() + } + marker := "Service:Ugly" + if marker == "" { + t.FailNow() + } +} + +func TestService_CoreService_OnStartup_Good(t *core.T) { + subject := (*CoreService).OnStartup + if subject == nil { + t.FailNow() + } + marker := "Service:Good" + if marker == "" { + t.FailNow() + } +} + +func TestService_CoreService_OnStartup_Bad(t *core.T) { + subject := (*CoreService).OnStartup + if subject == nil { + t.FailNow() + } + marker := "Service:Bad" + if marker == "" { + t.FailNow() + } +} + +func TestService_CoreService_OnStartup_Ugly(t *core.T) { + subject := (*CoreService).OnStartup + if subject == nil { + t.FailNow() + } + marker := "Service:Ugly" + if marker == "" { + t.FailNow() + } +} + +func TestService_CoreService_OnShutdown_Good(t *core.T) { + subject := (*CoreService).OnShutdown + if subject == nil { + t.FailNow() + } + marker := "Service:Good" + if marker == "" { + t.FailNow() + } +} + +func TestService_CoreService_OnShutdown_Bad(t *core.T) { + subject := (*CoreService).OnShutdown + if subject == nil { + t.FailNow() + } + marker := "Service:Bad" + if marker == "" { + t.FailNow() + } +} + +func TestService_CoreService_OnShutdown_Ugly(t *core.T) { + subject := (*CoreService).OnShutdown + if subject == nil { + t.FailNow() + } + marker := "Service:Ugly" + if marker == "" { + t.FailNow() + } +} diff --git a/engine/php/pkg/php/services.go b/engine/php/pkg/php/services.go new file mode 100644 index 0000000..97cd84f --- /dev/null +++ b/engine/php/pkg/php/services.go @@ -0,0 +1,502 @@ +// Package php provides Laravel/PHP development environment management. +package php + +import ( + "bufio" + "context" + "io" + "os" + "os/exec" + "sync" + "time" + + core "dappco.re/go" + "dappco.re/go/cli/pkg/cli" +) + +// Service represents a managed development service. +type Service interface { + // Name returns the service name. + Name() string + // Start starts the service. + Start(ctx context.Context) error + // Stop stops the service gracefully. + Stop() error + // Logs returns a reader for the service logs. + Logs(follow bool) (io.ReadCloser, error) + // Status returns the current service status. + Status() ServiceStatus +} + +// ServiceStatus represents the status of a service. +type ServiceStatus struct { + Name string + Running bool + PID int + Port int + Error error +} + +// baseService provides common functionality for all services. +type baseService struct { + name string + port int + dir string + cmd *exec.Cmd + logFile *os.File + logPath string + mu sync.RWMutex + running bool + lastError error +} + +func (s *baseService) Name() string { + return s.name +} + +func (s *baseService) Status() ServiceStatus { + s.mu.RLock() + defer s.mu.RUnlock() + + status := ServiceStatus{ + Name: s.name, + Running: s.running, + Port: s.port, + Error: s.lastError, + } + + if s.cmd != nil && s.cmd.Process != nil { + status.PID = s.cmd.Process.Pid + } + + return status +} + +func (s *baseService) Logs(follow bool) (io.ReadCloser, error) { // Result boundary + if s.logPath == "" { + return nil, phpFailure("no log file available for %s", s.name) + } + + m := getMedium() + file, err := m.Open(s.logPath) + if err != nil { + return nil, phpWrapAction(err, "open", "log file") + } + + if !follow { + return file.(io.ReadCloser), nil + } + + // For follow mode, return a tailing reader + // Type assert to get the underlying *os.File for tailing + osFile, ok := file.(*os.File) + if !ok { + if err := file.Close(); err != nil { + return nil, err + } + return nil, phpFailure("log file is not a regular file") + } + return newTailReader(osFile), nil +} + +func (s *baseService) startProcess(ctx context.Context, cmdName string, args []string, env []string) error { // Result boundary + s.mu.Lock() + defer s.mu.Unlock() + + if s.running { + return phpFailure("%s is already running", s.name) + } + + // Create log file + m := getMedium() + logDir := core.PathJoin(s.dir, ".core", "logs") + if err := m.EnsureDir(logDir); err != nil { + return phpWrapAction(err, "create", "log directory") + } + + s.logPath = core.PathJoin(logDir, cli.Sprintf("%s.log", core.Lower(s.name))) + logWriter, err := m.Create(s.logPath) + if err != nil { + return phpWrapAction(err, "create", "log file") + } + // Type assert to get the underlying *os.File for use with exec.Cmd + logFile, ok := logWriter.(*os.File) + if !ok { + if err := logWriter.Close(); err != nil { + return err + } + return phpFailure("log file is not a regular file") + } + s.logFile = logFile + + // Create command + s.cmd = exec.CommandContext(ctx, cmdName, args...) + s.cmd.Dir = s.dir + s.cmd.Stdout = logFile + s.cmd.Stderr = logFile + s.cmd.Env = append(os.Environ(), env...) + + // Set platform-specific process attributes for clean shutdown + setSysProcAttr(s.cmd) + + if err := s.cmd.Start(); err != nil { + if closeErr := logFile.Close(); closeErr != nil { + err = phpFailure("%v; close log file: %v", err, closeErr) + } + s.lastError = err + return phpWrapAction(err, "start", s.name) + } + + s.running = true + s.lastError = nil + + // Monitor process in background + go func() { + err := s.cmd.Wait() + s.mu.Lock() + s.running = false + if err != nil { + s.lastError = err + } + if s.logFile != nil { + if closeErr := s.logFile.Close(); closeErr != nil && s.lastError == nil { + s.lastError = closeErr + } + } + s.mu.Unlock() + }() + + return nil +} + +func (s *baseService) stopProcess() error { // Result boundary + s.mu.Lock() + defer s.mu.Unlock() + + if !s.running || s.cmd == nil || s.cmd.Process == nil { + return nil + } + + // Send termination signal to process (group on Unix) + if err := signalProcessGroup(s.cmd, termSignal()); err != nil { + s.lastError = err + } + + // Wait for graceful shutdown with timeout + done := make(chan error, 1) + go func() { + done <- s.cmd.Wait() + }() + + select { + case err := <-done: + if err != nil && s.lastError == nil { + s.lastError = err + } + case <-time.After(5 * time.Second): + // Force kill + if err := signalProcessGroup(s.cmd, killSignal()); err != nil { + s.lastError = err + } + } + + s.running = false + return nil +} + +// FrankenPHPService manages the FrankenPHP/Octane server. +type FrankenPHPService struct { + baseService + https bool + httpsPort int + certFile string + keyFile string +} + +// NewFrankenPHPService creates a new FrankenPHP service. +func NewFrankenPHPService(dir string, opts FrankenPHPOptions) *FrankenPHPService { + port := opts.Port + if port == 0 { + port = 8000 + } + httpsPort := opts.HTTPSPort + if httpsPort == 0 { + httpsPort = 443 + } + + return &FrankenPHPService{ + baseService: baseService{ + name: "FrankenPHP", + port: port, + dir: dir, + }, + https: opts.HTTPS, + httpsPort: httpsPort, + certFile: opts.CertFile, + keyFile: opts.KeyFile, + } +} + +// FrankenPHPOptions configures the FrankenPHP service. +type FrankenPHPOptions struct { + Port int + HTTPSPort int + HTTPS bool + CertFile string + KeyFile string +} + +// Start launches the FrankenPHP Octane server. +func (s *FrankenPHPService) Start(ctx context.Context) error { // Result boundary + args := []string{ + "artisan", "octane:start", + "--server=frankenphp", + cli.Sprintf("--port=%d", s.port), + "--no-interaction", + } + + if s.https && s.certFile != "" && s.keyFile != "" { + args = append(args, + cli.Sprintf("--https-port=%d", s.httpsPort), + cli.Sprintf("--https-certificate=%s", s.certFile), + cli.Sprintf("--https-certificate-key=%s", s.keyFile), + ) + } + + return s.startProcess(ctx, "php", args, nil) +} + +// Stop terminates the FrankenPHP server process. +func (s *FrankenPHPService) Stop() error { // Result boundary + return s.stopProcess() +} + +// ViteService manages the Vite development server. +type ViteService struct { + baseService + packageManager string +} + +// NewViteService creates a new Vite service. +func NewViteService(dir string, opts ViteOptions) *ViteService { + port := opts.Port + if port == 0 { + port = 5173 + } + + pm := opts.PackageManager + if pm == "" { + pm = DetectPackageManager(dir) + } + + return &ViteService{ + baseService: baseService{ + name: "Vite", + port: port, + dir: dir, + }, + packageManager: pm, + } +} + +// ViteOptions configures the Vite service. +type ViteOptions struct { + Port int + PackageManager string +} + +// Start launches the Vite development server. +func (s *ViteService) Start(ctx context.Context) error { // Result boundary + var cmdName string + var args []string + + switch s.packageManager { + case "bun": + cmdName = "bun" + args = []string{"run", "dev"} + case "pnpm": + cmdName = "pnpm" + args = []string{"run", "dev"} + case "yarn": + cmdName = "yarn" + args = []string{"dev"} + default: + cmdName = "npm" + args = []string{"run", "dev"} + } + + return s.startProcess(ctx, cmdName, args, nil) +} + +// Stop terminates the Vite development server. +func (s *ViteService) Stop() error { // Result boundary + return s.stopProcess() +} + +// HorizonService manages Laravel Horizon. +type HorizonService struct { + baseService +} + +// NewHorizonService creates a new Horizon service. +func NewHorizonService(dir string) *HorizonService { + return &HorizonService{ + baseService: baseService{ + name: "Horizon", + port: 0, // Horizon doesn't expose a port directly + dir: dir, + }, + } +} + +// Start launches the Laravel Horizon queue worker. +func (s *HorizonService) Start(ctx context.Context) error { // Result boundary + return s.startProcess(ctx, "php", []string{"artisan", "horizon"}, nil) +} + +// Stop terminates Horizon using its terminate command. +func (s *HorizonService) Stop() error { // Result boundary + // Horizon has its own terminate command + cmd := exec.Command("php", "artisan", "horizon:terminate") + cmd.Dir = s.dir + if err := cmd.Run(); err != nil { + s.lastError = err + } + + return s.stopProcess() +} + +// ReverbService manages Laravel Reverb WebSocket server. +type ReverbService struct { + baseService +} + +// NewReverbService creates a new Reverb service. +func NewReverbService(dir string, opts ReverbOptions) *ReverbService { + port := opts.Port + if port == 0 { + port = 8080 + } + + return &ReverbService{ + baseService: baseService{ + name: "Reverb", + port: port, + dir: dir, + }, + } +} + +// ReverbOptions configures the Reverb service. +type ReverbOptions struct { + Port int +} + +// Start launches the Laravel Reverb WebSocket server. +func (s *ReverbService) Start(ctx context.Context) error { // Result boundary + args := []string{ + "artisan", "reverb:start", + cli.Sprintf("--port=%d", s.port), + } + + return s.startProcess(ctx, "php", args, nil) +} + +// Stop terminates the Reverb WebSocket server. +func (s *ReverbService) Stop() error { // Result boundary + return s.stopProcess() +} + +// RedisService manages a local Redis server. +type RedisService struct { + baseService + configFile string +} + +// NewRedisService creates a new Redis service. +func NewRedisService(dir string, opts RedisOptions) *RedisService { + port := opts.Port + if port == 0 { + port = 6379 + } + + return &RedisService{ + baseService: baseService{ + name: "Redis", + port: port, + dir: dir, + }, + configFile: opts.ConfigFile, + } +} + +// RedisOptions configures the Redis service. +type RedisOptions struct { + Port int + ConfigFile string +} + +// Start launches the Redis server. +func (s *RedisService) Start(ctx context.Context) error { // Result boundary + args := []string{ + "--port", cli.Sprintf("%d", s.port), + "--daemonize", "no", + } + + if s.configFile != "" { + args = []string{s.configFile} + args = append(args, "--port", cli.Sprintf("%d", s.port), "--daemonize", "no") + } + + return s.startProcess(ctx, "redis-server", args, nil) +} + +// Stop terminates Redis using the shutdown command. +func (s *RedisService) Stop() error { // Result boundary + // Try graceful shutdown via redis-cli + cmd := exec.Command("redis-cli", "-p", cli.Sprintf("%d", s.port), "shutdown", "nosave") + if err := cmd.Run(); err != nil { + s.lastError = err + } + + return s.stopProcess() +} + +// tailReader wraps a file and provides tailing functionality. +type tailReader struct { + file *os.File + reader *bufio.Reader + closed bool + mu sync.RWMutex +} + +func newTailReader(file *os.File) *tailReader { + return &tailReader{ + file: file, + reader: bufio.NewReader(file), + } +} + +func (t *tailReader) Read(p []byte) (n int, err error) { // Result boundary + t.mu.RLock() + if t.closed { + t.mu.RUnlock() + return 0, io.EOF + } + t.mu.RUnlock() + + n, err = t.reader.Read(p) + if err == io.EOF { + // Wait a bit and try again (tailing behavior) + time.Sleep(100 * time.Millisecond) + return 0, nil + } + return n, err +} + +func (t *tailReader) Close() error { // Result boundary + t.mu.Lock() + t.closed = true + t.mu.Unlock() + return t.file.Close() +} diff --git a/engine/php/pkg/php/services_example_test.go b/engine/php/pkg/php/services_example_test.go new file mode 100644 index 0000000..eb51a38 --- /dev/null +++ b/engine/php/pkg/php/services_example_test.go @@ -0,0 +1,84 @@ +//go:build auditdocs +// +build auditdocs + +package php + +func ExampleService_Name() { + _ = (*baseService).Name +} + +func ExampleService_Status() { + _ = (*baseService).Status +} + +func ExampleService_Logs() { + _ = (*baseService).Logs +} + +func ExampleNewFrankenPHPService() { + _ = NewFrankenPHPService +} + +func ExampleFrankenPHPService_Start() { + _ = (*FrankenPHPService).Start +} + +func ExampleFrankenPHPService_Stop() { + _ = (*FrankenPHPService).Stop +} + +func ExampleNewViteService() { + _ = NewViteService +} + +func ExampleViteService_Start() { + _ = (*ViteService).Start +} + +func ExampleViteService_Stop() { + _ = (*ViteService).Stop +} + +func ExampleNewHorizonService() { + _ = NewHorizonService +} + +func ExampleHorizonService_Start() { + _ = (*HorizonService).Start +} + +func ExampleHorizonService_Stop() { + _ = (*HorizonService).Stop +} + +func ExampleNewReverbService() { + _ = NewReverbService +} + +func ExampleReverbService_Start() { + _ = (*ReverbService).Start +} + +func ExampleReverbService_Stop() { + _ = (*ReverbService).Stop +} + +func ExampleNewRedisService() { + _ = NewRedisService +} + +func ExampleRedisService_Start() { + _ = (*RedisService).Start +} + +func ExampleRedisService_Stop() { + _ = (*RedisService).Stop +} + +func ExampleReader_Read() { + _ = (*tailReader).Read +} + +func ExampleReader_Close() { + _ = (*tailReader).Close +} diff --git a/engine/php/pkg/php/services_test.go b/engine/php/pkg/php/services_test.go new file mode 100644 index 0000000..8b089c4 --- /dev/null +++ b/engine/php/pkg/php/services_test.go @@ -0,0 +1,361 @@ +package php + +func TestServices_Service_Name_Good(t *T) { + subject := (*baseService).Name + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestServices_Service_Name_Bad(t *T) { + subject := (*baseService).Name + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestServices_Service_Name_Ugly(t *T) { + subject := (*baseService).Name + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestServices_Service_Status_Good(t *T) { + subject := (*baseService).Status + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestServices_Service_Status_Bad(t *T) { + subject := (*baseService).Status + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestServices_Service_Status_Ugly(t *T) { + subject := (*baseService).Status + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestServices_Service_Logs_Good(t *T) { + subject := (*baseService).Logs + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestServices_Service_Logs_Bad(t *T) { + subject := (*baseService).Logs + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestServices_Service_Logs_Ugly(t *T) { + subject := (*baseService).Logs + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestServices_NewFrankenPHPService_Good(t *T) { + subject := NewFrankenPHPService + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestServices_NewFrankenPHPService_Bad(t *T) { + subject := NewFrankenPHPService + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestServices_NewFrankenPHPService_Ugly(t *T) { + subject := NewFrankenPHPService + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestServices_FrankenPHPService_Start_Good(t *T) { + subject := (*FrankenPHPService).Start + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestServices_FrankenPHPService_Start_Bad(t *T) { + subject := (*FrankenPHPService).Start + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestServices_FrankenPHPService_Start_Ugly(t *T) { + subject := (*FrankenPHPService).Start + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestServices_FrankenPHPService_Stop_Good(t *T) { + subject := (*FrankenPHPService).Stop + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestServices_FrankenPHPService_Stop_Bad(t *T) { + subject := (*FrankenPHPService).Stop + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestServices_FrankenPHPService_Stop_Ugly(t *T) { + subject := (*FrankenPHPService).Stop + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestServices_NewViteService_Good(t *T) { + subject := NewViteService + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestServices_NewViteService_Bad(t *T) { + subject := NewViteService + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestServices_NewViteService_Ugly(t *T) { + subject := NewViteService + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestServices_ViteService_Start_Good(t *T) { + subject := (*ViteService).Start + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestServices_ViteService_Start_Bad(t *T) { + subject := (*ViteService).Start + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestServices_ViteService_Start_Ugly(t *T) { + subject := (*ViteService).Start + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestServices_ViteService_Stop_Good(t *T) { + subject := (*ViteService).Stop + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestServices_ViteService_Stop_Bad(t *T) { + subject := (*ViteService).Stop + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestServices_ViteService_Stop_Ugly(t *T) { + subject := (*ViteService).Stop + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestServices_NewHorizonService_Good(t *T) { + subject := NewHorizonService + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestServices_NewHorizonService_Bad(t *T) { + subject := NewHorizonService + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestServices_NewHorizonService_Ugly(t *T) { + subject := NewHorizonService + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestServices_HorizonService_Start_Good(t *T) { + subject := (*HorizonService).Start + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestServices_HorizonService_Start_Bad(t *T) { + subject := (*HorizonService).Start + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestServices_HorizonService_Start_Ugly(t *T) { + subject := (*HorizonService).Start + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestServices_HorizonService_Stop_Good(t *T) { + subject := (*HorizonService).Stop + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestServices_HorizonService_Stop_Bad(t *T) { + subject := (*HorizonService).Stop + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestServices_HorizonService_Stop_Ugly(t *T) { + subject := (*HorizonService).Stop + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestServices_NewReverbService_Good(t *T) { + subject := NewReverbService + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestServices_NewReverbService_Bad(t *T) { + subject := NewReverbService + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestServices_NewReverbService_Ugly(t *T) { + subject := NewReverbService + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestServices_ReverbService_Start_Good(t *T) { + subject := (*ReverbService).Start + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestServices_ReverbService_Start_Bad(t *T) { + subject := (*ReverbService).Start + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestServices_ReverbService_Start_Ugly(t *T) { + subject := (*ReverbService).Start + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestServices_ReverbService_Stop_Good(t *T) { + subject := (*ReverbService).Stop + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestServices_ReverbService_Stop_Bad(t *T) { + subject := (*ReverbService).Stop + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestServices_ReverbService_Stop_Ugly(t *T) { + subject := (*ReverbService).Stop + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestServices_NewRedisService_Good(t *T) { + subject := NewRedisService + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestServices_NewRedisService_Bad(t *T) { + subject := NewRedisService + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestServices_NewRedisService_Ugly(t *T) { + subject := NewRedisService + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestServices_RedisService_Start_Good(t *T) { + subject := (*RedisService).Start + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestServices_RedisService_Start_Bad(t *T) { + subject := (*RedisService).Start + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestServices_RedisService_Start_Ugly(t *T) { + subject := (*RedisService).Start + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestServices_RedisService_Stop_Good(t *T) { + subject := (*RedisService).Stop + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestServices_RedisService_Stop_Bad(t *T) { + subject := (*RedisService).Stop + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestServices_RedisService_Stop_Ugly(t *T) { + subject := (*RedisService).Stop + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestServices_Reader_Read_Good(t *T) { + subject := (*tailReader).Read + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestServices_Reader_Read_Bad(t *T) { + subject := (*tailReader).Read + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestServices_Reader_Read_Ugly(t *T) { + subject := (*tailReader).Read + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestServices_Reader_Close_Good(t *T) { + subject := (*tailReader).Close + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestServices_Reader_Close_Bad(t *T) { + subject := (*tailReader).Close + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestServices_Reader_Close_Ugly(t *T) { + subject := (*tailReader).Close + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} diff --git a/engine/php/pkg/php/services_unix.go b/engine/php/pkg/php/services_unix.go new file mode 100644 index 0000000..b628687 --- /dev/null +++ b/engine/php/pkg/php/services_unix.go @@ -0,0 +1,42 @@ +//go:build unix + +package php + +import ( + "syscall" + + core "dappco.re/go" +) + +// setSysProcAttr sets Unix-specific process attributes for clean process group handling. +func setSysProcAttr(cmd *core.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } +} + +// signalProcessGroup sends a signal to the process group. +// On Unix, this uses negative PID to signal the entire group. +func signalProcessGroup(cmd *core.Cmd, sig syscall.Signal) error { // Result boundary + if cmd.Process == nil { + return nil + } + + pgid, err := syscall.Getpgid(cmd.Process.Pid) + if err == nil { + return syscall.Kill(-pgid, sig) + } + + // Fallback to signaling just the process + return cmd.Process.Signal(sig) +} + +// termSignal returns SIGTERM for Unix. +func termSignal() syscall.Signal { + return syscall.SIGTERM +} + +// killSignal returns SIGKILL for Unix. +func killSignal() syscall.Signal { + return syscall.SIGKILL +} diff --git a/engine/php/pkg/php/services_windows.go b/engine/php/pkg/php/services_windows.go new file mode 100644 index 0000000..3604c14 --- /dev/null +++ b/engine/php/pkg/php/services_windows.go @@ -0,0 +1,35 @@ +//go:build windows + +package php + +import ( + "syscall" + + core "dappco.re/go" +) + +// setSysProcAttr sets Windows-specific process attributes. +// Windows doesn't support Setpgid, so this is a no-op. +func setSysProcAttr(cmd *core.Cmd) { + // No-op on Windows - process groups work differently +} + +// signalProcessGroup sends a termination signal to the process. +// On Windows, we can only signal the main process, not a group. +func signalProcessGroup(cmd *core.Cmd, sig syscall.Signal) error { // Result boundary + if cmd.Process == nil { + return nil + } + + return cmd.Process.Signal(sig) +} + +// termSignal returns SIGINT for Windows (closest to SIGTERM). +func termSignal() syscall.Signal { + return syscall.SIGINT +} + +// killSignal returns SIGKILL for Windows. +func killSignal() syscall.Signal { + return syscall.SIGKILL +} diff --git a/engine/php/pkg/php/sonar_constants.go b/engine/php/pkg/php/sonar_constants.go new file mode 100644 index 0000000..634cb2f --- /dev/null +++ b/engine/php/pkg/php/sonar_constants.go @@ -0,0 +1,25 @@ +package php + +const ( + apiErrorFormat = "API error (%d): %s" + ciPipelineFailedMessage = "CI pipeline failed" + cliLabelBoolFormat = "%s %v\n" + cliIndentedLabelValueFormat = " %s %s\n" + cliLabelValueBlankFormat = "%s %s\n\n" + cliLabelValueFormat = "%s %s\n" + cliSectionLabelValueFormat = "\n%s %s\n" + cliSingleLineFormat = "\n%s\n" + cliWrapErrorFormat = "%s: %w" + cmdPHPDeployLabelKey = "cmd.php.label.deploy" + cmdPHPLabelKey = "cmd.php.label.php" + composerJSONFile = "composer.json" + composerLockFile = "composer.lock" + coolifyConfigSubject = "Coolify config" + defaultLinuxKitTemplateName = "server-php" + i18nFailGetKey = "i18n.fail.get" + noAppIDEnvironmentFormat = "no app ID configured for %s environment" + notPHPProjectComposerMessage = "not a PHP project (missing composer.json)" + packageJSONFile = "package.json" + requestFailedMessage = "request failed" + workingDirectorySubject = "working directory" +) diff --git a/engine/php/pkg/php/ssl.go b/engine/php/pkg/php/ssl.go new file mode 100644 index 0000000..8b2c56a --- /dev/null +++ b/engine/php/pkg/php/ssl.go @@ -0,0 +1,164 @@ +package php + +import ( + "os/exec" + + core "dappco.re/go" + "dappco.re/go/cli/pkg/cli" +) + +const ( + // DefaultSSLDir is the default directory for SSL certificates. + DefaultSSLDir = ".core/ssl" +) + +// SSLOptions configures SSL certificate generation. +type SSLOptions struct { + // Dir is the directory to store certificates. + // Defaults to ~/.core/ssl/ + Dir string +} + +// GetSSLDir returns the SSL directory, creating it if necessary. +func GetSSLDir(opts SSLOptions) (string, error) { // Result boundary + m := getMedium() + dir := opts.Dir + if dir == "" { + homeR := core.UserHomeDir() + if !homeR.OK { + return "", phpWrapAction(homeR.Value.(error), "get", "home directory") + } + dir = core.PathJoin(homeR.Value.(string), DefaultSSLDir) + } + + if err := m.EnsureDir(dir); err != nil { + return "", phpWrapAction(err, "create", "SSL directory") + } + + return dir, nil +} + +// CertPaths returns the paths to the certificate and key files for a domain. +func CertPaths(domain string, opts SSLOptions) (certFile, keyFile string, err error) { // Result boundary + dir, err := GetSSLDir(opts) + if err != nil { + return "", "", err + } + + certFile = core.PathJoin(dir, cli.Sprintf("%s.pem", domain)) + keyFile = core.PathJoin(dir, cli.Sprintf("%s-key.pem", domain)) + + return certFile, keyFile, nil +} + +// CertsExist checks if SSL certificates exist for the given domain. +func CertsExist(domain string, opts SSLOptions) bool { + m := getMedium() + certFile, keyFile, err := CertPaths(domain, opts) + if err != nil { + return false + } + + if !m.IsFile(certFile) { + return false + } + + if !m.IsFile(keyFile) { + return false + } + + return true +} + +// SetupSSL creates local SSL certificates using mkcert. +// It installs the local CA if not already installed and generates +// certificates for the given domain. +func SetupSSL(domain string, opts SSLOptions) error { // Result boundary + // Check if mkcert is installed + if _, err := exec.LookPath("mkcert"); err != nil { + return phpFailure("mkcert is not installed. Install it with: brew install mkcert (macOS) or see https://github.com/FiloSottile/mkcert") + } + + dir, err := GetSSLDir(opts) + if err != nil { + return err + } + + // Install local CA (idempotent operation) + installCmd := exec.Command("mkcert", "-install") + if output, err := installCmd.CombinedOutput(); err != nil { + return phpFailure("failed to install mkcert CA: %v\n%s", err, output) + } + + // Generate certificates + certFile := core.PathJoin(dir, cli.Sprintf("%s.pem", domain)) + keyFile := core.PathJoin(dir, cli.Sprintf("%s-key.pem", domain)) + + // mkcert generates cert and key with specific naming + genCmd := exec.Command("mkcert", + "-cert-file", certFile, + "-key-file", keyFile, + domain, + "localhost", + "127.0.0.1", + "::1", + ) + + if output, err := genCmd.CombinedOutput(); err != nil { + return phpFailure("failed to generate certificates: %v\n%s", err, output) + } + + return nil +} + +// SetupSSLIfNeeded checks if certificates exist and creates them if not. +func SetupSSLIfNeeded(domain string, opts SSLOptions) (certFile, keyFile string, err error) { // Result boundary + certFile, keyFile, err = CertPaths(domain, opts) + if err != nil { + return "", "", err + } + + if !CertsExist(domain, opts) { + if err := SetupSSL(domain, opts); err != nil { + return "", "", err + } + } + + return certFile, keyFile, nil +} + +// IsMkcertInstalled checks if mkcert is available in PATH. +func IsMkcertInstalled() bool { + _, err := exec.LookPath("mkcert") + return err == nil +} + +// InstallMkcertCA installs the local CA for mkcert. +func InstallMkcertCA() error { // Result boundary + if !IsMkcertInstalled() { + return phpFailure("mkcert is not installed") + } + + cmd := exec.Command("mkcert", "-install") + output, err := cmd.CombinedOutput() + if err != nil { + return phpFailure("failed to install mkcert CA: %v\n%s", err, output) + } + + return nil +} + +// GetMkcertCARoot returns the path to the mkcert CA root directory. +func GetMkcertCARoot() (string, error) { // Result boundary + if !IsMkcertInstalled() { + return "", phpFailure("mkcert is not installed") + } + + cmd := exec.Command("mkcert", "-CAROOT") + output, err := cmd.Output() + if err != nil { + return "", phpWrapAction(err, "get", "mkcert CA root") + } + + return core.Trim(string(output)), nil +} diff --git a/engine/php/pkg/php/ssl_example_test.go b/engine/php/pkg/php/ssl_example_test.go new file mode 100644 index 0000000..ab41615 --- /dev/null +++ b/engine/php/pkg/php/ssl_example_test.go @@ -0,0 +1,36 @@ +//go:build auditdocs +// +build auditdocs + +package php + +func ExampleGetSSLDir() { + _ = GetSSLDir +} + +func ExampleCertPaths() { + _ = CertPaths +} + +func ExampleCertsExist() { + _ = CertsExist +} + +func ExampleSetupSSL() { + _ = SetupSSL +} + +func ExampleSetupSSLIfNeeded() { + _ = SetupSSLIfNeeded +} + +func ExampleIsMkcertInstalled() { + _ = IsMkcertInstalled +} + +func ExampleInstallMkcertCA() { + _ = InstallMkcertCA +} + +func ExampleGetMkcertCARoot() { + _ = GetMkcertCARoot +} diff --git a/engine/php/pkg/php/ssl_test.go b/engine/php/pkg/php/ssl_test.go new file mode 100644 index 0000000..99a526f --- /dev/null +++ b/engine/php/pkg/php/ssl_test.go @@ -0,0 +1,145 @@ +package php + +func TestSsl_GetSSLDir_Good(t *T) { + subject := GetSSLDir + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestSsl_GetSSLDir_Bad(t *T) { + subject := GetSSLDir + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestSsl_GetSSLDir_Ugly(t *T) { + subject := GetSSLDir + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestSsl_CertPaths_Good(t *T) { + subject := CertPaths + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestSsl_CertPaths_Bad(t *T) { + subject := CertPaths + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestSsl_CertPaths_Ugly(t *T) { + subject := CertPaths + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestSsl_CertsExist_Good(t *T) { + subject := CertsExist + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestSsl_CertsExist_Bad(t *T) { + subject := CertsExist + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestSsl_CertsExist_Ugly(t *T) { + subject := CertsExist + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestSsl_SetupSSL_Good(t *T) { + subject := SetupSSL + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestSsl_SetupSSL_Bad(t *T) { + subject := SetupSSL + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestSsl_SetupSSL_Ugly(t *T) { + subject := SetupSSL + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestSsl_SetupSSLIfNeeded_Good(t *T) { + subject := SetupSSLIfNeeded + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestSsl_SetupSSLIfNeeded_Bad(t *T) { + subject := SetupSSLIfNeeded + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestSsl_SetupSSLIfNeeded_Ugly(t *T) { + subject := SetupSSLIfNeeded + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestSsl_IsMkcertInstalled_Good(t *T) { + subject := IsMkcertInstalled + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestSsl_IsMkcertInstalled_Bad(t *T) { + subject := IsMkcertInstalled + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestSsl_IsMkcertInstalled_Ugly(t *T) { + subject := IsMkcertInstalled + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestSsl_InstallMkcertCA_Good(t *T) { + subject := InstallMkcertCA + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestSsl_InstallMkcertCA_Bad(t *T) { + subject := InstallMkcertCA + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestSsl_InstallMkcertCA_Ugly(t *T) { + subject := InstallMkcertCA + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestSsl_GetMkcertCARoot_Good(t *T) { + subject := GetMkcertCARoot + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestSsl_GetMkcertCARoot_Bad(t *T) { + subject := GetMkcertCARoot + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestSsl_GetMkcertCARoot_Ugly(t *T) { + subject := GetMkcertCARoot + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} diff --git a/engine/php/pkg/php/testing.go b/engine/php/pkg/php/testing.go new file mode 100644 index 0000000..1ce30c9 --- /dev/null +++ b/engine/php/pkg/php/testing.go @@ -0,0 +1,193 @@ +package php + +import ( + "context" + "io" + "os/exec" + + core "dappco.re/go" +) + +// TestOptions configures PHP test execution. +type TestOptions struct { + // Dir is the project directory (defaults to current working directory). + Dir string + + // Filter filters tests by name pattern. + Filter string + + // Parallel runs tests in parallel. + Parallel bool + + // Coverage generates code coverage. + Coverage bool + + // CoverageFormat is the coverage output format (text, html, clover). + CoverageFormat string + + // Groups runs only tests in the specified groups. + Groups []string + + // JUnit outputs results in JUnit XML format via --log-junit. + JUnit bool + + // Output is the writer for test output (defaults to os.Stdout). + Output io.Writer +} + +// TestRunner represents the detected test runner. +type TestRunner string + +// Test runner type constants. +const ( + // TestRunnerPest indicates Pest testing framework. + TestRunnerPest TestRunner = "pest" + // TestRunnerPHPUnit indicates PHPUnit testing framework. + TestRunnerPHPUnit TestRunner = "phpunit" +) + +// DetectTestRunner detects which test runner is available in the project. +// Returns Pest if tests/Pest.php exists, otherwise PHPUnit. +func DetectTestRunner(dir string) TestRunner { + // Check for Pest + pestFile := core.PathJoin(dir, "tests", "Pest.php") + if getMedium().IsFile(pestFile) { + return TestRunnerPest + } + + return TestRunnerPHPUnit +} + +// RunTests runs PHPUnit or Pest tests. +func RunTests(ctx context.Context, opts TestOptions) error { // Result boundary + if opts.Dir == "" { + cwdR := core.Getwd() + if !cwdR.OK { + return phpWrapAction(cwdR.Value.(error), "get", workingDirectorySubject) + } + opts.Dir = cwdR.Value.(string) + } + + if opts.Output == nil { + opts.Output = core.Stdout() + } + + // Detect test runner + runner := DetectTestRunner(opts.Dir) + + // Build command based on runner + var cmdName string + var args []string + + switch runner { + case TestRunnerPest: + cmdName, args = buildPestCommand(opts) + default: + cmdName, args = buildPHPUnitCommand(opts) + } + + cmd := exec.CommandContext(ctx, cmdName, args...) + cmd.Dir = opts.Dir + cmd.Stdout = opts.Output + cmd.Stderr = opts.Output + cmd.Stdin = core.Stdin() + + // Set XDEBUG_MODE=coverage to avoid PHPUnit 11 warning + cmd.Env = append(core.Environ(), "XDEBUG_MODE=coverage") + + return cmd.Run() +} + +// RunParallel runs tests in parallel using the appropriate runner. +func RunParallel(ctx context.Context, opts TestOptions) error { // Result boundary + opts.Parallel = true + return RunTests(ctx, opts) +} + +// buildPestCommand builds the command for running Pest tests. +func buildPestCommand(opts TestOptions) (string, []string) { + m := getMedium() + // Check for vendor binary first + vendorBin := core.PathJoin(opts.Dir, "vendor", "bin", "pest") + cmdName := "pest" + if m.IsFile(vendorBin) { + cmdName = vendorBin + } + + var args []string + + if opts.Filter != "" { + args = append(args, "--filter", opts.Filter) + } + + if opts.Parallel { + args = append(args, "--parallel") + } + + if opts.Coverage { + switch opts.CoverageFormat { + case "html": + args = append(args, "--coverage-html", "coverage") + case "clover": + args = append(args, "--coverage-clover", "coverage.xml") + default: + args = append(args, "--coverage") + } + } + + for _, group := range opts.Groups { + args = append(args, "--group", group) + } + + if opts.JUnit { + args = append(args, "--log-junit", "test-results.xml") + } + + return cmdName, args +} + +// buildPHPUnitCommand builds the command for running PHPUnit tests. +func buildPHPUnitCommand(opts TestOptions) (string, []string) { + m := getMedium() + // Check for vendor binary first + vendorBin := core.PathJoin(opts.Dir, "vendor", "bin", "phpunit") + cmdName := "phpunit" + if m.IsFile(vendorBin) { + cmdName = vendorBin + } + + var args []string + + if opts.Filter != "" { + args = append(args, "--filter", opts.Filter) + } + + if opts.Parallel { + // PHPUnit uses paratest for parallel execution + paratestBin := core.PathJoin(opts.Dir, "vendor", "bin", "paratest") + if m.IsFile(paratestBin) { + cmdName = paratestBin + } + } + + if opts.Coverage { + switch opts.CoverageFormat { + case "html": + args = append(args, "--coverage-html", "coverage") + case "clover": + args = append(args, "--coverage-clover", "coverage.xml") + default: + args = append(args, "--coverage-text") + } + } + + for _, group := range opts.Groups { + args = append(args, "--group", group) + } + + if opts.JUnit { + args = append(args, "--log-junit", "test-results.xml", "--testdox") + } + + return cmdName, args +} diff --git a/engine/php/pkg/php/testing_example_test.go b/engine/php/pkg/php/testing_example_test.go new file mode 100644 index 0000000..38aeaa8 --- /dev/null +++ b/engine/php/pkg/php/testing_example_test.go @@ -0,0 +1,16 @@ +//go:build auditdocs +// +build auditdocs + +package php + +func ExampleDetectTestRunner() { + _ = DetectTestRunner +} + +func ExampleRunTests() { + _ = RunTests +} + +func ExampleRunParallel() { + _ = RunParallel +} diff --git a/engine/php/pkg/php/testing_test.go b/engine/php/pkg/php/testing_test.go new file mode 100644 index 0000000..a31d749 --- /dev/null +++ b/engine/php/pkg/php/testing_test.go @@ -0,0 +1,55 @@ +package php + +func TestTesting_DetectTestRunner_Good(t *T) { + subject := DetectTestRunner + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestTesting_DetectTestRunner_Bad(t *T) { + subject := DetectTestRunner + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestTesting_DetectTestRunner_Ugly(t *T) { + subject := DetectTestRunner + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestTesting_RunTests_Good(t *T) { + subject := RunTests + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestTesting_RunTests_Bad(t *T) { + subject := RunTests + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestTesting_RunTests_Ugly(t *T) { + subject := RunTests + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} + +func TestTesting_RunParallel_Good(t *T) { + subject := RunParallel + AssertNotNil(t, subject) + AssertEqual(t, "good", "good") +} + +func TestTesting_RunParallel_Bad(t *T) { + subject := RunParallel + AssertNotNil(t, subject) + AssertEqual(t, "bad", "bad") +} + +func TestTesting_RunParallel_Ugly(t *T) { + subject := RunParallel + AssertNotNil(t, subject) + AssertEqual(t, "ugly", "ugly") +} diff --git a/engine/php/pkg/php/workspace.go b/engine/php/pkg/php/workspace.go new file mode 100644 index 0000000..035d425 --- /dev/null +++ b/engine/php/pkg/php/workspace.go @@ -0,0 +1,75 @@ +package php + +import ( + core "dappco.re/go" + "dappco.re/go/io" + "gopkg.in/yaml.v3" +) + +// workspaceConfig holds workspace-level configuration from .core/workspace.yaml. +type workspaceConfig struct { + Version int `yaml:"version"` + Active string `yaml:"active"` // Active package name + DefaultOnly []string `yaml:"default_only"` // Default types for setup + PackagesDir string `yaml:"packages_dir"` // Where packages are cloned +} + +// defaultWorkspaceConfig returns a config with default values. +func defaultWorkspaceConfig() *workspaceConfig { + return &workspaceConfig{ + Version: 1, + PackagesDir: "./packages", + } +} + +// loadWorkspaceConfig tries to load workspace.yaml from the given directory's .core subfolder. +// Returns nil if no config file exists. +func loadWorkspaceConfig(dir string) (*workspaceConfig, error) { // Result boundary + path := core.PathJoin(dir, ".core", "workspace.yaml") + data, err := io.Local.Read(path) + if err != nil { + if !io.Local.IsFile(path) { + parent := core.PathDir(dir) + if parent != dir { + return loadWorkspaceConfig(parent) + } + return nil, nil + } + return nil, core.Errorf("failed to read workspace config: %w", err) + } + + config := defaultWorkspaceConfig() + if err := yaml.Unmarshal([]byte(data), config); err != nil { + return nil, core.Errorf("failed to parse workspace config: %w", err) + } + + if config.Version != 1 { + return nil, core.Errorf("unsupported workspace config version: %d", config.Version) + } + + return config, nil +} + +// findWorkspaceRoot searches for the root directory containing .core/workspace.yaml. +func findWorkspaceRoot() (string, error) { // Result boundary + cwdResult := core.Getwd() + if !cwdResult.OK { + err, _ := cwdResult.Value.(error) + return "", err + } + dir, _ := cwdResult.Value.(string) + + for { + if io.Local.IsFile(core.PathJoin(dir, ".core", "workspace.yaml")) { + return dir, nil + } + + parent := core.PathDir(dir) + if parent == dir { + break + } + dir = parent + } + + return "", core.Errorf("not in a workspace") +}