captive-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/exe/captive +6 -0
- data/lib/captive/activity_trail.rb +38 -0
- data/lib/captive/cli/dependencies.rb +42 -0
- data/lib/captive/cli.rb +170 -0
- data/lib/captive/cluster_apps.rb +82 -0
- data/lib/captive/cluster_endpoint.rb +23 -0
- data/lib/captive/commands/apps_commands.rb +104 -0
- data/lib/captive/commands/apps_info.rb +70 -0
- data/lib/captive/commands/apps_rebuild.rb +52 -0
- data/lib/captive/commands/apps_restart.rb +24 -0
- data/lib/captive/commands/console.rb +32 -0
- data/lib/captive/commands/db_commands.rb +105 -0
- data/lib/captive/commands/db_dump.rb +33 -0
- data/lib/captive/commands/db_exec.rb +24 -0
- data/lib/captive/commands/db_psql.rb +20 -0
- data/lib/captive/commands/db_restore.rb +145 -0
- data/lib/captive/commands/db_target.rb +19 -0
- data/lib/captive/commands/env_kube_commands.rb +25 -0
- data/lib/captive/commands/env_list_kube.rb +16 -0
- data/lib/captive/commands/logs.rb +65 -0
- data/lib/captive/commands/one_off_session.rb +76 -0
- data/lib/captive/commands/run.rb +24 -0
- data/lib/captive/commands/user_login.rb +64 -0
- data/lib/captive/dashboard_client.rb +119 -0
- data/lib/captive/errors.rb +16 -0
- data/lib/captive/flux.rb +15 -0
- data/lib/captive/gh_cli.rb +89 -0
- data/lib/captive/github_device_flow.rb +39 -0
- data/lib/captive/http_client.rb +31 -0
- data/lib/captive/kubectl.rb +330 -0
- data/lib/captive/loader.rb +33 -0
- data/lib/captive/log_noise_filter.rb +17 -0
- data/lib/captive/null_log_filter.rb +8 -0
- data/lib/captive/one_off_spec.rb +144 -0
- data/lib/captive/platform_root.rb +25 -0
- data/lib/captive/poller.rb +35 -0
- data/lib/captive/presenter/ansi_stripper.rb +22 -0
- data/lib/captive/presenter/log_colorizer.rb +54 -0
- data/lib/captive/presenter/log_line.rb +40 -0
- data/lib/captive/session_id.rb +20 -0
- data/lib/captive/shell_completions.rb +61 -0
- data/lib/captive/terminal.rb +10 -0
- data/lib/captive/ui/banner.rb +37 -0
- data/lib/captive/ui/spinner.rb +23 -0
- data/lib/captive/update_checker.rb +65 -0
- data/lib/captive/version.rb +5 -0
- metadata +172 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Captive
|
|
4
|
+
# Écarte le bruit d'infrastructure des logs applicatifs.
|
|
5
|
+
#
|
|
6
|
+
# La readinessProbe frappe /up toutes les 3s : Rails tait la requête
|
|
7
|
+
# (silence_healthcheck_path) mais rack-timeout logge son propre couple
|
|
8
|
+
# ready/completed, soit ~40 lignes/minute et par pod qui noient les lignes
|
|
9
|
+
# utiles. Les états timed_out et expired, eux, signalent un incident réel et
|
|
10
|
+
# restent affichés.
|
|
11
|
+
class LogNoiseFilter
|
|
12
|
+
SILENCED_STATES = %w[ready active completed].freeze
|
|
13
|
+
RACK_TIMEOUT_STATE = /source=rack-timeout\b.*\bstate=(?:#{SILENCED_STATES.join("|")})\b/
|
|
14
|
+
|
|
15
|
+
def noise?(line) = RACK_TIMEOUT_STATE.match?(line)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Captive
|
|
4
|
+
# Construit le manifeste Job one-off à partir du Deployment web courant.
|
|
5
|
+
# Labels app: <app>-run pour ne jamais entrer dans le Service web (app=<app>).
|
|
6
|
+
#
|
|
7
|
+
# Toujours un Job, même pour la console interactive : ttlSecondsAfterFinished
|
|
8
|
+
# laisse vivre le pod terminé — donc son log — quelques minutes après la
|
|
9
|
+
# sortie, ce qu'un Pod nu ne sait pas faire (pas de TTL sur un Pod).
|
|
10
|
+
class OneOffSpec
|
|
11
|
+
CONTAINER_NAME = "one-off"
|
|
12
|
+
CONSOLE_DEADLINE_SECONDS = 4 * 3600
|
|
13
|
+
RUN_DEADLINE_SECONDS = 3600
|
|
14
|
+
TTL_SECONDS = 600
|
|
15
|
+
|
|
16
|
+
PRODUCTION_NODE_AFFINITY = {
|
|
17
|
+
"nodeAffinity" => {
|
|
18
|
+
"requiredDuringSchedulingIgnoredDuringExecution" => {
|
|
19
|
+
"nodeSelectorTerms" => [
|
|
20
|
+
{
|
|
21
|
+
"matchExpressions" => [
|
|
22
|
+
{
|
|
23
|
+
"key" => "captive.dev/workload",
|
|
24
|
+
"operator" => "In",
|
|
25
|
+
"values" => [ "production" ],
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
def initialize(app:, deployment:, command:, interactive: false, session: nil)
|
|
35
|
+
@app = app
|
|
36
|
+
@deployment = deployment
|
|
37
|
+
@command = Array(command)
|
|
38
|
+
@interactive = interactive
|
|
39
|
+
@session = session
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_h = job_manifest
|
|
43
|
+
|
|
44
|
+
def self.run_app_label(app)
|
|
45
|
+
"#{app}-run"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.live_logs_selector(app)
|
|
49
|
+
"app in (#{app},#{run_app_label(app)})"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def job_manifest
|
|
55
|
+
{
|
|
56
|
+
"apiVersion" => "batch/v1",
|
|
57
|
+
"kind" => "Job",
|
|
58
|
+
"metadata" => metadata,
|
|
59
|
+
"spec" => {
|
|
60
|
+
"backoffLimit" => 0,
|
|
61
|
+
"ttlSecondsAfterFinished" => TTL_SECONDS,
|
|
62
|
+
"activeDeadlineSeconds" => deadline,
|
|
63
|
+
"template" => {
|
|
64
|
+
"metadata" => { "labels" => labels },
|
|
65
|
+
"spec" => pod_spec,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def deadline = @interactive ? CONSOLE_DEADLINE_SECONDS : RUN_DEADLINE_SECONDS
|
|
72
|
+
|
|
73
|
+
# Nommé, et non `generateName`, dès qu'une session est donnée : le pod hérite du nom du
|
|
74
|
+
# Job en préfixe, ce qui rend la session retrouvable dans Loki sur le seul label `pod`.
|
|
75
|
+
# Sans session on garde `generateName`, qui ne suppose rien de l'appelant.
|
|
76
|
+
def metadata
|
|
77
|
+
base = { "namespace" => @app, "labels" => labels }
|
|
78
|
+
return base.merge("generateName" => "#{run_label}-") unless @session
|
|
79
|
+
|
|
80
|
+
base.merge("name" => "#{run_label}-#{@session}")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def labels
|
|
84
|
+
{
|
|
85
|
+
"app" => run_label,
|
|
86
|
+
"captive.dev/type" => "one-off",
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def run_label
|
|
91
|
+
self.class.run_app_label(@app)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def pod_spec
|
|
95
|
+
template = @deployment.dig("spec", "template", "spec") || {}
|
|
96
|
+
source = primary_container(template)
|
|
97
|
+
|
|
98
|
+
spec = {
|
|
99
|
+
"restartPolicy" => "Never",
|
|
100
|
+
"imagePullSecrets" => image_pull_secrets(template),
|
|
101
|
+
"containers" => [ container(source) ],
|
|
102
|
+
"affinity" => node_affinity(template),
|
|
103
|
+
}
|
|
104
|
+
priority = template["priorityClassName"]
|
|
105
|
+
spec["priorityClassName"] = priority if priority
|
|
106
|
+
spec
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def primary_container(template)
|
|
110
|
+
containers = template["containers"] || []
|
|
111
|
+
containers.first || raise(ArgumentError, "Deployment '#{@app}' sans container")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def container(source)
|
|
115
|
+
c = {
|
|
116
|
+
"name" => CONTAINER_NAME,
|
|
117
|
+
"image" => source.fetch("image"),
|
|
118
|
+
"command" => @command,
|
|
119
|
+
}
|
|
120
|
+
c["env"] = source["env"] if source["env"]
|
|
121
|
+
c["envFrom"] = source["envFrom"] if source["envFrom"]
|
|
122
|
+
c["resources"] = source["resources"] if source["resources"]
|
|
123
|
+
if @interactive
|
|
124
|
+
c["stdin"] = true
|
|
125
|
+
c["tty"] = true
|
|
126
|
+
end
|
|
127
|
+
c
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def image_pull_secrets(template)
|
|
131
|
+
secrets = template["imagePullSecrets"]
|
|
132
|
+
return secrets if secrets && !secrets.empty?
|
|
133
|
+
|
|
134
|
+
[ { "name" => "ghcr-auth" } ]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def node_affinity(template)
|
|
138
|
+
existing = template.dig("affinity", "nodeAffinity")
|
|
139
|
+
return { "nodeAffinity" => existing } if existing
|
|
140
|
+
|
|
141
|
+
PRODUCTION_NODE_AFFINITY
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Captive
|
|
6
|
+
class Poller
|
|
7
|
+
def initialize(interval:, timeout:, sleeper: method(:sleep), clock: -> { Time.now })
|
|
8
|
+
@interval = interval
|
|
9
|
+
@timeout = timeout
|
|
10
|
+
@sleeper = sleeper
|
|
11
|
+
@clock = clock
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def poll_until(context: nil, on_tick: nil, &block)
|
|
15
|
+
start = @clock.call
|
|
16
|
+
deadline = start + @timeout
|
|
17
|
+
loop do
|
|
18
|
+
return true if block.call
|
|
19
|
+
|
|
20
|
+
now = @clock.call
|
|
21
|
+
raise PollTimeout, timeout_message(context) if now >= deadline
|
|
22
|
+
|
|
23
|
+
on_tick&.call(now - start)
|
|
24
|
+
@sleeper.call(@interval)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def timeout_message(context)
|
|
31
|
+
msg = "timeout après #{@timeout}s"
|
|
32
|
+
context ? "#{msg} — #{context}" : msg
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Captive
|
|
4
|
+
module Presenter
|
|
5
|
+
# Retire les séquences d'échappement terminal d'une ligne de log.
|
|
6
|
+
#
|
|
7
|
+
# Une console one-off est attachée à un TTY : IRB redessine son prompt à
|
|
8
|
+
# chaque frappe et le log du container capture tout (CSI, OSC, requêtes de
|
|
9
|
+
# position du curseur, retours chariot). Sans ce nettoyage, une ligne de
|
|
10
|
+
# console est illisible. Le CLI recolorise lui-même, la couleur d'origine
|
|
11
|
+
# n'a donc rien à conserver.
|
|
12
|
+
class AnsiStripper
|
|
13
|
+
CSI = /\e\[[0-?]*[ -\/]*[@-~]/
|
|
14
|
+
OSC = /\e\][^\a\e]*(?:\a|\e\\)/
|
|
15
|
+
SINGLE = /\e[@-Z\\-_]/
|
|
16
|
+
CARRIAGE_RETURN = /\r/
|
|
17
|
+
SEQUENCES = Regexp.union(CSI, OSC, SINGLE, CARRIAGE_RETURN)
|
|
18
|
+
|
|
19
|
+
def call(message) = message.gsub(SEQUENCES, "")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
require "time"
|
|
5
|
+
require "captive/log_utils"
|
|
6
|
+
require_relative "ansi_stripper"
|
|
7
|
+
|
|
8
|
+
module Captive
|
|
9
|
+
module Presenter
|
|
10
|
+
# Renders a log line as a colorized terminal string using Pastel and LogUtils.
|
|
11
|
+
class LogColorizer
|
|
12
|
+
TIME_FORMAT = "%Y-%m-%d %H:%M:%S.%N %z %Z"
|
|
13
|
+
|
|
14
|
+
PASTEL_COLORS = {
|
|
15
|
+
"blue" => :blue,
|
|
16
|
+
"green" => :green,
|
|
17
|
+
"yellow" => :yellow,
|
|
18
|
+
"red" => :red,
|
|
19
|
+
"magenta" => :magenta,
|
|
20
|
+
"dim" => :bright_black,
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def initialize(paint: Pastel.new, stripper: AnsiStripper.new)
|
|
24
|
+
@paint = paint
|
|
25
|
+
@stripper = stripper
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def format_line(ts:, label:, message:)
|
|
29
|
+
"#{dim_time(ts)} #{badge(label)} #{colorize_message(message)}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def dim_time(ts)
|
|
35
|
+
formatted = Time.iso8601(ts).localtime.strftime(TIME_FORMAT)
|
|
36
|
+
@paint.dim(formatted)
|
|
37
|
+
rescue ArgumentError
|
|
38
|
+
@paint.dim(ts)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def badge(label)
|
|
42
|
+
color = PASTEL_COLORS.fetch(LogUtils::Colors.for_process(label), :white)
|
|
43
|
+
@paint.decorate("[#{label}]", color)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def colorize_message(raw)
|
|
47
|
+
message = @stripper.call(raw)
|
|
48
|
+
level = LogUtils::LogLevelDetector.call(message)
|
|
49
|
+
color = PASTEL_COLORS[LogUtils::Colors.for_level(level)]
|
|
50
|
+
color ? @paint.decorate(message, color) : message
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "captive/log_utils"
|
|
4
|
+
require_relative "log_colorizer"
|
|
5
|
+
|
|
6
|
+
module Captive
|
|
7
|
+
module Presenter
|
|
8
|
+
# Parses a raw kubectl log line and renders it as a colorized terminal string.
|
|
9
|
+
class LogLine
|
|
10
|
+
BRACKET_RE = /\A\[(?:pod\/)?(?<pod>[^\/\]]+)(?:\/(?<container>[^\/\]]+))?\]\z/
|
|
11
|
+
|
|
12
|
+
def initialize(app:, paint: Pastel.new)
|
|
13
|
+
@app = app
|
|
14
|
+
@colorizer = LogColorizer.new(paint: paint)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(raw_line)
|
|
18
|
+
line = raw_line.chomp
|
|
19
|
+
first, second, message = line.split(" ", 3)
|
|
20
|
+
return line if first.nil? || second.nil? || message.nil?
|
|
21
|
+
|
|
22
|
+
bracket, ts = pick_bracket_and_timestamp(first, second)
|
|
23
|
+
return line unless bracket
|
|
24
|
+
|
|
25
|
+
label = LogUtils::ProcessLabel.for(pod: bracket[:pod], namespace: @app, container: bracket[:container])
|
|
26
|
+
@colorizer.format_line(ts: ts, label: label, message: message)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def pick_bracket_and_timestamp(first, second)
|
|
32
|
+
if (bracket = BRACKET_RE.match(first))
|
|
33
|
+
[ bracket, second ]
|
|
34
|
+
elsif (bracket = BRACKET_RE.match(second))
|
|
35
|
+
[ bracket, first ]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Captive
|
|
6
|
+
# L'identifiant qu'une session one-off se donne avant d'exister.
|
|
7
|
+
#
|
|
8
|
+
# Il est tiré côté client, et non déduit du Job créé, parce que l'accès est inscrit au fil
|
|
9
|
+
# d'activité *avant* l'action : à cet instant le Job n'existe pas encore, et une console
|
|
10
|
+
# abandonnée doit quand même laisser une trace qu'on saura relier à son log.
|
|
11
|
+
#
|
|
12
|
+
# Il nomme ensuite le Job, dont le pod hérite le préfixe — c'est ce qui permet au dashboard
|
|
13
|
+
# de retrouver la session dans Loki par le seul label `pod`, sans que la collecte ait à
|
|
14
|
+
# promouvoir quoi que ce soit.
|
|
15
|
+
module SessionId
|
|
16
|
+
BYTES = 4
|
|
17
|
+
|
|
18
|
+
def self.generate = SecureRandom.hex(BYTES)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Captive
|
|
4
|
+
module ShellCompletions
|
|
5
|
+
ROOT_COMMANDS = %w[apps env db login whoami console run logs version completions].freeze
|
|
6
|
+
|
|
7
|
+
SUBCOMMANDS = {
|
|
8
|
+
"apps" => %w[list info restart rebuild],
|
|
9
|
+
"env" => %w[list],
|
|
10
|
+
"db" => %w[restore],
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def self.bash
|
|
14
|
+
all = (ROOT_COMMANDS + SUBCOMMANDS.values.flatten).uniq.join(" ")
|
|
15
|
+
<<~BASH
|
|
16
|
+
_captive_completions() {
|
|
17
|
+
local cur prev words
|
|
18
|
+
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
19
|
+
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
|
20
|
+
|
|
21
|
+
case "$prev" in
|
|
22
|
+
apps) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["apps"].join(" ")}" -- "$cur")); return ;;
|
|
23
|
+
env) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["env"].join(" ")}" -- "$cur")); return ;;
|
|
24
|
+
db) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["db"].join(" ")}" -- "$cur")); return ;;
|
|
25
|
+
esac
|
|
26
|
+
|
|
27
|
+
COMPREPLY=($(compgen -W "#{ROOT_COMMANDS.join(" ")}" -- "$cur"))
|
|
28
|
+
}
|
|
29
|
+
complete -F _captive_completions captive
|
|
30
|
+
BASH
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.zsh
|
|
34
|
+
sub_cases = SUBCOMMANDS.map do |cmd, subs|
|
|
35
|
+
" #{cmd}) compadd #{subs.join(" ")} ;;"
|
|
36
|
+
end.join("\n")
|
|
37
|
+
|
|
38
|
+
<<~ZSH
|
|
39
|
+
_captive() {
|
|
40
|
+
local state
|
|
41
|
+
|
|
42
|
+
_arguments \\
|
|
43
|
+
'1: :->command' \\
|
|
44
|
+
'*: :->args'
|
|
45
|
+
|
|
46
|
+
case $state in
|
|
47
|
+
command)
|
|
48
|
+
compadd #{ROOT_COMMANDS.join(" ")}
|
|
49
|
+
;;
|
|
50
|
+
args)
|
|
51
|
+
case ${words[2]} in
|
|
52
|
+
#{sub_cases}
|
|
53
|
+
esac
|
|
54
|
+
;;
|
|
55
|
+
esac
|
|
56
|
+
}
|
|
57
|
+
compdef _captive captive
|
|
58
|
+
ZSH
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
|
|
5
|
+
module Captive
|
|
6
|
+
module UI
|
|
7
|
+
# L'enseigne posée en haut d'une session interactive — console ou shell.
|
|
8
|
+
#
|
|
9
|
+
# Elle ne sert à rien d'autre qu'à dire où l'on vient d'atterrir : une console distante
|
|
10
|
+
# ressemble à s'y méprendre à un terminal local, et on s'y croit chez soi. Le nom de l'app
|
|
11
|
+
# à côté de l'enseigne est le seul vrai renseignement de l'affichage.
|
|
12
|
+
class Banner
|
|
13
|
+
# Chaque lettre est tracée sur une grille de cinq lignes, pleins uniquement : les
|
|
14
|
+
# caractères de filet donnaient des angles bricolés à cette taille, là où un pixel plein
|
|
15
|
+
# n'a pas de forme à rater. Les deux signes du logo y sont — le `/` à la place du `I`,
|
|
16
|
+
# et le `A` sans barre, qui est le `V` retourné.
|
|
17
|
+
ART = <<~ART
|
|
18
|
+
████ █ ████ █████ █ █ █ ████
|
|
19
|
+
█ █ █ █ █ █ █ █ █ █
|
|
20
|
+
█ █ █ ████ █ █ █ █ ███
|
|
21
|
+
█ █ █ █ █ █ █ █ █
|
|
22
|
+
████ █ █ █ █ █ █ ████
|
|
23
|
+
ART
|
|
24
|
+
|
|
25
|
+
def initialize(output: $stdout, paint: Pastel.new)
|
|
26
|
+
@output = output
|
|
27
|
+
@paint = paint
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def show(app)
|
|
31
|
+
@output.puts(@paint.cyan(ART))
|
|
32
|
+
@output.puts(@paint.dim(" #{app}"))
|
|
33
|
+
@output.puts
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-spinner"
|
|
4
|
+
|
|
5
|
+
module Captive
|
|
6
|
+
module UI
|
|
7
|
+
class Spinner
|
|
8
|
+
def initialize(label, tty_spinner: nil)
|
|
9
|
+
@tty_spinner = tty_spinner || TTY::Spinner.new(" [:spinner] #{label}", format: :dots)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def run(success_msg:)
|
|
13
|
+
@tty_spinner.auto_spin
|
|
14
|
+
result = yield
|
|
15
|
+
@tty_spinner.success(success_msg)
|
|
16
|
+
result
|
|
17
|
+
rescue => e
|
|
18
|
+
@tty_spinner.error
|
|
19
|
+
raise
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "http_client"
|
|
7
|
+
require_relative "version"
|
|
8
|
+
|
|
9
|
+
module Captive
|
|
10
|
+
class UpdateChecker
|
|
11
|
+
VERSION_ENDPOINT = "https://raw.githubusercontent.com/captive-studio/homebrew-tap/main/version.json"
|
|
12
|
+
CACHE_TTL = 86_400 # 24h en secondes
|
|
13
|
+
|
|
14
|
+
def initialize(http: HttpClient.new, cache_path: default_cache_path, current_version: VERSION)
|
|
15
|
+
@http = http
|
|
16
|
+
@cache_path = cache_path
|
|
17
|
+
@current_version = Gem::Version.new(current_version)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def check
|
|
21
|
+
data = fetch_version_data
|
|
22
|
+
latest = Gem::Version.new(data["latest"])
|
|
23
|
+
minimum = Gem::Version.new(data["minimum"])
|
|
24
|
+
|
|
25
|
+
return :blocked if @current_version < minimum
|
|
26
|
+
return :update_available if @current_version < latest
|
|
27
|
+
|
|
28
|
+
:up_to_date
|
|
29
|
+
rescue StandardError
|
|
30
|
+
:up_to_date
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def fetch_version_data
|
|
36
|
+
cached = read_cache
|
|
37
|
+
return cached if cached
|
|
38
|
+
|
|
39
|
+
data = @http.get(VERSION_ENDPOINT)
|
|
40
|
+
write_cache(data)
|
|
41
|
+
data
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def read_cache
|
|
45
|
+
return unless File.exist?(@cache_path)
|
|
46
|
+
|
|
47
|
+
raw = JSON.parse(File.read(@cache_path))
|
|
48
|
+
age = Time.now - Time.parse(raw["checked_at"])
|
|
49
|
+
raw if age < CACHE_TTL
|
|
50
|
+
rescue StandardError
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def write_cache(data)
|
|
55
|
+
FileUtils.mkdir_p(File.dirname(@cache_path))
|
|
56
|
+
File.write(@cache_path, JSON.generate(data.merge("checked_at" => Time.now.iso8601)))
|
|
57
|
+
rescue StandardError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def default_cache_path
|
|
62
|
+
File.expand_path("~/.config/captive/last_update_check")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|