Skip to content

CS-10615: Reimplement boxel profile command#4354

Open
FadhlanR wants to merge 2 commits intomainfrom
cs-10615-reimplement-boxel-profile-command
Open

CS-10615: Reimplement boxel profile command#4354
FadhlanR wants to merge 2 commits intomainfrom
cs-10615-reimplement-boxel-profile-command

Conversation

@FadhlanR
Copy link
Copy Markdown
Contributor

@FadhlanR FadhlanR commented Apr 8, 2026

Summary

  • Port boxel profile command from standalone boxel-cli into monorepo at packages/boxel-cli
  • Add ProfileManager class for CRUD operations on ~/.boxel-cli/profiles.json (0600 perms)
  • Subcommands: list, add (interactive wizard), switch (partial match), remove, migrate (from .env)
  • 22 unit tests covering all ProfileManager operations + environment helpers

Test plan

  • pnpm --filter @cardstack/boxel-cli test — 24 tests pass
  • pnpm --filter @cardstack/boxel-cli build — builds successfully
  • pnpm --filter @cardstack/boxel-cli lint — no errors

🤖 Generated with Claude Code

FadhlanR and others added 2 commits April 8, 2026 15:55
Port profile management from standalone boxel-cli into monorepo.
Adds ProfileManager class for CRUD operations on ~/.boxel-cli/profiles.json
with subcommands: list, add, switch, remove, migrate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@FadhlanR FadhlanR marked this pull request as ready for review April 8, 2026 15:48
@FadhlanR FadhlanR requested review from a team and jurgenwerk April 8, 2026 15:48
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1d9086f986

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +169 to +173
env === 'production'
? 'https://matrix.boxel.ai'
: 'https://matrix-staging.stack.cards';
const defaultRealmUrl =
env === 'production'
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 Avoid defaulting unknown Matrix domains to staging URLs

ProfileManager.addProfile() treats every non-boxel.ai Matrix ID as staging by default, so boxel profile add -u @alice:custom.domain silently stores https://matrix-staging.stack.cards and https://realms-staging.stack.cards/ instead of endpoints that match the supplied ID. In practice this creates profiles that look valid but always authenticate against the wrong server when the domain is mistyped or custom, which is hard to diagnose; unknown domains should be rejected or explicitly mapped instead of defaulting to staging.

Useful? React with 👍 / 👎.

Comment on lines +309 to +310
if (this.config.profiles[matrixId]) {
return matrixId;
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 Return a distinct result when migrate finds existing profile

migrateFromEnv() returns the profile ID even when the profile already exists, which makes the caller print Created profile and suggest removing .env credentials although no migration happened. Re-running boxel profile migrate against an existing profile therefore reports a false success; this should return a separate status (or null for no-op) so the command can message existing-profile cases accurately.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@habdelra habdelra left a comment

Choose a reason for hiding this comment

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

Review: CS-10615 — Reimplement boxel profile command

Overall: Solid foundation for profile management. This is the right first step toward CS-10642's full auth lifecycle.

Key concerns (8 inline comments):

  1. Realm server URL inference from matrix URLgetActiveCredentials() guesses realmServerUrl from matrixUrl hostname patterns when env vars are used. This is fragile and inconsistent with migrateFromEnv() which correctly requires REALM_SERVER_URL explicitly. Recommend removing the inference.

  2. Duplicate helper functionsgetEnvironmentLabel and getEnvironmentShortLabel are identical implementations.

  3. Singleton configDir confusiongetProfileManager(configDir?) caches the first instance and silently ignores configDir on subsequent calls.

  4. migrateFromEnv no-op on existing profiles — Silently returns without updating credentials if profile already exists.

  5. Terminal cleanup in promptPassword — Raw mode may not be restored on unexpected errors.

  6. Duplicated ANSI constants — Same color codes defined in both profile.ts and profile-manager.ts.

  7. I would like to see a happy path test against a real realm server. consider leveraging testing infra from the packages/realm-server/tests so that we can spin up a real realm server that we can test against and assert that auth works against an actual realm server.

Phase 2 alignment (CS-10642): The Profile interface and async method signatures are well-positioned for extension with server tokens, per-realm JWTs, and auto-refresh. The getActiveCredentials() method is the natural evolution point for the programmatic BoxelAuth API. Inline comment on the interface details what fields will be needed.

Comment on lines +68 to +87
default:
return 'unknown';
}
}

