OpenC3/cosmos

View on GitHub
openc3/bin/openc3cli

Summary

Maintainability
Test Coverage
#!/usr/bin/env ruby
# encoding: ascii-8bit

# Copyright 2022 Ball Aerospace & Technologies Corp.
# All Rights Reserved.
#
# This program is free software; you can modify and/or redistribute it
# under the terms of the GNU Affero General Public License
# as published by the Free Software Foundation; version 3 with
# attribution addendums as found in the LICENSE.txt
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# Modified by OpenC3, Inc.
# All changes Copyright 2024, OpenC3, Inc.
# All Rights Reserved
#
# This file may also be used under the terms of a commercial license
# if purchased from OpenC3, Inc.

# This file will handle OpenC3 tasks such as instantiating a new project

require 'openc3'
require 'openc3/utilities/local_mode'
require 'openc3/utilities/bucket'
require 'openc3/utilities/cli_generator'
require 'openc3/models/scope_model'
require 'openc3/models/plugin_model'
require 'openc3/models/gem_model'
require 'openc3/models/migration_model'
require 'openc3/models/tool_model'
require 'openc3/packets/packet_config'
require 'openc3/bridge/bridge'
require 'ostruct'
require 'optparse'
require 'openc3/utilities/zip'
require 'fileutils'
require 'find'
require 'json'
require 'redis'
require 'erb'
require 'irb'
require 'irb/completion'

$redis_url = "redis://#{ENV['OPENC3_REDIS_HOSTNAME']}:#{ENV['OPENC3_REDIS_PORT']}"

# Build the OpenStruct and OptionParser here as constants so we can use in methods
MIGRATE_OPTIONS = OpenStruct.new
MIGRATE_OPTIONS.all = false
MIGRATE_PARSER = OptionParser.new do |opts|
  opts.banner = "cli migrate PLUGIN [TGT1...]      # Create a OpenC3 plugin from existing COSMOS 4 targets"
  opts.on("-a", "--all", " Move all COSMOS 4 targets into a single OpenC3 plugin") do
    MIGRATE_OPTIONS.all = true
  end
end
ERROR_CODE = 1

CLI_SCRIPT_ACTIONS = %w(help list run spawn)
$script_interrupt_text = ''
trap('INT') do
  abort("Interrupted at console; exiting.#{$script_interrupt_text}")
end

# Prints the usage text for the openc3cli executable
def print_usage
  puts "Usage:"
  puts "  cli help                          # Displays this information"
  puts "  cli rake                          # Runs rake in the local directory"
  puts "  cli irb                           # Runs irb in the local directory"
  puts "  cli script list /PATH SCOPE       # lists script names filtered by path within scope, 'DEFAULT' if not given"
  puts "  cli script spawn NAME SCOPE  variable1=value1 variable2=value2  # Starts named script remotely"
  puts "  cli script run NAME SCOPE variable1=value1 variable2=value2  # Starts named script, monitoring status on console,\
  by default until error or exit"
  puts "    PARAMETERS name-value pairs to form the script's runtime environment"
  puts "    OPTIONS: --wait 0 seconds to monitor status before detaching from the running script; ie --wait 100"
  puts "             --disconnect run the script in disconnect mode"
  puts "  cli validate /PATH/FILENAME.gem SCOPE variables.txt # Validate a COSMOS plugin gem file"
  puts "  cli load /PATH/FILENAME.gem SCOPE variables.txt     # Loads a COSMOS plugin gem file"
  puts "  cli list <SCOPE>                  # Lists installed plugins, SCOPE is DEFAULT if not given"
  puts "  cli generate TYPE OPTIONS         # Generate various COSMOS entities"
  puts "    OPTIONS: --ruby or --python is required to specify the language in the generated code unless OPENC3_LANGUAGE is set"
  puts "  #{MIGRATE_PARSER}"
  puts "  cli bridge CONFIG_FILENAME        # Run COSMOS host bridge"
  puts "  cli bridgegem gem_name variable1=value1 variable2=value2 # Runs bridge using gem bridge.txt"
  puts "  cli bridgesetup CONFIG_FILENAME   # Create a default config file"
  puts "  cli pkginstall PKGFILENAME SCOPE  # Install loaded package (Ruby gem or python package)"
  puts "  cli pkguninstall PKGFILENAME SCOPE  # Uninstall loaded package (Ruby gem or python package)"
  puts "  cli rubysloc                      # Counts Ruby SLOC recursively. Run with --help for more info."
  puts "  cli xtce_converter                # Convert to and from the XTCE format. Run with --help for more info."
  puts "  cli cstol_converter               # Converts CSTOL files (.prc) to COSMOS. Run with --help for more info."
  puts ""
