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.
- checksums.yaml +7 -0
- data/.yardopts +7 -0
- data/CHANGELOG.md +45 -0
- data/LICENSE.txt +21 -0
- data/README.md +1013 -0
- data/exe/slipway +10 -0
- data/lib/slipway/cli/builtins.rb +241 -0
- data/lib/slipway/cli/completer.rb +158 -0
- data/lib/slipway/cli/completion_scripts.rb +163 -0
- data/lib/slipway/cli/context.rb +67 -0
- data/lib/slipway/cli/errors.rb +19 -0
- data/lib/slipway/cli/globals.rb +27 -0
- data/lib/slipway/cli/help_renderer.rb +135 -0
- data/lib/slipway/cli/manpage.rb +226 -0
- data/lib/slipway/cli/parser.rb +45 -0
- data/lib/slipway/cli/registry.rb +191 -0
- data/lib/slipway/cli/runner.rb +186 -0
- data/lib/slipway/cli/style.rb +82 -0
- data/lib/slipway/cli/theme.rb +85 -0
- data/lib/slipway/cli/validator.rb +61 -0
- data/lib/slipway/cli.rb +22 -0
- data/lib/slipway/command_line.rb +22 -0
- data/lib/slipway/commands/api_resources.rb +82 -0
- data/lib/slipway/commands/apply.rb +172 -0
- data/lib/slipway/commands/base.rb +50 -0
- data/lib/slipway/commands/config.rb +73 -0
- data/lib/slipway/commands/create.rb +218 -0
- data/lib/slipway/commands/delete.rb +82 -0
- data/lib/slipway/commands/describe.rb +74 -0
- data/lib/slipway/commands/diff.rb +122 -0
- data/lib/slipway/commands/edit.rb +130 -0
- data/lib/slipway/commands/explain.rb +97 -0
- data/lib/slipway/commands/fetch.rb +112 -0
- data/lib/slipway/commands/from_dir.rb +141 -0
- data/lib/slipway/commands/get.rb +167 -0
- data/lib/slipway/commands/label.rb +114 -0
- data/lib/slipway/commands/manual.rb +67 -0
- data/lib/slipway/commands/options.rb +73 -0
- data/lib/slipway/commands/results.rb +57 -0
- data/lib/slipway/commands/rollout.rb +114 -0
- data/lib/slipway/commands/rollout_spec.rb +99 -0
- data/lib/slipway/commands/rollout_undo.rb +126 -0
- data/lib/slipway/commands/scope.rb +156 -0
- data/lib/slipway/commands/sync.rb +140 -0
- data/lib/slipway/commands.rb +54 -0
- data/lib/slipway/drift.rb +87 -0
- data/lib/slipway/editor.rb +71 -0
- data/lib/slipway/error.rb +27 -0
- data/lib/slipway/fetcher.rb +99 -0
- data/lib/slipway/field_selector.rb +86 -0
- data/lib/slipway/git/branch_name.rb +32 -0
- data/lib/slipway/git/commit.rb +13 -0
- data/lib/slipway/git/distance.rb +13 -0
- data/lib/slipway/git/errors.rb +125 -0
- data/lib/slipway/git/fake.rb +147 -0
- data/lib/slipway/git/fast_forward.rb +12 -0
- data/lib/slipway/git/fast_forwarding.rb +148 -0
- data/lib/slipway/git/fetch_result.rb +22 -0
- data/lib/slipway/git/move_back.rb +12 -0
- data/lib/slipway/git/reflog.rb +25 -0
- data/lib/slipway/git/repository.rb +288 -0
- data/lib/slipway/git/rolling_back.rb +98 -0
- data/lib/slipway/git/runner.rb +175 -0
- data/lib/slipway/git/status.rb +110 -0
- data/lib/slipway/git/url.rb +95 -0
- data/lib/slipway/git.rb +20 -0
- data/lib/slipway/inspector.rb +103 -0
- data/lib/slipway/labels.rb +126 -0
- data/lib/slipway/manifest.rb +265 -0
- data/lib/slipway/names.rb +22 -0
- data/lib/slipway/outcome.rb +45 -0
- data/lib/slipway/output/age.rb +70 -0
- data/lib/slipway/output/describe.rb +71 -0
- data/lib/slipway/output/explain.rb +75 -0
- data/lib/slipway/output/serializer.rb +35 -0
- data/lib/slipway/output/table.rb +67 -0
- data/lib/slipway/output.rb +28 -0
- data/lib/slipway/paths.rb +65 -0
- data/lib/slipway/plan.rb +227 -0
- data/lib/slipway/pool.rb +94 -0
- data/lib/slipway/resources.rb +91 -0
- data/lib/slipway/rollback.rb +236 -0
- data/lib/slipway/rollout_history.rb +69 -0
- data/lib/slipway/runtime.rb +65 -0
- data/lib/slipway/scanner.rb +54 -0
- data/lib/slipway/schema.rb +128 -0
- data/lib/slipway/selector.rb +146 -0
- data/lib/slipway/settings.rb +174 -0
- data/lib/slipway/state.rb +82 -0
- data/lib/slipway/store.rb +170 -0
- data/lib/slipway/syncer.rb +139 -0
- data/lib/slipway/version.rb +5 -0
- data/lib/slipway/views/group.rb +35 -0
- data/lib/slipway/views/project.rb +148 -0
- data/lib/slipway/views.rb +10 -0
- data/lib/slipway/yaml.rb +14 -0
- data/lib/slipway.rb +32 -0
- data/man/man1/slipway-api-resources.1 +53 -0
- data/man/man1/slipway-apply.1 +45 -0
- data/man/man1/slipway-completion.1 +29 -0
- data/man/man1/slipway-config-path.1 +20 -0
- data/man/man1/slipway-config-view.1 +25 -0
- data/man/man1/slipway-config.1 +22 -0
- data/man/man1/slipway-create.1 +89 -0
- data/man/man1/slipway-delete.1 +46 -0
- data/man/man1/slipway-describe.1 +95 -0
- data/man/man1/slipway-diff.1 +120 -0
- data/man/man1/slipway-edit.1 +34 -0
- data/man/man1/slipway-explain.1 +36 -0
- data/man/man1/slipway-fetch.1 +77 -0
- data/man/man1/slipway-get.1 +155 -0
- data/man/man1/slipway-help.1 +19 -0
- data/man/man1/slipway-label.1 +53 -0
- data/man/man1/slipway-man.1 +36 -0
- data/man/man1/slipway-rollout-history.1 +28 -0
- data/man/man1/slipway-rollout-pause.1 +21 -0
- data/man/man1/slipway-rollout-resume.1 +21 -0
- data/man/man1/slipway-rollout-undo.1 +73 -0
- data/man/man1/slipway-rollout-unpin.1 +21 -0
- data/man/man1/slipway-rollout.1 +36 -0
- data/man/man1/slipway-sync.1 +90 -0
- data/man/man1/slipway-version.1 +17 -0
- data/man/man1/slipway.1 +243 -0
- 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
|