@cardstack/boxel-cli 0.2.0-unstable.327 → 0.2.0-unstable.425

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardstack/boxel-cli",
3
- "version": "0.2.0-unstable.327",
3
+ "version": "0.2.0-unstable.425",
4
4
  "license": "MIT",
5
5
  "description": "CLI tools for Boxel workspace management",
6
6
  "main": "./dist/index.js",
@@ -4,11 +4,13 @@ import { registerCreateCommand } from './create';
4
4
  import { registerHistoryCommand } from './history';
5
5
  import { registerListCommand } from './list';
6
6
  import { registerMilestoneCommand } from './milestone';
7
+ import { registerPublishCommand } from './publish';
7
8
  import { registerPullCommand } from './pull';
8
9
  import { registerPushCommand } from './push';
9
10
  import { registerRemoveCommand } from './remove';
10
11
  import { registerStatusCommand } from './status';
11
12
  import { registerSyncCommand } from './sync';
13
+ import { registerUnpublishCommand } from './unpublish';
12
14
  import { registerWaitForReadyCommand } from './wait-for-ready';
13
15
  import { registerWatchCommand } from './watch';
14
16
 
@@ -22,11 +24,13 @@ export function registerRealmCommand(program: Command): void {
22
24
  registerHistoryCommand(realm);
23
25
  registerListCommand(realm);
24
26
  registerMilestoneCommand(realm);
27
+ registerPublishCommand(realm);
25
28
  registerPullCommand(realm);
26
29
  registerPushCommand(realm);
27
30
  registerRemoveCommand(realm);
28
31
  const sync = registerSyncCommand(realm);
29
32
  registerStatusCommand(sync);
33
+ registerUnpublishCommand(realm);
30
34
  registerWaitForReadyCommand(realm);
31
35
  registerWatchCommand(realm);
32
36
  }
@@ -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,0 +1,150 @@
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 { FG_CYAN, FG_GREEN, FG_RED, RESET } from '../../lib/colors';
9
+
10
+ export interface UnpublishOptions {
11
+ /**
12
+ * When true, do not fail if the server reports the realm was already
13
+ * unpublished. Useful for cleanup paths that must be idempotent (e.g.
14
+ * a PR-close hook that runs even if a previous close already unpublished).
15
+ * Default: false.
16
+ */
17
+ tolerateMissing?: boolean;
18
+ profileManager?: ProfileManager;
19
+ }
20
+
21
+ export interface UnpublishRealmResult {
22
+ publishedRealmURL: string;
23
+ unpublished: boolean;
24
+ notFound?: boolean;
25
+ error?: string;
26
+ }
27
+
28
+ /**
29
+ * Unpublish a published realm. Mirrors `boxel realm publish`'s contract
30
+ * with `/_unpublish-realm`.
31
+ *
32
+ * The realm-server returns 200 on success and 422 with a "not found" body
33
+ * when the URL isn't currently published. We special-case the latter (and
34
+ * 404, defensively) so cleanup callers can run unconditionally.
35
+ */
36
+ export async function unpublishRealm(
37
+ publishedRealmURL: string,
38
+ options: UnpublishOptions = {},
39
+ ): Promise<UnpublishRealmResult> {
40
+ let normalized = ensureTrailingSlash(publishedRealmURL);
41
+ let pm = options.profileManager ?? getProfileManager();
42
+ let active = pm.getActiveProfile();
43
+ if (!active) {
44
+ return {
45
+ publishedRealmURL: normalized,
46
+ unpublished: false,
47
+ error: NO_ACTIVE_PROFILE_ERROR,
48
+ };
49
+ }
50
+
51
+ let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, '');
52
+
53
+ let response: Response;
54
+ try {
55
+ response = await pm.authedRealmServerFetch(
56
+ `${realmServerUrl}/_unpublish-realm`,
57
+ {
58
+ method: 'POST',
59
+ headers: {
60
+ Accept: 'application/vnd.api+json',
61
+ 'Content-Type': 'application/json',
62
+ },
63
+ body: JSON.stringify({ publishedRealmURL: normalized }),
64
+ },
65
+ );
66
+ } catch (err) {
67
+ return {
68
+ publishedRealmURL: normalized,
69
+ unpublished: false,
70
+ error: `Failed to reach realm server: ${
71
+ err instanceof Error ? err.message : String(err)
72
+ }`,
73
+ };
74
+ }
75
+
76
+ if (response.ok) {
77
+ return { publishedRealmURL: normalized, unpublished: true };
78
+ }
79
+
80
+ let body = await safeReadResponseText(response);
81
+ let looksLikeNotFound =
82
+ response.status === 404 ||
83
+ (response.status === 422 && /not found/i.test(body));
84
+
85
+ if (looksLikeNotFound) {
86
+ if (options.tolerateMissing) {
87
+ return {
88
+ publishedRealmURL: normalized,
89
+ unpublished: false,
90
+ notFound: true,
91
+ };
92
+ }
93
+ return {
94
+ publishedRealmURL: normalized,
95
+ unpublished: false,
96
+ notFound: true,
97
+ error: `Published realm ${normalized} is not currently published`,
98
+ };
99
+ }
100
+
101
+ return {
102
+ publishedRealmURL: normalized,
103
+ unpublished: false,
104
+ error: `Realm server returned ${response.status}: ${body.slice(0, 500)}`,
105
+ };
106
+ }
107
+
108
+ async function safeReadResponseText(response: Response): Promise<string> {
109
+ try {
110
+ return await response.text();
111
+ } catch {
112
+ return '<no response body>';
113
+ }
114
+ }
115
+
116
+ interface UnpublishCliOptions {
117
+ tolerateMissing?: boolean;
118
+ }
119
+
120
+ export function registerUnpublishCommand(realm: Command): void {
121
+ realm
122
+ .command('unpublish')
123
+ .description('Unpublish a published realm by its public-facing URL')
124
+ .argument('<published-realm-url>', 'URL of the published realm to remove')
125
+ .option(
126
+ '--tolerate-missing',
127
+ 'Exit successfully when the realm is already unpublished',
128
+ )
129
+ .action(async (publishedRealmURL: string, opts: UnpublishCliOptions) => {
130
+ let result = await unpublishRealm(publishedRealmURL, {
131
+ tolerateMissing: opts.tolerateMissing === true,
132
+ });
133
+
134
+ if (result.error) {
135
+ console.error(`${FG_RED}Error:${RESET} ${result.error}`);
136
+ process.exit(1);
137
+ }
138
+
139
+ if (result.notFound) {
140
+ console.log(
141
+ `Already unpublished: ${FG_CYAN}${result.publishedRealmURL}${RESET}`,
142
+ );
143
+ return;
144
+ }
145
+
146
+ console.log(
147
+ `${FG_GREEN}Unpublished:${RESET} ${FG_CYAN}${result.publishedRealmURL}${RESET}`,
148
+ );
149
+ });
150
+ }