diff --git a/.github/workflows/code.yml b/.github/workflows/code.yml new file mode 100644 index 00000000..a2e8568b --- /dev/null +++ b/.github/workflows/code.yml @@ -0,0 +1,97 @@ +name: CODE + +on: + pull_request: + +jobs: + # ---------------------------------------------------------------- + # RUBY + # ---------------------------------------------------------------- + code_ruby: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + - name: Ruby Version + run: | + ruby -v + - name: Setup Ruby Dependencies + run: | + gem update bundler + bundle install --jobs 4 --retry 3 + - name: Test Ruby Dependencies + run: | + bundle exec rake -T + + # ---------------------------------------------------------------- + # BUNDLE + # ---------------------------------------------------------------- + code_bundle: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Update bundle-audit database + run: bundle exec bundle-audit update + + - name: Check for known security vulnerabilities in Ruby dependencies + run: bundle exec bundle-audit check + + - name: Check for outdated gems + run: bundle outdated --strict + continue-on-error: true + + # ---------------------------------------------------------------- + # QUALITY + # ---------------------------------------------------------------- + code_lint: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Lint code for consistent style! + run: bundle exec rubocop + + # ---------------------------------------------------------------- + # SPECS + # ---------------------------------------------------------------- + code_specs: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Set Up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Run RSpec Tests + env: + RAILS_ENV: test + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + run: bundle exec rspec --fail-fast diff --git a/.gitignore b/.gitignore index 0a1a31a6..e7a6efec 100644 --- a/.gitignore +++ b/.gitignore @@ -3,17 +3,14 @@ #-------------------------------------------- *.vscode *.claude +*.entire *.idea *.code -aitooling/cursor/workspaces/* -!aitooling/cursor/workspaces/.keep - applications/tmux/paths.json applications/vscode/workspaces/* !applications/vscode/workspaces/.keep -!applications/itermocil/.keep #-------------------------------------------- # APPLICATION diff --git a/.rspec b/.rspec new file mode 100644 index 00000000..7a2cc1a6 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--require spec_helper +--format documentation +--color diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 00000000..586622c7 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,28 @@ +plugins: + - rubocop-rspec + - rubocop-performance + +AllCops: + TargetRubyVersion: 4.0 + EnabledByDefault: true + DisplayCopNames: true + Exclude: + - tmp/**/* + - vendor/**/* + - .ruby-lsp/**/* + +Bundler/GemComment: + Enabled: false +Bundler/GemVersion: + Enabled: false +Bundler/OrderedGems: + Enabled: false + +Lint/ConstantResolution: + Enabled: false + +inherit_from: + - config/rubocop/metrics.yml + - config/rubocop/style.yml + - config/rubocop/layout.yml + - config/rubocop/rspec.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ddc9617..2eac0c0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -258,7 +258,7 @@ Complete modular architecture with 9 shell modules (`dotfiles/functions/collecti - libpq installation fixes for Apple Silicon #### Private Configuration Isolation -- All private settings moved to `~/.myconfigurations.private` (outside repo) +- All private settings moved to `~/.myconfigurations.private.keys` (outside repo) - Guards in Claude configuration files to prevent accidental commits ### Removed diff --git a/CLAUDE.md b/CLAUDE.md index 22db61a5..1ef7c4c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ rbenvset # Set local Ruby version to global ### Shell Module System The shell configuration loads modules dynamically through `dotfiles/functions/functions`: -1. Sources private configuration from `~/.myconfigurations.private` +1. Sources private configuration from `~/.myconfigurations.private.keys` 2. Sets global environment variables (HISTSIZE, BROWSER, EDITOR) 3. Iterates through defined modules (itermocil, ror, git, brew, python, pg, heroku, js) 4. For each module, sources the combined functions and help from `collections/` @@ -99,7 +99,7 @@ Each shell module in `collections/` combines: - Modules are self-contained and can be enabled/disabled via the `_myconfig_modules` array ### Private Configuration -Sensitive settings are stored in `~/.myconfigurations.private` (not tracked in git) and sourced at the beginning of the shell initialization process. +Sensitive settings are stored in `~/.myconfigurations.private.keys` (not tracked in git) and sourced at the beginning of the shell initialization process. ## Development Workflow @@ -128,7 +128,7 @@ Follow the guides in sequence: - For environment configuration questions, refer to `.env.example` only - **NEVER read Rails secrets files** (`config/secrets.yml`, `config/credentials.yml.enc`, `config/master.key`) - they contain encrypted credentials and sensitive application secrets - If asked about Rails secrets, only provide commands to view them (e.g., `rails credentials:edit`, `rails credentials:show`) - NEVER read the files directly -- Private configuration is stored in `~/.myconfigurations.private` (not tracked in git) +- Private configuration is stored in `~/.myconfigurations.private.keys` (not tracked in git) ## Important Notes @@ -137,4 +137,4 @@ Follow the guides in sequence: - Alfred workflows are stored in `scripts/alfred/` - Legacy configurations are prefixed with `__TBD__` indicating they may need updating - The repository follows the author's opinionated setup - adapt as needed for your use case -- **Documentation Files**: All explanation, documentation, and reference markdown files (*.md) should be written to the `./tmp` directory, NOT to the project root or other directories, unless told otherwise. These are for reference only and should not be committed to the repository. \ No newline at end of file +- **Documentation Files**: All explanation, documentation, and reference markdown files (*.md) should be written to the `./tmp` directory, NOT to the project root or other directories, unless told otherwise. These are for reference only and should not be committed to the repository. diff --git a/Gemfile b/Gemfile index 75cf79ce..8b3d764c 100644 --- a/Gemfile +++ b/Gemfile @@ -1,8 +1,18 @@ # frozen_string_literal: true -# rubocop:disable Layout/EmptyLines - source "https://rubygems.org" git_source( :github ) { |repo| "https://github.com/#{repo}.git" } ruby "4.0.0" + +gem "highline", "~> 3.1" + +group :development, :test do + gem "rubocop", require: false + gem "rubocop-performance", require: false + gem "rubocop-rspec", require: false +end + +group :test do + gem "rspec", "~> 3.13" +end diff --git a/Gemfile.lock b/Gemfile.lock index f4f399a7..353a2bb1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,15 +1,76 @@ GEM remote: https://rubygems.org/ specs: + ast (2.4.3) + diff-lcs (1.6.2) + highline (3.1.2) + reline + io-console (0.8.2) + json (2.18.1) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + parallel (1.27.0) + parser (3.3.10.1) + ast (~> 2.4.1) + racc + prism (1.9.0) + racc (1.8.1) + rainbow (3.1.1) + regexp_parser (2.11.3) + reline (0.6.3) + io-console (~> 0.5) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.7) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + rubocop (1.84.1) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rspec (3.9.0) + lint_roller (~> 1.1) + rubocop (~> 1.81) + ruby-progressbar (1.13.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) PLATFORMS ruby x86_64-darwin-24 DEPENDENCIES + highline (~> 3.1) + rspec (~> 3.13) + rubocop + rubocop-performance + rubocop-rspec RUBY VERSION - ruby 3.4.3p32 + ruby 4.0.0p0 BUNDLED WITH 2.6.6 diff --git a/aitooling/claude/CLAUDE.md b/aitooling/claude/CLAUDE.md deleted file mode 100644 index 46c5a0b8..00000000 --- a/aitooling/claude/CLAUDE.md +++ /dev/null @@ -1,208 +0,0 @@ -# Global Development Guidelines - -These are baseline guidelines for all projects. Project-specific CLAUDE.md files will override or extend these guidelines. - -You are an experienced software engineer with deep expertise in secure, scalable, and maintainable systems. Your role adapts based on project needs - from architectural design to implementation, code review to debugging. When no project-specific instructions exist, default to a Senior Software Architect mindset with 15+ years of experience. - -## Core Responsibilities - -When reviewing code or designs, you will: -- **Call out security issues** (e.g., injection, auth flaws, misconfigured cloud resources) -- **Highlight violations** of clean architecture or SOLID principles -- **Enforce scalability and maintainability** best practices -- **Point out missing tests** or observability gaps -- **Offer precise, actionable improvements** — not just vague feedback -- **Operate with real-world pragmatism**, balancing idealism with delivery realities - -## Project Context Detection - -Always analyze the project to determine: -1. **Primary Language**: Check file extensions, package managers, and config files -2. **Framework**: Identify from dependencies, project structure, and conventions -3. **Testing Framework**: Detect from test directories and configuration -4. **Code Style**: Look for linters/formatters (.rubocop.yml, .eslintrc, prettier.config, etc.) -5. **Build Tools**: Identify package managers, build systems, and deployment configs - -## Linter and Code Style Compliance - -**CRITICAL**: Always detect and strictly follow ALL project linters and formatters: -- **Read all linter configs** before writing any code (.rubocop.yml, .eslintrc, .prettierrc, pyproject.toml, etc.) -- **Apply all rules** from detected linters without exception -- **Check subdirectories** for additional linter configs (e.g., config/rubocop/) -- **Run linters** before considering any code complete -- **Never override** project-specific linting rules with general best practices -- **When multiple linters exist**, apply all of them in the appropriate order -- **NEVER automatically disable linter rules** without explicit permission - always ask first before adding ignore comments, disabling rules, or modifying linter configurations - -## Language-Specific Guidelines - -### Ruby/Rails Projects -- **Mental Model**: Think like Sandy Metz (Practical Object-Oriented Design in Ruby) -- **Style**: Apply RuboCop rules from `.rubocop.yml` and `config/rubocop/` -- **Framework Patterns**: Thin controllers, service objects, query objects -- **Testing**: RSpec with FactoryBot, following AAA pattern -- **Database**: PostgreSQL by default, prevent N+1 queries -- **Background Jobs**: Check for Sidekiq, Solid Queue, or ActiveJob - -### JavaScript/TypeScript Projects -- **Modern Patterns**: ES6+, functional programming where appropriate -- **Framework Conventions**: React (hooks, composition), Vue (Composition API), Angular (RxJS) -- **Testing**: Jest/Vitest, React Testing Library, Cypress/Playwright -- **Build Tools**: Webpack, Vite, ESBuild configurations -- **Type Safety**: Enforce strict TypeScript when available -- **State Management**: Context API, Redux Toolkit, Zustand patterns - -### Python Projects -- **Style**: PEP 8, Black formatting, type hints -- **Frameworks**: Django (MTV), FastAPI (async), Flask (minimal) -- **Testing**: pytest with fixtures, unittest for legacy -- **Package Management**: Poetry, pip-tools, or requirements.txt -- **Async Patterns**: asyncio best practices when applicable - -## Universal Best Practices - -### Security First -- **Input Validation**: Always validate and sanitize user input -- **Authentication/Authorization**: Implement proper access controls -- **File Permissions**: **NEVER run `chmod` commands** unless explicitly instructed by the user - - File permissions are security-critical and changes can expose sensitive data - - Always ask for explicit permission before modifying file permissions - - If permission issues arise, report them and ask the user how to proceed -- **Secrets Management**: Never hardcode credentials, use environment variables - - **NEVER read `.env` files** - they contain sensitive credentials, API keys, and secrets - - Use `.env.example` as reference for required environment variables - - For environment configuration questions, refer to `.env.example` only - - **NEVER read Rails secrets files** (`config/secrets.yml`, `config/credentials.yml.enc`, `config/master.key`) - they contain encrypted credentials and sensitive application secrets - - If asked about Rails secrets, only provide commands to view them (e.g., `rails credentials:edit`, `rails credentials:show`) - NEVER read the files directly -- **Dependencies**: Regular updates, vulnerability scanning -- **OWASP Top 10**: Always consider common vulnerabilities - -### Architecture & Design -- **SOLID Principles**: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion -- **Clean Architecture**: Separation of concerns, dependency rule -- **Design Patterns**: Apply appropriately, avoid over-engineering -- **API Design**: RESTful principles, GraphQL best practices, gRPC patterns -- **Microservices**: When beneficial, with proper service boundaries - -### Code Quality -- **Readability**: Clear naming, self-documenting code -- **Testing**: Unit, integration, E2E with appropriate coverage - - **NEVER mark tests as skipped** (skip, xit, it.skip, @skip, etc.) - fix them or ask for guidance - - **When working with specs**: Never skip as a solution, always fix the underlying issue - - **Maintain all existing tests** in working condition - - **Fix failing tests** rather than disabling them - - **Test Data Creation**: - - **Always use factories** (FactoryBot, factory_girl, etc.) if they exist in the project - - **NEVER create test data directly** in specs - use the project's factory pattern - - **Check for existing factories** before creating new test data - - **Follow project conventions** for traits, sequences, and associations -- **Documentation**: Meaningful comments, API docs, architecture decisions -- **Error Handling**: Graceful degradation, proper logging -- **Performance**: Profiling before optimizing, caching strategies - -### DevOps & Operations -- **CI/CD**: Automated testing, deployment pipelines -- **Containerization**: Docker best practices, multi-stage builds -- **Orchestration**: Kubernetes patterns, Helm charts -- **Monitoring**: Metrics, logs, traces (OpenTelemetry) -- **Infrastructure as Code**: Terraform, CloudFormation, Pulumi - -### Cloud & Scalability -- **Heroku (Primary)**: - - Leverage buildpacks and add-ons ecosystem - - Use Heroku Postgres, Redis, and managed services - - Configure dynos for appropriate scaling - - Implement Heroku CI/CD pipelines - - Monitor with Heroku metrics and logging -- **AWS (Secondary)**: - - S3 for object storage and static assets - - CloudFront for CDN distribution - - RDS for managed databases when needed - - ElastiCache for Redis clustering - - Lambda for serverless functions -- **Horizontal Scaling**: Stateless design, load balancing -- **Caching**: Redis (Heroku Redis or ElastiCache), CDNs, application-level caching -- **Message Queues**: RabbitMQ, Kafka, AWS SQS/SNS -- **Database Scaling**: Read replicas, connection pooling, follower databases - -### Database Performance & Optimization - -#### PostgreSQL Optimization -- **Query Performance**: - - Always use `EXPLAIN ANALYZE` for slow queries - - Create appropriate indexes (B-tree, GIN, GiST, BRIN) - - Use partial indexes for filtered queries - - Implement covering indexes to avoid heap lookups - - Monitor and eliminate N+1 queries -- **Configuration Tuning**: - - Adjust `shared_buffers` (typically 25% of RAM) - - Configure `work_mem` for complex queries - - Set appropriate `max_connections` with connection pooling - - Enable `pg_stat_statements` for query analysis - - Configure autovacuum for optimal performance -- **Schema Design**: - - Use appropriate data types (avoid overusing TEXT) - - Implement table partitioning for large datasets - - Consider JSONB for semi-structured data - - Use materialized views for expensive aggregations - - Implement proper foreign key constraints with indexes -- **Connection Management**: - - Use PgBouncer or similar for connection pooling - - Configure appropriate pool sizes - - Implement prepared statements wisely - - Monitor connection states and idle transactions - -#### Redis Optimization -- **Data Structure Selection**: - - Use appropriate data types (strings, hashes, lists, sets, sorted sets) - - Implement HyperLogLog for cardinality estimation - - Use bitmaps for boolean tracking - - Consider Redis Streams for event sourcing -- **Memory Management**: - - Configure `maxmemory` and eviction policies (LRU, LFU, TTL) - - Use key expiration strategically - - Monitor memory fragmentation - - Implement key naming conventions for organization -- **Performance Patterns**: - - Use pipelining for bulk operations - - Implement Lua scripts for atomic operations - - Use Redis transactions (MULTI/EXEC) appropriately - - Consider Redis Cluster for horizontal scaling -- **Persistence Strategy**: - - Choose between RDB snapshots and AOF logs - - Configure appropriate save intervals - - Monitor replication lag in master-slave setups - - Implement proper backup strategies - -#### General Database Performance -- **Monitoring & Metrics**: - - Track query execution times and frequency - - Monitor connection pool utilization - - Watch for lock contention and deadlocks - - Set up alerts for slow queries and high load -- **Caching Strategy**: - - Implement multi-tier caching (application, Redis, CDN) - - Use cache-aside pattern for read-heavy workloads - - Implement write-through for consistency - - Consider time-based and event-based invalidation -- **Data Access Patterns**: - - Batch operations when possible - - Use read replicas for analytics queries - - Implement database sharding for massive scale - - Consider CQRS for complex domains - -## Working Principles - -1. **Context Awareness**: Always check project-specific configurations and conventions -2. **Linter Compliance First**: Project linters override any general style preferences -3. **Pragmatic Approach**: Balance perfect architecture with delivery timelines -4. **Continuous Learning**: Stay updated with evolving best practices -5. **Clear Communication**: Explain the "why" behind recommendations -6. **Risk Assessment**: Prioritize critical issues over minor improvements - -## File Organization -- **Temporary Files**: Always use project's `./tmp` or `.tmp` directory -- **Project Structure**: Follow language/framework conventions -- **Configuration**: Respect .gitignore and tool-specific exclusions - -> **Important**: Always analyze the specific project context first. Check for configuration files, dependencies, and existing patterns. Project-specific conventions always take precedence over these global guidelines. Local CLAUDE.md files in project directories override these defaults. When uncertain, ask for clarification rather than making assumptions. diff --git a/aitooling/claude/project_brain_files/ruby_on_rails/CLAUDE.md b/aitooling/claude/project_brain_files/ruby_on_rails/CLAUDE.md deleted file mode 100644 index 87884303..00000000 --- a/aitooling/claude/project_brain_files/ruby_on_rails/CLAUDE.md +++ /dev/null @@ -1,1355 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - ---- - -## Quick Reference - -### Template Markers -When using this as a template for new projects, look for these markers: -- `[PROJECT-SPECIFIC]` - Must be updated for each project -- `[OPTIONAL]` - Remove section if not applicable to your project -- `[CUSTOMIZE]` - Adapt values/examples to project needs - -### Essential Commands Quick Reference -| Task | Command | -|------|---------| -| Run all tests | `bundle exec rspec` | -| Run linter | `bundle exec rubocop` | -| Auto-fix lint | `bundle exec rubocop -a` | -| Start server | `bin/dev` | -| Console | `bin/rails console` | -| DB migrate | `bin/rails db:migrate` | -| Security scan | `bundle exec rake appfactory:test:brakeman` | - ---- - -## Code Style & Project Guidelines - -- **Core Language:** Always use **Ruby**. -- **Framework:** Rails 8.0.2 (per Gemfile) `[PROJECT-SPECIFIC]` -- **Ruby Version:** Ruby 3.4.3 (per .ruby-version) `[PROJECT-SPECIFIC]` -- **Mental Model:** Think like Sandy Metz when she wrote Practical Object-Oriented Design in Ruby. -- **Code Style:** Apply the style, conventions, and rules from `.rubocop.yml` and all config files in `config/rubocop/` (base.yml, rails.yml, rspec.yml, metrics.yml, layout.yml, obsession.yml). **IMPORTANT: All generated code MUST pass RuboCop checks without violations.** -- **Gems & Dependencies:** Review the `Gemfile` and `Gemfile.lock` when providing code examples. All listed gems are available. -- **Testing Plugins:** This project uses: `[CUSTOMIZE]` - - rubocop-rspec - - rubocop-performance - - rubocop-rails - - rubocop-factory_bot - - rubocop-rspec_rails - - rubocop-obsession -- **Temporary Files:** Always use the project's `./tmp` directory for temporary files. -- **Documentation Files:** All explanation, documentation, and reference markdown files (*.md) should be written to the `./tmp` directory, NOT to the project root or other directories, unless told otherwise. - ---- - -## Essential Commands - -### Development -```bash -bin/setup # Initial setup: installs dependencies, prepares database, starts dev server -bin/dev # Start Rails development server -bin/rails server # Start Rails server directly -bin/rails console # Open Rails console -``` - -### Testing -```bash -bundle exec rspec # Run all tests -bundle exec rspec spec/services/ # Run specific directory -bundle exec rspec spec/services/some_spec.rb # Run specific file -bundle exec rspec spec/services/some_spec.rb:42 # Run specific line - -bundle exec rake appfactory:test:simplecov # Run tests with coverage [PROJECT-SPECIFIC] -bundle exec rake appfactory:test:rubocop # Run RuboCop checks [PROJECT-SPECIFIC] -bundle exec rake appfactory:test:brakeman # Run security analysis [PROJECT-SPECIFIC] -``` - -### Database -```bash -bin/rails db:prepare # Setup database -bin/rails db:migrate # Run migrations -bundle exec rake appfactory:database:reset # Reset database [PROJECT-SPECIFIC] -bundle exec rake appfactory:database:recreate[development] # Drop and recreate [PROJECT-SPECIFIC] -``` - -### Code Quality -```bash -bundle exec rubocop # Run all RuboCop checks -bundle exec rubocop -a # Auto-fix RuboCop violations -bundle exec rubocop spec/services/some_spec.rb # Check specific file -``` - ---- - -## Project Directory Structure - -``` -app/ -├── assets/ # Static assets (stylesheets, images) -├── builders/ # Builder pattern for complex object construction [OPTIONAL] -├── controllers/ # HTTP request handlers (keep thin!) -├── forms/ # Form objects for complex multi-model forms -├── helpers/ # View helpers (use sparingly) -├── inputs/ # Input objects for request parameter parsing [OPTIONAL] -├── jobs/ # Background jobs (Solid Queue) -├── lib/ # App-dependent library code (e.g., Rodauth config) -├── mailers/ # ActionMailer classes (prefer service objects) -├── models/ # ActiveRecord models (data + associations only) -├── policies/ # Pundit authorization policies -├── presenters/ # View presenters/decorators [OPTIONAL] -├── queries/ # Query objects for complex database queries -├── services/ # ServiceWrapper business logic (primary location) -├── validators/ # Custom ActiveModel validators -├── views/ # Slim templates -└── workers/ # Legacy background workers [OPTIONAL] - -config/ -├── environments/ # Environment-specific configuration -├── initializers/ # Boot-time initialization -├── locales/ # I18n translation files -├── rubocop/ # Modular RuboCop configuration -└── settings/ # Application settings (config gem) - -lib/ -├── core_extensions/ # Core Ruby class extensions -├── monkey_patches/ # Runtime modifications (use sparingly) -├── modules/ # Reusable utility modules -├── tasks/ # Custom Rake tasks -└── utilities/ # Helper utilities - -spec/ -├── factories/ # FactoryBot test data definitions -├── fixtures/ # VCR cassettes and test files -├── support/ # Test helpers, shared examples, configs -│ ├── configs/ # Test framework configurations -│ ├── helpers/ # Custom test helper modules -│ ├── matchers/ # Custom RSpec matchers -│ ├── modules/ # Utility modules for tests -│ ├── shared_contexts/ # Shared context setups -│ └── shared_examples/ # Reusable test patterns -├── controllers/ # Controller specs -├── features/ # Feature/system specs -├── jobs/ # Background job specs -├── models/ # Model specs -├── policies/ # Policy specs -├── requests/ # Request/integration specs -├── services/ # Service object specs -└── validators/ # Validator specs -``` - -### Where to Put New Code -| Code Type | Location | When to Use | -|-----------|----------|-------------| -| Business logic | `app/services/` | Any logic involving multiple models, external services, or complex operations | -| Database queries | `app/queries/` | Complex queries with joins, aggregations, or multiple conditions | -| Form handling | `app/forms/` | Multi-model forms, complex validations spanning models | -| Request parsing | `app/inputs/` | Complex parameter parsing, API request objects | -| View logic | `app/presenters/` | Complex view logic, formatting, calculated display values | -| Authorization | `app/policies/` | Access control, permission checks | -| Object construction | `app/builders/` | Objects with many optional parameters, step-by-step construction | -| Custom validations | `app/validators/` | Reusable validation rules across models | -| Background work | `app/jobs/` | Async operations, scheduled tasks, expensive computations | - ---- - -## Architecture Overview - -### Service Object Pattern -All business logic uses the ServiceWrapper pattern. Services must: -- Inherit from `ServiceWrapper` -- Define `_local_initialize` for setup (private, underscore prefix) -- Define `_local_call` for business logic (private, underscore prefix) -- Use `validates` for input validation -- Return data via `@response` - -Example structure with proper private method naming: -```ruby -# frozen_string_literal: true - -module Services - module User - class CreateAccount < ServiceWrapper - validates :email, presence: true - validate :_email_format_valid? - - private - - attr_accessor :email, :user - - def _local_initialize(email:) - self.email = email - end - - def _local_call - self.user = _create_user - _send_welcome_email - - @response = { user: user } - end - - def _email_format_valid? - return if email.match?(URI::MailTo::EMAIL_REGEXP) - - errors.add(:email, I18n.t("services.user.create_account.errors.invalid_email")) - end - - def _create_user - ::Account.create!(email: email, status: :unverified) - end - - def _send_welcome_email - ::Mail::WelcomeJob.perform_later(user_id: user.id) - end - end - end -end -``` - -Usage: -```ruby -result = Services::User::CreateAccount.call(email: "user@example.com") -if result.valid? - user = result.response[:user] -else - errors = result.errors -end -``` - -### Query Objects -Extract complex ActiveRecord queries into dedicated classes: - -```ruby -# frozen_string_literal: true - -module Queries - class ActiveLeadsByDateRange - def initialize(account:, start_date:, end_date:) - @account = account - @start_date = start_date - @end_date = end_date - end - - def call - _base_scope - .where(created_at: @start_date..@end_date) - .includes(:tags, :notes) - .order(created_at: :desc) - end - - private - - def _base_scope - @account.leads.active - end - end -end - -# Usage -leads = Queries::ActiveLeadsByDateRange.new( - account: current_account, - start_date: 30.days.ago, - end_date: Time.current, -).call -``` - -### Form Objects -Handle complex forms that span multiple models: - -```ruby -# frozen_string_literal: true - -module Forms - class UserRegistration - include ActiveModel::Model - include ActiveModel::Validations - - attr_accessor :email, :password, :company_name, :terms_accepted - - validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } - validates :password, presence: true, length: { minimum: 10 } - validates :company_name, presence: true - validates :terms_accepted, acceptance: true - - def save - return false unless valid? - - ActiveRecord::Base.transaction do - _create_account - _create_company - true - end - rescue ActiveRecord::RecordInvalid => e - errors.add(:base, e.message) - false - end - - private - - def _create_account - @account = Account.create!(email: email, password: password) - end - - def _create_company - @company = @account.companies.create!(name: company_name) - end - end -end -``` - -### Input Objects `[OPTIONAL]` -Parse and validate incoming request parameters: - -```ruby -# frozen_string_literal: true - -module Inputs - class LeadSearchInput - include ActiveModel::Model - - attr_accessor :query, :status, :date_from, :date_to, :page, :per_page - - def initialize(params = {}) - @query = params[:q]&.strip - @status = params[:status]&.to_sym - @date_from = _parse_date(params[:date_from]) - @date_to = _parse_date(params[:date_to]) - @page = (params[:page] || 1).to_i - @per_page = [(params[:per_page] || 25).to_i, 100].min - end - - private - - def _parse_date(value) - return nil if value.blank? - - Time.zone.parse(value) - rescue ArgumentError - nil - end - end -end -``` - -### Presenter Objects `[OPTIONAL]` -Encapsulate complex view logic: - -```ruby -# frozen_string_literal: true - -module Presenters - class LeadPresenter - def initialize(lead) - @lead = lead - end - - def full_name - "#{@lead.first_name} #{@lead.last_name}".strip - end - - def status_badge_class - case @lead.status - when :active then "badge-success" - when :pending then "badge-warning" - when :closed then "badge-secondary" - else "badge-info" - end - end - - def formatted_phone - return "N/A" if @lead.phone_number.blank? - - _format_phone_number(@lead.phone_number) - end - - def created_at_relative - time_ago_in_words(@lead.created_at) - end - - private - - def _format_phone_number(number) - # Format: (555) 123-4567 - cleaned = number.gsub(/\D/, "") - return number unless cleaned.length == 10 - - "(#{cleaned[0..2]}) #{cleaned[3..5]}-#{cleaned[6..9]}" - end - end -end -``` - -### Policy Objects -Pundit policies for authorization: `[PROJECT-SPECIFIC]` - -```ruby -# frozen_string_literal: true - -class LeadPolicy < ApplicationPolicy - def index? - _user_has_account? - end - - def show? - _user_owns_record? - end - - def create? - _user_has_account? - end - - def update? - _user_owns_record? - end - - def destroy? - _user_owns_record? && !record.has_active_deals? - end - - class Scope < ApplicationPolicy::Scope - def resolve - scope.where(account: user.account) - end - end - - private - - def _user_has_account? - user.account.present? - end - - def _user_owns_record? - record.account_id == user.account_id - end -end -``` - -### Builder Objects `[OPTIONAL]` -Construct complex objects with many optional parameters: - -```ruby -# frozen_string_literal: true - -module Builders - class ReportBuilder - def initialize - @filters = {} - @columns = [] - @sort_by = nil - @format = :html - end - - def with_date_range(start_date, end_date) - @filters[:date_range] = start_date..end_date - self - end - - def with_status(status) - @filters[:status] = status - self - end - - def with_columns(*columns) - @columns = columns.flatten - self - end - - def sorted_by(column, direction = :asc) - @sort_by = { column: column, direction: direction } - self - end - - def as_format(format) - @format = format - self - end - - def build - Report.new( - filters: @filters, - columns: @columns, - sort_by: @sort_by, - format: @format, - ) - end - end -end - -# Usage -report = Builders::ReportBuilder.new - .with_date_range(30.days.ago, Time.current) - .with_status(:active) - .with_columns(:name, :email, :created_at) - .sorted_by(:created_at, :desc) - .as_format(:csv) - .build -``` - -### Value Objects `[OPTIONAL]` -Immutable objects representing domain concepts: - -```ruby -# frozen_string_literal: true - -class Money - include Comparable - - attr_reader :cents, :currency - - def initialize(cents, currency = "USD") - @cents = cents.to_i - @currency = currency.to_s.upcase.freeze - freeze - end - - def dollars - cents / 100.0 - end - - def +(other) - _ensure_same_currency!(other) - self.class.new(cents + other.cents, currency) - end - - def -(other) - _ensure_same_currency!(other) - self.class.new(cents - other.cents, currency) - end - - def *(multiplier) - self.class.new((cents * multiplier).round, currency) - end - - def <=>(other) - return nil unless other.is_a?(Money) && currency == other.currency - - cents <=> other.cents - end - - def to_s - format("$%.2f %s", dollars, currency) - end - - private - - def _ensure_same_currency!(other) - return if currency == other.currency - - raise ArgumentError, "Cannot operate on different currencies: #{currency} vs #{other.currency}" - end -end -``` - -### Null Objects `[OPTIONAL]` -Handle nil cases elegantly: - -```ruby -# frozen_string_literal: true - -class NullAccount - def id - nil - end - - def email - "guest@example.com" - end - - def name - "Guest" - end - - def verified? - false - end - - def can_access?(_resource) - false - end - - def leads - Lead.none - end -end - -# Usage in controller -def current_account - @current_account ||= Account.find_by(id: session[:account_id]) || NullAccount.new -end -``` - -### Authentication Architecture `[PROJECT-SPECIFIC]` -- Uses Rodauth (not Devise) for authentication -- Configuration in `app/lib/rodauth_app.rb` and `rodauth_main.rb` -- Mounted at `/authentication` routes -- Database tables: `accounts`, `account_verification_keys`, `account_password_reset_keys`, etc. - -### Background Processing -- **Job Framework**: Solid Queue (per Gemfile) - all jobs in `app/jobs/` -- **Caching**: Solid Cache for Rails caching needs -- **WebSockets**: Solid Cable for ActionCable -- **Job Design**: Keep jobs idempotent and focused on a single task -- All background jobs inherit from `ApplicationJob` -- **CRITICAL**: All jobs must be idempotent - safe to run multiple times with same arguments - -```ruby -# frozen_string_literal: true - -class ImportLeadsJob < ApplicationJob - queue_as :default - - def perform(import_id:) - import = LeadImport.find(import_id) - return if import.completed? # Idempotency check - - Services::Leads::ProcessImport.call(import: import) - end -end -``` - -### Email Services `[PROJECT-SPECIFIC]` -Emails are sent via service objects, not traditional Rails mailers: -- `Services::Email::SendAccountVerificationEmail` -- `Services::Email::SendPasswordResetEmail` -- Uses SendGrid for production email delivery - -### Testing Patterns -- All services have corresponding specs in `spec/services/` -- Use shared examples: `it_behaves_like "a valid service response"` -- VCR cassettes for external API calls -- FactoryBot for test data - -### Frontend Architecture `[PROJECT-SPECIFIC]` -- **Templates**: Slim (not ERB) -- **JavaScript**: Import maps + Stimulus controllers -- **CSS**: SCSS with Propshaft -- **Interactivity**: Hotwire (Turbo + Stimulus) - ---- - -## Key Conventions - -### Service Response Structure -Services always return an object with: -- `valid?` - boolean indicating success -- `errors` - ActiveModel::Errors object (with `raw`, `text`, `translated`, `error_count`) -- `response` - hash containing returned data - -### Validation Pattern -Services validate inputs before execution: -```ruby -validates :required_param, presence: true -validates :email, format: { with: URI::MailTo::EMAIL_REGEXP } -validate :_custom_validation_method # Note underscore prefix -``` - -### Error Handling in Services -Errors use I18n translations from `config/locales/`: -- Service errors: `services.module.class_name.errors.error_key` -- Model errors: Standard Rails I18n patterns - -```ruby -def _validate_email - return if _email_valid? - - errors.add(:email, I18n.t("services.mail.verification.errors.email_invalid")) -end -``` - -### Database Migrations -Uses Rein gem for database constraints: -```ruby -add_presence_constraint :table, :column -add_foreign_key_constraint :table, :foreign_table -``` - -### RuboCop Configuration -Located in `config/rubocop/` with separate files: -- `base.yml` - Core Ruby style -- `rails.yml` - Rails-specific rules -- `rspec.yml` - RSpec style -- `metrics.yml` - Complexity metrics -- `layout.yml` - Code layout rules -- `obsession.yml` - Method obsession checks - -Key enforced styles: -- Double quotes for strings -- Trailing commas in multi-line hashes/arrays -- Parentheses required for method calls (except in specs) -- **CRITICAL: All private methods MUST have an underscore prefix** (e.g., `_calculate_total`, `_validate_input`) - This is a mandatory convention for ALL Ruby code in this project - -### Model Conventions -Order within model files: -```ruby -class Lead < ApplicationRecord - # 1. Constants - STATUSES = %i[active pending closed].freeze - - # 2. Attribute declarations (enums, etc.) - enum :status, { active: 0, pending: 1, closed: 2 }, default: :pending - - # 3. Associations (belongs_to first, then has_many, then has_one) - belongs_to :account - has_many :notes, dependent: :destroy - has_one :primary_contact, class_name: "Contact" - - # 4. Validations - validates :email, presence: true, uniqueness: { scope: :account_id } - validates :status, inclusion: { in: STATUSES } - - # 5. Scopes - scope :active, -> { where(status: :active) } - scope :recent, -> { order(created_at: :desc) } - - # 6. Callbacks (use sparingly - prefer service objects) - before_validation :_normalize_email, if: :email_changed? - - # 7. Class methods - def self.search(query) - where("email ILIKE ?", "%#{query}%") - end - - # 8. Public instance methods - def full_name - "#{first_name} #{last_name}".strip - end - - # 9. Private methods (with underscore prefix) - private - - def _normalize_email - self.email = email&.downcase&.strip - end -end -``` - -### Controller Conventions -Order within controller files: -```ruby -class LeadsController < ApplicationController - # 1. before_action declarations - before_action :authenticate! - before_action :_set_lead, only: %i[show edit update destroy] - - # 2. Standard RESTful actions (in order: index, show, new, create, edit, update, destroy) - def index - @leads = policy_scope(Lead).page(params[:page]) - end - - def show; end - - def new - @lead = Lead.new - end - - def create - @lead = current_account.leads.build(_lead_params) - if @lead.save - redirect_to @lead, notice: t(".success") - else - render :new, status: :unprocessable_entity - end - end - - def edit; end - - def update - if @lead.update(_lead_params) - redirect_to @lead, notice: t(".success") - else - render :edit, status: :unprocessable_entity - end - end - - def destroy - @lead.destroy - redirect_to leads_path, notice: t(".success") - end - - # 3. Custom public actions - def export - # ... - end - - # 4. Private methods (with underscore prefix) - private - - def _set_lead - @lead = policy_scope(Lead).find(params[:id]) - authorize @lead - end - - def _lead_params - params.require(:lead).permit(:email, :first_name, :last_name, :phone_number, :status) - end -end -``` - -### Time Handling -- **Always use `Time.zone.now`**, never `Time.now` or `DateTime.now` -- Store all times in UTC in the database -- Use `freeze_time` or `travel_to` in tests for deterministic time -- Parse user input with `Time.zone.parse` -- Display times using I18n localization: `l(time, format: :short)` - -```ruby -# Good -Time.zone.now -Time.zone.today -1.day.ago -Time.zone.parse("2024-01-15") - -# Bad - Never use these -Time.now -Date.today -DateTime.now -Time.parse("2024-01-15") -``` - ---- - -## Database Conventions - -### Naming Conventions -- **Tables**: Plural, snake_case (`leads`, `account_settings`, `password_reset_keys`) -- **Columns**: Singular, snake_case (`first_name`, `created_at`, `account_id`) -- **Foreign keys**: `{table_singular}_id` (`account_id`, `lead_id`) -- **Indexes**: `index_{table}_on_{columns}` (auto-generated by Rails) -- **Join tables**: Alphabetical order (`accounts_roles`, `leads_tags`) - -### Primary Keys -- Use integer IDs for internal references (default Rails behavior) -- Consider UUIDs for external-facing identifiers (API responses, URLs) -- Never expose sequential IDs in public URLs if order/count is sensitive - -### Index Strategies -```ruby -# Always index foreign keys -add_index :leads, :account_id - -# Index columns used in WHERE clauses -add_index :leads, :status -add_index :leads, :email - -# Composite indexes - put equality columns first, range columns last -add_index :leads, [:account_id, :status, :created_at] - -# Partial indexes for filtered queries -add_index :leads, :email, where: "status = 'active'", name: "index_active_leads_on_email" - -# Unique constraints -add_index :accounts, :email, unique: true - -# Index for ORDER BY queries -add_index :leads, [:account_id, :created_at], order: { created_at: :desc } -``` - -### Query Optimization Patterns -```ruby -# Use includes for belongs_to/has_many that will be accessed -Lead.includes(:account, :tags).where(status: :active) - -# Use preload when you need separate queries (useful for complex conditions) -Lead.preload(:notes).where(status: :active) - -# Use eager_load for LEFT OUTER JOIN (when filtering by association) -Lead.eager_load(:notes).where(notes: { important: true }) - -# Use exists? instead of loading records -Lead.where(account: account).exists? # Good -Lead.where(account: account).any? # Loads records - avoid - -# Use pluck for single columns -Lead.where(status: :active).pluck(:email) # Returns array of strings - -# Use select to limit columns -Lead.select(:id, :email, :status).where(status: :active) - -# Use find_each for batch processing -Lead.where(status: :pending).find_each(batch_size: 100) do |lead| - # Process each lead -end - -# Use update_all for bulk updates -Lead.where(status: :pending).update_all(status: :active) - -# Use insert_all for bulk inserts (Rails 6+) -Lead.insert_all([{ email: "a@b.com" }, { email: "c@d.com" }]) -``` - ---- - -## RuboCop Compliance - -**CRITICAL: All code must pass RuboCop checks.** Before finalizing any code: -- Run `bundle exec rubocop ` on new/modified files -- Fix all violations - do not disable cops without explicit user approval -- Common patterns to follow: - - Use frozen string literal comment: `# frozen_string_literal: true` - - Add proper spacing around operators and after commas - - Use consistent indentation (2 spaces) - - Keep lines under the configured length limit - - Use descriptive variable and method names - - Follow the configured naming conventions - - In specs, use proper RSpec style (no parentheses for matchers) - - **All private methods must start with underscore** - ---- - -## Rails Best Practices - -- **Database:** PostgreSQL with Rein for database integrity -- **Controllers:** Keep controllers thin - they only handle HTTP concerns (params, response, redirects) -- **Models:** Keep models focused on data and associations, extract complex logic to service objects -- **Service Objects:** Business logic in `app/services/` using single-responsibility classes (ServiceWrapper pattern) -- **Query Objects:** Place complex database queries in `app/queries/` -- **Form Objects:** Use form objects in `app/forms/` for complex multi-model forms -- **Input Objects:** Use input objects in `app/inputs/` for complex request parameter parsing -- **Presenters:** Use presenters in `app/presenters/` for complex view logic -- **Policies:** Use Pundit policies in `app/policies/` for authorization -- **Concerns:** Use concerns sparingly and only for truly shared, cohesive behavior -- **Callbacks:** Use ActiveRecord callbacks sparingly - prefer explicit service objects - - **Never use callbacks for**: sending emails, calling APIs, touching other models, or complex side effects - ---- - -## Testing Guidelines - -- **Testing Framework:** RSpec for all tests -- **Test Structure:** Follow the AAA pattern (Arrange, Act, Assert) -- **Factory Strategy:** FactoryBot for test data creation - ALWAYS use factories, never create records directly -- **Test Coverage:** Write comprehensive tests for all business logic -- **Test Location:** Follow Rails conventions - model specs in `spec/models/`, service specs in `spec/services/`, etc. -- **Shared Examples:** Use `it_behaves_like "a valid service response"` for service tests -- **VCR:** Use VCR cassettes for external API calls -- **Request Specs:** Prefer request specs over controller specs for full-stack testing -- **Feature Specs:** Use sparingly - prefer request specs for better performance - -### Factory Conventions -```ruby -# Always use factories - never create records directly in specs -let(:account) { create(:account) } -let(:lead) { create(:lead, account: account) } - -# Use traits for variations -let(:verified_account) { create(:account, :verified) } - -# Use build for objects that don't need persistence -let(:lead) { build(:lead, email: "test@example.com") } - -# Use sequences for unique values -sequence(:email) { |n| "user#{n}@example.com" } -``` - -### Service Testing Pattern -```ruby -RSpec.describe Services::User::CreateAccount, type: :service do - describe ".call" do - context "when valid" do - subject(:service) { described_class.call(email: "user@example.com") } - - it_behaves_like "a valid service response" do - let(:service_response) { service } - let(:response) { be_a(Account) } - end - end - - context "when invalid" do - subject(:service) { described_class.call(email: nil) } - - it_behaves_like "an invalid service response" do - let(:service_response) { service } - let(:error_array) do - [[:email, I18n.t("services.user.create_account.errors.email_required")]] - end - end - end - end -end -``` - -### Test Anti-Patterns to Avoid -- Don't test private methods directly -- Don't use hardcoded IDs (use `-1` for nonexistent records) -- Don't use `sleep` - use proper waiting conditions -- Don't mock ActiveRecord in integration tests -- Don't write specs with multiple unrelated expectations -- Don't depend on specific data ordering without explicit `ORDER BY` - ---- - -## Anti-Patterns to Avoid - -### Architecture Anti-Patterns -- **Don't use ActiveRecord models as service objects** - models handle data, services handle business logic -- **Don't put business logic in controllers, views, or callbacks** - use service objects -- **Don't use before_save/after_save for cross-model operations** - use explicit service transactions -- **Don't create services with multiple public methods** - one service, one purpose -- **Don't use concerns as dumping grounds** - only for truly shared, cohesive behavior -- **Don't use callbacks for emails, APIs, or side effects** - make these explicit in services -- **Don't use default_scope** - causes hidden complexity and unexpected behavior -- **Don't create deeply nested modules** - keep namespace depth reasonable (max 3 levels) - -### Code Organization Anti-Patterns -- **Don't put query logic in controllers or views** - use query objects or scopes -- **Don't put presentation logic in models** - use presenters -- **Don't put business logic in helpers** - use services or presenters -- **Don't use class inheritance when composition is clearer** - prefer modules and delegation -- **Don't use update_attribute/update_column** unless you explicitly need to skip validations -- **Don't create conditional logic based on Rails.env** - extract to configuration - -### Testing Anti-Patterns -- **Don't write model specs testing ActiveRecord functionality** - Rails already tests this -- **Don't write unit tests requiring database when logic can be tested in isolation** -- **Don't use fixtures** - use factories with explicit attributes -- **Don't test private methods directly** - test through public interface -- **Don't use hardcoded IDs** - use `-1` for nonexistent record tests -- **Don't use sleep statements** - use proper waiting/polling conditions -- **Don't mock ActiveRecord models in integration tests** -- **Don't write specs with multiple expectations testing different behaviors** - -### Metaprogramming Anti-Patterns -- **Don't add "clever" metaprogramming** that obscures control flow -- **Avoid method_missing** unless absolutely necessary -- **Don't use global refinements** - they're confusing and break tooling -- **Don't monkey-patch core classes** in application code (lib/monkey_patches is exception) - ---- - -## Code Review Checklist - -### Code Quality & Readability -- [ ] Method and variable names clearly express their purpose -- [ ] Methods do exactly what their names suggest, no more and no less -- [ ] Code reads like well-written prose with early returns reducing nesting -- [ ] No debug statements, console logs, or print statements remain -- [ ] Complex algorithms and business logic have explanatory "why" comments -- [ ] Magic numbers and strings extracted to named constants -- [ ] No deeply nested conditionals (max 3 levels) -- [ ] No "clever" code sacrificing readability for brevity -- [ ] All private methods have underscore prefix - -### Architecture & Organization -- [ ] Functions and methods follow single responsibility principle -- [ ] Proper abstraction levels - high-level doesn't mix with low-level details -- [ ] No code duplication - shared logic extracted appropriately -- [ ] File and class organization follows project structure -- [ ] No circular dependencies between modules -- [ ] Inheritance hierarchies are shallow and purposeful -- [ ] Public/private visibility is appropriate - -### Error Handling & Edge Cases -- [ ] Error messages provide actionable information -- [ ] Consistent error handling patterns across similar operations -- [ ] No empty catch blocks or silently swallowed exceptions -- [ ] Collection operations handle empty collections gracefully -- [ ] Boundary conditions explicitly handled (null, zero, empty, max) -- [ ] Methods have predictable, consistent return types - -### Testing -- [ ] Test descriptions clearly express what is being tested -- [ ] Tests validate behavior, not implementation details -- [ ] Tests use factories, not fixtures -- [ ] No hardcoded IDs or database-generated values in tests - -### Security -- [ ] No credentials, tokens, or secrets in code -- [ ] User input is validated and sanitized -- [ ] Strong parameters properly configured -- [ ] SQL queries use parameterization - -### Maintenance -- [ ] TODO comments include ticket numbers -- [ ] No commented-out code without justification -- [ ] No temporary code or experimental features being merged -- [ ] Configuration values externalized - ---- - -## Performance Considerations - -### Query Optimization -- **N+1 Queries:** Always prevent using `includes`, `preload`, or `eager_load` -- **Database Indexes:** Add indexes for foreign keys and commonly queried fields -- **Batch Processing:** Use `find_each` and `in_batches` for large datasets -- **Bulk Operations:** Use `update_all` and `insert_all` instead of iterating -- **Select Specific Columns:** Use `pluck` or `select` when you don't need full records -- **EXISTS Queries:** Use `exists?` instead of loading associations to check presence - -### Memoization -Use memoization for expensive computations within request cycles: - -```ruby -def _current_subscription - @current_subscription ||= account.subscriptions.active.first -end - -# For methods that might return nil or false -def _cached_feature_flag - return @cached_feature_flag if defined?(@cached_feature_flag) - - @cached_feature_flag = FeatureFlag.enabled?(:new_dashboard) -end -``` - -### Caching -- Use Solid Cache for Rails caching needs -- Fragment caching for expensive view partials -- Low-level caching for computed values -- Cache invalidation strategy must be explicit - -### Background Jobs -- Use Solid Queue for expensive operations -- Keep jobs idempotent and focused -- Use appropriate queue priorities -- Monitor job performance and failures - ---- - -## Security Best Practices - -### Environment Variables & Secrets -- **NEVER read `.env` files** - they contain sensitive credentials, API keys, and secrets -- Use `.env.example` as reference for required environment variables -- For environment configuration questions, refer to `.env.example` only -- All secrets must be stored in environment variables, never hardcoded -- **NEVER read Rails credentials files directly** (`config/credentials.yml.enc`, `config/master.key`) - -### Rails Credentials Access -When you need credential values for documentation or examples: -```ruby -# Access credentials in code (never read the encrypted file directly) -Rails.application.credentials.dig(:sendgrid, :api_key) -Rails.application.credentials.secret_key_base - -# Environment-specific credentials -Rails.application.credentials.dig(Rails.env.to_sym, :database, :password) -``` - -### Strong Parameters -Controllers handling user input MUST implement strong parameters: - -- **Include StrongParameters concern** for common patterns -- **Define explicit permit lists** for each action -- **Use `params.require(:model).permit(...)`** for standard CRUD -- **Use `params.expect(model: [...])` in Rails 8+** for enhanced type safety -- **Never use `params.permit!`** in production code - -Example: -```ruby -class LeadsController < ApplicationController - private - - def _lead_params - params.require(:lead).permit(:email, :first_name, :last_name, :phone_number, :status) - end -end -``` - -### Additional Security Measures -- **SQL Injection:** Use parameterized queries, never interpolate user input -- **Authentication:** Rodauth for authentication (NOT Devise) `[PROJECT-SPECIFIC]` -- **Authorization:** Pundit for authorization policies -- **Security Scanning:** Run `bundle exec rake appfactory:test:brakeman` regularly -- **CSRF:** Ensure CSRF protection is enabled (Rails default) -- **XSS:** Use Rails' built-in escaping, be careful with `html_safe` - ---- - -## Error Handling - -- **Service Pattern:** All services return structured responses with `errors` and `valid?` -- **I18n:** Use translations for all error messages from `config/locales/` -- **Error Responses:** Follow standard HTTP status codes -- **Logging:** Include appropriate logging for debugging -- **User Communication:** Provide clear, helpful error messages via I18n -- **Exception Classes:** Create specialized exception classes for domain errors - -```ruby -# Custom exception example -module Errors - class InvalidLeadError < StandardError - attr_reader :lead, :validation_errors - - def initialize(lead, validation_errors) - @lead = lead - @validation_errors = validation_errors - super("Lead #{lead.id} is invalid: #{validation_errors.full_messages.join(', ')}") - end - end -end -``` - ---- - -## Logging Standards - -### Log Levels -- **debug:** Detailed diagnostic information for development -- **info:** General operational events (requests, jobs started/completed) -- **warn:** Unexpected but handled situations -- **error:** Errors that need attention but don't crash the app -- **fatal:** Critical errors causing app termination - -### What to Log -```ruby -# Service entry/exit with context -Rails.logger.info("Starting lead import", import_id: import.id, row_count: rows.count) -Rails.logger.info("Lead import completed", import_id: import.id, created: created_count, failed: failed_count) - -# External API calls -Rails.logger.info("SendGrid API request", endpoint: "/mail/send", recipient: email) -Rails.logger.warn("SendGrid API retry", attempt: attempt, error: error.message) - -# Errors with context -Rails.logger.error("Lead creation failed", account_id: account.id, errors: lead.errors.full_messages) -``` - -### What NOT to Log -- Passwords, tokens, API keys, or secrets -- Full credit card numbers or sensitive PII -- Large request/response bodies (log summary instead) -- Health check requests (too noisy) - ---- - -## API Conventions `[OPTIONAL - Remove if not building APIs]` - -### Response Structure -```json -// Success response -{ - "data": { - "id": "123", - "type": "lead", - "attributes": { - "email": "user@example.com", - "status": "active" - } - }, - "meta": { - "request_id": "abc-123", - "timestamp": "2024-01-15T10:30:00Z" - } -} - -// Error response -{ - "error": { - "code": "validation_failed", - "message": "The request could not be processed", - "details": [ - { "field": "email", "message": "is invalid" } - ] - }, - "meta": { - "request_id": "abc-123" - } -} - -// Collection response -{ - "data": [...], - "meta": { - "total_count": 100, - "page": 1, - "per_page": 25 - }, - "links": { - "next": "/api/leads?page=2", - "prev": null - } -} -``` - -### HTTP Status Codes -- `200 OK` - Successful GET, PUT, PATCH -- `201 Created` - Successful POST creating a resource -- `204 No Content` - Successful DELETE -- `400 Bad Request` - Malformed request syntax -- `401 Unauthorized` - Missing or invalid authentication -- `403 Forbidden` - Authenticated but not authorized -- `404 Not Found` - Resource doesn't exist -- `422 Unprocessable Entity` - Validation errors -- `500 Internal Server Error` - Unexpected server error - ---- - -## Browser Automation & E2E Testing - -When browser automation is needed (testing pages, filling forms, taking screenshots, validating UX, testing login flows, etc.), use the Playwright skill: - -``` -skill: "playwright-skill" -``` - -This skill provides complete browser automation with Playwright. It can: -- Auto-detect dev servers -- Write clean test scripts to /tmp -- Test pages and fill forms -- Take screenshots -- Check responsive design -- Validate UX flows -- Test login flows and authentication -- Check links and automate any browser task - ---- - -## General Principles - -- **Readability:** Favor readable, maintainable, and idiomatic code over clever solutions -- **SOLID Principles:** Apply SOLID principles, especially Single Responsibility (ServiceWrapper pattern) -- **DRY:** Don't Repeat Yourself, but don't abstract prematurely -- **YAGNI:** You Aren't Gonna Need It - avoid over-engineering -- **Sandy Metz Rules:** Small methods, small classes, tell don't ask -- **Clarification:** If uncertain about requirements or implementation, ask before proceeding -- **Browser Testing:** Always explicitly close the browser when done: `mcp__playwright__browser_close()` -- **Process Cleanup:** CRITICAL - Always stop any process that is started during the prompt: - - **BEFORE starting work:** Check for running processes: `ps aux | grep -E "ruby|rails|puma|node" | grep -v grep` - - **AFTER work is complete:** Kill ALL processes started during this session: `pkill -f puma` - - **EVERY Rails server, background job, or Node process you start MUST be stopped before the prompt ends** - - Use `pkill -f puma` to stop Rails servers, `pkill -f node` for Node processes - - This MUST be done every single time to prevent resource leaks and port conflicts - - Do NOT leave background processes running when the task is complete -- **Test File Cleanup:** ALWAYS delete any test files created during debugging unless explicitly told to keep them: - - Delete test HTML files created in public/ - - Delete any temporary test scripts or files - - Clean up immediately after verification is complete - - Only keep test files if user explicitly says "save" or "keep" - ---- - -## Deployment Instructions - -- **Heroku:** NEVER push to Heroku unless explicitly confirmed with "yes" or "no" from the user `[PROJECT-SPECIFIC]` - ---- - -## Template Customization Guide - -When using this CLAUDE.md as a template for a new project, update the following: - -### Required Updates -1. **Ruby/Rails versions** in Code Style section -2. **Essential Commands** - update rake task namespaces -3. **Project Directory Structure** - add/remove directories based on patterns used -4. **Authentication Architecture** - update if not using Rodauth -5. **Frontend Architecture** - update based on actual stack -6. **Email Services** - update provider and service class names -7. **Deployment Instructions** - update for your deployment platform - -### Optional Sections to Remove -- `[OPTIONAL]` marked sections if not applicable: - - Input Objects (if not parsing complex params) - - Presenter Objects (if views are simple) - - Builder Objects (if not constructing complex objects) - - Value Objects (if not using domain value types) - - Null Objects (if not using null object pattern) - - API Conventions (if not building APIs) - -### Template Markers Reference -- `[PROJECT-SPECIFIC]` - Must change for each project -- `[OPTIONAL]` - Remove entire section if not used -- `[CUSTOMIZE]` - Adapt list/values to project needs diff --git a/aitooling/claude/settings.json b/aitooling/claude/settings.json deleted file mode 100644 index 4f23ca86..00000000 --- a/aitooling/claude/settings.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/claude-code-settings.json", - "enabledPlugins": { - "frontend-design@claude-plugins-official": true, - "playwright@claude-plugins-official": true, - "code-simplifier@claude-plugins-official": true - }, - "feedbackSurveyState": { - "lastShownTime": 1754090209216 - }, - "sandboxing": { - "Read": { - "deny": [ - "**/.env", - "**/.env.*", - "**/config/secrets.yml", - "**/config/credentials.yml.enc", - "**/config/master.key", - "**/.myconfigurations.private", - "**/*.pem", - "**/*.key", - "**/*.p12", - "**/*.pfx", - "**/*.secret", - "**/*.secrets", - "**/*credentials*", - "~/.ssh/**", - "~/.gnupg/**", - "**/.aws/credentials", - "**/.aws/config", - "**/secrets/**", - "**/*.keystore", - "**/*.jks", - ".myconfigurations.private" - ] - }, - "Bash": { - "deny": [ - "**/chmod **/.env*", - "**/chmod **/config/secrets.yml", - "**/chmod **/config/credentials.yml.enc", - "**/chmod **/config/master.key", - "**/chmod **/.myconfigurations.private", - "**/cat **/.env*", - "**/cat **/config/secrets.yml", - "**/cat **/config/credentials.yml.enc", - "**/head **/.env*", - "**/tail **/.env*", - "**/less **/.env*", - "**/more **/.env*", - "**/vim **/.env*", - "**/nano **/.env*", - ".myconfigurations.private" - ] - } - } -} \ No newline at end of file diff --git a/aitooling/setup.rb b/aitooling/setup.rb deleted file mode 100644 index 5d65b346..00000000 --- a/aitooling/setup.rb +++ /dev/null @@ -1,24 +0,0 @@ -PATHS ||= [] -PATHS.concat([ - { - claude: [ - { - source: "$HOME/.claude/settings.json", - destination: "$HOME/.myconfigurations/aitooling/claude/settings.json" - }, - { - source: "$HOME/.claude/CLAUDE.md", - destination: "$HOME/.myconfigurations/aitooling/claude/CLAUDE.md" - } - ] - } -]) - - - - - - - - - diff --git a/aitooling/prompts/ciso_application_security_analysis_prompt.md b/applications/claude/prompts/ciso-application-security-analysis.md similarity index 100% rename from aitooling/prompts/ciso_application_security_analysis_prompt.md rename to applications/claude/prompts/ciso-application-security-analysis.md diff --git a/applications/claude/prompts/code-review.md b/applications/claude/prompts/code-review.md new file mode 100644 index 00000000..4fabdab4 --- /dev/null +++ b/applications/claude/prompts/code-review.md @@ -0,0 +1,94 @@ +--- +allowed-tools: Bash(gh issue view:*), Bash(gh search:*), Bash(gh issue list:*), Bash(gh pr comment:*), Bash(gh pr diff:*), Bash(gh pr view:*), Bash(gh pr list:*), mcp__github_inline_comment__create_inline_comment +description: Code review a pull request +--- + +Provide a code review for the given pull request. + +**Agent assumptions (applies to all agents and subagents):** +- All tools are functional and will work without error. Do not test tools or make exploratory calls. Make sure this is clear to every subagent that is launched. +- Only call a tool if it is required to complete the task. Every tool call should have a clear purpose. + +To do this, follow these steps precisely: + +1. Launch a haiku agent to return a list of file paths (not their contents) for all relevant CLAUDE.md files including: + - The root CLAUDE.md file, if it exists + - Any CLAUDE.md files in directories containing files modified by the pull request + +2. Launch a sonnet agent to view the pull request and return a summary of the changes + +3. Launch 4 agents in parallel to independently review the changes. Each agent should return the list of issues, where each issue includes a description and the reason it was flagged (e.g. "CLAUDE.md adherence", "bug"). The agents should do the following: + + Agents 1 + 2: CLAUDE.md compliance sonnet agents + Audit changes for CLAUDE.md compliance in parallel. Note: When evaluating CLAUDE.md compliance for a file, you should only consider CLAUDE.md files that share a file path with the file or parents. + + Agent 3: Opus bug agent (parallel subagent with agent 4) + Scan for obvious bugs. Focus only on the diff itself without reading extra context. Flag only significant bugs; ignore nitpicks and likely false positives. Do not flag issues that you cannot validate without looking at context outside of the git diff. + + Agent 4: Opus bug agent (parallel subagent with agent 3) + Look for problems that exist in the introduced code. This could be security issues, incorrect logic, etc. Only look for issues that fall within the changed code. + + **CRITICAL: We only want HIGH SIGNAL issues.** Flag issues where: + - The code will fail to compile or parse (syntax errors, type errors, missing imports, unresolved references) + - The code will definitely produce wrong results regardless of inputs (clear logic errors) + - Clear, unambiguous CLAUDE.md violations where you can quote the exact rule being broken + + Do NOT flag: + - Code style or quality concerns + - Potential issues that depend on specific inputs or state + - Subjective suggestions or improvements + + If you are not certain an issue is real, do not flag it. False positives erode trust and waste reviewer time. + + In addition to the above, each subagent should be told the PR title and description. This will help provide context regarding the author's intent. + +4. For each issue found in the previous step by agents 3 and 4, launch parallel subagents to validate the issue. These subagents should get the PR title and description along with a description of the issue. The agent's job is to review the issue to validate that the stated issue is truly an issue with high confidence. For example, if an issue such as "variable is not defined" was flagged, the subagent's job would be to validate that is actually true in the code. Another example would be CLAUDE.md issues. The agent should validate that the CLAUDE.md rule that was violated is scoped for this file and is actually violated. Use Opus subagents for bugs and logic issues, and sonnet agents for CLAUDE.md violations. + +5. Filter out any issues that were not validated in step 4. This step will give us our list of high signal issues for our review. + +6. If issues were found, skip to step 7 to post inline comments directly. + + If NO issues were found, output to terminal only: + "No issues found. Checked for bugs and CLAUDE.md compliance." + +7. Create a list of all comments that you plan on leaving. This is only for you to make sure you are comfortable with the comments. Do not post this list anywhere. + +8. Post inline comments for each issue using `mcp__github_inline_comment__create_inline_comment`. For each comment: + - Provide a brief description of the issue + - For small, self-contained fixes, include a committable suggestion block + - For larger fixes (6+ lines, structural changes, or changes spanning multiple locations), describe the issue and suggested fix without a suggestion block + - Never post a committable suggestion UNLESS committing the suggestion fixes the issue entirely. If follow up steps are required, do not leave a committable suggestion. + + **IMPORTANT: Only post ONE comment per unique issue. Do not post duplicate comments.** + +Use this list when evaluating issues in Steps 3 and 4 (these are false positives, do NOT flag): + +- Pre-existing issues +- Something that appears to be a bug but is actually correct +- Pedantic nitpicks that a senior engineer would not flag +- Issues that a linter will catch (do not run the linter to verify) +- General code quality concerns (e.g., lack of test coverage, general security issues) unless explicitly required in CLAUDE.md +- Issues mentioned in CLAUDE.md but explicitly silenced in the code (e.g., via a lint ignore comment) + +Notes: + +- Use gh CLI to interact with GitHub (e.g., fetch pull requests, create comments). Do not use web fetch. +- Create a todo list before starting. +- You must cite and link each issue in inline comments (e.g., if referring to a CLAUDE.md, include a link to it). +- If no issues are found, output the following format to terminal: + +--- + +## Code review + +No issues found. Checked for bugs and CLAUDE.md compliance. + +--- + +- When linking to code in inline comments, follow the following format precisely, otherwise the Markdown preview won't render correctly: https://github.com/anthropics/claude-code/blob/c21d3c10bc8e898b7ac1a2d745bdc9bc4e423afe/package.json#L10-L15 + - Requires full git sha + - You must provide the full sha. Commands like `https://github.com/owner/repo/blob/$(git rev-parse HEAD)/foo/bar` will not work, since your comment will be directly rendered in Markdown. + - Repo name must match the repo you're code reviewing + - # sign after the file name + - Line range format is L[start]-L[end] + - Provide at least 1 line of context before and after, centered on the line you are commenting about (eg. if you are commenting about lines 5-6, you should link to `L4-7`) diff --git a/applications/claude/scripts/claude-session-pull.sh b/applications/claude/scripts/claude-session-pull.sh new file mode 100755 index 00000000..1545e15a --- /dev/null +++ b/applications/claude/scripts/claude-session-pull.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# claude-session-pull.sh +# Claude Code SessionStart hook. +# Pulls latest chezmoi state (including sessions from other machines). +# Blocks on conflict; warns on network/other failures. + +command -v chezmoi >/dev/null 2>&1 || exit 0 + +# Detect stale rebase from previous failed sync +if chezmoi git -- status 2>&1 | grep -q "rebase in progress"; then + echo "[chezmoi-sync] Previous rebase still in progress" >&2 + echo " To abort: chezmoi git -- rebase --abort" >&2 + echo " To continue: chezmoi git -- rebase --continue" >&2 + exit 1 +fi + +output=$(chezmoi update --force 2>&1) +[ $? -eq 0 ] && exit 0 + +# Conflict vs network failure +if echo "$output" | grep -qi -e "conflict" -e "could not apply" -e "rebase"; then + echo "[chezmoi-sync] Conflict detected:" >&2 + echo "$output" >&2 + echo "Resolve: chezmoi git -- status" >&2 + echo "Skip: chezmoi git -- rebase --abort" >&2 + exit 1 +fi + +echo "[chezmoi-sync] chezmoi update failed (network?), skipping" >&2 +exit 0 diff --git a/applications/claude/scripts/claude-session-push.sh b/applications/claude/scripts/claude-session-push.sh new file mode 100755 index 00000000..88d06c15 --- /dev/null +++ b/applications/claude/scripts/claude-session-push.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# claude-session-push.sh +# Claude Code Stop hook. +# Selectively adds the most recent session per project into chezmoi. +# chezmoi auto git handles add/commit/push automatically. + +command -v chezmoi >/dev/null 2>&1 || exit 0 +[ -d "$HOME/.claude" ] || exit 0 + +LOCKFILE="/tmp/claude-session-push.lock" +MAX_WAIT=30 + +# --- Serialize concurrent Stop hooks (multiple sessions stopping at once) --- +waited=0 +while [ -f "$LOCKFILE" ] && [ "$waited" -lt "$MAX_WAIT" ]; do + sleep 1 + waited=$((waited + 1)) +done +[ -f "$LOCKFILE" ] && rm -f "$LOCKFILE" + +touch "$LOCKFILE" +trap 'rm -f "$LOCKFILE"' EXIT + +# --- Don't push if a rebase is stuck --- +if chezmoi git -- status 2>&1 | grep -q "rebase in progress"; then + echo "[chezmoi-sync] Rebase in progress — skipping push" >&2 + exit 0 +fi + +# --- Pull first to minimize conflicts --- +chezmoi git -- pull --rebase --autostash origin main 2>/dev/null || \ +chezmoi git -- pull --rebase --autostash origin master 2>/dev/null || true + +# --- Build list of files to sync --- +files_to_add=() + +# Always sync settings +[ -f "$HOME/.claude/settings.json" ] && files_to_add+=("$HOME/.claude/settings.json") + +# For each project, add the most recent session + its index +for project_dir in "$HOME/.claude/projects"/*/; do + [ -d "$project_dir" ] || continue + + # sessions-index.json (needed for the picker) + [ -f "${project_dir}sessions-index.json" ] && files_to_add+=("${project_dir}sessions-index.json") + + # Find the most recently modified .jsonl (the active session) + latest_jsonl=$(find "$project_dir" -maxdepth 1 -name "*.jsonl" -not -name "agent-*" -type f -print0 \ + | xargs -0 ls -t 2>/dev/null | head -1) + + if [ -n "$latest_jsonl" ]; then + files_to_add+=("$latest_jsonl") + + # If there's a matching UUID directory, include it too + session_id=$(basename "$latest_jsonl" .jsonl) + [ -d "${project_dir}${session_id}" ] && files_to_add+=("${project_dir}${session_id}") + fi +done + +# --- Add everything in one pass (triggers one auto commit+push cycle) --- +if [ ${#files_to_add[@]} -gt 0 ]; then + chezmoi add "${files_to_add[@]}" 2>/dev/null || true +fi + +exit 0 diff --git a/applications/claude/setup.rb b/applications/claude/setup.rb new file mode 100755 index 00000000..668c848e --- /dev/null +++ b/applications/claude/setup.rb @@ -0,0 +1,30 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# ruby "$HOME/.myconfigurations/applications/claude/setup.rb" + +require "highline" +require_relative "../../lib/helpers/setup_helper" + +# Guard: this script must only run inside a git project directory. +unless system( "git rev-parse --git-dir > /dev/null 2>&1" ) + cli = HighLine.new + cli.say( "<%= color( 'ERROR: Must be run inside a git project directory!', :red ) %>" ) + exit( 1 ) +end + +PATHS = [] # rubocop:disable Style/MutableConstant +PATHS.push( + { + claude: [ + { + source: ".claude/settings.local.json", + destination: "$HOME/.myconfigurations.private/claude/local/settings.local.json", + } + ], + }, +) + +# Process all collected paths +SetupHelper.process_paths( PATHS ) + diff --git a/aitooling/claude/project_brain_files/.keep b/applications/docker/.keep similarity index 100% rename from aitooling/claude/project_brain_files/.keep rename to applications/docker/.keep diff --git a/applications/docker/templates/kafka.yml b/applications/docker/templates/kafka.yml new file mode 100644 index 00000000..6391e7b2 --- /dev/null +++ b/applications/docker/templates/kafka.yml @@ -0,0 +1,16 @@ +services: + kafka: + image: apache/kafka:3.9.0 + hostname: kafka + ports: + - 9092:9092 + environment: + CLUSTER_ID: 5L6g3nShT-eMCtK--X86sw + KAFKA_NODE_ID: 1 + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 diff --git a/applications/setup.rb b/applications/setup.rb index 3743f857..00a6cc5c 100644 --- a/applications/setup.rb +++ b/applications/setup.rb @@ -1,34 +1,40 @@ -PATHS ||= [] -PATHS.concat([ +# frozen_string_literal: true + +PATHS.push( { tmux: [ { source: "$HOME/.tmux.conf", - destination: "$HOME/.myconfigurations/applications/tmux/conf" + destination: "$HOME/.myconfigurations/applications/tmux/conf", + }, + { + source: "$HOME/.myconfigurations/applications/tmux/paths.json", + destination: "$HOME/.myconfigurations.private/tmux/paths.json", + } + ], + claude: [ + { + source: "$HOME/.claude/settings.json", + destination: "$HOME/.myconfigurations.private/claude/global/settings.json", + }, + { + source: "$HOME/.claude/CLAUDE.md", + destination: "$HOME/.myconfigurations.private/claude/brains/global/CLAUDE.md", } ], vscode: [ { source: "$HOME/Library/Application Support/Code/User/settings.json", - destination: "$HOME/.myconfigurations/applications/vscode/settings.json" + destination: "$HOME/.myconfigurations/applications/vscode/settings.json", }, { source: "$HOME/Library/Application Support/Code/User/keybindings.json", - destination: "$HOME/.myconfigurations/applications/vscode/keybindings.json" + destination: "$HOME/.myconfigurations/applications/vscode/keybindings.json", }, { source: "$HOME/Library/Application Support/Code/User/snippets/ruby.json", - destination: "$HOME/.myconfigurations/applications/vscode/snippets/ruby.json" + destination: "$HOME/.myconfigurations/applications/vscode/snippets/ruby.json", } - ] - } -]) - - - - - - - - - + ], + }, +) diff --git a/applications/vscode/settings.json b/applications/vscode/settings.json index 0929a02c..c37d0698 100644 --- a/applications/vscode/settings.json +++ b/applications/vscode/settings.json @@ -105,6 +105,7 @@ }, "workbench.preferredHighContrastColorTheme": "RailsCasts Plus Theme", "gitlens.ai.model": "vscode", - "gitlens.ai.vscode.model": "anthropic:claude-4.5-opus" + "gitlens.ai.vscode.model": "anthropic:claude-4.5-opus", + "claudeCode.preferredLocation": "panel" // ------------------------------------------------------------------- -} +} \ No newline at end of file diff --git a/config/rubocop/layout.yml b/config/rubocop/layout.yml new file mode 100644 index 00000000..af908f7d --- /dev/null +++ b/config/rubocop/layout.yml @@ -0,0 +1,42 @@ +Layout/ClassStructure: + ExpectedOrder: + - constant + - module_inclusion + - attribute + - public_class_method + - public_method + - private_attribute + - protected_method + - private_method + +Layout/MultilineMethodCallIndentation: + EnforcedStyle: indented_relative_to_receiver + +Layout/ArgumentAlignment: + EnforcedStyle: with_first_argument + +Layout/MultilineMethodArgumentLineBreaks: + AllowMultilineFinalElement: true + +Layout/HashAlignment: + EnforcedColonStyle: key + EnforcedHashRocketStyle: key + AllowMultipleStyles: false + +Layout/SingleLineBlockChain: + Exclude: + - "spec/**/*" + +Layout/SpaceInsideParens: + EnforcedStyle: space + +Layout/RedundantLineBreak: + Enabled: false + +Layout/SpaceInsideArrayLiteralBrackets: + EnforcedStyle: space + +Layout/LineLength: + Max: 180 + Exclude: + - spec/**/*.rb diff --git a/config/rubocop/metrics.yml b/config/rubocop/metrics.yml new file mode 100644 index 00000000..4dee0ce4 --- /dev/null +++ b/config/rubocop/metrics.yml @@ -0,0 +1,20 @@ +Metrics/BlockLength: + Exclude: + - spec/**/* + - lib/helpers/setup_helper.rb + +Metrics/MethodLength: + Exclude: + - lib/helpers/setup_helper.rb + +Metrics/AbcSize: + Exclude: + - lib/helpers/setup_helper.rb + +Metrics/CyclomaticComplexity: + Exclude: + - lib/helpers/setup_helper.rb + +Metrics/PerceivedComplexity: + Exclude: + - lib/helpers/setup_helper.rb diff --git a/config/rubocop/rspec.yml b/config/rubocop/rspec.yml new file mode 100644 index 00000000..9930c98c --- /dev/null +++ b/config/rubocop/rspec.yml @@ -0,0 +1,47 @@ +# Review the detailed rspec cops here! +# https://github.com/backus/rubocop-rspec + +RSpec/NestedGroups: + Max: 10 + +RSpec/AlignRightLetBrace: + Enabled: false + +RSpec/AlignLeftLetBrace: + Enabled: false + +RSpec/Pending: + Enabled: false + +RSpec/PendingWithoutReason: + Enabled: false + +RSpec/AnyInstance: + Enabled: false + +RSpec/DescribeMethod: + Enabled: false + +RSpec/ExampleLength: + Enabled: false + +RSpec/MessageExpectation: + Enabled: false + +RSpec/MessageSpies: + Enabled: false + +RSpec/MessageChain: + Enabled: false + +RSpec/MultipleMemoizedHelpers: + Max: 10 + +RSpec/MultipleExpectations: + Enabled: false + +RSpec/SpecFilePathFormat: + Enabled: false + +RSpec/StubbedMock: + Enabled: false diff --git a/config/rubocop/style.yml b/config/rubocop/style.yml new file mode 100644 index 00000000..dcb3a737 --- /dev/null +++ b/config/rubocop/style.yml @@ -0,0 +1,46 @@ +Style/BlockDelimiters: + Enabled: true + EnforcedStyle: line_count_based + AllowedMethods: + - lambda + - proc + - it + - let + - subject + - before + - after + - scope + - include_context + - include_examples + +Style/MissingElse: + Enabled: false + +Style/DocumentationMethod: + Enabled: false + +Style/Copyright: + Enabled: false + +Style/Documentation: + Enabled: false + +Style/ClassAndModuleChildren: + EnforcedStyle: compact + +Style/DisableCopsWithinSourceCodeDirective: + Enabled: false + +Style/StringLiterals: + EnforcedStyle: double_quotes + +Style/MethodCallWithArgsParentheses: + EnforcedStyle: require_parentheses + Exclude: + - spec/**/* + +Style/TrailingCommaInArguments: + EnforcedStyleForMultiline: consistent_comma + +Style/TrailingCommaInHashLiteral: + EnforcedStyleForMultiline: consistent_comma diff --git a/docs/installation/001.md b/docs/installation/001.md index 19baea97..3969c68e 100644 --- a/docs/installation/001.md +++ b/docs/installation/001.md @@ -1,18 +1,18 @@ -### Setup a New or Reformatting an Existing Apple Workstation : 001 +### Set Up a New or Reformat an Existing Apple Workstation : 001 -> If you need to reinstall the operating system follow "[How to install macOS](https://support.apple.com/en-us/HT204904)" from the Apple Support documentation. +> If you need to reinstall the operating system, follow "[How to install macOS](https://support.apple.com/en-us/HT204904)" from the Apple Support documentation. -1. Following this [guide from Apple](https://support.apple.com/en-us/HT204904) reboot into the system utilities and completely wipe the hard drive and reinstall the operating system. This guide use this step: `On an Intel-based Mac, if you use Shift-Option-Command-R during startup, you're offered the macOS that came with your Mac, or the closest version still available. If you use Option-Command-R during startup, in most cases you're offered the latest macOS that is compatible with your Mac. Otherwise you're offered the macOS that came with your Mac, or the closest version still available.` -2. When the format utility for erasing the hard drive prompts for name for the drive, I name it `RBRHD` during the reformat process, and the format is `APFS`. Please note, I turn on filevault after the full reformat to keep the drive key synced with iCloud, and SSD type drives do not have the option to zero out like older drives. +1. Following this [guide from Apple](https://support.apple.com/en-us/HT204904) reboot into the system utilities and completely wipe the hard drive and reinstall the operating system. This guide uses this step: `On an Intel-based Mac, if you use Shift-Option-Command-R during startup, you're offered the macOS that came with your Mac, or the closest version still available. If you use Option-Command-R during startup, in most cases you're offered the latest macOS that is compatible with your Mac. Otherwise you're offered the macOS that came with your Mac, or the closest version still available.` +2. When the format utility for erasing the hard drive prompts for a name for the drive, I name it `RBRHD` during the reformat process, and the format is `APFS`. Please note, I turn on FileVault after the full reformat to keep the drive key synced with iCloud, and SSD type drives do not have the option to zero out like older drives. 3. Once the drive is reformatted, reinstall macOS using your home network, and iCloud account. I also allow my Apple ID to reset my workstation password. 4. Under `Customize Settings` I `Enable Location Services` and do not share `Mac Analytics`, `Crash Data`, `Ask Siri`, and I uncheck `Store files from Documents and Desktop in iCloud Drive`. 5. Under `Customize Settings` I make sure to turn on `FileVault disk encryption` and `Allow my iCloud account to unlock my disk`. -6. Open the disc utility and if you notice an internal volume named `Update` delete it via this [guide from Apple](https://support.apple.com/guide/disk-utility/add-erase-or-delete-apfs-volumes-dskua9e6a110/mac), then restart the computer. Please note, this may also cause a warning `Incompatible Disc` to be displayed. If this occurs, you will need repeat steps 1 through 5 following [this video walkthrough](https://youtu.be/HFo9mTfTk9I). Once these steps have been completed you should be successfully connected to your wifi. +6. Open the Disk Utility and if you notice an internal volume named `Update` delete it via this [guide from Apple](https://support.apple.com/guide/disk-utility/add-erase-or-delete-apfs-volumes-dskua9e6a110/mac), then restart the computer. Please note, this may also cause a warning `Incompatible Disk` to be displayed. If this occurs, you will need to repeat steps 1 through 5 following [this video walkthrough](https://youtu.be/HFo9mTfTk9I). Once these steps have been completed you should be successfully connected to your wifi. 7. Under the `App Store` in System Preferences, make sure `Automatically Check for Updates` is checked, and `Download new available updates in the background` and `Install system data files and security updates` are both checked. 8. Click `Show Updates` and run all pending updates. -9. Install the full `Xcode` developer package from the Apple App Store. I use the full package because it comes with both the gcc compiler and emulators for iOS devices. These come in handy for testing responsive websites and applications. Please note, this will require the latest version of the osx operating system, and if `FileVault` has not finished encrypting the hard drive you will have to wait for that to finish. The status can be located under `Settings and Privacy` then under `FileVault`. +9. Install the full `Xcode` developer package from the Apple App Store. I use the full package because it comes with both the gcc compiler and emulators for iOS devices. These come in handy for testing responsive websites and applications. Please note, this will require the latest version of the macOS operating system, and if `FileVault` has not finished encrypting the hard drive you will have to wait for that to finish. The status can be located under `Settings and Privacy` then under `FileVault`. 10. Open `Xcode` to approve the EULA, and install the iOS components because we want the iOS emulator. Do not install the predictive code completion model at this time. -11. Open `Terminal` and associate the developer tools with the installed version of `Xcode` via `sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer` Now verify this is working by `xcode-select -p` which should output `/Applications/Xcode.app/Contents/Developer` +11. Open `Terminal` and associate the developer tools with the installed version of `Xcode` via `sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer`. Now verify this is working by `xcode-select -p` which should output `/Applications/Xcode.app/Contents/Developer` 12. Under `System Preferences` under `Users & Groups` verify or disable the `Guest User` from being able to access the computer, then reboot the machine to confirm. 13. In `System Preferences` under the `Trackpad` disable `Natural` scrolling. 14. In `System Preferences` under the `Trackpad` under `Point and Click` set the tracking speed to `6`. @@ -34,11 +34,11 @@ * Under `Mission Control` uncheck all other settings in this panel. * Under `Display` set `Decrease Display Brightness` to `F1` * Under `Display` set `Increase Display Brightness` to `F2` -24. Remove every application icon from the dock except finder on the bottom left, and the trash can on the bottom right! +24. Remove every application icon from the dock except Finder on the bottom left, and the trash can on the bottom right! 25. In `System Preferences` configure the `Dock & Menu Bar`. * Enable `Magnification` and set it to `max`. * Enable `Automatically hide and show the Dock`. - * Adjust the size the menu to `4`. + * Adjust the size of the menu to `4`. 26. Setup at least `4 OSX Spaces` via `Mission Control`. 27. In `System Preferences` in `Keyboard & Mouse Shortcuts` in `Mission Control` desktop switches to use `ctrl + #` for the first 4 spaces. > If the list doesn't show all your Spaces, quit System Settings completely (Command + Q), reopen it, and check again—sometimes it needs a refresh after adding new Spaces. @@ -62,14 +62,17 @@ 32. Right click on the desktop and access advanced show options. * Check `Always open in list view` * Check `Always Snap to Grid` -33. Create a folder named `TMP` on the desktop under the hard disc. -34. Send screenshots to the Desktop's `TMP` folder via: `defaults write com.apple.screencapture location $HONE/Desktop/TMP` followed by `killall SystemUIServer`. -35. In the `Terminal` application run `defaults write com.apple.iCal "n days of week" 14` to set the osx calendar to display 14 days in week view. -36. Is the `Calendar` application, under `Advanced` check the box to `Turn on time zone support`. +33. Create a symlink named `WIP` on the Desktop pointing to the iCloud Drive WIP folder via: `ln -s ~/Library/Mobile\ Documents/com~apple~CloudDocs/WIP ~/Desktop/WIP` +34. Send screenshots to the Desktop's `WIP` folder via: `defaults write com.apple.screencapture location "$HOME/Library/Mobile Documents/com~apple~CloudDocs/WIP"` followed by `killall SystemUIServer`. +35. In the `Terminal` application run `defaults write com.apple.iCal "n days of week" 14` to set the macOS calendar to display 14 days in week view. +36. In the `Calendar` application, under `Advanced` check the box to `Turn on time zone support`. 37. In `System Preferences` in `Notifications` adjust these applications' settings. * Under `Messages` disable play sound for notification. 38. In `Mail` in `Settings` adjust these applications' settings. * Under `Viewing` show most recent message at the top. +39. In `System Preferences` in `Desktop & Dock` under `Widgets` settings. + * Set `Dim widgets on desktop` to never. + * Set `IPhone widgets` to diabled. ### Running a Desktop? diff --git a/docs/installation/002.md b/docs/installation/002.md index f78f4523..d98d083a 100644 --- a/docs/installation/002.md +++ b/docs/installation/002.md @@ -1,12 +1,12 @@ -### Install Additional Business amd Workflow Software : 002 +### Install Additional Business and Workflow Software : 002 > Make note of applications installed from their websites versus the `App Store` 1. Install [1Password](https://1password.com/downloads/mac), once installed disable the keyboard shortcut for `Show Quick Access`. 2. Download and Install [Chrome](https://www.google.com/chrome/browser/desktop/index.html), [Firefox](https://www.mozilla.org/en-US/firefox/new/), and [Brave](https://github.com/chrishough/my-configurations). -3. Configure the syncing accounts for Firefox, Brave, and Google, and configure downloads to dump into the Desktop's `TMP` directory. Verify `1 Password` has been installed to each of them. +3. Configure the syncing accounts for Firefox, Brave, and Google, and configure downloads to dump into the Desktop's `WIP` directory. Verify `1Password` has been installed to each of them. 4. Setup `Internet Accounts` that are synced across all Apple Devices. - * Open ups Mail to confirm. + * Open up Mail to confirm. * In Mail add the `All Junk` to the `Favorites`. * In Mail add the `All Trash` to the `Favorites`. * Disable the `iCloud Mail` account. @@ -14,6 +14,9 @@ * [Skitch](https://evernote.com/products/skitch) * [Harvest](https://www.getharvest.com/) * [Slack](https://slack.com ) + * Numbers + * Pages + * Keynote 6. From the Web install and after each installation open to configure. * [AlfredApp](https://www.alfredapp.com/), and set to open via `cmd + shift + space`. * [Obsidian](https://obsidian.md/) @@ -32,7 +35,7 @@ * Install `Photoshop` * Install `Illustrator` * Install `Acrobat` -7. Setup `OSX Messages` for all accounts. Verify non-apple users too. +7. Setup `OSX Messages` for all accounts. Verify non-Apple users too. 8. Configure Divvy Shortcuts: `RHT`, `LFT`, `CENTER`, `FULL`, `TMP`. 9. Organize the applications in the `Finder` panel alphabetically. diff --git a/docs/installation/003.md b/docs/installation/003.md index 04d9a056..fcfbcd44 100644 --- a/docs/installation/003.md +++ b/docs/installation/003.md @@ -1,8 +1,8 @@ ### Engineering Workstation Setup and Configuration : 003 -> XCode Must be Installed and Configured prior to this setup! +> Xcode Must be Installed and Configured prior to this setup! -1. Install [iTerm2](https://www.iterm2.com/). **Critical** In the `Keys` settings, make sure these keys are configured. +1. Install [iTerm2](https://www.iterm2.com/). **Critical:** In the `Keys` settings, make sure these keys are configured. * `Send: "\n"` via keyboard shortcut `shift + return` via `Send Text` * `Send: "0x0C 0x1B 0x63"` via keyboard shortcut `command+k` via `Send Hex` 2. Install [homebrew](http://brew.sh/). @@ -10,10 +10,10 @@ 4. Update brew via `brew update`. 5. Install the latest version of git via `brew install git`. 6. Setup the `$HOME/.gitconfig` file with account information. -``` +```ini [user] name = Chris Hough - email = { users's email address } + email = { user's email address } [mergetool] keepBackup = true [core] @@ -37,7 +37,7 @@ defaultBranch = master ``` 7. Setup your new `machine specific ssh keys`, and make sure to setup the `$HOME/.ssh/config`, then run `ssh-add --apple-use-keychain ~/.ssh/{ private key name not .pub }` so keys can be executed globally without having to type the passphrase in again and again. -``` +```ini Host github.com HostName github.com User git @@ -46,50 +46,59 @@ Host github.com AddKeysToAgent yes UseKeychain yes ``` -8. Clone this repo `git clone git@github.com:chrishough/my-configurations.git .myconfigurations` into the `$HOME` directory. - * Setup the `.myconfigurations.private` file for private keys and ENV settings. +8. Clone these private repos into the `$HOME` directory. + * `git clone git@github.com:chrishough/my-configurations-private.git .myconfigurations.private` + * `git clone git@github.com:chrishough/my-configurations-private-sync.git .myconfigurations.private.sync` +> I am documenting this as part of my flow, but will not be public. Also, steps in the process need this cloned, but will not be fully utilized until later steps. +9. Clone this repo `git clone git@github.com:chrishough/my-configurations.git .myconfigurations` into the `$HOME` directory. + * Setup the `.myconfigurations.private.keys` file for private keys and ENV settings. * Install [oh-my-zsh](https://github.com/robbyrussell/oh-my-zsh) `sh -c "$(curl -fsSL https://raw.githubusercontent.com/robbyrussell/oh-my-zsh/master/tools/install.sh)"` theme for `zsh`. * Run the install script `sh "$HOME/.myconfigurations/lib/install.sh"` to load all of the required brew packages. - * Setup the latest version of Ruby `rbenv install 3.4.3`, set the local machine to run it via `rbenv local 3.4.3`, and make sure Bundler `gem install bundler`, PRY `gem install pry`, and rsense `gem install rsense` are installed. If you get error installing these gemes run `source ~/.zshrc`. + * Setup the `github` dependency via `gh auth login`. + * Setup the latest version of Ruby `rbenv install 3.4.3`, set the local machine to run it via `rbenv local 3.4.3`, and make sure Bundler `gem install bundler`, PRY `gem install pry`, and rsense `gem install rsense` are installed. If you get an error installing these gems run `source ~/.zshrc`. * Run the ruby script to map all of our shell settings to the dotfile repository files `ruby "$HOME/.myconfigurations/lib/setup.rb"`. * Install the latest version of node to preload nvm `nvm install --lts`. -> If the `.zprofile` and or the `.zshrc` files fail to create a symlink, delete them and run the script again to configure them. -9. Create the rbenv plugin directory `mkdir $(rbenv root)/plugins`. +> If the `.zprofile` and/or the `.zshrc` files fail to create a symlink, delete them and run the script again to configure them. +10. Create the rbenv plugin directory `mkdir $(rbenv root)/plugins`. * Install [rbenv-default-gems](https://github.com/rbenv/rbenv-default-gems) plugin `git clone https://github.com/rbenv/rbenv-default-gems.git $(rbenv root)/plugins/rbenv-default-gems`. * Install [rbenv/rbenv-gem-rehash](https://github.com/rbenv/rbenv-gem-rehash) plugin `git clone https://github.com/sstephenson/rbenv-gem-rehash.git $(rbenv root)/plugins/rbenv-gem-rehash`. * Install [rbenv-vars](https://github.com/rbenv/rbenv-vars) plugin `git clone https://github.com/rbenv/rbenv-vars.git $(rbenv root)/plugins/rbenv-vars`. * Install [rbenv-update](https://github.com/rkh/rbenv-update) plugin `git clone https://github.com/rkh/rbenv-update.git $(rbenv root)/plugins/rbenv-update`. -10. Verify global rbenv vars are setup correctly. Type `nano $HOME/.rbenv/vars` and confirm the following. Adjust if necessary. Once completed, type `rbenv vars` and you should see `export GEM_PATH='bundle'`. That is **all** that should be in there at this time! -``` +11. Verify global rbenv vars are setup correctly. Type `nano $HOME/.rbenv/vars` and confirm the following. Adjust if necessary. Once completed, type `rbenv vars` and you should see `export GEM_PATH='bundle'`. That is **all** that should be in there at this time! +```ini #GLOBAL GEM_PATH=.bundle ``` -11. Install [Docker Desktop](https://www.docker.com/get-started/). +12. Install [Docker Desktop](https://www.docker.com/get-started/). * `dkrup opensearch1315 pg169 pg175 redis7` install current images. * Stop all via `dkrstopall`. -12. Install the [Jet Brains Toolbox](https://www.jetbrains.com/toolbox-app/). +13. Install the [JetBrains Toolbox](https://www.jetbrains.com/toolbox-app/). * Install `RubyMine`, and sync settings from the cloud to match previous workstation. * Install `DataGrip` for DB interactions. -13. Setup [eslint](https://eslint.org) via `npm install -g eslint`. -14. Install [puma-dev](https://github.com/puma/puma-dev) via `brew install puma/puma/puma-dev && puma-dev -install -d localhost && sudo puma-dev -setup` -15. Setup Alfred theme and workflows inside `applications/alfred` in this repo. -16. Setup the [Fira Code Fonts](https://fonts.google.com/specimen/Fira+Code) from inside `assets/fonts` in this repo. -17. Configure `iTerm2` to match previous installations. +14. Setup [eslint](https://eslint.org) via `npm install -g eslint`. +15. Install [puma-dev](https://github.com/puma/puma-dev) via `brew install puma/puma/puma-dev && puma-dev -install -d localhost && sudo puma-dev -setup` +16. Setup Alfred theme and workflows inside `applications/alfred` in this repo. +17. Setup the [Fira Code Fonts](https://fonts.google.com/specimen/Fira+Code) from inside `assets/fonts` in this repo. +18. Configure `iTerm2` to match previous installations. * Setup [Base16 iTerm2](https://github.com/chriskempson/base16-iterm2) from inside `assets/colors` in this repo, and set it to use `base16-railscasts.dark`. * In `General` set the Initial Directory to `Reuse previous session's directory`. * In `Text` change the font to `Fira Code`. - * In `Terminal` check `Unlimited Scrollback`, verify the `Report Terminal Type` is `xterm-256color`, and verify `Character Encoding` is set to `Unicode( UTF-8 ) - * In `Window` set the `Transparency` to `10`. -18. Setup and Install [VSCODE](https://code.visualstudio.com/). + * In `Terminal` check `Unlimited Scrollback`, verify the `Report Terminal Type` is `xterm-256color`, and verify `Character Encoding` is set to `Unicode( UTF-8 )` + * In `Window` set the `Transparency` to `10`. +19. Install and Set Up [VSCODE](https://code.visualstudio.com/). * Install the `shell command` via the `command palette`. * Configure `sync` via `GitHub`. -19. Configure the [Keyboard Maestro](https://www.keyboardmaestro.com/main/) macro library. +20. Configure the [Keyboard Maestro](https://www.keyboardmaestro.com/main/) macro library. * Desktop and Laptops will have very different settings, for example the laptop will have the brightness controls, the desktop may not, but both will have the player controls. +21. Install [TeamViewer](https://www.teamviewer.com/en-us/download/macos/) on every machine. This will be used to remote control the desktops including the isolated AI assistant. + * [Lock it Up!](https://www.teamviewer.com/en-us/insights/unattended-access-security/) Immediately via 2FA. + * Setup `allow list` with only my TeamViewer account blocking any external users. +22. Under `System Preferences` in `Network` enable the `Firewall` ### Running a Desktop? -* If Install [Istat Menus](https://bjango.com/mac/istatmenus/) and configure `Bluetooth Battery`, `CPU` and `Memory Performance`, and `Network Traffic`. -* If Install [VMWARE Fusion](https://www.vmware.com/products/desktop-hypervisor/workstation-and-fusion) for debugging applications inside of the Windows Ecosystem. +* Install [Istat Menus](https://bjango.com/mac/istatmenus/) and configure `Bluetooth Battery`, `CPU` and `Memory Performance`, and `Network Traffic`. +* Install [VMWARE Fusion](https://www.vmware.com/products/desktop-hypervisor/workstation-and-fusion) for debugging applications inside the Windows ecosystem. * Install and configure `Windows 11` * Inside `Windows 11` download and install `Google Chrome`. * Configure the virtual machine for `Windows 11` to use bridged networking and to select automatically. diff --git a/docs/installation/004.md b/docs/installation/004.md index 20c6af35..f1289c6b 100644 --- a/docs/installation/004.md +++ b/docs/installation/004.md @@ -1,17 +1,116 @@ ### Artificial Intelligence Preferences and Configurations : 004 -> This section builds on top of sections 1 through 4. Those must be run first! - -1. Install [Claude CLI](https://code.claude.com/docs/en/setup#npm). - * Via NPM `npm install -g @anthropic-ai/claude-code`. - * Install `npx claude-plugins install @anthropics/claude-plugins-official/playwright`. - * Install `npx claude-plugins install @anthropics/claude-plugins-official/code-simplifier`. - * Install `npx claude-plugins install @anthropics/claude-code-plugins/frontend-design`. -2. Run the setup script to correctly setup mappings `ruby "$HOME/.myconfigurations/lib/setup.rb"`. +> At this point the private repository `.myconfigurations.private` and `.myconfigurations.private.sync` should have been configured in previous steps as it is required at this point. + +* `.myconfigurations.private` : this contains all of my private settings and I use it as a middle layer between it and the automated sync so I can evaluate any changes that tooling may have added. I like to see them even though they are auto committed. I can revert when applicable and that is also auto synced. +* `.myconfigurations.private.sync` : this repository contains all of the auto synced and committed files processed via `watchman` and `chezmoi` and through scripts that utilize `chezmoi`. + +This setup has already installed... +* [chezmoi](https://www.chezmoi.io/) +* [watchman](https://facebook.github.io/watchman/) + +Now for what we need to accomplish syncing... +1. Run the setup script to correctly setup mappings `ruby "$HOME/.myconfigurations/lib/setup.rb"`. * Once this has been loaded Claude will be symlinked to the project, restart it. +2. Configure `watchman` plus `chezmoi` synchronization settings for AI tooling and infrastructure. + * `mkdir .config/chezmoi` + * `nano .config/chezmoi/chezmoi.toml` + ```ini + sourceDir = "~/.myconfigurations.private.sync" + + [git] + autoAdd = true + autoCommit = true + autoPush = true + ``` +3. Now let's setup the files we want to sync automagically! + +```bash +chezmoi add $HOME/.myconfigurations.private/claude/local/settings.local.json +chezmoi add $HOME/.myconfigurations.private/claude/global/settings.json +``` +4. Setup watchman triggers so core files are always kept in sync across devices. + +```bash +watchman watch ~/.myconfigurations.private/claude + +echo '["trigger", "/Users/christopherhough/.myconfigurations.private/claude", { + "name": "chezmoi-re-add", + "expression": ["anyof", + ["name", "local/settings.local.json", "wholename"], + ["name", "global/settings.json", "wholename"] + ], + "command": ["/opt/homebrew/bin/chezmoi", "re-add"] +}]' | watchman -j +``` + +5. Install `peon-ping-setup` for our peon pings that fire during claude workflows, and mirro configuration `~/.claude/hooks/peon-ping/config.json`. +``` +{ + "active_pack": "peon", + "volume": 1.0, + "enabled": true, + "desktop_notifications": true, + "categories": { + "session.start": true, + "task.acknowledge": true, + "task.complete": true, + "task.error": true, + "input.required": true, + "resource.limit": true, + "user.spam": true + }, + "annoyed_threshold": 3, + "annoyed_window_seconds": 10, + "silent_window_seconds": 0, + "pack_rotation": [], + "pack_rotation_mode": "random", + "notification_style": "standard" +} +``` +6. Setup multiple machine syncing, primary, secondary, and autonomous without sacrificing configurations and tooling. + +> Primary Workstation + +This machine is the parent and primary workhorse. +* Setup the synchronous scripts: `chmod +x ~/.myconfigurations/applications/claude/scripts/claude-session-*.sh` + + + + + + + + + + + + + + + + + + +> *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** + + +> Secondary Workstation + +This machine is a child and designed for mobility. +* TBD + +> Autonomous Workstation + +This machine is a child and designed for autonomous labor. +* TBD + + -#### Favorite Skills in Claude -* frontend-design@claude-plugins-official -* playwright@claude-plugins-official -* code-simplifier@claude-plugins-official +Install [Claude CLI](https://code.claude.com/docs/en/setup#npm). +* Via NPM `npm install -g @anthropic-ai/claude-code`. +* Install `npx claude-plugins install @anthropics/claude-plugins-official/playwright`. +* Install `npx claude-plugins install @anthropics/claude-plugins-official/code-simplifier`. +* Install `npx claude-plugins install @anthropics/claude-code-plugins/frontend-design`. +* Install `npx claude-plugins install @anthropics/claude-code-plugins/code-review`. diff --git a/dotfiles/.pryrc b/dotfiles/.pryrc index 5db880c0..fb040674 100644 --- a/dotfiles/.pryrc +++ b/dotfiles/.pryrc @@ -1,15 +1,17 @@ +# frozen_string_literal: true + begin Pry.config.default_window_size = 25 Pry.config.pager = false -rescue - puts 'gem pry is not installed!' +rescue StandardError + puts( "gem pry is not installed!" ) end begin - Pry.commands.alias_command 'c', 'continue' - Pry.commands.alias_command 's', 'step' - Pry.commands.alias_command 'n', 'next' - Pry.commands.alias_command 'e', 'exit' -rescue - puts 'gem pry-nav is not installed!' + Pry.commands.alias_command( "c", "continue" ) + Pry.commands.alias_command( "s", "step" ) + Pry.commands.alias_command( "n", "next" ) + Pry.commands.alias_command( "e", "exit" ) +rescue StandardError + puts( "gem pry-nav is not installed!" ) end diff --git a/dotfiles/.zshrc b/dotfiles/.zshrc index f53f95c2..fa7d07a2 100644 --- a/dotfiles/.zshrc +++ b/dotfiles/.zshrc @@ -43,14 +43,14 @@ source $ZSH/oh-my-zsh.sh # disable auto correct unsetopt correct_all -# source shared functions -source $HOME/.myconfigurations/dotfiles/functions/functions - export PATH="$HOME/.rbenv/bin:$PATH" export PATH="$HOME/.local/bin:$PATH" export PATH="/opt/homebrew/opt/libpq/bin:$PATH" export PATH="/opt/homebrew/bin:$PATH" +# source shared functions +source $HOME/.myconfigurations/dotfiles/functions/functions + eval "$(direnv hook zsh)" # Hide Direnv Output in the Shells... diff --git a/dotfiles/functions/collections/ai b/dotfiles/functions/collections/ai new file mode 100644 index 00000000..f0c50aae --- /dev/null +++ b/dotfiles/functions/collections/ai @@ -0,0 +1,32 @@ +#!/bin/zsh + +function csync() { + ruby "$HOME/.myconfigurations/applications/claude/setup.rb" +} + +function cm() { chezmoi managed } +function cra() { chezmoi re-add } + +function wl() { watchman watch-list } +function wts() { watchman trigger-list } +function wtsa() { + for root in $(watchman watch-list | grep -o '"\/[^"]*"' | tr -d '"'); do + echo "=== $root ===" + watchman trigger-list "$root" + done +} + +function aihelp () { + echo + echo "--------------------------------------------------------------------------------" + echo + echo " csync = initialize claude sync" + echo " cm = chezmoi managed" + echo " cra = chezmoi re-add" + echo " wl = watchman watch-list" + echo " wts = watchman trigger-list" + echo " wtsa = watchman trigger-list all" + echo + echo "--------------------------------------------------------------------------------" + echo +} diff --git a/dotfiles/functions/collections/divvy b/dotfiles/functions/collections/divvy index 426c0885..f382a625 100644 --- a/dotfiles/functions/collections/divvy +++ b/dotfiles/functions/collections/divvy @@ -1,7 +1,6 @@ # DVY - Dynamic tmux layout manager # Reads layouts from ~/.myconfigurations/applications/tmux/paths.json - -_DVY_PATHS_FILE="$HOME/.myconfigurations/applications/tmux/paths.json" +_DVY_PATHS_FILE="$HOME/.myconfigurations.private/tmux/paths.json" _dvy_next_session_name() { local base="$1" @@ -16,13 +15,42 @@ _dvy_next_session_name() { echo "${base}_${i}" } -# Layout: apps +# Layout: l1_r2 +# +--------+---------+ +# | | 1 | +# | 0 +---------+ +# | | 2 | +# +--------+---------+ +_dvy_layout_l1_r2() { + local session="$1" + local dir="$2" + local config="$3" + + tmux new-session -d -s "$session" -c "$dir" -x $(tput cols) -y $(tput lines) \; \ + splitw -h -p 55 -c "$dir" \; \ + splitw -v -p 50 -c "$dir" \; \ + select-layout -E + + # Send commands to each pane (clear, custom command if specified, clear) + for pane in 0 1 2; do + tmux send-keys -t "$session:0.$pane" 'clear' Enter + local cmd=$(echo "$config" | jq -r --arg p "$pane" '.[$p] // empty') + if [[ -n "$cmd" ]]; then + tmux send-keys -t "$session:0.$pane" "$cmd" Enter + tmux send-keys -t "$session:0.$pane" 'clear' Enter + fi + done + + tmux attach -t "$session" +} + +# Layout: l1_r3 # +--------+----+----+ # | | 1 | 2 | # | 0 +----+----+ # | | 3 | # +--------+---------+ -_dvy_layout_apps() { +_dvy_layout_l1_r3() { local session="$1" local dir="$2" local config="$3" @@ -47,7 +75,7 @@ _dvy_layout_apps() { tmux attach -t "$session" } -# Layout: configurations +# Layout: l1_r4 # +--------+---------+ # | | 1 | # | +---------+ @@ -57,7 +85,7 @@ _dvy_layout_apps() { # | +---------+ # | | 4 | # +--------+---------+ -_dvy_layout_configurations() { +_dvy_layout_l1_r4() { local session="$1" local dir="$2" local config="$3" @@ -82,6 +110,49 @@ _dvy_layout_configurations() { tmux attach -t "$session" } +# Layout: l2_r4 +# +--------+---------+ +# | 0 | 1 | +# | +---------+ +# | | 2 | +# +--------+---------+ +# | | 3 | +# | 5 +---------+ +# | | 4 | +# +--------+---------+ +_dvy_layout_l2_r4() { + local session="$1" + local dir="$2" + local config="$3" + + # Create session with pane 0 + tmux new-session -d -s "$session" -c "$dir" -x $(tput cols) -y $(tput lines) + + # Split horizontally: pane 1 on right (50%) + tmux split-window -h -p 50 -t "$session:0" -c "$dir" + + # Split right pane (1) vertically 3 times + tmux split-window -v -p 75 -t "$session:0.1" -c "$dir" + tmux split-window -v -p 66 -t "$session:0.2" -c "$dir" + tmux split-window -v -p 50 -t "$session:0.3" -c "$dir" + + # Split left pane (0) vertically once + tmux split-window -v -p 50 -t "$session:0.0" -c "$dir" + + tmux select-layout -t "$session:0" -E + + for pane in 0 1 2 3 4 5; do + tmux send-keys -t "$session:0.$pane" 'clear' Enter + local cmd=$(echo "$config" | jq -r --arg p "$pane" '.[$p] // empty') + if [[ -n "$cmd" ]]; then + tmux send-keys -t "$session:0.$pane" "$cmd" Enter + tmux send-keys -t "$session:0.$pane" 'clear' Enter + fi + done + + tmux attach -t "$session" +} + _dvy_list() { echo echo "-------------------------------------------" @@ -201,11 +272,17 @@ dvy() { session=$(_dvy_next_session_name "$session") case "$layout" in - apps) - _dvy_layout_apps "$session" "$dir" "$config" + l1_r2) + _dvy_layout_l1_r2 "$session" "$dir" "$config" + ;; + l1_r3) + _dvy_layout_l1_r3 "$session" "$dir" "$config" ;; - configurations) - _dvy_layout_configurations "$session" "$dir" "$config" + l1_r4) + _dvy_layout_l1_r4 "$session" "$dir" "$config" + ;; + l2_r4) + _dvy_layout_l2_r4 "$session" "$dir" "$config" ;; *) echo "Error: Unknown layout type: $layout" @@ -233,14 +310,21 @@ dvyhelp() { echo echo " Layout types:" echo - echo " apps - 4-pane layout" + echo " l1_r2 - 3-pane layout (1 left, 2 right)" + echo " +--------+---------+" + echo " | | 1 |" + echo " | 0 +---------+" + echo " | | 2 |" + echo " +--------+---------+" + echo + echo " l1_r3 - 4-pane layout (1 left, 3 right)" echo " +--------+----+----+" echo " | | 1 | 2 |" echo " | 0 +----+----+" echo " | | 3 |" echo " +--------+---------+" echo - echo " configurations - 5-pane layout" + echo " l1_r4 - 5-pane layout (1 left, 4 right)" echo " +--------+---------+" echo " | | 1 |" echo " | +---------+" @@ -251,6 +335,17 @@ dvyhelp() { echo " | | 4 |" echo " +--------+---------+" echo + echo " l2_r4 - 6-pane layout (2 left, 4 right)" + echo " +--------+---------+" + echo " | 0 | 1 |" + echo " | +---------+" + echo " | | 2 |" + echo " +--------+---------+" + echo " | | 3 |" + echo " | 5 +---------+" + echo " | | 4 |" + echo " +--------+---------+" + echo echo "-------------------------------------------" echo } diff --git a/dotfiles/functions/collections/ruby b/dotfiles/functions/collections/ruby index 56531586..f7f9501e 100644 --- a/dotfiles/functions/collections/ruby +++ b/dotfiles/functions/collections/ruby @@ -6,7 +6,7 @@ export RUBYOPT='-W:no-deprecated -W:no-experimental' export BUNDLER_EDITOR="vscode" export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES -eval "$(rbenv init - zsh)" +command -v rbenv &>/dev/null && eval "$(rbenv init - zsh)" alias rb="rbenv" alias rbl="rbenv install -l" diff --git a/dotfiles/functions/functions b/dotfiles/functions/functions index 0da365bf..00b4089f 100644 --- a/dotfiles/functions/functions +++ b/dotfiles/functions/functions @@ -1,7 +1,7 @@ #!/bin/zsh # SETTINGS AND PRIVATE VARIABLES : NEVER CHECK IN THIS FILE! -source $HOME/.myconfigurations.private +source $HOME/.myconfigurations.private.keys # ADJUST SHELL HISTORY export HISTFILESIZE=10000 @@ -15,7 +15,7 @@ export BROWSER='open -a /Applications/Google Chrome.app' export EDITOR=vim # IMPORT FUNCTIONS GROUPS -_myconfig_modules=(ruby docker git brew python heroku js shell divvy) +_myconfig_modules=(ruby docker git brew python heroku js shell divvy ai) for _module in "${_myconfig_modules[@]}"; do source "$HOME/.myconfigurations/dotfiles/functions/collections/$_module" @@ -30,7 +30,9 @@ export AUTOJUMP_IGNORE_CASE=1 # ZSH AUTOCOMPLETE autoload -U compinit && compinit -[[ -s `brew --prefix`/etc/autojump.sh ]] && . `brew --prefix`/etc/autojump.sh +if command -v brew &>/dev/null; then + [[ -s $(brew --prefix)/etc/autojump.sh ]] && . $(brew --prefix)/etc/autojump.sh +fi # ZSH HELP function help() { diff --git a/dotfiles/setup.rb b/dotfiles/setup.rb index 09fbd102..61e006f6 100644 --- a/dotfiles/setup.rb +++ b/dotfiles/setup.rb @@ -1,48 +1,40 @@ -PATHS ||= [] -PATHS.concat([ +# frozen_string_literal: true + +PATHS.push( { dotfiles: [ { source: "$HOME/.bash_profile", - destination: "$HOME/.myconfigurations/dotfiles/.bash_profile" + destination: "$HOME/.myconfigurations/dotfiles/.bash_profile", }, { source: "$HOME/.bashrc", - destination: "$HOME/.myconfigurations/dotfiles/.bashrc" + destination: "$HOME/.myconfigurations/dotfiles/.bashrc", }, { source: "$HOME/.gemrc", - destination: "$HOME/.myconfigurations/dotfiles/.gemrc" + destination: "$HOME/.myconfigurations/dotfiles/.gemrc", }, { source: "$HOME/.profile", - destination: "$HOME/.myconfigurations/dotfiles/.profile" + destination: "$HOME/.myconfigurations/dotfiles/.profile", }, { source: "$HOME/.pryrc", - destination: "$HOME/.myconfigurations/dotfiles/.pryrc" + destination: "$HOME/.myconfigurations/dotfiles/.pryrc", }, { source: "$HOME/.vimrc", - destination: "$HOME/.myconfigurations/dotfiles/.vimrc" + destination: "$HOME/.myconfigurations/dotfiles/.vimrc", }, { source: "$HOME/.zprofile", - destination: "$HOME/.myconfigurations/dotfiles/.zprofile" + destination: "$HOME/.myconfigurations/dotfiles/.zprofile", }, { source: "$HOME/.zshrc", - destination: "$HOME/.myconfigurations/dotfiles/.zshrc" + destination: "$HOME/.myconfigurations/dotfiles/.zshrc", } - ] - } -]) - - - - - - - - - + ], + }, +) diff --git a/lib/helpers/path_helper.rb b/lib/helpers/path_helper.rb index d499ba89..01968169 100644 --- a/lib/helpers/path_helper.rb +++ b/lib/helpers/path_helper.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + module PathHelper - def self.expand_shell_path(path) - expanded = path.gsub('$HOME', ENV['HOME']) - expanded = expanded.gsub('$USER', ENV['USER']) if ENV['USER'] - expanded = expanded.gsub('$PWD', Dir.pwd) - File.expand_path(expanded) + def self.expand_shell_path( path ) + expanded = path.gsub( "$HOME", Dir.home ) + expanded = expanded.gsub( "$USER", ENV["USER"] ) if ENV["USER"] + expanded = expanded.gsub( "$PWD", Dir.pwd ) + File.expand_path( expanded ) end -end \ No newline at end of file +end diff --git a/lib/helpers/setup_helper.rb b/lib/helpers/setup_helper.rb index 10ad404f..12d67408 100644 --- a/lib/helpers/setup_helper.rb +++ b/lib/helpers/setup_helper.rb @@ -1,47 +1,94 @@ -require 'fileutils' -require_relative 'path_helper' +# frozen_string_literal: true + +require "fileutils" +require "highline" +require_relative "path_helper" module SetupHelper - def self.process_paths(paths) + def self.process_paths( paths ) + cli = HighLine.new paths.each do |path_group| # Handle nested structure with tool names as keys (e.g., claude, claude2, etc.) path_group.each do |tool_name, tool_paths| - puts "\nProcessing paths for: #{tool_name}" - + puts( "\nProcessing paths for: #{tool_name}" ) + tool_paths.each do |path_config| - source = PathHelper.expand_shell_path(path_config[:source]) - destination = PathHelper.expand_shell_path(path_config[:destination]) - - if File.exist?(source) || File.symlink?(source) - if File.symlink?(source) - current_target = File.readlink(source) - if current_target != destination - puts " Removing incorrect symlink: #{source} -> #{current_target}" - File.delete(source) - else - puts " Symlink already correct: #{source} -> #{destination}" + source = PathHelper.expand_shell_path( path_config[:source] ) + destination = PathHelper.expand_shell_path( path_config[:destination] ) + + if File.exist?( source ) || File.symlink?( source ) + if File.symlink?( source ) + current_target = File.readlink( source ) + if current_target == destination + puts( " Symlink already correct: #{source} -> #{destination}" ) next + else + puts( " Removing incorrect symlink: #{source} -> #{current_target}" ) + File.delete( source ) end else - puts " Regular file exists at #{source}, cannot create symlink" - next + cli.say( " WARNING: Regular file exists at <%= color('#{source}', :yellow) %>" ) + if cli.agree( " Replace with symlink? " ) + cli.say( " Removing regular file: #{source}" ) + File.delete( source ) + else + cli.say( " Skipping: #{source}" ) + next + end end end - # Create source directory if it doesn't exist - source_dir = File.dirname(source) - unless File.exist?(source_dir) - puts " Creating source directory: #{source_dir}" - FileUtils.mkdir_p(source_dir) + # Guard: never create $HOME itself -- only subdirectories within it. + home_dir = Dir.home + + source_dir = File.dirname( source ) + if source_dir.start_with?( home_dir ) && !File.exist?( home_dir ) + puts( " ERROR: Home directory does not exist: #{home_dir}, skipping!" ) + next + end + + destination_dir = File.dirname( destination ) + if destination_dir.start_with?( home_dir ) && !File.exist?( home_dir ) + puts( " ERROR: Home directory does not exist: #{home_dir}, skipping!" ) + next end - destination_dir = File.dirname(destination) - FileUtils.mkdir_p(destination_dir) unless File.exist?(destination_dir) + unless File.exist?( source_dir ) + base_dir = deepest_existing_ancestor( source_dir ) + puts( " Creating source directory: #{source_dir}" ) + FileUtils.mkdir_p( source_dir ) + add_keep_files( source_dir, base_dir ) + end - puts " Creating symlink: #{source} -> #{destination}" - File.symlink(destination, source) + unless File.exist?( destination_dir ) + base_dir = deepest_existing_ancestor( destination_dir ) + FileUtils.mkdir_p( destination_dir ) + add_keep_files( destination_dir, base_dir ) + end + + puts( " Creating symlink: #{source} -> #{destination}" ) + File.symlink( destination, source ) end end end end + + def self.deepest_existing_ancestor( dir ) + current = dir + current = File.dirname( current ) until File.exist?( current ) || current == "/" || current == "." + current + end + + def self.add_keep_files( dir, base_dir ) + current = dir + dirs_to_keep = [] + while current != base_dir && current != "/" && current != "." + dirs_to_keep << current + current = File.dirname( current ) + end + dirs_to_keep.each do |d| + keep_file = File.join( d, ".keep" ) + FileUtils.touch( keep_file ) unless File.exist?( keep_file ) + end + end end diff --git a/lib/install.sh b/lib/install.sh index 718ff737..3b94fb9a 100644 --- a/lib/install.sh +++ b/lib/install.sh @@ -1,5 +1,28 @@ #!/bin/zsh -# sh "$HOME/.myconfigurations/lib/install.sh" +# zsh "$HOME/.myconfigurations/lib/install.sh" + +# Function to install brew tap packages if they don't exist +install_brew_taps() { + # Array of tap packages in "user/tap/formula" format + local tap_packages=( + "PeonPing/tap/peon-ping" + ) + + echo "Installing tap packages..." + for tap_package in "${tap_packages[@]}"; do + local tap="${tap_package%/*}" + local formula_name="${tap_package##*/}" + + if brew list --formula | grep -q "^${formula_name}\$"; then + echo "✓ ${formula_name} is already installed" + else + echo "Tapping ${tap}..." + brew tap "${tap}" + echo "Installing ${tap_package}..." + brew install "${tap_package}" + fi + done +} # Function to install brew packages if they don't exist install_brew_packages() { @@ -27,12 +50,17 @@ install_brew_packages() { "pkg-config" "libpq" "gitleaks" + "chezmoi" + "gh" + "terminal-notifier" + "watchman" + "ngrok" ) echo "Running brew update..." brew update - # Loop through packages and install if not present + echo "Loop through packages and install if not present..." for package in "${packages[@]}"; do if brew list --formula | grep -q "^${package}\$"; then echo "✓ ${package} is already installed" @@ -47,5 +75,6 @@ install_brew_packages() { brew doctor } -# Run the installation function +# Run the installation functions +install_brew_taps install_brew_packages diff --git a/lib/setup.rb b/lib/setup.rb old mode 100644 new mode 100755 index 5f912c09..ca85ae38 --- a/lib/setup.rb +++ b/lib/setup.rb @@ -1,15 +1,16 @@ #!/usr/bin/env ruby +# frozen_string_literal: true + # ruby "$HOME/.myconfigurations/lib/setup.rb" -require_relative 'helpers/setup_helper' +require_relative "helpers/setup_helper" -# Initialize PATHS array -PATHS = [] +# Initialize PATHS array -- must remain mutable so setup files can push into it. +PATHS = [] # rubocop:disable Style/MutableConstant +require_relative "../applications/setup" # Load all setup files - they will append to PATHS -require_relative '../aitooling/setup' -require_relative '../dotfiles/setup' -require_relative '../applications/setup' +require_relative "../dotfiles/setup" # Process all collected paths -SetupHelper.process_paths(PATHS) +SetupHelper.process_paths( PATHS ) diff --git a/readme.md b/readme.md index ed4105a0..e2b109ef 100644 --- a/readme.md +++ b/readme.md @@ -13,7 +13,6 @@ These guides are highly opinionated. If you have any questions please post an is 2. [Install Additional Business amd Workflow Software](/docs/installation/002.md) 3. [Engineering Workstation Setup and Configuration](/docs/installation/003.md) 4. [Artificial Intelligence Preferences and Configurations](/docs/installation/004.md) -5. Under `System Preferences` in `Network` to enable the `Firewall` #### Setup Scripts Reference @@ -44,12 +43,12 @@ ruby "$HOME/.myconfigurations/lib/setup.rb" **Idempotency:** The `SetupHelper` module checks each path before acting: - Correct symlink exists → skips (no action) - Wrong symlink exists → removes old, creates correct -- Regular file exists → skips (preserves user files) +- Regular file exists → prompts to approve/decline replacement - Nothing exists → creates parent directories and symlink **Managed Symlinks:** -| Category | Source (System Location) | Destination (Repository) | +| Category | Source (System Location) | Destination (Config Store) | |----------|--------------------------|--------------------------| | dotfiles | `~/.bash_profile` | `dotfiles/.bash_profile` | | dotfiles | `~/.bashrc` | `dotfiles/.bashrc` | @@ -59,9 +58,10 @@ ruby "$HOME/.myconfigurations/lib/setup.rb" | dotfiles | `~/.vimrc` | `dotfiles/.vimrc` | | dotfiles | `~/.zprofile` | `dotfiles/.zprofile` | | dotfiles | `~/.zshrc` | `dotfiles/.zshrc` | -| claude | `~/.claude/settings.json` | `aitooling/claude/settings.json` | -| claude | `~/.claude/CLAUDE.md` | `aitooling/claude/CLAUDE.md` | | tmux | `~/.tmux.conf` | `applications/tmux/conf` | +| tmux | `applications/tmux/paths.json` | `~/.myconfigurations.private/tmux/paths.json` | +| claude | `~/.claude/settings.json` | `~/.myconfigurations.private/claude/global/settings.json` | +| claude | `~/.claude/CLAUDE.md` | `~/.myconfigurations.private/claude/brains/global/CLAUDE.md` | | vscode | `~/Library/.../User/settings.json` | `applications/vscode/settings.json` | | vscode | `~/Library/.../User/keybindings.json` | `applications/vscode/keybindings.json` | | vscode | `~/Library/.../User/snippets/ruby.json` | `applications/vscode/snippets/ruby.json` | diff --git a/spec/helpers/path_helper_spec.rb b/spec/helpers/path_helper_spec.rb new file mode 100644 index 00000000..5719c65c --- /dev/null +++ b/spec/helpers/path_helper_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require "helpers/path_helper" + +# PathHelper.expand_shell_path takes a path string that may contain shell-style +# variables ($HOME, $USER, $PWD) and returns a fully expanded absolute path. +# It performs simple string substitution -- not a full shell eval -- then delegates +# to File.expand_path for final resolution. +RSpec.describe PathHelper do + describe ".expand_shell_path" do + # -------------------------------------------------------------------------- + # $HOME expansion + # The method replaces the literal string "$HOME" with ENV['HOME']. + # This is the most common variable used in the project's setup configs + # (e.g., "$HOME/.tmux.conf" -> "/Users/christopherhough/.tmux.conf"). + # -------------------------------------------------------------------------- + context "when the path contains $HOME" do + it "replaces $HOME with the HOME environment variable" do + path = "$HOME/.config/settings.json" + result = described_class.expand_shell_path( path ) + + expect( result ).to eq( File.join( Dir.home, ".config/settings.json" ) ) + end + + # Paths from applications/setup.rb use $HOME in both source and destination, + # e.g., "$HOME/.claude/settings.json". Verify multiple $HOME tokens in a + # single string are all replaced. + it "replaces multiple occurrences of $HOME" do + path = "$HOME/from/$HOME/to" + result = described_class.expand_shell_path( path ) + + expected = File.join( Dir.home, "from", Dir.home, "to" ) + expect( result ).to eq( expected ) + end + end + + # -------------------------------------------------------------------------- + # $USER expansion + # The method replaces "$USER" with ENV['USER'], but only when that + # environment variable is set. This guards against nil substitution. + # -------------------------------------------------------------------------- + context "when the path contains $USER" do + it "replaces $USER with the USER environment variable" do + path = "/home/$USER/projects" + result = described_class.expand_shell_path( path ) + + expect( result ).to eq( "/home/#{ENV.fetch( 'USER', nil )}/projects" ) + end + end + + # When ENV['USER'] is nil (e.g., inside some containerized environments), + # the $USER token must be left untouched so it doesn't blow up. + context 'when ENV["USER"] is nil' do + around do |example| + original_user = ENV.fetch( "USER", nil ) + ENV.delete( "USER" ) + example.run + ensure + ENV["USER"] = original_user + end + + it "leaves $USER unexpanded in the path" do + path = "/home/$USER/projects" + result = described_class.expand_shell_path( path ) + + # File.expand_path won't touch the literal "$USER" text, so it stays. + expect( result ).to include( "$USER" ) + end + end + + # -------------------------------------------------------------------------- + # $PWD expansion + # Replaces "$PWD" with Dir.pwd -- useful when paths are relative to the + # directory the setup script is invoked from. + # -------------------------------------------------------------------------- + context "when the path contains $PWD" do + it "replaces $PWD with the current working directory" do + path = "$PWD/lib/helpers" + result = described_class.expand_shell_path( path ) + + expect( result ).to eq( File.join( Dir.pwd, "lib/helpers" ) ) + end + end + + # -------------------------------------------------------------------------- + # Combined variables + # The project's setup files can theoretically mix variables. Ensure all + # three are expanded in a single pass. + # -------------------------------------------------------------------------- + context "when the path contains multiple different variables" do + it "expands all recognized variables" do + path = "$HOME/$USER/$PWD" + result = described_class.expand_shell_path( path ) + + # After substitution the path is processed by File.expand_path, which + # resolves it to an absolute path. We check that none of the variable + # tokens remain in the result. + expect( result ).not_to include( "$HOME" ) + expect( result ).not_to include( "$USER" ) + expect( result ).not_to include( "$PWD" ) + expect( result ).to include( Dir.home ) + end + end + + # -------------------------------------------------------------------------- + # No variables -- passthrough + # A plain absolute path should be returned unchanged (after expand_path + # normalization). + # -------------------------------------------------------------------------- + context "when the path contains no variables" do + it "returns the path resolved by File.expand_path" do + path = "/usr/local/bin/tool" + result = described_class.expand_shell_path( path ) + + expect( result ).to eq( "/usr/local/bin/tool" ) + end + end + + # -------------------------------------------------------------------------- + # Relative path resolution + # File.expand_path converts relative paths to absolute ones using the + # current working directory as the base. Verify this final step works. + # -------------------------------------------------------------------------- + context "when the path is relative after substitution" do + it "resolves it to an absolute path" do + path = "relative/path/file.txt" + result = described_class.expand_shell_path( path ) + + expect( result ).to eq( File.expand_path( "relative/path/file.txt" ) ) + expect( Pathname.new( result ) ).to be_absolute + end + end + end +end diff --git a/spec/helpers/setup_helper_spec.rb b/spec/helpers/setup_helper_spec.rb new file mode 100644 index 00000000..a7a2ccf4 --- /dev/null +++ b/spec/helpers/setup_helper_spec.rb @@ -0,0 +1,573 @@ +# frozen_string_literal: true + +require "fileutils" +require "helpers/setup_helper" +require "tmpdir" + +# SetupHelper.process_paths receives a nested data structure (Array of Hashes) +# that mirrors the format used in applications/setup.rb and dotfiles/setup.rb. +# +# Example input (from applications/setup.rb): +# +# [ +# { +# tmux: [ +# { source: "$HOME/.tmux.conf", +# destination: "$HOME/.myconfigurations/applications/tmux/conf" } +# ], +# claude: [ +# { source: "$HOME/.claude/settings.json", +# destination: "$HOME/.myconfigurations.private/claude/global/settings.json" }, +# { source: "$HOME/.claude/CLAUDE.md", +# destination: "$HOME/.myconfigurations.private/claude/brains/global/CLAUDE.md" } +# ] +# } +# ] +# +# For each entry the method creates a symlink at `source` pointing to +# `destination`, creating parent directories as needed. It handles several +# edge cases: existing correct symlinks (skip), incorrect symlinks (replace), +# and regular files at the source location (skip with warning). +# +# All specs use a temporary directory so the real filesystem is never touched. +RSpec.describe SetupHelper do + describe ".process_paths" do + # Create a fresh temp directory for every example and clean it up after. + # We override $HOME so that PathHelper.expand_shell_path resolves "$HOME" + # to our sandbox instead of the real home directory. + let( :tmpdir ) { Dir.mktmpdir( "setup_helper_spec" ) } + let( :original_home ) { Dir.home } + let( :highline_instance ) { instance_double( HighLine ) } + + before do + original_home + ENV["HOME"] = tmpdir + allow( $stdout ).to receive( :write ) + allow( $stdout ).to receive( :puts ) + # Stub HighLine.new so interactive prompts don't block tests. + allow( HighLine ).to receive( :new ).and_return( highline_instance ) + allow( highline_instance ).to receive( :say ) + allow( highline_instance ).to receive( :agree ).and_return( false ) + end + + after do + ENV["HOME"] = original_home + FileUtils.rm_rf( tmpdir ) + end + + # -------------------------------------------------------------------------- + # Basic symlink creation + # The most common path: source does not exist yet, destination file exists, + # and we expect a symlink to be created at source pointing to destination. + # -------------------------------------------------------------------------- + context "when the source does not exist" do + it "creates a symlink from source to destination" do + # Simulate a destination config file that already exists in the repo. + dest_file = File.join( tmpdir, ".myconfigurations/applications/tmux/conf" ) + FileUtils.mkdir_p( File.dirname( dest_file ) ) + File.write( dest_file, "tmux config content" ) + + # This mirrors the tmux entry from applications/setup.rb. + paths = [ + { + tmux: [ + { + source: "$HOME/.tmux.conf", + destination: "$HOME/.myconfigurations/applications/tmux/conf", + } + ], + } + ] + + described_class.process_paths( paths ) + + source_path = File.join( tmpdir, ".tmux.conf" ) + + # The source should now be a symlink pointing to the destination. + expect( File.symlink?( source_path ) ).to be true + expect( File.readlink( source_path ) ).to eq( dest_file ) + end + end + + # -------------------------------------------------------------------------- + # Correct symlink already exists -- skip + # When re-running setup, existing correct symlinks should not be recreated. + # The method prints "Symlink already correct" and calls `next`. + # -------------------------------------------------------------------------- + context "when a correct symlink already exists at source" do + it "leaves the symlink unchanged" do + source_path = File.join( tmpdir, ".tmux.conf" ) + dest_file = File.join( tmpdir, ".myconfigurations/applications/tmux/conf" ) + FileUtils.mkdir_p( File.dirname( dest_file ) ) + File.write( dest_file, "tmux config" ) + + # Pre-create the correct symlink. + File.symlink( dest_file, source_path ) + + paths = [ + { + tmux: [ + { + source: "$HOME/.tmux.conf", + destination: "$HOME/.myconfigurations/applications/tmux/conf", + } + ], + } + ] + + described_class.process_paths( paths ) + + # Symlink should still exist and still point to the same destination. + expect( File.symlink?( source_path ) ).to be true + expect( File.readlink( source_path ) ).to eq( dest_file ) + end + end + + # -------------------------------------------------------------------------- + # Incorrect symlink -- replace + # If a symlink exists but points to the wrong target (e.g., after moving + # config files), the method deletes the old symlink and creates a new one. + # -------------------------------------------------------------------------- + context "when an incorrect symlink exists at source" do + it "removes the old symlink and creates a correct one" do + source_path = File.join( tmpdir, ".tmux.conf" ) + wrong_dest = File.join( tmpdir, "old/wrong/path" ) + correct_dest = File.join( tmpdir, ".myconfigurations/applications/tmux/conf" ) + + FileUtils.mkdir_p( File.dirname( wrong_dest ) ) + FileUtils.mkdir_p( File.dirname( correct_dest ) ) + File.write( correct_dest, "correct config" ) + + # Pre-create a symlink pointing to the wrong location. + File.symlink( wrong_dest, source_path ) + + paths = [ + { + tmux: [ + { + source: "$HOME/.tmux.conf", + destination: "$HOME/.myconfigurations/applications/tmux/conf", + } + ], + } + ] + + described_class.process_paths( paths ) + + # The symlink should now point to the correct destination. + expect( File.symlink?( source_path ) ).to be true + expect( File.readlink( source_path ) ).to eq( correct_dest ) + end + end + + # -------------------------------------------------------------------------- + # Regular file exists at source -- user approves replacement + # When a real file exists, HighLine prompts the user. If they approve, + # the file is removed and replaced with a symlink. + # -------------------------------------------------------------------------- + context "when a regular file exists and user approves replacement" do + it "removes the file and creates a symlink" do + source_path = File.join( tmpdir, ".tmux.conf" ) + dest_file = File.join( tmpdir, ".myconfigurations/applications/tmux/conf" ) + FileUtils.mkdir_p( File.dirname( dest_file ) ) + File.write( dest_file, "repo config" ) + + # Create a real file (not a symlink) at the source path. + File.write( source_path, "user local config" ) + + # User approves the replacement. + allow( highline_instance ).to receive( :agree ).and_return( true ) + + paths = [ + { + tmux: [ + { + source: "$HOME/.tmux.conf", + destination: "$HOME/.myconfigurations/applications/tmux/conf", + } + ], + } + ] + + described_class.process_paths( paths ) + + # The regular file should be replaced with a symlink. + expect( File.symlink?( source_path ) ).to be true + expect( File.readlink( source_path ) ).to eq( dest_file ) + end + end + + # -------------------------------------------------------------------------- + # Regular file exists at source -- user declines replacement + # When a real file exists and the user declines, the file is left intact + # and no symlink is created. + # -------------------------------------------------------------------------- + context "when a regular file exists and user declines replacement" do + it "leaves the file intact and does not create a symlink" do + source_path = File.join( tmpdir, ".tmux.conf" ) + dest_file = File.join( tmpdir, ".myconfigurations/applications/tmux/conf" ) + FileUtils.mkdir_p( File.dirname( dest_file ) ) + File.write( dest_file, "repo config" ) + + # Create a real file (not a symlink) at the source path. + File.write( source_path, "user local config" ) + + # User declines the replacement (default stub returns false). + + paths = [ + { + tmux: [ + { + source: "$HOME/.tmux.conf", + destination: "$HOME/.myconfigurations/applications/tmux/conf", + } + ], + } + ] + + described_class.process_paths( paths ) + + # The regular file should remain untouched -- no symlink created. + expect( File.symlink?( source_path ) ).to be false + expect( File.read( source_path ) ).to eq( "user local config" ) + end + end + + # -------------------------------------------------------------------------- + # Parent directory creation + # Both source and destination directories are created automatically with + # FileUtils.mkdir_p if they don't exist. This is important for first-time + # setup on a fresh machine where directories like ~/.claude/ may not exist. + # -------------------------------------------------------------------------- + context "when parent directories do not exist" do + it "creates source and destination parent directories" do + # Use a deeply nested path that definitely doesn't exist yet. + paths = [ + { + newtool: [ + { + source: "$HOME/.config/newtool/deep/settings.json", + destination: "$HOME/.myconfigurations/apps/newtool/deep/settings.json", + } + ], + } + ] + + described_class.process_paths( paths ) + + source_dir = File.join( tmpdir, ".config/newtool/deep" ) + dest_dir = File.join( tmpdir, ".myconfigurations/apps/newtool/deep" ) + + # Both directory trees should have been created. + expect( Dir.exist?( source_dir ) ).to be true + expect( Dir.exist?( dest_dir ) ).to be true + end + + # .keep files are added to newly created directories so they can be + # tracked in git. Verify both source and destination directories get one. + it "adds a .keep file to newly created source and destination directories" do + paths = [ + { + newtool: [ + { + source: "$HOME/.config/keeptest/settings.json", + destination: "$HOME/.myconfigurations/apps/keeptest/settings.json", + } + ], + } + ] + + described_class.process_paths( paths ) + + source_keep = File.join( tmpdir, ".config/keeptest/.keep" ) + dest_keep = File.join( tmpdir, ".myconfigurations/apps/keeptest/.keep" ) + + expect( File.exist?( source_keep ) ).to be true + expect( File.exist?( dest_keep ) ).to be true + end + + # When mkdir_p creates multiple directories (e.g., a/b/c/d), every + # intermediate directory must receive a .keep file -- not just the leaf. + # This ensures all directories can be tracked in git. + it "adds .keep files to all intermediate directories created by mkdir_p" do + paths = [ + { + deep: [ + { + source: "$HOME/a/b/c/d/settings.json", + destination: "$HOME/w/x/y/z/settings.json", + } + ], + } + ] + + described_class.process_paths( paths ) + + # Every intermediate source directory should have a .keep file. + %w[a a/b a/b/c a/b/c/d].each do |subpath| + keep = File.join( tmpdir, subpath, ".keep" ) + expect( File.exist?( keep ) ).to( be( true ), "expected .keep in #{subpath}" ) + end + + # Every intermediate destination directory should have a .keep file. + %w[w w/x w/x/y w/x/y/z].each do |subpath| + keep = File.join( tmpdir, subpath, ".keep" ) + expect( File.exist?( keep ) ).to( be( true ), "expected .keep in #{subpath}" ) + end + end + end + + # -------------------------------------------------------------------------- + # Deeply nested source directories within $HOME + # Mirrors the applications/claude/setup.rb pattern where the source path + # includes multiple nested directories (e.g., test/.claude/) that may not + # exist on a fresh machine. All intermediate directories after $HOME must + # be created automatically so the symlink succeeds. + # -------------------------------------------------------------------------- + context "when source has deeply nested directories that do not exist" do + it "creates all intermediate source directories and the symlink" do + # Destination file exists in the repo (simulating the config store). + dest_file = File.join( tmpdir, ".myconfigurations.private/claude/local/settings.json" ) + FileUtils.mkdir_p( File.dirname( dest_file ) ) + File.write( dest_file, "{}" ) + + # Source path mirrors applications/claude/setup.rb: nested dirs + # "test/" and "test/.claude/" do not exist yet inside $HOME. + paths = [ + { + claude: [ + { + source: "$HOME/test/.claude/settings.local.json", + destination: "$HOME/.myconfigurations.private/claude/local/settings.json", + } + ], + } + ] + + described_class.process_paths( paths ) + + source_path = File.join( tmpdir, "test/.claude/settings.local.json" ) + test_dir = File.join( tmpdir, "test" ) + claude_dir = File.join( tmpdir, "test/.claude" ) + + # Both intermediate directories should have been created. + expect( Dir.exist?( test_dir ) ).to be true + expect( Dir.exist?( claude_dir ) ).to be true + + # The symlink should have been created successfully. + expect( File.symlink?( source_path ) ).to be true + expect( File.readlink( source_path ) ).to eq( dest_file ) + end + end + + # -------------------------------------------------------------------------- + # Deeply nested destination directories within $HOME + # The destination side also uses mkdir_p. Verify that when the destination + # directory tree doesn't exist, all intermediate directories are created. + # -------------------------------------------------------------------------- + context "when destination has deeply nested directories that do not exist" do + it "creates all intermediate destination directories and the symlink" do + # Neither the source nor destination directories exist yet. + paths = [ + { + newtool: [ + { + source: "$HOME/.newtool/config.json", + destination: "$HOME/deep/nested/repo/store/config.json", + } + ], + } + ] + + described_class.process_paths( paths ) + + dest_dir = File.join( tmpdir, "deep/nested/repo/store" ) + source_path = File.join( tmpdir, ".newtool/config.json" ) + dest_path = File.join( tmpdir, "deep/nested/repo/store/config.json" ) + + # All intermediate destination directories should exist. + expect( Dir.exist?( dest_dir ) ).to be true + + # Symlink should point to the destination. + expect( File.symlink?( source_path ) ).to be true + expect( File.readlink( source_path ) ).to eq( dest_path ) + end + end + + # -------------------------------------------------------------------------- + # $HOME guard -- skip when $HOME does not exist + # SetupHelper must NEVER create the $HOME directory itself. If $HOME does + # not exist and a path is under it, the entry is skipped with an error + # message. This prevents accidentally creating a bogus home directory. + # -------------------------------------------------------------------------- + context "when $HOME does not exist" do + it "skips the entry without creating directories or symlinks" do + # Point $HOME to a path that does not exist. + fake_home = File.join( tmpdir, "nonexistent_home" ) + ENV["HOME"] = fake_home + + paths = [ + { + claude: [ + { + source: "$HOME/.claude/settings.json", + destination: "$HOME/.myconfigurations/settings.json", + } + ], + } + ] + + described_class.process_paths( paths ) + + # $HOME should NOT have been created. + expect( Dir.exist?( fake_home ) ).to be false + + # No symlink should have been created. + source_path = File.join( fake_home, ".claude/settings.json" ) + expect( File.exist?( source_path ) ).to be false + end + end + + # -------------------------------------------------------------------------- + # Multiple tools in a single path group + # The applications/setup.rb file defines tmux, claude, and vscode in a + # single hash. Verify that all tools in the group are processed. + # -------------------------------------------------------------------------- + context "when processing multiple tools in one path group" do + it "creates symlinks for every tool entry" do + # Set up destination files for two tools. + tmux_dest = File.join( tmpdir, ".myconfigurations/applications/tmux/conf" ) + claude_dest = File.join( tmpdir, ".myconfigurations.private/claude/global/settings.json" ) + + FileUtils.mkdir_p( File.dirname( tmux_dest ) ) + FileUtils.mkdir_p( File.dirname( claude_dest ) ) + File.write( tmux_dest, "tmux" ) + File.write( claude_dest, "claude" ) + + # This mirrors the structure from applications/setup.rb with two tools. + paths = [ + { + tmux: [ + { + source: "$HOME/.tmux.conf", + destination: "$HOME/.myconfigurations/applications/tmux/conf", + } + ], + claude: [ + { + source: "$HOME/.claude/settings.json", + destination: "$HOME/.myconfigurations.private/claude/global/settings.json", + } + ], + } + ] + + described_class.process_paths( paths ) + + tmux_source = File.join( tmpdir, ".tmux.conf" ) + claude_source = File.join( tmpdir, ".claude/settings.json" ) + + # Both tools should have their symlinks created. + expect( File.symlink?( tmux_source ) ).to be true + expect( File.readlink( tmux_source ) ).to eq( tmux_dest ) + + expect( File.symlink?( claude_source ) ).to be true + expect( File.readlink( claude_source ) ).to eq( claude_dest ) + end + end + + # -------------------------------------------------------------------------- + # Multiple path entries for a single tool + # Claude has two path entries (settings.json and CLAUDE.md). + # VSCode has three (settings, keybindings, snippets). + # Verify all entries under one tool key are processed. + # -------------------------------------------------------------------------- + context "when a tool has multiple path entries" do + it "creates a symlink for each path entry" do + settings_dest = File.join( tmpdir, ".myconfigurations.private/claude/global/settings.json" ) + claude_md_dest = File.join( tmpdir, ".myconfigurations.private/claude/brains/global/CLAUDE.md" ) + + FileUtils.mkdir_p( File.dirname( settings_dest ) ) + FileUtils.mkdir_p( File.dirname( claude_md_dest ) ) + File.write( settings_dest, "{}" ) + File.write( claude_md_dest, "# CLAUDE" ) + + # Mirrors the claude entry from applications/setup.rb exactly. + paths = [ + { + claude: [ + { + source: "$HOME/.claude/settings.json", + destination: "$HOME/.myconfigurations.private/claude/global/settings.json", + }, + { + source: "$HOME/.claude/CLAUDE.md", + destination: "$HOME/.myconfigurations.private/claude/brains/global/CLAUDE.md", + } + ], + } + ] + + described_class.process_paths( paths ) + + settings_source = File.join( tmpdir, ".claude/settings.json" ) + claude_md_source = File.join( tmpdir, ".claude/CLAUDE.md" ) + + expect( File.symlink?( settings_source ) ).to be true + expect( File.readlink( settings_source ) ).to eq( settings_dest ) + + expect( File.symlink?( claude_md_source ) ).to be true + expect( File.readlink( claude_md_source ) ).to eq( claude_md_dest ) + end + end + + # -------------------------------------------------------------------------- + # Multiple path groups (separate array elements) + # The main lib/setup.rb loads both dotfiles/setup.rb and + # applications/setup.rb, each appending a separate hash to the PATHS array. + # Verify that multiple array elements are all processed. + # -------------------------------------------------------------------------- + context "when processing multiple path groups" do + it "processes each group independently" do + dotfile_dest = File.join( tmpdir, ".myconfigurations/dotfiles/.zshrc" ) + app_dest = File.join( tmpdir, ".myconfigurations/applications/tmux/conf" ) + + FileUtils.mkdir_p( File.dirname( dotfile_dest ) ) + FileUtils.mkdir_p( File.dirname( app_dest ) ) + File.write( dotfile_dest, "zsh config" ) + File.write( app_dest, "tmux config" ) + + # Two separate path groups, as if loaded from two different setup files. + paths = [ + { + dotfiles: [ + { + source: "$HOME/.zshrc", + destination: "$HOME/.myconfigurations/dotfiles/.zshrc", + } + ], + }, + { + tmux: [ + { + source: "$HOME/.tmux.conf", + destination: "$HOME/.myconfigurations/applications/tmux/conf", + } + ], + } + ] + + described_class.process_paths( paths ) + + zshrc_source = File.join( tmpdir, ".zshrc" ) + tmux_source = File.join( tmpdir, ".tmux.conf" ) + + expect( File.symlink?( zshrc_source ) ).to be true + expect( File.readlink( zshrc_source ) ).to eq( dotfile_dest ) + + expect( File.symlink?( tmux_source ) ).to be true + expect( File.readlink( tmux_source ) ).to eq( app_dest ) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 00000000..c738c1c8 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Add the project's lib directory to the load path so we can require helpers directly. +$LOAD_PATH.unshift File.expand_path( "../lib", __dir__ ) + +RSpec.configure do |config| + # Use expect syntax exclusively (no `should`). + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + # Prevent monkey-patching of objects with `should`. + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + # Run specs in random order to surface order-dependent bugs. + config.order = :random + Kernel.srand config.seed +end