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..137352e 100644 --- a/README.md +++ b/README.md @@ -9,40 +9,69 @@ This package has a much more niche use case than nvm does. When developing on Wi 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 + +### 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. -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 + - __Will list out all the available PHP versions you have installed.__ + +``` +pvm list-remote +``` +- __Will list available PHP versions from remote repositories.__ ``` -pvm path +pvm install [nts] [path] ``` -Will tell you what to put in your Path variable. +- __Will install specified PHP vesrion.__ +- If the minor and patch versions are not specified, the newest available versions will be automatically selected. +- [nts] (optional): Install a non-thread-safe version. +- [path] (optional): Specify a custom installation path. ``` -pvm use 8.2.9 +pvm use ``` -> [!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 specified PHP vesrion.__ +- Using a version: Specify at least the `major.minor` version. If the `.patch` version is omitted, the newest available patch version will be selected automatically. +- Using a path: Specify the path to the PHP executable to set it as the active PHP version. -Will switch your currently active PHP version to PHP 8.2.9 +``` +pvm uninstall +``` +- __Will uninstall specified PHP vesrion.__ +- The `uninstall` command requires the full `major.minor.patch` version to be specified. +- This command can uninstall only php installed by pvm. ``` -pvm install 8.2 +pvm add ``` -> [!NOTE] -> The install command will automatically determine the newest minor/patch versions if they are not specified +- __Adds a custom PHP version by specifying the path to a PHP executable.__ -Will install PHP 8.2 at the latest patch. +``` +pvm remove +``` +- __Removes a custom PHP version by specifying the path to the previously added PHP executable.__ + +--- ## 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: @@ -50,9 +79,18 @@ You'll be able to invoke composer from terminal as it is intended: 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 +``` + +To build pvm-setup.exe use: + +```shell +iscc "pvm-setup.iss" +``` diff --git a/commands/help.go b/commands/help.go deleted file mode 100644 index 9e30cf7..0000000 --- a/commands/help.go +++ /dev/null @@ -1,23 +0,0 @@ -package commands - -import ( - "fmt" - "hjbdev/pvm/theme" -) - -func Help(notFoundError bool) { - theme.Title("pvm: PHP Version Manager") - theme.Info("Version 1.2.0") - - if notFoundError { - theme.Error("Command not found") - } - - fmt.Println("Available Commands:") - fmt.Println(" help") - fmt.Println(" install") - fmt.Println(" list") - fmt.Println(" list-remote") - fmt.Println(" path") - fmt.Println(" use") -} 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/common/helpers.go b/common/helpers.go deleted file mode 100644 index 202c744..0000000 --- a/common/helpers.go +++ /dev/null @@ -1,245 +0,0 @@ -package common - -import ( - "errors" - "fmt" - "io" - "log" - "net/http" - "os" - "path/filepath" - "regexp" - "sort" - "strconv" - "strings" -) - -type Version struct { - Major int - Minor int - Patch int - Url string - ThreadSafe bool -} - -func (v Version) Semantic() string { - return fmt.Sprintf("%v.%v.%v", v.Major, v.Minor, v.Patch) -} - -func (v Version) StringShort() string { - semantic := v.Semantic() - if v.ThreadSafe { - return semantic - } - return semantic + " nts" -} - -func (v Version) String() string { - semantic := v.Semantic() - if v.ThreadSafe { - return semantic + " thread safe" - } - return semantic + " non-thread safe" -} - -func ComputeVersion(text string, safe bool, url string) Version { - versionRe := regexp.MustCompile(`([0-9]{1,3})(?:.([0-9]{1,3}))?(?:.([0-9]{1,3}))?`) - matches := versionRe.FindAllStringSubmatch(text, -1) - if len(matches) == 0 { - return Version{} - } - - major, err := strconv.Atoi(matches[0][1]) - if err != nil { - major = -1 - } - - minor, err := strconv.Atoi(matches[0][2]) - if err != nil { - minor = -1 - } - - patch, err := strconv.Atoi(matches[0][3]) - if err != nil { - patch = -1 - } - - return Version{ - Major: major, - Minor: minor, - Patch: patch, - ThreadSafe: safe, - Url: url, - } -} - -func (v Version) Compare(o Version) int { - if v.Major == -1 || o.Major == -1 { - return 0 - } - if v.Major != o.Major { - if v.Major < o.Major { - return -1 - } - return 1 - } - - if v.Minor == -1 || o.Minor == -1 { - return 0 - } - if v.Minor != o.Minor { - if v.Minor < o.Minor { - return -1 - } - return 1 - } - - if v.Patch == -1 || o.Patch == -1 { - return 0 - } - if v.Patch != o.Patch { - if v.Patch < o.Patch { - return -1 - } - return 1 - } - - return 0 -} - -func (v Version) CompareThreadSafe(o Version) int { - result := v.Compare(o) - if result != 0 { - return result - } - - if v.ThreadSafe == o.ThreadSafe { - return 0 - } - - if v.ThreadSafe { - return -1 - } - return 1 -} - -func (v Version) LessThan(o Version) bool { - return v.CompareThreadSafe(o) == -1 -} - -func (v Version) Same(o Version) bool { - return v.CompareThreadSafe(o) == 0 -} - -func SortVersions(input []Version) []Version { - sort.SliceStable(input, func(i, j int) bool { - return input[i].LessThan(input[j]) - }) - 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/") - if err != nil { - return nil, err - } - // We Read the response body on the line below. - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - // Convert the body to type string - sb := string(body) - - // regex match - re := regexp.MustCompile(`([a-zA-Z0-9./-]+)`) - matches := re.FindAllStringSubmatch(sb, -1) - - versions := make([]Version, 0) - - for _, match := range matches { - url := match[1] - name := match[2] - - // check if name starts with "php-devel-pack-" - if name != "" && len(name) > 15 && name[:15] == "php-devel-pack-" { - continue - } - // check if name starts with "php-debug-pack-" - if name != "" && len(name) > 15 && name[:15] == "php-debug-pack-" { - continue - } - // check if name starts with "php-test-pack-" - if name != "" && len(name) > 15 && name[:14] == "php-test-pack-" { - continue - } - - // check if name contains "src" - if name != "" && strings.Contains(name, "src") { - continue - } - - // check if name does not end in zip - if name != "" && !strings.HasSuffix(name, ".zip") { - continue - } - - threadSafe := true - - // check if name contains "nts" or "NTS" - if name != "" && (strings.Contains(name, "nts") || strings.Contains(name, "NTS")) { - threadSafe = false - } - - // make sure we only get x64 versions - if name != "" && !strings.Contains(name, "x64") { - continue - } - - // regex match name and push to versions - versions = append(versions, ComputeVersion(name, threadSafe, url)) - } - return versions, nil -} - -func RetrieveInstalledPHPVersions() ([]Version, error) { - versions := make([]Version, 0) - // get users home dir - homeDir, err := os.UserHomeDir() - - 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") - } - - // check if .pvm/versions folder exists - versionsPath := filepath.Join(pvmPath, "versions") - if _, err := os.Stat(versionsPath); os.IsNotExist(err) { - return versions, errors.New("no PHP versions installed") - } - - // get all folders in .pvm/versions - folders, err := os.ReadDir(versionsPath) - if err != nil { - return versions, err - } - - for _, folder := range folders { - folderName := folder.Name() - safe := true - if strings.Contains(folderName, "nts") || strings.Contains(folderName, "NTS") { - safe = false - } - - versions = append(versions, ComputeVersion(folderName, safe, "")) - } - SortVersions(versions) - return versions, nil -} diff --git a/pvm-setup.exe b/pvm-setup.exe new file mode 100644 index 0000000..d68275f 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..84d122d --- /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.1" +#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/src/commands/add.go b/src/commands/add.go new file mode 100644 index 0000000..47fa00d --- /dev/null +++ b/src/commands/add.go @@ -0,0 +1,55 @@ +package commands + +import ( + "fmt" + "hjbdev/pvm/common" + "hjbdev/pvm/theme" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +func Add(args []string) { + if len(args) < 1 { + theme.Error("You must specify a path of external php.") + return + } + + addPath := args[0] + + // Verify that the specified path exists and contains a php executable + phpPath := filepath.Join(addPath, "php.exe") + if _, err := os.Stat(phpPath); os.IsNotExist(err) { + theme.Error(fmt.Sprintf("The file php.exe was not found in the specified path: %s", addPath)) + return + } + + // Run the "php -v" command to get the PHP version + cmd := exec.Command(phpPath, "-v") + output, err := cmd.CombinedOutput() + if err != nil { + theme.Error(fmt.Sprintf("failed to execute php -v: %v", err)) + } + + // Parse the version from the output + // The output usually looks like: PHP 7.4.3 (cli) (built: Mar 4 2020 22:44:12) (ZTS) + versionPattern := `PHP\s+([0-9]+\.[0-9]+\.[0-9]+)` + re := regexp.MustCompile(versionPattern) + matches := re.FindStringSubmatch(string(output)) + if len(matches) < 2 { + theme.Error("failed to parse PHP version from output") + } + + // Determine if the version is thread-safe (TS) or non-thread-safe (NTS) + threadSafe := "" + if strings.Contains(strings.ToLower(phpPath), "nts") { + threadSafe = " nts" + } + + // Add to versions.json + common.AddToVersionsJson(addPath, matches[1]+threadSafe, "external") + + theme.Success(fmt.Sprintf("Finished add PHP %s", addPath)) +} diff --git a/src/commands/help.go b/src/commands/help.go new file mode 100644 index 0000000..fea9876 --- /dev/null +++ b/src/commands/help.go @@ -0,0 +1,40 @@ +package commands + +import ( + "fmt" + "hjbdev/pvm/theme" +) + +func Help(notFoundError bool) { + theme.Title("pvm: PHP Version Manager") + theme.Info("Version 1.3.1") + + if notFoundError { + theme.Error("Error: Command not found.") + fmt.Println() + } + + fmt.Println("Usage:") + fmt.Println(" pvm [command] [options]") + fmt.Println() + fmt.Println("Available Commands:") + + commands := map[string]string{ + "help": "Display help information about pvm commands.", + "install": "Install a specific PHP version.", + "uninstall": "Uninstall a specific PHP version.", + "list-remote": "List available PHP versions from remote repositories.", + "list": "List installed PHP versions.", + "use": "Switch to a specific installed PHP version.", + "add": "Add a custom PHP version source.", + "remove": "Remove a custom PHP version source.", + } + + for cmd, desc := range commands { + fmt.Printf(" %-12s %s\n", cmd, desc) + } + + fmt.Println() + fmt.Println("For detailed usage of a specific command, use:") + fmt.Println(" pvm help [command]") +} diff --git a/commands/install.go b/src/commands/install.go similarity index 50% rename from commands/install.go rename to src/commands/install.go index e7f3dfc..a374716 100644 --- a/commands/install.go +++ b/src/commands/install.go @@ -14,98 +14,99 @@ import ( ) func Install(args []string) { - if len(args) < 2 { - theme.Error("You must specify a version to install.") - return + desireThreadSafe := true + installPath := "" // This will store the install path + var requestedVersion string + + if len(args) > 0 { + requestedVersion = args[0] + } else { + requestedVersion = "" + theme.Warning("Latest version will be installed") } - desireThreadSafe := true - if len(args) > 2 { - if args[2] == "nts" { - desireThreadSafe = false + if len(args) > 1 { + // Process additional arguments (install path or "nts" flag) + for _, arg := range args[1:] { + if arg == "nts" { + desireThreadSafe = false + } else { + installPath = arg + } } } + // Print the selected thread safety mode var threadSafeString string if desireThreadSafe { threadSafeString = "thread safe" - } else { - threadSafeString = "non-thread safe" - } - - if desireThreadSafe { theme.Warning("Thread safe version will be installed") } else { + threadSafeString = "non-thread safe" theme.Warning("Non-thread safe version will be installed") } - desiredVersionNumbers := common.ComputeVersion(args[1], desireThreadSafe, "") - - if desiredVersionNumbers == (common.Version{}) { - theme.Error("Invalid version specified") - return - } - - // Get the desired version from the user input + desiredVersionNumbers := common.ComputeVersion(requestedVersion, desireThreadSafe, "") + // Get the desired version components desiredMajorVersion := desiredVersionNumbers.Major 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) } - - // find desired version - var desiredVersion common.Version - - if desiredMajorVersion > -1 && desiredMinorVersion > -1 && desiredPatchVersion > -1 { - desiredVersion = FindExactVersion(versions, desiredMajorVersion, desiredMinorVersion, desiredPatchVersion, desireThreadSafe) + archivesVersions, err := common.RetrievePHPVersions("https://windows.php.net/downloads/releases/archives/") + if err != nil { + log.Fatalln(err) } - if desiredMajorVersion > -1 && desiredMinorVersion > -1 && desiredPatchVersion == -1 { - desiredVersion = FindLatestPatch(versions, desiredMajorVersion, desiredMinorVersion, desireThreadSafe) - } + versions := append(latestVersions, archivesVersions...) - if desiredMajorVersion > -1 && desiredMinorVersion == -1 && desiredPatchVersion == -1 { - desiredVersion = FindLatestMinor(versions, desiredMajorVersion, desireThreadSafe) - } + // find desired version + desiredVersion := FindVersion(versions, desiredMajorVersion, desiredMinorVersion, desiredPatchVersion, desireThreadSafe) if desiredVersion == (common.Version{}) { - theme.Error(fmt.Sprintf("Could not find the desired version: %s %s", args[1], threadSafeString)) + theme.Error(fmt.Sprintf("Could not find the desired version: %s %s", requestedVersion, threadSafeString)) return } - fmt.Printf("Installing PHP %s\n", desiredVersion) + theme.Title(fmt.Sprintf("Installing PHP %s", 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") - if _, err := os.Stat(versionsPath); os.IsNotExist(err) { - theme.Info("Creating .pvm/versions folder in home directory") - os.Mkdir(versionsPath, 0755) + var versionsPath string + // If no path is provided, default to current directory + if installPath == "" { + // Check if the install path folder exists + versionsPath = filepath.Join(fullDir, "versions") + if _, err := os.Stat(versionsPath); os.IsNotExist(err) { + theme.Info("Creating versions folder in the specified path") + err := os.Mkdir(versionsPath, 0755) + if err != nil { + theme.Error(fmt.Sprintf("Failed to create the versions directory: %v", err)) + return + } + } + } else { + versionsPath = installPath } theme.Info("Downloading") - // zip filename from url + // Zip filename from URL zipUrl := "https://windows.php.net" + desiredVersion.Url zipFileName := strings.Split(desiredVersion.Url, "/")[len(strings.Split(desiredVersion.Url, "/"))-1] zipPath := filepath.Join(versionsPath, zipFileName) - // check if zip already exists + // Check if zip already exists if _, err := os.Stat(zipPath); err == nil { theme.Error(fmt.Sprintf("PHP %s already exists", desiredVersion)) return @@ -113,23 +114,23 @@ func Install(args []string) { // Get the data if _, err := downloadFile(zipUrl, zipPath); err != nil { - log.Fatalf("Error while downloading PHP from %v: %v!", zipUrl, err) + theme.Error(fmt.Sprintf("Error while downloading PHP from %v: %v!", zipUrl, err)) } - // extract the zip file to a folder + // Extract the zip file to a folder phpFolder := strings.Replace(zipFileName, ".zip", "", -1) phpPath := filepath.Join(versionsPath, phpFolder) theme.Info("Unzipping") Unzip(zipPath, phpPath) - // remove the zip file + // Remove the zip file theme.Info("Cleaning up") err = os.Remove(zipPath) if err != nil { - log.Fatalln(err) + theme.Error("Error while cleaning up zip file") } - // install composer + // Install composer composerFolderPath := filepath.Join(phpPath, "composer") if _, err := os.Stat(composerFolderPath); os.IsNotExist(err) { theme.Info("Creating composer folder") @@ -143,9 +144,12 @@ func Install(args []string) { } if _, err := downloadFile(composerUrl, composerPath); err != nil { - log.Fatalf("Error while downloading Composer from %v: %v!", composerUrl, err) + theme.Error(fmt.Sprintf("Error while downloading Composer from %v: %v!", composerUrl, err)) } + // Add to versions.json + common.AddToVersionsJson(phpPath, desiredVersion.String(), "pvm") + theme.Success(fmt.Sprintf("Finished installing PHP %s", desiredVersion)) } @@ -213,53 +217,63 @@ func Unzip(src, dest string) error { return nil } -func FindExactVersion(versions []common.Version, major int, minor int, patch int, threadSafe bool) common.Version { - for _, version := range versions { - if version.ThreadSafe != threadSafe { - continue - } - if version.Major == major && version.Minor == minor && version.Patch == patch { - return version +func FindVersion(versions []common.Version, major, minor, patch int, threadSafe bool) common.Version { + var latestMajor, latestMinor, latestPatch int = -1, -1, -1 + + // Case 1: All are -1 → Find the latest available version (highest major, minor, patch) + if major == -1 && minor == -1 && patch == -1 { + for _, version := range versions { + if version.ThreadSafe != threadSafe { + continue + } + if version.Major > latestMajor || + (version.Major == latestMajor && version.Minor > latestMinor) || + (version.Major == latestMajor && version.Minor == latestMinor && version.Patch > latestPatch) { + latestMajor, latestMinor, latestPatch = version.Major, version.Minor, version.Patch + } } + major, minor, patch = latestMajor, latestMinor, latestPatch } - return common.Version{} -} - -func FindLatestPatch(versions []common.Version, major int, minor int, threadSafe bool) common.Version { - latestPatch := common.Version{} - - for _, version := range versions { - if version.ThreadSafe != threadSafe { - continue - } - if version.Major == major && version.Minor == minor { - if latestPatch.Patch == -1 || version.Patch > latestPatch.Patch { - latestPatch = version + // Case 2: Minor and Patch are -1 → Find the latest minor and patch for the given major + if minor == -1 && patch == -1 { + for _, version := range versions { + if version.ThreadSafe != threadSafe || version.Major != major { + continue + } + if version.Minor > latestMinor || + (version.Minor == latestMinor && version.Patch > latestPatch) { + latestMinor, latestPatch = version.Minor, version.Patch } } + minor, patch = latestMinor, latestPatch } - return latestPatch -} - -func FindLatestMinor(versions []common.Version, major int, threadSafe bool) common.Version { - latestMinor := common.Version{} + // Case 3: Patch is -1 → Find the latest patch for the given major and minor + if patch == -1 { + for _, version := range versions { + if version.ThreadSafe != threadSafe || version.Major != major || version.Minor != minor { + continue + } + if version.Patch > latestPatch { + latestPatch = version.Patch + } + } + patch = latestPatch + } + // Case 4: All values are provided → Look for an exact match for _, version := range versions { - if version.ThreadSafe != threadSafe { - continue - } - if version.Major == major { - if latestMinor.Minor == -1 || version.Minor > latestMinor.Minor { - if latestMinor.Patch == -1 || version.Patch > latestMinor.Patch { - latestMinor = version - } - } + if version.ThreadSafe == threadSafe && + version.Major == major && + version.Minor == minor && + version.Patch == patch { + return version } } - return latestMinor + // No matching version found + return common.Version{} } func downloadFile(fileUrl string, filePath string) (bool, error) { diff --git a/commands/list-remote.go b/src/commands/list-remote.go similarity index 64% rename from commands/list-remote.go rename to src/commands/list-remote.go index b825bd8..96b5e52 100644 --- a/commands/list-remote.go +++ b/src/commands/list-remote.go @@ -5,15 +5,20 @@ import ( "hjbdev/pvm/theme" "log" "slices" - "github.com/fatih/color" ) 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...) common.SortVersions(versions) 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/remove.go b/src/commands/remove.go new file mode 100644 index 0000000..c24db62 --- /dev/null +++ b/src/commands/remove.go @@ -0,0 +1,21 @@ +package commands + +import ( + "fmt" + "hjbdev/pvm/common" + "hjbdev/pvm/theme" +) + +func Remove(args []string) { + if len(args) < 1 { + theme.Error("You must specify a path of external php.") + return + } + + removePath := args[0] + + // Add to versions.json + common.RemoveFromVersionJson(removePath) + + theme.Success(fmt.Sprintf("Finished remove PHP %s", removePath)) +} diff --git a/src/commands/uninstall.go b/src/commands/uninstall.go new file mode 100644 index 0000000..6cbf629 --- /dev/null +++ b/src/commands/uninstall.go @@ -0,0 +1,121 @@ +package commands + +import ( + "fmt" + "hjbdev/pvm/common" + "hjbdev/pvm/theme" + "log" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Uninstall will remove the specified PHP version and its entry from versions.json. +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 directory + 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].Name(), + } + } + } + + if selectedVersion == nil { + theme.Error("The specified version is not installed.") + return + } + + // Remove php bat and sh scripts + scripts := []string{"php.bat", "php", "php-cgi.bat", "php-cgi", "composer.bat", "composer"} + + for _, script := range scripts { + scriptPath := filepath.Join(binPath, script) + if _, err := os.Stat(scriptPath); err == nil { + if err := os.Remove(scriptPath); err != nil { + theme.Error(fmt.Sprintf("Error removing script %s: %v", script, err)) + return + } + } + } + + // 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 { + theme.Error(fmt.Sprintf("Error deleting ext directory link: %v", err)) + return + } + } + + // Remove the version folder from versions directory + versionFolderPath := filepath.Join(fullDir, "versions", selectedVersion.Folder) + if _, err := os.Stat(versionFolderPath); err == nil { + os.RemoveAll(versionFolderPath) + } + + // Remove the version entry from versions.json + if err := common.RemoveFromVersionJson(versionFolderPath); err != nil { + theme.Error(fmt.Sprintf("Error removing version from versions.json: %v", err)) + } + + theme.Success(fmt.Sprintf("Finished uninstalling PHP %s", selectedVersion.Number)) +} diff --git a/commands/use.go b/src/commands/use.go similarity index 56% rename from commands/use.go rename to src/commands/use.go index 136497f..83f77cf 100644 --- a/commands/use.go +++ b/src/commands/use.go @@ -1,10 +1,10 @@ package commands import ( + "encoding/json" "fmt" "hjbdev/pvm/common" "hjbdev/pvm/theme" - "log" "os" "os/exec" "path/filepath" @@ -15,73 +15,84 @@ func Use(args []string) { threadSafe := true if len(args) < 1 { - theme.Error("You must specify a version to use.") + theme.Error("You must specify a version or path to use.") return } - if len(args) > 1 { - if args[1] == "nts" { - threadSafe = false - } + // If there are two arguments, check for "nts" flag + if len(args) > 1 && args[1] == "nts" { + threadSafe = false } - // get users home dir - homeDir, err := os.UserHomeDir() - + // Get current dir + currentDir, err := os.Executable() if err != nil { - log.Fatalln(err) + theme.Error(fmt.Sprintln(err)) + return } - // check if .pvm folder exists - if _, err := os.Stat(filepath.Join(homeDir, ".pvm")); os.IsNotExist(err) { + 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(homeDir, ".pvm", "versions")); os.IsNotExist(err) { + // 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(homeDir, ".pvm", "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")) - if err != nil { - log.Fatalln(err) + // Read versions from versions.json + versions := readVersionsFromJson(fullDir) + if versions == nil { + theme.Error("Error reading versions.json") + return } - var selectedVersion *versionMeta + var metaVersions []common.VersionMeta // loop over all found installed versions - for i, version := range versions { + for _, version := range *versions { safe := true - if strings.Contains(version.Name(), "nts") || strings.Contains(version.Name(), "NTS") { + if strings.Contains(strings.ToLower(version.Version), "non ") { safe = false } - foundVersion := common.ComputeVersion(version.Name(), safe, "") + foundVersion := common.ComputeVersion(version.Version, safe, "") if threadSafe == foundVersion.ThreadSafe && strings.HasPrefix(foundVersion.String(), args[0]) { - selectedVersion = &versionMeta{ - number: foundVersion, - folder: versions[i], - } + metaVersions = append(metaVersions, common.VersionMeta{ + Number: foundVersion, + Folder: version.Path, + }) } } + var selectedVersion *common.VersionMeta + if len(args) == 1 { + // If a version is provided, prioritize it + selectedVersion = findVersionByName(metaVersions, args[0], threadSafe) + } else if len(args) > 1 { + // If path is provided, find the version by path + selectedVersion = findVersionByPath(metaVersions, args[0]) + } + if selectedVersion == nil { - theme.Error("The specified version is not installed.") + theme.Error("The specified version or path is not 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) + } + 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,10 +131,9 @@ func Use(args []string) { os.Remove(shPathComposer) } - versionFolderPath := filepath.Join(homeDir, ".pvm", "versions", selectedVersion.folder.Name()) - versionPath := filepath.Join(versionFolderPath, "php.exe") - versionPathCGI := filepath.Join(versionFolderPath, "php-cgi.exe") - composerPath := filepath.Join(versionFolderPath, "composer", "composer.phar") + versionPath := filepath.Join(selectedVersion.Folder, "php.exe") + versionPathCGI := filepath.Join(selectedVersion.Folder, "php-cgi.exe") + composerPath := filepath.Join(selectedVersion.Folder, "composer", "composer.phar") // create bat script for php batCommand := "@echo off \n" @@ -134,7 +144,7 @@ func Use(args []string) { err = os.WriteFile(batPath, []byte(batCommand), 0755) if err != nil { - log.Fatalln(err) + theme.Error(fmt.Sprintln(err)) } // create sh script for php @@ -145,7 +155,7 @@ func Use(args []string) { err = os.WriteFile(shPath, []byte(shCommand), 0755) if err != nil { - log.Fatalln(err) + theme.Error(fmt.Sprintln(err)) } // create bat script for php-cgi @@ -157,7 +167,7 @@ func Use(args []string) { err = os.WriteFile(batPathCGI, []byte(batCommandCGI), 0755) if err != nil { - log.Fatalln(err) + theme.Error(fmt.Sprintln(err)) } // create sh script for php-cgi @@ -168,7 +178,7 @@ func Use(args []string) { err = os.WriteFile(shPathCGI, []byte(shCommandCGI), 0755) if err != nil { - log.Fatalln(err) + theme.Error(fmt.Sprintln(err)) } // create bat script for composer @@ -181,7 +191,7 @@ func Use(args []string) { err = os.WriteFile(batPathComposer, []byte(batCommandComposer), 0755) if err != nil { - log.Fatalln(err) + theme.Error(fmt.Sprintln(err)) } // create sh script for php @@ -193,11 +203,11 @@ func Use(args []string) { err = os.WriteFile(shPathComposer, []byte(shCommandComposer), 0755) if err != nil { - log.Fatalln(err) + theme.Error(fmt.Sprintln(err)) } // create directory link to ext directory - extensionDirPath := filepath.Join(versionFolderPath, "ext") + extensionDirPath := filepath.Join(selectedVersion.Folder, "ext") extensionLinkPath := filepath.Join(binPath, "ext") // delete the old link first if it exists @@ -205,7 +215,7 @@ func Use(args []string) { cmd := exec.Command("cmd", "/C", "rmdir", extensionLinkPath) _, err := cmd.Output() if err != nil { - log.Fatalln("Error deleting ext directory directory link:", err) + theme.Error(fmt.Sprintf("Error deleting ext directory directory link: %v", err)) return } } @@ -215,17 +225,55 @@ func Use(args []string) { output, err := cmd.Output() if err != nil { - log.Fatalln("Error creating ext directory symlink:", err) + theme.Error(fmt.Sprintln("Error creating ext directory symlink: %v", err)) return } else { theme.Info(string(output)) } // end of ext directory link creation - theme.Success(fmt.Sprintf("Using PHP %s", selectedVersion.number)) + theme.Success(fmt.Sprintf("Using PHP %s", selectedVersion.Number)) +} + +// readVersionsFromJson reads and returns the versions from versions.json +func readVersionsFromJson(fullDir string) *[]common.VersionJson { + // Path to the versions.json file + jsonPath := filepath.Join(fullDir, "versions", "versions.json") + + // Read the existing versions from versions.json + data, err := os.ReadFile(jsonPath) + if err != nil { + return nil + } + + // Expecting the JSON content to be a string + var existingVersions []common.VersionJson + if err := json.Unmarshal(data, &existingVersions); err != nil { + return nil + } + + return &existingVersions +} + +// findVersionByName finds and returns a version by its name +func findVersionByName(versions []common.VersionMeta, versionName string, threadSafe bool) *common.VersionMeta { + for _, version := range versions { + // Match by version name and thread-safe flag + if strings.HasPrefix(version.Number.String(), versionName) && version.Number.ThreadSafe == threadSafe { + return &version + } + } + return nil } -type versionMeta struct { - number common.Version - folder os.DirEntry +// findVersionByPath finds and returns a version by its path +func findVersionByPath(versions []common.VersionMeta, path string) *common.VersionMeta { + for _, version := range versions { + // Match the path in the version folder + versionFolderPath := filepath.Join(version.Folder, "php.exe") + if strings.Contains(versionFolderPath, path) { + return &version + } + } + return nil } 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/src/common/helpers.go b/src/common/helpers.go new file mode 100644 index 0000000..aa7d699 --- /dev/null +++ b/src/common/helpers.go @@ -0,0 +1,414 @@ +package common + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +type Version struct { + Major int + Minor int + Patch int + Url string + ThreadSafe bool +} + +type VersionMeta struct { + Number Version + Folder string +} + +type VersionJson struct { + Path string + Source string + Version string +} + +func (v Version) Semantic() string { + return fmt.Sprintf("%v.%v.%v", v.Major, v.Minor, v.Patch) +} + +func (v Version) StringShort() string { + semantic := v.Semantic() + if v.ThreadSafe { + return semantic + } + return semantic + " nts" +} + +func (v Version) String() string { + semantic := v.Semantic() + if v.ThreadSafe { + return semantic + " thread safe" + } + return semantic + " non-thread safe" +} + +func ComputeVersion(text string, safe bool, url string) Version { + versionRe := regexp.MustCompile(`([0-9]{1,3})(?:.([0-9]{1,3}))?(?:.([0-9]{1,3}))?`) + matches := versionRe.FindAllStringSubmatch(text, -1) + if len(matches) == 0 { + return Version{ + Major: -1, + Minor: -1, + Patch: -1, + ThreadSafe: safe, + Url: url, + } + } + + major, err := strconv.Atoi(matches[0][1]) + if err != nil { + major = -1 + } + + minor, err := strconv.Atoi(matches[0][2]) + if err != nil { + minor = -1 + } + + patch, err := strconv.Atoi(matches[0][3]) + if err != nil { + patch = -1 + } + + return Version{ + Major: major, + Minor: minor, + Patch: patch, + ThreadSafe: safe, + Url: url, + } +} + +func (v Version) Compare(o Version) int { + if v.Major == -1 || o.Major == -1 { + return 0 + } + if v.Major != o.Major { + if v.Major < o.Major { + return -1 + } + return 1 + } + + if v.Minor == -1 || o.Minor == -1 { + return 0 + } + if v.Minor != o.Minor { + if v.Minor < o.Minor { + return -1 + } + return 1 + } + + if v.Patch == -1 || o.Patch == -1 { + return 0 + } + if v.Patch != o.Patch { + if v.Patch < o.Patch { + return -1 + } + return 1 + } + + return 0 +} + +func (v Version) CompareThreadSafe(o Version) int { + result := v.Compare(o) + if result != 0 { + return result + } + + if v.ThreadSafe == o.ThreadSafe { + return 0 + } + + if v.ThreadSafe { + return -1 + } + return 1 +} + +func (v Version) LessThan(o Version) bool { + return v.CompareThreadSafe(o) == -1 +} + +func (v Version) Same(o Version) bool { + return v.CompareThreadSafe(o) == 0 +} + +func SortVersions(input []Version) []Version { + sort.SliceStable(input, func(i, j int) bool { + return input[i].LessThan(input[j]) + }) + return input +} + +func RetrievePHPVersions(url string) ([]Version, error) { + // Make the HTTP request to the provided URL + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch PHP versions from URL '%s': %v", url, err) + } + defer resp.Body.Close() // Ensure the response body is closed after reading + + // Read the response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body from URL '%s': %v", url, err) + } + + // Convert the body to a string + sb := string(body) + + // Regex match to extract href links + re := regexp.MustCompile(`([a-zA-Z0-9./-]+)`) + matches := re.FindAllStringSubmatch(sb, -1) + if len(matches) == 0 { + return nil, fmt.Errorf("no valid version links found on the page '%s'", url) + } + + // Initialize a slice to store the versions + versions := make([]Version, 0) + + // Loop through the matches and filter out unwanted versions + for _, match := range matches { + versionURL := match[1] + name := match[2] + + // Check if name starts with any of the unwanted prefixes + if strings.HasPrefix(name, "php-devel-pack-") || + strings.HasPrefix(name, "php-debug-pack-") || + strings.HasPrefix(name, "php-test-pack-") { + continue + } + + // Check if the name contains "src", which is not a PHP version + if strings.Contains(name, "src") { + continue + } + + // Check if the name does not end with ".zip", which indicates it's not a valid PHP version + if !strings.HasSuffix(name, ".zip") { + continue + } + + // Default to thread-safe version + threadSafe := true + + // Check if the name contains "nts" or "NTS", which indicates a non-thread-safe version + if strings.Contains(strings.ToLower(name), "nts") { + threadSafe = false + } + + // Ensure we only process x64 versions + if !strings.Contains(name, "x64") { + continue + } + + // Create a version object based on the extracted name and thread-safety flag + versions = append(versions, ComputeVersion(name, threadSafe, versionURL)) + } + + // Return the list of versions + if len(versions) == 0 { + return nil, fmt.Errorf("no valid PHP versions found on the page '%s'", url) + } + + return versions, nil +} + +// RetrieveInstalledPHPVersions reads the installed PHP versions from the versions.json file inside .pvm/versions. +func RetrieveInstalledPHPVersions() ([]Version, error) { + versions := make([]Version, 0) + + // Get the current dir + currentDir, err := os.Executable() + if err != nil { + return versions, fmt.Errorf("unable to get current executable directory: %v", err) + } + + fullDir := filepath.Dir(currentDir) + + // Construct the path to the versions.json file inside .pvm/versions + jsonPath := filepath.Join(fullDir, "versions", "versions.json") + + // Check if the versions.json file exists + if _, err := os.Stat(jsonPath); os.IsNotExist(err) { + return versions, fmt.Errorf("no PHP versions installed (versions.json not found)") + } + + // Read the versions.json file + data, err := os.ReadFile(jsonPath) + if err != nil { + return versions, fmt.Errorf("failed to read versions.json: %v", err) + } + + // Parse the existing JSON data from the versions.json file + var existingVersions []map[string]interface{} + if err := json.Unmarshal(data, &existingVersions); err != nil { + return versions, fmt.Errorf("failed to parse versions.json: %v", err) + } + + // Process the existing versions and map them to Version structs + for _, entry := range existingVersions { + // Extract the relevant fields from the JSON entry + versionStr, ok := entry["version"].(string) + if !ok { + continue + } + + // Determine if the version is thread-safe or non-thread-safe + safe := true + if strings.Contains(strings.ToLower(versionStr), "nts") { + safe = false + } + + // Create the Version object + version := ComputeVersion(versionStr, safe, "") + + // Add the version to the list + versions = append(versions, version) + } + + // Sort the versions in ascending order + SortVersions(versions) + + return versions, nil +} + +// AddToVersionsJson adds a new version entry to versions.json. +func AddToVersionsJson(path string, version string, source string) error { + // Get the current dir + currentDir, err := os.Executable() + if err != nil { + return fmt.Errorf("unable to get current executable directory: %v", err) + } + fullDir := filepath.Dir(currentDir) + + // Check if .pvm/versions folder exists, if not create it + versionsPath := filepath.Join(fullDir, "versions") + if _, err := os.Stat(versionsPath); os.IsNotExist(err) { + if err := os.Mkdir(versionsPath, 0755); err != nil { + return fmt.Errorf("failed to create versions directory: %v", err) + } + } + + // Construct the path to versions.json + jsonPath := filepath.Join(versionsPath, "versions.json") + + // Read the current versions.json (if it exists) + var existingVersions []map[string]interface{} + if _, err := os.Stat(jsonPath); err == nil { + data, err := os.ReadFile(jsonPath) + if err != nil { + return fmt.Errorf("failed to read versions.json: %v", err) + } + + // Parse existing JSON data + if err := json.Unmarshal(data, &existingVersions); err != nil { + return fmt.Errorf("failed to parse versions.json: %v", err) + } + } + + // Check if the path already exists in the versions list + for _, entry := range existingVersions { + if entry["path"] == path { + return fmt.Errorf("the PHP version from the path %s already exists in versions.json", path) + } + } + + // Prepare the new version entry + versionEntry := map[string]interface{}{ + "path": path, + "version": version, + "source": source, + } + + // Append the new version entry to existing versions + existingVersions = append(existingVersions, versionEntry) + + // Marshal the updated data back to JSON + updatedData, err := json.MarshalIndent(existingVersions, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal updated data: %v", err) + } + + // Write the updated JSON back to versions.json + if err := os.WriteFile(jsonPath, updatedData, 0644); err != nil { + return fmt.Errorf("failed to write to versions.json: %v", err) + } + + return nil +} + +// removeFromVersionJson removes the uninstalled version from the versions.json file. +func RemoveFromVersionJson(path string) error { + // Get current dir + currentDir, err := os.Executable() + if err != nil { + return fmt.Errorf("unable to get current executable directory: %v", err) + } + fullDir := filepath.Dir(currentDir) + + // Path to the versions.json file + jsonPath := filepath.Join(fullDir, "versions", "versions.json") + + // Read the existing versions from versions.json + data, err := os.ReadFile(jsonPath) + if err != nil { + return fmt.Errorf("failed to read versions.json: %v", err) + } + + // Parse the existing versions data into a slice of maps + var existingVersions []map[string]interface{} + if err := json.Unmarshal(data, &existingVersions); err != nil { + return fmt.Errorf("failed to parse versions.json: %v", err) + } + + // Check if the path exists in the versions list + var entryFound bool + for _, entry := range existingVersions { + if entry["path"] == path { + entryFound = true + break + } + } + + // If the path is not found, return an error + if !entryFound { + return fmt.Errorf("the PHP version from the path %s was not found in versions.json", path) + } + + // Find and remove the entry for the specified path + for i, entry := range existingVersions { + if entry["path"] == path { + // Remove the entry from the slice + existingVersions = append(existingVersions[:i], existingVersions[i+1:]...) + break + } + } + + // Marshal the updated data back to JSON + updatedData, err := json.MarshalIndent(existingVersions, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal updated versions data: %v", err) + } + + // Write the updated JSON data back to the versions.json file + if err := os.WriteFile(jsonPath, updatedData, 0644); err != nil { + return fmt.Errorf("failed to write updated versions.json: %v", err) + } + + return nil +} 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 80% rename from main.go rename to src/main.go index 13f6161..c3bdd86 100644 --- a/main.go +++ b/src/main.go @@ -35,12 +35,16 @@ func main() { commands.List() case "ls remote", "ls-remote", "list remote", "list-remote": commands.ListRemote() - case "path": - commands.Path() case "install": - commands.Install(args) + commands.Install(args[1:]) + case "uninstall": + commands.Uninstall(args[1:]) case "use": commands.Use(args[1:]) + case "add": + commands.Add(args[1:]) + case "remove": + commands.Remove(args[1:]) default: commands.Help(true) } diff --git a/src/pvm.exe b/src/pvm.exe new file mode 100644 index 0000000..6d4a23e 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