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,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative '../manifest'
5
+ require_relative '../store'
6
+
7
+ module Slipway
8
+ module Commands
9
+ class Apply < Base
10
+ # Raised once, after every document that could be applied was.
11
+ class Failed < Error; end
12
+
13
+ DESCRIPTION = "Apply a configuration to a resource by file name or stdin.\n\n" \
14
+ 'The resource name must be specified in the manifest. A resource is created when it does ' \
15
+ 'not exist yet, configured when its manifest differs from the one stored, and reported ' \
16
+ 'unchanged otherwise. A directory applies every *.yaml and *.yml file it holds, sorted by ' \
17
+ 'name and without descending into subdirectories. A project manifest without ' \
18
+ "metadata.group lands in the current group.\n\n" \
19
+ 'YAML is accepted, with several documents per file, and a document of kind List stands ' \
20
+ 'for each of its items. Documents are applied in order, so a group may be created by the ' \
21
+ 'document before the projects that use it.'
22
+ STDIN_FLAG = '-'
23
+ NO_OBJECTS = 'no objects passed to apply'
24
+
25
+ FILENAME = CLI::Option.new(long: 'filename', short: 'f', argument: 'FILE', repeatable: true, required: true,
26
+ completer: ->(_given, _current) { CLI::Completer::FILES },
27
+ description: 'The file that contains the manifests to apply; may be repeated. ' \
28
+ "A directory reads its *.yaml and *.yml files, '-' reads stdin.")
29
+
30
+ def self.command(factory)
31
+ CLI::Command.new(
32
+ name: 'apply', summary: 'Apply a configuration to a resource by file name or stdin',
33
+ section: 'Basic Commands', description: DESCRIPTION, examples:,
34
+ options: [FILENAME, Options::DRY_RUN],
35
+ handler: new(factory)
36
+ )
37
+ end
38
+
39
+ def self.examples
40
+ [
41
+ CLI::Example.new(comment: 'Apply the configuration in hldr.yaml to a project',
42
+ command: 'apply -f hldr.yaml'),
43
+ CLI::Example.new(comment: 'Apply every manifest in a directory', command: 'apply -f ./projects'),
44
+ CLI::Example.new(comment: 'Apply the YAML passed into stdin', command: 'apply -f - < hldr.yaml'),
45
+ CLI::Example.new(comment: 'Show what would change without writing anything',
46
+ command: 'apply -f hldr.yaml --dry-run')
47
+ ]
48
+ end
49
+ private_class_method :examples
50
+
51
+ def kinds = Resources::KINDS
52
+
53
+ def run(runtime, context, _args, opts)
54
+ dry_run = opts[:dry_run] == true
55
+ session = Session.new(runtime.store, default_group: scope(runtime, context, opts).group, dry_run:)
56
+ opts[:filename].each do |file|
57
+ session.apply(file) { |kind, name, word, role| result_line(context, kind, name, word, role, dry_run:) }
58
+ end
59
+ finish(session)
60
+ end
61
+
62
+ private
63
+
64
+ def finish(session)
65
+ raise Failed, NO_OBJECTS if session.problems.empty? && session.applied.zero?
66
+ raise Failed.new(problems: session.problems) unless session.problems.empty?
67
+ end
68
+
69
+ # A failed document is remembered in `problems` so the rest still run.
70
+ class Session
71
+ STDIN_SOURCE = 'STDIN'
72
+ EXTENSIONS = %w[.yaml .yml].freeze
73
+
74
+ attr_reader :problems, :applied
75
+
76
+ def initialize(store, default_group:, dry_run:)
77
+ @store = store
78
+ @default_group = default_group
79
+ @dry_run = dry_run
80
+ @problems = []
81
+ @applied = 0
82
+ @dry_run_groups = []
83
+ end
84
+
85
+ def apply(name, &)
86
+ read(name).each { |source, text| apply_stream(source, text, &) }
87
+ rescue Error => e
88
+ @problems << e.message
89
+ end
90
+
91
+ private
92
+
93
+ # The Context carries no input stream, so `-` reads the process's standard input.
94
+ def read(name)
95
+ return [[STDIN_SOURCE, $stdin.read]] if name == STDIN_FLAG
96
+ return directory(name) if File.directory?(name)
97
+
98
+ [[name, File.read(name)]]
99
+ rescue Errno::ENOENT
100
+ raise Error, "#{name}: no such file"
101
+ rescue SystemCallError => e
102
+ raise Error.from_system_call(e, name)
103
+ end
104
+
105
+ def directory(name)
106
+ files = Dir.children(name).select { EXTENSIONS.include?(File.extname(it)) }.sort
107
+ files = files.map { File.join(name, it) }.select { File.file?(it) }
108
+ raise Error, "#{name}: no .yaml or .yml files" if files.empty?
109
+
110
+ files.map { [it, File.read(it)] }
111
+ end
112
+
113
+ def apply_stream(source, text, &)
114
+ documents = Manifest.load_objects(text, source:)
115
+ documents.each_with_index do |document, index|
116
+ label = documents.size > 1 ? "#{source}:#{index + 1}" : source
117
+ collect(label) { apply_resource(Manifest.parse(document, source: label, default_group: @default_group), &) }
118
+ end
119
+ end
120
+
121
+ # A problem the manifest reader found already names its source; a store error is
122
+ # prefixed with it so every collected line reads `<source>: <problem>`.
123
+ def collect(source)
124
+ yield
125
+ rescue Manifest::Invalid => e
126
+ @problems << e.message
127
+ rescue Error => e
128
+ @problems << "#{source}: #{e.message}"
129
+ end
130
+
131
+ def apply_resource(resource)
132
+ kind = Resources.of(resource)
133
+ group = kind.namespaced? ? resource.group : nil
134
+ existing = @store.find(kind, resource.name, group:) if @store.exist?(kind, resource.name, group:)
135
+ word, role = existing ? update(existing, resource) : create(kind, resource)
136
+ @applied += 1
137
+ yield kind, resource.name, word, role
138
+ end
139
+
140
+ # The stored creationTimestamp is kept; anything else that differs is a change.
141
+ def update(existing, incoming)
142
+ incoming = incoming.with(created_at: existing.created_at)
143
+ return ['unchanged', :apply_unchanged] if Manifest.dump(existing) == Manifest.dump(incoming)
144
+
145
+ @store.save(incoming) unless @dry_run
146
+ ['configured', :apply_configured]
147
+ end
148
+
149
+ def create(kind, resource)
150
+ if @dry_run
151
+ check_group(resource) if kind.namespaced?
152
+ @dry_run_groups << resource.name unless kind.namespaced?
153
+ else
154
+ @store.create(resource)
155
+ end
156
+ ['created', :apply_created]
157
+ end
158
+
159
+ # A dry run writes nothing, so a group created earlier in the same run is remembered
160
+ # here for the projects that follow it.
161
+ def check_group(project)
162
+ group = project.group
163
+ return if @dry_run_groups.include?(group) || @store.group_available?(group)
164
+
165
+ raise Store::NotFound.of(Resources::GROUPS, group)
166
+ end
167
+ end
168
+
169
+ private_constant :Session
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../cli'
4
+ require_relative '../resources'
5
+ require_relative '../output'
6
+ require_relative 'options'
7
+ require_relative 'scope'
8
+
9
+ module Slipway
10
+ module Commands
11
+ # Subclasses define `self.command(factory)`, the registry entry whose handler is an
12
+ # instance, and `run(runtime, context, args, opts)`, which raises Slipway::Error to fail.
13
+ class Base
14
+ # Prints the warnings here so neither read verb can forget them.
15
+ def self.examine(runtime, context, resources)
16
+ batch = runtime.inspector.examine_all(resources)
17
+ batch.warnings.each { Output.warning(context, it) }
18
+ batch.inspections
19
+ end
20
+
21
+ # Shared by the verbs that print one line per resource, so the name is neutralized whichever
22
+ # of them prints it.
23
+ def self.result_text(context, kind, name, verb_word, role, reason: nil, dry_run: false)
24
+ line = "#{kind.singular}/#{Output.plain(name)} #{context.paint(role, verb_word)}"
25
+ line = "#{line} (#{reason})" if reason
26
+ dry_run ? "#{line} #{context.paint(:dry_run, '(dry run)')}" : line
27
+ end
28
+
29
+ def initialize(factory)
30
+ @factory = factory
31
+ end
32
+
33
+ def call(context, args, opts)
34
+ runtime = @factory.call(context, opts)
35
+ run(runtime, context, args, opts)
36
+ end
37
+
38
+ # The resource types the verb acts on, the ones api-resources lists it under.
39
+ def kinds = []
40
+
41
+ private
42
+
43
+ def scope(runtime, context, opts) = Scope.new(runtime, context, opts)
44
+
45
+ def result_line(context, kind, name, verb_word, role, dry_run: false)
46
+ context.puts(Base.result_text(context, kind, name, verb_word, role, dry_run:))
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative '../yaml'
5
+
6
+ module Slipway
7
+ module Commands
8
+ module Config
9
+ DESCRIPTION = 'Inspect the configuration that slipway resolved from flags, environment variables and ' \
10
+ 'the configuration file.'
11
+ NOT_FOUND = ' (not found)'
12
+
13
+ def self.command(factory)
14
+ CLI::Command.new(
15
+ name: 'config', summary: 'Inspect the configuration in effect', section: 'Settings Commands',
16
+ description: DESCRIPTION, subcommands: [View.command(factory), Path.command(factory)]
17
+ )
18
+ end
19
+
20
+ class View < Base
21
+ DESCRIPTION = "Display the configuration in effect.\n\n" \
22
+ 'Prints every setting as YAML after applying the precedence flag, then SLIPWAY_* ' \
23
+ 'environment variable, then configuration file, then built-in default. The first line ' \
24
+ 'names the configuration file that was consulted, and says (not found) when the default ' \
25
+ 'file does not exist. A file named by --config or SLIPWAY_CONFIG must exist.'
26
+
27
+ def self.command(factory)
28
+ CLI::Command.new(
29
+ name: 'view', summary: 'Display the configuration in effect', description: DESCRIPTION,
30
+ examples: [
31
+ CLI::Example.new(comment: 'Show the settings in effect', command: 'config view'),
32
+ CLI::Example.new(comment: 'Show the settings another file would give',
33
+ command: 'config view --config ~/work/slipway.yaml')
34
+ ],
35
+ handler: new(factory)
36
+ )
37
+ end
38
+
39
+ def run(runtime, context, _args, _opts)
40
+ settings = runtime.settings
41
+ comment = "# #{settings.path}"
42
+ comment += NOT_FOUND unless settings.exists?
43
+ context.puts(context.paint(:muted, comment))
44
+ context.print(Yaml.dump(settings.to_h))
45
+ end
46
+ end
47
+
48
+ class Path < Base
49
+ DESCRIPTION = "Display the path of the configuration file.\n\n" \
50
+ 'Prints the file that slipway reads: --config, then SLIPWAY_CONFIG, then ' \
51
+ '$XDG_CONFIG_HOME/slipway/config.yaml. When the default file does not exist, its path is ' \
52
+ 'still printed and a note on stderr says so; a file named by --config or SLIPWAY_CONFIG ' \
53
+ 'must exist.'
54
+ MISSING = 'The file does not exist; slipway uses its defaults.'
55
+
56
+ def self.command(factory)
57
+ CLI::Command.new(
58
+ name: 'path', summary: 'Display the path of the configuration file', description: DESCRIPTION,
59
+ examples: [CLI::Example.new(comment: 'Print the path of the configuration file', command: 'config path')],
60
+ handler: new(factory)
61
+ )
62
+ end
63
+
64
+ # stdout stays the bare path so "$(slipway config path)" can open the file before it exists.
65
+ def run(runtime, context, _args, _opts)
66
+ settings = runtime.settings
67
+ context.puts(settings.path)
68
+ context.warn(context.paint_err(:muted, MISSING)) unless settings.exists?
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative 'from_dir'
5
+ require_relative '../git/branch_name'
6
+ require_relative '../git/url'
7
+ require_relative '../labels'
8
+ require_relative '../scanner'
9
+ require_relative '../store'
10
+
11
+ module Slipway
12
+ module Commands
13
+ class Create < Base
14
+ DESCRIPTION = "Create a resource by name.\n\n" \
15
+ 'A project registers the git repository at --path in the current group, which must ' \
16
+ 'exist unless it is the default group. A group is created empty and holds the projects ' \
17
+ "you register with --group later.\n\n" \
18
+ 'With --from-dir DIR in place of NAME, create project registers every git repository at DIR ' \
19
+ 'or under it, down to --depth levels. Each project is named after its directory, lowercased ' \
20
+ 'with each run of characters other than ASCII letters, digits and dashes made one dash, and ' \
21
+ 'records its path and the URL of its origin without any credentials; the checked-out branch ' \
22
+ 'is not recorded. A path the group already holds is reported unchanged, so running it again ' \
23
+ "adds only new clones.\n\n" \
24
+ 'Use --dry-run to check the arguments without writing anything, and -o yaml with it ' \
25
+ "to print the manifest instead, ready for apply -f.\n\n" \
26
+ "#{Options::TYPES_SENTENCE}".freeze
27
+ PATH_MISSING = 'required flag(s) "--path" not set'
28
+ PATH_EMPTY = 'flag --path must not be empty'
29
+ PROJECT_ONLY = 'flag --%s applies to projects only'
30
+ PROJECT_FLAGS = %i[path remote branch].freeze
31
+ EITHER = 'give either NAME or --from-dir, not both'
32
+ NOT_WITH_FROM_DIR = 'flag --%s cannot be used with --from-dir'
33
+ FROM_DIR_EXCLUSIVE = %i[path description remote branch].freeze
34
+ FROM_DIR_EMPTY = 'flag --from-dir must not be empty'
35
+ DEPTHS = 1..Scanner::MAX_DEPTH
36
+ DEPTH_INVALID = "invalid argument %p for --depth: must be an integer from #{DEPTHS.min} to #{DEPTHS.max}".freeze
37
+ DEPTH_WITHOUT_FROM_DIR = 'flag --depth requires --from-dir'
38
+ USAGE = '(TYPE NAME | project --from-dir DIR)'
39
+
40
+ # The resource as written, or as it would be on a dry run, and the word and role of its result line.
41
+ Result = Data.define(:resource, :word, :role)
42
+
43
+ PATH = CLI::Option.new(long: 'path', argument: 'DIR',
44
+ description: 'Directory of the git repository to register; required for a project ' \
45
+ 'given by NAME. ' \
46
+ 'A relative directory is stored resolved against the current ' \
47
+ 'directory, a ~ path as written.')
48
+ TEXT = CLI::Option.new(long: 'description', argument: 'TEXT',
49
+ description: 'A short description of the resource.')
50
+ LABEL = CLI::Option.new(long: 'label', argument: 'KEY=VALUE', repeatable: true,
51
+ description: 'A label to set on the new resource; may be repeated.')
52
+ REMOTE = CLI::Option.new(long: 'remote', argument: 'URL',
53
+ description: 'The URL the origin remote is expected to have, written to spec.remote. ' \
54
+ 'A URL that embeds credentials is refused; use a credential helper.')
55
+ BRANCH = CLI::Option.new(long: 'branch', argument: 'NAME',
56
+ description: 'The branch the project is expected to have checked out, written to ' \
57
+ 'spec.branch.')
58
+ FROM_DIR = CLI::Option.new(long: 'from-dir', argument: 'DIR',
59
+ completer: ->(_given, _current) { CLI::Completer::FILES },
60
+ description: 'Register every git repository at or under DIR as a project, in ' \
61
+ 'place of NAME.')
62
+ # No option default: one would hide whether --depth was typed without --from-dir.
63
+ DEPTH = CLI::Option.new(long: 'depth', argument: 'N',
64
+ description: "How many directory levels under --from-dir to search, from #{DEPTHS.min} " \
65
+ "to #{DEPTHS.max}. The search stops at a repository and never follows " \
66
+ "a symbolic link under --from-dir. (default #{Scanner::DEFAULT_DEPTH})")
67
+ OUTPUT = CLI::Option.new(long: 'output', short: 'o', argument: 'FORMAT',
68
+ enum: [*Output::Serializer::STRUCTURED, Output::NAME],
69
+ description: 'Output format; without it, each resource prints a result line such as ' \
70
+ 'project/hldr created.')
71
+
72
+ def self.command(factory)
73
+ CLI::Command.new(
74
+ name: 'create', summary: 'Create a resource by name', section: 'Basic Commands',
75
+ description: DESCRIPTION, examples:, usage: USAGE,
76
+ positionals: [Options::TYPE, Options.name_positional(factory, variadic: false, required: false)],
77
+ options: [PATH, TEXT, LABEL, REMOTE, BRANCH, FROM_DIR, DEPTH, Options::DRY_RUN, OUTPUT],
78
+ handler: new(factory)
79
+ )
80
+ end
81
+
82
+ def self.examples = named_examples + directory_examples
83
+
84
+ def self.named_examples
85
+ [
86
+ CLI::Example.new(comment: 'Register the repository at ~/dev/hldr as a project in the current group',
87
+ command: "create project hldr --path '~/dev/hldr'"),
88
+ CLI::Example.new(comment: 'Register a project in the work group with two labels',
89
+ command: "create project api --path '~/work/api' -n work --label lang=go --label tier=api"),
90
+ CLI::Example.new(comment: 'Register a project with the remote and the branch it is expected to have',
91
+ command: "create project hldr --path '~/dev/hldr' " \
92
+ '--remote git@github.com:hvpaiva/hldr.git --branch main'),
93
+ CLI::Example.new(comment: 'Create a group with a description',
94
+ command: 'create group work --description "Projects for the day job"'),
95
+ CLI::Example.new(comment: 'Check the arguments without writing the project',
96
+ command: "create project hldr --path '~/dev/hldr' --dry-run"),
97
+ CLI::Example.new(comment: 'Print the manifest of a project without registering it',
98
+ command: "create project hldr --path '~/dev/hldr' --dry-run -o yaml")
99
+ ]
100
+ end
101
+
102
+ def self.directory_examples
103
+ [
104
+ CLI::Example.new(comment: 'Register every repository in ~/dev/personal in the personal group',
105
+ command: 'create project --from-dir ~/dev/personal -n personal'),
106
+ CLI::Example.new(comment: 'Write the manifests of the repositories two levels under ~/work to a file',
107
+ command: 'create project --from-dir ~/work --depth 2 --dry-run -o yaml > work.yaml')
108
+ ]
109
+ end
110
+ private_class_method :examples, :named_examples, :directory_examples
111
+
112
+ def kinds = Resources::KINDS
113
+
114
+ def run(runtime, context, args, opts)
115
+ scope = scope(runtime, context, opts)
116
+ kind, names = scope.targets(args)
117
+ return register_directory(runtime, context, scope, opts) if from_dir?(kind, names, opts)
118
+ raise CLI::UsageError, DEPTH_WITHOUT_FROM_DIR unless opts[:depth].nil?
119
+
120
+ _, name = scope.target(args)
121
+ resource = build(kind, name, scope.group, opts)
122
+ resource = dry_run?(opts) ? check(runtime.store, kind, resource) : runtime.store.create(resource)
123
+ report(context, kind, [Result.new(resource, 'created', :create_created)], opts, single: true)
124
+ end
125
+
126
+ private
127
+
128
+ def dry_run?(opts) = opts[:dry_run] == true
129
+
130
+ # NAME and --from-dir are the two ways to say which project to register; a group has only NAME.
131
+ def from_dir?(kind, names, opts)
132
+ return false if opts[:from_dir].nil?
133
+ raise CLI::UsageError, format(PROJECT_ONLY, 'from-dir') unless kind.namespaced?
134
+ raise CLI::UsageError, EITHER unless names.empty?
135
+
136
+ true
137
+ end
138
+
139
+ def register_directory(runtime, context, scope, opts)
140
+ check_directory_flags(opts)
141
+ labels = Labels.parse_pairs(opts[:label] || [])
142
+ registrar = FromDir.new(runtime, context, group: scope.group, labels:, dry_run: dry_run?(opts))
143
+ entries = registrar.register(opts[:from_dir], depth: depth(opts[:depth] || Scanner::DEFAULT_DEPTH))
144
+ report(context, Resources::PROJECTS, entries.map { result_of(it) }, opts, single: false)
145
+ raise Error.new(problems: registrar.problems) unless registrar.problems.empty?
146
+ end
147
+
148
+ def check_directory_flags(opts)
149
+ flag = FROM_DIR_EXCLUSIVE.find { !opts[it].nil? }
150
+ raise CLI::UsageError, format(NOT_WITH_FROM_DIR, flag) if flag
151
+ raise CLI::UsageError, FROM_DIR_EMPTY if opts[:from_dir].strip.empty?
152
+ end
153
+
154
+ def depth(value)
155
+ depth = Integer(value.to_s, 10, exception: false)
156
+ DEPTHS.cover?(depth) ? depth : raise(CLI::UsageError, format(DEPTH_INVALID, value.to_s))
157
+ end
158
+
159
+ def result_of(entry)
160
+ if entry.created then Result.new(entry.project, 'created', :create_created)
161
+ else Result.new(entry.project, 'unchanged', :apply_unchanged)
162
+ end
163
+ end
164
+
165
+ def report(context, kind, results, opts, single:)
166
+ dry_run = dry_run?(opts)
167
+ case opts[:output]
168
+ when nil then results.each { result_line(context, kind, it.resource.name, it.word, it.role, dry_run:) }
169
+ when Output::NAME then results.each { context.puts("#{kind.singular}/#{it.resource.name}") }
170
+ else context.print(Output::Serializer.render(opts[:output], results.map { it.resource.to_manifest }, single:))
171
+ end
172
+ end
173
+
174
+ def build(kind, name, group, opts)
175
+ labels = Labels.parse_pairs(opts[:label] || [])
176
+ return project(name, group, labels, opts) if kind.namespaced?
177
+
178
+ flag = PROJECT_FLAGS.find { !opts[it].nil? }
179
+ raise CLI::UsageError, format(PROJECT_ONLY, flag) if flag
180
+
181
+ Group.new(name:, labels:, description: opts[:description])
182
+ end
183
+
184
+ def project(name, group, labels, opts)
185
+ Project.new(name:, group:, labels:, path: project_path(opts[:path]), description: opts[:description],
186
+ remote: opts[:remote] && usage { Git::Url.validate!(opts[:remote], field: 'flag --remote') },
187
+ branch: opts[:branch] && usage { Git::BranchName.validate!(opts[:branch]) })
188
+ end
189
+
190
+ # A manifest has no working directory, so a relative --path is resolved here, against
191
+ # the directory the command was typed in; a ~ path stays portable as written.
192
+ def project_path(path)
193
+ raise CLI::UsageError, PATH_MISSING if path.nil?
194
+ raise CLI::UsageError, PATH_EMPTY if path.strip.empty?
195
+ return path if path.start_with?('~') || File.absolute_path?(path)
196
+
197
+ File.absolute_path(path)
198
+ end
199
+
200
+ # The checks a manifest's spec.remote and spec.branch pass, so a refusal reads the same; typed on
201
+ # the command line, it is a usage error.
202
+ def usage
203
+ yield
204
+ rescue Git::Url::Invalid, Git::BranchName::Invalid => e
205
+ raise CLI::UsageError, e.message
206
+ end
207
+
208
+ # What a real create would reject before writing.
209
+ def check(store, kind, resource)
210
+ if kind.namespaced? && !store.group_available?(resource.group)
211
+ raise Store::NotFound.of(Resources::GROUPS, resource.group)
212
+ end
213
+
214
+ resource
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative '../store'
5
+
6
+ module Slipway
7
+ module Commands
8
+ class Delete < Base
9
+ DESCRIPTION = "Delete resources by type and name.\n\n" \
10
+ 'Every name is resolved before anything is deleted, so a name that does not exist leaves ' \
11
+ 'the others untouched unless --ignore-not-found is set. Deleting a group also deletes the ' \
12
+ "registrations of its projects; the default group cannot be deleted.\n\n" \
13
+ "Deleting a project removes its registration only. The repository on disk is not touched.\n\n" \
14
+ "#{Options::TYPES_SENTENCE}".freeze
15
+ USAGE = '(TYPE NAME... | TYPE/NAME...)'
16
+ NO_NAMES = 'resource(s) were provided, but no name was specified'
17
+
18
+ IGNORE_NOT_FOUND = CLI::Option.new(long: 'ignore-not-found',
19
+ description: 'Treat "resource not found" as a successful delete.')
20
+
21
+ def self.command(factory)
22
+ CLI::Command.new(
23
+ name: 'delete', summary: 'Delete resources by type and name', section: 'Basic Commands',
24
+ description: DESCRIPTION, examples:, usage: USAGE,
25
+ positionals: [Options::TYPE, Options.name_positional(factory, variadic: true, required: false)],
26
+ options: [Options::DRY_RUN, IGNORE_NOT_FOUND],
27
+ handler: new(factory)
28
+ )
29
+ end
30
+
31
+ def self.examples
32
+ [
33
+ CLI::Example.new(comment: 'Delete a project from the current group', command: 'delete project hldr'),
34
+ CLI::Example.new(comment: 'Delete two projects from the work group',
35
+ command: 'delete projects api web -n work'),
36
+ CLI::Example.new(comment: 'Delete a group and the registrations of its projects',
37
+ command: 'delete group work'),
38
+ CLI::Example.new(comment: 'Delete a project if it exists, quietly otherwise',
39
+ command: 'delete project hldr --ignore-not-found')
40
+ ]
41
+ end
42
+ private_class_method :examples
43
+
44
+ def kinds = Resources::KINDS
45
+
46
+ def run(runtime, context, args, opts)
47
+ scope = scope(runtime, context, opts)
48
+ kind, names = scope.targets(args)
49
+ raise CLI::UsageError, NO_NAMES if names.empty?
50
+
51
+ group = kind.namespaced? ? scope.group : nil
52
+ dry_run = opts[:dry_run] == true
53
+ resolve(runtime.store, kind, names.uniq, group, ignore: opts[:ignore_not_found] == true).each do |resource|
54
+ runtime.store.delete(kind, resource.name, group:) unless dry_run
55
+ context.puts(line(context, kind, resource, dry_run:))
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ # Runs before anything is removed, so one bad name deletes nothing.
62
+ def resolve(store, kind, names, group, ignore:)
63
+ names.filter_map do |name|
64
+ raise Error, Store::PROTECTED_GROUP if !kind.namespaced? && name == Store::DEFAULT_GROUP
65
+
66
+ store.find(kind, name, group:)
67
+ rescue Store::NotFound
68
+ raise unless ignore
69
+
70
+ nil
71
+ end
72
+ end
73
+
74
+ def line(context, kind, resource, dry_run:)
75
+ parts = ["#{kind.singular} #{resource.name.inspect}", context.paint(:delete_deleted, 'deleted')]
76
+ parts << "from #{resource.group} group" if kind.namespaced?
77
+ parts << context.paint(:dry_run, '(dry run)') if dry_run
78
+ parts.join(' ')
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative '../views'
5
+
6
+ module Slipway
7
+ module Commands
8
+ class Describe < Base
9
+ DESCRIPTION = "Show details of one or many resources.\n\n" \
10
+ 'Print a detailed description of the selected resources, including the state of the ' \
11
+ 'repository at the registered path and where it differs from the manifest. You may select ' \
12
+ "a single object by name, all objects of that type, or use a label or field selector.\n\n" \
13
+ 'Status is one of the words listed below. Last Fetch reads ' \
14
+ "#{Views::Project::NEVER} when git answered and no fetch is on record, and a field with no " \
15
+ "value reads #{Output::Describe::NONE}. When git cannot read the repository, the Repository " \
16
+ "block holds git's reason instead of the fields.\n\n" \
17
+ "#{Options::TYPES_SENTENCE}".freeze
18
+
19
+ def self.command(factory)
20
+ CLI::Command.new(
21
+ name: 'describe', summary: 'Show details of one or many resources',
22
+ section: 'Basic Commands', description: DESCRIPTION, examples:, usage: Get::USAGE,
23
+ glossaries: [Get::STATUS_WORDS],
24
+ positionals: [Options::TYPE, Options.name_positional(factory, variadic: true, required: false)],
25
+ options: [Options::SELECTOR, Options::FIELD_SELECTOR, Options::ALL_GROUPS],
26
+ handler: new(factory)
27
+ )
28
+ end
29
+
30
+ def self.examples
31
+ [
32
+ CLI::Example.new(comment: 'Describe a project', command: 'describe project hldr'),
33
+ CLI::Example.new(comment: 'Describe every project in the work group', command: 'describe projects -n work'),
34
+ CLI::Example.new(comment: 'Describe the projects labeled lang=rust',
35
+ command: 'describe projects -l lang=rust'),
36
+ CLI::Example.new(comment: 'Describe the projects on the main branch',
37
+ command: 'describe projects --field-selector status.branch=main'),
38
+ CLI::Example.new(comment: 'Describe a group', command: 'describe group work')
39
+ ]
40
+ end
41
+ private_class_method :examples
42
+
43
+ def kinds = Resources::KINDS
44
+
45
+ def run(runtime, context, args, opts)
46
+ scope = scope(runtime, context, opts)
47
+ kind, names = scope.targets(args)
48
+ fields = scope.field_selector(kind, names)
49
+ scope.select(kind, names) do |resources|
50
+ blocks = kind.namespaced? ? projects(runtime, context, resources, fields) : groups(runtime, resources, fields)
51
+ next scope.report_none(kind) if blocks.empty?
52
+
53
+ renderer = Output::Describe.new(context)
54
+ context.print(blocks.map { renderer.render(it) }.join("\n"))
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def projects(runtime, context, resources, fields)
61
+ inspections = fields.filter(Base.examine(runtime, context, resources)) { Views::Project.object(it) }
62
+ now = runtime.clock.call
63
+ inspections.map { Views::Project.describe(it, now:) }
64
+ end
65
+
66
+ def groups(runtime, groups, fields)
67
+ now = runtime.clock.call
68
+ counted = groups.map { [it, runtime.store.project_count(it.name)] }
69
+ fields.filter(counted) { |group, count| Views::Group.object(group, count:) }
70
+ .map { |group, count| Views::Group.describe(group, count:, now:) }
71
+ end
72
+ end
73
+ end
74
+ end