tomo-1.22.0/0000755000004100000410000000000015216001452012576 5ustar www-datawww-datatomo-1.22.0/exe/0000755000004100000410000000000015216001452013357 5ustar www-datawww-datatomo-1.22.0/exe/tomo0000755000004100000410000000013315216001452014260 0ustar www-datawww-data#!/usr/bin/env ruby # frozen_string_literal: true require "tomo" Tomo::CLI.new.call(ARGV) tomo-1.22.0/lib/0000755000004100000410000000000015216001452013344 5ustar www-datawww-datatomo-1.22.0/lib/tomo.rb0000644000004100000410000000230515216001452014647 0ustar www-datawww-data# frozen_string_literal: true module Tomo autoload :CLI, "tomo/cli" autoload :Colors, "tomo/colors" autoload :Commands, "tomo/commands" autoload :Configuration, "tomo/configuration" autoload :Console, "tomo/console" autoload :Error, "tomo/error" autoload :Host, "tomo/host" autoload :Logger, "tomo/logger" autoload :Path, "tomo/path" autoload :Paths, "tomo/paths" autoload :Plugin, "tomo/plugin" autoload :PluginDSL, "tomo/plugin_dsl" autoload :Remote, "tomo/remote" autoload :Result, "tomo/result" autoload :Runtime, "tomo/runtime" autoload :Script, "tomo/script" autoload :ShellBuilder, "tomo/shell_builder" autoload :SSH, "tomo/ssh" autoload :TaskAPI, "tomo/task_api" autoload :TaskLibrary, "tomo/task_library" autoload :VERSION, "tomo/version" DEFAULT_CONFIG_PATH = ".tomo/config.rb" class << self attr_accessor :logger attr_writer :debug, :dry_run, :quiet def debug? !!@debug end def dry_run? !!@dry_run end def quiet? !!@quiet end def bundled? !!(defined?(Bundler) && ENV["BUNDLE_GEMFILE"]) end end self.debug = false self.dry_run = false self.quiet = false self.logger = Logger.new end tomo-1.22.0/lib/tomo/0000755000004100000410000000000015216001452014322 5ustar www-datawww-datatomo-1.22.0/lib/tomo/task_api.rb0000644000004100000410000000220115216001452016435 0ustar www-datawww-data# frozen_string_literal: true require "erb" module Tomo module TaskAPI extend Forwardable private def_delegators :context, :paths, :settings def die(reason) Runtime::TaskAbortedError.raise_with(reason, task: context.current_task, host: remote.host) end def dry_run? Tomo.dry_run? end def quiet? Tomo.quiet? end def logger Tomo.logger end def merge_template(path) working_path = paths.tomo_config_file&.dirname path = File.expand_path(path, working_path) if working_path && path.start_with?(".") Runtime::TemplateNotFoundError.raise_with(path:) unless File.file?(path) template = File.read(path) ERB.new(template).result(binding) end def raw(string) ShellBuilder.raw(string) end def remote context.current_remote end def require_setting(*names) missing = names.flatten.select { |sett| settings[sett].nil? } return if missing.empty? Runtime::SettingsRequiredError.raise_with(settings: missing, task: context.current_task) end alias require_settings require_setting end end tomo-1.22.0/lib/tomo/logger.rb0000644000004100000410000000272215216001452016131 0ustar www-datawww-data# frozen_string_literal: true require "forwardable" module Tomo class Logger autoload :TaggedIO, "tomo/logger/tagged_io" extend Forwardable include Tomo::Colors def initialize(stdout: $stdout, stderr: $stderr) @stdout = TaggedIO.new(stdout) @stderr = TaggedIO.new(stderr) end def script_start(script) return if Tomo.quiet? return unless script.echo? puts yellow(script.echo_string) end def script_output(script, output) return if script.silent? puts output end def script_end(script, result) return if Tomo.quiet? return unless result.failure? return unless script.silent? return unless script.raise_on_error? puts result.output end def connect(host) return if Tomo.quiet? puts gray("→ Connecting to #{host}") end def task_start(task) return if Tomo.quiet? puts blue("• #{task}") end def info(message) return if Tomo.quiet? puts message end def error(message) stderr.puts indent("\n" + red("ERROR: ") + message.strip + "\n\n") end def warn(message) stderr.puts red("WARNING: ") + message end def debug(message) return unless Tomo.debug? stderr.puts gray("DEBUG: #{message}") end private def_delegators :@stdout, :puts attr_reader :stderr def indent(message, prefix=" ") message.gsub(/^/, prefix) end end end tomo-1.22.0/lib/tomo/colors.rb0000644000004100000410000000161215216001452016150 0ustar www-datawww-data# frozen_string_literal: true module Tomo module Colors ANSI_CODES = { red: 31, green: 32, yellow: 33, blue: 34, gray: 90 }.freeze private_constant :ANSI_CODES class << self attr_writer :enabled def enabled? return @enabled if defined?(@enabled) @enabled = determine_color_support end private def determine_color_support if ENV["CLICOLOR_FORCE"] == "1" true elsif ENV["TERM"] == "dumb" || !ENV["NO_COLOR"].to_s.empty? false else tty?($stdout) && tty?($stderr) end end def tty?(io) io.respond_to?(:tty?) && io.tty? end end module_function ANSI_CODES.each do |name, code| define_method(name) do |str| ::Tomo::Colors.enabled? ? "\e[0;#{code};49m#{str}\e[0m" : str end end end end tomo-1.22.0/lib/tomo/testing/0000755000004100000410000000000015216001452015777 5ustar www-datawww-datatomo-1.22.0/lib/tomo/testing/cli_tester.rb0000644000004100000410000000204315216001452020460 0ustar www-datawww-data# frozen_string_literal: true require "securerandom" module Tomo module Testing class CLITester include Local include LogCapturing def initialize @token = SecureRandom.hex(8) end def in_temp_dir(&) super(token, &) end def run(*args, raise_on_error: true) in_temp_dir do restoring_defaults do capturing_logger_output do handling_exit(raise_on_error) do CLI.new.call(args.flatten) end end end end end private attr_reader :token def restoring_defaults yield ensure Tomo.debug = false Tomo.dry_run = false Tomo.quiet = false Tomo::CLI.show_backtrace = false Tomo::CLI::Completions.instance_variable_set(:@active, false) end def handling_exit(raise_on_error) yield rescue Tomo::Testing::MockedExitError => e raise if raise_on_error && !e.success? end end end end tomo-1.22.0/lib/tomo/testing/host_extensions.rb0000644000004100000410000000146115216001452021562 0ustar www-datawww-data# frozen_string_literal: true module Tomo module Testing module HostExtensions attr_reader :helper_values, :mocks, :scripts, :release def initialize(**kwargs) @mocks = [] @scripts = [] @helper_values = [] @release = {} super end def mock(script, stdout: "", stderr: "", exit_status: 0) mocks << [ script.is_a?(Regexp) ? script : /\A#{Regexp.quote(script)}\z/, Result.new(stdout: String.new(stdout), stderr: String.new(stderr), exit_status:) ] end def result_for(script) match = mocks.find { |regexp, _| regexp.match?(script.to_s) } raise "Scripts cannot be mocked during dry_run" if match && Tomo.dry_run? match&.last || Result.empty_success end end end end tomo-1.22.0/lib/tomo/testing/tomo_test_ed255190000644000004100000410000000063315216001452021017 0ustar www-datawww-data-----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW QyNTUxOQAAACDU7UqhQJMIlzZgNVZf7oGtDF054nePB/NY0X0dJlL3VgAAAJh/FNNRfxTT UQAAAAtzc2gtZWQyNTUxOQAAACDU7UqhQJMIlzZgNVZf7oGtDF054nePB/NY0X0dJlL3Vg AAAEDHWenvyWnYId1S/v4idksTmU28IntayxXkJUSbcGwETNTtSqFAkwiXNmA1Vl/uga0M XTnid48H81jRfR0mUvdWAAAAEHRvbW9AZXhhbXBsZS5jb20BAgMEBQ== -----END OPENSSH PRIVATE KEY----- tomo-1.22.0/lib/tomo/testing/systemctl.rb0000755000004100000410000001056015216001452020360 0ustar www-datawww-data#!/usr/bin/env ruby # frozen_string_literal: true # THIS SCRIPT IS FOR TESTING PURPOSES ONLY. # # We use Docker to run tomo deploy tests. Docker is not able to run systemd, but # tomo needs systemd for starting long-lived processes (e.g. puma). This script # simulates the behavior of systemctl commands so that a tomo deploy can succeed # in a Docker container where the real systemctl is unavailable. # # This basic workflow is supported: # # 1. systemctl --user enable [units...] # 2. systemctl --user start [units...] # 3. systemctl --user restart [units...] # 4. systemctl --user is-active [units...] # 5. systemctl --user status [units...] # # No other commands or options are supported. The only configuration that this # script understands is the ExecStart and WorkingDirectory attributes in a # *.service file that is expected to be installed in ~/.config/systemd/user/. # # This script will fork and exec the command listed in ExecStart and store the # resulting PID so that it can later be used when stopping or restarting the # service. It does not monitor the process, handle stdout/stderr of the process, # or do any of the real work that systemd is designed to handle. It simply is # the bare minimum behavior needed for tomo deploy to pass an E2E test. require "pstore" COMMANDS = %w[ daemon-reload enable is-active restart start status stop ].freeze def main(args) args = args.dup raise "First arg must be --user" unless args.shift == "--user" raise "Missing command" if args.empty? command = args.shift raise "Unknown command: #{command}" unless COMMANDS.include?(command) run(command, args) end def run(command, args) return daemon_reload(args) if command == "daemon-reload" raise "#{command} requires an argument" if args.empty? args.each { |name| Unit.find(name).public_send(command.tr("-", "_")) } end def daemon_reload(args) raise "daemon-reload does not accept arguments" unless args.empty? end class Unit def self.find(name) path = File.join(File.expand_path("~/.config/systemd/user/"), name) raise "Unknown unit: #{name}" unless File.file?(path) return Service.new(name, File.read(path)) if name.end_with?(".service") new(name, File.read(path)) end def initialize(name, spec) @name = name @spec = spec end def enable with_persistent_state { |state| state[:enabled] = true } end def status puts "● #{name}" puts " Loaded: loaded (enabled; vendor preset: enabled)" if enabled? end def start must_be_enabled! end def stop must_be_enabled! end def restart must_be_enabled! end private attr_reader :name, :spec def must_be_enabled! raise "#{name} must be enabled first" unless enabled? end def enabled? with_persistent_state { |state| state[:enabled] } end def with_persistent_state @pstore ||= begin pstore_path = File.expand_path("~/.config/systemd/state.db") PStore.new(pstore_path) end @pstore.transaction do state = @pstore[name] ||= {} yield(state) end end end class Service < Unit def is_active # rubocop:disable Naming/PredicatePrefix exit(false) unless started? puts "active" end def start super raise "#{name} is already running" if started? working_dir, executable = parse if (pid = Process.fork) with_persistent_state { |state| state[:pid] = pid } Process.detach(pid) return end with_detached_io { Dir.chdir(working_dir) { Process.exec(executable) } } end def stop with_persistent_state do |state| pid = state.delete(:pid) Process.kill("TERM", pid) unless pid.nil? end end def restart super stop if started? start end def status super puts " Active: active (running)" if started? end private def started? with_persistent_state { |state| !state[:pid].nil? } end def parse config = spec.scan(/^([^\s=]+)=\s*(\S.*?)\s*$/).to_h working_dir = config["WorkingDirectory"] || File.expand_path("~") executable = config.fetch("ExecStart") do raise "#{name} is missing ExecStart attribute" end [working_dir, executable] end def with_detached_io null_in = File.open(File::NULL, "r") null_out = File.open(File::NULL, "w") $stdin.reopen(null_in) $stderr.reopen(null_out) $stdout.reopen(null_out) yield end end main(ARGV) if $PROGRAM_NAME == __FILE__ tomo-1.22.0/lib/tomo/testing/Dockerfile0000644000004100000410000000042415216001452017771 0ustar www-datawww-dataFROM ubuntu:24.04 WORKDIR /provision COPY ./tomo_test_ed25519.pub /root/.ssh/authorized_keys COPY ./ubuntu_setup.sh ./ RUN ./ubuntu_setup.sh COPY ./systemctl.rb /usr/local/bin/systemctl RUN chmod a+x /usr/local/bin/systemctl EXPOSE 22 EXPOSE 3000 CMD ["/usr/sbin/sshd", "-D"] tomo-1.22.0/lib/tomo/testing/mock_plugin_tester.rb0000644000004100000410000000235315216001452022224 0ustar www-datawww-data# frozen_string_literal: true module Tomo module Testing class MockPluginTester include LogCapturing def initialize(*plugin_names, settings: {}, release: {}) @host = Host.parse("testing@host") @host.release.merge!(release) config = Configuration.new config.hosts << @host config.plugins.push(*plugin_names, "testing") config.settings[:application] = "testing" config.settings.merge!(settings) @runtime = config.build_runtime end def call_helper(helper, *args, **kwargs) run_task("testing:call_helper", helper, args, kwargs) host.helper_values.pop end def run_task(task, *args) capturing_logger_output do runtime.run!(task, *args, privileged: false) nil end end def executed_script return executed_scripts.first unless executed_scripts.length > 1 raise "Expected one executed script, got multiple: #{executed_scripts}" end def executed_scripts host.scripts.map(&:to_s) end def mock_script_result(script=/.*/, **) host.mock(script, **) self end private attr_reader :host, :runtime end end end tomo-1.22.0/lib/tomo/testing/tomo_test_ed25519.pub0000644000004100000410000000014215216001452021577 0ustar www-datawww-datassh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINTtSqFAkwiXNmA1Vl/uga0MXTnid48H81jRfR0mUvdW tomo@example.com tomo-1.22.0/lib/tomo/testing/ssh_extensions.rb0000644000004100000410000000063715216001452021406 0ustar www-datawww-data# frozen_string_literal: true module Tomo module Testing module SSHExtensions private def build_dry_run_connection(host, options) return super if Testing.ssh_enabled Testing::Connection.new(host, options) end def build_connection(host, options) return super if Testing.ssh_enabled Testing::Connection.new(host, options) end end end end tomo-1.22.0/lib/tomo/testing/docker_image.rb0000644000004100000410000000613115216001452020736 0ustar www-datawww-data# frozen_string_literal: true require "fileutils" require "open3" require "securerandom" require "shellwords" require "tmpdir" at_exit { Tomo::Testing::DockerImage.running_images.each(&:stop) } module Tomo module Testing class DockerImage FILES_TO_COPY = %w[ Dockerfile systemctl.rb tomo_test_ed25519.pub ubuntu_setup.sh ].freeze private_constant :FILES_TO_COPY class << self attr_reader :running_images end @running_images = [] attr_reader :host def build_and_run raise "Already running!" if frozen? set_up_build_dir pull_base_image_if_needed set_up_private_key @image_id = build_image @container_id = start_container @host = Host.parse("deployer@localhost", port: find_ssh_port) DockerImage.running_images << self freeze end def stop DockerImage.running_images.delete(self) Local.capture("docker stop #{container_id}", raise_on_error: false) nil end def puma_port Local.capture("docker port #{container_id} 3000")[/:(\d+)/, 1].to_i end # Connecting to SSH servers on local docker containers often triggers # known_hosts errors due to each container potentially having a # different host key. Work around this by using an empty blank temp file # for storing known_hosts. def ssh_settings hosts_file = File.join(Dir.tmpdir, "tomo_#{SecureRandom.hex(8)}_hosts") { ssh_extra_opts: [ "-o", "UserKnownHostsFile=#{hosts_file}", "-o", "IdentityFile=#{private_key_path}" ], ssh_strict_host_key_checking: false } end private attr_reader :container_id, :image_id, :private_key_path def pull_base_image_if_needed images = Local.capture('docker images --format "{{.ID}}" ubuntu:24.04') Local.capture("docker pull ubuntu:24.04") if images.strip.empty? end def set_up_private_key @private_key_path = File.join(Dir.tmpdir, "tomo_test_ed25519_#{SecureRandom.hex(8)}") FileUtils.cp(File.expand_path("tomo_test_ed25519", __dir__), private_key_path) FileUtils.chmod(0o600, private_key_path) end def build_image tag = "tomo_testing:latest" Local.capture("docker build --tag #{tag} #{build_dir}") tag end def start_container args = "--publish-all --detach --init #{image_id}" Local.capture("docker run #{args}")[/\S+/].tap do # Allow some time for the container to finish booting sleep 0.1 end end def find_ssh_port Local.capture("docker port #{container_id} 22")[/:(\d+)/, 1].to_i end def set_up_build_dir FileUtils.mkdir_p(build_dir) FILES_TO_COPY.each do |file| FileUtils.cp(File.expand_path(file, __dir__), build_dir) end end def build_dir @_build_dir ||= File.join(Dir.tmpdir, "tomo_docker_#{SecureRandom.hex(8)}") end end end end tomo-1.22.0/lib/tomo/testing/cli_extensions.rb0000644000004100000410000000034415216001452021353 0ustar www-datawww-data# frozen_string_literal: true module Tomo module Testing module CLIExtensions def exit(status=true) # rubocop:disable Style/OptionalBooleanParameter raise MockedExitError, status end end end end tomo-1.22.0/lib/tomo/testing/mocked_exit_error.rb0000644000004100000410000000055315216001452022033 0ustar www-datawww-data# frozen_string_literal: true module Tomo module Testing class MockedExitError < Exception # rubocop:disable Lint/InheritException attr_reader :status def initialize(status) @status = status super("tomo exited with status #{status}") end def success? [true, 0].include?(status) end end end end tomo-1.22.0/lib/tomo/testing/remote_extensions.rb0000644000004100000410000000030715216001452022076 0ustar www-datawww-data# frozen_string_literal: true module Tomo module Testing module RemoteExtensions def initialize(*args) super release.merge!(ssh.host.release) end end end end tomo-1.22.0/lib/tomo/testing/local.rb0000644000004100000410000000453015216001452017420 0ustar www-datawww-data# frozen_string_literal: true require "bundler" require "fileutils" require "open3" require "securerandom" require "shellwords" require "tmpdir" module Tomo module Testing module Local def with_tomo_gemfile(&) Local.with_tomo_gemfile(&) end def in_temp_dir(token=nil, &) Local.in_temp_dir(token, &) end def capture(*command, raise_on_error: true) Local.capture(*command, raise_on_error:) end class << self def with_tomo_gemfile Bundler.with_original_env do gemfile = File.expand_path("../../../Gemfile", __dir__) ENV["BUNDLE_GEMFILE"] = gemfile yield end end def in_temp_dir(token=nil, &) token ||= SecureRandom.hex(8) dir = File.join(Dir.tmpdir, "tomo_test_#{token}") FileUtils.mkdir_p(dir) Dir.chdir(dir, &) end def capture(*command, raise_on_error: true) command_str = command.join(" ") progress(command_str) do output, status = Open3.capture2e(*command) raise "Command failed: #{command_str}\n#{output}" if raise_on_error && !status.success? output end end private def progress(message, &) return with_progress(message, &) if interactive? thread = Thread.new(&) return thread.value if wait_for_exit(thread, 4) puts "#{message} ..." wait_for_exit(thread) puts "#{message} ✔" thread.value end def with_progress(message, &) spinner = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].cycle thread = Thread.new(&) return thread.value if wait_for_exit(thread, 4) print "#{spinner.next} #{message}..." loop do break if wait_for_exit(thread, 0.2) print "\r#{spinner.next} #{message}..." end puts "\r✔ #{message}..." thread.value end def interactive? Tomo::Console.interactive? end def wait_for_exit(thread, seconds=nil) thread.join(seconds) rescue StandardError # Sanity check. If we get an exception, the thread should be dead. raise if thread.alive? thread end end end end end tomo-1.22.0/lib/tomo/testing/mocked_exec_error.rb0000644000004100000410000000023415216001452022002 0ustar www-datawww-data# frozen_string_literal: true module Tomo module Testing class MockedExecError < Exception # rubocop:disable Lint/InheritException end end end tomo-1.22.0/lib/tomo/testing/connection.rb0000644000004100000410000000134715216001452020470 0ustar www-datawww-data# frozen_string_literal: true module Tomo module Testing class Connection < Tomo::SSH::Connection def initialize(host, options) super(host, options, exec_proc: proc { raise MockedExecError }, child_proc: method(:mock_child_process)) end def ssh_exec(script) host.scripts << script super end def ssh_subprocess(script, verbose: false) host.scripts << script super end private def mock_child_process(*_ssh_args, on_data:) result = host.result_for(host.scripts.last) on_data.call(result.stdout) unless result.stdout.empty? on_data.call(result.stderr) unless result.stderr.empty? result end end end end tomo-1.22.0/lib/tomo/testing/log_capturing.rb0000644000004100000410000000102515216001452021157 0ustar www-datawww-data# frozen_string_literal: true require "stringio" module Tomo module Testing module LogCapturing def stdout @stdout_io&.string end def stderr @stderr_io&.string end private def capturing_logger_output orig_logger = Tomo.logger @stdout_io = StringIO.new @stderr_io = StringIO.new Tomo.logger = Tomo::Logger.new(stdout: @stdout_io, stderr: @stderr_io) yield ensure Tomo.logger = orig_logger end end end end tomo-1.22.0/lib/tomo/testing/ubuntu_setup.sh0000755000004100000410000000306015216001452021077 0ustar www-datawww-data#!/bin/bash set -e export DEBIAN_FRONTEND=noninteractive apt-get -y update apt-get -y install adduser # Create `deployer` user adduser --disabled-password deployer < /dev/null mkdir -p /home/deployer/.ssh cp /root/.ssh/authorized_keys /home/deployer/.ssh chown -R deployer:deployer /home/deployer/.ssh chmod 600 /home/deployer/.ssh/authorized_keys mkdir -p /var/www chown deployer:deployer /var/www mkdir -p /var/lib/systemd/linger touch /var/lib/systemd/linger/deployer # Packages needed for ruby, etc. apt-get -y install autoconf \ bison \ build-essential \ curl \ git-core \ libdb-dev \ libffi-dev \ libgdbm-dev \ libgdbm6 \ libgmp-dev \ libncurses5-dev \ libreadline6-dev \ libsqlite3-dev \ libssl-dev \ libyaml-dev \ locales \ patch \ pkg-config \ rustc \ uuid-dev \ zlib1g-dev apt-get -y install tzdata \ -o DPkg::options::="--force-confdef" \ -o DPkg::options::="--force-confold" locale-gen en_US.UTF-8 # Install and configure sshd apt-get -y install openssh-server echo "Port 22" >> /etc/ssh/sshd_config echo "PasswordAuthentication no" >> /etc/ssh/sshd_config echo "ChallengeResponseAuthentication no" >> /etc/ssh/sshd_config mkdir /var/run/sshd chmod 0755 /var/run/sshd tomo-1.22.0/lib/tomo/paths.rb0000644000004100000410000000136015216001452015766 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Paths def initialize(settings) @settings = settings freeze end def deploy_to path(:deploy_to) end private attr_reader :settings def method_missing(method, *args) return super unless setting?(method) raise ArgumentError, "#{method} takes no arguments" unless args.empty? path(:"#{method}_path") end def respond_to_missing?(method, include_private) setting?(method) || super end def setting?(name) settings.key?(:"#{name}_path") end def path(setting) return nil if settings[setting].nil? path = settings.fetch(setting).to_s.gsub(%r{//+}, "/") Path.new(path) end end end tomo-1.22.0/lib/tomo/configuration.rb0000644000004100000410000000724115216001452017522 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Configuration autoload :DSL, "tomo/configuration/dsl" autoload :Environment, "tomo/configuration/environment" autoload :Glob, "tomo/configuration/glob" autoload :PluginFileNotFoundError, "tomo/configuration/plugin_file_not_found_error" autoload :PluginsRegistry, "tomo/configuration/plugins_registry" autoload :ProjectNotFoundError, "tomo/configuration/project_not_found_error" autoload :RoleBasedTaskFilter, "tomo/configuration/role_based_task_filter" autoload :UnknownEnvironmentError, "tomo/configuration/unknown_environment_error" autoload :UnknownPluginError, "tomo/configuration/unknown_plugin_error" autoload :UnspecifiedEnvironmentError, "tomo/configuration/unspecified_environment_error" def self.from_config_rb(path=DEFAULT_CONFIG_PATH) ProjectNotFoundError.raise_with(path:) unless File.file?(path) Tomo.logger.debug("Loading configuration from #{path.inspect}") config_rb = File.read(path) new.tap do |config| config.path = File.expand_path(path) DSL::ConfigFile.new(config).instance_eval(config_rb, path.to_s, 1) end rescue StandardError, SyntaxError => e raise DSL::ErrorFormatter.decorate(e, path, config_rb&.lines) end attr_accessor :environments, :deploy_tasks, :setup_tasks, :hosts, :plugins, :settings, :task_filter, :path def initialize @environments = {} @hosts = [] @plugins = [] @settings = {} @deploy_tasks = [] @setup_tasks = [] @task_filter = RoleBasedTaskFilter.new end def for_environment(environment) validate_environment!(environment) dup.tap do |copy| copy.environments = {} copy.hosts = hosts_for(environment) copy.settings = settings_with_env_overrides(environment) end end def build_runtime validate_environment!(nil) plugins_registry = register_plugins Runtime.new( deploy_tasks:, setup_tasks:, plugins_registry:, hosts: add_log_prefixes(hosts), settings: { tomo_config_file_path: path }.merge(settings), task_filter: ) end private def validate_environment!(name) if name.nil? raise_no_environment_specified unless environments.empty? else raise_unknown_environment(name) unless environments.key?(name) end end def hosts_for(environ) return hosts.dup unless environments.key?(environ) environments[environ].hosts end def add_log_prefixes(host_arr) return host_arr if host_arr.length == 1 return host_arr unless host_arr.all? { |h| h.log_prefix.nil? } width = host_arr.length.to_s.length host_arr.map.with_index do |host, i| host.with_log_prefix((i + 1).to_s.rjust(width, "0")) end end def register_plugins plugins_registry = PluginsRegistry.new (["core"] + plugins.uniq).each do |plug| if plug.start_with?(".", "/") plug = File.expand_path(plug, File.dirname(path)) unless path.nil? plugins_registry.load_plugin_from_path(plug) else plugins_registry.load_plugin_by_name(plug) end end plugins_registry end def settings_with_env_overrides(environ) return settings.dup unless environments.key?(environ) settings.merge(environments[environ].settings) end def raise_no_environment_specified UnspecifiedEnvironmentError.raise_with(environments: environments.keys) end def raise_unknown_environment(environ) UnknownEnvironmentError.raise_with(name: environ, known_environments: environments.keys) end end end tomo-1.22.0/lib/tomo/templates/0000755000004100000410000000000015216001452016320 5ustar www-datawww-datatomo-1.22.0/lib/tomo/templates/plugin.rb.erb0000644000004100000410000000010015216001452020701 0ustar www-datawww-data# https://tomo.mattbrictson.com/tutorials/writing-custom-tasks/ tomo-1.22.0/lib/tomo/templates/config.rb.erb0000644000004100000410000000361115216001452020662 0ustar www-datawww-data<% if rubocop? -%> # rubocop:disable Style/FormatStringToken <% end -%> plugin "git" plugin "env" plugin "bundler" plugin "rails" plugin "nodenv" plugin "puma" plugin "rbenv" plugin "./plugins/<%= app %>.rb" host "user@hostname.or.ip.address" set application: <%= app.inspect %> set deploy_to: "/var/www/%{application}" <% unless using_ruby_version_file? -%> set rbenv_ruby_version: <%= RUBY_VERSION.inspect %> <% end -%> <% unless using_node_version_file? -%> set nodenv_node_version: <%= node_version&.inspect || "nil # FIXME" %> <% end -%> set nodenv_install_yarn: <%= yarn_version ? "true" : "false" %> set git_url: <%= git_origin_url&.inspect || "nil # FIXME" %> set git_branch: <%= git_main_branch&.inspect || "nil # FIXME" %> set git_exclusions: %w[ .tomo/ spec/ test/ ] set env_vars: { RAILS_ENV: "production", RUBY_YJIT_ENABLE: "1", BOOTSNAP_CACHE_DIR: "tmp/bootsnap-cache", DATABASE_URL: :prompt, SECRET_KEY_BASE: :generate_secret } set linked_dirs: %w[ .yarn/cache log node_modules public/assets public/packs public/vite tmp/cache tmp/pids tmp/sockets ] setup do run "env:setup" run "core:setup_directories" run "git:config" run "git:clone" run "git:create_release" run "core:symlink_shared" run "nodenv:install" run "rbenv:install" run "bundler:upgrade_bundler" run "bundler:config" run "bundler:install" run "rails:db_create" run "rails:db_schema_load" run "rails:db_seed" run "puma:setup_systemd" end deploy do run "env:update" run "git:create_release" run "core:symlink_shared" run "core:write_release_json" run "bundler:install" run "rails:db_migrate" run "rails:db_seed" run "rails:assets_precompile" run "core:symlink_current" run "puma:restart" run "puma:check_active" run "core:clean_releases" run "bundler:clean" run "core:log_revision" end <% if rubocop? -%> # rubocop:enable Style/FormatStringToken <% end -%> tomo-1.22.0/lib/tomo/plugin_dsl.rb0000644000004100000410000000115215216001452017006 0ustar www-datawww-data# frozen_string_literal: true module Tomo module PluginDSL def self.extended(mod) mod.instance_variable_set(:@helper_modules, []) mod.instance_variable_set(:@default_settings, {}) mod.instance_variable_set(:@tasks_classes, []) end attr_reader :helper_modules, :default_settings, :tasks_classes def helpers(mod, *more_mods) @helper_modules.push(mod, *more_mods) end def defaults(settings) @default_settings.merge!(settings) end def tasks(tasks_class, *more_tasks_classes) @tasks_classes.push(tasks_class, *more_tasks_classes) end end end tomo-1.22.0/lib/tomo/error.rb0000644000004100000410000000076115216001452016004 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Error < StandardError autoload :Suggestions, "tomo/error/suggestions" include Colors def self.raise_with(message=nil, attributes) err = new(message) attributes.each { |attr, value| err.public_send(:"#{attr}=", value) } raise err end private def debug_suggestion return if Tomo.debug? "For more troubleshooting info, run tomo again using the #{blue('--debug')} option." end end end tomo-1.22.0/lib/tomo/error/0000755000004100000410000000000015216001452015453 5ustar www-datawww-datatomo-1.22.0/lib/tomo/error/suggestions.rb0000644000004100000410000000202115216001452020345 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Error class Suggestions def initialize(dictionary:, word:) @dictionary = dictionary @word = word end def any? to_a.any? end def to_a @_suggestions ||= if defined?(DidYouMean::SpellChecker) checker = DidYouMean::SpellChecker.new(dictionary:) suggestions = checker.correct(word) suggestions || [] else [] end end def to_console return unless any? sentence = to_sentence(to_a.map { |word| Colors.blue(word) }) "\nDid you mean #{sentence}?\n" end private attr_reader :dictionary, :word def to_sentence(words) return words.first if words.length == 1 return words.join(" or ") if words.length == 2 words[0...-1].join(", ") + ", or " + words.last end end end end tomo-1.22.0/lib/tomo/runtime.rb0000644000004100000410000000611215216001452016332 0ustar www-datawww-data# frozen_string_literal: true require "time" module Tomo class Runtime autoload :ConcurrentRubyLoadError, "tomo/runtime/concurrent_ruby_load_error" autoload :ConcurrentRubyThreadPool, "tomo/runtime/concurrent_ruby_thread_pool" autoload :Context, "tomo/runtime/context" autoload :Current, "tomo/runtime/current" autoload :ExecutionPlan, "tomo/runtime/execution_plan" autoload :Explanation, "tomo/runtime/explanation" autoload :HostExecutionStep, "tomo/runtime/host_execution_step" autoload :InlineThreadPool, "tomo/runtime/inline_thread_pool" autoload :NoTasksError, "tomo/runtime/no_tasks_error" autoload :PrivilegedTask, "tomo/runtime/privileged_task" autoload :SettingsInterpolation, "tomo/runtime/settings_interpolation" autoload :SettingsRequiredError, "tomo/runtime/settings_required_error" autoload :TaskAbortedError, "tomo/runtime/task_aborted_error" autoload :TaskRunner, "tomo/runtime/task_runner" autoload :TemplateNotFoundError, "tomo/runtime/template_not_found_error" autoload :UnknownTaskError, "tomo/runtime/unknown_task_error" def self.local_user ENV["USER"] || ENV["USERNAME"] || `whoami`.chomp rescue StandardError nil end attr_reader :tasks def initialize(deploy_tasks:, setup_tasks:, hosts:, task_filter:, settings:, plugins_registry:) @deploy_tasks = deploy_tasks.freeze @setup_tasks = setup_tasks.freeze @hosts = hosts.freeze @task_filter = task_filter.freeze @settings = settings @plugins_registry = plugins_registry @tasks = plugins_registry.task_names freeze end def deploy! NoTasksError.raise_with(task_type: "deploy") if deploy_tasks.empty? execution_plan_for(deploy_tasks, release: :new).execute end def setup! NoTasksError.raise_with(task_type: "setup") if setup_tasks.empty? execution_plan_for(setup_tasks, release: :tmp).execute end def run!(task, *args, privileged: false) task = String.new(task).extend(PrivilegedTask) if privileged execution_plan_for([task], release: :current, args:).execute end def execution_plan_for(tasks, release: :current, args: []) ExecutionPlan.new( tasks:, hosts:, task_filter:, task_runner: new_task_runner(release, args) ) end private attr_reader :deploy_tasks, :setup_tasks, :hosts, :task_filter, :settings, :plugins_registry def new_task_runner(release_type, args) run_settings = { release_path: release_path_for(release_type) } .merge(local_user: Runtime.local_user) .merge(settings) .merge(run_args: args) TaskRunner.new(plugins_registry:, settings: run_settings) end def release_path_for(type) start_time = Time.now release = start_time.utc.strftime("%Y%m%d%H%M%S") case type when :current then "%{current_path}" when :new then "%{releases_path}/#{release}" when :tmp then "%{tmp_path}/#{release}" else raise ArgumentError, "release: must be :current, :new, or :tmp" end end end end tomo-1.22.0/lib/tomo/shell_builder.rb0000644000004100000410000000427615216001452017475 0ustar www-datawww-data# frozen_string_literal: true require "shellwords" module Tomo class ShellBuilder def self.raw(string) string.dup.tap do |raw_string| raw_string.define_singleton_method(:shellescape) { string } end end def initialize @env = {} @chdir = [] @prefixes = [] @umask = nil end def chdir(dir) @chdir << dir yield ensure @chdir.pop end def env(hash) orig_env = @env @env = orig_env.merge(hash || {}) yield ensure @env = orig_env end def prepend(*command) prefixes.unshift(*command) yield ensure prefixes.shift(command.length) end def umask(mask) orig_umask = @umask @umask = mask yield ensure @umask = orig_umask end def build(*command, default_chdir: nil) return chdir(default_chdir) { build(*command) } if @chdir.empty? && default_chdir command_string = command_to_string(*command) modifiers = [cd_chdir, unset_env, export_env, set_umask].compact.flatten [*modifiers, command_string].join(" && ") end private attr_reader :prefixes def command_to_string(*command) command_string = shell_join(*command) return command_string if prefixes.empty? "#{shell_join(*prefixes)} #{command_string}" end def shell_join(*command) return command.first.to_s if command.length == 1 command.flatten.compact.map { |arg| arg.to_s.shellescape }.join(" ") end def cd_chdir @chdir.map { |dir| "cd #{dir.to_s.shellescape}" } end def unset_env unsets = @env.select { |_, value| value.nil? } return if unsets.empty? ["unset", *unsets.map { |entry| entry.first.to_s.shellescape }].join(" ") end def export_env exports = @env.compact return if exports.empty? [ "export", *exports.map do |key, value| "#{key.to_s.shellescape}=#{value.to_s.shellescape}" end ].join(" ") end def set_umask return if @umask.nil? umask_value = @umask.is_a?(Integer) ? @umask.to_s(8).rjust(4, "0") : @umask "umask #{umask_value.to_s.shellescape}" end end end tomo-1.22.0/lib/tomo/remote.rb0000644000004100000410000000206315216001452016143 0ustar www-datawww-data# frozen_string_literal: true require "forwardable" module Tomo class Remote include TaskAPI extend Forwardable def_delegators :ssh, :close, :host def_delegators :shell_builder, :chdir, :env, :prepend, :umask attr_reader :release def initialize(ssh, context, helper_modules) @ssh = ssh @context = context @release = {} @shell_builder = ShellBuilder.new helper_modules.each { |mod| extend(mod) } freeze end def attach(*command, default_chdir: nil, **command_opts) full_command = shell_builder.build(*command, default_chdir:) ssh.ssh_exec(Script.new(full_command, pty: true, **command_opts)) end def run(*command, attach: false, default_chdir: nil, **command_opts) attach(*command, default_chdir:, **command_opts) if attach full_command = shell_builder.build(*command, default_chdir:) ssh.ssh_subprocess(Script.new(full_command, **command_opts)) end private attr_reader :context, :ssh, :shell_builder def remote self end end end tomo-1.22.0/lib/tomo/configuration/0000755000004100000410000000000015216001452017171 5ustar www-datawww-datatomo-1.22.0/lib/tomo/configuration/dsl.rb0000644000004100000410000000101115216001452020271 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Configuration module DSL autoload :BatchBlock, "tomo/configuration/dsl/batch_block" autoload :ConfigFile, "tomo/configuration/dsl/config_file" autoload :EnvironmentBlock, "tomo/configuration/dsl/environment_block" autoload :ErrorFormatter, "tomo/configuration/dsl/error_formatter" autoload :HostsAndSettings, "tomo/configuration/dsl/hosts_and_settings" autoload :TasksBlock, "tomo/configuration/dsl/tasks_block" end end end tomo-1.22.0/lib/tomo/configuration/dsl/0000755000004100000410000000000015216001452017753 5ustar www-datawww-datatomo-1.22.0/lib/tomo/configuration/dsl/hosts_and_settings.rb0000644000004100000410000000100515216001452024176 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Configuration module DSL module HostsAndSettings def set(settings) @config.settings.merge!(settings) self end def host(address, port: 22, roles: [], log_prefix: nil, privileged_user: "root") @config.hosts << Host.parse( address, privileged_user:, port:, roles:, log_prefix: ) self end end end end end tomo-1.22.0/lib/tomo/configuration/dsl/environment_block.rb0000644000004100000410000000036115216001452024016 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Configuration module DSL class EnvironmentBlock include HostsAndSettings def initialize(config) @config = config end end end end end tomo-1.22.0/lib/tomo/configuration/dsl/tasks_block.rb0000644000004100000410000000103115216001452022572 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Configuration module DSL class TasksBlock def initialize(tasks) @tasks = tasks end def batch(&) batch = [] BatchBlock.new(batch).instance_eval(&) @tasks << batch unless batch.empty? self end def run(task, privileged: false) task = String.new(task).extend(Runtime::PrivilegedTask) if privileged @tasks << task self end end end end end tomo-1.22.0/lib/tomo/configuration/dsl/config_file.rb0000644000004100000410000000153415216001452022547 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Configuration module DSL class ConfigFile include HostsAndSettings def initialize(config) @config = config end def plugin(name) @config.plugins << name.to_s self end def role(name, runs:) @config.task_filter.add_role(name, runs) self end def environment(name, &) environment = @config.environments[name.to_s] ||= Environment.new EnvironmentBlock.new(environment).instance_eval(&) self end def deploy(&) TasksBlock.new(@config.deploy_tasks).instance_eval(&) self end def setup(&) TasksBlock.new(@config.setup_tasks).instance_eval(&) self end end end end end tomo-1.22.0/lib/tomo/configuration/dsl/error_formatter.rb0000644000004100000410000000402415216001452023514 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Configuration module DSL module ErrorFormatter class << self def decorate(error, path, lines) line_no = find_line_no(path, error.message, *error.backtrace[0..1]) return error if line_no.nil? error.extend(self) error.dsl_lines = lines || [] error.dsl_path = path error.error_line_no = line_no error end private def find_line_no(path, *lines) lines.find do |line| line_no = line[/^#{Regexp.quote(path)}:(\d+):/, 1] break line_no.to_i if line_no end end end include Colors attr_accessor :dsl_lines, :dsl_path, :error_line_no def to_console <<~ERROR Configuration syntax error in #{yellow(dsl_path)} at line #{yellow(error_line_no)}. #{highlighted_lines} #{Colors.red([self.class, message].join(': '))} Visit #{Colors.blue('https://tomo.mattbrictson.com/configuration')} for syntax reference. #{trace_hint} ERROR end private def trace_hint return "" if CLI.show_backtrace <<~HINT You can run this command again with #{Colors.blue('--trace')} for a full backtrace. HINT end def highlighted_lines # rubocop:disable Metrics/AbcSize first = [1, error_line_no - 1].max last = [dsl_lines.length, error_line_no + 1].min width = last.to_s.length (first..last).each_with_object(+"") do |line_no, result| line = dsl_lines[line_no - 1] line_no_prefix = line_no.to_s.rjust(width) result << if line_no == error_line_no yellow("→ #{line_no_prefix}: #{line}") else " #{line_no_prefix}: #{line}" end end end end end end end tomo-1.22.0/lib/tomo/configuration/dsl/batch_block.rb0000644000004100000410000000056415216001452022540 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Configuration module DSL class BatchBlock def initialize(batch) @batch = batch end def run(task, privileged: false) task = String.new(task).extend(Runtime::PrivilegedTask) if privileged @batch << task self end end end end end tomo-1.22.0/lib/tomo/configuration/project_not_found_error.rb0000644000004100000410000000120615216001452024447 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Configuration class ProjectNotFoundError < Tomo::Error attr_accessor :path def to_console path == DEFAULT_CONFIG_PATH ? default_message : custom_message end private def default_message <<~ERROR A #{yellow(path)} configuration file is required to run this command. Are you in the right directory? To create a new #{yellow(path)} file, run #{blue('tomo init')}. ERROR end def custom_message <<~ERROR #{yellow(path)} does not exist. ERROR end end end end tomo-1.22.0/lib/tomo/configuration/unknown_plugin_error.rb0000644000004100000410000000204515216001452024005 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Configuration class UnknownPluginError < Tomo::Error attr_accessor :name, :known_plugins, :gem_name def to_console error = <<~ERROR #{yellow(name)} is not a recognized plugin. ERROR sugg = Error::Suggestions.new(dictionary: known_plugins, word: name) error += sugg.to_console if sugg.any? error + gem_suggestion end private def gem_suggestion return "\nYou may need to add #{yellow(gem_name)} to your Gemfile." if Tomo.bundled? messages = ["\nYou may need to install the #{yellow(gem_name)} gem."] if present_in_gemfile? messages << "\nTry prefixing the tomo command with #{blue('bundle exec')} to fix this error." end messages.join end def present_in_gemfile? return false unless File.file?("Gemfile") File.read("Gemfile").match?(/^\s*gem ['"]#{Regexp.quote(gem_name)}['"]/) rescue IOError false end end end end tomo-1.22.0/lib/tomo/configuration/unspecified_environment_error.rb0000644000004100000410000000130215216001452025645 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Configuration class UnspecifiedEnvironmentError < Tomo::Error attr_accessor :environments def to_console <<~ERROR No environment specified. This is a multi-environment project. To run a remote task you must specify which environment to use by including the #{blue('-e')} option. Run tomo again with one of these options to specify the environment: #{env_options} ERROR end private def env_options environments.each_with_object([]) do |env, options| options << blue(" -e #{env}") end.join("\n") end end end end tomo-1.22.0/lib/tomo/configuration/plugins_registry.rb0000644000004100000410000000361315216001452023132 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Configuration class PluginsRegistry autoload :FileResolver, "tomo/configuration/plugins_registry/file_resolver" autoload :GemResolver, "tomo/configuration/plugins_registry/gem_resolver" attr_reader :helper_modules, :settings def initialize @settings = {} @helper_modules = [] @namespaced_classes = [] end def task_names bind_tasks(nil).keys end def bind_tasks(context) namespaced_classes.each_with_object({}) do |(namespace, klass), result| library = klass.new(context) klass.public_instance_methods(false).each do |name| qualified = [namespace, name].compact.join(":") result[qualified] = library.public_method(name) end end end def load_plugin_by_name(name) plugin = GemResolver.resolve(name) load_plugin(name, plugin) end def load_plugin_from_path(path) name = File.basename(path).sub(/\.rb$/i, "") plugin = FileResolver.resolve(path) load_plugin(name, plugin) end def load_plugin(namespace, plugin_class) Tomo.logger.debug("Loading plugin #{plugin_class}") helper_modules.push(*plugin_class.helper_modules) settings.merge!(plugin_class.default_settings) { |_, exist, _| exist } register_task_libraries(namespace, *plugin_class.tasks_classes) end private attr_reader :namespaced_classes def register_task_libraries(namespace, *library_classes) library_classes.each { |cls| register_task_library(namespace, cls) } end def register_task_library(namespace, library_class) Tomo.logger.debug("Registering task library #{library_class} (#{namespace.inspect} namespace)") namespaced_classes << [namespace, library_class] end end end end tomo-1.22.0/lib/tomo/configuration/plugin_file_not_found_error.rb0000644000004100000410000000050715216001452025301 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Configuration class PluginFileNotFoundError < Error attr_accessor :path def to_console <<~ERROR A plugin specified by this project could not be loaded. File does not exist: #{yellow(path)} ERROR end end end end tomo-1.22.0/lib/tomo/configuration/role_based_task_filter.rb0000644000004100000410000000173015216001452024205 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Configuration class RoleBasedTaskFilter def initialize @globs = {} end def freeze globs.freeze super end def add_role(name, task_specs) name = name.to_s task_globs = Array(task_specs).flatten.map { |spec| Glob.new(spec) } task_globs.each do |task_glob| (globs[task_glob] ||= []) << name end end def filter(tasks, host:) roles = host.roles roles = [""] if roles.empty? tasks.select do |task| roles.any? { |role| match?(task, role) } end end private attr_reader :globs def match?(task, role) task_globs = globs.keys.select { |glob| glob.match?(task) } # rubocop:disable Style/SelectByRegexp return true if task_globs.empty? roles = globs.values_at(*task_globs).flatten roles.include?(role) end end end end tomo-1.22.0/lib/tomo/configuration/environment.rb0000644000004100000410000000033215216001452022060 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Configuration class Environment attr_accessor :hosts, :settings def initialize @hosts = [] @settings = {} end end end end tomo-1.22.0/lib/tomo/configuration/plugins_registry/0000755000004100000410000000000015216001452022602 5ustar www-datawww-datatomo-1.22.0/lib/tomo/configuration/plugins_registry/gem_resolver.rb0000644000004100000410000000305015216001452025616 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Configuration class PluginsRegistry::GemResolver PLUGIN_PREFIX = "tomo/plugin" private_constant :PLUGIN_PREFIX def self.resolve(name) new(name).plugin_module end def initialize(name) @name = name end def plugin_module plugin_path = [PLUGIN_PREFIX, name.tr("-", "/")].join("/") require plugin_path plugin = constantize(plugin_path) assert_compatible_api(plugin) plugin rescue LoadError => e raise unless e.message.match?(/\s#{Regexp.quote(plugin_path)}$/) raise_unknown_plugin_error(e) end private attr_reader :name def assert_compatible_api(plugin) return if plugin.is_a?(::Tomo::PluginDSL) raise "#{plugin} does not extend Tomo::PluginDSL" end def constantize(path) parts = path.split("/") parts.reduce(Object) do |parent, part| child = part.gsub(/^[a-z]|_[a-z]/) { |str| str[-1].upcase } parent.const_get(child, false) end end def raise_unknown_plugin_error(error) UnknownPluginError.raise_with( error.message, name:, gem_name: "#{PLUGIN_PREFIX}/#{name}".tr("/", "-"), known_plugins: scan_for_plugins ) end def scan_for_plugins Gem.find_latest_files("#{PLUGIN_PREFIX}/*.rb").map do |file| file[%r{#{PLUGIN_PREFIX}/(.+).rb$}o, 1].tr("/", "-") end.uniq.sort end end end end tomo-1.22.0/lib/tomo/configuration/plugins_registry/file_resolver.rb0000644000004100000410000000174615216001452025777 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Configuration class PluginsRegistry::FileResolver def self.resolve(path) new(path).plugin_module end def initialize(path) @path = path end def plugin_module raise_file_not_found(path) unless File.file?(path) Tomo.logger.debug("Loading plugin from #{path.inspect}") script = File.read(path) plugin = define_anonymous_plugin_class plugin.class_eval(script, path.to_s, 1) plugin end private attr_reader :path def raise_file_not_found(path) PluginFileNotFoundError.raise_with(path:) end def define_anonymous_plugin_class name = path.to_s plugin = Class.new(TaskLibrary) plugin.extend(PluginDSL) plugin.send(:tasks, plugin) plugin.define_singleton_method(:to_s) do super().sub(/>$/, "(#{name})>") end plugin end end end end tomo-1.22.0/lib/tomo/configuration/glob.rb0000644000004100000410000000077115216001452020446 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Configuration class Glob def initialize(spec) @spec = spec.to_s.freeze regexp_parts = @spec.split(/(\*)/).map do |part| part == "*" ? ".*" : Regexp.quote(part) end @regexp = Regexp.new(regexp_parts.join).freeze freeze end def match?(str) regexp.match?(str) end def to_s spec end private attr_reader :regexp, :spec end end end tomo-1.22.0/lib/tomo/configuration/unknown_environment_error.rb0000644000004100000410000000201315216001452025046 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Configuration class UnknownEnvironmentError < Tomo::Error attr_accessor :name, :known_environments def to_console known_environments.empty? ? no_envs : wrong_envs end private def no_envs <<~ERROR This project does not have distinct environments. Run tomo again without the #{yellow("-e #{name}")} option. ERROR end def wrong_envs error = <<~ERROR #{yellow(name)} is not a recognized environment for this project. ERROR if suggestions.any? error + suggestions.to_console else envs = known_environments.map { |env| blue(" #{env}") } error + <<~ENVS The following environments are available: #{envs.join("\n")} ENVS end end def suggestions @_suggestions ||= Error::Suggestions.new(dictionary: known_environments, word: name) end end end end tomo-1.22.0/lib/tomo/runtime/0000755000004100000410000000000015216001452016005 5ustar www-datawww-datatomo-1.22.0/lib/tomo/runtime/concurrent_ruby_load_error.rb0000644000004100000410000000130115216001452023760 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Runtime class ConcurrentRubyLoadError < Tomo::Error attr_accessor :version def to_console <<~ERROR Running tasks on multiple hosts requires the #{yellow('concurrent-ruby')} gem. To install it, #{install_instructions} ERROR end private def install_instructions if Tomo.bundled? gem_entry = %Q(gem "concurrent-ruby", "#{version}") "add this entry to your Gemfile:\n\n #{blue(gem_entry)}" else gem_install = "gem install concurrent-ruby -v '#{version}'" "run:\n\n #{blue(gem_install)}" end end end end end tomo-1.22.0/lib/tomo/runtime/explanation.rb0000644000004100000410000000237015216001452020656 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Runtime class Explanation def initialize(applicable_hosts, plan, concurrency) @applicable_hosts = applicable_hosts @plan = plan @concurrency = concurrency end def to_s # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity desc = [] threads = [applicable_hosts.length, concurrency].min desc << "CONCURRENTLY (#{threads} THREADS):" if threads > 1 applicable_hosts.each do |host| indent = threads > 1 ? " = " : "" desc << "#{indent}CONNECT #{host}" end plan.each do |steps| threads = [steps.length, concurrency].min desc << "CONCURRENTLY (#{threads} THREADS):" if threads > 1 steps.each do |step| indent = threads > 1 ? " = " : "" if threads > 1 && step.applicable_tasks.length > 1 desc << "#{indent}IN SEQUENCE:" indent = indent.sub("=", " ") end desc << step.explain.gsub(/^/, indent) end end desc.join("\n") end private attr_reader :applicable_hosts, :plan, :concurrency end end end tomo-1.22.0/lib/tomo/runtime/task_runner.rb0000644000004100000410000000272015216001452020666 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Runtime class TaskRunner extend Forwardable def_delegators :@context, :paths, :settings attr_reader :context def initialize(plugins_registry:, settings:) interpolated_settings = SettingsInterpolation.interpolate( plugins_registry.settings.merge(settings) ) @helper_modules = plugins_registry.helper_modules.freeze @context = Context.new(interpolated_settings) @tasks_by_name = plugins_registry.bind_tasks(context).freeze freeze end def validate_task!(name) return if tasks_by_name.key?(name) UnknownTaskError.raise_with(name, unknown_task: name, known_tasks: tasks_by_name.keys) end def run(task:, remote:) validate_task!(task) Current.with(task:, remote:) do Tomo.logger.task_start(task) tasks_by_name[task].call end end def connect(host) Current.with(host:) do conn = SSH.connect(host:, options: ssh_options) remote = Remote.new(conn, context, helper_modules) return remote unless block_given? begin return yield(remote) ensure remote&.close if block_given? end end end private attr_reader :helper_modules, :tasks_by_name def ssh_options settings.slice(*SSH::Options::DEFAULTS.keys) end end end end tomo-1.22.0/lib/tomo/runtime/concurrent_ruby_thread_pool.rb0000644000004100000410000000202015216001452024127 0ustar www-datawww-data# frozen_string_literal: true concurrent_ver = "~> 1.1" begin gem "concurrent-ruby", concurrent_ver require "concurrent" rescue LoadError => e Tomo::Runtime::ConcurrentRubyLoadError.raise_with(e.message, version: concurrent_ver) end module Tomo class Runtime class ConcurrentRubyThreadPool include ::Concurrent::Promises::FactoryMethods def initialize(size) @executor = ::Concurrent::FixedThreadPool.new(size) @promises = [] end def post(...) return if failure? promises << future_on(executor, ...) .on_rejection_using(executor) do |reason| self.failure = reason end nil end def run_to_completion promises_to_wait = promises.dup promises.clear zip_futures_on(executor, *promises_to_wait).value raise failure if failure? end def failure? !!failure end private attr_accessor :failure attr_reader :executor, :promises end end end tomo-1.22.0/lib/tomo/runtime/privileged_task.rb0000644000004100000410000000014715216001452021510 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Runtime module PrivilegedTask end end end tomo-1.22.0/lib/tomo/runtime/no_tasks_error.rb0000644000004100000410000000076515216001452021374 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Runtime class NoTasksError < Error attr_accessor :task_type def to_console <<~ERROR No #{task_type} tasks are configured. You can specify them using a #{yellow(task_type)} block in #{yellow(Tomo::DEFAULT_CONFIG_PATH)}. More configuration documentation and examples can be found here: #{blue('https://tomo.mattbrictson.com/configuration')} ERROR end end end end tomo-1.22.0/lib/tomo/runtime/unknown_task_error.rb0000644000004100000410000000172215216001452022266 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Runtime class UnknownTaskError < Error attr_accessor :unknown_task, :known_tasks def to_console error = <<~ERROR #{yellow(unknown_task)} is not a recognized task. To see a list of all available tasks, run #{blue('tomo tasks')}. ERROR sugg = spelling_suggestion || missing_plugin_suggestion error += sugg if sugg error end private def spelling_suggestion sugg = Error::Suggestions.new(dictionary: known_tasks, word: unknown_task) sugg.to_console if sugg.any? end def missing_plugin_suggestion unknown_plugin = unknown_task[/\A(.+?):/, 1] known_plugins = known_tasks.map { |t| t.split(":").first }.uniq return if unknown_plugin.nil? || known_plugins.include?(unknown_plugin) "\nDid you forget to install the #{blue(unknown_plugin)} plugin?" end end end end tomo-1.22.0/lib/tomo/runtime/inline_thread_pool.rb0000644000004100000410000000065015216001452022171 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Runtime class InlineThreadPool def post(*) return if failure? yield(*) nil rescue StandardError => e self.failure = e nil end def run_to_completion raise failure if failure? end def failure? !!failure end private attr_accessor :failure end end end tomo-1.22.0/lib/tomo/runtime/task_aborted_error.rb0000644000004100000410000000045715216001452022213 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Runtime class TaskAbortedError < Tomo::Error attr_accessor :task, :host def to_console <<~ERROR The #{yellow(task)} task failed on #{yellow(host)}. #{red(message)} ERROR end end end end tomo-1.22.0/lib/tomo/runtime/execution_plan.rb0000644000004100000410000000541215216001452021351 0ustar www-datawww-data# frozen_string_literal: true require "forwardable" module Tomo class Runtime class ExecutionPlan extend Forwardable def_delegators :@task_runner, :paths, :settings attr_reader :applicable_hosts def initialize(tasks:, hosts:, task_filter:, task_runner:) @hosts = hosts @task_runner = task_runner @plan = build_plan(tasks, task_filter) @applicable_hosts = gather_applicable_hosts @thread_pool = build_thread_pool freeze validate_tasks! end def applicable_hosts_sentence return "no hosts" if applicable_hosts.empty? case applicable_hosts.length when 1 then applicable_hosts.first.to_s when 2 then applicable_hosts.map(&:to_s).join(" and ") else "#{applicable_hosts.first} and #{applicable_hosts.length - 1} other hosts" end end def execute Tomo.logger.debug("Execution plan:\n#{explain}") open_connections do |remotes| plan.each do |steps| steps.each do |step| step.execute(thread_pool:, remotes:) end thread_pool.run_to_completion end end self end def explain Explanation.new(applicable_hosts, plan, concurrency).to_s end private attr_reader :hosts, :plan, :task_runner, :thread_pool def validate_tasks! plan.each do |steps| steps.each do |step| step.applicable_tasks.each do |task| task_runner.validate_task!(task) end end end end def open_connections remotes = applicable_hosts.each_with_object({}) do |host, opened| thread_pool.post(host) do |thr_host| opened[thr_host] = task_runner.connect(thr_host) end end thread_pool.run_to_completion yield(remotes) ensure (remotes || {}).each_value(&:close) end def build_plan(tasks, task_filter) tasks.each_with_object([]) do |task, result| steps = hosts.map do |host| HostExecutionStep.new(tasks: task, host:, task_filter:, task_runner:) end steps.reject!(&:empty?) result << steps unless steps.empty? end end def gather_applicable_hosts plan.each_with_object([]) do |steps, result| steps.each do |step| result.push(*step.applicable_hosts) end end.uniq end def build_thread_pool if plan.map(&:length).max.to_i > 1 ConcurrentRubyThreadPool.new(concurrency) else InlineThreadPool.new end end def concurrency [settings[:concurrency].to_i, 1].max end end end end tomo-1.22.0/lib/tomo/runtime/settings_interpolation.rb0000644000004100000410000000317515216001452023147 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Runtime class SettingsInterpolation def self.interpolate(settings) new(settings).call end def initialize(settings) @settings = symbolize(settings) end def call hash = settings.keys.to_h { |name| [name, fetch(name)] } dump_settings(hash) if Tomo.debug? hash end private attr_reader :settings def fetch(name, stack=[]) raise_circular_dependency_error(name, stack) if stack.include?(name) stack += [name] value = settings.fetch(name) if value.is_a?(String) interpolate_string(value, stack) elsif value.is_a?(Hash) value.transform_values { |v| v.is_a?(String) ? interpolate_string(v, stack) : v } else value end end def interpolate_string(value, stack) value.gsub(/%{(\w+)}/) do fetch(Regexp.last_match[1].to_sym, stack) end end def raise_circular_dependency_error(name, stack) dependencies = [*stack, name].join(" -> ") raise "Circular dependency detected in settings: #{dependencies}" end def symbolize(hash) hash.transform_keys(&:to_sym) end def dump_settings(hash) key_len = hash.keys.map { |k| k.to_s.length }.max dump = +"Settings: {\n" hash.to_a.sort_by(&:first).each do |key, value| justified_key = "#{key}:".ljust(key_len + 1) dump << " #{justified_key} #{value.inspect},\n" end dump << "}" Tomo.logger.debug(dump) end end end end tomo-1.22.0/lib/tomo/runtime/context.rb0000644000004100000410000000056715216001452020026 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Runtime class Context attr_reader :paths, :settings def initialize(settings) @paths = Paths.new(settings) @settings = settings.freeze freeze end def current_remote Current.remote end def current_task Current.task end end end end tomo-1.22.0/lib/tomo/runtime/current.rb0000644000004100000410000000144415216001452020017 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Runtime module Current class << self def host fiber_locals[:host] || remote&.host end def remote fiber_locals[:remote] end def task fiber_locals[:task] end def with(new_locals) old_locals = slice(*new_locals.keys) fiber_locals.merge!(new_locals) yield ensure fiber_locals.merge!(old_locals) end def variables fiber_locals.dup.freeze end private def slice(*keys) keys.to_h { |key| [key, fiber_locals[key]] } end def fiber_locals Thread.current["Tomo::Runtime::Current"] ||= {} end end end end end tomo-1.22.0/lib/tomo/runtime/template_not_found_error.rb0000644000004100000410000000033515216001452023432 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Runtime class TemplateNotFoundError < Error attr_accessor :path def to_console "Template not found: #{yellow(path)}" end end end end tomo-1.22.0/lib/tomo/runtime/host_execution_step.rb0000644000004100000410000000300515216001452022423 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Runtime class HostExecutionStep attr_reader :applicable_hosts, :applicable_tasks def initialize(tasks:, host:, task_filter:, task_runner:) tasks = Array(tasks).flatten @host = host @task_runner = task_runner @applicable_tasks = task_filter.filter(tasks, host: @host).freeze @applicable_hosts = compute_applicable_hosts freeze end def empty? applicable_tasks.empty? end def execute(thread_pool:, remotes:) return if applicable_tasks.empty? thread_pool.post do applicable_tasks.each do |task| break if thread_pool.failure? task_host = task.is_a?(PrivilegedTask) ? host.as_privileged : host remote = remotes[task_host] task_runner.run(task:, remote:) end end end def explain desc = [] applicable_tasks.each do |task| task_host = task.is_a?(PrivilegedTask) ? host.as_privileged : host desc << "RUN #{task} ON #{task_host}" end desc.join("\n") end private attr_reader :host, :task_runner def compute_applicable_hosts priv_tasks, normal_tasks = applicable_tasks.partition do |task| task.is_a?(PrivilegedTask) end hosts = [] hosts << host if normal_tasks.any? hosts << host.as_privileged if priv_tasks.any? hosts.uniq.freeze end end end end tomo-1.22.0/lib/tomo/runtime/settings_required_error.rb0000644000004100000410000000161015216001452023301 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Runtime class SettingsRequiredError < Tomo::Error attr_accessor :command_name, :settings, :task def to_console <<~ERROR The #{yellow(task)} task requires #{settings_sentence} Settings can be specified in #{blue(DEFAULT_CONFIG_PATH)}, or by running tomo with the #{blue('-s')} option. For example: #{blue("tomo -s #{settings.first}=foo")} You can also use environment variables: #{blue("TOMO_#{settings.first.upcase}=foo tomo #{command_name}")} ERROR end private def settings_sentence return "a value for the #{yellow(settings.first.to_s)} setting." if settings.length == 1 sentence = "values for these settings:\n\n " sentence + settings.map { |s| yellow(s.to_s) }.join("\n ") end end end end tomo-1.22.0/lib/tomo/logger/0000755000004100000410000000000015216001452015601 5ustar www-datawww-datatomo-1.22.0/lib/tomo/logger/tagged_io.rb0000644000004100000410000000136215216001452020052 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Logger class TaggedIO include Colors def initialize(io) @io = io end def puts(str) io.puts(str.to_s.gsub(/^/, prefix)) end private attr_reader :io def prefix host = Runtime::Current.host return "" if host.nil? tags = [] tags << red("*") if Tomo.dry_run? tags << grayish("[#{host.log_prefix}]") unless host.log_prefix.nil? return "" if tags.empty? "#{tags.join(' ')} " end def grayish(str) parts = str.split(/(\e.*?\e\[0m)/) parts.map! do |part| part.start_with?("\e") ? part : gray(part) end.join end end end end tomo-1.22.0/lib/tomo/host.rb0000644000004100000410000000274515216001452015634 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Host PATTERN = /^(?:(\S+)@)?(\S*?)$/ private_constant :PATTERN attr_reader :address, :log_prefix, :user, :port, :roles, :as_privileged def self.parse(host, **) host = host.to_s.strip user, address = host.match(PATTERN).captures raise ArgumentError, "host cannot be blank" if address.empty? new(user:, address:, **) end def initialize(address:, port: nil, log_prefix: nil, roles: nil, user: nil, privileged_user: "root") @user = user.freeze @port = (port || 22).to_i.freeze @address = address.freeze @log_prefix = log_prefix.freeze @roles = Array(roles).map(&:freeze).freeze @as_privileged = privileged_copy(privileged_user) freeze end def with_log_prefix(prefix) copy = dup copy.instance_variable_set(:@log_prefix, prefix) copy.freeze end def to_s str = user ? "#{user}@#{address}" : address str += ":#{port}" unless port == 22 str end def to_ssh_args args = [user ? "#{user}@#{address}" : address] args.push("-p", port.to_s) unless port == 22 args end private def privileged_copy(priv_user) return self if user == priv_user new_prefix = Colors.red([log_prefix, priv_user].compact.join(":")) copy = dup copy.instance_variable_set(:@user, priv_user) copy.instance_variable_set(:@log_prefix, new_prefix) copy.freeze end end end tomo-1.22.0/lib/tomo/cli/0000755000004100000410000000000015216001452015071 5ustar www-datawww-datatomo-1.22.0/lib/tomo/cli/usage.rb0000644000004100000410000000162715216001452016530 0ustar www-datawww-data# frozen_string_literal: true module Tomo class CLI class Usage def initialize @options = [] @banner_proc = proc { "" } end def add_option(spec, desc) options << [ spec.start_with?("--") ? " #{spec}" : spec, desc ] end def banner=(banner) @banner_proc = banner.respond_to?(:call) ? banner : proc { banner } end def to_s indent(["", banner_proc.call, "Options:", "", indent(options_help), "\n"].join("\n")) end private attr_reader :banner_proc, :options def options_help width = options.map { |opt| opt.first.length }.max options.each_with_object([]) do |(spec, desc), help| help << "#{Colors.yellow(spec.ljust(width))} #{desc}" end.join("\n") end def indent(str) str.gsub(/^/, " ") end end end end tomo-1.22.0/lib/tomo/cli/rules/0000755000004100000410000000000015216001452016223 5ustar www-datawww-datatomo-1.22.0/lib/tomo/cli/rules/switch.rb0000644000004100000410000000147215216001452020055 0ustar www-datawww-data# frozen_string_literal: true class Tomo::CLI::Rules class Switch def initialize(key, *switches, callback_proc:, required: false, &convert_proc) @key = key @switches = switches @callback_proc = callback_proc @convert_proc = convert_proc || proc { true } @required = required end def match(arg, literal: false) return nil if literal 1 if switches.include?(arg) end def process(arg, state:) value = convert_proc.call(arg) callback_proc&.call(value) state.parsed_option(key, value) end def candidates(literal: false, **_kwargs) literal ? [] : switches end def required? @required end def multiple? true end private attr_reader :key, :switches, :convert_proc, :callback_proc end end tomo-1.22.0/lib/tomo/cli/rules/argument.rb0000644000004100000410000000143015216001452020370 0ustar www-datawww-data# frozen_string_literal: true class Tomo::CLI::Rules class Argument def initialize(label, values_proc:, multiple: false, required: false) @label = label @multiple = multiple @required = required @values_proc = values_proc end def match(arg, literal: false) 1 if literal || !arg.start_with?("-") end def process(arg, state:) state.parsed_arg(arg) end def candidates(state:, literal: false) values(state).reject { |val| literal && val.start_with?("-") } end def required? @required end def multiple? @multiple end def to_s @label end private attr_reader :values_proc def values(state) values_proc.call(*state.args, state.options) end end end tomo-1.22.0/lib/tomo/cli/rules/value_switch.rb0000644000004100000410000000266615216001452021257 0ustar www-datawww-data# frozen_string_literal: true class Tomo::CLI::Rules class ValueSwitch < Switch include Tomo::Colors def initialize(key, *switches, values_proc:, callback_proc:) super(key, *switches, callback_proc:) @values_proc = values_proc end def match(arg, literal: false) return nil if literal return 2 if switches.include?(arg) 1 if arg.start_with?("--") && switches.include?(arg.split("=").first) end def process(switch, arg=nil, state:) value = if switch.include?("=") switch.split("=", 2).last elsif !arg.to_s.start_with?("-") arg end raise_missing_value(switch) if value.nil? callback_proc&.call(value) state.parsed_option(key, value) end def candidates(switch=nil, state:, literal: false) return [] if literal vals = values(state) return vals.reject { |val| val.start_with?("-") } if switch switches.each_with_object([]) do |each_switch, result| result << each_switch vals.each do |value| result << "#{each_switch}=#{value}" if each_switch.start_with?("--") end end end private attr_reader :values_proc def values(state) values_proc.call(*state.args, state.options) end def raise_missing_value(switch) raise Tomo::CLI::Error, "Please specify a value for the #{yellow(switch)} option." end end end tomo-1.22.0/lib/tomo/cli/error.rb0000644000004100000410000000052615216001452016552 0ustar www-datawww-data# frozen_string_literal: true module Tomo class CLI class Error < ::Tomo::Error attr_accessor :command_name def to_console tomo_command = ["tomo", command_name].compact.join(" ") <<~ERROR #{message} Run #{blue("#{tomo_command} -h")} for help. ERROR end end end end tomo-1.22.0/lib/tomo/cli/rules.rb0000644000004100000410000000462215216001452016554 0ustar www-datawww-data# frozen_string_literal: true module Tomo class CLI class Rules autoload :Argument, "tomo/cli/rules/argument" autoload :Switch, "tomo/cli/rules/switch" autoload :ValueSwitch, "tomo/cli/rules/value_switch" ARG_PATTERNS = { /\A\[[A-Z_]+\]\z/ => :optional_arg_rule, /\A[A-Z_]+\z/ => :required_arg_rule, /\A\[[A-Z_]+\.\.\.\]\z/ => :mutiple_optional_args_rule }.freeze OPTION_PATTERNS = { /\A--\[no-\]([-a-z]+)\z/ => :on_off_switch_rule, /\A(-[a-z]), (--[-a-z]+)\z/ => :basic_switch_rule, /\A(-[a-z]), (--[-a-z]+) [A-Z=_-]+\z/ => :value_switch_rule }.freeze private_constant :ARG_PATTERNS, :OPTION_PATTERNS def initialize @rules = [] end def add_arg(spec, values_proc) rule = ARG_PATTERNS.find do |regexp, method| break send(method, spec, values_proc) if regexp.match?(spec) end raise ArgumentError, "Unrecognized arg style: #{spec}" if rule.nil? rules << rule end def add_option(key, spec, values_proc, &block) rule = OPTION_PATTERNS.find do |regexp, method| match = regexp.match(spec) break send(method, key, *match.captures, values_proc, block) if match end raise ArgumentError, "Unrecognized option style: #{spec}" if rule.nil? rules << rule end def to_a rules end private attr_reader :rules def optional_arg_rule(spec, values_proc) Rules::Argument.new(spec, values_proc:, required: false, multiple: false) end def required_arg_rule(spec, values_proc) Rules::Argument.new(spec, values_proc:, required: true, multiple: false) end def mutiple_optional_args_rule(spec, values_proc) Rules::Argument.new(spec, multiple: true, values_proc:) end def on_off_switch_rule(key, name, _values_proc, callback_proc) Rules::Switch.new(key, "--#{name}", "--no-#{name}", callback_proc:) do |arg| arg == "--#{name}" end end def basic_switch_rule(key, *switches, _values_proc, callback_proc) Rules::Switch.new(key, *switches, callback_proc:) end def value_switch_rule(key, *switches, values_proc, callback_proc) Rules::ValueSwitch.new(key, *switches, values_proc:, callback_proc:) end end end end tomo-1.22.0/lib/tomo/cli/common_options.rb0000644000004100000410000000262115216001452020462 0ustar www-datawww-data# frozen_string_literal: true module Tomo class CLI module CommonOptions def self.included(mod) # rubocop:disable Metrics/MethodLength mod.class_eval do option :color, "--[no-]color", "Enable/disable color output" do |color| Colors.enabled = color end option :debug, "--[no-]debug", "Enable/disable verbose debug logging" do |debug| Tomo.debug = debug end option :quiet, "--[no-]quiet", "Silence all progress output" do |quiet| Tomo.quiet = quiet end option :trace, "--[no-]trace", "Display full backtrace on error" do |trace| CLI.show_backtrace = trace end option :help, "-h, --help", "Print this documentation" do |_help| puts instance_variable_get(:@parser) CLI.exit end after_parse :dump_runtime_info end end private def dump_runtime_info(*) Tomo.logger.debug("tomo #{Tomo::VERSION}") Tomo.logger.debug(RUBY_DESCRIPTION) Tomo.logger.debug("rubygems #{Gem::VERSION}") Tomo.logger.debug("bundler #{Bundler::VERSION}") if Tomo.bundled? begin require "concurrent" Tomo.logger.debug("concurrent-ruby #{Concurrent::VERSION}") rescue LoadError # rubocop:disable Lint/SuppressedException end end end end end tomo-1.22.0/lib/tomo/cli/interrupted_error.rb0000644000004100000410000000024115216001452021171 0ustar www-datawww-data# frozen_string_literal: true module Tomo class CLI class InterruptedError < Error def to_console "Interrupted" end end end end tomo-1.22.0/lib/tomo/cli/deploy_options.rb0000644000004100000410000000312315216001452020464 0ustar www-datawww-data# frozen_string_literal: true module Tomo class CLI module DeployOptions def self.included(mod) # rubocop:disable Metrics/MethodLength mod.class_eval do option :environment, "-e, --environment ENVIRONMENT", "Specify environment to use (e.g. production)", values: :environment_names option :settings, "-s, --setting NAME=VALUE", "Override setting NAME with the given VALUE", values: :setting_completions option :dry_run, "--[no-]dry-run", "Simulate running tasks instead of using real SSH" do |dry| Tomo.dry_run = dry end after_parse :prompt_for_environment end end private def environment_names(*_args, options) load_configuration(options).environments.keys end def setting_completions(*_args, options) runtime = configure_runtime(options, strict: false) settings = runtime.execution_plan_for([]).settings settings = settings.select do |_key, value| value.nil? || value.is_a?(String) || value.is_a?(Numeric) end.to_h settings.keys.map { |sett| "#{sett}=" } end def prompt_for_environment(*, options) return unless options[:environment].nil? envs = environment_names(*, options) return if envs.empty? return unless Console.interactive? options[:environment] = Console.menu("Choose an environment:", choices: envs) end end end end tomo-1.22.0/lib/tomo/cli/command.rb0000644000004100000410000000154115216001452017035 0ustar www-datawww-data# frozen_string_literal: true module Tomo class CLI class Command class << self def arg(spec, values: []) parser.arg(spec, values:) end def option(key, spec, desc=nil, values: [], &) parser.option(key, spec, desc, values:, &) end def after_parse(context_method_name) parser.after_parse(context_method_name) end def parser @parser ||= Parser.new end def parse(argv) command = new parser.context = command parser.banner = command.method(:banner) *args, options = parser.parse(argv) command.call(*args, options) end end include Colors private def dry_run? Tomo.dry_run? end def logger Tomo.logger end end end end tomo-1.22.0/lib/tomo/cli/completions.rb0000644000004100000410000000452515216001452017760 0ustar www-datawww-data# frozen_string_literal: true module Tomo class CLI class Completions def self.activate @active = true end def self.active? defined?(@active) && @active end def initialize(literal: false, stdout: $stdout) @literal = literal @stdout = stdout end def print_completions_and_exit(rules, *args, state:) completions = completions_for(rules, *args, state) words = completions.map { |c| bash_word_for(c, args.last) } Tomo.logger.info(words.join("\n")) unless words.empty? CLI.exit end private attr_reader :literal, :stdout def completions_for(rules, *prefix_args, word, state) all_candidates(rules, prefix_args, state).select do |cand| next if !literal && redundant_option_completion?(cand, word) cand.start_with?(word) && cand != word end end def all_candidates(rules, prefix_args, state) rules.flat_map do |rule| rule.candidates(*prefix_args, literal:, state:) end end def redundant_option_completion?(cand, word) # Don't complete switches until the user has typed at least "--" return true if cand.start_with?("-") && !word.start_with?("--") # Don't complete the =value part of long switch unless the user has # already typed at least up to the = sign. true if cand.match?(/\A--.*=/) && !word.match?(/\A--.*=/) end # bash tokenizes the user's input prior to completion, and expects the # completions we return to be only the last token of the string. So if the # user typed "rails:c[TAB]", bash expects the completion to be the part # after the ":" character. In other words we should return "console", not # "rails:console". The special tokens we need to consider are ":" and "=". # # For convenience we also distinguish a partial completion vs a full # completion. If it is a full completion we append a " " to the end of # the word so that the user can naturally begin typing the next option or # argument. def bash_word_for(completion, user_input) completion = "#{completion} " unless completion.end_with?("=") return completion unless user_input.match?(/[:=]/) completion.sub(/.*[:=]/, "") end end end end tomo-1.22.0/lib/tomo/cli/state.rb0000644000004100000410000000104215216001452016533 0ustar www-datawww-data# frozen_string_literal: true module Tomo class CLI class State attr_reader :args, :options, :processed_rules def initialize @args = [] @options = Options.new @processed_rules = [] end def parsed_arg(arg) args << arg end def parsed_option(key, value) options.all(key) << value end def processed_rule(rule) @processed_rules |= [rule] end def processed?(rule) @processed_rules.include?(rule) end end end end tomo-1.22.0/lib/tomo/cli/options.rb0000644000004100000410000000111315216001452017105 0ustar www-datawww-data# frozen_string_literal: true module Tomo class CLI class Options def initialize @options = {} end def any? options.any? end def key?(key) !all(key).empty? end def fetch(key, default) values = all(key) values.empty? ? default : values.first end def [](key) fetch(key, nil) end def []=(key, value) all(key).clear.push(value) end def all(key) options[key] ||= [] end private attr_reader :options end end end tomo-1.22.0/lib/tomo/cli/rules_evaluator.rb0000644000004100000410000000335515216001452020640 0ustar www-datawww-data# frozen_string_literal: true module Tomo class CLI class RulesEvaluator def self.evaluate(**) new(**).call end def initialize(rules:, argv:, state:, literal:, completions: nil) @rules = rules @argv = argv.dup @state = state @literal = literal @completions = completions || Completions.new(literal:) end def call until argv.empty? complete_if_needed(remaining_rules, *argv) if argv.length == 1 rule, matched_args = match_next_rule complete_if_needed([rule], *matched_args) if argv.empty? rule.process(*matched_args, state:) state.processed_rule(rule) end end private attr_reader :rules, :argv, :state, :literal, :completions def match_next_rule matched_rule, length = remaining_rules.find do |rule| matching_length = rule.match(argv.first, literal:) break [rule, matching_length] if matching_length end raise_unrecognized_args if matched_rule.nil? matched_args = argv.shift(length) [matched_rule, matched_args] end def complete_if_needed(matched_rules, *matched_args) return unless Completions.active? completions.print_completions_and_exit(matched_rules, *matched_args, state:) end def remaining_rules rules.reject do |rule| state.processed?(rule) && !rule.multiple? end end def raise_unrecognized_args problem_arg = argv.first type = literal || !problem_arg.start_with?("-") ? "arg" : "option" raise CLI::Error, "#{Colors.yellow(problem_arg)} is not a recognized #{type} for this tomo command." end end end end tomo-1.22.0/lib/tomo/cli/project_options.rb0000644000004100000410000000253215216001452020641 0ustar www-datawww-data# frozen_string_literal: true module Tomo class CLI module ProjectOptions def self.included(mod) mod.class_eval do option :project, "-c, --config PATH", "Location of project config (default: #{DEFAULT_CONFIG_PATH})" end end private def configure_runtime(options, strict: true) config = load_configuration(options) env = options[:environment] env = config.environments.keys.first if env.nil? && !strict config = config.for_environment(env) config.settings.merge!(settings_from_env) config.settings.merge!(settings_from_options(options)) config.build_runtime end def load_configuration(options) path = options[:project] || DEFAULT_CONFIG_PATH @config_cache ||= {} @config_cache[path] ||= Configuration.from_config_rb(path) end def settings_from_options(options) options.all(:settings).each_with_object({}) do |arg, settings| name, value = arg.split("=", 2) settings[name.to_sym] = value end end def settings_from_env ENV.each_with_object({}) do |(key, value), result| setting_name = key[/^TOMO_(\w+)$/i, 1]&.downcase next if setting_name.nil? result[setting_name.to_sym] = value end end end end end tomo-1.22.0/lib/tomo/cli/parser.rb0000644000004100000410000000427315216001452016720 0ustar www-datawww-data# frozen_string_literal: true require "forwardable" module Tomo class CLI class Parser extend Forwardable include Colors def_delegators :usage, :banner=, :to_s attr_accessor :context def initialize @rules = Rules.new @usage = Usage.new @after_parse_methods = [] end def arg(spec, values: []) rules.add_arg(spec, proc_for(values)) end def option(key, spec, desc=nil, values: [], &) rules.add_option(key, spec, proc_for(values), &) usage.add_option(spec, desc) end def after_parse(context_method_name) after_parse_methods << context_method_name end def parse(argv) state = State.new options_argv, literal_argv = split(argv, "--") evaluate(options_argv, state, literal: false) evaluate(literal_argv, state, literal: true) check_required_rules(state) invoke_after_parse_methods(state) [*state.args, state.options] end private attr_reader :rules, :usage, :after_parse_methods def evaluate(argv, state, literal:) RulesEvaluator.evaluate(rules: rules.to_a, argv:, state:, literal:) end def check_required_rules(state) (rules.to_a - state.processed_rules).each do |rule| next unless rule.required? type = rule.is_a?(Rules::Argument) ? "" : " option" raise CLI::Error, "Please specify the #{yellow(rule.to_s)}#{type}." end end def invoke_after_parse_methods(state) after_parse_methods.each do |method| context.send(method, *state.args, state.options) end end def proc_for(values) return values if values.respond_to?(:call) return proc { values } unless values.is_a?(Symbol) method = values proc { |*args| context.send(method, *args) } end def split(argv, delimiter) index = argv.index(delimiter) return [argv, []] if index.nil? return [argv, []] if index == argv.length - 1 && Completions.active? before = argv[0...index] after = argv[(index + 1)..] [before, after] end end end end tomo-1.22.0/lib/tomo/plugin.rb0000644000004100000410000000010515216001452016141 0ustar www-datawww-data# frozen_string_literal: true module Tomo module Plugin end end tomo-1.22.0/lib/tomo/testing.rb0000644000004100000410000000254315216001452016330 0ustar www-datawww-data# frozen_string_literal: true require "tomo" module Tomo module Testing autoload :CLIExtensions, "tomo/testing/cli_extensions" autoload :CLITester, "tomo/testing/cli_tester" autoload :Connection, "tomo/testing/connection" autoload :DockerImage, "tomo/testing/docker_image" autoload :HostExtensions, "tomo/testing/host_extensions" autoload :Local, "tomo/testing/local" autoload :LogCapturing, "tomo/testing/log_capturing" autoload :MockedExecError, "tomo/testing/mocked_exec_error" autoload :MockedExitError, "tomo/testing/mocked_exit_error" autoload :MockPluginTester, "tomo/testing/mock_plugin_tester" autoload :RemoteExtensions, "tomo/testing/remote_extensions" autoload :SSHExtensions, "tomo/testing/ssh_extensions" class << self attr_reader :ssh_enabled def enabling_ssh orig_ssh = ssh_enabled @ssh_enabled = true yield ensure @ssh_enabled = orig_ssh end end @ssh_enabled = false end end Tomo.logger = Tomo::Logger.new( stdout: File.open(File::NULL, "w"), stderr: File.open(File::NULL, "w") ) class << Tomo::CLI prepend Tomo::Testing::CLIExtensions end Tomo::Colors.enabled = false Tomo::Host.prepend Tomo::Testing::HostExtensions Tomo::Remote.prepend Tomo::Testing::RemoteExtensions class << Tomo::SSH prepend Tomo::Testing::SSHExtensions end tomo-1.22.0/lib/tomo/cli.rb0000644000004100000410000000562315216001452015424 0ustar www-datawww-data# frozen_string_literal: true module Tomo class CLI autoload :Command, "tomo/cli/command" autoload :CommonOptions, "tomo/cli/common_options" autoload :Completions, "tomo/cli/completions" autoload :DeployOptions, "tomo/cli/deploy_options" autoload :Error, "tomo/cli/error" autoload :InterruptedError, "tomo/cli/interrupted_error" autoload :Options, "tomo/cli/options" autoload :Parser, "tomo/cli/parser" autoload :ProjectOptions, "tomo/cli/project_options" autoload :Rules, "tomo/cli/rules" autoload :RulesEvaluator, "tomo/cli/rules_evaluator" autoload :State, "tomo/cli/state" autoload :UnknownOptionError, "tomo/cli/unknown_option_error" autoload :Usage, "tomo/cli/usage" class << self attr_accessor :show_backtrace def exit(status=true) # rubocop:disable Style/OptionalBooleanParameter Process.exit(status) end end COMMANDS = { "deploy" => Tomo::Commands::Deploy, "help" => Tomo::Commands::Help, "init" => Tomo::Commands::Init, "run" => Tomo::Commands::Run, "setup" => Tomo::Commands::Setup, "tasks" => Tomo::Commands::Tasks, "version" => Tomo::Commands::Version, "completion-script" => Tomo::Commands::CompletionScript }.freeze COMMAND_ALIASES = { "-T" => Tomo::Commands::Tasks }.freeze def call(argv) prepare_completions(argv) command, command_name = lookup_command(argv) command.parse(argv) rescue Interrupt handle_error(InterruptedError.new, command_name) rescue StandardError, SyntaxError => e handle_error(e, command_name) end private def prepare_completions(argv) return unless %w[--complete --complete-word].include?(argv[0]) Completions.activate argv << "" if argv.shift == "--complete" end def lookup_command(argv) commands = COMMANDS.merge(COMMAND_ALIASES) command_name = argv.first unless Completions.active? && argv.length == 1 command_name = expand_abbrev(commands.keys, command_name) argv.shift if command_name command_name = "run" if command_name.nil? && task_format?(argv.first) command = commands[command_name] || Tomo::Commands::Default [command, command_name] end def expand_abbrev(names, abbrev) return nil if abbrev.to_s.empty? matches = names.select { |name| name.start_with?(abbrev) } matches.first if matches.one? end def task_format?(arg) arg.to_s.match?(/\A\S+:\S*\z/) end def handle_error(error, command_name) return if Completions.active? raise error unless error.respond_to?(:to_console) error.command_name = command_name if error.respond_to?(:command_name=) Tomo.logger.error(error.to_console) status = error.respond_to?(:exit_status) ? error.exit_status : 1 CLI.exit(status) unless Tomo::CLI.show_backtrace raise error end end end tomo-1.22.0/lib/tomo/commands/0000755000004100000410000000000015216001452016123 5ustar www-datawww-datatomo-1.22.0/lib/tomo/commands/tasks.rb0000644000004100000410000000153515216001452017601 0ustar www-datawww-data# frozen_string_literal: true module Tomo module Commands class Tasks < CLI::Command include CLI::ProjectOptions include CLI::CommonOptions def summary "List all tasks that can be used with the #{yellow('run')} command" end def banner <<~BANNER Usage: #{green('tomo tasks')} List all tomo tasks (i.e. those that can be used with #{blue('tomo run')}). Available tasks are those defined by plugins loaded in #{DEFAULT_CONFIG_PATH}. BANNER end def call(options) runtime = configure_runtime(options, strict: false) tasks = runtime.tasks groups = tasks.group_by { |task| task[/^([^:]+):/, 1].to_s } groups.keys.sort.each do |group| logger.info(groups[group].sort.join("\n")) end end end end end tomo-1.22.0/lib/tomo/commands/default.rb0000644000004100000410000000455315216001452020103 0ustar www-datawww-data# frozen_string_literal: true module Tomo module Commands class Default < CLI::Command arg "COMMAND", values: CLI::COMMANDS.keys option :version, "-v, --version", "Display tomo’s version and exit" do Version.parse([]) CLI.exit end include CLI::CommonOptions def banner <<~BANNER Usage: #{green('tomo')} #{yellow('COMMAND [options]')} Tomo is an extensible tool for deploying projects to remote hosts via SSH. Please specify a #{yellow('COMMAND')}, which can be: #{commands.map { |name, help| " #{yellow(name.ljust(10))} #{help}" }.join("\n")} The tomo CLI also provides some convenient shortcuts: - Commands can be abbreviated, like #{blue('tomo d')} to run #{blue('tomo deploy')}. - When running tasks, the #{yellow('run')} command is implied and can be omitted. E.g., #{blue('tomo run rails:console')} can be shortened to #{blue('tomo rails:console')}. - Bash completions are also available. Run #{blue('tomo completion-script')} for installation instructions. For help with any command, add #{blue('-h')} to the command, like this: #{blue('tomo run -h')} Or read the full documentation for all commands at: #{blue('https://tomo.mattbrictson.com/')} BANNER end def call(*args, options) # The bare `tomo` command (i.e. without `--help` or `--version`) doesn't # do anything, so if we got this far, something has gone wrong. if options.any? raise CLI::Error, "Options must be specified after the command: " + yellow("tomo #{args.first} [options]") end raise_unrecognized_command(args.first) end private def raise_unrecognized_command(command) error = "#{yellow(command)} is not a recognized tomo command." if command.match?(/\A\S+:/) suggestion = "tomo run #{command}" error << "\nMaybe you meant #{blue(suggestion)}?" end raise CLI::Error, error end def commands CLI::COMMANDS.each_with_object({}) do |(name, klass), result| command = klass.new help = command.summary if command.respond_to?(:summary) next if help.nil? result[name] = help end end end end end tomo-1.22.0/lib/tomo/commands/run.rb0000644000004100000410000000516015216001452017256 0ustar www-datawww-data# frozen_string_literal: true module Tomo module Commands class Run < CLI::Command include CLI::DeployOptions option :privileged, "--[no-]privileged", "Run the task using a privileged user (e.g. root)" include CLI::ProjectOptions include CLI::CommonOptions arg "TASK", values: :task_names arg "[ARGS...]" def summary "Run a specific remote task from the current project" end def banner <<~BANNER Usage: #{green('tomo run')} #{yellow('[--dry-run] [options] [--] TASK [ARGS...]')} Remotely run one specified #{yellow('TASK')}, optionally passing #{yellow('ARGS')} to that task. For example, if this project uses the "rails" plugin, you could run: #{blue('tomo run -- rails:console --sandbox')} This will run the #{blue('rails:console')} task on the host specified in #{DEFAULT_CONFIG_PATH}, and will pass the #{blue('--sandbox')} argument to that task. The #{blue('--')} is used to separate tomo options from options that are passed to the task. If a task does not accept options, the #{blue('--')} can be omitted, like this: #{blue('tomo run core:clean_releases')} When you specify a task name, the #{blue('run')} command is implied and can be omitted, so this works as well: #{blue('tomo core:clean_releases')} You can run any task defined by plugins loaded in #{DEFAULT_CONFIG_PATH}. To see a list of available tasks, run #{blue('tomo tasks')}. Tomo will auto-complete this command’s options, including the #{yellow('TASK')} name, if you are using bash and have tomo’s completion script installed. For installation instructions, run #{blue('tomo completion-script')}. For more documentation and examples, visit: #{blue('https://tomo.mattbrictson.com/commands/run')} BANNER end def call(task, *, options) logger.info "tomo run v#{Tomo::VERSION}" runtime = configure_runtime(options) plan = runtime.run!(task, *, privileged: options[:privileged]) log_completion(task, plan) end private def log_completion(task, plan) target = "#{task} on #{plan.applicable_hosts_sentence}" if dry_run? logger.info(green("* Simulated #{target} (dry run)")) else logger.info(green("✔ Ran #{target}")) end end def task_names(*, options) runtime = configure_runtime(options, strict: false) runtime.tasks end end end end tomo-1.22.0/lib/tomo/commands/init.rb0000644000004100000410000000742015216001452017416 0ustar www-datawww-data# frozen_string_literal: true require "erb" require "fileutils" module Tomo module Commands class Init < CLI::Command include CLI::CommonOptions arg "[APP]" def summary "Start a new tomo project with a sample config" end def banner <<~BANNER Usage: #{green('tomo init')} #{yellow('[APP]')} Set up a new tomo project named #{yellow('APP')}. If #{yellow('APP')} is not specified, the name of the current directory will be used. This command creates a #{DEFAULT_CONFIG_PATH} file relative the current directory containing some example configuration. BANNER end def call(*args, _options) assert_can_create_tomo_directory! assert_no_tomo_project! app = args.first || current_dir_name || "default" app = app.gsub(/([^\w-]|_)+/, "_").downcase FileUtils.mkdir_p(".tomo/plugins") File.write(".tomo/plugins/#{app}.rb", plugin_rb_template) File.write(DEFAULT_CONFIG_PATH, config_rb_template(app)) logger.info(green("✔ Created #{DEFAULT_CONFIG_PATH}")) end private def assert_can_create_tomo_directory! return if Dir.exist?(".tomo") return unless File.exist?(".tomo") logger.error("Can't create .tomo directory; a file already exists") CLI.exit(1) end def assert_no_tomo_project! return unless File.exist?(DEFAULT_CONFIG_PATH) logger.error("A #{DEFAULT_CONFIG_PATH} file already exists") CLI.exit(1) end def current_dir_name File.basename(File.expand_path(".")) end def git_origin_url return unless File.file?(".git/config") return unless `git remote -v`.match?(/^origin/) url = `git remote get-url origin`.chomp url.empty? ? nil : url rescue SystemCallError nil end def git_main_branch return unless File.file?(".git/config") # If "main" or "master" is in the list of branch names, use that branch = (%w[main master] & `git branch`.scan(/^\W*(.+)$/).flatten).first # If not, use the current branch if branch.nil? branch = if `git --version`[/\d+\.\d+/].to_f >= 2.22 `git branch --show-current`.chomp else `git rev-parse --abbrev-ref HEAD`.chomp end end branch.empty? ? nil : branch rescue SystemCallError nil end def node_version `node --version`.chomp.sub(/^v/i, "") rescue SystemCallError nil end def yarn_version `yarn --version`.chomp rescue SystemCallError nil end def rubocop? File.exist?(".rubocop.yml") end # Does a .ruby-version file exist match the executing RUBY_VERSION? def using_ruby_version_file? return false unless File.exist?(".ruby-version") File.read(".ruby-version").rstrip == RUBY_VERSION rescue IOError false end # Does a .node-version file exist match `node --version`? def using_node_version_file? return false unless File.exist?(".node-version") version = File.read(".node-version").rstrip !version.empty? && version == node_version rescue IOError false end def config_rb_template(app) path = File.expand_path("../templates/config.rb.erb", __dir__) template = File.read(path) ERB.new(template, trim_mode: "-").result(binding) end def plugin_rb_template path = File.expand_path("../templates/plugin.rb.erb", __dir__) template = File.read(path) ERB.new(template, trim_mode: "-").result(binding) end end end end tomo-1.22.0/lib/tomo/commands/deploy.rb0000644000004100000410000000475215216001452017754 0ustar www-datawww-data# frozen_string_literal: true module Tomo module Commands class Deploy < CLI::Command include CLI::DeployOptions include CLI::ProjectOptions include CLI::CommonOptions def summary "Deploy the current project to remote host(s)" end def banner <<~BANNER Usage: #{green('tomo deploy')} #{yellow('[--dry-run] [options]')} Sequentially run the "deploy" list of tasks specified in #{DEFAULT_CONFIG_PATH} to deploy the project to a remote host. Use the #{blue('--dry-run')} option to quickly simulate the entire deploy without actually connecting to the host. Add the #{blue('--debug')} option to see an in-depth explanation of the settings and execution plan that will be used for the deployment. For a #{DEFAULT_CONFIG_PATH} that specifies distinct environments (e.g. staging, production), you must specify the target environment using the #{blue('-e')} option. If you omit this option, tomo will automatically prompt for it. Tomo will use the settings specified in #{DEFAULT_CONFIG_PATH} to configure the deploy. You may override these on the command line using #{blue('-s')}. E.g.: #{blue('tomo deploy -e staging -s git_branch=develop')} Or use environment variables with the special #{blue('TOMO_')} prefix: #{blue('TOMO_GIT_BRANCH=develop tomo deploy -e staging')} Bash completions are provided for tomo’s options. For example, you could type #{blue('tomo deploy -s ')} to see a list of all settings, or #{blue('tomo deploy -e pr')} to expand #{blue('pr')} to #{blue('production')}. For bash completion installation instructions, run #{blue('tomo completion-script')}. More documentation and examples can be found here: #{blue('https://tomo.mattbrictson.com/commands/deploy')} BANNER end def call(options) logger.info "tomo deploy v#{Tomo::VERSION}" runtime = configure_runtime(options) plan = runtime.deploy! log_completion(plan) end private def log_completion(plan) app = plan.settings[:application] target = "#{app} to #{plan.applicable_hosts_sentence}" if dry_run? logger.info(green("* Simulated deploy of #{target} (dry run)")) else logger.info(green("✔ Deployed #{target}")) end end end end end tomo-1.22.0/lib/tomo/commands/help.rb0000644000004100000410000000025415216001452017401 0ustar www-datawww-data# frozen_string_literal: true module Tomo module Commands class Help def self.parse(argv) Default.parse([*argv, "--help"]) end end end end tomo-1.22.0/lib/tomo/commands/version.rb0000644000004100000410000000070615216001452020140 0ustar www-datawww-data# frozen_string_literal: true module Tomo module Commands class Version < CLI::Command include CLI::CommonOptions def summary "Display tomo’s version" end def banner <<~BANNER Usage: #{green('tomo version')} Display tomo’s version information. BANNER end def call(_options) puts "tomo/#{Tomo::VERSION} #{RUBY_DESCRIPTION}" end end end end tomo-1.22.0/lib/tomo/commands/setup.rb0000644000004100000410000000307215216001452017612 0ustar www-datawww-data# frozen_string_literal: true module Tomo module Commands class Setup < CLI::Command include CLI::DeployOptions include CLI::ProjectOptions include CLI::CommonOptions def summary "Prepare the current project for its first deploy" end def banner <<~BANNER Usage: #{green('tomo setup')} #{yellow('[--dry-run] [options]')} Prepare the remote host for its first deploy by sequentially running the "setup" list of tasks specified in #{DEFAULT_CONFIG_PATH}. These tasks typically create directories, initialize data stores, install prerequisite tools, and perform other one-time actions that are necessary before a deploy can take place. Use the #{blue('--dry-run')} option to quickly simulate the setup without actually connecting to the host. More documentation and examples can be found here: #{blue('https://tomo.mattbrictson.com/commands/setup')} BANNER end def call(options) logger.info "tomo setup v#{Tomo::VERSION}" runtime = configure_runtime(options) plan = runtime.setup! log_completion(plan) end private def log_completion(plan) app = plan.settings[:application] target = "#{app} on #{plan.applicable_hosts_sentence}" if dry_run? logger.info(green("* Simulated setup of #{target} (dry run)")) else logger.info(green("✔ Performed setup of #{target}")) end end end end end tomo-1.22.0/lib/tomo/commands/completion_script.rb0000644000004100000410000000260615216001452022211 0ustar www-datawww-data# frozen_string_literal: true module Tomo module Commands class CompletionScript def self.parse(_argv) puts <<~'SCRIPT' # TOMO COMPLETIONS FOR BASH # # Assuming tomo is in your PATH, you can install tomo bash completions by # adding this line to your .bashrc: # # eval "$(tomo completion-script)" # # The eval technique is a bit slow but ensures bash is always using the # latest version of the tomo completion script. # # Alternatively, you can copy and paste the current version of the script # into your .bashrc. The full script is listed below. _tomo_complete() { local cur="${COMP_WORDS[COMP_CWORD]}" local prev="${COMP_WORDS[COMP_CWORD-1]}" if [[ $prev == "-c" || $prev == "--config" ]]; then COMPREPLY=($(compgen -f -- ${cur})) return 0 fi if [[ "${COMP_LINE: -1}" == " " ]]; then command=${COMP_LINE/tomo/tomo --complete} else command=${COMP_LINE/tomo/tomo --complete-word} fi suggestions=$($command) local IFS=$'\n' COMPREPLY=($suggestions) return 0 } complete -o nospace -F _tomo_complete tomo SCRIPT end end end end tomo-1.22.0/lib/tomo/script.rb0000644000004100000410000000116115216001452016152 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Script attr_reader :script def initialize(script, echo: true, pty: false, raise_on_error: true, silent: false) @script = script @echo = echo @pty = pty @raise_on_error = raise_on_error @silent = silent freeze end def echo? !!@echo end def echo_string return nil unless echo? @echo == true ? script : @echo end def pty? !!@pty end def raise_on_error? !!@raise_on_error end def silent? !!@silent end def to_s script end end end tomo-1.22.0/lib/tomo/console/0000755000004100000410000000000015216001452015764 5ustar www-datawww-datatomo-1.22.0/lib/tomo/console/non_interactive_error.rb0000644000004100000410000000121115216001452022704 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Console class NonInteractiveError < Tomo::Error attr_accessor :task, :ci_var def to_console error = "" error += "#{operation_name} requires an interactive console." error += "\n\n#{seems_like_ci}" if ci_var error end private def seems_like_ci <<~ERROR This appears to be a non-interactive CI environment because the #{yellow(ci_var)} environment variable is set. ERROR end def operation_name task ? "The #{yellow(task)} task" : "Tomo::Console" end end end end tomo-1.22.0/lib/tomo/console/menu.rb0000644000004100000410000000510415216001452017255 0ustar www-datawww-data# frozen_string_literal: true require "forwardable" require "io/console" module Tomo class Console class Menu ARROW_UP = "\e[A" ARROW_DOWN = "\e[B" RETURN = "\r" ENTER = "\n" extend Forwardable include Colors def initialize(question, options, key_reader: KeyReader.new, output: $stdout) @question = question @options = options @position = 0 @key_reader = key_reader @output = output end def selected_option options[position] end def prompt_for_selection render_loop do |key| case key when RETURN, ENTER then break when ARROW_UP then move(-1) when ARROW_DOWN then move(1) else self.position = find_match_index(key) end end print "#{yellow(question)} #{blue(selected_option)}\n" selected_option end private def_delegators :@output, :flush, :print attr_reader :key_reader, :question, :options attr_accessor :position def render_loop loop do render key = key_reader.next clear yield key end end def move(amount) new_position = position + amount return if new_position.negative? || new_position >= options.length self.position = new_position end def find_match_index(string) exact = options.find_index do |opt| opt.match?(/^#{Regexp.quote(string)}/i) end substring = options.find_index do |opt| opt.match?(/#{Regexp.quote(string)}/i) end exact || substring || position end def render print "#{yellow(question)} #{gray(hint)}\n" visible_options.each do |option, selected| print selected ? blue("❯ #{option}\n") : " #{option}\n" end flush end def hint return unless options.length > visible_options.length "(press up/down to reveal more options)" end def clear height = visible_options.length + 2 esc_codes = Array.new(height) { "\e[2K\e[1G" }.join("\e[1A") print esc_codes end def visible_options options.map.with_index { |opt, i| [opt, i == position] }[visible_range] end def visible_range max_visible = [8, options.length].min offset = [0, position - (max_visible / 2)].max adjusted_offset = [offset, options.length - max_visible].min adjusted_offset...(adjusted_offset + max_visible) end end end end tomo-1.22.0/lib/tomo/console/key_reader.rb0000644000004100000410000000207515216001452020427 0ustar www-datawww-data# frozen_string_literal: true require "forwardable" require "io/console" require "time" module Tomo class Console class KeyReader extend Forwardable def initialize(input=$stdin) @buffer = +"" @input = input end def next pressed = raw { getc } pressed << read_chars_nonblock if pressed == "\e" raise Interrupt if pressed == ?\C-c clear if !pressed.match?(/\A\w+\z/) || seconds_since_last_press > 0.75 buffer << pressed end private def_delegators :@input, :getc, :raw, :read_nonblock def_delegators :buffer, :clear attr_reader :buffer def seconds_since_last_press start = @last_press_at || 0 @last_press_at = Time.now.to_f @last_press_at - start end def read_chars_nonblock chars = +"" loop do next_char = raw { read_nonblock(1) } break if next_char.nil? chars << next_char end chars rescue IO::WaitReadable chars end end end end tomo-1.22.0/lib/tomo/path.rb0000644000004100000410000000053615216001452015607 0ustar www-datawww-data# frozen_string_literal: true require "delegate" require "pathname" module Tomo class Path < SimpleDelegator def initialize(path) super(path.to_s) freeze end def join(*other) self.class.new(Pathname.new(to_s).join(*other)) end def dirname self.class.new(Pathname.new(to_s).dirname) end end end tomo-1.22.0/lib/tomo/ssh/0000755000004100000410000000000015216001452015117 5ustar www-datawww-datatomo-1.22.0/lib/tomo/ssh/unsupported_version_error.rb0000644000004100000410000000053715216001452023017 0ustar www-datawww-data# frozen_string_literal: true module Tomo module SSH class UnsupportedVersionError < Error attr_accessor :command, :expected_version def to_console msg = <<~ERROR Expected #{yellow(command)} to return #{blue(expected_version)} or higher. ERROR [msg, super].join("\n") end end end end tomo-1.22.0/lib/tomo/ssh/error.rb0000644000004100000410000000034615216001452016600 0ustar www-datawww-data# frozen_string_literal: true module Tomo module SSH class Error < Tomo::Error attr_accessor :host def to_console [debug_suggestion, red(message.strip)].compact.join("\n\n") end end end end tomo-1.22.0/lib/tomo/ssh/unknown_error.rb0000644000004100000410000000042415216001452020354 0ustar www-datawww-data# frozen_string_literal: true module Tomo module SSH class UnknownError < Error def to_console msg = <<~ERROR An unknown error occurred trying to SSH to #{yellow(host)}. ERROR [msg, super].join("\n") end end end end tomo-1.22.0/lib/tomo/ssh/connection_validator.rb0000644000004100000410000000471315216001452021655 0ustar www-datawww-data# frozen_string_literal: true require "forwardable" module Tomo module SSH class ConnectionValidator MINIMUM_OPENSSH_VERSION = 7.4 private_constant :MINIMUM_OPENSSH_VERSION extend Forwardable def initialize(executable, connection) @executable = executable @connection = connection end def assert_valid_executable! result = begin ChildProcess.execute(executable, "-V") rescue StandardError => e handle_bad_executable(e) end Tomo.logger.debug(result.output) return if result.success? && supported?(result.output) raise_unsupported_version(result.output) end def assert_valid_connection! script = Script.new("echo hi", silent: !Tomo.debug?, echo: false, raise_on_error: false) res = connection.ssh_subprocess(script, verbose: Tomo.debug?) raise_connection_failure(res) if res.exit_status == 255 raise_unknown_error(res) if res.failure? || res.stdout.chomp != "hi" end def dump_env script = Script.new("env", silent: true, echo: false) res = connection.ssh_subprocess(script) Tomo.logger.debug("#{host} environment:\n#{res.stdout.strip}") end private def_delegators :connection, :host attr_reader :executable, :connection def supported?(version) version[/OpenSSH_(\d+\.\d+)/i, 1].to_f >= MINIMUM_OPENSSH_VERSION end def handle_bad_executable(error) ExecutableError.raise_with(error, executable:) end def raise_unsupported_version(ver) UnsupportedVersionError.raise_with( ver, host:, command: "#{executable} -V", expected_version: "OpenSSH_#{MINIMUM_OPENSSH_VERSION}" ) end def raise_connection_failure(result) case result.output when /Permission denied/i PermissionError.raise_with(result.output, host:) when /(Could not resolve|Operation timed out|Connection refused)/i ConnectionError.raise_with(result.output, host:) else UnknownError.raise_with(result.output, host:) end end def raise_unknown_error(result) UnknownError.raise_with(<<~ERROR.strip, host:) Unexpected output from `ssh`. Expected `echo hi` to return "hi" but got: #{result.output} (exited with code #{result.exit_status}) ERROR end end end end tomo-1.22.0/lib/tomo/ssh/script_error.rb0000644000004100000410000000103015216001452020153 0ustar www-datawww-data# frozen_string_literal: true require "shellwords" module Tomo module SSH class ScriptError < Error attr_accessor :result, :script, :ssh_args def to_console msg = <<~ERROR The following script failed on #{yellow(host)} (exit status #{red(result.exit_status)}). #{yellow(script)} You can manually re-execute the script via SSH as follows: #{gray(ssh_args.map(&:shellescape).join(' '))} ERROR [msg, super].join("\n") end end end end tomo-1.22.0/lib/tomo/ssh/connection_error.rb0000644000004100000410000000065015216001452021015 0ustar www-datawww-data# frozen_string_literal: true module Tomo module SSH class ConnectionError < Error def to_console msg = <<~ERROR Unable to connect via SSH to #{yellow(host.address)} on port #{yellow(host.port)}. Make sure the hostname and port are correct and that you have the necessary network (or VPN) access. ERROR [msg, super].join("\n") end end end end tomo-1.22.0/lib/tomo/ssh/options.rb0000644000004100000410000000404315216001452017140 0ustar www-datawww-data# frozen_string_literal: true module Tomo module SSH class Options DEFAULTS = { ssh_connect_timeout: 5, ssh_executable: "ssh", ssh_extra_opts: %w[-o PasswordAuthentication=no].map(&:freeze), ssh_forward_agent: true, ssh_reuse_connections: true, ssh_strict_host_key_checking: "accept-new" }.freeze attr_reader :executable def initialize(options) DEFAULTS.merge(options).each do |attr, value| unprefixed_attr = attr.to_s.sub(/^ssh_/, "") send(:"#{unprefixed_attr}=", value) end freeze end def build_args(host, script, control_path, verbose) # rubocop:disable Metrics/AbcSize args = [verbose ? "-v" : ["-o", "LogLevel=ERROR"]] args << "-A" if forward_agent args << connect_timeout_option args << strict_host_key_checking_option args.push(*control_opts(control_path, verbose)) if reuse_connections args.push(*extra_opts) if extra_opts args << "-tt" if script.pty? args << host.to_ssh_args args << "--" [executable, args, script.to_s].flatten end private attr_writer :executable attr_accessor :connect_timeout, :extra_opts, :forward_agent, :reuse_connections, :strict_host_key_checking def control_opts(path, verbose) opts = [ "-o", "ControlMaster=auto", "-o", "ControlPath=#{path}", "-o" ] opts << (verbose ? "ControlPersist=1s" : "ControlPersist=30s") end def connect_timeout_option return [] if connect_timeout.nil? ["-o", "ConnectTimeout=#{connect_timeout}"] end def strict_host_key_checking_option return [] if strict_host_key_checking.nil? value = case strict_host_key_checking when true then "yes" when false then "no" else strict_host_key_checking end ["-o", "StrictHostKeyChecking=#{value}"] end end end end tomo-1.22.0/lib/tomo/ssh/child_process.rb0000644000004100000410000000313415216001452020266 0ustar www-datawww-data# frozen_string_literal: true require "open3" require "shellwords" require "stringio" module Tomo module SSH class ChildProcess def self.execute(*command, on_data: ->(data) {}) process = new(*command, on_data:) process.wait_for_exit process.result end def initialize(*command, on_data:) @command = *command @on_data = on_data @stdout_buffer = StringIO.new @stderr_buffer = StringIO.new Tomo.logger.debug command.map(&:shellescape).join(" ") end def wait_for_exit Open3.popen3(*command) do |stdin, stdout, stderr, wait_thread| stdin.close stdout_thread = start_io_thread(stdout, stdout_buffer) stderr_thread = start_io_thread(stderr, stderr_buffer) stdout_thread.join stderr_thread.join @exit_status = wait_thread.value.exitstatus end end def result Result.new(exit_status:, stdout: stdout_buffer.string, stderr: stderr_buffer.string) end private attr_reader :command, :exit_status, :on_data, :stdout_buffer, :stderr_buffer def start_io_thread(source, buffer) new_thread_inheriting_current_vars do while (line = source.gets) on_data&.call(line) buffer << line end rescue IOError # rubocop:disable Lint/SuppressedException end end def new_thread_inheriting_current_vars(&block) Thread.new(Runtime::Current.variables) do |vars| Runtime::Current.with(vars, &block) end end end end end tomo-1.22.0/lib/tomo/ssh/executable_error.rb0000644000004100000410000000100615216001452020773 0ustar www-datawww-data# frozen_string_literal: true module Tomo module SSH class ExecutableError < Error attr_accessor :executable def to_console hint = if executable.to_s.include?("/") "Is the ssh binary properly installed in this location?" else "Is #{yellow(executable)} installed and in your #{blue('$PATH')}?" end <<~ERROR Failed to execute #{yellow(executable)}. #{hint} ERROR end end end end tomo-1.22.0/lib/tomo/ssh/permission_error.rb0000644000004100000410000000074115216001452021047 0ustar www-datawww-data# frozen_string_literal: true module Tomo module SSH class PermissionError < Error def to_console as_user = host.user && " as user #{yellow(host.user)}" msg = <<~ERROR Unable to connect via SSH to #{yellow(host.address)}#{as_user}. Check that you’ve specified the correct username and that your public key is properly installed on the server. ERROR [msg, super].join("\n") end end end end tomo-1.22.0/lib/tomo/ssh/connection.rb0000644000004100000410000000345615216001452017613 0ustar www-datawww-data# frozen_string_literal: true require "fileutils" require "securerandom" require "tmpdir" module Tomo module SSH class Connection def self.dry_run(host, options) new(host, options, exec_proc: proc { CLI.exit }, child_proc: proc { Result.empty_success }) end attr_reader :host def initialize(host, options, exec_proc: nil, child_proc: nil) @host = host @options = options @exec_proc = exec_proc || Process.method(:exec) @child_proc = child_proc || ChildProcess.method(:execute) end def ssh_exec(script) ssh_args = build_args(script) logger.script_start(script) Tomo.logger.debug ssh_args.map(&:shellescape).join(" ") exec_proc.call(*ssh_args) end def ssh_subprocess(script, verbose: false) ssh_args = build_args(script, verbose:) handle_data = ->(data) { logger.script_output(script, data) } logger.script_start(script) result = child_proc.call(*ssh_args, on_data: handle_data) logger.script_end(script, result) raise_run_error(script, ssh_args, result) if result.failure? && script.raise_on_error? result end def close FileUtils.rm_f(control_path) end private attr_reader :options, :exec_proc, :child_proc def logger Tomo.logger end def build_args(script, verbose: false) options.build_args(host, script, control_path, verbose) end def control_path @control_path ||= begin token = SecureRandom.hex(8) File.join(Dir.tmpdir, "tomo_ssh_#{token}") end end def raise_run_error(script, ssh_args, result) ScriptError.raise_with(result.output, host:, result:, script:, ssh_args:) end end end end tomo-1.22.0/lib/tomo/version.rb0000644000004100000410000000010415216001452016327 0ustar www-datawww-data# frozen_string_literal: true module Tomo VERSION = "1.22.0" end tomo-1.22.0/lib/tomo/plugin/0000755000004100000410000000000015216001452015620 5ustar www-datawww-datatomo-1.22.0/lib/tomo/plugin/rbenv.rb0000644000004100000410000000037015216001452017261 0ustar www-datawww-data# frozen_string_literal: true require_relative "rbenv/tasks" module Tomo::Plugin module Rbenv extend Tomo::PluginDSL defaults bashrc_path: ".bashrc", rbenv_ruby_version: nil tasks Tomo::Plugin::Rbenv::Tasks end end tomo-1.22.0/lib/tomo/plugin/bundler.rb0000644000004100000410000000127015216001452017600 0ustar www-datawww-data# frozen_string_literal: true require_relative "bundler/helpers" require_relative "bundler/tasks" module Tomo::Plugin module Bundler extend Tomo::PluginDSL tasks Tomo::Plugin::Bundler::Tasks helpers Tomo::Plugin::Bundler::Helpers defaults bundler_config_path: ".bundle/config", bundler_deployment: true, bundler_gemfile: nil, bundler_ignore_messages: true, bundler_jobs: nil, bundler_path: "%{shared_path}/bundle", bundler_retry: "3", bundler_version: nil, bundler_without: %w[development test] end end tomo-1.22.0/lib/tomo/plugin/git/0000755000004100000410000000000015216001452016403 5ustar www-datawww-datatomo-1.22.0/lib/tomo/plugin/git/tasks.rb0000644000004100000410000000527215216001452020063 0ustar www-datawww-data# frozen_string_literal: true require "shellwords" require "time" module Tomo::Plugin::Git class Tasks < Tomo::TaskLibrary def config user_name = settings[:git_user_name] || remote.host.user user_email = settings[:git_user_email] || "#{remote.host.user}@example.com" remote.git("config", "--global", "user.name", user_name) remote.git("config", "--global", "user.email", user_email) end def clone require_setting :git_url if remote.directory?(paths.git_repo) && !dry_run? set_origin_url else remote.mkdir_p(paths.git_repo.dirname) remote.git("clone", "--mirror", settings[:git_url], paths.git_repo) end end def create_release # rubocop:disable Metrics/AbcSize remote.chdir(paths.git_repo) do remote.git("remote update --prune") end store_release_info configure_git_attributes remote.mkdir_p(paths.release) remote.chdir(paths.git_repo) do remote.git("archive #{ref.shellescape} | tar -x -f - -C #{paths.release.shellescape}") end end private def ref require_setting :git_ref if settings[:git_branch].nil? warn_if_ref_overrides_branch settings[:git_ref] || settings[:git_branch] end def warn_if_ref_overrides_branch return if defined?(@ref_override_warning) return unless settings[:git_ref] && settings[:git_branch] logger.warn( ":git_ref (#{settings[:git_ref]}) and " \ ":git_branch (#{settings[:git_branch]}) are both specified. " \ "Ignoring :git_branch." ) @ref_override_warning = true end def set_origin_url remote.chdir(paths.git_repo) do remote.git("remote", "set-url", "origin", settings[:git_url]) end end def configure_git_attributes exclusions = settings[:git_exclusions] || [] attributes = exclusions.map { |excl| "#{excl} export-ignore" }.join("\n") remote.write(text: attributes, to: paths.git_repo.join("info/attributes")) end def store_release_info # rubocop:disable Metrics/AbcSize, Metrics/MethodLength log = remote.chdir(paths.git_repo) do remote.git( %Q(log -n1 --date=iso --pretty=format:"%H/%cd/%ae" #{ref.shellescape} --), silent: true ).stdout.strip end sha, date, email = log.split("/", 3) remote.release[:branch] = ref if ref == settings[:git_branch] remote.release[:ref] = ref remote.release[:author] = email remote.release[:revision] = sha remote.release[:revision_date] = date remote.release[:deploy_date] = Time.now.to_s remote.release[:deploy_user] = settings.fetch(:local_user) end end end tomo-1.22.0/lib/tomo/plugin/git/helpers.rb0000644000004100000410000000034415216001452020373 0ustar www-datawww-data# frozen_string_literal: true module Tomo::Plugin::Git module Helpers def git(*args, **opts) env(settings[:git_env]) do prepend("git") do run(*args, **opts) end end end end end tomo-1.22.0/lib/tomo/plugin/env/0000755000004100000410000000000015216001452016410 5ustar www-datawww-datatomo-1.22.0/lib/tomo/plugin/env/tasks.rb0000644000004100000410000001025615216001452020066 0ustar www-datawww-data# frozen_string_literal: true require "monitor" require "securerandom" module Tomo::Plugin::Env class Tasks < Tomo::TaskLibrary # rubocop:disable Metrics/ClassLength include MonitorMixin def show env = read_existing logger.info env.gsub(/^export /, "").strip end def setup modify_bashrc update end def update # rubocop:disable Metrics/MethodLength return if settings[:env_vars].empty? modify_env_file do |env| settings[:env_vars].each do |name, value| case value when :prompt next if contains_entry?(env, name) value = prompt_for(name, message: <<~MSG) The remote host needs a value for the $#{name} environment variable. Please provide a value at the prompt. MSG when :generate_secret next if contains_entry?(env, name) value = SecureRandom.hex(64) end replace_entry(env, name, value) end end end def set return if settings[:run_args].empty? modify_env_file do |env| settings[:run_args].each do |arg| name, value = arg.split("=", 2) value ||= prompt_for(name) replace_entry(env, name, value) end end end def unset return if settings[:run_args].empty? modify_env_file do |env| settings[:run_args].each do |name| remove_entry(env, name) end end end private def modify_env_file env = read_existing original = env.dup yield(env) return if env == original remote.mkdir_p(paths.env.dirname) if original.empty? remote.write(text: env, to: paths.env) remote.run("chmod", "600", paths.env) if original.empty? end def read_existing remote.capture("cat", paths.env, raise_on_error: false, echo: false, silent: true) end def replace_entry(text, name, value) remove_entry(text, name) prepend_entry(text, name, value) end def remove_entry(text, name) text.gsub!(/^export #{Regexp.quote(name.to_s.shellescape)}=.*\n/, "") end def prepend_entry(text, name, value) text.prepend("\n") unless text.start_with?("\n") text.prepend("export #{name.to_s.shellescape}=#{value.to_s.shellescape}") end def contains_entry?(text, name) return true if dry_run? text.match?(/^export #{Regexp.quote(name.to_s.shellescape)}=/) end def prompt_for(name, message: nil) synchronize do @answers ||= {} next @answers[name] if @answers.key?(name) logger.info(message) if message @answers[name] = Tomo::Console.prompt("#{name}? ") end end def modify_bashrc env_path = paths.env.shellescape existing_rc = remote.capture("cat", paths.bashrc, raise_on_error: false) return if existing_rc.include?(". #{env_path}") fail_if_different_app_already_configured!(existing_rc) remote.write(text: <<~BASHRC + existing_rc, to: paths.bashrc) if [ -f #{env_path} ]; then # DO NOT MODIFY THESE LINES . #{env_path} # ENV MAINTAINED BY TOMO fi #{' ' * env_path.to_s.length}# END TOMO ENV BASHRC end def fail_if_different_app_already_configured!(bashrc) existing_env_path = bashrc[/\s*\.\s+(.+)\s+# ENV MAINTAINED BY TOMO/, 1] return if existing_env_path.nil? die <<~REASON Based on the contents of #{paths.bashrc}, it looks like another application is already being deployed via tomo to this host, using the following envrc path: #{existing_env_path} Tomo is designed such that only one application can be deployed to a given user@host. To deploy multiple applications to the same host, use a separate deployer user per app. Refer to the tomo FAQ for details: https://tomo.mattbrictson.com/#faq You may be receiving this message in error if you recently renamed or reconfigured your application. In this case, remove the references to the old envrc path in the host's #{paths.bashrc} and re-run env:setup. REASON end end end tomo-1.22.0/lib/tomo/plugin/bundler/0000755000004100000410000000000015216001452017253 5ustar www-datawww-datatomo-1.22.0/lib/tomo/plugin/bundler/tasks.rb0000644000004100000410000000346415216001452020734 0ustar www-datawww-data# frozen_string_literal: true require "yaml" module Tomo::Plugin::Bundler class Tasks < Tomo::TaskLibrary CONFIG_SETTINGS = %i[ bundler_deployment bundler_gemfile bundler_ignore_messages bundler_jobs bundler_path bundler_retry bundler_without ].freeze private_constant :CONFIG_SETTINGS def config configuration = settings_to_configuration remote.mkdir_p paths.bundler_config.dirname remote.write(text: YAML.dump(configuration), to: paths.bundler_config) end def install return if remote.bundle?("check") && !dry_run? remote.bundle("install") end def clean remote.bundle("clean") end def upgrade_bundler needed_bundler_ver = version_setting || extract_bundler_ver_from_lockfile remote.run("gem", "install", "bundler", "--conservative", "--no-document", "-v", needed_bundler_ver) end private def settings_to_configuration CONFIG_SETTINGS.each_with_object({}) do |key, config| next if settings[key].nil? entry_key = "BUNDLE_#{key.to_s.sub(/^bundler_/, '').upcase}" entry_value = settings.fetch(key) entry_value = entry_value.join(":") if entry_value.is_a?(Array) config[entry_key] = entry_value.to_s end end def version_setting settings[:bundler_version] end def extract_bundler_ver_from_lockfile lockfile_tail = remote.capture("tail", "-n", "10", paths.release.join("Gemfile.lock"), raise_on_error: false) version = lockfile_tail[/BUNDLED WITH\n \s*(\S+)$/, 1] return version if version || dry_run? die <<~REASON Could not guess bundler version from Gemfile.lock. Use the :bundler_version setting to specify the version of bundler to install. REASON end end end tomo-1.22.0/lib/tomo/plugin/bundler/helpers.rb0000644000004100000410000000050515216001452021242 0ustar www-datawww-data# frozen_string_literal: true module Tomo::Plugin::Bundler module Helpers def bundle(*args, **opts) prepend("bundle") do run(*args, **opts, default_chdir: paths.release) end end def bundle?(*, **) result = bundle(*, **, raise_on_error: false) result.success? end end end tomo-1.22.0/lib/tomo/plugin/puma/0000755000004100000410000000000015216001452016562 5ustar www-datawww-datatomo-1.22.0/lib/tomo/plugin/puma/tasks.rb0000644000004100000410000000651115216001452020237 0ustar www-datawww-data# frozen_string_literal: true module Tomo::Plugin::Puma class Tasks < Tomo::TaskLibrary SystemdUnit = Struct.new(:name, :template, :path) def setup_systemd # rubocop:disable Metrics/AbcSize linger_must_be_enabled! setup_directories remote.write template: socket.template, to: socket.path remote.write template: service.template, to: service.path remote.run "systemctl --user daemon-reload" remote.run "systemctl", "--user", "enable", service.name, socket.name end %i[start stop status].each do |action| define_method(action) do remote.run "systemctl", "--user", action, socket.name, service.name end end def restart remote.run "systemctl", "--user", "start", socket.name remote.run "systemctl", "--user", "restart", service.name end def check_active logger.info "Checking if puma is active and listening on port #{port}..." active = wait_until { dry_run? || (assert_active! && listening?) } remote.run("systemctl", "--user", "status", service.name) return if active logger.warn "Timed out waiting for puma to respond on port #{port}" end def log remote.attach "journalctl", "-q", raw("--user-unit=#{service.name.shellescape}"), *settings[:run_args] end def tail_log remote.attach "journalctl -q --user-unit=#{service.name.shellescape} -f" end private def port settings[:puma_port] end def service SystemdUnit.new( settings[:puma_systemd_service], paths.puma_systemd_service_template, paths.puma_systemd_service ) end def socket SystemdUnit.new( settings[:puma_systemd_socket], paths.puma_systemd_socket_template, paths.puma_systemd_socket ) end def linger_must_be_enabled! linger_users = remote.list_files( "/var/lib/systemd/linger", raise_on_error: false ) return if dry_run? || linger_users.include?(remote.host.user) die <<~ERROR.strip Linger must be enabled for the #{remote.host.user} user in order for puma to stay running in the background via systemd. Run the following command as root: loginctl enable-linger #{remote.host.user} ERROR end def setup_directories files = [service.path, socket.path].compact dirs = files.map { |f| f.dirname.to_s } remote.mkdir_p dirs.uniq end def wait_until timeout = settings[:puma_check_timeout].to_i start = Time.now.to_i delay = 1 loop do sleep delay return true if yield elapsed = Time.now.to_i - start return false if elapsed >= timeout delay = [delay + 1, timeout - elapsed].min end end def assert_active! return true if remote.run? "systemctl", "--user", "is-active", service.name, silent: true, raise_on_error: false remote.run "systemctl", "--user", "status", service.name, raise_on_error: false remote.run "journalctl -q -n 50 --user-unit=#{service.name.shellescape}", raise_on_error: false die "puma failed to start (see previous systemctl and journalctl output)" end def listening? test_url = "http://localhost:#{port}" remote.run? "curl -sS --connect-timeout 1 --max-time 10 #{test_url} > /dev/null" end end end tomo-1.22.0/lib/tomo/plugin/puma/systemd/0000755000004100000410000000000015216001452020252 5ustar www-datawww-datatomo-1.22.0/lib/tomo/plugin/puma/systemd/service.erb0000644000004100000410000000130015216001452022376 0ustar www-datawww-data[Unit] Description=Puma HTTP Server for <%= settings[:application] %> After=network.target Requires=<%= settings[:puma_systemd_socket] %> ConditionPathExists=<%= paths.current %> [Service] ExecStart=/bin/bash -lc 'exec bundle exec --keep-file-descriptors puma -C config/puma.rb -b tcp://<%= settings[:puma_host] %>:<%= settings[:puma_port] %>' KillMode=mixed Restart=always StandardInput=null SyslogIdentifier=%n TimeoutStopSec=5 <% if settings[:puma_systemd_service_type].to_s == "notify" %> Type=notify WatchdogSec=10 <% else %> Type=simple <% end %> WorkingDirectory=<%= paths.current %> # Helpful for debugging socket activation, etc. # Environment=PUMA_DEBUG=1 [Install] WantedBy=default.target tomo-1.22.0/lib/tomo/plugin/puma/systemd/socket.erb0000644000004100000410000000043115216001452022232 0ustar www-datawww-data[Unit] Description=Puma HTTP Server Accept Sockets for <%= settings[:application] %> [Socket] ListenStream=<%= settings[:puma_host] %>:<%= settings[:puma_port] %> # Socket options matching Puma defaults NoDelay=true ReusePort=true Backlog=1024 [Install] WantedBy=sockets.target tomo-1.22.0/lib/tomo/plugin/testing.rb0000644000004100000410000000064515216001452017627 0ustar www-datawww-data# frozen_string_literal: true raise "The testing plugin cannot be used outside of unit tests" unless defined?(Tomo::Testing) module Tomo::Plugin class Testing < Tomo::TaskLibrary extend Tomo::PluginDSL tasks self def call_helper helper, args, kwargs = settings[:run_args] value = remote.public_send(helper, *args, **(kwargs || {})) remote.host.helper_values << value end end end tomo-1.22.0/lib/tomo/plugin/rbenv/0000755000004100000410000000000015216001452016734 5ustar www-datawww-datatomo-1.22.0/lib/tomo/plugin/rbenv/tasks.rb0000644000004100000410000000405215216001452020407 0ustar www-datawww-data# frozen_string_literal: true require "shellwords" module Tomo::Plugin::Rbenv class Tasks < Tomo::TaskLibrary def install run_installer modify_bashrc compile_ruby end private def run_installer install_url = "https://github.com/rbenv/rbenv-installer/raw/HEAD/bin/rbenv-installer" remote.env PATH: raw("$HOME/.rbenv/bin:$HOME/.rbenv/shims:$PATH") do remote.run("curl -fsSL #{install_url.shellescape} | bash") end end def modify_bashrc existing_rc = remote.capture("cat", paths.bashrc, raise_on_error: false) return if existing_rc.include?("rbenv init") && existing_rc.include?("$HOME/.rbenv/bin") remote.write(text: <<~BASHRC + existing_rc, to: paths.bashrc) if [ -d $HOME/.rbenv ]; then export PATH="$HOME/.rbenv/bin:$PATH" eval "$(rbenv init -)" fi BASHRC end def compile_ruby ruby_version = version_setting || extract_ruby_ver_from_version_file unless ruby_installed?(ruby_version) logger.info("Installing ruby #{ruby_version} -- this may take several minutes") remote.run "CFLAGS=-O3 rbenv install #{ruby_version.shellescape} --verbose" end remote.run "rbenv global #{ruby_version.shellescape}" end def ruby_installed?(version) versions = remote.capture("rbenv versions", raise_on_error: false) if versions.match?(/^\*?\s*#{Regexp.quote(version)}\s/) logger.info("Ruby #{version} is already installed.") return true end false end def version_setting settings[:rbenv_ruby_version] end def extract_ruby_ver_from_version_file path = paths.release.join(".ruby-version") version = remote.capture("cat", path, raise_on_error: false).strip return version unless version.empty? return RUBY_VERSION if dry_run? die <<~REASON Could not guess ruby version from .ruby-version file. Use the :rbenv_ruby_version setting to specify the version of ruby to install. REASON end end end tomo-1.22.0/lib/tomo/plugin/nodenv.rb0000644000004100000410000000051315216001452017435 0ustar www-datawww-data# frozen_string_literal: true require_relative "nodenv/tasks" module Tomo::Plugin module Nodenv extend Tomo::PluginDSL defaults bashrc_path: ".bashrc", nodenv_install_yarn: true, nodenv_node_version: nil, nodenv_yarn_version: nil tasks Tomo::Plugin::Nodenv::Tasks end end tomo-1.22.0/lib/tomo/plugin/git.rb0000644000004100000410000000113715216001452016732 0ustar www-datawww-data# frozen_string_literal: true require_relative "git/helpers" require_relative "git/tasks" module Tomo::Plugin module Git extend Tomo::PluginDSL helpers Tomo::Plugin::Git::Helpers tasks Tomo::Plugin::Git::Tasks defaults git_branch: nil, git_repo_path: "%{deploy_to}/git_repo", git_exclusions: [], git_env: { GIT_SSH_COMMAND: "ssh -o PasswordAuthentication=no -o StrictHostKeyChecking=no" }, git_ref: nil, git_url: nil, git_user_name: nil, git_user_email: nil end end tomo-1.22.0/lib/tomo/plugin/rails.rb0000644000004100000410000000036715216001452017265 0ustar www-datawww-data# frozen_string_literal: true require_relative "rails/helpers" require_relative "rails/tasks" module Tomo::Plugin module Rails extend Tomo::PluginDSL helpers Tomo::Plugin::Rails::Helpers tasks Tomo::Plugin::Rails::Tasks end end tomo-1.22.0/lib/tomo/plugin/nodenv/0000755000004100000410000000000015216001452017111 5ustar www-datawww-datatomo-1.22.0/lib/tomo/plugin/nodenv/tasks.rb0000644000004100000410000000425015216001452020564 0ustar www-datawww-data# frozen_string_literal: true require "shellwords" module Tomo::Plugin::Nodenv class Tasks < Tomo::TaskLibrary def install run_installer modify_bashrc install_node install_yarn end private def run_installer install_url = "https://github.com/nodenv/nodenv-installer/raw/HEAD/bin/nodenv-installer" remote.env PATH: raw("$HOME/.nodenv/bin:$HOME/.nodenv/shims:$PATH") do remote.run("curl -fsSL #{install_url.shellescape} | bash") end end def modify_bashrc existing_rc = remote.capture("cat", paths.bashrc, raise_on_error: false) return if existing_rc.include?("nodenv init") remote.write(text: <<~BASHRC + existing_rc, to: paths.bashrc) if [ -d $HOME/.nodenv ]; then export PATH="$HOME/.nodenv/bin:$PATH" eval "$(nodenv init -)" fi BASHRC end def install_node node_version = settings[:nodenv_node_version] || extract_node_ver_from_version_file remote.run "nodenv install #{node_version.shellescape}" unless node_installed?(node_version) remote.run "nodenv global #{node_version.shellescape}" end def install_yarn unless settings[:nodenv_install_yarn] logger.info ":nodenv_install_yarn is false; skipping yarn installation." return end version = settings[:nodenv_yarn_version] yarn_spec = version ? "yarn@#{version.shellescape}" : "yarn" remote.run "npm i -g #{yarn_spec}" end def node_installed?(version) versions = remote.capture("nodenv versions", raise_on_error: false) if versions.include?(version) logger.info("Node #{version} is already installed.") return true end false end def extract_node_ver_from_version_file path = paths.release.join(".node-version") version = remote.capture("cat", path, raise_on_error: false).strip return version unless version.empty? return "DRY_RUN_PLACEHOLDER" if dry_run? die <<~REASON Could not guess node version from .node-version file. Use the :nodenv_node_version setting to specify the version of node to install. REASON end end end tomo-1.22.0/lib/tomo/plugin/core.rb0000644000004100000410000000204115216001452017072 0ustar www-datawww-data# frozen_string_literal: true require_relative "core/helpers" require_relative "core/tasks" module Tomo::Plugin module Core extend Tomo::PluginDSL helpers Tomo::Plugin::Core::Helpers tasks Tomo::Plugin::Core::Tasks defaults Tomo::SSH::Options::DEFAULTS.merge( application: "default", concurrency: 10, current_path: "%{deploy_to}/current", deploy_to: "/var/www/%{application}", keep_releases: 10, linked_dirs: [], linked_files: [], local_user: nil, # determined at runtime release_json_path: "%{release_path}/.tomo_release.json", releases_path: "%{deploy_to}/releases", revision_log_path: "%{deploy_to}/revisions.log", shared_path: "%{deploy_to}/shared", tmp_path: "/tmp/tomo-#{SecureRandom.alphanumeric(8)}", tomo_config_file_path: nil, # determined at runtime run_args: [] # determined at runtime ) end end tomo-1.22.0/lib/tomo/plugin/puma.rb0000644000004100000410000000150115216001452017104 0ustar www-datawww-data# frozen_string_literal: true require_relative "puma/tasks" module Tomo::Plugin module Puma extend Tomo::PluginDSL tasks Tomo::Plugin::Puma::Tasks defaults puma_check_timeout: 15, puma_host: "0.0.0.0", puma_port: "3000", puma_systemd_service: "puma_%{application}.service", puma_systemd_socket: "puma_%{application}.socket", puma_systemd_service_type: "notify", puma_systemd_service_path: ".config/systemd/user/%{puma_systemd_service}", puma_systemd_socket_path: ".config/systemd/user/%{puma_systemd_socket}", puma_systemd_service_template_path: File.expand_path("puma/systemd/service.erb", __dir__), puma_systemd_socket_template_path: File.expand_path("puma/systemd/socket.erb", __dir__) end end tomo-1.22.0/lib/tomo/plugin/env.rb0000644000004100000410000000042415216001452016735 0ustar www-datawww-data# frozen_string_literal: true require_relative "env/tasks" module Tomo::Plugin module Env extend Tomo::PluginDSL tasks Tomo::Plugin::Env::Tasks defaults bashrc_path: ".bashrc", env_path: "%{deploy_to}/envrc", env_vars: {} end end tomo-1.22.0/lib/tomo/plugin/rails/0000755000004100000410000000000015216001452016732 5ustar www-datawww-datatomo-1.22.0/lib/tomo/plugin/rails/tasks.rb0000644000004100000410000000403615216001452020407 0ustar www-datawww-data# frozen_string_literal: true module Tomo::Plugin::Rails class Tasks < Tomo::TaskLibrary def assets_precompile remote.rake("assets:precompile") end def console remote.rails("console", settings[:run_args], attach: true) end def query remote.rails("query", settings[:run_args], attach: true) end def db_console remote.rails("dbconsole", "--include-password", settings[:run_args], attach: true) end def db_migrate remote.rake("db:migrate") end def db_seed remote.rake("db:seed") end def db_create return remote.rake("db:create") unless database_exists? logger.info "Database already exists; skipping db:create." end def db_setup return remote.rake("db:setup") unless database_exists? logger.info "Database already exists; skipping db:setup." end def db_schema_load if !schema_rb_present? logger.warn "db/schema.rb is not present; skipping schema:load." elsif database_schema_loaded? logger.info "Database schema already loaded; skipping db:schema:load." else remote.rake("db:schema:load") end end def db_structure_load if !structure_sql_present? logger.warn "db/structure.sql is not present; skipping db:structure:load." elsif database_schema_loaded? logger.info "Database structure already loaded; skipping db:structure:load." else remote.rake("db:structure:load") end end private def database_exists? remote.rake?("db:version", silent: true) && !dry_run? end def database_schema_loaded? result = remote.rake("db:version", silent: true, raise_on_error: false) schema_version = result.output[/version:\s*(\d+)$/i, 1].to_i result.success? && schema_version.positive? && !dry_run? end def schema_rb_present? remote.file?(paths.release.join("db/schema.rb")) end def structure_sql_present? remote.file?(paths.release.join("db/structure.sql")) end end end tomo-1.22.0/lib/tomo/plugin/rails/helpers.rb0000644000004100000410000000100615216001452020716 0ustar www-datawww-data# frozen_string_literal: true module Tomo::Plugin::Rails module Helpers def rails(*args, **opts) prepend("exec", "rails") do bundle(*args, **opts) end end def rake(*args, **opts) prepend("exec", "rake") do bundle(*args, **opts) end end def rake?(*, **) result = rake(*, **, raise_on_error: false) result.success? end def thor(*args, **opts) prepend("exec", "thor") do bundle(*args, **opts) end end end end tomo-1.22.0/lib/tomo/plugin/core/0000755000004100000410000000000015216001452016550 5ustar www-datawww-datatomo-1.22.0/lib/tomo/plugin/core/tasks.rb0000644000004100000410000000652715216001452020234 0ustar www-datawww-data# frozen_string_literal: true require "json" require "securerandom" module Tomo::Plugin::Core class Tasks < Tomo::TaskLibrary RELEASE_REGEXP = /\d{14}/ private_constant :RELEASE_REGEXP def setup_directories dirs = [ paths.deploy_to, paths.current.dirname, paths.releases, paths.revision_log.dirname, paths.shared ].map(&:to_s).uniq remote.mkdir_p(*dirs) end def symlink_shared return if linked_dirs.empty? && linked_files.empty? remote.mkdir_p(*shared_directories, *link_dirnames) symlink_shared_directories symlink_shared_files end def symlink_current return if paths.release == paths.current tmp_link = "#{paths.current}-#{SecureRandom.hex(8)}" remote.ln_sf paths.release, tmp_link remote.run "mv", "-fT", tmp_link, paths.current end def clean_releases # rubocop:disable Metrics/AbcSize desired_count = settings[:keep_releases].to_i return if desired_count < 1 current = read_current_release remote.chdir(paths.releases) do releases = remote.list_files.grep(/^#{RELEASE_REGEXP}$/o).sort desired_count -= 1 if releases.delete(current) return if releases.length <= desired_count remote.rm_rf(*releases.take(releases.length - desired_count)) end end def write_release_json json = JSON.pretty_generate(remote.release) remote.write(text: "#{json}\n", to: paths.release_json) end def log_revision # rubocop:disable Metrics/AbcSize ref = remote.release[:ref] revision = remote.release[:revision] message = remote.release[:deploy_date].to_s message << " - #{revision || ''}" message << " (#{ref})" if ref && revision && !revision.start_with?(ref) message << " deployed by #{remote.release[:deploy_user] || ''}" message << "\n" remote.write(text: message, to: paths.revision_log, append: true) end private def linked_dirs settings[:linked_dirs] || [] end def linked_files settings[:linked_files] || [] end def shared_directories result = linked_dirs.map { |name| paths.shared.join(name) } linked_files.each do |name| result << paths.shared.join(name).dirname end result.map(&:to_s).uniq - [paths.shared.to_s] end def symlink_shared_files return if linked_files.empty? linked_files.each do |file| remote.ln_sfn paths.shared.join(file), paths.release.join(file) end end def symlink_shared_directories return if linked_dirs.empty? remove_existing_link_targets linked_dirs.each do |dir| remote.ln_sf paths.shared.join(dir), paths.release.join(dir) end end def link_dirnames parents = (linked_dirs + linked_files).map do |target| paths.release.join(target).dirname end parents.map(&:to_s).uniq - [paths.release.to_s] end def remove_existing_link_targets return if linked_dirs.empty? remote.chdir(paths.release) do remote.rm_rf(*linked_dirs) end end def read_current_release result = remote.run("readlink", paths.current, raise_on_error: false, silent: true) return nil if result.failure? result.stdout.strip[%r{/(#{RELEASE_REGEXP})$}o, 1] end end end tomo-1.22.0/lib/tomo/plugin/core/helpers.rb0000644000004100000410000000360415216001452020542 0ustar www-datawww-data# frozen_string_literal: true require "shellwords" module Tomo::Plugin::Core module Helpers def capture(*command, **run_opts) result = run(*command, silent: true, **run_opts) result.stdout end def run?(*command, **run_opts) result = run(*command, **run_opts, raise_on_error: false) result.success? end def write(to:, text: nil, template: nil, append: false, **run_opts) assert_text_or_template_required!(text, template) text = merge_template(template) unless template.nil? message = "Writing #{text.bytesize} bytes to #{to}" run( "echo -n #{text.shellescape} #{append ? '>>' : '>'} #{to.shellescape}", echo: message, **run_opts ) end def ln_sf(target, link, **run_opts) run("ln", "-sf", target, link, **run_opts) end def ln_sfn(target, link, **run_opts) run("ln", "-sfn", target, link, **run_opts) end def mkdir_p(*directories, **run_opts) run("mkdir", "-p", *directories, **run_opts) end def rm_rf(*paths, **run_opts) run("rm", "-rf", *paths, **run_opts) end def list_files(directory=nil, **run_opts) capture("ls", "-A1", directory, **run_opts).strip.split("\n") end def command_available?(command_name, **run_opts) run?("which", command_name, silent: true, **run_opts) end def file?(file, **run_opts) flag?("-f", file, **run_opts) end def executable?(file, **run_opts) flag?("-x", file, **run_opts) end def directory?(directory, **run_opts) flag?("-d", directory, **run_opts) end private def flag?(flag, path, **run_opts) run?("[ #{flag} #{path.to_s.shellescape} ]", **run_opts) end def assert_text_or_template_required!(text, template) return if text.nil? ^ template.nil? raise ArgumentError, "specify text: or template:" end end end tomo-1.22.0/lib/tomo/console.rb0000644000004100000410000000300515216001452016307 0ustar www-datawww-data# frozen_string_literal: true require "forwardable" require "io/console" module Tomo class Console autoload :KeyReader, "tomo/console/key_reader" autoload :Menu, "tomo/console/menu" autoload :NonInteractiveError, "tomo/console/non_interactive_error" class << self extend Forwardable def_delegators :@instance, :interactive?, :prompt, :menu end def initialize(env=ENV, input=$stdin, output=$stdout) @env = env @input = input @output = output end def interactive? input.respond_to?(:raw) && input.respond_to?(:tty?) && input.tty? && !ci? end def prompt(question) assert_interactive output.print question line = input.gets raise_non_interactive if line.nil? line.chomp end def menu(question, choices:) assert_interactive Menu.new(question, choices).prompt_for_selection end private attr_reader :env, :input, :output CI_VARS = %w[ JENKINS_HOME JENKINS_URL GITHUB_ACTION TRAVIS CIRCLECI TEAMCITY_VERSION bamboo_buildKey GITLAB_CI CI ].freeze private_constant :CI_VARS def ci? env.keys.intersect?(CI_VARS) end def assert_interactive raise_non_interactive unless interactive? end def raise_non_interactive NonInteractiveError.raise_with(task: Runtime::Current.task, ci_var: (env.keys & CI_VARS).first) end end end Tomo::Console.instance_variable_set :@instance, Tomo::Console.new tomo-1.22.0/lib/tomo/result.rb0000644000004100000410000000077315216001452016174 0ustar www-datawww-data# frozen_string_literal: true module Tomo class Result def self.empty_success new(stdout: +"", stderr: +"", exit_status: 0) end attr_reader :stdout, :stderr, :exit_status def initialize(stdout:, stderr:, exit_status:) @stdout = stdout @stderr = stderr @exit_status = exit_status freeze end def success? exit_status.zero? end def failure? !success? end def output [stdout, stderr].compact.join end end end tomo-1.22.0/lib/tomo/commands.rb0000644000004100000410000000074315216001452016454 0ustar www-datawww-data# frozen_string_literal: true module Tomo module Commands autoload :CompletionScript, "tomo/commands/completion_script" autoload :Default, "tomo/commands/default" autoload :Deploy, "tomo/commands/deploy" autoload :Help, "tomo/commands/help" autoload :Init, "tomo/commands/init" autoload :Run, "tomo/commands/run" autoload :Setup, "tomo/commands/setup" autoload :Tasks, "tomo/commands/tasks" autoload :Version, "tomo/commands/version" end end tomo-1.22.0/lib/tomo/ssh.rb0000644000004100000410000000255715216001452015455 0ustar www-datawww-data# frozen_string_literal: true module Tomo module SSH autoload :ChildProcess, "tomo/ssh/child_process" autoload :Connection, "tomo/ssh/connection" autoload :ConnectionValidator, "tomo/ssh/connection_validator" autoload :ConnectionError, "tomo/ssh/connection_error" autoload :Error, "tomo/ssh/error" autoload :ExecutableError, "tomo/ssh/executable_error" autoload :Options, "tomo/ssh/options" autoload :PermissionError, "tomo/ssh/permission_error" autoload :ScriptError, "tomo/ssh/script_error" autoload :UnknownError, "tomo/ssh/unknown_error" autoload :UnsupportedVersionError, "tomo/ssh/unsupported_version_error" class << self def connect(host:, options: {}) options = Options.new(options) unless options.is_a?(Options) Tomo.logger.connect(host) return build_dry_run_connection(host, options) if Tomo.dry_run? build_connection(host, options) end private def build_dry_run_connection(host, options) Connection.dry_run(host, options) end def build_connection(host, options) conn = Connection.new(host, options) validator = ConnectionValidator.new(options.executable, conn) validator.assert_valid_executable! validator.assert_valid_connection! validator.dump_env if Tomo.debug? conn end end end end tomo-1.22.0/lib/tomo/task_library.rb0000644000004100000410000000030215216001452017330 0ustar www-datawww-data# frozen_string_literal: true module Tomo class TaskLibrary include TaskAPI def initialize(context) @context = context end private attr_reader :context end end tomo-1.22.0/LICENSE.txt0000644000004100000410000000207015216001452014420 0ustar www-datawww-dataThe MIT License (MIT) Copyright (c) 2026 Matt Brictson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. tomo-1.22.0/README.md0000644000004100000410000002731415216001452014064 0ustar www-datawww-data# Tomo [![Gem Version](https://img.shields.io/gem/v/tomo)](https://rubygems.org/gems/tomo) [![Gem Downloads](https://img.shields.io/gem/dt/tomo)](https://www.ruby-toolbox.com/projects/tomo) [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/mattbrictson/tomo/ci.yml)](https://github.com/mattbrictson/tomo/actions/workflows/ci.yml) Tomo is a friendly command-line tool for deploying Rails apps. 💻 Rich command-line interface with built-in bash completions
☁️ Multi-environment and role-based multi-host support
💎 Everything you need to deploy a basic Rails app out of the box
🔌 Easily extensible for polyglot projects (not just Rails!)
📚 Quality documentation
🔬 Minimal dependencies
[→ See how tomo compares to other Ruby deployment tools like Capistrano and Mina.](https://tomo.mattbrictson.com/comparisons/) --- - [Quick start](#quick-start) - [Usage](#usage) - [Extending tomo](#extending-tomo) - [Tutorials](#tutorials) - [Blog posts](#blog-posts) - [Reference documentation](#reference-documentation) - [FAQ](#faq) - [Support](#support) - [License](#license) - [Code of conduct](#code-of-conduct) - [Contribution guide](#contribution-guide) ## Quick start #### Installation Tomo is distributed as a ruby gem. To install: ``` $ gem install tomo ``` > 💡 **Protip:** run `tomo completion-script` for instructions on setting up bash completions. #### Configuring a project Tomo is configured via a `.tomo/config.rb` file in your project. To get started, run `tomo init` to generate a configuration that works for a basic Rails app. ![$ tomo init](./readme_images/tomo-init.png) An abbreviated version looks like this: ```ruby # .tomo/config.rb plugin "git" plugin "bundler" plugin "rails" # ... host "user@hostname.or.ip.address" set application: "my-rails-app" set deploy_to: "/var/www/%{application}" set git_url: "git@github.com:my-username/my-rails-app.git" set git_branch: "main" # ... setup do run "git:clone" run "git:create_release" run "bundler:install" run "rails:db_schema_load" # ... end deploy do run "git:create_release" run "core:symlink_shared" run "core:write_release_json" run "bundler:install" run "rails:assets_precompile" run "rails:db_migrate" run "core:symlink_current" # ... end ``` #### Next steps [→ The reference docs have a complete guide to tomo configuration.](https://tomo.mattbrictson.com/configuration/)
[→ Check out the **Deploying Rails From Scratch** tutorial for a step-by-step guide to using tomo with a real app.](https://tomo.mattbrictson.com/tutorials/deploying-rails-from-scratch/) ## Usage Once your project is configured, you can: 1. Run `tomo setup` to prepare the remote host for its first deploy. 2. Run `tomo deploy` to deploy your app. 3. Use `tomo run` to invoke one-off tasks, like launching a Rails console. > 💡 **Protip:** add `-h` or `--help` when running any of these commands to see detailed docs and examples. ### `tomo setup` `tomo setup` prepares the remote host for its first deploy by sequentially running the `setup` list of tasks specified in `.tomo/config.rb`. These tasks typically create directories, initialize data stores, install prerequisite tools, and perform other one-time actions that are necessary before a deploy can take place. Out of the box, tomo will: - Configure necessary environment variables, like `RAILS_ENV` and `SECRET_KEY_BASE` - Install Ruby, Bundler, Node, Yarn, and dependencies - Create all necessary deployment directories - Create the Rails database, load the schema, and insert seed data [→ Here is the default list of tasks invoked by the setup command.](https://tomo.mattbrictson.com/configuration#setupblock)
[→ The `tomo setup` section of the reference docs explains supported command-line options.](https://tomo.mattbrictson.com/commands/setup/) ### `tomo deploy` Whereas `tomo setup` is typically run once, you can use `tomo deploy` every time you want to deploy a new version of your app. The deploy command will sequentially run the `deploy` list of tasks specified in `.tomo/config.rb`. You can customize this list to meet the needs of your app. By default, tomo runs these tasks: 1. Create a release (using the [git:create_release](https://tomo.mattbrictson.com/plugins/git#gitcreate_release) task) 2. Build the project (e.g. [bundler:install](https://tomo.mattbrictson.com/plugins/bundler#bundlerinstall), [rails:assets_precompile](https://tomo.mattbrictson.com/plugins/rails#railsassets_precompile)) 3. Migrate data to the meet the requirements of the new release (e.g. [rails:db_migrate](https://tomo.mattbrictson.com/plugins/rails#railsdb_migrate)) 4. Make the new release the "current" one ([core:symlink_current](https://tomo.mattbrictson.com/plugins/core#coresymlink_current)) 5. Restart the app to use the new current release (e.g. [puma:restart](https://tomo.mattbrictson.com/plugins/puma#pumarestart)) 6. Perform any cleanup (e.g. [bundler:clean](https://tomo.mattbrictson.com/plugins/bundler#bundlerclean)) > 💡 **Protip:** you can abbreviate tomo commands, like `tomo d` for `tomo deploy` or `tomo s` for `tomo setup`. [→ Here is the default list of tasks invoked by the deploy command.](https://tomo.mattbrictson.com/configuration#deployblock)
[→ The `tomo deploy` section of the reference docs explains supported command-line options, like `--dry-run`.](https://tomo.mattbrictson.com/commands/deploy/) ### `tomo run [TASK]` Tomo can also `run` individual remote tasks on demand. You can use the `tasks` command to see the list of tasks tomo knows about. ![$ tomo tasks](./readme_images/tomo-tasks.png) One of the built-in Rails tasks is `rails:console`, which brings up a fully-interactive Rails console over SSH. ![$ tomo run rails:console](./readme_images/tomo-run-rails-console.png) > 💡 **Protip:** you can shorten this as `tomo rails:console` (the `run` command is implied). [→ The `tomo run` section of the reference docs explains supported command-line options and has more examples.](https://tomo.mattbrictson.com/commands/run/) ## Extending tomo Tomo has a powerful plugin system that lets you extend tomo by installing Ruby gems (e.g. [tomo-plugin-sidekiq](https://github.com/mattbrictson/tomo-plugin-sidekiq)). You can also define plugins on the fly within your project by adding simple `.rb` files to `.tomo/plugins/`. These plugins can define tasks as plain ruby methods. For example: ```ruby # .tomo/plugins/my-plugin.rb def hello remote.run "echo", "hello", settings[:application] end ``` Load your plugin in `config.rb` like this: ```ruby # .tomo/config.rb plugin "./plugins/my-plugin.rb" ``` And run it! ![$ tomo run my-plugin:hello](./readme_images/tomo-run-hello.png) [→ The **Writing Custom Tasks** tutorial has an in-depth explanation of how plugins work.](https://tomo.mattbrictson.com/tutorials/writing-custom-tasks/)
[→ The **TaskLibrary** API is tomo's DSL for building tasks.](https://tomo.mattbrictson.com/api/TaskLibrary/)
[→ The **Publishing a Plugin** tutorial explains how to package your plugin as a Ruby gem to share it with the community.](https://tomo.mattbrictson.com/tutorials/publishing-a-plugin/) ## Tutorials - [Deploying Rails From Scratch](https://tomo.mattbrictson.com/tutorials/deploying-rails-from-scratch/) - [Writing Custom Tasks](https://tomo.mattbrictson.com/tutorials/writing-custom-tasks/) - [Publishing a Plugin](https://tomo.mattbrictson.com/tutorials/publishing-a-plugin/) ## Blog posts - [Automate Rails deployments with GitHub Actions](https://mattbrictson.com/blog/deploy-rails-with-github-actions) ## Reference documentation - [Configuration](https://tomo.mattbrictson.com/configuration/) - Commands - [init](https://tomo.mattbrictson.com/commands/init/) - [setup](https://tomo.mattbrictson.com/commands/setup/) - [deploy](https://tomo.mattbrictson.com/commands/deploy/) - [run](https://tomo.mattbrictson.com/commands/run/) - [tasks](https://tomo.mattbrictson.com/commands/tasks/) - Plugins - [core](https://tomo.mattbrictson.com/plugins/core/) - [bundler](https://tomo.mattbrictson.com/plugins/bundler/) - [env](https://tomo.mattbrictson.com/plugins/env/) - [git](https://tomo.mattbrictson.com/plugins/git/) - [nodenv](https://tomo.mattbrictson.com/plugins/nodenv/) - [puma](https://tomo.mattbrictson.com/plugins/puma/) - [rails](https://tomo.mattbrictson.com/plugins/rails/) - [rbenv](https://tomo.mattbrictson.com/plugins/rbenv/) - API - [Host](https://tomo.mattbrictson.com/api/Host/) - [Logger](https://tomo.mattbrictson.com/api/Logger/) - [Paths](https://tomo.mattbrictson.com/api/Paths/) - [PluginDSL](https://tomo.mattbrictson.com/api/PluginDSL/) - [Remote](https://tomo.mattbrictson.com/api/Remote/) - [Result](https://tomo.mattbrictson.com/api/Result/) - [TaskLibrary](https://tomo.mattbrictson.com/api/TaskLibrary/) - [Testing::MockPluginTester](https://tomo.mattbrictson.com/api/testing/MockPluginTester/) ## FAQ #### What does the `unsupported option "accept-new"` error mean? By default, tomo uses the ["accept-new"](https://www.openssh.com/txt/release-7.6) value for the StrictHostKeyChecking option, which is supported by OpenSSH 7.6 and newer. If you are using an older version, this will cause an error. As a workaround, you can override tomo's default behavior like this: ```ruby # Replace "accept-new" with something compatible with older versions of SSH set ssh_strict_host_key_checking: true # or false ``` #### Can I deploy multiple apps to a single host? Tomo relies on the host user's bash profile for various things, like setting environment variables and initializing rbenv and nodenv. This makes it impractical to deploy multiple apps to a single host using the same deploy user. The solution is to create multiple users on the remote host, and then configure a different user for deploying each app. That way each user can have its own distinct environment variables and you can easily configure each app differently without risking conflicts. Refer to the [tomo Rails tutorial](https://tomo.mattbrictson.com/tutorials/deploying-rails-from-scratch/#set-up-a-deployer-user) for instructions on creating a deploy user. E.g. app1 would be configured to deploy as: ```ruby host "app1@example.com" ``` And app2 would be configured to deploy as: ```ruby host "app2@example.com" ``` Next run `tomo setup` for _both_ apps; this will set everything up for both users on the remote host (environment variables, rbenv, etc.). You can now deploy both apps to the same host, with the confidence that their configurations will be kept cleanly separated. #### Does tomo support git submodules? No, not out of the box. However, you can extend tomo with an additional task for submodules; see the solution in [PR #220](https://github.com/mattbrictson/tomo/pull/220#pullrequestreview-979249573) suggested by [@numbcoder](https://github.com/numbcoder). ## Support This project is a labor of love and I can only spend a few hours a week maintaining it, at most. If you'd like to help by submitting a pull request, or if you've discovered a bug that needs my attention, please let me know. Check out [CONTRIBUTING.md](https://github.com/mattbrictson/tomo/blob/main/CONTRIBUTING.md) to get started. Happy hacking! —Matt ## License The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). ## Code of conduct Everyone interacting in the Tomo project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/mattbrictson/tomo/blob/main/CODE_OF_CONDUCT.md). ## Contribution guide Interested in filing a bug report, feature request, or opening a PR? Excellent! Please read the short [CONTRIBUTING.md](https://github.com/mattbrictson/tomo/blob/main/CONTRIBUTING.md) guidelines before you dive in. tomo-1.22.0/tomo.gemspec0000644000004100000410000001662615216001452015134 0ustar www-datawww-data######################################################### # This file has been automatically generated by gem2tgz # ######################################################### # -*- encoding: utf-8 -*- # stub: tomo 1.22.0 ruby lib Gem::Specification.new do |s| s.name = "tomo".freeze s.version = "1.22.0".freeze s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= s.metadata = { "bug_tracker_uri" => "https://github.com/mattbrictson/tomo/issues", "changelog_uri" => "https://github.com/mattbrictson/tomo/releases", "documentation_uri" => "https://tomo.mattbrictson.com/", "homepage_uri" => "https://github.com/mattbrictson/tomo", "rubygems_mfa_required" => "true", "source_code_uri" => "https://github.com/mattbrictson/tomo" } if s.respond_to? :metadata= s.require_paths = ["lib".freeze] s.authors = ["Matt Brictson".freeze] s.bindir = "exe".freeze s.date = "1980-01-02" s.description = "Tomo is a feature-rich deployment tool that contains everything you need to deploy a basic Rails app out of the box. It has an opinionated, production-tested set of defaults, but is easily extensible via a well-documented plugin system. Unlike other Ruby-based deployment tools, tomo\u2019s friendly command-line interface and task system do not rely on Rake.".freeze s.email = ["opensource@mattbrictson.com".freeze] s.executables = ["tomo".freeze] s.files = ["LICENSE.txt".freeze, "README.md".freeze, "exe/tomo".freeze, "lib/tomo.rb".freeze, "lib/tomo/cli.rb".freeze, "lib/tomo/cli/command.rb".freeze, "lib/tomo/cli/common_options.rb".freeze, "lib/tomo/cli/completions.rb".freeze, "lib/tomo/cli/deploy_options.rb".freeze, "lib/tomo/cli/error.rb".freeze, "lib/tomo/cli/interrupted_error.rb".freeze, "lib/tomo/cli/options.rb".freeze, "lib/tomo/cli/parser.rb".freeze, "lib/tomo/cli/project_options.rb".freeze, "lib/tomo/cli/rules.rb".freeze, "lib/tomo/cli/rules/argument.rb".freeze, "lib/tomo/cli/rules/switch.rb".freeze, "lib/tomo/cli/rules/value_switch.rb".freeze, "lib/tomo/cli/rules_evaluator.rb".freeze, "lib/tomo/cli/state.rb".freeze, "lib/tomo/cli/usage.rb".freeze, "lib/tomo/colors.rb".freeze, "lib/tomo/commands.rb".freeze, "lib/tomo/commands/completion_script.rb".freeze, "lib/tomo/commands/default.rb".freeze, "lib/tomo/commands/deploy.rb".freeze, "lib/tomo/commands/help.rb".freeze, "lib/tomo/commands/init.rb".freeze, "lib/tomo/commands/run.rb".freeze, "lib/tomo/commands/setup.rb".freeze, "lib/tomo/commands/tasks.rb".freeze, "lib/tomo/commands/version.rb".freeze, "lib/tomo/configuration.rb".freeze, "lib/tomo/configuration/dsl.rb".freeze, "lib/tomo/configuration/dsl/batch_block.rb".freeze, "lib/tomo/configuration/dsl/config_file.rb".freeze, "lib/tomo/configuration/dsl/environment_block.rb".freeze, "lib/tomo/configuration/dsl/error_formatter.rb".freeze, "lib/tomo/configuration/dsl/hosts_and_settings.rb".freeze, "lib/tomo/configuration/dsl/tasks_block.rb".freeze, "lib/tomo/configuration/environment.rb".freeze, "lib/tomo/configuration/glob.rb".freeze, "lib/tomo/configuration/plugin_file_not_found_error.rb".freeze, "lib/tomo/configuration/plugins_registry.rb".freeze, "lib/tomo/configuration/plugins_registry/file_resolver.rb".freeze, "lib/tomo/configuration/plugins_registry/gem_resolver.rb".freeze, "lib/tomo/configuration/project_not_found_error.rb".freeze, "lib/tomo/configuration/role_based_task_filter.rb".freeze, "lib/tomo/configuration/unknown_environment_error.rb".freeze, "lib/tomo/configuration/unknown_plugin_error.rb".freeze, "lib/tomo/configuration/unspecified_environment_error.rb".freeze, "lib/tomo/console.rb".freeze, "lib/tomo/console/key_reader.rb".freeze, "lib/tomo/console/menu.rb".freeze, "lib/tomo/console/non_interactive_error.rb".freeze, "lib/tomo/error.rb".freeze, "lib/tomo/error/suggestions.rb".freeze, "lib/tomo/host.rb".freeze, "lib/tomo/logger.rb".freeze, "lib/tomo/logger/tagged_io.rb".freeze, "lib/tomo/path.rb".freeze, "lib/tomo/paths.rb".freeze, "lib/tomo/plugin.rb".freeze, "lib/tomo/plugin/bundler.rb".freeze, "lib/tomo/plugin/bundler/helpers.rb".freeze, "lib/tomo/plugin/bundler/tasks.rb".freeze, "lib/tomo/plugin/core.rb".freeze, "lib/tomo/plugin/core/helpers.rb".freeze, "lib/tomo/plugin/core/tasks.rb".freeze, "lib/tomo/plugin/env.rb".freeze, "lib/tomo/plugin/env/tasks.rb".freeze, "lib/tomo/plugin/git.rb".freeze, "lib/tomo/plugin/git/helpers.rb".freeze, "lib/tomo/plugin/git/tasks.rb".freeze, "lib/tomo/plugin/nodenv.rb".freeze, "lib/tomo/plugin/nodenv/tasks.rb".freeze, "lib/tomo/plugin/puma.rb".freeze, "lib/tomo/plugin/puma/systemd/service.erb".freeze, "lib/tomo/plugin/puma/systemd/socket.erb".freeze, "lib/tomo/plugin/puma/tasks.rb".freeze, "lib/tomo/plugin/rails.rb".freeze, "lib/tomo/plugin/rails/helpers.rb".freeze, "lib/tomo/plugin/rails/tasks.rb".freeze, "lib/tomo/plugin/rbenv.rb".freeze, "lib/tomo/plugin/rbenv/tasks.rb".freeze, "lib/tomo/plugin/testing.rb".freeze, "lib/tomo/plugin_dsl.rb".freeze, "lib/tomo/remote.rb".freeze, "lib/tomo/result.rb".freeze, "lib/tomo/runtime.rb".freeze, "lib/tomo/runtime/concurrent_ruby_load_error.rb".freeze, "lib/tomo/runtime/concurrent_ruby_thread_pool.rb".freeze, "lib/tomo/runtime/context.rb".freeze, "lib/tomo/runtime/current.rb".freeze, "lib/tomo/runtime/execution_plan.rb".freeze, "lib/tomo/runtime/explanation.rb".freeze, "lib/tomo/runtime/host_execution_step.rb".freeze, "lib/tomo/runtime/inline_thread_pool.rb".freeze, "lib/tomo/runtime/no_tasks_error.rb".freeze, "lib/tomo/runtime/privileged_task.rb".freeze, "lib/tomo/runtime/settings_interpolation.rb".freeze, "lib/tomo/runtime/settings_required_error.rb".freeze, "lib/tomo/runtime/task_aborted_error.rb".freeze, "lib/tomo/runtime/task_runner.rb".freeze, "lib/tomo/runtime/template_not_found_error.rb".freeze, "lib/tomo/runtime/unknown_task_error.rb".freeze, "lib/tomo/script.rb".freeze, "lib/tomo/shell_builder.rb".freeze, "lib/tomo/ssh.rb".freeze, "lib/tomo/ssh/child_process.rb".freeze, "lib/tomo/ssh/connection.rb".freeze, "lib/tomo/ssh/connection_error.rb".freeze, "lib/tomo/ssh/connection_validator.rb".freeze, "lib/tomo/ssh/error.rb".freeze, "lib/tomo/ssh/executable_error.rb".freeze, "lib/tomo/ssh/options.rb".freeze, "lib/tomo/ssh/permission_error.rb".freeze, "lib/tomo/ssh/script_error.rb".freeze, "lib/tomo/ssh/unknown_error.rb".freeze, "lib/tomo/ssh/unsupported_version_error.rb".freeze, "lib/tomo/task_api.rb".freeze, "lib/tomo/task_library.rb".freeze, "lib/tomo/templates/config.rb.erb".freeze, "lib/tomo/templates/plugin.rb.erb".freeze, "lib/tomo/testing.rb".freeze, "lib/tomo/testing/Dockerfile".freeze, "lib/tomo/testing/cli_extensions.rb".freeze, "lib/tomo/testing/cli_tester.rb".freeze, "lib/tomo/testing/connection.rb".freeze, "lib/tomo/testing/docker_image.rb".freeze, "lib/tomo/testing/host_extensions.rb".freeze, "lib/tomo/testing/local.rb".freeze, "lib/tomo/testing/log_capturing.rb".freeze, "lib/tomo/testing/mock_plugin_tester.rb".freeze, "lib/tomo/testing/mocked_exec_error.rb".freeze, "lib/tomo/testing/mocked_exit_error.rb".freeze, "lib/tomo/testing/remote_extensions.rb".freeze, "lib/tomo/testing/ssh_extensions.rb".freeze, "lib/tomo/testing/systemctl.rb".freeze, "lib/tomo/testing/tomo_test_ed25519".freeze, "lib/tomo/testing/tomo_test_ed25519.pub".freeze, "lib/tomo/testing/ubuntu_setup.sh".freeze, "lib/tomo/version.rb".freeze] s.homepage = "https://github.com/mattbrictson/tomo".freeze s.licenses = ["MIT".freeze] s.required_ruby_version = Gem::Requirement.new(">= 3.2".freeze) s.rubygems_version = "4.0.6".freeze s.summary = "A friendly CLI for deploying Rails apps \u2728".freeze end