Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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: 16 additions & 1 deletion packages/boxel-cli/src/build-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { registerRunCommand } from './commands/run-command';
import { registerSearchCommand } from './commands/search';
import { setQuiet } from './lib/cli-log';
import { warnIfMisplacedLocalRealmDirs } from './lib/realm-local-paths';
import { getProfileManager } from './lib/profile-manager';

/**
* Construct the boxel CLI program with every command registered. Pure builder
Expand All @@ -27,12 +28,26 @@ export function buildBoxelProgram(version: string): Command {
'-q, --quiet',
'Suppress informational progress logs (info/log/debug). Errors and warnings, plus command result payloads (JSON, file contents), are still emitted. Use this when invoking the CLI from automation (e.g. the software factory test harness) to keep stdout focused on the result.',
)
.hook('preAction', (thisCommand) => {
.hook('preAction', async (thisCommand) => {
let opts = thisCommand.optsWithGlobals?.() ?? thisCommand.opts();
if (opts.quiet) {
setQuiet(true);
}
warnIfMisplacedLocalRealmDirs(process.cwd());
// One-shot migration for profiles persisted before CS-10725 (when the
// schema stored `password` instead of `matrixAccessToken`). Runs once
// per CLI invocation: re-logs-in with the on-disk password and
// replaces it with the resulting access token. Failures are warned
// about and skipped so a single broken profile doesn't block the
// rest of the command.
try {
await getProfileManager().migrateLegacyProfiles();
} catch {
Comment on lines +31 to +45
// migrateLegacyProfiles swallows per-profile failures internally;
// any error here means something is fundamentally wrong with the
// profiles file. Surface nothing — the actual command will fail
// loudly when it tries to use a profile.
}
});

program
Expand Down
48 changes: 24 additions & 24 deletions packages/boxel-cli/src/commands/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ function validateUrl(input: string, label: string): string {

// Matches scripts/env-slug.sh: lowercase, "/" -> "-", strip chars outside
// [a-z0-9-], collapse runs of "-", trim leading/trailing "-".
function computeEnvSlug(name: string): string {
export function computeEnvSlug(name: string): string {
return name
.toLowerCase()
.replace(/\//g, '-')
Expand All @@ -91,7 +91,7 @@ function computeEnvSlug(name: string): string {

// Derive URLs from BOXEL_ENVIRONMENT using the same ".${slug}.localhost"
// pattern that mise-tasks/lib/env-vars.sh produces for env-mode local dev.
function resolveBoxelEnvironment(): EnvironmentDefaults | null {
export function resolveBoxelEnvironment(): EnvironmentDefaults | null {
const raw = process.env.BOXEL_ENVIRONMENT;
if (!raw || !raw.trim()) return null;
const slug = computeEnvSlug(raw);
Expand Down Expand Up @@ -458,14 +458,28 @@ async function addProfileNonInteractive(
process.exit(1);
}

if (manager.getProfile(matrixId)) {
console.log(
`${FG_YELLOW}Profile ${matrixId} already exists. Updating password.${RESET}`,
const isUpdate = Boolean(manager.getProfile(matrixId));

// addProfile performs a real matrixLogin and persists the resulting
// access token (the password never lands on disk). It also handles the
// create-vs-reauth split uniformly: re-running it on an existing profile
// refreshes the stored token while preserving cached realm tokens.
try {
await manager.addProfile(
matrixId,
password,
displayName,
matrixUrl,
Comment on lines +468 to +472
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate cached tokens before rewriting profile URLs

When profile add updates an existing profile, this now calls manager.addProfile(...) before updateUrls(...). addProfileWithAuth preserves the previous realmTokens and realmServerToken, and because URLs have already been rewritten by the time updateUrls runs, it sees no change and does not clear those caches. That leaves tokens minted for the old server in the profile; code paths like realm create that use getOrRefreshServerToken() + fetchAndStoreRealmToken() without an automatic 401 retry can fail or silently skip token acquisition after a URL change.

Useful? React with 👍 / 👎.

realmServerUrl,
);
Comment on lines +461 to 474
await manager.updatePassword(matrixId, password);
if (displayName) {
manager.updateDisplayName(matrixId, displayName);
}
} catch (err) {
console.error(
`${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}

if (isUpdate) {
if (matrixUrl || realmServerUrl) {
const urlsChanged = manager.updateUrls(matrixId, {
matrixUrl,
Expand All @@ -483,20 +497,6 @@ async function addProfileNonInteractive(
return;
}

try {
await manager.addProfile(
matrixId,
password,
displayName,
matrixUrl,
realmServerUrl,
);
} catch (err) {
console.error(
`${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
console.log(
`${FG_GREEN}\u2713${RESET} Profile created: ${formatProfileBadge(matrixId)}`,
);
Expand Down Expand Up @@ -538,7 +538,7 @@ async function migrateFromEnv(manager: ProfileManager): Promise<void> {
);
} else {
console.log(
`${FG_YELLOW}Profile ${formatProfileBadge(result.profileId)} already exists.${RESET} Password has been updated if it changed.`,
`${FG_GREEN}\u2713${RESET} Refreshed profile: ${formatProfileBadge(result.profileId)}`,
);
console.log(
`\n${DIM}Use 'boxel profile add -u ${result.profileId} -p <password>' to update other fields.${RESET}`,
Expand Down
46 changes: 41 additions & 5 deletions packages/boxel-cli/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ export interface MatrixAuth {

export type RealmTokens = Record<string, string>;

// Thrown when Matrix rejects an access token (401/403). Callers can catch
// this specifically to drive interactive re-auth without parsing messages.
export class MatrixAuthError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = 'MatrixAuthError';
this.status = status;
}
}

interface MatrixLoginResponse {
access_token: string;
device_id: string;
Expand Down Expand Up @@ -69,6 +80,12 @@ async function getOpenIdToken(

if (!response.ok) {
let text = await response.text();
if (response.status === 401 || response.status === 403) {
throw new MatrixAuthError(
response.status,
`OpenID token request failed: ${response.status} ${text}`,
);
}
throw new Error(`OpenID token request failed: ${response.status} ${text}`);
}

Expand Down Expand Up @@ -138,17 +155,30 @@ function userRealmsAccountDataUrl(matrixAuth: MatrixAuth): string {
export async function getUserRealmsFromMatrixAccountData(
matrixAuth: MatrixAuth,
): Promise<string[]> {
let response: Response;
try {
let response = await fetch(userRealmsAccountDataUrl(matrixAuth), {
response = await fetch(userRealmsAccountDataUrl(matrixAuth), {
headers: { Authorization: `Bearer ${matrixAuth.accessToken}` },
});
if (!response.ok) {
return [];
}
} catch {
// Network unreachable / DNS / similar — treat as empty (best-effort).
return [];
}
if (response.status === 401 || response.status === 403) {
let text = await response.text();
throw new MatrixAuthError(
response.status,
`Matrix account_data fetch failed: ${response.status} ${text}`,
);
}
if (!response.ok) {
// 404 just means the event has never been set — return empty list.
return [];
}
try {
let data = (await response.json()) as { realms?: string[] };
return Array.isArray(data.realms) ? [...data.realms] : [];
} catch {
// Best-effort — treat unreachable account data as an empty list
return [];
}
}
Expand All @@ -171,6 +201,12 @@ export async function addRealmToMatrixAccountData(
});
if (!putResponse.ok) {
let text = await putResponse.text();
if (putResponse.status === 401 || putResponse.status === 403) {
throw new MatrixAuthError(
putResponse.status,
`Failed to update Matrix account data: ${putResponse.status} ${text}`,
);
}
throw new Error(
`Failed to update Matrix account data: ${putResponse.status} ${text}`,
);
Expand Down
Loading