diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5b3f3b72c91..faca8e38f8a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -947,7 +947,11 @@ jobs: boxel-cli-test: name: Boxel CLI Tests needs: [change-check, test-web-assets] - if: needs.change-check.outputs.boxel-cli == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' + # Also run on realm-server changes: boxel-cli's integration tests cover + # the publish/unpublish/readiness-check HTTP contract the realm-server + # exposes, so a realm-server-only PR that drifts that contract (as in + # CS-11161) needs to fail here pre-merge rather than on main post-merge. + if: needs.change-check.outputs.boxel-cli == 'true' || needs.change-check.outputs.realm-server == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' runs-on: ubuntu-latest concurrency: group: boxel-cli-test-${{ github.head_ref || github.run_id }} diff --git a/packages/boxel-cli/src/commands/realm/index.ts b/packages/boxel-cli/src/commands/realm/index.ts index 236ee710d7e..fc6f6077e4d 100644 --- a/packages/boxel-cli/src/commands/realm/index.ts +++ b/packages/boxel-cli/src/commands/realm/index.ts @@ -4,11 +4,13 @@ import { registerCreateCommand } from './create'; import { registerHistoryCommand } from './history'; import { registerListCommand } from './list'; import { registerMilestoneCommand } from './milestone'; +import { registerPublishCommand } from './publish'; import { registerPullCommand } from './pull'; import { registerPushCommand } from './push'; import { registerRemoveCommand } from './remove'; import { registerStatusCommand } from './status'; import { registerSyncCommand } from './sync'; +import { registerUnpublishCommand } from './unpublish'; import { registerWaitForReadyCommand } from './wait-for-ready'; import { registerWatchCommand } from './watch'; @@ -22,11 +24,13 @@ export function registerRealmCommand(program: Command): void { registerHistoryCommand(realm); registerListCommand(realm); registerMilestoneCommand(realm); + registerPublishCommand(realm); registerPullCommand(realm); registerPushCommand(realm); registerRemoveCommand(realm); const sync = registerSyncCommand(realm); registerStatusCommand(sync); + registerUnpublishCommand(realm); registerWaitForReadyCommand(realm); registerWatchCommand(realm); } diff --git a/packages/boxel-cli/src/commands/realm/publish.ts b/packages/boxel-cli/src/commands/realm/publish.ts new file mode 100644 index 00000000000..82bd2fdd090 --- /dev/null +++ b/packages/boxel-cli/src/commands/realm/publish.ts @@ -0,0 +1,291 @@ +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 { unpublishRealm } from './unpublish'; +import { FG_CYAN, FG_GREEN, FG_RED, RESET } from '../../lib/colors'; + +const DEFAULT_TIMEOUT_MS = 300_000; +const READINESS_POLL_INTERVAL_MS = 1000; + +export interface PublishOptions { + /** Wait for the published realm to pass readiness check (default: true). */ + waitForReady?: boolean; + /** Readiness-poll timeout in milliseconds (default: 300_000). */ + timeoutMs?: number; + /** + * When the server returns 400/409 (e.g. an existing publication conflicts), + * unpublish the target URL first and retry once. Default: true. + */ + republish?: boolean; + profileManager?: ProfileManager; +} + +export interface PublishRealmResult { + publishedRealmURL: string; + publishedRealmId: string; + lastPublishedAt: string; + status: string; +} + +/** + * Publish a source realm to a published-realm URL. + * + * Speaks the contract documented at + * `packages/realm-server/handlers/handle-publish-realm.ts`: the server + * accepts the publish, returns `202 Accepted` with `status: "pending"`, + * and the client polls `//_readiness-check` until + * the realm is mounted and indexed. 200/201 are accepted too so this + * function survives any future move back to a synchronous handler. + */ +export async function publishRealm( + sourceRealmURL: string, + publishedRealmURL: string, + options: PublishOptions = {}, +): Promise { + let pm = options.profileManager ?? getProfileManager(); + let active = pm.getActiveProfile(); + if (!active) { + throw new Error(NO_ACTIVE_PROFILE_ERROR); + } + + let normalizedSource = ensureTrailingSlash(sourceRealmURL); + let normalizedPublished = ensureTrailingSlash(publishedRealmURL); + let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, ''); + + let response = await postPublish( + pm, + realmServerUrl, + normalizedSource, + normalizedPublished, + ); + + if ( + (response.status === 400 || response.status === 409) && + options.republish !== false + ) { + let conflictBody = await safeReadResponseText(response); + console.log( + `Publish returned ${response.status} (${conflictBody.slice(0, 200)}). Unpublishing and retrying.`, + ); + let unpublishResult = await unpublishRealm(normalizedPublished, { + profileManager: pm, + tolerateMissing: true, + }); + if (!unpublishResult.unpublished && !unpublishResult.notFound) { + throw new Error( + `Conflict on publish; unpublish-then-retry also failed: ${ + unpublishResult.error ?? 'unknown' + }`, + ); + } + response = await postPublish( + pm, + realmServerUrl, + normalizedSource, + normalizedPublished, + ); + } + + if ( + response.status !== 200 && + response.status !== 201 && + response.status !== 202 + ) { + let body = await safeReadResponseText(response); + throw new Error( + `Publish failed: HTTP ${response.status}: ${body.slice(0, 1000)}`, + ); + } + + let body = (await response.json()) as PublishResponseBody; + let attrs = body?.data?.attributes; + if (!attrs?.publishedRealmURL) { + throw new Error( + `Publish response missing data.attributes.publishedRealmURL: ${JSON.stringify( + body, + ).slice(0, 500)}`, + ); + } + + let result: PublishRealmResult = { + publishedRealmURL: ensureTrailingSlash(attrs.publishedRealmURL), + publishedRealmId: body.data.id, + lastPublishedAt: attrs.lastPublishedAt, + status: attrs.status, + }; + + if (options.waitForReady !== false) { + let timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + let realmToken: string | undefined; + try { + let serverToken = await pm.getOrRefreshServerToken(); + realmToken = await pm.fetchAndStoreRealmToken( + result.publishedRealmURL, + serverToken, + ); + } catch { + // The published realm is permission-public-read; fall through to + // poll without an Authorization header. + } + await waitForPublishedRealmReady( + result.publishedRealmURL, + realmToken, + timeoutMs, + ); + } + + return result; +} + +interface PublishResponseBody { + data: { + type: 'published_realm'; + id: string; + attributes: { + sourceRealmURL: string; + publishedRealmURL: string; + lastPublishedAt: string; + status: string; + }; + }; +} + +async function postPublish( + pm: ProfileManager, + realmServerUrl: string, + sourceRealmURL: string, + publishedRealmURL: string, +): Promise { + return pm.authedRealmServerFetch(`${realmServerUrl}/_publish-realm`, { + method: 'POST', + headers: { + Accept: 'application/vnd.api+json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ sourceRealmURL, publishedRealmURL }), + }); +} + +async function waitForPublishedRealmReady( + publishedRealmURL: string, + realmToken: string | undefined, + timeoutMs: number, +): Promise { + let readinessUrl = new URL('_readiness-check', publishedRealmURL).href; + let startedAt = Date.now(); + let lastError: string | undefined; + + while (Date.now() - startedAt < timeoutMs) { + try { + let headers: Record = { + Accept: 'application/vnd.api+json', + }; + if (realmToken) { + headers.Authorization = realmToken; + } + let response = await fetch(readinessUrl, { headers }); + if (response.ok) { + return; + } + lastError = `HTTP ${response.status}`; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + let remaining = timeoutMs - (Date.now() - startedAt); + if (remaining <= 0) break; + await new Promise((r) => + setTimeout(r, Math.min(READINESS_POLL_INTERVAL_MS, remaining)), + ); + } + + throw new Error( + `Timed out after ${timeoutMs}ms waiting for ${publishedRealmURL} to pass readiness check${ + lastError ? `: ${lastError}` : '' + }`, + ); +} + +async function safeReadResponseText(response: Response): Promise { + try { + return await response.text(); + } catch { + return ''; + } +} + +export interface PublishCliOptions { + // Commander exposes `--no-wait` / `--no-republish` on the positive + // keys (`wait` / `republish`), defaulting to `true` and flipping to + // `false` when the negated flag is passed. + wait?: boolean; + timeout?: number; + republish?: boolean; +} + +export function publishCliOptsToOptions( + opts: PublishCliOptions, +): PublishOptions { + return { + waitForReady: opts.wait !== false, + timeoutMs: opts.timeout, + republish: opts.republish !== false, + }; +} + +export function registerPublishCommand(realm: Command): void { + realm + .command('publish') + .description( + 'Publish a source realm to a published-realm URL, polling readiness until ready', + ) + .argument('', 'URL of the source realm to publish') + .argument( + '', + 'Public-facing URL the published copy will serve at', + ) + .option('--no-wait', 'Return as soon as the server accepts the publish') + .option( + '--timeout ', + `Readiness-poll timeout in milliseconds (default: ${DEFAULT_TIMEOUT_MS})`, + parseTimeoutOption, + ) + .option( + '--no-republish', + 'Do not auto-unpublish + retry when the server returns 400/409', + ) + .action( + async ( + sourceRealmURL: string, + publishedRealmURL: string, + opts: PublishCliOptions, + ) => { + try { + let result = await publishRealm( + sourceRealmURL, + publishedRealmURL, + publishCliOptsToOptions(opts), + ); + console.log( + `${FG_GREEN}Published:${RESET} ${FG_CYAN}${result.publishedRealmURL}${RESET}`, + ); + } catch (err) { + console.error( + `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + }, + ); +} + +function parseTimeoutOption(value: string): number { + let n = Number.parseInt(value, 10); + if (!Number.isFinite(n) || n < 0 || String(n) !== value.trim()) { + throw new Error('--timeout must be a non-negative integer (milliseconds).'); + } + return n; +} diff --git a/packages/boxel-cli/src/commands/realm/unpublish.ts b/packages/boxel-cli/src/commands/realm/unpublish.ts new file mode 100644 index 00000000000..7f16d6dae69 --- /dev/null +++ b/packages/boxel-cli/src/commands/realm/unpublish.ts @@ -0,0 +1,150 @@ +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 { FG_CYAN, FG_GREEN, FG_RED, RESET } from '../../lib/colors'; + +export interface UnpublishOptions { + /** + * When true, do not fail if the server reports the realm was already + * unpublished. Useful for cleanup paths that must be idempotent (e.g. + * a PR-close hook that runs even if a previous close already unpublished). + * Default: false. + */ + tolerateMissing?: boolean; + profileManager?: ProfileManager; +} + +export interface UnpublishRealmResult { + publishedRealmURL: string; + unpublished: boolean; + notFound?: boolean; + error?: string; +} + +/** + * Unpublish a published realm. Mirrors `boxel realm publish`'s contract + * with `/_unpublish-realm`. + * + * The realm-server returns 200 on success and 422 with a "not found" body + * when the URL isn't currently published. We special-case the latter (and + * 404, defensively) so cleanup callers can run unconditionally. + */ +export async function unpublishRealm( + publishedRealmURL: string, + options: UnpublishOptions = {}, +): Promise { + let normalized = ensureTrailingSlash(publishedRealmURL); + let pm = options.profileManager ?? getProfileManager(); + let active = pm.getActiveProfile(); + if (!active) { + return { + publishedRealmURL: normalized, + unpublished: false, + error: NO_ACTIVE_PROFILE_ERROR, + }; + } + + let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, ''); + + let response: Response; + try { + response = await pm.authedRealmServerFetch( + `${realmServerUrl}/_unpublish-realm`, + { + method: 'POST', + headers: { + Accept: 'application/vnd.api+json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ publishedRealmURL: normalized }), + }, + ); + } catch (err) { + return { + publishedRealmURL: normalized, + unpublished: false, + error: `Failed to reach realm server: ${ + err instanceof Error ? err.message : String(err) + }`, + }; + } + + if (response.ok) { + return { publishedRealmURL: normalized, unpublished: true }; + } + + let body = await safeReadResponseText(response); + let looksLikeNotFound = + response.status === 404 || + (response.status === 422 && /not found/i.test(body)); + + if (looksLikeNotFound) { + if (options.tolerateMissing) { + return { + publishedRealmURL: normalized, + unpublished: false, + notFound: true, + }; + } + return { + publishedRealmURL: normalized, + unpublished: false, + notFound: true, + error: `Published realm ${normalized} is not currently published`, + }; + } + + return { + publishedRealmURL: normalized, + unpublished: false, + error: `Realm server returned ${response.status}: ${body.slice(0, 500)}`, + }; +} + +async function safeReadResponseText(response: Response): Promise { + try { + return await response.text(); + } catch { + return ''; + } +} + +interface UnpublishCliOptions { + tolerateMissing?: boolean; +} + +export function registerUnpublishCommand(realm: Command): void { + realm + .command('unpublish') + .description('Unpublish a published realm by its public-facing URL') + .argument('', 'URL of the published realm to remove') + .option( + '--tolerate-missing', + 'Exit successfully when the realm is already unpublished', + ) + .action(async (publishedRealmURL: string, opts: UnpublishCliOptions) => { + let result = await unpublishRealm(publishedRealmURL, { + tolerateMissing: opts.tolerateMissing === true, + }); + + if (result.error) { + console.error(`${FG_RED}Error:${RESET} ${result.error}`); + process.exit(1); + } + + if (result.notFound) { + console.log( + `Already unpublished: ${FG_CYAN}${result.publishedRealmURL}${RESET}`, + ); + return; + } + + console.log( + `${FG_GREEN}Unpublished:${RESET} ${FG_CYAN}${result.publishedRealmURL}${RESET}`, + ); + }); +} diff --git a/packages/boxel-cli/tests/commands/realm-publish-cli.test.ts b/packages/boxel-cli/tests/commands/realm-publish-cli.test.ts new file mode 100644 index 00000000000..4c92a339042 --- /dev/null +++ b/packages/boxel-cli/tests/commands/realm-publish-cli.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { Command } from 'commander'; +import { + publishCliOptsToOptions, + registerPublishCommand, +} from '../../src/commands/realm/publish'; + +// Regression test for the negated-flag bug fixed in CS-11161. Commander +// exposes `--no-foo` options on the positive key (`foo`) defaulting to +// `true`, so the CLI shim must read `opts.wait` / `opts.republish` — not +// `opts.noWait` / `opts.noRepublish`. The integration tests exercise the +// programmatic `publishRealm(...)` API only and missed this entirely. + +function parsePublishFlags(extra: string[]): { + capturedOpts: Record | null; + capturedArgs: [string, string] | null; +} { + let capturedOpts: Record | null = null; + let capturedArgs: [string, string] | null = null; + + const program = new Command().exitOverride(); + const realm = program.command('realm'); + registerPublishCommand(realm); + const publishCmd = realm.commands.find((c) => c.name() === 'publish'); + if (!publishCmd) { + throw new Error('publish subcommand not registered'); + } + + // Replace the action so we capture the parsed opts without executing + // publishRealm() (which would need a real realm-server). + publishCmd.action((sourceUrl: string, publishedUrl: string, opts: object) => { + capturedOpts = { ...opts } as Record; + capturedArgs = [sourceUrl, publishedUrl]; + }); + + program.parse( + [ + 'realm', + 'publish', + 'http://src.localhost/', + 'http://pub.localhost/', + ...extra, + ], + { from: 'user' }, + ); + + return { capturedOpts, capturedArgs }; +} + +describe('boxel realm publish CLI flags', () => { + it('with no flags, opts.wait and opts.republish default to true', () => { + const { capturedOpts, capturedArgs } = parsePublishFlags([]); + expect(capturedOpts).not.toBeNull(); + expect(capturedOpts!.wait).toBe(true); + expect(capturedOpts!.republish).toBe(true); + expect(capturedArgs).toEqual([ + 'http://src.localhost/', + 'http://pub.localhost/', + ]); + }); + + it('--no-wait flips opts.wait to false (not opts.noWait)', () => { + const { capturedOpts } = parsePublishFlags(['--no-wait']); + expect(capturedOpts!.wait).toBe(false); + expect(capturedOpts!.republish).toBe(true); + // Commander does not synthesize a noWait key — guarding against + // a future regression where someone reintroduces opts.noWait. + expect('noWait' in capturedOpts!).toBe(false); + }); + + it('--no-republish flips opts.republish to false (not opts.noRepublish)', () => { + const { capturedOpts } = parsePublishFlags(['--no-republish']); + expect(capturedOpts!.republish).toBe(false); + expect(capturedOpts!.wait).toBe(true); + expect('noRepublish' in capturedOpts!).toBe(false); + }); + + it('--timeout parses to opts.timeout as a number', () => { + const { capturedOpts } = parsePublishFlags(['--timeout', '60000']); + expect(capturedOpts!.timeout).toBe(60000); + }); +}); + +describe('publishCliOptsToOptions translation', () => { + it('translates empty opts to defaults that preserve current behavior', () => { + expect(publishCliOptsToOptions({})).toEqual({ + waitForReady: true, + timeoutMs: undefined, + republish: true, + }); + }); + + it('translates --no-wait to waitForReady: false', () => { + expect(publishCliOptsToOptions({ wait: false }).waitForReady).toBe(false); + }); + + it('translates --no-republish to republish: false', () => { + expect(publishCliOptsToOptions({ republish: false }).republish).toBe(false); + }); + + it('propagates --timeout into timeoutMs', () => { + expect(publishCliOptsToOptions({ timeout: 12345 }).timeoutMs).toBe(12345); + }); +}); diff --git a/packages/boxel-cli/tests/integration/realm-publish.test.ts b/packages/boxel-cli/tests/integration/realm-publish.test.ts new file mode 100644 index 00000000000..6554a9bd20f --- /dev/null +++ b/packages/boxel-cli/tests/integration/realm-publish.test.ts @@ -0,0 +1,154 @@ +import '../helpers/setup-realm-server'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createRealm } from '../../src/commands/realm/create'; +import { publishRealm } from '../../src/commands/realm/publish'; +import { unpublishRealm } from '../../src/commands/realm/unpublish'; +import { + startTestRealmServer, + stopTestRealmServer, + createTestProfileDir, + setupTestProfile, + uniqueRealmName, + TEST_REALM_SERVER_URL, +} from '../helpers/integration'; +import type { ProfileManager } from '../../src/lib/profile-manager'; + +let profileManager: ProfileManager; +let cleanup: () => void; + +beforeAll(async () => { + await startTestRealmServer(); + let testProfile = createTestProfileDir(); + profileManager = testProfile.profileManager; + cleanup = testProfile.cleanup; + await setupTestProfile(profileManager); +}); + +afterAll(async () => { + cleanup?.(); + await stopTestRealmServer(); +}); + +async function createPublishableSource(): Promise { + let name = uniqueRealmName(); + let result = await createRealm(name, `Source ${name}`, { profileManager }); + return result.realmUrl; +} + +function uniquePublishedUrl(): string { + // Realm server enforces `domainsForPublishedRealms` (typically + // `['localhost']` in tests) — use a *.localhost subdomain so the URL + // passes the publish-handler's allow-list. The hostname resolves to + // 127.0.0.1 via RFC 6761, and the realm-server listens on the same + // port for any host, so fetch() reaches it. + let port = new URL(TEST_REALM_SERVER_URL).port; + return `http://published-${uniqueRealmName()}.localhost:${port}/`; +} + +describe('realm publish (integration)', () => { + it('accepts the 202 + status:pending response and polls readiness', async () => { + let sourceUrl = await createPublishableSource(); + let publishedUrl = uniquePublishedUrl(); + + let result = await publishRealm(sourceUrl, publishedUrl, { + profileManager, + timeoutMs: 60_000, + }); + + expect(result.publishedRealmURL).toBe(publishedUrl); + expect(result.publishedRealmId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + expect(result.lastPublishedAt).toBeTruthy(); + // The server signals async indexing with status:'pending'. publishRealm() + // must surface that value rather than failing the call — earlier + // boxel-home CI broke when callers required 200/201 and got a 202. + expect(result.status).toBe('pending'); + }, 90_000); + + it('returns without waiting when waitForReady is false', async () => { + let sourceUrl = await createPublishableSource(); + let publishedUrl = uniquePublishedUrl(); + + let result = await publishRealm(sourceUrl, publishedUrl, { + profileManager, + waitForReady: false, + }); + + expect(result.publishedRealmURL).toBe(publishedUrl); + expect(result.status).toBe('pending'); + }, 60_000); + + it('republishes by unpublishing first when the target URL already exists', async () => { + let sourceUrl = await createPublishableSource(); + let publishedUrl = uniquePublishedUrl(); + + await publishRealm(sourceUrl, publishedUrl, { + profileManager, + waitForReady: false, + }); + + // Republishing the same URL must succeed via the action's auto-recovery + // path (unpublish-then-retry on 400/409). This mirrors what + // boxel-home's PR preview flow needs across successive PR pushes. + let republished = await publishRealm(sourceUrl, publishedUrl, { + profileManager, + waitForReady: false, + }); + + expect(republished.publishedRealmURL).toBe(publishedUrl); + }, 90_000); + + it('throws a useful error when the source realm does not exist', async () => { + let bogusSource = `${TEST_REALM_SERVER_URL}/does-not-exist-${uniqueRealmName()}/`; + let publishedUrl = uniquePublishedUrl(); + + await expect( + publishRealm(bogusSource, publishedUrl, { + profileManager, + waitForReady: false, + republish: false, + }), + ).rejects.toThrow(/Publish failed: HTTP/); + }, 30_000); +}); + +describe('realm unpublish (integration)', () => { + it('unpublishes a previously published realm', async () => { + let sourceUrl = await createPublishableSource(); + let publishedUrl = uniquePublishedUrl(); + + await publishRealm(sourceUrl, publishedUrl, { + profileManager, + waitForReady: false, + }); + + let result = await unpublishRealm(publishedUrl, { profileManager }); + + expect(result.unpublished).toBe(true); + expect(result.error).toBeUndefined(); + }, 60_000); + + it('treats a missing realm as success when tolerateMissing is set', async () => { + let bogusUrl = `${TEST_REALM_SERVER_URL}/never-published-${uniqueRealmName()}/`; + + let result = await unpublishRealm(bogusUrl, { + profileManager, + tolerateMissing: true, + }); + + expect(result.unpublished).toBe(false); + expect(result.notFound).toBe(true); + expect(result.error).toBeUndefined(); + }, 30_000); + + it('reports an error for a missing realm when tolerateMissing is unset', async () => { + let bogusUrl = `${TEST_REALM_SERVER_URL}/never-published-${uniqueRealmName()}/`; + + let result = await unpublishRealm(bogusUrl, { profileManager }); + + expect(result.unpublished).toBe(false); + expect(result.notFound).toBe(true); + expect(result.error).toMatch(/not currently published/); + }, 30_000); +});