end

def check_environment
  hostname = ENV['OPENC3_API_HOSTNAME'] || (ENV['OPENC3_DEVEL'] ? '127.0.0.1' : 'openc3-cosmos-cmd-tlm-api')
  begin
    Resolv.getaddress(hostname)
  rescue Resolv::ResolvError
    abort "Unable to resolv api hostname: #{hostname}"
  end

  if hostname =~ /openc3-cosmos-cmd-tlm-api/
    $openc3_in_cluster = true
  else
    $openc3_in_cluster = false
  end

  unless $openc3_in_cluster
    # Make sure the user has all the required environment variables set
    abort "OPENC3_API_HOSTNAME environment variable is required" unless ENV['OPENC3_API_HOSTNAME']
    abort "OPENC3_API_PORT environment variable is required" unless ENV['OPENC3_API_PORT']
    abort "OPENC3_API_PASSWORD environment variable is required" unless ENV['OPENC3_API_PASSWORD']
  end
end

def migrate(args)
  MIGRATE_PARSER.parse!(args)
  abort(MIGRATE_PARSER.to_s) if args.length == 0
  if MIGRATE_OPTIONS.all and args.length > 1
    puts "Only specify the plugin name when using --all"
    abort(MIGRATE_PARSER.to_s)
  end
  if !MIGRATE_OPTIONS.all and args.length < 2
    puts "Specify the individual target names when not using --all"
    abort(MIGRATE_PARSER.to_s)
  end
  if Dir.glob("config/targets/**/*").empty?
    puts "No targets found in config/targets/*"
    puts "Migrate must be run within an existing COSMOS configuration"
    abort(MIGRATE_PARSER.to_s)
  end

  ###############################################################
  # Create the framework for the plugin
  # NOTE: generate does a chdir to be inside the plugin directory
  ###############################################################
  plugin = args.shift
  OpenC3::CliGenerator.generate(['plugin', plugin])

  if MIGRATE_OPTIONS.all
    # Grab all target directories to match the command line input
    args = Dir.glob("../config/targets/*").map { |path| File.basename(path) }
  else
    # Ensure targets passed in on command line actually exist
    args.each do |target|
      path = File.join('..', 'config', 'targets', target)
      unless File.exist?(path)
        puts "Target #{path} does not exist!"
        abort(MIGRATE_PARSER.to_s)
      end
    end
  end

  # Overwrite plugin.txt with specified targets
  plugin = File.open('plugin.txt', 'w')
  FileUtils.mkdir 'targets'
  args.each do |target|
    puts "Migrating target #{target}"
    FileUtils.cp_r "../config/targets/#{target}", 'targets'
    plugin.puts "TARGET #{target} #{target}"
  end
  plugin.puts ""

  files = Dir.glob('../lib/*')
  files.concat(Dir.glob('../procedures/*'))
  unless files.empty?
    puts "Migrating /lib & /procedures to PROCEDURES target"
    FileUtils.cp_r '../lib', "targets/PROCEDURES"
    FileUtils.cp_r '../procedures', "targets/PROCEDURES"
  end

  # Migrate cmd_tlm_server.txt info to plugin.txt
  Dir.glob('targets/**/cmd_tlm_server*.txt') do |cmd_tlm_server_file|
    File.open(cmd_tlm_server_file) do |file|
      file.each do |line|
        next if line =~ /^\s*#/ # Ignore comments
        next if line.strip.empty? # Ignore empty lines

        # Convert TARGET to MAP_TARGET
        line.gsub!(/TARGET (\S+)/, 'MAP_TARGET \1')
        plugin.puts line
      end
    end
    plugin.puts ''
  end

  # Migrate target.txt
  Dir.glob('targets/**/target.txt') do |filename|
    file = File.read(filename)
    file.gsub!('LOG_RAW', 'LOG_STREAM')
    file.gsub!('AUTO_SCREEN_SUBSTITUTE', '')
    File.write(filename, file)
  end

  # Migrate some of the screens api
  Dir.glob('targets/**/screens/*') do |file|
    screen = File.read(file)
    screen.gsub!('cmd(', 'api.cmd(')
    screen.gsub!('cmd_no_checks(', 'api.cmd_no_checks(')
    screen.gsub!('cmd_no_range_check(', 'api.cmd_no_range_check(')
    screen.gsub!('cmd_no_hazardous_check(', 'api.cmd_no_hazardous_check(')
    screen.gsub!('get_named_widget(', 'screen.getNamedWidget(')
    lines = screen.split("\n")
    lines.map! do |line|
      if line.include?('Qt.')
        "# FIXME (no Qt): #{line.sub("<%", "< %").sub("%>", "% >")}"
      elsif line.include?('Cosmos::')
        "# FIXME (no Cosmos::): #{line.sub("<%", "< %").sub("%>", "% >")}"
      else
        line
      end
    end
    File.write(file, lines.join("\n"))
  end

  plugin.close
  puts "Plugin complete: #{File.expand_path('.')}" # Remember we're inside the plugin dir
