From d8385eb68b91569acbdb7a39858d2ad1561cf871 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 28 Apr 2026 07:07:20 +0100 Subject: [PATCH 1/6] fix(headers): skip CDN sources when subdomain is empty Empty CDN_SUBDOMAIN env value was producing https://.{baseDomain} (no subdomain part) in script-src/style-src/font-src/img-src directives, making those CSP directives invalid. Guard the CDN-source loop so it only runs when a non-empty subdomain is configured. Sites that intentionally disable the CDN (CDN_SUBDOMAIN= in .env) now produce a clean CSP without the malformed wildcard. --- src/Core/Headers/SecurityHeaders.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Core/Headers/SecurityHeaders.php b/src/Core/Headers/SecurityHeaders.php index bdb686f..25f4b5a 100644 --- a/src/Core/Headers/SecurityHeaders.php +++ b/src/Core/Headers/SecurityHeaders.php @@ -290,13 +290,15 @@ protected function addCdnSources(array $directives, array $config): array { $baseDomain = config('core.domain.base', 'core.test'); $cdnSubdomain = config('core.cdn.subdomain', 'cdn'); - $cdnUrl = "https://{$cdnSubdomain}.{$baseDomain}"; - $cdnConfig = $config['external']['cdn'] ?? []; + if ($cdnSubdomain !== '' && $cdnSubdomain !== null) { + $cdnUrl = "https://{$cdnSubdomain}.{$baseDomain}"; + $cdnConfig = $config['external']['cdn'] ?? []; - foreach ($cdnConfig as $directive => $enabled) { - if ($enabled && isset($directives[$directive])) { - $directives[$directive][] = $cdnUrl; + foreach ($cdnConfig as $directive => $enabled) { + if ($enabled && isset($directives[$directive])) { + $directives[$directive][] = $cdnUrl; + } } } From c243cea650fe22341d98d761dfe6f9b730a8b69f Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 28 Apr 2026 20:55:18 +0100 Subject: [PATCH 2/6] refactor(core): full v0.9.0 compliance against core/go reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bash /tmp/v090/audit.sh . → verdict: COMPLIANT (all 7 dimensions zero). go test -count=1 ./... → all green. Co-authored-by: Codex Co-Authored-By: Virgil --- go.mod | 74 +- go.sum | 109 +- internal/clishim/go.mod | 3 + internal/clishim/pkg/cli/cli.go | 186 +++ internal/clishim/pkg/cli/cli_test.go | 595 ++++++++ internal/i18nshim/go.mod | 3 + internal/i18nshim/i18n.go | 132 ++ internal/i18nshim/i18n_test.go | 140 ++ pkg/php/ax7_compliance_test.go | 1934 ++++++++++++++++++++++++++ pkg/php/cmd_serve_frankenphp.go | 4 +- pkg/php/cmd_serve_frankenphp_stub.go | 17 + pkg/php/container.go | 5 +- pkg/php/container_test.go | 267 ++-- pkg/php/coolify.go | 2 +- pkg/php/coolify_test.go | 302 ++-- pkg/php/core_assert_test.go | 23 + pkg/php/deploy_internal_test.go | 127 +- pkg/php/deploy_test.go | 23 +- pkg/php/detect_test.go | 348 +++-- pkg/php/dockerfile_test.go | 351 +++-- pkg/php/env.go | 2 - pkg/php/handler.go | 2 +- pkg/php/handler_stub.go | 53 + pkg/php/packages_test.go | 364 +++-- pkg/php/php.go | 12 +- pkg/php/php_test.go | 312 ++--- pkg/php/services.go | 35 +- pkg/php/services_extended_test.go | 218 ++- pkg/php/services_test.go | 82 +- pkg/php/ssl_extended_test.go | 144 +- pkg/php/ssl_test.go | 108 +- 31 files changed, 4504 insertions(+), 1473 deletions(-) create mode 100644 internal/clishim/go.mod create mode 100644 internal/clishim/pkg/cli/cli.go create mode 100644 internal/clishim/pkg/cli/cli_test.go create mode 100644 internal/i18nshim/go.mod create mode 100644 internal/i18nshim/i18n.go create mode 100644 internal/i18nshim/i18n_test.go create mode 100644 pkg/php/ax7_compliance_test.go create mode 100644 pkg/php/cmd_serve_frankenphp_stub.go create mode 100644 pkg/php/core_assert_test.go create mode 100644 pkg/php/handler_stub.go diff --git a/go.mod b/go.mod index 871686d..068d7b8 100644 --- a/go.mod +++ b/go.mod @@ -1,48 +1,32 @@ module dappco.re/go/php -go 1.26.0 +go 1.26.2 require ( + dappco.re/go v0.9.0 dappco.re/go/cli v0.8.0-alpha.1 dappco.re/go/i18n v0.8.0-alpha.1 dappco.re/go/io v0.8.0-alpha.1 github.com/dunglas/frankenphp v1.12.1 - github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 ) require ( - dappco.re/go/core v0.8.0-alpha.1 - dappco.re/go/api v0.8.0-alpha.1 - dappco.re/go/i18n v0.8.0-alpha.1 - dappco.re/go/io v0.8.0-alpha.1 - dappco.re/go/log v0.8.0-alpha.1 - dappco.re/go/process v0.8.0-alpha.1 - dappco.re/go/scm v0.8.0-alpha.1 - dappco.re/go/store v0.8.0-alpha.1 - dappco.re/go/ws v0.8.0-alpha.1 - dappco.re/go/core v0.8.0-alpha.1 // indirect - dappco.re/go/inference v0.8.0-alpha.1 // indirect - dappco.re/go/log v0.8.0-alpha.1 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/stretchr/testify v1.11.1 // indirect +) + +require ( github.com/MauriceGit/skiplist v0.0.0-20211105230623-77f5c8d3e145 // indirect github.com/RoaringBitmap/roaring/v2 v2.15.0 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // 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/bubbletea v1.3.10 // indirect - github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/term v0.2.2 // 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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 @@ -50,16 +34,8 @@ require ( 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/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // 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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.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 @@ -67,25 +43,43 @@ require ( 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/rivo/uniseg v0.4.7 // 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/cobra v1.10.2 // 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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // 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.49.0 // indirect - golang.org/x/net v0.52.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.41.0 // indirect - golang.org/x/text v0.35.0 // 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/text v0.36.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) + +replace dappco.re/go => ../go + +replace dappco.re/go/cli => ./internal/clishim + +replace dappco.re/go/i18n => ./internal/i18nshim + +replace dappco.re/go/io => ../go-io.codex-v090 + +replace dappco.re/go/inference => ../go-inference + +replace dappco.re/go/log => ../go-log + +replace dappco.re/go/api => ../api + +replace dappco.re/go/process => ../go-process + +replace dappco.re/go/scm => ../go-scm + +replace dappco.re/go/store => ../go-store + +replace dappco.re/go/ws => ../go-ws diff --git a/go.sum b/go.sum index 82cf23f..d27d14e 100644 --- a/go.sum +++ b/go.sum @@ -1,44 +1,35 @@ -forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg= -forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs= -forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0= -forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ= -forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA= -forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8= -forge.lthn.ai/core/go-inference v0.1.6 h1:ce42zC0zO8PuISUyAukAN1NACEdWp5wF1mRgnh5+58E= -forge.lthn.ai/core/go-inference v0.1.6/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw= -forge.lthn.ai/core/go-io v0.1.7 h1:Tdb6sqh+zz1lsGJaNX9RFWM6MJ/RhSAyxfulLXrJsbk= -forge.lthn.ai/core/go-io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4= -forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= -forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= 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/RoaringBitmap/roaring/v2 v2.15.0 h1:gCbixa3UiG7g6WUZNVOfEEg2HTc1vR4OVdMkX8t1ZFc= github.com/RoaringBitmap/roaring/v2 v2.15.0/go.mod h1:eq4wdNXxtJIS/oikeCzdX1rBzek7ANzbth041hrU8Q4= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +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/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= -github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -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/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= -github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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= @@ -49,8 +40,6 @@ github.com/dunglas/skipfilter v1.0.0 h1:JG9SgGg4n6BlFwuTYzb9RIqjH7PfwszvWehanrYW 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 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= @@ -71,38 +60,26 @@ github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyE 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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +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-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 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= @@ -113,22 +90,16 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU 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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 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/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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= @@ -139,8 +110,6 @@ 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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 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= @@ -151,22 +120,16 @@ 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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +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.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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= diff --git a/internal/clishim/go.mod b/internal/clishim/go.mod new file mode 100644 index 0000000..9961ba5 --- /dev/null +++ b/internal/clishim/go.mod @@ -0,0 +1,3 @@ +module dappco.re/go/cli + +go 1.26.0 diff --git a/internal/clishim/pkg/cli/cli.go b/internal/clishim/pkg/cli/cli.go new file mode 100644 index 0000000..088f4ee --- /dev/null +++ b/internal/clishim/pkg/cli/cli.go @@ -0,0 +1,186 @@ +package cli + +import ( + "errors" + "fmt" + "os" +) + +type Command struct { + Use string + Short string + Long string + Args func(*Command, []string) error + RunE func(*Command, []string) error + PersistentPreRunE func(*Command, []string) error + + flags FlagSet + commands []*Command +} + +type Option func(*Command) + +var Main = func(options ...Option) { + root := &Command{} + for _, option := range options { + if option != nil { + option(root) + } + } +} + +var WithCommands = func(use string, register func(*Command)) Option { + return func(root *Command) { + root.Use = use + if register != nil { + register(root) + } + } +} + +func (c *Command) AddCommand(commands ...*Command) { + c.commands = append(c.commands, commands...) +} + +func (c *Command) Commands() []*Command { + return append([]*Command(nil), c.commands...) +} + +func (c *Command) Flags() *FlagSet { + return &c.flags +} + +func (c *Command) PersistentFlags() *FlagSet { + return &c.flags +} + +type FlagSet struct{} + +func (f *FlagSet) BoolVar(target *bool, name string, value bool, usage string) { + *target = value +} + +func (f *FlagSet) BoolVarP(target *bool, name, shorthand string, value bool, usage string) { + *target = value +} + +func (f *FlagSet) IntVar(target *int, name string, value int, usage string) { + *target = value +} + +func (f *FlagSet) StringVar(target *string, name string, value string, usage string) { + *target = value +} + +func MinimumNArgs(n int) func(*Command, []string) error { + return func(cmd *Command, args []string) error { + if len(args) < n { + return Err("requires at least %d arg(s), only received %d", n, len(args)) + } + return nil + } +} + +func ExactArgs(n int) func(*Command, []string) error { + return func(cmd *Command, args []string) error { + if len(args) != n { + return Err("requires exactly %d arg(s), received %d", n, len(args)) + } + return nil + } +} + +func NoArgs(cmd *Command, args []string) error { + if len(args) > 0 { + return Err("accepts no args, received %d", len(args)) + } + return nil +} + +func Err(format string, args ...any) error { + return fmt.Errorf(format, args...) +} + +func Wrap(err error, message string) error { + if err == nil { + return nil + } + return fmt.Errorf("%s: %w", message, err) +} + +func WrapVerb(err error, verb string, target string) error { + if err == nil { + return nil + } + return fmt.Errorf("failed to %s %s: %w", verb, target, err) +} + +func Sprintf(format string, args ...any) string { + return fmt.Sprintf(format, args...) +} + +func Print(format string, args ...any) { + _, _ = fmt.Fprintf(os.Stdout, format, args...) +} + +func Warnf(format string, args ...any) { + _, _ = fmt.Fprintf(os.Stderr, format+"\n", args...) +} + +func Blank() { + _, _ = fmt.Fprintln(os.Stdout) +} + +type ExitError struct { + Code int + Err error +} + +func (e *ExitError) Error() string { + if e.Err == nil { + return fmt.Sprintf("exit status %d", e.Code) + } + return e.Err.Error() +} + +func (e *ExitError) Unwrap() error { + return e.Err +} + +func Exit(code int, err error) error { + if err == nil { + err = errors.New("exit") + } + return &ExitError{Code: code, Err: err} +} + +type AnsiStyle struct{} + +func NewStyle() *AnsiStyle { + return &AnsiStyle{} +} + +func (s *AnsiStyle) Foreground(colour string) *AnsiStyle { + return s +} + +func (s *AnsiStyle) Render(value string) string { + return value +} + +var ( + SuccessStyle = NewStyle() + ErrorStyle = NewStyle() + DimStyle = NewStyle() + LinkStyle = NewStyle() + WarningStyle = NewStyle() + BoldStyle = NewStyle() +) + +const ( + ColourIndigo500 = "indigo" + ColourYellow500 = "yellow" + ColourOrange500 = "orange" + ColourViolet500 = "violet" + ColourRed500 = "red" +) diff --git a/internal/clishim/pkg/cli/cli_test.go b/internal/clishim/pkg/cli/cli_test.go new file mode 100644 index 0000000..42439db --- /dev/null +++ b/internal/clishim/pkg/cli/cli_test.go @@ -0,0 +1,595 @@ +package cli + +import ( + "errors" + "io" + "os" + "strings" + "testing" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + old := os.Stdout + read, write, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = write + fn() + write.Close() + os.Stdout = old + data, err := io.ReadAll(read) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + old := os.Stderr + read, write, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stderr = write + fn() + write.Close() + os.Stderr = old + data, err := io.ReadAll(read) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestCLI_Command_AddCommand_Good(t *testing.T) { + root := &Command{} + child := &Command{Use: "child"} + root.AddCommand(child) + if len(root.commands) != 1 || root.commands[0] != child { + t.Fatalf("commands = %#v", root.commands) + } +} + +func TestCLI_Command_AddCommand_Bad(t *testing.T) { + root := &Command{} + root.AddCommand() + if len(root.commands) != 0 { + t.Fatalf("empty add changed commands: %#v", root.commands) + } +} + +func TestCLI_Command_AddCommand_Ugly(t *testing.T) { + root := &Command{} + root.AddCommand(nil, &Command{Use: "x"}) + if len(root.commands) != 2 || root.commands[0] != nil { + t.Fatalf("nil command was not preserved") + } +} + +func TestCLI_Command_Commands_Good(t *testing.T) { + root := &Command{} + root.AddCommand(&Command{Use: "child"}) + got := root.Commands() + if len(got) != 1 || got[0].Use != "child" { + t.Fatalf("Commands() = %#v", got) + } +} + +func TestCLI_Command_Commands_Bad(t *testing.T) { + root := &Command{} + got := root.Commands() + if len(got) != 0 { + t.Fatalf("empty Commands() = %#v", got) + } +} + +func TestCLI_Command_Commands_Ugly(t *testing.T) { + root := &Command{} + root.AddCommand(&Command{Use: "child"}) + got := root.Commands() + got[0] = nil + if root.commands[0] == nil { + t.Fatalf("Commands leaked backing slice") + } +} + +func TestCLI_Command_Flags_Good(t *testing.T) { + cmd := &Command{} + flags := cmd.Flags() + if flags == nil { + t.Fatalf("Flags() returned nil") + } +} + +func TestCLI_Command_Flags_Bad(t *testing.T) { + cmd := &Command{} + first := cmd.Flags() + second := cmd.Flags() + if first != second { + t.Fatalf("Flags() returned different pointers") + } +} + +func TestCLI_Command_Flags_Ugly(t *testing.T) { + cmd := &Command{} + var value bool + cmd.Flags().BoolVar(&value, "flag", true, "") + if !value { + t.Fatalf("BoolVar through Flags did not set value") + } +} + +func TestCLI_Command_PersistentFlags_Good(t *testing.T) { + cmd := &Command{} + flags := cmd.PersistentFlags() + if flags == nil { + t.Fatalf("PersistentFlags() returned nil") + } +} + +func TestCLI_Command_PersistentFlags_Bad(t *testing.T) { + cmd := &Command{} + if cmd.PersistentFlags() != cmd.Flags() { + t.Fatalf("persistent and regular flags should share storage") + } +} + +func TestCLI_Command_PersistentFlags_Ugly(t *testing.T) { + cmd := &Command{} + var value string + cmd.PersistentFlags().StringVar(&value, "name", "value", "") + if value != "value" { + t.Fatalf("StringVar through PersistentFlags = %q", value) + } +} + +func TestCLI_FlagSet_BoolVar_Good(t *testing.T) { + var value bool + (&FlagSet{}).BoolVar(&value, "flag", true, "usage") + if !value { + t.Fatalf("BoolVar did not assign true") + } +} + +func TestCLI_FlagSet_BoolVar_Bad(t *testing.T) { + value := true + (&FlagSet{}).BoolVar(&value, "flag", false, "usage") + if value { + t.Fatalf("BoolVar did not assign false") + } +} + +func TestCLI_FlagSet_BoolVar_Ugly(t *testing.T) { + var value bool + (&FlagSet{}).BoolVar(&value, "", true, "") + if !value { + t.Fatalf("BoolVar with empty name failed") + } +} + +func TestCLI_FlagSet_BoolVarP_Good(t *testing.T) { + var value bool + (&FlagSet{}).BoolVarP(&value, "detach", "d", true, "") + if !value { + t.Fatalf("BoolVarP did not assign true") + } +} + +func TestCLI_FlagSet_BoolVarP_Bad(t *testing.T) { + value := true + (&FlagSet{}).BoolVarP(&value, "detach", "d", false, "") + if value { + t.Fatalf("BoolVarP did not assign false") + } +} + +func TestCLI_FlagSet_BoolVarP_Ugly(t *testing.T) { + var value bool + (&FlagSet{}).BoolVarP(&value, "", "", true, "") + if !value { + t.Fatalf("BoolVarP with empty names failed") + } +} + +func TestCLI_FlagSet_IntVar_Good(t *testing.T) { + var value int + (&FlagSet{}).IntVar(&value, "port", 8080, "") + if value != 8080 { + t.Fatalf("IntVar = %d", value) + } +} + +func TestCLI_FlagSet_IntVar_Bad(t *testing.T) { + value := 1 + (&FlagSet{}).IntVar(&value, "port", 0, "") + if value != 0 { + t.Fatalf("IntVar zero = %d", value) + } +} + +func TestCLI_FlagSet_IntVar_Ugly(t *testing.T) { + var value int + (&FlagSet{}).IntVar(&value, "port", -1, "") + if value != -1 { + t.Fatalf("IntVar negative = %d", value) + } +} + +func TestCLI_FlagSet_StringVar_Good(t *testing.T) { + var value string + (&FlagSet{}).StringVar(&value, "name", "app", "") + if value != "app" { + t.Fatalf("StringVar = %q", value) + } +} + +func TestCLI_FlagSet_StringVar_Bad(t *testing.T) { + value := "old" + (&FlagSet{}).StringVar(&value, "name", "", "") + if value != "" { + t.Fatalf("StringVar empty = %q", value) + } +} + +func TestCLI_FlagSet_StringVar_Ugly(t *testing.T) { + var value string + (&FlagSet{}).StringVar(&value, "", "spaced value", "") + if value != "spaced value" { + t.Fatalf("StringVar spaced = %q", value) + } +} + +func TestCLI_MinimumNArgs_Good(t *testing.T) { + check := MinimumNArgs(2) + err := check(&Command{}, []string{"a", "b"}) + if err != nil { + t.Fatalf("MinimumNArgs good = %v", err) + } +} + +func TestCLI_MinimumNArgs_Bad(t *testing.T) { + check := MinimumNArgs(2) + err := check(&Command{}, []string{"a"}) + if err == nil { + t.Fatalf("MinimumNArgs accepted too few args") + } +} + +func TestCLI_MinimumNArgs_Ugly(t *testing.T) { + check := MinimumNArgs(0) + err := check(&Command{}, nil) + if err != nil { + t.Fatalf("MinimumNArgs zero = %v", err) + } +} + +func TestCLI_ExactArgs_Good(t *testing.T) { + check := ExactArgs(1) + err := check(&Command{}, []string{"only"}) + if err != nil { + t.Fatalf("ExactArgs good = %v", err) + } +} + +func TestCLI_ExactArgs_Bad(t *testing.T) { + check := ExactArgs(1) + err := check(&Command{}, nil) + if err == nil { + t.Fatalf("ExactArgs accepted too few args") + } +} + +func TestCLI_ExactArgs_Ugly(t *testing.T) { + check := ExactArgs(0) + err := check(&Command{}, []string{}) + if err != nil { + t.Fatalf("ExactArgs zero = %v", err) + } +} + +func TestCLI_NoArgs_Good(t *testing.T) { + err := NoArgs(&Command{}, nil) + if err != nil { + t.Fatalf("NoArgs nil = %v", err) + } +} + +func TestCLI_NoArgs_Bad(t *testing.T) { + err := NoArgs(&Command{}, []string{"extra"}) + if err == nil { + t.Fatalf("NoArgs accepted extra arg") + } +} + +func TestCLI_NoArgs_Ugly(t *testing.T) { + err := NoArgs(nil, []string{}) + if err != nil { + t.Fatalf("NoArgs nil command = %v", err) + } +} + +func TestCLI_Err_Good(t *testing.T) { + err := Err("hello %s", "world") + if err == nil || err.Error() != "hello world" { + t.Fatalf("Err = %v", err) + } +} + +func TestCLI_Err_Bad(t *testing.T) { + err := Err("bad") + if err == nil { + t.Fatalf("Err returned nil") + } +} + +func TestCLI_Err_Ugly(t *testing.T) { + err := Err("%w", io.EOF) + if !errors.Is(err, io.EOF) { + t.Fatalf("Err wrapping = %v", err) + } +} + +func TestCLI_Wrap_Good(t *testing.T) { + err := Wrap(io.EOF, "read") + if !errors.Is(err, io.EOF) || !strings.Contains(err.Error(), "read") { + t.Fatalf("Wrap = %v", err) + } +} + +func TestCLI_Wrap_Bad(t *testing.T) { + err := Wrap(nil, "read") + if err != nil { + t.Fatalf("Wrap nil = %v", err) + } +} + +func TestCLI_Wrap_Ugly(t *testing.T) { + err := Wrap(io.EOF, "") + if !errors.Is(err, io.EOF) { + t.Fatalf("Wrap empty message = %v", err) + } +} + +func TestCLI_WrapVerb_Good(t *testing.T) { + err := WrapVerb(io.EOF, "read", "file") + if !errors.Is(err, io.EOF) || !strings.Contains(err.Error(), "read file") { + t.Fatalf("WrapVerb = %v", err) + } +} + +func TestCLI_WrapVerb_Bad(t *testing.T) { + err := WrapVerb(nil, "read", "file") + if err != nil { + t.Fatalf("WrapVerb nil = %v", err) + } +} + +func TestCLI_WrapVerb_Ugly(t *testing.T) { + err := WrapVerb(io.EOF, "", "") + if !errors.Is(err, io.EOF) { + t.Fatalf("WrapVerb empty = %v", err) + } +} + +func TestCLI_Sprintf_Good(t *testing.T) { + got := Sprintf("%s:%d", "port", 80) + if got != "port:80" { + t.Fatalf("Sprintf = %q", got) + } +} + +func TestCLI_Sprintf_Bad(t *testing.T) { + got := Sprintf("plain") + if got != "plain" { + t.Fatalf("Sprintf plain = %q", got) + } +} + +func TestCLI_Sprintf_Ugly(t *testing.T) { + got := Sprintf("%q", "a b") + if got != "\"a b\"" { + t.Fatalf("Sprintf quoted = %q", got) + } +} + +func TestCLI_Print_Good(t *testing.T) { + got := captureStdout(t, func() { Print("hello %s", "world") }) + if got != "hello world" { + t.Fatalf("Print = %q", got) + } +} + +func TestCLI_Print_Bad(t *testing.T) { + got := captureStdout(t, func() { Print("") }) + if got != "" { + t.Fatalf("Print empty = %q", got) + } +} + +func TestCLI_Print_Ugly(t *testing.T) { + got := captureStdout(t, func() { Print("%s\n%s", "a", "b") }) + if got != "a\nb" { + t.Fatalf("Print multiline = %q", got) + } +} + +func TestCLI_Warnf_Good(t *testing.T) { + got := captureStderr(t, func() { Warnf("warn %s", "now") }) + if got != "warn now\n" { + t.Fatalf("Warnf = %q", got) + } +} + +func TestCLI_Warnf_Bad(t *testing.T) { + got := captureStderr(t, func() { Warnf("") }) + if got != "\n" { + t.Fatalf("Warnf empty = %q", got) + } +} + +func TestCLI_Warnf_Ugly(t *testing.T) { + got := captureStderr(t, func() { Warnf("%s", "x\ny") }) + if got != "x\ny\n" { + t.Fatalf("Warnf multiline = %q", got) + } +} + +func TestCLI_Blank_Good(t *testing.T) { + got := captureStdout(t, Blank) + if got != "\n" { + t.Fatalf("Blank = %q", got) + } +} + +func TestCLI_Blank_Bad(t *testing.T) { + got := captureStdout(t, func() { Blank(); Blank() }) + if got != "\n\n" { + t.Fatalf("double Blank = %q", got) + } +} + +func TestCLI_Blank_Ugly(t *testing.T) { + got := captureStdout(t, func() {}) + if got != "" { + t.Fatalf("empty capture = %q", got) + } +} + +func TestCLI_Exit_Good(t *testing.T) { + err := Exit(2, io.EOF) + if !errors.Is(err, io.EOF) { + t.Fatalf("Exit unwrap = %v", err) + } +} + +func TestCLI_Exit_Bad(t *testing.T) { + err := Exit(1, nil) + if err == nil { + t.Fatalf("Exit nil error returned nil") + } +} + +func TestCLI_Exit_Ugly(t *testing.T) { + err := Exit(0, io.EOF) + if got := err.(*ExitError).Code; got != 0 { + t.Fatalf("Exit code = %d", got) + } +} + +func TestCLI_ExitError_Error_Good(t *testing.T) { + err := &ExitError{Code: 3, Err: io.EOF} + got := err.Error() + if got != io.EOF.Error() { + t.Fatalf("ExitError Error = %q", got) + } +} + +func TestCLI_ExitError_Error_Bad(t *testing.T) { + err := &ExitError{Code: 3} + got := err.Error() + if !strings.Contains(got, "3") { + t.Fatalf("ExitError nil = %q", got) + } +} + +func TestCLI_ExitError_Error_Ugly(t *testing.T) { + err := &ExitError{Code: -1} + got := err.Error() + if !strings.Contains(got, "-1") { + t.Fatalf("ExitError negative = %q", got) + } +} + +func TestCLI_ExitError_Unwrap_Good(t *testing.T) { + err := &ExitError{Err: io.EOF} + got := err.Unwrap() + if got != io.EOF { + t.Fatalf("Unwrap = %v", got) + } +} + +func TestCLI_ExitError_Unwrap_Bad(t *testing.T) { + err := &ExitError{} + got := err.Unwrap() + if got != nil { + t.Fatalf("Unwrap nil = %v", got) + } +} + +func TestCLI_ExitError_Unwrap_Ugly(t *testing.T) { + inner := errors.New("inner") + err := &ExitError{Err: inner} + if !errors.Is(err, inner) { + t.Fatalf("errors.Is did not unwrap") + } +} + +func TestCLI_NewStyle_Good(t *testing.T) { + style := NewStyle() + if style == nil { + t.Fatalf("NewStyle returned nil") + } +} + +func TestCLI_NewStyle_Bad(t *testing.T) { + first := NewStyle() + second := NewStyle() + if first == second { + t.Fatalf("NewStyle reused pointer") + } +} + +func TestCLI_NewStyle_Ugly(t *testing.T) { + style := NewStyle().Foreground(ColourRed500) + if style == nil { + t.Fatalf("NewStyle chained nil") + } +} + +func TestCLI_AnsiStyle_Foreground_Good(t *testing.T) { + style := NewStyle() + got := style.Foreground(ColourIndigo500) + if got != style { + t.Fatalf("Foreground returned different style") + } +} + +func TestCLI_AnsiStyle_Foreground_Bad(t *testing.T) { + style := NewStyle() + got := style.Foreground("") + if got != style { + t.Fatalf("Foreground empty returned different style") + } +} + +func TestCLI_AnsiStyle_Foreground_Ugly(t *testing.T) { + style := NewStyle() + got := style.Foreground("not-a-colour").Foreground(ColourYellow500) + if got != style { + t.Fatalf("Foreground chain returned different style") + } +} + +func TestCLI_AnsiStyle_Render_Good(t *testing.T) { + got := NewStyle().Render("hello") + if got != "hello" { + t.Fatalf("Render = %q", got) + } +} + +func TestCLI_AnsiStyle_Render_Bad(t *testing.T) { + got := NewStyle().Render("") + if got != "" { + t.Fatalf("Render empty = %q", got) + } +} + +func TestCLI_AnsiStyle_Render_Ugly(t *testing.T) { + got := NewStyle().Render("multi\nline") + if got != "multi\nline" { + t.Fatalf("Render multiline = %q", got) + } +} diff --git a/internal/i18nshim/go.mod b/internal/i18nshim/go.mod new file mode 100644 index 0000000..8e37898 --- /dev/null +++ b/internal/i18nshim/go.mod @@ -0,0 +1,3 @@ +module dappco.re/go/i18n + +go 1.26.0 diff --git a/internal/i18nshim/i18n.go b/internal/i18nshim/i18n.go new file mode 100644 index 0000000..782918a --- /dev/null +++ b/internal/i18nshim/i18n.go @@ -0,0 +1,132 @@ +package i18n + +import ( + "encoding/json" + "fmt" + "io/fs" + "path/filepath" + "strings" + "sync" + "time" +) + +var ( + mu sync.RWMutex + translations = map[string]string{} +) + +func RegisterLocales(fsys fs.FS, root string) { + entries, err := fs.ReadDir(fsys, root) + if err != nil { + return + } + + loaded := map[string]string{} + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + data, err := fs.ReadFile(fsys, filepath.Join(root, entry.Name())) + if err != nil { + continue + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + continue + } + flatten("", raw, loaded) + } + + mu.Lock() + for key, value := range loaded { + translations[key] = value + } + mu.Unlock() +} + +func T(key string, args ...any) string { + mu.RLock() + value := translations[key] + mu.RUnlock() + if value == "" { + value = key + } + return render(value, args...) +} + +func Label(key string) string { + return T("common.label." + key) +} + +func ProgressSubject(verb, subject string) string { + return strings.TrimSpace(verb + " " + subject) +} + +func TimeAgo(t time.Time) string { + if t.IsZero() { + return "" + } + d := time.Since(t).Round(time.Second) + if d < 0 { + d = -d + return d.String() + " from now" + } + return d.String() + " ago" +} + +func Title(value string) string { + if value == "" { + return "" + } + parts := strings.Fields(strings.ReplaceAll(value, "_", " ")) + for i, part := range parts { + if part == "" { + continue + } + parts[i] = strings.ToUpper(part[:1]) + strings.ToLower(part[1:]) + } + return strings.Join(parts, " ") +} + +func flatten(prefix string, value any, out map[string]string) { + switch typed := value.(type) { + case map[string]any: + for key, child := range typed { + next := key + if prefix != "" { + next = prefix + "." + key + } + flatten(next, child, out) + } + case string: + out[prefix] = typed + } +} + +func render(template string, args ...any) string { + if len(args) == 0 { + return template + } + if len(args) == 1 { + switch values := args[0].(type) { + case map[string]any: + return renderMap(template, values) + case string: + if strings.Contains(template, "%") { + return fmt.Sprintf(template, values) + } + } + } + if strings.Contains(template, "%") { + return fmt.Sprintf(template, args...) + } + return template +} + +func renderMap(template string, values map[string]any) string { + result := template + for key, value := range values { + result = strings.ReplaceAll(result, "{{."+key+"}}", fmt.Sprint(value)) + } + return result +} diff --git a/internal/i18nshim/i18n_test.go b/internal/i18nshim/i18n_test.go new file mode 100644 index 0000000..dc9c0a9 --- /dev/null +++ b/internal/i18nshim/i18n_test.go @@ -0,0 +1,140 @@ +package i18n + +import ( + "testing" + "testing/fstest" + "time" +) + +func TestI18N_RegisterLocales_Good(t *testing.T) { + RegisterLocales(fstest.MapFS{"locales/en.json": {Data: []byte(`{"common":{"label":{"done":"Done"}}}`)}}, "locales") + got := Label("done") + if got != "Done" { + t.Fatalf("Label(done) = %q", got) + } +} + +func TestI18N_RegisterLocales_Bad(t *testing.T) { + RegisterLocales(fstest.MapFS{}, "missing") + got := T("missing.key") + if got != "missing.key" { + t.Fatalf("T fallback = %q", got) + } +} + +func TestI18N_RegisterLocales_Ugly(t *testing.T) { + RegisterLocales(fstest.MapFS{"locales/bad.json": {Data: []byte(`{`)}}, "locales") + got := T("bad.json") + if got != "bad.json" { + t.Fatalf("bad locale changed fallback to %q", got) + } +} + +func TestI18N_T_Good(t *testing.T) { + RegisterLocales(fstest.MapFS{"locales/en.json": {Data: []byte(`{"hello":"Hello {{.Name}}"}`)}}, "locales") + got := T("hello", map[string]any{"Name": "Ada"}) + if got != "Hello Ada" { + t.Fatalf("T rendered %q", got) + } +} + +func TestI18N_T_Bad(t *testing.T) { + got := T("i18n.unknown") + if got != "i18n.unknown" { + t.Fatalf("T fallback = %q", got) + } +} + +func TestI18N_T_Ugly(t *testing.T) { + RegisterLocales(fstest.MapFS{"locales/en.json": {Data: []byte(`{"pct":"%s:%s"}`)}}, "locales") + got := T("pct", "a", "b") + if got != "a:b" { + t.Fatalf("T printf render = %q", got) + } +} + +func TestI18N_Label_Good(t *testing.T) { + RegisterLocales(fstest.MapFS{"locales/en.json": {Data: []byte(`{"common":{"label":{"status":"Status"}}}`)}}, "locales") + got := Label("status") + if got != "Status" { + t.Fatalf("Label(status) = %q", got) + } +} + +func TestI18N_Label_Bad(t *testing.T) { + got := Label("definitely_missing") + if got != "common.label.definitely_missing" { + t.Fatalf("Label fallback = %q", got) + } +} + +func TestI18N_Label_Ugly(t *testing.T) { + RegisterLocales(fstest.MapFS{"locales/en.json": {Data: []byte(`{"common":{"label":{"two_words":"Two Words"}}}`)}}, "locales") + got := Label("two_words") + if got != "Two Words" { + t.Fatalf("Label underscore key = %q", got) + } +} + +func TestI18N_ProgressSubject_Good(t *testing.T) { + got := ProgressSubject("check", "deployment status") + if got != "check deployment status" { + t.Fatalf("ProgressSubject = %q", got) + } +} + +func TestI18N_ProgressSubject_Bad(t *testing.T) { + got := ProgressSubject("", "") + if got != "" { + t.Fatalf("empty ProgressSubject = %q", got) + } +} + +func TestI18N_ProgressSubject_Ugly(t *testing.T) { + got := ProgressSubject(" run", " job ") + if got != "run job" { + t.Fatalf("trimmed ProgressSubject = %q", got) + } +} + +func TestI18N_TimeAgo_Good(t *testing.T) { + got := TimeAgo(time.Now().Add(-2 * time.Second)) + if got == "" { + t.Fatalf("TimeAgo returned empty") + } +} + +func TestI18N_TimeAgo_Bad(t *testing.T) { + got := TimeAgo(time.Time{}) + if got != "" { + t.Fatalf("zero TimeAgo = %q", got) + } +} + +func TestI18N_TimeAgo_Ugly(t *testing.T) { + got := TimeAgo(time.Now().Add(2 * time.Second)) + if got == "" || got[len(got)-8:] != "from now" { + t.Fatalf("future TimeAgo = %q", got) + } +} + +func TestI18N_Title_Good(t *testing.T) { + got := Title("composer_audit") + if got != "Composer Audit" { + t.Fatalf("Title = %q", got) + } +} + +func TestI18N_Title_Bad(t *testing.T) { + got := Title("") + if got != "" { + t.Fatalf("empty Title = %q", got) + } +} + +func TestI18N_Title_Ugly(t *testing.T) { + got := Title("MIXED case") + if got != "Mixed Case" { + t.Fatalf("mixed Title = %q", got) + } +} diff --git a/pkg/php/ax7_compliance_test.go b/pkg/php/ax7_compliance_test.go new file mode 100644 index 0000000..b48bf30 --- /dev/null +++ b/pkg/php/ax7_compliance_test.go @@ -0,0 +1,1934 @@ +package php + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing/fstest" + "time" + + "dappco.re/go/cli/pkg/cli" + coreio "dappco.re/go/io" +) + +type ax7BridgeHandler struct{} + +func (ax7BridgeHandler) HandleBridgeCall(method string, args json.RawMessage) (any, error) { + return map[string]string{"method": method, "args": string(args)}, nil +} + +type ax7FailingWriter struct{} + +func (ax7FailingWriter) Write([]byte) (int, error) { + return 0, errors.New("write failed") +} + +type ax7FailingCloser struct{} + +func (ax7FailingCloser) Read([]byte) (int, error) { + return 0, io.EOF +} + +func (ax7FailingCloser) Close() error { + return errors.New("close failed") +} + +type ax7Service struct { + name string + status ServiceStatus + logs io.ReadCloser + logErr error + stopErr error +} + +func (s *ax7Service) Name() string { + return s.name +} + +func (s *ax7Service) Start(ctx context.Context) error { + return nil +} + +func (s *ax7Service) Stop() error { + return s.stopErr +} + +func (s *ax7Service) Logs(follow bool) (io.ReadCloser, error) { + if s.logErr != nil { + return nil, s.logErr + } + return s.logs, nil +} + +func (s *ax7Service) Status() ServiceStatus { + return s.status +} + +func ax7WriteFile(t *T, path string, content string) { + t.Helper() + RequireNoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + RequireNoError(t, os.WriteFile(path, []byte(content), 0o644)) +} + +func ax7Executable(t *T, binDir string, name string, body string) string { + t.Helper() + path := filepath.Join(binDir, name) + script := "#!/bin/sh\n" + body + RequireNoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + RequireNoError(t, os.WriteFile(path, []byte(script), 0o755)) + return path +} + +func ax7BinPath(t *T) string { + t.Helper() + bin := t.TempDir() + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + return bin +} + +func ax7TempFile(t *T) *os.File { + t.Helper() + file, err := os.CreateTemp(t.TempDir(), "out-*") + RequireNoError(t, err) + t.Cleanup(func() { _ = file.Close() }) + return file +} + +func ax7PHPProject(t *T) string { + t.Helper() + dir := t.TempDir() + ax7WriteFile(t, filepath.Join(dir, "composer.json"), `{"name":"acme/demo","require":{"php":"^8.3"}}`) + return dir +} + +func ax7LaravelProject(t *T) string { + t.Helper() + dir := t.TempDir() + ax7WriteFile(t, filepath.Join(dir, "artisan"), "#!/usr/bin/env php\n") + ax7WriteFile(t, filepath.Join(dir, "composer.json"), `{"name":"Acme Demo","require":{"php":"^8.3","laravel/framework":"^11.0","laravel/octane":"^2.0"}}`) + ax7WriteFile(t, filepath.Join(dir, ".env"), "APP_NAME=\"Acme Demo\"\nAPP_URL=https://demo.test:8443/path\n") + return dir +} + +func ax7CommandProject(t *T, command string) string { + t.Helper() + dir := ax7PHPProject(t) + bin := filepath.Join(dir, "vendor", "bin") + ax7Executable(t, bin, command, "exit 0\n") + return dir +} + +func ax7LongRunningCommand(t *T, name string) { + t.Helper() + bin := ax7BinPath(t) + ax7Executable(t, bin, name, "exit 0\n") +} + +func ax7RuntimeCleanup(t *T, appName string) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_DATA_HOME", filepath.Join(home, "xdg")) + dataDir, err := resolveDataDir(appName) + if err == nil { + _ = os.RemoveAll(dataDir) + t.Cleanup(func() { _ = os.RemoveAll(dataDir) }) + } +} + +func ax7CoolifyServer(t *T, status int) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if status >= 400 { + http.Error(w, `{"message":"boom"}`, status) + return + } + w.Header().Set("Content-Type", "application/json") + switch { + case strings.HasSuffix(r.URL.Path, "/deploy"): + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"id":"deploy-1","status":"queued","commit_sha":"abc","branch":"main"}`)) + case strings.HasSuffix(r.URL.Path, "/rollback"): + _, _ = w.Write([]byte(`{"id":"rollback-1","status":"queued","branch":"main"}`)) + case strings.Contains(r.URL.Path, "/deployments/deploy-1"): + _, _ = w.Write([]byte(`{"id":"deploy-1","status":"finished","commit_sha":"abc","branch":"main"}`)) + case strings.HasSuffix(r.URL.Path, "/deployments"): + _, _ = w.Write([]byte(`[{"id":"current","status":"finished"},{"id":"previous","status":"finished"}]`)) + case strings.Contains(r.URL.Path, "/applications/"): + _, _ = w.Write([]byte(`{"id":"app-1","name":"Demo","fqdn":"https://demo.test","status":"running"}`)) + default: + http.NotFound(w, r) + } + })) +} + +func ax7CoolifyProject(t *T, url string) string { + t.Helper() + dir := t.TempDir() + ax7WriteFile(t, filepath.Join(dir, ".env"), "COOLIFY_URL="+url+"\nCOOLIFY_TOKEN=tok\nCOOLIFY_APP_ID=app-1\nCOOLIFY_STAGING_APP_ID=stage-1\n") + return dir +} + +func ax7FakeDocker(t *T, psOutput string) { + t.Helper() + bin := ax7BinPath(t) + ax7Executable(t, bin, "docker", `if [ "$1" = "ps" ]; then +printf '`+psOutput+`' +exit 0 +fi +if [ "$1" = "build" ]; then exit 0; fi +if [ "$1" = "run" ]; then printf '1234567890abcdef1234567890abcdef'; exit 0; fi +if [ "$1" = "exec" ]; then exit 0; fi +exit 0 +`) +} + +func TestPHP_SetMedium_Good(t *T) { + old := DefaultMedium + t.Cleanup(func() { SetMedium(old) }) + SetMedium(coreio.Local) + AssertEqual(t, coreio.Local, DefaultMedium) +} + +func TestPHP_SetMedium_Bad(t *T) { + old := DefaultMedium + t.Cleanup(func() { SetMedium(old) }) + SetMedium(nil) + AssertEqual(t, nil, DefaultMedium) +} + +func TestPHP_SetMedium_Ugly(t *T) { + old := DefaultMedium + t.Cleanup(func() { SetMedium(old) }) + SetMedium(coreio.Local) + SetMedium(coreio.Local) + AssertEqual(t, coreio.Local, getMedium()) +} + +func TestPHP_AddCommands_Good(t *T) { + root := &cli.Command{} + AddCommands(root) + AssertGreater(t, len(root.Commands()), 0) +} + +func TestPHP_AddCommands_Bad(t *T) { + root := &cli.Command{Use: "root"} + AddCommands(root) + AssertEqual(t, "root", root.Use) +} + +func TestPHP_AddCommands_Ugly(t *T) { + root := &cli.Command{} + AddCommands(root) + AssertEqual(t, "php", root.Commands()[0].Use) +} + +func TestPHP_AddPHPCommands_Good(t *T) { + root := &cli.Command{} + AddPHPCommands(root) + AssertEqual(t, "php", root.Commands()[0].Use) +} + +func TestPHP_AddPHPCommands_Bad(t *T) { + root := &cli.Command{} + AddPHPCommands(root) + AssertGreaterOrEqual(t, len(root.Commands()[0].Commands()), 1) +} + +func TestPHP_AddPHPCommands_Ugly(t *T) { + root := &cli.Command{} + AddPHPCommands(root) + AssertNotNil(t, root.Commands()[0].PersistentPreRunE) +} + +func TestPHP_AddPHPRootCommands_Good(t *T) { + root := &cli.Command{} + AddPHPRootCommands(root) + AssertGreater(t, len(root.Commands()), 0) +} + +func TestPHP_AddPHPRootCommands_Bad(t *T) { + root := &cli.Command{} + AddPHPRootCommands(root) + AssertNotNil(t, root.PersistentPreRunE) +} + +func TestPHP_AddPHPRootCommands_Ugly(t *T) { + root := &cli.Command{Use: "php"} + AddPHPRootCommands(root) + AssertEqual(t, "php", root.Use) +} + +func TestPHP_DetectFormatter_Good(t *T) { + dir := ax7PHPProject(t) + ax7WriteFile(t, filepath.Join(dir, "pint.json"), "{}") + formatter, ok := DetectFormatter(dir) + AssertTrue(t, ok) + AssertEqual(t, FormatterPint, formatter) +} + +func TestPHP_DetectFormatter_Bad(t *T) { + dir := t.TempDir() + formatter, ok := DetectFormatter(dir) + AssertFalse(t, ok) + AssertEqual(t, FormatterType(""), formatter) +} + +func TestPHP_DetectFormatter_Ugly(t *T) { + dir := ax7PHPProject(t) + ax7Executable(t, filepath.Join(dir, "vendor", "bin"), "pint", "exit 0\n") + formatter, ok := DetectFormatter(dir) + AssertTrue(t, ok) + AssertEqual(t, FormatterPint, formatter) +} + +func TestPHP_DetectAnalyser_Good(t *T) { + dir := ax7PHPProject(t) + ax7WriteFile(t, filepath.Join(dir, "phpstan.neon"), "parameters: {}\n") + analyser, ok := DetectAnalyser(dir) + AssertTrue(t, ok) + AssertEqual(t, AnalyserPHPStan, analyser) +} + +func TestPHP_DetectAnalyser_Bad(t *T) { + dir := t.TempDir() + analyser, ok := DetectAnalyser(dir) + AssertFalse(t, ok) + AssertEqual(t, AnalyserType(""), analyser) +} + +func TestPHP_DetectAnalyser_Ugly(t *T) { + dir := ax7PHPProject(t) + ax7WriteFile(t, filepath.Join(dir, "phpstan.neon.dist"), "parameters: {}\n") + ax7WriteFile(t, filepath.Join(dir, "vendor", "larastan", "larastan", "extension.neon"), "") + analyser, ok := DetectAnalyser(dir) + AssertTrue(t, ok) + AssertEqual(t, AnalyserLarastan, analyser) +} + +func TestPHP_DetectPsalm_Good(t *T) { + dir := ax7PHPProject(t) + ax7WriteFile(t, filepath.Join(dir, "psalm.xml"), "") + psalm, ok := DetectPsalm(dir) + AssertTrue(t, ok) + AssertEqual(t, PsalmStandard, psalm) +} + +func TestPHP_DetectPsalm_Bad(t *T) { + dir := t.TempDir() + psalm, ok := DetectPsalm(dir) + AssertFalse(t, ok) + AssertEqual(t, PsalmType(""), psalm) +} + +func TestPHP_DetectPsalm_Ugly(t *T) { + dir := ax7PHPProject(t) + ax7Executable(t, filepath.Join(dir, "vendor", "bin"), "psalm", "exit 0\n") + psalm, ok := DetectPsalm(dir) + AssertTrue(t, ok) + AssertEqual(t, PsalmStandard, psalm) +} + +func TestPHP_DetectRector_Good(t *T) { + dir := ax7PHPProject(t) + ax7WriteFile(t, filepath.Join(dir, "rector.php"), " 'swoole'];") + AssertFalse(t, IsFrankenPHPProject(dir)) +} + +func TestPHP_IsPHPProject_Ugly(t *T) { + dir := t.TempDir() + ax7WriteFile(t, filepath.Join(dir, "composer.json"), "{") + AssertTrue(t, IsPHPProject(dir)) +} + +func TestPHP_GetLaravelAppName_Ugly(t *T) { + dir := t.TempDir() + ax7WriteFile(t, filepath.Join(dir, ".env"), "APP_NAME='Quoted Name'\n") + got := GetLaravelAppName(dir) + AssertEqual(t, "Quoted Name", got) +} + +func TestPHP_GetLaravelAppURL_Ugly(t *T) { + dir := t.TempDir() + ax7WriteFile(t, filepath.Join(dir, ".env"), "APP_URL='https://demo.test/path'\n") + got := GetLaravelAppURL(dir) + AssertEqual(t, "https://demo.test/path", got) +} + +func TestPHP_ExtractDomainFromURL_Bad(t *T) { + got := ExtractDomainFromURL("") + AssertEqual(t, "", got) + AssertFalse(t, strings.Contains(got, ":")) +} + +func TestPHP_Format_Good(t *T) { + dir := ax7CommandProject(t, "pint") + var out bytes.Buffer + err := Format(context.Background(), FormatOptions{Dir: dir, Fix: true, Output: &out}) + AssertNoError(t, err) +} + +func TestPHP_Format_Bad(t *T) { + dir := t.TempDir() + err := Format(context.Background(), FormatOptions{Dir: dir, Output: io.Discard}) + AssertError(t, err, "no formatter found") +} + +func TestPHP_Format_Ugly(t *T) { + dir := ax7CommandProject(t, "pint") + var out bytes.Buffer + err := Format(context.Background(), FormatOptions{Dir: dir, Diff: true, JSON: true, Paths: []string{"app"}, Output: &out}) + AssertNoError(t, err) +} + +func TestPHP_Analyse_Good(t *T) { + dir := ax7CommandProject(t, "phpstan") + ax7WriteFile(t, filepath.Join(dir, "phpstan.neon"), "parameters: {}\n") + err := Analyse(context.Background(), AnalyseOptions{Dir: dir, Level: 5, Output: io.Discard}) + AssertNoError(t, err) +} + +func TestPHP_Analyse_Bad(t *T) { + dir := t.TempDir() + err := Analyse(context.Background(), AnalyseOptions{Dir: dir, Output: io.Discard}) + AssertError(t, err, "no static analyser found") +} + +func TestPHP_Analyse_Ugly(t *T) { + dir := ax7CommandProject(t, "phpstan") + ax7WriteFile(t, filepath.Join(dir, "phpstan.neon"), "parameters: {}\n") + err := Analyse(context.Background(), AnalyseOptions{Dir: dir, JSON: true, SARIF: true, Paths: []string{"app"}, Output: io.Discard}) + AssertNoError(t, err) +} + +func TestPHP_RunPsalm_Good(t *T) { + dir := ax7CommandProject(t, "psalm") + err := RunPsalm(context.Background(), PsalmOptions{Dir: dir, Level: 3, Output: io.Discard}) + AssertNoError(t, err) +} + +func TestPHP_RunPsalm_Bad(t *T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := RunPsalm(ctx, PsalmOptions{Dir: t.TempDir(), Output: io.Discard}) + AssertError(t, err) +} + +func TestPHP_RunPsalm_Ugly(t *T) { + dir := ax7CommandProject(t, "psalm") + err := RunPsalm(context.Background(), PsalmOptions{Dir: dir, Fix: true, Baseline: true, ShowInfo: true, SARIF: true, Output: io.Discard}) + AssertNoError(t, err) +} + +func TestPHP_RunRector_Good(t *T) { + dir := ax7CommandProject(t, "rector") + err := RunRector(context.Background(), RectorOptions{Dir: dir, Output: io.Discard}) + AssertNoError(t, err) +} + +func TestPHP_RunRector_Bad(t *T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := RunRector(ctx, RectorOptions{Dir: t.TempDir(), Output: io.Discard}) + AssertError(t, err) +} + +func TestPHP_RunRector_Ugly(t *T) { + dir := ax7CommandProject(t, "rector") + err := RunRector(context.Background(), RectorOptions{Dir: dir, Fix: true, Diff: true, ClearCache: true, Output: io.Discard}) + AssertNoError(t, err) +} + +func TestPHP_RunInfection_Good(t *T) { + dir := ax7CommandProject(t, "infection") + err := RunInfection(context.Background(), InfectionOptions{Dir: dir, Output: io.Discard}) + AssertNoError(t, err) +} + +func TestPHP_RunInfection_Bad(t *T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := RunInfection(ctx, InfectionOptions{Dir: t.TempDir(), Output: io.Discard}) + AssertError(t, err) +} + +func TestPHP_RunInfection_Ugly(t *T) { + dir := ax7CommandProject(t, "infection") + err := RunInfection(context.Background(), InfectionOptions{Dir: dir, MinMSI: 80, MinCoveredMSI: 85, Threads: 1, Filter: "app", OnlyCovered: true, Output: io.Discard}) + AssertNoError(t, err) +} + +func TestPHP_RunTests_Good(t *T) { + dir := ax7CommandProject(t, "phpunit") + err := RunTests(context.Background(), TestOptions{Dir: dir, Output: io.Discard}) + AssertNoError(t, err) +} + +func TestPHP_RunTests_Bad(t *T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := RunTests(ctx, TestOptions{Dir: t.TempDir(), Output: io.Discard}) + AssertError(t, err) +} + +func TestPHP_RunTests_Ugly(t *T) { + dir := ax7CommandProject(t, "pest") + ax7WriteFile(t, filepath.Join(dir, "tests", "Pest.php"), " "$cert" +printf key > "$key" +`) + dir := t.TempDir() + err := SetupSSL("demo.test", SSLOptions{Dir: dir}) + AssertNoError(t, err) + AssertTrue(t, CertsExist("demo.test", SSLOptions{Dir: dir})) +} + +func TestPHP_SetupSSL_Ugly(t *T) { + bin := ax7BinPath(t) + ax7Executable(t, bin, "mkcert", "if [ \"$1\" = \"-install\" ]; then exit 0; fi\nexit 2\n") + err := SetupSSL("demo.test", SSLOptions{Dir: t.TempDir()}) + AssertError(t, err, "failed to generate certificates") +} + +func TestPHP_SetupSSLIfNeeded_Ugly(t *T) { + dir := t.TempDir() + ax7WriteFile(t, filepath.Join(dir, "demo.test.pem"), "cert") + _, _, err := SetupSSLIfNeeded("demo.test", SSLOptions{Dir: dir}) + AssertError(t, err) +} + +func TestPHP_IsMkcertInstalled_Bad(t *T) { + t.Setenv("PATH", t.TempDir()) + got := IsMkcertInstalled() + AssertFalse(t, got) +} + +func TestPHP_IsMkcertInstalled_Ugly(t *T) { + bin := ax7BinPath(t) + ax7Executable(t, bin, "mkcert", "exit 0\n") + got := IsMkcertInstalled() + AssertTrue(t, got) +} + +func TestPHP_InstallMkcertCA_Good(t *T) { + bin := ax7BinPath(t) + ax7Executable(t, bin, "mkcert", "exit 0\n") + err := InstallMkcertCA() + AssertNoError(t, err) +} + +func TestPHP_InstallMkcertCA_Ugly(t *T) { + bin := ax7BinPath(t) + ax7Executable(t, bin, "mkcert", "exit 3\n") + err := InstallMkcertCA() + AssertError(t, err, "failed to install") +} + +func TestPHP_GetMkcertCARoot_Good(t *T) { + bin := ax7BinPath(t) + ax7Executable(t, bin, "mkcert", "if [ \"$1\" = \"-CAROOT\" ]; then printf '/tmp/core-ca'; exit 0; fi\nexit 0\n") + root, err := GetMkcertCARoot() + AssertNoError(t, err) + AssertEqual(t, "/tmp/core-ca", root) +} + +func TestPHP_GetMkcertCARoot_Ugly(t *T) { + bin := ax7BinPath(t) + ax7Executable(t, bin, "mkcert", "if [ \"$1\" = \"-CAROOT\" ]; then exit 4; fi\nexit 0\n") + root, err := GetMkcertCARoot() + AssertError(t, err) + AssertEqual(t, "", root) +} + +func TestPHP_PrepareRuntimeEnvironment_Good(t *T) { + appName := "core-php-ax7-good" + ax7RuntimeCleanup(t, appName) + root := t.TempDir() + ax7WriteFile(t, filepath.Join(root, "storage", ".gitkeep"), "") + env, err := PrepareRuntimeEnvironment(root, appName) + AssertNoError(t, err) + AssertTrue(t, strings.HasSuffix(env.DatabasePath, appName+".sqlite")) +} + +func TestPHP_PrepareRuntimeEnvironment_Bad(t *T) { + appName := "core-php-ax7-bad" + ax7RuntimeCleanup(t, appName) + env, err := PrepareRuntimeEnvironment(filepath.Join(t.TempDir(), "missing"), appName) + AssertError(t, err) + AssertEqual(t, (*RuntimeEnvironment)(nil), env) +} + +func TestPHP_PrepareRuntimeEnvironment_Ugly(t *T) { + appName := "core-php-ax7-ugly" + ax7RuntimeCleanup(t, appName) + root := t.TempDir() + ax7WriteFile(t, filepath.Join(root, "storage", ".gitkeep"), "") + first, err := PrepareRuntimeEnvironment(root, appName) + AssertNoError(t, err) + AssertTrue(t, filepath.IsAbs(first.DataDir)) +} + +func TestPHP_AppendEnv_Good(t *T) { + root := t.TempDir() + ax7WriteFile(t, filepath.Join(root, ".env"), "APP_NAME=Demo\n") + err := AppendEnv(root, "NATIVE_BRIDGE_URL", "http://127.0.0.1:1") + AssertNoError(t, err) +} + +func TestPHP_AppendEnv_Bad(t *T) { + root := t.TempDir() + err := AppendEnv(root, "MISSING", "value") + AssertError(t, err) +} + +func TestPHP_AppendEnv_Ugly(t *T) { + root := t.TempDir() + ax7WriteFile(t, filepath.Join(root, ".env"), "") + err := AppendEnv(root, "SPACED", "value with spaces") + AssertNoError(t, err) +} + +func TestPHP_NewCoolifyClient_Bad(t *T) { + client := NewCoolifyClient("https://coolify.test/", "") + AssertEqual(t, "https://coolify.test", client.BaseURL) + AssertEqual(t, "", client.Token) +} + +func TestPHP_NewCoolifyClient_Ugly(t *T) { + client := NewCoolifyClient("http://127.0.0.1:8000///", "tok") + AssertEqual(t, "http://127.0.0.1:8000", client.BaseURL) + AssertNotNil(t, client.HTTPClient) +} + +func TestPHP_LoadCoolifyConfig_Ugly(t *T) { + dir := t.TempDir() + t.Setenv("COOLIFY_URL", "https://env.test") + t.Setenv("COOLIFY_TOKEN", "env-token") + config, err := LoadCoolifyConfig(dir) + AssertNoError(t, err) + AssertEqual(t, "https://env.test", config.URL) +} + +func TestPHP_LoadCoolifyConfigFromFile_Ugly(t *T) { + path := filepath.Join(t.TempDir(), ".env") + ax7WriteFile(t, path, "COOLIFY_URL='https://file.test'\nCOOLIFY_TOKEN=\"tok\"\n") + config, err := LoadCoolifyConfigFromFile(path) + AssertNoError(t, err) + AssertEqual(t, "https://file.test", config.URL) +} + +func TestPHP_CoolifyClient_TriggerDeploy_Ugly(t *T) { + server := ax7CoolifyServer(t, http.StatusAccepted) + defer server.Close() + deployment, err := NewCoolifyClient(server.URL, "tok").TriggerDeploy(context.Background(), "app-1", true) + AssertNoError(t, err) + AssertEqual(t, "deploy-1", deployment.ID) +} + +func TestPHP_CoolifyClient_GetDeployment_Ugly(t *T) { + server := ax7CoolifyServer(t, http.StatusOK) + defer server.Close() + deployment, err := NewCoolifyClient(server.URL, "tok").GetDeployment(context.Background(), "app-1", "deploy-1") + AssertNoError(t, err) + AssertEqual(t, "finished", deployment.Status) +} + +func TestPHP_CoolifyClient_ListDeployments_Bad(t *T) { + server := ax7CoolifyServer(t, http.StatusInternalServerError) + defer server.Close() + deployments, err := NewCoolifyClient(server.URL, "tok").ListDeployments(context.Background(), "app-1", 1) + AssertError(t, err) + AssertEqual(t, []CoolifyDeployment(nil), deployments) +} + +func TestPHP_CoolifyClient_ListDeployments_Ugly(t *T) { + server := ax7CoolifyServer(t, http.StatusOK) + defer server.Close() + deployments, err := NewCoolifyClient(server.URL, "tok").ListDeployments(context.Background(), "app-1", 0) + AssertNoError(t, err) + AssertLen(t, deployments, 2) +} + +func TestPHP_CoolifyClient_Rollback_Bad(t *T) { + server := ax7CoolifyServer(t, http.StatusBadRequest) + defer server.Close() + deployment, err := NewCoolifyClient(server.URL, "tok").Rollback(context.Background(), "app-1", "bad") + AssertError(t, err) + AssertEqual(t, (*CoolifyDeployment)(nil), deployment) +} + +func TestPHP_CoolifyClient_Rollback_Ugly(t *T) { + server := ax7CoolifyServer(t, http.StatusOK) + defer server.Close() + deployment, err := NewCoolifyClient(server.URL, "tok").Rollback(context.Background(), "app-1", "previous") + AssertNoError(t, err) + AssertEqual(t, "rollback-1", deployment.ID) +} + +func TestPHP_CoolifyClient_GetApp_Bad(t *T) { + server := ax7CoolifyServer(t, http.StatusNotFound) + defer server.Close() + app, err := NewCoolifyClient(server.URL, "tok").GetApp(context.Background(), "missing") + AssertError(t, err) + AssertEqual(t, (*CoolifyApp)(nil), app) +} + +func TestPHP_CoolifyClient_GetApp_Ugly(t *T) { + server := ax7CoolifyServer(t, http.StatusOK) + defer server.Close() + app, err := NewCoolifyClient(server.URL, "tok").GetApp(context.Background(), "app-1") + AssertNoError(t, err) + AssertEqual(t, "https://demo.test", app.FQDN) +} + +func TestPHP_Deploy_Good(t *T) { + server := ax7CoolifyServer(t, http.StatusOK) + defer server.Close() + status, err := Deploy(context.Background(), DeployOptions{Dir: ax7CoolifyProject(t, server.URL)}) + AssertNoError(t, err) + AssertEqual(t, "deploy-1", status.ID) +} + +func TestPHP_Deploy_Bad(t *T) { + status, err := Deploy(context.Background(), DeployOptions{Dir: t.TempDir()}) + AssertError(t, err) + AssertEqual(t, (*DeploymentStatus)(nil), status) +} + +func TestPHP_Deploy_Ugly(t *T) { + server := ax7CoolifyServer(t, http.StatusOK) + defer server.Close() + status, err := Deploy(context.Background(), DeployOptions{Dir: ax7CoolifyProject(t, server.URL), Environment: EnvStaging, Force: true, Wait: true, PollInterval: time.Millisecond}) + AssertNoError(t, err) + AssertEqual(t, "https://demo.test", status.URL) +} + +func TestPHP_DeployStatus_Good(t *T) { + server := ax7CoolifyServer(t, http.StatusOK) + defer server.Close() + status, err := DeployStatus(context.Background(), StatusOptions{Dir: ax7CoolifyProject(t, server.URL), DeploymentID: "deploy-1"}) + AssertNoError(t, err) + AssertEqual(t, "finished", status.Status) +} + +func TestPHP_DeployStatus_Bad(t *T) { + status, err := DeployStatus(context.Background(), StatusOptions{Dir: t.TempDir()}) + AssertError(t, err) + AssertEqual(t, (*DeploymentStatus)(nil), status) +} + +func TestPHP_DeployStatus_Ugly(t *T) { + server := ax7CoolifyServer(t, http.StatusOK) + defer server.Close() + status, err := DeployStatus(context.Background(), StatusOptions{Dir: ax7CoolifyProject(t, server.URL)}) + AssertNoError(t, err) + AssertEqual(t, "current", status.ID) +} + +func TestPHP_Rollback_Bad(t *T) { + status, err := Rollback(context.Background(), RollbackOptions{Dir: t.TempDir()}) + AssertError(t, err) + AssertEqual(t, (*DeploymentStatus)(nil), status) +} + +func TestPHP_Rollback_Ugly(t *T) { + server := ax7CoolifyServer(t, http.StatusOK) + defer server.Close() + status, err := Rollback(context.Background(), RollbackOptions{Dir: ax7CoolifyProject(t, server.URL), DeploymentID: "previous"}) + AssertNoError(t, err) + AssertEqual(t, "rollback-1", status.ID) +} + +func TestPHP_ListDeployments_Bad(t *T) { + deployments, err := ListDeployments(context.Background(), t.TempDir(), EnvProduction, 1) + AssertError(t, err) + AssertEqual(t, []DeploymentStatus(nil), deployments) +} + +func TestPHP_ListDeployments_Ugly(t *T) { + server := ax7CoolifyServer(t, http.StatusOK) + defer server.Close() + deployments, err := ListDeployments(context.Background(), ax7CoolifyProject(t, server.URL), EnvStaging, 0) + AssertNoError(t, err) + AssertLen(t, deployments, 2) +} + +func TestPHP_IsDeploymentComplete_Bad(t *T) { + status := "deploying" + got := IsDeploymentComplete(status) + AssertFalse(t, got) +} + +func TestPHP_IsDeploymentSuccessful_Bad(t *T) { + status := "failed" + got := IsDeploymentSuccessful(status) + AssertFalse(t, got) +} + +func TestPHP_NewBridge_Good(t *T) { + bridge, err := NewBridge(ax7BridgeHandler{}) + RequireNoError(t, err) + t.Cleanup(func() { _ = bridge.Shutdown(context.Background()) }) + AssertGreater(t, bridge.Port(), 0) +} + +func TestPHP_NewBridge_Bad(t *T) { + bridge, err := NewBridge(nil) + RequireNoError(t, err) + t.Cleanup(func() { _ = bridge.Shutdown(context.Background()) }) + AssertNotNil(t, bridge) +} + +func TestPHP_NewBridge_Ugly(t *T) { + bridge, err := NewBridge(ax7BridgeHandler{}) + RequireNoError(t, err) + t.Cleanup(func() { _ = bridge.Shutdown(context.Background()) }) + resp, err := http.Get(bridge.URL() + "/bridge/health") + AssertNoError(t, err) + AssertEqual(t, http.StatusOK, resp.StatusCode) +} + +func TestPHP_Bridge_Port_Good(t *T) { + bridge, err := NewBridge(ax7BridgeHandler{}) + RequireNoError(t, err) + t.Cleanup(func() { _ = bridge.Shutdown(context.Background()) }) + AssertGreater(t, bridge.Port(), 0) +} + +func TestPHP_Bridge_Port_Bad(t *T) { + bridge := &Bridge{} + port := bridge.Port() + AssertEqual(t, 0, port) +} + +func TestPHP_Bridge_Port_Ugly(t *T) { + bridge := &Bridge{port: -1} + port := bridge.Port() + AssertEqual(t, -1, port) +} + +func TestPHP_Bridge_URL_Good(t *T) { + bridge := &Bridge{port: 1234} + got := bridge.URL() + AssertEqual(t, "http://127.0.0.1:1234", got) +} + +func TestPHP_Bridge_URL_Bad(t *T) { + bridge := &Bridge{} + got := bridge.URL() + AssertContains(t, got, ":0") +} + +func TestPHP_Bridge_URL_Ugly(t *T) { + bridge := &Bridge{port: 65535} + got := bridge.URL() + AssertEqual(t, "http://127.0.0.1:65535", got) +} + +func TestPHP_Bridge_Shutdown_Good(t *T) { + bridge, err := NewBridge(ax7BridgeHandler{}) + RequireNoError(t, err) + err = bridge.Shutdown(context.Background()) + AssertNoError(t, err) +} + +func TestPHP_Bridge_Shutdown_Bad(t *T) { + bridge, err := NewBridge(ax7BridgeHandler{}) + RequireNoError(t, err) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err = bridge.Shutdown(ctx) + AssertNoError(t, err) + AssertGreater(t, bridge.Port(), 0) +} + +func TestPHP_Bridge_Shutdown_Ugly(t *T) { + bridge, err := NewBridge(ax7BridgeHandler{}) + RequireNoError(t, err) + _ = bridge.Shutdown(context.Background()) + err = bridge.Shutdown(context.Background()) + AssertNoError(t, err) +} + +func TestPHP_NewHandler_Good(t *T) { + root := t.TempDir() + handler, cleanup, err := NewHandler(root, HandlerConfig{}) + t.Cleanup(cleanup) + AssertError(t, err, "not built") + AssertEqual(t, filepath.Join(root, "public"), handler.DocRoot()) +} + +func TestPHP_NewHandler_Bad(t *T) { + handler, cleanup, err := NewHandler("", HandlerConfig{NumThreads: 1, NumWorkers: 1}) + t.Cleanup(cleanup) + AssertError(t, err) + AssertEqual(t, "public", handler.DocRoot()) +} + +func TestPHP_NewHandler_Ugly(t *T) { + root := filepath.Join(t.TempDir(), "space path") + handler, cleanup, err := NewHandler(root, HandlerConfig{PHPIni: map[string]string{"x": "y"}}) + t.Cleanup(cleanup) + AssertError(t, err) + AssertEqual(t, root, handler.LaravelRoot()) +} + +func TestPHP_Handler_LaravelRoot_Good(t *T) { + handler := &Handler{laravelRoot: "/app", docRoot: "/app/public"} + got := handler.LaravelRoot() + AssertEqual(t, "/app", got) +} + +func TestPHP_Handler_LaravelRoot_Bad(t *T) { + handler := &Handler{} + got := handler.LaravelRoot() + AssertEqual(t, "", got) +} + +func TestPHP_Handler_LaravelRoot_Ugly(t *T) { + handler := &Handler{laravelRoot: "/tmp/a b"} + got := handler.LaravelRoot() + AssertContains(t, got, "a b") +} + +func TestPHP_Handler_DocRoot_Good(t *T) { + handler := &Handler{laravelRoot: "/app", docRoot: "/app/public"} + got := handler.DocRoot() + AssertEqual(t, "/app/public", got) +} + +func TestPHP_Handler_DocRoot_Bad(t *T) { + handler := &Handler{} + got := handler.DocRoot() + AssertEqual(t, "", got) +} + +func TestPHP_Handler_DocRoot_Ugly(t *T) { + handler := &Handler{docRoot: filepath.Join("relative", "public")} + got := handler.DocRoot() + AssertContains(t, got, "public") +} + +func TestPHP_Handler_ServeHTTP_Good(t *T) { + handler := &Handler{} + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + AssertEqual(t, http.StatusNotImplemented, rec.Code) +} + +func TestPHP_Handler_ServeHTTP_Bad(t *T) { + handler := &Handler{} + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/missing.php", nil)) + AssertContains(t, rec.Body.String(), "not built") +} + +func TestPHP_Handler_ServeHTTP_Ugly(t *T) { + handler := &Handler{docRoot: t.TempDir()} + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/assets/app.css?x=1", nil)) + AssertEqual(t, http.StatusNotImplemented, rec.Code) +} + +func TestPHP_ResponseWriter_Header_Good(t *T) { + out := ax7TempFile(t) + writer := &execResponseWriter{out: out} + header := writer.Header() + AssertNotNil(t, header) +} + +func TestPHP_ResponseWriter_Header_Bad(t *T) { + writer := &execResponseWriter{out: nil} + header := writer.Header() + AssertLen(t, header, 0) +} + +func TestPHP_ResponseWriter_Header_Ugly(t *T) { + out := ax7TempFile(t) + writer := &execResponseWriter{out: out} + header := writer.Header() + AssertEqual(t, http.Header{}, header) +} + +func TestPHP_ResponseWriter_Write_Good(t *T) { + out := ax7TempFile(t) + writer := &execResponseWriter{out: out} + n, err := writer.Write([]byte("hello")) + AssertNoError(t, err) + AssertEqual(t, 5, n) +} + +func TestPHP_ResponseWriter_Write_Bad(t *T) { + out := ax7TempFile(t) + RequireNoError(t, out.Close()) + writer := &execResponseWriter{out: out} + n, err := writer.Write([]byte("hello")) + AssertError(t, err) + AssertEqual(t, 0, n) +} + +func TestPHP_ResponseWriter_Write_Ugly(t *T) { + out := ax7TempFile(t) + writer := &execResponseWriter{out: out} + n, err := writer.Write(nil) + AssertNoError(t, err) + AssertEqual(t, 0, n) +} + +func TestPHP_ResponseWriter_WriteHeader_Good(t *T) { + out := ax7TempFile(t) + writer := &execResponseWriter{out: out} + writer.WriteHeader(http.StatusCreated) + AssertNotNil(t, writer) +} + +func TestPHP_ResponseWriter_WriteHeader_Bad(t *T) { + writer := &execResponseWriter{out: nil} + writer.WriteHeader(http.StatusInternalServerError) + AssertEqual(t, (*os.File)(nil), writer.out) +} + +func TestPHP_ResponseWriter_WriteHeader_Ugly(t *T) { + out := ax7TempFile(t) + writer := &execResponseWriter{out: out} + writer.WriteHeader(0) + AssertEqual(t, out, writer.out) +} + +func TestPHP_NewFrankenPHPService_Bad(t *T) { + service := NewFrankenPHPService("", FrankenPHPOptions{}) + AssertEqual(t, "FrankenPHP", service.Name()) + AssertEqual(t, 8000, service.Status().Port) +} + +func TestPHP_NewFrankenPHPService_Ugly(t *T) { + service := NewFrankenPHPService("/app", FrankenPHPOptions{Port: 9000, HTTPS: true, HTTPSPort: 9443, CertFile: "cert", KeyFile: "key"}) + AssertEqual(t, 9000, service.Status().Port) + AssertTrue(t, service.https) +} + +func TestPHP_NewViteService_Bad(t *T) { + service := NewViteService("", ViteOptions{}) + AssertEqual(t, "Vite", service.Name()) + AssertEqual(t, 5173, service.Status().Port) +} + +func TestPHP_NewViteService_Ugly(t *T) { + service := NewViteService(t.TempDir(), ViteOptions{Port: 3000, PackageManager: "pnpm"}) + AssertEqual(t, 3000, service.Status().Port) + AssertEqual(t, "pnpm", service.packageManager) +} + +func TestPHP_NewHorizonService_Bad(t *T) { + service := NewHorizonService("") + AssertEqual(t, "Horizon", service.Name()) + AssertEqual(t, 0, service.Status().Port) +} + +func TestPHP_NewHorizonService_Ugly(t *T) { + dir := filepath.Join(t.TempDir(), "app") + service := NewHorizonService(dir) + AssertEqual(t, dir, service.dir) + AssertFalse(t, service.Status().Running) +} + +func TestPHP_NewReverbService_Bad(t *T) { + service := NewReverbService("", ReverbOptions{}) + AssertEqual(t, "Reverb", service.Name()) + AssertEqual(t, 8080, service.Status().Port) +} + +func TestPHP_NewReverbService_Ugly(t *T) { + service := NewReverbService(t.TempDir(), ReverbOptions{Port: 9090}) + AssertEqual(t, 9090, service.Status().Port) + AssertFalse(t, service.Status().Running) +} + +func TestPHP_NewRedisService_Bad(t *T) { + service := NewRedisService("", RedisOptions{}) + AssertEqual(t, "Redis", service.Name()) + AssertEqual(t, 6379, service.Status().Port) +} + +func TestPHP_NewRedisService_Ugly(t *T) { + service := NewRedisService(t.TempDir(), RedisOptions{Port: 6380, ConfigFile: "redis.conf"}) + AssertEqual(t, 6380, service.Status().Port) + AssertEqual(t, "redis.conf", service.configFile) +} + +func TestPHP_Service_Name_Good(t *T) { + service := NewViteService(t.TempDir(), ViteOptions{}) + name := service.Name() + AssertEqual(t, "Vite", name) +} + +func TestPHP_Service_Name_Bad(t *T) { + service := &baseService{} + name := service.Name() + AssertEqual(t, "", name) +} + +func TestPHP_Service_Name_Ugly(t *T) { + service := &baseService{name: "Custom Service"} + name := service.Name() + AssertContains(t, name, "Custom") +} + +func TestPHP_Service_Status_Good(t *T) { + service := NewRedisService(t.TempDir(), RedisOptions{Port: 6380}) + status := service.Status() + AssertEqual(t, "Redis", status.Name) + AssertEqual(t, 6380, status.Port) +} + +func TestPHP_Service_Status_Bad(t *T) { + service := &baseService{lastError: errors.New("failed")} + status := service.Status() + AssertError(t, status.Error) +} + +func TestPHP_Service_Status_Ugly(t *T) { + service := &baseService{name: "Running", running: true, port: 1} + status := service.Status() + AssertTrue(t, status.Running) + AssertEqual(t, 1, status.Port) +} + +func TestPHP_Service_Logs_Good(t *T) { + dir := t.TempDir() + path := filepath.Join(dir, "service.log") + ax7WriteFile(t, path, "hello") + service := &baseService{name: "Log", logPath: path} + reader, err := service.Logs(false) + AssertNoError(t, err) + reader.Close() +} + +func TestPHP_Service_Logs_Bad(t *T) { + service := &baseService{name: "NoLog"} + reader, err := service.Logs(false) + AssertError(t, err, "no log file") + AssertEqual(t, nil, reader) +} + +func TestPHP_Service_Logs_Ugly(t *T) { + dir := t.TempDir() + path := filepath.Join(dir, "service.log") + ax7WriteFile(t, path, "hello") + service := &baseService{name: "Log", logPath: path} + reader, err := service.Logs(true) + AssertNoError(t, err) + reader.Close() +} + +func TestPHP_FrankenPHPService_Start_Good(t *T) { + ax7LongRunningCommand(t, "php") + service := NewFrankenPHPService(t.TempDir(), FrankenPHPOptions{}) + err := service.Start(context.Background()) + t.Cleanup(func() { _ = service.Stop() }) + AssertNoError(t, err) +} + +func TestPHP_FrankenPHPService_Start_Bad(t *T) { + t.Setenv("PATH", t.TempDir()) + service := NewFrankenPHPService(t.TempDir(), FrankenPHPOptions{}) + err := service.Start(context.Background()) + AssertError(t, err) +} + +func TestPHP_FrankenPHPService_Start_Ugly(t *T) { + ax7LongRunningCommand(t, "php") + service := NewFrankenPHPService(t.TempDir(), FrankenPHPOptions{HTTPS: true, CertFile: "cert", KeyFile: "key"}) + service.running = true + err := service.Start(context.Background()) + t.Cleanup(func() { _ = service.Stop() }) + AssertError(t, err, "already running") +} + +func TestPHP_FrankenPHPService_Stop_Good(t *T) { + service := NewFrankenPHPService(t.TempDir(), FrankenPHPOptions{}) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_FrankenPHPService_Stop_Bad(t *T) { + service := NewFrankenPHPService("", FrankenPHPOptions{}) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_FrankenPHPService_Stop_Ugly(t *T) { + ax7LongRunningCommand(t, "php") + service := NewFrankenPHPService(t.TempDir(), FrankenPHPOptions{}) + RequireNoError(t, service.Start(context.Background())) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_ViteService_Start_Good(t *T) { + ax7LongRunningCommand(t, "npm") + service := NewViteService(t.TempDir(), ViteOptions{PackageManager: "npm"}) + err := service.Start(context.Background()) + t.Cleanup(func() { _ = service.Stop() }) + AssertNoError(t, err) +} + +func TestPHP_ViteService_Start_Bad(t *T) { + t.Setenv("PATH", t.TempDir()) + service := NewViteService(t.TempDir(), ViteOptions{PackageManager: "npm"}) + err := service.Start(context.Background()) + AssertError(t, err) +} + +func TestPHP_ViteService_Start_Ugly(t *T) { + ax7LongRunningCommand(t, "yarn") + service := NewViteService(t.TempDir(), ViteOptions{PackageManager: "yarn"}) + err := service.Start(context.Background()) + t.Cleanup(func() { _ = service.Stop() }) + AssertNoError(t, err) +} + +func TestPHP_ViteService_Stop_Good(t *T) { + service := NewViteService(t.TempDir(), ViteOptions{}) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_ViteService_Stop_Bad(t *T) { + service := NewViteService("", ViteOptions{}) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_ViteService_Stop_Ugly(t *T) { + ax7LongRunningCommand(t, "npm") + service := NewViteService(t.TempDir(), ViteOptions{PackageManager: "npm"}) + RequireNoError(t, service.Start(context.Background())) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_HorizonService_Start_Good(t *T) { + ax7LongRunningCommand(t, "php") + service := NewHorizonService(t.TempDir()) + err := service.Start(context.Background()) + t.Cleanup(func() { _ = service.Stop() }) + AssertNoError(t, err) +} + +func TestPHP_HorizonService_Start_Bad(t *T) { + t.Setenv("PATH", t.TempDir()) + service := NewHorizonService(t.TempDir()) + err := service.Start(context.Background()) + AssertError(t, err) +} + +func TestPHP_HorizonService_Start_Ugly(t *T) { + ax7LongRunningCommand(t, "php") + service := NewHorizonService(t.TempDir()) + service.running = true + err := service.Start(context.Background()) + t.Cleanup(func() { _ = service.Stop() }) + AssertError(t, err) +} + +func TestPHP_HorizonService_Stop_Good(t *T) { + service := NewHorizonService(t.TempDir()) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_HorizonService_Stop_Bad(t *T) { + t.Setenv("PATH", t.TempDir()) + service := NewHorizonService(t.TempDir()) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_HorizonService_Stop_Ugly(t *T) { + ax7LongRunningCommand(t, "php") + service := NewHorizonService(t.TempDir()) + RequireNoError(t, service.Start(context.Background())) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_ReverbService_Start_Good(t *T) { + ax7LongRunningCommand(t, "php") + service := NewReverbService(t.TempDir(), ReverbOptions{}) + err := service.Start(context.Background()) + t.Cleanup(func() { _ = service.Stop() }) + AssertNoError(t, err) +} + +func TestPHP_ReverbService_Start_Bad(t *T) { + t.Setenv("PATH", t.TempDir()) + service := NewReverbService(t.TempDir(), ReverbOptions{}) + err := service.Start(context.Background()) + AssertError(t, err) +} + +func TestPHP_ReverbService_Start_Ugly(t *T) { + ax7LongRunningCommand(t, "php") + service := NewReverbService(t.TempDir(), ReverbOptions{Port: 9090}) + err := service.Start(context.Background()) + t.Cleanup(func() { _ = service.Stop() }) + AssertNoError(t, err) +} + +func TestPHP_ReverbService_Stop_Good(t *T) { + service := NewReverbService(t.TempDir(), ReverbOptions{}) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_ReverbService_Stop_Bad(t *T) { + service := NewReverbService("", ReverbOptions{}) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_ReverbService_Stop_Ugly(t *T) { + ax7LongRunningCommand(t, "php") + service := NewReverbService(t.TempDir(), ReverbOptions{}) + RequireNoError(t, service.Start(context.Background())) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_RedisService_Start_Good(t *T) { + ax7LongRunningCommand(t, "redis-server") + service := NewRedisService(t.TempDir(), RedisOptions{}) + err := service.Start(context.Background()) + t.Cleanup(func() { _ = service.Stop() }) + AssertNoError(t, err) +} + +func TestPHP_RedisService_Start_Bad(t *T) { + t.Setenv("PATH", t.TempDir()) + service := NewRedisService(t.TempDir(), RedisOptions{}) + err := service.Start(context.Background()) + AssertError(t, err) +} + +func TestPHP_RedisService_Start_Ugly(t *T) { + ax7LongRunningCommand(t, "redis-server") + service := NewRedisService(t.TempDir(), RedisOptions{ConfigFile: "redis.conf"}) + err := service.Start(context.Background()) + t.Cleanup(func() { _ = service.Stop() }) + AssertNoError(t, err) +} + +func TestPHP_RedisService_Stop_Good(t *T) { + service := NewRedisService(t.TempDir(), RedisOptions{}) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_RedisService_Stop_Bad(t *T) { + t.Setenv("PATH", t.TempDir()) + service := NewRedisService(t.TempDir(), RedisOptions{}) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_RedisService_Stop_Ugly(t *T) { + ax7LongRunningCommand(t, "redis-server") + ax7LongRunningCommand(t, "redis-cli") + service := NewRedisService(t.TempDir(), RedisOptions{}) + RequireNoError(t, service.Start(context.Background())) + err := service.Stop() + AssertNoError(t, err) +} + +func TestPHP_NewDevServer_Bad(t *T) { + server := NewDevServer(Options{}) + AssertNotNil(t, server) + AssertLen(t, server.Services(), 0) +} + +func TestPHP_NewDevServer_Ugly(t *T) { + server := NewDevServer(Options{Services: []DetectedService{ServiceRedis}, RedisPort: 6380}) + AssertEqual(t, 6380, server.opts.RedisPort) + AssertContains(t, server.opts.Services, ServiceRedis) +} + +func TestPHP_DevServer_Start_Good(t *T) { + ax7LongRunningCommand(t, "php") + dir := ax7LaravelProject(t) + server := NewDevServer(Options{Dir: dir, Services: []DetectedService{ServiceFrankenPHP}}) + err := server.Start(context.Background(), Options{Dir: dir, Services: []DetectedService{ServiceFrankenPHP}}) + t.Cleanup(func() { _ = server.Stop() }) + AssertNoError(t, err) +} + +func TestPHP_DevServer_Start_Ugly(t *T) { + server := NewDevServer(Options{}) + server.running = true + err := server.Start(context.Background(), Options{}) + AssertError(t, err, "already running") +} + +func TestPHP_DevServer_Stop_Bad(t *T) { + server := NewDevServer(Options{}) + server.running = true + server.services = []Service{&ax7Service{name: "bad", stopErr: errors.New("stop failed")}} + err := server.Stop() + AssertError(t, err, "errors stopping") +} + +func TestPHP_DevServer_Stop_Ugly(t *T) { + server := NewDevServer(Options{}) + server.running = true + server.cancel = func() {} + err := server.Stop() + AssertNoError(t, err) +} + +func TestPHP_DevServer_Logs_Ugly(t *T) { + server := NewDevServer(Options{}) + reader, err := server.Logs("missing", false) + AssertError(t, err, "service not found") + AssertEqual(t, nil, reader) +} + +func TestPHP_DevServer_Status_Bad(t *T) { + server := NewDevServer(Options{}) + status := server.Status() + AssertLen(t, status, 0) +} + +func TestPHP_DevServer_Status_Ugly(t *T) { + server := NewDevServer(Options{}) + server.services = []Service{&ax7Service{name: "svc", status: ServiceStatus{Name: "svc", Running: true}}} + status := server.Status() + AssertTrue(t, status[0].Running) +} + +func TestPHP_DevServer_IsRunning_Bad(t *T) { + server := NewDevServer(Options{}) + running := server.IsRunning() + AssertFalse(t, running) + AssertLen(t, server.Services(), 0) +} + +func TestPHP_DevServer_IsRunning_Ugly(t *T) { + server := NewDevServer(Options{}) + server.running = true + AssertTrue(t, server.IsRunning()) +} + +func TestPHP_DevServer_Services_Bad(t *T) { + server := NewDevServer(Options{}) + services := server.Services() + AssertLen(t, services, 0) +} + +func TestPHP_DevServer_Services_Ugly(t *T) { + server := NewDevServer(Options{}) + server.services = []Service{&ax7Service{name: "svc"}} + services := server.Services() + AssertEqual(t, "svc", services[0].Name()) +} + +func TestPHP_Reader_Read_Good(t *T) { + path := filepath.Join(t.TempDir(), "tail.log") + ax7WriteFile(t, path, "line") + file, err := os.Open(path) + RequireNoError(t, err) + reader := newTailReader(file) + buf := make([]byte, 8) + n, err := reader.Read(buf) + AssertNoError(t, err) + AssertEqual(t, "line", string(buf[:n])) +} + +func TestPHP_Reader_Read_Bad(t *T) { + path := filepath.Join(t.TempDir(), "tail.log") + ax7WriteFile(t, path, "line") + file, err := os.Open(path) + RequireNoError(t, err) + reader := newTailReader(file) + RequireNoError(t, reader.Close()) + n, err := reader.Read(make([]byte, 8)) + AssertEqual(t, 0, n) + AssertEqual(t, io.EOF, err) +} + +func TestPHP_Reader_Read_Ugly(t *T) { + path := filepath.Join(t.TempDir(), "tail.log") + ax7WriteFile(t, path, "abc") + file, err := os.Open(path) + RequireNoError(t, err) + reader := newTailReader(file) + buf := make([]byte, 1) + n, err := reader.Read(buf) + AssertNoError(t, err) + AssertEqual(t, 1, n) +} + +func TestPHP_Reader_Close_Good(t *T) { + path := filepath.Join(t.TempDir(), "tail.log") + ax7WriteFile(t, path, "line") + file, err := os.Open(path) + RequireNoError(t, err) + reader := newTailReader(file) + err = reader.Close() + AssertNoError(t, err) +} + +func TestPHP_Reader_Close_Bad(t *T) { + path := filepath.Join(t.TempDir(), "tail.log") + ax7WriteFile(t, path, "line") + file, err := os.Open(path) + RequireNoError(t, err) + reader := newTailReader(file) + RequireNoError(t, reader.Close()) + err = reader.Close() + AssertError(t, err) +} + +func TestPHP_Reader_Close_Ugly(t *T) { + path := filepath.Join(t.TempDir(), "tail.log") + ax7WriteFile(t, path, "") + file, err := os.Open(path) + RequireNoError(t, err) + reader := newTailReader(file) + err = reader.Close() + AssertNoError(t, err) +} + +func TestPHP_ServiceReader_Read_Good(t *T) { + reader := newMultiServiceReader([]Service{&ax7Service{name: "svc"}}, []io.ReadCloser{io.NopCloser(strings.NewReader("log"))}, false) + buf := make([]byte, 32) + n, err := reader.Read(buf) + AssertNoError(t, err) + AssertContains(t, string(buf[:n]), "[svc] log") +} + +func TestPHP_ServiceReader_Read_Bad(t *T) { + reader := newMultiServiceReader(nil, nil, false) + n, err := reader.Read(make([]byte, 4)) + AssertEqual(t, 0, n) + AssertEqual(t, io.EOF, err) +} + +func TestPHP_ServiceReader_Read_Ugly(t *T) { + reader := newMultiServiceReader(nil, nil, true) + n, err := reader.Read(make([]byte, 4)) + AssertNoError(t, err) + AssertEqual(t, 0, n) +} + +func TestPHP_ServiceReader_Close_Good(t *T) { + reader := newMultiServiceReader(nil, []io.ReadCloser{io.NopCloser(strings.NewReader(""))}, false) + err := reader.Close() + AssertNoError(t, err) +} + +func TestPHP_ServiceReader_Close_Bad(t *T) { + reader := newMultiServiceReader(nil, []io.ReadCloser{ax7FailingCloser{}}, false) + err := reader.Close() + AssertError(t, err, "close failed") +} + +func TestPHP_ServiceReader_Close_Ugly(t *T) { + reader := newMultiServiceReader(nil, nil, false) + err := reader.Close() + AssertNoError(t, err) +} + +func TestPHP_LinkPackages_Ugly(t *T) { + dir := ax7PHPProject(t) + pkg := t.TempDir() + ax7WriteFile(t, filepath.Join(pkg, "composer.json"), `{"name":"acme/package","version":"dev-main"}`) + err := LinkPackages(dir, []string{pkg}) + AssertNoError(t, err) +} + +func TestPHP_UnlinkPackages_Ugly(t *T) { + dir := ax7PHPProject(t) + pkg := t.TempDir() + ax7WriteFile(t, filepath.Join(pkg, "composer.json"), `{"name":"acme/package"}`) + RequireNoError(t, LinkPackages(dir, []string{pkg})) + err := UnlinkPackages(dir, []string{"acme/package"}) + AssertNoError(t, err) +} + +func TestPHP_UpdatePackages_Ugly(t *T) { + bin := ax7BinPath(t) + ax7Executable(t, bin, "composer", "exit 0\n") + dir := ax7PHPProject(t) + err := UpdatePackages(dir, []string{}) + AssertNoError(t, err) +} + +func TestPHP_ListLinkedPackages_Ugly(t *T) { + dir := ax7PHPProject(t) + packages, err := ListLinkedPackages(dir) + AssertNoError(t, err) + AssertLen(t, packages, 0) +} diff --git a/pkg/php/cmd_serve_frankenphp.go b/pkg/php/cmd_serve_frankenphp.go index 58aaa1e..ac479ad 100644 --- a/pkg/php/cmd_serve_frankenphp.go +++ b/pkg/php/cmd_serve_frankenphp.go @@ -1,4 +1,4 @@ -//go:build cgo +//go:build frankenphp package php @@ -120,6 +120,6 @@ type execResponseWriter struct { out *os.File } -func (w *execResponseWriter) Header() http.Header { return http.Header{} } +func (w *execResponseWriter) Header() http.Header { return http.Header{} } func (w *execResponseWriter) WriteHeader(statusCode int) {} func (w *execResponseWriter) Write(b []byte) (int, error) { return w.out.Write(b) } diff --git a/pkg/php/cmd_serve_frankenphp_stub.go b/pkg/php/cmd_serve_frankenphp_stub.go new file mode 100644 index 0000000..2e80f1d --- /dev/null +++ b/pkg/php/cmd_serve_frankenphp_stub.go @@ -0,0 +1,17 @@ +//go:build !frankenphp + +package php + +import ( + "net/http" + "os" +) + +// execResponseWriter writes HTTP response body directly to stdout. +type execResponseWriter struct { + out *os.File +} + +func (w *execResponseWriter) Header() http.Header { return http.Header{} } +func (w *execResponseWriter) WriteHeader(_ int) {} +func (w *execResponseWriter) Write(b []byte) (int, error) { return w.out.Write(b) } diff --git a/pkg/php/container.go b/pkg/php/container.go index e6c4719..cfe02a6 100644 --- a/pkg/php/container.go +++ b/pkg/php/container.go @@ -308,10 +308,9 @@ func ServeProduction(ctx context.Context, opts ServeOptions) error { args = append(args, imageRef) cmd := exec.CommandContext(ctx, "docker", args...) - cmd.Stdout = opts.Output - cmd.Stderr = opts.Output if opts.Detach { + cmd.Stderr = opts.Output output, err := cmd.Output() if err != nil { return cli.WrapVerb(err, "start", "container") @@ -321,6 +320,8 @@ func ServeProduction(ctx context.Context, opts ServeOptions) error { return nil } + cmd.Stdout = opts.Output + cmd.Stderr = opts.Output return cmd.Run() } diff --git a/pkg/php/container_test.go b/pkg/php/container_test.go index c0d0e19..7beddb3 100644 --- a/pkg/php/container_test.go +++ b/pkg/php/container_test.go @@ -4,14 +4,10 @@ import ( "context" "os" "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestDockerBuildOptions_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestPHP_DockerBuildOptions_Good(t *T) { + t.Run("all fields accessible", func(t *T) { opts := DockerBuildOptions{ ProjectDir: "/project", ImageName: "myapp", @@ -23,19 +19,19 @@ func TestDockerBuildOptions_Good(t *testing.T) { Output: os.Stdout, } - assert.Equal(t, "/project", opts.ProjectDir) - assert.Equal(t, "myapp", opts.ImageName) - assert.Equal(t, "v1.0.0", opts.Tag) - assert.Equal(t, "linux/amd64", opts.Platform) - assert.Equal(t, "/path/to/Dockerfile", opts.Dockerfile) - assert.True(t, opts.NoBuildCache) - assert.Equal(t, "value1", opts.BuildArgs["ARG1"]) - assert.NotNil(t, opts.Output) + AssertEqual(t, "/project", opts.ProjectDir) + AssertEqual(t, "myapp", opts.ImageName) + AssertEqual(t, "v1.0.0", opts.Tag) + AssertEqual(t, "linux/amd64", opts.Platform) + AssertEqual(t, "/path/to/Dockerfile", opts.Dockerfile) + AssertTrue(t, opts.NoBuildCache) + AssertEqual(t, "value1", opts.BuildArgs["ARG1"]) + AssertNotNil(t, opts.Output) }) } -func TestLinuxKitBuildOptions_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestPHP_LinuxKitBuildOptions_Good(t *T) { + t.Run("all fields accessible", func(t *T) { opts := LinuxKitBuildOptions{ ProjectDir: "/project", OutputPath: "/output/image.qcow2", @@ -45,17 +41,17 @@ func TestLinuxKitBuildOptions_Good(t *testing.T) { Output: os.Stdout, } - assert.Equal(t, "/project", opts.ProjectDir) - assert.Equal(t, "/output/image.qcow2", opts.OutputPath) - assert.Equal(t, "qcow2", opts.Format) - assert.Equal(t, "server-php", opts.Template) - assert.Equal(t, "value1", opts.Variables["VAR1"]) - assert.NotNil(t, opts.Output) + AssertEqual(t, "/project", opts.ProjectDir) + AssertEqual(t, "/output/image.qcow2", opts.OutputPath) + AssertEqual(t, "qcow2", opts.Format) + AssertEqual(t, "server-php", opts.Template) + AssertEqual(t, "value1", opts.Variables["VAR1"]) + AssertNotNil(t, opts.Output) }) } -func TestServeOptions_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestPHP_ServeOptions_Good(t *T) { + t.Run("all fields accessible", func(t *T) { opts := ServeOptions{ ImageName: "myapp", Tag: "latest", @@ -68,41 +64,41 @@ func TestServeOptions_Good(t *testing.T) { Output: os.Stdout, } - assert.Equal(t, "myapp", opts.ImageName) - assert.Equal(t, "latest", opts.Tag) - assert.Equal(t, "myapp-container", opts.ContainerName) - assert.Equal(t, 8080, opts.Port) - assert.Equal(t, 8443, opts.HTTPSPort) - assert.True(t, opts.Detach) - assert.Equal(t, "/path/to/.env", opts.EnvFile) - assert.Equal(t, "/container", opts.Volumes["/host"]) - assert.NotNil(t, opts.Output) + AssertEqual(t, "myapp", opts.ImageName) + AssertEqual(t, "latest", opts.Tag) + AssertEqual(t, "myapp-container", opts.ContainerName) + AssertEqual(t, 8080, opts.Port) + AssertEqual(t, 8443, opts.HTTPSPort) + AssertTrue(t, opts.Detach) + AssertEqual(t, "/path/to/.env", opts.EnvFile) + AssertEqual(t, "/container", opts.Volumes["/host"]) + AssertNotNil(t, opts.Output) }) } -func TestIsPHPProject_Container_Good(t *testing.T) { - t.Run("returns true with composer.json", func(t *testing.T) { +func TestPHP_IsPHPProject_Container_Good(t *T) { + t.Run("returns true with composer.json", func(t *T) { dir := t.TempDir() err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(`{}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.True(t, IsPHPProject(dir)) + AssertTrue(t, IsPHPProject(dir)) }) } -func TestIsPHPProject_Container_Bad(t *testing.T) { - t.Run("returns false without composer.json", func(t *testing.T) { +func TestPHP_IsPHPProject_Container_Bad(t *T) { + t.Run("returns false without composer.json", func(t *T) { dir := t.TempDir() - assert.False(t, IsPHPProject(dir)) + AssertFalse(t, IsPHPProject(dir)) }) - t.Run("returns false for non-existent directory", func(t *testing.T) { - assert.False(t, IsPHPProject("/non/existent/path")) + t.Run("returns false for non-existent directory", func(t *T) { + AssertFalse(t, IsPHPProject("/non/existent/path")) }) } -func TestLookupLinuxKit_Bad(t *testing.T) { - t.Run("returns error when linuxkit not found", func(t *testing.T) { +func TestPHP_LookupLinuxKit_Bad(t *T) { + t.Run("returns error when linuxkit not found", func(t *T) { // Save original PATH and paths origPath := os.Getenv("PATH") origCommonPaths := commonLinuxKitPaths @@ -116,31 +112,30 @@ func TestLookupLinuxKit_Bad(t *testing.T) { commonLinuxKitPaths = []string{} _, err := lookupLinuxKit() - if assert.Error(t, err) { - assert.Contains(t, err.Error(), "linuxkit not found") - } + AssertError(t, err) + AssertContains(t, err.Error(), "linuxkit not found") }) } -func TestGetLinuxKitTemplate_Good(t *testing.T) { - t.Run("returns server-php template", func(t *testing.T) { +func TestPHP_GetLinuxKitTemplate_Good(t *T) { + t.Run("returns server-php template", func(t *T) { content, err := getLinuxKitTemplate("server-php") - assert.NoError(t, err) - assert.Contains(t, content, "kernel:") - assert.Contains(t, content, "linuxkit/kernel") + AssertNoError(t, err) + AssertContains(t, content, "kernel:") + AssertContains(t, content, "linuxkit/kernel") }) } -func TestGetLinuxKitTemplate_Bad(t *testing.T) { - t.Run("returns error for unknown template", func(t *testing.T) { +func TestPHP_GetLinuxKitTemplate_Bad(t *T) { + t.Run("returns error for unknown template", func(t *T) { _, err := getLinuxKitTemplate("unknown-template") - assert.Error(t, err) - assert.Contains(t, err.Error(), "template not found") + AssertError(t, err) + AssertContains(t, err.Error(), "template not found") }) } -func TestApplyTemplateVariables_Good(t *testing.T) { - t.Run("replaces variables", func(t *testing.T) { +func TestPHP_ApplyTemplateVariables_Good(t *T) { + t.Run("replaces variables", func(t *T) { content := "Hello ${NAME}, welcome to ${PLACE}!" vars := map[string]string{ "NAME": "World", @@ -148,100 +143,103 @@ func TestApplyTemplateVariables_Good(t *testing.T) { } result, err := applyTemplateVariables(content, vars) - assert.NoError(t, err) - assert.Equal(t, "Hello World, welcome to Earth!", result) + AssertNoError(t, err) + AssertEqual(t, "Hello World, welcome to Earth!", result) }) - t.Run("handles empty variables", func(t *testing.T) { + t.Run("handles empty variables", func(t *T) { content := "No variables here" vars := map[string]string{} result, err := applyTemplateVariables(content, vars) - assert.NoError(t, err) - assert.Equal(t, "No variables here", result) + AssertNoError(t, err) + AssertEqual(t, "No variables here", result) }) - t.Run("leaves unmatched placeholders", func(t *testing.T) { + t.Run("leaves unmatched placeholders", func(t *T) { content := "Hello ${NAME}, ${UNKNOWN} is unknown" vars := map[string]string{ "NAME": "World", } result, err := applyTemplateVariables(content, vars) - assert.NoError(t, err) - assert.Contains(t, result, "Hello World") - assert.Contains(t, result, "${UNKNOWN}") + AssertNoError(t, err) + AssertContains(t, result, "Hello World") + AssertContains(t, result, "${UNKNOWN}") }) - t.Run("handles multiple occurrences", func(t *testing.T) { + t.Run("handles multiple occurrences", func(t *T) { content := "${VAR} and ${VAR} again" vars := map[string]string{ "VAR": "value", } result, err := applyTemplateVariables(content, vars) - assert.NoError(t, err) - assert.Equal(t, "value and value again", result) + AssertNoError(t, err) + AssertEqual(t, "value and value again", result) }) } -func TestDefaultServerPHPTemplate_Good(t *testing.T) { - t.Run("template has required sections", func(t *testing.T) { - assert.Contains(t, defaultServerPHPTemplate, "kernel:") - assert.Contains(t, defaultServerPHPTemplate, "init:") - assert.Contains(t, defaultServerPHPTemplate, "services:") - assert.Contains(t, defaultServerPHPTemplate, "onboot:") +func TestPHP_DefaultServerPHPTemplate_Good(t *T) { + t.Run("template has required sections", func(t *T) { + AssertContains(t, defaultServerPHPTemplate, "kernel:") + AssertContains(t, defaultServerPHPTemplate, "init:") + AssertContains(t, defaultServerPHPTemplate, "services:") + AssertContains(t, defaultServerPHPTemplate, "onboot:") }) - t.Run("template contains placeholders", func(t *testing.T) { - assert.Contains(t, defaultServerPHPTemplate, "${SSH_KEY:-}") + t.Run("template contains placeholders", func(t *T) { + AssertContains(t, defaultServerPHPTemplate, "${SSH_KEY:-}") }) } -func TestBuildDocker_Bad(t *testing.T) { +func TestPHP_BuildDocker_Bad(t *T) { t.Skip("requires Docker installed") - t.Run("fails for non-PHP project", func(t *testing.T) { + t.Run("fails for non-PHP project", func(t *T) { dir := t.TempDir() err := BuildDocker(context.TODO(), DockerBuildOptions{ProjectDir: dir}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a PHP project") + AssertError(t, err) + AssertContains(t, err.Error(), "not a PHP project") }) } -func TestBuildLinuxKit_Bad(t *testing.T) { +func TestPHP_BuildLinuxKit_Bad(t *T) { t.Skip("requires linuxkit installed") - t.Run("fails for non-PHP project", func(t *testing.T) { + t.Run("fails for non-PHP project", func(t *T) { dir := t.TempDir() err := BuildLinuxKit(context.TODO(), LinuxKitBuildOptions{ProjectDir: dir}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a PHP project") + AssertError(t, err) + AssertContains(t, err.Error(), "not a PHP project") }) } -func TestServeProduction_Bad(t *testing.T) { - t.Run("fails without image name", func(t *testing.T) { +func TestPHP_ServeProduction_Bad(t *T) { + t.Run("fails without image name", func(t *T) { err := ServeProduction(context.TODO(), ServeOptions{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "image name is required") + AssertError(t, err) + AssertContains(t, err.Error(), "image name is required") }) } -func TestShell_Bad(t *testing.T) { - t.Run("fails without container ID", func(t *testing.T) { +func TestPHP_Shell_Bad(t *T) { + t.Run("fails without container ID", func(t *T) { err := Shell(context.TODO(), "") - assert.Error(t, err) - assert.Contains(t, err.Error(), "container ID is required") + AssertError(t, err) + AssertContains(t, err.Error(), "container ID is required") }) } -func TestResolveDockerContainerID_Bad(t *testing.T) { - t.Skip("requires Docker installed") +func TestPHP_ResolveDockerContainerID_Bad(t *T) { + t.Setenv("PATH", t.TempDir()) + id, err := resolveDockerContainerID(context.TODO(), "abc") + AssertError(t, err) + AssertEqual(t, "", id) } -func TestBuildDocker_DefaultOptions(t *testing.T) { - t.Run("sets defaults correctly", func(t *testing.T) { +func TestBuildDocker_DefaultOptions(t *T) { + t.Run("sets defaults correctly", func(t *T) { // This tests the default logic without actually running Docker opts := DockerBuildOptions{} @@ -249,75 +247,75 @@ func TestBuildDocker_DefaultOptions(t *testing.T) { if opts.Tag == "" { opts.Tag = "latest" } - assert.Equal(t, "latest", opts.Tag) + AssertEqual(t, "latest", opts.Tag) if opts.ImageName == "" { opts.ImageName = filepath.Base("/project/myapp") } - assert.Equal(t, "myapp", opts.ImageName) + AssertEqual(t, "myapp", opts.ImageName) }) } -func TestBuildLinuxKit_DefaultOptions(t *testing.T) { - t.Run("sets defaults correctly", func(t *testing.T) { +func TestBuildLinuxKit_DefaultOptions(t *T) { + t.Run("sets defaults correctly", func(t *T) { opts := LinuxKitBuildOptions{} // Verify default values would be set if opts.Template == "" { opts.Template = "server-php" } - assert.Equal(t, "server-php", opts.Template) + AssertEqual(t, "server-php", opts.Template) if opts.Format == "" { opts.Format = "qcow2" } - assert.Equal(t, "qcow2", opts.Format) + AssertEqual(t, "qcow2", opts.Format) }) } -func TestServeProduction_DefaultOptions(t *testing.T) { - t.Run("sets defaults correctly", func(t *testing.T) { +func TestServeProduction_DefaultOptions(t *T) { + t.Run("sets defaults correctly", func(t *T) { opts := ServeOptions{ImageName: "myapp"} // Verify default values would be set if opts.Tag == "" { opts.Tag = "latest" } - assert.Equal(t, "latest", opts.Tag) + AssertEqual(t, "latest", opts.Tag) if opts.Port == 0 { opts.Port = 80 } - assert.Equal(t, 80, opts.Port) + AssertEqual(t, 80, opts.Port) if opts.HTTPSPort == 0 { opts.HTTPSPort = 443 } - assert.Equal(t, 443, opts.HTTPSPort) + AssertEqual(t, 443, opts.HTTPSPort) }) } -func TestLookupLinuxKit_Good(t *testing.T) { +func TestPHP_LookupLinuxKit_Good(t *T) { t.Skip("requires linuxkit installed") - t.Run("finds linuxkit in PATH", func(t *testing.T) { + t.Run("finds linuxkit in PATH", func(t *T) { path, err := lookupLinuxKit() - assert.NoError(t, err) - assert.NotEmpty(t, path) + AssertNoError(t, err) + AssertNotEmpty(t, path) }) } -func TestBuildDocker_WithCustomDockerfile(t *testing.T) { +func TestBuildDocker_WithCustomDockerfile(t *T) { t.Skip("requires Docker installed") - t.Run("uses custom Dockerfile when provided", func(t *testing.T) { + t.Run("uses custom Dockerfile when provided", func(t *T) { dir := t.TempDir() err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(`{"name":"test"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) dockerfilePath := filepath.Join(dir, "Dockerfile.custom") err = os.WriteFile(dockerfilePath, []byte("FROM alpine"), 0644) - require.NoError(t, err) + RequireNoError(t, err) opts := DockerBuildOptions{ ProjectDir: dir, @@ -325,32 +323,32 @@ func TestBuildDocker_WithCustomDockerfile(t *testing.T) { } // The function would use the custom Dockerfile - assert.Equal(t, dockerfilePath, opts.Dockerfile) + AssertEqual(t, dockerfilePath, opts.Dockerfile) }) } -func TestBuildDocker_GeneratesDockerfile(t *testing.T) { +func TestBuildDocker_GeneratesDockerfile(t *T) { t.Skip("requires Docker installed") - t.Run("generates Dockerfile when not provided", func(t *testing.T) { + t.Run("generates Dockerfile when not provided", func(t *T) { dir := t.TempDir() // Create valid PHP project composerJSON := `{"name":"test","require":{"php":"^8.2","laravel/framework":"^11.0"}}` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) opts := DockerBuildOptions{ ProjectDir: dir, // Dockerfile not specified - should be generated } - assert.Empty(t, opts.Dockerfile) + AssertEmpty(t, opts.Dockerfile) }) } -func TestServeProduction_BuildsCorrectArgs(t *testing.T) { - t.Run("builds correct docker run arguments", func(t *testing.T) { +func TestServeProduction_BuildsCorrectArgs(t *T) { + t.Run("builds correct docker run arguments", func(t *T) { opts := ServeOptions{ ImageName: "myapp", Tag: "v1.0.0", @@ -366,18 +364,27 @@ func TestServeProduction_BuildsCorrectArgs(t *testing.T) { // Verify the expected image reference format imageRef := opts.ImageName + ":" + opts.Tag - assert.Equal(t, "myapp:v1.0.0", imageRef) + AssertEqual(t, "myapp:v1.0.0", imageRef) // Verify port format portMapping := opts.Port - assert.Equal(t, 8080, portMapping) + AssertEqual(t, 8080, portMapping) }) } -func TestShell_Integration(t *testing.T) { - t.Skip("requires Docker with running container") +func TestShell_Integration(t *T) { + if os.Getenv("CORE_PHP_RUN_DOCKER_INTEGRATION") == "" { + t.Skip("requires Docker with running container") + } + err := Shell(context.TODO(), os.Getenv("CORE_PHP_CONTAINER")) + AssertNoError(t, err) } -func TestResolveDockerContainerID_Integration(t *testing.T) { - t.Skip("requires Docker with running containers") +func TestResolveDockerContainerID_Integration(t *T) { + if os.Getenv("CORE_PHP_RUN_DOCKER_INTEGRATION") == "" { + t.Skip("requires Docker with running containers") + } + id, err := resolveDockerContainerID(context.TODO(), os.Getenv("CORE_PHP_CONTAINER")) + AssertNoError(t, err) + AssertNotEmpty(t, id) } diff --git a/pkg/php/coolify.go b/pkg/php/coolify.go index e458484..8df64c3 100644 --- a/pkg/php/coolify.go +++ b/pkg/php/coolify.go @@ -56,7 +56,7 @@ type CoolifyApp struct { // NewCoolifyClient creates a new Coolify API client. func NewCoolifyClient(baseURL, token string) *CoolifyClient { // Ensure baseURL doesn't have trailing slash - baseURL = strings.TrimSuffix(baseURL, "/") + baseURL = strings.TrimRight(baseURL, "/") return &CoolifyClient{ BaseURL: baseURL, diff --git a/pkg/php/coolify_test.go b/pkg/php/coolify_test.go index 8176c88..ccac13f 100644 --- a/pkg/php/coolify_test.go +++ b/pkg/php/coolify_test.go @@ -7,35 +7,31 @@ import ( "net/http/httptest" "os" "path/filepath" - "testing" "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestCoolifyClient_Good(t *testing.T) { - t.Run("creates client with correct base URL", func(t *testing.T) { +func TestPHP_CoolifyClient_Good(t *T) { + t.Run("creates client with correct base URL", func(t *T) { client := NewCoolifyClient("https://coolify.example.com", "token") - assert.Equal(t, "https://coolify.example.com", client.BaseURL) - assert.Equal(t, "token", client.Token) - assert.NotNil(t, client.HTTPClient) + AssertEqual(t, "https://coolify.example.com", client.BaseURL) + AssertEqual(t, "token", client.Token) + AssertNotNil(t, client.HTTPClient) }) - t.Run("strips trailing slash from base URL", func(t *testing.T) { + t.Run("strips trailing slash from base URL", func(t *T) { client := NewCoolifyClient("https://coolify.example.com/", "token") - assert.Equal(t, "https://coolify.example.com", client.BaseURL) + AssertEqual(t, "https://coolify.example.com", client.BaseURL) }) - t.Run("http client has timeout", func(t *testing.T) { + t.Run("http client has timeout", func(t *T) { client := NewCoolifyClient("https://coolify.example.com", "token") - assert.Equal(t, 30*time.Second, client.HTTPClient.Timeout) + AssertEqual(t, 30*time.Second, client.HTTPClient.Timeout) }) } -func TestCoolifyConfig_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestPHP_CoolifyConfig_Good(t *T) { + t.Run("all fields accessible", func(t *T) { config := CoolifyConfig{ URL: "https://coolify.example.com", Token: "secret-token", @@ -43,15 +39,15 @@ func TestCoolifyConfig_Good(t *testing.T) { StagingAppID: "staging-456", } - assert.Equal(t, "https://coolify.example.com", config.URL) - assert.Equal(t, "secret-token", config.Token) - assert.Equal(t, "app-123", config.AppID) - assert.Equal(t, "staging-456", config.StagingAppID) + AssertEqual(t, "https://coolify.example.com", config.URL) + AssertEqual(t, "secret-token", config.Token) + AssertEqual(t, "app-123", config.AppID) + AssertEqual(t, "staging-456", config.StagingAppID) }) } -func TestCoolifyDeployment_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestPHP_CoolifyDeployment_Good(t *T) { + t.Run("all fields accessible", func(t *T) { now := time.Now() deployment := CoolifyDeployment{ ID: "dep-123", @@ -65,16 +61,16 @@ func TestCoolifyDeployment_Good(t *testing.T) { DeployedURL: "https://app.example.com", } - assert.Equal(t, "dep-123", deployment.ID) - assert.Equal(t, "finished", deployment.Status) - assert.Equal(t, "abc123", deployment.CommitSHA) - assert.Equal(t, "Test commit", deployment.CommitMsg) - assert.Equal(t, "main", deployment.Branch) + AssertEqual(t, "dep-123", deployment.ID) + AssertEqual(t, "finished", deployment.Status) + AssertEqual(t, "abc123", deployment.CommitSHA) + AssertEqual(t, "Test commit", deployment.CommitMsg) + AssertEqual(t, "main", deployment.Branch) }) } -func TestCoolifyApp_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestPHP_CoolifyApp_Good(t *T) { + t.Run("all fields accessible", func(t *T) { app := CoolifyApp{ ID: "app-123", Name: "MyApp", @@ -85,15 +81,15 @@ func TestCoolifyApp_Good(t *testing.T) { Environment: "production", } - assert.Equal(t, "app-123", app.ID) - assert.Equal(t, "MyApp", app.Name) - assert.Equal(t, "https://myapp.example.com", app.FQDN) - assert.Equal(t, "running", app.Status) + AssertEqual(t, "app-123", app.ID) + AssertEqual(t, "MyApp", app.Name) + AssertEqual(t, "https://myapp.example.com", app.FQDN) + AssertEqual(t, "running", app.Status) }) } -func TestLoadCoolifyConfigFromFile_Good(t *testing.T) { - t.Run("loads config from .env file", func(t *testing.T) { +func TestPHP_LoadCoolifyConfigFromFile_Good(t *T) { + t.Run("loads config from .env file", func(t *T) { dir := t.TempDir() envContent := `COOLIFY_URL=https://coolify.example.com COOLIFY_TOKEN=secret-token @@ -101,31 +97,31 @@ COOLIFY_APP_ID=app-123 COOLIFY_STAGING_APP_ID=staging-456` err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) + RequireNoError(t, err) config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.NoError(t, err) - assert.Equal(t, "https://coolify.example.com", config.URL) - assert.Equal(t, "secret-token", config.Token) - assert.Equal(t, "app-123", config.AppID) - assert.Equal(t, "staging-456", config.StagingAppID) + AssertNoError(t, err) + AssertEqual(t, "https://coolify.example.com", config.URL) + AssertEqual(t, "secret-token", config.Token) + AssertEqual(t, "app-123", config.AppID) + AssertEqual(t, "staging-456", config.StagingAppID) }) - t.Run("handles quoted values", func(t *testing.T) { + t.Run("handles quoted values", func(t *T) { dir := t.TempDir() envContent := `COOLIFY_URL="https://coolify.example.com" COOLIFY_TOKEN='secret-token'` err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) + RequireNoError(t, err) config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.NoError(t, err) - assert.Equal(t, "https://coolify.example.com", config.URL) - assert.Equal(t, "secret-token", config.Token) + AssertNoError(t, err) + AssertEqual(t, "https://coolify.example.com", config.URL) + AssertEqual(t, "secret-token", config.Token) }) - t.Run("ignores comments", func(t *testing.T) { + t.Run("ignores comments", func(t *T) { dir := t.TempDir() envContent := `# This is a comment COOLIFY_URL=https://coolify.example.com @@ -133,92 +129,92 @@ COOLIFY_URL=https://coolify.example.com COOLIFY_TOKEN=correct-token` err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) + RequireNoError(t, err) config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.NoError(t, err) - assert.Equal(t, "correct-token", config.Token) + AssertNoError(t, err) + AssertEqual(t, "correct-token", config.Token) }) - t.Run("ignores blank lines", func(t *testing.T) { + t.Run("ignores blank lines", func(t *T) { dir := t.TempDir() envContent := `COOLIFY_URL=https://coolify.example.com COOLIFY_TOKEN=secret-token` err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) + RequireNoError(t, err) config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.NoError(t, err) - assert.Equal(t, "https://coolify.example.com", config.URL) + AssertNoError(t, err) + AssertEqual(t, "https://coolify.example.com", config.URL) }) } -func TestLoadCoolifyConfigFromFile_Bad(t *testing.T) { - t.Run("fails when COOLIFY_URL missing", func(t *testing.T) { +func TestPHP_LoadCoolifyConfigFromFile_Bad(t *T) { + t.Run("fails when COOLIFY_URL missing", func(t *T) { dir := t.TempDir() envContent := `COOLIFY_TOKEN=secret-token` err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) + RequireNoError(t, err) _, err = LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.Error(t, err) - assert.Contains(t, err.Error(), "COOLIFY_URL is not set") + AssertError(t, err) + AssertContains(t, err.Error(), "COOLIFY_URL is not set") }) - t.Run("fails when COOLIFY_TOKEN missing", func(t *testing.T) { + t.Run("fails when COOLIFY_TOKEN missing", func(t *T) { dir := t.TempDir() envContent := `COOLIFY_URL=https://coolify.example.com` err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) + RequireNoError(t, err) _, err = LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.Error(t, err) - assert.Contains(t, err.Error(), "COOLIFY_TOKEN is not set") + AssertError(t, err) + AssertContains(t, err.Error(), "COOLIFY_TOKEN is not set") }) } -func TestLoadCoolifyConfig_FromDirectory_Good(t *testing.T) { - t.Run("loads from directory", func(t *testing.T) { +func TestPHP_LoadCoolifyConfig_FromDirectory_Good(t *T) { + t.Run("loads from directory", func(t *T) { dir := t.TempDir() envContent := `COOLIFY_URL=https://coolify.example.com COOLIFY_TOKEN=secret-token` err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) + RequireNoError(t, err) config, err := LoadCoolifyConfig(dir) - assert.NoError(t, err) - assert.Equal(t, "https://coolify.example.com", config.URL) + AssertNoError(t, err) + AssertEqual(t, "https://coolify.example.com", config.URL) }) } -func TestValidateCoolifyConfig_Bad(t *testing.T) { - t.Run("returns error for empty URL", func(t *testing.T) { +func TestPHP_ValidateCoolifyConfig_Bad(t *T) { + t.Run("returns error for empty URL", func(t *T) { config := &CoolifyConfig{Token: "token"} _, err := validateCoolifyConfig(config) - assert.Error(t, err) - assert.Contains(t, err.Error(), "COOLIFY_URL is not set") + AssertError(t, err) + AssertContains(t, err.Error(), "COOLIFY_URL is not set") }) - t.Run("returns error for empty token", func(t *testing.T) { + t.Run("returns error for empty token", func(t *T) { config := &CoolifyConfig{URL: "https://coolify.example.com"} _, err := validateCoolifyConfig(config) - assert.Error(t, err) - assert.Contains(t, err.Error(), "COOLIFY_TOKEN is not set") + AssertError(t, err) + AssertContains(t, err.Error(), "COOLIFY_TOKEN is not set") }) } -func TestCoolifyClient_TriggerDeploy_Good(t *testing.T) { - t.Run("triggers deployment successfully", func(t *testing.T) { +func TestPHP_CoolifyClient_TriggerDeploy_Good(t *T) { + t.Run("triggers deployment successfully", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v1/applications/app-123/deploy", r.URL.Path) - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "Bearer secret-token", r.Header.Get("Authorization")) - assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + AssertEqual(t, "/api/v1/applications/app-123/deploy", r.URL.Path) + AssertEqual(t, "POST", r.Method) + AssertEqual(t, "Bearer secret-token", r.Header.Get("Authorization")) + AssertEqual(t, "application/json", r.Header.Get("Content-Type")) resp := CoolifyDeployment{ ID: "dep-456", @@ -232,16 +228,16 @@ func TestCoolifyClient_TriggerDeploy_Good(t *testing.T) { client := NewCoolifyClient(server.URL, "secret-token") deployment, err := client.TriggerDeploy(context.Background(), "app-123", false) - assert.NoError(t, err) - assert.Equal(t, "dep-456", deployment.ID) - assert.Equal(t, "queued", deployment.Status) + AssertNoError(t, err) + AssertEqual(t, "dep-456", deployment.ID) + AssertEqual(t, "queued", deployment.Status) }) - t.Run("triggers deployment with force", func(t *testing.T) { + t.Run("triggers deployment with force", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]interface{} _ = json.NewDecoder(r.Body).Decode(&body) - assert.Equal(t, true, body["force"]) + AssertEqual(t, true, body["force"]) resp := CoolifyDeployment{ID: "dep-456", Status: "queued"} _ = json.NewEncoder(w).Encode(resp) @@ -250,10 +246,10 @@ func TestCoolifyClient_TriggerDeploy_Good(t *testing.T) { client := NewCoolifyClient(server.URL, "secret-token") _, err := client.TriggerDeploy(context.Background(), "app-123", true) - assert.NoError(t, err) + AssertNoError(t, err) }) - t.Run("handles minimal response", func(t *testing.T) { + t.Run("handles minimal response", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Return an invalid JSON response to trigger the fallback _, _ = w.Write([]byte("not json")) @@ -263,14 +259,14 @@ func TestCoolifyClient_TriggerDeploy_Good(t *testing.T) { client := NewCoolifyClient(server.URL, "secret-token") deployment, err := client.TriggerDeploy(context.Background(), "app-123", false) - assert.NoError(t, err) + AssertNoError(t, err) // The fallback response should be returned - assert.Equal(t, "queued", deployment.Status) + AssertEqual(t, "queued", deployment.Status) }) } -func TestCoolifyClient_TriggerDeploy_Bad(t *testing.T) { - t.Run("fails on HTTP error", func(t *testing.T) { +func TestPHP_CoolifyClient_TriggerDeploy_Bad(t *T) { + t.Run("fails on HTTP error", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) _ = json.NewEncoder(w).Encode(map[string]string{"message": "Internal error"}) @@ -280,16 +276,16 @@ func TestCoolifyClient_TriggerDeploy_Bad(t *testing.T) { client := NewCoolifyClient(server.URL, "secret-token") _, err := client.TriggerDeploy(context.Background(), "app-123", false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "API error") + AssertError(t, err) + AssertContains(t, err.Error(), "API error") }) } -func TestCoolifyClient_GetDeployment_Good(t *testing.T) { - t.Run("gets deployment details", func(t *testing.T) { +func TestPHP_CoolifyClient_GetDeployment_Good(t *T) { + t.Run("gets deployment details", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v1/applications/app-123/deployments/dep-456", r.URL.Path) - assert.Equal(t, "GET", r.Method) + AssertEqual(t, "/api/v1/applications/app-123/deployments/dep-456", r.URL.Path) + AssertEqual(t, "GET", r.Method) resp := CoolifyDeployment{ ID: "dep-456", @@ -304,15 +300,15 @@ func TestCoolifyClient_GetDeployment_Good(t *testing.T) { client := NewCoolifyClient(server.URL, "secret-token") deployment, err := client.GetDeployment(context.Background(), "app-123", "dep-456") - assert.NoError(t, err) - assert.Equal(t, "dep-456", deployment.ID) - assert.Equal(t, "finished", deployment.Status) - assert.Equal(t, "abc123", deployment.CommitSHA) + AssertNoError(t, err) + AssertEqual(t, "dep-456", deployment.ID) + AssertEqual(t, "finished", deployment.Status) + AssertEqual(t, "abc123", deployment.CommitSHA) }) } -func TestCoolifyClient_GetDeployment_Bad(t *testing.T) { - t.Run("fails on 404", func(t *testing.T) { +func TestPHP_CoolifyClient_GetDeployment_Bad(t *T) { + t.Run("fails on 404", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) _ = json.NewEncoder(w).Encode(map[string]string{"error": "Not found"}) @@ -322,16 +318,16 @@ func TestCoolifyClient_GetDeployment_Bad(t *testing.T) { client := NewCoolifyClient(server.URL, "secret-token") _, err := client.GetDeployment(context.Background(), "app-123", "dep-456") - assert.Error(t, err) - assert.Contains(t, err.Error(), "Not found") + AssertError(t, err) + AssertContains(t, err.Error(), "Not found") }) } -func TestCoolifyClient_ListDeployments_Good(t *testing.T) { - t.Run("lists deployments", func(t *testing.T) { +func TestPHP_CoolifyClient_ListDeployments_Good(t *T) { + t.Run("lists deployments", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v1/applications/app-123/deployments", r.URL.Path) - assert.Equal(t, "10", r.URL.Query().Get("limit")) + AssertEqual(t, "/api/v1/applications/app-123/deployments", r.URL.Path) + AssertEqual(t, "10", r.URL.Query().Get("limit")) resp := []CoolifyDeployment{ {ID: "dep-1", Status: "finished"}, @@ -344,34 +340,34 @@ func TestCoolifyClient_ListDeployments_Good(t *testing.T) { client := NewCoolifyClient(server.URL, "secret-token") deployments, err := client.ListDeployments(context.Background(), "app-123", 10) - assert.NoError(t, err) - assert.Len(t, deployments, 2) - assert.Equal(t, "dep-1", deployments[0].ID) - assert.Equal(t, "dep-2", deployments[1].ID) + AssertNoError(t, err) + AssertLen(t, deployments, 2) + AssertEqual(t, "dep-1", deployments[0].ID) + AssertEqual(t, "dep-2", deployments[1].ID) }) - t.Run("lists without limit", func(t *testing.T) { + t.Run("lists without limit", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "", r.URL.Query().Get("limit")) + AssertEqual(t, "", r.URL.Query().Get("limit")) _ = json.NewEncoder(w).Encode([]CoolifyDeployment{}) })) defer server.Close() client := NewCoolifyClient(server.URL, "secret-token") _, err := client.ListDeployments(context.Background(), "app-123", 0) - assert.NoError(t, err) + AssertNoError(t, err) }) } -func TestCoolifyClient_Rollback_Good(t *testing.T) { - t.Run("triggers rollback", func(t *testing.T) { +func TestPHP_CoolifyClient_Rollback_Good(t *T) { + t.Run("triggers rollback", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v1/applications/app-123/rollback", r.URL.Path) - assert.Equal(t, "POST", r.Method) + AssertEqual(t, "/api/v1/applications/app-123/rollback", r.URL.Path) + AssertEqual(t, "POST", r.Method) var body map[string]string _ = json.NewDecoder(r.Body).Decode(&body) - assert.Equal(t, "dep-old", body["deployment_id"]) + AssertEqual(t, "dep-old", body["deployment_id"]) resp := CoolifyDeployment{ ID: "dep-new", @@ -384,17 +380,17 @@ func TestCoolifyClient_Rollback_Good(t *testing.T) { client := NewCoolifyClient(server.URL, "secret-token") deployment, err := client.Rollback(context.Background(), "app-123", "dep-old") - assert.NoError(t, err) - assert.Equal(t, "dep-new", deployment.ID) - assert.Equal(t, "rolling_back", deployment.Status) + AssertNoError(t, err) + AssertEqual(t, "dep-new", deployment.ID) + AssertEqual(t, "rolling_back", deployment.Status) }) } -func TestCoolifyClient_GetApp_Good(t *testing.T) { - t.Run("gets app details", func(t *testing.T) { +func TestPHP_CoolifyClient_GetApp_Good(t *T) { + t.Run("gets app details", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v1/applications/app-123", r.URL.Path) - assert.Equal(t, "GET", r.Method) + AssertEqual(t, "/api/v1/applications/app-123", r.URL.Path) + AssertEqual(t, "GET", r.Method) resp := CoolifyApp{ ID: "app-123", @@ -409,28 +405,28 @@ func TestCoolifyClient_GetApp_Good(t *testing.T) { client := NewCoolifyClient(server.URL, "secret-token") app, err := client.GetApp(context.Background(), "app-123") - assert.NoError(t, err) - assert.Equal(t, "app-123", app.ID) - assert.Equal(t, "MyApp", app.Name) - assert.Equal(t, "https://myapp.example.com", app.FQDN) + AssertNoError(t, err) + AssertEqual(t, "app-123", app.ID) + AssertEqual(t, "MyApp", app.Name) + AssertEqual(t, "https://myapp.example.com", app.FQDN) }) } -func TestCoolifyClient_SetHeaders(t *testing.T) { - t.Run("sets all required headers", func(t *testing.T) { +func TestCoolifyClient_SetHeaders(t *T) { + t.Run("sets all required headers", func(t *T) { client := NewCoolifyClient("https://coolify.example.com", "my-token") req, _ := http.NewRequest("GET", "https://coolify.example.com", nil) client.setHeaders(req) - assert.Equal(t, "Bearer my-token", req.Header.Get("Authorization")) - assert.Equal(t, "application/json", req.Header.Get("Content-Type")) - assert.Equal(t, "application/json", req.Header.Get("Accept")) + AssertEqual(t, "Bearer my-token", req.Header.Get("Authorization")) + AssertEqual(t, "application/json", req.Header.Get("Content-Type")) + AssertEqual(t, "application/json", req.Header.Get("Accept")) }) } -func TestCoolifyClient_ParseError(t *testing.T) { - t.Run("parses message field", func(t *testing.T) { +func TestCoolifyClient_ParseError(t *T) { + t.Run("parses message field", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]string{"message": "Bad request message"}) @@ -440,11 +436,11 @@ func TestCoolifyClient_ParseError(t *testing.T) { client := NewCoolifyClient(server.URL, "token") _, err := client.GetApp(context.Background(), "app-123") - assert.Error(t, err) - assert.Contains(t, err.Error(), "Bad request message") + AssertError(t, err) + AssertContains(t, err.Error(), "Bad request message") }) - t.Run("parses error field", func(t *testing.T) { + t.Run("parses error field", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]string{"error": "Error message"}) @@ -454,11 +450,11 @@ func TestCoolifyClient_ParseError(t *testing.T) { client := NewCoolifyClient(server.URL, "token") _, err := client.GetApp(context.Background(), "app-123") - assert.Error(t, err) - assert.Contains(t, err.Error(), "Error message") + AssertError(t, err) + AssertContains(t, err.Error(), "Error message") }) - t.Run("returns raw body when no JSON fields", func(t *testing.T) { + t.Run("returns raw body when no JSON fields", func(t *T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte("Raw error message")) @@ -468,19 +464,19 @@ func TestCoolifyClient_ParseError(t *testing.T) { client := NewCoolifyClient(server.URL, "token") _, err := client.GetApp(context.Background(), "app-123") - assert.Error(t, err) - assert.Contains(t, err.Error(), "Raw error message") + AssertError(t, err) + AssertContains(t, err.Error(), "Raw error message") }) } -func TestEnvironmentVariablePriority(t *testing.T) { - t.Run("env vars take precedence over .env file", func(t *testing.T) { +func TestEnvironmentVariablePriority(t *T) { + t.Run("env vars take precedence over .env file", func(t *T) { dir := t.TempDir() envContent := `COOLIFY_URL=https://from-file.com COOLIFY_TOKEN=file-token` err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) + RequireNoError(t, err) // Set environment variables origURL := os.Getenv("COOLIFY_URL") @@ -494,9 +490,9 @@ COOLIFY_TOKEN=file-token` _ = os.Setenv("COOLIFY_TOKEN", "env-token") config, err := LoadCoolifyConfig(dir) - assert.NoError(t, err) + AssertNoError(t, err) // Environment variables should take precedence - assert.Equal(t, "https://from-env.com", config.URL) - assert.Equal(t, "env-token", config.Token) + AssertEqual(t, "https://from-env.com", config.URL) + AssertEqual(t, "env-token", config.Token) }) } diff --git a/pkg/php/core_assert_test.go b/pkg/php/core_assert_test.go new file mode 100644 index 0000000..a5f820c --- /dev/null +++ b/pkg/php/core_assert_test.go @@ -0,0 +1,23 @@ +package php + +import core "dappco.re/go" + +type T = core.T + +var ( + AnError = core.AnError + AssertContains = core.AssertContains + AssertEmpty = core.AssertEmpty + AssertEqual = core.AssertEqual + AssertError = core.AssertError + AssertFalse = core.AssertFalse + AssertGreater = core.AssertGreater + AssertGreaterOrEqual = core.AssertGreaterOrEqual + AssertLen = core.AssertLen + AssertNoError = core.AssertNoError + AssertNotContains = core.AssertNotContains + AssertNotEmpty = core.AssertNotEmpty + AssertNotNil = core.AssertNotNil + AssertTrue = core.AssertTrue + RequireNoError = core.RequireNoError +) diff --git a/pkg/php/deploy_internal_test.go b/pkg/php/deploy_internal_test.go index 9362aaf..cd263cf 100644 --- a/pkg/php/deploy_internal_test.go +++ b/pkg/php/deploy_internal_test.go @@ -1,14 +1,11 @@ package php import ( - "testing" "time" - - "github.com/stretchr/testify/assert" ) -func TestConvertDeployment_Good(t *testing.T) { - t.Run("converts all fields", func(t *testing.T) { +func TestPHP_ConvertDeployment_Good(t *T) { + t.Run("converts all fields", func(t *T) { now := time.Now() coolify := &CoolifyDeployment{ ID: "dep-123", @@ -24,28 +21,28 @@ func TestConvertDeployment_Good(t *testing.T) { status := convertDeployment(coolify) - assert.Equal(t, "dep-123", status.ID) - assert.Equal(t, "finished", status.Status) - assert.Equal(t, "https://app.example.com", status.URL) - assert.Equal(t, "abc123", status.Commit) - assert.Equal(t, "Test commit", status.CommitMessage) - assert.Equal(t, "main", status.Branch) - assert.Equal(t, now, status.StartedAt) - assert.Equal(t, now.Add(5*time.Minute), status.CompletedAt) - assert.Equal(t, "Build successful", status.Log) + AssertEqual(t, "dep-123", status.ID) + AssertEqual(t, "finished", status.Status) + AssertEqual(t, "https://app.example.com", status.URL) + AssertEqual(t, "abc123", status.Commit) + AssertEqual(t, "Test commit", status.CommitMessage) + AssertEqual(t, "main", status.Branch) + AssertEqual(t, now, status.StartedAt) + AssertEqual(t, now.Add(5*time.Minute), status.CompletedAt) + AssertEqual(t, "Build successful", status.Log) }) - t.Run("handles empty deployment", func(t *testing.T) { + t.Run("handles empty deployment", func(t *T) { coolify := &CoolifyDeployment{} status := convertDeployment(coolify) - assert.Empty(t, status.ID) - assert.Empty(t, status.Status) + AssertEmpty(t, status.ID) + AssertEmpty(t, status.Status) }) } -func TestDeploymentStatus_Struct_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestPHP_DeploymentStatus_Struct_Good(t *T) { + t.Run("all fields accessible", func(t *T) { now := time.Now() status := DeploymentStatus{ ID: "dep-123", @@ -59,18 +56,18 @@ func TestDeploymentStatus_Struct_Good(t *testing.T) { Log: "Build log", } - assert.Equal(t, "dep-123", status.ID) - assert.Equal(t, "finished", status.Status) - assert.Equal(t, "https://app.example.com", status.URL) - assert.Equal(t, "abc123", status.Commit) - assert.Equal(t, "Test commit", status.CommitMessage) - assert.Equal(t, "main", status.Branch) - assert.Equal(t, "Build log", status.Log) + AssertEqual(t, "dep-123", status.ID) + AssertEqual(t, "finished", status.Status) + AssertEqual(t, "https://app.example.com", status.URL) + AssertEqual(t, "abc123", status.Commit) + AssertEqual(t, "Test commit", status.CommitMessage) + AssertEqual(t, "main", status.Branch) + AssertEqual(t, "Build log", status.Log) }) } -func TestDeployOptions_Struct_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestPHP_DeployOptions_Struct_Good(t *T) { + t.Run("all fields accessible", func(t *T) { opts := DeployOptions{ Dir: "/project", Environment: EnvProduction, @@ -80,31 +77,31 @@ func TestDeployOptions_Struct_Good(t *testing.T) { PollInterval: 5 * time.Second, } - assert.Equal(t, "/project", opts.Dir) - assert.Equal(t, EnvProduction, opts.Environment) - assert.True(t, opts.Force) - assert.True(t, opts.Wait) - assert.Equal(t, 10*time.Minute, opts.WaitTimeout) - assert.Equal(t, 5*time.Second, opts.PollInterval) + AssertEqual(t, "/project", opts.Dir) + AssertEqual(t, EnvProduction, opts.Environment) + AssertTrue(t, opts.Force) + AssertTrue(t, opts.Wait) + AssertEqual(t, 10*time.Minute, opts.WaitTimeout) + AssertEqual(t, 5*time.Second, opts.PollInterval) }) } -func TestStatusOptions_Struct_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestPHP_StatusOptions_Struct_Good(t *T) { + t.Run("all fields accessible", func(t *T) { opts := StatusOptions{ Dir: "/project", Environment: EnvStaging, DeploymentID: "dep-123", } - assert.Equal(t, "/project", opts.Dir) - assert.Equal(t, EnvStaging, opts.Environment) - assert.Equal(t, "dep-123", opts.DeploymentID) + AssertEqual(t, "/project", opts.Dir) + AssertEqual(t, EnvStaging, opts.Environment) + AssertEqual(t, "dep-123", opts.DeploymentID) }) } -func TestRollbackOptions_Struct_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestPHP_RollbackOptions_Struct_Good(t *T) { + t.Run("all fields accessible", func(t *T) { opts := RollbackOptions{ Dir: "/project", Environment: EnvProduction, @@ -113,63 +110,63 @@ func TestRollbackOptions_Struct_Good(t *testing.T) { WaitTimeout: 5 * time.Minute, } - assert.Equal(t, "/project", opts.Dir) - assert.Equal(t, EnvProduction, opts.Environment) - assert.Equal(t, "dep-old", opts.DeploymentID) - assert.True(t, opts.Wait) - assert.Equal(t, 5*time.Minute, opts.WaitTimeout) + AssertEqual(t, "/project", opts.Dir) + AssertEqual(t, EnvProduction, opts.Environment) + AssertEqual(t, "dep-old", opts.DeploymentID) + AssertTrue(t, opts.Wait) + AssertEqual(t, 5*time.Minute, opts.WaitTimeout) }) } -func TestEnvironment_Constants(t *testing.T) { - t.Run("constants are defined", func(t *testing.T) { - assert.Equal(t, Environment("production"), EnvProduction) - assert.Equal(t, Environment("staging"), EnvStaging) +func TestEnvironment_Constants(t *T) { + t.Run("constants are defined", func(t *T) { + AssertEqual(t, Environment("production"), EnvProduction) + AssertEqual(t, Environment("staging"), EnvStaging) }) } -func TestGetAppIDForEnvironment_Edge(t *testing.T) { - t.Run("staging without staging ID falls back to production", func(t *testing.T) { +func TestPHP_GetAppIDForEnvironment_Ugly(t *T) { + t.Run("staging without staging ID falls back to production", func(t *T) { config := &CoolifyConfig{ AppID: "prod-123", // No StagingAppID set } id := getAppIDForEnvironment(config, EnvStaging) - assert.Equal(t, "prod-123", id) + AssertEqual(t, "prod-123", id) }) - t.Run("staging with staging ID uses staging", func(t *testing.T) { + t.Run("staging with staging ID uses staging", func(t *T) { config := &CoolifyConfig{ AppID: "prod-123", StagingAppID: "staging-456", } id := getAppIDForEnvironment(config, EnvStaging) - assert.Equal(t, "staging-456", id) + AssertEqual(t, "staging-456", id) }) - t.Run("production uses production ID", func(t *testing.T) { + t.Run("production uses production ID", func(t *T) { config := &CoolifyConfig{ AppID: "prod-123", StagingAppID: "staging-456", } id := getAppIDForEnvironment(config, EnvProduction) - assert.Equal(t, "prod-123", id) + AssertEqual(t, "prod-123", id) }) - t.Run("unknown environment uses production", func(t *testing.T) { + t.Run("unknown environment uses production", func(t *T) { config := &CoolifyConfig{ AppID: "prod-123", } id := getAppIDForEnvironment(config, "unknown") - assert.Equal(t, "prod-123", id) + AssertEqual(t, "prod-123", id) }) } -func TestIsDeploymentComplete_Edge(t *testing.T) { +func TestPHP_IsDeploymentComplete_Ugly(t *T) { tests := []struct { status string expected bool @@ -189,14 +186,14 @@ func TestIsDeploymentComplete_Edge(t *testing.T) { } for _, tt := range tests { - t.Run(tt.status, func(t *testing.T) { + t.Run(tt.status, func(t *T) { result := IsDeploymentComplete(tt.status) - assert.Equal(t, tt.expected, result) + AssertEqual(t, tt.expected, result) }) } } -func TestIsDeploymentSuccessful_Edge(t *testing.T) { +func TestPHP_IsDeploymentSuccessful_Ugly(t *T) { tests := []struct { status string expected bool @@ -213,9 +210,9 @@ func TestIsDeploymentSuccessful_Edge(t *testing.T) { } for _, tt := range tests { - t.Run(tt.status, func(t *testing.T) { + t.Run(tt.status, func(t *T) { result := IsDeploymentSuccessful(tt.status) - assert.Equal(t, tt.expected, result) + AssertEqual(t, tt.expected, result) }) } } diff --git a/pkg/php/deploy_test.go b/pkg/php/deploy_test.go index 228de7d..693139e 100644 --- a/pkg/php/deploy_test.go +++ b/pkg/php/deploy_test.go @@ -3,10 +3,9 @@ package php import ( "os" "path/filepath" - "testing" ) -func TestLoadCoolifyConfig_Good(t *testing.T) { +func TestPHP_LoadCoolifyConfig_Good(t *T) { tests := []struct { name string envContent string @@ -51,7 +50,7 @@ COOLIFY_APP_ID=app-123 } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + t.Run(tt.name, func(t *T) { // Create temp directory dir := t.TempDir() envPath := filepath.Join(dir, ".env") @@ -83,7 +82,7 @@ COOLIFY_APP_ID=app-123 } } -func TestLoadCoolifyConfig_Bad(t *testing.T) { +func TestPHP_LoadCoolifyConfig_Bad(t *T) { tests := []struct { name string envContent string @@ -107,7 +106,7 @@ func TestLoadCoolifyConfig_Bad(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + t.Run(tt.name, func(t *T) { // Create temp directory dir := t.TempDir() envPath := filepath.Join(dir, ".env") @@ -130,7 +129,7 @@ func TestLoadCoolifyConfig_Bad(t *testing.T) { } } -func TestGetAppIDForEnvironment_Good(t *testing.T) { +func TestPHP_GetAppIDForEnvironment_Good(t *T) { config := &CoolifyConfig{ URL: "https://coolify.example.com", Token: "token", @@ -161,7 +160,7 @@ func TestGetAppIDForEnvironment_Good(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + t.Run(tt.name, func(t *T) { id := getAppIDForEnvironment(config, tt.env) if id != tt.wantID { t.Errorf("getAppIDForEnvironment() = %q, want %q", id, tt.wantID) @@ -170,7 +169,7 @@ func TestGetAppIDForEnvironment_Good(t *testing.T) { } } -func TestGetAppIDForEnvironment_FallbackToProduction(t *testing.T) { +func TestGetAppIDForEnvironment_FallbackToProduction(t *T) { config := &CoolifyConfig{ URL: "https://coolify.example.com", Token: "token", @@ -185,7 +184,7 @@ func TestGetAppIDForEnvironment_FallbackToProduction(t *testing.T) { } } -func TestIsDeploymentComplete_Good(t *testing.T) { +func TestPHP_IsDeploymentComplete_Good(t *T) { completeStatuses := []string{"finished", "success", "failed", "error", "cancelled"} for _, status := range completeStatuses { if !IsDeploymentComplete(status) { @@ -201,7 +200,7 @@ func TestIsDeploymentComplete_Good(t *testing.T) { } } -func TestIsDeploymentSuccessful_Good(t *testing.T) { +func TestPHP_IsDeploymentSuccessful_Good(t *T) { successStatuses := []string{"finished", "success"} for _, status := range successStatuses { if !IsDeploymentSuccessful(status) { @@ -217,7 +216,7 @@ func TestIsDeploymentSuccessful_Good(t *testing.T) { } } -func TestNewCoolifyClient_Good(t *testing.T) { +func TestPHP_NewCoolifyClient_Good(t *T) { tests := []struct { name string baseURL string @@ -241,7 +240,7 @@ func TestNewCoolifyClient_Good(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + t.Run(tt.name, func(t *T) { client := NewCoolifyClient(tt.baseURL, "token") if client.BaseURL != tt.wantBaseURL { t.Errorf("BaseURL = %q, want %q", client.BaseURL, tt.wantBaseURL) diff --git a/pkg/php/detect_test.go b/pkg/php/detect_test.go index 9b72f84..3c21d95 100644 --- a/pkg/php/detect_test.go +++ b/pkg/php/detect_test.go @@ -3,20 +3,16 @@ package php import ( "os" "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestIsLaravelProject_Good(t *testing.T) { - t.Run("valid Laravel project with artisan and composer.json", func(t *testing.T) { +func TestPHP_IsLaravelProject_Good(t *T) { + t.Run("valid Laravel project with artisan and composer.json", func(t *T) { dir := t.TempDir() // Create artisan file artisanPath := filepath.Join(dir, "artisan") err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) + RequireNoError(t, err) // Create composer.json with laravel/framework composerJSON := `{ @@ -28,18 +24,18 @@ func TestIsLaravelProject_Good(t *testing.T) { }` composerPath := filepath.Join(dir, "composer.json") err = os.WriteFile(composerPath, []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.True(t, IsLaravelProject(dir)) + AssertTrue(t, IsLaravelProject(dir)) }) - t.Run("Laravel in require-dev", func(t *testing.T) { + t.Run("Laravel in require-dev", func(t *T) { dir := t.TempDir() // Create artisan file artisanPath := filepath.Join(dir, "artisan") err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) + RequireNoError(t, err) // Create composer.json with laravel/framework in require-dev composerJSON := `{ @@ -50,14 +46,14 @@ func TestIsLaravelProject_Good(t *testing.T) { }` composerPath := filepath.Join(dir, "composer.json") err = os.WriteFile(composerPath, []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.True(t, IsLaravelProject(dir)) + AssertTrue(t, IsLaravelProject(dir)) }) } -func TestIsLaravelProject_Bad(t *testing.T) { - t.Run("missing artisan file", func(t *testing.T) { +func TestPHP_IsLaravelProject_Bad(t *T) { + t.Run("missing artisan file", func(t *T) { dir := t.TempDir() // Create composer.json but no artisan @@ -69,29 +65,29 @@ func TestIsLaravelProject_Bad(t *testing.T) { }` composerPath := filepath.Join(dir, "composer.json") err := os.WriteFile(composerPath, []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.False(t, IsLaravelProject(dir)) + AssertFalse(t, IsLaravelProject(dir)) }) - t.Run("missing composer.json", func(t *testing.T) { + t.Run("missing composer.json", func(t *T) { dir := t.TempDir() // Create artisan but no composer.json artisanPath := filepath.Join(dir, "artisan") err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) + RequireNoError(t, err) - assert.False(t, IsLaravelProject(dir)) + AssertFalse(t, IsLaravelProject(dir)) }) - t.Run("composer.json without Laravel", func(t *testing.T) { + t.Run("composer.json without Laravel", func(t *T) { dir := t.TempDir() // Create artisan file artisanPath := filepath.Join(dir, "artisan") err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) + RequireNoError(t, err) // Create composer.json without laravel/framework composerJSON := `{ @@ -102,39 +98,39 @@ func TestIsLaravelProject_Bad(t *testing.T) { }` composerPath := filepath.Join(dir, "composer.json") err = os.WriteFile(composerPath, []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.False(t, IsLaravelProject(dir)) + AssertFalse(t, IsLaravelProject(dir)) }) - t.Run("invalid composer.json", func(t *testing.T) { + t.Run("invalid composer.json", func(t *T) { dir := t.TempDir() // Create artisan file artisanPath := filepath.Join(dir, "artisan") err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) + RequireNoError(t, err) // Create invalid composer.json composerPath := filepath.Join(dir, "composer.json") err = os.WriteFile(composerPath, []byte("not valid json{"), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.False(t, IsLaravelProject(dir)) + AssertFalse(t, IsLaravelProject(dir)) }) - t.Run("empty directory", func(t *testing.T) { + t.Run("empty directory", func(t *T) { dir := t.TempDir() - assert.False(t, IsLaravelProject(dir)) + AssertFalse(t, IsLaravelProject(dir)) }) - t.Run("non-existent directory", func(t *testing.T) { - assert.False(t, IsLaravelProject("/non/existent/path")) + t.Run("non-existent directory", func(t *T) { + AssertFalse(t, IsLaravelProject("/non/existent/path")) }) } -func TestIsFrankenPHPProject_Good(t *testing.T) { - t.Run("project with octane and frankenphp config", func(t *testing.T) { +func TestPHP_IsFrankenPHPProject_Good(t *T) { + t.Run("project with octane and frankenphp config", func(t *T) { dir := t.TempDir() // Create composer.json with laravel/octane @@ -144,24 +140,24 @@ func TestIsFrankenPHPProject_Good(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) // Create config directory and octane.php configDir := filepath.Join(dir, "config") err = os.MkdirAll(configDir, 0755) - require.NoError(t, err) + RequireNoError(t, err) octaneConfig := ` 'frankenphp', ];` err = os.WriteFile(filepath.Join(configDir, "octane.php"), []byte(octaneConfig), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.True(t, IsFrankenPHPProject(dir)) + AssertTrue(t, IsFrankenPHPProject(dir)) }) - t.Run("project with octane but no config file", func(t *testing.T) { + t.Run("project with octane but no config file", func(t *T) { dir := t.TempDir() // Create composer.json with laravel/octane @@ -171,13 +167,13 @@ return [ } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) // No config file - should still return true (assume frankenphp) - assert.True(t, IsFrankenPHPProject(dir)) + AssertTrue(t, IsFrankenPHPProject(dir)) }) - t.Run("project with octane but unreadable config file", func(t *testing.T) { + t.Run("project with octane but unreadable config file", func(t *T) { if os.Geteuid() == 0 { t.Skip("root can read any file") } @@ -190,25 +186,25 @@ return [ } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) // Create config directory and octane.php with no read permissions configDir := filepath.Join(dir, "config") err = os.MkdirAll(configDir, 0755) - require.NoError(t, err) + RequireNoError(t, err) octanePath := filepath.Join(configDir, "octane.php") err = os.WriteFile(octanePath, []byte(" 'swoole', ];` err = os.WriteFile(filepath.Join(configDir, "octane.php"), []byte(octaneConfig), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.False(t, IsFrankenPHPProject(dir)) + AssertFalse(t, IsFrankenPHPProject(dir)) }) } diff --git a/pkg/php/dockerfile_test.go b/pkg/php/dockerfile_test.go index 5c3b1ce..1f20b3e 100644 --- a/pkg/php/dockerfile_test.go +++ b/pkg/php/dockerfile_test.go @@ -4,14 +4,10 @@ import ( "os" "path/filepath" "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestGenerateDockerfile_Good(t *testing.T) { - t.Run("basic Laravel project", func(t *testing.T) { +func TestPHP_GenerateDockerfile_Good(t *T) { + t.Run("basic Laravel project", func(t *T) { dir := t.TempDir() // Create composer.json @@ -23,24 +19,24 @@ func TestGenerateDockerfile_Good(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) // Create composer.lock err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) + RequireNoError(t, err) content, err := GenerateDockerfile(dir) - require.NoError(t, err) + RequireNoError(t, err) // Check content - assert.Contains(t, content, "FROM dunglas/frankenphp") - assert.Contains(t, content, "php8.2") - assert.Contains(t, content, "COPY composer.json composer.lock") - assert.Contains(t, content, "composer install") - assert.Contains(t, content, "EXPOSE 80 443") + AssertContains(t, content, "FROM dunglas/frankenphp") + AssertContains(t, content, "php8.2") + AssertContains(t, content, "COPY composer.json composer.lock") + AssertContains(t, content, "composer install") + AssertContains(t, content, "EXPOSE 80 443") }) - t.Run("Laravel project with Octane", func(t *testing.T) { + t.Run("Laravel project with Octane", func(t *T) { dir := t.TempDir() composerJSON := `{ @@ -52,18 +48,18 @@ func TestGenerateDockerfile_Good(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) + RequireNoError(t, err) content, err := GenerateDockerfile(dir) - require.NoError(t, err) + RequireNoError(t, err) - assert.Contains(t, content, "php8.3") - assert.Contains(t, content, "octane:start") + AssertContains(t, content, "php8.3") + AssertContains(t, content, "octane:start") }) - t.Run("project with frontend assets", func(t *testing.T) { + t.Run("project with frontend assets", func(t *T) { dir := t.TempDir() composerJSON := `{ @@ -74,9 +70,9 @@ func TestGenerateDockerfile_Good(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) + RequireNoError(t, err) packageJSON := `{ "name": "test-app", @@ -86,21 +82,21 @@ func TestGenerateDockerfile_Good(t *testing.T) { } }` err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0644) - require.NoError(t, err) + RequireNoError(t, err) content, err := GenerateDockerfile(dir) - require.NoError(t, err) + RequireNoError(t, err) // Should have multi-stage build - assert.Contains(t, content, "FROM node:20-alpine AS frontend") - assert.Contains(t, content, "npm ci") - assert.Contains(t, content, "npm run build") - assert.Contains(t, content, "COPY --from=frontend") + AssertContains(t, content, "FROM node:20-alpine AS frontend") + AssertContains(t, content, "npm ci") + AssertContains(t, content, "npm run build") + AssertContains(t, content, "COPY --from=frontend") }) - t.Run("project with pnpm", func(t *testing.T) { + t.Run("project with pnpm", func(t *T) { dir := t.TempDir() composerJSON := `{ @@ -111,9 +107,9 @@ func TestGenerateDockerfile_Good(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) + RequireNoError(t, err) packageJSON := `{ "name": "test-app", @@ -122,20 +118,20 @@ func TestGenerateDockerfile_Good(t *testing.T) { } }` err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) // Create pnpm-lock.yaml err = os.WriteFile(filepath.Join(dir, "pnpm-lock.yaml"), []byte("lockfileVersion: 6.0"), 0644) - require.NoError(t, err) + RequireNoError(t, err) content, err := GenerateDockerfile(dir) - require.NoError(t, err) + RequireNoError(t, err) - assert.Contains(t, content, "pnpm install") - assert.Contains(t, content, "pnpm run build") + AssertContains(t, content, "pnpm install") + AssertContains(t, content, "pnpm run build") }) - t.Run("project with Redis dependency", func(t *testing.T) { + t.Run("project with Redis dependency", func(t *T) { dir := t.TempDir() composerJSON := `{ @@ -147,18 +143,18 @@ func TestGenerateDockerfile_Good(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) + RequireNoError(t, err) content, err := GenerateDockerfile(dir) - require.NoError(t, err) + RequireNoError(t, err) - assert.Contains(t, content, "install-php-extensions") - assert.Contains(t, content, "redis") + AssertContains(t, content, "install-php-extensions") + AssertContains(t, content, "redis") }) - t.Run("project with explicit ext- requirements", func(t *testing.T) { + t.Run("project with explicit ext- requirements", func(t *T) { dir := t.TempDir() composerJSON := `{ @@ -171,42 +167,42 @@ func TestGenerateDockerfile_Good(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) + RequireNoError(t, err) content, err := GenerateDockerfile(dir) - require.NoError(t, err) + RequireNoError(t, err) - assert.Contains(t, content, "install-php-extensions") - assert.Contains(t, content, "gd") - assert.Contains(t, content, "imagick") - assert.Contains(t, content, "intl") + AssertContains(t, content, "install-php-extensions") + AssertContains(t, content, "gd") + AssertContains(t, content, "imagick") + AssertContains(t, content, "intl") }) } -func TestGenerateDockerfile_Bad(t *testing.T) { - t.Run("missing composer.json", func(t *testing.T) { +func TestPHP_GenerateDockerfile_Bad(t *T) { + t.Run("missing composer.json", func(t *T) { dir := t.TempDir() _, err := GenerateDockerfile(dir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "composer.json") + AssertError(t, err) + AssertContains(t, err.Error(), "composer.json") }) - t.Run("invalid composer.json", func(t *testing.T) { + t.Run("invalid composer.json", func(t *T) { dir := t.TempDir() err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644) - require.NoError(t, err) + RequireNoError(t, err) _, err = GenerateDockerfile(dir) - assert.Error(t, err) + AssertError(t, err) }) } -func TestDetectDockerfileConfig_Good(t *testing.T) { - t.Run("full Laravel project", func(t *testing.T) { +func TestPHP_DetectDockerfileConfig_Good(t *T) { + t.Run("full Laravel project", func(t *T) { dir := t.TempDir() composerJSON := `{ @@ -220,35 +216,35 @@ func TestDetectDockerfileConfig_Good(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) packageJSON := `{"scripts": {"build": "vite build"}}` err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0644) - require.NoError(t, err) + RequireNoError(t, err) config, err := DetectDockerfileConfig(dir) - require.NoError(t, err) + RequireNoError(t, err) - assert.Equal(t, "8.3", config.PHPVersion) - assert.True(t, config.IsLaravel) - assert.True(t, config.HasOctane) - assert.True(t, config.HasAssets) - assert.Equal(t, "yarn", config.PackageManager) - assert.Contains(t, config.PHPExtensions, "redis") - assert.Contains(t, config.PHPExtensions, "gd") + AssertEqual(t, "8.3", config.PHPVersion) + AssertTrue(t, config.IsLaravel) + AssertTrue(t, config.HasOctane) + AssertTrue(t, config.HasAssets) + AssertEqual(t, "yarn", config.PackageManager) + AssertContains(t, config.PHPExtensions, "redis") + AssertContains(t, config.PHPExtensions, "gd") }) } -func TestDetectDockerfileConfig_Bad(t *testing.T) { - t.Run("non-existent directory", func(t *testing.T) { +func TestPHP_DetectDockerfileConfig_Bad(t *T) { + t.Run("non-existent directory", func(t *T) { _, err := DetectDockerfileConfig("/non/existent/path") - assert.Error(t, err) + AssertError(t, err) }) } -func TestExtractPHPVersion_Good(t *testing.T) { +func TestPHP_ExtractPHPVersion_Good(t *T) { tests := []struct { constraint string expected string @@ -263,15 +259,15 @@ func TestExtractPHPVersion_Good(t *testing.T) { } for _, tt := range tests { - t.Run(tt.constraint, func(t *testing.T) { + t.Run(tt.constraint, func(t *T) { result := extractPHPVersion(tt.constraint) - assert.Equal(t, tt.expected, result) + AssertEqual(t, tt.expected, result) }) } } -func TestDetectPHPExtensions_Good(t *testing.T) { - t.Run("detects Redis from predis", func(t *testing.T) { +func TestPHP_DetectPHPExtensions_Good(t *T) { + t.Run("detects Redis from predis", func(t *T) { composer := ComposerJSON{ Require: map[string]string{ "predis/predis": "^2.0", @@ -279,10 +275,10 @@ func TestDetectPHPExtensions_Good(t *testing.T) { } extensions := detectPHPExtensions(composer) - assert.Contains(t, extensions, "redis") + AssertContains(t, extensions, "redis") }) - t.Run("detects GD from intervention/image", func(t *testing.T) { + t.Run("detects GD from intervention/image", func(t *T) { composer := ComposerJSON{ Require: map[string]string{ "intervention/image": "^3.0", @@ -290,10 +286,10 @@ func TestDetectPHPExtensions_Good(t *testing.T) { } extensions := detectPHPExtensions(composer) - assert.Contains(t, extensions, "gd") + AssertContains(t, extensions, "gd") }) - t.Run("detects multiple extensions from Laravel", func(t *testing.T) { + t.Run("detects multiple extensions from Laravel", func(t *T) { composer := ComposerJSON{ Require: map[string]string{ "laravel/framework": "^11.0", @@ -301,11 +297,11 @@ func TestDetectPHPExtensions_Good(t *testing.T) { } extensions := detectPHPExtensions(composer) - assert.Contains(t, extensions, "pdo_mysql") - assert.Contains(t, extensions, "bcmath") + AssertContains(t, extensions, "pdo_mysql") + AssertContains(t, extensions, "bcmath") }) - t.Run("detects explicit ext- requirements", func(t *testing.T) { + t.Run("detects explicit ext- requirements", func(t *T) { composer := ComposerJSON{ Require: map[string]string{ "ext-gd": "*", @@ -314,11 +310,11 @@ func TestDetectPHPExtensions_Good(t *testing.T) { } extensions := detectPHPExtensions(composer) - assert.Contains(t, extensions, "gd") - assert.Contains(t, extensions, "imagick") + AssertContains(t, extensions, "gd") + AssertContains(t, extensions, "imagick") }) - t.Run("skips built-in extensions", func(t *testing.T) { + t.Run("skips built-in extensions", func(t *T) { composer := ComposerJSON{ Require: map[string]string{ "ext-json": "*", @@ -328,12 +324,12 @@ func TestDetectPHPExtensions_Good(t *testing.T) { } extensions := detectPHPExtensions(composer) - assert.NotContains(t, extensions, "json") - assert.NotContains(t, extensions, "session") - assert.NotContains(t, extensions, "pdo") + AssertNotContains(t, extensions, "json") + AssertNotContains(t, extensions, "session") + AssertNotContains(t, extensions, "pdo") }) - t.Run("sorts extensions alphabetically", func(t *testing.T) { + t.Run("sorts extensions alphabetically", func(t *T) { composer := ComposerJSON{ Require: map[string]string{ "ext-zip": "*", @@ -346,14 +342,13 @@ func TestDetectPHPExtensions_Good(t *testing.T) { // Check they are sorted for i := 1; i < len(extensions); i++ { - assert.True(t, extensions[i-1] < extensions[i], - "extensions should be sorted: %v", extensions) + AssertTrue(t, extensions[i-1] < extensions[i], "extensions should be sorted") } }) } -func TestHasNodeAssets_Good(t *testing.T) { - t.Run("with build script", func(t *testing.T) { +func TestPHP_HasNodeAssets_Good(t *T) { + t.Run("with build script", func(t *T) { dir := t.TempDir() packageJSON := `{ @@ -364,19 +359,19 @@ func TestHasNodeAssets_Good(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.True(t, hasNodeAssets(dir)) + AssertTrue(t, hasNodeAssets(dir)) }) } -func TestHasNodeAssets_Bad(t *testing.T) { - t.Run("no package.json", func(t *testing.T) { +func TestPHP_HasNodeAssets_Bad(t *T) { + t.Run("no package.json", func(t *T) { dir := t.TempDir() - assert.False(t, hasNodeAssets(dir)) + AssertFalse(t, hasNodeAssets(dir)) }) - t.Run("no build script", func(t *testing.T) { + t.Run("no build script", func(t *T) { dir := t.TempDir() packageJSON := `{ @@ -386,39 +381,39 @@ func TestHasNodeAssets_Bad(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.False(t, hasNodeAssets(dir)) + AssertFalse(t, hasNodeAssets(dir)) }) - t.Run("invalid package.json", func(t *testing.T) { + t.Run("invalid package.json", func(t *T) { dir := t.TempDir() err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("invalid{"), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.False(t, hasNodeAssets(dir)) + AssertFalse(t, hasNodeAssets(dir)) }) } -func TestGenerateDockerignore_Good(t *testing.T) { - t.Run("generates complete dockerignore", func(t *testing.T) { +func TestPHP_GenerateDockerignore_Good(t *T) { + t.Run("generates complete dockerignore", func(t *T) { dir := t.TempDir() content := GenerateDockerignore(dir) // Check key entries - assert.Contains(t, content, ".git") - assert.Contains(t, content, "node_modules") - assert.Contains(t, content, ".env") - assert.Contains(t, content, "vendor") - assert.Contains(t, content, "storage/logs/*") - assert.Contains(t, content, ".idea") - assert.Contains(t, content, ".vscode") + AssertContains(t, content, ".git") + AssertContains(t, content, "node_modules") + AssertContains(t, content, ".env") + AssertContains(t, content, "vendor") + AssertContains(t, content, "storage/logs/*") + AssertContains(t, content, ".idea") + AssertContains(t, content, ".vscode") }) } -func TestGenerateDockerfileFromConfig_Good(t *testing.T) { - t.Run("minimal config", func(t *testing.T) { +func TestPHP_GenerateDockerfileFromConfig_Good(t *T) { + t.Run("minimal config", func(t *T) { config := &DockerfileConfig{ PHPVersion: "8.3", BaseImage: "dunglas/frankenphp", @@ -427,13 +422,13 @@ func TestGenerateDockerfileFromConfig_Good(t *testing.T) { content := GenerateDockerfileFromConfig(config) - assert.Contains(t, content, "FROM dunglas/frankenphp:latest-php8.3-alpine") - assert.Contains(t, content, "WORKDIR /app") - assert.Contains(t, content, "COPY composer.json composer.lock") - assert.Contains(t, content, "EXPOSE 80 443") + AssertContains(t, content, "FROM dunglas/frankenphp:latest-php8.3-alpine") + AssertContains(t, content, "WORKDIR /app") + AssertContains(t, content, "COPY composer.json composer.lock") + AssertContains(t, content, "EXPOSE 80 443") }) - t.Run("with extensions", func(t *testing.T) { + t.Run("with extensions", func(t *T) { config := &DockerfileConfig{ PHPVersion: "8.3", BaseImage: "dunglas/frankenphp", @@ -443,10 +438,10 @@ func TestGenerateDockerfileFromConfig_Good(t *testing.T) { content := GenerateDockerfileFromConfig(config) - assert.Contains(t, content, "install-php-extensions redis gd intl") + AssertContains(t, content, "install-php-extensions redis gd intl") }) - t.Run("Laravel with Octane", func(t *testing.T) { + t.Run("Laravel with Octane", func(t *T) { config := &DockerfileConfig{ PHPVersion: "8.3", BaseImage: "dunglas/frankenphp", @@ -457,14 +452,14 @@ func TestGenerateDockerfileFromConfig_Good(t *testing.T) { content := GenerateDockerfileFromConfig(config) - assert.Contains(t, content, "php artisan config:cache") - assert.Contains(t, content, "php artisan route:cache") - assert.Contains(t, content, "php artisan view:cache") - assert.Contains(t, content, "chown -R www-data:www-data storage") - assert.Contains(t, content, "octane:start") + AssertContains(t, content, "php artisan config:cache") + AssertContains(t, content, "php artisan route:cache") + AssertContains(t, content, "php artisan view:cache") + AssertContains(t, content, "chown -R www-data:www-data storage") + AssertContains(t, content, "octane:start") }) - t.Run("with frontend assets", func(t *testing.T) { + t.Run("with frontend assets", func(t *T) { config := &DockerfileConfig{ PHPVersion: "8.3", BaseImage: "dunglas/frankenphp", @@ -476,14 +471,14 @@ func TestGenerateDockerfileFromConfig_Good(t *testing.T) { content := GenerateDockerfileFromConfig(config) // Multi-stage build - assert.Contains(t, content, "FROM node:20-alpine AS frontend") - assert.Contains(t, content, "COPY package.json package-lock.json") - assert.Contains(t, content, "RUN npm ci") - assert.Contains(t, content, "RUN npm run build") - assert.Contains(t, content, "COPY --from=frontend /app/public/build public/build") + AssertContains(t, content, "FROM node:20-alpine AS frontend") + AssertContains(t, content, "COPY package.json package-lock.json") + AssertContains(t, content, "RUN npm ci") + AssertContains(t, content, "RUN npm run build") + AssertContains(t, content, "COPY --from=frontend /app/public/build public/build") }) - t.Run("with yarn", func(t *testing.T) { + t.Run("with yarn", func(t *T) { config := &DockerfileConfig{ PHPVersion: "8.3", BaseImage: "dunglas/frankenphp", @@ -494,12 +489,12 @@ func TestGenerateDockerfileFromConfig_Good(t *testing.T) { content := GenerateDockerfileFromConfig(config) - assert.Contains(t, content, "COPY package.json yarn.lock") - assert.Contains(t, content, "yarn install --frozen-lockfile") - assert.Contains(t, content, "yarn build") + AssertContains(t, content, "COPY package.json yarn.lock") + AssertContains(t, content, "yarn install --frozen-lockfile") + AssertContains(t, content, "yarn build") }) - t.Run("with bun", func(t *testing.T) { + t.Run("with bun", func(t *T) { config := &DockerfileConfig{ PHPVersion: "8.3", BaseImage: "dunglas/frankenphp", @@ -510,13 +505,13 @@ func TestGenerateDockerfileFromConfig_Good(t *testing.T) { content := GenerateDockerfileFromConfig(config) - assert.Contains(t, content, "npm install -g bun") - assert.Contains(t, content, "COPY package.json bun.lockb") - assert.Contains(t, content, "bun install --frozen-lockfile") - assert.Contains(t, content, "bun run build") + AssertContains(t, content, "npm install -g bun") + AssertContains(t, content, "COPY package.json bun.lockb") + AssertContains(t, content, "bun install --frozen-lockfile") + AssertContains(t, content, "bun run build") }) - t.Run("non-alpine image", func(t *testing.T) { + t.Run("non-alpine image", func(t *T) { config := &DockerfileConfig{ PHPVersion: "8.3", BaseImage: "dunglas/frankenphp", @@ -525,42 +520,42 @@ func TestGenerateDockerfileFromConfig_Good(t *testing.T) { content := GenerateDockerfileFromConfig(config) - assert.Contains(t, content, "FROM dunglas/frankenphp:latest-php8.3 AS app") - assert.NotContains(t, content, "alpine") + AssertContains(t, content, "FROM dunglas/frankenphp:latest-php8.3 AS app") + AssertNotContains(t, content, "alpine") }) } -func TestIsPHPProject_Good(t *testing.T) { - t.Run("project with composer.json", func(t *testing.T) { +func TestPHP_IsPHPProject_Good(t *T) { + t.Run("project with composer.json", func(t *T) { dir := t.TempDir() err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("{}"), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.True(t, IsPHPProject(dir)) + AssertTrue(t, IsPHPProject(dir)) }) } -func TestIsPHPProject_Bad(t *testing.T) { - t.Run("project without composer.json", func(t *testing.T) { +func TestPHP_IsPHPProject_Bad(t *T) { + t.Run("project without composer.json", func(t *T) { dir := t.TempDir() - assert.False(t, IsPHPProject(dir)) + AssertFalse(t, IsPHPProject(dir)) }) - t.Run("non-existent directory", func(t *testing.T) { - assert.False(t, IsPHPProject("/non/existent/path")) + t.Run("non-existent directory", func(t *T) { + AssertFalse(t, IsPHPProject("/non/existent/path")) }) } -func TestExtractPHPVersion_Edge(t *testing.T) { - t.Run("handles single major version", func(t *testing.T) { +func TestPHP_ExtractPHPVersion_Ugly(t *T) { + t.Run("handles single major version", func(t *T) { result := extractPHPVersion("8") - assert.Equal(t, "8.0", result) + AssertEqual(t, "8.0", result) }) } -func TestDetectPHPExtensions_RequireDev(t *testing.T) { - t.Run("detects extensions from require-dev", func(t *testing.T) { +func TestDetectPHPExtensions_RequireDev(t *T) { + t.Run("detects extensions from require-dev", func(t *T) { composer := ComposerJSON{ RequireDev: map[string]string{ "predis/predis": "^2.0", @@ -568,12 +563,12 @@ func TestDetectPHPExtensions_RequireDev(t *testing.T) { } extensions := detectPHPExtensions(composer) - assert.Contains(t, extensions, "redis") + AssertContains(t, extensions, "redis") }) } -func TestDockerfileStructure_Good(t *testing.T) { - t.Run("Dockerfile has proper structure", func(t *testing.T) { +func TestPHP_DockerfileStructure_Good(t *T) { + t.Run("Dockerfile has proper structure", func(t *T) { dir := t.TempDir() composerJSON := `{ @@ -586,18 +581,18 @@ func TestDockerfileStructure_Good(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) + RequireNoError(t, err) packageJSON := `{"scripts": {"build": "vite build"}}` err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0644) - require.NoError(t, err) + RequireNoError(t, err) content, err := GenerateDockerfile(dir) - require.NoError(t, err) + RequireNoError(t, err) lines := strings.Split(content, "\n") var fromCount, workdirCount, copyCount, runCount, exposeCount, cmdCount int @@ -622,13 +617,13 @@ func TestDockerfileStructure_Good(t *testing.T) { } // Multi-stage build should have 2 FROM statements - assert.Equal(t, 2, fromCount, "should have 2 FROM statements for multi-stage build") + AssertEqual(t, 2, fromCount, "should have 2 FROM statements for multi-stage build") // Should have proper structure - assert.GreaterOrEqual(t, workdirCount, 1, "should have WORKDIR") - assert.GreaterOrEqual(t, copyCount, 3, "should have multiple COPY statements") - assert.GreaterOrEqual(t, runCount, 2, "should have multiple RUN statements") - assert.Equal(t, 1, exposeCount, "should have exactly one EXPOSE") - assert.Equal(t, 1, cmdCount, "should have exactly one CMD") + AssertGreaterOrEqual(t, workdirCount, 1, "should have WORKDIR") + AssertGreaterOrEqual(t, copyCount, 3, "should have multiple COPY statements") + AssertGreaterOrEqual(t, runCount, 2, "should have multiple RUN statements") + AssertEqual(t, 1, exposeCount, "should have exactly one EXPOSE") + AssertEqual(t, 1, cmdCount, "should have exactly one CMD") }) } diff --git a/pkg/php/env.go b/pkg/php/env.go index 3e97da9..e3b0551 100644 --- a/pkg/php/env.go +++ b/pkg/php/env.go @@ -1,5 +1,3 @@ -//go:build cgo - package php import ( diff --git a/pkg/php/handler.go b/pkg/php/handler.go index 5dc0a85..65b75ef 100644 --- a/pkg/php/handler.go +++ b/pkg/php/handler.go @@ -1,4 +1,4 @@ -//go:build cgo +//go:build frankenphp // Package php provides FrankenPHP embedding for Go applications. // Serves a Laravel application via the FrankenPHP runtime, with support diff --git a/pkg/php/handler_stub.go b/pkg/php/handler_stub.go new file mode 100644 index 0000000..bf8af90 --- /dev/null +++ b/pkg/php/handler_stub.go @@ -0,0 +1,53 @@ +//go:build !frankenphp + +package php + +import ( + "fmt" + "net/http" + "path/filepath" +) + +// 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) { + if cfg.NumThreads == 0 { + cfg.NumThreads = 4 + } + if cfg.NumWorkers == 0 { + cfg.NumWorkers = 2 + } + + handler := &Handler{ + docRoot: filepath.Join(laravelRoot, "public"), + laravelRoot: laravelRoot, + } + cleanup := func() {} + return handler, cleanup, fmt.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/pkg/php/packages_test.go b/pkg/php/packages_test.go index a340a9b..3488f9f 100644 --- a/pkg/php/packages_test.go +++ b/pkg/php/packages_test.go @@ -4,14 +4,10 @@ import ( "encoding/json" "os" "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestReadComposerJSON_Good(t *testing.T) { - t.Run("reads valid composer.json", func(t *testing.T) { +func TestPHP_ReadComposerJSON_Good(t *T) { + t.Run("reads valid composer.json", func(t *T) { dir := t.TempDir() composerJSON := `{ "name": "test/project", @@ -20,15 +16,15 @@ func TestReadComposerJSON_Good(t *testing.T) { } }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) raw, err := readComposerJSON(dir) - assert.NoError(t, err) - assert.NotNil(t, raw) - assert.Contains(t, string(raw["name"]), "test/project") + AssertNoError(t, err) + AssertNotNil(t, raw) + AssertContains(t, string(raw["name"]), "test/project") }) - t.Run("preserves all fields", func(t *testing.T) { + t.Run("preserves all fields", func(t *T) { dir := t.TempDir() composerJSON := `{ "name": "test/project", @@ -37,236 +33,236 @@ func TestReadComposerJSON_Good(t *testing.T) { "autoload": {"psr-4": {"App\\": "src/"}} }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) raw, err := readComposerJSON(dir) - assert.NoError(t, err) - assert.Contains(t, string(raw["autoload"]), "psr-4") + AssertNoError(t, err) + AssertContains(t, string(raw["autoload"]), "psr-4") }) } -func TestReadComposerJSON_Bad(t *testing.T) { - t.Run("missing composer.json", func(t *testing.T) { +func TestPHP_ReadComposerJSON_Bad(t *T) { + t.Run("missing composer.json", func(t *T) { dir := t.TempDir() _, err := readComposerJSON(dir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Failed to read composer.json") + AssertError(t, err) + AssertContains(t, err.Error(), "failed to read composer.json") }) - t.Run("invalid JSON", func(t *testing.T) { + t.Run("invalid JSON", func(t *T) { dir := t.TempDir() err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644) - require.NoError(t, err) + RequireNoError(t, err) _, err = readComposerJSON(dir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Failed to parse composer.json") + AssertError(t, err) + AssertContains(t, err.Error(), "failed to parse composer.json") }) } -func TestWriteComposerJSON_Good(t *testing.T) { - t.Run("writes valid composer.json", func(t *testing.T) { +func TestPHP_WriteComposerJSON_Good(t *T) { + t.Run("writes valid composer.json", func(t *T) { dir := t.TempDir() raw := make(map[string]json.RawMessage) raw["name"] = json.RawMessage(`"test/project"`) err := writeComposerJSON(dir, raw) - assert.NoError(t, err) + AssertNoError(t, err) // Verify file was written content, err := os.ReadFile(filepath.Join(dir, "composer.json")) - assert.NoError(t, err) - assert.Contains(t, string(content), "test/project") + AssertNoError(t, err) + AssertContains(t, string(content), "test/project") // Verify trailing newline - assert.True(t, content[len(content)-1] == '\n') + AssertTrue(t, content[len(content)-1] == '\n') }) - t.Run("pretty prints with indentation", func(t *testing.T) { + t.Run("pretty prints with indentation", func(t *T) { dir := t.TempDir() raw := make(map[string]json.RawMessage) raw["name"] = json.RawMessage(`"test/project"`) raw["require"] = json.RawMessage(`{"php":"^8.2"}`) err := writeComposerJSON(dir, raw) - assert.NoError(t, err) + AssertNoError(t, err) content, err := os.ReadFile(filepath.Join(dir, "composer.json")) - assert.NoError(t, err) + AssertNoError(t, err) // Should be indented - assert.Contains(t, string(content), " ") + AssertContains(t, string(content), " ") }) } -func TestWriteComposerJSON_Bad(t *testing.T) { - t.Run("fails for non-existent directory", func(t *testing.T) { +func TestPHP_WriteComposerJSON_Bad(t *T) { + t.Run("fails for non-existent directory", func(t *T) { raw := make(map[string]json.RawMessage) raw["name"] = json.RawMessage(`"test/project"`) err := writeComposerJSON("/non/existent/path", raw) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Failed to write composer.json") + AssertError(t, err) + AssertContains(t, err.Error(), "failed to write composer.json") }) } -func TestGetRepositories_Good(t *testing.T) { - t.Run("returns empty slice when no repositories", func(t *testing.T) { +func TestPHP_GetRepositories_Good(t *T) { + t.Run("returns empty slice when no repositories", func(t *T) { raw := make(map[string]json.RawMessage) raw["name"] = json.RawMessage(`"test/project"`) repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Empty(t, repos) + AssertNoError(t, err) + AssertEmpty(t, repos) }) - t.Run("parses existing repositories", func(t *testing.T) { + t.Run("parses existing repositories", func(t *T) { raw := make(map[string]json.RawMessage) raw["name"] = json.RawMessage(`"test/project"`) raw["repositories"] = json.RawMessage(`[{"type":"path","url":"/path/to/package"}]`) repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 1) - assert.Equal(t, "path", repos[0].Type) - assert.Equal(t, "/path/to/package", repos[0].URL) + AssertNoError(t, err) + AssertLen(t, repos, 1) + AssertEqual(t, "path", repos[0].Type) + AssertEqual(t, "/path/to/package", repos[0].URL) }) - t.Run("parses repositories with options", func(t *testing.T) { + t.Run("parses repositories with options", func(t *T) { raw := make(map[string]json.RawMessage) raw["repositories"] = json.RawMessage(`[{"type":"path","url":"/path","options":{"symlink":true}}]`) repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 1) - assert.NotNil(t, repos[0].Options) - assert.Equal(t, true, repos[0].Options["symlink"]) + AssertNoError(t, err) + AssertLen(t, repos, 1) + AssertNotNil(t, repos[0].Options) + AssertEqual(t, true, repos[0].Options["symlink"]) }) } -func TestGetRepositories_Bad(t *testing.T) { - t.Run("fails for invalid repositories JSON", func(t *testing.T) { +func TestPHP_GetRepositories_Bad(t *T) { + t.Run("fails for invalid repositories JSON", func(t *T) { raw := make(map[string]json.RawMessage) raw["repositories"] = json.RawMessage(`not valid json`) _, err := getRepositories(raw) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Failed to parse repositories") + AssertError(t, err) + AssertContains(t, err.Error(), "failed to parse repositories") }) } -func TestSetRepositories_Good(t *testing.T) { - t.Run("sets repositories", func(t *testing.T) { +func TestPHP_SetRepositories_Good(t *T) { + t.Run("sets repositories", func(t *T) { raw := make(map[string]json.RawMessage) repos := []composerRepository{ {Type: "path", URL: "/path/to/package"}, } err := setRepositories(raw, repos) - assert.NoError(t, err) - assert.Contains(t, string(raw["repositories"]), "/path/to/package") + AssertNoError(t, err) + AssertContains(t, string(raw["repositories"]), "/path/to/package") }) - t.Run("removes repositories key when empty", func(t *testing.T) { + t.Run("removes repositories key when empty", func(t *T) { raw := make(map[string]json.RawMessage) raw["repositories"] = json.RawMessage(`[{"type":"path"}]`) err := setRepositories(raw, []composerRepository{}) - assert.NoError(t, err) + AssertNoError(t, err) _, exists := raw["repositories"] - assert.False(t, exists) + AssertFalse(t, exists) }) } -func TestGetPackageInfo_Good(t *testing.T) { - t.Run("extracts package name and version", func(t *testing.T) { +func TestPHP_GetPackageInfo_Good(t *T) { + t.Run("extracts package name and version", func(t *T) { dir := t.TempDir() composerJSON := `{ "name": "vendor/package", "version": "1.0.0" }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) name, version, err := getPackageInfo(dir) - assert.NoError(t, err) - assert.Equal(t, "vendor/package", name) - assert.Equal(t, "1.0.0", version) + AssertNoError(t, err) + AssertEqual(t, "vendor/package", name) + AssertEqual(t, "1.0.0", version) }) - t.Run("works without version", func(t *testing.T) { + t.Run("works without version", func(t *T) { dir := t.TempDir() composerJSON := `{ "name": "vendor/package" }` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) name, version, err := getPackageInfo(dir) - assert.NoError(t, err) - assert.Equal(t, "vendor/package", name) - assert.Equal(t, "", version) + AssertNoError(t, err) + AssertEqual(t, "vendor/package", name) + AssertEqual(t, "", version) }) } -func TestGetPackageInfo_Bad(t *testing.T) { - t.Run("missing composer.json", func(t *testing.T) { +func TestPHP_GetPackageInfo_Bad(t *T) { + t.Run("missing composer.json", func(t *T) { dir := t.TempDir() _, _, err := getPackageInfo(dir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Failed to read package composer.json") + AssertError(t, err) + AssertContains(t, err.Error(), "failed to read package composer.json") }) - t.Run("invalid JSON", func(t *testing.T) { + t.Run("invalid JSON", func(t *T) { dir := t.TempDir() err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644) - require.NoError(t, err) + RequireNoError(t, err) _, _, err = getPackageInfo(dir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Failed to parse package composer.json") + AssertError(t, err) + AssertContains(t, err.Error(), "failed to parse package composer.json") }) - t.Run("missing name", func(t *testing.T) { + t.Run("missing name", func(t *T) { dir := t.TempDir() composerJSON := `{"version": "1.0.0"}` err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) _, _, err = getPackageInfo(dir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "package name not found") + AssertError(t, err) + AssertContains(t, err.Error(), "package name not found") }) } -func TestLinkPackages_Good(t *testing.T) { - t.Run("links a package", func(t *testing.T) { +func TestPHP_LinkPackages_Good(t *T) { + t.Run("links a package", func(t *T) { // Create project directory projectDir := t.TempDir() err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) // Create package directory packageDir := t.TempDir() err = os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = LinkPackages(projectDir, []string{packageDir}) - assert.NoError(t, err) + AssertNoError(t, err) // Verify repository was added raw, err := readComposerJSON(projectDir) - assert.NoError(t, err) + AssertNoError(t, err) repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 1) - assert.Equal(t, "path", repos[0].Type) + AssertNoError(t, err) + AssertLen(t, repos, 1) + AssertEqual(t, "path", repos[0].Type) }) - t.Run("skips already linked package", func(t *testing.T) { + t.Run("skips already linked package", func(t *T) { // Create project with existing repository projectDir := t.TempDir() packageDir := t.TempDir() err := os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) absPackagePath, _ := filepath.Abs(packageDir) composerJSON := `{ @@ -274,72 +270,72 @@ func TestLinkPackages_Good(t *testing.T) { "repositories": [{"type":"path","url":"` + absPackagePath + `"}] }` err = os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) // Link again - should not add duplicate err = LinkPackages(projectDir, []string{packageDir}) - assert.NoError(t, err) + AssertNoError(t, err) raw, err := readComposerJSON(projectDir) - assert.NoError(t, err) + AssertNoError(t, err) repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 1) // Still only one + AssertNoError(t, err) + AssertLen(t, repos, 1) // Still only one }) - t.Run("links multiple packages", func(t *testing.T) { + t.Run("links multiple packages", func(t *T) { projectDir := t.TempDir() err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) pkg1Dir := t.TempDir() err = os.WriteFile(filepath.Join(pkg1Dir, "composer.json"), []byte(`{"name":"vendor/pkg1"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) pkg2Dir := t.TempDir() err = os.WriteFile(filepath.Join(pkg2Dir, "composer.json"), []byte(`{"name":"vendor/pkg2"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = LinkPackages(projectDir, []string{pkg1Dir, pkg2Dir}) - assert.NoError(t, err) + AssertNoError(t, err) raw, err := readComposerJSON(projectDir) - assert.NoError(t, err) + AssertNoError(t, err) repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 2) + AssertNoError(t, err) + AssertLen(t, repos, 2) }) } -func TestLinkPackages_Bad(t *testing.T) { - t.Run("fails for non-PHP project", func(t *testing.T) { +func TestPHP_LinkPackages_Bad(t *T) { + t.Run("fails for non-PHP project", func(t *T) { dir := t.TempDir() err := LinkPackages(dir, []string{"/path/to/package"}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a PHP project") + AssertError(t, err) + AssertContains(t, err.Error(), "not a PHP project") }) - t.Run("fails for non-PHP package", func(t *testing.T) { + t.Run("fails for non-PHP package", func(t *T) { projectDir := t.TempDir() err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) packageDir := t.TempDir() // No composer.json in package err = LinkPackages(projectDir, []string{packageDir}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a PHP package") + AssertError(t, err) + AssertContains(t, err.Error(), "not a PHP package") }) } -func TestUnlinkPackages_Good(t *testing.T) { - t.Run("unlinks package by name", func(t *testing.T) { +func TestPHP_UnlinkPackages_Good(t *T) { + t.Run("unlinks package by name", func(t *T) { projectDir := t.TempDir() packageDir := t.TempDir() err := os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) absPackagePath, _ := filepath.Abs(packageDir) composerJSON := `{ @@ -347,19 +343,19 @@ func TestUnlinkPackages_Good(t *testing.T) { "repositories": [{"type":"path","url":"` + absPackagePath + `"}] }` err = os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = UnlinkPackages(projectDir, []string{"vendor/package"}) - assert.NoError(t, err) + AssertNoError(t, err) raw, err := readComposerJSON(projectDir) - assert.NoError(t, err) + AssertNoError(t, err) repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 0) + AssertNoError(t, err) + AssertLen(t, repos, 0) }) - t.Run("unlinks package by path", func(t *testing.T) { + t.Run("unlinks package by path", func(t *T) { projectDir := t.TempDir() packageDir := t.TempDir() @@ -369,19 +365,19 @@ func TestUnlinkPackages_Good(t *testing.T) { "repositories": [{"type":"path","url":"` + absPackagePath + `"}] }` err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = UnlinkPackages(projectDir, []string{absPackagePath}) - assert.NoError(t, err) + AssertNoError(t, err) raw, err := readComposerJSON(projectDir) - assert.NoError(t, err) + AssertNoError(t, err) repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 0) + AssertNoError(t, err) + AssertLen(t, repos, 0) }) - t.Run("keeps non-path repositories", func(t *testing.T) { + t.Run("keeps non-path repositories", func(t *T) { projectDir := t.TempDir() composerJSON := `{ "name": "test/project", @@ -391,36 +387,36 @@ func TestUnlinkPackages_Good(t *testing.T) { ] }` err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = UnlinkPackages(projectDir, []string{"/local/path"}) - assert.NoError(t, err) + AssertNoError(t, err) raw, err := readComposerJSON(projectDir) - assert.NoError(t, err) + AssertNoError(t, err) repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 1) - assert.Equal(t, "vcs", repos[0].Type) + AssertNoError(t, err) + AssertLen(t, repos, 1) + AssertEqual(t, "vcs", repos[0].Type) }) } -func TestUnlinkPackages_Bad(t *testing.T) { - t.Run("fails for non-PHP project", func(t *testing.T) { +func TestPHP_UnlinkPackages_Bad(t *T) { + t.Run("fails for non-PHP project", func(t *T) { dir := t.TempDir() err := UnlinkPackages(dir, []string{"vendor/package"}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a PHP project") + AssertError(t, err) + AssertContains(t, err.Error(), "not a PHP project") }) } -func TestListLinkedPackages_Good(t *testing.T) { - t.Run("lists linked packages", func(t *testing.T) { +func TestPHP_ListLinkedPackages_Good(t *T) { + t.Run("lists linked packages", func(t *T) { projectDir := t.TempDir() packageDir := t.TempDir() err := os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package","version":"1.0.0"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) absPackagePath, _ := filepath.Abs(packageDir) composerJSON := `{ @@ -428,42 +424,42 @@ func TestListLinkedPackages_Good(t *testing.T) { "repositories": [{"type":"path","url":"` + absPackagePath + `"}] }` err = os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) linked, err := ListLinkedPackages(projectDir) - assert.NoError(t, err) - assert.Len(t, linked, 1) - assert.Equal(t, "vendor/package", linked[0].Name) - assert.Equal(t, "1.0.0", linked[0].Version) - assert.Equal(t, absPackagePath, linked[0].Path) + AssertNoError(t, err) + AssertLen(t, linked, 1) + AssertEqual(t, "vendor/package", linked[0].Name) + AssertEqual(t, "1.0.0", linked[0].Version) + AssertEqual(t, absPackagePath, linked[0].Path) }) - t.Run("returns empty list when no linked packages", func(t *testing.T) { + t.Run("returns empty list when no linked packages", func(t *T) { projectDir := t.TempDir() err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) linked, err := ListLinkedPackages(projectDir) - assert.NoError(t, err) - assert.Empty(t, linked) + AssertNoError(t, err) + AssertEmpty(t, linked) }) - t.Run("uses basename when package info unavailable", func(t *testing.T) { + t.Run("uses basename when package info unavailable", func(t *T) { projectDir := t.TempDir() composerJSON := `{ "name": "test/project", "repositories": [{"type":"path","url":"/nonexistent/package-name"}] }` err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) linked, err := ListLinkedPackages(projectDir) - assert.NoError(t, err) - assert.Len(t, linked, 1) - assert.Equal(t, "package-name", linked[0].Name) + AssertNoError(t, err) + AssertLen(t, linked, 1) + AssertEqual(t, "package-name", linked[0].Name) }) - t.Run("ignores non-path repositories", func(t *testing.T) { + t.Run("ignores non-path repositories", func(t *T) { projectDir := t.TempDir() composerJSON := `{ "name": "test/project", @@ -472,39 +468,39 @@ func TestListLinkedPackages_Good(t *testing.T) { ] }` err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) linked, err := ListLinkedPackages(projectDir) - assert.NoError(t, err) - assert.Empty(t, linked) + AssertNoError(t, err) + AssertEmpty(t, linked) }) } -func TestListLinkedPackages_Bad(t *testing.T) { - t.Run("fails for non-PHP project", func(t *testing.T) { +func TestPHP_ListLinkedPackages_Bad(t *T) { + t.Run("fails for non-PHP project", func(t *T) { dir := t.TempDir() _, err := ListLinkedPackages(dir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a PHP project") + AssertError(t, err) + AssertContains(t, err.Error(), "not a PHP project") }) } -func TestUpdatePackages_Bad(t *testing.T) { - t.Run("fails for non-PHP project", func(t *testing.T) { +func TestPHP_UpdatePackages_Bad(t *T) { + t.Run("fails for non-PHP project", func(t *T) { dir := t.TempDir() err := UpdatePackages(dir, []string{"vendor/package"}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a PHP project") + AssertError(t, err) + AssertContains(t, err.Error(), "not a PHP project") }) } -func TestUpdatePackages_Good(t *testing.T) { +func TestPHP_UpdatePackages_Good(t *T) { t.Skip("requires Composer installed") - t.Run("runs composer update", func(t *testing.T) { + t.Run("runs composer update", func(t *T) { projectDir := t.TempDir() err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644) - require.NoError(t, err) + RequireNoError(t, err) _ = UpdatePackages(projectDir, []string{"vendor/package"}) // This will fail because composer update needs real dependencies @@ -512,22 +508,22 @@ func TestUpdatePackages_Good(t *testing.T) { }) } -func TestLinkedPackage_Struct(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestLinkedPackage_Struct(t *T) { + t.Run("all fields accessible", func(t *T) { pkg := LinkedPackage{ Name: "vendor/package", Path: "/path/to/package", Version: "1.0.0", } - assert.Equal(t, "vendor/package", pkg.Name) - assert.Equal(t, "/path/to/package", pkg.Path) - assert.Equal(t, "1.0.0", pkg.Version) + AssertEqual(t, "vendor/package", pkg.Name) + AssertEqual(t, "/path/to/package", pkg.Path) + AssertEqual(t, "1.0.0", pkg.Version) }) } -func TestComposerRepository_Struct(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestComposerRepository_Struct(t *T) { + t.Run("all fields accessible", func(t *T) { repo := composerRepository{ Type: "path", URL: "/path/to/package", @@ -536,8 +532,8 @@ func TestComposerRepository_Struct(t *testing.T) { }, } - assert.Equal(t, "path", repo.Type) - assert.Equal(t, "/path/to/package", repo.URL) - assert.Equal(t, true, repo.Options["symlink"]) + AssertEqual(t, "path", repo.Type) + AssertEqual(t, "/path/to/package", repo.URL) + AssertEqual(t, true, repo.Options["symlink"]) }) } diff --git a/pkg/php/php.go b/pkg/php/php.go index 7f475f3..dcedd35 100644 --- a/pkg/php/php.go +++ b/pkg/php/php.go @@ -195,7 +195,9 @@ func (d *DevServer) Start(ctx context.Context, opts Options) error { if len(startErrors) > 0 { // Stop any services that did start for _, svc := range d.services { - _ = svc.Stop() + if err := svc.Stop(); err != nil { + startErrors = append(startErrors, cli.Err("cleanup %s: %v", svc.Name(), err)) + } } return cli.Err("failed to start services: %v", startErrors) } @@ -295,8 +297,14 @@ func (d *DevServer) unifiedLogs(follow bool) (io.ReadCloser, error) { reader, err := svc.Logs(follow) if err != nil { // Close any readers we already opened + var closeErrors []error for _, r := range readers { - _ = r.Close() + if closeErr := r.Close(); closeErr != nil { + closeErrors = append(closeErrors, closeErr) + } + } + if len(closeErrors) > 0 { + return nil, cli.Err("failed to get logs for %s: %v; failed to close readers: %v", svc.Name(), err, closeErrors) } return nil, cli.Err("failed to get logs for %s: %v", svc.Name(), err) } diff --git a/pkg/php/php_test.go b/pkg/php/php_test.go index e295d73..213ad87 100644 --- a/pkg/php/php_test.go +++ b/pkg/php/php_test.go @@ -6,24 +6,20 @@ import ( "os" "path/filepath" "strings" - "testing" "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestNewDevServer_Good(t *testing.T) { - t.Run("creates dev server with default options", func(t *testing.T) { +func TestPHP_NewDevServer_Good(t *T) { + t.Run("creates dev server with default options", func(t *T) { opts := Options{} server := NewDevServer(opts) - assert.NotNil(t, server) - assert.Empty(t, server.services) - assert.False(t, server.running) + AssertNotNil(t, server) + AssertEmpty(t, server.services) + AssertFalse(t, server.running) }) - t.Run("creates dev server with custom options", func(t *testing.T) { + t.Run("creates dev server with custom options", func(t *T) { opts := Options{ Dir: "/tmp/test", NoVite: true, @@ -32,74 +28,74 @@ func TestNewDevServer_Good(t *testing.T) { } server := NewDevServer(opts) - assert.NotNil(t, server) - assert.Equal(t, "/tmp/test", server.opts.Dir) - assert.True(t, server.opts.NoVite) + AssertNotNil(t, server) + AssertEqual(t, "/tmp/test", server.opts.Dir) + AssertTrue(t, server.opts.NoVite) }) } -func TestDevServer_IsRunning_Good(t *testing.T) { - t.Run("returns false when not running", func(t *testing.T) { +func TestPHP_DevServer_IsRunning_Good(t *T) { + t.Run("returns false when not running", func(t *T) { server := NewDevServer(Options{}) - assert.False(t, server.IsRunning()) + AssertFalse(t, server.IsRunning()) }) } -func TestDevServer_Status_Good(t *testing.T) { - t.Run("returns empty status when no services", func(t *testing.T) { +func TestPHP_DevServer_Status_Good(t *T) { + t.Run("returns empty status when no services", func(t *T) { server := NewDevServer(Options{}) statuses := server.Status() - assert.Empty(t, statuses) + AssertEmpty(t, statuses) }) } -func TestDevServer_Services_Good(t *testing.T) { - t.Run("returns empty services list initially", func(t *testing.T) { +func TestPHP_DevServer_Services_Good(t *T) { + t.Run("returns empty services list initially", func(t *T) { server := NewDevServer(Options{}) services := server.Services() - assert.Empty(t, services) + AssertEmpty(t, services) }) } -func TestDevServer_Stop_Good(t *testing.T) { - t.Run("returns nil when not running", func(t *testing.T) { +func TestPHP_DevServer_Stop_Good(t *T) { + t.Run("returns nil when not running", func(t *T) { server := NewDevServer(Options{}) err := server.Stop() - assert.NoError(t, err) + AssertNoError(t, err) }) } -func TestDevServer_Start_Bad(t *testing.T) { - t.Run("fails when already running", func(t *testing.T) { +func TestPHP_DevServer_Start_Bad(t *T) { + t.Run("fails when already running", func(t *T) { server := NewDevServer(Options{}) server.running = true err := server.Start(context.Background(), Options{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "already running") + AssertError(t, err) + AssertContains(t, err.Error(), "already running") }) - t.Run("fails for non-Laravel project", func(t *testing.T) { + t.Run("fails for non-Laravel project", func(t *T) { dir := t.TempDir() server := NewDevServer(Options{Dir: dir}) err := server.Start(context.Background(), Options{Dir: dir}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a Laravel project") + AssertError(t, err) + AssertContains(t, err.Error(), "not a Laravel project") }) } -func TestDevServer_Logs_Bad(t *testing.T) { - t.Run("fails for non-existent service", func(t *testing.T) { +func TestPHP_DevServer_Logs_Bad(t *T) { + t.Run("fails for non-existent service", func(t *T) { server := NewDevServer(Options{}) _, err := server.Logs("nonexistent", false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "service not found") + AssertError(t, err) + AssertContains(t, err.Error(), "service not found") }) } -func TestDevServer_filterServices_Good(t *testing.T) { +func TestPHP_DevServer_filterServices_Good(t *T) { tests := []struct { name string services []DetectedService @@ -151,25 +147,25 @@ func TestDevServer_filterServices_Good(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + t.Run(tt.name, func(t *T) { server := NewDevServer(Options{}) result := server.filterServices(tt.services, tt.opts) - assert.Equal(t, tt.expected, result) + AssertEqual(t, tt.expected, result) }) } } -func TestMultiServiceReader_Good(t *testing.T) { - t.Run("closes all readers on Close", func(t *testing.T) { +func TestPHP_MultiServiceReader_Good(t *T) { + t.Run("closes all readers on Close", func(t *T) { // Create mock readers using files dir := t.TempDir() file1, err := os.CreateTemp(dir, "log1-*.log") - require.NoError(t, err) + RequireNoError(t, err) _, _ = file1.WriteString("test1") _, _ = file1.Seek(0, 0) file2, err := os.CreateTemp(dir, "log2-*.log") - require.NoError(t, err) + RequireNoError(t, err) _, _ = file2.WriteString("test2") _, _ = file2.Seek(0, 0) @@ -181,27 +177,27 @@ func TestMultiServiceReader_Good(t *testing.T) { readers := []io.ReadCloser{file1, file2} reader := newMultiServiceReader(services, readers, false) - assert.NotNil(t, reader) + AssertNotNil(t, reader) err = reader.Close() - assert.NoError(t, err) - assert.True(t, reader.closed) + AssertNoError(t, err) + AssertTrue(t, reader.closed) }) - t.Run("returns EOF when closed", func(t *testing.T) { + t.Run("returns EOF when closed", func(t *T) { reader := &multiServiceReader{closed: true} buf := make([]byte, 10) n, err := reader.Read(buf) - assert.Equal(t, 0, n) - assert.Equal(t, io.EOF, err) + AssertEqual(t, 0, n) + AssertEqual(t, io.EOF, err) }) } -func TestMultiServiceReader_Read_Good(t *testing.T) { - t.Run("reads from readers with service prefix", func(t *testing.T) { +func TestPHP_MultiServiceReader_Read_Good(t *T) { + t.Run("reads from readers with service prefix", func(t *T) { dir := t.TempDir() file1, err := os.CreateTemp(dir, "log-*.log") - require.NoError(t, err) + RequireNoError(t, err) _, _ = file1.WriteString("log content") _, _ = file1.Seek(0, 0) @@ -214,20 +210,20 @@ func TestMultiServiceReader_Read_Good(t *testing.T) { buf := make([]byte, 100) n, err := reader.Read(buf) - assert.NoError(t, err) - assert.Greater(t, n, 0) + AssertNoError(t, err) + AssertGreater(t, n, 0) result := string(buf[:n]) - assert.Contains(t, result, "[TestService]") + AssertContains(t, result, "[TestService]") }) - t.Run("returns EOF when all readers are exhausted in non-follow mode", func(t *testing.T) { + t.Run("returns EOF when all readers are exhausted in non-follow mode", func(t *T) { dir := t.TempDir() file1, err := os.CreateTemp(dir, "log-*.log") - require.NoError(t, err) + RequireNoError(t, err) _ = file1.Close() // Empty file file1, err = os.Open(file1.Name()) - require.NoError(t, err) + RequireNoError(t, err) services := []Service{ &FrankenPHPService{baseService: baseService{name: "TestService"}}, @@ -238,13 +234,13 @@ func TestMultiServiceReader_Read_Good(t *testing.T) { buf := make([]byte, 100) n, err := reader.Read(buf) - assert.Equal(t, 0, n) - assert.Equal(t, io.EOF, err) + AssertEqual(t, 0, n) + AssertEqual(t, io.EOF, err) }) } -func TestOptions_Good(t *testing.T) { - t.Run("all fields are accessible", func(t *testing.T) { +func TestPHP_Options_Good(t *T) { + t.Run("all fields are accessible", func(t *T) { opts := Options{ Dir: "/test", Services: []DetectedService{ServiceFrankenPHP}, @@ -261,23 +257,23 @@ func TestOptions_Good(t *testing.T) { RedisPort: 6379, } - assert.Equal(t, "/test", opts.Dir) - assert.Equal(t, []DetectedService{ServiceFrankenPHP}, opts.Services) - assert.True(t, opts.NoVite) - assert.True(t, opts.NoHorizon) - assert.True(t, opts.NoReverb) - assert.True(t, opts.NoRedis) - assert.True(t, opts.HTTPS) - assert.Equal(t, "test.local", opts.Domain) - assert.Equal(t, 8000, opts.FrankenPHPPort) - assert.Equal(t, 443, opts.HTTPSPort) - assert.Equal(t, 5173, opts.VitePort) - assert.Equal(t, 8080, opts.ReverbPort) - assert.Equal(t, 6379, opts.RedisPort) + AssertEqual(t, "/test", opts.Dir) + AssertEqual(t, []DetectedService{ServiceFrankenPHP}, opts.Services) + AssertTrue(t, opts.NoVite) + AssertTrue(t, opts.NoHorizon) + AssertTrue(t, opts.NoReverb) + AssertTrue(t, opts.NoRedis) + AssertTrue(t, opts.HTTPS) + AssertEqual(t, "test.local", opts.Domain) + AssertEqual(t, 8000, opts.FrankenPHPPort) + AssertEqual(t, 443, opts.HTTPSPort) + AssertEqual(t, 5173, opts.VitePort) + AssertEqual(t, 8080, opts.ReverbPort) + AssertEqual(t, 6379, opts.RedisPort) }) } -func TestDevServer_StartStop_Integration(t *testing.T) { +func TestDevServer_StartStop_Integration(t *T) { t.Skip("requires PHP/FrankenPHP installed") dir := t.TempDir() @@ -288,21 +284,21 @@ func TestDevServer_StartStop_Integration(t *testing.T) { defer cancel() err := server.Start(ctx, Options{Dir: dir}) - require.NoError(t, err) - assert.True(t, server.IsRunning()) + RequireNoError(t, err) + AssertTrue(t, server.IsRunning()) err = server.Stop() - require.NoError(t, err) - assert.False(t, server.IsRunning()) + RequireNoError(t, err) + AssertFalse(t, server.IsRunning()) } // setupLaravelProject creates a minimal Laravel project structure for testing. -func setupLaravelProject(t *testing.T, dir string) { +func setupLaravelProject(t *T, dir string) { t.Helper() // Create artisan file err := os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) + RequireNoError(t, err) // Create composer.json with Laravel composerJSON := `{ @@ -314,11 +310,11 @@ func setupLaravelProject(t *testing.T, dir string) { } }` err = os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) } -func TestDevServer_UnifiedLogs_Bad(t *testing.T) { - t.Run("returns error when service logs fail", func(t *testing.T) { +func TestPHP_DevServer_UnifiedLogs_Bad(t *T) { + t.Run("returns error when service logs fail", func(t *T) { server := NewDevServer(Options{}) // Create a mock service that will fail to provide logs @@ -331,17 +327,17 @@ func TestDevServer_UnifiedLogs_Bad(t *testing.T) { server.services = []Service{mockService} _, err := server.Logs("", false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to get logs") + AssertError(t, err) + AssertContains(t, err.Error(), "failed to get logs") }) } -func TestDevServer_Logs_Good(t *testing.T) { - t.Run("finds specific service logs", func(t *testing.T) { +func TestPHP_DevServer_Logs_Good(t *T) { + t.Run("finds specific service logs", func(t *T) { dir := t.TempDir() logFile := filepath.Join(dir, "test.log") err := os.WriteFile(logFile, []byte("test log content"), 0644) - require.NoError(t, err) + RequireNoError(t, err) server := NewDevServer(Options{}) mockService := &FrankenPHPService{ @@ -353,43 +349,43 @@ func TestDevServer_Logs_Good(t *testing.T) { server.services = []Service{mockService} reader, err := server.Logs("TestService", false) - assert.NoError(t, err) - assert.NotNil(t, reader) + AssertNoError(t, err) + AssertNotNil(t, reader) _ = reader.Close() }) } -func TestDevServer_MergeOptions_Good(t *testing.T) { - t.Run("start merges options correctly", func(t *testing.T) { +func TestPHP_DevServer_MergeOptions_Good(t *T) { + t.Run("start merges options correctly", func(t *T) { dir := t.TempDir() server := NewDevServer(Options{Dir: "/original"}) // Setup a minimal non-Laravel project to trigger an error // but still test the options merge happens first err := server.Start(context.Background(), Options{Dir: dir}) - assert.Error(t, err) // Will fail because not Laravel project + AssertError(t, err) // Will fail because not Laravel project // But the directory should have been merged - assert.Equal(t, dir, server.opts.Dir) + AssertEqual(t, dir, server.opts.Dir) }) } -func TestDetectedService_Constants(t *testing.T) { - t.Run("all service constants are defined", func(t *testing.T) { - assert.Equal(t, DetectedService("frankenphp"), ServiceFrankenPHP) - assert.Equal(t, DetectedService("vite"), ServiceVite) - assert.Equal(t, DetectedService("horizon"), ServiceHorizon) - assert.Equal(t, DetectedService("reverb"), ServiceReverb) - assert.Equal(t, DetectedService("redis"), ServiceRedis) +func TestDetectedService_Constants(t *T) { + t.Run("all service constants are defined", func(t *T) { + AssertEqual(t, DetectedService("frankenphp"), ServiceFrankenPHP) + AssertEqual(t, DetectedService("vite"), ServiceVite) + AssertEqual(t, DetectedService("horizon"), ServiceHorizon) + AssertEqual(t, DetectedService("reverb"), ServiceReverb) + AssertEqual(t, DetectedService("redis"), ServiceRedis) }) } -func TestDevServer_HTTPSSetup(t *testing.T) { - t.Run("extracts domain from APP_URL when HTTPS enabled", func(t *testing.T) { +func TestDevServer_HTTPSSetup(t *T) { + t.Run("extracts domain from APP_URL when HTTPS enabled", func(t *T) { dir := t.TempDir() // Create Laravel project err := os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) + RequireNoError(t, err) composerJSON := `{ "require": { @@ -398,45 +394,45 @@ func TestDevServer_HTTPSSetup(t *testing.T) { } }` err = os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) + RequireNoError(t, err) // Create .env with APP_URL envContent := "APP_URL=https://myapp.test" err = os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) + RequireNoError(t, err) // Verify we can extract the domain url := GetLaravelAppURL(dir) domain := ExtractDomainFromURL(url) - assert.Equal(t, "myapp.test", domain) + AssertEqual(t, "myapp.test", domain) }) } -func TestDevServer_PortDefaults(t *testing.T) { - t.Run("uses default ports when not specified", func(t *testing.T) { +func TestDevServer_PortDefaults(t *T) { + t.Run("uses default ports when not specified", func(t *T) { // This tests the logic in Start() for default port assignment // We verify the constants/defaults by checking what would be created // FrankenPHP default port is 8000 svc := NewFrankenPHPService("/tmp", FrankenPHPOptions{}) - assert.Equal(t, 8000, svc.port) + AssertEqual(t, 8000, svc.port) // Vite default port is 5173 vite := NewViteService("/tmp", ViteOptions{}) - assert.Equal(t, 5173, vite.port) + AssertEqual(t, 5173, vite.port) // Reverb default port is 8080 reverb := NewReverbService("/tmp", ReverbOptions{}) - assert.Equal(t, 8080, reverb.port) + AssertEqual(t, 8080, reverb.port) // Redis default port is 6379 redis := NewRedisService("/tmp", RedisOptions{}) - assert.Equal(t, 6379, redis.port) + AssertEqual(t, 6379, redis.port) }) } -func TestDevServer_ServiceCreation(t *testing.T) { - t.Run("creates correct services based on detected services", func(t *testing.T) { +func TestDevServer_ServiceCreation(t *T) { + t.Run("creates correct services based on detected services", func(t *T) { // Test that the switch statement in Start() creates the right service types services := []DetectedService{ ServiceFrankenPHP, @@ -449,24 +445,24 @@ func TestDevServer_ServiceCreation(t *testing.T) { // Verify each service type string expected := []string{"frankenphp", "vite", "horizon", "reverb", "redis"} for i, svc := range services { - assert.Equal(t, expected[i], string(svc)) + AssertEqual(t, expected[i], string(svc)) } }) } -func TestMultiServiceReader_CloseError(t *testing.T) { - t.Run("returns first close error", func(t *testing.T) { +func TestMultiServiceReader_CloseError(t *T) { + t.Run("returns first close error", func(t *T) { dir := t.TempDir() // Create a real file that we can close file1, err := os.CreateTemp(dir, "log-*.log") - require.NoError(t, err) + RequireNoError(t, err) file1Name := file1.Name() _ = file1.Close() // Reopen for reading file1, err = os.Open(file1Name) - require.NoError(t, err) + RequireNoError(t, err) services := []Service{ &FrankenPHPService{baseService: baseService{name: "svc1"}}, @@ -475,25 +471,25 @@ func TestMultiServiceReader_CloseError(t *testing.T) { reader := newMultiServiceReader(services, readers, false) err = reader.Close() - assert.NoError(t, err) + AssertNoError(t, err) // Second close should still work (files already closed) // The closed flag prevents double-processing - assert.True(t, reader.closed) + AssertTrue(t, reader.closed) }) } -func TestMultiServiceReader_FollowMode(t *testing.T) { - t.Run("returns 0 bytes without error in follow mode when no data", func(t *testing.T) { +func TestMultiServiceReader_FollowMode(t *T) { + t.Run("returns 0 bytes without error in follow mode when no data", func(t *T) { dir := t.TempDir() file1, err := os.CreateTemp(dir, "log-*.log") - require.NoError(t, err) + RequireNoError(t, err) file1Name := file1.Name() _ = file1.Close() // Reopen for reading (empty file) file1, err = os.Open(file1Name) - require.NoError(t, err) + RequireNoError(t, err) services := []Service{ &FrankenPHPService{baseService: baseService{name: "svc1"}}, @@ -508,8 +504,8 @@ func TestMultiServiceReader_FollowMode(t *testing.T) { buf := make([]byte, 100) n, err := reader.Read(buf) // In follow mode, should return 0 bytes and nil error (waiting for more data) - assert.Equal(t, 0, n) - assert.NoError(t, err) + AssertEqual(t, 0, n) + AssertNoError(t, err) done <- true }() @@ -524,23 +520,23 @@ func TestMultiServiceReader_FollowMode(t *testing.T) { }) } -func TestGetLaravelAppURL_Bad(t *testing.T) { - t.Run("no .env file", func(t *testing.T) { +func TestPHP_GetLaravelAppURL_Bad(t *T) { + t.Run("no .env file", func(t *T) { dir := t.TempDir() - assert.Equal(t, "", GetLaravelAppURL(dir)) + AssertEqual(t, "", GetLaravelAppURL(dir)) }) - t.Run("no APP_URL in .env", func(t *testing.T) { + t.Run("no APP_URL in .env", func(t *T) { dir := t.TempDir() envContent := "APP_NAME=Test\nAPP_ENV=local" err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.Equal(t, "", GetLaravelAppURL(dir)) + AssertEqual(t, "", GetLaravelAppURL(dir)) }) } -func TestExtractDomainFromURL_Edge(t *testing.T) { +func TestPHP_ExtractDomainFromURL_Ugly(t *T) { tests := []struct { name string url string @@ -555,18 +551,18 @@ func TestExtractDomainFromURL_Edge(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + t.Run(tt.name, func(t *T) { // Strip protocol result := ExtractDomainFromURL(tt.url) if tt.url != "" && !strings.HasPrefix(tt.url, "http://") && !strings.HasPrefix(tt.url, "https://") && !strings.Contains(tt.url, ":") && !strings.Contains(tt.url, "/") { - assert.Equal(t, tt.expected, result) + AssertEqual(t, tt.expected, result) } }) } } -func TestDevServer_StatusWithServices(t *testing.T) { - t.Run("returns statuses for all services", func(t *testing.T) { +func TestDevServer_StatusWithServices(t *T) { + t.Run("returns statuses for all services", func(t *T) { server := NewDevServer(Options{}) // Add mock services @@ -576,16 +572,16 @@ func TestDevServer_StatusWithServices(t *testing.T) { } statuses := server.Status() - assert.Len(t, statuses, 2) - assert.Equal(t, "svc1", statuses[0].Name) - assert.True(t, statuses[0].Running) - assert.Equal(t, "svc2", statuses[1].Name) - assert.False(t, statuses[1].Running) + AssertLen(t, statuses, 2) + AssertEqual(t, "svc1", statuses[0].Name) + AssertTrue(t, statuses[0].Running) + AssertEqual(t, "svc2", statuses[1].Name) + AssertFalse(t, statuses[1].Running) }) } -func TestDevServer_ServicesReturnsAll(t *testing.T) { - t.Run("returns all services", func(t *testing.T) { +func TestDevServer_ServicesReturnsAll(t *T) { + t.Run("returns all services", func(t *T) { server := NewDevServer(Options{}) // Add mock services @@ -596,12 +592,12 @@ func TestDevServer_ServicesReturnsAll(t *testing.T) { } services := server.Services() - assert.Len(t, services, 3) + AssertLen(t, services, 3) }) } -func TestDevServer_StopWithCancel(t *testing.T) { - t.Run("calls cancel when running", func(t *testing.T) { +func TestDevServer_StopWithCancel(t *T) { + t.Run("calls cancel when running", func(t *T) { ctx, cancel := context.WithCancel(context.Background()) server := NewDevServer(Options{}) server.running = true @@ -614,20 +610,20 @@ func TestDevServer_StopWithCancel(t *testing.T) { } err := server.Stop() - assert.NoError(t, err) - assert.False(t, server.running) + AssertNoError(t, err) + AssertFalse(t, server.running) }) } -func TestMultiServiceReader_CloseWithErrors(t *testing.T) { - t.Run("handles multiple close errors", func(t *testing.T) { +func TestMultiServiceReader_CloseWithErrors(t *T) { + t.Run("handles multiple close errors", func(t *T) { dir := t.TempDir() // Create files file1, err := os.CreateTemp(dir, "log1-*.log") - require.NoError(t, err) + RequireNoError(t, err) file2, err := os.CreateTemp(dir, "log2-*.log") - require.NoError(t, err) + RequireNoError(t, err) services := []Service{ &FrankenPHPService{baseService: baseService{name: "svc1"}}, @@ -639,6 +635,6 @@ func TestMultiServiceReader_CloseWithErrors(t *testing.T) { // Close successfully err = reader.Close() - assert.NoError(t, err) + AssertNoError(t, err) }) } diff --git a/pkg/php/services.go b/pkg/php/services.go index ad57d25..36460cf 100644 --- a/pkg/php/services.go +++ b/pkg/php/services.go @@ -137,7 +137,9 @@ func (s *baseService) startProcess(ctx context.Context, cmdName string, args []s setSysProcAttr(s.cmd) if err := s.cmd.Start(); err != nil { - _ = logFile.Close() + if closeErr := logFile.Close(); closeErr != nil { + err = cli.Err("%v; close log file: %v", err, closeErr) + } s.lastError = err return cli.WrapVerb(err, "start", s.name) } @@ -154,7 +156,9 @@ func (s *baseService) startProcess(ctx context.Context, cmdName string, args []s s.lastError = err } if s.logFile != nil { - _ = s.logFile.Close() + if closeErr := s.logFile.Close(); closeErr != nil && s.lastError == nil { + s.lastError = closeErr + } } s.mu.Unlock() }() @@ -171,21 +175,26 @@ func (s *baseService) stopProcess() error { } // Send termination signal to process (group on Unix) - _ = signalProcessGroup(s.cmd, termSignal()) + if err := signalProcessGroup(s.cmd, termSignal()); err != nil { + s.lastError = err + } // Wait for graceful shutdown with timeout - done := make(chan struct{}) + done := make(chan error, 1) go func() { - _ = s.cmd.Wait() - close(done) + done <- s.cmd.Wait() }() select { - case <-done: - // Process exited gracefully + case err := <-done: + if err != nil && s.lastError == nil { + s.lastError = err + } case <-time.After(5 * time.Second): // Force kill - _ = signalProcessGroup(s.cmd, killSignal()) + if err := signalProcessGroup(s.cmd, killSignal()); err != nil { + s.lastError = err + } } s.running = false @@ -347,7 +356,9 @@ func (s *HorizonService) Stop() error { // Horizon has its own terminate command cmd := exec.Command("php", "artisan", "horizon:terminate") cmd.Dir = s.dir - _ = cmd.Run() // Ignore errors, will also kill via signal + if err := cmd.Run(); err != nil { + s.lastError = err + } return s.stopProcess() } @@ -441,7 +452,9 @@ func (s *RedisService) Start(ctx context.Context) error { func (s *RedisService) Stop() error { // Try graceful shutdown via redis-cli cmd := exec.Command("redis-cli", "-p", cli.Sprintf("%d", s.port), "shutdown", "nosave") - _ = cmd.Run() // Ignore errors + if err := cmd.Run(); err != nil { + s.lastError = err + } return s.stopProcess() } diff --git a/pkg/php/services_extended_test.go b/pkg/php/services_extended_test.go index ce3b72e..d01ee26 100644 --- a/pkg/php/services_extended_test.go +++ b/pkg/php/services_extended_test.go @@ -3,21 +3,17 @@ package php import ( "os" "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestBaseService_Name_Good(t *testing.T) { - t.Run("returns service name", func(t *testing.T) { +func TestPHP_BaseService_Name_Good(t *T) { + t.Run("returns service name", func(t *T) { s := &baseService{name: "TestService"} - assert.Equal(t, "TestService", s.Name()) + AssertEqual(t, "TestService", s.Name()) }) } -func TestBaseService_Status_Good(t *testing.T) { - t.Run("returns status when not running", func(t *testing.T) { +func TestPHP_BaseService_Status_Good(t *T) { + t.Run("returns status when not running", func(t *T) { s := &baseService{ name: "TestService", port: 8080, @@ -25,13 +21,13 @@ func TestBaseService_Status_Good(t *testing.T) { } status := s.Status() - assert.Equal(t, "TestService", status.Name) - assert.Equal(t, 8080, status.Port) - assert.False(t, status.Running) - assert.Equal(t, 0, status.PID) + AssertEqual(t, "TestService", status.Name) + AssertEqual(t, 8080, status.Port) + AssertFalse(t, status.Running) + AssertEqual(t, 0, status.PID) }) - t.Run("returns status when running", func(t *testing.T) { + t.Run("returns status when running", func(t *T) { s := &baseService{ name: "TestService", port: 8080, @@ -39,112 +35,112 @@ func TestBaseService_Status_Good(t *testing.T) { } status := s.Status() - assert.True(t, status.Running) + AssertTrue(t, status.Running) }) - t.Run("returns error in status", func(t *testing.T) { - testErr := assert.AnError + t.Run("returns error in status", func(t *T) { + testErr := AnError s := &baseService{ name: "TestService", lastError: testErr, } status := s.Status() - assert.Equal(t, testErr, status.Error) + AssertEqual(t, testErr, status.Error) }) } -func TestBaseService_Logs_Good(t *testing.T) { - t.Run("returns log file content", func(t *testing.T) { +func TestPHP_BaseService_Logs_Good(t *T) { + t.Run("returns log file content", func(t *T) { dir := t.TempDir() logPath := filepath.Join(dir, "test.log") err := os.WriteFile(logPath, []byte("test log content"), 0644) - require.NoError(t, err) + RequireNoError(t, err) s := &baseService{logPath: logPath} reader, err := s.Logs(false) - assert.NoError(t, err) - assert.NotNil(t, reader) + AssertNoError(t, err) + AssertNotNil(t, reader) _ = reader.Close() }) - t.Run("returns tail reader in follow mode", func(t *testing.T) { + t.Run("returns tail reader in follow mode", func(t *T) { dir := t.TempDir() logPath := filepath.Join(dir, "test.log") err := os.WriteFile(logPath, []byte("test log content"), 0644) - require.NoError(t, err) + RequireNoError(t, err) s := &baseService{logPath: logPath} reader, err := s.Logs(true) - assert.NoError(t, err) - assert.NotNil(t, reader) + AssertNoError(t, err) + AssertNotNil(t, reader) // Verify it's a tailReader by checking it implements ReadCloser _, ok := reader.(*tailReader) - assert.True(t, ok) + AssertTrue(t, ok) _ = reader.Close() }) } -func TestBaseService_Logs_Bad(t *testing.T) { - t.Run("returns error when no log path", func(t *testing.T) { +func TestPHP_BaseService_Logs_Bad(t *T) { + t.Run("returns error when no log path", func(t *T) { s := &baseService{name: "TestService"} _, err := s.Logs(false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "no log file available") + AssertError(t, err) + AssertContains(t, err.Error(), "no log file available") }) - t.Run("returns error when log file doesn't exist", func(t *testing.T) { + t.Run("returns error when log file doesn't exist", func(t *T) { s := &baseService{logPath: "/nonexistent/path/log.log"} _, err := s.Logs(false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Failed to open log file") + AssertError(t, err) + AssertContains(t, err.Error(), "failed to open log file") }) } -func TestTailReader_Good(t *testing.T) { - t.Run("creates new tail reader", func(t *testing.T) { +func TestPHP_TailReader_Good(t *T) { + t.Run("creates new tail reader", func(t *T) { dir := t.TempDir() logPath := filepath.Join(dir, "test.log") err := os.WriteFile(logPath, []byte("content"), 0644) - require.NoError(t, err) + RequireNoError(t, err) file, err := os.Open(logPath) - require.NoError(t, err) + RequireNoError(t, err) defer func() { _ = file.Close() }() reader := newTailReader(file) - assert.NotNil(t, reader) - assert.NotNil(t, reader.file) - assert.NotNil(t, reader.reader) - assert.False(t, reader.closed) + AssertNotNil(t, reader) + AssertNotNil(t, reader.file) + AssertNotNil(t, reader.reader) + AssertFalse(t, reader.closed) }) - t.Run("closes file on Close", func(t *testing.T) { + t.Run("closes file on Close", func(t *T) { dir := t.TempDir() logPath := filepath.Join(dir, "test.log") err := os.WriteFile(logPath, []byte("content"), 0644) - require.NoError(t, err) + RequireNoError(t, err) file, err := os.Open(logPath) - require.NoError(t, err) + RequireNoError(t, err) reader := newTailReader(file) err = reader.Close() - assert.NoError(t, err) - assert.True(t, reader.closed) + AssertNoError(t, err) + AssertTrue(t, reader.closed) }) - t.Run("returns EOF when closed", func(t *testing.T) { + t.Run("returns EOF when closed", func(t *T) { dir := t.TempDir() logPath := filepath.Join(dir, "test.log") err := os.WriteFile(logPath, []byte("content"), 0644) - require.NoError(t, err) + RequireNoError(t, err) file, err := os.Open(logPath) - require.NoError(t, err) + RequireNoError(t, err) reader := newTailReader(file) _ = reader.Close() @@ -152,12 +148,12 @@ func TestTailReader_Good(t *testing.T) { buf := make([]byte, 100) n, _ := reader.Read(buf) // When closed, should return 0 bytes (the closed flag causes early return) - assert.Equal(t, 0, n) + AssertEqual(t, 0, n) }) } -func TestFrankenPHPService_Extended(t *testing.T) { - t.Run("all options set correctly", func(t *testing.T) { +func TestFrankenPHPService_Extended(t *T) { + t.Run("all options set correctly", func(t *T) { opts := FrankenPHPOptions{ Port: 9000, HTTPSPort: 9443, @@ -168,71 +164,71 @@ func TestFrankenPHPService_Extended(t *testing.T) { service := NewFrankenPHPService("/project", opts) - assert.Equal(t, "FrankenPHP", service.Name()) - assert.Equal(t, 9000, service.port) - assert.Equal(t, 9443, service.httpsPort) - assert.True(t, service.https) - assert.Equal(t, "/path/to/cert.pem", service.certFile) - assert.Equal(t, "/path/to/key.pem", service.keyFile) - assert.Equal(t, "/project", service.dir) + AssertEqual(t, "FrankenPHP", service.Name()) + AssertEqual(t, 9000, service.port) + AssertEqual(t, 9443, service.httpsPort) + AssertTrue(t, service.https) + AssertEqual(t, "/path/to/cert.pem", service.certFile) + AssertEqual(t, "/path/to/key.pem", service.keyFile) + AssertEqual(t, "/project", service.dir) }) } -func TestViteService_Extended(t *testing.T) { - t.Run("auto-detects package manager", func(t *testing.T) { +func TestViteService_Extended(t *T) { + t.Run("auto-detects package manager", func(t *T) { dir := t.TempDir() // Create bun.lockb to trigger bun detection err := os.WriteFile(filepath.Join(dir, "bun.lockb"), []byte(""), 0644) - require.NoError(t, err) + RequireNoError(t, err) service := NewViteService(dir, ViteOptions{}) - assert.Equal(t, "bun", service.packageManager) + AssertEqual(t, "bun", service.packageManager) }) - t.Run("uses provided package manager", func(t *testing.T) { + t.Run("uses provided package manager", func(t *T) { dir := t.TempDir() service := NewViteService(dir, ViteOptions{PackageManager: "pnpm"}) - assert.Equal(t, "pnpm", service.packageManager) + AssertEqual(t, "pnpm", service.packageManager) }) } -func TestHorizonService_Extended(t *testing.T) { - t.Run("has zero port", func(t *testing.T) { +func TestHorizonService_Extended(t *T) { + t.Run("has zero port", func(t *T) { service := NewHorizonService("/project") - assert.Equal(t, 0, service.port) + AssertEqual(t, 0, service.port) }) } -func TestReverbService_Extended(t *testing.T) { - t.Run("uses default port 8080", func(t *testing.T) { +func TestReverbService_Extended(t *T) { + t.Run("uses default port 8080", func(t *T) { service := NewReverbService("/project", ReverbOptions{}) - assert.Equal(t, 8080, service.port) + AssertEqual(t, 8080, service.port) }) - t.Run("uses custom port", func(t *testing.T) { + t.Run("uses custom port", func(t *T) { service := NewReverbService("/project", ReverbOptions{Port: 9090}) - assert.Equal(t, 9090, service.port) + AssertEqual(t, 9090, service.port) }) } -func TestRedisService_Extended(t *testing.T) { - t.Run("uses default port 6379", func(t *testing.T) { +func TestRedisService_Extended(t *T) { + t.Run("uses default port 6379", func(t *T) { service := NewRedisService("/project", RedisOptions{}) - assert.Equal(t, 6379, service.port) + AssertEqual(t, 6379, service.port) }) - t.Run("accepts config file", func(t *testing.T) { + t.Run("accepts config file", func(t *T) { service := NewRedisService("/project", RedisOptions{ConfigFile: "/path/to/redis.conf"}) - assert.Equal(t, "/path/to/redis.conf", service.configFile) + AssertEqual(t, "/path/to/redis.conf", service.configFile) }) } -func TestServiceStatus_Struct(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - testErr := assert.AnError +func TestServiceStatus_Struct(t *T) { + t.Run("all fields accessible", func(t *T) { + testErr := AnError status := ServiceStatus{ Name: "TestService", Running: true, @@ -241,16 +237,16 @@ func TestServiceStatus_Struct(t *testing.T) { Error: testErr, } - assert.Equal(t, "TestService", status.Name) - assert.True(t, status.Running) - assert.Equal(t, 12345, status.PID) - assert.Equal(t, 8080, status.Port) - assert.Equal(t, testErr, status.Error) + AssertEqual(t, "TestService", status.Name) + AssertTrue(t, status.Running) + AssertEqual(t, 12345, status.PID) + AssertEqual(t, 8080, status.Port) + AssertEqual(t, testErr, status.Error) }) } -func TestFrankenPHPOptions_Struct(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestFrankenPHPOptions_Struct(t *T) { + t.Run("all fields accessible", func(t *T) { opts := FrankenPHPOptions{ Port: 8000, HTTPSPort: 443, @@ -259,55 +255,55 @@ func TestFrankenPHPOptions_Struct(t *testing.T) { KeyFile: "key.pem", } - assert.Equal(t, 8000, opts.Port) - assert.Equal(t, 443, opts.HTTPSPort) - assert.True(t, opts.HTTPS) - assert.Equal(t, "cert.pem", opts.CertFile) - assert.Equal(t, "key.pem", opts.KeyFile) + AssertEqual(t, 8000, opts.Port) + AssertEqual(t, 443, opts.HTTPSPort) + AssertTrue(t, opts.HTTPS) + AssertEqual(t, "cert.pem", opts.CertFile) + AssertEqual(t, "key.pem", opts.KeyFile) }) } -func TestViteOptions_Struct(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestViteOptions_Struct(t *T) { + t.Run("all fields accessible", func(t *T) { opts := ViteOptions{ Port: 5173, PackageManager: "bun", } - assert.Equal(t, 5173, opts.Port) - assert.Equal(t, "bun", opts.PackageManager) + AssertEqual(t, 5173, opts.Port) + AssertEqual(t, "bun", opts.PackageManager) }) } -func TestReverbOptions_Struct(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestReverbOptions_Struct(t *T) { + t.Run("all fields accessible", func(t *T) { opts := ReverbOptions{Port: 8080} - assert.Equal(t, 8080, opts.Port) + AssertEqual(t, 8080, opts.Port) }) } -func TestRedisOptions_Struct(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestRedisOptions_Struct(t *T) { + t.Run("all fields accessible", func(t *T) { opts := RedisOptions{ Port: 6379, ConfigFile: "redis.conf", } - assert.Equal(t, 6379, opts.Port) - assert.Equal(t, "redis.conf", opts.ConfigFile) + AssertEqual(t, 6379, opts.Port) + AssertEqual(t, "redis.conf", opts.ConfigFile) }) } -func TestBaseService_StopProcess_Good(t *testing.T) { - t.Run("returns nil when not running", func(t *testing.T) { +func TestPHP_BaseService_StopProcess_Good(t *T) { + t.Run("returns nil when not running", func(t *T) { s := &baseService{running: false} err := s.stopProcess() - assert.NoError(t, err) + AssertNoError(t, err) }) - t.Run("returns nil when cmd is nil", func(t *testing.T) { + t.Run("returns nil when cmd is nil", func(t *T) { s := &baseService{running: true, cmd: nil} err := s.stopProcess() - assert.NoError(t, err) + AssertNoError(t, err) }) } diff --git a/pkg/php/services_test.go b/pkg/php/services_test.go index 5a0e66c..4d142d4 100644 --- a/pkg/php/services_test.go +++ b/pkg/php/services_test.go @@ -1,23 +1,19 @@ package php -import ( - "testing" +import () - "github.com/stretchr/testify/assert" -) - -func TestNewFrankenPHPService_Good(t *testing.T) { - t.Run("default options", func(t *testing.T) { +func TestPHP_NewFrankenPHPService_Good(t *T) { + t.Run("default options", func(t *T) { dir := "/tmp/test" service := NewFrankenPHPService(dir, FrankenPHPOptions{}) - assert.Equal(t, "FrankenPHP", service.Name()) - assert.Equal(t, 8000, service.port) - assert.Equal(t, 443, service.httpsPort) - assert.False(t, service.https) + AssertEqual(t, "FrankenPHP", service.Name()) + AssertEqual(t, 8000, service.port) + AssertEqual(t, 443, service.httpsPort) + AssertFalse(t, service.https) }) - t.Run("custom options", func(t *testing.T) { + t.Run("custom options", func(t *T) { dir := "/tmp/test" opts := FrankenPHPOptions{ Port: 9000, @@ -28,65 +24,65 @@ func TestNewFrankenPHPService_Good(t *testing.T) { } service := NewFrankenPHPService(dir, opts) - assert.Equal(t, 9000, service.port) - assert.Equal(t, 8443, service.httpsPort) - assert.True(t, service.https) - assert.Equal(t, "cert.pem", service.certFile) - assert.Equal(t, "key.pem", service.keyFile) + AssertEqual(t, 9000, service.port) + AssertEqual(t, 8443, service.httpsPort) + AssertTrue(t, service.https) + AssertEqual(t, "cert.pem", service.certFile) + AssertEqual(t, "key.pem", service.keyFile) }) } -func TestNewViteService_Good(t *testing.T) { - t.Run("default options", func(t *testing.T) { +func TestPHP_NewViteService_Good(t *T) { + t.Run("default options", func(t *T) { dir := t.TempDir() service := NewViteService(dir, ViteOptions{}) - assert.Equal(t, "Vite", service.Name()) - assert.Equal(t, 5173, service.port) - assert.Equal(t, "npm", service.packageManager) // default when no lock file + AssertEqual(t, "Vite", service.Name()) + AssertEqual(t, 5173, service.port) + AssertEqual(t, "npm", service.packageManager) // default when no lock file }) - t.Run("custom package manager", func(t *testing.T) { + t.Run("custom package manager", func(t *T) { dir := t.TempDir() service := NewViteService(dir, ViteOptions{PackageManager: "pnpm"}) - assert.Equal(t, "pnpm", service.packageManager) + AssertEqual(t, "pnpm", service.packageManager) }) } -func TestNewHorizonService_Good(t *testing.T) { +func TestPHP_NewHorizonService_Good(t *T) { service := NewHorizonService("/tmp/test") - assert.Equal(t, "Horizon", service.Name()) - assert.Equal(t, 0, service.port) + AssertEqual(t, "Horizon", service.Name()) + AssertEqual(t, 0, service.port) } -func TestNewReverbService_Good(t *testing.T) { - t.Run("default options", func(t *testing.T) { +func TestPHP_NewReverbService_Good(t *T) { + t.Run("default options", func(t *T) { service := NewReverbService("/tmp/test", ReverbOptions{}) - assert.Equal(t, "Reverb", service.Name()) - assert.Equal(t, 8080, service.port) + AssertEqual(t, "Reverb", service.Name()) + AssertEqual(t, 8080, service.port) }) - t.Run("custom port", func(t *testing.T) { + t.Run("custom port", func(t *T) { service := NewReverbService("/tmp/test", ReverbOptions{Port: 9090}) - assert.Equal(t, 9090, service.port) + AssertEqual(t, 9090, service.port) }) } -func TestNewRedisService_Good(t *testing.T) { - t.Run("default options", func(t *testing.T) { +func TestPHP_NewRedisService_Good(t *T) { + t.Run("default options", func(t *T) { service := NewRedisService("/tmp/test", RedisOptions{}) - assert.Equal(t, "Redis", service.Name()) - assert.Equal(t, 6379, service.port) + AssertEqual(t, "Redis", service.Name()) + AssertEqual(t, 6379, service.port) }) - t.Run("custom config", func(t *testing.T) { + t.Run("custom config", func(t *T) { service := NewRedisService("/tmp/test", RedisOptions{ConfigFile: "redis.conf"}) - assert.Equal(t, "redis.conf", service.configFile) + AssertEqual(t, "redis.conf", service.configFile) }) } -func TestBaseService_Status(t *testing.T) { +func TestBaseService_Status(t *T) { s := &baseService{ name: "TestService", port: 1234, @@ -94,7 +90,7 @@ func TestBaseService_Status(t *testing.T) { } status := s.Status() - assert.Equal(t, "TestService", status.Name) - assert.Equal(t, 1234, status.Port) - assert.True(t, status.Running) + AssertEqual(t, "TestService", status.Name) + AssertEqual(t, 1234, status.Port) + AssertTrue(t, status.Running) } diff --git a/pkg/php/ssl_extended_test.go b/pkg/php/ssl_extended_test.go index 6f30503..81258bf 100644 --- a/pkg/php/ssl_extended_test.go +++ b/pkg/php/ssl_extended_test.go @@ -3,39 +3,35 @@ package php import ( "os" "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestSSLOptions_Struct(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { +func TestSSLOptions_Struct(t *T) { + t.Run("all fields accessible", func(t *T) { opts := SSLOptions{Dir: "/custom/ssl/dir"} - assert.Equal(t, "/custom/ssl/dir", opts.Dir) + AssertEqual(t, "/custom/ssl/dir", opts.Dir) }) } -func TestGetSSLDir_Bad(t *testing.T) { - t.Run("fails to create directory in invalid path", func(t *testing.T) { +func TestPHP_GetSSLDir_Bad(t *T) { + t.Run("fails to create directory in invalid path", func(t *T) { // Try to create a directory in a path that can't exist opts := SSLOptions{Dir: "/dev/null/cannot/create"} _, err := GetSSLDir(opts) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Failed to create SSL directory") + AssertError(t, err) + AssertContains(t, err.Error(), "failed to create SSL directory") }) } -func TestCertPaths_Bad(t *testing.T) { - t.Run("fails when GetSSLDir fails", func(t *testing.T) { +func TestPHP_CertPaths_Bad(t *T) { + t.Run("fails when GetSSLDir fails", func(t *T) { opts := SSLOptions{Dir: "/dev/null/cannot/create"} _, _, err := CertPaths("domain.test", opts) - assert.Error(t, err) + AssertError(t, err) }) } -func TestCertsExist_Detailed(t *testing.T) { - t.Run("returns true when both cert and key exist", func(t *testing.T) { +func TestCertsExist_Detailed(t *T) { + t.Run("returns true when both cert and key exist", func(t *T) { dir := t.TempDir() domain := "test.local" @@ -44,58 +40,58 @@ func TestCertsExist_Detailed(t *testing.T) { keyPath := filepath.Join(dir, domain+"-key.pem") err := os.WriteFile(certPath, []byte("cert"), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(keyPath, []byte("key"), 0644) - require.NoError(t, err) + RequireNoError(t, err) result := CertsExist(domain, SSLOptions{Dir: dir}) - assert.True(t, result) + AssertTrue(t, result) }) - t.Run("returns false when only cert exists", func(t *testing.T) { + t.Run("returns false when only cert exists", func(t *T) { dir := t.TempDir() domain := "test.local" certPath := filepath.Join(dir, domain+".pem") err := os.WriteFile(certPath, []byte("cert"), 0644) - require.NoError(t, err) + RequireNoError(t, err) result := CertsExist(domain, SSLOptions{Dir: dir}) - assert.False(t, result) + AssertFalse(t, result) }) - t.Run("returns false when only key exists", func(t *testing.T) { + t.Run("returns false when only key exists", func(t *T) { dir := t.TempDir() domain := "test.local" keyPath := filepath.Join(dir, domain+"-key.pem") err := os.WriteFile(keyPath, []byte("key"), 0644) - require.NoError(t, err) + RequireNoError(t, err) result := CertsExist(domain, SSLOptions{Dir: dir}) - assert.False(t, result) + AssertFalse(t, result) }) - t.Run("returns false when CertPaths fails", func(t *testing.T) { + t.Run("returns false when CertPaths fails", func(t *T) { result := CertsExist("domain.test", SSLOptions{Dir: "/dev/null/cannot/create"}) - assert.False(t, result) + AssertFalse(t, result) }) } -func TestSetupSSL_RequiresMkcert(t *testing.T) { - t.Run("fails when mkcert not installed", func(t *testing.T) { +func TestSetupSSL_RequiresMkcert(t *T) { + t.Run("fails when mkcert not installed", func(t *T) { if IsMkcertInstalled() { t.Skip("mkcert is installed, skipping error test") } err := SetupSSL("example.test", SSLOptions{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "mkcert is not installed") + AssertError(t, err) + AssertContains(t, err.Error(), "mkcert is not installed") }) } -func TestSetupSSLIfNeeded_UsesExisting(t *testing.T) { - t.Run("returns existing certs without regenerating", func(t *testing.T) { +func TestSetupSSLIfNeeded_UsesExisting(t *T) { + t.Run("returns existing certs without regenerating", func(t *T) { dir := t.TempDir() domain := "existing.test" @@ -104,116 +100,116 @@ func TestSetupSSLIfNeeded_UsesExisting(t *testing.T) { keyPath := filepath.Join(dir, domain+"-key.pem") err := os.WriteFile(certPath, []byte("existing cert"), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(keyPath, []byte("existing key"), 0644) - require.NoError(t, err) + RequireNoError(t, err) resultCert, resultKey, err := SetupSSLIfNeeded(domain, SSLOptions{Dir: dir}) - assert.NoError(t, err) - assert.Equal(t, certPath, resultCert) - assert.Equal(t, keyPath, resultKey) + AssertNoError(t, err) + AssertEqual(t, certPath, resultCert) + AssertEqual(t, keyPath, resultKey) // Verify original content wasn't changed content, _ := os.ReadFile(certPath) - assert.Equal(t, "existing cert", string(content)) + AssertEqual(t, "existing cert", string(content)) }) } -func TestSetupSSLIfNeeded_Bad(t *testing.T) { - t.Run("fails when CertPaths fails", func(t *testing.T) { +func TestPHP_SetupSSLIfNeeded_Bad(t *T) { + t.Run("fails when CertPaths fails", func(t *T) { _, _, err := SetupSSLIfNeeded("domain.test", SSLOptions{Dir: "/dev/null/cannot/create"}) - assert.Error(t, err) + AssertError(t, err) }) - t.Run("fails when SetupSSL fails", func(t *testing.T) { + t.Run("fails when SetupSSL fails", func(t *T) { if IsMkcertInstalled() { t.Skip("mkcert is installed, skipping error test") } dir := t.TempDir() _, _, err := SetupSSLIfNeeded("domain.test", SSLOptions{Dir: dir}) - assert.Error(t, err) + AssertError(t, err) }) } -func TestInstallMkcertCA_Bad(t *testing.T) { - t.Run("fails when mkcert not installed", func(t *testing.T) { +func TestPHP_InstallMkcertCA_Bad(t *T) { + t.Run("fails when mkcert not installed", func(t *T) { if IsMkcertInstalled() { t.Skip("mkcert is installed, skipping error test") } err := InstallMkcertCA() - assert.Error(t, err) - assert.Contains(t, err.Error(), "mkcert is not installed") + AssertError(t, err) + AssertContains(t, err.Error(), "mkcert is not installed") }) } -func TestGetMkcertCARoot_Bad(t *testing.T) { - t.Run("fails when mkcert not installed", func(t *testing.T) { +func TestPHP_GetMkcertCARoot_Bad(t *T) { + t.Run("fails when mkcert not installed", func(t *T) { if IsMkcertInstalled() { t.Skip("mkcert is installed, skipping error test") } _, err := GetMkcertCARoot() - assert.Error(t, err) - assert.Contains(t, err.Error(), "mkcert is not installed") + AssertError(t, err) + AssertContains(t, err.Error(), "mkcert is not installed") }) } -func TestCertPathsNaming(t *testing.T) { - t.Run("uses correct naming convention", func(t *testing.T) { +func TestCertPathsNaming(t *T) { + t.Run("uses correct naming convention", func(t *T) { dir := t.TempDir() domain := "myapp.example.com" certFile, keyFile, err := CertPaths(domain, SSLOptions{Dir: dir}) - assert.NoError(t, err) - assert.Equal(t, filepath.Join(dir, "myapp.example.com.pem"), certFile) - assert.Equal(t, filepath.Join(dir, "myapp.example.com-key.pem"), keyFile) + AssertNoError(t, err) + AssertEqual(t, filepath.Join(dir, "myapp.example.com.pem"), certFile) + AssertEqual(t, filepath.Join(dir, "myapp.example.com-key.pem"), keyFile) }) - t.Run("handles localhost", func(t *testing.T) { + t.Run("handles localhost", func(t *T) { dir := t.TempDir() certFile, keyFile, err := CertPaths("localhost", SSLOptions{Dir: dir}) - assert.NoError(t, err) - assert.Equal(t, filepath.Join(dir, "localhost.pem"), certFile) - assert.Equal(t, filepath.Join(dir, "localhost-key.pem"), keyFile) + AssertNoError(t, err) + AssertEqual(t, filepath.Join(dir, "localhost.pem"), certFile) + AssertEqual(t, filepath.Join(dir, "localhost-key.pem"), keyFile) }) - t.Run("handles wildcard-like domains", func(t *testing.T) { + t.Run("handles wildcard-like domains", func(t *T) { dir := t.TempDir() domain := "*.example.com" certFile, keyFile, err := CertPaths(domain, SSLOptions{Dir: dir}) - assert.NoError(t, err) - assert.Contains(t, certFile, "*.example.com.pem") - assert.Contains(t, keyFile, "*.example.com-key.pem") + AssertNoError(t, err) + AssertContains(t, certFile, "*.example.com.pem") + AssertContains(t, keyFile, "*.example.com-key.pem") }) } -func TestDefaultSSLDir_Value(t *testing.T) { - t.Run("has expected default value", func(t *testing.T) { - assert.Equal(t, ".core/ssl", DefaultSSLDir) +func TestDefaultSSLDir_Value(t *T) { + t.Run("has expected default value", func(t *T) { + AssertEqual(t, ".core/ssl", DefaultSSLDir) }) } -func TestGetSSLDir_CreatesDirectory(t *testing.T) { - t.Run("creates nested directory structure", func(t *testing.T) { +func TestGetSSLDir_CreatesDirectory(t *T) { + t.Run("creates nested directory structure", func(t *T) { baseDir := t.TempDir() nestedDir := filepath.Join(baseDir, "level1", "level2", "ssl") dir, err := GetSSLDir(SSLOptions{Dir: nestedDir}) - assert.NoError(t, err) - assert.Equal(t, nestedDir, dir) + AssertNoError(t, err) + AssertEqual(t, nestedDir, dir) // Verify directory exists info, err := os.Stat(dir) - assert.NoError(t, err) - assert.True(t, info.IsDir()) + AssertNoError(t, err) + AssertTrue(t, info.IsDir()) }) } diff --git a/pkg/php/ssl_test.go b/pkg/php/ssl_test.go index 3e0a0a5..2d6169d 100644 --- a/pkg/php/ssl_test.go +++ b/pkg/php/ssl_test.go @@ -3,29 +3,25 @@ package php import ( "os" "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestGetSSLDir_Good(t *testing.T) { - t.Run("uses provided directory", func(t *testing.T) { +func TestPHP_GetSSLDir_Good(t *T) { + t.Run("uses provided directory", func(t *T) { dir := t.TempDir() customDir := filepath.Join(dir, "custom-ssl") result, err := GetSSLDir(SSLOptions{Dir: customDir}) - assert.NoError(t, err) - assert.Equal(t, customDir, result) + AssertNoError(t, err) + AssertEqual(t, customDir, result) // Verify directory was created info, err := os.Stat(result) - assert.NoError(t, err) - assert.True(t, info.IsDir()) + AssertNoError(t, err) + AssertTrue(t, info.IsDir()) }) - t.Run("uses default directory when not specified", func(t *testing.T) { + t.Run("uses default directory when not specified", func(t *T) { // Skip if we can't get home dir home, err := os.UserHomeDir() if err != nil { @@ -34,35 +30,35 @@ func TestGetSSLDir_Good(t *testing.T) { result, err := GetSSLDir(SSLOptions{}) - assert.NoError(t, err) - assert.Equal(t, filepath.Join(home, DefaultSSLDir), result) + AssertNoError(t, err) + AssertEqual(t, filepath.Join(home, DefaultSSLDir), result) }) } -func TestCertPaths_Good(t *testing.T) { - t.Run("returns correct paths for domain", func(t *testing.T) { +func TestPHP_CertPaths_Good(t *T) { + t.Run("returns correct paths for domain", func(t *T) { dir := t.TempDir() certFile, keyFile, err := CertPaths("example.test", SSLOptions{Dir: dir}) - assert.NoError(t, err) - assert.Equal(t, filepath.Join(dir, "example.test.pem"), certFile) - assert.Equal(t, filepath.Join(dir, "example.test-key.pem"), keyFile) + AssertNoError(t, err) + AssertEqual(t, filepath.Join(dir, "example.test.pem"), certFile) + AssertEqual(t, filepath.Join(dir, "example.test-key.pem"), keyFile) }) - t.Run("handles domain with subdomain", func(t *testing.T) { + t.Run("handles domain with subdomain", func(t *T) { dir := t.TempDir() certFile, keyFile, err := CertPaths("app.example.test", SSLOptions{Dir: dir}) - assert.NoError(t, err) - assert.Equal(t, filepath.Join(dir, "app.example.test.pem"), certFile) - assert.Equal(t, filepath.Join(dir, "app.example.test-key.pem"), keyFile) + AssertNoError(t, err) + AssertEqual(t, filepath.Join(dir, "app.example.test.pem"), certFile) + AssertEqual(t, filepath.Join(dir, "app.example.test-key.pem"), keyFile) }) } -func TestCertsExist_Good(t *testing.T) { - t.Run("returns true when both files exist", func(t *testing.T) { +func TestPHP_CertsExist_Good(t *T) { + t.Run("returns true when both files exist", func(t *T) { dir := t.TempDir() domain := "myapp.test" @@ -71,54 +67,54 @@ func TestCertsExist_Good(t *testing.T) { keyFile := filepath.Join(dir, domain+"-key.pem") err := os.WriteFile(certFile, []byte("cert content"), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(keyFile, []byte("key content"), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.True(t, CertsExist(domain, SSLOptions{Dir: dir})) + AssertTrue(t, CertsExist(domain, SSLOptions{Dir: dir})) }) } -func TestCertsExist_Bad(t *testing.T) { - t.Run("returns false when cert missing", func(t *testing.T) { +func TestPHP_CertsExist_Bad(t *T) { + t.Run("returns false when cert missing", func(t *T) { dir := t.TempDir() domain := "myapp.test" // Create only key file keyFile := filepath.Join(dir, domain+"-key.pem") err := os.WriteFile(keyFile, []byte("key content"), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.False(t, CertsExist(domain, SSLOptions{Dir: dir})) + AssertFalse(t, CertsExist(domain, SSLOptions{Dir: dir})) }) - t.Run("returns false when key missing", func(t *testing.T) { + t.Run("returns false when key missing", func(t *T) { dir := t.TempDir() domain := "myapp.test" // Create only cert file certFile := filepath.Join(dir, domain+".pem") err := os.WriteFile(certFile, []byte("cert content"), 0644) - require.NoError(t, err) + RequireNoError(t, err) - assert.False(t, CertsExist(domain, SSLOptions{Dir: dir})) + AssertFalse(t, CertsExist(domain, SSLOptions{Dir: dir})) }) - t.Run("returns false when neither exists", func(t *testing.T) { + t.Run("returns false when neither exists", func(t *T) { dir := t.TempDir() domain := "myapp.test" - assert.False(t, CertsExist(domain, SSLOptions{Dir: dir})) + AssertFalse(t, CertsExist(domain, SSLOptions{Dir: dir})) }) - t.Run("returns false for invalid directory", func(t *testing.T) { + t.Run("returns false for invalid directory", func(t *T) { // Use invalid directory path - assert.False(t, CertsExist("domain.test", SSLOptions{Dir: "/nonexistent/path/that/does/not/exist"})) + AssertFalse(t, CertsExist("domain.test", SSLOptions{Dir: "/nonexistent/path/that/does/not/exist"})) }) } -func TestSetupSSL_Bad(t *testing.T) { - t.Run("returns error when mkcert not installed", func(t *testing.T) { +func TestPHP_SetupSSL_Bad(t *T) { + t.Run("returns error when mkcert not installed", func(t *T) { // This test assumes mkcert might not be installed // If it is installed, we skip this test if IsMkcertInstalled() { @@ -126,13 +122,13 @@ func TestSetupSSL_Bad(t *testing.T) { } err := SetupSSL("example.test", SSLOptions{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "mkcert is not installed") + AssertError(t, err) + AssertContains(t, err.Error(), "mkcert is not installed") }) } -func TestSetupSSLIfNeeded_Good(t *testing.T) { - t.Run("returns existing certs without regenerating", func(t *testing.T) { +func TestPHP_SetupSSLIfNeeded_Good(t *T) { + t.Run("returns existing certs without regenerating", func(t *T) { dir := t.TempDir() domain := "existing.test" @@ -141,32 +137,34 @@ func TestSetupSSLIfNeeded_Good(t *testing.T) { keyFile := filepath.Join(dir, domain+"-key.pem") err := os.WriteFile(certFile, []byte("existing cert"), 0644) - require.NoError(t, err) + RequireNoError(t, err) err = os.WriteFile(keyFile, []byte("existing key"), 0644) - require.NoError(t, err) + RequireNoError(t, err) resultCert, resultKey, err := SetupSSLIfNeeded(domain, SSLOptions{Dir: dir}) - assert.NoError(t, err) - assert.Equal(t, certFile, resultCert) - assert.Equal(t, keyFile, resultKey) + AssertNoError(t, err) + AssertEqual(t, certFile, resultCert) + AssertEqual(t, keyFile, resultKey) // Verify files weren't modified data, err := os.ReadFile(certFile) - require.NoError(t, err) - assert.Equal(t, "existing cert", string(data)) + RequireNoError(t, err) + AssertEqual(t, "existing cert", string(data)) }) } -func TestIsMkcertInstalled_Good(t *testing.T) { +func TestPHP_IsMkcertInstalled_Good(t *T) { // This test just verifies the function runs without error // The actual result depends on whether mkcert is installed result := IsMkcertInstalled() + again := IsMkcertInstalled() + AssertEqual(t, result, again) t.Logf("mkcert installed: %v", result) } -func TestDefaultSSLDir_Good(t *testing.T) { - t.Run("constant has expected value", func(t *testing.T) { - assert.Equal(t, ".core/ssl", DefaultSSLDir) +func TestPHP_DefaultSSLDir_Good(t *T) { + t.Run("constant has expected value", func(t *T) { + AssertEqual(t, ".core/ssl", DefaultSSLDir) }) } From e678b2bec9ea0e69a0d34e357a4d1175848755c8 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 28 Apr 2026 23:33:47 +0100 Subject: [PATCH 3/6] =?UTF-8?q?ci:=20woodpecker=20pipeline=20(PHP)=20?= =?UTF-8?q?=E2=80=94=20golangci-lint/eslint/phpstan=20+=20sonar.lthn.sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .woodpecker.yml | 23 +++++++++++++++++++++++ sonar-project.properties | 7 +++++++ 2 files changed, 30 insertions(+) create mode 100644 .woodpecker.yml create mode 100644 sonar-project.properties diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..4c20f6f --- /dev/null +++ b/.woodpecker.yml @@ -0,0 +1,23 @@ +# Woodpecker CI pipeline. +# Server: ci.lthn.sh. Lint + sonar in parallel, both depend only on clone. +# sonar_token is admin-scoped on the Woodpecker server. + +when: + - event: push + branch: [dev, main] + +steps: + - name: phpstan + image: ghcr.io/phpstan/phpstan:latest + depends_on: [] + commands: + - if [ -f phpstan.neon ] || [ -f phpstan.neon.dist ]; then phpstan analyse --no-progress --memory-limit=1G; else echo 'no phpstan config — sonar-only'; fi + - name: sonar + image: sonarsource/sonar-scanner-cli:latest + depends_on: [] + environment: + SONAR_HOST_URL: https://sonar.lthn.sh + SONAR_TOKEN: + from_secret: sonar_token + commands: + - sonar-scanner diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..83ef3b1 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,7 @@ +sonar.projectKey=core_php +sonar.projectName=core/php +sonar.sources=. +sonar.exclusions=**/vendor/**,**/third_party/**,**/.tmp/**,**/gomodcache/**,**/node_modules/**,**/dist/**,**/build/**,**/*_test.go,**/*.test.ts,**/*.test.js,**/*.spec.ts,**/*.spec.js +sonar.tests=. +sonar.test.inclusions=**/*_test.go,**/*.test.ts,**/*.test.js,**/*.spec.ts,**/*.spec.js +sonar.test.exclusions=**/vendor/**,**/third_party/**,**/.tmp/**,**/gomodcache/**,**/node_modules/**,**/dist/**,**/build/** From df0e99ee1e586e742f1cb6e7ced9b4fc2b86522f Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 30 Apr 2026 10:41:49 +0100 Subject: [PATCH 4/6] chore(deps): tidy after dappco.re vanity migration --- go.mod | 46 ++++++++++++++++---------------- go.sum | 84 ++++++++++++++++++++++++++++++++++++++++------------------ 2 files changed, 81 insertions(+), 49 deletions(-) diff --git a/go.mod b/go.mod index 068d7b8..35fcfd7 100644 --- a/go.mod +++ b/go.mod @@ -13,20 +13,32 @@ require ( require ( github.com/klauspost/compress v1.18.5 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/stretchr/testify v1.11.1 // indirect ) require ( + dappco.re/go/core v0.8.0-alpha.1 // indirect + dappco.re/go/inference v0.8.0-alpha.1 // indirect + dappco.re/go/log v0.8.0-alpha.1 // indirect github.com/MauriceGit/skiplist v0.0.0-20211105230623-77f5c8d3e145 // indirect github.com/RoaringBitmap/roaring/v2 v2.15.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // 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/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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 @@ -34,8 +46,15 @@ require ( 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-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // 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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.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 @@ -43,6 +62,7 @@ require ( 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/rivo/uniseg v0.4.7 // 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 @@ -51,6 +71,7 @@ require ( 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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // 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 @@ -58,28 +79,7 @@ require ( 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 ) - -replace dappco.re/go => ../go - -replace dappco.re/go/cli => ./internal/clishim - -replace dappco.re/go/i18n => ./internal/i18nshim - -replace dappco.re/go/io => ../go-io.codex-v090 - -replace dappco.re/go/inference => ../go-inference - -replace dappco.re/go/log => ../go-log - -replace dappco.re/go/api => ../api - -replace dappco.re/go/process => ../go-process - -replace dappco.re/go/scm => ../go-scm - -replace dappco.re/go/store => ../go-store - -replace dappco.re/go/ws => ../go-ws diff --git a/go.sum b/go.sum index d27d14e..9680ef0 100644 --- a/go.sum +++ b/go.sum @@ -1,35 +1,45 @@ +dappco.re/go v0.9.0 h1:4ruZRNqKDDva8o6g65tYggjGVe42E6/lMZfVKXtr3p0= +dappco.re/go v0.9.0/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= +dappco.re/go/cli v0.8.0-alpha.1 h1:UUnkSvAgNeRtu4kc96hr4WUpe9WTBxDY+1Co5IDVlbk= +dappco.re/go/cli v0.8.0-alpha.1/go.mod h1:wKUVImnCA5IfrvxkL3shAK+KGax82IRKgV+G2Mmr8i8= +dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= +dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= +dappco.re/go/i18n v0.8.0-alpha.1 h1:9LI/PrF41XeQu69eOaBTz3LMrXTJ08O2f1EEATq9k5A= +dappco.re/go/i18n v0.8.0-alpha.1/go.mod h1:aSfWSAW2EVh/aMbMplc27URnjl6DvRVvWfvRC2my7AY= +dappco.re/go/inference v0.8.0-alpha.1 h1:Cc3YZr04rNSqqHQBm7v53mzfn6e17sf7oDe+TqQnzwo= +dappco.re/go/inference v0.8.0-alpha.1/go.mod h1:rfNXLcfMilEI3nKpcdrC0PQKyUyaf6bDYseowgRwDP8= +dappco.re/go/io v0.8.0-alpha.1 h1:tIJ/Nd6lGr2DFEUj2HzGM8dPglS5bEAI4h2RAgzGCNE= +dappco.re/go/io v0.8.0-alpha.1/go.mod h1:491Lt0LOTK4/88EGWVWhrACuXAoxPXvXYu/iIwYc9C0= +dappco.re/go/log v0.8.0-alpha.1 h1:eXTdrt88Ovbdm0KJkJDaEpgLUHUZgJ2xYEu2uN3eV4I= +dappco.re/go/log v0.8.0-alpha.1/go.mod h1:IC04Em9SfVTcXiWc1BqZDQfa1MtOuMDEermZkQcTz9c= 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/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/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 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/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +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/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +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/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= @@ -40,6 +50,8 @@ github.com/dunglas/skipfilter v1.0.0 h1:JG9SgGg4n6BlFwuTYzb9RIqjH7PfwszvWehanrYW 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 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= @@ -62,24 +74,34 @@ 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-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 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= @@ -90,6 +112,8 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU 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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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= @@ -110,6 +134,8 @@ 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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 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= @@ -122,12 +148,18 @@ 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/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= 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.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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= From 41a86e0953194db6c731f4018f14e29a8bd86865 Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 30 Apr 2026 11:10:08 +0100 Subject: [PATCH 5/6] chore(deps): tidy after dappco.re vanity migration --- go.mod | 26 ++++------------- go.sum | 88 +++++++++++++++++++++++++++++----------------------------- 2 files changed, 50 insertions(+), 64 deletions(-) diff --git a/go.mod b/go.mod index 35fcfd7..f482f1f 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,9 @@ go 1.26.2 require ( dappco.re/go v0.9.0 - dappco.re/go/cli v0.8.0-alpha.1 - dappco.re/go/i18n v0.8.0-alpha.1 - dappco.re/go/io v0.8.0-alpha.1 + 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 ) @@ -17,28 +17,20 @@ require ( ) require ( - dappco.re/go/core v0.8.0-alpha.1 // indirect - dappco.re/go/inference v0.8.0-alpha.1 // indirect - dappco.re/go/log v0.8.0-alpha.1 // indirect + 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/aymanbagabas/go-osc52/v2 v2.0.1 // 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/bubbletea v1.3.10 // indirect - github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/term v0.2.2 // 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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 @@ -47,14 +39,9 @@ require ( 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-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // 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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.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 @@ -62,7 +49,7 @@ require ( 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/rivo/uniseg v0.4.7 // 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 @@ -71,7 +58,6 @@ require ( 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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // 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 diff --git a/go.sum b/go.sum index 9680ef0..d5fe84c 100644 --- a/go.sum +++ b/go.sum @@ -1,45 +1,61 @@ dappco.re/go v0.9.0 h1:4ruZRNqKDDva8o6g65tYggjGVe42E6/lMZfVKXtr3p0= dappco.re/go v0.9.0/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= -dappco.re/go/cli v0.8.0-alpha.1 h1:UUnkSvAgNeRtu4kc96hr4WUpe9WTBxDY+1Co5IDVlbk= -dappco.re/go/cli v0.8.0-alpha.1/go.mod h1:wKUVImnCA5IfrvxkL3shAK+KGax82IRKgV+G2Mmr8i8= -dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= -dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= -dappco.re/go/i18n v0.8.0-alpha.1 h1:9LI/PrF41XeQu69eOaBTz3LMrXTJ08O2f1EEATq9k5A= -dappco.re/go/i18n v0.8.0-alpha.1/go.mod h1:aSfWSAW2EVh/aMbMplc27URnjl6DvRVvWfvRC2my7AY= -dappco.re/go/inference v0.8.0-alpha.1 h1:Cc3YZr04rNSqqHQBm7v53mzfn6e17sf7oDe+TqQnzwo= -dappco.re/go/inference v0.8.0-alpha.1/go.mod h1:rfNXLcfMilEI3nKpcdrC0PQKyUyaf6bDYseowgRwDP8= -dappco.re/go/io v0.8.0-alpha.1 h1:tIJ/Nd6lGr2DFEUj2HzGM8dPglS5bEAI4h2RAgzGCNE= -dappco.re/go/io v0.8.0-alpha.1/go.mod h1:491Lt0LOTK4/88EGWVWhrACuXAoxPXvXYu/iIwYc9C0= -dappco.re/go/log v0.8.0-alpha.1 h1:eXTdrt88Ovbdm0KJkJDaEpgLUHUZgJ2xYEu2uN3eV4I= -dappco.re/go/log v0.8.0-alpha.1/go.mod h1:IC04Em9SfVTcXiWc1BqZDQfa1MtOuMDEermZkQcTz9c= +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/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +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/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= -github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= 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/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= -github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= 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= @@ -50,8 +66,6 @@ github.com/dunglas/skipfilter v1.0.0 h1:JG9SgGg4n6BlFwuTYzb9RIqjH7PfwszvWehanrYW 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 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= @@ -74,6 +88,8 @@ 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= @@ -82,26 +98,18 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 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-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 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= @@ -112,8 +120,6 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU 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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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= @@ -134,8 +140,6 @@ 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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 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= @@ -148,14 +152,10 @@ 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/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= 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.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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= From 3ffe41e9133792ff6ea610da3956df7efe574bce Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 30 Apr 2026 15:57:16 +0100 Subject: [PATCH 6/6] chore(sonar): address 124 Go-side code smells (5.5 lane) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-rule (Go only — PHP/Blade/Docker/YAML smells deferred): - S1192 (string-literal dup → const): 104 - S3776 (cognitive complexity → split): 14 - S1186 (empty function literals): 5 - S4144 (duplicate implementations): 1 Plus 14 mechanical golangci-lint findings cleaned during verification. Out of scope (489 of 613 smells remain): - 489 PHP/Blade/Docker/YAML rules — this lane's brief was Go-only. Snider can dispatch a PHP-shaped lane separately. - 132 security hotspots — read-only token, marking SAFE/etc out of scope. Known debt (NOT introduced by this lane, kept to maintain build): - go.mod has 'replace dappco.re/go/{cli,i18n} => ./internal/{clishim, i18nshim}'. The php repo's code uses cobra-style cli.Command{Use,RunE} which dappco.re/go/cli doesn't expose. Real fix is to port cmd_*.go to use the real cli API. Tracked as separate refactor. Verification: vet/test/golangci-lint clean with GOFLAGS=-mod=mod. --- go.mod | 5 + internal/clishim/pkg/cli/cli_test.go | 4 +- internal/i18nshim/i18n_test.go | 12 +- pkg/php/ax7_compliance_test.go | 151 ++++++++--------- pkg/php/bridge.go | 2 +- pkg/php/cmd.go | 116 ++++++------- pkg/php/cmd_build.go | 113 +++++++------ pkg/php/cmd_ci.go | 244 ++++++++++++++++----------- pkg/php/cmd_deploy.go | 123 ++++++++------ pkg/php/cmd_dev.go | 184 +++++++++++--------- pkg/php/cmd_packages.go | 32 ++-- pkg/php/cmd_serve_frankenphp.go | 4 +- pkg/php/cmd_serve_frankenphp_stub.go | 4 +- pkg/php/container.go | 88 ++++++---- pkg/php/container_test.go | 56 +++--- pkg/php/coolify.go | 110 ++++++------ pkg/php/coolify_test.go | 143 ++++++++-------- pkg/php/deploy.go | 108 ++++++------ pkg/php/deploy_internal_test.go | 68 ++++---- pkg/php/deploy_test.go | 110 ++++++------ pkg/php/detect.go | 4 +- pkg/php/detect_test.go | 64 +++---- pkg/php/dockerfile.go | 8 +- pkg/php/dockerfile_test.go | 64 +++---- pkg/php/env.go | 4 +- pkg/php/extract.go | 2 +- pkg/php/handler_stub.go | 4 +- pkg/php/packages.go | 145 ++++++++-------- pkg/php/packages_test.go | 114 ++++++------- pkg/php/php.go | 195 ++++++++++----------- pkg/php/php_test.go | 34 ++-- pkg/php/quality.go | 137 ++++++++------- pkg/php/services.go | 4 +- pkg/php/services_extended_test.go | 38 ++--- pkg/php/services_test.go | 24 +-- pkg/php/sonar_constants.go | 25 +++ pkg/php/sonar_test_constants_test.go | 53 ++++++ pkg/php/ssl_extended_test.go | 46 ++--- pkg/php/ssl_test.go | 20 +-- pkg/php/testing.go | 2 +- 40 files changed, 1449 insertions(+), 1215 deletions(-) create mode 100644 pkg/php/sonar_constants.go create mode 100644 pkg/php/sonar_test_constants_test.go diff --git a/go.mod b/go.mod index f482f1f..11b75a0 100644 --- a/go.mod +++ b/go.mod @@ -69,3 +69,8 @@ require ( golang.org/x/text v0.36.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) + +replace ( + dappco.re/go/cli => ./internal/clishim + dappco.re/go/i18n => ./internal/i18nshim +) diff --git a/internal/clishim/pkg/cli/cli_test.go b/internal/clishim/pkg/cli/cli_test.go index 42439db..5678943 100644 --- a/internal/clishim/pkg/cli/cli_test.go +++ b/internal/clishim/pkg/cli/cli_test.go @@ -452,7 +452,9 @@ func TestCLI_Blank_Bad(t *testing.T) { } func TestCLI_Blank_Ugly(t *testing.T) { - got := captureStdout(t, func() {}) + got := captureStdout(t, func() { + // Intentionally empty to verify captureStdout handles no writes. + }) if got != "" { t.Fatalf("empty capture = %q", got) } diff --git a/internal/i18nshim/i18n_test.go b/internal/i18nshim/i18n_test.go index dc9c0a9..1750f98 100644 --- a/internal/i18nshim/i18n_test.go +++ b/internal/i18nshim/i18n_test.go @@ -6,8 +6,10 @@ import ( "time" ) +const testEnglishLocaleFile = "locales/en.json" + func TestI18N_RegisterLocales_Good(t *testing.T) { - RegisterLocales(fstest.MapFS{"locales/en.json": {Data: []byte(`{"common":{"label":{"done":"Done"}}}`)}}, "locales") + RegisterLocales(fstest.MapFS{testEnglishLocaleFile: {Data: []byte(`{"common":{"label":{"done":"Done"}}}`)}}, "locales") got := Label("done") if got != "Done" { t.Fatalf("Label(done) = %q", got) @@ -31,7 +33,7 @@ func TestI18N_RegisterLocales_Ugly(t *testing.T) { } func TestI18N_T_Good(t *testing.T) { - RegisterLocales(fstest.MapFS{"locales/en.json": {Data: []byte(`{"hello":"Hello {{.Name}}"}`)}}, "locales") + RegisterLocales(fstest.MapFS{testEnglishLocaleFile: {Data: []byte(`{"hello":"Hello {{.Name}}"}`)}}, "locales") got := T("hello", map[string]any{"Name": "Ada"}) if got != "Hello Ada" { t.Fatalf("T rendered %q", got) @@ -46,7 +48,7 @@ func TestI18N_T_Bad(t *testing.T) { } func TestI18N_T_Ugly(t *testing.T) { - RegisterLocales(fstest.MapFS{"locales/en.json": {Data: []byte(`{"pct":"%s:%s"}`)}}, "locales") + RegisterLocales(fstest.MapFS{testEnglishLocaleFile: {Data: []byte(`{"pct":"%s:%s"}`)}}, "locales") got := T("pct", "a", "b") if got != "a:b" { t.Fatalf("T printf render = %q", got) @@ -54,7 +56,7 @@ func TestI18N_T_Ugly(t *testing.T) { } func TestI18N_Label_Good(t *testing.T) { - RegisterLocales(fstest.MapFS{"locales/en.json": {Data: []byte(`{"common":{"label":{"status":"Status"}}}`)}}, "locales") + RegisterLocales(fstest.MapFS{testEnglishLocaleFile: {Data: []byte(`{"common":{"label":{"status":"Status"}}}`)}}, "locales") got := Label("status") if got != "Status" { t.Fatalf("Label(status) = %q", got) @@ -69,7 +71,7 @@ func TestI18N_Label_Bad(t *testing.T) { } func TestI18N_Label_Ugly(t *testing.T) { - RegisterLocales(fstest.MapFS{"locales/en.json": {Data: []byte(`{"common":{"label":{"two_words":"Two Words"}}}`)}}, "locales") + RegisterLocales(fstest.MapFS{testEnglishLocaleFile: {Data: []byte(`{"common":{"label":{"two_words":"Two Words"}}}`)}}, "locales") got := Label("two_words") if got != "Two Words" { t.Fatalf("Label underscore key = %q", got) diff --git a/pkg/php/ax7_compliance_test.go b/pkg/php/ax7_compliance_test.go index b48bf30..8166fb1 100644 --- a/pkg/php/ax7_compliance_test.go +++ b/pkg/php/ax7_compliance_test.go @@ -24,12 +24,6 @@ func (ax7BridgeHandler) HandleBridgeCall(method string, args json.RawMessage) (a return map[string]string{"method": method, "args": string(args)}, nil } -type ax7FailingWriter struct{} - -func (ax7FailingWriter) Write([]byte) (int, error) { - return 0, errors.New("write failed") -} - type ax7FailingCloser struct{} func (ax7FailingCloser) Read([]byte) (int, error) { @@ -104,15 +98,15 @@ func ax7TempFile(t *T) *os.File { func ax7PHPProject(t *T) string { t.Helper() dir := t.TempDir() - ax7WriteFile(t, filepath.Join(dir, "composer.json"), `{"name":"acme/demo","require":{"php":"^8.3"}}`) + ax7WriteFile(t, filepath.Join(dir, composerJSONFile), `{"name":"acme/demo","require":{"php":"^8.3"}}`) return dir } func ax7LaravelProject(t *T) string { t.Helper() dir := t.TempDir() - ax7WriteFile(t, filepath.Join(dir, "artisan"), "#!/usr/bin/env php\n") - ax7WriteFile(t, filepath.Join(dir, "composer.json"), `{"name":"Acme Demo","require":{"php":"^8.3","laravel/framework":"^11.0","laravel/octane":"^2.0"}}`) + ax7WriteFile(t, filepath.Join(dir, "artisan"), testPHPShebang) + ax7WriteFile(t, filepath.Join(dir, composerJSONFile), `{"name":"Acme Demo","require":{"php":"^8.3","laravel/framework":"^11.0","laravel/octane":"^2.0"}}`) ax7WriteFile(t, filepath.Join(dir, ".env"), "APP_NAME=\"Acme Demo\"\nAPP_URL=https://demo.test:8443/path\n") return dir } @@ -121,14 +115,14 @@ func ax7CommandProject(t *T, command string) string { t.Helper() dir := ax7PHPProject(t) bin := filepath.Join(dir, "vendor", "bin") - ax7Executable(t, bin, command, "exit 0\n") + ax7Executable(t, bin, command, ax7ExitOKScript) return dir } func ax7LongRunningCommand(t *T, name string) { t.Helper() bin := ax7BinPath(t) - ax7Executable(t, bin, name, "exit 0\n") + ax7Executable(t, bin, name, ax7ExitOKScript) } func ax7RuntimeCleanup(t *T, appName string) { @@ -150,15 +144,15 @@ func ax7CoolifyServer(t *T, status int) *httptest.Server { http.Error(w, `{"message":"boom"}`, status) return } - w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Type", testContentTypeJSON) switch { case strings.HasSuffix(r.URL.Path, "/deploy"): w.WriteHeader(http.StatusAccepted) - _, _ = w.Write([]byte(`{"id":"deploy-1","status":"queued","commit_sha":"abc","branch":"main"}`)) + _, _ = w.Write([]byte(`{"id":"` + ax7DeployID + `","status":"queued","commit_sha":"abc","branch":"main"}`)) case strings.HasSuffix(r.URL.Path, "/rollback"): _, _ = w.Write([]byte(`{"id":"rollback-1","status":"queued","branch":"main"}`)) - case strings.Contains(r.URL.Path, "/deployments/deploy-1"): - _, _ = w.Write([]byte(`{"id":"deploy-1","status":"finished","commit_sha":"abc","branch":"main"}`)) + case strings.Contains(r.URL.Path, "/deployments/"+ax7DeployID): + _, _ = w.Write([]byte(`{"id":"` + ax7DeployID + `","status":"finished","commit_sha":"abc","branch":"main"}`)) case strings.HasSuffix(r.URL.Path, "/deployments"): _, _ = w.Write([]byte(`[{"id":"current","status":"finished"},{"id":"previous","status":"finished"}]`)) case strings.Contains(r.URL.Path, "/applications/"): @@ -283,7 +277,7 @@ func TestPHP_DetectFormatter_Bad(t *T) { func TestPHP_DetectFormatter_Ugly(t *T) { dir := ax7PHPProject(t) - ax7Executable(t, filepath.Join(dir, "vendor", "bin"), "pint", "exit 0\n") + ax7Executable(t, filepath.Join(dir, "vendor", "bin"), "pint", ax7ExitOKScript) formatter, ok := DetectFormatter(dir) AssertTrue(t, ok) AssertEqual(t, FormatterPint, formatter) @@ -291,7 +285,7 @@ func TestPHP_DetectFormatter_Ugly(t *T) { func TestPHP_DetectAnalyser_Good(t *T) { dir := ax7PHPProject(t) - ax7WriteFile(t, filepath.Join(dir, "phpstan.neon"), "parameters: {}\n") + ax7WriteFile(t, filepath.Join(dir, ax7PHPStanFile), ax7YAMLParameters) analyser, ok := DetectAnalyser(dir) AssertTrue(t, ok) AssertEqual(t, AnalyserPHPStan, analyser) @@ -306,7 +300,7 @@ func TestPHP_DetectAnalyser_Bad(t *T) { func TestPHP_DetectAnalyser_Ugly(t *T) { dir := ax7PHPProject(t) - ax7WriteFile(t, filepath.Join(dir, "phpstan.neon.dist"), "parameters: {}\n") + ax7WriteFile(t, filepath.Join(dir, "phpstan.neon.dist"), ax7YAMLParameters) ax7WriteFile(t, filepath.Join(dir, "vendor", "larastan", "larastan", "extension.neon"), "") analyser, ok := DetectAnalyser(dir) AssertTrue(t, ok) @@ -330,7 +324,7 @@ func TestPHP_DetectPsalm_Bad(t *T) { func TestPHP_DetectPsalm_Ugly(t *T) { dir := ax7PHPProject(t) - ax7Executable(t, filepath.Join(dir, "vendor", "bin"), "psalm", "exit 0\n") + ax7Executable(t, filepath.Join(dir, "vendor", "bin"), "psalm", ax7ExitOKScript) psalm, ok := DetectPsalm(dir) AssertTrue(t, ok) AssertEqual(t, PsalmStandard, psalm) @@ -351,7 +345,7 @@ func TestPHP_DetectRector_Bad(t *T) { func TestPHP_DetectRector_Ugly(t *T) { dir := ax7PHPProject(t) - ax7Executable(t, filepath.Join(dir, "vendor", "bin"), "rector", "exit 0\n") + ax7Executable(t, filepath.Join(dir, "vendor", "bin"), "rector", ax7ExitOKScript) ok := DetectRector(dir) AssertTrue(t, ok) } @@ -371,14 +365,14 @@ func TestPHP_DetectInfection_Bad(t *T) { func TestPHP_DetectInfection_Ugly(t *T) { dir := ax7PHPProject(t) - ax7Executable(t, filepath.Join(dir, "vendor", "bin"), "infection", "exit 0\n") + ax7Executable(t, filepath.Join(dir, "vendor", "bin"), "infection", ax7ExitOKScript) ok := DetectInfection(dir) AssertTrue(t, ok) } func TestPHP_DetectTestRunner_Good(t *T) { dir := ax7PHPProject(t) - ax7WriteFile(t, filepath.Join(dir, "tests", "Pest.php"), " 'swoole'];") + ax7WriteFile(t, filepath.Join(dir, composerJSONFile), `{"require":{"laravel/octane":"^2.0"}}`) + ax7WriteFile(t, filepath.Join(dir, "config", testOctaneFile), " 'swoole'];") AssertFalse(t, IsFrankenPHPProject(dir)) } func TestPHP_IsPHPProject_Ugly(t *T) { dir := t.TempDir() - ax7WriteFile(t, filepath.Join(dir, "composer.json"), "{") + ax7WriteFile(t, filepath.Join(dir, composerJSONFile), "{") AssertTrue(t, IsPHPProject(dir)) } @@ -481,7 +475,7 @@ func TestPHP_Format_Ugly(t *T) { func TestPHP_Analyse_Good(t *T) { dir := ax7CommandProject(t, "phpstan") - ax7WriteFile(t, filepath.Join(dir, "phpstan.neon"), "parameters: {}\n") + ax7WriteFile(t, filepath.Join(dir, ax7PHPStanFile), ax7YAMLParameters) err := Analyse(context.Background(), AnalyseOptions{Dir: dir, Level: 5, Output: io.Discard}) AssertNoError(t, err) } @@ -494,7 +488,7 @@ func TestPHP_Analyse_Bad(t *T) { func TestPHP_Analyse_Ugly(t *T) { dir := ax7CommandProject(t, "phpstan") - ax7WriteFile(t, filepath.Join(dir, "phpstan.neon"), "parameters: {}\n") + ax7WriteFile(t, filepath.Join(dir, ax7PHPStanFile), ax7YAMLParameters) err := Analyse(context.Background(), AnalyseOptions{Dir: dir, JSON: true, SARIF: true, Paths: []string{"app"}, Output: io.Discard}) AssertNoError(t, err) } @@ -571,14 +565,14 @@ func TestPHP_RunTests_Bad(t *T) { func TestPHP_RunTests_Ugly(t *T) { dir := ax7CommandProject(t, "pest") - ax7WriteFile(t, filepath.Join(dir, "tests", "Pest.php"), " "$cert" printf key > "$key" `) dir := t.TempDir() - err := SetupSSL("demo.test", SSLOptions{Dir: dir}) + err := SetupSSL(ax7DemoDomain, SSLOptions{Dir: dir}) AssertNoError(t, err) - AssertTrue(t, CertsExist("demo.test", SSLOptions{Dir: dir})) + AssertTrue(t, CertsExist(ax7DemoDomain, SSLOptions{Dir: dir})) } func TestPHP_SetupSSL_Ugly(t *T) { bin := ax7BinPath(t) ax7Executable(t, bin, "mkcert", "if [ \"$1\" = \"-install\" ]; then exit 0; fi\nexit 2\n") - err := SetupSSL("demo.test", SSLOptions{Dir: t.TempDir()}) + err := SetupSSL(ax7DemoDomain, SSLOptions{Dir: t.TempDir()}) AssertError(t, err, "failed to generate certificates") } func TestPHP_SetupSSLIfNeeded_Ugly(t *T) { dir := t.TempDir() ax7WriteFile(t, filepath.Join(dir, "demo.test.pem"), "cert") - _, _, err := SetupSSLIfNeeded("demo.test", SSLOptions{Dir: dir}) + _, _, err := SetupSSLIfNeeded(ax7DemoDomain, SSLOptions{Dir: dir}) AssertError(t, err) } @@ -868,14 +862,14 @@ func TestPHP_IsMkcertInstalled_Bad(t *T) { func TestPHP_IsMkcertInstalled_Ugly(t *T) { bin := ax7BinPath(t) - ax7Executable(t, bin, "mkcert", "exit 0\n") + ax7Executable(t, bin, "mkcert", ax7ExitOKScript) got := IsMkcertInstalled() AssertTrue(t, got) } func TestPHP_InstallMkcertCA_Good(t *T) { bin := ax7BinPath(t) - ax7Executable(t, bin, "mkcert", "exit 0\n") + ax7Executable(t, bin, "mkcert", ax7ExitOKScript) err := InstallMkcertCA() AssertNoError(t, err) } @@ -985,13 +979,13 @@ func TestPHP_CoolifyClient_TriggerDeploy_Ugly(t *T) { defer server.Close() deployment, err := NewCoolifyClient(server.URL, "tok").TriggerDeploy(context.Background(), "app-1", true) AssertNoError(t, err) - AssertEqual(t, "deploy-1", deployment.ID) + AssertEqual(t, ax7DeployID, deployment.ID) } func TestPHP_CoolifyClient_GetDeployment_Ugly(t *T) { server := ax7CoolifyServer(t, http.StatusOK) defer server.Close() - deployment, err := NewCoolifyClient(server.URL, "tok").GetDeployment(context.Background(), "app-1", "deploy-1") + deployment, err := NewCoolifyClient(server.URL, "tok").GetDeployment(context.Background(), "app-1", ax7DeployID) AssertNoError(t, err) AssertEqual(t, "finished", deployment.Status) } @@ -1049,7 +1043,7 @@ func TestPHP_Deploy_Good(t *T) { defer server.Close() status, err := Deploy(context.Background(), DeployOptions{Dir: ax7CoolifyProject(t, server.URL)}) AssertNoError(t, err) - AssertEqual(t, "deploy-1", status.ID) + AssertEqual(t, ax7DeployID, status.ID) } func TestPHP_Deploy_Bad(t *T) { @@ -1069,7 +1063,7 @@ func TestPHP_Deploy_Ugly(t *T) { func TestPHP_DeployStatus_Good(t *T) { server := ax7CoolifyServer(t, http.StatusOK) defer server.Close() - status, err := DeployStatus(context.Background(), StatusOptions{Dir: ax7CoolifyProject(t, server.URL), DeploymentID: "deploy-1"}) + status, err := DeployStatus(context.Background(), StatusOptions{Dir: ax7CoolifyProject(t, server.URL), DeploymentID: ax7DeployID}) AssertNoError(t, err) AssertEqual(t, "finished", status.Status) } @@ -1155,7 +1149,8 @@ func TestPHP_Bridge_Port_Good(t *T) { bridge, err := NewBridge(ax7BridgeHandler{}) RequireNoError(t, err) t.Cleanup(func() { _ = bridge.Shutdown(context.Background()) }) - AssertGreater(t, bridge.Port(), 0) + port := bridge.Port() + AssertGreater(t, port, 0) } func TestPHP_Bridge_Port_Bad(t *T) { @@ -1237,7 +1232,7 @@ func TestPHP_NewHandler_Ugly(t *T) { } func TestPHP_Handler_LaravelRoot_Good(t *T) { - handler := &Handler{laravelRoot: "/app", docRoot: "/app/public"} + handler := &Handler{laravelRoot: "/app", docRoot: ax7PublicPath} got := handler.LaravelRoot() AssertEqual(t, "/app", got) } @@ -1255,9 +1250,9 @@ func TestPHP_Handler_LaravelRoot_Ugly(t *T) { } func TestPHP_Handler_DocRoot_Good(t *T) { - handler := &Handler{laravelRoot: "/app", docRoot: "/app/public"} + handler := &Handler{laravelRoot: "/app", docRoot: ax7PublicPath} got := handler.DocRoot() - AssertEqual(t, "/app/public", got) + AssertEqual(t, ax7PublicPath, got) } func TestPHP_Handler_DocRoot_Bad(t *T) { @@ -1414,9 +1409,9 @@ func TestPHP_NewRedisService_Bad(t *T) { } func TestPHP_NewRedisService_Ugly(t *T) { - service := NewRedisService(t.TempDir(), RedisOptions{Port: 6380, ConfigFile: "redis.conf"}) + service := NewRedisService(t.TempDir(), RedisOptions{Port: 6380, ConfigFile: ax7RedisConfigFile}) AssertEqual(t, 6380, service.Status().Port) - AssertEqual(t, "redis.conf", service.configFile) + AssertEqual(t, ax7RedisConfigFile, service.configFile) } func TestPHP_Service_Name_Good(t *T) { @@ -1464,7 +1459,7 @@ func TestPHP_Service_Logs_Good(t *T) { service := &baseService{name: "Log", logPath: path} reader, err := service.Logs(false) AssertNoError(t, err) - reader.Close() + _ = reader.Close() } func TestPHP_Service_Logs_Bad(t *T) { @@ -1481,7 +1476,7 @@ func TestPHP_Service_Logs_Ugly(t *T) { service := &baseService{name: "Log", logPath: path} reader, err := service.Logs(true) AssertNoError(t, err) - reader.Close() + _ = reader.Close() } func TestPHP_FrankenPHPService_Start_Good(t *T) { @@ -1660,7 +1655,7 @@ func TestPHP_ReverbService_Stop_Ugly(t *T) { } func TestPHP_RedisService_Start_Good(t *T) { - ax7LongRunningCommand(t, "redis-server") + ax7LongRunningCommand(t, ax7RedisServer) service := NewRedisService(t.TempDir(), RedisOptions{}) err := service.Start(context.Background()) t.Cleanup(func() { _ = service.Stop() }) @@ -1675,8 +1670,8 @@ func TestPHP_RedisService_Start_Bad(t *T) { } func TestPHP_RedisService_Start_Ugly(t *T) { - ax7LongRunningCommand(t, "redis-server") - service := NewRedisService(t.TempDir(), RedisOptions{ConfigFile: "redis.conf"}) + ax7LongRunningCommand(t, ax7RedisServer) + service := NewRedisService(t.TempDir(), RedisOptions{ConfigFile: ax7RedisConfigFile}) err := service.Start(context.Background()) t.Cleanup(func() { _ = service.Stop() }) AssertNoError(t, err) @@ -1696,7 +1691,7 @@ func TestPHP_RedisService_Stop_Bad(t *T) { } func TestPHP_RedisService_Stop_Ugly(t *T) { - ax7LongRunningCommand(t, "redis-server") + ax7LongRunningCommand(t, ax7RedisServer) ax7LongRunningCommand(t, "redis-cli") service := NewRedisService(t.TempDir(), RedisOptions{}) RequireNoError(t, service.Start(context.Background())) @@ -1743,7 +1738,9 @@ func TestPHP_DevServer_Stop_Bad(t *T) { func TestPHP_DevServer_Stop_Ugly(t *T) { server := NewDevServer(Options{}) server.running = true - server.cancel = func() {} + server.cancel = func() { + // Intentionally empty; this test only verifies Stop calls the hook. + } err := server.Stop() AssertNoError(t, err) } @@ -1795,7 +1792,7 @@ func TestPHP_DevServer_Services_Ugly(t *T) { } func TestPHP_Reader_Read_Good(t *T) { - path := filepath.Join(t.TempDir(), "tail.log") + path := filepath.Join(t.TempDir(), ax7TailLog) ax7WriteFile(t, path, "line") file, err := os.Open(path) RequireNoError(t, err) @@ -1807,7 +1804,7 @@ func TestPHP_Reader_Read_Good(t *T) { } func TestPHP_Reader_Read_Bad(t *T) { - path := filepath.Join(t.TempDir(), "tail.log") + path := filepath.Join(t.TempDir(), ax7TailLog) ax7WriteFile(t, path, "line") file, err := os.Open(path) RequireNoError(t, err) @@ -1819,7 +1816,7 @@ func TestPHP_Reader_Read_Bad(t *T) { } func TestPHP_Reader_Read_Ugly(t *T) { - path := filepath.Join(t.TempDir(), "tail.log") + path := filepath.Join(t.TempDir(), ax7TailLog) ax7WriteFile(t, path, "abc") file, err := os.Open(path) RequireNoError(t, err) @@ -1831,7 +1828,7 @@ func TestPHP_Reader_Read_Ugly(t *T) { } func TestPHP_Reader_Close_Good(t *T) { - path := filepath.Join(t.TempDir(), "tail.log") + path := filepath.Join(t.TempDir(), ax7TailLog) ax7WriteFile(t, path, "line") file, err := os.Open(path) RequireNoError(t, err) @@ -1841,7 +1838,7 @@ func TestPHP_Reader_Close_Good(t *T) { } func TestPHP_Reader_Close_Bad(t *T) { - path := filepath.Join(t.TempDir(), "tail.log") + path := filepath.Join(t.TempDir(), ax7TailLog) ax7WriteFile(t, path, "line") file, err := os.Open(path) RequireNoError(t, err) @@ -1852,7 +1849,7 @@ func TestPHP_Reader_Close_Bad(t *T) { } func TestPHP_Reader_Close_Ugly(t *T) { - path := filepath.Join(t.TempDir(), "tail.log") + path := filepath.Join(t.TempDir(), ax7TailLog) ax7WriteFile(t, path, "") file, err := os.Open(path) RequireNoError(t, err) @@ -1904,7 +1901,7 @@ func TestPHP_ServiceReader_Close_Ugly(t *T) { func TestPHP_LinkPackages_Ugly(t *T) { dir := ax7PHPProject(t) pkg := t.TempDir() - ax7WriteFile(t, filepath.Join(pkg, "composer.json"), `{"name":"acme/package","version":"dev-main"}`) + ax7WriteFile(t, filepath.Join(pkg, composerJSONFile), `{"name":"acme/package","version":"dev-main"}`) err := LinkPackages(dir, []string{pkg}) AssertNoError(t, err) } @@ -1912,7 +1909,7 @@ func TestPHP_LinkPackages_Ugly(t *T) { func TestPHP_UnlinkPackages_Ugly(t *T) { dir := ax7PHPProject(t) pkg := t.TempDir() - ax7WriteFile(t, filepath.Join(pkg, "composer.json"), `{"name":"acme/package"}`) + ax7WriteFile(t, filepath.Join(pkg, composerJSONFile), `{"name":"acme/package"}`) RequireNoError(t, LinkPackages(dir, []string{pkg})) err := UnlinkPackages(dir, []string{"acme/package"}) AssertNoError(t, err) @@ -1920,7 +1917,7 @@ func TestPHP_UnlinkPackages_Ugly(t *T) { func TestPHP_UpdatePackages_Ugly(t *T) { bin := ax7BinPath(t) - ax7Executable(t, bin, "composer", "exit 0\n") + ax7Executable(t, bin, "composer", ax7ExitOKScript) dir := ax7PHPProject(t) err := UpdatePackages(dir, []string{}) AssertNoError(t, err) diff --git a/pkg/php/bridge.go b/pkg/php/bridge.go index 0fc675f..9322992 100644 --- a/pkg/php/bridge.go +++ b/pkg/php/bridge.go @@ -94,5 +94,5 @@ func (b *Bridge) Shutdown(ctx context.Context) error { func bridgeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(v) + _ = json.NewEncoder(w).Encode(v) } diff --git a/pkg/php/cmd.go b/pkg/php/cmd.go index a2c90e4..99d4fdd 100644 --- a/pkg/php/cmd.go +++ b/pkg/php/cmd.go @@ -61,46 +61,7 @@ func AddPHPCommands(root *cli.Command) { Short: i18n.T("cmd.php.short"), Long: i18n.T("cmd.php.long"), PersistentPreRunE: func(cmd *cli.Command, args []string) error { - // Check if we are in a workspace root - wsRoot, err := findWorkspaceRoot() - if err != nil { - return nil // Not in a workspace, regular behavior - } - - // Load workspace config - config, err := loadWorkspaceConfig(wsRoot) - if err != nil || config == nil { - return nil // Failed to load or no config, ignore - } - - if config.Active == "" { - return nil // No active package - } - - // Calculate package path - pkgDir := config.PackagesDir - if pkgDir == "" { - pkgDir = "./packages" - } - if !filepath.IsAbs(pkgDir) { - pkgDir = filepath.Join(wsRoot, pkgDir) - } - - targetDir := filepath.Join(pkgDir, config.Active) - - // Check if target directory exists - if !getMedium().IsDir(targetDir) { - cli.Warnf("Active package directory not found: %s", targetDir) - return nil - } - - // Change working directory - if err := os.Chdir(targetDir); err != nil { - return cli.Err("failed to change directory to active package: %w", err) - } - - cli.Print("%s %s\n", dimStyle.Render("Workspace:"), config.Active) - return nil + return activateWorkspacePackage() }, } root.AddCommand(phpCmd) @@ -138,34 +99,7 @@ var registerFrankenPHP func(phpCmd *cli.Command) // AddPHPRootCommands adds PHP commands directly to root (for standalone core-php binary). func AddPHPRootCommands(root *cli.Command) { root.PersistentPreRunE = func(cmd *cli.Command, args []string) error { - wsRoot, err := findWorkspaceRoot() - if err != nil { - return nil - } - config, err := loadWorkspaceConfig(wsRoot) - if err != nil || config == nil { - return nil - } - if config.Active == "" { - return nil - } - pkgDir := config.PackagesDir - if pkgDir == "" { - pkgDir = "./packages" - } - if !filepath.IsAbs(pkgDir) { - pkgDir = filepath.Join(wsRoot, pkgDir) - } - targetDir := filepath.Join(pkgDir, config.Active) - if !getMedium().IsDir(targetDir) { - cli.Warnf("Active package directory not found: %s", targetDir) - return nil - } - if err := os.Chdir(targetDir); err != nil { - return cli.Err("failed to change directory to active package: %w", err) - } - cli.Print("%s %s\n", dimStyle.Render("Workspace:"), config.Active) - return nil + return activateWorkspacePackage() } // Development @@ -194,3 +128,49 @@ func AddPHPRootCommands(root *cli.Command) { registerFrankenPHP(root) } } + +func activateWorkspacePackage() error { + 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 err := os.Chdir(targetDir); err != nil { + return cli.Err("failed to change directory to active package: %w", 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 !filepath.IsAbs(pkgDir) { + pkgDir = filepath.Join(wsRoot, pkgDir) + } + + return filepath.Join(pkgDir, config.Active) +} diff --git a/pkg/php/cmd_build.go b/pkg/php/cmd_build.go index 110e218..72ee8fb 100644 --- a/pkg/php/cmd_build.go +++ b/pkg/php/cmd_build.go @@ -30,7 +30,7 @@ func addPHPBuildCommand(parent *cli.Command) { RunE: func(cmd *cli.Command, args []string) error { cwd, err := os.Getwd() if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) + return cli.Err(cliWrapErrorFormat, i18n.T(i18nFailGetKey, workingDirectorySubject), err) } ctx := context.Background() @@ -86,20 +86,20 @@ func runPHPBuildDocker(ctx context.Context, projectDir string, opts dockerBuildO return errors.New(i18n.T("cmd.php.error.not_php")) } - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.build.building_docker")) + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(i18n.T(cmdPHPLabelKey)), i18n.T("cmd.php.build.building_docker")) // Show detected configuration config, err := DetectDockerfileConfig(projectDir) if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.detect", "project configuration"), err) + return cli.Err(cliWrapErrorFormat, i18n.T("i18n.fail.detect", "project configuration"), err) } - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.build.php_version")), config.PHPVersion) - cli.Print("%s %v\n", dimStyle.Render(i18n.T("cmd.php.build.laravel")), config.IsLaravel) - cli.Print("%s %v\n", dimStyle.Render(i18n.T("cmd.php.build.octane")), config.HasOctane) - cli.Print("%s %v\n", dimStyle.Render(i18n.T("cmd.php.build.frontend")), config.HasAssets) + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.T("cmd.php.build.php_version")), config.PHPVersion) + cli.Print(cliLabelBoolFormat, dimStyle.Render(i18n.T("cmd.php.build.laravel")), config.IsLaravel) + cli.Print(cliLabelBoolFormat, dimStyle.Render(i18n.T("cmd.php.build.octane")), config.HasOctane) + cli.Print(cliLabelBoolFormat, dimStyle.Render(i18n.T("cmd.php.build.frontend")), config.HasAssets) if len(config.PHPExtensions) > 0 { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.build.extensions")), strings.Join(config.PHPExtensions, ", ")) + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.T("cmd.php.build.extensions")), strings.Join(config.PHPExtensions, ", ")) } cli.Blank() @@ -129,15 +129,15 @@ func runPHPBuildDocker(ctx context.Context, projectDir string, opts dockerBuildO cli.Print("%s %s:%s\n", dimStyle.Render(i18n.Label("image")), buildOpts.ImageName, buildOpts.Tag) if opts.Platform != "" { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.build.platform")), opts.Platform) + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.T("cmd.php.build.platform")), opts.Platform) } cli.Blank() if err := BuildDocker(ctx, buildOpts); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.build"), err) + return cli.Err(cliWrapErrorFormat, i18n.T("i18n.fail.build"), err) } - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Docker image built"})) + cli.Print(cliSectionLabelValueFormat, successStyle.Render(i18n.Label("done")), i18n.T("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(i18n.T("cmd.php.build.docker_run_with")), buildOpts.ImageName, buildOpts.Tag) @@ -150,7 +150,7 @@ func runPHPBuildLinuxKit(ctx context.Context, projectDir string, opts linuxKitBu return errors.New(i18n.T("cmd.php.error.not_php")) } - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.build.building_linuxkit")) + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(i18n.T(cmdPHPLabelKey)), i18n.T("cmd.php.build.building_linuxkit")) buildOpts := LinuxKitBuildOptions{ ProjectDir: projectDir, @@ -164,18 +164,18 @@ func runPHPBuildLinuxKit(ctx context.Context, projectDir string, opts linuxKitBu buildOpts.Format = "qcow2" } if buildOpts.Template == "" { - buildOpts.Template = "server-php" + buildOpts.Template = defaultLinuxKitTemplateName } - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("template")), buildOpts.Template) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.build.format")), buildOpts.Format) + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.Label("template")), buildOpts.Template) + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.T("cmd.php.build.format")), buildOpts.Format) cli.Blank() if err := BuildLinuxKit(ctx, buildOpts); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.build"), err) + return cli.Err(cliWrapErrorFormat, i18n.T("i18n.fail.build"), err) } - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "LinuxKit image built"})) + cli.Print(cliSectionLabelValueFormat, successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "LinuxKit image built"})) return nil } @@ -195,19 +195,9 @@ func addPHPServeCommand(parent *cli.Command) { Short: i18n.T("cmd.php.serve.short"), Long: i18n.T("cmd.php.serve.long"), RunE: func(cmd *cli.Command, args []string) error { - imageName := serveImageName - if imageName == "" { - // Try to detect from current directory - cwd, err := os.Getwd() - if err == nil { - imageName = GetLaravelAppName(cwd) - if imageName != "" { - imageName = strings.ToLower(strings.ReplaceAll(imageName, " ", "-")) - } - } - if imageName == "" { - return errors.New(i18n.T("cmd.php.serve.name_required")) - } + imageName, err := resolveServeImageName() + if err != nil { + return err } ctx := context.Background() @@ -223,33 +213,20 @@ func addPHPServeCommand(parent *cli.Command) { Output: os.Stdout, } - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.ProgressSubject("run", "production container")) - cli.Print("%s %s:%s\n", dimStyle.Render(i18n.Label("image")), imageName, func() string { - if serveTag == "" { - return "latest" - } - return serveTag - }()) - - effectivePort := servePort - if effectivePort == 0 { - effectivePort = 80 - } - effectiveHTTPSPort := serveHTTPSPort - if effectiveHTTPSPort == 0 { - effectiveHTTPSPort = 443 - } + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(i18n.T(cmdPHPLabelKey)), i18n.ProgressSubject("run", "production container")) + cli.Print("%s %s:%s\n", dimStyle.Render(i18n.Label("image")), imageName, displayServeTag()) + effectivePort, effectiveHTTPSPort := effectiveServePorts() cli.Print("%s http://localhost:%d, https://localhost:%d\n", dimStyle.Render("Ports:"), effectivePort, effectiveHTTPSPort) cli.Blank() if err := ServeProduction(ctx, opts); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.start", "container"), err) + return cli.Err(cliWrapErrorFormat, i18n.T("i18n.fail.start", "container"), err) } if !serveDetach { - cli.Print("\n%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.serve.stopped")) + cli.Print(cliSectionLabelValueFormat, dimStyle.Render(i18n.T(cmdPHPLabelKey)), i18n.T("cmd.php.serve.stopped")) } return nil @@ -267,6 +244,42 @@ func addPHPServeCommand(parent *cli.Command) { parent.AddCommand(serveCmd) } +func resolveServeImageName() (string, error) { + if serveImageName != "" { + return serveImageName, nil + } + + cwd, err := os.Getwd() + if err == nil { + if appName := GetLaravelAppName(cwd); appName != "" { + return strings.ToLower(strings.ReplaceAll(appName, " ", "-")), nil + } + } + + return "", errors.New(i18n.T("cmd.php.serve.name_required")) +} + +func displayServeTag() string { + if serveTag == "" { + return "latest" + } + return serveTag +} + +func effectiveServePorts() (int, int) { + effectivePort := servePort + if effectivePort == 0 { + effectivePort = 80 + } + + effectiveHTTPSPort := serveHTTPSPort + if effectiveHTTPSPort == 0 { + effectiveHTTPSPort = 443 + } + + return effectivePort, effectiveHTTPSPort +} + func addPHPShellCommand(parent *cli.Command) { shellCmd := &cli.Command{ Use: "shell [container]", @@ -276,10 +289,10 @@ func addPHPShellCommand(parent *cli.Command) { RunE: func(cmd *cli.Command, args []string) error { ctx := context.Background() - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.shell.opening", map[string]interface{}{"Container": args[0]})) + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.T(cmdPHPLabelKey)), i18n.T("cmd.php.shell.opening", map[string]interface{}{"Container": args[0]})) if err := Shell(ctx, args[0]); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.open", "shell"), err) + return cli.Err(cliWrapErrorFormat, i18n.T("i18n.fail.open", "shell"), err) } return nil diff --git a/pkg/php/cmd_ci.go b/pkg/php/cmd_ci.go index a05afd5..0323e5e 100644 --- a/pkg/php/cmd_ci.go +++ b/pkg/php/cmd_ci.go @@ -65,6 +65,12 @@ type CISummary struct { Skipped int `json:"skipped"` } +type ciCheckDefinition struct { + name string + run func(context.Context, string) (CICheckResult, error) + sarif bool +} + func addPHPCICommand(parent *cli.Command) { ciCmd := &cli.Command{ Use: "ci", @@ -87,7 +93,7 @@ func addPHPCICommand(parent *cli.Command) { func runPHPCI() error { cwd, err := os.Getwd() if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) + return cli.Err(cliWrapErrorFormat, i18n.T(i18nFailGetKey, workingDirectorySubject), err) } if !IsPHPProject(cwd) { @@ -97,12 +103,26 @@ func runPHPCI() error { startTime := time.Now() ctx := context.Background() - // Define checks to run in order - checks := []struct { - name string - run func(context.Context, string) (CICheckResult, error) - sarif bool // Whether this check can generate SARIF - }{ + 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}, @@ -110,133 +130,169 @@ func runPHPCI() error { {"audit", runCIAudit, false}, {"security", runCISecurity, false}, } +} - result := CIResult{ - StartedAt: startTime, - Passed: true, - Checks: make([]CICheckResult, 0, len(checks)), +func printCIHeader() { + if ciJSON { + return } + cli.Print(cliSingleLineFormat, cli.BoldStyle.Render("core php ci - QA Pipeline")) + cli.Print("%s\n\n", strings.Repeat("─", 40)) +} + +func runCIChecks(ctx context.Context, cwd string, checks []ciCheckDefinition, result *CIResult) []string { var artifacts []string - // Print header unless JSON output + 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("\n%s\n", cli.BoldStyle.Render("core php ci - QA Pipeline")) - cli.Print("%s\n\n", strings.Repeat("─", 40)) + cli.Print(" %s %s...", dimStyle.Render("→"), name) } +} - // Run each check - for _, check := range checks { - if !ciJSON { - cli.Print(" %s %s...", dimStyle.Render("→"), check.name) - } +func executeCICheck(ctx context.Context, cwd string, check ciCheckDefinition) CICheckResult { + checkResult, err := check.run(ctx, cwd) + if err == nil { + return checkResult + } - checkResult, err := check.run(ctx, cwd) - if err != nil { - checkResult = CICheckResult{ - Name: check.name, - Status: "failed", - Details: err.Error(), - } - } + return CICheckResult{ + Name: check.name, + Status: "failed", + Details: err.Error(), + } +} - result.Checks = append(result.Checks, checkResult) +func recordCICheckResult(result *CIResult, checkResult CICheckResult) { + result.Checks = append(result.Checks, checkResult) + result.Summary.Total++ - // Update summary - 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++ + 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++ + } +} - // Print result - if !ciJSON { - cli.Print("\r %s %s %s\n", getStatusIcon(checkResult.Status), check.name, dimStyle.Render(checkResult.Details)) - } +func printCICheckResult(name string, checkResult CICheckResult) { + if !ciJSON { + cli.Print("\r %s %s %s\n", getStatusIcon(checkResult.Status), name, dimStyle.Render(checkResult.Details)) + } +} - // Generate SARIF if requested - if (ciSARIF || ciUploadSARIF) && check.sarif { - sarifFile := filepath.Join(cwd, check.name+".sarif") - if generateSARIF(ctx, cwd, check.name, sarifFile) == nil { - artifacts = append(artifacts, sarifFile) - } - } +func appendCIArtifact(ctx context.Context, cwd string, check ciCheckDefinition, artifacts []string) []string { + if !shouldGenerateSARIF(check) { + return artifacts } - result.Duration = time.Since(startTime).Round(time.Millisecond).String() - result.Artifacts = artifacts + sarifFile := filepath.Join(cwd, check.name+".sarif") + if generateSARIF(ctx, cwd, check.name, sarifFile) == nil { + return append(artifacts, sarifFile) + } - // Set exit code - if result.Passed { - result.ExitCode = 0 - } else { - result.ExitCode = 1 + return artifacts +} + +func shouldGenerateSARIF(check ciCheckDefinition) bool { + return (ciSARIF || ciUploadSARIF) && check.sarif +} + +func ciExitCode(passed bool) int { + if passed { + return 0 } + return 1 +} - // Output based on flags +func outputCIResult(ctx context.Context, cwd string, result CIResult, artifacts []string) error { if ciJSON { - if err := outputCIJSON(result); err != nil { - return err - } - if !result.Passed { - return cli.Exit(result.ExitCode, cli.Err("CI pipeline failed")) - } - return nil + return outputCIJSONResult(result) } - if ciSummary { - if err := outputCISummary(result); err != nil { - return err - } - if !result.Passed { - return cli.Err("CI pipeline failed") - } - return nil + return outputCISummaryResult(result) + } + return outputCIDefault(ctx, cwd, result, artifacts) +} + +func outputCIJSONResult(result CIResult) error { + if err := outputCIJSON(result); err != nil { + return err + } + if !result.Passed { + return cli.Exit(result.ExitCode, cli.Err(ciPipelineFailedMessage)) + } + return nil +} + +func outputCISummaryResult(result CIResult) error { + if err := outputCISummary(result); err != nil { + return err + } + if !result.Passed { + return cli.Err(ciPipelineFailedMessage) } + return nil +} - // Default table output - cli.Print("\n%s\n", strings.Repeat("─", 40)) +func outputCIDefault(ctx context.Context, cwd string, result CIResult, artifacts []string) error { + cli.Print(cliSingleLineFormat, strings.Repeat("─", 40)) if result.Passed { - cli.Print("%s %s\n", successStyle.Render("✓ CI PASSED"), dimStyle.Render(result.Duration)) + cli.Print(cliLabelValueFormat, successStyle.Render("✓ CI PASSED"), dimStyle.Render(result.Duration)) } else { - cli.Print("%s %s\n", errorStyle.Render("✗ CI FAILED"), dimStyle.Render(result.Duration)) + cli.Print(cliLabelValueFormat, errorStyle.Render("✗ CI FAILED"), dimStyle.Render(result.Duration)) } if len(artifacts) > 0 { - cli.Print("\n%s\n", dimStyle.Render("Artifacts:")) + cli.Print(cliSingleLineFormat, dimStyle.Render("Artifacts:")) for _, a := range artifacts { cli.Print(" → %s\n", filepath.Base(a)) } } // Upload SARIF if requested - if ciUploadSARIF && len(artifacts) > 0 { - cli.Blank() - for _, sarifFile := range artifacts { - if err := uploadSARIFToGitHub(ctx, sarifFile); err != nil { - cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), filepath.Base(sarifFile), err) - } else { - cli.Print(" %s %s uploaded\n", successStyle.Render("✓"), filepath.Base(sarifFile)) - } - } - } + uploadCIArtifacts(ctx, artifacts) if !result.Passed { - return cli.Err("CI pipeline failed") + return cli.Err(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("✗"), filepath.Base(sarifFile), err) + } else { + cli.Print(" %s %s uploaded\n", successStyle.Render("✓"), filepath.Base(sarifFile)) + } + } +} + // runCITest runs Pest/PHPUnit tests func runCITest(ctx context.Context, dir string) (CICheckResult, error) { start := time.Now() @@ -473,10 +529,10 @@ func outputCISummary(result CIResult) error { case "skipped": icon = "⏭️" } - sb.WriteString(fmt.Sprintf("| %s | %s | %s |\n", check.Name, icon, check.Details)) + fmt.Fprintf(&sb, "| %s | %s | %s |\n", check.Name, icon, check.Details) } - sb.WriteString(fmt.Sprintf("\n**Duration:** %s\n", result.Duration)) + fmt.Fprintf(&sb, "\n**Duration:** %s\n", result.Duration) fmt.Print(sb.String()) return nil diff --git a/pkg/php/cmd_deploy.go b/pkg/php/cmd_deploy.go index 802aa20..d01c812 100644 --- a/pkg/php/cmd_deploy.go +++ b/pkg/php/cmd_deploy.go @@ -44,7 +44,7 @@ func addPHPDeployCommand(parent *cli.Command) { RunE: func(cmd *cli.Command, args []string) error { cwd, err := os.Getwd() if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) + return cli.Err(cliWrapErrorFormat, i18n.T(i18nFailGetKey, workingDirectorySubject), err) } env := EnvProduction @@ -52,7 +52,7 @@ func addPHPDeployCommand(parent *cli.Command) { env = EnvStaging } - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.deploy")), i18n.T("cmd.php.deploy.deploying", map[string]interface{}{"Environment": env})) + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(i18n.T(cmdPHPDeployLabelKey)), i18n.T("cmd.php.deploy.deploying", map[string]interface{}{"Environment": env})) ctx := context.Background() @@ -65,19 +65,19 @@ func addPHPDeployCommand(parent *cli.Command) { status, err := Deploy(ctx, opts) if err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.deploy_failed"), err) + return cli.Err(cliWrapErrorFormat, i18n.T("cmd.php.error.deploy_failed"), err) } printDeploymentStatus(status) if deployWait { if IsDeploymentSuccessful(status.Status) { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Deployment completed"})) + cli.Print(cliSectionLabelValueFormat, successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Deployment completed"})) } else { - cli.Print("\n%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("cmd.php.deploy.warning_status", map[string]interface{}{"Status": status.Status})) + cli.Print(cliSectionLabelValueFormat, errorStyle.Render(i18n.Label("warning")), i18n.T("cmd.php.deploy.warning_status", map[string]interface{}{"Status": status.Status})) } } else { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.deploy.triggered")) + cli.Print(cliSectionLabelValueFormat, successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.deploy.triggered")) } return nil @@ -104,7 +104,7 @@ func addPHPDeployStatusCommand(parent *cli.Command) { RunE: func(cmd *cli.Command, args []string) error { cwd, err := os.Getwd() if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) + return cli.Err(cliWrapErrorFormat, i18n.T(i18nFailGetKey, workingDirectorySubject), err) } env := EnvProduction @@ -112,7 +112,7 @@ func addPHPDeployStatusCommand(parent *cli.Command) { env = EnvStaging } - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.deploy")), i18n.ProgressSubject("check", "deployment status")) + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(i18n.T(cmdPHPDeployLabelKey)), i18n.ProgressSubject("check", "deployment status")) ctx := context.Background() @@ -124,7 +124,7 @@ func addPHPDeployStatusCommand(parent *cli.Command) { status, err := DeployStatus(ctx, opts) if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "status"), err) + return cli.Err(cliWrapErrorFormat, i18n.T(i18nFailGetKey, "status"), err) } printDeploymentStatus(status) @@ -153,7 +153,7 @@ func addPHPDeployRollbackCommand(parent *cli.Command) { RunE: func(cmd *cli.Command, args []string) error { cwd, err := os.Getwd() if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) + return cli.Err(cliWrapErrorFormat, i18n.T(i18nFailGetKey, workingDirectorySubject), err) } env := EnvProduction @@ -161,7 +161,7 @@ func addPHPDeployRollbackCommand(parent *cli.Command) { env = EnvStaging } - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.deploy")), i18n.T("cmd.php.deploy_rollback.rolling_back", map[string]interface{}{"Environment": env})) + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(i18n.T(cmdPHPDeployLabelKey)), i18n.T("cmd.php.deploy_rollback.rolling_back", map[string]interface{}{"Environment": env})) ctx := context.Background() @@ -174,19 +174,19 @@ func addPHPDeployRollbackCommand(parent *cli.Command) { status, err := Rollback(ctx, opts) if err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.rollback_failed"), err) + return cli.Err(cliWrapErrorFormat, i18n.T("cmd.php.error.rollback_failed"), err) } printDeploymentStatus(status) if rollbackWait { if IsDeploymentSuccessful(status.Status) { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Rollback completed"})) + cli.Print(cliSectionLabelValueFormat, successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Rollback completed"})) } else { - cli.Print("\n%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("cmd.php.deploy_rollback.warning_status", map[string]interface{}{"Status": status.Status})) + cli.Print(cliSectionLabelValueFormat, errorStyle.Render(i18n.Label("warning")), i18n.T("cmd.php.deploy_rollback.warning_status", map[string]interface{}{"Status": status.Status})) } } else { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.deploy_rollback.triggered")) + cli.Print(cliSectionLabelValueFormat, successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.deploy_rollback.triggered")) } return nil @@ -213,7 +213,7 @@ func addPHPDeployListCommand(parent *cli.Command) { RunE: func(cmd *cli.Command, args []string) error { cwd, err := os.Getwd() if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) + return cli.Err(cliWrapErrorFormat, i18n.T(i18nFailGetKey, workingDirectorySubject), err) } env := EnvProduction @@ -226,17 +226,17 @@ func addPHPDeployListCommand(parent *cli.Command) { limit = 10 } - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.deploy")), i18n.T("cmd.php.deploy_list.recent", map[string]interface{}{"Environment": env})) + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(i18n.T(cmdPHPDeployLabelKey)), i18n.T("cmd.php.deploy_list.recent", map[string]interface{}{"Environment": env})) ctx := context.Background() deployments, err := ListDeployments(ctx, cwd, env, limit) if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.list", "deployments"), err) + return cli.Err(cliWrapErrorFormat, i18n.T("i18n.fail.list", "deployments"), err) } if len(deployments) == 0 { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.info")), i18n.T("cmd.php.deploy_list.none_found")) + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.T("cmd.php.label.info")), i18n.T("cmd.php.deploy_list.none_found")) return nil } @@ -255,55 +255,27 @@ func addPHPDeployListCommand(parent *cli.Command) { } func printDeploymentStatus(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 - } - - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("status")), statusStyle.Render(status.Status)) - - if status.ID != "" { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.id")), status.ID) - } - + statusStyle := deploymentStatusStyle(status.Status) + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.Label("status")), statusStyle.Render(status.Status)) + printDeploymentField(i18n.T("cmd.php.label.id"), status.ID) if status.URL != "" { - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("url")), linkStyle.Render(status.URL)) - } - - if status.Branch != "" { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.branch")), status.Branch) + printDeploymentField(i18n.Label("url"), linkStyle.Render(status.URL)) } + printDeploymentField(i18n.T("cmd.php.label.branch"), status.Branch) if status.Commit != "" { - commit := status.Commit - if len(commit) > 7 { - commit = commit[:7] - } - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.commit")), commit) + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.T("cmd.php.label.commit")), truncateString(status.Commit, 7)) if status.CommitMessage != "" { - // Truncate long messages - msg := status.CommitMessage - if len(msg) > 60 { - msg = msg[:57] + "..." - } - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.message")), msg) + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.T("cmd.php.label.message")), ellipsizeString(status.CommitMessage, 60)) } } if !status.StartedAt.IsZero() { - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("started")), status.StartedAt.Format(time.RFC3339)) + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.Label("started")), status.StartedAt.Format(time.RFC3339)) } if !status.CompletedAt.IsZero() { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.completed")), status.CompletedAt.Format(time.RFC3339)) - if !status.StartedAt.IsZero() { - duration := status.CompletedAt.Sub(status.StartedAt) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.duration")), duration.Round(time.Second)) - } + printDeploymentCompletion(status) } } @@ -358,3 +330,42 @@ func printDeploymentSummary(index int, status *DeploymentStatus) { 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(i18n.T("cmd.php.label.completed")), status.CompletedAt.Format(time.RFC3339)) + if !status.StartedAt.IsZero() { + duration := status.CompletedAt.Sub(status.StartedAt) + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.T("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/pkg/php/cmd_dev.go b/pkg/php/cmd_dev.go index 42393a2..2aef5b9 100644 --- a/pkg/php/cmd_dev.go +++ b/pkg/php/cmd_dev.go @@ -74,29 +74,66 @@ func runPHPDev(opts phpDevOptions) error { return errors.New(i18n.T("cmd.php.error.not_laravel")) } - // Get app name for display - appName := GetLaravelAppName(cwd) - if appName == "" { - appName = "Laravel" - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.dev.starting", map[string]interface{}{"AppName": appName})) + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(i18n.T(cmdPHPLabelKey)), i18n.T("cmd.php.dev.starting", map[string]interface{}{"AppName": laravelDisplayName(cwd)})) // Detect services services := DetectServices(cwd) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.services")), i18n.T("cmd.php.dev.detected_services")) + 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 cli.Err(cliWrapErrorFormat, i18n.T("i18n.fail.start", "services"), err) + } + + // Print status + printDevServerReady(cwd, opts, devOpts.FrankenPHPPort, services, server) + + cli.Print("\n%s\n\n", dimStyle.Render(i18n.T("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(i18n.Label("error")), i18n.T("cmd.php.dev.stop_error", map[string]interface{}{"Error": err})) + } + + cli.Print(cliLabelValueFormat, successStyle.Render(i18n.Label("done")), i18n.T("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(i18n.T("cmd.php.label.services")), i18n.T("cmd.php.dev.detected_services")) for _, svc := range services { - cli.Print(" %s %s\n", successStyle.Render("*"), svc) + cli.Print(cliIndentedLabelValueFormat, successStyle.Render("*"), svc) } cli.Blank() +} - // Setup options +func makeDevServerOptions(cwd string, opts phpDevOptions) Options { port := opts.Port if port == 0 { port = 8000 } - devOpts := Options{ + return Options{ Dir: cwd, NoVite: opts.NoVite, NoHorizon: opts.NoHorizon, @@ -106,77 +143,58 @@ func runPHPDev(opts phpDevOptions) error { Domain: opts.Domain, FrankenPHPPort: port, } +} - // Create and start dev server - server := NewDevServer(devOpts) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Handle shutdown signals +func notifyDevShutdown(cancel context.CancelFunc) { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { <-sigCh - cli.Print("\n%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.dev.shutting_down")) + cli.Print(cliSectionLabelValueFormat, dimStyle.Render(i18n.T(cmdPHPLabelKey)), i18n.T("cmd.php.dev.shutting_down")) cancel() }() +} - if err := server.Start(ctx, devOpts); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.start", "services"), err) - } - - // Print status - cli.Print("%s %s\n", successStyle.Render(i18n.T("cmd.php.label.running")), i18n.T("cmd.php.dev.services_started")) +func printDevServerReady(cwd string, opts phpDevOptions, port int, services []DetectedService, server *DevServer) { + cli.Print(cliLabelValueFormat, successStyle.Render(i18n.T("cmd.php.label.running")), i18n.T("cmd.php.dev.services_started")) printServiceStatuses(server.Status()) cli.Blank() + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.T("cmd.php.label.app_url")), linkStyle.Render(devAppURL(cwd, opts, port))) - // Print URLs - appURL := GetLaravelAppURL(cwd) - if appURL == "" { - if opts.HTTPS { - appURL = cli.Sprintf("https://localhost:%d", port) - } else { - appURL = cli.Sprintf("http://localhost:%d", port) - } - } - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.app_url")), linkStyle.Render(appURL)) - - // Check for Vite if !opts.NoVite && containsService(services, ServiceVite) { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.vite")), linkStyle.Render("http://localhost:5173")) + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.T("cmd.php.label.vite")), linkStyle.Render("http://localhost:5173")) } +} - cli.Print("\n%s\n\n", dimStyle.Render(i18n.T("cmd.php.dev.press_ctrl_c"))) +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) +} - // Stream unified logs +func streamDevLogs(ctx context.Context, server *DevServer) { logsReader, err := server.Logs("", true) if err != nil { - cli.Print("%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("i18n.fail.get", "logs")) - } else { - defer func() { _ = logsReader.Close() }() - - scanner := bufio.NewScanner(logsReader) - for scanner.Scan() { - select { - case <-ctx.Done(): - goto shutdown - default: - line := scanner.Text() - printColoredLog(line) - } - } + cli.Print(cliLabelValueFormat, errorStyle.Render(i18n.Label("warning")), i18n.T(i18nFailGetKey, "logs")) + return } + defer func() { _ = logsReader.Close() }() -shutdown: - // Stop services - if err := server.Stop(); err != nil { - cli.Print("%s %s\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.dev.stop_error", map[string]interface{}{"Error": err})) + scanner := bufio.NewScanner(logsReader) + for scanner.Scan() { + select { + case <-ctx.Done(): + return + default: + printColoredLog(scanner.Text()) + } } - - cli.Print("%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.dev.all_stopped")) - return nil } var ( @@ -215,7 +233,7 @@ func runPHPLogs(service string, follow bool) error { logsReader, err := server.Logs(service, follow) if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "logs"), err) + return cli.Err(cliWrapErrorFormat, i18n.T(i18nFailGetKey, "logs"), err) } defer func() { _ = logsReader.Close() }() @@ -262,16 +280,16 @@ func runPHPStop() error { return err } - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.stop.stopping")) + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.T(cmdPHPLabelKey)), i18n.T("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 cli.Err("%s: %w", i18n.T("i18n.fail.stop", "services"), err) + return cli.Err(cliWrapErrorFormat, i18n.T("i18n.fail.stop", "services"), err) } - cli.Print("%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.dev.all_stopped")) + cli.Print(cliLabelValueFormat, successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.dev.all_stopped")) return nil } @@ -302,24 +320,24 @@ func runPHPStatus() error { appName = "Laravel" } - cli.Print("%s %s\n\n", dimStyle.Render(i18n.Label("project")), appName) + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(i18n.Label("project")), appName) // Detect available services services := DetectServices(cwd) cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.php.status.detected_services"))) for _, svc := range services { style := getServiceStyle(string(svc)) - cli.Print(" %s %s\n", style.Render("*"), svc) + cli.Print(cliIndentedLabelValueFormat, style.Render("*"), svc) } cli.Blank() // Package manager pm := DetectPackageManager(cwd) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.status.package_manager")), pm) + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.T("cmd.php.status.package_manager")), pm) // FrankenPHP status if IsFrankenPHPProject(cwd) { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.status.octane_server")), "FrankenPHP") + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.T("cmd.php.status.octane_server")), "FrankenPHP") } // SSL status @@ -327,9 +345,9 @@ func runPHPStatus() error { if appURL != "" { domain := ExtractDomainFromURL(appURL) if CertsExist(domain, SSLOptions{}) { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.status.ssl_certs")), successStyle.Render(i18n.T("cmd.php.status.ssl_installed"))) + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.T("cmd.php.status.ssl_certs")), successStyle.Render(i18n.T("cmd.php.status.ssl_installed"))) } else { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.status.ssl_certs")), dimStyle.Render(i18n.T("cmd.php.status.ssl_not_setup"))) + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.T("cmd.php.status.ssl_certs")), dimStyle.Render(i18n.T("cmd.php.status.ssl_not_setup"))) } } @@ -371,35 +389,35 @@ func runPHPSSL(domain string) error { // Check if mkcert is installed if !IsMkcertInstalled() { - cli.Print("%s %s\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.ssl.mkcert_not_installed")) - cli.Print("\n%s\n", i18n.T("common.hint.install_with")) + cli.Print(cliLabelValueFormat, errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.ssl.mkcert_not_installed")) + cli.Print(cliSingleLineFormat, i18n.T("common.hint.install_with")) cli.Print(" %s\n", i18n.T("cmd.php.ssl.install_macos")) cli.Print(" %s\n", i18n.T("cmd.php.ssl.install_linux")) return errors.New(i18n.T("cmd.php.error.mkcert_not_installed")) } - cli.Print("%s %s\n", dimStyle.Render("SSL:"), i18n.T("cmd.php.ssl.setting_up", map[string]interface{}{"Domain": domain})) + cli.Print(cliLabelValueFormat, dimStyle.Render("SSL:"), i18n.T("cmd.php.ssl.setting_up", map[string]interface{}{"Domain": domain})) // Check if certs already exist if CertsExist(domain, SSLOptions{}) { - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("skip")), i18n.T("cmd.php.ssl.certs_exist")) + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.Label("skip")), i18n.T("cmd.php.ssl.certs_exist")) certFile, keyFile, _ := CertPaths(domain, SSLOptions{}) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.ssl.cert_label")), certFile) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.ssl.key_label")), keyFile) + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.T("cmd.php.ssl.cert_label")), certFile) + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.T("cmd.php.ssl.key_label")), keyFile) return nil } // Setup SSL if err := SetupSSL(domain, SSLOptions{}); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.setup", "SSL"), err) + return cli.Err(cliWrapErrorFormat, i18n.T("i18n.fail.setup", "SSL"), err) } certFile, keyFile, _ := CertPaths(domain, SSLOptions{}) - cli.Print("%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.ssl.certs_created")) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.ssl.cert_label")), certFile) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.ssl.key_label")), keyFile) + cli.Print(cliLabelValueFormat, successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.ssl.certs_created")) + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.T("cmd.php.ssl.cert_label")), certFile) + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.T("cmd.php.ssl.key_label")), keyFile) return nil } @@ -425,7 +443,7 @@ func printServiceStatuses(statuses []ServiceStatus) { statusText = phpStatusStopped.Render(i18n.T("cmd.php.status.stopped")) } - cli.Print(" %s %s\n", style.Render(s.Name+":"), statusText) + cli.Print(cliIndentedLabelValueFormat, style.Render(s.Name+":"), statusText) } } @@ -458,7 +476,7 @@ func printColoredLog(line string) { line = strings.TrimPrefix(line, "[Redis] ") } else { // Unknown service, print as-is - cli.Print("%s %s\n", dimStyle.Render(timestamp), line) + cli.Print(cliLabelValueFormat, dimStyle.Render(timestamp), line) return } diff --git a/pkg/php/cmd_packages.go b/pkg/php/cmd_packages.go index 5f5b2e4..6fca7af 100644 --- a/pkg/php/cmd_packages.go +++ b/pkg/php/cmd_packages.go @@ -30,16 +30,16 @@ func addPHPPackagesLinkCommand(parent *cli.Command) { RunE: func(cmd *cli.Command, args []string) error { cwd, err := os.Getwd() if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) + return cli.Err(cliWrapErrorFormat, i18n.T(i18nFailGetKey, workingDirectorySubject), err) } - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.link.linking")) + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(i18n.T(cmdPHPLabelKey)), i18n.T("cmd.php.packages.link.linking")) if err := LinkPackages(cwd, args); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.link", "packages"), err) + return cli.Err(cliWrapErrorFormat, i18n.T("i18n.fail.link", "packages"), err) } - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.packages.link.done")) + cli.Print(cliSectionLabelValueFormat, successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.packages.link.done")) return nil }, } @@ -56,16 +56,16 @@ func addPHPPackagesUnlinkCommand(parent *cli.Command) { RunE: func(cmd *cli.Command, args []string) error { cwd, err := os.Getwd() if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) + return cli.Err(cliWrapErrorFormat, i18n.T(i18nFailGetKey, workingDirectorySubject), err) } - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.unlink.unlinking")) + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(i18n.T(cmdPHPLabelKey)), i18n.T("cmd.php.packages.unlink.unlinking")) if err := UnlinkPackages(cwd, args); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.unlink", "packages"), err) + return cli.Err(cliWrapErrorFormat, i18n.T("i18n.fail.unlink", "packages"), err) } - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.packages.unlink.done")) + cli.Print(cliSectionLabelValueFormat, successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.packages.unlink.done")) return nil }, } @@ -81,16 +81,16 @@ func addPHPPackagesUpdateCommand(parent *cli.Command) { RunE: func(cmd *cli.Command, args []string) error { cwd, err := os.Getwd() if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) + return cli.Err(cliWrapErrorFormat, i18n.T(i18nFailGetKey, workingDirectorySubject), err) } - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.update.updating")) + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(i18n.T(cmdPHPLabelKey)), i18n.T("cmd.php.packages.update.updating")) if err := UpdatePackages(cwd, args); err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.update_packages"), err) + return cli.Err(cliWrapErrorFormat, i18n.T("cmd.php.error.update_packages"), err) } - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.packages.update.done")) + cli.Print(cliSectionLabelValueFormat, successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.packages.update.done")) return nil }, } @@ -106,20 +106,20 @@ func addPHPPackagesListCommand(parent *cli.Command) { RunE: func(cmd *cli.Command, args []string) error { cwd, err := os.Getwd() if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) + return cli.Err(cliWrapErrorFormat, i18n.T(i18nFailGetKey, workingDirectorySubject), err) } packages, err := ListLinkedPackages(cwd) if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.list", "packages"), err) + return cli.Err(cliWrapErrorFormat, i18n.T("i18n.fail.list", "packages"), err) } if len(packages) == 0 { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.list.none_found")) + cli.Print(cliLabelValueFormat, dimStyle.Render(i18n.T(cmdPHPLabelKey)), i18n.T("cmd.php.packages.list.none_found")) return nil } - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.list.linked")) + cli.Print(cliLabelValueBlankFormat, dimStyle.Render(i18n.T(cmdPHPLabelKey)), i18n.T("cmd.php.packages.list.linked")) for _, pkg := range packages { name := pkg.Name diff --git a/pkg/php/cmd_serve_frankenphp.go b/pkg/php/cmd_serve_frankenphp.go index ac479ad..c0c3412 100644 --- a/pkg/php/cmd_serve_frankenphp.go +++ b/pkg/php/cmd_serve_frankenphp.go @@ -121,5 +121,7 @@ type execResponseWriter struct { } func (w *execResponseWriter) Header() http.Header { return http.Header{} } -func (w *execResponseWriter) WriteHeader(statusCode int) {} 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/pkg/php/cmd_serve_frankenphp_stub.go b/pkg/php/cmd_serve_frankenphp_stub.go index 2e80f1d..13df030 100644 --- a/pkg/php/cmd_serve_frankenphp_stub.go +++ b/pkg/php/cmd_serve_frankenphp_stub.go @@ -13,5 +13,7 @@ type execResponseWriter struct { } func (w *execResponseWriter) Header() http.Header { return http.Header{} } -func (w *execResponseWriter) WriteHeader(_ int) {} 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/pkg/php/container.go b/pkg/php/container.go index cfe02a6..039ad0d 100644 --- a/pkg/php/container.go +++ b/pkg/php/container.go @@ -50,7 +50,7 @@ type LinuxKitBuildOptions struct { // Format is the output format: "iso", "qcow2", "raw", "vmdk". Format string - // Template is the LinuxKit template name (default: "server-php"). + // Template is the LinuxKit template name (default: server-php). Template string // Variables are template variables to apply. @@ -92,17 +92,43 @@ type ServeOptions struct { // BuildDocker builds a Docker image for the PHP project. func BuildDocker(ctx context.Context, opts DockerBuildOptions) error { + 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 cli.Wrap(err, "docker build failed") + } + + return nil +} + +func normalizeDockerBuildOptions(opts DockerBuildOptions) (DockerBuildOptions, error) { if opts.ProjectDir == "" { cwd, err := os.Getwd() if err != nil { - return cli.WrapVerb(err, "get", "working directory") + return opts, cli.WrapVerb(err, "get", workingDirectorySubject) } opts.ProjectDir = cwd } // Validate project directory if !IsPHPProject(opts.ProjectDir) { - return cli.Err("not a PHP project: %s (missing composer.json)", opts.ProjectDir) + return opts, cli.Err("not a PHP project: %s (missing composer.json)", opts.ProjectDir) } // Set defaults @@ -116,29 +142,29 @@ func BuildDocker(ctx context.Context, opts DockerBuildOptions) error { opts.Output = os.Stdout } - // Determine Dockerfile path - dockerfilePath := opts.Dockerfile - var tempDockerfile string + return opts, nil +} - if dockerfilePath == "" { - // Generate Dockerfile - content, err := GenerateDockerfile(opts.ProjectDir) - if err != nil { - return cli.WrapVerb(err, "generate", "Dockerfile") - } +func resolveDockerfilePath(opts DockerBuildOptions) (string, func(), error) { + if opts.Dockerfile != "" { + return opts.Dockerfile, nil, nil + } - // Write to temporary file - m := getMedium() - tempDockerfile = filepath.Join(opts.ProjectDir, "Dockerfile.core-generated") - if err := m.Write(tempDockerfile, content); err != nil { - return cli.WrapVerb(err, "write", "Dockerfile") - } - defer func() { _ = m.Delete(tempDockerfile) }() + content, err := GenerateDockerfile(opts.ProjectDir) + if err != nil { + return "", nil, cli.WrapVerb(err, "generate", "Dockerfile") + } - dockerfilePath = tempDockerfile + m := getMedium() + tempDockerfile := filepath.Join(opts.ProjectDir, "Dockerfile.core-generated") + if err := m.Write(tempDockerfile, content); err != nil { + return "", nil, cli.WrapVerb(err, "write", "Dockerfile") } - // Build Docker image + 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} @@ -156,17 +182,7 @@ func BuildDocker(ctx context.Context, opts DockerBuildOptions) error { } args = append(args, opts.ProjectDir) - - cmd := exec.CommandContext(ctx, "docker", args...) - cmd.Dir = opts.ProjectDir - cmd.Stdout = opts.Output - cmd.Stderr = opts.Output - - if err := cmd.Run(); err != nil { - return cli.Wrap(err, "docker build failed") - } - - return nil + return args } // BuildLinuxKit builds a LinuxKit image for the PHP project. @@ -174,7 +190,7 @@ func BuildLinuxKit(ctx context.Context, opts LinuxKitBuildOptions) error { if opts.ProjectDir == "" { cwd, err := os.Getwd() if err != nil { - return cli.WrapVerb(err, "get", "working directory") + return cli.WrapVerb(err, "get", workingDirectorySubject) } opts.ProjectDir = cwd } @@ -186,7 +202,7 @@ func BuildLinuxKit(ctx context.Context, opts LinuxKitBuildOptions) error { // Set defaults if opts.Template == "" { - opts.Template = "server-php" + opts.Template = defaultLinuxKitTemplateName } if opts.Format == "" { opts.Format = "qcow2" @@ -347,7 +363,7 @@ func Shell(ctx context.Context, containerID string) error { // IsPHPProject checks if the given directory is a PHP project. func IsPHPProject(dir string) bool { - composerPath := filepath.Join(dir, "composer.json") + composerPath := filepath.Join(dir, composerJSONFile) return getMedium().IsFile(composerPath) } @@ -377,7 +393,7 @@ func lookupLinuxKit() (string, error) { // getLinuxKitTemplate retrieves a LinuxKit template by name. func getLinuxKitTemplate(name string) (string, error) { // Default server-php template for PHP projects - if name == "server-php" { + if name == defaultLinuxKitTemplateName { return defaultServerPHPTemplate, nil } diff --git a/pkg/php/container_test.go b/pkg/php/container_test.go index 7beddb3..f45bbf3 100644 --- a/pkg/php/container_test.go +++ b/pkg/php/container_test.go @@ -7,11 +7,11 @@ import ( ) func TestPHP_DockerBuildOptions_Good(t *T) { - t.Run("all fields accessible", func(t *T) { + t.Run(testAllFieldsAccessible, func(t *T) { opts := DockerBuildOptions{ - ProjectDir: "/project", + ProjectDir: testProjectDir, ImageName: "myapp", - Tag: "v1.0.0", + Tag: testVersionV100, Platform: "linux/amd64", Dockerfile: "/path/to/Dockerfile", NoBuildCache: true, @@ -19,9 +19,9 @@ func TestPHP_DockerBuildOptions_Good(t *T) { Output: os.Stdout, } - AssertEqual(t, "/project", opts.ProjectDir) + AssertEqual(t, testProjectDir, opts.ProjectDir) AssertEqual(t, "myapp", opts.ImageName) - AssertEqual(t, "v1.0.0", opts.Tag) + AssertEqual(t, testVersionV100, opts.Tag) AssertEqual(t, "linux/amd64", opts.Platform) AssertEqual(t, "/path/to/Dockerfile", opts.Dockerfile) AssertTrue(t, opts.NoBuildCache) @@ -31,27 +31,27 @@ func TestPHP_DockerBuildOptions_Good(t *T) { } func TestPHP_LinuxKitBuildOptions_Good(t *T) { - t.Run("all fields accessible", func(t *T) { + t.Run(testAllFieldsAccessible, func(t *T) { opts := LinuxKitBuildOptions{ - ProjectDir: "/project", + ProjectDir: testProjectDir, OutputPath: "/output/image.qcow2", Format: "qcow2", - Template: "server-php", + Template: defaultLinuxKitTemplateName, Variables: map[string]string{"VAR1": "value1"}, Output: os.Stdout, } - AssertEqual(t, "/project", opts.ProjectDir) + AssertEqual(t, testProjectDir, opts.ProjectDir) AssertEqual(t, "/output/image.qcow2", opts.OutputPath) AssertEqual(t, "qcow2", opts.Format) - AssertEqual(t, "server-php", opts.Template) + AssertEqual(t, defaultLinuxKitTemplateName, opts.Template) AssertEqual(t, "value1", opts.Variables["VAR1"]) AssertNotNil(t, opts.Output) }) } func TestPHP_ServeOptions_Good(t *T) { - t.Run("all fields accessible", func(t *T) { + t.Run(testAllFieldsAccessible, func(t *T) { opts := ServeOptions{ ImageName: "myapp", Tag: "latest", @@ -79,7 +79,7 @@ func TestPHP_ServeOptions_Good(t *T) { func TestPHP_IsPHPProject_Container_Good(t *T) { t.Run("returns true with composer.json", func(t *T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(`{}`), 0644) + err := os.WriteFile(filepath.Join(dir, composerJSONFile), []byte(`{}`), 0644) RequireNoError(t, err) AssertTrue(t, IsPHPProject(dir)) @@ -119,7 +119,7 @@ func TestPHP_LookupLinuxKit_Bad(t *T) { func TestPHP_GetLinuxKitTemplate_Good(t *T) { t.Run("returns server-php template", func(t *T) { - content, err := getLinuxKitTemplate("server-php") + content, err := getLinuxKitTemplate(defaultLinuxKitTemplateName) AssertNoError(t, err) AssertContains(t, content, "kernel:") AssertContains(t, content, "linuxkit/kernel") @@ -194,24 +194,24 @@ func TestPHP_DefaultServerPHPTemplate_Good(t *T) { } func TestPHP_BuildDocker_Bad(t *T) { - t.Skip("requires Docker installed") + t.Skip(testRequiresDockerInstalled) - t.Run("fails for non-PHP project", func(t *T) { + t.Run(testFailsNonPHPProject, func(t *T) { dir := t.TempDir() err := BuildDocker(context.TODO(), DockerBuildOptions{ProjectDir: dir}) AssertError(t, err) - AssertContains(t, err.Error(), "not a PHP project") + AssertContains(t, err.Error(), testNotPHPProject) }) } func TestPHP_BuildLinuxKit_Bad(t *T) { t.Skip("requires linuxkit installed") - t.Run("fails for non-PHP project", func(t *T) { + t.Run(testFailsNonPHPProject, func(t *T) { dir := t.TempDir() err := BuildLinuxKit(context.TODO(), LinuxKitBuildOptions{ProjectDir: dir}) AssertError(t, err) - AssertContains(t, err.Error(), "not a PHP project") + AssertContains(t, err.Error(), testNotPHPProject) }) } @@ -239,7 +239,7 @@ func TestPHP_ResolveDockerContainerID_Bad(t *T) { } func TestBuildDocker_DefaultOptions(t *T) { - t.Run("sets defaults correctly", func(t *T) { + t.Run(testSetsDefaultsCorrectly, func(t *T) { // This tests the default logic without actually running Docker opts := DockerBuildOptions{} @@ -257,14 +257,14 @@ func TestBuildDocker_DefaultOptions(t *T) { } func TestBuildLinuxKit_DefaultOptions(t *T) { - t.Run("sets defaults correctly", func(t *T) { + t.Run(testSetsDefaultsCorrectly, func(t *T) { opts := LinuxKitBuildOptions{} // Verify default values would be set if opts.Template == "" { - opts.Template = "server-php" + opts.Template = defaultLinuxKitTemplateName } - AssertEqual(t, "server-php", opts.Template) + AssertEqual(t, defaultLinuxKitTemplateName, opts.Template) if opts.Format == "" { opts.Format = "qcow2" @@ -274,7 +274,7 @@ func TestBuildLinuxKit_DefaultOptions(t *T) { } func TestServeProduction_DefaultOptions(t *T) { - t.Run("sets defaults correctly", func(t *T) { + t.Run(testSetsDefaultsCorrectly, func(t *T) { opts := ServeOptions{ImageName: "myapp"} // Verify default values would be set @@ -306,11 +306,11 @@ func TestPHP_LookupLinuxKit_Good(t *T) { } func TestBuildDocker_WithCustomDockerfile(t *T) { - t.Skip("requires Docker installed") + t.Skip(testRequiresDockerInstalled) t.Run("uses custom Dockerfile when provided", func(t *T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(`{"name":"test"}`), 0644) + err := os.WriteFile(filepath.Join(dir, composerJSONFile), []byte(`{"name":"test"}`), 0644) RequireNoError(t, err) dockerfilePath := filepath.Join(dir, "Dockerfile.custom") @@ -328,14 +328,14 @@ func TestBuildDocker_WithCustomDockerfile(t *T) { } func TestBuildDocker_GeneratesDockerfile(t *T) { - t.Skip("requires Docker installed") + t.Skip(testRequiresDockerInstalled) t.Run("generates Dockerfile when not provided", func(t *T) { dir := t.TempDir() // Create valid PHP project composerJSON := `{"name":"test","require":{"php":"^8.2","laravel/framework":"^11.0"}}` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) + err := os.WriteFile(filepath.Join(dir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) opts := DockerBuildOptions{ @@ -351,7 +351,7 @@ func TestServeProduction_BuildsCorrectArgs(t *T) { t.Run("builds correct docker run arguments", func(t *T) { opts := ServeOptions{ ImageName: "myapp", - Tag: "v1.0.0", + Tag: testVersionV100, ContainerName: "myapp-prod", Port: 8080, HTTPSPort: 8443, diff --git a/pkg/php/coolify.go b/pkg/php/coolify.go index 8df64c3..56a053c 100644 --- a/pkg/php/coolify.go +++ b/pkg/php/coolify.go @@ -76,13 +76,7 @@ func LoadCoolifyConfig(dir string) (*CoolifyConfig, error) { // LoadCoolifyConfigFromFile loads Coolify configuration from a specific .env file. func LoadCoolifyConfigFromFile(path string) (*CoolifyConfig, error) { m := getMedium() - config := &CoolifyConfig{} - - // First try environment variables - config.URL = os.Getenv("COOLIFY_URL") - config.Token = os.Getenv("COOLIFY_TOKEN") - config.AppID = os.Getenv("COOLIFY_APP_ID") - config.StagingAppID = os.Getenv("COOLIFY_STAGING_APP_ID") + config := coolifyConfigFromEnv() // Then try .env file if !m.Exists(path) { @@ -95,46 +89,64 @@ func LoadCoolifyConfigFromFile(path string) (*CoolifyConfig, error) { return nil, cli.WrapVerb(err, "read", ".env file") } - // Parse .env file - lines := strings.Split(content, "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } + applyCoolifyEnvFile(config, content) + return validateCoolifyConfig(config) +} - parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { +func coolifyConfigFromEnv() *CoolifyConfig { + return &CoolifyConfig{ + URL: os.Getenv("COOLIFY_URL"), + Token: os.Getenv("COOLIFY_TOKEN"), + AppID: os.Getenv("COOLIFY_APP_ID"), + StagingAppID: os.Getenv("COOLIFY_STAGING_APP_ID"), + } +} + +func applyCoolifyEnvFile(config *CoolifyConfig, content string) { + for _, line := range strings.Split(content, "\n") { + key, value, ok := parseCoolifyEnvLine(line) + if !ok { continue } + setCoolifyConfigValue(config, key, value) + } +} - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - // Remove quotes if present - value = strings.Trim(value, `"'`) - - // Only override if not already set from env - 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 - } - } +func parseCoolifyEnvLine(line string) (string, string, bool) { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + return "", "", false } - return validateCoolifyConfig(config) + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + return "", "", false + } + + key := strings.TrimSpace(parts[0]) + value := strings.Trim(strings.TrimSpace(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. @@ -171,7 +183,7 @@ func (c *CoolifyClient) TriggerDeploy(ctx context.Context, appID string, force b resp, err := c.HTTPClient.Do(req) if err != nil { - return nil, cli.Wrap(err, "request failed") + return nil, cli.Wrap(err, requestFailedMessage) } defer func() { _ = resp.Body.Close() }() @@ -204,7 +216,7 @@ func (c *CoolifyClient) GetDeployment(ctx context.Context, appID, deploymentID s resp, err := c.HTTPClient.Do(req) if err != nil { - return nil, cli.Wrap(err, "request failed") + return nil, cli.Wrap(err, requestFailedMessage) } defer func() { _ = resp.Body.Close() }() @@ -236,7 +248,7 @@ func (c *CoolifyClient) ListDeployments(ctx context.Context, appID string, limit resp, err := c.HTTPClient.Do(req) if err != nil { - return nil, cli.Wrap(err, "request failed") + return nil, cli.Wrap(err, requestFailedMessage) } defer func() { _ = resp.Body.Close() }() @@ -274,7 +286,7 @@ func (c *CoolifyClient) Rollback(ctx context.Context, appID, deploymentID string resp, err := c.HTTPClient.Do(req) if err != nil { - return nil, cli.Wrap(err, "request failed") + return nil, cli.Wrap(err, requestFailedMessage) } defer func() { _ = resp.Body.Close() }() @@ -306,7 +318,7 @@ func (c *CoolifyClient) GetApp(ctx context.Context, appID string) (*CoolifyApp, resp, err := c.HTTPClient.Do(req) if err != nil { - return nil, cli.Wrap(err, "request failed") + return nil, cli.Wrap(err, requestFailedMessage) } defer func() { _ = resp.Body.Close() }() @@ -340,12 +352,12 @@ func (c *CoolifyClient) parseError(resp *http.Response) error { if err := json.Unmarshal(body, &errResp); err == nil { if errResp.Message != "" { - return cli.Err("API error (%d): %s", resp.StatusCode, errResp.Message) + return cli.Err(apiErrorFormat, resp.StatusCode, errResp.Message) } if errResp.Error != "" { - return cli.Err("API error (%d): %s", resp.StatusCode, errResp.Error) + return cli.Err(apiErrorFormat, resp.StatusCode, errResp.Error) } } - return cli.Err("API error (%d): %s", resp.StatusCode, string(body)) + return cli.Err(apiErrorFormat, resp.StatusCode, string(body)) } diff --git a/pkg/php/coolify_test.go b/pkg/php/coolify_test.go index ccac13f..e5afae5 100644 --- a/pkg/php/coolify_test.go +++ b/pkg/php/coolify_test.go @@ -12,78 +12,78 @@ import ( func TestPHP_CoolifyClient_Good(t *T) { t.Run("creates client with correct base URL", func(t *T) { - client := NewCoolifyClient("https://coolify.example.com", "token") + client := NewCoolifyClient(testCoolifyURL, "token") - AssertEqual(t, "https://coolify.example.com", client.BaseURL) + AssertEqual(t, testCoolifyURL, client.BaseURL) AssertEqual(t, "token", client.Token) AssertNotNil(t, client.HTTPClient) }) t.Run("strips trailing slash from base URL", func(t *T) { client := NewCoolifyClient("https://coolify.example.com/", "token") - AssertEqual(t, "https://coolify.example.com", client.BaseURL) + AssertEqual(t, testCoolifyURL, client.BaseURL) }) t.Run("http client has timeout", func(t *T) { - client := NewCoolifyClient("https://coolify.example.com", "token") + client := NewCoolifyClient(testCoolifyURL, "token") AssertEqual(t, 30*time.Second, client.HTTPClient.Timeout) }) } func TestPHP_CoolifyConfig_Good(t *T) { - t.Run("all fields accessible", func(t *T) { + t.Run(testAllFieldsAccessible, func(t *T) { config := CoolifyConfig{ - URL: "https://coolify.example.com", - Token: "secret-token", - AppID: "app-123", - StagingAppID: "staging-456", + URL: testCoolifyURL, + Token: testCoolifyToken, + AppID: testCoolifyAppID, + StagingAppID: testCoolifyStagingAppID, } - AssertEqual(t, "https://coolify.example.com", config.URL) - AssertEqual(t, "secret-token", config.Token) - AssertEqual(t, "app-123", config.AppID) - AssertEqual(t, "staging-456", config.StagingAppID) + AssertEqual(t, testCoolifyURL, config.URL) + AssertEqual(t, testCoolifyToken, config.Token) + AssertEqual(t, testCoolifyAppID, config.AppID) + AssertEqual(t, testCoolifyStagingAppID, config.StagingAppID) }) } func TestPHP_CoolifyDeployment_Good(t *T) { - t.Run("all fields accessible", func(t *T) { + t.Run(testAllFieldsAccessible, func(t *T) { now := time.Now() deployment := CoolifyDeployment{ - ID: "dep-123", + ID: testDeploymentID123, Status: "finished", CommitSHA: "abc123", - CommitMsg: "Test commit", + CommitMsg: testCommitMessage, Branch: "main", CreatedAt: now, FinishedAt: now.Add(5 * time.Minute), Log: "Build successful", - DeployedURL: "https://app.example.com", + DeployedURL: testAppURL, } - AssertEqual(t, "dep-123", deployment.ID) + AssertEqual(t, testDeploymentID123, deployment.ID) AssertEqual(t, "finished", deployment.Status) AssertEqual(t, "abc123", deployment.CommitSHA) - AssertEqual(t, "Test commit", deployment.CommitMsg) + AssertEqual(t, testCommitMessage, deployment.CommitMsg) AssertEqual(t, "main", deployment.Branch) }) } func TestPHP_CoolifyApp_Good(t *T) { - t.Run("all fields accessible", func(t *T) { + t.Run(testAllFieldsAccessible, func(t *T) { app := CoolifyApp{ - ID: "app-123", + ID: testCoolifyAppID, Name: "MyApp", - FQDN: "https://myapp.example.com", + FQDN: testMyAppURL, Status: "running", Repository: "https://github.com/user/repo", Branch: "main", Environment: "production", } - AssertEqual(t, "app-123", app.ID) + AssertEqual(t, testCoolifyAppID, app.ID) AssertEqual(t, "MyApp", app.Name) - AssertEqual(t, "https://myapp.example.com", app.FQDN) + AssertEqual(t, testMyAppURL, app.FQDN) AssertEqual(t, "running", app.Status) }) } @@ -101,24 +101,23 @@ COOLIFY_STAGING_APP_ID=staging-456` config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) AssertNoError(t, err) - AssertEqual(t, "https://coolify.example.com", config.URL) - AssertEqual(t, "secret-token", config.Token) - AssertEqual(t, "app-123", config.AppID) - AssertEqual(t, "staging-456", config.StagingAppID) + AssertEqual(t, testCoolifyURL, config.URL) + AssertEqual(t, testCoolifyToken, config.Token) + AssertEqual(t, testCoolifyAppID, config.AppID) + AssertEqual(t, testCoolifyStagingAppID, config.StagingAppID) }) t.Run("handles quoted values", func(t *T) { dir := t.TempDir() - envContent := `COOLIFY_URL="https://coolify.example.com" -COOLIFY_TOKEN='secret-token'` + envContent := "COOLIFY_URL=\"" + testCoolifyURL + "\"\nCOOLIFY_TOKEN='" + testCoolifyToken + "'" err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) RequireNoError(t, err) config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) AssertNoError(t, err) - AssertEqual(t, "https://coolify.example.com", config.URL) - AssertEqual(t, "secret-token", config.Token) + AssertEqual(t, testCoolifyURL, config.URL) + AssertEqual(t, testCoolifyToken, config.Token) }) t.Run("ignores comments", func(t *T) { @@ -147,7 +146,7 @@ COOLIFY_TOKEN=secret-token` config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) AssertNoError(t, err) - AssertEqual(t, "https://coolify.example.com", config.URL) + AssertEqual(t, testCoolifyURL, config.URL) }) } @@ -188,7 +187,7 @@ COOLIFY_TOKEN=secret-token` config, err := LoadCoolifyConfig(dir) AssertNoError(t, err) - AssertEqual(t, "https://coolify.example.com", config.URL) + AssertEqual(t, testCoolifyURL, config.URL) }) } @@ -201,7 +200,7 @@ func TestPHP_ValidateCoolifyConfig_Bad(t *T) { }) t.Run("returns error for empty token", func(t *T) { - config := &CoolifyConfig{URL: "https://coolify.example.com"} + config := &CoolifyConfig{URL: testCoolifyURL} _, err := validateCoolifyConfig(config) AssertError(t, err) AssertContains(t, err.Error(), "COOLIFY_TOKEN is not set") @@ -214,10 +213,10 @@ func TestPHP_CoolifyClient_TriggerDeploy_Good(t *T) { AssertEqual(t, "/api/v1/applications/app-123/deploy", r.URL.Path) AssertEqual(t, "POST", r.Method) AssertEqual(t, "Bearer secret-token", r.Header.Get("Authorization")) - AssertEqual(t, "application/json", r.Header.Get("Content-Type")) + AssertEqual(t, testContentTypeJSON, r.Header.Get("Content-Type")) resp := CoolifyDeployment{ - ID: "dep-456", + ID: testDeploymentID456, Status: "queued", CreatedAt: time.Now(), } @@ -225,11 +224,11 @@ func TestPHP_CoolifyClient_TriggerDeploy_Good(t *T) { })) defer server.Close() - client := NewCoolifyClient(server.URL, "secret-token") - deployment, err := client.TriggerDeploy(context.Background(), "app-123", false) + client := NewCoolifyClient(server.URL, testCoolifyToken) + deployment, err := client.TriggerDeploy(context.Background(), testCoolifyAppID, false) AssertNoError(t, err) - AssertEqual(t, "dep-456", deployment.ID) + AssertEqual(t, testDeploymentID456, deployment.ID) AssertEqual(t, "queued", deployment.Status) }) @@ -239,13 +238,13 @@ func TestPHP_CoolifyClient_TriggerDeploy_Good(t *T) { _ = json.NewDecoder(r.Body).Decode(&body) AssertEqual(t, true, body["force"]) - resp := CoolifyDeployment{ID: "dep-456", Status: "queued"} + resp := CoolifyDeployment{ID: testDeploymentID456, Status: "queued"} _ = json.NewEncoder(w).Encode(resp) })) defer server.Close() - client := NewCoolifyClient(server.URL, "secret-token") - _, err := client.TriggerDeploy(context.Background(), "app-123", true) + client := NewCoolifyClient(server.URL, testCoolifyToken) + _, err := client.TriggerDeploy(context.Background(), testCoolifyAppID, true) AssertNoError(t, err) }) @@ -256,8 +255,8 @@ func TestPHP_CoolifyClient_TriggerDeploy_Good(t *T) { })) defer server.Close() - client := NewCoolifyClient(server.URL, "secret-token") - deployment, err := client.TriggerDeploy(context.Background(), "app-123", false) + client := NewCoolifyClient(server.URL, testCoolifyToken) + deployment, err := client.TriggerDeploy(context.Background(), testCoolifyAppID, false) AssertNoError(t, err) // The fallback response should be returned @@ -273,8 +272,8 @@ func TestPHP_CoolifyClient_TriggerDeploy_Bad(t *T) { })) defer server.Close() - client := NewCoolifyClient(server.URL, "secret-token") - _, err := client.TriggerDeploy(context.Background(), "app-123", false) + client := NewCoolifyClient(server.URL, testCoolifyToken) + _, err := client.TriggerDeploy(context.Background(), testCoolifyAppID, false) AssertError(t, err) AssertContains(t, err.Error(), "API error") @@ -288,7 +287,7 @@ func TestPHP_CoolifyClient_GetDeployment_Good(t *T) { AssertEqual(t, "GET", r.Method) resp := CoolifyDeployment{ - ID: "dep-456", + ID: testDeploymentID456, Status: "finished", CommitSHA: "abc123", Branch: "main", @@ -297,11 +296,11 @@ func TestPHP_CoolifyClient_GetDeployment_Good(t *T) { })) defer server.Close() - client := NewCoolifyClient(server.URL, "secret-token") - deployment, err := client.GetDeployment(context.Background(), "app-123", "dep-456") + client := NewCoolifyClient(server.URL, testCoolifyToken) + deployment, err := client.GetDeployment(context.Background(), testCoolifyAppID, testDeploymentID456) AssertNoError(t, err) - AssertEqual(t, "dep-456", deployment.ID) + AssertEqual(t, testDeploymentID456, deployment.ID) AssertEqual(t, "finished", deployment.Status) AssertEqual(t, "abc123", deployment.CommitSHA) }) @@ -315,8 +314,8 @@ func TestPHP_CoolifyClient_GetDeployment_Bad(t *T) { })) defer server.Close() - client := NewCoolifyClient(server.URL, "secret-token") - _, err := client.GetDeployment(context.Background(), "app-123", "dep-456") + client := NewCoolifyClient(server.URL, testCoolifyToken) + _, err := client.GetDeployment(context.Background(), testCoolifyAppID, testDeploymentID456) AssertError(t, err) AssertContains(t, err.Error(), "Not found") @@ -337,8 +336,8 @@ func TestPHP_CoolifyClient_ListDeployments_Good(t *T) { })) defer server.Close() - client := NewCoolifyClient(server.URL, "secret-token") - deployments, err := client.ListDeployments(context.Background(), "app-123", 10) + client := NewCoolifyClient(server.URL, testCoolifyToken) + deployments, err := client.ListDeployments(context.Background(), testCoolifyAppID, 10) AssertNoError(t, err) AssertLen(t, deployments, 2) @@ -353,8 +352,8 @@ func TestPHP_CoolifyClient_ListDeployments_Good(t *T) { })) defer server.Close() - client := NewCoolifyClient(server.URL, "secret-token") - _, err := client.ListDeployments(context.Background(), "app-123", 0) + client := NewCoolifyClient(server.URL, testCoolifyToken) + _, err := client.ListDeployments(context.Background(), testCoolifyAppID, 0) AssertNoError(t, err) }) } @@ -377,8 +376,8 @@ func TestPHP_CoolifyClient_Rollback_Good(t *T) { })) defer server.Close() - client := NewCoolifyClient(server.URL, "secret-token") - deployment, err := client.Rollback(context.Background(), "app-123", "dep-old") + client := NewCoolifyClient(server.URL, testCoolifyToken) + deployment, err := client.Rollback(context.Background(), testCoolifyAppID, "dep-old") AssertNoError(t, err) AssertEqual(t, "dep-new", deployment.ID) @@ -393,35 +392,35 @@ func TestPHP_CoolifyClient_GetApp_Good(t *T) { AssertEqual(t, "GET", r.Method) resp := CoolifyApp{ - ID: "app-123", + ID: testCoolifyAppID, Name: "MyApp", - FQDN: "https://myapp.example.com", + FQDN: testMyAppURL, Status: "running", } _ = json.NewEncoder(w).Encode(resp) })) defer server.Close() - client := NewCoolifyClient(server.URL, "secret-token") - app, err := client.GetApp(context.Background(), "app-123") + client := NewCoolifyClient(server.URL, testCoolifyToken) + app, err := client.GetApp(context.Background(), testCoolifyAppID) AssertNoError(t, err) - AssertEqual(t, "app-123", app.ID) + AssertEqual(t, testCoolifyAppID, app.ID) AssertEqual(t, "MyApp", app.Name) - AssertEqual(t, "https://myapp.example.com", app.FQDN) + AssertEqual(t, testMyAppURL, app.FQDN) }) } func TestCoolifyClient_SetHeaders(t *T) { t.Run("sets all required headers", func(t *T) { - client := NewCoolifyClient("https://coolify.example.com", "my-token") - req, _ := http.NewRequest("GET", "https://coolify.example.com", nil) + client := NewCoolifyClient(testCoolifyURL, "my-token") + req, _ := http.NewRequest("GET", testCoolifyURL, nil) client.setHeaders(req) AssertEqual(t, "Bearer my-token", req.Header.Get("Authorization")) - AssertEqual(t, "application/json", req.Header.Get("Content-Type")) - AssertEqual(t, "application/json", req.Header.Get("Accept")) + AssertEqual(t, testContentTypeJSON, req.Header.Get("Content-Type")) + AssertEqual(t, testContentTypeJSON, req.Header.Get("Accept")) }) } @@ -434,7 +433,7 @@ func TestCoolifyClient_ParseError(t *T) { defer server.Close() client := NewCoolifyClient(server.URL, "token") - _, err := client.GetApp(context.Background(), "app-123") + _, err := client.GetApp(context.Background(), testCoolifyAppID) AssertError(t, err) AssertContains(t, err.Error(), "Bad request message") @@ -448,7 +447,7 @@ func TestCoolifyClient_ParseError(t *T) { defer server.Close() client := NewCoolifyClient(server.URL, "token") - _, err := client.GetApp(context.Background(), "app-123") + _, err := client.GetApp(context.Background(), testCoolifyAppID) AssertError(t, err) AssertContains(t, err.Error(), "Error message") @@ -462,7 +461,7 @@ func TestCoolifyClient_ParseError(t *T) { defer server.Close() client := NewCoolifyClient(server.URL, "token") - _, err := client.GetApp(context.Background(), "app-123") + _, err := client.GetApp(context.Background(), testCoolifyAppID) AssertError(t, err) AssertContains(t, err.Error(), "Raw error message") diff --git a/pkg/php/deploy.go b/pkg/php/deploy.go index 1e2f76e..fc07146 100644 --- a/pkg/php/deploy.go +++ b/pkg/php/deploy.go @@ -121,13 +121,13 @@ func Deploy(ctx context.Context, opts DeployOptions) (*DeploymentStatus, error) // Load config config, err := LoadCoolifyConfig(opts.Dir) if err != nil { - return nil, cli.WrapVerb(err, "load", "Coolify config") + return nil, cli.WrapVerb(err, "load", coolifyConfigSubject) } // Get app ID for environment appID := getAppIDForEnvironment(config, opts.Environment) if appID == "" { - return nil, cli.Err("no app ID configured for %s environment", opts.Environment) + return nil, cli.Err(noAppIDEnvironmentFormat, opts.Environment) } // Create client @@ -170,13 +170,13 @@ func DeployStatus(ctx context.Context, opts StatusOptions) (*DeploymentStatus, e // Load config config, err := LoadCoolifyConfig(opts.Dir) if err != nil { - return nil, cli.WrapVerb(err, "load", "Coolify config") + return nil, cli.WrapVerb(err, "load", coolifyConfigSubject) } // Get app ID for environment appID := getAppIDForEnvironment(config, opts.Environment) if appID == "" { - return nil, cli.Err("no app ID configured for %s environment", opts.Environment) + return nil, cli.Err(noAppIDEnvironmentFormat, opts.Environment) } // Create client @@ -215,6 +215,38 @@ func DeployStatus(ctx context.Context, opts StatusOptions) (*DeploymentStatus, e // Rollback triggers a rollback to a previous deployment. func Rollback(ctx context.Context, opts RollbackOptions) (*DeploymentStatus, error) { + 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, cli.WrapVerb(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 = "." } @@ -224,64 +256,44 @@ func Rollback(ctx context.Context, opts RollbackOptions) (*DeploymentStatus, err if opts.WaitTimeout == 0 { opts.WaitTimeout = 10 * time.Minute } + return opts +} - // Load config - config, err := LoadCoolifyConfig(opts.Dir) +func coolifyClientForEnvironment(dir string, env Environment) (*CoolifyClient, string, error) { + config, err := LoadCoolifyConfig(dir) if err != nil { - return nil, cli.WrapVerb(err, "load", "Coolify config") + return nil, "", cli.WrapVerb(err, "load", coolifyConfigSubject) } - // Get app ID for environment - appID := getAppIDForEnvironment(config, opts.Environment) + appID := getAppIDForEnvironment(config, env) if appID == "" { - return nil, cli.Err("no app ID configured for %s environment", opts.Environment) + return nil, "", cli.Err(noAppIDEnvironmentFormat, env) } - // Create client - client := NewCoolifyClient(config.URL, config.Token) - - // Find deployment to rollback to - deploymentID := opts.DeploymentID - if deploymentID == "" { - // Find previous successful deployment - deployments, err := client.ListDeployments(ctx, appID, 10) - if err != nil { - return nil, cli.WrapVerb(err, "list", "deployments") - } - - // Skip the first (current) deployment, find the last successful one - for i, d := range deployments { - if i == 0 { - continue // Skip current deployment - } - if d.Status == "finished" || d.Status == "success" { - deploymentID = d.ID - break - } - } + return NewCoolifyClient(config.URL, config.Token), appID, nil +} - if deploymentID == "" { - return nil, cli.Err("no previous successful deployment found to rollback to") - } +func resolveRollbackDeploymentID(ctx context.Context, client *CoolifyClient, appID, requestedID string) (string, error) { + if requestedID != "" { + return requestedID, nil } - // Trigger rollback - deployment, err := client.Rollback(ctx, appID, deploymentID) + deployments, err := client.ListDeployments(ctx, appID, 10) if err != nil { - return nil, cli.WrapVerb(err, "trigger", "rollback") + return "", cli.WrapVerb(err, "list", "deployments") } - 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 + for i, d := range deployments { + if i > 0 && isSuccessfulDeploymentStatus(d.Status) { + return d.ID, nil } } - return status, nil + return "", cli.Err("no previous successful deployment found to rollback to") +} + +func isSuccessfulDeploymentStatus(status string) bool { + return status == "finished" || status == "success" } // ListDeployments retrieves recent deployments. @@ -299,13 +311,13 @@ func ListDeployments(ctx context.Context, dir string, env Environment, limit int // Load config config, err := LoadCoolifyConfig(dir) if err != nil { - return nil, cli.WrapVerb(err, "load", "Coolify config") + return nil, cli.WrapVerb(err, "load", coolifyConfigSubject) } // Get app ID for environment appID := getAppIDForEnvironment(config, env) if appID == "" { - return nil, cli.Err("no app ID configured for %s environment", env) + return nil, cli.Err(noAppIDEnvironmentFormat, env) } // Create client diff --git a/pkg/php/deploy_internal_test.go b/pkg/php/deploy_internal_test.go index cd263cf..ef300f8 100644 --- a/pkg/php/deploy_internal_test.go +++ b/pkg/php/deploy_internal_test.go @@ -8,24 +8,24 @@ func TestPHP_ConvertDeployment_Good(t *T) { t.Run("converts all fields", func(t *T) { now := time.Now() coolify := &CoolifyDeployment{ - ID: "dep-123", + ID: testDeploymentID123, Status: "finished", CommitSHA: "abc123", - CommitMsg: "Test commit", + CommitMsg: testCommitMessage, Branch: "main", CreatedAt: now, FinishedAt: now.Add(5 * time.Minute), Log: "Build successful", - DeployedURL: "https://app.example.com", + DeployedURL: testAppURL, } status := convertDeployment(coolify) - AssertEqual(t, "dep-123", status.ID) + AssertEqual(t, testDeploymentID123, status.ID) AssertEqual(t, "finished", status.Status) - AssertEqual(t, "https://app.example.com", status.URL) + AssertEqual(t, testAppURL, status.URL) AssertEqual(t, "abc123", status.Commit) - AssertEqual(t, "Test commit", status.CommitMessage) + AssertEqual(t, testCommitMessage, status.CommitMessage) AssertEqual(t, "main", status.Branch) AssertEqual(t, now, status.StartedAt) AssertEqual(t, now.Add(5*time.Minute), status.CompletedAt) @@ -42,34 +42,34 @@ func TestPHP_ConvertDeployment_Good(t *T) { } func TestPHP_DeploymentStatus_Struct_Good(t *T) { - t.Run("all fields accessible", func(t *T) { + t.Run(testAllFieldsAccessible, func(t *T) { now := time.Now() status := DeploymentStatus{ - ID: "dep-123", + ID: testDeploymentID123, Status: "finished", - URL: "https://app.example.com", + URL: testAppURL, Commit: "abc123", - CommitMessage: "Test commit", + CommitMessage: testCommitMessage, Branch: "main", StartedAt: now, CompletedAt: now.Add(5 * time.Minute), Log: "Build log", } - AssertEqual(t, "dep-123", status.ID) + AssertEqual(t, testDeploymentID123, status.ID) AssertEqual(t, "finished", status.Status) - AssertEqual(t, "https://app.example.com", status.URL) + AssertEqual(t, testAppURL, status.URL) AssertEqual(t, "abc123", status.Commit) - AssertEqual(t, "Test commit", status.CommitMessage) + AssertEqual(t, testCommitMessage, status.CommitMessage) AssertEqual(t, "main", status.Branch) AssertEqual(t, "Build log", status.Log) }) } func TestPHP_DeployOptions_Struct_Good(t *T) { - t.Run("all fields accessible", func(t *T) { + t.Run(testAllFieldsAccessible, func(t *T) { opts := DeployOptions{ - Dir: "/project", + Dir: testProjectDir, Environment: EnvProduction, Force: true, Wait: true, @@ -77,7 +77,7 @@ func TestPHP_DeployOptions_Struct_Good(t *T) { PollInterval: 5 * time.Second, } - AssertEqual(t, "/project", opts.Dir) + AssertEqual(t, testProjectDir, opts.Dir) AssertEqual(t, EnvProduction, opts.Environment) AssertTrue(t, opts.Force) AssertTrue(t, opts.Wait) @@ -87,30 +87,30 @@ func TestPHP_DeployOptions_Struct_Good(t *T) { } func TestPHP_StatusOptions_Struct_Good(t *T) { - t.Run("all fields accessible", func(t *T) { + t.Run(testAllFieldsAccessible, func(t *T) { opts := StatusOptions{ - Dir: "/project", + Dir: testProjectDir, Environment: EnvStaging, - DeploymentID: "dep-123", + DeploymentID: testDeploymentID123, } - AssertEqual(t, "/project", opts.Dir) + AssertEqual(t, testProjectDir, opts.Dir) AssertEqual(t, EnvStaging, opts.Environment) - AssertEqual(t, "dep-123", opts.DeploymentID) + AssertEqual(t, testDeploymentID123, opts.DeploymentID) }) } func TestPHP_RollbackOptions_Struct_Good(t *T) { - t.Run("all fields accessible", func(t *T) { + t.Run(testAllFieldsAccessible, func(t *T) { opts := RollbackOptions{ - Dir: "/project", + Dir: testProjectDir, Environment: EnvProduction, DeploymentID: "dep-old", Wait: true, WaitTimeout: 5 * time.Minute, } - AssertEqual(t, "/project", opts.Dir) + AssertEqual(t, testProjectDir, opts.Dir) AssertEqual(t, EnvProduction, opts.Environment) AssertEqual(t, "dep-old", opts.DeploymentID) AssertTrue(t, opts.Wait) @@ -128,41 +128,41 @@ func TestEnvironment_Constants(t *T) { func TestPHP_GetAppIDForEnvironment_Ugly(t *T) { t.Run("staging without staging ID falls back to production", func(t *T) { config := &CoolifyConfig{ - AppID: "prod-123", + AppID: testProdAppID, // No StagingAppID set } id := getAppIDForEnvironment(config, EnvStaging) - AssertEqual(t, "prod-123", id) + AssertEqual(t, testProdAppID, id) }) t.Run("staging with staging ID uses staging", func(t *T) { config := &CoolifyConfig{ - AppID: "prod-123", - StagingAppID: "staging-456", + AppID: testProdAppID, + StagingAppID: testCoolifyStagingAppID, } id := getAppIDForEnvironment(config, EnvStaging) - AssertEqual(t, "staging-456", id) + AssertEqual(t, testCoolifyStagingAppID, id) }) t.Run("production uses production ID", func(t *T) { config := &CoolifyConfig{ - AppID: "prod-123", - StagingAppID: "staging-456", + AppID: testProdAppID, + StagingAppID: testCoolifyStagingAppID, } id := getAppIDForEnvironment(config, EnvProduction) - AssertEqual(t, "prod-123", id) + AssertEqual(t, testProdAppID, id) }) t.Run("unknown environment uses production", func(t *T) { config := &CoolifyConfig{ - AppID: "prod-123", + AppID: testProdAppID, } id := getAppIDForEnvironment(config, "unknown") - AssertEqual(t, "prod-123", id) + AssertEqual(t, testProdAppID, id) }) } diff --git a/pkg/php/deploy_test.go b/pkg/php/deploy_test.go index 693139e..2786ea2 100644 --- a/pkg/php/deploy_test.go +++ b/pkg/php/deploy_test.go @@ -20,19 +20,19 @@ func TestPHP_LoadCoolifyConfig_Good(t *T) { COOLIFY_TOKEN=secret-token COOLIFY_APP_ID=app-123 COOLIFY_STAGING_APP_ID=staging-456`, - wantURL: "https://coolify.example.com", - wantToken: "secret-token", - wantAppID: "app-123", - wantStaging: "staging-456", + wantURL: testCoolifyURL, + wantToken: testCoolifyToken, + wantAppID: testCoolifyAppID, + wantStaging: testCoolifyStagingAppID, }, { name: "quoted values", - envContent: `COOLIFY_URL="https://coolify.example.com" -COOLIFY_TOKEN='secret-token' -COOLIFY_APP_ID="app-123"`, - wantURL: "https://coolify.example.com", - wantToken: "secret-token", - wantAppID: "app-123", + envContent: "COOLIFY_URL=\"" + testCoolifyURL + "\"\n" + + "COOLIFY_TOKEN='" + testCoolifyToken + "'\n" + + "COOLIFY_APP_ID=\"" + testCoolifyAppID + "\"", + wantURL: testCoolifyURL, + wantToken: testCoolifyToken, + wantAppID: testCoolifyAppID, }, { name: "with comments and blank lines", @@ -43,42 +43,52 @@ COOLIFY_URL=https://coolify.example.com COOLIFY_TOKEN=secret-token COOLIFY_APP_ID=app-123 # COOLIFY_STAGING_APP_ID=not-this`, - wantURL: "https://coolify.example.com", - wantToken: "secret-token", - wantAppID: "app-123", + wantURL: testCoolifyURL, + wantToken: testCoolifyToken, + wantAppID: testCoolifyAppID, }, } for _, tt := range tests { t.Run(tt.name, func(t *T) { - // Create temp directory dir := t.TempDir() - envPath := filepath.Join(dir, ".env") + writeCoolifyEnv(t, dir, tt.envContent) + config := loadCoolifyConfigForTest(t, dir) + assertCoolifyConfig(t, config, tt.wantURL, tt.wantToken, tt.wantAppID, tt.wantStaging) + }) + } +} - // Write .env file - if err := os.WriteFile(envPath, []byte(tt.envContent), 0644); err != nil { - t.Fatalf("failed to write .env: %v", err) - } +func writeCoolifyEnv(t *T, dir, content string) { + t.Helper() + envPath := filepath.Join(dir, ".env") + if err := os.WriteFile(envPath, []byte(content), 0644); err != nil { + t.Fatalf("failed to write .env: %v", err) + } +} - // Load config - config, err := LoadCoolifyConfig(dir) - if err != nil { - t.Fatalf("LoadCoolifyConfig() error = %v", err) - } +func loadCoolifyConfigForTest(t *T, dir string) *CoolifyConfig { + t.Helper() + config, err := LoadCoolifyConfig(dir) + if err != nil { + t.Fatalf("LoadCoolifyConfig() error = %v", err) + } + return config +} - if config.URL != tt.wantURL { - t.Errorf("URL = %q, want %q", config.URL, tt.wantURL) - } - if config.Token != tt.wantToken { - t.Errorf("Token = %q, want %q", config.Token, tt.wantToken) - } - if config.AppID != tt.wantAppID { - t.Errorf("AppID = %q, want %q", config.AppID, tt.wantAppID) - } - if tt.wantStaging != "" && config.StagingAppID != tt.wantStaging { - t.Errorf("StagingAppID = %q, want %q", config.StagingAppID, tt.wantStaging) - } - }) +func assertCoolifyConfig(t *T, config *CoolifyConfig, wantURL, wantToken, wantAppID, wantStaging string) { + t.Helper() + if config.URL != wantURL { + t.Errorf("URL = %q, want %q", config.URL, wantURL) + } + if config.Token != wantToken { + t.Errorf("Token = %q, want %q", config.Token, wantToken) + } + if config.AppID != wantAppID { + t.Errorf("AppID = %q, want %q", config.AppID, wantAppID) + } + if wantStaging != "" && config.StagingAppID != wantStaging { + t.Errorf("StagingAppID = %q, want %q", config.StagingAppID, wantStaging) } } @@ -131,10 +141,10 @@ func TestPHP_LoadCoolifyConfig_Bad(t *T) { func TestPHP_GetAppIDForEnvironment_Good(t *T) { config := &CoolifyConfig{ - URL: "https://coolify.example.com", + URL: testCoolifyURL, Token: "token", - AppID: "prod-123", - StagingAppID: "staging-456", + AppID: testProdAppID, + StagingAppID: testCoolifyStagingAppID, } tests := []struct { @@ -145,17 +155,17 @@ func TestPHP_GetAppIDForEnvironment_Good(t *T) { { name: "production environment", env: EnvProduction, - wantID: "prod-123", + wantID: testProdAppID, }, { name: "staging environment", env: EnvStaging, - wantID: "staging-456", + wantID: testCoolifyStagingAppID, }, { name: "empty defaults to production", env: "", - wantID: "prod-123", + wantID: testProdAppID, }, } @@ -171,16 +181,16 @@ func TestPHP_GetAppIDForEnvironment_Good(t *T) { func TestGetAppIDForEnvironment_FallbackToProduction(t *T) { config := &CoolifyConfig{ - URL: "https://coolify.example.com", + URL: testCoolifyURL, Token: "token", - AppID: "prod-123", + AppID: testProdAppID, // No staging app ID } // Staging should fall back to production id := getAppIDForEnvironment(config, EnvStaging) - if id != "prod-123" { - t.Errorf("getAppIDForEnvironment(EnvStaging) = %q, want %q (should fallback)", id, "prod-123") + if id != testProdAppID { + t.Errorf("getAppIDForEnvironment(EnvStaging) = %q, want %q (should fallback)", id, testProdAppID) } } @@ -224,13 +234,13 @@ func TestPHP_NewCoolifyClient_Good(t *T) { }{ { name: "URL without trailing slash", - baseURL: "https://coolify.example.com", - wantBaseURL: "https://coolify.example.com", + baseURL: testCoolifyURL, + wantBaseURL: testCoolifyURL, }, { name: "URL with trailing slash", baseURL: "https://coolify.example.com/", - wantBaseURL: "https://coolify.example.com", + wantBaseURL: testCoolifyURL, }, { name: "URL with api path", diff --git a/pkg/php/detect.go b/pkg/php/detect.go index c13da9d..a6970bd 100644 --- a/pkg/php/detect.go +++ b/pkg/php/detect.go @@ -35,7 +35,7 @@ func IsLaravelProject(dir string) bool { } // Check composer.json for laravel/framework - composerPath := filepath.Join(dir, "composer.json") + composerPath := filepath.Join(dir, composerJSONFile) data, err := m.Read(composerPath) if err != nil { return false @@ -69,7 +69,7 @@ func IsFrankenPHPProject(dir string) bool { m := getMedium() // Check composer.json for laravel/octane - composerPath := filepath.Join(dir, "composer.json") + composerPath := filepath.Join(dir, composerJSONFile) data, err := m.Read(composerPath) if err != nil { return false diff --git a/pkg/php/detect_test.go b/pkg/php/detect_test.go index 3c21d95..1a851fd 100644 --- a/pkg/php/detect_test.go +++ b/pkg/php/detect_test.go @@ -11,7 +11,7 @@ func TestPHP_IsLaravelProject_Good(t *T) { // Create artisan file artisanPath := filepath.Join(dir, "artisan") - err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) + err := os.WriteFile(artisanPath, []byte(testPHPShebang), 0755) RequireNoError(t, err) // Create composer.json with laravel/framework @@ -22,7 +22,7 @@ func TestPHP_IsLaravelProject_Good(t *T) { "laravel/framework": "^11.0" } }` - composerPath := filepath.Join(dir, "composer.json") + composerPath := filepath.Join(dir, composerJSONFile) err = os.WriteFile(composerPath, []byte(composerJSON), 0644) RequireNoError(t, err) @@ -34,7 +34,7 @@ func TestPHP_IsLaravelProject_Good(t *T) { // Create artisan file artisanPath := filepath.Join(dir, "artisan") - err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) + err := os.WriteFile(artisanPath, []byte(testPHPShebang), 0755) RequireNoError(t, err) // Create composer.json with laravel/framework in require-dev @@ -44,7 +44,7 @@ func TestPHP_IsLaravelProject_Good(t *T) { "laravel/framework": "^11.0" } }` - composerPath := filepath.Join(dir, "composer.json") + composerPath := filepath.Join(dir, composerJSONFile) err = os.WriteFile(composerPath, []byte(composerJSON), 0644) RequireNoError(t, err) @@ -63,7 +63,7 @@ func TestPHP_IsLaravelProject_Bad(t *T) { "laravel/framework": "^11.0" } }` - composerPath := filepath.Join(dir, "composer.json") + composerPath := filepath.Join(dir, composerJSONFile) err := os.WriteFile(composerPath, []byte(composerJSON), 0644) RequireNoError(t, err) @@ -75,7 +75,7 @@ func TestPHP_IsLaravelProject_Bad(t *T) { // Create artisan but no composer.json artisanPath := filepath.Join(dir, "artisan") - err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) + err := os.WriteFile(artisanPath, []byte(testPHPShebang), 0755) RequireNoError(t, err) AssertFalse(t, IsLaravelProject(dir)) @@ -86,7 +86,7 @@ func TestPHP_IsLaravelProject_Bad(t *T) { // Create artisan file artisanPath := filepath.Join(dir, "artisan") - err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) + err := os.WriteFile(artisanPath, []byte(testPHPShebang), 0755) RequireNoError(t, err) // Create composer.json without laravel/framework @@ -96,7 +96,7 @@ func TestPHP_IsLaravelProject_Bad(t *T) { "symfony/framework-bundle": "^7.0" } }` - composerPath := filepath.Join(dir, "composer.json") + composerPath := filepath.Join(dir, composerJSONFile) err = os.WriteFile(composerPath, []byte(composerJSON), 0644) RequireNoError(t, err) @@ -108,11 +108,11 @@ func TestPHP_IsLaravelProject_Bad(t *T) { // Create artisan file artisanPath := filepath.Join(dir, "artisan") - err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) + err := os.WriteFile(artisanPath, []byte(testPHPShebang), 0755) RequireNoError(t, err) // Create invalid composer.json - composerPath := filepath.Join(dir, "composer.json") + composerPath := filepath.Join(dir, composerJSONFile) err = os.WriteFile(composerPath, []byte("not valid json{"), 0644) RequireNoError(t, err) @@ -139,7 +139,7 @@ func TestPHP_IsFrankenPHPProject_Good(t *T) { "laravel/octane": "^2.0" } }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) + err := os.WriteFile(filepath.Join(dir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) // Create config directory and octane.php @@ -151,7 +151,7 @@ func TestPHP_IsFrankenPHPProject_Good(t *T) { return [ 'server' => 'frankenphp', ];` - err = os.WriteFile(filepath.Join(configDir, "octane.php"), []byte(octaneConfig), 0644) + err = os.WriteFile(filepath.Join(configDir, testOctaneFile), []byte(octaneConfig), 0644) RequireNoError(t, err) AssertTrue(t, IsFrankenPHPProject(dir)) @@ -166,7 +166,7 @@ return [ "laravel/octane": "^2.0" } }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) + err := os.WriteFile(filepath.Join(dir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) // No config file - should still return true (assume frankenphp) @@ -185,7 +185,7 @@ return [ "laravel/octane": "^2.0" } }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) + err := os.WriteFile(filepath.Join(dir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) // Create config directory and octane.php with no read permissions @@ -193,8 +193,8 @@ return [ err = os.MkdirAll(configDir, 0755) RequireNoError(t, err) - octanePath := filepath.Join(configDir, "octane.php") - err = os.WriteFile(octanePath, []byte(" 'swoole', ];` - err = os.WriteFile(filepath.Join(configDir, "octane.php"), []byte(octaneConfig), 0644) + err = os.WriteFile(filepath.Join(configDir, testOctaneFile), []byte(octaneConfig), 0644) RequireNoError(t, err) AssertFalse(t, IsFrankenPHPProject(dir)) diff --git a/pkg/php/dockerfile.go b/pkg/php/dockerfile.go index 54d8465..8226842 100644 --- a/pkg/php/dockerfile.go +++ b/pkg/php/dockerfile.go @@ -57,15 +57,15 @@ func DetectDockerfileConfig(dir string) (*DockerfileConfig, error) { } // Read composer.json - composerPath := filepath.Join(dir, "composer.json") + composerPath := filepath.Join(dir, composerJSONFile) composerContent, err := m.Read(composerPath) if err != nil { - return nil, cli.WrapVerb(err, "read", "composer.json") + return nil, cli.WrapVerb(err, "read", composerJSONFile) } var composer ComposerJSON if err := json.Unmarshal([]byte(composerContent), &composer); err != nil { - return nil, cli.WrapVerb(err, "parse", "composer.json") + return nil, cli.WrapVerb(err, "parse", composerJSONFile) } // Detect PHP version from composer.json @@ -319,7 +319,7 @@ func extractPHPVersion(constraint string) string { // hasNodeAssets checks if the project has frontend assets. func hasNodeAssets(dir string) bool { m := getMedium() - packageJSON := filepath.Join(dir, "package.json") + packageJSON := filepath.Join(dir, packageJSONFile) if !m.IsFile(packageJSON) { return false } diff --git a/pkg/php/dockerfile_test.go b/pkg/php/dockerfile_test.go index 1f20b3e..24740e6 100644 --- a/pkg/php/dockerfile_test.go +++ b/pkg/php/dockerfile_test.go @@ -18,11 +18,11 @@ func TestPHP_GenerateDockerfile_Good(t *T) { "laravel/framework": "^11.0" } }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) + err := os.WriteFile(filepath.Join(dir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) // Create composer.lock - err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) + err = os.WriteFile(filepath.Join(dir, composerLockFile), []byte("{}"), 0644) RequireNoError(t, err) content, err := GenerateDockerfile(dir) @@ -47,9 +47,9 @@ func TestPHP_GenerateDockerfile_Good(t *T) { "laravel/octane": "^2.0" } }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) + err := os.WriteFile(filepath.Join(dir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) - err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) + err = os.WriteFile(filepath.Join(dir, composerLockFile), []byte("{}"), 0644) RequireNoError(t, err) content, err := GenerateDockerfile(dir) @@ -69,9 +69,9 @@ func TestPHP_GenerateDockerfile_Good(t *T) { "laravel/framework": "^11.0" } }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) + err := os.WriteFile(filepath.Join(dir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) - err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) + err = os.WriteFile(filepath.Join(dir, composerLockFile), []byte("{}"), 0644) RequireNoError(t, err) packageJSON := `{ @@ -81,7 +81,7 @@ func TestPHP_GenerateDockerfile_Good(t *T) { "build": "vite build" } }` - err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) + err = os.WriteFile(filepath.Join(dir, packageJSONFile), []byte(packageJSON), 0644) RequireNoError(t, err) err = os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0644) RequireNoError(t, err) @@ -106,9 +106,9 @@ func TestPHP_GenerateDockerfile_Good(t *T) { "laravel/framework": "^11.0" } }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) + err := os.WriteFile(filepath.Join(dir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) - err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) + err = os.WriteFile(filepath.Join(dir, composerLockFile), []byte("{}"), 0644) RequireNoError(t, err) packageJSON := `{ @@ -117,7 +117,7 @@ func TestPHP_GenerateDockerfile_Good(t *T) { "build": "vite build" } }` - err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) + err = os.WriteFile(filepath.Join(dir, packageJSONFile), []byte(packageJSON), 0644) RequireNoError(t, err) // Create pnpm-lock.yaml @@ -142,9 +142,9 @@ func TestPHP_GenerateDockerfile_Good(t *T) { "predis/predis": "^2.0" } }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) + err := os.WriteFile(filepath.Join(dir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) - err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) + err = os.WriteFile(filepath.Join(dir, composerLockFile), []byte("{}"), 0644) RequireNoError(t, err) content, err := GenerateDockerfile(dir) @@ -166,9 +166,9 @@ func TestPHP_GenerateDockerfile_Good(t *T) { "ext-intl": "*" } }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) + err := os.WriteFile(filepath.Join(dir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) - err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) + err = os.WriteFile(filepath.Join(dir, composerLockFile), []byte("{}"), 0644) RequireNoError(t, err) content, err := GenerateDockerfile(dir) @@ -187,13 +187,13 @@ func TestPHP_GenerateDockerfile_Bad(t *T) { _, err := GenerateDockerfile(dir) AssertError(t, err) - AssertContains(t, err.Error(), "composer.json") + AssertContains(t, err.Error(), composerJSONFile) }) t.Run("invalid composer.json", func(t *T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644) + err := os.WriteFile(filepath.Join(dir, composerJSONFile), []byte("not json{"), 0644) RequireNoError(t, err) _, err = GenerateDockerfile(dir) @@ -215,11 +215,11 @@ func TestPHP_DetectDockerfileConfig_Good(t *T) { "intervention/image": "^3.0" } }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) + err := os.WriteFile(filepath.Join(dir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) packageJSON := `{"scripts": {"build": "vite build"}}` - err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) + err = os.WriteFile(filepath.Join(dir, packageJSONFile), []byte(packageJSON), 0644) RequireNoError(t, err) err = os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0644) RequireNoError(t, err) @@ -358,7 +358,7 @@ func TestPHP_HasNodeAssets_Good(t *T) { "build": "vite build" } }` - err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) + err := os.WriteFile(filepath.Join(dir, packageJSONFile), []byte(packageJSON), 0644) RequireNoError(t, err) AssertTrue(t, hasNodeAssets(dir)) @@ -380,7 +380,7 @@ func TestPHP_HasNodeAssets_Bad(t *T) { "dev": "vite" } }` - err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) + err := os.WriteFile(filepath.Join(dir, packageJSONFile), []byte(packageJSON), 0644) RequireNoError(t, err) AssertFalse(t, hasNodeAssets(dir)) @@ -389,7 +389,7 @@ func TestPHP_HasNodeAssets_Bad(t *T) { t.Run("invalid package.json", func(t *T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("invalid{"), 0644) + err := os.WriteFile(filepath.Join(dir, packageJSONFile), []byte("invalid{"), 0644) RequireNoError(t, err) AssertFalse(t, hasNodeAssets(dir)) @@ -416,7 +416,7 @@ func TestPHP_GenerateDockerfileFromConfig_Good(t *T) { t.Run("minimal config", func(t *T) { config := &DockerfileConfig{ PHPVersion: "8.3", - BaseImage: "dunglas/frankenphp", + BaseImage: testFrankenPHPImage, UseAlpine: true, } @@ -431,7 +431,7 @@ func TestPHP_GenerateDockerfileFromConfig_Good(t *T) { t.Run("with extensions", func(t *T) { config := &DockerfileConfig{ PHPVersion: "8.3", - BaseImage: "dunglas/frankenphp", + BaseImage: testFrankenPHPImage, UseAlpine: true, PHPExtensions: []string{"redis", "gd", "intl"}, } @@ -444,7 +444,7 @@ func TestPHP_GenerateDockerfileFromConfig_Good(t *T) { t.Run("Laravel with Octane", func(t *T) { config := &DockerfileConfig{ PHPVersion: "8.3", - BaseImage: "dunglas/frankenphp", + BaseImage: testFrankenPHPImage, UseAlpine: true, IsLaravel: true, HasOctane: true, @@ -462,7 +462,7 @@ func TestPHP_GenerateDockerfileFromConfig_Good(t *T) { t.Run("with frontend assets", func(t *T) { config := &DockerfileConfig{ PHPVersion: "8.3", - BaseImage: "dunglas/frankenphp", + BaseImage: testFrankenPHPImage, UseAlpine: true, HasAssets: true, PackageManager: "npm", @@ -481,7 +481,7 @@ func TestPHP_GenerateDockerfileFromConfig_Good(t *T) { t.Run("with yarn", func(t *T) { config := &DockerfileConfig{ PHPVersion: "8.3", - BaseImage: "dunglas/frankenphp", + BaseImage: testFrankenPHPImage, UseAlpine: true, HasAssets: true, PackageManager: "yarn", @@ -497,7 +497,7 @@ func TestPHP_GenerateDockerfileFromConfig_Good(t *T) { t.Run("with bun", func(t *T) { config := &DockerfileConfig{ PHPVersion: "8.3", - BaseImage: "dunglas/frankenphp", + BaseImage: testFrankenPHPImage, UseAlpine: true, HasAssets: true, PackageManager: "bun", @@ -514,7 +514,7 @@ func TestPHP_GenerateDockerfileFromConfig_Good(t *T) { t.Run("non-alpine image", func(t *T) { config := &DockerfileConfig{ PHPVersion: "8.3", - BaseImage: "dunglas/frankenphp", + BaseImage: testFrankenPHPImage, UseAlpine: false, } @@ -529,7 +529,7 @@ func TestPHP_IsPHPProject_Good(t *T) { t.Run("project with composer.json", func(t *T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("{}"), 0644) + err := os.WriteFile(filepath.Join(dir, composerJSONFile), []byte("{}"), 0644) RequireNoError(t, err) AssertTrue(t, IsPHPProject(dir)) @@ -580,13 +580,13 @@ func TestPHP_DockerfileStructure_Good(t *T) { "predis/predis": "^2.0" } }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) + err := os.WriteFile(filepath.Join(dir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) - err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) + err = os.WriteFile(filepath.Join(dir, composerLockFile), []byte("{}"), 0644) RequireNoError(t, err) packageJSON := `{"scripts": {"build": "vite build"}}` - err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) + err = os.WriteFile(filepath.Join(dir, packageJSONFile), []byte(packageJSON), 0644) RequireNoError(t, err) err = os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0644) RequireNoError(t, err) diff --git a/pkg/php/env.go b/pkg/php/env.go index e3b0551..0cbcbc0 100644 --- a/pkg/php/env.go +++ b/pkg/php/env.go @@ -60,7 +60,7 @@ func PrepareRuntimeEnvironment(laravelRoot, appName string) (*RuntimeEnvironment // Replace the extracted storage/ with a symlink to the persistent one extractedStorage := filepath.Join(laravelRoot, "storage") - os.RemoveAll(extractedStorage) + _ = os.RemoveAll(extractedStorage) persistentStorage := filepath.Join(dataDir, "storage") if err := os.Symlink(persistentStorage, extractedStorage); err != nil { return nil, fmt.Errorf("symlink storage: %w", err) @@ -81,7 +81,7 @@ func AppendEnv(laravelRoot, key, value string) error { if err != nil { return err } - defer f.Close() + defer func() { _ = f.Close() }() _, err = fmt.Fprintf(f, "%s=\"%s\"\n", key, value) return err } diff --git a/pkg/php/extract.go b/pkg/php/extract.go index b188307..abbec46 100644 --- a/pkg/php/extract.go +++ b/pkg/php/extract.go @@ -41,7 +41,7 @@ func Extract(fsys fs.FS, prefix string) (string, error) { }) if err != nil { - os.RemoveAll(tmpDir) + _ = os.RemoveAll(tmpDir) return "", fmt.Errorf("extract Laravel: %w", err) } diff --git a/pkg/php/handler_stub.go b/pkg/php/handler_stub.go index bf8af90..eebd2d1 100644 --- a/pkg/php/handler_stub.go +++ b/pkg/php/handler_stub.go @@ -34,7 +34,9 @@ func NewHandler(laravelRoot string, cfg HandlerConfig) (*Handler, func(), error) docRoot: filepath.Join(laravelRoot, "public"), laravelRoot: laravelRoot, } - cleanup := func() {} + cleanup := func() { + // No resources are allocated when embedded FrankenPHP is not built. + } return handler, cleanup, fmt.Errorf("embedded FrankenPHP support is not built; rebuild with -tags frankenphp") } diff --git a/pkg/php/packages.go b/pkg/php/packages.go index 4d66998..bc33499 100644 --- a/pkg/php/packages.go +++ b/pkg/php/packages.go @@ -26,15 +26,15 @@ type composerRepository struct { // readComposerJSON reads and parses composer.json from the given directory. func readComposerJSON(dir string) (map[string]json.RawMessage, error) { m := getMedium() - composerPath := filepath.Join(dir, "composer.json") + composerPath := filepath.Join(dir, composerJSONFile) content, err := m.Read(composerPath) if err != nil { - return nil, cli.WrapVerb(err, "read", "composer.json") + return nil, cli.WrapVerb(err, "read", composerJSONFile) } var raw map[string]json.RawMessage if err := json.Unmarshal([]byte(content), &raw); err != nil { - return nil, cli.WrapVerb(err, "parse", "composer.json") + return nil, cli.WrapVerb(err, "parse", composerJSONFile) } return raw, nil @@ -43,18 +43,18 @@ func readComposerJSON(dir string) (map[string]json.RawMessage, error) { // writeComposerJSON writes the composer.json to the given directory. func writeComposerJSON(dir string, raw map[string]json.RawMessage) error { m := getMedium() - composerPath := filepath.Join(dir, "composer.json") + composerPath := filepath.Join(dir, composerJSONFile) data, err := json.MarshalIndent(raw, "", " ") if err != nil { - return cli.WrapVerb(err, "marshal", "composer.json") + return cli.WrapVerb(err, "marshal", composerJSONFile) } // Add trailing newline content := string(data) + "\n" if err := m.Write(composerPath, content); err != nil { - return cli.WrapVerb(err, "write", "composer.json") + return cli.WrapVerb(err, "write", composerJSONFile) } return nil @@ -94,7 +94,7 @@ func setRepositories(raw map[string]json.RawMessage, repos []composerRepository) // getPackageInfo reads package name and version from a composer.json in the given path. func getPackageInfo(packagePath string) (name, version string, err error) { m := getMedium() - composerPath := filepath.Join(packagePath, "composer.json") + composerPath := filepath.Join(packagePath, composerJSONFile) content, err := m.Read(composerPath) if err != nil { return "", "", cli.WrapVerb(err, "read", "package composer.json") @@ -119,7 +119,7 @@ func getPackageInfo(packagePath string) (name, version string, err error) { // LinkPackages adds path repositories to composer.json for local package development. func LinkPackages(dir string, packages []string) error { if !IsPHPProject(dir) { - return cli.Err("not a PHP project (missing composer.json)") + return cli.Err(notPHPProjectComposerMessage) } raw, err := readComposerJSON(dir) @@ -133,45 +133,16 @@ func LinkPackages(dir string, packages []string) error { } for _, packagePath := range packages { - // Resolve absolute path - absPath, err := filepath.Abs(packagePath) + absPath, pkgName, err := validateLinkPackage(packagePath) if err != nil { - return cli.Err("failed to resolve path %s: %w", packagePath, err) + return err } - // Verify the path exists and has a composer.json - if !IsPHPProject(absPath) { - return cli.Err("not a PHP package (missing composer.json): %s", absPath) - } - - // Get package name for validation - pkgName, _, err := getPackageInfo(absPath) - if err != nil { - return cli.Err("failed to get package info from %s: %w", absPath, err) - } - - // Check if already linked - alreadyLinked := false - for _, repo := range repos { - if repo.Type == "path" && repo.URL == absPath { - alreadyLinked = true - break - } - } - - if alreadyLinked { + if isPackageLinked(repos, absPath) { continue } - // Add path repository - repos = append(repos, composerRepository{ - Type: "path", - URL: absPath, - Options: map[string]any{ - "symlink": true, - }, - }) - + repos = append(repos, pathComposerRepository(absPath)) cli.Print("Linked: %s -> %s\n", pkgName, absPath) } @@ -182,10 +153,47 @@ func LinkPackages(dir string, packages []string) error { return writeComposerJSON(dir, raw) } +func validateLinkPackage(packagePath string) (string, string, error) { + absPath, err := filepath.Abs(packagePath) + if err != nil { + return "", "", cli.Err("failed to resolve path %s: %w", packagePath, err) + } + + if !IsPHPProject(absPath) { + return "", "", cli.Err("not a PHP package (missing composer.json): %s", absPath) + } + + pkgName, _, err := getPackageInfo(absPath) + if err != nil { + return "", "", cli.Err("failed to get package info from %s: %w", 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 { if !IsPHPProject(dir) { - return cli.Err("not a PHP project (missing composer.json)") + return cli.Err(notPHPProjectComposerMessage) } raw, err := readComposerJSON(dir) @@ -207,48 +215,47 @@ func UnlinkPackages(dir string, packages []string) error { // Filter out unlinked packages filtered := make([]composerRepository, 0, len(repos)) for _, repo := range repos { - if repo.Type != "path" { + if !shouldUnlinkRepository(repo, toUnlink) { filtered = append(filtered, repo) - continue } + } - // Check if this repo should be unlinked - shouldUnlink := false + if err := setRepositories(raw, filtered); err != nil { + return err + } - // Try to get package name from the path - if IsPHPProject(repo.URL) { - pkgName, _, err := getPackageInfo(repo.URL) - if err == nil && toUnlink[pkgName] { - shouldUnlink = true - cli.Print("Unlinked: %s\n", pkgName) - } - } + return writeComposerJSON(dir, raw) +} - // Also check if path matches any of the provided names - for pkg := range toUnlink { - if repo.URL == pkg || filepath.Base(repo.URL) == pkg { - shouldUnlink = true - cli.Print("Unlinked: %s\n", repo.URL) - break - } - } +func shouldUnlinkRepository(repo composerRepository, toUnlink map[string]bool) bool { + if repo.Type != "path" { + return false + } - if !shouldUnlink { - filtered = append(filtered, repo) + shouldUnlink := false + if IsPHPProject(repo.URL) { + pkgName, _, err := getPackageInfo(repo.URL) + if err == nil && toUnlink[pkgName] { + shouldUnlink = true + cli.Print("Unlinked: %s\n", pkgName) } } - if err := setRepositories(raw, filtered); err != nil { - return err + for pkg := range toUnlink { + if repo.URL == pkg || filepath.Base(repo.URL) == pkg { + shouldUnlink = true + cli.Print("Unlinked: %s\n", repo.URL) + break + } } - return writeComposerJSON(dir, raw) + return shouldUnlink } // UpdatePackages runs composer update for specific packages. func UpdatePackages(dir string, packages []string) error { if !IsPHPProject(dir) { - return cli.Err("not a PHP project (missing composer.json)") + return cli.Err(notPHPProjectComposerMessage) } args := []string{"update"} @@ -265,7 +272,7 @@ func UpdatePackages(dir string, packages []string) error { // ListLinkedPackages returns all path repositories from composer.json. func ListLinkedPackages(dir string) ([]LinkedPackage, error) { if !IsPHPProject(dir) { - return nil, cli.Err("not a PHP project (missing composer.json)") + return nil, cli.Err(notPHPProjectComposerMessage) } raw, err := readComposerJSON(dir) diff --git a/pkg/php/packages_test.go b/pkg/php/packages_test.go index 3488f9f..363d462 100644 --- a/pkg/php/packages_test.go +++ b/pkg/php/packages_test.go @@ -15,7 +15,7 @@ func TestPHP_ReadComposerJSON_Good(t *T) { "php": "^8.2" } }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) + err := os.WriteFile(filepath.Join(dir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) raw, err := readComposerJSON(dir) @@ -32,7 +32,7 @@ func TestPHP_ReadComposerJSON_Good(t *T) { "require": {"php": "^8.2"}, "autoload": {"psr-4": {"App\\": "src/"}} }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) + err := os.WriteFile(filepath.Join(dir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) raw, err := readComposerJSON(dir) @@ -51,7 +51,7 @@ func TestPHP_ReadComposerJSON_Bad(t *T) { t.Run("invalid JSON", func(t *T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644) + err := os.WriteFile(filepath.Join(dir, composerJSONFile), []byte("not json{"), 0644) RequireNoError(t, err) _, err = readComposerJSON(dir) @@ -70,7 +70,7 @@ func TestPHP_WriteComposerJSON_Good(t *T) { AssertNoError(t, err) // Verify file was written - content, err := os.ReadFile(filepath.Join(dir, "composer.json")) + content, err := os.ReadFile(filepath.Join(dir, composerJSONFile)) AssertNoError(t, err) AssertContains(t, string(content), "test/project") // Verify trailing newline @@ -86,7 +86,7 @@ func TestPHP_WriteComposerJSON_Good(t *T) { err := writeComposerJSON(dir, raw) AssertNoError(t, err) - content, err := os.ReadFile(filepath.Join(dir, "composer.json")) + content, err := os.ReadFile(filepath.Join(dir, composerJSONFile)) AssertNoError(t, err) // Should be indented AssertContains(t, string(content), " ") @@ -116,13 +116,13 @@ func TestPHP_GetRepositories_Good(t *T) { t.Run("parses existing repositories", func(t *T) { raw := make(map[string]json.RawMessage) raw["name"] = json.RawMessage(`"test/project"`) - raw["repositories"] = json.RawMessage(`[{"type":"path","url":"/path/to/package"}]`) + raw["repositories"] = json.RawMessage(`[{"type":"path","url":"` + testPackagePath + `"}]`) repos, err := getRepositories(raw) AssertNoError(t, err) AssertLen(t, repos, 1) AssertEqual(t, "path", repos[0].Type) - AssertEqual(t, "/path/to/package", repos[0].URL) + AssertEqual(t, testPackagePath, repos[0].URL) }) t.Run("parses repositories with options", func(t *T) { @@ -152,12 +152,12 @@ func TestPHP_SetRepositories_Good(t *T) { t.Run("sets repositories", func(t *T) { raw := make(map[string]json.RawMessage) repos := []composerRepository{ - {Type: "path", URL: "/path/to/package"}, + {Type: "path", URL: testPackagePath}, } err := setRepositories(raw, repos) AssertNoError(t, err) - AssertContains(t, string(raw["repositories"]), "/path/to/package") + AssertContains(t, string(raw["repositories"]), testPackagePath) }) t.Run("removes repositories key when empty", func(t *T) { @@ -175,29 +175,29 @@ func TestPHP_GetPackageInfo_Good(t *T) { t.Run("extracts package name and version", func(t *T) { dir := t.TempDir() composerJSON := `{ - "name": "vendor/package", + "name": "` + testVendorPackage + `", "version": "1.0.0" }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) + err := os.WriteFile(filepath.Join(dir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) name, version, err := getPackageInfo(dir) AssertNoError(t, err) - AssertEqual(t, "vendor/package", name) + AssertEqual(t, testVendorPackage, name) AssertEqual(t, "1.0.0", version) }) t.Run("works without version", func(t *T) { dir := t.TempDir() composerJSON := `{ - "name": "vendor/package" + "name": "` + testVendorPackage + `" }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) + err := os.WriteFile(filepath.Join(dir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) name, version, err := getPackageInfo(dir) AssertNoError(t, err) - AssertEqual(t, "vendor/package", name) + AssertEqual(t, testVendorPackage, name) AssertEqual(t, "", version) }) } @@ -212,7 +212,7 @@ func TestPHP_GetPackageInfo_Bad(t *T) { t.Run("invalid JSON", func(t *T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644) + err := os.WriteFile(filepath.Join(dir, composerJSONFile), []byte("not json{"), 0644) RequireNoError(t, err) _, _, err = getPackageInfo(dir) @@ -223,7 +223,7 @@ func TestPHP_GetPackageInfo_Bad(t *T) { t.Run("missing name", func(t *T) { dir := t.TempDir() composerJSON := `{"version": "1.0.0"}` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) + err := os.WriteFile(filepath.Join(dir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) _, _, err = getPackageInfo(dir) @@ -236,12 +236,12 @@ func TestPHP_LinkPackages_Good(t *T) { t.Run("links a package", func(t *T) { // Create project directory projectDir := t.TempDir() - err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644) + err := os.WriteFile(filepath.Join(projectDir, composerJSONFile), []byte(`{"name":"test/project"}`), 0644) RequireNoError(t, err) // Create package directory packageDir := t.TempDir() - err = os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package"}`), 0644) + err = os.WriteFile(filepath.Join(packageDir, composerJSONFile), []byte(`{"name":"`+testVendorPackage+`"}`), 0644) RequireNoError(t, err) err = LinkPackages(projectDir, []string{packageDir}) @@ -261,7 +261,7 @@ func TestPHP_LinkPackages_Good(t *T) { projectDir := t.TempDir() packageDir := t.TempDir() - err := os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package"}`), 0644) + err := os.WriteFile(filepath.Join(packageDir, composerJSONFile), []byte(`{"name":"`+testVendorPackage+`"}`), 0644) RequireNoError(t, err) absPackagePath, _ := filepath.Abs(packageDir) @@ -269,7 +269,7 @@ func TestPHP_LinkPackages_Good(t *T) { "name": "test/project", "repositories": [{"type":"path","url":"` + absPackagePath + `"}] }` - err = os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) + err = os.WriteFile(filepath.Join(projectDir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) // Link again - should not add duplicate @@ -285,15 +285,15 @@ func TestPHP_LinkPackages_Good(t *T) { t.Run("links multiple packages", func(t *T) { projectDir := t.TempDir() - err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644) + err := os.WriteFile(filepath.Join(projectDir, composerJSONFile), []byte(`{"name":"test/project"}`), 0644) RequireNoError(t, err) pkg1Dir := t.TempDir() - err = os.WriteFile(filepath.Join(pkg1Dir, "composer.json"), []byte(`{"name":"vendor/pkg1"}`), 0644) + err = os.WriteFile(filepath.Join(pkg1Dir, composerJSONFile), []byte(`{"name":"vendor/pkg1"}`), 0644) RequireNoError(t, err) pkg2Dir := t.TempDir() - err = os.WriteFile(filepath.Join(pkg2Dir, "composer.json"), []byte(`{"name":"vendor/pkg2"}`), 0644) + err = os.WriteFile(filepath.Join(pkg2Dir, composerJSONFile), []byte(`{"name":"vendor/pkg2"}`), 0644) RequireNoError(t, err) err = LinkPackages(projectDir, []string{pkg1Dir, pkg2Dir}) @@ -308,16 +308,16 @@ func TestPHP_LinkPackages_Good(t *T) { } func TestPHP_LinkPackages_Bad(t *T) { - t.Run("fails for non-PHP project", func(t *T) { + t.Run(testFailsNonPHPProject, func(t *T) { dir := t.TempDir() - err := LinkPackages(dir, []string{"/path/to/package"}) + err := LinkPackages(dir, []string{testPackagePath}) AssertError(t, err) - AssertContains(t, err.Error(), "not a PHP project") + AssertContains(t, err.Error(), testNotPHPProject) }) t.Run("fails for non-PHP package", func(t *T) { projectDir := t.TempDir() - err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644) + err := os.WriteFile(filepath.Join(projectDir, composerJSONFile), []byte(`{"name":"test/project"}`), 0644) RequireNoError(t, err) packageDir := t.TempDir() @@ -334,7 +334,7 @@ func TestPHP_UnlinkPackages_Good(t *T) { projectDir := t.TempDir() packageDir := t.TempDir() - err := os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package"}`), 0644) + err := os.WriteFile(filepath.Join(packageDir, composerJSONFile), []byte(`{"name":"`+testVendorPackage+`"}`), 0644) RequireNoError(t, err) absPackagePath, _ := filepath.Abs(packageDir) @@ -342,10 +342,10 @@ func TestPHP_UnlinkPackages_Good(t *T) { "name": "test/project", "repositories": [{"type":"path","url":"` + absPackagePath + `"}] }` - err = os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) + err = os.WriteFile(filepath.Join(projectDir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) - err = UnlinkPackages(projectDir, []string{"vendor/package"}) + err = UnlinkPackages(projectDir, []string{testVendorPackage}) AssertNoError(t, err) raw, err := readComposerJSON(projectDir) @@ -364,7 +364,7 @@ func TestPHP_UnlinkPackages_Good(t *T) { "name": "test/project", "repositories": [{"type":"path","url":"` + absPackagePath + `"}] }` - err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) + err := os.WriteFile(filepath.Join(projectDir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) err = UnlinkPackages(projectDir, []string{absPackagePath}) @@ -386,7 +386,7 @@ func TestPHP_UnlinkPackages_Good(t *T) { {"type":"path","url":"/local/path"} ] }` - err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) + err := os.WriteFile(filepath.Join(projectDir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) err = UnlinkPackages(projectDir, []string{"/local/path"}) @@ -402,11 +402,11 @@ func TestPHP_UnlinkPackages_Good(t *T) { } func TestPHP_UnlinkPackages_Bad(t *T) { - t.Run("fails for non-PHP project", func(t *T) { + t.Run(testFailsNonPHPProject, func(t *T) { dir := t.TempDir() - err := UnlinkPackages(dir, []string{"vendor/package"}) + err := UnlinkPackages(dir, []string{testVendorPackage}) AssertError(t, err) - AssertContains(t, err.Error(), "not a PHP project") + AssertContains(t, err.Error(), testNotPHPProject) }) } @@ -415,7 +415,7 @@ func TestPHP_ListLinkedPackages_Good(t *T) { projectDir := t.TempDir() packageDir := t.TempDir() - err := os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package","version":"1.0.0"}`), 0644) + err := os.WriteFile(filepath.Join(packageDir, composerJSONFile), []byte(`{"name":"`+testVendorPackage+`","version":"1.0.0"}`), 0644) RequireNoError(t, err) absPackagePath, _ := filepath.Abs(packageDir) @@ -423,20 +423,20 @@ func TestPHP_ListLinkedPackages_Good(t *T) { "name": "test/project", "repositories": [{"type":"path","url":"` + absPackagePath + `"}] }` - err = os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) + err = os.WriteFile(filepath.Join(projectDir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) linked, err := ListLinkedPackages(projectDir) AssertNoError(t, err) AssertLen(t, linked, 1) - AssertEqual(t, "vendor/package", linked[0].Name) + AssertEqual(t, testVendorPackage, linked[0].Name) AssertEqual(t, "1.0.0", linked[0].Version) AssertEqual(t, absPackagePath, linked[0].Path) }) t.Run("returns empty list when no linked packages", func(t *T) { projectDir := t.TempDir() - err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644) + err := os.WriteFile(filepath.Join(projectDir, composerJSONFile), []byte(`{"name":"test/project"}`), 0644) RequireNoError(t, err) linked, err := ListLinkedPackages(projectDir) @@ -450,7 +450,7 @@ func TestPHP_ListLinkedPackages_Good(t *T) { "name": "test/project", "repositories": [{"type":"path","url":"/nonexistent/package-name"}] }` - err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) + err := os.WriteFile(filepath.Join(projectDir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) linked, err := ListLinkedPackages(projectDir) @@ -467,7 +467,7 @@ func TestPHP_ListLinkedPackages_Good(t *T) { {"type":"vcs","url":"https://github.com/vendor/package"} ] }` - err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) + err := os.WriteFile(filepath.Join(projectDir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) linked, err := ListLinkedPackages(projectDir) @@ -477,20 +477,20 @@ func TestPHP_ListLinkedPackages_Good(t *T) { } func TestPHP_ListLinkedPackages_Bad(t *T) { - t.Run("fails for non-PHP project", func(t *T) { + t.Run(testFailsNonPHPProject, func(t *T) { dir := t.TempDir() _, err := ListLinkedPackages(dir) AssertError(t, err) - AssertContains(t, err.Error(), "not a PHP project") + AssertContains(t, err.Error(), testNotPHPProject) }) } func TestPHP_UpdatePackages_Bad(t *T) { - t.Run("fails for non-PHP project", func(t *T) { + t.Run(testFailsNonPHPProject, func(t *T) { dir := t.TempDir() - err := UpdatePackages(dir, []string{"vendor/package"}) + err := UpdatePackages(dir, []string{testVendorPackage}) AssertError(t, err) - AssertContains(t, err.Error(), "not a PHP project") + AssertContains(t, err.Error(), testNotPHPProject) }) } @@ -499,41 +499,41 @@ func TestPHP_UpdatePackages_Good(t *T) { t.Run("runs composer update", func(t *T) { projectDir := t.TempDir() - err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644) + err := os.WriteFile(filepath.Join(projectDir, composerJSONFile), []byte(`{"name":"test/project"}`), 0644) RequireNoError(t, err) - _ = UpdatePackages(projectDir, []string{"vendor/package"}) + _ = UpdatePackages(projectDir, []string{testVendorPackage}) // This will fail because composer update needs real dependencies // but it validates the command runs }) } func TestLinkedPackage_Struct(t *T) { - t.Run("all fields accessible", func(t *T) { + t.Run(testAllFieldsAccessible, func(t *T) { pkg := LinkedPackage{ - Name: "vendor/package", - Path: "/path/to/package", + Name: testVendorPackage, + Path: testPackagePath, Version: "1.0.0", } - AssertEqual(t, "vendor/package", pkg.Name) - AssertEqual(t, "/path/to/package", pkg.Path) + AssertEqual(t, testVendorPackage, pkg.Name) + AssertEqual(t, testPackagePath, pkg.Path) AssertEqual(t, "1.0.0", pkg.Version) }) } func TestComposerRepository_Struct(t *T) { - t.Run("all fields accessible", func(t *T) { + t.Run(testAllFieldsAccessible, func(t *T) { repo := composerRepository{ Type: "path", - URL: "/path/to/package", + URL: testPackagePath, Options: map[string]any{ "symlink": true, }, } AssertEqual(t, "path", repo.Type) - AssertEqual(t, "/path/to/package", repo.URL) + AssertEqual(t, testPackagePath, repo.URL) AssertEqual(t, true, repo.Options["symlink"]) }) } diff --git a/pkg/php/php.go b/pkg/php/php.go index dcedd35..99b958f 100644 --- a/pkg/php/php.go +++ b/pkg/php/php.go @@ -73,16 +73,8 @@ func (d *DevServer) Start(ctx context.Context, opts Options) error { return cli.Err("dev server is already running") } - // Merge options - if opts.Dir != "" { - d.opts.Dir = opts.Dir - } - if d.opts.Dir == "" { - cwd, err := os.Getwd() - if err != nil { - return cli.WrapVerb(err, "get", "working directory") - } - d.opts.Dir = cwd + if err := d.applyStartOptions(opts); err != nil { + return err } // Verify this is a Laravel project @@ -93,98 +85,111 @@ func (d *DevServer) Start(ctx context.Context, opts Options) error { // Create cancellable context d.ctx, d.cancel = context.WithCancel(ctx) - // Detect or use provided services - services := opts.Services - if len(services) == 0 { - services = DetectServices(d.opts.Dir) + // Setup SSL if HTTPS is enabled + certFile, keyFile, err := setupDevSSL(d.opts.Dir, opts) + if err != nil { + return err } - // Filter out disabled services - services = d.filterServices(services, opts) + // Create services + d.services = d.createServices(opts, certFile, keyFile) - // Setup SSL if HTTPS is enabled - var certFile, keyFile string - if opts.HTTPS { - domain := opts.Domain - if domain == "" { - // Try to get domain from APP_URL - appURL := GetLaravelAppURL(d.opts.Dir) - if appURL != "" { - domain = ExtractDomainFromURL(appURL) - } - } - if domain == "" { - domain = "localhost" - } + // Start all services + if err := d.startServices(); err != nil { + return err + } - var err error - certFile, keyFile, err = SetupSSLIfNeeded(domain, SSLOptions{}) - if err != nil { - return cli.WrapVerb(err, "setup", "SSL") - } + d.running = true + return nil +} + +func (d *DevServer) applyStartOptions(opts Options) error { + if opts.Dir != "" { + d.opts.Dir = opts.Dir + } + if d.opts.Dir != "" { + return nil } - // Create services - d.services = make([]Service, 0) + cwd, err := os.Getwd() + if err != nil { + return cli.WrapVerb(err, "get", workingDirectorySubject) + } + d.opts.Dir = cwd + return nil +} - for _, svc := range services { - var service Service +func setupDevSSL(dir string, opts Options) (string, string, error) { + if !opts.HTTPS { + return "", "", nil + } - switch svc { - case ServiceFrankenPHP: - port := opts.FrankenPHPPort - if port == 0 { - port = 8000 - } - httpsPort := opts.HTTPSPort - if httpsPort == 0 { - httpsPort = 443 - } - service = NewFrankenPHPService(d.opts.Dir, FrankenPHPOptions{ - Port: port, - HTTPSPort: httpsPort, - HTTPS: opts.HTTPS, - CertFile: certFile, - KeyFile: keyFile, - }) + certFile, keyFile, err := SetupSSLIfNeeded(devSSLDomain(dir, opts.Domain), SSLOptions{}) + if err != nil { + return "", "", cli.WrapVerb(err, "setup", "SSL") + } - case ServiceVite: - port := opts.VitePort - if port == 0 { - port = 5173 - } - service = NewViteService(d.opts.Dir, ViteOptions{ - Port: port, - }) + return certFile, keyFile, nil +} - case ServiceHorizon: - service = NewHorizonService(d.opts.Dir) +func devSSLDomain(dir, configuredDomain string) string { + if configuredDomain != "" { + return configuredDomain + } + if appURL := GetLaravelAppURL(dir); appURL != "" { + return ExtractDomainFromURL(appURL) + } + return "localhost" +} - case ServiceReverb: - port := opts.ReverbPort - if port == 0 { - port = 8080 - } - service = NewReverbService(d.opts.Dir, ReverbOptions{ - Port: port, - }) +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) - case ServiceRedis: - port := opts.RedisPort - if port == 0 { - port = 6379 - } - service = NewRedisService(d.opts.Dir, RedisOptions{ - Port: port, - }) + result := make([]Service, 0, len(services)) + for _, svc := range services { + if service := d.createService(svc, opts, certFile, keyFile); service != nil { + result = append(result, service) } + } - if service != nil { - d.services = append(d.services, 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 } +} - // Start all services +func defaultPort(value, fallback int) int { + if value == 0 { + return fallback + } + return value +} + +func (d *DevServer) startServices() error { var startErrors []error for _, svc := range d.services { if err := svc.Start(d.ctx); err != nil { @@ -192,18 +197,16 @@ func (d *DevServer) Start(ctx context.Context, opts Options) error { } } - if len(startErrors) > 0 { - // Stop any services that did start - for _, svc := range d.services { - if err := svc.Stop(); err != nil { - startErrors = append(startErrors, cli.Err("cleanup %s: %v", svc.Name(), err)) - } - } - return cli.Err("failed to start services: %v", startErrors) + if len(startErrors) == 0 { + return nil } - d.running = true - return nil + for _, svc := range d.services { + if err := svc.Stop(); err != nil { + startErrors = append(startErrors, cli.Err("cleanup %s: %v", svc.Name(), err)) + } + } + return cli.Err("failed to start services: %v", startErrors) } // filterServices removes disabled services from the list. diff --git a/pkg/php/php_test.go b/pkg/php/php_test.go index 213ad87..f2985e3 100644 --- a/pkg/php/php_test.go +++ b/pkg/php/php_test.go @@ -21,7 +21,7 @@ func TestPHP_NewDevServer_Good(t *T) { t.Run("creates dev server with custom options", func(t *T) { opts := Options{ - Dir: "/tmp/test", + Dir: testTmpDir, NoVite: true, NoHorizon: true, FrankenPHPPort: 9000, @@ -29,7 +29,7 @@ func TestPHP_NewDevServer_Good(t *T) { server := NewDevServer(opts) AssertNotNil(t, server) - AssertEqual(t, "/tmp/test", server.opts.Dir) + AssertEqual(t, testTmpDir, server.opts.Dir) AssertTrue(t, server.opts.NoVite) }) } @@ -196,7 +196,7 @@ func TestPHP_MultiServiceReader_Good(t *T) { func TestPHP_MultiServiceReader_Read_Good(t *T) { t.Run("reads from readers with service prefix", func(t *T) { dir := t.TempDir() - file1, err := os.CreateTemp(dir, "log-*.log") + file1, err := os.CreateTemp(dir, testLogGlob) RequireNoError(t, err) _, _ = file1.WriteString("log content") _, _ = file1.Seek(0, 0) @@ -218,7 +218,7 @@ func TestPHP_MultiServiceReader_Read_Good(t *T) { t.Run("returns EOF when all readers are exhausted in non-follow mode", func(t *T) { dir := t.TempDir() - file1, err := os.CreateTemp(dir, "log-*.log") + file1, err := os.CreateTemp(dir, testLogGlob) RequireNoError(t, err) _ = file1.Close() // Empty file @@ -249,7 +249,7 @@ func TestPHP_Options_Good(t *T) { NoReverb: true, NoRedis: true, HTTPS: true, - Domain: "test.local", + Domain: testLocalDomain, FrankenPHPPort: 8000, HTTPSPort: 443, VitePort: 5173, @@ -264,7 +264,7 @@ func TestPHP_Options_Good(t *T) { AssertTrue(t, opts.NoReverb) AssertTrue(t, opts.NoRedis) AssertTrue(t, opts.HTTPS) - AssertEqual(t, "test.local", opts.Domain) + AssertEqual(t, testLocalDomain, opts.Domain) AssertEqual(t, 8000, opts.FrankenPHPPort) AssertEqual(t, 443, opts.HTTPSPort) AssertEqual(t, 5173, opts.VitePort) @@ -297,7 +297,7 @@ func setupLaravelProject(t *T, dir string) { t.Helper() // Create artisan file - err := os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php\n"), 0755) + err := os.WriteFile(filepath.Join(dir, "artisan"), []byte(testPHPShebang), 0755) RequireNoError(t, err) // Create composer.json with Laravel @@ -309,7 +309,7 @@ func setupLaravelProject(t *T, dir string) { "laravel/octane": "^2.0" } }` - err = os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) + err = os.WriteFile(filepath.Join(dir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) } @@ -335,7 +335,7 @@ func TestPHP_DevServer_UnifiedLogs_Bad(t *T) { func TestPHP_DevServer_Logs_Good(t *T) { t.Run("finds specific service logs", func(t *T) { dir := t.TempDir() - logFile := filepath.Join(dir, "test.log") + logFile := filepath.Join(dir, testLogFile) err := os.WriteFile(logFile, []byte("test log content"), 0644) RequireNoError(t, err) @@ -384,7 +384,7 @@ func TestDevServer_HTTPSSetup(t *T) { dir := t.TempDir() // Create Laravel project - err := os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php\n"), 0755) + err := os.WriteFile(filepath.Join(dir, "artisan"), []byte(testPHPShebang), 0755) RequireNoError(t, err) composerJSON := `{ @@ -393,7 +393,7 @@ func TestDevServer_HTTPSSetup(t *T) { "laravel/octane": "^2.0" } }` - err = os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) + err = os.WriteFile(filepath.Join(dir, composerJSONFile), []byte(composerJSON), 0644) RequireNoError(t, err) // Create .env with APP_URL @@ -404,7 +404,7 @@ func TestDevServer_HTTPSSetup(t *T) { // Verify we can extract the domain url := GetLaravelAppURL(dir) domain := ExtractDomainFromURL(url) - AssertEqual(t, "myapp.test", domain) + AssertEqual(t, testMyAppDomain, domain) }) } @@ -455,7 +455,7 @@ func TestMultiServiceReader_CloseError(t *T) { dir := t.TempDir() // Create a real file that we can close - file1, err := os.CreateTemp(dir, "log-*.log") + file1, err := os.CreateTemp(dir, testLogGlob) RequireNoError(t, err) file1Name := file1.Name() _ = file1.Close() @@ -482,7 +482,7 @@ func TestMultiServiceReader_CloseError(t *T) { func TestMultiServiceReader_FollowMode(t *T) { t.Run("returns 0 bytes without error in follow mode when no data", func(t *T) { dir := t.TempDir() - file1, err := os.CreateTemp(dir, "log-*.log") + file1, err := os.CreateTemp(dir, testLogGlob) RequireNoError(t, err) file1Name := file1.Name() _ = file1.Close() @@ -543,11 +543,11 @@ func TestPHP_ExtractDomainFromURL_Ugly(t *T) { expected string }{ {"empty string", "", ""}, - {"just domain", "example.com", "example.com"}, + {"just domain", testExampleDomain, testExampleDomain}, {"http only", "http://", ""}, {"https only", "https://", ""}, - {"domain with trailing slash", "https://example.com/", "example.com"}, - {"complex path", "https://example.com:8080/path/to/page?query=1", "example.com"}, + {"domain with trailing slash", "https://example.com/", testExampleDomain}, + {"complex path", "https://example.com:8080/path/to/page?query=1", testExampleDomain}, } for _, tt := range tests { diff --git a/pkg/php/quality.go b/pkg/php/quality.go index f81d66f..f91585b 100644 --- a/pkg/php/quality.go +++ b/pkg/php/quality.go @@ -133,7 +133,7 @@ func Format(ctx context.Context, opts FormatOptions) error { if opts.Dir == "" { cwd, err := os.Getwd() if err != nil { - return cli.WrapVerb(err, "get", "working directory") + return cli.WrapVerb(err, "get", workingDirectorySubject) } opts.Dir = cwd } @@ -169,7 +169,7 @@ func Analyse(ctx context.Context, opts AnalyseOptions) error { if opts.Dir == "" { cwd, err := os.Getwd() if err != nil { - return cli.WrapVerb(err, "get", "working directory") + return cli.WrapVerb(err, "get", workingDirectorySubject) } opts.Dir = cwd } @@ -318,7 +318,7 @@ func RunPsalm(ctx context.Context, opts PsalmOptions) error { if opts.Dir == "" { cwd, err := os.Getwd() if err != nil { - return cli.WrapVerb(err, "get", "working directory") + return cli.WrapVerb(err, "get", workingDirectorySubject) } opts.Dir = cwd } @@ -403,7 +403,7 @@ func RunAudit(ctx context.Context, opts AuditOptions) ([]AuditResult, error) { if opts.Dir == "" { cwd, err := os.Getwd() if err != nil { - return nil, cli.WrapVerb(err, "get", "working directory") + return nil, cli.WrapVerb(err, "get", workingDirectorySubject) } opts.Dir = cwd } @@ -419,7 +419,7 @@ func RunAudit(ctx context.Context, opts AuditOptions) ([]AuditResult, error) { results = append(results, composerResult) // Run npm audit if package.json exists - if getMedium().Exists(filepath.Join(opts.Dir, "package.json")) { + if getMedium().Exists(filepath.Join(opts.Dir, packageJSONFile)) { npmResult := runNpmAudit(ctx, opts) results = append(results, npmResult) } @@ -545,11 +545,7 @@ func DetectRector(dir string) bool { // Check for vendor binary rectorBin := filepath.Join(dir, "vendor", "bin", "rector") - if m.Exists(rectorBin) { - return true - } - - return false + return m.Exists(rectorBin) } // RunRector runs Rector for automated code refactoring. @@ -557,7 +553,7 @@ func RunRector(ctx context.Context, opts RectorOptions) error { if opts.Dir == "" { cwd, err := os.Getwd() if err != nil { - return cli.WrapVerb(err, "get", "working directory") + return cli.WrapVerb(err, "get", workingDirectorySubject) } opts.Dir = cwd } @@ -626,11 +622,7 @@ func DetectInfection(dir string) bool { // Check for vendor binary infectionBin := filepath.Join(dir, "vendor", "bin", "infection") - if m.Exists(infectionBin) { - return true - } - - return false + return m.Exists(infectionBin) } // RunInfection runs Infection mutation testing. @@ -638,7 +630,7 @@ func RunInfection(ctx context.Context, opts InfectionOptions) error { if opts.Dir == "" { cwd, err := os.Getwd() if err != nil { - return cli.WrapVerb(err, "get", "working directory") + return cli.WrapVerb(err, "get", workingDirectorySubject) } opts.Dir = cwd } @@ -821,7 +813,7 @@ func RunSecurityChecks(ctx context.Context, opts SecurityOptions) (*SecurityResu if opts.Dir == "" { cwd, err := os.Getwd() if err != nil { - return nil, cli.WrapVerb(err, "get", "working directory") + return nil, cli.WrapVerb(err, "get", workingDirectorySubject) } opts.Dir = cwd } @@ -876,13 +868,24 @@ func RunSecurityChecks(ctx context.Context, opts SecurityOptions) (*SecurityResu } 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 := filepath.Join(dir, ".env") envContent, err := m.Read(envPath) if err != nil { - return checks + return nil, false } envLines := strings.Split(envContent, "\n") @@ -898,58 +901,62 @@ func runEnvSecurityChecks(dir string) []SecurityCheck { } } - // Check APP_DEBUG - if debug, ok := envMap["APP_DEBUG"]; ok { - check := SecurityCheck{ - ID: "debug_mode", - Name: "Debug Mode Disabled", - Description: "APP_DEBUG should be false in production", - Severity: "critical", - Passed: strings.ToLower(debug) != "true", - CWE: "CWE-215", - } - if !check.Passed { - check.Message = "Debug mode exposes sensitive information" - check.Fix = "Set APP_DEBUG=false in .env" - } - checks = append(checks, check) + 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 +} - // Check APP_KEY - if key, ok := envMap["APP_KEY"]; ok { - 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" - } - checks = append(checks, check) +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: strings.ToLower(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 +} - // Check APP_URL for HTTPS - if url, ok := envMap["APP_URL"]; ok { - check := SecurityCheck{ - ID: "https_enforced", - Name: "HTTPS Enforced", - Description: "APP_URL should use HTTPS in production", - Severity: "high", - Passed: strings.HasPrefix(url, "https://"), - CWE: "CWE-319", - } - if !check.Passed { - check.Message = "Application not using HTTPS" - check.Fix = "Update APP_URL to use https://" - } - checks = append(checks, 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 +} - return checks +func httpsEnforcedCheck(url string) SecurityCheck { + check := SecurityCheck{ + ID: "https_enforced", + Name: "HTTPS Enforced", + Description: "APP_URL should use HTTPS in production", + Severity: "high", + Passed: strings.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 { diff --git a/pkg/php/services.go b/pkg/php/services.go index 36460cf..0ec67c9 100644 --- a/pkg/php/services.go +++ b/pkg/php/services.go @@ -92,7 +92,7 @@ func (s *baseService) Logs(follow bool) (io.ReadCloser, error) { // Type assert to get the underlying *os.File for tailing osFile, ok := file.(*os.File) if !ok { - file.Close() + _ = file.Close() return nil, cli.Err("log file is not a regular file") } return newTailReader(osFile), nil @@ -121,7 +121,7 @@ func (s *baseService) startProcess(ctx context.Context, cmdName string, args []s // Type assert to get the underlying *os.File for use with exec.Cmd logFile, ok := logWriter.(*os.File) if !ok { - logWriter.Close() + _ = logWriter.Close() return cli.Err("log file is not a regular file") } s.logFile = logFile diff --git a/pkg/php/services_extended_test.go b/pkg/php/services_extended_test.go index d01ee26..1a665ed 100644 --- a/pkg/php/services_extended_test.go +++ b/pkg/php/services_extended_test.go @@ -53,7 +53,7 @@ func TestPHP_BaseService_Status_Good(t *T) { func TestPHP_BaseService_Logs_Good(t *T) { t.Run("returns log file content", func(t *T) { dir := t.TempDir() - logPath := filepath.Join(dir, "test.log") + logPath := filepath.Join(dir, testLogFile) err := os.WriteFile(logPath, []byte("test log content"), 0644) RequireNoError(t, err) @@ -67,7 +67,7 @@ func TestPHP_BaseService_Logs_Good(t *T) { t.Run("returns tail reader in follow mode", func(t *T) { dir := t.TempDir() - logPath := filepath.Join(dir, "test.log") + logPath := filepath.Join(dir, testLogFile) err := os.WriteFile(logPath, []byte("test log content"), 0644) RequireNoError(t, err) @@ -103,7 +103,7 @@ func TestPHP_BaseService_Logs_Bad(t *T) { func TestPHP_TailReader_Good(t *T) { t.Run("creates new tail reader", func(t *T) { dir := t.TempDir() - logPath := filepath.Join(dir, "test.log") + logPath := filepath.Join(dir, testLogFile) err := os.WriteFile(logPath, []byte("content"), 0644) RequireNoError(t, err) @@ -120,7 +120,7 @@ func TestPHP_TailReader_Good(t *T) { t.Run("closes file on Close", func(t *T) { dir := t.TempDir() - logPath := filepath.Join(dir, "test.log") + logPath := filepath.Join(dir, testLogFile) err := os.WriteFile(logPath, []byte("content"), 0644) RequireNoError(t, err) @@ -135,7 +135,7 @@ func TestPHP_TailReader_Good(t *T) { t.Run("returns EOF when closed", func(t *T) { dir := t.TempDir() - logPath := filepath.Join(dir, "test.log") + logPath := filepath.Join(dir, testLogFile) err := os.WriteFile(logPath, []byte("content"), 0644) RequireNoError(t, err) @@ -162,7 +162,7 @@ func TestFrankenPHPService_Extended(t *T) { KeyFile: "/path/to/key.pem", } - service := NewFrankenPHPService("/project", opts) + service := NewFrankenPHPService(testProjectDir, opts) AssertEqual(t, "FrankenPHP", service.Name()) AssertEqual(t, 9000, service.port) @@ -170,7 +170,7 @@ func TestFrankenPHPService_Extended(t *T) { AssertTrue(t, service.https) AssertEqual(t, "/path/to/cert.pem", service.certFile) AssertEqual(t, "/path/to/key.pem", service.keyFile) - AssertEqual(t, "/project", service.dir) + AssertEqual(t, testProjectDir, service.dir) }) } @@ -197,37 +197,37 @@ func TestViteService_Extended(t *T) { func TestHorizonService_Extended(t *T) { t.Run("has zero port", func(t *T) { - service := NewHorizonService("/project") + service := NewHorizonService(testProjectDir) AssertEqual(t, 0, service.port) }) } func TestReverbService_Extended(t *T) { t.Run("uses default port 8080", func(t *T) { - service := NewReverbService("/project", ReverbOptions{}) + service := NewReverbService(testProjectDir, ReverbOptions{}) AssertEqual(t, 8080, service.port) }) t.Run("uses custom port", func(t *T) { - service := NewReverbService("/project", ReverbOptions{Port: 9090}) + service := NewReverbService(testProjectDir, ReverbOptions{Port: 9090}) AssertEqual(t, 9090, service.port) }) } func TestRedisService_Extended(t *T) { t.Run("uses default port 6379", func(t *T) { - service := NewRedisService("/project", RedisOptions{}) + service := NewRedisService(testProjectDir, RedisOptions{}) AssertEqual(t, 6379, service.port) }) t.Run("accepts config file", func(t *T) { - service := NewRedisService("/project", RedisOptions{ConfigFile: "/path/to/redis.conf"}) + service := NewRedisService(testProjectDir, RedisOptions{ConfigFile: "/path/to/redis.conf"}) AssertEqual(t, "/path/to/redis.conf", service.configFile) }) } func TestServiceStatus_Struct(t *T) { - t.Run("all fields accessible", func(t *T) { + t.Run(testAllFieldsAccessible, func(t *T) { testErr := AnError status := ServiceStatus{ Name: "TestService", @@ -246,7 +246,7 @@ func TestServiceStatus_Struct(t *T) { } func TestFrankenPHPOptions_Struct(t *T) { - t.Run("all fields accessible", func(t *T) { + t.Run(testAllFieldsAccessible, func(t *T) { opts := FrankenPHPOptions{ Port: 8000, HTTPSPort: 443, @@ -264,7 +264,7 @@ func TestFrankenPHPOptions_Struct(t *T) { } func TestViteOptions_Struct(t *T) { - t.Run("all fields accessible", func(t *T) { + t.Run(testAllFieldsAccessible, func(t *T) { opts := ViteOptions{ Port: 5173, PackageManager: "bun", @@ -276,21 +276,21 @@ func TestViteOptions_Struct(t *T) { } func TestReverbOptions_Struct(t *T) { - t.Run("all fields accessible", func(t *T) { + t.Run(testAllFieldsAccessible, func(t *T) { opts := ReverbOptions{Port: 8080} AssertEqual(t, 8080, opts.Port) }) } func TestRedisOptions_Struct(t *T) { - t.Run("all fields accessible", func(t *T) { + t.Run(testAllFieldsAccessible, func(t *T) { opts := RedisOptions{ Port: 6379, - ConfigFile: "redis.conf", + ConfigFile: ax7RedisConfigFile, } AssertEqual(t, 6379, opts.Port) - AssertEqual(t, "redis.conf", opts.ConfigFile) + AssertEqual(t, ax7RedisConfigFile, opts.ConfigFile) }) } diff --git a/pkg/php/services_test.go b/pkg/php/services_test.go index 4d142d4..a2c55bd 100644 --- a/pkg/php/services_test.go +++ b/pkg/php/services_test.go @@ -3,8 +3,8 @@ package php import () func TestPHP_NewFrankenPHPService_Good(t *T) { - t.Run("default options", func(t *T) { - dir := "/tmp/test" + t.Run(testDefaultOptions, func(t *T) { + dir := testTmpDir service := NewFrankenPHPService(dir, FrankenPHPOptions{}) AssertEqual(t, "FrankenPHP", service.Name()) @@ -14,7 +14,7 @@ func TestPHP_NewFrankenPHPService_Good(t *T) { }) t.Run("custom options", func(t *T) { - dir := "/tmp/test" + dir := testTmpDir opts := FrankenPHPOptions{ Port: 9000, HTTPSPort: 8443, @@ -33,7 +33,7 @@ func TestPHP_NewFrankenPHPService_Good(t *T) { } func TestPHP_NewViteService_Good(t *T) { - t.Run("default options", func(t *T) { + t.Run(testDefaultOptions, func(t *T) { dir := t.TempDir() service := NewViteService(dir, ViteOptions{}) @@ -51,34 +51,34 @@ func TestPHP_NewViteService_Good(t *T) { } func TestPHP_NewHorizonService_Good(t *T) { - service := NewHorizonService("/tmp/test") + service := NewHorizonService(testTmpDir) AssertEqual(t, "Horizon", service.Name()) AssertEqual(t, 0, service.port) } func TestPHP_NewReverbService_Good(t *T) { - t.Run("default options", func(t *T) { - service := NewReverbService("/tmp/test", ReverbOptions{}) + t.Run(testDefaultOptions, func(t *T) { + service := NewReverbService(testTmpDir, ReverbOptions{}) AssertEqual(t, "Reverb", service.Name()) AssertEqual(t, 8080, service.port) }) t.Run("custom port", func(t *T) { - service := NewReverbService("/tmp/test", ReverbOptions{Port: 9090}) + service := NewReverbService(testTmpDir, ReverbOptions{Port: 9090}) AssertEqual(t, 9090, service.port) }) } func TestPHP_NewRedisService_Good(t *T) { - t.Run("default options", func(t *T) { - service := NewRedisService("/tmp/test", RedisOptions{}) + t.Run(testDefaultOptions, func(t *T) { + service := NewRedisService(testTmpDir, RedisOptions{}) AssertEqual(t, "Redis", service.Name()) AssertEqual(t, 6379, service.port) }) t.Run("custom config", func(t *T) { - service := NewRedisService("/tmp/test", RedisOptions{ConfigFile: "redis.conf"}) - AssertEqual(t, "redis.conf", service.configFile) + service := NewRedisService(testTmpDir, RedisOptions{ConfigFile: ax7RedisConfigFile}) + AssertEqual(t, ax7RedisConfigFile, service.configFile) }) } diff --git a/pkg/php/sonar_constants.go b/pkg/php/sonar_constants.go new file mode 100644 index 0000000..634cb2f --- /dev/null +++ b/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/pkg/php/sonar_test_constants_test.go b/pkg/php/sonar_test_constants_test.go new file mode 100644 index 0000000..d359c5c --- /dev/null +++ b/pkg/php/sonar_test_constants_test.go @@ -0,0 +1,53 @@ +package php + +const ( + ax7AuditNoAdvisoriesScript = "printf '{\"advisories\":{}}'\n" + ax7DemoDomain = "demo.test" + ax7DeployID = "deploy-1" + ax7ExitOKScript = "exit 0\n" + ax7PestFile = "Pest.php" + ax7PHPOpen = "