@cardstack/boxel-cli 0.2.0-unstable.327 → 0.2.0-unstable.446
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 +89 -89
- package/package.json +3 -3
- package/src/commands/profile.ts +24 -24
- 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/src/lib/auth.ts +47 -5
- package/src/lib/profile-manager.ts +235 -109
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cardstack/boxel-cli",
|
|
3
|
-
"version": "0.2.0-unstable.
|
|
3
|
+
"version": "0.2.0-unstable.446",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "CLI tools for Boxel workspace management",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -54,8 +54,8 @@
|
|
|
54
54
|
"vite": "^6.3.2",
|
|
55
55
|
"vitest": "^2.1.9",
|
|
56
56
|
"@cardstack/postgres": "0.0.0",
|
|
57
|
-
"@cardstack/
|
|
58
|
-
"@cardstack/
|
|
57
|
+
"@cardstack/runtime-common": "1.0.0",
|
|
58
|
+
"@cardstack/local-types": "0.0.0"
|
|
59
59
|
},
|
|
60
60
|
"publishConfig": {
|
|
61
61
|
"access": "public",
|
package/src/commands/profile.ts
CHANGED
|
@@ -80,7 +80,7 @@ function validateUrl(input: string, label: string): string {
|
|
|
80
80
|
|
|
81
81
|
// Matches scripts/env-slug.sh: lowercase, "/" -> "-", strip chars outside
|
|
82
82
|
// [a-z0-9-], collapse runs of "-", trim leading/trailing "-".
|
|
83
|
-
function computeEnvSlug(name: string): string {
|
|
83
|
+
export function computeEnvSlug(name: string): string {
|
|
84
84
|
return name
|
|
85
85
|
.toLowerCase()
|
|
86
86
|
.replace(/\//g, '-')
|
|
@@ -91,7 +91,7 @@ function computeEnvSlug(name: string): string {
|
|
|
91
91
|
|
|
92
92
|
// Derive URLs from BOXEL_ENVIRONMENT using the same ".${slug}.localhost"
|
|
93
93
|
// pattern that mise-tasks/lib/env-vars.sh produces for env-mode local dev.
|
|
94
|
-
function resolveBoxelEnvironment(): EnvironmentDefaults | null {
|
|
94
|
+
export function resolveBoxelEnvironment(): EnvironmentDefaults | null {
|
|
95
95
|
const raw = process.env.BOXEL_ENVIRONMENT;
|
|
96
96
|
if (!raw || !raw.trim()) return null;
|
|
97
97
|
const slug = computeEnvSlug(raw);
|
|
@@ -458,14 +458,28 @@ async function addProfileNonInteractive(
|
|
|
458
458
|
process.exit(1);
|
|
459
459
|
}
|
|
460
460
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
461
|
+
const isUpdate = Boolean(manager.getProfile(matrixId));
|
|
462
|
+
|
|
463
|
+
// addProfile performs a real matrixLogin and persists the resulting
|
|
464
|
+
// access token (the password never lands on disk). It also handles the
|
|
465
|
+
// create-vs-reauth split uniformly: re-running it on an existing profile
|
|
466
|
+
// refreshes the stored token while preserving cached realm tokens.
|
|
467
|
+
try {
|
|
468
|
+
await manager.addProfile(
|
|
469
|
+
matrixId,
|
|
470
|
+
password,
|
|
471
|
+
displayName,
|
|
472
|
+
matrixUrl,
|
|
473
|
+
realmServerUrl,
|
|
464
474
|
);
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
475
|
+
} catch (err) {
|
|
476
|
+
console.error(
|
|
477
|
+
`${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
|
|
478
|
+
);
|
|
479
|
+
process.exit(1);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (isUpdate) {
|
|
469
483
|
if (matrixUrl || realmServerUrl) {
|
|
470
484
|
const urlsChanged = manager.updateUrls(matrixId, {
|
|
471
485
|
matrixUrl,
|
|
@@ -483,20 +497,6 @@ async function addProfileNonInteractive(
|
|
|
483
497
|
return;
|
|
484
498
|
}
|
|
485
499
|
|
|
486
|
-
try {
|
|
487
|
-
await manager.addProfile(
|
|
488
|
-
matrixId,
|
|
489
|
-
password,
|
|
490
|
-
displayName,
|
|
491
|
-
matrixUrl,
|
|
492
|
-
realmServerUrl,
|
|
493
|
-
);
|
|
494
|
-
} catch (err) {
|
|
495
|
-
console.error(
|
|
496
|
-
`${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
|
|
497
|
-
);
|
|
498
|
-
process.exit(1);
|
|
499
|
-
}
|
|
500
500
|
console.log(
|
|
501
501
|
`${FG_GREEN}\u2713${RESET} Profile created: ${formatProfileBadge(matrixId)}`,
|
|
502
502
|
);
|
|
@@ -538,7 +538,7 @@ async function migrateFromEnv(manager: ProfileManager): Promise<void> {
|
|
|
538
538
|
);
|
|
539
539
|
} else {
|
|
540
540
|
console.log(
|
|
541
|
-
`${
|
|
541
|
+
`${FG_GREEN}\u2713${RESET} Refreshed profile: ${formatProfileBadge(result.profileId)}`,
|
|
542
542
|
);
|
|
543
543
|
console.log(
|
|
544
544
|
`\n${DIM}Use 'boxel profile add -u ${result.profileId} -p <password>' to update other fields.${RESET}`,
|
|
@@ -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
|
+
}
|
package/src/lib/auth.ts
CHANGED
|
@@ -7,6 +7,17 @@ export interface MatrixAuth {
|
|
|
7
7
|
|
|
8
8
|
export type RealmTokens = Record<string, string>;
|
|
9
9
|
|
|
10
|
+
// Thrown when Matrix rejects an access token (401/403). Callers can catch
|
|
11
|
+
// this specifically to drive interactive re-auth without parsing messages.
|
|
12
|
+
export class MatrixAuthError extends Error {
|
|
13
|
+
status: number;
|
|
14
|
+
constructor(status: number, message: string) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'MatrixAuthError';
|
|
17
|
+
this.status = status;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
10
21
|
interface MatrixLoginResponse {
|
|
11
22
|
access_token: string;
|
|
12
23
|
device_id: string;
|
|
@@ -69,6 +80,12 @@ async function getOpenIdToken(
|
|
|
69
80
|
|
|
70
81
|
if (!response.ok) {
|
|
71
82
|
let text = await response.text();
|
|
83
|
+
if (response.status === 401 || response.status === 403) {
|
|
84
|
+
throw new MatrixAuthError(
|
|
85
|
+
response.status,
|
|
86
|
+
`OpenID token request failed: ${response.status} ${text}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
72
89
|
throw new Error(`OpenID token request failed: ${response.status} ${text}`);
|
|
73
90
|
}
|
|
74
91
|
|
|
@@ -138,17 +155,30 @@ function userRealmsAccountDataUrl(matrixAuth: MatrixAuth): string {
|
|
|
138
155
|
export async function getUserRealmsFromMatrixAccountData(
|
|
139
156
|
matrixAuth: MatrixAuth,
|
|
140
157
|
): Promise<string[]> {
|
|
158
|
+
let response: Response;
|
|
141
159
|
try {
|
|
142
|
-
|
|
160
|
+
response = await fetch(userRealmsAccountDataUrl(matrixAuth), {
|
|
143
161
|
headers: { Authorization: `Bearer ${matrixAuth.accessToken}` },
|
|
144
162
|
});
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
163
|
+
} catch {
|
|
164
|
+
// Network unreachable / DNS / similar — treat as empty (best-effort).
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
if (response.status === 401 || response.status === 403) {
|
|
168
|
+
let text = await response.text();
|
|
169
|
+
throw new MatrixAuthError(
|
|
170
|
+
response.status,
|
|
171
|
+
`Matrix account_data fetch failed: ${response.status} ${text}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (!response.ok) {
|
|
175
|
+
// 404 just means the event has never been set — return empty list.
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
148
179
|
let data = (await response.json()) as { realms?: string[] };
|
|
149
180
|
return Array.isArray(data.realms) ? [...data.realms] : [];
|
|
150
181
|
} catch {
|
|
151
|
-
// Best-effort — treat unreachable account data as an empty list
|
|
152
182
|
return [];
|
|
153
183
|
}
|
|
154
184
|
}
|
|
@@ -171,6 +201,12 @@ export async function addRealmToMatrixAccountData(
|
|
|
171
201
|
});
|
|
172
202
|
if (!putResponse.ok) {
|
|
173
203
|
let text = await putResponse.text();
|
|
204
|
+
if (putResponse.status === 401 || putResponse.status === 403) {
|
|
205
|
+
throw new MatrixAuthError(
|
|
206
|
+
putResponse.status,
|
|
207
|
+
`Failed to update Matrix account data: ${putResponse.status} ${text}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
174
210
|
throw new Error(
|
|
175
211
|
`Failed to update Matrix account data: ${putResponse.status} ${text}`,
|
|
176
212
|
);
|
|
@@ -205,6 +241,12 @@ export async function removeRealmFromMatrixAccountData(
|
|
|
205
241
|
});
|
|
206
242
|
if (!putResponse.ok) {
|
|
207
243
|
let text = await putResponse.text();
|
|
244
|
+
if (putResponse.status === 401 || putResponse.status === 403) {
|
|
245
|
+
throw new MatrixAuthError(
|
|
246
|
+
putResponse.status,
|
|
247
|
+
`Failed to update Matrix account data: ${putResponse.status} ${text}`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
208
250
|
throw new Error(
|
|
209
251
|
`Failed to update Matrix account data: ${putResponse.status} ${text}`,
|
|
210
252
|
);
|