Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions packages/boxel-cli/src/commands/realm/archive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import type { Command } from 'commander';
import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
import {
getProfileManager,
NO_ACTIVE_PROFILE_ERROR,
type ProfileManager,
} from '../../lib/profile-manager.ts';
import { prompt } from '../../lib/prompt.ts';
import { DIM, FG_CYAN, FG_GREEN, FG_RED, RESET } from '../../lib/colors.ts';

export interface ArchiveRealmOptions {
realmUrl: string;
profileManager?: ProfileManager;
}

export interface ArchiveRealmResult {
/** Normalized URL the operation targeted (always trailing-slashed). */
realmUrl: string;
/** True when POST /_archive-realm returned 200. */
archived: boolean;
error?: string;
}

/**
* Archive a realm via `POST /_archive-realm`. Owner-only; the server
* returns 403 when the caller is not an owner. Programmatic API: returns
* a result object on every path, never prompts, never calls
* `process.exit`. The CLI wraps this with a TTY confirmation step (see
* `registerArchiveCommand`).
*/
export async function archiveRealm(
options: ArchiveRealmOptions,
): Promise<ArchiveRealmResult> {
let realmUrl = ensureTrailingSlash(options.realmUrl.trim());
let pm = options.profileManager ?? getProfileManager();
let active = pm.getActiveProfile();
if (!active) {
return {
realmUrl,
archived: false,
error: NO_ACTIVE_PROFILE_ERROR,
};
}

let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, '');
let response: Response;
try {
response = await pm.authedRealmServerFetch(
`${realmServerUrl}/_archive-realm`,
{
method: 'POST',
headers: { 'Content-Type': 'application/vnd.api+json' },
body: JSON.stringify({
data: { type: 'realm', id: realmUrl },
}),
},
);
} catch (err) {
return {
realmUrl,
archived: false,
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 archive it. Server returned 403: ${body}`
: `Realm server returned ${response.status}: ${body}`;
return {
realmUrl,
archived: false,
error,
};
}

return {
realmUrl,
archived: true,
};
}

async function safeReadResponseText(response: Response): Promise<string> {
try {
return await response.text();
} catch {
return '<no response body>';
}
}

interface ArchiveCliOptions {
yes?: boolean;
}

export function registerArchiveCommand(realm: Command): void {
realm
.command('archive')
.description(
'Archive a realm — hides it from enumeration and stops its indexer (owner-only)',
)
.argument('<realm-url>', 'realm URL to archive')
.option('-y, --yes', 'Skip the interactive confirmation prompt')
.action(async (realmUrlInput: string, opts: ArchiveCliOptions) => {
let normalized = ensureTrailingSlash(realmUrlInput.trim());

console.log(`Archive target: ${FG_CYAN}${normalized}${RESET}`);

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 archive the realm: it will be hidden from your realm list, sealed for content, and its indexer will stop. You can restore it later with `boxel realm restore`. Proceed? (y/N) ',
);
if (!/^y/i.test(answer)) {
console.log(`${DIM}Cancelled.${RESET}`);
return;
}
}

let result = await archiveRealm({ realmUrl: normalized });
if (result.error || !result.archived) {
console.error(
`${FG_RED}Error:${RESET} ${result.error ?? 'Archive did not complete.'}`,
);
process.exit(1);
}

console.log(
`${FG_GREEN}Archived:${RESET} ${FG_CYAN}${result.realmUrl}${RESET}`,
);
});
}
4 changes: 4 additions & 0 deletions packages/boxel-cli/src/commands/realm/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Command } from 'commander';
import { registerArchiveCommand } from './archive.ts';
import { registerCancelIndexingCommand } from './cancel-indexing.ts';
import { registerCreateCommand } from './create.ts';
import { registerHistoryCommand } from './history.ts';
Expand All @@ -10,6 +11,7 @@ import { registerPublishCommand } from './publish.ts';
import { registerPullCommand } from './pull.ts';
import { registerPushCommand } from './push.ts';
import { registerRemoveCommand } from './remove.ts';
import { registerRestoreCommand } from './restore.ts';
import { registerStatusCommand } from './status.ts';
import { registerSyncCommand } from './sync.ts';
import { registerUnpublishCommand } from './unpublish.ts';
Expand All @@ -21,6 +23,7 @@ export function registerRealmCommand(program: Command): void {
.command('realm')
.description('Manage realms on the realm server');

registerArchiveCommand(realm);
registerCancelIndexingCommand(realm);
registerCreateCommand(realm);
registerHistoryCommand(realm);
Expand All @@ -32,6 +35,7 @@ export function registerRealmCommand(program: Command): void {
registerPullCommand(realm);
registerPushCommand(realm);
registerRemoveCommand(realm);
registerRestoreCommand(realm);
const sync = registerSyncCommand(realm);
registerStatusCommand(sync);
registerUnpublishCommand(realm);
Expand Down
60 changes: 55 additions & 5 deletions packages/boxel-cli/src/commands/realm/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const MUTUALLY_EXCLUSIVE_FLAGS_ERROR =
export interface RealmSummary {
url: string;
hidden: boolean;
archived: boolean;
}

export interface ListRealmsResult {
Expand All @@ -23,24 +24,32 @@ export interface ListRealmsResult {
export interface ListRealmsOptions {
allAccessible?: boolean;
hidden?: boolean;
includeArchived?: boolean;
profileManager?: ProfileManager;
}

interface ListCliOptions {
json?: boolean;
allAccessible?: boolean;
hidden?: boolean;
includeArchived?: boolean;
}

/**
* List realms accessible to the active profile.
*
* Calls `_realm-auth` to discover all realms the user can access, then
* marks each as `hidden` based on whether it appears in the user's
* Calls `_realm-auth` to discover the user's accessible non-archived
* realms, then marks each as `hidden` based on whether it appears in the
Comment thread
lukemelia marked this conversation as resolved.
* `app.boxel.realms` Matrix account data (the UI realm list).
*
* Default mode shows only non-hidden realms; `--all-accessible` shows
* everything; `--hidden` shows only hidden ones.
* Archived realms are hidden by default (matching the workspace
* chooser). With `--include-archived`, the owner-only `_archived-realms`
* endpoint is consulted and the caller's archived realms are appended
* with `archived: true`.
*
* Default mode shows only non-hidden, non-archived realms;
* `--all-accessible` shows everything accessible; `--hidden` shows only
* hidden non-archived ones.
*/
export async function listRealms(
options: ListRealmsOptions = {},
Expand Down Expand Up @@ -92,6 +101,7 @@ export async function listRealms(
let summaries: RealmSummary[] = accessibleUrls.map((url) => ({
url,
hidden: !userRealmsSet.has(url),
archived: false,
}));

if (options.allAccessible) {
Expand All @@ -102,6 +112,38 @@ export async function listRealms(
summaries = summaries.filter((r) => !r.hidden);
}

if (options.includeArchived) {

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.

--include-archived failure replaces entire realm. When the archived-realms lookup fails (e.g. 403 for non-owner), the response is { realms: [], error: "..." }. this wipes out the normal realm list entirely. A non-owner running boxel realm list --include-archived gets no realms back at all, just an error.

let archivedResponse = await pm.authedRealmServerFetch(
`${realmServerUrl}/_archived-realms`,
{
method: 'GET',
headers: { Accept: 'application/vnd.api+json' },
},
);
if (!archivedResponse.ok) {
let text = await archivedResponse.text();
return {
realms: [],
error: `Archived realms lookup failed: ${archivedResponse.status} ${text}`,
};
}
let archivedBody = (await archivedResponse.json()) as {
data?: Array<{ id?: string }>;
};
let archivedUrls = (archivedBody.data ?? [])
.map((entry) => (entry?.id ? ensureTrailingSlash(entry.id) : null))
.filter((u): u is string => u !== null);

let alreadyListed = new Set(summaries.map((r) => r.url));
for (let url of archivedUrls) {
if (alreadyListed.has(url)) {
continue;
}
summaries.push({ url, hidden: !userRealmsSet.has(url), archived: true });
alreadyListed.add(url);
}
}

summaries.sort((a, b) => a.url.localeCompare(b.url));
return { realms: summaries };
}
Expand All @@ -117,12 +159,17 @@ export function registerListCommand(realm: Command): void {
'Show all accessible realms, including hidden ones',
)
.option('--hidden', "Show only realms not in the user's UI realm list")
.option(
'--include-archived',
'Also list realms the caller owns that have been archived',
)
.action(async (opts: ListCliOptions) => {
let result: ListRealmsResult;
try {
result = await listRealms({
allAccessible: opts.allAccessible,
hidden: opts.hidden,
includeArchived: opts.includeArchived,
});
} catch (err) {
console.error(
Expand All @@ -149,7 +196,10 @@ export function registerListCommand(realm: Command): void {

console.log(`${BOLD}${result.realms.length} realm(s):${RESET}`);
for (let r of result.realms) {
let tag = r.hidden ? ` ${DIM}(hidden)${RESET}` : '';
let tags: string[] = [];
if (r.archived) tags.push('archived');
if (r.hidden && !r.archived) tags.push('hidden');
let tag = tags.length ? ` ${DIM}(${tags.join(', ')})${RESET}` : '';
console.log(` ${FG_CYAN}${r.url}${RESET}${tag}`);
}
});
Expand Down
Loading
Loading