From a9cfdd44527c6a0212105574864478e2ccb22ad1 Mon Sep 17 00:00:00 2001 From: Mark Allen Date: Sat, 28 Mar 2026 21:16:56 +0000 Subject: [PATCH 1/2] feat: support multiple Slack/GitHub project integrations with TUI configuration Add multi-project support allowing multiple Slack workspaces to be paired with different GitHub repositories. Projects are managed via an interactive TUI (tty-prompt) and stored in an AES-256-GCM encrypted config file. Incoming requests are routed by Slack team_id with env var fallback for full backward compatibility. Closes #34 Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 12 +- Gemfile | 2 + Gemfile.lock | 27 +++++ README.md | 93 +++++++++++++- Rakefile | 6 + app.rb | 84 ++++++++----- bin/slack-gh-config | 8 ++ lib/cli/tui.rb | 179 +++++++++++++++++++++++++++ lib/config/encryption.rb | 66 ++++++++++ lib/config/project_config.rb | 97 +++++++++++++++ test/cli/test_tui.rb | 165 +++++++++++++++++++++++++ test/config/test_encryption.rb | 78 ++++++++++++ test/config/test_project_config.rb | 187 +++++++++++++++++++++++++++++ test/test_app.rb | 5 +- test/test_multi_project.rb | 124 +++++++++++++++++++ 15 files changed, 1093 insertions(+), 40 deletions(-) create mode 100755 bin/slack-gh-config create mode 100644 lib/cli/tui.rb create mode 100644 lib/config/encryption.rb create mode 100644 lib/config/project_config.rb create mode 100644 test/cli/test_tui.rb create mode 100644 test/config/test_encryption.rb create mode 100644 test/config/test_project_config.rb create mode 100644 test/test_multi_project.rb diff --git a/CLAUDE.md b/CLAUDE.md index f78e4ce..6e2ce9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ bundle exec rake ci # Full CI: syntax + rubocop + tests bundle exec rake rubocop # Lint only bundle exec rake lint # Syntax check + rubocop bundle exec rake server # Start dev server +bundle exec rake config # Manage multi-project integrations (TUI) DEBUG=true bundle exec ruby app.rb # Run with debug logging ``` @@ -27,19 +28,24 @@ To run a single test file: `bundle exec ruby test/services/test_text_processor.r ``` Slack (slash command / shortcut) → app.rb (Sinatra routing, 3 endpoints: GET /up, POST /ghcomment, POST /shortcut) + → resolve_tokens(team_id) → ProjectConfig lookup or ENV fallback → CommentService (orchestration) ├→ SlackService (fetch thread via conversations.replies, resolve user mentions) ├→ TextProcessor (format messages: HTML entity decoding, @mention replacement) └→ GitHubService (POST comment to issue via REST API) ``` -- **app.rb** — Entry point. Routes requests, validates params, delegates to CommentService. +- **app.rb** — Entry point. Routes requests, resolves credentials by team_id, delegates to CommentService. +- **lib/config/encryption.rb** — AES-256-GCM encryption/decryption with PBKDF2 key derivation (stdlib only). +- **lib/config/project_config.rb** — Multi-project config model: CRUD, encrypted file I/O, team_id lookup. +- **lib/cli/tui.rb** — Interactive TUI (tty-prompt) for managing project integrations. - **lib/services/comment_service.rb** — Orchestrates the flow: fetch thread → format → post to GitHub → reply in Slack. - **lib/services/slack_service.rb** — Slack API client (Net::HTTP). Auto-joins channels if bot isn't a member. - **lib/services/github_service.rb** — GitHub API client (Net::HTTP). Parses issue URLs to extract org/repo/number. - **lib/services/text_processor.rb** — Converts Slack message formatting to GitHub-compatible markdown. - **lib/helpers/modal_builder.rb** — Constructs Slack Block Kit modals for shortcut flows. - **lib/version_helper.rb** — Semantic versioning and changelog generation from conventional commits. +- **bin/slack-gh-config** — CLI entry point for the project configuration TUI. All API calls use Ruby's native `Net::HTTP` — no external HTTP client gems. @@ -52,8 +58,8 @@ All API calls use Ruby's native `Net::HTTP` — no external HTTP client gems. ## Environment Variables -Required: `SLACK_BOT_TOKEN` (xoxb_*), `GITHUB_TOKEN` (ghp_*) -Optional: `DEBUG` (true/false), `RACK_ENV`, `PORT` (default 3000) +Required (single-project mode): `SLACK_BOT_TOKEN` (xoxb-*), `GITHUB_TOKEN` (ghp_*) +Optional: `DEBUG` (true/false), `RACK_ENV`, `PORT` (default 3000), `CONFIG_PASSPHRASE` (for multi-project mode) ## Code Style diff --git a/Gemfile b/Gemfile index 16b0468..bee3411 100644 --- a/Gemfile +++ b/Gemfile @@ -6,6 +6,8 @@ gem 'dotenv' gem 'puma' gem 'sinatra' gem 'thin' +gem 'tty-prompt' +gem 'tty-table' group :development do gem 'rake' diff --git a/Gemfile.lock b/Gemfile.lock index eb52870..828e06b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -12,6 +12,7 @@ GEM daemons (1.4.1) dotenv (3.1.8) drb (2.2.3) + equatable (0.7.0) eventmachine (1.2.7) hashdiff (1.2.0) json (2.13.2) @@ -23,11 +24,14 @@ GEM prism (~> 1.5) mustermann (3.0.4) ruby2_keywords (~> 0.0.1) + necromancer (0.7.0) nio4r (2.7.4) parallel (1.27.0) parser (3.3.9.0) ast (~> 2.4.1) racc + pastel (0.8.0) + tty-color (~> 0.5) prism (1.9.0) public_suffix (6.0.2) puma (7.0.1) @@ -83,13 +87,34 @@ GEM logger rack (>= 1, < 4) tilt (2.7.0) + tty-color (0.6.0) + tty-cursor (0.7.1) + tty-prompt (0.23.1) + pastel (~> 0.8) + tty-reader (~> 0.8) + tty-reader (0.9.0) + tty-cursor (~> 0.7) + tty-screen (~> 0.8) + wisper (~> 2.0) + tty-screen (0.8.2) + tty-table (0.3.0) + equatable (~> 0.5) + necromancer (~> 0.3) + pastel (~> 0.4) + tty-screen (~> 0.2) + unicode_utils (~> 1.4.0) + verse (~> 0.4) unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.2.0) + unicode_utils (1.4.0) + verse (0.4.0) + unicode_utils (~> 1.4.0) webmock (3.25.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) + wisper (2.0.1) PLATFORMS arm64-darwin-24 @@ -106,6 +131,8 @@ DEPENDENCIES rubocop-rake sinatra thin + tty-prompt + tty-table webmock BUNDLED WITH diff --git a/README.md b/README.md index 6ec8565..e25963e 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Ready to get started? Here's the fastest way to set up slack-github-threads: - 🔒 **Secure**: Uses environment variables for sensitive tokens - 🎯 **Smart Formatting**: Preserves message structure, usernames, and timestamps - 📱 **Multiple Interfaces**: Slash commands, message shortcuts, and global shortcuts +- 🏢 **Multi-Project Support**: Connect multiple Slack workspaces to different GitHub repositories with encrypted configuration ### Why Use This Tool? @@ -101,6 +102,7 @@ Create a `.env` file with the following variables: SLACK_BOT_TOKEN=xoxb-your-slack-bot-token GITHUB_TOKEN=ghp_your-github-personal-access-token DEBUG=false # Optional: set to 'true' for debug logging +CONFIG_PASSPHRASE= # Optional: passphrase to decrypt multi-project config (see Multi-Project Setup) ``` ### Getting Tokens @@ -155,6 +157,80 @@ You have two options for setting up your Slack app: - `repo` (for private repositories) or `public_repo` (for public repositories only) 3. Copy the generated token +## Multi-Project Setup + +The app supports connecting multiple Slack workspaces to different GitHub repositories. Each workspace-repository pair is stored as a "project" in an encrypted configuration file. + +### How It Works + +When the app receives a request from Slack, it extracts the workspace's `team_id` from the payload and looks up the matching project configuration to find the correct Slack bot token and GitHub token. If no match is found, it falls back to the `SLACK_BOT_TOKEN` and `GITHUB_TOKEN` environment variables. + +This means existing single-project deployments require **no changes** -- they continue to work with just environment variables. + +### Managing Projects + +Use the interactive TUI (terminal user interface) to add, edit, remove, and list project integrations: + +```bash +bundle exec rake config +# or directly: +bundle exec ruby bin/slack-gh-config +``` + +The TUI will guide you through: + +1. **Setting a passphrase** (first run) or **entering your passphrase** (subsequent runs) +2. **Main menu** with options to: + - **Add project** -- provide a name, Slack team ID, Slack bot token, GitHub token, and optional default GitHub org + - **Edit project** -- update any field of an existing project + - **Remove project** -- delete a project configuration + - **List projects** -- display all configured projects in a table (tokens are masked) + +### Finding Your Slack Team ID + +Your Slack team ID (e.g., `T12345ABC`) can be found in: +- The URL when viewing your workspace in a browser: `https://app.slack.com/client/T12345ABC/...` +- Slack Admin Settings > About this workspace + +### Running the App in Multi-Project Mode + +Set the `CONFIG_PASSPHRASE` environment variable so the app can decrypt the configuration on startup: + +```bash +CONFIG_PASSPHRASE=your-passphrase bundle exec ruby app.rb +``` + +Or add it to your `.env` file: + +```env +CONFIG_PASSPHRASE=your-passphrase +``` + +### Encrypted Configuration + +The project configuration is stored at `.config/projects.enc`, which is: + +- **Encrypted at rest** using AES-256-GCM with a passphrase-derived key (PBKDF2, 100,000 iterations) +- **Git-ignored** by default (`.config/` is in `.gitignore`) +- **Authenticated** -- any tampering with the file is detected automatically + +### Docker and Kamal Deployments + +For containerized deployments, you need to: + +1. Mount or copy the `.config/projects.enc` file into the container +2. Set `CONFIG_PASSPHRASE` as an environment variable or secret + +Example with Docker: + +```bash +docker run -p 3000:3000 \ + -v $(pwd)/.config:/app/.config \ + -e CONFIG_PASSPHRASE=your-passphrase \ + --env-file .env \ + slack-github-threads +``` + ## Usage ### Local Development @@ -244,9 +320,16 @@ docker run -p 3000:3000 --env-file .env slack-github-threads ├── Gemfile # Ruby dependencies ├── Dockerfile # Docker configuration ├── Rakefile # Task definitions and test runner +├── bin/ +│ └── slack-gh-config # TUI for managing multi-project config ├── docs/ # Documentation and configuration │ └── app-manifest.json # Slack app manifest for easy setup ├── lib/ # Application modules +│ ├── cli/ +│ │ └── tui.rb # Interactive TUI for project management +│ ├── config/ +│ │ ├── encryption.rb # AES-256-GCM encryption utilities +│ │ └── project_config.rb # Multi-project configuration model │ ├── services/ # Business logic services │ │ ├── slack_service.rb # Slack API interactions │ │ ├── github_service.rb # GitHub API interactions @@ -257,10 +340,12 @@ docker run -p 3000:3000 --env-file .env slack-github-threads ├── test/ # Test suite │ ├── test_helper.rb # Test configuration and helpers │ ├── test_app.rb # Integration tests +│ ├── test_multi_project.rb # Multi-project routing tests +│ ├── cli/ # CLI/TUI tests +│ ├── config/ # Encryption and config tests │ └── services/ # Service unit tests -│ ├── test_slack_service.rb -│ ├── test_github_service.rb -│ └── test_text_processor.rb +├── .config/ # Encrypted project config (git-ignored) +│ └── projects.enc # AES-256-GCM encrypted YAML ├── config/ │ └── deploy.yml.example # Kamal deployment configuration template └── .kamal/ @@ -419,6 +504,8 @@ See [docs/CONVENTIONAL_COMMITS.md](docs/CONVENTIONAL_COMMITS.md) for detailed co - Use environment variables for all sensitive data - Regularly rotate your API tokens - Use HTTPS in production +- Multi-project config is encrypted at rest with AES-256-GCM; the `.config/` directory is git-ignored +- Choose a strong passphrase for your config encryption; store `CONFIG_PASSPHRASE` securely in your deployment secrets ## License diff --git a/Rakefile b/Rakefile index b5a42ee..f0e6db5 100644 --- a/Rakefile +++ b/Rakefile @@ -81,6 +81,12 @@ task :server do system('bundle exec ruby app.rb') end +# Configure multi-project integrations +desc 'Manage multi-project Slack/GitHub integrations' +task :config do + system('bundle exec ruby bin/slack-gh-config') +end + # Check syntax of all Ruby files desc 'Check syntax of all Ruby files' task :syntax do diff --git a/app.rb b/app.rb index 5b41bda..1edae25 100644 --- a/app.rb +++ b/app.rb @@ -10,6 +10,7 @@ require_relative 'lib/services/comment_service' require_relative 'lib/services/text_processor' require_relative 'lib/helpers/modal_builder' +require_relative 'lib/config/project_config' # Configuration configure do @@ -45,6 +46,19 @@ puts "Log file: #{log_file}" end + # Load multi-project config if available + config_path = File.join(settings.root, '.config', 'projects.enc') + config_passphrase = ENV.fetch('CONFIG_PASSPHRASE', nil) + + if File.exist?(config_path) && config_passphrase + project_config = Config::ProjectConfig.new(config_path: config_path) + project_config.load!(config_passphrase) + set :project_config, project_config + settings.logger.info "Loaded #{project_config.projects.length} project(s) from encrypted config" + else + set :project_config, nil + end + # Log startup settings.logger.info "Starting slack-github-threads app (#{settings.environment})" end @@ -70,23 +84,29 @@ def error_log(message) logger.error(message) end - def comment_service - @comment_service ||= CommentService.new( - ENV.fetch('SLACK_BOT_TOKEN', nil), - ENV.fetch('GITHUB_TOKEN', nil), - debug: settings.debug_mode, - logger: logger - ) + def resolve_tokens(team_id) + if settings.project_config && team_id + project = settings.project_config.find_by_team_id(team_id) + return [project[:slack_bot_token], project[:github_token]] if project + end + + [ENV.fetch('SLACK_BOT_TOKEN', nil), ENV.fetch('GITHUB_TOKEN', nil)] end - def validate_environment! + def comment_service_for(team_id) + slack_token, github_token = resolve_tokens(team_id) + CommentService.new(slack_token, github_token, debug: settings.debug_mode, logger: logger) + end + + def validate_tokens!(team_id) + slack_token, github_token = resolve_tokens(team_id) missing = [] - missing << 'SLACK_BOT_TOKEN' unless ENV['SLACK_BOT_TOKEN'] - missing << 'GITHUB_TOKEN' unless ENV['GITHUB_TOKEN'] + missing << 'Slack bot token' unless slack_token + missing << 'GitHub token' unless github_token return if missing.empty? - halt 500, "Missing environment variables: #{missing.join(', ')}" + halt 500, "Missing credentials: #{missing.join(', ')}" end end @@ -98,7 +118,8 @@ def validate_environment! # Slack slash command endpoint post '/ghcomment' do - validate_environment! + team_id = params['team_id'] + validate_tokens!(team_id) issue_url = params['text']&.strip channel_id = params['channel_id'] @@ -108,7 +129,7 @@ def validate_environment! halt 400, 'Missing issue URL.' unless issue_url && !issue_url.empty? begin - comment_url = comment_service.post_thread_to_github(channel_id, thread_ts, issue_url) + comment_url = comment_service_for(team_id).post_thread_to_github(channel_id, thread_ts, issue_url) info_log "Successfully posted comment to GitHub: #{comment_url}" status 200 "✅ Posted to GitHub: #{comment_url}" @@ -121,36 +142,37 @@ def validate_environment! # Slack shortcuts and modal submissions post '/shortcut' do - validate_environment! - request.body.rewind raw_payload = request.body.read parsed = URI.decode_www_form(raw_payload).to_h payload = JSON.parse(parsed['payload']) + team_id = payload.dig('team', 'id') || payload.dig('user', 'team_id') + validate_tokens!(team_id) + case payload['type'] when 'shortcut' - handle_global_shortcut(payload) + handle_global_shortcut(payload, team_id) when 'message_action' - handle_message_shortcut(payload) + handle_message_shortcut(payload, team_id) when 'view_submission' - handle_modal_submission(payload) + handle_modal_submission(payload, team_id) else halt 400, "Unsupported payload type: #{payload['type']}" end end -def handle_global_shortcut(payload) +def handle_global_shortcut(payload, team_id) trigger_id = payload['trigger_id'] debug_log 'DEBUG: Global shortcut triggered' - result = comment_service.open_global_shortcut_modal(trigger_id) + result = comment_service_for(team_id).open_global_shortcut_modal(trigger_id) status result[:status_code] body result[:body] end -def handle_message_shortcut(payload) +def handle_message_shortcut(payload, team_id) trigger_id = payload['trigger_id'] channel_id = payload.dig('channel', 'id') message_ts = payload.dig('message', 'ts') @@ -158,7 +180,7 @@ def handle_message_shortcut(payload) debug_log "DEBUG: Message shortcut - Channel: #{channel_id}, Thread: #{thread_ts}" - result = comment_service.open_message_shortcut_modal(trigger_id, channel_id, thread_ts) + result = comment_service_for(team_id).open_message_shortcut_modal(trigger_id, channel_id, thread_ts) # For message shortcuts, return empty response if modal opened successfully if result[:success] @@ -170,20 +192,20 @@ def handle_message_shortcut(payload) end end -def handle_modal_submission(payload) +def handle_modal_submission(payload, team_id) callback_id = payload.dig('view', 'callback_id') case callback_id when 'gh_comment_modal_global' - handle_global_modal_submission(payload) + handle_global_modal_submission(payload, team_id) when 'gh_comment_modal_message' - handle_message_modal_submission(payload) + handle_message_modal_submission(payload, team_id) else halt 400, "Unknown callback ID: #{callback_id}" end end -def handle_global_modal_submission(payload) +def handle_global_modal_submission(payload, team_id) thread_url = payload.dig('view', 'state', 'values', 'thread_block', 'thread_url', 'value') issue_url = payload.dig('view', 'state', 'values', 'issue_block', 'issue_url', 'value') @@ -198,10 +220,10 @@ def handle_global_modal_submission(payload) }) end - process_modal_submission(thread_info[:channel_id], thread_info[:thread_ts], issue_url) + process_modal_submission(thread_info[:channel_id], thread_info[:thread_ts], issue_url, team_id) end -def handle_message_modal_submission(payload) +def handle_message_modal_submission(payload, team_id) metadata = JSON.parse(payload.dig('view', 'private_metadata')) channel_id = metadata['channel_id'] thread_ts = metadata['thread_ts'] @@ -209,10 +231,10 @@ def handle_message_modal_submission(payload) debug_log "DEBUG: Message shortcut submission - Channel: #{channel_id}, Thread: #{thread_ts}, Issue: #{issue_url}" - process_modal_submission(channel_id, thread_ts, issue_url) + process_modal_submission(channel_id, thread_ts, issue_url, team_id) end -def process_modal_submission(channel_id, thread_ts, issue_url) +def process_modal_submission(channel_id, thread_ts, issue_url, team_id) # Validate GitHub issue URL unless GitHubService.parse_issue_url(issue_url) status 200 @@ -222,7 +244,7 @@ def process_modal_submission(channel_id, thread_ts, issue_url) end begin - comment_service.post_thread_to_github(channel_id, thread_ts, issue_url) + comment_service_for(team_id).post_thread_to_github(channel_id, thread_ts, issue_url) info_log "Successfully posted comment via modal to GitHub issue: #{issue_url}" status 200 body '' # Required response for modals diff --git a/bin/slack-gh-config b/bin/slack-gh-config new file mode 100755 index 0000000..929dcee --- /dev/null +++ b/bin/slack-gh-config @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative '../lib/cli/tui' + +config_path = File.join(__dir__, '..', '.config', 'projects.enc') +tui = CLI::Tui.new(config_path: config_path) +tui.run diff --git a/lib/cli/tui.rb b/lib/cli/tui.rb new file mode 100644 index 0000000..f1c3450 --- /dev/null +++ b/lib/cli/tui.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +require 'tty-prompt' +require 'tty-table' +require 'tty-screen' +require_relative '../config/project_config' + +module CLI + class Tui + def initialize(config_path: '.config/projects.enc', prompt: TTY::Prompt.new) + @config_path = config_path + @prompt = prompt + @config = Config::ProjectConfig.new(config_path: config_path) + @passphrase = nil + end + + def run + @passphrase = prompt_passphrase + @config.load!(@passphrase) + main_menu_loop + rescue Config::Encryption::DecryptionError + @prompt.error('Wrong passphrase. Unable to decrypt config file.') + exit 1 + end + + private + + def prompt_passphrase + if File.exist?(@config_path) + @prompt.mask('Enter config passphrase:') + else + @prompt.ok('No config file found. Creating a new one.') + passphrase = @prompt.mask('Choose a passphrase for encrypting your config:') + confirm = @prompt.mask('Confirm passphrase:') + unless passphrase == confirm + @prompt.error('Passphrases do not match.') + exit 1 + end + passphrase + end + end + + def main_menu_loop + loop do + choices = build_menu_choices + action = @prompt.select('What would you like to do?', choices) + break if action == :exit + + send(action) + end + end + + def build_menu_choices + choices = [] + choices << { name: 'Add project', value: :add_project } + unless @config.empty? + choices << { name: 'Edit project', value: :edit_project } + choices << { name: 'Remove project', value: :remove_project } + choices << { name: 'List projects', value: :list_projects } + end + choices << { name: 'Exit', value: :exit } + choices + end + + def add_project + name = @prompt.ask('Project name:', required: true) + if @config.find_by_name(name) + @prompt.error("Project '#{name}' already exists.") + return + end + + slack_team_id = @prompt.ask('Slack team ID (e.g., T12345ABC):', required: true) + slack_bot_token = @prompt.mask('Slack bot token (xoxb-...):', required: true) + github_token = @prompt.mask('GitHub token (ghp_... or github_pat_...):', required: true) + default_github_org = @prompt.ask('Default GitHub org (optional):') + + warn_token_format(slack_bot_token, github_token) + + @config.add_project( + name: name, + slack_team_id: slack_team_id, + slack_bot_token: slack_bot_token, + github_token: github_token, + default_github_org: default_github_org.to_s.empty? ? nil : default_github_org + ) + @config.save!(@passphrase) + @prompt.ok("Project '#{name}' added successfully.") + end + + def edit_project + name = select_project('Select project to edit:') + return unless name + + project = @config.find_by_name(name) + updates = {} + + updates[:name] = @prompt.ask('Project name:', default: project[:name]) + updates[:slack_team_id] = @prompt.ask('Slack team ID:', default: project[:slack_team_id]) + updates[:slack_bot_token] = prompt_optional_mask('Slack bot token:', project[:slack_bot_token]) + updates[:github_token] = prompt_optional_mask('GitHub token:', project[:github_token]) + updates[:default_github_org] = @prompt.ask('Default GitHub org:', default: project[:default_github_org]) + + updates[:default_github_org] = nil if updates[:default_github_org].to_s.empty? + + warn_token_format(updates[:slack_bot_token], updates[:github_token]) + + @config.update_project(name, updates) + @config.save!(@passphrase) + @prompt.ok("Project '#{name}' updated successfully.") + end + + def remove_project + name = select_project('Select project to remove:') + return unless name + + return unless @prompt.yes?("Remove project '#{name}'? This cannot be undone.") + + @config.remove_project(name) + @config.save!(@passphrase) + @prompt.ok("Project '#{name}' removed.") + end + + def list_projects + if @config.empty? + @prompt.warn('No projects configured.') + return + end + + header = ['Name', 'Team ID', 'Slack Token', 'GitHub Token', 'Default Org'] + rows = @config.projects.map do |p| + [ + p[:name], + p[:slack_team_id], + mask_token(p[:slack_bot_token]), + mask_token(p[:github_token]), + p[:default_github_org] || '-', + ] + end + + table = TTY::Table.new(header: header, rows: rows) + puts table.render(:unicode, padding: [0, 1], width: terminal_width) + end + + def select_project(message) + names = @config.projects.map { |p| p[:name] } + @prompt.select(message, names) + end + + def prompt_optional_mask(label, current_value) + display = mask_token(current_value) + if @prompt.yes?("#{label} (current: #{display}) Change it?") + @prompt.mask("New #{label.downcase}", required: true) + else + current_value + end + end + + def mask_token(token) + return '-' unless token + + "#{token[0, 8]}..." + end + + def terminal_width + TTY::Screen.width + rescue NoMethodError + 120 + end + + def warn_token_format(slack_token, github_token) + unless slack_token.start_with?('xoxb-') + @prompt.warn("Slack token doesn't start with 'xoxb-' — are you sure it's correct?") + end + return if github_token.start_with?('ghp_') || github_token.start_with?('github_pat_') + + @prompt.warn("GitHub token doesn't start with 'ghp_' or 'github_pat_' — are you sure it's correct?") + end + end +end diff --git a/lib/config/encryption.rb b/lib/config/encryption.rb new file mode 100644 index 0000000..b896f13 --- /dev/null +++ b/lib/config/encryption.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'openssl' +require 'securerandom' +require 'base64' + +module Config + module Encryption + SALT_LENGTH = 32 + IV_LENGTH = 12 + AUTH_TAG_LENGTH = 16 + KEY_LENGTH = 32 + ITERATIONS = 100_000 + CIPHER = 'aes-256-gcm' + + class DecryptionError < StandardError; end + + def self.encrypt(plaintext, passphrase) + salt = SecureRandom.random_bytes(SALT_LENGTH) + key = derive_key(passphrase, salt) + + cipher = OpenSSL::Cipher.new(CIPHER) + cipher.encrypt + cipher.key = key + iv = cipher.random_iv + + ciphertext = cipher.update(plaintext) + cipher.final + auth_tag = cipher.auth_tag + + blob = salt + iv + auth_tag + ciphertext + Base64.strict_encode64(blob) + end + + def self.decrypt(encoded_blob, passphrase) + blob = Base64.strict_decode64(encoded_blob) + + salt = blob[0, SALT_LENGTH] + iv = blob[SALT_LENGTH, IV_LENGTH] + auth_tag = blob[SALT_LENGTH + IV_LENGTH, AUTH_TAG_LENGTH] + ciphertext = blob[(SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH)..] + + key = derive_key(passphrase, salt) + + cipher = OpenSSL::Cipher.new(CIPHER) + cipher.decrypt + cipher.key = key + cipher.iv = iv + cipher.auth_tag = auth_tag + + cipher.update(ciphertext) + cipher.final + rescue OpenSSL::Cipher::CipherError + raise DecryptionError, 'Decryption failed — wrong passphrase or corrupted data' + end + + def self.derive_key(passphrase, salt) + OpenSSL::KDF.pbkdf2_hmac( + passphrase, + salt: salt, + iterations: ITERATIONS, + length: KEY_LENGTH, + hash: 'SHA256' + ) + end + private_class_method :derive_key + end +end diff --git a/lib/config/project_config.rb b/lib/config/project_config.rb new file mode 100644 index 0000000..08bde63 --- /dev/null +++ b/lib/config/project_config.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'yaml' +require 'fileutils' +require_relative 'encryption' + +module Config + class ProjectConfig + attr_reader :projects + + def initialize(config_path: '.config/projects.enc') + @config_path = config_path + @projects = [] + end + + def load!(passphrase) + unless File.exist?(@config_path) + @projects = [] + return self + end + + encoded_blob = File.read(@config_path) + yaml = Config::Encryption.decrypt(encoded_blob, passphrase) + data = YAML.safe_load(yaml, permitted_classes: [Symbol]) || {} + @projects = (data['projects'] || []).map { |p| normalize_project(p) } + self + end + + def save!(passphrase) + dir = File.dirname(@config_path) + FileUtils.mkdir_p(dir) + + data = { 'projects' => @projects.map { |p| stringify_project(p) } } + yaml = YAML.dump(data) + encoded_blob = Config::Encryption.encrypt(yaml, passphrase) + File.write(@config_path, encoded_blob) + self + end + + def find_by_team_id(team_id) + @projects.find { |p| p[:slack_team_id] == team_id } + end + + def add_project(attrs) + project = normalize_project(attrs) + raise ArgumentError, 'Project name is required' if project[:name].to_s.strip.empty? + raise ArgumentError, 'Slack team ID is required' if project[:slack_team_id].to_s.strip.empty? + raise ArgumentError, "Project '#{project[:name]}' already exists" if find_by_name(project[:name]) + + @projects << project + project + end + + def update_project(name, attrs) + project = find_by_name(name) + raise ArgumentError, "Project '#{name}' not found" unless project + + attrs.each do |key, value| + sym_key = key.to_sym + project[sym_key] = value if project.key?(sym_key) + end + project + end + + def remove_project(name) + project = find_by_name(name) + raise ArgumentError, "Project '#{name}' not found" unless project + + @projects.delete(project) + project + end + + def find_by_name(name) + @projects.find { |p| p[:name] == name } + end + + def empty? + @projects.empty? + end + + private + + def normalize_project(attrs) + { + name: attrs[:name] || attrs['name'], + slack_team_id: attrs[:slack_team_id] || attrs['slack_team_id'], + slack_bot_token: attrs[:slack_bot_token] || attrs['slack_bot_token'], + github_token: attrs[:github_token] || attrs['github_token'], + default_github_org: attrs[:default_github_org] || attrs['default_github_org'], + } + end + + def stringify_project(project) + project.transform_keys(&:to_s) + end + end +end diff --git a/test/cli/test_tui.rb b/test/cli/test_tui.rb new file mode 100644 index 0000000..f8d5ccb --- /dev/null +++ b/test/cli/test_tui.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require_relative '../test_helper' +require_relative '../../lib/cli/tui' +require 'tmpdir' + +class TestTui < Minitest::Spec + let(:tmpdir) { Dir.mktmpdir } + let(:config_path) { File.join(tmpdir, 'projects.enc') } + let(:passphrase) { 'test-passphrase' } + + after do + FileUtils.rm_rf(tmpdir) + end + + def build_tui(prompt) + CLI::Tui.new(config_path: config_path, prompt: prompt) + end + + describe 'add_project flow' do + it 'adds a project and saves to encrypted file' do + prompt = MockPrompt.new + prompt.queue_mask(passphrase) # choose passphrase + prompt.queue_mask(passphrase) # confirm passphrase + + # Main menu: Add project, then Exit + prompt.queue_select(:add_project) + prompt.queue_ask('My Company') # name + prompt.queue_ask('T12345') # team_id + prompt.queue_mask('xoxb-test-token') # slack token + prompt.queue_mask('ghp_testtoken') # github token + prompt.queue_ask('mycompany') # default org + + prompt.queue_select(:exit) # exit + + tui = build_tui(prompt) + tui.run + + # Verify the config was saved + config = Config::ProjectConfig.new(config_path: config_path) + config.load!(passphrase) + + assert_equal 1, config.projects.length + assert_equal 'My Company', config.projects.first[:name] + assert_equal 'T12345', config.projects.first[:slack_team_id] + end + end + + describe 'remove_project flow' do + it 'removes a project' do + # Pre-populate config + config = Config::ProjectConfig.new(config_path: config_path) + config.add_project( + name: 'To Remove', + slack_team_id: 'T99999', + slack_bot_token: 'xoxb-remove', + github_token: 'ghp_remove' + ) + config.save!(passphrase) + + prompt = MockPrompt.new + prompt.queue_mask(passphrase) # enter passphrase + + prompt.queue_select(:remove_project) # main menu + prompt.queue_select('To Remove') # select project + prompt.queue_yes(true) # confirm removal + prompt.queue_select(:exit) # exit + + tui = build_tui(prompt) + tui.run + + # Verify removal + reloaded = Config::ProjectConfig.new(config_path: config_path) + reloaded.load!(passphrase) + + assert_empty reloaded + end + end + + describe 'list_projects flow' do + it 'lists projects without error' do + config = Config::ProjectConfig.new(config_path: config_path) + config.add_project( + name: 'Listed Project', + slack_team_id: 'T11111', + slack_bot_token: 'xoxb-listed', + github_token: 'ghp_listed', + default_github_org: 'listedorg' + ) + config.save!(passphrase) + + prompt = MockPrompt.new + prompt.queue_mask(passphrase) + + prompt.queue_select(:list_projects) + prompt.queue_select(:exit) + + tui = build_tui(prompt) + output = capture_io { tui.run } + + assert_includes output.first, 'Listed Project' + end + end +end + +# Minimal mock for TTY::Prompt that replays queued responses +class MockPrompt + def initialize + @mask_queue = [] + @ask_queue = [] + @select_queue = [] + @yes_queue = [] + @messages = [] + end + + def queue_mask(value) + @mask_queue << value + end + + def queue_ask(value) + @ask_queue << value + end + + def queue_select(value) + @select_queue << value + end + + def queue_yes(value) + @yes_queue << value + end + + def mask(_message, **_opts) + @mask_queue.shift + end + + def ask(_message, **_opts) + @ask_queue.shift + end + + def select(_message, choices, **_opts) + selected_value = @select_queue.shift + # If choices are hashes with :value keys, find the matching one + if choices.is_a?(Array) && choices.first.is_a?(Hash) + match = choices.find { |c| c[:value] == selected_value } + return match[:value] if match + end + selected_value + end + + def yes?(_message, **_opts) + @yes_queue.shift + end + + def ok(message) + @messages << [:ok, message] + end + + def warn(message) + @messages << [:warn, message] + end + + def error(message) + @messages << [:error, message] + end +end diff --git a/test/config/test_encryption.rb b/test/config/test_encryption.rb new file mode 100644 index 0000000..1bff2cf --- /dev/null +++ b/test/config/test_encryption.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require_relative '../test_helper' +require_relative '../../lib/config/encryption' + +class TestEncryption < Minitest::Spec + describe Config::Encryption do + let(:passphrase) { 'test-passphrase-123' } + let(:plaintext) { 'Hello, encrypted world!' } + + describe '.encrypt and .decrypt' do + it 'round-trips plaintext correctly' do + encrypted = Config::Encryption.encrypt(plaintext, passphrase) + decrypted = Config::Encryption.decrypt(encrypted, passphrase) + + assert_equal plaintext, decrypted + end + + it 'handles multi-line YAML content' do + yaml_content = <<~YAML + projects: + - name: Test Project + slack_team_id: T12345 + slack_bot_token: xoxb-test-token + github_token: ghp_testtoken123 + YAML + + encrypted = Config::Encryption.encrypt(yaml_content, passphrase) + decrypted = Config::Encryption.decrypt(encrypted, passphrase) + + assert_equal yaml_content, decrypted + end + + it 'produces different ciphertext each time due to random salt' do + encrypted1 = Config::Encryption.encrypt(plaintext, passphrase) + encrypted2 = Config::Encryption.encrypt(plaintext, passphrase) + + refute_equal encrypted1, encrypted2 + end + + it 'produces valid Base64 output' do + encrypted = Config::Encryption.encrypt(plaintext, passphrase) + + assert_match(%r{\A[A-Za-z0-9+/]+=*\z}, encrypted) + end + + it 'handles empty string' do + encrypted = Config::Encryption.encrypt('', passphrase) + decrypted = Config::Encryption.decrypt(encrypted, passphrase) + + assert_equal '', decrypted + end + end + + describe 'wrong passphrase' do + it 'raises DecryptionError' do + encrypted = Config::Encryption.encrypt(plaintext, passphrase) + assert_raises(Config::Encryption::DecryptionError) do + Config::Encryption.decrypt(encrypted, 'wrong-passphrase') + end + end + end + + describe 'corrupted data' do + it 'raises DecryptionError for tampered ciphertext' do + encrypted = Config::Encryption.encrypt(plaintext, passphrase) + # Tamper with the encoded blob + tampered = encrypted.reverse + assert_raises(Config::Encryption::DecryptionError) do + Config::Encryption.decrypt(tampered, passphrase) + rescue ArgumentError + # Base64 decode failure is also acceptable + raise Config::Encryption::DecryptionError, 'corrupted' + end + end + end + end +end diff --git a/test/config/test_project_config.rb b/test/config/test_project_config.rb new file mode 100644 index 0000000..19c7b45 --- /dev/null +++ b/test/config/test_project_config.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require_relative '../test_helper' +require_relative '../../lib/config/project_config' +require 'tmpdir' + +class TestProjectConfig < Minitest::Spec + let(:passphrase) { 'test-passphrase' } + let(:tmpdir) { Dir.mktmpdir } + let(:config_path) { File.join(tmpdir, 'projects.enc') } + let(:config) { Config::ProjectConfig.new(config_path: config_path) } + + let(:sample_project) do + { + name: 'My Company', + slack_team_id: 'T12345', + slack_bot_token: 'xoxb-test-token', + github_token: 'ghp_testtoken123', + default_github_org: 'mycompany', + } + end + + after do + FileUtils.rm_rf(tmpdir) + end + + describe '#load!' do + it 'returns empty projects when file does not exist' do + config.load!(passphrase) + + assert_empty config + assert_empty config.projects + end + + it 'loads projects from encrypted file' do + config.add_project(sample_project) + config.save!(passphrase) + + loaded = Config::ProjectConfig.new(config_path: config_path) + loaded.load!(passphrase) + + refute_empty loaded + assert_equal 1, loaded.projects.length + assert_equal 'My Company', loaded.projects.first[:name] + end + + it 'raises DecryptionError with wrong passphrase' do + config.add_project(sample_project) + config.save!(passphrase) + + loaded = Config::ProjectConfig.new(config_path: config_path) + assert_raises(Config::Encryption::DecryptionError) do + loaded.load!('wrong-passphrase') + end + end + end + + describe '#save!' do + it 'creates the config directory if it does not exist' do + nested_path = File.join(tmpdir, 'nested', 'dir', 'projects.enc') + nested_config = Config::ProjectConfig.new(config_path: nested_path) + nested_config.add_project(sample_project) + nested_config.save!(passphrase) + + assert_path_exists nested_path + end + + it 'writes an encrypted file that is not readable as plain YAML' do + config.add_project(sample_project) + config.save!(passphrase) + + raw = File.read(config_path) + parsed = YAML.safe_load(raw) rescue nil # rubocop:disable Style/RescueModifier + + refute_kind_of Hash, parsed + end + end + + describe '#add_project' do + it 'adds a project to the list' do + config.add_project(sample_project) + + assert_equal 1, config.projects.length + assert_equal 'My Company', config.projects.first[:name] + end + + it 'raises ArgumentError for missing name' do + assert_raises(ArgumentError) do + config.add_project(sample_project.merge(name: '')) + end + end + + it 'raises ArgumentError for missing team ID' do + assert_raises(ArgumentError) do + config.add_project(sample_project.merge(slack_team_id: '')) + end + end + + it 'raises ArgumentError for duplicate name' do + config.add_project(sample_project) + assert_raises(ArgumentError) do + config.add_project(sample_project) + end + end + + it 'normalizes string keys to symbols' do + string_attrs = { + 'name' => 'String Project', + 'slack_team_id' => 'T99999', + 'slack_bot_token' => 'xoxb-string', + 'github_token' => 'ghp_string', + 'default_github_org' => 'stringorg', + } + config.add_project(string_attrs) + project = config.projects.first + + assert_equal 'String Project', project[:name] + assert_equal 'T99999', project[:slack_team_id] + end + end + + describe '#update_project' do + it 'updates project attributes' do + config.add_project(sample_project) + config.update_project('My Company', github_token: 'ghp_newtoken') + + assert_equal 'ghp_newtoken', config.projects.first[:github_token] + end + + it 'raises ArgumentError for unknown project' do + assert_raises(ArgumentError) do + config.update_project('Nonexistent', github_token: 'ghp_new') + end + end + end + + describe '#remove_project' do + it 'removes a project by name' do + config.add_project(sample_project) + config.remove_project('My Company') + + assert_empty config + end + + it 'raises ArgumentError for unknown project' do + assert_raises(ArgumentError) do + config.remove_project('Nonexistent') + end + end + end + + describe '#find_by_team_id' do + it 'returns the matching project' do + config.add_project(sample_project) + project = config.find_by_team_id('T12345') + + assert_equal 'My Company', project[:name] + end + + it 'returns nil for unknown team ID' do + config.add_project(sample_project) + + assert_nil config.find_by_team_id('T99999') + end + end + + describe 'round-trip with multiple projects' do + it 'persists and reloads multiple projects' do + config.add_project(sample_project) + config.add_project( + name: 'Side Project', + slack_team_id: 'T67890', + slack_bot_token: 'xoxb-side-token', + github_token: 'ghp_sidetoken', + default_github_org: nil + ) + config.save!(passphrase) + + loaded = Config::ProjectConfig.new(config_path: config_path) + loaded.load!(passphrase) + + assert_equal 2, loaded.projects.length + assert_equal 'Side Project', loaded.find_by_team_id('T67890')[:name] + assert_nil loaded.find_by_team_id('T67890')[:default_github_org] + end + end +end diff --git a/test/test_app.rb b/test/test_app.rb index a26a7d4..80e5204 100644 --- a/test/test_app.rb +++ b/test/test_app.rb @@ -197,9 +197,8 @@ def test_missing_environment_variables } assert_equal 500, last_response.status - assert_includes last_response.body, 'Missing environment variables' - - # Restore environment variables + assert_includes last_response.body, 'Missing credentials' + ensure ENV['SLACK_BOT_TOKEN'] = old_slack_token ENV['GITHUB_TOKEN'] = old_github_token end diff --git a/test/test_multi_project.rb b/test/test_multi_project.rb new file mode 100644 index 0000000..fc58acd --- /dev/null +++ b/test/test_multi_project.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require_relative 'test_helper' +require_relative '../lib/config/project_config' +require 'tmpdir' + +class MultiProjectTest < Minitest::Test + def setup + super + @tmpdir = Dir.mktmpdir + @config_path = File.join(@tmpdir, 'projects.enc') + @passphrase = 'test-passphrase' + + # Create a project config with a test project + config = Config::ProjectConfig.new(config_path: @config_path) + config.add_project( + name: 'Test Workspace', + slack_team_id: 'T_MULTI', + slack_bot_token: 'xoxb-multi-project-token', + github_token: 'ghp_multi_project_token', + default_github_org: 'testorg' + ) + config.save!(@passphrase) + + # Load config into the app + loaded = Config::ProjectConfig.new(config_path: @config_path) + loaded.load!(@passphrase) + Sinatra::Application.set :project_config, loaded + end + + def teardown + # Restore to no project config + Sinatra::Application.set :project_config, nil + FileUtils.rm_rf(@tmpdir) + end + + def test_ghcomment_uses_project_tokens_for_matching_team_id + # Stub using multi-project tokens (the Authorization header will contain the project's token) + slack_stub = stub_request(:get, 'https://slack.com/api/conversations.replies') + .with( + query: hash_including(channel: 'C123'), + headers: { 'Authorization' => 'Bearer xoxb-multi-project-token' } + ) + .to_return( + status: 200, + body: slack_thread_response([slack_message]).to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + stub_slack_users_info + stub_slack_chat_post_message + + github_stub = stub_request(:post, 'https://api.github.com/repos/owner/repo/issues/1/comments') + .with(headers: { 'Authorization' => 'token ghp_multi_project_token' }) + .to_return( + status: 201, + body: github_comment_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + post '/ghcomment', { + text: 'https://github.com/owner/repo/issues/1', + channel_id: 'C123', + thread_ts: '1234567890.123456', + team_id: 'T_MULTI', + } + + assert_predicate last_response, :ok? + assert_requested slack_stub + assert_requested github_stub + end + + def test_ghcomment_falls_back_to_env_vars_for_unknown_team_id + stub_slack_conversations_replies('C123', [slack_message]) + stub_slack_users_info + stub_slack_chat_post_message + stub_github_create_comment('owner', 'repo', '1') + + post '/ghcomment', { + text: 'https://github.com/owner/repo/issues/1', + channel_id: 'C123', + thread_ts: '1234567890.123456', + team_id: 'T_UNKNOWN', + } + + assert_predicate last_response, :ok? + end + + def test_ghcomment_falls_back_to_env_vars_when_no_team_id + stub_slack_conversations_replies('C123', [slack_message]) + stub_slack_users_info + stub_slack_chat_post_message + stub_github_create_comment('owner', 'repo', '1') + + post '/ghcomment', { + text: 'https://github.com/owner/repo/issues/1', + channel_id: 'C123', + thread_ts: '1234567890.123456', + } + + assert_predicate last_response, :ok? + end + + def test_shortcut_uses_project_tokens_for_matching_team_id + views_stub = stub_request(:post, 'https://slack.com/api/views.open') + .with(headers: { 'Authorization' => 'Bearer xoxb-multi-project-token' }) + .to_return( + status: 200, + body: { 'ok' => true }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + payload = { + type: 'shortcut', + trigger_id: 'trigger123', + team: { id: 'T_MULTI' }, + } + + post '/shortcut', { payload: payload.to_json } + + assert_predicate last_response, :ok? + assert_requested views_stub + end +end From 882468e104014dfc3f3359fdb1ff8d214a9d111a Mon Sep 17 00:00:00 2001 From: Mark Allen Date: Sun, 29 Mar 2026 06:23:08 +0000 Subject: [PATCH 2/2] fix: address PR review feedback - Log warning when config file exists but CONFIG_PASSPHRASE is unset - Rescue ArgumentError in decrypt for corrupted/non-Base64 data - Add blob length validation before attempting decryption - Enforce unique slack_team_id in add_project and validate on update - Require non-empty passphrases in TUI prompts - Replace send() with explicit lambda dispatch table in menu loop - Add required: true to team_id field in edit flow - Show restart reminder when exiting TUI with configured projects Co-Authored-By: Claude Opus 4.6 (1M context) --- app.rb | 16 +++++++++++----- lib/cli/tui.rb | 20 ++++++++++++++------ lib/config/encryption.rb | 5 ++++- lib/config/project_config.rb | 13 +++++++++++++ 4 files changed, 42 insertions(+), 12 deletions(-) diff --git a/app.rb b/app.rb index 1edae25..abab1e9 100644 --- a/app.rb +++ b/app.rb @@ -50,11 +50,17 @@ config_path = File.join(settings.root, '.config', 'projects.enc') config_passphrase = ENV.fetch('CONFIG_PASSPHRASE', nil) - if File.exist?(config_path) && config_passphrase - project_config = Config::ProjectConfig.new(config_path: config_path) - project_config.load!(config_passphrase) - set :project_config, project_config - settings.logger.info "Loaded #{project_config.projects.length} project(s) from encrypted config" + if File.exist?(config_path) + if config_passphrase + project_config = Config::ProjectConfig.new(config_path: config_path) + project_config.load!(config_passphrase) + set :project_config, project_config + settings.logger.info "Loaded #{project_config.projects.length} project(s) from encrypted config" + else + set :project_config, nil + settings.logger.warn 'Encrypted project config found but CONFIG_PASSPHRASE is not set; ' \ + 'falling back to environment variable credentials' + end else set :project_config, nil end diff --git a/lib/cli/tui.rb b/lib/cli/tui.rb index f1c3450..c6c826c 100644 --- a/lib/cli/tui.rb +++ b/lib/cli/tui.rb @@ -18,6 +18,7 @@ def run @passphrase = prompt_passphrase @config.load!(@passphrase) main_menu_loop + @prompt.warn('Restart the running app for config changes to take effect.') unless @config.empty? rescue Config::Encryption::DecryptionError @prompt.error('Wrong passphrase. Unable to decrypt config file.') exit 1 @@ -27,11 +28,11 @@ def run def prompt_passphrase if File.exist?(@config_path) - @prompt.mask('Enter config passphrase:') + @prompt.mask('Enter config passphrase:', required: true) else @prompt.ok('No config file found. Creating a new one.') - passphrase = @prompt.mask('Choose a passphrase for encrypting your config:') - confirm = @prompt.mask('Confirm passphrase:') + passphrase = @prompt.mask('Choose a passphrase for encrypting your config:', required: true) + confirm = @prompt.mask('Confirm passphrase:', required: true) unless passphrase == confirm @prompt.error('Passphrases do not match.') exit 1 @@ -41,12 +42,19 @@ def prompt_passphrase end def main_menu_loop + handlers = { + add_project: -> { add_project }, + edit_project: -> { edit_project }, + remove_project: -> { remove_project }, + list_projects: -> { list_projects }, + } + loop do choices = build_menu_choices action = @prompt.select('What would you like to do?', choices) break if action == :exit - send(action) + handlers[action]&.call end end @@ -94,8 +102,8 @@ def edit_project project = @config.find_by_name(name) updates = {} - updates[:name] = @prompt.ask('Project name:', default: project[:name]) - updates[:slack_team_id] = @prompt.ask('Slack team ID:', default: project[:slack_team_id]) + updates[:name] = @prompt.ask('Project name:', default: project[:name], required: true) + updates[:slack_team_id] = @prompt.ask('Slack team ID:', default: project[:slack_team_id], required: true) updates[:slack_bot_token] = prompt_optional_mask('Slack bot token:', project[:slack_bot_token]) updates[:github_token] = prompt_optional_mask('GitHub token:', project[:github_token]) updates[:default_github_org] = @prompt.ask('Default GitHub org:', default: project[:default_github_org]) diff --git a/lib/config/encryption.rb b/lib/config/encryption.rb index b896f13..73aba46 100644 --- a/lib/config/encryption.rb +++ b/lib/config/encryption.rb @@ -31,8 +31,11 @@ def self.encrypt(plaintext, passphrase) Base64.strict_encode64(blob) end + MIN_BLOB_LENGTH = SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH + def self.decrypt(encoded_blob, passphrase) blob = Base64.strict_decode64(encoded_blob) + raise DecryptionError, 'Data too short — file may be corrupted' if blob.bytesize < MIN_BLOB_LENGTH salt = blob[0, SALT_LENGTH] iv = blob[SALT_LENGTH, IV_LENGTH] @@ -48,7 +51,7 @@ def self.decrypt(encoded_blob, passphrase) cipher.auth_tag = auth_tag cipher.update(ciphertext) + cipher.final - rescue OpenSSL::Cipher::CipherError + rescue OpenSSL::Cipher::CipherError, ArgumentError raise DecryptionError, 'Decryption failed — wrong passphrase or corrupted data' end diff --git a/lib/config/project_config.rb b/lib/config/project_config.rb index 08bde63..57497bb 100644 --- a/lib/config/project_config.rb +++ b/lib/config/project_config.rb @@ -46,6 +46,9 @@ def add_project(attrs) raise ArgumentError, 'Project name is required' if project[:name].to_s.strip.empty? raise ArgumentError, 'Slack team ID is required' if project[:slack_team_id].to_s.strip.empty? raise ArgumentError, "Project '#{project[:name]}' already exists" if find_by_name(project[:name]) + if find_by_team_id(project[:slack_team_id]) + raise ArgumentError, "Team ID '#{project[:slack_team_id]}' already in use" + end @projects << project project @@ -59,6 +62,8 @@ def update_project(name, attrs) sym_key = key.to_sym project[sym_key] = value if project.key?(sym_key) end + + validate_project!(project) project end @@ -80,6 +85,14 @@ def empty? private + def validate_project!(project) + raise ArgumentError, 'Project name is required' if project[:name].to_s.strip.empty? + raise ArgumentError, 'Slack team ID is required' if project[:slack_team_id].to_s.strip.empty? + + duplicate = @projects.find { |p| p[:slack_team_id] == project[:slack_team_id] && p != project } + raise ArgumentError, "Team ID '#{project[:slack_team_id]}' already in use" if duplicate + end + def normalize_project(attrs) { name: attrs[:name] || attrs['name'],