logi-cli 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE +21 -0
- data/README.ko.md +102 -0
- data/README.md +102 -0
- data/bin/logi +6 -0
- data/lib/logi/cli.rb +104 -0
- data/lib/logi/commands/app.rb +235 -0
- data/lib/logi/commands/device_login.rb +134 -0
- data/lib/logi/commands/login.rb +182 -0
- data/lib/logi/commands/token.rb +71 -0
- data/lib/logi/config.rb +52 -0
- data/lib/logi/http_client.rb +98 -0
- data/lib/logi/output.rb +58 -0
- data/lib/logi/version.rb +5 -0
- data/lib/logi.rb +23 -0
- metadata +148 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Logi
|
|
8
|
+
module Commands
|
|
9
|
+
# Device-code login for headless / SSH environments.
|
|
10
|
+
#
|
|
11
|
+
# Flow:
|
|
12
|
+
# 1. POST /cli/auth/device → receive user_code + device_code + verification_uri
|
|
13
|
+
# 2. Show the user_code and URL to the developer
|
|
14
|
+
# 3. Poll POST /cli/auth/device/poll every `interval` seconds
|
|
15
|
+
# 4. On success receive PAK → save credentials
|
|
16
|
+
module DeviceLogin
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
POLL_TIMEOUT = 600 # 10 minutes max
|
|
20
|
+
|
|
21
|
+
def perform(api_url:, portal_url: nil, **)
|
|
22
|
+
pastel = Pastel.new
|
|
23
|
+
portal = portal_url || ENV["LOGI_PORTAL_URL"] || Login::DEFAULT_PORTAL
|
|
24
|
+
|
|
25
|
+
# Step 1: issue device code
|
|
26
|
+
grant = request_device_code(api_url: api_url)
|
|
27
|
+
if grant.nil?
|
|
28
|
+
abort pastel.red("Couldn't get a device code. Check your connection to the server.")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
user_code = grant["user_code"]
|
|
32
|
+
device_code = grant["device_code"]
|
|
33
|
+
verification_uri = grant["verification_uri"] || "#{portal}/cli/activate"
|
|
34
|
+
interval = (grant["interval"] || 5).to_i
|
|
35
|
+
expires_in = (grant["expires_in"] || 600).to_i
|
|
36
|
+
|
|
37
|
+
# Step 2: display to user
|
|
38
|
+
puts ""
|
|
39
|
+
puts pastel.bold("Open the URL below in your browser and enter the code.")
|
|
40
|
+
puts ""
|
|
41
|
+
puts " URL: " + pastel.cyan(verification_uri)
|
|
42
|
+
puts " Code: " + pastel.bold.yellow(user_code)
|
|
43
|
+
puts ""
|
|
44
|
+
puts pastel.dim("Waiting for approval… (expires in #{expires_in / 60} minutes)")
|
|
45
|
+
puts ""
|
|
46
|
+
|
|
47
|
+
# Step 3: poll
|
|
48
|
+
deadline = Time.now + POLL_TIMEOUT
|
|
49
|
+
loop do
|
|
50
|
+
sleep interval
|
|
51
|
+
|
|
52
|
+
if Time.now > deadline
|
|
53
|
+
abort pastel.red("Timed out. Try again from your terminal.")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
result = poll(api_url: api_url, device_code: device_code)
|
|
57
|
+
next if result.nil?
|
|
58
|
+
|
|
59
|
+
error = result["error"]
|
|
60
|
+
|
|
61
|
+
case error
|
|
62
|
+
when nil
|
|
63
|
+
# Success — result contains token
|
|
64
|
+
save_and_print(result, pastel)
|
|
65
|
+
return result
|
|
66
|
+
when "authorization_pending"
|
|
67
|
+
next
|
|
68
|
+
when "slow_down"
|
|
69
|
+
interval += 5
|
|
70
|
+
next
|
|
71
|
+
when "access_denied"
|
|
72
|
+
abort pastel.red("Sign-in was declined.")
|
|
73
|
+
when "expired_token"
|
|
74
|
+
abort pastel.red("The code has expired. Please try again.")
|
|
75
|
+
else
|
|
76
|
+
abort pastel.red("Something went wrong: #{error} — #{result['error_description']}")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def request_device_code(api_url:)
|
|
82
|
+
uri = URI.join(api_url, "/cli/auth/device")
|
|
83
|
+
req = Net::HTTP::Post.new(uri.path,
|
|
84
|
+
"Content-Type" => "application/json",
|
|
85
|
+
"Accept" => "application/json"
|
|
86
|
+
)
|
|
87
|
+
req.body = "{}"
|
|
88
|
+
res = http(uri).request(req)
|
|
89
|
+
JSON.parse(res.body)
|
|
90
|
+
rescue => e
|
|
91
|
+
warn "Device authorization request failed: #{e.message}"
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def poll(api_url:, device_code:)
|
|
96
|
+
uri = URI.join(api_url, "/cli/auth/device/poll")
|
|
97
|
+
req = Net::HTTP::Post.new(uri.path,
|
|
98
|
+
"Content-Type" => "application/json",
|
|
99
|
+
"Accept" => "application/json"
|
|
100
|
+
)
|
|
101
|
+
req.body = JSON.generate(device_code: device_code)
|
|
102
|
+
res = http(uri).request(req)
|
|
103
|
+
JSON.parse(res.body)
|
|
104
|
+
rescue
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def save_and_print(token_response, pastel)
|
|
109
|
+
Config.save(
|
|
110
|
+
api_key: token_response["token"],
|
|
111
|
+
api_url: token_response["api_url"] || Login::DEFAULT_API,
|
|
112
|
+
name: "CLI (device)"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
user = token_response["user"] || {}
|
|
116
|
+
puts pastel.green("✓ Signed in")
|
|
117
|
+
puts " Account: #{user["email_address"] || "(unknown)"}"
|
|
118
|
+
puts " Scopes: #{(token_response["scopes"] || []).join(", ")}"
|
|
119
|
+
puts " Saved: #{pastel.cyan(Config.path)}"
|
|
120
|
+
puts ""
|
|
121
|
+
puts pastel.bold("Next steps")
|
|
122
|
+
puts " " + pastel.cyan("logi apps list") + pastel.dim(" # List your apps")
|
|
123
|
+
puts " " + pastel.cyan("logi apps create --name \"My app\" -r https://app.example/cb") + pastel.dim(" # Register your first app")
|
|
124
|
+
puts " " + pastel.cyan("logi --help") + pastel.dim(" # All commands")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def http(uri)
|
|
128
|
+
Net::HTTP.new(uri.host, uri.port).tap do |h|
|
|
129
|
+
h.use_ssl = uri.scheme == "https"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "json"
|
|
8
|
+
require "net/http"
|
|
9
|
+
require "socket"
|
|
10
|
+
require "webrick"
|
|
11
|
+
|
|
12
|
+
module Logi
|
|
13
|
+
module Commands
|
|
14
|
+
# Browser-redirect OAuth login (PKCE + loopback). Same pattern as GitHub gh and vercel CLI.
|
|
15
|
+
#
|
|
16
|
+
# Flow:
|
|
17
|
+
# 1. Pick a free port, generate code_verifier + code_challenge.
|
|
18
|
+
# 2. Open browser → start.1pass.dev/cli/auth/start?code_challenge=…&port=…&state=…
|
|
19
|
+
# 3. Run a tiny WEBrick server on the port, wait for /cb?code=…&state=….
|
|
20
|
+
# 4. POST /cli/auth/exchange { code, code_verifier } → receive PAK.
|
|
21
|
+
# 5. Save credentials.
|
|
22
|
+
module Login
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
DEFAULT_PORTAL = "https://start.1pass.dev"
|
|
26
|
+
DEFAULT_API = "https://api.1pass.dev"
|
|
27
|
+
|
|
28
|
+
def perform(api_url:, name: "CLI", portal_url: nil, scopes: nil, **)
|
|
29
|
+
pastel = Pastel.new
|
|
30
|
+
portal = portal_url || ENV["LOGI_PORTAL_URL"] || DEFAULT_PORTAL
|
|
31
|
+
scopes ||= %w[apps:read apps:manage org:read profile:read]
|
|
32
|
+
|
|
33
|
+
port = pick_free_port
|
|
34
|
+
code_verifier = SecureRandom.urlsafe_base64(64).delete("=")
|
|
35
|
+
code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
|
36
|
+
state = SecureRandom.hex(16)
|
|
37
|
+
|
|
38
|
+
authorize_url = build_authorize_url(portal, code_challenge, state, port, scopes)
|
|
39
|
+
|
|
40
|
+
puts pastel.dim("Opening your browser…")
|
|
41
|
+
puts pastel.cyan(" #{authorize_url}")
|
|
42
|
+
puts ""
|
|
43
|
+
|
|
44
|
+
open_browser(authorize_url)
|
|
45
|
+
|
|
46
|
+
result = wait_for_callback(port: port, expected_state: state)
|
|
47
|
+
unless result
|
|
48
|
+
abort pastel.red("Sign-in timed out or was cancelled.")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
code = result[:code]
|
|
52
|
+
if code.nil? || code.empty?
|
|
53
|
+
abort pastel.red("Sign-in failed: no code was returned.")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
puts pastel.dim("Exchanging the code for a token…")
|
|
57
|
+
token_response = exchange_code(api_url: api_url, code: code, verifier: code_verifier)
|
|
58
|
+
|
|
59
|
+
Config.save(
|
|
60
|
+
api_key: token_response["token"],
|
|
61
|
+
api_url: token_response["api_url"] || api_url,
|
|
62
|
+
name: name
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
user = token_response["user"] || {}
|
|
66
|
+
puts pastel.green("✓ Signed in")
|
|
67
|
+
puts " Account: #{user["email_address"] || "(unknown)"}"
|
|
68
|
+
puts " Scopes: #{(token_response["scopes"] || []).join(", ")}"
|
|
69
|
+
puts " Saved: #{pastel.cyan(Config.path)}"
|
|
70
|
+
puts ""
|
|
71
|
+
puts pastel.bold("Next steps")
|
|
72
|
+
puts " " + pastel.cyan("logi apps list") + pastel.dim(" # List your apps")
|
|
73
|
+
puts " " + pastel.cyan("logi apps create --name \"My app\" -r https://app.example/cb") + pastel.dim(" # Register your first app")
|
|
74
|
+
puts " " + pastel.cyan("logi --help") + pastel.dim(" # All commands")
|
|
75
|
+
token_response
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def pick_free_port
|
|
79
|
+
server = TCPServer.new("127.0.0.1", 0)
|
|
80
|
+
port = server.addr[1]
|
|
81
|
+
server.close
|
|
82
|
+
port
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build_authorize_url(portal, code_challenge, state, port, scopes)
|
|
86
|
+
query = URI.encode_www_form(
|
|
87
|
+
code_challenge: code_challenge,
|
|
88
|
+
code_challenge_method: "S256",
|
|
89
|
+
state: state,
|
|
90
|
+
port: port,
|
|
91
|
+
scope: scopes.join(" ")
|
|
92
|
+
)
|
|
93
|
+
"#{portal}/cli/auth/start?#{query}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def open_browser(url)
|
|
97
|
+
cmd =
|
|
98
|
+
case RbConfig::CONFIG["host_os"]
|
|
99
|
+
when /darwin/ then [ "open", url ]
|
|
100
|
+
when /mingw|mswin/ then [ "cmd", "/C", "start", "", url ]
|
|
101
|
+
else [ "xdg-open", url ]
|
|
102
|
+
end
|
|
103
|
+
Process.detach(spawn(*cmd, out: File::NULL, err: File::NULL))
|
|
104
|
+
rescue => e
|
|
105
|
+
warn "Couldn't open the browser automatically (#{e.message}). Please open the URL above manually."
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Run a WEBrick server until /cb is hit, return {code:, state:}.
|
|
109
|
+
def wait_for_callback(port:, expected_state:, timeout: 300)
|
|
110
|
+
result = nil
|
|
111
|
+
server = WEBrick::HTTPServer.new(
|
|
112
|
+
BindAddress: "127.0.0.1",
|
|
113
|
+
Port: port,
|
|
114
|
+
Logger: WEBrick::Log.new(File::NULL),
|
|
115
|
+
AccessLog: []
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
server.mount_proc("/cb") do |req, res|
|
|
119
|
+
got_state = req.query["state"]
|
|
120
|
+
got_code = req.query["code"]
|
|
121
|
+
if got_state == expected_state && got_code
|
|
122
|
+
result = { code: got_code, state: got_state }
|
|
123
|
+
res["Content-Type"] = "text/html; charset=utf-8"
|
|
124
|
+
res.body = success_html
|
|
125
|
+
else
|
|
126
|
+
res.status = 400
|
|
127
|
+
res["Content-Type"] = "text/plain; charset=utf-8"
|
|
128
|
+
res.body = "state mismatch or missing code"
|
|
129
|
+
end
|
|
130
|
+
Thread.new { sleep 0.3; server.shutdown }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
Thread.new do
|
|
134
|
+
sleep timeout
|
|
135
|
+
server.shutdown if result.nil?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
server.start
|
|
139
|
+
result
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def exchange_code(api_url:, code:, verifier:)
|
|
143
|
+
uri = URI.join(api_url, "/cli/auth/exchange")
|
|
144
|
+
req = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json", "Accept" => "application/json")
|
|
145
|
+
req.body = JSON.generate(code: code, code_verifier: verifier)
|
|
146
|
+
|
|
147
|
+
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |h| h.request(req) }
|
|
148
|
+
body = JSON.parse(res.body) rescue {}
|
|
149
|
+
unless res.code.to_i == 200
|
|
150
|
+
abort Pastel.new.red("Token exchange failed (#{res.code}): #{body["error"] || res.body}")
|
|
151
|
+
end
|
|
152
|
+
body
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def success_html
|
|
156
|
+
<<~HTML
|
|
157
|
+
<!doctype html>
|
|
158
|
+
<html lang="en">
|
|
159
|
+
<head><meta charset="utf-8"><title>logi CLI</title>
|
|
160
|
+
<style>
|
|
161
|
+
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
|
162
|
+
background: linear-gradient(135deg, #f5f7ff 0%, #f3f0ff 100%);
|
|
163
|
+
margin: 0; padding: 60px 20px; text-align: center; color: #1d1d1f; }
|
|
164
|
+
.card { display: inline-block; padding: 40px 56px; border-radius: 24px;
|
|
165
|
+
background: rgba(255,255,255,0.6); backdrop-filter: blur(20px);
|
|
166
|
+
border: 1px solid rgba(255,255,255,0.4); box-shadow: 0 4px 30px rgba(0,0,0,0.05); }
|
|
167
|
+
h1 { font-size: 28px; margin: 0 0 8px; }
|
|
168
|
+
p { color: #6e6e73; margin: 4px 0; }
|
|
169
|
+
.check { font-size: 48px; }
|
|
170
|
+
</style></head>
|
|
171
|
+
<body>
|
|
172
|
+
<div class="card">
|
|
173
|
+
<div class="check">✓</div>
|
|
174
|
+
<h1>You're signed in to logi CLI</h1>
|
|
175
|
+
<p>You can close this window.</p>
|
|
176
|
+
</div>
|
|
177
|
+
</body></html>
|
|
178
|
+
HTML
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Logi
|
|
4
|
+
module Commands
|
|
5
|
+
# JWT inspection. Signature verification fetches JWKS from the server.
|
|
6
|
+
class Token < Thor
|
|
7
|
+
desc "inspect JWT", "Decode a JWT access token (header + payload) and verify its signature against JWKS"
|
|
8
|
+
method_option :verify, type: :boolean, default: true,
|
|
9
|
+
desc: "Verify the signature (fetches JWKS)"
|
|
10
|
+
def inspect(jwt)
|
|
11
|
+
pastel = Pastel.new
|
|
12
|
+
segments = jwt.split(".")
|
|
13
|
+
if segments.size != 3
|
|
14
|
+
abort pastel.red("Not a valid JWT (expected header.payload.signature).")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
header_json = JSON.parse(Base64.urlsafe_decode64(segments[0] + "=" * ((4 - segments[0].size % 4) % 4)))
|
|
18
|
+
payload_json = JSON.parse(Base64.urlsafe_decode64(segments[1] + "=" * ((4 - segments[1].size % 4) % 4)))
|
|
19
|
+
|
|
20
|
+
puts pastel.cyan("Header:")
|
|
21
|
+
puts JSON.pretty_generate(header_json)
|
|
22
|
+
puts pastel.cyan("\nPayload:")
|
|
23
|
+
puts JSON.pretty_generate(payload_json)
|
|
24
|
+
|
|
25
|
+
if options[:verify]
|
|
26
|
+
verify_with_jwks(jwt, header_json, payload_json)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
no_commands do
|
|
31
|
+
def verify_with_jwks(jwt, header, payload)
|
|
32
|
+
pastel = Pastel.new
|
|
33
|
+
config = Config.load
|
|
34
|
+
http = HttpClient.new(base_url: config.api_url)
|
|
35
|
+
jwks = http.get("/.well-known/jwks.json")
|
|
36
|
+
|
|
37
|
+
kid = header["kid"]
|
|
38
|
+
jwk = (jwks["keys"] || []).find { |k| k["kid"] == kid }
|
|
39
|
+
if jwk.nil?
|
|
40
|
+
warn pastel.yellow("⚠ Signature check: kid=#{kid} was not found in JWKS")
|
|
41
|
+
return
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
public_key = build_rsa(jwk["n"], jwk["e"])
|
|
45
|
+
JWT.decode(jwt, public_key, true, algorithm: "RS256")
|
|
46
|
+
puts pastel.green("\n✓ Signature valid (kid=#{kid})")
|
|
47
|
+
|
|
48
|
+
exp = payload["exp"]
|
|
49
|
+
if exp && Time.at(exp) < Time.now
|
|
50
|
+
puts pastel.yellow("⚠ Expired (exp=#{Time.at(exp).iso8601})")
|
|
51
|
+
elsif exp
|
|
52
|
+
remaining = (Time.at(exp) - Time.now).to_i
|
|
53
|
+
puts pastel.green(" #{remaining} seconds until expiration")
|
|
54
|
+
end
|
|
55
|
+
rescue JWT::DecodeError => e
|
|
56
|
+
warn pastel.red("✗ Signature check failed: #{e.message}")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_rsa(n_b64, e_b64)
|
|
60
|
+
decode = ->(v) { OpenSSL::BN.new(Base64.urlsafe_decode64(v + "=" * ((4 - v.size % 4) % 4)), 2) }
|
|
61
|
+
# Build RSA from modulus+exponent via ASN.1
|
|
62
|
+
sequence = OpenSSL::ASN1::Sequence([
|
|
63
|
+
OpenSSL::ASN1::Integer(decode.call(n_b64)),
|
|
64
|
+
OpenSSL::ASN1::Integer(decode.call(e_b64))
|
|
65
|
+
])
|
|
66
|
+
OpenSSL::PKey::RSA.new(sequence.to_der)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/logi/config.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Logi
|
|
4
|
+
# Load/save credentials.json. Default path: ~/.config/logi/credentials.json
|
|
5
|
+
# Environment variables LOGI_API_KEY / LOGI_API_URL take precedence when set.
|
|
6
|
+
class Config
|
|
7
|
+
DEFAULT_API_URL = "http://localhost:3000"
|
|
8
|
+
|
|
9
|
+
def self.path
|
|
10
|
+
File.expand_path(ENV.fetch("LOGI_CONFIG_PATH", "~/.config/logi/credentials.json"))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.load
|
|
14
|
+
env_key = ENV["LOGI_API_KEY"]
|
|
15
|
+
env_url = ENV["LOGI_API_URL"]
|
|
16
|
+
if env_key
|
|
17
|
+
return new(api_key: env_key, api_url: env_url || DEFAULT_API_URL, source: :env)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
if File.exist?(path)
|
|
21
|
+
data = JSON.parse(File.read(path))
|
|
22
|
+
return new(api_key: data["api_key"], api_url: data["api_url"] || DEFAULT_API_URL,
|
|
23
|
+
source: :file, name: data["name"])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
new(api_key: nil, api_url: env_url || DEFAULT_API_URL, source: :none)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.save(api_key:, api_url:, name:)
|
|
30
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
31
|
+
File.write(path, JSON.pretty_generate({ api_key: api_key, api_url: api_url, name: name }))
|
|
32
|
+
File.chmod(0o600, path)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.clear
|
|
36
|
+
File.delete(path) if File.exist?(path)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
attr_reader :api_key, :api_url, :source, :name
|
|
40
|
+
|
|
41
|
+
def initialize(api_key:, api_url:, source:, name: nil)
|
|
42
|
+
@api_key = api_key
|
|
43
|
+
@api_url = api_url
|
|
44
|
+
@source = source
|
|
45
|
+
@name = name
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def authenticated?
|
|
49
|
+
!api_key.nil? && !api_key.empty?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Logi
|
|
4
|
+
# Minimal HTTP client using Net::HTTP. Keeps dependencies light.
|
|
5
|
+
class HttpClient
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
attr_reader :status, :body
|
|
8
|
+
|
|
9
|
+
def initialize(status, body)
|
|
10
|
+
@status = status
|
|
11
|
+
@body = body
|
|
12
|
+
super("HTTP #{status}: #{body}")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(base_url:, api_key: nil, session_cookie: nil)
|
|
17
|
+
@base_url = base_url.chomp("/")
|
|
18
|
+
@api_key = api_key
|
|
19
|
+
@session_cookie = session_cookie
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_accessor :session_cookie
|
|
23
|
+
|
|
24
|
+
def get(path, params: {})
|
|
25
|
+
request(:get, path, params: params)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def post(path, body: nil, params: {})
|
|
29
|
+
request(:post, path, body: body, params: params)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def patch(path, body: nil)
|
|
33
|
+
request(:patch, path, body: body)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def delete(path)
|
|
37
|
+
request(:delete, path)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def request(method, path, body: nil, params: {})
|
|
43
|
+
uri = URI("#{@base_url}#{path}")
|
|
44
|
+
uri.query = URI.encode_www_form(params) if params.any?
|
|
45
|
+
|
|
46
|
+
req = build_request(method, uri, body)
|
|
47
|
+
apply_auth!(req)
|
|
48
|
+
|
|
49
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
50
|
+
http.use_ssl = uri.scheme == "https"
|
|
51
|
+
|
|
52
|
+
res = http.request(req)
|
|
53
|
+
capture_session_cookie!(res)
|
|
54
|
+
parse_response(res)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def build_request(method, uri, body)
|
|
58
|
+
klass = case method
|
|
59
|
+
when :get then Net::HTTP::Get
|
|
60
|
+
when :post then Net::HTTP::Post
|
|
61
|
+
when :patch then Net::HTTP::Patch
|
|
62
|
+
when :delete then Net::HTTP::Delete
|
|
63
|
+
end
|
|
64
|
+
req = klass.new(uri.request_uri)
|
|
65
|
+
if body
|
|
66
|
+
req["Content-Type"] = "application/json"
|
|
67
|
+
req.body = JSON.dump(body)
|
|
68
|
+
end
|
|
69
|
+
req["Accept"] = "application/json"
|
|
70
|
+
req
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def apply_auth!(req)
|
|
74
|
+
req["Authorization"] = "Bearer #{@api_key}" if @api_key
|
|
75
|
+
req["Cookie"] = "session_id=#{@session_cookie}" if @session_cookie
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def capture_session_cookie!(res)
|
|
79
|
+
set = res.get_fields("Set-Cookie") || []
|
|
80
|
+
set.each do |c|
|
|
81
|
+
if c.start_with?("session_id=")
|
|
82
|
+
@session_cookie = c.split(";").first.split("=", 2).last
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def parse_response(res)
|
|
88
|
+
status = res.code.to_i
|
|
89
|
+
body = res.body.to_s.empty? ? {} : (JSON.parse(res.body) rescue { "raw" => res.body })
|
|
90
|
+
|
|
91
|
+
if status >= 400
|
|
92
|
+
raise Error.new(status, body)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
body
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/logi/output.rb
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Logi
|
|
6
|
+
# Centralized output for human / JSON modes.
|
|
7
|
+
#
|
|
8
|
+
# Why: When an LLM (Claude, ChatGPT) drives the CLI, it can pass `--json`
|
|
9
|
+
# (or set `LOGI_OUTPUT=json`) to receive a stable, parseable shape on stdout.
|
|
10
|
+
# Human messages still go to stderr so they don't pollute pipes.
|
|
11
|
+
#
|
|
12
|
+
# Pattern: GitHub `gh --json fields`, Stripe CLI default JSON, Vercel `--output json`.
|
|
13
|
+
module Output
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Resolve mode from (in order):
|
|
17
|
+
# 1. ENV `LOGI_OUTPUT=json` or `=human`
|
|
18
|
+
# 2. options[:json] true → json
|
|
19
|
+
# 3. tty/pipe heuristic — pipe defaults to json (LLM-friendly)
|
|
20
|
+
# 4. fallback: human
|
|
21
|
+
def mode(options = {})
|
|
22
|
+
return ENV["LOGI_OUTPUT"] if %w[json human].include?(ENV["LOGI_OUTPUT"])
|
|
23
|
+
return "json" if options[:json]
|
|
24
|
+
"human"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def json?(options = {}); mode(options) == "json"; end
|
|
28
|
+
|
|
29
|
+
# Emit a structured success result. In human mode, runs the block instead.
|
|
30
|
+
def success(data, options = {}, &human_block)
|
|
31
|
+
if json?(options)
|
|
32
|
+
$stdout.puts JSON.generate(success: true, data: data)
|
|
33
|
+
elsif human_block
|
|
34
|
+
human_block.call
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def failure(code:, message:, status: nil, options: {}, &human_block)
|
|
39
|
+
payload = { success: false, error: { code: code, message: message } }
|
|
40
|
+
payload[:error][:status] = status if status
|
|
41
|
+
|
|
42
|
+
if json?(options)
|
|
43
|
+
$stdout.puts JSON.generate(payload)
|
|
44
|
+
elsif human_block
|
|
45
|
+
human_block.call
|
|
46
|
+
else
|
|
47
|
+
warn message
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Pretty-print to stderr only when human (no-op in JSON mode).
|
|
52
|
+
# Use for progress chatter ("Opening your browser…" etc).
|
|
53
|
+
def chatter(text, options = {})
|
|
54
|
+
return if json?(options)
|
|
55
|
+
$stderr.puts text
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/logi/version.rb
ADDED
data/lib/logi.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "thor"
|
|
8
|
+
require "pastel"
|
|
9
|
+
require "tty-prompt"
|
|
10
|
+
require "tty-table"
|
|
11
|
+
require "jwt"
|
|
12
|
+
require "base64"
|
|
13
|
+
require "openssl"
|
|
14
|
+
|
|
15
|
+
require_relative "logi/version"
|
|
16
|
+
require_relative "logi/config"
|
|
17
|
+
require_relative "logi/http_client"
|
|
18
|
+
require_relative "logi/output"
|
|
19
|
+
require_relative "logi/commands/login"
|
|
20
|
+
require_relative "logi/commands/device_login"
|
|
21
|
+
require_relative "logi/commands/app"
|
|
22
|
+
require_relative "logi/commands/token"
|
|
23
|
+
require_relative "logi/cli"
|