cpflow 4.1.0 → 4.1.1

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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +4 -0
  3. data/CHANGELOG.md +4 -0
  4. data/Gemfile.lock +1 -1
  5. data/README.md +7 -7
  6. data/docs/commands.md +16 -0
  7. data/docs/terraform/details.md +415 -0
  8. data/docs/terraform/example/.controlplane/controlplane.yml +29 -0
  9. data/docs/terraform/example/.controlplane/templates/app.yml +38 -0
  10. data/docs/terraform/example/.controlplane/templates/postgres.yml +30 -0
  11. data/docs/terraform/example/.controlplane/templates/rails.yml +26 -0
  12. data/docs/terraform/overview.md +105 -0
  13. data/lib/command/base.rb +29 -5
  14. data/lib/command/base_sub_command.rb +15 -0
  15. data/lib/command/generate.rb +1 -1
  16. data/lib/command/ps.rb +1 -1
  17. data/lib/command/ps_stop.rb +2 -1
  18. data/lib/command/run.rb +1 -1
  19. data/lib/command/terraform/base.rb +35 -0
  20. data/lib/command/terraform/generate.rb +99 -0
  21. data/lib/command/terraform/import.rb +79 -0
  22. data/lib/core/controlplane.rb +3 -3
  23. data/lib/core/shell.rb +9 -4
  24. data/lib/core/terraform_config/agent.rb +31 -0
  25. data/lib/core/terraform_config/audit_context.rb +31 -0
  26. data/lib/core/terraform_config/base.rb +25 -0
  27. data/lib/core/terraform_config/dsl.rb +102 -0
  28. data/lib/core/terraform_config/generator.rb +184 -0
  29. data/lib/core/terraform_config/gvc.rb +63 -0
  30. data/lib/core/terraform_config/identity.rb +35 -0
  31. data/lib/core/terraform_config/local_variable.rb +30 -0
  32. data/lib/core/terraform_config/policy.rb +151 -0
  33. data/lib/core/terraform_config/provider.rb +22 -0
  34. data/lib/core/terraform_config/required_provider.rb +23 -0
  35. data/lib/core/terraform_config/secret.rb +138 -0
  36. data/lib/core/terraform_config/volume_set.rb +155 -0
  37. data/lib/core/terraform_config/workload/main.tf +316 -0
  38. data/lib/core/terraform_config/workload/required_providers.tf +8 -0
  39. data/lib/core/terraform_config/workload/variables.tf +263 -0
  40. data/lib/core/terraform_config/workload.rb +132 -0
  41. data/lib/cpflow/version.rb +1 -1
  42. data/lib/cpflow.rb +50 -9
  43. data/lib/generator_templates/templates/postgres.yml +1 -1
  44. data/lib/patches/array.rb +8 -0
  45. data/lib/patches/hash.rb +47 -0
  46. data/lib/patches/string.rb +34 -0
  47. data/script/update_command_docs +6 -2
  48. metadata +33 -3
