@cardstack/boxel-cli 0.0.1 → 0.1.0
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/README.md +124 -0
- package/api.ts +3 -0
- package/bin/boxel.js +15 -0
- package/dist/index.js +107 -66
- package/package.json +31 -24
- package/src/commands/file/delete.ts +110 -0
- package/src/commands/file/index.ts +20 -0
- package/src/commands/file/lint.ts +235 -0
- package/src/commands/file/list.ts +121 -0
- package/src/commands/file/read.ts +113 -0
- package/src/commands/file/touch.ts +222 -0
- package/src/commands/file/write.ts +152 -0
- package/src/commands/profile.ts +199 -106
- package/src/commands/read-transpiled.ts +120 -0
- package/src/commands/realm/cancel-indexing.ts +113 -0
- package/src/commands/realm/create.ts +1 -4
- package/src/commands/realm/history.ts +388 -0
- package/src/commands/realm/index.ts +12 -0
- package/src/commands/realm/list.ts +156 -0
- package/src/commands/realm/pull.ts +51 -17
- package/src/commands/realm/push.ts +52 -16
- package/src/commands/realm/remove.ts +281 -0
- package/src/commands/realm/sync.ts +153 -60
- package/src/commands/realm/wait-for-ready.ts +120 -0
- package/src/commands/realm/watch.ts +626 -0
- package/src/commands/run-command.ts +4 -3
- package/src/commands/search.ts +160 -0
- package/src/index.ts +60 -2
- package/src/lib/auth-resolver.ts +58 -0
- package/src/lib/auth.ts +56 -12
- package/src/lib/boxel-cli-client.ts +135 -279
- package/src/lib/cli-log.ts +132 -0
- package/src/lib/colors.ts +14 -9
- package/src/lib/find-checkpoint.ts +65 -0
- package/src/lib/profile-manager.ts +49 -4
- package/src/lib/prompt.ts +133 -0
- package/src/lib/realm-authenticator.ts +12 -0
- package/src/lib/realm-sync-base.ts +47 -10
- package/src/lib/seed-auth.ts +214 -0
- package/src/lib/watch-lock.ts +81 -0
- package/LICENSE +0 -21
|
@@ -8,10 +8,10 @@ import {
|
|
|
8
8
|
CheckpointManager,
|
|
9
9
|
type CheckpointChange,
|
|
10
10
|
} from '../../lib/checkpoint-manager';
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
} from '../../lib/
|
|
11
|
+
import type { ProfileManager } from '../../lib/profile-manager';
|
|
12
|
+
import type { RealmAuthenticator } from '../../lib/realm-authenticator';
|
|
13
|
+
import { resolveRealmAuthenticator } from '../../lib/auth-resolver';
|
|
14
|
+
import { resolveRealmSecretSeed } from '../../lib/prompt';
|
|
15
15
|
import {
|
|
16
16
|
type SyncManifest,
|
|
17
17
|
computeFileHash,
|
|
@@ -30,9 +30,9 @@ class RealmPusher extends RealmSyncBase {
|
|
|
30
30
|
|
|
31
31
|
constructor(
|
|
32
32
|
private pushOptions: PushOptions,
|
|
33
|
-
|
|
33
|
+
authenticator: RealmAuthenticator,
|
|
34
34
|
) {
|
|
35
|
-
super(pushOptions,
|
|
35
|
+
super(pushOptions, authenticator);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
async sync(): Promise<void> {
|
|
@@ -308,6 +308,18 @@ export interface PushCommandOptions {
|
|
|
308
308
|
dryRun?: boolean;
|
|
309
309
|
force?: boolean;
|
|
310
310
|
profileManager?: ProfileManager;
|
|
311
|
+
/**
|
|
312
|
+
* Pre-resolved realm secret seed for administrative access. When set, the
|
|
313
|
+
* CLI mints a JWT locally and skips Matrix login + /_server-session +
|
|
314
|
+
* /_realm-auth. The `--realm-secret-seed` CLI flag is resolved via
|
|
315
|
+
* `resolveRealmSecretSeed` (env var or interactive prompt) before being
|
|
316
|
+
* passed here.
|
|
317
|
+
*/
|
|
318
|
+
realmSecretSeed?: string;
|
|
319
|
+
/**
|
|
320
|
+
* @internal Test hook: supply an already-constructed authenticator.
|
|
321
|
+
*/
|
|
322
|
+
authenticator?: RealmAuthenticator;
|
|
311
323
|
}
|
|
312
324
|
|
|
313
325
|
export function registerPushCommand(realm: Command): void {
|
|
@@ -322,13 +334,30 @@ export function registerPushCommand(realm: Command): void {
|
|
|
322
334
|
.option('--delete', 'Delete remote files that do not exist locally')
|
|
323
335
|
.option('--dry-run', 'Show what would be done without making changes')
|
|
324
336
|
.option('--force', 'Upload all files, even if unchanged')
|
|
337
|
+
.option(
|
|
338
|
+
'--realm-secret-seed',
|
|
339
|
+
'Administrative auth: prompt for a realm secret seed and mint a JWT locally instead of using a Matrix profile (env: BOXEL_REALM_SECRET_SEED)',
|
|
340
|
+
)
|
|
325
341
|
.action(
|
|
326
342
|
async (
|
|
327
343
|
localDir: string,
|
|
328
344
|
realmUrl: string,
|
|
329
|
-
options: {
|
|
345
|
+
options: {
|
|
346
|
+
delete?: boolean;
|
|
347
|
+
dryRun?: boolean;
|
|
348
|
+
force?: boolean;
|
|
349
|
+
realmSecretSeed?: boolean;
|
|
350
|
+
},
|
|
330
351
|
) => {
|
|
331
|
-
await
|
|
352
|
+
const realmSecretSeed = await resolveRealmSecretSeed(
|
|
353
|
+
options.realmSecretSeed === true,
|
|
354
|
+
);
|
|
355
|
+
await pushCommand(localDir, realmUrl, {
|
|
356
|
+
delete: options.delete,
|
|
357
|
+
dryRun: options.dryRun,
|
|
358
|
+
force: options.force,
|
|
359
|
+
realmSecretSeed,
|
|
360
|
+
});
|
|
332
361
|
},
|
|
333
362
|
);
|
|
334
363
|
}
|
|
@@ -338,13 +367,20 @@ export async function pushCommand(
|
|
|
338
367
|
realmUrl: string,
|
|
339
368
|
options: PushCommandOptions,
|
|
340
369
|
): Promise<void> {
|
|
341
|
-
let
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
370
|
+
let authenticator: RealmAuthenticator;
|
|
371
|
+
if (options.authenticator) {
|
|
372
|
+
authenticator = options.authenticator;
|
|
373
|
+
} else {
|
|
374
|
+
const resolution = resolveRealmAuthenticator({
|
|
375
|
+
realmUrl,
|
|
376
|
+
realmSecretSeed: options.realmSecretSeed,
|
|
377
|
+
profileManager: options.profileManager,
|
|
378
|
+
});
|
|
379
|
+
if (!resolution.ok) {
|
|
380
|
+
console.error(`Error: ${resolution.error}`);
|
|
381
|
+
process.exit(1);
|
|
382
|
+
}
|
|
383
|
+
authenticator = resolution.authenticator;
|
|
348
384
|
}
|
|
349
385
|
|
|
350
386
|
if (!(await pathExists(localDir))) {
|
|
@@ -361,7 +397,7 @@ export async function pushCommand(
|
|
|
361
397
|
dryRun: options.dryRun,
|
|
362
398
|
force: options.force,
|
|
363
399
|
},
|
|
364
|
-
|
|
400
|
+
authenticator,
|
|
365
401
|
);
|
|
366
402
|
|
|
367
403
|
await pusher.sync();
|
|
@@ -0,0 +1,281 @@
|
|
|
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 { prompt } from '../../lib/prompt';
|
|
9
|
+
import { DIM, FG_CYAN, FG_GREEN, FG_RED, RESET } from '../../lib/colors';
|
|
10
|
+
|
|
11
|
+
export interface RemoveRealmOptions {
|
|
12
|
+
realmUrl: string;
|
|
13
|
+
dryRun?: boolean;
|
|
14
|
+
profileManager?: ProfileManager;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RemoveRealmResult {
|
|
18
|
+
/** Normalized URL the operation targeted (always trailing-slashed). */
|
|
19
|
+
realmUrl: string;
|
|
20
|
+
/** True only when both server delete and Matrix unlink completed. */
|
|
21
|
+
removed: boolean;
|
|
22
|
+
/** True when DELETE /_delete-realm returned 204. */
|
|
23
|
+
serverDeleted: boolean;
|
|
24
|
+
/** True when Matrix `app.boxel.realms` was rewritten without the URL. */
|
|
25
|
+
unlinked: boolean;
|
|
26
|
+
/** Number of entries before the change. */
|
|
27
|
+
previousCount: number;
|
|
28
|
+
/** Number of entries the next list would contain (computed even on dry-run). */
|
|
29
|
+
nextCount: number;
|
|
30
|
+
/**
|
|
31
|
+
* True when the URL was not present in `app.boxel.realms`. Mutually
|
|
32
|
+
* exclusive with a successful real removal.
|
|
33
|
+
*/
|
|
34
|
+
notInList?: boolean;
|
|
35
|
+
error?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Remove a realm: delete server-side files / index / registry via
|
|
40
|
+
* `DELETE /_delete-realm`, then unlink the URL from the active profile's
|
|
41
|
+
* `app.boxel.realms` Matrix account_data list. Mirrors the host UI's
|
|
42
|
+
* workspace delete flow and inverts `boxel realm create`.
|
|
43
|
+
*
|
|
44
|
+
* Programmatic API. Returns a result object on every code path; never
|
|
45
|
+
* prompts and never calls `process.exit`. The CLI wraps this with a TTY
|
|
46
|
+
* confirmation step (see `registerRemoveCommand`).
|
|
47
|
+
*/
|
|
48
|
+
export async function removeRealm(
|
|
49
|
+
options: RemoveRealmOptions,
|
|
50
|
+
): Promise<RemoveRealmResult> {
|
|
51
|
+
let realmUrl = ensureTrailingSlash(options.realmUrl.trim());
|
|
52
|
+
let pm = options.profileManager ?? getProfileManager();
|
|
53
|
+
let active = pm.getActiveProfile();
|
|
54
|
+
if (!active) {
|
|
55
|
+
return {
|
|
56
|
+
realmUrl,
|
|
57
|
+
removed: false,
|
|
58
|
+
serverDeleted: false,
|
|
59
|
+
unlinked: false,
|
|
60
|
+
previousCount: 0,
|
|
61
|
+
nextCount: 0,
|
|
62
|
+
error: NO_ACTIVE_PROFILE_ERROR,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let existing: string[];
|
|
67
|
+
try {
|
|
68
|
+
existing = await pm.getUserRealms();
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return {
|
|
71
|
+
realmUrl,
|
|
72
|
+
removed: false,
|
|
73
|
+
serverDeleted: false,
|
|
74
|
+
unlinked: false,
|
|
75
|
+
previousCount: 0,
|
|
76
|
+
nextCount: 0,
|
|
77
|
+
error: `Failed to load realm list: ${
|
|
78
|
+
err instanceof Error ? err.message : String(err)
|
|
79
|
+
}`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
let normalized = existing.map(ensureTrailingSlash);
|
|
83
|
+
let previousCount = normalized.length;
|
|
84
|
+
let matchCount = normalized.filter((u) => u === realmUrl).length;
|
|
85
|
+
|
|
86
|
+
if (matchCount === 0) {
|
|
87
|
+
return {
|
|
88
|
+
realmUrl,
|
|
89
|
+
removed: false,
|
|
90
|
+
serverDeleted: false,
|
|
91
|
+
unlinked: false,
|
|
92
|
+
previousCount,
|
|
93
|
+
nextCount: previousCount,
|
|
94
|
+
notInList: true,
|
|
95
|
+
error: 'Realm is not in app.boxel.realms. Nothing to remove.',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let nextCount = previousCount - matchCount;
|
|
100
|
+
|
|
101
|
+
if (options.dryRun) {
|
|
102
|
+
return {
|
|
103
|
+
realmUrl,
|
|
104
|
+
removed: false,
|
|
105
|
+
serverDeleted: false,
|
|
106
|
+
unlinked: false,
|
|
107
|
+
previousCount,
|
|
108
|
+
nextCount,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, '');
|
|
113
|
+
let response: Response;
|
|
114
|
+
try {
|
|
115
|
+
response = await pm.authedRealmServerFetch(
|
|
116
|
+
`${realmServerUrl}/_delete-realm`,
|
|
117
|
+
{
|
|
118
|
+
method: 'DELETE',
|
|
119
|
+
headers: { 'Content-Type': 'application/vnd.api+json' },
|
|
120
|
+
body: JSON.stringify({
|
|
121
|
+
data: { type: 'realm', id: realmUrl },
|
|
122
|
+
}),
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
return {
|
|
127
|
+
realmUrl,
|
|
128
|
+
removed: false,
|
|
129
|
+
serverDeleted: false,
|
|
130
|
+
unlinked: false,
|
|
131
|
+
previousCount,
|
|
132
|
+
nextCount: previousCount,
|
|
133
|
+
error: `Failed to reach realm server: ${
|
|
134
|
+
err instanceof Error ? err.message : String(err)
|
|
135
|
+
}`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
let body = await safeReadResponseText(response);
|
|
141
|
+
let error =
|
|
142
|
+
response.status === 403
|
|
143
|
+
? `You do not own this realm and cannot delete it on the server. Server returned 403: ${body}`
|
|
144
|
+
: `Realm server returned ${response.status}: ${body}`;
|
|
145
|
+
return {
|
|
146
|
+
realmUrl,
|
|
147
|
+
removed: false,
|
|
148
|
+
serverDeleted: false,
|
|
149
|
+
unlinked: false,
|
|
150
|
+
previousCount,
|
|
151
|
+
nextCount: previousCount,
|
|
152
|
+
error,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let unlinked: boolean;
|
|
157
|
+
try {
|
|
158
|
+
unlinked = await pm.removeFromUserRealms(realmUrl);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
return {
|
|
161
|
+
realmUrl,
|
|
162
|
+
removed: false,
|
|
163
|
+
serverDeleted: true,
|
|
164
|
+
unlinked: false,
|
|
165
|
+
previousCount,
|
|
166
|
+
nextCount: previousCount,
|
|
167
|
+
error: `Server delete succeeded, but Matrix unlink failed: ${
|
|
168
|
+
err instanceof Error ? err.message : String(err)
|
|
169
|
+
}`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!unlinked) {
|
|
174
|
+
return {
|
|
175
|
+
realmUrl,
|
|
176
|
+
removed: false,
|
|
177
|
+
serverDeleted: true,
|
|
178
|
+
unlinked: false,
|
|
179
|
+
previousCount,
|
|
180
|
+
nextCount: previousCount,
|
|
181
|
+
error:
|
|
182
|
+
'Server delete succeeded, but Matrix account_data did not contain the URL by the time we PUT (concurrent edit?). Server-side files are gone; please refresh and check your realm list.',
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
realmUrl,
|
|
188
|
+
removed: true,
|
|
189
|
+
serverDeleted: true,
|
|
190
|
+
unlinked,
|
|
191
|
+
previousCount,
|
|
192
|
+
nextCount,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function safeReadResponseText(response: Response): Promise<string> {
|
|
197
|
+
try {
|
|
198
|
+
return await response.text();
|
|
199
|
+
} catch {
|
|
200
|
+
return '<no response body>';
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface RemoveCliOptions {
|
|
205
|
+
yes?: boolean;
|
|
206
|
+
dryRun?: boolean;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function registerRemoveCommand(realm: Command): void {
|
|
210
|
+
realm
|
|
211
|
+
.command('remove')
|
|
212
|
+
.description(
|
|
213
|
+
'Remove a realm — deletes server-side files and unlinks it from your realm list',
|
|
214
|
+
)
|
|
215
|
+
.argument('<realm-url>', 'realm URL to remove')
|
|
216
|
+
.option('-y, --yes', 'Skip the interactive confirmation prompt')
|
|
217
|
+
.option('--dry-run', 'Preview the change without writing to Matrix')
|
|
218
|
+
.action(async (realmUrlInput: string, opts: RemoveCliOptions) => {
|
|
219
|
+
let normalized = ensureTrailingSlash(realmUrlInput.trim());
|
|
220
|
+
|
|
221
|
+
let preview = await removeRealm({
|
|
222
|
+
realmUrl: normalized,
|
|
223
|
+
dryRun: true,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
if (preview.error && !preview.notInList) {
|
|
227
|
+
console.error(`${FG_RED}Error:${RESET} ${preview.error}`);
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (preview.notInList) {
|
|
232
|
+
console.error(`${FG_RED}Error:${RESET} ${preview.error}`);
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
console.log(`Remove target: ${FG_CYAN}${preview.realmUrl}${RESET}`);
|
|
237
|
+
console.log(
|
|
238
|
+
`${DIM}app.boxel.realms: ${preview.previousCount} -> ${preview.nextCount}${RESET}`,
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
if (opts.dryRun) {
|
|
242
|
+
console.log(
|
|
243
|
+
`${DIM}[DRY RUN] No server delete or Matrix changes sent.${RESET}`,
|
|
244
|
+
);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!opts.yes) {
|
|
249
|
+
if (!process.stdin.isTTY) {
|
|
250
|
+
console.error(
|
|
251
|
+
`${FG_RED}Error:${RESET} stdin is not a TTY. Pass --yes to confirm in non-interactive mode.`,
|
|
252
|
+
);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
let answer = await prompt(
|
|
256
|
+
'This will permanently delete the realm files, indexer state, and registry entry on the server. Proceed? (y/N) ',
|
|
257
|
+
);
|
|
258
|
+
if (!/^y/i.test(answer)) {
|
|
259
|
+
console.log(`${DIM}Cancelled.${RESET}`);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let result = await removeRealm({ realmUrl: normalized });
|
|
265
|
+
if (result.error || !result.removed) {
|
|
266
|
+
console.error(
|
|
267
|
+
`${FG_RED}Error:${RESET} ${result.error ?? 'Removal did not complete.'}`,
|
|
268
|
+
);
|
|
269
|
+
if (result.serverDeleted && !result.unlinked) {
|
|
270
|
+
console.error(
|
|
271
|
+
`${DIM}The realm is gone, but your account_data still references ${result.realmUrl}.${RESET}`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
process.exit(1);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
console.log(
|
|
278
|
+
`${FG_GREEN}Removed:${RESET} ${FG_CYAN}${result.realmUrl}${RESET}`,
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
}
|