# frozen_string_literal: true require "shopify_cli" require "fileutils" require "rbconfig" require "net/http" require "json" module ShopifyCLI ## # Context captures a lot about the current running command. It captures the # environment, output, system and file operations. It is useful to have the # context especially in tests so that you have a single access point to these # resoures. # class Context GEM_LATEST_URI = URI.parse("https://rubygems.org/api/v1/versions/shopify-cli/latest.json") VERSION_CHECK_SECTION = "versioncheck" LAST_CHECKED_AT_FIELD = "last_checked_at" VERSION_CHECK_INTERVAL = 86400 class << self attr_reader :messages # adds a new set of messages to be used by the CLI. The messages are expected to be a hash of symbols, and # multiple levels are allowed. When fetching messages a dot notation is used to separate different levels. See # Context::message for more information. # # #### Parameters # * `messages` - Hash containing the new keys to register def load_messages(messages) @messages ||= {} @messages = @messages.merge(messages) do |key| Context.new.abort("Message key '#{key}' already exists and cannot be registered") if @messages.key?(key) end end # returns the user-facing messages for the given key. Returns the key if no message is available. # # #### Parameters # * `key` - a symbol representing the message # * `params` - the parameters to format the string with def message(key, *params) key_parts = key.split(".").map(&:to_sym) str = Context.messages.dig(*key_parts) str ? str % params : key end end # is the directory root that the current command is running in. If you want to # simulate a `cd` for the file operations, you can change this variable. attr_accessor :root # is an accessor for environment variables. These variables are also added to # any command run by the context. attr_accessor :env def initialize(root: Dir.pwd, env: ($original_env || ENV).clone) # :nodoc: self.root = root self.env = env end # will return which operating system that the cli is running on [:mac, :linux] def os host = uname return :mac_m1 if /arm64-apple-darwin/i.match(host) return :mac if /darwin/i.match(host) return :windows if /mswin|mingw|cygwin/i.match(host) return :linux if /linux|bsd/i.match(host) :unknown end # will return true if the cli is running on an apple computer. def mac? os == :mac end # will return true if the cli is running on a linux distro def linux? os == :linux end # will return true if the cli is running on Windows def windows? os == :windows end # will return true if the os is unknown def unknown_os? os == :unknown end # will return true if being launched from a tty def tty? !testing? && $stdin.tty? end # will return true if the cli is being run from an installation, and not a # development instance. The gem installation will not have a 'test' directory. # See `#development?` for checking for development environment. # def system? !Dir.exist?(File.join(ShopifyCLI::ROOT, "test")) end # will return true if the cli is running on your development instance. # def development? !system? && !testing? end # will return true while tests are running, either locally or on CI def testing? ci? || ENV["TEST"] end ## # will return true if the cli is being tested on CI def ci? ENV["CI"] end ## # will return true if the cli is running with the DEBUG flag def debug? getenv("DEBUG") end # get a environment variable value by name. # # #### Parameters # * `name` - the name of the environment variable that you want to fetch # # #### Returns # * `value` - will return the value, or nil if the variable does not exist # def getenv(name) v = @env[name] v == "" ? nil : v end # set a environment variable value by name. # # #### Parameters # * `key` - the name of the environment variable that you want to set # * `value` - the value of the variable # def setenv(key, value) @env[key] = value end # will write/overwrite a file with the provided contents, relative to the context root # unless the file path is absolute. # # #### Parameters # * `fname` - filename of the file that you are writing, relative to root unless it is absolute. # * `content` - the body contents of the file that you are writing # # #### Example # # @ctx.write('new.txt', 'hello world') # def write(fname, content) File.write(ctx_path(fname), content) end # will read a file relative to the context root unless the file path is absolute. # # #### Parameters # * `fname` - filename of the file that you are reading, relative to root unless it is absolute. # # #### Example # # @ctx.read('file.txt') # def read(fname) File.read(ctx_path(fname)) end # will read a binary file relative to the context root unless the file path is absolute. # # #### Parameters # * `fname` - filename of the file that you are reading, relative to root unless it is absolute. # # #### Example # # @ctx.read('binary.out') # def binread(fname) File.binread(ctx_path(fname)) end # will write/overwrite a binary file with the provided contents, relative to the context root # unless the file path is absolute. # # #### Parameters # * `fname` - filename of the file that you are writing, relative to root unless it is absolute. # * `content` - the body contents of the file that you are writing # # #### Example # # @ctx.binwrite('binary.out', 'ASCII-8BIT encoded binary') # def binwrite(fname, content) File.binwrite(ctx_path(fname), content) end # will change directories and update the root, the filepath is relative to the command root unless absolute # # #### Parameters # * `path` - the file path to a directory, relative to the context root to remove from the FS # def chdir(path) Dir.chdir(ctx_path(path)) self.root = ctx_path(path) end # checks if a directory exists, the filepath is relative to the command root unless absolute # # #### Parameters # * `path` - the file path to a directory, relative to the context root to remove from the FS # def dir_exist?(path) Dir.exist?(ctx_path(path)) end # checks if a file exists, the filepath is relative to the command root unless absolute # # #### Parameters # * `path` - the file path to a file, relative to the context root to remove from the FS # def file_exist?(path) File.exist?(ctx_path(path)) end # will recursively copy a directory from the FS, the filepath is relative to the command # root unless absolute # # #### Parameters # * `from` - the path of the original file # * `to` - the destination path # def cp_r(from, to) FileUtils.cp_r(ctx_path(from), ctx_path(to)) end # will copy a directory from the FS, the filepath is relative to the command # root unless absolute # # #### Parameters # * `from` - the path of the original file # * `to` - the destination path # def cp(from, to) FileUtils.cp(ctx_path(from), ctx_path(to)) end # will rename a file from one place to another, relative to the command root # unless the path is absolute. # # #### Parameters # * `from` - the path of the original file # * `to` - the destination path # def rename(from, to) File.rename(ctx_path(from), ctx_path(to)) end # will remove a plain file from the FS, the filepath is relative to the command # root unless absolute. # # #### Parameters # * `fname` - the file path relative to the context root to remove from the FS # def rm(fname) FileUtils.rm(ctx_path(fname)) end # will remove a directory from the FS, the filepath is relative to the command # root unless absolute # # #### Parameters # * `fname` - the file path to a directory, relative to the context root to remove from the FS # def rm_r(fname) FileUtils.rm_r(ctx_path(fname)) end # will force remove a directory from the FS, the filepath is relative to the command # root unless absolute # # #### Parameters # * `fname` - the file path to a directory, relative to the context root to remove from the FS # def rm_rf(fname) FileUtils.rm_rf(ctx_path(fname)) end # will create a directory, recursively if it does not exist. So if you create # a directory `foo/bar/dun`, this will also create the directories `foo` and # `foo/bar` if they do not exist. The path will be made relative to the command # root unless absolute # # #### Parameters # * `path` - file path of the directory that you want to create # def mkdir_p(path) FileUtils.mkdir_p(path) end # will output to the console a link for the user to either copy/paste # or click on. # # #### Parameters # * `uri` - a http URI to open in a browser # def open_url!(uri) help = message("core.context.open_url", uri) puts(help) end # will output to the console a link for the user to either copy/paste # or click on. # # #### Parameters # * `uri` - a http URI to open in a browser # def open_browser_url!(uri) if tty? if linux? && which("xdg-open") system("xdg-open", uri.to_s) elsif windows? system("start \"\" \"#{uri}\"") elsif mac? system("open", uri.to_s) else open_url!(uri) end else open_url!(uri) end end # will output a message, prefixed by a yellow star, indicating that task # started. # # #### Parameters # * `text` - a string message to output # def print_task(text) puts "{{yellow:*}} #{text}" end # a wrapper around Kernel.puts to allow for easy formatting # # #### Parameters # * `text` - a string message to output # def puts(*args) Kernel.puts(CLI::UI.fmt(*args)) end # a wrapper around $stderr.puts to allow for easy formatting # # #### Parameters # * `text` - a string message to output # def error(text) $stderr.puts(CLI::UI.fmt(text)) end # a wrapper around Kernel.warn to allow for easy formatting # # #### Parameters # * `text` - a string message to output # def warn(*args) Kernel.warn(CLI::UI.fmt(*args)) end # outputs a message, prefixed by a checkmark indicating that something completed # # #### Parameters # * `text` - a string message to output # def done(text) puts("{{v}} #{text}") end # aborts the current running command and outputs an error message, prefixed # by a red x # # #### Parameters # * `text` - a string message to output # def abort(text) raise ShopifyCLI::Abort, "{{x}} #{text}" end # outputs a message, prefixed by a red `DEBUG` tag. This will only output to # the console if you have `DEBUG=1` set in your shell environment. # # #### Parameters # * `text` - a string message to output # def debug(text) puts("{{red:DEBUG}} #{text}") if debug? end # proxy call to Context.message. # # #### Parameters # * `key` - a symbol representing the message # * `params` - the parameters to format the string with def message(key, *params) Context.message(key, *params) end # will grab the host info of the computer running the cli. This indicates the # computer architecture and operating system def uname @uname ||= RbConfig::CONFIG["host"] end # Execute a command in the user's environment # Outputs result of the command without capturing it # # #### Parameters # - `*args`: A splat of arguments evaluated as a command. (e.g. `'rm', folder` is equivalent to `rm #{folder}`) # - `**kwargs`: additional keyword arguments to pass to Process.spawn # # #### Returns # - `status`: The `Process::Status` result of the command execution. # # #### Usage # # stat = @ctx.system('ls', 'a_folder') # def system(*args, **kwargs) process_status = CLI::Kit::System.system(*args, env: @env, **kwargs) unless process_status.success? abort("System call failed: #{args.join(" ")}") end process_status end # Execute a command in the user's environment # This is meant to be largely equivalent to backticks, only with the env passed in. # Captures the results of the command without output to the console # # #### Parameters # - `*args`: A splat of arguments evaluated as a command. (e.g. `'rm', folder` is equivalent to `rm #{folder}`) # - `**kwargs`: additional arguments to pass to Open3.capture2 # # #### Returns # - `output`: output (STDOUT) of the command execution # - `status`: boolean success status of the command execution # # #### Usage # # out, stat = @ctx.capture2('ls', 'a_folder') # def capture2(*args, **kwargs) CLI::Kit::System.capture2(*args, env: @env, **kwargs) end # Execute a command in the user's environment # This is meant to be largely equivalent to backticks, only with the env passed in. # Captures the results of the command without output to the console # # #### Parameters # - `*args`: A splat of arguments evaluated as a command. (e.g. `'rm', folder` is equivalent to `rm #{folder}`) # - `**kwargs`: additional arguments to pass to Open3.capture2e # # #### Returns # - `output`: output (STDOUT merged with STDERR) of the command execution # - `status`: boolean success status of the command execution # # #### Usage # # out_and_err, stat = @ctx.capture2e('ls', 'a_folder') # def capture2e(*args, **kwargs) CLI::Kit::System.capture2e(*args, env: @env, **kwargs) end # Execute a command in the user's environment # This is meant to be largely equivalent to backticks, only with the env passed in. # Captures the results of the command without output to the console # # #### Parameters # - `*args`: A splat of arguments evaluated as a command. (e.g. `'rm', folder` is equivalent to `rm #{folder}`) # - `**kwargs`: additional arguments to pass to Open3.capture3 # # #### Returns # - `output`: STDOUT of the command execution # - `error`: STDERR of the command execution # - `status`: boolean success status of the command execution # # #### Usage # # out, err, stat = @ctx.capture3('ls', 'a_folder') # def capture3(*args, **kwargs) CLI::Kit::System.capture3(*args, env: @env, **kwargs) end # captures the info signal (ctrl-T) and provide a handler to it. # # #### Example # # @ctx.on_siginfo do # @ctx.open_url!("http://google.com") # end # def on_siginfo # Reset any previous SIGINFO handling we had so the only action we take is the given block trap("INFO", "DEFAULT") fork do begin r, w = IO.pipe @signal = false trap("SIGINFO") do @signal = true w.write(0) end while r.read(1) next unless @signal @signal = false yield end rescue Interrupt exit(0) end end end # Checks if the given command exists in the system # # #### Parameters # - `cmd`: The command to test # # #### Returns # The path of the executable if it is found # # @todo This is currently a duplicate of CLI::Kit::System.which() - we should make that method public when we make # Kit changes and make this a wrapper instead. def which(cmd) exts = ENV["PATHEXT"] ? ENV["PATHEXT"].split(";") : [""] ENV["PATH"].split(File::PATH_SEPARATOR).each do |path| exts.each do |ext| exe = File.join(File.expand_path(path), "#{cmd}#{ext}") return exe if File.executable?(exe) && !File.directory?(exe) end end nil end # Checks if there's a newer version of the CLI available and returns version string if # this should be conveyed to the user (i.e., if it's been over 24 hours since last check) # # #### Parameters # # #### Returns # - `version`: string of newer version available, IFF new version is available AND it's time to inform user, # : nil otherwise # def new_version if (time_of_last_check + VERSION_CHECK_INTERVAL) < (now = Time.now.to_i) update_time_of_last_check(now) latest_version = retrieve_latest_gem_version latest_version unless latest_version == ShopifyCLI::VERSION end end # Returns file extension depending on OS # since windows has multiple extensions, the default is .exe unless otherwise specified # # #### Parameters # - ext: optional extension for windows file # # #### Returns # - ext: string for file extension on windows # : empty string otherwise def executable_file_extension(ext = ".exe") if windows? ext else "" end end private def ctx_path(fname) require "pathname" if Pathname.new(fname).absolute? fname else File.join(root, fname) end end def retrieve_latest_gem_version response = Net::HTTP.get_response(GEM_LATEST_URI) latest = JSON.parse(response.body) latest["version"] rescue nil end def time_of_last_check (val = ShopifyCLI::Config.get(VERSION_CHECK_SECTION, LAST_CHECKED_AT_FIELD)) ? val.to_i : 0 end def update_time_of_last_check(time) ShopifyCLI::Config.set(VERSION_CHECK_SECTION, LAST_CHECKED_AT_FIELD, time) end end end