@@ -0,0 +1,263 @@
1
+ variable "containers" {
2
+ type = map(
3
+ object({
4
+ args = optional(list(string))
5
+ command = optional(string)
6
+ cpu = optional(string, "1000m")
7
+ envs = optional(map(string))
8
+ image = string
9
+ inherit_env = optional(bool)
10
+ liveness_probe = optional(
11
+ object({
12
+ exec = optional(
13
+ object({
14
+ command = list(string)
15
+ })
16
+ )
17
+ failure_threshold = optional(number)
18
+ grpc = optional(
19
+ object({
20
+ port = optional(number)
21
+ })
22
+ )
23
+ http_get = optional(
24
+ object({
25
+ http_headers = optional(map(string))
26
+ path = optional(string)
27
+ port = optional(number)
28
+ scheme = optional(string)
29
+ })
30
+ )
31
+ initial_delay_seconds = optional(number)
32
+ period_seconds = optional(number)
33
+ success_threshold = optional(number)
34
+ timeout_seconds = optional(number)
35
+ tcp_socket = optional(
36
+ object({
37
+ port = optional(number)
38
+ })
39
+ )
40
+ })
41
+ )
42
+ memory = optional(string, "2048Mi")
43
+ ports = optional(
44
+ list(
45
+ object({
46
+ number = number
47
+ protocol = string
48
+ })
49
+ ),
50
+ [],
51
+ )
52
+ post_start_command = optional(string)
53
+ pre_stop_command = optional(string)
54
+ readiness_probe = optional(
55
+ object({
56
+ exec = optional(
57
+ object({
58
+ command = list(string)
59
+ })
60
+ )
61
+ failure_threshold = optional(number)
62
+ grpc = optional(
63
+ object({
64
+ port = optional(number)
65
+ })
66
+ )
67
+ http_get = optional(
68
+ object({
69
+ http_headers = optional(map(string))
70
+ path = optional(string)
71
+ port = optional(number)
72
+ scheme = optional(string)
73
+ })
74
+ )
75
+ initial_delay_seconds = optional(number)
76
+ period_seconds = optional(number)
77
+ success_threshold = optional(number)
78
+ timeout_seconds = optional(number)
79
+ tcp_socket = optional(
80
+ object({
81
+ port = optional(number)
82
+ })
83
+ )
84
+ })
85
+ )
86
+ volumes = optional(
87
+ list(
88
+ object({
89
+ path = string
90
+ uri = string
91
+ })
92
+ ),
93
+ [],
94
+ )
95
+ })
96
+ )
97
+ }
98
+
99
+ variable "description" {
100
+ type = string
101
+ default = null
102
+ }
103
+
104
+ variable "firewall_spec" {
105
+ type = object({
106
+ external = optional(
107
+ object({
108
+ inbound_allow_cidr = optional(list(string))
109
+ outbound_allow_hostname = optional(list(string))
110
+ outbound_allow_cidr = optional(list(string))
111
+ outbound_allow_port = optional(
112
+ list(
113
+ object({
114
+ number = number
115
+ protocol = optional(string, "tcp")
116
+ })
117
+ ),
118
+ []
119
+ )
120
+ })
121
+ )
122
+ internal = optional(
123
+ object({
124
+ inbound_allow_type = optional(string)
125
+ inbound_allow_workload = optional(list(string))
126
+ }),
127
+ )
128
+ })
129
+ default = null
130
+ }
131
+
132
+ variable "gvc" {
133
+ type = string
134
+ }
135
+
136
+ variable "identity_link" {
137
+ type = string
138
+ default = null
139
+ }
140
+
141
+ variable "job" {
142
+ type = object({
143
+ active_deadline_seconds = optional(number)
144
+ concurrency_policy = optional(string, "Forbid")
145
+ history_limit = optional(number, 5)
146
+ restart_policy = optional(string, "Never")
147
+ schedule = string
148
+ })
149
+ default = null
150
+ }
151
+
152
+ variable "load_balancer" {
153
+ type = object({
154
+ direct = optional(
155
+ object({
156
+ enabled = bool
157
+ port = optional(
158
+ list(
159
+ object({
160
+ container_port = optional(number)
161
+ external_port = number
162
+ protocol = string
163
+ scheme = optional(string)
164
+ })
165
+ ),
166
+ []
167
+ )
168
+ })
169
+ )
170
+ geo_location = optional(
171
+ object({
172
+ enabled = optional(bool)
173
+ headers = optional(
174
+ object({
175
+ asn = optional(string)
176
+ city = optional(string)
177
+ country = optional(string)
178
+ region = optional(string)
179
+ })
180
+ )
181
+ })
182
+ )
183
+ })
184
+ default = null
185
+ }
186
+
187
+ variable "local_options" {
188
+ type = object({
189
+ autoscaling = optional(
190
+ object({
191
+ metric = optional(string)
192
+ metric_percentile = optional(string)
193
+ target = optional(number)
194
+ max_scale = optional(number)
195
+ min_scale = optional(number)
196
+ scale_to_zero_delay = optional(number)
197
+ max_concurrency = optional(number)
198
+ })
199
+ )
200
+ location = string
201
+ capacity_ai = optional(bool, true)
202
+ debug = optional(bool, false)
203
+ suspend = optional(bool, false)
204
+ timeout_seconds = optional(number, 5)
205
+ })
206
+ default = null
207
+ }
208
+
209
+ variable "name" {
210
+ type = string
211
+ }
212
+
213
+ variable "options" {
214
+ type = object({
215
+ autoscaling = optional(
216
+ object({
217
+ max_concurrency = optional(number)
218
+ max_scale = optional(number)
219
+ metric = optional(string)
220
+ metric_percentile = optional(string)
221
+ min_scale = optional(number)
222
+ scale_to_zero_delay = optional(number)
223
+ target = optional(number)
224
+ })
225
+ )
226
+ capacity_ai = optional(bool, true)
227
+ debug = optional(bool, false)
228
+ suspend = optional(bool, false)
229
+ timeout_seconds = optional(number, 5)
230
+ })
231
+ default = null
232
+ }
233
+
234
+ variable "rollout_options" {
235
+ type = object({
236
+ max_surge_replicas = optional(string)
237
+ max_unavailable_replicas = optional(string)
238
+ min_ready_seconds = optional(number)
239
+ scaling_policy = optional(string, "OrderedReady")
240
+ })
241
+ default = null
242
+ }
243
+
244
+ variable "security_options" {
245
+ type = object({
246
+ file_system_group_id = optional(number)
247
+ })
248
+ default = null
249
+ }
250
+
251
+ variable "support_dynamic_tags" {
252
+ type = bool
253
+ default = false
254
+ }
255
+
256
+ variable "tags" {
257
+ type = map(string)
258
+ default = {}
259
+ }
260
+
261
+ variable "type" {
262
+ type = string
263
+ }
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TerraformConfig
4
+ class Workload < Base # rubocop:disable Metrics/ClassLength
5
+ RAW_ARGS = %i[
6
+ containers options local_options rollout_options security_options
7
+ firewall_spec load_balancer job
8
+ ].freeze
9
+
10
+ OPTIONS_KEYS = %i[autoscaling capacity_ai suspend timeout_seconds].freeze
11
+ LOCAL_OPTIONS_KEYS = (OPTIONS_KEYS + %i[location]).freeze
12
+ ROLLOUT_OPTIONS_KEYS = %i[min_ready_seconds max_unavailable_replicas max_surge_replicas scaling_policy].freeze
13
+ SECURITY_OPTIONS_KEYS = %i[file_system_group_id].freeze
14
+ LOAD_BALANCER_KEYS = %i[direct geo_location].freeze
15
+ FIREWALL_SPEC_KEYS = %i[internal external].freeze
16
+ VOLUME_KEYS = %i[uri path].freeze
17
+ JOB_KEYS = %i[schedule concurrency_policy history_limit restart_policy active_deadline_seconds].freeze
18
+ LIVENESS_PROBE_KEYS = %i[
19
+ exec http_get tcp_socket grpc
20
+ failure_threshold initial_delay_seconds period_seconds success_threshold timeout_seconds
21
+ ].freeze
22
+
23
+ attr_reader :type, :name, :gvc, :containers,
24
+ :description, :tags, :support_dynamic_tags, :firewall_spec, :identity_link,
25
+ :options, :local_options, :rollout_options, :security_options, :load_balancer, :job
26
+
27
+ def initialize( # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
28
+ type:,
29
+ gvc:,
30
+ name:,
31
+ containers:,
32
+ description: nil,
33
+ tags: nil,
34
+ support_dynamic_tags: false,
35
+ firewall_spec: nil,
36
+ identity_link: nil,
37
+ options: nil,
38
+ local_options: nil,
39
+ rollout_options: nil,
40
+ security_options: nil,
41
+ load_balancer: nil,
42
+ job: nil
43
+ )
44
+ super()
45
+
46
+ @type = type
47
+ @gvc = gvc
48
+
49
+ @name = name
50
+ @description = description
51
+ @tags = tags
52
+
53
+ @containers = containers
54
+ @firewall_spec = firewall_spec
55
+ @identity_link = identity_link
56
+
57
+ @options = options
58
+ @local_options = local_options
59
+ @rollout_options = rollout_options
60
+ @security_options = security_options
61
+
62
+ @load_balancer = load_balancer
63
+ @support_dynamic_tags = support_dynamic_tags
64
+ @job = job
65
+ end
66
+
67
+ def importable?
68
+ true
69
+ end
70
+
71
+ def reference
72
+ "module.#{name}.cpln_workload.workload"
73
+ end
74
+
75
+ def to_tf
76
+ block :module, name do
77
+ argument :source, "../workload"
78
+
79
+ argument :type, type
80
+ argument :name, name
81
+ argument :gvc, gvc
82
+ argument :identity_link, identity_link, optional: true
83
+ argument :support_dynamic_tags, support_dynamic_tags, optional: true
84
+
85
+ RAW_ARGS.each { |arg_name| argument arg_name, send(:"#{arg_name}_arg"), raw: true, optional: true }
86
+ end
87
+ end
88
+
89
+ def locals
90
+ containers.reduce({}) do |result, container|
91
+ envs = container[:env].to_h { |env_var| [env_var[:name], env_var[:value]] }
92
+ next result if envs.empty?
93
+
94
+ envs_name = :"#{container.fetch(:name)}_envs"
95
+ result.merge("#{envs_name}.tf" => LocalVariable.new(envs_name => envs))
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def containers_arg
102
+ containers.reduce({}) { |result, container| result.merge(container_args(container)) }.crush
103
+ end
104
+
105
+ def container_args(container) # rubocop:disable Metrics/MethodLength
106
+ container_name = container.fetch(:name)
107
+
108
+ args = container.slice(:args, :command, :image, :cpu, :memory).merge(
109
+ post_start: container.dig(:lifecycle, :post_start, :exec, :command),
110
+ pre_stop: container.dig(:lifecycle, :pre_stop, :exec, :command),
111
+ inherit_env: container.fetch(:inherit_env, nil),
112
+ envs: ("local.#{container_name}_envs" if container[:env]&.any?),
113
+ ports: container.fetch(:ports, nil),
114
+ readiness_probe: container.fetch(:readiness_probe, nil)&.slice(*LIVENESS_PROBE_KEYS),
115
+ liveness_probe: container.fetch(:liveness_probe, nil)&.slice(*LIVENESS_PROBE_KEYS),
116
+ volumes: container.fetch(:volumes, nil)&.map { |volume| volume.slice(*VOLUME_KEYS) }
117
+ )
118
+
119
+ { container_name => args }
120
+ end
121
+
122
+ RAW_ARGS.each do |spec|
123
+ next if spec == :containers
124
+
125
+ define_method("#{spec}_arg") do
126
+ return if send(spec).nil?
127
+
128
+ send(spec).slice(*self.class.const_get("#{spec.upcase}_KEYS"))
129
+ end
130
+ end
131
+ end
132
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cpflow
4
- VERSION = "4.1.0"
4
+ VERSION = "4.1.1"
5
5
  MIN_CPLN_VERSION = "3.1.0"
