diff --git a/packages/boxel-cli/src/commands/realm/index.ts b/packages/boxel-cli/src/commands/realm/index.ts index 088a16b253a..add86c8400e 100644 --- a/packages/boxel-cli/src/commands/realm/index.ts +++ b/packages/boxel-cli/src/commands/realm/index.ts @@ -5,6 +5,7 @@ import { registerHistoryCommand } from './history'; import { registerListCommand } from './list'; import { registerPullCommand } from './pull'; import { registerPushCommand } from './push'; +import { registerRemoveCommand } from './remove'; import { registerSyncCommand } from './sync'; import { registerWaitForReadyCommand } from './wait-for-ready'; @@ -19,6 +20,7 @@ export function registerRealmCommand(program: Command): void { registerListCommand(realm); registerPullCommand(realm); registerPushCommand(realm); + registerRemoveCommand(realm); registerSyncCommand(realm); registerWaitForReadyCommand(realm); } diff --git a/packages/boxel-cli/src/commands/realm/remove.ts b/packages/boxel-cli/src/commands/realm/remove.ts new file mode 100644 index 00000000000..66fe7e0c59d --- /dev/null +++ b/packages/boxel-cli/src/commands/realm/remove.ts @@ -0,0 +1,281 @@ +import type { Command } from 'commander'; +import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; +import { + getProfileManager, + NO_ACTIVE_PROFILE_ERROR, + type ProfileManager, +} from '../../lib/profile-manager'; +import { prompt } from '../../lib/prompt'; +import { DIM, FG_CYAN, FG_GREEN, FG_RED, RESET } from '../../lib/colors'; + +export interface RemoveRealmOptions { + realmUrl: string; + dryRun?: boolean; + profileManager?: ProfileManager; +} + +export interface RemoveRealmResult { + /** Normalized URL the operation targeted (always trailing-slashed). */ + realmUrl: string; + /** True only when both server delete and Matrix unlink completed. */ + removed: boolean; + /** True when DELETE /_delete-realm returned 204. */ + serverDeleted: boolean; + /** True when Matrix `app.boxel.realms` was rewritten without the URL. */ + unlinked: boolean; + /** Number of entries before the change. */ + previousCount: number; + /** Number of entries the next list would contain (computed even on dry-run). */ + nextCount: number; + /** + * True when the URL was not present in `app.boxel.realms`. Mutually + * exclusive with a successful real removal. + */ + notInList?: boolean; + error?: string; +} + +/** + * Remove a realm: delete server-side files / index / registry via + * `DELETE /_delete-realm`, then unlink the URL from the active profile's + * `app.boxel.realms` Matrix account_data list. Mirrors the host UI's + * workspace delete flow and inverts `boxel realm create`. + * + * Programmatic API. Returns a result object on every code path; never + * prompts and never calls `process.exit`. The CLI wraps this with a TTY + * confirmation step (see `registerRemoveCommand`). + */ +export async function removeRealm( + options: RemoveRealmOptions, +): Promise { + let realmUrl = ensureTrailingSlash(options.realmUrl.trim()); + let pm = options.profileManager ?? getProfileManager(); + let active = pm.getActiveProfile(); + if (!active) { + return { + realmUrl, + removed: false, + serverDeleted: false, + unlinked: false, + previousCount: 0, + nextCount: 0, + error: NO_ACTIVE_PROFILE_ERROR, + }; + } + + let existing: string[]; + try { + existing = await pm.getUserRealms(); + } catch (err) { + return { + realmUrl, + removed: false, + serverDeleted: false, + unlinked: false, + previousCount: 0, + nextCount: 0, + error: `Failed to load realm list: ${ + err instanceof Error ? err.message : String(err) + }`, + }; + } + let normalized = existing.map(ensureTrailingSlash); + let previousCount = normalized.length; + let matchCount = normalized.filter((u) => u === realmUrl).length; + + if (matchCount === 0) { + return { + realmUrl, + removed: false, + serverDeleted: false, + unlinked: false, + previousCount, + nextCount: previousCount, + notInList: true, + error: 'Realm is not in app.boxel.realms. Nothing to remove.', + }; + } + + let nextCount = previousCount - matchCount; + + if (options.dryRun) { + return { + realmUrl, + removed: false, + serverDeleted: false, + unlinked: false, + previousCount, + nextCount, + }; + } + + let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, ''); + let response: Response; + try { + response = await pm.authedRealmServerFetch( + `${realmServerUrl}/_delete-realm`, + { + method: 'DELETE', + headers: { 'Content-Type': 'application/vnd.api+json' }, + body: JSON.stringify({ + data: { type: 'realm', id: realmUrl }, + }), + }, + ); + } catch (err) { + return { + realmUrl, + removed: false, + serverDeleted: false, + unlinked: false, + previousCount, + nextCount: previousCount, + error: `Failed to reach realm server: ${ + err instanceof Error ? err.message : String(err) + }`, + }; + } + + if (!response.ok) { + let body = await safeReadResponseText(response); + let error = + response.status === 403 + ? `You do not own this realm and cannot delete it on the server. Server returned 403: ${body}` + : `Realm server returned ${response.status}: ${body}`; + return { + realmUrl, + removed: false, + serverDeleted: false, + unlinked: false, + previousCount, + nextCount: previousCount, + error, + }; + } + + let unlinked: boolean; + try { + unlinked = await pm.removeFromUserRealms(realmUrl); + } catch (err) { + return { + realmUrl, + removed: false, + serverDeleted: true, + unlinked: false, + previousCount, + nextCount: previousCount, + error: `Server delete succeeded, but Matrix unlink failed: ${ + err instanceof Error ? err.message : String(err) + }`, + }; + } + + if (!unlinked) { + return { + realmUrl, + removed: false, + serverDeleted: true, + unlinked: false, + previousCount, + nextCount: previousCount, + error: + 'Server delete succeeded, but Matrix account_data did not contain the URL by the time we PUT (concurrent edit?). Server-side files are gone; please refresh and check your realm list.', + }; + } + + return { + realmUrl, + removed: true, + serverDeleted: true, + unlinked, + previousCount, + nextCount, + }; +} + +async function safeReadResponseText(response: Response): Promise { + try { + return await response.text(); + } catch { + return ''; + } +} + +interface RemoveCliOptions { + yes?: boolean; + dryRun?: boolean; +} + +export function registerRemoveCommand(realm: Command): void { + realm + .command('remove') + .description( + 'Remove a realm — deletes server-side files and unlinks it from your realm list', + ) + .argument('', 'realm URL to remove') + .option('-y, --yes', 'Skip the interactive confirmation prompt') + .option('--dry-run', 'Preview the change without writing to Matrix') + .action(async (realmUrlInput: string, opts: RemoveCliOptions) => { + let normalized = ensureTrailingSlash(realmUrlInput.trim()); + + let preview = await removeRealm({ + realmUrl: normalized, + dryRun: true, + }); + + if (preview.error && !preview.notInList) { + console.error(`${FG_RED}Error:${RESET} ${preview.error}`); + process.exit(1); + } + + if (preview.notInList) { + console.error(`${FG_RED}Error:${RESET} ${preview.error}`); + process.exit(1); + } + + console.log(`Remove target: ${FG_CYAN}${preview.realmUrl}${RESET}`); + console.log( + `${DIM}app.boxel.realms: ${preview.previousCount} -> ${preview.nextCount}${RESET}`, + ); + + if (opts.dryRun) { + console.log( + `${DIM}[DRY RUN] No server delete or Matrix changes sent.${RESET}`, + ); + return; + } + + if (!opts.yes) { + if (!process.stdin.isTTY) { + console.error( + `${FG_RED}Error:${RESET} stdin is not a TTY. Pass --yes to confirm in non-interactive mode.`, + ); + process.exit(1); + } + let answer = await prompt( + 'This will permanently delete the realm files, indexer state, and registry entry on the server. Proceed? (y/N) ', + ); + if (!/^y/i.test(answer)) { + console.log(`${DIM}Cancelled.${RESET}`); + return; + } + } + + let result = await removeRealm({ realmUrl: normalized }); + if (result.error || !result.removed) { + console.error( + `${FG_RED}Error:${RESET} ${result.error ?? 'Removal did not complete.'}`, + ); + if (result.serverDeleted && !result.unlinked) { + console.error( + `${DIM}The realm is gone, but your account_data still references ${result.realmUrl}.${RESET}`, + ); + } + process.exit(1); + } + + console.log( + `${FG_GREEN}Removed:${RESET} ${FG_CYAN}${result.realmUrl}${RESET}`, + ); + }); +} diff --git a/packages/boxel-cli/src/lib/auth.ts b/packages/boxel-cli/src/lib/auth.ts index 1318ad63591..44d6e16b4ca 100644 --- a/packages/boxel-cli/src/lib/auth.ts +++ b/packages/boxel-cli/src/lib/auth.ts @@ -14,6 +14,7 @@ interface MatrixLoginResponse { } import { APP_BOXEL_REALMS_EVENT_TYPE } from '@cardstack/runtime-common/matrix-constants'; +import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; export async function matrixLogin( matrixUrl: string, @@ -176,3 +177,37 @@ export async function addRealmToMatrixAccountData( } } } + +// Returns true when at least one entry was removed and a write occurred, +// false when no entry matched the URL (caller decides how to surface that +// to the user). Comparison is normalized via `ensureTrailingSlash` and every +// matching entry is dropped, so legacy duplicates like `https://host/realm` +// + `https://host/realm/` are both cleaned out in a single PUT. +export async function removeRealmFromMatrixAccountData( + matrixAuth: MatrixAuth, + realmUrl: string, +): Promise { + let target = ensureTrailingSlash(realmUrl); + let existingRealms = await getUserRealmsFromMatrixAccountData(matrixAuth); + let next = existingRealms.filter( + (url) => ensureTrailingSlash(url) !== target, + ); + if (next.length === existingRealms.length) { + return false; + } + let putResponse = await fetch(userRealmsAccountDataUrl(matrixAuth), { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${matrixAuth.accessToken}`, + }, + body: JSON.stringify({ realms: next }), + }); + if (!putResponse.ok) { + let text = await putResponse.text(); + throw new Error( + `Failed to update Matrix account data: ${putResponse.status} ${text}`, + ); + } + return true; +} diff --git a/packages/boxel-cli/src/lib/profile-manager.ts b/packages/boxel-cli/src/lib/profile-manager.ts index 88dd553b58e..0d7296666eb 100644 --- a/packages/boxel-cli/src/lib/profile-manager.ts +++ b/packages/boxel-cli/src/lib/profile-manager.ts @@ -7,6 +7,7 @@ import { getRealmServerToken as fetchRealmServerToken, getRealmTokens, addRealmToMatrixAccountData, + removeRealmFromMatrixAccountData, getUserRealmsFromMatrixAccountData, type MatrixAuth, } from './auth'; @@ -524,6 +525,11 @@ export class ProfileManager implements RealmAuthenticator { await addRealmToMatrixAccountData(matrixAuth, realmUrl); } + async removeFromUserRealms(realmUrl: string): Promise { + let matrixAuth = await this.loginToMatrix(); + return removeRealmFromMatrixAccountData(matrixAuth, realmUrl); + } + async getUserRealms(): Promise { let matrixAuth = await this.loginToMatrix(); return getUserRealmsFromMatrixAccountData(matrixAuth); diff --git a/packages/boxel-cli/tests/helpers/integration.ts b/packages/boxel-cli/tests/helpers/integration.ts index 3811c133dca..9c64da097ae 100644 --- a/packages/boxel-cli/tests/helpers/integration.ts +++ b/packages/boxel-cli/tests/helpers/integration.ts @@ -14,6 +14,12 @@ import { } from '#realm-server/tests/helpers/index'; import { createJWT as createRealmServerJWT } from '#realm-server/utils/jwt'; import { registerUser } from '#realm-server/synapse'; + +export { registerUser } from '#realm-server/synapse'; +export { + matrixURL, + matrixRegistrationSecret, +} from '#realm-server/tests/helpers/index'; import { PgQueuePublisher, PgQueueRunner, diff --git a/packages/boxel-cli/tests/integration/realm-remove.test.ts b/packages/boxel-cli/tests/integration/realm-remove.test.ts new file mode 100644 index 00000000000..fc7be86e636 --- /dev/null +++ b/packages/boxel-cli/tests/integration/realm-remove.test.ts @@ -0,0 +1,221 @@ +import '../helpers/setup-realm-server'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { createRealm } from '../../src/commands/realm/create'; +import { removeRealm } from '../../src/commands/realm/remove'; +import { ProfileManager } from '../../src/lib/profile-manager'; +import { + startTestRealmServer, + stopTestRealmServer, + createTestProfileDir, + setupTestProfile, + uniqueRealmName, + registerUser, + matrixURL, + matrixRegistrationSecret, + TEST_REALM_SERVER_URL, +} from '../helpers/integration'; + +let profileManager: ProfileManager; +let cleanupProfile: () => void; + +beforeAll(async () => { + await startTestRealmServer(); + let testProfile = createTestProfileDir(); + profileManager = testProfile.profileManager; + cleanupProfile = testProfile.cleanup; + await setupTestProfile(profileManager); +}); + +afterAll(async () => { + cleanupProfile?.(); + await stopTestRealmServer(); +}); + +describe('realm remove (integration)', () => { + it('hard-deletes the realm on the server and unlinks from Matrix', async () => { + let name = uniqueRealmName(); + let { realmUrl } = await createRealm(name, `Test ${name}`, { + profileManager, + }); + + let result = await removeRealm({ realmUrl, profileManager }); + + expect(result.error).toBeUndefined(); + expect(result.removed).toBe(true); + expect(result.serverDeleted).toBe(true); + expect(result.unlinked).toBe(true); + expect(result.realmUrl).toBe(realmUrl); + expect(result.nextCount).toBe(result.previousCount - 1); + + let userRealms = await profileManager.getUserRealms(); + expect(userRealms).not.toContain(realmUrl); + }); + + it('frees the realm name so it can be recreated', async () => { + let name = uniqueRealmName(); + let first = await createRealm(name, `Test ${name}`, { profileManager }); + let removed = await removeRealm({ + realmUrl: first.realmUrl, + profileManager, + }); + expect(removed.removed).toBe(true); + + let second = await createRealm(name, `Test ${name}`, { profileManager }); + expect(second.created).toBe(true); + expect(second.realmUrl).toBe(first.realmUrl); + + await removeRealm({ realmUrl: second.realmUrl, profileManager }); + }); + + it('reports notInList when the URL is not in the user list', async () => { + let result = await removeRealm({ + realmUrl: `${TEST_REALM_SERVER_URL}/never-added-${Date.now()}/`, + profileManager, + }); + expect(result.removed).toBe(false); + expect(result.serverDeleted).toBe(false); + expect(result.unlinked).toBe(false); + expect(result.notInList).toBe(true); + expect(result.error).toContain('Nothing to remove'); + expect(result.previousCount).toBe(result.nextCount); + }); + + it('dry-run does not hit the server or modify Matrix', async () => { + let name = uniqueRealmName(); + let { realmUrl } = await createRealm(name, `Test ${name}`, { + profileManager, + }); + let before = await profileManager.getUserRealms(); + + let result = await removeRealm({ + realmUrl, + dryRun: true, + profileManager, + }); + + expect(result.error).toBeUndefined(); + expect(result.removed).toBe(false); + expect(result.serverDeleted).toBe(false); + expect(result.unlinked).toBe(false); + expect(result.previousCount).toBe(before.length); + expect(result.nextCount).toBe(before.length - 1); + + let after = await profileManager.getUserRealms(); + expect(after).toContain(realmUrl); + + let stillThere = await removeRealm({ realmUrl, profileManager }); + expect(stillThere.serverDeleted).toBe(true); + }); + + it('normalizes trailing-slash on input', async () => { + let name = uniqueRealmName(); + let { realmUrl } = await createRealm(name, `Test ${name}`, { + profileManager, + }); + + let withoutSlash = realmUrl.replace(/\/$/, ''); + let result = await removeRealm({ + realmUrl: withoutSlash, + profileManager, + }); + expect(result.error).toBeUndefined(); + expect(result.removed).toBe(true); + expect(result.realmUrl).toBe(realmUrl); + + let after = await profileManager.getUserRealms(); + expect(after).not.toContain(realmUrl); + }); + + it('removes legacy duplicate entries (with and without trailing slash)', async () => { + let name = uniqueRealmName(); + let { realmUrl } = await createRealm(name, `Test ${name}`, { + profileManager, + }); + let withoutSlash = realmUrl.replace(/\/$/, ''); + + // createRealm adds the trailing-slash form. Inject the trailing-slash-less + // form directly so the list looks like a legacy account_data with both + // shapes for the same realm. + await profileManager.addToUserRealms(withoutSlash); + let beforeRemove = await profileManager.getUserRealms(); + expect(beforeRemove).toContain(realmUrl); + expect(beforeRemove).toContain(withoutSlash); + + let result = await removeRealm({ realmUrl, profileManager }); + expect(result.error).toBeUndefined(); + expect(result.removed).toBe(true); + expect(result.serverDeleted).toBe(true); + expect(result.unlinked).toBe(true); + expect(result.previousCount - result.nextCount).toBe(2); + + let after = await profileManager.getUserRealms(); + expect(after).not.toContain(realmUrl); + expect(after).not.toContain(withoutSlash); + }); + + it('fails with a 403 error when the caller does not own the realm', async () => { + let realmName = uniqueRealmName(); + let { realmUrl } = await createRealm(realmName, `Test ${realmName}`, { + profileManager, + }); + + let userBSuffix = `userb-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 6)}`; + let userBUsername = `cli-test-${userBSuffix}`; + let userBPassword = 'test-password-userb'; + await registerUser({ + matrixURL, + displayname: 'CLI Test User B', + username: userBUsername, + password: userBPassword, + registrationSecret: matrixRegistrationSecret, + }); + + let userBProfile = createTestProfileDir(); + try { + await userBProfile.profileManager.addProfile( + `@${userBUsername}:localhost`, + userBPassword, + 'CLI Test User B', + matrixURL.href, + `${TEST_REALM_SERVER_URL}/`, + ); + await userBProfile.profileManager.addToUserRealms(realmUrl); + + let result = await removeRealm({ + realmUrl, + profileManager: userBProfile.profileManager, + }); + + expect(result.removed).toBe(false); + expect(result.serverDeleted).toBe(false); + expect(result.unlinked).toBe(false); + expect(result.error).toMatch(/403/); + expect(result.error).toMatch(/do not own this realm/); + + let listAfter = await profileManager.getUserRealms(); + expect(listAfter).toContain(realmUrl); + } finally { + userBProfile.cleanup(); + } + + let cleanup = await removeRealm({ realmUrl, profileManager }); + expect(cleanup.removed).toBe(true); + }); + + it('returns an error when no active profile', async () => { + let emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-')); + let emptyManager = new ProfileManager(emptyDir); + let result = await removeRealm({ + realmUrl: `${TEST_REALM_SERVER_URL}/anything/`, + profileManager: emptyManager, + }); + expect(result.removed).toBe(false); + expect(result.error).toContain('No active profile'); + fs.rmSync(emptyDir, { recursive: true, force: true }); + }); +});