end

def xtce_converter(args)
  options = {}
  option_parser = OptionParser.new do |opts|
    opts.banner = "Usage: xtce_converter [options] --import input_xtce_filename --output output_dir\n"+
      "       xtce_converter [options] --plugin /PATH/FILENAME.gem --output output_dir --variables variables.txt"
    opts.separator("")
    opts.on("-h", "--help", "Show this message") do
      puts opts
      exit
    end
    opts.on("-i VALUE", "--import VALUE", "Import the specified .xtce file") do |arg|
      options[:import] = arg
    end
    opts.on("-o", "--output DIRECTORY", "Create files in the directory") do |arg|
      options[:output] = arg
    end
    opts.on("-p", "--plugin PLUGIN", "Export .xtce file(s) from the plugin") do |arg|
      options[:plugin] = arg
    end
    opts.on("-v", "--variables", "Optional variables file to pass to the plugin") do |arg|
      options[:variables] = arg
    end
  end

  begin
    option_parser.parse!(args)
  rescue
    abort(option_parser.to_s)
  end

  if options[:import] && options[:plugin]
    puts "xtce_converter options --import and --plugin are mutually exclusive"
    abort(option_parser.to_s)
  end

  ENV['OPENC3_NO_STORE'] = '1' # it can be anything
  OpenC3::Logger.stdout = false
  OpenC3::Logger.level = OpenC3::Logger::DEBUG

  if options[:import] && options[:output]
    packet_config = OpenC3::PacketConfig.new
    puts "Processing #{options[:import]}..."
    packet_config.process_file(options[:import], nil)
    puts "Writing COSMOS config files to #{options[:output]}/"
    packet_config.to_config(options[:output])
    exit(0)
  elsif options[:plugin] && options[:output]
    begin
      variables = nil
      variables = JSON.parse(File.read(options[:variables]), :allow_nan => true, :create_additions => true) if options[:variables]
      puts "Installing #{File.basename(options[:plugin])}"
      plugin_hash = OpenC3::PluginModel.install_phase1(options[:plugin], existing_variables: variables, scope: 'DEFAULT', validate_only: true)
      plugin_hash['variables']['xtce_output'] = options[:output]
      OpenC3::PluginModel.install_phase2(plugin_hash, scope: 'DEFAULT', validate_only: true,
        gem_file_path: options[:plugin])
      result = 0 # bash and Windows consider 0 success
    rescue => e
      puts "Error: #{e.message}"
      puts e.backtrace
      result = ERROR_CODE
    ensure
      exit(result)
    end
  else
    abort(option_parser.to_s)
  end
end

# A helper method to make the zip writing recursion work
def write_zip_entries(base_dir, entries, zip_path, io)
  io.add(zip_path, base_dir) # Add the directory whether it has entries or not
  entries.each do |e|
    zip_file_path = File.join(zip_path, e)
    disk_file_path = File.join(base_dir, e)
    if File.directory? disk_file_path
      recursively_deflate_directory(disk_file_path, io, zip_file_path)
    else
      put_into_archive(disk_file_path, io, zip_file_path)
    end
  end
end

def recursively_deflate_directory(disk_file_path, io, zip_file_path)
  io.add(zip_file_path, disk_file_path)
  write_zip_entries(disk_file_path, entries, zip_file_path, io)
end

def put_into_archive(disk_file_path, io, zip_file_path)
  io.get_output_stream(zip_file_path) do |f|
    data = nil
    File.open(disk_file_path, 'rb') { |file| data = file.read }
    f.write(data)
  end
end

def validate_plugin(plugin_file_path, scope:, variables_file: nil)
  ENV['OPENC3_NO_STORE'] = '1' # it can be anything
  OpenC3::Logger.stdout = false
  OpenC3::Logger.level = OpenC3::Logger::DEBUG
  scope ||= 'DEFAULT'
  variables = nil
  variables = JSON.parse(File.read(variables_file), :allow_nan => true, :create_additions => true) if variables_file
  puts "Installing #{File.basename(plugin_file_path)}"
  plugin_hash = OpenC3::PluginModel.install_phase1(plugin_file_path, existing_variables: variables, scope: scope, validate_only: true)
  OpenC3::PluginModel.install_phase2(plugin_hash, scope: scope, validate_only: true,
    gem_file_path: plugin_file_path)
  puts "Successfully validated #{File.basename(plugin_file_path)}"
  result = 0 # bash and Windows consider 0 success
