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,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'registry'
4
+ require_relative 'style'
5
+
6
+ module Slipway
7
+ module CLI
8
+ # The Runner reads help, version and color from the parsed values by these keys, so a
9
+ # registry passes ALL, or .all, as its globals.
10
+ module Globals
11
+ # No default: a nil value tells a handler the flag was not typed, so SLIPWAY_COLOR and the
12
+ # config file can still decide. The Runner falls back to auto on its own.
13
+ COLOR = Option.new(long: 'color', argument: 'WHEN', optional: true, implicit: 'always', enum: Style::MODES,
14
+ description: 'When to use color in the output; a bare --color means always.')
15
+ GROUP = Option.new(long: 'group', short: 'n', argument: 'NAME', description: 'The group scope for this request.')
16
+ CONFIG = Option.new(long: 'config', argument: 'PATH', description: 'Path to the configuration file.')
17
+ HELP = Option.new(long: 'help', short: 'h', description: 'Print help and exit.')
18
+ VERSION = Option.new(long: 'version', short: 'V', description: 'Print the version and exit.')
19
+
20
+ ALL = [COLOR, GROUP, CONFIG, HELP, VERSION].freeze
21
+
22
+ def self.all(group_completer:)
23
+ ALL.map { it.equal?(GROUP) ? it.with(completer: group_completer) : it }.freeze
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slipway
4
+ module CLI
5
+ # Alignment is computed on plain text and color applied afterwards, so ANSI escapes
6
+ # never skew columns.
7
+ class HelpRenderer
8
+ COMMAND_COLUMN = 16
9
+ FLAG_COLUMN = 30
10
+ GAP = ' '
11
+ INDENT = ' '
12
+ FLAGS = '[flags]'
13
+ # Width of "-x, ", so long-only options line up with the long form of short ones.
14
+ SHORT_PREFIX = ' ' * 4
15
+
16
+ def initialize(registry, style)
17
+ @registry = registry
18
+ @style = style
19
+ end
20
+
21
+ def root
22
+ join([
23
+ @registry.root.description,
24
+ *command_sections(@registry.root),
25
+ option_section('Options', @registry.globals),
26
+ usage("#{@registry.program} #{FLAGS} COMMAND [ARGS...]"),
27
+ command_trailer([])
28
+ ])
29
+ end
30
+
31
+ def command(command, path)
32
+ join([
33
+ command.description,
34
+ *command.glossaries.map { glossary(it) },
35
+ exit_statuses(command.exit_statuses),
36
+ examples(command.examples),
37
+ *command_sections(command),
38
+ option_section('Options', command.options),
39
+ usage(command_usage(command, path)),
40
+ trailer(command, path)
41
+ ])
42
+ end
43
+
44
+ private
45
+
46
+ def join(sections) = "#{sections.compact.join("\n\n")}\n"
47
+
48
+ def header(text) = @style.paint(:help_header, "#{text}:")
49
+
50
+ def command_sections(command)
51
+ command.sections.map do |section, commands|
52
+ width = [COMMAND_COLUMN, *commands.map { it.name.size + GAP.size }].max
53
+ rows = commands.map { "#{INDENT}#{it.name.ljust(width)}#{it.summary}" }
54
+ "#{header(section)}\n#{rows.join("\n")}"
55
+ end
56
+ end
57
+
58
+ def exit_statuses(statuses)
59
+ return nil if statuses.empty?
60
+
61
+ "#{header('Exit Status')}\n#{terms(statuses)}"
62
+ end
63
+
64
+ def glossary(glossary)
65
+ intro = glossary.intro && "#{INDENT}#{glossary.intro}\n\n"
66
+ "#{header(glossary.title)}\n#{intro}#{terms(glossary.entries)}"
67
+ end
68
+
69
+ def terms(entries)
70
+ width = entries.keys.map { it.size + GAP.size }.max
71
+ entries.map { |term, meaning| "#{INDENT}#{term.ljust(width)}#{meaning}" }.join("\n")
72
+ end
73
+
74
+ def examples(examples)
75
+ return nil if examples.empty?
76
+
77
+ blocks = examples.map do |example|
78
+ "#{INDENT}#{@style.paint(:help_comment, "# #{example.comment}")}\n#{INDENT}#{shell_line(example.command)}"
79
+ end
80
+ "#{header('Examples')}\n#{blocks.join("\n\n")}"
81
+ end
82
+
83
+ def shell_line(command)
84
+ program, *words = "#{@registry.program} #{command}".split
85
+ painted = words.map { it.start_with?('-') ? @style.paint(:help_flag, it) : it }
86
+ [@style.paint(:help_command, program), *painted].join(' ')
87
+ end
88
+
89
+ def option_section(title, options)
90
+ return nil if options.empty?
91
+
92
+ labels = options.map { padded_label(it) }
93
+ width = [labels.map(&:size).max, FLAG_COLUMN].min
94
+ rows = options.zip(labels).map { |option, label| option_row(option, label, width) }
95
+ "#{header(title)}\n#{rows.join("\n")}"
96
+ end
97
+
98
+ def padded_label(option) = option.short ? option.label : "#{SHORT_PREFIX}#{option.label}"
99
+
100
+ def option_row(option, label, width)
101
+ painted = paint_label(label)
102
+ text = option.description_parts.join(' ')
103
+ return "#{INDENT}#{painted}\n#{INDENT}#{' ' * width}#{GAP}#{text}" if label.size > width
104
+
105
+ "#{INDENT}#{painted}#{' ' * (width - label.size)}#{GAP}#{text}"
106
+ end
107
+
108
+ # The padding of a long-only label stays outside the escape sequence.
109
+ def paint_label(label)
110
+ stripped = label.lstrip
111
+ "#{label[0, label.size - stripped.size]}#{@style.paint(:help_flag, stripped)}"
112
+ end
113
+
114
+ def usage(line) = "#{header('Usage')}\n#{INDENT}#{line}"
115
+
116
+ # Required options come before the positionals, as kubectl writes `apply -f FILENAME`;
117
+ # `[flags]` is always there because the global options apply to every command.
118
+ def command_usage(command, path)
119
+ required = command.options.select(&:required).map { "#{it.switches.first} #{it.argument}" }
120
+ [@registry.program, *path, *required, command.usage_args, FLAGS].reject(&:empty?).join(' ')
121
+ end
122
+
123
+ def trailer(command, path)
124
+ lines = []
125
+ lines << command_trailer(path) if command.group?
126
+ lines << %(Use "#{@registry.program} --help" for a list of global options (applies to all commands).)
127
+ lines.join("\n")
128
+ end
129
+
130
+ def command_trailer(path)
131
+ %(Use "#{[@registry.program, *path].join(' ')} <command> --help" for more information about a given command.)
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slipway
4
+ module CLI
5
+ module Roff
6
+ BULLET = /\A\s*\*\s+/
7
+
8
+ module_function
9
+
10
+ # Backslashes become \e and hyphens \- so options stay searchable; a line that would
11
+ # start with a control character is neutralized with \&.
12
+ def text(value)
13
+ value.to_s.gsub('\\', '\e').gsub('-', '\-').sub(/\A(?=[.'])/) { '\&' }
14
+ end
15
+
16
+ def argument(value)
17
+ escaped = text(value).gsub('"', '\(dq')
18
+ escaped.include?(' ') ? %("#{escaped}") : escaped
19
+ end
20
+
21
+ def heading(title) = ".SH #{argument(title)}"
22
+
23
+ def subheading(title) = ".SS #{argument(title)}"
24
+
25
+ def bold(value) = "\\fB#{text(value)}\\fR"
26
+
27
+ def italic(value) = "\\fI#{text(value)}\\fR"
28
+
29
+ # The first paragraph follows the heading directly, since .PP right after .SH is a lint
30
+ # error. Leading spaces, which the terminal help keeps, are dropped because roff would
31
+ # break the line on them.
32
+ def paragraphs(value)
33
+ value.to_s.split(/\n{2,}/).flat_map.with_index do |paragraph, index|
34
+ lines = paragraph.lines(chomp: true).flat_map { line(it) }
35
+ index.zero? || lines.first.start_with?('.IP') ? lines : ['.PP', *lines]
36
+ end
37
+ end
38
+
39
+ def line(value)
40
+ return [text(value.lstrip)] unless BULLET.match?(value)
41
+
42
+ ['.IP \(bu 2', text(value.sub(BULLET, ''))]
43
+ end
44
+
45
+ # `indent` is in ens.
46
+ def tagged(label, description, indent: nil) = [indent ? ".TP #{indent}" : '.TP', label, *paragraphs(description)]
47
+
48
+ def reference(name) = ".BR #{text(name)} (1)"
49
+ end
50
+
51
+ # The date is passed in so the output is reproducible.
52
+ class Manpage
53
+ SECTION = '1'
54
+ # `source` fills the fourth .TH field. The root page's ENVIRONMENT, FILES, CONFIGURATION and
55
+ # EXIT STATUS sections come from the caller, each a Hash of a variable, a path, a config
56
+ # file key or a status to its meaning, because what they describe belongs to the program; an
57
+ # empty Hash leaves its section out.
58
+ def initialize(registry, date:, source: nil, environment: {}, files: {}, configuration: {}, exit_statuses: {})
59
+ @registry = registry
60
+ @date = date
61
+ @source = source || "#{registry.program} #{registry.version}"
62
+ @environment = environment
63
+ @files = files
64
+ @configuration = configuration
65
+ @exit_statuses = exit_statuses
66
+ end
67
+
68
+ def pages
69
+ paths.to_h { |path| [file_name(path), page(path)] }
70
+ end
71
+
72
+ def page(path)
73
+ command, = @registry.resolve(path)
74
+ lines = path.empty? ? root_page(command) : command_page(command, path)
75
+ "#{lines.join("\n")}\n"
76
+ end
77
+
78
+ private
79
+
80
+ def paths(command = @registry.root, path = [])
81
+ [path, *command.visible_subcommands.flat_map { paths(it, [*path, it.name]) }]
82
+ end
83
+
84
+ def file_name(path) = "#{page_name(path)}.#{SECTION}"
85
+
86
+ def page_name(path) = [@registry.program, *path].join('-')
87
+
88
+ # The .TH fields are quoted verbatim: an escaped date is one mandoc cannot parse.
89
+ def header(path)
90
+ manual = "#{@registry.program.capitalize} Manual"
91
+ fields = [page_name(path).upcase, SECTION, @date, @source, manual].map { %("#{it.gsub('"', '\\(dq')}") }
92
+ [%(.\\" Generated by #{@registry.program} #{@registry.version}. Do not edit.), ".TH #{fields.join(' ')}"]
93
+ end
94
+
95
+ def root_page(root)
96
+ [
97
+ *header([]),
98
+ *name_section([], @registry.description.delete_suffix('.')),
99
+ *root_synopsis,
100
+ '.SH DESCRIPTION', *Roff.paragraphs(root.description),
101
+ *commands_section(root),
102
+ *options_section(@registry.globals),
103
+ *tagged_section('ENVIRONMENT', @environment),
104
+ *tagged_section('FILES', @files, label: Roff.method(:italic)),
105
+ *tagged_section('CONFIGURATION', @configuration),
106
+ *tagged_section('EXIT STATUS', @exit_statuses), *own_exit_statuses,
107
+ *see_also(paths.drop(1))
108
+ ]
109
+ end
110
+
111
+ def command_page(command, path)
112
+ [
113
+ *header(path),
114
+ *name_section(path, command.summary),
115
+ *synopsis(command, path),
116
+ '.SH DESCRIPTION', *Roff.paragraphs(command.description),
117
+ *commands_section(command),
118
+ *options_section(command.options),
119
+ *command.glossaries.flat_map { glossary_section(it) },
120
+ *tagged_section('EXIT STATUS', command.exit_statuses),
121
+ *examples_section(command.examples),
122
+ *see_also(related(command, path))
123
+ ]
124
+ end
125
+
126
+ def root_synopsis
127
+ ['.SH SYNOPSIS', ".SY #{Roff.argument(@registry.program)}", '.RI [ flags ]', '.I COMMAND', '.RI [ ARGS... ]\\&',
128
+ '.YS']
129
+ end
130
+
131
+ def name_section(path, summary)
132
+ ['.SH NAME', "#{Roff.text(page_name(path))} \\- #{Roff.text(summary)}"]
133
+ end
134
+
135
+ def synopsis(command, path)
136
+ ['.SH SYNOPSIS', ".SY #{Roff.argument([@registry.program, *path].join(' '))}", *synopsis_args(command), '.YS']
137
+ end
138
+
139
+ def synopsis_args(command)
140
+ required = command.options.select(&:required)
141
+ options = required.flat_map { [".B #{Roff.text(it.switches.first)}", ".I #{it.argument}"] }
142
+ [*options, *positional_args(command), '.RI [ flags ]']
143
+ end
144
+
145
+ def positional_args(command)
146
+ return ['.I COMMAND'] if command.group?
147
+ return [".I #{Roff.argument(command.usage)}\\&"] if command.usage
148
+
149
+ command.positionals.map { positional_arg(it) }
150
+ end
151
+
152
+ # Both forms end with \& so a trailing period is not read as the end of a sentence.
153
+ def positional_arg(positional)
154
+ token = Roff.text(positional.variadic ? "#{positional.name}..." : positional.name)
155
+ positional.required ? ".I #{token}\\&" : ".RI [ #{token} ]\\&"
156
+ end
157
+
158
+ def commands_section(command)
159
+ sections = command.sections
160
+ return [] if sections.empty?
161
+
162
+ width = command.visible_subcommands.map { it.name.size }.max + 2
163
+ entries = sections.flat_map do |section, commands|
164
+ heading = sections.size > 1 ? [Roff.subheading(section)] : []
165
+ heading + commands.flat_map { Roff.tagged(Roff.bold(it.name), it.summary, indent: width) }
166
+ end
167
+ ['.SH COMMANDS', *entries]
168
+ end
169
+
170
+ def options_section(options)
171
+ return [] if options.empty?
172
+
173
+ ['.SH OPTIONS', *options.flat_map { Roff.tagged(option_label(it), it.description_parts.join(' ')) }]
174
+ end
175
+
176
+ def option_label(option)
177
+ switches = option.switches.map { Roff.bold(it) }.join(', ')
178
+ return switches if option.flag?
179
+
180
+ argument = Roff.italic(option.argument)
181
+ option.optional ? "#{switches}[=#{argument}]" : "#{switches} #{argument}"
182
+ end
183
+
184
+ def examples_section(examples)
185
+ return [] if examples.empty?
186
+
187
+ blocks = examples.map do |example|
188
+ ['.EX', Roff.text("# #{example.comment}"), Roff.text("#{@registry.program} #{example.command}"), '.EE']
189
+ end
190
+ ['.SH EXAMPLES', *blocks.flat_map.with_index { |block, index| index.zero? ? block : ['.PP', *block] }]
191
+ end
192
+
193
+ def tagged_section(title, entries, label: Roff.method(:bold))
194
+ return [] if entries.empty?
195
+
196
+ [Roff.heading(title), *entries.flat_map { |key, meaning| Roff.tagged(label.call(key), meaning) }]
197
+ end
198
+
199
+ def glossary_section(glossary)
200
+ intro = glossary.intro ? Roff.paragraphs(glossary.intro) : []
201
+ [Roff.heading(glossary.title.upcase), *intro,
202
+ *glossary.entries.flat_map { |term, meaning| Roff.tagged(Roff.bold(term), meaning) }]
203
+ end
204
+
205
+ # A script author reads the root page for the exit statuses, so it names the pages that add
206
+ # to them.
207
+ def own_exit_statuses
208
+ pages = paths.drop(1).select { @registry.resolve(it).first.exit_statuses.any? }
209
+ return [] if pages.empty? || @exit_statuses.empty?
210
+
211
+ ['.PP', 'The pages of these commands list their own statuses:',
212
+ pages.map { Roff.reference(page_name(it)) }.join(",\n")]
213
+ end
214
+
215
+ # A command page points back at the root page and, when nested, at its group page; a group
216
+ # page also names the pages of its commands.
217
+ def related(command, path)
218
+ [[], *(path.size > 1 ? [path[0...-1]] : []), *command.visible_subcommands.map { [*path, it.name] }]
219
+ end
220
+
221
+ def see_also(paths)
222
+ [Roff.heading('SEE ALSO'), paths.map { Roff.reference(page_name(it)) }.join(",\n")]
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Slipway
6
+ module CLI
7
+ class Parser
8
+ def initialize(options, values)
9
+ @options = options
10
+ @values = values
11
+ end
12
+
13
+ # Stops at the first non-option word: `slipway [GLOBALS] VERB ...`.
14
+ def order!(argv) = parser.order!(argv)
15
+
16
+ # Interleaves options and positionals: `slipway get projects -o json alpha`.
17
+ def permute!(argv) = parser.permute!(argv)
18
+
19
+ def defaults
20
+ @options.each { |opt| @values[opt.key] = opt.default unless @values.key?(opt.key) }
21
+ @values
22
+ end
23
+
24
+ private
25
+
26
+ def parser
27
+ @parser ||= OptionParser.new do |o|
28
+ o.require_exact = true
29
+ drop_officious_completion(o)
30
+ @options.each do |opt|
31
+ o.on(*opt.switch_spec) { |raw| @values[opt.key] = opt.accept(@values[opt.key], raw) }
32
+ end
33
+ end
34
+ end
35
+
36
+ # OptionParser registers `--*-completion-bash=WORD` and `--*-completion-zsh` on every
37
+ # parser; both print to $stdout and call `exit` mid-parse, which bypasses the Context
38
+ # and the exit-code contract. `--help` and `--version` are shadowed by the registry's
39
+ # own switches; these two have no registry counterpart, so they go.
40
+ def drop_officious_completion(parser)
41
+ parser.base.long.delete_if { |name, _| name.start_with?('*-') }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slipway
4
+ module CLI
5
+ SECTION_ORDER = ['Basic Commands', 'Repository Commands', 'Settings Commands', 'Other Commands'].freeze
6
+
7
+ # `long` has no dashes, and a nil `argument` makes a boolean flag. An `optional` option
8
+ # takes its value only attached (--long=VALUE) and stores `implicit` when it is omitted.
9
+ # `completer` is called with the positional words typed so far and the word being completed;
10
+ # see Completer for what it returns.
11
+ Option = Data.define(:long, :short, :argument, :enum, :default, :description,
12
+ :repeatable, :required, :optional, :implicit, :completer) do
13
+ def initialize(long:, description:, short: nil, argument: nil, enum: nil, default: nil,
14
+ repeatable: false, required: false, optional: false, implicit: nil, completer: nil)
15
+ super
16
+ end
17
+
18
+ def key = long.tr('-', '_').to_sym
19
+
20
+ def flag? = argument.nil?
21
+
22
+ def switches = [short && "-#{short}", "--#{long}"].compact
23
+
24
+ def switch_spec
25
+ long_spec = if flag? then "--#{long}"
26
+ elsif optional then "--#{long}[=#{argument}]"
27
+ else "--#{long} #{argument}"
28
+ end
29
+ [short && "-#{short}", long_spec].compact
30
+ end
31
+
32
+ def label
33
+ text = switches.join(', ')
34
+ return text if flag?
35
+
36
+ optional ? "#{text}[=#{argument}]" : "#{text} #{argument}"
37
+ end
38
+
39
+ def description_parts
40
+ parts = [description]
41
+ parts << "One of: #{enum.join(', ')}." if enum
42
+ parts << "(default #{default.inspect})" unless default.nil? || default == false
43
+ parts << '(required)' if required
44
+ parts
45
+ end
46
+
47
+ def accept(current, raw)
48
+ return true if flag?
49
+ return implicit if optional && raw.nil?
50
+ return Array(current) << raw if repeatable
51
+
52
+ raw
53
+ end
54
+
55
+ def candidates(given = [], current = '') = enum || completer&.call(given, current) || []
56
+ end
57
+
58
+ Positional = Data.define(:name, :required, :variadic, :enum, :completer) do
59
+ def initialize(name:, required: true, variadic: false, enum: nil, completer: nil)
60
+ super
61
+ end
62
+
63
+ def usage
64
+ token = variadic ? "#{name}..." : name
65
+ required ? token : "[#{token}]"
66
+ end
67
+
68
+ def candidates(given = [], current = '') = enum || completer&.call(given, current) || []
69
+ end
70
+
71
+ # `command` omits the program name; help and man pages prepend it.
72
+ Example = Data.define(:comment, :command)
73
+
74
+ # A titled list of terms and what each means, such as the columns or the result words a command
75
+ # prints. `intro` is a paragraph printed above the terms.
76
+ Glossary = Data.define(:title, :intro, :entries) do
77
+ def initialize(title:, entries:, intro: nil) = super
78
+ end
79
+
80
+ # `handler` responds to call(context, args, opts). A `raw` command receives argv untouched,
81
+ # with no option parsing, which is what the completion endpoint needs. `usage` replaces
82
+ # the positional list in the Usage line when the accepted forms cannot be read off them.
83
+ # `exit_statuses` maps each status to its meaning, for a command whose statuses differ from
84
+ # the ones every command shares. `glossaries` are the Glossary sections help prints under the
85
+ # description and the man page renders after the options.
86
+ Command = Data.define(:name, :aliases, :summary, :description, :section, :examples, :positionals, :options,
87
+ :subcommands, :hidden, :raw, :handler, :usage, :exit_statuses, :glossaries) do
88
+ def initialize(name:, summary:, description: nil, aliases: [], section: 'Available Commands', examples: [],
89
+ positionals: [], options: [], subcommands: [], hidden: false, raw: false, handler: nil,
90
+ usage: nil, exit_statuses: {}, glossaries: [])
91
+ super(name:, summary:, description: description || summary, aliases:, section:, examples:,
92
+ positionals:, options:, subcommands:, hidden:, raw:, handler:, usage:, exit_statuses:, glossaries:)
93
+ end
94
+
95
+ def group? = !subcommands.empty?
96
+
97
+ def names = [name, *aliases]
98
+
99
+ def find(word) = subcommands.find { it.names.include?(word) }
100
+
101
+ def visible_subcommands = subcommands.reject(&:hidden)
102
+
103
+ def sections
104
+ visible_subcommands.group_by(&:section).sort_by.with_index do |(name, _), seen|
105
+ [SECTION_ORDER.index(name) || SECTION_ORDER.size, seen]
106
+ end
107
+ end
108
+
109
+ def positional_at(index) = positionals[index] || (positionals.last if positionals.last&.variadic)
110
+
111
+ def min_args = positionals.count(&:required)
112
+
113
+ def max_args = positionals.last&.variadic ? nil : positionals.size
114
+
115
+ def usage_args
116
+ return 'COMMAND' if group?
117
+
118
+ usage || positionals.map(&:usage).join(' ')
119
+ end
120
+ end
121
+
122
+ # The single source of truth for help, man pages, completion and dispatch.
123
+ class Registry
124
+ attr_reader :program, :version, :description, :globals, :root
125
+
126
+ # `long_description` replaces `description` on the root help and man page. A Hash
127
+ # `builtins` is passed on as options to the man builtin.
128
+ def initialize(program:, version:, description:, globals:, commands:, long_description: nil, builtins: true)
129
+ @program = program
130
+ @version = version
131
+ @description = description
132
+ @globals = globals
133
+ extra = builtins ? Builtins.all(program:, version:, resolve: -> { self }, **man_options(builtins)) : []
134
+ @root = Command.new(name: program, summary: description, description: long_description,
135
+ subcommands: commands + extra)
136
+ end
137
+
138
+ def resolve(words)
139
+ words.reduce([root, []]) do |(command, path), word|
140
+ nxt = command.find(word)
141
+ raise UsageError.new(unknown_command(word, path), hint: run_hint(path)) unless nxt
142
+
143
+ [nxt, [*path, word]]
144
+ end
145
+ end
146
+
147
+ def unknown_command(word, path)
148
+ message = "unknown command #{word.inspect} for #{[program, *path].join(' ').inspect}"
149
+ with_guesses(message, word, resolve(path).first.visible_subcommands.flat_map(&:names))
150
+ end
151
+
152
+ # OptionParser attaches "Did you mean?" only on its abbreviation-completing path, which
153
+ # require_exact disables, so suggestions for long switches come from the registry.
154
+ def unknown_option(word, options)
155
+ name = word.sub(/=.*/, '')
156
+ message = "unknown flag: #{name}"
157
+ return message unless name.start_with?('--')
158
+
159
+ with_guesses(message, name, options.map { "--#{it.long}" })
160
+ end
161
+
162
+ # kubectl words the hint for an unknown command with Run and every other one with See.
163
+ def help_hint(path) = "See '#{[program, *path, '--help'].join(' ')}' for usage."
164
+
165
+ def run_hint(path) = "Run '#{[program, *path, '--help'].join(' ')}' for usage."
166
+
167
+ private
168
+
169
+ def man_options(builtins) = builtins == true ? {} : builtins
170
+
171
+ def with_guesses(message, word, dictionary)
172
+ guesses = Suggest.similar(word, dictionary)
173
+ return message if guesses.empty?
174
+
175
+ "#{message}\n\nDid you mean this?\n#{guesses.map { "\t#{it}" }.join("\n")}\n\n"
176
+ end
177
+ end
178
+
179
+ # Cobra's rule for "Did you mean this?".
180
+ module Suggest
181
+ DISTANCE = 2
182
+
183
+ def self.similar(word, dictionary)
184
+ dictionary.select do |candidate|
185
+ DidYouMean::Levenshtein.distance(word, candidate) <= DISTANCE ||
186
+ candidate.start_with?(word) || word.start_with?(candidate)
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end