Skip to content

Commit

Permalink
Add login url to cb login output.
Browse files Browse the repository at this point in the history
This came as a request in #139. The gist of it being that there are some
use cases where a browser may not be available to the user, e.g.
headless or no default browser. Therefore it would be beneficial to
present such configurations with a login url instead that can be
copy/pasted to a browser of choice.

Though, here, we're taking it one step further an making sure to provide
the link regardless of browser availability. Though, in the case of no
browser availability, we no longer prompt the user to open one and
instead just present the login url.

Example:

```
> # With browser
> cb login
Press Enter to open a browser to login. (Ctrl+C to quit)
Or visit: <login_url>

> # Without browser
> cb login
To login with Crunchy Bridge, please visit: <login_url>
```
  • Loading branch information
abrightwell committed Dec 7, 2023
1 parent 74c8d17 commit 6f7b1e7
Show file tree
Hide file tree
Showing 5 changed files with 140 additions and 77 deletions.
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Changed
- `cb login` now presents a login url for use with headless environments or
where a default browser is not available

### Fixed
- High availability changes with `cb upgrade start` must be made without any other changes to the cluster.
Expand All @@ -17,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed
- `cb login` now uses a browser login flow. If direct use of an `API_KEY` is
necessary then it must be set via the `CB_API_KEY` environment variable.
necessary then it must be set via the `CB_API_KEY` environment variable.

### Fixed
- `cb create --fork` and `cb create --replica` input validation when using
Expand Down
58 changes: 38 additions & 20 deletions spec/cb/login_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -4,47 +4,65 @@ include CB
Spectator.describe CB::Login do
subject(action) { described_class.new input: IO::Memory.new, output: IO::Memory.new }

describe "#open_browser?" do
it "return true when input is not 'q'" do
action.input = IO::Memory.new "y"
expect(&.open_browser?).to be_true
end

it "returns false when input is 'q'" do
action.input = IO::Memory.new "q"
expect(&.open_browser?).to be_false
end
end

describe "#call" do
mock Client
let(client) { mock(Client) }

mock CB::Lib::Open
let(lib_open_mock) { class_mock(CB::Lib::Open) }

mock Process
let(process_mock) { class_mock(Process) }

let(account) { Factory.account }

before_each {
ENV["CB_API_KEY"] = nil
action.client = client
action.open_browser = ->(_url : String) { true }
action.store_credentials = ->(_account : String, _secret : String) { true }
action.lib_open = lib_open_mock
}

it "doesn't open browser when input is 'q'" do
action.input = IO::Memory.new "q"
expect(&.call).to raise_error
it "creates and stores a new session (browser)" do
expect(lib_open_mock).to receive(:can_open_browser?).and_return(true)
expect(lib_open_mock).to receive(:run).and_return(true)
expect(client).to receive(:create_session_intent).and_return(Factory.session_intent)
expect(client).to receive(:get_account).and_return(account)
expect(client).to receive(:get_session_intent).and_return(
Factory.session_intent(expires_at: Time.utc + 1.day, session: Factory.session)
)

action.input = IO::Memory.new "\n"

result = action.call
expect(result).to_not be_empty
expect(action.output.to_s.ends_with?("Logged in as #{account.email}\n")).to be_true
end

it "creates and stores a new session" do
it "creates and stores a new session (headless)" do
expect(lib_open_mock).to receive(:can_open_browser?).and_return(false)
expect(client).to receive(:create_session_intent).and_return(Factory.session_intent)
expect(client).to receive(:get_account).and_return(Factory.account)
expect(client).to receive(:get_account).and_return(account)
expect(client).to receive(:get_session_intent).and_return(
Factory.session_intent(expires_at: Time.utc + 1.day, session: Factory.session)
)

action.input = IO::Memory.new "y"

result = action.call
expect(result).to_not be_empty
expect(action.output.to_s.ends_with?("Logged in as #{account.email}\n")).to be_true
end

# it "exits with error message if session is expired" do
# expect(lib_open_mock).to receive(:can_open_browser?).and_return(false)
# expect(client).to receive(:get_session_intent).and_return(
# Factory.session_intent(expires_at: Time.utc - 1.day, session: Factory.session)
# )

# action.call

# expect(action.output.to_s.ends_with?("login error: login timed out\n")).to be_true
# end

it "raises error if CB_API_KEY is set" do
ENV["CB_API_KEY"] = "cbkey_secret"
expect(&.call).to raise_error(CB::Program::Error)
Expand Down
123 changes: 77 additions & 46 deletions src/cb/login.cr
Original file line number Diff line number Diff line change
Expand Up @@ -3,84 +3,115 @@ require "./action"

module CB
class Login < Action
private class LoginError < Exception
def initialize(message)
super("login error: #{message}")
end
end

private class NoSession < Exception
def initialize(message = "No session")
super(message)
end
end

property open_browser : Proc(String, Bool) = ->(url : String) : Bool {
CB::Lib::Open.run([url])
}
private alias LoginResult = LoginInfo | LoginError

private struct LoginInfo
property account : CB::Model::Account
property secret : String?

def initialize(@account, @secret)
end
end

property client : CB::Client = CB::Client.new CB::HOST

# Library used for opening a browser. This should ONLY be overriden for
# testing purposes.
property lib_open : CB::Lib::Open.class = CB::Lib::Open

property store_credentials : Proc(String, String, Bool) = ->(account : String, secret : String) : Bool {
Credentials.store account: account, secret: secret
}

property client : CB::Client = CB::Client.new CB::HOST
private def poll_login(channel : Channel(LoginResult), session_intent : CB::Model::SessionIntent)
si_get_params = Client::SessionIntentGetParams.new(
session_intent_id: session_intent.id,
secret: "#{session_intent.secret}",
)