rescue => e
  puts e.message
  result = ERROR_CODE
ensure
  exit(result)
end

def update_plugin(plugin_file_path, plugin_name, variables: nil, plugin_txt_lines: nil, scope:, existing_plugin_name:, force: false)
  new_gem = File.basename(plugin_file_path)
  old_gem = existing_plugin_name.split("__")[0]
  puts "Updating existing plugin: #{existing_plugin_name} with #{File.basename(plugin_file_path)}"
  plugin_model = OpenC3::PluginModel.get_model(name: existing_plugin_name, scope: scope)
  begin
    # Only update if something has changed
    if force or (new_gem != old_gem) or (variables and variables != plugin_model.variables) or (plugin_txt_lines and plugin_txt_lines != plugin_model.plugin_txt_lines)
      puts "Gem version change detected - New: #{new_gem}, Old: #{old_gem}" if new_gem != old_gem
      if variables and variables != plugin_model.variables
        pp_variables = ""
        PP.pp(variables, pp_variables)
        pp_plugin_model_variables = ""
        PP.pp(plugin_model.variables, pp_plugin_model_variables)
        puts "Variables change detected\nNew:\n#{pp_variables}\nOld:\n#{pp_plugin_model_variables}"
      end
      puts "plugin.txt change detected\nNew:\n#{plugin_txt_lines.join("\n")}\n\nOld:\n#{plugin_model.plugin_txt_lines.join("\n")}\n" if plugin_txt_lines and plugin_txt_lines != plugin_model.plugin_txt_lines
      variables = plugin_model.variables unless variables
      plugin_model.destroy

      plugin_hash = OpenC3::PluginModel.install_phase1(plugin_file_path, existing_variables: variables, existing_plugin_txt_lines: plugin_txt_lines, process_existing: true, scope: scope)
      puts "Updating plugin: #{plugin_file_path}\n#{plugin_hash}"
      plugin_hash = OpenC3::PluginModel.install_phase2(plugin_hash, scope: scope)
      OpenC3::LocalMode.update_local_plugin(plugin_file_path, plugin_hash, old_plugin_name: plugin_name, scope: scope)
    else
      puts "No changes detected - Exiting without change"
    end
  rescue => e
    puts e.formatted
    if plugin_model.destroyed?
      plugin_model.restore
      # Local mode files should still be good because restore will now reuse the old name
    end
    raise e
  end
end

def wait_process_complete(process_name)
  STDOUT.flush
  state = 'Running'
  status = nil
  while state == 'Running'
    status = plugin_status(process_name)
    state = status['state']
    sleep(5)
    print '.'
    STDOUT.flush
  end
  puts "\nFinished: #{state}"
  puts "Output:\n"
  puts status['output']
  if state == 'Complete'
    puts "Success!"
    exit 0
  else
    puts "Failed!"
    exit 1
  end
end

# Outputs list of installed plugins
def list_plugins(scope:)
  scope ||= 'DEFAULT'
  check_environment()
  names = []
  if $openc3_in_cluster
    names = OpenC3::PluginModel.names(scope: scope)
  else
    require 'openc3/script'
    names = plugin_list(scope: scope)
  end
  names.each do |name|
    puts name
  end
end

# Loads a plugin into the OpenC3 system
# This code is used from the command line and is the same code that gets called if you
# edit/upgrade or install a new plugin from the Admin interface
#
# Usage: cli load gemfile_path [scope] [plugin_hash_file_path] [force]
#
# With just gemfile_path and/or scope: Will do nothing if any plugin
# with the same gem file already exists
#
# Otherwise will do what the plugin_hash_file says to do
# Plugin hash file must have the exact name of an existing plugin for upgrades and edits
# Otherwise, it will be assumed that the plugin is intentionally being installed for a second
# time
#
# Pass true as the last argument to force install even if a plugin with
# the same version number exists
#
def load_plugin(plugin_file_path, scope:, plugin_hash_file: nil, force: false)
  scope ||= 'DEFAULT'
  check_environment()
  if $openc3_in_cluster
    # In Cluster

    # Only create the scope if it doesn't already exist
    unless OpenC3::ScopeModel.names.include?(scope)
      begin
        puts "Creating scope: #{scope}"
        scope_model = OpenC3::ScopeModel.new(name: scope)
        scope_model.create
        scope_model.deploy(".", {})
      rescue => e
        abort("Error creating scope: #{scope}: #{e.formatted}")
      end
    end

    begin
      if plugin_hash_file
        # Admin Create / Edit / or Upgrade Plugin
        OpenC3::PluginModel.install_phase1(plugin_file_path, scope: scope)
        plugin_hash = JSON.parse(File.read(plugin_hash_file), :allow_nan => true, :create_additions => true)
      else
        # Init or Command Line openc3cli load with no plugin_hash_file
        file_full_name = File.basename(plugin_file_path, ".gem")
        file_gem_name = file_full_name.split('-')[0..-2].join('-')
        found = false
        plugin_names = OpenC3::PluginModel.names(scope: scope)
        plugin_names.each do |plugin_name|
          gem_name = plugin_name.split("__")[0]
          full_name = File.basename(gem_name, ".gem")
          gem_name = full_name.split('-')[0..-2].join('-')
          if file_gem_name == gem_name
            found = true
            # Upgrade if version changed else do nothing
            if file_full_name != full_name
              update_plugin(plugin_file_path, plugin_name, scope: scope, existing_plugin_name: plugin_name, force: force)
            else
              puts "No version change detected for: #{plugin_name}"
            end
          end
        end
        return if found

        plugin_hash = OpenC3::PluginModel.install_phase1(plugin_file_path, scope: scope)
      end

      # Determine if plugin named in plugin_hash exists
      existing_plugin_hash = OpenC3::PluginModel.get(name: plugin_hash['name'], scope: scope)

      # Existing plugin hash will be present if plugin is being edited or upgraded
      # However, a missing existing could also be that a plugin was updated in local mode directly from across installations
      # changing the plugin name without really meaning to create a new instance of the plugin
      # ie.
      # User on machine 1 checks in a changed plugin_instance.json with a different name - There is still only one plugin desired and committed
      # User on machine 2 starts up with the new configuration, OpenC3::PluginModel.get will return nil because the exact name is different
      # In this case, the plugin should be updated without installing a second instance. analyze_local_mode figures this out.
      unless existing_plugin_hash
        existing_plugin_hash = OpenC3::LocalMode.analyze_local_mode(plugin_name: plugin_hash['name'], scope: scope)
      end

      if existing_plugin_hash
        # Upgrade or Edit
        update_plugin(plugin_file_path, plugin_hash['name'], variables: plugin_hash['variables'], scope: scope,
          plugin_txt_lines: plugin_hash['plugin_txt_lines'], existing_plugin_name: existing_plugin_hash['name'], force: force)
      else
        # New Install
        puts "Loading new plugin: #{plugin_file_path}\n#{plugin_hash}"
        plugin_hash = OpenC3::PluginModel.install_phase2(plugin_hash, scope: scope)
        OpenC3::LocalMode.update_local_plugin(plugin_file_path, plugin_hash, scope: scope)
      end
    rescue => e
      abort("Error installing plugin: #{scope}: #{plugin_file_path}\n#{e.formatted}")
    end
  else
    # Outside Cluster
    require 'openc3/script'
    if plugin_hash_file
      plugin_hash = JSON.parse(File.read(plugin_hash_file), :allow_nan => true, :create_additions => true)
    else
      plugin_hash = plugin_install_phase1(plugin_file_path, scope: scope)
    end

    process_name = plugin_install_phase2(plugin_hash, scope: scope)

    print "Installing..."
    wait_process_complete(process_name)
  end
end

def unload_plugin(plugin_name, scope:)
  scope ||= 'DEFAULT'
  check_environment()
  if $openc3_in_cluster
    begin
      plugin_model = OpenC3::PluginModel.get_model(name: plugin_name, scope: scope)
      plugin_model.destroy
      OpenC3::LocalMode.remove_local_plugin(plugin_name, scope: scope)
      OpenC3::Logger.info("PluginModel destroyed: #{plugin_name}", scope: scope)
    rescue => e
      abort("Error uninstalling plugin: #{scope}: #{plugin_name}: #{e.formatted}")
    end
  else
    # Outside Cluster
    require 'openc3/script'
    process_name = plugin_uninstall(plugin_name, scope: scope)
    print "Uninstalling..."
    wait_process_complete(process_name)
  end
end

def cli_pkg_install(filename, scope:)
  scope ||= 'DEFAULT'
  check_environment()
  if $openc3_in_cluster
    if File.extname(filename) == '.gem'
      OpenC3::GemModel.install(filename, scope: scope)
    else
      OpenC3::PythonPackageModel.install(filename, scope: scope)
    end
  else
    # Outside Cluster
    require 'openc3/script'
    process_name = package_install(filename, scope: scope)
    print "Installing..."
    wait_process_complete(process_name)
  end
end

