slipway 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.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +7 -0
  3. data/CHANGELOG.md +45 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +1013 -0
  6. data/exe/slipway +10 -0
  7. data/lib/slipway/cli/builtins.rb +241 -0
  8. data/lib/slipway/cli/completer.rb +158 -0
  9. data/lib/slipway/cli/completion_scripts.rb +163 -0
  10. data/lib/slipway/cli/context.rb +67 -0
  11. data/lib/slipway/cli/errors.rb +19 -0
  12. data/lib/slipway/cli/globals.rb +27 -0
  13. data/lib/slipway/cli/help_renderer.rb +135 -0
  14. data/lib/slipway/cli/manpage.rb +226 -0
  15. data/lib/slipway/cli/parser.rb +45 -0
  16. data/lib/slipway/cli/registry.rb +191 -0
  17. data/lib/slipway/cli/runner.rb +186 -0
  18. data/lib/slipway/cli/style.rb +82 -0
  19. data/lib/slipway/cli/theme.rb +85 -0
  20. data/lib/slipway/cli/validator.rb +61 -0
  21. data/lib/slipway/cli.rb +22 -0
  22. data/lib/slipway/command_line.rb +22 -0
  23. data/lib/slipway/commands/api_resources.rb +82 -0
  24. data/lib/slipway/commands/apply.rb +172 -0
  25. data/lib/slipway/commands/base.rb +50 -0
  26. data/lib/slipway/commands/config.rb +73 -0
  27. data/lib/slipway/commands/create.rb +218 -0
  28. data/lib/slipway/commands/delete.rb +82 -0
  29. data/lib/slipway/commands/describe.rb +74 -0
  30. data/lib/slipway/commands/diff.rb +122 -0
  31. data/lib/slipway/commands/edit.rb +130 -0
  32. data/lib/slipway/commands/explain.rb +97 -0
  33. data/lib/slipway/commands/fetch.rb +112 -0
  34. data/lib/slipway/commands/from_dir.rb +141 -0
  35. data/lib/slipway/commands/get.rb +167 -0
  36. data/lib/slipway/commands/label.rb +114 -0
  37. data/lib/slipway/commands/manual.rb +67 -0
  38. data/lib/slipway/commands/options.rb +73 -0
  39. data/lib/slipway/commands/results.rb +57 -0
  40. data/lib/slipway/commands/rollout.rb +114 -0
  41. data/lib/slipway/commands/rollout_spec.rb +99 -0
  42. data/lib/slipway/commands/rollout_undo.rb +126 -0
  43. data/lib/slipway/commands/scope.rb +156 -0
  44. data/lib/slipway/commands/sync.rb +140 -0
  45. data/lib/slipway/commands.rb +54 -0
  46. data/lib/slipway/drift.rb +87 -0
  47. data/lib/slipway/editor.rb +71 -0
  48. data/lib/slipway/error.rb +27 -0
  49. data/lib/slipway/fetcher.rb +99 -0
  50. data/lib/slipway/field_selector.rb +86 -0
  51. data/lib/slipway/git/branch_name.rb +32 -0
  52. data/lib/slipway/git/commit.rb +13 -0
  53. data/lib/slipway/git/distance.rb +13 -0
  54. data/lib/slipway/git/errors.rb +125 -0
  55. data/lib/slipway/git/fake.rb +147 -0
  56. data/lib/slipway/git/fast_forward.rb +12 -0
  57. data/lib/slipway/git/fast_forwarding.rb +148 -0
  58. data/lib/slipway/git/fetch_result.rb +22 -0
  59. data/lib/slipway/git/move_back.rb +12 -0
  60. data/lib/slipway/git/reflog.rb +25 -0
  61. data/lib/slipway/git/repository.rb +288 -0
  62. data/lib/slipway/git/rolling_back.rb +98 -0
  63. data/lib/slipway/git/runner.rb +175 -0
  64. data/lib/slipway/git/status.rb +110 -0
  65. data/lib/slipway/git/url.rb +95 -0
  66. data/lib/slipway/git.rb +20 -0
  67. data/lib/slipway/inspector.rb +103 -0
  68. data/lib/slipway/labels.rb +126 -0
  69. data/lib/slipway/manifest.rb +265 -0
  70. data/lib/slipway/names.rb +22 -0
  71. data/lib/slipway/outcome.rb +45 -0
  72. data/lib/slipway/output/age.rb +70 -0
  73. data/lib/slipway/output/describe.rb +71 -0
  74. data/lib/slipway/output/explain.rb +75 -0
  75. data/lib/slipway/output/serializer.rb +35 -0
  76. data/lib/slipway/output/table.rb +67 -0
  77. data/lib/slipway/output.rb +28 -0
  78. data/lib/slipway/paths.rb +65 -0
  79. data/lib/slipway/plan.rb +227 -0
  80. data/lib/slipway/pool.rb +94 -0
  81. data/lib/slipway/resources.rb +91 -0
  82. data/lib/slipway/rollback.rb +236 -0
  83. data/lib/slipway/rollout_history.rb +69 -0
  84. data/lib/slipway/runtime.rb +65 -0
  85. data/lib/slipway/scanner.rb +54 -0
  86. data/lib/slipway/schema.rb +128 -0
  87. data/lib/slipway/selector.rb +146 -0
  88. data/lib/slipway/settings.rb +174 -0
  89. data/lib/slipway/state.rb +82 -0
  90. data/lib/slipway/store.rb +170 -0
  91. data/lib/slipway/syncer.rb +139 -0
  92. data/lib/slipway/version.rb +5 -0
  93. data/lib/slipway/views/group.rb +35 -0
  94. data/lib/slipway/views/project.rb +148 -0
  95. data/lib/slipway/views.rb +10 -0
  96. data/lib/slipway/yaml.rb +14 -0
  97. data/lib/slipway.rb +32 -0
  98. data/man/man1/slipway-api-resources.1 +53 -0
  99. data/man/man1/slipway-apply.1 +45 -0
  100. data/man/man1/slipway-completion.1 +29 -0
  101. data/man/man1/slipway-config-path.1 +20 -0
  102. data/man/man1/slipway-config-view.1 +25 -0
  103. data/man/man1/slipway-config.1 +22 -0
  104. data/man/man1/slipway-create.1 +89 -0
  105. data/man/man1/slipway-delete.1 +46 -0
  106. data/man/man1/slipway-describe.1 +95 -0
  107. data/man/man1/slipway-diff.1 +120 -0
  108. data/man/man1/slipway-edit.1 +34 -0
  109. data/man/man1/slipway-explain.1 +36 -0
  110. data/man/man1/slipway-fetch.1 +77 -0
  111. data/man/man1/slipway-get.1 +155 -0
  112. data/man/man1/slipway-help.1 +19 -0
  113. data/man/man1/slipway-label.1 +53 -0
  114. data/man/man1/slipway-man.1 +36 -0
  115. data/man/man1/slipway-rollout-history.1 +28 -0
  116. data/man/man1/slipway-rollout-pause.1 +21 -0
  117. data/man/man1/slipway-rollout-resume.1 +21 -0
  118. data/man/man1/slipway-rollout-undo.1 +73 -0
  119. data/man/man1/slipway-rollout-unpin.1 +21 -0
  120. data/man/man1/slipway-rollout.1 +36 -0
  121. data/man/man1/slipway-sync.1 +90 -0
  122. data/man/man1/slipway-version.1 +17 -0
  123. data/man/man1/slipway.1 +243 -0
  124. metadata +173 -0
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+ require_relative 'style'
5
+ require_relative 'theme'
6
+ require_relative 'globals'
7
+ require_relative 'parser'
8
+ require_relative 'validator'
9
+ require_relative 'help_renderer'
10
+ require_relative 'builtins'
11
+
12
+ module Slipway
13
+ module CLI
14
+ # Returns the exit status and never lets an exception escape as a backtrace.
15
+ class Runner
16
+ SIGINT_STATUS = 130
17
+ EPIPE_STATUS = 0
18
+ UNEXPECTED_STATUS = 1
19
+ COLOR_VARIABLE = 'SLIPWAY_COLOR'
20
+ THEME_VARIABLE = 'SLIPWAY_THEME'
21
+ DEBUG_VARIABLE = 'SLIPWAY_DEBUG'
22
+ COLOR_FLAG = "--#{Globals::COLOR.long}".freeze
23
+ COLOR_INLINE = "#{COLOR_FLAG}=".freeze
24
+
25
+ # `color` and `theme` are the fallbacks used when neither a flag nor the environment
26
+ # decides, which is where the configuration file values arrive.
27
+ def initialize(registry, context, color: nil, theme: nil)
28
+ @registry = registry
29
+ @context = context
30
+ @default_color = color
31
+ @default_theme = theme
32
+ end
33
+
34
+ # The Context drops output once a stream's reader is gone, so a broken pipe reaching here
35
+ # comes from a handler writing past the Context; it ends the run quietly.
36
+ def run(argv)
37
+ execute(argv.dup)
38
+ rescue Errno::EPIPE
39
+ EPIPE_STATUS
40
+ end
41
+
42
+ private
43
+
44
+ # Color is settled first, from `--color` typed anywhere on the line or from the
45
+ # environment, so even an error in the parse that follows is painted the way the user asked.
46
+ def execute(argv)
47
+ @flag_color = flag_color(argv)
48
+ colorize({})
49
+ dispatch(argv)
50
+ rescue Errno::EPIPE
51
+ raise
52
+ rescue Slipway::Error => e
53
+ report(e.problems, e.hint, layout: e.is_a?(UsageError))
54
+ e.exit_status
55
+ rescue Interrupt
56
+ @context.err.write("\n")
57
+ SIGINT_STATUS
58
+ rescue StandardError => e
59
+ report_unexpected(e)
60
+ UNEXPECTED_STATUS
61
+ end
62
+
63
+ def dispatch(argv)
64
+ values = {}
65
+ command, path, rest = walk(argv, values)
66
+ return run_raw(command, rest) if command.raw
67
+
68
+ args = parse(@registry.globals + command.options, path, values) { it.permute!(rest) }
69
+ opts = defaults(command, values)
70
+ return show_help(command, path) if opts[:help]
71
+ return show_version if opts[:version]
72
+ return show_help(command, path) if command.group?
73
+
74
+ Validator.new(command, path, @registry).call(args, opts)
75
+ invoke(command, path, args, opts)
76
+ end
77
+
78
+ # Globals are parsed before every word, so they may appear before the verb and between a
79
+ # group and its subcommand.
80
+ def walk(argv, values)
81
+ command = @registry.root
82
+ path = []
83
+ while command.group?
84
+ argv = parse(@registry.globals, path, values) { it.order!(argv) }
85
+ word = argv.shift
86
+ return [command, path, argv] if word.nil?
87
+
88
+ command = command.find(word) or raise unknown_command(word, path)
89
+ path << word
90
+ end
91
+ [command, path, argv]
92
+ end
93
+
94
+ def unknown_command(word, path)
95
+ UsageError.new(@registry.unknown_command(word, path), hint: @registry.run_hint(path))
96
+ end
97
+
98
+ # Color is re-resolved after every parse step, so a `--color` the parser has just
99
+ # read applies to the error that may follow it.
100
+ def parse(options, path, values)
101
+ parser = Parser.new(options, values)
102
+ yield parser
103
+ rescue OptionParser::InvalidOption => e
104
+ raise UsageError.new(@registry.unknown_option(e.args.first, options), hint: @registry.help_hint(path))
105
+ rescue OptionParser::ParseError => e
106
+ raise UsageError.new(e.message, hint: @registry.help_hint(path))
107
+ ensure
108
+ colorize(values)
109
+ end
110
+
111
+ def defaults(command, values)
112
+ Parser.new(@registry.globals + command.options, values).defaults.freeze
113
+ end
114
+
115
+ # Taken as typed; the Validator reports an invalid value once the parse reaches it.
116
+ def flag_color(argv)
117
+ argv.take_while { it != '--' }.reverse_each do |word|
118
+ return Globals::COLOR.implicit if word == COLOR_FLAG
119
+ return word.delete_prefix(COLOR_INLINE) if word.start_with?(COLOR_INLINE)
120
+ end
121
+ nil
122
+ end
123
+
124
+ def colorize(values)
125
+ @context = @context.with_color(values.fetch(:color) { @flag_color || fallback_color }, theme:)
126
+ end
127
+
128
+ def fallback_color
129
+ @fallback_color ||= environment(COLOR_VARIABLE, Style::MODES) || @default_color || Style::DEFAULT_MODE
130
+ end
131
+
132
+ def theme
133
+ @theme ||= Theme.fetch(environment(THEME_VARIABLE, Theme::NAMES) || @default_theme || Theme::DEFAULT_NAME)
134
+ end
135
+
136
+ # An invalid value is reported the way the configuration file reports the same key.
137
+ def environment(name, allowed = nil)
138
+ value = @context.env[name]
139
+ return nil if value.to_s.empty?
140
+ return value if allowed.nil? || allowed.include?(value)
141
+
142
+ raise Slipway::Error, "#{name}: must be one of #{allowed.join(', ')}"
143
+ end
144
+
145
+ def invoke(command, path, args, opts)
146
+ command.handler.call(@context, args, opts)
147
+ 0
148
+ rescue UsageError => e
149
+ raise if e.hint
150
+
151
+ raise UsageError.new(e.message, hint: @registry.help_hint(path))
152
+ end
153
+
154
+ def run_raw(command, rest)
155
+ command.handler.call(@context, rest, {})
156
+ 0
157
+ end
158
+
159
+ def show_help(command, path)
160
+ renderer = HelpRenderer.new(@registry, @context.style)
161
+ @context.print(command.equal?(@registry.root) ? renderer.root : renderer.command(command, path))
162
+ 0
163
+ end
164
+
165
+ def show_version
166
+ @context.puts(Builtins.version_line(@registry.program, @registry.version))
167
+ 0
168
+ end
169
+
170
+ # A problem can quote git's stderr, a manifest field, a path or a typed word, so nothing reaches
171
+ # stderr raw. Only a usage error keeps its line feeds, for its "Did you mean this?" list.
172
+ def report(problems, hint, layout: false)
173
+ prefix = @context.paint_err(:error, 'error:')
174
+ problems.each { @context.warn("#{prefix} #{Style.plain(it, layout:)}") }
175
+ @context.warn(Style.plain(hint)) if hint
176
+ end
177
+
178
+ def report_unexpected(error)
179
+ report([error.message], nil)
180
+ return unless environment(DEBUG_VARIABLE)
181
+
182
+ @context.warn(error.class.name, *Array(error.backtrace).map { " #{it}" })
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slipway
4
+ module CLI
5
+ class Style
6
+ MODES = %w[auto always never].freeze
7
+ DEFAULT_MODE = 'auto'
8
+ FORCE_VARIABLES = %w[FORCE_COLOR CLICOLOR_FORCE].freeze
9
+ # C0 and C1 control characters, DEL, and the bidirectional embedding, override and isolate
10
+ # controls: everything a terminal would obey, or use to reorder a line, instead of show.
11
+ CONTROL = /[\x00-\x1F\x7F\u0080-\u009F\u202A-\u202E\u2066-\u2069]/
12
+ REPLACEMENT = "\uFFFD"
13
+ LAYOUT = "\t\n"
14
+
15
+ attr_reader :theme
16
+
17
+ def self.for(mode, tty:, env:, theme: Theme.default)
18
+ new(enabled: enabled?(mode, tty:, env:), theme:)
19
+ end
20
+
21
+ def self.enabled?(mode, tty:, env:)
22
+ case mode
23
+ when 'always' then true
24
+ when 'never' then false
25
+ else auto?(tty:, env:)
26
+ end
27
+ end
28
+
29
+ def self.auto?(tty:, env:)
30
+ return false if set?(env['NO_COLOR'])
31
+ return true if FORCE_VARIABLES.any? { set?(env[it]) }
32
+ return false if env['TERM'] == 'dumb'
33
+
34
+ tty
35
+ end
36
+
37
+ def self.set?(value) = !value.to_s.empty?
38
+
39
+ private_class_method :enabled?, :auto?, :set?
40
+
41
+ def self.disabled = new(enabled: false, theme: Theme.default)
42
+
43
+ # Painting is how slipway's own escape sequences reach a stream. Text from anywhere else is
44
+ # made visible, so a commit subject, a git message or a manifest field can neither move the
45
+ # cursor nor paint a STATUS of its own: C0 characters and DEL take caret notation
46
+ # (ESC is ^[, DEL is ^?), the rest the replacement character. `layout` keeps line feeds and
47
+ # tabs, for a message laid out on several lines such as "Did you mean this?". Under the C
48
+ # locale Ruby hands ARGV, ENV and paths over as BINARY, which a UTF-8 pattern cannot match;
49
+ # reading the bytes as UTF-8 keeps the valid ones and scrubs the rest.
50
+ def self.plain(text, layout: false)
51
+ String.new(text.to_s, encoding: Encoding::UTF_8).scrub.gsub(CONTROL) do |char|
52
+ next char if layout && LAYOUT.include?(char)
53
+
54
+ char.ord < 0x80 ? "^#{((char.ord + 0x40) & 0x7F).chr}" : REPLACEMENT
55
+ end
56
+ end
57
+
58
+ def initialize(enabled:, theme:)
59
+ @enabled = enabled
60
+ @theme = theme
61
+ end
62
+
63
+ def enabled? = @enabled
64
+
65
+ def paint(role, text)
66
+ wrap(theme.sgr(role), text)
67
+ end
68
+
69
+ def paint_cycle(role, index, text)
70
+ wrap(theme.sgr_at(role, index), text)
71
+ end
72
+
73
+ private
74
+
75
+ def wrap(sgr, text)
76
+ return text.to_s unless @enabled
77
+
78
+ "\e[#{sgr}m#{text}\e[0m"
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slipway
4
+ module CLI
5
+ # The presets follow kubecolor's dark and light themes.
6
+ class Theme
7
+ # Roles whose value is a list are cycled by an index (column or nesting depth).
8
+ DARK = {
9
+ table_header: '1',
10
+ table_columns: %w[37 36],
11
+ describe_keys: %w[96 36],
12
+ explain_required: '31',
13
+ string: '93',
14
+ number: '35',
15
+ boolean_true: '32',
16
+ boolean_false: '31',
17
+ none: '90;3',
18
+ status_success: '32',
19
+ status_warning: '33',
20
+ status_danger: '31',
21
+ muted: '90;3',
22
+ help_header: '1',
23
+ help_flag: '36',
24
+ help_command: '32',
25
+ help_comment: '90;3',
26
+ apply_created: '32',
27
+ apply_configured: '33',
28
+ apply_unchanged: '35',
29
+ create_created: '32',
30
+ delete_deleted: '31',
31
+ label_labeled: '32',
32
+ label_unlabeled: '33',
33
+ label_not_labeled: '90;3',
34
+ result_changed: '32',
35
+ result_unchanged: '35',
36
+ result_skipped: '33',
37
+ result_paused: '90;3',
38
+ result_denied: '31',
39
+ result_failed: '31',
40
+ dry_run: '36',
41
+ error: '31',
42
+ warning: '33'
43
+ }.freeze
44
+
45
+ # White becomes black, cyan blue and bright yellow plain yellow, to read on a light background.
46
+ LIGHT = DARK.merge(
47
+ table_columns: %w[30 34],
48
+ describe_keys: %w[94 34],
49
+ string: '33',
50
+ help_flag: '34',
51
+ dry_run: '34'
52
+ ).freeze
53
+
54
+ PRESETS = { 'dark' => DARK, 'light' => LIGHT }.freeze
55
+ NAMES = PRESETS.keys.freeze
56
+ DEFAULT_NAME = 'dark'
57
+
58
+ attr_reader :name
59
+
60
+ # Callers check `name` against NAMES first, so an unknown name here is a bug.
61
+ def self.fetch(name)
62
+ new(name, PRESETS.fetch(name))
63
+ end
64
+
65
+ def self.default = fetch(DEFAULT_NAME)
66
+
67
+ def initialize(name, roles)
68
+ @name = name
69
+ @roles = roles
70
+ end
71
+
72
+ def sgr(role)
73
+ value = @roles.fetch(role)
74
+ raise ArgumentError, "role #{role.inspect} is a list; use sgr_at" if value.is_a?(Array)
75
+
76
+ value
77
+ end
78
+
79
+ def sgr_at(role, index)
80
+ values = Array(@roles.fetch(role))
81
+ values[index % values.size]
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slipway
4
+ module CLI
5
+ class Validator
6
+ def initialize(command, path, registry)
7
+ @command = command
8
+ @path = path
9
+ @registry = registry
10
+ end
11
+
12
+ def call(args, opts)
13
+ check_arity(args)
14
+ check_positional_enums(args)
15
+ check_required(opts)
16
+ check_option_enums(opts)
17
+ end
18
+
19
+ private
20
+
21
+ def check_arity(args)
22
+ if args.size < @command.min_args
23
+ missing = @command.positionals[args.size]
24
+ usage_error("missing required argument #{missing.name.inspect}")
25
+ end
26
+ max = @command.max_args
27
+ usage_error("unexpected argument #{args[max].inspect}") if max && args.size > max
28
+ end
29
+
30
+ def check_positional_enums(args)
31
+ args.each_with_index do |arg, index|
32
+ positional = @command.positional_at(index)
33
+ next unless positional&.enum && !positional.enum.include?(arg)
34
+
35
+ usage_error("invalid argument #{arg.inspect} for #{positional.name}: #{allowed(positional.enum)}")
36
+ end
37
+ end
38
+
39
+ def check_required(opts)
40
+ @command.options.select(&:required).each do |opt|
41
+ usage_error("required flag(s) #{"--#{opt.long}".inspect} not set") if opts[opt.key].nil?
42
+ end
43
+ end
44
+
45
+ def check_option_enums(opts)
46
+ (@registry.globals + @command.options).select(&:enum).each do |opt|
47
+ value = opts[opt.key]
48
+ next if value.nil? || opt.enum.include?(value)
49
+
50
+ usage_error("invalid argument #{value.inspect} for --#{opt.long}: #{allowed(opt.enum)}")
51
+ end
52
+ end
53
+
54
+ def allowed(values) = "must be one of #{values.join(', ')}"
55
+
56
+ def usage_error(message)
57
+ raise UsageError.new(message, hint: @registry.help_hint(@path))
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'did_you_mean'
4
+ require_relative 'cli/errors'
5
+ require_relative 'cli/theme'
6
+ require_relative 'cli/style'
7
+ require_relative 'cli/context'
8
+ require_relative 'cli/registry'
9
+ require_relative 'cli/globals'
10
+ require_relative 'cli/parser'
11
+ require_relative 'cli/validator'
12
+ require_relative 'cli/help_renderer'
13
+ require_relative 'cli/completer'
14
+ require_relative 'cli/completion_scripts'
15
+ require_relative 'cli/manpage'
16
+ require_relative 'cli/builtins'
17
+ require_relative 'cli/runner'
18
+
19
+ module Slipway
20
+ module CLI
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+ require_relative 'paths'
5
+
6
+ module Slipway
7
+ # The git command lines slipway prints for the user to run. Slipway never runs them.
8
+ module CommandLine
9
+ # Joins `words` as given, so a literal such as @{upstream}..HEAD prints as it is typed; a
10
+ # caller quotes any word a manifest or git supplied, as Plan does.
11
+ def self.git(project, *words) = ['git', '-C', path(project.path), *words].join(' ')
12
+
13
+ # The path as the manifest writes it, so the line matches what was registered. Only the part
14
+ # after ~ is quoted, since a shell does not expand a quoted ~.
15
+ def self.path(path)
16
+ rest = path.sub(Paths::TILDE, '')
17
+ return Shellwords.escape(path) if rest == path
18
+
19
+ rest.empty? ? '~' : "~#{Shellwords.escape(rest)}"
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Slipway
6
+ module Commands
7
+ class APIResources < Base
8
+ DESCRIPTION = "Print the supported resource types.\n\n" \
9
+ 'Prints a table of the resource types slipway knows, sorted by name. A command accepts a type ' \
10
+ 'by its NAME, its singular or one of its SHORTNAMES. Type words are case-insensitive. Use -o ' \
11
+ 'wide to add the verbs that act on each type, and -o name to print only the names.'
12
+ COLUMNS = {
13
+ 'NAME' => 'The name of the type, in the plural.',
14
+ 'SHORTNAMES' => 'The aliases of the type, besides its singular.',
15
+ 'KIND' => 'The kind a manifest of the type declares.',
16
+ 'GROUPED' => 'Whether resources of the type live in a group, so that -n and -A scope them. kubectl\'s ' \
17
+ 'NAMESPACED column says the same about namespaces.',
18
+ 'VERBS' => 'The commands that act on resources of the type.'
19
+ }.freeze
20
+ WIDE_HEADERS = COLUMNS.keys.freeze
21
+ HEADERS = (WIDE_HEADERS - ['VERBS']).freeze
22
+ GLOSSARIES = [
23
+ CLI::Glossary.new(title: 'Columns',
24
+ intro: 'Every type shows NAME, SHORTNAMES, KIND and GROUPED, and -o wide adds VERBS. A ' \
25
+ "type without aliases reads #{Output::Table::NONE} in SHORTNAMES.",
26
+ entries: COLUMNS)
27
+ ].freeze
28
+ OUTPUT = Options::OUTPUT.with(enum: [Output::TABLE, Output::WIDE, Output::NAME])
29
+
30
+ def self.command(factory)
31
+ CLI::Command.new(
32
+ name: 'api-resources', summary: 'Print the supported resource types', section: 'Other Commands',
33
+ description: DESCRIPTION, examples:, glossaries: GLOSSARIES, options: [OUTPUT, Options::NO_HEADERS],
34
+ handler: new(factory)
35
+ )
36
+ end
37
+
38
+ def self.examples
39
+ [
40
+ CLI::Example.new(comment: 'Print the supported resource types', command: 'api-resources'),
41
+ CLI::Example.new(comment: 'Print the supported resource types with more information',
42
+ command: 'api-resources -o wide'),
43
+ CLI::Example.new(comment: 'Print the names of the supported resource types', command: 'api-resources -o name')
44
+ ]
45
+ end
46
+ private_class_method :examples
47
+
48
+ def run(_runtime, context, _args, opts)
49
+ types = Resources::KINDS.sort_by(&:plural)
50
+ case opts[:output]
51
+ when Output::NAME then types.each { context.puts(it.plural) }
52
+ when Output::WIDE
53
+ verbs = verbs_by_kind
54
+ table(context, opts, WIDE_HEADERS, types.map { [*row(it), verbs.fetch(it)] })
55
+ else table(context, opts, HEADERS, types.map { row(it) })
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def row(kind) = [kind.plural, kind.aliases.join(','), kind.title, kind.namespaced?]
62
+
63
+ # The registry that holds this verb is built after it, so the verbs are built again from
64
+ # VERBS to read what each one acts on.
65
+ def verbs_by_kind
66
+ commands = VERBS.map { it.command(@factory) }
67
+ Resources::KINDS.to_h { |kind| [kind, commands.select { acts_on?(it, kind) }.map(&:name).sort.join(',')] }
68
+ end
69
+
70
+ # A group such as rollout acts on a kind when one of its subcommands does.
71
+ def acts_on?(command, kind)
72
+ return command.subcommands.any? { acts_on?(it, kind) } if command.group?
73
+
74
+ command.handler.kinds.include?(kind)
75
+ end
76
+
77
+ def table(context, opts, headers, rows)
78
+ Output::Table.new(context, headers:, show_headers: !opts[:no_headers]).print(rows)
79
+ end
80
+ end
81
+ end
82
+ end