/**
* Get short environment label (uses domain)
*/
export function getEnvironmentShortLabel(env: Environment): string {
switch (env) {
case 'staging':
return 'stack.cards';
case 'production':
return 'boxel.ai';
default:
return 'unknown';
}
}

/**
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

getEnvironmentLabel and getEnvironmentShortLabel are identical — they return the exact same values for all cases. Should one be removed, or were they intended to differ (e.g., short label returning abbreviated forms like "staging" / "prod")?

matrixUrl: string;
realmServerUrl: string;
password: string; // Stored in plaintext - file should have restricted permissions
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Plaintext password storage: the comment acknowledges this, but worth flagging for the CS-10642 follow-up. When ProfileManager becomes the canonical auth store for the full token lifecycle (realm server tokens, per-realm JWTs, auto-acquired tokens on realm creation), plaintext credentials in a JSON file become a bigger liability. Consider planning for OS keychain integration (e.g., keytar or @anthropic-ai/keychain) as a future step — the async signatures on getPassword/updatePassword are well-positioned for this.

Comment on lines +228 to +248
return {
matrixUrl: active.profile.matrixUrl,
username: getUsernameFromMatrixId(active.id),
password: active.profile.password,
realmServerUrl: active.profile.realmServerUrl,
profileId: active.id,
};
}

const matrixUrl = process.env.MATRIX_URL;
const username = process.env.MATRIX_USERNAME;
const password = process.env.MATRIX_PASSWORD;
let realmServerUrl = process.env.REALM_SERVER_URL;

if (matrixUrl && username && password) {
if (!realmServerUrl) {
try {
const matrixUrlObj = new URL(matrixUrl);
if (matrixUrlObj.hostname.startsWith('matrix.')) {
realmServerUrl = `${matrixUrlObj.protocol}//app.${matrixUrlObj.hostname.slice(7)}/`;
} else if (matrixUrlObj.hostname.startsWith('matrix-staging.')) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This block infers realmServerUrl from the matrixUrl hostname pattern (e.g., matrix.app., matrix-staging.realms-staging.). This is fragile — it breaks for non-standard deployments and encodes URL conventions that may change. The project has an established convention of never inferring realm server URL from other URLs.

Consider requiring REALM_SERVER_URL to be set explicitly when falling back to env vars (i.e., return null if it's missing rather than guessing). The migrateFromEnv() method already requires it explicitly — this fallback path should be consistent.

Comment on lines +353 to +362
let _instance: ProfileManager | null = null;

export function getProfileManager(configDir?: string): ProfileManager {
if (!_instance) {
_instance = new ProfileManager(configDir);
}
return _instance;
}

/**
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The singleton caches the first instance, but configDir is only used on the first call — subsequent calls silently ignore it. This could cause confusing behavior in tests or when the programmatic API (CS-10642's BoxelAuth) needs multiple managers for different contexts.

Consider either:

  • Removing the configDir parameter from getProfileManager() (force callers who need a custom dir to use new ProfileManager(dir) directly)
  • Or validating that the configDir matches if an instance already exists

Comment on lines +325 to +328
if (active) {
console.log(
`\n${BOLD}Active Profile:${RESET} ${formatProfileBadge(active.id)}`,
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If a profile already exists for the migrated Matrix ID, this silently returns the existing ID without updating the password or other fields. A user running boxel profile migrate after changing their password in .env would expect the stored credentials to update. Consider either updating the existing profile or logging a message that tells the user to use boxel profile add --user ... --password ... to update.

Comment on lines +13 to +19
const FG_GREEN = '\x1b[32m';
const FG_YELLOW = '\x1b[33m';
const FG_CYAN = '\x1b[36m';
const FG_MAGENTA = '\x1b[35m';
const FG_RED = '\x1b[31m';
const DIM = '\x1b[2m';
const BOLD = '\x1b[1m';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ANSI color constants are duplicated here and in profile-manager.ts. Consider extracting them to a shared utils/colors.ts or similar — other future commands will likely need them too.

Comment on lines +37 to +77
const mutableOutput = new Writable({
write: (_chunk, _encoding, callback) => callback(),
});
const rl = readline.createInterface({
input: process.stdin,
output: mutableOutput,
terminal: true,
});

return new Promise((resolve) => {
const stdin = process.stdin;
if (stdin.isTTY) {
stdin.setRawMode(true);
}

process.stdout.write(question);
let password = '';

const onData = (char: Buffer) => {
const c = char.toString();
if (c === '\n' || c === '\r') {
stdin.removeListener('data', onData);
if (stdin.isTTY) {
stdin.setRawMode(false);
}
process.stdout.write('\n');
rl.close();
resolve(password);
} else if (c === '\u0003') {
// Ctrl+C
process.exit();
} else if (c === '\u007F' || c === '\b') {
// Backspace
if (password.length > 0) {
password = password.slice(0, -1);
process.stdout.write('\b \b');
}
} else {
password += c;
process.stdout.write('*');
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If an error is thrown between stdin.setRawMode(true) (line 50) and the cleanup in the onData handler, the terminal will be left in raw mode. Consider wrapping in a try/finally, or adding a process exit handler that restores terminal state:

const cleanup = () => {
  stdin.removeListener('data', onData);
  if (stdin.isTTY) stdin.setRawMode(false);
};

Also, stdin.resume() at line 76 may not be paired with a stdin.pause() — if stdin was already flowing (e.g., piped input), this is fine, but in a TTY context the rl.close() in the resolve path doesn't explicitly pause it.

Comment on lines +17 to +24
displayName: string;
matrixUrl: string;
realmServerUrl: string;
password: string; // Stored in plaintext - file should have restricted permissions
}

export interface ProfilesConfig {
profiles: Record<string, Profile>;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Phase 2 / CS-10642 alignment note: The Profile interface will need extension to support the two-tier token model. Fields like serverToken, serverTokenExpiry, realmTokens: Record<string, { token: string, expiry: number }> will eventually live here. The current shape is a good foundation — just flagging that getActiveCredentials() will evolve significantly when it needs to return (and auto-refresh) realm server tokens and per-realm JWTs.

The async signatures on credential methods are well-placed for this future — token refresh will require async operations.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Ports the boxel profile command into the monorepo’s @cardstack/boxel-cli, adding a local profile storage layer and CLI UX to manage Matrix/realm credentials across environments.

Changes:

  • Added ProfileManager + helper utilities for reading/writing ~/.boxel-cli/profiles.json with restrictive permissions.
  • Implemented boxel profile subcommands (list/add/switch/remove/migrate) including interactive prompting.
  • Added unit tests for ProfileManager behavior and environment parsing helpers.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

File Description
packages/boxel-cli/src/lib/profile-manager.ts Implements profile config persistence, credential selection, and migration from env vars.
packages/boxel-cli/src/commands/profile.ts Adds CLI subcommand routing + interactive/non-interactive profile workflows.
packages/boxel-cli/src/index.ts Wires the new profile command into the CLI entrypoint and loads dotenv.
packages/boxel-cli/tests/commands/profile.test.ts Adds unit tests covering ProfileManager operations and helper functions.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +115 to +123
if (fs.existsSync(this.profilesFile)) {
try {
const data = fs.readFileSync(this.profilesFile, 'utf-8');
return JSON.parse(data);
} catch {
// Corrupted file, start fresh
}
}
return { profiles: {}, activeProfile: null };
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

loadConfig() returns the raw result of JSON.parse() without validating the expected shape. A profiles.json that is valid JSON but missing profiles/activeProfile (or with wrong types) will later cause runtime errors (e.g., Object.keys(this.config.profiles) when profiles is undefined/null). Consider normalizing/validating the parsed value and falling back to { profiles: {}, activeProfile: null } when the shape is not correct.

Suggested change
if (fs.existsSync(this.profilesFile)) {
try {
const data = fs.readFileSync(this.profilesFile, 'utf-8');
return JSON.parse(data);
} catch {
// Corrupted file, start fresh
}
}
return { profiles: {}, activeProfile: null };
const defaultConfig: ProfilesConfig = { profiles: {}, activeProfile: null };
if (fs.existsSync(this.profilesFile)) {
try {
const data = fs.readFileSync(this.profilesFile, 'utf-8');
const parsed: unknown = JSON.parse(data);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const candidate = parsed as {
profiles?: unknown;
activeProfile?: unknown;
};
const profiles =
candidate.profiles &&
typeof candidate.profiles === 'object' &&
!Array.isArray(candidate.profiles)
? candidate.profiles
: null;
const activeProfile =
candidate.activeProfile === null ||
typeof candidate.activeProfile === 'string'
? candidate.activeProfile
: null;
if (profiles) {
return {
profiles: profiles as ProfilesConfig['profiles'],
activeProfile,
};
}
}
} catch {
// Corrupted file, start fresh
}
}
return defaultConfig;

Copilot uses AI. Check for mistakes.
Comment on lines +391 to +402
const matrixUrl = process.env.MATRIX_URL;
const username = process.env.MATRIX_USERNAME;
const password = process.env.MATRIX_PASSWORD;

if (!matrixUrl || !username || !password) {
console.log(
`${FG_YELLOW}No complete credentials found in environment variables.${RESET}`,
);
console.log(
`\nRequired variables: MATRIX_URL, MATRIX_USERNAME, MATRIX_PASSWORD, REALM_SERVER_URL`,
);
return;
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

migrateFromEnv() help text says REALM_SERVER_URL is required, but this precheck only validates MATRIX_URL/MATRIX_USERNAME/MATRIX_PASSWORD. When REALM_SERVER_URL is missing, the command falls through and later prints a generic "Migration failed" message. Consider including process.env.REALM_SERVER_URL in the early check so the user gets the correct actionable message.

Copilot uses AI. Check for mistakes.
Comment on lines +118 to +126
it('sets file permissions to 0600', async () => {
await manager.addProfile('@testuser:stack.cards', 'password123');

const profilesFile = path.join(tmpDir, 'profiles.json');
const stats = fs.statSync(profilesFile);
// Check owner-only permissions (0600 = 0o600 = 384 decimal)
const mode = stats.mode & 0o777;
expect(mode).toBe(0o600);
});
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

This permission-mode assertion is likely to fail on Windows, where chmodSync is ignored/unsupported (and the implementation explicitly swallows chmod errors on Windows). Consider skipping this test on process.platform === 'win32' or asserting more loosely there.

Copilot uses AI. Check for mistakes.
Comment on lines +60 to +70
* Get environment emoji/label for display
*/
export function getEnvironmentLabel(env: Environment): string {
switch (env) {
case 'staging':
return 'stack.cards';
case 'production':
return 'boxel.ai';
default:
return 'unknown';
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

The getEnvironmentLabel doc comment says "emoji/label" but the function returns only a string label and appears unused (and duplicates getEnvironmentShortLabel). Consider either removing it or updating the comment/behavior to match intended usage to avoid confusion.

Suggested change
* Get environment emoji/label for display
*/
export function getEnvironmentLabel(env: Environment): string {
switch (env) {
case 'staging':
return 'stack.cards';
case 'production':
return 'boxel.ai';
default:
return 'unknown';
}
* Get environment label for display (uses domain)
*/
export function getEnvironmentLabel(env: Environment): string {
return getEnvironmentShortLabel(env);

Copilot uses AI. Check for mistakes.
const matrixId = `@${username}:${domain}`;

if (this.config.profiles[matrixId]) {
return matrixId;
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

migrateFromEnv() returns matrixId even when the profile already exists (and no migration occurred). The command currently treats any non-null return value as "Created profile", which becomes misleading. Consider returning a richer result (e.g., { profileId, created }) or returning null when the profile already exists so callers can report the right outcome.

Suggested change
return matrixId;
return null;

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants