captive-release 0.3.2 → 0.4.4

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/lib/captive_release/build_command.rb +52 -0
  3. data/lib/captive_release/build_config.rb +20 -0
  4. data/lib/captive_release/build_config_prompt.rb +31 -0
  5. data/lib/captive_release/build_dispatch_run.rb +35 -0
  6. data/lib/captive_release/cli.rb +27 -382
  7. data/lib/captive_release/config.rb +2 -44
  8. data/lib/captive_release/config_file.rb +30 -0
  9. data/lib/captive_release/config_loader.rb +34 -0
  10. data/lib/captive_release/config_writer.rb +45 -0
  11. data/lib/captive_release/framework_detector.rb +30 -0
  12. data/lib/captive_release/git_commands.rb +18 -27
  13. data/lib/captive_release/git_release.rb +24 -0
  14. data/lib/captive_release/github_api.rb +45 -0
  15. data/lib/captive_release/github_dispatch.rb +12 -27
  16. data/lib/captive_release/github_http.rb +34 -25
  17. data/lib/captive_release/github_release.rb +16 -32
  18. data/lib/captive_release/github_repo.rb +29 -0
  19. data/lib/captive_release/github_token.rb +18 -0
  20. data/lib/captive_release/help.rb +50 -0
  21. data/lib/captive_release/init_command.rb +44 -0
  22. data/lib/captive_release/menu.rb +33 -0
  23. data/lib/captive_release/options.rb +27 -0
  24. data/lib/captive_release/release_command.rb +42 -0
  25. data/lib/captive_release/release_preconditions.rb +24 -0
  26. data/lib/captive_release/release_publication.rb +30 -0
  27. data/lib/captive_release/repo_files.rb +35 -0
  28. data/lib/captive_release/shell.rb +20 -0
  29. data/lib/captive_release/terminal.rb +37 -0
  30. data/lib/captive_release/version.rb +1 -1
  31. data/lib/captive_release/version_choice.rb +56 -0
  32. data/lib/captive_release/version_detector.rb +26 -57
  33. data/lib/captive_release/version_file.rb +14 -79
  34. data/lib/captive_release/version_file_bootstrap.rb +25 -0
  35. data/lib/captive_release/version_file_selection.rb +53 -0
  36. data/lib/captive_release/version_files_argument.rb +21 -0
  37. data/lib/captive_release/version_files_writing.rb +22 -0
  38. data/lib/captive_release/version_format.rb +86 -0
  39. data/lib/captive_release.rb +29 -0
  40. metadata +29 -1
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CaptiveRelease
4
+ # Trouver la configuration du dépôt, ou la déduire par détection quand elle n'a jamais été
5
+ # écrite : un dépôt qui n'a qu'un fichier VERSION doit pouvoir releaser sans init préalable.
6
+ #
7
+ # Cette unité change quand les sources de configuration changent, les commandes quand leur
8
+ # geste change.
9
+ class ConfigLoader
10
+ def initialize(terminal:)
11
+ @terminal = terminal
12
+ end
13
+
14
+ def call
15
+ return ConfigFile.load if File.exist?(CONFIG_FILE)
16
+
17
+ fichiers = VersionDetector.scan
18
+ refuser if fichiers.empty?
19
+
20
+ Config.new(fichiers)
21
+ end
22
+
23
+ private
24
+
25
+ def refuser
26
+ @terminal.say @terminal.error(
27
+ "Aucun fichier de version trouvé (package.json, lib/**/version.rb, VERSION).",
28
+ )
29
+ @terminal.say "Créez un fichier #{@terminal.bold("VERSION")} " \
30
+ "ou lancez #{@terminal.bold("captive-release init")}.\n"
31
+ exit 1
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CaptiveRelease
4
+ # Compose captive-release.json et l'écrit : les fichiers de version retenus, et les
5
+ # workflows de build si le dépôt en réclame.
6
+ #
7
+ # Cette unité change quand la forme de la configuration change, InitCommand quand la façon
8
+ # de réunir les fichiers change.
9
+ class ConfigWriter
10
+ def initialize(terminal:, frameworks: nil)
11
+ @terminal = terminal
12
+ @frameworks = frameworks || FrameworkDetector.new(RepoFiles.new("."))
13
+ end
14
+
15
+ def call(fichiers)
16
+ refuser_le_vide if fichiers.empty?
17
+
18
+ expo = demander("Expo", EXPO_DEFAULT_WORKFLOW) if @frameworks.expo?
19
+ capacitor = demander("Capacitor", CAPACITOR_DEFAULT_WORKFLOW) if @frameworks.capacitor?
20
+
21
+ ConfigFile.write(fichiers, expo: expo, capacitor: capacitor)
22
+ annoncer(fichiers, expo, capacitor)
23
+ end
24
+
25
+ private
26
+
27
+ def refuser_le_vide
28
+ @terminal.say @terminal.error("No files selected.")
29
+ exit 1
30
+ end
31
+
32
+ def annoncer(fichiers, expo, capacitor)
33
+ @terminal.say "\n#{@terminal.ok("✅ captive-release.json written with " \
34
+ "#{fichiers.size} version file(s).")}"
35
+ @terminal.say " #{@terminal.dim("Expo build workflow: #{expo.build_workflow}")}" if expo
36
+ return unless capacitor
37
+
38
+ @terminal.say " #{@terminal.dim("Capacitor build workflow: #{capacitor.build_workflow}")}"
39
+ end
40
+
41
+ def demander(framework, workflow_par_defaut)
42
+ BuildConfigPrompt.new(terminal: @terminal).call(framework, workflow_par_defaut)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CaptiveRelease
4
+ # Quel framework mobile ce dépôt utilise, donc quel workflow de build proposer.
5
+ #
6
+ # Cette unité change quand un framework s'ajoute, VersionDetector quand un format de
7
+ # fichier de version s'ajoute : deux raisons de changer différentes.
8
+ class FrameworkDetector
9
+ CAPACITOR_CONFIGS = %w[capacitor.config.ts capacitor.config.json capacitor.config.js].freeze
10
+
11
+ def initialize(files)
12
+ @files = files
13
+ end
14
+
15
+ # Les rescue existent pour qu'une arborescence hostile ne fasse pas échouer une release :
16
+ # la détection est une commodité, jamais une raison de s'arrêter.
17
+ def expo?
18
+ @files.glob("**", "app.config.js").any? ||
19
+ @files.glob("**", "app.json").any? { |chemin| File.read(chemin).include?('"expo"') }
20
+ rescue StandardError
21
+ false
22
+ end
23
+
24
+ def capacitor?
25
+ CAPACITOR_CONFIGS.any? { |nom| File.exist?(@files.path(nom)) }
26
+ rescue StandardError
27
+ false
28
+ end
29
+ end
30
+ end
@@ -1,47 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CaptiveRelease
4
+ # Les commandes git d'une release. La façon de les lancer vit dans Shell.
4
5
  class GitCommands
5
6
  def self.dirty?
6
- lines = `git diff --name-only && git diff --cached --name-only`.lines.map(&:strip).reject(&:empty?)
7
- !lines.empty?
7
+ sortie = Shell.capture("git diff --name-only && git diff --cached --name-only")
8
+ !sortie.lines.map(&:strip).reject(&:empty?).empty?
8
9
  end
9
10
 
10
- def self.last_tag
11
- tag = `git describe --tags --abbrev=0 2>/dev/null`.strip
12
- tag.empty? ? nil : tag
13
- end
14
-
15
- def self.commits_since(tag)
16
- range = tag ? "#{tag}..HEAD" : "HEAD"
17
- `git log #{range} --pretty=format:"- %s (%h)" --no-merges`.strip
18
- end
11
+ def self.remote_url = Shell.capture("git remote get-url origin 2>/dev/null")
19
12
 
20
- def self.remote_url
21
- `git remote get-url origin 2>/dev/null`.strip
13
+ def self.current_branch
14
+ branche = Shell.capture("git rev-parse --abbrev-ref HEAD 2>/dev/null")
15
+ branche.empty? ? "main" : branche
22
16
  end
23
17
 
18
+ # --only borne le commit aux fichiers de version : sans lui, ce qui traînait dans l'index
19
+ # part dans la release, taggé et poussé, sans que rien ne le signale.
24
20
  def self.add_and_commit(version, paths)
25
- run! "git add #{paths.map { |p| shellescape(p) }.join(" ")}"
26
- run! %(git commit -m "🔖 Set version v#{version}")
21
+ echappes = paths.map { |chemin| Shell.escape(chemin) }.join(" ")
22
+ Shell.run! "git add #{echappes}"
23
+ Shell.run! %(git commit --only #{echappes} -m "🔖 Set version v#{version}")
27
24
  end
28
25
 
29
26
  def self.tag(version)
30
- run! "git tag v#{version}"
27
+ Shell.run! "git tag v#{version}"
31
28
  end
32
29
 
30
+ # Le tag part avant HEAD : au moment où le webhook du push sur main arrive, le tag existe
31
+ # déjà. Dans l'autre sens, demander « ce sha porte-t-il un tag v* ? » répond non presque
32
+ # toujours, et la même release se construit deux fois.
33
33
  def self.push(version)
34
- run! "git push origin HEAD"
35
- run! "git push origin v#{version}"
36
- end
37
-
38
- def self.run!(cmd)
39
- system(cmd) || raise("Git command failed: #{cmd}")
40
- end
41
-
42
- def self.shellescape(str)
43
- str.gsub(/[^a-zA-Z0-9._\-\/]/) { |c| "\\#{c}" }
34
+ Shell.run! "git push origin v#{version}"
35
+ Shell.run! "git push origin HEAD"
44
36
  end
45
- private_class_method :shellescape
46
37
  end
47
38
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CaptiveRelease
4
+ # La séquence git d'une release : commiter les fichiers de version, taguer, pousser.
5
+ #
6
+ # Cette unité change quand la séquence change, ReleaseCommand quand ce qui l'entoure
7
+ # change. Chaque étape est annoncée au fur et à mesure : une release qui s'arrête doit
8
+ # laisser voir jusqu'où elle est allée.
9
+ class GitRelease
10
+ def initialize(terminal:)
11
+ @terminal = terminal
12
+ end
13
+
14
+ def call(version, paths)
15
+ @terminal.say "\n#{@terminal.bold("Running git commands...")}"
16
+ GitCommands.add_and_commit(version, paths)
17
+ @terminal.done %(git commit "🔖 Set version v#{version}")
18
+ GitCommands.tag(version)
19
+ @terminal.done "git tag v#{version}"
20
+ GitCommands.push(version)
21
+ @terminal.done "git push + tag"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require_relative "github_http"
6
+ require_relative "github_repo"
7
+
8
+ module CaptiveRelease
9
+ # L'API GitHub d'un dépôt : à quelle adresse poster, et ce qui compte comme un échec. Les
10
+ # commandes lui parlent par délégation plutôt que d'en hériter le transport, ce qui leur
11
+ # laisse la seule composition de leur payload.
12
+ #
13
+ # Cette unité change quand l'adressage ou la politique d'erreur changent, GithubHttp quand
14
+ # le transport change.
15
+ class GithubApi
16
+ HOTE = "https://api.github.com"
17
+
18
+ attr_reader :depot
19
+
20
+ def initialize(remote_url:, token:, http: nil)
21
+ @depot = GithubRepo.from_remote(remote_url)
22
+ @http = http || GithubHttp.new(token: token)
23
+ end
24
+
25
+ def post(chemin, corps, attendu:)
26
+ reponse = @http.post(URI("#{HOTE}/#{depot.path(chemin)}"), JSON.generate(corps))
27
+ refuser(reponse) unless reponse.code == attendu
28
+
29
+ reponse.body.to_s.empty? ? nil : JSON.parse(reponse.body)
30
+ end
31
+
32
+ private
33
+
34
+ # Le corps d'une erreur GitHub est du JSON qui nomme la cause, sauf quand il n'en est pas :
35
+ # un 500 rend parfois du texte brut, qu'il vaut mieux montrer que d'avaler.
36
+ def refuser(reponse)
37
+ data = begin
38
+ JSON.parse(reponse.body)
39
+ rescue StandardError
40
+ {}
41
+ end
42
+ raise "GitHub API error (#{reponse.code}): #{data["message"] || reponse.body}"
43
+ end
44
+ end
45
+ end
@@ -1,40 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "uri"
5
- require_relative "github_http"
3
+ require_relative "github_api"
6
4
 
7
5
  module CaptiveRelease
6
+ # Déclenche un workflow GitHub Actions. Elle compose ce qu'elle demande ; l'API, son
7
+ # authentification et ses échecs vivent dans GithubApi.
8
8
  class GithubDispatch
9
- GITHUB_API = "https://api.github.com"
10
-
11
- include GithubHttp
12
-
13
- def self.dispatch(workflow_file:, ref:, inputs:, remote_url:, token:)
14
- new(workflow_file: workflow_file, ref: ref, inputs: inputs, remote_url: remote_url, token: token).dispatch
9
+ def self.dispatch(workflow_file:, ref:, inputs:, remote_url:, token:, api: nil)
10
+ api ||= GithubApi.new(remote_url: remote_url, token: token)
11
+ new(api).dispatch(workflow_file: workflow_file, ref: ref, inputs: inputs)
15
12
  end
16
13
 
17
- def initialize(workflow_file:, ref:, inputs:, remote_url:, token:)
18
- @workflow_file = workflow_file
19
- @ref = ref
20
- @inputs = inputs
21
- @remote_url = remote_url
22
- @token = token
14
+ def initialize(api)
15
+ @api = api
23
16
  end
24
17
 
25
- def dispatch
26
- owner, repo = parse_remote(@remote_url)
27
- uri = URI("#{GITHUB_API}/repos/#{owner}/#{repo}/actions/workflows/#{@workflow_file}/dispatches")
28
-
29
- payload = JSON.generate({ "ref" => @ref, "inputs" => @inputs })
30
- response = github_post(uri, @token, payload)
31
-
32
- unless response.code == "204"
33
- data = JSON.parse(response.body) rescue {}
34
- raise "GitHub API error (#{response.code}): #{data["message"] || response.body}"
35
- end
18
+ def dispatch(workflow_file:, ref:, inputs:)
19
+ @api.post("actions/workflows/#{workflow_file}/dispatches",
20
+ { "ref" => ref, "inputs" => inputs }, attendu: "204")
36
21
 
37
- "https://github.com/#{owner}/#{repo}/actions"
22
+ @api.depot.actions_url
38
23
  end
39
24
  end
40
25
  end
@@ -4,39 +4,48 @@ require "net/http"
4
4
  require "uri"
5
5
 
6
6
  module CaptiveRelease
7
- module GithubHttp
8
- MAX_REDIRECTS = 3
9
-
10
- def github_post(uri, token, body)
11
- MAX_REDIRECTS.times do
12
- http = Net::HTTP.new(uri.host, uri.port)
13
- http.use_ssl = true
14
-
15
- request = Net::HTTP::Post.new(uri.path)
16
- request["Authorization"] = "token #{token}"
17
- request["Content-Type"] = "application/json"
18
- request["Accept"] = "application/vnd.github+json"
19
- request["X-GitHub-Api-Version"] = "2022-11-28"
20
- request.body = body
21
-
22
- response = http.request(request)
23
-
24
- return response unless %w[301 302 307 308].include?(response.code)
7
+ # Le transport : poster sur l'API GitHub, authentifié, en suivant les redirections qu'elle
8
+ # sert quand un dépôt a été renommé. Elle ne sait rien de ce qui est posté ni de ce qui
9
+ # compte comme un échec, qui appartiennent à GithubApi.
10
+ class GithubHttp
11
+ MAX_REDIRECTIONS = 3
12
+ REDIRECTIONS = %w[301 302 307 308].freeze
13
+ EN_TETES = {
14
+ "Content-Type" => "application/json",
15
+ "Accept" => "application/vnd.github+json",
16
+ "X-GitHub-Api-Version" => "2022-11-28",
17
+ }.freeze
18
+
19
+ def initialize(token:)
20
+ @token = token
21
+ end
25
22
 
26
- location = response["location"] || response["Location"]
27
- raise "GitHub redirect without Location header" unless location
23
+ def post(uri, corps)
24
+ MAX_REDIRECTIONS.times do
25
+ reponse = envoyer(uri, corps)
26
+ return reponse unless REDIRECTIONS.include?(reponse.code)
28
27
 
29
- uri = URI(location)
28
+ uri = URI(ailleurs!(reponse))
30
29
  end
31
30
 
32
31
  raise "Too many redirects from GitHub API"
33
32
  end
34
33
 
35
- def parse_remote(url)
36
- match = url.match(%r{github\.com[:/]([^/]+)/(.+?)(?:\.git)?\z})
37
- raise "Cannot parse GitHub repo from remote URL: #{url}" unless match
34
+ private
35
+
36
+ def envoyer(uri, corps)
37
+ http = Net::HTTP.new(uri.host, uri.port)
38
+ http.use_ssl = true
39
+ requete = Net::HTTP::Post.new(uri.path)
40
+ EN_TETES.each { |nom, valeur| requete[nom] = valeur }
41
+ requete["Authorization"] = "token #{@token}"
42
+ requete.body = corps
43
+ http.request(requete)
44
+ end
38
45
 
39
- [ match[1], match[2] ]
46
+ def ailleurs!(reponse)
47
+ reponse["location"] || reponse["Location"] ||
48
+ raise("GitHub redirect without Location header")
40
49
  end
41
50
  end
42
51
  end
@@ -1,44 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "uri"
5
- require_relative "github_http"
3
+ require_relative "github_api"
6
4
 
7
5
  module CaptiveRelease
6
+ # Crée la release GitHub d'une version. Elle compose ce qu'elle demande ; l'API, son
7
+ # authentification et ses échecs vivent dans GithubApi.
8
8
  class GithubRelease
9
- GITHUB_API = "https://api.github.com"
10
-
11
- include GithubHttp
12
-
13
- def self.create(version:, body:, remote_url:, token:)
14
- new(version: version, body: body, remote_url: remote_url, token: token).create
9
+ def self.create(version:, remote_url:, token:, api: nil)
10
+ api ||= GithubApi.new(remote_url: remote_url, token: token)
11
+ new(api).create(version)
15
12
  end
16
13
 
17
- def initialize(version:, body:, remote_url:, token:)
18
- @version = version
19
- @body = body
20
- @remote_url = remote_url
21
- @token = token
14
+ def initialize(api)
15
+ @api = api
22
16
  end
23
17
 
24
- def create
25
- owner, repo = parse_remote(@remote_url)
26
- uri = URI("#{GITHUB_API}/repos/#{owner}/#{repo}/releases")
27
-
28
- payload = JSON.generate({
29
- "tag_name" => "v#{@version}",
30
- "name" => "v#{@version}",
31
- "body" => @body.empty? ? "_No commits since last release._" : @body,
32
- "draft" => false,
33
- "prerelease" => false,
34
- })
35
-
36
- response = github_post(uri, @token, payload)
37
- data = JSON.parse(response.body)
38
-
39
- raise "GitHub API error (#{response.code}): #{data["message"]}" unless response.code == "201"
40
-
41
- data["html_url"]
18
+ # GitHub compose le corps : commits catégorisés, contributeurs, lien Full Changelog. Le
19
+ # composer ici obligeait à lister les commits, et sans tag précédent cette liste était
20
+ # l'historique entier du dépôt.
21
+ def create(version)
22
+ @api.post("releases",
23
+ { "tag_name" => "v#{version}", "name" => "v#{version}",
24
+ "generate_release_notes" => true, "draft" => false, "prerelease" => false, },
25
+ attendu: "201")["html_url"]
42
26
  end
43
27
  end
44
28
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CaptiveRelease
4
+ # Quel dépôt GitHub un remote git désigne. Les deux formes de remote, SSH et HTTPS, mènent
5
+ # au même couple propriétaire et dépôt.
6
+ #
7
+ # Cette unité change quand les formes de remote changent, GithubApi quand l'API change.
8
+ class GithubRepo
9
+ REMOTE = %r{github\.com[:/]([^/]+)/(.+?)(?:\.git)?\z}
10
+
11
+ attr_reader :owner, :repo
12
+
13
+ def self.from_remote(url)
14
+ match = url.match(REMOTE)
15
+ raise "Cannot parse GitHub repo from remote URL: #{url}" unless match
16
+
17
+ new(match[1], match[2])
18
+ end
19
+
20
+ def initialize(owner, repo)
21
+ @owner = owner
22
+ @repo = repo
23
+ end
24
+
25
+ def path(suite) = "repos/#{owner}/#{repo}/#{suite}"
26
+
27
+ def actions_url = "https://github.com/#{owner}/#{repo}/actions"
28
+ end
29
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CaptiveRelease
4
+ # D'où vient le jeton GitHub : la variable d'environnement d'abord, la CLI `gh` ensuite.
5
+ #
6
+ # Cette unité change quand les sources d'authentification changent. La lecture reste
7
+ # paresseuse chez les appelants, qui la reçoivent en fournisseur : interroger `gh` coûte un
8
+ # appel système, inutile tant qu'on n'a pas vérifié le reste.
9
+ module GithubToken
10
+ def self.call
11
+ depuis_env = ENV["GITHUB_TOKEN"].to_s
12
+ return depuis_env unless depuis_env.empty?
13
+
14
+ depuis_gh = `gh auth token 2>/dev/null`.strip
15
+ depuis_gh.empty? ? nil : depuis_gh
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CaptiveRelease
4
+ # Ce que la CLI documente d'elle-même. Cette unité change quand la documentation de
5
+ # l'interface change, la CLI quand son aiguillage change.
6
+ class Help
7
+ def initialize(terminal:)
8
+ @terminal = terminal
9
+ end
10
+
11
+ def show
12
+ @terminal.say <<~HELP
13
+
14
+ #{@terminal.bold("captive-release")} : Semver release tool
15
+
16
+ #{@terminal.bold("Usage:")}
17
+ captive-release init [--files path[:type],...]
18
+ captive-release new [patch|minor|major|x.y.z] [--yes]
19
+ captive-release version
20
+
21
+ #{@terminal.bold("Commands:")}
22
+ init Detect version files and write captive-release.json
23
+ new Bump version, commit, tag, push, create GitHub release
24
+ Refuse de tourner sur un arbre de travail sale.
25
+ Demande toujours confirmation, sauf avec --yes.
26
+ build Dispatch an EAS build workflow (staging/production) via GitHub Actions
27
+
28
+ #{@terminal.bold("Options for init:")}
29
+ --files Non-interactive: comma-separated list of files with optional type
30
+ Type is auto-detected (package_json or plain) when omitted
31
+ Example: --files=package.json,ios/VERSION:plain
32
+
33
+ #{@terminal.bold("Examples:")}
34
+ captive-release init
35
+ captive-release init --files=package.json,apps/mobile/package.json
36
+ captive-release new
37
+ captive-release new patch
38
+ captive-release new 2.0.0
39
+ captive-release new patch --yes
40
+ captive-release build
41
+ captive-release build --profile staging
42
+ captive-release build --profile production
43
+
44
+ #{@terminal.bold("GitHub release:")}
45
+ Set GITHUB_TOKEN env var or authenticate with `gh auth login`
46
+
47
+ HELP
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CaptiveRelease
4
+ # Détecte les fichiers de version d'un dépôt et écrit captive-release.json.
5
+ #
6
+ # Cette unité change quand la façon de constituer la configuration change, la CLI quand
7
+ # l'ensemble des commandes ou le parsing des options change.
8
+ class InitCommand
9
+ def initialize(terminal:)
10
+ @terminal = terminal
11
+ end
12
+
13
+ def run(files_arg)
14
+ files_arg ? sans_interaction(files_arg) : avec_interaction
15
+ end
16
+
17
+ private
18
+
19
+ def sans_interaction(files_arg)
20
+ writer.call(VersionFilesArgument.parse(files_arg))
21
+ end
22
+
23
+ def avec_interaction
24
+ @terminal.say "\n#{@terminal.bold("captive-release init")}\n"
25
+ @terminal.say "Scanning for version files...\n"
26
+
27
+ trouves = VersionDetector.scan
28
+ return creer_un_version("No version files found (package.json, VERSION).") if trouves.empty?
29
+
30
+ retenus = selection.call(trouves)
31
+ return creer_un_version("Aucun fichier sélectionné.") if retenus.empty?
32
+
33
+ writer.call(retenus)
34
+ end
35
+
36
+ def creer_un_version(motif)
37
+ writer.call(VersionFileBootstrap.new(terminal: @terminal).call(motif))
38
+ end
39
+
40
+ def writer = @writer ||= ConfigWriter.new(terminal: @terminal)
41
+
42
+ def selection = @selection ||= VersionFileSelection.new(terminal: @terminal)
43
+ end
44
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CaptiveRelease
4
+ # Une liste numérotée, un choix, et le refus de ce qui n'est pas dans la liste.
5
+ #
6
+ # Un rang négatif est refusé comme un rang trop grand : sans cette garde, une saisie non
7
+ # numérique vaut 0, donc l'indice -1, et Ruby rendait silencieusement la dernière entrée.
8
+ class Menu
9
+ def initialize(terminal:)
10
+ @terminal = terminal
11
+ end
12
+
13
+ # entries : un Hash {libellé affiché => valeur rendue}, dans l'ordre d'affichage.
14
+ def choose(title, entries)
15
+ @terminal.say "#{title}\n"
16
+ entries.each_key.with_index do |libelle, rang|
17
+ @terminal.say " #{@terminal.dim("#{rang + 1})")} #{libelle}"
18
+ end
19
+
20
+ rang = @terminal.ask_choice - 1
21
+ refuse if rang.negative? || rang >= entries.size
22
+
23
+ entries.values[rang]
24
+ end
25
+
26
+ private
27
+
28
+ def refuse
29
+ @terminal.say @terminal.error("Invalid choice.")
30
+ exit 1
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CaptiveRelease
4
+ # La ligne de commande et ce qu'on en lit : la commande, les drapeaux, les options à
5
+ # valeur, sous leurs deux formes « --opt valeur » et « --opt=valeur ».
6
+ #
7
+ # Cette unité change quand la syntaxe des options change, la CLI quand l'ensemble des
8
+ # commandes change. Elle consomme ce qu'elle lit, pour qu'un argument positionnel lu
9
+ # ensuite ne récupère pas une option.
10
+ class Options
11
+ def initialize(argv)
12
+ @argv = argv
13
+ end
14
+
15
+ def next_argument = @argv.shift
16
+
17
+ def flag?(name) = !@argv.delete(name).nil?
18
+
19
+ def value(name)
20
+ rang = @argv.index { |argument| argument.start_with?("#{name}=") || argument == name }
21
+ return nil if rang.nil?
22
+
23
+ argument = @argv.delete_at(rang)
24
+ argument.include?("=") ? argument.split("=", 2).last : @argv.delete_at(rang)
25
+ end
26
+ end
27
+ end