captive-cli 1.0.0 → 1.3.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/lib/captive/cli/dependencies.rb +12 -2
  3. data/lib/captive/cli.rb +30 -13
  4. data/lib/captive/commands/apps_commands.rb +25 -50
  5. data/lib/captive/commands/apps_restart.rb +23 -9
  6. data/lib/captive/commands/auth_commands.rb +16 -0
  7. data/lib/captive/commands/auth_token.rb +29 -0
  8. data/lib/captive/commands/cron_declaration.rb +25 -0
  9. data/lib/captive/commands/crons_commands.rb +123 -0
  10. data/lib/captive/commands/env_commands.rb +66 -0
  11. data/lib/captive/commands/env_set.rb +40 -0
  12. data/lib/captive/commands/user_login.rb +20 -13
  13. data/lib/captive/credentials.rb +43 -0
  14. data/lib/captive/credentials_store.rb +47 -0
  15. data/lib/captive/dashboard/apps_resource.rb +26 -0
  16. data/lib/captive/dashboard/env_vars_resource.rb +27 -0
  17. data/lib/captive/dashboard/scheduled_tasks_resource.rb +32 -0
  18. data/lib/captive/dashboard_api.rb +58 -0
  19. data/lib/captive/dashboard_client.rb +25 -25
  20. data/lib/captive/dashboard_response.rb +39 -0
  21. data/lib/captive/errors.rb +14 -3
  22. data/lib/captive/flux.rb +11 -2
  23. data/lib/captive/github_device_flow.rb +5 -3
  24. data/lib/captive/kubectl.rb +0 -10
  25. data/lib/captive/loader.rb +6 -4
  26. data/lib/captive/presenter/app_info.rb +40 -0
  27. data/lib/captive/presenter/scheduled_task.rb +36 -0
  28. data/lib/captive/session_token.rb +60 -0
  29. data/lib/captive/shell_completions.rb +4 -2
  30. data/lib/captive/token_refresher.rb +27 -0
  31. data/lib/captive/validation_message.rb +26 -0
  32. data/lib/captive/version.rb +1 -1
  33. metadata +19 -8
  34. data/lib/captive/cluster_apps.rb +0 -82
  35. data/lib/captive/commands/apps_info.rb +0 -70
  36. data/lib/captive/commands/apps_rebuild.rb +0 -52
  37. data/lib/captive/commands/env_kube_commands.rb +0 -25
  38. data/lib/captive/commands/env_list_kube.rb +0 -16
  39. data/lib/captive/gh_cli.rb +0 -89
  40. data/lib/captive/platform_root.rb +0 -25
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "credentials_store"
4
+ require_relative "token_refresher"
5
+ require_relative "errors"
6
+
7
+ module Captive
8
+ # La source unique du jeton envoyé au dashboard et à kubectl : renouvelé quand il expire dans
9
+ # moins de 5 minutes, ou quand le dashboard le refuse (401).
10
+ class SessionToken
11
+ INVALID = "Token invalide ou expiré. Lance : captive login"
12
+
13
+ def initialize(store: CredentialsStore.new, refresher: TokenRefresher.new, clock: -> { Time.now })
14
+ @store = store
15
+ @refresher = refresher
16
+ @clock = clock
17
+ end
18
+
19
+ def current
20
+ now = @clock.call
21
+ credentials = @store.read
22
+ return credentials unless credentials.expiring?(now)
23
+
24
+ renew { |fresh| fresh.expiring?(now) }
25
+ end
26
+
27
+ # Après un 401 sur `stale_token` : renouvelle, sauf si un autre processus l'a déjà fait.
28
+ def refresh(stale_token)
29
+ renew { |fresh| fresh.access_token == stale_token }.access_token
30
+ end
31
+
32
+ # Pour les transports : la requête est rejouée une fois avec un jeton renouvelé sur un 401.
33
+ def authorize
34
+ token = current.access_token
35
+ response = yield token
36
+ return response unless response.code.to_i == 401
37
+
38
+ yield refresh(token)
39
+ end
40
+
41
+ private
42
+
43
+ # Sous verrou, on relit : si un autre processus a renouvelé entre-temps, son jeton suffit.
44
+ def renew(&stale)
45
+ @store.exclusively do
46
+ fresh = @store.read
47
+ next fresh unless stale.call(fresh)
48
+
49
+ @refresher.refresh(refreshable(fresh).refresh_token).tap { |credentials| @store.write(credentials) }
50
+ end
51
+ end
52
+
53
+ def refreshable(credentials)
54
+ return credentials if credentials.refreshable?(@clock.call)
55
+ raise SessionExpiredError if credentials.expirable?
56
+
57
+ raise AuthError, INVALID
58
+ end
59
+ end
60
+ end
@@ -2,11 +2,12 @@
2
2
 
3
3
  module Captive
4
4
  module ShellCompletions
5
- ROOT_COMMANDS = %w[apps env db login whoami console run logs version completions].freeze
5
+ ROOT_COMMANDS = %w[apps env crons db login whoami console run logs version completions].freeze
6
6
 
7
7
  SUBCOMMANDS = {
8
8
  "apps" => %w[list info restart rebuild],
9
- "env" => %w[list],
9
+ "env" => %w[list set unset],
10
+ "crons" => %w[list add edit rm suspend resume trigger runs],
10
11
  "db" => %w[restore],
11
12
  }.freeze
12
13
 
@@ -21,6 +22,7 @@ module Captive
21
22
  case "$prev" in
22
23
  apps) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["apps"].join(" ")}" -- "$cur")); return ;;
23
24
  env) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["env"].join(" ")}" -- "$cur")); return ;;
25
+ crons) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["crons"].join(" ")}" -- "$cur")); return ;;
24
26
  db) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["db"].join(" ")}" -- "$cur")); return ;;
25
27
  esac
26
28
 
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "http_client"
4
+ require_relative "credentials"
5
+ require_relative "errors"
6
+ require_relative "github_device_flow"
7
+
8
+ module Captive
9
+ # Échange un refresh_token contre de nouveaux jetons. Jeton issu du device flow : pas de
10
+ # client_secret. L'échange est à usage unique et invalide l'ancien couple de jetons.
11
+ class TokenRefresher
12
+ URL = "https://github.com/login/oauth/access_token"
13
+
14
+ def initialize(http: HttpClient.new, clock: -> { Time.now })
15
+ @http = http
16
+ @clock = clock
17
+ end
18
+
19
+ def refresh(refresh_token)
20
+ data = @http.post(URL, client_id: GithubDeviceFlow::CLIENT_ID, grant_type: "refresh_token",
21
+ refresh_token: refresh_token)
22
+ raise SessionExpiredError unless data["access_token"]
23
+
24
+ Credentials.from_github(data, now: @clock.call)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ # Les refus de validation, un par ligne : `errors` est une liste de {field, message}, un
5
+ # objet champ → messages, ou absent (seul `error` dit alors ce qui ne va pas).
6
+ class ValidationMessage
7
+ def initialize(body)
8
+ @body = body.is_a?(Hash) ? body : {}
9
+ end
10
+
11
+ def to_s
12
+ ([ "Requête refusée :" ] + lines.map { |line| " - #{line}" }).join("\n")
13
+ end
14
+
15
+ private
16
+
17
+ def lines
18
+ errors = @body["errors"]
19
+ case errors
20
+ when Array then errors.map { |e| e.is_a?(Hash) ? "#{e["field"]} : #{e["message"]}" : e.to_s }
21
+ when Hash then errors.map { |field, messages| "#{field} : #{Array(messages).join(", ")}" }
22
+ else [ @body.fetch("error", "invalide").to_s ]
23
+ end
24
+ end
25
+ end
26
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Captive
4
- VERSION = "1.0.0"
4
+ VERSION = "1.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: captive-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Captive Studio
@@ -106,29 +106,36 @@ files:
106
106
  - lib/captive/activity_trail.rb
107
107
  - lib/captive/cli.rb
108
108
  - lib/captive/cli/dependencies.rb
109
- - lib/captive/cluster_apps.rb
110
109
  - lib/captive/cluster_endpoint.rb
111
110
  - lib/captive/commands/apps_commands.rb
112
- - lib/captive/commands/apps_info.rb
113
- - lib/captive/commands/apps_rebuild.rb
114
111
  - lib/captive/commands/apps_restart.rb
112
+ - lib/captive/commands/auth_commands.rb
113
+ - lib/captive/commands/auth_token.rb
115
114
  - lib/captive/commands/console.rb
115
+ - lib/captive/commands/cron_declaration.rb
116
+ - lib/captive/commands/crons_commands.rb
116
117
  - lib/captive/commands/db_commands.rb
117
118
  - lib/captive/commands/db_dump.rb
118
119
  - lib/captive/commands/db_exec.rb
119
120
  - lib/captive/commands/db_psql.rb
120
121
  - lib/captive/commands/db_restore.rb
121
122
  - lib/captive/commands/db_target.rb
122
- - lib/captive/commands/env_kube_commands.rb
123
- - lib/captive/commands/env_list_kube.rb
123
+ - lib/captive/commands/env_commands.rb
124
+ - lib/captive/commands/env_set.rb
124
125
  - lib/captive/commands/logs.rb
125
126
  - lib/captive/commands/one_off_session.rb
126
127
  - lib/captive/commands/run.rb
127
128
  - lib/captive/commands/user_login.rb
129
+ - lib/captive/credentials.rb
130
+ - lib/captive/credentials_store.rb
131
+ - lib/captive/dashboard/apps_resource.rb
132
+ - lib/captive/dashboard/env_vars_resource.rb
133
+ - lib/captive/dashboard/scheduled_tasks_resource.rb
134
+ - lib/captive/dashboard_api.rb
128
135
  - lib/captive/dashboard_client.rb
136
+ - lib/captive/dashboard_response.rb
129
137
  - lib/captive/errors.rb
130
138
  - lib/captive/flux.rb
131
- - lib/captive/gh_cli.rb
132
139
  - lib/captive/github_device_flow.rb
133
140
  - lib/captive/http_client.rb
134
141
  - lib/captive/kubectl.rb
@@ -136,17 +143,21 @@ files:
136
143
  - lib/captive/log_noise_filter.rb
137
144
  - lib/captive/null_log_filter.rb
138
145
  - lib/captive/one_off_spec.rb
139
- - lib/captive/platform_root.rb
140
146
  - lib/captive/poller.rb
141
147
  - lib/captive/presenter/ansi_stripper.rb
148
+ - lib/captive/presenter/app_info.rb
142
149
  - lib/captive/presenter/log_colorizer.rb
143
150
  - lib/captive/presenter/log_line.rb
151
+ - lib/captive/presenter/scheduled_task.rb
144
152
  - lib/captive/session_id.rb
153
+ - lib/captive/session_token.rb
145
154
  - lib/captive/shell_completions.rb
146
155
  - lib/captive/terminal.rb
156
+ - lib/captive/token_refresher.rb
147
157
  - lib/captive/ui/banner.rb
148
158
  - lib/captive/ui/spinner.rb
149
159
  - lib/captive/update_checker.rb
160
+ - lib/captive/validation_message.rb
150
161
  - lib/captive/version.rb
151
162
  homepage: https://github.com/captive-studio/captive-platform
152
163
  licenses:
@@ -1,82 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
-
5
- module Captive
6
- # Ce que le cluster sait des apps déployées. Le CLI développeur décrit l'état réellement
7
- # servi, pas l'état désiré dans le repo GitOps — auquel il n'a pas accès.
8
- class ClusterApps
9
- def initialize(kubectl:)
10
- @kubectl = kubectl
11
- end
12
-
13
- def all
14
- web_deployments.map do |deployment|
15
- namespace = deployment.dig("metadata", "namespace")
16
- { name: namespace, stage: stage_of(namespace), domains: domains_by_namespace[namespace] || [] }
17
- end
18
- end
19
-
20
- def domains(app) = domains_by_namespace[app] || []
21
-
22
- def stage(app) = stage_of(app)
23
-
24
- def tier(app) = web_of(app)&.dig("metadata", "annotations", "captive.dev/tier")
25
-
26
- def workers(app) = of_type(app, "worker").map { |d| d.dig("metadata", "name") }
27
-
28
- # L'add-on peut ne pas être posé, ou la CRD absente du cluster : les deux se lisent
29
- # comme « pas d'IP fixe », pas comme une panne.
30
- def egress_ip(app)
31
- first_item("egresspolicy", app)&.dig("spec", "egressIP", "ipv4")
32
- end
33
-
34
- def postgres(app)
35
- spec = first_item("cluster", app)&.fetch("spec", nil) or return nil
36
-
37
- { instances: spec["instances"],
38
- memory: spec.dig("resources", "limits", "memory"),
39
- storage: spec.dig("storage", "size"), }.compact
40
- end
41
-
42
- private
43
-
44
- def first_item(kind, app)
45
- items(@kubectl.run("get", kind, "-n", app, "-o", "json")).first
46
- rescue RuntimeError
47
- nil
48
- end
49
-
50
- def web_of(app) = of_type(app, "web").first
51
-
52
- def of_type(app, type)
53
- workloads(app).select { |d| d.dig("metadata", "labels", "captive.dev/type") == type }
54
- end
55
-
56
- def workloads(app)
57
- @workloads ||= {}
58
- @workloads[app] ||= items(@kubectl.run("get", "deployment", "-n", app, "-o", "json"))
59
- end
60
-
61
-
62
- # Une app est un Deployment portant le type web : le même label que les composants de
63
- # tier ciblent, et que la conformité exige.
64
- def web_deployments
65
- items(@kubectl.run("get", "deployment", "-A", "-l", "captive.dev/type=web", "-o", "json"))
66
- end
67
-
68
- def domains_by_namespace
69
- @domains_by_namespace ||= items(@kubectl.run("get", "httproute", "-A", "-o", "json"))
70
- .group_by { |route| route.dig("metadata", "namespace") }
71
- .transform_values { |routes| routes.flat_map { |r| Array(r.dig("spec", "hostnames")) } }
72
- end
73
-
74
- def items(json) = JSON.parse(json).fetch("items", [])
75
-
76
- def stage_of(namespace)
77
- return "production" if namespace.end_with?("-production")
78
-
79
- "staging" if namespace.end_with?("-staging")
80
- end
81
- end
82
- end
@@ -1,70 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Captive
4
- module Commands
5
- # L'état d'une app tel que le cluster le sert. Le CLI développeur n'a pas accès au repo
6
- # GitOps : tout se lit sur les ressources vivantes, ce qui décrit l'état réel plutôt que
7
- # l'état désiré — un déploiement en cours s'y voit, un manifeste poussé mais non
8
- # réconcilié ne s'y voit pas encore.
9
- class AppsInfo
10
- TIER_RESOURCES = {
11
- "xs" => { cpu: "100m → 500m", memory: "256Mi → 512Mi" },
12
- "s" => { cpu: "250m → 1", memory: "512Mi → 1Gi" },
13
- "m" => { cpu: "500m → 2", memory: "1Gi → 2Gi" },
14
- "l" => { cpu: "1 → 4", memory: "2Gi → 4Gi" },
15
- }.freeze
16
-
17
- def initialize(kubectl:, cluster_apps: nil)
18
- @kubectl = kubectl
19
- @apps = cluster_apps || ClusterApps.new(kubectl: kubectl)
20
- end
21
-
22
- def gather(app)
23
- tier = @apps.tier(app)
24
- postgres = @apps.postgres(app)
25
- egress = @apps.egress_ip(app)
26
- {
27
- pod_status: pod_status(app),
28
- replicas: replicas(app),
29
- postgres: !postgres.nil?,
30
- redis: redis?(app),
31
- egress_ip: !egress.nil?,
32
- tier: tier,
33
- resources: TIER_RESOURCES[tier],
34
- domains: @apps.domains(app),
35
- stage: @apps.stage(app),
36
- workers: @apps.workers(app),
37
- postgres_details: postgres,
38
- redis_details: redis_details(app),
39
- egress_ip_details: egress && { ip: egress },
40
- }
41
- end
42
-
43
- private
44
-
45
- def pod_status(app)
46
- @kubectl.run("get", "pod", "-n", app, "-l", "app=#{app}",
47
- "-o", "jsonpath={.items[0].status.phase}").strip
48
- end
49
-
50
- def replicas(app)
51
- @kubectl.run("get", "deployment", app, "-n", app,
52
- "-o", "jsonpath={.spec.replicas}").strip.to_i
53
- end
54
-
55
- def redis?(app)
56
- !@kubectl.run("get", "deployment", "-n", app, "-l", "app=redis",
57
- "--no-headers", "-o", "name").strip.empty?
58
- end
59
-
60
- # Redis est un Deployment ordinaire : sa taille se lit sur le conteneur, pas sur une CRD.
61
- def redis_details(app)
62
- limits = @kubectl.run(
63
- "get", "deployment", "-n", app, "-l", "app=redis",
64
- "-o", "jsonpath={.items[0].spec.template.spec.containers[0].resources.limits.memory}"
65
- ).strip
66
- limits.empty? ? nil : { memory: limits }
67
- end
68
- end
69
- end
70
- end
@@ -1,52 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../errors"
4
- require_relative "../platform_root"
5
-
6
- module Captive
7
- module Commands
8
- class AppsRebuild
9
- # Dernière commande du CLI développeur qui lit le repo GitOps, contre l'ADR 0016.
10
- # Elle déduit le repo GitHub de l'image, or les images valent
11
- # ghcr.io/captive-studio/captive-platform/<app> : la déduction rend trois segments, qui
12
- # ne désignent aucun repo. Rien dans le service ni sur le cluster n'enregistre le repo
13
- # d'origine, donc la commande n'est pas portable en l'état — voir la note de l'étape 9.
14
- # Elle exige donc un clone de captive-platform (PlatformRoot), jusqu'à passer par l'API
15
- # du dashboard.
16
- def initialize(gh:, repo_root: nil)
17
- @gh = gh
18
- @repo_root = repo_root
19
- end
20
-
21
- def run(app)
22
- repo = extract_repo(app)
23
- version = current_version(app)
24
- fields = version ? { version: version } : {}
25
- @gh.trigger_workflow(repo, ref: "main", fields: fields)
26
- end
27
-
28
- private
29
-
30
- def repo_root = @repo_root ||= PlatformRoot.find
31
-
32
- def extract_repo(app)
33
- image_file = File.join(repo_root, "services", app, "image.yaml")
34
- raise ImageYamlNotFound, "image.yaml introuvable pour '#{app}'" unless File.exist?(image_file)
35
-
36
- content = File.read(image_file)
37
- image = content.match(/image:\s+ghcr\.io\/([^\s:]+)/)&.captures&.first
38
- raise ImageYamlNotFound, "image introuvable dans image.yaml de '#{app}'" unless image
39
-
40
- image
41
- end
42
-
43
- def current_version(app)
44
- deploy_file = File.join(repo_root, "services", app, "#{app}.yaml")
45
- return nil unless File.exist?(deploy_file)
46
-
47
- tag = File.read(deploy_file).match(/:(v\d+\.\d+\.\d+(?:-\d{14})?)/)&.captures&.first
48
- tag&.sub(/-\d{14}$/, "")
49
- end
50
- end
51
- end
52
- end
@@ -1,25 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "env_list_kube"
4
-
5
- module Captive
6
- module Commands
7
- class EnvKubeCommands < Thor
8
- include CLIDependencies
9
-
10
- def self.exit_on_failure? = true
11
-
12
- desc "list APP", "Liste les variables d'environnement de l'app depuis K8s"
13
- long_desc <<~DESC
14
- Lit directement le secret <app>-env depuis le cluster Kubernetes.
15
-
16
- \x05Exemple :
17
- \x05 captive env list my-app-staging
18
- DESC
19
- def list(app)
20
- vars = EnvListKube.new(kubectl: kubectl).run(app)
21
- vars.sort.each { |v| puts v }
22
- end
23
- end
24
- end
25
- end
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Captive
4
- module Commands
5
- class EnvListKube
6
- def initialize(kubectl:)
7
- @kubectl = kubectl
8
- end
9
-
10
- def run(app)
11
- data = @kubectl.get_secret_data(app, "#{app}-env")
12
- data.map { |k, v| "#{k}=#{v}" }
13
- end
14
- end
15
- end
16
- end
@@ -1,89 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "open3"
5
- require_relative "errors"
6
-
7
- module Captive
8
- class GhCLI
9
- def trigger_workflow(repo, workflow: "ci.yml", ref: "main", fields: {})
10
- field_args = fields.flat_map { |k, v| [ "--field", "#{k}=#{v}" ] }
11
- run("workflow", "run", workflow, "--repo", repo, "--ref", ref, *field_args)
12
- rescue RuntimeError => e
13
- raise WorkflowDispatchNotConfigured,
14
- "Le workflow '#{workflow}' de '#{repo}' n'a pas de trigger workflow_dispatch.\n" \
15
- " Regénère le ci.yml : captive apps launch <app> dans le repo de l'app." \
16
- if e.message.include?("workflow_dispatch")
17
- raise
18
- end
19
-
20
- def workflow_exists?(repo)
21
- %w[deploy.yml ci.yml].any? do |file|
22
- run("api", "/repos/#{repo}/contents/.github/workflows/#{file}") && true
23
- rescue RuntimeError
24
- false
25
- end
26
- end
27
-
28
- def latest_run_status(repo)
29
- output = run("run", "list", "--repo", repo, "--limit", "1",
30
- "--json", "status,conclusion", "--jq", ".[0]")
31
- data = JSON.parse(output)
32
- return :success if data["status"] == "completed" && data["conclusion"] == "success"
33
- return :failure if data["status"] == "completed"
34
-
35
- :in_progress
36
- end
37
-
38
- def image_exists?(repo, tag)
39
- output = run("api", "/repos/#{repo}/packages/container/#{repo.split("/").last}/versions",
40
- "--jq", ".[].metadata.container.tags[]")
41
- output.split("\n").include?(tag)
42
- rescue RuntimeError => e
43
- return false if e.message.include?("404") || e.message.include?("Not Found")
44
-
45
- raise
46
- end
47
-
48
- def app_installed?(repo)
49
- run("api", "repos/#{repo}/installation")
50
- true
51
- rescue RuntimeError
52
- false
53
- end
54
-
55
- def detect_type(repo)
56
- data = JSON.parse(run("api", "repos/#{repo}"))
57
- case data["language"]
58
- when "Ruby" then "rails-app"
59
- when "JavaScript", "TypeScript" then "node-app"
60
- else raise UnknownAppType, "language non supportée : #{data["language"]}"
61
- end
62
- end
63
-
64
- PLATFORM_REPO = "captive-studio/captive-platform"
65
-
66
- def dispatch_build(repo)
67
- repo_name = repo.split("/").last.downcase
68
- sha = run("api", "repos/#{repo}/git/ref/heads/main", "--jq", ".object.sha").strip
69
- body = JSON.generate(
70
- event_type: "build-app",
71
- client_payload: { repo: repo, repo_name: repo_name, sha: sha, ref: "refs/heads/main" }
72
- )
73
- run_with_input("api", "repos/#{PLATFORM_REPO}/dispatches", "-X", "POST", "--input", "-",
74
- input: body)
75
- end
76
-
77
- def run(*args, input: nil)
78
- opts = input ? { stdin_data: input } : {}
79
- out, err, status = Open3.capture3("gh", *args, **opts)
80
- raise "gh #{args.join(" ")} failed: #{err}" unless status.success?
81
-
82
- out
83
- end
84
-
85
- def run_with_input(*args, input:)
86
- run(*args, input: input)
87
- end
88
- end
89
- end
@@ -1,25 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "pathname"
4
- require_relative "errors"
5
-
6
- module Captive
7
- # Où est le clone de captive-platform. Installé en gem, le CLI ne vit plus dans le monorepo :
8
- # la racine se trouve en remontant depuis le dossier courant, comme git trouve .git, ou se
9
- # désigne par CAPTIVE_PLATFORM_ROOT. Seul `apps rebuild` en a encore besoin (ADR 0018).
10
- module PlatformRoot
11
- ENV_VAR = "CAPTIVE_PLATFORM_ROOT"
12
-
13
- def self.find(start: Dir.pwd, env: ENV)
14
- env[ENV_VAR] || ascend(start) ||
15
- raise(PlatformRootNotFound, "Clone de captive-platform introuvable depuis #{start}. " \
16
- "Lance la commande depuis le clone ou définis #{ENV_VAR}.")
17
- end
18
-
19
- def self.ascend(start)
20
- Pathname.new(start).realpath.ascend
21
- .find { |dir| dir.join("services").directory? && dir.join(".sops.yaml").file? }&.to_s
22
- end
23
- private_class_method :ascend
24
- end
25
- end