logi-cli 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +5 -0
- data/lib/logi/cli.rb +28 -5
- data/lib/logi/commands/app.rb +92 -15
- data/lib/logi/commands/developer.rb +195 -0
- data/lib/logi/commands/device_login.rb +43 -16
- data/lib/logi/commands/manual.rb +104 -0
- data/lib/logi/config.rb +5 -1
- data/lib/logi/http_client.rb +2 -0
- data/lib/logi/version.rb +1 -1
- data/lib/logi.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa3a61b59de983a4b5b5873e91456a8906ed221afb614665d5021a92524d13dd
|
|
4
|
+
data.tar.gz: d247641e4e0144646af75dff18f92fd57104c06088a646dc1b2965c20f70f73e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 88f30a07076fd1869f156f2397c5027cd2caaaac28a923922d13a05352de8692c88413d13099b42ed2f1152e5338419470d4e4211b3cab8bc820f3a3609fdce2
|
|
7
|
+
data.tar.gz: f0cfc31661ecc9cbf9969371bf26afec6d6ac06a9bec17d6753d72dab4a1d397e5993186a6314a96eb4a65318d1ebaf76f9b29901232caa6301a0f2ceb4c8f1b
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.0] - 2026-07-06
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `logi manual` — agent-oriented self-loading manual (the onpod `manual` pattern): key OAuth facts, full command catalog, and the 6-step RP registration flow in one offline-safe dump. `--full` additionally fetches the registration + integration runbook LLM splits from docs.1pass.dev.
|
|
13
|
+
|
|
9
14
|
## [0.1.0] - 2026-05-27
|
|
10
15
|
|
|
11
16
|
Initial public release on RubyGems.org.
|
data/lib/logi/cli.rb
CHANGED
|
@@ -23,6 +23,7 @@ module Logi
|
|
|
23
23
|
puts " logi apps verify <id> -r URL # Check an RP integration (env / redirect URI)"
|
|
24
24
|
puts " logi apps rotate-secret <id> # Rotate client_secret"
|
|
25
25
|
puts " logi whoami # Show sign-in info"
|
|
26
|
+
puts " logi manual # Agent-oriented manual (paste into your AI agent)"
|
|
26
27
|
puts " logi --help # All commands"
|
|
27
28
|
else
|
|
28
29
|
puts pastel.yellow("You're not signed in yet.")
|
|
@@ -49,21 +50,33 @@ module Logi
|
|
|
49
50
|
method_option :api_url, type: :string, default: nil, desc: "logi API URL (default: https://api.1pass.dev)"
|
|
50
51
|
method_option :portal_url, type: :string, default: nil, desc: "Developer Portal URL (default: https://start.1pass.dev)"
|
|
51
52
|
method_option :name, type: :string, default: "CLI", desc: "PAK name"
|
|
52
|
-
method_option :scope, type: :array, default: nil, desc: "Scopes to request"
|
|
53
|
-
method_option :code, type: :boolean, default: false, desc: "
|
|
53
|
+
method_option :scope, type: :array, default: nil, desc: "Scopes to request (clamped to the allowlist)"
|
|
54
|
+
method_option :code, type: :boolean, default: false, desc: "Headless: print a code instead of opening a browser (for SSH / no-GUI)"
|
|
55
|
+
method_option :loopback, type: :boolean, default: false, desc: "Use the loopback redirect flow (no code; same-device only)"
|
|
54
56
|
def login
|
|
55
57
|
api_url = options[:api_url] || ENV["LOGI_API_URL"] || Config::DEFAULT_API_URL
|
|
56
58
|
portal_url = options[:portal_url] || ENV["LOGI_PORTAL_URL"]
|
|
57
59
|
|
|
58
|
-
if options[:
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
if options[:loopback]
|
|
61
|
+
# Same-device loopback redirect (PKCE). No code to type; auto-completes
|
|
62
|
+
# via 127.0.0.1 callback. Supports custom --scope.
|
|
61
63
|
Commands::Login.perform(
|
|
62
64
|
api_url: api_url,
|
|
63
65
|
portal_url: portal_url,
|
|
64
66
|
name: options[:name],
|
|
65
67
|
scopes: options[:scope]
|
|
66
68
|
)
|
|
69
|
+
else
|
|
70
|
+
# Default: device flow. Opens the browser with the code pre-filled AND
|
|
71
|
+
# prints the code, so the user can approve here or on another device.
|
|
72
|
+
# `--code` skips the browser for headless / SSH environments.
|
|
73
|
+
Commands::DeviceLogin.perform(
|
|
74
|
+
api_url: api_url,
|
|
75
|
+
portal_url: portal_url,
|
|
76
|
+
name: options[:name],
|
|
77
|
+
scopes: options[:scope],
|
|
78
|
+
open_browser: !options[:code]
|
|
79
|
+
)
|
|
67
80
|
end
|
|
68
81
|
end
|
|
69
82
|
|
|
@@ -94,9 +107,19 @@ module Logi
|
|
|
94
107
|
desc "app SUBCOMMAND", "(alias) same as `apps`", hide: true
|
|
95
108
|
subcommand "app", Commands::App
|
|
96
109
|
|
|
110
|
+
desc "developer SUBCOMMAND", "Developer onboarding (register / status / verify-email)"
|
|
111
|
+
subcommand "developer", Commands::Developer
|
|
112
|
+
|
|
97
113
|
desc "token SUBCOMMAND", "JWT access token tools (inspect)"
|
|
98
114
|
subcommand "token", Commands::Token
|
|
99
115
|
|
|
116
|
+
desc "manual", "Print the agent-oriented manual (self-contained; --full appends the online runbooks)"
|
|
117
|
+
method_option :full, type: :boolean, default: false,
|
|
118
|
+
desc: "Also fetch and print the registration + integration runbooks (network required)"
|
|
119
|
+
def manual
|
|
120
|
+
Commands::Manual.perform(full: options[:full])
|
|
121
|
+
end
|
|
122
|
+
|
|
100
123
|
def self.exit_on_failure?
|
|
101
124
|
true
|
|
102
125
|
end
|
data/lib/logi/commands/app.rb
CHANGED
|
@@ -46,25 +46,37 @@ module Logi
|
|
|
46
46
|
method_option :scope, type: :array, default: [], aliases: "-s",
|
|
47
47
|
desc: "Scopes to enable (e.g. -s openid profile:basic email). Optional."
|
|
48
48
|
method_option :webhook_url, type: :string
|
|
49
|
+
method_option :backchannel_logout_uri, type: :string,
|
|
50
|
+
desc: "OIDC back-channel logout endpoint on your RP"
|
|
51
|
+
method_option :health_url, type: :string,
|
|
52
|
+
desc: "RP health endpoint logi pings hourly (must be public, no private IPs)"
|
|
53
|
+
method_option :health_check, type: :boolean, default: true,
|
|
54
|
+
desc: "Enable hourly RP health checks. Use --no-health-check to disable."
|
|
55
|
+
method_option :client_type, type: :string,
|
|
56
|
+
desc: "confidential (default) or public"
|
|
49
57
|
def create
|
|
50
58
|
data = authed_client.post("/api/v1/applications", body: {
|
|
51
59
|
application: {
|
|
52
60
|
name: options[:name],
|
|
53
61
|
redirect_uris: [ options[:redirect_uri] ],
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
# Flatten whitespace inside each --scope arg so BOTH `--scope openid profile email`
|
|
63
|
+
# and `--scope "openid profile email"` (Thor passes the quoted form as one element)
|
|
64
|
+
# resolve to separate scope names. Avoids a single bogus "openid profile email" scope.
|
|
65
|
+
allowed_scopes: Array(options[:scope]).flat_map { |s| s.to_s.split(/\s+/) }.reject(&:empty?),
|
|
66
|
+
webhook_url: options[:webhook_url],
|
|
67
|
+
backchannel_logout_uri: options[:backchannel_logout_uri],
|
|
68
|
+
health_url: options[:health_url],
|
|
69
|
+
# Only send health_check_enabled when explicitly opted out, so the
|
|
70
|
+
# server default (enabled) applies otherwise.
|
|
71
|
+
health_check_enabled: (options[:health_check] == false ? false : nil),
|
|
72
|
+
client_type: options[:client_type]
|
|
56
73
|
}.compact
|
|
57
74
|
})
|
|
58
75
|
Output.success(data, options) do
|
|
59
|
-
|
|
60
|
-
puts " client_id: #{data['client_id']}"
|
|
61
|
-
puts " client_secret: #{pastel.yellow(data['client_secret'])}"
|
|
62
|
-
puts pastel.dim(" ⚠ The secret is only shown once. Store it somewhere safe.")
|
|
76
|
+
print_create_result(data)
|
|
63
77
|
end
|
|
64
78
|
rescue HttpClient::Error => e
|
|
65
|
-
|
|
66
|
-
abort pastel.red("Couldn't create app: #{e.status} #{e.body}")
|
|
67
|
-
end
|
|
79
|
+
handle_create_error(e)
|
|
68
80
|
end
|
|
69
81
|
|
|
70
82
|
desc "rotate-secret ID", "Rotate the client_secret"
|
|
@@ -127,10 +139,23 @@ module Logi
|
|
|
127
139
|
|
|
128
140
|
# Check 2: tier
|
|
129
141
|
tier = app["tier"]
|
|
130
|
-
|
|
142
|
+
case tier
|
|
143
|
+
when "production"
|
|
131
144
|
ok "Tier: production"
|
|
145
|
+
when nil
|
|
146
|
+
warn "Tier: unknown (server didn't report a tier)."
|
|
132
147
|
else
|
|
133
|
-
warn "Tier:
|
|
148
|
+
warn "Tier: #{tier} — only localhost / staging redirects are allowed. To use a production URL, request a tier upgrade."
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Health-check status (only shown when the server reports it).
|
|
152
|
+
unless app["health_check_enabled"].nil?
|
|
153
|
+
if app["health_check_enabled"]
|
|
154
|
+
status = app["rp_health_status"] || "unknown"
|
|
155
|
+
ok "Health checks: enabled (status: #{status})"
|
|
156
|
+
else
|
|
157
|
+
warn "Health checks: disabled"
|
|
158
|
+
end
|
|
134
159
|
end
|
|
135
160
|
|
|
136
161
|
# Check 3: redirect_uri registration
|
|
@@ -162,7 +187,8 @@ module Logi
|
|
|
162
187
|
api_url = Config.load.api_url
|
|
163
188
|
puts " LOGI_API_URL=#{api_url}"
|
|
164
189
|
puts " LOGI_CLIENT_ID=#{app['client_id']}"
|
|
165
|
-
puts " LOGI_CLIENT_SECRET=" + pastel.dim("(
|
|
190
|
+
puts " LOGI_CLIENT_SECRET=" + pastel.dim("(shown once at create — or rotate-secret to re-issue)")
|
|
191
|
+
puts " LOGI_RP_HEALTH_SECRET=" + pastel.dim("(shown once at create)")
|
|
166
192
|
puts ""
|
|
167
193
|
puts pastel.dim("Docs: https://docs.1pass.dev/guide/troubleshooting")
|
|
168
194
|
rescue HttpClient::Error => e
|
|
@@ -219,16 +245,67 @@ module Logi
|
|
|
219
245
|
client_id: app["client_id"],
|
|
220
246
|
tier: app["tier"],
|
|
221
247
|
status: app["status"],
|
|
248
|
+
health_check_enabled: app["health_check_enabled"],
|
|
249
|
+
rp_health_status: app["rp_health_status"],
|
|
222
250
|
redirect_uris: app["redirect_uris"],
|
|
223
251
|
target_redirect_uri: target_uri,
|
|
224
252
|
checks: checks,
|
|
225
253
|
recommended_env: {
|
|
226
|
-
"LOGI_API_URL"
|
|
227
|
-
"LOGI_CLIENT_ID"
|
|
228
|
-
"LOGI_CLIENT_SECRET"
|
|
254
|
+
"LOGI_API_URL" => Config.load.api_url,
|
|
255
|
+
"LOGI_CLIENT_ID" => app["client_id"],
|
|
256
|
+
"LOGI_CLIENT_SECRET" => "(shown once at create / rotate-secret)",
|
|
257
|
+
"LOGI_RP_HEALTH_SECRET" => "(shown once at create)"
|
|
229
258
|
}
|
|
230
259
|
}
|
|
231
260
|
end
|
|
261
|
+
|
|
262
|
+
# Human-mode output for `apps create`: identifiers + the one-time
|
|
263
|
+
# secrets + a ready-to-paste env block. Secrets are NEVER written to a
|
|
264
|
+
# file by the CLI; the user copies them from the terminal.
|
|
265
|
+
def print_create_result(data)
|
|
266
|
+
puts pastel.green("✓ App created")
|
|
267
|
+
puts " client_id: #{data['client_id']}"
|
|
268
|
+
puts " status: #{data['status']}" if data["status"]
|
|
269
|
+
puts ""
|
|
270
|
+
if data["client_secret"]
|
|
271
|
+
puts " client_secret: #{pastel.yellow(data['client_secret'])}"
|
|
272
|
+
else
|
|
273
|
+
puts pastel.dim(" client_secret: (none — public client)")
|
|
274
|
+
end
|
|
275
|
+
if data["rp_health_secret"]
|
|
276
|
+
puts " rp_health_secret: #{pastel.yellow(data['rp_health_secret'])}"
|
|
277
|
+
end
|
|
278
|
+
puts ""
|
|
279
|
+
puts pastel.bold("Paste these into your RP's environment:")
|
|
280
|
+
puts pastel.dim("# secrets below are shown ONCE — store them now")
|
|
281
|
+
puts "LOGI_API_URL=#{Config.load.api_url}"
|
|
282
|
+
puts "LOGI_CLIENT_ID=#{data['client_id']}"
|
|
283
|
+
puts "LOGI_CLIENT_SECRET=#{data['client_secret']}" if data["client_secret"]
|
|
284
|
+
puts "LOGI_RP_HEALTH_SECRET=#{data['rp_health_secret']}" if data["rp_health_secret"]
|
|
285
|
+
puts ""
|
|
286
|
+
puts pastel.dim(" ⚠ The client_secret and rp_health_secret are only shown once. Store them somewhere safe.")
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def handle_create_error(e)
|
|
290
|
+
code = e.body.is_a?(Hash) ? e.body["error"] : nil
|
|
291
|
+
error_code, message =
|
|
292
|
+
case code
|
|
293
|
+
when "identity_verification_required"
|
|
294
|
+
[ "identity_verification_required",
|
|
295
|
+
"Your email must be verified before registering an RP. Run: logi developer verify-email" ]
|
|
296
|
+
when "forbidden"
|
|
297
|
+
[ "forbidden",
|
|
298
|
+
"You need a developer or admin role first. Run: logi developer register" ]
|
|
299
|
+
else
|
|
300
|
+
[ "create_failed", "Couldn't create app: #{e.status} #{e.body}" ]
|
|
301
|
+
end
|
|
302
|
+
# Emit the actionable hint in BOTH human and --json modes, and always
|
|
303
|
+
# exit non-zero so automation never reads a 403 as success.
|
|
304
|
+
Output.failure(code: error_code, message: message, status: e.status, options: options) do
|
|
305
|
+
abort pastel.red(message.sub(". Run: ", "\n → "))
|
|
306
|
+
end
|
|
307
|
+
exit 1
|
|
308
|
+
end
|
|
232
309
|
end
|
|
233
310
|
end
|
|
234
311
|
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Logi
|
|
4
|
+
module Commands
|
|
5
|
+
# Developer self-serve: promote your account to a developer, check its
|
|
6
|
+
# identity-verification state, and verify your email with a code.
|
|
7
|
+
#
|
|
8
|
+
# Why: registering an RP (`logi apps create`) requires (1) a developer/admin
|
|
9
|
+
# role and (2) an identity-verified email. These commands let an AI agent or
|
|
10
|
+
# a human complete the whole onboarding from the terminal, no web console.
|
|
11
|
+
#
|
|
12
|
+
# All endpoints authenticate with the PAK (Bearer) — no special scope is
|
|
13
|
+
# required (same trust level as `promote_to_developer`).
|
|
14
|
+
class Developer < Thor
|
|
15
|
+
class_option :json, type: :boolean, default: false,
|
|
16
|
+
desc: "JSON output mode for LLM and CI workflows. You can also set LOGI_OUTPUT=json."
|
|
17
|
+
|
|
18
|
+
desc "register", "Promote your account to a developer (creates a personal org)"
|
|
19
|
+
method_option :email, type: :string,
|
|
20
|
+
desc: "Email to attach (required if your account has no email yet)"
|
|
21
|
+
method_option :org, type: :string,
|
|
22
|
+
desc: "Name for your personal organization"
|
|
23
|
+
def register
|
|
24
|
+
body = {
|
|
25
|
+
email_address: options[:email],
|
|
26
|
+
organization_name: options[:org]
|
|
27
|
+
}.compact
|
|
28
|
+
|
|
29
|
+
data = authed_client.post("/api/v1/me/promote_to_developer", body: body)
|
|
30
|
+
user = data["user"] || {}
|
|
31
|
+
org = data["organization"] || {}
|
|
32
|
+
|
|
33
|
+
Output.success(data, options) do
|
|
34
|
+
puts pastel.green("✓ You're a developer now")
|
|
35
|
+
puts " role: #{user['role']}"
|
|
36
|
+
puts " email: #{user['email_address'] || '-'}"
|
|
37
|
+
puts " organization: #{org['name'] || '-'}#{org['slug'] ? " (#{org['slug']})" : ''}"
|
|
38
|
+
puts ""
|
|
39
|
+
unless identity_verified?(user)
|
|
40
|
+
puts pastel.yellow(" Your email isn't verified yet — RP registration needs it.")
|
|
41
|
+
puts pastel.dim(" → logi developer verify-email")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
rescue HttpClient::Error => e
|
|
45
|
+
handle_register_error(e)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
desc "status", "Show your developer role and identity-verification state"
|
|
49
|
+
def status
|
|
50
|
+
data = authed_client.get("/api/v1/me")
|
|
51
|
+
user = data["user"] || {}
|
|
52
|
+
|
|
53
|
+
Output.success({ user: user }, options) do
|
|
54
|
+
puts pastel.bold("logi developer status")
|
|
55
|
+
puts ""
|
|
56
|
+
puts " role: #{user['role'] || '-'}"
|
|
57
|
+
puts " identity_verified_level: #{user['identity_verified_level'] || '-'}"
|
|
58
|
+
puts " email: #{user['email_address'] || '-'}"
|
|
59
|
+
puts ""
|
|
60
|
+
if identity_verified?(user)
|
|
61
|
+
ok "Identity verified — you can register RPs."
|
|
62
|
+
else
|
|
63
|
+
warn "Identity not verified — required before `logi apps create`."
|
|
64
|
+
puts pastel.dim(" → logi developer verify-email")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
rescue HttpClient::Error => e
|
|
68
|
+
Output.failure(code: "status_failed", message: "Couldn't fetch status: #{e.status} #{e.body}",
|
|
69
|
+
status: e.status, options: options) do
|
|
70
|
+
abort pastel.red("Couldn't fetch status: #{e.status} #{e.body}")
|
|
71
|
+
end
|
|
72
|
+
exit 1
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
desc "verify-email", "Verify your email by entering a 6-digit code sent to your inbox"
|
|
76
|
+
def verify_email
|
|
77
|
+
client = authed_client
|
|
78
|
+
|
|
79
|
+
# Short-circuit: already verified → no email is sent.
|
|
80
|
+
me = client.get("/api/v1/me")["user"] || {}
|
|
81
|
+
if identity_verified?(me)
|
|
82
|
+
Output.success({ identity_verified_level: me["identity_verified_level"] }, options) do
|
|
83
|
+
puts pastel.green("✓ Your email is already verified.")
|
|
84
|
+
end
|
|
85
|
+
return
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# In JSON / non-interactive mode the code comes from LOGI_VERIFY_CODE.
|
|
89
|
+
# Validate it BEFORE sending an email so an automation mistake doesn't
|
|
90
|
+
# trigger a wasted email + an out-of-contract abort.
|
|
91
|
+
preflight_code = noninteractive_code
|
|
92
|
+
if Output.json?(options) && (preflight_code.nil? || preflight_code.empty?)
|
|
93
|
+
Output.failure(
|
|
94
|
+
code: "code_required",
|
|
95
|
+
message: "Set LOGI_VERIFY_CODE with the 6-digit code in JSON mode.",
|
|
96
|
+
options: options
|
|
97
|
+
) { abort pastel.red("Set LOGI_VERIFY_CODE with the 6-digit code in JSON mode.") }
|
|
98
|
+
exit 1
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
client.post("/api/v1/me/email_verifications")
|
|
102
|
+
Output.chatter(pastel.dim("A 6-digit code was sent to your email (valid 10 minutes)."), options)
|
|
103
|
+
|
|
104
|
+
code = preflight_code && !preflight_code.empty? ? preflight_code : read_code
|
|
105
|
+
data = client.post("/api/v1/me/email_verifications/verify_code", body: { code: code })
|
|
106
|
+
|
|
107
|
+
Output.success(data, options) do
|
|
108
|
+
puts pastel.green("✓ Email verified")
|
|
109
|
+
puts " identity_verified_level: #{data['identity_verified_level']}"
|
|
110
|
+
end
|
|
111
|
+
rescue HttpClient::Error => e
|
|
112
|
+
handle_verify_error(e)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
no_commands do
|
|
116
|
+
def pastel
|
|
117
|
+
@pastel ||= Pastel.new
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def authed_client
|
|
121
|
+
config = Config.load
|
|
122
|
+
unless config.authenticated?
|
|
123
|
+
abort pastel.red("You need to sign in. Run `logi login`.")
|
|
124
|
+
end
|
|
125
|
+
HttpClient.new(base_url: config.api_url, api_key: config.api_key)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def identity_verified?(user)
|
|
129
|
+
level = user["identity_verified_level"]
|
|
130
|
+
!level.nil? && level != "unverified"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The code from LOGI_VERIFY_CODE (CI / agent path), or nil if unset.
|
|
134
|
+
def noninteractive_code
|
|
135
|
+
env_code = ENV["LOGI_VERIFY_CODE"]
|
|
136
|
+
env_code.nil? ? nil : env_code.strip
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Interactively prompt for the 6-digit code (human mode only — JSON mode
|
|
140
|
+
# is short-circuited to LOGI_VERIFY_CODE before this is reached).
|
|
141
|
+
def read_code
|
|
142
|
+
TTY::Prompt.new.ask("Enter the 6-digit code:") do |q|
|
|
143
|
+
q.required true
|
|
144
|
+
q.validate(/\A\d{6}\z/, "Enter exactly 6 digits.")
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def ok(msg); puts pastel.green(" ✓ ") + msg; end
|
|
149
|
+
def warn(msg); puts pastel.yellow(" ⚠ ") + msg; end
|
|
150
|
+
|
|
151
|
+
def handle_register_error(e)
|
|
152
|
+
code = e.body.is_a?(Hash) ? e.body["error"] : nil
|
|
153
|
+
error_code, message =
|
|
154
|
+
case code
|
|
155
|
+
when "email_required"
|
|
156
|
+
[ "email_required", "An email is required. Pass --email you@example.com." ]
|
|
157
|
+
when "invalid_email"
|
|
158
|
+
[ "invalid_email", "That email looks invalid." ]
|
|
159
|
+
when "email_taken"
|
|
160
|
+
[ "email_taken", "That email is already in use." ]
|
|
161
|
+
else
|
|
162
|
+
[ "register_failed", "Couldn't register as a developer: #{e.status} #{e.body}" ]
|
|
163
|
+
end
|
|
164
|
+
Output.failure(code: error_code, message: message, status: e.status, options: options) do
|
|
165
|
+
abort pastel.red(message)
|
|
166
|
+
end
|
|
167
|
+
exit 1
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def handle_verify_error(e)
|
|
171
|
+
code = e.body.is_a?(Hash) ? e.body["error"] : nil
|
|
172
|
+
error_code, message =
|
|
173
|
+
case code
|
|
174
|
+
when "no_deliverable_email"
|
|
175
|
+
[ "no_deliverable_email",
|
|
176
|
+
"No deliverable email on your account. Add a real email first: logi developer register --email you@example.com" ]
|
|
177
|
+
when "rate_limited"
|
|
178
|
+
[ "rate_limited", "A code was just sent — wait a moment before requesting another." ]
|
|
179
|
+
when "invalid_or_expired"
|
|
180
|
+
[ "invalid_or_expired",
|
|
181
|
+
"That code is invalid or expired. Run `logi developer verify-email` again for a new one." ]
|
|
182
|
+
when "too_many_attempts"
|
|
183
|
+
[ "too_many_attempts", "Too many attempts. Request a fresh code with `logi developer verify-email`." ]
|
|
184
|
+
else
|
|
185
|
+
[ "verify_email_failed", "Couldn't verify your email: #{e.status} #{e.body}" ]
|
|
186
|
+
end
|
|
187
|
+
Output.failure(code: error_code, message: message, status: e.status, options: options) do
|
|
188
|
+
abort pastel.red(message)
|
|
189
|
+
end
|
|
190
|
+
exit 1
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "cgi"
|
|
3
4
|
require "json"
|
|
4
5
|
require "net/http"
|
|
5
6
|
require "uri"
|
|
6
7
|
|
|
7
8
|
module Logi
|
|
8
9
|
module Commands
|
|
9
|
-
# Device-code login
|
|
10
|
+
# Device-code login — the default `logi login` flow.
|
|
11
|
+
#
|
|
12
|
+
# Unifies "approve in browser" and "enter a code" in one flow (like gh):
|
|
13
|
+
# by default we open the browser to verification_uri_complete (the code is
|
|
14
|
+
# pre-filled) AND print the code, so the user can approve on this device or
|
|
15
|
+
# type the code on another device / over SSH. `open_browser: false`
|
|
16
|
+
# (`logi login --code`) skips the browser for headless environments.
|
|
10
17
|
#
|
|
11
18
|
# Flow:
|
|
12
|
-
# 1. POST /cli/auth/device → receive user_code + device_code + verification_uri
|
|
13
|
-
# 2.
|
|
19
|
+
# 1. POST /cli/auth/device → receive user_code + device_code + verification_uri(_complete)
|
|
20
|
+
# 2. Open the browser (code pre-filled) and/or print the code + URL
|
|
14
21
|
# 3. Poll POST /cli/auth/device/poll every `interval` seconds
|
|
15
22
|
# 4. On success receive PAK → save credentials
|
|
16
23
|
module DeviceLogin
|
|
@@ -18,29 +25,48 @@ module Logi
|
|
|
18
25
|
|
|
19
26
|
POLL_TIMEOUT = 600 # 10 minutes max
|
|
20
27
|
|
|
21
|
-
def perform(api_url:, portal_url: nil, **)
|
|
22
|
-
pastel
|
|
23
|
-
|
|
28
|
+
def perform(api_url:, portal_url: nil, scopes: nil, name: "CLI", open_browser: true, **)
|
|
29
|
+
pastel = Pastel.new
|
|
30
|
+
portal_override = portal_url || ENV["LOGI_PORTAL_URL"]
|
|
31
|
+
portal = portal_override || Login::DEFAULT_PORTAL
|
|
24
32
|
|
|
25
33
|
# Step 1: issue device code
|
|
26
|
-
grant = request_device_code(api_url: api_url)
|
|
34
|
+
grant = request_device_code(api_url: api_url, scopes: scopes)
|
|
27
35
|
if grant.nil?
|
|
28
36
|
abort pastel.red("Couldn't get a device code. Check your connection to the server.")
|
|
29
37
|
end
|
|
30
38
|
|
|
31
39
|
user_code = grant["user_code"]
|
|
32
40
|
device_code = grant["device_code"]
|
|
33
|
-
|
|
41
|
+
if portal_override
|
|
42
|
+
# Local / self-hosted: ignore the server-supplied (prod) URI and build
|
|
43
|
+
# the URL against the configured portal, so the browser opens the same
|
|
44
|
+
# server that minted this code instead of start.1pass.dev.
|
|
45
|
+
verification_uri = "#{portal}/cli/activate"
|
|
46
|
+
verification_uri_complete = "#{verification_uri}?user_code=#{CGI.escape(user_code.to_s)}"
|
|
47
|
+
else
|
|
48
|
+
verification_uri = grant["verification_uri"] || "#{portal}/cli/activate"
|
|
49
|
+
# Code pre-filled URL — fall back to building it if the server is older.
|
|
50
|
+
verification_uri_complete =
|
|
51
|
+
grant["verification_uri_complete"].to_s.empty? ?
|
|
52
|
+
"#{verification_uri}?user_code=#{CGI.escape(user_code.to_s)}" :
|
|
53
|
+
grant["verification_uri_complete"]
|
|
54
|
+
end
|
|
34
55
|
interval = (grant["interval"] || 5).to_i
|
|
35
56
|
expires_in = (grant["expires_in"] || 600).to_i
|
|
36
57
|
|
|
37
|
-
# Step 2:
|
|
58
|
+
# Step 2: present both paths — approve in browser OR enter the code.
|
|
38
59
|
puts ""
|
|
39
|
-
puts pastel.bold("
|
|
60
|
+
puts pastel.bold("Sign in: approve in your browser, or enter the code on another device.")
|
|
40
61
|
puts ""
|
|
41
|
-
puts " URL: " + pastel.cyan(verification_uri)
|
|
42
62
|
puts " Code: " + pastel.bold.yellow(user_code)
|
|
63
|
+
puts " URL: " + pastel.cyan(verification_uri)
|
|
43
64
|
puts ""
|
|
65
|
+
if open_browser
|
|
66
|
+
puts pastel.dim("Opening your browser… (the code is pre-filled — just approve)")
|
|
67
|
+
Login.open_browser(verification_uri_complete)
|
|
68
|
+
puts ""
|
|
69
|
+
end
|
|
44
70
|
puts pastel.dim("Waiting for approval… (expires in #{expires_in / 60} minutes)")
|
|
45
71
|
puts ""
|
|
46
72
|
|
|
@@ -61,7 +87,7 @@ module Logi
|
|
|
61
87
|
case error
|
|
62
88
|
when nil
|
|
63
89
|
# Success — result contains token
|
|
64
|
-
save_and_print(result, pastel)
|
|
90
|
+
save_and_print(result, pastel, name: name)
|
|
65
91
|
return result
|
|
66
92
|
when "authorization_pending"
|
|
67
93
|
next
|
|
@@ -78,13 +104,14 @@ module Logi
|
|
|
78
104
|
end
|
|
79
105
|
end
|
|
80
106
|
|
|
81
|
-
def request_device_code(api_url:)
|
|
107
|
+
def request_device_code(api_url:, scopes: nil)
|
|
82
108
|
uri = URI.join(api_url, "/cli/auth/device")
|
|
83
109
|
req = Net::HTTP::Post.new(uri.path,
|
|
84
110
|
"Content-Type" => "application/json",
|
|
85
111
|
"Accept" => "application/json"
|
|
86
112
|
)
|
|
87
|
-
|
|
113
|
+
body = scopes ? { scope: Array(scopes).join(" ") } : {}
|
|
114
|
+
req.body = JSON.generate(body)
|
|
88
115
|
res = http(uri).request(req)
|
|
89
116
|
JSON.parse(res.body)
|
|
90
117
|
rescue => e
|
|
@@ -105,11 +132,11 @@ module Logi
|
|
|
105
132
|
nil
|
|
106
133
|
end
|
|
107
134
|
|
|
108
|
-
def save_and_print(token_response, pastel)
|
|
135
|
+
def save_and_print(token_response, pastel, name: "CLI")
|
|
109
136
|
Config.save(
|
|
110
137
|
api_key: token_response["token"],
|
|
111
138
|
api_url: token_response["api_url"] || Login::DEFAULT_API,
|
|
112
|
-
name:
|
|
139
|
+
name: name
|
|
113
140
|
)
|
|
114
141
|
|
|
115
142
|
user = token_response["user"] || {}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Logi
|
|
4
|
+
module Commands
|
|
5
|
+
# `logi manual` — agent-first self-loading manual.
|
|
6
|
+
#
|
|
7
|
+
# One command that loads usage + the command catalog + RP-integration
|
|
8
|
+
# entry points into an AI agent's context (the onpod `manual` pattern):
|
|
9
|
+
# docs prompts can say "run `logi manual`" instead of pointing agents at
|
|
10
|
+
# multiple doc URLs. The embedded text is self-contained and offline-safe;
|
|
11
|
+
# `--full` additionally fetches the two runbook LLM splits from docs.
|
|
12
|
+
module Manual
|
|
13
|
+
DOCS_BASE = "https://docs.1pass.dev"
|
|
14
|
+
RUNBOOKS = {
|
|
15
|
+
"registration runbook" => "/llms/guide/cli-rp-registration.md",
|
|
16
|
+
"integration runbook" => "/llms/guide/agent-quickstart.md"
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
# Plain text on purpose — no colors/TTY layout. The primary reader is
|
|
20
|
+
# an AI agent ingesting stdout; humans get the same text fine.
|
|
21
|
+
EMBEDDED = <<~MANUAL
|
|
22
|
+
# logi CLI — agent manual
|
|
23
|
+
|
|
24
|
+
logi (1pass) is an OIDC Identity Provider. This manual is designed to be
|
|
25
|
+
loaded into an AI agent's context via `logi manual`. It is self-contained
|
|
26
|
+
for registering an RP (OAuth app) and starting a login integration.
|
|
27
|
+
|
|
28
|
+
## Key facts
|
|
29
|
+
- Issuer / OAuth host: https://api.1pass.dev — the id_token `iss` is this
|
|
30
|
+
literal. The console host start.1pass.dev is NOT an OAuth host
|
|
31
|
+
(discovery/jwks return 404 there).
|
|
32
|
+
- Discovery: https://api.1pass.dev/.well-known/openid-configuration
|
|
33
|
+
- Valid scopes: openid profile:basic email (bare `profile` is rejected)
|
|
34
|
+
- redirect_uri matches byte-for-byte. `apps create` takes exactly one;
|
|
35
|
+
add the rest (local / staging / prod / mobile scheme) with
|
|
36
|
+
`apps add-redirect`.
|
|
37
|
+
- client_secret / rp_health_secret are shown ONCE at create — put them in
|
|
38
|
+
the deploy env, never in code/git. Missed them? Rotate in the developer
|
|
39
|
+
console (https://start.1pass.dev) — CLI/API rotation is refused with
|
|
40
|
+
403 use_web_portal because it requires web OTP step-up.
|
|
41
|
+
- --client-type: confidential (backend can hide the secret) or public
|
|
42
|
+
(mobile/SPA, PKCE-only, no client_secret). Immutable after create.
|
|
43
|
+
|
|
44
|
+
## Command catalog
|
|
45
|
+
logi login Sign in (browser OAuth; --code for headless/SSH)
|
|
46
|
+
logi whoami Show sign-in info
|
|
47
|
+
logi developer register --email <e> --org "<o>" Become a developer
|
|
48
|
+
logi developer status Check identity_verified_level
|
|
49
|
+
logi developer verify-email Enter the 6-digit email code (SSO signups skip this)
|
|
50
|
+
logi apps create --name "<n>" --redirect-uri <url> \\
|
|
51
|
+
--scope openid profile:basic email --client-type <confidential|public>
|
|
52
|
+
logi apps add-redirect <id> <uri>
|
|
53
|
+
logi apps list | show <id> | verify <id> | delete <id>
|
|
54
|
+
(secret rotation: developer console only — web OTP step-up required)
|
|
55
|
+
logi token inspect <jwt> Decode + verify a JWT against JWKS
|
|
56
|
+
logi manual [--full] This manual (--full appends both runbooks)
|
|
57
|
+
|
|
58
|
+
## RP registration flow (6 steps)
|
|
59
|
+
1. logi login
|
|
60
|
+
2. logi developer register --email <e> --org "<o>"
|
|
61
|
+
3. logi developer status → if unverified: logi developer verify-email
|
|
62
|
+
4. logi apps create ... (client_id + secrets issued once)
|
|
63
|
+
5. Inject env: LOGI_API_URL / LOGI_CLIENT_ID / LOGI_CLIENT_SECRET (confidential only) / LOGI_RP_HEALTH_SECRET
|
|
64
|
+
6. logi apps verify <id> (production domains go `pending` → admin review)
|
|
65
|
+
|
|
66
|
+
## Deep docs (fetch on demand)
|
|
67
|
+
- Registration runbook: #{DOCS_BASE}/llms/guide/cli-rp-registration.md
|
|
68
|
+
- Integration runbook (track branching + F1-F8 pitfalls):
|
|
69
|
+
#{DOCS_BASE}/llms/guide/agent-quickstart.md
|
|
70
|
+
- English editions: swap /llms/ for /llms-en/
|
|
71
|
+
- Index of every topic: #{DOCS_BASE}/llms.txt
|
|
72
|
+
|
|
73
|
+
Run `logi manual --full` to print both runbooks inline (network required).
|
|
74
|
+
MANUAL
|
|
75
|
+
|
|
76
|
+
def self.perform(full: false)
|
|
77
|
+
puts EMBEDDED
|
|
78
|
+
return unless full
|
|
79
|
+
|
|
80
|
+
RUNBOOKS.each do |label, path|
|
|
81
|
+
body = fetch(path)
|
|
82
|
+
if body
|
|
83
|
+
puts "\n\n---\n# [#{label}] #{DOCS_BASE}#{path}\n\n"
|
|
84
|
+
puts body
|
|
85
|
+
else
|
|
86
|
+
warn "⚠ Could not fetch #{label} (#{DOCS_BASE}#{path}) — network unavailable? " \
|
|
87
|
+
"Fetch it yourself with: curl #{DOCS_BASE}#{path}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.fetch(path)
|
|
93
|
+
uri = URI("#{DOCS_BASE}#{path}")
|
|
94
|
+
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 10) do |http|
|
|
95
|
+
http.get(uri.path)
|
|
96
|
+
end
|
|
97
|
+
res.is_a?(Net::HTTPSuccess) ? res.body : nil
|
|
98
|
+
rescue StandardError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
private_class_method :fetch
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
data/lib/logi/config.rb
CHANGED
|
@@ -4,7 +4,11 @@ module Logi
|
|
|
4
4
|
# Load/save credentials.json. Default path: ~/.config/logi/credentials.json
|
|
5
5
|
# Environment variables LOGI_API_KEY / LOGI_API_URL take precedence when set.
|
|
6
6
|
class Config
|
|
7
|
-
|
|
7
|
+
# Production default. The shipped gem must point at prod out of the box —
|
|
8
|
+
# otherwise `logi login` issues the authorize request to start.1pass.dev
|
|
9
|
+
# but POSTs the token exchange to localhost, which 404s for every user.
|
|
10
|
+
# Local development overrides this with LOGI_API_URL=http://localhost:3000.
|
|
11
|
+
DEFAULT_API_URL = "https://api.1pass.dev"
|
|
8
12
|
|
|
9
13
|
def self.path
|
|
10
14
|
File.expand_path(ENV.fetch("LOGI_CONFIG_PATH", "~/.config/logi/credentials.json"))
|
data/lib/logi/http_client.rb
CHANGED
data/lib/logi/version.rb
CHANGED
data/lib/logi.rb
CHANGED
|
@@ -19,5 +19,7 @@ require_relative "logi/output"
|
|
|
19
19
|
require_relative "logi/commands/login"
|
|
20
20
|
require_relative "logi/commands/device_login"
|
|
21
21
|
require_relative "logi/commands/app"
|
|
22
|
+
require_relative "logi/commands/developer"
|
|
22
23
|
require_relative "logi/commands/token"
|
|
24
|
+
require_relative "logi/commands/manual"
|
|
23
25
|
require_relative "logi/cli"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: logi-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dcode
|
|
@@ -110,8 +110,10 @@ files:
|
|
|
110
110
|
- lib/logi.rb
|
|
111
111
|
- lib/logi/cli.rb
|
|
112
112
|
- lib/logi/commands/app.rb
|
|
113
|
+
- lib/logi/commands/developer.rb
|
|
113
114
|
- lib/logi/commands/device_login.rb
|
|
114
115
|
- lib/logi/commands/login.rb
|
|
116
|
+
- lib/logi/commands/manual.rb
|
|
115
117
|
- lib/logi/commands/token.rb
|
|
116
118
|
- lib/logi/config.rb
|
|
117
119
|
- lib/logi/http_client.rb
|