Making the CLI and Browser Talk

A Fly balloon getting the browser and CLI to talk
Image by Annie Ruygt

Need a place for your Laravel app in the cloud? Fly it with Fly.io, it’ll be up and running in minutes!

The CLI and web browser don’t really talk to each other. Let’s force some communication and make it πŸŽƒ wyrd πŸŽƒ.

We’re going to let users create an account (“register”) in our app, using the CLI and their web browser.

CLI as User Interface

The flyctl (aka fly) command is the main UI for Fly.io. There’s nothing special about how it works tho - to create servers, deployments, and everything else, it’s “just” making calls to the Fly API. This requires an API key.

The fly command can get that API key for you automatically. Getting the API token happens on registration or login (fly auth signup|login). The command kicks you out to the browser. After you sign up and return to your terminal, flyctl knows you’ve authenticated and already has a new API key.

How does flyctl know you’ve done that, and how does it get the API key?

Let’s see how (and do it ourself).

The Magic

There’s no magic, not even a hint of πŸ§™β€β™€οΈ witchcraft.

The signup command gets a session ID, and performs an extremely polite-but-boring polling of the API (using that session ID) to ask if the API could please inform us if the user has taken the time out of their busy schedule to perhaps finish the registering they requested.

What we’re doing here will resemble what flyctl does, but we’re going to do it in Laravel.

The process is this:

A register command will:

  1. Ask for a new CLI session (we get a token back)
  2. Kick the user out to the browser to register, passing the session ID
  3. Poll the API with the session ID to ask if the user finished registering

Our application needs:

  1. API endpoints to handle creating a CLI session and checking on its status
  2. Adjustments to the browser-based registration flow to capture the CLI session ID and associate it with a user who registered

Project Setup

We’ll create a new Laravel project, using Breeze to scaffold authentication. You can ➑️ view the repository ⬅️ for any details I hand-wave over.

Here’s a quick set of commands to scaffold everything out:

# Create the web app that a user will interact with in the browser
composer create-project laravel/laravel browser-cli
cd browser-cli

composer require laravel/breeze --dev
php artisan breeze:install
npm i && npm run build

# Create a model, migration, and resource controller for
# CLI session handling
php artisan make:model --migration --controller --resource CliSession

# Create a CLI command that we'll use, but in reality would likely
# be a separate code base that a user gets installed to their machine
php artisan make:command --command register RegisterCommand

The model and migration are extremely vanilla. We get a UUID as a CLI session ID, and can associate it with a user.

Let’s dive into the details, starting with the register CLI command, since it’s the “top-level” thing we’ll be doing.

Register Command

I created an artisan register command within the same code base as the API. This is just easier for demonstration. In reality, you’d likely create a separate CLI-only app for users to install (and it would talk to your API, which is probably a whole separate Laravel code base).

The register command is going to get a new CLI session, kick the user to the browser, and then wait for registration to happen. It polls the API, asking if the user registered. When registration happens, the CLI will get a valid API token for the user related to the CLI session ID.

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;

class RegisterCommand extends Command
{

    protected $signature = 'register';
    protected $description = 'Kick off user registration';

    public function handle()
    {
        // We're using the same code base for the API, so lets
        // just get the URL for cli-session stuff
        $baseUrl = url('/api/cli-session');

        // Get a new CLI session
        $cliSessionResponse = Http::post($baseUrl, [
            'name' => gethostname(),
        ]);

        if (! $cliSessionResponse->successful()) {
            $this->error("Could not start registration session");
            return Command::FAILURE;
        }

        $cliSession = $cliSessionResponse->json();

        $stop = now()->addMinutes(15);

        // TODO: Using "open" is different per OS
        Process::run("open ".$cliSession['url']);

        // Poll API for session status every few seconds
        $apiToken = null;
        while(now()->lte($stop)) {

            // check session status to see if user
            // has finished the registration process
            $response = Http::get($baseUrl.'/'.$cliSession['id']);

            // non-20x response is an unexpected error
            if (! $response->successful()) {
                $this->error('could not register');
                return Command::FAILURE;
            }

            // 200 response means user registered
            if ($response->ok()) {
                // response includes an API token
                $apiToken = $response->json('api_token');
                $this->info('Success! Retrieved API token: ' . $apiToken);
                break;
            }

            // Else I chose to assume we got an HTTP 202, 
            // meaning "keep trying every few seconds"
            sleep(2);
        }

        // TODO: Success! Store $apiToken somewhere for future use
        //       e.g. ~/.<my-app>/.config.yml πŸ¦‰
        $this->info('success!');
        return Command::SUCCESS;
    }
}

Just as advertised, we’re starting a session and then polling the API for its status (for up to 15 minutes).

Once the user registers, we get an API token back that we can use to perform future actions as that user.