def cli_pkg_uninstall(filename, scope:)
  scope ||= 'DEFAULT'
  check_environment()
  if $openc3_in_cluster
    if File.extname(filename) == '.rb'
      OpenC3::GemModel.destroy(filename)
    else
      OpenC3::PythonPackageModel.destroy(filename, scope: scope)
    end
  else
    # Outside Cluster
    require 'openc3/script'
    process_name = package_uninstall(filename, scope: scope)
    if File.extname(filename) == '.rb'
      puts "Uninstalled"
    else
      print "Uninstalling..."
      wait_process_complete(process_name)
    end
  end
end

def get_redis_keys
  redis = Redis.new(url: $redis_url, username: ENV['OPENC3_REDIS_USERNAME'], password: ENV['OPENC3_REDIS_PASSWORD'])
  puts "\n--- COSMOS Redis database keys ---"
  cursor = 0
  keys = []
  loop do
    cursor, result = redis.scan(cursor)
    keys.concat(result)
    cursor = cursor.to_i # cursor is returned as a string
    break if cursor == 0
  end
  keys.uniq!
  keys.sort!
  keys.select { |item| !item[/^tlm__/] }.each do |key|
    puts "#{key}\n  #{redis.hkeys(key)}"
  rescue Redis::CommandError
    begin
      # CommandError is raised if you try to hkeys on a stream
      puts "Stream: #{key}\n  #{redis.xinfo(:stream, key)}"
    rescue
      puts "Unknown key '#{key}'"
    end
  end
  puts "Packets Defs: #{keys.select { |item| item[/^tlm__/] }}"
end

def run_migrations(folder)
  # Determine if this is a brand new installation (no tools installed)
  # We don't run migrations on new installations
  tools = OpenC3::ToolModel.names(scope: 'DEFAULT')
  if tools.length <= 0
    puts "Brand new installation detected"
    brand_new = true
  else
    puts "Checking for needed migrations..."
    brand_new = false
  end

  # Run each newly discovered migration unless brand_new
  folder = "/openc3/lib/openc3/migrations" unless folder
  migrations = OpenC3::MigrationModel.all
  entries = Dir.entries(folder).sort # run in alphabetical order
  entries.each do |entry|
    name = File.basename(entry)
    extension = File.extname(name)
    if extension == '.rb' and not migrations[name]
      unless brand_new
        puts "Running Migration: #{name}"
        require File.join(folder, entry)
      end
      OpenC3::MigrationModel.new(name: name).create
    end
  end
  if brand_new
    puts "All migrations skipped"
  else
    puts "Migrations complete"
  end
end

def run_bridge(filename, params)
  variables = {}
  params.each do |param|
    name, value = param.split('=')
    if name and value
      variables[name] = value
    else
      raise "Invalid variable passed to bridgegem (syntax name=value): #{param}"
    end
  end
  OpenC3::Bridge.new(filename, variables)
  begin
    while true
      sleep(1)
    end
  rescue Interrupt
    exit(0)
  end
end

def cli_script_monitor(script_id)
  ret_code = ERROR_CODE
  require 'openc3/script'
  OpenC3::RunningScriptWebSocketApi.new(id: script_id) do |api|
    while (resp = api.read) do
      # see ScriptRunner.vue for types and states
      case resp['type']
      when 'error', 'fatal'
        $script_interrupt_text = ''
        puts 'script failed'
        break
      when 'file'
        puts "Filename #{resp['filename']} scope #{resp['scope']}"
      when 'line'
        fn = resp['filename'].nil? ? '<no file>' : resp['filename']
        puts "At [#{fn}:#{resp['line_no']}] state [#{resp['state']}]"
        if resp['state'] == 'error'
          $script_interrupt_text = ''
          puts 'script failed'
          break
        end
      when 'output'
        puts resp['line']
      when 'paused'
        if resp['state'] == 'fatal'
          $script_interrupt_text = ''
          puts 'script failed'
          break
        end
      when 'complete'
        $script_interrupt_text = ''
        puts 'script complete'
        ret_code = 0
        break
      # These conditions are all handled by the else
      # when 'running', 'breakpoint', 'waiting', 'time'
      else
        puts resp.pretty_inspect
      end
    end
  end
  return ret_code
end

def cli_script_list(args=[])
  path = ''
  if (args[0] && args[0][0] == '/')
    path = (args.shift)[1..-1]+'/'
  end
  scope = args[1]
  scope ||= 'DEFAULT'
  require 'openc3/script'
  script_list(scope: scope).each do |script_name|
    puts(script_name) if script_name.start_with?(path)
  end
  return 0
end