6
6
  end
data/lib/cpflow.rb CHANGED
@@ -17,6 +17,9 @@ require_relative "constants/exit_code"
17
17
 
18
18
  # We need to require base before all commands, since the commands inherit from it
19
19
  require_relative "command/base"
20
+ require_relative "command/terraform/base"
21
+ # We need to require base terraform config before all commands, since the terraform configs inherit from it
22
+ require_relative "core/terraform_config/base"
20
23
 
21
24
  modules = Dir["#{__dir__}/**/*.rb"].reject do |file|
22
25
  file == __FILE__ || file.end_with?("base.rb")
@@ -33,9 +36,15 @@ end
33
36
 
34
37
  require_relative "patches/thor"
35
38
 
39
+ require_relative "patches/string"
40
+
36
41
  module Cpflow
37
42
  class Error < StandardError; end
38
43
 
44
+ def self.root_path
45
+ Pathname.new(File.expand_path("../", __dir__))
46
+ end
47
+
39
48
  class Cli < Thor # rubocop:disable Metrics/ClassLength
40
49
  package_name "cpflow"
41
50
  default_task :no_command
@@ -95,12 +104,21 @@ module Cpflow
95
104
  def self.fix_help_option
96
105
  help_mappings = Thor::HELP_MAPPINGS + ["help"]
