Last active
May 28, 2025 01:57
-
-
Save SteveBenner/77aa8a1d46ede9fa4f24c8a74166fd09 to your computer and use it in GitHub Desktop.
Epic macOS bootstrap script for provisioning all your software needs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env ruby | |
# | |
# This is my master system initialization script for macOS. It is designed to be run from anywhere. | |
# There are no dependencies other than Ruby. The script explains what it will do as it goes. | |
# The script returns true if it ran a typical provisioning process without any flags, and false otherwise. | |
# | |
# TODO: Improve Ruby auto-detection so you don't have to relaunch the script as much | |
# TODO: Compose a way to, when relaunching the script, skip to where you left off from | |
# TODO: Add ssh key/env setup | |
# TODO: Add support for google drive | |
# TODO: Refactor so that script never exits prematurely during the flow | |
# TODO: Add support for 'deletions', a list of apps and/or directories to delete from the disk | |
# TODO: Update unbrew to remove installed casks from /Applications | |
# | |
# TODO: FIX MANIFEST PROCESSING | |
# TODO: FIX ERROR - httyparty not found | |
# TODO: FIX ERROR - bitbucket repo download | |
# TODO: FIX HOMEBREW LINKING | |
require 'optparse' | |
require 'open-uri' | |
require 'open3' | |
require 'fileutils' | |
require 'yaml' | |
# This method MUST be defined before optparse is used | |
UNBREW_URL = 'https://gist.githubusercontent.com/SteveBenner/11254428/raw/unbrew.rb' | |
$unbrew_path = File.expand_path '~/unbrew.rb' | |
def self.uninstall_homebrew | |
FileUtils.mkdir_p File.dirname $unbrew_path | |
FileUtils.rm $unbrew_path if File.exist? $unbrew_path # We want the LATEST version of the script | |
puts 'Downloading `unbrew.rb` from Github...' | |
File.open($unbrew_path, 'wb') { |file| file.write URI.open(UNBREW_URL).read } | |
File.chmod 0755, $unbrew_path | |
puts 'Executing `unbrew.rb`...' | |
result = system "ruby #{$unbrew_path} -v" | |
if result | |
puts "\nHomebrew successfully removed from your system. Proceeding..." | |
else | |
puts "No files were found! It looks like Homebrew doesn't exist on your system. Proceeding..." | |
end | |
puts | |
sleep 1 | |
end | |
optparser = OptionParser.new do |opts| | |
opts.on '-h', '--help', 'Prints this help message' do | |
puts <<~STR | |
Example usage (run the script with one of the following flags after the filename, such as `ruby bootstrap.rb -h`): | |
-p, --print Print out a comprehensive list of all script operations (in the order they occur) | |
-s, --spec Print the `manifest.yml` spec | |
-u, --uninstall Completely REMOVE Homebrew from your system | |
-h, --help Prints this help message | |
STR | |
exit false | |
end | |
opts.on '-p', '--print', 'Print a comprehensive list of all script operations (in the order they occur)' do | |
puts <<~STR | |
The script will perform the following operations in this order: | |
1. Automatically detect if pCloud is installed and drive enabled (if it is, the script looks there for repos) | |
2. Install and configure Homebrew (does nothing if Homebrew already exists and is properly configured) | |
- If Homebrew is installed but broken, link Homebrew files and reconfigure the shell accordingly | |
- If Homebrew is not installed, install it (link/configure before and after copying dotfiles) | |
3. Detect a manifest file and proceed accordingly: | |
- Install libraries used with Ruby: openssl, readline, libyaml, gdbm, and libffi | |
- Install and link the latest versions of Ruby, Bash, and Git (using config options in the manifest) | |
A. If NO manifest file exists: | |
- Prompt the user to optionally download dotfiles from GitHub or Bitbucket | |
- TEMPORARILY install ruby gems to facilitate access to the GitHub and/or Bitbucket APIs if necessary | |
- The script walks the user through the process of setting up access to their GitHub or BitBucket account | |
- Once the user inputs their access credentials into the script, it automatically clones into `~/.dotfiles` | |
- Once downloaded, your dotfiles and dotfolders are linked into the local user home directory | |
- Prompt the user to optionally download git repos (and gists) from GitHub and BitBucket | |
- Downloaded repos/gists are placed into `~/github`, `~/gists`, and `~/bitbucket` directories | |
- Existing repos and gists are ignored | |
B. If a manifest file DOES exist: | |
- If there is a `dotfiles` entry, use it as the local path to your dotfiles | |
- Link dotfiles and dotfolders into the user home directory, and dotfiles to ~/.dotfiles | |
- If no `dotfiles` entry exists, prompt the user to optionally download from GitHub or Bitbucket | |
- If a `dirs` entry exists in the manifest, link any listed directories from pCloud into the home directory | |
- Prompt the user to optionally download git repos (and gists) from GitHub and BitBucket | |
- TEMPORARILY install ruby gems to facilitate access to the GitHub and/or Bitbucket APIs if necessary | |
- The script walks the user through the process of setting up access to their GitHub or BitBucket account | |
- Once the user inputs their access credentials into the script, it automatically clones all repositories | |
- Downloaded repos/gists are placed into `~/github`, `~/gists`, and `~/bitbucket` directories | |
- Existing repos and gists are ignored | |
- If a Gemfile is present in the dotfiles directory, it will be linked to the home directory as usual | |
- Gems will be installed via Bundler, and your shell and Ruby environments will be appropriately configured | |
- Install packages listed under `homebrew` (`formulae` and `casks`) and then `npm_packages` in the manifest | |
- Compare applications listed under `applications` with what is already installed on disk, and print a report | |
4. Configure the shell environment | |
- The shell is configured via the `.bashrc` startup script. If `.bashrc` exists in dotfiles already, | |
the script will scan it for a Homebrew configuration and repair the configuration if necessary. | |
- If no `.bashrc` profile exists, the script creates one and configures it for Homebrew/Bash/Ruby/Git | |
5. Cleanup: The script will run `brew doctor` and `brew cleanup`, and removes any temporarily installed gems | |
STR | |
exit false | |
end | |
opts.on '-s', '--spec', 'Print the `manifest.yml` spec' do | |
puts <<~YAML | |
# This is an example of a `manifest.yml` document. | |
dotfiles: /local/path/to/dotfiles # Absolute path to a local dotfiles directory | |
dirs: # Names of directories to link to your home directory | |
- github | |
- bitbucket | |
- ... | |
config: # Configuration settings for installing Ruby and other software | |
ruby: | |
- install_location: # Directory to install Ruby into | |
homebrew: # Homebrew packages | |
formulae: | |
- package1 | |
- ... | |
casks: | |
- cask1 | |
- ... | |
npm_packages: # NPM packages | |
- package1 | |
- ... | |
applications: # A list of applications that were installed in `/Applications` | |
- app1.app | |
- ... | |
delete: | |
apps: # List of applications under /Applications to remove from local disk | |
- app1 | |
- ... | |
dirs: # List of directory paths to delete from the local disk | |
- /path/to/directory1 | |
- ... | |
YAML | |
exit false | |
end | |
opts.on '-u', '--uninstall', 'Completely REMOVE Homebrew from your system' do | |
uninstall_homebrew | |
exit false | |
end | |
end | |
optparser.version = '1.1' | |
optparser.summary_width = 16 | |
optparser.parse! | |
puts <<~STR | |
This master initialization script will provision a mac with all your software, libraries, and configurations. | |
It explains what it does as it goes, and interactively bootstraps your entire setup, step by step. | |
- It is designed to consume a `manifest.yml` file containing references to dotfiles, packages, and applications. | |
- The manifest file can be in the same folder as the script, in the working directory, or on your pCloud Drive. | |
- The script looks for a `dotfiles` folder in the manifest, in the directory the script is in, and on your pCloud. | |
- The script will link any directories listed under `dirs` in the manifest into your local home directory. | |
WITH or WITHOUT a manifest file, you can use this script to: | |
- Install and configure a Homebrew installation | |
- Remove a Homebrew installation and any vestigial Homebrew files from your system | |
- Upgrade your Bash and Ruby from the default system versions, and configure your system to use Homebrew versions | |
- Install or upgrade Git to the latest version (necessary if using the script to download repositories) | |
- Download and link your dotfiles from GitHub or Bitbucket, or detect and link a local dotfiles directory | |
- Download repositories directly from GitHub or Bitbucket | |
- View the manifest file spec, so you can create your own manifest file (it is very simple) | |
WARNING: The script operates DESTRUCTIVELY, assuming you want to REPLACE any existing local config files. | |
NOTE: It is HIGHLY recommended to upgrade the system Bash and Ruby IMMEDIATELY, primarily for security reasons! | |
NOTE: This script must be run by the user (as opposed to root). | |
- To REMOVE a Homebrew installation from your system, run `ruby bootstrap.rb -u` | |
- For a description of the `manifest.yml` spec, run `ruby bootstrap.rb -s` | |
- To print out a comprehensive list of all the operations the script performs (in order) run `ruby bootstrap.rb -p` | |
Press enter to proceed, or type 'exit' to quit. | |
STR | |
input = gets.chomp.downcase | |
exit if input == 'exit' | |
def symbolize_keys(obj) | |
case obj | |
when Hash | |
obj.each_with_object({}) do |(k, v), result| | |
# Convert key to symbol if it's a string | |
key = k.is_a?(String) ? k.to_sym : k | |
# Recursively symbolize nested hashes and arrays | |
result[key] = symbolize_keys(v) | |
end | |
when Array | |
obj.map { |v| symbolize_keys(v) } | |
else | |
obj | |
end | |
end | |
def detect_homebrew_installations | |
puts 'Detecting Homebrew installations...' | |
known_prefixes = %w[/opt/homebrew /usr/local] | |
installations = known_prefixes.select { |prefix| File.exist? "#{prefix}/bin/brew" } | |
installations.each { |installation| puts "Homebrew found at: #{installation}" } | |
installations | |
end | |
def latest_ruby_from_ruby_install | |
output = `ruby-install` | |
versions = {} | |
current_category = nil | |
output.each_line do |line| | |
line = line.strip | |
next if line.empty? || line == 'Stable ruby versions:' | |
if line.end_with?(":") | |
# Category header such as "ruby:" | |
current_category = line.chomp(":") | |
versions[current_category] = [] | |
elsif current_category && line =~ /^\d[\d\.]*$/ # Version line like "3.4.1" | |
versions[current_category] << line | |
end | |
end | |
# Use the "ruby" category only (ignore jruby, etc.) | |
versions['ruby'].map { |v| Gem::Version.new(v) }.max.to_s | |
end | |
def ruby_install_location | |
if defined?(MANIFEST) && MANIFEST.dig(:config, :ruby, :install_location) | |
File.expand_path MANIFEST.dig :config, :ruby, :install_location | |
else | |
File.expand_path '~/.rubies' | |
end | |
end | |
def ruby_path | |
Dir[File.expand_path("#{ruby_install_location}/ruby-*")].sort_by do |f| | |
f.scan(/\d+/).map(&:to_i) | |
end.last | |
end | |
def local_ruby_version(install_location = nil) | |
install_location = install_location || ruby_install_location | |
# Look for directories named like "ruby-X.Y.Z" | |
ruby_dirs = Dir.exist?(install_location) ? Dir.entries(install_location).select do |entry| | |
entry =~ /^ruby-\d+\.\d+\.\d+$/ | |
end : [] | |
return nil if ruby_dirs.empty? | |
# Extract version strings and determine the highest version | |
versions = ruby_dirs.map { |dir| dir.match(/^ruby-(\d+\.\d+\.\d+)$/)[1] } | |
versions.map { |v| Gem::Version.new(v) }.max.to_s | |
end | |
def update_ruby_path | |
# TODO: separate out the relaunching phase | |
unless ruby_path | |
puts "No Ruby installation found in #{ruby_install_location}" | |
return nil | |
end | |
ruby_version = ruby_path.match(/ruby-(\d+\.\d+\.\d+)/)[1] | |
raise 'ERROR: Ruby version was not able to be parsed.' unless ruby_version | |
major_minor = ruby_version.split('.')[0..1].join('.') | |
ENV['GEM_HOME'] = File.expand_path("~/.gem/ruby/#{major_minor}.0") | |
ENV['GEM_PATH'] = ENV['GEM_HOME'] | |
new_ruby_bin = File.join ruby_path, 'bin' | |
new_gem_bin = File.join ENV['GEM_HOME'], 'bin' | |
ENV['PATH'] = "#{new_ruby_bin}:#{new_gem_bin}:#{ENV['PATH']}" | |
# If the current interpreter version is lower than desired, re-exec the script with the new Ruby | |
current_version = Gem::Version.new RUBY_VERSION | |
latest_version = Gem::Version.new ruby_version | |
if current_version < latest_version | |
puts "Re-launching script with Ruby #{ruby_version}...\n" | |
exec "#{new_ruby_bin}/ruby", $0, *ARGV | |
end | |
puts "Ruby path updated! Now using Ruby version #{ruby_version} from #{new_ruby_bin}" | |
ruby_version | |
end | |
def update_homebrew_path(brew_prefix) | |
# Set the necessary Homebrew environment variables in Ruby | |
ENV['HOMEBREW_PREFIX'] = brew_prefix | |
ENV['HOMEBREW_CELLAR'] = "#{brew_prefix}/Cellar" | |
ENV['HOMEBREW_REPOSITORY'] = brew_prefix | |
# Update the PATH to include Homebrew's `bin` and `sbin` directories | |
ENV['PATH'] = "#{brew_prefix}/bin:#{brew_prefix}sbin:#{ENV['PATH']}" | |
end | |
def install_homebrew | |
puts 'Installing Homebrew into /opt/homebrew...' | |
install_command = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' | |
system install_command | |
puts 'Homebrew successfully installed! Proceeding...' | |
sleep 1 | |
end | |
def install_libraries_for_ruby(libraries) | |
system "#{$brew_prefix}/bin/brew cleanup" # Old/bad symlinks can mess with library installs | |
system "brew install #{libraries.join ' '}" | |
puts "Successfully installed libraries: #{libraries.join ', '}" | |
sleep 1 | |
end | |
def install_ruby | |
puts 'Installing ruby-install...' | |
system 'brew install ruby-install' | |
# Install into the location specified in the config (defaults to ~/.rubies for use with chruby) | |
location = (defined?(MANIFEST) && MANIFEST.dig(:config, :ruby, :install_location)) ? | |
File.expand_path(MANIFEST.dig(:config, :ruby, :install_location)) : | |
File.expand_path('~/.rubies') | |
# Parse the latest ruby version | |
output = `ruby-install` | |
versions = {} | |
current_category = nil | |
output.each_line do |line| | |
line = line.strip | |
next if line.empty? || line == "Stable ruby versions:" | |
# If the line ends with a colon, it's a category header. | |
if line.end_with?(":") | |
current_category = line.chomp(":") | |
versions[current_category] = [] | |
# Otherwise, if the line looks like a version number (e.g., "3.1.6") | |
elsif current_category && line =~ /^\d[\d\.]*$/ | |
versions[current_category] << line | |
end | |
end | |
# Now extract the latest Ruby version from the "ruby" category. | |
latest_ruby = versions["ruby"].map { |v| Gem::Version.new(v) }.max.to_s | |
# Construct the `ruby-install` command with library paths | |
ruby_install_command = %W[ | |
ruby-install | |
ruby | |
-i #{location}/ruby-#{latest_ruby} | |
-- | |
--with-openssl-dir=#{RUBY_LIB_PREFIXES['openssl@3']} | |
--with-readline-dir=#{RUBY_LIB_PREFIXES['readline']} | |
--with-libyaml-dir=#{RUBY_LIB_PREFIXES['libyaml']} | |
--with-gdbm-dir=#{RUBY_LIB_PREFIXES['gdbm']} | |
--with-libffi-dir=#{RUBY_LIB_PREFIXES['libffi']} | |
].join ' ' | |
if File.exist? "#{location}/ruby-#{latest_ruby}/bin/ruby" | |
puts 'It appears that Ruby is already installed and up to date! Proceeding...' | |
else | |
system ruby_install_command | |
puts "Ruby #{latest_ruby} successfully installed! Proceeding..." | |
end | |
$ruby_updated = true | |
update_ruby_path | |
sleep 1 | |
puts 'Installing chruby Ruby manager...' | |
system 'brew install chruby' | |
puts 'chruby successfully installed! Proceeding...' | |
sleep 1 | |
end | |
def install_bash | |
puts 'Installing latest version of Bash...' | |
system 'brew install bash' | |
puts 'Bash successfully installed! Proceeding...' | |
sleep 1 | |
end | |
def install_git | |
puts 'Installing the latest version of Git...' | |
system 'brew install git' | |
puts 'Git successfully installed! Proceeding...' | |
sleep 1 | |
end | |
def check_if_git_installed | |
unless system 'git --version' | |
puts 'Git was not found! Would you like to install it at this point? [y/n]' | |
input = gets.chomp.downcase | |
if input == 'y' | |
install_git | |
else | |
puts 'This operation requires git. Please rerun the script to continue. Exiting...' | |
end | |
end | |
end | |
def check_if_ruby_updated | |
latest_ruby = latest_ruby_from_ruby_install | |
local_ruby = update_ruby_path | |
unless $ruby_updated || local_ruby == latest_ruby | |
puts 'Your Ruby is out of date! Install now? [y/n]' | |
input = gets.chomp.downcase | |
if input == 'y' | |
install_ruby | |
else | |
puts 'This operation requires a modern version of Ruby. Please rerun the script to continue. Exiting...' | |
end | |
end | |
end | |
def setup_github_personal_access_token | |
puts <<~STR | |
You must provide a PAT (Personal Access Token) to continue. Instructions for setting up a PAT: | |
1. Navigate to https://github.com | |
2. Click your profile picture in the top-right corner | |
3. Select 'Settings' from the dropdown menu | |
4. In the left sidebar, click 'Developer Settings' | |
5. Under 'Developer Settings', click on 'Personal access tokens' | |
6. Choose 'Tokens (classic)' | |
7. Click 'Generate new token' and select 'classic' from the dropdown menu | |
8. Give it a name in the 'Note' field | |
9. Under 'Select scopes', check 'public_repo', 'read:org', and 'read:user' | |
10. Click 'Generate token' | |
11. Copy the generated token to your clipboard (there is a copy button) | |
Once you have your PAT, paste it here and then press enter. Without a PAT, press enter to cancel this operation. | |
STR | |
input = gets.chomp | |
input.empty? ? nil : input | |
end | |
def setup_bitbucket_personal_access_token | |
puts <<~STR | |
You must provide a PAT (Personal Access Token) to continue. Instructions for setting up a PAT: | |
1. Navigate to https://bitbucket.org | |
2. Click the gear icon in the top right corner | |
3. Select 'Personal Bitbucket settings' from the dropdown menu | |
4. In the left sidebar, click 'App passwords' | |
5. Click 'Create app password' | |
6. Name your app password | |
7. Check the following permissions: 'Account - Read' and 'Repositories - Read' | |
8. Click 'Create' | |
9. Copy the generated token to your clipboard | |
Once you have your PAT, paste it here and then press enter. Without a PAT, press enter to cancel this operation. | |
STR | |
input = gets.chomp | |
input.empty? ? nil : input | |
end | |
def clone_dotfiles(source) | |
pat = case source | |
when :github then setup_github_personal_access_token | |
when :bitbucket then setup_bitbucket_personal_access_token | |
else nil | |
end | |
abort 'This operation requires a Personal Access Token. Exiting...' unless pat | |
check_if_git_installed | |
check_if_ruby_updated | |
if source == :github | |
# Install the GitHub API gem | |
`gem install octokit` | |
require 'octokit' | |
# Initialize Octokit client | |
client = Octokit::Client.new access_token: pat | |
client.auto_paginate = true | |
# Search for 'dotfiles' repository | |
repos = client.repositories | |
dotfiles_repo = repos.find { |repo| repo.name.downcase == 'dotfiles' } | |
end | |
if source == :bitbucket | |
`gem install bitbucket-api` | |
require 'bitbucket-api' | |
puts 'Enter your Bitbucket username:' | |
bb_username = gets.chomp | |
BitbucketApi.configure do |config| | |
config.username = bb_username | |
config.app_password = pat # Use the correct method for app password | |
end | |
client = BitbucketApi::Client.new # Do not pass any arguments here! | |
repos = client.repositories.all | |
# Search for 'dotfiles' repository | |
dotfiles_repo = repos.find { |repo| repo.name.downcase == 'dotfiles' } | |
end | |
# Prompt for alternative repo if 'dotfiles' not found | |
if dotfiles_repo.nil? | |
puts "No repository named 'dotfiles' found in your account." | |
puts 'Please enter the name of the repository you want to download:' | |
repo_name = gets.chomp | |
dotfiles_repo = repos.find { |repo| repo.name.downcase == repo_name.downcase } | |
if dotfiles_repo.nil? | |
abort "Repository named '#{repo_name}' not found. Exiting..." | |
end | |
end | |
# Get the clone URL (HTTPS) | |
clone_url = source == :github ? | |
dotfiles_repo.clone_url : | |
dotfiles_repo.links.find { |link| link.name == 'https' }&.href | |
# Clone the repository using Git | |
puts "Cloning repository '#{dotfiles_repo.name}'..." | |
success = system "git clone #{clone_url} #{File.expand '~/.dotfiles'}" | |
if success | |
puts 'Successfully cloned repo into ~/.dotfiles. Proceeding...' | |
else | |
abort 'Failed to clone the repository. Exiting...' | |
end | |
end | |
def clone_repos(source) | |
cloned_repos = 0 | |
pat = case source | |
when :github then setup_github_personal_access_token | |
when :bitbucket then setup_bitbucket_personal_access_token | |
when :gist then setup_github_personal_access_token | |
else nil | |
end | |
abort 'This operation requires a Personal Access Token. Exiting...' unless pat | |
check_if_git_installed | |
check_if_ruby_updated | |
puts 'Temporarily installing necessary API gems...' | |
case source | |
when :github | |
`gem install octokit` | |
require 'octokit' | |
client = Octokit::Client.new access_token: pat | |
client.auto_paginate = true | |
repos = client.repositories | |
when :gist | |
`gem install octokit` | |
require 'octokit' | |
client = Octokit::Client.new access_token: pat | |
client.auto_paginate = true | |
repos = client.gists | |
when :bitbucket | |
`gem install httparty` | |
require 'httparty' | |
puts ENV['PATH'] | |
bb_username = 'SteveBenner09' | |
app_password = 'ATBBK8K3mY725NqgtDue2XG26K569A027A13' | |
# puts 'Please enter your Bitbucket username:' | |
# Define basic auth credentials as a hash | |
auth = { username: bb_username, password: app_password } | |
$bb_api_base_path = 'https://api.bitbucket.org/2.0' | |
# Method to retrieve all workspaces for the authenticated user. | |
def get_repositories(workspace_slug, auth) | |
repos = [] | |
# Start with the full URL | |
url = "#{$bb_api_base_path}/repositories/#{workspace_slug}" | |
loop do | |
response = HTTParty.get(url, basic_auth: auth) | |
data = response.parsed_response | |
# Debug output: | |
puts "Requesting #{url}..." | |
puts "Response code: #{response.code}" | |
puts "Response body: #{response.body}..." | |
if data['next'] | |
puts "Next page URL: #{data['next']}" | |
else | |
puts "No 'next' link in the response." | |
end | |
unless data.is_a?(Hash) | |
puts "Unexpected response format." | |
break | |
end | |
repos.concat(data['values'] || []) | |
break unless data['next'] | |
# Use the next URL directly. | |
url = data['next'] | |
end | |
repos | |
end | |
# Method to get all workspaces for the authenticated user with pagination. | |
def get_workspaces(auth) | |
workspaces = [] | |
endpoint = "workspaces" | |
loop do | |
full_url = "#{$bb_api_base_path}/#{endpoint}" | |
response = HTTParty.get(full_url, basic_auth: auth) | |
data = response.parsed_response | |
puts "Requesting #{endpoint}..." | |
puts "Response code: #{response.code}" | |
puts "Response body: #{response.body}..." | |
if data['next'] | |
puts "Next page URL: #{data['next']}" | |
else | |
puts "No 'next' link in the response." | |
end | |
unless data.is_a?(Hash) | |
puts "Unexpected response format." | |
break | |
end | |
workspaces.concat(data['values'] || []) | |
break unless data['next'] | |
endpoint = URI(data['next']).request_uri | |
end | |
workspaces | |
end | |
# Get workspaces. | |
workspaces = get_workspaces(auth) | |
if workspaces.empty? | |
puts "No workspaces found for user #{bb_username}. Exiting." | |
exit 1 | |
end | |
puts "Found #{workspaces.size} workspace(s)." | |
# Define target directory for cloning Bitbucket repositories. | |
target_dir = File.expand_path('~/bitbucket') | |
FileUtils.mkdir_p target_dir | |
cloned_repos = 0 | |
workspaces.each do |ws| | |
ws_slug = ws['slug'] | |
puts "\nProcessing workspace: #{ws_slug}" | |
repos = get_repositories(ws_slug, auth) | |
puts " Found #{repos.size} repositories in workspace #{ws_slug}." | |
repos.each do |repo| | |
repo_name = repo['name'] | |
clone_links = repo['links']['clone'] | |
https_link = clone_links.find { |link| link['name'].downcase == 'https' } | |
if https_link.nil? | |
puts " No HTTPS clone URL found for repository #{repo_name}. Skipping..." | |
next | |
end | |
clone_url = https_link['href'] | |
repo_path = File.join(target_dir, repo_name) | |
if Dir.exist?(repo_path) | |
puts " Repository #{repo_name} already exists at #{repo_path}. Skipping..." | |
else | |
cloned_repos += 1 | |
puts " Cloning #{repo_name} into #{target_dir}..." | |
unless system("git clone #{clone_url} #{repo_path}") | |
puts " Failed to clone #{repo_name}." | |
end | |
end | |
end | |
end | |
puts "Cloned #{cloned_repos} repositories into #{target_dir}. Proceeding...\n\n" | |
else raise 'Must be :github, :gist, or :bitbucket' | |
end | |
target_dir = case source | |
when :github then File.expand_path "~/github" | |
when :bitbucket then File.expand_path "~/bitbucket" | |
when :gist then File.expand_path '~/.gists' | |
else raise 'Must be :github, :gist, or :bitbucket' | |
end | |
FileUtils.mkdir_p target_dir | |
unless source == :bitbucket | |
if repos && !repos.empty? | |
puts "\nCloning git repositories...\n" | |
repos.each do |repo| | |
case source | |
when :github | |
repo_name = repo.name | |
clone_url = repo.clone_url.sub 'https://', "https://x-access-token:#{pat}@" | |
when :gist | |
# Get and normalize the gist title | |
gist_title = repo.description || "untitled-gist-#{repo.id[0..6]}" | |
repo_name = gist_title.downcase.gsub(/[^a-z0-9]+/, '-') # Convert to slug | |
.gsub(/-{2,}/, '-') # Remove double dashes | |
.gsub(/(^-|-$)/, '')[0..64] # Trim edges and limit length | |
clone_url = repo.git_pull_url.sub('https://', "https://x-access-token:#{pat}@") | |
else raise 'Must be :github or :gist' | |
end | |
next unless repo_name | |
repo_path = File.join target_dir, repo_name | |
if Dir.exist? repo_path | |
puts "Repo already exists: #{repo_name}. Skipping..." | |
next | |
end | |
puts "Cloning #{repo_name} into #{target_dir}..." | |
success = system "git clone #{clone_url} #{repo_path}" | |
puts(if success | |
cloned_repos += 1 | |
"Successfully cloned #{repo_name}" | |
else | |
"Failed to clone #{repo_name}" | |
end) | |
end | |
else 'No repositories found. Proceeding...' | |
end | |
end | |
puts "Cloned #{cloned_repos} repositories into #{target_dir}. Proceeding..." | |
end | |
def configure_shell(profile) | |
# TODO: add smart detection of bad values, and remove them line by line | |
profile_path = File.expand_path profile, Dir.home | |
# Create the profile file if it doesn't exist | |
unless File.exist? profile_path | |
puts "Creating #{profile_path}..." | |
FileUtils.touch profile_path | |
end | |
# Read the current contents of the profile | |
contents = File.read profile_path | |
# Add Ruby to the PATH | |
ruby_export = "export PATH=\"#{ruby_path}/bin:$PATH\"" | |
# Define the Homebrew PATH export, as well as chruby and Bundler/Ruby gems sourcing lines | |
brew_export = "export PATH=\"#{ENV['HOMEBREW_PREFIX']}/bin:#{ENV['HOMEBREW_PREFIX']}/sbin:$PATH\"" | |
chruby_source = <<~STR | |
source #{$brew_prefix}/opt/chruby/share/chruby/chruby.sh | |
source #{$brew_prefix}/opt/chruby/share/chruby/auto.sh | |
STR | |
gem_export = <<~STR | |
export GEM_HOME="$HOME/.gem/ruby/#{local_ruby_version}" | |
export GEM_PATH="$GEM_HOME" | |
export PATH="$GEM_HOME/bin:$PATH" | |
STR | |
unless contents.include? ruby_export | |
File.open profile_path, 'a' do |file| | |
file.puts '# Ruby' | |
file.puts ruby_export | |
file.puts | |
end | |
puts "Added Ruby PATH export to #{profile_path}" | |
end | |
unless contents.include? brew_export | |
File.open profile_path, 'a' do |file| | |
file.puts '# Homebrew' | |
file.puts brew_export | |
file.puts | |
end | |
puts "Added Homebrew PATH export to #{profile_path}" | |
end | |
unless contents.include? chruby_source | |
File.open profile_path, 'a' do |file| | |
file.puts '# chruby Ruby manager' | |
file.puts chruby_source | |
file.puts | |
end | |
puts "Added chruby sourcing to #{profile_path}" | |
end | |
unless contents.include? gem_export | |
File.open profile_path, 'a' do |file| | |
file.puts '# Bundler/Ruby gems' | |
file.puts gem_export | |
file.puts | |
end | |
puts "Added Bundler sourcing to #{File.realpath profile_path}" | |
end | |
puts "Shell profile: #{profile} successfully configured. Proceeding..." | |
end | |
### MAIN OPERATIONS ### | |
# Detect Homebrew, and install/link if not found. Link existing installation, or uninstall if necessary. | |
homebrew_installations = detect_homebrew_installations | |
case homebrew_installations.size | |
when 0 | |
puts 'Homebrew not found. Proceeding to install...' | |
install_homebrew | |
$brew_prefix = detect_homebrew_installations.first | |
update_homebrew_path $brew_prefix | |
when 1 | |
puts "Homebrew installation detected! Proceeding...\n\n" | |
$brew_prefix = homebrew_installations.first | |
update_homebrew_path $brew_prefix | |
else | |
puts 'Uh oh! It appears there is more than one installation of Homebrew on your computer!' | |
puts 'To proceed, you must remove all but ONE installation. Run unbrew to do so? [y/n]' | |
input = gets.chomp.downcase | |
if input == 'y' | |
update_homebrew_path homebrew_installations.first | |
uninstall_homebrew | |
else | |
abort 'Exiting...' | |
end | |
end | |
# Install essential software | |
puts 'Install Ruby? [y/n]' | |
input = gets.chomp.downcase | |
if input == 'y' | |
# Install libraries for Ruby | |
RUBY_LIBRARIES = %w[openssl@3 readline libyaml gdbm libffi] | |
install_libraries_for_ruby RUBY_LIBRARIES | |
# Configure Ruby library prefixes for the installation command | |
RUBY_LIB_PREFIXES = RUBY_LIBRARIES.each_with_object({}) do |lib, hash| | |
hash[lib] = `brew --prefix #{lib}`.chomp | |
end | |
install_ruby | |
else | |
puts "Proceeding...\n\n" | |
end | |
puts 'Install Bash? [y/n]' | |
input = gets.chomp.downcase | |
if input == 'y' | |
install_bash | |
else | |
puts "Proceeding...\n\n" | |
end | |
puts 'Install git? [y/n] - Note that you MUST have git installed in order to download repositories.' | |
input = gets.chomp.downcase | |
if input == 'y' | |
install_git | |
else | |
puts "Proceeding...\n\n" | |
end | |
# Detect pCloud | |
PCLOUD_LOCATION = File.expand_path "#{Dir.home}/pCloud Drive" | |
if Dir.exist? PCLOUD_LOCATION | |
puts 'pCloud drive successfully located! The script will look there for your repositories. Proceeding...' | |
PCLOUD_DIR = PCLOUD_LOCATION | |
else | |
puts 'No pCloud drive detected. If you have pCloud installed, you may need to enable drive using the app menu.' | |
puts 'Press enter to continue...' # Give the user a chance to set up their pCloud | |
gets | |
if Dir.exist? PCLOUD_LOCATION | |
puts 'pCloud Drive successfully located! The script will look there for your repositories. Proceeding...' | |
PCLOUD_DIR = PCLOUD_LOCATION | |
sleep 1 | |
else | |
puts 'Continuing without pCloud drive...' | |
sleep 1 | |
end | |
end | |
# Detect manifest file | |
puts 'Detecting manifest file...' | |
MANIFEST_PATHS = [ | |
File.join(__dir__, 'manifest.yml'), # Directory of the script | |
File.join(Dir.pwd, 'manifest.yml'), # Current working directory | |
File.join('/pCloud Drive', 'manifest.yml'), | |
File.join(__dir__, 'manifest.yaml'), | |
File.join(Dir.pwd, 'manifest.yaml'), | |
File.join('/pCloud Drive', 'manifest.yaml') | |
].compact # Remove nil values if ENV['MANIFEST'] is not set | |
MANIFEST_FILE = MANIFEST_PATHS.find { |path| File.exist? path } | |
if defined? MANIFEST_FILE && !MANIFEST_FILE.nil? | |
print "Manifest file located at: #{MANIFEST_FILE}. Parsing..." | |
MANIFEST = symbolize_keys YAML.load_file(MANIFEST_FILE) | |
puts ' Manifest successfully loaded! Proceeding...' | |
else | |
puts 'No manifest file detected. Proceeding...' | |
end | |
# Primary logic | |
if defined? MANIFEST | |
# Detect dotfiles location (from a manifest OR mounted pCloud drive) or download them | |
if MANIFEST[:dotfiles] | |
DOTFILES_LOCATION = MANIFEST[:dotfiles] | |
puts "Dotfiles found at: #{DOTFILES_LOCATION}! Proceeding..." | |
elsif defined?(PCLOUD_DIR) && Dir.exist?("#{PCLOUD_DIR}/dotfiles") | |
DOTFILES_LOCATION = "#{PCLOUD_DIR}/dotfiles" | |
puts "Dotfiles found at: #{DOTFILES_LOCATION}! Proceeding..." | |
elsif Dir.exist? File.join(__dir__, 'dotfiles') # Directory of the script | |
DOTFILES_LOCATION = File.join(__dir__, 'dotfiles') | |
elsif Dir.exist? File.join(Dir.pwd, 'dotfiles') # Current working directory | |
DOTFILES_LOCATION = File.join(Dir.pwd, 'dotfiles') | |
else # Prompt the user to optionally download their dotfiles from GitHub or Bitbucket | |
puts 'No dotfiles directory found. Would you like to download a dotfiles repo from one of the following?' | |
puts 'NOTE: This will necessitate setting up a Personal Access Token on GitHub, as well as installing Ruby and Git.' | |
puts 'Enter a number corresponding to one of the platforms below to continue (input anything else to skip):' | |
puts '1) GitHub' | |
puts '2) Bitbucket' | |
input = gets.chomp.downcase | |
case input | |
when '1' | |
clone_dotfiles :github | |
DOTFILES_LOCATION = '~/.dotfiles' | |
when '2' | |
clone_dotfiles :bitbucket | |
DOTFILES_LOCATION = '~/.dotfiles' | |
else puts 'Proceeding without dotfiles...' | |
end | |
sleep 1 | |
end | |
# Link any directories listed under `dirs` in the manifest (from pCloud drive) into HOME | |
if defined?(PCLOUD_DIR) && MANIFEST[:dirs] | |
MANIFEST[:dirs].each do |dir| | |
unless Dir.exist? "#{PCLOUD_DIR}/#{dir}" | |
puts "#{dir} not found in pCloud. Skipping..." | |
next | |
end | |
if Dir.exist? File.expand_path "~/#{dir}" | |
puts "#{dir} already exists in your local user home directory. Skipping..." | |
else | |
puts "Linking #{dir}..." | |
File.symlink "#{PCLOUD_DIR}/#{dir}", File.expand_path("~/#{dir}") | |
end | |
end | |
puts 'Directories successfully linked from pCloud. Proceeding...' | |
sleep 1 | |
end | |
if MANIFEST[:homebrew] | |
if MANIFEST[:homebrew][:formulae] | |
puts 'Installing Homebrew formulae...' | |
failures = [] | |
MANIFEST[:homebrew][:formulae].each do |formula| | |
begin | |
success = system "#{$brew_prefix}/bin/brew install #{formula}" | |
if success | |
puts "#{formula} successfully installed." | |
else | |
raise "Installation of #{formula} failed." | |
end | |
rescue StandardError => e | |
failures << formula | |
puts "#{e.message}: Installation failed for #{formula}." | |
end | |
end | |
if !failures.empty? | |
puts "The following formulae failed to install: #{failures.join(', ')}" | |
else | |
puts "All formulae installed successfully." | |
end | |
end | |
if MANIFEST[:homebrew][:casks] | |
puts "Installing Homebrew casks..." | |
failures = [] | |
MANIFEST[:homebrew][:casks].each do |cask| | |
begin | |
success = "#{$brew_prefix}/bin/brew install #{cask} --cask" | |
if success | |
puts "#{cask} successfully installed." | |
else | |
raise "Installation of #{cask} failed." | |
end | |
rescue StandardError => e | |
failures << cask | |
puts "#{e.message}: Installation failed for #{cask}." | |
ensure | |
sleep 1 | |
end | |
end | |
if !failures.empty? | |
puts "The following casks failed to install: #{failures.join(', ')}" | |
else | |
puts "All casks installed successfully." | |
end | |
end | |
puts "Homebrew formulae and casks successfully installed. Proceeding...\n\n" | |
end | |
end | |
# Link dotfiles and dotfolders from the dotfiles directory (excluding `.git` and `.idea`) | |
if defined? DOTFILES_LOCATION | |
DOTFILES_DIR = File.expand_path DOTFILES_LOCATION | |
puts "Linking dotfiles from #{DOTFILES_DIR} to user local home directory..." | |
excluded_entries = %w[.git .idea] # DO NOT LINK THESE! | |
# Process each item in dotfiles directory | |
Dir.each_child(DOTFILES_DIR) do |entry| | |
next if excluded_entries.include? entry | |
next unless entry.start_with? '.' # ONLY copy files and folders whose name begins with a dot | |
source = File.join DOTFILES_DIR, entry | |
target = File.join File.expand_path('~'), entry | |
# Handle existing items | |
if File.exist?(target) || File.symlink?(target) | |
puts "Removing existing: #{entry}" | |
FileUtils.rm_rf target | |
end | |
# Create symbolic link | |
puts "Linking: #{entry} → ~/#{entry}" | |
FileUtils.ln_s source, target | |
end | |
puts "Successfully linked #{Dir.children(DOTFILES_DIR).count - excluded_entries.count} items" | |
puts | |
else | |
puts "No dotfiles directory found. Skipping linking phase...\n\n" | |
end | |
sleep 1 | |
update_ruby_path # TODO: see if I can remove this | |
# Configure local user shell profiles and repair if necessary | |
puts 'Configuring shell profiles for use with Homebrew and Ruby...' | |
%w[.zshrc .zprofile .bashrc .bash_profile].each { |profile| configure_shell profile } | |
puts "\nSuccess! Your shell is now fully configured for use with Homebrew and Ruby!\n\n" | |
sleep 1 | |
%i[GitHub gist Bitbucket].each do |source| | |
puts "Would you like to clone your #{source} repositories? [y/n]" | |
input = gets.chomp.downcase | |
if input == 'y' | |
clone_repos source.downcase | |
else | |
puts "Proceeding...\n\n" | |
end | |
end | |
# Detect Gemfile and install the bundle, if one exists | |
if File.exist? File.expand_path '~/Gemfile' | |
puts 'Gemfile detected! Installing bundler and your gems...' | |
check_if_ruby_updated | |
`gem install bundler` | |
`bundle install` | |
sleep 1 | |
end | |
# Cleanup | |
puts "Cleaning up...\n\n" | |
`gem uninstall octokit httparty -a -x -I` # Force uninstallation without prompt, and ignore dependencies | |
File.delete $unbrew_path if File.exist? $unbrew_path | |
puts 'Congratulations!!! Your system is now fully provisioned.' | |
puts 'For more information or to report an issue with this script, refer to:' | |
puts 'https://gist.github.com/SteveBenner/21b7d4aacba464cc1961f490db7903f9' | |
exit true |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment