captive-cli 1.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 29616a82b79478784474098a994449670c0e5ba17c4ddbc10b773168adbd5480
4
- data.tar.gz: 5dfa16e3181afafa0f3800bfbacce3b07ab0eac18f3863a785b58b66fa4726d4
3
+ metadata.gz: f79f7992c692e80419a6da77317d0e3d50d72ee4b48d41ea6001fa3e8a968400
4
+ data.tar.gz: 43220b3bb3e0b9cdaae4bf96de10d80eb3145af3f52584bff1c73fb45d4e7349
5
5
  SHA512:
6
- metadata.gz: 66462246f725f169bdd2c74e41bf896774fc821961afd12171455b1c4ce2ef94006c3f091203bb7495f8ba70e97e15a6c194ac21100a38b3072c8c6f960f8955
7
- data.tar.gz: 4c78529a60c79c0021dcb53a8f124b9fd4a706caa05cb22bb65a4f3a25687b0c288f329301c51628c6f9b216822d7ddbc3e745698f609c2834dc181d93eaef12
6
+ metadata.gz: cec86c2467edb73bb4c46c520c2fb793c1c4b67800213d5ef68348aa2ddae4c91b25e85d82fe73e599fc7cb188aee96b63feb5674b10885e7aa7100027f240ac
7
+ data.tar.gz: a69f88f24623b2329515daff60be1df8d3092046c51cd20b995cb6a0ba58a176dc29898640a07cdc6d9904315b10a321b7d7f6cc9cb7c301e768e991d55a3980
data/lib/captive/cli.rb CHANGED
@@ -8,6 +8,8 @@ require_relative "commands/env_commands"
8
8
  require_relative "commands/crons_commands"
9
9
  require_relative "commands/db_commands"
10
10
  require_relative "commands/user_login"
11
+ require_relative "commands/auth_commands"
12
+ require_relative "credentials_store"
11
13
  require_relative "dashboard_client"
12
14
  require_relative "github_device_flow"
13
15
  require_relative "cluster_endpoint"
@@ -23,13 +25,7 @@ module Captive
23
25
  def self.start(args = ARGV, config = {})
24
26
  args = [ "help", args.first ] if args.length == 2 && %w[--help -h].include?(args.last)
25
27
 
26
- case UpdateChecker.new.check
27
- when :blocked
28
- warn "Version #{VERSION} n'est plus supportée. Mettez à jour : brew upgrade captive-studio/tap/captive-cli"
29
- exit 1
30
- when :update_available
31
- warn "⚠ Nouvelle version disponible. Mettez à jour : brew upgrade captive-studio/tap/captive-cli"
32
- end
28
+ check_for_update unless args.first(2) == %w[auth token]
33
29
  super
34
30
  rescue AuthError, NotAuthenticatedError, NoTtyError, MissingDependencyError, DashboardError => e
35
31
  warn e.message
@@ -40,6 +36,19 @@ module Captive
40
36
  exit 1
41
37
  end
42
38
 
39
+ # kubectl appelle `captive auth token` à chaque commande : ni requête réseau en plus, ni
40
+ # avertissement à afficher.
41
+ def self.check_for_update
42
+ case UpdateChecker.new.check
43
+ when :blocked
44
+ warn "Version #{VERSION} n'est plus supportée. Mettez à jour : brew upgrade captive-studio/tap/captive-cli"
45
+ exit 1
46
+ when :update_available
47
+ warn "⚠ Nouvelle version disponible. Mettez à jour : brew upgrade captive-studio/tap/captive-cli"
48
+ end
49
+ end
50
+ private_class_method :check_for_update
51
+
43
52
  # ── Subcommands ─────────────────────────────────────────────────────────
44
53
  desc "apps SUBCOMMAND", "Gestion des apps"
45
54
  subcommand "apps", Commands::AppsCommands
@@ -53,6 +62,9 @@ module Captive
53
62
  desc "crons SUBCOMMAND", "Tâches planifiées d'une app"
54
63
  subcommand "crons", Commands::CronsCommands
55
64
 
65
+ desc "auth SUBCOMMAND", "Jeton pour kubectl", hide: true
66
+ subcommand "auth", Commands::AuthCommands
67
+
56
68
  # ── Root commands ────────────────────────────────────────────────────────
57
69
  map "--version" => :version
58
70
  desc "version", "Affiche la version de la CLI"
@@ -69,7 +81,7 @@ module Captive
69
81
  Commands::UserLogin.new(
70
82
  github: GithubDeviceFlow.new,
71
83
  cluster: ClusterEndpoint.new,
72
- token_path: File.expand_path("~/.config/captive/token"),
84
+ store: CredentialsStore.new,
73
85
  kubeconfig_path: File.expand_path("~/.config/captive/kubeconfig")
74
86
  ).call
75
87
  puts "Authentifié avec succès."
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "auth_token"
4
+ require_relative "../session_token"
5
+
6
+ module Captive
7
+ module Commands
8
+ # `captive auth` : ce dont kubectl a besoin pour s'authentifier, masqué de l'aide.
9
+ class AuthCommands < Thor
10
+ def self.exit_on_failure? = true
11
+
12
+ desc "token", "Imprime un ExecCredential pour kubectl (appelé par le kubeconfig)"
13
+ def token = AuthToken.new(session: SessionToken.new).call
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Captive
7
+ module Commands
8
+ # Le plugin d'authentification exec de kubectl : un ExecCredential sur stdout, et rien d'autre.
9
+ class AuthToken
10
+ def initialize(session:, out: $stdout)
11
+ @session = session
12
+ @out = out
13
+ end
14
+
15
+ def call
16
+ @out.puts JSON.generate(exec_credential(@session.current))
17
+ end
18
+
19
+ private
20
+
21
+ def exec_credential(credentials)
22
+ expires_at = credentials.expires_at
23
+ status = { token: credentials.access_token }
24
+ status[:expirationTimestamp] = expires_at.utc.iso8601 if expires_at
25
+ { apiVersion: "client.authentication.k8s.io/v1", kind: "ExecCredential", status: status }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -9,36 +9,36 @@ module Captive
9
9
  def open(url) = system(open_cmd, url)
10
10
  end
11
11
 
12
- def initialize(github:, cluster:, token_path:, kubeconfig_path:,
12
+ def initialize(github:, cluster:, store:, kubeconfig_path:,
13
13
  ui: method(:puts), browser: Browser.new("open"))
14
14
  @github = github
15
15
  @cluster = cluster
16
- @token_path = token_path
16
+ @store = store
17
17
  @kubeconfig_path = kubeconfig_path
18
18
  @ui = ui
19
19
  @browser = browser
20
20
  end
21
21
 
22
22
  def call
23
- ensure_dirs
24
23
  device = @github.request_device_code
25
24
  @browser.open(device[:verification_uri])
26
25
  @ui.call("Entre le code sur #{device[:verification_uri]} : #{device[:user_code]}")
27
- token = @github.poll_token(device_code: device[:device_code], interval: device[:interval])
28
- File.write(@token_path, token)
29
- File.chmod(0o600, @token_path)
30
- File.write(@kubeconfig_path, kubeconfig_content(token))
31
- File.chmod(0o600, @kubeconfig_path)
26
+ credentials = @github.poll_token(device_code: device[:device_code], interval: device[:interval])
27
+ @store.write(credentials)
28
+ write_kubeconfig(@cluster.fetch(credentials.access_token))
32
29
  end
33
30
 
34
31
  private
35
32
 
36
- def ensure_dirs
37
- [ @token_path, @kubeconfig_path ].each { |p| FileUtils.mkdir_p(File.dirname(p)) }
33
+ def write_kubeconfig(endpoint)
34
+ FileUtils.mkdir_p(File.dirname(@kubeconfig_path))
35
+ File.write(@kubeconfig_path, kubeconfig_content(endpoint))
36
+ File.chmod(0o600, @kubeconfig_path)
38
37
  end
39
38
 
40
- def kubeconfig_content(token)
41
- endpoint = @cluster.fetch(token)
39
+ # Pas de jeton dans le kubeconfig : kubectl demande à `captive auth token` un jeton
40
+ # renouvelé quand il le faut.
41
+ def kubeconfig_content(endpoint)
42
42
  <<~YAML
43
43
  apiVersion: v1
44
44
  kind: Config
@@ -50,7 +50,14 @@ module Captive
50
50
  users:
51
51
  - name: captive-user
52
52
  user:
53
- token: #{token}
53
+ exec:
54
+ apiVersion: client.authentication.k8s.io/v1
55
+ command: captive
56
+ args:
57
+ - auth
58
+ - token
59
+ interactiveMode: Never
60
+ provideClusterInfo: false
54
61
  contexts:
55
62
  - name: captive
56
63
  context:
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Captive
6
+ # Les jetons GitHub de l'utilisateur et leurs échéances. Un jeton sans `expires_at` vient d'une
7
+ # GitHub App sans expiration (ou de l'ancien fichier `token`) : il n'est jamais renouvelé.
8
+ Credentials = Data.define(:access_token, :refresh_token, :expires_at, :refresh_token_expires_at) do
9
+ def self.from_github(data, now:)
10
+ new(access_token: data.fetch("access_token"),
11
+ refresh_token: data["refresh_token"],
12
+ expires_at: after(now, data["expires_in"]),
13
+ refresh_token_expires_at: after(now, data["refresh_token_expires_in"]))
14
+ end
15
+
16
+ def self.from_json_hash(hash)
17
+ new(access_token: hash.fetch("access_token"),
18
+ refresh_token: hash["refresh_token"],
19
+ expires_at: parse_time(hash["expires_at"]),
20
+ refresh_token_expires_at: parse_time(hash["refresh_token_expires_at"]))
21
+ end
22
+
23
+ def self.after(now, seconds) = seconds && (now + seconds.to_i)
24
+ def self.parse_time(value) = value && Time.iso8601(value)
25
+ private_class_method :after, :parse_time
26
+
27
+ def initialize(access_token:, refresh_token: nil, expires_at: nil, refresh_token_expires_at: nil) = super
28
+
29
+ # Moins de `margin` secondes de validité : on renouvelle avant qu'une requête n'échoue.
30
+ def expiring?(now, margin: 300) = expirable? && expires_at - now < margin
31
+ def expirable? = !expires_at.nil?
32
+ def refreshable?(now) = !refresh_token.nil? && (refresh_token_expires_at.nil? || refresh_token_expires_at > now)
33
+
34
+ def to_json_hash
35
+ {
36
+ "access_token" => access_token,
37
+ "refresh_token" => refresh_token,
38
+ "expires_at" => expires_at&.utc&.iso8601,
39
+ "refresh_token_expires_at" => refresh_token_expires_at&.utc&.iso8601,
40
+ }
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "securerandom"
6
+ require_relative "credentials"
7
+ require_relative "errors"
8
+
9
+ module Captive
10
+ # Où vivent les jetons : `credentials.json` (0600, écrit atomiquement), et en repli l'ancien
11
+ # fichier `token` des postes qui ne se sont pas reconnectés depuis l'arrivée du renouvellement.
12
+ class CredentialsStore
13
+ DIR = File.expand_path("~/.config/captive")
14
+
15
+ def initialize(dir: DIR)
16
+ @path = File.join(dir, "credentials.json")
17
+ @legacy_path = File.join(dir, "token")
18
+ @lock_path = File.join(dir, "credentials.lock")
19
+ end
20
+
21
+ def read
22
+ return Credentials.from_json_hash(JSON.parse(File.read(@path))) if File.exist?(@path)
23
+ return Credentials.new(access_token: File.read(@legacy_path).strip) if File.exist?(@legacy_path)
24
+
25
+ raise NotAuthenticatedError, "Pas de session active. Lance : captive login"
26
+ end
27
+
28
+ # Un fichier temporaire renommé : un lecteur concurrent voit l'ancien contenu ou le nouveau,
29
+ # jamais un fichier à moitié écrit.
30
+ def write(credentials)
31
+ FileUtils.mkdir_p(File.dirname(@path))
32
+ tmp = "#{@path}.#{SecureRandom.hex(4)}.tmp"
33
+ File.write(tmp, JSON.pretty_generate(credentials.to_json_hash), perm: 0o600)
34
+ File.rename(tmp, @path)
35
+ end
36
+
37
+ # Verrou inter-processus : deux commandes (ou kubectl et une commande) ne renouvellent pas en
38
+ # même temps un refresh_token à usage unique.
39
+ def exclusively
40
+ FileUtils.mkdir_p(File.dirname(@lock_path))
41
+ File.open(@lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
42
+ lock.flock(File::LOCK_EX)
43
+ yield
44
+ end
45
+ end
46
+ end
47
+ end
@@ -3,11 +3,12 @@
3
3
  require "net/http"
4
4
  require "uri"
5
5
  require "json"
6
- require_relative "token_file"
6
+ require_relative "session_token"
7
7
  require_relative "dashboard_response"
8
8
 
9
9
  module Captive
10
- # Le transport JSON vers l'API du dashboard : jeton GitHub en Bearer, timeouts courts, et
10
+ # Le transport JSON vers l'API du dashboard : jeton GitHub en Bearer (rejoué une fois renouvelé
11
+ # sur un 401), timeouts courts, et
11
12
  # erreurs HTTP traduites par DashboardResponse. Les chemins sont relatifs à /api.
12
13
  class DashboardApi
13
14
  BASE_URL = "https://dashboard.captive.dev"
@@ -17,7 +18,7 @@ module Captive
17
18
 
18
19
  def self.segment(value) = URI.encode_uri_component(value.to_s)
19
20
 
20
- def initialize(token: TokenFile.new, base_url: BASE_URL, starter: Net::HTTP.method(:start),
21
+ def initialize(token: SessionToken.new, base_url: BASE_URL, starter: Net::HTTP.method(:start),
21
22
  read_timeout: 15)
22
23
  @token = token
23
24
  @base_url = base_url
@@ -34,17 +35,18 @@ module Captive
34
35
 
35
36
  def request(verb, path, body = nil)
36
37
  uri = URI.parse("#{@base_url}/api#{path}")
37
- req = build(verb, uri, body)
38
- response = @starter.call(uri.host, uri.port, use_ssl: uri.scheme == "https",
39
- open_timeout: 5, read_timeout: @read_timeout) do |http|
40
- http.request(req)
38
+ response = @token.authorize do |token|
39
+ @starter.call(uri.host, uri.port, use_ssl: uri.scheme == "https",
40
+ open_timeout: 5, read_timeout: @read_timeout) do |http|
41
+ http.request(build(verb, uri, body, token))
42
+ end
41
43
  end
42
44
  DashboardResponse.new(response).parse
43
45
  end
44
46
 
45
- def build(verb, uri, body)
47
+ def build(verb, uri, body, token)
46
48
  req = VERBS.fetch(verb).new(uri.request_uri)
47
- req["Authorization"] = "Bearer #{@token.read}"
49
+ req["Authorization"] = "Bearer #{token}"
48
50
  req["Accept"] = "application/json"
49
51
  if body
50
52
  req["Content-Type"] = "application/json"
@@ -5,22 +5,24 @@ require "uri"
5
5
  require "json"
6
6
  require "time"
7
7
  require_relative "errors"
8
+ require_relative "session_token"
8
9
 
9
10
  module Captive
10
11
  class DashboardClient
11
12
  DASHBOARD_URL = "https://dashboard.captive.dev"
12
- TOKEN_PATH = File.expand_path("~/.config/captive/token")
13
13
 
14
14
  PAGE_SIZE = 500
15
15
 
16
- def logs(app, start_at: nil, end_at: nil, limit: nil)
17
- token = read_token
16
+ def initialize(session: SessionToken.new)
17
+ @session = session
18
+ end
18
19
 
20
+ def logs(app, start_at: nil, end_at: nil, limit: nil)
19
21
  if start_at || end_at
20
- fetch_all(app, token, start_at:, end_at:)
22
+ fetch_all(app, start_at:, end_at:)
21
23
  else
22
24
  uri = build_uri(app, limit: limit || PAGE_SIZE)
23
- response = get(uri, token)
25
+ response = get(uri)
24
26
  page, _ = extract_page(handle_response(response))
25
27
  page
26
28
  end
@@ -34,20 +36,20 @@ module Captive
34
36
  body[:command] = command if command
35
37
  body[:session] = session if session
36
38
 
37
- post(uri, read_token, body)
39
+ post(uri, body)
38
40
  end
39
41
 
40
42
  private
41
43
 
42
44
  # Pagine en remontant depuis end_at vers start_at (direction backward côté Loki).
43
45
  # Chaque page utilise le ts du log le plus ancien comme prochain curseur `before`.
44
- def fetch_all(app, token, start_at:, end_at:)
46
+ def fetch_all(app, start_at:, end_at:)
45
47
  pages = []
46
48
  cursor = end_at
47
49
 
48
50
  loop do
49
51
  uri = build_uri(app, start_at:, before: cursor, limit: PAGE_SIZE)
50
- response = get(uri, token)
52
+ response = get(uri)
51
53
  page, has_more = extract_page(handle_response(response))
52
54
 
53
55
  break if page.empty?
@@ -74,12 +76,6 @@ module Captive
74
76
  end
75
77
  end
76
78
 
77
- def read_token
78
- raise NotAuthenticatedError, "Pas de session active. Lance : captive login" unless File.exist?(TOKEN_PATH)
79
-
80
- File.read(TOKEN_PATH).strip
81
- end
82
-
83
79
  def build_uri(app, start_at: nil, end_at: nil, before: nil, limit:)
84
80
  params = { limit: }
85
81
  params[:start_at] = start_at.utc.iso8601(3) if start_at
@@ -90,21 +86,25 @@ module Captive
90
86
  uri
91
87
  end
92
88
 
93
- def get(uri, token)
94
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
95
- open_timeout: 5, read_timeout: 30) do |http|
96
- http.get(uri.request_uri, "Authorization" => "Bearer #{token}", "Accept" => "application/json")
89
+ def get(uri)
90
+ @session.authorize do |token|
91
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
92
+ open_timeout: 5, read_timeout: 30) do |http|
93
+ http.get(uri.request_uri, "Authorization" => "Bearer #{token}", "Accept" => "application/json")
94
+ end
97
95
  end
98
96
  end
99
97
 
100
98
  # Timeouts courts : une console ne doit pas attendre un dashboard qui ne répond pas.
101
- def post(uri, token, body)
102
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
103
- open_timeout: 2, read_timeout: 5) do |http|
104
- http.post(uri.request_uri, JSON.generate(body),
105
- "Authorization" => "Bearer #{token}",
106
- "Accept" => "application/json",
107
- "Content-Type" => "application/json")
99
+ def post(uri, body)
100
+ @session.authorize do |token|
101
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
102
+ open_timeout: 2, read_timeout: 5) do |http|
103
+ http.post(uri.request_uri, JSON.generate(body),
104
+ "Authorization" => "Bearer #{token}",
105
+ "Accept" => "application/json",
106
+ "Content-Type" => "application/json")
107
+ end
108
108
  end