97
106
  matches = help_mappings & ARGV
107
+
108
+ # Help option works correctly for subcommands
109
+ return if matches && subcommand?
110
+
98
111
  matches.each do |match|
99
112
  ARGV.delete(match)
100
113
  ARGV.unshift(match)
101
114
  end
102
115
  end
103
116
 
117
+ def self.subcommand?
118
+ (subcommand_names & ARGV).any?
119
+ end
120
+ private_class_method :subcommand?
121
+
104
122
  # Needed to silence deprecation warning
105
123
  def self.exit_on_failure?
106
124
  true
@@ -131,6 +149,10 @@ module Cpflow
131
149
  ::Command::Base.all_commands.merge(deprecated_commands)
132
150
  end
133
151
 
152
+ def self.subcommand_names
153
+ Dir["#{__dir__}/command/*"].filter_map { |name| File.basename(name) if File.directory?(name) }
154
+ end
155
+
134
156
  def self.process_option_params(params)
135
157
  # Ensures that if no value is provided for a non-boolean option (e.g., `cpflow command --option`),
136
158
  # it defaults to an empty string instead of the option name (which is the default Thor behavior)
@@ -139,8 +161,21 @@ module Cpflow
139
161
  params
140
162
  end
141
163
 
164
+ def self.klass_for(subcommand_name)
165
+ klass_name = subcommand_name.to_s.split("-").map(&:capitalize).join
166
+ full_klass_name = "Cpflow::#{klass_name}"
167
+ return const_get(full_klass_name) if const_defined?(full_klass_name)
168
+
169
+ Cpflow.const_set(klass_name, Class.new(BaseSubCommand)).tap do |subcommand_klass|
170
+ desc(subcommand_name, "#{subcommand_name.capitalize} commands")
171
+ subcommand(subcommand_name, subcommand_klass)
172
+ end
173
+ end
174
+ private_class_method :klass_for
175
+
142
176
  @commands_with_required_options = []