def open_browser? : Bool
output << "Press any key to open a browser to login or "
output << "q".colorize.yellow
output << " to exit: "
Retriable.retry(on: NoSession, base_interval: 1.seconds, multiplier: 1.0, rand_factor: 0.0) do
session_intent = @client.get_session_intent(si_get_params)
raise LoginError.new("login timed out") if Time.utc > session_intent.expires_at
raise NoSession.new unless session_intent.session
end

response = if input == STDIN
STDIN.raw &.read_char
else
input.read_char
end
output << '\n'
secret = session_intent.session.try &.secret
account = @client.get_account(secret)

return response.downcase != 'q' if response
false
channel.send(LoginInfo.new account: account, secret: secret)
end

def run
# Prevent login if API key ENV is set.
raise Error.new "Cannot login with #{"CB_API_KEY".colorize.red.bold} set." if ENV["CB_API_KEY"]?

# Prompt to open a browser or allow the user to abort the login.
raise Error.new "Aborting login." unless open_browser?

# Request a session intent.
si_params = Client::SessionIntentCreateParams.new(agent_name: "cb #{CB::VERSION}")
session_intent = @client.create_session_intent si_params

# Open a browser with the new session intent.
login_url = "https://www.crunchybridge.com/account/verify-cli/#{session_intent.id}?code=#{session_intent.code}"
@open_browser.call(login_url)

# Begin polling for session intent activation.
spinner = Spinner.new("Waiting for login...", output)
spinner.start

si_get_params = Client::SessionIntentGetParams.new(
session_intent_id: session_intent.id,
secret: "#{session_intent.secret}",
)

begin
Retriable.retry(on: NoSession, base_interval: 1.seconds, multiplier: 1.0, rand_factor: 0.0) do
session_intent = @client.get_session_intent(si_get_params)
raise Error.new "login timed out" if Time.utc > session_intent.expires_at
raise NoSession.new unless session_intent.session
end

spinner.stop

secret = session_intent.session.try &.secret
account = @client.get_account(secret)
# Start polling for completion of session authentication.
poll_login_channel = Channel(LoginResult).new
spawn do
poll_login(channel: poll_login_channel, session_intent: session_intent)
rescue e : CB::Client::Error
output << '\n'
raise Error.new e.resp.status.description
rescue e : LoginError
poll_login_channel.send(e)
end

spinner = Spinner.new("Waiting for login...", output)

# If the client can open a browser then prompt to do so. If the client is
# headless or can't open a browser then only present a login url that can
# be copied and pasted in to a browser.
if lib_open.can_open_browser?
output << "Press #{"Enter".colorize.bold} to open a browser to login. (#{"Ctrl+C".colorize.yellow} to quit)\n"
output << "Or visit: #{login_url}"

# Spawn a new fiber while we're waiting for user input. This is so that
# we can continue on to wait for polling. The reason for this is that a
# user might not want to simply copy/paste the URL and not have it
# opened for them in a browser. If they were do that that they'd still
# be stuck here until hitting `Enter`. So, we want to ensure that we can
# reach the the point at which the session is received regardless of the
# path they choose to take.
spawn do
input.gets
spinner.start
lib_open.run([login_url])
end
else
output << "To login with Crunchy Bridge, please visit: #{login_url}\n"
spinner.start
end

stored = @store_credentials.call(account.email.to_s, secret.to_s)
result = poll_login_channel.receive

raise Error.new "Could not store login credentials." unless stored
output << "Logged in as #{account.email.to_s.colorize.green}\n"
if result.is_a? LoginError
spinner.stop "#{result.message.to_s.colorize.red}"
result.message.to_s
else
spinner.stop "Done!"
stored = @store_credentials.call(result.account.email.to_s, result.secret.to_s)

secret.to_s
raise Error.new "Could not store login credentials." unless stored
output << "Logged in as #{result.account.email.to_s.colorize.green}\n"
result.secret.to_s
end
end
end
end
11 changes: 11 additions & 0 deletions src/lib/open.cr
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ module CB::Lib
raise CB::Program::Error.new "Sorry, don't know how to open a web browser on your operating system"
{% end %}

def self.can_open_browser?
{% if flag?(:darwin) %}
true
{% elsif flag?(:linux) %}
Process.run("command", ["-v", "xdg-settings", "get", "default-web-browser"]).success?
# Process.run("command -v #{OPEN_COMMAND}").success?
{% else %}
false
{% end %}
end

def self.run(args : Array(String), env : Process::Env = {} of String => String) : Bool
status = Process.run(OPEN_COMMAND, args: args, env: env)
status.success?
Expand Down
20 changes: 10 additions & 10 deletions src/ui/spinner.cr
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,31 @@ module CB
def initialize(@text : String = "", @io : IO = IO::Memory.new)
@chars = ["|", "/", "-", "\\"].map { |c| "#{c.colorize.blue}" }
@delay = 0.2
@running = true
@running = false

# Control Sequence to allow overwriting the line so that the spinner can
# be properly animated.
@clear = @io.tty? ? "\u001b[0G" : "\u000d \u000d"
end

def start
@running = true
spawn do
# Control Sequence to allow overwriting the line so that the spinner can
# be properly animated.
clear = @io.tty? ? "\u001b[0G" : "\u000d \u000d"

# Allow the spinner iterator to restart when it reaches the end.
spinner = @chars.each.cycle

while @running
@io << clear
@io << @clear
@io << "#{@text} #{spinner.next}"
sleep @delay
end

@io << clear
@io << "#{@text} done\n"
end
end

def stop
def stop(message : String)
@running = false
@io << @clear
@io << "#{@text} #{message}\n"
end
end
end

0 comments on commit 6f7b1e7

Please sign in to comment.