@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/dist/index.js +73 -73
- package/package.json +1 -1
- package/src/commands/realm/index.ts +4 -0
- package/src/commands/realm/publish.ts +291 -0
- package/src/commands/realm/unpublish.ts +150 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|