143
177
  @commands_with_extra_options = []
178
+ cli_package_name = @package_name
144
179
 
145
180
  ::Command::Base.common_options.each do |option|
146
181
  params = process_option_params(option[:params])
@@ -151,6 +186,7 @@ module Cpflow
151
186
  deprecated = deprecated_commands[command_key]
152
187
 
153
188
  name = command_class::NAME
189
+ subcommand_name = command_class::SUBCOMMAND_NAME
154
190
  name_for_method = deprecated ? command_key : name.tr("-", "_")
155
191
  usage = command_class::USAGE.empty? ? name : command_class::USAGE
156
192
  requires_args = command_class::REQUIRES_ARGS
@@ -170,21 +206,26 @@ module Cpflow
170
206
  # so we store it here to be able to use it
171
207
  raise_args_error = ->(*args) { handle_argument_error(commands[name_for_method], ArgumentError, *args) }
172
208
 
173
- desc(usage, description, hide: hide)
174
- long_desc(long_description)
175
-
176
- command_options.each do |option|
177
- params = process_option_params(option[:params])
178
- method_option(option[:name], **params)
179
- end
180
-
181
209
  # We'll handle required options manually in `Config`
182
210
  required_options = command_options.select { |option| option[:params][:required] }.map { |option| option[:name] }
183
211
  @commands_with_required_options.push(name_for_method.to_sym) if required_options.any?
184
212
 
185
213
  @commands_with_extra_options.push(name_for_method.to_sym) if accepts_extra_options
186
214
 
187
- define_method(name_for_method) do |*provided_args| # rubocop:disable Metrics/BlockLength, Metrics/MethodLength
215
+ klass = subcommand_name ? klass_for(subcommand_name) : self
216
+
217
+ klass.class_eval do
218
+ package_name(cli_package_name) if subcommand_name
219
+ desc(usage, description, hide: hide)
220
+ long_desc(long_description)
221
+
222
+ command_options.each do |option|
223
+ params = Cpflow::Cli.process_option_params(option[:params])
224
+ method_option(option[:name], **params)
225
+ end
226
+ end
227
+
228
+ klass.define_method(name_for_method) do |*provided_args| # rubocop:disable Metrics/BlockLength, Metrics/MethodLength
188
229
  if deprecated
189
230
  normalized_old_name = ::Helpers.normalize_command_name(command_key)
