diff --git a/CHANGELOG.md b/CHANGELOG.md index d5bdc85..dd1dc91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ --- +## [v0.1.0] - 2025-10-01 + +#### Changed + +- Commands with missing binary dependencies (e.g., `qmlformat`, `curl`) are now gracefully disabled and marked with an `(disabled)` tag in the help text. +- Running a disabled command now prints a clear, user-friendly message explaining which dependency is missing. +- The previous behavior of auto-installing dependencies has been removed to avoid crashes in different distributions. Similar to [this issue](https://github.com/PRASSamin/prasmoid/issues/16) +- Added a new `prasmoid fix` command that runs a script to install all required dependencies for your distribution. +- The `setup` command has been dropped in favor of the `fix` command. +- The update-checking mechanism has been completely rewritten to use SHA256 checksums for verification instead of version tags, ensuring notifications are always accurate. +- The CLI's help message has been reorganized with a new "Maintenance Commands" group for better readability. +- The main `install` and `update` shell scripts have been rewritten to be more robust, POSIX-compliant, and no longer depend on `jq`. + ## [v0.0.4] - 2025-09-12 #### Changed @@ -58,4 +71,3 @@ ## [v0.0.1] - 2025-07-28 - **Initial Release**: Initial release of the project - diff --git a/README.md b/README.md index 62e7b7a..d190f58 100644 --- a/README.md +++ b/README.md @@ -32,15 +32,54 @@ While the core structure of KDE Plasma plasmoids is straightforward, the surroun One of its most revolutionary features is a **built-in, zero-dependency JavaScript runtime**. This allows you to extend the CLI with your own custom commands, automating any workflow imaginable, directly within your project – no Node.js installation required! -## Getting Started +## 🚀 Getting Started ### Installation -Prasmoid is designed for quick and easy installation. Choose your preferred method: +Prasmoid is designed for quick and easy installation. You can use the provided installer script or install dependencies manually. -> [!IMPORTANT] -> The installer script requires jq to be installed for parsing GitHub API responses. -> Install it via `sudo apt install jq`, `sudo dnf install jq`, `sudo pacman -S jq` depending on your distro. +--- + +### 🛠 Dependencies + +Prasmoid depends on the following tools being available on your system: + +- **plasmoidviewer** – for testing and running plasmoids +- **qmlformat** – for formatting QML files +- **curl** – for fetching release assets +- **gettext** – for translation tools (`xgettext`, `msgmerge`, etc.) + +> [!NOTE] +> Package names may differ depending on your Linux distribution. +> On some systems, these tools are bundled in development kits such as **Plasma SDK** or **Qt tools**. +> If the installer cannot resolve them automatically, please install the tools manually using your package manager. + +> [!IMPORTANT] +> Want to extend installer support for your distro? +> Please [open an issue](https://github.com/PRASSamin/prasmoid/issues) or submit a PR. +> We just need a regular user of your distro to help test and add support. + +--- + +### 📦 Manual Dependency Installation (per distro) + +If you prefer to install dependencies manually (instead of using the installer), here are the commands for supported package managers: + +```bash +# Debian/Ubuntu +sudo apt install -y curl qt6-tools-dev plasma-sdk gettext + +# Fedora +sudo dnf install -y curl qmlformat plasma-sdk gettext + +# Arch Linux +sudo pacman -Sy --noconfirm curl qt6-declarative plasma-sdk gettext +sudo ln -s /usr/lib/qt6/bin/qmlformat /usr/bin/qmlformat + +# Alpine +sudo apk add curl qt6-qttools-dev plasma-sdk gettext +sudo ln -s /usr/lib/qt6/bin/qmlformat /usr/bin/qmlformat +``` #### Recommended: Standard Build (Native) @@ -203,7 +242,6 @@ Prasmoid provides a comprehensive set of commands to manage your plasmoid projec | Command | Description | Usage & Flags | | :------------------ | :---------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | -| `setup` | Bootstraps the development environment (e.g. installs dependencies). | `prasmoid setup` | | `init` | Initializes a new plasmoid project. | `prasmoid init [-n ]`
`-n, --name`: Project name. | | `build` | Packages the project into a `.plasmoid` archive. | `prasmoid build [-o ]`
`-o, --output`: Output directory (default: `./build`). | | `preview` | Launches the plasmoid in a live preview window. | `prasmoid preview [-w]`
`-w, --watch`: Auto-restart on file changes. | @@ -216,8 +254,8 @@ Prasmoid provides a comprehensive set of commands to manage your plasmoid projec | `changeset add` | Creates a new changeset with version bump and summary. | `prasmoid changeset add [-b ] [-s ]`
`-b, --bump`: `patch`, `minor`, or `major`.
`-s, --summary`: Changelog summary. | | `changeset apply` | Applies pending changesets to `metadata.json` and `CHANGELOG.md`. | `prasmoid changeset apply` | | `command` | Manages custom JavaScript CLI commands. | See subcommands below. | -| `command add` | Adds a new custom JS command in `.prasmoid/commands/`. | `prasmoid command add [-n ]`
`-n, --name`: Command name. | -| `command remove` | Removes a custom command. | `prasmoid command remove [-n ]`
`-n, --name`: Command name. | +| `command add` | Adds a new custom JS command in `.prasmoid/commands/`. | `prasmoid command add [-n ]`
`-n, --name`: Command name. | +| `command remove` | Removes a custom command. | `prasmoid command remove [-n ]`
`-n, --name`: Command name. | | `i18n` | Handles internationalization tasks. | See subcommands below. | | `i18n extract` | Extracts strings for translation from metadata and QML files. | `prasmoid i18n extract`
`--no-po`: Skip `.po` generation. | | `i18n compile` | Compiles `.po` files into `.mo` files for use in plasmoids. | `prasmoid i18n compile`
`-s, --silent`: Suppress output. | @@ -227,6 +265,7 @@ Prasmoid provides a comprehensive set of commands to manage your plasmoid projec | `regen types` | Regenerates `prasmoid.d.ts`. | `prasmoid regen types` | | `regen config` | Regenerates `prasmoid.config.js`. | `prasmoid regen config` | | `upgrade` | Updates Prasmoid itself to the latest version. | `prasmoid upgrade` | +| `fix` | Install missing dependencies or fix other issues. | `prasmoid fix` | ## Extending Prasmoid with Custom Commands diff --git a/cmd/fix/fix.go b/cmd/fix/fix.go new file mode 100644 index 0000000..babd22e --- /dev/null +++ b/cmd/fix/fix.go @@ -0,0 +1,52 @@ +/* +Copyright 2025 PRAS + +This command implements the Prasmoid CLI fix functionality using a remote fix script. Instead of embedding complex fix logic directly in the Go code, which would increase the binary size by approximately 2MB, this approach leverages a lightweight shell script hosted on GitHub. This design choice ensures that Prasmoid remains lightweight while maintaining robust fix capabilities. +*/ +package fix + +import ( + "fmt" + "os" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + root "github.com/PRASSamin/prasmoid/cmd" +) + +func init() { + if utilsIsPackageInstalled("curl") { + cliFixCmd.Short = "Install missing dependencies." + } else { + cliFixCmd.Short = fmt.Sprintf("Install missing dependencies %s", color.RedString("(disabled)")) + } + cliFixCmd.GroupID = "cli" + root.RootCmd.AddCommand(cliFixCmd) +} + +var cliFixCmd = &cobra.Command{ + Use: "fix", + Run: func(cmd *cobra.Command, args []string) { + if !utilsIsPackageInstalled("curl") { + fmt.Println(color.YellowString("fix command is disabled due to missing curl dependency.")) + fmt.Println(color.BlueString("Please install curl and try again.")) + return + } + + if err := utilsCheckRoot(); err != nil { + fmt.Println(color.RedString(err.Error())) + return + } + + cmdStr := fmt.Sprintf("sudo curl -sSL %s | bash", scriptURL) + + command := execCommand("bash", "-c", cmdStr) + command.Stdout = os.Stdout + command.Stderr = os.Stderr + + if err := command.Run(); err != nil { + fmt.Println(color.RedString("Fix failed: %v", err)) + } + }, +} diff --git a/cmd/fix/fix_test.go b/cmd/fix/fix_test.go new file mode 100644 index 0000000..dc75714 --- /dev/null +++ b/cmd/fix/fix_test.go @@ -0,0 +1,106 @@ +package fix + +import ( + "bytes" + "errors" + "io" + "os" + "os/exec" + "testing" + + "github.com/fatih/color" + "github.com/stretchr/testify/assert" +) + +func TestCliFixCmd(t *testing.T) { + // Save original functions + originalUtilsIsPackageInstalled := utilsIsPackageInstalled + originalCheckRoot := utilsCheckRoot + originalExecCommand := execCommand + + t.Cleanup(func() { + utilsIsPackageInstalled = originalUtilsIsPackageInstalled + utilsCheckRoot = originalCheckRoot + execCommand = originalExecCommand + }) + + // Helper to capture stdout + captureOutput := func() (*bytes.Buffer, func()) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + color.Output = w + buf := new(bytes.Buffer) + return buf, func() { + _ = w.Close() + _, _ = io.Copy(buf, r) + os.Stdout = oldStdout + color.Output = oldStdout + } + } + + t.Run("curl not installed", func(t *testing.T) { + // Arrange + utilsIsPackageInstalled = func(pkg string) bool { return false } + buf, restore := captureOutput() + + // Act + cliFixCmd.Run(cliFixCmd, []string{}) + + // Assert + restore() + output := buf.String() + assert.Contains(t, output, "fix command is disabled due to missing curl dependency.") + }) + + t.Run("checkRoot fails", func(t *testing.T) { + // Arrange + utilsIsPackageInstalled = func(pkg string) bool { return true } + utilsCheckRoot = func() error { return errors.New("not root") } + buf, restore := captureOutput() + + // Act + cliFixCmd.Run(cliFixCmd, []string{}) + + // Assert + restore() + output := buf.String() + assert.Contains(t, output, "not root") + }) + + t.Run("exec command fails", func(t *testing.T) { + // Arrange + utilsIsPackageInstalled = func(pkg string) bool { return true } + utilsCheckRoot = func() error { return nil } + execCommand = func(name string, arg ...string) *exec.Cmd { + return exec.Command("bash", "-c", "exit 1") + } + buf, restore := captureOutput() + + // Act + cliFixCmd.Run(cliFixCmd, []string{}) + + // Assert + restore() + output := buf.String() + assert.Contains(t, output, "Fix failed") + }) + + t.Run("exec command succeeds", func(t *testing.T) { + // Arrange + utilsIsPackageInstalled = func(pkg string) bool { return true } + utilsCheckRoot = func() error { return nil } + execCommand = func(name string, arg ...string) *exec.Cmd { + return exec.Command("true") + } + buf, restore := captureOutput() + + // Act + cliFixCmd.Run(cliFixCmd, []string{}) + + // Assert + restore() + output := buf.String() + assert.NotContains(t, output, "Fix failed") + }) +} \ No newline at end of file diff --git a/cmd/fix/vars.go b/cmd/fix/vars.go new file mode 100644 index 0000000..6bbd858 --- /dev/null +++ b/cmd/fix/vars.go @@ -0,0 +1,16 @@ +package fix + +import ( + "os/exec" + + "github.com/PRASSamin/prasmoid/utils" +) + +var ( + execCommand = exec.Command + + utilsIsPackageInstalled = utils.IsPackageInstalled + utilsCheckRoot = utils.CheckRoot + + scriptURL = "https://raw.githubusercontent.com/PRASSamin/prasmoid/main/scripts/fix" +) diff --git a/cmd/format/format.go b/cmd/format/format.go index 5ab2ff7..6b82b74 100644 --- a/cmd/format/format.go +++ b/cmd/format/format.go @@ -12,9 +12,7 @@ import ( "sync" "time" - "github.com/AlecAivazis/survey/v2" "github.com/PRASSamin/prasmoid/cmd" - "github.com/PRASSamin/prasmoid/consts" "github.com/PRASSamin/prasmoid/utils" "github.com/fatih/color" "github.com/fsnotify/fsnotify" @@ -41,12 +39,9 @@ func (w *watcherWrapper) Errors() chan error { } var ( - utilsIsPackageInstalled = utils.IsPackageInstalled utilsIsValidPlasmoid = utils.IsValidPlasmoid - utilsDetectPackageManager = utils.DetectPackageManager - surveyAskOne = survey.AskOne utilsIsQmlFile = utils.IsQmlFile - utilsInstallPackage = utils.InstallPackage + utilsIsPackageInstalled = utils.IsPackageInstalled execCommand = exec.Command // for testing filepathWalk = filepath.Walk @@ -66,40 +61,30 @@ var dir string func init() { FormatCmd.Flags().BoolVarP(&watch, "watch", "w", false, "watch for changes") FormatCmd.Flags().StringVarP(&dir, "dir", "d", "./contents", "directory to format") + + if utilsIsPackageInstalled("qmlformat") { + FormatCmd.Short = "Prettify QML files" + } else { + FormatCmd.Short = fmt.Sprintf("Prettify QML files %s", color.RedString("(disabled)")) + } + cmd.RootCmd.AddCommand(FormatCmd) } // FormatCmd represents the format command var FormatCmd = &cobra.Command{ Use: "format", - Short: "Prettify QML files", Long: "Automatically format QML source files to ensure consistent style and readability.", Run: func(cmd *cobra.Command, args []string) { - if !utilsIsValidPlasmoid() { - fmt.Println(color.RedString("Current directory is not a valid plasmoid.")) + if !utilsIsPackageInstalled("qmlformat") { + fmt.Println(color.YellowString("format command is disabled due to missing qmlformat dependency.")) + fmt.Println(color.BlueString("- Use `prasmoid fix` to install it.")) return } - if !utilsIsPackageInstalled(consts.QmlFormatPackageName["binary"]) { - pm, _ := utilsDetectPackageManager() - var confirm bool - confirmPrompt := &survey.Confirm{ - Message: "qmlformat is not installed. Do you want to install it?", - Default: true, - } - if err := surveyAskOne(confirmPrompt, &confirm); err != nil { - return - } - if confirm { - if err := utilsInstallPackage(pm, consts.QmlFormatPackageName["binary"], consts.QmlFormatPackageName); err != nil { - fmt.Println(color.RedString("Failed to install qmlformat.")) - return - } - fmt.Println(color.GreenString("qmlformat installed successfully.")) - } else { - fmt.Println(color.YellowString("Operation cancelled.")) - return - } + if !utilsIsValidPlasmoid() { + fmt.Println(color.RedString("Current directory is not a valid plasmoid.")) + return } crrPath, _ := os.Getwd() diff --git a/cmd/format/format_test.go b/cmd/format/format_test.go index da92775..0c3cb21 100644 --- a/cmd/format/format_test.go +++ b/cmd/format/format_test.go @@ -15,7 +15,6 @@ import ( "testing" "time" - "github.com/AlecAivazis/survey/v2" "github.com/PRASSamin/prasmoid/tests" "github.com/fatih/color" "github.com/fsnotify/fsnotify" @@ -272,165 +271,85 @@ func TestPrettify(t *testing.T) { } func TestFormatCmdRun(t *testing.T) { - t.Run("Not a valid plasmoid", func(t *testing.T) { - // Create an empty directory that is not a plasmoid - tmpDir, err := os.MkdirTemp("", "format-invalid-*") - require.NoError(t, err) - defer func() { require.NoError(t, os.RemoveAll(tmpDir)) }() - - oldWd, _ := os.Getwd() - require.NoError(t, os.Chdir(tmpDir)) - defer func() { require.NoError(t, os.Chdir(oldWd)) }() - - // Capture stderr to check for the error message - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - FormatCmd.Run(FormatCmd, []string{}) - - require.NoError(t, w.Close()) - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - os.Stdout = oldStdout - color.Output = os.Stdout - - assert.Contains(t, buf.String(), "Current directory is not a valid plasmoid") - }) - - t.Run("Run format successfully", func(t *testing.T) { - _, cleanup := tests.SetupTestProject(t) - utilsIsPackageInstalled = func(name string) bool { return true } - defer cleanup() + t.Run("qmlformat not installed", func(t *testing.T) { + // Arrange + originalIsPackageInstalled := utilsIsPackageInstalled + utilsIsPackageInstalled = func(pkg string) bool { return false } + defer func() { utilsIsPackageInstalled = originalIsPackageInstalled }() - // Capture stdout oldStdout := os.Stdout r, w, _ := os.Pipe() os.Stdout = w + color.Output = w + // Act FormatCmd.Run(FormatCmd, []string{}) + _ = w.Close() - require.NoError(t, w.Close()) + // Assert var buf bytes.Buffer _, _ = io.Copy(&buf, r) os.Stdout = oldStdout - color.Output = os.Stdout output := buf.String() - assert.Contains(t, output, "Formatted 1 files") + assert.Contains(t, output, "format command is disabled due to missing qmlformat dependency.") }) - t.Run("qmlformat not installed, user confirms installation", func(t *testing.T) { - _, cleanup := tests.SetupTestProject(t) - defer cleanup() - - // Mock functions - utilsIsPackageInstalled = func(name string) bool { return false } - utilsDetectPackageManager = func() (string, error) { return "apt", nil } - surveyAskOne = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { - *(response.(*bool)) = true - return nil - } - utilsInstallPackage = func(pm string, binName string, pkgNames map[string]string) error { return nil } - - // Capture stdout - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - FormatCmd.Run(FormatCmd, []string{}) - - require.NoError(t, w.Close()) - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - os.Stdout = oldStdout - color.Output = os.Stdout - output := buf.String() - assert.Contains(t, output, "qmlformat installed successfully") - }) + t.Run("Not a valid plasmoid", func(t *testing.T) { + // Arrange + originalIsPackageInstalled := utilsIsPackageInstalled + utilsIsPackageInstalled = func(pkg string) bool { return true } // Mock as installed + defer func() { utilsIsPackageInstalled = originalIsPackageInstalled }() - t.Run("qmlformat not installed, user cancels installation", func(t *testing.T) { - _, cleanup := tests.SetupTestProject(t) - defer cleanup() + tmpDir, err := os.MkdirTemp("", "format-invalid-*") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(tmpDir)) }() - // Mock functions - utilsIsPackageInstalled = func(name string) bool { return false } - utilsDetectPackageManager = func() (string, error) { return "apt", nil } - surveyAskOne = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { - *(response.(*bool)) = false - return nil - } + oldWd, _ := os.Getwd() + require.NoError(t, os.Chdir(tmpDir)) + defer func() { require.NoError(t, os.Chdir(oldWd)) }() - // Capture stdout oldStdout := os.Stdout r, w, _ := os.Pipe() os.Stdout = w + color.Output = w + // Act FormatCmd.Run(FormatCmd, []string{}) + _ = w.Close() - require.NoError(t, w.Close()) + // Assert var buf bytes.Buffer _, _ = io.Copy(&buf, r) os.Stdout = oldStdout color.Output = os.Stdout - output := buf.String() - assert.Contains(t, output, "Operation cancelled") + assert.Contains(t, buf.String(), "Current directory is not a valid plasmoid") }) - t.Run("qmlformat not installed, installation fails", func(t *testing.T) { - _, cleanup := tests.SetupTestProject(t) - defer cleanup() - - // Mock functions - utilsIsPackageInstalled = func(name string) bool { return false } - utilsDetectPackageManager = func() (string, error) { return "apt", nil } - surveyAskOne = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { - *(response.(*bool)) = true - return nil - } - utilsInstallPackage = func(pm string, binName string, pkgNames map[string]string) error { return errors.New("install error") } - - // Capture stdout - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - FormatCmd.Run(FormatCmd, []string{}) - - require.NoError(t, w.Close()) - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - os.Stdout = oldStdout - color.Output = os.Stdout - output := buf.String() - assert.Contains(t, output, "Failed to install qmlformat") - }) + t.Run("Run format successfully", func(t *testing.T) { + // Arrange + originalIsPackageInstalled := utilsIsPackageInstalled + utilsIsPackageInstalled = func(pkg string) bool { return true } // Mock as installed + defer func() { utilsIsPackageInstalled = originalIsPackageInstalled }() - t.Run("survey returns error", func(t *testing.T) { _, cleanup := tests.SetupTestProject(t) defer cleanup() - // Mock functions - utilsIsPackageInstalled = func(name string) bool { return false } - utilsDetectPackageManager = func() (string, error) { return "apt", nil } - surveyAskOne = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { - return errors.New("survey error") - } - - // Capture stdout oldStdout := os.Stdout r, w, _ := os.Pipe() os.Stdout = w + color.Output = w + // Act FormatCmd.Run(FormatCmd, []string{}) + _ = w.Close() - require.NoError(t, w.Close()) + // Assert var buf bytes.Buffer _, _ = io.Copy(&buf, r) os.Stdout = oldStdout color.Output = os.Stdout output := buf.String() - assert.NotContains(t, output, "qmlformat installed successfully") - assert.NotContains(t, output, "Operation cancelled") + assert.Contains(t, output, "Formatted 1 files") }) } diff --git a/cmd/i18n/compile.go b/cmd/i18n/compile.go index f3e5774..5a5f03a 100644 --- a/cmd/i18n/compile.go +++ b/cmd/i18n/compile.go @@ -11,9 +11,7 @@ import ( "path/filepath" "strings" - "github.com/AlecAivazis/survey/v2" root "github.com/PRASSamin/prasmoid/cmd" - "github.com/PRASSamin/prasmoid/consts" "github.com/PRASSamin/prasmoid/types" "github.com/PRASSamin/prasmoid/utils" "github.com/fatih/color" @@ -25,40 +23,27 @@ var silent bool func init() { I18nCompileCmd.Flags().BoolVarP(&silent, "silent", "s", false, "Do not show progress messages") + if utilsIsPackageInstalled("msgfmt") { + I18nCompileCmd.Short = "Compile .po files to binary .mo files" + } else { + I18nCompileCmd.Short = fmt.Sprintf("Compile .po files to binary .mo files %s", color.RedString("(disabled)")) + } + I18nCmd.AddCommand(I18nCompileCmd) } var I18nCompileCmd = &cobra.Command{ Use: "compile", - Short: "Compile .po files to binary .mo files", Run: func(cmd *cobra.Command, args []string) { - if !utilsIsValidPlasmoid() { - fmt.Println(color.RedString("Current directory is not a valid plasmoid.")) + if !utilsIsPackageInstalled("msgfmt") { + fmt.Println(color.RedString("compile command is disabled due to missing msgfmt dependency.")) + fmt.Println(color.BlueString("- Use `prasmoid fix` to install it.")) return } - if !utilsIsPackageInstalled(consts.GettextPackageName["binary"]) { - pm, _ := utilsDetectPackageManager() - var confirm bool - confirmPrompt := &survey.Confirm{ - Message: "gettext is not installed. Do you want to install it first?", - Default: true, - } - if !confirm { - if err := surveyAskOne(confirmPrompt, &confirm); err != nil { - return - } - } - - if confirm { - if err := utilsInstallPackage(pm, consts.GettextPackageName["binary"], consts.GettextPackageName); err != nil { - fmt.Println(color.RedString("Failed to install gettext:", err)) - return - } - } else { - fmt.Println("Operation cancelled.") - return - } + if !utilsIsValidPlasmoid() { + fmt.Println(color.RedString("Current directory is not a valid plasmoid.")) + return } if !silent { diff --git a/cmd/i18n/extract.go b/cmd/i18n/extract.go index b435186..26cd4a3 100644 --- a/cmd/i18n/extract.go +++ b/cmd/i18n/extract.go @@ -15,9 +15,7 @@ import ( "strings" "time" - "github.com/AlecAivazis/survey/v2" root "github.com/PRASSamin/prasmoid/cmd" - "github.com/PRASSamin/prasmoid/consts" "github.com/PRASSamin/prasmoid/utils" "github.com/fatih/color" "github.com/spf13/cobra" @@ -25,40 +23,28 @@ import ( func init() { I18nExtractCmd.Flags().Bool("no-po", false, "Skip .po file generation") + + if utilsIsPackageInstalled("msginit") && utilsIsPackageInstalled("msgmerge") && utilsIsPackageInstalled("xgettext") { + I18nExtractCmd.Short = "Extract translatable strings from source files" + } else { + I18nExtractCmd.Short = fmt.Sprintf("Extract translatable strings from source files %s", color.RedString("(disabled)")) + } + I18nCmd.AddCommand(I18nExtractCmd) } var I18nExtractCmd = &cobra.Command{ Use: "extract", - Short: "Extract translatable strings from source files", Run: func(cmd *cobra.Command, args []string) { - if !utilsIsValidPlasmoid() { - fmt.Println(color.RedString("Current directory is not a valid plasmoid.")) + if !utilsIsPackageInstalled("msginit") || !utilsIsPackageInstalled("msgmerge") || !utilsIsPackageInstalled("xgettext") { + fmt.Println(color.RedString("extract command is disabled due to missing dependencies.")) + fmt.Println(color.BlueString("- Use `prasmoid fix` to install them.")) return } - if !utilsIsPackageInstalled(consts.GettextPackageName["binary"]) { - pm, _ := utilsDetectPackageManager() - var confirm bool - confirmPrompt := &survey.Confirm{ - Message: "gettext is not installed. Do you want to install it first?", - Default: true, - } - if !confirm { - if err := surveyAskOne(confirmPrompt, &confirm); err != nil { - return - } - } - - if confirm { - if err := utilsInstallPackage(pm, consts.GettextPackageName["binary"], consts.GettextPackageName); err != nil { - fmt.Println(color.RedString("Failed to install gettext:", err)) - return - } - } else { - fmt.Println("Operation cancelled.") - return - } + if !utilsIsValidPlasmoid() { + fmt.Println(color.RedString("Current directory is not a valid plasmoid.")) + return } color.Cyan("Extracting translatable strings...") diff --git a/cmd/i18n/i18n_compile_test.go b/cmd/i18n/i18n_compile_test.go index 97b3f45..1283b91 100644 --- a/cmd/i18n/i18n_compile_test.go +++ b/cmd/i18n/i18n_compile_test.go @@ -10,7 +10,6 @@ import ( "path/filepath" "testing" - "github.com/AlecAivazis/survey/v2" "github.com/PRASSamin/prasmoid/tests" "github.com/PRASSamin/prasmoid/types" "github.com/PRASSamin/prasmoid/utils" @@ -20,11 +19,53 @@ import ( ) func TestI18nCompileCommand(t *testing.T) { + t.Run("msgfmt not installed", func(t *testing.T) { + _, cleanup := tests.SetupTestProject(t) + defer cleanup() + + originalIsPackageInstalled := utilsIsPackageInstalled + utilsIsPackageInstalled = func(pkg string) bool { return false } + defer func() { utilsIsPackageInstalled = originalIsPackageInstalled }() + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + color.Output = w + + I18nCompileCmd.Run(I18nCompileCmd, []string{}) + _ = w.Close() + + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + os.Stdout = oldStdout + output := buf.String() + assert.Contains(t, output, "compile command is disabled due to missing msgfmt dependency.") + }) // Set up a temporary project t.Run("successfully compiles .po files", func(t *testing.T) { projectDir, cleanup := tests.SetupTestProject(t) defer cleanup() + // Mock execCommand to create the expected .mo file + oldExecCommand := execCommand + execCommand = func(name string, arg ...string) *exec.Cmd { + if name == "msgfmt" { + var outputFile string + for i, a := range arg { + if a == "-o" && i+1 < len(arg) { + outputFile = arg[i+1] + break + } + } + if outputFile != "" { + _ = os.MkdirAll(filepath.Dir(outputFile), 0755) + _ = os.WriteFile(outputFile, []byte("dummy mo file"), 0644) + } + } + return exec.Command("true") + } + defer func() { execCommand = oldExecCommand }() + // Create a dummy config config := types.Config{ I18n: types.ConfigI18n{ @@ -82,83 +123,6 @@ msgstr "Hello World" output := buf.String() assert.Contains(t, output, "Current directory is not a valid plasmoid") }) - - t.Run("error on gettext missing", func(t *testing.T) { - _, cleanup := tests.SetupTestProject(t) - defer cleanup() - - oldIsPackageInstalled := utilsIsPackageInstalled - utilsIsPackageInstalled = func(packageName string) bool { - return false - } - t.Cleanup(func() { utilsIsPackageInstalled = oldIsPackageInstalled }) - - // Capture stdout - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - I18nCompileCmd.Run(I18nCompileCmd, []string{}) - _ = w.Close() - - os.Stdout = oldStdout - color.Output = os.Stdout - - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - output := buf.String() - - require.Contains(t, output, "mgettext is not installed. Do you want to install it first?") - }) - - t.Run("successfull install missing gettext package", func(t *testing.T) { - _, cleanup := tests.SetupTestProject(t) - defer cleanup() - - oldIsValidPlasmoid := utilsIsValidPlasmoid - oldIsPackageInstalled := utilsIsPackageInstalled - oldInstallPackage := utilsInstallPackage - oldSurveyAskOne := surveyAskOne - - t.Cleanup(func() { - utilsIsPackageInstalled = oldIsPackageInstalled - utilsIsValidPlasmoid = oldIsValidPlasmoid - utilsInstallPackage = oldInstallPackage - surveyAskOne = oldSurveyAskOne - }) - - utilsIsPackageInstalled = func(packageName string) bool { - return false - } - utilsIsValidPlasmoid = func() bool { - return true - } - utilsInstallPackage = func(pm string, binName string, pkgNames map[string]string) error { - return errors.New("install failed") - } - surveyAskOne = func(prompt survey.Prompt, response interface{}, opts ...survey.AskOpt) error { - *(response.(*bool)) = true - return nil - } - - // Capture stdout - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - I18nCompileCmd.Run(I18nCompileCmd, []string{}) - _ = w.Close() - - os.Stdout = oldStdout - color.Output = os.Stdout - - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - output := buf.String() - - require.Contains(t, output, "Failed to install gettext") - }) - } func TestCompileI18n(t *testing.T) { @@ -273,4 +237,4 @@ func TestCompileI18n(t *testing.T) { err := CompileI18n(config, true) assert.Error(t, err) }) -} +} \ No newline at end of file diff --git a/cmd/i18n/i18n_extract_test.go b/cmd/i18n/i18n_extract_test.go index c9013d3..5f93576 100644 --- a/cmd/i18n/i18n_extract_test.go +++ b/cmd/i18n/i18n_extract_test.go @@ -12,7 +12,6 @@ import ( "strings" "testing" - "github.com/AlecAivazis/survey/v2" "github.com/PRASSamin/prasmoid/cmd" "github.com/PRASSamin/prasmoid/tests" "github.com/PRASSamin/prasmoid/utils" @@ -35,6 +34,31 @@ func mockExecCommand(t *testing.T, failingCmd string) { } func TestI18nExtractCommand(t *testing.T) { + t.Run("dependencies not installed", func(t *testing.T) { + // Arrange + _, cleanup := tests.SetupTestProject(t) + defer cleanup() + + originalIsPackageInstalled := utilsIsPackageInstalled + utilsIsPackageInstalled = func(pkg string) bool { return false } + defer func() { utilsIsPackageInstalled = originalIsPackageInstalled }() + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + color.Output = w + + // Act + I18nExtractCmd.Run(I18nExtractCmd, []string{}) + _ = w.Close() + + // Assert + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + os.Stdout = oldStdout + output := buf.String() + assert.Contains(t, output, "extract command is disabled due to missing dependencies.") + }) t.Run("invalid plasmoid", func(t *testing.T) { _, cleanup := tests.SetupTestProject(t) defer cleanup() @@ -65,6 +89,22 @@ func TestI18nExtractCommand(t *testing.T) { _ = os.WriteFile(filepath.Join(qmlDir, "main.qml"), []byte(`Text { text: i18n("Hello") }`), 0644) _ = I18nExtractCmd.Flags().Set("no-po", "false") + // Mock execCommand to prevent actual execution of external tools + oldExecCommand := execCommand + execCommand = func(name string, arg ...string) *exec.Cmd { + // Create dummy files that the commands expect to exist or create + if name == "xgettext" { + for i, a := range arg { + if a == "-o" && i+1 < len(arg) { + _ = os.WriteFile(arg[i+1], []byte(`msgid "Hello" +msgstr ""`), 0644) + } + } + } + return exec.Command("true") + } + defer func() { execCommand = oldExecCommand }() + // Act I18nExtractCmd.Run(I18nExtractCmd, []string{}) @@ -87,6 +127,20 @@ func TestI18nExtractCommand(t *testing.T) { _ = os.WriteFile(filepath.Join(qmlDir, "main.qml"), []byte(`Text { text: i18n("Hello") }`), 0644) _ = I18nExtractCmd.Flags().Set("no-po", "true") + // Mock execCommand + oldExecCommand := execCommand + execCommand = func(name string, arg ...string) *exec.Cmd { + if name == "xgettext" { + for i, a := range arg { + if a == "-o" && i+1 < len(arg) { + _ = os.WriteFile(arg[i+1], []byte("dummy pot content"), 0644) + } + } + } + return exec.Command("true") + } + defer func() { execCommand = oldExecCommand }() + // Act I18nExtractCmd.Run(I18nExtractCmd, []string{}) @@ -96,83 +150,6 @@ func TestI18nExtractCommand(t *testing.T) { assert.FileExists(t, potFile) assert.NoFileExists(t, enPoFile) }) - - t.Run("error on gettext missing", func(t *testing.T) { - _, cleanup := tests.SetupTestProject(t) - defer cleanup() - - // Save & override PATH to raise error - oldIsPackageInstalled := utilsIsPackageInstalled - utilsIsPackageInstalled = func(packageName string) bool { - return false - } - t.Cleanup(func() { utilsIsPackageInstalled = oldIsPackageInstalled }) - - // Capture stdout - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - I18nExtractCmd.Run(I18nExtractCmd, []string{}) - _ = w.Close() - - os.Stdout = oldStdout - color.Output = os.Stdout - - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - output := buf.String() - - require.Contains(t, output, "mgettext is not installed. Do you want to install it first?") - }) - - t.Run("successfull install missing gettext package", func(t *testing.T) { - _, cleanup := tests.SetupTestProject(t) - defer cleanup() - - oldIsValidPlasmoid := utilsIsValidPlasmoid - oldIsPackageInstalled := utilsIsPackageInstalled - oldInstallPackage := utilsInstallPackage - oldSurveyAskOne := surveyAskOne - - t.Cleanup(func() { - utilsIsPackageInstalled = oldIsPackageInstalled - utilsIsValidPlasmoid = oldIsValidPlasmoid - utilsInstallPackage = oldInstallPackage - surveyAskOne = oldSurveyAskOne - }) - - utilsIsPackageInstalled = func(packageName string) bool { - return false - } - utilsIsValidPlasmoid = func() bool { - return true - } - utilsInstallPackage = func(pm string, binName string, pkgNames map[string]string) error { - return errors.New("install failed") - } - surveyAskOne = func(prompt survey.Prompt, response interface{}, opts ...survey.AskOpt) error { - *(response.(*bool)) = true - return nil - } - - // Capture stdout - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - I18nExtractCmd.Run(I18nExtractCmd, []string{}) - _ = w.Close() - - os.Stdout = oldStdout - color.Output = os.Stdout - - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - output := buf.String() - - require.Contains(t, output, "Failed to install gettext") - }) } func TestGeneratePoFiles(t *testing.T) { @@ -202,6 +179,13 @@ msgstr "Bonjour"` _ = os.WriteFile(filepath.Join(translationsDir, "template.pot"), []byte(potContent), 0644) _ = os.WriteFile(filepath.Join(translationsDir, "fr.po"), []byte(poContent), 0644) + // Mock execCommand for msgmerge + oldExecCommand := execCommand + execCommand = func(name string, arg ...string) *exec.Cmd { + return exec.Command("true") + } + defer func() { execCommand = oldExecCommand }() + // Act err := generatePoFiles(translationsDir) @@ -403,10 +387,7 @@ func TestPostProcessPotFile(t *testing.T) { defer cleanup() path := "test.pot" - content := []byte(`charset=CHARSET -SOME DESCRIPTIVE TITLE. -Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -FIRST AUTHOR , YEAR.`) + content := []byte(`charset=CHARSET\nSOME DESCRIPTIVE TITLE.\nCopyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER\nFIRST AUTHOR , YEAR.`) require.NoError(t, os.WriteFile(path, content, 0644)) postProcessPotFile(path, "MyApp", nil) @@ -424,10 +405,7 @@ FIRST AUTHOR , YEAR.`) defer cleanup() path := "test.pot" - content := []byte(`charset=CHARSET -SOME DESCRIPTIVE TITLE. -Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -FIRST AUTHOR , YEAR.`) + content := []byte(`charset=CHARSET\nSOME DESCRIPTIVE TITLE.\nCopyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER\nFIRST AUTHOR , YEAR.`) require.NoError(t, os.WriteFile(path, content, 0644)) authors := []interface{}{ @@ -449,10 +427,7 @@ FIRST AUTHOR , YEAR.`) defer cleanup() path := "test.pot" - content := []byte(`charset=CHARSET -SOME DESCRIPTIVE TITLE. -Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -FIRST AUTHOR , YEAR.`) + content := []byte(`charset=CHARSET\nSOME DESCRIPTIVE TITLE.\nCopyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER\nFIRST AUTHOR , YEAR.`) require.NoError(t, os.WriteFile(path, content, 0644)) postProcessPotFile(path, "OtherApp", "not-a-list") @@ -468,10 +443,7 @@ FIRST AUTHOR , YEAR.`) defer cleanup() path := "test.pot" - content := []byte(`charset=CHARSET -SOME DESCRIPTIVE TITLE. -Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -FIRST AUTHOR , YEAR.`) + content := []byte(`charset=CHARSET\nSOME DESCRIPTIVE TITLE.\nCopyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER\nFIRST AUTHOR , YEAR.`) require.NoError(t, os.WriteFile(path, content, 0644)) authors := []interface{}{ @@ -490,7 +462,7 @@ FIRST AUTHOR , YEAR.`) func TestRunXGettext(t *testing.T) { t.Run("fails when Name metadata is missing", func(t *testing.T) { // Mock GetDataFromMetadata to return invalid name - oldGetData := GetDataFromMetadata + oldGetData := GetDataFromMetadata GetDataFromMetadata = func(key string) (interface{}, error) { if key == "Name" { return nil, fmt.Errorf("missing") @@ -556,7 +528,7 @@ func TestRunXGettext(t *testing.T) { _ = os.WriteFile("main.qml", []byte(`Text { text: i18n("Hello") }`), 0644) // Mock execCommand to fail - mockExecCommand(t, "xgettext") + mockExecCommand(t, "xgettext") oldGetData := GetDataFromMetadata GetDataFromMetadata = func(key string) (interface{}, error) { @@ -583,8 +555,8 @@ func TestRunXGettext(t *testing.T) { _ = os.WriteFile("main.qml", []byte(`Text { text: i18n("Hello") }`), 0644) // Mock runCommand to succeed but we won’t create template.pot.new - oldRunCmd := runCommand - runCommand = func(cmd *exec.Cmd) error { return nil } + oldRunCmd := runCommand + runCommand = func(cmd *exec.Cmd) error { return nil } t.Cleanup(func() { runCommand = oldRunCmd }) oldGetData := GetDataFromMetadata @@ -614,8 +586,8 @@ func TestRunXGettext(t *testing.T) { _ = os.WriteFile("main.qml", []byte(`Text { text: i18n("Hello") }`), 0644) // Mock runCommand to simulate creating potFileNew - oldRunCmd := runCommand - runCommand = func(cmd *exec.Cmd) error { + oldRunCmd := runCommand + runCommand = func(cmd *exec.Cmd) error { _ = os.WriteFile(filepath.Join(translations, "template.pot.new"), []byte(`dummy`), 0644) return nil } diff --git a/cmd/i18n/vars.go b/cmd/i18n/vars.go index 54770d5..1cd6234 100644 --- a/cmd/i18n/vars.go +++ b/cmd/i18n/vars.go @@ -8,7 +8,6 @@ import ( "os/exec" "path/filepath" - "github.com/AlecAivazis/survey/v2" "github.com/PRASSamin/prasmoid/utils" "github.com/bmatcuk/doublestar/v4" ) @@ -36,14 +35,9 @@ var ( } // utils functions - GetDataFromMetadata = utils.GetDataFromMetadata - utilsIsPackageInstalled = utils.IsPackageInstalled - utilsIsValidPlasmoid = utils.IsValidPlasmoid - utilsInstallPackage = utils.InstallPackage - utilsDetectPackageManager = utils.DetectPackageManager - - // survey functions - surveyAskOne = survey.AskOne + GetDataFromMetadata = utils.GetDataFromMetadata + utilsIsValidPlasmoid = utils.IsValidPlasmoid + utilsIsPackageInstalled = utils.IsPackageInstalled // command runner runCommand = func(cmd *exec.Cmd) error { diff --git a/cmd/init/init.go b/cmd/init/init.go index c9e9587..39c9e95 100644 --- a/cmd/init/init.go +++ b/cmd/init/init.go @@ -90,7 +90,7 @@ var InitCmd = &cobra.Command{ return } - if Config.InitGit { + if Config.InitGit && utilsIsPackageInstalled("git") { if err := initializeGitRepo(); err != nil { fmt.Println(color.YellowString("Could not initialize git repository: %v", err)) } else { @@ -176,11 +176,11 @@ var gatherProjectConfig = func() error { Config.Locales = utilsAskForLocales() // Check for git and ask to initialize - if utilsIsPackageInstalled("git") { - gitQuestion := &survey.Confirm{ + gitQuestion := &survey.Confirm{ Message: "Initialize a git repository?", Default: true, - } + } + if utilsIsPackageInstalled("git") { if err := surveyAskOne(gitQuestion, &Config.InitGit); err != nil { return err } @@ -214,10 +214,6 @@ func validateProjectName(ans interface{}) error { } var InitPlasmoid = func() error { - if err := utilsInstallDependencies(); err != nil { - return err - } - // Create project files from templates for relPath, content := range FileTemplates { if err := CreateFileFromTemplate(relPath, content); err != nil { @@ -238,7 +234,13 @@ var InitPlasmoid = func() error { // Create custom commands directory _ = osMkdirAll(filepath.Join(Config.Path, ".prasmoid/commands"), 0755) - dest := filepath.Join(os.Getenv("HOME"), ".local/share/plasma/plasmoids", Config.ID) + plasmoidDir := filepath.Join(os.Getenv("HOME"), ".local/share/plasma/plasmoids") + + if _, err := osStat(plasmoidDir); os.IsNotExist(err) { + _ = osMkdirAll(plasmoidDir, 0755) + } + + dest := filepath.Join(plasmoidDir, Config.ID) // Remove if exists _ = osRemoveAll(dest) @@ -363,7 +365,7 @@ func printNextSteps() { if Config.Name != "." { fmt.Printf("1. %s\n", cyan("cd ", Config.Name)) } - fmt.Printf("2. %s\n", cyan("plasmoid preview")) + fmt.Printf("2. %s\n", cyan("prasmoid preview")) } func clearLine() { diff --git a/cmd/init/init_test.go b/cmd/init/init_test.go index 53429f5..258edbe 100644 --- a/cmd/init/init_test.go +++ b/cmd/init/init_test.go @@ -441,27 +441,11 @@ func TestCreateMetadataFile(t *testing.T) { } func TestInitPlasmoid(t *testing.T) { - t.Run("fails if install dependencies fails", func(t *testing.T) { - _, cleanup := SetupTempDir(t) - defer cleanup() - - oldInstall := utilsInstallDependencies - utilsInstallDependencies = func() error { return errors.New("install error") } - defer func() { utilsInstallDependencies = oldInstall }() - - err := InitPlasmoid() - assert.Error(t, err) - assert.Contains(t, err.Error(), "install error") - }) - t.Run("fails if create file from template fails", func(t *testing.T) { _, cleanup := SetupTempDir(t) defer cleanup() // Mock dependencies - oldInstall := utilsInstallDependencies - utilsInstallDependencies = func() error { return nil } - defer func() { utilsInstallDependencies = oldInstall }() oldCreate := CreateFileFromTemplate CreateFileFromTemplate = func(relPath, contentTmpl string) error { return errors.New("template error") @@ -477,9 +461,6 @@ func TestInitPlasmoid(t *testing.T) { _, cleanup := SetupTempDir(t) defer cleanup() // Mock dependencies - oldInstall := utilsInstallDependencies - utilsInstallDependencies = func() error { return nil } - defer func() { utilsInstallDependencies = oldInstall }() oldCreateMeta := createMetadataFile createMetadataFile = func() error { return errors.New("metadata error") } defer func() { createMetadataFile = oldCreateMeta }() @@ -496,9 +477,6 @@ func TestInitPlasmoid(t *testing.T) { _, cleanup := SetupTempDir(t) defer cleanup() // Mock dependencies - oldInstall := utilsInstallDependencies - utilsInstallDependencies = func() error { return nil } - defer func() { utilsInstallDependencies = oldInstall }() oldCreateMeta := createMetadataFile createMetadataFile = func() error { return nil } defer func() { createMetadataFile = oldCreateMeta }() @@ -526,9 +504,6 @@ func TestInitPlasmoid(t *testing.T) { defer func() { osSymlink = oldSymlink }() // Mock other dependencies to succeed - oldInstall := utilsInstallDependencies - utilsInstallDependencies = func() error { return nil } - defer func() { utilsInstallDependencies = oldInstall }() oldCreateMeta := createMetadataFile createMetadataFile = func() error { return nil } defer func() { createMetadataFile = oldCreateMeta }() diff --git a/cmd/init/vars.go b/cmd/init/vars.go index bbca7c9..d0d1a13 100644 --- a/cmd/init/vars.go +++ b/cmd/init/vars.go @@ -29,10 +29,9 @@ var ( execCommand = exec.Command // utils - utilsAskForLocales = utils.AskForLocales utilsIsPackageInstalled = utils.IsPackageInstalled - utilsInstallDependencies = utils.InstallDependencies - + utilsAskForLocales = utils.AskForLocales + // json jsonMarshalIndent = json.MarshalIndent diff --git a/cmd/preview/preview.go b/cmd/preview/preview.go index 43f4218..84f2cfd 100644 --- a/cmd/preview/preview.go +++ b/cmd/preview/preview.go @@ -17,7 +17,6 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/PRASSamin/prasmoid/cmd" "github.com/PRASSamin/prasmoid/cmd/link" - "github.com/PRASSamin/prasmoid/consts" "github.com/PRASSamin/prasmoid/utils" "github.com/fatih/color" "github.com/fsnotify/fsnotify" @@ -52,11 +51,9 @@ var ( utilsIsValidPlasmoid = utils.IsValidPlasmoid utilsIsLinked = utils.IsLinked utilsGetDevDest = utils.GetDevDest - utilsIsPackageInstalled = utils.IsPackageInstalled - utilsDetectPackageManager = utils.DetectPackageManager - utilsInstallPackage = utils.InstallPackage utilsGetDataFromMetadata = utils.GetDataFromMetadata utilsIsQmlFile = utils.IsQmlFile + utilsIsPackageInstalled = utils.IsPackageInstalled // link linkLinkPlasmoid = link.LinkPlasmoid @@ -85,21 +82,31 @@ var ( timeAfterFunc = time.AfterFunc // confirmation - confirmInstallation bool confirmLink bool ) func init() { PreviewCmd.Flags().BoolP("watch", "w", false, "Watch for changes and automatically restart the preview. Note: This uses hot restart instead of hot reload, which may be slower.") + + if utilsIsPackageInstalled("plasmoidviewer") { + PreviewCmd.Short = "Enter plasmoid preview mode" + } else { + PreviewCmd.Short = fmt.Sprintf("Enter plasmoid preview mode %s", color.RedString("(disabled)")) + } + cmd.RootCmd.AddCommand(PreviewCmd) } // PreviewCmd represents the preview command var PreviewCmd = &cobra.Command{ Use: "preview", - Short: "Enter plasmoid preview mode", Long: "Launch the plasmoid in preview mode for testing and development.", Run: func(cmd *cobra.Command, args []string) { + if !utilsIsPackageInstalled("plasmoidviewer") { + fmt.Println(color.RedString("preview command is disabled due to missing dependencies.")) + fmt.Println(color.BlueString("- Use `prasmoid fix` to install them.")) + return + } if !utilsIsValidPlasmoid() { fmt.Println(color.RedString("Current directory is not a valid plasmoid.")) return @@ -131,26 +138,7 @@ var PreviewCmd = &cobra.Command{ } } - if !utilsIsPackageInstalled(consts.PlasmoidPreviewPackageName["binary"]) { - pm, _ := utilsDetectPackageManager() - confirmPrompt := &survey.Confirm{ - Message: "plasmoidviewer is not installed. Do you want to install it first?", - Default: true, - } - if err := surveyAskOne(confirmPrompt, &confirmInstallation); err != nil { - return - } - - if confirmInstallation { - if err := utilsInstallPackage(pm, consts.PlasmoidPreviewPackageName["binary"], consts.PlasmoidPreviewPackageName); err != nil { - fmt.Println(color.RedString("Failed to install plasmoidviewer: %v", err)) - return - } - } else { - fmt.Println("Operation cancelled.") - return - } - } + if err := previewPlasmoid(watch); err != nil { fmt.Println(color.RedString("Failed to preview plasmoid: %v", err)) @@ -304,4 +292,4 @@ var watchOnChange = func(path string, id string) { fmt.Printf("Previewer running in watch mode ... Press Ctrl+C to exit\n") <-done -} +} \ No newline at end of file diff --git a/cmd/preview/preview_test.go b/cmd/preview/preview_test.go index d16d8a8..38853d5 100644 --- a/cmd/preview/preview_test.go +++ b/cmd/preview/preview_test.go @@ -25,6 +25,33 @@ func TestPreviewCmdRun(t *testing.T) { previewTestMutex.Lock() defer previewTestMutex.Unlock() + t.Run("plasmoidviewer not installed", func(t *testing.T) { + // Arrange + _, _, cleanup := tests.SetupTestEnvironment(t) + defer cleanup() + + originalIsPackageInstalled := utilsIsPackageInstalled + utilsIsPackageInstalled = func(pkg string) bool { return false } + defer func() { utilsIsPackageInstalled = originalIsPackageInstalled }() + + // Capture output + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + color.Output = w + + // Act + PreviewCmd.Run(PreviewCmd, []string{}) + _ = w.Close() + + // Assert + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + os.Stdout = oldStdout + assert.Contains(t, buf.String(), "preview command is disabled due to missing dependencies.") + }) + + originalPreviewPlasmoid := previewPlasmoid defer func() { previewPlasmoid = originalPreviewPlasmoid @@ -34,8 +61,8 @@ func TestPreviewCmdRun(t *testing.T) { setupMocks := func() { utilsIsValidPlasmoid = func() bool { return true } utilsIsLinked = func() bool { return true } - utilsIsPackageInstalled = func(pkg string) bool { return true } previewPlasmoid = func(watch bool) error { return nil } + utilsIsPackageInstalled = func(pkg string) bool { return true } } t.Run("invalid plasmoid", func(t *testing.T) { @@ -62,40 +89,6 @@ func TestPreviewCmdRun(t *testing.T) { assert.Contains(t, buf.String(), "Current directory is not a valid plasmoid.") }) - t.Run("failed plasmaviewer install", func(t *testing.T) { - // Arrange - _, _, cleanup := tests.SetupTestEnvironment(t) - defer cleanup() - setupMocks() - orgUtilsIsPackageInstalled := utilsIsPackageInstalled - orgUtilsIsLinked := utilsIsLinked - orgUtilsInstallPackage := utilsInstallPackage - orgSurveyAskOne := surveyAskOne - - utilsIsPackageInstalled = func(name string) bool { return false } - utilsIsLinked = func() bool { return true } - utilsInstallPackage = func(pm, pkg string, names map[string]string) error { return errors.New("install error") } - surveyAskOne = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { return nil } - defer func() { utilsIsPackageInstalled = orgUtilsIsPackageInstalled }() - defer func() { utilsIsLinked = orgUtilsIsLinked }() - defer func() { utilsInstallPackage = orgUtilsInstallPackage }() - defer func() { surveyAskOne = orgSurveyAskOne }() - - orgStdout := os.Stdout - defer func() { os.Stdout = orgStdout }() - r, w, _ := os.Pipe() - os.Stdout = w - color.Output = w - - confirmInstallation = true - PreviewCmd.Run(PreviewCmd, []string{}) - _ = w.Close() - - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - assert.Contains(t, buf.String(), "Failed to install plasmoidviewer:") - }) - t.Run("failed to link", func(t *testing.T) { // Arrange _, _, cleanup := tests.SetupTestEnvironment(t) @@ -177,30 +170,6 @@ func TestPreviewCmdRun(t *testing.T) { assert.False(t, linkCalled, "LinkPlasmoid should not have been called") }) - t.Run("viewer not installed, user confirms install", func(t *testing.T) { - // Arrange - _, _, cleanup := tests.SetupTestEnvironment(t) - defer cleanup() - setupMocks() - utilsIsPackageInstalled = func(pkg string) bool { return false } - surveyAskOne = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { - *(response.(*bool)) = true - return nil - } - var installCalled bool - utilsInstallPackage = func(pm, pkg string, names map[string]string) error { - installCalled = true - return nil - } - previewPlasmoid = func(watch bool) error { return nil } - - // Act - PreviewCmd.Run(PreviewCmd, []string{}) - - // Assert - assert.True(t, installCalled, "InstallPackage should have been called") - }) - t.Run("previewPlasmoid fails", func(t *testing.T) { // Arrange _, _, cleanup := tests.SetupTestEnvironment(t) diff --git a/cmd/root.go b/cmd/root.go index 25113e9..4a70a0a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,18 +4,20 @@ Copyright 2025 PRAS package cmd import ( - "crypto/tls" + "bufio" + "crypto/sha256" "encoding/json" "fmt" "io" "log" + "net/http" "os" "path/filepath" - "strconv" "strings" "time" "github.com/PRASSamin/prasmoid/cmd/extendcli" + "github.com/PRASSamin/prasmoid/consts" "github.com/PRASSamin/prasmoid/internal" "github.com/PRASSamin/prasmoid/types" "github.com/PRASSamin/prasmoid/utils" @@ -41,20 +43,14 @@ var ( osTempDir = os.TempDir osReadFile = os.ReadFile osWriteFile = os.WriteFile + osExecutable = os.Executable + httpGet = http.Get // time timeParse = time.Parse timeSince = time.Since timeNow = time.Now - // net/tls - tlsDial = tls.Dial - connWrite = func(conn *tls.Conn, b []byte) (n int, err error) { return conn.Write(b) } - connClose = func(conn *tls.Conn) error { return conn.Close() } - - // io - ioReadAll = io.ReadAll - // encoding/json jsonUnmarshal = json.Unmarshal jsonMarshal = json.Marshal @@ -77,8 +73,15 @@ func init() { RootCmd.Flags().BoolP("version", "v", false, "show Prasmoid version") RootCmd.AddGroup(&cobra.Group{ ID: "custom", - Title: "Custom Commands:", + Title: "Custom Commands", + }) + + RootCmd.AddGroup(&cobra.Group{ + ID: "cli", + Title: "Maintenance Commands", }) + + RootCmd.SetHelpCommandGroupID("cli") } // rootCmd represents the base command when called without any subcommands @@ -102,8 +105,9 @@ var RootCmd = &cobra.Command{ // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { extendcliDiscoverAndRegisterCustomCommands(RootCmd, ConfigRC) - CheckForUpdates() + + RootCmd.SetUsageTemplate(consts.UsageTemplate) err := rootCmdExecute() if err != nil { @@ -114,8 +118,6 @@ func Execute() { // -------- UPDATE CHECKER -------- var CheckForUpdates = func() { - const host = "api.github.com" - const path = "/repos/PRASSamin/prasmoid/releases/latest" const checkInterval = 24 * time.Hour cache, err := readUpdateCache() @@ -123,9 +125,12 @@ var CheckForUpdates = func() { if lastCheckedStr, ok := cache["last_checked"].(string); ok { lastCheckedTime, err := timeParse(time.RFC3339, lastCheckedStr) if err == nil && timeSince(lastCheckedTime) < checkInterval { - if latestTag, ok := cache["latest_tag"].(string); ok { - if isUpdateAvailable(latestTag) { - printUpdateMessage(latestTag) + if latestHash, ok := cache["latest_hash"].(string); ok { + isAvailable, currentHash := isUpdateAvailable(latestHash) + if isAvailable { + if latestTag, ok := cache["latest_tag"].(string); ok { + printUpdateMessage(latestTag, latestHash, currentHash) + } } } return @@ -133,114 +138,139 @@ var CheckForUpdates = func() { } } - // Establish TLS connection to GitHub - conn, err := tlsDial("tcp", host+":443", nil) + releaseJSON, err := fetchURL("https://api.github.com/repos/PRASSamin/prasmoid/releases/latest") if err != nil { return } - defer func() { - if err := connClose(conn); err != nil { - logPrintf("Error closing connection: %v", err) - } - }() - - // Manually write the HTTP GET request - request := fmt.Sprintf( - "GET %s HTTP/1.1\r\nHost: %s\r\nUser-Agent: Prasmoid-Updater\r\nConnection: close\r\n\r\n", - path, host, - ) - _, err = connWrite(conn, []byte(request)) - if err != nil { + + var releaseData map[string]interface{} + if err := jsonUnmarshal([]byte(releaseJSON), &releaseData); err != nil { return } - // Read the raw HTTP response - raw, err := ioReadAll(conn) + sha256sums, err := getSha256Sums(releaseData["assets"]) + if err != nil { return } - // Parse the body from the response (after \r\n\r\n) - parts := strings.SplitN(string(raw), "\r\n\r\n", 2) - if len(parts) < 2 { - return + assetName := "prasmoid" + if strings.Contains(internalAppMetaDataVersion, "-portable") { + assetName = "prasmoid-portable" } - headers := parts[0] - body := parts[1] - // parse status code from headers - if !strings.Contains(headers, "200 OK") { - return + latestHash := parseChecksums(sha256sums, assetName) + latestTag := getLatestTag(releaseData) + + writeUpdateCache(latestTag, latestHash) + + isAvailable, currentHash := isUpdateAvailable(latestHash) + if isAvailable { + printUpdateMessage(latestTag, latestHash, currentHash) } +} - // Extract latest tag and do the same flow - latestTag := getLatestTag([]byte(body)) - writeUpdateCache(latestTag, []byte(body)) - if isUpdateAvailable(latestTag) { - printUpdateMessage(latestTag) +var getLatestTag = func(releaseData map[string]interface{}) string { + tag, ok := releaseData["tag_name"].(string) + if !ok { + return "" } + return strings.TrimPrefix(tag, "v") } -var getLatestTag = func(body []byte) string { - var tagData map[string]interface{} +var parseChecksums = func(content, assetName string) string { + scanner := bufio.NewScanner(strings.NewReader(content)) + for scanner.Scan() { + parts := strings.Fields(scanner.Text()) + if len(parts) == 2 && parts[1] == assetName { + return parts[0] + } + } + return "" +} - err := jsonUnmarshal(body, &tagData) +var calculateFileSHA256 = func(filePath string) (string, error) { + file, err := os.Open(filePath) if err != nil { - return "" + return "", err } + defer func() { _ = file.Close() }() - tag, ok := tagData["tag_name"].(string) - if !ok { - return "" + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err } + return fmt.Sprintf("%x", hash.Sum(nil)), nil +} - return strings.TrimPrefix(tag, "v") +var fetchURL = func(rawURL string) (string, error) { + resp, err := httpGet(rawURL) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("non-200 status for %s: %s", rawURL, resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + return string(body), nil } -// compareVersions returns: -// -1 if current < latest -// 0 if current == latest -// 1 if current > latest -var compareVersions = func(current, latest string) int { - parse := func(v string) []int { - v = strings.TrimPrefix(v, "v") - parts := strings.Split(v, ".") - out := make([]int, 3) - for i := 0; i < 3 && i < len(parts); i++ { - num, err := strconv.Atoi(parts[i]) - if err != nil { - out[i] = 0 - } else { - out[i] = num +var getSha256Sums = func(assets interface{}) (string, error) { + assetsList, ok := assets.([]interface{}) + + if !ok { + return "", fmt.Errorf("invalid assets format") + } + + var shaURL string + for _, asset := range assetsList { + assetMap, ok := asset.(map[string]interface{}) + if !ok { + continue + } + if name, ok := assetMap["name"].(string); ok && name == "SHA256SUMS" { + if url, ok := assetMap["browser_download_url"].(string); ok { + shaURL = url + break } } - return out } - curr := parse(current) - lat := parse(latest) + if shaURL == "" { + return "", fmt.Errorf("SHA256SUMS URL not found in release assets") + } - for i := 0; i < 3; i++ { - if curr[i] < lat[i] { - return -1 - } - if curr[i] > lat[i] { - return 1 - } + sha256sums, err := fetchURL(shaURL) + + if err != nil { + return "", err } - return 0 + return sha256sums, nil } -var isUpdateAvailable = func(latestTag string) bool { - if latestTag == "" { - return false +var isUpdateAvailable = func(latestHash string) (bool, string) { + if latestHash == "" { + return false, "" } - - current := internalAppMetaDataVersion - return compareVersions(current, latestTag) < 0 + exePath, err := osExecutable() + if err != nil { + return false, "" + } + currentHash, err := calculateFileSHA256(exePath) + if err != nil { + return false, "" + } + return currentHash != latestHash, currentHash } -var printUpdateMessage = func(latest string) { +var printUpdateMessage = func(latest string, latestHash string, currentHash string) { // Get terminal width width, _, err := termGetSize(int(os.Stdout.Fd())) if err != nil { @@ -255,8 +285,11 @@ var printUpdateMessage = func(latest string) { return fmt.Sprintf(" %s ", content) } + currentVersion := fmt.Sprintf("%s.%s", strings.Split(internalAppMetaDataVersion, "-")[0], currentHash[:4]) + latestVersion := fmt.Sprintf("%s.%s", latest, latestHash[:4]) + fmt.Println(star(bottom)) - fmt.Println(star(printLine(fmt.Sprintf("💠 Prasmoid update available! %s → %s", internalAppMetaDataVersion, latest)))) + fmt.Println(star(printLine(fmt.Sprintf("💠 Prasmoid update available! %s → %s", currentVersion, latestVersion)))) fmt.Println(star(printLine("Run `prasmoid upgrade` to update"))) fmt.Println(star(bottom)) fmt.Println() @@ -267,11 +300,12 @@ var GetCacheFilePath = func() string { if err != nil { dir = osTempDir() } - return filepath.Join(dir, "prasmoid_update.json") + return filepath.Join(dir, ".prasmoid") } var readUpdateCache = func() (map[string]interface{}, error) { path := GetCacheFilePath() + data, err := osReadFile(path) if err != nil { return nil, err @@ -282,15 +316,12 @@ var readUpdateCache = func() (map[string]interface{}, error) { return cache, err } -var writeUpdateCache = func(tag string, body []byte) { - var releaseData map[string]interface{} - _ = jsonUnmarshal(body, &releaseData) - +var writeUpdateCache = func(tag, hash string) { cache := map[string]interface{}{ "last_checked": timeNow().Format(time.RFC3339), "latest_tag": tag, - "data": releaseData, + "latest_hash": hash, } data, _ := jsonMarshal(cache) _ = osWriteFile(GetCacheFilePath(), data, 0o644) -} +} \ No newline at end of file diff --git a/cmd/root_test.go b/cmd/root_test.go index 566b1bd..63136d7 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -2,698 +2,276 @@ package cmd import ( "bytes" - "crypto/tls" - "encoding/json" "errors" + "fmt" "io" + "net/http" "os" - "path/filepath" "strings" "testing" "time" "github.com/PRASSamin/prasmoid/internal" - "github.com/PRASSamin/prasmoid/types" "github.com/fatih/color" - "github.com/spf13/cobra" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestRootCmd_Run(t *testing.T) { - // Capture stdout - captureOutput := func() (*bytes.Buffer, func()) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - color.Output = w // Redirect color output as well - buf := new(bytes.Buffer) - return buf, func() { - _ = w.Close() - _, _ = io.Copy(buf, r) - os.Stdout = oldStdout - color.Output = oldStdout - } - } - - // Mock os.Exit to prevent actual exit during test - oldOsExit := osExit - defer func() { osExit = oldOsExit }() - var exitCode int - osExit = func(code int) { exitCode = code } - - t.Run("version flag set", func(t *testing.T) { - // Arrange - cmd := &cobra.Command{} - cmd.Flags().BoolP("version", "v", false, "show Prasmoid version") - _ = cmd.Flags().Set("version", "true") - buf, restore := captureOutput() - - // Act - RootCmd.Run(cmd, []string{}) - - // Assert - restore() - assert.Contains(t, buf.String(), internal.AppMetaData.Version) - assert.Equal(t, 0, exitCode) - }) -} - -func TestExecute(t *testing.T) { - // Save original functions - originalDiscover := extendcliDiscoverAndRegisterCustomCommands - originalCheckForUpdates := CheckForUpdates - originalRootCmdExecute := rootCmdExecute - originalOsExit := osExit - - t.Cleanup(func() { - extendcliDiscoverAndRegisterCustomCommands = originalDiscover - CheckForUpdates = originalCheckForUpdates - rootCmdExecute = originalRootCmdExecute - osExit = originalOsExit - }) - - // Mock os.Exit to prevent actual exit during test - var exitCode int - osExit = func(code int) { exitCode = code } - - t.Run("successful execution", func(t *testing.T) { - // Arrange - var discoverCalled, checkForUpdatesCalled, rootCmdExecuteCalled bool - extendcliDiscoverAndRegisterCustomCommands = func(*cobra.Command, types.Config) { discoverCalled = true } - CheckForUpdates = func() { checkForUpdatesCalled = true } - rootCmdExecute = func() error { rootCmdExecuteCalled = true; return nil } - - // Act - Execute() - - // Assert - assert.True(t, discoverCalled) - assert.True(t, checkForUpdatesCalled) - assert.True(t, rootCmdExecuteCalled) - assert.Equal(t, 0, exitCode) - }) - - t.Run("rootCmdExecute returns error", func(t *testing.T) { - // Arrange - extendcliDiscoverAndRegisterCustomCommands = func(*cobra.Command, types.Config) {} - CheckForUpdates = func() {} - rootCmdExecute = func() error { return errors.New("execute error") } +// --- Mocks and Test Setup --- - // Act - Execute() +var testExeHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" // sha256 of empty string - // Assert - assert.Equal(t, 1, exitCode) - }) -} - -func TestCheckForUpdates_AllBranches(t *testing.T) { +func setupUpdateCheckerTests(t *testing.T) func() { // Backup originals origReadUpdateCache := readUpdateCache + origWriteUpdateCache := writeUpdateCache origTimeParse := timeParse origTimeSince := timeSince - origTlsDial := tlsDial - origConnWrite := connWrite - origConnClose := connClose - origIoReadAll := ioReadAll - origGetLatestTag := getLatestTag - origWriteUpdateCache := writeUpdateCache + origFetchURL := fetchURL origIsUpdateAvailable := isUpdateAvailable origPrintUpdateMessage := printUpdateMessage - origLogPrintf := logPrintf + origOsExecutable := osExecutable + origCalculateFileSHA256 := calculateFileSHA256 + origInternalVersion := internalAppMetaDataVersion + origHttpGet := httpGet - t.Cleanup(func() { + // Restore at the end of the test + return func() { readUpdateCache = origReadUpdateCache + writeUpdateCache = origWriteUpdateCache timeParse = origTimeParse timeSince = origTimeSince - tlsDial = origTlsDial - connWrite = origConnWrite - connClose = origConnClose - ioReadAll = origIoReadAll - getLatestTag = origGetLatestTag - writeUpdateCache = origWriteUpdateCache + fetchURL = origFetchURL isUpdateAvailable = origIsUpdateAvailable printUpdateMessage = origPrintUpdateMessage - logPrintf = origLogPrintf - }) - - logPrintf = func(format string, v ...interface{}) { /* silence */ } - - t.Run("cache expired due to parse error", func(t *testing.T) { - readUpdateCache = func() (map[string]interface{}, error) { - return map[string]interface{}{"last_checked": "bad-time"}, nil - } - timeParse = func(layout, value string) (time.Time, error) { return time.Time{}, errors.New("bad time") } - tlsDial = func(_, _ string, _ *tls.Config) (*tls.Conn, error) { return nil, errors.New("dial fail") } - - CheckForUpdates() // should early return, no panic - }) - - t.Run("tlsDial works, but connWrite fails", func(t *testing.T) { - readUpdateCache = func() (map[string]interface{}, error) { return nil, errors.New("miss") } - mockConn := &tls.Conn{} - tlsDial = func(_, _ string, _ *tls.Config) (*tls.Conn, error) { return mockConn, nil } - connWrite = func(_ *tls.Conn, _ []byte) (int, error) { return 0, errors.New("write fail") } - connClose = func(_ *tls.Conn) error { return nil } - - CheckForUpdates() // hit connWrite fail branch - }) - - t.Run("tlsDial ok, connWrite ok, ioReadAll fails", func(t *testing.T) { - readUpdateCache = func() (map[string]interface{}, error) { return nil, errors.New("miss") } - mockConn := &tls.Conn{} - tlsDial = func(_, _ string, _ *tls.Config) (*tls.Conn, error) { return mockConn, nil } - connWrite = func(_ *tls.Conn, _ []byte) (int, error) { return 1, nil } - ioReadAll = func(_ io.Reader) ([]byte, error) { return nil, errors.New("read fail") } - connClose = func(_ *tls.Conn) error { return errors.New("close fail") } - - var logged bool - logPrintf = func(format string, v ...interface{}) { logged = true } - - CheckForUpdates() - assert.True(t, logged, "logPrintf should be called when connClose fails") - }) - - t.Run("response malformed (<2 parts)", func(t *testing.T) { - readUpdateCache = func() (map[string]interface{}, error) { return nil, errors.New("miss") } - mockConn := &tls.Conn{} - tlsDial = func(_, _ string, _ *tls.Config) (*tls.Conn, error) { return mockConn, nil } - connWrite = func(_ *tls.Conn, _ []byte) (int, error) { return 1, nil } - ioReadAll = func(_ io.Reader) ([]byte, error) { return []byte("no-body-here"), nil } - connClose = func(_ *tls.Conn) error { return nil } - - CheckForUpdates() // hits malformed response branch - }) - - t.Run("response not 200 OK", func(t *testing.T) { - readUpdateCache = func() (map[string]interface{}, error) { return nil, errors.New("miss") } - mockConn := &tls.Conn{} - tlsDial = func(_, _ string, _ *tls.Config) (*tls.Conn, error) { return mockConn, nil } - connWrite = func(_ *tls.Conn, _ []byte) (int, error) { return 1, nil } - ioReadAll = func(_ io.Reader) ([]byte, error) { - return []byte("HTTP/1.1 404 Not Found\r\n\r\n{}"), nil - } - connClose = func(_ *tls.Conn) error { return nil } - - CheckForUpdates() - }) - - t.Run("cached, update available", func(t *testing.T) { - readUpdateCache = func() (map[string]interface{}, error) { - return map[string]interface{}{ - "last_checked": time.Now().Format(time.RFC3339), - "latest_tag": "2.0.0", - }, - nil - } - timeParse = func(layout, value string) (time.Time, error) { return time.Now(), nil } - timeSince = func(t time.Time) time.Duration { return 1 * time.Hour } - isUpdateAvailable = func(tag string) bool { return true } - var tlsDialCalled bool - tlsDial = func(network, addr string, config *tls.Config) (*tls.Conn, error) { - tlsDialCalled = true - return nil, errors.New("mock error") - } - - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - color.Output = w // Redirect color output as well - buf := new(bytes.Buffer) - - // Act - CheckForUpdates() - - // Assert - _ = w.Close() - _, _ = io.Copy(buf, r) - os.Stdout = oldStdout - color.Output = oldStdout - assert.False(t, tlsDialCalled, "tlsDial should not be called if cache is valid and update available") - require.Contains(t, buf.String(), "update available") - require.Contains(t, buf.String(), "2.0.0") - }) - - t.Run("happy flow, update available", func(t *testing.T) { - readUpdateCache = func() (map[string]interface{}, error) { return nil, errors.New("miss") } - mockConn := &tls.Conn{} - tlsDial = func(_, _ string, _ *tls.Config) (*tls.Conn, error) { return mockConn, nil } - connWrite = func(_ *tls.Conn, _ []byte) (int, error) { return 1, nil } - ioReadAll = func(_ io.Reader) ([]byte, error) { - return []byte("HTTP/1.1 200 OK\r\n\r\n{\"tag_name\":\"3.0.0\"}"), nil - } - connClose = func(_ *tls.Conn) error { return nil } - getLatestTag = func(b []byte) string { return "3.0.0" } - writeUpdateCache = func(tag string, b []byte) {} - isUpdateAvailable = func(tag string) bool { return true } - var printed bool - printUpdateMessage = func(tag string) { printed = true } - - CheckForUpdates() - assert.True(t, printed) - }) - - t.Run("happy flow, update available", func(t *testing.T) { - readUpdateCache = func() (map[string]interface{}, error) { return nil, errors.New("miss") } - mockConn := &tls.Conn{} - tlsDial = func(_, _ string, _ *tls.Config) (*tls.Conn, error) { return mockConn, nil } - connWrite = func(_ *tls.Conn, _ []byte) (int, error) { return 1, nil } - ioReadAll = func(_ io.Reader) ([]byte, error) { - return []byte("HTTP/1.1 200 OK\r\n\r\n{\"tag_name\":\"3.0.0\"}"), nil - } - connClose = func(_ *tls.Conn) error { return nil } - getLatestTag = func(b []byte) string { return "3.0.0" } - writeUpdateCache = func(tag string, b []byte) {} - isUpdateAvailable = func(tag string) bool { return true } - var printed bool - printUpdateMessage = func(tag string) { printed = true } - - CheckForUpdates() - assert.True(t, printed) - }) - - t.Run("happy flow, no update available", func(t *testing.T) { - readUpdateCache = func() (map[string]interface{}, error) { return nil, errors.New("miss") } - mockConn := &tls.Conn{} - tlsDial = func(_, _ string, _ *tls.Config) (*tls.Conn, error) { return mockConn, nil } - connWrite = func(_ *tls.Conn, _ []byte) (int, error) { return 1, nil } - ioReadAll = func(_ io.Reader) ([]byte, error) { - return []byte("HTTP/1.1 200 OK\r\n\r\n{\"tag_name\":\"3.0.0\"}"), nil - } - connClose = func(_ *tls.Conn) error { return nil } - getLatestTag = func(b []byte) string { return "3.0.0" } - writeUpdateCache = func(tag string, b []byte) {} - isUpdateAvailable = func(tag string) bool { return false } - printUpdateMessage = func(tag string) { t.Fatal("should not print") } - - CheckForUpdates() - }) + osExecutable = origOsExecutable + calculateFileSHA256 = origCalculateFileSHA256 + internal.AppMetaData.Version = origInternalVersion + httpGet = origHttpGet + } } -func TestGetCacheFilePath(t *testing.T) { - // Save original functions - originalOsUserCacheDir := osUserCacheDir - originalOsTempDir := osTempDir +// --- Individual Function Tests --- - t.Cleanup(func() { - osUserCacheDir = originalOsUserCacheDir - osTempDir = originalOsTempDir +func TestParseChecksums(t *testing.T) { + checksums := ` +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 prasmoid +9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 prasmoid-portable +` + t.Run("finds standard asset", func(t *testing.T) { + hash := parseChecksums(checksums, "prasmoid") + assert.Equal(t, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", hash) }) - - t.Run("UserCacheDir succeeds", func(t *testing.T) { - // Arrange - osUserCacheDir = func() (string, error) { return "/home/user/.cache", nil } - - // Act - path := GetCacheFilePath() - - // Assert - assert.Equal(t, filepath.Join("/home/user/.cache", "prasmoid_update.json"), path) + t.Run("finds portable asset", func(t *testing.T) { + hash := parseChecksums(checksums, "prasmoid-portable") + assert.Equal(t, "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", hash) }) - - t.Run("UserCacheDir fails, TempDir succeeds", func(t *testing.T) { - // Arrange - osUserCacheDir = func() (string, error) { return "", errors.New("cache dir error") } - osTempDir = func() string { return "/tmp" } - - // Act - path := GetCacheFilePath() - - // Assert - assert.Equal(t, filepath.Join("/tmp", "prasmoid_update.json"), path) + t.Run("returns empty for missing asset", func(t *testing.T) { + hash := parseChecksums(checksums, "missing-asset") + assert.Empty(t, hash) }) } -func TestReadUpdateCache(t *testing.T) { - // Save original functions - originalGetCacheFilePath := GetCacheFilePath - originalOsReadFile := osReadFile - originalJsonUnmarshal := jsonUnmarshal - - t.Cleanup(func() { - GetCacheFilePath = originalGetCacheFilePath - osReadFile = originalOsReadFile - jsonUnmarshal = originalJsonUnmarshal - }) - - t.Run("file read fails", func(t *testing.T) { - // Arrange - GetCacheFilePath = func() string { return "/nonexistent/path" } - osReadFile = func(name string) ([]byte, error) { return nil, errors.New("read error") } - - // Act - _, err := readUpdateCache() - - // Assert - assert.Error(t, err) - assert.Contains(t, err.Error(), "read error") - }) - - t.Run("json unmarshal fails", func(t *testing.T) { - // Arrange - GetCacheFilePath = func() string { return "/mock/path" } - osReadFile = func(name string) ([]byte, error) { return []byte("invalid json"), nil } - jsonUnmarshal = func(data []byte, v interface{}) error { return errors.New("unmarshal error") } - - // Act - _, err := readUpdateCache() - - // Assert - assert.Error(t, err) - }) - - t.Run("success", func(t *testing.T) { - // Arrange - GetCacheFilePath = func() string { return "/mock/path" } - osReadFile = func(name string) ([]byte, error) { return []byte(`{"key":"value"}`), nil } - jsonUnmarshal = func(data []byte, v interface{}) error { - if m, ok := v.(*map[string]interface{}); ok { - *m = map[string]interface{}{ - "key": "value", - } - } - return nil - } - - // Act - cache, err := readUpdateCache() +func TestIsUpdateAvailable(t *testing.T) { + cleanup := setupUpdateCheckerTests(t) + defer cleanup() - // Assert - assert.NoError(t, err) - assert.NotNil(t, cache) - assert.Equal(t, "value", cache["key"]) + t.Run("returns false if latest hash is empty", func(t *testing.T) { + isAvailable, _ := isUpdateAvailable("") + assert.False(t, isAvailable) }) -} -func TestWriteUpdateCache(t *testing.T) { - // Save original functions - originalGetCacheFilePath := GetCacheFilePath - originalOsWriteFile := osWriteFile - originalJsonMarshal := jsonMarshal - originalJsonUnmarshal := jsonUnmarshal // Used by writeUpdateCache internally - originalTimeNow := timeNow - - t.Cleanup(func() { - GetCacheFilePath = originalGetCacheFilePath - osWriteFile = originalOsWriteFile - jsonMarshal = originalJsonMarshal - jsonUnmarshal = originalJsonUnmarshal - timeNow = originalTimeNow + t.Run("returns false if os.Executable fails", func(t *testing.T) { + osExecutable = func() (string, error) { return "", errors.New("exec error") } + isAvailable, _ := isUpdateAvailable("somehash") + assert.False(t, isAvailable) }) - t.Run("json unmarshal fails (releaseData)", func(t *testing.T) { - // Arrange - jsonUnmarshal = func(data []byte, v interface{}) error { return errors.New("unmarshal error") } - var writeFileCalled bool - osWriteFile = func(name string, data []byte, perm os.FileMode) error { writeFileCalled = true; return nil } - - // Act - writeUpdateCache("v1.0.0", []byte("invalid json")) - - // Assert: Should not panic, but write file might still be called with empty data - assert.True(t, writeFileCalled) + t.Run("returns false if calculateFileSHA256 fails", func(t *testing.T) { + osExecutable = func() (string, error) { return "/path/to/exe", nil } + calculateFileSHA256 = func(filePath string) (string, error) { return "", errors.New("hash error") } + isAvailable, _ := isUpdateAvailable("somehash") + assert.False(t, isAvailable) }) - t.Run("json marshal fails (cache data)", func(t *testing.T) { - // Arrange - jsonMarshal = func(v interface{}) ([]byte, error) { return nil, errors.New("marshal error") } - var writeFileCalled bool - osWriteFile = func(name string, data []byte, perm os.FileMode) error { writeFileCalled = true; return nil } - - // Act - writeUpdateCache("v1.0.0", []byte(`{"tag_name":"v1.0.0"}`)) - - // Assert: Should not panic, but write file might still be called with empty data - assert.True(t, writeFileCalled) + t.Run("returns false and current hash if hashes match", func(t *testing.T) { + osExecutable = func() (string, error) { return "/path/to/exe", nil } + calculateFileSHA256 = func(filePath string) (string, error) { return testExeHash, nil } + isAvailable, currentHash := isUpdateAvailable(testExeHash) + assert.False(t, isAvailable) + assert.Equal(t, testExeHash, currentHash) }) - t.Run("osWriteFile fails", func(t *testing.T) { - // Arrange - osWriteFile = func(name string, data []byte, perm os.FileMode) error { return errors.New("write error") } - - // Act - writeUpdateCache("v1.0.0", []byte(`{"tag_name":"v1.0.0"}`)) - - // Assert: Should not panic + t.Run("returns true and current hash if hashes differ", func(t *testing.T) { + osExecutable = func() (string, error) { return "/path/to/exe", nil } + calculateFileSHA256 = func(filePath string) (string, error) { return testExeHash, nil } + isAvailable, currentHash := isUpdateAvailable("differenthash") + assert.True(t, isAvailable) + assert.Equal(t, testExeHash, currentHash) }) +} - t.Run("success", func(t *testing.T) { - // Arrange - var writtenData []byte - osWriteFile = func(name string, data []byte, perm os.FileMode) error { - writtenData = data - return nil - } - - // Mock jsonUnmarshal to parse the input body - jsonUnmarshal = func(data []byte, v interface{}) error { - if m, ok := v.(*map[string]interface{}); ok { - *m = map[string]interface{}{ - "tag_name": "v1.0.0", - "other": "data", - } - } - return nil - } +func TestPrintUpdateMessage(t *testing.T) { + cleanup := setupUpdateCheckerTests(t) + defer cleanup() - // Mock jsonMarshal to return test data - jsonMarshal = func(v interface{}) ([]byte, error) { - return json.Marshal(map[string]interface{}{ - "last_checked": "2025-01-01T00:00:00Z", - "latest_tag": "v1.0.0", - "data": map[string]interface{}{ - "tag_name": "v1.0.0", - "other": "data", - }, - }) - } + // Capture output + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + color.Output = w - timeNow = func() time.Time { return time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) } + printUpdateMessage("2.0.0", "newhash12345", "oldhash67890") - // Act - writeUpdateCache("v1.0.0", []byte(`{"tag_name":"v1.0.0","other":"data"}`)) + _ = w.Close() + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + os.Stdout = oldStdout + output := buf.String() - // Assert - assert.NotNil(t, writtenData) - assert.Contains(t, string(writtenData), `"latest_tag":"v1.0.0"`) - assert.Contains(t, string(writtenData), `"last_checked":"2025-01-01T00:00:00Z"`) - }) + assert.Contains(t, output, fmt.Sprintf("%s.oldh → 2.0.0.newh", strings.Split(internal.AppMetaData.Version, "-")[0])) } -func TestGetLatestTag(t *testing.T) { - // Save original function - originalJsonUnmarshal := jsonUnmarshal - t.Cleanup(func() { - jsonUnmarshal = originalJsonUnmarshal - }) - - t.Run("json unmarshal fails", func(t *testing.T) { - // Arrange - jsonUnmarshal = func(data []byte, v interface{}) error { return errors.New("unmarshal error") } +func TestCheckForUpdates(t *testing.T) { + cleanup := setupUpdateCheckerTests(t) + defer cleanup() - // Act - tag := getLatestTag([]byte("invalid json")) + var printCalled bool + printUpdateMessage = func(latest, latestHash, currentHash string) { + printCalled = true + } - // Assert - assert.Empty(t, tag) - }) + // Restore original isUpdateAvailable for this test block + origIsUpdateAvailable := isUpdateAvailable + defer func() { isUpdateAvailable = origIsUpdateAvailable }() - t.Run("tag_name not found", func(t *testing.T) { - // Arrange - jsonUnmarshal = func(data []byte, v interface{}) error { - if m, ok := v.(*map[string]interface{}); ok { - *m = map[string]interface{}{ - "some_other_key": "value", - } - } - return nil + t.Run("cache is fresh and no update available", func(t *testing.T) { + printCalled = false + readUpdateCache = func() (map[string]interface{}, error) { + return map[string]interface{}{ + "last_checked": time.Now().Format(time.RFC3339), + "latest_hash": testExeHash, + }, nil } + timeParse = func(layout, value string) (time.Time, error) { return time.Now(), nil } + timeSince = func(t time.Time) time.Duration { return 1 * time.Hour } + isUpdateAvailable = func(latestHash string) (bool, string) { return false, testExeHash } - // Act - tag := getLatestTag([]byte(`{"some_other_key":"value"}`)) + CheckForUpdates() - // Assert - assert.Empty(t, tag) + assert.False(t, printCalled) }) - t.Run("tag_name is not string", func(t *testing.T) { - // Arrange - jsonUnmarshal = func(data []byte, v interface{}) error { - if m, ok := v.(*map[string]interface{}); ok { - *m = map[string]interface{}{ - "tag_name": 123, - } - } - return nil + t.Run("cache is fresh and update is available", func(t *testing.T) { + printCalled = false + readUpdateCache = func() (map[string]interface{}, error) { + return map[string]interface{}{ + "last_checked": time.Now().Format(time.RFC3339), + "latest_hash": "new_hash", + "latest_tag": "2.0.0", + }, nil } + timeParse = func(layout, value string) (time.Time, error) { return time.Now(), nil } + timeSince = func(t time.Time) time.Duration { return 1 * time.Hour } + isUpdateAvailable = func(latestHash string) (bool, string) { return true, testExeHash } - // Act - tag := getLatestTag([]byte(`{"tag_name":123}`)) + CheckForUpdates() - // Assert - assert.Empty(t, tag) + assert.True(t, printCalled) }) - t.Run("success with v prefix", func(t *testing.T) { - // Arrange - jsonUnmarshal = func(data []byte, v interface{}) error { - if m, ok := v.(*map[string]interface{}); ok { - *m = map[string]interface{}{ - "tag_name": "v1.2.3", - } + t.Run("cache is stale, network fetch succeeds, update available", func(t *testing.T) { + printCalled = false + readUpdateCache = func() (map[string]interface{}, error) { return nil, errors.New("cache miss") } + httpGet = func(rawURL string) (*http.Response, error) { + if strings.Contains(rawURL, "releases/latest") { + json := `{"tag_name":"2.0.0", "assets":[{"name":"SHA256SUMS", "browser_download_url":"http://localhost/SHA256SUMS"}]}` + body := io.NopCloser(strings.NewReader(json)) + return &http.Response{StatusCode: 200, Body: body}, nil } - return nil - } - - // Act - tag := getLatestTag([]byte(`{"tag_name":"v1.2.3"}`)) - - // Assert - assert.Equal(t, "1.2.3", tag) - }) - - t.Run("success without v prefix", func(t *testing.T) { - // Arrange - jsonUnmarshal = func(data []byte, v interface{}) error { - if m, ok := v.(*map[string]interface{}); ok { - *m = map[string]interface{}{ - "tag_name": "1.2.3", - } + if strings.Contains(rawURL, "SHA256SUMS") { + body := io.NopCloser(strings.NewReader("new_hash prasmoid")) + return &http.Response{StatusCode: 200, Body: body}, nil } - return nil + return nil, errors.New("unexpected URL") } + var cacheWritten bool + writeUpdateCache = func(tag, hash string) { cacheWritten = true } + isUpdateAvailable = func(latestHash string) (bool, string) { return true, testExeHash } - // Act - tag := getLatestTag([]byte(`{"tag_name":"1.2.3"}`)) + CheckForUpdates() - // Assert - assert.Equal(t, "1.2.3", tag) + assert.True(t, cacheWritten) + assert.True(t, printCalled) }) } -func TestCompareVersions(t *testing.T) { - t.Run("current less than latest", func(t *testing.T) { - assert.Equal(t, -1, compareVersions("1.0.0", "1.0.1")) - assert.Equal(t, -1, compareVersions("1.0.0", "1.1.0")) - assert.Equal(t, -1, compareVersions("1.0.0", "2.0.0")) - assert.Equal(t, -1, compareVersions("v1.0.0", "v1.0.1")) - }) +func TestCalculateFileSHA256(t *testing.T) { + t.Run("calculates hash of a file", func(t *testing.T) { + content := "hello world" + tmpfile, err := os.CreateTemp("", "sha_test_*") + assert.NoError(t, err) + defer func() { _ = os.Remove(tmpfile.Name()) }() - t.Run("current equal to latest", func(t *testing.T) { - assert.Equal(t, 0, compareVersions("1.0.0", "1.0.0")) - assert.Equal(t, 0, compareVersions("v1.0.0", "1.0.0")) - assert.Equal(t, 0, compareVersions("1.0.0", "v1.0.0")) - }) + _, err = tmpfile.WriteString(content) + assert.NoError(t, err) + assert.NoError(t, tmpfile.Close()) - t.Run("current greater than latest", func(t *testing.T) { - assert.Equal(t, 1, compareVersions("1.0.1", "1.0.0")) - assert.Equal(t, 1, compareVersions("1.1.0", "1.0.0")) - assert.Equal(t, 1, compareVersions("2.0.0", "1.0.0")) - assert.Equal(t, 1, compareVersions("v2.0.0", "v1.0.0")) + hash, err := calculateFileSHA256(tmpfile.Name()) + assert.NoError(t, err) + // sha256sum of "hello world" + assert.Equal(t, "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", hash) }) - t.Run("malformed versions", func(t *testing.T) { - assert.Equal(t, -1, compareVersions("abc", "1.0.0")) // 0.0.0 < 1.0.0 - assert.Equal(t, 1, compareVersions("1.0.0", "abc")) // 1.0.0 > 0.0.0 - assert.Equal(t, 0, compareVersions("1.0", "1.0.0")) // 1.0.0 == 1.0.0 (missing parts default to 0) - assert.Equal(t, 0, compareVersions("1", "1.0.0")) // 1.0.0 == 1.0.0 (missing parts default to 0) + t.Run("returns error for non-existent file", func(t *testing.T) { + _, err := calculateFileSHA256("/non/existent/file") + assert.Error(t, err) }) } -func TestIsUpdateAvailable(t *testing.T) { - // Save original function - originalCompareVersions := compareVersions - originalInternalAppMetaDataVersion := internalAppMetaDataVersion - - t.Cleanup(func() { - compareVersions = originalCompareVersions - internalAppMetaDataVersion = originalInternalAppMetaDataVersion - }) - - t.Run("latestTag is empty", func(t *testing.T) { - assert.False(t, isUpdateAvailable("")) - }) - - t.Run("update available", func(t *testing.T) { - internalAppMetaDataVersion = "1.0.0" - compareVersions = func(current, latest string) int { return -1 } - assert.True(t, isUpdateAvailable("1.0.1")) - }) +func TestGetCacheFilePath(t *testing.T) { + cleanup := setupUpdateCheckerTests(t) + defer cleanup() - t.Run("no update available", func(t *testing.T) { - internalAppMetaDataVersion = "1.0.0" - compareVersions = func(current, latest string) int { return 0 } - assert.False(t, isUpdateAvailable("1.0.0")) + t.Run("uses user cache dir", func(t *testing.T) { + osUserCacheDir = func() (string, error) { return "/fake/cache", nil } + assert.Equal(t, "/fake/cache/.prasmoid", GetCacheFilePath()) }) - t.Run("current is newer", func(t *testing.T) { - internalAppMetaDataVersion = "1.0.1" - compareVersions = func(current, latest string) int { return 1 } - assert.False(t, isUpdateAvailable("1.0.0")) + t.Run("falls back to temp dir", func(t *testing.T) { + osUserCacheDir = func() (string, error) { return "", errors.New("cache dir error") } + osTempDir = func() string { return "/fake/tmp" } + assert.Equal(t, "/fake/tmp/.prasmoid", GetCacheFilePath()) }) } -func TestPrintUpdateMessage(t *testing.T) { - // Save original functions - originalTermGetSize := termGetSize - originalInternalAppMetaDataVersion := internalAppMetaDataVersion +func TestReadWriteUpdateCache(t *testing.T) { + cleanup := setupUpdateCheckerTests(t) + defer cleanup() - t.Cleanup(func() { - termGetSize = originalTermGetSize - internalAppMetaDataVersion = originalInternalAppMetaDataVersion - }) + tmpfile, err := os.CreateTemp("", "cache_test_*") + assert.NoError(t, err) + defer func() { _ = os.Remove(tmpfile.Name()) }() - // Helper to capture stdout - captureOutput := func() (*bytes.Buffer, func()) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - color.Output = w // Redirect color output as well - buf := new(bytes.Buffer) - return buf, func() { - _ = w.Close() - _, _ = io.Copy(buf, r) - os.Stdout = oldStdout - color.Output = oldStdout - } + GetCacheFilePath = func() string { + return tmpfile.Name() } - t.Run("prints update message with default width", func(t *testing.T) { - // Arrange - termGetSize = func(fd int) (width, height int, err error) { return 0, 0, errors.New("error getting size") } - internalAppMetaDataVersion = "1.0.0" - - buf, restoreOutput := captureOutput() - // Act - printUpdateMessage("1.0.1") - - // Assert - restoreOutput() - output := buf.String() - assert.Contains(t, output, "Prasmoid update available! 1.0.0 → 1.0.1") - assert.Contains(t, output, "Run `prasmoid upgrade` to update") - // Check for default width (70) - this is tricky with color codes, but we can check line length roughly - lines := strings.Split(output, "\n") - assert.GreaterOrEqual(t, len(lines[0]), 70) // First line should be at least 70 chars wide + t.Run("writes and reads cache successfully", func(t *testing.T) { + testTag := "v1.2.3" + testHash := "testhash123" + writeUpdateCache(testTag, testHash) + + cache, err := readUpdateCache() + assert.NoError(t, err) + assert.Equal(t, testTag, cache["latest_tag"]) + assert.Equal(t, testHash, cache["latest_hash"]) + _, hasTimestamp := cache["last_checked"] + assert.True(t, hasTimestamp) }) - t.Run("prints update message with custom width", func(t *testing.T) { - // Arrange - termGetSize = func(fd int) (width, height int, err error) { return 100, 20, nil } - internalAppMetaDataVersion = "1.0.0" - buf, restoreOutput := captureOutput() - - // Act - printUpdateMessage("1.0.1") - - // Assert - restoreOutput() - output := buf.String() - assert.Contains(t, output, "Prasmoid update available! 1.0.0 → 1.0.1") - assert.Contains(t, output, "Run `prasmoid upgrade` to update") - // Check for custom width (100) - lines := strings.Split(output, "\n") - assert.GreaterOrEqual(t, len(lines[0]), 100) // First line should be at least 100 chars wide + t.Run("read returns error for non-existent file", func(t *testing.T) { + GetCacheFilePath = func() string { return "/non/existent/cache/file" } + _, err := readUpdateCache() + assert.Error(t, err) }) } diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go deleted file mode 100644 index af7dc1c..0000000 --- a/cmd/setup/setup.go +++ /dev/null @@ -1,27 +0,0 @@ -/* -Copyright 2025 PRAS -*/ -package setup - -import ( - "github.com/PRASSamin/prasmoid/cmd" - "github.com/fatih/color" - "github.com/spf13/cobra" -) - -func init() { - cmd.RootCmd.AddCommand(SetupCmd) -} - -// SetupCmd represents the setup command -var SetupCmd = &cobra.Command{ - Use: "setup", - Short: "Setup development environment", - Long: "Install plasmoidviewer and other development dependencies.", - Run: func(cmd *cobra.Command, args []string) { - if err := utilsInstallDependencies(); err != nil { - color.Red("Failed to install dependencies: %v", err) - return - } - }, -} diff --git a/cmd/setup/setup_test.go b/cmd/setup/setup_test.go deleted file mode 100644 index 02b2087..0000000 --- a/cmd/setup/setup_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package setup - -import ( - "bytes" - "errors" - "io" - "os" - "testing" - - "github.com/fatih/color" - "github.com/stretchr/testify/assert" -) - -func TestSetupCmd(t *testing.T) { - // Save original function and restore after test - originalInstallDependencies := utilsInstallDependencies - t.Cleanup(func() { - utilsInstallDependencies = originalInstallDependencies - }) - - t.Run("success", func(t *testing.T) { - // Arrange - utilsInstallDependencies = func() error { - return nil - } - - // Capture output - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - color.Output = w - - // Act - SetupCmd.Run(SetupCmd, []string{}) - _ = w.Close() - - // Assert - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - os.Stdout = oldStdout - assert.Empty(t, buf.String(), "Expected no output on success") - }) - - t.Run("failure", func(t *testing.T) { - // Arrange - expectedError := errors.New("installation failed") - utilsInstallDependencies = func() error { - return expectedError - } - - // Capture output - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - color.Output = w - - // Act - SetupCmd.Run(SetupCmd, []string{}) - _ = w.Close() - - // Assert - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - os.Stdout = oldStdout - assert.Contains(t, buf.String(), "Failed to install dependencies: installation failed") - }) -} diff --git a/cmd/setup/vars.go b/cmd/setup/vars.go deleted file mode 100644 index 3e49714..0000000 --- a/cmd/setup/vars.go +++ /dev/null @@ -1,9 +0,0 @@ -package setup - -import ( - "github.com/PRASSamin/prasmoid/utils" -) - -var ( - utilsInstallDependencies = utils.InstallDependencies -) diff --git a/cmd/upgrade/upgrade.go b/cmd/upgrade/upgrade.go index f4f68f2..3bc5d0f 100644 --- a/cmd/upgrade/upgrade.go +++ b/cmd/upgrade/upgrade.go @@ -8,10 +8,7 @@ package upgrade import ( "fmt" "os" - "strings" - "github.com/AlecAivazis/survey/v2" - "github.com/PRASSamin/prasmoid/consts" "github.com/fatih/color" "github.com/spf13/cobra" @@ -19,38 +16,27 @@ import ( ) func init() { + if utilsIsPackageInstalled("curl") { + upgradeCmd.Short = "Upgrade to latest version of Prasmoid CLI." + } else { + upgradeCmd.Short = fmt.Sprintf("Upgrade to latest version of Prasmoid CLI %s", color.RedString("(disabled)")) + } + upgradeCmd.GroupID = "cli" root.RootCmd.AddCommand(upgradeCmd) } var upgradeCmd = &cobra.Command{ Use: "upgrade", - Short: "Upgrade to latest version of Prasmoid CLI.", Run: func(cmd *cobra.Command, args []string) { - if err := checkRoot(); err != nil { - fmt.Println(color.RedString(err.Error())) + if !utilsIsPackageInstalled("curl") { + fmt.Println(color.RedString("upgrade command is disabled due to missing dependencies.")) + fmt.Println(color.BlueString("Please install curl and try again.")) return } - if !utilsIsPackageInstalled(consts.CurlPackageName["binary"]) { - pm, _ := utilsDetectPackageManager() - confirmPrompt := &survey.Confirm{ - Message: "curl is not installed. Do you want to install it first?", - Default: true, - } - if err := surveyAskOne(confirmPrompt, &confirmInstallation); err != nil { - fmt.Println(color.RedString("Failed to ask for curl installation: %v", err)) - return - } - - if confirmInstallation { - if err := utilsInstallPackage(pm, consts.CurlPackageName["binary"], consts.CurlPackageName); err != nil { - fmt.Println(color.RedString("Failed to install curl:", err)) - return - } - } else { - fmt.Println("Operation cancelled.") - return - } + if err := utilsCheckRoot(); err != nil { + fmt.Println(color.RedString(err.Error())) + return } exePath, err := osExecutable() @@ -59,7 +45,7 @@ var upgradeCmd = &cobra.Command{ return } - cmdStr := fmt.Sprintf("curl -sSL %s | bash -s %s", scriptURL, exePath) + cmdStr := fmt.Sprintf("sudo curl -sSL %s | bash -s %s", scriptURL, exePath) command := execCommand("bash", "-c", cmdStr) command.Stdout = os.Stdout @@ -75,15 +61,3 @@ var upgradeCmd = &cobra.Command{ } }, } - -var checkRoot = func() error { - currentUser, err := userCurrent() - if err != nil { - return fmt.Errorf("failed to get current user: %v", err) - } - - if currentUser.Uid != "0" { - return fmt.Errorf("the requested operation requires superuser privileges. use `sudo %s`", strings.Join(os.Args[0:], " ")) - } - return nil -} diff --git a/cmd/upgrade/upgrade_test.go b/cmd/upgrade/upgrade_test.go index dda9e09..102a3b0 100644 --- a/cmd/upgrade/upgrade_test.go +++ b/cmd/upgrade/upgrade_test.go @@ -6,74 +6,34 @@ import ( "io" "os" "os/exec" - "os/user" "testing" - "github.com/AlecAivazis/survey/v2" + "github.com/PRASSamin/prasmoid/utils" "github.com/fatih/color" "github.com/stretchr/testify/assert" ) -// TestCheckRoot tests the checkRoot function -func TestCheckRoot(t *testing.T) { - originalUserCurrent := userCurrent - t.Cleanup(func() { - userCurrent = originalUserCurrent - }) - - t.Run("user is root", func(t *testing.T) { - userCurrent = func() (*user.User, error) { - return &user.User{Uid: "0"}, nil - } - assert.NoError(t, checkRoot()) - }) - - t.Run("user is not root", func(t *testing.T) { - userCurrent = func() (*user.User, error) { - return &user.User{Uid: "1000"}, nil - } - err := checkRoot() - assert.Error(t, err) - assert.Contains(t, err.Error(), "the requested operation requires superuser privileges") - }) - - t.Run("user.Current returns error", func(t *testing.T) { - userCurrent = func() (*user.User, error) { - return nil, errors.New("user error") - } - err := checkRoot() - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to get current user") - }) -} - // TestUpgradeCmd tests the upgradeCmd.Run function func TestUpgradeCmd(t *testing.T) { // Save original functions and restore after test - originalCheckRoot := checkRoot - originalUtilsIsPackageInstalled := utilsIsPackageInstalled - originalUtilsDetectPackageManager := utilsDetectPackageManager - originalSurveyAskOne := surveyAskOne - originalUtilsInstallPackage := utilsInstallPackage + originalCheckRoot := utils.CheckRoot originalOsExecutable := osExecutable originalExecCommand := execCommand originalOsRemove := osRemove originalRootGetCacheFilePath := rootGetCacheFilePath + originalUtilsIsPackageInstalled := utilsIsPackageInstalled t.Cleanup(func() { - checkRoot = originalCheckRoot - utilsIsPackageInstalled = originalUtilsIsPackageInstalled - utilsDetectPackageManager = originalUtilsDetectPackageManager - surveyAskOne = originalSurveyAskOne - utilsInstallPackage = originalUtilsInstallPackage + utilsCheckRoot = originalCheckRoot osExecutable = originalOsExecutable execCommand = originalExecCommand osRemove = originalOsRemove rootGetCacheFilePath = originalRootGetCacheFilePath + utilsIsPackageInstalled = originalUtilsIsPackageInstalled }) // Mock checkRoot to always succeed by default for upgradeCmd tests - checkRoot = func() error { return nil } + utilsCheckRoot = func() error { return nil } // Helper to capture stdout captureOutput := func() (*bytes.Buffer, func()) { @@ -90,78 +50,10 @@ func TestUpgradeCmd(t *testing.T) { } } - t.Run("checkRoot fails", func(t *testing.T) { - // Arrange - checkRoot = func() error { return errors.New("root check failed") } - defer func() { checkRoot = func() error { return nil } }() - - buf, restoreOutput := captureOutput() - - // Act - upgradeCmd.Run(nil, []string{}) - - // Assert - restoreOutput() - assert.Contains(t, buf.String(), "root check failed") - }) - - t.Run("curl not installed, user confirms, install succeeds, upgrade succeeds", func(t *testing.T) { - // Arrange - utilsIsPackageInstalled = func(pkg string) bool { return false } - utilsDetectPackageManager = func() (string, error) { return "apt", nil } - surveyAskOne = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { - *(response.(*bool)) = true // User confirms - return nil - } - utilsInstallPackage = func(pm, pkg string, names map[string]string) error { return nil } - osExecutable = func() (string, error) { return "/usr/local/bin/prasmoid", nil } - execCommand = func(name string, arg ...string) *exec.Cmd { - // Mock the command to succeed - return exec.Command("bash", "-c", "exit 0") - } - osRemove = func(name string) error { return nil } - rootGetCacheFilePath = func() string { return "/tmp/cache" } - - buf, restoreOutput := captureOutput() - - // Act - upgradeCmd.Run(nil, []string{}) - - // Assert - restoreOutput() - assert.NotContains(t, buf.String(), "Failed to install curl") - assert.NotContains(t, buf.String(), "Update failed") - assert.NotContains(t, buf.String(), "Warning: Failed to remove update cache file") - }) - - t.Run("curl not installed, user confirms, install fails", func(t *testing.T) { - // Arrange - utilsIsPackageInstalled = func(pkg string) bool { return false } - utilsDetectPackageManager = func() (string, error) { return "apt", nil } - surveyAskOne = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { - *(response.(*bool)) = true // User confirms - return nil - } - utilsInstallPackage = func(pm, pkg string, names map[string]string) error { return errors.New("install failed") } - - buf, restoreOutput := captureOutput() - - // Act - upgradeCmd.Run(nil, []string{}) - - // Assert - restoreOutput() - assert.Contains(t, buf.String(), "Failed to install curl") - }) - - t.Run("curl not installed, user cancels", func(t *testing.T) { + t.Run("curl not installed", func(t *testing.T) { // Arrange utilsIsPackageInstalled = func(pkg string) bool { return false } - utilsDetectPackageManager = func() (string, error) { return "apt", nil } // Added mock for DetectPackageManager - surveyAskOne = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { - *(response.(*bool)) = false // User cancels - return nil - } + defer func() { utilsIsPackageInstalled = originalUtilsIsPackageInstalled }() buf, restoreOutput := captureOutput() @@ -170,16 +62,14 @@ func TestUpgradeCmd(t *testing.T) { // Assert restoreOutput() - assert.Contains(t, buf.String(), "Operation cancelled.") + assert.Contains(t, buf.String(), "upgrade command is disabled due to missing dependencies.") }) - t.Run("curl not installed, ask fails", func(t *testing.T) { + t.Run("checkRoot fails", func(t *testing.T) { // Arrange - utilsIsPackageInstalled = func(pkg string) bool { return false } - utilsDetectPackageManager = func() (string, error) { return "apt", nil } // Added mock for DetectPackageManager - surveyAskOne = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { - return errors.New("ask failed") // survey.AskOne returns error - } + utilsIsPackageInstalled = func(pkg string) bool { return true } + utilsCheckRoot = func() error { return errors.New("root check failed") } + defer func() { utilsCheckRoot = func() error { return nil } }() buf, restoreOutput := captureOutput() @@ -188,12 +78,12 @@ func TestUpgradeCmd(t *testing.T) { // Assert restoreOutput() - assert.Contains(t, buf.String(), "Failed to ask for curl installation: ask failed") + assert.Contains(t, buf.String(), "root check failed") }) t.Run("os.Executable fails", func(t *testing.T) { // Arrange - utilsIsPackageInstalled = func(pkg string) bool { return true } // Assume curl is installed + utilsIsPackageInstalled = func(pkg string) bool { return true } osExecutable = func() (string, error) { return "", errors.New("exec error") } buf, restoreOutput := captureOutput() @@ -208,7 +98,7 @@ func TestUpgradeCmd(t *testing.T) { t.Run("command.Run fails", func(t *testing.T) { // Arrange - utilsIsPackageInstalled = func(pkg string) bool { return true } // Assume curl is installed + utilsIsPackageInstalled = func(pkg string) bool { return true } osExecutable = func() (string, error) { return "/usr/local/bin/prasmoid", nil } execCommand = func(name string, arg ...string) *exec.Cmd { // Mock the command to fail @@ -227,7 +117,7 @@ func TestUpgradeCmd(t *testing.T) { t.Run("os.Remove fails", func(t *testing.T) { // Arrange - utilsIsPackageInstalled = func(pkg string) bool { return true } // Assume curl is installed + utilsIsPackageInstalled = func(pkg string) bool { return true } osExecutable = func() (string, error) { return "/usr/local/bin/prasmoid", nil } execCommand = func(name string, arg ...string) *exec.Cmd { // Mock the command to succeed @@ -246,3 +136,4 @@ func TestUpgradeCmd(t *testing.T) { assert.Contains(t, buf.String(), "Warning: Failed to remove update cache file: remove error") }) } + diff --git a/cmd/upgrade/vars.go b/cmd/upgrade/vars.go index 7f24911..c270121 100644 --- a/cmd/upgrade/vars.go +++ b/cmd/upgrade/vars.go @@ -3,24 +3,19 @@ package upgrade import ( "os" "os/exec" - "os/user" - "github.com/AlecAivazis/survey/v2" root "github.com/PRASSamin/prasmoid/cmd" "github.com/PRASSamin/prasmoid/utils" ) var ( - utilsIsPackageInstalled = utils.IsPackageInstalled - utilsDetectPackageManager = utils.DetectPackageManager - surveyAskOne = survey.AskOne - utilsInstallPackage = utils.InstallPackage - osExecutable = os.Executable - execCommand = exec.Command - osRemove = os.Remove - userCurrent = user.Current - rootGetCacheFilePath = root.GetCacheFilePath + osExecutable = os.Executable + execCommand = exec.Command + osRemove = os.Remove + rootGetCacheFilePath = root.GetCacheFilePath + + utilsCheckRoot = utils.CheckRoot + utilsIsPackageInstalled = utils.IsPackageInstalled - confirmInstallation bool - scriptURL = "https://raw.githubusercontent.com/PRASSamin/prasmoid/main/update" + scriptURL = "https://raw.githubusercontent.com/PRASSamin/prasmoid/main/update" ) diff --git a/consts/dependency.go b/consts/dependency.go deleted file mode 100644 index aec22cf..0000000 --- a/consts/dependency.go +++ /dev/null @@ -1,33 +0,0 @@ -package consts - -var QmlFormatPackageName = map[string]string{ - "apt": "qt6-tools-dev", - "dnf": "qmlformat", - "pacman": "qt6-tools", - "nix": "nixpkgs.qt6.qttools", - "binary": "qmlformat", -} - -var PlasmoidPreviewPackageName = map[string]string{ - "apt": "plasma-sdk", - "dnf": "plasma-sdk", - "pacman": "plasma-sdk", - "nix": "nixpkgs.kdePackages.plasma-sdk", - "binary": "plasmoidviewer", -} - -var CurlPackageName = map[string]string{ - "apt": "curl", - "dnf": "curl", - "pacman": "curl", - "nix": "nixpkgs.curl", - "binary": "curl", -} - -var GettextPackageName = map[string]string{ - "apt": "gettext", - "dnf": "gettext", - "pacman": "gettext", - "nix": "nixpkgs.gettext", - "binary": "xgettext", -} diff --git a/consts/templates.go b/consts/templates.go index 81c0e70..0506bf6 100644 --- a/consts/templates.go +++ b/consts/templates.go @@ -1,5 +1,37 @@ package consts +var UsageTemplate = `Usage:{{if .Runnable}} + {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} + {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} + +Aliases: + {{.NameAndAliases}}{{end}}{{- if .HasExample }} + +Examples: + {{.Example}}{{end -}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}} + +Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}{{- $printed := false}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}} +{{- if not $printed}}{{$printed = true}} + +{{$group.Title}}:{{end}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}} + +Available Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end -}}{{if .HasAvailableInheritedFlags }} + +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end -}}{{if .HasHelpSubCommands }} + +Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} + {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} +` + var MAIN_QML = `import QtQuick 6.5 import QtQuick.Layouts 6.5 import org.kde.kirigami 2.20 as Kirigami diff --git a/install b/install index c8d633b..3f7726b 100755 --- a/install +++ b/install @@ -1,97 +1,175 @@ -#!/bin/bash - +#!/bin/sh set -e -# --- Helper Functions --- -formatted_size() { - local bytes=$1 - local units=("B" "KB" "MB" "GB" "TB" "PB") - local i=0 - - while (( bytes >= 1024 && i < ${#units[@]} - 1 )); do - bytes=$(( bytes / 1024 )) - (( i++ )) - done +# --- Logging --- +log_step() { printf '\033[0;36m→ %s\033[0m\n' "$1"; } +log_done() { printf '\033[0;32m✔ %s\033[0m\n' "$1"; } +log_warn() { printf '\033[0;33m! %s\033[0m\n' "$1"; } +log_fail() { printf '\033[0;31m✘ %s\033[0m\n' "$1"; exit 1; } + +# --- Detect Package Manager --- +detect_pkg_manager() { + if command -v apt >/dev/null 2>&1; then + echo "apt" + elif command -v dnf >/dev/null 2>&1; then + echo "dnf" + elif command -v yum >/dev/null 2>&1; then + echo "yum" + elif command -v zypper >/dev/null 2>&1; then + echo "zypper" + elif command -v pacman >/dev/null 2>&1; then + echo "pacman" + elif command -v apk >/dev/null 2>&1; then + echo "apk" + elif command -v nix-env >/dev/null 2>&1; then + echo "nix" + else + echo "null" + fi +} - echo "${bytes} ${units[$i]}" +# --- Install Dependencies --- +install_deps() { + pm=$1 + if [ "$pm" = "null" ]; then + log_warn "Unsupported package manager detected. Automatic dependency installation is not supported." + log_warn "Please install the required dependencies manually:" + log_step "https://github.com/PRASSamin/prasmoid/blob/main/README.md#dependencies" + log_warn "If you’d like to help extend the fix script for your package manager, please reach out." + log_step "https://github.com/PRASSamin/prasmoid/issues" + log_warn "Prasmoid will continue installing, but some features may not work until dependencies are installed." + return + fi + + log_step "Installing dependencies using $pm..." + + case "$pm" in + apt) + sudo apt install -y curl qt6-tools-dev plasma-sdk gettext + ;; + dnf|yum) + sudo $pm install -y curl qmlformat plasma-sdk gettext + ;; + zypper|nix) + log_warn "$pm detected. Automatic dependency installation is not supported." + log_warn "Please install the required dependencies manually:" + log_step "https://github.com/PRASSamin/prasmoid/blob/main/README.md#dependencies" + log_warn "If you’d like to help extend the fix script for $pm, please reach out." + log_step "https://github.com/PRASSamin/prasmoid/issues" + log_warn "Prasmoid will continue installing, but some features may not work until dependencies are installed." + return + ;; + pacman) + sudo pacman -Sy --noconfirm curl qt6-declarative plasma-sdk gettext + sudo ln -sf /usr/lib/qt6/bin/qmlformat /usr/bin/qmlformat + ;; + apk) + sudo apk add curl qt6-qttools-dev plasma-sdk gettext + sudo ln -sf /usr/lib/qt6/bin/qmlformat /usr/bin/qmlformat + ;; + *) + log_warn "Unsupported package manager ($pm). Manual installation required." + log_step "Dependencies info: https://github.com/PRASSamin/prasmoid/blob/main/README.md#dependencies" + log_warn "If you’d like to help extend the fix script for $pm, please reach out." + log_step "https://github.com/PRASSamin/prasmoid/issues" + log_warn "Prasmoid will continue installing, but some features may not work until dependencies are installed." + return + ;; + esac + + log_done "Dependencies installed" } -log_step() { echo -e "\033[0;36m→ $1\033[0m"; } -log_done() { echo -e "\033[0;32m✔ $1\033[0m"; } -log_fail() { echo -e "\033[0;31m✘ $1\033[0m"; exit 1; } -# Get all release assets -ASSETS=$(curl -s "https://api.github.com/repos/prassamin/prasmoid/releases/latest" | jq '.assets') +# --- Main --- +install_deps "$(detect_pkg_manager)" -# Extract URLs for both binaries -NORMAL_URL=$(echo "$ASSETS" | jq -r '.[] | select(.name == "prasmoid") | .browser_download_url') -PORTABLE_URL=$(echo "$ASSETS" | jq -r '.[] | select(.name == "prasmoid-portable") | .browser_download_url') -# Get sizes -NORMAL_SIZE=$(echo "$ASSETS" | jq -r '.[] | select(.name == "prasmoid") | .size') -PORTABLE_SIZE=$(echo "$ASSETS" | jq -r '.[] | select(.name == "prasmoid-portable") | .size') +# --- Get GitHub release info --- +RELEASE_JSON=$(curl -s "https://api.github.com/repos/prassamin/prasmoid/releases/latest") -# Get URLs for checksums file -SHA256_URL=$(echo "$ASSETS" | jq -r '.[] | select(.name == "SHA256SUMS") | .browser_download_url') -if [[ -z "$SHA256_URL" ]]; then - log_fail "Error: SHA256SUMS file is missing from the release assets. Cannot continue." -fi +extract_url() { + asset=$1 + echo "$RELEASE_JSON" | awk -v a="$asset" ' + $0 ~ "\"name\": \""a"\"" {found=1} + found && $0 ~ "\"browser_download_url\"" { + gsub(/.*"browser_download_url": "|".*/, "", $0) + print $0 + exit + } + ' +} -# Handle user input based on TTY availability -if [[ -t 0 ]]; then - echo "Please select which version to install:" - echo "1) Prasmoid ($(formatted_size $NORMAL_SIZE))" - echo "2) Prasmoid-portable ($(formatted_size $PORTABLE_SIZE)) (Recommended for minimal systems e.g. Alpine, NixOS, etc.)" - read -p "Enter your choice (1 or 2): " choice -else - choice="${1:-1}" # Default to 1 if no arg is passed -fi +extract_size() { + asset=$1 + echo "$RELEASE_JSON" | awk -v a="$asset" ' + $0 ~ "\"name\": \""a"\"" {found=1} + found && $0 ~ "\"size\"" { + gsub(/[^0-9]/, "", $0) + print $0 + exit + } + ' +} -# Validate choice -if [[ "$choice" != "1" && "$choice" != "2" ]]; then - log_fail "Invalid choice! Please enter 1 or 2." +NORMAL_URL=$(extract_url "prasmoid") +PORTABLE_URL=$(extract_url "prasmoid-portable") +SHA256_URL=$(extract_url "SHA256SUMS") +NORMAL_SIZE=$(extract_size "prasmoid") +PORTABLE_SIZE=$(extract_size "prasmoid-portable") + +[ -z "$SHA256_URL" ] && log_fail "SHA256SUMS file missing in release assets." + +# --- Select binary --- +if [ -t 0 ]; then + echo "Please select which version to install:" + echo "1) Prasmoid $(($NORMAL_SIZE / 1024 / 1024)) MB" + echo "2) Prasmoid-portable $(($PORTABLE_SIZE / 1024 / 1024)) MB (Recommended for Alpine, NixOS, etc.)" + printf "Enter choice (1 or 2): " + read choice +else + choice="${1:-1}" fi -# Select appropriate binary -if [[ "$choice" == "1" ]]; then - DOWNLOAD_URL="$NORMAL_URL" - BINARY_NAME="prasmoid" +if [ "$choice" = "1" ]; then + DOWNLOAD_URL="$NORMAL_URL" + BINARY_NAME="prasmoid" else - DOWNLOAD_URL="$PORTABLE_URL" - BINARY_NAME="prasmoid-portable" + DOWNLOAD_URL="$PORTABLE_URL" + BINARY_NAME="prasmoid-portable" fi -# Download binary -echo "Downloading $BINARY_NAME..." +# --- Download --- +log_step "Downloading $BINARY_NAME..." curl -L "$DOWNLOAD_URL" -o "$BINARY_NAME" -# Verify download -if [[ ! -f "$BINARY_NAME" ]]; then - log_fail "Download failed!" -fi +# --- Verify checksum --- +TMPDIR="${TMPDIR:-/tmp}" +TMPFILE=$(mktemp "$TMPDIR/prasmoid-XXXXXX") +curl -L "$SHA256_URL" -o "$TMPFILE" -if curl -L "$SHA256_URL" | sha256sum -c --ignore-missing --status - ; then - log_done "Checksum passed" -else - log_fail "Checksum failed!" -fi +EXPECTED_CHECKSUM=$(awk -v bin="$BINARY_NAME" '$2 == bin {print $1}' "$TMPFILE") +ACTUAL_CHECKSUM=$(sha256sum "$BINARY_NAME" | awk '{print $1}') +[ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ] && log_fail "Checksum failed! Expected: $EXPECTED_CHECKSUM, Got: $ACTUAL_CHECKSUM" +log_done "Checksum passed" + +rm -f "$TMPFILE" chmod +x "$BINARY_NAME" -# Detect if running as root, else use sudo -if [[ "$EUID" -ne 0 ]]; then - log_step "Installing to /usr/bin using sudo..." - sudo cp "$BINARY_NAME" /usr/bin/prasmoid +# --- Install --- +if [ "$(id -u)" -ne 0 ]; then + log_step "Installing to /usr/bin using sudo..." + sudo cp "$BINARY_NAME" /usr/bin/prasmoid else - log_step "Installing to /usr/bin..." - cp "$BINARY_NAME" /usr/bin/prasmoid + log_step "Installing to /usr/bin..." + cp "$BINARY_NAME" /usr/bin/prasmoid fi rm "$BINARY_NAME" -# Final check -if [[ ! -f "/usr/bin/prasmoid" ]]; then - log_fail "Installation failed!" -fi +[ ! -f "/usr/bin/prasmoid" ] && log_fail "Installation failed!" log_done "$BINARY_NAME installed successfully!" -echo -e "\Run it anytime with: prasmoid" + +echo "" +echo "Run it anytime with: prasmoid" \ No newline at end of file diff --git a/internal/metadata.go b/internal/metadata.go index c1fa05e..1a5c93c 100644 --- a/internal/metadata.go +++ b/internal/metadata.go @@ -1,6 +1,6 @@ package internal -var Version = "0.0.4" +var Version = "0.1.0" type AppMetadata struct { Version string diff --git a/main.go b/main.go index c0ba9a6..ed0dc74 100644 --- a/main.go +++ b/main.go @@ -16,10 +16,10 @@ import ( _ "github.com/PRASSamin/prasmoid/cmd/link" _ "github.com/PRASSamin/prasmoid/cmd/preview" _ "github.com/PRASSamin/prasmoid/cmd/regen" - _ "github.com/PRASSamin/prasmoid/cmd/setup" _ "github.com/PRASSamin/prasmoid/cmd/uninstall" _ "github.com/PRASSamin/prasmoid/cmd/unlink" _ "github.com/PRASSamin/prasmoid/cmd/upgrade" + _ "github.com/PRASSamin/prasmoid/cmd/fix" ) func main() { diff --git a/scripts/fix b/scripts/fix new file mode 100755 index 0000000..1e930d4 --- /dev/null +++ b/scripts/fix @@ -0,0 +1,83 @@ +#!/bin/sh +set -e + +# --- Logging --- +log_step() { printf '\033[0;36m→ %s\033[0m\n' "$1"; } +log_done() { printf '\033[0;32m✔ %s\033[0m\n' "$1"; } +log_warn() { printf '\033[0;33m! %s\033[0m\n' "$1"; } +log_fail() { printf '\033[0;31m✘ %s\033[0m\n' "$1"; exit 1; } + +# --- Detect Package Manager --- +detect_pkg_manager() { + if command -v apt >/dev/null 2>&1; then + echo "apt" + elif command -v dnf >/dev/null 2>&1; then + echo "dnf" + elif command -v yum >/dev/null 2>&1; then + echo "yum" + elif command -v zypper >/dev/null 2>&1; then + echo "zypper" + elif command -v pacman >/dev/null 2>&1; then + echo "pacman" + elif command -v apk >/dev/null 2>&1; then + echo "apk" + elif command -v nix-env >/dev/null 2>&1; then + echo "nix" + else + echo "null" + fi +} + +# --- Install Dependencies --- +install_deps() { + pm=$1 + if [ "$pm" = "null" ]; then + log_warn "Unsupported package manager detected. Automatic dependency installation is not supported." + log_warn "Please install the required dependencies manually:" + log_step "https://github.com/PRASSamin/prasmoid/blob/main/README.md#dependencies" + log_warn "If you’d like to help extend the fix script for your package manager, please reach out." + log_step "https://github.com/PRASSamin/prasmoid/issues" + return + fi + + log_step "Installing dependencies using $pm..." + + case "$pm" in + apt) + sudo apt install -y curl qt6-tools-dev plasma-sdk gettext + ;; + dnf|yum) + sudo $pm install -y curl qmlformat plasma-sdk gettext + ;; + zypper|nix) + log_warn "$pm detected. Automatic dependency installation is not supported." + log_warn "Please install the required dependencies manually:" + log_step "https://github.com/PRASSamin/prasmoid/blob/main/README.md#dependencies" + log_warn "If you’d like to help extend the fix script for $pm, please reach out." + log_step "https://github.com/PRASSamin/prasmoid/issues" + return + ;; + pacman) + sudo pacman -Sy --noconfirm curl qt6-declarative plasma-sdk gettext + sudo ln -sf /usr/lib/qt6/bin/qmlformat /usr/bin/qmlformat + ;; + apk) + sudo apk add curl qt6-qttools-dev plasma-sdk gettext + sudo ln -sf /usr/lib/qt6/bin/qmlformat /usr/bin/qmlformat + ;; + *) + log_warn "Unsupported package manager ($pm). Manual installation required." + log_step "Dependencies info: https://github.com/PRASSamin/prasmoid/blob/main/README.md#dependencies" + log_warn "If you’d like to help extend the fix script for $pm, please reach out." + log_step "https://github.com/PRASSamin/prasmoid/issues" + return + ;; + esac + + log_done "Dependencies installed" +} + +# --- Main --- +install_deps "$(detect_pkg_manager)" + +exit 0 diff --git a/scripts/update b/scripts/update new file mode 100755 index 0000000..63181d5 --- /dev/null +++ b/scripts/update @@ -0,0 +1,96 @@ +#!/bin/sh +set -e + +# --- Helper Functions --- +log_step() { printf '\033[0;36m→ %s\033[0m\n' "$1"; } +log_done() { printf '\033[0;32m✔ %s\033[0m\n' "$1"; } +log_fail() { printf '\033[0;31m✘ %s\033[0m\n' "$1"; exit 1; } + +# --- Argument Parsing --- +if [ "$#" -lt 1 ]; then + log_fail "Usage: $0 " +fi + +CURRENT_EXE=$1 +shift 1 + +log_step "Checking for update..." + +LATEST_RELEASE_INFO=$(curl -s "https://api.github.com/repos/PRASSamin/prasmoid/releases/latest") +[ -z "$LATEST_RELEASE_INFO" ] && log_fail "Failed to fetch release info from GitHub." + +CURRENT_VERSION=$("$CURRENT_EXE" --version) +RAW_CURRENT_VERSION=$(echo "$CURRENT_VERSION" | sed "s/-portable//") +IS_PORTABLE=false +case "$CURRENT_VERSION" in + *-portable*) IS_PORTABLE=true ;; +esac + +ASSET_NAME="prasmoid" +$IS_PORTABLE && ASSET_NAME="prasmoid-portable" + +# --- Extract download URLs --- +extract_url() { + name=$1 + echo "$LATEST_RELEASE_INFO" | awk -v a="$name" ' + $0 ~ "\"name\": \""a"\"" {found=1} + found && $0 ~ "\"browser_download_url\"" { + gsub(/.*"browser_download_url": "|".*/, "", $0) + print $0 + exit + } + ' +} + +extract_tag() { + echo "$LATEST_RELEASE_INFO" | awk -F '"' '/"tag_name":/ {gsub(/^v/, "", $4); print $4; exit}' +} + +SHA256_URL=$(extract_url "SHA256SUMS") +[ -z "$SHA256_URL" ] && log_fail "SHA256SUMS file missing in release." + +log_step "Fetching SHA256SUMS..." +SHA256_CONTENT=$(curl -sL "$SHA256_URL") + +LATEST_HASH=$(echo "$SHA256_CONTENT" | awk -v a="$ASSET_NAME" '$2 == a {print $1; exit}') +[ -z "$LATEST_HASH" ] && log_fail "Could not find hash for $ASSET_NAME in SHA256SUMS." + +log_step "Calculating current binary hash..." +CURRENT_HASH=$(sha256sum "$CURRENT_EXE" | awk '{print $1}') + +if [ "$CURRENT_HASH" = "$LATEST_HASH" ]; then + log_done "You are already using the latest version of prasmoid ($RAW_CURRENT_VERSION.$LATEST_HASH)." + exit 0 +fi + +log_step "New binary available. Downloading $ASSET_NAME..." +DOWNLOAD_URL=$(extract_url "$ASSET_NAME") +[ -z "$DOWNLOAD_URL" ] && log_fail "Download URL for $ASSET_NAME not found." + +TEMP_FILE=$(mktemp) +curl -L -o "$TEMP_FILE" "$DOWNLOAD_URL" || { + rm -f "$TEMP_FILE" + log_fail "Download failed." +} + +LATEST_VERSION=$(extract_tag) + +log_step "Verifying downloaded binary hash..." +DOWNLOADED_HASH=$(sha256sum "$TEMP_FILE" | awk '{print $1}') + +if [ "$DOWNLOADED_HASH" != "$LATEST_HASH" ]; then + rm -f "$TEMP_FILE" + log_fail "Checksum verification failed for downloaded binary." +fi + +chmod +x "$TEMP_FILE" + +# This script is executed by the Go binary, which already checks for root. +# So, we can assume we have the necessary permissions. + +log_step "Replacing old binary..." +mv "$TEMP_FILE" "$CURRENT_EXE" + +log_done "Update complete $LATEST_VERSION ($LATEST_HASH)" + +exit 0 diff --git a/update b/update index 299c634..63181d5 100755 --- a/update +++ b/update @@ -1,11 +1,10 @@ -#!/bin/bash - +#!/bin/sh set -e # --- Helper Functions --- -log_step() { echo -e "\033[0;36m→ $1\033[0m"; } -log_done() { echo -e "\033[0;32m✔ $1\033[0m"; } -log_fail() { echo -e "\033[0;31m✘ $1\033[0m"; exit 1; } +log_step() { printf '\033[0;36m→ %s\033[0m\n' "$1"; } +log_done() { printf '\033[0;32m✔ %s\033[0m\n' "$1"; } +log_fail() { printf '\033[0;31m✘ %s\033[0m\n' "$1"; exit 1; } # --- Argument Parsing --- if [ "$#" -lt 1 ]; then @@ -18,58 +17,63 @@ shift 1 log_step "Checking for update..." LATEST_RELEASE_INFO=$(curl -s "https://api.github.com/repos/PRASSamin/prasmoid/releases/latest") -if [ -z "$LATEST_RELEASE_INFO" ]; then - log_fail "Failed to fetch release info from GitHub." -fi +[ -z "$LATEST_RELEASE_INFO" ] && log_fail "Failed to fetch release info from GitHub." CURRENT_VERSION=$("$CURRENT_EXE" --version) RAW_CURRENT_VERSION=$(echo "$CURRENT_VERSION" | sed "s/-portable//") IS_PORTABLE=false -if [[ "$CURRENT_VERSION" == *"-portable"* ]]; then - IS_PORTABLE=true -fi +case "$CURRENT_VERSION" in + *-portable*) IS_PORTABLE=true ;; +esac ASSET_NAME="prasmoid" -if $IS_PORTABLE; then - ASSET_NAME="prasmoid-portable" -fi - -SHA256_URL=$(echo "$LATEST_RELEASE_INFO" | jq -r '.assets[] | select(.name == "SHA256SUMS") | .browser_download_url') -if [[ -z "$SHA256_URL" ]]; then - log_fail "SHA256SUMS file missing in release. This is likely a problem with the release itself, not your setup. Please check the release page on GitHub for updates, or contact the maintainers (open an issue at https://github.com/PRASSamin/prasmoid/issues) for assistance." -fi +$IS_PORTABLE && ASSET_NAME="prasmoid-portable" + +# --- Extract download URLs --- +extract_url() { + name=$1 + echo "$LATEST_RELEASE_INFO" | awk -v a="$name" ' + $0 ~ "\"name\": \""a"\"" {found=1} + found && $0 ~ "\"browser_download_url\"" { + gsub(/.*"browser_download_url": "|".*/, "", $0) + print $0 + exit + } + ' +} + +extract_tag() { + echo "$LATEST_RELEASE_INFO" | awk -F '"' '/"tag_name":/ {gsub(/^v/, "", $4); print $4; exit}' +} + +SHA256_URL=$(extract_url "SHA256SUMS") +[ -z "$SHA256_URL" ] && log_fail "SHA256SUMS file missing in release." log_step "Fetching SHA256SUMS..." - SHA256_CONTENT=$(curl -sL "$SHA256_URL") -LATEST_HASH=$(echo "$SHA256_CONTENT" | grep " $ASSET_NAME$" | awk '{print $1}') -if [[ -z "$LATEST_HASH" ]]; then - log_fail "Could not find hash for $ASSET_NAME in SHA256SUMS." -fi + +LATEST_HASH=$(echo "$SHA256_CONTENT" | awk -v a="$ASSET_NAME" '$2 == a {print $1; exit}') +[ -z "$LATEST_HASH" ] && log_fail "Could not find hash for $ASSET_NAME in SHA256SUMS." log_step "Calculating current binary hash..." CURRENT_HASH=$(sha256sum "$CURRENT_EXE" | awk '{print $1}') -if [ "$CURRENT_HASH" == "$LATEST_HASH" ]; then - log_done "You are already using the latest version of Prasmoid ($RAW_CURRENT_VERSION.$LATEST_HASH)." +if [ "$CURRENT_HASH" = "$LATEST_HASH" ]; then + log_done "You are already using the latest version of prasmoid ($RAW_CURRENT_VERSION.$LATEST_HASH)." exit 0 fi log_step "New binary available. Downloading $ASSET_NAME..." - -DOWNLOAD_URL=$(echo "$LATEST_RELEASE_INFO" | jq -r ".assets[] | select(.name == \"$ASSET_NAME\") | .browser_download_url") -if [ -z "$DOWNLOAD_URL" ] || [ "$DOWNLOAD_URL" == "null" ]; then - log_fail "Download URL for $ASSET_NAME not found." -fi +DOWNLOAD_URL=$(extract_url "$ASSET_NAME") +[ -z "$DOWNLOAD_URL" ] && log_fail "Download URL for $ASSET_NAME not found." TEMP_FILE=$(mktemp) -curl -L -o "$TEMP_FILE" "$DOWNLOAD_URL" -if [ $? -ne 0 ]; then +curl -L -o "$TEMP_FILE" "$DOWNLOAD_URL" || { rm -f "$TEMP_FILE" log_fail "Download failed." -fi +} -LATEST_VERSION=$(echo "$LATEST_RELEASE_INFO" | jq -r ".tag_name" | sed "s/^v//") +LATEST_VERSION=$(extract_tag) log_step "Verifying downloaded binary hash..." DOWNLOADED_HASH=$(sha256sum "$TEMP_FILE" | awk '{print $1}') @@ -89,7 +93,4 @@ mv "$TEMP_FILE" "$CURRENT_EXE" log_done "Update complete $LATEST_VERSION ($LATEST_HASH)" -# log_step "Relaunching CLI..." -# exec "$CURRENT_EXE" - exit 0 diff --git a/utils/main.go b/utils/main.go index ce54e03..ecf8830 100644 --- a/utils/main.go +++ b/utils/main.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/exec" + "os/user" "path/filepath" "sort" "strings" @@ -14,16 +15,12 @@ import ( "github.com/PRASSamin/prasmoid/consts" "github.com/PRASSamin/prasmoid/internal/runtime" "github.com/PRASSamin/prasmoid/types" - "github.com/fatih/color" ) var ( surveyAskOne = survey.AskOne - execCommand = exec.Command execLookPath = exec.LookPath - osLstat = os.Lstat - osSymlink = os.Symlink - getBinPath = GetBinPath + userCurrent = user.Current ) // check if plasmoid is linked @@ -183,24 +180,6 @@ func UpdateMetadata(key string, value interface{}, sectionOpt ...string) error { return nil } -var supportedPackageManagers = map[string]string{ - "apt": "apt", - "dnf": "dnf", - "pacman": "pacman", - "nix-env": "nix", -} - -// Detect package manager -var DetectPackageManager = func() (string, error) { - for binary, pm := range supportedPackageManagers { - _, err := execLookPath(binary) - if err == nil { - return pm, nil - } - } - return "", fmt.Errorf("no supported package manager found: %+v", supportedPackageManagers) -} - var GetBinPath = func() (string, error) { defaultCandidates := []string{ "/usr/bin", @@ -225,110 +204,6 @@ var GetBinPath = func() (string, error) { return "", fmt.Errorf("no supported bin path found: %+v", defaultCandidates) } -var InstallPackage = func(pm, binName string, pkgNames map[string]string) error { - binPath, err := getBinPath() - if err != nil { - return fmt.Errorf("failed to get bin path: %v", err) - } - - pkgName, ok := pkgNames[pm] - if !ok { - return fmt.Errorf("unsupported package manager: %s", pm) - } - - var cmd *exec.Cmd - switch pm { - case "nix": - cmd = execCommand("nix-env", "-iA", pkgName) - case "pacman": - cmd = execCommand("sudo", "pacman", "-S", "--noconfirm", pkgName) - default: - cmd = execCommand("sudo", pm, "install", "-y", pkgName) - } - - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - if err := cmd.Run(); err != nil { - color.Yellow("Warning: install command exited with error: %v", err) - } - - if err := ensureBinaryLinked(binName, binPath); err != nil { - return err - } - - color.Green("%s installed!", binName) - return nil -} - -// ensureBinaryLinked looks for a binary and symlinks it into our binPath. -var ensureBinaryLinked = func(binName, binPath string) error { - if _, err := execLookPath(binName); err == nil { - return nil // already found in PATH - } - - color.Yellow("Binary %s not in PATH, searching manually...", binName) - findCmd := execCommand("sudo", "find", "/", "-type", "f", "-name", binName) - out, err := findCmd.Output() - if err != nil { - return fmt.Errorf("failed to locate %s binary: %v", binName, err) - } - - path := strings.TrimSpace(strings.Split(string(out), "\n")[0]) - if path == "" { - return fmt.Errorf("%s not found on system", binName) - } - - link := filepath.Join(binPath, binName) - if _, err := osLstat(link); err == nil { - color.Yellow("Warning: symlink already exists at %s, skipping...", link) - return nil - } - - if err := osSymlink(path, link); err != nil { - return fmt.Errorf("failed to create symlink: %v", err) - } - - return nil -} - -func InstallDependencies() error { - pm, err := DetectPackageManager() - if err != nil { - return err - } - - if !IsPackageInstalled(consts.QmlFormatPackageName["binary"]) { - color.Yellow("Installing qmlformat...") - if err := InstallPackage(pm, consts.QmlFormatPackageName["binary"], consts.QmlFormatPackageName); err != nil { - return err - } - } - - if !IsPackageInstalled(consts.PlasmoidPreviewPackageName["binary"]) { - color.Yellow("Installing plasmoidviewer...") - if err := InstallPackage(pm, consts.PlasmoidPreviewPackageName["binary"], consts.PlasmoidPreviewPackageName); err != nil { - return err - } - } - - if !IsPackageInstalled(consts.GettextPackageName["binary"]) { - color.Yellow("Installing gettext...") - if err := InstallPackage(pm, consts.GettextPackageName["binary"], consts.GettextPackageName); err != nil { - return err - } - } - - if !IsPackageInstalled(consts.CurlPackageName["binary"]) { - color.Yellow("Installing curl...") - if err := InstallPackage(pm, consts.CurlPackageName["binary"], consts.CurlPackageName); err != nil { - return err - } - } - - return nil -} - func IsValidPlasmoid() bool { if _, err := os.Stat("metadata.json"); os.IsNotExist(err) { return false @@ -449,3 +324,15 @@ func AskForLocales(defaultLocales ...[]string) []string { func IsQmlFile(filename string) bool { return strings.HasSuffix(filename, ".qml") } + +var CheckRoot = func() error { + currentUser, err := userCurrent() + if err != nil { + return fmt.Errorf("failed to get current user: %v", err) + } + + if currentUser.Uid != "0" { + return fmt.Errorf("the requested operation requires superuser privileges. use `sudo %s`", strings.Join(os.Args[0:], " ")) + } + return nil +} diff --git a/utils/main_test.go b/utils/main_test.go index a9bad3b..4b82bc1 100644 --- a/utils/main_test.go +++ b/utils/main_test.go @@ -4,7 +4,7 @@ import ( "encoding/json" "errors" "os" - "os/exec" + "os/user" "path/filepath" "testing" @@ -318,51 +318,6 @@ func TestIsInstalled(t *testing.T) { }) } -func TestDetectPackageManager(t *testing.T) { - setup := func(t *testing.T, executables ...string) { - tmpDir := t.TempDir() - for _, exec := range executables { - err := os.WriteFile(filepath.Join(tmpDir, exec), []byte("#!/bin/sh\n"), 0755) - require.NoError(t, err) - } - t.Setenv("PATH", tmpDir) - } - - t.Run("detect apt", func(t *testing.T) { - setup(t, "apt") - pm, err := DetectPackageManager() - assert.NoError(t, err) - assert.Equal(t, "apt", pm) - }) - - t.Run("detect dnf", func(t *testing.T) { - setup(t, "dnf") - pm, err := DetectPackageManager() - assert.NoError(t, err) - assert.Equal(t, "dnf", pm) - }) - - t.Run("detect pacman", func(t *testing.T) { - setup(t, "pacman") - pm, err := DetectPackageManager() - assert.NoError(t, err) - assert.Equal(t, "pacman", pm) - }) - - t.Run("detect nix-env", func(t *testing.T) { - setup(t, "nix-env") - pm, err := DetectPackageManager() - assert.NoError(t, err) - assert.Equal(t, "nix", pm) - }) - - t.Run("no supported pm found", func(t *testing.T) { - setup(t) - _, err := DetectPackageManager() - assert.Error(t, err) - }) -} - func TestIsPackageInstalled(t *testing.T) { setup := func(t *testing.T, executables ...string) { tmpDir := t.TempDir() @@ -454,247 +409,35 @@ func TestLoadConfigRC(t *testing.T) { }) } -func TestInstallDependencies(t *testing.T) { - originalDetectPackageManager := DetectPackageManager - originalIsPackageInstalled := IsPackageInstalled - originalInstallPackage := InstallPackage - defer func() { - DetectPackageManager = originalDetectPackageManager - IsPackageInstalled = originalIsPackageInstalled - InstallPackage = originalInstallPackage - }() - - t.Run("all packages installed", func(t *testing.T) { - DetectPackageManager = func() (string, error) { return "apt", nil } - IsPackageInstalled = func(name string) bool { return true } - var installCalled bool - InstallPackage = func(pm, binName string, pkgNames map[string]string) error { installCalled = true; return nil } - - err := InstallDependencies() - assert.NoError(t, err) - assert.False(t, installCalled) - }) - - t.Run("none of packages installed", func(t *testing.T) { - DetectPackageManager = func() (string, error) { return "apt", nil } - IsPackageInstalled = func(name string) bool { return false } - var installCalled bool - InstallPackage = func(pm, binName string, pkgNames map[string]string) error { installCalled = true; return nil } - - err := InstallDependencies() - assert.NoError(t, err) - assert.True(t, installCalled) - }) - - t.Run("qmlformat not installed", func(t *testing.T) { - DetectPackageManager = func() (string, error) { return "apt", nil } - IsPackageInstalled = func(name string) bool { return name != "qmlformat" } - var qmlformatInstallCalled bool - InstallPackage = func(pm, binName string, pkgNames map[string]string) error { - if binName == "qmlformat" { - qmlformatInstallCalled = true - } - return nil - } - - err := InstallDependencies() - assert.NoError(t, err) - assert.True(t, qmlformatInstallCalled) - }) - - t.Run("installation fails", func(t *testing.T) { - DetectPackageManager = func() (string, error) { return "apt", nil } - IsPackageInstalled = func(name string) bool { return false } - expectedErr := errors.New("install error") - InstallPackage = func(pm, binName string, pkgNames map[string]string) error { - return expectedErr - } - - err := InstallDependencies() - assert.Equal(t, expectedErr, err) - }) -} - -func TestEnsureBinaryLinked(t *testing.T) { - originalExecLookPath := execLookPath - originalExecCommand := execCommand - originalOsLstat := osLstat - originalOsSymlink := osSymlink - defer func() { - execLookPath = originalExecLookPath - execCommand = originalExecCommand - osLstat = originalOsLstat - osSymlink = originalOsSymlink - }() - - t.Run("binary already in path", func(t *testing.T) { - execLookPath = func(file string) (string, error) { - return "/usr/bin/my-bin", nil - } - err := ensureBinaryLinked("my-bin", "/usr/local/bin") - assert.NoError(t, err) - }) - - t.Run("binary found and symlinked", func(t *testing.T) { - execLookPath = func(file string) (string, error) { - return "", errors.New("not found") - } - execCommand = func(name string, args ...string) *exec.Cmd { - return exec.Command("echo", "/opt/my-bin/my-bin") - } - osLstat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist - } - var symlinkCalled bool - osSymlink = func(oldname, newname string) error { - symlinkCalled = true - assert.Equal(t, "/opt/my-bin/my-bin", oldname) - assert.Equal(t, "/usr/local/bin/my-bin", newname) - return nil - } - - err := ensureBinaryLinked("my-bin", "/usr/local/bin") - assert.NoError(t, err) - assert.True(t, symlinkCalled) - }) - - t.Run("binary found but symlink exists", func(t *testing.T) { - execLookPath = func(file string) (string, error) { - return "", errors.New("not found") - } - execCommand = func(name string, args ...string) *exec.Cmd { - return exec.Command("echo", "/opt/my-bin/my-bin") - } - osLstat = func(name string) (os.FileInfo, error) { - return nil, nil // file exists - } - var symlinkCalled bool - osSymlink = func(oldname, newname string) error { - symlinkCalled = true - return nil - } - - err := ensureBinaryLinked("my-bin", "/usr/local/bin") - assert.NoError(t, err) - assert.False(t, symlinkCalled) - }) - - t.Run("binary not found", func(t *testing.T) { - execLookPath = func(file string) (string, error) { - return "", errors.New("not found") - } - execCommand = func(name string, args ...string) *exec.Cmd { - return exec.Command("echo", "") // empty output - } - - err := ensureBinaryLinked("my-bin", "/usr/local/bin") - assert.Error(t, err) - }) - - t.Run("find command fails", func(t *testing.T) { - execLookPath = func(file string) (string, error) { - return "", errors.New("not found") - } - execCommand = func(name string, args ...string) *exec.Cmd { - return exec.Command("false") // command that fails - } - - err := ensureBinaryLinked("my-bin", "/usr/local/bin") - assert.Error(t, err) - }) - - t.Run("symlink fails", func(t *testing.T) { - execLookPath = func(file string) (string, error) { - return "", errors.New("not found") - } - execCommand = func(name string, args ...string) *exec.Cmd { - return exec.Command("echo", "/opt/my-bin/my-bin") - } - osLstat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist - } - osSymlink = func(oldname, newname string) error { - return errors.New("symlink error") - } - - err := ensureBinaryLinked("my-bin", "/usr/local/bin") - assert.Error(t, err) - }) -} - -func TestInstallPackage(t *testing.T) { - originalGetBinPath := getBinPath - originalExecCommand := execCommand - originalEnsureBinaryLinked := ensureBinaryLinked - defer func() { - getBinPath = originalGetBinPath - execCommand = originalExecCommand - ensureBinaryLinked = originalEnsureBinaryLinked - }() - - t.Run("success for apt", func(t *testing.T) { - getBinPath = func() (string, error) { - return "/usr/local/bin", nil - } - var cmdArgs []string - execCommand = func(name string, args ...string) *exec.Cmd { - cmdArgs = append([]string{name}, args...) - return exec.Command("true") - } - ensureBinaryLinked = func(binName, binPath string) error { - return nil - } - - err := InstallPackage("apt", "my-bin", map[string]string{"apt": "my-pkg"}) - assert.NoError(t, err) - assert.Equal(t, []string{"sudo", "apt", "install", "-y", "my-pkg"}, cmdArgs) - }) - - t.Run("success for pacman", func(t *testing.T) { - getBinPath = func() (string, error) { - return "/usr/local/bin", nil - } - var cmdArgs []string - execCommand = func(name string, args ...string) *exec.Cmd { - cmdArgs = append([]string{name}, args...) - return exec.Command("true") - } - ensureBinaryLinked = func(binName, binPath string) error { - return nil - } - - err := InstallPackage("pacman", "my-bin", map[string]string{"pacman": "my-pkg"}) - assert.NoError(t, err) - assert.Equal(t, []string{"sudo", "pacman", "-S", "--noconfirm", "my-pkg"}, cmdArgs) +// TestCheckRoot tests the checkRoot function +func TestCheckRoot(t *testing.T) { + originalUserCurrent := userCurrent + t.Cleanup(func() { + userCurrent = originalUserCurrent }) - t.Run("getBinPath fails", func(t *testing.T) { - getBinPath = func() (string, error) { - return "", errors.New("getBinPath error") + t.Run("user is root", func(t *testing.T) { + userCurrent = func() (*user.User, error) { + return &user.User{Uid: "0"}, nil } - err := InstallPackage("apt", "my-bin", map[string]string{"apt": "my-pkg"}) - assert.Error(t, err) + assert.NoError(t, CheckRoot()) }) - t.Run("unsupported package manager", func(t *testing.T) { - getBinPath = func() (string, error) { - return "/usr/local/bin", nil + t.Run("user is not root", func(t *testing.T) { + userCurrent = func() (*user.User, error) { + return &user.User{Uid: "1000"}, nil } - err := InstallPackage("yum", "my-bin", map[string]string{"apt": "my-pkg"}) + err := CheckRoot() assert.Error(t, err) + assert.Contains(t, err.Error(), "the requested operation requires superuser privileges") }) - t.Run("ensureBinaryLinked fails", func(t *testing.T) { - getBinPath = func() (string, error) { - return "/usr/local/bin", nil + t.Run("user.Current returns error", func(t *testing.T) { + userCurrent = func() (*user.User, error) { + return nil, errors.New("user error") } - execCommand = func(name string, args ...string) *exec.Cmd { - return exec.Command("true") - } - ensureBinaryLinked = func(binName, binPath string) error { - return errors.New("ensureBinaryLinked error") - } - err := InstallPackage("apt", "my-bin", map[string]string{"apt": "my-pkg"}) + err := CheckRoot() assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get current user") }) -} +} \ No newline at end of file