diff --git a/packages/boxel-cli/api.ts b/packages/boxel-cli/api.ts index 6eb2aabafd2..61a7ff77ef1 100644 --- a/packages/boxel-cli/api.ts +++ b/packages/boxel-cli/api.ts @@ -3,3 +3,8 @@ export { type CreateRealmOptions, type CreateRealmResult, } from './src/lib/boxel-cli-client'; + +export { + resetProfileManager, + setProfileManager, +} from './src/lib/profile-manager'; diff --git a/packages/boxel-cli/src/lib/profile-manager.ts b/packages/boxel-cli/src/lib/profile-manager.ts index 16db86c52b9..2bc462d73ea 100644 --- a/packages/boxel-cli/src/lib/profile-manager.ts +++ b/packages/boxel-cli/src/lib/profile-manager.ts @@ -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); +} diff --git a/packages/software-factory/README.md b/packages/software-factory/README.md index c315ea9bcc8..158e6af3355 100644 --- a/packages/software-factory/README.md +++ b/packages/software-factory/README.md @@ -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 ``` diff --git a/packages/software-factory/docs/testing-strategy.md b/packages/software-factory/docs/testing-strategy.md index f82fd633db7..3cae336d8a4 100644 --- a/packages/software-factory/docs/testing-strategy.md +++ b/packages/software-factory/docs/testing-strategy.md @@ -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 diff --git a/packages/software-factory/scripts/smoke-tests/smoke-test-factory-scenarios.ts b/packages/software-factory/scripts/smoke-tests/smoke-test-factory-scenarios.ts index ceed80d6cd3..9b5e203dde3 100644 --- a/packages/software-factory/scripts/smoke-tests/smoke-test-factory-scenarios.ts +++ b/packages/software-factory/scripts/smoke-tests/smoke-test-factory-scenarios.ts @@ -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: @@ -672,25 +672,24 @@ async function scenario3( async function main(): Promise { 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 += '/'; } diff --git a/packages/software-factory/scripts/smoke-tests/smoke-test-realm.ts b/packages/software-factory/scripts/smoke-tests/smoke-test-realm.ts index c0cee3fe1b9..26f47121d18 100644 --- a/packages/software-factory/scripts/smoke-tests/smoke-test-realm.ts +++ b/packages/software-factory/scripts/smoke-tests/smoke-test-realm.ts @@ -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= MATRIX_PASSWORD= \ - * pnpm smoke:test-realm -- \ - * --target-realm-url + * pnpm smoke:test-realm -- --target-realm-url */ // This should be first @@ -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 '); - 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`, @@ -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}`); diff --git a/packages/software-factory/src/boxel.ts b/packages/software-factory/src/boxel.ts index 4a713813aad..fed242bbc1d 100644 --- a/packages/software-factory/src/boxel.ts +++ b/packages/software-factory/src/boxel.ts @@ -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; @@ -85,11 +87,12 @@ export type ParsedArgs = Record & { }; 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 { @@ -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( diff --git a/packages/software-factory/src/factory-entrypoint.ts b/packages/software-factory/src/factory-entrypoint.ts index 2833c95249e..27a7c215caa 100644 --- a/packages/software-factory/src/factory-entrypoint.ts +++ b/packages/software-factory/src/factory-entrypoint.ts @@ -96,20 +96,19 @@ export function getFactoryEntrypointUsage(): string { ' --target-realm-url Absolute URL for the target realm', '', 'Options:', - ' --realm-server-url Realm server URL (default: http://localhost:4201/)', + ' --realm-server-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 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'); } diff --git a/packages/software-factory/src/factory-issue-loop-wiring.ts b/packages/software-factory/src/factory-issue-loop-wiring.ts index f09d53f05ce..143b4a65b7b 100644 --- a/packages/software-factory/src/factory-issue-loop-wiring.ts +++ b/packages/software-factory/src/factory-issue-loop-wiring.ts @@ -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) + }`, ); } @@ -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 }; } // --------------------------------------------------------------------------- diff --git a/packages/software-factory/src/factory-target-realm.ts b/packages/software-factory/src/factory-target-realm.ts index 9957024e047..3e587f96703 100644 --- a/packages/software-factory/src/factory-target-realm.ts +++ b/packages/software-factory/src/factory-target-realm.ts @@ -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, @@ -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}"`, ); } } @@ -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 { @@ -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, @@ -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 { @@ -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; -} diff --git a/packages/software-factory/src/realm-auth.ts b/packages/software-factory/src/realm-auth.ts index 38f5d09e4c0..816a38b427b 100644 --- a/packages/software-factory/src/realm-auth.ts +++ b/packages/software-factory/src/realm-auth.ts @@ -58,7 +58,7 @@ export function createBoxelRealmFetch( let profile = options && 'profile' in options ? (options.profile ?? undefined) - : getOptionalActiveProfile(resourceUrl); + : getOptionalActiveProfile(); if (!profile || !sharesOrigin(resourceUrl, profile.realmServerUrl)) { return fetchImpl; @@ -96,15 +96,7 @@ export function createBoxelRealmFetch( }; } -function getOptionalActiveProfile( - resourceUrl: string, -): ActiveBoxelProfile | undefined { - let envProfile = buildEnvProfile(resourceUrl); - - if (envProfile) { - return envProfile; - } - +function getOptionalActiveProfile(): ActiveBoxelProfile | undefined { let config = parseProfilesConfig(); if (config.activeProfile && config.profiles[config.activeProfile]) { @@ -140,7 +132,7 @@ function parseProfilesConfig(): BoxelProfilesConfig { throw new Error( `Failed to parse Boxel profiles config at ${profilesFile}: ${ error instanceof Error ? error.message : String(error) - }. Fix or remove the file, or provide auth via environment variables.`, + }. Fix or remove the file.`, ); } } @@ -187,34 +179,6 @@ function normalizeProfileUrl(value: string, label: string): string { } } -function buildEnvProfile(resourceUrl: string): ActiveBoxelProfile | undefined { - let matrixUrl = normalizeOptionalString(process.env.MATRIX_URL); - let username = normalizeOptionalString(process.env.MATRIX_USERNAME); - let password = normalizeOptionalString(process.env.MATRIX_PASSWORD); - let realmServerUrl = normalizeOptionalString(process.env.REALM_SERVER_URL); - - if (!matrixUrl) { - return undefined; - } - - let normalizedMatrixUrl = normalizeProfileUrl(matrixUrl, 'MATRIX_URL'); - let normalizedRealmServerUrl = realmServerUrl - ? normalizeProfileUrl(realmServerUrl, 'REALM_SERVER_URL') - : normalizeProfileUrl(new URL('/', resourceUrl).href, 'resourceUrl origin'); - - if (!username || !password) { - return undefined; - } - - return { - profileId: null, - username: getMatrixUsername(username), - matrixUrl: normalizedMatrixUrl, - realmServerUrl: normalizedRealmServerUrl, - password, - }; -} - function normalizeOptionalString( value: string | undefined, ): string | undefined { diff --git a/packages/software-factory/tests/factory-entrypoint.integration.test.ts b/packages/software-factory/tests/factory-entrypoint.integration.test.ts index 3d3a87dced0..8675653ac91 100644 --- a/packages/software-factory/tests/factory-entrypoint.integration.test.ts +++ b/packages/software-factory/tests/factory-entrypoint.integration.test.ts @@ -1,4 +1,10 @@ -import { mkdtempSync, readFileSync } from 'node:fs'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { spawn, spawnSync } from 'node:child_process'; import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; @@ -44,6 +50,36 @@ interface RunCommandResult { stderr: string; } +function createTempProfileHome(options: { + username: string; + matrixUrl: string; + realmServerUrl: string; + password: string; +}): string { + let tempHome = mkdtempSync(join(tmpdir(), 'boxel-test-')); + let boxelCliDir = join(tempHome, '.boxel-cli'); + mkdirSync(boxelCliDir, { recursive: true }); + + let profileId = `@${options.username}:localhost`; + let config = { + profiles: { + [profileId]: { + matrixUrl: options.matrixUrl, + realmServerUrl: options.realmServerUrl, + password: options.password, + }, + }, + activeProfile: profileId, + }; + + writeFileSync( + join(boxelCliDir, 'profiles.json'), + JSON.stringify(config, null, 2), + ); + + return tempHome; +} + module('factory-entrypoint integration', function () { test('factory:go package script prints a structured JSON summary', async function (assert) { let canonicalTargetRealmUrl: string; @@ -223,6 +259,13 @@ module('factory-entrypoint integration', function () { targetRealmUrl = `${origin}/typed-by-user/personal/`; canonicalTargetRealmUrl = `${origin}/hassan/personal/`; + let tempHome = createTempProfileHome({ + username: 'hassan', + matrixUrl: `${origin}/`, + realmServerUrl: `${origin}/`, + password: 'secret', + }); + try { let result = await runCommand( 'pnpm', @@ -242,11 +285,7 @@ module('factory-entrypoint integration', function () { encoding: 'utf8', env: { ...process.env, - HOME: mkdtempSync(join(tmpdir(), 'boxel-test-')), - MATRIX_USERNAME: 'hassan', - MATRIX_PASSWORD: 'secret', - MATRIX_URL: origin, - REALM_SERVER_URL: `${origin}/`, + HOME: tempHome, }, }, ); @@ -281,6 +320,7 @@ module('factory-entrypoint integration', function () { nextStep: 'all-issues-completed', }); } finally { + rmSync(tempHome, { recursive: true, force: true }); await new Promise((resolvePromise, reject) => server.close((error) => (error ? reject(error) : resolvePromise())), ); @@ -321,7 +361,7 @@ module('factory-entrypoint integration', function () { assert.true(/--no-retry-blocked/.test(result.stdout)); }); - test('factory:go fails clearly when MATRIX_USERNAME is missing', async function (assert) { + test('factory:go fails clearly when no profile is configured', async function (assert) { let server = createServer((_request, response) => { response.writeHead(200, { 'content-type': SupportedMimeType.JSON }); response.end(stickyNoteFixture); @@ -355,15 +395,13 @@ module('factory-entrypoint integration', function () { encoding: 'utf8', env: { ...process.env, - MATRIX_USERNAME: '', + HOME: '/tmp/no-boxel-cli-here', }, }, ); assert.strictEqual(result.status, 1); - assert.true( - /Set MATRIX_USERNAME before running factory:go/.test(result.stderr), - ); + assert.true(/boxel profile add/.test(result.stderr)); } finally { await new Promise((resolvePromise, reject) => server.close((error) => (error ? reject(error) : resolvePromise())), diff --git a/packages/software-factory/tests/factory-entrypoint.test.ts b/packages/software-factory/tests/factory-entrypoint.test.ts index 2aa5485ab6c..2d12a892c3c 100644 --- a/packages/software-factory/tests/factory-entrypoint.test.ts +++ b/packages/software-factory/tests/factory-entrypoint.test.ts @@ -13,6 +13,7 @@ import { import type { FactoryBrief } from '../src/factory-brief'; import type { FactoryTargetRealmBootstrapResult } from '../src/factory-target-realm'; import type { SeedIssueResult } from '../src/factory-seed'; +import { installTestProfile } from './helpers/test-profile'; const briefUrl = 'https://briefs.example.test/software-factory/Wiki/sticky-note'; @@ -39,12 +40,22 @@ const mockSeedResult: SeedIssueResult = { }; module('factory-entrypoint', function (hooks) { - let originalMatrixUsername = process.env.MATRIX_USERNAME; + let cleanupProfile: (() => void) | undefined; hooks.afterEach(function () { - process.env.MATRIX_USERNAME = originalMatrixUsername; + cleanupProfile?.(); + cleanupProfile = undefined; }); + function useTestProfile() { + cleanupProfile = installTestProfile({ + username: 'hassan', + matrixUrl: 'https://matrix.example.test/', + realmServerUrl: 'https://realms.example.test/', + password: 'secret', + }); + } + test('parseFactoryEntrypointArgs accepts required inputs', function (assert) { let options = parseFactoryEntrypointArgs([ '--brief-url', @@ -142,14 +153,15 @@ module('factory-entrypoint', function (hooks) { assert.true(/--realm-server-url /.test(usage)); assert.true(/--no-retry-blocked/.test(usage)); assert.true(/--help/.test(usage)); - assert.true(/MATRIX_USERNAME is required/.test(usage)); - assert.true(/For public briefs, no auth setup is needed./.test(usage)); - assert.true(/MATRIX_URL \+ MATRIX_USERNAME \+ MATRIX_PASSWORD/.test(usage)); + assert.true(/active Boxel profile/.test(usage)); + assert.true( + /For public briefs, no further auth setup is needed./.test(usage), + ); assert.false(/REALM_SECRET_SEED/.test(usage)); }); test('runFactoryEntrypoint creates seed issue and loads brief data', async function (assert) { - process.env.MATRIX_USERNAME = 'hassan'; + useTestProfile(); let summary = await runFactoryEntrypoint( { @@ -219,7 +231,7 @@ module('factory-entrypoint', function (hooks) { }); test('runFactoryEntrypoint uses the resolved realm server URL for darkfactory module', async function (assert) { - process.env.MATRIX_USERNAME = 'hassan'; + useTestProfile(); let capturedDarkfactoryModuleUrl: string | undefined; diff --git a/packages/software-factory/tests/factory-target-realm.spec.ts b/packages/software-factory/tests/factory-target-realm.spec.ts index 1ab306ec97b..3986489da34 100644 --- a/packages/software-factory/tests/factory-target-realm.spec.ts +++ b/packages/software-factory/tests/factory-target-realm.spec.ts @@ -1,5 +1,11 @@ import { createServer } from 'node:http'; -import { mkdtempSync, readFileSync } from 'node:fs'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import type { AddressInfo } from 'node:net'; @@ -67,6 +73,13 @@ test('factory:go creates a target realm and bootstraps project artifacts end-to- realmServerURL, ).href; + let tempProfileHome = createTempProfileHome( + targetUsername, + targetPassword, + matrixURL, + realmServerURL, + ); + try { // The factory always runs the issue loop after seed creation. Without // OPENROUTER_API_KEY the loop will fail when it tries to invoke the LLM @@ -91,11 +104,7 @@ test('factory:go creates a target realm and bootstraps project artifacts end-to- cwd: packageRoot, env: { ...process.env, - HOME: mkdtempSync(join(tmpdir(), 'boxel-test-')), - MATRIX_USERNAME: targetUsername, - MATRIX_PASSWORD: targetPassword, - MATRIX_URL: matrixURL, - REALM_SERVER_URL: realmServerURL, + HOME: tempProfileHome, }, timeoutMs: 120_000, }, @@ -143,8 +152,33 @@ test('factory:go creates a target realm and bootstraps project artifacts end-to- 'Process brief and create project artifacts', ); } finally { + rmSync(tempProfileHome, { recursive: true, force: true }); await new Promise((r, reject) => briefServer.close((err) => (err ? reject(err) : r())), ); } }); + +function createTempProfileHome( + username: string, + password: string, + matrixUrl: string, + realmServerUrl: string, +): string { + let tempHome = mkdtempSync(join(tmpdir(), 'boxel-test-')); + let boxelCliDir = join(tempHome, '.boxel-cli'); + mkdirSync(boxelCliDir, { recursive: true }); + + let profileId = `@${username}:localhost`; + writeFileSync( + join(boxelCliDir, 'profiles.json'), + JSON.stringify({ + profiles: { + [profileId]: { matrixUrl, realmServerUrl, password }, + }, + activeProfile: profileId, + }), + ); + + return tempHome; +} diff --git a/packages/software-factory/tests/factory-target-realm.test.ts b/packages/software-factory/tests/factory-target-realm.test.ts index 1b44ce3a3ed..e672286f696 100644 --- a/packages/software-factory/tests/factory-target-realm.test.ts +++ b/packages/software-factory/tests/factory-target-realm.test.ts @@ -1,22 +1,41 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { module, test } from 'qunit'; +import { + setProfileManager, + resetProfileManager, +} from '@cardstack/boxel-cli/api'; + import { FactoryEntrypointUsageError } from '../src/factory-entrypoint-errors'; import { bootstrapFactoryTargetRealm, resolveFactoryTargetRealm, } from '../src/factory-target-realm'; +import { installTestProfile } from './helpers/test-profile'; const targetRealmUrl = 'https://realms.example.test/hassan/personal/'; module('factory-target-realm', function (hooks) { - let originalMatrixUsername = process.env.MATRIX_USERNAME; + let cleanupProfile: (() => void) | undefined; hooks.afterEach(function () { - restoreEnv('MATRIX_USERNAME', originalMatrixUsername); + cleanupProfile?.(); + cleanupProfile = undefined; }); - test('resolveFactoryTargetRealm uses MATRIX_USERNAME and explicit target URL', function (assert) { - process.env.MATRIX_USERNAME = 'hassan'; + function useTestProfile() { + cleanupProfile = installTestProfile({ + username: 'hassan', + matrixUrl: 'https://matrix.example.test/', + realmServerUrl: 'https://realms.example.test/', + password: 'secret', + }); + } + + test('resolveFactoryTargetRealm resolves owner from active profile', function (assert) { + useTestProfile(); let resolution = resolveFactoryTargetRealm({ targetRealmUrl, @@ -26,14 +45,14 @@ module('factory-target-realm', function (hooks) { assert.strictEqual(resolution.url, targetRealmUrl); assert.strictEqual( resolution.serverUrl, - 'http://localhost:4201/', - 'defaults to localhost when --realm-server-url is not provided', + 'https://realms.example.test/', + 'defaults to active profile realmServerUrl when --realm-server-url is not provided', ); assert.strictEqual(resolution.ownerUsername, 'hassan'); }); test('resolveFactoryTargetRealm accepts an explicit realm server URL override', function (assert) { - process.env.MATRIX_USERNAME = 'hassan'; + useTestProfile(); let resolution = resolveFactoryTargetRealm({ targetRealmUrl: 'https://realms.example.test/boxel/hassan/personal/', @@ -47,7 +66,7 @@ module('factory-target-realm', function (hooks) { }); test('resolveFactoryTargetRealm rejects when target realm URL is missing', function (assert) { - process.env.MATRIX_USERNAME = 'hassan'; + useTestProfile(); assert.throws( () => @@ -61,8 +80,40 @@ module('factory-target-realm', function (hooks) { ); }); - test('resolveFactoryTargetRealm rejects when MATRIX_USERNAME is missing', function (assert) { - delete process.env.MATRIX_USERNAME; + test('resolveFactoryTargetRealm rejects when target realm origin does not match profile', function (assert) { + // Profile points to staging, but target realm is localhost + cleanupProfile = installTestProfile({ + username: 'hassan', + matrixUrl: 'https://matrix-staging.stack.cards/', + realmServerUrl: 'https://realms-staging.stack.cards/', + password: 'secret', + }); + + assert.throws( + () => + resolveFactoryTargetRealm({ + targetRealmUrl: 'http://localhost:4201/hassan/my-realm/', + realmServerUrl: null, + }), + (error: unknown) => + error instanceof FactoryEntrypointUsageError && + error.message.includes('does not match the realm server') && + error.message.includes('boxel profile switch'), + ); + }); + + test('resolveFactoryTargetRealm rejects when no active profile is configured', function (assert) { + // Point the singleton at a temp dir with an empty profiles file + let tempConfigDir = mkdtempSync(join(tmpdir(), 'boxel-test-empty-')); + writeFileSync( + join(tempConfigDir, 'profiles.json'), + JSON.stringify({ profiles: {}, activeProfile: null }), + ); + setProfileManager(tempConfigDir); + cleanupProfile = () => { + resetProfileManager(); + rmSync(tempConfigDir, { recursive: true, force: true }); + }; assert.throws( () => @@ -72,12 +123,14 @@ module('factory-target-realm', function (hooks) { }), (error: unknown) => error instanceof FactoryEntrypointUsageError && - error.message.includes('Set MATRIX_USERNAME'), + (error.message.includes('boxel profile add') || + error.message.includes('active Boxel profile')), ); }); test('bootstrapFactoryTargetRealm creates the realm through the API', async function (assert) { - process.env.MATRIX_USERNAME = 'hassan'; + useTestProfile(); + let resolution = resolveFactoryTargetRealm({ targetRealmUrl, realmServerUrl: null, @@ -101,7 +154,8 @@ module('factory-target-realm', function (hooks) { }); test('bootstrapFactoryTargetRealm reports when the realm already exists', async function (assert) { - process.env.MATRIX_USERNAME = 'hassan'; + useTestProfile(); + let resolution = resolveFactoryTargetRealm({ targetRealmUrl, realmServerUrl: null, @@ -120,7 +174,8 @@ module('factory-target-realm', function (hooks) { }); test('bootstrapFactoryTargetRealm uses the canonical realm URL returned by create-realm', async function (assert) { - process.env.MATRIX_USERNAME = 'hassan'; + useTestProfile(); + let resolution = resolveFactoryTargetRealm({ targetRealmUrl: 'https://realms.example.test/typed-by-user/personal/', realmServerUrl: null, @@ -141,11 +196,3 @@ module('factory-target-realm', function (hooks) { assert.strictEqual(result.authorization, 'Bearer target-realm-token'); }); }); - -function restoreEnv(name: string, value: string | undefined): void { - if (value === undefined) { - delete process.env[name]; - } else { - process.env[name] = value; - } -} diff --git a/packages/software-factory/tests/helpers/browser-auth.ts b/packages/software-factory/tests/helpers/browser-auth.ts index 0cdd724b8a8..ed421345329 100644 --- a/packages/software-factory/tests/helpers/browser-auth.ts +++ b/packages/software-factory/tests/helpers/browser-auth.ts @@ -41,7 +41,6 @@ function getSupportRealmServerURL(): string | undefined { const defaultMatrixUrl = ensureTrailingSlash( process.env.SOFTWARE_FACTORY_BROWSER_MATRIX_URL ?? getSupportMatrixURL() ?? - process.env.MATRIX_URL ?? 'http://localhost:8008/', ); const defaultUsername = diff --git a/packages/software-factory/tests/helpers/test-profile.ts b/packages/software-factory/tests/helpers/test-profile.ts new file mode 100644 index 00000000000..84ab7a1d9a4 --- /dev/null +++ b/packages/software-factory/tests/helpers/test-profile.ts @@ -0,0 +1,50 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + resetProfileManager, + setProfileManager, +} from '@cardstack/boxel-cli/api'; + +export interface TestProfileOptions { + username: string; + matrixUrl: string; + realmServerUrl: string; + password: string; +} + +/** + * Installs a fake Boxel CLI profile into an isolated temp directory. + * Replaces the ProfileManager singleton so BoxelCLIClient picks up the + * test profile without touching the real ~/.boxel-cli/profiles.json. + * + * Returns a cleanup function that resets the singleton and removes the temp dir. + */ +export function installTestProfile(options: TestProfileOptions): () => void { + let tempConfigDir = mkdtempSync(join(tmpdir(), 'boxel-test-config-')); + mkdirSync(tempConfigDir, { recursive: true }); + + let profileId = `@${options.username}:localhost`; + let config = { + profiles: { + [profileId]: { + matrixUrl: options.matrixUrl, + realmServerUrl: options.realmServerUrl, + password: options.password, + }, + }, + activeProfile: profileId, + }; + + writeFileSync( + join(tempConfigDir, 'profiles.json'), + JSON.stringify(config, null, 2), + ); + setProfileManager(tempConfigDir); + + return () => { + resetProfileManager(); + rmSync(tempConfigDir, { recursive: true, force: true }); + }; +} diff --git a/packages/software-factory/tests/realm-auth.test.ts b/packages/software-factory/tests/realm-auth.test.ts index 26a64e130c0..8aac4e9a0f4 100644 --- a/packages/software-factory/tests/realm-auth.test.ts +++ b/packages/software-factory/tests/realm-auth.test.ts @@ -230,79 +230,32 @@ module('realm-auth', function () { } }); - test('createBoxelRealmFetch uses MATRIX_USERNAME and MATRIX_PASSWORD env auth for a private brief', async function (assert) { + test('createBoxelRealmFetch uses active Boxel profile auth for a private brief', async function (assert) { let tempHome = mkdtempSync(join(tmpdir(), 'software-factory-realm-auth-')); let originalHome = process.env.HOME; - let originalMatrixUrl = process.env.MATRIX_URL; - let originalMatrixUsername = process.env.MATRIX_USERNAME; - let originalMatrixPassword = process.env.MATRIX_PASSWORD; - let originalRealmServerUrl = process.env.REALM_SERVER_URL; let username = 'software-factory-browser'; let password = browserPassword(username); let servers = await startServers({ username, password }); let briefUrl = `${servers.realmServer.realmUrl}Wiki/brief-card`; - try { - process.env.HOME = tempHome; - process.env.MATRIX_URL = servers.matrixServer.url; - process.env.MATRIX_USERNAME = username; - process.env.MATRIX_PASSWORD = password; - delete process.env.REALM_SERVER_URL; - - let brief = await loadFactoryBrief(briefUrl, { - fetch: createBoxelRealmFetch(briefUrl), - }); - - assert.strictEqual(brief.title, 'Private Brief'); - assert.strictEqual( - brief.contentSummary, - 'Private brief content for testing realm auth.', - ); - } finally { - restoreEnv('HOME', originalHome); - restoreEnv('MATRIX_URL', originalMatrixUrl); - restoreEnv('MATRIX_USERNAME', originalMatrixUsername); - restoreEnv('MATRIX_PASSWORD', originalMatrixPassword); - restoreEnv('REALM_SERVER_URL', originalRealmServerUrl); - await servers.stop(); - rmSync(tempHome, { recursive: true, force: true }); - } - }); - - test('createBoxelRealmFetch prefers env auth over an unrelated active profile', async function (assert) { - let tempHome = mkdtempSync(join(tmpdir(), 'software-factory-realm-auth-')); - let originalHome = process.env.HOME; - let originalMatrixUrl = process.env.MATRIX_URL; - let originalMatrixUsername = process.env.MATRIX_USERNAME; - let originalMatrixPassword = process.env.MATRIX_PASSWORD; - let originalRealmServerUrl = process.env.REALM_SERVER_URL; let profilesDir = join(tempHome, '.boxel-cli'); mkdirSync(profilesDir, { recursive: true }); writeFileSync( join(profilesDir, 'profiles.json'), JSON.stringify({ - activeProfile: '@someone-else:localhost', + activeProfile: `@${username}:localhost`, profiles: { - '@someone-else:localhost': { - matrixUrl: 'https://unrelated-matrix.example.test/', - realmServerUrl: 'https://unrelated-realm-server.example.test/', - password: 'wrong-password', + [`@${username}:localhost`]: { + matrixUrl: servers.matrixServer.url, + realmServerUrl: servers.realmServer.origin, + password, }, }, }), ); - let username = 'software-factory-browser'; - let password = browserPassword(username); - let servers = await startServers({ username, password }); - let briefUrl = `${servers.realmServer.realmUrl}Wiki/brief-card`; - try { process.env.HOME = tempHome; - process.env.MATRIX_URL = servers.matrixServer.url; - process.env.MATRIX_USERNAME = username; - process.env.MATRIX_PASSWORD = password; - delete process.env.REALM_SERVER_URL; let brief = await loadFactoryBrief(briefUrl, { fetch: createBoxelRealmFetch(briefUrl), @@ -315,16 +268,12 @@ module('realm-auth', function () { ); } finally { restoreEnv('HOME', originalHome); - restoreEnv('MATRIX_URL', originalMatrixUrl); - restoreEnv('MATRIX_USERNAME', originalMatrixUsername); - restoreEnv('MATRIX_PASSWORD', originalMatrixPassword); - restoreEnv('REALM_SERVER_URL', originalRealmServerUrl); await servers.stop(); rmSync(tempHome, { recursive: true, force: true }); } }); - test('matrixLogin rejects when MATRIX_USERNAME and MATRIX_PASSWORD are invalid', async function (assert) { + test('matrixLogin rejects when credentials are invalid', async function (assert) { let username = 'software-factory-browser'; let servers = await startServers({ username });