109
109
  end
110
110
 
@@ -9,6 +9,11 @@ module Captive
9
9
  class ExecError < StandardError; end
10
10
  class AuthError < StandardError; end
11
11
  class NotAuthenticatedError < StandardError; end
12
+
13
+ # Le refresh_token a expiré ou GitHub le refuse : seul un nouveau `captive login` rétablit l'accès.
14
+ class SessionExpiredError < AuthError
15
+ def initialize(message = "Session expirée. Lance : captive login") = super
16
+ end
12
17
  class NoTtyError < StandardError; end
13
18
  class MissingDependencyError < StandardError; end
14
19
 
@@ -1,13 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "http_client"
4
+ require_relative "credentials"
4
5
 
5
6
  module Captive
6
7
  class GithubDeviceFlow
7
8
  CLIENT_ID = "Iv23liHr9n6eK4XhwtnP"
8
9
 
9
- def initialize(http: HttpClient.new)
10
- @http = http
10
+ def initialize(http: HttpClient.new, clock: -> { Time.now })
11
+ @http = http
12
+ @clock = clock
11
13
  end
12
14
 
13
15
  RETRIABLE_ERRORS = %w[authorization_pending slow_down].freeze
@@ -19,7 +21,7 @@ module Captive
19
21
  data = @http.post("https://github.com/login/oauth/access_token",
20
22
  client_id: CLIENT_ID, device_code: device_code,
21
23
  grant_type: "urn:ietf:params:oauth:grant-type:device_code")
