Skip to content

Commit 473dfea

Browse files
authored
feat(boxel-cli): Add publish/unpublish (#4851)
1 parent fb7fe83 commit 473dfea

6 files changed

Lines changed: 708 additions & 1 deletion

File tree

.github/workflows/ci.yaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -947,7 +947,11 @@ jobs:
947947
boxel-cli-test:
948948
name: Boxel CLI Tests
949949
needs: [change-check, test-web-assets]
950-
if: needs.change-check.outputs.boxel-cli == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true'
950+
# Also run on realm-server changes: boxel-cli's integration tests cover
951+
# the publish/unpublish/readiness-check HTTP contract the realm-server
952+
# exposes, so a realm-server-only PR that drifts that contract (as in
953+
# CS-11161) needs to fail here pre-merge rather than on main post-merge.
954+
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'
951955
runs-on: ubuntu-latest
952956
concurrency:
953957
group: boxel-cli-test-${{ github.head_ref || github.run_id }}

packages/boxel-cli/src/commands/realm/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import { registerCreateCommand } from './create';
44
import { registerHistoryCommand } from './history';
55
import { registerListCommand } from './list';
66
import { registerMilestoneCommand } from './milestone';
7+
import { registerPublishCommand } from './publish';
78
import { registerPullCommand } from './pull';
89
import { registerPushCommand } from './push';
910
import { registerRemoveCommand } from './remove';
1011
import { registerStatusCommand } from './status';
1112
import { registerSyncCommand } from './sync';
13+
import { registerUnpublishCommand } from './unpublish';
1214
import { registerWaitForReadyCommand } from './wait-for-ready';
1315
import { registerWatchCommand } from './watch';
1416

@@ -22,11 +24,13 @@ export function registerRealmCommand(program: Command): void {
2224
registerHistoryCommand(realm);
2325
registerListCommand(realm);
2426
registerMilestoneCommand(realm);
27+
registerPublishCommand(realm);
2528
registerPullCommand(realm);
2629
registerPushCommand(realm);
2730
registerRemoveCommand(realm);
2831
const sync = registerSyncCommand(realm);
2932
registerStatusCommand(sync);
33+
registerUnpublishCommand(realm);
3034
registerWaitForReadyCommand(realm);
3135
registerWatchCommand(realm);
3236
}
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
import type { Command } from 'commander';
2+
import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
3+
import {
4+
getProfileManager,
5+
NO_ACTIVE_PROFILE_ERROR,
6+
type ProfileManager,
7+
} from '../../lib/profile-manager';
8+
import { unpublishRealm } from './unpublish';
9+
import { FG_CYAN, FG_GREEN, FG_RED, RESET } from '../../lib/colors';
10+
11+
const DEFAULT_TIMEOUT_MS = 300_000;
12+
const READINESS_POLL_INTERVAL_MS = 1000;
13+
14+
export interface PublishOptions {
15+
/** Wait for the published realm to pass readiness check (default: true). */
16+
waitForReady?: boolean;
17+
/** Readiness-poll timeout in milliseconds (default: 300_000). */
18+
timeoutMs?: number;
19+
/**
20+
* When the server returns 400/409 (e.g. an existing publication conflicts),
21+
* unpublish the target URL first and retry once. Default: true.
22+
*/
23+
republish?: boolean;
24+
profileManager?: ProfileManager;
25+
}
26+
27+
export interface PublishRealmResult {
28+
publishedRealmURL: string;
29+
publishedRealmId: string;
30+
lastPublishedAt: string;
31+
status: string;
32+
}
33+
34+
/**
35+
* Publish a source realm to a published-realm URL.
36+
*
37+
* Speaks the contract documented at
38+
* `packages/realm-server/handlers/handle-publish-realm.ts`: the server
39+
* accepts the publish, returns `202 Accepted` with `status: "pending"`,
40+
* and the client polls `/<publishedRealmURL>/_readiness-check` until
41+
* the realm is mounted and indexed. 200/201 are accepted too so this
42+
* function survives any future move back to a synchronous handler.
43+
*/
44+
export async function publishRealm(
45+
sourceRealmURL: string,
46+
publishedRealmURL: string,
47+
options: PublishOptions = {},
48+
): Promise<PublishRealmResult> {
49+
let pm = options.profileManager ?? getProfileManager();
50+
let active = pm.getActiveProfile();
51+
if (!active) {
52+
throw new Error(NO_ACTIVE_PROFILE_ERROR);
53+
}
54+
55+
let normalizedSource = ensureTrailingSlash(sourceRealmURL);
56+
let normalizedPublished = ensureTrailingSlash(publishedRealmURL);
57+
let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, '');
58+
59+
let response = await postPublish(
60+
pm,
61+
realmServerUrl,
62+
normalizedSource,
63+
normalizedPublished,
64+
);
65+
66+
if (
67+
(response.status === 400 || response.status === 409) &&
68+
options.republish !== false
69+
) {
70+
let conflictBody = await safeReadResponseText(response);
71+
console.log(
72+
`Publish returned ${response.status} (${conflictBody.slice(0, 200)}). Unpublishing and retrying.`,
73+
);
74+
let unpublishResult = await unpublishRealm(normalizedPublished, {
75+
profileManager: pm,
76+
tolerateMissing: true,
77+
});
78+
if (!unpublishResult.unpublished && !unpublishResult.notFound) {
79+
throw new Error(
80+
`Conflict on publish; unpublish-then-retry also failed: ${
81+
unpublishResult.error ?? 'unknown'
82+
}`,
83+
);
84+
}
85+
response = await postPublish(
86+
pm,
87+
realmServerUrl,
88+
normalizedSource,
89+
normalizedPublished,
90+
);
91+
}
92+
93+
if (
94+
response.status !== 200 &&
95+
response.status !== 201 &&
96+
response.status !== 202
97+
) {
98+
let body = await safeReadResponseText(response);
99+
throw new Error(
100+
`Publish failed: HTTP ${response.status}: ${body.slice(0, 1000)}`,
101+
);
102+
}
103+
104+
let body = (await response.json()) as PublishResponseBody;
105+
let attrs = body?.data?.attributes;
106+
if (!attrs?.publishedRealmURL) {
107+
throw new Error(
108+
`Publish response missing data.attributes.publishedRealmURL: ${JSON.stringify(
109+
body,
110+
).slice(0, 500)}`,
111+
);
112+
}
113+
114+
let result: PublishRealmResult = {
115+
publishedRealmURL: ensureTrailingSlash(attrs.publishedRealmURL),
116+
publishedRealmId: body.data.id,
117+
lastPublishedAt: attrs.lastPublishedAt,
118+
status: attrs.status,
119+
};
120+
121+
if (options.waitForReady !== false) {
122+
let timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
123+
let realmToken: string | undefined;
124+
try {
125+
let serverToken = await pm.getOrRefreshServerToken();
126+
realmToken = await pm.fetchAndStoreRealmToken(
127+
result.publishedRealmURL,
128+
serverToken,
129+
);
130+
} catch {
131+
// The published realm is permission-public-read; fall through to
132+
// poll without an Authorization header.
133+
}
134+
await waitForPublishedRealmReady(
135+
result.publishedRealmURL,
136+
realmToken,
137+
timeoutMs,
138+
);
139+
}
140+
141+
return result;
142+
}
143+
144+
interface PublishResponseBody {
145+
data: {
146+
type: 'published_realm';
147+
id: string;
148+
attributes: {
149+
sourceRealmURL: string;
150+
publishedRealmURL: string;
151+
lastPublishedAt: string;
152+
status: string;
153+
};
154+
};
155+
}
156+
157+
async function postPublish(
158+
pm: ProfileManager,
159+
realmServerUrl: string,
160+
sourceRealmURL: string,
161+
publishedRealmURL: string,
162+
): Promise<Response> {
163+
return pm.authedRealmServerFetch(`${realmServerUrl}/_publish-realm`, {
164+
method: 'POST',
165+
headers: {
166+
Accept: 'application/vnd.api+json',
167+
'Content-Type': 'application/json',
168+
},
169+
body: JSON.stringify({ sourceRealmURL, publishedRealmURL }),
170+
});
171+
}
172+
173+
async function waitForPublishedRealmReady(
174+
publishedRealmURL: string,
175+
realmToken: string | undefined,
176+
timeoutMs: number,
177+
): Promise<void> {
178+
let readinessUrl = new URL('_readiness-check', publishedRealmURL).href;
179+
let startedAt = Date.now();
180+
let lastError: string | undefined;
181+
182+
while (Date.now() - startedAt < timeoutMs) {
183+
try {
184+
let headers: Record<string, string> = {
185+
Accept: 'application/vnd.api+json',
186+
};
187+
if (realmToken) {
188+
headers.Authorization = realmToken;
189+
}
190+
let response = await fetch(readinessUrl, { headers });
191+
if (response.ok) {
192+
return;
193+
}
194+
lastError = `HTTP ${response.status}`;
195+
} catch (error) {
196+
lastError = error instanceof Error ? error.message : String(error);
197+
}
198+
let remaining = timeoutMs - (Date.now() - startedAt);
199+
if (remaining <= 0) break;
200+
await new Promise((r) =>
201+
setTimeout(r, Math.min(READINESS_POLL_INTERVAL_MS, remaining)),
202+
);
203+
}
204+
205+
throw new Error(
206+
`Timed out after ${timeoutMs}ms waiting for ${publishedRealmURL} to pass readiness check${
207+
lastError ? `: ${lastError}` : ''
208+
}`,
209+
);
210+
}
211+
212+
async function safeReadResponseText(response: Response): Promise<string> {
213+
try {
214+
return await response.text();
215+
} catch {
216+
return '<no response body>';
217+
}
218+
}
219+
220+
export interface PublishCliOptions {
221+
// Commander exposes `--no-wait` / `--no-republish` on the positive
222+
// keys (`wait` / `republish`), defaulting to `true` and flipping to
223+
// `false` when the negated flag is passed.
224+
wait?: boolean;
225+
timeout?: number;
226+
republish?: boolean;
227+
}
228+
229+
export function publishCliOptsToOptions(
230+
opts: PublishCliOptions,
231+
): PublishOptions {
232+
return {
233+
waitForReady: opts.wait !== false,
234+
timeoutMs: opts.timeout,
235+
republish: opts.republish !== false,
236+
};
237+
}
238+
239+
export function registerPublishCommand(realm: Command): void {
240+
realm
241+
.command('publish')
242+
.description(
243+
'Publish a source realm to a published-realm URL, polling readiness until ready',
244+
)
245+
.argument('<source-realm-url>', 'URL of the source realm to publish')
246+
.argument(
247+
'<published-realm-url>',
248+
'Public-facing URL the published copy will serve at',
249+
)
250+
.option('--no-wait', 'Return as soon as the server accepts the publish')
251+
.option(
252+
'--timeout <ms>',
253+
`Readiness-poll timeout in milliseconds (default: ${DEFAULT_TIMEOUT_MS})`,
254+
parseTimeoutOption,
255+
)
256+
.option(
257+
'--no-republish',
258+
'Do not auto-unpublish + retry when the server returns 400/409',
259+
)
260+
.action(
261+
async (
262+
sourceRealmURL: string,
263+
publishedRealmURL: string,
264+
opts: PublishCliOptions,
265+
) => {
266+
try {
267+
let result = await publishRealm(
268+
sourceRealmURL,
269+
publishedRealmURL,
270+
publishCliOptsToOptions(opts),
271+
);
272+
console.log(
273+
`${FG_GREEN}Published:${RESET} ${FG_CYAN}${result.publishedRealmURL}${RESET}`,
274+
);
275+
} catch (err) {
276+
console.error(
277+
`${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
278+
);
279+
process.exit(1);
280+
}
281+
},
282+
);
283+
}
284+
285+
function parseTimeoutOption(value: string): number {
286+
let n = Number.parseInt(value, 10);
287+
if (!Number.isFinite(n) || n < 0 || String(n) !== value.trim()) {
288+
throw new Error('--timeout must be a non-negative integer (milliseconds).');
289+
}
290+
return n;
291+
}

0 commit comments

Comments
 (0)