def cli_script_run(disconnect=false, environment={}, args=[])
  #  we are limiting the wait for status, not the script run time
  #  we make 0 mean 'forever'
  ret_code = ERROR_CODE
  wait_limit = 0
  if (i = args.index('--wait'))
    begin
      args.delete('--wait')
      # pull out the flag
      seconds = args[i]
      wait_limit = Integer(seconds, 10) # only decimal, ignore leading 0
      args.delete_at(i)
      # and its value
    rescue ArgumentError
      abort("  --wait requires a number of seconds to wait, not [#{seconds}]")
    end
  end
  abort("No script file provided") if args[0].nil?
  scope = args[1]
  scope ||= 'DEFAULT'
  require 'openc3/script'
  id = script_run(args[0], disconnect: disconnect, environment: environment, scope: scope) # could raise
  $script_interrupt_text = "  Script #{args[0]} still running remotely.\n" # for Ctrl-C
  if (wait_limit < 1) then
    ret_code = cli_script_monitor(id)
  else
    Timeout::timeout(wait_limit, nil, "--wait #{wait_limit} exceeded") do
      ret_code = cli_script_monitor(id)
    rescue Timeout::ExitException, Timeout::Error => e
      # Timeout exceptions are also raised by the Websocket API, so we check
      if e.message =~ /^--wait /
        puts e.message + ", detaching from running script #{args[0]}"
      else
        raise
      end
    end
  end
  return ret_code
end

def cli_script_spawn(disconnect=false, environment={}, args=[])
  ret_code = ERROR_CODE
  if (args.index('--wait'))
    abort("Did you mean \"script run --wait <seconds> [...]\"?")
  end
  abort("No script file provided") if args[0].nil?
  # heaven help you if you left out the script name
  scope = args[1]
  scope ||= 'DEFAULT'
  require 'openc3/script'
  if (id = script_run(args[0], disconnect: disconnect, environment: environment, scope: scope))
    puts id
    ret_code = 0
  end
  return ret_code
end

## cli_script(args) turns an ARGV of [spawn|run] <--wait 123...> <--disconnect> SCRIPT <scope> <ENV_A=1 ENV_B=2 ...>
#                   into function calls and tidied parameters to remote-control a script via RunningScriptWebSocketApi
def cli_script(args=[])
  ret_code = ERROR_CODE
  check_environment()
  # Double check for the OPENC3_API_PASSWORD because it is absolutely required
  # We always pass it via openc3.sh even if it's not defined so check for empty
  if ENV['OPENC3_API_PASSWORD'].nil? or ENV['OPENC3_API_PASSWORD'].empty?
    abort "OPENC3_API_PASSWORD environment variable is required for cli script"
  end
  command = args.shift
  # pull out the disconnect flag
  discon = args.delete('--disconnect')
  discon = (discon.is_a? String) ? true : false
  environ = {}
  args.each do |arg|
    name, value = arg.split('=')
    if name and value
    # add env[k]=v ; pull out "k=v"
      environ[name] = value
      args.delete(arg)
    end
  end
  case command
  # for list
  # args[] should now be ["/<path>", "<scope>"]
  #                   or ["/<path>"]
  #                   or ["<scope>"]
  #                   or []
  when 'list'
    ret_code = cli_script_list(args)
  # for spawn or run
  # args[] should now be ["--wait 100", "script_name", "<scope>"]
  #                   or ["--wait 100", "script_name"]
  #                   or ["script_name", "<scope>"]
  #                   or ["script_name"]
  when 'spawn'
    ret_code = cli_script_spawn(discon, environ, args)
  when 'run'
    ret_code = cli_script_run(discon, environ, args)
  else
    abort 'openc3cli internal error: parsing arguments'
  end
  exit(ret_code)
end

if not ARGV[0].nil? # argument(s) given

  # Handle each task
  case ARGV[0].downcase

  when 'irb'
    ARGV.clear
    IRB.start

  when 'script'
    case ARGV[1]
    when 'list', 'run', 'spawn'
      cli_script(ARGV[1..-1])
    else
      # invalid actions, misplaced and malformed leading options and 'help' come here
      abort("cli script <action> must be one of #{CLI_SCRIPT_ACTIONS}, not [#{ARGV[1]}]")
    end

  when 'rake'
    if File.exist?('Rakefile')
      puts `rake #{ARGV[1..-1].join(' ')}`
    else
      puts "No Rakefile found! Only run 'rake' in the presence of a Rakefile which is typically at the root of your COSMOS project."
    end

  when 'validate'
    validate_plugin(ARGV[1], scope: ARGV[2], variables_file: ARGV[3])

  when 'load'
    # force is a boolean so if they pass 'force' it is true
    # See plugins_controller.rb install for usage
    load_plugin(ARGV[1], scope: ARGV[2], plugin_hash_file: ARGV[3], force: ARGV[4] == 'force')

  when 'list'
    list_plugins(scope: ARGV[1])

  when 'unload'
    unload_plugin(ARGV[1], scope: ARGV[2])

  when 'pkginstall', 'geminstall'
    cli_pkg_install(ARGV[1], scope: ARGV[2])

  when 'pkguninstall', 'gemuninstall'
    cli_pkg_uninstall(ARGV[1], scope: ARGV[2])

  when 'generate'
    OpenC3::CliGenerator.generate(ARGV[1..-1])

  when 'migrate'
    migrate(ARGV[1..-1])

  when 'rubysloc'
    puts `ruby /openc3/bin/rubysloc #{ARGV[1..-1].join(' ')}`

  when 'cstol_converter'
    puts `ruby /openc3/bin/cstol_converter #{ARGV[1..-1].join(' ')}`

  when 'xtce_converter'
    xtce_converter(ARGV[1..-1])

  when 'bridge'
    ENV['OPENC3_NO_STORE'] = '1'
    filename = ARGV[1]
    filename = 'bridge.txt' unless filename
    params = ARGV[2..-1]
    params = [] unless params
    run_bridge(filename, params)

  when 'bridgegem'
    ENV['OPENC3_NO_STORE'] = '1'
    filename = nil
    gem_name = ARGV[1]
    Gem::Specification.each do |s|
      # This appears to return the newest version of each gem first,
      # so its ok that we stop on the first time it is found
      if s.name == gem_name
        if Array === Gem.path
          Gem.path.each do |gem_path|
            filename = File.join(gem_path, 'gems', "#{s.name}-#{s.version}", 'bridge.txt')
            puts "Trying #{filename}"
            break if File.exist?(filename)
          end
        else
          filename = File.join(Gem.path, 'gems', "#{s.name}-#{s.version}", 'bridge.txt')
        end
        raise "#{filename} not found" unless File.exist?(filename)
      end
    end
    raise "gem #{gem_name} not found" unless filename
    params = ARGV[2..-1]
    params = [] unless params
    run_bridge(filename, params)

  when 'bridgesetup'
    ENV['OPENC3_NO_STORE'] = '1'
    filename = ARGV[1]
    filename = 'bridge.txt' unless filename
    unless File.exist?(filename)
      OpenC3::BridgeConfig.generate_default(filename)
    end

  when 'help'
    print_usage()

  when 'redis'
    case (ARGV[1])
    when 'keys'
      get_redis_keys()
    when 'hget'
      redis = Redis.new(url: $redis_url, username: ENV['OPENC3_REDIS_USERNAME'], password: ENV['OPENC3_REDIS_PASSWORD'])
      puts JSON.parse(redis.hget(ARGV[2], ARGV[3]), :allow_nan => true, :create_additions => true)
    else
      puts "Unknown redis task: #{ARGV[1]}\n"
      puts "Valid redis tasks: keys, hget"
    end

  when 'removebase'
    # Used to remove tool base to better support enterprise upgrade
    scopes = OpenC3::ScopeModel.all
    scopes.each do |scope_name, _scope|
      plugins = OpenC3::PluginModel.all(scope: scope_name)
      plugins.each do |plugin_name, plugin|
        if plugin["name"] =~ /tool-base/ and plugin["name"] !~ /enterprise/
          unload_plugin(plugin_name, scope: scope_name)
        end
        if plugin["name"] =~ /tool-admin/ and plugin["name"] !~ /enterprise/
          unload_plugin(plugin_name, scope: scope_name)
        end
      end
    end

  when 'removeenterprise'
    # Used to remove enterprise plugins to better support downgrade
    scopes = OpenC3::ScopeModel.all
    scopes.each do |scope_name, _scope|
      plugins = OpenC3::PluginModel.all(scope: scope_name)
      plugins.each do |plugin_name, plugin|
        if plugin["name"] =~ /enterprise/
          unload_plugin(plugin_name, scope: scope_name)
        end
      end
    end

  when 'destroyscope'
    scope = OpenC3::ScopeModel.get_model(name: ARGV[1])
    scope.destroy

  when 'localinit'
    OpenC3::LocalMode.local_init()

  when 'initbuckets'
    client = OpenC3::Bucket.getClient()
    ENV.map do |key, value|
      if key.match(/^OPENC3_(.+)_BUCKET$/) && !value.empty?
        client.create(value)
      end
    end
    client.ensure_public(ENV['OPENC3_TOOLS_BUCKET'])

  when 'runmigrations'
    run_migrations(ARGV[1])

  else # Unknown task
    print_usage()
    abort("Unknown task: #{ARGV[0]}")
  end

else # No arguments given
  print_usage()
end