Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5041d36
Sign in to boxel-cli through the browser
lukemelia Jul 30, 2026
db6cf35
Cover the CLI's browser sign-in against the mock OIDC provider
lukemelia Jul 31, 2026
d571de2
Use an obviously synthetic Matrix ID in the SSO fixtures
lukemelia Jul 31, 2026
0857bb5
Sign in through the Boxel authorization page, not straight to Google
lukemelia Jul 31, 2026
972ebcf
Point the CLI at the right host app origin per environment
lukemelia Aug 3, 2026
6592011
Serve the CLI sign-in page from the realm server
lukemelia Aug 3, 2026
7011a76
Identify the CLI's callback by port instead of by URL
lukemelia Aug 3, 2026
a6502b9
Give the CLI authorization page the sign-in screen's chrome
lukemelia Aug 3, 2026
3b6b613
Space the submit button off the password field
lukemelia Aug 3, 2026
8e3fb6a
Wait a quarter of an hour for the browser sign-in
lukemelia Aug 3, 2026
13db5cc
Prefill the username on the CLI authorization page
lukemelia Aug 3, 2026
10d5bb4
Open the sign-in page on the app's origin in local dev
lukemelia Aug 3, 2026
4c2e826
Reset a password without leaving the CLI authorization
lukemelia Aug 3, 2026
b2f855d
Ask the homeserver about Google directly
lukemelia Aug 3, 2026
9d4ea3b
Release the handles that outlive a browser sign-in
lukemelia Aug 3, 2026
4a34e1f
Destroy the callback connections, not just the idle ones
lukemelia Aug 3, 2026
20f35f3
Answer a callback the listener cannot parse, and close once
lukemelia Aug 3, 2026
a21453e
Select the CLI authorization fields as they are rendered
lukemelia Aug 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/boxel-cli/src/build-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,24 @@ export function buildBoxelProgram(version: string): Command {
'-r, --realm-server-url <url>',
'Realm server URL (for add command with non-standard domains)',
)
.option(
'--no-browser',
'Sign in with a username and password in the terminal instead of opening a browser (for add command)',
)
.option(
'--host-url <url>',
'Origin serving the browser sign-in page, when it is not the realm server (for add command)',
)
.addHelpText(
'after',
`
Sign-in (for 'add'):
Interactive 'boxel profile add' opens your browser to the Boxel sign-in
page, which offers both a username/password form and Google. Use
--no-browser to sign in with a username and password in the terminal
instead. Supplying -u with a password (or BOXEL_PASSWORD) stays fully
non-interactive and never opens a browser, which is the path to use in CI.

Environment variables (for 'add'):
BOXEL_PASSWORD Password; preferred over -p to avoid shell history.
BOXEL_ENVIRONMENT An env-mode slug (e.g. a branch name), interpreted
Expand All @@ -75,6 +90,8 @@ Environment variables (for 'add'):
name?: string;
matrixUrl?: string;
realmServerUrl?: string;
browser?: boolean;
hostUrl?: string;
},
) => {
if (options?.password) {
Expand Down
205 changes: 166 additions & 39 deletions packages/boxel-cli/src/commands/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getUsernameFromMatrixId,
} from '../lib/profile-manager.ts';
import { prompt, promptPassword } from '../lib/prompt.ts';
import { SsoTimeoutError, browserLogin } from '../lib/sso-login.ts';
import {
FG_GREEN,
FG_YELLOW,
Expand All @@ -24,12 +25,23 @@ export interface ProfileCommandOptions {
name?: string;
matrixUrl?: string;
realmServerUrl?: string;
// Commander sets this to false for `--no-browser`. Undefined means the
// default: sign in through the browser.
browser?: boolean;
hostUrl?: string;
}

interface EnvironmentDefaults {
domain: string;
matrixUrl: string;
realmServerUrl: string;
// Only set where the app is served from a different origin than the realm
// server. Deployed environments serve both from one, so the sign-in page is
// reachable at realmServerUrl and this stays unset. Local dev splits them
// across ports, and the origin matters: the browser session lives in
// origin-scoped storage, so a page loaded from the realm server's port
// wouldn't see the session established on the app's.
appUrl?: string;
}

const MENU_ENVIRONMENTS: Record<
Expand All @@ -50,6 +62,8 @@ const MENU_ENVIRONMENTS: Record<
domain: 'localhost',
matrixUrl: 'http://localhost:8008',
realmServerUrl: 'https://localhost:4201/',
// The host vite dev server, which is where local dev signs in.
appUrl: 'https://localhost:4200/',
},
};

Expand Down Expand Up @@ -155,7 +169,14 @@ export async function profileCommand(
realmServerUrl ?? envDefaults?.realmServerUrl,
);
} else {
await addProfile(manager, resolveBoxelEnvironment());
await addProfile(
manager,
resolveBoxelEnvironment(),
options?.browser !== false,
options?.hostUrl
? validateUrl(options.hostUrl, '--host-url')
: undefined,
);
}
break;
}
Expand Down Expand Up @@ -241,11 +262,7 @@ async function listProfiles(manager: ProfileManager): Promise<void> {
}
}

async function promptEnvironmentMenu(): Promise<{
domain: string;
matrixUrl: string;
realmServerUrl: string;
}> {
async function promptEnvironmentMenu(): Promise<EnvironmentDefaults> {
console.log(`Which environment?`);
console.log(` ${FG_CYAN}1${RESET}) Staging (realms-staging.stack.cards)`);
console.log(` ${FG_MAGENTA}2${RESET}) Production (app.boxel.ai)`);
Expand Down Expand Up @@ -290,30 +307,92 @@ async function promptEnvironmentMenu(): Promise<{
return { ...MENU_ENVIRONMENTS.staging };
}

async function addProfile(
// Returns false when the user declined to replace an existing profile.
async function confirmOverwrite(
manager: ProfileManager,
envDefaults?: EnvironmentDefaults | null,
): Promise<void> {
console.log(`\n${BOLD}Add New Profile${RESET}\n`);
matrixId: string,
): Promise<boolean> {
if (!manager.getProfile(matrixId)) {
return true;
}
console.log(`\n${FG_YELLOW}Profile ${matrixId} already exists.${RESET}`);
const overwrite = await prompt('Overwrite? [y/N]: ');
if (overwrite.toLowerCase() !== 'y') {
console.log('Cancelled.');
return false;
}
return true;
}

let domain: string;
let defaultMatrixUrl: string;
let defaultRealmUrl: string;
async function promptDisplayName(matrixId: string): Promise<string> {
const defaultDisplayName = `${getUsernameFromMatrixId(matrixId)} \u00b7 ${getDomainFromMatrixId(matrixId)}`;
const displayNameInput = await prompt(
`Display name [${defaultDisplayName}]: `,
);
return displayNameInput || defaultDisplayName;
}

if (envDefaults) {
console.log(
`${DIM}Using BOXEL_ENVIRONMENT=${process.env.BOXEL_ENVIRONMENT}${RESET}`,
);
domain = envDefaults.domain;
defaultMatrixUrl = envDefaults.matrixUrl;
defaultRealmUrl = envDefaults.realmServerUrl;
} else {
const menuResult = await promptEnvironmentMenu();
domain = menuResult.domain;
defaultMatrixUrl = menuResult.matrixUrl;
defaultRealmUrl = menuResult.realmServerUrl;
// `usePassword` is distinct from `cancelled`: the first means the browser path
// couldn't finish and the caller should ask for a password instead, the second
// means the user chose to stop and nothing more should be asked.
type AddProfileOutcome =
| { status: 'added'; matrixId: string }
| { status: 'cancelled' }
| { status: 'usePassword' };

// Browser sign-in. The authorization page offers both a password form and a
// Google button, and reports back whichever account the user signed in as — so
// unlike the terminal password path there is nothing to ask for up front.
async function addProfileViaBrowser(
manager: ProfileManager,
matrixUrl: string,
hostUrl: string,
realmServerUrl: string,
): Promise<AddProfileOutcome> {
let auth;
try {
auth = await browserLogin({ matrixUrl, hostUrl });
} catch (err) {
if (err instanceof SsoTimeoutError) {
console.log(`\n${FG_YELLOW}${err.message}${RESET}`);
return { status: 'usePassword' };
}
throw err;
}

// The page can sign in as an account other than the one the user expected —
// a Google identity whose verified email matches no existing account gets a
// brand-new one. Naming it before anything is written makes that visible
// rather than silent.
console.log(
`\n${FG_GREEN}✓${RESET} Signed in as ${formatProfileBadge(auth.userId)}`,
);
const proceed = await prompt('Save this profile? [Y/n]: ');
if (proceed.toLowerCase() === 'n') {
console.log('Cancelled.');
return { status: 'cancelled' };
}

if (!(await confirmOverwrite(manager, auth.userId))) {
return { status: 'cancelled' };
}

const displayName = await promptDisplayName(auth.userId);
await manager.addProfileWithAuth(
auth.userId,
auth,
displayName,
realmServerUrl,
);
return { status: 'added', matrixId: auth.userId };
}

async function addProfileViaPassword(
manager: ProfileManager,
domain: string,
matrixUrl: string,
realmServerUrl: string,
): Promise<AddProfileOutcome> {
console.log(`\nEnter your Boxel username (without @ or domain)`);
console.log(`${DIM}Example: ctse, aallen90${RESET}`);
const username = await prompt('Username: ');
Expand All @@ -325,13 +404,8 @@ async function addProfile(

const matrixId = `@${username}:${domain}`;

if (manager.getProfile(matrixId)) {
console.log(`\n${FG_YELLOW}Profile ${matrixId} already exists.${RESET}`);
const overwrite = await prompt('Overwrite? [y/N]: ');
if (overwrite.toLowerCase() !== 'y') {
console.log('Cancelled.');
return;
}
if (!(await confirmOverwrite(manager, matrixId))) {
return { status: 'cancelled' };
}

const password = await promptPassword('Password: ');
Expand All @@ -341,19 +415,72 @@ async function addProfile(
process.exit(1);
}

const defaultDisplayName = `${username} \u00b7 ${domain}`;
const displayNameInput = await prompt(
`Display name [${defaultDisplayName}]: `,
);
const displayName = displayNameInput || defaultDisplayName;
const displayName = await promptDisplayName(matrixId);

await manager.addProfile(
matrixId,
password,
displayName,
defaultMatrixUrl,
defaultRealmUrl,
matrixUrl,
realmServerUrl,
);
return { status: 'added', matrixId };
}

async function addProfile(
manager: ProfileManager,
envDefaults?: EnvironmentDefaults | null,
useBrowser = true,
hostUrlOverride?: string,
): Promise<void> {
console.log(`\n${BOLD}Add New Profile${RESET}\n`);

let domain: string;
let defaultMatrixUrl: string;
let defaultRealmUrl: string;
let defaultAppUrl: string | undefined;

if (envDefaults) {
console.log(
`${DIM}Using BOXEL_ENVIRONMENT=${process.env.BOXEL_ENVIRONMENT}${RESET}`,
);
domain = envDefaults.domain;
defaultMatrixUrl = envDefaults.matrixUrl;
defaultRealmUrl = envDefaults.realmServerUrl;
defaultAppUrl = envDefaults.appUrl;
} else {
const menuResult = await promptEnvironmentMenu();
domain = menuResult.domain;
defaultMatrixUrl = menuResult.matrixUrl;
defaultRealmUrl = menuResult.realmServerUrl;
defaultAppUrl = menuResult.appUrl;
}

// The realm server serves the app, so it serves the sign-in page too wherever
// the two share an origin — which is everywhere except local dev.
let outcome: AddProfileOutcome = useBrowser
? await addProfileViaBrowser(
manager,
defaultMatrixUrl,
hostUrlOverride ?? defaultAppUrl ?? defaultRealmUrl,
defaultRealmUrl,
)
: { status: 'usePassword' };

if (outcome.status === 'usePassword') {
outcome = await addProfileViaPassword(
manager,
domain,
defaultMatrixUrl,
defaultRealmUrl,
);
}

if (outcome.status !== 'added') {
return;
}

const matrixId = outcome.matrixId;

console.log(
`\n${FG_GREEN}\u2713${RESET} Profile created: ${formatProfileBadge(matrixId)}`,
Expand Down
8 changes: 8 additions & 0 deletions packages/boxel-cli/src/lib/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as readline from 'readline';
import { Writable } from 'stream';

export function prompt(question: string): Promise<string> {
const wasFlowing = process.stdin.readableFlowing;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
Expand All @@ -10,6 +11,13 @@ export function prompt(question: string): Promise<string> {
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
// Creating the interface resumes stdin, and closing it doesn't undo that —
// a resumed stdin keeps the event loop alive, so a command whose last act
// is a prompt would sit there having already finished its work. The
// no-echo prompt below takes the same care.
if (!wasFlowing) {
process.stdin.pause();
}
resolve(answer.trim());
});
});
Expand Down
Loading
Loading