Skip to content
5 changes: 5 additions & 0 deletions packages/boxel-cli/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@ export {
type CreateRealmOptions,
type CreateRealmResult,
} from './src/lib/boxel-cli-client';

export {
resetProfileManager,
setProfileManager,
} from './src/lib/profile-manager';
9 changes: 9 additions & 0 deletions packages/boxel-cli/src/lib/profile-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,3 +572,12 @@ export function getProfileManager(): ProfileManager {
export function resetProfileManager(): void {
_instance = null;
}

/**
* Replace the singleton with a ProfileManager using a custom config directory.
* Useful for tests that need an isolated profile without touching the real
* ~/.boxel-cli/profiles.json.
*/
export function setProfileManager(configDir: string): void {
_instance = new ProfileManager(configDir);
}
8 changes: 3 additions & 5 deletions packages/software-factory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,17 @@ The orchestrator (`runIssueLoop`) is a thin scheduler that picks the next unbloc

- Docker running
- `mise run dev-all` (starts realm server, host app, icons server, Postgres, Synapse)
- Matrix credentials (username/password) for realm creation and auth
- Active Boxel CLI profile (`boxel profile add`)
- An [OpenRouter API key](https://openrouter.ai/keys) for the LLM agent (when running the full factory)

## Running the Factory

Make sure the prerequisites above are met, and that you have a brief card published in the software-factory realm (e.g., `http://localhost:4201/software-factory/Wiki/sticky-note`).

Set up credentials first (these persist in your shell session):
Set up your profile and API key first:

```bash
export MATRIX_URL=http://localhost:8008/
export MATRIX_USERNAME=your-username
read -s 'MATRIX_PASSWORD?Matrix password: ' && export MATRIX_PASSWORD
boxel profile add # Interactive wizard — choose your environment, enter credentials
export OPENROUTER_API_KEY=sk-or-v1-your-key-here
```

Expand Down
4 changes: 2 additions & 2 deletions packages/software-factory/docs/phase-1-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,8 @@ Required behavior:

Required behavior:

- require `MATRIX_USERNAME` so the target realm owner is explicit before bootstrap starts
- infer the target realm server URL from the target realm URL by default, but allow an explicit override when the realm server lives under a subdirectory and the URL shape is ambiguous
- require an active Boxel profile so the target realm owner is explicit before bootstrap starts
- use `--realm-server-url` when explicitly provided; otherwise take the realm server URL from the active Boxel profile rather than inferring it from the target realm URL
- create missing target realms through the realm server `/_create-realm` API rather than by creating local directories directly
- treat the successful `/_create-realm` response as the readiness boundary

Expand Down
2 changes: 1 addition & 1 deletion packages/software-factory/docs/testing-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ Use:
- temporary-directory integration tests
- bootstrap tests that cover missing-realm creation through `/_create-realm`
- readiness checks that treat a successful `/_create-realm` response as the readiness boundary
- tests that require `MATRIX_USERNAME` instead of an explicit brief JWT flag
- tests that require an active Boxel profile instead of an explicit brief JWT flag

### Project Artifact Bootstrap

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*
* Prerequisites:
* - Docker running, `mise run dev-all` (realm server, host app, Synapse, etc.)
* - MATRIX_URL, MATRIX_USERNAME, MATRIX_PASSWORD environment variables
* - Active Boxel profile (`boxel profile add`)
* - OPENROUTER_API_KEY for the LLM agent
*
* Usage:
Expand Down Expand Up @@ -672,25 +672,24 @@ async function scenario3(

async function main(): Promise<void> {
let { scenario, debug, briefUrl } = parseArgs();
let username = process.env.MATRIX_USERNAME;

if (!username) {
let client = new BoxelCLIClient();
let active = client.getActiveProfile();
if (!active) {
log.error(
'MATRIX_USERNAME is required. Set MATRIX_URL, MATRIX_USERNAME, and MATRIX_PASSWORD.',
'No active Boxel profile found. Run `boxel profile add` to configure one.',
);
process.exit(1);
}

let username = active.matrixId.replace(/^@/, '').replace(/:.*$/, '');

if (!process.env.OPENROUTER_API_KEY) {
log.error('OPENROUTER_API_KEY is required for the LLM agent.');
process.exit(1);
}

if (!process.env.MATRIX_URL) {
process.env.MATRIX_URL = 'http://localhost:8008';
}

let realmServerUrl = process.env.REALM_SERVER_URL ?? 'http://localhost:4201/';
let realmServerUrl = active.realmServerUrl;
if (!realmServerUrl.endsWith('/')) {
realmServerUrl += '/';
}
Expand Down
34 changes: 12 additions & 22 deletions packages/software-factory/scripts/smoke-tests/smoke-test-realm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,10 @@
*
* Prerequisites:
*
* Realm server authentication -- one of:
* a. Active Boxel CLI profile (`boxel profile add` then `boxel profile switch`)
* b. Environment variables: MATRIX_URL, MATRIX_USERNAME, MATRIX_PASSWORD
* Active Boxel CLI profile (`boxel profile add`)
*
* Usage:
* MATRIX_URL=http://localhost:8008 MATRIX_USERNAME=<user> MATRIX_PASSWORD=<pass> \
* pnpm smoke:test-realm -- \
* --target-realm-url <realm-url>
* pnpm smoke:test-realm -- --target-realm-url <realm-url>
*/

// This should be first
Expand Down Expand Up @@ -142,15 +138,17 @@ async function main() {
let args = parseArgs(process.argv.slice(2));
let targetRealmUrl = (args['target-realm-url'] as string) ?? '';

let client = new BoxelCLIClient();
let active = client.getActiveProfile();
if (!active) {
log.error(
'No active Boxel profile found. Run `boxel profile add` to configure one.',
);
process.exit(1);
}

if (!targetRealmUrl) {
let username = process.env.MATRIX_USERNAME;
if (!username) {
log.error('Usage: pnpm smoke:test-realm -- --target-realm-url <url>');
log.error(
'\nRequires MATRIX_USERNAME and MATRIX_PASSWORD environment variables.',
);
process.exit(1);
}
let username = active.matrixId.replace(/^@/, '').replace(/:.*$/, '');
targetRealmUrl = `http://localhost:4201/${username}/smoke-test-realm/`;
log.info(
`No --target-realm-url specified, using default: ${targetRealmUrl}\n`,
Expand All @@ -174,14 +172,6 @@ async function main() {
// The username is determined from the JWT. Extract just the last segment.
let realmEndpoint = realmPath.split('/').pop() ?? realmPath;

// Set defaults for the auth chain
if (!process.env.MATRIX_URL) {
process.env.MATRIX_URL = 'http://localhost:8008';
}
if (!process.env.REALM_SERVER_URL) {
process.env.REALM_SERVER_URL = realmServerUrl;
}

log.info('=== Factory Test Realm Smoke Test (QUnit) ===\n');
log.info(`Target realm: ${targetRealmUrl}`);
log.info(`Realm server: ${realmServerUrl}`);
Expand Down
29 changes: 9 additions & 20 deletions packages/software-factory/src/boxel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { join } from 'node:path';
import { formatErrorResponse } from './error-format';
import { ensureTrailingSlash, SupportedMimeType } from './realm-operations';

const PROFILES_FILE = join(homedir(), '.boxel-cli', 'profiles.json');
function getProfilesFile(): string {
return join(homedir(), '.boxel-cli', 'profiles.json');
}

type BoxelStoredProfile = {
matrixUrl: string;
Expand Down Expand Up @@ -85,11 +87,12 @@ export type ParsedArgs = Record<string, ParsedArgValue | undefined> & {
};

function parseProfilesConfig(): BoxelProfilesConfig {
if (!existsSync(PROFILES_FILE)) {
let profilesFile = getProfilesFile();
if (!existsSync(profilesFile)) {
return { profiles: {}, activeProfile: null };
}

return JSON.parse(readFileSync(PROFILES_FILE, 'utf8')) as BoxelProfilesConfig;
return JSON.parse(readFileSync(profilesFile, 'utf8')) as BoxelProfilesConfig;
}

export function getActiveProfile(): ActiveBoxelProfile {
Expand All @@ -106,23 +109,9 @@ export function getActiveProfile(): ActiveBoxelProfile {
};
}

let matrixUrl = process.env.MATRIX_URL;
let username = process.env.MATRIX_USERNAME;
let password = process.env.MATRIX_PASSWORD;
let realmServerUrl = process.env.REALM_SERVER_URL;
if (!matrixUrl || !username || !password || !realmServerUrl) {
throw new Error(
'No active Boxel profile found and MATRIX_URL/MATRIX_USERNAME/MATRIX_PASSWORD/REALM_SERVER_URL are not fully set',
);
}

return {
profileId: null,
username,
matrixUrl,
realmServerUrl: ensureTrailingSlash(realmServerUrl),
password,
};
throw new Error(
'No active Boxel profile found. Run `boxel profile add` to configure one.',
);
}

export async function matrixLogin(
Expand Down
15 changes: 7 additions & 8 deletions packages/software-factory/src/factory-entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,20 +96,19 @@ export function getFactoryEntrypointUsage(): string {
' --target-realm-url <url> Absolute URL for the target realm',
'',
'Options:',
' --realm-server-url <url> Realm server URL (default: http://localhost:4201/)',
' --realm-server-url <url> Realm server URL (default: from active Boxel profile)',
' --no-retry-blocked Skip retrying blocked issues (by default, blocked issues are reset to backlog)',
' --model <model> OpenRouter model ID (e.g., anthropic/claude-sonnet-4)',
' --debug Log LLM prompts and responses to stderr',
' --help Show this usage information',
'',
'Auth:',
' MATRIX_USERNAME is required and determines the target realm owner.',
' For public briefs, no auth setup is needed.',
' For private briefs, factory:go can authenticate via:',
' 1. the active Boxel profile, or',
' 2. MATRIX_URL + MATRIX_USERNAME + MATRIX_PASSWORD environment variables',
' The realm server URL comes from --realm-server-url (default: http://localhost:4201/).',
' It is never inferred from --target-realm-url or read from an environment variable.',
' Authentication uses the active Boxel profile (see: boxel profile add).',
' The target realm owner is determined from the active profile username.',
' For public briefs, no further auth setup is needed.',
' For private briefs, factory:go authenticates via the active Boxel profile.',
' The realm server URL comes from --realm-server-url, or the active Boxel profile.',
' It is never inferred from --target-realm-url.',
].join('\n');
}

Expand Down
33 changes: 5 additions & 28 deletions packages/software-factory/src/factory-issue-loop-wiring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,10 +320,9 @@ async function resolveAuth(config: IssueLoopWiringConfig): Promise<{
}
} catch (error) {
throw new Error(
`Matrix login failed. Ensure MATRIX_URL, MATRIX_USERNAME, and MATRIX_PASSWORD are set, ` +
`and pass --realm-server-url on the CLI.\n${
error instanceof Error ? error.message : String(error)
}`,
`Matrix login failed. Ensure an active Boxel profile is configured (run \`boxel profile add\`).\n${
error instanceof Error ? error.message : String(error)
}`,
);
}

Expand Down Expand Up @@ -355,30 +354,8 @@ async function resolveAuth(config: IssueLoopWiringConfig): Promise<{
function buildProfileWithCliRealmServer(
realmServerUrl: string,
): ActiveBoxelProfile {
try {
let profile = getActiveProfile();
return { ...profile, realmServerUrl };
} catch {
// No active profile — fall back to env vars
}

let matrixUrl = process.env.MATRIX_URL?.trim();
let username = process.env.MATRIX_USERNAME?.trim();
let password = process.env.MATRIX_PASSWORD?.trim();

if (!matrixUrl || !username || !password) {
throw new Error(
'No active Boxel profile found and MATRIX_URL/MATRIX_USERNAME/MATRIX_PASSWORD are not fully set.',
);
}

return {
profileId: null,
username,
matrixUrl,
realmServerUrl,
password,
};
let profile = getActiveProfile();
return { ...profile, realmServerUrl };
}

// ---------------------------------------------------------------------------
Expand Down
56 changes: 33 additions & 23 deletions packages/software-factory/src/factory-target-realm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,21 @@ export function resolveFactoryTargetRealm(
let serverUrl = resolveRealmServerUrl(options.realmServerUrl, url);
let ownerUsername = resolveTargetRealmOwner();

let targetOrigin = new URL(url).origin;
let serverOrigin = new URL(serverUrl).origin;
if (targetOrigin !== serverOrigin) {
let client = new BoxelCLIClient();
let active = client.getActiveProfile();
let profileLabel = active
? `Your active Boxel profile "${active.matrixId}" points to ${ensureTrailingSlash(active.realmServerUrl)}.`
: 'No active Boxel profile is configured.';
throw new FactoryEntrypointUsageError(
`Target realm URL "${url}" (origin: ${targetOrigin}) does not match the realm server "${serverUrl}" (origin: ${serverOrigin}).\n` +
`${profileLabel}\n` +
`Either switch to a profile that matches the target realm (boxel profile switch), or pass --realm-server-url explicitly.`,
);
}

return {
url,
serverUrl,
Expand Down Expand Up @@ -73,13 +88,13 @@ async function createRealm(
let activeServerUrl = ensureTrailingSlash(active.realmServerUrl);
if (activeServerUrl !== resolution.serverUrl) {
throw new FactoryEntrypointUsageError(
`Active Boxel profile realm server "${activeServerUrl}" does not match --realm-server-url "${resolution.serverUrl}"`,
`Active Boxel profile realm server "${activeServerUrl}" does not match target realm server "${resolution.serverUrl}"`,
);
}
let activeUsername = getMatrixUsername(active.matrixId);
if (activeUsername !== resolution.ownerUsername) {
throw new FactoryEntrypointUsageError(
`Active Boxel profile user "${activeUsername}" does not match MATRIX_USERNAME "${resolution.ownerUsername}"`,
`Active Boxel profile user "${activeUsername}" does not match target realm owner "${resolution.ownerUsername}"`,
);
}
}
Expand All @@ -94,15 +109,15 @@ async function createRealm(
}

function resolveTargetRealmOwner(): string {
let envUsername = normalizeOptionalString(process.env.MATRIX_USERNAME);

if (!envUsername) {
throw new FactoryEntrypointUsageError(
'Cannot determine the target realm owner. Set MATRIX_USERNAME before running factory:go.',
);
let client = new BoxelCLIClient();
let active = client.getActiveProfile();
if (active) {
return getMatrixUsername(active.matrixId);
}

return getMatrixUsername(envUsername);
throw new FactoryEntrypointUsageError(
'Cannot determine the target realm owner. Run `boxel profile add` to configure a profile.',
);
}

function resolveTargetRealmUrl(explicitTargetRealmUrl: string | null): string {
Expand All @@ -115,8 +130,6 @@ function resolveTargetRealmUrl(explicitTargetRealmUrl: string | null): string {
return normalizeUrl(explicitTargetRealmUrl, '--target-realm-url');
}

const DEFAULT_REALM_SERVER_URL = 'http://localhost:4201/';

function resolveRealmServerUrl(
explicitRealmServerUrl: string | null,
_targetRealmUrl: string,
Expand All @@ -125,7 +138,15 @@ function resolveRealmServerUrl(
return normalizeUrl(explicitRealmServerUrl, '--realm-server-url');
}

return DEFAULT_REALM_SERVER_URL;
let client = new BoxelCLIClient();
let active = client.getActiveProfile();
if (active) {
return ensureTrailingSlash(active.realmServerUrl);
}

throw new FactoryEntrypointUsageError(
'No active Boxel profile found. Run `boxel profile add` to configure one, or pass --realm-server-url explicitly.',
);
}

function extractEndpointFromRealmUrl(targetRealmUrl: string): string {
Expand All @@ -152,14 +173,3 @@ function normalizeUrl(url: string, label: string): string {
);
}
}

function normalizeOptionalString(
value: string | undefined,
): string | undefined {
if (typeof value !== 'string') {
return undefined;
}

let trimmed = value.trim();
return trimmed === '' ? undefined : trimmed;
}
Loading
Loading