diff --git a/.gitignore b/.gitignore index 779508f..f66d762 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ /.idea -/.vscode -pvm.exe \ No newline at end of file +/.vscode \ No newline at end of file diff --git a/README.md b/README.md index 3f21b0c..68acd3a 100644 --- a/README.md +++ b/README.md @@ -11,38 +11,53 @@ This utility changes that. ## Installation -Download the latest pvm version from the releases page (1.0-alpha-1, it's currently a pre-release). +> [!WARNING] +> version lower than 1.3.0 will have only pvm.exe +> version 1.3.0 or higher will include pvm-setup.exe but can still get pvm.exe from source -Create the folder `%UserProfile%\.pvm\bin` (e.g. `C:\Users\Harry\.pvm\bin`) and drop the pvm exe in there. Add the folder to your PATH. +### Installer +> Download the latest pvm installer from the releases page (>= 1.3.0). + +### Manual Installation +> Create the folder `%UserProfile%\.pvm\bin` (e.g. `C:\Users\Harry\.pvm\bin`) and drop the pvm.exe in there. Add the folder to your PATH. ## Commands + ``` pvm list ``` + Will list out all the available PHP versions you have installed ``` -pvm path +pvm install 8 ``` -Will tell you what to put in your Path variable. + +> [!NOTE] +> The install command will automatically determine the newest minor/patch versions if they are not specified + +Will install PHP 8 at the latest minor and patch. ``` -pvm use 8.2.9 +pvm use 8.2 ``` -> [!NOTE] + +> [!NOTE] > Versions must have major.minor specified in the *use* command. If a .patch version is omitted, newest available patch version is chosen. -Will switch your currently active PHP version to PHP 8.2.9 +Will switch your currently active PHP version to PHP 8.2 latest patch. ``` -pvm install 8.2 +pvm uninstall 8.2.9 ``` -> [!NOTE] -> The install command will automatically determine the newest minor/patch versions if they are not specified -Will install PHP 8.2 at the latest patch. +> [!NOTE] +> Versions must have major.minor.patch specified in the *uninstall* command. If a .patch version is omitted, it will not uninstalling. + +Will uninstall PHP version to PHP 8.2.9 ## Composer support + `pvm` now installs also composer with each php version installed. It will install Composer latest stable release for PHP >= 7.2 and Composer latest 2.2.x LTS for PHP < 7.2. You'll be able to invoke composer from terminal as it is intended: @@ -53,6 +68,7 @@ composer --version ## Build this project To compile this project use: + ```shell GOOS=windows GOARCH=amd64 go build -o pvm.exe -``` \ No newline at end of file +``` diff --git a/commands/path.go b/commands/path.go deleted file mode 100644 index 602732e..0000000 --- a/commands/path.go +++ /dev/null @@ -1,22 +0,0 @@ -package commands - -import ( - "fmt" - "hjbdev/pvm/theme" - "log" - "os" - "path/filepath" -) - -func Path() { - theme.Title("pvm: PHP Version Manager") - - // get home dir - homeDir, err := os.UserHomeDir() - if err != nil { - log.Fatalln(err) - } - - fmt.Println("Add the following directory to your PATH:") - fmt.Println(" " + filepath.Join(homeDir, ".pvm", "bin")) -} diff --git a/pvm-setup.exe b/pvm-setup.exe new file mode 100644 index 0000000..604e207 Binary files /dev/null and b/pvm-setup.exe differ diff --git a/pvm-setup.iss b/pvm-setup.iss new file mode 100644 index 0000000..1208305 --- /dev/null +++ b/pvm-setup.iss @@ -0,0 +1,139 @@ +; Script generated by the Inno Setup Script Wizard. +; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! + +#define MyAppName "PVM" +#define MyAppVersion "1.3.0" +#define MyAppPublisher "hjb.dev" +#define MyAppURL "https://github.com/hjbdev/pvm" +#define MyAppExeName "pvm.exe" + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. +; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) +AppId={{DA006438-7CC4-41E4-B2BF-2AD84AAA18E9} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +;AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} +DefaultDirName={%USERPROFILE}\.pvm +UninstallDisplayIcon={app}\{#MyAppExeName} +; "ArchitecturesAllowed=x64compatible" specifies that Setup cannot run +; on anything but x64 and Windows 11 on Arm. +ArchitecturesAllowed=x64os +; "ArchitecturesInstallIn64BitMode=x64compatible" requests that the +; install be done in "64-bit mode" on x64 or Windows 11 on Arm, +; meaning it should use the native 64-bit Program Files directory and +; the 64-bit view of the registry. +ArchitecturesInstallIn64BitMode=x64compatible +DefaultGroupName={#MyAppName} +AllowNoIcons=yes +LicenseFile=LICENSE +InfoAfterFile=README.md +; Uncomment the following line to run in non administrative install mode (install for current user only). +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog +OutputDir=. +OutputBaseFilename=pvm-setup +SolidCompression=yes +WizardStyle=modern + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Files] +Source: "src\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[Dirs] +Name: "{app}\bin" +Name: "{app}\versions" + +[UninstallDelete] +Type: filesandordirs; Name: "{app}" + +[Code] +var + path: string; + +procedure InitializePath(); +begin + if RegQueryStringValue(HKEY_CURRENT_USER, 'Environment', 'Path', path) then + begin + if Pos(ExpandConstant('{app}'), path) = 0 then + begin + // Check if PATH is empty + if path <> '' then + path := path + ';'; + + // Add the app directory and bin directory to the PATH + path := path + ExpandConstant('{app}') + ';' + ExpandConstant('{app}') + '\bin'; + + // Remove extra semicolons (e.g., ;; becomes ; or ;;; becomes ;) + StringChangeEx(path, ';;', ';', True); + + // Remove any leading semicolon at the start of the path if present + if (Pos(';', path) = 1) then + path := Copy(path, 2, Length(path) - 1); + + // Ensure there's no trailing semicolon at the end of the path + if (Pos(';', path) = Length(path)) then + path := Copy(path, 1, Length(path) - 1); + + // Write the added path back to the registry + RegWriteExpandStringValue(HKEY_CURRENT_USER, 'Environment', 'Path', path); + end; + end + else + begin + MsgBox('Unable to read PATH environment variable.', mbError, MB_OK); + end; +end; + +procedure RemovePath(); +begin + if RegQueryStringValue(HKEY_CURRENT_USER, 'Environment', 'Path', path) then + begin + // Remove the app directory and bin directory from the PATH + StringChangeEx(path, ExpandConstant('{app}') + ';', '', True); + StringChangeEx(path, ExpandConstant('{app}') + '\bin' + ';', '', True); + + // Remove extra semicolons (e.g., ;; becomes ; or ;;; becomes ;) + StringChangeEx(path, ';;', ';', True); + + // Remove any leading semicolon at the start of the path if present + if (Pos(';', path) = 1) then + path := Copy(path, 2, Length(path) - 1); + + // Ensure there's no trailing semicolon at the end of the path + if (Pos(';', path) = Length(path)) then + path := Copy(path, 1, Length(path) - 1); + + // Write the cleaned path back to the registry + RegWriteExpandStringValue(HKEY_CURRENT_USER, 'Environment', 'Path', path); + end + else + begin + MsgBox('Unable to read PATH environment variable.', mbError, MB_OK); + end; +end; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + if CurStep = ssPostInstall then + begin + InitializePath(); + end +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if CurUninstallStep = usUninstall then + begin + RemovePath(); + end; +end; + + diff --git a/commands/help.go b/src/commands/help.go similarity index 85% rename from commands/help.go rename to src/commands/help.go index 9e30cf7..92998b9 100644 --- a/commands/help.go +++ b/src/commands/help.go @@ -7,7 +7,7 @@ import ( func Help(notFoundError bool) { theme.Title("pvm: PHP Version Manager") - theme.Info("Version 1.2.0") + theme.Info("Version 1.3.0") if notFoundError { theme.Error("Command not found") @@ -16,8 +16,8 @@ func Help(notFoundError bool) { fmt.Println("Available Commands:") fmt.Println(" help") fmt.Println(" install") - fmt.Println(" list") + fmt.Println(" uninstall") fmt.Println(" list-remote") - fmt.Println(" path") + fmt.Println(" list") fmt.Println(" use") } diff --git a/commands/install.go b/src/commands/install.go similarity index 93% rename from commands/install.go rename to src/commands/install.go index e7f3dfc..3ce087b 100644 --- a/commands/install.go +++ b/src/commands/install.go @@ -51,11 +51,17 @@ func Install(args []string) { desiredMinorVersion := desiredVersionNumbers.Minor desiredPatchVersion := desiredVersionNumbers.Patch - versions, err := common.RetrievePHPVersions() + latestVersions, err := common.RetrievePHPVersions("https://windows.php.net/downloads/releases/") + if err != nil { + log.Fatalln(err) + } + archivesVersions, err := common.RetrievePHPVersions("https://windows.php.net/downloads/releases/archives/") if err != nil { log.Fatalln(err) } + versions := append(latestVersions, archivesVersions...) + // find desired version var desiredVersion common.Version @@ -78,21 +84,17 @@ func Install(args []string) { fmt.Printf("Installing PHP %s\n", desiredVersion) - homeDir, err := os.UserHomeDir() + // get current dir + currentDir, err := os.Executable() if err != nil { log.Fatalln(err) } - // check if .pvm folder exists - pvmPath := filepath.Join(homeDir, ".pvm") - if _, err := os.Stat(pvmPath); os.IsNotExist(err) { - theme.Info("Creating .pvm folder in home directory") - os.Mkdir(pvmPath, 0755) - } + fullDir := filepath.Dir(currentDir) // check if .pvm/versions folder exists - versionsPath := filepath.Join(pvmPath, "versions") + versionsPath := filepath.Join(fullDir, "versions") if _, err := os.Stat(versionsPath); os.IsNotExist(err) { theme.Info("Creating .pvm/versions folder in home directory") os.Mkdir(versionsPath, 0755) diff --git a/commands/list-remote.go b/src/commands/list-remote.go similarity index 61% rename from commands/list-remote.go rename to src/commands/list-remote.go index b825bd8..c8218b5 100644 --- a/commands/list-remote.go +++ b/src/commands/list-remote.go @@ -10,7 +10,16 @@ import ( ) func ListRemote() { - versions, err := common.RetrievePHPVersions() + latestVersions, err := common.RetrievePHPVersions("https://windows.php.net/downloads/releases/") + if err != nil { + log.Fatalln(err) + } + archivesVersions, err := common.RetrievePHPVersions("https://windows.php.net/downloads/releases/archives/") + if err != nil { + log.Fatalln(err) + } + + versions := append(latestVersions, archivesVersions...) if err != nil { log.Fatalln(err) } diff --git a/commands/list.go b/src/commands/list.go similarity index 99% rename from commands/list.go rename to src/commands/list.go index 22d3891..272ad11 100644 --- a/commands/list.go +++ b/src/commands/list.go @@ -3,7 +3,6 @@ package commands import ( "hjbdev/pvm/common" "hjbdev/pvm/theme" - "github.com/fatih/color" ) diff --git a/src/commands/uninstall.go b/src/commands/uninstall.go new file mode 100644 index 0000000..4defe92 --- /dev/null +++ b/src/commands/uninstall.go @@ -0,0 +1,138 @@ +package commands + +import ( + "fmt" + "hjbdev/pvm/common" + "hjbdev/pvm/theme" + "log" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func Uninstall(args []string) { + threadSafe := true + + if len(args) < 1 { + theme.Error("You must specify a version to uninstall.") + return + } + + if len(args) > 1 { + if args[1] == "nts" { + threadSafe = false + } + } + + // get current dir + currentDir, err := os.Executable() + + if err != nil { + log.Fatalln(err) + return + } + + fullDir := filepath.Dir(currentDir) + + // check if .pvm folder exists + if _, err := os.Stat(filepath.Join(fullDir)); os.IsNotExist(err) { + theme.Error("No PHP versions installed") + return + } + + // check if .pvm/versions folder exists + if _, err := os.Stat(filepath.Join(fullDir, "versions")); os.IsNotExist(err) { + theme.Error("No PHP versions installed") + return + } + + // check if .pvm/bin folder exists + binPath := filepath.Join(fullDir, "bin") + if _, err := os.Stat(binPath); os.IsNotExist(err) { + os.Mkdir(binPath, 0755) + } + + // get all folders in .pvm/versions + versions, err := os.ReadDir(filepath.Join(fullDir, "versions")) + if err != nil { + log.Fatalln(err) + } + + var selectedVersion *common.VersionMeta + // loop over all found installed versions + for i, version := range versions { + safe := true + if strings.Contains(version.Name(), "nts") || strings.Contains(version.Name(), "NTS") { + safe = false + } + foundVersion := common.ComputeVersion(version.Name(), safe, "") + if threadSafe == foundVersion.ThreadSafe && strings.HasPrefix(foundVersion.String(), args[0]) { + selectedVersion = &common.VersionMeta{ + Number: foundVersion, + Folder: versions[i], + } + } + } + + if selectedVersion == nil { + theme.Error("The specified version is not installed.") + return + } + + // remove old php bat script + batPath := filepath.Join(binPath, "php.bat") + if _, err := os.Stat(batPath); err == nil { + os.Remove(batPath) + } + + // remove the old php sh script + shPath := filepath.Join(binPath, "php") + if _, err := os.Stat(shPath); err == nil { + os.Remove(shPath) + } + + // remove old php-cgi bat script + batPathCGI := filepath.Join(binPath, "php-cgi.bat") + if _, err := os.Stat(batPathCGI); err == nil { + os.Remove(batPathCGI) + } + + // remove old php-cgi sh script + shPathCGI := filepath.Join(binPath, "php-cgi") + if _, err := os.Stat(shPathCGI); err == nil { + os.Remove(shPathCGI) + } + + // remove old composer bat script + batPathComposer := filepath.Join(binPath, "composer.bat") + if _, err := os.Stat(batPathComposer); err == nil { + os.Remove(batPathComposer) + } + + // remove the old composer sh script + shPathComposer := filepath.Join(binPath, "composer") + if _, err := os.Stat(shPathComposer); err == nil { + os.Remove(shPathComposer) + } + + // create directory link to ext directory + extensionLinkPath := filepath.Join(binPath, "ext") + + // delete the old link first if it exists + if _, err := os.Stat(extensionLinkPath); err == nil { + cmd := exec.Command("cmd", "/C", "rmdir", extensionLinkPath) + _, err := cmd.Output() + if err != nil { + log.Fatalln("Error deleting ext directory directory link:", err) + return + } + } + + versionFolderPath := filepath.Join(fullDir, "versions", selectedVersion.Folder.Name()) + if _, err := os.Stat(versionFolderPath); err == nil { + os.RemoveAll(versionFolderPath) + } + + theme.Success(fmt.Sprintf("Uninstalling PHP %s", selectedVersion.Number)) +} diff --git a/commands/use.go b/src/commands/use.go similarity index 87% rename from commands/use.go rename to src/commands/use.go index 136497f..69b21ed 100644 --- a/commands/use.go +++ b/src/commands/use.go @@ -25,38 +25,41 @@ func Use(args []string) { } } - // get users home dir - homeDir, err := os.UserHomeDir() + // get current dir + currentDir, err := os.Executable() if err != nil { log.Fatalln(err) + return } + fullDir := filepath.Dir(currentDir) + // check if .pvm folder exists - if _, err := os.Stat(filepath.Join(homeDir, ".pvm")); os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(fullDir)); os.IsNotExist(err) { theme.Error("No PHP versions installed") return } // check if .pvm/versions folder exists - if _, err := os.Stat(filepath.Join(homeDir, ".pvm", "versions")); os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(fullDir, "versions")); os.IsNotExist(err) { theme.Error("No PHP versions installed") return } // check if .pvm/bin folder exists - binPath := filepath.Join(homeDir, ".pvm", "bin") + binPath := filepath.Join(fullDir, "bin") if _, err := os.Stat(binPath); os.IsNotExist(err) { os.Mkdir(binPath, 0755) } // get all folders in .pvm/versions - versions, err := os.ReadDir(filepath.Join(homeDir, ".pvm", "versions")) + versions, err := os.ReadDir(filepath.Join(fullDir, "versions")) if err != nil { log.Fatalln(err) } - var selectedVersion *versionMeta + var selectedVersion *common.VersionMeta // loop over all found installed versions for i, version := range versions { safe := true @@ -65,9 +68,9 @@ func Use(args []string) { } foundVersion := common.ComputeVersion(version.Name(), safe, "") if threadSafe == foundVersion.ThreadSafe && strings.HasPrefix(foundVersion.String(), args[0]) { - selectedVersion = &versionMeta{ - number: foundVersion, - folder: versions[i], + selectedVersion = &common.VersionMeta{ + Number: foundVersion, + Folder: versions[i], } } } @@ -79,9 +82,9 @@ func Use(args []string) { requestedVersion := common.ComputeVersion(args[0], threadSafe, "") if requestedVersion.Minor == -1 { - theme.Warning(fmt.Sprintf("No minor version specified, assumed newest minor version %s.", selectedVersion.number.String())) + theme.Warning(fmt.Sprintf("No minor version specified, assumed newest minor version %s.", selectedVersion.Number.String())) } else if requestedVersion.Patch == -1 { - theme.Warning(fmt.Sprintf("No patch version specified, assumed newest patch version %s.", selectedVersion.number.String())) + theme.Warning(fmt.Sprintf("No patch version specified, assumed newest patch version %s.", selectedVersion.Number.String())) } // remove old php bat script @@ -120,7 +123,7 @@ func Use(args []string) { os.Remove(shPathComposer) } - versionFolderPath := filepath.Join(homeDir, ".pvm", "versions", selectedVersion.folder.Name()) + versionFolderPath := filepath.Join(fullDir, "versions", selectedVersion.Folder.Name()) versionPath := filepath.Join(versionFolderPath, "php.exe") versionPathCGI := filepath.Join(versionFolderPath, "php-cgi.exe") composerPath := filepath.Join(versionFolderPath, "composer", "composer.phar") @@ -222,10 +225,5 @@ func Use(args []string) { } // end of ext directory link creation - theme.Success(fmt.Sprintf("Using PHP %s", selectedVersion.number)) -} - -type versionMeta struct { - number common.Version - folder os.DirEntry + theme.Success(fmt.Sprintf("Using PHP %s", selectedVersion.Number)) } diff --git a/commands/use_test.go b/src/commands/use_test.go similarity index 100% rename from commands/use_test.go rename to src/commands/use_test.go diff --git a/common/helpers.go b/src/common/helpers.go similarity index 91% rename from common/helpers.go rename to src/common/helpers.go index 202c744..3d1804e 100644 --- a/common/helpers.go +++ b/src/common/helpers.go @@ -22,6 +22,11 @@ type Version struct { ThreadSafe bool } +type VersionMeta struct { + Number Version + Folder os.DirEntry +} + func (v Version) Semantic() string { return fmt.Sprintf("%v.%v.%v", v.Major, v.Minor, v.Patch) } @@ -138,9 +143,8 @@ func SortVersions(input []Version) []Version { return input } -func RetrievePHPVersions() ([]Version, error) { - // perform get request to https://windows.php.net/downloads/releases/archives/ - resp, err := http.Get("https://windows.php.net/downloads/releases/archives/") +func RetrievePHPVersions(url string) ([]Version, error) { + resp, err := http.Get(url) if err != nil { return nil, err } @@ -206,21 +210,18 @@ func RetrievePHPVersions() ([]Version, error) { func RetrieveInstalledPHPVersions() ([]Version, error) { versions := make([]Version, 0) // get users home dir - homeDir, err := os.UserHomeDir() + // get current dir + currentDir, err := os.Executable() if err != nil { log.Fatalln(err) - return versions, err - } - - // check if .pvm folder exists - pvmPath := filepath.Join(homeDir, ".pvm") - if _, err := os.Stat(pvmPath); os.IsNotExist(err) { return versions, errors.New("no PHP versions installed") } + fullDir := filepath.Dir(currentDir) + // check if .pvm/versions folder exists - versionsPath := filepath.Join(pvmPath, "versions") + versionsPath := filepath.Join(fullDir, "versions") if _, err := os.Stat(versionsPath); os.IsNotExist(err) { return versions, errors.New("no PHP versions installed") } diff --git a/common/helpers_test.go b/src/common/helpers_test.go similarity index 100% rename from common/helpers_test.go rename to src/common/helpers_test.go diff --git a/go.mod b/src/go.mod similarity index 100% rename from go.mod rename to src/go.mod diff --git a/go.sum b/src/go.sum similarity index 100% rename from go.sum rename to src/go.sum diff --git a/main.go b/src/main.go similarity index 93% rename from main.go rename to src/main.go index 13f6161..99d5aaf 100644 --- a/main.go +++ b/src/main.go @@ -35,10 +35,10 @@ func main() { commands.List() case "ls remote", "ls-remote", "list remote", "list-remote": commands.ListRemote() - case "path": - commands.Path() case "install": commands.Install(args) + case "uninstall": + commands.Uninstall(args[1:]) case "use": commands.Use(args[1:]) default: diff --git a/src/pvm.exe b/src/pvm.exe new file mode 100644 index 0000000..d6e58e6 Binary files /dev/null and b/src/pvm.exe differ diff --git a/theme/theme.go b/src/theme/theme.go similarity index 100% rename from theme/theme.go rename to src/theme/theme.go