deploio-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deploio
4
+ module Commands
5
+ class Orgs < Thor
6
+ include SharedOptions
7
+
8
+ namespace "orgs"
9
+
10
+ class_option :json, type: :boolean, default: false, desc: "Output as JSON"
11
+
12
+ default_task :list
13
+
14
+ desc "list", "List all organizations"
15
+ def list
16
+ setup_options
17
+ raw_orgs = @nctl.get_orgs
18
+
19
+ if options[:json]
20
+ puts JSON.pretty_generate(raw_orgs)
21
+ return
22
+ end
23
+
24
+ if raw_orgs.empty?
25
+ Output.warning("No organizations found") unless merged_options[:dry_run]
26
+ return
27
+ end
28
+
29
+ rows = raw_orgs.map do |org|
30
+ current_marker = org["current"] ? "*" : ""
31
+ [
32
+ current_marker,
33
+ presence(org["name"])
34
+ ]
35
+ end
36
+
37
+ Output.table(rows, headers: ["", "ORGANIZATION"])
38
+ end
39
+
40
+ desc "set ORG_NAME", "Set the current organization"
41
+ def set(org_name)
42
+ setup_options
43
+ @nctl.set_org(org_name)
44
+ Output.success("Switched to organization #{org_name}")
45
+ end
46
+
47
+ private
48
+
49
+ def presence(value, default: "-")
50
+ (value.nil? || value.to_s.empty?) ? default : value
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module Deploio
6
+ # Generates zsh completion script from Thor command metadata.
7
+ # Can be reused with any Thor-based CLI by passing the CLI class.
8
+ #
9
+ # @example Basic usage
10
+ # CompletionGenerator.new(MyCLI).generate
11
+ #
12
+ # @example With custom option completers
13
+ # CompletionGenerator.new(MyCLI, option_completers: {
14
+ # 'env' => 'environment:(production staging development)'
15
+ # }).generate
16
+ #
17
+ class CompletionGenerator
18
+ TEMPLATE_PATH = File.expand_path("templates/completion.zsh.erb", __dir__)
19
+
20
+ # Commands that pass through to external programs (like exec)
21
+ # These get '*:command:_normal' as their final argument
22
+ PASSTHROUGH_COMMANDS = %w[exec run].freeze
23
+
24
+ attr_reader :cli_class, :option_completers, :positional_completers, :program_name
25
+
26
+ # @param cli_class [Class] Thor CLI class to generate completions for
27
+ # @param option_completers [Hash] Custom completers for specific options
28
+ # e.g., { 'app' => 'app:_myapp_apps_list' }
29
+ # @param positional_completers [Hash] Custom completers for positional args
30
+ # e.g., { 'orgs:set' => "'1:organization:_myapp_orgs_list'" }
31
+ # @param program_name [String] Override the program name (default: derived from CLI class)
32
+ def initialize(cli_class = nil, option_completers: {}, positional_completers: {}, program_name: nil)
33
+ @cli_class = cli_class || default_cli_class
34
+ @program_name = program_name || derive_program_name
35
+ @option_completers = default_option_completers.merge(option_completers)
36
+ @positional_completers = default_positional_completers.merge(positional_completers)
37
+ end
38
+
39
+ def generate
40
+ template = File.read(TEMPLATE_PATH)
41
+ ERB.new(template, trim_mode: "-").result(binding)
42
+ end
43
+
44
+ private
45
+
46
+ def default_cli_class
47
+ require_relative "cli"
48
+ CLI
49
+ end
50
+
51
+ def default_option_completers
52
+ {
53
+ "app" => "app:_#{program_name}_apps_list",
54
+ "size" => "size:(micro mini standard)"
55
+ }
56
+ end
57
+
58
+ def default_positional_completers
59
+ {
60
+ "orgs:set" => "'1:organization:_#{program_name}_orgs_list'"
61
+ }
62
+ end
63
+
64
+ def derive_program_name
65
+ # Convert "Deploio::CLI" -> "deploio", "MyApp::CLI" -> "myapp"
66
+ cli_class.name.split("::").first.downcase
67
+ end
68
+
69
+ def subcommands
70
+ cli_class.subcommand_classes.map do |name, klass|
71
+ commands = klass.commands.except("help").map do |cmd_name, cmd|
72
+ [cmd_name, cmd.description, cmd.options]
73
+ end
74
+ [name, commands, klass.class_options]
75
+ end
76
+ end
77
+
78
+ def main_commands
79
+ cli_class.commands.except("help").map do |name, cmd|
80
+ [name, cmd.description]
81
+ end
82
+ end
83
+
84
+ def direct_commands
85
+ subcommand_names = cli_class.subcommands
86
+ cli_class.commands.reject do |name, _|
87
+ name == "help" || subcommand_names.include?(name) || passthrough_command?(name)
88
+ end.map do |name, cmd|
89
+ [name, cmd.options]
90
+ end
91
+ end
92
+
93
+ def passthrough_commands
94
+ PASSTHROUGH_COMMANDS.filter_map do |name|
95
+ cmd = cli_class.commands[name]
96
+ [name, cmd.options] if cmd
97
+ end
98
+ end
99
+
100
+ def passthrough_command?(name)
101
+ PASSTHROUGH_COMMANDS.include?(name)
102
+ end
103
+
104
+ def cli_class_options
105
+ cli_class.class_options
106
+ end
107
+
108
+ def positional_arg(subcommand, cmd_name)
109
+ positional_completers["#{subcommand}:#{cmd_name}"]
110
+ end
111
+
112
+ def format_options(method_options, class_options, extra_arg = nil)
113
+ all_options = cli_class.class_options.merge(class_options).merge(method_options)
114
+ lines = all_options.map { |name, opt| format_option(name, opt) }.compact
115
+ lines << extra_arg if extra_arg
116
+
117
+ return " # No options" if lines.empty?
118
+
119
+ lines.map.with_index do |line, i|
120
+ continuation = (i < lines.size - 1) ? " \\" : ""
121
+ " #{line}#{continuation}"
122
+ end.join("\n")
123
+ end
124
+
125
+ def format_option(name, opt)
126
+ flag = name.to_s.tr("_", "-")
127
+ short = opt.aliases&.first
128
+ desc = escape(opt.description || "")
129
+ completer = option_completer(name, flag)
130
+
131
+ if short
132
+ if opt.type == :boolean
133
+ "'(#{short} --#{flag})'{#{short},--#{flag}}'[#{desc}]'"
134
+ else
135
+ "'(#{short} --#{flag})'{#{short},--#{flag}}'[#{desc}]:#{completer}'"
136
+ end
137
+ elsif opt.type == :boolean
138
+ "'--#{flag}[#{desc}]'"
139
+ else
140
+ "'--#{flag}[#{desc}]:#{completer}'"
141
+ end
142
+ end
143
+
144
+ def option_completer(name, flag)
145
+ option_completers[name.to_s] || "#{flag}:"
146
+ end
147
+
148
+ def escape(text)
149
+ text.to_s.gsub("'", "'\\''").gsub("[", '\\[').gsub("]", '\\]')
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module Deploio
7
+ class NctlClient
8
+ REQUIRED_VERSION = "1.10.0"
9
+
10
+ attr_reader :dry_run
11
+
12
+ def initialize(dry_run: false)
13
+ @dry_run = dry_run
14
+ end
15
+
16
+ def check_requirements
17
+ check_nctl_installed
18
+ check_nctl_version
19
+ end
20
+
21
+ def logs(app_ref, tail: false, lines: 100)
22
+ args = ["--lines=#{lines}"]
23
+ args << "-f" if tail
24
+ exec_passthrough("logs", "app", app_ref.app_name,
25
+ "--project", app_ref.project_name,
26
+ *args)
27
+ end
28
+
29
+ def exec_command(app_ref, command)
30
+ exec_passthrough("exec", "app", app_ref.app_name,
31
+ "--project", app_ref.project_name,
32
+ "--", *command)
33
+ end
34
+
35
+ def get_all_apps
36
+ @all_apps ||= begin
37
+ output = capture("get", "apps", "-A", "-o", "json")
38
+ return [] if output.nil? || output.empty?
39
+
40
+ data = JSON.parse(output)
41
+ data.is_a?(Array) ? data : (data["items"] || [])
42
+ rescue JSON::ParserError
43
+ []
44
+ end
45
+ end
46
+
47
+ def get_app(app_ref)
48
+ output = capture("get", "app", app_ref.app_name,
49
+ "--project", app_ref.project_name,
50
+ "-o", "json")
51
+ return {} if output.nil? || output.empty?
52
+
53
+ JSON.parse(output)
54
+ rescue JSON::ParserError
55
+ {}
56
+ end
57
+
58
+ def get_app_stats(app_ref)
59
+ capture("get", "app", app_ref.app_name,
60
+ "--project", app_ref.project_name,
61
+ "-o", "stats")
62
+ end
63
+
64
+ def edit_app(app_ref)
65
+ exec_passthrough("edit", "app", app_ref.app_name,
66
+ "--project", app_ref.project_name)
67
+ end
68
+
69
+ def create_app(project, app_name, git_url:, git_revision:, size: "mini")
70
+ run("create", "app", app_name,
71
+ "--project", project,
72
+ "--git-url", git_url,
73
+ "--git-revision", git_revision,
74
+ "--size", size)
75
+ end
76
+
77
+ def delete_app(app_ref)
78
+ run("delete", "app", app_ref.app_name,
79
+ "--project", app_ref.project_name)
80
+ end
81
+
82
+ def create_project(project_name)
83
+ run("create", "project", project_name)
84
+ end
85
+
86
+ def get_projects
87
+ output = capture("get", "projects", "-o", "json")
88
+ return [] if output.nil? || output.empty?
89
+
90
+ data = JSON.parse(output)
91
+ data.is_a?(Array) ? data : (data["items"] || [])
92
+ rescue JSON::ParserError
93
+ []
94
+ end
95
+
96
+ def get_orgs
97
+ output = capture("auth", "whoami")
98
+ return [] if output.nil? || output.empty?
99
+
100
+ parse_orgs_from_whoami(output)
101
+ end
102
+
103
+ def current_org
104
+ get_orgs.find { |o| o["current"] }&.fetch("name", nil)
105
+ end
106
+
107
+ def parse_orgs_from_whoami(output)
108
+ orgs = []
109
+ in_orgs_section = false
110
+
111
+ output.each_line do |line|
112
+ if line.include?("Available Organizations:")
113
+ in_orgs_section = true
114
+ next
115
+ end
116
+
117
+ next unless in_orgs_section
118
+ break if line.strip.empty? || line.start_with?("To switch")
119
+
120
+ # Lines are either "*\torg_name" (current) or "\torg_name"
121
+ current = line.start_with?("*")
122
+ org_name = line.sub(/^\*?\t/, "").strip
123
+ next if org_name.empty?
124
+
125
+ orgs << {"name" => org_name, "current" => current}
126
+ end
127
+
128
+ orgs
129
+ end
130
+
131
+ def set_org(org_name)
132
+ run("auth", "set-org", org_name)
133
+ end
134
+
135
+ def auth_login
136
+ exec_passthrough("auth", "login")
137
+ end
138
+
139
+ def auth_logout
140
+ run("auth", "logout")
141
+ end
142
+
143
+ def auth_whoami
144
+ exec_passthrough("auth", "whoami")
145
+ end
146
+
147
+ private
148
+
149
+ # Runs nctl command as subprocess with output to terminal. Used for
150
+ # commands that modify state (create, delete) where we show output but don't need to parse it.
151
+ def run(*args)
152
+ cmd = build_command(args)
153
+ Output.command(cmd.join(" "))
154
+ if dry_run
155
+ true
156
+ else
157
+ system(*cmd)
158
+ end
159
+ end
160
+
161
+ # Replaces current process with nctl command. Used for interactive commands
162
+ # (logs -f, exec, edit, auth) that need direct terminal access. Never returns.
163
+ def exec_passthrough(*args)
164
+ cmd = build_command(args)
165
+ Output.command(cmd.join(" "))
166
+ if dry_run
167
+ true
168
+ else
169
+ exec(*cmd)
170
+ end
171
+ end
172
+
173
+ # Runs nctl command and captures stdout. Used for commands that return data
174
+ # (get apps, get projects) that needs to be parsed. Raises on failure.
175
+ def capture(*args)
176
+ cmd = build_command(args)
177
+ if dry_run
178
+ Output.command(cmd.join(" "))
179
+ ""
180
+ else
181
+ stdout, stderr, status = Open3.capture3(*cmd)
182
+ unless status.success?
183
+ raise Deploio::NctlError, "nctl command failed: #{stderr}"
184
+ end
185
+
186
+ stdout
187
+ end
188
+ end
189
+
190
+ def build_command(args)
191
+ ["nctl"] + args.map(&:to_s)
192
+ end
193
+
194
+ def check_nctl_installed
195
+ _stdout, _stderr, status = Open3.capture3("nctl", "--version")
196
+ return if status.success?
197
+
198
+ raise Deploio::NctlError,
199
+ "nctl not found. Please install it: https://github.com/ninech/nctl"
200
+ end
201
+
202
+ def check_nctl_version
203
+ stdout, _stderr, _status = Open3.capture3("nctl", "--version")
204
+ version_match = stdout.match(/(\d+\.\d+\.\d+)/)
205
+ return unless version_match
206
+
207
+ version = version_match[1]
208
+ return unless Gem::Version.new(version) < Gem::Version.new(REQUIRED_VERSION)
209
+
210
+ raise Deploio::NctlError,
211
+ "nctl version #{version} is too old. Need #{REQUIRED_VERSION}+. Run: brew upgrade nctl"
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-table"
4
+ require "pastel"
5
+
6
+ # Utility module for formatted console output
7
+ module Deploio
8
+ module Output
9
+ class << self
10
+ def color_enabled
11
+ return @color_enabled if defined?(@color_enabled)
12
+
13
+ @color_enabled = $stdout.tty?
14
+ end
15
+
16
+ def color_enabled=(value)
17
+ @color_enabled = value
18
+ @pastel = nil
19
+ end
20
+
21
+ def success(message)
22
+ puts pastel.green("✓ #{message}")
23
+ end
24
+
25
+ def error(message)
26
+ warn pastel.red("✗ #{message}")
27
+ end
28
+
29
+ def warning(message)
30
+ puts pastel.yellow("! #{message}")
31
+ end
32
+
33
+ def info(message)
34
+ puts pastel.cyan("→ #{message}")
35
+ end
36
+
37
+ def command(cmd)
38
+ puts pastel.bold("> #{cmd}")
39
+ end
40
+
41
+ def header(text)
42
+ puts pastel.magenta.bold(text)
43
+ end
44
+
45
+ def table(rows, headers: nil)
46
+ return if rows.empty?
47
+
48
+ tty_table = headers ? TTY::Table.new(header: headers, rows: rows) : TTY::Table.new(rows: rows)
49
+ puts tty_table.render(:unicode, padding: [0, 2, 0, 1], width: 10_000)
50
+ end
51
+
52
+ private
53
+
54
+ def pastel
55
+ @pastel ||= Pastel.new(enabled: color_enabled)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Deploio
6
+ module SharedOptions
7
+ def self.included(base)
8
+ base.class_option :app, aliases: "-a", type: :string, desc: "App in <project>-<app> format"
9
+ base.class_option :org, aliases: "-o", type: :string, desc: "Organization"
10
+ base.class_option :dry_run, type: :boolean, default: false, desc: "Print commands without executing"
11
+ base.class_option :no_color, type: :boolean, default: false, desc: "Disable colored output"
12
+
13
+ base.define_singleton_method(:exit_on_failure?) { true }
14
+ end
15
+
16
+ private
17
+
18
+ # Merges parent CLI options with subcommand options.
19
+ # Parent options take precedence over subcommand defaults.
20
+ def merged_options
21
+ @merged_options ||= options
22
+ .to_h
23
+ .merge(parent_options.to_h) { |_key, sub, par| par.nil? ? sub : par }
24
+ .transform_keys(&:to_sym)
25
+ end
26
+
27
+ def setup_options
28
+ Output.color_enabled = !merged_options[:no_color] && $stdout.tty?
29
+ @nctl = NctlClient.new(dry_run: merged_options[:dry_run])
30
+ @nctl.check_requirements unless merged_options[:dry_run]
31
+ end
32
+
33
+ def resolve_app
34
+ resolver = AppResolver.new(nctl_client: @nctl)
35
+ resolver.resolve(app_name: merged_options[:app])
36
+ rescue Deploio::Error => e
37
+ Output.error(e.message)
38
+ exit 1
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,126 @@
1
+ #compdef <%= program_name %> depl
2
+
3
+ # Zsh completion for <%= program_name %>-cli
4
+ # Auto-generated from Thor command definitions
5
+ # Enable with: eval "$(<%= program_name %> completion)"
6
+
7
+ # Dynamic completion for organizations
8
+ _<%= program_name %>_orgs_list() {
9
+ local -a orgs
10
+ orgs=(${(f)"$(<%= program_name %> orgs --json 2>/dev/null | ruby -rjson -e '
11
+ data = JSON.parse(STDIN.read) rescue []
12
+ data.each do |o|
13
+ name = o["name"] || ""
14
+ current = o["current"] ? " (current)" : ""
15
+ puts "#{name}:#{name}#{current}"
16
+ end
17
+ ' 2>/dev/null)"})
18
+
19
+ if [[ ${#orgs[@]} -gt 0 ]]; then
20
+ _describe -t orgs 'available organizations' orgs
21
+ else
22
+ _message 'organization name'
23
+ fi
24
+ }
25
+
26
+ # Dynamic completion for apps
27
+ _<%= program_name %>_apps_list() {
28
+ local -a apps
29
+ apps=(${(f)"$(<%= program_name %> apps --json 2>/dev/null | ruby -rjson -e '
30
+ data = JSON.parse(STDIN.read) rescue []
31
+ orgs_json = `<%= program_name %> orgs --json 2>/dev/null` rescue "[]"
32
+ orgs = JSON.parse(orgs_json) rescue []
33
+ current_org = orgs.find { |o| o["current"] }&.fetch("name", nil)
34
+
35
+ data.each do |a|
36
+ ns = a.dig("metadata", "namespace") || ""
37
+ name = a.dig("metadata", "name") || ""
38
+ project = current_org && ns.start_with?("#{current_org}-") ? ns.delete_prefix("#{current_org}-") : ns
39
+ short_name = "#{project}-#{name}"
40
+ puts "#{short_name}:#{project}/#{name}"
41
+ end
42
+ ' 2>/dev/null)"})
43
+
44
+ if [[ ${#apps[@]} -gt 0 ]]; then
45
+ _describe -t apps 'available apps' apps
46
+ else
47
+ _message 'app (format: project-appname)'
48
+ fi
49
+ }
50
+
51
+ <% subcommands.each do |name, commands, class_options| %>
52
+ # <%= name %> subcommand
53
+ _<%= program_name %>_<%= name %>() {
54
+ local -a <%= name %>_commands
55
+ <%= name %>_commands=(
56
+ <% commands.each do |cmd_name, desc| -%>
57
+ '<%= cmd_name %>:<%= escape(desc) %>'
58
+ <% end -%>
59
+ )
60
+
61
+ _arguments -s \
62
+ '1:<%= name %> command:-><%= name %>_cmd' \
63
+ '*::<%= name %> args:-><%= name %>_args'
64
+
65
+ case "$state" in
66
+ <%= name %>_cmd)
67
+ _describe -t commands '<%= name %> commands' <%= name %>_commands
68
+ ;;
69
+ <%= name %>_args)
70
+ case "$words[1]" in
71
+ <% commands.each do |cmd_name, _, options| -%>
72
+ <%= cmd_name %>)
73
+ _arguments -s \
74
+ <%= format_options(options, class_options, positional_arg(name, cmd_name)) %>
75
+ ;;
76
+ <% end -%>
77
+ esac
78
+ ;;
79
+ esac
80
+ }
81
+ <% end %>
82
+
83
+ # Main completion function
84
+ _<%= program_name %>() {
85
+ local -a main_commands
86
+ main_commands=(
87
+ <% main_commands.each do |name, desc| -%>
88
+ '<%= name %>:<%= escape(desc) %>'
89
+ <% end -%>
90
+ )
91
+
92
+ _arguments -s \
93
+ '(-v --version)'{-v,--version}'[Show version]' \
94
+ '1:command:->cmd' \
95
+ '*::command args:->args'
96
+
97
+ case "$state" in
98
+ cmd)
99
+ _describe -t commands '<%= program_name %> commands' main_commands
100
+ ;;
101
+ args)
102
+ case "$words[1]" in
103
+ <% subcommands.each do |name, _, _| -%>
104
+ <%= name %>)
105
+ _<%= program_name %>_<%= name %>
106
+ ;;
107
+ <% end -%>
108
+ <% direct_commands.each do |cmd_name, options| -%>
109
+ <%= cmd_name %>)
110
+ _arguments -s \
111
+ <%= format_options(options, cli_class_options) %>
112
+ ;;
113
+ <% end -%>
114
+ <% passthrough_commands.each do |cmd_name, options| -%>
115
+ <%= cmd_name %>)
116
+ _arguments -s \
117
+ <%= format_options(options, cli_class_options, "'*:command:_normal'") %>
118
+ ;;
119
+ <% end -%>
120
+ esac
121
+ ;;
122
+ esac
123
+ }
124
+
125
+ # Register the completion function
126
+ compdef _<%= program_name %> <%= program_name %> depl
@@ -0,0 +1,12 @@
1
+ module Deploio
2
+ module Utils
3
+ # fetches the git remote of origin in the current directory
4
+ # This is used in different places to auto-detect the app based on the current git remote ala heroku.
5
+ def self.detect_git_remote
6
+ stdout, _stderr, status = Open3.capture3("git", "remote", "get-url", "origin")
7
+ status.success? ? stdout.strip : nil
8
+ rescue Errno::ENOENT
9
+ nil
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deploio
4
+ VERSION = "0.1.0"
5
+ end
data/lib/deploio.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ require_relative "deploio/version"
6
+ require_relative "deploio/utils"
7
+ require_relative "deploio/output"
8
+ require_relative "deploio/app_ref"
9
+ require_relative "deploio/nctl_client"
10
+ require_relative "deploio/app_resolver"
11
+ require_relative "deploio/shared_options"
12
+ require_relative "deploio/cli"
13
+
14
+ module Deploio
15
+ class Error < StandardError; end
16
+ class AppNotFoundError < Error; end
17
+ class NctlError < Error; end
18
+ end