#!/usr/bin/env ruby # frozen_string_literal: true require 'json' require 'open3' require 'did_you_mean' $stdout.sync = true def check_requirements check_nctl_version check_nctl_json_support end def check_nctl_version stdout, _stderr, status = Open3.capture3('nctl --version') unless status.success? puts 'Error: nctl not found or not working properly' puts 'Please install nctl first: https://github.com/ninech/nctl' exit 1 end version_output = stdout.strip version_match = version_output.match(/(\d+\.\d+\.\d+)/) unless version_match puts "Warning: Could not parse nctl version from: #{version_output}" return end version = version_match[1] required_version = '1.10.0' return unless Gem::Version.new(version) < Gem::Version.new(required_version) puts "Error: nctl version #{version} does not meet requirements (need at least #{required_version})" puts 'Please update nctl via homebrew:' puts ' brew upgrade nctl' exit 1 end def check_nctl_json_support stdout, _stderr, _status = Open3.capture3('nctl get apps --help') return if stdout.include?('-o, --output') || stdout.include?('--output') || stdout.include?('json') puts 'Warning: nctl may not support JSON output format' puts "This could cause issues with the 'list' command" nil end module SuggestionHelper def self.suggest(input, dictionary) return [] if dictionary.empty? spell_checker = DidYouMean::SpellChecker.new(dictionary: dictionary) spell_checker.correct(input) end def self.format_suggestions(input, dictionary, context_message = nil) suggestions = suggest(input, dictionary) return nil if suggestions.empty? message = context_message || 'Did you mean?' formatted = "\n#{message}" suggestions.each { |suggestion| formatted += "\n #{suggestion}" } formatted end end def extract_global_flags(argv, default_org_prefix: 'renuo') args = argv.dup org_prefix = default_org_prefix if (idx = args.index('--org-prefix')) && args[idx + 1] org_prefix = args[idx + 1] args.slice!(idx, 2) end dry_run = args.delete('--dry-run') ? true : false [args, org_prefix, dry_run] end def usage(exit_code = 1) message = <<~USAGE deploio (deplo.io app CLI) Usage: deploio [--org-prefix ] [--dry-run] [--help] deploio [args] Commands: new Create project and app (git url inferred) list List apps as - logs [-- ...args] Stream logs for app exec [-- ...args] Exec into app (args passed to nctl) stats Show app stats config Show app config (yaml) config:edit Edit app config hosts Print app hosts Global flags (must appear before the command): --org-prefix Default: renuo --dry-run Print commands without executing --help Show nctl help for a command. If a command is provided (e.g. "deploio logs --help"), shows help for the underlying nctl command and bypasses other flags. If no command is provided, shows top-level nctl help. Examples: deploio new fizzbuzz-main depl logs fizzbuzz-main deploio exec fizzbuzz-main -- -c 'echo hi' USAGE puts message exit exit_code end class AppRef attr_reader :project_short, :environment, :org_prefix def initialize(project_env, org_prefix, available_project_envs = []) parts = project_env.split('-') if parts.size < 2 error_msg = "Invalid : #{project_env}\n" \ "Expected format: - (e.g., 'myapp-staging', 'api-prod')" suggestions = SuggestionHelper.format_suggestions(project_env, available_project_envs) error_msg += suggestions if suggestions raise error_msg end @environment = parts.pop @project_short = parts.join('-') @org_prefix = org_prefix end def project_full [org_prefix, project_short].join('-') end def app_name environment end def git_url "git@github.com:#{org_prefix}/#{project_short}.git" end end class Runner def initialize(dry_run: false) @dry_run = dry_run end def run(cmd) puts "> #{cmd}" return true if @dry_run system(cmd) end def capture(cmd) puts "> #{cmd}" return ['', '', 0] if @dry_run stdout, stderr, status = Open3.capture3(cmd) [stdout, stderr, status.exitstatus] end end def nctl_app_cmd(action, ref) "nctl #{action} app #{ref.app_name} --project #{ref.project_full}" end def fetch_available_project_envs(org_prefix, runner) return @cached_project_envs if defined?(@cached_project_envs) stdout, _stderr, code = runner.capture(%(nctl get apps -A -o json | jq -r '.[] | (.metadata.namespace + "-" + .metadata.name | gsub("#{org_prefix}-"; ""))')) if code.zero? && !stdout.strip.empty? @cached_project_envs = stdout.strip.split("\n").reject(&:empty?) else # For testing purposes, provide some mock data in dry-run mode @cached_project_envs = if runner.instance_variable_get(:@dry_run) %w[myapp-staging myapp-production api-staging fizzbuzz-main shapehub-develop] else [] end end @cached_project_envs rescue StandardError @cached_project_envs = [] end def validate_project_env_with_suggestions(project_env, org_prefix, runner, command_name, extra_args = []) available_project_envs = fetch_available_project_envs(org_prefix, runner) return true if available_project_envs.include?(project_env) return true if available_project_envs.empty? puts "Project-environment '#{project_env}' not found." suggestions = SuggestionHelper.suggest(project_env, available_project_envs) unless suggestions.empty? puts "\nDid you mean?" suggestions.each do |suggestion| full_command = "#{command_name} #{suggestion}" full_command += " #{extra_args.join(' ')}" unless extra_args.empty? puts " #{full_command}" end end puts "\nRun 'deploio list' to see all available project-environments." false end COMMANDS = { 'new' => { help: 'nctl create app --help', run: lambda { |args, org_prefix, runner| raise 'missing ' if args.empty? project_env = args.shift available_project_envs = fetch_available_project_envs(org_prefix, runner) ref = AppRef.new(project_env, org_prefix, available_project_envs) stdout, _stderr, _code = runner.capture('nctl get projects -o json') existing = [] begin json = JSON.parse(stdout) if json.is_a?(Array) existing = json.map { |p| p.dig('metadata', 'name') }.compact elsif json.is_a?(Hash) && json['items'].is_a?(Array) existing = json['items'].map { |p| p.dig('metadata', 'name') }.compact end rescue JSON::ParserError existing = [] end unless existing.include?(ref.project_full) ok = runner.run("nctl create project #{ref.project_full}") exit 1 unless ok end ok = runner.run("nctl create app #{ref.app_name} --project #{ref.project_full} --git-url #{ref.git_url} --git-revision \"#{ref.environment}\" --size=mini") exit 1 unless ok } }, 'list' => { help: 'nctl get apps --help', run: lambda { |_args, org_prefix, runner| runner.run(%(nctl get apps -A -o json | jq -r '.[] | (.metadata.namespace + "-" + .metadata.name | gsub("#{org_prefix}-"; ""))')) } }, 'logs' => { help: 'nctl logs app --help', run: lambda { |args, org_prefix, runner| raise 'missing ' if args.empty? project_env = args.shift exit 1 unless validate_project_env_with_suggestions(project_env, org_prefix, runner, 'logs', args) ref = AppRef.new(project_env, org_prefix) extra = args.empty? ? '' : " #{args.join(' ')}" runner.run(nctl_app_cmd('logs', ref) + extra) } }, 'exec' => { help: 'nctl exec app --help', run: lambda { |args, org_prefix, runner| raise 'missing ' if args.empty? project_env = args.shift exit 1 unless validate_project_env_with_suggestions(project_env, org_prefix, runner, 'exec', args) ref = AppRef.new(project_env, org_prefix) extra = args.empty? ? '' : " #{args.join(' ')}" runner.run(nctl_app_cmd('exec', ref) + extra) } }, 'stats' => { help: 'nctl get app --help', run: lambda { |args, org_prefix, runner| raise 'missing ' if args.empty? project_env = args.shift exit 1 unless validate_project_env_with_suggestions(project_env, org_prefix, runner, 'stats', args) ref = AppRef.new(project_env, org_prefix) runner.run("#{nctl_app_cmd('get', ref)} -o stats") } }, 'config' => { help: 'nctl get app --help', run: lambda { |args, org_prefix, runner| raise 'missing ' if args.empty? project_env = args.shift exit 1 unless validate_project_env_with_suggestions(project_env, org_prefix, runner, 'config', args) ref = AppRef.new(project_env, org_prefix) runner.run("#{nctl_app_cmd('get', ref)} -o yaml") } }, 'config:edit' => { help: 'nctl edit app --help', run: lambda { |args, org_prefix, runner| raise 'missing ' if args.empty? project_env = args.shift exit 1 unless validate_project_env_with_suggestions(project_env, org_prefix, runner, 'config:edit', args) ref = AppRef.new(project_env, org_prefix) runner.run(nctl_app_cmd('edit', ref)) } }, 'hosts' => { help: 'nctl get app --help', run: lambda { |args, org_prefix, runner| raise 'missing ' if args.empty? project_env = args.shift exit 1 unless validate_project_env_with_suggestions(project_env, org_prefix, runner, 'hosts', args) ref = AppRef.new(project_env, org_prefix) runner.run("#{nctl_app_cmd('get', ref)} -o json | jq -r '.status.atProvider.hosts | map(.name) | .[]'") } } }.freeze if ARGV.include?('--help') args_ng, _org, dry_for_help = extract_global_flags(ARGV) args_ng.delete('--help') dapp_cmd = args_ng[0] if dapp_cmd.nil? usage(0) elsif COMMANDS[dapp_cmd] help_cmd = COMMANDS[dapp_cmd][:help] if dry_for_help puts "> #{help_cmd}" exit 0 else exec(help_cmd) end else usage end end args, org_prefix, dry_run = extract_global_flags(ARGV) check_requirements unless dry_run usage if args.empty? cmd = args.shift runner = Runner.new(dry_run: dry_run) action = COMMANDS[cmd] unless action puts "Unknown command: #{cmd}" suggestions = SuggestionHelper.format_suggestions(cmd, COMMANDS.keys) puts suggestions if suggestions puts "\nRun 'deploio --help' to see available commands." exit 1 end action[:run].call(args, org_prefix, runner)