diff --git a/README.md b/README.md index 6fdc8df..43ed4de 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,15 @@ Major is a platform that lets you deploy and manage access to applications you b ## Installation -### Homebrew +### Direct Install ```bash -brew tap major-technology/tap -brew install major-technology/tap/major +curl -fsSL https://install.major.build | bash ``` -### Direct Install +### Homebrew ```bash -curl -fsSL https://install.major.build | bash +brew tap major-technology/tap +brew install major-technology/tap/major ``` ### Updating diff --git a/cmd/completion.go b/cmd/completion.go new file mode 100644 index 0000000..c265201 --- /dev/null +++ b/cmd/completion.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +var completionCmd = &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate completion script", + Long: `To load completions: + +Bash: + + $ source <(major completion bash) + + # To load completions for each session, execute once: + # Linux: + $ major completion bash > /etc/bash_completion.d/major + # macOS: + $ major completion bash > /usr/local/etc/bash_completion.d/major + +Zsh: + + # If shell completion is not already enabled in your environment, + # you will need to enable it. You can execute the following once: + + $ echo "autoload -U compinit; compinit" >> ~/.zshrc + + # To load completions for each session, execute once: + $ major completion zsh > "${fpath[1]}/_major" + + # You will need to start a new shell for this setup to take effect. + +Fish: + + $ major completion fish | source + + # To load completions for each session, execute once: + $ major completion fish > ~/.config/fish/completions/major.fish +`, + DisableFlagsInUseLine: true, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + Hidden: true, + Run: func(cmd *cobra.Command, args []string) { + switch args[0] { + case "bash": + cmd.Root().GenBashCompletion(os.Stdout) + case "zsh": + cmd.Root().GenZshCompletion(os.Stdout) + case "fish": + cmd.Root().GenFishCompletion(os.Stdout, true) + case "powershell": + cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) + } + }, +} + +func init() { + rootCmd.AddCommand(completionCmd) +} diff --git a/cmd/install.go b/cmd/install.go index eec0334..9dde682 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -30,6 +30,9 @@ func runInstall(cmd *cobra.Command) error { Bold(true). Foreground(lipgloss.Color("#00FF00")) + stepStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#87D7FF")) + // Get the path to the current executable exe, err := os.Executable() if err != nil { @@ -55,11 +58,6 @@ func runInstall(cmd *cobra.Command) error { } } - // If we are in a temp directory or not in the expected location, - // we might want to copy ourselves? - // The script does the downloading, so we assume we are already in ~/.major/bin/major - // We just need to ensure ~/.major/bin is in PATH - home, err := os.UserHomeDir() if err != nil { return fmt.Errorf("failed to get user home dir: %w", err) @@ -67,10 +65,12 @@ func runInstall(cmd *cobra.Command) error { shell := os.Getenv("SHELL") var configFile string + var shellType string switch { case strings.Contains(shell, "zsh"): configFile = filepath.Join(home, ".zshrc") + shellType = "zsh" case strings.Contains(shell, "bash"): configFile = filepath.Join(home, ".bashrc") // Check for .bash_profile on macOS @@ -79,6 +79,7 @@ func runInstall(cmd *cobra.Command) error { configFile = filepath.Join(home, ".bash_profile") } } + shellType = "bash" default: // Fallback or skip cmd.Println("Could not detect compatible shell (zsh/bash). Please add the following to your path manually:") @@ -86,11 +87,69 @@ func runInstall(cmd *cobra.Command) error { return nil } - // Check if already in config + // Create completions directory + completionsDir := filepath.Join(home, ".major", "completions") + if err := os.MkdirAll(completionsDir, 0755); err != nil { + return fmt.Errorf("failed to create completions directory: %w", err) + } + + // Generate completion script + cmd.Println(stepStyle.Render("▸ Generating shell completions...")) + + var completionEntry string + switch shellType { + case "zsh": + // For Zsh, we generate _major file and add directory to fpath + completionFile := filepath.Join(completionsDir, "_major") + f, err := os.Create(completionFile) + if err != nil { + return fmt.Errorf("failed to create zsh completion file: %w", err) + } + defer f.Close() + + if err := cmd.Root().GenZshCompletion(f); err != nil { + return fmt.Errorf("failed to generate zsh completion: %w", err) + } + + // We need to add fpath before compinit + // But often users already have compinit in their .zshrc + // The safest robust way is to append to fpath and ensure compinit is called + completionEntry = fmt.Sprintf(` +# Major CLI +export PATH="%s:$PATH" +export FPATH="%s:$FPATH" +# Ensure compinit is loaded (if not already) +autoload -U compinit && compinit +`, binDir, completionsDir) + + case "bash": + completionFile := filepath.Join(completionsDir, "major.bash") + f, err := os.Create(completionFile) + if err != nil { + return fmt.Errorf("failed to create bash completion file: %w", err) + } + defer f.Close() + + if err := cmd.Root().GenBashCompletion(f); err != nil { + return fmt.Errorf("failed to generate bash completion: %w", err) + } + + completionEntry = fmt.Sprintf(` +# Major CLI +export PATH="%s:$PATH" +source "%s" +`, binDir, completionFile) + } + + // Check if already configured content, err := os.ReadFile(configFile) if err == nil { - if strings.Contains(string(content), binDir) { - cmd.Println(successStyle.Render("Major CLI is already in your PATH!")) + // If we already see our marker or the bin path, we might want to update it or skip + // But the user might have moved the directory. + // Let's check for our specific comment + if strings.Contains(string(content), "# Major CLI") { + cmd.Println(successStyle.Render("Major CLI is already configured in your shell!")) + // We still re-generated the completion file above, which is good for updates. return nil } } @@ -102,13 +161,12 @@ func runInstall(cmd *cobra.Command) error { } defer f.Close() - pathEntry := fmt.Sprintf("\n# Major CLI\nexport PATH=\"%s:$PATH\"\n", binDir) - if _, err := f.WriteString(pathEntry); err != nil { + if _, err := f.WriteString(completionEntry); err != nil { return fmt.Errorf("failed to write to shell config file: %w", err) } - cmd.Println(successStyle.Render(fmt.Sprintf("Added Major CLI to %s", configFile))) - cmd.Println("Please restart your shell or source your config file to start using 'major'") + cmd.Println(successStyle.Render(fmt.Sprintf("Added Major CLI configuration to %s", configFile))) + cmd.Println("Please restart your shell or run 'source " + configFile + "' to start using 'major'") return nil } diff --git a/cmd/root.go b/cmd/root.go index 1cff0c8..c614777 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -74,7 +74,7 @@ func Execute() { func init() { cobra.OnInitialize(initConfig) - // Disable the completion command + // Disable the default completion command (we use our own) rootCmd.CompletionOptions.DisableDefaultCmd = true // Disable the help command (use -h flag instead) diff --git a/install.sh b/install.sh index db3c915..32f53fc 100755 --- a/install.sh +++ b/install.sh @@ -71,34 +71,82 @@ if [ -z "$LATEST_TAG" ]; then exit 1 fi -# Remove 'v' prefix for version number if your assets use strict numbering (major_1.0.0 vs major_v1.0.0) -# GoReleaser usually strips the 'v' in the version template variable {{ .Version }} +# Remove 'v' prefix for version number if your assets use strict numbering VERSION=${LATEST_TAG#v} # Construct the asset name ASSET_NAME="${BINARY}_${VERSION}_${OS}_${ARCH}.tar.gz" DOWNLOAD_URL="https://github.com/$OWNER/$REPO/releases/download/$LATEST_TAG/$ASSET_NAME" +CHECKSUMS_URL="https://github.com/$OWNER/$REPO/releases/download/$LATEST_TAG/checksums.txt" print_step "Downloading ${BINARY} ${LATEST_TAG}..." # Create a temporary directory TMP_DIR=$(mktemp -d) -curl -fsSL "$DOWNLOAD_URL" -o "$TMP_DIR/$ASSET_NAME" || { print_error "Failed to download from $DOWNLOAD_URL"; exit 1; } + +# Download Asset +if ! curl -fsSL "$DOWNLOAD_URL" -o "$TMP_DIR/$ASSET_NAME"; then + print_error "Failed to download binary from $DOWNLOAD_URL" + rm -rf "$TMP_DIR" + exit 1 +fi + +# Download Checksums +if ! curl -fsSL "$CHECKSUMS_URL" -o "$TMP_DIR/checksums.txt"; then + print_error "Failed to download checksums from $CHECKSUMS_URL" + rm -rf "$TMP_DIR" + exit 1 +fi + +# Verify Checksum +print_step "Verifying checksum..." +cd "$TMP_DIR" + +# Extract the checksum for our specific asset +EXPECTED_CHECKSUM=$(grep "$ASSET_NAME" checksums.txt | awk '{print $1}') + +if [ -z "$EXPECTED_CHECKSUM" ]; then + print_error "Could not find checksum for $ASSET_NAME in checksums.txt" + rm -rf "$TMP_DIR" + exit 1 +fi + +# Calculate actual checksum +if command -v sha256sum >/dev/null 2>&1; then + ACTUAL_CHECKSUM=$(sha256sum "$ASSET_NAME" | awk '{print $1}') +elif command -v shasum >/dev/null 2>&1; then + ACTUAL_CHECKSUM=$(shasum -a 256 "$ASSET_NAME" | awk '{print $1}') +else + print_error "Neither sha256sum nor shasum found to verify checksum" + rm -rf "$TMP_DIR" + exit 1 +fi + +if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then + print_error "Checksum verification failed!" + printf " Expected: %s\n" "$EXPECTED_CHECKSUM" + printf " Actual: %s\n" "$ACTUAL_CHECKSUM" + rm -rf "$TMP_DIR" + exit 1 +fi + +print_success "Checksum verified" # Extract and Install print_step "Installing to $INSTALL_DIR..." -tar -xzf "$TMP_DIR/$ASSET_NAME" -C "$TMP_DIR" +tar -xzf "$ASSET_NAME" # Create install directory mkdir -p "$INSTALL_DIR" # Move binary to install directory -mv "$TMP_DIR/$BINARY" "$INSTALL_DIR/$BINARY" +mv "$BINARY" "$INSTALL_DIR/$BINARY" # Make sure it's executable chmod +x "$INSTALL_DIR/$BINARY" # Cleanup +cd - >/dev/null rm -rf "$TMP_DIR" # Run the internal install command to setup shell integration