One thing I skipped over is how we use the open command to open the web browser. How you do that changes per OS. See examples of how core Laravel does this across OSes here.

API Routes

We have /api/cli-session routes to handle! Within routes/api.php, we can register the resource controller. The interesting part is the CliSessionController itself:

<?php

namespace App\Http\Controllers;

use App\Models\CliSession;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

class CliSessionController extends Controller
{
    // Create a CLI session when requested by
    // the register command
    public function store(Request $request)
    {
        $request->validate([
            'name' => 'required',
        ]);

        $cliSession = CliSession::create([
            'name' => $request->name,
            'uuid' => Str::uuid()->toString(),
        ]);

        return response()->json([
            'id' => $cliSession->uuid,
            'url' => route('register', [
                'cli_session' => $cliSession->uuid
            ]),
        ]);
    }

    // Allow the register command to poll
    // for session status
    public function show(string $id)
    {
        $cliSession = CliSession::with('user')
            ->where('uuid', $id)
            ->firstOrFail();

        // Session exists but no user yet
        if (! $cliSession->user) {
            return response('', 202);
        }

        /**
         * User is associated with session 
         * (successful login or register).
         * Give them a new, usable API token
         */

        // Ensure no one can re-use this session
        $cliSession->delete();

        // TODO: Generate a for-real api token
        //       perhaps via Laravel Sanctum πŸ¦‰
        return [
            'api_token' => Str::random(32),
        ];
    }
}

It only has two jobs:

  1. Let the register command start a session
  2. Let the register command poll that session to see if the user finished registering

Creating a session just involves making a new cli_sessions table record with a new UUID. That UUID is passed back and becomes the session ID the register command uses to check on the session status.

The register command then polls the show method. When the user finishes registering, the it returns a valid API token for the App\Models\User associated with the CLI session. That’s the end of the process!

Browser Registration

We need to tweak the registration process so it becomes aware of the CLI session.

When a user registers, we need to associate the CLI session with the newly created user.

The API route checks for an associated user - if there is one, it assumes the user registered. It can then return an API key next time it is polled!

What those tweaks looks like depends if you’ve hand-rolled authentication, or used Breeze/Jetstream/Whatever. Here’s what it looks like for Breeze.

What we do is:

  1. Include a cli_session hidden <input /> in the registration form with the UUID of the session (if present)
  2. Adjust the registration POST request to find and update the CLI Session record to be associated with the just-created user

Relevant snippets from the adjusted Breeze-generated app/Http/Controllers/Auth/RegisteredUserController.php

/**
 * Display the registration view.
 */
public function create(): View
{
    // 1️⃣ Add the CLI session UUID to the register view
    return view('auth.register', [
        'cli_session' => request('cli_session')
    ]);
}

/**
 * Handle an incoming registration request.
 *
 * @throws \Illuminate\Validation\ValidationException
 */
public function store(Request $request): RedirectResponse
{
    $request->validate([...]);

    $user = User::create([...]);

    event(new Registered($user));

    Auth::login($user);

    // 2️⃣ Associate user with cli session, if present
    if ($request->cli_session) {
        $cliSession = CliSession::where('uuid', $request->cli_session)
            ->firstOrFail();

        $cliSession->user_id = $user->getKey();
        $cliSession->save();

        // TODO: Create this route with message letting
        //       user know to head to back to CLI
        return redirect('/cli-session-success');
    }

    return redirect(RouteServiceProvider::HOME);
}

Two hand-wavy πŸ‘‹ things:

  1. You’ll need to update the auth.register view to add a hidden input and store the cli_session value
  2. I didn’t bother creating a “success!” view that tells the user to head back to their terminal

Fly.io ❀️ Laravel

Fly your servers close to your users—and marvel at the speed of close proximity. Deploy globally on Fly in minutes!

Deploy your Laravel app!  

That’s It

Overall, it’s not too bad - we didn’t need to overhaul our registration process! We just hooked into it a bit in a way that allowed for the addition of a CLI session.

The register command did most of the work, and all that entailed was asking for a UUID and polling an endpoint.

Other Wacky Ideas

The main blocker for CLI-browser communication is the communication itself. How could the browser “push” data to a terminal process?

In our case, we didn’t “push” data anywhere. The terminal command was polite - it asked for the CLI session, and then asked for the status of the CLI session.

Here’s 2 zany ideas, both of which sort of suck for PHP but might fit in with other languages with stronger concurrency/async.

Warning: Strong “draw the rest of the owl” vibes here.

  1. Web sockets - Have your command and the browser connect to a web socket server, and communicate that way
  2. Web server - Have your command start a web server, listening at localhost:<some-port>, then have your web app redirect to http://localhosts:<some-port>/success (or whatever) when registration is complete