From d3c957c1464ed1868d0a06a9a1ecd16da8e70a1c Mon Sep 17 00:00:00 2001 From: char Date: Sat, 1 Mar 2025 16:50:00 +0000 Subject: [PATCH 1/4] custom package dir --- main.go | 156 +++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 144 insertions(+), 12 deletions(-) diff --git a/main.go b/main.go index f808f3f..1dd1ba3 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "io" "net/http" @@ -80,6 +81,7 @@ type model struct { version string mode string packages []string + packages_dir string } func validateInput(source, installerType, input string) error { @@ -120,7 +122,60 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } - if m.step == 2 && m.mode != "Repackage Application" { + if m.step == -2 && m.typing { + switch msg.Type { + case tea.KeyEnter: + if m.textInput == "" { + m.textInput = packagesDir + } + + if err := os.MkdirAll(m.textInput, 0755); err != nil { + m.validationErr = fmt.Sprintf("Failed to create directory: %v", err) + return m, nil + } + + if err := save_config(m.textInput); err != nil { + m.validationErr = fmt.Sprintf("Failed to save configuration: %v", err) + return m, nil + } + + m.packages_dir = m.textInput + m.step = -1 + m.cursor = 0 + m.typing = false + m.textInput = "" + m.validationErr = "" + return m, nil + case tea.KeyBackspace: + if len(m.textInput) > 0 { + m.textInput = m.textInput[:len(m.textInput)-1] + } + return m, nil + case tea.KeyRunes: + m.textInput += string(msg.Runes) + return m, nil + } + } else if m.step == -2 { + switch msg.String() { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < 1 { + m.cursor++ + } + case "enter": + if m.cursor == 0 { + m.packages_dir = packagesDir + m.step = -1 + m.cursor = 0 + } else { + m.typing = true + m.textInput = packagesDir + } + } + } else if m.step == 2 && m.mode != "Repackage Application" { m.typing = true switch msg.Type { case tea.KeyEnter: @@ -130,7 +185,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.mode == "Repackage Application" { sanitized_name := sanitize_package_name(m.packageName) - package_dir := filepath.Join(packagesDir, sanitized_name) + package_dir := filepath.Join(m.packages_dir, sanitized_name) if _, err := os.Stat(package_dir); os.IsNotExist(err) { m.validationErr = fmt.Sprintf("Package '%s' does not exist", m.packageName) @@ -154,7 +209,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } sanitized_name := sanitize_package_name(m.packageName) - package_dir := filepath.Join(packagesDir, sanitized_name) + package_dir := filepath.Join(m.packages_dir, sanitized_name) if _, err := os.Stat(package_dir); err == nil { entries, err := os.ReadDir(filepath.Dir(package_dir)) @@ -205,7 +260,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(m.packages) > 0 { m.packageName = m.packages[m.cursor] sanitized_name := sanitize_package_name(m.packageName) - package_dir := filepath.Join(packagesDir, sanitized_name) + package_dir := filepath.Join(m.packages_dir, sanitized_name) m.outputDir = package_dir m.step = 3 m.cursor = 0 @@ -248,7 +303,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "down", "j": switch m.step { case -1: - if m.cursor < 1 { + if m.cursor < 2 { m.cursor++ } case 0, 1: @@ -259,9 +314,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "enter": switch m.step { case -1: - m.mode = []string{"New Application Package", "Repackage Application"}[m.cursor] + choices := []string{"New Application Package", "Repackage Application", "Set Packages Directory"} + m.mode = choices[m.cursor] if m.mode == "Repackage Application" { - packages, err := get_existing_packages() + packages, err := get_existing_packages(m.packages_dir) if err != nil { m.err = err return m, tea.Quit @@ -275,6 +331,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.packages = packages m.step = 2 m.cursor = 0 + } else if m.mode == "Set Packages Directory" { + m.step = -2 + m.cursor = 0 } else { m.step = 0 } @@ -403,9 +462,31 @@ func (m model) View() string { s += "\n\n" switch m.step { + case -2: + s += "Set Packages Directory:\n\n" + if m.typing { + s += "Enter custom packages directory path:\n" + s += m.textInput + if m.validationErr != "" { + s += "\n\n" + lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF0000")). + Render("Error: "+m.validationErr) + } + } else { + choices := []string{"Use Default Directory", "Set Custom Directory"} + for i, choice := range choices { + cursor := " " + if m.cursor == i { + cursor = ">" + } + s += fmt.Sprintf("%s %s\n", cursor, choice) + } + s += "\n" + s += fmt.Sprintf("Current packages directory: %s", m.packages_dir) + } case -1: s += "Select Operation:\n\n" - choices := []string{"New Application Package", "Repackage Application"} + choices := []string{"New Application Package", "Repackage Application", "Set Packages Directory"} for i, choice := range choices { cursor := " " if m.cursor == i { @@ -413,6 +494,8 @@ func (m model) View() string { } s += fmt.Sprintf("%s %s\n", cursor, choice) } + s += "\n" + s += fmt.Sprintf("Current packages directory: %s", m.packages_dir) case 0: s += "Select Installation Source:\n\n" choices := []string{"Local File", "Download from Internet"} @@ -606,7 +689,13 @@ func run_interactive(cmd *cobra.Command, args []string) { return } - p := tea.NewProgram(model{step: -1}) + packages_dir, err := load_config() + if err != nil { + fmt.Printf("Error loading configuration: %v\n", err) + packages_dir = packagesDir + } + + p := tea.NewProgram(model{step: -1, packages_dir: packages_dir}) m, err := p.Run() if err != nil { fmt.Printf("Error running program: %v\n", err) @@ -913,12 +1002,12 @@ func sanitizePackageName(name string) string { return strings.Trim(name, "-") } -func get_existing_packages() ([]string, error) { - if err := os.MkdirAll(packagesDir, 0755); err != nil { +func get_existing_packages(packages_dir string) ([]string, error) { + if err := os.MkdirAll(packages_dir, 0755); err != nil { return nil, fmt.Errorf("failed to create packages directory: %v", err) } - entries, err := os.ReadDir(packagesDir) + entries, err := os.ReadDir(packages_dir) if err != nil { return nil, fmt.Errorf("failed to read packages directory: %v", err) } @@ -940,3 +1029,46 @@ func get_existing_packages() ([]string, error) { func sanitize_package_name(name string) string { return sanitizePackageName(name) } + +func load_config() (string, error) { + config_path := filepath.Join(nexusDir, "config.json") + if _, err := os.Stat(config_path); os.IsNotExist(err) { + return packagesDir, nil + } + + data, err := os.ReadFile(config_path) + if err != nil { + return packagesDir, err + } + + var config struct { + PackagesDir string `json:"packages_dir"` + } + + if err := json.Unmarshal(data, &config); err != nil { + return packagesDir, err + } + + if config.PackagesDir == "" { + return packagesDir, nil + } + + return config.PackagesDir, nil +} + +func save_config(packages_dir string) error { + config_path := filepath.Join(nexusDir, "config.json") + + config := struct { + PackagesDir string `json:"packages_dir"` + }{ + PackagesDir: packages_dir, + } + + data, err := json.Marshal(config) + if err != nil { + return err + } + + return os.WriteFile(config_path, data, 0644) +} From a9f26c543912caf4fc5fc591064a83bb8702cc21 Mon Sep 17 00:00:00 2001 From: char Date: Sat, 1 Mar 2025 17:10:28 +0000 Subject: [PATCH 2/4] Add interactive input with autocomplete and suggestions --- go.mod | 2 + go.sum | 4 + main.go | 296 ++++++++++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 282 insertions(+), 20 deletions(-) diff --git a/go.mod b/go.mod index a024924..4573c2d 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,9 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v0.20.0 // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect diff --git a/go.sum b/go.sum index 63d924d..6ee3a6a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= +github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= github.com/charmbracelet/bubbletea v1.3.3 h1:WpU6fCY0J2vDWM3zfS3vIDi/ULq3SYphZhkAGGvmEUY= github.com/charmbracelet/bubbletea v1.3.3/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= diff --git a/main.go b/main.go index 1dd1ba3..fe39d36 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "path" "path/filepath" "regexp" + "runtime" "strings" "nexus/internal/msi" @@ -18,6 +19,9 @@ import ( _ "embed" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" @@ -82,6 +86,50 @@ type model struct { mode string packages []string packages_dir string + text_input textinput.Model + help help.Model + keymap keymap + suggestions []string +} + +type keymap struct{} + +func (k keymap) ShortHelp() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "complete")), + key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "quit")), + } +} + +func (k keymap) FullHelp() [][]key.Binding { + return [][]key.Binding{k.ShortHelp()} +} + +func initial_model() model { + m := model{ + step: -1, + packages_dir: packagesDir, + } + + // Initialize text input + ti := textinput.New() + ti.Placeholder = "application name" + ti.Prompt = "Package name: " + ti.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF875F")) + ti.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF875F")) + ti.Focus() + ti.CharLimit = 50 + ti.Width = 30 + ti.ShowSuggestions = true + + // Initialize help + h := help.New() + + m.text_input = ti + m.help = h + m.keymap = keymap{} + + return m } func validateInput(source, installerType, input string) error { @@ -112,7 +160,99 @@ func validateInput(source, installerType, input string) error { } func (m model) Init() tea.Cmd { - return nil + // Load existing packages for suggestions + packages, _ := get_existing_packages(m.packages_dir) + var suggestions []string + for _, pkg := range packages { + suggestions = append(suggestions, pkg) + } + + // Add some common application names as suggestions + common_apps := []string{ + "Microsoft Office", + "Adobe Acrobat Reader", + "Google Chrome", + "Mozilla Firefox", + "Zoom", + "Microsoft Teams", + "VLC Media Player", + "7-Zip", + "Notepad++", + "Visual Studio Code", + } + + for _, app := range common_apps { + if !contains(suggestions, app) { + suggestions = append(suggestions, app) + } + } + + m.suggestions = suggestions + m.text_input.SetSuggestions(suggestions) + + return textinput.Blink +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +// Add this function to handle file path suggestions +func get_path_suggestions(current_path string) []string { + var suggestions []string + + // Get the directory to look in + dir_path := filepath.Dir(current_path) + if dir_path == "." { + dir_path = "." + current_path = "" + } + + // If empty, start with drives on Windows or root on other OS + if dir_path == "." && current_path == "" { + if runtime.GOOS == "windows" { + for _, drive := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ" { + drive_path := string(drive) + ":\\" + if _, err := os.Stat(drive_path); err == nil { + suggestions = append(suggestions, drive_path) + } + } + return suggestions + } else { + dir_path = "/" + } + } + + // Read directory contents + files, err := os.ReadDir(dir_path) + if err != nil { + return suggestions + } + + // Get the base name to filter with + base_name := filepath.Base(current_path) + if base_name == "." || dir_path == current_path { + base_name = "" + } + + // Add matching entries + for _, file := range files { + name := file.Name() + if strings.HasPrefix(strings.ToLower(name), strings.ToLower(base_name)) { + full_path := filepath.Join(dir_path, name) + if file.IsDir() { + full_path = filepath.Join(full_path, "") // Add trailing separator + } + suggestions = append(suggestions, full_path) + } + } + + return suggestions } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -122,6 +262,117 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } + // Handle custom directory input + if m.step == -2 && m.typing { + switch msg.Type { + case tea.KeyEnter: + input := m.text_input.Value() + if input == "" { + input = packagesDir + } + + if err := os.MkdirAll(input, 0755); err != nil { + m.validationErr = fmt.Sprintf("Failed to create directory: %v", err) + return m, nil + } + + if err := save_config(input); err != nil { + m.validationErr = fmt.Sprintf("Failed to save configuration: %v", err) + return m, nil + } + + m.packages_dir = input + m.step = -1 + m.cursor = 0 + m.typing = false + m.text_input.Reset() + m.validationErr = "" + return m, nil + default: + // If Tab is pressed, update suggestions with directory paths + if msg.String() == "tab" { + suggestions := get_path_suggestions(m.text_input.Value()) + m.text_input.SetSuggestions(suggestions) + } + + var cmd tea.Cmd + m.text_input, cmd = m.text_input.Update(msg) + return m, cmd + } + } + + // Handle autocomplete input when in package name input step + if m.step == 2 && m.mode != "Repackage Application" && m.packageName == "" { + switch msg.Type { + case tea.KeyEnter: + m.packageName = m.text_input.Value() + m.text_input.Reset() + m.text_input.Focus() // Keep focus for the next input + return m, nil + default: + var cmd tea.Cmd + m.text_input, cmd = m.text_input.Update(msg) + return m, cmd + } + } + + // Handle file path input with autocomplete + if m.step == 2 && m.mode != "Repackage Application" && m.packageName != "" { + switch msg.Type { + case tea.KeyEnter: + input := m.text_input.Value() + if err := validateInput(m.source, m.installerType, input); err != nil { + m.validationErr = err.Error() + return m, nil + } + + m.textInput = input + + sanitized_name := sanitize_package_name(m.packageName) + package_dir := filepath.Join(m.packages_dir, sanitized_name) + + // Check for existing package directory + if _, err := os.Stat(package_dir); err == nil { + entries, err := os.ReadDir(filepath.Dir(package_dir)) + if err == nil { + prefix := filepath.Base(package_dir) + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), prefix) { + os.RemoveAll(filepath.Join(filepath.Dir(package_dir), entry.Name())) + } + } + } + if err := os.RemoveAll(package_dir); err != nil { + m.err = fmt.Errorf("failed to remove existing package directory: %v", err) + return m, tea.Quit + } + } + + if err := os.MkdirAll(package_dir, 0755); err != nil { + m.err = err + return m, tea.Quit + } + + m.outputDir = package_dir + m.step++ + m.typing = false + m.cursor = 0 + m.validationErr = "" + m.text_input.Blur() + return m, nil + default: + // Update suggestions when typing + if msg.String() == "tab" { + suggestions := get_path_suggestions(m.text_input.Value()) + m.text_input.SetSuggestions(suggestions) + } + + var cmd tea.Cmd + m.text_input, cmd = m.text_input.Update(msg) + return m, cmd + } + } + if m.step == -2 && m.typing { switch msg.Type { case tea.KeyEnter: @@ -465,8 +716,10 @@ func (m model) View() string { case -2: s += "Set Packages Directory:\n\n" if m.typing { - s += "Enter custom packages directory path:\n" - s += m.textInput + m.text_input.Prompt = "Enter custom packages directory path: " + m.text_input.Placeholder = packagesDir + s += m.text_input.View() + if m.validationErr != "" { s += "\n\n" + lipgloss.NewStyle(). Foreground(lipgloss.Color("#FF0000")). @@ -531,19 +784,24 @@ func (m model) View() string { } } } else if m.packageName == "" { - s += "Enter package name: " + m.textInput + s += m.text_input.View() } else { label := "path to" if m.source == "Download from Internet" { label = "download URL for" + m.text_input.Placeholder = "https://example.com/installer.exe" + } else { + m.text_input.Placeholder = "C:\\path\\to\\installer.exe" } - s += fmt.Sprintf("Enter %s %s file: %s", label, m.installerType, m.textInput) - } - if m.validationErr != "" { - s += "\n\n" + lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FF0000")). - Render("Error: "+m.validationErr) + m.text_input.Prompt = fmt.Sprintf("Enter %s %s file: ", label, m.installerType) + s += m.text_input.View() + + if m.validationErr != "" { + s += "\n\n" + lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF0000")). + Render("Error: "+m.validationErr) + } } case 3: s += titleStyle.Render("Package Summary") @@ -587,7 +845,7 @@ func (m model) View() string { } } - s += "\n" + lipgloss.NewStyle().Faint(true).Render("Press Ctrl+C to quit") + s += "\n\n" + m.help.View(m.keymap) return s } @@ -695,14 +953,18 @@ func run_interactive(cmd *cobra.Command, args []string) { packages_dir = packagesDir } - p := tea.NewProgram(model{step: -1, packages_dir: packages_dir}) - m, err := p.Run() + // Use the new initial_model function + m := initial_model() + m.packages_dir = packages_dir + + p := tea.NewProgram(m) + final_model, err := p.Run() if err != nil { fmt.Printf("Error running program: %v\n", err) return } - finalModel := m.(model) + finalModel := final_model.(model) if finalModel.step == 3 && finalModel.cursor == 0 { fmt.Println("\n" + titleStyle.Render("Creating Package")) indent := " " @@ -889,12 +1151,6 @@ func run_interactive(cmd *cobra.Command, args []string) { fmt.Printf("%s• Install Script: Install.ps1\n", indent) fmt.Printf("%s• Uninstall Script: Uninstall.ps1\n", indent) - fmt.Println("\n" + sectionStyle.Render("Installation Commands")) - fmt.Printf("%s• Install Command:\n", indent) - fmt.Printf("%s powershell.exe -ExecutionPolicy Bypass -File .\\Install.ps1\n", indent) - fmt.Printf("%s• Uninstall Command:\n", indent) - fmt.Printf("%s powershell.exe -ExecutionPolicy Bypass -File .\\Uninstall.ps1\n", indent) - fmt.Println("\n" + sectionStyle.Render("Detection Method")) if finalModel.installerType == "MSI" { fmt.Printf("%s• MSI Product Code:\n", indent) From 63c039b0f930f5e0542e66f19000df33a4437d7f Mon Sep 17 00:00:00 2001 From: char Date: Sat, 1 Mar 2025 17:12:46 +0000 Subject: [PATCH 3/4] Enable fullscreen mode for interactive UI --- main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index fe39d36..22160da 100644 --- a/main.go +++ b/main.go @@ -957,7 +957,8 @@ func run_interactive(cmd *cobra.Command, args []string) { m := initial_model() m.packages_dir = packages_dir - p := tea.NewProgram(m) + // Add WithAltScreen() option to use fullscreen mode + p := tea.NewProgram(m, tea.WithAltScreen()) final_model, err := p.Run() if err != nil { fmt.Printf("Error running program: %v\n", err) From ce48d2f40ecc83d54b17fccde54f4e8c47abc4ca Mon Sep 17 00:00:00 2001 From: char Date: Sun, 2 Mar 2025 00:33:42 +0000 Subject: [PATCH 4/4] Improved UI and workflow with recent packages, styling, and usability improvements --- README.md | 57 ++++++++-- go.mod | 2 +- main.go | 327 ++++++++++++++++++++++++++++++++++++++---------------- 3 files changed, 278 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index 5ea93b9..1d53dbd 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # Nexus -Automated tool for monitoring, packaging, and versioning Intune applications. Streamlines the process of detecting new software versions, repackaging applications, and managing deployments through Microsoft Intune. +Automated tool for packaging and managing Intune applications. Streamlines the process of creating application packages and managing deployments through Microsoft Intune with an intuitive interface. > Currently a work in progress. ## Features +- **Interactive UI**: User-friendly terminal interface with color-coded menus and selections - **Application Packaging**: Create ready-to-deploy Intune application packages from MSI or EXE installers - **Automatic Detection**: Extract product codes and version information from MSI installers - **Script Generation**: Automatically generate installation and uninstallation scripts @@ -13,6 +14,12 @@ Automated tool for monitoring, packaging, and versioning Intune applications. St - **Intunewin Creation**: Seamlessly create .intunewin files required for Intune deployment - **Local & Remote Sources**: Package applications from local files or direct download URLs - **Standardized Structure**: Consistent package organization for easier management +- **Recent Packages**: Quick access to recently modified packages +- **Custom Package Directory**: Configure where packages are stored +- **Detailed Progress**: Verbose output during package creation for better visibility +- **Path Autocomplete**: Tab completion for file paths when selecting installers +- **Package Suggestions**: Common application name suggestions for faster package creation +- **Error Handling**: Comprehensive error checking and validation throughout the process ## Build @@ -25,7 +32,7 @@ go build -ldflags="-s -w" -o dist/nexus.exe ### Creating a New Application Package 1. Run Nexus and select "New Application Package" -2. Choose your installation source (Local File or Download from Internet) +2. Choose your installation source (Local File or Download File) 3. Select the installer type (MSI or EXE) 4. Enter a name for your package 5. Provide the path or URL to the installer file @@ -34,9 +41,15 @@ go build -ldflags="-s -w" -o dist/nexus.exe ### Repackaging an Existing Application 1. Run Nexus and select "Repackage Application" -2. Choose the application to repackage from the list +2. Choose the application to repackage from the list (sorted by most recently modified) 3. Review the package details and confirm repackaging +### Customizing Package Location + +1. Run Nexus and select "Set Packages Directory" +2. Choose "Use Default Directory" or "Set Custom Directory" +3. If setting a custom directory, enter the path with tab-completion assistance + ### Package Structure Each package created by Nexus includes: @@ -46,21 +59,45 @@ Each package created by Nexus includes: - Uninstall.ps1 script for removal - .intunewin file for Intune deployment +### Customizing Installation + +After package creation, you can customize the installation process: + +1. Open Install.ps1 in the package directory +2. Modify installation arguments by updating the $install_args variable +3. Add custom PowerShell code before or after the install_application() function +4. Repackage the application to create a new .intunewin file with your changes + ### Intune Deployment After package creation, Nexus provides a comprehensive summary with all the information needed for Intune deployment: ```plaintext -Package Summary - • Name, type, version and product code (for MSI) - • Package location and files - • Installation and uninstallation commands - • Detection method details - • Configuration options +Summary: + • Name, version and product code (for MSI) + • Source location + +Location: + • Installer File + • IntuneWin File + • Package Directory + +Intune Configuration: + • Install Script + • Uninstall Script + +Intune Detection Method: + • MSI Product Code (for MSI installers) + • Version Detection (for MSI installers) + • Custom detection options (for EXE installers) + +Customizing Installation: + • Installation Arguments + • Custom Installation Steps ``` This summary contains everything you need to configure the application in Intune, with no additional information required. ## Demo -https://github.com/user-attachments/assets/d61fcb7f-b265-4a83-a1e5-f4f498629787 + diff --git a/go.mod b/go.mod index 4573c2d..2287982 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module nexus go 1.24.0 require ( + github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.3.3 github.com/charmbracelet/lipgloss v1.0.0 github.com/spf13/cobra v1.9.1 @@ -12,7 +13,6 @@ require ( require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbles v0.20.0 // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect diff --git a/main.go b/main.go index 22160da..f2131f7 100644 --- a/main.go +++ b/main.go @@ -11,7 +11,9 @@ import ( "path/filepath" "regexp" "runtime" + "sort" "strings" + "time" "nexus/internal/msi" @@ -37,12 +39,16 @@ var rootCmd = &cobra.Command{ var ( titleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#FF875F")). - Margin(0, 0, 1, 0). - Padding(0, 2). - Width(50) + Foreground(lipgloss.Color("#FFFFFF")). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#FF875F")). + Padding(0, 2) + + sectionStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Background(lipgloss.Color("#FF875F")). + Padding(0, 1). + MarginBottom(1) ) const ( @@ -89,7 +95,6 @@ type model struct { text_input textinput.Model help help.Model keymap keymap - suggestions []string } type keymap struct{} @@ -111,18 +116,16 @@ func initial_model() model { packages_dir: packagesDir, } - // Initialize text input ti := textinput.New() ti.Placeholder = "application name" ti.Prompt = "Package name: " ti.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF875F")) ti.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF875F")) ti.Focus() - ti.CharLimit = 50 - ti.Width = 30 + ti.CharLimit = 500 + ti.Width = 60 ti.ShowSuggestions = true - // Initialize help h := help.New() m.text_input = ti @@ -133,7 +136,7 @@ func initial_model() model { } func validateInput(source, installerType, input string) error { - if source == "Download from Internet" { + if source == "Download File" { if !strings.HasPrefix(strings.ToLower(input), "https://") { return fmt.Errorf("URL must start with 'https://'") } @@ -160,14 +163,10 @@ func validateInput(source, installerType, input string) error { } func (m model) Init() tea.Cmd { - // Load existing packages for suggestions packages, _ := get_existing_packages(m.packages_dir) var suggestions []string - for _, pkg := range packages { - suggestions = append(suggestions, pkg) - } + suggestions = append(suggestions, packages...) - // Add some common application names as suggestions common_apps := []string{ "Microsoft Office", "Adobe Acrobat Reader", @@ -187,7 +186,6 @@ func (m model) Init() tea.Cmd { } } - m.suggestions = suggestions m.text_input.SetSuggestions(suggestions) return textinput.Blink @@ -202,18 +200,15 @@ func contains(slice []string, item string) bool { return false } -// Add this function to handle file path suggestions func get_path_suggestions(current_path string) []string { var suggestions []string - // Get the directory to look in dir_path := filepath.Dir(current_path) if dir_path == "." { dir_path = "." current_path = "" } - // If empty, start with drives on Windows or root on other OS if dir_path == "." && current_path == "" { if runtime.GOOS == "windows" { for _, drive := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ" { @@ -228,25 +223,22 @@ func get_path_suggestions(current_path string) []string { } } - // Read directory contents files, err := os.ReadDir(dir_path) if err != nil { return suggestions } - // Get the base name to filter with base_name := filepath.Base(current_path) if base_name == "." || dir_path == current_path { base_name = "" } - // Add matching entries for _, file := range files { name := file.Name() if strings.HasPrefix(strings.ToLower(name), strings.ToLower(base_name)) { full_path := filepath.Join(dir_path, name) if file.IsDir() { - full_path = filepath.Join(full_path, "") // Add trailing separator + full_path = filepath.Join(full_path, "") } suggestions = append(suggestions, full_path) } @@ -262,7 +254,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } - // Handle custom directory input if m.step == -2 && m.typing { switch msg.Type { case tea.KeyEnter: @@ -289,7 +280,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.validationErr = "" return m, nil default: - // If Tab is pressed, update suggestions with directory paths if msg.String() == "tab" { suggestions := get_path_suggestions(m.text_input.Value()) m.text_input.SetSuggestions(suggestions) @@ -301,13 +291,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - // Handle autocomplete input when in package name input step if m.step == 2 && m.mode != "Repackage Application" && m.packageName == "" { switch msg.Type { case tea.KeyEnter: m.packageName = m.text_input.Value() m.text_input.Reset() - m.text_input.Focus() // Keep focus for the next input + m.text_input.Focus() return m, nil default: var cmd tea.Cmd @@ -316,7 +305,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - // Handle file path input with autocomplete if m.step == 2 && m.mode != "Repackage Application" && m.packageName != "" { switch msg.Type { case tea.KeyEnter: @@ -331,7 +319,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { sanitized_name := sanitize_package_name(m.packageName) package_dir := filepath.Join(m.packages_dir, sanitized_name) - // Check for existing package directory if _, err := os.Stat(package_dir); err == nil { entries, err := os.ReadDir(filepath.Dir(package_dir)) if err == nil { @@ -361,7 +348,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.text_input.Blur() return m, nil default: - // Update suggestions when typing if msg.String() == "tab" { suggestions := get_path_suggestions(m.text_input.Value()) m.text_input.SetSuggestions(suggestions) @@ -419,11 +405,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "enter": if m.cursor == 0 { m.packages_dir = packagesDir + + if err := save_config(packagesDir); err != nil { + m.validationErr = fmt.Sprintf("Failed to save configuration: %v", err) + return m, nil + } + m.step = -1 m.cursor = 0 } else { m.typing = true - m.textInput = packagesDir + m.text_input.Reset() + m.text_input.SetValue(packagesDir) + m.text_input.Focus() } } } else if m.step == 2 && m.mode != "Repackage Application" { @@ -590,7 +584,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.cursor = 0 case 0: - m.source = []string{"Local File", "Download from Internet"}[m.cursor] + m.source = []string{"Local File", "Download File"}[m.cursor] m.step++ m.cursor = 0 case 1: @@ -709,9 +703,11 @@ func createPackageScripts(outputDir, packageName, installerType string) error { } func (m model) View() string { - s := titleStyle.Render("Welcome to the Nexus CLI preview!") + s := titleStyle.Render("Welcome to the packager preview!") s += "\n\n" + selected_style := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF875F")) + switch m.step { case -2: s += "Set Packages Directory:\n\n" @@ -730,7 +726,8 @@ func (m model) View() string { for i, choice := range choices { cursor := " " if m.cursor == i { - cursor = ">" + cursor = "▸" + choice = selected_style.Render(choice) } s += fmt.Sprintf("%s %s\n", cursor, choice) } @@ -738,24 +735,34 @@ func (m model) View() string { s += fmt.Sprintf("Current packages directory: %s", m.packages_dir) } case -1: - s += "Select Operation:\n\n" + s += sectionStyle.Render("Select Operation") + "\n" choices := []string{"New Application Package", "Repackage Application", "Set Packages Directory"} for i, choice := range choices { cursor := " " if m.cursor == i { - cursor = ">" + cursor = "▸" + choice = selected_style.Render(choice) } s += fmt.Sprintf("%s %s\n", cursor, choice) } s += "\n" s += fmt.Sprintf("Current packages directory: %s", m.packages_dir) + + recent_packages, _ := get_recent_packages(m.packages_dir, 3) + if len(recent_packages) > 0 { + s += "\n\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("#FF875F")).Render("Recent packages:") + for _, pkg := range recent_packages { + s += fmt.Sprintf("\n • %s", pkg) + } + } case 0: s += "Select Installation Source:\n\n" - choices := []string{"Local File", "Download from Internet"} + choices := []string{"Local File", "Download File"} for i, choice := range choices { cursor := " " if m.cursor == i { - cursor = ">" + cursor = "▸" + choice = selected_style.Render(choice) } s += fmt.Sprintf("%s %s\n", cursor, choice) } @@ -765,7 +772,8 @@ func (m model) View() string { for i, choice := range choices { cursor := " " if m.cursor == i { - cursor = ">" + cursor = "▸" + choice = selected_style.Render(choice) } s += fmt.Sprintf("%s %s\n", cursor, choice) } @@ -778,7 +786,8 @@ func (m model) View() string { for i, pkg := range m.packages { cursor := " " if m.cursor == i { - cursor = ">" + cursor = "▸" + pkg = selected_style.Render(pkg) } s += fmt.Sprintf("%s %s\n", cursor, pkg) } @@ -787,7 +796,7 @@ func (m model) View() string { s += m.text_input.View() } else { label := "path to" - if m.source == "Download from Internet" { + if m.source == "Download File" { label = "download URL for" m.text_input.Placeholder = "https://example.com/installer.exe" } else { @@ -840,6 +849,7 @@ func (m model) View() string { cursor := fmt.Sprintf("%s ", indent) if m.cursor == i { cursor = fmt.Sprintf("%s▸ ", indent) + choice = selected_style.Render(choice) } s += fmt.Sprintf("%s%s\n", cursor, choice) } @@ -953,12 +963,10 @@ func run_interactive(cmd *cobra.Command, args []string) { packages_dir = packagesDir } - // Use the new initial_model function m := initial_model() m.packages_dir = packages_dir - // Add WithAltScreen() option to use fullscreen mode - p := tea.NewProgram(m, tea.WithAltScreen()) + p := tea.NewProgram(m) final_model, err := p.Run() if err != nil { fmt.Printf("Error running program: %v\n", err) @@ -979,7 +987,8 @@ func run_interactive(cmd *cobra.Command, args []string) { if finalModel.mode == "Repackage Application" { fmt.Println("\n" + sectionStyle.Render("Actions")) - fmt.Printf("%s• Repackaging existing application...\n", indent) + fmt.Printf("%s• Analyzing existing package...\n", indent) + fmt.Printf("%s - Package directory: %s\n", indent, finalModel.outputDir) files, err := os.ReadDir(finalModel.outputDir) if err != nil { @@ -988,11 +997,13 @@ func run_interactive(cmd *cobra.Command, args []string) { } var installer_path string + var installer_file string for _, file := range files { if !file.IsDir() && (strings.HasSuffix(strings.ToLower(file.Name()), ".msi") || strings.HasSuffix(strings.ToLower(file.Name()), ".exe")) { installer_path = filepath.Join(finalModel.outputDir, file.Name()) - installerFile = file.Name() + installer_file = file.Name() + fmt.Printf("%s - Found installer: %s\n", indent, installer_file) break } } @@ -1002,7 +1013,7 @@ func run_interactive(cmd *cobra.Command, args []string) { return } - if strings.HasSuffix(strings.ToLower(installerFile), ".msi") { + if strings.HasSuffix(strings.ToLower(installer_file), ".msi") { finalModel.installerType = "MSI" } else { finalModel.installerType = "EXE" @@ -1024,50 +1035,62 @@ func run_interactive(cmd *cobra.Command, args []string) { } fmt.Printf("%s• Generating IntuneWin package...\n", indent) - args := []string{ - "-c", finalModel.outputDir, - "-s", installer_path, - "-o", finalModel.outputDir, - "-q", - } - cmd := exec.Command(intuneUtilPath, args...) - if output, err := cmd.CombinedOutput(); err != nil { - fmt.Printf("Error generating IntuneWin package: %v\n%s\n", err, output) - return - } + fmt.Printf("%s - Source: %s\n", indent, installer_path) + fmt.Printf("%s - Output: %s\n", indent, finalModel.outputDir) + + time.Sleep(500 * time.Millisecond) if finalModel.installerType == "MSI" { product_code, version, err := getMSIProductCode(installer_path) if err != nil { - fmt.Printf("Warning: Could not get MSI product code: %v\n", err) + fmt.Printf("%s• Warning: Could not extract MSI metadata: %v\n", indent, err) + fmt.Printf("%s - You may need to manually set detection rules in Intune\n", indent) } else { finalModel.productCode = product_code finalModel.version = version + fmt.Printf("%s• Successfully extracted MSI metadata\n", indent) + fmt.Printf("%s - Product Code: %s\n", indent, product_code) + fmt.Printf("%s - Version: %s\n", indent, version) } } } else if finalModel.source == "Local File" { - fmt.Println("\n" + sectionStyle.Render("Actions")) + fmt.Println("\n" + sectionStyle.Render("Actions:")) + fmt.Printf("%s• Preparing package directory...\n", indent) + fmt.Printf("%s - Creating: %s\n", indent, finalModel.outputDir) + fmt.Printf("%s• Copying installer file...\n", indent) + fmt.Printf("%s - Source: %s\n", indent, finalModel.textInput) + fmt.Printf("%s - Destination: %s\n", indent, filepath.Join(finalModel.outputDir, installerFile)) + if err := copyFileToDir(finalModel.textInput, finalModel.outputDir, installerFile); err != nil { fmt.Printf("Error copying installer: %v\n", err) return } + time.Sleep(500 * time.Millisecond) + if finalModel.installerType == "MSI" { + fmt.Printf("%s• Extracting MSI metadata...\n", indent) msiPath := filepath.Join(finalModel.outputDir, installerFile) productCode, version, err := getMSIProductCode(msiPath) if err != nil { - fmt.Printf("Error getting product code: %v\n", err) - return + fmt.Printf("%s - Warning: Could not extract MSI metadata: %v\n", indent, err) + fmt.Printf("%s - You may need to manually set detection rules in Intune\n", indent) + } else { + finalModel.productCode = productCode + finalModel.version = version + fmt.Printf("%s - Product Code: %s\n", indent, productCode) + fmt.Printf("%s - Version: %s\n", indent, version) } - finalModel.productCode = productCode - finalModel.version = version } + fmt.Printf("%s• Creating installation scripts...\n", indent) if err := createPackageScripts(finalModel.outputDir, finalModel.packageName, finalModel.installerType); err != nil { fmt.Printf("Error creating package scripts: %v\n", err) return } + fmt.Printf("%s - Install.ps1: Silent installation script\n", indent) + fmt.Printf("%s - Uninstall.ps1: Clean removal script\n", indent) fmt.Printf("%s• Generating IntuneWin package...\n", indent) args := []string{ @@ -1082,38 +1105,63 @@ func run_interactive(cmd *cobra.Command, args []string) { return } + fmt.Printf("%s• Package creation complete\n", indent) + fmt.Printf("%s - IntuneWin file: %s\n", indent, intunewinFile) } else { - fmt.Println("\n" + sectionStyle.Render("Actions")) + fmt.Println("\n" + sectionStyle.Render("Actions:")) fmt.Printf("%s• Downloading installer file...\n", indent) download_path := filepath.Join(downloadsDir, installerFile) + fmt.Printf("%s - URL: %s\n", indent, finalModel.textInput) + fmt.Printf("%s - Temporary location: %s\n", indent, download_path) + if err := downloadFile(finalModel.textInput, download_path); err != nil { - fmt.Printf("Error downloading installer: %v\n", err) + fmt.Printf("%s - Error downloading installer: %v\n", indent, err) return } + if _, err := os.Stat(download_path); os.IsNotExist(err) { + fmt.Printf("%s - Error: Download failed, file not found\n", indent) + return + } + + fmt.Printf("%s - Download complete\n", indent) + + fmt.Printf("%s• Preparing package directory...\n", indent) + fmt.Printf("%s - Creating: %s\n", indent, finalModel.outputDir) + fmt.Printf("%s• Copying installer to package directory...\n", indent) if err := copyFileToDir(download_path, finalModel.outputDir, installerFile); err != nil { - fmt.Printf("Error copying installer: %v\n", err) + fmt.Printf("%s - Error copying installer: %v\n", indent, err) return } + fmt.Printf("%s - Copy complete\n", indent) + + time.Sleep(500 * time.Millisecond) if finalModel.installerType == "MSI" { + fmt.Printf("%s• Extracting MSI metadata...\n", indent) msiPath := filepath.Join(finalModel.outputDir, installerFile) productCode, version, err := getMSIProductCode(msiPath) if err != nil { - fmt.Printf("Error getting product code: %v\n", err) - return + fmt.Printf("%s - Warning: Could not extract MSI metadata: %v\n", indent, err) + fmt.Printf("%s - You may need to manually set detection rules in Intune\n", indent) + } else { + finalModel.productCode = productCode + finalModel.version = version + fmt.Printf("%s - Product Code: %s\n", indent, productCode) + fmt.Printf("%s - Version: %s\n", indent, version) } - finalModel.productCode = productCode - finalModel.version = version } + fmt.Printf("%s• Creating installation scripts...\n", indent) if err := createPackageScripts(finalModel.outputDir, finalModel.packageName, finalModel.installerType); err != nil { - fmt.Printf("Error creating package scripts: %v\n", err) + fmt.Printf("%s - Error creating package scripts: %v\n", indent, err) return } + fmt.Printf("%s - Install.ps1: Silent installation script\n", indent) + fmt.Printf("%s - Uninstall.ps1: Clean removal script\n", indent) fmt.Printf("%s• Generating IntuneWin package...\n", indent) args := []string{ @@ -1124,35 +1172,38 @@ func run_interactive(cmd *cobra.Command, args []string) { } cmd := exec.Command(intuneUtilPath, args...) if output, err := cmd.CombinedOutput(); err != nil { - fmt.Printf("Error generating IntuneWin package: %v\n%s\n", err, output) + fmt.Printf("%s - Error generating IntuneWin package: %v\n%s\n", indent, err, output) return } + fmt.Printf("%s - IntuneWin package created successfully\n", indent) + + fmt.Printf("%s• Package creation complete\n", indent) + fmt.Printf("%s - IntuneWin file: %s\n", indent, intunewinFile) } fmt.Println("\n" + titleStyle.Render("Package Complete")) - fmt.Println("\n" + sectionStyle.Render("Package Summary")) + fmt.Println("\n" + sectionStyle.Render("Summary:")) fmt.Printf("%s• Name: %s\n", indent, finalModel.packageName) - fmt.Printf("%s• Type: %s\n", indent, finalModel.installerType) if finalModel.version != "" { fmt.Printf("%s• Version: %s\n", indent, finalModel.version) } + fmt.Printf("%s• Source: %s\n", indent, finalModel.textInput) if finalModel.installerType == "MSI" { fmt.Printf("%s• Product Code: %s\n", indent, finalModel.productCode) } - fmt.Println("\n" + sectionStyle.Render("Package Location")) - fmt.Printf("%s• Source: %s\n", indent, finalModel.textInput) - fmt.Printf("%s• Package Directory: %s\n", indent, finalModel.outputDir) + fmt.Println("\n" + sectionStyle.Render("Location:")) fmt.Printf("%s• Installer File: %s\n", indent, installerFile) fmt.Printf("%s• IntuneWin File: %s\n", indent, intunewinFile) + fmt.Printf("%s• Package Directory: %s\n", indent, finalModel.outputDir) - fmt.Println("\n" + sectionStyle.Render("Intune Configuration")) + fmt.Println("\n" + sectionStyle.Render("Intune Configuration:")) fmt.Printf("%s• Install Script: Install.ps1\n", indent) fmt.Printf("%s• Uninstall Script: Uninstall.ps1\n", indent) - fmt.Println("\n" + sectionStyle.Render("Detection Method")) + fmt.Println("\n" + sectionStyle.Render("Intune Detection Method:")) if finalModel.installerType == "MSI" { fmt.Printf("%s• MSI Product Code:\n", indent) fmt.Printf("%s - Property: ProductCode\n", indent) @@ -1167,18 +1218,23 @@ func run_interactive(cmd *cobra.Command, args []string) { fmt.Printf("%s• Use custom detection script or file existence\n", indent) } - fmt.Println("\n" + sectionStyle.Render("Additional Configuration")) - fmt.Printf("%s• To modify installation arguments:\n", indent) + fmt.Println("\n" + sectionStyle.Render("Customizing Installation:")) + fmt.Printf("%s• Installation Arguments:\n", indent) if finalModel.installerType == "MSI" { - fmt.Printf("%s - Default: /qn /norestart\n", indent) - fmt.Printf("%s - Edit Install.ps1 and modify $install_args\n", indent) + fmt.Printf("%s Current: /qn /norestart (silent install, no restart)\n", indent) } else { - fmt.Printf("%s - Default: /silent\n", indent) - fmt.Printf("%s - Edit Install.ps1 and modify $install_args\n", indent) + fmt.Printf("%s Current: /silent (silent install)\n", indent) } - fmt.Printf("%s• To add custom installation steps:\n", indent) - fmt.Printf("%s - Edit Install.ps1 in the package directory\n", indent) - fmt.Printf("%s - Add steps before or after the install_application function\n", indent) + fmt.Printf("%s To modify: Open Install.ps1 and update $install_args\n", indent) + + fmt.Printf("\n%s• Custom Installation Steps:\n", indent) + fmt.Printf("%s 1. Open Install.ps1 in the package directory\n", indent) + fmt.Printf("%s 2. Add your custom PowerShell code:\n", indent) + fmt.Printf("%s - Before install_application() for pre-install tasks\n", indent) + fmt.Printf("%s - After install_application() for post-install tasks\n", indent) + fmt.Printf("%s 3. After making changes, repackage the application using:\n", indent) + fmt.Printf("%s - Select 'Repackage Application' from main menu\n", indent) + fmt.Printf("%s - Choose the modified package to create new IntuneWin file\n", indent) fmt.Println() } } @@ -1195,7 +1251,9 @@ func ensureNexusDirs() error { func downloadFile(url, filepath string) error { dir := path.Dir(filepath) - fmt.Printf(" Creating directory: %s\n", dir) + indent := " " + + fmt.Printf("%s- Creating directory: %s\n", indent, dir) if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("failed to create directory %s: %v", dir, err) @@ -1205,7 +1263,7 @@ func downloadFile(url, filepath string) error { return fmt.Errorf("directory creation failed, path still doesn't exist: %s", dir) } - fmt.Printf(" Opening file for writing: %s\n", filepath) + fmt.Printf("%s- Opening file for writing: %s\n", indent, filepath) out, err := os.Create(filepath) if err != nil { return fmt.Errorf("failed to create file: %v", err) @@ -1223,7 +1281,7 @@ func downloadFile(url, filepath string) error { } size := resp.ContentLength - fmt.Printf(" Downloading %s (%d bytes)...\n", path.Base(filepath), size) + fmt.Printf("%s- Downloading %s (%d bytes)...\n", indent, path.Base(filepath), size) _, err = io.Copy(out, resp.Body) if err != nil { @@ -1269,18 +1327,42 @@ func get_existing_packages(packages_dir string) ([]string, error) { return nil, fmt.Errorf("failed to read packages directory: %v", err) } - var packages []string + type pkg_info struct { + name string + time time.Time + } + + var packages []pkg_info caser := cases.Title(language.English) + for _, entry := range entries { if entry.IsDir() { + info, err := entry.Info() + if err != nil { + continue + } + name := entry.Name() name = strings.ReplaceAll(name, "-", " ") name = caser.String(name) - packages = append(packages, name) + + packages = append(packages, pkg_info{ + name: name, + time: info.ModTime(), + }) } } - return packages, nil + sort.Slice(packages, func(i, j int) bool { + return packages[i].time.After(packages[j].time) + }) + + var result []string + for _, pkg := range packages { + result = append(result, pkg.name) + } + + return result, nil } func sanitize_package_name(name string) string { @@ -1329,3 +1411,54 @@ func save_config(packages_dir string) error { return os.WriteFile(config_path, data, 0644) } + +func get_recent_packages(packages_dir string, count int) ([]string, error) { + if _, err := os.Stat(packages_dir); os.IsNotExist(err) { + return nil, nil + } + + entries, err := os.ReadDir(packages_dir) + if err != nil { + return nil, fmt.Errorf("failed to read packages directory: %v", err) + } + + type pkg_info struct { + name string + time time.Time + } + + var packages []pkg_info + caser := cases.Title(language.English) + + for _, entry := range entries { + if entry.IsDir() { + info, err := entry.Info() + if err != nil { + continue + } + + name := entry.Name() + name = strings.ReplaceAll(name, "-", " ") + name = caser.String(name) + + packages = append(packages, pkg_info{ + name: name, + time: info.ModTime(), + }) + } + } + + sort.Slice(packages, func(i, j int) bool { + return packages[i].time.After(packages[j].time) + }) + + var recent []string + for i, pkg := range packages { + if i >= count { + break + } + recent = append(recent, pkg.name) + } + + return recent, nil +}