-
Notifications
You must be signed in to change notification settings - Fork 12
feat: add boxel realm archive / restore + archived state in realm list (CS-11671)
#5365
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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}`, | ||
| ); | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,7 @@ const MUTUALLY_EXCLUSIVE_FLAGS_ERROR = | |
| export interface RealmSummary { | ||
| url: string; | ||
| hidden: boolean; | ||
| archived: boolean; | ||
| } | ||
|
|
||
| export interface ListRealmsResult { | ||
|
|
@@ -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 | ||
| * `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 = {}, | ||
|
|
@@ -92,6 +101,7 @@ export async function listRealms( | |
| let summaries: RealmSummary[] = accessibleUrls.map((url) => ({ | ||
| url, | ||
| hidden: !userRealmsSet.has(url), | ||
| archived: false, | ||
| })); | ||
|
|
||
| if (options.allAccessible) { | ||
|
|
@@ -102,6 +112,38 @@ export async function listRealms( | |
| summaries = summaries.filter((r) => !r.hidden); | ||
| } | ||
|
|
||
| if (options.includeArchived) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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 }; | ||
| } | ||
|
|
@@ -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( | ||
|
|
@@ -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}`); | ||
| } | ||
| }); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.