22
- return data["access_token"] if data["access_token"]
24
+ return Credentials.from_github(data, now: @clock.call) if data["access_token"]
23
25
  raise DeviceFlowError, data["error"] unless RETRIABLE_ERRORS.include?(data["error"])
24
26
  end
25
27
  end
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Captive
4
- VERSION = "1.2.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.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Captive Studio
@@ -109,6 +109,8 @@ files:
109
109
  - lib/captive/cluster_endpoint.rb
110
110
  - lib/captive/commands/apps_commands.rb
111
111
  - lib/captive/commands/apps_restart.rb
112
+ - lib/captive/commands/auth_commands.rb
113
+ - lib/captive/commands/auth_token.rb
112
114
  - lib/captive/commands/console.rb
113
115
  - lib/captive/commands/cron_declaration.rb
114
116
  - lib/captive/commands/crons_commands.rb
@@ -124,6 +126,8 @@ files:
124
126
  - lib/captive/commands/one_off_session.rb
125
127
  - lib/captive/commands/run.rb
126
128
  - lib/captive/commands/user_login.rb
129
+ - lib/captive/credentials.rb
130
+ - lib/captive/credentials_store.rb
127
131
  - lib/captive/dashboard/apps_resource.rb
128
132
  - lib/captive/dashboard/env_vars_resource.rb
129
133
  - lib/captive/dashboard/scheduled_tasks_resource.rb
@@ -146,9 +150,10 @@ files:
146
150
  - lib/captive/presenter/log_line.rb
147
151
  - lib/captive/presenter/scheduled_task.rb
148
152
  - lib/captive/session_id.rb
153
+ - lib/captive/session_token.rb
149
154
  - lib/captive/shell_completions.rb
150
155
  - lib/captive/terminal.rb
151
- - lib/captive/token_file.rb
156
+ - lib/captive/token_refresher.rb
152
157
  - lib/captive/ui/banner.rb
153
158
  - lib/captive/ui/spinner.rb
154
159
  - lib/captive/update_checker.rb
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "errors"
4
-
5
- module Captive
6
- # Le jeton GitHub écrit par `captive login`, qui authentifie toutes les requêtes au dashboard.
7
- class TokenFile
8
- PATH = File.expand_path("~/.config/captive/token")
9
-
10
- def initialize(path: PATH)
11
- @path = path
12
- end
13
-
14
- def read
15
- raise NotAuthenticatedError, "Pas de session active. Lance : captive login" unless File.exist?(@path)
16
-
17
- File.read(@path).strip
18
- end
19
- end
20
- end