├── .meta └── yerd_logo.jpg ├── main.go ├── .config ├── ssl │ └── ext.conf ├── nginx │ ├── systemd.conf │ ├── nginx.conf │ └── site.conf └── php │ ├── systemd.conf │ ├── www.conf │ ├── php-fpm.conf │ └── php.ini ├── cmd ├── sites.go ├── web.go ├── php.go ├── composer.go ├── php │ ├── php_version.go │ ├── rebuild.go │ ├── list.go │ ├── install.go │ ├── status.go │ ├── cli.go │ ├── update.go │ ├── uninstall.go │ └── extensions.go ├── sites │ ├── list.go │ ├── remove.go │ ├── set.go │ └── add.go ├── web │ ├── install.go │ ├── uninstall.go │ └── trust.go ├── composer │ ├── uninstall.go │ ├── install.go │ └── update.go ├── root.go └── update.go ├── go.mod ├── internal ├── config │ ├── web.go │ └── php.go ├── utils │ ├── common.go │ ├── arrays.go │ ├── systemd.go │ ├── ui.go │ ├── user.go │ ├── template.go │ ├── commands.go │ ├── download.go │ ├── spinner.go │ ├── logger.go │ ├── hosts.go │ └── file.go ├── installers │ ├── composer │ │ └── common.go │ ├── php │ │ ├── cli.go │ │ ├── uninstall.go │ │ ├── general.go │ │ ├── extensions.go │ │ └── versions.go │ └── nginx │ │ └── installer.go ├── constants │ ├── constants.go │ ├── nginx.go │ └── php.go ├── version │ └── version.go └── manager │ ├── certificate.go │ └── site.go ├── LICENSE ├── .gitignore ├── go.sum ├── .github └── workflows │ └── release.yml ├── scripts └── build-releases.sh ├── install.sh └── README.md /.meta/yerd_logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LumoSolutions/yerd/HEAD/.meta/yerd_logo.jpg -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/lumosolutions/yerd/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /.config/ssl/ext.conf: -------------------------------------------------------------------------------- 1 | authorityKeyIdentifier=keyid,issuer 2 | basicConstraints=CA:FALSE 3 | keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment 4 | subjectAltName = DNS:{{% domain %}},DNS:www.{{% domain %}} -------------------------------------------------------------------------------- /cmd/sites.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var sitesCmd = &cobra.Command{ 8 | Use: "sites", 9 | Short: "Manage local development sites", 10 | Long: `Add, remove and manage local development sites.`, 11 | } 12 | -------------------------------------------------------------------------------- /cmd/web.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var webCmd = &cobra.Command{ 8 | Use: "web", 9 | Short: "Manage Web Components", 10 | Long: `Install, uninstall web components and manage local development sites.`, 11 | } 12 | -------------------------------------------------------------------------------- /cmd/php.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var phpCmd = &cobra.Command{ 8 | Use: "php", 9 | Short: "Manage PHP versions", 10 | Long: `Install, remove, update, and manage multiple PHP versions on your system.`, 11 | } 12 | -------------------------------------------------------------------------------- /cmd/composer.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var composerCmd = &cobra.Command{ 8 | Use: "composer", 9 | Short: "Manage Composer", 10 | Long: `Install, uninstall and update a YERD managed version of composer on your system.`, 11 | } 12 | -------------------------------------------------------------------------------- /.config/nginx/systemd.conf: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Yerd nginx 3 | After=network.target 4 | 5 | [Service] 6 | Type=forking 7 | PIDFile=/opt/yerd/web/nginx/run/nginx.pid 8 | ExecStartPre=/opt/yerd/web/nginx/sbin/nginx -t -c /opt/yerd/web/nginx/conf/nginx.conf 9 | ExecStart=/opt/yerd/web/nginx/sbin/nginx -c /opt/yerd/web/nginx/conf/nginx.conf 10 | ExecReload=/bin/kill -s HUP $MAINPID 11 | ExecStop=/bin/kill -s QUIT $MAINPID 12 | User=root 13 | Group=root 14 | 15 | NonBlocking=true 16 | 17 | [Install] 18 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /.config/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | user {{% user %}}; 2 | worker_processes auto; 3 | pid /opt/yerd/web/nginx/run/nginx.pid; 4 | 5 | events { 6 | worker_connections 1024; 7 | } 8 | 9 | http { 10 | include /opt/yerd/web/nginx/conf/mime.types; 11 | default_type application/octet-stream; 12 | 13 | sendfile on; 14 | keepalive_timeout 65; 15 | 16 | access_log /opt/yerd/web/nginx/logs/access.log; 17 | error_log /opt/yerd/web/nginx/logs/error.log; 18 | 19 | include /opt/yerd/web/nginx/sites-enabled/*; 20 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lumosolutions/yerd 2 | 3 | go 1.24.6 4 | 5 | require github.com/fatih/color v1.18.0 6 | 7 | require ( 8 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 9 | github.com/mattn/go-runewidth v0.0.16 // indirect 10 | github.com/olekukonko/errors v1.1.0 // indirect 11 | github.com/olekukonko/ll v0.0.9 // indirect 12 | github.com/rivo/uniseg v0.2.0 // indirect 13 | github.com/spf13/pflag v1.0.6 // indirect 14 | ) 15 | 16 | require ( 17 | github.com/mattn/go-colorable v0.1.13 // indirect 18 | github.com/mattn/go-isatty v0.0.20 // indirect 19 | github.com/olekukonko/tablewriter v1.0.9 20 | github.com/spf13/cobra v1.9.1 21 | golang.org/x/sys v0.25.0 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /internal/config/web.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type WebConfig struct { 4 | Installed bool `json:"is_installed"` 5 | Sites map[string]SiteConfig `json:"sites"` 6 | } 7 | 8 | type SiteConfig struct { 9 | RootDirectory string `json:"rootDir"` 10 | PublicDirectory string `json:"publicDir"` 11 | Domain string `json:"domain"` 12 | PhpVersion string `json:"php_version"` 13 | } 14 | 15 | func GetWebConfig() *WebConfig { 16 | var webConfig *WebConfig 17 | err := GetStruct("web", &webConfig) 18 | if err != nil || webConfig == nil { 19 | webConfig = &WebConfig{ 20 | Installed: false, 21 | Sites: make(map[string]SiteConfig), 22 | } 23 | } 24 | 25 | return webConfig 26 | } 27 | -------------------------------------------------------------------------------- /cmd/php/php_version.go: -------------------------------------------------------------------------------- 1 | package php 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func CreateVersionCommand(version string) *cobra.Command { 10 | versionCmd := &cobra.Command{ 11 | Use: version, 12 | Short: fmt.Sprintf("Manage PHP %s", version), 13 | Long: fmt.Sprintf("Commands for managing PHP version %s", version), 14 | } 15 | 16 | versionCmd.AddCommand(buildInstallCmd(version)) 17 | versionCmd.AddCommand(buildRebuildCmd(version)) 18 | versionCmd.AddCommand(buildExtensionsCmd(version)) 19 | versionCmd.AddCommand(buildCliCmd(version)) 20 | versionCmd.AddCommand(buildUninstallCmd(version)) 21 | versionCmd.AddCommand(buildUpdateCmd(version)) 22 | 23 | return versionCmd 24 | } 25 | -------------------------------------------------------------------------------- /.config/php/systemd.conf: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=PHP-FPM {{% version %}} (YERD managed) 3 | Documentation=man:php-fpm{{% version %}}(8) 4 | After=network.target 5 | 6 | [Service] 7 | Type=forking 8 | PIDFile={{% pid_path %}} 9 | ExecStart={{% fpm_binary_path %}} --fpm-config {{% main_config_path %}} --pid {{% pid_path %}} 10 | ExecReload=/bin/kill -USR2 $MAINPID 11 | ExecStop=/bin/kill -SIGINT $MAINPID 12 | TimeoutStopSec=5 13 | KillMode=process 14 | PrivateTmp=false 15 | ProtectSystem=full 16 | ProtectHome=false 17 | RuntimeDirectory=yerd-php{{% version %}}mfpm 18 | RuntimeDirectoryMode=0755 19 | 20 | # Restart policy 21 | Restart=on-failure 22 | RestartSec=5 23 | StartLimitInterval=60s 24 | StartLimitBurst=3 25 | 26 | [Install] 27 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /internal/utils/common.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/lumosolutions/yerd/internal/constants" 7 | ) 8 | 9 | const context = "utils" 10 | 11 | func EnsureYerdDirectories() error { 12 | dirs := []string{ 13 | constants.YerdBaseDir, 14 | constants.YerdBinDir, 15 | constants.YerdPHPDir, 16 | constants.YerdEtcDir, 17 | constants.YerdWebDir, 18 | } 19 | 20 | for _, dir := range dirs { 21 | if err := CreateDirectory(dir); err != nil { 22 | return fmt.Errorf("failed to create directory %s: %v", dir, err) 23 | } 24 | } 25 | 26 | return nil 27 | } 28 | 29 | func RunAll(fns ...func() error) error { 30 | for _, fn := range fns { 31 | if err := fn(); err != nil { 32 | return err 33 | } 34 | } 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /.config/php/www.conf: -------------------------------------------------------------------------------- 1 | ; PHP-FPM pool configuration for PHP {{% version %}} 2 | ; Generated by YERD 3 | 4 | [www] 5 | ; The name of the pool 6 | user = {{% user %}} 7 | group = {{% group %}} 8 | 9 | ; Unix socket configuration 10 | listen = {{% sock_path %}} 11 | listen.owner = {{% user %}} 12 | listen.group = {{% group %}} 13 | listen.mode = 0660 14 | 15 | ; Process management 16 | pm = dynamic 17 | pm.max_children = 5 18 | pm.start_servers = 2 19 | pm.min_spare_servers = 1 20 | pm.max_spare_servers = 3 21 | 22 | ; Logging 23 | php_admin_value[error_log] = {{% log_path %}} 24 | php_admin_flag[log_errors] = on 25 | 26 | ; Performance 27 | php_value[memory_limit] = 1024M 28 | php_value[max_execution_time] = 999 29 | php_value[upload_max_filesize] = 999M 30 | php_value[post_max_size] = 999M -------------------------------------------------------------------------------- /internal/utils/arrays.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | // AddUnique is a helper function to add unique items to a slice 4 | func AddUnique(slice []string, items ...string) []string { 5 | existing := make(map[string]bool) 6 | for _, s := range slice { 7 | existing[s] = true 8 | } 9 | 10 | for _, item := range items { 11 | if !existing[item] { 12 | slice = append(slice, item) 13 | existing[item] = true 14 | } 15 | } 16 | return slice 17 | } 18 | 19 | // RemoveItems is a helper function to remove items from a slice 20 | func RemoveItems(slice []string, items ...string) []string { 21 | removeMap := make(map[string]bool) 22 | for _, item := range items { 23 | removeMap[item] = true 24 | } 25 | 26 | result := make([]string, 0, len(slice)) 27 | for _, s := range slice { 28 | if !removeMap[s] { 29 | result = append(result, s) 30 | } 31 | } 32 | return result 33 | } 34 | -------------------------------------------------------------------------------- /internal/installers/composer/common.go: -------------------------------------------------------------------------------- 1 | package composer 2 | 3 | import ( 4 | "github.com/lumosolutions/yerd/internal/constants" 5 | "github.com/lumosolutions/yerd/internal/utils" 6 | ) 7 | 8 | func downloadComposer() error { 9 | return utils.DownloadFile( 10 | constants.ComposerDownloadUrl, 11 | constants.LocalComposerPath, 12 | nil, 13 | ) 14 | } 15 | 16 | func InstallComposer() error { 17 | return utils.RunAll( 18 | func() error { return downloadComposer() }, 19 | func() error { return utils.Chmod(constants.LocalComposerPath, 0755) }, 20 | func() error { return utils.CreateSymlink(constants.LocalComposerPath, constants.GlobalComposerPath) }, 21 | ) 22 | } 23 | 24 | func RemoveComposer() error { 25 | return utils.RunAll( 26 | func() error { return utils.RemoveSymlink(constants.GlobalComposerPath) }, 27 | func() error { return utils.RemoveFile(constants.LocalComposerPath) }, 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /.config/nginx/site.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name {{% domain %}}; 4 | return 301 https://$server_name$request_uri; 5 | } 6 | 7 | server { 8 | listen 443 ssl http2; 9 | server_name {{% domain %}}; 10 | 11 | ssl_certificate {{% cert %}}; 12 | ssl_certificate_key {{% key %}}; 13 | 14 | ssl_protocols TLSv1.2 TLSv1.3; 15 | ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384; 16 | ssl_prefer_server_ciphers off; 17 | 18 | root {{% path %}}; 19 | index index.php index.html; 20 | 21 | location / { 22 | try_files $uri $uri/ /index.php?$query_string; 23 | } 24 | 25 | location ~ \.php$ { 26 | fastcgi_pass unix:/opt/yerd/php/run/php{{% php_version %}}-fpm.sock; 27 | include /opt/yerd/web/nginx/conf/fastcgi_params; 28 | fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; 29 | } 30 | } -------------------------------------------------------------------------------- /cmd/sites/list.go: -------------------------------------------------------------------------------- 1 | package sites 2 | 3 | import ( 4 | "github.com/fatih/color" 5 | "github.com/lumosolutions/yerd/internal/manager" 6 | "github.com/lumosolutions/yerd/internal/version" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | func BuildListCommand() *cobra.Command { 11 | return &cobra.Command{ 12 | Use: "list", 13 | Short: "Lists development sites & their configuration", 14 | Run: func(cmd *cobra.Command, args []string) { 15 | version.PrintSplash() 16 | red := color.New(color.FgRed) 17 | blue := color.New(color.FgBlue) 18 | 19 | siteManager, err := manager.NewSiteManager() 20 | if err != nil { 21 | red.Println("Unable to create a site manager instance") 22 | red.Println("Are the web components installed?") 23 | blue.Println("- You can install the web components with:") 24 | blue.Println("- 'sudo yerd web install'") 25 | return 26 | } 27 | 28 | siteManager.ListSites() 29 | }, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /cmd/web/install.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "github.com/fatih/color" 5 | "github.com/lumosolutions/yerd/internal/installers/nginx" 6 | "github.com/lumosolutions/yerd/internal/utils" 7 | "github.com/lumosolutions/yerd/internal/version" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func BuildInstallCommand() *cobra.Command { 12 | return &cobra.Command{ 13 | Use: "install", 14 | Short: "Installs any web components required for local development sites", 15 | Run: func(cmd *cobra.Command, args []string) { 16 | version.PrintSplash() 17 | //green := color.New(color.FgGreen) 18 | //yellow := color.New(color.FgYellow) 19 | //blue := color.New(color.FgBlue) 20 | red := color.New(color.FgRed) 21 | 22 | if !utils.CheckAndPromptForSudo() { 23 | return 24 | } 25 | 26 | installer, err := nginx.NewNginxInstaller(false, true) 27 | if err != nil { 28 | red.Printf("Install failed\n\n") 29 | } 30 | 31 | installer.Install() 32 | }, 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /internal/installers/php/cli.go: -------------------------------------------------------------------------------- 1 | package php 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/lumosolutions/yerd/internal/config" 7 | "github.com/lumosolutions/yerd/internal/constants" 8 | "github.com/lumosolutions/yerd/internal/utils" 9 | ) 10 | 11 | func SetCliVersion(info *config.PhpInfo) error { 12 | binaryPath := getBinaryPath(info.Version) 13 | globalPath := constants.GlobalPhpPath 14 | 15 | if err := utils.CreateSymlink(binaryPath, globalPath); err != nil { 16 | utils.LogError(err, "setcli") 17 | return err 18 | } 19 | 20 | info.IsCLI = true 21 | config.SetStruct(fmt.Sprintf("php.[%s]", info.Version), info) 22 | 23 | phpVersions := constants.GetAvailablePhpVersions() 24 | for _, version := range phpVersions { 25 | if version == info.Version { 26 | continue 27 | } 28 | 29 | if data, installed := config.GetInstalledPhpInfo(version); installed { 30 | if data.IsCLI { 31 | data.IsCLI = false 32 | config.SetStruct(fmt.Sprintf("php.[%s]", data.Version), data) 33 | } 34 | } 35 | } 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /internal/config/php.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | type PhpInfo struct { 9 | Version string `json:"version"` 10 | InstalledVersion string `json:"installed_version"` 11 | InstallPath string `json:"install_path"` 12 | InstallDate time.Time `json:"install_date"` 13 | IsCLI bool `json:"is_cli"` 14 | Extensions []string `json:"extensions"` 15 | AddExtensions []string `json:"add_extensions"` 16 | RemoveExtensions []string `json:"remove_extensions"` 17 | } 18 | 19 | type PhpConfig map[string]PhpInfo 20 | 21 | // GetInstalledPhpInfo returns the PhpInfo struct for an installed 22 | // version of php, however, if the version is not installed, then 23 | // the return of bool will be false 24 | // version: the version of php to check, eg: 8.1 25 | func GetInstalledPhpInfo(version string) (*PhpInfo, bool) { 26 | info := &PhpInfo{} 27 | err := GetStruct(fmt.Sprintf("php.[%s]", version), info) 28 | if err == nil { 29 | return info, true 30 | } 31 | 32 | return &PhpInfo{}, false 33 | } 34 | -------------------------------------------------------------------------------- /cmd/sites/remove.go: -------------------------------------------------------------------------------- 1 | package sites 2 | 3 | import ( 4 | "github.com/fatih/color" 5 | "github.com/lumosolutions/yerd/internal/manager" 6 | "github.com/lumosolutions/yerd/internal/utils" 7 | "github.com/lumosolutions/yerd/internal/version" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func BuildRemoveCommand() *cobra.Command { 12 | return &cobra.Command{ 13 | Use: "remove", 14 | Short: "Removes a local development site given a directory", 15 | Run: func(cmd *cobra.Command, args []string) { 16 | version.PrintSplash() 17 | red := color.New(color.FgRed) 18 | blue := color.New(color.FgBlue) 19 | 20 | if !utils.CheckAndPromptForSudo() { 21 | return 22 | } 23 | 24 | path := args[0] 25 | 26 | siteManager, err := manager.NewSiteManager() 27 | if err != nil { 28 | red.Println("Unable to create a site manager instance") 29 | red.Println("Are the web components installed?") 30 | blue.Println("- You can install the web components with:") 31 | blue.Println("- 'sudo yerd web install'") 32 | return 33 | } 34 | 35 | siteManager.RemoveSite(path) 36 | }, 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 LumoSolutions 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /cmd/web/uninstall.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "github.com/fatih/color" 5 | "github.com/lumosolutions/yerd/internal/config" 6 | "github.com/lumosolutions/yerd/internal/installers/nginx" 7 | "github.com/lumosolutions/yerd/internal/utils" 8 | "github.com/lumosolutions/yerd/internal/version" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | func BuildUninstallCommand() *cobra.Command { 13 | return &cobra.Command{ 14 | Use: "uninstall", 15 | Short: "Uninstalls the web components required for local development", 16 | Run: func(cmd *cobra.Command, args []string) { 17 | version.PrintSplash() 18 | green := color.New(color.FgGreen) 19 | red := color.New(color.FgRed) 20 | 21 | if !utils.CheckAndPromptForSudo() { 22 | return 23 | } 24 | 25 | installer, err := nginx.NewNginxInstaller(false, true) 26 | if err != nil { 27 | red.Printf("Install failed\n\n") 28 | } 29 | 30 | installer.Uninstall() 31 | 32 | newConfig := &config.WebConfig{ 33 | Installed: false, 34 | } 35 | 36 | config.SetStruct("web", newConfig) 37 | 38 | green.Println("Successfully uninstalled web components") 39 | }, 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/constants/constants.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | const ( 8 | YerdBaseDir = "/opt/yerd" 9 | YerdBinDir = "/opt/yerd/bin" 10 | YerdPHPDir = "/opt/yerd/php" 11 | YerdEtcDir = "/opt/yerd/etc" 12 | YerdWebDir = "/opt/yerd/web" 13 | SystemBinDir = "/usr/local/bin" 14 | GlobalPhpPath = SystemBinDir + "/php" 15 | SpinnerInterval = 200 * time.Millisecond 16 | LogTimeFormat = "15:04:05" 17 | FilePermissions = 0644 18 | DirPermissions = 0755 19 | 20 | // Composer 21 | LocalComposerPath = YerdBinDir + "/composer.phar" 22 | GlobalComposerPath = "/usr/local/bin/composer" 23 | ComposerPharName = "composer.phar" 24 | ComposerDownloadUrl = "https://getcomposer.org/download/latest-stable/composer.phar" 25 | 26 | // FPM Configuration 27 | FPMSockDir = "/opt/yerd/php/run" 28 | FPMPidDir = "/opt/yerd/php/run" 29 | FPMLogDir = "/opt/yerd/php/logs" 30 | 31 | // FPM Paths and Names 32 | FPMPoolDir = "php-fpm.d" 33 | FPMPoolConfig = "www.conf" 34 | SystemdDir = "/etc/systemd/system" 35 | 36 | // Error Messages 37 | ErrEmptyPHPVersion = "PHP version cannot be empty" 38 | 39 | // Web 40 | CertsDir = YerdWebDir + "/certs" 41 | 42 | // Config 43 | YerdConfigName = "config.json" 44 | ) 45 | -------------------------------------------------------------------------------- /cmd/web/trust.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "github.com/fatih/color" 7 | "github.com/lumosolutions/yerd/internal/config" 8 | "github.com/lumosolutions/yerd/internal/constants" 9 | "github.com/lumosolutions/yerd/internal/manager" 10 | "github.com/lumosolutions/yerd/internal/utils" 11 | "github.com/lumosolutions/yerd/internal/version" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | func BuildTrustCommand() *cobra.Command { 16 | return &cobra.Command{ 17 | Use: "trust", 18 | Short: "Attempts to refresh the YERD CA for Chrome", 19 | Run: func(cmd *cobra.Command, args []string) { 20 | version.PrintSplash() 21 | red := color.New(color.FgRed) 22 | green := color.New(color.FgGreen) 23 | 24 | if !utils.CheckAndPromptForSudo() { 25 | return 26 | } 27 | 28 | webConfig := config.GetWebConfig() 29 | 30 | if !webConfig.Installed { 31 | red.Println("YERD web components are not installed") 32 | return 33 | } 34 | 35 | cm := manager.NewCertificateManager() 36 | 37 | caPath := filepath.Join(constants.CertsDir, "ca") 38 | caFile := "yerd.crt" 39 | 40 | cm.ChromeUntrust() 41 | if err := cm.ChromeTrust(caPath, caFile); err != nil { 42 | red.Println("Unable to trust CA cert with chrome due to the following error:") 43 | red.Println(err) 44 | return 45 | } 46 | 47 | green.Println("Chrome Trust Updated") 48 | }, 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.config/php/php-fpm.conf: -------------------------------------------------------------------------------- 1 | ; PHP-FPM main configuration for PHP {{% version %}} 2 | ; Generated by YERD 3 | 4 | [global] 5 | ; PID file for the main process 6 | pid = {{% pid_path %}} 7 | 8 | ; Error log file 9 | error_log = {{% log_path %}} 10 | 11 | ; Log level (alert, error, warning, notice, debug) 12 | log_level = notice 13 | 14 | ; When this amount of php-fpm processes is reached, master process will abort. 15 | ; Set to 0 for no limits. 16 | process.max = 128 17 | 18 | ; Emergency restart threshold. If this number of child processes exit with 19 | ; SIGSEGV or SIGBUS within the time interval set by emergency_restart_interval 20 | ; then FPM will restart. A value of '0' means 'Off'. Available Units: s(econds), m(inutes), h(ours), or d(ays) 21 | emergency_restart_threshold = 0 22 | 23 | ; Interval of time used by emergency_restart_interval to determine when 24 | ; a graceful restart will be initiated. This can be useful to work around 25 | ; accidental corruptions in an accelerator's shared memory. 26 | ; Available Units: s(econds), m(inutes), h(ours), or d(ays) 27 | emergency_restart_interval = 0 28 | 29 | ; Time limit for child processes to wait for a reaction on signals from master. 30 | ; Available units: s(econds), m(inutes), h(ours), or d(ays) 31 | process_control_timeout = 0 32 | 33 | ; Send FPM to background. Set to 'no' to keep FPM in foreground for debugging. 34 | daemonize = yes 35 | 36 | ; Include pool configurations 37 | include = {{% pool_dir %}}/*.conf -------------------------------------------------------------------------------- /cmd/composer/uninstall.go: -------------------------------------------------------------------------------- 1 | package composer 2 | 3 | import ( 4 | "github.com/fatih/color" 5 | "github.com/lumosolutions/yerd/internal/constants" 6 | internalComposer "github.com/lumosolutions/yerd/internal/installers/composer" 7 | "github.com/lumosolutions/yerd/internal/utils" 8 | intVersion "github.com/lumosolutions/yerd/internal/version" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | func BuildUninstallCommand() *cobra.Command { 13 | return &cobra.Command{ 14 | Use: "uninstall", 15 | Short: "Uninstalls the YERD managed Composer", 16 | Run: func(cmd *cobra.Command, args []string) { 17 | intVersion.PrintSplash() 18 | green := color.New(color.FgGreen) 19 | yellow := color.New(color.FgYellow) 20 | blue := color.New(color.FgBlue) 21 | red := color.New(color.FgRed) 22 | 23 | if !utils.CheckAndPromptForSudo() { 24 | return 25 | } 26 | 27 | if !utils.FileExists(constants.LocalComposerPath) { 28 | yellow.Printf("Composer is not installed\n") 29 | blue.Printf("- To install composer, please run:\n") 30 | blue.Printf("- 'sudo yerd composer install'\n\n") 31 | 32 | red.Printf("❌ Operation cancelled\n") 33 | return 34 | } 35 | 36 | if err := internalComposer.RemoveComposer(); err != nil { 37 | red.Printf("Composer failed to uninstall!\n") 38 | blue.Printf("- Error: %v\n\n", err) 39 | red.Printf("❌ Operation cancelled\n") 40 | return 41 | } 42 | 43 | green.Printf("✓ Composer was uninstalled\n") 44 | }, 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /cmd/php/rebuild.go: -------------------------------------------------------------------------------- 1 | package php 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/lumosolutions/yerd/internal/config" 7 | phpinstaller "github.com/lumosolutions/yerd/internal/installers/php" 8 | "github.com/lumosolutions/yerd/internal/utils" 9 | intVersion "github.com/lumosolutions/yerd/internal/version" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func buildRebuildCmd(version string) *cobra.Command { 14 | cmd := &cobra.Command{ 15 | Use: "rebuild", 16 | Short: fmt.Sprintf("Rebuild PHP %s", version), 17 | Run: func(cmd *cobra.Command, args []string) { 18 | intVersion.PrintSplash() 19 | 20 | if !utils.CheckAndPromptForSudo() { 21 | return 22 | } 23 | 24 | data, installed := config.GetInstalledPhpInfo(version) 25 | if !installed { 26 | fmt.Printf("PHP %s is not installed, please use\n", version) 27 | fmt.Printf(" 'sudo yerd php %s install' instead\n\n", version) 28 | fmt.Println("Thanks for using YERD") 29 | return 30 | } 31 | 32 | nocache, _ := cmd.Flags().GetBool("nocache") 33 | configFlag, _ := cmd.Flags().GetBool("config") 34 | 35 | if err := phpinstaller.RunRebuild(data, nocache, configFlag); err != nil { 36 | fmt.Printf("Failed to rebuild php%s: %v\n", version, err) 37 | return 38 | } 39 | }, 40 | } 41 | 42 | cmd.Flags().BoolP("nocache", "n", false, "Bypass cache to get the latest version from php.net") 43 | cmd.Flags().BoolP("config", "c", false, "Recreate associated configuration, if it already exists") 44 | 45 | return cmd 46 | } 47 | -------------------------------------------------------------------------------- /cmd/composer/install.go: -------------------------------------------------------------------------------- 1 | package composer 2 | 3 | import ( 4 | "github.com/fatih/color" 5 | "github.com/lumosolutions/yerd/internal/constants" 6 | internalComposer "github.com/lumosolutions/yerd/internal/installers/composer" 7 | "github.com/lumosolutions/yerd/internal/utils" 8 | intVersion "github.com/lumosolutions/yerd/internal/version" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | func BuildInstallCommand() *cobra.Command { 13 | return &cobra.Command{ 14 | Use: "install", 15 | Short: "Installs a YERD managed Composer", 16 | Run: func(cmd *cobra.Command, args []string) { 17 | intVersion.PrintSplash() 18 | green := color.New(color.FgGreen) 19 | yellow := color.New(color.FgYellow) 20 | blue := color.New(color.FgBlue) 21 | red := color.New(color.FgRed) 22 | 23 | if !utils.CheckAndPromptForSudo() { 24 | return 25 | } 26 | 27 | if utils.FileExists(constants.LocalComposerPath) { 28 | yellow.Printf("Composer is already installed\n") 29 | blue.Printf("- To upgrade composer, please run:\n") 30 | blue.Printf("- 'sudo yerd composer update'\n\n") 31 | 32 | red.Printf("❌ Operation cancelled\n") 33 | return 34 | } 35 | 36 | if err := internalComposer.InstallComposer(); err != nil { 37 | red.Printf("Composer failed to install!\n") 38 | blue.Printf("- Error: %v\n\n", err) 39 | red.Printf("❌ Operation cancelled\n") 40 | return 41 | } 42 | 43 | green.Printf("✓ Composer installed successfully\n") 44 | blue.Printf("- Type it out with: 'composer --version'\n") 45 | }, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /cmd/sites/set.go: -------------------------------------------------------------------------------- 1 | package sites 2 | 3 | import ( 4 | "github.com/fatih/color" 5 | "github.com/lumosolutions/yerd/internal/manager" 6 | "github.com/lumosolutions/yerd/internal/utils" 7 | "github.com/lumosolutions/yerd/internal/version" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func BuildSetCommand() *cobra.Command { 12 | return &cobra.Command{ 13 | Use: "set", 14 | Short: "Sets a configuration value for a given site", 15 | Run: func(cmd *cobra.Command, args []string) { 16 | version.PrintSplash() 17 | //green := color.New(color.FgGreen) 18 | //yellow := color.New(color.FgYellow) 19 | blue := color.New(color.FgBlue) 20 | red := color.New(color.FgRed) 21 | 22 | if !utils.CheckAndPromptForSudo() { 23 | return 24 | } 25 | 26 | if len(args) < 3 { 27 | red.Println("At least three arguments are required") 28 | blue.Println("- yerd sites set ") 29 | blue.Println("- Examples:") 30 | blue.Println("- 'sudo yerd sites set php 8.3 example.test'") 31 | return 32 | } 33 | 34 | setName := args[0] 35 | setValue := args[1] 36 | siteIdentifier := args[2] 37 | 38 | siteManager, err := manager.NewSiteManager() 39 | if err != nil { 40 | red.Println("Unable to create a site manager instance") 41 | red.Println("Are the web components installed?") 42 | blue.Println("- You can install the web components with:") 43 | blue.Println("- 'sudo yerd web install'") 44 | return 45 | } 46 | 47 | siteManager.SetValue(setName, setValue, siteIdentifier) 48 | }, 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /cmd/composer/update.go: -------------------------------------------------------------------------------- 1 | package composer 2 | 3 | import ( 4 | "github.com/fatih/color" 5 | "github.com/lumosolutions/yerd/internal/constants" 6 | internalComposer "github.com/lumosolutions/yerd/internal/installers/composer" 7 | "github.com/lumosolutions/yerd/internal/utils" 8 | intVersion "github.com/lumosolutions/yerd/internal/version" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | func BuildUpdateCommand() *cobra.Command { 13 | return &cobra.Command{ 14 | Use: "update", 15 | Short: "Updates the YERD managed Composer to the latest version", 16 | Run: func(cmd *cobra.Command, args []string) { 17 | intVersion.PrintSplash() 18 | green := color.New(color.FgGreen) 19 | yellow := color.New(color.FgYellow) 20 | blue := color.New(color.FgBlue) 21 | red := color.New(color.FgRed) 22 | 23 | if !utils.CheckAndPromptForSudo() { 24 | return 25 | } 26 | 27 | if !utils.FileExists(constants.LocalComposerPath) { 28 | yellow.Printf("Composer is not installed\n") 29 | blue.Printf("- To install composer, please run:\n") 30 | blue.Printf("- 'sudo yerd composer install'\n\n") 31 | 32 | red.Printf("❌ Operation cancelled\n") 33 | return 34 | } 35 | 36 | if err := internalComposer.InstallComposer(); err != nil { 37 | red.Printf("Composer failed to update!\n") 38 | blue.Printf("- Error: %v\n\n", err) 39 | red.Printf("❌ Operation cancelled\n") 40 | return 41 | } 42 | 43 | green.Printf("✓ Composer updated successfully\n") 44 | blue.Printf("- Type it out with: 'composer --version'\n") 45 | }, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /cmd/sites/add.go: -------------------------------------------------------------------------------- 1 | package sites 2 | 3 | import ( 4 | "github.com/fatih/color" 5 | "github.com/lumosolutions/yerd/internal/manager" 6 | "github.com/lumosolutions/yerd/internal/utils" 7 | "github.com/lumosolutions/yerd/internal/version" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func BuildAddCommand() *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "add", 14 | Short: "Adds a new local development site given a directory", 15 | Run: func(cmd *cobra.Command, args []string) { 16 | version.PrintSplash() 17 | red := color.New(color.FgRed) 18 | blue := color.New(color.FgBlue) 19 | 20 | if !utils.CheckAndPromptForSudo() { 21 | return 22 | } 23 | 24 | path := args[0] 25 | domain, _ := cmd.Flags().GetString("domain") 26 | folder, _ := cmd.Flags().GetString("folder") 27 | php, _ := cmd.Flags().GetString("php") 28 | 29 | siteManager, err := manager.NewSiteManager() 30 | if err != nil { 31 | red.Println("Unable to create a site manager instance") 32 | red.Println("Are the web components installed?") 33 | blue.Println("- You can install the web components with:") 34 | blue.Println("- 'sudo yerd web install'") 35 | return 36 | } 37 | 38 | siteManager.AddSite(path, domain, folder, php) 39 | }, 40 | } 41 | 42 | cmd.Flags().StringP("domain", "d", "", "Override the default domain value (eg: mysite.test)") 43 | cmd.Flags().StringP("folder", "f", "", "Specify a public directory under the root") 44 | cmd.Flags().StringP("php", "p", "", "Specify the version of php to use") 45 | 46 | return cmd 47 | } 48 | -------------------------------------------------------------------------------- /cmd/php/list.go: -------------------------------------------------------------------------------- 1 | package php 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/lumosolutions/yerd/internal/config" 8 | "github.com/lumosolutions/yerd/internal/constants" 9 | phpinstaller "github.com/lumosolutions/yerd/internal/installers/php" 10 | intVersion "github.com/lumosolutions/yerd/internal/version" 11 | "github.com/olekukonko/tablewriter" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | func BuildListCmd() *cobra.Command { 16 | cmd := &cobra.Command{ 17 | Use: "list", 18 | Short: "Lists installed PHP versions", 19 | Run: func(cmd *cobra.Command, args []string) { 20 | intVersion.PrintSplash() 21 | 22 | versions := constants.GetAvailablePhpVersions() 23 | latestVersions, _, _ := phpinstaller.GetLatestVersions() 24 | 25 | rows := [][]string{} 26 | headers := []string{"VERSION", "INSTALLED", "CLI", "EXTENSIONS", "UPDATES"} 27 | 28 | for _, version := range versions { 29 | if data, installed := config.GetInstalledPhpInfo(version); installed { 30 | rows = append(rows, []string{ 31 | data.Version, 32 | data.InstalledVersion, 33 | friendlyBool(data.IsCLI), 34 | fmt.Sprintf("%d", len(data.Extensions)), 35 | friendlyBool(data.InstalledVersion != latestVersions[version]), 36 | }) 37 | } 38 | } 39 | 40 | if len(rows) == 0 { 41 | fmt.Println("No YERD PHP versions installed") 42 | fmt.Println("Run 'sudo yerd php {version} install' to get started") 43 | return 44 | } 45 | 46 | table := tablewriter.NewWriter(os.Stdout) 47 | table.Header(headers) 48 | table.Bulk(rows) 49 | 50 | table.Render() 51 | }, 52 | } 53 | 54 | return cmd 55 | } 56 | 57 | func friendlyBool(value bool) string { 58 | if value { 59 | return "Yes" 60 | } 61 | 62 | return "No" 63 | } 64 | -------------------------------------------------------------------------------- /internal/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fatih/color" 7 | ) 8 | 9 | const Version = "1.1.7" 10 | const Branch = "main" 11 | const Repo = "LumoSolutions/yerd" 12 | 13 | // PrintSplash displays the YERD ASCII art logo and version information with colors. 14 | func PrintSplash() { 15 | cyan := color.New(color.FgCyan, color.Bold) 16 | yellow := color.New(color.FgYellow, color.Bold) 17 | white := color.New(color.FgWhite) 18 | gray := color.New(color.FgHiBlack) 19 | 20 | splash := ` 21 | ██╗ ██╗███████╗██████╗ ██████╗ 22 | ╚██╗ ██╔╝██╔════╝██╔══██╗██╔══██╗ 23 | ╚████╔╝ █████╗ ██████╔╝██║ ██║ 24 | ╚██╔╝ ██╔══╝ ██╔══██╗██║ ██║ 25 | ██║ ███████╗██║ ██║██████╔╝ 26 | ╚═╝ ╚══════╝╚═╝ ╚═╝╚═════╝` 27 | 28 | cyan.Print(splash) 29 | fmt.Println() 30 | 31 | yellow.Printf(" v%s\n", Version) 32 | fmt.Println() 33 | 34 | white.Println("A powerful, developer-friendly tool for managing") 35 | white.Println("multiple PHP versions and local development ") 36 | white.Println("environments via nginx with ease") 37 | fmt.Println() 38 | gray.Println("Consider contributing today") 39 | gray.Println("https://github.com/LumoSolutions/yerd") 40 | fmt.Println() 41 | } 42 | 43 | // GetVersion returns the current YERD version string. 44 | func GetVersion() string { 45 | return Version 46 | } 47 | 48 | // GetBranch returns the current branch that files should 49 | // be downloaded from, useful during development, should 50 | // be reset to main on build for release 51 | func GetBranch() string { 52 | return Branch 53 | } 54 | 55 | // GetRepo returns the current repo used to store files 56 | // and will be where the .config files are located 57 | func GetRepo() string { 58 | return Repo 59 | } 60 | -------------------------------------------------------------------------------- /internal/utils/systemd.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "fmt" 4 | 5 | func SystemdReload() error { 6 | if output, success := ExecuteCommand("systemctl", "daemon-reload"); !success { 7 | LogInfo("systemd", "Failed to reload systemd with daemon-reload") 8 | LogInfo("systemd", "Output: %s", output) 9 | return fmt.Errorf("unable to reload systemd") 10 | } 11 | 12 | return nil 13 | } 14 | 15 | func SystemdStopService(service string) { 16 | if output, success := ExecuteCommand("systemctl", "stop", service); !success { 17 | LogInfo("systemd", "Failed to reload systemd with daemon-reload") 18 | LogInfo("systemd", "Output: %s", output) 19 | } 20 | } 21 | 22 | func SystemdStartService(service string) error { 23 | if output, success := ExecuteCommand("systemctl", "start", service); !success { 24 | LogInfo("systemd", "Failed to start service") 25 | LogInfo("systemd", "Output: %s", output) 26 | return fmt.Errorf("unable to start systemd service %s", service) 27 | } 28 | 29 | return nil 30 | } 31 | 32 | func SystemdEnable(service string) error { 33 | if output, success := ExecuteCommand("systemctl", "enable", service); !success { 34 | LogInfo("systemd", "Failed to enable systemd service") 35 | LogInfo("systemd", "Output: %s", output) 36 | return fmt.Errorf("unable to enable systemd service %s", service) 37 | } 38 | 39 | return nil 40 | } 41 | 42 | func SystemdDisable(service string) error { 43 | if output, success := ExecuteCommand("systemctl", "disable", service); !success { 44 | LogInfo("systemd", "Failed to enable systemd service") 45 | LogInfo("systemd", "Output: %s", output) 46 | return fmt.Errorf("unable to enable systemd service %s", service) 47 | } 48 | 49 | return nil 50 | } 51 | 52 | func SystemdServiceActive(service string) bool { 53 | _, success := ExecuteCommand("systemctl", "is-active", service) 54 | return success 55 | } 56 | -------------------------------------------------------------------------------- /internal/installers/php/uninstall.go: -------------------------------------------------------------------------------- 1 | package php 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | 7 | "github.com/lumosolutions/yerd/internal/config" 8 | "github.com/lumosolutions/yerd/internal/constants" 9 | "github.com/lumosolutions/yerd/internal/utils" 10 | ) 11 | 12 | func UninstallPhp(info *config.PhpInfo) error { 13 | serviceName := fmt.Sprintf("yerd-php%s-fpm", info.Version) 14 | systemdPath := filepath.Join(constants.SystemdDir, fmt.Sprintf("yerd-php%s-fpm.service", info.Version)) 15 | 16 | utils.SystemdDisable(serviceName) 17 | utils.SystemdStopService(serviceName) 18 | if err := utils.RemoveFile(systemdPath); err != nil { 19 | utils.LogError(err, "uninstall") 20 | return err 21 | } 22 | 23 | utils.SystemdReload() 24 | 25 | installDir := fmt.Sprintf("%s/php%s", constants.YerdPHPDir, info.Version) 26 | etcDir := fmt.Sprintf("%s/php%s", constants.YerdEtcDir, info.Version) 27 | localSymlink := fmt.Sprintf("%s/php%s", constants.YerdBinDir, info.Version) 28 | globalSymlink := fmt.Sprintf("%s/php%s", constants.SystemBinDir, info.Version) 29 | pidFile := fmt.Sprintf("%s/run/php%s-fpm.pid", constants.YerdPHPDir, info.Version) 30 | 31 | if err := utils.RunAll( 32 | func() error { return utils.RemoveSymlink(globalSymlink) }, 33 | func() error { return utils.RemoveSymlink(localSymlink) }, 34 | func() error { return utils.RemoveFile(pidFile) }, 35 | func() error { return utils.RemoveFolder(installDir) }, 36 | func() error { return utils.RemoveFolder(etcDir) }, 37 | func() error { 38 | if info.IsCLI { 39 | return utils.RemoveSymlink(constants.GlobalPhpPath) 40 | } 41 | return nil 42 | }, 43 | ); err != nil { 44 | utils.LogError(err, "uninstall") 45 | utils.LogInfo("uninstall", "Failed to one one of the required functions") 46 | return err 47 | } 48 | 49 | config.Delete(fmt.Sprintf("php.[%s]", info.Version)) 50 | 51 | return nil 52 | 53 | } 54 | -------------------------------------------------------------------------------- /cmd/php/install.go: -------------------------------------------------------------------------------- 1 | package php 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fatih/color" 7 | "github.com/lumosolutions/yerd/internal/config" 8 | phpinstaller "github.com/lumosolutions/yerd/internal/installers/php" 9 | "github.com/lumosolutions/yerd/internal/utils" 10 | intVersion "github.com/lumosolutions/yerd/internal/version" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func buildInstallCmd(version string) *cobra.Command { 15 | cmd := &cobra.Command{ 16 | Use: "install", 17 | Short: fmt.Sprintf("Install PHP %s", version), 18 | Run: func(cmd *cobra.Command, args []string) { 19 | intVersion.PrintSplash() 20 | 21 | if !utils.CheckAndPromptForSudo() { 22 | return 23 | } 24 | 25 | blue := color.New(color.FgBlue) 26 | yellow := color.New(color.FgYellow) 27 | red := color.New(color.FgRed) 28 | green := color.New(color.FgGreen) 29 | 30 | if _, installed := config.GetInstalledPhpInfo(version); installed { 31 | yellow.Printf("PHP %s is already installed, please use one of the following:\n", version) 32 | blue.Printf("- 'sudo yerd php %s rebuild' to build the current version\n", version) 33 | blue.Printf("- 'sudo yerd php %s upgrade' to update PHP %s to the latest version\n\n", version, version) 34 | red.Printf("❌ Operation cancelled\n") 35 | return 36 | } 37 | 38 | nocache, _ := cmd.Flags().GetBool("nocache") 39 | 40 | installer, err := phpinstaller.NewPhpInstaller(version, nocache, true) 41 | if err != nil { 42 | red.Printf("Failed to install php%s: %v\n", version, err) 43 | return 44 | } 45 | 46 | if err := installer.Install(); err != nil { 47 | red.Printf("Failed to install php%s: %v\n", version, err) 48 | return 49 | } 50 | 51 | green.Println("✓ Installation complete...") 52 | fmt.Println("Thanks for using YERD") 53 | }, 54 | } 55 | 56 | cmd.Flags().BoolP("nocache", "n", false, "Bypass cache to get the latest version from php.net") 57 | 58 | return cmd 59 | } 60 | -------------------------------------------------------------------------------- /internal/utils/ui.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/fatih/color" 8 | "github.com/lumosolutions/yerd/internal/constants" 9 | ) 10 | 11 | // PrintExtensionsGrid displays extensions in a nicely formatted grid (4 per line). 12 | // extensions: List of extension names to display with proper spacing and alignment. 13 | func PrintExtensionsGrid(extensions []string) { 14 | for i, ext := range extensions { 15 | if i%4 == 0 { 16 | fmt.Print(" ") 17 | } 18 | fmt.Printf("%-12s", ext) 19 | if (i+1)%4 == 0 || i == len(extensions)-1 { 20 | fmt.Println() 21 | } 22 | } 23 | } 24 | 25 | // PrintInvalidExtensionsWithSuggestions prints invalid extensions and their suggestions. 26 | // invalid: List of invalid extension names. Returns error if invalid extensions found. 27 | func PrintInvalidExtensionsWithSuggestions(invalid []string) { 28 | fmt.Println("Invalid extensions:") 29 | PrintExtensionsGrid(invalid) 30 | fmt.Println() 31 | 32 | for _, inv := range invalid { 33 | suggestions := constants.SuggestSimilarExtensions(inv) 34 | if len(suggestions) > 0 { 35 | fmt.Printf("Did you mean '%s'? Suggestions: %s\n", inv, strings.Join(suggestions, ", ")) 36 | } 37 | } 38 | } 39 | 40 | // CheckAndPromptForSudo verifies permissions and provides helpful sudo guidance if needed. 41 | // operation: Description of operation, command: Command name, args: Command arguments. Returns true if permissions OK. 42 | func CheckAndPromptForSudo() bool { 43 | if err := CheckInstallPermissions(); err != nil { 44 | blue := color.New(color.FgBlue) 45 | red := color.New(color.FgRed) 46 | 47 | red.Printf("❌ Error: this command requires elevated permissions\n") 48 | blue.Printf("💡 This is needed to:\n") 49 | blue.Printf(" • Install or remove installations\n") 50 | blue.Printf(" • Update system-wide configuration\n\n") 51 | fmt.Printf("Please rerun this command with sudo\n\n") 52 | return false 53 | } 54 | return true 55 | } 56 | -------------------------------------------------------------------------------- /cmd/php/status.go: -------------------------------------------------------------------------------- 1 | package php 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/lumosolutions/yerd/internal/config" 7 | "github.com/lumosolutions/yerd/internal/constants" 8 | "github.com/lumosolutions/yerd/internal/utils" 9 | "github.com/lumosolutions/yerd/internal/version" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func BuildStatusCmd() *cobra.Command { 14 | return &cobra.Command{ 15 | Use: "status", 16 | Short: "Show YERD PHP status and configuration", 17 | Run: func(cmd *cobra.Command, args []string) { 18 | version.PrintSplash() 19 | 20 | all := make(config.PhpConfig) 21 | config.GetStruct("php", &all) 22 | 23 | outputYerdConfig(all) 24 | 25 | for _, version := range all { 26 | outputPhpInfo(version) 27 | } 28 | }, 29 | } 30 | } 31 | 32 | func outputYerdConfig(fullConfig config.PhpConfig) { 33 | fmt.Printf("📊 YERD PHP Status\n") 34 | fmt.Printf("├─ Installed PHP versions: %d\n", len(fullConfig)) 35 | 36 | cliVersion := "" 37 | for _, version := range fullConfig { 38 | if version.IsCLI { 39 | cliVersion = version.Version 40 | } 41 | } 42 | 43 | if cliVersion != "" { 44 | fmt.Printf("├─ Current CLI: PHP %s\n", cliVersion) 45 | } else { 46 | fmt.Printf("├─ Current CLI: None set\n") 47 | } 48 | 49 | fmt.Printf("└─ Config: ~/.config/yerd/config.json\n") 50 | fmt.Println() 51 | } 52 | 53 | func outputPhpInfo(info config.PhpInfo) { 54 | status := getServiceStatus(info.Version) 55 | flag := "🟢" 56 | if status != "Running" { 57 | flag = "🔴" 58 | } 59 | 60 | fmt.Printf("%s PHP %s (%s)\n", flag, info.Version, info.InstalledVersion) 61 | fmt.Printf("├─ Binary: %s\n", constants.YerdBinDir+fmt.Sprintf("/php%s", info.Version)) 62 | fmt.Printf("├─ php.ini: %s\n", constants.YerdEtcDir+fmt.Sprintf("/php%s/php.ini", info.Version)) 63 | fmt.Printf("├─ FPM Socket: %s\n", constants.YerdPHPDir+fmt.Sprintf("/run/php%s-fpm.sock", info.Version)) 64 | fmt.Printf("└─ FPM Service: %s\n", status) 65 | fmt.Println() 66 | } 67 | 68 | func getServiceStatus(version string) string { 69 | if utils.SystemdServiceActive(fmt.Sprintf("yerd-php%s-fpm", version)) { 70 | return "Running" 71 | } 72 | return "Stopped" 73 | } 74 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/lumosolutions/yerd/cmd/composer" 8 | "github.com/lumosolutions/yerd/cmd/php" 9 | "github.com/lumosolutions/yerd/cmd/sites" 10 | "github.com/lumosolutions/yerd/cmd/web" 11 | "github.com/lumosolutions/yerd/internal/constants" 12 | "github.com/lumosolutions/yerd/internal/version" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | var rootCmd = &cobra.Command{ 17 | Use: "yerd", 18 | Short: "A powerful, developer-friendly tool for managing PHP versions and local development environments with ease", 19 | Long: `Features: 20 | • Install and manage multiple PHP versions simultaneously 21 | • Switch PHP CLI versions instantly with simple commands 22 | • Lightweight and fast - no unnecessary overhead 23 | • Developer friendly`, 24 | Version: version.GetVersion(), 25 | Run: func(cmd *cobra.Command, args []string) { 26 | version.PrintSplash() 27 | cmd.Help() 28 | }, 29 | } 30 | 31 | // Execute runs the root command and handles any errors using cobra's error handler. 32 | func Execute() { 33 | if err := rootCmd.Execute(); err != nil { 34 | fmt.Fprintln(os.Stderr, err) 35 | os.Exit(1) 36 | } 37 | } 38 | 39 | func init() { 40 | phpCmd.AddCommand(php.BuildListCmd()) 41 | phpCmd.AddCommand(php.BuildStatusCmd()) 42 | phpVersions := constants.GetAvailablePhpVersions() 43 | for _, version := range phpVersions { 44 | phpCmd.AddCommand(php.CreateVersionCommand(version)) 45 | } 46 | 47 | rootCmd.AddCommand(phpCmd) 48 | 49 | composerCmd.AddCommand(composer.BuildInstallCommand()) 50 | composerCmd.AddCommand(composer.BuildUninstallCommand()) 51 | composerCmd.AddCommand(composer.BuildUpdateCommand()) 52 | 53 | rootCmd.AddCommand(composerCmd) 54 | 55 | webCmd.AddCommand(web.BuildInstallCommand()) 56 | webCmd.AddCommand(web.BuildUninstallCommand()) 57 | webCmd.AddCommand(web.BuildTrustCommand()) 58 | 59 | rootCmd.AddCommand(webCmd) 60 | 61 | sitesCmd.AddCommand(sites.BuildListCommand()) 62 | sitesCmd.AddCommand(sites.BuildAddCommand()) 63 | sitesCmd.AddCommand(sites.BuildRemoveCommand()) 64 | sitesCmd.AddCommand(sites.BuildSetCommand()) 65 | 66 | rootCmd.AddCommand(sitesCmd) 67 | 68 | UpdateCmd.Flags().BoolVarP(&autoConfirm, "yes", "y", false, "Automatically confirm update without prompting") 69 | rootCmd.AddCommand(UpdateCmd) 70 | } 71 | -------------------------------------------------------------------------------- /cmd/php/cli.go: -------------------------------------------------------------------------------- 1 | package php 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fatih/color" 7 | "github.com/lumosolutions/yerd/internal/config" 8 | phpinstaller "github.com/lumosolutions/yerd/internal/installers/php" 9 | "github.com/lumosolutions/yerd/internal/utils" 10 | intVersion "github.com/lumosolutions/yerd/internal/version" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func buildCliCmd(version string) *cobra.Command { 15 | cmd := &cobra.Command{ 16 | Use: "cli", 17 | Short: fmt.Sprintf("Set default PHP CLI version to PHP %s", version), 18 | Long: `Set the default PHP version for command line usage. 19 | 20 | Examples: 21 | yerd php 8.4 cli`, 22 | Run: func(cmd *cobra.Command, args []string) { 23 | intVersion.PrintSplash() 24 | 25 | if !utils.CheckAndPromptForSudo() { 26 | return 27 | } 28 | 29 | force, _ := cmd.Flags().GetBool("force") 30 | green := color.New(color.FgGreen) 31 | blue := color.New(color.FgBlue) 32 | red := color.New(color.FgRed) 33 | 34 | data, installed := config.GetInstalledPhpInfo(version) 35 | if !installed { 36 | red.Println("❌ Error: No action taken") 37 | blue.Printf("- PHP %s is not installed, please use\n", version) 38 | blue.Printf("- 'sudo yerd php %s install'\n\n", version) 39 | return 40 | } 41 | 42 | if data.IsCLI && !force { 43 | red.Println("❌ Error: No action taken") 44 | blue.Printf("- PHP %s is already the default CLI version of PHP\n", version) 45 | blue.Println("- If you wish to reapply this version forceably, you can use:") 46 | blue.Printf("- 'sudo yerd php %s cli -f'\n\n", version) 47 | return 48 | } 49 | 50 | fmt.Printf("Setting PHP %s as the default CLI version\n", version) 51 | 52 | if err := phpinstaller.SetCliVersion(data); err != nil { 53 | red.Println("❌ Error: No action taken") 54 | blue.Printf("- Unable to set PHP %s as the default CLI version", version) 55 | blue.Printf("- %v", err) 56 | return 57 | } 58 | 59 | green.Println("✓ Default PHP CLI version has been updated") 60 | blue.Printf("- PHP CLI version %s\n", version) 61 | 62 | if force { 63 | blue.Print("- update was forced using the -f/--force flag") 64 | } 65 | 66 | }, 67 | } 68 | 69 | cmd.Flags().BoolP("force", "f", false, "Force the regeneration of the symlinks for the php CLI") 70 | return cmd 71 | } 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # Go workspace file 18 | go.work 19 | 20 | # Built binaries 21 | yerd 22 | yerd.exe 23 | 24 | # Build directories 25 | build/ 26 | dist/ 27 | bin/ 28 | 29 | # IDE and editor files 30 | .vscode/ 31 | .idea/ 32 | *.swp 33 | *.swo 34 | *~ 35 | .DS_Store 36 | Thumbs.db 37 | 38 | # Log files 39 | *.log 40 | 41 | # Temporary files 42 | *.tmp 43 | *.temp 44 | 45 | # Local configuration files 46 | .env 47 | .env.local 48 | .env.*.local 49 | 50 | # Coverage reports 51 | coverage.txt 52 | coverage.html 53 | coverage.out 54 | 55 | # Profiling data 56 | cpu.prof 57 | mem.prof 58 | profile.out 59 | 60 | # OS generated files 61 | .DS_Store 62 | .DS_Store? 63 | ._* 64 | .Spotlight-V100 65 | .Trashes 66 | ehthumbs.db 67 | Thumbs.db 68 | 69 | # Archive files 70 | *.tar.gz 71 | *.zip 72 | *.7z 73 | *.rar 74 | 75 | # Local development 76 | local/ 77 | tmp/ 78 | 79 | # Release artifacts (keep in repo for GitHub Actions) 80 | # dist/ is commented out because we may want to include release artifacts 81 | 82 | # Node modules (in case of any JS tooling) 83 | node_modules/ 84 | 85 | # Python cache (in case of any Python scripts) 86 | __pycache__/ 87 | *.py[cod] 88 | *$py.class 89 | 90 | # Backup files 91 | *.bak 92 | *.backup 93 | 94 | # AI Agent files and configurations 95 | CLAUDE.md 96 | claude.md 97 | CLAUDE/* 98 | claude/* 99 | .claude/ 100 | .claude* 101 | 102 | # Cursor AI files 103 | .cursor/ 104 | .cursorrules 105 | .cursor-tutor 106 | 107 | # GitHub Copilot files 108 | .github/copilot-instructions.md 109 | .copilot/ 110 | 111 | # Other AI assistant files 112 | .ai/ 113 | .assistant/ 114 | ai-instructions.md 115 | assistant-config.* 116 | 117 | # Anthropic Claude files 118 | anthropic.md 119 | claude-instructions.* 120 | claude-config.* 121 | 122 | # OpenAI files 123 | openai.* 124 | chatgpt.* 125 | 126 | # Continue.dev files 127 | .continue/ 128 | continue.md 129 | 130 | # Cody files 131 | .cody/ 132 | cody-config.* 133 | 134 | # Tabnine files 135 | .tabnine/ 136 | tabnine.* 137 | 138 | # Other common AI files 139 | ai-context.* 140 | ai-prompts.* 141 | assistant-notes.* 142 | llm-config.* -------------------------------------------------------------------------------- /internal/installers/php/general.go: -------------------------------------------------------------------------------- 1 | package php 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/user" 7 | "path/filepath" 8 | "strconv" 9 | 10 | "github.com/lumosolutions/yerd/internal/config" 11 | "github.com/lumosolutions/yerd/internal/constants" 12 | "github.com/lumosolutions/yerd/internal/utils" 13 | ) 14 | 15 | // getPhpVersionInfo returns the version information for a PHP version 16 | // version: PHP version to be fetched 17 | // cached: Should we use the cache, or download fresh results from php.net 18 | func getPhpVersionInfo(version string, cached bool) (*PhpVersionInfo, error) { 19 | var versions map[string]string 20 | var urls map[string]string 21 | var err error 22 | 23 | if cached { 24 | versions, urls, err = GetLatestVersions() 25 | } else { 26 | versions, urls, err = GetLatestVersionsFresh() 27 | } 28 | 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | info, installed := config.GetInstalledPhpInfo(version) 34 | var extensions []string 35 | if installed { 36 | extensions = info.Extensions 37 | } else { 38 | extensions = constants.GetDefaultExtensions() 39 | } 40 | 41 | tempDir := os.TempDir() 42 | 43 | return &PhpVersionInfo{ 44 | MajorMinor: version, 45 | Version: versions[version], 46 | DownloadURL: urls[versions[version]], 47 | ConfigureFlags: getConfigureFlags(version, extensions), 48 | SourcePackage: fmt.Sprintf("php-%s", version), 49 | ArchivePath: filepath.Join(tempDir, fmt.Sprintf("php-%s.tar.gz", versions[version])), 50 | ExtractPath: filepath.Join(tempDir, fmt.Sprintf("php-%s-extract", versions[version])), 51 | SourcePath: filepath.Join(constants.YerdPHPDir, "src", fmt.Sprintf("php-%s", version)), 52 | }, nil 53 | } 54 | 55 | // GetFPMUser returns the username that should be used for PHP-FPM processes. 56 | // Uses the real user context (handling sudo scenarios) or falls back to "nobody". 57 | func GetFPMUser() string { 58 | userCtx, err := utils.GetRealUser() 59 | if err != nil { 60 | return "nobody" 61 | } 62 | return userCtx.Username 63 | } 64 | 65 | // GetFPMGroup returns the group name that should be used for PHP-FPM processes. 66 | // Uses the real user's primary group (handling sudo scenarios) or falls back to "nobody". 67 | func GetFPMGroup() string { 68 | userCtx, err := utils.GetRealUser() 69 | if err != nil { 70 | return "nobody" 71 | } 72 | 73 | // Get group information from GID 74 | group, err := user.LookupGroupId(strconv.Itoa(userCtx.GID)) 75 | if err != nil { 76 | return "nobody" 77 | } 78 | 79 | return group.Name 80 | } 81 | 82 | func getBinaryPath(version string) string { 83 | return constants.YerdBinDir + "/php" + version 84 | } 85 | -------------------------------------------------------------------------------- /cmd/php/update.go: -------------------------------------------------------------------------------- 1 | package php 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fatih/color" 7 | "github.com/lumosolutions/yerd/internal/config" 8 | phpinstaller "github.com/lumosolutions/yerd/internal/installers/php" 9 | "github.com/lumosolutions/yerd/internal/utils" 10 | intVersion "github.com/lumosolutions/yerd/internal/version" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func buildUpdateCmd(version string) *cobra.Command { 15 | cmd := &cobra.Command{ 16 | Use: "update", 17 | Short: fmt.Sprintf("Update PHP %s", version), 18 | Run: func(cmd *cobra.Command, args []string) { 19 | intVersion.PrintSplash() 20 | 21 | if !utils.CheckAndPromptForSudo() { 22 | return 23 | } 24 | 25 | blue := color.New(color.FgBlue) 26 | yellow := color.New(color.FgYellow) 27 | red := color.New(color.FgRed) 28 | green := color.New(color.FgGreen) 29 | 30 | data, installed := config.GetInstalledPhpInfo(version) 31 | if !installed { 32 | yellow.Printf("PHP %s is not installed, please use the following command:\n", version) 33 | blue.Printf("- 'sudo yerd php %s install'\n\n", version) 34 | red.Printf("❌ Operation cancelled\n") 35 | return 36 | } 37 | 38 | configFlag, _ := cmd.Flags().GetBool("config") 39 | versions, _, err := phpinstaller.GetLatestVersionsFresh() 40 | if err != nil { 41 | phpinstaller.PrintVersionFetchError(version) 42 | red.Printf("\n❌ Operation cancelled\n") 43 | return 44 | } 45 | 46 | if versions[version] == data.InstalledVersion { 47 | yellow.Printf("PHP %s is already running the latest version\n", version) 48 | blue.Printf("- Running version: %s\n", data.InstalledVersion) 49 | blue.Printf("- Latest version: %s\n", versions[version]) 50 | blue.Printf("- To rebuild the current version, please use:\n") 51 | blue.Printf("- 'sudo yerd php %s rebuild\n\n", version) 52 | 53 | red.Printf("❌ Operation cancelled\n") 54 | return 55 | } 56 | 57 | installer, err := phpinstaller.NewPhpInstaller(version, true, configFlag) 58 | if err != nil { 59 | red.Printf("Failed to upgrade PHP %s: %v\n", version, err) 60 | red.Printf("❌ Operation cancelled\n") 61 | return 62 | } 63 | 64 | if err := installer.Install(); err != nil { 65 | red.Printf("Failed to upgrade PHP %s: %v\n", version, err) 66 | red.Printf("❌ Operation cancelled\n") 67 | return 68 | } 69 | 70 | green.Println("✓ PHP has been upgraded to the latest version successfully...") 71 | fmt.Println("Thanks for using YERD") 72 | }, 73 | } 74 | 75 | cmd.Flags().BoolP("config", "c", false, "Recreate associated configuration, if it already exists") 76 | 77 | return cmd 78 | } 79 | -------------------------------------------------------------------------------- /cmd/php/uninstall.go: -------------------------------------------------------------------------------- 1 | package php 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | "strings" 7 | 8 | "github.com/fatih/color" 9 | "github.com/lumosolutions/yerd/internal/config" 10 | phpinstaller "github.com/lumosolutions/yerd/internal/installers/php" 11 | "github.com/lumosolutions/yerd/internal/utils" 12 | intVersion "github.com/lumosolutions/yerd/internal/version" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | func buildUninstallCmd(version string) *cobra.Command { 17 | cmd := &cobra.Command{ 18 | Use: "uninstall", 19 | Short: fmt.Sprintf("Uninstalls PHP %s", version), 20 | Run: func(cmd *cobra.Command, args []string) { 21 | intVersion.PrintSplash() 22 | 23 | if !utils.CheckAndPromptForSudo() { 24 | return 25 | } 26 | 27 | green := color.New(color.FgGreen) 28 | yellow := color.New(color.FgYellow) 29 | blue := color.New(color.FgBlue) 30 | red := color.New(color.FgRed) 31 | 32 | agree, _ := cmd.Flags().GetBool("yes") 33 | 34 | data, installed := config.GetInstalledPhpInfo(version) 35 | if !installed { 36 | red.Println("❌ Error: No action taken") 37 | blue.Printf("- PHP %s is not installed\n\n", version) 38 | return 39 | } 40 | 41 | if data.IsCLI && !agree { 42 | yellow.Printf("⚠️ Warning: PHP %s is currently set as CLI version\n", version) 43 | fmt.Printf("This will remove the PHP CLI and the 'php' command will no longer work.\n") 44 | fmt.Printf("Continue? (y/N): ") 45 | 46 | var response string 47 | fmt.Scanln(&response) 48 | 49 | if !isYes(response) { 50 | red.Printf("\n❌ Operation cancelled\n") 51 | return 52 | } 53 | 54 | fmt.Println() 55 | } 56 | 57 | if !agree { 58 | yellow.Printf("⚠️ Are you sure you want to uninstall PHP %s?\n", version) 59 | fmt.Printf("Confirm Action (y/N): ") 60 | } 61 | 62 | var response string 63 | fmt.Scanln(&response) 64 | 65 | if !isYes(response) { 66 | red.Printf("\n❌ Operation cancelled\n") 67 | return 68 | } 69 | 70 | fmt.Printf("Removing PHP %s\n", version) 71 | 72 | if err := phpinstaller.UninstallPhp(data); err != nil { 73 | red.Println("❌ Error: No action taken") 74 | blue.Printf("- Unable to uninstall PHP %s\n", version) 75 | blue.Printf("- %v\n\n", err) 76 | return 77 | } 78 | 79 | green.Printf("✓ PHP %s has been uninstalled\n\n", version) 80 | 81 | }, 82 | } 83 | 84 | cmd.Flags().BoolP("agree", "y", false, "Automatically agree to any prompts") 85 | 86 | return cmd 87 | } 88 | 89 | func isYes(value string) bool { 90 | valid := []string{"y", "yes"} 91 | 92 | return slices.Contains( 93 | valid, 94 | strings.ToLower(strings.TrimSpace(value)), 95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /internal/utils/user.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/user" 7 | "path/filepath" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/lumosolutions/yerd/internal/constants" 12 | ) 13 | 14 | type UserContext struct { 15 | User *user.User 16 | Username string 17 | HomeDir string 18 | UID int 19 | GID int 20 | } 21 | 22 | // GetUserConfigDir returns the YERD configuration directory path for the real user. 23 | // Returns config directory path or error if user context cannot be determined. 24 | func GetUserConfigDir() (string, error) { 25 | userCtx, err := GetRealUser() 26 | if err != nil { 27 | return "", err 28 | } 29 | 30 | return filepath.Join(userCtx.HomeDir, ".config", "yerd"), nil 31 | } 32 | 33 | // GetRealUser determines the actual user context, handling sudo scenarios correctly. 34 | // Returns UserContext with user details including UID/GID or error if user lookup fails. 35 | func GetRealUser() (*UserContext, error) { 36 | var realUser *user.User 37 | var err error 38 | 39 | sudoUser := os.Getenv("SUDO_USER") 40 | if sudoUser != "" { 41 | realUser, err = user.Lookup(sudoUser) 42 | if err != nil { 43 | return nil, fmt.Errorf("failed to lookup sudo user %s: %v", sudoUser, err) 44 | } 45 | } else { 46 | realUser, err = user.Current() 47 | if err != nil { 48 | return nil, fmt.Errorf("failed to get current user: %v", err) 49 | } 50 | } 51 | 52 | uid, err := strconv.Atoi(realUser.Uid) 53 | if err != nil { 54 | return nil, fmt.Errorf("failed to parse UID: %v", err) 55 | } 56 | 57 | gid, err := strconv.Atoi(realUser.Gid) 58 | if err != nil { 59 | return nil, fmt.Errorf("failed to parse GID: %v", err) 60 | } 61 | 62 | return &UserContext{ 63 | User: realUser, 64 | Username: realUser.Username, 65 | HomeDir: realUser.HomeDir, 66 | UID: uid, 67 | GID: gid, 68 | }, nil 69 | } 70 | 71 | // CheckInstallPermissions verifies write access to directories required for YERD operations. 72 | // Returns error if insufficient permissions, nil if all required paths are writable. 73 | func CheckInstallPermissions() error { 74 | if os.Geteuid() == 0 { 75 | return nil 76 | } 77 | 78 | requiredPaths := map[string]string{ 79 | constants.YerdBaseDir: "YERD installation directory", 80 | constants.SystemBinDir: "system binary directory", 81 | } 82 | 83 | var failedPaths []string 84 | for path, desc := range requiredPaths { 85 | if !CanWriteToPath(path) { 86 | failedPaths = append(failedPaths, fmt.Sprintf("%s (%s)", path, desc)) 87 | } 88 | } 89 | 90 | if len(failedPaths) > 0 { 91 | return fmt.Errorf("cannot write to required directories: %s", strings.Join(failedPaths, ", ")) 92 | } 93 | 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 2 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 3 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 4 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 5 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 6 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 7 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 8 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 9 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 10 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 11 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 12 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 13 | github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= 14 | github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= 15 | github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= 16 | github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= 17 | github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8= 18 | github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= 19 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 20 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 21 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 22 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 23 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 24 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 25 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 26 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 27 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 28 | golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= 29 | golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 30 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 31 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 32 | -------------------------------------------------------------------------------- /cmd/php/extensions.go: -------------------------------------------------------------------------------- 1 | package php 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fatih/color" 7 | "github.com/lumosolutions/yerd/internal/config" 8 | phpinstaller "github.com/lumosolutions/yerd/internal/installers/php" 9 | "github.com/lumosolutions/yerd/internal/utils" 10 | intVersion "github.com/lumosolutions/yerd/internal/version" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func buildExtensionsCmd(version string) *cobra.Command { 15 | cmd := &cobra.Command{ 16 | Use: "extensions [extension2...]", 17 | Short: fmt.Sprintf("Manage PHP %s extensions", version), 18 | Long: fmt.Sprintf(`Manage PHP %s extensions. 19 | 20 | Examples: 21 | yerd php %s extensions list # List installed and available extensions 22 | yerd php %s extensions add gd memcached # Add multiple extensions to PHP 23 | yerd php %s extensions remove gd # Remove multiple extensions from PHP 24 | yerd php %s extensions add gd --rebuild # Add extensions and automatically rebuild PHP`, 25 | version, version, version, version, version, 26 | ), 27 | ValidArgs: []string{"add", "remove"}, 28 | Run: func(cmd *cobra.Command, args []string) { 29 | intVersion.PrintSplash() 30 | 31 | blue := color.New(color.FgBlue) 32 | red := color.New(color.FgRed) 33 | 34 | if len(args) < 1 { 35 | red.Println("Error: requires at least 1 argument: ") 36 | cmd.Usage() 37 | return 38 | } 39 | 40 | action := args[0] 41 | 42 | if len(args) < 2 && action != "list" { 43 | red.Printf("Error: requires at least 2 arguments: %s \n", action) 44 | cmd.Usage() 45 | return 46 | } 47 | 48 | extensions := args[1:] 49 | 50 | rebuild, _ := cmd.Flags().GetBool("rebuild") 51 | nocache, _ := cmd.Flags().GetBool("nocache") 52 | configFlag, _ := cmd.Flags().GetBool("config") 53 | 54 | if rebuild { 55 | if !utils.CheckAndPromptForSudo() { 56 | return 57 | } 58 | } 59 | 60 | data, installed := config.GetInstalledPhpInfo(version) 61 | if !installed { 62 | red.Println("❌ Error: No action taken") 63 | blue.Printf("- PHP %s is not installed, please use\n", version) 64 | blue.Printf("- 'sudo yerd php %s install'\n\n", version) 65 | return 66 | } 67 | 68 | extManager := phpinstaller.NewExtensionManager(version, data, nocache, configFlag, rebuild) 69 | if err := extManager.RunAction(action, extensions); err != nil { 70 | return 71 | } 72 | }, 73 | } 74 | 75 | cmd.Flags().BoolP("rebuild", "r", false, "Rebuild PHP after modifying extensions") 76 | cmd.Flags().BoolP("nocache", "n", false, "Bypass cache to get the latest version from php.net") 77 | cmd.Flags().BoolP("config", "c", false, "Recreate associated configuration, if it already exists") 78 | 79 | return cmd 80 | } 81 | -------------------------------------------------------------------------------- /.config/php/php.ini: -------------------------------------------------------------------------------- 1 | ; Default PHP configuration for YERD-managed PHP 2 | ; Generated by YERD (A powerful, developer-friendly tool for managing PHP versions) 3 | 4 | [PHP] 5 | ; Core Settings 6 | engine = On 7 | short_open_tag = Off 8 | precision = 14 9 | output_buffering = 4096 10 | zlib.output_compression = Off 11 | implicit_flush = Off 12 | unserialize_callback_func = 13 | serialize_precision = -1 14 | disable_functions = 15 | disable_classes = 16 | zend.enable_gc = On 17 | zend.exception_ignore_args = On 18 | zend.exception_string_param_max_len = 0 19 | 20 | ; Resource Limits 21 | max_execution_time = 30 22 | max_input_time = 60 23 | memory_limit = 128M 24 | 25 | ; Error handling and logging 26 | error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT 27 | display_errors = Off 28 | display_startup_errors = Off 29 | log_errors = On 30 | ignore_repeated_errors = Off 31 | ignore_repeated_source = Off 32 | report_memleaks = On 33 | 34 | ; Data Handling 35 | variables_order = "GPCS" 36 | request_order = "GP" 37 | register_argc_argv = Off 38 | auto_globals_jit = On 39 | post_max_size = 8M 40 | auto_prepend_file = 41 | auto_append_file = 42 | default_mimetype = "text/html" 43 | default_charset = "UTF-8" 44 | 45 | ; File Uploads 46 | file_uploads = On 47 | upload_max_filesize = 2M 48 | max_file_uploads = 20 49 | 50 | ; Fopen wrappers 51 | allow_url_fopen = On 52 | allow_url_include = Off 53 | default_socket_timeout = 60 54 | 55 | ; Dynamic Extensions 56 | ; extension_dir will be dynamically set based on PHP version 57 | extension_dir = "" 58 | 59 | ; Date 60 | date.timezone = UTC 61 | 62 | ; Session 63 | session.save_handler = files 64 | session.use_strict_mode = 0 65 | session.use_cookies = 1 66 | session.use_only_cookies = 1 67 | session.name = PHPSESSID 68 | session.auto_start = 0 69 | session.cookie_lifetime = 0 70 | session.cookie_path = / 71 | session.cookie_domain = 72 | session.cookie_httponly = 73 | session.cookie_samesite = 74 | session.serialize_handler = php 75 | session.gc_probability = 0 76 | session.gc_divisor = 1000 77 | session.gc_maxlifetime = 1440 78 | session.referer_check = 79 | session.cache_limiter = nocache 80 | session.cache_expire = 180 81 | session.use_trans_sid = 0 82 | session.sid_length = 26 83 | session.trans_sid_tags = "a=href,area=href,frame=src,form=" 84 | session.sid_bits_per_character = 5 85 | 86 | ; MySQLi 87 | mysqli.max_persistent = -1 88 | mysqli.allow_persistent = On 89 | mysqli.max_links = -1 90 | mysqli.default_port = 3306 91 | mysqli.default_socket = 92 | mysqli.default_host = 93 | mysqli.default_user = 94 | mysqli.default_pw = 95 | mysqli.reconnect = Off 96 | 97 | ; bcmath 98 | bcmath.scale = 0 99 | 100 | ; OpCache (if available) 101 | ; opcache.enable=1 102 | ; opcache.enable_cli=0 103 | ; opcache.memory_consumption=128 104 | ; opcache.interned_strings_buffer=8 105 | ; opcache.max_accelerated_files=4000 106 | ; opcache.revalidate_freq=2 107 | ; opcache.fast_shutdown=1 -------------------------------------------------------------------------------- /internal/utils/template.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // TemplateData represents key-value pairs for template replacement 9 | type TemplateData map[string]string 10 | 11 | // Template processes a template string by replacing {{% key %}} patterns with values 12 | func Template(template string, data TemplateData) string { 13 | result := template 14 | 15 | for key, value := range data { 16 | placeholder := fmt.Sprintf("{{%% %s %%}}", key) 17 | result = strings.ReplaceAll(result, placeholder, value) 18 | } 19 | 20 | return result 21 | } 22 | 23 | // TemplateWithDefaults processes a template with fallback values for missing keys 24 | func TemplateWithDefaults(template string, data TemplateData, defaults TemplateData) string { 25 | merged := make(TemplateData) 26 | 27 | for k, v := range defaults { 28 | merged[k] = v 29 | } 30 | 31 | for k, v := range data { 32 | merged[k] = v 33 | } 34 | 35 | return Template(template, merged) 36 | } 37 | 38 | // ExtractTemplateKeys returns all unique template keys found in a template string 39 | func ExtractTemplateKeys(template string) []string { 40 | uniqueKeys := extractUniqueKeys(template) 41 | return toSlice(uniqueKeys) 42 | } 43 | 44 | // extractUniqueKeys finds all unique keys in the template 45 | func extractUniqueKeys(template string) map[string]bool { 46 | uniqueKeys := make(map[string]bool) 47 | remaining := template 48 | 49 | for { 50 | key, nextPos := findNextKey(remaining) 51 | if key == "" { 52 | break 53 | } 54 | uniqueKeys[key] = true 55 | remaining = remaining[nextPos:] 56 | } 57 | 58 | return uniqueKeys 59 | } 60 | 61 | // findNextKey finds the next template key and returns it along with the position after it 62 | func findNextKey(template string) (string, int) { 63 | const startDelim = "{{%" 64 | const endDelim = "%}}" 65 | 66 | startIdx := strings.Index(template, startDelim) 67 | if startIdx == -1 { 68 | return "", 0 69 | } 70 | 71 | afterStart := startIdx + len(startDelim) 72 | endIdx := strings.Index(template[afterStart:], endDelim) 73 | if endIdx == -1 { 74 | return "", 0 75 | } 76 | 77 | key := strings.TrimSpace(template[afterStart : afterStart+endIdx]) 78 | nextPos := afterStart + endIdx + len(endDelim) 79 | 80 | return key, nextPos 81 | } 82 | 83 | // toSlice converts a map of keys to a slice 84 | func toSlice(uniqueKeys map[string]bool) []string { 85 | keys := make([]string, 0, len(uniqueKeys)) 86 | for key := range uniqueKeys { 87 | if key != "" { 88 | keys = append(keys, key) 89 | } 90 | } 91 | return keys 92 | } 93 | 94 | // ValidateTemplate checks if all template keys have corresponding data values 95 | func ValidateTemplate(template string, data TemplateData) []string { 96 | keys := ExtractTemplateKeys(template) 97 | var missing []string 98 | 99 | for _, key := range keys { 100 | if _, exists := data[key]; !exists { 101 | missing = append(missing, key) 102 | } 103 | } 104 | 105 | return missing 106 | } 107 | -------------------------------------------------------------------------------- /internal/utils/commands.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "io" 5 | "os/exec" 6 | "strconv" 7 | "strings" 8 | "syscall" 9 | ) 10 | 11 | // CommandExistschecks if a command exists using the 'which' command 12 | func CommandExists(cmd string) (string, bool) { 13 | output, success := ExecuteCommand("which", cmd) 14 | return strings.Trim(output, "\n"), success 15 | } 16 | 17 | // ExecuteCommand runs a command with full output logging to the specified logger. 18 | // command: Command to run 19 | // args: Additonal arguments 20 | func ExecuteCommandAsUser(command string, args ...string) (string, bool) { 21 | LogInfo(context, "=== EXECUTING COMMAND AS USER ===") 22 | LogInfo(context, "Executing: %s", command) 23 | LogInfo(context, "With Params: %s", strings.Join(args, " ")) 24 | 25 | userCtx, err := GetRealUser() 26 | if err != nil { 27 | LogError(err, "ExecuteCommand") 28 | return "", false 29 | } 30 | 31 | cmd := exec.Command(command, args...) 32 | cmd.SysProcAttr = &syscall.SysProcAttr{ 33 | Credential: &syscall.Credential{ 34 | Uid: uint32(userCtx.UID), 35 | Gid: uint32(userCtx.GID), 36 | }, 37 | } 38 | 39 | return runCommand(cmd) 40 | } 41 | 42 | func ExecuteCommandInDirAsUser(directory, command string, args ...string) (string, bool) { 43 | LogInfo(context, "=== EXECUTING COMMAND AS USER ===") 44 | LogInfo(context, "Executing: %s", command) 45 | LogInfo(context, "With Params: %s", strings.Join(args, " ")) 46 | LogInfo(context, "In Directory: %s", directory) 47 | 48 | userCtx, err := GetRealUser() 49 | if err != nil { 50 | LogError(err, "ExecuteCommand") 51 | return "", false 52 | } 53 | 54 | cmd := exec.Command(command, args...) 55 | cmd.Dir = directory 56 | cmd.SysProcAttr = &syscall.SysProcAttr{ 57 | Credential: &syscall.Credential{ 58 | Uid: uint32(userCtx.UID), 59 | Gid: uint32(userCtx.GID), 60 | }, 61 | } 62 | 63 | return runCommand(cmd) 64 | } 65 | 66 | func ExecuteCommand(command string, args ...string) (string, bool) { 67 | LogInfo(context, "=== EXECUTING COMMAND AS ROOT ===") 68 | LogInfo(context, "Executing: %s", command) 69 | LogInfo(context, "With Params: %s", strings.Join(args, " ")) 70 | 71 | cmd := exec.Command(command, args...) 72 | 73 | return runCommand(cmd) 74 | } 75 | 76 | func ExecuteCommandInDir(directory, command string, args ...string) (string, bool) { 77 | LogInfo(context, "=== EXECUTING COMMAND AS ROOT ===") 78 | LogInfo(context, "Executing: %s", command) 79 | LogInfo(context, "With Params: %s", strings.Join(args, " ")) 80 | LogInfo(context, "In Directory: %s", directory) 81 | 82 | cmd := exec.Command(command, args...) 83 | cmd.Dir = directory 84 | 85 | return runCommand(cmd) 86 | } 87 | 88 | func runCommand(cmd *exec.Cmd) (string, bool) { 89 | var output strings.Builder 90 | cmd.Stdout = io.MultiWriter(&output) 91 | cmd.Stderr = io.MultiWriter(&output) 92 | 93 | err := cmd.Run() 94 | result := output.String() 95 | 96 | var success bool 97 | 98 | if err != nil { 99 | success = false 100 | LogError(err, context) 101 | LogInfo("output", "%s", result) 102 | } else { 103 | success = true 104 | LogInfo(context, "Command executed successfully") 105 | } 106 | 107 | return result, success 108 | } 109 | 110 | // GetProcessorCount detects the number of CPU cores for parallel processing. 111 | // Returns processor count or defaults to 4 if detection fails. 112 | func GetProcessorCount() int { 113 | output, success := ExecuteCommand("nproc") 114 | if !success { 115 | return 4 116 | } 117 | 118 | if n, err := strconv.Atoi(strings.TrimSpace(output)); err == nil && n > 0 { 119 | return n 120 | } 121 | 122 | return 4 123 | } 124 | -------------------------------------------------------------------------------- /internal/constants/nginx.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | import "path/filepath" 4 | 5 | // NginxConfig represents configuration for a web Nginx 6 | type NginxConfig struct { 7 | Name string 8 | Version string 9 | DownloadURL string 10 | BuildFlags []string 11 | Dependencies []string 12 | InstallPath string 13 | ConfigPath string 14 | LogPath string 15 | RunPath string 16 | TempPath string 17 | BinaryPath string 18 | SourcePath string 19 | } 20 | 21 | // GetNginxConfig returns configuration for a specific Nginx 22 | func GetNginxConfig() *NginxConfig { 23 | return &NginxConfig{ 24 | Name: "nginx", 25 | Version: "1.29.1", 26 | DownloadURL: "http://nginx.org/download/nginx-1.29.1.tar.gz", 27 | BuildFlags: []string{ 28 | "--prefix=/opt/yerd/web/nginx", 29 | "--conf-path=/opt/yerd/web/nginx/conf/nginx.conf", 30 | "--error-log-path=/opt/yerd/web/nginx/logs/error.log", 31 | "--pid-path=/opt/yerd/web/nginx/run/nginx.pid", 32 | "--lock-path=/opt/yerd/web/nginx/run/nginx.lock", 33 | "--http-client-body-temp-path=/opt/yerd/web/nginx/temp/client_temp", 34 | "--http-proxy-temp-path=/opt/yerd/web/nginx/temp/proxy_temp", 35 | "--http-fastcgi-temp-path=/opt/yerd/web/nginx/temp/fastcgi_temp", 36 | "--http-uwsgi-temp-path=/opt/yerd/web/nginx/temp/uwsgi_temp", 37 | "--http-scgi-temp-path=/opt/yerd/web/nginx/temp/scgi_temp", 38 | "--with-http_ssl_module", 39 | "--with-http_realip_module", 40 | "--with-stream", 41 | "--with-stream_realip_module", 42 | "--with-cc-opt=-I/usr/include", 43 | "--with-ld-opt=-lsystemd", 44 | "--with-http_addition_module", 45 | "--with-http_sub_module", 46 | "--with-http_dav_module", 47 | "--with-http_flv_module", 48 | "--with-http_mp4_module", 49 | "--with-http_gunzip_module", 50 | "--with-http_gzip_static_module", 51 | "--with-http_auth_request_module", 52 | "--with-http_random_index_module", 53 | "--with-http_secure_link_module", 54 | "--with-http_degradation_module", 55 | "--with-http_slice_module", 56 | "--with-http_stub_status_module", 57 | "--with-http_v2_module", 58 | "--with-file-aio", 59 | "--with-threads", 60 | }, 61 | Dependencies: []string{"pcre2", "zlib", "openssl"}, 62 | InstallPath: getNginxInstallPath(), 63 | ConfigPath: getNginxConfigPath(), 64 | LogPath: getNginxLogPath(), 65 | RunPath: getNginxRunPath(), 66 | TempPath: getNginxTempPath(), 67 | BinaryPath: getNginxBinaryPath(), 68 | SourcePath: getNginxSrcPath(), 69 | } 70 | } 71 | 72 | // GetNginxInstallPath returns the installation path for a Nginx 73 | func getNginxInstallPath() string { 74 | return filepath.Join(YerdWebDir, "nginx") 75 | } 76 | 77 | // GetNginxConfigPath returns the configuration path for a Nginx 78 | func getNginxConfigPath() string { 79 | return filepath.Join(getNginxInstallPath(), "conf") 80 | } 81 | 82 | // GetNginxLogPath returns the log path for a Nginx 83 | func getNginxLogPath() string { 84 | return filepath.Join(getNginxInstallPath(), "logs") 85 | } 86 | 87 | // GetNginxRunPath returns the runtime path for a Nginx 88 | func getNginxRunPath() string { 89 | return filepath.Join(getNginxInstallPath(), "run") 90 | } 91 | 92 | // GetNginxTempPath returns the temporary files path for a Nginx 93 | func getNginxTempPath() string { 94 | return filepath.Join(getNginxInstallPath(), "temp") 95 | } 96 | 97 | // GetNginxBinaryPath returns the binary path for a Nginx 98 | func getNginxBinaryPath() string { 99 | return filepath.Join(getNginxInstallPath(), "sbin", "nginx") 100 | } 101 | 102 | func getNginxSrcPath() string { 103 | return filepath.Join(getNginxInstallPath(), "src") 104 | } 105 | -------------------------------------------------------------------------------- /internal/utils/download.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "path/filepath" 8 | "time" 9 | 10 | "github.com/lumosolutions/yerd/internal/constants" 11 | ) 12 | 13 | const ( 14 | DefaultTimeout = 30 * time.Second 15 | DefaultUserAgent = "YERD/1.0" 16 | ConfigRepoBase = "https://raw.githubusercontent.com/LumoSolutions/yerd" 17 | ) 18 | 19 | type DownloadOptions struct { 20 | Timeout time.Duration 21 | UserAgent string 22 | } 23 | 24 | // DefaultDownloadOptions returns default download configuration 25 | func DefaultDownloadOptions() *DownloadOptions { 26 | return &DownloadOptions{ 27 | Timeout: DefaultTimeout, 28 | UserAgent: DefaultUserAgent, 29 | } 30 | } 31 | 32 | // DownloadFile downloads a file to a given location 33 | // url: The URL to download 34 | // filePath: The location to place the file 35 | // opts: Any download options required 36 | func DownloadFile(url, filePath string, opts *DownloadOptions) error { 37 | if opts == nil { 38 | opts = DefaultDownloadOptions() 39 | } 40 | 41 | LogInfo(context, "Starting download from %s", url) 42 | LogInfo(context, "Destination: %s", filePath) 43 | 44 | dir := filepath.Dir(filePath) 45 | if dir != "" { 46 | if err := CreateDirectory(dir); err != nil { 47 | LogError(err, context) 48 | return fmt.Errorf("failed to create directory: %s", dir) 49 | } 50 | } 51 | 52 | if err := useHttpClient(url, filePath, opts); err != nil { 53 | LogWarning(context, "HTTP client failed %v, trying command-line tooling", err) 54 | if err := useCommandLine(url, filePath); err != nil { 55 | LogError(err, context) 56 | return fmt.Errorf("failed to download file") 57 | } 58 | } 59 | 60 | LogInfo(context, "File downloaded") 61 | return nil 62 | } 63 | 64 | func useHttpClient(url, filePath string, opts *DownloadOptions) error { 65 | client := &http.Client{ 66 | Timeout: opts.Timeout, 67 | Transport: &http.Transport{ 68 | DisableKeepAlives: true, 69 | }, 70 | } 71 | 72 | req, err := http.NewRequest("GET", url, nil) 73 | if err != nil { 74 | LogError(err, context) 75 | return fmt.Errorf("failed to create request") 76 | } 77 | 78 | req.Header.Set("User-Agent", opts.UserAgent) 79 | req.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate") 80 | req.Header.Set("Pragma", "no-cache") 81 | req.Header.Set("Expires", "0") 82 | 83 | resp, err := client.Do(req) 84 | if err != nil { 85 | return fmt.Errorf("HTTP request failed: %v", err) 86 | } 87 | defer resp.Body.Close() 88 | 89 | if resp.StatusCode != http.StatusOK { 90 | return fmt.Errorf("HTTP request failed with status %d", resp.StatusCode) 91 | } 92 | 93 | body, err := io.ReadAll(resp.Body) 94 | if err != nil { 95 | return fmt.Errorf("failed to read response body: %v", err) 96 | } 97 | 98 | if err := WriteToFile(filePath, body, constants.FilePermissions); err != nil { 99 | return fmt.Errorf("failed to create file: %v", err) 100 | } 101 | 102 | LogInfo(context, "Downloaded using HTTP client") 103 | return nil 104 | } 105 | 106 | func useCommandLine(url, filePath string) error { 107 | if _, exists := CommandExists("wget"); exists { 108 | if _, success := ExecuteCommand("wget", "-O", filePath, url); !success { 109 | return fmt.Errorf("wget donwload failed") 110 | } 111 | 112 | LogInfo(context, "File downloaded using wget") 113 | return nil 114 | } 115 | 116 | if _, exists := CommandExists("curl"); exists { 117 | if _, success := ExecuteCommand("curl", "-L", "-o", filePath, url); !success { 118 | return fmt.Errorf("curl download failed") 119 | } 120 | 121 | LogInfo(context, "File downloaded using curl") 122 | return nil 123 | } 124 | 125 | return fmt.Errorf("wget & curl unavailable") 126 | } 127 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | inputs: 9 | version: 10 | description: 'Version to release (e.g., 1.0.0)' 11 | required: true 12 | default: '1.0.0' 13 | 14 | env: 15 | GO_VERSION: '1.21' 16 | 17 | permissions: 18 | contents: write 19 | 20 | jobs: 21 | build: 22 | name: Build Release Binaries 23 | runs-on: ubuntu-latest 24 | strategy: 25 | matrix: 26 | include: 27 | - goos: linux 28 | goarch: amd64 29 | - goos: linux 30 | goarch: arm64 31 | - goos: linux 32 | goarch: 386 33 | - goos: darwin 34 | goarch: amd64 35 | - goos: darwin 36 | goarch: arm64 37 | 38 | steps: 39 | - name: Checkout code 40 | uses: actions/checkout@v4 41 | 42 | - name: Set up Go 43 | uses: actions/setup-go@v5 44 | with: 45 | go-version: ${{ env.GO_VERSION }} 46 | 47 | - name: Get version 48 | id: get_version 49 | run: | 50 | if [[ $GITHUB_REF == refs/tags/* ]]; then 51 | VERSION=${GITHUB_REF#refs/tags/v} 52 | elif [[ "${{ github.event.inputs.version }}" != "" ]]; then 53 | VERSION="${{ github.event.inputs.version }}" 54 | else 55 | VERSION="1.0.0" 56 | fi 57 | echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT 58 | echo "Building version: ${VERSION}" 59 | 60 | - name: Build binary 61 | env: 62 | GOOS: ${{ matrix.goos }} 63 | GOARCH: ${{ matrix.goarch }} 64 | VERSION: ${{ steps.get_version.outputs.VERSION }} 65 | run: | 66 | BINARY_NAME="yerd" 67 | 68 | echo "Building for $GOOS/$GOARCH..." 69 | go build -o "$BINARY_NAME" \ 70 | -ldflags="-s -w -X github.com/LumoSolutions/yerd/internal/version.Version=${VERSION}" \ 71 | . 72 | 73 | - name: Create archive 74 | env: 75 | GOOS: ${{ matrix.goos }} 76 | GOARCH: ${{ matrix.goarch }} 77 | VERSION: ${{ steps.get_version.outputs.VERSION }} 78 | run: | 79 | BINARY_NAME="yerd" 80 | ARCHIVE_NAME="yerd_${{ steps.get_version.outputs.VERSION }}_${{ matrix.goos }}_${{ matrix.goarch }}" 81 | 82 | tar -czf "${ARCHIVE_NAME}.tar.gz" "$BINARY_NAME" 83 | echo "ASSET_NAME=${ARCHIVE_NAME}.tar.gz" >> $GITHUB_ENV 84 | 85 | - name: Upload artifact 86 | uses: actions/upload-artifact@v4 87 | with: 88 | name: ${{ env.ASSET_NAME }} 89 | path: ${{ env.ASSET_NAME }} 90 | 91 | release: 92 | name: Create Release 93 | needs: build 94 | runs-on: ubuntu-latest 95 | steps: 96 | - name: Checkout code 97 | uses: actions/checkout@v4 98 | 99 | - name: Get version 100 | id: get_version 101 | run: | 102 | if [[ $GITHUB_REF == refs/tags/* ]]; then 103 | VERSION=${GITHUB_REF#refs/tags/v} 104 | TAG_NAME=${GITHUB_REF#refs/tags/} 105 | elif [[ "${{ github.event.inputs.version }}" != "" ]]; then 106 | VERSION="${{ github.event.inputs.version }}" 107 | TAG_NAME="v${VERSION}" 108 | else 109 | VERSION="1.0.0" 110 | TAG_NAME="v1.0.0" 111 | fi 112 | echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT 113 | echo "TAG_NAME=${TAG_NAME}" >> $GITHUB_OUTPUT 114 | 115 | - name: Download all artifacts 116 | uses: actions/download-artifact@v4 117 | 118 | - name: Generate checksums 119 | run: | 120 | find . -name "*.tar.gz" | sort | while read file; do 121 | if [ -f "$file" ]; then 122 | sha256sum "$file" >> checksums.txt 123 | fi 124 | done 125 | 126 | - name: Create Release 127 | id: create_release 128 | uses: softprops/action-gh-release@v1 129 | env: 130 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 131 | with: 132 | tag_name: ${{ steps.get_version.outputs.TAG_NAME }} 133 | name: v${{ steps.get_version.outputs.VERSION }} 134 | draft: false 135 | prerelease: false 136 | files: | 137 | ./**/*.tar.gz 138 | checksums.txt -------------------------------------------------------------------------------- /scripts/build-releases.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | 5 | 6 | set -e 7 | 8 | 9 | VERSION=${VERSION:-"1.0.0"} 10 | OUTPUT_DIR="dist" 11 | BINARY_NAME="yerd" 12 | 13 | 14 | RED='\033[0;31m' 15 | GREEN='\033[0;32m' 16 | YELLOW='\033[1;33m' 17 | BLUE='\033[0;34m' 18 | NC='\033[0m' 19 | 20 | print_status() { 21 | echo -e "${BLUE}[INFO]${NC} $1" 22 | } 23 | 24 | print_success() { 25 | echo -e "${GREEN}[SUCCESS]${NC} $1" 26 | } 27 | 28 | print_error() { 29 | echo -e "${RED}[ERROR]${NC} $1" 30 | } 31 | 32 | 33 | declare -a platforms=( 34 | "linux/amd64" 35 | "linux/arm64" 36 | "linux/386" 37 | "darwin/amd64" 38 | "darwin/arm64" 39 | ) 40 | 41 | 42 | prepare_output() { 43 | print_status "Preparing output directory..." 44 | rm -rf "$OUTPUT_DIR" 45 | mkdir -p "$OUTPUT_DIR" 46 | } 47 | 48 | 49 | build_platform() { 50 | local platform=$1 51 | local os=${platform%/*} 52 | local arch=${platform#*/} 53 | local output_name="$BINARY_NAME" 54 | 55 | local output_path="${OUTPUT_DIR}/${output_name}" 56 | local archive_name="${BINARY_NAME}_${VERSION}_${os}_${arch}" 57 | 58 | print_status "Building for ${os}/${arch}..." 59 | 60 | 61 | GOOS=$os GOARCH=$arch go build -o "$output_path" \ 62 | -ldflags="-s -w -X github.com/LumoSolutions/yerd/internal/version.Version=${VERSION}" \ 63 | . 64 | 65 | if [ ! -f "$output_path" ]; then 66 | print_error "Build failed for ${os}/${arch}" 67 | return 1 68 | fi 69 | 70 | 71 | print_status "Creating archive for ${os}/${arch}..." 72 | 73 | (cd "$OUTPUT_DIR" && tar -czf "${archive_name}.tar.gz" "$output_name") 74 | 75 | 76 | rm "$output_path" 77 | 78 | print_success "Built ${archive_name}" 79 | } 80 | 81 | 82 | generate_checksums() { 83 | print_status "Generating checksums..." 84 | 85 | cd "$OUTPUT_DIR" 86 | 87 | 88 | if command -v sha256sum >/dev/null 2>&1; then 89 | sha256sum * > checksums.txt 90 | elif command -v shasum >/dev/null 2>&1; then 91 | shasum -a 256 * > checksums.txt 92 | else 93 | print_error "No checksum utility found" 94 | return 1 95 | fi 96 | 97 | cd .. 98 | print_success "Checksums generated" 99 | } 100 | 101 | 102 | main() { 103 | 104 | while [[ $# -gt 0 ]]; do 105 | case $1 in 106 | --version) 107 | VERSION="$2" 108 | shift 2 109 | ;; 110 | --help|-h) 111 | echo "Usage: $0 [options]" 112 | echo "" 113 | echo "Options:" 114 | echo " --version VERSION Build specific version (default: 1.0.0)" 115 | echo " --help, -h Show this help message" 116 | echo "" 117 | echo "Environment variables:" 118 | echo " VERSION Version to build (default: 1.0.0)" 119 | exit 0 120 | ;; 121 | *) 122 | print_error "Unknown option: $1" 123 | exit 1 124 | ;; 125 | esac 126 | done 127 | 128 | echo "╔══════════════════════════════════════════════════════════════════════════════╗" 129 | echo "║ YERD Release Builder ║" 130 | echo "║ Building v${VERSION} ║" 131 | echo "╚══════════════════════════════════════════════════════════════════════════════╝" 132 | echo 133 | 134 | prepare_output 135 | 136 | 137 | local failed_builds=0 138 | for platform in "${platforms[@]}"; do 139 | if ! build_platform "$platform"; then 140 | ((failed_builds++)) 141 | fi 142 | done 143 | 144 | if [ $failed_builds -gt 0 ]; then 145 | print_error "$failed_builds builds failed" 146 | exit 1 147 | fi 148 | 149 | generate_checksums 150 | 151 | print_success "All builds completed successfully!" 152 | print_status "Artifacts created in: $OUTPUT_DIR" 153 | 154 | echo 155 | echo "Built packages:" 156 | ls -la "$OUTPUT_DIR" 157 | } 158 | 159 | 160 | if ! command -v go >/dev/null 2>&1; then 161 | print_error "Go is not installed or not in PATH" 162 | exit 1 163 | fi 164 | 165 | main "$@" -------------------------------------------------------------------------------- /internal/manager/certificate.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | 7 | "github.com/lumosolutions/yerd/internal/constants" 8 | "github.com/lumosolutions/yerd/internal/utils" 9 | ) 10 | 11 | type CertificateManager struct { 12 | } 13 | 14 | func NewCertificateManager() *CertificateManager { 15 | return &CertificateManager{} 16 | } 17 | 18 | func (certManager *CertificateManager) GenerateCaCertificate(name string) error { 19 | caPath := filepath.Join(constants.CertsDir, "ca") 20 | keyName := name + ".key" 21 | certName := name + ".crt" 22 | 23 | utils.CreateDirectory(caPath) 24 | 25 | if !certManager.generateKey(caPath, keyName, "4096") { 26 | return fmt.Errorf("unable to generate ca key") 27 | } 28 | 29 | params := []string{"req", "-new", "-x509", "-days", "3650", "-key", keyName, "-out", certName, "-subj", "/C=US/CN=YERD/O=YERD/OU=YERD"} 30 | output, success := utils.ExecuteCommandInDir(caPath, "openssl", params...) 31 | if !success { 32 | utils.LogInfo("cacert", "openssl command failed") 33 | utils.LogInfo("cacert", "output: %s", output) 34 | return fmt.Errorf("failed to generate ca cert") 35 | } 36 | 37 | depMan, _ := NewDependencyManager() 38 | depMan.TrustCertificate(filepath.Join(caPath, certName), name) 39 | 40 | certManager.ChromeTrust(caPath, certName) 41 | 42 | return nil 43 | } 44 | 45 | func (certManager *CertificateManager) ChromeTrust(caPath, certName string) error { 46 | userCtx, _ := utils.GetRealUser() 47 | 48 | params := []string{"-A", "-n", "YERD CA", "-t", "TCu,Cu,Tu", "-i", filepath.Join(caPath, certName), "-d", fmt.Sprintf("sql:%s/.pki/nssdb", userCtx.HomeDir)} 49 | 50 | if _, success := utils.ExecuteCommandAsUser("certutil", params...); !success { 51 | utils.LogInfo("cacert", "cert command failed") 52 | return fmt.Errorf("failed to trust certificate") 53 | } 54 | 55 | return nil 56 | } 57 | 58 | func (certManager *CertificateManager) GenerateCert(domain, caName string) (string, string, error) { 59 | certPath := filepath.Join(constants.CertsDir, "sites") 60 | keyName := domain + ".key" 61 | csrName := domain + ".csr" 62 | certName := domain + ".crt" 63 | 64 | caCert := filepath.Join(constants.CertsDir, "ca", caName+".crt") 65 | caKey := filepath.Join(constants.CertsDir, "ca", caName+".key") 66 | 67 | utils.CreateDirectory(certPath) 68 | 69 | if !certManager.generateKey(certPath, keyName, "2048") { 70 | return "", "", fmt.Errorf("unable to generate site key") 71 | } 72 | 73 | if !certManager.generateCsr(certPath, keyName, csrName, "/C=GB/CN="+domain) { 74 | return "", "", fmt.Errorf("unable to generate site csr") 75 | } 76 | 77 | if !certManager.generateSiteCertificate(certPath, domain, csrName, certName, caCert, caKey) { 78 | return "", "", fmt.Errorf("unable to generate site cert") 79 | } 80 | 81 | return filepath.Join(certPath, keyName), filepath.Join(certPath, certName), nil 82 | } 83 | 84 | func (certManager *CertificateManager) generateKey(folder, keyFile, keySize string) bool { 85 | output, success := utils.ExecuteCommandInDir(folder, "openssl", "genrsa", "-out", keyFile, keySize) 86 | if !success { 87 | utils.LogInfo("generateKey", "openssl command failed") 88 | utils.LogInfo("generateKey", "output: %s", output) 89 | return false 90 | } 91 | 92 | return true 93 | } 94 | 95 | func (certManager *CertificateManager) generateCsr(folder, keyFile, csrFile, subject string) bool { 96 | params := []string{"req", "-new", "-key", keyFile, "-out", csrFile, "-subj", subject} 97 | output, success := utils.ExecuteCommandInDir(folder, "openssl", params...) 98 | if !success { 99 | utils.LogInfo("createcerts", "openssl command failed") 100 | utils.LogInfo("createcerts", "output: %s", output) 101 | return false 102 | } 103 | 104 | return true 105 | } 106 | 107 | func (certManager *CertificateManager) generateSiteCertificate(certPath, domain, csrFileName, certFileName, caCertPath, caKeyPath string) bool { 108 | content, err := utils.FetchFromGitHub("ssl", "ext.conf") 109 | if err != nil { 110 | utils.LogError(err, "createcerts") 111 | utils.LogInfo("createcerts", "failed to ext.conf") 112 | return false 113 | } 114 | 115 | content = utils.Template(content, utils.TemplateData{ 116 | "domain": domain, 117 | }) 118 | 119 | extFile := domain + ".ext" 120 | extPath := filepath.Join(certPath, extFile) 121 | utils.WriteStringToFile(extPath, content, constants.FilePermissions) 122 | 123 | params := []string{"x509", "-req", "-in", csrFileName, "-CA", caCertPath, "-CAkey", caKeyPath, "-CAcreateserial", "-out", certFileName, "-days", "3650", "-extfile", extFile} 124 | output, success := utils.ExecuteCommandInDir(certPath, "openssl", params...) 125 | if !success { 126 | utils.LogInfo("createcerts", "openssl command failed") 127 | utils.LogInfo("createcerts", "output: %s", output) 128 | return false 129 | } 130 | 131 | utils.RemoveFile(extPath) 132 | utils.RemoveFile(filepath.Join(constants.CertsDir, "sites", csrFileName)) 133 | 134 | return true 135 | } 136 | 137 | func (certManager *CertificateManager) ChromeUntrust() { 138 | userCtx, _ := utils.GetRealUser() 139 | 140 | params := []string{"-D", "-n", "YERD CA", "-d", fmt.Sprintf("sql:%s/.pki/nssdb", userCtx.HomeDir)} 141 | utils.ExecuteCommandAsUser("certutil", params...) 142 | } 143 | -------------------------------------------------------------------------------- /internal/utils/spinner.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "sync" 8 | "time" 9 | 10 | "github.com/fatih/color" 11 | ) 12 | 13 | // Spinner represents an animated CLI spinner with status messages 14 | type Spinner struct { 15 | mu sync.Mutex 16 | writer io.Writer 17 | active bool 18 | phrase string 19 | frames []string 20 | frameIndex int 21 | stopChan chan struct{} 22 | doneChan chan struct{} 23 | hideCursor bool 24 | delay time.Duration 25 | } 26 | 27 | // New creates a new spinner instance 28 | func NewSpinner(phrase string) *Spinner { 29 | return &Spinner{ 30 | writer: os.Stdout, 31 | phrase: phrase, 32 | frames: []string{"-", "\\", "|", "/"}, 33 | stopChan: make(chan struct{}), 34 | doneChan: make(chan struct{}), 35 | hideCursor: true, 36 | delay: 100 * time.Millisecond, 37 | } 38 | } 39 | 40 | // SetWriter sets the output writer (useful for testing) 41 | func (s *Spinner) SetWriter(w io.Writer) { 42 | s.mu.Lock() 43 | defer s.mu.Unlock() 44 | s.writer = w 45 | } 46 | 47 | // SetDelay sets the animation delay between frames 48 | func (s *Spinner) SetDelay(delay time.Duration) { 49 | s.mu.Lock() 50 | defer s.mu.Unlock() 51 | s.delay = delay * time.Millisecond 52 | } 53 | 54 | // SetFrames sets custom spinner frames 55 | func (s *Spinner) SetFrames(frames []string) { 56 | s.mu.Lock() 57 | defer s.mu.Unlock() 58 | s.frames = frames 59 | } 60 | 61 | // Start begins the spinner animation 62 | func (s *Spinner) Start() { 63 | s.mu.Lock() 64 | if s.active { 65 | s.mu.Unlock() 66 | return 67 | } 68 | s.active = true 69 | s.mu.Unlock() 70 | 71 | if s.hideCursor { 72 | fmt.Fprint(s.writer, "\033[?25l") // Hide cursor 73 | } 74 | 75 | // Print initial spinner 76 | s.mu.Lock() 77 | fmt.Fprintf(s.writer, "[%s] %s", s.frames[s.frameIndex], s.phrase) 78 | s.mu.Unlock() 79 | 80 | go s.animate() 81 | } 82 | 83 | // animate handles the spinner animation loop 84 | func (s *Spinner) animate() { 85 | ticker := time.NewTicker(s.delay) 86 | defer ticker.Stop() 87 | defer close(s.doneChan) 88 | 89 | for { 90 | select { 91 | case <-s.stopChan: 92 | return 93 | case <-ticker.C: 94 | s.updateSpinner() 95 | } 96 | } 97 | } 98 | 99 | // updateSpinner updates only the spinner line 100 | func (s *Spinner) updateSpinner() { 101 | s.mu.Lock() 102 | defer s.mu.Unlock() 103 | 104 | s.frameIndex = (s.frameIndex + 1) % len(s.frames) 105 | 106 | // Clear current line and redraw spinner 107 | fmt.Fprint(s.writer, "\r\033[2K") // Move to start of line and clear it 108 | fmt.Fprintf(s.writer, "[%s] %s", s.frames[s.frameIndex], s.phrase) 109 | } 110 | 111 | // UpdatePhrase updates the spinner phrase 112 | func (s *Spinner) UpdatePhrase(phrase string) { 113 | s.mu.Lock() 114 | defer s.mu.Unlock() 115 | 116 | s.phrase = phrase 117 | 118 | if s.active { 119 | // Clear current line and redraw with new phrase 120 | fmt.Fprint(s.writer, "\r\033[2K") 121 | fmt.Fprintf(s.writer, "[%s] %s", s.frames[s.frameIndex], s.phrase) 122 | } 123 | } 124 | 125 | // AddSuccessStatus adds a status message above the spinner 126 | func (s *Spinner) AddSuccessStatus(status string, args ...any) { 127 | message := fmt.Sprintf("✓ "+status, args...) 128 | s.addStatus(message, color.New(color.FgGreen)) 129 | } 130 | 131 | // AddErrorStatus adds a status message above the spinner 132 | func (s *Spinner) AddErrorStatus(status string, args ...any) { 133 | message := fmt.Sprintf("✗ "+status, args...) 134 | s.addStatus(message, color.New(color.FgRed)) 135 | } 136 | 137 | // AddInfoStatus add a status message above the spinner 138 | func (s *Spinner) AddInfoStatus(status string, args ...any) { 139 | message := fmt.Sprintf("- "+status, args...) 140 | s.addStatus(message, color.New(color.FgBlue)) 141 | } 142 | 143 | // AddWarningStatus adds a status message above the spinner 144 | func (s *Spinner) AddWarningStatus(status string, args ...any) { 145 | message := fmt.Sprintf("- "+status, args...) 146 | s.addStatus(message, color.New(color.FgYellow)) 147 | } 148 | 149 | func (s *Spinner) addStatus(status string, outputColor *color.Color) { 150 | s.mu.Lock() 151 | defer s.mu.Unlock() 152 | 153 | if !s.active { 154 | // If spinner isn't active, just print the status 155 | outputColor.Fprintf(s.writer, "%s\n", status) 156 | return 157 | } 158 | 159 | // Clear the current spinner line 160 | fmt.Fprint(s.writer, "\r\033[2K") 161 | 162 | // Print the status message (this becomes a permanent line) 163 | outputColor.Fprintf(s.writer, "%s\n", status) 164 | 165 | // Redraw the spinner on the new line 166 | fmt.Fprintf(s.writer, "[%s] %s", s.frames[s.frameIndex], s.phrase) 167 | } 168 | 169 | // Stop stops the spinner with a final message 170 | func (s *Spinner) Stop(finalMessage string, outputColor *color.Color) { 171 | s.mu.Lock() 172 | if !s.active { 173 | s.mu.Unlock() 174 | return 175 | } 176 | s.active = false 177 | s.mu.Unlock() 178 | 179 | close(s.stopChan) 180 | <-s.doneChan 181 | 182 | s.mu.Lock() 183 | // Clear the spinner line 184 | fmt.Fprint(s.writer, "\r\033[2K") 185 | 186 | // Print the final message if provided 187 | if finalMessage != "" { 188 | outputColor.Fprintf(s.writer, "%s\n", finalMessage) 189 | } 190 | s.mu.Unlock() 191 | 192 | if s.hideCursor { 193 | fmt.Fprint(s.writer, "\033[?25h") // Show cursor 194 | } 195 | } 196 | 197 | // StopWithSuccess stops the spinner with a success message (convenience method) 198 | func (s *Spinner) StopWithSuccess(message string, args ...any) { 199 | message = fmt.Sprintf(message, args...) 200 | s.Stop(fmt.Sprintf("🎉 %s", message), color.New(color.FgGreen)) 201 | } 202 | 203 | // StopWithError stops the spinner with an error message (convenience method) 204 | func (s *Spinner) StopWithError(message string, args ...any) { 205 | message = fmt.Sprintf(message, args...) 206 | s.Stop(fmt.Sprintf("✗ %s", message), color.New(color.FgRed)) 207 | } 208 | -------------------------------------------------------------------------------- /internal/installers/php/extensions.go: -------------------------------------------------------------------------------- 1 | package php 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | 7 | "github.com/lumosolutions/yerd/internal/config" 8 | "github.com/lumosolutions/yerd/internal/constants" 9 | "github.com/lumosolutions/yerd/internal/manager" 10 | "github.com/lumosolutions/yerd/internal/utils" 11 | ) 12 | 13 | type ExtensionManager struct { 14 | Version string 15 | Info *config.PhpInfo 16 | Cached bool 17 | Config bool 18 | Rebuild bool 19 | ToAdd []string 20 | ToRemove []string 21 | } 22 | 23 | func NewExtensionManager(version string, data *config.PhpInfo, cached, config, rebuild bool) *ExtensionManager { 24 | return &ExtensionManager{ 25 | Version: version, 26 | Info: data, 27 | Cached: cached, 28 | Config: config, 29 | Rebuild: rebuild, 30 | } 31 | } 32 | 33 | func (ext *ExtensionManager) RunAction(action string, extensions []string) error { 34 | switch action { 35 | case "list": 36 | ext.listExtensions() 37 | return nil 38 | 39 | case "add": 40 | if err := ext.addExtensions(extensions); err != nil { 41 | return err 42 | } 43 | 44 | case "remove": 45 | if err := ext.removeExtensions(extensions); err != nil { 46 | return err 47 | } 48 | 49 | default: 50 | fmt.Printf("Error: Invalid action '%s'. Use 'add' or 'remove'\n", action) 51 | return fmt.Errorf("invalid action") 52 | } 53 | 54 | ext.saveConfig() 55 | if err := ext.handleRebuild(action, extensions); err != nil { 56 | fmt.Printf("Failed to rebuild PHP %s, please try again via command: \n", ext.Version) 57 | fmt.Printf(" sudo yerd php %s rebuild\n\n", ext.Version) 58 | return err 59 | } 60 | 61 | return nil 62 | } 63 | 64 | func (ext *ExtensionManager) listExtensions() error { 65 | fmt.Printf("PHP %s Extensions:\n\n", ext.Version) 66 | 67 | fmt.Println("✓ INSTALLED:") 68 | utils.PrintExtensionsGrid(ext.Info.Extensions) 69 | 70 | if len(ext.Info.AddExtensions) > 0 { 71 | fmt.Println("\n✓ TO BE ADDED:") 72 | utils.PrintExtensionsGrid(ext.Info.AddExtensions) 73 | } 74 | 75 | if len(ext.Info.RemoveExtensions) > 0 { 76 | fmt.Println("\n✗ TO BE REMOVED:") 77 | utils.PrintExtensionsGrid(ext.Info.RemoveExtensions) 78 | } 79 | 80 | fmt.Println("\nAVAILABLE:") 81 | all := constants.GetAvailableExtensions() 82 | all = utils.RemoveItems(all, ext.Info.Extensions...) 83 | all = utils.RemoveItems(all, ext.Info.AddExtensions...) 84 | utils.PrintExtensionsGrid(all) 85 | 86 | fmt.Println() 87 | fmt.Println("USAGE:") 88 | fmt.Printf(" yerd php %s extensions add # Add Extensions\n", ext.Version) 89 | fmt.Printf(" yerd php %s extensions remove # Remove Extensions\n", ext.Version) 90 | fmt.Printf(" yerd php %s extensions add -r # Add Extensions & Rebuild PHP\n", ext.Version) 91 | 92 | return nil 93 | } 94 | 95 | func (ext *ExtensionManager) addExtensions(extensions []string) error { 96 | valid, invalid := constants.ValidateExtensions(extensions) 97 | if len(invalid) > 0 { 98 | utils.PrintInvalidExtensionsWithSuggestions(invalid) 99 | return fmt.Errorf("invalid extensions") 100 | } 101 | 102 | toAdd := []string{} 103 | 104 | for _, item := range valid { 105 | if slices.Contains(ext.Info.Extensions, item) && !slices.Contains(ext.Info.RemoveExtensions, item) { 106 | fmt.Printf("ℹ️ Extension %s is already installed\n", item) 107 | } else { 108 | toAdd = append(toAdd, item) 109 | } 110 | } 111 | 112 | if len(toAdd) > 0 { 113 | ext.displayInstallationNotes(toAdd) 114 | } 115 | 116 | ext.Info.AddExtensions = utils.AddUnique(ext.Info.AddExtensions, toAdd...) 117 | ext.Info.RemoveExtensions = utils.RemoveItems(ext.Info.RemoveExtensions, toAdd...) 118 | ext.Info.AddExtensions = utils.RemoveItems(ext.Info.AddExtensions, ext.Info.Extensions...) 119 | 120 | return nil 121 | } 122 | 123 | func (ext *ExtensionManager) removeExtensions(extensions []string) error { 124 | valid, invalid := constants.ValidateExtensions(extensions) 125 | if len(invalid) > 0 { 126 | utils.PrintInvalidExtensionsWithSuggestions(invalid) 127 | return fmt.Errorf("invalid extensions") 128 | } 129 | 130 | toRemove := []string{} 131 | 132 | for _, item := range valid { 133 | if !slices.Contains(ext.Info.Extensions, item) && !slices.Contains(ext.Info.AddExtensions, item) { 134 | fmt.Printf("ℹ️ Extension %s is not installed\n", item) 135 | } else { 136 | toRemove = append(toRemove, item) 137 | } 138 | } 139 | 140 | ext.Info.RemoveExtensions = utils.AddUnique(ext.Info.RemoveExtensions, toRemove...) 141 | ext.Info.AddExtensions = utils.RemoveItems(ext.Info.AddExtensions, toRemove...) 142 | 143 | config.SetStruct(fmt.Sprintf("php.[%s]", ext.Info.Version), ext.Info) 144 | 145 | return nil 146 | } 147 | 148 | func (ext *ExtensionManager) saveConfig() { 149 | config.SetStruct(fmt.Sprintf("php.[%s]", ext.Info.Version), ext.Info) 150 | } 151 | 152 | func (ext *ExtensionManager) handleRebuild(action string, extensions []string) error { 153 | if ext.Rebuild { 154 | if len(ext.Info.AddExtensions) == 0 && len(ext.Info.RemoveExtensions) == 0 { 155 | fmt.Println("ℹ️ Nothing to add or remove, skipping rebuild") 156 | return nil 157 | } 158 | 159 | if len(ext.Info.AddExtensions) > 0 { 160 | fmt.Println("\n✓ TO BE ADDED:") 161 | utils.PrintExtensionsGrid(ext.Info.AddExtensions) 162 | } 163 | 164 | if len(ext.Info.RemoveExtensions) > 0 { 165 | fmt.Println("\n✗ TO BE REMOVED:") 166 | utils.PrintExtensionsGrid(ext.Info.RemoveExtensions) 167 | } 168 | 169 | if err := RunRebuild(ext.Info, ext.Cached, ext.Config); err != nil { 170 | fmt.Printf("Failed to rebuild PHP %s, %v\n", ext.Version, err) 171 | return err 172 | } 173 | } else { 174 | if action == "add" { 175 | fmt.Printf("These extensions will be added to PHP %s on the next rebuild\n", ext.Version) 176 | } else { 177 | fmt.Printf("These extensions will be removed from PHP %s on the next rebuild\n", ext.Version) 178 | } 179 | 180 | utils.PrintExtensionsGrid(extensions) 181 | fmt.Println() 182 | 183 | fmt.Println("ℹ️ These changes won't apply until PHP is rebuilt") 184 | fmt.Println("ℹ️ PHP can be rebuilt with the following command:") 185 | fmt.Printf("\n sudo yerd php %s rebuild\n\n", ext.Version) 186 | } 187 | 188 | return nil 189 | } 190 | 191 | func (ext *ExtensionManager) displayInstallationNotes(extensions []string) { 192 | dm, _ := manager.NewDependencyManager() 193 | pm := dm.GetPackageManager() 194 | 195 | hasSpecialNotes := false 196 | 197 | for _, extName := range extensions { 198 | if phpExt, exists := constants.GetExtension(extName); exists { 199 | for _, dep := range phpExt.Dependencies { 200 | if depConfig, exists := constants.GetDependencyConfig(dep); exists { 201 | if depConfig.RequiresSpecialHandling != nil && depConfig.RequiresSpecialHandling[pm] { 202 | if !hasSpecialNotes { 203 | fmt.Println("\n⚠️ SPECIAL INSTALLATION REQUIREMENTS:") 204 | fmt.Println("─────────────────────────────────────") 205 | hasSpecialNotes = true 206 | } 207 | 208 | fmt.Printf("\n📦 Extension '%s' (dependency: %s):\n", extName, dep) 209 | if note, exists := depConfig.InstallNotes[pm]; exists { 210 | fmt.Printf(" %s\n", note) 211 | } 212 | } 213 | } 214 | } 215 | } 216 | } 217 | 218 | if hasSpecialNotes { 219 | fmt.Println("\n─────────────────────────────────────") 220 | fmt.Println("ℹ️ Please install these dependencies before rebuilding PHP") 221 | fmt.Println() 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /internal/utils/logger.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "github.com/lumosolutions/yerd/internal/constants" 12 | ) 13 | 14 | type Logger struct { 15 | logFile *os.File 16 | logPath string 17 | mu sync.Mutex // Mutex for thread-safe operations 18 | } 19 | 20 | type logWriter struct { 21 | logger *Logger 22 | prefix string 23 | } 24 | 25 | var ( 26 | instance *Logger 27 | once sync.Once 28 | ) 29 | 30 | // GetLogger returns the singleton Logger instance, creating it if necessary. 31 | // Uses sync.Once to ensure thread-safe initialization. 32 | func GetLogger() (*Logger, error) { 33 | var err error 34 | once.Do(func() { 35 | instance, err = initLogger() 36 | }) 37 | 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | return instance, nil 43 | } 44 | 45 | // initLogger creates the singleton logger instance with proper file setup. 46 | // This is called only once through sync.Once. 47 | func initLogger() (*Logger, error) { 48 | configDir, err := GetUserConfigDir() 49 | if err != nil { 50 | return nil, fmt.Errorf("failed to get user config directory: %v", err) 51 | } 52 | 53 | if err := os.MkdirAll(configDir, constants.DirPermissions); err != nil { 54 | return nil, fmt.Errorf("failed to create config directory: %v", err) 55 | } 56 | 57 | userCtx, err := GetRealUser() 58 | if err != nil { 59 | return nil, fmt.Errorf("failed to get user context: %v", err) 60 | } 61 | 62 | if os.Geteuid() == 0 { 63 | if err := os.Chown(configDir, userCtx.UID, userCtx.GID); err != nil { 64 | return nil, fmt.Errorf("failed to set config directory ownership: %v", err) 65 | } 66 | } 67 | 68 | // Create log filename with format: yerd_YYYYMMDD_timestamp.log 69 | now := time.Now() 70 | date := now.Format("20060102") 71 | timestamp := now.Format("150405") 72 | nanos := now.Nanosecond() 73 | logFileName := fmt.Sprintf("yerd_%s_%s%d.log", date, timestamp, nanos/1000) 74 | logPath := filepath.Join(configDir, logFileName) 75 | 76 | logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, constants.FilePermissions) 77 | if err != nil { 78 | return nil, fmt.Errorf("failed to create log file: %v", err) 79 | } 80 | 81 | if os.Geteuid() == 0 { 82 | if err := os.Chown(logPath, userCtx.UID, userCtx.GID); err != nil { 83 | logFile.Close() 84 | return nil, fmt.Errorf("failed to set log file ownership: %v", err) 85 | } 86 | } 87 | 88 | logger := &Logger{ 89 | logFile: logFile, 90 | logPath: logPath, 91 | } 92 | 93 | logger.WriteLog("=== YERD Logger Initialized ===") 94 | logger.WriteLog("Timestamp: %s", time.Now().Format("2006-01-02 15:04:05")) 95 | logger.WriteLog("Log file: %s", logPath) 96 | logger.WriteLog("Running as: UID=%d (effective), User: %s, Home: %s", os.Geteuid(), userCtx.Username, userCtx.HomeDir) 97 | if os.Getenv("SUDO_USER") != "" { 98 | logger.WriteLog("Original user (SUDO_USER): %s", os.Getenv("SUDO_USER")) 99 | } 100 | logger.WriteLog("") 101 | 102 | return logger, nil 103 | } 104 | 105 | // ResetLogger forces creation of a new logger instance (useful for testing or log rotation). 106 | // This should be used sparingly as it breaks the singleton pattern temporarily. 107 | func ResetLogger() { 108 | if instance != nil { 109 | instance.Close() 110 | } 111 | instance = nil 112 | once = sync.Once{} 113 | } 114 | 115 | // WriteLog writes a timestamped message to the log file with formatting. 116 | // Thread-safe through mutex locking. 117 | func (l *Logger) WriteLog(format string, args ...interface{}) { 118 | if l == nil || l.logFile == nil { 119 | return 120 | } 121 | 122 | l.mu.Lock() 123 | defer l.mu.Unlock() 124 | 125 | timestamp := time.Now().Format(constants.LogTimeFormat) 126 | message := fmt.Sprintf("[%s] %s\n", timestamp, fmt.Sprintf(format, args...)) 127 | l.logFile.WriteString(message) 128 | l.logFile.Sync() 129 | } 130 | 131 | // WriteLogRaw writes a message to the log file without timestamp formatting. 132 | // Thread-safe through mutex locking. 133 | func (l *Logger) WriteLogRaw(message string) { 134 | if l == nil || l.logFile == nil { 135 | return 136 | } 137 | 138 | l.mu.Lock() 139 | defer l.mu.Unlock() 140 | 141 | l.logFile.WriteString(message) 142 | l.logFile.Sync() 143 | } 144 | 145 | // Close finalizes and closes the log file, returning the log file path. 146 | // Thread-safe through mutex locking. 147 | func (l *Logger) Close() string { 148 | if l == nil { 149 | return "" 150 | } 151 | 152 | l.mu.Lock() 153 | defer l.mu.Unlock() 154 | 155 | logPath := l.logPath 156 | if l.logFile != nil { 157 | l.WriteLogRaw(fmt.Sprintf("[%s] === Logger Closed ===\n", 158 | time.Now().Format(constants.LogTimeFormat))) 159 | l.logFile.Close() 160 | l.logFile = nil 161 | } 162 | return logPath 163 | } 164 | 165 | // DeleteLogFile closes and removes the log file from disk. 166 | // Used for cleanup when logs are no longer needed. 167 | func (l *Logger) DeleteLogFile() { 168 | logPath := l.Close() 169 | if logPath != "" { 170 | os.Remove(logPath) 171 | } 172 | } 173 | 174 | // GetLogPath returns the file system path to the current log file. 175 | func (l *Logger) GetLogPath() string { 176 | if l == nil { 177 | return "" 178 | } 179 | 180 | l.mu.Lock() 181 | defer l.mu.Unlock() 182 | 183 | return l.logPath 184 | } 185 | 186 | // CreateLogWriter creates an io.Writer that writes to the logger with a prefix. 187 | // Useful for capturing command output. 188 | func (l *Logger) CreateLogWriter(prefix string) *logWriter { 189 | return &logWriter{ 190 | logger: l, 191 | prefix: prefix, 192 | } 193 | } 194 | 195 | // Write implements io.Writer interface for capturing command output to log. 196 | func (w *logWriter) Write(p []byte) (n int, err error) { 197 | if w.logger != nil && w.logger.logFile != nil { 198 | lines := strings.Split(string(p), "\n") 199 | for _, line := range lines { 200 | if strings.TrimSpace(line) != "" { 201 | w.logger.WriteLogRaw(fmt.Sprintf("[%s] %s: %s\n", 202 | time.Now().Format("15:04:05"), w.prefix, line)) 203 | } 204 | } 205 | } 206 | return len(p), nil 207 | } 208 | 209 | // SafeLog writes to the singleton logger instance if available. 210 | // Attempts to get the logger if not provided, handles errors gracefully. 211 | func SafeLog(format string, args ...interface{}) { 212 | logger, err := GetLogger() 213 | if err != nil || logger == nil { 214 | // Fallback to stderr if logger unavailable 215 | fmt.Fprintf(os.Stderr, "[LOG ERROR] "+format+"\n", args...) 216 | return 217 | } 218 | logger.WriteLog(format, args...) 219 | } 220 | 221 | // LogError is a convenience function for logging errors. 222 | func LogError(err error, context string) { 223 | if err == nil { 224 | return 225 | } 226 | SafeLog("ERROR [%s]: %v", context, err) 227 | } 228 | 229 | // LogInfo is a convenience function for logging informational messages. 230 | func LogInfo(context string, format string, args ...interface{}) { 231 | message := fmt.Sprintf(format, args...) 232 | SafeLog("INFO [%s]: %s", context, message) 233 | } 234 | 235 | // LogDebug is a convenience function for logging debug messages. 236 | func LogDebug(context string, format string, args ...interface{}) { 237 | message := fmt.Sprintf(format, args...) 238 | SafeLog("DEBUG [%s]: %s", context, message) 239 | } 240 | 241 | // LogWarning is a convenience function for logging warning messages. 242 | func LogWarning(context string, format string, args ...interface{}) { 243 | message := fmt.Sprintf(format, args...) 244 | SafeLog("WARNING [%s]: %s", context, message) 245 | } 246 | -------------------------------------------------------------------------------- /internal/installers/php/versions.go: -------------------------------------------------------------------------------- 1 | package php 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | "time" 9 | 10 | "github.com/lumosolutions/yerd/internal/config" 11 | "github.com/lumosolutions/yerd/internal/constants" 12 | ) 13 | 14 | type PHPReleaseResponse struct { 15 | Version string `json:"version"` 16 | Date string `json:"date"` 17 | Tags []string `json:"tags"` 18 | Source []SourceFile `json:"source"` 19 | SupportedVersions []string `json:"supported_versions"` 20 | } 21 | 22 | type SourceFile struct { 23 | Filename string `json:"filename"` 24 | Name string `json:"name"` 25 | SHA256 string `json:"sha256"` 26 | Date string `json:"date"` 27 | } 28 | 29 | type VersionCache struct { 30 | LastUpdated time.Time `json:"last_updated"` 31 | LatestVersions map[string]string `json:"latest_versions"` 32 | DownloadURLs map[string]string `json:"download_urls"` 33 | } 34 | 35 | type PhpVersionInfo struct { 36 | MajorMinor string 37 | Version string 38 | DownloadURL string 39 | SourcePackage string 40 | ConfigureFlags []string 41 | ArchivePath string 42 | ExtractPath string 43 | SourcePath string 44 | } 45 | 46 | const ( 47 | PHPReleasesURL = "https://www.php.net/releases/index.php?json&version=" 48 | CacheValidDuration = 1 * time.Hour 49 | HTTPTimeout = 10 * time.Second 50 | CacheFileExtension = "/version_cache.json" 51 | TempFileExtension = ".tmp" 52 | ) 53 | 54 | func FetchSpecificVersion(version string) (string, string, error) { 55 | return fetchLatestForMajorMinor(version) 56 | } 57 | 58 | // FetchLatestVersions retrieves latest PHP version information from php.net for all supported versions. 59 | // Returns latest versions map, download URLs map, or error if any version fetch fails. 60 | func FetchLatestVersions() (map[string]string, map[string]string, error) { 61 | supportedMajorMinor := constants.GetAvailablePhpVersions() 62 | latestVersions := make(map[string]string) 63 | downloadURLs := make(map[string]string) 64 | 65 | for _, majorMinor := range supportedMajorMinor { 66 | latest, downloadURL, err := fetchLatestForMajorMinor(majorMinor) 67 | if err != nil { 68 | return nil, nil, fmt.Errorf("failed to fetch latest version for %s: %v", majorMinor, err) 69 | } 70 | latestVersions[majorMinor] = latest 71 | downloadURLs[latest] = downloadURL 72 | } 73 | 74 | return latestVersions, downloadURLs, nil 75 | } 76 | 77 | // fetchLatestForMajorMinor gets the latest version and download URL for a specific PHP major.minor version. 78 | // majorMinor: Version string like "8.3". Returns version string, download URL, or error if fetch fails. 79 | func fetchLatestForMajorMinor(majorMinor string) (string, string, error) { 80 | url := PHPReleasesURL + majorMinor 81 | 82 | client := &http.Client{Timeout: HTTPTimeout} 83 | resp, err := client.Get(url) 84 | if err != nil { 85 | return "", "", fmt.Errorf("HTTP request failed: %v", err) 86 | } 87 | defer resp.Body.Close() 88 | 89 | if resp.StatusCode != 200 { 90 | return "", "", fmt.Errorf("HTTP request failed with status %d", resp.StatusCode) 91 | } 92 | 93 | var release PHPReleaseResponse 94 | if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { 95 | return "", "", fmt.Errorf("JSON decode failed: %v", err) 96 | } 97 | 98 | downloadURL := "" 99 | for _, source := range release.Source { 100 | if strings.HasSuffix(source.Filename, ".tar.gz") { 101 | downloadURL = fmt.Sprintf("https://www.php.net/distributions/%s", source.Filename) 102 | break 103 | } 104 | } 105 | 106 | if downloadURL == "" { 107 | return "", "", fmt.Errorf("no tar.gz download found for %s", majorMinor) 108 | } 109 | 110 | return release.Version, downloadURL, nil 111 | } 112 | 113 | // compareVersions compares two semantic version strings numerically. 114 | // v1, v2: Version strings to compare. Returns 1 if v1>v2, -1 if v1 maxLen { 121 | // maxLen = len(parts2) 122 | // } 123 | 124 | // for i := 0; i < maxLen; i++ { 125 | // var n1, n2 int 126 | 127 | // if i < len(parts1) { 128 | // n1, _ = strconv.Atoi(parts1[i]) 129 | // } 130 | // if i < len(parts2) { 131 | // n2, _ = strconv.Atoi(parts2[i]) 132 | // } 133 | 134 | // if n1 > n2 { 135 | // return 1 136 | // } else if n1 < n2 { 137 | // return -1 138 | // } 139 | // } 140 | 141 | // return 0 142 | // } 143 | 144 | // GetCachedVersions retrieves version information from local cache if valid. 145 | // Returns cached version data and validity flag, or nil and false if cache invalid/missing. 146 | func GetCachedVersions() (*VersionCache, bool) { 147 | var cache VersionCache 148 | err := config.GetStruct("versions.php.cache", &cache) 149 | if err != nil { 150 | return nil, false 151 | } 152 | 153 | if time.Since(cache.LastUpdated) > CacheValidDuration { 154 | return nil, false 155 | } 156 | 157 | return &cache, true 158 | } 159 | 160 | // SaveVersionCache writes version and download URL data to local cache file with proper ownership. 161 | // latestVersions: Version mapping, downloadURLs: Download URL mapping. Returns error if save fails. 162 | func SaveVersionCache(latestVersions, downloadURLs map[string]string) error { 163 | cache := VersionCache{ 164 | LastUpdated: time.Now(), 165 | LatestVersions: latestVersions, 166 | DownloadURLs: downloadURLs, 167 | } 168 | 169 | if err := config.SetStruct("versions.php.cache", cache); err != nil { 170 | return err 171 | } 172 | 173 | return nil 174 | } 175 | 176 | // GetLatestVersions retrieves PHP versions from cache or fetches fresh data if cache expired. 177 | // Returns latest versions map, download URLs map, or error if fetch fails. 178 | func GetLatestVersions() (map[string]string, map[string]string, error) { 179 | 180 | if cache, valid := GetCachedVersions(); valid { 181 | return cache.LatestVersions, cache.DownloadURLs, nil 182 | } 183 | 184 | latestVersions, downloadURLs, err := FetchLatestVersions() 185 | if err != nil { 186 | return nil, nil, err 187 | } 188 | 189 | if err := SaveVersionCache(latestVersions, downloadURLs); err != nil { 190 | 191 | fmt.Printf("Warning: failed to save version cache: %v\n", err) 192 | } 193 | 194 | return latestVersions, downloadURLs, nil 195 | } 196 | 197 | // GetLatestVersionsFresh bypasses cache and fetches fresh PHP version data from php.net. 198 | // Returns latest versions map, download URLs map, or error if fetch fails. 199 | func GetLatestVersionsFresh() (map[string]string, map[string]string, error) { 200 | 201 | latestVersions, downloadURLs, err := FetchLatestVersions() 202 | if err != nil { 203 | return nil, nil, err 204 | } 205 | 206 | if err := SaveVersionCache(latestVersions, downloadURLs); err != nil { 207 | 208 | fmt.Printf("Warning: failed to save version cache: %v\n", err) 209 | } 210 | 211 | return latestVersions, downloadURLs, nil 212 | } 213 | 214 | // CheckForUpdates compares installed PHP versions against latest available versions using cache. 215 | // installedVersions: Map of installed version info. Returns update availability map or error. 216 | // func CheckForUpdates(installedVersions map[string]string) (map[string]bool, error) { 217 | // latestVersions, _, err := GetLatestVersions() 218 | // if err != nil { 219 | // return nil, err 220 | // } 221 | 222 | // updates := make(map[string]bool) 223 | 224 | // for majorMinor, installedVersion := range installedVersions { 225 | // if latestVersion, exists := latestVersions[majorMinor]; exists { 226 | 227 | // installedFull := extractVersionFromString(installedVersion) 228 | // updates[majorMinor] = compareVersions(latestVersion, installedFull) > 0 229 | // } 230 | // } 231 | 232 | // return updates, nil 233 | // } 234 | 235 | // CheckForUpdatesFresh compares installed versions against fresh data and returns available updates. 236 | // installedVersions: Map of installed version info. Returns update flags, available updates map, or error. 237 | // func CheckForUpdatesFresh(installedVersions map[string]string) (map[string]bool, map[string]string, error) { 238 | // latestVersions, _, err := GetLatestVersionsFresh() 239 | // if err != nil { 240 | // return nil, nil, err 241 | // } 242 | 243 | // updates := make(map[string]bool) 244 | // availableUpdates := make(map[string]string) 245 | 246 | // for majorMinor, installedVersion := range installedVersions { 247 | // if latestVersion, exists := latestVersions[majorMinor]; exists { 248 | 249 | // installedFull := extractVersionFromString(installedVersion) 250 | // hasUpdate := compareVersions(latestVersion, installedFull) > 0 251 | // updates[majorMinor] = hasUpdate 252 | // if hasUpdate { 253 | // availableUpdates[majorMinor] = latestVersion 254 | // } 255 | // } 256 | // } 257 | 258 | // return updates, availableUpdates, nil 259 | // } 260 | 261 | // extractVersionFromString extracts semantic version number from version string using regex. 262 | // versionStr: Input version string. Returns extracted version or original string if no match. 263 | // func extractVersionFromString(versionStr string) string { 264 | 265 | // versionRegex := regexp.MustCompile(`\d+\.\d+\.\d+`) 266 | // matches := versionRegex.FindString(versionStr) 267 | // if matches != "" { 268 | // return matches 269 | // } 270 | // return versionStr 271 | // } 272 | 273 | // ExtractVersionFromString is a public wrapper for extractVersionFromString. 274 | // versionStr: Input version string. Returns extracted semantic version number. 275 | // func ExtractVersionFromString(versionStr string) string { 276 | // return extractVersionFromString(versionStr) 277 | // } 278 | -------------------------------------------------------------------------------- /internal/constants/php.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | import ( 4 | "maps" 5 | "slices" 6 | "sort" 7 | "strings" 8 | ) 9 | 10 | type Extension struct { 11 | Name string 12 | ConfigFlag string 13 | Dependencies []string 14 | IsPECL bool 15 | PECLName string 16 | } 17 | 18 | var availablePhpVersions = []string{"8.1", "8.2", "8.3", "8.4"} 19 | var availableExtensions = map[string]Extension{ 20 | "mbstring": { 21 | Name: "mbstring", 22 | ConfigFlag: "--enable-mbstring", 23 | IsPECL: false, 24 | }, 25 | "bcmath": { 26 | Name: "bcmath", 27 | ConfigFlag: "--enable-bcmath", 28 | IsPECL: false, 29 | }, 30 | "opcache": { 31 | Name: "opcache", 32 | ConfigFlag: "--enable-opcache", 33 | IsPECL: false, 34 | }, 35 | "curl": { 36 | Name: "curl", 37 | ConfigFlag: "--with-curl", 38 | Dependencies: []string{"libcurl"}, 39 | IsPECL: false, 40 | }, 41 | "openssl": { 42 | Name: "openssl", 43 | ConfigFlag: "--with-openssl", 44 | Dependencies: []string{"openssl"}, 45 | IsPECL: false, 46 | }, 47 | "zip": { 48 | Name: "zip", 49 | ConfigFlag: "--with-zip", 50 | Dependencies: []string{"libzip"}, 51 | IsPECL: false, 52 | }, 53 | "sockets": { 54 | Name: "sockets", 55 | ConfigFlag: "--enable-sockets", 56 | IsPECL: false, 57 | }, 58 | "mysqli": { 59 | Name: "mysqli", 60 | ConfigFlag: "--with-mysqli", 61 | Dependencies: []string{"mysql"}, 62 | IsPECL: false, 63 | }, 64 | "pdo-mysql": { 65 | Name: "pdo-mysql", 66 | ConfigFlag: "--with-pdo-mysql", 67 | Dependencies: []string{"mysql"}, 68 | IsPECL: false, 69 | }, 70 | "gd": { 71 | Name: "gd", 72 | ConfigFlag: "--enable-gd", 73 | Dependencies: []string{"libgd"}, 74 | IsPECL: false, 75 | }, 76 | "jpeg": { 77 | Name: "jpeg", 78 | ConfigFlag: "--with-jpeg", 79 | Dependencies: []string{"libjpeg"}, 80 | IsPECL: false, 81 | }, 82 | "freetype": { 83 | Name: "freetype", 84 | ConfigFlag: "--with-freetype", 85 | Dependencies: []string{"freetype2"}, 86 | IsPECL: false, 87 | }, 88 | "xml": { 89 | Name: "xml", 90 | ConfigFlag: "--enable-xml", 91 | IsPECL: false, 92 | }, 93 | "json": { 94 | Name: "json", 95 | ConfigFlag: "--enable-json", 96 | IsPECL: false, 97 | }, 98 | "session": { 99 | Name: "session", 100 | ConfigFlag: "--enable-session", 101 | IsPECL: false, 102 | }, 103 | "hash": { 104 | Name: "hash", 105 | ConfigFlag: "--enable-hash", 106 | IsPECL: false, 107 | }, 108 | "filter": { 109 | Name: "filter", 110 | ConfigFlag: "--enable-filter", 111 | IsPECL: false, 112 | }, 113 | "pcre": { 114 | Name: "pcre", 115 | ConfigFlag: "--with-pcre-jit", 116 | Dependencies: []string{"pcre2"}, 117 | IsPECL: false, 118 | }, 119 | "zlib": { 120 | Name: "zlib", 121 | ConfigFlag: "--with-zlib", 122 | Dependencies: []string{"zlib"}, 123 | IsPECL: false, 124 | }, 125 | "bz2": { 126 | Name: "bz2", 127 | ConfigFlag: "--with-bz2", 128 | Dependencies: []string{"bzip2"}, 129 | IsPECL: false, 130 | }, 131 | "iconv": { 132 | Name: "iconv", 133 | ConfigFlag: "--with-iconv", 134 | IsPECL: false, 135 | }, 136 | "intl": { 137 | Name: "intl", 138 | ConfigFlag: "--enable-intl", 139 | Dependencies: []string{"icu"}, 140 | IsPECL: false, 141 | }, 142 | "pgsql": { 143 | Name: "pgsql", 144 | ConfigFlag: "--with-pgsql", 145 | Dependencies: []string{"postgresql"}, 146 | IsPECL: false, 147 | }, 148 | "pdo-pgsql": { 149 | Name: "pdo-pgsql", 150 | ConfigFlag: "--with-pdo-pgsql", 151 | Dependencies: []string{"postgresql"}, 152 | IsPECL: false, 153 | }, 154 | "sqlite3": { 155 | Name: "sqlite3", 156 | ConfigFlag: "--with-sqlite3", 157 | Dependencies: []string{"sqlite"}, 158 | IsPECL: false, 159 | }, 160 | "pdo-sqlite": { 161 | Name: "pdo-sqlite", 162 | ConfigFlag: "--with-pdo-sqlite", 163 | Dependencies: []string{"sqlite"}, 164 | IsPECL: false, 165 | }, 166 | "fileinfo": { 167 | Name: "fileinfo", 168 | ConfigFlag: "--enable-fileinfo", 169 | IsPECL: false, 170 | }, 171 | "exif": { 172 | Name: "exif", 173 | ConfigFlag: "--enable-exif", 174 | IsPECL: false, 175 | }, 176 | "gettext": { 177 | Name: "gettext", 178 | ConfigFlag: "--with-gettext", 179 | Dependencies: []string{"gettext"}, 180 | IsPECL: false, 181 | }, 182 | "gmp": { 183 | Name: "gmp", 184 | ConfigFlag: "--with-gmp", 185 | Dependencies: []string{"gmp"}, 186 | IsPECL: false, 187 | }, 188 | "ldap": { 189 | Name: "ldap", 190 | ConfigFlag: "--with-ldap", 191 | Dependencies: []string{"ldap"}, 192 | IsPECL: false, 193 | }, 194 | "soap": { 195 | Name: "soap", 196 | ConfigFlag: "--enable-soap", 197 | IsPECL: false, 198 | }, 199 | "ftp": { 200 | Name: "ftp", 201 | ConfigFlag: "--enable-ftp", 202 | IsPECL: false, 203 | }, 204 | "pcntl": { 205 | Name: "pcntl", 206 | ConfigFlag: "--enable-pcntl", 207 | IsPECL: false, 208 | }, 209 | "imagick": { 210 | Name: "imagick", 211 | ConfigFlag: "", 212 | Dependencies: []string{"imagick"}, 213 | IsPECL: true, 214 | PECLName: "imagick", 215 | }, 216 | "imap": { 217 | Name: "imap", 218 | ConfigFlag: "--with-imap", 219 | Dependencies: []string{"imap"}, 220 | IsPECL: false, 221 | }, 222 | "redis": { 223 | Name: "redis", 224 | ConfigFlag: "", 225 | Dependencies: []string{}, 226 | IsPECL: true, 227 | PECLName: "redis", 228 | }, 229 | } 230 | var defaultExtensions = []string{ 231 | "mbstring", "curl", "openssl", "fileinfo", "filter", "hash", 232 | "pcre", "session", "xml", "zip", "mysqli", "sqlite3", "pdo-mysql", 233 | "sockets", "zlib", 234 | } 235 | 236 | // GetAvailableVersions returns the list of PHP versions supported by YERD. 237 | func GetAvailablePhpVersions() []string { 238 | return availablePhpVersions 239 | } 240 | 241 | // IsValidVersion checks if the provided version string is supported by YERD. 242 | // version: PHP version string to validate. Returns true if version is supported. 243 | func IsValidPhpVersion(version string) bool { 244 | return slices.Contains(availablePhpVersions, version) 245 | } 246 | 247 | // GetDefaultExtensions returns the default extensions for all PHP installations 248 | func GetDefaultExtensions() []string { 249 | return defaultExtensions 250 | } 251 | 252 | // GetExtension retrieves extension information by name. 253 | // name: Extension name to lookup. Returns Extension struct and existence boolean. 254 | func GetExtensions() []string { 255 | keys := make([]string, 0, len(availableExtensions)) 256 | for key := range availableExtensions { 257 | keys = append(keys, key) 258 | } 259 | return keys 260 | } 261 | 262 | // GetExtension retrieves extension information by name. 263 | // name: Extension name to lookup. Returns Extension struct and existence boolean. 264 | func GetExtension(name string) (Extension, bool) { 265 | ext, exists := availableExtensions[name] 266 | return ext, exists 267 | } 268 | 269 | // ValidateExtensions separates provided extensions into valid and invalid lists. 270 | // extensions: Extension names to validate. Returns valid extensions slice and invalid extensions slice. 271 | func ValidateExtensions(extensions []string) ([]string, []string) { 272 | var valid []string 273 | var invalid []string 274 | 275 | for _, ext := range extensions { 276 | if _, exists := availableExtensions[ext]; exists { 277 | valid = append(valid, ext) 278 | } else { 279 | invalid = append(invalid, ext) 280 | } 281 | } 282 | 283 | return valid, invalid 284 | } 285 | 286 | // GetConfigureFlags returns PHP configure flags for the specified extensions. 287 | // extensions: Extension names to get flags for. Returns slice of configure flag strings. 288 | func GetExtensionConfigureFlags(extensions []string) []string { 289 | var flags []string 290 | 291 | for _, extName := range extensions { 292 | if ext, exists := availableExtensions[extName]; exists { 293 | flags = append(flags, ext.ConfigFlag) 294 | } 295 | } 296 | 297 | return flags 298 | } 299 | 300 | // GetDependencies returns system dependencies required for the specified extensions. 301 | // extensions: Extension names to check. Returns sorted slice of unique system dependencies. 302 | func GetExtensionDependencies(extensions []string) []string { 303 | depMap := make(map[string]bool) 304 | 305 | for _, extName := range extensions { 306 | if ext, exists := availableExtensions[extName]; exists { 307 | for _, dep := range ext.Dependencies { 308 | depMap[dep] = true 309 | } 310 | } 311 | } 312 | 313 | var deps []string 314 | for dep := range depMap { 315 | deps = append(deps, dep) 316 | } 317 | sort.Strings(deps) 318 | return deps 319 | } 320 | 321 | // SuggestSimilarExtensions finds extension names similar to an invalid extension name. 322 | // invalid: Invalid extension name to find suggestions for. Returns sorted slice of similar extension names. 323 | func SuggestSimilarExtensions(invalid string) []string { 324 | var suggestions []string 325 | invalid = strings.ToLower(invalid) 326 | 327 | for name := range availableExtensions { 328 | if strings.Contains(strings.ToLower(name), invalid) || 329 | strings.Contains(invalid, strings.ToLower(name)) { 330 | suggestions = append(suggestions, name) 331 | } 332 | } 333 | 334 | sort.Strings(suggestions) 335 | return suggestions 336 | } 337 | 338 | // GetAvailableExtensions returns a list of available PHP extensions 339 | func GetAvailableExtensions() []string { 340 | keys := maps.Keys(availableExtensions) 341 | return slices.Collect(keys) 342 | } 343 | -------------------------------------------------------------------------------- /internal/installers/nginx/installer.go: -------------------------------------------------------------------------------- 1 | package nginx 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/lumosolutions/yerd/internal/config" 9 | "github.com/lumosolutions/yerd/internal/constants" 10 | "github.com/lumosolutions/yerd/internal/manager" 11 | "github.com/lumosolutions/yerd/internal/utils" 12 | ) 13 | 14 | type NginxInstaller struct { 15 | Info *constants.NginxConfig 16 | IsUpdate bool 17 | ForceConfig bool 18 | Spinner *utils.Spinner 19 | DepManager *manager.DependencyManager 20 | } 21 | 22 | func NewNginxInstaller(update, forceConfig bool) (*NginxInstaller, error) { 23 | s := utils.NewSpinner("Starting Nginx Installer...") 24 | s.SetDelay(150) 25 | 26 | depMan, err := manager.NewDependencyManager() 27 | if err != nil { 28 | s.AddErrorStatus("Failed to create a new dependency manager") 29 | s.StopWithError("No action taken") 30 | return nil, err 31 | } 32 | 33 | return &NginxInstaller{ 34 | Info: constants.GetNginxConfig(), 35 | IsUpdate: update, 36 | ForceConfig: forceConfig, 37 | Spinner: s, 38 | DepManager: depMan, 39 | }, nil 40 | } 41 | 42 | func (installer *NginxInstaller) Uninstall() error { 43 | installer.Spinner.UpdatePhrase("Uninstalling Web Components...") 44 | 45 | webConfig := config.GetWebConfig() 46 | 47 | if webConfig.Sites != nil { 48 | for _, site := range webConfig.Sites { 49 | sm, _ := manager.NewSiteManager() 50 | sm.RemoveSite(site.Domain) 51 | } 52 | } 53 | 54 | utils.SystemdStopService("yerd-nginx") 55 | utils.SystemdDisable("yerd-nginx") 56 | 57 | utils.RemoveFolder(constants.YerdWebDir) 58 | utils.RemoveFile(filepath.Join(constants.SystemdDir, "yerd-nginx.service")) 59 | 60 | utils.SystemdReload() 61 | 62 | dm, _ := manager.NewDependencyManager() 63 | dm.RemoveTrust() 64 | 65 | return nil 66 | } 67 | 68 | func (installer *NginxInstaller) Install() error { 69 | installer.Spinner.Start() 70 | webConfig := config.GetWebConfig() 71 | 72 | if webConfig.Installed { 73 | installer.Spinner.StopWithError("Web Components are already installed") 74 | return fmt.Errorf("already installed") 75 | } 76 | 77 | err := utils.RunAll( 78 | func() error { return installer.installDependencies() }, 79 | func() error { return installer.prepareInstall() }, 80 | func() error { return installer.downloadSource() }, 81 | func() error { return installer.compileAndInstall() }, 82 | func() error { return installer.createCerts() }, 83 | func() error { return installer.addNginxConf() }, 84 | func() error { return installer.addSystemdService() }, 85 | func() error { return installer.writeConfig() }, 86 | ) 87 | 88 | if err != nil { 89 | return err 90 | } 91 | 92 | installer.Spinner.StopWithSuccess("Installed Successfully") 93 | 94 | return nil 95 | } 96 | 97 | func (installer *NginxInstaller) installDependencies() error { 98 | installer.Spinner.UpdatePhrase("Installing Dependencies...") 99 | if err := installer.DepManager.InstallWebDependencies(); err != nil { 100 | installer.Spinner.StopWithError("Failed to install dependencies") 101 | return err 102 | } 103 | 104 | installer.Spinner.AddSuccessStatus("Dependencies Installed") 105 | return nil 106 | } 107 | 108 | func (installer *NginxInstaller) prepareInstall() error { 109 | installer.Spinner.UpdatePhrase("Created required folders") 110 | requiredDirs := []string{ 111 | installer.Info.ConfigPath, 112 | installer.Info.LogPath, 113 | installer.Info.RunPath, 114 | installer.Info.TempPath, 115 | installer.Info.SourcePath, 116 | filepath.Join(installer.Info.InstallPath, "sbin"), 117 | filepath.Join(constants.YerdWebDir, "nginx", "sites-available"), 118 | } 119 | 120 | for _, dir := range requiredDirs { 121 | if err := utils.CreateDirectory(dir); err != nil { 122 | installer.Spinner.AddErrorStatus("Failed to create directory: %s", dir) 123 | installer.Spinner.StopWithError("Installation stopped failure in setup") 124 | return err 125 | } 126 | } 127 | 128 | installer.Spinner.AddSuccessStatus("Directories created successfully") 129 | 130 | return nil 131 | } 132 | 133 | func (installer *NginxInstaller) downloadSource() error { 134 | installer.Spinner.UpdatePhrase("Downloading Nginx") 135 | archivePath := filepath.Join(os.TempDir(), "nginx.tar.gz") 136 | if err := utils.DownloadFile(installer.Info.DownloadURL, archivePath, nil); err != nil { 137 | installer.Spinner.AddErrorStatus("Unable to download Nginx") 138 | installer.Spinner.AddInfoStatus("- Error: %v", err) 139 | installer.Spinner.StopWithError("Failed to download Nginx") 140 | return err 141 | } 142 | 143 | userCtx, err := utils.GetRealUser() 144 | if err != nil { 145 | installer.Spinner.StopWithError("Failed to identify real user") 146 | return err 147 | } 148 | 149 | if err := utils.ExtractArchive(archivePath, installer.Info.SourcePath, userCtx); err != nil { 150 | installer.Spinner.StopWithError("Failed to extract Nginx") 151 | return err 152 | } 153 | 154 | installer.Spinner.AddSuccessStatus("Downloaded nginx successfully") 155 | 156 | return nil 157 | } 158 | 159 | func (installer *NginxInstaller) compileAndInstall() error { 160 | installer.Spinner.UpdatePhrase("Compiling Nginx...") 161 | 162 | buildPath := filepath.Join(installer.Info.SourcePath, fmt.Sprintf("nginx-%s", installer.Info.Version)) 163 | 164 | if !utils.FileExists(filepath.Join(buildPath, "/configure")) { 165 | installer.Spinner.StopWithError("No configure script for Nginx") 166 | return fmt.Errorf("configure script not found in source directory") 167 | } 168 | 169 | _, success := utils.ExecuteCommandInDir( 170 | buildPath, 171 | "./configure", 172 | installer.Info.BuildFlags..., 173 | ) 174 | 175 | if !success { 176 | installer.Spinner.StopWithError("Unable to configure Nginx") 177 | return fmt.Errorf("unable to configure nginx") 178 | } 179 | 180 | installer.Spinner.AddSuccessStatus("Nginx Configured Successfully") 181 | installer.Spinner.UpdatePhrase("Installing Nginx...") 182 | 183 | _, success = utils.ExecuteCommandInDir( 184 | buildPath, 185 | "make", 186 | "install", 187 | ) 188 | 189 | if !success { 190 | installer.Spinner.StopWithError("Unable to install Nginx") 191 | return fmt.Errorf("unable to install nginx") 192 | } 193 | 194 | utils.RemoveFolder(installer.Info.SourcePath) 195 | 196 | return nil 197 | } 198 | 199 | func (installer *NginxInstaller) addNginxConf() error { 200 | if installer.IsUpdate && !installer.ForceConfig { 201 | installer.Spinner.AddInfoStatus("- Not updating nginx.conf") 202 | return nil 203 | } 204 | 205 | installer.Spinner.UpdatePhrase("Downloading nginx.conf") 206 | 207 | content, err := utils.FetchFromGitHub("nginx", "nginx.conf") 208 | if err != nil { 209 | utils.LogError(err, "addConf") 210 | installer.Spinner.AddErrorStatus("Failed to download nginx configuration") 211 | installer.Spinner.StopWithError("nginx.conf download failed") 212 | return err 213 | } 214 | 215 | content = utils.Template(content, utils.TemplateData{ 216 | "user": "root", 217 | }) 218 | 219 | filePath := filepath.Join(installer.Info.ConfigPath, "nginx.conf") 220 | 221 | err = utils.WriteStringToFile(filePath, content, constants.FilePermissions) 222 | if err != nil { 223 | utils.LogError(err, "addConf") 224 | installer.Spinner.StopWithError("Failed to write to nginx.conf") 225 | return err 226 | } 227 | 228 | installer.Spinner.AddInfoStatus("- Downloaded Nginx.conf") 229 | installer.Spinner.AddInfoStatus("- Stored Nginx.conf") 230 | installer.Spinner.AddSuccessStatus("Nginx Configured Successfully") 231 | return nil 232 | } 233 | 234 | func (installer *NginxInstaller) addSystemdService() error { 235 | if installer.IsUpdate && !installer.ForceConfig { 236 | installer.Spinner.AddInfoStatus("- Not updating systemd config") 237 | return nil 238 | } 239 | 240 | installer.Spinner.UpdatePhrase("Configuring Systemd") 241 | 242 | systemdPath := filepath.Join(constants.SystemdDir, "yerd-nginx.service") 243 | content, err := utils.FetchFromGitHub("nginx", "systemd.conf") 244 | if err != nil { 245 | utils.LogError(err, "systemd") 246 | installer.Spinner.AddErrorStatus("Failed to download systemd configuration") 247 | installer.Spinner.StopWithError("systemd.conf download failed") 248 | return err 249 | } 250 | 251 | utils.WriteStringToFile(systemdPath, content, constants.FilePermissions) 252 | 253 | installer.Spinner.AddInfoStatus("Created %s", filepath.Base(systemdPath)) 254 | 255 | if err := utils.SystemdReload(); err != nil { 256 | utils.LogInfo("setupSystemd", "Unable to reload daemons") 257 | return err 258 | } 259 | 260 | installer.Spinner.AddInfoStatus("[Systemd] Reloaded daemons") 261 | 262 | serviceName := "yerd-nginx" 263 | utils.SystemdStopService(serviceName) 264 | if err := utils.SystemdStartService(serviceName); err != nil { 265 | utils.LogInfo("setupSystemd", "Unable to start service %s", serviceName) 266 | installer.Spinner.StopWithError("Unable to start service %s", serviceName) 267 | return fmt.Errorf("unable to start service %s", serviceName) 268 | } 269 | 270 | utils.SystemdEnable(serviceName) 271 | 272 | installer.Spinner.AddInfoStatus("[Systemd] Started '%s' successfully", serviceName) 273 | installer.Spinner.AddSuccessStatus("Systemd Configured") 274 | 275 | return nil 276 | } 277 | 278 | func (installer *NginxInstaller) writeConfig() error { 279 | installer.Spinner.UpdatePhrase("Writing YERD Configuration") 280 | 281 | webConfig := config.GetWebConfig() 282 | 283 | if webConfig.Installed { 284 | installer.Spinner.AddInfoStatus("- YERD configuration does not need updating") 285 | return nil 286 | } 287 | 288 | webConfig.Installed = true 289 | config.SetStruct("web", webConfig) 290 | 291 | hostManager := utils.NewHostsManager() 292 | hostManager.Install() 293 | 294 | return nil 295 | } 296 | 297 | func (installer *NginxInstaller) createCerts() error { 298 | installer.Spinner.UpdatePhrase("Generated Root CA Certificate") 299 | certManager := manager.NewCertificateManager() 300 | if err := certManager.GenerateCaCertificate("yerd"); err != nil { 301 | installer.Spinner.AddErrorStatus("Failed to generate CA Certificate") 302 | return err 303 | } 304 | 305 | installer.Spinner.AddSuccessStatus("Root CA Certificate Created") 306 | 307 | return nil 308 | } 309 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | 5 | 6 | set -e 7 | 8 | 9 | REPO="LumoSolutions/yerd" 10 | INSTALL_DIR="/usr/local/bin" 11 | BINARY_NAME="yerd" 12 | YERD_DIRS="/opt/yerd" 13 | 14 | 15 | RED='\033[0;31m' 16 | GREEN='\033[0;32m' 17 | YELLOW='\033[1;33m' 18 | BLUE='\033[0;34m' 19 | NC='\033[0m' # No Color 20 | 21 | 22 | print_status() { 23 | echo -e "${BLUE}[INFO]${NC} $1" 24 | } 25 | 26 | print_success() { 27 | echo -e "${GREEN}[SUCCESS]${NC} $1" 28 | } 29 | 30 | print_warning() { 31 | echo -e "${YELLOW}[WARNING]${NC} $1" 32 | } 33 | 34 | print_error() { 35 | echo -e "${RED}[ERROR]${NC} $1" 36 | } 37 | 38 | 39 | detect_arch() { 40 | local arch 41 | arch=$(uname -m) 42 | case $arch in 43 | x86_64) 44 | echo "amd64" 45 | ;; 46 | aarch64|arm64) 47 | echo "arm64" 48 | ;; 49 | i386|i686) 50 | echo "386" 51 | ;; 52 | armv7l) 53 | echo "arm" 54 | ;; 55 | *) 56 | print_error "Unsupported architecture: $arch" 57 | exit 1 58 | ;; 59 | esac 60 | } 61 | 62 | 63 | detect_os() { 64 | local os 65 | os=$(uname -s) 66 | case $os in 67 | Linux) 68 | echo "linux" 69 | ;; 70 | Darwin) 71 | echo "darwin" 72 | ;; 73 | *) 74 | print_error "Unsupported operating system: $os" 75 | exit 1 76 | ;; 77 | esac 78 | } 79 | 80 | 81 | detect_distribution() { 82 | if [ -f /etc/os-release ]; then 83 | . /etc/os-release 84 | echo "$ID" 85 | elif [ -f /etc/arch-release ]; then 86 | echo "arch" 87 | elif [ -f /etc/debian_version ]; then 88 | echo "debian" 89 | elif [ -f /etc/redhat-release ]; then 90 | echo "rhel" 91 | else 92 | echo "unknown" 93 | fi 94 | } 95 | 96 | 97 | command_exists() { 98 | command -v "$1" >/dev/null 2>&1 99 | } 100 | 101 | 102 | check_prerequisites() { 103 | print_status "Checking prerequisites..." 104 | 105 | if ! command_exists curl && ! command_exists wget; then 106 | print_error "Neither curl nor wget found. Please install one of them." 107 | 108 | 109 | local distro 110 | distro=$(detect_distribution) 111 | case $distro in 112 | ubuntu|debian) 113 | print_status "Try: sudo apt update && sudo apt install curl wget" 114 | ;; 115 | arch|manjaro) 116 | print_status "Try: sudo pacman -S curl wget" 117 | ;; 118 | fedora) 119 | print_status "Try: sudo dnf install curl wget" 120 | ;; 121 | *) 122 | print_status "Please install curl or wget using your system's package manager" 123 | ;; 124 | esac 125 | exit 1 126 | fi 127 | 128 | if ! command_exists tar; then 129 | print_error "tar command not found. Please install tar." 130 | exit 1 131 | fi 132 | 133 | print_success "Prerequisites check passed" 134 | } 135 | 136 | 137 | get_latest_version() { 138 | local api_url="https://api.github.com/repos/${REPO}/releases/latest" 139 | 140 | if command_exists curl; then 141 | curl -s "$api_url" | grep '"tag_name":' | sed -E 's/.*"tag_name": "([^"]+)".*/\1/' 142 | elif command_exists wget; then 143 | wget -qO- "$api_url" | grep '"tag_name":' | sed -E 's/.*"tag_name": "([^"]+)".*/\1/' 144 | else 145 | print_error "Failed to get latest version" 146 | exit 1 147 | fi 148 | } 149 | 150 | 151 | install_yerd() { 152 | local version="$1" 153 | local os="$2" 154 | local arch="$3" 155 | 156 | 157 | version=${version#v} 158 | 159 | local filename="${BINARY_NAME}_${version}_${os}_${arch}.tar.gz" 160 | local download_url="https://github.com/${REPO}/releases/download/v${version}/${filename}" 161 | local temp_dir 162 | temp_dir=$(mktemp -d) 163 | 164 | print_status "Downloading YERD v${version} for ${os}/${arch}..." 165 | 166 | if command_exists curl; then 167 | if ! curl -sL "$download_url" -o "${temp_dir}/${filename}"; then 168 | print_error "Failed to download using curl" 169 | exit 1 170 | fi 171 | elif command_exists wget; then 172 | if ! wget -q "$download_url" -O "${temp_dir}/${filename}"; then 173 | print_error "Failed to download using wget" 174 | exit 1 175 | fi 176 | fi 177 | 178 | if [ ! -f "${temp_dir}/${filename}" ]; then 179 | print_error "Failed to download ${filename}" 180 | print_error "URL: ${download_url}" 181 | exit 1 182 | fi 183 | 184 | print_status "Extracting archive..." 185 | if ! tar -xzf "${temp_dir}/${filename}" -C "$temp_dir"; then 186 | print_error "Failed to extract archive" 187 | print_error "Archive might be corrupted or incompatible" 188 | exit 1 189 | fi 190 | 191 | if [ ! -f "${temp_dir}/${BINARY_NAME}" ]; then 192 | print_error "Binary not found in archive" 193 | exit 1 194 | fi 195 | 196 | print_status "Installing YERD to ${INSTALL_DIR}..." 197 | 198 | 199 | if [ -w "$INSTALL_DIR" ]; then 200 | cp "${temp_dir}/${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}" 201 | chmod +x "${INSTALL_DIR}/${BINARY_NAME}" 202 | else 203 | print_warning "Installing to ${INSTALL_DIR} requires sudo privileges" 204 | sudo cp "${temp_dir}/${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}" 205 | sudo chmod +x "${INSTALL_DIR}/${BINARY_NAME}" 206 | fi 207 | 208 | 209 | rm -rf "$temp_dir" 210 | 211 | print_success "YERD binary installed successfully" 212 | } 213 | 214 | 215 | setup_directories() { 216 | print_status "Setting up YERD directories..." 217 | 218 | if [ -w "$(dirname "$YERD_DIRS")" ]; then 219 | mkdir -p "${YERD_DIRS}"/{bin,php,etc} 220 | else 221 | print_warning "Setting up directories in ${YERD_DIRS} requires sudo privileges" 222 | sudo mkdir -p "${YERD_DIRS}"/{bin,php,etc} 223 | 224 | 225 | if [ "$EUID" -ne 0 ]; then 226 | read -p "Set user ownership for ${YERD_DIRS}? This allows installation without sudo. (y/N): " -r 227 | if [[ $REPLY =~ ^[Yy]$ ]]; then 228 | sudo chown -R "$USER:$USER" "$YERD_DIRS" 229 | print_success "User ownership set for ${YERD_DIRS}" 230 | fi 231 | fi 232 | fi 233 | 234 | print_success "YERD directories created" 235 | } 236 | 237 | 238 | verify_installation() { 239 | print_status "Verifying installation..." 240 | 241 | if command_exists "$BINARY_NAME"; then 242 | local installed_version 243 | 244 | installed_version=$($BINARY_NAME --version 2>/dev/null | head -n1 | grep -o 'v[0-9]\+\.[0-9]\+\.[0-9]\+' | sed 's/v//' || echo "unknown") 245 | if [ "$installed_version" = "unknown" ]; then 246 | print_success "YERD installed successfully!" 247 | else 248 | print_success "YERD v${installed_version} installed successfully!" 249 | fi 250 | print_status "Try: ${BINARY_NAME} --help" 251 | else 252 | print_error "Installation failed. ${BINARY_NAME} command not found." 253 | print_warning "Make sure ${INSTALL_DIR} is in your PATH" 254 | 255 | 256 | if [[ ":$PATH:" != *":${INSTALL_DIR}:"* ]]; then 257 | print_warning "${INSTALL_DIR} is not in your PATH" 258 | print_status "Add this line to your shell profile (~/.bashrc, ~/.zshrc, etc.):" 259 | print_status "export PATH=\"${INSTALL_DIR}:\$PATH\"" 260 | print_status "Then run: source ~/.bashrc (or restart your shell)" 261 | fi 262 | exit 1 263 | fi 264 | } 265 | 266 | 267 | main() { 268 | echo "╔══════════════════════════════════════════════════════════════════════════════╗" 269 | echo "║ YERD Installation Script ║" 270 | echo "║ A powerful, developer-friendly tool for managing PHP versions ║" 271 | echo "╚══════════════════════════════════════════════════════════════════════════════╝" 272 | echo 273 | 274 | 275 | local force_version="" 276 | while [[ $# -gt 0 ]]; do 277 | case $1 in 278 | --version) 279 | force_version="$2" 280 | shift 2 281 | ;; 282 | --help|-h) 283 | echo "Usage: $0 [options]" 284 | echo "" 285 | echo "Options:" 286 | echo " --version VERSION Install specific version" 287 | echo " --help, -h Show this help message" 288 | echo "" 289 | echo "Examples:" 290 | echo " $0 Install latest version" 291 | echo " $0 --version 1.0.0 Install version 1.0.0" 292 | exit 0 293 | ;; 294 | *) 295 | print_error "Unknown option: $1" 296 | echo "Use --help for usage information" 297 | exit 1 298 | ;; 299 | esac 300 | done 301 | 302 | check_prerequisites 303 | 304 | local os arch version 305 | os=$(detect_os) 306 | arch=$(detect_arch) 307 | 308 | if [ -n "$force_version" ]; then 309 | version="$force_version" 310 | print_status "Installing specified version: v${version}" 311 | else 312 | print_status "Getting latest version from GitHub..." 313 | version=$(get_latest_version) 314 | if [ -z "$version" ]; then 315 | print_error "Failed to get latest version" 316 | exit 1 317 | fi 318 | print_status "Latest version: ${version}" 319 | fi 320 | 321 | install_yerd "$version" "$os" "$arch" 322 | setup_directories 323 | verify_installation 324 | 325 | echo 326 | print_success "🎉 YERD installation completed!" 327 | echo 328 | echo "Next steps:" 329 | echo "1. Run: yerd --help" 330 | echo "2. List PHP versions: yerd php list" 331 | echo "3. Install PHP: sudo yerd php 8.4 install" 332 | echo 333 | } 334 | 335 | # Call main function with all arguments 336 | main "$@" -------------------------------------------------------------------------------- /internal/utils/hosts.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | const ( 11 | HostsFilePath = "/etc/hosts" 12 | YerdStartMarker = "# YERD START - DO NOT MODIFY THIS LINE" 13 | YerdEndMarker = "# YERD END - DO NOT MODIFY THIS LINE" 14 | DefaultIP = "127.0.0.1" 15 | ) 16 | 17 | // HostsManager manages YERD-specific entries in the hosts file 18 | type HostsManager struct { 19 | hostsPath string 20 | } 21 | 22 | // NewHostsManager creates a new HostsManager with the default hosts file path 23 | func NewHostsManager() *HostsManager { 24 | return &HostsManager{hostsPath: HostsFilePath} 25 | } 26 | 27 | // NewHostsManagerWithPath creates a new HostsManager with a custom hosts file path 28 | func NewHostsManagerWithPath(path string) *HostsManager { 29 | return &HostsManager{hostsPath: path} 30 | } 31 | 32 | // Install creates YERD comment markers in the hosts file if they don't exist 33 | func (hm *HostsManager) Install() error { 34 | content, err := hm.loadHostsFile() 35 | if err != nil { 36 | LogError(err, "hosts") 37 | return fmt.Errorf("failed to load hosts file: %w", err) 38 | } 39 | 40 | if hm.findYerdBounds(content) != nil { 41 | LogInfo("hosts", "YERD section already exists") 42 | return nil 43 | } 44 | 45 | if len(content) > 0 && strings.TrimSpace(content[len(content)-1]) != "" { 46 | content = append(content, "") 47 | } 48 | content = append(content, YerdStartMarker, YerdEndMarker) 49 | 50 | if err := hm.writeHostsFile(content); err != nil { 51 | LogError(err, "hosts") 52 | return fmt.Errorf("failed to install YERD section: %w", err) 53 | } 54 | 55 | LogInfo("hosts", "YERD section installed successfully") 56 | return nil 57 | } 58 | 59 | // Add inserts a hostname entry within the YERD section 60 | func (hm *HostsManager) Add(hostname string) error { 61 | if err := hm.validateHostname(hostname); err != nil { 62 | return err 63 | } 64 | hostname = strings.TrimSpace(hostname) 65 | 66 | if err := hm.ensureInstalled(); err != nil { 67 | return fmt.Errorf("failed to ensure YERD section: %w", err) 68 | } 69 | 70 | content, err := hm.loadHostsFile() 71 | if err != nil { 72 | return fmt.Errorf("failed to load hosts file: %w", err) 73 | } 74 | 75 | bounds := hm.findYerdBounds(content) 76 | if bounds == nil { 77 | return fmt.Errorf("YERD section not found after installation") 78 | } 79 | 80 | if hm.hostExists(content, bounds, hostname) { 81 | return fmt.Errorf("host '%s' already exists in YERD section", hostname) 82 | } 83 | 84 | newEntry := fmt.Sprintf("%s\t%s", DefaultIP, hostname) 85 | newContent := hm.insertLine(content, bounds.endLine, newEntry) 86 | 87 | if err := hm.writeHostsFile(newContent); err != nil { 88 | return fmt.Errorf("failed to add host: %w", err) 89 | } 90 | 91 | LogInfo("hosts", "Added host: %s", hostname) 92 | return nil 93 | } 94 | 95 | // Remove deletes a hostname entry from the YERD section 96 | func (hm *HostsManager) Remove(hostname string) error { 97 | if err := hm.validateHostname(hostname); err != nil { 98 | return err 99 | } 100 | hostname = strings.TrimSpace(hostname) 101 | 102 | content, err := hm.loadHostsFile() 103 | if err != nil { 104 | return fmt.Errorf("failed to load hosts file: %w", err) 105 | } 106 | 107 | bounds := hm.findYerdBounds(content) 108 | if bounds == nil { 109 | return fmt.Errorf("YERD section not found in hosts file") 110 | } 111 | 112 | removed := false 113 | newContent := make([]string, 0, len(content)) 114 | 115 | for i, line := range content { 116 | if hm.shouldRemoveLine(i, line, bounds, hostname) { 117 | removed = true 118 | LogInfo("hosts", "Removing host: %s", hostname) 119 | continue 120 | } 121 | newContent = append(newContent, line) 122 | } 123 | 124 | if !removed { 125 | return fmt.Errorf("host '%s' not found in YERD section", hostname) 126 | } 127 | 128 | if err := hm.writeHostsFile(newContent); err != nil { 129 | return fmt.Errorf("failed to remove host: %w", err) 130 | } 131 | 132 | return nil 133 | } 134 | 135 | // Uninstall removes the entire YERD section and all its contents 136 | func (hm *HostsManager) Uninstall() error { 137 | content, err := hm.loadHostsFile() 138 | if err != nil { 139 | return fmt.Errorf("failed to load hosts file: %w", err) 140 | } 141 | 142 | bounds := hm.findYerdBounds(content) 143 | if bounds == nil { 144 | LogInfo("hosts", "YERD section not found, nothing to uninstall") 145 | return nil 146 | } 147 | 148 | newContent := hm.removeSection(content, bounds) 149 | 150 | if err := hm.writeHostsFile(newContent); err != nil { 151 | return fmt.Errorf("failed to uninstall YERD section: %w", err) 152 | } 153 | 154 | LogInfo("hosts", "YERD section uninstalled successfully") 155 | return nil 156 | } 157 | 158 | // ListYerdHosts returns all hostnames managed by YERD 159 | func (hm *HostsManager) ListYerdHosts() ([]string, error) { 160 | content, err := hm.loadHostsFile() 161 | if err != nil { 162 | return nil, fmt.Errorf("failed to load hosts file: %w", err) 163 | } 164 | 165 | bounds := hm.findYerdBounds(content) 166 | if bounds == nil { 167 | return []string{}, nil 168 | } 169 | 170 | hosts := make([]string, 0) 171 | for i := bounds.startLine + 1; i < bounds.endLine; i++ { 172 | hostname := hm.extractHostname(content[i]) 173 | if hostname != "" { 174 | hosts = append(hosts, hostname) 175 | } 176 | } 177 | 178 | return hosts, nil 179 | } 180 | 181 | // yerdBounds represents the boundaries of the YERD section 182 | type yerdBounds struct { 183 | startLine int 184 | endLine int 185 | } 186 | 187 | // ensureInstalled makes sure the YERD section exists in the hosts file 188 | func (hm *HostsManager) ensureInstalled() error { 189 | content, err := hm.loadHostsFile() 190 | if err != nil { 191 | return err 192 | } 193 | 194 | if hm.findYerdBounds(content) == nil { 195 | return hm.Install() 196 | } 197 | return nil 198 | } 199 | 200 | // validateHostname checks if a hostname is valid 201 | func (hm *HostsManager) validateHostname(hostname string) error { 202 | hostname = strings.TrimSpace(hostname) 203 | if hostname == "" { 204 | return fmt.Errorf("hostname cannot be empty") 205 | } 206 | if strings.Contains(hostname, " ") || strings.Contains(hostname, "\t") { 207 | return fmt.Errorf("hostname cannot contain whitespace") 208 | } 209 | if strings.Contains(hostname, "#") { 210 | return fmt.Errorf("hostname cannot contain '#' character") 211 | } 212 | return nil 213 | } 214 | 215 | // loadHostsFile reads and returns the hosts file content as lines 216 | func (hm *HostsManager) loadHostsFile() ([]string, error) { 217 | file, err := os.Open(hm.hostsPath) 218 | if err != nil { 219 | return nil, err 220 | } 221 | defer file.Close() 222 | 223 | var lines []string 224 | scanner := bufio.NewScanner(file) 225 | for scanner.Scan() { 226 | lines = append(lines, scanner.Text()) 227 | } 228 | 229 | if err := scanner.Err(); err != nil { 230 | return nil, err 231 | } 232 | 233 | return lines, nil 234 | } 235 | 236 | // writeHostsFile atomically writes content to the hosts file 237 | func (hm *HostsManager) writeHostsFile(content []string) error { 238 | tempPath := hm.hostsPath + ".yerd.tmp" 239 | 240 | file, err := os.Create(tempPath) 241 | if err != nil { 242 | return err 243 | } 244 | defer func() { 245 | file.Close() 246 | if _, err := os.Stat(tempPath); err == nil { 247 | os.Remove(tempPath) 248 | } 249 | }() 250 | 251 | writer := bufio.NewWriter(file) 252 | for i, line := range content { 253 | if i > 0 { 254 | writer.WriteString("\n") 255 | } 256 | writer.WriteString(line) 257 | } 258 | 259 | if len(content) > 0 { 260 | writer.WriteString("\n") 261 | } 262 | 263 | if err := writer.Flush(); err != nil { 264 | return err 265 | } 266 | 267 | if err := file.Close(); err != nil { 268 | return err 269 | } 270 | 271 | return os.Rename(tempPath, hm.hostsPath) 272 | } 273 | 274 | // findYerdBounds returns the boundaries of the YERD section or nil if not found 275 | func (hm *HostsManager) findYerdBounds(content []string) *yerdBounds { 276 | startIdx, endIdx := -1, -1 277 | 278 | for i, line := range content { 279 | trimmed := strings.TrimSpace(line) 280 | if trimmed == YerdStartMarker { 281 | startIdx = i 282 | } else if trimmed == YerdEndMarker { 283 | endIdx = i 284 | break 285 | } 286 | } 287 | 288 | LogInfo("hosts", "bounds check - start: %d, end: %d", startIdx, endIdx) 289 | 290 | if startIdx == -1 || endIdx == -1 || startIdx >= endIdx { 291 | return nil 292 | } 293 | 294 | return &yerdBounds{ 295 | startLine: startIdx, 296 | endLine: endIdx, 297 | } 298 | } 299 | 300 | // hostExists checks if hostname already exists in the YERD section 301 | func (hm *HostsManager) hostExists(content []string, bounds *yerdBounds, hostname string) bool { 302 | for i := bounds.startLine + 1; i < bounds.endLine; i++ { 303 | if hm.isHostEntry(content[i], hostname) { 304 | return true 305 | } 306 | } 307 | return false 308 | } 309 | 310 | // isHostEntry checks if a line is a host entry for the given hostname 311 | func (hm *HostsManager) isHostEntry(line, hostname string) bool { 312 | extracted := hm.extractHostname(line) 313 | return extracted != "" && extracted == hostname 314 | } 315 | 316 | // extractHostname extracts the hostname from a hosts file entry line 317 | func (hm *HostsManager) extractHostname(line string) string { 318 | line = strings.TrimSpace(line) 319 | 320 | if line == "" || strings.HasPrefix(line, "#") { 321 | return "" 322 | } 323 | 324 | parts := strings.Fields(line) 325 | if len(parts) >= 2 && parts[0] == DefaultIP { 326 | return parts[1] 327 | } 328 | 329 | return "" 330 | } 331 | 332 | // insertLine inserts a new line at the specified position 333 | func (hm *HostsManager) insertLine(content []string, position int, line string) []string { 334 | newContent := make([]string, 0, len(content)+1) 335 | newContent = append(newContent, content[:position]...) 336 | newContent = append(newContent, line) 337 | newContent = append(newContent, content[position:]...) 338 | return newContent 339 | } 340 | 341 | // removeSection removes the YERD section from content 342 | func (hm *HostsManager) removeSection(content []string, bounds *yerdBounds) []string { 343 | newContent := make([]string, 0, len(content)) 344 | newContent = append(newContent, content[:bounds.startLine]...) 345 | 346 | if bounds.endLine+1 < len(content) { 347 | newContent = append(newContent, content[bounds.endLine+1:]...) 348 | } 349 | 350 | for len(newContent) > 0 && strings.TrimSpace(newContent[len(newContent)-1]) == "" { 351 | newContent = newContent[:len(newContent)-1] 352 | } 353 | 354 | return newContent 355 | } 356 | 357 | // shouldRemoveLine determines if a line should be removed during Remove operation 358 | func (hm *HostsManager) shouldRemoveLine(index int, line string, bounds *yerdBounds, hostname string) bool { 359 | if index <= bounds.startLine || index >= bounds.endLine { 360 | return false 361 | } 362 | return hm.isHostEntry(line, hostname) 363 | } 364 | -------------------------------------------------------------------------------- /internal/utils/file.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "os" 8 | "path/filepath" 9 | "time" 10 | 11 | "github.com/lumosolutions/yerd/internal/constants" 12 | "github.com/lumosolutions/yerd/internal/version" 13 | ) 14 | 15 | // FileExists checks if a file or directory exists at the given path. 16 | // path: File system path to check. Returns true if path exists, false otherwise. 17 | func FileExists(path string) bool { 18 | _, err := os.Stat(path) 19 | return !os.IsNotExist(err) 20 | } 21 | 22 | // IsDirectory checks if the given path exists and is a directory. 23 | // Returns false if the path doesn't exist, is a file, or if there's an error accessing it. 24 | func IsDirectory(path string) bool { 25 | info, err := os.Stat(path) 26 | if err != nil { 27 | return false 28 | } 29 | return info.IsDir() 30 | } 31 | 32 | // CanWriteToPath checks write permissions by traversing up directory tree to find writable parent. 33 | // path: Target path to check. Returns true if path or parent directory is writable. 34 | func CanWriteToPath(path string) bool { 35 | for current := path; current != "/" && current != "."; current = filepath.Dir(current) { 36 | if info, err := os.Stat(current); err == nil { 37 | if !info.IsDir() { 38 | return false 39 | } 40 | return testWriteAccess(current) 41 | } 42 | if current == filepath.Dir(current) { 43 | break 44 | } 45 | } 46 | return false 47 | } 48 | 49 | // testWriteAccess attempts to create and remove a test file to verify write permissions. 50 | // dir: Directory to test. Returns true if write access is available. 51 | func testWriteAccess(dir string) bool { 52 | testFile := filepath.Join(dir, ".yerd_test_write") 53 | file, err := os.Create(testFile) 54 | if err != nil { 55 | return false 56 | } 57 | file.Close() 58 | os.Remove(testFile) 59 | return true 60 | } 61 | 62 | // CreateDirectory creates a directory with proper permissions and error handling. 63 | // path: Directory path to create. Returns error if creation fails. 64 | func CreateDirectory(path string) error { 65 | if err := os.MkdirAll(path, constants.DirPermissions); err != nil { 66 | LogError(err, "create-directory") 67 | return fmt.Errorf("failed to create directory %s: %v", path, err) 68 | } 69 | return nil 70 | } 71 | 72 | func ReplaceDirectory(path string) error { 73 | os.RemoveAll(path) 74 | return CreateDirectory(path) 75 | } 76 | 77 | // WriteToFile writes content to a file with specified permissions, overwriting if it exists 78 | func WriteToFile(filename string, content []byte, perm os.FileMode) error { 79 | dir := filepath.Dir(filename) 80 | if err := CreateDirectory(dir); err != nil { 81 | return fmt.Errorf("failed to create directory for file %s: %w", filename, err) 82 | } 83 | 84 | err := os.WriteFile(filename, content, perm) 85 | if err != nil { 86 | return fmt.Errorf("failed to write file %s: %w", filename, err) 87 | } 88 | return nil 89 | } 90 | 91 | // WriteStringToFile writes string content to a file with specified permissions 92 | func WriteStringToFile(filename string, content string, perm os.FileMode) error { 93 | return WriteToFile(filename, []byte(content), perm) 94 | } 95 | 96 | // Chown changes the ownership of a file or directory 97 | func Chown(path string, uid, gid int) error { 98 | // Check if path exists 99 | if _, err := os.Stat(path); os.IsNotExist(err) { 100 | return fmt.Errorf("path does not exist: %s", path) 101 | } 102 | 103 | // Change ownership 104 | if err := os.Chown(path, uid, gid); err != nil { 105 | return fmt.Errorf("failed to change ownership of %s: %w", path, err) 106 | } 107 | 108 | return nil 109 | } 110 | 111 | // ChownRecursive changes ownership recursively for directories 112 | func ChownRecursive(path string, uid, gid int) error { 113 | return filepath.Walk(path, func(name string, info os.FileInfo, err error) error { 114 | if err != nil { 115 | return err 116 | } 117 | 118 | if err := os.Chown(name, uid, gid); err != nil { 119 | return fmt.Errorf("failed to chown %s: %w", name, err) 120 | } 121 | 122 | return nil 123 | }) 124 | } 125 | 126 | // ChownSymlink changes ownership of symlink itself (not the target) 127 | func ChownSymlink(path string, uid, gid int) error { 128 | return os.Lchown(path, uid, gid) 129 | } 130 | 131 | // Copy copies a file from src to dst using the cp command 132 | func Copy(src, dst string) error { 133 | output, success := ExecuteCommand("cp", src, dst) 134 | if !success { 135 | return fmt.Errorf("failed to copy %s to %s: %s", src, dst, output) 136 | } 137 | return nil 138 | } 139 | 140 | // CopyRecursive copies a directory recursively from src to dst using cp -r 141 | func CopyRecursive(src, dst string) error { 142 | output, success := ExecuteCommand("cp", "-rT", src, dst) 143 | if !success { 144 | return fmt.Errorf("failed to copy recursively %s to %s: %s", src, dst, output) 145 | } 146 | return nil 147 | } 148 | 149 | func Chmod(filePath string, mode os.FileMode) error { 150 | if err := os.Chmod(filePath, mode); err != nil { 151 | LogError(err, "chmod") 152 | return fmt.Errorf("failed to chmod %s: %v", filePath, err) 153 | } 154 | return nil 155 | } 156 | 157 | // IsSymlink checks if the given path is a symbolic link. 158 | // path: File system path to check. Returns true if path is a symlink, false otherwise. 159 | func IsSymlink(path string) bool { 160 | info, err := os.Lstat(path) 161 | if err != nil { 162 | return false 163 | } 164 | return info.Mode()&os.ModeSymlink != 0 165 | } 166 | 167 | // IsBrokenSymlink checks if the path is a symlink that points to a non-existent target. 168 | // path: File system path to check. Returns true if symlink exists but target doesn't. 169 | func IsBrokenSymlink(path string) bool { 170 | if !IsSymlink(path) { 171 | return false 172 | } 173 | 174 | _, err := os.Stat(path) 175 | return os.IsNotExist(err) 176 | } 177 | 178 | // ReadSymlink returns the target path that a symbolic link points to. 179 | // path: File system path of the symlink. Returns the target path and nil error if successful, 180 | // or empty string and error if path is not a symlink or cannot be read. 181 | func ReadSymlink(path string) (string, error) { 182 | if !IsSymlink(path) { 183 | return "", fmt.Errorf("path %s is not a symbolic link", path) 184 | } 185 | 186 | target, err := os.Readlink(path) 187 | if err != nil { 188 | return "", fmt.Errorf("failed to read symlink %s: %w", path, err) 189 | } 190 | 191 | return target, nil 192 | } 193 | 194 | // CreateSymlink creates a symbolic link, removing existing link if present. 195 | // target: Path to link target, link: Path where symlink should be created. Returns error if creation fails. 196 | func CreateSymlink(target, link string) error { 197 | if FileExists(link) { 198 | if err := os.Remove(link); err != nil { 199 | LogError(err, "create-symlink") 200 | return fmt.Errorf("failed to remove existing symlink %s: %v", link, err) 201 | } 202 | } 203 | 204 | linkDir := filepath.Dir(link) 205 | if err := os.MkdirAll(linkDir, constants.DirPermissions); err != nil { 206 | LogError(err, "create-symlink") 207 | return fmt.Errorf("failed to create symlink directory %s: %v", linkDir, err) 208 | } 209 | 210 | LogInfo("create-symlink", "Creating link from %s to %s", link, target) 211 | return os.Symlink(target, link) 212 | } 213 | 214 | // RemoveSymlink safely removes a symbolic link with validation to prevent removing regular files. 215 | // link: Path to symlink to remove. Returns error if not a symlink or removal fails. 216 | func RemoveSymlink(link string) error { 217 | if !FileExists(link) { 218 | return nil 219 | } 220 | 221 | info, err := os.Lstat(link) 222 | if err != nil { 223 | return fmt.Errorf("failed to stat symlink %s: %v", link, err) 224 | } 225 | 226 | if info.Mode()&os.ModeSymlink == 0 { 227 | return fmt.Errorf("%s is not a symlink, refusing to remove", link) 228 | } 229 | 230 | return os.Remove(link) 231 | } 232 | 233 | // ExtractArchive extracts a tar achieve to a folder 234 | func ExtractArchive(archivePath, toFolder string, userCtx *UserContext) error { 235 | ReplaceDirectory(toFolder) 236 | Chown(toFolder, userCtx.UID, userCtx.GID) 237 | if _, success := ExecuteCommand("tar", "-xzf", archivePath, "-C", toFolder); !success { 238 | return fmt.Errorf("tar command failed") 239 | } 240 | 241 | return nil 242 | } 243 | 244 | // FetchFromGitHub downloads a file from github and returns it as 245 | // a string value 246 | func FetchFromGitHub(folder, file string) (string, error) { 247 | filePath := filepath.Join(".config", folder, file) 248 | 249 | url := fmt.Sprintf( 250 | "https://raw.githubusercontent.com/%s/%s/%s", 251 | version.GetRepo(), 252 | version.GetBranch(), 253 | filePath, 254 | ) 255 | 256 | LogInfo("github", "Attempting to download %s", url) 257 | 258 | client := &http.Client{ 259 | Timeout: 30 * time.Second, 260 | Transport: &http.Transport{ 261 | DisableKeepAlives: true, 262 | }, 263 | } 264 | 265 | req, err := http.NewRequest("GET", url, nil) 266 | if err != nil { 267 | LogError(err, "github") 268 | LogInfo("github", "Failed to create request") 269 | return "", fmt.Errorf("failed to create request: %v", err) 270 | } 271 | 272 | req.Header.Set("User-Agent", "YERD/1.0") 273 | req.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate") 274 | req.Header.Set("Pragma", "no-cache") 275 | req.Header.Set("Expires", "0") 276 | 277 | resp, err := client.Do(req) 278 | 279 | if err != nil { 280 | LogError(err, "github") 281 | LogInfo("github", "Failed to fetch data from github") 282 | return "", fmt.Errorf("failed to fetch from GitHub: %w", err) 283 | } 284 | defer resp.Body.Close() 285 | 286 | // Check if the request was successful 287 | if resp.StatusCode != http.StatusOK { 288 | LogInfo("github", "Status code was %d which was not expected", resp.StatusCode) 289 | return "", fmt.Errorf("GitHub returned status %d: %s", resp.StatusCode, resp.Status) 290 | } 291 | 292 | // Read the response body 293 | content, err := io.ReadAll(resp.Body) 294 | if err != nil { 295 | LogInfo("github", "Failed to read body") 296 | return "", fmt.Errorf("failed to read response body: %w", err) 297 | } 298 | 299 | return string(content), nil 300 | } 301 | 302 | // RemoveFile removes a single file 303 | func RemoveFile(filePath string) error { 304 | info, err := os.Stat(filePath) 305 | if err != nil { 306 | return nil 307 | } 308 | 309 | if info.IsDir() { 310 | return fmt.Errorf("path is a directory, not a file: %s", filePath) 311 | } 312 | 313 | if err := os.Remove(filePath); err != nil { 314 | return fmt.Errorf("failed to remove file: %w", err) 315 | } 316 | 317 | return nil 318 | } 319 | 320 | // RemoveFolder removes a directory and all its contents 321 | func RemoveFolder(folderPath string) error { 322 | info, err := os.Stat(folderPath) 323 | if err != nil { 324 | return nil 325 | } 326 | 327 | if !info.IsDir() { 328 | return fmt.Errorf("path is not a directory: %s", folderPath) 329 | } 330 | 331 | if err := os.RemoveAll(folderPath); err != nil { 332 | return fmt.Errorf("failed to remove folder: %w", err) 333 | } 334 | 335 | return nil 336 | } 337 | 338 | func GetWorkingDirectory() (string, error) { 339 | dir, err := os.Getwd() 340 | if err != nil { 341 | LogError(err, "pwd") 342 | return "", err 343 | } 344 | 345 | return dir, nil 346 | } 347 | -------------------------------------------------------------------------------- /cmd/update.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "archive/tar" 5 | "compress/gzip" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "os" 11 | "runtime" 12 | "strings" 13 | "time" 14 | 15 | "github.com/lumosolutions/yerd/internal/utils" 16 | "github.com/lumosolutions/yerd/internal/version" 17 | "github.com/spf13/cobra" 18 | ) 19 | 20 | type GitHubRelease struct { 21 | TagName string `json:"tag_name"` 22 | Name string `json:"name"` 23 | Body string `json:"body"` 24 | Assets []struct { 25 | Name string `json:"name"` 26 | BrowserDownloadURL string `json:"browser_download_url"` 27 | } `json:"assets"` 28 | } 29 | 30 | var UpdateCmd = &cobra.Command{ 31 | Use: "update", 32 | Short: "Update YERD to the latest version", 33 | Long: `Check for and install the latest version of YERD from GitHub releases. 34 | 35 | This command will: 36 | - Check the current YERD version 37 | - Fetch the latest release from GitHub 38 | - Download and install the update if available 39 | - Preserve existing configuration and PHP installations 40 | 41 | Examples: 42 | yerd update # Check for and install updates 43 | yerd update -y # Auto-confirm update without prompting`, 44 | Args: cobra.NoArgs, 45 | Run: runUpdate, 46 | } 47 | 48 | var autoConfirm bool 49 | 50 | // runUpdate executes the YERD self-update process by checking for new releases and installing them. 51 | func runUpdate(cmd *cobra.Command, args []string) { 52 | version.PrintSplash() 53 | 54 | currentVersion := version.GetVersion() 55 | if currentVersion == "" { 56 | currentVersion = "unknown" 57 | } 58 | 59 | fmt.Printf("🔍 Current YERD version: %s\n", currentVersion) 60 | fmt.Printf("🌐 Checking for updates from GitHub...\n") 61 | 62 | latestRelease, err := fetchLatestRelease() 63 | if err != nil { 64 | fmt.Printf("❌ Failed to check for updates: %v\n", err) 65 | fmt.Printf("💡 Check your internet connection and try again\n") 66 | return 67 | } 68 | 69 | latestVersion := strings.TrimPrefix(latestRelease.TagName, "v") 70 | if currentVersion == latestVersion { 71 | fmt.Printf("✅ YERD is already up to date (v%s)\n", currentVersion) 72 | return 73 | } 74 | 75 | if currentVersion != "unknown" && !isNewerVersion(latestVersion, currentVersion) { 76 | fmt.Printf("ℹ️ You have a newer or development version (v%s) than the latest release (v%s)\n", currentVersion, latestVersion) 77 | fmt.Printf("💡 No update needed\n") 78 | return 79 | } 80 | 81 | fmt.Printf("🆕 New version available: v%s\n", latestVersion) 82 | 83 | if !utils.CheckAndPromptForSudo() { 84 | return 85 | } 86 | 87 | if !confirmUpdate(latestVersion) { 88 | fmt.Printf("❌ Update cancelled\n") 89 | return 90 | } 91 | 92 | if err := performUpdate(latestRelease); err != nil { 93 | fmt.Printf("❌ Update failed: %v\n", err) 94 | fmt.Printf("💡 You can manually download from: https://github.com/LumoSolutions/yerd/releases\n") 95 | return 96 | } 97 | 98 | fmt.Printf("✅ YERD updated successfully to v%s\n", latestVersion) 99 | fmt.Printf("💡 Run 'yerd --version' to verify the update\n") 100 | } 101 | 102 | // fetchLatestRelease retrieves the latest YERD release information from GitHub API. 103 | // Returns GitHubRelease struct with release details or error if request fails. 104 | func fetchLatestRelease() (*GitHubRelease, error) { 105 | client := &http.Client{Timeout: 10 * time.Second} 106 | 107 | resp, err := client.Get("https://api.github.com/repos/LumoSolutions/yerd/releases/latest") 108 | if err != nil { 109 | return nil, fmt.Errorf("HTTP request failed: %v", err) 110 | } 111 | defer resp.Body.Close() 112 | 113 | if resp.StatusCode != 200 { 114 | return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode) 115 | } 116 | 117 | var release GitHubRelease 118 | if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { 119 | return nil, fmt.Errorf("failed to parse GitHub response: %v", err) 120 | } 121 | 122 | return &release, nil 123 | } 124 | 125 | // isNewerVersion compares two semantic version strings to determine if latest is newer than current. 126 | // latest: Version string to compare, current: Current version string. Returns true if latest > current. 127 | func isNewerVersion(latest, current string) bool { 128 | latestParts := strings.Split(latest, ".") 129 | currentParts := strings.Split(current, ".") 130 | 131 | maxLen := len(latestParts) 132 | if len(currentParts) > maxLen { 133 | maxLen = len(currentParts) 134 | } 135 | 136 | for i := 0; i < maxLen; i++ { 137 | var latestNum, currentNum int 138 | 139 | if i < len(latestParts) { 140 | fmt.Sscanf(latestParts[i], "%d", &latestNum) 141 | } 142 | if i < len(currentParts) { 143 | fmt.Sscanf(currentParts[i], "%d", ¤tNum) 144 | } 145 | 146 | if latestNum > currentNum { 147 | return true 148 | } else if latestNum < currentNum { 149 | return false 150 | } 151 | } 152 | 153 | return false 154 | } 155 | 156 | // confirmUpdate prompts user for update confirmation or auto-confirms based on flags. 157 | // version: Version string to update to. Returns true if user confirms or auto-confirm is enabled. 158 | func confirmUpdate(version string) bool { 159 | if autoConfirm { 160 | fmt.Printf("🔄 Auto-updating to v%s...\n", version) 161 | return true 162 | } 163 | 164 | fmt.Printf("🔄 Update to v%s? (y/N): ", version) 165 | var response string 166 | fmt.Scanln(&response) 167 | response = strings.ToLower(strings.TrimSpace(response)) 168 | 169 | return response == "y" || response == "yes" 170 | } 171 | 172 | // performUpdate downloads and installs the new YERD version from GitHub release. 173 | // release: GitHubRelease containing download URLs and version info. Returns error if update fails. 174 | func performUpdate(release *GitHubRelease) error { 175 | binaryName := getBinaryName() 176 | if binaryName == "" { 177 | return fmt.Errorf("no suitable binary found for %s/%s", runtime.GOOS, runtime.GOARCH) 178 | } 179 | 180 | var downloadURL string 181 | for _, asset := range release.Assets { 182 | if asset.Name == binaryName { 183 | downloadURL = asset.BrowserDownloadURL 184 | break 185 | } 186 | } 187 | 188 | if downloadURL == "" { 189 | return fmt.Errorf("binary not found in release assets") 190 | } 191 | 192 | fmt.Printf("📦 Downloading %s...\n", binaryName) 193 | 194 | tempArchive, err := downloadBinary(downloadURL) 195 | if err != nil { 196 | return fmt.Errorf("download failed: %v", err) 197 | } 198 | defer os.Remove(tempArchive) 199 | 200 | fmt.Printf("📂 Extracting archive...\n") 201 | extractedBinary, err := extractBinaryFromArchive(tempArchive) 202 | if err != nil { 203 | return fmt.Errorf("extraction failed: %v", err) 204 | } 205 | defer os.Remove(extractedBinary) 206 | 207 | executablePath, err := os.Executable() 208 | if err != nil { 209 | return fmt.Errorf("failed to get current executable path: %v", err) 210 | } 211 | 212 | fmt.Printf("🔄 Installing update...\n") 213 | 214 | backupPath := executablePath + ".backup" 215 | if err := os.Rename(executablePath, backupPath); err != nil { 216 | return fmt.Errorf("failed to backup current binary: %v", err) 217 | } 218 | 219 | if err := copyFile(extractedBinary, executablePath); err != nil { 220 | os.Rename(backupPath, executablePath) 221 | return fmt.Errorf("failed to install new binary: %v", err) 222 | } 223 | 224 | if err := os.Chmod(executablePath, 0755); err != nil { 225 | return fmt.Errorf("failed to set executable permissions: %v", err) 226 | } 227 | 228 | os.Remove(backupPath) 229 | 230 | return nil 231 | } 232 | 233 | // getBinaryName constructs the appropriate binary filename for the current platform and architecture. 234 | // Returns platform-specific binary name or empty string if platform is unsupported. 235 | func getBinaryName() string { 236 | version := strings.TrimPrefix(fetchLatestVersionTag(), "v") 237 | if version == "" { 238 | return "" 239 | } 240 | 241 | goos := runtime.GOOS 242 | goarch := runtime.GOARCH 243 | 244 | if goarch == "amd64" && goos == "linux" { 245 | return fmt.Sprintf("yerd_%s_linux_amd64.tar.gz", version) 246 | } else if goarch == "arm64" && goos == "linux" { 247 | return fmt.Sprintf("yerd_%s_linux_arm64.tar.gz", version) 248 | } else if goarch == "386" && goos == "linux" { 249 | return fmt.Sprintf("yerd_%s_linux_386.tar.gz", version) 250 | } else if goos == "darwin" { 251 | if goarch == "amd64" { 252 | return fmt.Sprintf("yerd_%s_darwin_amd64.tar.gz", version) 253 | } else if goarch == "arm64" { 254 | return fmt.Sprintf("yerd_%s_darwin_arm64.tar.gz", version) 255 | } 256 | } 257 | 258 | return "" 259 | } 260 | 261 | // fetchLatestVersionTag retrieves the latest version tag from GitHub releases. 262 | // Returns version tag string or empty string if fetch fails. 263 | func fetchLatestVersionTag() string { 264 | release, err := fetchLatestRelease() 265 | if err != nil { 266 | return "" 267 | } 268 | return release.TagName 269 | } 270 | 271 | // downloadBinary downloads a binary file from the given URL to a temporary file. 272 | // url: Download URL. Returns path to temporary file or error if download fails. 273 | func downloadBinary(url string) (string, error) { 274 | client := &http.Client{Timeout: 60 * time.Second} 275 | 276 | resp, err := client.Get(url) 277 | if err != nil { 278 | return "", err 279 | } 280 | defer resp.Body.Close() 281 | 282 | if resp.StatusCode != 200 { 283 | return "", fmt.Errorf("download failed with status %d", resp.StatusCode) 284 | } 285 | 286 | tempFile, err := os.CreateTemp("", "yerd-update-*.tar.gz") 287 | if err != nil { 288 | return "", err 289 | } 290 | defer tempFile.Close() 291 | 292 | _, err = io.Copy(tempFile, resp.Body) 293 | if err != nil { 294 | os.Remove(tempFile.Name()) 295 | return "", err 296 | } 297 | 298 | return tempFile.Name(), nil 299 | } 300 | 301 | // extractBinaryFromArchive extracts the 'yerd' binary from a tar.gz archive. 302 | // archivePath: Path to archive file. Returns path to extracted binary or error if extraction fails. 303 | func extractBinaryFromArchive(archivePath string) (string, error) { 304 | file, err := os.Open(archivePath) 305 | if err != nil { 306 | return "", err 307 | } 308 | defer file.Close() 309 | 310 | gzr, err := gzip.NewReader(file) 311 | if err != nil { 312 | return "", err 313 | } 314 | defer gzr.Close() 315 | 316 | tr := tar.NewReader(gzr) 317 | 318 | for { 319 | header, err := tr.Next() 320 | if err == io.EOF { 321 | break 322 | } 323 | if err != nil { 324 | return "", err 325 | } 326 | 327 | if header.Name == "yerd" && header.Typeflag == tar.TypeReg { 328 | tempFile, err := os.CreateTemp("", "yerd-binary-*") 329 | if err != nil { 330 | return "", err 331 | } 332 | defer tempFile.Close() 333 | 334 | _, err = io.Copy(tempFile, tr) 335 | if err != nil { 336 | os.Remove(tempFile.Name()) 337 | return "", err 338 | } 339 | 340 | return tempFile.Name(), nil 341 | } 342 | } 343 | 344 | return "", fmt.Errorf("yerd binary not found in archive") 345 | } 346 | 347 | // copyFile copies a file from src to dst, handling cross-device links properly. 348 | // src: Source file path, dst: Destination file path. Returns error if copy fails. 349 | func copyFile(src, dst string) error { 350 | srcFile, err := os.Open(src) 351 | if err != nil { 352 | return err 353 | } 354 | defer srcFile.Close() 355 | 356 | dstFile, err := os.Create(dst) 357 | if err != nil { 358 | return err 359 | } 360 | defer dstFile.Close() 361 | 362 | _, err = io.Copy(dstFile, srcFile) 363 | return err 364 | } 365 | -------------------------------------------------------------------------------- /internal/manager/site.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "slices" 7 | "strings" 8 | 9 | "github.com/lumosolutions/yerd/internal/config" 10 | "github.com/lumosolutions/yerd/internal/constants" 11 | "github.com/lumosolutions/yerd/internal/utils" 12 | ) 13 | 14 | type SiteManager struct { 15 | Directory string 16 | PhpVersion string 17 | Domain string 18 | Spinner *utils.Spinner 19 | WebConfig *config.WebConfig 20 | PublicFolder string 21 | CrtFile string 22 | KeyFile string 23 | } 24 | 25 | func NewSiteManager() (*SiteManager, error) { 26 | s := utils.NewSpinner("Managing Sites...") 27 | s.SetDelay(150) 28 | 29 | webConfig := config.GetWebConfig() 30 | 31 | if !webConfig.Installed { 32 | return nil, fmt.Errorf("web not installed") 33 | } 34 | 35 | return &SiteManager{ 36 | Spinner: s, 37 | WebConfig: webConfig, 38 | }, nil 39 | } 40 | 41 | func (sm *SiteManager) ListSites() { 42 | if len(sm.WebConfig.Sites) == 0 { 43 | sm.Spinner.AddWarningStatus("No Sites Created") 44 | sm.Spinner.AddInfoStatus("Create a site with one of the following commands:") 45 | sm.Spinner.AddInfoStatus("'sudo yerd sites add .' # use current directory") 46 | sm.Spinner.AddInfoStatus("'sudo yerd sites add relative/folder'") 47 | sm.Spinner.AddInfoStatus("'sudo yerd sites add /home/user/absolute/folder'") 48 | return 49 | } 50 | 51 | for _, site := range sm.WebConfig.Sites { 52 | fmt.Printf("🌐 Site: %s (PHP %s)\n", site.Domain, site.PhpVersion) 53 | fmt.Printf("├─ Secure Link: https://%s/\n", site.Domain) 54 | fmt.Printf("└─ Directory: %s\n\n", site.RootDirectory) 55 | } 56 | } 57 | 58 | func (sm *SiteManager) SetValue(name, value, site string) error { 59 | sm.Spinner.UpdatePhrase("Updating Site...") 60 | sm.Spinner.Start() 61 | 62 | if !sm.identifySite(site) { 63 | sm.Spinner.StopWithError("Unable to identify site") 64 | return fmt.Errorf("unable to identify site") 65 | } 66 | 67 | sm.Spinner.AddSuccessStatus("Identified Site") 68 | sm.Spinner.AddInfoStatus("Domain: %s", sm.Domain) 69 | sm.Spinner.AddInfoStatus("Directory: %s", sm.Directory) 70 | 71 | switch strings.ToLower(name) { 72 | case "php": 73 | return sm.updatePhp(value) 74 | default: 75 | sm.Spinner.StopWithError("Unknown setting name %s", name) 76 | return fmt.Errorf("unknown setting name") 77 | } 78 | } 79 | 80 | func (sm *SiteManager) updatePhp(version string) error { 81 | sm.PhpVersion = version 82 | if err := sm.validatePhpVersion(); err != nil { 83 | sm.Spinner.StopWithError("Failed to update site") 84 | return err 85 | } 86 | 87 | if err := sm.createSiteConfig(); err != nil { 88 | sm.Spinner.StopWithError("Failed to update site") 89 | return err 90 | } 91 | 92 | if err := sm.restartNginx(); err != nil { 93 | sm.Spinner.StopWithError("Failed to restart nginx") 94 | return err 95 | } 96 | 97 | config.SetStringData(fmt.Sprintf("web.sites.[%s].php_version", sm.Domain), sm.PhpVersion) 98 | 99 | sm.Spinner.AddInfoStatus("Updated to PHP %s", sm.PhpVersion) 100 | sm.Spinner.StopWithSuccess("Update Successful") 101 | 102 | return nil 103 | } 104 | 105 | func (sm *SiteManager) RemoveSite(identifier string) error { 106 | sm.Spinner.UpdatePhrase("Removing site") 107 | sm.Spinner.Start() 108 | 109 | sm.Spinner.AddInfoStatus("Using identifier: %s", identifier) 110 | if !sm.identifySite(identifier) { 111 | sm.Spinner.StopWithError("Unable to identify site") 112 | return fmt.Errorf("unable to identify site") 113 | } 114 | 115 | sm.Spinner.AddSuccessStatus("Identified Site") 116 | sm.Spinner.AddInfoStatus("Domain: %s", sm.Domain) 117 | sm.Spinner.AddInfoStatus("Directory: %s", sm.Directory) 118 | 119 | nginxPath := filepath.Join(constants.YerdWebDir, "nginx") 120 | files := []string{ 121 | filepath.Join(constants.YerdWebDir, "certs", "sites", sm.Domain+".key"), 122 | filepath.Join(constants.YerdWebDir, "certs", "sites", sm.Domain+".crt"), 123 | filepath.Join(nginxPath, "sites-enabled", sm.Domain+".conf"), 124 | } 125 | 126 | utils.SystemdStopService("yerd-nginx") 127 | 128 | for _, file := range files { 129 | if err := utils.RemoveFile(file); err != nil { 130 | sm.Spinner.AddInfoStatus("Unable to remove %s", filepath.Base(file)) 131 | } else { 132 | sm.Spinner.AddSuccessStatus("Removed %s", filepath.Base(file)) 133 | } 134 | } 135 | 136 | if err := utils.SystemdStartService("yerd-nginx"); err != nil { 137 | sm.Spinner.AddInfoStatus("Unable to restart nginx") 138 | } else { 139 | sm.Spinner.AddSuccessStatus("Restarted Nginx") 140 | } 141 | 142 | hm := utils.NewHostsManager() 143 | hm.Remove(sm.Domain) 144 | 145 | config.Delete(fmt.Sprintf("web.sites.[%s]", sm.Domain)) 146 | 147 | sm.Spinner.StopWithSuccess("Site Removed") 148 | return nil 149 | } 150 | 151 | func (sm *SiteManager) identifySite(identifier string) bool { 152 | path, _ := filepath.Abs(identifier) 153 | for _, site := range sm.WebConfig.Sites { 154 | if site.Domain == identifier || site.RootDirectory == path { 155 | sm.Domain = site.Domain 156 | sm.Directory = site.RootDirectory 157 | sm.PublicFolder = site.PublicDirectory 158 | sm.PhpVersion = site.PhpVersion 159 | sm.CrtFile = filepath.Join(constants.CertsDir, "sites", site.Domain+".crt") 160 | sm.KeyFile = filepath.Join(constants.CertsDir, "sites", site.Domain+".key") 161 | 162 | return true 163 | } 164 | } 165 | 166 | return false 167 | } 168 | 169 | func (siteManager *SiteManager) AddSite(directory, domain, publicFolder, phpVersion string) error { 170 | siteManager.Directory = directory 171 | siteManager.Domain = domain 172 | siteManager.PhpVersion = phpVersion 173 | siteManager.PublicFolder = publicFolder 174 | 175 | siteManager.Spinner.Start() 176 | 177 | err := utils.RunAll( 178 | func() error { return siteManager.validateDirectory() }, 179 | func() error { return siteManager.validateDomain() }, 180 | func() error { return siteManager.validatePhpVersion() }, 181 | func() error { return siteManager.createCertificate() }, 182 | func() error { return siteManager.createSiteConfig() }, 183 | func() error { return siteManager.createHostsEntry() }, 184 | func() error { return siteManager.restartNginx() }, 185 | func() error { return siteManager.addToConfig() }, 186 | ) 187 | 188 | if err != nil { 189 | siteManager.Spinner.StopWithError("Failed to add site") 190 | return err 191 | } 192 | 193 | siteManager.Spinner.StopWithSuccess("Site Created! https://%s", siteManager.Domain) 194 | 195 | return nil 196 | } 197 | 198 | func (sm *SiteManager) addToConfig() error { 199 | siteConfig := &config.SiteConfig{ 200 | RootDirectory: sm.Directory, 201 | PublicDirectory: sm.PublicFolder, 202 | PhpVersion: sm.PhpVersion, 203 | Domain: sm.Domain, 204 | } 205 | 206 | config.SetStruct(fmt.Sprintf("web.sites.[%s]", sm.Domain), siteConfig) 207 | 208 | return nil 209 | } 210 | 211 | func (siteManager *SiteManager) validateDirectory() error { 212 | siteManager.Spinner.UpdatePhrase("Validating Path...") 213 | abs, err := filepath.Abs(siteManager.Directory) 214 | if err != nil { 215 | siteManager.Spinner.AddErrorStatus("Directory provided is invalid") 216 | return err 217 | } 218 | 219 | if !utils.IsDirectory(abs) { 220 | siteManager.Spinner.AddErrorStatus("Path provided is not a directory") 221 | return fmt.Errorf("path not a directory") 222 | } 223 | 224 | siteManager.Directory = abs 225 | siteManager.Spinner.AddInfoStatus("Directory: %s", abs) 226 | 227 | if siteManager.PublicFolder == "" { 228 | if utils.IsDirectory(filepath.Join(abs, "public")) { 229 | siteManager.PublicFolder = "public" 230 | siteManager.Spinner.AddInfoStatus("Public directory discovered") 231 | } 232 | } 233 | 234 | if !utils.IsDirectory(filepath.Join(abs, siteManager.PublicFolder)) { 235 | siteManager.Spinner.AddErrorStatus("Provided public path is not a directory") 236 | return fmt.Errorf("public folder does not exist") 237 | } 238 | 239 | if siteManager.WebConfig.Sites != nil { 240 | for _, site := range siteManager.WebConfig.Sites { 241 | if site.RootDirectory == siteManager.Directory { 242 | siteManager.Spinner.AddErrorStatus("Directory is already registered") 243 | return fmt.Errorf("directory in use") 244 | } 245 | } 246 | } 247 | 248 | siteManager.Spinner.AddInfoStatus("Serving from /%s", siteManager.PublicFolder) 249 | 250 | return nil 251 | } 252 | 253 | func (sm *SiteManager) createCertificate() error { 254 | cm := NewCertificateManager() 255 | keyFile, certFile, err := cm.GenerateCert(sm.Domain, "yerd") 256 | if err != nil { 257 | sm.Spinner.AddErrorStatus("Unable to secure site") 258 | return err 259 | } 260 | 261 | sm.CrtFile = certFile 262 | sm.KeyFile = keyFile 263 | 264 | sm.Spinner.AddSuccessStatus("Site Secured Successfully") 265 | sm.Spinner.AddInfoStatus("https://%s/", sm.Domain) 266 | 267 | return nil 268 | } 269 | 270 | func (siteManager *SiteManager) validateDomain() error { 271 | if siteManager.Domain == "" { 272 | base := filepath.Base(siteManager.Directory) 273 | base = strings.ToLower(base) + ".test" 274 | siteManager.Domain = base 275 | } 276 | 277 | siteManager.Domain = strings.ToLower(siteManager.Domain) 278 | 279 | if siteManager.WebConfig.Sites != nil { 280 | for _, site := range siteManager.WebConfig.Sites { 281 | if site.Domain == siteManager.Domain { 282 | siteManager.Spinner.AddErrorStatus("Domain is already in use") 283 | siteManager.Spinner.AddInfoStatus("Use the -d flag to specify a custom domain") 284 | return fmt.Errorf("domain in use") 285 | } 286 | } 287 | } 288 | 289 | siteManager.Spinner.AddInfoStatus("Domain: %s", siteManager.Domain) 290 | 291 | return nil 292 | } 293 | 294 | func (siteManager *SiteManager) validatePhpVersion() error { 295 | siteManager.Spinner.UpdatePhrase("Validating PHP Version...") 296 | 297 | if siteManager.PhpVersion == "" { 298 | versions := constants.GetAvailablePhpVersions() 299 | slices.Reverse(versions) 300 | 301 | for _, version := range versions { 302 | if _, installed := config.GetInstalledPhpInfo(version); installed { 303 | siteManager.PhpVersion = version 304 | break 305 | } 306 | } 307 | } 308 | 309 | _, installed := config.GetInstalledPhpInfo(siteManager.PhpVersion) 310 | if !installed { 311 | siteManager.Spinner.AddErrorStatus("PHP %s is not installed, or is not valid", siteManager.PhpVersion) 312 | return fmt.Errorf("php version not installed") 313 | } 314 | 315 | siteManager.Spinner.AddInfoStatus("Using PHP %s", siteManager.PhpVersion) 316 | 317 | return nil 318 | } 319 | 320 | func (siteManager *SiteManager) createSiteConfig() error { 321 | siteManager.Spinner.UpdatePhrase("Downloading site.conf...") 322 | content, err := utils.FetchFromGitHub("nginx", "site.conf") 323 | if err != nil { 324 | siteManager.Spinner.AddErrorStatus("Unable to download site.conf") 325 | } 326 | 327 | projectPath := filepath.Join(siteManager.Directory, siteManager.PublicFolder) 328 | content = utils.Template(content, utils.TemplateData{ 329 | "domain": siteManager.Domain, 330 | "path": projectPath, 331 | "php_version": siteManager.PhpVersion, 332 | "cert": siteManager.CrtFile, 333 | "key": siteManager.KeyFile, 334 | }) 335 | 336 | path := filepath.Join(constants.YerdWebDir, "nginx", "sites-enabled", siteManager.Domain+".conf") 337 | err = utils.WriteStringToFile( 338 | path, 339 | content, 340 | constants.FilePermissions, 341 | ) 342 | 343 | if err != nil { 344 | siteManager.Spinner.AddErrorStatus("Unable to save %s", filepath.Base(path)) 345 | return err 346 | } 347 | 348 | siteManager.Spinner.AddSuccessStatus("Created Nginx Configration (%s)", filepath.Base(path)) 349 | 350 | return nil 351 | } 352 | 353 | func (siteManager *SiteManager) createHostsEntry() error { 354 | hostManager := utils.NewHostsManager() 355 | if err := hostManager.Add(siteManager.Domain); err != nil { 356 | siteManager.Spinner.AddErrorStatus("Unable to add hosts entry") 357 | utils.LogError(err, "hosts") 358 | return err 359 | } 360 | 361 | return nil 362 | } 363 | 364 | func (siteManager *SiteManager) restartNginx() error { 365 | siteManager.Spinner.UpdatePhrase("Restarting Nginx...") 366 | utils.SystemdStopService("yerd-nginx") 367 | if err := utils.SystemdStartService("yerd-nginx"); err != nil { 368 | utils.LogError(err, "nginx") 369 | siteManager.Spinner.AddErrorStatus("Failed to restart nginx") 370 | return err 371 | } 372 | 373 | return nil 374 | } 375 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # YERD - The Ultimate PHP Development Environment Manager 2 | 3 |
4 | 5 | ![YERD Logo](.meta/yerd_logo.jpg) 6 | 7 | **Transform your PHP development workflow with intelligent version management and seamless local environments** 8 | 9 | https://github.com/LumoSolutions/yerd 10 | 11 | [![Release](https://img.shields.io/github/v/release/LumoSolutions/yerd)](https://github.com/LumoSolutions/yerd/releases) 12 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 13 | [![Go Version](https://img.shields.io/badge/Go-1.24+-blue.svg)](https://golang.org) 14 | [![Platform](https://img.shields.io/badge/Platform-Linux%20%7C%20macOS-green.svg)](https://github.com/LumoSolutions/yerd) 15 | 16 |
17 | 18 | --- 19 | 20 | ## 🚀 Why YERD? 21 | 22 | **Stop wrestling with PHP versions. Start building.** 23 | 24 | YERD revolutionizes PHP development by providing a zero-friction environment manager that just works. Whether you're juggling legacy projects, testing against multiple PHP versions, or deploying production applications, YERD eliminates the complexity and lets you focus on what matters - your code. 25 | 26 | ### ✨ Key Benefits 27 | 28 | - **🎯 Zero Conflicts** - Complete isolation from system PHP. Never break production again. 29 | - **⚡ Instant Switching** - Change PHP versions in milliseconds, not minutes 30 | - **🛠️ Production-Grade** - Built from official PHP source for maximum reliability 31 | - **🔧 30+ Extensions** - Pre-configured with smart dependency management 32 | - **🌐 Local Development** - Integrated nginx with automatic HTTPS for every site 33 | - **🔒 Chrome-Trusted SSL** - Self-signed certificates that browsers actually trust 34 | - **📦 Composer Included** - Latest Composer managed automatically 35 | - **🔄 Self-Updating** - Stay current with one-command updates 36 | 37 | ### 🏆 Perfect For 38 | 39 | - **Development Teams** - Standardize environments across your entire team 40 | - **Agencies** - Manage multiple client projects with different PHP requirements 41 | - **Open Source Maintainers** - Test against multiple PHP versions effortlessly 42 | - **DevOps Engineers** - Deploy consistent, reproducible PHP environments 43 | - **Freelancers** - Switch between client projects without environment conflicts 44 | 45 | ## ⚡ Quick Start 46 | 47 | ```bash 48 | curl -sSL https://raw.githubusercontent.com/LumoSolutions/yerd/main/install.sh | sudo bash 49 | ``` 50 | 51 | Then: 52 | 53 | ```bash 54 | # Install PHP 8.4 with a single command 55 | sudo yerd php 8.4 install 56 | 57 | # Set as your default CLI 58 | sudo yerd php 8.4 cli 59 | 60 | # You're ready to code! 61 | php -v # PHP 8.4.x 62 | ``` 63 | 64 | ## 📋 System Requirements 65 | 66 | - **Operating Systems**: Linux (all distributions), macOS 67 | - **Architecture**: x86_64, ARM64, 32-bit 68 | - **Privileges**: sudo access for installations 69 | - **Dependencies**: Automatically managed per distribution 70 | 71 | ## 🎯 Core Features 72 | 73 | ### Multiple PHP Versions 74 | Run PHP 8.1, 8.2, 8.3, and 8.4 simultaneously without conflicts. Each version is completely isolated with its own configuration, extensions, and FPM service. 75 | 76 | ### Intelligent Extension Management 77 | Choose from 30+ extensions with automatic dependency resolution. YERD handles the complexity of building PHP with your exact requirements. 78 | 79 | ### Web Development Ready 80 | Integrated nginx support transforms YERD into a complete local development environment. Create sites with custom domains, automatic SSL certificates, and per-site PHP versions. Every site gets a chrome-trusted HTTPS certificate automatically. 81 | 82 | ### Zero-Configuration Composer 83 | Latest Composer version managed by YERD - always up-to-date, always available globally. 84 | 85 | ### Enterprise-Grade Reliability 86 | Built from official PHP source code with production-ready configurations. Perfect for both development and production deployments. 87 | 88 | ## 📚 Complete Command Reference 89 | 90 | ### PHP Version Management 91 | 92 | #### Installation Commands 93 | 94 | ```bash 95 | # Install a PHP version (8.1, 8.2, 8.3, or 8.4) 96 | sudo yerd php 8.4 install 97 | 98 | # Install with fresh source (bypass cache) 99 | sudo yerd php 8.4 install --nocache 100 | ``` 101 | 102 | #### Version Control 103 | 104 | ```bash 105 | # List all installed PHP versions 106 | yerd php list 107 | 108 | # Show detailed PHP status 109 | yerd php status 110 | 111 | # Set default CLI version 112 | sudo yerd php 8.4 cli 113 | 114 | # Force CLI version update 115 | sudo yerd php 8.4 cli --force 116 | ``` 117 | 118 | #### Extension Management 119 | 120 | ```bash 121 | # List available extensions for a version 122 | yerd php 8.3 extensions list 123 | 124 | # Add extensions (automatically rebuilds PHP) 125 | sudo yerd php 8.3 extensions add gd mysqli opcache 126 | 127 | # Remove extensions 128 | sudo yerd php 8.3 extensions remove gd 129 | 130 | # Add extensions and rebuild immediately 131 | sudo yerd php 8.3 extensions add gd --rebuild 132 | ``` 133 | 134 | **Available Extensions**: mbstring, bcmath, opcache, curl, openssl, zip, sockets, mysqli, pdo-mysql, gd, jpeg, freetype, xml, json, session, hash, filter, pcre, zlib, bz2, iconv, intl, pgsql, pdo-pgsql, sqlite3, pdo-sqlite, fileinfo, exif, gettext, gmp, ldap, soap, ftp, pcntl, imap, imagick, redis 135 | 136 | #### Maintenance Operations 137 | 138 | ```bash 139 | # Update PHP to latest patch version 140 | sudo yerd php 8.4 update 141 | 142 | # Rebuild PHP (useful after system updates) 143 | sudo yerd php 8.4 rebuild 144 | 145 | # Rebuild with config regeneration 146 | sudo yerd php 8.4 rebuild --config 147 | 148 | # Uninstall a PHP version 149 | sudo yerd php 8.2 uninstall 150 | 151 | # Skip confirmation prompts 152 | sudo yerd php 8.2 uninstall --yes 153 | ``` 154 | 155 | Note, the imap extension may require special handling, you will be informed of the actions required when you attempt to add the extension, for example: 156 | 157 | ```bash 158 | ⚠️ SPECIAL INSTALLATION REQUIREMENTS: 159 | ───────────────────────────────────── 160 | 161 | 📦 Extension 'imap' (dependency: imap): 162 | Run: yay -S c-client (requires AUR helper) 163 | 164 | ───────────────────────────────────── 165 | ℹ️ Please install these dependencies before rebuilding PHP 166 | 167 | These extensions will be added to PHP 8.3 on the next rebuild 168 | imap imagick 169 | 170 | ℹ️ These changes won't apply until PHP is rebuilt 171 | ℹ️ PHP can be rebuilt with the following command: 172 | 173 | sudo yerd php 8.3 rebuild 174 | ``` 175 | 176 | ### Composer Management 177 | 178 | ```bash 179 | # Install Composer 180 | sudo yerd composer install 181 | 182 | # Update to latest version 183 | sudo yerd composer update 184 | 185 | # Remove Composer 186 | sudo yerd composer uninstall 187 | ``` 188 | 189 | ### Web Services (nginx) 190 | 191 | ```bash 192 | # Install web components 193 | sudo yerd web install 194 | 195 | # Remove web components 196 | sudo yerd web uninstall 197 | 198 | # If you are having problems with Chrome trusting SSL certificates 199 | sudo yerd web trust 200 | ``` 201 | 202 | ### Site Management 203 | 204 | ```bash 205 | # List all sites 206 | yerd sites list 207 | 208 | # Add a new site (automatically creates HTTPS certificate) 209 | sudo yerd sites add /path/to/project 210 | 211 | # Add with custom domain and PHP version 212 | sudo yerd sites add /var/www/myapp --domain myapp.test --php 8.3 213 | 214 | # Specify public directory 215 | sudo yerd sites add /var/www/myapp --folder public 216 | 217 | # Remove a site 218 | sudo yerd sites remove /path/to/project 219 | 220 | # Update site configuration 221 | sudo yerd sites set php 8.4 myapp.test 222 | ``` 223 | 224 | **🔒 Automatic SSL Certificates**: Every site is served over HTTPS by default with a chrome-trusted SSL certificate, signed by a YERD Certificate Authority generated and managed on your system. No more browser warnings! 225 | 226 | ### Self-Update 227 | 228 | ```bash 229 | # Check for and install updates 230 | sudo yerd update 231 | 232 | # Auto-confirm updates 233 | sudo yerd update --yes 234 | ``` 235 | 236 | ## 🔄 Typical Workflows 237 | 238 | ### New Project Setup 239 | 240 | ```bash 241 | # 1. Install required PHP version 242 | sudo yerd php 8.4 install 243 | 244 | # 2. Add necessary extensions 245 | sudo yerd php 8.4 extensions add mysqli gd opcache curl 246 | 247 | # 3. Install Composer 248 | sudo yerd composer install 249 | 250 | # 4. Set as CLI default 251 | sudo yerd php 8.4 cli 252 | 253 | # 5. Install web components 254 | sudo yerd web install 255 | 256 | # 6. Add your project site (automatic HTTPS) 257 | sudo yerd sites add /var/www/myproject --domain myproject.test --php 8.4 258 | 259 | # 7. Start developing with HTTPS! 260 | cd /var/www/myproject 261 | composer install 262 | # Access at: https://myproject.test 263 | ``` 264 | 265 | ### Multi-Version Testing 266 | 267 | ```bash 268 | # Install all PHP versions 269 | sudo yerd php 8.1 install 270 | sudo yerd php 8.2 install 271 | sudo yerd php 8.3 install 272 | sudo yerd php 8.4 install 273 | 274 | # Test your code across versions 275 | php8.1 vendor/bin/phpunit 276 | php8.2 vendor/bin/phpunit 277 | php8.3 vendor/bin/phpunit 278 | php8.4 vendor/bin/phpunit 279 | 280 | # Or switch CLI versions as needed 281 | sudo yerd php 8.1 cli && phpunit 282 | sudo yerd php 8.4 cli && phpunit 283 | ``` 284 | 285 | ### Production Deployment 286 | 287 | ```bash 288 | # Install specific PHP version 289 | sudo yerd php 8.3 install 290 | 291 | # Add production extensions 292 | sudo yerd php 8.3 extensions add opcache mysqli pdo-mysql curl openssl 293 | 294 | # Set as system default 295 | sudo yerd php 8.3 cli 296 | 297 | # Keep updated 298 | sudo yerd update --yes 299 | sudo yerd php 8.3 update 300 | ``` 301 | 302 | ## 🏗️ Architecture 303 | 304 | YERD provides a clean, organized structure: 305 | 306 | ``` 307 | /opt/yerd/ 308 | ├── bin/ # PHP binaries and Composer 309 | ├── php/ # PHP installations 310 | ├── etc/ # Configuration files 311 | └── web/ # nginx and certificates 312 | 313 | /usr/local/bin/ 314 | ├── php # Current CLI version 315 | ├── php8.1 # Direct version access 316 | ├── php8.2 317 | ├── php8.3 318 | ├── php8.4 319 | └── composer # Global Composer 320 | 321 | ~/.config/yerd/config.json # User configuration 322 | ``` 323 | 324 | ## 🛠️ Advanced Features 325 | 326 | ### 🔒 Automatic SSL Certificate Management 327 | YERD includes a complete SSL certificate infrastructure for local development: 328 | - **YERD Certificate Authority**: A local CA is generated and managed on your system 329 | - **Automatic Certificate Generation**: Every site gets its own SSL certificate automatically 330 | - **Chrome/Browser Trust**: Certificates are signed by the YERD CA, eliminating browser warnings 331 | - **HTTPS by Default**: All sites are served over HTTPS (port 443) with HTTP redirect 332 | - **Zero Configuration**: Just add a site and SSL is handled automatically 333 | 334 | ### FPM Service Management 335 | Each PHP version includes its own FPM service, managed via systemd: 336 | - Service names: `yerd-php{version}-fpm` 337 | - Sockets: `/opt/yerd/php/run/php{version}-fpm.sock` 338 | - Automatic start on boot 339 | - Graceful reloads during updates 340 | 341 | ### Smart Dependency Management 342 | YERD automatically detects your Linux distribution and installs appropriate packages: 343 | - **Ubuntu/Debian**: Uses `apt` with development libraries 344 | - **Arch/Manjaro**: Uses `pacman` with build tools 345 | - **Fedora/RHEL**: Uses `dnf`/`yum` with devel packages 346 | - **openSUSE**: Uses `zypper` with development patterns 347 | 348 | ### Security First 349 | - Complete isolation from system PHP 350 | - Privilege separation during builds 351 | - Automatic configuration backups 352 | - Chrome-trusted SSL certificates for all local sites 353 | - No root processes except installation 354 | 355 | ## 🚨 Troubleshooting 356 | 357 | ```bash 358 | # Check system status 359 | yerd php status 360 | 361 | # Verify installations 362 | yerd php list 363 | 364 | # Force rebuild after system updates 365 | sudo yerd php 8.4 rebuild --config 366 | 367 | # Check service status 368 | systemctl status yerd-php84-fpm 369 | 370 | # View logs 371 | journalctl -u yerd-php84-fpm 372 | ``` 373 | 374 | ## 🤝 Contributing 375 | 376 | We welcome contributions! YERD is built with Go and uses the Cobra CLI framework. Check out our [contributing guidelines](CONTRIBUTING.md) to get started. 377 | 378 | ```bash 379 | # Clone and build from source 380 | git clone https://github.com/LumoSolutions/yerd.git 381 | cd yerd 382 | go build -o yerd main.go 383 | ``` 384 | 385 | ## 📄 License 386 | 387 | MIT License - see [LICENSE](LICENSE) for details. 388 | 389 | ## 🔗 Resources 390 | 391 | - **GitHub**: [LumoSolutions/yerd](https://github.com/LumoSolutions/yerd) 392 | - **Issues**: [Report bugs or request features](https://github.com/LumoSolutions/yerd/issues) 393 | - **Releases**: [Download latest version](https://github.com/LumoSolutions/yerd/releases) 394 | 395 | --- 396 | 397 |
398 | 399 | **Built with ❤️ for developers who demand more from their tools** 400 | 401 | *Stop managing environments. Start shipping code.* 402 | 403 |
404 | --------------------------------------------------------------------------------