190
231
  ::Shell.warn_deprecated("Command '#{normalized_old_name}' is deprecated, " \
@@ -133,7 +133,7 @@ spec:
133
133
  value: cpln://secret/postgres-poc-credentials.password
134
134
  - name: POSTGRES_USER #The name of the default user
135
135
  value: cpln://secret/postgres-poc-credentials.username
136
- name: stateful
136
+ name: postgres
137
137
  image: postgres:15
138
138
  command: /bin/bash
139
139
  args:
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Array
4
+ def crush
5
+ crushed = map { |el| el.respond_to?(:crush) ? el.crush : el }.compact
6
+ crushed unless crushed.empty?
7
+ end
8
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hash
4
+ # Copied from Rails
5
+ def deep_symbolize_keys
6
+ deep_transform_keys { |key| key.to_sym rescue key } # rubocop:disable Style/RescueModifier
7
+ end
8
+
9
+ def deep_underscore_keys
10
+ deep_transform_keys do |key|
11
+ underscored = key.to_s.underscore
12
+ key.is_a?(Symbol) ? underscored.to_sym : underscored
13
+ rescue StandardError
14
+ key
15
+ end
16
+ end
17
+
18
+ def crush
19
+ crushed = each_with_object({}) do |(key, value), hash|
20
+ crushed_value = value.respond_to?(:crush) ? value.crush : value
21
+ hash[key] = crushed_value unless crushed_value.nil?
22
+ end
23
+
24
+ crushed unless crushed.empty?
25
+ end
26
+
27
+ private
28
+
29
+ # Copied from Rails
30
+ def deep_transform_keys(&block)
31
+ deep_transform_keys_in_object(self, &block)
32
+ end
33
+
34
+ # Copied from Rails
35
+ def deep_transform_keys_in_object(object, &block)
36
+ case object
37
+ when Hash
38
+ object.each_with_object(self.class.new) do |(key, value), result|
39
+ result[yield(key)] = deep_transform_keys_in_object(value, &block)
40
+ end
41
+ when Array
42
+ object.map { |e| deep_transform_keys_in_object(e, &block) }
43
+ else
44
+ object
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Style/OptionalBooleanParameter, Lint/UnderscorePrefixedVariableName
4
+ class String
5
+ # Copied from Rails
6
+ def indent(amount, indent_string = nil, indent_empty_lines = false)
7
+ dup.tap { |_| _.indent!(amount, indent_string, indent_empty_lines) }
8
+ end
9
+
10
+ # Copied from Rails
11
+ def indent!(amount, indent_string = nil, indent_empty_lines = false)
12
+ indent_string = indent_string || self[/^[ \t]/] || " "
13
+ re = indent_empty_lines ? /^/ : /^(?!$)/
14
+ gsub!(re, indent_string * amount)
15
+ end
16
+
17
+ def unindent
18
+ gsub(/^#{scan(/^[ \t]+(?=\S)/).min}/, "")
19
+ end
20
+
21
+ # Copied from Rails
22
+ def underscore
23
+ gsub("::", "/").gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').tr("-", "_").downcase
24
+ end
25
+
26
+ # Covers only simple cases and used for pluralizing controlplane template kinds (`gvc`, `secret`, `policy`, etc.)
27
+ def pluralize
28
+ return self if empty?
29
+ return "#{self[...-1]}ies" if end_with?("y")
30
+
31
+ "#{self}s"
32
+ end
33
+ end
34
+ # rubocop:enable Style/OptionalBooleanParameter, Lint/UnderscorePrefixedVariableName
@@ -12,12 +12,16 @@ commands.keys.sort.each do |command_key|
12
12
  next if command_class::HIDE
13
13
 
14
14
  name = command_class::NAME
15
- usage = command_class::USAGE.empty? ? name : command_class::USAGE
15
+ subcommand_name = command_class::SUBCOMMAND_NAME
16
+
17
+ full_command = [subcommand_name, name].compact.join(" ")
18
+
19
+ usage = command_class::USAGE.empty? ? full_command : command_class::USAGE
16
20
  options = command_class::OPTIONS
17
21
  long_description = command_class::LONG_DESCRIPTION
18
22
  examples = command_class::EXAMPLES
19
23
 
20
- command_str = "### `#{name}`\n\n"
24
+ command_str = "### `#{full_command}`\n\n"
21
25
  command_str += "#{long_description.strip}\n\n"
22
26
 
23
27
  if examples.empty?